From 9beb9349d7621c229521aeb7db8a38693bfd99ac Mon Sep 17 00:00:00 2001 From: Titus Wormer Date: Wed, 22 Jan 2025 14:22:10 +0100 Subject: [PATCH 01/15] Update dev-dependencies --- lib/index.js | 16 ++++++---------- package.json | 17 ++++++++++------- readme.md | 50 +++++++++++++++++++++++++------------------------- 3 files changed, 41 insertions(+), 42 deletions(-) diff --git a/lib/index.js b/lib/index.js index d68f9be..0976bc6 100644 --- a/lib/index.js +++ b/lib/index.js @@ -308,10 +308,6 @@ function handleDirective(node, _, state, info) { const subexit = state.enter(labelType) value += tracker.move('[') value += tracker.move( - // @ts-expect-error: `containerPhrasing` is typed correctly, but TS - // generates *hardcoded* types, which means that our dynamically added - // directives are not present. - // At some point, TS should fix that, and `from-markdown` should be fine. state.containerPhrasing(label, { ...tracker.current(), before: value, @@ -358,7 +354,7 @@ function peekDirective() { function attributes(node, state) { const quote = state.options.quote || '"' const subset = node.type === 'textDirective' ? [quote] : [quote, '\n', '\r'] - const attrs = node.attributes || {} + const attributes = node.attributes || {} /** @type {Array} */ const values = [] /** @type {string | undefined} */ @@ -370,13 +366,13 @@ function attributes(node, state) { /** @type {string} */ let key - for (key in attrs) { + for (key in attributes) { if ( - own.call(attrs, key) && - attrs[key] !== undefined && - attrs[key] !== null + own.call(attributes, key) && + attributes[key] !== undefined && + attributes[key] !== null ) { - const value = String(attrs[key]) + const value = String(attributes[key]) if (key === 'id') { id = shortcut.test(value) ? '#' + value : quoted('id', value) diff --git a/package.json b/package.json index ac95540..bf3b871 100644 --- a/package.json +++ b/package.json @@ -45,16 +45,16 @@ "unist-util-visit-parents": "^6.0.0" }, "devDependencies": { - "@types/node": "^20.0.0", - "c8": "^8.0.0", + "@types/node": "^22.0.0", + "c8": "^10.0.0", "micromark-extension-directive": "^3.0.0", - "prettier": "^2.0.0", - "remark-cli": "^11.0.0", - "remark-preset-wooorm": "^9.0.0", + "prettier": "^3.0.0", + "remark-cli": "^12.0.0", + "remark-preset-wooorm": "^10.0.0", "type-coverage": "^2.0.0", "typescript": "^5.0.0", "unist-util-remove-position": "^5.0.0", - "xo": "^0.54.0" + "xo": "^0.60.0" }, "scripts": { "prepack": "npm run build && npm run format", @@ -97,6 +97,9 @@ } } ], - "prettier": true + "prettier": true, + "rules": { + "unicorn/prefer-at": "off" + } } } diff --git a/readme.md b/readme.md index daa7937..59cdd99 100644 --- a/readme.md +++ b/readme.md @@ -14,27 +14,27 @@ such). ## Contents -* [What is this?](#what-is-this) -* [When to use this](#when-to-use-this) -* [Install](#install) -* [Use](#use) -* [API](#api) - * [`directiveFromMarkdown()`](#directivefrommarkdown) - * [`directiveToMarkdown()`](#directivetomarkdown) - * [`ContainerDirective`](#containerdirective) - * [`Directives`](#directives) - * [`LeafDirective`](#leafdirective) - * [`TextDirective`](#textdirective) -* [HTML](#html) -* [Syntax](#syntax) -* [Syntax tree](#syntax-tree) - * [Nodes](#nodes) - * [Mixin](#mixin) -* [Types](#types) -* [Compatibility](#compatibility) -* [Related](#related) -* [Contribute](#contribute) -* [License](#license) +* [What is this?](#what-is-this) +* [When to use this](#when-to-use-this) +* [Install](#install) +* [Use](#use) +* [API](#api) + * [`directiveFromMarkdown()`](#directivefrommarkdown) + * [`directiveToMarkdown()`](#directivetomarkdown) + * [`ContainerDirective`](#containerdirective) + * [`Directives`](#directives) + * [`LeafDirective`](#leafdirective) + * [`TextDirective`](#textdirective) +* [HTML](#html) +* [Syntax](#syntax) +* [Syntax tree](#syntax-tree) + * [Nodes](#nodes) + * [Mixin](#mixin) +* [Types](#types) +* [Compatibility](#compatibility) +* [Related](#related) +* [Contribute](#contribute) +* [License](#license) ## What is this? @@ -457,10 +457,10 @@ This utility works with `mdast-util-from-markdown` version 2+ and ## Related -* [`remarkjs/remark-directive`][remark-directive] - — remark plugin to support generic directives -* [`micromark/micromark-extension-directive`][extension] - — micromark extension to parse directives +* [`remarkjs/remark-directive`][remark-directive] + — remark plugin to support generic directives +* [`micromark/micromark-extension-directive`][extension] + — micromark extension to parse directives ## Contribute From 7f3c69e5f1f01860deb7cf60cc9ac7ae5c063994 Mon Sep 17 00:00:00 2001 From: Titus Wormer Date: Wed, 22 Jan 2025 14:22:41 +0100 Subject: [PATCH 02/15] Update Actions --- .github/workflows/bb.yml | 12 ++++++------ .github/workflows/main.yml | 16 ++++++++-------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/workflows/bb.yml b/.github/workflows/bb.yml index 0198fc3..3dbfce5 100644 --- a/.github/workflows/bb.yml +++ b/.github/workflows/bb.yml @@ -1,9 +1,3 @@ -name: bb -on: - issues: - types: [opened, reopened, edited, closed, labeled, unlabeled] - pull_request_target: - types: [opened, reopened, edited, closed, labeled, unlabeled] jobs: main: runs-on: ubuntu-latest @@ -11,3 +5,9 @@ jobs: - uses: unifiedjs/beep-boop-beta@main with: repo-token: ${{secrets.GITHUB_TOKEN}} +name: bb +on: + issues: + types: [closed, edited, labeled, opened, reopened, unlabeled] + pull_request_target: + types: [closed, edited, labeled, opened, reopened, unlabeled] diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index fb63387..ade3921 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,21 +1,21 @@ -name: main -on: - - pull_request - - push jobs: main: name: ${{matrix.node}} runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: node-version: ${{matrix.node}} - run: npm install - run: npm test - - uses: codecov/codecov-action@v3 + - uses: codecov/codecov-action@v5 strategy: matrix: node: - - lts/gallium + - lts/hydrogen - node +name: main +on: + - pull_request + - push From ec3152b53a5f6a1b17e72166798eb66969d01060 Mon Sep 17 00:00:00 2001 From: Titus Wormer Date: Wed, 22 Jan 2025 14:23:01 +0100 Subject: [PATCH 03/15] Add `.tsbuildinfo` to `.gitignore` --- .gitignore | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index fcb2607..3f5bcd4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,8 @@ -coverage/ -node_modules/ -.DS_Store *.d.ts *.log +*.tsbuildinfo +.DS_Store +coverage/ +node_modules/ yarn.lock !/index.d.ts From 961877106a36a6afda32936e819b21ebcc2a3176 Mon Sep 17 00:00:00 2001 From: Titus Wormer Date: Wed, 22 Jan 2025 14:23:14 +0100 Subject: [PATCH 04/15] Refactor `.editorconfig` --- .editorconfig | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.editorconfig b/.editorconfig index c6c8b36..0f17867 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,9 +1,9 @@ root = true [*] -indent_style = space -indent_size = 2 -end_of_line = lf charset = utf-8 -trim_trailing_whitespace = true +end_of_line = lf +indent_size = 2 +indent_style = space insert_final_newline = true +trim_trailing_whitespace = true From fa89fde6fd6ed8dd5725b331c9413e58d8494f4c Mon Sep 17 00:00:00 2001 From: Titus Wormer Date: Wed, 22 Jan 2025 14:26:24 +0100 Subject: [PATCH 05/15] Refactor code-style --- index.d.ts | 16 ++++++++-------- lib/index.js | 20 ++++++++------------ 2 files changed, 16 insertions(+), 20 deletions(-) diff --git a/index.d.ts b/index.d.ts index 5ee7327..228f64b 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,8 +1,8 @@ import type { - Data, - Parent, BlockContent, + Data, DefinitionContent, + Parent, PhrasingContent } from 'mdast' @@ -13,20 +13,20 @@ export {directiveFromMarkdown, directiveToMarkdown} from './lib/index.js' */ interface DirectiveFields { /** - * Directive name. + * Directive attributes. */ - name: string + attributes?: Record | null | undefined /** - * Directive attributes. + * Directive name. */ - attributes?: Record | null | undefined + name: string } /** * Markdown directive (container form). */ -export interface ContainerDirective extends Parent, DirectiveFields { +export interface ContainerDirective extends DirectiveFields, Parent { /** * Node type of container directive. */ @@ -76,7 +76,7 @@ export interface LeafDirectiveData extends Data {} /** * Markdown directive (text form). */ -export interface TextDirective extends Parent, DirectiveFields { +export interface TextDirective extends DirectiveFields, Parent { /** * Node type of text directive. */ diff --git a/lib/index.js b/lib/index.js index 0976bc6..3a414eb 100644 --- a/lib/index.js +++ b/lib/index.js @@ -87,6 +87,11 @@ export function directiveFromMarkdown() { */ export function directiveToMarkdown() { return { + handlers: { + containerDirective: handleDirective, + leafDirective: handleDirective, + textDirective: handleDirective + }, unsafe: [ { character: '\r', @@ -103,12 +108,7 @@ export function directiveToMarkdown() { inConstruct: ['phrasing'] }, {atBreak: true, character: ':', after: ':'} - ], - handlers: { - containerDirective: handleDirective, - leafDirective: handleDirective, - textDirective: handleDirective - } + ] } } @@ -196,9 +196,7 @@ function exitAttributeIdValue(token) { assert(list, 'expected `directiveAttributes`') list.push([ 'id', - parseEntities(this.sliceSerialize(token), { - attribute: true - }) + parseEntities(this.sliceSerialize(token), {attribute: true}) ]) } @@ -211,9 +209,7 @@ function exitAttributeClassValue(token) { assert(list, 'expected `directiveAttributes`') list.push([ 'class', - parseEntities(this.sliceSerialize(token), { - attribute: true - }) + parseEntities(this.sliceSerialize(token), {attribute: true}) ]) } From 3a406c3d99b67dae157e1095c3103be525a7330e Mon Sep 17 00:00:00 2001 From: Titus Wormer Date: Wed, 22 Jan 2025 14:26:33 +0100 Subject: [PATCH 06/15] Remove license year --- license | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/license b/license index 3937235..bc8f165 100644 --- a/license +++ b/license @@ -1,6 +1,6 @@ (The MIT License) -Copyright (c) 2020 Titus Wormer +Copyright (c) Titus Wormer Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the From 1268a7c69371a8fa04e996a1e54235c06b6fd297 Mon Sep 17 00:00:00 2001 From: Titus Wormer Date: Wed, 22 Jan 2025 14:29:47 +0100 Subject: [PATCH 07/15] Refactor `package.json` --- index.d.ts | 4 +- package.json | 101 ++++++++++++++++++++++++++++----------------------- 2 files changed, 58 insertions(+), 47 deletions(-) diff --git a/index.d.ts b/index.d.ts index 228f64b..5fdd7c1 100644 --- a/index.d.ts +++ b/index.d.ts @@ -60,7 +60,7 @@ export interface LeafDirective extends Parent, DirectiveFields { /** * Children of leaf directive. */ - children: PhrasingContent[] + children: Array /** * Data associated with the mdast leaf directive. @@ -85,7 +85,7 @@ export interface TextDirective extends DirectiveFields, Parent { /** * Children of text directive. */ - children: PhrasingContent[] + children: Array /** * Data associated with the text leaf directive. diff --git a/package.json b/package.json index bf3b871..2335f09 100644 --- a/package.json +++ b/package.json @@ -1,39 +1,9 @@ { - "name": "mdast-util-directive", - "version": "3.0.0", - "description": "mdast extension to parse and serialize generic directives (`:cite[smith04]`)", - "license": "MIT", - "keywords": [ - "unist", - "mdast", - "mdast-util", - "util", - "utility", - "markdown", - "markup", - "generic", - "directive", - "container", - "extension" - ], - "repository": "syntax-tree/mdast-util-directive", - "bugs": "https://github.com/syntax-tree/mdast-util-directive/issues", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - }, "author": "Titus Wormer (https://wooorm.com)", + "bugs": "https://github.com/syntax-tree/mdast-util-directive/issues", "contributors": [ "Titus Wormer (https://wooorm.com)" ], - "sideEffects": false, - "type": "module", - "exports": "./index.js", - "files": [ - "lib/", - "index.d.ts", - "index.js" - ], "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", @@ -44,6 +14,7 @@ "stringify-entities": "^4.0.0", "unist-util-visit-parents": "^6.0.0" }, + "description": "mdast extension to parse and serialize generic directives (`:cite[smith04]`)", "devDependencies": { "@types/node": "^22.0.0", "c8": "^10.0.0", @@ -56,16 +27,31 @@ "unist-util-remove-position": "^5.0.0", "xo": "^0.60.0" }, - "scripts": { - "prepack": "npm run build && npm run format", - "build": "tsc --build --clean && tsc --build && type-coverage", - "format": "remark . -qfo && prettier . -w --loglevel warn && xo --fix", - "test-api-prod": "node --conditions production test.js", - "test-api-dev": "node --conditions development test.js", - "test-api": "npm run test-api-dev && npm run test-api-prod", - "test-coverage": "c8 --100 --reporter lcov npm run test-api", - "test": "npm run build && npm run format && npm run test-coverage" + "exports": "./index.js", + "files": [ + "index.d.ts", + "index.js", + "lib/" + ], + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" }, + "keywords": [ + "container", + "directive", + "extension", + "generic", + "markdown", + "markup", + "mdast-util", + "mdast", + "unist", + "utility", + "util" + ], + "license": "MIT", + "name": "mdast-util-directive", "prettier": { "bracketSpacing": false, "semi": false, @@ -79,21 +65,46 @@ "remark-preset-wooorm" ] }, + "repository": "syntax-tree/mdast-util-directive", + "scripts": { + "build": "tsc --build --clean && tsc --build && type-coverage", + "format": "remark --frail --output --quiet -- . && prettier --log-level warn --write -- . && xo --fix", + "test-api-dev": "node --conditions development test.js", + "test-api-prod": "node --conditions production test.js", + "test-api": "npm run test-api-dev && npm run test-api-prod", + "test-coverage": "c8 --100 --reporter lcov -- npm run test-api", + "test": "npm run build && npm run format && npm run test-coverage" + }, + "sideEffects": false, "typeCoverage": { "atLeast": 100, - "detail": true, - "ignoreCatch": true, "strict": true }, + "type": "module", + "version": "3.0.0", "xo": { "overrides": [ { "files": [ - "**/*.ts" + "**/*.d.ts" ], "rules": { - "@typescript-eslint/ban-types": "off", - "@typescript-eslint/consistent-type-definitions": "off" + "@typescript-eslint/array-type": [ + "error", + { + "default": "generic" + } + ], + "@typescript-eslint/ban-types": [ + "error", + { + "extendDefaults": true + } + ], + "@typescript-eslint/consistent-type-definitions": [ + "error", + "interface" + ] } } ], From ce46103815847d8e1cecce9d0e6ec5a00acbbb81 Mon Sep 17 00:00:00 2001 From: Titus Wormer Date: Wed, 22 Jan 2025 14:30:14 +0100 Subject: [PATCH 08/15] Add declaration maps --- .gitignore | 1 + tsconfig.json | 1 + 2 files changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 3f5bcd4..8123c9e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ *.d.ts *.log +*.map *.tsbuildinfo .DS_Store coverage/ diff --git a/tsconfig.json b/tsconfig.json index bed2bb4..c3e0c82 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,6 +2,7 @@ "compilerOptions": { "checkJs": true, "customConditions": ["development"], + "declarationMap": true, "declaration": true, "emitDeclarationOnly": true, "exactOptionalPropertyTypes": true, From f952db142f1552e627ca05e1bfe9e0f4c6ff0e11 Mon Sep 17 00:00:00 2001 From: Titus Wormer Date: Wed, 22 Jan 2025 14:33:16 +0100 Subject: [PATCH 09/15] Refactor to use `@import`s --- lib/index.js | 30 ++++++++++++++---------------- readme.md | 5 +++-- 2 files changed, 17 insertions(+), 18 deletions(-) diff --git a/lib/index.js b/lib/index.js index 3a414eb..1b0bcad 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,20 +1,18 @@ /** - * @typedef {import('mdast').Nodes} Nodes - * @typedef {import('mdast').Paragraph} Paragraph - * - * @typedef {import('mdast-util-from-markdown').CompileContext} CompileContext - * @typedef {import('mdast-util-from-markdown').Extension} FromMarkdownExtension - * @typedef {import('mdast-util-from-markdown').Handle} FromMarkdownHandle - * @typedef {import('mdast-util-from-markdown').Token} Token - * - * @typedef {import('mdast-util-to-markdown').ConstructName} ConstructName - * @typedef {import('mdast-util-to-markdown').Handle} ToMarkdownHandle - * @typedef {import('mdast-util-to-markdown').Options} ToMarkdownExtension - * @typedef {import('mdast-util-to-markdown').State} State - * - * @typedef {import('../index.js').Directives} Directives - * @typedef {import('../index.js').LeafDirective} LeafDirective - * @typedef {import('../index.js').TextDirective} TextDirective + * @import {Directives, LeafDirective, TextDirective} from 'mdast-util-directive' + * @import { + * CompileContext, + * Extension as FromMarkdownExtension, + * Handle as FromMarkdownHandle, + * Token + * } from 'mdast-util-from-markdown' + * @import { + * ConstructName, + * Handle as ToMarkdownHandle, + * Options as ToMarkdownExtension, + * State + * } from 'mdast-util-to-markdown' + * @import {Nodes, Paragraph} from 'mdast' */ import {ok as assert} from 'devlop' diff --git a/readme.md b/readme.md index 59cdd99..3c116da 100644 --- a/readme.md +++ b/readme.md @@ -429,12 +429,13 @@ somewhere in your types, as that registers the new node types in the tree. ```js /** - * @typedef {import('mdast-util-directive')} + * @import {} from 'mdast-util-directive' + * @import {Root} from 'mdast' */ import {visit} from 'unist-util-visit' -/** @type {import('mdast').Root} */ +/** @type {Root} */ const tree = getMdastNodeSomeHow() visit(tree, function (node) { From 77a185bf06b4b59f472ceafb52fcf1492caa5a86 Mon Sep 17 00:00:00 2001 From: Titus Wormer Date: Wed, 22 Jan 2025 14:55:26 +0100 Subject: [PATCH 10/15] Add `quote` option Related-to: GH-12. --- index.d.ts | 11 ++ lib/index.js | 308 +++++++++++++++++++++++++++------------------------ readme.md | 27 ++++- test.js | 38 +++++++ 4 files changed, 232 insertions(+), 152 deletions(-) diff --git a/index.d.ts b/index.d.ts index 5fdd7c1..dffc597 100644 --- a/index.d.ts +++ b/index.d.ts @@ -8,6 +8,17 @@ import type { export {directiveFromMarkdown, directiveToMarkdown} from './lib/index.js' +/** + * Configuration. + */ +export interface ToMarkdownOptions { + /** + * Preferred quote to use around attribute values + * (default: the `quote` used by `mdast-util-to-markdown` for titles). + */ + quote?: '"' | "'" | null | undefined +} + /** * Fields shared by directives. */ diff --git a/lib/index.js b/lib/index.js index 1b0bcad..7192234 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,5 +1,5 @@ /** - * @import {Directives, LeafDirective, TextDirective} from 'mdast-util-directive' + * @import {Directives, LeafDirective, TextDirective, ToMarkdownOptions} from 'mdast-util-directive' * @import { * CompileContext, * Extension as FromMarkdownExtension, @@ -22,9 +22,10 @@ import {visitParents} from 'unist-util-visit-parents' const own = {}.hasOwnProperty -const shortcut = /^[^\t\n\r "#'.<=>`}]+$/ +/** @type {Readonly} */ +const emptyOptions = {} -handleDirective.peek = peekDirective +const shortcut = /^[^\t\n\r "#'.<=>`}]+$/ /** * Create an extension for `mdast-util-from-markdown` to enable directives in @@ -80,10 +81,16 @@ export function directiveFromMarkdown() { * Create an extension for `mdast-util-to-markdown` to enable directives in * markdown. * + * @param {Readonly | null | undefined} [options] + * Configuration (optional). * @returns {ToMarkdownExtension} * Extension for `mdast-util-to-markdown` to enable directives. */ -export function directiveToMarkdown() { +export function directiveToMarkdown(options) { + const settings = options || emptyOptions + + handleDirective.peek = peekDirective + return { handlers: { containerDirective: handleDirective, @@ -108,6 +115,156 @@ export function directiveToMarkdown() { {atBreak: true, character: ':', after: ':'} ] } + + /** + * @type {ToMarkdownHandle} + * @param {Directives} node + */ + function handleDirective(node, _, state, info) { + const tracker = state.createTracker(info) + const sequence = fence(node) + const exit = state.enter(node.type) + let value = tracker.move(sequence + (node.name || '')) + /** @type {LeafDirective | Paragraph | TextDirective | undefined} */ + let label + + if (node.type === 'containerDirective') { + const head = (node.children || [])[0] + label = inlineDirectiveLabel(head) ? head : undefined + } else { + label = node + } + + if (label && label.children && label.children.length > 0) { + const exit = state.enter('label') + /** @type {ConstructName} */ + const labelType = `${node.type}Label` + const subexit = state.enter(labelType) + value += tracker.move('[') + value += tracker.move( + state.containerPhrasing(label, { + ...tracker.current(), + before: value, + after: ']' + }) + ) + value += tracker.move(']') + subexit() + exit() + } + + value += tracker.move(attributes(node, state)) + + if (node.type === 'containerDirective') { + const head = (node.children || [])[0] + let shallow = node + + if (inlineDirectiveLabel(head)) { + shallow = Object.assign({}, node, {children: node.children.slice(1)}) + } + + if (shallow && shallow.children && shallow.children.length > 0) { + value += tracker.move('\n') + value += tracker.move(state.containerFlow(shallow, tracker.current())) + } + + value += tracker.move('\n' + sequence) + } + + exit() + return value + } + + /** + * @param {Directives} node + * @param {State} state + * @returns {string} + */ + function attributes(node, state) { + // If the alternative is less common than `quote`, switch. + const appliedQuote = settings.quote || state.options.quote || '"' + const subset = + node.type === 'textDirective' + ? [appliedQuote] + : [appliedQuote, '\n', '\r'] + const attributes = node.attributes || {} + /** @type {Array} */ + const values = [] + /** @type {string | undefined} */ + let classesFull + /** @type {string | undefined} */ + let classes + /** @type {string | undefined} */ + let id + /** @type {string} */ + let key + + for (key in attributes) { + if ( + own.call(attributes, key) && + attributes[key] !== undefined && + attributes[key] !== null + ) { + const value = String(attributes[key]) + + if (key === 'id') { + id = shortcut.test(value) ? '#' + value : quoted('id', value) + } else if (key === 'class') { + const list = value.split(/[\t\n\r ]+/g) + /** @type {Array} */ + const classesFullList = [] + /** @type {Array} */ + const classesList = [] + let index = -1 + + while (++index < list.length) { + ;(shortcut.test(list[index]) ? classesList : classesFullList).push( + list[index] + ) + } + + classesFull = + classesFullList.length > 0 + ? quoted('class', classesFullList.join(' ')) + : '' + classes = classesList.length > 0 ? '.' + classesList.join('.') : '' + } else { + values.push(quoted(key, value)) + } + } + } + + if (classesFull) { + values.unshift(classesFull) + } + + if (classes) { + values.unshift(classes) + } + + if (id) { + values.unshift(id) + } + + return values.length > 0 ? '{' + values.join(' ') + '}' : '' + + /** + * @param {string} key + * @param {string} value + * @returns {string} + */ + function quoted(key, value) { + return ( + key + + (value + ? '=' + + appliedQuote + + stringifyEntitiesLight(value, {subset}) + + appliedQuote + : '') + ) + } + } } /** @@ -276,154 +433,11 @@ function exit(token) { this.exit(token) } -/** - * @type {ToMarkdownHandle} - * @param {Directives} node - */ -function handleDirective(node, _, state, info) { - const tracker = state.createTracker(info) - const sequence = fence(node) - const exit = state.enter(node.type) - let value = tracker.move(sequence + (node.name || '')) - /** @type {LeafDirective | Paragraph | TextDirective | undefined} */ - let label - - if (node.type === 'containerDirective') { - const head = (node.children || [])[0] - label = inlineDirectiveLabel(head) ? head : undefined - } else { - label = node - } - - if (label && label.children && label.children.length > 0) { - const exit = state.enter('label') - /** @type {ConstructName} */ - const labelType = `${node.type}Label` - const subexit = state.enter(labelType) - value += tracker.move('[') - value += tracker.move( - state.containerPhrasing(label, { - ...tracker.current(), - before: value, - after: ']' - }) - ) - value += tracker.move(']') - subexit() - exit() - } - - value += tracker.move(attributes(node, state)) - - if (node.type === 'containerDirective') { - const head = (node.children || [])[0] - let shallow = node - - if (inlineDirectiveLabel(head)) { - shallow = Object.assign({}, node, {children: node.children.slice(1)}) - } - - if (shallow && shallow.children && shallow.children.length > 0) { - value += tracker.move('\n') - value += tracker.move(state.containerFlow(shallow, tracker.current())) - } - - value += tracker.move('\n' + sequence) - } - - exit() - return value -} - /** @type {ToMarkdownHandle} */ function peekDirective() { return ':' } -/** - * @param {Directives} node - * @param {State} state - * @returns {string} - */ -function attributes(node, state) { - const quote = state.options.quote || '"' - const subset = node.type === 'textDirective' ? [quote] : [quote, '\n', '\r'] - const attributes = node.attributes || {} - /** @type {Array} */ - const values = [] - /** @type {string | undefined} */ - let classesFull - /** @type {string | undefined} */ - let classes - /** @type {string | undefined} */ - let id - /** @type {string} */ - let key - - for (key in attributes) { - if ( - own.call(attributes, key) && - attributes[key] !== undefined && - attributes[key] !== null - ) { - const value = String(attributes[key]) - - if (key === 'id') { - id = shortcut.test(value) ? '#' + value : quoted('id', value) - } else if (key === 'class') { - const list = value.split(/[\t\n\r ]+/g) - /** @type {Array} */ - const classesFullList = [] - /** @type {Array} */ - const classesList = [] - let index = -1 - - while (++index < list.length) { - ;(shortcut.test(list[index]) ? classesList : classesFullList).push( - list[index] - ) - } - - classesFull = - classesFullList.length > 0 - ? quoted('class', classesFullList.join(' ')) - : '' - classes = classesList.length > 0 ? '.' + classesList.join('.') : '' - } else { - values.push(quoted(key, value)) - } - } - } - - if (classesFull) { - values.unshift(classesFull) - } - - if (classes) { - values.unshift(classes) - } - - if (id) { - values.unshift(id) - } - - return values.length > 0 ? '{' + values.join(' ') + '}' : '' - - /** - * @param {string} key - * @param {string} value - * @returns {string} - */ - function quoted(key, value) { - return ( - key + - (value - ? '=' + quote + stringifyEntitiesLight(value, {subset}) + quote - : '') - ) - } -} - /** * @param {Nodes} node * @returns {node is Paragraph & {data: {directiveLabel: true}}} diff --git a/readme.md b/readme.md index 3c116da..a7071d6 100644 --- a/readme.md +++ b/readme.md @@ -20,11 +20,12 @@ such). * [Use](#use) * [API](#api) * [`directiveFromMarkdown()`](#directivefrommarkdown) - * [`directiveToMarkdown()`](#directivetomarkdown) + * [`directiveToMarkdown(options?)`](#directivetomarkdownoptions) * [`ContainerDirective`](#containerdirective) * [`Directives`](#directives) * [`LeafDirective`](#leafdirective) * [`TextDirective`](#textdirective) + * [`ToMarkdownOptions`](#tomarkdownoptions) * [HTML](#html) * [Syntax](#syntax) * [Syntax tree](#syntax-tree) @@ -177,13 +178,16 @@ to enable directives in markdown. Extension for `mdast-util-from-markdown` to enable directives ([`FromMarkdownExtension`][from-markdown-extension]). -### `directiveToMarkdown()` +### `directiveToMarkdown(options?)` Create an extension for [`mdast-util-to-markdown`][mdast-util-to-markdown] to enable directives in markdown. -There are no options, but passing [`options.quote`][quote] to -`mdast-util-to-markdown` is honored for attributes. +###### Parameters + +* `options` + ([`ToMarkdownOptions`][api-to-markdown-options], optional) + — configuration ###### Returns @@ -254,6 +258,17 @@ interface TextDirective extends Parent { } ``` +### `ToMarkdownOptions` + +Configuration. + +###### Parameters + +* `quote` + (`'"'` or `"'"`, + default: the [`quote`][quote] used by `mdast-util-to-markdown` for titles) + — preferred quote to use around attribute values + ## HTML This utility does not handle how markdown is turned to HTML. @@ -559,7 +574,9 @@ abide by its terms. [api-directive-from-markdown]: #directivefrommarkdown -[api-directive-to-markdown]: #directivetomarkdown +[api-directive-to-markdown]: #directivetomarkdownoptions + +[api-to-markdown-options]: #tomarkdownoptions [api-container-directive]: #containerdirective diff --git a/test.js b/test.js index e37e23e..ba1eb36 100644 --- a/test.js +++ b/test.js @@ -1102,4 +1102,42 @@ test('directiveToMarkdown()', async function (t) { ':red:\n' ) }) + + await t.test( + 'should quote attribute values with double quotes by default', + async function () { + assert.deepEqual( + toMarkdown( + { + type: 'textDirective', + name: 'i', + attributes: {title: 'a'}, + children: [] + }, + {extensions: [directiveToMarkdown()]} + ), + ':i{title="a"}\n' + ) + } + ) + + await t.test( + "should quote attribute values with single quotes if `quote: '\\''`", + async function () { + assert.deepEqual( + toMarkdown( + { + type: 'textDirective', + name: 'i', + attributes: {title: 'a'}, + children: [] + }, + { + extensions: [directiveToMarkdown({quote: "'"})] + } + ), + ":i{title='a'}\n" + ) + } + ) }) From 7c8d606e315217606e6e043ad3026dc4fce1bf37 Mon Sep 17 00:00:00 2001 From: Titus Wormer Date: Wed, 22 Jan 2025 15:17:23 +0100 Subject: [PATCH 11/15] Add `quoteSmart` option Related-to: GH-12. --- index.d.ts | 5 ++ lib/index.js | 73 +++++++++++++++-------- package.json | 1 + readme.md | 3 + test.js | 163 ++++++++++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 217 insertions(+), 28 deletions(-) diff --git a/index.d.ts b/index.d.ts index dffc597..dcf5b7b 100644 --- a/index.d.ts +++ b/index.d.ts @@ -12,6 +12,11 @@ export {directiveFromMarkdown, directiveToMarkdown} from './lib/index.js' * Configuration. */ export interface ToMarkdownOptions { + /** + * Use the other quote if that results in less bytes + * (default: `false`). + */ + quoteSmart?: boolean | null | undefined /** * Preferred quote to use around attribute values * (default: the `quote` used by `mdast-util-to-markdown` for titles). diff --git a/lib/index.js b/lib/index.js index 7192234..9458aeb 100644 --- a/lib/index.js +++ b/lib/index.js @@ -15,6 +15,7 @@ * @import {Nodes, Paragraph} from 'mdast' */ +import {ccount} from 'ccount' import {ok as assert} from 'devlop' import {parseEntities} from 'parse-entities' import {stringifyEntitiesLight} from 'stringify-entities' @@ -88,6 +89,16 @@ export function directiveFromMarkdown() { */ export function directiveToMarkdown(options) { const settings = options || emptyOptions + if ( + settings.quote !== '"' && + settings.quote !== "'" && + settings.quote !== null && + settings.quote !== undefined + ) { + throw new Error( + 'Invalid quote `' + settings.quote + '`, expected `\'` or `"`' + ) + } handleDirective.peek = peekDirective @@ -181,12 +192,6 @@ export function directiveToMarkdown(options) { * @returns {string} */ function attributes(node, state) { - // If the alternative is less common than `quote`, switch. - const appliedQuote = settings.quote || state.options.quote || '"' - const subset = - node.type === 'textDirective' - ? [appliedQuote] - : [appliedQuote, '\n', '\r'] const attributes = node.attributes || {} /** @type {Array} */ const values = [] @@ -208,7 +213,9 @@ export function directiveToMarkdown(options) { const value = String(attributes[key]) if (key === 'id') { - id = shortcut.test(value) ? '#' + value : quoted('id', value) + id = shortcut.test(value) + ? '#' + value + : quoted('id', value, node, state) } else if (key === 'class') { const list = value.split(/[\t\n\r ]+/g) /** @type {Array} */ @@ -225,11 +232,11 @@ export function directiveToMarkdown(options) { classesFull = classesFullList.length > 0 - ? quoted('class', classesFullList.join(' ')) + ? quoted('class', classesFullList.join(' '), node, state) : '' classes = classesList.length > 0 ? '.' + classesList.join('.') : '' } else { - values.push(quoted(key, value)) + values.push(quoted(key, value, node, state)) } } } @@ -247,23 +254,39 @@ export function directiveToMarkdown(options) { } return values.length > 0 ? '{' + values.join(' ') + '}' : '' + } - /** - * @param {string} key - * @param {string} value - * @returns {string} - */ - function quoted(key, value) { - return ( - key + - (value - ? '=' + - appliedQuote + - stringifyEntitiesLight(value, {subset}) + - appliedQuote - : '') - ) - } + /** + * @param {string} key + * @param {string} value + * @param {Directives} node + * @param {State} state + * @returns {string} + */ + function quoted(key, value, node, state) { + if (!value) return key + + // If the alternative is less common than `quote`, switch. + const preferred = settings.quote || state.options.quote || '"' + const alternative = preferred === '"' ? "'" : '"' + // If the alternative is less common than `quote`, switch. + const appliedQuote = + settings.quoteSmart && + ccount(value, preferred) > ccount(value, alternative) + ? alternative + : preferred + const subset = + node.type === 'textDirective' + ? [appliedQuote] + : [appliedQuote, '\n', '\r'] + + return ( + key + + ('=' + + appliedQuote + + stringifyEntitiesLight(value, {subset}) + + appliedQuote) + ) } } diff --git a/package.json b/package.json index 2335f09..adb48c5 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", + "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", diff --git a/readme.md b/readme.md index a7071d6..e4d0880 100644 --- a/readme.md +++ b/readme.md @@ -264,6 +264,9 @@ Configuration. ###### Parameters +* `quoteSmart` + (`boolean`, default: `false`) + — use the other quote if that results in less bytes * `quote` (`'"'` or `"'"`, default: the [`quote`][quote] used by `mdast-util-to-markdown` for titles) diff --git a/test.js b/test.js index ba1eb36..00e7ab1 100644 --- a/test.js +++ b/test.js @@ -1132,12 +1132,169 @@ test('directiveToMarkdown()', async function (t) { attributes: {title: 'a'}, children: [] }, - { - extensions: [directiveToMarkdown({quote: "'"})] - } + {extensions: [directiveToMarkdown({quote: "'"})]} ), ":i{title='a'}\n" ) } ) + + await t.test( + "should quote attribute values with double quotes if `quote: '\\\"'`", + async function () { + assert.deepEqual( + toMarkdown( + { + type: 'textDirective', + name: 'i', + attributes: {title: 'a'}, + children: [] + }, + {extensions: [directiveToMarkdown({quote: '"'})]} + ), + ':i{title="a"}\n' + ) + } + ) + + await t.test( + "should quote attribute values with single quotes if `quote: '\\''` even if they occur in value", + async function () { + assert.deepEqual( + toMarkdown( + { + type: 'textDirective', + name: 'i', + attributes: {title: "'a'"}, + children: [] + }, + {extensions: [directiveToMarkdown({quote: "'"})]} + ), + ":i{title=''a''}\n" + ) + } + ) + + await t.test( + "should quote attribute values with double quotes if `quote: '\\\"'` even if they occur in value", + async function () { + assert.deepEqual( + toMarkdown( + { + type: 'textDirective', + name: 'i', + attributes: {title: '"a"'}, + children: [] + }, + {extensions: [directiveToMarkdown({quote: '"'})]} + ), + ':i{title=""a""}\n' + ) + } + ) + + await t.test('should throw on invalid quotes', async function () { + assert.throws(function () { + toMarkdown( + { + type: 'textDirective', + name: 'i', + attributes: {}, + children: [] + }, + // @ts-expect-error: check how the runtime handles an incorrect `quote` + {extensions: [directiveToMarkdown({quote: '`'})]} + ) + }, /Invalid quote ```, expected `'` or `"`/) + }) + + await t.test( + 'should quote attribute values with primary quotes if they occur less than the alternative', + async function () { + assert.deepEqual( + toMarkdown( + { + type: 'textDirective', + name: 'i', + attributes: {title: "'\"a'"}, + children: [] + }, + {extensions: [directiveToMarkdown({quoteSmart: true})]} + ), + ':i{title="\'"a\'"}\n' + ) + } + ) + + await t.test( + 'should quote attribute values with primary quotes if they occur as much as alternatives (#1)', + async function () { + assert.deepEqual( + toMarkdown( + { + type: 'textDirective', + name: 'i', + attributes: {title: '"a\''}, + children: [] + }, + {extensions: [directiveToMarkdown({quoteSmart: true})]} + ), + ':i{title=""a\'"}\n' + ) + } + ) + + await t.test( + 'should quote attribute values with primary quotes if they occur as much as alternatives (#2)', + async function () { + assert.deepEqual( + toMarkdown( + { + type: 'textDirective', + name: 'i', + attributes: {title: '"\'a\'"'}, + children: [] + }, + {extensions: [directiveToMarkdown({quoteSmart: true})]} + ), + ':i{title=""\'a\'""}\n' + ) + } + ) + + await t.test( + 'should quote attribute values with alternative quotes if the primary occurs', + async function () { + assert.deepEqual( + toMarkdown( + { + type: 'textDirective', + name: 'i', + attributes: {title: '"a"'}, + children: [] + }, + {extensions: [directiveToMarkdown({quoteSmart: true})]} + ), + ':i{title=\'"a"\'}\n' + ) + } + ) + + await t.test( + 'should quote attribute values with alternative quotes if they occur less than the primary', + async function () { + assert.deepEqual( + toMarkdown( + { + type: 'textDirective', + name: 'i', + attributes: {title: '"\'a"'}, + children: [] + }, + {extensions: [directiveToMarkdown({quoteSmart: true})]} + ), + ':i{title=\'"'a"\'}\n' + ) + } + ) }) From 6331d955d8194d0859371a150b45c452f38e946b Mon Sep 17 00:00:00 2001 From: Titus Wormer Date: Wed, 22 Jan 2025 15:27:31 +0100 Subject: [PATCH 12/15] Add `preferUnquoted` option Related-to: GH-12. --- index.d.ts | 5 + lib/index.js | 14 +- readme.md | 3 + test.js | 357 +++++++++++++++++++++++++++++---------------------- 4 files changed, 225 insertions(+), 154 deletions(-) diff --git a/index.d.ts b/index.d.ts index dcf5b7b..5bc061e 100644 --- a/index.d.ts +++ b/index.d.ts @@ -12,6 +12,11 @@ export {directiveFromMarkdown, directiveToMarkdown} from './lib/index.js' * Configuration. */ export interface ToMarkdownOptions { + /** + * Leave attributes unquoted if that results in less bytes + * (default: `false`). + */ + preferUnquoted?: boolean | null | undefined /** * Use the other quote if that results in less bytes * (default: `false`). diff --git a/lib/index.js b/lib/index.js index 9458aeb..42a4893 100644 --- a/lib/index.js +++ b/lib/index.js @@ -27,6 +27,7 @@ const own = {}.hasOwnProperty const emptyOptions = {} const shortcut = /^[^\t\n\r "#'.<=>`}]+$/ +const unquoted = /^[^\t\n\r "'<=>`}]+$/ /** * Create an extension for `mdast-util-from-markdown` to enable directives in @@ -89,6 +90,7 @@ export function directiveFromMarkdown() { */ export function directiveToMarkdown(options) { const settings = options || emptyOptions + if ( settings.quote !== '"' && settings.quote !== "'" && @@ -266,6 +268,10 @@ export function directiveToMarkdown(options) { function quoted(key, value, node, state) { if (!value) return key + if (settings.preferUnquoted && unquoted.test(value)) { + return key + '=' + value + } + // If the alternative is less common than `quote`, switch. const preferred = settings.quote || state.options.quote || '"' const alternative = preferred === '"' ? "'" : '"' @@ -282,10 +288,10 @@ export function directiveToMarkdown(options) { return ( key + - ('=' + - appliedQuote + - stringifyEntitiesLight(value, {subset}) + - appliedQuote) + '=' + + appliedQuote + + stringifyEntitiesLight(value, {subset}) + + appliedQuote ) } } diff --git a/readme.md b/readme.md index e4d0880..d3b1244 100644 --- a/readme.md +++ b/readme.md @@ -264,6 +264,9 @@ Configuration. ###### Parameters +* `preferUnquoted` + (`boolean`, default: `false`) + — leave attributes unquoted if that results in less bytes * `quoteSmart` (`boolean`, default: `false`) — use the other quote if that results in less bytes diff --git a/test.js b/test.js index 00e7ab1..1b3ff81 100644 --- a/test.js +++ b/test.js @@ -1121,9 +1121,8 @@ test('directiveToMarkdown()', async function (t) { } ) - await t.test( - "should quote attribute values with single quotes if `quote: '\\''`", - async function () { + await t.test('preferUnquoted', async function (t) { + await t.test('should omit quotes in `preferUnquoted`', async function () { assert.deepEqual( toMarkdown( { @@ -1132,169 +1131,227 @@ test('directiveToMarkdown()', async function (t) { attributes: {title: 'a'}, children: [] }, - {extensions: [directiveToMarkdown({quote: "'"})]} + {extensions: [directiveToMarkdown({preferUnquoted: true})]} ), - ":i{title='a'}\n" + ':i{title=a}\n' ) - } - ) + }) - await t.test( - "should quote attribute values with double quotes if `quote: '\\\"'`", - async function () { - assert.deepEqual( - toMarkdown( - { - type: 'textDirective', - name: 'i', - attributes: {title: 'a'}, - children: [] - }, - {extensions: [directiveToMarkdown({quote: '"'})]} - ), - ':i{title="a"}\n' - ) - } - ) + await t.test( + 'should keep quotes in `preferUnquoted` and impossible', + async function () { + assert.deepEqual( + toMarkdown( + { + type: 'textDirective', + name: 'i', + attributes: {title: 'a b'}, + children: [] + }, + {extensions: [directiveToMarkdown({preferUnquoted: true})]} + ), + ':i{title="a b"}\n' + ) + } + ) - await t.test( - "should quote attribute values with single quotes if `quote: '\\''` even if they occur in value", - async function () { - assert.deepEqual( - toMarkdown( - { - type: 'textDirective', - name: 'i', - attributes: {title: "'a'"}, - children: [] - }, - {extensions: [directiveToMarkdown({quote: "'"})]} - ), - ":i{title=''a''}\n" - ) - } - ) + await t.test( + 'should not add `=` when omitting quotes on empty values', + async function () { + assert.deepEqual( + toMarkdown( + { + type: 'textDirective', + name: 'i', + attributes: {title: ''}, + children: [] + }, + {extensions: [directiveToMarkdown({preferUnquoted: true})]} + ), + ':i{title}\n' + ) + } + ) + }) - await t.test( - "should quote attribute values with double quotes if `quote: '\\\"'` even if they occur in value", - async function () { - assert.deepEqual( - toMarkdown( - { - type: 'textDirective', - name: 'i', - attributes: {title: '"a"'}, - children: [] - }, - {extensions: [directiveToMarkdown({quote: '"'})]} - ), - ':i{title=""a""}\n' - ) - } - ) + await t.test('quoteSmart', async function (t) { + await t.test( + 'should quote attribute values with primary quotes if they occur less than the alternative', + async function () { + assert.deepEqual( + toMarkdown( + { + type: 'textDirective', + name: 'i', + attributes: {title: "'\"a'"}, + children: [] + }, + {extensions: [directiveToMarkdown({quoteSmart: true})]} + ), + ':i{title="\'"a\'"}\n' + ) + } + ) - await t.test('should throw on invalid quotes', async function () { - assert.throws(function () { - toMarkdown( - { - type: 'textDirective', - name: 'i', - attributes: {}, - children: [] - }, - // @ts-expect-error: check how the runtime handles an incorrect `quote` - {extensions: [directiveToMarkdown({quote: '`'})]} - ) - }, /Invalid quote ```, expected `'` or `"`/) + await t.test( + 'should quote attribute values with primary quotes if they occur as much as alternatives (#1)', + async function () { + assert.deepEqual( + toMarkdown( + { + type: 'textDirective', + name: 'i', + attributes: {title: '"a\''}, + children: [] + }, + {extensions: [directiveToMarkdown({quoteSmart: true})]} + ), + ':i{title=""a\'"}\n' + ) + } + ) + + await t.test( + 'should quote attribute values with primary quotes if they occur as much as alternatives (#2)', + async function () { + assert.deepEqual( + toMarkdown( + { + type: 'textDirective', + name: 'i', + attributes: {title: '"\'a\'"'}, + children: [] + }, + {extensions: [directiveToMarkdown({quoteSmart: true})]} + ), + ':i{title=""\'a\'""}\n' + ) + } + ) + + await t.test( + 'should quote attribute values with alternative quotes if the primary occurs', + async function () { + assert.deepEqual( + toMarkdown( + { + type: 'textDirective', + name: 'i', + attributes: {title: '"a"'}, + children: [] + }, + {extensions: [directiveToMarkdown({quoteSmart: true})]} + ), + ':i{title=\'"a"\'}\n' + ) + } + ) + + await t.test( + 'should quote attribute values with alternative quotes if they occur less than the primary', + async function () { + assert.deepEqual( + toMarkdown( + { + type: 'textDirective', + name: 'i', + attributes: {title: '"\'a"'}, + children: [] + }, + {extensions: [directiveToMarkdown({quoteSmart: true})]} + ), + ':i{title=\'"'a"\'}\n' + ) + } + ) }) - await t.test( - 'should quote attribute values with primary quotes if they occur less than the alternative', - async function () { - assert.deepEqual( - toMarkdown( - { - type: 'textDirective', - name: 'i', - attributes: {title: "'\"a'"}, - children: [] - }, - {extensions: [directiveToMarkdown({quoteSmart: true})]} - ), - ':i{title="\'"a\'"}\n' - ) - } - ) + await t.test('quote', async function (t) { + await t.test( + "should quote attribute values with single quotes if `quote: '\\''`", + async function () { + assert.deepEqual( + toMarkdown( + { + type: 'textDirective', + name: 'i', + attributes: {title: 'a'}, + children: [] + }, + {extensions: [directiveToMarkdown({quote: "'"})]} + ), + ":i{title='a'}\n" + ) + } + ) - await t.test( - 'should quote attribute values with primary quotes if they occur as much as alternatives (#1)', - async function () { - assert.deepEqual( - toMarkdown( - { - type: 'textDirective', - name: 'i', - attributes: {title: '"a\''}, - children: [] - }, - {extensions: [directiveToMarkdown({quoteSmart: true})]} - ), - ':i{title=""a\'"}\n' - ) - } - ) + await t.test( + "should quote attribute values with double quotes if `quote: '\\\"'`", + async function () { + assert.deepEqual( + toMarkdown( + { + type: 'textDirective', + name: 'i', + attributes: {title: 'a'}, + children: [] + }, + {extensions: [directiveToMarkdown({quote: '"'})]} + ), + ':i{title="a"}\n' + ) + } + ) - await t.test( - 'should quote attribute values with primary quotes if they occur as much as alternatives (#2)', - async function () { - assert.deepEqual( - toMarkdown( - { - type: 'textDirective', - name: 'i', - attributes: {title: '"\'a\'"'}, - children: [] - }, - {extensions: [directiveToMarkdown({quoteSmart: true})]} - ), - ':i{title=""\'a\'""}\n' - ) - } - ) + await t.test( + "should quote attribute values with single quotes if `quote: '\\''` even if they occur in value", + async function () { + assert.deepEqual( + toMarkdown( + { + type: 'textDirective', + name: 'i', + attributes: {title: "'a'"}, + children: [] + }, + {extensions: [directiveToMarkdown({quote: "'"})]} + ), + ":i{title=''a''}\n" + ) + } + ) - await t.test( - 'should quote attribute values with alternative quotes if the primary occurs', - async function () { - assert.deepEqual( - toMarkdown( - { - type: 'textDirective', - name: 'i', - attributes: {title: '"a"'}, - children: [] - }, - {extensions: [directiveToMarkdown({quoteSmart: true})]} - ), - ':i{title=\'"a"\'}\n' - ) - } - ) + await t.test( + "should quote attribute values with double quotes if `quote: '\\\"'` even if they occur in value", + async function () { + assert.deepEqual( + toMarkdown( + { + type: 'textDirective', + name: 'i', + attributes: {title: '"a"'}, + children: [] + }, + {extensions: [directiveToMarkdown({quote: '"'})]} + ), + ':i{title=""a""}\n' + ) + } + ) - await t.test( - 'should quote attribute values with alternative quotes if they occur less than the primary', - async function () { - assert.deepEqual( + await t.test('should throw on invalid quotes', async function () { + assert.throws(function () { toMarkdown( { type: 'textDirective', name: 'i', - attributes: {title: '"\'a"'}, + attributes: {}, children: [] }, - {extensions: [directiveToMarkdown({quoteSmart: true})]} - ), - ':i{title=\'"'a"\'}\n' - ) - } - ) + // @ts-expect-error: check how the runtime handles an incorrect `quote` + {extensions: [directiveToMarkdown({quote: '`'})]} + ) + }, /Invalid quote ```, expected `'` or `"`/) + }) + }) }) From 0c253fe5e514f99d1cce7d4ecd368864568635b2 Mon Sep 17 00:00:00 2001 From: Titus Wormer Date: Wed, 22 Jan 2025 15:36:55 +0100 Subject: [PATCH 13/15] Add `collapseEmptyAttributes` option Related-to: GH-12. --- index.d.ts | 5 ++++ lib/index.js | 2 +- readme.md | 3 ++ test.js | 85 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 94 insertions(+), 1 deletion(-) diff --git a/index.d.ts b/index.d.ts index 5bc061e..77cc9ea 100644 --- a/index.d.ts +++ b/index.d.ts @@ -12,6 +12,11 @@ export {directiveFromMarkdown, directiveToMarkdown} from './lib/index.js' * Configuration. */ export interface ToMarkdownOptions { + /** + * Collapse empty attributes: get `title` instead of `title=""` + * (default: `true`). + */ + collapseEmptyAttributes?: boolean | null | undefined /** * Leave attributes unquoted if that results in less bytes * (default: `false`). diff --git a/lib/index.js b/lib/index.js index 42a4893..0bdc958 100644 --- a/lib/index.js +++ b/lib/index.js @@ -266,7 +266,7 @@ export function directiveToMarkdown(options) { * @returns {string} */ function quoted(key, value, node, state) { - if (!value) return key + if (settings.collapseEmptyAttributes !== false && !value) return key if (settings.preferUnquoted && unquoted.test(value)) { return key + '=' + value diff --git a/readme.md b/readme.md index d3b1244..be24de1 100644 --- a/readme.md +++ b/readme.md @@ -264,6 +264,9 @@ Configuration. ###### Parameters +* `collapseEmptyAttributes` + (`boolean`, default: `true`) + — collapse empty attributes: get `title` instead of `title=""` * `preferUnquoted` (`boolean`, default: `false`) — leave attributes unquoted if that results in less bytes diff --git a/test.js b/test.js index 1b3ff81..f0d7e8d 100644 --- a/test.js +++ b/test.js @@ -1121,6 +1121,91 @@ test('directiveToMarkdown()', async function (t) { } ) + await t.test('collapseEmptyAttributes', async function (t) { + await t.test( + 'should hide empty string attributes by default', + async function () { + assert.deepEqual( + toMarkdown( + { + type: 'textDirective', + name: 'i', + attributes: {title: ''}, + children: [] + }, + {extensions: [directiveToMarkdown()]} + ), + ':i{title}\n' + ) + } + ) + + await t.test( + 'should hide empty string attributes w/ `collapseEmptyAttributes: true`', + async function () { + assert.deepEqual( + toMarkdown( + { + type: 'textDirective', + name: 'i', + attributes: {title: ''}, + children: [] + }, + {extensions: [directiveToMarkdown({collapseEmptyAttributes: true})]} + ), + ':i{title}\n' + ) + } + ) + + await t.test( + 'should show empty string attributes w/ `collapseEmptyAttributes: false`', + async function () { + assert.deepEqual( + toMarkdown( + { + type: 'textDirective', + name: 'i', + attributes: {title: ''}, + children: [] + }, + { + extensions: [ + directiveToMarkdown({collapseEmptyAttributes: false}) + ] + } + ), + ':i{title=""}\n' + ) + } + ) + + await t.test( + 'should use quotes for empty string attributes w/ `collapseEmptyAttributes: false` and `preferUnquoted: true`', + async function () { + assert.deepEqual( + toMarkdown( + { + type: 'textDirective', + name: 'i', + attributes: {title: ''}, + children: [] + }, + { + extensions: [ + directiveToMarkdown({ + collapseEmptyAttributes: false, + preferUnquoted: true + }) + ] + } + ), + ':i{title=""}\n' + ) + } + ) + }) + await t.test('preferUnquoted', async function (t) { await t.test('should omit quotes in `preferUnquoted`', async function () { assert.deepEqual( From f8c734830440b41cd2004d34e2075af7a1a9c18b Mon Sep 17 00:00:00 2001 From: Titus Wormer Date: Wed, 22 Jan 2025 15:45:26 +0100 Subject: [PATCH 14/15] Add `preferShortcut` option Closes: GH-12. --- index.d.ts | 5 +++++ lib/index.js | 18 +++++++++++------ readme.md | 3 +++ test.js | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 76 insertions(+), 6 deletions(-) diff --git a/index.d.ts b/index.d.ts index 77cc9ea..fece913 100644 --- a/index.d.ts +++ b/index.d.ts @@ -17,6 +17,11 @@ export interface ToMarkdownOptions { * (default: `true`). */ collapseEmptyAttributes?: boolean | null | undefined + /** + * Prefer `#` and `.` shortcuts for `id` and `class` + * (default: `true`). + */ + preferShortcut?: boolean | null | undefined /** * Leave attributes unquoted if that results in less bytes * (default: `false`). diff --git a/lib/index.js b/lib/index.js index 0bdc958..0adb8f9 100644 --- a/lib/index.js +++ b/lib/index.js @@ -214,10 +214,15 @@ export function directiveToMarkdown(options) { ) { const value = String(attributes[key]) + // To do: next major: + // Do not reorder `id` and `class` attributes when they do not turn into + // shortcuts. + // Additionally, join shortcuts: `#a .b.c d="e"` -> `#a.b.c d="e"` if (key === 'id') { - id = shortcut.test(value) - ? '#' + value - : quoted('id', value, node, state) + id = + settings.preferShortcut !== false && shortcut.test(value) + ? '#' + value + : quoted('id', value, node, state) } else if (key === 'class') { const list = value.split(/[\t\n\r ]+/g) /** @type {Array} */ @@ -227,9 +232,10 @@ export function directiveToMarkdown(options) { let index = -1 while (++index < list.length) { - ;(shortcut.test(list[index]) ? classesList : classesFullList).push( - list[index] - ) + ;(settings.preferShortcut !== false && shortcut.test(list[index]) + ? classesList + : classesFullList + ).push(list[index]) } classesFull = diff --git a/readme.md b/readme.md index be24de1..229b061 100644 --- a/readme.md +++ b/readme.md @@ -267,6 +267,9 @@ Configuration. * `collapseEmptyAttributes` (`boolean`, default: `true`) — collapse empty attributes: get `title` instead of `title=""` +* `preferShortcut` + (`boolean`, default: `true`) + — prefer `#` and `.` shortcuts for `id` and `class` * `preferUnquoted` (`boolean`, default: `false`) — leave attributes unquoted if that results in less bytes diff --git a/test.js b/test.js index f0d7e8d..ca69ca4 100644 --- a/test.js +++ b/test.js @@ -1206,6 +1206,62 @@ test('directiveToMarkdown()', async function (t) { ) }) + await t.test('preferShortcut', async function (t) { + await t.test( + 'should use `#` for `id`, `.` for `class` by default', + async function () { + assert.deepEqual( + toMarkdown( + { + type: 'textDirective', + name: 'i', + attributes: {class: 'a b', id: 'c'}, + children: [] + }, + {extensions: [directiveToMarkdown()]} + ), + ':i{#c .a.b}\n' + ) + } + ) + + await t.test( + 'should use `#` for `id`, `.` for `class` w/ `preferShortcut: true`', + async function () { + assert.deepEqual( + toMarkdown( + { + type: 'textDirective', + name: 'i', + attributes: {class: 'a b', id: 'c'}, + children: [] + }, + {extensions: [directiveToMarkdown({preferShortcut: true})]} + ), + ':i{#c .a.b}\n' + ) + } + ) + + await t.test( + 'should not use use `#` for `id`, `.` for `class` w/ `preferShortcut: false`', + async function () { + assert.deepEqual( + toMarkdown( + { + type: 'textDirective', + name: 'i', + attributes: {class: 'a b', id: 'c'}, + children: [] + }, + {extensions: [directiveToMarkdown({preferShortcut: false})]} + ), + ':i{id="c" class="a b"}\n' + ) + } + ) + }) + await t.test('preferUnquoted', async function (t) { await t.test('should omit quotes in `preferUnquoted`', async function () { assert.deepEqual( From 1ba569c6dfdf3b0157747291cc5e6df5099ad480 Mon Sep 17 00:00:00 2001 From: Titus Wormer Date: Wed, 22 Jan 2025 15:54:32 +0100 Subject: [PATCH 15/15] 3.1.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index adb48c5..f22d424 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,7 @@ "strict": true }, "type": "module", - "version": "3.0.0", + "version": "3.1.0", "xo": { "overrides": [ {