@jxsuite/schema 1.4.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/class-schema.json +1 -0
- package/defs/children-value.schema.ts +10 -1
- package/defs/class-def.schema.ts +1 -0
- package/defs/element-def.schema.ts +1 -1
- package/defs/expression-node.schema.ts +4 -1
- package/defs/external-class-def.schema.ts +20 -2
- package/defs/field-schema.schema.ts +75 -0
- package/defs/head-entry.schema.ts +6 -1
- package/defs/image-config.schema.ts +7 -0
- package/defs/index.ts +2 -1
- package/defs/project-config.schema.ts +299 -7
- package/defs/tag-expression.schema.ts +98 -0
- package/defs/tag-name.schema.ts +13 -1
- package/package.json +7 -3
- package/project-schema.json +441 -5
- package/schema.json +289 -13
- package/schemas/document.paths.schema.json +54 -1
- package/schemas/project.core.schema.json +401 -5
- package/src/asset-paths.ts +21 -0
- package/src/extension-registry.ts +12 -0
- package/src/format-registry.ts +32 -2
- package/src/guards.ts +68 -2
- package/src/ijson.ts +205 -0
- package/src/intl.ts +155 -0
- package/src/locale.ts +429 -0
- package/src/media-type.ts +217 -0
- package/src/parse.ts +78 -1
- package/src/project-schemas.ts +358 -12
- package/src/schema.ts +85 -8
- package/types.ts +191 -4
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
|
-
|
|
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/project-schemas.ts
CHANGED
|
@@ -18,7 +18,11 @@
|
|
|
18
18
|
* relative refs resolve (the committed files stay machine-path-free).
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
DOCUMENT_PATHS_SCHEMA_ID,
|
|
23
|
+
PROJECT_FIELDS_SCHEMA_ID,
|
|
24
|
+
documentPathsCoreMembers,
|
|
25
|
+
} from "../defs/field-schema.schema";
|
|
22
26
|
|
|
23
27
|
export { DOCUMENT_PATHS_SCHEMA_ID, PROJECT_FIELDS_SCHEMA_ID } from "../defs/field-schema.schema";
|
|
24
28
|
|
|
@@ -83,6 +87,9 @@ export type SchemaJsonLoader = (path: string) => Promise<Record<string, unknown>
|
|
|
83
87
|
* @param {Record<string, unknown>} entry - The entry document (e.g. a parsed project.schema.json)
|
|
84
88
|
* @param {SchemaJsonLoader} loadJson - Loader for `$ref` target files (host-restricted)
|
|
85
89
|
* @param {string} baseDir - Directory the entry document's relative refs resolve against
|
|
90
|
+
* @param {string[]} [alsoEmbed] - Relative paths of resources to embed even though no path-`$ref`
|
|
91
|
+
* reaches them, because they are referenced only by canonical `$id` (a document fragment whose
|
|
92
|
+
* `$defs` feed the paths union). Without this they would stay dangling.
|
|
86
93
|
* @returns {Promise<Record<string, unknown>>} A new, self-contained schema (the input is not
|
|
87
94
|
* mutated)
|
|
88
95
|
*/
|
|
@@ -90,6 +97,7 @@ export async function bundleSchema(
|
|
|
90
97
|
entry: Record<string, unknown>,
|
|
91
98
|
loadJson: SchemaJsonLoader,
|
|
92
99
|
baseDir: string,
|
|
100
|
+
alsoEmbed: string[] = [],
|
|
93
101
|
): Promise<Record<string, unknown>> {
|
|
94
102
|
const bundled = structuredClone(entry);
|
|
95
103
|
const embeddedByPath = new Map<string, string>();
|
|
@@ -167,9 +175,220 @@ export async function bundleSchema(
|
|
|
167
175
|
};
|
|
168
176
|
|
|
169
177
|
await rewrite(bundled, normalizeSchemaPath(baseDir), true);
|
|
178
|
+
for (const path of alsoEmbed) {
|
|
179
|
+
await embed(`${normalizeSchemaPath(baseDir)}/${path}`);
|
|
180
|
+
}
|
|
170
181
|
return bundled;
|
|
171
182
|
}
|
|
172
183
|
|
|
184
|
+
// ─── Single-resource flattening (specs/extensions.md §5.4) ───────────────────
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Keywords whose value is a single subschema (or, for draft-07 tuple `items`, an array of them).
|
|
188
|
+
* The flattener walks ONLY these keyword positions, never every object key: `examples`, `default`,
|
|
189
|
+
* `const` and `enum` hold arbitrary instance data, and Jx document examples legitimately contain
|
|
190
|
+
* `$ref` strings (`#/state/cart`) that must survive untouched.
|
|
191
|
+
*/
|
|
192
|
+
const SINGLE_SUBSCHEMA_KEYWORDS = [
|
|
193
|
+
"additionalItems",
|
|
194
|
+
"additionalProperties",
|
|
195
|
+
"contains",
|
|
196
|
+
"contentSchema",
|
|
197
|
+
"else",
|
|
198
|
+
"if",
|
|
199
|
+
"items",
|
|
200
|
+
"not",
|
|
201
|
+
"propertyNames",
|
|
202
|
+
"then",
|
|
203
|
+
"unevaluatedItems",
|
|
204
|
+
"unevaluatedProperties",
|
|
205
|
+
] as const;
|
|
206
|
+
|
|
207
|
+
/** Keywords whose value maps names to subschemas. */
|
|
208
|
+
const MAP_SUBSCHEMA_KEYWORDS = [
|
|
209
|
+
"$defs",
|
|
210
|
+
"definitions",
|
|
211
|
+
"dependencies",
|
|
212
|
+
"dependentSchemas",
|
|
213
|
+
"patternProperties",
|
|
214
|
+
"properties",
|
|
215
|
+
] as const;
|
|
216
|
+
|
|
217
|
+
/** Keywords whose value is an array of subschemas. */
|
|
218
|
+
const LIST_SUBSCHEMA_KEYWORDS = ["allOf", "anyOf", "oneOf", "prefixItems"] as const;
|
|
219
|
+
|
|
220
|
+
/** True for a plain object node (a candidate subschema); booleans and arrays are not. */
|
|
221
|
+
function isSchemaObject(value: unknown): value is Record<string, unknown> {
|
|
222
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Escape a `$defs` key for use as a JSON Pointer segment (RFC 6901). */
|
|
226
|
+
function escapePointerSegment(segment: string): string {
|
|
227
|
+
return segment.replaceAll("~", "~0").replaceAll("/", "~1");
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Walk the known subschema keyword positions of `node`.
|
|
232
|
+
*
|
|
233
|
+
* @param {Record<string, unknown>} node - The schema object to walk
|
|
234
|
+
* @param {string} pointer - JSON Pointer of `node` within the document
|
|
235
|
+
* @yields {[Record<string, unknown>, string]} Each subschema paired with its own JSON Pointer
|
|
236
|
+
*/
|
|
237
|
+
function* subschemaEntries(
|
|
238
|
+
node: Record<string, unknown>,
|
|
239
|
+
pointer: string,
|
|
240
|
+
): Generator<[Record<string, unknown>, string]> {
|
|
241
|
+
for (const keyword of SINGLE_SUBSCHEMA_KEYWORDS) {
|
|
242
|
+
const value = node[keyword];
|
|
243
|
+
if (Array.isArray(value)) {
|
|
244
|
+
for (const [index, item] of value.entries()) {
|
|
245
|
+
if (isSchemaObject(item)) {
|
|
246
|
+
yield [item, `${pointer}/${keyword}/${index}`];
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
} else if (isSchemaObject(value)) {
|
|
250
|
+
yield [value, `${pointer}/${keyword}`];
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
for (const keyword of MAP_SUBSCHEMA_KEYWORDS) {
|
|
254
|
+
const value = node[keyword];
|
|
255
|
+
if (!isSchemaObject(value)) {
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
for (const [name, item] of Object.entries(value)) {
|
|
259
|
+
if (isSchemaObject(item)) {
|
|
260
|
+
yield [item, `${pointer}/${keyword}/${escapePointerSegment(name)}`];
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
for (const keyword of LIST_SUBSCHEMA_KEYWORDS) {
|
|
265
|
+
const value = node[keyword];
|
|
266
|
+
if (!Array.isArray(value)) {
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
for (const [index, item] of value.entries()) {
|
|
270
|
+
if (isSchemaObject(item)) {
|
|
271
|
+
yield [item, `${pointer}/${keyword}/${index}`];
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* A readable, collision-free `$defs` key for an embedded resource's `$id`: drop the scheme, the
|
|
279
|
+
* authority, and the conventional `schema/` namespace segment, then join the remainder with dashes
|
|
280
|
+
* (`https://jxsuite.com/schema/project/core/v2` → `project-core-v2`, `urn:jx:bundled:1` →
|
|
281
|
+
* `jx-bundled-1`). Collisions get a numeric suffix, so output stays deterministic given the
|
|
282
|
+
* emitters' stable `$defs` order.
|
|
283
|
+
*
|
|
284
|
+
* @param {string} id - The resource's canonical `$id`
|
|
285
|
+
* @param {Set<string>} used - Keys already taken in the target `$defs` (mutated)
|
|
286
|
+
* @returns {string}
|
|
287
|
+
*/
|
|
288
|
+
function slugForSchemaId(id: string, used: Set<string>): string {
|
|
289
|
+
let rest = id.replace(/^[a-zA-Z][a-zA-Z\d+.-]*:/, "");
|
|
290
|
+
if (rest.startsWith("//")) {
|
|
291
|
+
const slash = rest.indexOf("/", 2);
|
|
292
|
+
rest = slash === -1 ? "" : rest.slice(slash + 1);
|
|
293
|
+
}
|
|
294
|
+
rest = rest.replace(/^schema\//, "");
|
|
295
|
+
const base =
|
|
296
|
+
rest
|
|
297
|
+
.split(/[^a-zA-Z0-9]+/)
|
|
298
|
+
.filter(Boolean)
|
|
299
|
+
.join("-") || "resource";
|
|
300
|
+
let key = base;
|
|
301
|
+
for (let n = 2; used.has(key); n += 1) {
|
|
302
|
+
key = `${base}-${n}`;
|
|
303
|
+
}
|
|
304
|
+
used.add(key);
|
|
305
|
+
return key;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Collapse a bundled compound document into a SINGLE schema resource: embedded resources are
|
|
310
|
+
* rekeyed from their canonical `$id`s to readable `$defs` slugs, their `$id`s are dropped, and
|
|
311
|
+
* every `$ref` becomes a root-relative JSON Pointer (`#/$defs/project-core-v2/$defs/StyleObject`).
|
|
312
|
+
*
|
|
313
|
+
* Why this is required rather than cosmetic: VS Code's JSON language service (and Monaco, which
|
|
314
|
+
* embeds it) implements neither half of compound-document resolution. It resolves a `#/pointer` ref
|
|
315
|
+
* against the DOCUMENT ROOT — never re-based on an enclosing `$id` — so a fragment's own
|
|
316
|
+
* `#/$defs/StyleObject` reports "can not be resolved"; and it treats any ref with a non-empty part
|
|
317
|
+
* before `#` as EXTERNAL, fetching `https://jxsuite.com/schema/...` over the network instead of
|
|
318
|
+
* matching the embedded resource, which fails offline and (when it succeeds) silently substitutes
|
|
319
|
+
* the shipped defaults for the project's effective unions. Root-pointer-only refs are the one form
|
|
320
|
+
* every consumer resolves identically — ajv, the language service, and Monaco alike.
|
|
321
|
+
*
|
|
322
|
+
* `$id` shadowing (§5.3) still decides the outcome; it is simply resolved AT FLATTEN TIME, since
|
|
323
|
+
* refs to a shadowed `$id` rewrite to the pointer of the entry document's embed.
|
|
324
|
+
*
|
|
325
|
+
* @param {Record<string, unknown>} entry - A bundled compound document (output of
|
|
326
|
+
* {@link bundleSchema})
|
|
327
|
+
* @returns {Record<string, unknown>} A new single-resource schema (the input is not mutated)
|
|
328
|
+
*/
|
|
329
|
+
export function flattenSchema(entry: Record<string, unknown>): Record<string, unknown> {
|
|
330
|
+
const flat = structuredClone(entry);
|
|
331
|
+
|
|
332
|
+
// 1. Rekey URI-keyed `$defs` entries to readable slugs (pointers address them by key).
|
|
333
|
+
if (isSchemaObject(flat.$defs)) {
|
|
334
|
+
const used = new Set(Object.keys(flat.$defs).filter((key) => !hasUriScheme(key)));
|
|
335
|
+
const renamed: Record<string, unknown> = {};
|
|
336
|
+
for (const [key, value] of Object.entries(flat.$defs)) {
|
|
337
|
+
const id = isSchemaObject(value) && typeof value.$id === "string" ? value.$id : key;
|
|
338
|
+
renamed[hasUriScheme(key) ? slugForSchemaId(id, used) : key] = value;
|
|
339
|
+
}
|
|
340
|
+
flat.$defs = renamed;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// 2. Map each embedded resource's `$id` to its pointer here (first wins, as the bundler embeds).
|
|
344
|
+
const idToPointer = new Map<string, string>();
|
|
345
|
+
const scan = (node: Record<string, unknown>, pointer: string): void => {
|
|
346
|
+
const { $id } = node;
|
|
347
|
+
if (typeof $id === "string" && hasUriScheme($id) && !idToPointer.has($id)) {
|
|
348
|
+
idToPointer.set($id, pointer);
|
|
349
|
+
}
|
|
350
|
+
for (const [child, childPointer] of subschemaEntries(node, pointer)) {
|
|
351
|
+
scan(child, childPointer);
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
scan(flat, "#");
|
|
355
|
+
|
|
356
|
+
// 3. Rewrite refs against the enclosing resource, then dissolve the resource boundary.
|
|
357
|
+
const rewrite = (node: Record<string, unknown>, scope: string): void => {
|
|
358
|
+
let inner = scope;
|
|
359
|
+
const { $id } = node;
|
|
360
|
+
if (typeof $id === "string" && hasUriScheme($id)) {
|
|
361
|
+
inner = idToPointer.get($id) ?? scope;
|
|
362
|
+
if (inner !== "#") {
|
|
363
|
+
// The root keeps its identity; nested resources must not remain ref targets.
|
|
364
|
+
delete node.$id;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
const ref = node.$ref;
|
|
368
|
+
if (typeof ref === "string") {
|
|
369
|
+
const hashIndex = ref.indexOf("#");
|
|
370
|
+
const target = hashIndex === -1 ? ref : ref.slice(0, hashIndex);
|
|
371
|
+
const fragment = hashIndex === -1 ? "" : ref.slice(hashIndex + 1);
|
|
372
|
+
if (target === "") {
|
|
373
|
+
// A local pointer resolves against the resource it appears in, not the file.
|
|
374
|
+
if (fragment.startsWith("/")) {
|
|
375
|
+
node.$ref = `${inner}${fragment}`;
|
|
376
|
+
}
|
|
377
|
+
} else {
|
|
378
|
+
const base = idToPointer.get(target);
|
|
379
|
+
if (base !== undefined) {
|
|
380
|
+
node.$ref = fragment ? `${base}${fragment}` : base;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
for (const [child] of subschemaEntries(node, inner)) {
|
|
385
|
+
rewrite(child, inner);
|
|
386
|
+
}
|
|
387
|
+
};
|
|
388
|
+
rewrite(flat, "#");
|
|
389
|
+
return flat;
|
|
390
|
+
}
|
|
391
|
+
|
|
173
392
|
/** Collect every absolute `$id` already present in a document (embeds like the field union). */
|
|
174
393
|
function collectResourceIds(node: unknown, out: Set<string>): void {
|
|
175
394
|
if (Array.isArray(node)) {
|
|
@@ -240,23 +459,150 @@ export interface DocumentSchemaInputs {
|
|
|
240
459
|
pathsValueRefs: string[];
|
|
241
460
|
}
|
|
242
461
|
|
|
243
|
-
/**
|
|
462
|
+
/**
|
|
463
|
+
* Emit the `document.schema.json` entry document for a project.
|
|
464
|
+
*
|
|
465
|
+
* The core reference goes in a single-member `allOf`, never a root-level `$ref`. They are
|
|
466
|
+
* equivalent to a compliant validator, but VS Code's JSON language service resolves a `$ref` by
|
|
467
|
+
* SHALLOW-MERGING the target's keys into the referencing node — from the root, that overwrites the
|
|
468
|
+
* root's own `$defs` with the core resource's `$defs`, and every later `#/$defs/<embed>/...`
|
|
469
|
+
* pointer stops resolving. Wrapping in `allOf` keeps the merge target off the root.
|
|
470
|
+
*
|
|
471
|
+
* @param {DocumentSchemaInputs} inputs - Core path and the contributed paths-union refs
|
|
472
|
+
* @returns {Record<string, unknown>}
|
|
473
|
+
*/
|
|
244
474
|
export function emitDocumentSchema(inputs: DocumentSchemaInputs): Record<string, unknown> {
|
|
245
475
|
const { corePath, pathsValueRefs } = inputs;
|
|
246
476
|
return {
|
|
247
477
|
$comment: GENERATED_SCHEMA_COMMENT,
|
|
248
478
|
$defs: {
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
479
|
+
/* The EFFECTIVE paths union: the core source shapes plus every extension-contributed one.
|
|
480
|
+
Carrying the core members here (rather than leaving them to the core schema) is what makes
|
|
481
|
+
the override additive — this embed shadows the shipped default by $id, so anything it omits
|
|
482
|
+
stops validating. Mirrors the field union in emitProjectSchema, which names its core
|
|
483
|
+
members the same way. */
|
|
484
|
+
PathsValue: {
|
|
485
|
+
$id: DOCUMENT_PATHS_SCHEMA_ID,
|
|
486
|
+
anyOf: [...documentPathsCoreMembers, ...pathsValueRefs.map((ref) => ({ $ref: ref }))],
|
|
487
|
+
},
|
|
258
488
|
},
|
|
259
|
-
$ref: corePath,
|
|
260
489
|
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
490
|
+
allOf: [{ $ref: corePath }],
|
|
261
491
|
};
|
|
262
492
|
}
|
|
493
|
+
|
|
494
|
+
// ─── Composition (specs/extensions.md §5.2) ──────────────────────────────────
|
|
495
|
+
|
|
496
|
+
/** Loader-resolvable refs to one extension's shipped schema fragments, as its manifest names them. */
|
|
497
|
+
export interface ExtensionFragmentRefs {
|
|
498
|
+
/** Project fragment — plain `properties` for the extension's section keys. */
|
|
499
|
+
project?: string | undefined;
|
|
500
|
+
/** Document fragment — its `$defs` become `$paths` source-union members. */
|
|
501
|
+
document?: string | undefined;
|
|
502
|
+
/** Fields fragment — its `$defs` become Field-schema union members. */
|
|
503
|
+
fields?: string | undefined;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
export interface ComposeProjectSchemasInputs {
|
|
507
|
+
/** Ref to the core project fragment (`@jxsuite/schema/schemas/project.core.schema.json`). */
|
|
508
|
+
coreProjectRef: string;
|
|
509
|
+
/** Ref to the core document schema (`@jxsuite/schema/schema.json`). */
|
|
510
|
+
coreDocumentRef: string;
|
|
511
|
+
/** Enabled extensions in registry order; each contributes the fragments it ships. */
|
|
512
|
+
extensions: ExtensionFragmentRefs[];
|
|
513
|
+
/** Loads a ref's JSON. Every ref above and every `$ref` inside resolves through this ALONE. */
|
|
514
|
+
loadJson: SchemaJsonLoader;
|
|
515
|
+
/** Directory the refs resolve against ("" for hosts addressing by bare specifier). */
|
|
516
|
+
baseDir: string;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Compose a project's two committed entry documents from the core schemas and its enabled
|
|
521
|
+
* extensions' fragments — bundled, then flattened to root JSON pointers (§5.2/§5.4).
|
|
522
|
+
*
|
|
523
|
+
* Host-agnostic by construction: every file it needs arrives through `loadJson`, so the same
|
|
524
|
+
* composition runs over a filesystem (`jx schema`) and over a virtual tree of bundled artifacts
|
|
525
|
+
* (the cloud session, which has no `node_modules` and no filesystem). That shared path is the point
|
|
526
|
+
* — a project's schemas must not depend on which host generated them.
|
|
527
|
+
*
|
|
528
|
+
* @param {ComposeProjectSchemasInputs} inputs - Core refs, extension fragments, loader, base
|
|
529
|
+
* @returns {Promise<{ project: Record<string, unknown>; document: Record<string, unknown> }>} The
|
|
530
|
+
* self-contained entry documents, ready to write or to hand an editor
|
|
531
|
+
*/
|
|
532
|
+
export async function composeProjectSchemas(inputs: ComposeProjectSchemasInputs): Promise<{
|
|
533
|
+
project: Record<string, unknown>;
|
|
534
|
+
document: Record<string, unknown>;
|
|
535
|
+
}> {
|
|
536
|
+
const { baseDir, coreDocumentRef, coreProjectRef, extensions, loadJson } = inputs;
|
|
537
|
+
|
|
538
|
+
const fragments: string[] = [];
|
|
539
|
+
const pathsValueRefs: string[] = [];
|
|
540
|
+
const documentFragments: string[] = [];
|
|
541
|
+
const fieldSchemaRefs: string[] = [];
|
|
542
|
+
for (const ext of extensions) {
|
|
543
|
+
if (ext.project) {
|
|
544
|
+
fragments.push(ext.project);
|
|
545
|
+
}
|
|
546
|
+
if (ext.document) {
|
|
547
|
+
const documentRefs = await fragmentDefsRefs(ext.document, loadJson, baseDir);
|
|
548
|
+
pathsValueRefs.push(...documentRefs);
|
|
549
|
+
/* The paths union names this fragment's shapes by canonical $id only, so the bundler needs
|
|
550
|
+
the path handed to it explicitly or those refs stay dangling. A fragment that contributed
|
|
551
|
+
nothing (no $id, or no $defs) is left out rather than embedded unreferenced. */
|
|
552
|
+
if (documentRefs.length > 0) {
|
|
553
|
+
documentFragments.push(ext.document);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
// The `schemas.fields` manifest convention: a fragment whose $defs members are extension
|
|
557
|
+
// Field extras, unioned into the per-project field resource (specs/extensions.md §5.3).
|
|
558
|
+
if (ext.fields) {
|
|
559
|
+
fieldSchemaRefs.push(...(await fragmentDefsRefs(ext.fields, loadJson, baseDir)));
|
|
560
|
+
fragments.push(ext.fields);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// Bundle then flatten: the emitted relative refs are an intermediate form only.
|
|
565
|
+
const projectEntry = emitProjectSchema({ corePath: coreProjectRef, fieldSchemaRefs, fragments });
|
|
566
|
+
const documentEntry = emitDocumentSchema({ corePath: coreDocumentRef, pathsValueRefs });
|
|
567
|
+
return {
|
|
568
|
+
document: flattenSchema(
|
|
569
|
+
await bundleSchema(documentEntry, loadJson, baseDir, documentFragments),
|
|
570
|
+
),
|
|
571
|
+
project: flattenSchema(await bundleSchema(projectEntry, loadJson, baseDir)),
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Canonical "uri#/pointer" refs into a fragment's `$defs` shapes: one per `$defs` entry, addressed
|
|
577
|
+
* by the fragment's own `$id` (refs inside the entry document's embeds resolve against canonical
|
|
578
|
+
* URIs, never file paths). An `$id`-less or `$defs`-less fragment contributes no union members —
|
|
579
|
+
* that is simply the shape of a fragment declaring only `properties`.
|
|
580
|
+
*
|
|
581
|
+
* An UNREADABLE fragment contributes nothing here but still fails the composition, because the
|
|
582
|
+
* bundler goes on to embed the same ref and throws. That is deliberate: silently dropping it would
|
|
583
|
+
* emit an entry document that under-validates the project with nothing to show for it. A host that
|
|
584
|
+
* cannot supply an extension's fragments must leave that extension out of `extensions` entirely —
|
|
585
|
+
* which is exactly what the cloud session does for packages it does not bundle.
|
|
586
|
+
*
|
|
587
|
+
* @param {string} ref - Loader-resolvable ref to the fragment, relative to `baseDir`
|
|
588
|
+
* @param {SchemaJsonLoader} loadJson - The host's loader
|
|
589
|
+
* @param {string} baseDir - Directory the ref resolves against
|
|
590
|
+
* @returns {Promise<string[]>} One ref per `$defs` member, in declaration order
|
|
591
|
+
*/
|
|
592
|
+
async function fragmentDefsRefs(
|
|
593
|
+
ref: string,
|
|
594
|
+
loadJson: SchemaJsonLoader,
|
|
595
|
+
baseDir: string,
|
|
596
|
+
): Promise<string[]> {
|
|
597
|
+
let fragment: { $id?: unknown; $defs?: Record<string, unknown> };
|
|
598
|
+
try {
|
|
599
|
+
fragment = await loadJson(normalizeSchemaPath(`${normalizeSchemaPath(baseDir)}/${ref}`));
|
|
600
|
+
} catch {
|
|
601
|
+
return [];
|
|
602
|
+
}
|
|
603
|
+
const { $id } = fragment;
|
|
604
|
+
if (typeof $id !== "string" || !fragment.$defs) {
|
|
605
|
+
return [];
|
|
606
|
+
}
|
|
607
|
+
return Object.keys(fragment.$defs).map((name) => `${$id}#/$defs/${name}`);
|
|
608
|
+
}
|
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,
|
|
@@ -102,6 +103,8 @@ import { extensionManifestSchema } from "../defs/extension-manifest.schema";
|
|
|
102
103
|
import {
|
|
103
104
|
DOCUMENT_PATHS_SCHEMA_ID,
|
|
104
105
|
PROJECT_FIELDS_SCHEMA_ID,
|
|
106
|
+
documentPathsCoreMembers,
|
|
107
|
+
documentPathsUnknownSourceMember,
|
|
105
108
|
jxFieldSchemaDef,
|
|
106
109
|
relationshipRefSchema,
|
|
107
110
|
} from "../defs/field-schema.schema";
|
|
@@ -285,9 +288,24 @@ export async function generateSchema() {
|
|
|
285
288
|
...tagNameSchema,
|
|
286
289
|
examples: [...tagExamples, "my-counter", "todo-app", "user-card"],
|
|
287
290
|
},
|
|
291
|
+
TagExpression: tagExpressionSchema,
|
|
292
|
+
ElementTagName: elementTagNameSchema,
|
|
288
293
|
JsonSchemaType: jsonSchemaTypeSchema,
|
|
289
294
|
HeadEntry: headEntrySchema,
|
|
290
295
|
ImageConfig: imageConfigSchema,
|
|
296
|
+
/* The shipped DEFAULT paths union, embedded under its canonical $id so `$paths`'s ref
|
|
297
|
+
resolves with this file alone (it is registered standalone as the studio's offline
|
|
298
|
+
fallback). A per-project entry document embeds the same $id carrying core members PLUS
|
|
299
|
+
extension shapes; that embed is declared first, so it wins resolution and this one is
|
|
300
|
+
inert there (extensions.md §5.3). */
|
|
301
|
+
PathsValue: {
|
|
302
|
+
$id: DOCUMENT_PATHS_SCHEMA_ID,
|
|
303
|
+
anyOf: [...documentPathsCoreMembers, documentPathsUnknownSourceMember],
|
|
304
|
+
description:
|
|
305
|
+
"Default $paths source union: the core shapes, plus a catch-all for source shapes " +
|
|
306
|
+
"contributed by extensions this schema cannot see. The project's generated " +
|
|
307
|
+
"document.schema.json replaces this with the exact set its extensions provide.",
|
|
308
|
+
},
|
|
291
309
|
},
|
|
292
310
|
$id: "https://jxsuite.com/schema/v1",
|
|
293
311
|
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
@@ -327,9 +345,16 @@ export async function generateSchema() {
|
|
|
327
345
|
},
|
|
328
346
|
$head: {
|
|
329
347
|
description:
|
|
330
|
-
"Page-level <head> entries. Array of
|
|
348
|
+
"Page-level <head> entries. Array of head entries for meta tags, " +
|
|
331
349
|
"link tags, script tags, etc. Merged with layout and site-level $head entries.",
|
|
332
|
-
|
|
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" },
|
|
333
358
|
type: "array",
|
|
334
359
|
},
|
|
335
360
|
$id: {
|
|
@@ -337,6 +362,30 @@ export async function generateSchema() {
|
|
|
337
362
|
examples: ["Counter", "TodoApp", "UserCard"],
|
|
338
363
|
type: "string",
|
|
339
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
|
+
},
|
|
340
389
|
$layout: {
|
|
341
390
|
description:
|
|
342
391
|
"Layout reference for pages. String path to a layout JSON file, " +
|
|
@@ -344,6 +393,24 @@ export async function generateSchema() {
|
|
|
344
393
|
examples: ["./layouts/base.json"],
|
|
345
394
|
oneOf: [{ type: "string" }, { const: false, type: "boolean" }],
|
|
346
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
|
+
},
|
|
347
414
|
$media: {
|
|
348
415
|
additionalProperties: { type: "string" },
|
|
349
416
|
description:
|
|
@@ -362,10 +429,17 @@ export async function generateSchema() {
|
|
|
362
429
|
type: "object",
|
|
363
430
|
},
|
|
364
431
|
$paths: {
|
|
432
|
+
/* Was `type: "object"`, which accepted any object at all — a misspelled key or a
|
|
433
|
+
content-type source in a project without the parser enabled passed validation and then
|
|
434
|
+
silently expanded to zero pages. Referencing the union resource by its canonical $id is
|
|
435
|
+
what makes the per-project entry document's effective union apply (extensions.md §5.3);
|
|
436
|
+
`$defs.PathsValue` below carries the shipped default so this file still resolves alone. */
|
|
437
|
+
$ref: DOCUMENT_PATHS_SCHEMA_ID,
|
|
365
438
|
description:
|
|
366
|
-
"Dynamic route
|
|
367
|
-
"
|
|
368
|
-
|
|
439
|
+
"Dynamic route source for a page: expands one page per entry at build time. One of the " +
|
|
440
|
+
"core shapes (explicit `values`, a `$ref` data file, or a bare array of parameter " +
|
|
441
|
+
"objects) or a source shape contributed by an enabled extension, such as the parser's " +
|
|
442
|
+
"`contentType`.",
|
|
369
443
|
},
|
|
370
444
|
$schema: {
|
|
371
445
|
description: "URI identifying the Jx dialect version. Enables schema-aware IDE tooling.",
|
|
@@ -499,9 +573,11 @@ export function generateDocumentPathsSchema() {
|
|
|
499
573
|
return {
|
|
500
574
|
$id: DOCUMENT_PATHS_SCHEMA_ID,
|
|
501
575
|
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
576
|
+
anyOf: [...documentPathsCoreMembers, documentPathsUnknownSourceMember],
|
|
502
577
|
description:
|
|
503
|
-
"Default $paths-value union for documents. Per-project entry
|
|
504
|
-
"resource with
|
|
578
|
+
"Default $paths-value union for documents: the core source shapes. Per-project entry " +
|
|
579
|
+
"documents override this resource with those members PLUS every extension-contributed " +
|
|
580
|
+
"paths shape.",
|
|
505
581
|
title: "Jx Document Paths Union",
|
|
506
582
|
};
|
|
507
583
|
}
|
|
@@ -644,7 +720,8 @@ export async function validateWithSchema(
|
|
|
644
720
|
doc: Record<string, unknown>,
|
|
645
721
|
schema: Record<string, unknown>,
|
|
646
722
|
) {
|
|
647
|
-
let Ajv: AjvCtor
|
|
723
|
+
let Ajv: AjvCtor;
|
|
724
|
+
let addFormats: AddFormatsFn;
|
|
648
725
|
try {
|
|
649
726
|
// The generated schema is JSON Schema 2020-12, so use the matching Ajv build
|
|
650
727
|
// (the default `ajv` export is draft-07 and can't compile a 2020-12 schema).
|