diff --git a/scripts/lib/mdx-active-content.mjs b/scripts/lib/mdx-active-content.mjs index 17ccb7a9..ff0eb671 100644 --- a/scripts/lib/mdx-active-content.mjs +++ b/scripts/lib/mdx-active-content.mjs @@ -32,7 +32,9 @@ * A fenced code block is allowed because MDX evaluates nothing inside one -- * except for ```mdx-code-block, which Docusaurus unwraps into live MDX before * the compiler runs (see ./mdx-code-blocks.mjs). That fence is not a code - * block at all, so it is a finding. + * block at all, so it is a finding. Because that unwrap decides for itself + * which fences pair, the scan is run over its output as well as over the text + * on disk; see `findActiveContent()`. * * The scan answers the second of the two questions `uri-safety.mjs` states * both gates must answer -- does the value make a visitor's browser contact @@ -56,7 +58,10 @@ import { mdxFromMarkdown } from 'mdast-util-mdx'; import { gfm } from 'micromark-extension-gfm'; import { gfmFromMarkdown } from 'mdast-util-gfm'; import { eachNode } from './mdast-walk.mjs'; -import { isUnwrappedCodeBlock } from './mdx-code-blocks.mjs'; +import { + isUnwrappedCodeBlock, + unwrapMdxCodeBlocks, +} from './mdx-code-blocks.mjs'; import { describeTarget, disallowedScheme, @@ -183,18 +188,13 @@ function literalValue(expression) { } /** - * Scans a Markdown/MDX body for anything outside the allowlists, and for - * images loaded from another host. + * Scans one source text, reporting each finding at its line within that text. * - * @param {string} markdown - Document body to scan. - * @param {{ mdastExtensions?: Array }} [options] - Extra mdast - * extensions to parse with. A node kind they introduce is not on the - * allowlist, so it is reported rather than walked past. - * @returns {Array<{ line: number, reason: string, snippet: string }>} One entry - * per finding, ordered by line. + * @param {string} text - Document body to scan. + * @param {Array} mdastExtensions - Extra mdast extensions. + * @returns {Array<{ line: number, reason: string, snippet: string }>} */ -export function findActiveContent(markdown, { mdastExtensions = [] } = {}) { - const text = String(markdown ?? ''); +function scanSource(text, mdastExtensions) { const lines = text.split('\n'); const findings = []; const report = (line, reason) => @@ -320,6 +320,70 @@ export function findActiveContent(markdown, { mdastExtensions = [] } = {}) { } }); + return findings; +} + +/** + * Maps each line of `unwrapped` back to its line number in `text`. + * + * `unwrapMdxCodeBlocks()` keeps a block's body verbatim and deletes only the + * opening and closing fence lines, so every line of the unwrapped document is + * a line of the source, in order, and a greedy forward walk recovers the + * original line numbers without restating the upstream patterns. + * + * @param {string} text - Source document. + * @param {string} unwrapped - `unwrapMdxCodeBlocks(text)`. + * @returns {number[]} Source line number for each line of `unwrapped`. + */ +function mapUnwrappedLines(text, unwrapped) { + const sourceLines = text.split('\n'); + const mapping = []; + let cursor = 0; + for (const line of unwrapped.split('\n')) { + while (cursor < sourceLines.length && sourceLines[cursor] !== line) { + cursor += 1; + } + // Unreachable for a pure deletion; fall back to the last line rather than + // throw if the upstream rewrite ever changes shape. + mapping.push(Math.min(cursor + 1, sourceLines.length)); + cursor += 1; + } + return mapping; +} + +/** + * Scans a Markdown/MDX body for anything outside the allowlists, and for + * images loaded from another host. + * + * The body is scanned as written and again as `unwrapMdxCodeBlocks()` leaves + * it, because that rewritten text is what the MDX compiler is handed. The + * per-node `mdx-code-block` check only sees the span mdast gave the node, and + * the fence grammars disagree about where it ends: CommonMark closes a + * three-backtick fence with any run of three or more, while Docusaurus + * requires exactly three and pairs the opener with a later bare fence. A + * payload between those two closers sits inside a `code` node the scan trusts. + * Scanning the unwrapped text closes that gap for any such disagreement. This + * mirrors `hasActiveMdx()` in ./mdx-escape.mjs. + * + * @param {string} markdown - Document body to scan. + * @param {{ mdastExtensions?: Array }} [options] - Extra mdast + * extensions to parse with. A node kind they introduce is not on the + * allowlist, so it is reported rather than walked past. + * @returns {Array<{ line: number, reason: string, snippet: string }>} One entry + * per finding, ordered by line. + */ +export function findActiveContent(markdown, { mdastExtensions = [] } = {}) { + const text = String(markdown ?? ''); + const findings = scanSource(text, mdastExtensions); + + const unwrapped = unwrapMdxCodeBlocks(text); + if (unwrapped !== text) { + const sourceLine = mapUnwrappedLines(text, unwrapped); + for (const finding of scanSource(unwrapped, mdastExtensions)) { + findings.push({ ...finding, line: sourceLine[finding.line - 1] }); + } + } + const seen = new Set(); return findings .filter((finding) => { diff --git a/scripts/lib/mdx-code-blocks.mjs b/scripts/lib/mdx-code-blocks.mjs index 7b9bce5c..c7490ce6 100644 --- a/scripts/lib/mdx-code-blocks.mjs +++ b/scripts/lib/mdx-code-blocks.mjs @@ -32,8 +32,19 @@ import { unwrapMdxCodeBlocks } from '@docusaurus/utils'; * * The node's own source range is passed to `unwrapMdxCodeBlocks()`: the range * begins at the opening fence and ends at the closing one, which is exactly - * the `^`/`$` anchoring its patterns allow, so the answer for the slice is the - * answer for the block in the whole document. + * the `^`/`$` anchoring its patterns allow, so a block that reads as unwrapped + * on its own slice is reported at its own line. + * + * The converse does not hold, so `false` here is not a clearance. mdast ends + * the node at the closing fence *CommonMark* chooses -- any run of three or + * more backticks -- while the unwrap pattern requires a closer of exactly + * three and searches on with `.*?` under `s` when it meets a longer one. An + * opener whose CommonMark closer is four backticks therefore pairs, in the + * full document, with a later bare fence outside this slice entirely. Callers + * must not read `false` as "Docusaurus leaves this page alone": + * `findActiveContent()` (./mdx-active-content.mjs) and `hasActiveMdx()` + * (./mdx-escape.mjs) both also scan `unwrapMdxCodeBlocks()` output, for this + * reason. * * @param {string} source Full document text. * @param {{ position?: { start?: { offset?: number }, end?: { offset?: number } } }} node diff --git a/tests/active-content-allowlist.test.mjs b/tests/active-content-allowlist.test.mjs index 98c1762d..902f6292 100644 --- a/tests/active-content-allowlist.test.mjs +++ b/tests/active-content-allowlist.test.mjs @@ -356,6 +356,7 @@ const BYPASSES = [ ), [ 'mdx-code-block fence, whose body Docusaurus compiles as MDX rather than displaying as code', + 'disallowed element ', ], ], [ diff --git a/tests/mdx-active-content.test.mjs b/tests/mdx-active-content.test.mjs index a957ea1d..4cb68ef6 100644 --- a/tests/mdx-active-content.test.mjs +++ b/tests/mdx-active-content.test.mjs @@ -530,6 +530,55 @@ test('reports the four-backtick and indented forms Docusaurus also unwraps', () ]); }); +test('reports active content exposed only when the whole document is unwrapped', () => { + // The two fence grammars disagree about which closer pairs with the opener. + // CommonMark ends the code node at the four-backtick line, so the node's own + // slice is not something unwrapMdxCodeBlocks() rewrites and the per-node + // check clears it. Docusaurus's pattern needs a closer of exactly three + // backticks, skips the four-backtick line under `.*?`/`s`, and pairs the + // opener with the bare fence at the end -- unwrapping the into live + // MDX. Before the whole-document pass this body scanned clean, validated, + // and built to HTML carrying the remote plus a preload link for it. + const body = [ + '```mdx-code-block', + '', + '````', + '', + '```', + ].join('\n'); + assert.deepEqual(findActiveContent(body), [ + { + line: 2, + reason: 'disallowed element ', + snippet: '', + }, + ]); +}); + +test('maps an unwrapped-pass finding back to its true source line', () => { + // Only the fence lines are deleted, so the unwrapped document's lines are a + // subsequence of the source's and the reported line must be the one a reader + // can open the file to, not the shifted line of the rewritten text. + const body = [ + '## Overview', + '', + 'Prose.', + '', + '```mdx-code-block', + '', + '````', + '', + '```', + ].join('\n'); + assert.deepEqual(findActiveContent(body), [ + { + line: 6, + reason: 'disallowed element ', + }, + ]); +}); + test('leaves ordinary fenced and inline code alone', () => { // The exemption is for the one info string Docusaurus rewrites, not for // code blocks in general: an architecture page showing a YAML snippet with