Files
paperclip-docs/scripts/verify-crawlable-links.mjs
Aron PrinsandClaude Opus 5 29beedeb6a 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]>
2026-08-25 21:57:07 +02:00

221 lines
8.0 KiB
JavaScript

#!/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.`,
);