@enmaq/mdgl 0.3.0 → 0.4.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/dom.d.ts ADDED
@@ -0,0 +1,25 @@
1
+ export declare const MDGL_COLORS: readonly ["normal", "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white", "accent"];
2
+ export type MDGLColor = typeof MDGL_COLORS[number];
3
+ export interface RenderOptions {
4
+ /** Omit to follow prefers-reduced-motion, including changes while mounted. */
5
+ reducedMotion?: boolean;
6
+ /** Reject larger stages; default and hard ceiling: 12000 cells. */
7
+ maxCells?: number;
8
+ /** Default 30; hard ceiling 60. The scene's own fps may be lower. */
9
+ maxFps?: number;
10
+ palette?: Partial<Record<MDGLColor, string>>;
11
+ autoplay?: boolean;
12
+ onError?: (message: string) => void;
13
+ }
14
+ export interface RenderHandle {
15
+ /** Permanent stop; retain the current frame. */
16
+ stop(): void;
17
+ /** Pause/resume without advancing scene time while paused. */
18
+ pause(): void;
19
+ play(): void;
20
+ /** Stop and remove this player's output. Safe after replacement. */
21
+ destroy(): void;
22
+ readonly error: string | null;
23
+ }
24
+ /** Browser-only entry point. Importing this module itself is safe outside a browser. */
25
+ export declare function renderToElement(el: HTMLElement, source: string, opts?: RenderOptions): RenderHandle;
package/dist/dom.js ADDED
@@ -0,0 +1,206 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MDGL_COLORS = void 0;
4
+ exports.renderToElement = renderToElement;
5
+ const eval_1 = require("./eval");
6
+ exports.MDGL_COLORS = ['normal', 'black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white', 'accent'];
7
+ const owners = new WeakMap();
8
+ function color(value, background = false) {
9
+ if (/^#[0-9a-f]{6}$/i.test(value))
10
+ return value;
11
+ if (exports.MDGL_COLORS.includes(value))
12
+ return `var(--mdgl-${value})`;
13
+ return background ? 'transparent' : 'var(--mdgl-normal)';
14
+ }
15
+ /** Paint evaluator cells verbatim, grouping only adjacent equal colors. Never parse HTML. */
16
+ function paint(pre, frame) {
17
+ const doc = pre.ownerDocument;
18
+ const fragment = doc.createDocumentFragment();
19
+ frame.cells.forEach((row, y) => {
20
+ let x = 0;
21
+ while (x < row.length) {
22
+ const first = row[x];
23
+ let text = first.r;
24
+ let end = x + 1;
25
+ while (end < row.length && row[end].fg === first.fg && row[end].bg === first.bg)
26
+ text += row[end++].r;
27
+ const span = doc.createElement('span');
28
+ span.textContent = text;
29
+ span.dataset.mdglFg = first.fg;
30
+ span.dataset.mdglBg = first.bg;
31
+ span.style.color = color(first.fg);
32
+ span.style.backgroundColor = color(first.bg, true);
33
+ fragment.appendChild(span);
34
+ x = end;
35
+ }
36
+ if (y + 1 < frame.height)
37
+ fragment.appendChild(doc.createTextNode('\n'));
38
+ });
39
+ pre.replaceChildren(fragment);
40
+ }
41
+ function cap(value, fallback, ceiling, name) {
42
+ if (value === undefined)
43
+ return fallback;
44
+ if (!Number.isFinite(value) || value < 1)
45
+ throw new Error(`${name} must be a finite number >= 1`);
46
+ return Math.min(Math.floor(value), ceiling);
47
+ }
48
+ /** Browser-only entry point. Importing this module itself is safe outside a browser. */
49
+ function renderToElement(el, source, opts = {}) {
50
+ owners.get(el)?.stop();
51
+ const doc = el.ownerDocument;
52
+ const win = doc.defaultView;
53
+ const pre = doc.createElement('pre');
54
+ pre.className = 'mdgl-frame';
55
+ pre.setAttribute('aria-live', 'off');
56
+ el.replaceChildren(pre);
57
+ let error = null;
58
+ let stopped = false, paused = opts.autoplay === false, finished = false, started = false;
59
+ let request = null;
60
+ let media;
61
+ let reduced = opts.reducedMotion === true;
62
+ let elapsed = 0, origin = 0, lastPaint = -Infinity;
63
+ let resume = () => { };
64
+ let mediaChange = () => { };
65
+ const now = () => win?.performance.now() ?? 0;
66
+ const cancel = () => {
67
+ if (request !== null)
68
+ win?.cancelAnimationFrame(request);
69
+ request = null;
70
+ };
71
+ const stop = () => {
72
+ stopped = true;
73
+ cancel();
74
+ media?.removeEventListener('change', mediaChange);
75
+ if (owners.get(el) === handle)
76
+ owners.delete(el);
77
+ };
78
+ const handle = {
79
+ stop,
80
+ pause() {
81
+ if (stopped || paused)
82
+ return;
83
+ if (!reduced && !finished)
84
+ elapsed = Math.max(elapsed, now() - origin);
85
+ paused = true;
86
+ cancel();
87
+ },
88
+ play() {
89
+ if (stopped || !paused)
90
+ return;
91
+ paused = false;
92
+ try {
93
+ resume();
94
+ }
95
+ catch (e) {
96
+ fail(e instanceof Error ? e.message : String(e));
97
+ }
98
+ },
99
+ destroy() { stop(); pre.remove(); },
100
+ get error() { return error; },
101
+ };
102
+ owners.set(el, handle);
103
+ const fail = (message) => {
104
+ error = message;
105
+ stop();
106
+ pre.classList.add('mdgl-error');
107
+ pre.textContent = `${source}\n\nMDGL: ${message}`;
108
+ // Consumer error reporting must not turn a rejected scene into a page exception.
109
+ try {
110
+ opts.onError?.(message);
111
+ }
112
+ catch { /* callback belongs to the host */ }
113
+ };
114
+ try {
115
+ const maxCells = cap(opts.maxCells, 12000, 12000, 'maxCells');
116
+ const maxFps = cap(opts.maxFps, 30, 60, 'maxFps');
117
+ const result = (0, eval_1.compile)(source);
118
+ if (!result.ok) {
119
+ fail(result.diagnostics.map(d => `${d.line}:${d.col}: ${d.message}`).join('\n'));
120
+ return handle;
121
+ }
122
+ const scene = result.scene;
123
+ if (scene.width * scene.height > maxCells) {
124
+ fail(`stage has ${scene.width * scene.height} cells; maxCells is ${maxCells}`);
125
+ return handle;
126
+ }
127
+ for (const name of exports.MDGL_COLORS) {
128
+ const value = opts.palette?.[name];
129
+ if (value !== undefined)
130
+ pre.style.setProperty(`--mdgl-${name}`, value);
131
+ }
132
+ if (opts.reducedMotion === undefined && win?.matchMedia) {
133
+ media = win.matchMedia('(prefers-reduced-motion: reduce)');
134
+ reduced = media.matches;
135
+ }
136
+ // A headless/nonvisual document has no animation clock: render the declared fallback.
137
+ reduced || (reduced = !win?.requestAnimationFrame);
138
+ const interval = 1000 / Math.min(scene.fps, maxFps);
139
+ const draw = (time) => {
140
+ const frame = (0, eval_1.frameNs)(scene, Math.min(Number.MAX_SAFE_INTEGER, Math.floor(time * 1e6)));
141
+ paint(pre, frame);
142
+ finished = frame.done === true;
143
+ lastPaint = time;
144
+ started = true;
145
+ };
146
+ const tick = () => {
147
+ request = null;
148
+ if (stopped || paused || reduced || finished)
149
+ return;
150
+ try {
151
+ elapsed = Math.max(elapsed, now() - origin);
152
+ if (elapsed - lastPaint + 1e-7 >= interval)
153
+ draw(elapsed);
154
+ if (!finished)
155
+ request = win.requestAnimationFrame(tick);
156
+ }
157
+ catch (e) {
158
+ fail(e instanceof Error ? e.message : String(e));
159
+ }
160
+ };
161
+ resume = () => {
162
+ if (stopped || reduced || paused || finished)
163
+ return;
164
+ if (!started)
165
+ draw(elapsed);
166
+ if (finished)
167
+ return;
168
+ origin = now() - elapsed;
169
+ if (request === null)
170
+ request = win.requestAnimationFrame(tick);
171
+ };
172
+ mediaChange = () => {
173
+ if (stopped)
174
+ return;
175
+ try {
176
+ const next = media.matches;
177
+ if (next === reduced)
178
+ return;
179
+ if (!paused && !finished && !reduced)
180
+ elapsed = Math.max(elapsed, now() - origin);
181
+ reduced = next;
182
+ cancel();
183
+ if (reduced)
184
+ paint(pre, (0, eval_1.fallbackFrame)(scene));
185
+ else if (started || !paused) {
186
+ draw(elapsed);
187
+ resume();
188
+ }
189
+ }
190
+ catch (e) {
191
+ fail(e instanceof Error ? e.message : String(e));
192
+ }
193
+ };
194
+ media?.addEventListener('change', mediaChange);
195
+ if (reduced || paused)
196
+ paint(pre, (0, eval_1.fallbackFrame)(scene));
197
+ else {
198
+ draw(0);
199
+ resume();
200
+ }
201
+ }
202
+ catch (e) {
203
+ fail(e instanceof Error ? e.message : String(e));
204
+ }
205
+ return handle;
206
+ }
package/dist/eval.js CHANGED
@@ -274,8 +274,10 @@ class Grid {
274
274
  if (col < 0 || row < 0 || col >= this.w || row >= this.h || r === ' ' || alpha <= 0)
275
275
  return;
276
276
  const c = this.cells[row][col];
277
+ // source-over onto the cell's current colour: the glyph already there if any, else the (black) background
278
+ const dest = c.r !== ' ' ? c.fg : c.bg;
277
279
  c.r = r;
278
- c.fg = alpha >= 1 ? color : blend(color, alpha, c.bg);
280
+ c.fg = alpha >= 1 ? color : blend(color, alpha, dest);
279
281
  }
280
282
  }
281
283
  const q9 = (v) => Math.round(v * 1e9) / 1e9;
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.RESET = exports.SHOW_CURSOR = exports.HIDE_CURSOR = exports.SYNC_END = exports.SYNC_BEGIN = exports.sanitizeRune = exports.plainText = exports.plainFallback = exports.play = exports.MAX_REQUEST_LINE = exports.mathInt = exports.handleLine = exports.fallbackFrame = exports.frameNs = exports.compileDocument = exports.compile = exports.graphemes = exports.graphemeRanges = exports.graphemeCount = exports.clusterWidth = exports.cellRune = exports.UNICODE_VERSION = exports.parse = exports.parseChecked = exports.checkDocument = exports.check = exports.VERSION = void 0;
4
4
  // @enmaq/mdgl: MDGL 0.1 parser and checker (development tree; evaluator and
5
5
  // rendering are not implemented yet). Spec: ../SPEC.md
6
- exports.VERSION = '0.3.0';
6
+ exports.VERSION = '0.4.0';
7
7
  var check_1 = require("./check");
8
8
  Object.defineProperty(exports, "check", { enumerable: true, get: function () { return check_1.check; } });
9
9
  Object.defineProperty(exports, "checkDocument", { enumerable: true, get: function () { return check_1.checkDocument; } });
package/dist/parse.js CHANGED
@@ -6,9 +6,10 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.bytes = exports.MAX_LINE = exports.MAX_INPUT = void 0;
7
7
  exports.decimalToNs = decimalToNs;
8
8
  exports.parse = parse;
9
+ const utf8 = new TextEncoder();
9
10
  exports.MAX_INPUT = 256 * 1024;
10
11
  exports.MAX_LINE = 16384;
11
- const bytes = (s) => Buffer.byteLength(s, 'utf8');
12
+ const bytes = (s) => utf8.encode(s).length;
12
13
  exports.bytes = bytes;
13
14
  class SyntaxErr {
14
15
  constructor(d) {
@@ -0,0 +1,2 @@
1
+ /** Register once, explicitly, without reading browser globals at module import time. */
2
+ export declare function registerMDGLPlayer(): void;
package/dist/player.js ADDED
@@ -0,0 +1,77 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerMDGLPlayer = registerMDGLPlayer;
4
+ const dom_1 = require("./dom");
5
+ /** Register once, explicitly, without reading browser globals at module import time. */
6
+ function registerMDGLPlayer() {
7
+ if (typeof customElements === 'undefined' || typeof HTMLElement === 'undefined')
8
+ return;
9
+ if (customElements.get('mdgl-player'))
10
+ return;
11
+ class MDGLPlayer extends HTMLElement {
12
+ constructor() {
13
+ super(...arguments);
14
+ this.revision = 0;
15
+ this.queued = false;
16
+ }
17
+ static get observedAttributes() { return ['src', 'reduced-motion', 'max-cells', 'max-fps']; }
18
+ /** Use this property for programmatic inline-source updates after connection. */
19
+ get source() { return this.inline ?? this.textContent ?? ''; }
20
+ set source(value) { this.inline = value; this.schedule(); }
21
+ connectedCallback() { this.schedule(); }
22
+ disconnectedCallback() {
23
+ this.revision++;
24
+ this.pending?.abort();
25
+ this.playback?.stop();
26
+ }
27
+ attributeChangedCallback() { this.schedule(); }
28
+ schedule() {
29
+ this.revision++;
30
+ this.pending?.abort();
31
+ this.playback?.stop();
32
+ if (this.queued)
33
+ return;
34
+ this.queued = true;
35
+ queueMicrotask(() => {
36
+ this.queued = false;
37
+ if (this.isConnected)
38
+ void this.load();
39
+ });
40
+ }
41
+ async load() {
42
+ if (this.inline === undefined)
43
+ this.inline = this.textContent ?? '';
44
+ const revision = this.revision;
45
+ const src = this.getAttribute('src');
46
+ let source = this.inline;
47
+ try {
48
+ if (src !== null) {
49
+ const url = new URL(src, this.ownerDocument.baseURI);
50
+ if (url.protocol !== 'https:' && url.protocol !== 'http:')
51
+ throw new Error('src must use HTTP or HTTPS');
52
+ this.pending = new AbortController();
53
+ const response = await fetch(url.href, { signal: this.pending.signal });
54
+ if (!response.ok)
55
+ throw new Error(`src request failed (${response.status})`);
56
+ source = await response.text();
57
+ }
58
+ if (revision !== this.revision || !this.isConnected)
59
+ return;
60
+ this.playback = (0, dom_1.renderToElement)(this, source, {
61
+ reducedMotion: this.hasAttribute('reduced-motion') ? this.getAttribute('reduced-motion') !== 'false' : undefined,
62
+ maxCells: this.hasAttribute('max-cells') ? Number(this.getAttribute('max-cells')) : undefined,
63
+ maxFps: this.hasAttribute('max-fps') ? Number(this.getAttribute('max-fps')) : undefined,
64
+ });
65
+ }
66
+ catch (e) {
67
+ if (revision !== this.revision || !this.isConnected)
68
+ return;
69
+ const pre = this.ownerDocument.createElement('pre');
70
+ pre.className = 'mdgl-frame mdgl-error';
71
+ pre.textContent = `${this.inline}\n\nMDGL: ${e instanceof Error ? e.message : String(e)}`;
72
+ this.replaceChildren(pre);
73
+ }
74
+ }
75
+ }
76
+ customElements.define('mdgl-player', MDGLPlayer);
77
+ }
package/mdgl.css ADDED
@@ -0,0 +1,52 @@
1
+ /* Variables inherit from the host; set them on mdgl-player or a wrapper to theme. */
2
+ :root {
3
+ --mdgl-normal: #172033;
4
+ --mdgl-black: #000000;
5
+ --mdgl-red: #b42318;
6
+ --mdgl-green: #16713c;
7
+ --mdgl-yellow: #8b6100;
8
+ --mdgl-blue: #2459bd;
9
+ --mdgl-magenta: #9126a7;
10
+ --mdgl-cyan: #00788b;
11
+ --mdgl-white: #435069;
12
+ --mdgl-accent: #005fcc;
13
+ --mdgl-background: #f6f8fc;
14
+ }
15
+ @media (prefers-color-scheme: dark) {
16
+ :root {
17
+ --mdgl-normal: #e6edf7;
18
+ --mdgl-black: #000000;
19
+ --mdgl-red: #ff7b72;
20
+ --mdgl-green: #7ee787;
21
+ --mdgl-yellow: #f2cc60;
22
+ --mdgl-blue: #79a9ff;
23
+ --mdgl-magenta: #e2a8ff;
24
+ --mdgl-cyan: #76e3ea;
25
+ --mdgl-white: #ffffff;
26
+ --mdgl-accent: #a5c8ff;
27
+ --mdgl-background: #111827;
28
+ }
29
+ }
30
+ mdgl-player { display: block; }
31
+ .mdgl-frame {
32
+ display: block;
33
+ margin: 0;
34
+ padding: 0.75rem;
35
+ overflow: auto;
36
+ max-width: 100%;
37
+ box-sizing: border-box;
38
+ white-space: pre;
39
+ direction: ltr;
40
+ unicode-bidi: isolate;
41
+ font-family: ui-monospace, SFMono-Regular, Consolas, 'Liberation Mono', monospace;
42
+ font-size: 14px;
43
+ font-weight: normal;
44
+ font-style: normal;
45
+ font-variant-ligatures: none;
46
+ letter-spacing: 0;
47
+ line-height: 1.2;
48
+ color: var(--mdgl-normal);
49
+ background: var(--mdgl-background);
50
+ }
51
+ .mdgl-frame > span { font: inherit; letter-spacing: inherit; }
52
+ .mdgl-error { white-space: pre-wrap; overflow-wrap: anywhere; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@enmaq/mdgl",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "MDGL 0.1 parser, checker, evaluator, terminal-cell renderer and terminal player.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -9,7 +9,20 @@
9
9
  "types": "./dist/index.d.ts",
10
10
  "default": "./dist/index.js"
11
11
  },
12
- "./package.json": "./package.json"
12
+ "./package.json": "./package.json",
13
+ "./dom": {
14
+ "types": "./dist/dom.d.ts",
15
+ "default": "./dist/dom.js"
16
+ },
17
+ "./player": {
18
+ "types": "./dist/player.d.ts",
19
+ "default": "./dist/player.js"
20
+ },
21
+ "./browser": {
22
+ "types": "./dist/browser.d.ts",
23
+ "default": "./dist/browser.mjs"
24
+ },
25
+ "./mdgl.css": "./mdgl.css"
13
26
  },
14
27
  "publishConfig": {
15
28
  "access": "public"
@@ -20,15 +33,17 @@
20
33
  "README.md",
21
34
  "LICENSE-MIT",
22
35
  "LICENSE-APACHE",
23
- "UNICODE-LICENSE.txt"
36
+ "UNICODE-LICENSE.txt",
37
+ "mdgl.css"
24
38
  ],
25
39
  "scripts": {
26
- "build": "tsc -p .",
40
+ "build": "tsc -p . && esbuild src/browser.ts --bundle --format=esm --target=es2020 --outfile=dist/browser.mjs",
27
41
  "prepack": "npm run build",
28
42
  "test:types": "tsc -p test/consumer",
29
43
  "test": "npm run build && npm run test:types && node --test test/*.test.js",
30
44
  "example:js": "npm run build && node examples/render.mjs",
31
- "example:ts": "npm run build && tsc -p examples && node .examples/render.js"
45
+ "example:ts": "npm run build && tsc -p examples && node .examples/render.js",
46
+ "test:dom": "npm run build && node --test test/dom.test.js"
32
47
  },
33
48
  "repository": {
34
49
  "type": "git",
@@ -48,7 +63,9 @@
48
63
  ],
49
64
  "license": "MIT OR Apache-2.0",
50
65
  "devDependencies": {
51
- "typescript": "5.9.3"
66
+ "typescript": "5.9.3",
67
+ "esbuild": "0.24.2",
68
+ "jsdom": "22.1.0"
52
69
  },
53
70
  "bin": {
54
71
  "mdgl": "bin/mdgl.js",