@jxsuite/schema 1.4.0 → 1.5.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/defs/field-schema.schema.ts +75 -0
- package/package.json +3 -3
- package/schema.json +103 -2
- package/schemas/document.paths.schema.json +54 -1
- package/src/guards.ts +6 -1
- package/src/project-schemas.ts +358 -12
- package/src/schema.ts +29 -5
|
@@ -56,3 +56,78 @@ export const relationshipRefSchema = {
|
|
|
56
56
|
required: ["$ref"],
|
|
57
57
|
type: "object",
|
|
58
58
|
} as const;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The CORE `$paths` source shapes — the members the shipped default paths union carries, and the
|
|
62
|
+
* ones every per-project entry document unions extension shapes on top of.
|
|
63
|
+
*
|
|
64
|
+
* These mirror `resolvePathEntries` in the compiler's pages-discovery exactly (specs/
|
|
65
|
+
* site-architecture.md §4.3): an explicit value list, a JSON data-file reference, and the legacy
|
|
66
|
+
* bare array of parameter objects. Anything else is dispatched by discriminator key to an
|
|
67
|
+
* extension's `resolvePaths` capability, so extension shapes reach the union through the entry
|
|
68
|
+
* document rather than from here.
|
|
69
|
+
*
|
|
70
|
+
* Each shape is CLOSED (`additionalProperties: false`). `$paths` is a build-time instruction with a
|
|
71
|
+
* fully specified grammar and no forward-compatible extension points of its own — a stray or
|
|
72
|
+
* misspelled key means the route silently expands to nothing, which is exactly the failure worth
|
|
73
|
+
* catching in the editor.
|
|
74
|
+
*/
|
|
75
|
+
export const documentPathsCoreMembers = [
|
|
76
|
+
{
|
|
77
|
+
additionalProperties: false,
|
|
78
|
+
description:
|
|
79
|
+
'Explicit value list: generates one page per value, bound to `param` (default "value").',
|
|
80
|
+
properties: {
|
|
81
|
+
param: { description: 'Route parameter to fill (default "value").', type: "string" },
|
|
82
|
+
values: { description: "The values to expand.", type: "array" },
|
|
83
|
+
},
|
|
84
|
+
required: ["values"],
|
|
85
|
+
type: "object",
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
additionalProperties: false,
|
|
89
|
+
description:
|
|
90
|
+
"JSON data-file source: loads a project-relative file that must contain an array, and " +
|
|
91
|
+
"generates one page per item.",
|
|
92
|
+
properties: {
|
|
93
|
+
$ref: {
|
|
94
|
+
description: "Project-relative path to a .json file containing an array.",
|
|
95
|
+
type: "string",
|
|
96
|
+
},
|
|
97
|
+
field: {
|
|
98
|
+
description: 'Item field providing the parameter value (default "id").',
|
|
99
|
+
type: "string",
|
|
100
|
+
},
|
|
101
|
+
param: { description: 'Route parameter to fill (default "id").', type: "string" },
|
|
102
|
+
},
|
|
103
|
+
required: ["$ref"],
|
|
104
|
+
type: "object",
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
description:
|
|
108
|
+
"Legacy explicit form: an array of ready-made parameter objects, used as-is with no " +
|
|
109
|
+
"expansion. Prefer one of the source shapes above.",
|
|
110
|
+
items: { additionalProperties: { type: "string" }, type: "object" },
|
|
111
|
+
type: "array",
|
|
112
|
+
},
|
|
113
|
+
] as const;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The extra member the SHIPPED DEFAULT union carries and a per-project entry document does not.
|
|
117
|
+
*
|
|
118
|
+
* A default cannot know which extensions a project enables, so without this any extension source
|
|
119
|
+
* shape — `{ contentType: "blog" }` with the parser on — would be a FALSE ERROR wherever the
|
|
120
|
+
* default is what is in play: the studio's offline fallback before per-project schemas arrive, and
|
|
121
|
+
* clients that fetch the canonical URL. Spec §5.4 promises that path degrades to
|
|
122
|
+
* _under-suggestion_, never false errors, so the default accepts any non-empty object and the entry
|
|
123
|
+
* document — which does know the enabled extensions, and is what `jx validate` and every editor
|
|
124
|
+
* actually resolve — is the strict one.
|
|
125
|
+
*/
|
|
126
|
+
export const documentPathsUnknownSourceMember = {
|
|
127
|
+
description:
|
|
128
|
+
"A source shape contributed by an extension this schema cannot see. The project's generated " +
|
|
129
|
+
"document.schema.json validates these exactly; enable the owning extension and regenerate " +
|
|
130
|
+
"with `jx schema` to get precise checking here.",
|
|
131
|
+
minProperties: 1,
|
|
132
|
+
type: "object",
|
|
133
|
+
} as const;
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"dependencies": {
|
|
3
|
-
"@webref/css": "^8.7.
|
|
3
|
+
"@webref/css": "^8.7.1",
|
|
4
4
|
"@webref/elements": "^2.7.1",
|
|
5
|
-
"@webref/idl": "^3.82.
|
|
5
|
+
"@webref/idl": "^3.82.1",
|
|
6
6
|
"ajv": "^8.20.0",
|
|
7
7
|
"ajv-formats": "^3.0.1"
|
|
8
8
|
},
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"upgrade": "bunx npm-check-updates -u && bun install"
|
|
51
51
|
},
|
|
52
52
|
"type": "module",
|
|
53
|
-
"version": "1.
|
|
53
|
+
"version": "1.5.0",
|
|
54
54
|
"devDependencies": {
|
|
55
55
|
"json-schema-to-ts": "^3.1.1"
|
|
56
56
|
}
|
package/schema.json
CHANGED
|
@@ -7719,6 +7719,26 @@
|
|
|
7719
7719
|
}
|
|
7720
7720
|
]
|
|
7721
7721
|
},
|
|
7722
|
+
"animation-delay-end": {
|
|
7723
|
+
"oneOf": [
|
|
7724
|
+
{
|
|
7725
|
+
"type": "string"
|
|
7726
|
+
},
|
|
7727
|
+
{
|
|
7728
|
+
"type": "number"
|
|
7729
|
+
}
|
|
7730
|
+
]
|
|
7731
|
+
},
|
|
7732
|
+
"animation-delay-start": {
|
|
7733
|
+
"oneOf": [
|
|
7734
|
+
{
|
|
7735
|
+
"type": "string"
|
|
7736
|
+
},
|
|
7737
|
+
{
|
|
7738
|
+
"type": "number"
|
|
7739
|
+
}
|
|
7740
|
+
]
|
|
7741
|
+
},
|
|
7722
7742
|
"animation-direction": {
|
|
7723
7743
|
"oneOf": [
|
|
7724
7744
|
{
|
|
@@ -7869,6 +7889,26 @@
|
|
|
7869
7889
|
}
|
|
7870
7890
|
]
|
|
7871
7891
|
},
|
|
7892
|
+
"animationDelayEnd": {
|
|
7893
|
+
"oneOf": [
|
|
7894
|
+
{
|
|
7895
|
+
"type": "string"
|
|
7896
|
+
},
|
|
7897
|
+
{
|
|
7898
|
+
"type": "number"
|
|
7899
|
+
}
|
|
7900
|
+
]
|
|
7901
|
+
},
|
|
7902
|
+
"animationDelayStart": {
|
|
7903
|
+
"oneOf": [
|
|
7904
|
+
{
|
|
7905
|
+
"type": "string"
|
|
7906
|
+
},
|
|
7907
|
+
{
|
|
7908
|
+
"type": "number"
|
|
7909
|
+
}
|
|
7910
|
+
]
|
|
7911
|
+
},
|
|
7872
7912
|
"animationDirection": {
|
|
7873
7913
|
"oneOf": [
|
|
7874
7914
|
{
|
|
@@ -22996,6 +23036,67 @@
|
|
|
22996
23036
|
}
|
|
22997
23037
|
},
|
|
22998
23038
|
"type": "object"
|
|
23039
|
+
},
|
|
23040
|
+
"PathsValue": {
|
|
23041
|
+
"$id": "https://jxsuite.com/schema/document/paths/v2",
|
|
23042
|
+
"anyOf": [
|
|
23043
|
+
{
|
|
23044
|
+
"additionalProperties": false,
|
|
23045
|
+
"description": "Explicit value list: generates one page per value, bound to `param` (default \"value\").",
|
|
23046
|
+
"properties": {
|
|
23047
|
+
"param": {
|
|
23048
|
+
"description": "Route parameter to fill (default \"value\").",
|
|
23049
|
+
"type": "string"
|
|
23050
|
+
},
|
|
23051
|
+
"values": {
|
|
23052
|
+
"description": "The values to expand.",
|
|
23053
|
+
"type": "array"
|
|
23054
|
+
}
|
|
23055
|
+
},
|
|
23056
|
+
"required": [
|
|
23057
|
+
"values"
|
|
23058
|
+
],
|
|
23059
|
+
"type": "object"
|
|
23060
|
+
},
|
|
23061
|
+
{
|
|
23062
|
+
"additionalProperties": false,
|
|
23063
|
+
"description": "JSON data-file source: loads a project-relative file that must contain an array, and generates one page per item.",
|
|
23064
|
+
"properties": {
|
|
23065
|
+
"$ref": {
|
|
23066
|
+
"description": "Project-relative path to a .json file containing an array.",
|
|
23067
|
+
"type": "string"
|
|
23068
|
+
},
|
|
23069
|
+
"field": {
|
|
23070
|
+
"description": "Item field providing the parameter value (default \"id\").",
|
|
23071
|
+
"type": "string"
|
|
23072
|
+
},
|
|
23073
|
+
"param": {
|
|
23074
|
+
"description": "Route parameter to fill (default \"id\").",
|
|
23075
|
+
"type": "string"
|
|
23076
|
+
}
|
|
23077
|
+
},
|
|
23078
|
+
"required": [
|
|
23079
|
+
"$ref"
|
|
23080
|
+
],
|
|
23081
|
+
"type": "object"
|
|
23082
|
+
},
|
|
23083
|
+
{
|
|
23084
|
+
"description": "Legacy explicit form: an array of ready-made parameter objects, used as-is with no expansion. Prefer one of the source shapes above.",
|
|
23085
|
+
"items": {
|
|
23086
|
+
"additionalProperties": {
|
|
23087
|
+
"type": "string"
|
|
23088
|
+
},
|
|
23089
|
+
"type": "object"
|
|
23090
|
+
},
|
|
23091
|
+
"type": "array"
|
|
23092
|
+
},
|
|
23093
|
+
{
|
|
23094
|
+
"description": "A source shape contributed by an extension this schema cannot see. The project's generated document.schema.json validates these exactly; enable the owning extension and regenerate with `jx schema` to get precise checking here.",
|
|
23095
|
+
"minProperties": 1,
|
|
23096
|
+
"type": "object"
|
|
23097
|
+
}
|
|
23098
|
+
],
|
|
23099
|
+
"description": "Default $paths source union: the core shapes, plus a catch-all for source shapes contributed by extensions this schema cannot see. The project's generated document.schema.json replaces this with the exact set its extensions provide."
|
|
22999
23100
|
}
|
|
23000
23101
|
},
|
|
23001
23102
|
"$id": "https://jxsuite.com/schema/v1",
|
|
@@ -23079,8 +23180,8 @@
|
|
|
23079
23180
|
"type": "object"
|
|
23080
23181
|
},
|
|
23081
23182
|
"$paths": {
|
|
23082
|
-
"
|
|
23083
|
-
"
|
|
23183
|
+
"$ref": "https://jxsuite.com/schema/document/paths/v2",
|
|
23184
|
+
"description": "Dynamic route source for a page: expands one page per entry at build time. One of the core shapes (explicit `values`, a `$ref` data file, or a bare array of parameter objects) or a source shape contributed by an enabled extension, such as the parser's `contentType`."
|
|
23084
23185
|
},
|
|
23085
23186
|
"$schema": {
|
|
23086
23187
|
"description": "URI identifying the Jx dialect version. Enables schema-aware IDE tooling.",
|
|
@@ -1,6 +1,59 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$id": "https://jxsuite.com/schema/document/paths/v2",
|
|
3
3
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
4
|
-
"
|
|
4
|
+
"anyOf": [
|
|
5
|
+
{
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"description": "Explicit value list: generates one page per value, bound to `param` (default \"value\").",
|
|
8
|
+
"properties": {
|
|
9
|
+
"param": {
|
|
10
|
+
"description": "Route parameter to fill (default \"value\").",
|
|
11
|
+
"type": "string"
|
|
12
|
+
},
|
|
13
|
+
"values": {
|
|
14
|
+
"description": "The values to expand.",
|
|
15
|
+
"type": "array"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"required": ["values"],
|
|
19
|
+
"type": "object"
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"additionalProperties": false,
|
|
23
|
+
"description": "JSON data-file source: loads a project-relative file that must contain an array, and generates one page per item.",
|
|
24
|
+
"properties": {
|
|
25
|
+
"$ref": {
|
|
26
|
+
"description": "Project-relative path to a .json file containing an array.",
|
|
27
|
+
"type": "string"
|
|
28
|
+
},
|
|
29
|
+
"field": {
|
|
30
|
+
"description": "Item field providing the parameter value (default \"id\").",
|
|
31
|
+
"type": "string"
|
|
32
|
+
},
|
|
33
|
+
"param": {
|
|
34
|
+
"description": "Route parameter to fill (default \"id\").",
|
|
35
|
+
"type": "string"
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"required": ["$ref"],
|
|
39
|
+
"type": "object"
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"description": "Legacy explicit form: an array of ready-made parameter objects, used as-is with no expansion. Prefer one of the source shapes above.",
|
|
43
|
+
"items": {
|
|
44
|
+
"additionalProperties": {
|
|
45
|
+
"type": "string"
|
|
46
|
+
},
|
|
47
|
+
"type": "object"
|
|
48
|
+
},
|
|
49
|
+
"type": "array"
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"description": "A source shape contributed by an extension this schema cannot see. The project's generated document.schema.json validates these exactly; enable the owning extension and regenerate with `jx schema` to get precise checking here.",
|
|
53
|
+
"minProperties": 1,
|
|
54
|
+
"type": "object"
|
|
55
|
+
}
|
|
56
|
+
],
|
|
57
|
+
"description": "Default $paths-value union for documents: the core source shapes. Per-project entry documents override this resource with those members PLUS every extension-contributed paths shape.",
|
|
5
58
|
"title": "Jx Document Paths Union"
|
|
6
59
|
}
|
package/src/guards.ts
CHANGED
|
@@ -179,9 +179,14 @@ export function isTemplateString(value?: unknown): value is string {
|
|
|
179
179
|
* "Function"` as a computed value vs an event handler. Uses a word boundary so identifiers like
|
|
180
180
|
* `returned` do not match. Shared by the runtime interpreter and the compiler so both agree on the
|
|
181
181
|
* classification.
|
|
182
|
+
*
|
|
183
|
+
* A bare `return;` does not count. It is an early-exit guard, which is handler code — reading it as
|
|
184
|
+
* a value return turned any handler with a guard clause into a `computed()`, so the binding that
|
|
185
|
+
* invoked it found a value where it expected a function. The value has to follow on the same line,
|
|
186
|
+
* because a newline after `return` is an ASI bare return.
|
|
182
187
|
*/
|
|
183
188
|
export function bodyReturnsValue(body: string): boolean {
|
|
184
|
-
return /\breturn\b/.test(body);
|
|
189
|
+
return /\breturn\b[^\S\n]*(?![;}\s]|$)/.test(body);
|
|
185
190
|
}
|
|
186
191
|
|
|
187
192
|
// ─── Style guards & accessors ───────────────────────────────────────────────────
|
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
|
@@ -102,6 +102,8 @@ import { extensionManifestSchema } from "../defs/extension-manifest.schema";
|
|
|
102
102
|
import {
|
|
103
103
|
DOCUMENT_PATHS_SCHEMA_ID,
|
|
104
104
|
PROJECT_FIELDS_SCHEMA_ID,
|
|
105
|
+
documentPathsCoreMembers,
|
|
106
|
+
documentPathsUnknownSourceMember,
|
|
105
107
|
jxFieldSchemaDef,
|
|
106
108
|
relationshipRefSchema,
|
|
107
109
|
} from "../defs/field-schema.schema";
|
|
@@ -288,6 +290,19 @@ export async function generateSchema() {
|
|
|
288
290
|
JsonSchemaType: jsonSchemaTypeSchema,
|
|
289
291
|
HeadEntry: headEntrySchema,
|
|
290
292
|
ImageConfig: imageConfigSchema,
|
|
293
|
+
/* The shipped DEFAULT paths union, embedded under its canonical $id so `$paths`'s ref
|
|
294
|
+
resolves with this file alone (it is registered standalone as the studio's offline
|
|
295
|
+
fallback). A per-project entry document embeds the same $id carrying core members PLUS
|
|
296
|
+
extension shapes; that embed is declared first, so it wins resolution and this one is
|
|
297
|
+
inert there (extensions.md §5.3). */
|
|
298
|
+
PathsValue: {
|
|
299
|
+
$id: DOCUMENT_PATHS_SCHEMA_ID,
|
|
300
|
+
anyOf: [...documentPathsCoreMembers, documentPathsUnknownSourceMember],
|
|
301
|
+
description:
|
|
302
|
+
"Default $paths source union: the core shapes, plus a catch-all for source shapes " +
|
|
303
|
+
"contributed by extensions this schema cannot see. The project's generated " +
|
|
304
|
+
"document.schema.json replaces this with the exact set its extensions provide.",
|
|
305
|
+
},
|
|
291
306
|
},
|
|
292
307
|
$id: "https://jxsuite.com/schema/v1",
|
|
293
308
|
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
@@ -362,10 +377,17 @@ export async function generateSchema() {
|
|
|
362
377
|
type: "object",
|
|
363
378
|
},
|
|
364
379
|
$paths: {
|
|
380
|
+
/* Was `type: "object"`, which accepted any object at all — a misspelled key or a
|
|
381
|
+
content-type source in a project without the parser enabled passed validation and then
|
|
382
|
+
silently expanded to zero pages. Referencing the union resource by its canonical $id is
|
|
383
|
+
what makes the per-project entry document's effective union apply (extensions.md §5.3);
|
|
384
|
+
`$defs.PathsValue` below carries the shipped default so this file still resolves alone. */
|
|
385
|
+
$ref: DOCUMENT_PATHS_SCHEMA_ID,
|
|
365
386
|
description:
|
|
366
|
-
"Dynamic route
|
|
367
|
-
"
|
|
368
|
-
|
|
387
|
+
"Dynamic route source for a page: expands one page per entry at build time. One of the " +
|
|
388
|
+
"core shapes (explicit `values`, a `$ref` data file, or a bare array of parameter " +
|
|
389
|
+
"objects) or a source shape contributed by an enabled extension, such as the parser's " +
|
|
390
|
+
"`contentType`.",
|
|
369
391
|
},
|
|
370
392
|
$schema: {
|
|
371
393
|
description: "URI identifying the Jx dialect version. Enables schema-aware IDE tooling.",
|
|
@@ -499,9 +521,11 @@ export function generateDocumentPathsSchema() {
|
|
|
499
521
|
return {
|
|
500
522
|
$id: DOCUMENT_PATHS_SCHEMA_ID,
|
|
501
523
|
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
524
|
+
anyOf: [...documentPathsCoreMembers, documentPathsUnknownSourceMember],
|
|
502
525
|
description:
|
|
503
|
-
"Default $paths-value union for documents. Per-project entry
|
|
504
|
-
"resource with
|
|
526
|
+
"Default $paths-value union for documents: the core source shapes. Per-project entry " +
|
|
527
|
+
"documents override this resource with those members PLUS every extension-contributed " +
|
|
528
|
+
"paths shape.",
|
|
505
529
|
title: "Jx Document Paths Union",
|
|
506
530
|
};
|
|
507
531
|
}
|