@ultimat3/render 22.3.0 → 22.3.1
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/CLAUDE.md +1 -1
- package/package.json +6 -6
- package/src/css-modules.ts +48 -17
- package/src/sass-cache.ts +139 -0
package/CLAUDE.md
CHANGED
|
@@ -94,7 +94,7 @@ axiom 6). Never `cli` (upward).
|
|
|
94
94
|
| Escaping | `html.ts` only — including `render-stream.ts`'s `holeMarker` and `revealChunk` (`JSON.stringify`), and `head.ts`'s `themeScript`. `escapeAttribute` is `@ultimat3/seo`'s, re-exported by `html.ts`. |
|
|
95
95
|
| Script and style CONTENT | never raw: `escapeText`, `escapeRawTextContent` (`</` → `<\/`, `<!--` → `<\!--`), or `escapeJsonContent` for a `type` ending in `json`. Never HTML-escape a script body. |
|
|
96
96
|
| Which export is the page | `route-component.ts`: `Page` → a single `…Page` → a single capitalised function. |
|
|
97
|
-
| Stylesheets | compiled by `css-modules.ts`, grouped per surface, served by the CLI as one content-hashed file per surface (`@ultimat3/cli`'s `style-bundle.ts`) from `stylesFor`. `sass` is this package's only third-party dependency. |
|
|
97
|
+
| Stylesheets | compiled by `css-modules.ts`, grouped per surface, served by the CLI as one content-hashed file per surface (`@ultimat3/cli`'s `style-bundle.ts`) from `stylesFor`. `sass` is this package's only third-party dependency. Each compile goes through `sass-cache.ts`: `.x/cache/sass/` under cwd, a hit only while every file the compile read hashes the same; `setSassCacheDir(null)` turns it off. |
|
|
98
98
|
| CSS order | `stylesFor` sorts **globals before modules** (`isGlobalStylesheet`). `shared/` is carried by both graphs. |
|
|
99
99
|
| The global layer | the app's `shared/global.scss` `@use`s `@ultimat3/ui/global.scss`, side-effect-imported by `shared/global.ts` (this package may not import `ui`). `x verify` fails with `X_STYLES_GLOBAL_MISSING` when a surface's document defines none. |
|
|
100
100
|
| Colours | tokens and `data-theme` only. No hex in `head.ts` or any emitted script. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/render",
|
|
3
|
-
"version": "22.3.
|
|
3
|
+
"version": "22.3.1",
|
|
4
4
|
"description": "The route primitive and the five render modes: static, isr, ssr, stream, spa.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -36,11 +36,11 @@
|
|
|
36
36
|
"test": "bun test"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@ultimat3/cache": "22.3.
|
|
40
|
-
"@ultimat3/core": "22.3.
|
|
41
|
-
"@ultimat3/http": "22.3.
|
|
42
|
-
"@ultimat3/i18n": "22.3.
|
|
43
|
-
"@ultimat3/seo": "22.3.
|
|
39
|
+
"@ultimat3/cache": "22.3.1",
|
|
40
|
+
"@ultimat3/core": "22.3.1",
|
|
41
|
+
"@ultimat3/http": "22.3.1",
|
|
42
|
+
"@ultimat3/i18n": "22.3.1",
|
|
43
|
+
"@ultimat3/seo": "22.3.1",
|
|
44
44
|
"sass": "1.104.0"
|
|
45
45
|
}
|
|
46
46
|
}
|
package/src/css-modules.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { renderThrowable } from '@ultimat3/core';
|
|
|
11
11
|
import type * as Sass from 'sass';
|
|
12
12
|
import { PrerenderFailedError } from './errors';
|
|
13
13
|
import { contentHash } from './render-static';
|
|
14
|
+
import { cachedSassCompile } from './sass-cache';
|
|
14
15
|
|
|
15
16
|
export interface CompiledStylesheet {
|
|
16
17
|
readonly css: string;
|
|
@@ -145,13 +146,18 @@ export function scopeClasses(
|
|
|
145
146
|
classes[name] = local;
|
|
146
147
|
return `.${local}`;
|
|
147
148
|
});
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
149
|
+
// Recursive: a `:global()` payload is masked AFTER the strings inside it were, so its literal
|
|
150
|
+
// holds their placeholders — one pass restored `html[data-theme='light']` as
|
|
151
|
+
// `html[data-theme=\0 0 \0]`, a selector that matched nothing. A literal only ever holds
|
|
152
|
+
// placeholders with LOWER indexes than its own, so the recursion ends.
|
|
153
|
+
const restore = (text: string): string =>
|
|
154
|
+
text.replace(
|
|
155
|
+
MASKED,
|
|
156
|
+
// The mask is dense and index-addressed, so a miss is impossible; `??` only keeps
|
|
157
|
+
// `noUncheckedIndexedAccess` honest.
|
|
158
|
+
(_match, index: string) => restore(literals[Number(index)] ?? ''),
|
|
159
|
+
);
|
|
160
|
+
return { css: restore(scoped), classes };
|
|
155
161
|
}
|
|
156
162
|
|
|
157
163
|
/** The fix line for a stylesheet that names tokens `@ultimat3/ui/tokens` does not export. */
|
|
@@ -198,20 +204,45 @@ const sassCompiler = (): typeof Sass => {
|
|
|
198
204
|
return loadedSass;
|
|
199
205
|
};
|
|
200
206
|
|
|
207
|
+
/** Changes whenever the `compileString` options below do, so a cached entry never outlives them. */
|
|
208
|
+
const COMPILE_OPTIONS = 'v1 compressed charset:false loadPaths:dirname importer:package';
|
|
209
|
+
|
|
210
|
+
let loadedVersion: string | undefined;
|
|
211
|
+
|
|
212
|
+
/** `sass`'s own version, without evaluating `sass` — the cache key needs it before any compile. */
|
|
213
|
+
const sassVersion = (): string => {
|
|
214
|
+
loadedVersion ??= String(
|
|
215
|
+
(require('sass/package.json') as { readonly version?: unknown }).version ?? 'unknown',
|
|
216
|
+
);
|
|
217
|
+
return loadedVersion;
|
|
218
|
+
};
|
|
219
|
+
|
|
201
220
|
export function compileStylesheet(file: string, source: string): CompiledStylesheet {
|
|
202
221
|
let css: string;
|
|
203
222
|
try {
|
|
223
|
+
// Everything that is not a loaded file goes in the key: the compiler, the options below (named
|
|
224
|
+
// by `COMPILE_OPTIONS`), the path the relative `@use`s resolve from, and the source itself. The
|
|
225
|
+
// version is read from Sass's package.json, so a run whose every sheet hits never loads Sass.
|
|
226
|
+
const key = `${sassVersion()}\0${COMPILE_OPTIONS}\0${file}\0${source}`;
|
|
204
227
|
css = stripCharset(
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
228
|
+
cachedSassCompile(key, () => {
|
|
229
|
+
const result = sassCompiler().compileString(source, {
|
|
230
|
+
url: pathToFileURL(file),
|
|
231
|
+
loadPaths: [dirname(file)],
|
|
232
|
+
importers: [packageImporter(dirname(file))],
|
|
233
|
+
style: 'compressed',
|
|
234
|
+
// No `@charset`, no BOM — see `stripCharset`. Dart Sass writes one for any compressed
|
|
235
|
+
// output holding a non-ASCII character, and re-emits an escaped `\\00b7` as the literal
|
|
236
|
+
// character, so escaping in the app cannot avoid it.
|
|
237
|
+
charset: false,
|
|
238
|
+
});
|
|
239
|
+
return {
|
|
240
|
+
css: result.css,
|
|
241
|
+
loaded: result.loadedUrls.map((loaded) =>
|
|
242
|
+
loaded.protocol === 'file:' ? fileURLToPath(loaded) : undefined,
|
|
243
|
+
),
|
|
244
|
+
};
|
|
245
|
+
}),
|
|
215
246
|
);
|
|
216
247
|
} catch (error) {
|
|
217
248
|
// `renderThrowable`, never `.message`/`String()`: an importer, a plugin or a future Sass
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// A content-addressed disk cache for one Sass compilation: keyed by the compiler, the file and its
|
|
2
|
+
// source, and valid only while every file that compilation READ still hashes the same. Sass has no
|
|
3
|
+
// cache across compilations, so every `.module.scss` re-parses `@ultimat3/ui/tokens` — measured on
|
|
4
|
+
// notificado.co (140 modules), 3.7 s wall and 12 s CPU of `x manifest --check`'s 6.5 s wall.
|
|
5
|
+
|
|
6
|
+
// why: `compileStylesheet` is synchronous — Bun's loader `onLoad` path calls it inline — and Bun
|
|
7
|
+
// ships no synchronous file read, write or rename; `node:fs` is the only sync file API.
|
|
8
|
+
import { mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
9
|
+
// why: Bun ships no path-join primitive.
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
|
|
12
|
+
/** Relative to the process's cwd — the app root for every `x` command. `.x/` is gitignored. */
|
|
13
|
+
export const SASS_CACHE_DIR = join('.x', 'cache', 'sass');
|
|
14
|
+
|
|
15
|
+
/** Bumped when the entry shape changes, so an old entry is a miss and never a misread. */
|
|
16
|
+
const ENTRY_VERSION = 1;
|
|
17
|
+
|
|
18
|
+
/** `undefined`: the default dir under cwd. `null`: off. A string: that directory. */
|
|
19
|
+
let configured: string | null | undefined;
|
|
20
|
+
|
|
21
|
+
/** Test/host seam. `null` turns the cache off; `undefined` restores the default. */
|
|
22
|
+
export function setSassCacheDir(dir: string | null | undefined): void {
|
|
23
|
+
configured = dir;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const cacheDir = (): string | null =>
|
|
27
|
+
configured === undefined ? join(process.cwd(), SASS_CACHE_DIR) : configured;
|
|
28
|
+
|
|
29
|
+
const sha256 = (input: string | Uint8Array): string =>
|
|
30
|
+
new Bun.CryptoHasher('sha256').update(input).digest('hex');
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* What one compilation produced, and every file it read to produce it — as PATHS, `undefined` for
|
|
34
|
+
* a load that was not a file. The caller converts Sass's URLs: `node:url` stays in `css-modules.ts`,
|
|
35
|
+
* the one file the browser-barrel test names as the build-time half.
|
|
36
|
+
*/
|
|
37
|
+
export interface SassOutput {
|
|
38
|
+
readonly css: string;
|
|
39
|
+
readonly loaded: readonly (string | undefined)[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface Entry {
|
|
43
|
+
readonly v: number;
|
|
44
|
+
readonly css: string;
|
|
45
|
+
/** `[absolute path, sha256 of its bytes]` for every file the compilation read. */
|
|
46
|
+
readonly loaded: readonly (readonly [string, string])[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Every module `@use`s the same token files, so one run hashes each of them once rather than once
|
|
51
|
+
* per module. Keyed by size and mtime as well as path: under `x dev` a token file is edited while
|
|
52
|
+
* the process lives, and a path-only memo would validate every entry against the old bytes.
|
|
53
|
+
*/
|
|
54
|
+
const digests = new Map<string, string>();
|
|
55
|
+
|
|
56
|
+
const digestOf = (path: string): string | undefined => {
|
|
57
|
+
try {
|
|
58
|
+
const stat = statSync(path);
|
|
59
|
+
const memo = `${path}\0${String(stat.size)}\0${String(stat.mtimeMs)}`;
|
|
60
|
+
const known = digests.get(memo);
|
|
61
|
+
if (known !== undefined) return known;
|
|
62
|
+
const digest = sha256(readFileSync(path));
|
|
63
|
+
digests.set(memo, digest);
|
|
64
|
+
return digest;
|
|
65
|
+
} catch {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const isEntry = (value: unknown): value is Entry => {
|
|
71
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
72
|
+
const entry = value as Record<string, unknown>;
|
|
73
|
+
return (
|
|
74
|
+
entry['v'] === ENTRY_VERSION &&
|
|
75
|
+
typeof entry['css'] === 'string' &&
|
|
76
|
+
Array.isArray(entry['loaded']) &&
|
|
77
|
+
entry['loaded'].every(
|
|
78
|
+
(pair: unknown) =>
|
|
79
|
+
Array.isArray(pair) &&
|
|
80
|
+
pair.length === 2 &&
|
|
81
|
+
typeof pair[0] === 'string' &&
|
|
82
|
+
typeof pair[1] === 'string',
|
|
83
|
+
)
|
|
84
|
+
);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** A hit only when every file the stored compilation read is byte-identical today. */
|
|
88
|
+
const readHit = (file: string): string | undefined => {
|
|
89
|
+
let parsed: unknown;
|
|
90
|
+
try {
|
|
91
|
+
parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
92
|
+
} catch {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
if (!isEntry(parsed)) return undefined;
|
|
96
|
+
return parsed.loaded.every(([path, digest]) => digestOf(path) === digest)
|
|
97
|
+
? parsed.css
|
|
98
|
+
: undefined;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Best effort: a read-only filesystem (a production container) or a race with another worker
|
|
103
|
+
* costs the next run a compile, never this one its result. Written beside and renamed, so a
|
|
104
|
+
* concurrent reader sees a whole entry or none.
|
|
105
|
+
*/
|
|
106
|
+
const store = (dir: string, file: string, output: SassOutput): void => {
|
|
107
|
+
// A compilation that read something other than a file cannot be validated by re-reading it.
|
|
108
|
+
const loaded: (readonly [string, string | undefined])[] = [];
|
|
109
|
+
for (const path of output.loaded) {
|
|
110
|
+
if (path === undefined) return;
|
|
111
|
+
loaded.push([path, digestOf(path)]);
|
|
112
|
+
}
|
|
113
|
+
if (loaded.some(([, digest]) => digest === undefined)) return;
|
|
114
|
+
const entry = { v: ENTRY_VERSION, css: output.css, loaded };
|
|
115
|
+
try {
|
|
116
|
+
mkdirSync(dir, { recursive: true });
|
|
117
|
+
const temporary = `${file}.${process.pid}.${Bun.nanoseconds()}.tmp`;
|
|
118
|
+
writeFileSync(temporary, JSON.stringify(entry));
|
|
119
|
+
renameSync(temporary, file);
|
|
120
|
+
} catch {
|
|
121
|
+
// Nothing to report: the css this call returns is already correct.
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The css `compile` would return for `key`, read from disk when a previous compilation of the
|
|
127
|
+
* same key read the same bytes. `key` must name everything that is not a loaded file: the
|
|
128
|
+
* compiler version, the options, the file's path and its source.
|
|
129
|
+
*/
|
|
130
|
+
export function cachedSassCompile(key: string, compile: () => SassOutput): string {
|
|
131
|
+
const dir = cacheDir();
|
|
132
|
+
if (dir === null) return compile().css;
|
|
133
|
+
const file = join(dir, `${sha256(key)}.json`);
|
|
134
|
+
const hit = readHit(file);
|
|
135
|
+
if (hit !== undefined) return hit;
|
|
136
|
+
const output = compile();
|
|
137
|
+
store(dir, file, output);
|
|
138
|
+
return output.css;
|
|
139
|
+
}
|