@gusnips/vite 0.8.34 → 0.8.36
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.
- package/README.md +20 -20
- package/dist/catalog.d.ts +5 -6
- package/dist/catalog.d.ts.map +1 -1
- package/dist/catalog.js +5 -6
- package/dist/catalog.js.map +1 -1
- package/dist/contrast.js +8 -8
- package/dist/contrast.js.map +1 -1
- package/dist/head.d.ts +35 -36
- package/dist/head.d.ts.map +1 -1
- package/dist/head.js +20 -20
- package/dist/head.js.map +1 -1
- package/dist/i18n.d.ts +8 -7
- package/dist/i18n.d.ts.map +1 -1
- package/dist/i18n.js +5 -4
- package/dist/i18n.js.map +1 -1
- package/dist/icons.d.ts +6 -6
- package/dist/icons.d.ts.map +1 -1
- package/dist/icons.js +2 -2
- package/dist/icons.js.map +1 -1
- package/dist/index.d.ts +7 -7
- package/dist/index.js +7 -7
- package/dist/node.d.ts +24 -25
- package/dist/node.d.ts.map +1 -1
- package/dist/node.js +26 -26
- package/dist/node.js.map +1 -1
- package/dist/og.d.ts +7 -7
- package/dist/og.d.ts.map +1 -1
- package/dist/og.js +2 -2
- package/dist/og.js.map +1 -1
- package/dist/pre-paint.d.ts +3 -3
- package/dist/preset.d.ts +9 -9
- package/dist/preset.js +3 -3
- package/dist/render.d.ts +9 -9
- package/dist/render.js +4 -4
- package/dist/render.js.map +1 -1
- package/dist/sitemap.d.ts +26 -27
- package/dist/sitemap.d.ts.map +1 -1
- package/dist/sitemap.js +18 -18
- package/dist/sitemap.js.map +1 -1
- package/dist/theme.d.ts +1 -2
- package/dist/theme.d.ts.map +1 -1
- package/dist/theme.js +1 -2
- package/dist/theme.js.map +1 -1
- package/package.json +2 -2
- package/src/catalog.ts +5 -6
- package/src/contrast.ts +8 -8
- package/src/head.ts +36 -37
- package/src/i18n.ts +8 -7
- package/src/icons.ts +7 -7
- package/src/index.ts +7 -7
- package/src/node.ts +28 -29
- package/src/og.ts +8 -8
- package/src/pre-paint.ts +3 -3
- package/src/preset.ts +9 -9
- package/src/render.ts +10 -10
- package/src/sitemap.ts +27 -28
- package/src/theme.ts +1 -2
package/src/head.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import { PRERENDERED_ROUTE_ATTR } from "@gusnips/react/contract";
|
|
16
16
|
import { escapeAttr, escapeRegex } from "./escape.ts";
|
|
17
17
|
|
|
18
|
-
/** The empty root a template must carry
|
|
18
|
+
/** The empty root a template must carry. `bakeHead` fills it and refuses a filled one. */
|
|
19
19
|
export const EMPTY_ROOT = '<div id="root"></div>';
|
|
20
20
|
|
|
21
21
|
/**
|
|
@@ -106,7 +106,7 @@ function setMetaIfPresent(html: string, patterns: readonly RegExp[], value: stri
|
|
|
106
106
|
return rewriteAttr(html, patterns, value) ?? html;
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
-
/** One `<link rel="alternate" hreflang>`
|
|
109
|
+
/** One `<link rel="alternate" hreflang>` for this page's address in another language. */
|
|
110
110
|
export interface Alternate {
|
|
111
111
|
/** A BCP-47 tag, or `x-default` for the address a crawler should show when it has no reason
|
|
112
112
|
* to prefer one. */
|
|
@@ -122,17 +122,16 @@ export interface HeadTags {
|
|
|
122
122
|
/**
|
|
123
123
|
* The page's own URL, for `<link rel="canonical">` and the share tags.
|
|
124
124
|
*
|
|
125
|
-
* `null` means this page has no canonical URL and must not claim one
|
|
126
|
-
* is served for every address that does not exist and would otherwise tell a crawler that
|
|
125
|
+
* `null` means this page has no canonical URL and must not claim one, as with the 404 shell,
|
|
126
|
+
* which is served for every address that does not exist and would otherwise tell a crawler that
|
|
127
127
|
* all of them are the front page.
|
|
128
128
|
*/
|
|
129
129
|
canonical: string | null;
|
|
130
130
|
ogTitle?: string;
|
|
131
131
|
ogDescription?: string;
|
|
132
|
-
/** Absolute URL of the share card. Omit where the template carries no `og:image` tag
|
|
133
|
-
* — setting one there is a build error, by design. */
|
|
132
|
+
/** Absolute URL of the share card. Omit where the template carries no `og:image` tag. Setting one there is a build error, by design. */
|
|
134
133
|
image?: string;
|
|
135
|
-
/** Markup inserted before `</head>`
|
|
134
|
+
/** Markup inserted before `</head>` for structured data, and nothing else so far. Emitted here
|
|
136
135
|
* because its only reader is a crawler. */
|
|
137
136
|
headExtra?: string;
|
|
138
137
|
/** Keep this page out of every index. Pairs with `canonical: null`. */
|
|
@@ -142,8 +141,8 @@ export interface HeadTags {
|
|
|
142
141
|
* goes with it.
|
|
143
142
|
*
|
|
144
143
|
* A crawler and a screen reader both read `lang`, and neither runs the bundle that would
|
|
145
|
-
* otherwise set it
|
|
146
|
-
*
|
|
144
|
+
* otherwise set it, so a Portuguese file whose `<html>` still says `en` is announced in the wrong
|
|
145
|
+
* voice and indexed as the wrong language.
|
|
147
146
|
*/
|
|
148
147
|
lang?: string;
|
|
149
148
|
/**
|
|
@@ -158,14 +157,14 @@ export interface HeadTags {
|
|
|
158
157
|
* The rendered page, injected into `<div id="root">`, and the route it is a render OF.
|
|
159
158
|
*
|
|
160
159
|
* The head alone was never enough. A shipped `<div id="root"></div>` is a page whose entire
|
|
161
|
-
* content is a description tag
|
|
162
|
-
*
|
|
160
|
+
* content is a description tag. It can be listed, and it can never be read, quoted or answered
|
|
161
|
+
* from.
|
|
163
162
|
*
|
|
164
|
-
* The two travel together because markup alone is not enough to hydrate against. A static
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
163
|
+
* The two travel together because markup alone is not enough to hydrate against. A static host
|
|
164
|
+
* answers every unpublished address with the nearest `404.html`, and that file has a rendered
|
|
165
|
+
* body in it, so a route served from the shell would find a full root and hydrate the not-found
|
|
166
|
+
* page into a page that is not it. The marker is what the browser entry compares its own route
|
|
167
|
+
* against before deciding to hydrate or to mount fresh.
|
|
169
168
|
*/
|
|
170
169
|
body?: { route: string; html: string };
|
|
171
170
|
}
|
|
@@ -174,18 +173,18 @@ export interface HeadTags {
|
|
|
174
173
|
* A BCP-47 tag in Open Graph's spelling: underscore, and a TERRITORY it will not infer.
|
|
175
174
|
*
|
|
176
175
|
* `og:locale` wants `language_TERRITORY` and quietly ignores anything else, which is the same
|
|
177
|
-
* outcome as omitting it
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
176
|
+
* outcome as omitting it: the `en_US` default. So a Portuguese page with a Portuguese `og:title`
|
|
177
|
+
* and no `og:locale` tells every share crawler the card is English. `pt-BR` already carries its
|
|
178
|
+
* territory; `en` and `es` do not, so one is chosen here rather than left to a crawler. `es_ES` is
|
|
179
|
+
* not a claim that the copy is peninsular Spanish. It is the most widely recognised Spanish value,
|
|
180
|
+
* and the tag's job is to be understood, not precise about dialect.
|
|
182
181
|
*/
|
|
183
182
|
export function ogLocale(tag: string): string {
|
|
184
183
|
const TERRITORY: Record<string, string> = { en: "en_US", es: "es_ES" };
|
|
185
184
|
return TERRITORY[tag] ?? tag.replace("-", "_");
|
|
186
185
|
}
|
|
187
186
|
|
|
188
|
-
/** One question and its answer, as the page renders them
|
|
187
|
+
/** One question and its answer, as the page renders them. Pass the same strings, so the
|
|
189
188
|
* structured data cannot drift from the copy. One donor's template carried four questions
|
|
190
189
|
* where the page renders seven, and nothing compared them until the migration. */
|
|
191
190
|
export interface FaqEntry {
|
|
@@ -196,11 +195,11 @@ export interface FaqEntry {
|
|
|
196
195
|
/**
|
|
197
196
|
* FAQ structured data for `headExtra`, with the script-closing sequence escaped.
|
|
198
197
|
*
|
|
199
|
-
* A `</script>` inside an answer ends the element early and ships the rest of the JSON as
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
198
|
+
* A `</script>` inside an answer ends the element early and ships the rest of the JSON as markup.
|
|
199
|
+
* `<\/` is the same string to a JSON parser and inert to an HTML one, so every `</` becomes `<\/`
|
|
200
|
+
* and `<!--` becomes `<\!--`. `JSON.stringify` never emits `<` outside a string, since the
|
|
201
|
+
* structural characters are all brackets, quotes and digits. The replacement only ever touches
|
|
202
|
+
* copy.
|
|
204
203
|
*/
|
|
205
204
|
export function faqJsonLd(faqs: readonly FaqEntry[]): string {
|
|
206
205
|
const data = {
|
|
@@ -219,9 +218,9 @@ export function faqJsonLd(faqs: readonly FaqEntry[]): string {
|
|
|
219
218
|
/**
|
|
220
219
|
* One page's `<head>` and body, written into the built template.
|
|
221
220
|
*
|
|
222
|
-
* Every tag is SET rather than appended, and `setMeta` throws on a tag the template does not
|
|
223
|
-
*
|
|
224
|
-
*
|
|
221
|
+
* Every tag is SET rather than appended, and `setMeta` throws on a tag the template does not carry,
|
|
222
|
+
* so the failure mode of editing `index.html` is a red build, never a page quietly shipping
|
|
223
|
+
* somebody else's description.
|
|
225
224
|
*/
|
|
226
225
|
export function bakeHead(template: string, tags: HeadTags): string {
|
|
227
226
|
const shareTitle = tags.ogTitle ?? tags.title;
|
|
@@ -345,7 +344,7 @@ export function bakeHead(template: string, tags: HeadTags): string {
|
|
|
345
344
|
}
|
|
346
345
|
|
|
347
346
|
export interface RenderedChecks {
|
|
348
|
-
/** The BCP-47 tag this file must be marked with
|
|
347
|
+
/** The BCP-47 tag this file must be marked with. Pass the same `lang` to `bakeHead`. */
|
|
349
348
|
lang?: string;
|
|
350
349
|
/** Bytes the page must have gained over the template before it counts as rendered.
|
|
351
350
|
* 500 is the floor both donor builds ran with. */
|
|
@@ -375,11 +374,11 @@ const RENDERER_ERROR =
|
|
|
375
374
|
/**
|
|
376
375
|
* What every written file must be true of before the build is allowed to pass.
|
|
377
376
|
*
|
|
378
|
-
* The regression this exists for is a root that renders to nothing. A router whose location
|
|
379
|
-
*
|
|
380
|
-
*
|
|
381
|
-
*
|
|
382
|
-
*
|
|
377
|
+
* The regression this exists for is a root that renders to nothing. A router whose location does
|
|
378
|
+
* not match its routes yields empty markup with no error and no warning. It looks fine in every
|
|
379
|
+
* browser and is invisible to everything that reads a link. Checking the bytes we actually wrote is
|
|
380
|
+
* the only thing that catches it, and it needs no browser, so it gates the BUILD rather than
|
|
381
|
+
* sitting in a test suite.
|
|
383
382
|
*
|
|
384
383
|
* The loading-screen check is the half a size floor misses. With `lazy()` routes behind one
|
|
385
384
|
* `<Suspense fallback={<Spinner />}>`, a render that resolves nothing still produces a
|
|
@@ -455,5 +454,5 @@ export function assertRendered(
|
|
|
455
454
|
// guard missed it from the other side: it failed a file containing a spinner class its splash
|
|
456
455
|
// does not use. Two checks written for one regression, neither of which could ever fire.
|
|
457
456
|
if (grew < SPLASH_MAX_GROWTH && head.includes('role="status"'))
|
|
458
|
-
fail("rendered the loading screen, not the page
|
|
457
|
+
fail("rendered the loading screen, not the page. Something suspended and never resolved");
|
|
459
458
|
}
|
package/src/i18n.ts
CHANGED
|
@@ -43,7 +43,7 @@ export interface I18nBundle {
|
|
|
43
43
|
/** The app's `fallbackLng`: the language the others are compared to, and code keys must be in. */
|
|
44
44
|
canonical: string;
|
|
45
45
|
/**
|
|
46
|
-
* One language's catalogs. A throw fails the check with its message
|
|
46
|
+
* One language's catalogs. A throw fails the check with its message. It never skips the bundle,
|
|
47
47
|
* because every other rule compares languages and a missing one would pass them all.
|
|
48
48
|
*/
|
|
49
49
|
load: (locale: string) => Catalogs | Promise<Catalogs>;
|
|
@@ -388,8 +388,8 @@ async function files(root: string, patterns: readonly string[]): Promise<string[
|
|
|
388
388
|
export interface MergeOptions {
|
|
389
389
|
/**
|
|
390
390
|
* Refuse two fragments that share a top-level key. Set it when the app merges fragments at
|
|
391
|
-
* runtime with a spread
|
|
392
|
-
*
|
|
391
|
+
* runtime with a spread, `{ ...auth.en, ...nav.en }`, where the second `nav` replaces the first
|
|
392
|
+
* instead of joining it, and the keys of one fragment vanish with no error anywhere.
|
|
393
393
|
*/
|
|
394
394
|
ownTopLevel?: boolean;
|
|
395
395
|
}
|
|
@@ -446,10 +446,11 @@ function sortTree(tree: Tree): Tree {
|
|
|
446
446
|
}
|
|
447
447
|
|
|
448
448
|
/**
|
|
449
|
-
* Fold `dir/*.json`
|
|
450
|
-
*
|
|
451
|
-
* parallel do not all edit the same three files. Deterministic: files in name order, keys
|
|
452
|
-
* so two runs write the same bytes and a derived catalog can be compared with a fresh
|
|
449
|
+
* Fold `dir/*.json` into one catalog per language. Each file holds one area, with languages side by
|
|
450
|
+
* side: `{ "en": {…}, "pt-BR": {…} }`. Fragments exist so that people and agents building screens
|
|
451
|
+
* in parallel do not all edit the same three files. Deterministic: files in name order, keys
|
|
452
|
+
* sorted, so two runs write the same bytes and a derived catalog can be compared with a fresh
|
|
453
|
+
* merge.
|
|
453
454
|
*/
|
|
454
455
|
export async function mergeFragments(
|
|
455
456
|
dir: string,
|
package/src/icons.ts
CHANGED
|
@@ -18,7 +18,7 @@ export type RenderIcon = (svg: string, size: number) => Uint8Array | Promise<Uin
|
|
|
18
18
|
export type PackIco = (pngs: readonly Uint8Array[]) => Uint8Array | Promise<Uint8Array>;
|
|
19
19
|
|
|
20
20
|
export interface WriteIconSetOptions {
|
|
21
|
-
/** Where the files land
|
|
21
|
+
/** Where the files land. Every donor uses `public/`, so they are served as static files. */
|
|
22
22
|
outDir: string;
|
|
23
23
|
/** The one master artwork. A single path with no variants is what keeps every size in step. */
|
|
24
24
|
svg: string;
|
|
@@ -27,9 +27,9 @@ export interface WriteIconSetOptions {
|
|
|
27
27
|
/** The file name for one PNG size. Default `` `icon-${size}.png` ``. */
|
|
28
28
|
file?: (size: number) => string;
|
|
29
29
|
/**
|
|
30
|
-
* The size written as `apple-touch-icon.png`.
|
|
31
|
-
* asks for. Reuses the PNG render when it is already in `pngSizes`, so the common case costs
|
|
32
|
-
*
|
|
30
|
+
* The size written as `apple-touch-icon.png`. Defaults to 180, the size the reader's home screen
|
|
31
|
+
* asks for. Reuses the PNG render when it is already in `pngSizes`, so the common case costs no
|
|
32
|
+
* extra raster.
|
|
33
33
|
*/
|
|
34
34
|
appleTouchSize?: number;
|
|
35
35
|
/** Sizes re-rendered and packed into `favicon.ico`, e.g. `[16, 32, 48]`. */
|
|
@@ -44,7 +44,7 @@ export interface WriteIconSetOptions {
|
|
|
44
44
|
* Render one icon set and write it, plus the multi-size `favicon.ico`.
|
|
45
45
|
*
|
|
46
46
|
* Every size is rendered BEFORE anything is written, so a renderer that throws leaves no
|
|
47
|
-
* half-written set behind
|
|
47
|
+
* half-written set behind. This is the same order `writeOgCards` keeps for the same reason.
|
|
48
48
|
*/
|
|
49
49
|
export async function writeIconSet({
|
|
50
50
|
outDir,
|
|
@@ -100,7 +100,7 @@ export interface WebManifestOptions {
|
|
|
100
100
|
display?: string;
|
|
101
101
|
backgroundColor?: string;
|
|
102
102
|
themeColor?: string;
|
|
103
|
-
/** At least one
|
|
103
|
+
/** At least one icon is required. A manifest with no icons fails installability with no error anywhere. */
|
|
104
104
|
icons: readonly ManifestIcon[];
|
|
105
105
|
}
|
|
106
106
|
|
|
@@ -121,7 +121,7 @@ export function webManifest({
|
|
|
121
121
|
}: WebManifestOptions): string {
|
|
122
122
|
if (name.trim() === "") throw new Error("manifest: `name` is empty");
|
|
123
123
|
if (icons.length === 0)
|
|
124
|
-
throw new Error("manifest: `icons` is empty
|
|
124
|
+
throw new Error("manifest: `icons` is empty. Name what writeIconSet wrote");
|
|
125
125
|
const manifest: Record<string, unknown> = { name };
|
|
126
126
|
if (shortName !== undefined) manifest["short_name"] = shortName;
|
|
127
127
|
if (description !== undefined) manifest["description"] = description;
|
package/src/index.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The build-time half of a prerendered Vite + React SPA.
|
|
3
3
|
*
|
|
4
|
-
* A Vite SPA ships one `index.html` and draws the rest with JavaScript. Nothing that reads a
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* A Vite SPA ships one `index.html` and draws the rest with JavaScript. Nothing that reads a link
|
|
5
|
+
* for a living runs that bundle, whether it is a search crawler, an LLM or the thing that draws the
|
|
6
|
+
* preview card in a chat app. So the build renders every public route to a real file with a real
|
|
7
|
+
* `<head>` and a real body, and this is the part every app doing that shares.
|
|
8
8
|
*
|
|
9
9
|
* Four repos wrote it independently and each learned something the others had not. What is
|
|
10
10
|
* here is the merge; every non-obvious rule carries the reason it exists.
|
|
@@ -13,9 +13,9 @@
|
|
|
13
13
|
* otherwise force on everyone: the vite config preset at `@gusnips/vite/preset` (the React and
|
|
14
14
|
* Tailwind plugins), `renderTree` at `@gusnips/vite/render`, the theme pre-paint plugin at
|
|
15
15
|
* `@gusnips/vite/theme` (both React itself), the catalog check at `@gusnips/vite/i18n`, and the
|
|
16
|
-
* contrast gate at `@gusnips/vite/contrast` (tailwindcss)
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* contrast gate at `@gusnips/vite/contrast` (tailwindcss). The last two run in CI and never in a
|
|
17
|
+
* build. Nothing here imports React, vite or tailwindcss, so a prerender script, an OG generator
|
|
18
|
+
* and a repo that only wants a sitemap all install exactly what they use.
|
|
19
19
|
*/
|
|
20
20
|
export { resolveKey } from "./catalog.ts";
|
|
21
21
|
// `headExtra` takes raw markup, so the moment an adopter builds a tag out of its own copy it
|
package/src/node.ts
CHANGED
|
@@ -35,7 +35,7 @@ export async function loadTemplate(distDir: string): Promise<string> {
|
|
|
35
35
|
if (!template.includes(EMPTY_ROOT))
|
|
36
36
|
throw new Error(
|
|
37
37
|
`prerender: ${file} does not carry ${EMPTY_ROOT}. Either it is already a rendered page ` +
|
|
38
|
-
"
|
|
38
|
+
"(run `vite build` to regenerate the shell this reads), or the app's root element " +
|
|
39
39
|
"carries attributes, which the baker cannot fill.",
|
|
40
40
|
);
|
|
41
41
|
return template;
|
|
@@ -58,16 +58,16 @@ function exportsRenderer<Context>(mod: unknown): mod is { renderPage: PageRender
|
|
|
58
58
|
* produced it. So the entry is a path on disk, written by `vite build --ssr src/entry-server.tsx`,
|
|
59
59
|
* and nothing in the source tree references it.
|
|
60
60
|
*
|
|
61
|
-
* **The calling script has to exit itself.** This import is where the renderer enters your
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
61
|
+
* **The calling script has to exit itself.** This import is where the renderer enters your process,
|
|
62
|
+
* and under bun `react-dom/static.browser`, which invariant 1 requires every app on this rig to
|
|
63
|
+
* load, leaves that process alive after its last await. Measured by elimination with no app code at
|
|
64
|
+
* all: `react-dom/server` exits, `static.browser` does not, and `process.getActiveResourcesInfo()`
|
|
65
|
+
* reports nothing either way, so the handle is invisible to everything except the hang. Under node
|
|
66
|
+
* it exits. Nothing here can undo it, because a library must not call `process.exit` on its host. A
|
|
67
|
+
* prerender script ends with `process.exit(0)` once every file is written, and in CI the
|
|
68
|
+
* alternative is a job that does all its work and then runs to its timeout. Five adopters had
|
|
69
|
+
* already written that line against a DIFFERENT holder (an auth client whose token-refresh timer
|
|
70
|
+
* starts at module scope); none of them knew the renderer does it too.
|
|
71
71
|
*/
|
|
72
72
|
export async function loadRenderer<Context = unknown>(
|
|
73
73
|
entryFile: string,
|
|
@@ -75,7 +75,7 @@ export async function loadRenderer<Context = unknown>(
|
|
|
75
75
|
const mod: unknown = await import(pathToFileURL(entryFile).href);
|
|
76
76
|
if (!exportsRenderer<Context>(mod))
|
|
77
77
|
throw new Error(
|
|
78
|
-
`prerender: ${entryFile} does not export renderPage
|
|
78
|
+
`prerender: ${entryFile} does not export renderPage. Run \`vite build --ssr\` first`,
|
|
79
79
|
);
|
|
80
80
|
return mod.renderPage;
|
|
81
81
|
}
|
|
@@ -90,14 +90,13 @@ export async function writeDist(distDir: string, file: string, contents: string)
|
|
|
90
90
|
/** One laid-out share card: the bytes, and anything that did not fit. */
|
|
91
91
|
export interface OgCard {
|
|
92
92
|
png: Uint8Array;
|
|
93
|
-
/** Copy the layout could not hold. A single one refuses the whole run
|
|
94
|
-
* {@link writeOgCards}. */
|
|
93
|
+
/** Copy the layout could not hold. A single one refuses the whole run. See {@link writeOgCards}. */
|
|
95
94
|
overflow?: readonly OgOverflow[];
|
|
96
95
|
}
|
|
97
96
|
|
|
98
97
|
export interface WriteOgCardsOptions<Page> {
|
|
99
98
|
pages: readonly Page[];
|
|
100
|
-
/** Where the cards land
|
|
99
|
+
/** Where the cards land. Both donors use `public/og`, so they are committed beside the
|
|
101
100
|
* favicons and served as static files. */
|
|
102
101
|
outDir: string;
|
|
103
102
|
/** The file name for one page, inside `outDir`. `(page) => \`${pageSlug(page.path)}.png\``. */
|
|
@@ -112,10 +111,10 @@ export interface WriteOgCardsOptions<Page> {
|
|
|
112
111
|
* Walk a page registry and write one share card per page.
|
|
113
112
|
*
|
|
114
113
|
* Every card is laid out BEFORE any is written, and a single line that does not fit refuses the
|
|
115
|
-
* whole run. That order is the point: a card that cannot hold its copy is a product decision,
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
114
|
+
* whole run. That order is the point: a card that cannot hold its copy is a product decision, not
|
|
115
|
+
* something to resolve with an ellipsis. An ellipsis makes every string "fit", so overgrown copy
|
|
116
|
+
* has no failing case and ships a card missing the end of the one line the card exists to carry.
|
|
117
|
+
* The reader who finds out is someone else's link unfurl.
|
|
119
118
|
*
|
|
120
119
|
* Overflow is collected rather than thrown on the first card, so one run names every bad one:
|
|
121
120
|
* copy lands per locale in batches, and a build that dies on the first of six sends its
|
|
@@ -139,7 +138,7 @@ export async function writeOgCards<Page>({
|
|
|
139
138
|
|
|
140
139
|
if (overflows.length > 0)
|
|
141
140
|
throw new Error(
|
|
142
|
-
`og: ${String(overflows.length)} card(s) cannot hold their copy
|
|
141
|
+
`og: ${String(overflows.length)} card(s) cannot hold their copy:\n\n` +
|
|
143
142
|
overflows.map((o) => ` · ${describeOverflow(o)}`).join("\n\n") +
|
|
144
143
|
"\n\n Shorten the copy, or change the size ladder deliberately. Nothing was written.",
|
|
145
144
|
);
|
|
@@ -201,11 +200,11 @@ async function isFile(path: string): Promise<boolean> {
|
|
|
201
200
|
* Every share card a built page advertises is a file that exists.
|
|
202
201
|
*
|
|
203
202
|
* Three repos shipped a page whose `og:image` named a card nothing had ever rendered. All three
|
|
204
|
-
* were `404.html`, all three for the same reason: a card generator walks the page registry, and
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
203
|
+
* were `404.html`, all three for the same reason: a card generator walks the page registry, and a
|
|
204
|
+
* not-found shell is not in the registry, so a head built from the same template inherits a card
|
|
205
|
+
* address with no card behind it. Every share of a dead link unfurled broken, in one case in three
|
|
206
|
+
* languages, and **nothing in a browser shows it**. The page looks perfect, and the only reader who
|
|
207
|
+
* finds out is whoever the link was sent to.
|
|
209
208
|
*
|
|
210
209
|
* `bakeHead` cannot catch it: it is handed an `image` and writes it, and a shell carrying the
|
|
211
210
|
* brand card is correct, so it has no way to tell a right image from a wrong one (invariant 12).
|
|
@@ -216,9 +215,9 @@ async function isFile(path: string): Promise<boolean> {
|
|
|
216
215
|
* await assertOgImages("dist", "https://example.com");
|
|
217
216
|
* ```
|
|
218
217
|
*
|
|
219
|
-
* Cards on another host are somebody else's to serve and are skipped
|
|
220
|
-
*
|
|
221
|
-
*
|
|
218
|
+
* Cards on another host are somebody else's to serve and are skipped. But a build whose pages all
|
|
219
|
+
* advertise cards and whose origin matches NONE of them has been given the wrong origin, and a
|
|
220
|
+
* check that silently passes is worse than no check, so that is an error too.
|
|
222
221
|
*/
|
|
223
222
|
export async function assertOgImages(distDir: string, origin: string): Promise<number> {
|
|
224
223
|
const root = siteOrigin(origin);
|
|
@@ -247,7 +246,7 @@ export async function assertOgImages(distDir: string, origin: string): Promise<n
|
|
|
247
246
|
|
|
248
247
|
if (missing.length > 0)
|
|
249
248
|
throw new Error(
|
|
250
|
-
`og: ${String(missing.length)} page(s) advertise a share card that is not in ${distDir}
|
|
249
|
+
`og: ${String(missing.length)} page(s) advertise a share card that is not in ${distDir}:\n\n` +
|
|
251
250
|
missing.map((card) => ` · ${card.page} → ${card.url}`).join("\n") +
|
|
252
251
|
"\n\n Either render the card, or leave `image` out of the head for that page.",
|
|
253
252
|
);
|
package/src/og.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Fitting copy into a share card.
|
|
3
3
|
*
|
|
4
|
-
* The card's ART
|
|
4
|
+
* The card's ART, including the palette, lockup and size ladder, is the product, and it stays in
|
|
5
5
|
* the product. What ships here is the part two independent generators got wrong the same way:
|
|
6
6
|
* deciding what happens when a headline does not fit.
|
|
7
7
|
*
|
|
@@ -19,7 +19,7 @@ export interface OgOverflow {
|
|
|
19
19
|
/** The string as given. */
|
|
20
20
|
text: string;
|
|
21
21
|
maxLines: number;
|
|
22
|
-
/** Characters per line at this size
|
|
22
|
+
/** Characters per line at this size, the budget the text exceeded. */
|
|
23
23
|
maxChars: number;
|
|
24
24
|
size: number;
|
|
25
25
|
}
|
|
@@ -34,11 +34,11 @@ export interface FitOptions {
|
|
|
34
34
|
/**
|
|
35
35
|
* Average glyph advance as a fraction of the font size.
|
|
36
36
|
*
|
|
37
|
-
* Character-budget estimation rather than real metrics: an SVG rasterizer gives no measuring
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
37
|
+
* Character-budget estimation rather than real metrics: an SVG rasterizer gives no measuring API,
|
|
38
|
+
* and card copy is short enough that the estimate never drifts more than a word. Measure it off a
|
|
39
|
+
* rendered card and round UP. A budget that is too generous overflows the column, while one that
|
|
40
|
+
* is too mean only breaks a line early. Donor values: ~0.5 for a display face at headline sizes,
|
|
41
|
+
* ~0.46 for body copy.
|
|
42
42
|
*/
|
|
43
43
|
advance: number;
|
|
44
44
|
/** Names this line in an overflow report. `"pricing headline"`, not `"line 1"`. */
|
|
@@ -96,7 +96,7 @@ export function fitText(text: string, options: FitOptions): FitResult {
|
|
|
96
96
|
/** One overflow, as the line an operator reads in a failed build. */
|
|
97
97
|
export function describeOverflow(overflow: OgOverflow): string {
|
|
98
98
|
return (
|
|
99
|
-
`${overflow.label}
|
|
99
|
+
`${overflow.label}: ${String(overflow.maxLines)} lines of ~${String(overflow.maxChars)} ` +
|
|
100
100
|
`chars at ${String(overflow.size)}px, ${String(overflow.text.length)} chars given\n` +
|
|
101
101
|
` ${JSON.stringify(overflow.text)}`
|
|
102
102
|
);
|
package/src/pre-paint.ts
CHANGED
|
@@ -26,10 +26,10 @@ export interface PrePaintScriptOptions {
|
|
|
26
26
|
/** The script's full text. It runs alone, so it must not reference anything outside itself. */
|
|
27
27
|
source: string;
|
|
28
28
|
/**
|
|
29
|
-
* Where the tag goes. `"head"` (the end of `<head>`) when the script reads elements above it
|
|
29
|
+
* Where the tag goes. `"head"` (the end of `<head>`) when the script reads elements above it, as
|
|
30
30
|
* the theme reads `<meta name="theme-color">`. `"head-prepend"` when it only needs to run as
|
|
31
|
-
* early as possible: a classic script after a stylesheet waits for that stylesheet, so the
|
|
32
|
-
*
|
|
31
|
+
* early as possible: a classic script after a stylesheet waits for that stylesheet, so the locale
|
|
32
|
+
* gate goes first and redirects without downloading the page's CSS.
|
|
33
33
|
*/
|
|
34
34
|
position?: "head" | "head-prepend";
|
|
35
35
|
}
|
package/src/preset.ts
CHANGED
|
@@ -16,9 +16,9 @@ import type { Plugin, PluginOption, UserConfig } from "vite";
|
|
|
16
16
|
* `index.html` cannot import TypeScript, so a brand name or tagline written there is a literal
|
|
17
17
|
* that drifts from the one the app renders. This injects them from the module that owns them.
|
|
18
18
|
*
|
|
19
|
-
* Vite already replaces `%VITE_FOO%` from the environment, and that is the right tool
|
|
20
|
-
* value
|
|
21
|
-
* belongs in a typed module the app imports, not in a `.env` nobody reviews.
|
|
19
|
+
* Vite already replaces `%VITE_FOO%` from the environment, and that is the right tool for an
|
|
20
|
+
* environment value, such as an API URL or a build id. It is the wrong one for brand identity,
|
|
21
|
+
* which belongs in a typed module the app imports, not in a `.env` nobody reviews.
|
|
22
22
|
*/
|
|
23
23
|
export function htmlPlaceholders(values: Readonly<Record<string, string>>): Plugin {
|
|
24
24
|
return {
|
|
@@ -34,7 +34,7 @@ export function htmlPlaceholders(values: Readonly<Record<string, string>>): Plug
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
export interface WebPresetOptions {
|
|
37
|
-
/** The app folder
|
|
37
|
+
/** The app folder holding `index.html`. In a `vite.config.ts` that is
|
|
38
38
|
* `import.meta.dirname`. `@` resolves to `<root>/src`. */
|
|
39
39
|
root: string;
|
|
40
40
|
/** Dev server port. Two apps in one repo must not share one, which is why it has no clever
|
|
@@ -48,11 +48,11 @@ export interface WebPresetOptions {
|
|
|
48
48
|
/**
|
|
49
49
|
* The workspace scope to bundle into the SSR build, as in `"@acme"`.
|
|
50
50
|
*
|
|
51
|
-
* INSURANCE, not a fix. Measured: Vite already bundles linked workspace dependencies in an
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
51
|
+
* INSURANCE, not a fix. Measured: Vite already bundles linked workspace dependencies in an SSR
|
|
52
|
+
* build. One donor runs without this declaration and its SSR output has zero bare imports. It is
|
|
53
|
+
* here because workspace packages are consumed as TypeScript SOURCE through subpath exports, and
|
|
54
|
+
* leaving them external would hand the runtime `.ts` files with Vite-only semantics in them
|
|
55
|
+
* (aliases, `?raw`, `define`) if that behaviour ever changed.
|
|
56
56
|
*/
|
|
57
57
|
ssrScope?: string;
|
|
58
58
|
/** More plugins, after the preset's own: `[themeScript(THEME)]`. */
|
package/src/render.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* One page, as the markup that goes inside `<div id="root">`.
|
|
3
3
|
*
|
|
4
|
-
* This is the build-time half of `main.tsx`, and the app's `entry-server.tsx` is expected to
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* This is the build-time half of `main.tsx`, and the app's `entry-server.tsx` is expected to be
|
|
5
|
+
* four lines around it: the same `<App />` and the same route table the browser runs, with a
|
|
6
|
+
* `StaticRouter` in place of the history the build does not have. `main.tsx` is deliberately NOT
|
|
7
|
+
* reused because it reads `window.location`, registers listeners and starts analytics at module
|
|
8
8
|
* scope, none of which mean anything here.
|
|
9
9
|
*
|
|
10
10
|
* No `node:` import: this file is bundled into the SSR build by Vite, and it is React's own
|
|
@@ -37,9 +37,9 @@ const HOISTED_HEAD = /^(?:<title>[^<]*<\/title>|<meta\b[^>]*\/?>|<link\b[^>]*\/?
|
|
|
37
37
|
* Render a tree to markup with `prerender` from `react-dom/static`.
|
|
38
38
|
*
|
|
39
39
|
* **Never `renderToString`.** With `lazy()` routes behind a `<Suspense fallback={<Spinner />}>`,
|
|
40
|
-
* `renderToString` renders the FALLBACK
|
|
41
|
-
*
|
|
42
|
-
*
|
|
40
|
+
* `renderToString` renders the FALLBACK. It would write a loading screen into every file and pass
|
|
41
|
+
* every gate that only asks whether the root has children. `prerender` waits for the tree to
|
|
42
|
+
* settle, which is also why this is async and why the whole renderer contract is.
|
|
43
43
|
*
|
|
44
44
|
* Two more halves of the same lesson are here too: `onError` is captured and rethrown, so a
|
|
45
45
|
* render failure fails the build rather than shipping a partial page; and an empty result is
|
|
@@ -59,7 +59,7 @@ export async function renderTree(tree: ReactNode): Promise<string> {
|
|
|
59
59
|
const markup = html.replace(HOISTED_HEAD, "");
|
|
60
60
|
if (markup.trim() === "")
|
|
61
61
|
throw new Error(
|
|
62
|
-
"renderTree: the tree rendered to nothing
|
|
62
|
+
"renderTree: the tree rendered to nothing. Usually a router whose location or basename " +
|
|
63
63
|
"matches no route, which React reports as an empty string rather than an error",
|
|
64
64
|
);
|
|
65
65
|
return markup;
|
|
@@ -68,8 +68,8 @@ export async function renderTree(tree: ReactNode): Promise<string> {
|
|
|
68
68
|
/**
|
|
69
69
|
* What an SSR entry exports, and what {@link loadRenderer} looks for.
|
|
70
70
|
*
|
|
71
|
-
* `context` is opaque on purpose. A multi-locale app builds its i18n instance per call
|
|
72
|
-
* languages render in one process and a shared singleton would have them racing for one `lng
|
|
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
73
|
* while a single-locale app ignores the argument and keeps its singleton.
|
|
74
74
|
*/
|
|
75
75
|
export type PageRenderer<Context = unknown> = (route: string, context?: Context) => Promise<string>;
|