@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 +4 -4
- package/schema.json +52 -26
- package/src/a11y.ts +91 -95
- package/src/asset-paths.ts +23 -1
- package/src/component-meta.ts +123 -55
- package/src/dialogs.ts +20 -19
- package/src/doc-ops.ts +59 -35
- package/src/extension-registry.ts +15 -8
- package/src/format-registry.ts +504 -31
- package/src/guards.ts +89 -14
- package/src/ijson.ts +8 -8
- package/src/json-layout.ts +64 -25
- package/src/locale.ts +23 -18
- package/src/media-type.ts +26 -12
- package/src/overlays.ts +50 -45
- package/src/parse.ts +42 -24
- package/src/project-schemas.ts +94 -66
- package/src/schema.ts +50 -38
- package/src/validate-project.ts +34 -11
- package/types.ts +116 -47
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"dependencies": {
|
|
3
|
-
"@webref/css": "^8.7.
|
|
4
|
-
"@webref/elements": "^2.9.
|
|
5
|
-
"@webref/idl": "^3.85.
|
|
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": "
|
|
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:
|
|
46
|
+
criterion: WcagCriterion;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
/** The success criteria the rules cite, by number. */
|
|
50
|
-
export const WCAG_CRITERIA
|
|
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
|
-
/**
|
|
62
|
-
|
|
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
|
|
85
|
+
return { kind: "bound" };
|
|
65
86
|
}
|
|
66
|
-
|
|
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
|
|
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 =
|
|
81
|
-
return
|
|
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
|
|
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 =
|
|
95
|
-
if (value ===
|
|
96
|
-
return
|
|
109
|
+
const value = read(node, "role");
|
|
110
|
+
if (value.kind === "bound") {
|
|
111
|
+
return BOUND;
|
|
97
112
|
}
|
|
98
|
-
|
|
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 =
|
|
107
|
-
if (value ===
|
|
108
|
-
return
|
|
118
|
+
const value = read(node, "id");
|
|
119
|
+
if (value.kind === "bound") {
|
|
120
|
+
return BOUND;
|
|
109
121
|
}
|
|
110
|
-
return
|
|
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 =
|
|
116
|
-
|
|
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 (
|
|
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 (
|
|
159
|
+
if (isString(child)) {
|
|
151
160
|
if (child.trim() !== "") {
|
|
152
161
|
return true;
|
|
153
162
|
}
|
|
154
|
-
} else if (child &&
|
|
163
|
+
} else if (isNonNullObject(child) && hasContentName(child)) {
|
|
155
164
|
return true;
|
|
156
165
|
}
|
|
157
166
|
}
|
|
158
|
-
} else if (children
|
|
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) =>
|
|
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:
|
|
199
|
-
menuitem
|
|
200
|
-
menuitemcheckbox
|
|
201
|
-
menuitemradio
|
|
202
|
-
option
|
|
203
|
-
tab
|
|
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(
|
|
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
|
|
233
|
-
yield* walkAncestry(child
|
|
241
|
+
if (isNonNullObject(child)) {
|
|
242
|
+
yield* walkAncestry(child, [...path, "children", index], below, tagsBelow);
|
|
234
243
|
}
|
|
235
244
|
}
|
|
236
|
-
} else if (isJsonObject(children) &&
|
|
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 (
|
|
245
|
-
yield* walkAncestry(root.map
|
|
248
|
+
if (isNodeObject(root.map)) {
|
|
249
|
+
yield* walkAncestry(root.map, [...path, "map"], below, tagsBelow);
|
|
246
250
|
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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:
|
|
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
|
-
*
|
|
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
|
|
285
|
-
/** Every literal
|
|
293
|
+
interface IdTargets {
|
|
294
|
+
/** Every literal id named. */
|
|
286
295
|
ids: Set<string>;
|
|
287
|
-
/**
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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 =
|
|
620
|
-
if (
|
|
613
|
+
const value = read(node, "aria-selected");
|
|
614
|
+
if (value.kind === "bound") {
|
|
621
615
|
return null;
|
|
622
616
|
}
|
|
623
|
-
seen.push(
|
|
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:
|
|
703
|
-
const owned = new Map<string,
|
|
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);
|
package/src/asset-paths.ts
CHANGED
|
@@ -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 (
|
|
605
|
+
if (!isString(url) || url === "") {
|
|
584
606
|
return "";
|
|
585
607
|
}
|
|
586
608
|
let pathname: string;
|