diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 36ffd25..1b44593 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -33,6 +33,21 @@ npm start | `NEXT_PUBLIC_SENTRY_DSN` | Error reporting is disabled | | `SENTRY_ORG` / `SENTRY_PROJECT` / `SENTRY_AUTH_TOKEN` | Source maps are not uploaded; stack traces stay minified | +## Measuring an export + +Before shipping a change that touches the exporter, a template or the engine, measure a +real export rather than trusting that it is still fine: + +```sh +npm run build && npx next start -p 3000 & +npm run check:export -- --slug weft --base http://127.0.0.1:3000 +``` + +It exports that template through the running app, serves the result as a plain folder the +way anybody would host it, runs Lighthouse against that, and exits non-zero under 95 +performance or 100 accessibility. Measured on 3 October 2026: kept, aurabeauty, weft and +orbitcrm each score 100 performance, 100 accessibility, 100 SEO and 96 best practices. + ## Checking a deploy `GET /api/health` returns `200` with `{"status":"ok"}`. It reports configuration only — diff --git a/package.json b/package.json index 9a9511a..98f86c0 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "test:coverage": "vitest run --coverage", "build:examples": "node scripts/build-examples.mjs", "prebuild": "node scripts/build-examples.mjs", - "examples:frames": "node scripts/build-examples.mjs --frames" + "examples:frames": "node scripts/build-examples.mjs --frames", + "check:export": "node scripts/check-export.mjs" }, "dependencies": { "@base-ui/react": "^1.5.0", diff --git a/scripts/check-export.mjs b/scripts/check-export.mjs new file mode 100644 index 0000000..698eddf --- /dev/null +++ b/scripts/check-export.mjs @@ -0,0 +1,103 @@ +#!/usr/bin/env node +/** + * Measure a real export, and fail if it is worse than what we already ship. + * + * Exports a catalogue template through the running app, serves the result as a plain + * folder the way anybody would host it, and runs Lighthouse against that. The numbers in + * the README and in the deck come from here rather than from somebody's memory. + * + * npm run build && npx next start -p 3000 & + * node scripts/check-export.mjs --slug weft --base http://127.0.0.1:3000 + * + * Options: + * --slug catalogue template to export (default: kept) + * --base the running app (default: http://127.0.0.1:3000) + * --port port to serve the export on (default: 8910) + * --performance minimum performance score (default: 95) + * --accessibility minimum accessibility score (default: 100) + * --keep leave the exported folder on disk + */ +import { createServer } from "node:http"; +import { mkdtempSync, writeFileSync, readFileSync, rmSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, extname } from "node:path"; +import { spawn } from "node:child_process"; + +const args = process.argv.slice(2); +const opt = (name, fallback) => { + const i = args.indexOf(`--${name}`); + return i === -1 ? fallback : args[i + 1]; +}; + +const slug = opt("slug", "kept"); +const base = opt("base", "http://127.0.0.1:3000"); +const port = Number(opt("port", 8910)); +const minPerformance = Number(opt("performance", 95)); +const minAccessibility = Number(opt("accessibility", 100)); +const keep = args.includes("--keep"); + +const TYPES = { ".html": "text/html", ".css": "text/css", ".js": "text/javascript", ".json": "application/json", ".jpg": "image/jpeg", ".png": "image/png", ".svg": "image/svg+xml", ".txt": "text/plain" }; + +function fail(message) { + console.error(`\n ${message}\n`); + process.exit(1); +} + +const res = await fetch(`${base}/api/export-site`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ template: slug, fps: 24 }), +}).catch((err) => fail(`Could not reach ${base}: ${err.message}. Is the app running?`)); + +if (!res.ok) fail(`Export failed with ${res.status}: ${(await res.text()).slice(0, 200)}`); +const { pages } = await res.json(); +if (!Array.isArray(pages) || pages.length === 0) fail("The export returned no pages."); + +const dir = mkdtempSync(join(tmpdir(), `scrollcraft-${slug}-`)); +for (const page of pages) writeFileSync(join(dir, page.path), page.html); +console.log(` exported ${slug}: ${pages.map((p) => p.path).join(", ")} (${Math.round(pages.reduce((n, p) => n + p.html.length, 0) / 1024)} KB)`); + +// Served as a folder, because that is how an export is hosted and how its performance +// should be judged. No framework, no server rendering, no edge cache. +const server = createServer((req, response) => { + const path = join(dir, decodeURIComponent((req.url || "/").split("?")[0]).replace(/^\/+/, "") || "index.html"); + if (!existsSync(path) || !path.startsWith(dir)) { + response.writeHead(404).end("not found"); + return; + } + response.writeHead(200, { "content-type": TYPES[extname(path)] ?? "application/octet-stream" }); + response.end(readFileSync(path)); +}); +await new Promise((resolve) => server.listen(port, resolve)); + +const report = join(dir, "lighthouse.json"); +const chrome = process.env.CHROME_PATH || "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; +// Spawned asynchronously on purpose: spawnSync blocks this process's event loop, so the +// server above cannot answer a single request while Lighthouse runs, and Chrome dies with +// "Target closed" against a server that is listening and deaf. +const stderr = []; +const code = await new Promise((resolve) => { + const child = spawn( + "npx", + ["-y", "lighthouse@12", `http://127.0.0.1:${port}/`, "--quiet", "--preset=desktop", "--output=json", `--output-path=${report}`, "--chrome-flags=--headless=new --no-sandbox"], + { env: { ...process.env, CHROME_PATH: chrome } } + ); + child.stderr.on("data", (chunk) => stderr.push(String(chunk))); + child.on("close", resolve); +}); +server.close(); + +if (!existsSync(report)) fail(`Lighthouse exited ${code} without a report.\n${stderr.join("").slice(-500)}`); +const scores = Object.fromEntries( + Object.values(JSON.parse(readFileSync(report, "utf8")).categories).map((c) => [c.title, Math.round(c.score * 100)]) +); +if (!keep) rmSync(dir, { recursive: true, force: true }); + +for (const [title, score] of Object.entries(scores)) console.log(` ${title.padEnd(16)} ${score}`); + +const problems = []; +if (scores.Performance < minPerformance) problems.push(`performance ${scores.Performance}, under ${minPerformance}`); +if (scores.Accessibility < minAccessibility) problems.push(`accessibility ${scores.Accessibility}, under ${minAccessibility}`); +if (problems.length) fail(`${slug} is worse than what we ship: ${problems.join("; ")}`); + +console.log(`\n ${slug} clears the bar.\n`); diff --git a/src/__tests__/exportPages.test.ts b/src/__tests__/exportPages.test.ts index 3829aed..2068867 100644 --- a/src/__tests__/exportPages.test.ts +++ b/src/__tests__/exportPages.test.ts @@ -1,7 +1,8 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; import type { NextRequest } from "next/server"; -import { templateBySlug } from "@/lib/templates"; + import { HOME_SLUG } from "@/lib/sitePages"; +import { templateBySlug } from "@/lib/templates"; import { readFileSync } from "node:fs"; const rateLimitMock = vi.hoisted(() => vi.fn()); @@ -186,3 +187,69 @@ describe("a button that points at another page actually goes there", () => { } }); }); + +describe("the browser tab says what the site is called", () => { + it("titles a one page export with the site name, not with the word Home", async () => { + // Shipped broken in #426: every single page export since then said "Home" in the tab + // and in its share card, because the page list calls its first entry that. + const { body } = await exportSite({ sections: kept.sections, siteName: "Riverbank Books" }); + expect(body.html).toContain("Riverbank Books"); + expect(body.html).toContain(''); + }); + + it("still titles each page of a real site with its own name", async () => { + const { body } = await exportSite({ pages: threePages, siteName: "Northgate" }); + expect(body.pages[0].html).toContain("Home"); + expect(body.pages[2].html).toContain("Contact"); + }); +}); + +describe("a catalogue template can be exported by name", () => { + it("fills in the sections, theme, style, name and description from the catalogue", async () => { + const { status, body } = await exportSite({ template: "weft", siteName: undefined, sections: undefined }); + expect(status).toBe(200); + const weft = templateBySlug("weft")!; + expect(body.html).toContain(weft.sections[0].heading!); + expect(body.html).toContain(`${weft.name}`); + expect(body.html).toContain(weft.theme.fontDisplay!.replace(" ", "+")); + expect(body.siteName).toBe(weft.name); + }); + + it("lets what is sent win over what the catalogue holds", async () => { + const { body } = await exportSite({ template: "weft", siteName: "My studio" }); + expect(body.html).toContain("My studio"); + }); + + it("names a template it does not have, rather than exporting something else", async () => { + const { status, body } = await exportSite({ template: "not-a-template" }); + expect(status).toBe(400); + expect(body.error).toMatch(/no template called not-a-template/); + }); +}); + +describe("the gate that measures an export", () => { + const SCRIPT = readFileSync("scripts/check-export.mjs", "utf8"); + + it("fails under the scores we already ship", () => { + expect(SCRIPT).toContain('opt("performance", 95)'); + expect(SCRIPT).toContain('opt("accessibility", 100)'); + expect(SCRIPT).toMatch(/process\.exit\(1\)/); + }); + + it("serves the export as a folder, which is how it is hosted", () => { + expect(SCRIPT).toContain("createServer"); + expect(SCRIPT).toContain("index.html"); + }); + + it("does not block its own server while Lighthouse runs", () => { + // spawnSync blocks the event loop, so the server answers nothing and Chrome dies + // with "Target closed" against a server that is listening and deaf. + expect(SCRIPT, "spawnSync would deafen the server").not.toMatch(/spawnSync\s*\(/); + expect(SCRIPT).toContain('import { spawn }'); + }); + + it("is a command rather than a thing to remember", () => { + expect(readFileSync("package.json", "utf8")).toContain('"check:export"'); + expect(readFileSync("docs/DEPLOY.md", "utf8")).toContain("npm run check:export"); + }); +}); diff --git a/src/app/api/export-site/route.ts b/src/app/api/export-site/route.ts index 7689d2c..fa94f5d 100644 --- a/src/app/api/export-site/route.ts +++ b/src/app/api/export-site/route.ts @@ -3,6 +3,7 @@ import { logger } from "@/lib/logger"; import { rateLimit, getClientIp } from "@/lib/rateLimit"; import { REVEALS, exportSectionsSchema, sectionAnchor, type Section, visibleSections as onlyVisible } from "@/lib/siteSchema"; import { isAllowedHref } from "@/lib/siteSchema"; +import { templateBySlug } from "@/lib/templates"; import { HOME_SLUG, pageFileName, navLinks, asPages, pagesSchema, type SitePage } from "@/lib/sitePages"; import { layoutStyle } from "@/lib/layoutStyles"; import { DISPLAY_STYLES, displayStyle } from "@/lib/displayStyles"; @@ -136,16 +137,42 @@ export async function POST(req: NextRequest) { } = body; const { pages, - sections, - siteName, - siteDescription = "", - styleJson = "", + sections: sectionsFromBody, + siteName: siteNameFromBody, + siteDescription: siteDescriptionFromBody = "", + styleJson: styleJsonFromBody = "", customHead = "", customCss = "", - themeJson = "", + themeJson: themeJsonFromBody = "", fps = 24, } = body; + /** + * A catalogue template by name, so the whole of it can be exported in one call. + * + * The editor sends what is on screen and always will. This is for everything else: + * the plugin, and the check that measures a real export before a change ships. + */ + const named = typeof body.template === "string" ? templateBySlug(body.template) : undefined; + if (typeof body.template === "string" && !named) { + return NextResponse.json({ error: `template: no template called ${String(body.template).slice(0, 40)}` }, { status: 400 }); + } + const { sections, siteName, siteDescription, styleJson, themeJson } = named + ? { + sections: sectionsFromBody ?? named.sections, + siteName: siteNameFromBody || named.name, + siteDescription: siteDescriptionFromBody || named.tagline, + styleJson: styleJsonFromBody || JSON.stringify({ style: named.style, colors: named.colors }), + themeJson: themeJsonFromBody || JSON.stringify(named.theme), + } + : { + sections: sectionsFromBody, + siteName: siteNameFromBody, + siteDescription: siteDescriptionFromBody, + styleJson: styleJsonFromBody, + themeJson: themeJsonFromBody, + }; + // One page arrives as `sections`, a site as `pages`. Exactly one of them is required. const bodyContent = Array.isArray(pages) ? pages : sections; @@ -183,7 +210,10 @@ export async function POST(req: NextRequest) { */ let sitePages: SitePage[]; if (pages === undefined || pages === null) { - sitePages = asPages({ sections: validSections }); + // A site of one page is titled by the site's own name. asPages calls it "Home", + // which is right in a nav beside About and Contact, and wrong in the browser tab + // and the share card of a site that has no other page. + sitePages = asPages({ sections: validSections }).map((page) => ({ ...page, title: siteName || page.title })); } else { const parsedPages = pagesSchema.safeParse(pages); if (!parsedPages.success) {