@texuli/spec 0.5.0 → 0.6.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.
@@ -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/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.6.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.6.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@texuli/spec",
3
- "version": "0.5.0",
3
+ "version": "0.6.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": [