From 04f369e64a899d72f0a1e60037eb93252b23f238 Mon Sep 17 00:00:00 2001 From: rachfop Date: Fri, 2 Oct 2026 17:11:27 -0700 Subject: [PATCH] fix: mask empty and unclosed fences, keep require() images, rewrite @site images - maskCodeSegments masks an empty fenced block, and an unclosed fence from its opener to the end of its container (document, list item, or blockquote), so cleaning and partial resolution leave its content as written. - and literal brace src values become markdown images. - rewriteImageUrls resolves @site/ image paths against the site directory like a relative image; one with no bundled asset that the build serves from a static directory is rewritten to its static URL. - A front matter description whose first line leaves a code span or link open drops the dangling opener in llms.txt and ends with '...'. --- package.json | 2 +- src/content.ts | 62 ++++++- src/generator.ts | 34 ++-- src/images.ts | 63 ++++++- src/processor.ts | 1 + tests/test-content-edge-cases.js | 288 +++++++++++++++++++++++++++++++ 6 files changed, 429 insertions(+), 21 deletions(-) create mode 100644 tests/test-content-edge-cases.js diff --git a/package.json b/package.json index 2c0d07a..0a491b3 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "format:check": "oxfmt --check", "cleanup": "node cleanup.js", "prepublishOnly": "npm run build && npm run cleanup", - "test:unit": "node tests/test-plugin-options-validation.js && node tests/test-plugin-validation-integration.js && node tests/test-regex-escaping.js && node tests/test-baseurl-handling.js && node tests/test-baseurl-url-construction.js && node tests/test-baseurl-individual-markdown.js && node tests/test-compound-number-prefixes.js && node tests/test-relative-urls.js && node tests/test-content-cleaning-fences.js && node tests/test-jsx-attr-stripping.js && node tests/test-preserve-components.js && node tests/test-tabitem-attr-edge-cases.js && node tests/test-partial-dollar-replacement.js && node tests/test-partial-fence-masking.js && node tests/test-content-loss.js && node tests/test-output-assembly.js && node tests/test-crlf-handling.js && node tests/test-path-transforms.js && node tests/test-header-deduplication.js && node tests/test-import-removal.js && node tests/test-partials.js && node tests/test-site-alias-partials.js && node tests/test-partial-code-fence-imports.js && node tests/test-mdx-page-body-imports.js && node tests/test-missing-partials.js && node tests/test-circular-imports.js && node tests/test-root-content.js && node tests/test-rewrite-image-urls.js && node tests/test-filenames.js && node tests/test-url-encoding.js && node tests/test-nested-paths.js && node tests/test-filename-sanitization.js && node tests/test-yaml-encoding.js && node tests/test-url-error-handling.js && node tests/test-regex-lastindex.js && node tests/test-whitespace-paths.js && node tests/test-unique-identifier-iteration-limit.js && node tests/test-error-handling.js && node tests/test-file-io-error-handling.js && node tests/test-parallel-processing.js && node tests/test-path-length-validation.js && node tests/test-bom-handling.js && node tests/test-batch-processing.js && node tests/test-input-validation.js && node tests/test-add-md-extension.js && node tests/test-import-description-skip.js && node tests/test-description-non-prose.js && node tests/test-filename-sanitization-improved.js", + "test:unit": "node tests/test-plugin-options-validation.js && node tests/test-plugin-validation-integration.js && node tests/test-regex-escaping.js && node tests/test-baseurl-handling.js && node tests/test-baseurl-url-construction.js && node tests/test-baseurl-individual-markdown.js && node tests/test-compound-number-prefixes.js && node tests/test-relative-urls.js && node tests/test-content-cleaning-fences.js && node tests/test-jsx-attr-stripping.js && node tests/test-preserve-components.js && node tests/test-tabitem-attr-edge-cases.js && node tests/test-partial-dollar-replacement.js && node tests/test-partial-fence-masking.js && node tests/test-content-loss.js && node tests/test-output-assembly.js && node tests/test-crlf-handling.js && node tests/test-path-transforms.js && node tests/test-header-deduplication.js && node tests/test-import-removal.js && node tests/test-partials.js && node tests/test-site-alias-partials.js && node tests/test-partial-code-fence-imports.js && node tests/test-mdx-page-body-imports.js && node tests/test-missing-partials.js && node tests/test-circular-imports.js && node tests/test-root-content.js && node tests/test-rewrite-image-urls.js && node tests/test-filenames.js && node tests/test-url-encoding.js && node tests/test-nested-paths.js && node tests/test-filename-sanitization.js && node tests/test-yaml-encoding.js && node tests/test-url-error-handling.js && node tests/test-regex-lastindex.js && node tests/test-whitespace-paths.js && node tests/test-unique-identifier-iteration-limit.js && node tests/test-error-handling.js && node tests/test-file-io-error-handling.js && node tests/test-parallel-processing.js && node tests/test-path-length-validation.js && node tests/test-bom-handling.js && node tests/test-batch-processing.js && node tests/test-input-validation.js && node tests/test-add-md-extension.js && node tests/test-import-description-skip.js && node tests/test-description-non-prose.js && node tests/test-filename-sanitization-improved.js && node tests/test-content-edge-cases.js", "test:integration": "node tests/test-path-transformation.js && node tests/test-multi-doc.js && node tests/test-draft-integration.js && node tests/test-md-extension-generation.js && node tests/test-regression-fixes.js && node tests/test-multi-version.js && node tests/test-versions-i18n.js && node tests/test-review-followups.js && node tests/test-frontmatter-slug-priority.js && node tests/test-numeric-frontmatter-slug.js && node tests/test-index-filename-case.js && node tests/test-log-level-and-batch-size.js && node tests/test-multi-section-route-scoping.js && node tests/test-path-safety.js && node tests/test-url-resolution.js && node tests/test-small-fixes.js", "test": "npm run build && npm run test:unit && npm run test:integration" }, diff --git a/src/content.ts b/src/content.ts index 1092cdb..34be79d 100644 --- a/src/content.ts +++ b/src/content.ts @@ -32,29 +32,58 @@ export function maskCodeSegments(content: string): { segments.push(code); return token; }; + const restore = (s: string): string => + s.replace(new RegExp(`CODE${id}:(\\d+)`, 'g'), (_t, i) => segments[Number(i)] ?? ''); // Fenced code blocks first: opening fence (>=3 backticks or tildes) through a // closing fence of the same character that is at least as long as the // opener (CommonMark), so a ```` fence can show a ``` fence inside it. The + // block may be empty (the closer on the line after the opener). The // leading newline (if any) stays outside the token so line structure is // unchanged. let masked = content.replace( - /(^|\n)([ \t]*(?:(`{3,})[^\n]*\n[\s\S]*?\n[ \t]*\3`*|(~{3,})[^\n]*\n[\s\S]*?\n[ \t]*\4~*)[ \t]*)(?=\n|$)/g, + /(^|\n)([ \t]*(?:(`{3,})[^\n]*(?:\n[\s\S]*?)??\n[ \t]*\3`*|(~{3,})[^\n]*(?:\n[\s\S]*?)??\n[ \t]*\4~*)[ \t]*)(?=\n|$)/g, (_match, lead, block) => `${lead}${store(block)}`, ); + // An opening fence left without a closer runs to the end of its container + // (CommonMark): the end of the document, or for an indented opener (inside + // a list item) the first non-blank line indented less than the opener. A + // backtick opener's info string has no backticks, so a line of inline code + // is not an opener. Blocks masked above inside the region are restored into + // it, so its token holds the region as written. + const unclosedFence = /(^|\n)([ \t]*)(?:`{3,}[^`\n]*|~{3,}[^\n]*)(?=\n|$)/; + let open: RegExpExecArray | null; + let from = 0; + while ((open = unclosedFence.exec(masked.slice(from))) !== null) { + const start = from + open.index + open[1].length; + const indent = open[2].length; + let end = masked.length; + if (indent > 0) { + const dedent = new RegExp(`\\n(?![ \\t]{${indent}}|[ \\t]*(?:\\n|$))`, 'g'); + dedent.lastIndex = start; + const next = dedent.exec(masked); + if (next) end = next.index; + } + const token = store(restore(masked.slice(start, end))); + masked = masked.slice(0, start) + token + masked.slice(end); + from = start + token.length; + } + // Fences inside blockquotes: every line of the block carries a `>` prefix. + // An opener without a closer runs to the end of the blockquote. masked = masked.replace( /(^|\n)([ \t]*>(?:[ \t]*>)*[ \t]*(?:(`{3,})[^\n]*(?:\n[ \t]*>[^\n]*?)*?\n[ \t]*>(?:[ \t]*>)*[ \t]*\3`*|(~{3,})[^\n]*(?:\n[ \t]*>[^\n]*?)*?\n[ \t]*>(?:[ \t]*>)*[ \t]*\4~*)[ \t]*)(?=\n|$)/g, (_match, lead, block) => `${lead}${store(block)}`, ); + masked = masked.replace( + /(^|\n)([ \t]*>(?:[ \t]*>)*[ \t]*(?:`{3,}[^`\n]*|~{3,}[^\n]*)(?:\n[ \t]*>[^\n]*)*)(?=\n|$)/g, + (_match, lead, block) => `${lead}${store(restore(block))}`, + ); // Then inline code spans (`code`, ``co`de``) — a span never crosses a line. masked = masked.replace(/(`+)(?:(?!\1)[^\n])+?\1/g, (m) => store(m)); - const restore = (s: string): string => - s.replace(new RegExp(`CODE${id}:(\\d+)`, 'g'), (_t, i) => segments[Number(i)] ?? ''); - return { masked, restore }; } @@ -372,6 +401,24 @@ function quotedTagAttr(tag: string, name: string): string | undefined { return isNonEmptyString(raw) ? raw : undefined; } +/** + * Extract the path from a JSX `src` brace expression on an `` tag: a + * string literal (`{"./x.png"}`, `{'./x.png'}`, or a template literal without + * `${}`), or a `require()` of one (`{require('./x.png').default}`), which the + * bundler resolves like a relative import. Any other expression yields + * undefined. + */ +function imgSrcExpression(tag: string): string | undefined { + const m = tag.match(/\ssrc\s*=\s*\{([^{}]*)\}/i); + if (!m) return undefined; + const literal = /(?:"([^"]*)"|'([^']*)'|`([^`]*)`)/.source; + const expression = m[1] + .trim() + .match(new RegExp(`^(?:${literal}|require\\(\\s*${literal}\\s*\\)(?:\\.default)?)$`)); + const text = expression?.slice(1).find(isDefined); + return isNonEmptyString(text) && !text.includes('${') ? text : undefined; +} + /** * Clean markdown content for LLM consumption * @param content - Raw markdown content @@ -416,8 +463,11 @@ export function cleanMarkdownContent( // capitalized name (, ) is a component, which the JSX pass // further down strips or keeps per preserveComponents. cleaned = cleaned.replace(new RegExp(``, 'g'), (tag) => { - const src = quotedTagAttr(tag, 'src'); - return src ? `![${quotedTagAttr(tag, 'alt') ?? ''}](${src})` : ''; + // Restore first: a template-literal value (src={`x`}) was masked as + // inline code along with the rest of the content. + const source = restore(tag); + const src = quotedTagAttr(source, 'src') ?? imgSrcExpression(source); + return src ? `![${quotedTagAttr(source, 'alt') ?? ''}](${src})` : ''; }); // Remove common HTML tags (code blocks are already masked out above), diff --git a/src/generator.ts b/src/generator.ts index 08ac819..1953a3f 100644 --- a/src/generator.ts +++ b/src/generator.ts @@ -79,19 +79,33 @@ function cleanDescriptionForToc(description: string, fromFrontMatter: boolean = cleaned = cleaned.replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1'); } - // Truncate if too long (150 characters max with ellipsis) - if (cleaned.length <= 150) return cleaned; - let truncated = cleaned.substring(0, 147); - // A cut inside a code span or link would leave it open and break the TOC - // line's markdown, so cut before the unclosed opener instead. - if ((truncated.match(/`/g) || []).length % 2 === 1) { - truncated = truncated.substring(0, truncated.lastIndexOf('`')).trimEnd(); + // Truncate if too long (150 characters max with ellipsis). A front matter + // description cut to its first line can also leave a code span or link + // open (it closes on a later line); that line is balanced and marked as cut + // too, and a first line with nothing open is kept as written. + if (cleaned.length <= 150) { + if (!fromFrontMatter || lines.length < 2) return cleaned; + const balanced = cutBeforeOpenSpan(cleaned); + if (balanced === cleaned) return cleaned; + return balanced ? `${balanced}...` : ''; } - const openLink = truncated.search(/!?\[[^\]]*$|!?\[[^\]]*\]\([^)]*$/); + return `${cutBeforeOpenSpan(cleaned.substring(0, 147))}...`; +} + +/** + * Cut text before an unclosed code span or link opener: a cut inside one + * would leave it open and break the TOC line's markdown. + */ +function cutBeforeOpenSpan(text: string): string { + let cut = text; + if ((cut.match(/`/g) || []).length % 2 === 1) { + cut = cut.substring(0, cut.lastIndexOf('`')).trimEnd(); + } + const openLink = cut.search(/!?\[[^\]]*$|!?\[[^\]]*\]\([^)]*$/); if (openLink !== -1) { - truncated = truncated.substring(0, openLink).trimEnd(); + cut = cut.substring(0, openLink).trimEnd(); } - return `${truncated}...`; + return cut; } /** diff --git a/src/images.ts b/src/images.ts index 41b4cb8..bd13d32 100644 --- a/src/images.ts +++ b/src/images.ts @@ -6,6 +6,7 @@ import * as fs from 'fs/promises'; import * as path from 'path'; import { Dirent } from 'fs'; import { maskCodeSegments } from './content'; +import { isDefined } from './guards'; /** Image extensions recognised by Docusaurus / browsers (regex alternation). */ const IMAGE_EXTENSIONS = 'png|jpe?g|gif|svg|webp|bmp|ico|avif|tiff?'; @@ -57,6 +58,38 @@ export async function buildImageAssetMap(outDir: string): Promise//

`, the file `/

` with the same bytes (each + * leading directory is tried as the static directory, shortest first). + * Returns its site-root-relative URL path, or null when there is none. + */ +async function findStaticCopy( + sourceFile: string, + siteDir: string, + outDir: string, +): Promise { + const segments = path.relative(siteDir, sourceFile).split(path.sep); + if (segments[0] === '..' || path.isAbsolute(segments[0])) return null; + let srcBytes: Buffer; + try { + srcBytes = await fs.readFile(sourceFile); + } catch { + return null; + } + for (let i = 1; i < segments.length; i++) { + const rel = segments.slice(i); + try { + if (srcBytes.equals(await fs.readFile(path.join(outDir, ...rel)))) { + return `/${rel.join('/')}`; + } + } catch { + /* no copy at this depth */ + } + } + return null; +} + /** * Rewrite relative image references in markdown content to absolute URLs * pointing to the hashed build output in `assets/images/`. @@ -67,8 +100,13 @@ export async function buildImageAssetMap(outDir: string): Promise` (both quote styles) * * As in Docusaurus, a markdown image path that is not absolute, root-relative, - * or an alias (`@site/...`) resolves against the source file's directory, and - * is percent-decoded before the lookup. + * or an alias resolves against the source file's directory, and is + * percent-decoded before the lookup. When `siteDir` is given, a `@site/

` + * path resolves to `/

`: Docusaurus bundles it like a relative + * image, so it takes the same lookup. A `@site/` image with no bundled asset + * (one small enough to be inlined as a data URI) that the build serves from a + * static directory (`@site/static/

` copied to `/

`, confirmed by + * comparing bytes) is rewritten to `

`. * * Resolution strategy: * 1. Extract the basename from the relative path (e.g., `foo.png`). @@ -85,6 +123,7 @@ export async function buildImageAssetMap(outDir: string): Promise, siteUrl: string, outDir: string, + siteDir?: string, ): Promise { const baseUrl = siteUrl.endsWith('/') ? siteUrl.slice(0, -1) : siteUrl; const sourceDir = path.dirname(sourceFilePath); @@ -112,7 +152,9 @@ export async function rewriteRelativeImageUrls( // filtering is done separately with imgExtRe so we don't accidentally miss // legitimate multi-level paths. const imageRefRe = /(!\[[^\]]*\]\()(?:<([^<>\n]+)>|([^)"'\s<>]+))|(src=["'])(\.[^"'\s]+)/gi; - const isRelative = (p: string): boolean => !/^(?:[a-z][a-z0-9+.-]*:|[/#@~])/i.test(p); + const isSiteAlias = (p: string): boolean => isDefined(siteDir) && p.startsWith('@site/'); + const isRelative = (p: string): boolean => + isSiteAlias(p) || !/^(?:[a-z][a-z0-9+.-]*:|[/#@~])/i.test(p); // The file-system path a reference names: no query/fragment, percent-decoded. const toFsPath = (ref: string): string => { const bare = ref.split('?')[0].split('#')[0]; @@ -123,6 +165,12 @@ export async function rewriteRelativeImageUrls( } }; + // The source file an image reference names. + const toSourceFile = (ref: string): string => + isSiteAlias(ref) + ? path.join(siteDir!, toFsPath(ref).slice('@site/'.length)) + : path.resolve(sourceDir, toFsPath(ref)); + // Collect unique relative paths that point to image files const uniquePaths = new Set(); let m: RegExpExecArray | null; @@ -148,7 +196,7 @@ export async function rewriteRelativeImageUrls( assetPath = candidates[0]; } else if (candidates.length > 1) { // Multiple files with same basename — byte-compare to find the right one - const absSource = path.resolve(sourceDir, toFsPath(relPath)); + const absSource = toSourceFile(relPath); try { const srcBytes = await fs.readFile(absSource); for (const candidate of candidates) { @@ -167,6 +215,13 @@ export async function rewriteRelativeImageUrls( } } + // A `@site/

/

` image with no bundled asset: the build output + // serves it at `/

` when `

` is a static directory, which copies + // its files to the output root. Matching bytes confirm the copy. + if (!assetPath && isSiteAlias(relPath)) { + assetPath = await findStaticCopy(toSourceFile(relPath), siteDir!, outDir); + } + // Preserve any query string / fragment (e.g. "?raw=1", "#anchor") so we // don't change semantics for downstream tooling that relies on them. const suffixMatch = relPath.match(/[?#].*$/); diff --git a/src/processor.ts b/src/processor.ts index 07d15e7..0d6994e 100644 --- a/src/processor.ts +++ b/src/processor.ts @@ -401,6 +401,7 @@ export async function processMarkdownFile( imageAssetMap, siteUrl, outDir, + siteDir, ) : cleanedContent; diff --git a/tests/test-content-edge-cases.js b/tests/test-content-edge-cases.js new file mode 100644 index 0000000..7326ac1 --- /dev/null +++ b/tests/test-content-edge-cases.js @@ -0,0 +1,288 @@ +/** + * Content edge cases: + * - empty and unclosed fenced code blocks are masked, so cleaning (HTML strip, + * import removal, JSX strip, partial resolution) leaves their content as + * written + * - `` and other literal brace `src` + * values become markdown images + * - `@site/...` image paths are rewritten like Docusaurus resolves them + * - a front matter description whose first line opens a code span or link + * that closes on a later line keeps the TOC line balanced + */ + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { cleanMarkdownContent, resolvePartialImports } = require('../lib/content'); +const { rewriteRelativeImageUrls } = require('../lib/images'); +const { processMarkdownFile } = require('../lib/processor'); +const docusaurusPluginLLMs = require('../lib/index.js').default; + +let passed = 0; +let failed = 0; +const tempDirs = []; + +function checkEqual(name, actual, expected) { + if (actual === expected) { + console.log(` PASS: ${name}`); + passed++; + } else { + console.log(` FAIL: ${name}`); + console.log(` expected: ${JSON.stringify(expected)}`); + console.log(` actual: ${JSON.stringify(actual)}`); + failed++; + } +} + +function tempDir() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'llms-content-edge-cases-')); + tempDirs.push(dir); + return dir; +} + +function writeFiles(root, files) { + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } +} + +async function links(files) { + const siteDir = tempDir(); + const outDir = path.join(siteDir, 'build'); + fs.mkdirSync(outDir, { recursive: true }); + writeFiles(siteDir, files); + const plugin = docusaurusPluginLLMs( + { + siteDir, + outDir, + siteConfig: { title: 'T', tagline: 'TL', url: 'https://ex.com', baseUrl: '/' }, + }, + { logLevel: 'quiet', generateLLMsFullTxt: false }, + ); + await plugin.postBuild({ outDir }); + return fs + .readFileSync(path.join(outDir, 'llms.txt'), 'utf8') + .split('\n') + .filter((l) => l.startsWith('- [')); +} + +async function main() { + console.log('Empty fenced code blocks'); + checkEqual( + 'empty ``` block between prose is masked', + cleanMarkdownContent('Before x\n\n```\n```\n\nAfter y\n\n```\nz\n```'), + 'Before x\n\n```\n```\n\nAfter y\n\n```\nz\n```', + ); + checkEqual( + 'empty ```js block does not pair with a later fence', + cleanMarkdownContent('```js\n```\n\nText bold\n\n```\n
code
\n```'), + '```js\n```\n\nText bold\n\n```\n
code
\n```', + ); + checkEqual( + 'empty ~~~ block does not pair with a later fence', + cleanMarkdownContent('~~~\n~~~\n\nText bold\n\n~~~\n
code
\n~~~'), + '~~~\n~~~\n\nText bold\n\n~~~\n
code
\n~~~', + ); + + console.log('Unclosed fenced code blocks'); + checkEqual( + 'unclosed ``` keeps HTML to the end of the document', + cleanMarkdownContent('Intro x\n\n```html\n
\n hi\n
'), + 'Intro x\n\n```html\n
\n hi\n
', + ); + checkEqual( + 'unclosed ~~~ keeps imports with excludeImports', + cleanMarkdownContent("Text\n\n~~~js\nimport x from 'y';\nx();", true), + "Text\n\n~~~js\nimport x from 'y';\nx();", + ); + checkEqual( + 'unclosed ``` keeps JSX component tags', + cleanMarkdownContent('Text\n\n```jsx\n\n A\n'), + 'Text\n\n```jsx\n\n A\n', + ); + checkEqual( + 'unclosed ``` swallows a later closed ~~~ block whole', + cleanMarkdownContent('```\na\n~~~\nb\n~~~'), + '```\na\n~~~\nb\n~~~', + ); + checkEqual( + 'unclosed fence in a list item ends with the item', + cleanMarkdownContent('- Step\n\n ```sh\n cmd\n\nAfter bold'), + '- Step\n\n ```sh\n cmd\n\nAfter bold', + ); + checkEqual( + 'unclosed fence in a blockquote ends with the blockquote', + cleanMarkdownContent('> ```\n> q\n\nAfter bold'), + '> ```\n> q\n\nAfter bold', + ); + checkEqual( + 'a line of inline code is not an unclosed fence', + cleanMarkdownContent('```a``` then b\n\nMore c'), + '```a``` then b\n\nMore c', + ); + checkEqual( + 'closed blockquoted fence still masked', + cleanMarkdownContent('> ```\n> q\n> ```\n\nAfter bold'), + '> ```\n> q\n> ```\n\nAfter bold', + ); + checkEqual( + 'nested fence (```` around ```) still masked', + cleanMarkdownContent('````md\n```\nx\n```\n````\n\ny'), + '````md\n```\nx\n```\n````\n\ny', + ); + { + const dir = tempDir(); + writeFiles(dir, { '_p.mdx': 'Partial body.' }); + const doc = path.join(dir, 'doc.mdx'); + checkEqual( + 'partial usage inside an unclosed fence is not resolved', + await resolvePartialImports( + "import P from './_p.mdx';\n\n

\n\n```mdx\nimport P from './_p.mdx';\n

", + doc, + new Set(), + dir, + ), + "\n\nPartial body.\n\n```mdx\nimport P from './_p.mdx';\n

", + ); + } + + console.log('JSX img src expressions'); + checkEqual( + "src={require('./img/x.png').default}", + cleanMarkdownContent(`X`), + '![X](./img/x.png)', + ); + checkEqual( + 'src={require("./x.png")}', + cleanMarkdownContent('Y'), + '![Y](./x.png)', + ); + checkEqual( + 'src={"./x.png"}', + cleanMarkdownContent('Z'), + '![Z](./x.png)', + ); + checkEqual("src={'./x.png'}", cleanMarkdownContent(""), '![](./x.png)'); + checkEqual( + 'src={`./x.png`}', + cleanMarkdownContent('T'), + '![T](./x.png)', + ); + checkEqual( + 'src={`${base}/x.png`} (interpolated) is dropped', + cleanMarkdownContent('Text end'), + 'Text end', + ); + checkEqual( + 'src={variable} is dropped', + cleanMarkdownContent('Text end'), + 'Text end', + ); + + console.log('@site image paths'); + { + const siteDir = tempDir(); + const outDir = path.join(siteDir, 'build'); + const hash = 'abcdef0123456789'; + writeFiles(siteDir, { + 'docs/img/x.png': 'XPNG', + 'static/img/s.png': 'SPNG', + 'static/img/big.png': 'BIG', + [`build/assets/images/x-${hash}.png`]: 'XPNG', + [`build/assets/images/big-${hash}.png`]: 'BIG', + 'build/img/s.png': 'SPNG', + 'build/img/big.png': 'BIG', + 'docs/doc.md': '', + }); + const map = new Map([ + ['x.png', [`/assets/images/x-${hash}.png`]], + ['big.png', [`/assets/images/big-${hash}.png`]], + ]); + const rewrite = (content) => + rewriteRelativeImageUrls( + content, + path.join(siteDir, 'docs/doc.md'), + map, + 'https://ex.com/base/', + outDir, + siteDir, + ); + checkEqual( + '@site/ path resolves to the bundled asset', + await rewrite('![a](@site/docs/img/x.png)'), + `![a](https://ex.com/base/assets/images/x-${hash}.png)`, + ); + checkEqual( + '@site/static/

bundled asset wins (Docusaurus bundles markdown images)', + await rewrite('![b](@site/static/img/big.png)'), + `![b](https://ex.com/base/assets/images/big-${hash}.png)`, + ); + checkEqual( + '@site/static/

with no bundled asset is served at

', + await rewrite('![c](@site/static/img/s.png)'), + '![c](https://ex.com/base/img/s.png)', + ); + checkEqual( + 'missing @site image left unchanged', + await rewrite('![d](@site/static/img/missing.png)'), + '![d](@site/static/img/missing.png)', + ); + checkEqual( + '@site image in code left unchanged', + await rewrite('`![e](@site/docs/img/x.png)`'), + '`![e](@site/docs/img/x.png)`', + ); + writeFiles(siteDir, { 'docs/page.md': '# Page\n\n![a](@site/docs/img/x.png)\n' }); + const doc = await processMarkdownFile( + path.join(siteDir, 'docs/page.md'), + path.join(siteDir, 'docs'), + 'https://ex.com/base/', + 'docs', + { imageAssetMap: map, outDir, siteDir }, + ); + checkEqual( + 'processMarkdownFile passes siteDir to the rewrite', + doc.content, + `# Page\n\n![a](https://ex.com/base/assets/images/x-${hash}.png)`, + ); + } + + console.log('TOC description with a span closing on a later line'); + { + const lines = await links({ + 'docs/code.md': + '---\ntitle: Code\ndescription: |\n Split-K pass 2: every partition owns the band `x\n y` here.\n---\n\nBody.\n', + 'docs/link.md': + '---\ntitle: Link\ndescription: |\n See the [guide\n page](./guide.md) for details.\n---\n\nBody.\n', + 'docs/plain.md': + '---\ntitle: Plain\ndescription: |\n First line `ok` here.\n Second line.\n---\n\nBody.\n', + }); + const line = (title) => lines.find((l) => l.startsWith(`- [${title}]`)); + checkEqual( + 'dangling code span dropped, ellipsis appended', + line('Code'), + '- [Code](https://ex.com/docs/code): Split-K pass 2: every partition owns the band...', + ); + checkEqual( + 'dangling link opener dropped, ellipsis appended', + line('Link'), + '- [Link](https://ex.com/docs/link): See the...', + ); + checkEqual( + 'balanced first line unchanged', + line('Plain'), + '- [Plain](https://ex.com/docs/plain): First line `ok` here.', + ); + } + + for (const dir of tempDirs) fs.rmSync(dir, { recursive: true, force: true }); + console.log(`\n${passed} passed, ${failed} failed`); + if (failed > 0) process.exit(1); +} + +main().catch((error) => { + for (const dir of tempDirs) fs.rmSync(dir, { recursive: true, force: true }); + console.error(error); + process.exit(1); +});