diff --git a/CHANGELOG.md b/CHANGELOG.md index c635686..09a2d08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Added + +- 新增 `MarkdownSourceMap.getCodeSourceInfo()`。它返回 block `code` 节点的源码结构:fenced(含 `openingFence` 与 `infoInsertPoint`)或 indented。parser 在解析阶段记录该结构,consumer 不再重新扫描 Markdown(#141)。 + ## 0.3.2 ### Refactored diff --git a/README.md b/README.md index 70c6195..280a5b2 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,7 @@ sourceMap.getRaw(textNode); // 'A&B' - `getSourceRange(node, valueStart, valueEnd)` 的索引与 JavaScript 字符串下标一致,范围均为半开区间 `[start, end)`;当前支持 `text.value`、`inlineCode.value` 与 block `code.value`。`getFieldSourceRange(node, 'url', valueStart, valueEnd)` 当前支持 inline resource link 与 definition 的 destination;autolink 和 GFM autolink literal 暂不包含。 - `getValueSourceIndex(node).sourceOffsetAt(valueIndex)` 返回原始 Markdown 的绝对 offset。该接口为顺序边界查询保留游标。反向和随机查询使用二分查找。 +- `getCodeSourceInfo(node)` 返回 block `code` 节点的源码结构。fenced code 返回 `{ kind: 'fenced', openingFence, infoInsertPoint }`,indented code 返回 `{ kind: 'indented' }`。`openingFence` 是围栏序列(反引号或波浪线)的范围;`infoInsertPoint` 是 opening fence 行末、行结束符之前的位置。当 `node.lang` 缺失时,在该 offset 插入 info string 即可补上语言;该位置不会落在 CRLF 的行结束符中间。parser 在解析阶段记录这些位置,不重新扫描 Markdown。 - 映射覆盖受支持节点的整个 `value`,segment 之间无空洞、无重叠。 - 当 value 对应的原始源码连续时,`getSourceRange(node, 0, node.value.length)` 覆盖该节点 value 的完整原始来源范围。当 blockquote marker、list indentation 等容器语法将来源分隔开时,单个连续的 `ParsedPosition` 无法准确表达该范围,`getSourceRange()` 会抛出 `RangeError`。 - 错误分为三条路径,专属错误均继承 `RangeError`(现有 `catch (RangeError)` 不受影响),并带稳定的 `code` 字段;当跨边界传递时(如跨 CJS/ESM 实例、重复安装、worker 边界),只要错误被显式序列化且 `code` 字段被保留,即可用 `code` 而非 `instanceof` 判断(类本身无法保证任意序列化机制一定保留自定义属性): diff --git a/__tests__/source-map/code-source-info.spec.ts b/__tests__/source-map/code-source-info.spec.ts new file mode 100644 index 0000000..6c2e869 --- /dev/null +++ b/__tests__/source-map/code-source-info.spec.ts @@ -0,0 +1,259 @@ +import { + SourceMapUnavailableError, + parseMdWithSourceMap, +} from '../helpers'; + +/** Collect matching nodes in document order. */ +function nodesOfType(root: any, type: string): any[] { + const out: any[] = []; + (function walk(n: any) { + if (n.type === type) out.push(n); + for (const c of n.children || []) walk(c); + })(root); + return out; +} + +function parse(md: string): { ast: any; sourceMap: any; node: any } { + const { ast, sourceMap } = parseMdWithSourceMap(md); + return { ast, sourceMap, node: nodesOfType(ast, 'code')[0] }; +} + +function insertAt(md: string, offset: number, text: string): string { + return md.slice(0, offset) + text + md.slice(offset); +} + +/** Reparse `md` and return the first code node's lang and value. */ +function reparseCode(md: string): { lang: string | null; value: string } { + const { ast } = parseMdWithSourceMap(md); + const node = nodesOfType(ast, 'code')[0]; + return { lang: node.lang ?? null, value: node.value }; +} + +/** + * Apply the `no-empty-code-lang` style fix through the public query. Insert + * `plain` only when the node has no lang and the block is fenced. + */ +function fixEmptyCodeLang(md: string): string { + const { sourceMap, node } = parse(md); + const info = sourceMap.getCodeSourceInfo(node); + if (node.lang || info.kind !== 'fenced') + return md; + return insertAt(md, info.infoInsertPoint.offset, 'plain'); +} + +describe('sourceMap.getCodeSourceInfo', () => { + describe('fenced code', () => { + test.each([ + ['```\ncode\n```', 3, '```'], + ['~~~\ncode\n~~~', 3, '~~~'], + ['````\ncode\n````', 4, '````'], + ['~~~~\ncode\n~~~~', 4, '~~~~'], + ['`````\ncode\n`````', 5, '`````'], + ])( + 'reports the opening fence and info insert point for %p', + (md, fenceLength, fence) => { + const { sourceMap, node } = parse(md); + const info = sourceMap.getCodeSourceInfo(node); + + expect(info.kind).toBe('fenced'); + expect(info.openingFence.start.offset).toBe(node.position.start.offset); + expect(info.openingFence.end.offset).toBe(fenceLength); + expect( + md.slice(info.openingFence.start.offset, info.openingFence.end.offset), + ).toBe(fence); + expect(info.infoInsertPoint.offset).toBe(fenceLength); + }, + ); + + test('places infoInsertPoint at the end of the line, not after the fence', () => { + const md = '```js\ncode\n```'; + const { sourceMap, node } = parse(md); + const info = sourceMap.getCodeSourceInfo(node); + + expect(info.kind).toBe('fenced'); + expect(info.openingFence.start.offset).toBe(0); + expect(info.openingFence.end.offset).toBe(3); + // The line is "```js", so the insert point is after "js". + expect(info.infoInsertPoint).toEqual({ line: 1, column: 6, offset: 5 }); + }); + + test('keeps infoInsertPoint before trailing whitespace line ending', () => { + const md = '``` \ncode\n```'; + const { sourceMap, node } = parse(md); + const info = sourceMap.getCodeSourceInfo(node); + + expect(info.kind).toBe('fenced'); + expect(info.infoInsertPoint).toEqual({ line: 1, column: 7, offset: 6 }); + }); + + test.each([ + ['```\ncode\n```', 3], + ['```\r\ncode\r\n```', 3], + ['```\rcode\r```', 3], + ['``` \ncode\n```', 6], + ['``` \r\ncode\r\n```', 6], + ['``` \rcode\r```', 6], + ])( + 'positions infoInsertPoint before the line ending for %p', + (md, insertOffset) => { + const { sourceMap, node } = parse(md); + const info = sourceMap.getCodeSourceInfo(node); + + expect(info.kind).toBe('fenced'); + expect(info.infoInsertPoint.offset).toBe(insertOffset); + // Never inside a CRLF pair: the point is a line ending or end of input. + const at = md[insertOffset]; + expect(at === undefined || at === '\n' || at === '\r').toBe(true); + }, + ); + + test('handles a fenced block in a blockquote', () => { + const md = '> ```\n> code\n> ```'; + const { sourceMap, node } = parse(md); + const info = sourceMap.getCodeSourceInfo(node); + + expect(info.kind).toBe('fenced'); + expect(md.slice(info.openingFence.start.offset, info.openingFence.end.offset)) + .toBe('```'); + expect(info.infoInsertPoint.offset).toBe(md.indexOf('```') + 3); + + const fixed = fixEmptyCodeLang(md); + expect(reparseCode(fixed)).toEqual({ lang: 'plain', value: 'code' }); + }); + + test('handles a fenced block in a blockquote with a tab prefix', () => { + const md = '> \t```\n> \tcode\n> \t```'; + const { sourceMap, node } = parse(md); + const info = sourceMap.getCodeSourceInfo(node); + + expect(info.kind).toBe('fenced'); + expect(info.infoInsertPoint.offset).toBe(md.indexOf('```') + 3); + const fixed = fixEmptyCodeLang(md); + expect(reparseCode(fixed)).toEqual({ lang: 'plain', value: 'code' }); + }); + + test('handles a fenced block nested in a list', () => { + const md = '- item\n ```\n value\n ```'; + const { sourceMap, node } = parse(md); + const info = sourceMap.getCodeSourceInfo(node); + + expect(info.kind).toBe('fenced'); + expect(md.slice(info.openingFence.start.offset, info.openingFence.end.offset)) + .toBe('```'); + expect(info.infoInsertPoint.offset).toBe(md.indexOf('```') + 3); + + const fixed = fixEmptyCodeLang(md); + expect(reparseCode(fixed)).toEqual({ lang: 'plain', value: 'value' }); + }); + + test('handles a fenced block in a list with tab indentation', () => { + const md = '- item\n\t```\n code\n\t```'; + const { sourceMap, node } = parse(md); + const info = sourceMap.getCodeSourceInfo(node); + + expect(info.kind).toBe('fenced'); + expect(info.infoInsertPoint.offset).toBe(md.indexOf('```') + 3); + const fixed = fixEmptyCodeLang(md); + expect(reparseCode(fixed)).toEqual({ lang: 'plain', value: 'code' }); + }); + + test.each([ + ['```\n```', 3], + ['```\n\n```', 3], + ['```\r\n```', 3], + ])('handles an empty fenced block: %p', (md, insertOffset) => { + const { sourceMap, node } = parse(md); + const info = sourceMap.getCodeSourceInfo(node); + + expect(node.value).toBe(''); + expect(info.kind).toBe('fenced'); + expect(info.infoInsertPoint.offset).toBe(insertOffset); + + const fixed = fixEmptyCodeLang(md); + expect(reparseCode(fixed)).toEqual({ lang: 'plain', value: '' }); + }); + + test.each([ + ['```\ncode', 3], + ['```', 3], + ['``` ', 6], + ['```\r\ncode', 3], + ['> ```', 5], + ['> ```\n', 5], + ['> ```\r', 5], + ['> ```\r\n', 5], + ])('handles an unclosed fenced block: %p', (md, insertOffset) => { + const { sourceMap, node } = parse(md); + const info = sourceMap.getCodeSourceInfo(node); + + expect(info.kind).toBe('fenced'); + expect(info.infoInsertPoint.offset).toBe(insertOffset); + if (insertOffset >= md.length) + expect(md[insertOffset]).toBeUndefined(); + else + expect(['\n', '\r']).toContain(md[insertOffset]); + + const fixed = fixEmptyCodeLang(md); + expect(reparseCode(fixed).lang).toBe('plain'); + }); + + test('is stable when applied repeatedly (fixer convergence)', () => { + const md = '````\ncode\n````'; + let current = md; + for (let i = 0; i < 5; i++) + current = fixEmptyCodeLang(current); + + expect(reparseCode(current)).toEqual({ lang: 'plain', value: 'code' }); + expect(current).toBe('````plain\ncode\n````'); + }); + }); + + describe('indented code', () => { + test.each([ + ' code\n', + '\tcode\n', + ' a\n\n b\n', + '> indented\n> code', + '- Foo\n\n bar\n baz', + ])('reports indented structure for %p', (md) => { + const { sourceMap, node } = parse(md); + expect(sourceMap.getCodeSourceInfo(node)).toEqual({ kind: 'indented' }); + }); + + test('does not apply an info-string fix to indented code', () => { + const md = ' const a = 1;\n'; + expect(fixEmptyCodeLang(md)).toBe(md); + }); + }); + + describe('errors', () => { + test('rejects a node from another document', () => { + const a = parse('```\ncode\n```'); + const b = parse('```\ncode\n```'); + expect(() => a.sourceMap.getCodeSourceInfo(b.node)).toThrow( + SourceMapUnavailableError, + ); + }); + + test('rejects a node that was added after parsing', () => { + const { ast, sourceMap } = parseMdWithSourceMap('```\ncode\n```'); + const generated = { type: 'code', lang: null, meta: null, value: '' }; + ast.children.push(generated); + expect(() => sourceMap.getCodeSourceInfo(generated as any)).toThrow( + SourceMapUnavailableError, + ); + }); + + test('rejects an owned non-code node', () => { + const { ast, sourceMap } = parseMdWithSourceMap('paragraph'); + const paragraph = ast.children[0]; + expect(paragraph.type).toBe('paragraph'); + // The node is owned (it came from the parse), but no code structure was + // recorded for it. This must reach the missing-info branch, not the + // ownership branch. + expect(() => sourceMap.getCodeSourceInfo(paragraph as any)).toThrow( + /no source structure is available/, + ); + }); + }); +}); diff --git a/__tests__/types/package-exports.cts b/__tests__/types/package-exports.cts index dd473cc..b57d00c 100644 --- a/__tests__/types/package-exports.cts +++ b/__tests__/types/package-exports.cts @@ -33,6 +33,26 @@ const urlRange = doc.sourceMap.getFieldSourceRange( 0, 1, ); +const codeNode = doc.ast.children[0] as parser.MarkdownCodeNode; +const codeSourceInfo: parser.CodeSourceInfo = + doc.sourceMap.getCodeSourceInfo(codeNode); + +// The three new public types must stay exported. +type ExportedCodeSourceInfo = parser.CodeSourceInfo; +type ExportedFencedCodeSourceInfo = parser.FencedCodeSourceInfo; +type ExportedIndentedCodeSourceInfo = parser.IndentedCodeSourceInfo; + +// A consumer narrows the result with `kind`. +if (codeSourceInfo.kind === 'fenced') { + const fenceStart: number = codeSourceInfo.openingFence.start.offset; + const infoOffset: number = codeSourceInfo.infoInsertPoint.offset; + void fenceStart; + void infoOffset; +} +else { + const indentedKind: 'indented' = codeSourceInfo.kind; + void indentedKind; +} const consistency = new parser.SourceMapConsistencyError(); const asRangeError: RangeError = new parser.SourceMapUnavailableError(); const isSourceMapError: boolean = consistency instanceof parser.SourceMapError; @@ -45,6 +65,8 @@ void codeRange; void sourceIndex; void sourceOffset; void urlRange; +void codeNode; +void codeSourceInfo; void codeRaw; void consistency; void asRangeError; diff --git a/__tests__/types/package-exports.mts b/__tests__/types/package-exports.mts index f90fa92..1f76734 100644 --- a/__tests__/types/package-exports.mts +++ b/__tests__/types/package-exports.mts @@ -7,6 +7,10 @@ import { SourceMapConsistencyError, SourceMapUnavailableError, type SourceMapErrorCode, + type CodeSourceInfo, + type FencedCodeSourceInfo, + type IndentedCodeSourceInfo, + type MarkdownCodeNode, type ParsedMarkdownDocument, type MarkdownLinkNode, type MarkdownTextNode, @@ -37,6 +41,25 @@ const urlRange = doc.sourceMap.getFieldSourceRange( 0, 1, ); +const codeNode = doc.ast.children[0] as MarkdownCodeNode; +const codeSourceInfo: CodeSourceInfo = doc.sourceMap.getCodeSourceInfo(codeNode); + +// The three new public types must stay exported. +type ExportedCodeSourceInfo = CodeSourceInfo; +type ExportedFencedCodeSourceInfo = FencedCodeSourceInfo; +type ExportedIndentedCodeSourceInfo = IndentedCodeSourceInfo; + +// A consumer narrows the result with `kind`. +if (codeSourceInfo.kind === 'fenced') { + const fenceStart: number = codeSourceInfo.openingFence.start.offset; + const infoOffset: number = codeSourceInfo.infoInsertPoint.offset; + void fenceStart; + void infoOffset; +} +else { + const indentedKind: 'indented' = codeSourceInfo.kind; + void indentedKind; +} // @ts-expect-error segment implementation details are intentionally internal. type HiddenSegment = import('@lint-md/parser').MarkdownSourceMapSegment; @@ -59,6 +82,8 @@ void doc; void sourceIndex; void sourceOffset; void urlRange; +void codeNode; +void codeSourceInfo; void consistency; void unavailable; void asRangeError; diff --git a/etc/parser.api.md b/etc/parser.api.md index e0e5ac9..a8870dd 100644 --- a/etc/parser.api.md +++ b/etc/parser.api.md @@ -20,6 +20,21 @@ import { Text as Text_2 } from 'mdast'; export { Code } +// @public +export type CodeSourceInfo = FencedCodeSourceInfo | IndentedCodeSourceInfo; + +// @public +export interface FencedCodeSourceInfo { + infoInsertPoint: ParsedPoint; + kind: 'fenced'; + openingFence: ParsedPosition; +} + +// @public +export interface IndentedCodeSourceInfo { + kind: 'indented'; +} + export { Link } export { ListItem } @@ -85,6 +100,7 @@ export type MarkdownRoot = Root; // @public export interface MarkdownSourceMap { + getCodeSourceInfo(node: MarkdownCodeNode): CodeSourceInfo; getFieldSourceRange(node: MarkdownLinkNode | MarkdownDefinitionNode, field: 'url', valueStart: number, valueEnd: number): ParsedPosition; getRaw(node: MarkdownNode | MarkdownTextNode | MarkdownInlineCodeNode | MarkdownCodeNode | MarkdownLinkNode | MarkdownDefinitionNode): string; getSourceRange(node: MarkdownTextNode | MarkdownInlineCodeNode | MarkdownCodeNode, valueStart: number, valueEnd: number): ParsedPosition; diff --git a/scripts/bench-parse.mjs b/scripts/bench-parse.mjs index 06c1285..da497cb 100644 --- a/scripts/bench-parse.mjs +++ b/scripts/bench-parse.mjs @@ -115,6 +115,7 @@ function makeState(source) { inlineCodeSegments: new WeakMap(), codeSegments: new WeakMap(), emptyCodeOffsets: new WeakMap(), + codeSourceInfos: new WeakMap(), urlSegments: new WeakMap(), emptyUrlOffsets: new WeakMap(), }; diff --git a/src/index.ts b/src/index.ts index afa1c9c..37528cf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,9 @@ export { export * from './types'; export type { + CodeSourceInfo, + FencedCodeSourceInfo, + IndentedCodeSourceInfo, MarkdownSourceMap, MarkdownValueSourceIndex, ParsedMarkdownDocument, diff --git a/src/source-map/build-source-map.ts b/src/source-map/build-source-map.ts index c133418..7048893 100644 --- a/src/source-map/build-source-map.ts +++ b/src/source-map/build-source-map.ts @@ -17,7 +17,9 @@ import { SourceMapUnavailableError, } from './errors'; import { recordingExtension } from './recording-extension'; +import type { RecordedCodeSourceInfo } from './recording-extension'; import type { + CodeSourceInfo, MarkdownSourceMap, MarkdownSourceMapSegment, MarkdownValueSourceIndex, @@ -42,6 +44,8 @@ interface RecordingState { codeSegments: WeakMap /** code node -> source point for an empty value. */ emptyCodeOffsets: WeakMap + /** code node -> parser-recorded fenced / indented structure. */ + codeSourceInfos: WeakMap /** link / definition node -> normalized URL segments. */ urlSegments: WeakMap /** link / definition node -> source point for an empty URL. */ @@ -336,6 +340,7 @@ export const parseMdWithSourceMap = (md: string): ParsedMarkdownDocument => { inlineCodeSegments: new WeakMap(), codeSegments: new WeakMap(), emptyCodeOffsets: new WeakMap(), + codeSourceInfos: new WeakMap(), urlSegments: new WeakMap(), emptyUrlOffsets: new WeakMap(), }; @@ -610,6 +615,35 @@ export const parseMdWithSourceMap = (md: string): ParsedMarkdownDocument => { }); }, + getCodeSourceInfo(node: MarkdownCodeNode): CodeSourceInfo { + if (!owned.has(node as object)) { + throw new SourceMapUnavailableError( + 'getCodeSourceInfo: the given node does not belong to this document; ' + + 'pass a node from the tree returned by the same ' + + 'parseMdWithSourceMap() call', + ); + } + const info = state.codeSourceInfos.get(node as object); + if (!info) { + throw new SourceMapUnavailableError( + 'getCodeSourceInfo: no source structure is available for the given ' + + 'node; it was generated, added after parsing, or is not a block ' + + 'code node', + ); + } + if (info.kind === 'indented') + return { kind: 'indented' }; + lineStarts ??= computeLineStarts(md); + return { + kind: 'fenced', + openingFence: { + start: pointAtOffset(lineStarts, md, info.openingFenceStart), + end: pointAtOffset(lineStarts, md, info.openingFenceEnd), + }, + infoInsertPoint: pointAtOffset(lineStarts, md, info.infoInsertPoint), + }; + }, + getFieldSourceRange( node: MarkdownLinkNode | MarkdownDefinitionNode, field: 'url', diff --git a/src/source-map/recording-extension.ts b/src/source-map/recording-extension.ts index 45ec389..8e204a2 100644 --- a/src/source-map/recording-extension.ts +++ b/src/source-map/recording-extension.ts @@ -9,10 +9,37 @@ interface RecordingState { inlineCodeSegments: WeakMap codeSegments: WeakMap emptyCodeOffsets: WeakMap + codeSourceInfos: WeakMap urlSegments: WeakMap emptyUrlOffsets: WeakMap } +/** + * Parser-recorded structure of a fenced `code` node. All offsets are absolute + * UTF-16 offsets into the original Markdown. + * + * @internal + */ +export interface RecordedFencedCodeSourceInfo { + kind: 'fenced' + /** Offset of the first character of the opening fence sequence. */ + openingFenceStart: number + /** Offset just after the opening fence sequence. */ + openingFenceEnd: number + /** Offset at the end of the opening fence line, before its line ending. */ + infoInsertPoint: number +} + +/** Parser-recorded structure of an indented `code` node. @internal */ +export interface RecordedIndentedCodeSourceInfo { + kind: 'indented' +} + +/** @internal */ +export type RecordedCodeSourceInfo = + | RecordedFencedCodeSourceInfo + | RecordedIndentedCodeSourceInfo; + interface SegmentMetadata { sourceStart: number sourceEnd: number @@ -40,6 +67,9 @@ interface FencedCodeRecording extends CodeValueRecording { sawContentAfterOpening: boolean sawClosingFence: boolean openingIndent: number + openingFenceStart?: number + openingFenceEnd?: number + infoInsertPoint?: number closingFenceStart?: number } @@ -465,6 +495,19 @@ export function recordingExtension(state: RecordingState) { }; }; + const onentercodefencedfencesequence = function ( + this: CompileContext, + token: any, + ) { + // The parser enters the opening fence sequence before it sets + // `flowCodeInside`. So this is the only fence sequence seen while that flag + // is false. The token spans the backtick or tilde run exactly. + if (fencedCodeRecording && !this.getData('flowCodeInside')) { + fencedCodeRecording.openingFenceStart = token.start.offset; + fencedCodeRecording.openingFenceEnd = token.end.offset; + } + }; + const onentercodefencedfence = function ( this: CompileContext, token: any, @@ -481,8 +524,14 @@ export function recordingExtension(state: RecordingState) { } this.buffer(); this.setData('flowCodeInside', true); - if (fencedCodeRecording) + if (fencedCodeRecording) { + // The opening fence token spans the opening fence line. It ends before + // the line ending. That end is where an inserted info string belongs. + // Reuse it for the empty-value boundary too; a later line ending may + // move `emptyOffset` forward. fencedCodeRecording.emptyOffset = token.end.offset; + fencedCodeRecording.infoInsertPoint = token.end.offset; + } }; const onexitcodefencedfencesequence = function ( @@ -540,6 +589,18 @@ export function recordingExtension(state: RecordingState) { state.codeSegments.set(node, segments); state.emptyCodeOffsets.set(node, recording.emptyOffset); } + if ( + recording.openingFenceStart !== undefined + && recording.openingFenceEnd !== undefined + && recording.infoInsertPoint !== undefined + ) { + state.codeSourceInfos.set(node, { + kind: 'fenced', + openingFenceStart: recording.openingFenceStart, + openingFenceEnd: recording.openingFenceEnd, + infoInsertPoint: recording.infoInsertPoint, + }); + } if ( value.length === 0 && !recording.sawOpeningLineEnding @@ -583,6 +644,7 @@ export function recordingExtension(state: RecordingState) { ); if (segments) state.codeSegments.set(node, segments); + state.codeSourceInfos.set(node, { kind: 'indented' }); this.exit(token); }; @@ -668,6 +730,7 @@ export function recordingExtension(state: RecordingState) { enter: { codeFenced: onentercodefenced, codeFencedFence: onentercodefencedfence, + codeFencedFenceSequence: onentercodefencedfencesequence, codeIndented: onentercodeindented, codeFlowValue: onenterdata, codeText: onentercodetext, diff --git a/src/source-map/types.ts b/src/source-map/types.ts index df7bca8..eb26ec1 100644 --- a/src/source-map/types.ts +++ b/src/source-map/types.ts @@ -5,6 +5,7 @@ import type { MarkdownLinkNode, MarkdownNode, MarkdownTextNode, + ParsedPoint, ParsedPosition, } from '../types'; @@ -55,6 +56,53 @@ export interface MarkdownSourceMapSegment { kind: SourceMapSegmentKind } +/** + * Source structure of a fenced code block. + * + * @public + */ +export interface FencedCodeSourceInfo { + /** + * The block uses fenced code syntax. It starts with a fence sequence. + */ + kind: 'fenced' + /** + * The opening fence sequence: the run of backticks or tildes that starts the + * block. It excludes the container prefix, the indentation, the info string, + * and the line ending. + */ + openingFence: ParsedPosition + /** + * Point at the end of the opening fence line, before its line ending. + * Insert an info string here when `node.lang` is absent. + * + * The point stays before a `\r` in a CRLF line ending, so an insert never + * splits the line ending. If the opening fence line has no line ending, the + * point is the end of the input. + */ + infoInsertPoint: ParsedPoint +} + +/** + * Source structure of an indented code block. It has no fence and no position + * for an info string. + * + * @public + */ +export interface IndentedCodeSourceInfo { + /** + * The block is code indented by four or more columns. It has no fence. + */ + kind: 'indented' +} + +/** + * Source structure of a block `code` node: fenced or indented. + * + * @public + */ +export type CodeSourceInfo = FencedCodeSourceInfo | IndentedCodeSourceInfo; + /** * Sidecar source map produced alongside a parse. Maps supported value nodes to * compressed segments that reconstruct supported normalized fields from the @@ -139,6 +187,29 @@ export interface MarkdownSourceMap { valueEnd: number, ): ParsedPosition + /** + * Returns the source structure of a block `code` node. The result states + * whether the block is fenced or indented. + * + * For a fenced block, `openingFence` is the opening fence sequence and + * `infoInsertPoint` is the end of the opening fence line. Insert a language + * at `infoInsertPoint.offset` when `node.lang` is absent. The parser records + * both during parsing; a consumer does not rescan the Markdown. + * + * For an indented block, the result is `{ kind: 'indented' }`. An indented + * block has no info string position. + * + * Failure modes: + * + * - {@link SourceMapUnavailableError} — the node belongs to another + * document, was generated or added after parsing, or is not a block `code` + * node. No structure is fabricated. + * + * @param node - A block `code` node from the document this map was built for. + * @returns The source structure of `node`. + */ + getCodeSourceInfo(node: MarkdownCodeNode): CodeSourceInfo + /** * Maps a half-open range of a named normalized field back to the raw * Markdown source. The current field is `url`, supported by inline resource