Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
107 changes: 107 additions & 0 deletions scripts/validate-page-title-order.mjs
Original file line number Diff line number Diff line change
@@ -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 `<meta name="description">`;
// - the text in the first 200 characters after `</h1>` 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 `</h1>` 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 `&#39;` 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 </h1> 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).`);
185 changes: 69 additions & 116 deletions src/components/CopyPageButton.astro
Original file line number Diff line number Diff line change
@@ -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 `<template>` that used to carry a
* second copy of the raw MDX source in every page's HTML, directly after the
* H1, where it sat between the title and the page description.
*/
import { VARS } from '@data/vars';

// Not global, and applied line by line from the top of the file rather than
// across the whole body: an MDX component import only ever appears in the
// leading block, while a code sample can legitimately contain the same shape.
// A global replace ate `import OpenAI from "openai";` out of the Node example
// in guides/external-tools/how-to-set-up-ollama.mdx, so the copied snippet no
// longer ran.
const IMPORT_LINE = /^import\s+[^\n]*?from\s+['"][^'"]+['"];?\s*$/;
// The optional leading `$` matters: MDX authors write `{VARS.KEY}` in prose but
// `${VARS.KEY}` inside JSX template literals, e.g. href={`${VARS.WEB_APP_URL}/runs`}.
// Matching only the braces leaves the dollar behind and yields `$https://...`.
const PROSE_TOKEN = /\$?\{VARS\.([A-Z0-9_]+)\}/g;
const FRONTMATTER_TOKEN = /\{\{([A-Z0-9_]+)\}\}/g;

interface Props {
body: string;
title: string;
/** Optional custom agent prompt. Overrides all automatic prompt selection when set. */
agentPrompt?: string;
}
const { body, title, agentPrompt } = Astro.props;

/**
* `body` arrives as raw MDX source, so it still carries authoring scaffolding:
* `import` statements at the top and unsubstituted `{VARS.KEY}` / `{{KEY}}`
* product-name tokens throughout.
*
* That matters more than it looks. This payload is what "Copy page as Markdown
* for LLMs" puts on the clipboard, so without this an assistant reading the
* copied text sees the literal string `{VARS.WARP_AUTOMATION_PLATFORM}` instead
* of the product name -- worse than the stale name it replaced.
*
* The sibling `.md` route does not have this problem because
* `docs-markdown-integration.js` derives those files from the *rendered* HTML
* after the build, by which point Astro has resolved everything. This component
* runs during render and only has the source, so it repeats the two cleanups.
*
* Known gap: raw JSX (`<VideoEmbed />`) and directive syntax (`:::note`) still
* pass through here, where the `.md` route renders them properly. The durable
* fix is for this button to fetch the page's own `.md` URL on click rather than
* carry a second copy of the content.
*/
/** Drops the leading `import` block, stopping at the first line of content. */
function stripLeadingImports(source: string): string {
const lines = source.split('\n');
let start = 0;
while (
start < lines.length &&
(lines[start].trim() === '' || IMPORT_LINE.test(lines[start]))
) {
start++;
}
return lines.slice(start).join('\n');
}
const { agentPrompt } = Astro.props;

function resolveMdxSource(source: string): string {
const vars = VARS as unknown as Record<string, string>;
return stripLeadingImports(source)
.replace(PROSE_TOKEN, (match, key) => vars[key] ?? match)
.replace(FRONTMATTER_TOKEN, (match, key) => vars[key] ?? match);
}

const markdownContent = `# ${title}\n\n${resolveMdxSource(body)}`;
const pageUrl = Astro.url.href;
const pathname = Astro.url.pathname;

Expand Down Expand Up @@ -184,11 +133,9 @@ const claudeUrl = `https://claude.ai/new?q=${prompt}`;
</button>
</div>

<span class="copy-toast" id="copy-toast">Copied!</span>
<span class="copy-toast" id="copy-toast" role="status" aria-live="polite">Copied!</span>
</div>

<!-- Store the markdown content in a hidden element so client JS can read it -->
<template id="page-markdown-content">{markdownContent}</template>
<!-- Store the agent prompt in a hidden element so client JS can read it -->
<template id="agent-prompt-content">{agentPromptText}</template>

Expand All @@ -205,7 +152,6 @@ const claudeUrl = `https://claude.ai/new?q=${prompt}`;
const copyPromptBtn = document.getElementById('copy-agent-prompt');
const viewMdBtn = document.getElementById('view-as-markdown');
const exportBtn = document.getElementById('export-as-pdf');
const mdTemplate = document.getElementById('page-markdown-content') as HTMLTemplateElement | null;
const promptTemplate = document.getElementById('agent-prompt-content') as HTMLTemplateElement | null;

if (!wrapper || !trigger || !panel) return;
Expand All @@ -228,29 +174,57 @@ const claudeUrl = `https://claude.ai/new?q=${prompt}`;
}
}

// Copy page as Markdown — reads from the template element (raw MDX body).
// Use `template.content.textContent` because `<template>` children live in
// the DocumentFragment, not as direct children of the element itself,
// so `template.textContent` returns an empty string.
// `.md` twin of the current page: `/factories/` -> `/factories.md`,
// `/` -> `/index.md`. The `[...slug].md.ts` endpoint serves it in dev; in
// prod the build-time docs-markdown integration writes it as a static file.
function markdownUrl(): string {
const path = window.location.pathname.replace(/\/$/, '') || '/index';
return path + '.md';
}

async function fetchMarkdown(): Promise<string> {
const res = await fetch(markdownUrl(), { headers: { Accept: 'text/markdown' } });
if (!res.ok) throw new Error(`Markdown fetch failed: ${res.status}`);
const text = await res.text();
if (!text.trim()) throw new Error('Markdown fetch returned an empty body');
return text;
}

// Copy page as Markdown — fetches the `.md` twin on click.
//
// The site is served over HTTPS and the click is a user-gesture handler,
// so `navigator.clipboard.writeText` is the only path we need — we
// previously had a `document.execCommand('copy')` textarea fallback for
// legacy browsers, but that path is deprecated, broken on iOS Safari,
// and never actually runs in production.
// Safari only lets a clipboard write through while the click's user
// activation is live, so awaiting the fetch before `writeText` can fail
// there. Passing a still-pending Blob promise to `ClipboardItem` starts
// the write synchronously inside the gesture and lets the browser wait
// for the data (supported in Safari, Chrome, and current Firefox). If
// `ClipboardItem` is missing or rejects the promise form, fall back to
// fetch-then-`writeText`, which Chrome and Firefox allow after an await.
copyBtn?.addEventListener('click', async () => {
const markdown = mdTemplate?.content.textContent || '';
if (markdown) {
try {
await navigator.clipboard.writeText(markdown);
showToast();
} catch {
// Permission denied / insecure context — fail silently rather than
// copy nothing and pretend it worked.
}
}
panel?.classList.remove('open');
trigger?.classList.remove('active');

try {
if (typeof ClipboardItem !== 'undefined' && navigator.clipboard?.write) {
const blob = fetchMarkdown().then(
(text) => new Blob([text], { type: 'text/plain' }),
);
// Avoid an unhandled-rejection report; failures surface via `await` below.
blob.catch(() => {});
try {
await navigator.clipboard.write([new ClipboardItem({ 'text/plain': blob })]);
} catch {
// Rethrows if the fetch itself failed, so no false success.
await navigator.clipboard.writeText(await (await blob).text());
}
} else {
await navigator.clipboard.writeText(await fetchMarkdown());
}
showToast('Copied!');
} catch {
// Fetch failed, permission denied, or insecure context — say so
// rather than pretend the copy worked.
showToast('Copy failed');
}
});

// Copy agent prompt
Expand All @@ -259,7 +233,7 @@ const claudeUrl = `https://claude.ai/new?q=${prompt}`;
if (agentPrompt) {
try {
await navigator.clipboard.writeText(agentPrompt);
showToast();
showToast('Copied!');
} catch {
// Permission denied / insecure context — fail silently.
}
Expand All @@ -268,37 +242,12 @@ const claudeUrl = `https://claude.ai/new?q=${prompt}`;
trigger?.classList.remove('active');
});

// View as Markdown — prefer the `.md` URL (shareable, stable path).
// The `[...slug].md.ts` endpoint serves it in dev; in prod the build-time
// docs-markdown integration writes it as a static file. If for any reason
// the `.md` URL isn't available, fall back to a Blob URL built from the
// raw body passed as a prop.
viewMdBtn?.addEventListener('click', async () => {
// View as Markdown — opens the `.md` twin (shareable, stable path).
// Opened synchronously so popup blockers see the click's user gesture.
viewMdBtn?.addEventListener('click', () => {
panel?.classList.remove('open');
trigger?.classList.remove('active');

const path = window.location.pathname.replace(/\/$/, '') || '/index';
const mdUrl = path + '.md';

// Probe the .md URL with a HEAD request. If it responds OK, navigate there.
try {
const res = await fetch(mdUrl, { method: 'HEAD' });
if (res.ok) {
window.open(mdUrl, '_blank', 'noopener');
return;
}
} catch {
// Fall through to the Blob URL fallback below.
}

// Fallback: open the body directly as a Blob URL.
const markdown = mdTemplate?.content.textContent || '';
if (markdown) {
const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' });
const blobUrl = URL.createObjectURL(blob);
window.open(blobUrl, '_blank', 'noopener');
setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000);
}
window.open(markdownUrl(), '_blank', 'noopener');
});

// Export as PDF
Expand All @@ -308,9 +257,13 @@ const claudeUrl = `https://claude.ai/new?q=${prompt}`;
window.print();
});

function showToast() {
toast?.classList.add('visible');
setTimeout(() => toast?.classList.remove('visible'), 2000);
let toastTimer: ReturnType<typeof setTimeout> | undefined;
function showToast(message: string) {
if (!toast) return;
toast.textContent = message;
toast.classList.add('visible');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => toast.classList.remove('visible'), 2000);
}
}

Expand Down
Loading
Loading