@enmaq/mdgl 0.0.0-stage → 0.3.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,231 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.invalidUtf8 = exports.MAX_REQUEST_LINE = void 0;
4
+ exports.mathInt = mathInt;
5
+ exports.handleLine = handleLine;
6
+ // Conformance adapter core (PROTOCOL v1): one request line in, one response line out.
7
+ // Pure string -> string; bin/mdgl-adapter.js wires stdin/stdout. JSON numbers are kept as
8
+ // lexemes and validated as exact mathematical integers (9007199254740991.1 rejects).
9
+ const check_1 = require("./check");
10
+ const eval_1 = require("./eval");
11
+ exports.MAX_REQUEST_LINE = 2097152;
12
+ class Bad extends Error {
13
+ }
14
+ function parseJson(t) {
15
+ let i = 0, lone = false, depth = 0;
16
+ const ws = () => { while (i < t.length && (t[i] === ' ' || t[i] === '\t' || t[i] === '\n' || t[i] === '\r'))
17
+ i++; };
18
+ const lit = (w, v) => { if (!t.startsWith(w, i))
19
+ throw new Bad(); i += w.length; return v; };
20
+ const str = () => {
21
+ i++;
22
+ let o = '';
23
+ for (;;) {
24
+ if (i >= t.length)
25
+ throw new Bad();
26
+ const c = t[i];
27
+ if (c === '"') {
28
+ i++;
29
+ return o;
30
+ }
31
+ if (c < ' ')
32
+ throw new Bad();
33
+ if (c !== '\\') {
34
+ o += c;
35
+ i++;
36
+ continue;
37
+ }
38
+ const e = t[i + 1];
39
+ const m = { '"': '"', '\\': '\\', '/': '/', b: '\b', f: '\f', n: '\n', r: '\r', t: '\t' };
40
+ if (e in m) {
41
+ o += m[e];
42
+ i += 2;
43
+ }
44
+ else if (e === 'u') {
45
+ const h = t.slice(i + 2, i + 6);
46
+ if (!/^[0-9a-fA-F]{4}$/.test(h))
47
+ throw new Bad();
48
+ const cu = parseInt(h, 16);
49
+ if (cu >= 0xd800 && cu < 0xdc00) {
50
+ const lo = /^\\u([dD][c-fC-F][0-9a-fA-F]{2})/.exec(t.slice(i + 6, i + 12));
51
+ if (lo) {
52
+ o += String.fromCharCode(cu, parseInt(lo[1], 16));
53
+ i += 12;
54
+ continue;
55
+ }
56
+ lone = true;
57
+ }
58
+ else if (cu >= 0xdc00 && cu < 0xe000)
59
+ lone = true;
60
+ o += String.fromCharCode(cu);
61
+ i += 6;
62
+ }
63
+ else
64
+ throw new Bad();
65
+ }
66
+ };
67
+ const val = () => {
68
+ ws();
69
+ const c = t[i];
70
+ if (++depth > 200)
71
+ throw new Bad();
72
+ let r;
73
+ if (c === '{') {
74
+ i++;
75
+ const o = {};
76
+ ws();
77
+ if (t[i] === '}')
78
+ i++;
79
+ else
80
+ for (;;) {
81
+ ws();
82
+ if (t[i] !== '"')
83
+ throw new Bad();
84
+ const k = str();
85
+ ws();
86
+ if (t[i++] !== ':')
87
+ throw new Bad();
88
+ o[k] = val();
89
+ ws();
90
+ if (t[i] === ',') {
91
+ i++;
92
+ continue;
93
+ }
94
+ if (t[i++] === '}')
95
+ break;
96
+ throw new Bad();
97
+ }
98
+ r = o;
99
+ }
100
+ else if (c === '[') {
101
+ i++;
102
+ const a = [];
103
+ ws();
104
+ if (t[i] === ']')
105
+ i++;
106
+ else
107
+ for (;;) {
108
+ a.push(val());
109
+ ws();
110
+ if (t[i] === ',') {
111
+ i++;
112
+ continue;
113
+ }
114
+ if (t[i++] === ']')
115
+ break;
116
+ throw new Bad();
117
+ }
118
+ r = a;
119
+ }
120
+ else if (c === '"')
121
+ r = str();
122
+ else if (c === 't')
123
+ r = lit('true', true);
124
+ else if (c === 'f')
125
+ r = lit('false', false);
126
+ else if (c === 'n')
127
+ r = lit('null', null);
128
+ else {
129
+ const m = /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/.exec(t.slice(i, i + 4096));
130
+ if (!m)
131
+ throw new Bad();
132
+ i += m[0].length;
133
+ r = { num: m[0] };
134
+ }
135
+ depth--;
136
+ return r;
137
+ };
138
+ const v = val();
139
+ ws();
140
+ if (i !== t.length)
141
+ throw new Bad();
142
+ return { v, lone };
143
+ }
144
+ /** Exact mathematical integer value of a JSON number lexeme, or null if it is not an integer. */
145
+ function mathInt(lex) {
146
+ const m = /^(-?)(\d+)(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/.exec(lex);
147
+ if (!m)
148
+ return null;
149
+ const frac = m[3] ?? '';
150
+ const exp = Number(m[4] ?? '0') - frac.length;
151
+ let d = BigInt(m[2] + frac);
152
+ if (d === 0n)
153
+ return 0n;
154
+ if (exp > 400)
155
+ return null; // far beyond any accepted range; treat as out of range by callers
156
+ if (exp >= 0)
157
+ d *= 10n ** BigInt(exp);
158
+ else {
159
+ const p = 10n ** BigInt(-exp);
160
+ if (-exp > 4000 || d % p !== 0n)
161
+ return null;
162
+ d /= p;
163
+ }
164
+ return m[1] ? -d : d;
165
+ }
166
+ const isNum = (v) => typeof v === 'object' && v !== null && !Array.isArray(v) && 'num' in v && typeof v.num === 'string';
167
+ function intIn(v, lo, hi) {
168
+ if (!isNum(v))
169
+ return null;
170
+ const n = mathInt(v.num);
171
+ return n === null || n < lo || n > hi ? null : Number(n);
172
+ }
173
+ const out = (o) => JSON.stringify(o);
174
+ const fail = (id, code, message, extra) => out({ v: 1, id, ok: false, error: { code, message, ...extra } });
175
+ /** bad_request response for a line whose bytes are not valid UTF-8 (the id is unrecoverable). */
176
+ const invalidUtf8 = () => fail(null, 'bad_request', 'request line is not valid UTF-8');
177
+ exports.invalidUtf8 = invalidUtf8;
178
+ /** Handle one request line (without its newline). Always returns exactly one response line. */
179
+ function handleLine(line, lineBytes = line.length) {
180
+ if (lineBytes > exports.MAX_REQUEST_LINE)
181
+ return fail(null, 'bad_request', 'request line too long');
182
+ let top, lone;
183
+ try {
184
+ ({ v: top, lone } = parseJson(line));
185
+ }
186
+ catch (e) {
187
+ return fail(null, 'bad_request', line.trim() === '' ? 'blank line' : 'invalid JSON');
188
+ }
189
+ if (typeof top !== 'object' || top === null || Array.isArray(top) || isNum(top))
190
+ return fail(null, 'bad_request', 'request must be a JSON object');
191
+ const o = top;
192
+ const id = typeof o.id === 'string' ? o.id : null;
193
+ if (!isNum(o.v) || mathInt(o.v.num) === null)
194
+ return fail(id, 'bad_request', 'v must be an integer');
195
+ if (mathInt(o.v.num) !== 1n)
196
+ return fail(id, 'unsupported_version', `protocol version ${o.v.num} is not supported`);
197
+ if (id === null)
198
+ return fail(null, 'bad_request', 'id must be a string');
199
+ if (o.op !== 'check' && o.op !== 'frame')
200
+ return fail(id, 'bad_request', 'op must be "check" or "frame"');
201
+ if (typeof o.source !== 'string')
202
+ return fail(id, 'bad_request', 'source must be a string');
203
+ if (lone)
204
+ return fail(id, 'bad_request', 'source contains a lone surrogate');
205
+ let width = 0, height = 0, tNs = 0, fb = false;
206
+ if (o.op === 'frame') {
207
+ const w = intIn(o.width, 1n, 200n), h = intIn(o.height, 1n, 60n);
208
+ if (w === null || h === null)
209
+ return fail(id, 'bad_request', 'width and height must be integers in 1..200 and 1..60');
210
+ width = w;
211
+ height = h;
212
+ if ('fallback' in o && typeof o.fallback !== 'boolean')
213
+ return fail(id, 'bad_request', 'fallback must be a boolean');
214
+ fb = o.fallback === true;
215
+ if (!fb) {
216
+ const t = intIn(o.time_ns, 0n, 9007199254740991n);
217
+ if (t === null)
218
+ return fail(id, 'bad_request', 'time_ns must be an integer in 0..2^53-1');
219
+ tNs = t;
220
+ }
221
+ }
222
+ const r = (0, check_1.parseChecked)(o.source);
223
+ if (o.op === 'check')
224
+ return out({ v: 1, id, ok: true, diagnostics: r.ok ? [] : r.diagnostics });
225
+ if (!r.ok)
226
+ return fail(id, 'invalid_source', 'source has diagnostics', { diagnostics: r.diagnostics });
227
+ const sc = (0, eval_1.compileDocument)(r.doc);
228
+ const f = fb ? (0, eval_1.fallbackFrame)(sc, width, height) : (0, eval_1.frameNs)(sc, tNs, width, height);
229
+ const cells = f.cells.map((row) => row.map((c) => ({ r: c.r, fg: c.fg, bg: c.bg })));
230
+ return out(fb ? { v: 1, id, ok: true, width: f.width, height: f.height, cells } : { v: 1, id, ok: true, width: f.width, height: f.height, cells, done: f.done });
231
+ }
@@ -0,0 +1,48 @@
1
+ import { ActionStmt, ActorStmt, Diag, Document } from './parse';
2
+ export declare const MAX_ACTORS = 64;
3
+ export declare const MAX_ACTIONS = 1024;
4
+ export type Fallback = {
5
+ kind: 'start';
6
+ } | {
7
+ kind: 'end';
8
+ } | {
9
+ kind: 'at';
10
+ ns: number;
11
+ };
12
+ export interface CheckedScene {
13
+ line: number;
14
+ width: number;
15
+ height: number;
16
+ fps: number;
17
+ /** explicit period in ns, if given */
18
+ period?: number;
19
+ /** repeat count; Infinity for `infinite` */
20
+ repeat: number;
21
+ fallback: Fallback;
22
+ /** simple duration in ns: explicit period, else greatest action end */
23
+ duration: number;
24
+ }
25
+ export interface CheckedAction extends ActionStmt {
26
+ beginNs: number;
27
+ endNs: number;
28
+ property: string;
29
+ durNs: number;
30
+ }
31
+ export interface CheckedDocument {
32
+ scene: CheckedScene;
33
+ actors: ActorStmt[];
34
+ actions: CheckedAction[];
35
+ }
36
+ export type ParseResult = {
37
+ ok: true;
38
+ doc: CheckedDocument;
39
+ } | {
40
+ ok: false;
41
+ diagnostics: Diag[];
42
+ };
43
+ /** Check a parsed document; returns the checked model or the first diagnostic. */
44
+ export declare function checkDocument(doc: Document): ParseResult;
45
+ /** Parse and check; returns the checked, source-located scene model or diagnostics. */
46
+ export declare function parseChecked(source: string): ParseResult;
47
+ /** Compat API: `[]` means the source is valid; otherwise the first diagnostic. */
48
+ export declare function check(source: string): Diag[];
package/dist/check.js ADDED
@@ -0,0 +1,374 @@
1
+ "use strict";
2
+ // MDGL 0.1 semantic checker over the parsed Document. Statement-order, reference,
3
+ // argument, timing and limit rules from SPEC.md. First error is the compat
4
+ // diagnostic (`line:col: message`); checking stops at the first error.
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.MAX_ACTIONS = exports.MAX_ACTORS = void 0;
7
+ exports.checkDocument = checkDocument;
8
+ exports.parseChecked = parseChecked;
9
+ exports.check = check;
10
+ const unicode_1 = require("./unicode");
11
+ const parse_1 = require("./parse");
12
+ exports.MAX_ACTORS = 64;
13
+ exports.MAX_ACTIONS = 1024;
14
+ const MAX_NS = 60e9;
15
+ const SCENE_KEYS = ['width', 'height', 'period', 'repeat', 'fallback', 'fps'];
16
+ const COLORS = ['normal', 'black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white', 'accent'];
17
+ const EASES = ['linear', 'ease', 'ease-in', 'ease-out', 'ease-in-out', 'bounce'];
18
+ const VERBS = ['fade', 'move', 'scale', 'color', 'type', 'blink', 'wipe', 'slide', 'rotate'];
19
+ const PROP = { fade: 'opacity', blink: 'opacity', move: 'position', slide: 'position', scale: 'scale', color: 'color', type: 'reveal', wipe: 'reveal', rotate: 'rotation' };
20
+ class Stop {
21
+ constructor(d) {
22
+ this.d = d;
23
+ }
24
+ }
25
+ const q = (s) => JSON.stringify(s);
26
+ function lev(a, b) {
27
+ const p = Array.from({ length: b.length + 1 }, (_, i) => i);
28
+ for (let i = 1; i <= a.length; i++) {
29
+ let prev = p[0];
30
+ p[0] = i;
31
+ for (let j = 1; j <= b.length; j++) {
32
+ const t = p[j];
33
+ p[j] = Math.min(p[j] + 1, p[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
34
+ prev = t;
35
+ }
36
+ }
37
+ return p[b.length];
38
+ }
39
+ /** `; did you mean "x"?` when a candidate is within edit distance 2 (ties: lexical order). */
40
+ function suggest(name, cands) {
41
+ let best, bd = 3;
42
+ for (const c of [...cands].sort()) {
43
+ const d = lev(name, c);
44
+ if (d <= 2 && d < bd) {
45
+ best = c;
46
+ bd = d;
47
+ }
48
+ }
49
+ return best === undefined ? '' : `; did you mean ${q(best)}?`;
50
+ }
51
+ function checkScene(sc) {
52
+ const fail = (m) => { throw new Stop({ line: sc.line, col: sc.col, message: m }); };
53
+ const v = new Map();
54
+ for (const a of sc.attrs) {
55
+ if (!SCENE_KEYS.includes(a.key))
56
+ fail(`unknown scene attribute ${q(a.key)}${suggest(a.key, SCENE_KEYS)}`);
57
+ if (v.has(a.key))
58
+ fail(`duplicate scene attribute ${q(a.key)}`);
59
+ v.set(a.key, a.value);
60
+ }
61
+ for (const k of ['width', 'height', 'fallback'])
62
+ if (!v.has(k))
63
+ fail(`missing scene attribute ${k}`);
64
+ const int = (k, lo, hi, def) => {
65
+ const s = v.get(k) ?? def;
66
+ if (s === undefined || !/^[0-9]{1,9}$/.test(s) || Number(s) < lo || Number(s) > hi)
67
+ fail(`${k} must be an integer in ${lo}..${hi}`);
68
+ return Number(s);
69
+ };
70
+ const width = int('width', 1, 200), height = int('height', 1, 60), fps = int('fps', 1, 30, '30');
71
+ let period;
72
+ if (v.has('period')) {
73
+ const m = /^((?:[0-9]+\.?[0-9]*|\.[0-9]+))s?$/.exec(v.get('period'));
74
+ period = m ? (0, parse_1.decimalToNs)(m[1]) : NaN;
75
+ if (!(period > 0) || period > MAX_NS)
76
+ fail('period must be positive seconds up to 60');
77
+ }
78
+ let repeat = 1;
79
+ const r = v.get('repeat');
80
+ if (r === 'infinite')
81
+ repeat = Infinity;
82
+ else if (r !== undefined)
83
+ repeat = int('repeat', 1, 1000);
84
+ if (repeat !== 1 && period === undefined)
85
+ fail('repeat requires explicit period');
86
+ const f = v.get('fallback');
87
+ let fallback;
88
+ if (f === 'start' || f === 'end')
89
+ fallback = { kind: f };
90
+ else {
91
+ const m = /^@((?:[0-9]+\.?[0-9]*|\.[0-9]+))s?$/.exec(f);
92
+ if (!m)
93
+ return fail('fallback must be start, end or @time');
94
+ fallback = { kind: 'at', ns: (0, parse_1.decimalToNs)(m[1]) };
95
+ }
96
+ return { line: sc.line, width, height, fps, period, repeat, fallback, duration: period ?? 0 };
97
+ }
98
+ function checkCoord(c, axis, fail) {
99
+ if (c.kind === 'num') {
100
+ if (!Number.isFinite(c.value) || Math.abs(c.value) > 10000)
101
+ fail('coordinate out of range');
102
+ return;
103
+ }
104
+ const ok = axis === 'x' ? ['center', 'left', 'right'] : ['center', 'top', 'bottom'];
105
+ if (!ok.includes(c.name))
106
+ fail(`anchor ${c.name} is not valid on the ${axis} axis`);
107
+ if (Math.abs(c.offset) > 10000)
108
+ fail('coordinate out of range');
109
+ }
110
+ function checkPoint(p, fail) { checkCoord(p.x, 'x', fail); checkCoord(p.y, 'y', fail); }
111
+ function checkActor(a, names) {
112
+ const fail = (m) => { throw new Stop({ line: a.line, col: a.col, message: m }); };
113
+ if (names.has(a.name))
114
+ fail(`duplicate actor ${a.name}`);
115
+ const s = a.shape;
116
+ const pos = (n, what, max = 200) => { if (!(n > 0 && n <= max))
117
+ fail(`${what} must be greater than 0 and at most ${max}`); };
118
+ const label = (t, what) => {
119
+ if (/[\x00-\x1f\x7f-\x9f]/.test(t))
120
+ fail(`${what} must not contain tabs, newlines or control characters`);
121
+ if ((0, unicode_1.graphemeCount)(t) > 1024)
122
+ fail(`${what} exceeds 1024 grapheme clusters`);
123
+ };
124
+ if (s.kind === 'text')
125
+ label(s.text, 'text');
126
+ else if (s.kind === 'rect') {
127
+ pos(s.width, 'rect width');
128
+ pos(s.height, 'rect height');
129
+ if (s.title !== undefined)
130
+ label(s.title, 'title');
131
+ }
132
+ else if (s.kind === 'circle') {
133
+ if (!(s.radius > 0 && s.radius <= 100))
134
+ fail('circle radius must be positive and <=100');
135
+ } // diameter 2N <= 200;
136
+ else
137
+ pos(s.size, 'cube size');
138
+ checkPoint(a.at, fail);
139
+ if (a.anchor)
140
+ checkPoint(a.anchor, fail);
141
+ if (a.color !== undefined && !COLORS.includes(a.color))
142
+ fail(`unknown color ${q(a.color)}${suggest(a.color, COLORS)}`);
143
+ if (a.z !== undefined && !Number.isInteger(a.z))
144
+ fail('z must be an integer'); // SPEC: optional integer z; no other bound
145
+ }
146
+ function checkParents(actors) {
147
+ const by = new Map(actors.map((a) => [a.name, a]));
148
+ for (const a of actors) {
149
+ if (a.parent !== undefined && !by.has(a.parent))
150
+ throw new Stop({ line: a.line, col: a.col, message: `unknown parent ${q(a.parent)}${suggest(a.parent, [...by.keys()])}` });
151
+ }
152
+ for (const a of actors) {
153
+ const seen = new Set();
154
+ for (let c = a; c; c = c.parent === undefined ? undefined : by.get(c.parent)) {
155
+ if (seen.has(c.name)) {
156
+ if (c === a)
157
+ throw new Stop({ line: a.line, col: a.col, message: `parent cycle involving ${a.name}` });
158
+ break;
159
+ }
160
+ seen.add(c.name);
161
+ }
162
+ }
163
+ }
164
+ function checkAction(a, actors) {
165
+ const fail = (m) => { throw new Stop({ line: a.line, col: a.col, message: m }); };
166
+ const actor = actors.get(a.actor);
167
+ if (!actor)
168
+ return fail(`unknown actor ${q(a.actor)}${suggest(a.actor, [...actors.keys()])}`);
169
+ if (!VERBS.includes(a.verb))
170
+ return fail(`unknown action ${q(a.verb)}${suggest(a.verb, VERBS)}`);
171
+ if (a.timeNs > MAX_NS)
172
+ fail('action time must be at most 60 seconds');
173
+ const allowed = {
174
+ fade: ['in', 'out', 'dur', 'ease'], move: ['to', 'dur', 'ease'], scale: ['to', 'dur', 'ease'], color: ['to', 'dur', 'ease'],
175
+ type: ['per_char'], blink: ['times', 'dur'], wipe: ['in', 'out', 'dur', 'ease'], slide: ['in', 'out', 'to', 'dur', 'ease'],
176
+ rotate: ['x', 'y', 'z', 'dur', 'ease'],
177
+ };
178
+ const args = new Map();
179
+ for (const g of a.args) {
180
+ if (!allowed[a.verb].includes(g.key))
181
+ fail(`unknown argument ${q(g.key)} for ${a.verb}${suggest(g.key, allowed[a.verb])}`);
182
+ if (args.has(g.key))
183
+ fail(`duplicate argument ${g.key}`);
184
+ args.set(g.key, g);
185
+ }
186
+ const need = (k) => {
187
+ const g = args.get(k);
188
+ if (!g)
189
+ return fail(`${a.verb} requires ${k}`);
190
+ if (!g.value)
191
+ return fail(`${k} requires a value`);
192
+ return g.value;
193
+ };
194
+ const flag = (k) => { const g = args.get(k); if (g && g.value)
195
+ fail(`${k} takes no value`); return !!g; };
196
+ const seconds = (k, v, what) => {
197
+ if (v.kind !== 'number' || !(v.suffix === '' || v.suffix === 's') || v.value < 0 || !/^[+]?[0-9.]+s?$/.test(v.raw))
198
+ return fail(`${what} must be positive seconds`);
199
+ const ns = (0, parse_1.decimalToNs)(v.raw.replace(/^\+/, '').replace(/s$/, ''));
200
+ if (!(ns > 0))
201
+ return fail(`${what} must be positive`);
202
+ if (ns > MAX_NS)
203
+ fail(`${what} must be at most 60 seconds`);
204
+ return ns;
205
+ };
206
+ let durNs = 0;
207
+ if (a.verb !== 'type') {
208
+ if (a.verb === 'rotate' || a.verb === 'fade' || a.verb === 'move' || a.verb === 'scale' || a.verb === 'color' || a.verb === 'wipe' || a.verb === 'slide' || a.verb === 'blink')
209
+ durNs = seconds('dur', need('dur'), 'dur');
210
+ }
211
+ const ease = args.get('ease');
212
+ if (ease) {
213
+ const e = need('ease');
214
+ if (e.kind !== 'keyword' || !EASES.includes(e.value))
215
+ fail(`unknown ease ${q(e.raw)}${suggest(e.raw, EASES)}`);
216
+ }
217
+ const inout = () => { const i = flag('in'), o = flag('out'); if (i === o)
218
+ fail(`${a.verb} requires exactly one of in or out`); };
219
+ const num = (k, lo, msg) => { const v = need(k); if (v.kind !== 'number' || v.suffix !== '')
220
+ fail(`${k} must be a number`); const n = v.value; if (!lo(n))
221
+ fail(msg); return n; };
222
+ switch (a.verb) {
223
+ case 'fade':
224
+ inout();
225
+ break;
226
+ case 'wipe':
227
+ inout();
228
+ break;
229
+ case 'move': {
230
+ const v = need('to');
231
+ if (v.kind !== 'point')
232
+ fail('to must be a point');
233
+ checkPoint(v.value, fail);
234
+ break;
235
+ }
236
+ case 'scale':
237
+ num('to', (n) => n > 0 && n <= 16, 'scale must be greater than 0 and at most 16');
238
+ break;
239
+ case 'color': {
240
+ const v = need('to');
241
+ if (v.kind !== 'keyword' || !COLORS.includes(v.value))
242
+ fail(`unknown color ${q(v.raw)}${suggest(v.raw, COLORS)}`);
243
+ break;
244
+ }
245
+ case 'blink':
246
+ num('times', (n) => Number.isInteger(n) && n > 0, 'times must be a positive integer');
247
+ break;
248
+ case 'slide': {
249
+ inout();
250
+ const v = need('to');
251
+ if (v.kind !== 'keyword' || !['left', 'right', 'top', 'bottom'].includes(v.value))
252
+ fail('slide to must be left, right, top or bottom');
253
+ break;
254
+ }
255
+ case 'rotate': {
256
+ if (actor.shape.kind !== 'cube')
257
+ fail('rotate is only valid on cube actors');
258
+ if (!['x', 'y', 'z'].some((k) => args.has(k)))
259
+ fail('rotate requires at least one of x, y, z');
260
+ for (const k of ['x', 'y', 'z']) {
261
+ if (!args.has(k))
262
+ continue;
263
+ const v = need(k);
264
+ if (v.kind !== 'number' || !['', 'deg', 'rad'].includes(v.suffix))
265
+ fail(`${k} must be an angle in degrees or radians`);
266
+ const deg = v.kind === 'number' ? (v.suffix === 'rad' ? (v.value * 180) / Math.PI : v.value) : 0;
267
+ if (Math.abs(deg) > 10000)
268
+ fail('rotation out of range');
269
+ }
270
+ break;
271
+ }
272
+ case 'type': {
273
+ const sh = actor.shape;
274
+ if (sh.kind !== 'text')
275
+ return fail('type is only valid on text actors');
276
+ const per = seconds('per_char', need('per_char'), 'per_char');
277
+ durNs = per * (0, unicode_1.graphemeCount)(sh.text);
278
+ break;
279
+ }
280
+ }
281
+ const endNs = a.timeNs + durNs;
282
+ if (endNs > MAX_NS)
283
+ fail('action end must be at most 60 seconds');
284
+ return { ...a, beginNs: a.timeNs, endNs, property: PROP[a.verb], durNs };
285
+ }
286
+ /** Check a parsed document; returns the checked model or the first diagnostic. */
287
+ function checkDocument(doc) {
288
+ const stop = (d) => ({ ok: false, diagnostics: [d] });
289
+ if (doc.inputError)
290
+ return stop(doc.inputError);
291
+ let scene;
292
+ let sawAction = false, sawContent = false;
293
+ const actors = [];
294
+ const names = new Map();
295
+ const actions = [];
296
+ let parentsChecked = false;
297
+ try {
298
+ for (const s of doc.statements) {
299
+ const fail = (m) => { throw new Stop({ line: s.line, col: s.col, message: m }); };
300
+ switch (s.kind) {
301
+ case 'version':
302
+ if (sawContent || !s.ok)
303
+ fail('expected initial mdgl 0.1');
304
+ break;
305
+ case 'unknown':
306
+ fail('unsupported statement (expected mdgl, scene, actor or @)');
307
+ break;
308
+ case 'scene':
309
+ if (scene)
310
+ fail('duplicate scene');
311
+ sawContent = true;
312
+ scene = checkScene(s);
313
+ break;
314
+ case 'actor':
315
+ sawContent = true;
316
+ if (!scene)
317
+ fail('scene must precede actors');
318
+ if (sawAction)
319
+ fail('actors must precede actions');
320
+ if (actors.length >= exports.MAX_ACTORS)
321
+ fail('actor limit 64 exceeded');
322
+ checkActor(s, new Set(names.keys()));
323
+ actors.push(s);
324
+ names.set(s.name, s);
325
+ break;
326
+ case 'action': {
327
+ sawContent = true;
328
+ if (!scene)
329
+ fail('scene must precede actions');
330
+ if (!parentsChecked) {
331
+ checkParents(actors);
332
+ parentsChecked = true;
333
+ }
334
+ sawAction = true;
335
+ if (actions.length >= exports.MAX_ACTIONS)
336
+ fail('action limit 1024 exceeded');
337
+ const ca = checkAction(s, names);
338
+ for (const p of actions) {
339
+ if (p.actor === ca.actor && p.property === ca.property && p.beginNs < ca.endNs && ca.beginNs < p.endNs)
340
+ fail(`overlapping ${ca.property} actions on ${ca.actor}`);
341
+ }
342
+ actions.push(ca);
343
+ break;
344
+ }
345
+ }
346
+ }
347
+ if (!parentsChecked)
348
+ checkParents(actors);
349
+ if (doc.syntax)
350
+ throw new Stop(doc.syntax);
351
+ if (!scene)
352
+ throw new Stop({ line: 1, col: 1, message: 'missing scene' });
353
+ const end = actions.reduce((m, a) => Math.max(m, a.endNs), 0);
354
+ scene.duration = scene.period ?? end;
355
+ if (scene.fallback.kind === 'at' && scene.fallback.ns > scene.duration)
356
+ throw new Stop({ line: scene.line, col: 1, message: 'fallback time exceeds the simple duration' });
357
+ return { ok: true, doc: { scene, actors, actions } };
358
+ }
359
+ catch (e) {
360
+ if (e instanceof Stop)
361
+ return stop(e.d);
362
+ throw e;
363
+ }
364
+ }
365
+ /** Parse and check; returns the checked, source-located scene model or diagnostics. */
366
+ function parseChecked(source) { return checkDocument((0, parse_1.parse)(source)); }
367
+ /** Compat API: `[]` means the source is valid; otherwise the first diagnostic. */
368
+ function check(source) {
369
+ if (typeof source !== 'string')
370
+ throw new TypeError('mdgl.check: source must be a string');
371
+ const r = parseChecked(source);
372
+ return r.ok ? [] : r.diagnostics;
373
+ }
374
+ void parse_1.bytes;