From a7202c71617624614d8f76b211c9818604425ba1 Mon Sep 17 00:00:00 2001
From: tubarao312
Date: Wed, 21 Oct 2026 18:18:05 +0000
Subject: [PATCH 1/2] feat: expose original-source attribute ranges for editors
---
AGENTS.md | 1 +
docs/content/5.reference/1.parse.md | 22 +++
packages/comark/package.json | 1 +
.../src/internal/parse/syntax/block-params.ts | 22 ++-
.../comark/src/internal/parse/syntax/props.ts | 16 +-
packages/comark/src/internal/source-ranges.ts | 183 ++++++++++++++++++
packages/comark/src/plugins/attributes.ts | 3 +-
packages/comark/src/source.ts | 52 +++++
packages/comark/test/source-ranges.test.ts | 88 +++++++++
9 files changed, 383 insertions(+), 5 deletions(-)
create mode 100644 packages/comark/src/internal/source-ranges.ts
create mode 100644 packages/comark/src/source.ts
create mode 100644 packages/comark/test/source-ranges.test.ts
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..484f8bb3a 100644
--- a/packages/comark/src/internal/parse/syntax/props.ts
+++ b/packages/comark/src/internal/parse/syntax/props.ts
@@ -19,10 +19,11 @@ 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
// Skip Vue mustache `{{ }}` syntax
if (content[index + 1] === '{') return undefined
@@ -53,15 +54,27 @@ 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
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
+ ? { name: key }
+ : { name: key, start: valueStart + (quoted ? 1 : 0), end: valueEnd - (quoted ? 1 : 0) }
+ )
+ }
}
}
}
@@ -124,5 +137,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..0f17a13bd
--- /dev/null
+++ b/packages/comark/src/internal/source-ranges.ts
@@ -0,0 +1,183 @@
+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]): number[] | undefined {
+ const positions: number[] = []
+ const limit = context.lines[lines[1]] ?? context.source.length
+ let cursor = Math.max(context.lines[lines[0]], context.cursor)
+ 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[], props: Token): Token | undefined {
+ let index = tokens.indexOf(props) - 1
+ 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
+ return tokens
+ .slice(0, index)
+ .findLast((token) => token.nesting === 1 && token.tag === previous.tag && token.level === previous.level)
+}
+
+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