seo: make the docs link graph crawlable (#113)

## Why

Google has indexed effectively nothing on `docs.paperclip.ing`. Search
Console over 3 months: **8 of 192 URLs** have ever drawn an impression,
**681 of 975 impressions (70%) go to
`/guides/agent-developer/comments-and-communication`** — a legacy URL
that 301s and isn't in the sitemap. Strip that, the homepage, and the
team's own `site:` queries, and the canonical docs corpus earned **~7
organic impressions in 90 days**.

It isn't an eligibility problem. Pages are fetchable, self-canonical,
`index, follow`, and identical under a Googlebot UA. It's the internal
link graph.

## What was actually broken

**1. Every relative markdown link 404'd.** Authors write
`./glossary.md`, but pages are served from trailing-slash route
directories, so links resolved one level too deep:

```
page: /guides/welcome/key-concepts/   href: ./glossary.md
  → /guides/welcome/key-concepts/glossary.md   404
  ( /guides/welcome/glossary.md would be 200 )
```

Crawled all 192 live pages: **all 742 unique `.md` targets return 404**.
That's **1,283 of 1,306 in-body internal links — 98% — broken**, leaving
**0.1 working in-body links per page**.

It went unnoticed because `app.js:postProcessInternalLinks` repairs
these at runtime. Crawlers index the pre-render.

**2. The sidebar wasn't server-rendered.** Interior pages shipped `<div
id="sb-sections"></div>`, filled by JS. So the raw HTML offered the
logo, prev, next — and ~7 dead links. No link equity reached deep pages.
Search Console classified 98% of crawling as refresh, 2% as discovery.

Net effect: a crawler saw every page as a dead end that was 98% broken
links. Crawl demand collapsed — 14 crawled of 184, 15% of crawl
responses 404.

## Changes

- **Rewrite markdown links to canonical routes at build time**, and fail
the build on any link no nav page publishes. (Passed first run with zero
unresolved — every link had a valid destination, just emitted in a
404ing form.)
- **Server-render the sidebar and breadcrumb** into every interior route
from the manifest `app.js` already uses. `app.js` detects
`data-server-rendered` and keeps the DOM rather than re-rendering
identical markup. The mobile drawer stays client-built — it's
`inert`/`aria-hidden` until a JS toggle opens it, so rendering it
duplicated 192 links/page (~41KB) for no crawl benefit.
- **Intercept canonical route links in articles** so in-page navigation
stays a soft transition instead of a full reload.
- **Omit sitemap `<lastmod>` when git history is untrustworthy.**
Reproduced the real mechanism: in a `--depth=1` clone, `git log -1`
returns the single fetched commit's date for *every* file. Two files
that differ locally (`2026-08-18` / `2026-07-08`) both come back
`2026-08-25` shallow. That is exactly the live `192 × 2026-08-20`.
Cloudflare Pages shallow-clones by default.
- **Ship `redirects.json`** — `app.js` fetches it at boot and it 404'd.
- **Canonicalise the footer's `/reference/api/overview`** link, which
spent a 301 on all 193 pages.
- **Add `scripts/verify-crawlable-links.mjs` + CI**, gating on raw `.md`
hrefs, missing targets, redirecting targets, relative hrefs, an
unrendered sidebar, and uniform sitemap dates.

The last two were both caught by the new verifier on its first run.

## Result

| | Before (production) | After (generated bundle) |
|---|---|---|
| Broken in-body links | 1,283 (98%) | **0** |
| Working in-body links | 23 | **1,501** |
| Crawlable nav links/page | 0 | **192** |
| Distinct sitemap dates | 1 | **24** (0 on shallow CI) |

`Crawlable-link contract passed: 41078 internal links across 194 pages
resolve in one hop.`

## Verified

Built and driven in a real browser at `localhost:4321`:

- Raw HTML (crawler / no-JS view): 192 inline sidebar links, breadcrumb
rendered, **0 local `.md` hrefs**, single H1. Remaining empty containers
are JS-only UI (search modal, drawer, runtime status).
- SPA intact, no reloads, no JS errors: in-article links, prev/next,
sidebar links, mobile drawer (opens, 192 links, closes on navigate),
search (8 results for "adapter"). Breadcrumb and active-link state
follow correctly throughout.
- Subpath build for gh-pages emits correct `/paperclip-docs/...` hrefs.
- Homepage unchanged: 15 landing cards, 192 directory links.
- Shallow-clone build confirmed to warn and emit zero `<lastmod>`.

## Notes for review

- **This can't ship until the Cloudflare GitHub App is unsuspended.**
Everything here is verified against a local build only.
- **CI will be red on one pre-existing, unrelated failure**:
`docs:test:skill-source-blocks` on
`docs/reference/skills/optional/content/simplified-english.md` (missing
its skill-source block, from release #95). Confirmed failing on clean
`main`. Left in the aggregate rather than weakening the suite — fixing
it needs the real `SKILL.md` from the parent repo, which is sync-docs
work.
- **Production will now ship no `lastmod` at all** on Cloudflare's
shallow clone. Correct versus lying. To get real dates back, set the
Pages build command to `git fetch --unshallow || true; npm run
docs:build` — documented in the generated `DEPLOY.md`.
- Page weight 67KB → 109KB from the inlined nav. Highly repetitive,
compresses well, but it is a real increase.
- Weak/duplicate titles were deliberately skipped, per the plan's own
ranking (secondary).
- Plan item 4 (contextual links from high-authority surfaces to 20–25
priority pages) is a `paperclip-website` change plus editorial judgment,
so it's out of scope here. Worth noting that fixing the internal graph
recovers most of what that item was meant to buy — the dominant
authority failure was internal, not off-site.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Aron PrinsandClaude Opus 5 authored and GitHub committed 2026-08-25 21:57:07 +02:00
1 parent 63e10a3cd6
commit 29beedeb6a
8 files changed
+559 -127

No files matched your search

+45
View File
@@ -0,0 +1,45 @@
name: Docs checks
on:
pull_request:
push:
branches: [main, nightly]
jobs:
docs-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# Sitemap <lastmod> is derived from per-file git history. A shallow
# checkout collapses every file to the same commit date, which the
# build detects and responds to by omitting the dates entirely.
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
# Separate steps with `if: always()` so one failure cannot mask the rest.
# An `&&` chain short-circuits, which silently skipped later gates.
- name: Static routes
run: npm run docs:test:static-routes
- name: Asset fingerprints
if: always()
run: npm run docs:test:asset-fingerprints
- name: Hierarchical skills nav
if: always()
run: npm run docs:test:hierarchical-skills-nav
- name: Crawlable links
if: always()
run: npm run docs:test:crawlable-links
- name: Skill source blocks
if: always()
run: npm run docs:test:skill-source-blocks
+2
View File
@@ -10,6 +10,8 @@
"docs:test:asset-fingerprints": "node scripts/verify-asset-fingerprints.mjs",
"docs:test:hierarchical-skills-nav": "node scripts/verify-hierarchical-skills-nav.mjs",
"docs:test:skill-source-blocks": "node scripts/verify-skill-source-blocks.mjs",
"docs:test:crawlable-links": "node scripts/verify-crawlable-links.mjs",
"docs:test": "npm run docs:test:static-routes && npm run docs:test:asset-fingerprints && npm run docs:test:hierarchical-skills-nav && npm run docs:test:skill-source-blocks && npm run docs:test:crawlable-links",
"docs:serve": "python3 -m http.server 4321 --directory .site",
"sync:lint-links": "node scripts/sync/lint-links.mjs",
"sync:verify-nav": "node scripts/sync/verify-nav.mjs",
+220
View File
@@ -0,0 +1,220 @@
#!/usr/bin/env node
/**
* Crawlable-link contract for the generated docs bundle.
*
* Every internal href in the shipped HTML must be a link a crawler can follow
* in one hop to an indexable page. This fails the build on the four ways that
* stops being true:
*
* 1. raw `.md` hrefs — app.js repairs these at runtime, crawlers never
* see the repair, and the raw URL 404s
* 2. missing targets — the route has no generated document
* 3. redirecting targets — a link that spends a hop on a 301
* 4. non-canonical hrefs — an absolute URL that disagrees with the page's
* own canonical form
*
* It also asserts interior routes ship their navigation server-rendered, so
* link equity actually reaches deep pages rather than dying at the homepage.
*/
import { mkdtempSync, readFileSync, existsSync, readdirSync, statSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, relative, posix } from "node:path";
import { spawnSync } from "node:child_process";
const root = process.cwd();
const outDir = mkdtempSync(join(tmpdir(), "paperclip-docs-crawlable-links-"));
const SITE_ORIGIN = "https://docs.paperclip.ing";
const failures = [];
function fail(message) {
failures.push(message);
}
const build = spawnSync(
process.execPath,
["site/build-release.mjs", "--base-path", "/", "--out-dir", outDir],
{ cwd: root, encoding: "utf8" },
);
if (build.status !== 0) {
console.error(`docs build failed\nstdout:\n${build.stdout}\nstderr:\n${build.stderr}`);
process.exit(1);
}
function walk(dir) {
const found = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) found.push(...walk(full));
else if (entry.endsWith(".html")) found.push(full);
}
return found;
}
/* Routes that intentionally 301, parsed from the generated _redirects. A link
pointing at one of these wastes a crawl hop even though it resolves. */
const redirectSources = new Set();
const redirectsPath = join(outDir, "_redirects");
if (existsSync(redirectsPath)) {
for (const line of readFileSync(redirectsPath, "utf8").split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const [source] = trimmed.split(/\s+/);
if (source) redirectSources.add(source);
}
}
function hrefsIn(html) {
return [...html.matchAll(/href="([^"]*)"/g)]
.map((match) => match[1])
.map((href) => href
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'"));
}
/* A route href resolves if the bundle contains the document it names, either
as `<route>/index.html` or as a literal file. */
function resolvesInBundle(pathname) {
const clean = pathname.replace(/^\/+/, "");
if (clean === "") return existsSync(join(outDir, "index.html"));
if (pathname.endsWith("/")) return existsSync(join(outDir, clean, "index.html"));
const literal = join(outDir, clean);
return existsSync(literal) && statSync(literal).isFile();
}
const htmlFiles = walk(outDir);
if (htmlFiles.length < 100) {
fail(`Expected the bundle to contain the full docs set; found ${htmlFiles.length} HTML files.`);
}
let checkedLinks = 0;
for (const file of htmlFiles) {
const rel = relative(outDir, file);
const html = readFileSync(file, "utf8");
for (const href of hrefsIn(html)) {
if (!href || href.startsWith("#") || href.startsWith("data:") || href.startsWith("mailto:")) continue;
let pathname = null;
if (/^https?:\/\//i.test(href)) {
let parsed;
try {
parsed = new URL(href);
} catch {
fail(`${rel}: unparseable href "${href}"`);
continue;
}
if (parsed.origin !== SITE_ORIGIN) continue;
pathname = parsed.pathname;
} else if (href.startsWith("/")) {
pathname = href.split("#")[0].split("?")[0];
} else {
// A relative href in a static route directory resolves against that
// directory, which is exactly how the `.md` links were 404ing.
fail(`${rel}: relative href "${href}" — emit an absolute route path instead`);
continue;
}
if (!pathname) continue;
checkedLinks += 1;
if (pathname.endsWith(".md")) {
fail(`${rel}: markdown href "${href}" — link to the canonical HTML route instead`);
continue;
}
if (redirectSources.has(pathname)) {
fail(`${rel}: href "${href}" hits a 301 in _redirects — link to the redirect target directly`);
continue;
}
if (!resolvesInBundle(pathname)) {
fail(`${rel}: href "${href}" has no target in the build (would 404)`);
}
}
}
/* Deep pages need the navigation in their shipped bytes, not after app.js.
This is also where full-manifest coverage is guaranteed: the homepage used
to carry a "Browse all documentation" directory, and the sidebar is what
replaced it as the surface that reaches every route. */
const sitemapForNav = readFileSync(join(outDir, "sitemap.xml"), "utf8");
const manifestRoutes = [...sitemapForNav.matchAll(/<loc>https:\/\/docs\.paperclip\.ing(\/[^<]*)<\/loc>/g)]
.map((match) => match[1])
.filter((routePath) => routePath !== "/");
const interiorSamples = [
"guides/welcome/key-concepts/index.html",
"reference/adapters/overview/index.html",
"administration/settings/index.html",
];
for (const sample of interiorSamples) {
const full = join(outDir, sample);
if (!existsSync(full)) {
fail(`Expected sample route ${sample} to exist in the build.`);
continue;
}
const html = readFileSync(full, "utf8");
const sidebar = html.match(/<div id="sb-sections"[^>]*>([\s\S]*?)<\/nav>/);
if (!sidebar) {
fail(`${sample}: could not locate the #sb-sections container.`);
continue;
}
if (!/data-server-rendered="true"/.test(sidebar[0])) {
fail(`${sample}: sidebar navigation is not server-rendered.`);
}
const sidebarHrefs = new Set(
[...sidebar[1].matchAll(/<a class="sb-link[^"]*"[^>]*href="([^"]+)"/g)].map((match) => match[1]),
);
const missing = manifestRoutes.filter((routePath) => !sidebarHrefs.has(routePath));
if (missing.length > 0) {
fail(
`${sample}: server-rendered sidebar is missing ${missing.length} manifest route(s), `
+ `starting with ${missing[0]}`,
);
}
if (sidebarHrefs.size !== manifestRoutes.length) {
fail(
`${sample}: sidebar link count (${sidebarHrefs.size}) does not match the manifest `
+ `(${manifestRoutes.length})`,
);
}
}
/* Every section must still be reachable from the homepage itself. */
const rootHtml = readFileSync(join(outDir, "index.html"), "utf8");
const rootCardHrefs = new Set(
[...rootHtml.matchAll(/<a class="card" href="([^"]+)"/g)].map((match) => match[1]),
);
if (rootCardHrefs.size < 10) {
fail(`index.html: homepage exposes only ${rootCardHrefs.size} section links.`);
}
for (const href of rootCardHrefs) {
if (!resolvesInBundle(href)) fail(`index.html: homepage card href "${href}" has no target.`);
}
/* Uniform sitemap dates are worse than none — they claim the whole site
changed at once on every resubmission. */
const sitemap = readFileSync(join(outDir, "sitemap.xml"), "utf8");
const lastmods = [...sitemap.matchAll(/<lastmod>([^<]+)<\/lastmod>/g)].map((match) => match[1]);
if (lastmods.length > 10 && new Set(lastmods).size === 1) {
fail(`sitemap.xml: all ${lastmods.length} entries share one <lastmod> (${lastmods[0]}); omit the dates instead.`);
}
if (failures.length > 0) {
const shown = failures.slice(0, 40);
console.error(`\nCrawlable-link contract failed with ${failures.length} problem(s):\n`);
for (const message of shown) console.error(` - ${message}`);
if (failures.length > shown.length) {
console.error(` ... and ${failures.length - shown.length} more.`);
}
process.exit(1);
}
console.log(
`Crawlable-link contract passed: ${checkedLinks} internal links across ${htmlFiles.length} pages `
+ `resolve in one hop, the server-rendered sidebar covers all ${manifestRoutes.length} manifest `
+ `routes, and sitemap dates are not uniform.`,
);
+14 -27
View File
@@ -45,7 +45,7 @@ const REPRESENTATIVE_ROUTES = [
route: "/",
file: "index.html",
h1: '<h1 id="landing-title">Everything you need to <em>run Paperclip.</em></h1>',
contentMarker: '<nav id="landing-directory" aria-labelledby="landing-directory-title">',
contentMarker: '<div class="card-grid" id="landing-cards" data-server-rendered="true">',
isDocsRoot: true,
},
{
@@ -296,28 +296,16 @@ try {
rootHtml.includes('<div class="card-grid" id="landing-cards" data-server-rendered="true">'),
"/: the homepage card grid must be server-rendered",
);
const directoryHtml = rootHtml.match(/<nav id="landing-directory"[\s\S]*?<\/nav>/)?.[0];
assert(directoryHtml, "/: the homepage directory is missing from raw HTML");
const directoryHrefs = new Set(
[...directoryHtml.matchAll(/<a href="([^"]+)"/g)].map((match) => match[1]),
/* Full-manifest coverage moved off the homepage and onto the server-rendered
sidebar that every interior route carries; verify-crawlable-links.mjs owns
that assertion now. The homepage still opens every section via its cards. */
const cardHrefs = new Set(
[...rootHtml.matchAll(/<a class="card" href="([^"]+)"/g)].map((match) => match[1]),
);
const sitemapRoutes = [...sitemap.matchAll(/<loc>https:\/\/docs\.paperclip\.ing(\/[^<]*)<\/loc>/g)]
.map((match) => match[1])
.filter((routePath) => routePath !== "/");
for (const routePath of sitemapRoutes) {
assert(
directoryHrefs.has(routePath),
`/: the homepage directory is missing the canonical-manifest route ${routePath}`,
);
}
assert(
directoryHrefs.size === sitemapRoutes.length,
`/: homepage directory link count (${directoryHrefs.size}) does not match the manifest (${sitemapRoutes.length})`,
cardHrefs.size >= 10,
`/: the homepage should link every section; found ${cardHrefs.size} card links`,
);
for (const { route, isDocsRoot } of REPRESENTATIVE_ROUTES) {
if (isDocsRoot) continue;
assert(directoryHrefs.has(route), `/: the homepage directory is missing a link to ${route}`);
}
assert(
/<a href="\/" [^>]*data-nav="home"/.test(rootHtml) || /<a [^>]*href="\/"[^>]*data-nav="home"/.test(rootHtml),
"/: docs-root links must be real anchors so they work without JavaScript",
@@ -373,16 +361,15 @@ try {
}
}
const subpathRootHtml = readFileSync(join(subpathOutDir, "index.html"), "utf8");
const subpathDirectory = subpathRootHtml.match(/<nav id="landing-directory"[\s\S]*?<\/nav>/)?.[0];
assert(subpathDirectory, `${SUBPATH_BASE}: the homepage directory is missing from raw HTML`);
const subpathHrefs = [...subpathDirectory.matchAll(/<a href="([^"]+)"/g)].map((match) => match[1]);
const subpathCardHrefs = [...subpathRootHtml.matchAll(/<a class="card" href="([^"]+)"/g)]
.map((match) => match[1]);
assert(
subpathHrefs.length === directoryHrefs.size,
`${SUBPATH_BASE}: the homepage directory should cover the same manifest as the root build`,
subpathCardHrefs.length === cardHrefs.size,
`${SUBPATH_BASE}: the homepage should expose the same sections as the root build`,
);
assert(
subpathHrefs.every((href) => href.startsWith(SUBPATH_BASE)),
`${SUBPATH_BASE}: homepage directory links must be prefixed with the configured base path`,
subpathCardHrefs.every((href) => href.startsWith(SUBPATH_BASE)),
`${SUBPATH_BASE}: homepage links must be prefixed with the configured base path`,
);
assert(
subpathRootHtml.includes(`<a href="${SUBPATH_BASE}" class="navbar-link" data-nav="home">`),
+31 -1
View File
@@ -1036,7 +1036,12 @@ function wireSidebarContainer(container) {
function buildSidebar() {
const container = document.getElementById('sb-sections');
container.innerHTML = sidebarSectionsHTML();
// Interior routes ship the sidebar server-rendered from the same manifest,
// so crawlers see all of it. Keep that DOM instead of replacing it with
// byte-identical markup — re-rendering only costs a flash of layout.
if (container.dataset.serverRendered !== 'true') {
container.innerHTML = sidebarSectionsHTML();
}
wireSidebarContainer(container);
renderLucideIcons();
}
@@ -1602,6 +1607,14 @@ function postProcessTables(root) {
});
}
function findPageByRoutePath(routePath) {
const basePath = APP_BASE_URL.pathname.replace(/\/$/, '');
if (basePath && !routePath.startsWith(`${basePath}/`) && routePath !== `${basePath}/`) return null;
const slug = normalizeRouteKey(routePath.slice(basePath.length));
if (!slug) return null;
return allPages.find(candidate => candidate.slug === slug) || null;
}
function postProcessInternalLinks(root) {
root.querySelectorAll('a[href]').forEach(a => {
const href = a.getAttribute('href');
@@ -1636,6 +1649,23 @@ function postProcessInternalLinks(root) {
}
const [docHref, headingHref] = href.split('#');
// Static builds already emit canonical route hrefs. Route them through the
// client so an in-article link is still a soft transition, not a reload.
if (docHref && docHref.startsWith('/')) {
const routePage = findPageByRoutePath(docHref);
if (routePage) {
a.addEventListener('click', e => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
e.preventDefault();
loadPage(routePage.file, headingHref || null);
});
}
return;
}
// Legacy relative markdown links, still present when the SPA renders
// markdown it fetched at runtime.
if (docHref && docHref.endsWith('.md')) {
const baseDir = currentFile.replace(/\/[^/]+$/, '/');
const targetFile = normalizeDocPath(baseDir + docHref);
+246 -35
View File
@@ -642,8 +642,35 @@ async function pageMetadataForNav(nav, outDir, siteUrl, basePath) {
return pages;
}
// A shallow or grafted checkout — what CI providers clone by default — has a
// single commit, so `git log -1` reports that commit's date for *every* file.
// The dates look valid and are uniformly wrong, which is worse than having
// none: every sitemap resubmission then claims the whole site changed at once
// and Google learns to discount the signal.
let trustworthyGitHistory = null;
async function hasTrustworthyGitHistory() {
if (trustworthyGitHistory !== null) return trustworthyGitHistory;
try {
const { stdout } = await execFileAsync("git", ["rev-parse", "--is-shallow-repository"], {
cwd: repoRoot,
});
const isShallow = stdout.trim() === "true";
if (isShallow) {
console.warn(
"Shallow git checkout detected: omitting sitemap <lastmod>. "
+ "Clone with full history (actions/checkout fetch-depth: 0) to publish real dates.",
);
}
trustworthyGitHistory = !isShallow;
} catch {
trustworthyGitHistory = false;
}
return trustworthyGitHistory;
}
async function gitLastModified(sourcePath) {
if (!isPathInside(repoRoot, sourcePath)) return undefined;
if (!(await hasTrustworthyGitHistory())) return undefined;
const repoRelativePath = toPosixPath(path.relative(repoRoot, sourcePath));
try {
const { stdout } = await execFileAsync(
@@ -660,6 +687,20 @@ async function gitLastModified(sourcePath) {
}
}
// Last defence for any history shape that still collapses to a single date:
// a 192-page docs set does not genuinely change in one commit.
const UNIFORM_LASTMOD_MIN_PAGES = 10;
export function dropUniformLastmod(pages) {
const dated = pages.filter((page) => page.lastmod);
if (dated.length < UNIFORM_LASTMOD_MIN_PAGES) return pages;
if (new Set(dated.map((page) => page.lastmod)).size > 1) return pages;
console.warn(
`All ${dated.length} pages report the same git date (${dated[0].lastmod}); `
+ "omitting sitemap <lastmod> rather than claiming the whole site changed at once.",
);
return pages.map((page) => ({ ...page, lastmod: undefined }));
}
function buildSitemap({ siteUrl, basePath, pages }) {
const rootLastmod = pages
.map((page) => page.lastmod)
@@ -993,10 +1034,60 @@ function isSkillSourceInfo(infostring) {
return String(infostring || "").trim().split(/\s+/).includes("skill-source");
}
export function renderStaticMarkdown(markdown) {
// docs-root-relative markdown path -> the canonical HTML route for that page.
// Markdown authors write relative `../foo.md` links, but every page is served
// from a trailing-slash route directory, so a raw relative link resolves one
// level too deep and 404s for anything that has not run app.js yet.
export function buildDocRouteMap(nav, basePath) {
const routeMap = new Map();
for (const { page } of flattenNavPages(nav)) {
routeMap.set(page.file, routePathForSlug(basePath, page.slug));
}
return routeMap;
}
/**
* Resolve a markdown link against the document that contains it.
*
* Returns `null` for links this pass does not own (external URLs, in-page
* anchors, images and other non-markdown assets). For a markdown link it
* always reports the resolved docs-root-relative `targetFile`, and a `route`
* that is `null` when no page in the nav publishes that file.
*/
export function resolveDocHref(href, sourceFile, routeMap) {
if (typeof href !== "string" || !href) return null;
if (!isLocalDocHref(href)) return null;
const hashIndex = href.indexOf("#");
const docHref = hashIndex === -1 ? href : href.slice(0, hashIndex);
const hash = hashIndex === -1 ? "" : href.slice(hashIndex);
if (!docHref.endsWith(".md")) return null;
const sourceDir = sourceFile.includes("/") ? sourceFile.replace(/\/[^/]*$/, "") : "";
const targetFile = normalizeDocPath(sourceDir ? `${sourceDir}/${docHref}` : docHref);
const route = routeMap.get(targetFile);
return { targetFile, route: route ? `${route}${hash}` : null };
}
export function renderStaticMarkdown(markdown, { sourceFile = null, routeMap = null, onUnresolvedLink = null } = {}) {
const renderer = new marked.Renderer();
const usedHeadingIds = new Set();
renderer.image = releaseMarkdownImage;
// Emit canonical route hrefs into the static HTML. app.js repairs these at
// runtime too, but crawlers index the pre-render, so the bytes we ship have
// to already be correct.
if (routeMap && sourceFile) {
const defaultLink = renderer.link.bind(renderer);
renderer.link = (href, title, text) => {
const resolved = resolveDocHref(href, sourceFile, routeMap);
if (!resolved) return defaultLink(href, title, text);
if (!resolved.route) {
onUnresolvedLink?.({ sourceFile, href, targetFile: resolved.targetFile });
return defaultLink(href, title, text);
}
return defaultLink(resolved.route, title, text);
};
}
const defaultCode = renderer.code.bind(renderer);
renderer.code = (code, infostring, escaped) => {
if (isSkillSourceInfo(infostring)) {
@@ -1135,41 +1226,104 @@ function buildLandingCardsHtml(nav, basePath, iconPaths) {
}).join("");
}
function landingDirectoryListHtml(nodes, basePath) {
const items = getNavChildren({ pages: nodes }).map((node) => {
// ── Server-rendered navigation chrome ───────────────────────────────────────
// app.js builds the sidebar, mobile drawer and breadcrumb from content.json on
// load. That leaves interior routes shipping an empty `#sb-sections`, so the
// only crawlable links in the raw HTML are the logo and the prev/next pair and
// no link equity ever reaches a deep page. These builders emit the same markup
// at build time; app.js keeps the DOM and just wires the click handlers.
function staticSidebarPagesHtml(nodes, basePath, activeFile, level = 0) {
return getNavChildren({ pages: nodes }).map((node) => {
if (isNavPage(node)) {
const href = routePathForSlug(basePath, node.slug);
return `<li><a href="${escapeAttr(href)}">${escapeHtml(node.title)}</a></li>`;
const isActive = node.file === activeFile;
return `<a class="sb-link${isActive ? " active" : ""}" data-file="${escapeAttr(node.file)}"`
+ `${isActive ? ' aria-current="page"' : ""}`
+ ` href="${escapeAttr(href)}">${escapeHtml(node.title)}</a>`;
}
const children = getNavChildren(node);
if (!children.length) return "";
return `<li class="landing-directory-group">`
+ `<span class="landing-directory-group-title">${escapeHtml(node.title || "Group")}</span>`
+ landingDirectoryListHtml(children, basePath)
+ `</li>`;
}).filter(Boolean).join("");
return items ? `<ul class="landing-directory-list">${items}</ul>` : "";
const pageCount = countNavPages(children);
const isOpen = navSubtreeContainsFile(children, activeFile);
return `<div class="sb-group" data-open="${isOpen}" data-depth="${level}">`
+ `<button class="sb-group-btn" type="button">`
+ `<span class="sb-group-title">${escapeHtml(node.title || "Group")}</span>`
+ `<span class="sb-group-count">${pageCount}</span>`
+ `<svg class="chev" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="m4 2 4 4-4 4"/></svg>`
+ `</button>`
+ `<div class="sb-group-pages">${staticSidebarPagesHtml(children, basePath, activeFile, level + 1)}</div>`
+ `</div>`;
}).join("");
}
// Every document in the manifest, so crawlers and no-JS readers get the full
// index rather than one entry point per section.
function buildLandingDirectoryHtml(nav, basePath) {
const sections = sectionsByTier(nav).flatMap(({ entries }) => entries)
.map(({ section }) => {
const list = landingDirectoryListHtml(section.pages, basePath);
if (!list) return "";
return `<div class="landing-directory-section">`
+ `<h3>${escapeHtml(section.title)}</h3>`
+ list
function navSubtreeContainsFile(nodes, activeFile) {
if (!activeFile) return false;
return getNavChildren({ pages: nodes }).some((node) => {
if (isNavPage(node)) return node.file === activeFile;
return navSubtreeContainsFile(getNavChildren(node), activeFile);
});
}
function buildSidebarSectionsHtml(nav, basePath, iconPaths, activeFile) {
return sectionsByTier(nav).map(({ tier, entries }) => {
const header = `<div class="sb-tier-header">${escapeHtml(tier)}</div>`;
const sections = entries.map(({ section, index }) => {
const pageCount = countNavPages(section.pages);
const isOpen = navSubtreeContainsFile(section.pages, activeFile);
return `<div class="sb-section" data-section-idx="${index}"`
+ ` data-section-title="${escapeAttr(section.title)}" data-open="${isOpen}">`
+ `<button class="sb-section-btn" type="button">`
+ `<span class="sb-section-icon">${sectionIconTag(section, iconPaths)}</span>`
+ `<span class="sb-section-title">${escapeHtml(section.title)}</span>`
+ `<span class="sb-section-count">${pageCount}</span>`
+ `<svg class="chev" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="m4 2 4 4-4 4"/></svg>`
+ `</button>`
+ `<div class="sb-pages">${staticSidebarPagesHtml(section.pages, basePath, activeFile)}</div>`
+ `</div>`;
})
.filter(Boolean)
.join("");
if (!sections) return "";
return `<nav id="landing-directory" aria-labelledby="landing-directory-title">`
+ `<h2 id="landing-directory-title">Browse all documentation</h2>`
+ `<div class="landing-directory-columns">${sections}</div>`
+ `</nav>`;
}).join("");
return header + sections;
}).join("");
}
function buildBreadcrumbHtml(metadata) {
const trail = Array.isArray(metadata?.page?.navTrail) && metadata.page.navTrail.length
? metadata.page.navTrail
: [metadata?.sectionTitle, metadata?.page?.title].filter(Boolean);
return trail.map((crumb, index) => {
const isCurrent = index === trail.length - 1;
const crumbHtml = `<span${isCurrent ? ' class="crumb-current"' : ""}>${escapeHtml(crumb)}</span>`;
return index === 0 ? crumbHtml : `<span class="sep">/</span>${crumbHtml}`;
}).join("");
}
// Fill the shell's empty nav containers for one interior route.
function withStaticChrome(sourceIndex, nav, basePath, iconPaths, metadata) {
if (!nav) return sourceIndex;
const activeFile = metadata?.page?.file || null;
const sectionsHtml = buildSidebarSectionsHtml(nav, basePath, iconPaths, activeFile);
// Desktop sidebar only. The mobile drawer is `inert`/`aria-hidden` until a
// JS toggle opens it, so server-rendering it would duplicate all 192 links
// per page for no crawl or no-JS benefit; app.js still builds it on demand.
const placeholder = '<div id="sb-sections"></div>';
if (!sourceIndex.includes(placeholder)) {
throw new Error("site/index.html is missing the sb-sections navigation container.");
}
let html = sourceIndex.replace(
placeholder,
`<div id="sb-sections" data-server-rendered="true">${sectionsHtml}</div>`,
);
const breadcrumbPlaceholder = '<div id="breadcrumb" aria-label="Breadcrumb"></div>';
if (!html.includes(breadcrumbPlaceholder)) {
throw new Error("site/index.html is missing the breadcrumb container.");
}
return html.replace(
breadcrumbPlaceholder,
`<div id="breadcrumb" aria-label="Breadcrumb" data-server-rendered="true">${buildBreadcrumbHtml(metadata)}</div>`,
);
}
function buildRootPageHtml(sourceIndex, nav, basePath, iconPaths) {
@@ -1177,8 +1331,7 @@ function buildRootPageHtml(sourceIndex, nav, basePath, iconPaths) {
.replace('<section id="landing">', '<section id="landing" class="is-active">')
.replace(
'<div class="card-grid" id="landing-cards"></div>',
`<div class="card-grid" id="landing-cards" data-server-rendered="true">${buildLandingCardsHtml(nav, basePath, iconPaths)}</div>`
+ buildLandingDirectoryHtml(nav, basePath),
`<div class="card-grid" id="landing-cards" data-server-rendered="true">${buildLandingCardsHtml(nav, basePath, iconPaths)}</div>`,
);
}
@@ -1215,8 +1368,8 @@ function buildStaticPageNav(prev, next) {
return `${prevHtml}${nextHtml}`;
}
function buildStaticPageHtml(sourceIndex, metadata, markdown, basePath, releaseStyles, prev, next) {
const articleHtml = renderStaticMarkdown(markdown);
function buildStaticPageHtml(sourceIndex, metadata, markdown, basePath, releaseStyles, prev, next, linkOptions = {}) {
const articleHtml = renderStaticMarkdown(markdown, linkOptions);
const routeBaseHref = getPublicBasePath(basePath);
return removeLandingSubtree(
inlineReleaseStyles(injectSeo(sourceIndex, metadata, { baseHref: routeBaseHref }), releaseStyles),
@@ -1229,7 +1382,18 @@ function buildStaticPageHtml(sourceIndex, metadata, markdown, basePath, releaseS
);
}
async function writeStaticRoutePages({ outDir, sourceIndex, pages, markdownBodiesByFile, basePath, releaseStyles }) {
async function writeStaticRoutePages({
outDir,
sourceIndex,
pages,
markdownBodiesByFile,
basePath,
releaseStyles,
routeMap,
nav,
iconPaths,
onUnresolvedLink,
}) {
for (const [index, metadata] of pages.entries()) {
const { page } = metadata;
const markdown = markdownBodiesByFile.get(page.file);
@@ -1242,13 +1406,14 @@ async function writeStaticRoutePages({ outDir, sourceIndex, pages, markdownBodie
await fs.writeFile(
routePath,
buildStaticPageHtml(
sourceIndex,
withStaticChrome(sourceIndex, nav, basePath, iconPaths, metadata),
metadata,
markdown,
basePath,
releaseStyles,
pages[index - 1],
pages[index + 1],
{ sourceFile: page.file, routeMap, onUnresolvedLink },
),
);
}
@@ -1290,6 +1455,24 @@ If \`content.json\` or linked markdown files are missing from the uploaded bundl
- Canary URLs are Cloudflare-generated deployment URLs, for example \`https://92b9a99c.paperclip-docs-74t.pages.dev\`; do not derive them from the branch name by hand.
- If GitHub shows a Cloudflare Pages check or deployment link on the commit/PR, that URL should match the Cloudflare dashboard deployment row.
## Sitemap freshness
\`sitemap.xml\` derives each \`<lastmod>\` from that file's last commit date.
Cloudflare Pages clones shallowly by default. In a shallow checkout every file
reports the single fetched commit's date, so the build detects that and omits
\`<lastmod>\` entirely rather than telling Google that all 192 documents changed
on the same day — a claim that trains Google to discount the sitemap.
To publish real per-file dates, deepen the checkout before building, for example
by setting the Pages build command to:
\`\`\`sh
git fetch --unshallow || true; npm run docs:build
\`\`\`
Omitted dates are the safe default; wrong dates are not.
## Other static hosts
The generated \`.htaccess\` and \`nginx.conf.example\` are optional examples for non-Pages hosting.
@@ -1375,8 +1558,17 @@ async function main() {
if (fm) page.frontmatter = fm;
}
await fs.writeFile(path.join(options.outDir, "content.json"), `${JSON.stringify(releaseNav)}\n`);
// app.js fetches this at boot to resolve legacy hash/?page= routes. It was
// never copied into the bundle, so every visit spent a request on a 404.
// `_headers` already marks /*.json noindex, so shipping it costs nothing.
await fs.writeFile(
path.join(options.outDir, "redirects.json"),
`${JSON.stringify(sourceRedirects)}\n`,
);
const pageMetadata = await pageMetadataForNav(releaseNav, options.outDir, options.siteUrl, options.basePath);
const pageMetadata = dropUniformLastmod(
await pageMetadataForNav(releaseNav, options.outDir, options.siteUrl, options.basePath),
);
await fs.writeFile(path.join(options.outDir, "_redirects"), buildCloudflareRedirects({
basePath: options.basePath,
pages: pageMetadata,
@@ -1396,6 +1588,11 @@ async function main() {
releaseStyles,
),
);
// A markdown link that no nav page publishes would ship as a raw `.md` href,
// which 404s for every crawler. Collect them and fail the build rather than
// emitting a page full of dead links.
const routeMap = buildDocRouteMap(releaseNav, options.basePath);
const unresolvedLinks = [];
await writeStaticRoutePages({
outDir: options.outDir,
sourceIndex,
@@ -1403,8 +1600,22 @@ async function main() {
markdownBodiesByFile,
basePath: options.basePath,
releaseStyles,
routeMap,
nav: releaseNav,
iconPaths: sectionIconPaths,
onUnresolvedLink: (entry) => unresolvedLinks.push(entry),
});
if (unresolvedLinks.length > 0) {
const detail = unresolvedLinks
.map(({ sourceFile, href, targetFile }) => ` ${sourceFile} -> ${href} (resolves to ${targetFile})`)
.join("\n");
throw new Error(
`${unresolvedLinks.length} markdown link(s) do not resolve to a published docs route.\n`
+ `Point each one at a page listed in site/content.json, or remove the link:\n${detail}`,
);
}
await fs.writeFile(path.join(options.outDir, "sitemap.xml"), buildSitemap({
siteUrl: options.siteUrl,
basePath: options.basePath,
+1 -1
View File
@@ -179,7 +179,7 @@
<div class="footer-col">
<div class="footer-col-heading">Developers</div>
<a href="/" data-nav="home">Documentation</a>
<a href="/reference/api/overview" data-nav="link">API reference</a>
<a href="/reference/api/overview/" data-nav="link">API reference</a>
<a href="https://github.com/paperclipai/paperclip" target="_blank" rel="noopener">GitHub</a>
</div>
<div class="footer-col">
-63
View File
@@ -1432,66 +1432,3 @@
.landing-tier-cards { grid-template-columns: 1fr; }
}
/* Full manifest directory — server-rendered, so it reads and navigates
with JavaScript disabled. */
#landing-directory {
margin-top: 64px;
padding-top: 40px;
border-top: 1px solid var(--stone);
}
#landing-directory-title {
margin: 0 0 24px;
font-size: 13px;
font-weight: 700;
color: var(--ash);
text-transform: uppercase;
letter-spacing: 0.1em;
}
.landing-directory-columns {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 28px 24px;
}
.landing-directory-section > h3 {
margin: 0 0 10px;
font-family: var(--font-serif);
font-size: 17px;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--ink);
}
.landing-directory-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.landing-directory-list .landing-directory-list {
margin: 4px 0 8px;
padding-left: 12px;
border-left: 1px solid var(--stone);
}
.landing-directory-list a {
font-size: 13.5px;
line-height: 1.5;
color: var(--graphite);
text-decoration: none;
}
.landing-directory-list a:hover { color: var(--ink); text-decoration: underline; }
.landing-directory-group-title {
display: block;
margin-top: 6px;
font-size: 11.5px;
font-weight: 600;
color: var(--ash);
text-transform: uppercase;
letter-spacing: 0.08em;
}
@media (max-width: 900px) {
.landing-directory-columns { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 600px) {
.landing-directory-columns { grid-template-columns: 1fr; }
}