diff --git a/changelog.d/fixed-569-mdx-shared-fence-rule.md b/changelog.d/fixed-569-mdx-shared-fence-rule.md new file mode 100644 index 0000000..e06d0ca --- /dev/null +++ b/changelog.d/fixed-569-mdx-shared-fence-rule.md @@ -0,0 +1,4 @@ +- The MDX-to-Markdown sync conversion now uses the same fenced-code-block + rule as the other sync scripts (hivecommons/docs#569): a backtick run whose + info string contains a backtick is no longer mistaken for a fence opener, so + it can no longer hide a component's closing tag. diff --git a/scripts/markdown-fences.ts b/scripts/markdown-fences.ts index c9ae23a..d072d7d 100644 --- a/scripts/markdown-fences.ts +++ b/scripts/markdown-fences.ts @@ -14,16 +14,41 @@ export const DEFAULT_FENCE_LANGUAGE = "text"; const FENCE_RE = /^(\s*)(`{3,}|~{3,})(.*)$/; -interface OpenFence { - marker: string; -} +/** Where a line sits relative to fenced code blocks. */ +export type FenceLineKind = "outside" | "opener" | "inside" | "closer"; + +/** + * Line-by-line fenced-code-block tracker: the one fence rule every sync script + * shares. Feed lines in document order, without their trailing newline. A + * fence opens on a run of three or more backticks or tildes — except a + * backtick run whose info string contains a backtick, which CommonMark does + * not treat as a fence — and closes on a line of the same marker character, at + * least as long as the opener, with no info string. + */ +export class FenceTracker { + private openMarker: string | null = null; -function isFenceCloser(open: OpenFence, marker: string, info: string): boolean { - return ( - marker[0] === open.marker[0] && - marker.length >= open.marker.length && - info.trim() === "" - ); + /** Classify `line` and advance the tracker past it. */ + next(line: string): FenceLineKind { + const m = line.match(FENCE_RE); + if (this.openMarker !== null) { + if ( + m && + m[2][0] === this.openMarker[0] && + m[2].length >= this.openMarker.length && + m[3].trim() === "" + ) { + this.openMarker = null; + return "closer"; + } + return "inside"; + } + if (m && !(m[2][0] === "`" && m[3].includes("`"))) { + this.openMarker = m[2]; + return "opener"; + } + return "outside"; + } } /** @@ -36,17 +61,10 @@ export function splitFencedSegments( content: string ): Array<{ text: string; fenced: boolean }> { const segments: Array<{ text: string; fenced: boolean }> = []; - let open: OpenFence | null = null; + const fences = new FenceTracker(); for (const line of content.split(/(?<=\n)/)) { - const m = line.replace(/\r?\n$/, "").match(FENCE_RE); - let fenced = open !== null; - if (open) { - if (m && isFenceCloser(open, m[2], m[3])) open = null; - } else if (m && !(m[2][0] === "`" && m[3].includes("`"))) { - open = { marker: m[2] }; - fenced = true; - } + const fenced = fences.next(line.replace(/\r?\n$/, "")) !== "outside"; const last = segments[segments.length - 1]; if (last && last.fenced === fenced) last.text += line; else segments.push({ text: line, fenced }); @@ -72,14 +90,13 @@ export function mapOutsideFences( export function normalizeFences(content: string): string { const lines = content.split("\n"); const out: string[] = []; - let open: OpenFence | null = null; + const fences = new FenceTracker(); for (const line of lines) { - const m = line.match(FENCE_RE); - if (open) { + const kind = fences.next(line); + if (kind === "inside" || kind === "closer") { out.push(line); - if (m && isFenceCloser(open, m[2], m[3])) { - open = null; + if (kind === "closer") { // MD031: a closer needs a blank line after it. We cannot look ahead // without buffering, so the check happens when the next line lands. out.push("\0AFTER_FENCE"); @@ -92,21 +109,14 @@ export function normalizeFences(content: string): string { if (line.trim() !== "") out.push(""); } - if (m) { - const [, indent, marker, info] = m; - // A backtick fence whose info string contains a backtick is not a - // fence per CommonMark; leave it alone. - if (marker[0] === "`" && info.includes("`")) { - out.push(line); - continue; - } + if (kind === "opener") { + const [, indent, marker, info] = line.match(FENCE_RE)!; // MD031: blank line before the opener. if (out.length > 0 && out[out.length - 1].trim() !== "") out.push(""); // MD040: language on the opener. const lang = info.trim() === "" ? DEFAULT_FENCE_LANGUAGE : info.trimStart(); out.push(`${indent}${marker}${lang}`); - open = { marker }; continue; } diff --git a/scripts/mdx-to-markdown.test.ts b/scripts/mdx-to-markdown.test.ts index 7760976..615e102 100644 --- a/scripts/mdx-to-markdown.test.ts +++ b/scripts/mdx-to-markdown.test.ts @@ -86,6 +86,19 @@ describe("convertMdxToMarkdown", () => { ); }); + it("does not let a backtick run with a backtick in its info string hide a closing tag (CommonMark)", () => { + const src = [ + '', + "", + " ```not`a`fence", + "", + "", + ].join("\n"); + const out = convertMdxToMarkdown(src, { siteBase: SITE }); + expect(out.markdown).toBe("### Step 1: Quote\n\n```not`a`fence\n"); + expect(out.notes).toEqual([]); + }); + it("converts nested inside to labelled sections", () => { const src = [ '', diff --git a/scripts/mdx-to-markdown.ts b/scripts/mdx-to-markdown.ts index fa3dfd7..4983999 100644 --- a/scripts/mdx-to-markdown.ts +++ b/scripts/mdx-to-markdown.ts @@ -22,6 +22,8 @@ * Fenced code blocks are never parsed for components or rewritten. */ +import { FenceTracker } from "./markdown-fences"; + export interface MdxConvertOptions { /** Canonical site the page is published on, e.g. https://spektacular.dev. */ siteBase: string; @@ -46,7 +48,6 @@ export interface MdxConversion { } const DEFAULT_IMAGE_PREFIX = "/images/"; -const FENCE_RE = /^\s*(`{3,}|~{3,})(.*)$/; // A JSX tag on a line of its own: ``, `` or ``. const BLOCK_OPEN_RE = /^\s*<([A-Z][A-Za-z0-9.]*)((?:\s+[^>]*?)?)\s*(\/?)>\s*$/; const BLOCK_CLOSE_RE = /^\s*<\/([A-Z][A-Za-z0-9.]*)\s*>\s*$/; @@ -173,23 +174,9 @@ function dedent(lines: string[]): string[] { function findClose(lines: string[], from: number, name: string): number { let depth = 0; - let fence: string | null = null; + const fences = new FenceTracker(); for (let i = from; i < lines.length; i++) { - const f = lines[i].match(FENCE_RE); - if (fence) { - if ( - f && - f[1][0] === fence[0] && - f[1].length >= fence.length && - f[2].trim() === "" - ) - fence = null; - continue; - } - if (f) { - fence = f[1]; - continue; - } + if (fences.next(lines[i]) !== "outside") continue; const open = lines[i].match(BLOCK_OPEN_RE); if (open && open[1] === name && open[3] !== "/") depth++; const close = lines[i].match(BLOCK_CLOSE_RE); @@ -203,24 +190,11 @@ function findClose(lines: string[], from: number, name: string): number { function convertBlocks(lines: string[], notes: string[]): string[] { const out: string[] = []; - let fence: string | null = null; + const fences = new FenceTracker(); for (let i = 0; i < lines.length; i++) { const line = lines[i]; - const f = line.match(FENCE_RE); - if (fence) { - out.push(line); - if ( - f && - f[1][0] === fence[0] && - f[1].length >= fence.length && - f[2].trim() === "" - ) - fence = null; - continue; - } - if (f) { - fence = f[1]; + if (fences.next(line) !== "outside") { out.push(line); continue; }