@jxsuite/schema 3.0.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/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.85.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": "3.0.0",
63
+ "version": "4.0.0",
64
64
  "devDependencies": {
65
65
  "json-schema-to-ts": "^3.1.1"
66
66
  }
package/schema.json CHANGED
@@ -14845,6 +14845,32 @@
14845
14845
  }
14846
14846
  ]
14847
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
+ },
14848
14874
  "flex": {
14849
14875
  "oneOf": [
14850
14876
  {
@@ -15209,19 +15235,6 @@
15209
15235
  }
15210
15236
  ]
15211
15237
  },
15212
- "flow-tolerance": {
15213
- "oneOf": [
15214
- {
15215
- "type": "string"
15216
- },
15217
- {
15218
- "type": "number"
15219
- },
15220
- {
15221
- "$ref": "#/$defs/RefObject"
15222
- }
15223
- ]
15224
- },
15225
15238
  "flowFrom": {
15226
15239
  "oneOf": [
15227
15240
  {
@@ -15248,19 +15261,6 @@
15248
15261
  }
15249
15262
  ]
15250
15263
  },
15251
- "flowTolerance": {
15252
- "oneOf": [
15253
- {
15254
- "type": "string"
15255
- },
15256
- {
15257
- "type": "number"
15258
- },
15259
- {
15260
- "$ref": "#/$defs/RefObject"
15261
- }
15262
- ]
15263
- },
15264
15264
  "font": {
15265
15265
  "oneOf": [
15266
15266
  {
@@ -25219,6 +25219,19 @@
25219
25219
  }
25220
25220
  ]
25221
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
+ },
25222
25235
  "timeline-trigger-source": {
25223
25236
  "oneOf": [
25224
25237
  {
@@ -25349,6 +25362,19 @@
25349
25362
  }
25350
25363
  ]
25351
25364
  },
25365
+ "timelineTriggerRange": {
25366
+ "oneOf": [
25367
+ {
25368
+ "type": "string"
25369
+ },
25370
+ {
25371
+ "type": "number"
25372
+ },
25373
+ {
25374
+ "$ref": "#/$defs/RefObject"
25375
+ }
25376
+ ]
25377
+ },
25352
25378
  "timelineTriggerSource": {
25353
25379
  "oneOf": [
25354
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
 
@@ -89,6 +91,26 @@ export interface AssetMount {
89
91
  filter?: (path: string) => boolean;
90
92
  }
91
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
+
92
114
  /** Normalize a path or prefix for comparison: forward slashes, no trailing slash. */
93
115
  function normalizeDir(value: string): string {
94
116
  const forward = value.replaceAll("\\", "/");
@@ -580,7 +602,7 @@ export function formatSrcset(candidates: readonly SrcsetCandidate[]): string {
580
602
  * @returns {string} `""` or a path with a leading slash and no trailing one
581
603
  */
582
604
  export function siteBasePath(url: string | undefined | null): string {
583
- if (typeof url !== "string" || url === "") {
605
+ if (!isString(url) || url === "") {
584
606
  return "";
585
607
  }
586
608
  let pathname: string;