@enmaq/mdgl 0.2.0 → 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.
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;
package/dist/eval.d.ts ADDED
@@ -0,0 +1,53 @@
1
+ import { CheckedAction, CheckedDocument } from './check';
2
+ import { Diag, Point } from './parse';
3
+ export interface Cell {
4
+ r: string;
5
+ fg: string;
6
+ bg: string;
7
+ }
8
+ export interface Frame {
9
+ width: number;
10
+ height: number;
11
+ cells: Cell[][];
12
+ done?: boolean;
13
+ }
14
+ interface Act {
15
+ name: string;
16
+ index: number;
17
+ z: number;
18
+ parent: Act | null;
19
+ shape: CheckedDocument['actors'][number]['shape'];
20
+ w: number;
21
+ h: number;
22
+ clusters: string[];
23
+ atPoint: Point;
24
+ anchorPoint?: Point;
25
+ color: string;
26
+ acts: CheckedAction[];
27
+ }
28
+ export interface Compiled {
29
+ width: number;
30
+ height: number;
31
+ fps: number;
32
+ /** simple duration in ns */
33
+ durationNs: number;
34
+ periodNs?: number;
35
+ repeat: number;
36
+ fallback: CheckedDocument['scene']['fallback'];
37
+ actors: Act[];
38
+ order: Act[];
39
+ }
40
+ export type CompileResult = {
41
+ ok: true;
42
+ scene: Compiled;
43
+ } | {
44
+ ok: false;
45
+ diagnostics: Diag[];
46
+ };
47
+ export declare function compileDocument(doc: CheckedDocument): Compiled;
48
+ export declare function compile(source: string): CompileResult;
49
+ /** Frame at absolute time `tNs` (integer nanoseconds). `Done` per SPEC; repeat semantics included. */
50
+ export declare function frameNs(sc: Compiled, tNs: number, width?: number, height?: number): Frame;
51
+ /** Fallback sample of the simple timeline (no repeat); `done` is omitted. */
52
+ export declare function fallbackFrame(sc: Compiled, width?: number, height?: number): Frame;
53
+ export {};