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.
+
+ -
+
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:
+
+
+ -
+
Start the services with docker compose up.
+ The first startup may take several minutes while images download.
+
+
+ `;
+
+ 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);
+ });
});