@gusnips/vite 0.8.9 → 0.8.11

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 CHANGED
@@ -151,6 +151,20 @@ written `dist/` reads a finished page as its blank and nests one render inside a
151
151
  build` empties `dist/` and normally makes that impossible — a restored build cache and a hand-run
152
152
  of the script both route around it.
153
153
 
154
+ ## Theme before the first frame
155
+
156
+ ```ts
157
+ import { themeScript } from "@gusnips/vite/theme";
158
+
159
+ export default defineConfig({ plugins: [themeScript({ key: "app.theme" })] });
160
+ ```
161
+
162
+ Writes `assets/theme-<hash>.js` and one `<script src>` at the end of `<head>`, so the page is
163
+ painted in the reader's theme before anything else runs. The file is the source of `startTheme`
164
+ from `@gusnips/react/theme`, so the script before paint and the controller the app uses are
165
+ the same function. It is a file rather than an inline script so a `script-src 'self'` policy
166
+ lets it run. Pass it the same options you pass `createTheme`; see that package's README.
167
+
154
168
  ## The vite preset
155
169
 
156
170
  ```ts
package/dist/index.d.ts CHANGED
@@ -9,9 +9,10 @@
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.
11
11
  *
12
- * Two things live behind their own subpath, because each drags a dependency this barrel would
12
+ * Three things live behind their own subpath, because each drags a dependency this barrel would
13
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
14
+ * Tailwind plugins), `renderTree` at `@gusnips/vite/render`, and the theme pre-paint plugin at
15
+ * `@gusnips/vite/theme` (both React itself). Nothing here
15
16
  * imports React or vite, so a prerender script, an OG generator and a repo that only wants a
16
17
  * sitemap all install exactly what they use.
17
18
  */
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;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;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"}
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;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 CHANGED
@@ -9,9 +9,10 @@
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.
11
11
  *
12
- * Two things live behind their own subpath, because each drags a dependency this barrel would
12
+ * Three things live behind their own subpath, because each drags a dependency this barrel would
13
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
14
+ * Tailwind plugins), `renderTree` at `@gusnips/vite/render`, and the theme pre-paint plugin at
15
+ * `@gusnips/vite/theme` (both React itself). Nothing here
15
16
  * imports React or vite, so a prerender script, an OG generator and a repo that only wants a
16
17
  * sitemap all install exactly what they use.
17
18
  */
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;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;AAMjB,OAAO,EACL,WAAW,EACX,QAAQ,EACR,QAAQ,EACR,SAAS,EACT,UAAU,EACV,UAAU,EACV,UAAU,GAIX,MAAM,cAAc,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;AAMjB,OAAO,EACL,WAAW,EACX,QAAQ,EACR,QAAQ,EACR,SAAS,EACT,UAAU,EACV,UAAU,EACV,UAAU,GAIX,MAAM,cAAc,CAAC"}
@@ -0,0 +1,19 @@
1
+ import { type ThemeOptions } from "@gusnips/react/theme";
2
+ import type { Plugin } from "vite";
3
+ /** The exact script the plugin serves. Exported so a test can run it in a bare context. */
4
+ export declare function themeScriptSource(options: ThemeOptions): string;
5
+ /**
6
+ * Pass the same options `createTheme` gets. Keep them in one module that both
7
+ * `vite.config.ts` and the app import:
8
+ *
9
+ * ```ts
10
+ * // src/theme.ts
11
+ * export const THEME = { key: "app.theme" } satisfies ThemeOptions;
12
+ * export const { useTheme } = createTheme(THEME);
13
+ *
14
+ * // vite.config.ts
15
+ * plugins: [themeScript(THEME)]
16
+ * ```
17
+ */
18
+ export declare function themeScript(options: ThemeOptions): Plugin;
19
+ //# sourceMappingURL=theme.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"theme.d.ts","sourceRoot":"","sources":["../src/theme.ts"],"names":[],"mappings":"AAyBA,OAAO,EAAc,KAAK,YAAY,EAAE,MAAM,sBAAsB,CAAC;AACrE,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,MAAM,CAAC;AAEnC,2FAA2F;AAC3F,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,YAAY,GAAG,MAAM,CAE/D;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,YAAY,GAAG,MAAM,CA2BzD"}
package/dist/theme.js ADDED
@@ -0,0 +1,73 @@
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
+ * What it writes is a FILE, `<assetsDir>/theme-<hash>.js`, plus one `<script src>` tag at the end
9
+ * of `<head>`. Two things decided that shape:
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
+ * - **Not hand-written.** The file is `startTheme`'s own source, called with your options. The
16
+ * script that paints first and the controller the app talks to cannot disagree, because they
17
+ * are the same function.
18
+ *
19
+ * The name carries a hash of the contents, so it sits beside the bundle under a long-cache
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.
24
+ */
25
+ import { createHash } from "node:crypto";
26
+ import { startTheme } from "@gusnips/react/theme";
27
+ /** The exact script the plugin serves. Exported so a test can run it in a bare context. */
28
+ export function themeScriptSource(options) {
29
+ return `(${startTheme.toString()})(${JSON.stringify(options)});\n`;
30
+ }
31
+ /**
32
+ * Pass the same options `createTheme` gets. Keep them in one module that both
33
+ * `vite.config.ts` and the app import:
34
+ *
35
+ * ```ts
36
+ * // src/theme.ts
37
+ * export const THEME = { key: "app.theme" } satisfies ThemeOptions;
38
+ * export const { useTheme } = createTheme(THEME);
39
+ *
40
+ * // vite.config.ts
41
+ * plugins: [themeScript(THEME)]
42
+ * ```
43
+ */
44
+ export function themeScript(options) {
45
+ const source = themeScriptSource(options);
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
+ };
72
+ }
73
+ //# sourceMappingURL=theme.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"theme.js","sourceRoot":"","sources":["../src/theme.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACzC,OAAO,EAAE,UAAU,EAAqB,MAAM,sBAAsB,CAAC;AAGrE,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,MAAM,MAAM,GAAG,iBAAiB,CAAC,OAAO,CAAC,CAAC;IAC1C,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,uBAAuB;QAC7B,cAAc,CAAC,MAAM;YACnB,QAAQ,GAAG,GAAG,MAAM,CAAC,KAAK,CAAC,SAAS,UAAU,IAAI,KAAK,CAAC;YACxD,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,MAAM,EAAE,CAAC;KAChF,CAAC;AACJ,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gusnips/vite",
3
- "version": "0.8.9",
3
+ "version": "0.8.11",
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",
@@ -21,6 +21,10 @@
21
21
  "./render": {
22
22
  "types": "./dist/render.d.ts",
23
23
  "default": "./dist/render.js"
24
+ },
25
+ "./theme": {
26
+ "types": "./dist/theme.d.ts",
27
+ "default": "./dist/theme.js"
24
28
  }
25
29
  },
26
30
  "types": "./dist/index.d.ts",
@@ -59,7 +63,7 @@
59
63
  "release:major": "bun pm version major && bun publish --access public"
60
64
  },
61
65
  "dependencies": {
62
- "@gusnips/react": "0.9.5"
66
+ "@gusnips/react": "0.9.7"
63
67
  },
64
68
  "peerDependencies": {
65
69
  "@tailwindcss/vite": "^4",
package/src/index.ts CHANGED
@@ -9,9 +9,10 @@
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.
11
11
  *
12
- * Two things live behind their own subpath, because each drags a dependency this barrel would
12
+ * Three things live behind their own subpath, because each drags a dependency this barrel would
13
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
14
+ * Tailwind plugins), `renderTree` at `@gusnips/vite/render`, and the theme pre-paint plugin at
15
+ * `@gusnips/vite/theme` (both React itself). Nothing here
15
16
  * imports React or vite, so a prerender script, an OG generator and a repo that only wants a
16
17
  * sitemap all install exactly what they use.
17
18
  */
@@ -0,0 +1,104 @@
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 { runInNewContext } from "node:vm";
5
+ import { afterEach, describe, expect, it } from "vitest";
6
+ import { build } from "vite";
7
+ import { themeScript, themeScriptSource } from "./theme.ts";
8
+
9
+ const KEY = "app.theme";
10
+
11
+ /**
12
+ * A page with nothing in it but what a browser gives a classic script in `<head>`. Handing the
13
+ * script only `window` and `document` is the point: a reference to anything else in
14
+ * `startTheme`'s module — a helper, a constant, an import — is a `ReferenceError` here, which is
15
+ * what it would be before first paint in a real page.
16
+ */
17
+ function prePaint(source: string, stored: string | null, { osDark = false, blocked = false } = {}) {
18
+ const classes = new Set<string>();
19
+ const window = {
20
+ matchMedia: () => ({ matches: osDark, addEventListener: () => {} }),
21
+ localStorage: {
22
+ getItem: () => {
23
+ if (blocked) throw new Error("site data blocked");
24
+ return stored;
25
+ },
26
+ },
27
+ addEventListener: () => {},
28
+ };
29
+ const document = {
30
+ documentElement: {
31
+ classList: {
32
+ toggle: (name: string, on: boolean) => (on ? classes.add(name) : classes.delete(name)),
33
+ },
34
+ },
35
+ };
36
+ runInNewContext(source, { window, document });
37
+ return classes.has("dark");
38
+ }
39
+
40
+ describe("themeScriptSource", () => {
41
+ const source = themeScriptSource({ key: KEY });
42
+
43
+ it.each([
44
+ ["dark stored", "dark", {}, true],
45
+ ["light stored, OS dark", "light", { osDark: true }, false],
46
+ ["system, OS dark", "system", { osDark: true }, true],
47
+ ["nothing stored, OS dark", null, { osDark: true }, true],
48
+ ["a stale value, OS dark", "blue", { osDark: true }, true],
49
+ ["storage blocked, OS dark", null, { osDark: true, blocked: true }, true],
50
+ ])("runs on its own before first paint: %s", (_case, stored, env, dark) => {
51
+ expect(prePaint(source, stored, env)).toBe(dark);
52
+ });
53
+
54
+ it("carries the options into the page", () => {
55
+ const darkDefault = themeScriptSource({ key: KEY, defaultMode: "dark" });
56
+ expect(prePaint(darkDefault, null)).toBe(true);
57
+ });
58
+ });
59
+
60
+ describe("themeScript", () => {
61
+ const dirs: string[] = [];
62
+ afterEach(async () => {
63
+ await Promise.all(dirs.map((dir) => rm(dir, { recursive: true, force: true })));
64
+ dirs.length = 0;
65
+ });
66
+
67
+ /** A real `vite build`, because what matters is the page and the file it produces. */
68
+ async function built(options = { key: KEY }) {
69
+ const root = await mkdtemp(join(tmpdir(), "frontkit-theme-"));
70
+ dirs.push(root);
71
+ await writeFile(
72
+ join(root, "index.html"),
73
+ '<!doctype html><html><head><meta name="theme-color" content="#fff"></head><body></body></html>',
74
+ );
75
+ await build({ root, logLevel: "silent", plugins: [themeScript(options)] });
76
+ const html = await readFile(join(root, "dist/index.html"), "utf8");
77
+ const assets = await readdir(join(root, "dist/assets"));
78
+ return { root, html, assets };
79
+ }
80
+
81
+ // A `script-src 'self'` policy blocks an inline script, and nothing reports it: the page just
82
+ // paints the wrong theme first. So the page must name a file, and the file must exist.
83
+ it("writes a same-origin file and names it at the end of <head>, never inline", async () => {
84
+ const { root, html, assets } = await built();
85
+ const file = assets.find((name) => /^theme-[0-9a-f]{8}\.js$/.test(name));
86
+ expect(file).toBeDefined();
87
+ expect(html).toMatch(
88
+ new RegExp(`<meta name="theme-color"[^>]*>\\s*<script src="/assets/${file}"></script>`),
89
+ );
90
+ expect(html).not.toMatch(/<script>/);
91
+ expect(await readFile(join(root, "dist/assets", file!), "utf8")).toBe(
92
+ themeScriptSource({ key: KEY }),
93
+ );
94
+ });
95
+
96
+ // The file sits under a long-cache rule, so new contents need a new name.
97
+ it("names the file after its contents", async () => {
98
+ const first = (await built()).assets.find((name) => name.startsWith("theme-"));
99
+ const second = (await built({ key: "other.key" })).assets.find((name) =>
100
+ name.startsWith("theme-"),
101
+ );
102
+ expect(first).not.toBe(second);
103
+ });
104
+ });
package/src/theme.ts ADDED
@@ -0,0 +1,74 @@
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
+ * What it writes is a FILE, `<assetsDir>/theme-<hash>.js`, plus one `<script src>` tag at the end
9
+ * of `<head>`. Two things decided that shape:
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
+ * - **Not hand-written.** The file is `startTheme`'s own source, called with your options. The
16
+ * script that paints first and the controller the app talks to cannot disagree, because they
17
+ * are the same function.
18
+ *
19
+ * The name carries a hash of the contents, so it sits beside the bundle under a long-cache
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.
24
+ */
25
+ import { createHash } from "node:crypto";
26
+ import { startTheme, type ThemeOptions } from "@gusnips/react/theme";
27
+ import type { Plugin } from "vite";
28
+
29
+ /** The exact script the plugin serves. Exported so a test can run it in a bare context. */
30
+ export function themeScriptSource(options: ThemeOptions): string {
31
+ return `(${startTheme.toString()})(${JSON.stringify(options)});\n`;
32
+ }
33
+
34
+ /**
35
+ * Pass the same options `createTheme` gets. Keep them in one module that both
36
+ * `vite.config.ts` and the app import:
37
+ *
38
+ * ```ts
39
+ * // src/theme.ts
40
+ * export const THEME = { key: "app.theme" } satisfies ThemeOptions;
41
+ * export const { useTheme } = createTheme(THEME);
42
+ *
43
+ * // vite.config.ts
44
+ * plugins: [themeScript(THEME)]
45
+ * ```
46
+ */
47
+ export function themeScript(options: ThemeOptions): Plugin {
48
+ const source = themeScriptSource(options);
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
+ };
74
+ }