@gusnips/vite 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +123 -0
  3. package/dist/escape.d.ts +6 -0
  4. package/dist/escape.d.ts.map +1 -0
  5. package/dist/escape.js +6 -0
  6. package/dist/escape.js.map +1 -0
  7. package/dist/head.d.ts +110 -0
  8. package/dist/head.d.ts.map +1 -0
  9. package/dist/head.js +183 -0
  10. package/dist/head.js.map +1 -0
  11. package/dist/index.d.ts +23 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +22 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/node.d.ts +57 -0
  16. package/dist/node.d.ts.map +1 -0
  17. package/dist/node.js +104 -0
  18. package/dist/node.js.map +1 -0
  19. package/dist/og.d.ts +70 -0
  20. package/dist/og.d.ts.map +1 -0
  21. package/dist/og.js +60 -0
  22. package/dist/og.js.map +1 -0
  23. package/dist/preset.d.ts +37 -0
  24. package/dist/preset.d.ts.map +1 -0
  25. package/dist/preset.js +43 -0
  26. package/dist/preset.js.map +1 -0
  27. package/dist/render.d.ts +36 -0
  28. package/dist/render.d.ts.map +1 -0
  29. package/dist/render.js +50 -0
  30. package/dist/render.js.map +1 -0
  31. package/dist/sitemap.d.ts +82 -0
  32. package/dist/sitemap.d.ts.map +1 -0
  33. package/dist/sitemap.js +91 -0
  34. package/dist/sitemap.js.map +1 -0
  35. package/package.json +105 -0
  36. package/src/escape.ts +8 -0
  37. package/src/head.test.ts +258 -0
  38. package/src/head.ts +288 -0
  39. package/src/index.ts +59 -0
  40. package/src/node.test.ts +102 -0
  41. package/src/node.ts +140 -0
  42. package/src/og.test.ts +53 -0
  43. package/src/og.ts +103 -0
  44. package/src/preset.ts +78 -0
  45. package/src/render.test.ts +67 -0
  46. package/src/render.ts +75 -0
  47. package/src/sitemap.test.ts +124 -0
  48. package/src/sitemap.ts +159 -0
package/src/index.ts ADDED
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The build-time half of a prerendered Vite + React SPA.
3
+ *
4
+ * A Vite SPA ships one `index.html` and draws the rest with JavaScript. Nothing that reads a
5
+ * link for a living runs that bundle — not a search crawler, not an LLM, not the thing that
6
+ * draws the preview card in a chat app. So the build renders every public route to a real file
7
+ * with a real `<head>` and a real body, and this is the part every app doing that shares.
8
+ *
9
+ * Four repos wrote it independently and each learned something the others had not. What is
10
+ * here is the merge; every non-obvious rule carries the reason it exists.
11
+ *
12
+ * Two things live behind their own subpath, because each drags a dependency this barrel would
13
+ * otherwise force on everyone: the vite config preset at `@gusnips/vite/preset` (the React and
14
+ * Tailwind plugins) and `renderTree` at `@gusnips/vite/render` (React itself). Nothing here
15
+ * imports React or vite, so a prerender script, an OG generator and a repo that only wants a
16
+ * sitemap all install exactly what they use.
17
+ */
18
+ export {
19
+ assertRendered,
20
+ bakeHead,
21
+ EMPTY_ROOT,
22
+ ogLocale,
23
+ type Alternate,
24
+ type HeadTags,
25
+ type RenderedChecks,
26
+ } from "./head.ts";
27
+ export {
28
+ loadRenderer,
29
+ loadTemplate,
30
+ writeDist,
31
+ writeOgCards,
32
+ type OgCard,
33
+ type WriteOgCardsOptions,
34
+ } from "./node.ts";
35
+ export {
36
+ describeOverflow,
37
+ fitText,
38
+ OG_CANVAS,
39
+ type FitOptions,
40
+ type FitResult,
41
+ type OgOverflow,
42
+ } from "./og.ts";
43
+ // `renderTree` itself is NOT here — it lives at `@gusnips/vite/render`, because it is the one
44
+ // thing in this package that loads React. Its consumer is `entry-server.tsx`, a different file
45
+ // in a different bundle to the prerender script, and a repo using this only for `sitemapXml`
46
+ // and `robotsTxt` should not have to install a renderer. The TYPE is free: it erases.
47
+ export type { PageRenderer } from "./render.ts";
48
+ export {
49
+ ogImagePath,
50
+ pageFile,
51
+ pageSlug,
52
+ robotsTxt,
53
+ siteOrigin,
54
+ sitemapFor,
55
+ sitemapXml,
56
+ type PublicPage,
57
+ type RobotsOptions,
58
+ type SitemapEntry,
59
+ } from "./sitemap.ts";
@@ -0,0 +1,102 @@
1
+ import { mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+ import { loadTemplate, writeOgCards } from "./node.ts";
6
+
7
+ const dirs: string[] = [];
8
+
9
+ async function tempDist(indexHtml: string): Promise<string> {
10
+ const dir = await mkdtemp(join(tmpdir(), "frontkit-vite-"));
11
+ dirs.push(dir);
12
+ await writeFile(join(dir, "index.html"), indexHtml, "utf8");
13
+ return dir;
14
+ }
15
+
16
+ afterEach(async () => {
17
+ await Promise.all(dirs.map((dir) => rm(dir, { recursive: true, force: true })));
18
+ dirs.length = 0;
19
+ });
20
+
21
+ describe("loadTemplate", () => {
22
+ it("reads the built shell", async () => {
23
+ const dist = await tempDist('<html><body><div id="root"></div></body></html>');
24
+ await expect(loadTemplate(dist)).resolves.toContain('<div id="root"></div>');
25
+ });
26
+
27
+ /**
28
+ * `dist/index.html` is both the template and the front page's destination, so a second run
29
+ * over a `dist/` the prerender already touched would read a finished page as its blank shell
30
+ * and nest one render inside another. `vite build` empties `dist/` and normally makes this
31
+ * impossible; a restored build cache does not.
32
+ */
33
+ it("refuses a dist that is already a rendered page", async () => {
34
+ const dist = await tempDist(
35
+ '<html><body><div id="root" data-prerendered-route="/"><main>hi</main></div></body></html>',
36
+ );
37
+ await expect(loadTemplate(dist)).rejects.toThrow(/already a rendered page/);
38
+ });
39
+ });
40
+
41
+ describe("writeOgCards", () => {
42
+ const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
43
+
44
+ it("writes one card per page", async () => {
45
+ const out = join(await tempDist("<html></html>"), "og");
46
+ await writeOgCards({
47
+ pages: ["/", "/pricing"],
48
+ outDir: out,
49
+ file: (path) => `${path === "/" ? "home" : path.slice(1)}.png`,
50
+ card: () => ({ png }),
51
+ });
52
+ expect((await readdir(out)).sort()).toEqual(["home.png", "pricing.png"]);
53
+ });
54
+
55
+ /**
56
+ * A card that cannot hold its copy is a product decision, not something to resolve with an
57
+ * ellipsis — and nothing is written, so a half-updated `public/og` never gets committed.
58
+ */
59
+ it("refuses the whole run when one card cannot hold its copy, and writes nothing", async () => {
60
+ const out = join(await tempDist("<html></html>"), "og");
61
+ const run = writeOgCards({
62
+ pages: ["/", "/pricing"],
63
+ outDir: out,
64
+ file: (path) => `${path === "/" ? "home" : path.slice(1)}.png`,
65
+ card: (path) => ({
66
+ png,
67
+ overflow:
68
+ path === "/pricing"
69
+ ? [
70
+ {
71
+ label: "pricing headline",
72
+ text: "far too long",
73
+ maxLines: 2,
74
+ maxChars: 20,
75
+ size: 60,
76
+ },
77
+ ]
78
+ : undefined,
79
+ }),
80
+ });
81
+ await expect(run).rejects.toThrow(/pricing headline/);
82
+ await expect(readdir(out)).rejects.toThrow();
83
+ });
84
+
85
+ // Copy lands per locale in batches; a build that dies on the first of six sends its operator
86
+ // round the loop six times.
87
+ it("names every bad card in one run", async () => {
88
+ const out = join(await tempDist("<html></html>"), "og");
89
+ const run = writeOgCards({
90
+ pages: ["/a", "/b"],
91
+ outDir: out,
92
+ file: (path) => `${path.slice(1)}.png`,
93
+ card: (path) => ({
94
+ png,
95
+ overflow: [
96
+ { label: `${path} headline`, text: "far too long", maxLines: 2, maxChars: 20, size: 60 },
97
+ ],
98
+ }),
99
+ });
100
+ await expect(run).rejects.toThrow(/2 card\(s\)/);
101
+ });
102
+ });
package/src/node.ts ADDED
@@ -0,0 +1,140 @@
1
+ /**
2
+ * The whole filesystem surface of this package: read the template, load the SSR bundle, write
3
+ * files.
4
+ *
5
+ * It is one file on purpose. Everything else here is string work that a test can run without a
6
+ * disk, and keeping the four impure functions together is what lets `head.ts`, `sitemap.ts`,
7
+ * `render.ts` and `og.ts` stay that way.
8
+ *
9
+ * The prerender LOOP is not here. Every donor's loop is different — one walks a registry once,
10
+ * another walks it per locale, a third writes three separate shells — and all of that is
11
+ * policy the product owns. What is identical in every one of them is these four functions and
12
+ * the gate in `assertRendered`, so those ship and the ~40-line loop stays in the app.
13
+ */
14
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
15
+ import { dirname, join } from "node:path";
16
+ import { pathToFileURL } from "node:url";
17
+ import { EMPTY_ROOT } from "./head.ts";
18
+ import type { OgOverflow } from "./og.ts";
19
+ import { describeOverflow } from "./og.ts";
20
+ import type { PageRenderer } from "./render.ts";
21
+
22
+ /**
23
+ * The built `index.html`, and a refusal to bake one twice.
24
+ *
25
+ * `dist/index.html` is BOTH the template and the front page's destination, so a second run over
26
+ * a `dist/` the prerender has already touched would read a finished page as its blank shell and
27
+ * nest one render inside another. `vite build` empties `dist/` and normally makes this
28
+ * impossible; what does not is a restored build cache, or somebody running the script directly
29
+ * to debug it. Caught here, by name, rather than as a missing-root error three frames down.
30
+ */
31
+ export async function loadTemplate(distDir: string): Promise<string> {
32
+ const file = join(distDir, "index.html");
33
+ const template = await readFile(file, "utf8");
34
+ if (!template.includes(EMPTY_ROOT))
35
+ throw new Error(
36
+ `prerender: ${file} does not carry ${EMPTY_ROOT}. Either it is already a rendered page ` +
37
+ "— run `vite build` to regenerate the shell this reads — or the app's root element " +
38
+ "carries attributes, which the baker cannot fill.",
39
+ );
40
+ return template;
41
+ }
42
+
43
+ function exportsRenderer<Context>(mod: unknown): mod is { renderPage: PageRenderer<Context> } {
44
+ return (
45
+ typeof mod === "object" &&
46
+ mod !== null &&
47
+ "renderPage" in mod &&
48
+ typeof mod.renderPage === "function"
49
+ );
50
+ }
51
+
52
+ /**
53
+ * The app compiled for the server, imported out of the BUILT bundle.
54
+ *
55
+ * The prerender is a script rather than a Vite plugin for exactly this reason: it needs the SSR
56
+ * bundle, and a plugin running in `closeBundle` is inside the build that would have to have
57
+ * produced it. So the entry is a path on disk, written by `vite build --ssr src/entry-server.tsx`,
58
+ * and nothing in the source tree references it.
59
+ */
60
+ export async function loadRenderer<Context = unknown>(
61
+ entryFile: string,
62
+ ): Promise<PageRenderer<Context>> {
63
+ const mod: unknown = await import(pathToFileURL(entryFile).href);
64
+ if (!exportsRenderer<Context>(mod))
65
+ throw new Error(
66
+ `prerender: ${entryFile} does not export renderPage — run \`vite build --ssr\` first`,
67
+ );
68
+ return mod.renderPage;
69
+ }
70
+
71
+ /** Write one file under `dist`, creating the folders a nested route needs. */
72
+ export async function writeDist(distDir: string, file: string, contents: string): Promise<void> {
73
+ const target = join(distDir, file);
74
+ await mkdir(dirname(target), { recursive: true });
75
+ await writeFile(target, contents, "utf8");
76
+ }
77
+
78
+ /** One laid-out share card: the bytes, and anything that did not fit. */
79
+ export interface OgCard {
80
+ png: Uint8Array;
81
+ /** Copy the layout could not hold. A single one refuses the whole run — see
82
+ * {@link writeOgCards}. */
83
+ overflow?: readonly OgOverflow[];
84
+ }
85
+
86
+ export interface WriteOgCardsOptions<Page> {
87
+ pages: readonly Page[];
88
+ /** Where the cards land — `public/og` in both donors, so they are committed beside the
89
+ * favicons and served as static files. */
90
+ outDir: string;
91
+ /** The file name for one page, inside `outDir`. `(page) => \`${pageSlug(page.path)}.png\``. */
92
+ file: (page: Page) => string;
93
+ /** Lay one card out and rasterize it. The card's art is the product's; this only drives it. */
94
+ card: (page: Page) => OgCard | Promise<OgCard>;
95
+ /** Lay every card out and report, without writing anything. */
96
+ check?: boolean;
97
+ }
98
+
99
+ /**
100
+ * Walk a page registry and write one share card per page.
101
+ *
102
+ * Every card is laid out BEFORE any is written, and a single line that does not fit refuses the
103
+ * whole run. That order is the point: a card that cannot hold its copy is a product decision,
104
+ * not something to resolve with an ellipsis — an ellipsis makes every string "fit", so
105
+ * overgrown copy has no failing case and ships a card missing the end of the one line the card
106
+ * exists to carry. The reader who finds out is someone else's link unfurl.
107
+ *
108
+ * Overflow is collected rather than thrown on the first card, so one run names every bad one:
109
+ * copy lands per locale in batches, and a build that dies on the first of six sends its
110
+ * operator round the loop six times.
111
+ */
112
+ export async function writeOgCards<Page>({
113
+ pages,
114
+ outDir,
115
+ file,
116
+ card,
117
+ check = false,
118
+ }: WriteOgCardsOptions<Page>): Promise<string[]> {
119
+ const laid: { file: string; png: Uint8Array }[] = [];
120
+ const overflows: OgOverflow[] = [];
121
+
122
+ for (const page of pages) {
123
+ const { png, overflow } = await card(page);
124
+ if (overflow?.length) overflows.push(...overflow);
125
+ laid.push({ file: file(page), png });
126
+ }
127
+
128
+ if (overflows.length > 0)
129
+ throw new Error(
130
+ `og: ${String(overflows.length)} card(s) cannot hold their copy —\n\n` +
131
+ overflows.map((o) => ` · ${describeOverflow(o)}`).join("\n\n") +
132
+ "\n\n Shorten the copy, or change the size ladder deliberately. Nothing was written.",
133
+ );
134
+
135
+ if (check) return laid.map((entry) => entry.file);
136
+
137
+ await mkdir(outDir, { recursive: true });
138
+ for (const entry of laid) await writeFile(join(outDir, entry.file), entry.png);
139
+ return laid.map((entry) => entry.file);
140
+ }
package/src/og.test.ts ADDED
@@ -0,0 +1,53 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { fitText } from "./og.ts";
3
+
4
+ const COLUMN = { width: 600, size: 60, advance: 0.5, label: "pricing headline" };
5
+
6
+ describe("fitText", () => {
7
+ it("wraps at the column, not mid-word", () => {
8
+ const { lines, overflow } = fitText("Everything your team ships, in one place", {
9
+ ...COLUMN,
10
+ maxLines: 3,
11
+ });
12
+ expect(overflow).toBeUndefined();
13
+ expect(lines.join(" ")).toBe("Everything your team ships, in one place");
14
+ expect(lines.length).toBeGreaterThan(1);
15
+ });
16
+
17
+ it("leaves a line that already fits alone", () => {
18
+ const { lines, overflow } = fitText("Short line", { ...COLUMN, maxLines: 2 });
19
+ expect(lines).toEqual(["Short line"]);
20
+ expect(overflow).toBeUndefined();
21
+ });
22
+
23
+ // The behaviour this file exists for. Appending `…` made wrapping a total function: every
24
+ // string "fitted", so copy that outgrew the column had no failing case and shipped a card
25
+ // missing the end of the one line the card exists to carry.
26
+ it("reports copy that does not fit instead of quietly cutting it", () => {
27
+ const tooLong = "A headline written for a search result rather than for a card, "
28
+ .repeat(3)
29
+ .trim();
30
+ const { overflow } = fitText(tooLong, { ...COLUMN, maxLines: 2 });
31
+ expect(overflow).toBeDefined();
32
+ expect(overflow?.label).toBe("pricing headline");
33
+ expect(overflow?.text).toBe(tooLong);
34
+ expect(overflow?.maxLines).toBe(2);
35
+ });
36
+
37
+ // Still returned, so a failed run can show what the card WOULD have said.
38
+ it("still returns the lines it managed, so a report can show them", () => {
39
+ const { lines } = fitText("word ".repeat(40).trim(), { ...COLUMN, maxLines: 2 });
40
+ expect(lines).toHaveLength(2);
41
+ expect(lines[1]?.endsWith("…")).toBe(true);
42
+ });
43
+
44
+ it("collapses the newlines a catalog string may carry", () => {
45
+ const { lines } = fitText("one\n two", { ...COLUMN, maxLines: 3 });
46
+ expect(lines).toEqual(["one two"]);
47
+ });
48
+
49
+ it("keeps a word longer than the column rather than dropping it", () => {
50
+ const { lines } = fitText("supercalifragilisticexpialidocious", { ...COLUMN, maxLines: 1 });
51
+ expect(lines).toEqual(["supercalifragilisticexpialidocious"]);
52
+ });
53
+ });
package/src/og.ts ADDED
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Fitting copy into a share card.
3
+ *
4
+ * The card's ART — the palette, the lockup, the size ladder — is the product, and it stays in
5
+ * the product. What ships here is the part two independent generators got wrong the same way:
6
+ * deciding what happens when a headline does not fit.
7
+ *
8
+ * Pure math, no `node:` import. The renderer that turns lines into SVG and SVG into a PNG is
9
+ * the caller's; `writeOgCards` in `node.ts` is the loop around it.
10
+ */
11
+
12
+ /** The canvas every platform crops from. 1200×630 is the Open Graph size, not a brand choice. */
13
+ export const OG_CANVAS = { width: 1200, height: 630 } as const;
14
+
15
+ /** Copy that did not fit its column. */
16
+ export interface OgOverflow {
17
+ /** Which line of which card, so an operator can act on the report without grepping. */
18
+ label: string;
19
+ /** The string as given. */
20
+ text: string;
21
+ maxLines: number;
22
+ /** Characters per line at this size — the budget the text blew. */
23
+ maxChars: number;
24
+ size: number;
25
+ }
26
+
27
+ export interface FitOptions {
28
+ /** Pixel width of the column the text has to live in. */
29
+ width: number;
30
+ /** Font size, in pixels. */
31
+ size: number;
32
+ /** Hard cap on lines. */
33
+ maxLines: number;
34
+ /**
35
+ * Average glyph advance as a fraction of the font size.
36
+ *
37
+ * Character-budget estimation rather than real metrics: an SVG rasterizer gives no measuring
38
+ * API, and card copy is short enough that the estimate never drifts more than a word. Measure
39
+ * it off a rendered card and round UP — a budget that is too generous overflows the column,
40
+ * while one that is too mean only breaks a line early. Donor values: ~0.5 for a display face
41
+ * at headline sizes, ~0.46 for body copy.
42
+ */
43
+ advance: number;
44
+ /** Names this line in an overflow report. `"pricing headline"`, not `"line 1"`. */
45
+ label: string;
46
+ }
47
+
48
+ export interface FitResult {
49
+ lines: readonly string[];
50
+ /** Set when the copy did not fit. See {@link fitText} for why this is reported rather than
51
+ * quietly truncated. */
52
+ overflow?: OgOverflow;
53
+ }
54
+
55
+ /**
56
+ * Greedy word wrap into a column, hard-capped at `maxLines`.
57
+ *
58
+ * Overflow is REPORTED, not truncated. The donor used to append `…`, which made wrapping a
59
+ * total function: every string "fitted", so copy that outgrew the column had no failing case to
60
+ * observe and shipped a card missing the end of the one line the card exists to carry. The
61
+ * reader who finds out is someone else's link unfurl. Cutting a headline to win an argument
62
+ * with a long sentence is a decision nobody reviewed; the size ladder is one somebody did.
63
+ *
64
+ * The truncated lines still come back, so a report can show what the card would have said, and
65
+ * so a `--check` run can lay every card out before refusing. One run names every bad card: copy
66
+ * lands per locale in batches, and a build that dies on the first of six sends its operator
67
+ * round the loop six times.
68
+ */
69
+ export function fitText(text: string, options: FitOptions): FitResult {
70
+ const { width, size, maxLines, advance, label } = options;
71
+ const maxChars = Math.max(8, Math.floor(width / (size * advance)));
72
+ const flat = text.replace(/\s+/g, " ").trim();
73
+ const lines: string[] = [];
74
+ let line = "";
75
+ let overflowed = false;
76
+
77
+ for (const word of flat.split(" ").filter(Boolean)) {
78
+ const candidate = line ? `${line} ${word}` : word;
79
+ if (candidate.length <= maxChars || !line) line = candidate;
80
+ else if (lines.length < maxLines - 1) {
81
+ lines.push(line);
82
+ line = word;
83
+ } else {
84
+ overflowed = true;
85
+ line = `${candidate.slice(0, maxChars - 1).trimEnd()}…`;
86
+ break;
87
+ }
88
+ }
89
+ if (line) lines.push(line);
90
+
91
+ return overflowed
92
+ ? { lines, overflow: { label, text: flat, maxLines, maxChars, size } }
93
+ : { lines };
94
+ }
95
+
96
+ /** One overflow, as the line an operator reads in a failed build. */
97
+ export function describeOverflow(overflow: OgOverflow): string {
98
+ return (
99
+ `${overflow.label} — ${String(overflow.maxLines)} lines of ~${String(overflow.maxChars)} ` +
100
+ `chars at ${String(overflow.size)}px, ${String(overflow.text.length)} chars given\n` +
101
+ ` ${JSON.stringify(overflow.text)}`
102
+ );
103
+ }
package/src/preset.ts ADDED
@@ -0,0 +1,78 @@
1
+ /**
2
+ * The `vite.config.ts` every app in this stack was writing by hand.
3
+ *
4
+ * Its own entry point (`@gusnips/vite/preset`) rather than the barrel, because it imports the
5
+ * React and Tailwind plugins: a prerender script that only wants `bakeHead` would otherwise
6
+ * load a build toolchain it never uses. Same reason the four packages are split at all.
7
+ */
8
+ import tailwindcss from "@tailwindcss/vite";
9
+ import react from "@vitejs/plugin-react";
10
+ import path from "node:path";
11
+ import type { Plugin, UserConfig } from "vite";
12
+
13
+ /**
14
+ * Substitute `%NAME%` placeholders in `index.html`.
15
+ *
16
+ * `index.html` cannot import TypeScript, so a brand name or tagline written there is a literal
17
+ * that drifts from the one the app renders. This injects them from the module that owns them.
18
+ *
19
+ * Vite already replaces `%VITE_FOO%` from the environment, and that is the right tool when the
20
+ * value IS environment — an API URL, a build id. It is the wrong one for brand identity, which
21
+ * belongs in a typed module the app imports, not in a `.env` nobody reviews.
22
+ */
23
+ export function htmlPlaceholders(values: Readonly<Record<string, string>>): Plugin {
24
+ return {
25
+ name: "frontkit:html-placeholders",
26
+ transformIndexHtml(html: string) {
27
+ return Object.entries(values).reduce(
28
+ (out, [name, value]) => out.replaceAll(`%${name}%`, value),
29
+ html,
30
+ );
31
+ },
32
+ };
33
+ }
34
+
35
+ export interface WebPresetOptions {
36
+ /** The app folder — the one holding `index.html`. In a `vite.config.ts` that is
37
+ * `import.meta.dirname`. `@` resolves to `<root>/src`. */
38
+ root: string;
39
+ /** Dev server port. Two apps in one repo must not share one, which is why it has no clever
40
+ * default beyond Vite's own. */
41
+ port?: number;
42
+ /** Preview server port. Defaults to `port - 1000`, the pairing both donors landed on
43
+ * (5173/4173, 5174/4174). */
44
+ previewPort?: number;
45
+ /** `{ BRAND_NAME: "Acme" }` replaces `%BRAND_NAME%` in `index.html`. */
46
+ placeholders?: Readonly<Record<string, string>>;
47
+ /**
48
+ * The workspace scope to bundle into the SSR build, as in `"@acme"`.
49
+ *
50
+ * INSURANCE, not a fix. Measured: Vite already bundles linked workspace dependencies in an
51
+ * SSR build — one donor runs without this declaration and its SSR output has zero bare
52
+ * imports. It is here because workspace packages are consumed as TypeScript SOURCE through
53
+ * subpath exports, and leaving them external would hand the runtime `.ts` files with
54
+ * Vite-only semantics in them (aliases, `?raw`, `define`) if that behaviour ever changed.
55
+ */
56
+ ssrScope?: string;
57
+ }
58
+
59
+ export function webPreset({
60
+ root,
61
+ port,
62
+ previewPort,
63
+ placeholders,
64
+ ssrScope,
65
+ }: WebPresetOptions): UserConfig {
66
+ const preview = previewPort ?? (port === undefined ? undefined : port - 1000);
67
+ return {
68
+ plugins: [react(), tailwindcss(), ...(placeholders ? [htmlPlaceholders(placeholders)] : [])],
69
+ resolve: {
70
+ alias: { "@": path.resolve(root, "src") },
71
+ },
72
+ ...(ssrScope && {
73
+ ssr: { noExternal: [new RegExp(`^${ssrScope.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/`)] },
74
+ }),
75
+ ...(port !== undefined && { server: { port } }),
76
+ ...(preview !== undefined && { preview: { port: preview } }),
77
+ };
78
+ }
@@ -0,0 +1,67 @@
1
+ import { createElement, lazy, Suspense, type ReactNode } from "react";
2
+ import { describe, expect, it } from "vitest";
3
+ import { renderTree } from "./render.ts";
4
+
5
+ /** A route that arrives one tick late, like every `lazy()` route in a real app. */
6
+ const Late = lazy(() =>
7
+ Promise.resolve({ default: () => createElement("main", null, "the real page") }),
8
+ );
9
+
10
+ const behindSuspense = (): ReactNode =>
11
+ createElement(
12
+ Suspense,
13
+ { fallback: createElement("div", { role: "status" }, "Loading…") },
14
+ createElement(Late),
15
+ );
16
+
17
+ describe("renderTree", () => {
18
+ /**
19
+ * Invariant 1, and the reason this helper exists at all. `renderToString` renders the
20
+ * FALLBACK here — it would write a loading screen into every file and pass every gate that
21
+ * only asks whether the root has children.
22
+ */
23
+ it("waits for a lazy route instead of rendering the fallback", async () => {
24
+ const html = await renderTree(behindSuspense());
25
+ expect(html).toContain("the real page");
26
+ expect(html).not.toContain('role="status"');
27
+ });
28
+
29
+ /**
30
+ * React 19 hoists in-tree `<title>`/`<meta>`/`<link>` to the FRONT of the server stream, for
31
+ * a caller assembling a whole document. We are filling one `<div>`, so they would land inside
32
+ * the body — invalid there, and a duplicate of the head the prerender bakes.
33
+ */
34
+ it("drops the head tags React hoists to the front of the stream", async () => {
35
+ const html = await renderTree(
36
+ createElement(
37
+ "main",
38
+ null,
39
+ createElement("title", null, "hoisted"),
40
+ createElement("meta", { name: "description", content: "hoisted" }),
41
+ "body copy",
42
+ ),
43
+ );
44
+ expect(html.startsWith("<main")).toBe(true);
45
+ expect(html).toContain("body copy");
46
+ });
47
+
48
+ it("keeps a script the page itself carries", async () => {
49
+ const html = await renderTree(
50
+ createElement("main", null, createElement("script", { type: "application/ld+json" }, "{}")),
51
+ );
52
+ expect(html).toContain("application/ld+json");
53
+ });
54
+
55
+ // `renderToString` answers "" for a router whose basename does not match its location — no
56
+ // error, no warning. A build must not be allowed to write that file.
57
+ it("refuses a tree that rendered to nothing", async () => {
58
+ await expect(renderTree(null)).rejects.toThrow(/rendered to nothing/);
59
+ });
60
+
61
+ it("fails the build when a component throws", async () => {
62
+ const Boom = (): ReactNode => {
63
+ throw new Error("boom");
64
+ };
65
+ await expect(renderTree(createElement(Boom))).rejects.toThrow(/boom/);
66
+ });
67
+ });
package/src/render.ts ADDED
@@ -0,0 +1,75 @@
1
+ /**
2
+ * One page, as the markup that goes inside `<div id="root">`.
3
+ *
4
+ * This is the build-time half of `main.tsx`, and the app's `entry-server.tsx` is expected to
5
+ * be four lines around it: the same `<App />` and the same route table the browser runs, with
6
+ * a `StaticRouter` in place of the history the build does not have. `main.tsx` is deliberately
7
+ * NOT reused — it reads `window.location`, registers listeners and starts analytics at module
8
+ * scope, none of which mean anything here.
9
+ *
10
+ * No `node:` import: this file is bundled into the SSR build by Vite, and it is React's own
11
+ * static renderer plus two string rules.
12
+ */
13
+ import type { ReactNode } from "react";
14
+ // `static.browser`, not `static`. Three donor entry-servers converged on it independently, and
15
+ // the reason is resolution rather than behaviour: `react-dom/static` is condition-resolved into
16
+ // four different files (node, edge-light, workerd, browser) and an SSR bundler picks the
17
+ // condition, not us. `.browser` is one implementation everywhere. Measured, `prerender` hands
18
+ // back a Web `ReadableStream` from every one of them — which is what `new Response()` below
19
+ // wants — so this pins a resolution, it does not fix a stream type.
20
+ import { prerender } from "react-dom/static.browser";
21
+
22
+ /**
23
+ * React 19 hoists `<title>`, `<meta>` and `<link>` rendered anywhere in the tree into the
24
+ * document head — and on the server it does that by emitting them at the FRONT of the stream,
25
+ * for a caller that is expected to be assembling a whole document. We are not: we are filling
26
+ * one `<div>`, so an in-tree SEO component's tags would land inside the body, where that markup
27
+ * is invalid and duplicates the head the prerender bakes.
28
+ *
29
+ * They are dropped rather than lifted, because the page registry is the one source of that head
30
+ * by contract and the baked copy is the half that exists before React runs. `<style>` and
31
+ * `<script>` are deliberately NOT in this pattern: if one appears here it belongs to the page
32
+ * and must survive.
33
+ */
34
+ const HOISTED_HEAD = /^(?:<title>[^<]*<\/title>|<meta\b[^>]*\/?>|<link\b[^>]*\/?>|\s+)+/;
35
+
36
+ /**
37
+ * Render a tree to markup with `prerender` from `react-dom/static`.
38
+ *
39
+ * **Never `renderToString`.** With `lazy()` routes behind a `<Suspense fallback={<Spinner />}>`,
40
+ * `renderToString` renders the FALLBACK — it would write a loading screen into every file and
41
+ * pass every gate that only asks whether the root has children. `prerender` waits for the tree
42
+ * to settle, which is also why this is async and why the whole renderer contract is.
43
+ *
44
+ * Two more halves of the same lesson are here too: `onError` is captured and rethrown, so a
45
+ * render failure fails the build rather than shipping a partial page; and an empty result is
46
+ * refused, because a router whose basename does not match its location answers `""` with no
47
+ * error and no warning.
48
+ */
49
+ export async function renderTree(tree: ReactNode): Promise<string> {
50
+ let failure: unknown;
51
+ const { prelude } = await prerender(tree, {
52
+ onError(error: unknown) {
53
+ failure ??= error;
54
+ },
55
+ });
56
+ const html = await new Response(prelude).text();
57
+ if (failure !== undefined) throw failure;
58
+
59
+ const markup = html.replace(HOISTED_HEAD, "");
60
+ if (markup.trim() === "")
61
+ throw new Error(
62
+ "renderTree: the tree rendered to nothing — usually a router whose location or basename " +
63
+ "matches no route, which React reports as an empty string rather than an error",
64
+ );
65
+ return markup;
66
+ }
67
+
68
+ /**
69
+ * What an SSR entry exports, and what {@link loadRenderer} looks for.
70
+ *
71
+ * `context` is opaque on purpose. A multi-locale app builds its i18n instance per call — three
72
+ * languages render in one process and a shared singleton would have them racing for one `lng` —
73
+ * while a single-locale app ignores the argument and keeps its singleton.
74
+ */
75
+ export type PageRenderer<Context = unknown> = (route: string, context?: Context) => Promise<string>;