@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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gustavo Salomé
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,123 @@
1
+ # @gusnips/vite
2
+
3
+ Turn a Vite + React SPA's public routes into real HTML files at build time — a real `<head>`, a
4
+ real body, one file per page.
5
+
6
+ ```bash
7
+ bun add -d @gusnips/vite
8
+ ```
9
+
10
+ ```ts
11
+ import { pageFile } from "@gusnips/vite";
12
+
13
+ pageFile("/pricing"); // → "pricing.html"
14
+ ```
15
+
16
+ ## Why bother
17
+
18
+ A Vite SPA ships one `index.html` and draws everything else with JavaScript. Nothing that reads a
19
+ link for a living runs that bundle — not a search crawler, not an LLM, not the thing that draws
20
+ the preview card when someone pastes your link in a chat. A shipped `<div id="root"></div>` is a
21
+ page that can be listed and never quoted.
22
+
23
+ So the build renders each public route to its own file. This is the part every app doing that
24
+ ends up writing.
25
+
26
+ ## Baking a page
27
+
28
+ ```ts
29
+ import { bakeHead } from "@gusnips/vite";
30
+
31
+ const html = bakeHead(template, {
32
+ title: "Pricing — Example",
33
+ description: "What it costs.",
34
+ canonical: "https://example.com/pricing",
35
+ body: { route: "/pricing", html: rendered },
36
+ });
37
+ ```
38
+
39
+ `bakeHead` rewrites the title, the description, the canonical, the Open Graph and Twitter tags,
40
+ `<html lang>`, the `hreflang` set, and injects the rendered markup into `<div id="root">`.
41
+
42
+ Four rules in there are load-bearing:
43
+
44
+ - **`canonical: null` strips the tag, and takes `og:image` and `og:url` with it.** For the 404
45
+ shell, which is served for every address that does not exist. Blanking is not stripping: an
46
+ empty canonical is a claim about `""`, and an empty `og:url` is a card pointing at your front
47
+ page. The image is the half people miss — one codebase set it before it branched on the shell,
48
+ so its `404.html` advertised a card at `/og/__not-found__.png`, a file that has never existed.
49
+ Every share of a dead link unfurled broken, and nothing in a browser showed it.
50
+ - **`og:locale` is not optional on a non-English page.** Leave it out and the spec does not
51
+ default it to "unknown" — it defaults to `en_US`. A Portuguese page with a Portuguese
52
+ `og:title` then tells every share crawler the card is English. `ogLocale("pt-BR")` gives you
53
+ the underscore spelling it actually wants.
54
+ - **`body` carries the route, not just the markup.** A static host answers every address it does
55
+ not publish with the nearest `404.html`, and that file has the not-found page rendered _into_
56
+ it. So "does the root have children" is the wrong question: a route served from the shell finds
57
+ a full root, hydrates, and React reconciles two different pages. It recovers by throwing the
58
+ tree away and logging — a page that works, and a bug nobody sees. The marker is what the
59
+ browser entry compares against before it decides.
60
+ - **`alternates` must be reciprocal.** A crawler ignores the whole set unless every address in it
61
+ points back at the others, which is why you pass the full list to every page rather than "the
62
+ other two".
63
+
64
+ ## Rendering
65
+
66
+ ```ts
67
+ import { renderTree } from "@gusnips/vite/render";
68
+ ```
69
+
70
+ Behind its own subpath, because it is the one thing here that loads React. A repo that only wants
71
+ a sitemap installs no renderer.
72
+
73
+ It uses `prerender` from `react-dom/static`, never `renderToString`. With `lazy()` routes behind
74
+ a `<Suspense>`, **`renderToString` renders the fallback** — it will write a loading screen into
75
+ every file and pass any gate that only asks whether the root has children. It also answers `""`
76
+ for a basename mismatch with no error and no warning, so `assertRendered` checks every route for
77
+ non-empty output. And React 19 hoists in-tree `<title>` and `<meta>` to the front of the server
78
+ stream, which lands them inside your body when you are filling one `<div>` rather than assembling
79
+ a document — so those get stripped, and a render error is rethrown so a broken page fails the
80
+ build instead of shipping.
81
+
82
+ ## Flat files
83
+
84
+ That `pageFile` at the top writes `pricing.html`, not `pricing/index.html`. Nested routes keep
85
+ their folders — `/guides/errors` → `guides/errors.html`.
86
+
87
+ The directory form looks tidier and is a trap. Cloudflare Pages serves it at `/pricing/` and
88
+ answers `/pricing` with a 308, so every address your app advertises in its own canonical and its
89
+ own sitemap would be a redirect rather than a page. A flat file answers both, 200 either way.
90
+
91
+ ## Sitemap, robots, OG cards
92
+
93
+ `sitemapFor(origin, pages)` walks your page registry; `robotsTxt({ origin, sitemaps })` generates
94
+ the file, listing every sitemap in one, because a crawler only reads the one at the origin root —
95
+ a subdirectory app cannot ship its own.
96
+
97
+ `sitemapXml` takes `priority` **pre-formatted, as a string**. Ranking pages is a decision that
98
+ belongs to whoever walks the registry: one codebase reads a field off its own registry, another
99
+ gives the front door 1.0 and every guide 0.8 flat, because ranking one guide above another would
100
+ be a guess about a reader.
101
+
102
+ `writeOgCards` walks the registry and calls a renderer you supply, and `fitText` does the
103
+ text-fitting maths. When copy does not fit, it **records the overflow and refuses to write**
104
+ rather than appending an ellipsis — an ellipsis makes every string "fit", so copy that outgrew
105
+ its column has no failing case and ships.
106
+
107
+ `loadTemplate` refuses a `dist/index.html` that is already a rendered page. `dist/index.html` is
108
+ both the file every page is baked _from_ and the home page's own output, so a second pass over a
109
+ written `dist/` reads a finished page as its blank and nests one render inside another. `vite
110
+ build` empties `dist/` and normally makes that impossible — a restored build cache and a hand-run
111
+ of the script both route around it.
112
+
113
+ ## The vite preset
114
+
115
+ ```ts
116
+ import { webPreset } from "@gusnips/vite/preset";
117
+
118
+ export default defineConfig(webPreset({/* … */}));
119
+ ```
120
+
121
+ Its own subpath too, since it pulls in the React and Tailwind plugins.
122
+
123
+ MIT · part of [frontkit](https://github.com/gusnips/frontkit)
@@ -0,0 +1,6 @@
1
+ /** Escaping shared by the head baker and the sitemap writer. Its own file so neither has to
2
+ * import the other, and so both stay free of every dependency this package has. */
3
+ /** `&` first, or the ampersands introduced by the later rules get escaped twice. */
4
+ export declare const escapeAttr: (s: string) => string;
5
+ export declare const escapeRegex: (s: string) => string;
6
+ //# sourceMappingURL=escape.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"escape.d.ts","sourceRoot":"","sources":["../src/escape.ts"],"names":[],"mappings":"AAAA;oFACoF;AAEpF,oFAAoF;AACpF,eAAO,MAAM,UAAU,GAAI,GAAG,MAAM,KAAG,MACuD,CAAC;AAE/F,eAAO,MAAM,WAAW,GAAI,GAAG,MAAM,KAAG,MAAkD,CAAC"}
package/dist/escape.js ADDED
@@ -0,0 +1,6 @@
1
+ /** Escaping shared by the head baker and the sitemap writer. Its own file so neither has to
2
+ * import the other, and so both stay free of every dependency this package has. */
3
+ /** `&` first, or the ampersands introduced by the later rules get escaped twice. */
4
+ export const escapeAttr = (s) => s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
5
+ export const escapeRegex = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
6
+ //# sourceMappingURL=escape.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"escape.js","sourceRoot":"","sources":["../src/escape.ts"],"names":[],"mappings":"AAAA;oFACoF;AAEpF,oFAAoF;AACpF,MAAM,CAAC,MAAM,UAAU,GAAG,CAAC,CAAS,EAAU,EAAE,CAC9C,CAAC,CAAC,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC,OAAO,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;AAE/F,MAAM,CAAC,MAAM,WAAW,GAAG,CAAC,CAAS,EAAU,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,qBAAqB,EAAE,MAAM,CAAC,CAAC"}
package/dist/head.d.ts ADDED
@@ -0,0 +1,110 @@
1
+ /** The empty root a template must carry — `bakeHead` fills it, and refuses a filled one. */
2
+ export declare const EMPTY_ROOT = "<div id=\"root\"></div>";
3
+ /** One `<link rel="alternate" hreflang>` — this page's address in another language. */
4
+ export interface Alternate {
5
+ /** A BCP-47 tag, or `x-default` for the address a crawler should show when it has no reason
6
+ * to prefer one. */
7
+ hreflang: string;
8
+ href: string;
9
+ }
10
+ export interface HeadTags {
11
+ /** `<title>`, and the share title unless overridden. */
12
+ title: string;
13
+ /** `name="description"`, and the share description unless overridden. */
14
+ description: string;
15
+ /**
16
+ * The page's own URL, for `<link rel="canonical">` and the share tags.
17
+ *
18
+ * `null` means this page has no canonical URL and must not claim one — the 404 shell, which
19
+ * is served for every address that does not exist and would otherwise tell a crawler that
20
+ * all of them are the front page.
21
+ */
22
+ canonical: string | null;
23
+ ogTitle?: string;
24
+ ogDescription?: string;
25
+ /** Absolute URL of the share card. Omit where the template carries no `og:image` tag at all
26
+ * — setting one there is a build error, by design. */
27
+ image?: string;
28
+ /** Markup inserted before `</head>` — structured data, and nothing else so far. Emitted here
29
+ * because its only reader is a crawler. */
30
+ headExtra?: string;
31
+ /** Keep this page out of every index. Pairs with `canonical: null`. */
32
+ noindex?: boolean;
33
+ /**
34
+ * `<html lang>`, when this page is not in the template's language, and the `og:locale` that
35
+ * goes with it.
36
+ *
37
+ * A crawler and a screen reader both read `lang`, and neither runs the bundle that would
38
+ * otherwise set it — so a Portuguese file whose `<html>` still says `en` is announced in the
39
+ * wrong voice and indexed as the wrong language.
40
+ */
41
+ lang?: string;
42
+ /**
43
+ * Every language this page exists in, INCLUDING this one, plus `x-default`.
44
+ *
45
+ * Reciprocal by contract: a crawler ignores the whole set unless each address in it points
46
+ * back at the others, which is why the caller passes the full list to every page rather than
47
+ * "the other two".
48
+ */
49
+ alternates?: readonly Alternate[];
50
+ /**
51
+ * The rendered page, injected into `<div id="root">`, and the route it is a render OF.
52
+ *
53
+ * The head alone was never enough. A shipped `<div id="root"></div>` is a page whose entire
54
+ * content is a description tag — it can be listed, and it can never be read, quoted or
55
+ * answered from.
56
+ *
57
+ * The two travel together because markup alone is not enough to hydrate against. A static
58
+ * host answers every unpublished address with the nearest `404.html`, and that file has a
59
+ * rendered body in it — so a route served from the shell would find a full root and hydrate
60
+ * the not-found page into a page that is not it. The marker is what the browser entry
61
+ * compares its own route against before deciding to hydrate or to mount fresh.
62
+ */
63
+ body?: {
64
+ route: string;
65
+ html: string;
66
+ };
67
+ }
68
+ /**
69
+ * A BCP-47 tag in Open Graph's spelling: underscore, and a TERRITORY it will not infer.
70
+ *
71
+ * `og:locale` wants `language_TERRITORY` and quietly ignores anything else, which is the same
72
+ * outcome as omitting it — the `en_US` default. So a Portuguese page with a Portuguese
73
+ * `og:title` and no `og:locale` tells every share crawler the card is English. `pt-BR` already
74
+ * carries its territory; `en` and `es` do not, so one is chosen here rather than left to a
75
+ * crawler. `es_ES` is not a claim that the copy is peninsular Spanish — it is the most widely
76
+ * recognised Spanish value, and the tag's job is to be understood, not precise about dialect.
77
+ */
78
+ export declare function ogLocale(tag: string): string;
79
+ /**
80
+ * One page's `<head>` and body, written into the built template.
81
+ *
82
+ * Every tag is SET rather than appended, and `setMeta` throws on a tag the template does not
83
+ * carry — so the failure mode of editing `index.html` is a red build, never a page quietly
84
+ * shipping somebody else's description.
85
+ */
86
+ export declare function bakeHead(template: string, tags: HeadTags): string;
87
+ export interface RenderedChecks {
88
+ /** The BCP-47 tag this file must be marked with — the same `lang` passed to `bakeHead`. */
89
+ lang?: string;
90
+ /** Bytes the page must have gained over the template before it counts as rendered.
91
+ * 500 is the floor both donor builds ran with. */
92
+ minGrowth?: number;
93
+ }
94
+ /**
95
+ * What every written file must be true of before the build is allowed to pass.
96
+ *
97
+ * The regression this exists for is a root that renders to nothing. A router whose location
98
+ * does not match its routes yields empty markup with no error and no warning — it looks fine
99
+ * in every browser and is invisible to everything that reads a link. Checking the bytes we
100
+ * actually wrote is the only thing that catches it, and it needs no browser, so it gates the
101
+ * BUILD rather than sitting in a test suite.
102
+ *
103
+ * The loading-screen check is the half a size floor misses. With `lazy()` routes behind one
104
+ * `<Suspense fallback={<Spinner />}>`, a render that resolves nothing still produces a
105
+ * plausible body: one donor shipped a 1,174-byte spinner with a perfect title and an element
106
+ * inside the root. A file whose body is a spinner is WORSE than an empty one, because every
107
+ * cheap check passes.
108
+ */
109
+ export declare function assertRendered(file: string, html: string, template: string, checks?: RenderedChecks): void;
110
+ //# sourceMappingURL=head.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"head.d.ts","sourceRoot":"","sources":["../src/head.ts"],"names":[],"mappings":"AAiBA,4FAA4F;AAC5F,eAAO,MAAM,UAAU,4BAA0B,CAAC;AA+BlD,uFAAuF;AACvF,MAAM,WAAW,SAAS;IACxB;yBACqB;IACrB,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,QAAQ;IACvB,wDAAwD;IACxD,KAAK,EAAE,MAAM,CAAC;IACd,yEAAyE;IACzE,WAAW,EAAE,MAAM,CAAC;IACpB;;;;;;OAMG;IACH,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;2DACuD;IACvD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;gDAC4C;IAC5C,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,uEAAuE;IACvE,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;;;;OAOG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,SAAS,SAAS,EAAE,CAAC;IAClC;;;;;;;;;;;;OAYG;IACH,IAAI,CAAC,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC;CACxC;AAED;;;;;;;;;GASG;AACH,wBAAgB,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAG5C;AAED;;;;;;GAMG;AACH,wBAAgB,QAAQ,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,QAAQ,GAAG,MAAM,CAqFjE;AAED,MAAM,WAAW,cAAc;IAC7B,2FAA2F;IAC3F,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;uDACmD;IACnD,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,cAAc,CAC5B,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,EACZ,QAAQ,EAAE,MAAM,EAChB,MAAM,GAAE,cAAmB,GAC1B,IAAI,CAqCN"}
package/dist/head.js ADDED
@@ -0,0 +1,183 @@
1
+ /**
2
+ * The head a crawler sees, baked at build time.
3
+ *
4
+ * A Vite SPA ships one `index.html` and draws everything else with JavaScript. Nothing that
5
+ * reads a link for a living runs that bundle — not Google's first pass, not Bing, not an LLM
6
+ * crawler, not whatever draws the preview card in Slack. So the build rewrites the template
7
+ * once per route and writes a real file per page, with a real `<head>` AND a real body.
8
+ *
9
+ * Pure string work, on purpose: no `node:` import anywhere in this file, so every branch is
10
+ * unit-testable without a filesystem. The reading and writing lives in `node.ts`.
11
+ */
12
+ // The `/contract` subpath, not the barrel: the barrel reaches `react-dom/client` through
13
+ // `hydrate.ts`, and a build script asking for one string should not pull the browser renderer
14
+ // into a Node process. That file imports nothing at all.
15
+ import { PRERENDERED_ROUTE_ATTR } from "@gusnips/react/contract";
16
+ import { escapeAttr, escapeRegex } from "./escape.js";
17
+ /** The empty root a template must carry — `bakeHead` fills it, and refuses a filled one. */
18
+ export const EMPTY_ROOT = '<div id="root"></div>';
19
+ /** `[^>]*` on both sides tolerates the attributes being split across lines, as Vite emits them. */
20
+ const metaRe = (selector) => new RegExp(`(<meta[^>]*${escapeRegex(selector)}[^>]*content=")[^"]*(")`);
21
+ /**
22
+ * Replace the `content` of one `<meta …>`. Throws when the tag is missing, so an edit to
23
+ * `index.html` fails the build instead of silently shipping the front door's description to
24
+ * every crawler.
25
+ */
26
+ function setMeta(html, selector, value) {
27
+ const re = metaRe(selector);
28
+ if (!re.test(html))
29
+ throw new Error(`prerender: <meta ${selector}> not found in index.html`);
30
+ return html.replace(re, `$1${escapeAttr(value)}$2`);
31
+ }
32
+ /**
33
+ * The same, for a tag a template is allowed not to carry.
34
+ *
35
+ * Only the `twitter:` pair and `name="title"` get this. X reads the `og:` tags when the
36
+ * `twitter:` ones are absent, and no crawler reads `name="title"` at all — so a template that
37
+ * omits them is correct, and demanding them would break an app that never had them. A template
38
+ * that DOES carry one and gets a stale value is still a bug, which is why they are written
39
+ * rather than ignored. Everything a crawler actually depends on goes through `setMeta`.
40
+ */
41
+ function setMetaIfPresent(html, selector, value) {
42
+ const re = metaRe(selector);
43
+ return re.test(html) ? html.replace(re, `$1${escapeAttr(value)}$2`) : html;
44
+ }
45
+ /**
46
+ * A BCP-47 tag in Open Graph's spelling: underscore, and a TERRITORY it will not infer.
47
+ *
48
+ * `og:locale` wants `language_TERRITORY` and quietly ignores anything else, which is the same
49
+ * outcome as omitting it — the `en_US` default. So a Portuguese page with a Portuguese
50
+ * `og:title` and no `og:locale` tells every share crawler the card is English. `pt-BR` already
51
+ * carries its territory; `en` and `es` do not, so one is chosen here rather than left to a
52
+ * crawler. `es_ES` is not a claim that the copy is peninsular Spanish — it is the most widely
53
+ * recognised Spanish value, and the tag's job is to be understood, not precise about dialect.
54
+ */
55
+ export function ogLocale(tag) {
56
+ const TERRITORY = { en: "en_US", es: "es_ES" };
57
+ return TERRITORY[tag] ?? tag.replace("-", "_");
58
+ }
59
+ /**
60
+ * One page's `<head>` and body, written into the built template.
61
+ *
62
+ * Every tag is SET rather than appended, and `setMeta` throws on a tag the template does not
63
+ * carry — so the failure mode of editing `index.html` is a red build, never a page quietly
64
+ * shipping somebody else's description.
65
+ */
66
+ export function bakeHead(template, tags) {
67
+ const shareTitle = tags.ogTitle ?? tags.title;
68
+ const shareDescription = tags.ogDescription ?? tags.description;
69
+ let html = template.replace(/<title>[^<]*<\/title>/, `<title>${escapeAttr(tags.title)}</title>`);
70
+ html = setMeta(html, 'name="description"', tags.description);
71
+ html = setMeta(html, 'property="og:title"', shareTitle);
72
+ html = setMeta(html, 'property="og:description"', shareDescription);
73
+ html = setMetaIfPresent(html, 'name="title"', tags.title);
74
+ html = setMetaIfPresent(html, 'property="twitter:title"', shareTitle);
75
+ html = setMetaIfPresent(html, 'property="twitter:description"', shareDescription);
76
+ if (tags.image !== undefined) {
77
+ html = setMeta(html, 'property="og:image"', tags.image);
78
+ html = setMetaIfPresent(html, 'property="twitter:image"', tags.image);
79
+ }
80
+ if (tags.canonical === null) {
81
+ // Strip rather than blank: an empty canonical is a claim about "" and an empty og:url is a
82
+ // share card pointing at the origin root. The image is left alone — it is a picture, not a
83
+ // claim about this address, and a dead link that still unfurls the brand card is fine.
84
+ html = html
85
+ .replace(/\s*<link rel="canonical"[^>]*>/, "")
86
+ .replace(/\s*<meta property="(?:og|twitter):url"[^>]*>/g, "");
87
+ }
88
+ else {
89
+ html = setMeta(html, 'property="og:url"', tags.canonical);
90
+ html = setMetaIfPresent(html, 'property="twitter:url"', tags.canonical);
91
+ html = html.replace(/(<link rel="canonical" href=")[^"]*(")/, `$1${escapeAttr(tags.canonical)}$2`);
92
+ }
93
+ if (tags.alternates?.length) {
94
+ const links = tags.alternates
95
+ .map((alt) => ` <link rel="alternate" hreflang="${escapeAttr(alt.hreflang)}" href="${escapeAttr(alt.href)}" />`)
96
+ .join("\n");
97
+ html = html.replace("</head>", `${links}\n </head>`);
98
+ }
99
+ if (tags.lang !== undefined) {
100
+ // `og:locale` is not the same claim as `<html lang>` and is read by different machines.
101
+ // APPENDED rather than set, because a Vite template carries no such tag — but one that
102
+ // does would end up with two, and a crawler reading the first would get the template's
103
+ // language on every page. So drop whatever is there before writing this page's own.
104
+ html = html.replace(/\s*<meta property="og:locale(?::alternate)?"[^>]*>/g, "");
105
+ const alternates = (tags.alternates ?? [])
106
+ .filter((alt) => alt.hreflang !== "x-default" && alt.hreflang !== tags.lang)
107
+ .map((alt) => ` <meta property="og:locale:alternate" content="${escapeAttr(ogLocale(alt.hreflang))}" />`);
108
+ html = html.replace("</head>", ` <meta property="og:locale" content="${escapeAttr(ogLocale(tags.lang))}" />\n${alternates.length ? `${alternates.join("\n")}\n` : ""} </head>`);
109
+ }
110
+ if (tags.noindex)
111
+ html = html.replace("</head>", ` <meta name="robots" content="noindex" />\n </head>`);
112
+ if (tags.headExtra)
113
+ html = html.replace("</head>", ` ${tags.headExtra}\n </head>`);
114
+ if (tags.lang !== undefined) {
115
+ const re = /(<html[^>]*\blang=")[^"]*(")/;
116
+ if (!re.test(html))
117
+ throw new Error("prerender: <html lang> not found in index.html");
118
+ html = html.replace(re, `$1${escapeAttr(tags.lang)}$2`);
119
+ }
120
+ if (tags.body !== undefined) {
121
+ // Empty on purpose in the template, and it must STAY empty there: a second bake over an
122
+ // already-filled root would nest one render inside another.
123
+ if (!html.includes(EMPTY_ROOT))
124
+ throw new Error(`prerender: ${EMPTY_ROOT} not found in index.html`);
125
+ html = html.replace(EMPTY_ROOT, `<div id="root" ${PRERENDERED_ROUTE_ATTR}="${escapeAttr(tags.body.route)}">${tags.body.html}</div>`);
126
+ }
127
+ return html;
128
+ }
129
+ /**
130
+ * What every written file must be true of before the build is allowed to pass.
131
+ *
132
+ * The regression this exists for is a root that renders to nothing. A router whose location
133
+ * does not match its routes yields empty markup with no error and no warning — it looks fine
134
+ * in every browser and is invisible to everything that reads a link. Checking the bytes we
135
+ * actually wrote is the only thing that catches it, and it needs no browser, so it gates the
136
+ * BUILD rather than sitting in a test suite.
137
+ *
138
+ * The loading-screen check is the half a size floor misses. With `lazy()` routes behind one
139
+ * `<Suspense fallback={<Spinner />}>`, a render that resolves nothing still produces a
140
+ * plausible body: one donor shipped a 1,174-byte spinner with a perfect title and an element
141
+ * inside the root. A file whose body is a spinner is WORSE than an empty one, because every
142
+ * cheap check passes.
143
+ */
144
+ export function assertRendered(file, html, template, checks = {}) {
145
+ function fail(why) {
146
+ throw new Error(`prerender: ${file} ${why}`);
147
+ }
148
+ // Measured against the template rather than by matching the root's closing tag — the body is
149
+ // thousands of nested `</div>`s and no regex should be asked to find the right one.
150
+ // Everything this file has over the shell it was baked from is the page.
151
+ const floor = checks.minGrowth ?? 500;
152
+ const grew = html.length - template.length;
153
+ if (grew < floor)
154
+ fail(`is only ${String(grew)} bytes bigger than the shell — nothing rendered`);
155
+ if (/<title>\s*<\/title>/.test(html))
156
+ fail("has an empty <title>");
157
+ if (checks.lang !== undefined && !html.includes(`<html lang="${checks.lang}"`))
158
+ fail(`is not marked as ${checks.lang}`);
159
+ // A `%NAME%` the HTML transform never filled in. Three inner characters minimum, because two
160
+ // is the shape of percent-encoding: `/caf%C3%A9` contains `%C3%`, and an accented slug is not
161
+ // a broken build.
162
+ const placeholder = /%[A-Z][A-Z0-9_]{2,}%/.exec(html);
163
+ if (placeholder)
164
+ fail(`still carries an unsubstituted placeholder, ${placeholder[0]}`);
165
+ // What the root ACTUALLY opens with. Sliced rather than matched in one pattern, because a
166
+ // regex that skips React's `<!--$-->` Suspense markers with `(?:<!--.*?-->|\s)*` can backtrack
167
+ // across the whole document — it will happily skip 40 KB of real page to find a
168
+ // `role="status"` further down and report a perfectly good file as a spinner. It did exactly
169
+ // that in the donor before this was rewritten as a slice.
170
+ const opened = /<div id="root"[^>]*>/.exec(html);
171
+ if (!opened)
172
+ fail("has no root element at all");
173
+ const start = opened.index + opened[0].length;
174
+ const head = html
175
+ .slice(start, start + 600)
176
+ .replace(/<!--.*?-->/g, "")
177
+ .trimStart();
178
+ if (!/^<[a-z]/.test(head))
179
+ fail("has no element inside its root");
180
+ if (/^<[^>]*role="status"/.test(head))
181
+ fail("rendered the loading screen, not the page — something suspended and never resolved");
182
+ }
183
+ //# sourceMappingURL=head.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"head.js","sourceRoot":"","sources":["../src/head.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,yFAAyF;AACzF,8FAA8F;AAC9F,yDAAyD;AACzD,OAAO,EAAE,sBAAsB,EAAE,MAAM,yBAAyB,CAAC;AACjE,OAAO,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAEtD,4FAA4F;AAC5F,MAAM,CAAC,MAAM,UAAU,GAAG,uBAAuB,CAAC;AAElD,mGAAmG;AACnG,MAAM,MAAM,GAAG,CAAC,QAAgB,EAAU,EAAE,CAC1C,IAAI,MAAM,CAAC,cAAc,WAAW,CAAC,QAAQ,CAAC,yBAAyB,CAAC,CAAC;AAE3E;;;;GAIG;AACH,SAAS,OAAO,CAAC,IAAY,EAAE,QAAgB,EAAE,KAAa;IAC5D,MAAM,EAAE,GAAG,MAAM,CAAC,QAAQ,CAAC,CAAC;IAC5B,IAAI,CAAC,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC;QAAE,MAAM,IAAI,KAAK,CAAC,oBAAoB,QAAQ,2BAA2B,CAAC,CAAC;IAC7F,OAAO,IAAI,CAAC,OAAO,CAAC,EAAE,EAAE,KAAK,UAAU,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;AACtD,CAAC;AAED;;;;;;;;GAQG;AACH,SAAS,gBAAgB,CAAC,IAAY,EAAE,QAAgB,EAAE,KAAa;IACrE,MAAM,EAAE,GAAG,MAAM,CAAC,QAAQ,CAAC,CAAC;IAC5B,OAAO,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,EAAE,KAAK,UAAU,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;AAC7E,CAAC;AAkED;;;;;;;;;GASG;AACH,MAAM,UAAU,QAAQ,CAAC,GAAW;IAClC,MAAM,SAAS,GAA2B,EAAE,EAAE,EAAE,OAAO,EAAE,EAAE,EAAE,OAAO,EAAE,CAAC;IACvE,OAAO,SAAS,CAAC,GAAG,CAAC,IAAI,GAAG,CAAC,OAAO,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC;AACjD,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,QAAQ,CAAC,QAAgB,EAAE,IAAc;IACvD,MAAM,UAAU,GAAG,IAAI,CAAC,OAAO,IAAI,IAAI,CAAC,KAAK,CAAC;IAC9C,MAAM,gBAAgB,GAAG,IAAI,CAAC,aAAa,IAAI,IAAI,CAAC,WAAW,CAAC;IAEhE,IAAI,IAAI,GAAG,QAAQ,CAAC,OAAO,CAAC,uBAAuB,EAAE,UAAU,UAAU,CAAC,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC;IACjG,IAAI,GAAG,OAAO,CAAC,IAAI,EAAE,oBAAoB,EAAE,IAAI,CAAC,WAAW,CAAC,CAAC;IAC7D,IAAI,GAAG,OAAO,CAAC,IAAI,EAAE,qBAAqB,EAAE,UAAU,CAAC,CAAC;IACxD,IAAI,GAAG,OAAO,CAAC,IAAI,EAAE,2BAA2B,EAAE,gBAAgB,CAAC,CAAC;IACpE,IAAI,GAAG,gBAAgB,CAAC,IAAI,EAAE,cAAc,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC;IAC1D,IAAI,GAAG,gBAAgB,CAAC,IAAI,EAAE,0BAA0B,EAAE,UAAU,CAAC,CAAC;IACtE,IAAI,GAAG,gBAAgB,CAAC,IAAI,EAAE,gCAAgC,EAAE,gBAAgB,CAAC,CAAC;IAElF,IAAI,IAAI,CAAC,KAAK,KAAK,SAAS,EAAE,CAAC;QAC7B,IAAI,GAAG,OAAO,CAAC,IAAI,EAAE,qBAAqB,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC;QACxD,IAAI,GAAG,gBAAgB,CAAC,IAAI,EAAE,0BAA0B,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC;IACxE,CAAC;IAED,IAAI,IAAI,CAAC,SAAS,KAAK,IAAI,EAAE,CAAC;QAC5B,2FAA2F;QAC3F,2FAA2F;QAC3F,uFAAuF;QACvF,IAAI,GAAG,IAAI;aACR,OAAO,CAAC,gCAAgC,EAAE,EAAE,CAAC;aAC7C,OAAO,CAAC,+CAA+C,EAAE,EAAE,CAAC,CAAC;IAClE,CAAC;SAAM,CAAC;QACN,IAAI,GAAG,OAAO,CAAC,IAAI,EAAE,mBAAmB,EAAE,IAAI,CAAC,SAAS,CAAC,CAAC;QAC1D,IAAI,GAAG,gBAAgB,CAAC,IAAI,EAAE,wBAAwB,EAAE,IAAI,CAAC,SAAS,CAAC,CAAC;QACxE,IAAI,GAAG,IAAI,CAAC,OAAO,CACjB,wCAAwC,EACxC,KAAK,UAAU,CAAC,IAAI,CAAC,SAAS,CAAC,IAAI,CACpC,CAAC;IACJ,CAAC;IAED,IAAI,IAAI,CAAC,UAAU,EAAE,MAAM,EAAE,CAAC;QAC5B,MAAM,KAAK,GAAG,IAAI,CAAC,UAAU;aAC1B,GAAG,CACF,CAAC,GAAG,EAAE,EAAE,CACN,uCAAuC,UAAU,CAAC,GAAG,CAAC,QAAQ,CAAC,WAAW,UAAU,CAAC,GAAG,CAAC,IAAI,CAAC,MAAM,CACvG;aACA,IAAI,CAAC,IAAI,CAAC,CAAC;QACd,IAAI,GAAG,IAAI,CAAC,OAAO,CAAC,SAAS,EAAE,GAAG,KAAK,aAAa,CAAC,CAAC;IACxD,CAAC;IAED,IAAI,IAAI,CAAC,IAAI,KAAK,SAAS,EAAE,CAAC;QAC5B,wFAAwF;QACxF,uFAAuF;QACvF,uFAAuF;QACvF,oFAAoF;QACpF,IAAI,GAAG,IAAI,CAAC,OAAO,CAAC,qDAAqD,EAAE,EAAE,CAAC,CAAC;QAC/E,MAAM,UAAU,GAAG,CAAC,IAAI,CAAC,UAAU,IAAI,EAAE,CAAC;aACvC,MAAM,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,QAAQ,KAAK,WAAW,IAAI,GAAG,CAAC,QAAQ,KAAK,IAAI,CAAC,IAAI,CAAC;aAC3E,GAAG,CACF,CAAC,GAAG,EAAE,EAAE,CACN,qDAAqD,UAAU,CAAC,QAAQ,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,MAAM,CAChG,CAAC;QACJ,IAAI,GAAG,IAAI,CAAC,OAAO,CACjB,SAAS,EACT,2CAA2C,UAAU,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,SACxE,UAAU,CAAC,MAAM,CAAC,CAAC,CAAC,GAAG,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,EACrD,WAAW,CACZ,CAAC;IACJ,CAAC;IAED,IAAI,IAAI,CAAC,OAAO;QACd,IAAI,GAAG,IAAI,CAAC,OAAO,CAAC,SAAS,EAAE,yDAAyD,CAAC,CAAC;IAC5F,IAAI,IAAI,CAAC,SAAS;QAAE,IAAI,GAAG,IAAI,CAAC,OAAO,CAAC,SAAS,EAAE,OAAO,IAAI,CAAC,SAAS,aAAa,CAAC,CAAC;IAEvF,IAAI,IAAI,CAAC,IAAI,KAAK,SAAS,EAAE,CAAC;QAC5B,MAAM,EAAE,GAAG,8BAA8B,CAAC;QAC1C,IAAI,CAAC,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC;YAAE,MAAM,IAAI,KAAK,CAAC,gDAAgD,CAAC,CAAC;QACtF,IAAI,GAAG,IAAI,CAAC,OAAO,CAAC,EAAE,EAAE,KAAK,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAC1D,CAAC;IAED,IAAI,IAAI,CAAC,IAAI,KAAK,SAAS,EAAE,CAAC;QAC5B,wFAAwF;QACxF,4DAA4D;QAC5D,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,UAAU,CAAC;YAC5B,MAAM,IAAI,KAAK,CAAC,cAAc,UAAU,0BAA0B,CAAC,CAAC;QACtE,IAAI,GAAG,IAAI,CAAC,OAAO,CACjB,UAAU,EACV,kBAAkB,sBAAsB,KAAK,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,KAAK,IAAI,CAAC,IAAI,CAAC,IAAI,QAAQ,CACpG,CAAC;IACJ,CAAC;IAED,OAAO,IAAI,CAAC;AACd,CAAC;AAUD;;;;;;;;;;;;;;GAcG;AACH,MAAM,UAAU,cAAc,CAC5B,IAAY,EACZ,IAAY,EACZ,QAAgB,EAChB,SAAyB,EAAE;IAE3B,SAAS,IAAI,CAAC,GAAW;QACvB,MAAM,IAAI,KAAK,CAAC,cAAc,IAAI,IAAI,GAAG,EAAE,CAAC,CAAC;IAC/C,CAAC;IAED,6FAA6F;IAC7F,oFAAoF;IACpF,yEAAyE;IACzE,MAAM,KAAK,GAAG,MAAM,CAAC,SAAS,IAAI,GAAG,CAAC;IACtC,MAAM,IAAI,GAAG,IAAI,CAAC,MAAM,GAAG,QAAQ,CAAC,MAAM,CAAC;IAC3C,IAAI,IAAI,GAAG,KAAK;QAAE,IAAI,CAAC,WAAW,MAAM,CAAC,IAAI,CAAC,iDAAiD,CAAC,CAAC;IACjG,IAAI,qBAAqB,CAAC,IAAI,CAAC,IAAI,CAAC;QAAE,IAAI,CAAC,sBAAsB,CAAC,CAAC;IACnE,IAAI,MAAM,CAAC,IAAI,KAAK,SAAS,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,eAAe,MAAM,CAAC,IAAI,GAAG,CAAC;QAC5E,IAAI,CAAC,oBAAoB,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC;IAE1C,6FAA6F;IAC7F,8FAA8F;IAC9F,kBAAkB;IAClB,MAAM,WAAW,GAAG,sBAAsB,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACtD,IAAI,WAAW;QAAE,IAAI,CAAC,+CAA+C,WAAW,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;IAEvF,0FAA0F;IAC1F,+FAA+F;IAC/F,gFAAgF;IAChF,6FAA6F;IAC7F,0DAA0D;IAC1D,MAAM,MAAM,GAAG,sBAAsB,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACjD,IAAI,CAAC,MAAM;QAAE,IAAI,CAAC,4BAA4B,CAAC,CAAC;IAChD,MAAM,KAAK,GAAG,MAAM,CAAC,KAAK,GAAG,MAAM,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC;IAC9C,MAAM,IAAI,GAAG,IAAI;SACd,KAAK,CAAC,KAAK,EAAE,KAAK,GAAG,GAAG,CAAC;SACzB,OAAO,CAAC,aAAa,EAAE,EAAE,CAAC;SAC1B,SAAS,EAAE,CAAC;IAEf,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC;QAAE,IAAI,CAAC,gCAAgC,CAAC,CAAC;IAClE,IAAI,sBAAsB,CAAC,IAAI,CAAC,IAAI,CAAC;QACnC,IAAI,CAAC,oFAAoF,CAAC,CAAC;AAC/F,CAAC"}
@@ -0,0 +1,23 @@
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 { assertRendered, bakeHead, EMPTY_ROOT, ogLocale, type Alternate, type HeadTags, type RenderedChecks, } from "./head.ts";
19
+ export { loadRenderer, loadTemplate, writeDist, writeOgCards, type OgCard, type WriteOgCardsOptions, } from "./node.ts";
20
+ export { describeOverflow, fitText, OG_CANVAS, type FitOptions, type FitResult, type OgOverflow, } from "./og.ts";
21
+ export type { PageRenderer } from "./render.ts";
22
+ export { ogImagePath, pageFile, pageSlug, robotsTxt, siteOrigin, sitemapFor, sitemapXml, type PublicPage, type RobotsOptions, type SitemapEntry, } from "./sitemap.ts";
23
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AACH,OAAO,EACL,cAAc,EACd,QAAQ,EACR,UAAU,EACV,QAAQ,EACR,KAAK,SAAS,EACd,KAAK,QAAQ,EACb,KAAK,cAAc,GACpB,MAAM,WAAW,CAAC;AACnB,OAAO,EACL,YAAY,EACZ,YAAY,EACZ,SAAS,EACT,YAAY,EACZ,KAAK,MAAM,EACX,KAAK,mBAAmB,GACzB,MAAM,WAAW,CAAC;AACnB,OAAO,EACL,gBAAgB,EAChB,OAAO,EACP,SAAS,EACT,KAAK,UAAU,EACf,KAAK,SAAS,EACd,KAAK,UAAU,GAChB,MAAM,SAAS,CAAC;AAKjB,YAAY,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAChD,OAAO,EACL,WAAW,EACX,QAAQ,EACR,QAAQ,EACR,SAAS,EACT,UAAU,EACV,UAAU,EACV,UAAU,EACV,KAAK,UAAU,EACf,KAAK,aAAa,EAClB,KAAK,YAAY,GAClB,MAAM,cAAc,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,22 @@
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 { assertRendered, bakeHead, EMPTY_ROOT, ogLocale, } from "./head.js";
19
+ export { loadRenderer, loadTemplate, writeDist, writeOgCards, } from "./node.js";
20
+ export { describeOverflow, fitText, OG_CANVAS, } from "./og.js";
21
+ export { ogImagePath, pageFile, pageSlug, robotsTxt, siteOrigin, sitemapFor, sitemapXml, } from "./sitemap.js";
22
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AACH,OAAO,EACL,cAAc,EACd,QAAQ,EACR,UAAU,EACV,QAAQ,GAIT,MAAM,WAAW,CAAC;AACnB,OAAO,EACL,YAAY,EACZ,YAAY,EACZ,SAAS,EACT,YAAY,GAGb,MAAM,WAAW,CAAC;AACnB,OAAO,EACL,gBAAgB,EAChB,OAAO,EACP,SAAS,GAIV,MAAM,SAAS,CAAC;AAMjB,OAAO,EACL,WAAW,EACX,QAAQ,EACR,QAAQ,EACR,SAAS,EACT,UAAU,EACV,UAAU,EACV,UAAU,GAIX,MAAM,cAAc,CAAC"}
package/dist/node.d.ts ADDED
@@ -0,0 +1,57 @@
1
+ import type { OgOverflow } from "./og.ts";
2
+ import type { PageRenderer } from "./render.ts";
3
+ /**
4
+ * The built `index.html`, and a refusal to bake one twice.
5
+ *
6
+ * `dist/index.html` is BOTH the template and the front page's destination, so a second run over
7
+ * a `dist/` the prerender has already touched would read a finished page as its blank shell and
8
+ * nest one render inside another. `vite build` empties `dist/` and normally makes this
9
+ * impossible; what does not is a restored build cache, or somebody running the script directly
10
+ * to debug it. Caught here, by name, rather than as a missing-root error three frames down.
11
+ */
12
+ export declare function loadTemplate(distDir: string): Promise<string>;
13
+ /**
14
+ * The app compiled for the server, imported out of the BUILT bundle.
15
+ *
16
+ * The prerender is a script rather than a Vite plugin for exactly this reason: it needs the SSR
17
+ * bundle, and a plugin running in `closeBundle` is inside the build that would have to have
18
+ * produced it. So the entry is a path on disk, written by `vite build --ssr src/entry-server.tsx`,
19
+ * and nothing in the source tree references it.
20
+ */
21
+ export declare function loadRenderer<Context = unknown>(entryFile: string): Promise<PageRenderer<Context>>;
22
+ /** Write one file under `dist`, creating the folders a nested route needs. */
23
+ export declare function writeDist(distDir: string, file: string, contents: string): Promise<void>;
24
+ /** One laid-out share card: the bytes, and anything that did not fit. */
25
+ export interface OgCard {
26
+ png: Uint8Array;
27
+ /** Copy the layout could not hold. A single one refuses the whole run — see
28
+ * {@link writeOgCards}. */
29
+ overflow?: readonly OgOverflow[];
30
+ }
31
+ export interface WriteOgCardsOptions<Page> {
32
+ pages: readonly Page[];
33
+ /** Where the cards land — `public/og` in both donors, so they are committed beside the
34
+ * favicons and served as static files. */
35
+ outDir: string;
36
+ /** The file name for one page, inside `outDir`. `(page) => \`${pageSlug(page.path)}.png\``. */
37
+ file: (page: Page) => string;
38
+ /** Lay one card out and rasterize it. The card's art is the product's; this only drives it. */
39
+ card: (page: Page) => OgCard | Promise<OgCard>;
40
+ /** Lay every card out and report, without writing anything. */
41
+ check?: boolean;
42
+ }
43
+ /**
44
+ * Walk a page registry and write one share card per page.
45
+ *
46
+ * Every card is laid out BEFORE any is written, and a single line that does not fit refuses the
47
+ * whole run. That order is the point: a card that cannot hold its copy is a product decision,
48
+ * not something to resolve with an ellipsis — an ellipsis makes every string "fit", so
49
+ * overgrown copy has no failing case and ships a card missing the end of the one line the card
50
+ * exists to carry. The reader who finds out is someone else's link unfurl.
51
+ *
52
+ * Overflow is collected rather than thrown on the first card, so one run names every bad one:
53
+ * copy lands per locale in batches, and a build that dies on the first of six sends its
54
+ * operator round the loop six times.
55
+ */
56
+ export declare function writeOgCards<Page>({ pages, outDir, file, card, check, }: WriteOgCardsOptions<Page>): Promise<string[]>;
57
+ //# sourceMappingURL=node.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"node.d.ts","sourceRoot":"","sources":["../src/node.ts"],"names":[],"mappings":"AAiBA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AAE1C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAEhD;;;;;;;;GAQG;AACH,wBAAsB,YAAY,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAUnE;AAWD;;;;;;;GAOG;AACH,wBAAsB,YAAY,CAAC,OAAO,GAAG,OAAO,EAClD,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,YAAY,CAAC,OAAO,CAAC,CAAC,CAOhC;AAED,8EAA8E;AAC9E,wBAAsB,SAAS,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAI9F;AAED,yEAAyE;AACzE,MAAM,WAAW,MAAM;IACrB,GAAG,EAAE,UAAU,CAAC;IAChB;gCAC4B;IAC5B,QAAQ,CAAC,EAAE,SAAS,UAAU,EAAE,CAAC;CAClC;AAED,MAAM,WAAW,mBAAmB,CAAC,IAAI;IACvC,KAAK,EAAE,SAAS,IAAI,EAAE,CAAC;IACvB;+CAC2C;IAC3C,MAAM,EAAE,MAAM,CAAC;IACf,+FAA+F;IAC/F,IAAI,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,MAAM,CAAC;IAC7B,+FAA+F;IAC/F,IAAI,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAC/C,+DAA+D;IAC/D,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED;;;;;;;;;;;;GAYG;AACH,wBAAsB,YAAY,CAAC,IAAI,EAAE,EACvC,KAAK,EACL,MAAM,EACN,IAAI,EACJ,IAAI,EACJ,KAAa,GACd,EAAE,mBAAmB,CAAC,IAAI,CAAC,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAsB/C"}