@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.
Files changed (39) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +97 -2
  3. package/dist/build.d.ts +47 -0
  4. package/dist/build.js +65 -0
  5. package/dist/canonical.d.ts +17 -0
  6. package/dist/canonical.js +131 -0
  7. package/dist/catalog.d.ts +41 -0
  8. package/dist/catalog.js +45 -0
  9. package/dist/codegen/index.d.ts +13 -0
  10. package/dist/codegen/index.js +12 -0
  11. package/dist/codegen/kotlin.d.ts +18 -0
  12. package/dist/codegen/kotlin.js +328 -0
  13. package/dist/codegen/shared.d.ts +35 -0
  14. package/dist/codegen/shared.js +69 -0
  15. package/dist/codegen/typescript.d.ts +10 -0
  16. package/dist/codegen/typescript.js +167 -0
  17. package/dist/conformance.d.ts +20 -0
  18. package/dist/conformance.js +124 -0
  19. package/dist/dialect.d.ts +56 -0
  20. package/dist/dialect.js +264 -0
  21. package/dist/format.d.ts +9 -0
  22. package/dist/format.js +48 -0
  23. package/dist/index.d.ts +17 -0
  24. package/dist/index.js +13 -0
  25. package/dist/validate.d.ts +25 -0
  26. package/dist/validate.js +118 -0
  27. package/dist/version.d.ts +2 -0
  28. package/dist/version.js +2 -0
  29. package/generated/typescript/blueprint.d.ts +83 -0
  30. package/generated/typescript/blueprint.js +3 -0
  31. package/generated/typescript/catalog-schema.d.ts +33 -0
  32. package/generated/typescript/catalog-schema.js +3 -0
  33. package/generated/typescript/diagnostics.d.ts +12 -0
  34. package/generated/typescript/diagnostics.js +3 -0
  35. package/generated/typescript/manifest.d.ts +9 -0
  36. package/generated/typescript/manifest.js +3 -0
  37. package/generated/typescript/vocabulary.d.ts +943 -0
  38. package/generated/typescript/vocabulary.js +138 -0
  39. package/package.json +26 -4
@@ -0,0 +1,328 @@
1
+ import { assertSupported, defName, isRecordType, isStringEnum, pascal, refType, rootName, upperSnake, wrap, } from "./shared.js";
2
+ /**
3
+ * Deterministic Kotlin projections of the spec, written as committed text
4
+ * (no Kotlin toolchain here): data classes, closed enums and static
5
+ * decoders over a small `JsonValue` model, with no library dependency.
6
+ * Decoders check JSON types, required and unknown fields, enums and
7
+ * constants; patterns and limits stay with the shared validator.
8
+ */
9
+ export const KOTLIN_PACKAGE = 'com.texuli.spec';
10
+ const INDENT = ' ';
11
+ function header(source, stamp) {
12
+ return [
13
+ `// Generated by @texuli/spec from ${source}. Do not edit: run the spec generator.`,
14
+ `// Spec ${stamp.specVersion}, digest ${stamp.specDigest}`,
15
+ '',
16
+ `package ${KOTLIN_PACKAGE}`,
17
+ '',
18
+ ];
19
+ }
20
+ /** A Kotlin string literal (escapes backslash, quote, dollar and controls). */
21
+ export function kotlinString(value) {
22
+ let out = '';
23
+ for (const char of value) {
24
+ const code = char.codePointAt(0) ?? 0;
25
+ if (char === '\\')
26
+ out += '\\\\';
27
+ else if (char === '"')
28
+ out += '\\"';
29
+ else if (char === '$')
30
+ out += '\\$';
31
+ else if (char === '\n')
32
+ out += '\\n';
33
+ else if (code < 0x20)
34
+ out += `\\u${code.toString(16).padStart(4, '0')}`;
35
+ else
36
+ out += char;
37
+ }
38
+ return `"${out}"`;
39
+ }
40
+ function kdoc(text, indent) {
41
+ if (!text)
42
+ return [];
43
+ // A line starting with `@` would read as a KDoc tag: quote package names.
44
+ const quoted = text.replace(/(^|\s)(@[\w/.-]+)/g, '$1`$2`');
45
+ return [
46
+ `${indent}/**`,
47
+ ...wrap(quoted, 96 - indent.length).map((line) => `${indent} * ${line}`),
48
+ `${indent} */`,
49
+ ];
50
+ }
51
+ const KEYWORDS = new Set([
52
+ 'as',
53
+ 'class',
54
+ 'fun',
55
+ 'in',
56
+ 'is',
57
+ 'object',
58
+ 'package',
59
+ 'typealias',
60
+ 'val',
61
+ 'var',
62
+ 'when',
63
+ ]);
64
+ function field(name) {
65
+ return KEYWORDS.has(name) ? `\`${name}\`` : name;
66
+ }
67
+ function kotlinType(node, ctx, inlineName) {
68
+ if (node.$ref !== undefined)
69
+ return refType(ctx.unit, node.$ref);
70
+ if (node.const !== undefined)
71
+ return typeof node.const === 'string' ? 'String' : 'Long';
72
+ if (isStringEnum(node)) {
73
+ ctx.inlineEnums.set(inlineName, node);
74
+ return inlineName;
75
+ }
76
+ switch (node.type) {
77
+ case undefined:
78
+ return 'JsonValue';
79
+ case 'string':
80
+ return 'String';
81
+ case 'integer':
82
+ return 'Long';
83
+ case 'number':
84
+ return 'Double';
85
+ case 'boolean':
86
+ return 'Boolean';
87
+ case 'array':
88
+ return `List<${node.items ? kotlinType(node.items, ctx, inlineName) : 'JsonValue'}>`;
89
+ case 'object':
90
+ if (node.properties !== undefined) {
91
+ throw new Error(`${ctx.unit.source}: inline object types are not supported; use $defs`);
92
+ }
93
+ return `Map<String, ${node.additionalProperties ? kotlinType(node.additionalProperties, ctx, inlineName) : 'JsonValue'}>`;
94
+ }
95
+ }
96
+ /** A decode expression for `value` (a `JsonValue`) at path expression `path`. */
97
+ function decodeExpr(node, value, path, ctx, inlineName) {
98
+ if (node.$ref !== undefined) {
99
+ const name = refType(ctx.unit, node.$ref);
100
+ const target = ctx.unit.schema.$defs?.[node.$ref.slice('#/$defs/'.length)];
101
+ // Records and enums decode through their companion; aliases through a function.
102
+ return target && (isRecordType(target) || isStringEnum(target))
103
+ ? `${name}.decode(${value}, ${path})`
104
+ : `decode${name}(${value}, ${path})`;
105
+ }
106
+ if (node.const !== undefined) {
107
+ return typeof node.const === 'string'
108
+ ? `${value}.constant(${kotlinString(node.const)}, ${path})`
109
+ : `${value}.constant(${node.const}L, ${path})`;
110
+ }
111
+ if (isStringEnum(node))
112
+ return `${inlineName}.decode(${value}, ${path})`;
113
+ switch (node.type) {
114
+ case undefined:
115
+ return value;
116
+ case 'string':
117
+ return `${value}.str(${path})`;
118
+ case 'integer':
119
+ return `${value}.long(${path})`;
120
+ case 'number':
121
+ return `${value}.double(${path})`;
122
+ case 'boolean':
123
+ return `${value}.bool(${path})`;
124
+ case 'array': {
125
+ const item = node.items
126
+ ? decodeExpr(node.items, 'item', '"$p[$i]"', ctx, inlineName)
127
+ : 'item';
128
+ return `(${path}).let { p -> ${value}.arrValues(p).mapIndexed { i, item -> ${item} } }`;
129
+ }
130
+ case 'object': {
131
+ const inner = node.additionalProperties
132
+ ? decodeExpr(node.additionalProperties, 'item', '"$p.$k"', ctx, inlineName)
133
+ : 'item';
134
+ return `(${path}).let { p -> ${value}.obj(p).mapValues { (k, item) -> ${inner} } }`;
135
+ }
136
+ }
137
+ }
138
+ function enumClass(name, values, description) {
139
+ const names = values.map(upperSnake);
140
+ if (new Set(names).size !== names.length)
141
+ throw new Error(`${name}: enum entries collide`);
142
+ return [
143
+ ...kdoc(description, ''),
144
+ `enum class ${name}(val wire: String) {`,
145
+ ...values.map((value, i) => `${INDENT}${names[i]}(${kotlinString(value)}),`),
146
+ `${INDENT};`,
147
+ '',
148
+ `${INDENT}companion object {`,
149
+ `${INDENT}${INDENT}fun decode(json: JsonValue, path: String): ${name} {`,
150
+ `${INDENT}${INDENT}${INDENT}val wire = json.str(path)`,
151
+ `${INDENT}${INDENT}${INDENT}return entries.firstOrNull { it.wire == wire }`,
152
+ `${INDENT}${INDENT}${INDENT}${INDENT}?: throw SpecDecodeException(path, "unknown value \\"$wire\\"")`,
153
+ `${INDENT}${INDENT}}`,
154
+ `${INDENT}}`,
155
+ '}',
156
+ ];
157
+ }
158
+ function dataClass(name, node, ctx) {
159
+ const required = new Set(node.required ?? []);
160
+ const props = Object.entries(node.properties ?? {});
161
+ const params = [];
162
+ const args = [];
163
+ for (const [prop, child] of props) {
164
+ const inlineName = `${name}${pascal(prop)}`;
165
+ const type = kotlinType(child, ctx, inlineName);
166
+ const path = `"$path.${prop}"`;
167
+ if (required.has(prop)) {
168
+ const defaultValue = typeof child.const === 'string'
169
+ ? ` = ${kotlinString(child.const)}`
170
+ : child.const !== undefined
171
+ ? ` = ${child.const}L`
172
+ : '';
173
+ params.push(`${INDENT}val ${field(prop)}: ${type}${defaultValue},`);
174
+ args.push(`${INDENT}${INDENT}${INDENT}${INDENT}${field(prop)} = ${decodeExpr(child, `fields.required("${prop}", path)`, path, ctx, inlineName)},`);
175
+ }
176
+ else {
177
+ params.push(`${INDENT}val ${field(prop)}: ${type}? = null,`);
178
+ args.push(`${INDENT}${INDENT}${INDENT}${INDENT}${field(prop)} = fields["${prop}"]?.let { ${decodeExpr(child, 'it', path, ctx, inlineName)} },`);
179
+ }
180
+ }
181
+ const allowed = props.map(([prop]) => kotlinString(prop)).join(', ');
182
+ return [
183
+ ...kdoc(node.description, ''),
184
+ `data class ${name}(`,
185
+ ...params,
186
+ ') {',
187
+ `${INDENT}companion object {`,
188
+ `${INDENT}${INDENT}private val FIELDS = setOf(${allowed})`,
189
+ '',
190
+ `${INDENT}${INDENT}fun decode(json: JsonValue, path: String = "\\$"): ${name} {`,
191
+ `${INDENT}${INDENT}${INDENT}val fields = json.obj(path)`,
192
+ `${INDENT}${INDENT}${INDENT}fields.requireOnly(FIELDS, path)`,
193
+ `${INDENT}${INDENT}${INDENT}return ${name}(`,
194
+ ...args,
195
+ `${INDENT}${INDENT}${INDENT})`,
196
+ `${INDENT}${INDENT}}`,
197
+ `${INDENT}}`,
198
+ '}',
199
+ ];
200
+ }
201
+ function aliasBlock(name, node, ctx) {
202
+ const type = kotlinType(node, ctx, `${name}Value`);
203
+ return [
204
+ ...kdoc(node.description, ''),
205
+ `typealias ${name} = ${type}`,
206
+ '',
207
+ `fun decode${name}(json: JsonValue, path: String): ${name} =`,
208
+ `${INDENT}${decodeExpr(node, 'json', 'path', ctx, `${name}Value`)}`,
209
+ ];
210
+ }
211
+ /** One schema document as a Kotlin file of data classes, enums and decoders. */
212
+ export function schemaToKotlin(unit, stamp) {
213
+ assertSupported(unit);
214
+ const ctx = { unit, inlineEnums: new Map() };
215
+ const blocks = [];
216
+ const entries = [
217
+ ...Object.entries(unit.schema.$defs ?? {}).map(([def, node]) => [
218
+ defName(unit, def),
219
+ node,
220
+ ]),
221
+ [rootName(unit), unit.schema],
222
+ ];
223
+ for (const [name, node] of entries) {
224
+ if (isRecordType(node))
225
+ blocks.push(dataClass(name, node, ctx));
226
+ else if (isStringEnum(node))
227
+ blocks.push(enumClass(name, node.enum, node.description));
228
+ else
229
+ blocks.push(aliasBlock(name, node, ctx));
230
+ }
231
+ for (const [name, node] of [...ctx.inlineEnums.entries()].sort(([a], [b]) => (a < b ? -1 : 1))) {
232
+ blocks.push(enumClass(name, node.enum, node.description));
233
+ }
234
+ return `${[...header(unit.source, stamp), blocks.map((b) => b.join('\n')).join('\n\n')].join('\n')}\n`;
235
+ }
236
+ /** The JSON model and decode helpers every generated decoder uses. */
237
+ export function kotlinJsonRuntime(stamp) {
238
+ const lines = [
239
+ ...header('the spec generator (JSON runtime)', stamp),
240
+ '/** A parsed JSON value. Hosts convert their parser output into it. */',
241
+ 'sealed interface JsonValue {',
242
+ `${INDENT}data class Str(val value: String) : JsonValue`,
243
+ `${INDENT}data class Num(val value: Double) : JsonValue`,
244
+ `${INDENT}data class Bool(val value: Boolean) : JsonValue`,
245
+ `${INDENT}data class Arr(val items: List<JsonValue>) : JsonValue`,
246
+ `${INDENT}data class Obj(val fields: Map<String, JsonValue>) : JsonValue`,
247
+ `${INDENT}data object Null : JsonValue`,
248
+ '}',
249
+ '',
250
+ '/** A value that does not match the spec, with its JSON path. */',
251
+ 'class SpecDecodeException(val path: String, message: String) :',
252
+ `${INDENT}IllegalArgumentException("$path: $message")`,
253
+ '',
254
+ 'internal fun JsonValue.obj(path: String): Map<String, JsonValue> =',
255
+ `${INDENT}(this as? JsonValue.Obj)?.fields ?: throw SpecDecodeException(path, "expected an object")`,
256
+ '',
257
+ 'internal fun JsonValue.arrValues(path: String): List<JsonValue> =',
258
+ `${INDENT}(this as? JsonValue.Arr)?.items ?: throw SpecDecodeException(path, "expected an array")`,
259
+ '',
260
+ 'internal fun JsonValue.str(path: String): String =',
261
+ `${INDENT}(this as? JsonValue.Str)?.value ?: throw SpecDecodeException(path, "expected a string")`,
262
+ '',
263
+ 'internal fun JsonValue.double(path: String): Double {',
264
+ `${INDENT}val number = (this as? JsonValue.Num)?.value`,
265
+ `${INDENT}if (number == null || !number.isFinite()) throw SpecDecodeException(path, "expected a number")`,
266
+ `${INDENT}return number`,
267
+ '}',
268
+ '',
269
+ 'internal fun JsonValue.long(path: String): Long {',
270
+ `${INDENT}val number = double(path)`,
271
+ `${INDENT}if (number % 1.0 != 0.0) throw SpecDecodeException(path, "expected an integer")`,
272
+ `${INDENT}return number.toLong()`,
273
+ '}',
274
+ '',
275
+ 'internal fun JsonValue.bool(path: String): Boolean =',
276
+ `${INDENT}(this as? JsonValue.Bool)?.value ?: throw SpecDecodeException(path, "expected a boolean")`,
277
+ '',
278
+ 'internal fun JsonValue.constant(expected: String, path: String): String {',
279
+ `${INDENT}val value = str(path)`,
280
+ `${INDENT}if (value != expected) throw SpecDecodeException(path, "expected \\"$expected\\"")`,
281
+ `${INDENT}return value`,
282
+ '}',
283
+ '',
284
+ 'internal fun JsonValue.constant(expected: Long, path: String): Long {',
285
+ `${INDENT}val value = long(path)`,
286
+ `${INDENT}if (value != expected) throw SpecDecodeException(path, "expected $expected")`,
287
+ `${INDENT}return value`,
288
+ '}',
289
+ '',
290
+ 'internal fun Map<String, JsonValue>.required(name: String, path: String): JsonValue =',
291
+ `${INDENT}this[name] ?: throw SpecDecodeException("$path.$name", "is required")`,
292
+ '',
293
+ '/** Unknown fields fail closed. */',
294
+ 'internal fun Map<String, JsonValue>.requireOnly(allowed: Set<String>, path: String) {',
295
+ `${INDENT}val unknown = keys.firstOrNull { it !in allowed }`,
296
+ `${INDENT}if (unknown != null) throw SpecDecodeException("$path.$unknown", "is not allowed")`,
297
+ '}',
298
+ '',
299
+ ];
300
+ return lines.join('\n');
301
+ }
302
+ /** The catalog vocabulary as a Kotlin registry of `SpecComponent` values. */
303
+ export function vocabularyToKotlin(catalog, stamp) {
304
+ const ids = catalog.components.map((c) => c.id);
305
+ const optional = (value) => (value === undefined ? 'null' : value);
306
+ const lines = [
307
+ ...header('catalog/components.json', stamp),
308
+ ...enumClass('SpecComponentId', ids, 'Every closed component id, sorted.'),
309
+ '',
310
+ '/** The catalog vocabulary: one entry per component id. */',
311
+ 'object SpecVocabulary {',
312
+ `${INDENT}const val CATALOG_HASH: String = ${kotlinString(catalog.catalogHash)}`,
313
+ '',
314
+ `${INDENT}val components: List<SpecComponent> = listOf(`,
315
+ ];
316
+ for (const c of catalog.components) {
317
+ lines.push(`${INDENT}${INDENT}SpecComponent(`, `${INDENT}${INDENT}${INDENT}id = ${kotlinString(c.id)},`, `${INDENT}${INDENT}${INDENT}label = ${kotlinString(c.label)},`, `${INDENT}${INDENT}${INDENT}summary = ${kotlinString(c.summary)},`, `${INDENT}${INDENT}${INDENT}group = ${kotlinString(c.group)},`, `${INDENT}${INDENT}${INDENT}placement = SpecComponentPlacement.${upperSnake(c.placement)},`, `${INDENT}${INDENT}${INDENT}emit = listOf(${c.emit.map((e) => `SpecComponentEmit.${upperSnake(e)}`).join(', ')}),`, `${INDENT}${INDENT}${INDENT}capability = SpecComponentCapability.${upperSnake(c.capability)},`, `${INDENT}${INDENT}${INDENT}props = listOf(`);
318
+ for (const p of c.props) {
319
+ const enumValues = p.enumValues
320
+ ? `listOf(${p.enumValues.map(kotlinString).join(', ')})`
321
+ : undefined;
322
+ lines.push(`${INDENT}${INDENT}${INDENT}${INDENT}SpecProp(`, `${INDENT}${INDENT}${INDENT}${INDENT}${INDENT}name = ${kotlinString(p.name)},`, `${INDENT}${INDENT}${INDENT}${INDENT}${INDENT}kind = SpecPropKind.${upperSnake(p.kind)},`, `${INDENT}${INDENT}${INDENT}${INDENT}${INDENT}required = ${optional(p.required === undefined ? undefined : String(p.required))},`, `${INDENT}${INDENT}${INDENT}${INDENT}${INDENT}enumValues = ${optional(enumValues)},`, `${INDENT}${INDENT}${INDENT}${INDENT}${INDENT}extension = ${optional(p.extension ? `SpecPropExtension.${upperSnake(p.extension)}` : undefined)},`, `${INDENT}${INDENT}${INDENT}${INDENT}),`);
323
+ }
324
+ lines.push(`${INDENT}${INDENT}${INDENT}),`, `${INDENT}${INDENT}),`);
325
+ }
326
+ lines.push(`${INDENT})`, '', `${INDENT}fun component(id: String): SpecComponent? = components.firstOrNull { it.id == id }`, '}', '');
327
+ return lines.join('\n');
328
+ }
@@ -0,0 +1,35 @@
1
+ import { type Schema } from '../dialect.js';
2
+ /** What every generated file is stamped with. */
3
+ export interface Stamp {
4
+ specVersion: string;
5
+ /** SHA-256 over the normative files' digests (`manifest.json`). */
6
+ specDigest: string;
7
+ }
8
+ /** One schema document the generators project. */
9
+ export interface SchemaUnit {
10
+ /** Path inside the package, e.g. `schemas/blueprint.schema.json`. */
11
+ source: string;
12
+ schema: Schema;
13
+ /** Prefix for `$defs` type names (`Blueprint` + `node` = `BlueprintNode`). */
14
+ prefix: string;
15
+ }
16
+ /** `meetingRef` → `MeetingRef`, `stacked-bar` → `StackedBar`. */
17
+ export declare function pascal(name: string): string;
18
+ /** `portable-core` → `PORTABLE_CORE`, `setProp` → `SET_PROP`. */
19
+ export declare function upperSnake(value: string): string;
20
+ /** The generated type name of a `$defs` entry. */
21
+ export declare function defName(unit: SchemaUnit, def: string): string;
22
+ /** The generated type name a `$ref` points at. */
23
+ export declare function refType(unit: SchemaUnit, ref: string): string;
24
+ /** The root type name: the schema's `title`. */
25
+ export declare function rootName(unit: SchemaUnit): string;
26
+ /** Refuse a schema outside the dialect: generation never guesses. */
27
+ export declare function assertSupported(unit: SchemaUnit): void;
28
+ /** Whether a schema node is a closed object with declared properties. */
29
+ export declare function isRecordType(node: Schema): boolean;
30
+ /** Whether a schema node is a string enum. */
31
+ export declare function isStringEnum(node: Schema): node is Schema & {
32
+ enum: readonly string[];
33
+ };
34
+ /** Words of `text` wrapped to `width` columns. */
35
+ export declare function wrap(text: string, width: number): string[];
@@ -0,0 +1,69 @@
1
+ import { checkSchemaDocument, refName } from "../dialect.js";
2
+ /** `meetingRef` → `MeetingRef`, `stacked-bar` → `StackedBar`. */
3
+ export function pascal(name) {
4
+ return name
5
+ .split(/[-_\s]+/)
6
+ .filter(Boolean)
7
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
8
+ .join('');
9
+ }
10
+ /** `portable-core` → `PORTABLE_CORE`, `setProp` → `SET_PROP`. */
11
+ export function upperSnake(value) {
12
+ const snake = value
13
+ .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
14
+ .replace(/[^A-Za-z0-9]+/g, '_')
15
+ .replace(/^_+|_+$/g, '')
16
+ .toUpperCase();
17
+ return /^[0-9]/.test(snake) ? `V_${snake}` : snake;
18
+ }
19
+ /** The generated type name of a `$defs` entry. */
20
+ export function defName(unit, def) {
21
+ return `${unit.prefix}${pascal(def)}`;
22
+ }
23
+ /** The generated type name a `$ref` points at. */
24
+ export function refType(unit, ref) {
25
+ const name = refName(ref);
26
+ if (name === undefined)
27
+ throw new Error(`${unit.source}: unsupported $ref ${ref}`);
28
+ return defName(unit, name);
29
+ }
30
+ /** The root type name: the schema's `title`. */
31
+ export function rootName(unit) {
32
+ if (!unit.schema.title || !/^[A-Z][A-Za-z0-9]*$/.test(unit.schema.title)) {
33
+ throw new Error(`${unit.source}: the root schema needs a PascalCase "title"`);
34
+ }
35
+ return unit.schema.title;
36
+ }
37
+ /** Refuse a schema outside the dialect: generation never guesses. */
38
+ export function assertSupported(unit) {
39
+ const issues = checkSchemaDocument(unit.schema);
40
+ if (issues.length > 0) {
41
+ const detail = issues.map((i) => `${i.path} ${i.code}`).join('; ');
42
+ throw new Error(`${unit.source}: not in the spec dialect: ${detail}`);
43
+ }
44
+ }
45
+ /** Whether a schema node is a closed object with declared properties. */
46
+ export function isRecordType(node) {
47
+ return node.type === 'object' && node.properties !== undefined;
48
+ }
49
+ /** Whether a schema node is a string enum. */
50
+ export function isStringEnum(node) {
51
+ return Array.isArray(node.enum) && node.enum.every((value) => typeof value === 'string');
52
+ }
53
+ /** Words of `text` wrapped to `width` columns. */
54
+ export function wrap(text, width) {
55
+ const lines = [];
56
+ let line = '';
57
+ for (const word of text.split(/\s+/).filter(Boolean)) {
58
+ if (line && line.length + 1 + word.length > width) {
59
+ lines.push(line);
60
+ line = word;
61
+ }
62
+ else {
63
+ line = line ? `${line} ${word}` : word;
64
+ }
65
+ }
66
+ if (line)
67
+ lines.push(line);
68
+ return lines;
69
+ }
@@ -0,0 +1,10 @@
1
+ import type { SpecCatalog } from '../catalog.js';
2
+ import { type SchemaUnit, type Stamp } from './shared.js';
3
+ /** One schema document as a TypeScript module of types. */
4
+ export declare function schemaToTypeScript(unit: SchemaUnit, stamp: Stamp): string;
5
+ /**
6
+ * The catalog vocabulary as TypeScript: the closed component ids and the
7
+ * portable props of each component. Platform-extension props are left out:
8
+ * they never come from blueprint data.
9
+ */
10
+ export declare function vocabularyToTypeScript(catalog: SpecCatalog, stamp: Stamp): string;
@@ -0,0 +1,167 @@
1
+ import { LINE_WIDTH } from "../format.js";
2
+ import { assertSupported, defName, isRecordType, refType, rootName, wrap, } from "./shared.js";
3
+ /**
4
+ * Deterministic TypeScript projections of the spec: strict, erasable types
5
+ * (interfaces, unions, `as const` arrays). The output is already in the
6
+ * repository formatter's style, so it is byte-stable under `pnpm lint`.
7
+ */
8
+ const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
9
+ function header(source, stamp) {
10
+ return [
11
+ `// Generated by @texuli/spec from ${source}. Do not edit: run the spec generator.`,
12
+ `// Spec ${stamp.specVersion}, digest ${stamp.specDigest}`,
13
+ '',
14
+ ].join('\n');
15
+ }
16
+ function literal(value) {
17
+ return typeof value === 'string'
18
+ ? `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`
19
+ : String(value);
20
+ }
21
+ function key(name) {
22
+ return IDENTIFIER.test(name) ? name : literal(name);
23
+ }
24
+ function doc(text, indent) {
25
+ if (!text)
26
+ return [];
27
+ const one = `${indent}/** ${text} */`;
28
+ if (one.length <= LINE_WIDTH)
29
+ return [one];
30
+ return [
31
+ `${indent}/**`,
32
+ ...wrap(text, LINE_WIDTH - indent.length - 3).map((line) => `${indent} * ${line}`),
33
+ `${indent} */`,
34
+ ];
35
+ }
36
+ /** The union members of a type, when it is a union of literals. */
37
+ function unionMembers(node) {
38
+ if (node.enum !== undefined)
39
+ return node.enum.map(literal);
40
+ return null;
41
+ }
42
+ function typeOf(node, unit) {
43
+ if (node.$ref !== undefined)
44
+ return refType(unit, node.$ref);
45
+ if (node.const !== undefined)
46
+ return literal(node.const);
47
+ const members = unionMembers(node);
48
+ if (members)
49
+ return members.join(' | ');
50
+ switch (node.type) {
51
+ case undefined:
52
+ return 'unknown';
53
+ case 'string':
54
+ return 'string';
55
+ case 'number':
56
+ case 'integer':
57
+ return 'number';
58
+ case 'boolean':
59
+ return 'boolean';
60
+ case 'array': {
61
+ const item = node.items ? typeOf(node.items, unit) : 'unknown';
62
+ return item.includes(' | ') ? `readonly (${item})[]` : `readonly ${item}[]`;
63
+ }
64
+ case 'object':
65
+ if (node.properties !== undefined) {
66
+ throw new Error(`${unit.source}: inline object types are not supported; use $defs`);
67
+ }
68
+ return `Readonly<Record<string, ${node.additionalProperties ? typeOf(node.additionalProperties, unit) : 'never'}>>`;
69
+ }
70
+ }
71
+ /** `head` followed by a type, breaking a long union one member per line. */
72
+ function typed(head, node, unit, indent) {
73
+ const type = typeOf(node, unit);
74
+ const one = `${head} ${type};`;
75
+ const members = unionMembers(node);
76
+ if (one.length <= LINE_WIDTH || !members)
77
+ return [one];
78
+ const last = members.length - 1;
79
+ return [head, ...members.map((m, i) => `${indent} | ${m}${i === last ? ';' : ''}`)];
80
+ }
81
+ function declaration(name, node, unit) {
82
+ const lines = doc(node.description, '');
83
+ if (isRecordType(node)) {
84
+ lines.push(`export interface ${name} {`);
85
+ const required = new Set(node.required ?? []);
86
+ for (const [prop, child] of Object.entries(node.properties ?? {})) {
87
+ lines.push(...doc(child.description, ' '));
88
+ const head = ` ${key(prop)}${required.has(prop) ? '' : '?'}:`;
89
+ lines.push(...typed(head, child, unit, ' '));
90
+ }
91
+ lines.push('}');
92
+ return lines;
93
+ }
94
+ lines.push(...typed(`export type ${name} =`, node, unit, ''));
95
+ return lines;
96
+ }
97
+ /** One schema document as a TypeScript module of types. */
98
+ export function schemaToTypeScript(unit, stamp) {
99
+ assertSupported(unit);
100
+ const blocks = [];
101
+ for (const [def, node] of Object.entries(unit.schema.$defs ?? {})) {
102
+ blocks.push(declaration(defName(unit, def), node, unit));
103
+ }
104
+ blocks.push(declaration(rootName(unit), unit.schema, unit));
105
+ return `${header(unit.source, stamp)}\n${blocks.map((b) => b.join('\n')).join('\n\n')}\n`;
106
+ }
107
+ function propType(prop) {
108
+ switch (prop.kind) {
109
+ case 'string':
110
+ return 'string';
111
+ case 'number':
112
+ return 'number';
113
+ case 'boolean':
114
+ return 'boolean';
115
+ case 'enum':
116
+ return (prop.enumValues ?? []).map(literal).join(' | ') || 'string';
117
+ case 'array':
118
+ return 'readonly unknown[]';
119
+ default:
120
+ return 'Readonly<Record<string, unknown>>';
121
+ }
122
+ }
123
+ /**
124
+ * The catalog vocabulary as TypeScript: the closed component ids and the
125
+ * portable props of each component. Platform-extension props are left out:
126
+ * they never come from blueprint data.
127
+ */
128
+ export function vocabularyToTypeScript(catalog, stamp) {
129
+ const lines = [
130
+ header('catalog/components.json', stamp),
131
+ '/** The built catalog this vocabulary was projected from. */',
132
+ `export const SPEC_CATALOG_HASH = ${literal(catalog.catalogHash)};`,
133
+ '',
134
+ '/** Every closed component id, sorted. */',
135
+ 'export const SPEC_COMPONENT_IDS = [',
136
+ ...catalog.components.map((c) => ` ${literal(c.id)},`),
137
+ '] as const;',
138
+ '',
139
+ 'export type SpecComponentId = (typeof SPEC_COMPONENT_IDS)[number];',
140
+ '',
141
+ '/** The props a blueprint may set on each component (platform extensions left out). */',
142
+ 'export interface SpecComponentProps {',
143
+ ];
144
+ for (const component of catalog.components) {
145
+ const props = component.props.filter((p) => !p.extension);
146
+ if (props.length === 0) {
147
+ lines.push(` ${key(component.id)}: Record<string, never>;`);
148
+ continue;
149
+ }
150
+ lines.push(` ${key(component.id)}: {`);
151
+ for (const prop of props) {
152
+ const head = ` ${key(prop.name)}${prop.required ? '' : '?'}:`;
153
+ const type = propType(prop);
154
+ const one = `${head} ${type};`;
155
+ if (one.length <= LINE_WIDTH || prop.kind !== 'enum') {
156
+ lines.push(one);
157
+ }
158
+ else {
159
+ const members = (prop.enumValues ?? []).map(literal);
160
+ lines.push(head, ...members.map((m, i) => ` | ${m}${i === members.length - 1 ? ';' : ''}`));
161
+ }
162
+ }
163
+ lines.push(' };');
164
+ }
165
+ lines.push('}', '');
166
+ return lines.join('\n');
167
+ }
@@ -0,0 +1,20 @@
1
+ /** One conformance failure: drift is a failed gate, never a warning. */
2
+ export interface ConformanceIssue {
3
+ check: 'dialect' | 'catalog' | 'capabilities' | 'manifest' | 'generated' | 'golden' | 'invalid';
4
+ path: string;
5
+ message: string;
6
+ }
7
+ /**
8
+ * Run the spec's conformance harness (spec/Compose design §7.1 and §7.3)
9
+ * over the normative files and the committed outputs (`manifest.json`,
10
+ * `generated-manifest.json` and every generated file):
11
+ * - every schema stays in the dialect;
12
+ * - `catalog/components.json` and `catalog/capabilities.json` are valid;
13
+ * - the manifest digests match the normative files;
14
+ * - regenerating gives byte-identical TypeScript and Kotlin, with no file
15
+ * missing or extra;
16
+ * - every golden blueprint validates, and every invalid fixture fails with
17
+ * exactly its expected diagnostic codes.
18
+ * Runtime-neutral: the caller reads the files.
19
+ */
20
+ export declare function checkConformance(normative: Readonly<Record<string, string>>, committed: Readonly<Record<string, string>>): ConformanceIssue[];