mirror of
https://github.com/paperclipai/paperclip-docs.git
synced 2026-11-07 06:16:07 -10:00
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:
5 files changed
+530
-40
No files matched your search
@@ -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'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
@@ -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
@@ -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, "&")
|
||||
@@ -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
@@ -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
@@ -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; }
|
||||
}
|
||||
Reference in new issue
Block a user