From abb6432ba9d985e39506d49c9881ba52f08680fb Mon Sep 17 00:00:00 2001 From: Divyansh Singh <40380293+brc-dd@users.noreply.github.com> Date: Sun, 30 Aug 2026 23:40:53 +0530 Subject: [PATCH] feat(markdown): track exact source positions for links and images Every inline rule is wrapped (lazily on first parse, so user config rules are covered) to record the source range it consumed onto the tokens it emits; a terminal core rule converts ranges into {file, line, column} via the parent inline map and env.lineMap, stamped as token.meta.vpLoc together with the decoded as-authored destination (token.meta.vpRaw). Table-cell inline tokens inherit their row's map, and links spliced in by the linkify core rule recover at least their line. Ranges ride on token objects, so plugins that splice children or mutate content don't disturb them. Co-Authored-By: Claude Fable 5 --- .../markdown/plugins/sourcePositions.test.ts | 146 +++++++++ src/node/markdown/markdown.ts | 5 + src/node/markdown/plugins/sourcePositions.ts | 295 ++++++++++++++++++ types/shared.d.ts | 35 +++ 4 files changed, 481 insertions(+) create mode 100644 __tests__/unit/node/markdown/plugins/sourcePositions.test.ts create mode 100644 src/node/markdown/plugins/sourcePositions.ts diff --git a/__tests__/unit/node/markdown/plugins/sourcePositions.test.ts b/__tests__/unit/node/markdown/plugins/sourcePositions.test.ts new file mode 100644 index 00000000..4a070cef --- /dev/null +++ b/__tests__/unit/node/markdown/plugins/sourcePositions.test.ts @@ -0,0 +1,146 @@ +import { + createMarkdownRenderer, + disposeMdItInstance +} from 'node/markdown/markdown' +import type { MarkdownEnv, MarkdownSourceLoc } from 'node/shared' + +async function collect(src: string, env: Partial = {}) { + disposeMdItInstance() + const md = await createMarkdownRenderer('.', { + highlight: (code) => code + }) + const fullEnv = { + path: '/docs/page.md', + relativePath: 'page.md', + cleanUrls: false, + ...env + } as MarkdownEnv + const tokens = md.parse(src, fullEnv) + const found: { type: string; raw?: string; loc?: MarkdownSourceLoc }[] = [] + for (const token of tokens) { + for (const child of token.children ?? []) { + if ( + (child.type === 'link_open' || child.type === 'image') && + child.attrGet('class') !== 'header-anchor' + ) { + found.push({ + type: child.type, + raw: child.meta?.vpRaw, + loc: child.meta?.vpLoc + }) + } + } + } + return found +} + +describe('markdown/plugins/sourcePositions', () => { + test('exact lines and columns across a multi-line paragraph', async () => { + const links = await collect('para one [a](./a)\nand two [b](./b) end\n') + expect(links.map((l) => l.loc)).toEqual([ + { file: '/docs/page.md', line: 1, column: 10 }, + { file: '/docs/page.md', line: 2, column: 9 } + ]) + }) + + test('lines stay file-accurate after frontmatter', async () => { + const links = await collect( + '---\ntitle: x\n---\n\n# H\n\nsee [a](./a)\nand [b](./b)\n' + ) + expect(links.map((l) => l.loc)).toEqual([ + { file: '/docs/page.md', line: 7, column: 5 }, + { file: '/docs/page.md', line: 8, column: 5 } + ]) + }) + + test('table-cell links inherit their exact row line', async () => { + const links = await collect( + '| a | b |\n|---|---|\n| [x](./x) | y |\n| r2 | [z](./z) |\n' + ) + expect(links.map((l) => l.loc?.line)).toEqual([3, 4]) + }) + + test('blockquote columns account for the stripped prefix', async () => { + const links = await collect('> quoted [q](./q)\n> > deep [d](./d)\n') + expect(links.map((l) => l.loc)).toEqual([ + { file: '/docs/page.md', line: 1, column: 10 }, + { file: '/docs/page.md', line: 2, column: 10 } + ]) + }) + + test('multi-line code span before a link does not shift it', async () => { + // code-span newlines become spaces in token content; offset capture is + // unaffected, the softbreak-walking approach would be off by one here + const links = await collect('a `code\nspan` then [x](./x)\nnext [y](./y)\n') + expect(links.map((l) => l.loc?.line)).toEqual([2, 3]) + }) + + test('list continuation lines and nested items', async () => { + const links = await collect('- item\n cont [i](./i)\n- [j](./j)\n') + expect(links.map((l) => l.loc)).toEqual([ + { file: '/docs/page.md', line: 2, column: 8 }, + { file: '/docs/page.md', line: 3, column: 3 } + ]) + }) + + test('reference links report the usage site', async () => { + const links = await collect('start\n\nuse [ref][r] here\n\n[r]: ./target\n') + expect(links.map((l) => l.loc?.line)).toEqual([3]) + }) + + test('images and autolinks carry positions and raw destinations', async () => { + const found = await collect( + 'pic ![alt](./img.png) and \n' + ) + expect(found).toEqual([ + { + type: 'image', + raw: './img.png', + loc: { file: '/docs/page.md', line: 1, column: 5 } + }, + { + type: 'link_open', + raw: 'http://localhost:5173/x', + loc: { file: '/docs/page.md', line: 1, column: 27 } + } + ]) + }) + + test('linkified bare URLs after breaks get at least the line', async () => { + const links = await collect('one\ntwo http://localhost:5173/a end\n') + expect(links[0].loc?.line).toBe(2) + }) + + test('raw destination is decoded and keeps hash/query', async () => { + const links = await collect('[c](./中文.md#über?q=1)\n') + expect(links[0].raw).toBe('./中文.md#über?q=1') + expect(links[0].loc).toEqual({ file: '/docs/page.md', line: 1, column: 1 }) + }) + + test('emphasis, attrs suffixes and emoji before links do not break offsets', async () => { + const links = await collect( + '**bold** :smile: [a](./a){.cls}\nplain [b](./b)\n' + ) + expect(links.map((l) => l.loc)).toEqual([ + { file: '/docs/page.md', line: 1, column: 18 }, + { file: '/docs/page.md', line: 2, column: 7 } + ]) + }) + + test('header anchors get no synthetic position', async () => { + disposeMdItInstance() + const md = await createMarkdownRenderer('.', { + highlight: (code) => code + }) + const tokens = md.parse('# Heading\n', { + path: '/docs/page.md', + relativePath: 'page.md', + cleanUrls: false + } as MarkdownEnv) + const permalink = tokens + .flatMap((t) => t.children ?? []) + .find((t) => t.attrGet('class') === 'header-anchor') + expect(permalink).toBeDefined() + expect(permalink!.meta?.vpLoc).toBeUndefined() + }) +}) diff --git a/src/node/markdown/markdown.ts b/src/node/markdown/markdown.ts index f13e7949..4a75343d 100644 --- a/src/node/markdown/markdown.ts +++ b/src/node/markdown/markdown.ts @@ -64,6 +64,7 @@ import { snippetPlugin, type Options as SnippetPluginOptions } from './plugins/snippet' +import { sourcePositionsPlugin } from './plugins/sourcePositions' import { tablePlugin } from './plugins/table' export type { Header } from '../shared' @@ -581,6 +582,10 @@ export async function createMarkdownRenderer( eagerFrontmatterInterpolationPlugin(md) } + // inline rules are wrapped lazily on first parse, so rules registered by + // the `config` hook below are position-tracked too + sourcePositionsPlugin(md) + // apply user config if (options.config) { await options.config(md) diff --git a/src/node/markdown/plugins/sourcePositions.ts b/src/node/markdown/plugins/sourcePositions.ts new file mode 100644 index 00000000..d3608669 --- /dev/null +++ b/src/node/markdown/plugins/sourcePositions.ts @@ -0,0 +1,295 @@ +import type { MarkdownItAsync } from 'markdown-it-async' +import type MarkdownIt from 'markdown-it' +import type StateCore from 'markdown-it/lib/rules_core/state_core.mjs' +import type Token from 'markdown-it/lib/token.mjs' + +import type { MarkdownEnv, MarkdownSourceLoc } from '../../shared' + +// consumed source range of an inline token, [start, end) offsets into the +// inline parser's src. Symbols ride on the token objects themselves, so they +// survive plugins that splice children arrays or mutate `inline.content` +// (attrs, emoji, tasklist, github alerts) — index- or content-based +// reconstruction would not. +const RANGE = Symbol('vpRange') +// line-relative position, precomputed while the pristine inline src is still +// available (later core rules mutate token content) +const POS = Symbol('vpPos') +const INSTALLED = Symbol('vpPositionsInstalled') + +interface InlinePos { + /** line breaks in the inline src before the range start */ + dLine: number + /** offset within that inline-src line; -1 when only the line is known */ + startInLine: number + /** that inline-src line's text, for column re-alignment against the raw source */ + lineText: string +} + +type PositionedToken = Token & { + [RANGE]?: [number, number] + [POS]?: InlinePos +} + +/** + * Tracks exact source positions for links and images. markdown-it only maps + * block-level tokens to lines, so on its own every link in a multi-line + * paragraph reports the paragraph's first line, and table-cell links (whose + * inline tokens carry no map at all) report nothing. + * + * Mechanism: every inline rule is wrapped (lazily, on first parse, so rules + * registered by user `config` hooks are covered too) to record the source + * range it consumed onto the tokens it emitted. A final core rule converts + * ranges into `{file, line, column}` via the parent inline token's map and + * `env.lineMap`, and stamps the result as `token.meta.vpLoc` on `link_open` + * and `image` tokens, along with the decoded pre-normalization destination + * as `token.meta.vpRaw`. + */ +export function sourcePositionsPlugin(md: MarkdownItAsync): void { + md.core.ruler.before('normalize', 'vp_inline_positions', (state) => { + const inline = state.md.inline as unknown as Record + if (!inline[INSTALLED]) { + inline[INSTALLED] = true + installInlineWrappers(state.md) + installParseWrapper(state.md) + } + }) + + // markdown-it's table rule leaves cell inline tokens without a map; the + // enclosing row token has one, so cells inherit their exact row line + md.core.ruler.after('block', 'vp_table_cell_maps', (state) => { + let rowMap: [number, number] | null = null + for (const token of state.tokens) { + if (token.type === 'tr_open') rowMap = token.map + else if (token.type === 'tr_close') rowMap = null + else if (rowMap && token.type === 'inline' && !token.map) + token.map = [rowMap[0], rowMap[1]] + } + }) + + // the linkify *core* rule splices brand-new link tokens into children after + // inline parsing; they carry no range, so recover at least the line from + // the break tokens before them + md.core.ruler.after('linkify', 'vp_linkify_positions', (state) => { + if (!state.md.options.linkify) return + for (const token of state.tokens) { + if (token.type !== 'inline' || !token.children) continue + let breaks = 0 + for (const child of token.children as PositionedToken[]) { + if ( + child.type === 'link_open' && + child.markup === 'linkify' && + !child[RANGE] + ) { + child[POS] = { dLine: breaks, startInLine: -1, lineText: '' } + } else if (child.type === 'softbreak' || child.type === 'hardbreak') { + breaks++ + } else if (child.content) { + breaks += countLineBreaks(child.content) + } + } + } + }) + + md.core.ruler.push('vp_source_locs', sourceLocs) +} + +function installInlineWrappers(md: MarkdownIt): void { + interface RuleEntry { + name: string + enabled: boolean + fn: ((state: any, silent: boolean) => boolean) & { [INSTALLED]?: boolean } + alt: string[] + } + const ruler = md.inline.ruler as unknown as { __rules__: RuleEntry[] } + + for (const rule of [...ruler.__rules__]) { + const orig = rule.fn + if (orig[INSTALLED]) continue + const name = rule.name + + const wrapped = (state: any, silent: boolean): boolean => { + if (silent) return orig(state, silent) + + const startIdx: number = state.tokens.length + // state.push() flushes pending text first, so the first token a rule + // appears to emit is often the text run *before* the construct — it + // must not receive this rule's range (the gap-fill pass owns it) + const hadPending: boolean = state.pending.length > 0 + let start: number = state.pos + if (!orig(state, false)) return false + + // the linkify inline rule is entered at the "://", with the scheme + // already consumed into pending — back-scan to recover it + if (name === 'linkify') { + while (start > 0 && /[a-z0-9.+-]/i.test(state.src[start - 1])) start-- + } + + const end: number = state.pos + for (let i = startIdx; i < state.tokens.length; i++) { + const token = state.tokens[i] as PositionedToken + // nested tokenization (link labels) has already stamped inner tokens + // with more precise ranges + if (token[RANGE]) continue + if (i === startIdx && hadPending && token.type === 'text') continue + token[RANGE] = [start, end] + } + return true + } + wrapped[INSTALLED] = true + + md.inline.ruler.at(name, wrapped, { alt: rule.alt }) + } +} + +function installParseWrapper(md: MarkdownIt): void { + const inline = md.inline + const parse = inline.parse.bind(inline) + + inline.parse = (src, mdIt, env, outTokens: PositionedToken[]) => { + parse(src, mdIt, env, outTokens) + + // gap-fill: text runs flushed from pending have no range; they span from + // the previous stamped range to the next one. Runs after ruler2, so + // emphasis retyping and fragment joining are already done. + let prevEnd = 0 + for (let i = 0; i < outTokens.length; i++) { + let range = outTokens[i][RANGE] + if (!range) { + let next = src.length + for (let j = i + 1; j < outTokens.length; j++) { + const r = outTokens[j][RANGE] + if (r) { + next = r[0] + break + } + } + range = outTokens[i][RANGE] = [prevEnd, next] + } + prevEnd = range[1] + } + + // precompute line-relative positions for the tokens we resolve later, + // while src is still pristine (core rules mutate token content) + let lineStarts: number[] | undefined + for (const token of outTokens) { + if (token.type !== 'link_open' && token.type !== 'image') continue + const range = token[RANGE] + if (!range) continue + lineStarts ??= makeLineStarts(src) + const line = findLine(lineStarts, range[0]) + const lineStart = lineStarts[line] + const lineEnd = + line + 1 < lineStarts.length ? lineStarts[line + 1] - 1 : src.length + token[POS] = { + dLine: line, + startInLine: range[0] - lineStart, + lineText: src.slice(lineStart, lineEnd) + } + } + } +} + +function sourceLocs(state: StateCore): void { + const env = state.env as MarkdownEnv + let srcLineStarts: number[] | undefined + + for (const token of state.tokens) { + if (token.type !== 'inline' || !token.children || !token.map) continue + + for (const child of token.children as PositionedToken[]) { + const isImage = child.type === 'image' + if (child.type !== 'link_open' && !isImage) continue + + // no captured position means the token was injected synthetically by a + // core rule (e.g. anchor permalinks) — fabricating a location for it + // would be worse than none + const pos = child[POS] + if (!pos) continue + + const line = token.map[0] + pos.dLine + const loc = resolveLoc(env, line) + + // block parsing only ever strips a prefix per line, so the inline line + // is a suffix of the raw source line; re-align to get the true column + if (pos.startInLine >= 0) { + srcLineStarts ??= makeLineStarts(state.src) + const raw = getLine(state.src, srcLineStarts, line) + if (raw !== undefined) { + if (raw.endsWith(pos.lineText)) { + loc.column = raw.length - pos.lineText.length + pos.startInLine + 1 + } else { + // over-indented content gets spaces prepended instead — fall + // back to searching, and omit the column on ambiguity + const at = raw.indexOf(pos.lineText) + if (at >= 0) loc.column = at + pos.startInLine + 1 + } + } + } + + child.meta ??= {} + child.meta.vpLoc = loc + // the destination as authored (before rebasing and href normalization + // mutate the attr at render time), for dead-link reporting + const dest = child.attrGet(isImage ? 'src' : 'href') + if (dest != null) child.meta.vpRaw = safeDecodeURI(dest) + } + } +} + +function resolveLoc(env: MarkdownEnv, line: number): MarkdownSourceLoc { + const resolved = env.lineMap?.resolve(line) + if (resolved) return { file: resolved.file, line: resolved.line + 1 } + return { file: env.realPath ?? env.path, line: line + 1 } +} + +function safeDecodeURI(str: string): string { + try { + return decodeURI(str) + } catch { + return str + } +} + +function countLineBreaks(str: string): number { + let n = 0 + let i = str.indexOf('\n') + while (i !== -1) { + n++ + i = str.indexOf('\n', i + 1) + } + return n +} + +function makeLineStarts(src: string): number[] { + const starts = [0] + let i = src.indexOf('\n') + while (i !== -1) { + starts.push(i + 1) + i = src.indexOf('\n', i + 1) + } + return starts +} + +/** index of the line containing `offset`, given sorted line-start offsets */ +function findLine(lineStarts: number[], offset: number): number { + let lo = 0 + let hi = lineStarts.length + while (lo < hi) { + const mid = (lo + hi) >>> 1 + if (lineStarts[mid] <= offset) lo = mid + 1 + else hi = mid + } + return Math.max(0, lo - 1) +} + +function getLine( + src: string, + lineStarts: number[], + line: number +): string | undefined { + if (line < 0 || line >= lineStarts.length) return undefined + const start = lineStarts[line] + const end = + line + 1 < lineStarts.length ? lineStarts[line + 1] - 1 : src.length + return src.slice(start, end) +} diff --git a/types/shared.d.ts b/types/shared.d.ts index a35663f9..18e2a726 100644 --- a/types/shared.d.ts +++ b/types/shared.d.ts @@ -607,6 +607,13 @@ export interface MarkdownEnv { * include processing is enabled. */ src?: string + /** + * Maps lines of the rendered source (`src`) back to the physical files + * they came from, set by the include plugin. Token maps stay in rendered + * source coordinates; every position that leaves the markdown layer must + * be translated through this. + */ + lineMap?: MarkdownLineMap /** * The absolute path of the actual source file on disk: the route template * for dynamic routes, or the original file when rewrites are in use. @@ -624,3 +631,31 @@ export interface MarkdownEnv { */ eagerInterpolations?: { expression: string; value: string }[] } + +/** + * A position in a source file, in editor coordinates. + */ +export interface MarkdownSourceLoc { + /** + * Absolute path of the physical file containing the construct — with + * includes, the included file rather than the page. Absent when the render + * has no backing file. + */ + file?: string + /** + * 1-based line in `file`. + */ + line: number + /** + * 1-based column, present when it could be determined exactly. + */ + column?: number +} + +/** + * Maps 0-based lines of the rendered markdown source (`MarkdownEnv.src`) to + * the physical file and 0-based line they came from. + */ +export interface MarkdownLineMap { + resolve(line: number): { file: string; line: number } +}