@ultimat3/render 22.2.2 → 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 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/README.md CHANGED
@@ -453,6 +453,7 @@ a job boundary the class is gone and the `code` is what survives — match on th
453
453
 
454
454
  | Class | Code | Declared in |
455
455
  |---|---|---|
456
+ | `AssetMissingError` | `X_ASSET_MISSING` | `src/errors.ts` |
456
457
  | `BudgetExceededError` | `X_BUDGET_EXCEEDED` | `src/errors.ts` |
457
458
  | `IslandInvalidError` | `X_ISLAND_INVALID` | `src/errors.ts` |
458
459
  | `IslandNotHydratedError` | `X_ISLAND_NOT_HYDRATED` | `src/errors.ts` |
@@ -477,6 +478,7 @@ a job boundary the class is gone and the `code` is what survives — match on th
477
478
  | `defineRoute` | the `route` primitive |
478
479
  | `withStatus`, `routeStatusOf` | the status a loader answers, carried on its data; 200 when nothing asked |
479
480
  | `island`, `createIslandCollector` | one interactive component on a static page |
481
+ | `asset`, `setAssetResolver`, `assetPathProblem`, `AssetPath` | `asset('assets/x.avif')` → the content-hashed URL of a public site file; the table is the CLI's ([Static Assets](../../wiki/Static-Assets.md)) |
480
482
  | `MODE_SPECS`, `assertModeShape`, `assertModeInvariants` | the mode invariant table |
481
483
  | `registerRoute`, `describeRoutes`, `routeFor`, `routePathFromFile` | the route table |
482
484
  | `checkSurfaceBoundary`, `assertSurfaceBoundary`, `surfaceOf` | the hard boundary |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/render",
3
- "version": "22.2.2",
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.2.2",
40
- "@ultimat3/core": "22.2.2",
41
- "@ultimat3/http": "22.2.2",
42
- "@ultimat3/i18n": "22.2.2",
43
- "@ultimat3/seo": "22.2.2",
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/asset.ts ADDED
@@ -0,0 +1,109 @@
1
+ /**
2
+ * `asset('assets/hero.avif')` — the one way a page names a public file of its own: the path under
3
+ * `apps/web/site/assets/` in, its content-hashed URL out. Typed so a path outside that tree or with
4
+ * an extension nothing serves is a compile error, and resolved while the page RENDERS so a file
5
+ * that is not there fails `x build --target static` with `X_ASSET_MISSING`, exactly as a bad
6
+ * `island({ src })` does. The table is the CLI's (`site-assets.ts`); this file holds only the seam.
7
+ */
8
+
9
+ import { AssetMissingError } from './errors';
10
+
11
+ /**
12
+ * What the asset surface serves, by extension. A list and not a pattern: each one has a content
13
+ * type the server answers with, and an extension outside it would be a file the export copies and
14
+ * the container serves as `application/octet-stream`.
15
+ */
16
+ export const ASSET_EXTENSIONS = [
17
+ 'avif',
18
+ 'webp',
19
+ 'png',
20
+ 'jpg',
21
+ 'jpeg',
22
+ 'gif',
23
+ 'svg',
24
+ 'ico',
25
+ 'woff2',
26
+ 'mp4',
27
+ 'webm',
28
+ 'vtt',
29
+ ] as const;
30
+
31
+ export type AssetExtension = (typeof ASSET_EXTENSIONS)[number];
32
+
33
+ /** Relative to the site surface, always under `assets/`: `assets/brand/logo.svg`. */
34
+ export type AssetPath = `assets/${string}.${AssetExtension}`;
35
+
36
+ /** Maps a declared path to the URL a browser fetches. Throws `X_ASSET_MISSING` on a miss. */
37
+ export type AssetResolver = (path: AssetPath) => string;
38
+
39
+ /** The directory every asset path starts with, and the URL prefix every hashed URL starts with. */
40
+ export const ASSET_DIR = 'assets';
41
+
42
+ const EXTENSIONS: ReadonlySet<string> = new Set(ASSET_EXTENSIONS);
43
+
44
+ /**
45
+ * Why `path` can never name a servable asset, or `undefined` when it can. Shared with the CLI's
46
+ * table and route, so the page, the build and the server refuse one set of paths — a `..` segment
47
+ * the type admits (`assets/../app.config.ts` is a `string`, and so an `AssetPath` suffix) is
48
+ * refused here, before any disk is asked.
49
+ */
50
+ export function assetPathProblem(path: string): string | undefined {
51
+ if (path.includes('\\')) return `"${path}" contains a backslash`;
52
+ const segments = path.split('/');
53
+ if (segments[0] !== ASSET_DIR || segments.length < 2) {
54
+ return `"${path}" is not under ${ASSET_DIR}/`;
55
+ }
56
+ if (segments.some((segment) => segment === '' || segment === '.' || segment === '..')) {
57
+ return `"${path}" has an empty, "." or ".." segment`;
58
+ }
59
+ const name = segments[segments.length - 1] ?? '';
60
+ const dot = name.lastIndexOf('.');
61
+ const extension = dot <= 0 ? '' : name.slice(dot + 1).toLowerCase();
62
+ if (!EXTENSIONS.has(extension)) {
63
+ return `"${path}" has an extension the asset surface does not serve (one of ${ASSET_EXTENSIONS.join(', ')})`;
64
+ }
65
+ return undefined;
66
+ }
67
+
68
+ /**
69
+ * Registered globally, like the island brand, so two copies of this module — the app's and the
70
+ * CLI's, resolved through different `node_modules` — read the table one boot installed.
71
+ */
72
+ const RESOLVER_SLOT: unique symbol = Symbol.for('ultimate.render.assetResolver') as never;
73
+
74
+ interface ResolverHolder {
75
+ [RESOLVER_SLOT]?: AssetResolver | undefined;
76
+ }
77
+
78
+ const holder = globalThis as unknown as ResolverHolder;
79
+
80
+ /**
81
+ * Installed by the process that renders: `loadApp` does it for `x dev`, the container and the
82
+ * static build alike. `undefined` uninstalls — a test that installed one hands the slot back.
83
+ */
84
+ export function setAssetResolver(resolver: AssetResolver | undefined): void {
85
+ holder[RESOLVER_SLOT] = resolver;
86
+ }
87
+
88
+ /**
89
+ * The hashed URL of one site asset, e.g. `/assets/hero.3f2a1b9c.avif`, served
90
+ * `public, max-age=31536000, immutable`. Call it in a page or a server component and hand the URL
91
+ * to an island as a prop: a browser has no asset table, and an island calling this throws.
92
+ */
93
+ export function asset(path: AssetPath): string {
94
+ const problem = assetPathProblem(path);
95
+ if (problem !== undefined) {
96
+ throw new AssetMissingError(
97
+ `asset(${JSON.stringify(path)}): ${problem}`,
98
+ `name a file under apps/web/site/${ASSET_DIR}/ with one of: ${ASSET_EXTENSIONS.join(', ')}`,
99
+ );
100
+ }
101
+ const resolver = holder[RESOLVER_SLOT];
102
+ if (resolver === undefined) {
103
+ throw new AssetMissingError(
104
+ `asset(${JSON.stringify(path)}) ran with no site asset table installed — in a browser (an island), or in a test that never loaded the app`,
105
+ 'call asset() in the page and pass the URL to the island as a prop; a unit test installs a table with setAssetResolver((path) => "/" + path)',
106
+ );
107
+ }
108
+ return resolver(path);
109
+ }
@@ -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
package/src/errors.ts CHANGED
@@ -25,6 +25,7 @@ export const RENDER_ERROR_CODES = [
25
25
  // stylesheet registry and `stylesFor`, so "the CSS a document on this surface carries" is a fact
26
26
  // about render's own output. `x verify` is only the surface that reports it.
27
27
  'X_STYLES_GLOBAL_MISSING',
28
+ 'X_ASSET_MISSING',
28
29
  ] as const;
29
30
 
30
31
  export type RenderErrorCode = (typeof RENDER_ERROR_CODES)[number];
@@ -47,6 +48,7 @@ export const RENDER_ERROR_TITLES: Readonly<Record<RenderErrorCode, string>> = {
47
48
  X_ISLAND_NOT_HYDRATED: 'a page renders an island that nothing would ever boot',
48
49
  X_STYLES_GLOBAL_MISSING:
49
50
  'a surface renders documents whose CSS defines no :root custom properties',
51
+ X_ASSET_MISSING: 'a page names a site asset the app does not have',
50
52
  };
51
53
 
52
54
  // Titles must be registered for `format()` to render the contract's first line. Every code above is
@@ -273,3 +275,19 @@ export class RouteStatusInvalidError extends UltimateError {
273
275
  });
274
276
  }
275
277
  }
278
+
279
+ /**
280
+ * `asset('assets/…')` named a file the app's site asset table does not hold. Raised while the
281
+ * page RENDERS, so the static build fails on it the way it fails on a bad island — a hashed URL
282
+ * that 404s in every browser is the one outcome a build-checked helper exists to prevent.
283
+ */
284
+ export class AssetMissingError extends UltimateError {
285
+ static readonly code = 'X_ASSET_MISSING' as const;
286
+ constructor(cause: string, fix: string) {
287
+ super({
288
+ code: AssetMissingError.code,
289
+ cause,
290
+ fix,
291
+ });
292
+ }
293
+ }
package/src/head-seo.ts CHANGED
@@ -15,6 +15,13 @@ const IDENTITY: Readonly<Record<SeoHeadTag['tag'], readonly string[]>> = {
15
15
  script: ['type'],
16
16
  };
17
17
 
18
+ /**
19
+ * Open Graph properties a document carries once PER VALUE. Keyed by property alone, the dedupe
20
+ * kept the last: an article with three tags published one, and a page in three locales named one
21
+ * alternate. Their content is part of their identity.
22
+ */
23
+ const REPEATABLE_PROPERTIES = new Set(['og:locale:alternate', 'article:tag']);
24
+
18
25
  /**
19
26
  * `<meta name="description">` → `meta:description`. Scripts also carry their position: a page
20
27
  * with three JSON-LD nodes emits three tags with identical attributes, and keying them alike
@@ -24,7 +31,14 @@ export function headTagKey(tag: SeoHeadTag, index: number): string {
24
31
  const identity = IDENTITY[tag.tag]
25
32
  .map((name) => tag.attrs[name])
26
33
  .filter((value): value is string => value !== undefined);
27
- return [tag.tag, ...identity, ...(tag.tag === 'script' ? [String(index)] : [])].join(':');
34
+ const property = tag.attrs['property'];
35
+ const repeated =
36
+ property !== undefined && REPEATABLE_PROPERTIES.has(property)
37
+ ? [tag.attrs['content'] ?? '']
38
+ : [];
39
+ return [tag.tag, ...identity, ...repeated, ...(tag.tag === 'script' ? [String(index)] : [])].join(
40
+ ':',
41
+ );
28
42
  }
29
43
 
30
44
  export function toHeadTag(tag: SeoHeadTag, index: number): HeadTag {
package/src/index.ts CHANGED
@@ -16,6 +16,9 @@ export type { HydrateStrategy, OfflineStrategy, RenderMode } from '@ultimat3/cor
16
16
  // never had); still named here because `@ultimat3/cli`'s budget reporter reads it beside the route
17
17
  // table it prints against.
18
18
  export { formatBytes, HYDRATE_STRATEGIES, OFFLINE_STRATEGIES, RENDER_MODES } from '@ultimat3/core';
19
+ /** `asset('assets/x.avif')` → the content-hashed URL the site asset surface serves it at. */
20
+ export type { AssetExtension, AssetPath, AssetResolver } from './asset';
21
+ export { ASSET_DIR, ASSET_EXTENSIONS, asset, assetPathProblem, setAssetResolver } from './asset';
19
22
  /** The `<meta name="ultimate-scope">` core's `pageClient()` reads, on private documents only. */
20
23
  export {
21
24
  CLIENT_PERSIST_META,
@@ -36,6 +39,7 @@ export {
36
39
  export { parseTtlMs } from './duration';
37
40
  export type { RenderErrorCode } from './errors';
38
41
  export {
42
+ AssetMissingError,
39
43
  BudgetExceededError,
40
44
  IslandInvalidError,
41
45
  IslandNotHydratedError,
@@ -127,6 +131,7 @@ export type {
127
131
  PrerenderFn,
128
132
  RenderResult,
129
133
  RevalidateConfig,
134
+ RouteAlternate,
130
135
  RouteBudget,
131
136
  RouteCache,
132
137
  RouteConfig,
package/src/route-data.ts CHANGED
@@ -6,9 +6,21 @@
6
6
  */
7
7
 
8
8
  import { isUltimateError, renderThrowable } from '@ultimat3/core';
9
- import { useI18n } from '@ultimat3/i18n';
9
+ import {
10
+ currentLocale,
11
+ localizedPath,
12
+ routedLocales,
13
+ unlocalizedPath,
14
+ useI18n,
15
+ } from '@ultimat3/i18n';
10
16
  import { RouteLoadFailedError } from './errors';
11
- import type { RouteConfig, RouteContext, RouteData, RouteMetaContext } from './route';
17
+ import type {
18
+ RouteAlternate,
19
+ RouteConfig,
20
+ RouteContext,
21
+ RouteData,
22
+ RouteMetaContext,
23
+ } from './route';
12
24
 
13
25
  /**
14
26
  * The route's data for one render.
@@ -75,5 +87,21 @@ export function metaContextFor<TData = RouteData>(
75
87
  ctx: RouteContext,
76
88
  data: TData,
77
89
  ): RouteMetaContext<TData> {
78
- return { data, params: ctx.params, url: ctx.url, t: useI18n() };
90
+ // The UNPREFIXED path: `/en/precios` and `/precios` are one page, and every locale's spelling of
91
+ // it is derived from that one path rather than by rewriting whichever prefix the request wore.
92
+ const page = unlocalizedPath(pathnameOf(ctx.url));
93
+ const inLocale = (locale: string): string => localizedPath(page, locale);
94
+ const alternates: readonly RouteAlternate[] = routedLocales().map((locale) => ({
95
+ locale,
96
+ path: inLocale(locale),
97
+ }));
98
+ return {
99
+ data,
100
+ params: ctx.params,
101
+ url: ctx.url,
102
+ t: useI18n(),
103
+ locale: currentLocale(),
104
+ localizedPath: inLocale,
105
+ alternates,
106
+ };
79
107
  }
package/src/route.ts CHANGED
@@ -129,6 +129,24 @@ export interface RouteMetaContext<TData = RouteData> {
129
129
  readonly url: string;
130
130
  /** The request's own translator. Never a hardcoded string in a `<title>`. */
131
131
  readonly t: Translator;
132
+ /**
133
+ * The locale this document renders in: a `/<locale>/` prefix's, the default on an unprefixed
134
+ * `site/` page, the negotiated one elsewhere. What a localized `description` is chosen by.
135
+ */
136
+ readonly locale: string;
137
+ /** This page's own path spelled in `locale` — unprefixed for the default, `/en/…` otherwise. */
138
+ readonly localizedPath: (locale: string) => string;
139
+ /**
140
+ * This page in every routed locale, the default first — what the automatic hreflang cluster is
141
+ * built from. One entry for a single-locale app.
142
+ */
143
+ readonly alternates: readonly RouteAlternate[];
144
+ }
145
+
146
+ /** One page in one locale. */
147
+ export interface RouteAlternate {
148
+ readonly locale: string;
149
+ readonly path: string;
132
150
  }
133
151
 
134
152
  /** What an author writes. Sync or async, whichever the page's data needs. */
@@ -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
+ }