diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ff229d761..74f11b97b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -144,6 +144,9 @@ jobs: - name: Validate homepage JSON-LD run: npm run test:homepage-json-ld + - name: Validate page title order + run: npm run test:page-title-order + - name: Check internal links run: python3 .agents/skills/check_for_broken_links/check_links.py --internal-only - name: Test redirect invariants diff --git a/package.json b/package.json index 18983cd39..4046a9771 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "test:kapa-webmcp": "node --test src/lib/kapa-webmcp.test.mjs", "test:kapa-support-escalation": "node --test src/lib/kapa-support-escalation.test.mjs", "test:homepage-json-ld": "node scripts/validate-homepage-json-ld.mjs", + "test:page-title-order": "node scripts/validate-page-title-order.mjs", "postinstall": "patch-package" }, "engines": { diff --git a/scripts/validate-page-title-order.mjs b/scripts/validate-page-title-order.mjs new file mode 100644 index 000000000..dc0cfeb52 --- /dev/null +++ b/scripts/validate-page-title-order.mjs @@ -0,0 +1,107 @@ +#!/usr/bin/env node +// Validates the page-title markup in the built site (`npm run build` first). +// +// Search and answer engines anchor snippets on the H1 and the text right after +// it, so on every docs page that renders `CustomPageTitle`: +// - the page description (`p.page-subtitle`) is the H1's next element, and its +// text matches ``; +// - the text in the first 200 characters after `` starts with that +// description (no Copy-menu labels in between); +// - the page no longer ships the hidden `page-markdown-content` template that +// carried a second copy of the raw MDX source. +import assert from 'node:assert/strict'; +import { readdir, readFile } from 'node:fs/promises'; +import { join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseHTML } from 'linkedom'; + +const STATIC_DIR = fileURLToPath(new URL('../.vercel/output/static/', import.meta.url)); +const CONTENT_DIR = fileURLToPath(new URL('../src/content/docs/', import.meta.url)); +// Every docs page renders CustomPageTitle today (400 of 400 on 2026-10-28). Allow a +// 2% shortfall for pages that legitimately render without a title row (for example a +// future splash-template page), so a component change that drops the title row from +// a meaningful share of pages fails here instead of passing silently. +const MIN_COVERAGE = 0.98; +const WINDOW = 200; + +async function* filesWithExtension(dir, extensions) { + for (const entry of await readdir(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) yield* filesWithExtension(path, extensions); + else if (extensions.some((ext) => entry.name.endsWith(ext))) yield path; + } +} +const htmlFiles = (dir) => filesWithExtension(dir, ['.html']); + +let sourcePages = 0; +for await (const _ of filesWithExtension(CONTENT_DIR, ['.md', '.mdx'])) sourcePages++; +const minPages = Math.ceil(sourcePages * MIN_COVERAGE); + +const normalize = (text) => text.replace(/\s+/g, ' ').trim(); + +// Text of everything after `` in document order, skipping non-rendered +// elements, until `limit` characters are collected. +function textAfter(node, limit) { + const skip = new Set(['SCRIPT', 'STYLE', 'TEMPLATE', 'SVG', 'svg']); + let out = ''; + function walk(n) { + if (out.length >= limit) return; + // Adjacent text nodes (e.g. around an `'` entity) join directly; + // element boundaries count as whitespace. + if (n.nodeType === 3) out += n.textContent; + else if (n.nodeType === 1 && !skip.has(n.tagName)) { + out += ' '; + n.childNodes.forEach(walk); + out += ' '; + } + } + for (let cur = node; cur && out.length < limit; cur = cur.parentNode) { + for (let sib = cur.nextSibling; sib && out.length < limit; sib = sib.nextSibling) walk(sib); + } + return normalize(out).slice(0, limit); +} + +let checked = 0; +const failures = []; + +for await (const file of htmlFiles(STATIC_DIR)) { + const html = await readFile(file, 'utf8'); + if (!html.includes('page-title-row')) continue; + const page = relative(STATIC_DIR, file); + checked++; + + if (html.includes('page-markdown-content')) { + failures.push(`${page}: still ships the page-markdown-content template`); + } + + const { document } = parseHTML(html); + const h1 = document.querySelector('.page-title-row h1'); + const description = normalize( + document.querySelector('meta[name="description"]')?.getAttribute('content') ?? '', + ); + if (!h1) { + failures.push(`${page}: no H1 inside .page-title-row`); + continue; + } + if (!description) continue; + + const next = h1.nextElementSibling; + if (!next?.classList.contains('page-subtitle')) { + failures.push(`${page}: H1 is followed by <${next?.tagName?.toLowerCase()}>, not p.page-subtitle`); + continue; + } + if (normalize(next.textContent) !== description) { + failures.push(`${page}: page-subtitle text does not match the meta description`); + } + const after = textAfter(h1, WINDOW); + if (!after.startsWith(description.slice(0, Math.min(60, description.length)))) { + failures.push(`${page}: first ${WINDOW} chars after are "${after.slice(0, 80)}..."`); + } +} + +assert.ok( + checked >= minPages, + `Expected at least ${minPages} built pages with a page title row (${MIN_COVERAGE * 100}% of ${sourcePages} source docs), found ${checked}. Run \`npm run build\` first.`, +); +assert.deepEqual(failures, [], `Page title order check failed:\n${failures.join('\n')}`); +console.log(`Page title order validation passed (${checked} pages).`); diff --git a/src/components/CopyPageButton.astro b/src/components/CopyPageButton.astro index 56d5b8a3b..72cfde37f 100644 --- a/src/components/CopyPageButton.astro +++ b/src/components/CopyPageButton.astro @@ -1,76 +1,25 @@ --- /** * CopyPageButton — Scalar-style dropdown with: - * - Copy page (copies the MDX body as markdown, with imports stripped and - * product-name variables resolved -- see resolveMdxSource below) + * - Copy page (fetches the page's `.md` twin on click and copies it) * - Copy agent prompt (copies a context-appropriate prompt for the page type) * - View as Markdown (opens .md URL in new tab) * - Export as PDF (window.print) + * + * The `.md` twin (`/factories/` -> `/factories.md`, `/` -> `/index.md`) is + * written by `docs-markdown-integration.js` from the rendered HTML, so it has + * imports stripped, product-name variables resolved, and components rendered. + * Fetching it on click replaces the hidden `