Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
22 changes: 22 additions & 0 deletions docs/content/5.reference/1.parse.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions packages/comark/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
22 changes: 19 additions & 3 deletions packages/comark/src/internal/parse/syntax/block-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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)
Expand All @@ -27,20 +28,35 @@ 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
}
} else if (remaining) {
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
Expand Down
24 changes: 21 additions & 3 deletions packages/comark/src/internal/parse/syntax/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) }
)
}
}
}
}
Expand All @@ -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 {
Expand Down Expand Up @@ -124,5 +141,6 @@ export function searchProps(content: string, index = 0) {
return {
props,
index,
ranges,
}
}
193 changes: 193 additions & 0 deletions packages/comark/src/internal/source-ranges.ts
Original file line number Diff line number Diff line change
@@ -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<Token, SourceElement>
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<SourceMetadata> {
const environments = new WeakMap<object, SourceContext>()
const documents = new WeakMap<Token[], SourceContext>()
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<StateInline>()
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
},
}
}
3 changes: 2 additions & 1 deletion packages/comark/src/plugins/attributes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
})
Expand Down
Loading
Loading