From e4c9e1d04af722a08343df4d7203e1b1de031d84 Mon Sep 17 00:00:00 2001 From: Nathan Contino Date: Tue, 18 Aug 2026 14:50:32 -0400 Subject: [PATCH 1/2] Fix nested code fence parsing in lists --- package-lock.json | 9 -- .../observability/markdown-content-parity.ts | 19 +++-- .../checks/markdown-content-parity.test.ts | 82 +++++++++++++++++++ 3 files changed, 96 insertions(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index 61743db..3dd7325 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1288,7 +1288,6 @@ "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1359,7 +1358,6 @@ "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/types": "8.56.0", @@ -1709,7 +1707,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2277,7 +2274,6 @@ "integrity": "sha512-20MV9SUdeN6Jd84xESsKhRly+/vxI+hwvpBMA93s+9dAcjdCuCojn4IqUGS3lvVaqjVYGYHSRMCpeFtF2rQYxQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -3199,7 +3195,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@inquirer/confirm": "^5.0.0", "@mswjs/interceptors": "^0.41.2", @@ -3439,7 +3434,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3971,7 +3965,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4037,7 +4030,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -4113,7 +4105,6 @@ "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", diff --git a/src/checks/observability/markdown-content-parity.ts b/src/checks/observability/markdown-content-parity.ts index c122736..2d25edd 100644 --- a/src/checks/observability/markdown-content-parity.ts +++ b/src/checks/observability/markdown-content-parity.ts @@ -332,12 +332,21 @@ function extractMarkdownText(markdown: string): string { // on a run of >=N. Capture the opener so the close-side backreference // matches; otherwise nested example fences (4-backtick outer, 3-backtick // inner) get mis-paired and inner markers leak out as text. + // + // Per CommonMark §4.5, a fence may be indented 0–3 spaces (list items + // produced by Turndown indent fences by 4 spaces, which is still a valid + // CommonMark indented code fence inside a list continuation). The leading + // spaces are stripped from the opener match so the backreference on the + // closer still works. const codeBlocks: string[] = []; - text = text.replace(/^(`{3,})[^`\n]*\n([\s\S]*?)^\1`*\s*$/gm, (_match, _opener, content) => { - const idx = codeBlocks.length; - codeBlocks.push(content); - return `\x00BLOCK${idx}\x00`; - }); + text = text.replace( + /^ {0,3}(`{3,})[^`\n]*\n([\s\S]*?)^ {0,3}\1`*\s*$/gm, + (_match, _opener, content) => { + const idx = codeBlocks.length; + codeBlocks.push(content); + return `\x00BLOCK${idx}\x00`; + }, + ); // Step 2: Protect inline code spans from subsequent stripping. // Replace `...` with placeholders so link/emphasis regexes don't diff --git a/test/unit/checks/markdown-content-parity.test.ts b/test/unit/checks/markdown-content-parity.test.ts index bf97e10..9b4fe44 100644 --- a/test/unit/checks/markdown-content-parity.test.ts +++ b/test/unit/checks/markdown-content-parity.test.ts @@ -2658,4 +2658,86 @@ This test guards against regressions that special-case any single backtick run l const pageResults = result.details?.pageResults as Array<{ missingSegments: number }>; expect(pageResults[0].missingSegments).toBe(0); }); + + it('protects fenced code blocks indented inside list items', async () => { + // Turndown indents fenced code blocks by 4 spaces when they appear inside + // numbered list items. Without this fix, the code fence regex (which + // required backticks at column 0) failed to match, leaving the YAML list + // lines unprotected. The list-marker stripper then removed the leading + // "- " from lines like "- db_data:/var/lib/postgresql/data", so the HTML + // segment "- db_data:/var/lib/postgresql/data" had no match in the + // stripped markdown text. + const html = `
+

Install with Docker

+

Use the steps below to install FusionAuth using Docker Compose.

+
    +
  1. +

    Download the configuration files from the repository.

    +

    Full docker-compose.yml example:

    +
    services:
    +  db:
    +    image: postgres:16.0-bookworm
    +    volumes:
    +      - db_data:/var/lib/postgresql/data
    +  fusionauth:
    +    image: fusionauth/fusionauth-app:latest
    +    volumes:
    +      - fusionauth_config:/usr/local/fusionauth/config
    +      - \${FUSIONAUTH_LOCAL_KICKSTART_DIRECTORY}:/usr/local/fusionauth/kickstart
    +volumes:
    +  db_data:
    +  fusionauth_config:
    +
    +
  2. +
  3. +

    Start the services with docker compose up.

    +
  4. +
+
`; + + const markdown = `# Install with Docker + +Use the steps below to install FusionAuth using Docker Compose. + +1. Download the configuration files from the repository. + + Full docker-compose.yml example: + + \`\`\`yaml + services: + db: + image: postgres:16.0-bookworm + volumes: + - db_data:/var/lib/postgresql/data + fusionauth: + image: fusionauth/fusionauth-app:latest + volumes: + - fusionauth_config:/usr/local/fusionauth/config + - \${FUSIONAUTH_LOCAL_KICKSTART_DIRECTORY}:/usr/local/fusionauth/kickstart + volumes: + db_data: + fusionauth_config: + \`\`\` + +2. Start the services with docker compose up.`; + + const url = 'http://mcp-indented-fence.local/docs/docker'; + + server.use( + http.get( + url, + () => + new HttpResponse(html, { + status: 200, + headers: { 'Content-Type': 'text/html' }, + }), + ), + ); + + const ctx = makeCtx([{ url, markdown, htmlBody: html }], 'mcp-indented-fence.local'); + const result = await check.run(ctx); + expect(result.status).toBe('pass'); + const pageResults = result.details?.pageResults as Array<{ missingSegments: number }>; + expect(pageResults[0].missingSegments).toBe(0); + }); }); From edc0662d56422c488f2119cb2b08e9dc1af069b1 Mon Sep 17 00:00:00 2001 From: dacharyc Date: Sun, 23 Aug 2026 11:05:48 -0400 Subject: [PATCH 2/2] fix: match list-indented fence openers by captured indentation The {0,3} indent tolerance can't match fences inside list items, which are indented by the list marker width (4 spaces for Turndown's numbered lists). Capture the opener's indentation instead and require the closer at the same indent plus CommonMark's 0-3 slack, so deeper-indented literal fences inside a block can't close it early. Also extend the test fixture past the 10-segment comparison threshold (9 segments auto-pass regardless of the markdown), and revert the unrelated package-lock.json changes. --- package-lock.json | 9 +++++++++ .../observability/markdown-content-parity.ts | 14 +++++++------- test/unit/checks/markdown-content-parity.test.ts | 5 ++++- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3dd7325..61743db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1288,6 +1288,7 @@ "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1358,6 +1359,7 @@ "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/types": "8.56.0", @@ -1707,6 +1709,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2274,6 +2277,7 @@ "integrity": "sha512-20MV9SUdeN6Jd84xESsKhRly+/vxI+hwvpBMA93s+9dAcjdCuCojn4IqUGS3lvVaqjVYGYHSRMCpeFtF2rQYxQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -3195,6 +3199,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@inquirer/confirm": "^5.0.0", "@mswjs/interceptors": "^0.41.2", @@ -3434,6 +3439,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -3965,6 +3971,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4030,6 +4037,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -4105,6 +4113,7 @@ "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", diff --git a/src/checks/observability/markdown-content-parity.ts b/src/checks/observability/markdown-content-parity.ts index 2d25edd..c96ae6c 100644 --- a/src/checks/observability/markdown-content-parity.ts +++ b/src/checks/observability/markdown-content-parity.ts @@ -333,15 +333,15 @@ function extractMarkdownText(markdown: string): string { // matches; otherwise nested example fences (4-backtick outer, 3-backtick // inner) get mis-paired and inner markers leak out as text. // - // Per CommonMark §4.5, a fence may be indented 0–3 spaces (list items - // produced by Turndown indent fences by 4 spaces, which is still a valid - // CommonMark indented code fence inside a list continuation). The leading - // spaces are stripped from the opener match so the backreference on the - // closer still works. + // Fences inside list items are indented by the list marker width (e.g. + // Turndown indents them 4 spaces under "1. item"), so the opener may not + // sit at column 0. Capture the opener's indentation and require the closer + // at the same indent plus the 0-3 spaces of slack CommonMark allows, so a + // more deeply indented literal ``` inside the block can't close it early. const codeBlocks: string[] = []; text = text.replace( - /^ {0,3}(`{3,})[^`\n]*\n([\s\S]*?)^ {0,3}\1`*\s*$/gm, - (_match, _opener, content) => { + /^( *)(`{3,})[^`\n]*\n([\s\S]*?)^\1 {0,3}\2`*\s*$/gm, + (_match, _indent, _opener, content) => { const idx = codeBlocks.length; codeBlocks.push(content); return `\x00BLOCK${idx}\x00`; diff --git a/test/unit/checks/markdown-content-parity.test.ts b/test/unit/checks/markdown-content-parity.test.ts index 9b4fe44..f79f347 100644 --- a/test/unit/checks/markdown-content-parity.test.ts +++ b/test/unit/checks/markdown-content-parity.test.ts @@ -2691,6 +2691,7 @@ volumes:
  • Start the services with docker compose up.

    +

    The first startup may take several minutes while images download.

  • `; @@ -2719,7 +2720,9 @@ Use the steps below to install FusionAuth using Docker Compose. fusionauth_config: \`\`\` -2. Start the services with docker compose up.`; +2. Start the services with docker compose up. + + The first startup may take several minutes while images download.`; const url = 'http://mcp-indented-fence.local/docs/docker';