diff --git a/packages/landing/index.html b/packages/landing/index.html index 5da396c..98114e3 100644 --- a/packages/landing/index.html +++ b/packages/landing/index.html @@ -7,15 +7,30 @@ + + + + - + + + + + + PenguinHarness — Efficient Self-Improving Harness for Everyone + + + + + + +
+
+
+
+ +
+

PenguinHarness

+

Efficient Self-Improving Harness for Everyone

+
+
+
+ Zero-code CLI & Web UI1,000+ modelsApache-2.0 +
+
penguin.ooo
+
+
+
+ + diff --git a/packages/landing/scripts/postbuild.mjs b/packages/landing/scripts/postbuild.mjs index 2ce864a..1863ac1 100644 --- a/packages/landing/scripts/postbuild.mjs +++ b/packages/landing/scripts/postbuild.mjs @@ -1,13 +1,57 @@ /** - * Post-build step for GitHub Pages: copy index.html to 404.html so deep links - * (/blog/xxx) served by Pages' 404 fallback still boot the SPA router, and add - * .nojekyll so Pages serves the dist verbatim without Jekyll processing. + * Post-build step for GitHub Pages, which serves static files only: + * + * - A shell per blog route (dist/blog/index.html, dist/blog//index.html), the + * same trick the docs package uses. Without them Pages answers every blog URL from + * 404.html: the SPA still boots and the page looks right, but the response carries + * HTTP 404, so crawlers drop it and the posts never get indexed. Each shell also + * carries its own canonical/og:url, which is what a crawler that does not run JS + * sees — the SPA cannot rewrite them in time to matter. + * - dist/404.html for paths that really do not exist (and for /docs/* misses, which the + * landing router hands to the docs index). Its canonical is stripped: claiming the + * home page as the canonical of an unknown URL would ask search engines to fold + * every typo into it. + * - .nojekyll so Pages serves the dist verbatim without Jekyll processing. */ -import { copyFileSync, writeFileSync } from "node:fs"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { absoluteUrl, blogRoutes } from "./site-routes.mjs"; const dist = join(dirname(fileURLToPath(import.meta.url)), "..", "dist"); -copyFileSync(join(dist, "index.html"), join(dist, "404.html")); +const shell = readFileSync(join(dist, "index.html"), "utf8"); + +// Loose enough to survive Vite's HTML rewriting, strict enough to hit only these two +// tags. Both must exist in index.html — if a future edit drops them, fail the build +// rather than silently shipping every route with the home page's canonical. +const CANONICAL = /]*>/; +const OG_URL = /]*>/; +for (const [name, pattern] of [ + ["canonical", CANONICAL], + ["og:url", OG_URL], +]) { + if (!pattern.test(shell)) { + throw new Error(`[postbuild] no ${name} tag in dist/index.html — index.html must declare one`); + } +} + +/** The shell with its self-referencing URLs pointed at `route`. */ +function shellFor(route) { + const url = absoluteUrl(route); + return shell + .replace(CANONICAL, ``) + .replace(OG_URL, ``); +} + +const routes = blogRoutes(); +for (const { route } of routes) { + const dir = join(dist, route); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "index.html"), shellFor(route)); +} + +writeFileSync(join(dist, "404.html"), shell.replace(CANONICAL, "")); writeFileSync(join(dist, ".nojekyll"), ""); -console.log("[postbuild] wrote dist/404.html and dist/.nojekyll"); +console.log( + `[postbuild] wrote ${routes.length} blog route shells, dist/404.html and dist/.nojekyll`, +); diff --git a/packages/landing/scripts/site-routes.mjs b/packages/landing/scripts/site-routes.mjs new file mode 100644 index 0000000..05f790b --- /dev/null +++ b/packages/landing/scripts/site-routes.mjs @@ -0,0 +1,75 @@ +/** + * The deployed site's public routes, derived from the same Markdown filenames the two + * routers read (landing: content/blog/..md, docs: content/..md). + * + * Two consumers share this list, and they have to agree: the landing postbuild writes + * one static shell per route (so GitHub Pages answers 200 instead of falling back to + * 404.html), and build-site.mjs writes sitemap.xml for the assembled tree. A route that + * gets a shell belongs in the sitemap, and a sitemap URL that has no shell is a 404. + */ +import { readdirSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +/** Production origin, no trailing slash. Override for a staging deploy. */ +export const SITE_ORIGIN = (process.env.SITE_ORIGIN ?? "https://penguin.ooo").replace(/\/+$/, ""); + +/** Deploy subpath, no trailing slash ("" at the domain root, "/repo" under a Pages subpath). */ +export const BASE_PATH = (process.env.BASE_PATH ?? "/").replace(/\/+$/, ""); + +/** Absolute URL for a site-root-relative route path. */ +export function absoluteUrl(route) { + return `${SITE_ORIGIN}${BASE_PATH}${route}`; +} + +const HERE = dirname(fileURLToPath(import.meta.url)); +const BLOG_DIR = join(HERE, "..", "content", "blog"); +const DOCS_DIR = join(HERE, "..", "..", "docs", "content"); + +/** `..md` -> slug, deduplicated across the two languages, sorted. */ +function slugsIn(dir) { + const slugs = new Set(); + for (const file of readdirSync(dir)) { + const slug = /^(.+)\.(zh|en)\.md$/.exec(file)?.[1]; + if (slug !== undefined) slugs.add(slug); + } + return [...slugs].sort(); +} + +/** Newest `date:` in a post's frontmatter across its language variants, if any. */ +function newestDate(dir, slug) { + let newest; + for (const lang of ["en", "zh"]) { + let raw; + try { + raw = readFileSync(join(dir, `${slug}.${lang}.md`), "utf8"); + } catch { + continue; // A post exists in one language only; the other is a legitimate miss. + } + const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---/.exec(raw)?.[1] ?? ""; + const date = /^date:\s*"?(\d{4}-\d{2}-\d{2})"?\s*$/m.exec(frontmatter)?.[1]; + if (date !== undefined && (newest === undefined || date > newest)) newest = date; + } + return newest; +} + +/** + * Routes served by the landing SPA. Trailing slashes are deliberate: Pages serves + * `/index.html` and 301s the slashless form to it, so the slash form is the URL + * that actually answers 200 — the one canonical tags and the sitemap should name. + * The home page is excluded; Vite's own index.html already sits at the dist root. + */ +export function blogRoutes() { + return [ + { route: "/blog/" }, + ...slugsIn(BLOG_DIR).map((slug) => ({ + route: `/blog/${slug}/`, + lastmod: newestDate(BLOG_DIR, slug), + })), + ]; +} + +/** Routes served by the docs SPA, whose own postbuild already writes their shells. */ +export function docsRoutes() { + return [{ route: "/docs/" }, ...slugsIn(DOCS_DIR).map((slug) => ({ route: `/docs/${slug}/` }))]; +} diff --git a/scripts/build-site.mjs b/scripts/build-site.mjs index 64c5dd4..4165731 100644 --- a/scripts/build-site.mjs +++ b/scripts/build-site.mjs @@ -11,9 +11,10 @@ * single artifact — one GitHub Pages deployment hosts both sites. */ import { execSync } from "node:child_process"; -import { cpSync, rmSync } from "node:fs"; +import { cpSync, rmSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { absoluteUrl, blogRoutes, docsRoutes } from "../packages/landing/scripts/site-routes.mjs"; const root = join(dirname(fileURLToPath(import.meta.url)), ".."); const base = process.env.BASE_PATH ?? "/"; @@ -34,4 +35,27 @@ const landingDist = join(root, "packages", "landing", "dist"); const docsTarget = join(landingDist, "docs"); rmSync(docsTarget, { recursive: true, force: true }); cpSync(join(root, "packages", "docs", "dist"), docsTarget, { recursive: true }); + +// sitemap.xml goes in last, because only here do both dists exist: it is the one file +// that has to name routes from both sites. It is also the only way a crawler learns +// those routes — both pages ship an empty
and build their navigation +// in JS, so following links is not an option. +const routes = [{ route: "/" }, ...blogRoutes(), ...docsRoutes()]; +const sitemap = [ + '', + '', + ...routes.map(({ route, lastmod }) => + [ + " ", + ` ${absoluteUrl(route)}`, + ...(lastmod === undefined ? [] : [` ${lastmod}`]), + " ", + ].join("\n"), + ), + "", + "", +].join("\n"); +writeFileSync(join(landingDist, "sitemap.xml"), sitemap); + console.log(`\n[build-site] assembled site at ${landingDist} (docs under /docs/)`); +console.log(`[build-site] wrote sitemap.xml (${routes.length} URLs)`);