@ultimat3/render 22.6.2 → 22.7.0
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/package.json +6 -6
- package/src/css-modules.ts +63 -7
- package/src/head-seo.ts +19 -3
- package/src/module-loader.ts +3 -0
- package/src/sass-cache.ts +37 -13
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/render",
|
|
3
|
-
"version": "22.
|
|
3
|
+
"version": "22.7.0",
|
|
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.7.0",
|
|
40
|
+
"@ultimat3/core": "22.7.0",
|
|
41
|
+
"@ultimat3/http": "22.7.0",
|
|
42
|
+
"@ultimat3/i18n": "22.7.0",
|
|
43
|
+
"@ultimat3/seo": "22.7.0",
|
|
44
44
|
"sass": "1.104.0"
|
|
45
45
|
}
|
|
46
46
|
}
|
package/src/css-modules.ts
CHANGED
|
@@ -9,7 +9,8 @@ import { basename, dirname, resolve } from 'node:path';
|
|
|
9
9
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
10
10
|
import { renderThrowable } from '@ultimat3/core';
|
|
11
11
|
import type * as Sass from 'sass';
|
|
12
|
-
import {
|
|
12
|
+
import { type AssetPath, asset } from './asset';
|
|
13
|
+
import { AssetMissingError, PrerenderFailedError } from './errors';
|
|
13
14
|
import { contentHash } from './render-static';
|
|
14
15
|
import { cachedSassCompile } from './sass-cache';
|
|
15
16
|
|
|
@@ -17,6 +18,12 @@ export interface CompiledStylesheet {
|
|
|
17
18
|
readonly css: string;
|
|
18
19
|
/** `hero` → `hero_1f2e3d4c`. Empty for a plain (non-module) stylesheet. */
|
|
19
20
|
readonly classes: Readonly<Record<string, string>>;
|
|
21
|
+
/**
|
|
22
|
+
* Every OTHER file the compilation read — the partials it `@use`s, `@forward`s or `@import`s,
|
|
23
|
+
* transitively, as absolute paths. What `x dev` watches to recompile a sheet whose own bytes
|
|
24
|
+
* did not change.
|
|
25
|
+
*/
|
|
26
|
+
readonly dependencies: readonly string[];
|
|
20
27
|
}
|
|
21
28
|
|
|
22
29
|
/** A file is a CSS module when its name says so — one spelling, per the `.module.scss` convention. */
|
|
@@ -217,17 +224,59 @@ const sassVersion = (): string => {
|
|
|
217
224
|
return loadedVersion;
|
|
218
225
|
};
|
|
219
226
|
|
|
227
|
+
/**
|
|
228
|
+
* `asset('assets/fonts/inter.woff2')` in Sass — the same path, the same table and the same refusals
|
|
229
|
+
* as the TypeScript `asset()`, so `src: url(asset('assets/fonts/inter.woff2'))` compiles to the
|
|
230
|
+
* content-hashed URL `/assets/*` serves. Records each call for the disk cache (`SassOutput.assets`)
|
|
231
|
+
* and keeps the typed failure: Sass wraps a thrown function error in its own exception, and an
|
|
232
|
+
* `X_ASSET_MISSING` must reach the author as itself, not as a Sass stack.
|
|
233
|
+
*/
|
|
234
|
+
function sassAssetFunction(
|
|
235
|
+
sass: typeof Sass,
|
|
236
|
+
calls: [string, string][],
|
|
237
|
+
failed: { error?: AssetMissingError },
|
|
238
|
+
): Record<string, Sass.CustomFunction<'sync'>> {
|
|
239
|
+
return {
|
|
240
|
+
'asset($path)': (args: readonly Sass.Value[]): Sass.Value => {
|
|
241
|
+
const path = args[0]?.assertString('path').text ?? '';
|
|
242
|
+
try {
|
|
243
|
+
const url = asset(path as AssetPath);
|
|
244
|
+
calls.push([path, url]);
|
|
245
|
+
return new sass.SassString(url, { quotes: true });
|
|
246
|
+
} catch (error) {
|
|
247
|
+
if (error instanceof AssetMissingError) failed.error = error;
|
|
248
|
+
throw error;
|
|
249
|
+
}
|
|
250
|
+
},
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** A cache entry's assets are current when each path still resolves to the URL it baked in. */
|
|
255
|
+
const assetsCurrent = (assets: readonly (readonly [string, string])[]): boolean => {
|
|
256
|
+
try {
|
|
257
|
+
return assets.every(([path, url]) => asset(path as AssetPath) === url);
|
|
258
|
+
} catch {
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
|
|
220
263
|
export function compileStylesheet(file: string, source: string): CompiledStylesheet {
|
|
221
264
|
let css: string;
|
|
265
|
+
let dependencies: readonly string[];
|
|
266
|
+
const failed: { error?: AssetMissingError } = {};
|
|
222
267
|
try {
|
|
223
268
|
// Everything that is not a loaded file goes in the key: the compiler, the options below (named
|
|
224
269
|
// by `COMPILE_OPTIONS`), the path the relative `@use`s resolve from, and the source itself. The
|
|
225
270
|
// version is read from Sass's package.json, so a run whose every sheet hits never loads Sass.
|
|
226
271
|
const key = `${sassVersion()}\0${COMPILE_OPTIONS}\0${file}\0${source}`;
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
272
|
+
const output = cachedSassCompile(
|
|
273
|
+
key,
|
|
274
|
+
() => {
|
|
275
|
+
const sass = sassCompiler();
|
|
276
|
+
const assets: [string, string][] = [];
|
|
277
|
+
const result = sass.compileString(source, {
|
|
230
278
|
url: pathToFileURL(file),
|
|
279
|
+
functions: sassAssetFunction(sass, assets, failed),
|
|
231
280
|
loadPaths: [dirname(file)],
|
|
232
281
|
importers: [packageImporter(dirname(file))],
|
|
233
282
|
style: 'compressed',
|
|
@@ -241,10 +290,17 @@ export function compileStylesheet(file: string, source: string): CompiledStylesh
|
|
|
241
290
|
loaded: result.loadedUrls.map((loaded) =>
|
|
242
291
|
loaded.protocol === 'file:' ? fileURLToPath(loaded) : undefined,
|
|
243
292
|
),
|
|
293
|
+
assets,
|
|
244
294
|
};
|
|
245
|
-
}
|
|
295
|
+
},
|
|
296
|
+
assetsCurrent,
|
|
297
|
+
);
|
|
298
|
+
css = stripCharset(output.css);
|
|
299
|
+
dependencies = output.loaded.filter(
|
|
300
|
+
(loaded): loaded is string => loaded !== undefined && loaded !== file,
|
|
246
301
|
);
|
|
247
302
|
} catch (error) {
|
|
303
|
+
if (failed.error !== undefined) throw failed.error;
|
|
248
304
|
// `renderThrowable`, never `.message`/`String()`: an importer, a plugin or a future Sass
|
|
249
305
|
// release can throw a value whose own read raises, and this frame is what turns a failed
|
|
250
306
|
// compile into `X_PRERENDER_FAILED` naming the file — a laundered read leaves it a bare one.
|
|
@@ -254,9 +310,9 @@ export function compileStylesheet(file: string, source: string): CompiledStylesh
|
|
|
254
310
|
`edit ${file}: ${TOKEN_FIX}`,
|
|
255
311
|
);
|
|
256
312
|
}
|
|
257
|
-
if (!isCssModule(file)) return { css, classes: {} };
|
|
313
|
+
if (!isCssModule(file)) return { css, classes: {}, dependencies };
|
|
258
314
|
// Content-addressed, not path-addressed: a checkout at a different absolute path must produce
|
|
259
315
|
// byte-identical CSS, which a hash over the absolute filename would not.
|
|
260
316
|
const scoped = scopeClasses(css, contentHash(`${file.split('/').pop() ?? file} ${source}`));
|
|
261
|
-
return { css: scoped.css, classes: scoped.classes };
|
|
317
|
+
return { css: scoped.css, classes: scoped.classes, dependencies };
|
|
262
318
|
}
|
package/src/head-seo.ts
CHANGED
|
@@ -31,14 +31,30 @@ export function headTagKey(tag: SeoHeadTag, index: number): string {
|
|
|
31
31
|
const identity = IDENTITY[tag.tag]
|
|
32
32
|
.map((name) => tag.attrs[name])
|
|
33
33
|
.filter((value): value is string => value !== undefined);
|
|
34
|
+
// A link with no hreflang and no canonical rel is one of the route's own `links`, and two of
|
|
35
|
+
// them share a rel by design — two font preloads, a preconnect per origin. Keyed by rel alone,
|
|
36
|
+
// the second replaced the first. Its href is part of what it is.
|
|
37
|
+
const rel = tag.attrs['rel'];
|
|
38
|
+
const href = tag.attrs['href'];
|
|
39
|
+
const linkHref =
|
|
40
|
+
tag.tag === 'link' &&
|
|
41
|
+
rel !== 'canonical' &&
|
|
42
|
+
tag.attrs['hreflang'] === undefined &&
|
|
43
|
+
href !== undefined
|
|
44
|
+
? [href]
|
|
45
|
+
: [];
|
|
34
46
|
const property = tag.attrs['property'];
|
|
35
47
|
const repeated =
|
|
36
48
|
property !== undefined && REPEATABLE_PROPERTIES.has(property)
|
|
37
49
|
? [tag.attrs['content'] ?? '']
|
|
38
50
|
: [];
|
|
39
|
-
return [
|
|
40
|
-
|
|
41
|
-
|
|
51
|
+
return [
|
|
52
|
+
tag.tag,
|
|
53
|
+
...identity,
|
|
54
|
+
...linkHref,
|
|
55
|
+
...repeated,
|
|
56
|
+
...(tag.tag === 'script' ? [String(index)] : []),
|
|
57
|
+
].join(':');
|
|
42
58
|
}
|
|
43
59
|
|
|
44
60
|
export function toHeadTag(tag: SeoHeadTag, index: number): HeadTag {
|
package/src/module-loader.ts
CHANGED
|
@@ -85,6 +85,8 @@ export interface Stylesheet {
|
|
|
85
85
|
*/
|
|
86
86
|
readonly island: boolean;
|
|
87
87
|
readonly css: string;
|
|
88
|
+
/** The partials this sheet's compilation read (`CompiledStylesheet.dependencies`). */
|
|
89
|
+
readonly dependencies: readonly string[];
|
|
88
90
|
}
|
|
89
91
|
|
|
90
92
|
const stylesheets = new Map<string, Stylesheet>();
|
|
@@ -234,6 +236,7 @@ export function loadStylesheet(
|
|
|
234
236
|
global: isGlobalStylesheet(path),
|
|
235
237
|
island,
|
|
236
238
|
css: compiled.css,
|
|
239
|
+
dependencies: compiled.dependencies,
|
|
237
240
|
});
|
|
238
241
|
}
|
|
239
242
|
return `export default ${JSON.stringify(compiled.classes)};`;
|
package/src/sass-cache.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { join } from 'node:path';
|
|
|
13
13
|
export const SASS_CACHE_DIR = join('.x', 'cache', 'sass');
|
|
14
14
|
|
|
15
15
|
/** Bumped when the entry shape changes, so an old entry is a miss and never a misread. */
|
|
16
|
-
const ENTRY_VERSION =
|
|
16
|
+
const ENTRY_VERSION = 2;
|
|
17
17
|
|
|
18
18
|
/** `undefined`: the default dir under cwd. `null`: off. A string: that directory. */
|
|
19
19
|
let configured: string | null | undefined;
|
|
@@ -37,6 +37,12 @@ const sha256 = (input: string | Uint8Array): string =>
|
|
|
37
37
|
export interface SassOutput {
|
|
38
38
|
readonly css: string;
|
|
39
39
|
readonly loaded: readonly (string | undefined)[];
|
|
40
|
+
/**
|
|
41
|
+
* Every `asset('assets/…')` the compilation called, `[path, url]`. The URL is content-hashed, so
|
|
42
|
+
* an entry is only valid while each path still resolves to the URL baked into its css — an
|
|
43
|
+
* edited font under an unchanged sheet would otherwise be served at its old hash.
|
|
44
|
+
*/
|
|
45
|
+
readonly assets?: readonly (readonly [string, string])[];
|
|
40
46
|
}
|
|
41
47
|
|
|
42
48
|
interface Entry {
|
|
@@ -44,6 +50,7 @@ interface Entry {
|
|
|
44
50
|
readonly css: string;
|
|
45
51
|
/** `[absolute path, sha256 of its bytes]` for every file the compilation read. */
|
|
46
52
|
readonly loaded: readonly (readonly [string, string])[];
|
|
53
|
+
readonly assets: readonly (readonly [string, string])[];
|
|
47
54
|
}
|
|
48
55
|
|
|
49
56
|
/**
|
|
@@ -80,12 +87,23 @@ const isEntry = (value: unknown): value is Entry => {
|
|
|
80
87
|
pair.length === 2 &&
|
|
81
88
|
typeof pair[0] === 'string' &&
|
|
82
89
|
typeof pair[1] === 'string',
|
|
90
|
+
) &&
|
|
91
|
+
Array.isArray(entry['assets']) &&
|
|
92
|
+
entry['assets'].every(
|
|
93
|
+
(pair: unknown) =>
|
|
94
|
+
Array.isArray(pair) &&
|
|
95
|
+
pair.length === 2 &&
|
|
96
|
+
typeof pair[0] === 'string' &&
|
|
97
|
+
typeof pair[1] === 'string',
|
|
83
98
|
)
|
|
84
99
|
);
|
|
85
100
|
};
|
|
86
101
|
|
|
102
|
+
/** Answers whether every `[path, url]` an entry baked in still resolves to that url. */
|
|
103
|
+
export type AssetCheck = (assets: readonly (readonly [string, string])[]) => boolean;
|
|
104
|
+
|
|
87
105
|
/** A hit only when every file the stored compilation read is byte-identical today. */
|
|
88
|
-
const readHit = (file: string):
|
|
106
|
+
const readHit = (file: string, assetsValid: AssetCheck): SassOutput | undefined => {
|
|
89
107
|
let parsed: unknown;
|
|
90
108
|
try {
|
|
91
109
|
parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
@@ -93,9 +111,9 @@ const readHit = (file: string): string | undefined => {
|
|
|
93
111
|
return undefined;
|
|
94
112
|
}
|
|
95
113
|
if (!isEntry(parsed)) return undefined;
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
114
|
+
if (!parsed.loaded.every(([path, digest]) => digestOf(path) === digest)) return undefined;
|
|
115
|
+
if (parsed.assets.length > 0 && !assetsValid(parsed.assets)) return undefined;
|
|
116
|
+
return { css: parsed.css, loaded: parsed.loaded.map(([path]) => path), assets: parsed.assets };
|
|
99
117
|
};
|
|
100
118
|
|
|
101
119
|
/**
|
|
@@ -111,7 +129,7 @@ const store = (dir: string, file: string, output: SassOutput): void => {
|
|
|
111
129
|
loaded.push([path, digestOf(path)]);
|
|
112
130
|
}
|
|
113
131
|
if (loaded.some(([, digest]) => digest === undefined)) return;
|
|
114
|
-
const entry = { v: ENTRY_VERSION, css: output.css, loaded };
|
|
132
|
+
const entry = { v: ENTRY_VERSION, css: output.css, loaded, assets: output.assets ?? [] };
|
|
115
133
|
try {
|
|
116
134
|
mkdirSync(dir, { recursive: true });
|
|
117
135
|
const temporary = `${file}.${process.pid}.${Bun.nanoseconds()}.tmp`;
|
|
@@ -123,17 +141,23 @@ const store = (dir: string, file: string, output: SassOutput): void => {
|
|
|
123
141
|
};
|
|
124
142
|
|
|
125
143
|
/**
|
|
126
|
-
*
|
|
127
|
-
* same key read the same bytes. `key` must name everything that is not
|
|
128
|
-
* compiler version, the options, the file's path and its source.
|
|
144
|
+
* What `compile` would return for `key` — the css and every file it read — from disk when a
|
|
145
|
+
* previous compilation of the same key read the same bytes. `key` must name everything that is not
|
|
146
|
+
* a loaded file: the compiler version, the options, the file's path and its source. The loaded
|
|
147
|
+
* list is returned on a hit too: `x dev` reloads a sheet when a partial it `@use`s changes, and a
|
|
148
|
+
* hit that forgot its partials would make that the one sheet that never reloads.
|
|
129
149
|
*/
|
|
130
|
-
export function cachedSassCompile(
|
|
150
|
+
export function cachedSassCompile(
|
|
151
|
+
key: string,
|
|
152
|
+
compile: () => SassOutput,
|
|
153
|
+
assetsValid: AssetCheck = () => false,
|
|
154
|
+
): SassOutput {
|
|
131
155
|
const dir = cacheDir();
|
|
132
|
-
if (dir === null) return compile()
|
|
156
|
+
if (dir === null) return compile();
|
|
133
157
|
const file = join(dir, `${sha256(key)}.json`);
|
|
134
|
-
const hit = readHit(file);
|
|
158
|
+
const hit = readHit(file, assetsValid);
|
|
135
159
|
if (hit !== undefined) return hit;
|
|
136
160
|
const output = compile();
|
|
137
161
|
store(dir, file, output);
|
|
138
|
-
return output
|
|
162
|
+
return output;
|
|
139
163
|
}
|