@jxsuite/schema 1.8.1 → 2.0.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/README.md +6 -6
- package/defs/function-def.schema.ts +7 -0
- package/package.json +5 -5
- package/schema.json +35 -0
- package/src/asset-paths.ts +342 -0
- package/src/locale.ts +24 -0
- package/types.ts +14 -0
package/README.md
CHANGED
|
@@ -44,12 +44,12 @@ const valid = await validateDocument(doc); // validate a Jx document
|
|
|
44
44
|
|
|
45
45
|
## Component schema coverage
|
|
46
46
|
|
|
47
|
-
- **`tagName
|
|
48
|
-
- **Element properties
|
|
49
|
-
- **`style
|
|
50
|
-
- **Event handlers
|
|
51
|
-
- **`state` shapes
|
|
52
|
-
- **Built-in `$prototype` values
|
|
47
|
+
- **`tagName`**: all standard HTML element names from `@webref/elements`
|
|
48
|
+
- **Element properties**: all DOM IDL properties from `@webref/idl`
|
|
49
|
+
- **`style`**: all CSSOM camelCase properties from `@webref/css`
|
|
50
|
+
- **Event handlers**: all `EventHandler` names (`onclick`, `oninput`, …)
|
|
51
|
+
- **`state` shapes**: naked value, typed value, computed, function, external class
|
|
52
|
+
- **Built-in `$prototype` values**: `Request`, `LocalStorage`, `SessionStorage`, `Cookie`, `IndexedDB`, `Array`, `Set`, `Map`, `Blob`, `ReadableStream`, `URLSearchParams`, `FormData`
|
|
53
53
|
|
|
54
54
|
## VSCode / editor integration
|
|
55
55
|
|
|
@@ -9,6 +9,13 @@ export const functionDefSchema = {
|
|
|
9
9
|
description: "Named export in $src module. Defaults to the state key name.",
|
|
10
10
|
type: "string",
|
|
11
11
|
},
|
|
12
|
+
$lazy: {
|
|
13
|
+
description:
|
|
14
|
+
"Load $src on first call instead of importing it up front. The function then returns a " +
|
|
15
|
+
"promise, so it may only be called from a handler or lifecycle hook — never bound as a " +
|
|
16
|
+
"computed value. Use it for code most visitors never reach.",
|
|
17
|
+
type: "boolean",
|
|
18
|
+
},
|
|
12
19
|
$prototype: { const: "Function", type: "string" },
|
|
13
20
|
$src: {
|
|
14
21
|
description: "External module specifier. Mutually exclusive with body.",
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"dependencies": {
|
|
3
|
-
"@webref/css": "^8.7.
|
|
4
|
-
"@webref/elements": "^2.
|
|
5
|
-
"@webref/idl": "^3.
|
|
3
|
+
"@webref/css": "^8.7.3",
|
|
4
|
+
"@webref/elements": "^2.8.0",
|
|
5
|
+
"@webref/idl": "^3.83.0",
|
|
6
6
|
"ajv": "^8.20.0",
|
|
7
7
|
"ajv-formats": "^3.0.1"
|
|
8
8
|
},
|
|
@@ -52,10 +52,10 @@
|
|
|
52
52
|
"schema": "bun run src/schema.ts",
|
|
53
53
|
"test": "bun test --isolate",
|
|
54
54
|
"test:coverage": "bun test --isolate --coverage",
|
|
55
|
-
"upgrade": "
|
|
55
|
+
"upgrade": "bun update --latest && bun install"
|
|
56
56
|
},
|
|
57
57
|
"type": "module",
|
|
58
|
-
"version": "
|
|
58
|
+
"version": "2.0.0",
|
|
59
59
|
"devDependencies": {
|
|
60
60
|
"json-schema-to-ts": "^3.1.1"
|
|
61
61
|
}
|
package/schema.json
CHANGED
|
@@ -211,6 +211,10 @@
|
|
|
211
211
|
"description": "Named export in $src module. Defaults to the state key name.",
|
|
212
212
|
"type": "string"
|
|
213
213
|
},
|
|
214
|
+
"$lazy": {
|
|
215
|
+
"description": "Load $src on first call instead of importing it up front. The function then returns a promise, so it may only be called from a handler or lifecycle hook — never bound as a computed value. Use it for code most visitors never reach.",
|
|
216
|
+
"type": "boolean"
|
|
217
|
+
},
|
|
214
218
|
"$prototype": {
|
|
215
219
|
"const": "Function",
|
|
216
220
|
"type": "string"
|
|
@@ -2565,6 +2569,20 @@
|
|
|
2565
2569
|
}
|
|
2566
2570
|
]
|
|
2567
2571
|
},
|
|
2572
|
+
"onconfigurationchange": {
|
|
2573
|
+
"description": "Event handler for the \"configurationchange\" event.",
|
|
2574
|
+
"oneOf": [
|
|
2575
|
+
{
|
|
2576
|
+
"$ref": "#/$defs/RefObject"
|
|
2577
|
+
},
|
|
2578
|
+
{
|
|
2579
|
+
"$ref": "#/$defs/ExpressionEntry"
|
|
2580
|
+
},
|
|
2581
|
+
{
|
|
2582
|
+
"$ref": "#/$defs/FunctionDef"
|
|
2583
|
+
}
|
|
2584
|
+
]
|
|
2585
|
+
},
|
|
2568
2586
|
"onconnect": {
|
|
2569
2587
|
"description": "Event handler for the \"connect\" event.",
|
|
2570
2588
|
"oneOf": [
|
|
@@ -5421,6 +5439,20 @@
|
|
|
5421
5439
|
}
|
|
5422
5440
|
]
|
|
5423
5441
|
},
|
|
5442
|
+
"onstream": {
|
|
5443
|
+
"description": "Event handler for the \"stream\" event.",
|
|
5444
|
+
"oneOf": [
|
|
5445
|
+
{
|
|
5446
|
+
"$ref": "#/$defs/RefObject"
|
|
5447
|
+
},
|
|
5448
|
+
{
|
|
5449
|
+
"$ref": "#/$defs/ExpressionEntry"
|
|
5450
|
+
},
|
|
5451
|
+
{
|
|
5452
|
+
"$ref": "#/$defs/FunctionDef"
|
|
5453
|
+
}
|
|
5454
|
+
]
|
|
5455
|
+
},
|
|
5424
5456
|
"onsubmit": {
|
|
5425
5457
|
"description": "Event handler for the \"submit\" event.",
|
|
5426
5458
|
"oneOf": [
|
|
@@ -22776,6 +22808,7 @@
|
|
|
22776
22808
|
"body",
|
|
22777
22809
|
"br",
|
|
22778
22810
|
"button",
|
|
22811
|
+
"camera",
|
|
22779
22812
|
"canvas",
|
|
22780
22813
|
"caption",
|
|
22781
22814
|
"circle",
|
|
@@ -22872,6 +22905,7 @@
|
|
|
22872
22905
|
"meter",
|
|
22873
22906
|
"mfrac",
|
|
22874
22907
|
"mi",
|
|
22908
|
+
"microphone",
|
|
22875
22909
|
"mmultiscripts",
|
|
22876
22910
|
"mn",
|
|
22877
22911
|
"mo",
|
|
@@ -22960,6 +22994,7 @@
|
|
|
22960
22994
|
"u",
|
|
22961
22995
|
"ul",
|
|
22962
22996
|
"use",
|
|
22997
|
+
"usermedia",
|
|
22963
22998
|
"var",
|
|
22964
22999
|
"video",
|
|
22965
23000
|
"view",
|
package/src/asset-paths.ts
CHANGED
|
@@ -196,3 +196,345 @@ export function collectAssetUrls(text: string, mounts: readonly AssetMount[]): s
|
|
|
196
196
|
}
|
|
197
197
|
return [...found].filter(Boolean);
|
|
198
198
|
}
|
|
199
|
+
|
|
200
|
+
// ─── Project paths ↔ site URLs (specs/site-architecture.md §9.3) ─────────────
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The project directory whose contents publish at the site root.
|
|
204
|
+
*
|
|
205
|
+
* A convention rather than a setting: `public/logo.png` is `/logo.png` on the built site, which is
|
|
206
|
+
* why an authored `/logo.png` names a file the project tree calls something else.
|
|
207
|
+
*/
|
|
208
|
+
export const PUBLIC_DIR = "public";
|
|
209
|
+
|
|
210
|
+
/** Node keys whose value is a media reference. Mirrors `ASSET_KEYS` in the content loader. */
|
|
211
|
+
export const ASSET_KEYS = ["src", "poster"] as const;
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* One step in a host's resolution order for a site-absolute URL.
|
|
215
|
+
*
|
|
216
|
+
* - `"mounts"` — an extension asset mount publishes a directory at a URL prefix (extensions.md §8.5).
|
|
217
|
+
* - `"root"` — the URL path is a project-relative path as written.
|
|
218
|
+
* - `"public"` — the URL path is relative to {@link PUBLIC_DIR}.
|
|
219
|
+
*/
|
|
220
|
+
export type AssetLane = "mounts" | "root" | "public";
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* What a BUILD resolves — mounts, then `public/`, and nothing else.
|
|
224
|
+
*
|
|
225
|
+
* This is the published contract (site-architecture.md §9.3): a file at `<root>/hero.jpg` is not
|
|
226
|
+
* addressable at `/hero.jpg` on a deployed site, because nothing copies it there.
|
|
227
|
+
*/
|
|
228
|
+
export const BUILD_LANES: readonly AssetLane[] = ["mounts", "public"];
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* What the editing servers resolve — mounts, then the project root, then `public/`.
|
|
232
|
+
*
|
|
233
|
+
* It is deliberately NOT {@link BUILD_LANES}, and the difference is a real one rather than an
|
|
234
|
+
* implementation detail: `serveProjectFile` in `@jxsuite/server` tries the project root BEFORE
|
|
235
|
+
* `public/`, so `<root>/hero.jpg` loads at `/hero.jpg` in a preview and 404s in production. A test
|
|
236
|
+
* asserts the two constants differ, so nobody can quietly assume they agree.
|
|
237
|
+
*/
|
|
238
|
+
export const DEV_SERVER_LANES: readonly AssetLane[] = ["mounts", "root", "public"];
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Split a reference into the path part and the `?query#hash` suffix.
|
|
242
|
+
*
|
|
243
|
+
* The suffix must survive every rewrite untouched: `./hero.png?v=2` is one file plus a
|
|
244
|
+
* cache-buster, and a rewrite that drops the buster changes what the browser requests.
|
|
245
|
+
*
|
|
246
|
+
* @param {string} value - An authored reference
|
|
247
|
+
* @returns {{ path: string; suffix: string }} The bare path and everything from the first `?`/`#`
|
|
248
|
+
*/
|
|
249
|
+
export function splitRefSuffix(value: string): { path: string; suffix: string } {
|
|
250
|
+
const cut = value.search(/[#?]/);
|
|
251
|
+
return cut === -1
|
|
252
|
+
? { path: value, suffix: "" }
|
|
253
|
+
: { path: value.slice(0, cut), suffix: value.slice(cut) };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* True when `value` is already a URL, a template, a fragment, or empty — never a project file.
|
|
258
|
+
*
|
|
259
|
+
* Mirrors `isNonRelativeRef` in the content loader. A root-relative `/hero.jpg` counts as
|
|
260
|
+
* non-relative here because it is a SITE URL, not a path relative to the document.
|
|
261
|
+
*
|
|
262
|
+
* @param {string} value - An authored reference
|
|
263
|
+
* @returns {boolean}
|
|
264
|
+
*/
|
|
265
|
+
export function isNonRelativeRef(value: string): boolean {
|
|
266
|
+
return (
|
|
267
|
+
value === "" ||
|
|
268
|
+
value.startsWith("/") ||
|
|
269
|
+
value.startsWith("#") ||
|
|
270
|
+
value.includes("${") ||
|
|
271
|
+
/^[a-z][\w+.-]*:/i.test(value)
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Normalize a project-relative path: forward slashes, no `./` prefix, no leading or trailing slash.
|
|
277
|
+
*
|
|
278
|
+
* @param {string} path - A project-relative path in any spelling
|
|
279
|
+
* @returns {string} The canonical spelling
|
|
280
|
+
*/
|
|
281
|
+
export function normalizeProjectPath(path: string): string {
|
|
282
|
+
return path.replaceAll("\\", "/").replace(/^\.\//, "").replace(/^\/+/, "").replace(/\/+$/, "");
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** The directory portion of a project-relative path (`""` for a root-level file). */
|
|
286
|
+
export function dirOfPath(path: string): string {
|
|
287
|
+
const normalized = normalizeProjectPath(path);
|
|
288
|
+
const slash = normalized.lastIndexOf("/");
|
|
289
|
+
return slash === -1 ? "" : normalized.slice(0, slash);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Resolve `ref` against `dir` in a pure, POSIX-ish way — no `node:path`, and no leading-slash
|
|
294
|
+
* assumption, because project paths are relative to a root the browser does not have.
|
|
295
|
+
*
|
|
296
|
+
* @param {string} dir - Project-relative directory (`""` for the root)
|
|
297
|
+
* @param {string} ref - A relative reference
|
|
298
|
+
* @returns {string | null} The project-relative result, or null when it climbs above the root
|
|
299
|
+
*/
|
|
300
|
+
export function joinProjectPath(dir: string, ref: string): string | null {
|
|
301
|
+
const base = dir === "" || dir === "." ? [] : normalizeProjectPath(dir).split("/");
|
|
302
|
+
const out = base.filter((segment) => segment !== "" && segment !== ".");
|
|
303
|
+
for (const segment of ref.replaceAll("\\", "/").split("/")) {
|
|
304
|
+
if (segment === "" || segment === ".") {
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
if (segment === "..") {
|
|
308
|
+
if (out.length === 0) {
|
|
309
|
+
return null; // Escapes the project root.
|
|
310
|
+
}
|
|
311
|
+
out.pop();
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
out.push(segment);
|
|
315
|
+
}
|
|
316
|
+
return out.join("/");
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* The site URL a project file publishes at under `lanes`, or null when nothing publishes it.
|
|
321
|
+
*
|
|
322
|
+
* The lanes are tried in order, so the answer is the URL the host would resolve FIRST — which is
|
|
323
|
+
* what makes {@link projectPathsForSiteUrl} its inverse. Under {@link DEV_SERVER_LANES} that means
|
|
324
|
+
* `public/logo.png` answers `/public/logo.png` (the root lane, which the server tries first) rather
|
|
325
|
+
* than `/logo.png`; both work there, and only one round-trips.
|
|
326
|
+
*
|
|
327
|
+
* @param {string} path - Project-relative path
|
|
328
|
+
* @param {readonly AssetMount[]} mounts - Project-relative asset mounts
|
|
329
|
+
* @param {readonly AssetLane[]} lanes - The host's resolution order
|
|
330
|
+
* @returns {string | null} A site-absolute URL, or null
|
|
331
|
+
*/
|
|
332
|
+
export function siteUrlForPath(
|
|
333
|
+
path: string,
|
|
334
|
+
mounts: readonly AssetMount[],
|
|
335
|
+
lanes: readonly AssetLane[],
|
|
336
|
+
): string | null {
|
|
337
|
+
const normalized = normalizeProjectPath(path);
|
|
338
|
+
if (normalized === "") {
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
for (const lane of lanes) {
|
|
342
|
+
if (lane === "mounts") {
|
|
343
|
+
const url = assetUrlFor(mounts, normalized);
|
|
344
|
+
if (url) {
|
|
345
|
+
return url;
|
|
346
|
+
}
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
if (lane === "root") {
|
|
350
|
+
return `/${encodeProjectPath(normalized)}`;
|
|
351
|
+
}
|
|
352
|
+
if (normalized === PUBLIC_DIR || normalized.startsWith(`${PUBLIC_DIR}/`)) {
|
|
353
|
+
return `/${encodeProjectPath(normalized.slice(PUBLIC_DIR.length + 1))}`;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return null;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Every project path a site-absolute URL could name under `lanes`, in the order the host tries
|
|
361
|
+
* them.
|
|
362
|
+
*
|
|
363
|
+
* A list rather than one answer because the lanes genuinely overlap: `/hero.jpg` is `hero.jpg` to
|
|
364
|
+
* the root lane and `public/hero.jpg` to the public lane, and only a filesystem can say which
|
|
365
|
+
* exists. Callers with no filesystem — the Studio canvas — take the first.
|
|
366
|
+
*
|
|
367
|
+
* @param {string} url - A site-absolute URL (query and hash are ignored)
|
|
368
|
+
* @param {readonly AssetMount[]} mounts - Project-relative asset mounts
|
|
369
|
+
* @param {readonly AssetLane[]} lanes - The host's resolution order
|
|
370
|
+
* @returns {string[]} Candidate project-relative paths, most-preferred first
|
|
371
|
+
*/
|
|
372
|
+
export function projectPathsForSiteUrl(
|
|
373
|
+
url: string,
|
|
374
|
+
mounts: readonly AssetMount[],
|
|
375
|
+
lanes: readonly AssetLane[],
|
|
376
|
+
): string[] {
|
|
377
|
+
const { path } = splitRefSuffix(url);
|
|
378
|
+
if (!path.startsWith("/")) {
|
|
379
|
+
return [];
|
|
380
|
+
}
|
|
381
|
+
let decoded: string;
|
|
382
|
+
try {
|
|
383
|
+
decoded = decodeURIComponent(path);
|
|
384
|
+
} catch {
|
|
385
|
+
return [];
|
|
386
|
+
}
|
|
387
|
+
// A still-encoded dot or slash after one decode is a traversal attempt, not a filename.
|
|
388
|
+
if (/%2e|%2f/i.test(decoded)) {
|
|
389
|
+
return [];
|
|
390
|
+
}
|
|
391
|
+
const rest = decoded.replace(/^\/+/, "");
|
|
392
|
+
if (rest === "" || rest.split("/").some((segment) => segment === "." || segment === "..")) {
|
|
393
|
+
return [];
|
|
394
|
+
}
|
|
395
|
+
const out: string[] = [];
|
|
396
|
+
for (const lane of lanes) {
|
|
397
|
+
if (lane === "mounts") {
|
|
398
|
+
const mounted = resolveAssetUrl(mounts, path);
|
|
399
|
+
// Only a mount INSIDE the project is addressable by path; `../../docs` is not.
|
|
400
|
+
if (mounted !== null && !mounted.startsWith("/") && !/^[A-Za-z]:/.test(mounted)) {
|
|
401
|
+
out.push(normalizeProjectPath(mounted));
|
|
402
|
+
}
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
out.push(lane === "root" ? rest : `${PUBLIC_DIR}/${rest}`);
|
|
406
|
+
}
|
|
407
|
+
return [...new Set(out)];
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* The project file an authored reference names, seen from the document that wrote it.
|
|
412
|
+
*
|
|
413
|
+
* The two shapes an author writes resolve differently and neither goes through a site URL:
|
|
414
|
+
*
|
|
415
|
+
* - A content-relative `./images/hero.png` resolves against the document's own directory, which IS a
|
|
416
|
+
* project path already — `content/posts/images/hero.png`. The mount detour that the built site
|
|
417
|
+
* performs is not needed to name the file, only to publish it.
|
|
418
|
+
* - A root-relative `/hero.jpg` is a site URL, so it resolves through {@link projectPathsForSiteUrl}
|
|
419
|
+
* and takes the host's first lane.
|
|
420
|
+
*
|
|
421
|
+
* Everything else — an absolute URL, a `data:` URI, a `${…}` template, a bare fragment — names no
|
|
422
|
+
* project file and returns null, which callers read as "leave it exactly as written".
|
|
423
|
+
*
|
|
424
|
+
* @param {string} value - The authored reference
|
|
425
|
+
* @param {string} documentDir - Project-relative directory of the document that wrote it
|
|
426
|
+
* @param {readonly AssetMount[]} mounts - Project-relative asset mounts
|
|
427
|
+
* @param {readonly AssetLane[]} lanes - The host's resolution order
|
|
428
|
+
* @returns {string | null} A project-relative path, or null
|
|
429
|
+
*/
|
|
430
|
+
export function projectPathForRef(
|
|
431
|
+
value: string,
|
|
432
|
+
documentDir: string,
|
|
433
|
+
mounts: readonly AssetMount[],
|
|
434
|
+
lanes: readonly AssetLane[],
|
|
435
|
+
): string | null {
|
|
436
|
+
const { path } = splitRefSuffix(value);
|
|
437
|
+
if (path === "") {
|
|
438
|
+
return null;
|
|
439
|
+
}
|
|
440
|
+
if (isNonRelativeRef(value)) {
|
|
441
|
+
return path.startsWith("/") ? (projectPathsForSiteUrl(path, mounts, lanes)[0] ?? null) : null;
|
|
442
|
+
}
|
|
443
|
+
// A reference ending in a slash names a DIRECTORY, and no directory is a media file.
|
|
444
|
+
if (path.endsWith("/")) {
|
|
445
|
+
return null;
|
|
446
|
+
}
|
|
447
|
+
let decoded: string;
|
|
448
|
+
try {
|
|
449
|
+
decoded = decodeURIComponent(path);
|
|
450
|
+
} catch {
|
|
451
|
+
return null;
|
|
452
|
+
}
|
|
453
|
+
const joined = joinProjectPath(documentDir, decoded);
|
|
454
|
+
if (joined === null || joined === "" || joined === normalizeProjectPath(documentDir)) {
|
|
455
|
+
return null;
|
|
456
|
+
}
|
|
457
|
+
return joined;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Percent-encode each path segment so the result survives an HTML attribute.
|
|
462
|
+
*
|
|
463
|
+
* Segment by segment, so the separators stay separators: `encodeURIComponent` on the whole path
|
|
464
|
+
* would turn every `/` into `%2F` and produce one very long filename.
|
|
465
|
+
*
|
|
466
|
+
* @param {string} path - A project-relative path
|
|
467
|
+
* @returns {string} The same path, each segment encoded
|
|
468
|
+
*/
|
|
469
|
+
export function encodeProjectPath(path: string): string {
|
|
470
|
+
return path
|
|
471
|
+
.split("/")
|
|
472
|
+
.map((segment) => encodeURIComponent(segment))
|
|
473
|
+
.join("/");
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// ─── srcset ──────────────────────────────────────────────────────────────────
|
|
477
|
+
|
|
478
|
+
/** One `srcset` candidate: a URL and the descriptor that selects it (`""` when there is none). */
|
|
479
|
+
export interface SrcsetCandidate {
|
|
480
|
+
url: string;
|
|
481
|
+
descriptor: string;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Parse a `srcset` attribute into its candidates.
|
|
486
|
+
*
|
|
487
|
+
* Splitting on `","` is wrong and quietly so: a `data:` URL contains commas, and so does any URL
|
|
488
|
+
* with a query list. This follows HTML's own algorithm instead — collect non-whitespace as the URL,
|
|
489
|
+
* and if it ends in commas, the descriptor is empty and the next candidate starts immediately.
|
|
490
|
+
*
|
|
491
|
+
* @param {string} value - The attribute value
|
|
492
|
+
* @returns {SrcsetCandidate[]} Candidates in source order
|
|
493
|
+
*/
|
|
494
|
+
export function parseSrcset(value: string): SrcsetCandidate[] {
|
|
495
|
+
const out: SrcsetCandidate[] = [];
|
|
496
|
+
let i = 0;
|
|
497
|
+
while (i < value.length) {
|
|
498
|
+
while (i < value.length && (/\s/.test(value[i]!) || value[i] === ",")) {
|
|
499
|
+
i += 1;
|
|
500
|
+
}
|
|
501
|
+
if (i >= value.length) {
|
|
502
|
+
break;
|
|
503
|
+
}
|
|
504
|
+
const start = i;
|
|
505
|
+
while (i < value.length && !/\s/.test(value[i]!)) {
|
|
506
|
+
i += 1;
|
|
507
|
+
}
|
|
508
|
+
let url = value.slice(start, i);
|
|
509
|
+
if (url.endsWith(",")) {
|
|
510
|
+
out.push({ descriptor: "", url: url.replace(/,+$/, "") });
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
while (i < value.length && /\s/.test(value[i]!)) {
|
|
514
|
+
i += 1;
|
|
515
|
+
}
|
|
516
|
+
const descStart = i;
|
|
517
|
+
while (i < value.length && value[i] !== ",") {
|
|
518
|
+
i += 1;
|
|
519
|
+
}
|
|
520
|
+
const descriptor = value.slice(descStart, i).trim();
|
|
521
|
+
i += 1; // Consume the separating comma.
|
|
522
|
+
url = url.replace(/,+$/, "");
|
|
523
|
+
if (url !== "") {
|
|
524
|
+
out.push({ descriptor, url });
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
return out;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Serialize candidates back into a `srcset` attribute value.
|
|
532
|
+
*
|
|
533
|
+
* @param {readonly SrcsetCandidate[]} candidates
|
|
534
|
+
* @returns {string} The attribute value
|
|
535
|
+
*/
|
|
536
|
+
export function formatSrcset(candidates: readonly SrcsetCandidate[]): string {
|
|
537
|
+
return candidates
|
|
538
|
+
.map(({ url, descriptor }) => (descriptor ? `${url} ${descriptor}` : url))
|
|
539
|
+
.join(", ");
|
|
540
|
+
}
|
package/src/locale.ts
CHANGED
|
@@ -427,3 +427,27 @@ export function primaryLanguage(tag: unknown): string | null {
|
|
|
427
427
|
const canonical = canonicalizeLocale(tag);
|
|
428
428
|
return canonical === null ? null : (canonical.split("-")[0] ?? null);
|
|
429
429
|
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* The locale a URL belongs to.
|
|
433
|
+
*
|
|
434
|
+
* Matching is on the **first path segment**, compared canonically, so `/fr-CA/about/` resolves for
|
|
435
|
+
* a project that declared `fr-ca`. A path under no declared locale is the default locale — under
|
|
436
|
+
* `prefix-except-default` that is the point, and under `prefix-always` it is a page the author put
|
|
437
|
+
* outside the locale tree, which is theirs to place.
|
|
438
|
+
*
|
|
439
|
+
* @param {string} urlPattern - Site-absolute route pattern, e.g. `/fr/about/`
|
|
440
|
+
* @param {ResolvedI18n | null} i18n
|
|
441
|
+
* @returns {string | null} A canonical tag, or null when the project has no i18n
|
|
442
|
+
*/
|
|
443
|
+
export function localeOfRoute(urlPattern: string, i18n: ResolvedI18n | null): string | null {
|
|
444
|
+
if (i18n === null) {
|
|
445
|
+
return null;
|
|
446
|
+
}
|
|
447
|
+
const first = urlPattern.split("/").find(Boolean);
|
|
448
|
+
if (first === undefined) {
|
|
449
|
+
return i18n.defaultLocale;
|
|
450
|
+
}
|
|
451
|
+
const canonical = canonicalizeLocale(first);
|
|
452
|
+
return canonical !== null && i18n.locales.includes(canonical) ? canonical : i18n.defaultLocale;
|
|
453
|
+
}
|
package/types.ts
CHANGED
|
@@ -171,6 +171,13 @@ export interface JxFunctionDef {
|
|
|
171
171
|
$src?: string;
|
|
172
172
|
/** Named export within `$src`; defaults to the state key. */
|
|
173
173
|
$export?: string;
|
|
174
|
+
/**
|
|
175
|
+
* Load `$src` on first call rather than importing it with the module.
|
|
176
|
+
*
|
|
177
|
+
* The function returns a promise once lazy, so it may only be called — never bound as a computed
|
|
178
|
+
* value. Meant for code most visitors never reach.
|
|
179
|
+
*/
|
|
180
|
+
$lazy?: boolean;
|
|
174
181
|
/** Legacy alias for `parameters` (bare names only). */
|
|
175
182
|
arguments?: string[];
|
|
176
183
|
timing?: "compiler" | "server" | "client";
|
|
@@ -648,6 +655,13 @@ export interface ProjectConfig {
|
|
|
648
655
|
deploy?: DeployConfig;
|
|
649
656
|
sitemap?: boolean;
|
|
650
657
|
headers?: HeadersConfig;
|
|
658
|
+
/**
|
|
659
|
+
* Minify emitted browser JavaScript. Defaults to true.
|
|
660
|
+
*
|
|
661
|
+
* Set false to ship readable bundles — debugging a deployed page, or diffing `dist/` where the
|
|
662
|
+
* two bundler backends agree on semantics but not on minified bytes.
|
|
663
|
+
*/
|
|
664
|
+
minify?: boolean;
|
|
651
665
|
[key: string]: unknown;
|
|
652
666
|
};
|
|
653
667
|
images?: ImageConfig;
|