@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 +1 -1
- package/package.json +4 -4
- package/schema.json +80 -26
- package/src/a11y.ts +91 -95
- package/src/asset-paths.ts +41 -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 +136 -47
package/LICENSE
CHANGED
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.
|
|
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
|
@@ -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:
|
|
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
|
|
|
@@ -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 (
|
|
605
|
+
if (!isString(url) || url === "") {
|
|
566
606
|
return "";
|
|
567
607
|
}
|
|
568
608
|
let pathname: string;
|