PR1 — server-render the docs root and split it from interior routes (Phase 1) [DO NOT MERGE] (#87)

# PR1 — server-render the docs root and split it from interior routes
(Phase 1)

**Do not merge — Dotta alone may merge.** This PR is opened for review
only, per the hard write-gate on the approved plan.

## Provenance

| item | link |
|---|---|
| Accepted plan | [PAP-16672 · plan
document](https://paperclip-dev.tail29c1aa.ts.net/PAP/issues/PAP-16672#document-plan)
|
| Phase 0 review packet | [PAP-16671 · packet
comment](https://paperclip-dev.tail29c1aa.ts.net/PAP/issues/PAP-16671#comment-f653273d-6450-4f8d-bcd6-f805ad28b923)
|
| Implementation candidate |
[PAP-16682](https://paperclip-dev.tail29c1aa.ts.net/PAP/issues/PAP-16682)
|
| Independent QA — **PASS** |
[PAP-16683](https://paperclip-dev.tail29c1aa.ts.net/PAP/issues/PAP-16683)
|
| Finalization / this PR |
[PAP-16684](https://paperclip-dev.tail29c1aa.ts.net/PAP/issues/PAP-16684)
|
| Full evidence gallery (screenshots + raw JSON probes) |
https://pages.paperclip.ing/pap-16684-pr1-evidence/ |

Base: approved `main` @ `2ab76e8e4`. Head: `f5bbba24c` (exactly one
commit on top; `main` has not moved).

## What changed

- **1.1 — root-only landing shell.** The hero is *physically absent*
from interior documents (removed at build time, not CSS/JS/ARIA hidden);
the build throws if removal fails. Header, footer, search, sidebar,
breadcrumbs and article nav are preserved on every page.
- **1.2 — server-rendered homepage directory** generated from the same
canonical `site/content.json` manifest that already feeds the sidebar,
breadcrumbs, prev/next, routes and sitemap. No second URL manifest was
introduced.
- **Single descriptive H1 per interior page** (was 2 — the hero H1 bled
onto every route).
- **Truthful runtime UI.** `Loading…` / `Could not load this guide.` no
longer ship inside success HTML; they are produced only by `app.js` at
runtime on the empty, `hidden` `#runtime-status` mount, so they can only
appear during a genuine client transition or failure.

```
 scripts/verify-static-routes.mjs | 200 +++++++++++++++++++++++++++++++++-
 site/app.js                      |  90 ++++++++++++----
 site/build-release.mjs           | 189 ++++++++++++++++++++++++++++++--
 site/index.html                  |  17 ++--
 site/styles.css                  |  70 ++++++++++-
 5 files changed, 528 insertions(+), 38 deletions(-)
```

`.site/` is **generated build output and remains uncommitted**
(gitignored): `git ls-files | grep '^\.site/'` → 0 files.

## Before → after, raw HTTP/HTML for the six representative routes

Both trees built with the identical command and base path, then served
by a production-equivalent static server (real 404 status, honors
`_redirects`). "Before" is a real build of approved `main` @
`2ab76e8e4`.

| route | HTTP | content type | H1 count before → **after** | H1 text
(after) | `id="landing"` before → after | `Loading…` | `Could not load
this guide.` | internal directory anchors |
|---|---|---|---|---|---|---|---|---|
| `/` | 200 | `text/html` | 1 → **1** | Everything you need to run
Paperclip. | 1 → 1 (root-only ✓) | 1 → **0** | 1 → **0** | 4 → **184** |
| `/guides/welcome/what-is-paperclip/` | 200 | `text/html` | 2 → **1** |
What is Paperclip? | 1 → **0** | 1 → **0** | 1 → **0** | 4 → 4 |
| `/reference/api/overview/` | 200 | `text/html` | 2 → **1** | API
Overview | 1 → **0** | 1 → **0** | 1 → **0** | 4 → 4 |
| `/how-to/add-mcp-server-to-agent/` | 200 | `text/html` | 2 → **1** |
Add an MCP server to an agent's toolkit | 1 → **0** | 1 → **0** | 1 →
**0** | 4 → 4 |
| `/reference/changelog/` | 200 | `text/html` | 2 → **1** |
Documentation Changelog | 1 → **0** | 1 → **0** | 1 → **0** | 4 → 4 |
| `/guides/welcome/this-route-does-not-exist/` | **404** | `text/html;
charset=utf-8` | 1 → **1** | Not found | 0 → 0 | 0 → 0 | 0 → 0 | 0 → 0 |

Machine-readable probes:
[before-raw.json](https://pages.paperclip.ing/pap-16684-pr1-evidence/before-raw.json)
·
[after-raw.json](https://pages.paperclip.ing/pap-16684-pr1-evidence/after-raw.json).

### Homepage bleed and false state — absence proof

- `id="landing"` returns **zero** matches in every interior document.
Only inlined `#landing-*` **CSS selector text** remains (stylesheet, not
markup).
- Interiors carry no directory manifest (4 internal anchors — the
footer); the 184-anchor directory is root-only.
- The two false-state phrases appear in **0** built HTML files across
the whole bundle.

### 404 / noindex / sitemap

- Real HTTP **404** for the nonexistent route (root base and
`/paperclip-docs/` subpath build).
- `404.html`: exactly one `<h1>Not found</h1>`, `<meta name="robots"
content="noindex, nofollow">`, `<title>Not found | Paperclip
Docs</title>`.
- Absent from `sitemap.xml` (0 occurrences); no built file for the
route.
- `sitemap.xml` has 184 `<loc>` (183 routes + root) and the root
directory anchor set covers **183/183** non-root sitemap routes — 0
missing.
- `_redirects` contains **no wildcard `200` rewrite** (0 lines with a
`200` status), so unknown routes cannot be masked as success.
- `robots.txt`: `User-agent: * / Allow: /` + `Sitemap:
https://docs.paperclip.ing/sitemap.xml`.

## Focused checks — rerun on the final revision (`f5bbba24c`)

| command | result |
|---|---|
| `npm run docs:test:static-routes` | **PASS** — "Static route
verification passed (5 representative routes +
/guides/welcome/this-route-does-not-exist/, root and /paperclip-docs/
builds)." |
| `npm run docs:build` | **PASS** — "Generated 183 crawlable route pages
plus sitemap.xml and robots.txt." |

`git status` is clean after the build (no generated output enters the
tree).

## Browser matrix — desktop 1440×900 and mobile 390×844

Run with Playwright + Chromium 1228. The QA sandbox could not exercise
this (blank-glyph text rendering); the root cause turned out to be that
**no fonts are installed on the host at all** — installing DejaVu TTFs
with a minimal fontconfig makes text render correctly, which unblocked
the full interactive matrix here. Screenshots:
https://pages.paperclip.ing/pap-16684-pr1-evidence/

### Per-route, JavaScript enabled (both viewports)

All six routes on both viewports: expected HTTP status, **exactly 1
`<h1>`** with the correct text, `#landing` present **only** on `/`, no
`Loading`/`Could not load this guide.` text in the rendered body,
`#runtime-status` hidden, **no horizontal overflow**.

### Interactive

| check | result |
|---|---|
| root directory link → interior (client nav) | ✅
`/guides/welcome/what-is-paperclip/`, single H1 "What is Paperclip?",
hero gone, not stuck loading |
| direct load vs client-nav parity | ✅ identical H1 set |
| back / forward history | ✅ back → `/` with hero restored; forward →
interior with correct H1 |
| home nav from an interior doc | ✅ `/` with hero |
| theme toggle | ✅ toggles (`dark` → light) |
| desktop sidebar / TOC / breadcrumbs / prev-next / search / footer | ✅
191 sidebar links, 14 TOC links, breadcrumbs rendered, article nav
present, search control present, 15 footer links |
| mobile drawer | ✅ opens (screenshot `mobile-drawer-open.png`) |
| mobile horizontal scroll | ✅ none |
| unknown route | ✅ 404 page, single H1, noindex |

### JavaScript disabled

| check | result |
|---|---|
| `/` directory usable with JS off | ✅ 202 anchors present in raw
markup; hero + card directory fully rendered |
| clicking a directory link with JS off | ✅ full page load to
`/reference/api/overview/` |
| that interior page with JS off | ✅ 1 H1 ("API Overview"), no hero, no
false loading/error state, 6368 chars of real article text |
| mobile with JS off | ✅ root and interior both render correctly |

### Console / network

No JavaScript errors and no page errors on any route. Exactly two
non-2xx requests, **both reproduced identically on approved `main` @
`2ab76e8e4`** — neither is introduced by this PR:

- `GET /redirects.json` → 404. Pre-existing: neither the before nor
after build emits this file. Verified byte-for-byte identical behaviour
on `main`.
- `GET https://api.github.com/repos/paperclipai/paperclip` → 403.
Unauthenticated GitHub API rate limit for the header star counter;
environmental, external, not a product defect.

Also confirmed unchanged before → after on the same interior page:
header (1), footer (1), search controls (4), sidebar links (191), TOC
links (14), article nav (2), article text length (4328 chars). The
**only** rendered difference is the intended H1 2 → 1 and the removed
hero.

## Verified against the real Cloudflare Pages deploy, not just a local
server

The Cloudflare Pages check on this PR is **green** and produced a live
preview:
**https://pap-16682-server-render-root.paperclip-docs-74t.pages.dev**

I reran the entire matrix against that real deploy. Every invariant
reproduces **identically** to the local run — so these results hold on
actual production hosting, including Cloudflare's own 404 handling:

| route | HTTP | H1 | `#landing` | `Loading…` | `Could not load…` |
noindex | anchors |
|---|---|---|---|---|---|---|---|
| `/` | 200 | 1 — Everything you need to run Paperclip. | present
(root-only) | 0 | 0 | – | 184 |
| `/guides/welcome/what-is-paperclip/` | 200 | 1 — What is Paperclip? |
absent | 0 | 0 | – | 4 |
| `/reference/api/overview/` | 200 | 1 — API Overview | absent | 0 | 0 |
– | 4 |
| `/how-to/add-mcp-server-to-agent/` | 200 | 1 — Add an MCP server to an
agent's toolkit | absent | 0 | 0 | – | 4 |
| `/reference/changelog/` | 200 | 1 — Documentation Changelog | absent |
0 | 0 | – | 4 |
| nonexistent route | **404** | 1 — Not found | absent | 0 | 0 | **yes**
| 0 |

Browser matrix on the live preview, desktop 1440×900 and mobile 390×844:
identical outcomes — client nav, direct-load parity, back/forward,
home-nav, theme toggle, 191 sidebar links, 14 TOC links, mobile drawer
opens, no horizontal scroll, and with **JavaScript disabled** the root
directory (202 anchors) navigates by real page load into an interior
with 1 H1, no hero, no false state and 6368 chars of article text.

**Console/network on the live deploy: zero page errors.** The same two
known non-product requests appear (`/redirects.json` and the GitHub
star-count API). `/redirects.json` returns **404 on live production
`docs.paperclip.ing` today** and on the `main` branch preview —
conclusively pre-existing and unrelated to this PR.

Screenshots from both the Cloudflare deploy and the local server are in
the gallery: https://pages.paperclip.ing/pap-16684-pr1-evidence/

## Out of scope — pre-existing observation, not a regression

On the `--base-path /paperclip-docs/` subpath build, 4 footer links
(`/reference/api/overview`, `/guides/org/agents/`,
`/guides/org/adapters/`, `/guides/getting-started/five-minute-path/`)
are emitted un-prefixed. Building approved `main` @ `2ab76e8e4` with the
identical flag reproduces **exactly the same 4 links**, so this is a
pre-existing footer-template issue untouched by this PR. Smallest fix
would be routing the footer's hardcoded hrefs through the base-path
prefixer, in a separate ticket.

## Merge policy

`.site/` is generated output and is intentionally uncommitted. **This PR
must remain open and unmerged — Dotta alone may merge it.**

Co-authored-by: ClaudeCoder <[email protected]>
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-08-10 13:11:38 -07:00
1 parent 2ab76e8e4b
commit e2adb1c167
5 files changed
+530 -40

No files matched your search

+195 -5
View File
@@ -7,6 +7,8 @@ import { spawnSync } from "node:child_process";
const root = process.cwd();
const outDir = mkdtempSync(join(tmpdir(), "paperclip-docs-static-routes-"));
const subpathOutDir = mkdtempSync(join(tmpdir(), "paperclip-docs-static-routes-subpath-"));
const SUBPATH_BASE = "/paperclip-docs/";
function assert(condition, message) {
if (!condition) throw new Error(message);
@@ -16,19 +18,66 @@ function read(relPath) {
return readFileSync(join(outDir, relPath), "utf8");
}
try {
function buildRelease(basePath, targetDir) {
const build = spawnSync(process.execPath, [
"site/build-release.mjs",
"--base-path",
"/",
basePath,
"--out-dir",
outDir,
targetDir,
], {
cwd: root,
encoding: "utf8",
});
assert(
build.status === 0,
`docs build failed for base path ${basePath}\nstdout:\n${build.stdout}\nstderr:\n${build.stderr}`,
);
}
assert(build.status === 0, `docs build failed\nstdout:\n${build.stdout}\nstderr:\n${build.stderr}`);
/* The six representative routes from the AEO review. Each interior document
must stand on its own in raw HTML: one descriptive H1, its real body copy,
and none of the homepage subtree. */
const FALSE_STATUS_PHRASES = ["Loading…", "Could not load this guide."];
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">',
isDocsRoot: true,
},
{
route: "/guides/welcome/what-is-paperclip/",
file: "guides/welcome/what-is-paperclip/index.html",
h1: '<h1 id="what-is-paperclip">What is Paperclip?</h1>',
contentMarker: "Paperclip is the operating system for your AI company",
},
{
route: "/reference/api/overview/",
file: "reference/api/overview/index.html",
h1: '<h1 id="api-overview">API Overview</h1>',
contentMarker: "Paperclip exposes a JSON API for company control-plane work",
},
{
route: "/how-to/add-mcp-server-to-agent/",
file: "how-to/add-mcp-server-to-agent/index.html",
h1: '<h1 id="add-an-mcp-server-to-an-agents-toolkit">Add an MCP server to an agent&#39;s toolkit</h1>',
contentMarker: "Attach a Model Context Protocol (MCP) server to a specific Paperclip agent",
},
{
route: "/reference/changelog/",
file: "reference/changelog/index.html",
h1: '<h1 id="documentation-changelog">Documentation Changelog</h1>',
contentMarker: "What changed in these docs",
},
];
const NONEXISTENT_ROUTE = "/guides/welcome/this-route-does-not-exist/";
try {
buildRelease("/", outDir);
const skillsPath = "reference/skills/index.html";
assert(existsSync(join(outDir, skillsPath)), `missing ${skillsPath}`);
@@ -198,7 +247,148 @@ try {
assert(!deployGuide.includes("## GitHub Pages"), "deploy guide should not recommend GitHub Pages publishing");
assert(!deployGuide.includes("gh-pages"), "deploy guide should not mention gh-pages publishing");
console.log("Static route verification passed.");
/* ─── Representative-route raw HTML invariants ─────────────────────────── */
for (const { route, file, h1, contentMarker, isDocsRoot } of REPRESENTATIVE_ROUTES) {
assert(existsSync(join(outDir, file)), `${route}: no document generated at ${file}`);
const html = read(file);
const h1Count = (html.match(/<h1[\s>]/g) || []).length;
assert(h1Count === 1, `${route}: expected exactly one H1 in raw HTML, found ${h1Count}`);
assert(html.includes(h1), `${route}: raw HTML is missing its descriptive H1 ${h1}`);
assert(html.includes(contentMarker), `${route}: raw HTML is missing its intended main content`);
for (const phrase of FALSE_STATUS_PHRASES) {
assert(!html.includes(phrase), `${route}: successful response must not contain "${phrase}"`);
}
assert(
html.includes('<div id="runtime-status" role="status" aria-live="polite" hidden></div>'),
`${route}: the runtime status mount should ship empty and hidden`,
);
// The homepage subtree must be physically absent from interior documents,
// not hidden with CSS, JS, or ARIA.
const hasLandingMarkup = /<section id="landing"/.test(html) || /id="landing-title"/.test(html);
if (isDocsRoot) {
assert(hasLandingMarkup, `${route}: the docs root must keep the landing hero`);
assert(
html.includes('<section id="landing" class="is-active">'),
`${route}: the landing hero must be visible without JavaScript`,
);
} else {
assert(!hasLandingMarkup, `${route}: interior documents must not contain the homepage subtree`);
assert(
!html.includes("Everything you need to <em>run Paperclip.</em>"),
`${route}: the homepage headline must not bleed into interior documents`,
);
assert(
html.includes('<div id="article-view" class="is-active">'),
`${route}: the article view must be visible without JavaScript`,
);
}
}
/* ─── Root directory is complete and works without JavaScript ──────────── */
assert(
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]),
);
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})`,
);
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",
);
/* ─── Nonexistent route stays a real 404 ───────────────────────────────── */
assert(
!existsSync(join(outDir, NONEXISTENT_ROUTE.replace(/^\/|\/$/g, ""), "index.html")),
`${NONEXISTENT_ROUTE}: a document was generated for a route that should not exist`,
);
assert(
!sitemap.includes(NONEXISTENT_ROUTE),
`${NONEXISTENT_ROUTE}: a nonexistent route must not appear in the sitemap`,
);
assert(
!redirects.includes("/* /index.html 200") && !redirects.includes("/* /404.html 200"),
`${NONEXISTENT_ROUTE}: unknown routes must 404 rather than being rewritten to a 200 shell`,
);
const notFoundH1Count = (notFoundHtml.match(/<h1[\s>]/g) || []).length;
assert(
notFoundH1Count === 1,
`${NONEXISTENT_ROUTE}: 404.html must have exactly one H1, found ${notFoundH1Count}`,
);
assert(
!/<section id="landing"/.test(notFoundHtml),
`${NONEXISTENT_ROUTE}: 404.html must stay separate from the homepage subtree`,
);
/* ─── Subpath build resolves the same routes under its base path ───────── */
buildRelease(SUBPATH_BASE, subpathOutDir);
for (const { route, file, h1, isDocsRoot } of REPRESENTATIVE_ROUTES) {
const subpathRoute = `${SUBPATH_BASE}${route.replace(/^\//, "")}`;
assert(
existsSync(join(subpathOutDir, file)),
`${subpathRoute}: no document generated at ${file} for the subpath build`,
);
const html = readFileSync(join(subpathOutDir, file), "utf8");
const h1Count = (html.match(/<h1[\s>]/g) || []).length;
assert(h1Count === 1, `${subpathRoute}: expected exactly one H1, found ${h1Count}`);
assert(html.includes(h1), `${subpathRoute}: raw HTML is missing its descriptive H1`);
for (const phrase of FALSE_STATUS_PHRASES) {
assert(!html.includes(phrase), `${subpathRoute}: successful response must not contain "${phrase}"`);
}
if (!isDocsRoot) {
assert(
!/<section id="landing"/.test(html),
`${subpathRoute}: interior documents must not contain the homepage subtree`,
);
assert(
html.includes(`<base data-seo-base href="${SUBPATH_BASE}" />`),
`${subpathRoute}: interior documents must resolve relative assets against the subpath base`,
);
}
}
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]);
assert(
subpathHrefs.length === directoryHrefs.size,
`${SUBPATH_BASE}: the homepage directory should cover the same manifest 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`,
);
assert(
subpathRootHtml.includes(`<a href="${SUBPATH_BASE}" class="navbar-link" data-nav="home">`),
`${SUBPATH_BASE}: docs-root links must point at the configured base path`,
);
console.log(
`Static route verification passed (${REPRESENTATIVE_ROUTES.length} representative routes + ${NONEXISTENT_ROUTE}, root and ${SUBPATH_BASE} builds).`,
);
} finally {
rmSync(outDir, { recursive: true, force: true });
rmSync(subpathOutDir, { recursive: true, force: true });
}
+76 -18
View File
@@ -541,16 +541,29 @@ window.addEventListener('resize', () => {
});
/* ─── Landing <-> article view switching ────────────────────────────────── */
function docsRootUrl() {
return `${APP_BASE_URL.pathname.replace(/\/$/, '')}/`;
}
function showLanding() {
document.getElementById('landing').classList.add('is-active');
const landing = document.getElementById('landing');
// Interior documents ship without the homepage subtree, so there is no view
// to swap to — go to the real docs root instead.
if (!landing) {
window.location.assign(docsRootUrl());
return;
}
landing.classList.add('is-active');
document.getElementById('article-view').classList.remove('is-active');
document.getElementById('breadcrumb').innerHTML = '';
const basePath = APP_BASE_URL.pathname.replace(/\/$/, '');
history.replaceState(null, '', `${basePath}/`);
history.replaceState(null, '', docsRootUrl());
updateLandingSeo();
}
function showArticleView() {
document.getElementById('landing').classList.remove('is-active');
// Only the docs root ships a homepage subtree, and once it hands off to an
// article the hero is dropped rather than hidden — otherwise the live DOM
// would keep a second H1 and the homepage headline on an interior route.
document.getElementById('landing')?.remove();
document.getElementById('article-view').classList.add('is-active');
}
@@ -559,6 +572,9 @@ document.addEventListener('click', e => {
// Home nav (logo, back-to-all-docs)
const home = e.target.closest('[data-nav="home"]');
if (home) {
// Without a homepage subtree on this document, let the anchor's real root
// href navigate rather than intercepting into a view that does not exist.
if (!document.getElementById('landing')) return;
e.preventDefault();
closeDrawer();
showLanding();
@@ -814,6 +830,9 @@ async function init() {
if (!res.ok) throw new Error(`content.json ${res.status}`);
navData = await res.json();
} catch (e) {
// Server-rendered documents stay readable without the nav manifest; only
// report a failure when there is nothing on the page to fall back to.
if (document.getElementById('article')?.children.length) return;
showError('Could not load content.json. Check that the release bundle was uploaded intact and the base path is correct.', e.message);
return;
}
@@ -848,6 +867,14 @@ async function init() {
/* ─── Landing cards + quick links ───────────────────────────────────────── */
function buildLanding() {
const grid = document.getElementById('landing-cards');
// Interior documents have no homepage subtree at all.
if (!grid) return;
// The docs root ships the directory server-rendered from the same manifest.
// Keep that DOM — the delegated click handlers already wire it up.
if (grid.dataset.serverRendered === 'true') {
renderLucideIcons();
return;
}
grid.innerHTML = '';
// Group sections by tier, preserving original indices so data-nav-section still works.
@@ -1022,11 +1049,13 @@ async function loadPage(file, targetHeading = null, historyMode = 'push', option
currentFile = file;
showArticleView();
setActiveState(file);
showLoading();
let md;
const article = document.getElementById('article');
const useStaticArticle = Boolean(options.useStaticArticle && article?.children.length);
// Server-rendered content is already on screen; only a real client-side
// transition has anything to wait for.
if (!useStaticArticle) showLoading();
if (useStaticArticle) {
currentMarkdown = '';
} else {
@@ -1750,26 +1779,55 @@ function renderPageNav(file) {
}
/* ─── Helpers ───────────────────────────────────────────────────────────── */
function showLoading() {
resetToc();
document.getElementById('loading').style.display = 'flex';
document.getElementById('error-state').style.display = 'none';
document.getElementById('article').style.display = 'none';
document.getElementById('page-nav').style.display = 'none';
/* Runtime status is built on demand so a served document never ships loading or
error copy it cannot justify. Only real client-side transitions fill it in. */
function renderRuntimeStatus(state, message, detail = '') {
const mount = document.getElementById('runtime-status');
if (!mount) return;
mount.textContent = '';
mount.dataset.state = state;
if (state === 'loading') {
const spinner = document.createElement('div');
spinner.className = 'spinner';
mount.appendChild(spinner);
}
const messageEl = document.createElement('span');
messageEl.className = 'runtime-status-message';
messageEl.textContent = message;
mount.appendChild(messageEl);
if (detail) {
const detailEl = document.createElement('span');
detailEl.className = 'runtime-status-detail';
detailEl.textContent = detail;
mount.appendChild(detailEl);
}
mount.hidden = false;
}
function hideLoading() { document.getElementById('loading').style.display = 'none'; }
function clearRuntimeStatus() {
const mount = document.getElementById('runtime-status');
if (!mount) return;
mount.hidden = true;
delete mount.dataset.state;
mount.textContent = '';
}
function showLoading() {
resetToc();
renderRuntimeStatus('loading', 'Loading…');
document.getElementById('article').style.display = 'none';
document.getElementById('page-nav').style.display = 'none';
}
function hideLoading() { clearRuntimeStatus(); }
function showError(msg, detail = '') {
// Error state lives inside article-view; make sure the right view is showing.
showArticleView();
resetToc();
document.getElementById('loading').style.display = 'none';
document.getElementById('article').style.display = 'none';
document.getElementById('page-nav').style.display = 'none';
document.getElementById('error-state').style.display = 'flex';
document.getElementById('error-state').querySelector('span').textContent = msg;
document.getElementById('error-detail').textContent = detail;
document.getElementById('article').style.display = 'none';
document.getElementById('page-nav').style.display = 'none';
renderRuntimeStatus('error', msg, detail);
}
function escapeHtml(s) {
+183 -6
View File
@@ -446,6 +446,14 @@ function getDeploymentBasePath(basePath) {
return basePath === "auto" ? "/paperclip-docs/" : basePath;
}
function getPublicBasePath(basePath) {
return basePath === "auto" ? "/" : basePath;
}
function routePathForSlug(basePath, slug) {
return `${getPublicBasePath(basePath)}${normalizeRouteKey(slug)}/`;
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, "&amp;")
@@ -977,6 +985,167 @@ function inlineReleaseStyles(html, css) {
);
}
/* ─── Server-rendered homepage directory ──────────────────────────────────
The docs root is the only document that carries the `#landing` subtree, and
its directory is generated here from the same content.json tree that feeds
the sidebar, previous/next links, and the sitemap. Every entry is a real
`<a href>` so the whole manifest is reachable without JavaScript. */
const LANDING_TIER_ORDER = ["Learn", "Administration", "Reference"];
// app.js owns the icon set for the sidebar and drawer, so read it back out of
// the shell source rather than keeping a second copy that can drift.
function extractSectionIconPaths(appJsSource) {
const block = appJsSource.match(/const SECTION_ICON_PATHS = \{([\s\S]*?)\n\};/);
if (!block) {
throw new Error("Could not locate SECTION_ICON_PATHS in site/app.js.");
}
const iconPaths = {};
const entryRegex = /^\s*'?([\w-]+)'?:\s*'([^']*)',?\s*$/gm;
let match;
while ((match = entryRegex.exec(block[1])) !== null) {
iconPaths[match[1]] = match[2];
}
if (!iconPaths["book-open"]) {
throw new Error("SECTION_ICON_PATHS in site/app.js is missing the book-open fallback icon.");
}
return iconPaths;
}
function sectionIconTag(section, iconPaths) {
const icon = iconPaths[section?.icon] || iconPaths["book-open"];
return `<svg class="section-icon-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${icon}</svg>`;
}
function getSectionKind(section) {
return (section && typeof section === "object" && section.tier) || "Guides";
}
function countNavPages(nodes) {
return getNavChildren({ pages: nodes }).reduce((count, node) => {
if (isNavPage(node)) return count + 1;
return count + countNavPages(getNavChildren(node));
}, 0);
}
function getFirstNavPage(nodes) {
for (const node of getNavChildren({ pages: nodes })) {
if (isNavPage(node)) return node;
const firstChild = getFirstNavPage(getNavChildren(node));
if (firstChild) return firstChild;
}
return null;
}
function sectionsByTier(nav) {
const byTier = new Map();
(nav.sections || []).forEach((section, index) => {
const tier = getSectionKind(section);
if (!byTier.has(tier)) byTier.set(tier, []);
byTier.get(tier).push({ section, index });
});
const orderedTiers = [
...LANDING_TIER_ORDER.filter((tier) => byTier.has(tier)),
...[...byTier.keys()].filter((tier) => !LANDING_TIER_ORDER.includes(tier)),
];
return orderedTiers.map((tier) => ({ tier, entries: byTier.get(tier) }));
}
// Mirrors the card grid app.js renders client-side, so the root document looks
// identical whether or not the script runs.
function buildLandingCardsHtml(nav, basePath, iconPaths) {
return sectionsByTier(nav).map(({ tier, entries }) => {
const columns = entries.length <= 3 ? entries.length : (entries.length === 4 ? 2 : 3);
const cards = entries.map(({ section, index }) => {
const firstPage = getFirstNavPage(section.pages);
const pageCount = countNavPages(section.pages);
const pageLabel = `${pageCount} page${pageCount === 1 ? "" : "s"}`;
const href = firstPage ? routePathForSlug(basePath, firstPage.slug) : getPublicBasePath(basePath);
const description = section.desc || `${pageLabel} in ${section.title}.`;
return `<a class="card" href="${escapeAttr(href)}" data-nav-section="${index}">`
+ `<div class="card-icon">${sectionIconTag(section, iconPaths)}</div>`
+ `<div class="card-title">${escapeHtml(section.title)}</div>`
+ `<div class="card-desc">${escapeHtml(description)}</div>`
+ `<div class="card-meta"><span>${escapeHtml(pageLabel)}</span><span class="dot"></span><span>${escapeHtml(tier)}</span></div>`
+ `</a>`;
}).join("");
return `<section class="landing-tier" data-tier="${escapeAttr(tier)}">`
+ `<h2>${escapeHtml(tier)}</h2>`
+ `<div class="landing-tier-cards" style="--tier-cols:${columns}">${cards}</div>`
+ `</section>`;
}).join("");
}
function landingDirectoryListHtml(nodes, basePath) {
const items = 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 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>` : "";
}
// 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
+ `</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>`;
}
function buildRootPageHtml(sourceIndex, nav, basePath, iconPaths) {
return sourceIndex
.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),
);
}
// Interior documents must not carry the homepage subtree at all — hiding it
// would still leave a second H1 and the homepage headline in the raw HTML.
function removeLandingSubtree(html) {
const output = html.replace(/\n?<!-- ─── Landing page[^\n]*-->\n?/, "\n")
.replace(/<section id="landing">[\s\S]*?<\/section>\n?/, "");
if (output.includes('id="landing"') || output.includes('id="landing-title"')) {
throw new Error("Failed to remove the homepage landing subtree from an interior route.");
}
return output;
}
// Home links are plain anchors so they work without JavaScript; point them at
// the base path this bundle is actually served from.
function linkDocsRootAnchors(html, basePath) {
const rootHref = getPublicBasePath(basePath);
return html.replace(/<a\b[^>]*\bdata-nav="home"[^>]*>/g, (tag) => {
if (!/\bhref="[^"]*"/.test(tag)) {
throw new Error(`A data-nav="home" anchor in site/index.html is missing an href: ${tag}`);
}
return tag.replace(/\bhref="[^"]*"/, `href="${escapeAttr(rootHref)}"`);
});
}
function buildStaticPageNav(prev, next) {
const prevHtml = prev
? `<a class="page-nav-btn prev" href="${escapeAttr(prev.url)}"><span class="page-nav-label">← Previous</span><span class="page-nav-title">${escapeHtml(prev.page.title)}</span></a>`
@@ -989,11 +1158,11 @@ function buildStaticPageNav(prev, next) {
function buildStaticPageHtml(sourceIndex, metadata, markdown, basePath, releaseStyles, prev, next) {
const articleHtml = renderStaticMarkdown(markdown);
const routeBaseHref = basePath === "auto" ? "/" : basePath;
return inlineReleaseStyles(injectSeo(sourceIndex, metadata, { baseHref: routeBaseHref }), releaseStyles)
.replace('<section id="landing">', '<section id="landing">')
const routeBaseHref = getPublicBasePath(basePath);
return removeLandingSubtree(
inlineReleaseStyles(injectSeo(sourceIndex, metadata, { baseHref: routeBaseHref }), releaseStyles),
)
.replace('<div id="article-view">', '<div id="article-view" class="is-active">')
.replace('<div id="loading">', '<div id="loading" style="display:none">')
.replace('<article id="article" style="display:none"></article>', `<article id="article">${articleHtml}</article>`)
.replace(
'<div id="page-nav" style="display:none"></div>',
@@ -1097,9 +1266,11 @@ async function main() {
await fs.rm(options.outDir, { recursive: true, force: true });
await ensureDir(options.outDir);
const sourceIndex = await fs.readFile(sourceIndexPath, "utf8");
const rawSourceIndex = await fs.readFile(sourceIndexPath, "utf8");
const sourceStyles = await fs.readFile(sourceStylesPath, "utf8");
const sourceAppJs = await fs.readFile(sourceAppJsPath, "utf8");
const sectionIconPaths = extractSectionIconPaths(sourceAppJs);
const sourceIndex = linkDocsRootAnchors(rawSourceIndex, options.basePath);
const releaseStyles = minifyCss(sourceStyles);
const releaseAppJs = rewriteAppJs(sourceAppJs, options.basePath);
await fs.writeFile(path.join(options.outDir, "styles.css"), releaseStyles);
@@ -1150,7 +1321,13 @@ async function main() {
siteUrl: options.siteUrl,
basePath: options.basePath,
};
await fs.writeFile(path.join(options.outDir, "index.html"), inlineReleaseStyles(injectSeo(sourceIndex, rootMetadata), releaseStyles));
await fs.writeFile(
path.join(options.outDir, "index.html"),
inlineReleaseStyles(
injectSeo(buildRootPageHtml(sourceIndex, releaseNav, options.basePath, sectionIconPaths), rootMetadata),
releaseStyles,
),
);
await writeStaticRoutePages({
outDir: options.outDir,
sourceIndex,
+8 -9
View File
@@ -41,7 +41,7 @@
</svg>
</button>
<a href="https://paperclip.ing/blog" class="navbar-link">Blog</a>
<a href="#" class="navbar-link" data-nav="home">Docs</a>
<a href="/" class="navbar-link" data-nav="home">Docs</a>
<a href="https://github.com/paperclipai/paperclip" class="navbar-link navbar-link--icon" target="_blank" rel="noopener" aria-label="GitHub">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z"/></svg>
GitHub
@@ -112,7 +112,7 @@
<svg viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="m4 4 10 10M14 4 4 14"/></svg>
</button>
</div>
<a class="sb-back" data-nav="home">
<a class="sb-back" href="/" data-nav="home">
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M8 2 4 6l4 4"/></svg>
All docs
</a>
@@ -134,7 +134,7 @@
<div id="article-view">
<div id="body">
<nav id="sidebar" aria-label="Documentation">
<a class="sb-back" data-nav="home">
<a class="sb-back" href="/" data-nav="home">
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M8 2 4 6l4 4"/></svg>
All docs
</a>
@@ -143,11 +143,10 @@
<main id="content">
<div id="breadcrumb" aria-label="Breadcrumb"></div>
<div id="loading"><div class="spinner"></div><span>Loading…</span></div>
<div id="error-state" style="display:none">
<span>Could not load this guide.</span>
<span id="error-detail" style="font-size:12px;color:var(--ash);margin-top:4px"></span>
</div>
<!-- Empty on every served document. Loading and error text is written here
by app.js only during a real client-side transition, so raw HTML never
claims a page is loading or broken. -->
<div id="runtime-status" role="status" aria-live="polite" hidden></div>
<article id="article" style="display:none"></article>
<div id="page-nav" style="display:none"></div>
</main>
@@ -179,7 +178,7 @@
</div>
<div class="footer-col">
<div class="footer-col-heading">Developers</div>
<a href="#" data-nav="home">Documentation</a>
<a href="/" data-nav="home">Documentation</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>
+68 -2
View File
@@ -663,12 +663,14 @@
gap: 2px;
}
/* Loading / error */
#loading, #error-state {
/* Runtime status — populated by app.js only during a client transition. */
#runtime-status[data-state] {
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 12px; padding: 80px 0;
color: var(--ash); font-size: 14px;
}
#runtime-status[hidden] { display: none; }
.runtime-status-detail { font-size: 12px; color: var(--ash); margin-top: 4px; }
.spinner {
width: 24px; height: 24px;
border: 2px solid var(--stone); border-top-color: var(--green);
@@ -1417,3 +1419,67 @@
@media (max-width: 600px) {
.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; }
}