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