Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions docs/DEPLOY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
103 changes: 103 additions & 0 deletions scripts/check-export.mjs
Original file line number Diff line number Diff line change
@@ -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 <name> catalogue template to export (default: kept)
* --base <url> the running app (default: http://127.0.0.1:3000)
* --port <n> port to serve the export on (default: 8910)
* --performance <n> minimum performance score (default: 95)
* --accessibility <n> 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`);
69 changes: 68 additions & 1 deletion src/__tests__/exportPages.test.ts
Original file line number Diff line number Diff line change
@@ -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());
Expand Down Expand Up @@ -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("<title>Riverbank Books</title>");
expect(body.html).toContain('<meta property="og:title" content="Riverbank Books" />');
});

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("<title>Home</title>");
expect(body.pages[2].html).toContain("<title>Contact</title>");
});
});

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(`<title>${weft.name}</title>`);
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("<title>My studio</title>");
});

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");
});
});
42 changes: 36 additions & 6 deletions src/app/api/export-site/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
Loading