@jxsuite/schema 1.5.0 → 1.8.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/parse.ts CHANGED
@@ -4,10 +4,20 @@
4
4
  * Raw `JSON.parse()` returns `any`, which silently disables checking for everything downstream.
5
5
  * These helpers are the single sanctioned crossing point: they parse, structurally verify, and
6
6
  * return the domain type — failures carry the source path.
7
+ *
8
+ * Being the single crossing point is also what makes it the right place to enforce I-JSON (RFC
9
+ * 7493). `JSON.parse` accepts documents that mean something other than what they say — a repeated
10
+ * key whose first value it discards, an integer it rounds — and a Jx document does not stay JSON:
11
+ * it round-trips through markdown frontmatter and a CRDT, each rebuilding the object from the
12
+ * parsed value. Whatever was dropped here is dropped for good.
13
+ *
14
+ * It is also where identifiers are normalized (UAX #31 §R4), for the same reason: a name that means
15
+ * one thing on one keyboard and another thing on another is a defect nothing downstream can see.
7
16
  */
8
17
 
9
18
  import type { JxClassDef, JxDocument, ProjectConfig } from "../types";
10
19
  import { isJsonObject } from "./guards";
20
+ import { describeIJsonProblem, findIJsonProblems } from "./ijson";
11
21
 
12
22
  // ─── Error helpers ──────────────────────────────────────────────────────────────
13
23
 
@@ -21,6 +31,63 @@ export function toError(e: unknown): Error {
21
31
  return e instanceof Error ? e : new Error(String(e));
22
32
  }
23
33
 
34
+ // ─── Unicode normalization ──────────────────────────────────────────────────────
35
+
36
+ /**
37
+ * Text outside ASCII, which is the only text whose normalization form can differ. Testing for it
38
+ * first keeps this walk close to free on the pure-ASCII documents that are almost all of them.
39
+ */
40
+ const NON_ASCII = /[^\p{ASCII}]/u;
41
+
42
+ /**
43
+ * Recursively put every key and every string value into Normalization Form C.
44
+ *
45
+ * **The defect this fixes.** A state name is an identifier: `"état"` declared in `state`,
46
+ * referenced as `${state.état}` in a template and as `#/state/état` in a `$ref`. Typed on macOS it
47
+ * arrives decomposed (`e` + U+0301); typed on Windows or pasted from most of the web it arrives
48
+ * precomposed (U+00E9). Those are two different JavaScript property names, so a document whose
49
+ * declaration and reference were typed on different machines builds cleanly, emits a valid bundle,
50
+ * and renders nothing — `state.état` is simply `undefined`. There is no error to see and no symptom
51
+ * to search for. UAX #31 §R4 exists for exactly this, and NFC is the form the web platform assumes
52
+ * everywhere else.
53
+ *
54
+ * **Why values are normalized too, and why that is not a content change.** Two canonically
55
+ * equivalent strings are, by the definition in UAX #15, the same text: a conforming renderer must
56
+ * display them identically, and no process may distinguish them. So normalizing a `textContent` is
57
+ * a no-op for meaning. It is also unavoidable, because Jx has no syntactic boundary between content
58
+ * and code — `"Café ${state.été}"` is one string carrying both, and normalizing only "identifier
59
+ * positions" would mean parsing every template to find them.
60
+ *
61
+ * **Why here and not on the source text.** `"état"` is pure ASCII until `JSON.parse` turns the
62
+ * escape into a combining mark, so normalizing the raw text would miss exactly the documents a
63
+ * generator wrote.
64
+ *
65
+ * @param {unknown} value - Any parsed JSON value
66
+ * @returns {unknown} The same shape, with all text in NFC
67
+ */
68
+ function normalizeIdentifiers(value: unknown): unknown {
69
+ if (typeof value === "string") {
70
+ return NON_ASCII.test(value) ? value.normalize("NFC") : value;
71
+ }
72
+ if (Array.isArray(value)) {
73
+ return value.map((item) => normalizeIdentifiers(item));
74
+ }
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;
87
+ }
88
+ return value;
89
+ }
90
+
24
91
  // ─── Parse helpers ──────────────────────────────────────────────────────────────
25
92
 
26
93
  function parseObject(text: string, sourcePath: string, what: string): Record<string, unknown> {
@@ -35,7 +102,17 @@ function parseObject(text: string, sourcePath: string, what: string): Record<str
35
102
  if (!isJsonObject(parsed)) {
36
103
  throw new Error(`Invalid ${what} at ${sourcePath}: expected a JSON object`);
37
104
  }
38
- return parsed;
105
+ /*
106
+ * An error rather than a warning, because both problems are silent data loss and neither has a
107
+ * reading under which the author got what they wrote. The whole repository — 375 documents —
108
+ * was already clean when this landed, so this rejects mistakes rather than existing work.
109
+ */
110
+ const problems = findIJsonProblems(text);
111
+ if (problems.length > 0) {
112
+ const described = problems.map((problem) => describeIJsonProblem(problem)).join("; ");
113
+ throw new Error(`Invalid ${what} at ${sourcePath}: ${described}`);
114
+ }
115
+ return normalizeIdentifiers(parsed) as Record<string, unknown>;
39
116
  }
40
117
 
41
118
  /** Parse a Jx document (.json page/layout/component source). */
package/src/schema.ts CHANGED
@@ -26,6 +26,7 @@ import { listAll as listElements } from "@webref/elements";
26
26
  import css from "@webref/css";
27
27
  import idl from "@webref/idl";
28
28
 
29
+ import { elementTagNameSchema, tagExpressionSchema } from "../defs/tag-expression.schema";
29
30
  import { tagNameSchema } from "../defs/tag-name.schema";
30
31
  import {
31
32
  boolOrRefSchema,
@@ -287,6 +288,8 @@ export async function generateSchema() {
287
288
  ...tagNameSchema,
288
289
  examples: [...tagExamples, "my-counter", "todo-app", "user-card"],
289
290
  },
291
+ TagExpression: tagExpressionSchema,
292
+ ElementTagName: elementTagNameSchema,
290
293
  JsonSchemaType: jsonSchemaTypeSchema,
291
294
  HeadEntry: headEntrySchema,
292
295
  ImageConfig: imageConfigSchema,
@@ -342,9 +345,16 @@ export async function generateSchema() {
342
345
  },
343
346
  $head: {
344
347
  description:
345
- "Page-level <head> entries. Array of element definitions for meta tags, " +
348
+ "Page-level <head> entries. Array of head entries for meta tags, " +
346
349
  "link tags, script tags, etc. Merged with layout and site-level $head entries.",
347
- items: { $ref: "#/$defs/ElementDef" },
350
+ /* `HeadEntry`, NOT `ElementDef`. These were typed as elements, which was harmless only
351
+ while an element's tagName was a plain name — the moment it could be chosen at creation,
352
+ a head tag could be too, and `head-merger.ts` splices one straight into the built page as
353
+ `<${tag} …>`. A head tag is static by nature (the runtime's `injectHead` builds a CSS
354
+ selector out of it before it ever creates anything), and `HeadEntry` already says so.
355
+ It had been an orphan def until now: its only inbound `$ref` was its own recursive
356
+ `children`. */
357
+ items: { $ref: "#/$defs/HeadEntry" },
348
358
  type: "array",
349
359
  },
350
360
  $id: {
@@ -352,6 +362,30 @@ export async function generateSchema() {
352
362
  examples: ["Counter", "TodoApp", "UserCard"],
353
363
  type: "string",
354
364
  },
365
+ $dir: {
366
+ description:
367
+ "Base direction for this page's <html> element, overriding `defaults.dir`. " +
368
+ "Right-to-left content renders left-to-right without it.",
369
+ enum: ["ltr", "rtl", "auto"],
370
+ type: "string",
371
+ },
372
+ $lang: {
373
+ description:
374
+ "Language tag for this page's <html> element, overriding `defaults.lang` " +
375
+ "(site-architecture.md §8.4).",
376
+ examples: ["en", "fr", "ar-EG"],
377
+ type: "string",
378
+ },
379
+ $translationKey: {
380
+ description:
381
+ "This page's identity across languages, overriding the one its path implies " +
382
+ "(site-architecture.md §13.5). Two routes are translations when their keys match; " +
383
+ "the default key is the path with the locale prefix removed, which is right whenever " +
384
+ "the paths are parallel and wrong for a localized slug — /fr-ca/a-propos/ shares " +
385
+ "nothing with /about/ until one of them says so.",
386
+ examples: ["about", "blog/hello-world"],
387
+ type: "string",
388
+ },
355
389
  $layout: {
356
390
  description:
357
391
  "Layout reference for pages. String path to a layout JSON file, " +
@@ -359,6 +393,24 @@ export async function generateSchema() {
359
393
  examples: ["./layouts/base.json"],
360
394
  oneOf: [{ type: "string" }, { const: false, type: "boolean" }],
361
395
  },
396
+ $shadow: {
397
+ description:
398
+ "Render this component into a shadow root instead of the light DOM (spec.md §16.6). " +
399
+ "Off unless set: a shadow root isolates the component's styles from the page and " +
400
+ "replaces the <slot> emulation with real slot distribution, so it changes how the " +
401
+ "component composes. Overrides `defaults.shadow` in both directions — `false` opts " +
402
+ "one component out of a project that opted in.",
403
+ oneOf: [
404
+ { enum: ["open", "closed"], type: "string" },
405
+ { const: false, type: "boolean" },
406
+ ],
407
+ },
408
+ $sitemap: {
409
+ description:
410
+ "Set false to exclude this page from sitemap.xml (site-architecture.md §8.4.1). " +
411
+ "Every other page with a URL is included.",
412
+ type: "boolean",
413
+ },
362
414
  $media: {
363
415
  additionalProperties: { type: "string" },
364
416
  description:
@@ -668,7 +720,8 @@ export async function validateWithSchema(
668
720
  doc: Record<string, unknown>,
669
721
  schema: Record<string, unknown>,
670
722
  ) {
671
- let Ajv: AjvCtor, addFormats: AddFormatsFn;
723
+ let Ajv: AjvCtor;
724
+ let addFormats: AddFormatsFn;
672
725
  try {
673
726
  // The generated schema is JSON Schema 2020-12, so use the matching Ajv build
674
727
  // (the default `ajv` export is draft-07 and can't compile a 2020-12 schema).
package/types.ts CHANGED
@@ -200,7 +200,11 @@ export interface JxServerFnDef {
200
200
  export interface JxHeadEntry {
201
201
  tagName: string;
202
202
  attributes?: Record<string, string | boolean>;
203
- textContent?: string;
203
+ /**
204
+ * Element content. An **object** is a structured data block — the compiler serializes it to JSON
205
+ * inside the tag, which is how JSON-LD is authored (site-architecture.md §8.5).
206
+ */
207
+ textContent?: string | Record<string, unknown>;
204
208
  children?: (JxHeadEntry | string)[];
205
209
  [key: string]: unknown;
206
210
  }
@@ -212,6 +216,8 @@ export interface ImageConfig {
212
216
  quality?: { webp?: number; avif?: number; jpeg?: number; png?: number };
213
217
  sizes?: string;
214
218
  lazyLoad?: boolean;
219
+ /** Wrap a multi-format image in a `<picture>` with one `<source>` per format. Default true. */
220
+ picture?: boolean;
215
221
  service?: "build" | "cloudflare";
216
222
  remoteDomains?: string[];
217
223
  }
@@ -240,8 +246,26 @@ export interface JxStyle {
240
246
  [property: string]: string | number | JxStyle | undefined;
241
247
  }
242
248
 
249
+ /**
250
+ * A tag chosen when the element is created, from a set the schema enumerates.
251
+ *
252
+ * Both branches resolve to a `TagName`, so the candidates are readable without evaluating anything
253
+ * — which is what lets the compiler emit one template per candidate and `jx validate` refuse an
254
+ * illegal name at authoring time. See `defs/tag-expression.schema.ts` for why this is not a `${…}`
255
+ * template and not a third `$switch` spelling.
256
+ */
257
+ export type JxTagExpression =
258
+ | { operator: "?:"; target: unknown; value: string; initial: string }
259
+ | { operator: "switch"; target: unknown; cases: Record<string, string>; default: string };
260
+
261
+ /**
262
+ * An ELEMENT's tag. The document root's and a head entry's stay `string` — see
263
+ * `defs/tag-expression.schema.ts`.
264
+ */
265
+ export type JxElementTagName = string | { $expression: JxTagExpression };
266
+
243
267
  export interface JxElement {
244
- tagName?: string;
268
+ tagName?: JxElementTagName;
245
269
  textContent?: string | null | JxRef;
246
270
  innerHTML?: string;
247
271
  /**
@@ -285,8 +309,19 @@ export interface JxMappedArray {
285
309
  }
286
310
 
287
311
  export interface JxDocument extends JxElement {
312
+ /**
313
+ * The custom element's NAME, always a literal.
314
+ *
315
+ * Narrowed from {@link JxElement.tagName} deliberately: this string becomes
316
+ * `customElements.define(…)`, the emitted module's file name and a CSS selector prefix, none of
317
+ * which can be chosen per instance. Carrying the rule in the type means `defineElement` and the
318
+ * compiler keep receiving a string without a positional guard anyone can forget to write.
319
+ */
320
+ tagName?: string;
288
321
  state?: Record<string, JxStateDefinition>;
289
322
  $layout?: string | false;
323
+ /** This page's identity across languages, overriding the one its path implies (§13.5). */
324
+ $translationKey?: string;
290
325
  $paths?: JxPathsDef;
291
326
  $elements?: (JxElement | string)[];
292
327
  $head?: JxHeadEntry[];
@@ -483,6 +518,114 @@ export type AdapterId = "static" | "cloudflare-pages" | "cloudflare-workers" | "
483
518
  * Identifiers only — no secrets — so it travels with the repo and any Studio (local or cloud) can
484
519
  * tell whether publishing is set up.
485
520
  */
521
+ /** `dist/_headers` output — the response headers only the build can know (RFC 9111, RFC 8246). */
522
+ /**
523
+ * CSP Level 3, as far as a static build can go.
524
+ *
525
+ * Off by default. Every other security header the build emits is safe to turn on for a site that
526
+ * has never seen one; a policy is the one that can leave a page blank, because it governs code the
527
+ * build cannot see — a third-party script that loads a second script, a widget that opens a frame.
528
+ * Turning it on is one line and one look at the browser console.
529
+ */
530
+ export interface CspConfig {
531
+ /** `"report-only"` sends `Content-Security-Policy-Report-Only`. Default `"enforce"`. */
532
+ mode?: "enforce" | "report-only";
533
+ /**
534
+ * Replace or remove a computed directive. `false` removes it; a string replaces it wholesale, so
535
+ * an addition means restating the default's sources alongside the new one.
536
+ */
537
+ directives?: Record<string, string | false>;
538
+ /** Where violation reports go. Emits `report-to`, `report-uri` and `Reporting-Endpoints`. */
539
+ reportUri?: string;
540
+ }
541
+
542
+ /**
543
+ * W3C Web App Manifest. Off unless declared — a manifest is a claim that a site is meant to be
544
+ * installed, and most are not.
545
+ */
546
+ export interface ManifestConfig {
547
+ enabled?: boolean;
548
+ /** Defaults to the project `name`. */
549
+ name?: string;
550
+ shortName?: string;
551
+ description?: string;
552
+ /** Defaults to `/`. */
553
+ startUrl?: string;
554
+ scope?: string;
555
+ display?: "fullscreen" | "standalone" | "minimal-ui" | "browser";
556
+ orientation?: string;
557
+ themeColor?: string;
558
+ backgroundColor?: string;
559
+ lang?: string;
560
+ dir?: "ltr" | "rtl" | "auto";
561
+ categories?: string[];
562
+ icons?: { src: string; sizes?: string; type?: string; purpose?: string }[];
563
+ }
564
+
565
+ /**
566
+ * W3C Service Worker. **Off unless declared, and turning it off means `false`, not deleting the
567
+ * key** — a worker is sticky, so a site that once had one must keep serving something at that URL
568
+ * to tell previous visitors to stop.
569
+ */
570
+ export interface ServiceWorkerConfig {
571
+ enabled?: boolean;
572
+ /** Registration scope. Cannot exceed the worker's own path. Default `"/"`. */
573
+ scope?: string;
574
+ /** URLs fetched and cached at install time. */
575
+ precache?: string[];
576
+ /** Page served for a failed navigation. Added to `precache` if it is not already there. */
577
+ offlineFallback?: string;
578
+ }
579
+
580
+ /**
581
+ * RFC 9116 `security.txt`. `expires` is required by §2.5.5 and an absent or past one is a build
582
+ * error: an expired file advertises a reporting channel while telling the reporter not to trust
583
+ * it.
584
+ */
585
+ export interface SecurityTxtConfig {
586
+ enabled?: boolean;
587
+ /** At least one is required (§2.5.3): a `mailto:`, `https:` or `tel:` URI. */
588
+ contact?: string[];
589
+ /** RFC 3339 date-time; §2.5.5. */
590
+ expires?: string;
591
+ encryption?: string[];
592
+ acknowledgments?: string[];
593
+ /** BCP 47 tags, validated and canonicalized like `i18n.locales`. */
594
+ preferredLanguages?: string[];
595
+ canonical?: string;
596
+ policy?: string[];
597
+ hiring?: string[];
598
+ }
599
+
600
+ export interface HeadersConfig {
601
+ /** Emit the file at all. Default true. */
602
+ enabled?: boolean;
603
+ /**
604
+ * `"auto"` marks the content-addressed image variants immutable and revalidates everything else.
605
+ * `"off"` emits no `Cache-Control` at all, for a host that manages caching itself.
606
+ */
607
+ cache?: "auto" | "off";
608
+ security?: {
609
+ /** `X-Content-Type-Options: nosniff`. Default true. */
610
+ contentTypeOptions?: boolean;
611
+ /** `X-Frame-Options`, or false to omit. Default `"SAMEORIGIN"`. */
612
+ frameOptions?: "DENY" | "SAMEORIGIN" | false;
613
+ /** `Referrer-Policy`, or false to omit. Default `"strict-origin-when-cross-origin"`. */
614
+ referrerPolicy?: string | false;
615
+ /** `Permissions-Policy`, or false to omit. */
616
+ permissionsPolicy?: string | false;
617
+ /**
618
+ * RFC 6797. **Off by default**: a wrong `max-age` locks an apex domain to HTTPS for that long,
619
+ * and the mistake is not visible until a certificate lapses.
620
+ */
621
+ hsts?: boolean | { maxAge?: number; includeSubDomains?: boolean; preload?: boolean };
622
+ /** CSP Level 3. Off by default — see {@link CspConfig}. */
623
+ csp?: boolean | "report-only" | CspConfig;
624
+ };
625
+ /** Verbatim rules, merged after the generated block. Keys are path patterns. */
626
+ rules?: Record<string, Record<string, string>>;
627
+ }
628
+
486
629
  export interface DeployConfig {
487
630
  provider: "cloudflare-pages";
488
631
  accountId: string;
@@ -504,6 +647,7 @@ export interface ProjectConfig {
504
647
  adapter?: AdapterId | (string & Record<never, never>);
505
648
  deploy?: DeployConfig;
506
649
  sitemap?: boolean;
650
+ headers?: HeadersConfig;
507
651
  [key: string]: unknown;
508
652
  };
509
653
  images?: ImageConfig;
@@ -515,11 +659,39 @@ export interface ProjectConfig {
515
659
  */
516
660
  extensions?: string[];
517
661
  /** Redirect map: source path → destination (or destination with HTTP status). */
518
- redirects?: Record<string, string | { destination: string; status?: number }>;
662
+ redirects?: Record<
663
+ string,
664
+ string | { destination: string; status?: number } | { destination: string; rewrite: true }
665
+ >;
666
+ /** W3C Web App Manifest, emitted as `manifest.webmanifest`. */
667
+ manifest?: ManifestConfig;
668
+ /** RFC 9116, emitted as `.well-known/security.txt`. */
669
+ securityTxt?: SecurityTxtConfig;
670
+ /**
671
+ * W3C Service Worker, emitted as `sw.js`. Off unless declared; `false` emits a tombstone that
672
+ * unregisters a previously deployed worker (site-architecture.md §14.6).
673
+ */
674
+ serviceWorker?: ServiceWorkerConfig | boolean;
675
+ /**
676
+ * Locale routing. Tags are BCP 47; a malformed one is a build error, since a locale is a URL
677
+ * prefix, an `hreflang` value and an `<html lang>` at once.
678
+ */
679
+ i18n?: {
680
+ defaultLocale?: string;
681
+ locales?: string[];
682
+ routing?: "prefix-except-default" | "prefix-always";
683
+ };
519
684
  defaults?: {
520
685
  /** Default layout path; `null` means explicitly no layout. */
521
686
  layout?: string | null;
522
687
  lang?: string;
688
+ /** Base direction for `<html dir>`. A page's `$dir` overrides it. */
689
+ dir?: "ltr" | "rtl" | "auto";
690
+ /**
691
+ * Default shadow-DOM mode for components. `false` (light DOM) unless set; a component's own
692
+ * `$shadow` overrides it in both directions.
693
+ */
694
+ shadow?: "open" | "closed" | false;
523
695
  charset?: string;
524
696
  [key: string]: unknown;
525
697
  };
@@ -550,7 +722,7 @@ export type JxPathsDef =
550
722
  * `@jxsuite/schema/guards` before use.
551
723
  */
552
724
  export interface JxMutableNode {
553
- tagName?: string;
725
+ tagName?: JxElementTagName;
554
726
  textContent?: string | null | JxRef;
555
727
  innerHTML?: string;
556
728
  /**
@@ -639,5 +811,20 @@ export interface ContentLoaderEntry {
639
811
  toc?: TocEntry[];
640
812
  readingTime?: number;
641
813
  wordCount?: number;
814
+ /**
815
+ * The source file's modification time, RFC 3339. The fallback a feed uses when an entry carries
816
+ * no authored date, and what lets the sitemap give a generated page its own `<lastmod>` rather
817
+ * than its template's.
818
+ */
819
+ mtime?: string;
820
+ /** Authored text of a field the date-coercion pass rewrote, keyed by field name. */
821
+ rawDates?: Record<string, unknown>;
822
+ /**
823
+ * The locale this entry was loaded for, set only when its content type's `source` carried a
824
+ * `{locale}` placeholder. It is what lets a `[slug]` route under `/fr/` expand the French
825
+ * entries and not the English ones — without it, two translations of one post share an id and
826
+ * the second silently overwrites the first's route.
827
+ */
828
+ locale?: string;
642
829
  };
643
830
  }