@jxsuite/schema 2.3.0 → 4.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/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Kevin Shenk
3
+ Copyright (c) 2026 Avunu LLC and Jx Suite contributors
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "dependencies": {
3
- "@webref/css": "^8.7.5",
4
- "@webref/elements": "^2.9.0",
5
- "@webref/idl": "^3.84.0",
3
+ "@webref/css": "^8.7.7",
4
+ "@webref/elements": "^2.9.2",
5
+ "@webref/idl": "^3.85.2",
6
6
  "ajv": "^8.20.0",
7
7
  "ajv-formats": "^3.0.1"
8
8
  },
@@ -60,7 +60,7 @@
60
60
  "upgrade": "bun update --latest && bun install"
61
61
  },
62
62
  "type": "module",
63
- "version": "2.3.0",
63
+ "version": "4.0.0",
64
64
  "devDependencies": {
65
65
  "json-schema-to-ts": "^3.1.1"
66
66
  }
package/schema.json CHANGED
@@ -5650,6 +5650,34 @@
5650
5650
  }
5651
5651
  ]
5652
5652
  },
5653
+ "ontoolactivated": {
5654
+ "description": "Event handler for the \"toolactivated\" event.",
5655
+ "oneOf": [
5656
+ {
5657
+ "$ref": "#/$defs/RefObject"
5658
+ },
5659
+ {
5660
+ "$ref": "#/$defs/ExpressionEntry"
5661
+ },
5662
+ {
5663
+ "$ref": "#/$defs/FunctionDef"
5664
+ }
5665
+ ]
5666
+ },
5667
+ "ontoolcancel": {
5668
+ "description": "Event handler for the \"toolcancel\" event.",
5669
+ "oneOf": [
5670
+ {
5671
+ "$ref": "#/$defs/RefObject"
5672
+ },
5673
+ {
5674
+ "$ref": "#/$defs/ExpressionEntry"
5675
+ },
5676
+ {
5677
+ "$ref": "#/$defs/FunctionDef"
5678
+ }
5679
+ ]
5680
+ },
5653
5681
  "ontoolchange": {
5654
5682
  "description": "Event handler for the \"toolchange\" event.",
5655
5683
  "oneOf": [
@@ -14817,6 +14845,32 @@
14817
14845
  }
14818
14846
  ]
14819
14847
  },
14848
+ "fit-tolerance": {
14849
+ "oneOf": [
14850
+ {
14851
+ "type": "string"
14852
+ },
14853
+ {
14854
+ "type": "number"
14855
+ },
14856
+ {
14857
+ "$ref": "#/$defs/RefObject"
14858
+ }
14859
+ ]
14860
+ },
14861
+ "fitTolerance": {
14862
+ "oneOf": [
14863
+ {
14864
+ "type": "string"
14865
+ },
14866
+ {
14867
+ "type": "number"
14868
+ },
14869
+ {
14870
+ "$ref": "#/$defs/RefObject"
14871
+ }
14872
+ ]
14873
+ },
14820
14874
  "flex": {
14821
14875
  "oneOf": [
14822
14876
  {
@@ -15181,19 +15235,6 @@
15181
15235
  }
15182
15236
  ]
15183
15237
  },
15184
- "flow-tolerance": {
15185
- "oneOf": [
15186
- {
15187
- "type": "string"
15188
- },
15189
- {
15190
- "type": "number"
15191
- },
15192
- {
15193
- "$ref": "#/$defs/RefObject"
15194
- }
15195
- ]
15196
- },
15197
15238
  "flowFrom": {
15198
15239
  "oneOf": [
15199
15240
  {
@@ -15220,19 +15261,6 @@
15220
15261
  }
15221
15262
  ]
15222
15263
  },
15223
- "flowTolerance": {
15224
- "oneOf": [
15225
- {
15226
- "type": "string"
15227
- },
15228
- {
15229
- "type": "number"
15230
- },
15231
- {
15232
- "$ref": "#/$defs/RefObject"
15233
- }
15234
- ]
15235
- },
15236
15264
  "font": {
15237
15265
  "oneOf": [
15238
15266
  {
@@ -25191,6 +25219,19 @@
25191
25219
  }
25192
25220
  ]
25193
25221
  },
25222
+ "timeline-trigger-range": {
25223
+ "oneOf": [
25224
+ {
25225
+ "type": "string"
25226
+ },
25227
+ {
25228
+ "type": "number"
25229
+ },
25230
+ {
25231
+ "$ref": "#/$defs/RefObject"
25232
+ }
25233
+ ]
25234
+ },
25194
25235
  "timeline-trigger-source": {
25195
25236
  "oneOf": [
25196
25237
  {
@@ -25321,6 +25362,19 @@
25321
25362
  }
25322
25363
  ]
25323
25364
  },
25365
+ "timelineTriggerRange": {
25366
+ "oneOf": [
25367
+ {
25368
+ "type": "string"
25369
+ },
25370
+ {
25371
+ "type": "number"
25372
+ },
25373
+ {
25374
+ "$ref": "#/$defs/RefObject"
25375
+ }
25376
+ ]
25377
+ },
25324
25378
  "timelineTriggerSource": {
25325
25379
  "oneOf": [
25326
25380
  {
package/src/a11y.ts CHANGED
@@ -15,7 +15,7 @@
15
15
  * @docs framework/concepts/accessibility
16
16
  */
17
17
 
18
- import { isJsonObject, isRef } from "./guards";
18
+ import { isJsonObject, isNodeObject, isNonNullObject, isRef, isString } from "./guards";
19
19
  import { attrs, labelOf, tagOf } from "./overlays";
20
20
  import type { JxElement } from "../types";
21
21
  import type { PopoverPath } from "./overlays";
@@ -43,47 +43,62 @@ export interface A11yDefect {
43
43
  /** `error` fails a conformance check; `warn` is very probably not what was meant. */
44
44
  severity: "error" | "warn";
45
45
  /** The WCAG 2.2 success criterion the rule stands on, e.g. `4.1.2`. */
46
- criterion: string;
46
+ criterion: WcagCriterion;
47
47
  }
48
48
 
49
49
  /** The success criteria the rules cite, by number. */
50
- export const WCAG_CRITERIA: Readonly<Record<string, string>> = {
50
+ export const WCAG_CRITERIA = {
51
51
  "1.1.1": "Non-text Content",
52
52
  "1.3.1": "Info and Relationships",
53
53
  "2.1.1": "Keyboard",
54
54
  "4.1.2": "Name, Role, Value",
55
- };
55
+ } as const;
56
+
57
+ /** The number of a success criterion some rule here cites: a key of {@link WCAG_CRITERIA}. */
58
+ export type WcagCriterion = keyof typeof WCAG_CRITERIA;
56
59
 
57
60
  const DOCS = "See docs/framework/concepts/accessibility.";
58
61
 
59
62
  // ─── Reading a node ─────────────────────────────────────────────────────────────
60
63
 
61
- /** A value the document decides at run time: a `$ref`, an expression, or a `${…}` template. */
62
- function isBound(value: unknown): boolean {
64
+ /**
65
+ * An attribute or top-level property as the rules can read it.
66
+ *
67
+ * `absent` is not written (or written as `null`); `bound` is a value the document decides at run
68
+ * time (a `$ref`, an expression, or a `${…}` template); `text` is a literal string; `other` is any
69
+ * other literal (a number, a boolean, an array), of which only `true` means anything to a rule.
70
+ */
71
+ type Reading =
72
+ | { kind: "absent" }
73
+ | { kind: "bound" }
74
+ | { kind: "text"; text: string }
75
+ | { kind: "other"; isTrue: boolean };
76
+
77
+ /** Read an attribute or top-level property, whichever the author wrote. */
78
+ function read(node: JxElement, name: string): Reading {
79
+ const bag = attrs(node);
80
+ const value = name in bag ? bag[name] : node[name];
81
+ if (value === undefined || value === null) {
82
+ return { kind: "absent" };
83
+ }
63
84
  if (isRef(value) || isJsonObject(value)) {
64
- return true;
85
+ return { kind: "bound" };
65
86
  }
66
- return typeof value === "string" && value.includes("${");
67
- }
68
-
69
- /** An attribute or top-level property, whichever the author wrote. */
70
- function valueOf(node: JxElement, name: string): unknown {
71
- const bag = attrs(node);
72
- if (name in bag) {
73
- return bag[name];
87
+ if (isString(value)) {
88
+ return value.includes("${") ? { kind: "bound" } : { kind: "text", text: value };
74
89
  }
75
- return (node as Record<string, unknown>)[name];
90
+ return { isTrue: value === true, kind: "other" };
76
91
  }
77
92
 
78
93
  /** A literal, non-empty string value, or null when absent, empty or bound. */
79
94
  function literal(node: JxElement, name: string): string | null {
80
- const value = valueOf(node, name);
81
- return typeof value === "string" && value.trim() !== "" && !isBound(value) ? value : null;
95
+ const value = read(node, name);
96
+ return value.kind === "text" && value.text.trim() !== "" ? value.text : null;
82
97
  }
83
98
 
84
99
  /** Whether the author wrote the attribute at all, bound or not. */
85
100
  function has(node: JxElement, name: string): boolean {
86
- return valueOf(node, name) !== undefined && valueOf(node, name) !== null;
101
+ return read(node, name).kind !== "absent";
87
102
  }
88
103
 
89
104
  /** The sentinel a reader returns for a value the document decides at run time. */
@@ -91,32 +106,26 @@ const BOUND = "bound";
91
106
 
92
107
  /** The node's role, lowercased; `null` when it has none and `"bound"` when it is not decidable. */
93
108
  function roleOf(node: JxElement): string | null {
94
- const value = valueOf(node, "role");
95
- if (value === undefined || value === null) {
96
- return null;
109
+ const value = read(node, "role");
110
+ if (value.kind === "bound") {
111
+ return BOUND;
97
112
  }
98
- if (isBound(value)) {
99
- return "bound";
100
- }
101
- return typeof value === "string" ? value.trim().toLowerCase().split(/\s+/)[0]! : null;
113
+ return value.kind === "text" ? value.text.trim().toLowerCase().split(/\s+/)[0]! : null;
102
114
  }
103
115
 
104
116
  /** The node's literal id, or `"bound"` when it has one the document decides at run time. */
105
117
  function idStateOf(node: JxElement): string | null {
106
- const value = valueOf(node, "id");
107
- if (value === undefined || value === null || value === "") {
108
- return null;
118
+ const value = read(node, "id");
119
+ if (value.kind === "bound") {
120
+ return BOUND;
109
121
  }
110
- return isBound(value) ? "bound" : typeof value === "string" ? value : null;
122
+ return value.kind === "text" && value.text !== "" ? value.text : null;
111
123
  }
112
124
 
113
125
  /** Whether a name source the platform reads is present, bound or literal and non-empty. */
114
126
  function hasNameSource(node: JxElement, name: string): boolean {
115
- const value = valueOf(node, name);
116
- if (value === undefined || value === null) {
117
- return false;
118
- }
119
- return isBound(value) || (typeof value === "string" && value.trim() !== "");
127
+ const value = read(node, name);
128
+ return value.kind === "bound" || (value.kind === "text" && value.text.trim() !== "");
120
129
  }
121
130
 
122
131
  /**
@@ -128,7 +137,7 @@ function hasNameSource(node: JxElement, name: string): boolean {
128
137
  */
129
138
  function hasContentName(node: JxElement): boolean {
130
139
  const text = node.textContent;
131
- if (typeof text === "string") {
140
+ if (isString(text)) {
132
141
  if (text.trim() !== "") {
133
142
  return true;
134
143
  }
@@ -147,23 +156,23 @@ function hasContentName(node: JxElement): boolean {
147
156
  const { children } = node;
148
157
  if (Array.isArray(children)) {
149
158
  for (const child of children) {
150
- if (typeof child === "string") {
159
+ if (isString(child)) {
151
160
  if (child.trim() !== "") {
152
161
  return true;
153
162
  }
154
- } else if (child && typeof child === "object" && hasContentName(child)) {
163
+ } else if (isNonNullObject(child) && hasContentName(child)) {
155
164
  return true;
156
165
  }
157
166
  }
158
- } else if (children !== undefined && children !== null) {
159
- // A mapped array: rows the document decides.
167
+ } else if (children != null) {
168
+ // A mapped array: rows the document decides. (`!= null` also refuses a JSON `null`.)
160
169
  return true;
161
170
  }
162
171
  if (isJsonObject(node.map)) {
163
172
  return true;
164
173
  }
165
174
  const cases = isJsonObject(node.cases) ? Object.values(node.cases) : [];
166
- return cases.some((branch) => isJsonObject(branch) && hasContentName(branch as JxElement));
175
+ return cases.some((branch) => isNodeObject(branch) && hasContentName(branch));
167
176
  }
168
177
 
169
178
  /** Roles that take their name from content, as ARIA's "name from: contents" allows. */
@@ -195,16 +204,16 @@ const LABELLED_ROLES = new Set([
195
204
  const VALUE_NAMED_INPUTS = new Set(["button", "reset", "submit"]);
196
205
 
197
206
  /** For each role the container rules judge, the container roles allowed to own it. */
198
- const OWNING_ROLES: Readonly<Record<string, readonly string[]>> = {
199
- menuitem: ["menu", "menubar"],
200
- menuitemcheckbox: ["menu", "menubar"],
201
- menuitemradio: ["menu", "menubar"],
202
- option: ["listbox"],
203
- tab: ["tablist"],
204
- };
207
+ const OWNING_ROLES: ReadonlyMap<string, readonly string[]> = new Map([
208
+ ["menuitem", ["menu", "menubar"]],
209
+ ["menuitemcheckbox", ["menu", "menubar"]],
210
+ ["menuitemradio", ["menu", "menubar"]],
211
+ ["option", ["listbox"]],
212
+ ["tab", ["tablist"]],
213
+ ]);
205
214
 
206
215
  /** Every role on the owning side of that map, so one walk can collect what each of them owns. */
207
- const CONTAINER_ROLES = new Set(Object.values(OWNING_ROLES).flat());
216
+ const CONTAINER_ROLES = new Set([...OWNING_ROLES.values()].flat());
208
217
 
209
218
  // ─── Walking with ancestors ─────────────────────────────────────────────────────
210
219
 
@@ -229,25 +238,21 @@ function* walkAncestry(
229
238
  const { children } = root;
230
239
  if (Array.isArray(children)) {
231
240
  for (const [index, child] of children.entries()) {
232
- if (child && typeof child === "object") {
233
- yield* walkAncestry(child as JxElement, [...path, "children", index], below, tagsBelow);
241
+ if (isNonNullObject(child)) {
242
+ yield* walkAncestry(child, [...path, "children", index], below, tagsBelow);
234
243
  }
235
244
  }
236
- } else if (isJsonObject(children) && isJsonObject((children as JxElement).map)) {
237
- yield* walkAncestry(
238
- (children as JxElement).map as JxElement,
239
- [...path, "children", "map"],
240
- below,
241
- tagsBelow,
242
- );
245
+ } else if (isJsonObject(children) && isNodeObject(children.map)) {
246
+ yield* walkAncestry(children.map, [...path, "children", "map"], below, tagsBelow);
243
247
  }
244
- if (isJsonObject(root.map)) {
245
- yield* walkAncestry(root.map as JxElement, [...path, "map"], below, tagsBelow);
248
+ if (isNodeObject(root.map)) {
249
+ yield* walkAncestry(root.map, [...path, "map"], below, tagsBelow);
246
250
  }
247
- const cases = isJsonObject(root.cases) ? (root.cases as Record<string, JxElement>) : {};
248
- for (const [key, branch] of Object.entries(cases)) {
249
- if (branch && typeof branch === "object") {
250
- yield* walkAncestry(branch, [...path, "cases", key], below, tagsBelow);
251
+ if (isJsonObject(root.cases)) {
252
+ for (const [key, branch] of Object.entries(root.cases)) {
253
+ if (isNonNullObject(branch)) {
254
+ yield* walkAncestry(branch, [...path, "cases", key], below, tagsBelow);
255
+ }
251
256
  }
252
257
  }
253
258
  }
@@ -264,7 +269,7 @@ function defect(
264
269
  path: PopoverPath,
265
270
  message: string,
266
271
  detail: string,
267
- criterion: string,
272
+ criterion: WcagCriterion,
268
273
  severity: "error" | "warn" = "error",
269
274
  ): A11yDefect {
270
275
  return {
@@ -278,13 +283,17 @@ function defect(
278
283
  }
279
284
 
280
285
  /**
281
- * What the document's `<label>` elements point at, and whether any of them points somewhere
286
+ * The literal ids a family of id references names, and whether any of them points somewhere
282
287
  * unreadable.
288
+ *
289
+ * Two families use it: what the document's `<label for>` elements point at, where a `<label
290
+ * for="row-${index}">` makes which control it names undecidable; and what the containers of one
291
+ * role own through `aria-owns`, where one owning `"tab-${state.i}"` does the same.
283
292
  */
284
- interface LabelTargets {
285
- /** Every literal `for` value. */
293
+ interface IdTargets {
294
+ /** Every literal id named. */
286
295
  ids: Set<string>;
287
- /** A `<label for="row-${index}">` exists, so which control it names is not decidable here. */
296
+ /** One reference is bound, so which elements the family names is not decidable here. */
288
297
  bound: boolean;
289
298
  }
290
299
 
@@ -299,7 +308,7 @@ interface LabelTargets {
299
308
  * literally that pairing looked like no label at all, so the one correct form was the one reported
300
309
  * — and at `error` severity, which fails `jx validate --strict` on a document that is fine.
301
310
  */
302
- function labelledByLabel(id: string | null, labelledIds: LabelTargets, tags: string[]): boolean {
311
+ function labelledByLabel(id: string | null, labelledIds: IdTargets, tags: string[]): boolean {
303
312
  if (tags.includes("label")) {
304
313
  return true;
305
314
  }
@@ -309,7 +318,7 @@ function labelledByLabel(id: string | null, labelledIds: LabelTargets, tags: str
309
318
  return id !== null && labelledIds.ids.has(id);
310
319
  }
311
320
 
312
- function unnamedInteractive(visit: Ancestry, labelledIds: LabelTargets): A11yDefect | null {
321
+ function unnamedInteractive(visit: Ancestry, labelledIds: IdTargets): A11yDefect | null {
313
322
  const { node, path, tags } = visit;
314
323
  const tag = tagOf(node);
315
324
  const role = roleOf(node);
@@ -377,7 +386,7 @@ function unnamedInteractive(visit: Ancestry, labelledIds: LabelTargets): A11yDef
377
386
  function nativeNameRoute(
378
387
  node: JxElement,
379
388
  tag: string,
380
- labelledIds: LabelTargets,
389
+ labelledIds: IdTargets,
381
390
  tags: string[],
382
391
  ): "named" | "exempt" | "unnamed" | "unnamed-value" | "unnamed-alt" | "none" {
383
392
  /*
@@ -512,14 +521,6 @@ function ariaTargetsMissing(visit: Ancestry, ids: Set<string>): A11yDefect[] {
512
521
  return defects;
513
522
  }
514
523
 
515
- /** What the containers of one role name through `aria-owns`, across the whole document. */
516
- interface OwnedTargets {
517
- /** Every literal id such a container owns. */
518
- ids: Set<string>;
519
- /** One of them owns `"tab-${state.i}"`, so which elements it owns is not decidable here. */
520
- bound: boolean;
521
- }
522
-
523
524
  /**
524
525
  * Whether a container that may own this role claims it through `aria-owns`.
525
526
  *
@@ -534,12 +535,8 @@ interface OwnedTargets {
534
535
  * this stays a silence rather than a hole: a `role="banner" aria-owns="t1"` over a tab owns nothing
535
536
  * a tab may belong to, and the tab is still reported.
536
537
  */
537
- function ownedByContainer(
538
- id: string | null,
539
- role: string,
540
- owned: Map<string, OwnedTargets>,
541
- ): boolean {
542
- for (const container of OWNING_ROLES[role] ?? []) {
538
+ function ownedByContainer(id: string | null, role: string, owned: Map<string, IdTargets>): boolean {
539
+ for (const container of OWNING_ROLES.get(role) ?? []) {
543
540
  const targets = owned.get(container);
544
541
  if (targets === undefined) {
545
542
  continue;
@@ -557,10 +554,7 @@ function ownedByContainer(
557
554
  return false;
558
555
  }
559
556
 
560
- function roleOutsideContainer(
561
- visit: Ancestry,
562
- owned: Map<string, OwnedTargets>,
563
- ): A11yDefect | null {
557
+ function roleOutsideContainer(visit: Ancestry, owned: Map<string, IdTargets>): A11yDefect | null {
564
558
  const { node, path, roles, tags } = visit;
565
559
  const role = roleOf(node);
566
560
  if (role === null || role === "bound" || path.length === 0 || underCustomElement(tags)) {
@@ -616,11 +610,13 @@ function tabSelections(root: JxElement): boolean[] | null {
616
610
  if (roleOf(node) !== "tab") {
617
611
  continue;
618
612
  }
619
- const value = valueOf(node, "aria-selected");
620
- if (isBound(value)) {
613
+ const value = read(node, "aria-selected");
614
+ if (value.kind === "bound") {
621
615
  return null;
622
616
  }
623
- seen.push(value === true || value === "true");
617
+ seen.push(
618
+ (value.kind === "other" && value.isTrue) || (value.kind === "text" && value.text === "true"),
619
+ );
624
620
  }
625
621
  return seen;
626
622
  }
@@ -699,8 +695,8 @@ function activedescendantNotFocusable(visit: Ancestry): A11yDefect | null {
699
695
  export function findA11yDefects(doc: JxElement): A11yDefect[] {
700
696
  const visits = [...walkAncestry(doc)];
701
697
  const ids = new Set<string>();
702
- const labelledIds: LabelTargets = { bound: false, ids: new Set<string>() };
703
- const owned = new Map<string, OwnedTargets>();
698
+ const labelledIds: IdTargets = { bound: false, ids: new Set<string>() };
699
+ const owned = new Map<string, IdTargets>();
704
700
  let boundIds = false;
705
701
  for (const { node } of visits) {
706
702
  const role = roleOf(node);
@@ -11,6 +11,8 @@
11
11
  * Pure string math only — no node imports — so browser hosts can import this module.
12
12
  */
13
13
 
14
+ import { isString } from "./guards";
15
+
14
16
  /** Prefix for npm package specifiers in `$src` (spec §12): `npm:<pkg>[/subpath]`. */
15
17
  export const NPM_SPECIFIER_PREFIX = "npm:";
16
18
 
@@ -77,8 +79,38 @@ export interface AssetMount {
77
79
  urlPrefix: string;
78
80
  /** Absolute directory the prefix maps onto. */
79
81
  dir: string;
82
+ /**
83
+ * Whether the mount serves a file, asked with the file's path relative to {@link dir},
84
+ * `/`-separated. Absent means every file under the directory. A mount over a folder of notes sets
85
+ * it so that what the collection leaves out (`exclude`, hidden files, unpublished documents) is
86
+ * never reachable through the mount either: {@link assetUrlFor} gives a refused file no URL and
87
+ * {@link resolveAssetUrl} resolves a refused URL to nothing, so no host serves or copies it. It is
88
+ * a function, so it exists only where the mount was declared; a host that rebuilds a mount from
89
+ * its two strings (a project-relative copy for Studio) gets none.
90
+ */
91
+ filter?: (path: string) => boolean;
80
92
  }
81
93
 
94
+ /**
95
+ * The project.json section key content types live under; also the URL namespace of their asset
96
+ * mounts (`/content/<type>`). Shared by the parser's content loader, which declares the mounts, and
97
+ * Studio's canvas, which rebuilds them in a browser.
98
+ */
99
+ export const CONTENT_SECTION_KEY = "content";
100
+
101
+ /** Content type names (and locale segments) safe to place in a URL path segment unescaped. */
102
+ export const SAFE_CONTENT_TYPE_NAME = /^[\w.~-]+$/;
103
+
104
+ /**
105
+ * The placeholder a content type's `source` may carry to say "one directory per locale".
106
+ *
107
+ * `./blog/{locale}/` is one content type spread over N directories, not N content types. That
108
+ * distinction is what keeps a translated post the same post: it keeps one schema, one set of
109
+ * relationship targets, and one name in `$paths`, and differs only in which directory it was read
110
+ * from — which is exactly the model `site-architecture.md` §13 uses for routes.
111
+ */
112
+ export const LOCALE_PLACEHOLDER = "{locale}";
113
+
82
114
  /** Normalize a path or prefix for comparison: forward slashes, no trailing slash. */
83
115
  function normalizeDir(value: string): string {
84
116
  const forward = value.replaceAll("\\", "/");
@@ -117,6 +149,10 @@ export function assetUrlFor(mounts: readonly AssetMount[], absolutePath: string)
117
149
  continue;
118
150
  }
119
151
  const rest = path.slice(dir.length + 1);
152
+ const { filter: serves } = mount;
153
+ if (serves && !serves(rest)) {
154
+ return null;
155
+ }
120
156
  const encoded = rest
121
157
  .split("/")
122
158
  .map((segment) => encodeURIComponent(segment))
@@ -160,6 +196,10 @@ export function resolveAssetUrl(mounts: readonly AssetMount[], url: string): str
160
196
  if (segments.some((s) => s === "" || s === "." || s === "..")) {
161
197
  return null;
162
198
  }
199
+ const { filter: serves } = mount;
200
+ if (serves && !serves(segments.join("/"))) {
201
+ return null;
202
+ }
163
203
  return `${normalizeDir(mount.dir)}/${segments.join("/")}`;
164
204
  }
165
205
  return null;
@@ -562,7 +602,7 @@ export function formatSrcset(candidates: readonly SrcsetCandidate[]): string {
562
602
  * @returns {string} `""` or a path with a leading slash and no trailing one
563
603
  */
564
604
  export function siteBasePath(url: string | undefined | null): string {
565
- if (typeof url !== "string" || url === "") {
605
+ if (!isString(url) || url === "") {
566
606
  return "";
567
607
  }
568
608
  let pathname: string;