From f290b455e8ff14b5188a4d6c76b35b0532b1df9a Mon Sep 17 00:00:00 2001 From: Trinh Van Thoai Date: Thu, 22 Oct 2026 17:44:13 +0700 Subject: [PATCH] Add `verbose` option for positional info of attributes Closes GH-19. --- index.d.ts | 88 ++++++++++++++++++++- lib/index.js | 118 +++++++++++++++++++++++++++- readme.md | 142 ++++++++++++++++++++++++++++++++- test.js | 216 +++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 555 insertions(+), 9 deletions(-) diff --git a/index.d.ts b/index.d.ts index fece913..6a45f7c 100644 --- a/index.d.ts +++ b/index.d.ts @@ -5,9 +5,67 @@ import type { Parent, PhrasingContent } from 'mdast' +import type {Position} from 'unist' export {directiveFromMarkdown, directiveToMarkdown} from './lib/index.js' +/** + * Configuration. + */ +export interface FromMarkdownOptions { + /** + * Whether to add extra positional info about attributes to directives + * (default: `false`). + * + * When `true`, directives with attributes get + * `data.position.attributes`. + */ + verbose?: boolean | null | undefined +} + +/** + * Positional info of an attribute of a directive. + * + * `start` and `end` span the whole attribute: + * `#a`, `.a`, `a`, `a=b`, `a="b"`. + */ +export interface DirectiveAttributePosition extends Position { + /** + * Attribute name (`id` for `#a`, `class` for `.a`). + */ + name: string + + /** + * Positional info of the value, without quotes + * (such as `b` in `a="b"`, or `a` in `#a`); + * empty between the quotes for an empty quoted value (`a=""`); + * not present when there is no value (`a`). + * + * Character references are not decoded: + * this is the place of the raw value in the source. + */ + value?: Position | undefined +} + +/** + * Extra positional info of a directive. + */ +export interface DirectiveDataPosition { + /** + * Positional info of every attribute, in source order. + * + * Duplicate attributes are all included. + * For a given name, the last one is the one whose value is in + * `attributes`, + * except for `class`, + * where the values of all of them are joined. + * + * Field added by `mdast-util-directive` when passing `verbose: true` to + * `directiveFromMarkdown`. + */ + attributes: Array +} + /** * Configuration. */ @@ -77,7 +135,13 @@ export interface ContainerDirective extends DirectiveFields, Parent { /** * Info associated with mdast container directive nodes by the ecosystem. */ -export interface ContainerDirectiveData extends Data {} +export interface ContainerDirectiveData extends Data { + /** + * Extra positional info, + * added when passing `verbose: true` to `directiveFromMarkdown`. + */ + position?: DirectiveDataPosition | undefined +} /** * Markdown directive (leaf form). @@ -102,7 +166,13 @@ export interface LeafDirective extends Parent, DirectiveFields { /** * Info associated with mdast leaf directive nodes by the ecosystem. */ -export interface LeafDirectiveData extends Data {} +export interface LeafDirectiveData extends Data { + /** + * Extra positional info, + * added when passing `verbose: true` to `directiveFromMarkdown`. + */ + position?: DirectiveDataPosition | undefined +} /** * Markdown directive (text form). @@ -127,7 +197,13 @@ export interface TextDirective extends DirectiveFields, Parent { /** * Info associated with mdast text directive nodes by the ecosystem. */ -export interface TextDirectiveData extends Data {} +export interface TextDirectiveData extends Data { + /** + * Extra positional info, + * added when passing `verbose: true` to `directiveFromMarkdown`. + */ + position?: DirectiveDataPosition | undefined +} /** * Union of registered mdast directive nodes. @@ -143,6 +219,12 @@ declare module 'mdast-util-from-markdown' { * Attributes for current directive. */ directiveAttributes?: Array<[string, string]> | undefined + + /** + * Positional info of attributes for current directive, + * when `verbose`. + */ + directiveAttributePositions?: Array | undefined } } diff --git a/lib/index.js b/lib/index.js index 6008625..355d82a 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,6 +1,7 @@ /** * @import { * Directives, + * FromMarkdownOptions, * LeafDirective, * TextDirective, * ToMarkdownOptions @@ -18,6 +19,7 @@ * State * } from 'mdast-util-to-markdown' * @import {Nodes, Paragraph} from 'mdast' + * @import {Point} from 'unist' */ import {ccount} from 'ccount' @@ -29,6 +31,9 @@ import {visitParents} from 'unist-util-visit-parents' const own = {}.hasOwnProperty +/** @type {Readonly} */ +const emptyFromMarkdownOptions = {} + /** @type {Readonly} */ const emptyOptions = {} @@ -39,10 +44,17 @@ const unquoted = /^[^\t\n\r "'<=>`}]+$/ * Create an extension for `mdast-util-from-markdown` to enable directives in * markdown. * + * @param {Readonly | null | undefined} [options] + * Configuration (optional). * @returns {FromMarkdownExtension} * Extension for `mdast-util-from-markdown` to enable directives. */ -export function directiveFromMarkdown() { +export function directiveFromMarkdown(options) { + const settings = options || emptyFromMarkdownOptions + const enterAttributes = settings.verbose + ? enterAttributesVerbose + : enterAttributesTerse + return { canContainEols: ['textDirective'], enter: { @@ -62,6 +74,7 @@ export function directiveFromMarkdown() { directiveContainerAttributeIdValue: exitAttributeIdValue, directiveContainerAttributeName: exitAttributeName, directiveContainerAttributeValue: exitAttributeValue, + directiveContainerAttributeValueLiteral: exitAttributeValueLiteral, directiveContainerAttributes: exitAttributes, directiveContainerLabel: exitContainerLabel, directiveContainerName: exitName, @@ -71,6 +84,7 @@ export function directiveFromMarkdown() { directiveLeafAttributeIdValue: exitAttributeIdValue, directiveLeafAttributeName: exitAttributeName, directiveLeafAttributeValue: exitAttributeValue, + directiveLeafAttributeValueLiteral: exitAttributeValueLiteral, directiveLeafAttributes: exitAttributes, directiveLeafName: exitName, @@ -79,6 +93,7 @@ export function directiveFromMarkdown() { directiveTextAttributeIdValue: exitAttributeIdValue, directiveTextAttributeName: exitAttributeName, directiveTextAttributeValue: exitAttributeValue, + directiveTextAttributeValueLiteral: exitAttributeValueLiteral, directiveTextAttributes: exitAttributes, directiveTextName: exitName } @@ -429,11 +444,22 @@ function exitContainerLabel(token) { * @this {CompileContext} * @type {FromMarkdownHandle} */ -function enterAttributes() { +function enterAttributesTerse() { this.data.directiveAttributes = [] this.buffer() // Capture EOLs } +/** + * Handle the start of attributes, tracking their positional info. + * + * @this {CompileContext} + * @type {FromMarkdownHandle} + */ +function enterAttributesVerbose(token) { + enterAttributesTerse.call(this, token) + this.data.directiveAttributePositions = [] +} + /** * Handle the end of an ID attribute value. * @@ -447,6 +473,7 @@ function exitAttributeIdValue(token) { 'id', parseEntities(this.sliceSerialize(token), {attribute: true}) ]) + addShortcutPosition(this, 'id', token) } /** @@ -462,6 +489,7 @@ function exitAttributeClassValue(token) { 'class', parseEntities(this.sliceSerialize(token), {attribute: true}) ]) + addShortcutPosition(this, 'class', token) } /** @@ -476,6 +504,32 @@ function exitAttributeValue(token) { list[list.length - 1][1] = parseEntities(this.sliceSerialize(token), { attribute: true }) + + const positions = this.data.directiveAttributePositions + + if (positions) { + const position = positions[positions.length - 1] + position.end = point(token.end, 0) + position.value = {start: point(token.start, 0), end: point(token.end, 0)} + } +} + +/** + * Handle the end of a quoted attribute value. + * + * @this {CompileContext} + * @type {FromMarkdownHandle} + */ +function exitAttributeValueLiteral(token) { + const positions = this.data.directiveAttributePositions + + if (positions) { + const position = positions[positions.length - 1] + position.end = point(token.end, 0) + // Exclude the quotes, which are always one character each. + // This also gives empty values (`a=""`) a place. + position.value = {start: point(token.start, 1), end: point(token.end, -1)} + } } /** @@ -490,7 +544,18 @@ function exitAttributeName(token) { // Attribute names in CommonMark are significantly limited, so character // references can’t exist. - list.push([this.sliceSerialize(token), '']) + const name = this.sliceSerialize(token) + list.push([name, '']) + + const positions = this.data.directiveAttributePositions + + if (positions) { + positions.push({ + name, + start: point(token.start, 0), + end: point(token.end, 0) + }) + } } /** @@ -516,7 +581,9 @@ function exitAttributes() { } } + const positions = this.data.directiveAttributePositions this.data.directiveAttributes = undefined + this.data.directiveAttributePositions = undefined this.resume() // Drop EOLs const node = this.stack[this.stack.length - 1] assert( @@ -525,6 +592,51 @@ function exitAttributes() { node.type === 'textDirective' ) node.attributes = cleaned + + if (positions) { + node.data = {...node.data, position: {attributes: positions}} + } +} + +/** + * Track the positional info of an `id` or `class` shortcut. + * + * @param {CompileContext} context + * Compile context. + * @param {string} name + * Attribute name. + * @param {Token} token + * Value token (without the `#` or `.` marker). + * @returns {undefined} + * Nothing. + */ +function addShortcutPosition(context, name, token) { + const positions = context.data.directiveAttributePositions + + if (positions) { + positions.push({ + name, + // The marker (`#` or `.`) is always one character. + start: point(token.start, -1), + end: point(token.end, 0), + value: {start: point(token.start, 0), end: point(token.end, 0)} + }) + } +} + +/** + * Copy a micromark point to a unist point, moved by `delta` characters on its + * line. + * + * @param {Token['start']} d + * micromark point. + * @param {number} delta + * Characters to move by. + * @returns {Point} + * unist point. + */ +function point(d, delta) { + return {line: d.line, column: d.column + delta, offset: d.offset + delta} } /** diff --git a/readme.md b/readme.md index 8f9af0c..1adb08b 100644 --- a/readme.md +++ b/readme.md @@ -18,10 +18,12 @@ and such). * [Install](#install) * [Use](#use) * [API](#api) - * [`directiveFromMarkdown()`](#directivefrommarkdown) + * [`directiveFromMarkdown(options?)`](#directivefrommarkdownoptions) * [`directiveToMarkdown(options?)`](#directivetomarkdownoptions) * [`ContainerDirective`](#containerdirective) + * [`DirectiveAttributePosition`](#directiveattributeposition) * [`Directives`](#directives) + * [`FromMarkdownOptions`](#frommarkdownoptions) * [`LeafDirective`](#leafdirective) * [`TextDirective`](#textdirective) * [`ToMarkdownOptions`](#tomarkdownoptions) @@ -183,12 +185,18 @@ This package exports the identifiers [`directiveToMarkdown`][api-directive-to-markdown]. There is no default export. -### `directiveFromMarkdown()` +### `directiveFromMarkdown(options?)` Create an extension for [`mdast-util-from-markdown`][github-mdast-util-from-markdown] to enable directives in markdown. +###### Parameters + +* `options` + ([`FromMarkdownOptions`][api-from-markdown-options], optional) + — configuration + ###### Returns Extension for `mdast-util-from-markdown` to enable directives @@ -231,6 +239,45 @@ interface ContainerDirective extends Parent { } ``` +### `DirectiveAttributePosition` + +Positional info of an attribute of a directive +(TypeScript type). + +`start` and `end` span the whole attribute: +`#a`, +`.a`, +`a`, +`a=b`, +or `a="b"`. + +###### Type + +```ts +import type {Position} from 'unist' + +interface DirectiveAttributePosition extends Position { + name: string + value?: Position | undefined +} +``` + +###### Fields + +* `name` + (`string`) + — attribute name; + `id` for `#a` and `class` for `.a` +* `value` + ([`Position`][github-unist-position], optional) + — place of the value, + without quotes + (such as `b` in `a="b"`, or `a` in `#a`); + empty between the quotes for an empty quoted value (`a=""`); + not present when there is no value (`a`); + character references are not decoded, + this is the place of the raw value in the source + ### `Directives` The different directive nodes @@ -242,6 +289,87 @@ The different directive nodes type Directives = ContainerDirective | LeafDirective | TextDirective ``` +### `FromMarkdownOptions` + +Configuration. + +###### Parameters + +* `verbose` + (`boolean`, default: `false`) + — whether to add extra positional info about attributes to directives + +When `verbose` is `true`, +directives with attributes get `data.position.attributes`: +a list of [`DirectiveAttributePosition`][api-directive-attribute-position]s, +one for every attribute, +in source order. +Duplicate attributes are all included. +For a given name, +the last one is the one whose value is in `attributes`, +except for `class`, +where the values of all of them are joined. + +For the following markdown: + +```markdown +::a[b]{#c .d src="e" src=f} +``` + +The verbose info looks as follows: + +```js +{ + type: 'leafDirective', + name: 'a', + attributes: {id: 'c', class: 'd', src: 'f'}, + children: [/* … */], + data: { + position: { + attributes: [ + { + name: 'id', + start: {line: 1, column: 8, offset: 7}, + end: {line: 1, column: 10, offset: 9}, + value: { + start: {line: 1, column: 9, offset: 8}, + end: {line: 1, column: 10, offset: 9} + } + }, + { + name: 'class', + start: {line: 1, column: 11, offset: 10}, + end: {line: 1, column: 13, offset: 12}, + value: { + start: {line: 1, column: 12, offset: 11}, + end: {line: 1, column: 13, offset: 12} + } + }, + { + name: 'src', + start: {line: 1, column: 14, offset: 13}, + end: {line: 1, column: 21, offset: 20}, + value: { + start: {line: 1, column: 19, offset: 18}, + end: {line: 1, column: 20, offset: 19} + } + }, + { + name: 'src', + start: {line: 1, column: 22, offset: 21}, + end: {line: 1, column: 27, offset: 26}, + value: { + start: {line: 1, column: 26, offset: 25}, + end: {line: 1, column: 27, offset: 26} + } + } + ] + } + }, + position: {/* … */} +} +``` + ### `LeafDirective` Directive in flow content @@ -487,7 +615,9 @@ there are no semantics (such as by HTML or hast). This package is fully typed with [TypeScript][]. It exports the additional types [`ContainerDirective`][api-container-directive], +[`DirectiveAttributePosition`][api-directive-attribute-position], [`Directives`][api-directives], +[`FromMarkdownOptions`][api-from-markdown-options], [`LeafDirective`][api-leaf-directive], and [`TextDirective`][api-text-directive]. @@ -555,12 +685,16 @@ or community you agree to abide by its terms. [api-container-directive]: #containerdirective -[api-directive-from-markdown]: #directivefrommarkdown +[api-directive-attribute-position]: #directiveattributeposition + +[api-directive-from-markdown]: #directivefrommarkdownoptions [api-directive-to-markdown]: #directivetomarkdownoptions [api-directives]: #directives +[api-from-markdown-options]: #frommarkdownoptions + [api-leaf-directive]: #leafdirective [api-text-directive]: #textdirective @@ -619,6 +753,8 @@ or community you agree to abide by its terms. [github-remark-directive]: https://github.com/remarkjs/remark-directive +[github-unist-position]: https://github.com/syntax-tree/unist#position + [health]: https://github.com/syntax-tree/.github [health-coc]: https://github.com/syntax-tree/.github/blob/main/code-of-conduct.md diff --git a/test.js b/test.js index 6ae75ea..a67b351 100644 --- a/test.js +++ b/test.js @@ -302,6 +302,222 @@ test('directiveFromMarkdown()', async function (t) { ] }) }) + + await t.test( + 'should not add positional info of attributes by default', + async function () { + const tree = fromMarkdown(':a{b=c}', { + extensions: [directive()], + mdastExtensions: [directiveFromMarkdown()] + }) + const paragraph = tree.children[0] + assert.strictEqual(paragraph.type, 'paragraph') + const node = paragraph.children[0] + assert.strictEqual(node.type, 'textDirective') + + assert.equal(node.data, undefined) + } + ) + + await t.test( + 'should support `verbose` (positional info of attributes)', + async function () { + const tree = fromMarkdown(':a{#b.c d e="" f=g h=\'i&j\'}', { + extensions: [directive()], + mdastExtensions: [directiveFromMarkdown({verbose: true})] + }) + const paragraph = tree.children[0] + assert.strictEqual(paragraph.type, 'paragraph') + const node = paragraph.children[0] + assert.strictEqual(node.type, 'textDirective') + + assert.deepEqual(node.attributes, { + id: 'b', + class: 'c', + d: '', + e: '', + f: 'g', + h: 'i&j' + }) + assert.deepEqual(node.data, { + position: { + attributes: [ + { + name: 'id', + start: {line: 1, column: 4, offset: 3}, + end: {line: 1, column: 6, offset: 5}, + value: { + start: {line: 1, column: 5, offset: 4}, + end: {line: 1, column: 6, offset: 5} + } + }, + { + name: 'class', + start: {line: 1, column: 6, offset: 5}, + end: {line: 1, column: 8, offset: 7}, + value: { + start: {line: 1, column: 7, offset: 6}, + end: {line: 1, column: 8, offset: 7} + } + }, + { + name: 'd', + start: {line: 1, column: 9, offset: 8}, + end: {line: 1, column: 10, offset: 9} + }, + { + name: 'e', + start: {line: 1, column: 11, offset: 10}, + end: {line: 1, column: 15, offset: 14}, + value: { + start: {line: 1, column: 14, offset: 13}, + end: {line: 1, column: 14, offset: 13} + } + }, + { + name: 'f', + start: {line: 1, column: 16, offset: 15}, + end: {line: 1, column: 19, offset: 18}, + value: { + start: {line: 1, column: 18, offset: 17}, + end: {line: 1, column: 19, offset: 18} + } + }, + { + name: 'h', + start: {line: 1, column: 20, offset: 19}, + end: {line: 1, column: 31, offset: 30}, + value: { + start: {line: 1, column: 23, offset: 22}, + end: {line: 1, column: 30, offset: 29} + } + } + ] + } + }) + } + ) + + await t.test( + 'should support `verbose` w/ duplicate attributes and a label', + async function () { + const tree = fromMarkdown('::a[b src=c]{src="d" src="e"}', { + extensions: [directive()], + mdastExtensions: [directiveFromMarkdown({verbose: true})] + }) + const node = tree.children[0] + assert.strictEqual(node.type, 'leafDirective') + + assert.deepEqual(node.attributes, {src: 'e'}) + assert.deepEqual(node.data, { + position: { + attributes: [ + { + name: 'src', + start: {line: 1, column: 14, offset: 13}, + end: {line: 1, column: 21, offset: 20}, + value: { + start: {line: 1, column: 19, offset: 18}, + end: {line: 1, column: 20, offset: 19} + } + }, + { + name: 'src', + start: {line: 1, column: 22, offset: 21}, + end: {line: 1, column: 29, offset: 28}, + value: { + start: {line: 1, column: 27, offset: 26}, + end: {line: 1, column: 28, offset: 27} + } + } + ] + } + }) + } + ) + + await t.test('should support `verbose` (container)', async function () { + const tree = fromMarkdown(':::a[b]{.c d=e}\nf\n:::', { + extensions: [directive()], + mdastExtensions: [directiveFromMarkdown({verbose: true})] + }) + const node = tree.children[0] + assert.strictEqual(node.type, 'containerDirective') + + assert.deepEqual(node.data, { + position: { + attributes: [ + { + name: 'class', + start: {line: 1, column: 9, offset: 8}, + end: {line: 1, column: 11, offset: 10}, + value: { + start: {line: 1, column: 10, offset: 9}, + end: {line: 1, column: 11, offset: 10} + } + }, + { + name: 'd', + start: {line: 1, column: 12, offset: 11}, + end: {line: 1, column: 15, offset: 14}, + value: { + start: {line: 1, column: 14, offset: 13}, + end: {line: 1, column: 15, offset: 14} + } + } + ] + } + }) + }) + + await t.test( + 'should support `verbose` w/ EOLs in attributes', + async function () { + const tree = fromMarkdown(':a{b\nc="d\ne"}', { + extensions: [directive()], + mdastExtensions: [directiveFromMarkdown({verbose: true})] + }) + const paragraph = tree.children[0] + assert.strictEqual(paragraph.type, 'paragraph') + const node = paragraph.children[0] + assert.strictEqual(node.type, 'textDirective') + + assert.deepEqual(node.data, { + position: { + attributes: [ + { + name: 'b', + start: {line: 1, column: 4, offset: 3}, + end: {line: 1, column: 5, offset: 4} + }, + { + name: 'c', + start: {line: 2, column: 1, offset: 5}, + end: {line: 3, column: 3, offset: 12}, + value: { + start: {line: 2, column: 4, offset: 8}, + end: {line: 3, column: 2, offset: 11} + } + } + ] + } + }) + } + ) + + await t.test('should support `verbose` w/o attributes', async function () { + const tree = fromMarkdown('::a\n\n::b{}', { + extensions: [directive()], + mdastExtensions: [directiveFromMarkdown({verbose: true})] + }) + const a = tree.children[0] + const b = tree.children[1] + assert.strictEqual(a.type, 'leafDirective') + assert.strictEqual(b.type, 'leafDirective') + + assert.equal(a.data, undefined) + assert.deepEqual(b.data, {position: {attributes: []}}) + }) }) test('directiveToMarkdown()', async function (t) {