@ultimat3/render 22.3.0 → 22.3.2

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 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.0",
3
+ "version": "22.3.2",
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.0",
40
- "@ultimat3/core": "22.3.0",
41
- "@ultimat3/http": "22.3.0",
42
- "@ultimat3/i18n": "22.3.0",
43
- "@ultimat3/seo": "22.3.0",
39
+ "@ultimat3/cache": "22.3.2",
40
+ "@ultimat3/core": "22.3.2",
41
+ "@ultimat3/http": "22.3.2",
42
+ "@ultimat3/i18n": "22.3.2",
43
+ "@ultimat3/seo": "22.3.2",
44
44
  "sass": "1.104.0"
45
45
  }
46
46
  }
@@ -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
- const restored = scoped.replace(
149
- MASKED,
150
- // The mask is dense and index-addressed, so a miss is impossible; `??` only keeps
151
- // `noUncheckedIndexedAccess` honest.
152
- (_match, index: string) => literals[Number(index)] ?? '',
153
- );
154
- return { css: restored, classes };
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
- sassCompiler().compileString(source, {
206
- url: pathToFileURL(file),
207
- loadPaths: [dirname(file)],
208
- importers: [packageImporter(dirname(file))],
209
- style: 'compressed',
210
- // No `@charset`, no BOM — see `stripCharset`. Dart Sass writes one for any compressed
211
- // output holding a non-ASCII character, and re-emits an escaped `\\00b7` as the literal
212
- // character, so escaping in the app cannot avoid it.
213
- charset: false,
214
- }).css,
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
+ }