@texuli/spec 0.0.0-stage → 0.1.0-next.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/LICENSE +21 -0
- package/README.md +97 -2
- package/dist/build.d.ts +47 -0
- package/dist/build.js +65 -0
- package/dist/canonical.d.ts +17 -0
- package/dist/canonical.js +131 -0
- package/dist/catalog.d.ts +41 -0
- package/dist/catalog.js +45 -0
- package/dist/codegen/index.d.ts +13 -0
- package/dist/codegen/index.js +12 -0
- package/dist/codegen/kotlin.d.ts +18 -0
- package/dist/codegen/kotlin.js +328 -0
- package/dist/codegen/shared.d.ts +35 -0
- package/dist/codegen/shared.js +69 -0
- package/dist/codegen/typescript.d.ts +10 -0
- package/dist/codegen/typescript.js +167 -0
- package/dist/conformance.d.ts +20 -0
- package/dist/conformance.js +124 -0
- package/dist/dialect.d.ts +56 -0
- package/dist/dialect.js +264 -0
- package/dist/format.d.ts +9 -0
- package/dist/format.js +48 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +13 -0
- package/dist/validate.d.ts +25 -0
- package/dist/validate.js +118 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +2 -0
- package/generated/typescript/blueprint.d.ts +83 -0
- package/generated/typescript/blueprint.js +3 -0
- package/generated/typescript/catalog-schema.d.ts +33 -0
- package/generated/typescript/catalog-schema.js +3 -0
- package/generated/typescript/diagnostics.d.ts +12 -0
- package/generated/typescript/diagnostics.js +3 -0
- package/generated/typescript/manifest.d.ts +9 -0
- package/generated/typescript/manifest.js +3 -0
- package/generated/typescript/vocabulary.d.ts +943 -0
- package/generated/typescript/vocabulary.js +138 -0
- package/package.json +26 -4
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { buildSpec } from "./build.js";
|
|
2
|
+
import { CAPABILITIES } from "./catalog.js";
|
|
3
|
+
import { checkSchemaDocument, isRecord, validateInstance } from "./dialect.js";
|
|
4
|
+
import { validateBlueprint } from "./validate.js";
|
|
5
|
+
/**
|
|
6
|
+
* Run the spec's conformance harness (spec/Compose design §7.1 and §7.3)
|
|
7
|
+
* over the normative files and the committed outputs (`manifest.json`,
|
|
8
|
+
* `generated-manifest.json` and every generated file):
|
|
9
|
+
* - every schema stays in the dialect;
|
|
10
|
+
* - `catalog/components.json` and `catalog/capabilities.json` are valid;
|
|
11
|
+
* - the manifest digests match the normative files;
|
|
12
|
+
* - regenerating gives byte-identical TypeScript and Kotlin, with no file
|
|
13
|
+
* missing or extra;
|
|
14
|
+
* - every golden blueprint validates, and every invalid fixture fails with
|
|
15
|
+
* exactly its expected diagnostic codes.
|
|
16
|
+
* Runtime-neutral: the caller reads the files.
|
|
17
|
+
*/
|
|
18
|
+
export function checkConformance(normative, committed) {
|
|
19
|
+
const issues = [];
|
|
20
|
+
const json = (path) => JSON.parse(normative[path] ?? 'null');
|
|
21
|
+
const schemas = {};
|
|
22
|
+
for (const path of Object.keys(normative).filter((p) => p.startsWith('schemas/'))) {
|
|
23
|
+
const schema = json(path);
|
|
24
|
+
schemas[path] = schema;
|
|
25
|
+
for (const issue of checkSchemaDocument(schema)) {
|
|
26
|
+
issues.push({
|
|
27
|
+
check: 'dialect',
|
|
28
|
+
path,
|
|
29
|
+
message: `${issue.path} ${issue.code}: ${issue.message}`,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const catalogSchema = schemas['schemas/catalog.schema.json'];
|
|
34
|
+
const blueprintSchema = schemas['schemas/blueprint.schema.json'];
|
|
35
|
+
const catalog = json('catalog/components.json');
|
|
36
|
+
if (catalogSchema) {
|
|
37
|
+
for (const issue of validateInstance(catalogSchema, catalog)) {
|
|
38
|
+
issues.push({
|
|
39
|
+
check: 'catalog',
|
|
40
|
+
path: 'catalog/components.json',
|
|
41
|
+
message: `${issue.path} ${issue.code}`,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const ids = new Set((catalog?.components ?? []).map((c) => c.id));
|
|
46
|
+
const capabilities = json('catalog/capabilities.json');
|
|
47
|
+
if (!isRecord(capabilities)) {
|
|
48
|
+
issues.push({
|
|
49
|
+
check: 'capabilities',
|
|
50
|
+
path: 'catalog/capabilities.json',
|
|
51
|
+
message: 'must be an object',
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
for (const [id, value] of Object.entries(capabilities)) {
|
|
56
|
+
if (!ids.has(id) || !CAPABILITIES.includes(value)) {
|
|
57
|
+
issues.push({
|
|
58
|
+
check: 'capabilities',
|
|
59
|
+
path: 'catalog/capabilities.json',
|
|
60
|
+
message: `${id}: an audited capability needs a catalog id and a known tier`,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
let build;
|
|
66
|
+
try {
|
|
67
|
+
build = buildSpec(normative);
|
|
68
|
+
}
|
|
69
|
+
catch (cause) {
|
|
70
|
+
issues.push({ check: 'generated', path: '', message: cause.message });
|
|
71
|
+
}
|
|
72
|
+
if (build) {
|
|
73
|
+
if (committed['manifest.json'] !== build.manifest) {
|
|
74
|
+
issues.push({
|
|
75
|
+
check: 'manifest',
|
|
76
|
+
path: 'manifest.json',
|
|
77
|
+
message: 'digests do not match the normative files',
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
if (committed['generated-manifest.json'] !== build.generatedManifest) {
|
|
81
|
+
issues.push({
|
|
82
|
+
check: 'generated',
|
|
83
|
+
path: 'generated-manifest.json',
|
|
84
|
+
message: 'does not match a fresh generation',
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
const expected = new Set(Object.keys(build.generated));
|
|
88
|
+
for (const [path, text] of Object.entries(build.generated)) {
|
|
89
|
+
if (committed[path] !== text) {
|
|
90
|
+
issues.push({
|
|
91
|
+
check: 'generated',
|
|
92
|
+
path,
|
|
93
|
+
message: committed[path] === undefined ? 'missing' : 'differs from a fresh generation',
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
for (const path of Object.keys(committed)) {
|
|
98
|
+
if (path.startsWith('generated/') && !expected.has(path)) {
|
|
99
|
+
issues.push({ check: 'generated', path, message: 'no generator produces this file' });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (blueprintSchema) {
|
|
104
|
+
const documents = { blueprintSchema, catalog };
|
|
105
|
+
for (const path of Object.keys(normative).filter((p) => p.startsWith('fixtures/golden/'))) {
|
|
106
|
+
const result = validateBlueprint(json(path), documents);
|
|
107
|
+
for (const d of result.diagnostics) {
|
|
108
|
+
issues.push({ check: 'golden', path, message: `${d.path} ${d.code}: ${d.message}` });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
for (const path of Object.keys(normative).filter((p) => p.startsWith('fixtures/invalid/'))) {
|
|
112
|
+
const fixture = json(path);
|
|
113
|
+
const got = validateBlueprint(fixture.blueprint, documents, fixture.options).diagnostics.map((d) => d.code);
|
|
114
|
+
if (got.join(',') !== fixture.expect.join(',')) {
|
|
115
|
+
issues.push({
|
|
116
|
+
check: 'invalid',
|
|
117
|
+
path,
|
|
118
|
+
message: `expected [${fixture.expect.join(', ')}], got [${got.join(', ')}]`,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return issues;
|
|
124
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pinned, closed schema dialect: a small subset of JSON Schema 2020-12
|
|
3
|
+
* that both the TypeScript and Kotlin generators can project exactly. A
|
|
4
|
+
* schema that uses anything else fails `checkSchemaDocument`, and code
|
|
5
|
+
* generation refuses it (spec/Compose design §2.3: unsupported constructs
|
|
6
|
+
* fail generation). Schemas are read as data; nothing is evaluated.
|
|
7
|
+
*/
|
|
8
|
+
export declare const DIALECT_URI = "https://json-schema.org/draft/2020-12/schema";
|
|
9
|
+
/** Every keyword the dialect understands. */
|
|
10
|
+
export declare const DIALECT_KEYWORDS: readonly ["$schema", "$id", "$comment", "$defs", "$ref", "title", "description", "type", "properties", "required", "additionalProperties", "items", "enum", "const", "minLength", "maxLength", "pattern", "minimum", "maximum", "minItems", "maxItems"];
|
|
11
|
+
export declare const DIALECT_TYPES: readonly ["object", "array", "string", "number", "integer", "boolean"];
|
|
12
|
+
export type DialectType = (typeof DIALECT_TYPES)[number];
|
|
13
|
+
/** A schema node in the dialect. `{}` (no keywords) accepts any JSON value. */
|
|
14
|
+
export interface Schema {
|
|
15
|
+
$schema?: string;
|
|
16
|
+
$id?: string;
|
|
17
|
+
$comment?: string;
|
|
18
|
+
$defs?: Readonly<Record<string, Schema>>;
|
|
19
|
+
$ref?: string;
|
|
20
|
+
title?: string;
|
|
21
|
+
description?: string;
|
|
22
|
+
type?: DialectType;
|
|
23
|
+
properties?: Readonly<Record<string, Schema>>;
|
|
24
|
+
required?: readonly string[];
|
|
25
|
+
/** `false` for a closed object, or a schema for map values. */
|
|
26
|
+
additionalProperties?: false | Schema;
|
|
27
|
+
items?: Schema;
|
|
28
|
+
enum?: readonly (string | number | boolean)[];
|
|
29
|
+
const?: string | number | boolean;
|
|
30
|
+
minLength?: number;
|
|
31
|
+
maxLength?: number;
|
|
32
|
+
pattern?: string;
|
|
33
|
+
minimum?: number;
|
|
34
|
+
maximum?: number;
|
|
35
|
+
minItems?: number;
|
|
36
|
+
maxItems?: number;
|
|
37
|
+
}
|
|
38
|
+
export interface SchemaIssue {
|
|
39
|
+
code: string;
|
|
40
|
+
path: string;
|
|
41
|
+
message: string;
|
|
42
|
+
}
|
|
43
|
+
/** The `$defs` name a `$ref` points at. */
|
|
44
|
+
export declare function refName(ref: string): string | undefined;
|
|
45
|
+
/**
|
|
46
|
+
* Check that a schema document stays inside the dialect: known keywords only,
|
|
47
|
+
* local `$ref`s that resolve, a single `type`, and every object either closed
|
|
48
|
+
* (`additionalProperties: false`) or an explicit map. Fails closed.
|
|
49
|
+
*/
|
|
50
|
+
export declare function checkSchemaDocument(document: unknown): SchemaIssue[];
|
|
51
|
+
/**
|
|
52
|
+
* Validate a JSON value against a dialect schema. `root` resolves `$ref`s
|
|
53
|
+
* (defaults to `schema`). Returns every issue, with JSON paths from `$`.
|
|
54
|
+
*/
|
|
55
|
+
export declare function validateInstance(schema: Schema, value: unknown, root?: Schema): SchemaIssue[];
|
|
56
|
+
export declare function isRecord(value: unknown): value is Record<string, unknown>;
|
package/dist/dialect.js
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pinned, closed schema dialect: a small subset of JSON Schema 2020-12
|
|
3
|
+
* that both the TypeScript and Kotlin generators can project exactly. A
|
|
4
|
+
* schema that uses anything else fails `checkSchemaDocument`, and code
|
|
5
|
+
* generation refuses it (spec/Compose design §2.3: unsupported constructs
|
|
6
|
+
* fail generation). Schemas are read as data; nothing is evaluated.
|
|
7
|
+
*/
|
|
8
|
+
export const DIALECT_URI = 'https://json-schema.org/draft/2020-12/schema';
|
|
9
|
+
/** Every keyword the dialect understands. */
|
|
10
|
+
export const DIALECT_KEYWORDS = [
|
|
11
|
+
'$schema',
|
|
12
|
+
'$id',
|
|
13
|
+
'$comment',
|
|
14
|
+
'$defs',
|
|
15
|
+
'$ref',
|
|
16
|
+
'title',
|
|
17
|
+
'description',
|
|
18
|
+
'type',
|
|
19
|
+
'properties',
|
|
20
|
+
'required',
|
|
21
|
+
'additionalProperties',
|
|
22
|
+
'items',
|
|
23
|
+
'enum',
|
|
24
|
+
'const',
|
|
25
|
+
'minLength',
|
|
26
|
+
'maxLength',
|
|
27
|
+
'pattern',
|
|
28
|
+
'minimum',
|
|
29
|
+
'maximum',
|
|
30
|
+
'minItems',
|
|
31
|
+
'maxItems',
|
|
32
|
+
];
|
|
33
|
+
export const DIALECT_TYPES = ['object', 'array', 'string', 'number', 'integer', 'boolean'];
|
|
34
|
+
const REF = /^#\/\$defs\/([A-Za-z][A-Za-z0-9]*)$/;
|
|
35
|
+
const MAX_DEPTH = 64;
|
|
36
|
+
/** The `$defs` name a `$ref` points at. */
|
|
37
|
+
export function refName(ref) {
|
|
38
|
+
return REF.exec(ref)?.[1];
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Check that a schema document stays inside the dialect: known keywords only,
|
|
42
|
+
* local `$ref`s that resolve, a single `type`, and every object either closed
|
|
43
|
+
* (`additionalProperties: false`) or an explicit map. Fails closed.
|
|
44
|
+
*/
|
|
45
|
+
export function checkSchemaDocument(document) {
|
|
46
|
+
const issues = [];
|
|
47
|
+
if (!isRecord(document)) {
|
|
48
|
+
return [{ code: 'not-a-schema', path: '$', message: 'A schema must be an object.' }];
|
|
49
|
+
}
|
|
50
|
+
if (document.$schema !== DIALECT_URI) {
|
|
51
|
+
issues.push({
|
|
52
|
+
code: 'dialect',
|
|
53
|
+
path: '$.$schema',
|
|
54
|
+
message: `"$schema" must be ${DIALECT_URI}.`,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
const defs = isRecord(document.$defs) ? document.$defs : {};
|
|
58
|
+
const visit = (node, path, depth) => {
|
|
59
|
+
if (depth > MAX_DEPTH) {
|
|
60
|
+
issues.push({ code: 'too-deep', path, message: 'The schema nests too deeply.' });
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (!isRecord(node)) {
|
|
64
|
+
issues.push({ code: 'not-a-schema', path, message: 'A schema node must be an object.' });
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
for (const key of Object.keys(node)) {
|
|
68
|
+
if (!DIALECT_KEYWORDS.includes(key)) {
|
|
69
|
+
issues.push({
|
|
70
|
+
code: 'unsupported-keyword',
|
|
71
|
+
path: `${path}.${key}`,
|
|
72
|
+
message: `"${key}" is not in the dialect.`,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (node.$ref !== undefined) {
|
|
77
|
+
const name = typeof node.$ref === 'string' ? refName(node.$ref) : undefined;
|
|
78
|
+
if (name === undefined || !Object.hasOwn(defs, name)) {
|
|
79
|
+
issues.push({
|
|
80
|
+
code: 'bad-ref',
|
|
81
|
+
path: `${path}.$ref`,
|
|
82
|
+
message: 'A $ref must name a local $defs entry.',
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (node.type !== undefined && !DIALECT_TYPES.includes(node.type)) {
|
|
87
|
+
issues.push({
|
|
88
|
+
code: 'bad-type',
|
|
89
|
+
path: `${path}.type`,
|
|
90
|
+
message: '"type" must be one dialect type.',
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
if (node.type === 'object') {
|
|
94
|
+
if (node.additionalProperties === undefined || node.additionalProperties === true) {
|
|
95
|
+
issues.push({
|
|
96
|
+
code: 'open-object',
|
|
97
|
+
path,
|
|
98
|
+
message: 'Objects must set "additionalProperties": false or a map schema.',
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
if (Array.isArray(node.required) && isRecord(node.properties)) {
|
|
102
|
+
for (const name of node.required) {
|
|
103
|
+
if (typeof name !== 'string' || !Object.hasOwn(node.properties, name)) {
|
|
104
|
+
issues.push({
|
|
105
|
+
code: 'bad-required',
|
|
106
|
+
path: `${path}.required`,
|
|
107
|
+
message: `Required "${String(name)}" is not a declared property.`,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (node.enum !== undefined) {
|
|
114
|
+
const values = node.enum;
|
|
115
|
+
const valid = Array.isArray(values) &&
|
|
116
|
+
values.length > 0 &&
|
|
117
|
+
values.every((v) => typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean');
|
|
118
|
+
if (!valid) {
|
|
119
|
+
issues.push({
|
|
120
|
+
code: 'bad-enum',
|
|
121
|
+
path: `${path}.enum`,
|
|
122
|
+
message: '"enum" must list scalars.',
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (isRecord(node.properties)) {
|
|
127
|
+
for (const [name, child] of Object.entries(node.properties)) {
|
|
128
|
+
visit(child, `${path}.properties.${name}`, depth + 1);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (isRecord(node.additionalProperties)) {
|
|
132
|
+
visit(node.additionalProperties, `${path}.additionalProperties`, depth + 1);
|
|
133
|
+
}
|
|
134
|
+
if (node.items !== undefined)
|
|
135
|
+
visit(node.items, `${path}.items`, depth + 1);
|
|
136
|
+
if (isRecord(node.$defs)) {
|
|
137
|
+
for (const [name, child] of Object.entries(node.$defs)) {
|
|
138
|
+
if (!/^[A-Za-z][A-Za-z0-9]*$/.test(name)) {
|
|
139
|
+
issues.push({
|
|
140
|
+
code: 'bad-def-name',
|
|
141
|
+
path: `${path}.$defs.${name}`,
|
|
142
|
+
message: 'Def names are alphanumeric.',
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
visit(child, `${path}.$defs.${name}`, depth + 1);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
visit(document, '$', 0);
|
|
150
|
+
return issues;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Validate a JSON value against a dialect schema. `root` resolves `$ref`s
|
|
154
|
+
* (defaults to `schema`). Returns every issue, with JSON paths from `$`.
|
|
155
|
+
*/
|
|
156
|
+
export function validateInstance(schema, value, root = schema) {
|
|
157
|
+
const issues = [];
|
|
158
|
+
const fail = (code, path, message) => {
|
|
159
|
+
issues.push({ code, path, message });
|
|
160
|
+
};
|
|
161
|
+
const visit = (node, current, path, depth) => {
|
|
162
|
+
if (depth > MAX_DEPTH) {
|
|
163
|
+
fail('too-deep', path, 'The value nests too deeply.');
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (node.$ref !== undefined) {
|
|
167
|
+
const name = refName(node.$ref);
|
|
168
|
+
const target = name === undefined ? undefined : root.$defs?.[name];
|
|
169
|
+
if (target === undefined)
|
|
170
|
+
fail('bad-ref', path, `Unresolved ${node.$ref}.`);
|
|
171
|
+
else
|
|
172
|
+
visit(target, current, path, depth + 1);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (node.const !== undefined && current !== node.const) {
|
|
176
|
+
fail('const', path, `Must be ${JSON.stringify(node.const)}.`);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (node.enum !== undefined && !node.enum.includes(current)) {
|
|
180
|
+
fail('enum', path, `Must be one of ${node.enum.map((v) => JSON.stringify(v)).join(', ')}.`);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
switch (node.type) {
|
|
184
|
+
case undefined:
|
|
185
|
+
return;
|
|
186
|
+
case 'string':
|
|
187
|
+
if (typeof current !== 'string') {
|
|
188
|
+
fail('type', path, 'Must be a string.');
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (node.minLength !== undefined && [...current].length < node.minLength)
|
|
192
|
+
fail('min-length', path, `At least ${node.minLength} characters.`);
|
|
193
|
+
if (node.maxLength !== undefined && [...current].length > node.maxLength)
|
|
194
|
+
fail('max-length', path, `At most ${node.maxLength} characters.`);
|
|
195
|
+
if (node.pattern !== undefined && !new RegExp(node.pattern, 'u').test(current))
|
|
196
|
+
fail('pattern', path, `Must match ${node.pattern}.`);
|
|
197
|
+
return;
|
|
198
|
+
case 'number':
|
|
199
|
+
case 'integer':
|
|
200
|
+
if (typeof current !== 'number' || !Number.isFinite(current)) {
|
|
201
|
+
fail('type', path, 'Must be a finite number.');
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (node.type === 'integer' && !Number.isInteger(current)) {
|
|
205
|
+
fail('type', path, 'Must be an integer.');
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (node.minimum !== undefined && current < node.minimum)
|
|
209
|
+
fail('minimum', path, `At least ${node.minimum}.`);
|
|
210
|
+
if (node.maximum !== undefined && current > node.maximum)
|
|
211
|
+
fail('maximum', path, `At most ${node.maximum}.`);
|
|
212
|
+
return;
|
|
213
|
+
case 'boolean':
|
|
214
|
+
if (typeof current !== 'boolean')
|
|
215
|
+
fail('type', path, 'Must be a boolean.');
|
|
216
|
+
return;
|
|
217
|
+
case 'array':
|
|
218
|
+
if (!Array.isArray(current)) {
|
|
219
|
+
fail('type', path, 'Must be an array.');
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (node.minItems !== undefined && current.length < node.minItems)
|
|
223
|
+
fail('min-items', path, `At least ${node.minItems} items.`);
|
|
224
|
+
if (node.maxItems !== undefined && current.length > node.maxItems) {
|
|
225
|
+
fail('max-items', path, `At most ${node.maxItems} items.`);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if (node.items) {
|
|
229
|
+
const items = node.items;
|
|
230
|
+
current.forEach((item, index) => {
|
|
231
|
+
visit(items, item, `${path}[${index}]`, depth + 1);
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
return;
|
|
235
|
+
case 'object': {
|
|
236
|
+
if (!isRecord(current)) {
|
|
237
|
+
fail('type', path, 'Must be an object.');
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
const properties = node.properties ?? {};
|
|
241
|
+
for (const name of node.required ?? []) {
|
|
242
|
+
if (!Object.hasOwn(current, name))
|
|
243
|
+
fail('required', `${path}.${name}`, `"${name}" is required.`);
|
|
244
|
+
}
|
|
245
|
+
for (const [key, child] of Object.entries(current)) {
|
|
246
|
+
const here = `${path}.${key}`;
|
|
247
|
+
const declared = Object.hasOwn(properties, key) ? properties[key] : undefined;
|
|
248
|
+
if (declared)
|
|
249
|
+
visit(declared, child, here, depth + 1);
|
|
250
|
+
else if (node.additionalProperties)
|
|
251
|
+
visit(node.additionalProperties, child, here, depth + 1);
|
|
252
|
+
else
|
|
253
|
+
fail('additional-property', here, `"${key}" is not allowed.`);
|
|
254
|
+
}
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
visit(schema, value, '$', 0);
|
|
260
|
+
return issues;
|
|
261
|
+
}
|
|
262
|
+
export function isRecord(value) {
|
|
263
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
264
|
+
}
|
package/dist/format.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** The repository formatter's line width (biome.json). */
|
|
2
|
+
export declare const LINE_WIDTH = 100;
|
|
3
|
+
/**
|
|
4
|
+
* Committed JSON in the repository formatter's style, so generated files
|
|
5
|
+
* stay byte-stable under `pnpm lint`: keys sorted, two-space indent, objects
|
|
6
|
+
* always expanded, and an array of scalars kept on one line when it fits the
|
|
7
|
+
* line width. Ends with a newline.
|
|
8
|
+
*/
|
|
9
|
+
export declare function formatJson(value: unknown): string;
|
package/dist/format.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { canonicalJson } from "./canonical.js";
|
|
2
|
+
/** The repository formatter's line width (biome.json). */
|
|
3
|
+
export const LINE_WIDTH = 100;
|
|
4
|
+
/**
|
|
5
|
+
* Committed JSON in the repository formatter's style, so generated files
|
|
6
|
+
* stay byte-stable under `pnpm lint`: keys sorted, two-space indent, objects
|
|
7
|
+
* always expanded, and an array of scalars kept on one line when it fits the
|
|
8
|
+
* line width. Ends with a newline.
|
|
9
|
+
*/
|
|
10
|
+
export function formatJson(value) {
|
|
11
|
+
// Round-trip through canonical JSON: sorted keys, undefined dropped.
|
|
12
|
+
return `${print(JSON.parse(canonicalJson(value)), 0, '', 0)}\n`;
|
|
13
|
+
}
|
|
14
|
+
function isScalar(value) {
|
|
15
|
+
return value === null || typeof value !== 'object';
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* `prefix` is what precedes the value on its line (indent and key) and
|
|
19
|
+
* `suffix` the length of what follows it (a comma, or nothing for the last
|
|
20
|
+
* entry), for the width check.
|
|
21
|
+
*/
|
|
22
|
+
function print(value, indent, prefix, suffix) {
|
|
23
|
+
if (isScalar(value))
|
|
24
|
+
return JSON.stringify(value);
|
|
25
|
+
const pad = ' '.repeat(indent + 1);
|
|
26
|
+
const close = ' '.repeat(indent);
|
|
27
|
+
if (Array.isArray(value)) {
|
|
28
|
+
if (value.length === 0)
|
|
29
|
+
return '[]';
|
|
30
|
+
if (value.every(isScalar)) {
|
|
31
|
+
const inline = `[${value.map((item) => JSON.stringify(item)).join(', ')}]`;
|
|
32
|
+
if (prefix.length + inline.length + suffix <= LINE_WIDTH)
|
|
33
|
+
return inline;
|
|
34
|
+
}
|
|
35
|
+
const last = value.length - 1;
|
|
36
|
+
const items = value.map((item, index) => `${pad}${print(item, indent + 1, pad, index < last ? 1 : 0)}`);
|
|
37
|
+
return `[\n${items.join(',\n')}\n${close}]`;
|
|
38
|
+
}
|
|
39
|
+
const entries = Object.entries(value);
|
|
40
|
+
if (entries.length === 0)
|
|
41
|
+
return '{}';
|
|
42
|
+
const last = entries.length - 1;
|
|
43
|
+
const lines = entries.map(([key, child], index) => {
|
|
44
|
+
const head = `${pad}${JSON.stringify(key)}: `;
|
|
45
|
+
return `${head}${print(child, indent + 1, head, index < last ? 1 : 0)}`;
|
|
46
|
+
});
|
|
47
|
+
return `{\n${lines.join(',\n')}\n${close}}`;
|
|
48
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@texuli/spec` — the schema-first blueprint contract (spec/Compose design
|
|
3
|
+
* B1), runtime half: the generated types and vocabulary, the dialect
|
|
4
|
+
* validator, the target-neutral blueprint validator and deterministic
|
|
5
|
+
* hashing. Build-time generation and the conformance harness live in
|
|
6
|
+
* `@texuli/spec/codegen`. Framework-free: no DOM, no Svelte and no Node APIs.
|
|
7
|
+
*/
|
|
8
|
+
export type * from '../generated/typescript/blueprint.js';
|
|
9
|
+
export type * from '../generated/typescript/catalog-schema.js';
|
|
10
|
+
export type * from '../generated/typescript/diagnostics.js';
|
|
11
|
+
export type * from '../generated/typescript/manifest.js';
|
|
12
|
+
export { SPEC_CATALOG_HASH, SPEC_COMPONENT_IDS, type SpecComponentId, type SpecComponentProps, } from '../generated/typescript/vocabulary.js';
|
|
13
|
+
export { canonicalJson, sha256 } from './canonical.js';
|
|
14
|
+
export { CAPABILITIES, type Capability, type SpecPropKind, WEB_ONLY_KINDS, WEB_ONLY_PROP_NAMES, } from './catalog.js';
|
|
15
|
+
export { checkSchemaDocument, DIALECT_KEYWORDS, DIALECT_TYPES, DIALECT_URI, type DialectType, type Schema, type SchemaIssue, validateInstance, } from './dialect.js';
|
|
16
|
+
export { type SpecDocuments, validateBlueprint, validateTree, } from './validate.js';
|
|
17
|
+
export { SPEC_VERSION } from './version.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@texuli/spec` — the schema-first blueprint contract (spec/Compose design
|
|
3
|
+
* B1), runtime half: the generated types and vocabulary, the dialect
|
|
4
|
+
* validator, the target-neutral blueprint validator and deterministic
|
|
5
|
+
* hashing. Build-time generation and the conformance harness live in
|
|
6
|
+
* `@texuli/spec/codegen`. Framework-free: no DOM, no Svelte and no Node APIs.
|
|
7
|
+
*/
|
|
8
|
+
export { SPEC_CATALOG_HASH, SPEC_COMPONENT_IDS, } from "../generated/typescript/vocabulary.js";
|
|
9
|
+
export { canonicalJson, sha256 } from "./canonical.js";
|
|
10
|
+
export { CAPABILITIES, WEB_ONLY_KINDS, WEB_ONLY_PROP_NAMES, } from "./catalog.js";
|
|
11
|
+
export { checkSchemaDocument, DIALECT_KEYWORDS, DIALECT_TYPES, DIALECT_URI, validateInstance, } from "./dialect.js";
|
|
12
|
+
export { validateBlueprint, validateTree, } from "./validate.js";
|
|
13
|
+
export { SPEC_VERSION } from "./version.js";
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { SpecDiagnostic, SpecDiagnosticCode, SpecValidation } from '../generated/typescript/diagnostics.js';
|
|
2
|
+
import type { SpecCatalog } from './catalog.js';
|
|
3
|
+
import { type Schema } from './dialect.js';
|
|
4
|
+
export type { SpecDiagnostic, SpecDiagnosticCode, SpecValidation };
|
|
5
|
+
export interface SpecDocuments {
|
|
6
|
+
blueprintSchema: Schema;
|
|
7
|
+
catalog: SpecCatalog;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Validate a blueprint envelope against the spec alone: the envelope schema,
|
|
11
|
+
* then every node against the catalog vocabulary (closed ids, closed prop
|
|
12
|
+
* names, prop kinds and enum values, required props, no platform-extension
|
|
13
|
+
* props from data) and the declared profile. With `catalogHash`, an envelope
|
|
14
|
+
* bound to another catalog is refused. This is the shared, target-neutral
|
|
15
|
+
* contract; URL policy, limits, grammar and rendering stay in the runtime
|
|
16
|
+
* validators (`@texuli/catalog/validate`).
|
|
17
|
+
*/
|
|
18
|
+
export declare function validateBlueprint(input: unknown, documents: SpecDocuments, options?: {
|
|
19
|
+
catalogHash?: string;
|
|
20
|
+
}): SpecValidation;
|
|
21
|
+
/** Check one catalog tree against the vocabulary. See {@link validateBlueprint}. */
|
|
22
|
+
export declare function validateTree(tree: unknown, catalog: SpecCatalog, options?: {
|
|
23
|
+
portable?: boolean;
|
|
24
|
+
path?: string;
|
|
25
|
+
}): SpecDiagnostic[];
|