@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.
- package/README.md +55 -11
- package/dist/build.d.ts +8 -0
- package/dist/build.js +8 -0
- package/dist/codegen/index.d.ts +1 -1
- package/dist/codegen/index.js +1 -1
- package/dist/conformance.d.ts +1 -1
- package/dist/conformance.js +4 -3
- package/dist/contract.d.ts +8 -1
- package/dist/contract.js +8 -0
- package/dist/generated/typescript/blueprint.d.ts +98 -5
- package/dist/generated/typescript/blueprint.js +1 -1
- package/dist/generated/typescript/catalog-schema.js +1 -1
- package/dist/generated/typescript/diagnostics.d.ts +1 -1
- package/dist/generated/typescript/diagnostics.js +1 -1
- package/dist/generated/typescript/manifest.js +1 -1
- package/dist/generated/typescript/vocabulary.d.ts +7 -2
- package/dist/generated/typescript/vocabulary.js +3 -2
- package/dist/generated/typescript/workflow.js +1 -1
- package/dist/migrations/0.5.0-0.6.0.d.ts +9 -0
- package/dist/migrations/0.5.0-0.6.0.js +12 -0
- package/dist/migrations/0.6.0-0.7.0.d.ts +10 -0
- package/dist/migrations/0.6.0-0.7.0.js +13 -0
- package/dist/migrations/index.js +4 -0
- package/dist/motion.d.ts +40 -0
- package/dist/motion.js +133 -0
- package/dist/stream/assembler.d.ts +101 -0
- package/dist/stream/assembler.js +284 -0
- package/dist/stream/index.d.ts +11 -0
- package/dist/stream/index.js +11 -0
- package/dist/stream/source.d.ts +22 -0
- package/dist/stream/source.js +72 -0
- package/dist/stream/tokenizer.d.ts +67 -0
- package/dist/stream/tokenizer.js +383 -0
- package/dist/validate.js +13 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- 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.
|
|
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.
|
|
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.
|
|
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": [
|