@jxsuite/schema 2.0.0 → 2.2.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/class-schema.json +2 -1
- package/defs/children-value.schema.ts +13 -0
- package/defs/class-def.schema.ts +2 -1
- package/defs/element-def.schema.ts +7 -4
- package/defs/statement.schema.ts +29 -2
- package/defs/style-object.schema.ts +52 -3
- package/package.json +6 -3
- package/project-schema.json +24 -3
- package/schema.json +5052 -14
- package/schemas/project.core.schema.json +24 -3
- package/src/a11y.ts +760 -0
- package/src/asset-paths.ts +86 -0
- package/src/dialogs.ts +428 -0
- package/src/format-registry.ts +9 -1
- package/src/guards.ts +43 -5
- package/src/overlays.ts +825 -0
- package/src/schema.ts +28 -4
- package/types.ts +26 -2
package/src/asset-paths.ts
CHANGED
|
@@ -538,3 +538,89 @@ export function formatSrcset(candidates: readonly SrcsetCandidate[]): string {
|
|
|
538
538
|
.map(({ url, descriptor }) => (descriptor ? `${url} ${descriptor}` : url))
|
|
539
539
|
.join(", ");
|
|
540
540
|
}
|
|
541
|
+
|
|
542
|
+
// ─── Deployment base path (RFC 3986 §5) ──────────────────────────────────────
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* The path a site is deployed under, derived from `project.json`'s `url`.
|
|
546
|
+
*
|
|
547
|
+
* **There is no second key for this, on purpose.** Every URL the build emits is a URI reference,
|
|
548
|
+
* and `url` is the base URI it is resolved against (RFC 3986 §5). A site at
|
|
549
|
+
* `https://example.pages.dev/m/my-site/` has already said where it lives; a `build.base` beside it
|
|
550
|
+
* would be a second writer on one fact, free to disagree with the canonical links and the sitemap
|
|
551
|
+
* that are built from the same value.
|
|
552
|
+
*
|
|
553
|
+
* What went wrong was narrower than a missing option: the compiler resolved its references against
|
|
554
|
+
* the base's ORIGIN and discarded its path, which is what §5.2 does to an absolute-path reference
|
|
555
|
+
* (`/assets/x.js`) and is not what a deployment means by one. So the fix is to stop discarding it.
|
|
556
|
+
*
|
|
557
|
+
* Returns `""` for a site at an origin root, a missing `url`, or a `url` that will not parse — all
|
|
558
|
+
* three mean "prefix nothing", and a malformed URL must not take the build down when the only thing
|
|
559
|
+
* it can affect is a prefix. Never has a trailing slash, so `base + "/assets/x"` is well-formed.
|
|
560
|
+
*
|
|
561
|
+
* @param {string | undefined} url - `project.json`'s `url`
|
|
562
|
+
* @returns {string} `""` or a path with a leading slash and no trailing one
|
|
563
|
+
*/
|
|
564
|
+
export function siteBasePath(url: string | undefined | null): string {
|
|
565
|
+
if (typeof url !== "string" || url === "") {
|
|
566
|
+
return "";
|
|
567
|
+
}
|
|
568
|
+
let pathname: string;
|
|
569
|
+
try {
|
|
570
|
+
({ pathname } = new URL(url));
|
|
571
|
+
} catch {
|
|
572
|
+
return "";
|
|
573
|
+
}
|
|
574
|
+
const trimmed = pathname.replace(/\/+$/, "");
|
|
575
|
+
if (trimmed === "" || trimmed === "/") {
|
|
576
|
+
return "";
|
|
577
|
+
}
|
|
578
|
+
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Re-root a site-absolute URL onto the deployment base.
|
|
583
|
+
*
|
|
584
|
+
* Only an absolute-path reference is affected — the one form whose meaning is "from the site root",
|
|
585
|
+
* and therefore the one form that moves when the site root does. A protocol-relative `//host/x`, an
|
|
586
|
+
* absolute URL, a fragment, a query and every relative path already resolve against something else
|
|
587
|
+
* and are returned untouched.
|
|
588
|
+
*
|
|
589
|
+
* **Idempotent**, because a URL may reach this more than once: an emitter that already prefixed and
|
|
590
|
+
* an output pass that prefixes everything must not compound. A value already under the base is
|
|
591
|
+
* returned as-is.
|
|
592
|
+
*
|
|
593
|
+
* @param {string} base - From {@link siteBasePath}
|
|
594
|
+
* @param {string} url - A URL as emitted
|
|
595
|
+
* @returns {string} The URL a browser should be given
|
|
596
|
+
*/
|
|
597
|
+
export function withBase(base: string, url: string): string {
|
|
598
|
+
if (base === "" || !url.startsWith("/") || url.startsWith("//")) {
|
|
599
|
+
return url;
|
|
600
|
+
}
|
|
601
|
+
if (url === base || url.startsWith(`${base}/`)) {
|
|
602
|
+
return url;
|
|
603
|
+
}
|
|
604
|
+
return base + url;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* An absolute URL for a site route, keeping the base's path.
|
|
609
|
+
*
|
|
610
|
+
* `new URL("/about", "https://x.dev/m/site/")` is `https://x.dev/about` — RFC 3986 §5.2 replaces
|
|
611
|
+
* the whole path for an absolute-path reference, which is exactly right for the RFC and exactly
|
|
612
|
+
* wrong for a canonical link. Resolving the route as a RELATIVE-path reference keeps the base, so
|
|
613
|
+
* `<link rel="canonical">`, `sitemap.xml` and `robots.txt` name pages that exist.
|
|
614
|
+
*
|
|
615
|
+
* @param {string} route - A site route (`/about`), leading slash optional
|
|
616
|
+
* @param {string} siteUrl - `project.json`'s `url`
|
|
617
|
+
* @returns {string} The absolute URL
|
|
618
|
+
*/
|
|
619
|
+
export function siteAbsoluteUrl(route: string, siteUrl: string): string {
|
|
620
|
+
const base = new URL(siteUrl);
|
|
621
|
+
// A base whose path does not end in "/" would drop its last segment under §5.2's merge.
|
|
622
|
+
if (!base.pathname.endsWith("/")) {
|
|
623
|
+
base.pathname += "/";
|
|
624
|
+
}
|
|
625
|
+
return new URL(route.replace(/^\/+/, ""), base).href;
|
|
626
|
+
}
|
package/src/dialogs.ts
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dialog and invoker-command rules — the second half of the overlay lint (spec §8.7).
|
|
3
|
+
*
|
|
4
|
+
* `overlays.ts` judges `popover` and `popovertarget`. This module judges the other two things the
|
|
5
|
+
* platform gives a document for overlays: a `<dialog>`, opened modally with `showModal()` and
|
|
6
|
+
* closed by Escape or a `close` command, and the **invoker commands** — `command` and `commandfor`
|
|
7
|
+
* on a `<button>` — that open and close either kind without a line of script. The rules share one
|
|
8
|
+
* shape and one walker with the popover rules, so a document report can file both under one source
|
|
9
|
+
* and a repair can address a node by the same path.
|
|
10
|
+
*
|
|
11
|
+
* Every rule encodes a fact about the platform that is easy to get wrong and hard to see:
|
|
12
|
+
*
|
|
13
|
+
* - `commandfor` and `command` come from `HTMLButtonElement` and nowhere else — not `<input>`, which
|
|
14
|
+
* `popovertarget` does allow — so on any other element they parse and do nothing.
|
|
15
|
+
* - `command` is an enumerated attribute: a value that is not one of the built-in keywords and does
|
|
16
|
+
* not start with `--` is invalid, and an invalid value makes the button do nothing.
|
|
17
|
+
* - `show-modal`, `close` and `request-close` act on a dialog; the three `*-popover` commands act on
|
|
18
|
+
* a popover. A command aimed at the other kind is silently ignored.
|
|
19
|
+
* - A `--custom` command is a `CommandEvent` the target must answer; with no `oncommand` handler
|
|
20
|
+
* there, the click does nothing.
|
|
21
|
+
* - `closedby` is enumerated too: `any`, `closerequest` or `none`.
|
|
22
|
+
* - A modal dialog that closes on nothing — `closedby="none"` and no close button inside — traps the
|
|
23
|
+
* reader, because a modal makes the rest of the page inert.
|
|
24
|
+
* - A closed dialog is hidden by the browser's own `dialog:not([open]) { display: none }`, which is
|
|
25
|
+
* UA-origin, so an author `display` in the base rule shows it on every page. Same defect as a
|
|
26
|
+
* popover's, same repair: the `display` belongs in the open rule.
|
|
27
|
+
*/
|
|
28
|
+
import { getNestedStyle } from "./guards.ts";
|
|
29
|
+
import {
|
|
30
|
+
attrs,
|
|
31
|
+
idOf,
|
|
32
|
+
isPopover,
|
|
33
|
+
labelOf,
|
|
34
|
+
literalAttr,
|
|
35
|
+
popoverIdsIn,
|
|
36
|
+
scalar,
|
|
37
|
+
tagOf,
|
|
38
|
+
walk,
|
|
39
|
+
} from "./overlays.ts";
|
|
40
|
+
|
|
41
|
+
import type { OverlayScope, PopoverPath } from "./overlays.ts";
|
|
42
|
+
import type { JxElement, JxStyle } from "../types.ts";
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The built-in invoker commands.
|
|
46
|
+
*
|
|
47
|
+
* The six the HTML standard defines for popovers and dialogs. Anything else the button does not
|
|
48
|
+
* understand — unless it starts with `--`, which is a custom command the target answers itself.
|
|
49
|
+
*/
|
|
50
|
+
export const INVOKER_COMMANDS = [
|
|
51
|
+
"toggle-popover",
|
|
52
|
+
"show-popover",
|
|
53
|
+
"hide-popover",
|
|
54
|
+
"show-modal",
|
|
55
|
+
"close",
|
|
56
|
+
"request-close",
|
|
57
|
+
] as const;
|
|
58
|
+
|
|
59
|
+
/** The commands that act on a `<dialog>`. */
|
|
60
|
+
export const DIALOG_COMMANDS: ReadonlySet<string> = new Set([
|
|
61
|
+
"show-modal",
|
|
62
|
+
"close",
|
|
63
|
+
"request-close",
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
/** The commands that act on a popover. */
|
|
67
|
+
export const POPOVER_COMMANDS: ReadonlySet<string> = new Set([
|
|
68
|
+
"toggle-popover",
|
|
69
|
+
"show-popover",
|
|
70
|
+
"hide-popover",
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
/** The values `closedby` takes. */
|
|
74
|
+
export const DIALOG_CLOSEDBY = ["any", "closerequest", "none"] as const;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Only a `<button>` carries `command` and `commandfor` (`HTMLButtonElement`, and no other
|
|
78
|
+
* interface).
|
|
79
|
+
*/
|
|
80
|
+
export const COMMAND_INVOKER_TAGS: ReadonlySet<string> = new Set(["button"]);
|
|
81
|
+
|
|
82
|
+
/** Every rule this module can report. */
|
|
83
|
+
export type DialogRule =
|
|
84
|
+
| "invoker-not-button"
|
|
85
|
+
| "command-invalid"
|
|
86
|
+
| "command-target-missing"
|
|
87
|
+
| "command-target-mismatch"
|
|
88
|
+
| "custom-command-unhandled"
|
|
89
|
+
| "dialog-closedby-invalid"
|
|
90
|
+
| "dialog-no-invoker"
|
|
91
|
+
| "modal-without-close"
|
|
92
|
+
| "dialog-display";
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The mechanical repair a finding can carry: `display` moves into the open rule, `invoker` removes
|
|
96
|
+
* the two attributes.
|
|
97
|
+
*/
|
|
98
|
+
export type DialogFix = "display" | "invoker";
|
|
99
|
+
|
|
100
|
+
export interface DialogDefect {
|
|
101
|
+
rule: DialogRule;
|
|
102
|
+
path: PopoverPath;
|
|
103
|
+
message: string;
|
|
104
|
+
detail: string;
|
|
105
|
+
severity: "error" | "warn";
|
|
106
|
+
fix?: DialogFix;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const DOCS = "See docs/framework/concepts/overlays.";
|
|
110
|
+
|
|
111
|
+
/** Whether the node is a `<dialog>`. */
|
|
112
|
+
export function isDialog(node: JxElement): boolean {
|
|
113
|
+
return tagOf(node) === "dialog";
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Whether the value is a custom command. */
|
|
117
|
+
function isCustomCommand(command: string): boolean {
|
|
118
|
+
return command.startsWith("--");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Every `id` in the document that belongs to a `<dialog>`, in document order. */
|
|
122
|
+
export function dialogIdsIn(doc: JxElement): string[] {
|
|
123
|
+
const ids: string[] = [];
|
|
124
|
+
for (const { node } of walk(doc)) {
|
|
125
|
+
const id = idOf(node);
|
|
126
|
+
if (isDialog(node) && id !== null && !ids.includes(id)) {
|
|
127
|
+
ids.push(id);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return ids;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Whether the document holds a `<dialog>` at all — what a "show dialog" verb's enablement asks. */
|
|
134
|
+
export function documentHasDialog(doc: JxElement): boolean {
|
|
135
|
+
for (const { node } of walk(doc)) {
|
|
136
|
+
if (isDialog(node)) {
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Whether a node declares an `oncommand` handler, in any of the handler spellings. */
|
|
144
|
+
function handlesCommand(node: JxElement): boolean {
|
|
145
|
+
const handler = (node as Record<string, unknown>).oncommand;
|
|
146
|
+
return handler !== undefined && handler !== null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The buttons inside `node` whose command closes it, or closes the dialog they are in through a
|
|
151
|
+
* form.
|
|
152
|
+
*/
|
|
153
|
+
function hasCloseControl(dialog: JxElement, dialogId: string | null): boolean {
|
|
154
|
+
for (const { node } of walk(dialog)) {
|
|
155
|
+
if (node === dialog) {
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
const tag = tagOf(node);
|
|
159
|
+
if (tag === "button") {
|
|
160
|
+
const command = literalAttr(node, "command");
|
|
161
|
+
const target = literalAttr(node, "commandfor");
|
|
162
|
+
if (
|
|
163
|
+
command !== null &&
|
|
164
|
+
(command === "close" || command === "request-close") &&
|
|
165
|
+
(target === null || target === dialogId)
|
|
166
|
+
) {
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (tag === "form" && literalAttr(node, "method")?.toLowerCase() === "dialog") {
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Every dialog and invoker-command defect in a document, in document order.
|
|
179
|
+
*
|
|
180
|
+
* @param doc The document to check.
|
|
181
|
+
* @returns The defects, most structural first within each node.
|
|
182
|
+
*/
|
|
183
|
+
export function findDialogDefects(doc: JxElement, scope?: OverlayScope): DialogDefect[] {
|
|
184
|
+
const visits = [...walk(doc, [], null, scope)];
|
|
185
|
+
const dialogIds = dialogIdsIn(doc);
|
|
186
|
+
const popoverIds = popoverIdsIn(doc, scope);
|
|
187
|
+
const hasAnyTarget = dialogIds.length > 0 || popoverIds.length > 0;
|
|
188
|
+
const byId = new Map<string, JxElement>();
|
|
189
|
+
const targeted = new Map<string, Set<string>>();
|
|
190
|
+
for (const { node } of visits) {
|
|
191
|
+
const id = idOf(node);
|
|
192
|
+
if (id !== null && !byId.has(id)) {
|
|
193
|
+
byId.set(id, node);
|
|
194
|
+
}
|
|
195
|
+
if (tagOf(node) === "button") {
|
|
196
|
+
const target = literalAttr(node, "commandfor");
|
|
197
|
+
const command = literalAttr(node, "command");
|
|
198
|
+
if (target !== null) {
|
|
199
|
+
const commands = targeted.get(target) ?? new Set<string>();
|
|
200
|
+
commands.add(command ?? "");
|
|
201
|
+
targeted.set(target, commands);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const defects: DialogDefect[] = [];
|
|
207
|
+
for (const { node, path } of visits) {
|
|
208
|
+
defects.push(...invokerDefects(node, path, byId, dialogIds, popoverIds, hasAnyTarget, scope));
|
|
209
|
+
if (isDialog(node)) {
|
|
210
|
+
defects.push(...dialogDefects(node, path, targeted));
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return defects;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** The rules that judge a button carrying `command` or `commandfor`. */
|
|
217
|
+
function invokerDefects(
|
|
218
|
+
node: JxElement,
|
|
219
|
+
path: PopoverPath,
|
|
220
|
+
byId: ReadonlyMap<string, JxElement>,
|
|
221
|
+
dialogIds: string[],
|
|
222
|
+
popoverIds: string[],
|
|
223
|
+
hasAnyTarget: boolean,
|
|
224
|
+
scope?: OverlayScope,
|
|
225
|
+
): DialogDefect[] {
|
|
226
|
+
const out: DialogDefect[] = [];
|
|
227
|
+
const tag = tagOf(node);
|
|
228
|
+
const forwards = scope?.invokerTags?.has(tag) ?? false;
|
|
229
|
+
const command = literalAttr(node, "command");
|
|
230
|
+
const target = literalAttr(node, "commandfor");
|
|
231
|
+
const carries =
|
|
232
|
+
command !== null || target !== null || "command" in attrs(node) || "commandfor" in attrs(node);
|
|
233
|
+
if (!carries) {
|
|
234
|
+
return out;
|
|
235
|
+
}
|
|
236
|
+
const label = labelOf(node, "the element");
|
|
237
|
+
|
|
238
|
+
if (!COMMAND_INVOKER_TAGS.has(tag) && !forwards) {
|
|
239
|
+
out.push({
|
|
240
|
+
detail:
|
|
241
|
+
"`command` and `commandfor` are attributes of `HTMLButtonElement` and of no other " +
|
|
242
|
+
"interface — not even `<input>`, which `popovertarget` does allow. Elsewhere they parse " +
|
|
243
|
+
`and do nothing. Make it a <button>, or remove both attributes. ${DOCS}`,
|
|
244
|
+
fix: "invoker",
|
|
245
|
+
message: `<${tag || "element"}> carries command/commandfor, which only <button> supports.`,
|
|
246
|
+
path,
|
|
247
|
+
rule: "invoker-not-button",
|
|
248
|
+
severity: "error",
|
|
249
|
+
});
|
|
250
|
+
return out;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (
|
|
254
|
+
command !== null &&
|
|
255
|
+
!(INVOKER_COMMANDS as readonly string[]).includes(command) &&
|
|
256
|
+
!isCustomCommand(command)
|
|
257
|
+
) {
|
|
258
|
+
out.push({
|
|
259
|
+
detail:
|
|
260
|
+
`\`command\` is an enumerated attribute taking ${INVOKER_COMMANDS.map((c) => `\`${c}\``).join(", ")}, ` +
|
|
261
|
+
"or a custom command starting with `--`. Anything else is the invalid-value state, and a " +
|
|
262
|
+
`button in that state does nothing when clicked. ${DOCS}`,
|
|
263
|
+
message: `${label} declares command="${command}", which is not a built-in or --custom command.`,
|
|
264
|
+
path,
|
|
265
|
+
rule: "command-invalid",
|
|
266
|
+
severity: "error",
|
|
267
|
+
});
|
|
268
|
+
return out;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (target !== null) {
|
|
272
|
+
const known = byId.get(target);
|
|
273
|
+
const targetsDialog = dialogIds.includes(target);
|
|
274
|
+
const targetsPopover = popoverIds.includes(target);
|
|
275
|
+
if (!known && hasAnyTarget) {
|
|
276
|
+
const names = [...dialogIds, ...popoverIds].map((id) => `"${id}"`).join(", ");
|
|
277
|
+
out.push({
|
|
278
|
+
detail:
|
|
279
|
+
`No element in this document has the id "${target}". The dialogs and popovers here are: ${names}. ` +
|
|
280
|
+
`A commandfor that names nothing does nothing, silently. ${DOCS}`,
|
|
281
|
+
message: `commandfor="${target}" names nothing in this document.`,
|
|
282
|
+
path,
|
|
283
|
+
rule: "command-target-missing",
|
|
284
|
+
severity: "error",
|
|
285
|
+
});
|
|
286
|
+
return out;
|
|
287
|
+
}
|
|
288
|
+
if (command !== null && known) {
|
|
289
|
+
if (DIALOG_COMMANDS.has(command) && !isDialog(known)) {
|
|
290
|
+
out.push({
|
|
291
|
+
detail:
|
|
292
|
+
`\`${command}\` acts on a <dialog>, and "${target}" is ${targetsPopover ? "a popover" : `a <${tagOf(known) || "element"}>`}. ` +
|
|
293
|
+
`The click is ignored. ${targetsPopover ? "Use `toggle-popover`, `show-popover` or `hide-popover` for a popover." : "Point it at a dialog, or make the target one."} ${DOCS}`,
|
|
294
|
+
message: `${label} sends ${command} to "${target}", which is not a dialog.`,
|
|
295
|
+
path,
|
|
296
|
+
rule: "command-target-mismatch",
|
|
297
|
+
severity: "error",
|
|
298
|
+
});
|
|
299
|
+
} else if (POPOVER_COMMANDS.has(command) && !isPopover(known, scope)) {
|
|
300
|
+
out.push({
|
|
301
|
+
detail:
|
|
302
|
+
`\`${command}\` acts on a popover, and "${target}" is ${targetsDialog ? "a dialog" : `a <${tagOf(known) || "element"}>`} that declares no \`popover\`. ` +
|
|
303
|
+
`The click is ignored. ${targetsDialog ? "Use `show-modal`, `close` or `request-close` for a dialog." : "Give the target a `popover` attribute, or point the button elsewhere."} ${DOCS}`,
|
|
304
|
+
message: `${label} sends ${command} to "${target}", which is not a popover.`,
|
|
305
|
+
path,
|
|
306
|
+
rule: "command-target-mismatch",
|
|
307
|
+
severity: "error",
|
|
308
|
+
});
|
|
309
|
+
} else if (isCustomCommand(command) && !handlesCommand(known)) {
|
|
310
|
+
out.push({
|
|
311
|
+
detail:
|
|
312
|
+
`A custom command is a \`CommandEvent\` dispatched at its target, and "${target}" has no ` +
|
|
313
|
+
"`oncommand` handler to answer it — so the click does nothing. Add one that switches on " +
|
|
314
|
+
`\`event#/command\`, or use a built-in command. ${DOCS}`,
|
|
315
|
+
message: `${label} sends ${command} to "${target}", which handles no command.`,
|
|
316
|
+
path,
|
|
317
|
+
rule: "custom-command-unhandled",
|
|
318
|
+
severity: "warn",
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return out;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** The rules that judge a `<dialog>` itself. */
|
|
327
|
+
function dialogDefects(
|
|
328
|
+
node: JxElement,
|
|
329
|
+
path: PopoverPath,
|
|
330
|
+
targeted: ReadonlyMap<string, Set<string>>,
|
|
331
|
+
): DialogDefect[] {
|
|
332
|
+
const out: DialogDefect[] = [];
|
|
333
|
+
const label = labelOf(node, "the dialog");
|
|
334
|
+
const id = idOf(node);
|
|
335
|
+
const { style } = node;
|
|
336
|
+
|
|
337
|
+
const closedby = literalAttr(node, "closedby");
|
|
338
|
+
if (closedby !== null && !(DIALOG_CLOSEDBY as readonly string[]).includes(closedby)) {
|
|
339
|
+
out.push({
|
|
340
|
+
detail:
|
|
341
|
+
"`closedby` is an enumerated attribute taking `any` (light dismiss and Escape), " +
|
|
342
|
+
"`closerequest` (Escape only, the default for a modal) or `none`. Anything else is the " +
|
|
343
|
+
`invalid-value default, \`auto\`, which is the same as leaving it out. ${DOCS}`,
|
|
344
|
+
message: `${label} declares closedby="${closedby}", which is not any, closerequest or none.`,
|
|
345
|
+
path,
|
|
346
|
+
rule: "dialog-closedby-invalid",
|
|
347
|
+
severity: "error",
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (scalar(style, "display") !== null) {
|
|
352
|
+
out.push({
|
|
353
|
+
detail:
|
|
354
|
+
"A closed dialog is hidden by the browser's own `dialog:not([open]) { display: none }`. " +
|
|
355
|
+
"That rule is UA-origin, so any author `display` beats it, and the dialog is laid out on " +
|
|
356
|
+
"every page whether or not anyone opened it. `display` belongs in the `&[open]` rule and " +
|
|
357
|
+
`nowhere else. ${DOCS}`,
|
|
358
|
+
fix: "display",
|
|
359
|
+
message: `${label} sets display in its base rule.`,
|
|
360
|
+
path,
|
|
361
|
+
rule: "dialog-display",
|
|
362
|
+
severity: "error",
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const commands = id === null ? undefined : targeted.get(id);
|
|
367
|
+
const opened =
|
|
368
|
+
literalAttr(node, "open") !== null || attrs(node).open === true || node.open === true;
|
|
369
|
+
// Its own close button names it too, and closing is not opening.
|
|
370
|
+
if (id !== null && !commands?.has("show-modal") && !opened) {
|
|
371
|
+
out.push({
|
|
372
|
+
detail:
|
|
373
|
+
'No <button> in this document opens it with `command="show-modal"`. If it is opened from another ' +
|
|
374
|
+
"file or from a function that calls `showModal()`, this is fine; otherwise there is no " +
|
|
375
|
+
`way for a reader to open it. ${DOCS}`,
|
|
376
|
+
message: `${label} has no invoker in this document.`,
|
|
377
|
+
path,
|
|
378
|
+
rule: "dialog-no-invoker",
|
|
379
|
+
severity: "warn",
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (commands?.has("show-modal") && closedby === "none" && !hasCloseControl(node, id)) {
|
|
384
|
+
out.push({
|
|
385
|
+
detail:
|
|
386
|
+
"A modal dialog makes everything outside it inert, so the only ways out are the ones it " +
|
|
387
|
+
'offers: `closedby="none"` turns off Escape and light dismiss, and nothing inside it ' +
|
|
388
|
+
'closes it. A reader who opens it cannot leave. Add a <button command="close"> inside, or ' +
|
|
389
|
+
`drop \`closedby="none"\`. ${DOCS}`,
|
|
390
|
+
message: `${label} opens modally, closes on nothing, and has no close button.`,
|
|
391
|
+
path,
|
|
392
|
+
rule: "modal-without-close",
|
|
393
|
+
severity: "error",
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
return out;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export interface DialogDisplayRepair {
|
|
400
|
+
/** Properties to delete from the dialog's BASE rule. */
|
|
401
|
+
base: string[];
|
|
402
|
+
/** The `display` value to write into `&[open]`, or null when it already declares one. */
|
|
403
|
+
openDisplay: string | null;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* The style edit that repairs a `dialog-display` finding: the base `display` moves into the open
|
|
408
|
+
* rule. Pure and returned as data, for the same reason `popoverDisplayRepair` is.
|
|
409
|
+
*
|
|
410
|
+
* @param style The dialog's own style object.
|
|
411
|
+
* @returns The properties to remove from the base rule and the value to write into `&[open]`, or
|
|
412
|
+
* null when there is nothing to move.
|
|
413
|
+
*/
|
|
414
|
+
export function dialogDisplayRepair(style?: JxStyle): DialogDisplayRepair | null {
|
|
415
|
+
if (!style) {
|
|
416
|
+
return null;
|
|
417
|
+
}
|
|
418
|
+
const display = scalar(style, "display");
|
|
419
|
+
if (display === null) {
|
|
420
|
+
return null;
|
|
421
|
+
}
|
|
422
|
+
const existing =
|
|
423
|
+
getNestedStyle(style, "&[open]") ??
|
|
424
|
+
getNestedStyle(style, "[open]") ??
|
|
425
|
+
getNestedStyle(style, "&:open") ??
|
|
426
|
+
getNestedStyle(style, ":open");
|
|
427
|
+
return { base: ["display"], openDisplay: scalar(existing, "display") !== null ? null : display };
|
|
428
|
+
}
|
package/src/format-registry.ts
CHANGED
|
@@ -12,11 +12,12 @@
|
|
|
12
12
|
|
|
13
13
|
import { mediaTypeEssence, mediaTypeProblem } from "./media-type";
|
|
14
14
|
|
|
15
|
-
export type FormatCapability = "parse" | "serialize" | "discover" | "load";
|
|
15
|
+
export type FormatCapability = "parse" | "serialize" | "rewrite" | "discover" | "load";
|
|
16
16
|
|
|
17
17
|
export const FORMAT_CAPABILITIES: readonly FormatCapability[] = [
|
|
18
18
|
"parse",
|
|
19
19
|
"serialize",
|
|
20
|
+
"rewrite",
|
|
20
21
|
"discover",
|
|
21
22
|
"load",
|
|
22
23
|
];
|
|
@@ -27,6 +28,13 @@ export const FORMAT_CAPABILITIES: readonly FormatCapability[] = [
|
|
|
27
28
|
* asset emission (`emit`), `<head>` contribution (`head`), static asset mounts (`assets`),
|
|
28
29
|
* server-mount (`mount`), and connector (`dialect`, `deploySchema`, `bindings`, `testConnection`)
|
|
29
30
|
* roles.
|
|
31
|
+
*
|
|
32
|
+
* `rewrite` is the narrow sibling of `serialize`, and the two are not ranked: a format may declare
|
|
33
|
+
* either, both or neither. `serialize` says "this document round-trips through me"; `rewrite` says
|
|
34
|
+
* only "I can replace these authored values in my own source text and change nothing else". A
|
|
35
|
+
* load-only format — a CSV collection is read to load entries, never re-emitted as a document — can
|
|
36
|
+
* honestly promise the second and not the first, which is enough for a rename to repair a reference
|
|
37
|
+
* living inside it (specs/extensions.md §8).
|
|
30
38
|
*/
|
|
31
39
|
export type ExtensionCapability =
|
|
32
40
|
| FormatCapability
|
package/src/guards.ts
CHANGED
|
@@ -192,9 +192,17 @@ export function bodyReturnsValue(body: string): boolean {
|
|
|
192
192
|
|
|
193
193
|
// ─── Style guards & accessors ───────────────────────────────────────────────────
|
|
194
194
|
|
|
195
|
-
/**
|
|
196
|
-
|
|
197
|
-
|
|
195
|
+
/**
|
|
196
|
+
* A nested style object (selector/media block), as opposed to a scalar CSS value.
|
|
197
|
+
*
|
|
198
|
+
* An ARRAY of blocks is not one: it is a key written more than once (`@font-face`, spec.md §9.4),
|
|
199
|
+
* and every caller here reads or edits a single block. Answering true for it would hand a caller an
|
|
200
|
+
* array to look properties up on, which finds nothing and reports nothing.
|
|
201
|
+
*/
|
|
202
|
+
export function isNestedStyle(
|
|
203
|
+
value: string | number | JxStyle | JxStyle[] | undefined,
|
|
204
|
+
): value is JxStyle {
|
|
205
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
198
206
|
}
|
|
199
207
|
|
|
200
208
|
/** Read a nested style block (`:hover`, `@--md`, `& > li`, …), if present. */
|
|
@@ -243,9 +251,39 @@ export function paramNames(parameters: JxFunctionDef["parameters"]): string[] {
|
|
|
243
251
|
return parameters.map((p) => (typeof p === "string" ? p : p.name));
|
|
244
252
|
}
|
|
245
253
|
|
|
246
|
-
/**
|
|
254
|
+
/**
|
|
255
|
+
* Whether an element's `tagName` is a choice rather than a name.
|
|
256
|
+
*
|
|
257
|
+
* **It reads the WHOLE shape, because it promises the whole shape.**
|
|
258
|
+
* `defs/tag-expression.schema.ts` admits exactly two forms — `?:` with a `TagName` in `value` and
|
|
259
|
+
* `initial`, and `switch` with a `TagName` per case and a required `default` — and this predicate
|
|
260
|
+
* is the only thing standing between that promise and the four readers who trust it
|
|
261
|
+
* ({@link tagNameCandidates}, the runtime's `resolveTagName`, the compiler's `resolveStaticTagName`
|
|
262
|
+
* and its two target emitters). Answering `true` for any `{ $expression: … }` at all was the
|
|
263
|
+
* unsound half: a general formula in tag position — which the Inspector's Tag row let a user pick
|
|
264
|
+
* out of the formula catalog — then reached `Object.values(expression.cases)` with no `cases` to
|
|
265
|
+
* read, and `Cannot convert undefined or null to object` came out of the Command Bar, the jump bar,
|
|
266
|
+
* the Inspector and the canvas renderer at once.
|
|
267
|
+
*
|
|
268
|
+
* A `$expression` this refuses is not a tag choice, so it has no candidates and no static tag; the
|
|
269
|
+
* readers fall back to their own "nothing declared" answer (`[]`, `"div"`) instead of throwing.
|
|
270
|
+
*/
|
|
247
271
|
export function isTagExpression(tagName: unknown): tagName is { $expression: JxTagExpression } {
|
|
248
|
-
|
|
272
|
+
if (!isJsonObject(tagName) || !isJsonObject(tagName.$expression)) {
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
const expression = tagName.$expression;
|
|
276
|
+
if (expression.operator === "?:") {
|
|
277
|
+
return typeof expression.value === "string" && typeof expression.initial === "string";
|
|
278
|
+
}
|
|
279
|
+
if (expression.operator === "switch") {
|
|
280
|
+
return (
|
|
281
|
+
isJsonObject(expression.cases) &&
|
|
282
|
+
Object.values(expression.cases).every((tag) => typeof tag === "string") &&
|
|
283
|
+
typeof expression.default === "string"
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
return false;
|
|
249
287
|
}
|
|
250
288
|
|
|
251
289
|
/**
|