@jxsuite/schema 3.0.0 → 4.1.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/src/overlays.ts CHANGED
@@ -25,8 +25,16 @@
25
25
  * @docs framework/concepts/overlays
26
26
  */
27
27
 
28
- import { getNestedStyle, isJsonObject, isNestedStyle } from "./guards";
29
- import type { JxElement, JxStyle } from "../types";
28
+ import {
29
+ getNestedStyle,
30
+ isBoolean,
31
+ isJsonObject,
32
+ isNestedStyle,
33
+ isNodeObject,
34
+ isNonNullObject,
35
+ isString,
36
+ } from "./guards";
37
+ import type { JsonObject, JxElement, JxStyle } from "../types";
30
38
 
31
39
  /**
32
40
  * The three keywords the `popover` attribute accepts.
@@ -95,24 +103,24 @@ export interface PopoverDefect {
95
103
  // ─── Reading a node ─────────────────────────────────────────────────────────────
96
104
 
97
105
  /** The attribute bag, whatever shape the author wrote it in. */
98
- export function attrs(node: JxElement): Record<string, unknown> {
99
- return isJsonObject(node.attributes) ? (node.attributes as Record<string, unknown>) : {};
106
+ export function attrs(node: JxElement): JsonObject {
107
+ return isJsonObject(node.attributes) ? node.attributes : {};
100
108
  }
101
109
 
102
110
  /** An attribute's literal string value, or null when absent or bound. */
103
111
  export function literalAttr(node: JxElement, name: string): string | null {
104
112
  const value = attrs(node)[name];
105
- return typeof value === "string" ? value : null;
113
+ return isString(value) ? value : null;
106
114
  }
107
115
 
108
116
  /** The element's tag, lowercased, or "" when it is a tag expression or absent. */
109
117
  export function tagOf(node: JxElement): string {
110
- return typeof node.tagName === "string" ? node.tagName.toLowerCase() : "";
118
+ return isString(node.tagName) ? node.tagName.toLowerCase() : "";
111
119
  }
112
120
 
113
121
  /** How the author addresses this node in a message: its id if it has one, else its tag. */
114
122
  export function labelOf(node: JxElement, fallback = "the popover"): string {
115
- const id = typeof node.id === "string" ? node.id : literalAttr(node, "id");
123
+ const id = isString(node.id) ? node.id : literalAttr(node, "id");
116
124
  if (id) {
117
125
  return `#${id}`;
118
126
  }
@@ -130,7 +138,7 @@ export function labelOf(node: JxElement, fallback = "the popover"): string {
130
138
  */
131
139
  export function popoverModeOf(node: JxElement): string | null {
132
140
  const raw = attrs(node).popover;
133
- return typeof raw === "string" ? raw : null;
141
+ return isString(raw) ? raw : null;
134
142
  }
135
143
 
136
144
  /**
@@ -172,18 +180,19 @@ const INVOKER_ATTRS = ["popovertarget", "popovertargetaction", "command", "comma
172
180
  * @param definitions The element documents in scope.
173
181
  * @returns {OverlayScope} The two tag sets, ready to pass to any rule here.
174
182
  */
175
- export function overlayScopeFor(definitions: Iterable<JxElement>): OverlayScope {
183
+ export function overlayScopeFor(definitions: Iterable<JxElement | null>): OverlayScope {
176
184
  const popoverTags = new Set<string>();
177
185
  const invokerTags = new Set<string>();
178
186
  for (const def of definitions) {
179
- const tag = typeof def?.tagName === "string" ? def.tagName : "";
180
- if (!tag.includes("-")) {
187
+ // A definition file whose JSON is a bare `null` still parses, so the compiler can pass one.
188
+ const tag = def !== null && isString(def.tagName) ? def.tagName : "";
189
+ if (def === null || !tag.includes("-")) {
181
190
  continue;
182
191
  }
183
192
  if (declaresPopover(def)) {
184
193
  popoverTags.add(tag);
185
194
  }
186
- const observed = (def as { observedAttributes?: unknown }).observedAttributes;
195
+ const observed = def.observedAttributes;
187
196
  if (Array.isArray(observed) && INVOKER_ATTRS.every((attr) => observed.includes(attr))) {
188
197
  invokerTags.add(tag);
189
198
  }
@@ -206,7 +215,7 @@ export function declaresPopover(node: JxElement): boolean {
206
215
  to every rule here: no `base-display` finding, no `no-open-rule` finding, and its tag missing
207
216
  from the scope, so consumers of that definition went unrecognised too. `idOf` below already
208
217
  reads a key either way, for the same reason. */
209
- return "popover" in attrs(node) || "popover" in (node as Record<string, unknown>);
218
+ return "popover" in attrs(node) || "popover" in node;
210
219
  }
211
220
 
212
221
  /**
@@ -221,7 +230,7 @@ export function isPopover(node: JxElement, scope?: OverlayScope): boolean {
221
230
 
222
231
  /** The node's own `id`, from either the top-level key or the attribute bag. */
223
232
  export function idOf(node: JxElement): string | null {
224
- if (typeof node.id === "string" && node.id !== "") {
233
+ if (isString(node.id) && node.id !== "") {
225
234
  return node.id;
226
235
  }
227
236
  const attr = literalAttr(node, "id");
@@ -258,25 +267,20 @@ export function* walk(
258
267
  const { children } = root;
259
268
  if (Array.isArray(children)) {
260
269
  for (const [index, child] of children.entries()) {
261
- if (child && typeof child === "object") {
262
- yield* walk(child as JxElement, [...path, "children", index], inside, scope);
270
+ if (isNonNullObject(child)) {
271
+ yield* walk(child, [...path, "children", index], inside, scope);
263
272
  }
264
273
  }
265
- } else if (isJsonObject(children) && isJsonObject((children as JxElement).map)) {
266
- yield* walk(
267
- (children as JxElement).map as JxElement,
268
- [...path, "children", "map"],
269
- inside,
270
- scope,
271
- );
272
- }
273
- const template = isJsonObject(root.map) ? (root.map as JxElement) : null;
274
+ } else if (isJsonObject(children) && isNodeObject(children.map)) {
275
+ yield* walk(children.map, [...path, "children", "map"], inside, scope);
276
+ }
277
+ const template = isNodeObject(root.map) ? root.map : null;
274
278
  if (template) {
275
279
  yield* walk(template, [...path, "map"], inside, scope);
276
280
  }
277
- const cases = isJsonObject(root.cases) ? (root.cases as Record<string, JxElement>) : {};
281
+ const cases = isJsonObject(root.cases) ? root.cases : {};
278
282
  for (const [key, branch] of Object.entries(cases)) {
279
- if (branch && typeof branch === "object") {
283
+ if (isNonNullObject(branch)) {
280
284
  yield* walk(branch, [...path, "cases", key], inside, scope);
281
285
  }
282
286
  }
@@ -493,7 +497,8 @@ function hasAnchorFallback(style: JxStyle | undefined): boolean {
493
497
 
494
498
  // ─── The rules ──────────────────────────────────────────────────────────────────
495
499
 
496
- const DOCS = "See docs/framework/concepts/overlays.";
500
+ /** The sentence every overlay and dialog defect ends with, pointing at the concept page. */
501
+ export const OVERLAY_DOCS = "See docs/framework/concepts/overlays.";
497
502
 
498
503
  /**
499
504
  * Every popover defect in a document, in document order.
@@ -551,7 +556,7 @@ function invokerDefects(
551
556
  "mixin, which HTML includes into `HTMLButtonElement` and `HTMLInputElement` and into " +
552
557
  "nothing else. Elsewhere they parse and do nothing. A link inside an open popover " +
553
558
  "dismisses it by navigating away, which is why this looks like it works — but it does " +
554
- `not for a new tab or a same-page fragment. Remove both attributes. ${DOCS}`,
559
+ `not for a new tab or a same-page fragment. Remove both attributes. ${OVERLAY_DOCS}`,
555
560
  fix: "invoker",
556
561
  message: `<${tag || "element"}> carries popovertarget, which only <button> and <input> support.`,
557
562
  path,
@@ -568,7 +573,7 @@ function invokerDefects(
568
573
  out.push({
569
574
  detail:
570
575
  `No element in this document declares \`popover\` with the id "${target}". ${known}` +
571
- `A target that names nothing does nothing, silently. ${DOCS}`,
576
+ `A target that names nothing does nothing, silently. ${OVERLAY_DOCS}`,
572
577
  message: `popovertarget="${target}" names no popover in this document.`,
573
578
  path,
574
579
  rule: "target-missing",
@@ -579,7 +584,7 @@ function invokerDefects(
579
584
  detail:
580
585
  `This control is inside ${labelOf(enclosing)} but targets "${target}". Dismissing a ` +
581
586
  "different popover leaves the one the reader is looking at open. This is usually a " +
582
- `copy-paste between two panels in the same file. ${DOCS}`,
587
+ `copy-paste between two panels in the same file. ${OVERLAY_DOCS}`,
583
588
  message: `A control inside ${labelOf(enclosing)} targets "${target}" instead.`,
584
589
  path,
585
590
  rule: "target-mismatch",
@@ -589,7 +594,7 @@ function invokerDefects(
589
594
  }
590
595
 
591
596
  if (tag === "a" && enclosing !== null && target === null) {
592
- const href = literalAttr(node, "href") ?? (typeof node.href === "string" ? node.href : null);
597
+ const href = literalAttr(node, "href") ?? (isString(node.href) ? node.href : null);
593
598
  const blank = literalAttr(node, "target") === "_blank";
594
599
  const fragment = href !== null && href.includes("#");
595
600
  if (blank || fragment) {
@@ -600,7 +605,7 @@ function invokerDefects(
600
605
  detail:
601
606
  `A link inside a panel normally dismisses it by navigating away. This one does not: ${why}` +
602
607
  "There is no declarative fix — a link cannot carry `popovertarget`. Move it out of the " +
603
- `panel, make it a <button>, or accept it deliberately. ${DOCS}`,
608
+ `panel, make it a <button>, or accept it deliberately. ${OVERLAY_DOCS}`,
604
609
  message: `A link inside ${labelOf(enclosing)} does not dismiss it.`,
605
610
  path,
606
611
  rule: "link-does-not-dismiss",
@@ -622,25 +627,25 @@ function panelDefects(
622
627
  const label = labelOf(node);
623
628
  const mode = popoverModeOf(node);
624
629
 
625
- if (mode !== null && mode !== "" && !(POPOVER_MODES as readonly string[]).includes(mode)) {
630
+ if (mode !== null && mode !== "" && !POPOVER_MODES.some((known) => known === mode)) {
626
631
  out.push({
627
632
  detail:
628
633
  "`popover` is an enumerated attribute taking `auto`, `manual` or `hint`. Anything else " +
629
634
  "falls back to the invalid-value default `manual`, which gives up light dismiss and gives " +
630
- `up Escape — so the panel opens and there is no way to close it. Write "auto". ${DOCS}`,
635
+ `up Escape — so the panel opens and there is no way to close it. Write "auto". ${OVERLAY_DOCS}`,
631
636
  fix: "mode",
632
637
  message: `${label} declares popover="${mode}", which is not one of auto, manual or hint.`,
633
638
  path,
634
639
  rule: "invalid-mode",
635
640
  severity: "error",
636
641
  });
637
- } else if (typeof attrs(node).popover === "boolean") {
642
+ } else if (isBoolean(attrs(node).popover)) {
638
643
  out.push({
639
644
  detail:
640
645
  "`popover` carries its value in its text, so a boolean is not the right shape. Written " +
641
646
  'as `true` it reaches two of the compiler\'s emitters as `popover="true"`, which is not ' +
642
647
  "one of the three keywords, so HTML falls back to `manual`: no light dismiss, no Escape, " +
643
- `and the trigger stops toggling. Write "auto". ${DOCS}`,
648
+ `and the trigger stops toggling. Write "auto". ${OVERLAY_DOCS}`,
644
649
  fix: "mode",
645
650
  message: `${label} writes popover as a boolean.`,
646
651
  path,
@@ -662,7 +667,7 @@ function panelDefects(
662
667
  "none }`. That rule is UA-origin, so any author `display` beats it at any specificity, and " +
663
668
  "the panel is laid out on every page whether it is open or not — usually parked off the " +
664
669
  "side of the viewport by the transform meant to animate it, so nothing looks wrong until " +
665
- `you scroll sideways. \`display\` belongs in \`:popover-open\` and nowhere else. ${DOCS}`,
670
+ `you scroll sideways. \`display\` belongs in \`:popover-open\` and nowhere else. ${OVERLAY_DOCS}`,
666
671
  fix: "display",
667
672
  message: `${label} sets display in its base rule.`,
668
673
  path,
@@ -676,7 +681,7 @@ function panelDefects(
676
681
  detail:
677
682
  `The same defect as a base \`display\`, arriving through \`${key}\` and therefore only at ` +
678
683
  "one width — which is what makes it hard to see. Gate the TRIGGER on a breakpoint, never " +
679
- `the panel: the popover mechanism is what hides the panel. ${DOCS}`,
684
+ `the panel: the popover mechanism is what hides the panel. ${OVERLAY_DOCS}`,
680
685
  fix: "display",
681
686
  message: `${label} sets display inside ${key}.`,
682
687
  path,
@@ -690,7 +695,7 @@ function panelDefects(
690
695
  detail:
691
696
  "A second hiding mechanism the browser already provides. Once `display` leaves the base " +
692
697
  "rule this one only adds a frame of flicker on open, and it hides the element from " +
693
- `assistive technology in a way \`display: none\` already did. ${DOCS}`,
698
+ `assistive technology in a way \`display: none\` already did. ${OVERLAY_DOCS}`,
694
699
  fix: "display",
695
700
  message: `${label} also hides itself with visibility.`,
696
701
  path,
@@ -704,7 +709,7 @@ function panelDefects(
704
709
  detail:
705
710
  "Nothing changes when it opens, so no transition on it can run and — if `display` is not " +
706
711
  "in the base rule — the panel has no `display` at all while open. This is usually a panel " +
707
- `that was styled before it was made a popover. ${DOCS}`,
712
+ `that was styled before it was made a popover. ${OVERLAY_DOCS}`,
708
713
  message: `${label} has no :popover-open rule.`,
709
714
  path,
710
715
  rule: "no-open-rule",
@@ -720,7 +725,7 @@ function panelDefects(
720
725
  `${label} declares flex or grid properties, but neither its base rule nor its ` +
721
726
  "`:popover-open` rule sets a `display` for them to apply to — and the base rule must not " +
722
727
  "(that is what keeps the closed panel hidden). So it opens with the UA's `display: block` " +
723
- `and the layout silently does nothing. Put \`display: flex\` in \`:popover-open\`. ${DOCS}`,
728
+ `and the layout silently does nothing. Put \`display: flex\` in \`:popover-open\`. ${OVERLAY_DOCS}`,
724
729
  fix: "open-display",
725
730
  message: `${label} is laid out with flex or grid but never sets display.`,
726
731
  path,
@@ -736,7 +741,7 @@ function panelDefects(
736
741
  "`display …allow-discrete` keeps the box alive while it animates out; `overlay " +
737
742
  "…allow-discrete` is what keeps it in the TOP LAYER for those same milliseconds. " +
738
743
  "Without it the panel drops behind the page content and the backdrop disappears a frame " +
739
- `early, so the exit reads as a flicker rather than an animation. ${DOCS}`,
744
+ `early, so the exit reads as a flicker rather than an animation. ${OVERLAY_DOCS}`,
740
745
  message: `${label} transitions display but not overlay.`,
741
746
  path,
742
747
  rule: "cut-exit",
@@ -753,7 +758,7 @@ function panelDefects(
753
758
  "`position: fixed` box with four `auto` insets falls back to its STATIC position, which " +
754
759
  "for a panel declared inside a header is in the middle of the header. Use `position-area` " +
755
760
  "with `position-try-fallbacks`, or add a `@supports` block that positions it without " +
756
- `anchors. ${DOCS}`,
761
+ `anchors. ${OVERLAY_DOCS}`,
757
762
  message: `${label} is anchor-positioned with no fallback.`,
758
763
  path,
759
764
  rule: "unsafe-anchor",
@@ -767,7 +772,7 @@ function panelDefects(
767
772
  detail:
768
773
  "No `<button>` or `<input>` in this document names it with `popovertarget`. If it is " +
769
774
  "opened from another file or from a function that calls `showPopover()`, this is fine; " +
770
- `otherwise there is no way for a reader to open it. ${DOCS}`,
775
+ `otherwise there is no way for a reader to open it. ${OVERLAY_DOCS}`,
771
776
  message: `${label} has no invoker in this document.`,
772
777
  path,
773
778
  rule: "no-invoker",
package/src/parse.ts CHANGED
@@ -15,20 +15,20 @@
15
15
  * one thing on one keyboard and another thing on another is a defect nothing downstream can see.
16
16
  */
17
17
 
18
- import type { JxClassDef, JxDocument, ProjectConfig } from "../types";
19
- import { isJsonObject } from "./guards";
18
+ import type { JsonObject, JsonValue, JxClassDef, JxDocument, ProjectConfig } from "../types";
19
+ import { isJsonObject, isString } from "./guards";
20
20
  import { describeIJsonProblem, findIJsonProblems } from "./ijson";
21
21
 
22
22
  // ─── Error helpers ──────────────────────────────────────────────────────────────
23
23
 
24
24
  /** Extract a human-readable message from a caught `unknown`. */
25
- export function errorMessage(e: unknown): string {
26
- return e instanceof Error ? e.message : String(e);
25
+ export function errorMessage(cause: unknown): string {
26
+ return cause instanceof Error ? cause.message : String(cause);
27
27
  }
28
28
 
29
29
  /** Coerce a caught `unknown` to an Error without losing the original. */
30
- export function toError(e: unknown): Error {
31
- return e instanceof Error ? e : new Error(String(e));
30
+ export function toError(cause: unknown): Error {
31
+ return cause instanceof Error ? cause : new Error(String(cause));
32
32
  }
33
33
 
34
34
  // ─── Unicode normalization ──────────────────────────────────────────────────────
@@ -62,35 +62,40 @@ const NON_ASCII = /[^\p{ASCII}]/u;
62
62
  * escape into a combining mark, so normalizing the raw text would miss exactly the documents a
63
63
  * generator wrote.
64
64
  *
65
- * @param {unknown} value - Any parsed JSON value
66
- * @returns {unknown} The same shape, with all text in NFC
65
+ * @param {JsonValue} value - Any parsed JSON value
66
+ * @returns {JsonValue} The same shape, with all text in NFC
67
67
  */
68
- function normalizeIdentifiers(value: unknown): unknown {
69
- if (typeof value === "string") {
68
+ function normalizeIdentifiers(value: JsonValue): JsonValue {
69
+ if (isString(value)) {
70
70
  return NON_ASCII.test(value) ? value.normalize("NFC") : value;
71
71
  }
72
72
  if (Array.isArray(value)) {
73
73
  return value.map((item) => normalizeIdentifiers(item));
74
74
  }
75
75
  if (isJsonObject(value)) {
76
- const out: Record<string, unknown> = {};
77
- for (const [key, entry] of Object.entries(value)) {
78
- /*
79
- * A later key wins on collision, matching what `JSON.parse` does with a literal duplicate.
80
- * It cannot happen through this boundary — I-JSON already rejected duplicate keys, and two
81
- * keys differing only by normalization form are not duplicates to `JSON.parse` — so this is
82
- * a statement about which is authoritative rather than a branch anything reaches.
83
- */
84
- out[NON_ASCII.test(key) ? key.normalize("NFC") : key] = normalizeIdentifiers(entry);
85
- }
86
- return out;
76
+ return normalizeObject(value);
87
77
  }
88
78
  return value;
89
79
  }
90
80
 
81
+ /** {@link normalizeIdentifiers} for an object, keeping the object type for the parse helpers. */
82
+ function normalizeObject(value: JsonObject): JsonObject {
83
+ const out: JsonObject = {};
84
+ for (const [key, entry] of Object.entries(value)) {
85
+ /*
86
+ * A later key wins on collision, matching what `JSON.parse` does with a literal duplicate.
87
+ * It cannot happen through this boundary — I-JSON already rejected duplicate keys, and two
88
+ * keys differing only by normalization form are not duplicates to `JSON.parse` — so this is
89
+ * a statement about which is authoritative rather than a branch anything reaches.
90
+ */
91
+ out[NON_ASCII.test(key) ? key.normalize("NFC") : key] = normalizeIdentifiers(entry);
92
+ }
93
+ return out;
94
+ }
95
+
91
96
  // ─── Parse helpers ──────────────────────────────────────────────────────────────
92
97
 
93
- function parseObject(text: string, sourcePath: string, what: string): Record<string, unknown> {
98
+ function parseObject(text: string, sourcePath: string, what: string): JsonObject {
94
99
  let parsed: unknown;
95
100
  try {
96
101
  parsed = JSON.parse(text);
@@ -112,16 +117,25 @@ function parseObject(text: string, sourcePath: string, what: string): Record<str
112
117
  const described = problems.map((problem) => describeIJsonProblem(problem)).join("; ");
113
118
  throw new Error(`Invalid ${what} at ${sourcePath}: ${described}`);
114
119
  }
115
- return normalizeIdentifiers(parsed) as Record<string, unknown>;
120
+ return normalizeObject(parsed);
116
121
  }
117
122
 
118
123
  /** Parse a Jx document (.json page/layout/component source). */
119
124
  export function parseJxDocument(text: string, sourcePath: string): JxDocument {
125
+ /*
126
+ * SAFETY: every JxDocument member is optional, so any JSON object is a structurally admissible
127
+ * document. This boundary verifies the object shape and I-JSON only; member-level checking is the
128
+ * schema's job (`validateDocument`), which callers run when they need it.
129
+ */
120
130
  return parseObject(text, sourcePath, "Jx document") as JxDocument;
121
131
  }
122
132
 
123
133
  /** Parse a project.json site configuration. */
124
134
  export function parseProjectConfig(text: string, sourcePath: string): ProjectConfig {
135
+ /*
136
+ * SAFETY: every ProjectConfig member is optional, so any JSON object is a structurally admissible
137
+ * config; member-level checking is `validateProject`'s job against the per-project schema.
138
+ */
125
139
  return parseObject(text, sourcePath, "project config") as ProjectConfig;
126
140
  }
127
141
 
@@ -130,5 +144,9 @@ export function parseProjectConfig(text: string, sourcePath: string): ProjectCon
130
144
  * discriminator but is not required — existing class files omit it.
131
145
  */
132
146
  export function parseClassDef(text: string, sourcePath: string): JxClassDef {
133
- return parseObject(text, sourcePath, "class definition") as unknown as JxClassDef;
147
+ /*
148
+ * SAFETY: only the object shape is verified here. The type states the canonical form, and a class
149
+ * file that omits `$prototype` (accepted on purpose, see above) is the one known deviation from it.
150
+ */
151
+ return parseObject(text, sourcePath, "class definition") as JxClassDef;
134
152
  }