@gusnips/vite 0.8.10 → 0.8.12
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 +14 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/theme.d.ts +19 -0
- package/dist/theme.d.ts.map +1 -0
- package/dist/theme.js +73 -0
- package/dist/theme.js.map +1 -0
- package/package.json +6 -2
- package/src/index.ts +3 -2
- package/src/theme.test.ts +104 -0
- package/src/theme.ts +74 -0
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
|
-
*
|
|
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)
|
|
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.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA
|
|
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
|
-
*
|
|
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)
|
|
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
|
|
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"}
|
package/dist/theme.d.ts
ADDED
|
@@ -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.
|
|
3
|
+
"version": "0.8.12",
|
|
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.
|
|
66
|
+
"@gusnips/react": "0.9.8"
|
|
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
|
-
*
|
|
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)
|
|
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
|
+
}
|