mirror of
https://github.com/paperclipai/paperclip-docs.git
synced 2026-11-07 06:16:07 -10:00
## 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]>
221 lines
8.0 KiB
JavaScript
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(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, '"')
|
|
.replace(/'/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.`,
|
|
);
|