diff --git a/src/checks/observability/markdown-content-parity.ts b/src/checks/observability/markdown-content-parity.ts index c122736..c96ae6c 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. + // + // 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(/^(`{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( + /^( *)(`{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`; + }, + ); // 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..f79f347 100644 --- a/test/unit/checks/markdown-content-parity.test.ts +++ b/test/unit/checks/markdown-content-parity.test.ts @@ -2658,4 +2658,89 @@ 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.

    +

    The first startup may take several minutes while images download.

    +
  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. + + The first startup may take several minutes while images download.`; + + 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); + }); });