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); + }); });