Files
paperclip-docs/site/build-release.mjs
T
Michael Nguyen 9f46125a8d Add unlisted hosted beta guide and FAQ (#122)
## Summary
Adds the hosted beta getting-started guide and FAQ at `/hosted-beta/`,
using the existing docs header, sidebar, article layout, theme controls,
and table of contents.

The page remains outside the navigation/search manifest, homepage
listings, previous/next links, and sitemap, with a `noindex` directive.
Direct-link visitors can navigate into the regular docs. This is a
public, unlisted page rather than access-controlled content.

## Validation
- Root and subpath builds and unlisted discovery checks pass.
- Existing static route verification passes.
- Desktop and mobile browser checks show no overflow or runtime errors.
- Table-of-contents anchors and navigation into regular docs verified.

Local preview: http://127.0.0.1:4337/hosted-beta/
Hosted preview:
https://aa3d8178.paperclip-docs-74t.pages.dev/hosted-beta/

Cloudflare deployment succeeded for commit 4b94d17.
2026-09-11 00:43:36 -07:00

1806 lines
69 KiB
JavaScript

#!/usr/bin/env node
import fs from "node:fs/promises";
import path from "node:path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
import { createHash } from "node:crypto";
import { transform } from "esbuild";
import { marked } from "marked";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const repoRoot = path.resolve(__dirname, "..");
const docsRoot = path.resolve(__dirname, "..", "docs");
const sourceIndexPath = path.join(__dirname, "index.html");
const sourceStylesPath = path.join(__dirname, "styles.css");
const sourceAppJsPath = path.join(__dirname, "app.js");
const sourceNavPath = path.join(__dirname, "content.json");
const sourceRedirectsPath = path.join(__dirname, "redirects.json");
const sourceVendorDir = path.join(__dirname, "vendor");
const screenshotsSourceDir = path.join(docsRoot, "user-guides", "screenshots");
const defaultSiteUrl = "https://docs.paperclip.ing";
const defaultSeoDescription = "Guides, references, and walkthroughs for running Paperclip, an AI company operating system for agent teams, governance, budgets, and workflows.";
const execFileAsync = promisify(execFile);
function printUsage() {
console.log(`Usage: node site/build-release.mjs [options]
Options:
--base-path <path> Public URL base path for the uploaded docs bundle.
Examples: /, /docs/, /random/paperclip-docs/, auto
Default: auto (explicit paths are recommended for deployment)
--site-url <url> Absolute public origin used for canonical URLs and sitemaps.
Default: ${defaultSiteUrl}
--out-dir <path> Output directory for the release bundle.
Default: site/release
--help Show this help text.`);
}
function parseArgs(argv) {
const options = {
basePath: "auto",
siteUrl: defaultSiteUrl,
outDir: path.join(__dirname, "release"),
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--help") {
printUsage();
process.exit(0);
}
if (arg === "--base-path") {
const value = argv[index + 1];
if (!value) {
throw new Error("--base-path requires a value.");
}
options.basePath = normalizeBasePath(value);
index += 1;
continue;
}
if (arg === "--out-dir") {
const value = argv[index + 1];
if (!value) {
throw new Error("--out-dir requires a value.");
}
options.outDir = path.resolve(process.cwd(), value);
index += 1;
continue;
}
if (arg === "--site-url") {
const value = argv[index + 1];
if (!value) {
throw new Error("--site-url requires a value.");
}
options.siteUrl = normalizeSiteUrl(value);
index += 1;
continue;
}
throw new Error(`Unknown argument: ${arg}`);
}
return options;
}
function normalizeBasePath(value) {
const trimmed = value.trim();
if (!trimmed || trimmed === "auto") return "auto";
if (!trimmed || trimmed === "/") return "/";
const withLeadingSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
return withLeadingSlash.endsWith("/") ? withLeadingSlash : `${withLeadingSlash}/`;
}
function normalizeSiteUrl(value) {
const trimmed = value.trim().replace(/\/+$/, "");
if (!trimmed) throw new Error("--site-url must not be empty.");
const parsed = new URL(trimmed);
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
throw new Error("--site-url must be an http(s) URL.");
}
return parsed.toString().replace(/\/+$/, "");
}
function toPosixPath(value) {
return value.split(path.sep).join("/");
}
function normalizeRouteKey(value) {
return String(value || "").replace(/^\/+/, "").replace(/\/+$/, "");
}
function normalizeDocPath(value) {
const normalized = [];
for (const segment of value.split("/")) {
if (!segment || segment === ".") continue;
if (segment === "..") {
if (normalized.length && normalized[normalized.length - 1] !== "..") {
normalized.pop();
} else {
normalized.push("..");
}
continue;
}
normalized.push(segment);
}
return normalized.join("/");
}
function derivePageSlug(file) {
const normalized = normalizeDocPath(file).replace(/^(\.\.\/)+/, "");
const withoutExtension = normalized.replace(/\.md$/, "");
if (withoutExtension.startsWith("user-guides/guides/")) {
return withoutExtension.slice("user-guides/guides/".length);
}
return withoutExtension;
}
function isNavPage(node) {
return Boolean(node && typeof node === "object" && typeof node.file === "string");
}
function getNavChildren(node) {
return Array.isArray(node?.pages) ? node.pages : [];
}
export function flattenNavPages(nav) {
const pages = [];
for (const section of nav.sections || []) {
const visit = (nodes, groupTrail = []) => {
for (const node of getNavChildren({ pages: nodes })) {
if (isNavPage(node)) {
pages.push({
page: node,
section,
navTrail: [section.title, ...groupTrail, node.title],
});
continue;
}
const children = getNavChildren(node);
if (children.length) visit(children, [...groupTrail, node.title].filter(Boolean));
}
};
visit(section.pages || []);
}
return pages;
}
export function attachSlugs(nav) {
const slugCounts = new Map();
for (const { page, section, navTrail } of flattenNavPages(nav)) {
const baseSlug = normalizeRouteKey(page.slug || derivePageSlug(page.file));
const seenCount = slugCounts.get(baseSlug) || 0;
page.slug = seenCount === 0 ? baseSlug : `${baseSlug}-${seenCount + 1}`;
slugCounts.set(baseSlug, seenCount + 1);
page.sectionTitle = section.title;
page.navTrail = navTrail;
}
return nav;
}
export function isPathInside(parentPath, targetPath) {
const rel = path.relative(parentPath, targetPath);
return rel === "" || (rel && !rel.startsWith("..") && !path.isAbsolute(rel));
}
function releaseTargetPathForDoc(sourcePath, releaseRoot) {
if (!isPathInside(docsRoot, sourcePath)) {
throw new Error(`Refusing to copy a file outside docs/: ${path.relative(process.cwd(), sourcePath)}`);
}
const relativeFromDocsRoot = path.relative(docsRoot, sourcePath);
const targetPath = path.join(releaseRoot, relativeFromDocsRoot);
if (!isPathInside(releaseRoot, targetPath)) {
throw new Error(`Refusing to write outside release directory: ${path.relative(process.cwd(), targetPath)}`);
}
return targetPath;
}
/**
* Parse YAML frontmatter from the head of a markdown string.
*
* Supports only the simple `key: value` shape (one per line). Values may be
* optionally wrapped in single or double quotes; quotes are stripped. The
* frontmatter must start at byte 0 with `---` followed by a newline, and end
* with another `---` on its own line. Malformed or missing frontmatter is
* treated as "no frontmatter" — the original body is returned and the parsed
* object is empty.
*
* Returns `{ body, frontmatter }`.
*/
export function parseFrontmatter(source) {
if (typeof source !== "string") return { body: source, frontmatter: {} };
if (!source.startsWith("---\n") && !source.startsWith("---\r\n")) {
return { body: source, frontmatter: {} };
}
// Find the closing fence: a line containing only `---`.
const closeRegex = /\r?\n---[ \t]*(\r?\n|$)/;
const afterOpen = source.indexOf("\n") + 1;
const rest = source.slice(afterOpen);
const closeMatch = rest.match(closeRegex);
if (!closeMatch) {
return { body: source, frontmatter: {} };
}
const yamlBlock = rest.slice(0, closeMatch.index);
let body = rest.slice(closeMatch.index + closeMatch[0].length);
// Consume a single blank line that authors typically leave between the
// closing fence and the first line of real content. Keeps headings flush.
body = body.replace(/^\r?\n/, "");
const frontmatter = {};
for (const rawLine of yamlBlock.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
const match = line.match(/^([A-Za-z_][\w.-]*)\s*:\s*(.*)$/);
if (!match) continue;
let value = match[2].trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
frontmatter[match[1]] = value;
}
return { body, frontmatter };
}
function isLocalDocHref(href) {
return !/^(?:[a-z]+:)?\/\//i.test(href) && !href.startsWith("#");
}
async function pathExists(targetPath) {
try {
await fs.access(targetPath);
return true;
} catch {
return false;
}
}
async function ensureDir(targetPath) {
await fs.mkdir(targetPath, { recursive: true });
}
async function copyFileIntoRelease(sourcePath, releaseRoot) {
const targetPath = releaseTargetPathForDoc(sourcePath, releaseRoot);
await ensureDir(path.dirname(targetPath));
await fs.copyFile(sourcePath, targetPath);
}
// Copy a markdown file into the release bundle while stripping any YAML
// frontmatter. Returns the parsed frontmatter object (empty if none).
async function copyMarkdownIntoRelease(sourcePath, releaseRoot) {
const targetPath = releaseTargetPathForDoc(sourcePath, releaseRoot);
await ensureDir(path.dirname(targetPath));
const source = await fs.readFile(sourcePath, "utf8");
const { body, frontmatter } = parseFrontmatter(source);
await fs.writeFile(targetPath, body);
return frontmatter;
}
async function copyDirRecursive(sourceDir, targetDir) {
await ensureDir(targetDir);
const entries = await fs.readdir(sourceDir, { withFileTypes: true });
for (const entry of entries) {
const sourcePath = path.join(sourceDir, entry.name);
const targetPath = path.join(targetDir, entry.name);
if (entry.isDirectory()) {
await copyDirRecursive(sourcePath, targetPath);
} else if (entry.isFile()) {
await ensureDir(path.dirname(targetPath));
await fs.copyFile(sourcePath, targetPath);
}
}
}
function rewriteAppJs(source, basePath) {
const appBaseBlock = `const APP_DIR_NAME = 'site';
const APP_BASE_PATH = (() => {
const marker = \`/\${APP_DIR_NAME}\`;
const pathname = window.location.pathname;
const markerIndex = pathname.indexOf(marker);
if (markerIndex === -1) return '';
return pathname.slice(0, markerIndex + marker.length);
})();
const APP_BASE_URL = new URL(\`\${APP_BASE_PATH.replace(/\\/$/, '')}/\`, window.location.origin);
const APP_SHELL_URL = new URL('index.html', APP_BASE_URL);`;
const rewrittenBaseBlock = `const RELEASE_BASE_PATH = ${JSON.stringify(basePath)};
let APP_BASE_PATH = "/";
let APP_BASE_URL = new URL("/", window.location.origin);
let APP_SHELL_URL = new URL("index.html", APP_BASE_URL);
let PRELOADED_NAV_DATA = null;
function applyAppBasePath(basePath) {
APP_BASE_PATH = !basePath || basePath === "auto" ? "/" : (basePath.endsWith("/") ? basePath : \`\${basePath}/\`);
APP_BASE_URL = new URL(\`\${APP_BASE_PATH.replace(/\\/$/, "")}/\`, window.location.origin);
APP_SHELL_URL = new URL("index.html", APP_BASE_URL);
}
function isNavPayload(value) {
const isNavPageNode = (node) => Boolean(
node &&
typeof node === "object" &&
typeof node.title === "string" &&
(
typeof node.file === "string" ||
(Array.isArray(node.pages) && node.pages.every(isNavPageNode))
)
);
return Boolean(
value &&
typeof value === "object" &&
Array.isArray(value.sections) &&
value.sections.every((section) =>
section &&
typeof section === "object" &&
typeof section.title === "string" &&
Array.isArray(section.pages) &&
section.pages.every(isNavPageNode)
)
);
}
async function fetchNavForBasePath(basePath) {
const normalizedBasePath = !basePath || basePath === "auto"
? "/"
: (basePath.endsWith("/") ? basePath : \`\${basePath}/\`);
const baseUrl = new URL(\`\${normalizedBasePath.replace(/\\/$/, "")}/\`, window.location.origin);
const response = await fetch(new URL("content.json", baseUrl), {
cache: "no-store",
headers: { Accept: "application/json" },
});
if (!response.ok) return null;
const text = await response.text();
let parsed;
try {
parsed = JSON.parse(text);
} catch {
return null;
}
if (!isNavPayload(parsed)) return null;
return parsed;
}
async function detectAppBasePath() {
if (RELEASE_BASE_PATH !== "auto") {
applyAppBasePath(RELEASE_BASE_PATH);
try {
PRELOADED_NAV_DATA = await fetchNavForBasePath(RELEASE_BASE_PATH);
} catch {
PRELOADED_NAV_DATA = null;
}
return;
}
const cleanPath = window.location.pathname.replace(/\\/index\\.html$/, "").replace(/\\/$/, "");
const segments = cleanPath.split("/").filter(Boolean);
const candidates = [];
for (let index = segments.length; index >= 0; index -= 1) {
const prefix = segments.slice(0, index).join("/");
const candidate = prefix ? \`/\${prefix}/\` : "/";
if (!candidates.includes(candidate)) candidates.push(candidate);
}
for (const candidate of candidates) {
try {
const navData = await fetchNavForBasePath(candidate);
if (navData) {
applyAppBasePath(candidate);
PRELOADED_NAV_DATA = navData;
return;
}
} catch {
// Keep probing parent paths until a valid content.json is found.
}
}
applyAppBasePath("/");
}`;
let output = source.replace(appBaseBlock, rewrittenBaseBlock);
if (output === source) {
throw new Error("Could not rewrite the docs shell base-path block.");
}
output = output.replace(
"async function init() {\n try {",
"async function init() {\n await detectAppBasePath();\n try {",
);
if (!output.includes("await detectAppBasePath();")) {
throw new Error("Could not wire base-path detection into init().");
}
output = output.replace(
` try {
const res = await fetch(resolveContentUrl('content.json'));
if (!res.ok) throw new Error(\`content.json \${res.status}\`);
navData = await res.json();
} catch (e) {`,
` try {
if (PRELOADED_NAV_DATA) {
navData = PRELOADED_NAV_DATA;
} else {
const res = await fetch(resolveContentUrl("content.json"), {
headers: { Accept: "application/json" },
});
if (!res.ok) throw new Error(\`content.json \${res.status}\`);
const text = await res.text();
try {
navData = JSON.parse(text);
} catch {
throw new Error("content.json did not return valid JSON. The server is likely rewriting missing JSON requests to index.html.");
}
}
if (!isNavPayload(navData)) {
throw new Error("content.json did not match the expected Paperclip docs schema.");
}
} catch (e) {`,
);
output = output.replace("../docs/user-guides/screenshots/", "user-guides/screenshots/");
output = output.replace(
"Could not load content.json. Check site hosting and rewrite configuration.",
"Could not load content.json. Check that the release bundle was uploaded intact and the base path is correct.",
);
return output;
}
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;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function escapeAttr(value) {
return escapeHtml(value).replace(/'/g, "&#39;");
}
function escapeXml(value) {
return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
const responsiveScreenshotVariants = new Map([
["dashboard/dashboard-overview.png", { width: 2880, height: 1800, variantWidth: 900 }],
]);
function screenshotReleasePath(src, theme = "dark") {
const match = String(src).match(/(?:^|\/)user-guides\/screenshots\/(?:light|dark)\/(.+)$/);
if (match) return `user-guides/screenshots/${theme}/${match[1]}`;
return src;
}
function screenshotVariantConfig(src) {
const match = String(src).match(/(?:^|\/)user-guides\/screenshots\/(?:light|dark)\/(.+)$/);
if (!match) return null;
return responsiveScreenshotVariants.get(match[1]) || null;
}
function releaseMarkdownImage(href, title, text) {
const src = screenshotReleasePath(href);
const attrs = [
`src="${escapeAttr(src)}"`,
`alt="${escapeAttr(text || "")}"`,
];
if (title) attrs.push(`title="${escapeAttr(title)}"`);
const variantConfig = screenshotVariantConfig(src);
if (variantConfig) {
const optimizedSrc = src.replace(/\.png(?:\?.*)?$/i, "-900.webp");
attrs.push(
`class="responsive-screenshot"`,
`data-screenshot="${escapeAttr(href)}"`,
`width="${variantConfig.width}"`,
`height="${variantConfig.height}"`,
`sizes="(max-width: 820px) calc(100vw - 48px), 820px"`,
`srcset="${escapeAttr(`${optimizedSrc} ${variantConfig.variantWidth}w, ${src} ${variantConfig.width}w`)}"`,
`decoding="async"`,
`loading="eager"`,
`fetchpriority="high"`,
`style="aspect-ratio:${variantConfig.width}/${variantConfig.height}"`,
);
}
return `<img ${attrs.join(" ")}>`;
}
function slugifyHeadingText(text) {
return String(text)
.trim()
.toLowerCase()
.replace(/[^\w\s-]/g, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-");
}
function markdownToPlainText(markdown) {
return markdown
.replace(/```[\s\S]*?```/g, " ")
.replace(/`([^`]+)`/g, "$1")
.replace(/!\[([^\]]*)\]\([^)]+\)/g, "$1")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/<[^>]+>/g, " ")
.replace(/[*_>#|-]/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function markdownDescription(markdown) {
const block = markdown
.split(/\n{2,}/)
.map((item) => item.trim())
.find((item) =>
item &&
!item.startsWith("#") &&
!item.startsWith("![") &&
!item.startsWith("|") &&
!item.startsWith("---")
);
const description = block ? markdownToPlainText(block) : defaultSeoDescription;
return description.slice(0, 220);
}
function siteUrlForPath(siteUrl, basePath, routePath = "") {
const publicBasePath = basePath === "auto" ? "/" : basePath;
const base = new URL(publicBasePath, `${siteUrl}/`);
return new URL(routePath.replace(/^\/+/, ""), base).toString();
}
function routeUrlForPage(siteUrl, basePath, page) {
return siteUrlForPath(siteUrl, basePath, `${page.slug}/`);
}
function buildJsonLd(metadata) {
const graph = metadata.page
? {
"@context": "https://schema.org",
"@type": "TechArticle",
headline: metadata.title.replace(/ \| Paperclip Docs$/, ""),
description: metadata.description,
url: metadata.url,
isPartOf: {
"@type": "WebSite",
name: "Paperclip Docs",
url: siteUrlForPath(metadata.siteUrl, metadata.basePath),
},
}
: {
"@context": "https://schema.org",
"@type": "WebSite",
name: "Paperclip Docs",
description: metadata.description,
url: metadata.url,
};
return JSON.stringify(graph);
}
function escapeScriptContent(value) {
return String(value).replace(/<\//g, "<\\/");
}
function injectSeo(html, metadata, { baseHref = null } = {}) {
const title = escapeHtml(metadata.title);
let output = html.replace(/<title>[\s\S]*?<\/title>/, `<title>${title}</title>`);
output = output.replace(/\n\s*<(?:meta|link)\b[^>]*data-seo-managed[^>]*>/g, "");
output = output.replace(/\n\s*<script\b[^>]*data-seo-managed[^>]*>[\s\S]*?<\/script>/g, "");
output = output.replace(/\n\s*<base\b[^>]*data-seo-base[^>]*>/g, "");
const tags = [
...(baseHref ? [`<base data-seo-base href="${escapeHtml(baseHref)}" />`] : []),
`<meta name="description" data-seo-managed content="${escapeHtml(metadata.description)}" />`,
`<meta name="robots" data-seo-managed content="index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1" />`,
`<link rel="canonical" data-seo-managed href="${escapeHtml(metadata.url)}" />`,
`<meta property="og:type" data-seo-managed content="${metadata.page ? "article" : "website"}" />`,
`<meta property="og:site_name" data-seo-managed content="Paperclip Docs" />`,
`<meta property="og:title" data-seo-managed content="${title}" />`,
`<meta property="og:description" data-seo-managed content="${escapeHtml(metadata.description)}" />`,
`<meta property="og:url" data-seo-managed content="${escapeHtml(metadata.url)}" />`,
`<meta name="twitter:card" data-seo-managed content="summary" />`,
`<meta name="twitter:title" data-seo-managed content="${title}" />`,
`<meta name="twitter:description" data-seo-managed content="${escapeHtml(metadata.description)}" />`,
`<script type="application/ld+json" data-seo-managed>${escapeScriptContent(buildJsonLd(metadata))}</script>`,
];
return output.replace(/(<title>[\s\S]*?<\/title>)/, `$1\n ${tags.join("\n ")}`);
}
async function pageMetadataForNav(nav, outDir, siteUrl, basePath) {
const navPages = [...flattenNavPages(nav)];
// Each page's date costs a `git log` plus a few `git show` calls, so resolving
// them one page at a time leaves the build waiting on ~600 round-trips in
// series. They are independent — start them all, then read them off below.
const lastmods = new Map(
await Promise.all(
navPages.map(async ({ page }) => [
page.file,
await gitLastModified(path.join(docsRoot, page.file)),
]),
),
);
const pages = [];
for (const { page, section } of navPages) {
const releaseMarkdownPath = path.join(outDir, page.file);
const markdown = await fs.readFile(releaseMarkdownPath, "utf8");
const h1 = markdown.match(/^#\s+(.+)$/m)?.[1]?.trim();
// Authored `seo_title` / `seo_description` frontmatter wins. The sidebar
// label is written for a 240px-wide nav, not a search result, and the
// derived description is just the opening paragraph clipped to a length.
const authoredTitle = page.frontmatter?.seo_title?.trim();
const authoredDescription = page.frontmatter?.seo_description?.trim();
const pageTitle = authoredTitle || page.title || h1 || "Paperclip Docs";
pages.push({
page,
sectionTitle: section.title,
title: `${pageTitle} | Paperclip Docs`,
description: authoredDescription || markdownDescription(markdown),
url: routeUrlForPage(siteUrl, basePath, page),
lastmod: lastmods.get(page.file),
siteUrl,
basePath,
});
}
return pages;
}
// A shallow or grafted checkout — what CI providers clone by default — has a
// single commit, so `git log -1` reports that commit's date for *every* file.
// The dates look valid and are uniformly wrong, which is worse than having
// none: every sitemap resubmission then claims the whole site changed at once
// and Google learns to discount the signal. So: deepen the checkout if we can,
// and fall back to publishing no dates at all if we can't.
let trustworthyGitHistory = null;
async function isShallowRepository() {
const { stdout } = await execFileAsync("git", ["rev-parse", "--is-shallow-repository"], {
cwd: repoRoot,
});
return stdout.trim() === "true";
}
// Cloudflare Pages builds from a depth-1 clone and its clone depth is not
// configurable, so asking the caller for full history is not an option there.
// Deepening the checkout in-place is: the build container keeps the remote it
// cloned from, and this repo's history is small enough to fetch in seconds.
const UNSHALLOW_TIMEOUT_MS = 90_000;
async function deepenCheckout() {
try {
await execFileAsync("git", ["fetch", "--unshallow", "--quiet"], {
cwd: repoRoot,
timeout: UNSHALLOW_TIMEOUT_MS,
});
return !(await isShallowRepository());
} catch (error) {
// Print the reason: whether this fails is entirely down to whether the
// build environment kept usable fetch credentials, and the build log is
// the only place we get to find that out.
const detail = (error.stderr || error.message || "").trim().split("\n")[0];
console.warn(`Could not deepen the shallow checkout: ${detail}`);
return false;
}
}
// Memoise the *promise*, not the resolved value. Every page asks this question
// at once, and caching only the answer lets all of them past the guard before
// the first has finished — which means 192 concurrent `git fetch --unshallow`
// calls fighting over .git/shallow.lock, and no dates at all.
function hasTrustworthyGitHistory() {
trustworthyGitHistory ??= resolveTrustworthyGitHistory();
return trustworthyGitHistory;
}
async function resolveTrustworthyGitHistory() {
try {
let usable = !(await isShallowRepository());
if (!usable) {
console.warn("Shallow git checkout detected: fetching full history for sitemap <lastmod>.");
usable = await deepenCheckout();
console.warn(
usable
? "Full history fetched: publishing real sitemap <lastmod> dates."
: "Still shallow: omitting sitemap <lastmod> rather than dating every page alike.",
);
}
return usable;
} catch {
return false;
}
}
// `lastmod` is meant to carry the date the page's *content* last changed, and
// a metadata sweep is not that: #115 rewrote seo_title/seo_description on all
// 192 pages in one commit without touching a word of prose. Dating every page
// by that commit hands Search Console the "whole site changed at once" signal
// the rest of this file works to avoid — so walk the file's history and report
// the newest commit that actually changed the rendered body.
const GIT_MAX_BUFFER = 64 * 1024 * 1024;
function renderedBody(source) {
return parseFrontmatter(source).body.trim();
}
async function fileAtCommit(sha, repoRelativePath) {
try {
const { stdout } = await execFileAsync("git", ["show", `${sha}:${repoRelativePath}`], {
cwd: repoRoot,
maxBuffer: GIT_MAX_BUFFER,
});
return stdout;
} catch {
return null;
}
}
// `--follow` reports each commit under the path the file carried *at that*
// commit, so renames have to be read back out of the status line to know what
// to hand `git show`: `M<TAB>path` for an edit, `R095<TAB>old<TAB>new` for a
// rename. The path as of the commit is the last field either way. 55 of the
// docs files have a rename in their history, so this is not a rare branch.
export function parseFollowLog(stdout) {
const commits = [];
let pending = null;
for (const line of stdout.split("\n")) {
const header = /^C ([0-9a-f]{40}) (\d{4}-\d{2}-\d{2})$/.exec(line);
if (header) {
pending = { sha: header[1], date: header[2], path: null };
commits.push(pending);
continue;
}
if (!pending || pending.path || !line.includes("\t")) continue;
pending.path = line.split("\t").at(-1).trim() || null;
}
return commits.filter((commit) => commit.path);
}
async function gitLastModified(sourcePath) {
if (!isPathInside(repoRoot, sourcePath)) return undefined;
if (!(await hasTrustworthyGitHistory())) return undefined;
const repoRelativePath = toPosixPath(path.relative(repoRoot, sourcePath));
try {
const { stdout } = await execFileAsync(
"git",
["log", "--follow", "--name-status", "--format=C %H %cs", "--", repoRelativePath],
{ cwd: repoRoot, maxBuffer: GIT_MAX_BUFFER },
);
const commits = parseFollowLog(stdout);
if (!commits.length) return undefined;
let newer = await fileAtCommit(commits[0].sha, commits[0].path);
if (newer === null) return commits[0].date;
for (let index = 0; index < commits.length - 1; index += 1) {
const older = await fileAtCommit(commits[index + 1].sha, commits[index + 1].path);
// A commit we cannot read across is a boundary, not a body change we can
// rule out — treat it as the point this content came into existence.
if (older === null) return commits[index].date;
if (renderedBody(older) !== renderedBody(newer)) return commits[index].date;
newer = older;
}
// Every commit since creation only ever touched frontmatter.
return commits.at(-1).date;
} catch {
// An omitted lastmod is safer than publishing the build timestamp as if
// every document changed at once.
return undefined;
}
}
// Last defence for any history shape that still collapses to a single date:
// a 192-page docs set does not genuinely change in one commit.
const UNIFORM_LASTMOD_MIN_PAGES = 10;
export function dropUniformLastmod(pages) {
const dated = pages.filter((page) => page.lastmod);
if (dated.length < UNIFORM_LASTMOD_MIN_PAGES) return pages;
if (new Set(dated.map((page) => page.lastmod)).size > 1) return pages;
console.warn(
`All ${dated.length} pages report the same git date (${dated[0].lastmod}); `
+ "omitting sitemap <lastmod> rather than claiming the whole site changed at once.",
);
return pages.map((page) => ({ ...page, lastmod: undefined }));
}
function buildSitemap({ siteUrl, basePath, pages }) {
const rootLastmod = pages
.map((page) => page.lastmod)
.filter(Boolean)
.sort()
.at(-1);
const entries = [
{ loc: siteUrlForPath(siteUrl, basePath), lastmod: rootLastmod },
...pages.map((page) => ({ loc: page.url, lastmod: page.lastmod })),
];
return `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${entries.map((entry) => ` <url>
<loc>${escapeXml(entry.loc)}</loc>
${entry.lastmod ? ` <lastmod>${escapeXml(entry.lastmod)}</lastmod>\n` : ""}
</url>`).join("\n")}
</urlset>
`;
}
function buildRobots({ siteUrl, basePath }) {
return `User-agent: *
Allow: /
Sitemap: ${siteUrlForPath(siteUrl, basePath, "sitemap.xml")}
`;
}
function cloudflarePathForRoute(basePath, routePath, { trailingSlash = false } = {}) {
const baseKey = normalizeRouteKey(getDeploymentBasePath(basePath));
const routeKey = normalizeRouteKey(routePath);
const key = [baseKey, routeKey].filter(Boolean).join("/");
return `/${key}${trailingSlash ? "/" : ""}`;
}
function cloudflareRedirectLine(basePath, sourceRoute, destinationRoute) {
const sourcePath = cloudflarePathForRoute(basePath, sourceRoute);
const sourceSlashPath = cloudflarePathForRoute(basePath, sourceRoute, { trailingSlash: true });
const destinationPath = cloudflarePathForRoute(basePath, destinationRoute, { trailingSlash: true });
if (sourceSlashPath === destinationPath) return [];
return [
`${sourcePath} ${destinationPath} 301`,
`${sourceSlashPath} ${destinationPath} 301`,
];
}
function buildCloudflareRedirects({ basePath, pages, legacyRedirects = {} }) {
const routeRedirects = pages
.map(({ page }) => {
const sourcePath = cloudflarePathForRoute(basePath, page.slug);
const destinationPath = cloudflarePathForRoute(basePath, page.slug, { trailingSlash: true });
return `${sourcePath} ${destinationPath} 301`;
})
.join("\n");
const legacyRouteRedirects = Object.entries(legacyRedirects)
.flatMap(([sourceRoute, destinationRoute]) =>
cloudflareRedirectLine(basePath, sourceRoute, destinationRoute)
)
.join("\n");
return `# Canonical docs URLs include trailing slashes. Keep no-slash requests
# on a normal one-hop 301 instead of Cloudflare Pages' implicit directory 308.
${routeRedirects}
# Legacy docs URLs moved during the information architecture cleanup. Redirect
# them before unknown URLs fall through to 404 so crawlers see one canonical URL per page.
${legacyRouteRedirects}
`;
}
function buildCloudflareHeaders() {
return `/*
Referrer-Policy: strict-origin-when-cross-origin
X-Content-Type-Options: nosniff
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
Cross-Origin-Opener-Policy: same-origin
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()
Content-Security-Policy: default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'none'; img-src 'self' data: https:; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self' https://api.github.com; upgrade-insecure-requests
# PAP-16990 cache policy. HTML shells must always revalidate so a returning
# visitor never renders a stale document that points at a since-changed bundle.
# Path routes are extensionless directories, so the docs root and every route
# directory are matched explicitly. Fingerprinted JS/CSS carry a content hash in
# the URL and are safe to cache immutably; content.json ships per release with
# the exact-version bundle, so it revalidates alongside the HTML.
/
Cache-Control: public, max-age=0, must-revalidate
/*/
Cache-Control: public, max-age=0, must-revalidate
/app.*.js
Cache-Control: public, max-age=31536000, immutable
/styles.*.css
Cache-Control: public, max-age=31536000, immutable
/content.json
Cache-Control: public, max-age=0, must-revalidate
/sitemap.xml
Content-Type: application/xml; charset=utf-8
X-Robots-Tag: noindex, nofollow
/robots.txt
Content-Type: text/plain; charset=utf-8
X-Robots-Tag: noindex, nofollow
/*.css
X-Robots-Tag: noindex, nofollow
/*.js
X-Robots-Tag: noindex, nofollow
/*.json
X-Robots-Tag: noindex, nofollow
/*.md
X-Robots-Tag: noindex, nofollow
/*.png
X-Robots-Tag: noindex, nofollow
/*.jpg
X-Robots-Tag: noindex, nofollow
/*.jpeg
X-Robots-Tag: noindex, nofollow
/*.webp
X-Robots-Tag: noindex, nofollow
/*.svg
X-Robots-Tag: noindex, nofollow
/*.txt
X-Robots-Tag: noindex, nofollow
`;
}
function buildNotFoundPage(siteUrl, basePath) {
const metadata = {
title: "Not found | Paperclip Docs",
description: "This Paperclip Docs URL does not exist.",
url: siteUrlForPath(siteUrl, basePath, "404.html"),
siteUrl,
basePath,
};
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${escapeHtml(metadata.title)}</title>
<meta name="robots" content="noindex, nofollow" />
<link rel="canonical" href="${escapeHtml(metadata.url)}" />
</head>
<body>
<main>
<h1>Not found</h1>
<p>This Paperclip Docs URL does not exist.</p>
<p><a href="${escapeAttr(siteUrlForPath(siteUrl, basePath))}">Open the docs home page</a></p>
</main>
</body>
</html>
`;
}
function collectMarkdownLinks(markdown) {
const links = [];
const markdownLinkRegex = /\[[^\]]+\]\(([^)\s]+(?:\s+\"[^\"]*\")?)\)/g;
const htmlImageRegex = /<img\b[^>]*\bsrc=["']([^"']+)["'][^>]*>/gi;
let match;
while ((match = markdownLinkRegex.exec(markdown)) !== null) {
const rawTarget = match[1].trim().replace(/\s+"[^"]*"$/, "");
links.push(rawTarget);
}
while ((match = htmlImageRegex.exec(markdown)) !== null) {
links.push(match[1].trim());
}
return links;
}
async function collectReleaseFiles(nav) {
const markdownFiles = new Set();
const queue = [];
const warnings = [];
for (const { page } of flattenNavPages(nav)) {
const absolutePath = path.resolve(__dirname, page.file);
queue.push(absolutePath);
}
while (queue.length > 0) {
const currentPath = queue.shift();
if (markdownFiles.has(currentPath)) continue;
if (!(await pathExists(currentPath))) {
warnings.push(`Missing markdown file: ${path.relative(process.cwd(), currentPath)}`);
continue;
}
markdownFiles.add(currentPath);
const markdown = await fs.readFile(currentPath, "utf8");
const baseDir = path.dirname(currentPath);
for (const rawHref of collectMarkdownLinks(markdown)) {
const [href] = rawHref.split("#", 1);
if (!href || !isLocalDocHref(href)) continue;
const resolvedPath = path.resolve(baseDir, href);
if (!isPathInside(docsRoot, resolvedPath)) continue;
if (href.endsWith(".md")) {
if (await pathExists(resolvedPath)) {
queue.push(resolvedPath);
} else {
warnings.push(`Missing linked markdown file: ${path.relative(process.cwd(), resolvedPath)}`);
}
}
}
}
return { markdownFiles, warnings };
}
export function rewriteNav(nav) {
const rewriteNodes = (nodes) => getNavChildren({ pages: nodes }).map((node) => {
if (!isNavPage(node)) {
return {
...node,
pages: rewriteNodes(getNavChildren(node)),
};
}
const absolutePath = path.resolve(__dirname, node.file);
const relativeFromDocsRoot = toPosixPath(path.relative(docsRoot, absolutePath));
return {
...node,
file: relativeFromDocsRoot,
};
});
return {
...nav,
sections: nav.sections.map((section) => ({
...section,
pages: rewriteNodes(section.pages),
})),
};
}
function buildHtaccess(basePath) {
const rewriteBaseLine = basePath === "auto" ? "" : `RewriteBase ${basePath}\n\n`;
return `RewriteEngine On
${rewriteBaseLine}RewriteCond %{REQUEST_FILENAME} -f [OR]
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^ - [L]
RewriteRule ^ index.html [L]
`;
}
function buildNginxConfig(basePath) {
const deploymentBasePath = getDeploymentBasePath(basePath);
const placeholderComment = basePath === "auto"
? "# Replace /paperclip-docs/ with the public mount path for this bundle before using this snippet.\n"
: "";
return `${placeholderComment}# Paperclip docs static SPA
# Real files must 404 if missing. Only extensionless routes should fall back to index.html.
location ~ ^${deploymentBasePath}.*\\.[A-Za-z0-9]+$ {
try_files $uri =404;
}
location ${deploymentBasePath} {
try_files $uri $uri/ ${deploymentBasePath}index.html;
}
`;
}
function renderTabsBlock(labels, body) {
const names = labels.split(",").map((label) => label.trim());
let output = '<div class="tabs-container">';
output += '<div class="tabs-bar">';
names.forEach((name, index) => {
output += `<button class="tab-btn${index === 0 ? " active" : ""}" data-tab="${escapeAttr(name)}">${escapeHtml(name)}</button>`;
});
output += "</div>";
const tabRegex = /<!-- tab: (.+?) -->([\s\S]*?)(?=<!-- tab:|$)/g;
let match;
let index = 0;
while ((match = tabRegex.exec(body)) !== null) {
output += `<div class="tab-panel${index === 0 ? " active" : ""}" data-panel="${escapeAttr(match[1].trim())}">`;
output += marked.parse(match[2].trim());
output += "</div>";
index += 1;
}
return `${output}</div>`;
}
function preprocessTabs(markdown) {
const openMarker = "<!-- tabs:";
const closeMarker = "<!-- /tabs -->";
const maxIterations = 100;
let output = markdown;
for (let index = 0; index < maxIterations; index += 1) {
const closeIndex = output.indexOf(closeMarker);
if (closeIndex === -1) break;
const openIndex = output.lastIndexOf(openMarker, closeIndex - 1);
if (openIndex === -1) break;
const afterOpen = output.indexOf("-->", openIndex);
if (afterOpen === -1 || afterOpen > closeIndex) break;
const labels = output.slice(openIndex + openMarker.length, afterOpen).trim();
const body = output.slice(afterOpen + 3, closeIndex);
output = output.slice(0, openIndex) + renderTabsBlock(labels, body) + output.slice(closeIndex + closeMarker.length);
}
return output;
}
// A fenced code block tagged `skill-source` (e.g. ```` ```markdown skill-source ````)
// renders as a standard markdown code block that additionally advertises a
// downloadable filename via `data-skill-download`. The client (app.js) reads
// that attribute to offer a Download control beside the shared Copy button.
// Every other code block is rendered exactly as marked's default renderer does.
function isSkillSourceInfo(infostring) {
return String(infostring || "").trim().split(/\s+/).includes("skill-source");
}
// docs-root-relative markdown path -> the canonical HTML route for that page.
// Markdown authors write relative `../foo.md` links, but every page is served
// from a trailing-slash route directory, so a raw relative link resolves one
// level too deep and 404s for anything that has not run app.js yet.
export function buildDocRouteMap(nav, basePath) {
const routeMap = new Map();
for (const { page } of flattenNavPages(nav)) {
routeMap.set(page.file, routePathForSlug(basePath, page.slug));
}
return routeMap;
}
/**
* Resolve a markdown link against the document that contains it.
*
* Returns `null` for links this pass does not own (external URLs, in-page
* anchors, images and other non-markdown assets). For a markdown link it
* always reports the resolved docs-root-relative `targetFile`, and a `route`
* that is `null` when no page in the nav publishes that file.
*/
export function resolveDocHref(href, sourceFile, routeMap) {
if (typeof href !== "string" || !href) return null;
if (!isLocalDocHref(href)) return null;
const hashIndex = href.indexOf("#");
const docHref = hashIndex === -1 ? href : href.slice(0, hashIndex);
const hash = hashIndex === -1 ? "" : href.slice(hashIndex);
if (!docHref.endsWith(".md")) return null;
const sourceDir = sourceFile.includes("/") ? sourceFile.replace(/\/[^/]*$/, "") : "";
const targetFile = normalizeDocPath(sourceDir ? `${sourceDir}/${docHref}` : docHref);
const route = routeMap.get(targetFile);
return { targetFile, route: route ? `${route}${hash}` : null };
}
export function renderStaticMarkdown(markdown, { sourceFile = null, routeMap = null, onUnresolvedLink = null } = {}) {
const renderer = new marked.Renderer();
const usedHeadingIds = new Set();
renderer.image = releaseMarkdownImage;
// Emit canonical route hrefs into the static HTML. app.js repairs these at
// runtime too, but crawlers index the pre-render, so the bytes we ship have
// to already be correct.
if (routeMap && sourceFile) {
const defaultLink = renderer.link.bind(renderer);
renderer.link = (href, title, text) => {
const resolved = resolveDocHref(href, sourceFile, routeMap);
if (!resolved) return defaultLink(href, title, text);
if (!resolved.route) {
onUnresolvedLink?.({ sourceFile, href, targetFile: resolved.targetFile });
return defaultLink(href, title, text);
}
return defaultLink(resolved.route, title, text);
};
}
const defaultCode = renderer.code.bind(renderer);
renderer.code = (code, infostring, escaped) => {
if (isSkillSourceInfo(infostring)) {
return `<pre><code class="language-markdown" data-skill-download="SKILL.md">${escapeHtml(code)}\n</code></pre>\n`;
}
return defaultCode(code, infostring, escaped);
};
renderer.heading = (html, level, rawText) => {
const baseId = slugifyHeadingText(rawText) || `h${level}`;
let id = baseId;
let suffix = 2;
while (usedHeadingIds.has(id)) id = `${baseId}-${suffix++}`;
usedHeadingIds.add(id);
return `<h${level} id="${escapeAttr(id)}">${html}</h${level}>\n`;
};
marked.setOptions({ gfm: true, breaks: false, renderer });
return marked.parse(preprocessTabs(markdown));
}
function inlineReleaseStyles(html, css) {
// Match the stylesheet link whether or not its href has been content-
// fingerprinted (styles.css or styles.<hash>.css) so inlining keeps working.
return html.replace(
/<link rel="stylesheet" href="styles(?:\.[0-9a-f]+)?\.css" \/>/,
`<style data-inline-release-css>${css}</style>`,
);
}
// Derive a content-fingerprinted filename (e.g. app.js -> app.<hash>.js). The
// hash is over the exact bytes that ship, so the URL changes if and only if the
// asset's contents change. Exported for the fingerprint regression test.
export function fingerprintAssetName(filename, content) {
const dot = filename.lastIndexOf(".");
const stem = filename.slice(0, dot);
const ext = filename.slice(dot);
const hash = createHash("sha256").update(content).digest("hex").slice(0, 12);
return `${stem}.${hash}${ext}`;
}
// Point the shell's external asset references at their fingerprinted URLs so
// every generated route loads the exact bundle it was built with (PAP-16990).
function rewriteAssetReferences(html, { appJsName, stylesName }) {
return html
.replace('src="app.js"', `src="${appJsName}"`)
.replace('href="styles.css"', `href="${stylesName}"`);
}
/* ─── 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("");
}
// ── Server-rendered navigation chrome ───────────────────────────────────────
// app.js builds the sidebar, mobile drawer and breadcrumb from content.json on
// load. That leaves interior routes shipping an empty `#sb-sections`, so the
// only crawlable links in the raw HTML are the logo and the prev/next pair and
// no link equity ever reaches a deep page. These builders emit the same markup
// at build time; app.js keeps the DOM and just wires the click handlers.
function staticSidebarPagesHtml(nodes, basePath, activeFile, level = 0) {
return getNavChildren({ pages: nodes }).map((node) => {
if (isNavPage(node)) {
const href = routePathForSlug(basePath, node.slug);
const isActive = node.file === activeFile;
return `<a class="sb-link${isActive ? " active" : ""}" data-file="${escapeAttr(node.file)}"`
+ `${isActive ? ' aria-current="page"' : ""}`
+ ` href="${escapeAttr(href)}">${escapeHtml(node.title)}</a>`;
}
const children = getNavChildren(node);
if (!children.length) return "";
const pageCount = countNavPages(children);
const isOpen = navSubtreeContainsFile(children, activeFile);
return `<div class="sb-group" data-open="${isOpen}" data-depth="${level}">`
+ `<button class="sb-group-btn" type="button">`
+ `<span class="sb-group-title">${escapeHtml(node.title || "Group")}</span>`
+ `<span class="sb-group-count">${pageCount}</span>`
+ `<svg class="chev" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="m4 2 4 4-4 4"/></svg>`
+ `</button>`
+ `<div class="sb-group-pages">${staticSidebarPagesHtml(children, basePath, activeFile, level + 1)}</div>`
+ `</div>`;
}).join("");
}
function navSubtreeContainsFile(nodes, activeFile) {
if (!activeFile) return false;
return getNavChildren({ pages: nodes }).some((node) => {
if (isNavPage(node)) return node.file === activeFile;
return navSubtreeContainsFile(getNavChildren(node), activeFile);
});
}
function buildSidebarSectionsHtml(nav, basePath, iconPaths, activeFile) {
return sectionsByTier(nav).map(({ tier, entries }) => {
const header = `<div class="sb-tier-header">${escapeHtml(tier)}</div>`;
const sections = entries.map(({ section, index }) => {
const pageCount = countNavPages(section.pages);
const isOpen = navSubtreeContainsFile(section.pages, activeFile);
return `<div class="sb-section" data-section-idx="${index}"`
+ ` data-section-title="${escapeAttr(section.title)}" data-open="${isOpen}">`
+ `<button class="sb-section-btn" type="button">`
+ `<span class="sb-section-icon">${sectionIconTag(section, iconPaths)}</span>`
+ `<span class="sb-section-title">${escapeHtml(section.title)}</span>`
+ `<span class="sb-section-count">${pageCount}</span>`
+ `<svg class="chev" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="m4 2 4 4-4 4"/></svg>`
+ `</button>`
+ `<div class="sb-pages">${staticSidebarPagesHtml(section.pages, basePath, activeFile)}</div>`
+ `</div>`;
}).join("");
return header + sections;
}).join("");
}
function buildBreadcrumbHtml(metadata) {
const trail = Array.isArray(metadata?.page?.navTrail) && metadata.page.navTrail.length
? metadata.page.navTrail
: [metadata?.sectionTitle, metadata?.page?.title].filter(Boolean);
return trail.map((crumb, index) => {
const isCurrent = index === trail.length - 1;
const crumbHtml = `<span${isCurrent ? ' class="crumb-current"' : ""}>${escapeHtml(crumb)}</span>`;
return index === 0 ? crumbHtml : `<span class="sep">/</span>${crumbHtml}`;
}).join("");
}
// Fill the shell's empty nav containers for one interior route.
function withStaticChrome(sourceIndex, nav, basePath, iconPaths, metadata) {
if (!nav) return sourceIndex;
const activeFile = metadata?.page?.file || null;
const sectionsHtml = buildSidebarSectionsHtml(nav, basePath, iconPaths, activeFile);
// Desktop sidebar only. The mobile drawer is `inert`/`aria-hidden` until a
// JS toggle opens it, so server-rendering it would duplicate all 192 links
// per page for no crawl or no-JS benefit; app.js still builds it on demand.
const placeholder = '<div id="sb-sections"></div>';
if (!sourceIndex.includes(placeholder)) {
throw new Error("site/index.html is missing the sb-sections navigation container.");
}
let html = sourceIndex.replace(
placeholder,
`<div id="sb-sections" data-server-rendered="true">${sectionsHtml}</div>`,
);
const breadcrumbPlaceholder = '<div id="breadcrumb" aria-label="Breadcrumb"></div>';
if (!html.includes(breadcrumbPlaceholder)) {
throw new Error("site/index.html is missing the breadcrumb container.");
}
return html.replace(
breadcrumbPlaceholder,
`<div id="breadcrumb" aria-label="Breadcrumb" data-server-rendered="true">${buildBreadcrumbHtml(metadata)}</div>`,
);
}
function buildRootPageHtml(sourceIndex, nav, basePath, iconPaths) {
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>`,
);
}
// 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>`
: '<div class="page-nav-spacer"></div>';
const nextHtml = next
? `<a class="page-nav-btn next" href="${escapeAttr(next.url)}"><span class="page-nav-label">Next →</span><span class="page-nav-title">${escapeHtml(next.page.title)}</span></a>`
: "";
return `${prevHtml}${nextHtml}`;
}
function buildStaticPageHtml(sourceIndex, metadata, markdown, basePath, releaseStyles, prev, next, linkOptions = {}) {
const articleHtml = renderStaticMarkdown(markdown, linkOptions);
const routeBaseHref = getPublicBasePath(basePath);
return removeLandingSubtree(
inlineReleaseStyles(injectSeo(sourceIndex, metadata, { baseHref: routeBaseHref }), releaseStyles),
)
.replace('<div id="article-view">', '<div id="article-view" class="is-active">')
.replace('<article id="article" style="display:none"></article>', `<article id="article">${articleHtml}</article>`)
.replace(
'<div id="page-nav" style="display:none"></div>',
`<div id="page-nav" style="display:flex">${buildStaticPageNav(prev, next)}</div>`,
);
}
async function writeStaticRoutePages({
outDir,
sourceIndex,
pages,
markdownBodiesByFile,
basePath,
releaseStyles,
routeMap,
nav,
iconPaths,
onUnresolvedLink,
}) {
for (const [index, metadata] of pages.entries()) {
const { page } = metadata;
const markdown = markdownBodiesByFile.get(page.file);
if (!markdown) continue;
const routePath = path.join(outDir, ...page.slug.split("/"), "index.html");
if (!isPathInside(outDir, routePath)) {
throw new Error(`Refusing to write route outside release directory: ${page.slug}`);
}
await ensureDir(path.dirname(routePath));
await fs.writeFile(
routePath,
buildStaticPageHtml(
withStaticChrome(sourceIndex, nav, basePath, iconPaths, metadata),
metadata,
markdown,
basePath,
releaseStyles,
pages[index - 1],
pages[index + 1],
{ sourceFile: page.file, routeMap, onUnresolvedLink },
),
);
}
}
function buildDeployGuide(basePath) {
const deploymentBasePath = getDeploymentBasePath(basePath);
const basePathGuidance = basePath === "auto"
? `This bundle was built with \`--base-path auto\`.
That mode is a fallback. For production or a Cloudflare Pages preview, rebuild with an explicit path, for example:
\`\`\`sh
node site/build-release.mjs --base-path ${deploymentBasePath}
\`\`\``
: `This bundle was built for the public base path \`${basePath}\`.`;
return `# Paperclip Docs Release Deployment
${basePathGuidance}
## Routing model
- The app uses static path routes, so deep links look like \`${deploymentBasePath}reference/skills\`
- Each docs page is emitted as its own \`index.html\` with route-specific SEO metadata and crawler-visible content
- Legacy hash and \`?page=\` links are still accepted by the client app and normalized to path routes
- Serve the bundle root at \`${deploymentBasePath}\`
- Keep all copied files together so requests for \`content.json\`, markdown files, images, fonts, and JS resolve normally
- Serve generated files such as \`sitemap.xml\`, \`robots.txt\`, and nested route directories unchanged
- Do not add a wildcard SPA rewrite such as \`/* /index.html 200\`; unknown URLs and removed assets must return 404 so crawlers do not treat them as duplicate docs pages
If \`content.json\` or linked markdown files are missing from the uploaded bundle, the docs app will fail to load content.
## Cloudflare Pages
- The \`paperclipai/paperclip-docs\` repository is connected to Cloudflare Pages; do not deploy this bundle with Wrangler for normal docs releases.
- Pushing \`main\` triggers the production deployment. Cloudflare serves it on \`docs.paperclip.ing\`, the project domain, and a deployment-specific \`https://<hash>.paperclip-docs-74t.pages.dev\` URL.
- Pushing any other branch triggers a preview/canary deployment. In the Cloudflare Pages dashboard, open **Workers & Pages -> paperclip-docs -> Deployments** and use the row whose source branch and commit match your push.
- Canary URLs are Cloudflare-generated deployment URLs, for example \`https://92b9a99c.paperclip-docs-74t.pages.dev\`; do not derive them from the branch name by hand.
- If GitHub shows a Cloudflare Pages check or deployment link on the commit/PR, that URL should match the Cloudflare dashboard deployment row.
## Sitemap freshness
\`sitemap.xml\` derives each \`<lastmod>\` from that file's last commit date.
Cloudflare Pages clones shallowly by default. In a shallow checkout every file
reports the single fetched commit's date, so the build detects that and omits
\`<lastmod>\` entirely rather than telling Google that all 192 documents changed
on the same day — a claim that trains Google to discount the sitemap.
To publish real per-file dates, deepen the checkout before building, for example
by setting the Pages build command to:
\`\`\`sh
git fetch --unshallow || true; npm run docs:build
\`\`\`
Omitted dates are the safe default; wrong dates are not.
## Other static hosts
The generated \`.htaccess\` and \`nginx.conf.example\` are optional examples for non-Pages hosting.
`;
}
function minifyCss(source) {
return source
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/\s+/g, " ")
.replace(/\s*([{}:;,>+~])\s*/g, "$1")
.replace(/;}/g, "}")
.trim();
}
async function minifyJs(source) {
const result = await transform(source, {
loader: "js",
minify: true,
target: "es2020",
legalComments: "none",
});
return result.code;
}
async function main() {
const options = parseArgs(process.argv.slice(2));
const sourceNav = JSON.parse(await fs.readFile(sourceNavPath, "utf8"));
const sourceRedirects = JSON.parse(await fs.readFile(sourceRedirectsPath, "utf8"));
const releaseNav = attachSlugs(rewriteNav(sourceNav));
const { markdownFiles, warnings } = await collectReleaseFiles(sourceNav);
await fs.rm(options.outDir, { recursive: true, force: true });
await ensureDir(options.outDir);
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 releaseStyles = minifyCss(sourceStyles);
const releaseAppJs = await minifyJs(rewriteAppJs(sourceAppJs, options.basePath));
// Content-fingerprint the external client bundle so a returning browser can
// never combine freshly revalidated HTML with a stale cached app.js. Every
// generated route references this exact build's asset URL; a changed bundle
// gets a brand-new URL that no cache can satisfy with old bytes (PAP-16990).
const appJsName = fingerprintAssetName("app.js", releaseAppJs);
const stylesName = fingerprintAssetName("styles.css", releaseStyles);
const sourceIndex = rewriteAssetReferences(
linkDocsRootAnchors(rawSourceIndex, options.basePath),
{ appJsName, stylesName },
);
await fs.writeFile(path.join(options.outDir, stylesName), releaseStyles);
await fs.writeFile(path.join(options.outDir, appJsName), releaseAppJs);
if (await pathExists(sourceVendorDir)) {
await copyDirRecursive(sourceVendorDir, path.join(options.outDir, "vendor"));
}
await fs.writeFile(path.join(options.outDir, ".htaccess"), buildHtaccess(options.basePath));
await fs.writeFile(path.join(options.outDir, "nginx.conf.example"), buildNginxConfig(options.basePath));
await fs.writeFile(path.join(options.outDir, "DEPLOY.md"), buildDeployGuide(options.basePath));
await fs.writeFile(path.join(options.outDir, "_headers"), buildCloudflareHeaders());
await fs.writeFile(path.join(options.outDir, "404.html"), buildNotFoundPage(options.siteUrl, options.basePath));
// Copy markdown files, stripping YAML frontmatter, and collect per-file
// frontmatter to surface via content.json (keyed by repo-relative path).
const sortedMarkdownFiles = [...markdownFiles].sort((left, right) => left.localeCompare(right));
const frontmatterByFile = new Map();
const markdownBodiesByFile = new Map();
for (const markdownPath of sortedMarkdownFiles) {
const frontmatter = await copyMarkdownIntoRelease(markdownPath, options.outDir);
const source = await fs.readFile(markdownPath, "utf8");
const { body } = parseFrontmatter(source);
const relativeFromDocsRoot = toPosixPath(path.relative(docsRoot, markdownPath));
markdownBodiesByFile.set(relativeFromDocsRoot, body);
if (Object.keys(frontmatter).length > 0) {
frontmatterByFile.set(relativeFromDocsRoot, frontmatter);
}
}
// Attach parsed frontmatter onto matching nav page entries so the SPA can
// surface fields like `paperclip_version` per page.
for (const { page } of flattenNavPages(releaseNav)) {
const fm = frontmatterByFile.get(page.file);
if (fm) page.frontmatter = fm;
}
await fs.writeFile(path.join(options.outDir, "content.json"), `${JSON.stringify(releaseNav)}\n`);
// app.js fetches this at boot to resolve legacy hash/?page= routes. It was
// never copied into the bundle, so every visit spent a request on a 404.
// `_headers` already marks /*.json noindex, so shipping it costs nothing.
await fs.writeFile(
path.join(options.outDir, "redirects.json"),
`${JSON.stringify(sourceRedirects)}\n`,
);
const pageMetadata = dropUniformLastmod(
await pageMetadataForNav(releaseNav, options.outDir, options.siteUrl, options.basePath),
);
await fs.writeFile(path.join(options.outDir, "_redirects"), buildCloudflareRedirects({
basePath: options.basePath,
pages: pageMetadata,
legacyRedirects: sourceRedirects,
}));
const rootMetadata = {
title: "Paperclip Docs",
description: defaultSeoDescription,
url: siteUrlForPath(options.siteUrl, options.basePath),
siteUrl: options.siteUrl,
basePath: options.basePath,
};
await fs.writeFile(
path.join(options.outDir, "index.html"),
inlineReleaseStyles(
injectSeo(buildRootPageHtml(sourceIndex, releaseNav, options.basePath, sectionIconPaths), rootMetadata),
releaseStyles,
),
);
// A markdown link that no nav page publishes would ship as a raw `.md` href,
// which 404s for every crawler. Collect them and fail the build rather than
// emitting a page full of dead links.
const routeMap = buildDocRouteMap(releaseNav, options.basePath);
const unresolvedLinks = [];
await writeStaticRoutePages({
outDir: options.outDir,
sourceIndex,
pages: pageMetadata,
markdownBodiesByFile,
basePath: options.basePath,
releaseStyles,
routeMap,
nav: releaseNav,
iconPaths: sectionIconPaths,
onUnresolvedLink: (entry) => unresolvedLinks.push(entry),
});
if (unresolvedLinks.length > 0) {
const detail = unresolvedLinks
.map(({ sourceFile, href, targetFile }) => ` ${sourceFile} -> ${href} (resolves to ${targetFile})`)
.join("\n");
throw new Error(
`${unresolvedLinks.length} markdown link(s) do not resolve to a published docs route.\n`
+ `Point each one at a page listed in site/content.json, or remove the link:\n${detail}`,
);
}
await fs.writeFile(path.join(options.outDir, "sitemap.xml"), buildSitemap({
siteUrl: options.siteUrl,
basePath: options.basePath,
pages: pageMetadata,
}));
await fs.writeFile(path.join(options.outDir, "robots.txt"), buildRobots({
siteUrl: options.siteUrl,
basePath: options.basePath,
}));
if (await pathExists(screenshotsSourceDir)) {
const screenshotTargetDir = path.join(options.outDir, "user-guides", "screenshots");
await copyDirRecursive(screenshotsSourceDir, screenshotTargetDir);
}
// Unlisted routes reuse the full docs shell, but never enter the discovery manifest.
const betaMarkdown = await fs.readFile(path.join(docsRoot, "hosted-beta.md"), "utf8");
const betaPage = { file: "hosted-beta.md", slug: "hosted-beta", title: "Hosted beta", navTrail: ["Hosted beta"] };
const betaMetadata = { ...rootMetadata, title: "Welcome to the Paperclip hosted beta", description: markdownDescription(betaMarkdown), url: siteUrlForPath(options.siteUrl, options.basePath, "hosted-beta/"), page: betaPage };
const betaHtml = buildStaticPageHtml(
withStaticChrome(sourceIndex, releaseNav, options.basePath, sectionIconPaths, betaMetadata),
betaMetadata, betaMarkdown, options.basePath, releaseStyles, null, null,
).replace("</head>", '<meta name="robots" content="noindex, follow" /></head>')
.replace('<article id="article">', '<article id="article" data-unlisted-page="hosted-beta">');
await ensureDir(path.join(options.outDir, "hosted-beta"));
await fs.writeFile(path.join(options.outDir, "hosted-beta/index.html"), betaHtml);
await fs.writeFile(path.join(options.outDir, "hosted-beta.md"), betaMarkdown);
const missingNavTargets = [];
for (const { page } of flattenNavPages(releaseNav)) {
const targetPath = path.join(options.outDir, page.file);
if (!(await pathExists(targetPath))) {
missingNavTargets.push(page.file);
}
}
if (missingNavTargets.length > 0) {
throw new Error(`Release build is incomplete. Missing nav targets: ${missingNavTargets.join(", ")}`);
}
console.log(`Release bundle written to ${path.relative(process.cwd(), options.outDir)}`);
console.log(`Base path: ${options.basePath}`);
console.log(`Site URL: ${options.siteUrl}`);
console.log(`Copied ${sortedMarkdownFiles.length} markdown files.`);
console.log(`Generated ${pageMetadata.length} crawlable route pages plus sitemap.xml and robots.txt.`);
if (await pathExists(screenshotsSourceDir)) {
console.log("Copied screenshot assets.");
}
if (options.basePath === "auto") {
console.warn("Warning: --base-path auto is less robust for deployed subdirectory hosting. Prefer an explicit path such as /random/paperclip-docs/.");
}
if (warnings.length > 0) {
console.warn(`Completed with ${warnings.length} warning(s):`);
for (const warning of warnings) {
console.warn(`- ${warning}`);
}
}
}
// Only run the build when this file is executed directly. Importing it as a
// module (e.g. from the sync test suite) must not trigger a build.
const invokedDirectly = (() => {
if (!process.argv[1]) return false;
try {
return fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
} catch {
return false;
}
})();
if (invokedDirectly) {
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
}