diff --git a/AGENTS.md b/AGENTS.md index 72d3d153f..c0ef76009 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -466,6 +466,7 @@ import mermaid, { Mermaid } from '@comark/angular/plugins/mermaid' // Core parsing import { parseMarkdown, createMarkdownParser, autoCloseMarkdown, defineComarkPlugin } from 'comark' import { parseMarkdown } from 'comark/parse' // same parser API without the auto-close/context re-exports +import { createMarkdownSourceParser } from 'comark/source' // opt-in original-source attribute ranges for editors // Live renderer context (patch a rendered document without re-parsing) import { createComarkContext } from 'comark' diff --git a/docs/content/5.reference/1.parse.md b/docs/content/5.reference/1.parse.md index b853574ec..a7cb8713d 100644 --- a/docs/content/5.reference/1.parse.md +++ b/docs/content/5.reference/1.parse.md @@ -14,6 +14,28 @@ links: variant: soft --- +## Source ranges for editing + +Use `createMarkdownSourceParser()` from `comark/source` to locate editable destinations without +serializing the document again. It uses the default CoMark grammar with streaming repair disabled. + +```ts +import { createMarkdownSourceParser } from 'comark/source' + +const parseSource = createMarkdownSourceParser() +const source = ':Card{href="/guide"}' +const elements = await parseSource(source) +const attribute = elements[0].attributes[0] +const destination = attribute.range && source.slice(attribute.range.start, attribute.range.end) +``` + +Each element groups its authored attributes in source order. A `:name` binding can override a +literal attribute, and later duplicates take precedence. A boolean shorthand has no value range. +Ranges use half-open UTF-16 offsets into the original input, including frontmatter and original +line endings, and exclude surrounding quotes. Images, reference definitions, YAML component props, +and raw HTML attributes are omitted. An unmappable value has no range; editors should leave it +untouched. Source metadata is separate from the document tree and is collected only by this API. + ## `parseMarkdown(source, options?)`{lang="ts"} Parses Markdown from a string and returns a complete `MarkdownDocument`. Default plugins add frontmatter, alerts, task lists, HTML, components, and attributes to the standard Markdown parser. diff --git a/packages/comark/package.json b/packages/comark/package.json index 315c0bbcc..1784d17c1 100644 --- a/packages/comark/package.json +++ b/packages/comark/package.json @@ -53,6 +53,7 @@ "./utils": "./dist/utils/index.js", "./utils/trace": "./dist/utils/trace.js", "./parse": "./dist/parse.js", + "./source": "./dist/source.js", "./render": "./dist/render.js" }, "publishConfig": { diff --git a/packages/comark/src/internal/parse/syntax/block-params.ts b/packages/comark/src/internal/parse/syntax/block-params.ts index 5172dd7af..6bd3c2788 100644 --- a/packages/comark/src/internal/parse/syntax/block-params.ts +++ b/packages/comark/src/internal/parse/syntax/block-params.ts @@ -7,7 +7,7 @@ const RE_BLOCK_NAME = /^[a-z$][$\w.-]*/i /** * Parse `component-name [content] {.params}` from the trailing portion of a block fence line. */ -export function parseBlockParams(str: string) { +export function parseBlockParams(str: string, captureRanges = false) { str = str.trim() if (!str) return { name: '' } const name = str.match(RE_BLOCK_NAME)?.[0] @@ -17,6 +17,7 @@ export function parseBlockParams(str: string) { let content: string | undefined let props: [string, string][] | undefined let unparsedRemaining: string | undefined + let sourceRanges: { name: string; start?: number; end?: number }[] | undefined if (remaining.startsWith('[')) { const result = parseBracketContent(remaining, 0) @@ -27,9 +28,17 @@ export function parseBlockParams(str: string) { } if (remaining.startsWith('{')) { - const propsResult = searchProps(remaining, 0) + const propsResult = searchProps(remaining, 0, captureRanges) if (propsResult) { props = propsResult.props + if (captureRanges) { + const offset = str.length - remaining.length + sourceRanges = propsResult.ranges?.map((range) => ({ + ...range, + start: range.start === undefined ? undefined : range.start + offset, + end: range.end === undefined ? undefined : range.end + offset, + })) + } const afterProps = remaining.slice(propsResult.index).trim() if (afterProps) unparsedRemaining = afterProps } @@ -37,10 +46,17 @@ export function parseBlockParams(str: string) { unparsedRemaining = remaining } - const result: { name: string; content?: string; props?: [string, string][]; remaining?: string } = { + const result: { + name: string + content?: string + props?: [string, string][] + remaining?: string + sourceRanges?: typeof sourceRanges + } = { name: kebabCase(name), } + if (captureRanges) result.sourceRanges = sourceRanges if (content !== undefined) result.content = content if (props !== undefined) result.props = props if (unparsedRemaining) result.remaining = unparsedRemaining diff --git a/packages/comark/src/internal/parse/syntax/props.ts b/packages/comark/src/internal/parse/syntax/props.ts index 155cef15f..83fd5e348 100644 --- a/packages/comark/src/internal/parse/syntax/props.ts +++ b/packages/comark/src/internal/parse/syntax/props.ts @@ -19,10 +19,12 @@ export function parseProps(content: string) { return props.props } -export function searchProps(content: string, index = 0) { +export function searchProps(content: string, index = 0, captureRanges = false) { if (content[index] !== '{') throw new Error(`Invalid props, expected \`{\` but got '${content[index]}'`) const props: [string, string][] = [] + const ranges: { name: string; start?: number; end?: number }[] | undefined = captureRanges ? [] : undefined + let valueComplete = true // Skip Vue mustache `{{ }}` syntax if (content[index + 1] === '{') return undefined @@ -53,15 +55,28 @@ export function searchProps(content: string, index = 0) { if (start !== index) { let key = content.slice(start, index).trim() let value = '' + let valueStart: number | undefined + let valueEnd: number | undefined if (char === '=') { index += 1 + valueStart = index + valueComplete = true value = searchValue() + valueEnd = index } else { key = key[0] === ':' ? key : `:${key}` value = 'true' } if (key.match(/^:?[a-z_][a-z0-9_-]*$/gi)) { props.push([key, value]) + if (ranges) { + const quoted = value.length >= 2 && value[0] in quotePairs && value[value.length - 1] === value[0] + ranges.push( + valueStart === undefined || valueEnd === undefined || !valueComplete || valueEnd > content.length + ? { name: key } + : { name: key, start: valueStart + (quoted ? 1 : 0), end: valueEnd - (quoted ? 1 : 0) } + ) + } } } } @@ -88,11 +103,13 @@ export function searchProps(content: string, index = 0) { function searchValue() { const start = index if (content[index] in bracketPairs) { - searchBracket(bracketPairs[content[index] as keyof typeof bracketPairs]) + if (!searchBracket(bracketPairs[content[index] as keyof typeof bracketPairs])) valueComplete = false index += 1 return content.slice(start, index) } else if (content[index] in quotePairs) { - searchString(quotePairs[content[index] as keyof typeof quotePairs]) + const quote = quotePairs[content[index] as keyof typeof quotePairs] + searchString(quote) + if (content[index] !== quote) valueComplete = false index += 1 return content.slice(start, index) } else { @@ -124,5 +141,6 @@ export function searchProps(content: string, index = 0) { return { props, index, + ranges, } } diff --git a/packages/comark/src/internal/source-ranges.ts b/packages/comark/src/internal/source-ranges.ts new file mode 100644 index 000000000..5994ce7fb --- /dev/null +++ b/packages/comark/src/internal/source-ranges.ts @@ -0,0 +1,193 @@ +import type { ComarkPlugin } from '../types.ts' +import type { SourceAttribute, SourceElement, SourceRange } from '../source.ts' +import type { PluginSimple, StateInline, Token } from 'markdown-exit' +import { parseBlockParams } from './parse/syntax/block-params.ts' + +interface SourceContext { + source: string + lines: number[] + elements: Map + inline: number[] | undefined + cursor: number +} + +interface AttributeRange { + name: string + start?: number + end?: number +} + +interface SourceMetadata { + sourceText: string + sourceElements: SourceElement[] +} + +function element(context: SourceContext, token: Token): SourceElement { + let entry = context.elements.get(token) + if (!entry) { + entry = { tag: token.tag, attributes: [] } + context.elements.set(token, entry) + } + return entry +} + +function inlineRange(context: SourceContext, start: number, end: number): SourceRange | undefined { + const positions = context.inline + if (!positions || start > end || positions[end - 1] === undefined) return + if (start === end) return { start: positions[start], end: positions[start] } + for (let i = start + 1; i < end; i++) { + if (positions[i] !== positions[i - 1] + 1) return + } + return { start: positions[start], end: positions[end - 1] + 1 } +} + +function inlinePositions( + context: SourceContext, + content: string, + lines: [number, number], + table: boolean +): number[] | undefined { + const positions: number[] = [] + const limit = context.lines[lines[1]] ?? context.source.length + let cursor = Math.max(context.lines[lines[0]], context.cursor) + context.cursor = limit + if (table && context.source.slice(context.lines[lines[0]], limit).includes('\\|')) return + const parts = content.split('\n') + for (let i = 0; i < parts.length; i++) { + const index = context.source.slice(cursor, limit).indexOf(parts[i]) + const found = index < 0 ? -1 : cursor + index + if (found < 0 || found + parts[i].length > limit) return + for (let j = 0; j < parts[i].length; j++) positions.push(found + j) + cursor = found + parts[i].length + if (i < parts.length - 1) { + cursor = context.source.indexOf('\n', cursor) + if (cursor < 0 || cursor >= limit) return + positions.push(cursor++) + } + } + context.cursor = cursor + return positions +} + +function precedingElement(tokens: Token[]): Token | undefined { + let index = tokens.length - 2 + while (index >= 0 && tokens[index].type === 'text' && !tokens[index].content.trim()) index-- + const previous = tokens[index] + if (!previous || previous.type === 'text') return + if (previous.nesting !== -1) return previous + for (let i = index - 1; i >= 0; i--) { + const token = tokens[i] + if (token.nesting === 1 && token.tag === previous.tag && token.level === previous.level) return token + } +} + +function linkRange(state: StateInline, context: SourceContext): SourceRange | undefined { + let start = state.posMax + 1 + if (state.src[start] !== '(') return + start++ + while (/\s/.test(state.src[start] ?? '') && start < state.src.length) start++ + const destination = state.md.helpers.parseLinkDestination(state.src, start, state.src.length) + if (!destination.ok) return + const angled = state.src[start] === '<' + return inlineRange(context, start + (angled ? 1 : 0), destination.pos - (angled ? 1 : 0)) +} + +export function sourceRangesPlugin(): ComarkPlugin { + const environments = new WeakMap() + const documents = new WeakMap() + const track: PluginSimple = (md) => { + md.core.ruler.after('block', 'comark_source_ranges', (state) => { + const lines = [0] + for (let i = 0; i < state.src.length; i++) if (state.src[i] === '\n') lines.push(i + 1) + const context: SourceContext = { source: state.src, lines, elements: new Map(), inline: undefined, cursor: 0 } + environments.set(state.env, context) + documents.set(state.tokens, context) + state.env.comarkSourceAttributes = (inline: StateInline, ranges: AttributeRange[]) => { + const owner = precedingElement(inline.tokens) + if (!owner) return + for (const attribute of ranges) { + const range = + attribute.start === undefined || attribute.end === undefined + ? undefined + : inlineRange(context, attribute.start, attribute.end) + element(context, owner).attributes.push({ name: attribute.name, range }) + } + } + for (const token of state.tokens) { + if (!token.type.startsWith('mdc_block') || token.nesting === -1 || !token.map) continue + const start = lines[token.map[0]] + const end = state.src.indexOf('\n', start) + const line = state.src.slice(start, end < 0 ? undefined : end) + const marker = line.indexOf(':') + if (marker < 0) continue + let nameStart = marker + while (line[nameStart] === ':') nameStart++ + while (line[nameStart] === ' ' || line[nameStart] === '\t') nameStart++ + const params = parseBlockParams(line.slice(nameStart), true) + const attributes: SourceAttribute[] = (params.sourceRanges ?? []).map((range) => ({ + name: range.name, + range: + range.start === undefined || range.end === undefined + ? undefined + : { start: start + nameStart + range.start, end: start + nameStart + range.end }, + })) + if (attributes.length) element(context, token).attributes.push(...attributes) + } + }) + + md.core.ruler.at('inline', (state) => { + const context = environments.get(state.env)! + let lines: [number, number] = [0, context.lines.length] + let tableDepth = 0 + for (const token of state.tokens) { + if (token.tag === 'table') tableDepth += token.nesting + if (token.map && token.nesting !== -1) lines = token.map + if (token.type !== 'inline') continue + context.inline = inlinePositions(context, token.content, lines, tableDepth > 0) + md.inline.parse(token.content, md, state.env, token.children!) + } + }) + + const active = new WeakSet() + const tokenize = md.inline.tokenize + md.inline.tokenize = (state) => { + if (active.has(state)) return tokenize.call(md.inline, state) + active.add(state) + const context = environments.get(state.env)! + const push = state.push + state.push = function (type, tag, nesting) { + const token = push.call(this, type, tag, nesting) + if (type === 'link_open') { + const range = linkRange(this, context) + if (range) element(context, token).attributes.push({ name: 'href', range }) + } + return token + } + try { + tokenize.call(md.inline, state) + } finally { + state.push = push + active.delete(state) + } + } + } + + return { + name: 'source-ranges', + markdownItPlugins: [track], + post(state) { + const context = documents.get(state.tokens)! + const elements: SourceElement[] = [] + function collect(tokens: Token[]): void { + for (const token of tokens) { + const entry = context.elements.get(token) + if (entry) elements.push(entry) + if (token.children && token.type !== 'image') collect(token.children) + } + } + collect(state.tokens) + state.tree.meta.sourceText = context.source + state.tree.meta.sourceElements = elements + }, + } +} diff --git a/packages/comark/src/plugins/attributes.ts b/packages/comark/src/plugins/attributes.ts index f32d17dd1..dc7b0997f 100644 --- a/packages/comark/src/plugins/attributes.ts +++ b/packages/comark/src/plugins/attributes.ts @@ -23,7 +23,7 @@ const markdownItInlineProps: PluginSimple = (md) => { // Skip Vue mustache `{{ }}` and template `${ }` syntax if (state.src[start + 1] === '{' || state.src[start - 1] === '{' || state.src[start - 1] === '$') return false - const search = searchProps(state.src, start) + const search = searchProps(state.src, start, !!state.env.comarkSourceAttributes) if (!search) return false const { props, index: end } = search @@ -37,6 +37,7 @@ const markdownItInlineProps: PluginSimple = (md) => { const token = state.push('mdc_inline_props', 'span', 0) token.attrs = props token.hidden = true + state.env.comarkSourceAttributes?.(state, search.ranges) return true }) diff --git a/packages/comark/src/source.ts b/packages/comark/src/source.ts new file mode 100644 index 000000000..dbba7fd7c --- /dev/null +++ b/packages/comark/src/source.ts @@ -0,0 +1,58 @@ +import { createMarkdownParser, parseFrontmatter } from './parse.ts' +import { sourceRangesPlugin } from './internal/source-ranges.ts' + +/** Half-open UTF-16 offsets into the original source, excluding surrounding quotes. */ +export interface SourceRange { + start: number + end: number +} + +export interface SourceAttribute { + name: string + /** Absent when the attribute has no authored value, such as a boolean shorthand. */ + range?: SourceRange +} + +/** Attributes sharing one authored element; duplicate and bound names remain in source order. */ +export interface SourceElement { + tag: string + attributes: SourceAttribute[] +} + +/** + * Locate inline Markdown destinations and component attributes with the default CoMark grammar. + * Reference definitions, YAML props, raw HTML attributes, and transformed source are omitted. + * Table rows containing escaped pipes are transformed by the block parser and have no ranges. + * Streaming repair is disabled. The returned ranges are separate from the renderable tree. + */ +export function createMarkdownSourceParser(): (source: string) => Promise { + const parse = createMarkdownParser({ autoClose: false, plugins: [sourceRangesPlugin()] }) + + return async (source) => { + const { content } = parseFrontmatter(source) + const offset = source.length - content.length + const positions: number[] = [] + for (let i = 0; i < content.length; i++) { + positions.push(offset + i) + if (content[i] === '\r' && content[i + 1] === '\n') i++ + } + positions.push(source.length) + + const document = await parse(source) + if (document.meta.sourceText !== content.replace(/\r\n?/g, '\n').replace(/\0/g, '\uFFFD')) { + throw new Error('Source ranges require untransformed Markdown.') + } + return document.meta.sourceElements.map((element) => ({ + ...element, + attributes: element.attributes.map((attribute) => { + const range = attribute.range + const start = range && positions[range.start] + const end = range && positions[range.end] + return { + ...attribute, + range: start !== undefined && end !== undefined && start <= end ? { start, end } : undefined, + } + }), + })) + } +} diff --git a/packages/comark/test/source-ranges.test.ts b/packages/comark/test/source-ranges.test.ts new file mode 100644 index 000000000..e09a72d59 --- /dev/null +++ b/packages/comark/test/source-ranges.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest' +import { createMarkdownSourceParser } from '../src/source.ts' + +describe('source ranges', () => { + it.each([':Badge{href="/old}', '::Card{href="/old}\nx\n::', ':Badge{href=[unclosed}'])( + 'omits incomplete value ranges: %s', + async (source) => { + const elements = await createMarkdownSourceParser()(source) + expect( + elements.flatMap((element) => element.attributes).every((attribute) => attribute.range === undefined) + ).toBe(true) + } + ) + + it('retains exact block attribute ranges after tab-separated fences', async () => { + const source = '::\tCard{href="/old"}\nx\n::' + const elements = await createMarkdownSourceParser()(source) + const range = elements[0].attributes[0].range! + expect(source.slice(range.start, range.end)).toBe('/old') + }) + + it('omits transformed table rows without locating links inside code samples', async () => { + const source = '| `\\| [A](/old)` | [A](/old) |\n| --- | --- |\n| [B](/real) | [C](/other) |' + const elements = await createMarkdownSourceParser()(source) + const destinations = elements.flatMap((element) => + element.attributes.flatMap(({ range }) => (range ? [source.slice(range.start, range.end)] : [])) + ) + expect(destinations).toEqual(['/real', '/other']) + }) + + it('retains valueless bindings so editors can respect their precedence', async () => { + const elements = await createMarkdownSourceParser()(':Badge{href="/old" :href}') + expect(elements[0].attributes.map((attribute) => attribute.name)).toEqual(['href', ':href']) + expect(elements[0].attributes[1].range).toBeUndefined() + }) + + it.each(['\n', '\r\n'])( + 'locates original destinations and component attributes with %j newlines', + async (newline) => { + const source = [ + '---', + 'title: Keep /old', + '---', + '', + '::::Card{href="/old?x=1#usage" title="Kept"}', + "A :Badge[Status]{href='/old'} and :Icon{name=book href=/old}.", + '[Direct]( "Title")', + '::::', + ].join(newline) + const elements = await createMarkdownSourceParser()(source) + expect( + elements.flatMap((element) => + element.attributes + .filter((attr) => attr.name === 'href') + .map((attr) => source.slice(attr.range!.start, attr.range!.end)) + ) + ).toEqual(['/old?x=1#usage', '/old', '/old', '/old#details']) + } + ) + + it('keeps duplicate and bound attributes associated with their own element', async () => { + const source = ':Badge[One]{href="/old" href="/other"} :Badge[Two]{href="/old" :href="destination"}' + const elements = await createMarkdownSourceParser()(source) + expect( + elements.map((element) => + element.attributes.map((attr) => [attr.name, source.slice(attr.range!.start, attr.range!.end)]) + ) + ).toEqual([ + [ + ['href', '/old'], + ['href', '/other'], + ], + [ + ['href', '/old'], + [':href', 'destination'], + ], + ]) + }) + + it('excludes images, reference definitions, comments, and code samples', async () => { + const source = + '`[Code](/old)`\n\n```md\n:Badge{href="/old"}\n```\n\n\n\n![Image](/old)\n\n[Reference][shared]\n\n[shared]: /old\n\n[Direct](/real)' + const elements = await createMarkdownSourceParser()(source) + expect( + elements.flatMap((element) => element.attributes.map((attr) => source.slice(attr.range!.start, attr.range!.end))) + ).toEqual(['/real']) + }) + + it('maps links inside lists, blockquotes, component labels, and table cells', async () => { + const source = + '- [List](/list)\n\n> [Quote](/quote)\n\n:Card[[Label](/label)]{href="/card"}\n\n| A | B |\n| --- | --- |\n| [One](/one) | [Two](/two) |' + const elements = await createMarkdownSourceParser()(source) + expect( + elements.flatMap((element) => + element.attributes + .filter((attr) => attr.name === 'href') + .map((attr) => source.slice(attr.range!.start, attr.range!.end)) + ) + ).toEqual(['/list', '/quote', '/card', '/label', '/one', '/two']) + }) + + it('keeps concurrent parser calls independent', async () => { + const parse = createMarkdownSourceParser() + const sources = ['[First](/first)', ':Card{href="/second"}'] + const results = await Promise.all(sources.map((source) => parse(source))) + expect( + results.map((elements, index) => + elements.flatMap((element) => + element.attributes.map((attr) => sources[index].slice(attr.range!.start, attr.range!.end)) + ) + ) + ).toEqual([['/first'], ['/second']]) + }) +})