@texuli/spec 0.5.0 → 0.7.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 (37) hide show
  1. package/README.md +55 -11
  2. package/dist/build.d.ts +8 -0
  3. package/dist/build.js +8 -0
  4. package/dist/codegen/index.d.ts +1 -1
  5. package/dist/codegen/index.js +1 -1
  6. package/dist/conformance.d.ts +1 -1
  7. package/dist/conformance.js +4 -3
  8. package/dist/contract.d.ts +8 -1
  9. package/dist/contract.js +8 -0
  10. package/dist/generated/typescript/blueprint.d.ts +98 -5
  11. package/dist/generated/typescript/blueprint.js +1 -1
  12. package/dist/generated/typescript/catalog-schema.js +1 -1
  13. package/dist/generated/typescript/diagnostics.d.ts +1 -1
  14. package/dist/generated/typescript/diagnostics.js +1 -1
  15. package/dist/generated/typescript/manifest.js +1 -1
  16. package/dist/generated/typescript/vocabulary.d.ts +7 -2
  17. package/dist/generated/typescript/vocabulary.js +3 -2
  18. package/dist/generated/typescript/workflow.js +1 -1
  19. package/dist/migrations/0.5.0-0.6.0.d.ts +9 -0
  20. package/dist/migrations/0.5.0-0.6.0.js +12 -0
  21. package/dist/migrations/0.6.0-0.7.0.d.ts +10 -0
  22. package/dist/migrations/0.6.0-0.7.0.js +13 -0
  23. package/dist/migrations/index.js +4 -0
  24. package/dist/motion.d.ts +40 -0
  25. package/dist/motion.js +133 -0
  26. package/dist/stream/assembler.d.ts +101 -0
  27. package/dist/stream/assembler.js +284 -0
  28. package/dist/stream/index.d.ts +11 -0
  29. package/dist/stream/index.js +11 -0
  30. package/dist/stream/source.d.ts +22 -0
  31. package/dist/stream/source.js +72 -0
  32. package/dist/stream/tokenizer.d.ts +67 -0
  33. package/dist/stream/tokenizer.js +383 -0
  34. package/dist/validate.js +13 -0
  35. package/dist/version.d.ts +1 -1
  36. package/dist/version.js +1 -1
  37. package/package.json +9 -1
@@ -0,0 +1,67 @@
1
+ /**
2
+ * An incremental JSON tokenizer for untrusted streams (ADR-014). Text
3
+ * arrives in chunks split anywhere (inside a string, an escape, a number, a
4
+ * literal or a UTF-16 surrogate pair); the tokenizer reports each value as
5
+ * it closes. One pass over every code unit, no backtracking and no
6
+ * recursion, so the time is linear in the input and the memory is bounded
7
+ * by the byte budget and the depth cap. Framework-free: no DOM, no Node.
8
+ *
9
+ * It refuses, at a UTF-8 byte offset: malformed JSON, input over the byte
10
+ * budget, containers nested past the depth cap, duplicate keys and the
11
+ * prototype keys (`__proto__`, `constructor`, `prototype`). After an error
12
+ * it reports nothing more.
13
+ */
14
+ /** Why a stream stopped. Tokenizer codes, plus the codes a stream reader adds. */
15
+ export type StreamErrorCode = 'bad-json' | 'too-large' | 'too-deep' | 'duplicate-key' | 'prototype-key' | 'truncated' | 'aborted' | 'connection';
16
+ export interface StreamError {
17
+ code: StreamErrorCode;
18
+ /** UTF-8 byte offset of the refused input (the bytes read so far for a reader error). */
19
+ offset: number;
20
+ message: string;
21
+ }
22
+ export type JsonScalar = string | number | boolean | null;
23
+ /** One closed token. `offset` is the UTF-8 byte offset where it starts. */
24
+ export type JsonStreamEvent = {
25
+ type: 'open';
26
+ kind: 'object' | 'array';
27
+ offset: number;
28
+ } | {
29
+ type: 'key';
30
+ key: string;
31
+ offset: number;
32
+ } | {
33
+ type: 'value';
34
+ value: JsonScalar;
35
+ offset: number;
36
+ } | {
37
+ type: 'close';
38
+ kind: 'object' | 'array';
39
+ offset: number;
40
+ };
41
+ export interface JsonTokenizerOptions {
42
+ /** UTF-8 bytes the whole stream may take. Default {@link DEFAULT_STREAM_MAX_BYTES}. */
43
+ maxBytes?: number;
44
+ /** Containers that may nest. Default {@link DEFAULT_STREAM_MAX_DEPTH}. */
45
+ maxDepth?: number;
46
+ }
47
+ export interface JsonTokenizer {
48
+ /** Feed the next chunk; returns the tokens that closed inside it. */
49
+ write(chunk: string): JsonStreamEvent[];
50
+ /** No more input: closes a trailing top-level number or literal, or reports `truncated`. */
51
+ end(): JsonStreamEvent[];
52
+ /** UTF-8 bytes read so far. */
53
+ readonly bytes: number;
54
+ /** Open containers right now. */
55
+ readonly depth: number;
56
+ /** The first error, or `null`. */
57
+ readonly error: StreamError | null;
58
+ /** `true` once `end()` accepted one complete JSON value. */
59
+ readonly done: boolean;
60
+ }
61
+ /** Default byte budget: the catalog's default tree cap (256 KiB) plus 8 KiB of whitespace. */
62
+ export declare const DEFAULT_STREAM_MAX_BYTES: number;
63
+ /** Default depth cap: the catalog's value-depth guard. */
64
+ export declare const DEFAULT_STREAM_MAX_DEPTH = 128;
65
+ /** Keys refused anywhere in a stream. */
66
+ export declare const STREAM_PROTOTYPE_KEYS: readonly string[];
67
+ export declare function createJsonTokenizer(options?: JsonTokenizerOptions): JsonTokenizer;
@@ -0,0 +1,383 @@
1
+ /**
2
+ * An incremental JSON tokenizer for untrusted streams (ADR-014). Text
3
+ * arrives in chunks split anywhere (inside a string, an escape, a number, a
4
+ * literal or a UTF-16 surrogate pair); the tokenizer reports each value as
5
+ * it closes. One pass over every code unit, no backtracking and no
6
+ * recursion, so the time is linear in the input and the memory is bounded
7
+ * by the byte budget and the depth cap. Framework-free: no DOM, no Node.
8
+ *
9
+ * It refuses, at a UTF-8 byte offset: malformed JSON, input over the byte
10
+ * budget, containers nested past the depth cap, duplicate keys and the
11
+ * prototype keys (`__proto__`, `constructor`, `prototype`). After an error
12
+ * it reports nothing more.
13
+ */
14
+ /** Default byte budget: the catalog's default tree cap (256 KiB) plus 8 KiB of whitespace. */
15
+ export const DEFAULT_STREAM_MAX_BYTES = 256 * 1024 + 8 * 1024;
16
+ /** Default depth cap: the catalog's value-depth guard. */
17
+ export const DEFAULT_STREAM_MAX_DEPTH = 128;
18
+ /** Keys refused anywhere in a stream. */
19
+ export const STREAM_PROTOTYPE_KEYS = Object.freeze([
20
+ '__proto__',
21
+ 'constructor',
22
+ 'prototype',
23
+ ]);
24
+ const PROTOTYPE_KEYS = new Set(STREAM_PROTOTYPE_KEYS);
25
+ const NUMBER = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/;
26
+ const LITERALS = {
27
+ t: { text: 'true', value: true },
28
+ f: { text: 'false', value: false },
29
+ n: { text: 'null', value: null },
30
+ };
31
+ const ESCAPES = {
32
+ '"': '"',
33
+ '\\': '\\',
34
+ '/': '/',
35
+ b: '\b',
36
+ f: '\f',
37
+ n: '\n',
38
+ r: '\r',
39
+ t: '\t',
40
+ };
41
+ const isWhitespace = (code) => code === 0x20 || code === 0x0a || code === 0x0d || code === 0x09;
42
+ export function createJsonTokenizer(options = {}) {
43
+ const maxBytes = options.maxBytes ?? DEFAULT_STREAM_MAX_BYTES;
44
+ const maxDepth = options.maxDepth ?? DEFAULT_STREAM_MAX_DEPTH;
45
+ const stack = [];
46
+ let expect = 'value';
47
+ let lexeme = 'none';
48
+ // The token being read, as chunks joined at its close (strings) or text.
49
+ let parts = [];
50
+ let tokenStart = 0;
51
+ let stringIsKey = false;
52
+ // Escape state inside a string: 0 none, 1 after `\`, 2–5 reading `\u` hex digits.
53
+ let escaping = 0;
54
+ let hex = 0;
55
+ let literal = null;
56
+ let bytes = 0;
57
+ // A high surrogate counted as 3 bytes; a low one right after it adds 1 (4 in all).
58
+ let pendingHigh = false;
59
+ let error = null;
60
+ let done = false;
61
+ let ended = false;
62
+ const fail = (code, offset, message) => {
63
+ error = { code, offset, message };
64
+ };
65
+ /** Bytes of one UTF-16 code unit, given the unit before it. */
66
+ const unitBytes = (code) => {
67
+ if (code < 0x80) {
68
+ pendingHigh = false;
69
+ return 1;
70
+ }
71
+ if (code < 0x800) {
72
+ pendingHigh = false;
73
+ return 2;
74
+ }
75
+ if (code >= 0xd800 && code <= 0xdbff) {
76
+ pendingHigh = true;
77
+ return 3;
78
+ }
79
+ if (code >= 0xdc00 && code <= 0xdfff && pendingHigh) {
80
+ pendingHigh = false;
81
+ return 1;
82
+ }
83
+ pendingHigh = false;
84
+ return 3;
85
+ };
86
+ /** A value closed: move the container state on. */
87
+ const closedValue = () => {
88
+ const top = stack[stack.length - 1];
89
+ expect = top ? 'comma-or-close' : 'after';
90
+ };
91
+ function emitScalar(events, value) {
92
+ events.push({ type: 'value', value, offset: tokenStart });
93
+ closedValue();
94
+ }
95
+ function finishNumber(events) {
96
+ const text = parts.join('');
97
+ parts = [];
98
+ lexeme = 'none';
99
+ if (!NUMBER.test(text)) {
100
+ fail('bad-json', tokenStart, `"${text.slice(0, 32)}" is not a JSON number.`);
101
+ return false;
102
+ }
103
+ emitScalar(events, Number(text));
104
+ return true;
105
+ }
106
+ function finishString(events) {
107
+ const text = parts.join('');
108
+ parts = [];
109
+ lexeme = 'none';
110
+ if (!stringIsKey) {
111
+ emitScalar(events, text);
112
+ return true;
113
+ }
114
+ if (PROTOTYPE_KEYS.has(text)) {
115
+ fail('prototype-key', tokenStart, `The key "${text}" is not allowed.`);
116
+ return false;
117
+ }
118
+ const frame = stack[stack.length - 1];
119
+ const keys = frame.keys;
120
+ if (keys.has(text)) {
121
+ fail('duplicate-key', tokenStart, `The key "${text.slice(0, 64)}" appears twice.`);
122
+ return false;
123
+ }
124
+ keys.add(text);
125
+ events.push({ type: 'key', key: text, offset: tokenStart });
126
+ expect = 'colon';
127
+ return true;
128
+ }
129
+ /** Start a value at `chunk[i]`; returns `false` on error. */
130
+ function startValue(events, char, offset) {
131
+ if (char === '{' || char === '[') {
132
+ if (stack.length >= maxDepth) {
133
+ fail('too-deep', offset, `Values nest deeper than ${maxDepth} levels.`);
134
+ return false;
135
+ }
136
+ const kind = char === '{' ? 'object' : 'array';
137
+ stack.push({ kind, keys: kind === 'object' ? new Set() : null });
138
+ events.push({ type: 'open', kind, offset });
139
+ expect = kind === 'object' ? 'key-or-close' : 'value-or-close';
140
+ return true;
141
+ }
142
+ tokenStart = offset;
143
+ if (char === '"') {
144
+ lexeme = 'string';
145
+ stringIsKey = false;
146
+ parts = [];
147
+ escaping = 0;
148
+ return true;
149
+ }
150
+ if (char === '-' || (char >= '0' && char <= '9')) {
151
+ lexeme = 'number';
152
+ parts = [char];
153
+ return true;
154
+ }
155
+ const found = LITERALS[char];
156
+ if (found) {
157
+ lexeme = 'literal';
158
+ literal = found;
159
+ parts = [char];
160
+ return true;
161
+ }
162
+ fail('bad-json', offset, `Unexpected "${char}" where a value should start.`);
163
+ return false;
164
+ }
165
+ function closeContainer(events, kind, offset) {
166
+ stack.pop();
167
+ events.push({ type: 'close', kind, offset });
168
+ closedValue();
169
+ }
170
+ function run(chunk, events) {
171
+ const length = chunk.length;
172
+ let i = 0;
173
+ while (i < length && error === null) {
174
+ const code = chunk.charCodeAt(i);
175
+ // ── Inside a string: copy runs of plain characters at once ──
176
+ if (lexeme === 'string') {
177
+ if (escaping === 0) {
178
+ let j = i;
179
+ while (j < length) {
180
+ const c = chunk.charCodeAt(j);
181
+ if (c === 0x22 || c === 0x5c || c < 0x20)
182
+ break;
183
+ bytes += unitBytes(c);
184
+ j += 1;
185
+ }
186
+ if (bytes > maxBytes) {
187
+ fail('too-large', maxBytes, `The stream is over its ${maxBytes}-byte budget.`);
188
+ return;
189
+ }
190
+ if (j > i)
191
+ parts.push(chunk.slice(i, j));
192
+ i = j;
193
+ if (i >= length)
194
+ return;
195
+ const c = chunk.charCodeAt(i);
196
+ const at = bytes;
197
+ bytes += unitBytes(c);
198
+ i += 1;
199
+ if (c === 0x22)
200
+ finishString(events);
201
+ else if (c === 0x5c)
202
+ escaping = 1;
203
+ else
204
+ fail('bad-json', at, 'Control characters must be escaped inside strings.');
205
+ continue;
206
+ }
207
+ const at = bytes;
208
+ bytes += unitBytes(code);
209
+ i += 1;
210
+ const char = chunk[i - 1];
211
+ if (escaping === 1) {
212
+ if (char === 'u') {
213
+ escaping = 2;
214
+ hex = 0;
215
+ }
216
+ else if (Object.hasOwn(ESCAPES, char)) {
217
+ parts.push(ESCAPES[char]);
218
+ escaping = 0;
219
+ }
220
+ else {
221
+ fail('bad-json', at, `"\\${char}" is not a JSON escape.`);
222
+ }
223
+ continue;
224
+ }
225
+ // Reading the four hex digits of `\u`.
226
+ if (!/^[0-9a-fA-F]$/.test(char)) {
227
+ fail('bad-json', at, 'A "\\u" escape needs four hex digits.');
228
+ continue;
229
+ }
230
+ hex = hex * 16 + Number.parseInt(char, 16);
231
+ escaping += 1;
232
+ if (escaping === 6) {
233
+ // A pair of `\u` escapes forms a surrogate pair by concatenation, as JSON.parse does.
234
+ parts.push(String.fromCharCode(hex));
235
+ escaping = 0;
236
+ }
237
+ continue;
238
+ }
239
+ // ── Numbers and literals end at the first character that is not theirs ──
240
+ if (lexeme === 'number') {
241
+ const char = chunk[i];
242
+ if ((char >= '0' && char <= '9') ||
243
+ char === '.' ||
244
+ char === 'e' ||
245
+ char === 'E' ||
246
+ char === '+' ||
247
+ char === '-') {
248
+ bytes += 1;
249
+ pendingHigh = false;
250
+ if (bytes > maxBytes) {
251
+ fail('too-large', maxBytes, `The stream is over its ${maxBytes}-byte budget.`);
252
+ return;
253
+ }
254
+ parts.push(char);
255
+ i += 1;
256
+ continue;
257
+ }
258
+ if (!finishNumber(events))
259
+ return;
260
+ // Fall through: this character is read as structure below.
261
+ }
262
+ else if (lexeme === 'literal') {
263
+ const current = literal;
264
+ const at = bytes;
265
+ const length0 = parts.length;
266
+ const char = chunk[i];
267
+ if (current.text[length0] !== char) {
268
+ fail('bad-json', at, `Unexpected "${char}" in "${current.text}".`);
269
+ return;
270
+ }
271
+ bytes += unitBytes(code);
272
+ parts.push(char);
273
+ i += 1;
274
+ if (parts.length === current.text.length) {
275
+ parts = [];
276
+ lexeme = 'none';
277
+ literal = null;
278
+ emitScalar(events, current.value);
279
+ }
280
+ continue;
281
+ }
282
+ // ── Structure ──
283
+ const at = bytes;
284
+ bytes += unitBytes(chunk.charCodeAt(i));
285
+ if (bytes > maxBytes) {
286
+ fail('too-large', maxBytes, `The stream is over its ${maxBytes}-byte budget.`);
287
+ return;
288
+ }
289
+ const char = chunk[i];
290
+ i += 1;
291
+ if (isWhitespace(chunk.charCodeAt(i - 1)))
292
+ continue;
293
+ switch (expect) {
294
+ case 'after':
295
+ fail('bad-json', at, `Unexpected "${char}" after the end of the value.`);
296
+ return;
297
+ case 'value':
298
+ if (!startValue(events, char, at))
299
+ return;
300
+ break;
301
+ case 'value-or-close':
302
+ if (char === ']')
303
+ closeContainer(events, 'array', at);
304
+ else if (!startValue(events, char, at))
305
+ return;
306
+ break;
307
+ case 'key':
308
+ case 'key-or-close':
309
+ if (char === '}' && expect === 'key-or-close') {
310
+ closeContainer(events, 'object', at);
311
+ }
312
+ else if (char === '"') {
313
+ tokenStart = at;
314
+ lexeme = 'string';
315
+ stringIsKey = true;
316
+ parts = [];
317
+ escaping = 0;
318
+ }
319
+ else {
320
+ fail('bad-json', at, `Expected a key in double quotes, got "${char}".`);
321
+ return;
322
+ }
323
+ break;
324
+ case 'colon':
325
+ if (char !== ':') {
326
+ fail('bad-json', at, `Expected ":" after a key, got "${char}".`);
327
+ return;
328
+ }
329
+ expect = 'value';
330
+ break;
331
+ case 'comma-or-close': {
332
+ const top = stack[stack.length - 1];
333
+ if (char === ',')
334
+ expect = top.kind === 'object' ? 'key' : 'value';
335
+ else if (char === (top.kind === 'object' ? '}' : ']'))
336
+ closeContainer(events, top.kind, at);
337
+ else {
338
+ fail('bad-json', at, `Expected "," or "${top.kind === 'object' ? '}' : ']'}", got "${char}".`);
339
+ return;
340
+ }
341
+ break;
342
+ }
343
+ }
344
+ }
345
+ }
346
+ return {
347
+ write(chunk) {
348
+ const events = [];
349
+ if (error !== null || ended || typeof chunk !== 'string' || chunk.length === 0)
350
+ return events;
351
+ run(chunk, events);
352
+ return events;
353
+ },
354
+ end() {
355
+ const events = [];
356
+ if (error !== null || ended)
357
+ return events;
358
+ ended = true;
359
+ if (lexeme === 'number')
360
+ finishNumber(events);
361
+ if (error !== null)
362
+ return events;
363
+ if (lexeme !== 'none' || stack.length > 0 || expect !== 'after') {
364
+ fail('truncated', bytes, 'The stream ended before the value was complete.');
365
+ return events;
366
+ }
367
+ done = true;
368
+ return events;
369
+ },
370
+ get bytes() {
371
+ return bytes;
372
+ },
373
+ get depth() {
374
+ return stack.length;
375
+ },
376
+ get error() {
377
+ return error;
378
+ },
379
+ get done() {
380
+ return done;
381
+ },
382
+ };
383
+ }
package/dist/validate.js CHANGED
@@ -275,9 +275,22 @@ export function validateTree(tree, catalog, options = {}) {
275
275
  const flow = options.flow ?? null;
276
276
  const root = options.root ?? { $defs: {} };
277
277
  const warn = (warning) => options.warnings?.push(warning);
278
+ // Spec 0.7.0 (ADR-014): a node key is unique in its tree; the schema checks its pattern.
279
+ const keys = new Set();
278
280
  const visit = (node, path, depth, parent, insideForm) => {
279
281
  if (!isRecord(node) || typeof node.id !== 'string' || depth > MAX_TREE_DEPTH)
280
282
  return;
283
+ if (typeof node.key === 'string' && SPEC_REGEX.nodeKey.test(node.key)) {
284
+ if (keys.has(node.key)) {
285
+ out.push({
286
+ code: 'duplicate-key',
287
+ path: `${path}.key`,
288
+ id: node.id,
289
+ message: `The key "${node.key.slice(0, 64)}" is already used in this tree.`,
290
+ });
291
+ }
292
+ keys.add(node.key);
293
+ }
281
294
  checkSlot(node.slot, parent, node.id, path, out);
282
295
  const component = byId.get(node.id);
283
296
  const context = reportInto({
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  /** The semantic version of the spec documents in this package. */
2
- export declare const SPEC_VERSION = "0.5.0";
2
+ export declare const SPEC_VERSION = "0.7.0";
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  /** The semantic version of the spec documents in this package. */
2
- export const SPEC_VERSION = '0.5.0';
2
+ export const SPEC_VERSION = '0.7.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@texuli/spec",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "The schema-first blueprint contract: JSON schemas, the catalog vocabulary, deterministic TypeScript and Kotlin projections, and the conformance harness.",
5
5
  "keywords": [
6
6
  "svelte",
@@ -42,6 +42,14 @@
42
42
  "./surface": {
43
43
  "types": "./dist/surface.d.ts",
44
44
  "default": "./dist/surface.js"
45
+ },
46
+ "./motion": {
47
+ "types": "./dist/motion.d.ts",
48
+ "default": "./dist/motion.js"
49
+ },
50
+ "./stream": {
51
+ "types": "./dist/stream/index.d.ts",
52
+ "default": "./dist/stream/index.js"
45
53
  }
46
54
  },
47
55
  "files": [