@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
package/dist/motion.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@texuli/spec/motion` — the closed motion vocabulary of spec 0.6.0
|
|
3
|
+
* (ADR-012, ADR-013) in code, mirrored from `schemas/blueprint.schema.json`
|
|
4
|
+
* (`tests/spec.test.ts` asserts they stay equal), and the one structural
|
|
5
|
+
* check every runtime shares: `@texuli/catalog` refuses with it, and
|
|
6
|
+
* `@texuli/player` paints only what it accepted.
|
|
7
|
+
*
|
|
8
|
+
* Agents name presets and token steps. Nothing here is CSS, keyframes, a
|
|
9
|
+
* duration in milliseconds or code. No imports at runtime.
|
|
10
|
+
*/
|
|
11
|
+
import type { BlueprintEnvelopeMotion, BlueprintMotionDuration, BlueprintMotionLevel, BlueprintMotionPreset, BlueprintMotionStagger, BlueprintNodeMotion } from './generated/typescript/blueprint.js';
|
|
12
|
+
export type { BlueprintEnvelopeMotion, BlueprintMotionDuration, BlueprintMotionLevel, BlueprintMotionPreset, BlueprintMotionStagger, BlueprintNodeMotion, };
|
|
13
|
+
/** Enter and exit presets (`$defs.motionPreset`). */
|
|
14
|
+
export declare const SPEC_MOTION_PRESETS: readonly BlueprintMotionPreset[];
|
|
15
|
+
/** Duration token steps (`$defs.motionDuration`); `base` when omitted. */
|
|
16
|
+
export declare const SPEC_MOTION_DURATIONS: readonly BlueprintMotionDuration[];
|
|
17
|
+
/** Stagger token steps between entering children (`$defs.motionStagger`). */
|
|
18
|
+
export declare const SPEC_MOTION_STAGGERS: readonly BlueprintMotionStagger[];
|
|
19
|
+
/** Motion ceilings (`$defs.motionLevel`), highest first. */
|
|
20
|
+
export declare const SPEC_MOTION_LEVELS: readonly BlueprintMotionLevel[];
|
|
21
|
+
/** The fields a node `motion` takes (`$defs.nodeMotion`). */
|
|
22
|
+
export declare const SPEC_NODE_MOTION_FIELDS: readonly string[];
|
|
23
|
+
export type SpecMotionCheck<T> = {
|
|
24
|
+
ok: true;
|
|
25
|
+
motion: Readonly<T>;
|
|
26
|
+
}
|
|
27
|
+
/** `path` is relative to the motion value (`""`, `.enter` …). */
|
|
28
|
+
| {
|
|
29
|
+
ok: false;
|
|
30
|
+
path: string;
|
|
31
|
+
message: string;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Check a node `motion` against the closed vocabulary. Returns a frozen
|
|
35
|
+
* copy with only the known fields, or the first refusal. Linear in the
|
|
36
|
+
* number of keys, and it stops at the first unknown one.
|
|
37
|
+
*/
|
|
38
|
+
export declare function checkNodeMotion(value: unknown): SpecMotionCheck<BlueprintNodeMotion>;
|
|
39
|
+
/** Check an envelope `motion` (`{ level }`). Returns a frozen copy, or the first refusal. */
|
|
40
|
+
export declare function checkEnvelopeMotion(value: unknown): SpecMotionCheck<BlueprintEnvelopeMotion>;
|
package/dist/motion.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@texuli/spec/motion` — the closed motion vocabulary of spec 0.6.0
|
|
3
|
+
* (ADR-012, ADR-013) in code, mirrored from `schemas/blueprint.schema.json`
|
|
4
|
+
* (`tests/spec.test.ts` asserts they stay equal), and the one structural
|
|
5
|
+
* check every runtime shares: `@texuli/catalog` refuses with it, and
|
|
6
|
+
* `@texuli/player` paints only what it accepted.
|
|
7
|
+
*
|
|
8
|
+
* Agents name presets and token steps. Nothing here is CSS, keyframes, a
|
|
9
|
+
* duration in milliseconds or code. No imports at runtime.
|
|
10
|
+
*/
|
|
11
|
+
/** Enter and exit presets (`$defs.motionPreset`). */
|
|
12
|
+
export const SPEC_MOTION_PRESETS = Object.freeze([
|
|
13
|
+
'fade',
|
|
14
|
+
'rise',
|
|
15
|
+
'scale',
|
|
16
|
+
'slide-start',
|
|
17
|
+
'slide-end',
|
|
18
|
+
]);
|
|
19
|
+
/** Duration token steps (`$defs.motionDuration`); `base` when omitted. */
|
|
20
|
+
export const SPEC_MOTION_DURATIONS = Object.freeze([
|
|
21
|
+
'fast',
|
|
22
|
+
'base',
|
|
23
|
+
'slow',
|
|
24
|
+
]);
|
|
25
|
+
/** Stagger token steps between entering children (`$defs.motionStagger`). */
|
|
26
|
+
export const SPEC_MOTION_STAGGERS = Object.freeze([
|
|
27
|
+
'none',
|
|
28
|
+
'fast',
|
|
29
|
+
'base',
|
|
30
|
+
]);
|
|
31
|
+
/** Motion ceilings (`$defs.motionLevel`), highest first. */
|
|
32
|
+
export const SPEC_MOTION_LEVELS = Object.freeze([
|
|
33
|
+
'full',
|
|
34
|
+
'subtle',
|
|
35
|
+
'none',
|
|
36
|
+
]);
|
|
37
|
+
/** The fields a node `motion` takes (`$defs.nodeMotion`). */
|
|
38
|
+
export const SPEC_NODE_MOTION_FIELDS = Object.freeze([
|
|
39
|
+
'enter',
|
|
40
|
+
'exit',
|
|
41
|
+
'layout',
|
|
42
|
+
'stagger',
|
|
43
|
+
'duration',
|
|
44
|
+
]);
|
|
45
|
+
const isRecord = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
46
|
+
/** A short, single-line label for a refused value: never the value itself in full. */
|
|
47
|
+
function describe(value) {
|
|
48
|
+
if (typeof value === 'string')
|
|
49
|
+
return JSON.stringify(value.slice(0, 24));
|
|
50
|
+
if (Array.isArray(value))
|
|
51
|
+
return 'an array';
|
|
52
|
+
return value === null ? 'null' : typeof value;
|
|
53
|
+
}
|
|
54
|
+
function oneOf(value, allowed, field) {
|
|
55
|
+
if (typeof value === 'string' && allowed.includes(value)) {
|
|
56
|
+
return { ok: true, value: value };
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
ok: false,
|
|
60
|
+
path: `.${field}`,
|
|
61
|
+
message: `"${field}" must be one of ${allowed.join(', ')} (got ${describe(value)}).`,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Check a node `motion` against the closed vocabulary. Returns a frozen
|
|
66
|
+
* copy with only the known fields, or the first refusal. Linear in the
|
|
67
|
+
* number of keys, and it stops at the first unknown one.
|
|
68
|
+
*/
|
|
69
|
+
export function checkNodeMotion(value) {
|
|
70
|
+
if (!isRecord(value)) {
|
|
71
|
+
return { ok: false, path: '', message: `"motion" must be an object (got ${describe(value)}).` };
|
|
72
|
+
}
|
|
73
|
+
const out = {};
|
|
74
|
+
for (const key of Object.keys(value)) {
|
|
75
|
+
if (!SPEC_NODE_MOTION_FIELDS.includes(key)) {
|
|
76
|
+
return {
|
|
77
|
+
ok: false,
|
|
78
|
+
path: `.${key.slice(0, 32)}`,
|
|
79
|
+
message: `"motion" takes only ${SPEC_NODE_MOTION_FIELDS.join(', ')}; no CSS, keyframes or code.`,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
const entry = value[key];
|
|
83
|
+
if (key === 'layout') {
|
|
84
|
+
if (typeof entry !== 'boolean') {
|
|
85
|
+
return {
|
|
86
|
+
ok: false,
|
|
87
|
+
path: '.layout',
|
|
88
|
+
message: `"layout" must be a boolean (got ${describe(entry)}).`,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
out.layout = entry;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (key === 'enter' || key === 'exit') {
|
|
95
|
+
const checked = oneOf(entry, SPEC_MOTION_PRESETS, key);
|
|
96
|
+
if (!checked.ok)
|
|
97
|
+
return checked;
|
|
98
|
+
out[key] = checked.value;
|
|
99
|
+
}
|
|
100
|
+
else if (key === 'stagger') {
|
|
101
|
+
const checked = oneOf(entry, SPEC_MOTION_STAGGERS, key);
|
|
102
|
+
if (!checked.ok)
|
|
103
|
+
return checked;
|
|
104
|
+
out.stagger = checked.value;
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
const checked = oneOf(entry, SPEC_MOTION_DURATIONS, key);
|
|
108
|
+
if (!checked.ok)
|
|
109
|
+
return checked;
|
|
110
|
+
out.duration = checked.value;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return { ok: true, motion: Object.freeze(out) };
|
|
114
|
+
}
|
|
115
|
+
/** Check an envelope `motion` (`{ level }`). Returns a frozen copy, or the first refusal. */
|
|
116
|
+
export function checkEnvelopeMotion(value) {
|
|
117
|
+
if (!isRecord(value)) {
|
|
118
|
+
return { ok: false, path: '', message: `"motion" must be an object (got ${describe(value)}).` };
|
|
119
|
+
}
|
|
120
|
+
for (const key of Object.keys(value)) {
|
|
121
|
+
if (key !== 'level') {
|
|
122
|
+
return {
|
|
123
|
+
ok: false,
|
|
124
|
+
path: `.${key.slice(0, 32)}`,
|
|
125
|
+
message: 'The envelope "motion" takes only "level".',
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const level = oneOf(value.level, SPEC_MOTION_LEVELS, 'level');
|
|
130
|
+
if (!level.ok)
|
|
131
|
+
return level;
|
|
132
|
+
return { ok: true, motion: Object.freeze({ level: level.value }) };
|
|
133
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { type JsonTokenizerOptions, type StreamError } from './tokenizer.js';
|
|
2
|
+
/**
|
|
3
|
+
* The partial-blueprint assembler (ADR-014): builds the value the tokenizer
|
|
4
|
+
* reads and reports, for every catalog node, when its **shell** is final.
|
|
5
|
+
* Streamed nodes put `children` last (`key`, `id`, `slot`, `props`, `when`,
|
|
6
|
+
* `bind`, `on`, `children`), so a node's shell (every member but `children`)
|
|
7
|
+
* is final once `children` starts, or once the node closes without it.
|
|
8
|
+
* Envelopes put `state` and `flow` before the tree, so the envelope header
|
|
9
|
+
* is final once `tree` starts. A member after `children` (or after `tree`)
|
|
10
|
+
* is a `stream-order` issue: the node it lands in is held until the final
|
|
11
|
+
* verdict. The assembler judges no rule itself; the catalog checks each
|
|
12
|
+
* shell and `validateDraft` judges the whole value.
|
|
13
|
+
*/
|
|
14
|
+
/** Members a streamed node may have, in the canonical order (`children` last). */
|
|
15
|
+
export declare const STREAM_NODE_ORDER: readonly string[];
|
|
16
|
+
/** A node's members except `children`. Values are the assembled ones (not copies). */
|
|
17
|
+
export type StreamNodeShell = Readonly<Record<string, unknown>>;
|
|
18
|
+
export type BlueprintStreamEvent =
|
|
19
|
+
/** An envelope's members before its tree (`final` once the tree starts; again for a late member). */
|
|
20
|
+
{
|
|
21
|
+
type: 'header';
|
|
22
|
+
header: Readonly<Record<string, unknown>>;
|
|
23
|
+
offset: number;
|
|
24
|
+
}
|
|
25
|
+
/** A node object opened. Its shell is not final yet. */
|
|
26
|
+
| {
|
|
27
|
+
type: 'node-start';
|
|
28
|
+
path: string;
|
|
29
|
+
parent: string | null;
|
|
30
|
+
index: number;
|
|
31
|
+
/** `true` for an element of an array (children, or an array of roots). */
|
|
32
|
+
inArray: boolean;
|
|
33
|
+
offset: number;
|
|
34
|
+
}
|
|
35
|
+
/** A node's `id` member closed (for a skeleton of the right family). */
|
|
36
|
+
| {
|
|
37
|
+
type: 'node-id';
|
|
38
|
+
path: string;
|
|
39
|
+
id: unknown;
|
|
40
|
+
offset: number;
|
|
41
|
+
}
|
|
42
|
+
/** A node's shell is final: every member but `children` arrived. */
|
|
43
|
+
| {
|
|
44
|
+
type: 'node';
|
|
45
|
+
path: string;
|
|
46
|
+
/** The node's stable stream id: its `key` when it has a string one, else its path. */
|
|
47
|
+
streamId: string;
|
|
48
|
+
parent: string | null;
|
|
49
|
+
index: number;
|
|
50
|
+
shell: StreamNodeShell;
|
|
51
|
+
/** `children` started (more nodes follow under this one). */
|
|
52
|
+
hasChildren: boolean;
|
|
53
|
+
/** `true` for an element of an array (children, or an array of roots). */
|
|
54
|
+
inArray: boolean;
|
|
55
|
+
offset: number;
|
|
56
|
+
}
|
|
57
|
+
/** A node object closed: all its children arrived. */
|
|
58
|
+
| {
|
|
59
|
+
type: 'node-end';
|
|
60
|
+
path: string;
|
|
61
|
+
offset: number;
|
|
62
|
+
}
|
|
63
|
+
/** A member after `children` (node) or after the tree (envelope, path `$`). */
|
|
64
|
+
| {
|
|
65
|
+
type: 'stream-order';
|
|
66
|
+
path: string;
|
|
67
|
+
key: string;
|
|
68
|
+
offset: number;
|
|
69
|
+
}
|
|
70
|
+
/** The whole value arrived and the stream ended cleanly. */
|
|
71
|
+
| {
|
|
72
|
+
type: 'complete';
|
|
73
|
+
value: unknown;
|
|
74
|
+
offset: number;
|
|
75
|
+
}
|
|
76
|
+
/** The stream stopped: nothing more is reported. */
|
|
77
|
+
| {
|
|
78
|
+
type: 'error';
|
|
79
|
+
error: StreamError;
|
|
80
|
+
};
|
|
81
|
+
export interface BlueprintAssemblerOptions extends JsonTokenizerOptions {
|
|
82
|
+
/**
|
|
83
|
+
* Root members that hold the tree. Default `['tree']` (the blueprint
|
|
84
|
+
* envelope). A host that also streams another wrapper (the player's
|
|
85
|
+
* local-state wrapper uses `blueprint`) adds its member here.
|
|
86
|
+
*/
|
|
87
|
+
treeMembers?: readonly string[];
|
|
88
|
+
}
|
|
89
|
+
export interface BlueprintAssembler {
|
|
90
|
+
/** Feed a chunk; returns what it completed. */
|
|
91
|
+
write(chunk: string): BlueprintStreamEvent[];
|
|
92
|
+
/** No more input. Returns `complete` or the `truncated` error. */
|
|
93
|
+
end(): BlueprintStreamEvent[];
|
|
94
|
+
/** Stop with an `aborted` or `connection` error (a dropped stream). */
|
|
95
|
+
abort(code?: 'aborted' | 'connection', message?: string): BlueprintStreamEvent[];
|
|
96
|
+
readonly bytes: number;
|
|
97
|
+
readonly error: StreamError | null;
|
|
98
|
+
/** The value assembled so far (containers still open are partial). */
|
|
99
|
+
readonly value: unknown;
|
|
100
|
+
}
|
|
101
|
+
export declare function createBlueprintAssembler(options?: BlueprintAssemblerOptions): BlueprintAssembler;
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { createJsonTokenizer, } from "./tokenizer.js";
|
|
2
|
+
/**
|
|
3
|
+
* The partial-blueprint assembler (ADR-014): builds the value the tokenizer
|
|
4
|
+
* reads and reports, for every catalog node, when its **shell** is final.
|
|
5
|
+
* Streamed nodes put `children` last (`key`, `id`, `slot`, `props`, `when`,
|
|
6
|
+
* `bind`, `on`, `children`), so a node's shell (every member but `children`)
|
|
7
|
+
* is final once `children` starts, or once the node closes without it.
|
|
8
|
+
* Envelopes put `state` and `flow` before the tree, so the envelope header
|
|
9
|
+
* is final once `tree` starts. A member after `children` (or after `tree`)
|
|
10
|
+
* is a `stream-order` issue: the node it lands in is held until the final
|
|
11
|
+
* verdict. The assembler judges no rule itself; the catalog checks each
|
|
12
|
+
* shell and `validateDraft` judges the whole value.
|
|
13
|
+
*/
|
|
14
|
+
/** Members a streamed node may have, in the canonical order (`children` last). */
|
|
15
|
+
export const STREAM_NODE_ORDER = Object.freeze([
|
|
16
|
+
'key',
|
|
17
|
+
'id',
|
|
18
|
+
'slot',
|
|
19
|
+
'props',
|
|
20
|
+
'when',
|
|
21
|
+
'bind',
|
|
22
|
+
'on',
|
|
23
|
+
'motion',
|
|
24
|
+
'children',
|
|
25
|
+
]);
|
|
26
|
+
/** Node path of child `index` under `parent` (`null` for a root). */
|
|
27
|
+
function childPath(parent, index) {
|
|
28
|
+
return parent === null ? String(index) : `${parent}.children.${index}`;
|
|
29
|
+
}
|
|
30
|
+
export function createBlueprintAssembler(options = {}) {
|
|
31
|
+
const tokenizer = createJsonTokenizer(options);
|
|
32
|
+
const treeMembers = new Set(options.treeMembers ?? ['tree']);
|
|
33
|
+
const stack = [];
|
|
34
|
+
let root;
|
|
35
|
+
let rootSet = false;
|
|
36
|
+
let error = null;
|
|
37
|
+
let finished = false;
|
|
38
|
+
// `true` when the root object turned out to be an envelope or wrapper.
|
|
39
|
+
let rootIsEnvelope = false;
|
|
40
|
+
const header = (frame) => {
|
|
41
|
+
const out = {};
|
|
42
|
+
for (const [key, value] of Object.entries(frame.container)) {
|
|
43
|
+
if (!treeMembers.has(key))
|
|
44
|
+
out[key] = value;
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
};
|
|
48
|
+
const shellOf = (frame) => {
|
|
49
|
+
const out = {};
|
|
50
|
+
for (const [key, value] of Object.entries(frame.container)) {
|
|
51
|
+
if (key !== 'children')
|
|
52
|
+
out[key] = value;
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
};
|
|
56
|
+
const nodeEvent = (frame, parent, index, hasChildren, offset) => {
|
|
57
|
+
const shell = shellOf(frame);
|
|
58
|
+
const key = shell.key;
|
|
59
|
+
return {
|
|
60
|
+
type: 'node',
|
|
61
|
+
path: frame.path,
|
|
62
|
+
streamId: typeof key === 'string' && key !== '' ? key : frame.path,
|
|
63
|
+
parent,
|
|
64
|
+
index,
|
|
65
|
+
shell,
|
|
66
|
+
hasChildren,
|
|
67
|
+
inArray: frame.inArray,
|
|
68
|
+
offset,
|
|
69
|
+
};
|
|
70
|
+
};
|
|
71
|
+
/** Where a node sits: its parent path and index, from its path. */
|
|
72
|
+
const position = (path) => {
|
|
73
|
+
const at = path.lastIndexOf('.children.');
|
|
74
|
+
if (at === -1)
|
|
75
|
+
return { parent: null, index: Number(path) };
|
|
76
|
+
return { parent: path.slice(0, at), index: Number(path.slice(at + '.children.'.length)) };
|
|
77
|
+
};
|
|
78
|
+
/** Attach a closed value to the container on top of the stack (or make it the root). */
|
|
79
|
+
function attach(value, out, offset) {
|
|
80
|
+
const top = stack[stack.length - 1];
|
|
81
|
+
if (!top) {
|
|
82
|
+
root = value;
|
|
83
|
+
rootSet = true;
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (Array.isArray(top.container)) {
|
|
87
|
+
top.container.push(value);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const key = top.key;
|
|
91
|
+
top.container[key] = value;
|
|
92
|
+
top.key = null;
|
|
93
|
+
if (top.role === 'node' && key === 'id' && !top.final) {
|
|
94
|
+
out.push({ type: 'node-id', path: top.path, id: value, offset });
|
|
95
|
+
}
|
|
96
|
+
else if (top.role === 'root' && rootIsEnvelope && top.final && !treeMembers.has(key)) {
|
|
97
|
+
// A late envelope member (after the tree) changes the header the shells were checked with.
|
|
98
|
+
out.push({ type: 'header', header: header(top), offset });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** The role and path a new container gets from where it opens. */
|
|
102
|
+
function open(kind, out, offset) {
|
|
103
|
+
const container = kind === 'object' ? {} : [];
|
|
104
|
+
const top = stack[stack.length - 1];
|
|
105
|
+
const frame = {
|
|
106
|
+
role: 'value',
|
|
107
|
+
container,
|
|
108
|
+
key: null,
|
|
109
|
+
path: null,
|
|
110
|
+
final: false,
|
|
111
|
+
inArray: false,
|
|
112
|
+
};
|
|
113
|
+
if (!top) {
|
|
114
|
+
// The top level: an object is an envelope, a wrapper or a root node; an array holds roots.
|
|
115
|
+
if (kind === 'object') {
|
|
116
|
+
frame.role = 'root';
|
|
117
|
+
frame.path = '0';
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
frame.role = 'children';
|
|
121
|
+
frame.path = null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
else if (top.role === 'children' && kind === 'object') {
|
|
125
|
+
const index = top.container.length;
|
|
126
|
+
frame.role = 'node';
|
|
127
|
+
frame.path = childPath(top.path, index);
|
|
128
|
+
frame.inArray = true;
|
|
129
|
+
out.push({
|
|
130
|
+
type: 'node-start',
|
|
131
|
+
path: frame.path,
|
|
132
|
+
parent: top.path,
|
|
133
|
+
index,
|
|
134
|
+
inArray: true,
|
|
135
|
+
offset,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
else if (top.role === 'node' && top.key === 'children' && kind === 'array') {
|
|
139
|
+
frame.role = 'children';
|
|
140
|
+
frame.path = top.path;
|
|
141
|
+
}
|
|
142
|
+
else if (top.role === 'root' && top.key !== null && treeMembers.has(top.key)) {
|
|
143
|
+
// The envelope's tree: one root node at `0`, or an array of roots.
|
|
144
|
+
if (kind === 'object') {
|
|
145
|
+
frame.role = 'node';
|
|
146
|
+
frame.path = '0';
|
|
147
|
+
out.push({ type: 'node-start', path: '0', parent: null, index: 0, inArray: false, offset });
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
frame.role = 'children';
|
|
151
|
+
frame.path = null;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
else if (top.role === 'root' &&
|
|
155
|
+
top.key === 'children' &&
|
|
156
|
+
!rootIsEnvelope &&
|
|
157
|
+
kind === 'array') {
|
|
158
|
+
frame.role = 'children';
|
|
159
|
+
frame.path = '0';
|
|
160
|
+
}
|
|
161
|
+
stack.push(frame);
|
|
162
|
+
}
|
|
163
|
+
function onKey(key, out, offset) {
|
|
164
|
+
const top = stack[stack.length - 1];
|
|
165
|
+
top.key = key;
|
|
166
|
+
if (top.role === 'root') {
|
|
167
|
+
if (top.final) {
|
|
168
|
+
out.push({ type: 'stream-order', path: rootIsEnvelope ? '$' : '0', key, offset });
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (treeMembers.has(key)) {
|
|
172
|
+
top.final = true;
|
|
173
|
+
rootIsEnvelope = true;
|
|
174
|
+
top.path = null;
|
|
175
|
+
out.push({ type: 'header', header: header(top), offset });
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (key === 'children') {
|
|
179
|
+
// The root is a node: say so (late) and report its shell.
|
|
180
|
+
top.final = true;
|
|
181
|
+
out.push({ type: 'node-start', path: '0', parent: null, index: 0, inArray: false, offset });
|
|
182
|
+
out.push(nodeEvent(top, null, 0, true, offset));
|
|
183
|
+
}
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (top.role !== 'node')
|
|
187
|
+
return;
|
|
188
|
+
if (top.final) {
|
|
189
|
+
out.push({ type: 'stream-order', path: top.path, key, offset });
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (key === 'children') {
|
|
193
|
+
top.final = true;
|
|
194
|
+
const { parent, index } = position(top.path);
|
|
195
|
+
out.push(nodeEvent(top, parent, index, true, offset));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
function onClose(out, offset) {
|
|
199
|
+
const frame = stack.pop();
|
|
200
|
+
if (frame.role === 'node' || (frame.role === 'root' && !rootIsEnvelope)) {
|
|
201
|
+
const path = frame.path;
|
|
202
|
+
if (!frame.final) {
|
|
203
|
+
if (frame.role === 'root') {
|
|
204
|
+
const record = frame.container;
|
|
205
|
+
// A childless top-level object is a node unless it reads as an envelope.
|
|
206
|
+
if (typeof record.format !== 'string') {
|
|
207
|
+
out.push({ type: 'node-start', path, parent: null, index: 0, inArray: false, offset });
|
|
208
|
+
out.push(nodeEvent(frame, null, 0, false, offset));
|
|
209
|
+
out.push({ type: 'node-end', path, offset });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
const { parent, index } = position(path);
|
|
214
|
+
out.push(nodeEvent(frame, parent, index, false, offset));
|
|
215
|
+
out.push({ type: 'node-end', path, offset });
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
out.push({ type: 'node-end', path, offset });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
attach(frame.container, out, offset);
|
|
223
|
+
}
|
|
224
|
+
function apply(events, out) {
|
|
225
|
+
for (const event of events) {
|
|
226
|
+
if (event.type === 'open')
|
|
227
|
+
open(event.kind, out, event.offset);
|
|
228
|
+
else if (event.type === 'key')
|
|
229
|
+
onKey(event.key, out, event.offset);
|
|
230
|
+
else if (event.type === 'value')
|
|
231
|
+
attach(event.value, out, event.offset);
|
|
232
|
+
else
|
|
233
|
+
onClose(out, event.offset);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
function settle(out) {
|
|
237
|
+
if (error === null && tokenizer.error !== null) {
|
|
238
|
+
error = tokenizer.error;
|
|
239
|
+
out.push({ type: 'error', error });
|
|
240
|
+
}
|
|
241
|
+
return out;
|
|
242
|
+
}
|
|
243
|
+
return {
|
|
244
|
+
write(chunk) {
|
|
245
|
+
if (error !== null || finished)
|
|
246
|
+
return [];
|
|
247
|
+
const out = [];
|
|
248
|
+
apply(tokenizer.write(chunk), out);
|
|
249
|
+
return settle(out);
|
|
250
|
+
},
|
|
251
|
+
end() {
|
|
252
|
+
if (error !== null || finished)
|
|
253
|
+
return [];
|
|
254
|
+
const out = [];
|
|
255
|
+
apply(tokenizer.end(), out);
|
|
256
|
+
settle(out);
|
|
257
|
+
if (error === null && tokenizer.done && rootSet) {
|
|
258
|
+
finished = true;
|
|
259
|
+
out.push({ type: 'complete', value: root, offset: tokenizer.bytes });
|
|
260
|
+
}
|
|
261
|
+
return out;
|
|
262
|
+
},
|
|
263
|
+
abort(code = 'aborted', message) {
|
|
264
|
+
if (error !== null || finished)
|
|
265
|
+
return [];
|
|
266
|
+
error = {
|
|
267
|
+
code,
|
|
268
|
+
offset: tokenizer.bytes,
|
|
269
|
+
message: message ??
|
|
270
|
+
(code === 'aborted' ? 'The stream was aborted.' : 'The stream connection dropped.'),
|
|
271
|
+
};
|
|
272
|
+
return [{ type: 'error', error }];
|
|
273
|
+
},
|
|
274
|
+
get bytes() {
|
|
275
|
+
return tokenizer.bytes;
|
|
276
|
+
},
|
|
277
|
+
get error() {
|
|
278
|
+
return error;
|
|
279
|
+
},
|
|
280
|
+
get value() {
|
|
281
|
+
return rootSet ? root : stack[0]?.container;
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@texuli/spec/stream` — streamed blueprints (ADR-014): an incremental JSON
|
|
3
|
+
* tokenizer with a byte budget and a depth cap, the partial-blueprint
|
|
4
|
+
* assembler that reports when each node's shell is final, and a push handle
|
|
5
|
+
* hosts write chunks into. Framework-free: no DOM, no Svelte, no Node APIs.
|
|
6
|
+
* Nothing here validates a node; `@texuli/catalog/validate`
|
|
7
|
+
* (`validateStreamNode`, then `validateDraft`) does.
|
|
8
|
+
*/
|
|
9
|
+
export { type BlueprintAssembler, type BlueprintAssemblerOptions, type BlueprintStreamEvent, createBlueprintAssembler, STREAM_NODE_ORDER, type StreamNodeShell, } from './assembler.js';
|
|
10
|
+
export { BlueprintStreamAbortError, type BlueprintStreamHandle, createBlueprintStream, } from './source.js';
|
|
11
|
+
export { createJsonTokenizer, DEFAULT_STREAM_MAX_BYTES, DEFAULT_STREAM_MAX_DEPTH, type JsonScalar, type JsonStreamEvent, type JsonTokenizer, type JsonTokenizerOptions, STREAM_PROTOTYPE_KEYS, type StreamError, type StreamErrorCode, } from './tokenizer.js';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@texuli/spec/stream` — streamed blueprints (ADR-014): an incremental JSON
|
|
3
|
+
* tokenizer with a byte budget and a depth cap, the partial-blueprint
|
|
4
|
+
* assembler that reports when each node's shell is final, and a push handle
|
|
5
|
+
* hosts write chunks into. Framework-free: no DOM, no Svelte, no Node APIs.
|
|
6
|
+
* Nothing here validates a node; `@texuli/catalog/validate`
|
|
7
|
+
* (`validateStreamNode`, then `validateDraft`) does.
|
|
8
|
+
*/
|
|
9
|
+
export { createBlueprintAssembler, STREAM_NODE_ORDER, } from "./assembler.js";
|
|
10
|
+
export { BlueprintStreamAbortError, createBlueprintStream, } from "./source.js";
|
|
11
|
+
export { createJsonTokenizer, DEFAULT_STREAM_MAX_BYTES, DEFAULT_STREAM_MAX_DEPTH, STREAM_PROTOTYPE_KEYS, } from "./tokenizer.js";
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A push handle for a blueprint stream (ADR-014): the host writes text as it
|
|
3
|
+
* arrives (an agent's tool deltas, server-sent events) and a reader such as
|
|
4
|
+
* `<Player stream={handle}>` iterates it. One reader per handle. `abort()`
|
|
5
|
+
* makes the reader's iteration throw a {@link BlueprintStreamAbortError};
|
|
6
|
+
* chunks written before it are still read first.
|
|
7
|
+
*/
|
|
8
|
+
export interface BlueprintStreamHandle extends AsyncIterable<string> {
|
|
9
|
+
/** Queue the next chunk. Ignored after `end()` or `abort()`. */
|
|
10
|
+
write(text: string): void;
|
|
11
|
+
/** The stream is complete: the reader finishes after the queued chunks. */
|
|
12
|
+
end(): void;
|
|
13
|
+
/** The stream stopped early: the reader throws after the queued chunks. */
|
|
14
|
+
abort(reason?: string): void;
|
|
15
|
+
/** `true` after `end()` or `abort()`. */
|
|
16
|
+
readonly closed: boolean;
|
|
17
|
+
}
|
|
18
|
+
/** What a reader of an aborted {@link BlueprintStreamHandle} throws. */
|
|
19
|
+
export declare class BlueprintStreamAbortError extends Error {
|
|
20
|
+
readonly name = "AbortError";
|
|
21
|
+
}
|
|
22
|
+
export declare function createBlueprintStream(): BlueprintStreamHandle;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A push handle for a blueprint stream (ADR-014): the host writes text as it
|
|
3
|
+
* arrives (an agent's tool deltas, server-sent events) and a reader such as
|
|
4
|
+
* `<Player stream={handle}>` iterates it. One reader per handle. `abort()`
|
|
5
|
+
* makes the reader's iteration throw a {@link BlueprintStreamAbortError};
|
|
6
|
+
* chunks written before it are still read first.
|
|
7
|
+
*/
|
|
8
|
+
/** What a reader of an aborted {@link BlueprintStreamHandle} throws. */
|
|
9
|
+
export class BlueprintStreamAbortError extends Error {
|
|
10
|
+
name = 'AbortError';
|
|
11
|
+
}
|
|
12
|
+
export function createBlueprintStream() {
|
|
13
|
+
const queue = [];
|
|
14
|
+
let state = 'open';
|
|
15
|
+
let reason = 'The stream was aborted.';
|
|
16
|
+
let wake = null;
|
|
17
|
+
const notify = () => {
|
|
18
|
+
const resume = wake;
|
|
19
|
+
wake = null;
|
|
20
|
+
resume?.();
|
|
21
|
+
};
|
|
22
|
+
return {
|
|
23
|
+
write(text) {
|
|
24
|
+
if (state !== 'open' || typeof text !== 'string' || text === '')
|
|
25
|
+
return;
|
|
26
|
+
queue.push(text);
|
|
27
|
+
notify();
|
|
28
|
+
},
|
|
29
|
+
end() {
|
|
30
|
+
if (state !== 'open')
|
|
31
|
+
return;
|
|
32
|
+
state = 'ended';
|
|
33
|
+
notify();
|
|
34
|
+
},
|
|
35
|
+
abort(message) {
|
|
36
|
+
if (state !== 'open')
|
|
37
|
+
return;
|
|
38
|
+
state = 'aborted';
|
|
39
|
+
if (typeof message === 'string' && message !== '')
|
|
40
|
+
reason = message;
|
|
41
|
+
notify();
|
|
42
|
+
},
|
|
43
|
+
get closed() {
|
|
44
|
+
return state !== 'open';
|
|
45
|
+
},
|
|
46
|
+
[Symbol.asyncIterator]() {
|
|
47
|
+
return {
|
|
48
|
+
async next() {
|
|
49
|
+
while (queue.length === 0 && state === 'open') {
|
|
50
|
+
await new Promise((resolve) => {
|
|
51
|
+
wake = resolve;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
const chunk = queue.shift();
|
|
55
|
+
if (chunk !== undefined)
|
|
56
|
+
return { value: chunk, done: false };
|
|
57
|
+
if (state === 'aborted')
|
|
58
|
+
throw new BlueprintStreamAbortError(reason);
|
|
59
|
+
return { value: undefined, done: true };
|
|
60
|
+
},
|
|
61
|
+
async return() {
|
|
62
|
+
// The reader stopped listening: drop what is queued.
|
|
63
|
+
queue.length = 0;
|
|
64
|
+
if (state === 'open')
|
|
65
|
+
state = 'ended';
|
|
66
|
+
notify();
|
|
67
|
+
return { value: undefined, done: true };
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|