@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 +1 -1
- package/README.md +2 -0
- package/package.json +6 -6
- package/src/asset.ts +109 -0
- package/src/css-modules.ts +48 -17
- package/src/errors.ts +18 -0
- package/src/head-seo.ts +15 -1
- package/src/index.ts +5 -0
- package/src/route-data.ts +31 -3
- package/src/route.ts +18 -0
- 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/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.
|
|
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.
|
|
40
|
-
"@ultimat3/core": "22.
|
|
41
|
-
"@ultimat3/http": "22.
|
|
42
|
-
"@ultimat3/i18n": "22.
|
|
43
|
-
"@ultimat3/seo": "22.
|
|
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
|
+
}
|
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
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
+
}
|