@gusnips/vite 0.8.12 → 0.8.14
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 +13 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/pre-paint.d.ts +16 -0
- package/dist/pre-paint.d.ts.map +1 -0
- package/dist/pre-paint.js +49 -0
- package/dist/pre-paint.js.map +1 -0
- package/dist/theme.d.ts +16 -0
- package/dist/theme.d.ts.map +1 -1
- package/dist/theme.js +6 -40
- package/dist/theme.js.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +2 -0
- package/src/pre-paint.test.ts +66 -0
- package/src/pre-paint.ts +63 -0
- package/src/theme.ts +6 -39
package/README.md
CHANGED
|
@@ -165,6 +165,19 @@ from `@gusnips/react/theme`, so the script before paint and the controller the a
|
|
|
165
165
|
the same function. It is a file rather than an inline script so a `script-src 'self'` policy
|
|
166
166
|
lets it run. Pass it the same options you pass `createTheme`; see that package's README.
|
|
167
167
|
|
|
168
|
+
`themeScript` is built on `prePaintScript`, which takes any script that has to run before the
|
|
169
|
+
page shows. `@gusnips/locale`'s `localeGateScript` is the other one:
|
|
170
|
+
|
|
171
|
+
```ts
|
|
172
|
+
import { prePaintScript } from "@gusnips/vite";
|
|
173
|
+
|
|
174
|
+
prePaintScript({ name: "locale", source: localeGateScript({ ... }), position: "head-prepend" });
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
The script runs on its own, before your bundle, so it must not use anything outside itself. The
|
|
178
|
+
tag goes at the end of `<head>` by default. `position: "head-prepend"` puts it first instead, so
|
|
179
|
+
it does not wait for your stylesheets to download. Use that for a script that may leave the page.
|
|
180
|
+
|
|
168
181
|
## The vite preset
|
|
169
182
|
|
|
170
183
|
```ts
|
package/dist/index.d.ts
CHANGED
|
@@ -21,6 +21,7 @@ export { escapeAttr } from "./escape.ts";
|
|
|
21
21
|
export { assertRendered, bakeHead, EMPTY_ROOT, ogLocale, type Alternate, type HeadTags, type RenderedChecks, } from "./head.ts";
|
|
22
22
|
export { assertOgImages, loadRenderer, loadTemplate, writeDist, writeOgCards, type OgCard, type WriteOgCardsOptions, } from "./node.ts";
|
|
23
23
|
export { describeOverflow, fitText, OG_CANVAS, type FitOptions, type FitResult, type OgOverflow, } from "./og.ts";
|
|
24
|
+
export { prePaintScript, type PrePaintScriptOptions } from "./pre-paint.ts";
|
|
24
25
|
export type { PageRenderer } from "./render.ts";
|
|
25
26
|
export { ogImagePath, pageFile, pageSlug, robotsTxt, siteOrigin, sitemapFor, sitemapXml, type PublicPage, type RobotsOptions, type SitemapEntry, } from "./sitemap.ts";
|
|
26
27
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AACH,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAK1C,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACzC,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,cAAc,EACd,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;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AACH,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAK1C,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACzC,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,cAAc,EACd,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;AAEjB,OAAO,EAAE,cAAc,EAAE,KAAK,qBAAqB,EAAE,MAAM,gBAAgB,CAAC;AAK5E,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
CHANGED
|
@@ -25,5 +25,7 @@ export { escapeAttr } from "./escape.js";
|
|
|
25
25
|
export { assertRendered, bakeHead, EMPTY_ROOT, ogLocale, } from "./head.js";
|
|
26
26
|
export { assertOgImages, loadRenderer, loadTemplate, writeDist, writeOgCards, } from "./node.js";
|
|
27
27
|
export { describeOverflow, fitText, OG_CANVAS, } from "./og.js";
|
|
28
|
+
// The locale gate's delivery. Type-only use of vite, so the barrel still imports no peer.
|
|
29
|
+
export { prePaintScript } from "./pre-paint.js";
|
|
28
30
|
export { ogImagePath, pageFile, pageSlug, robotsTxt, siteOrigin, sitemapFor, sitemapXml, } from "./sitemap.js";
|
|
29
31
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AACH,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAC1C,6FAA6F;AAC7F,2FAA2F;AAC3F,8FAA8F;AAC9F,yFAAyF;AACzF,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACzC,OAAO,EACL,cAAc,EACd,QAAQ,EACR,UAAU,EACV,QAAQ,GAIT,MAAM,WAAW,CAAC;AACnB,OAAO,EACL,cAAc,EACd,YAAY,EACZ,YAAY,EACZ,SAAS,EACT,YAAY,GAGb,MAAM,WAAW,CAAC;AACnB,OAAO,EACL,gBAAgB,EAChB,OAAO,EACP,SAAS,GAIV,MAAM,SAAS,CAAC;
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AACH,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAC1C,6FAA6F;AAC7F,2FAA2F;AAC3F,8FAA8F;AAC9F,yFAAyF;AACzF,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACzC,OAAO,EACL,cAAc,EACd,QAAQ,EACR,UAAU,EACV,QAAQ,GAIT,MAAM,WAAW,CAAC;AACnB,OAAO,EACL,cAAc,EACd,YAAY,EACZ,YAAY,EACZ,SAAS,EACT,YAAY,GAGb,MAAM,WAAW,CAAC;AACnB,OAAO,EACL,gBAAgB,EAChB,OAAO,EACP,SAAS,GAIV,MAAM,SAAS,CAAC;AACjB,0FAA0F;AAC1F,OAAO,EAAE,cAAc,EAA8B,MAAM,gBAAgB,CAAC;AAM5E,OAAO,EACL,WAAW,EACX,QAAQ,EACR,QAAQ,EACR,SAAS,EACT,UAAU,EACV,UAAU,EACV,UAAU,GAIX,MAAM,cAAc,CAAC"}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Plugin } from "vite";
|
|
2
|
+
export interface PrePaintScriptOptions {
|
|
3
|
+
/** Names the file (`theme`, `locale`) and the plugin. */
|
|
4
|
+
name: string;
|
|
5
|
+
/** The script's full text. It runs alone, so it must not reference anything outside itself. */
|
|
6
|
+
source: string;
|
|
7
|
+
/**
|
|
8
|
+
* Where the tag goes. `"head"` (the end of `<head>`) when the script reads elements above it —
|
|
9
|
+
* the theme reads `<meta name="theme-color">`. `"head-prepend"` when it only needs to run as
|
|
10
|
+
* early as possible: a classic script after a stylesheet waits for that stylesheet, so the
|
|
11
|
+
* locale gate goes first and redirects without downloading the page's CSS.
|
|
12
|
+
*/
|
|
13
|
+
position?: "head" | "head-prepend";
|
|
14
|
+
}
|
|
15
|
+
export declare function prePaintScript({ name, source, position }: PrePaintScriptOptions): Plugin;
|
|
16
|
+
//# sourceMappingURL=pre-paint.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pre-paint.d.ts","sourceRoot":"","sources":["../src/pre-paint.ts"],"names":[],"mappings":"AAoBA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,MAAM,CAAC;AAEnC,MAAM,WAAW,qBAAqB;IACpC,yDAAyD;IACzD,IAAI,EAAE,MAAM,CAAC;IACb,+FAA+F;IAC/F,MAAM,EAAE,MAAM,CAAC;IACf;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,MAAM,GAAG,cAAc,CAAC;CACpC;AAED,wBAAgB,cAAc,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,QAAiB,EAAE,EAAE,qBAAqB,GAAG,MAAM,CA0BjG"}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A classic script that runs before first paint, served as a file of its own.
|
|
3
|
+
*
|
|
4
|
+
* Two pre-paint jobs share this shape: the theme (`@gusnips/vite/theme`) and the locale gate
|
|
5
|
+
* (`localeGateScript` from `@gusnips/locale`). Both have to act before the body shows, and both
|
|
6
|
+
* lose silently when they cannot — the page paints light then turns dark, or paints English then
|
|
7
|
+
* turns Portuguese. So both get the same delivery:
|
|
8
|
+
*
|
|
9
|
+
* - **A file, never inline.** A `script-src 'self'` policy blocks an inline script, and one
|
|
10
|
+
* adopter's inline pre-paint never ran in production because of it. A hash in the policy works
|
|
11
|
+
* until the first whitespace change breaks it again. A same-origin file passes every policy that
|
|
12
|
+
* lets the app's own bundle run.
|
|
13
|
+
* - **A name that carries a hash of the contents,** `<assetsDir>/<name>-<hash>.js`, so it sits
|
|
14
|
+
* beside the bundle under a long-cache `/assets/*` rule and changes whenever the source does.
|
|
15
|
+
* - **A classic `<script src>`,** no `defer`, no `type="module"`: it blocks the parser, which is
|
|
16
|
+
* the point. The body is what gets painted, and nothing below the tag is parsed until it ran.
|
|
17
|
+
*
|
|
18
|
+
* This file imports nothing at runtime but `node:crypto`, so it lives in the barrel.
|
|
19
|
+
*/
|
|
20
|
+
import { createHash } from "node:crypto";
|
|
21
|
+
export function prePaintScript({ name, source, position = "head" }) {
|
|
22
|
+
const hash = createHash("sha256").update(source).digest("hex").slice(0, 8);
|
|
23
|
+
let fileName = "";
|
|
24
|
+
let src = "";
|
|
25
|
+
let ssr = false;
|
|
26
|
+
return {
|
|
27
|
+
name: `frontkit:${name}-script`,
|
|
28
|
+
configResolved(config) {
|
|
29
|
+
fileName = `${config.build.assetsDir}/${name}-${hash}.js`;
|
|
30
|
+
src = `${config.base}${fileName}`;
|
|
31
|
+
ssr = Boolean(config.build.ssr);
|
|
32
|
+
},
|
|
33
|
+
configureServer(server) {
|
|
34
|
+
server.middlewares.use((req, res, next) => {
|
|
35
|
+
if (req.url?.split("?")[0] !== src)
|
|
36
|
+
return next();
|
|
37
|
+
res.setHeader("Content-Type", "text/javascript");
|
|
38
|
+
res.end(source);
|
|
39
|
+
});
|
|
40
|
+
},
|
|
41
|
+
generateBundle() {
|
|
42
|
+
// The server build renders pages into files; it never serves this script.
|
|
43
|
+
if (!ssr)
|
|
44
|
+
this.emitFile({ type: "asset", fileName, source });
|
|
45
|
+
},
|
|
46
|
+
transformIndexHtml: () => [{ tag: "script", attrs: { src }, injectTo: position }],
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
//# sourceMappingURL=pre-paint.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pre-paint.js","sourceRoot":"","sources":["../src/pre-paint.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AACH,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAiBzC,MAAM,UAAU,cAAc,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,GAAG,MAAM,EAAyB;IACvF,MAAM,IAAI,GAAG,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;IAC3E,IAAI,QAAQ,GAAG,EAAE,CAAC;IAClB,IAAI,GAAG,GAAG,EAAE,CAAC;IACb,IAAI,GAAG,GAAG,KAAK,CAAC;IAEhB,OAAO;QACL,IAAI,EAAE,YAAY,IAAI,SAAS;QAC/B,cAAc,CAAC,MAAM;YACnB,QAAQ,GAAG,GAAG,MAAM,CAAC,KAAK,CAAC,SAAS,IAAI,IAAI,IAAI,IAAI,KAAK,CAAC;YAC1D,GAAG,GAAG,GAAG,MAAM,CAAC,IAAI,GAAG,QAAQ,EAAE,CAAC;YAClC,GAAG,GAAG,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;QAClC,CAAC;QACD,eAAe,CAAC,MAAM;YACpB,MAAM,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE;gBACxC,IAAI,GAAG,CAAC,GAAG,EAAE,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,KAAK,GAAG;oBAAE,OAAO,IAAI,EAAE,CAAC;gBAClD,GAAG,CAAC,SAAS,CAAC,cAAc,EAAE,iBAAiB,CAAC,CAAC;gBACjD,GAAG,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;YAClB,CAAC,CAAC,CAAC;QACL,CAAC;QACD,cAAc;YACZ,0EAA0E;YAC1E,IAAI,CAAC,GAAG;gBAAE,IAAI,CAAC,QAAQ,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,CAAC,CAAC;QAC/D,CAAC;QACD,kBAAkB,EAAE,GAAG,EAAE,CAAC,CAAC,EAAE,GAAG,EAAE,QAAQ,EAAE,KAAK,EAAE,EAAE,GAAG,EAAE,EAAE,QAAQ,EAAE,QAAQ,EAAE,CAAC;KAClF,CAAC;AACJ,CAAC"}
|
package/dist/theme.d.ts
CHANGED
|
@@ -1,3 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pre-paint half of `@gusnips/react/theme`: a Vite plugin that runs the theme controller
|
|
3
|
+
* before the first frame.
|
|
4
|
+
*
|
|
5
|
+
* It lives behind its own subpath because it imports the controller, and that module imports
|
|
6
|
+
* React. The barrel stays free of React so a script that only wants a sitemap does not load it.
|
|
7
|
+
*
|
|
8
|
+
* The delivery — a hashed same-origin file and one classic `<script src>`, never inline — is
|
|
9
|
+
* `prePaintScript`'s, and that module says why. What is particular to the theme:
|
|
10
|
+
*
|
|
11
|
+
* - **Not hand-written.** The file is `startTheme`'s own source, called with your options. The
|
|
12
|
+
* script that paints first and the controller the app talks to cannot disagree, because they
|
|
13
|
+
* are the same function.
|
|
14
|
+
* - **At the END of `<head>`,** so any `<meta name="theme-color">` above it already exists when
|
|
15
|
+
* the script runs. It still runs before first paint: the body is what gets painted.
|
|
16
|
+
*/
|
|
1
17
|
import { type ThemeOptions } from "@gusnips/react/theme";
|
|
2
18
|
import type { Plugin } from "vite";
|
|
3
19
|
/** The exact script the plugin serves. Exported so a test can run it in a bare context. */
|
package/dist/theme.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"theme.d.ts","sourceRoot":"","sources":["../src/theme.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"theme.d.ts","sourceRoot":"","sources":["../src/theme.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AACH,OAAO,EAAc,KAAK,YAAY,EAAE,MAAM,sBAAsB,CAAC;AACrE,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,MAAM,CAAC;AAGnC,2FAA2F;AAC3F,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,YAAY,GAAG,MAAM,CAE/D;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,YAAY,GAAG,MAAM,CAEzD"}
|
package/dist/theme.js
CHANGED
|
@@ -5,25 +5,17 @@
|
|
|
5
5
|
* It lives behind its own subpath because it imports the controller, and that module imports
|
|
6
6
|
* React. The barrel stays free of React so a script that only wants a sitemap does not load it.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* The delivery — a hashed same-origin file and one classic `<script src>`, never inline — is
|
|
9
|
+
* `prePaintScript`'s, and that module says why. What is particular to the theme:
|
|
10
10
|
*
|
|
11
|
-
* - **Not inline.** A `script-src 'self'` policy blocks inline scripts, and one adopter's inline
|
|
12
|
-
* pre-paint never ran in production because of it: the app booted light and turned dark a frame
|
|
13
|
-
* later. A hash in the policy works until the first whitespace change breaks it again. A
|
|
14
|
-
* same-origin file passes every policy that lets the app's own bundle run.
|
|
15
11
|
* - **Not hand-written.** The file is `startTheme`'s own source, called with your options. The
|
|
16
12
|
* script that paints first and the controller the app talks to cannot disagree, because they
|
|
17
13
|
* are the same function.
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* `/assets/*` rule and changes whenever the controller or your options do. The tag goes at the
|
|
21
|
-
* END of `<head>` so any `<meta name="theme-color">` above it already exists when the script
|
|
22
|
-
* runs. It still runs before first paint: a classic script in `<head>` blocks the body, and the
|
|
23
|
-
* body is what gets painted.
|
|
14
|
+
* - **At the END of `<head>`,** so any `<meta name="theme-color">` above it already exists when
|
|
15
|
+
* the script runs. It still runs before first paint: the body is what gets painted.
|
|
24
16
|
*/
|
|
25
|
-
import { createHash } from "node:crypto";
|
|
26
17
|
import { startTheme } from "@gusnips/react/theme";
|
|
18
|
+
import { prePaintScript } from "./pre-paint.js";
|
|
27
19
|
/** The exact script the plugin serves. Exported so a test can run it in a bare context. */
|
|
28
20
|
export function themeScriptSource(options) {
|
|
29
21
|
return `(${startTheme.toString()})(${JSON.stringify(options)});\n`;
|
|
@@ -42,32 +34,6 @@ export function themeScriptSource(options) {
|
|
|
42
34
|
* ```
|
|
43
35
|
*/
|
|
44
36
|
export function themeScript(options) {
|
|
45
|
-
|
|
46
|
-
const hash = createHash("sha256").update(source).digest("hex").slice(0, 8);
|
|
47
|
-
let fileName = "";
|
|
48
|
-
let src = "";
|
|
49
|
-
let ssr = false;
|
|
50
|
-
return {
|
|
51
|
-
name: "frontkit:theme-script",
|
|
52
|
-
configResolved(config) {
|
|
53
|
-
fileName = `${config.build.assetsDir}/theme-${hash}.js`;
|
|
54
|
-
src = `${config.base}${fileName}`;
|
|
55
|
-
ssr = Boolean(config.build.ssr);
|
|
56
|
-
},
|
|
57
|
-
configureServer(server) {
|
|
58
|
-
server.middlewares.use((req, res, next) => {
|
|
59
|
-
if (req.url?.split("?")[0] !== src)
|
|
60
|
-
return next();
|
|
61
|
-
res.setHeader("Content-Type", "text/javascript");
|
|
62
|
-
res.end(source);
|
|
63
|
-
});
|
|
64
|
-
},
|
|
65
|
-
generateBundle() {
|
|
66
|
-
// The server build renders pages into files; it never serves this script.
|
|
67
|
-
if (!ssr)
|
|
68
|
-
this.emitFile({ type: "asset", fileName, source });
|
|
69
|
-
},
|
|
70
|
-
transformIndexHtml: () => [{ tag: "script", attrs: { src }, injectTo: "head" }],
|
|
71
|
-
};
|
|
37
|
+
return prePaintScript({ name: "theme", source: themeScriptSource(options), position: "head" });
|
|
72
38
|
}
|
|
73
39
|
//# sourceMappingURL=theme.js.map
|
package/dist/theme.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"theme.js","sourceRoot":"","sources":["../src/theme.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"theme.js","sourceRoot":"","sources":["../src/theme.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AACH,OAAO,EAAE,UAAU,EAAqB,MAAM,sBAAsB,CAAC;AAErE,OAAO,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAEhD,2FAA2F;AAC3F,MAAM,UAAU,iBAAiB,CAAC,OAAqB;IACrD,OAAO,IAAI,UAAU,CAAC,QAAQ,EAAE,KAAK,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,MAAM,CAAC;AACrE,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,WAAW,CAAC,OAAqB;IAC/C,OAAO,cAAc,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,iBAAiB,CAAC,OAAO,CAAC,EAAE,QAAQ,EAAE,MAAM,EAAE,CAAC,CAAC;AACjG,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gusnips/vite",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.14",
|
|
4
4
|
"description": "The build-time half of a prerendered Vite SPA: head baking, sitemap, robots, the prerender driver, and a shared vite preset.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
"release:major": "bun pm version major && bun publish --access public"
|
|
64
64
|
},
|
|
65
65
|
"dependencies": {
|
|
66
|
-
"@gusnips/react": "0.9.
|
|
66
|
+
"@gusnips/react": "0.9.9"
|
|
67
67
|
},
|
|
68
68
|
"peerDependencies": {
|
|
69
69
|
"@tailwindcss/vite": "^4",
|
package/src/index.ts
CHANGED
|
@@ -48,6 +48,8 @@ export {
|
|
|
48
48
|
type FitResult,
|
|
49
49
|
type OgOverflow,
|
|
50
50
|
} from "./og.ts";
|
|
51
|
+
// The locale gate's delivery. Type-only use of vite, so the barrel still imports no peer.
|
|
52
|
+
export { prePaintScript, type PrePaintScriptOptions } from "./pre-paint.ts";
|
|
51
53
|
// `renderTree` itself is NOT here — it lives at `@gusnips/vite/render`, because it is the one
|
|
52
54
|
// thing in this package that loads React. Its consumer is `entry-server.tsx`, a different file
|
|
53
55
|
// in a different bundle to the prerender script, and a repo using this only for `sitemapXml`
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { mkdtemp, readdir, readFile, 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 { build } from "vite";
|
|
6
|
+
import { prePaintScript, type PrePaintScriptOptions } from "./pre-paint.ts";
|
|
7
|
+
|
|
8
|
+
const dirs: string[] = [];
|
|
9
|
+
afterEach(async () => {
|
|
10
|
+
await Promise.all(dirs.map((dir) => rm(dir, { recursive: true, force: true })));
|
|
11
|
+
dirs.length = 0;
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
/** A real `vite build` of a page with a stylesheet, because the order in the output is the point. */
|
|
15
|
+
async function built(options: PrePaintScriptOptions) {
|
|
16
|
+
const root = await mkdtemp(join(tmpdir(), "frontkit-prepaint-"));
|
|
17
|
+
dirs.push(root);
|
|
18
|
+
await writeFile(join(root, "style.css"), "body { color: red }");
|
|
19
|
+
await writeFile(
|
|
20
|
+
join(root, "index.html"),
|
|
21
|
+
'<!doctype html><html><head><meta charset="utf-8"><link rel="stylesheet" href="./style.css"></head><body></body></html>',
|
|
22
|
+
);
|
|
23
|
+
await build({ root, logLevel: "silent", plugins: [prePaintScript(options)] });
|
|
24
|
+
const html = await readFile(join(root, "dist/index.html"), "utf8");
|
|
25
|
+
const assets = await readdir(join(root, "dist/assets"));
|
|
26
|
+
return { root, html, assets };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("prePaintScript", () => {
|
|
30
|
+
const source = "globalThis.ran = true;\n";
|
|
31
|
+
|
|
32
|
+
// A classic script after a stylesheet waits for it, so a redirect decided there first downloads
|
|
33
|
+
// CSS for a page it is about to leave.
|
|
34
|
+
it("puts a head-prepend script ahead of every stylesheet, as a file", async () => {
|
|
35
|
+
const { root, html, assets } = await built({
|
|
36
|
+
name: "locale",
|
|
37
|
+
source,
|
|
38
|
+
position: "head-prepend",
|
|
39
|
+
});
|
|
40
|
+
const file = assets.find((name) => /^locale-[0-9a-f]{8}\.js$/.test(name));
|
|
41
|
+
expect(file).toBeDefined();
|
|
42
|
+
const tag = html.indexOf(`<script src="/assets/${file}"></script>`);
|
|
43
|
+
expect(tag).toBeGreaterThan(-1);
|
|
44
|
+
expect(tag).toBeLessThan(html.indexOf('rel="stylesheet"'));
|
|
45
|
+
expect(html).not.toMatch(/<script>/);
|
|
46
|
+
expect(await readFile(join(root, "dist/assets", file!), "utf8")).toBe(source);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("defaults to the end of <head>", async () => {
|
|
50
|
+
const { html, assets } = await built({ name: "theme", source });
|
|
51
|
+
const file = assets.find((name) => name.startsWith("theme-"));
|
|
52
|
+
expect(html.indexOf(`<script src="/assets/${file}"></script>`)).toBeGreaterThan(
|
|
53
|
+
html.indexOf('rel="stylesheet"'),
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("names the file after its contents", async () => {
|
|
58
|
+
const first = (await built({ name: "locale", source })).assets.find((n) =>
|
|
59
|
+
n.startsWith("locale-"),
|
|
60
|
+
);
|
|
61
|
+
const second = (await built({ name: "locale", source: `${source};` })).assets.find((n) =>
|
|
62
|
+
n.startsWith("locale-"),
|
|
63
|
+
);
|
|
64
|
+
expect(first).not.toBe(second);
|
|
65
|
+
});
|
|
66
|
+
});
|
package/src/pre-paint.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A classic script that runs before first paint, served as a file of its own.
|
|
3
|
+
*
|
|
4
|
+
* Two pre-paint jobs share this shape: the theme (`@gusnips/vite/theme`) and the locale gate
|
|
5
|
+
* (`localeGateScript` from `@gusnips/locale`). Both have to act before the body shows, and both
|
|
6
|
+
* lose silently when they cannot — the page paints light then turns dark, or paints English then
|
|
7
|
+
* turns Portuguese. So both get the same delivery:
|
|
8
|
+
*
|
|
9
|
+
* - **A file, never inline.** A `script-src 'self'` policy blocks an inline script, and one
|
|
10
|
+
* adopter's inline pre-paint never ran in production because of it. A hash in the policy works
|
|
11
|
+
* until the first whitespace change breaks it again. A same-origin file passes every policy that
|
|
12
|
+
* lets the app's own bundle run.
|
|
13
|
+
* - **A name that carries a hash of the contents,** `<assetsDir>/<name>-<hash>.js`, so it sits
|
|
14
|
+
* beside the bundle under a long-cache `/assets/*` rule and changes whenever the source does.
|
|
15
|
+
* - **A classic `<script src>`,** no `defer`, no `type="module"`: it blocks the parser, which is
|
|
16
|
+
* the point. The body is what gets painted, and nothing below the tag is parsed until it ran.
|
|
17
|
+
*
|
|
18
|
+
* This file imports nothing at runtime but `node:crypto`, so it lives in the barrel.
|
|
19
|
+
*/
|
|
20
|
+
import { createHash } from "node:crypto";
|
|
21
|
+
import type { Plugin } from "vite";
|
|
22
|
+
|
|
23
|
+
export interface PrePaintScriptOptions {
|
|
24
|
+
/** Names the file (`theme`, `locale`) and the plugin. */
|
|
25
|
+
name: string;
|
|
26
|
+
/** The script's full text. It runs alone, so it must not reference anything outside itself. */
|
|
27
|
+
source: string;
|
|
28
|
+
/**
|
|
29
|
+
* Where the tag goes. `"head"` (the end of `<head>`) when the script reads elements above it —
|
|
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
|
+
* locale gate goes first and redirects without downloading the page's CSS.
|
|
33
|
+
*/
|
|
34
|
+
position?: "head" | "head-prepend";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function prePaintScript({ name, source, position = "head" }: PrePaintScriptOptions): Plugin {
|
|
38
|
+
const hash = createHash("sha256").update(source).digest("hex").slice(0, 8);
|
|
39
|
+
let fileName = "";
|
|
40
|
+
let src = "";
|
|
41
|
+
let ssr = false;
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
name: `frontkit:${name}-script`,
|
|
45
|
+
configResolved(config) {
|
|
46
|
+
fileName = `${config.build.assetsDir}/${name}-${hash}.js`;
|
|
47
|
+
src = `${config.base}${fileName}`;
|
|
48
|
+
ssr = Boolean(config.build.ssr);
|
|
49
|
+
},
|
|
50
|
+
configureServer(server) {
|
|
51
|
+
server.middlewares.use((req, res, next) => {
|
|
52
|
+
if (req.url?.split("?")[0] !== src) return next();
|
|
53
|
+
res.setHeader("Content-Type", "text/javascript");
|
|
54
|
+
res.end(source);
|
|
55
|
+
});
|
|
56
|
+
},
|
|
57
|
+
generateBundle() {
|
|
58
|
+
// The server build renders pages into files; it never serves this script.
|
|
59
|
+
if (!ssr) this.emitFile({ type: "asset", fileName, source });
|
|
60
|
+
},
|
|
61
|
+
transformIndexHtml: () => [{ tag: "script", attrs: { src }, injectTo: position }],
|
|
62
|
+
};
|
|
63
|
+
}
|
package/src/theme.ts
CHANGED
|
@@ -5,26 +5,18 @@
|
|
|
5
5
|
* It lives behind its own subpath because it imports the controller, and that module imports
|
|
6
6
|
* React. The barrel stays free of React so a script that only wants a sitemap does not load it.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* The delivery — a hashed same-origin file and one classic `<script src>`, never inline — is
|
|
9
|
+
* `prePaintScript`'s, and that module says why. What is particular to the theme:
|
|
10
10
|
*
|
|
11
|
-
* - **Not inline.** A `script-src 'self'` policy blocks inline scripts, and one adopter's inline
|
|
12
|
-
* pre-paint never ran in production because of it: the app booted light and turned dark a frame
|
|
13
|
-
* later. A hash in the policy works until the first whitespace change breaks it again. A
|
|
14
|
-
* same-origin file passes every policy that lets the app's own bundle run.
|
|
15
11
|
* - **Not hand-written.** The file is `startTheme`'s own source, called with your options. The
|
|
16
12
|
* script that paints first and the controller the app talks to cannot disagree, because they
|
|
17
13
|
* are the same function.
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* `/assets/*` rule and changes whenever the controller or your options do. The tag goes at the
|
|
21
|
-
* END of `<head>` so any `<meta name="theme-color">` above it already exists when the script
|
|
22
|
-
* runs. It still runs before first paint: a classic script in `<head>` blocks the body, and the
|
|
23
|
-
* body is what gets painted.
|
|
14
|
+
* - **At the END of `<head>`,** so any `<meta name="theme-color">` above it already exists when
|
|
15
|
+
* the script runs. It still runs before first paint: the body is what gets painted.
|
|
24
16
|
*/
|
|
25
|
-
import { createHash } from "node:crypto";
|
|
26
17
|
import { startTheme, type ThemeOptions } from "@gusnips/react/theme";
|
|
27
18
|
import type { Plugin } from "vite";
|
|
19
|
+
import { prePaintScript } from "./pre-paint.ts";
|
|
28
20
|
|
|
29
21
|
/** The exact script the plugin serves. Exported so a test can run it in a bare context. */
|
|
30
22
|
export function themeScriptSource(options: ThemeOptions): string {
|
|
@@ -45,30 +37,5 @@ export function themeScriptSource(options: ThemeOptions): string {
|
|
|
45
37
|
* ```
|
|
46
38
|
*/
|
|
47
39
|
export function themeScript(options: ThemeOptions): Plugin {
|
|
48
|
-
|
|
49
|
-
const hash = createHash("sha256").update(source).digest("hex").slice(0, 8);
|
|
50
|
-
let fileName = "";
|
|
51
|
-
let src = "";
|
|
52
|
-
let ssr = false;
|
|
53
|
-
|
|
54
|
-
return {
|
|
55
|
-
name: "frontkit:theme-script",
|
|
56
|
-
configResolved(config) {
|
|
57
|
-
fileName = `${config.build.assetsDir}/theme-${hash}.js`;
|
|
58
|
-
src = `${config.base}${fileName}`;
|
|
59
|
-
ssr = Boolean(config.build.ssr);
|
|
60
|
-
},
|
|
61
|
-
configureServer(server) {
|
|
62
|
-
server.middlewares.use((req, res, next) => {
|
|
63
|
-
if (req.url?.split("?")[0] !== src) return next();
|
|
64
|
-
res.setHeader("Content-Type", "text/javascript");
|
|
65
|
-
res.end(source);
|
|
66
|
-
});
|
|
67
|
-
},
|
|
68
|
-
generateBundle() {
|
|
69
|
-
// The server build renders pages into files; it never serves this script.
|
|
70
|
-
if (!ssr) this.emitFile({ type: "asset", fileName, source });
|
|
71
|
-
},
|
|
72
|
-
transformIndexHtml: () => [{ tag: "script", attrs: { src }, injectTo: "head" }],
|
|
73
|
-
};
|
|
40
|
+
return prePaintScript({ name: "theme", source: themeScriptSource(options), position: "head" });
|
|
74
41
|
}
|