eyelang 0.1.10 → 0.1.11

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 CHANGED
@@ -45,9 +45,17 @@ console.log(result.stdout);
45
45
 
46
46
  ## Documentation
47
47
 
48
+ - [Playground](playground.html)
48
49
  - [Guide](docs/guide.md)
49
50
  - [Language reference](docs/language-reference.md)
50
51
 
52
+ For local browser use, serve the checkout first so the playground can load ES modules and example files:
53
+
54
+ ```bash
55
+ python3 -m http.server
56
+ # then open http://localhost:8000/playground.html
57
+ ```
58
+
51
59
  ## Tests
52
60
 
53
61
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eyelang",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "description": "A small Prolog-syntax-subset logic programming language for rules, goals, answers, and proofs.",
5
5
  "type": "module",
6
6
  "main": "./index.js",
@@ -27,6 +27,7 @@
27
27
  "README.md",
28
28
  "index.js",
29
29
  "index.d.ts",
30
+ "playground.html",
30
31
  "bin",
31
32
  "src",
32
33
  "docs",
@@ -0,0 +1,839 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Eyelang Playground</title>
7
+ <style>
8
+ :root {
9
+ color-scheme: light dark;
10
+ --bg: #f7f8fb;
11
+ --panel: #ffffff;
12
+ --panel-soft: #eef2f7;
13
+ --text: #172033;
14
+ --muted: #64748b;
15
+ --border: #d7dce5;
16
+ --accent: #0969da;
17
+ --accent-strong: #0757b8;
18
+ --danger: #b42318;
19
+ --code-bg: #0f172a;
20
+ --code-text: #dbeafe;
21
+ --comment: #94a3b8;
22
+ --string: #86efac;
23
+ --number: #fbbf24;
24
+ --variable: #93c5fd;
25
+ --keyword: #c084fc;
26
+ --predicate: #f0abfc;
27
+ --punctuation: #cbd5e1;
28
+ }
29
+
30
+ @media (prefers-color-scheme: dark) {
31
+ :root {
32
+ --bg: #0b1020;
33
+ --panel: #111827;
34
+ --panel-soft: #1f2937;
35
+ --text: #e5e7eb;
36
+ --muted: #9ca3af;
37
+ --border: #374151;
38
+ --accent: #60a5fa;
39
+ --accent-strong: #93c5fd;
40
+ }
41
+ }
42
+
43
+ * { box-sizing: border-box; }
44
+
45
+ body {
46
+ margin: 0;
47
+ background: var(--bg);
48
+ color: var(--text);
49
+ font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
50
+ line-height: 1.45;
51
+ }
52
+
53
+ header {
54
+ padding: 1rem clamp(1rem, 4vw, 2rem);
55
+ border-bottom: 1px solid var(--border);
56
+ background: var(--panel);
57
+ position: sticky;
58
+ top: 0;
59
+ z-index: 5;
60
+ }
61
+
62
+ h1 {
63
+ margin: 0;
64
+ font-size: clamp(1.35rem, 4vw, 2rem);
65
+ }
66
+
67
+ header p { margin: 0.35rem 0 0; color: var(--muted); }
68
+
69
+ main {
70
+ display: grid;
71
+ grid-template-columns: minmax(0, 1fr) minmax(18rem, 32rem);
72
+ gap: 1rem;
73
+ padding: 1rem clamp(1rem, 4vw, 2rem) 2rem;
74
+ max-width: 1500px;
75
+ margin: 0 auto;
76
+ }
77
+
78
+ section,
79
+ aside {
80
+ background: var(--panel);
81
+ border: 1px solid var(--border);
82
+ border-radius: 0.9rem;
83
+ box-shadow: 0 8px 28px rgba(15, 23, 42, 0.06);
84
+ overflow: hidden;
85
+ }
86
+
87
+ .panel-body { padding: 1rem; }
88
+
89
+ .toolbar,
90
+ .example-row,
91
+ .button-row,
92
+ .option-row {
93
+ display: flex;
94
+ flex-wrap: wrap;
95
+ gap: 0.6rem;
96
+ align-items: center;
97
+ }
98
+
99
+ .toolbar {
100
+ padding: 0.85rem 1rem;
101
+ border-bottom: 1px solid var(--border);
102
+ background: var(--panel-soft);
103
+ }
104
+
105
+ label { font-weight: 650; }
106
+
107
+ select,
108
+ input,
109
+ textarea,
110
+ button {
111
+ font: inherit;
112
+ font-size: 1rem;
113
+ }
114
+
115
+ select,
116
+ input[type="url"],
117
+ input[type="search"] {
118
+ min-height: 2.65rem;
119
+ padding: 0.55rem 0.7rem;
120
+ border: 1px solid var(--border);
121
+ border-radius: 0.55rem;
122
+ background: var(--panel);
123
+ color: var(--text);
124
+ }
125
+
126
+ select { min-width: min(100%, 18rem); flex: 1; }
127
+ input[type="url"] { flex: 1 1 20rem; min-width: 12rem; }
128
+ input[type="search"] { flex: 1 1 12rem; min-width: 10rem; }
129
+
130
+ button {
131
+ border: 1px solid var(--border);
132
+ border-radius: 0.55rem;
133
+ padding: 0.58rem 0.78rem;
134
+ min-height: 2.65rem;
135
+ background: var(--panel);
136
+ color: var(--text);
137
+ cursor: pointer;
138
+ }
139
+
140
+ button.primary {
141
+ border-color: var(--accent);
142
+ background: var(--accent);
143
+ color: white;
144
+ font-weight: 700;
145
+ }
146
+
147
+ button.primary:hover { background: var(--accent-strong); }
148
+ button:disabled { cursor: not-allowed; opacity: 0.6; }
149
+
150
+ .option-row label {
151
+ display: inline-flex;
152
+ gap: 0.35rem;
153
+ align-items: center;
154
+ font-weight: 500;
155
+ color: var(--muted);
156
+ }
157
+
158
+ .editor-shell { padding: 0; }
159
+
160
+ .editor-label {
161
+ display: flex;
162
+ justify-content: space-between;
163
+ gap: 1rem;
164
+ padding: 0.7rem 1rem;
165
+ border-bottom: 1px solid var(--border);
166
+ background: var(--panel);
167
+ }
168
+
169
+ .editor {
170
+ position: relative;
171
+ min-height: 58vh;
172
+ background: var(--code-bg);
173
+ overflow: hidden;
174
+ }
175
+
176
+ .editor pre,
177
+ .editor textarea {
178
+ position: absolute;
179
+ inset: 0;
180
+ margin: 0;
181
+ padding: 1rem;
182
+ border: 0;
183
+ width: 100%;
184
+ height: 100%;
185
+ resize: none;
186
+ overflow: auto;
187
+ white-space: pre;
188
+ tab-size: 2;
189
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
190
+ font-size: 0.95rem;
191
+ line-height: 1.55;
192
+ }
193
+
194
+ .editor pre {
195
+ pointer-events: none;
196
+ color: var(--code-text);
197
+ }
198
+
199
+ .editor textarea {
200
+ background: transparent;
201
+ color: rgba(255, 255, 255, 0.02);
202
+ caret-color: white;
203
+ outline: none;
204
+ -webkit-text-fill-color: rgba(255, 255, 255, 0.02);
205
+ }
206
+
207
+ .editor textarea::selection { background: rgba(96, 165, 250, 0.35); }
208
+
209
+ .tok-comment { color: var(--comment); font-style: italic; }
210
+ .tok-string { color: var(--string); }
211
+ .tok-number { color: var(--number); }
212
+ .tok-variable { color: var(--variable); }
213
+ .tok-keyword { color: var(--keyword); font-weight: 700; }
214
+ .tok-predicate { color: var(--predicate); }
215
+ .tok-punctuation { color: var(--punctuation); }
216
+
217
+ .output {
218
+ min-height: 18rem;
219
+ max-height: 58vh;
220
+ overflow: auto;
221
+ padding: 1rem;
222
+ margin: 0;
223
+ background: var(--code-bg);
224
+ color: var(--code-text);
225
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
226
+ font-size: 0.9rem;
227
+ line-height: 1.55;
228
+ white-space: pre-wrap;
229
+ overflow-wrap: anywhere;
230
+ }
231
+
232
+ .status {
233
+ display: flex;
234
+ align-items: center;
235
+ justify-content: space-between;
236
+ gap: 0.75rem;
237
+ padding: 0.65rem 1rem;
238
+ border-top: 1px solid var(--border);
239
+ color: var(--muted);
240
+ font-size: 0.95rem;
241
+ }
242
+
243
+ .hint {
244
+ color: var(--muted);
245
+ font-size: 0.95rem;
246
+ margin: 0.4rem 0 0;
247
+ }
248
+
249
+ .error { color: var(--danger); }
250
+
251
+ footer {
252
+ color: var(--muted);
253
+ padding: 0 1rem 1.5rem;
254
+ text-align: center;
255
+ font-size: 0.95rem;
256
+ }
257
+
258
+ footer a { color: var(--accent); }
259
+
260
+ @media (max-width: 900px) {
261
+ header { position: static; }
262
+ main { grid-template-columns: 1fr; }
263
+ .editor { min-height: 54vh; }
264
+ .output { max-height: 42vh; }
265
+ }
266
+
267
+ @media (max-width: 560px) {
268
+ .toolbar,
269
+ .panel-body,
270
+ .editor-label { padding: 0.8rem; }
271
+ button,
272
+ select,
273
+ input[type="url"],
274
+ input[type="search"] { width: 100%; }
275
+ .button-row > button { flex: 1 1 8rem; }
276
+ .editor pre,
277
+ .editor textarea { font-size: 0.9rem; padding: 0.8rem; }
278
+ }
279
+ </style>
280
+ </head>
281
+ <body>
282
+ <header>
283
+ <h1>The Eyelang Playground</h1>
284
+ <p>Run Eyelang programs in your browser with syntax coloring, examples, proofs, and shareable editor links.</p>
285
+ </header>
286
+
287
+ <main>
288
+ <section aria-labelledby="editor-heading">
289
+ <div class="toolbar" aria-label="Example loader">
290
+ <label for="example-search">Load example</label>
291
+ <input id="example-search" type="search" placeholder="Filter examples" autocomplete="off">
292
+ <select id="examples" aria-label="Examples"></select>
293
+ <button id="load-example" type="button">Load</button>
294
+ </div>
295
+
296
+ <div class="toolbar" aria-label="URL loader">
297
+ <label for="source-url">Load from URL</label>
298
+ <input id="source-url" type="url" placeholder="https://example.org/program.pl">
299
+ <button id="load-url" type="button">Load URL</button>
300
+ </div>
301
+
302
+ <div class="toolbar option-row" aria-label="Run options">
303
+ <label><input id="proof" type="checkbox"> proof explanations</label>
304
+ <label><input id="stats" type="checkbox"> show stats</label>
305
+ </div>
306
+
307
+ <div class="editor-shell">
308
+ <div class="editor-label">
309
+ <strong id="editor-heading">Editable Eyelang program</strong>
310
+ <span id="source-name" class="hint">custom program</span>
311
+ </div>
312
+ <div class="editor">
313
+ <pre id="highlight" aria-hidden="true"><code></code></pre>
314
+ <textarea id="source" spellcheck="false" autocapitalize="off" autocomplete="off" aria-label="Editable Eyelang source"></textarea>
315
+ </div>
316
+ </div>
317
+ </section>
318
+
319
+ <aside aria-labelledby="output-heading">
320
+ <div class="toolbar button-row">
321
+ <button id="run" type="button" class="primary">Run</button>
322
+ <button id="stop" type="button" disabled>Stop</button>
323
+ <button id="copy-source" type="button">Copy source</button>
324
+ <button id="copy-output" type="button">Copy output</button>
325
+ <button id="share" type="button">Copy share link</button>
326
+ </div>
327
+ <div class="editor-label">
328
+ <strong id="output-heading">Output</strong>
329
+ <span id="elapsed" class="hint">Idle.</span>
330
+ </div>
331
+ <pre id="output" class="output">(no output yet)</pre>
332
+ <div class="status">
333
+ <span id="status">Ready.</span>
334
+ <span id="version">eyelang v…</span>
335
+ </div>
336
+ <div class="panel-body">
337
+ <p class="hint">For local use, serve the checkout with a small HTTP server first; browser modules and example loading usually do not work from <code>file://</code>.</p>
338
+ </div>
339
+ </aside>
340
+ </main>
341
+
342
+ <footer>
343
+ Powered by <a href="https://github.com/eyereasoner/eyelang">Eyelang</a>.
344
+ </footer>
345
+
346
+ <script type="module">
347
+ const EXAMPLES = [
348
+ "access-control-policy",
349
+ "ackermann",
350
+ "age",
351
+ "aliases-and-namespaces",
352
+ "alignment-demo",
353
+ "allen-interval-calculus",
354
+ "ancestor",
355
+ "animal",
356
+ "annotation",
357
+ "auroracare",
358
+ "backward",
359
+ "basic-monadic",
360
+ "bayes-diagnosis",
361
+ "bayes-therapy",
362
+ "beam-deflection",
363
+ "blocks-world-planning",
364
+ "bmi",
365
+ "braking-safety-worlds",
366
+ "buck-converter-design",
367
+ "cache-performance",
368
+ "canary-release",
369
+ "cat-koko",
370
+ "clinical-trial-screening",
371
+ "collatz-1000",
372
+ "combinatorics-findall-sort",
373
+ "competitive-enzyme-kinetics",
374
+ "complex",
375
+ "composition-of-injective-functions-is-injective",
376
+ "context-association",
377
+ "context-schema-audit",
378
+ "control-system",
379
+ "cyclic-path",
380
+ "d3-group",
381
+ "dairy-energy-balance",
382
+ "data-negotiation",
383
+ "deep-taxonomy-10",
384
+ "deep-taxonomy-100",
385
+ "deep-taxonomy-1000",
386
+ "deep-taxonomy-10000",
387
+ "deep-taxonomy-100000",
388
+ "delfour",
389
+ "deontic-logic",
390
+ "derived-backward-rule",
391
+ "derived-rule",
392
+ "diamond-property",
393
+ "dijkstra-findall-sort",
394
+ "dijkstra-risk-path",
395
+ "dijkstra",
396
+ "dining-philosophers",
397
+ "dog",
398
+ "dpv-odrl-purpose-mapping",
399
+ "drone-corridor-planner",
400
+ "easter-computus",
401
+ "electrical-rc-filter",
402
+ "epidemic-policy",
403
+ "equivalence-classes-overlap-implies-same-class",
404
+ "eulerian-path",
405
+ "ev-range-worlds",
406
+ "existential-rule",
407
+ "exoplanet-validation-worlds",
408
+ "expression-eval",
409
+ "family-cousins",
410
+ "fastpow",
411
+ "fft8-numeric",
412
+ "fibonacci",
413
+ "field-nitrogen-balance",
414
+ "flandor",
415
+ "floating-point",
416
+ "four-color-map",
417
+ "fundamental-theorem-arithmetic",
418
+ "gd-step-certified",
419
+ "gdpr-compliance",
420
+ "good-cobbler",
421
+ "gps",
422
+ "graph-reachability",
423
+ "gray-code-counter",
424
+ "greatest-lower-bound-uniqueness",
425
+ "group-inverse-uniqueness",
426
+ "hamiltonian-path",
427
+ "hamming-code",
428
+ "hanoi",
429
+ "heat-loss",
430
+ "heron-theorem",
431
+ "ideal-gas-law",
432
+ "illegitimate-reasoning",
433
+ "knowledge-engineering-alignment-flow",
434
+ "law-of-cosines",
435
+ "least-squares-regression",
436
+ "list-collection",
437
+ "lldm",
438
+ "manufacturing-quality-control",
439
+ "microgrid-dispatch",
440
+ "monkey-bananas",
441
+ "network-sla",
442
+ "newton-raphson",
443
+ "nixon-diamond",
444
+ "observability-log-correlation",
445
+ "odrl-dpv-fpv-trust-flow",
446
+ "odrl-dpv-healthcare-risk-ranked",
447
+ "odrl-dpv-risk-ranked",
448
+ "orbital-transfer-design",
449
+ "path-discovery",
450
+ "peano-arithmetic",
451
+ "peasant",
452
+ "pendulum-period",
453
+ "polynomial",
454
+ "proof-contrapositive",
455
+ "quadratic-formula",
456
+ "radioactive-decay",
457
+ "reusable-builtins",
458
+ "riemann-hypothesis",
459
+ "security-incident-correlation",
460
+ "service-impact",
461
+ "sieve",
462
+ "skolem-functions",
463
+ "socket-age",
464
+ "socket-family",
465
+ "socrates",
466
+ "statistics-summary",
467
+ "superdense-coding",
468
+ "term-tools",
469
+ "trust-flow-provenance-threshold",
470
+ "turing",
471
+ "vector-similarity",
472
+ "vulnerability-impact",
473
+ "witch",
474
+ "wolf-goat-cabbage",
475
+ "zebra"
476
+ ];
477
+ const FALLBACK_SOURCE = `materialize(answer, 1).
478
+ answer(ok) :- eq(ok, ok).
479
+ `;
480
+ const KEYWORDS = new Set(['materialize', 'memoize']);
481
+ const BUILTINS = new Set([
482
+ "abs",
483
+ "acos",
484
+ "add",
485
+ "aggregate_max",
486
+ "aggregate_min",
487
+ "append",
488
+ "arg",
489
+ "asin",
490
+ "atan2",
491
+ "atom_string",
492
+ "between",
493
+ "ceiling",
494
+ "compound_name_arguments",
495
+ "contains",
496
+ "cos",
497
+ "countall",
498
+ "difference",
499
+ "div",
500
+ "drop",
501
+ "eq",
502
+ "exp",
503
+ "findall",
504
+ "floor",
505
+ "forall",
506
+ "functor",
507
+ "ge",
508
+ "gt",
509
+ "head",
510
+ "holds",
511
+ "join",
512
+ "last",
513
+ "le",
514
+ "length",
515
+ "list_to_set",
516
+ "local_time",
517
+ "log",
518
+ "lowercase",
519
+ "lt",
520
+ "matches",
521
+ "max",
522
+ "max_list",
523
+ "member",
524
+ "min",
525
+ "min_list",
526
+ "mod",
527
+ "mul",
528
+ "neg",
529
+ "neq",
530
+ "not",
531
+ "not_matches",
532
+ "not_member",
533
+ "nth0",
534
+ "number_string",
535
+ "once",
536
+ "pow",
537
+ "replace",
538
+ "rest",
539
+ "reverse",
540
+ "rounded",
541
+ "select",
542
+ "set_nth0",
543
+ "sin",
544
+ "slice",
545
+ "smallest_divisor_from",
546
+ "sort",
547
+ "split",
548
+ "sqrt",
549
+ "str_concat",
550
+ "sub",
551
+ "substring",
552
+ "sum_list",
553
+ "sumall",
554
+ "take",
555
+ "tan",
556
+ "term_string",
557
+ "trim",
558
+ "trunc",
559
+ "uppercase"
560
+ ]);
561
+
562
+ const source = document.querySelector('#source');
563
+ const highlight = document.querySelector('#highlight');
564
+ const exampleSelect = document.querySelector('#examples');
565
+ const exampleSearch = document.querySelector('#example-search');
566
+ const output = document.querySelector('#output');
567
+ const status = document.querySelector('#status');
568
+ const elapsed = document.querySelector('#elapsed');
569
+ const sourceName = document.querySelector('#source-name');
570
+ const proof = document.querySelector('#proof');
571
+ const stats = document.querySelector('#stats');
572
+ const runButton = document.querySelector('#run');
573
+ const stopButton = document.querySelector('#stop');
574
+ let activeWorker = null;
575
+ let activeWorkerUrl = null;
576
+
577
+ populateExamples(EXAMPLES);
578
+ restoreFromHash() || loadExample('ancestor');
579
+ loadVersion();
580
+
581
+ source.addEventListener('input', render);
582
+ source.addEventListener('scroll', syncScroll);
583
+ exampleSearch.addEventListener('input', () => populateExamples(filterExamples(exampleSearch.value)));
584
+ document.querySelector('#load-example').addEventListener('click', () => loadExample(exampleSelect.value));
585
+ document.querySelector('#load-url').addEventListener('click', loadUrl);
586
+ document.querySelector('#run').addEventListener('click', runProgram);
587
+ document.querySelector('#stop').addEventListener('click', stopProgram);
588
+ document.querySelector('#copy-source').addEventListener('click', () => copyText(source.value, 'Source copied.'));
589
+ document.querySelector('#copy-output').addEventListener('click', () => copyText(output.textContent, 'Output copied.'));
590
+ document.querySelector('#share').addEventListener('click', copyShareLink);
591
+
592
+ function populateExamples(names) {
593
+ const current = exampleSelect.value;
594
+ exampleSelect.replaceChildren(...names.map((name) => {
595
+ const option = document.createElement('option');
596
+ option.value = name;
597
+ option.textContent = name;
598
+ return option;
599
+ }));
600
+ if (names.includes(current)) exampleSelect.value = current;
601
+ else if (names.length) exampleSelect.value = names[0];
602
+ }
603
+
604
+ function filterExamples(query) {
605
+ const needle = query.trim().toLowerCase();
606
+ if (!needle) return EXAMPLES;
607
+ return EXAMPLES.filter((name) => name.includes(needle));
608
+ }
609
+
610
+ async function loadExample(name) {
611
+ if (!name) return;
612
+ try {
613
+ setStatus(`Loading ${name}…`);
614
+ const exampleUrl = new URL(`./examples/${name}.pl`, location.href);
615
+ const response = await fetch(exampleUrl, { cache: 'no-store' });
616
+ if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
617
+ setSource(await response.text(), `examples/${name}.pl`);
618
+ setStatus(`Loaded examples/${name}.pl.`);
619
+ } catch (error) {
620
+ setSource(FALLBACK_SOURCE, 'fallback program');
621
+ setStatus(`Could not load examples/${name}.pl: ${formatError(error)}`, true);
622
+ }
623
+ }
624
+
625
+ async function loadUrl() {
626
+ const url = document.querySelector('#source-url').value.trim();
627
+ if (!url) return;
628
+ try {
629
+ setStatus(`Loading ${url}…`);
630
+ const response = await fetch(url);
631
+ if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
632
+ setSource(await response.text(), url);
633
+ setStatus(`Loaded ${url}.`);
634
+ } catch (error) {
635
+ setStatus(`Could not load URL: ${formatError(error)}`, true);
636
+ }
637
+ }
638
+
639
+ function setSource(text, name) {
640
+ source.value = text;
641
+ sourceName.textContent = name;
642
+ render();
643
+ }
644
+
645
+ function render() {
646
+ highlight.firstElementChild.innerHTML = colorize(source.value) + (source.value.endsWith('\n') ? ' ' : '');
647
+ syncScroll();
648
+ }
649
+
650
+ function syncScroll() {
651
+ highlight.scrollTop = source.scrollTop;
652
+ highlight.scrollLeft = source.scrollLeft;
653
+ }
654
+
655
+ function colorize(text) {
656
+ let out = '';
657
+ for (let i = 0; i < text.length;) {
658
+ const ch = text[i];
659
+ if (ch === '%') {
660
+ const end = text.indexOf('\n', i);
661
+ const stop = end === -1 ? text.length : end;
662
+ out += span('comment', text.slice(i, stop));
663
+ i = stop;
664
+ } else if (ch === '"' || ch === "'") {
665
+ const quote = ch;
666
+ let j = i + 1;
667
+ while (j < text.length) {
668
+ if (text[j] === '\\') j += 2;
669
+ else if (text[j] === quote) { j++; break; }
670
+ else j++;
671
+ }
672
+ out += span('string', text.slice(i, j));
673
+ i = j;
674
+ } else if (/\d/.test(ch)) {
675
+ let j = i + 1;
676
+ while (j < text.length && /[\d.]/.test(text[j])) j++;
677
+ out += span('number', text.slice(i, j));
678
+ i = j;
679
+ } else if (/[A-Z_]/.test(ch)) {
680
+ let j = i + 1;
681
+ while (j < text.length && /[A-Za-z0-9_]/.test(text[j])) j++;
682
+ out += span('variable', text.slice(i, j));
683
+ i = j;
684
+ } else if (/[a-z]/.test(ch)) {
685
+ let j = i + 1;
686
+ while (j < text.length && /[A-Za-z0-9_]/.test(text[j])) j++;
687
+ const word = text.slice(i, j);
688
+ if (KEYWORDS.has(word)) out += span('keyword', word);
689
+ else if (BUILTINS.has(word) || text[j] === '(') out += span('predicate', word);
690
+ else out += escapeHtml(word);
691
+ i = j;
692
+ } else if ('()[]{},.;|:-'.includes(ch)) {
693
+ out += span('punctuation', ch);
694
+ i++;
695
+ } else {
696
+ out += escapeHtml(ch);
697
+ i++;
698
+ }
699
+ }
700
+ return out;
701
+ }
702
+
703
+ function span(kind, text) { return `<span class="tok-${kind}">${escapeHtml(text)}</span>`; }
704
+
705
+ function escapeHtml(text) {
706
+ return text.replace(/[&<>]/g, (ch) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[ch]));
707
+ }
708
+
709
+ function runProgram() {
710
+ stopProgram(false);
711
+ output.textContent = '';
712
+ elapsed.textContent = 'Running…';
713
+ setStatus('Running Eyelang…');
714
+ runButton.disabled = true;
715
+ stopButton.disabled = false;
716
+
717
+ const engineUrl = new URL('./src/index.js', location.href).href;
718
+ const workerCode = `
719
+ import { run } from ${JSON.stringify(engineUrl)};
720
+ self.onmessage = (event) => {
721
+ const started = performance.now();
722
+ try {
723
+ const result = run(event.data.source, event.data.options);
724
+ self.postMessage({
725
+ ok: true,
726
+ stdout: result.stdout,
727
+ stats: result.stats,
728
+ elapsedMs: performance.now() - started,
729
+ });
730
+ } catch (error) {
731
+ self.postMessage({ ok: false, error: error?.stack || error?.message || String(error) });
732
+ }
733
+ };
734
+ `;
735
+ activeWorkerUrl = URL.createObjectURL(new Blob([workerCode], { type: 'text/javascript' }));
736
+ activeWorker = new Worker(activeWorkerUrl, { type: 'module' });
737
+ activeWorker.onmessage = (event) => finishRun(event.data);
738
+ activeWorker.onerror = (event) => finishRun({ ok: false, error: event.message || 'worker error' });
739
+ activeWorker.postMessage({
740
+ source: source.value,
741
+ options: { proof: proof.checked, stats: stats.checked },
742
+ });
743
+ }
744
+
745
+ function finishRun(message) {
746
+ const worker = activeWorker;
747
+ cleanupWorker();
748
+ runButton.disabled = false;
749
+ stopButton.disabled = true;
750
+ if (!message.ok) {
751
+ output.textContent = message.error || 'Unknown error.';
752
+ elapsed.textContent = 'Failed.';
753
+ setStatus('Run failed.', true);
754
+ return;
755
+ }
756
+ let text = message.stdout || '(no materialized output)\n';
757
+ if (stats.checked && message.stats) text += '\n% stats\n' + formatStats(message.stats);
758
+ output.textContent = text;
759
+ const seconds = (message.elapsedMs / 1000).toFixed(3);
760
+ elapsed.textContent = `${seconds} sec`;
761
+ setStatus(worker ? 'Run complete.' : 'Run complete.');
762
+ }
763
+
764
+ function stopProgram(report = true) {
765
+ if (!activeWorker) return;
766
+ cleanupWorker();
767
+ runButton.disabled = false;
768
+ stopButton.disabled = true;
769
+ elapsed.textContent = 'Stopped.';
770
+ if (report) setStatus('Run stopped.');
771
+ }
772
+
773
+ function cleanupWorker() {
774
+ if (activeWorker) activeWorker.terminate();
775
+ activeWorker = null;
776
+ if (activeWorkerUrl) URL.revokeObjectURL(activeWorkerUrl);
777
+ activeWorkerUrl = null;
778
+ }
779
+
780
+ function formatStats(values) {
781
+ return Object.entries(values).map(([key, value]) => `${key}: ${value}`).join('\n') + '\n';
782
+ }
783
+
784
+ async function copyText(text, message) {
785
+ try {
786
+ await navigator.clipboard.writeText(text);
787
+ setStatus(message);
788
+ } catch (_) {
789
+ setStatus('Clipboard access is not available in this browser.', true);
790
+ }
791
+ }
792
+
793
+ async function copyShareLink() {
794
+ const payload = JSON.stringify({ source: source.value, proof: proof.checked, stats: stats.checked });
795
+ const data = new TextEncoder().encode(payload);
796
+ let binary = '';
797
+ for (const byte of data) binary += String.fromCharCode(byte);
798
+ const encoded = btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
799
+ await copyText(`${location.href.split('#')[0]}#state=${encoded}`, 'Share link copied.');
800
+ }
801
+
802
+ function restoreFromHash() {
803
+ if (!location.hash.startsWith('#state=')) return false;
804
+ try {
805
+ const encoded = location.hash.slice(7).replaceAll('-', '+').replaceAll('_', '/');
806
+ const padded = encoded + '='.repeat((4 - encoded.length % 4) % 4);
807
+ const bytes = Uint8Array.from(atob(padded), (ch) => ch.charCodeAt(0));
808
+ const payload = JSON.parse(new TextDecoder().decode(bytes));
809
+ setSource(String(payload.source || ''), 'shared program');
810
+ proof.checked = Boolean(payload.proof);
811
+ stats.checked = Boolean(payload.stats);
812
+ setStatus('Loaded shared program.');
813
+ return true;
814
+ } catch (error) {
815
+ setStatus(`Could not read shared state: ${formatError(error)}`, true);
816
+ return false;
817
+ }
818
+ }
819
+
820
+ async function loadVersion() {
821
+ try {
822
+ const response = await fetch('package.json', { cache: 'no-store' });
823
+ if (!response.ok) return;
824
+ const pkg = await response.json();
825
+ if (pkg.version) document.querySelector('#version').textContent = `eyelang v${pkg.version}`;
826
+ } catch (_) {
827
+ // Package metadata is optional when the page is copied elsewhere.
828
+ }
829
+ }
830
+
831
+ function setStatus(message, isError = false) {
832
+ status.textContent = message;
833
+ status.classList.toggle('error', isError);
834
+ }
835
+
836
+ function formatError(error) { return error?.message || String(error); }
837
+ </script>
838
+ </body>
839
+ </html>
@@ -287,8 +287,16 @@ function documentationSyncCases() {
287
287
  },
288
288
  },
289
289
  {
290
- name: 'guide example catalog matches examples directory',
291
- run: () => assertArrayEqual(guideCatalogExampleNames(), listExampleNames(), 'example catalog'),
290
+ name: 'guide example catalog source and output links match examples directory',
291
+ run: () => assertArrayEqual(guideExampleCatalogIssues(), [], 'guide example catalog'),
292
+ },
293
+ {
294
+ name: 'playground example catalog and relative loaders match examples directory',
295
+ run: () => assertArrayEqual(playgroundExampleIssues(), [], 'playground examples'),
296
+ },
297
+ {
298
+ name: 'playground static page is browser-ready and packaged',
299
+ run: () => assertArrayEqual(playgroundStaticIssues(), [], 'playground static page'),
292
300
  },
293
301
  {
294
302
  name: 'documentation local links and anchors resolve',
@@ -572,13 +580,73 @@ function listExampleNames() {
572
580
  .sort();
573
581
  }
574
582
 
575
- function guideCatalogExampleNames() {
583
+ function guideExampleCatalogIssues() {
584
+ const issues = [];
585
+ const expected = listExampleNames();
576
586
  const guide = fs.readFileSync(path.join(packageRoot, 'docs', 'guide.md'), 'utf8');
577
587
  const section = between(guide, '## Example catalog', '## Golden outputs, tests, and conformance');
578
- return [...section.matchAll(/examples\/([A-Za-z0-9_-]+)\.pl/g)]
579
- .map((match) => match[1])
580
- .filter((name, index, names) => names.indexOf(name) === index)
581
- .sort();
588
+ const rows = [...section.matchAll(/^\| \[`([A-Za-z0-9_-]+)\.pl`\]\(\.\.\/examples\/\1\.pl\) \|[^|]+\| \[`output\/\1\.pl`\]\(\.\.\/examples\/output\/\1\.pl\) \|$/gm)]
589
+ .map((match) => match[1]);
590
+ const sourceNames = [...section.matchAll(/\.\.\/examples\/([A-Za-z0-9_-]+)\.pl/g)].map((match) => match[1]).sort();
591
+ const outputNames = [...section.matchAll(/\.\.\/examples\/output\/([A-Za-z0-9_-]+)\.pl/g)].map((match) => match[1]).sort();
592
+ if (rows.length !== expected.length) issues.push(`expected ${expected.length} complete example rows, found ${rows.length}`);
593
+ issues.push(...arrayDiffMessages(rows.sort(), expected, 'complete example rows'));
594
+ issues.push(...arrayDiffMessages(sourceNames, expected, 'source links'));
595
+ issues.push(...arrayDiffMessages(outputNames, expected, 'output links'));
596
+ for (const name of expected) {
597
+ const outputPath = path.join(packageRoot, 'examples', 'output', `${name}.pl`);
598
+ if (!fs.existsSync(outputPath)) issues.push(`missing examples/output/${name}.pl`);
599
+ }
600
+ return issues.sort();
601
+ }
602
+
603
+ function playgroundExampleIssues() {
604
+ const issues = [];
605
+ const expected = listExampleNames();
606
+ const html = fs.readFileSync(path.join(packageRoot, 'playground.html'), 'utf8');
607
+ const match = html.match(/const EXAMPLES = (\[[\s\S]*?\]);/);
608
+ if (match == null) return ['playground EXAMPLES array not found'];
609
+ const examples = JSON.parse(match[1]).sort();
610
+ issues.push(...arrayDiffMessages(examples, expected, 'playground EXAMPLES'));
611
+ if (!html.includes('new URL(`./examples/${name}.pl`, location.href)')) {
612
+ issues.push('playground must load selected examples from relative ./examples/*.pl URLs');
613
+ }
614
+ if (!html.includes("fetch(exampleUrl, { cache: 'no-store' })")) {
615
+ issues.push('playground must fetch selected example source from its relative URL');
616
+ }
617
+ return issues.sort();
618
+ }
619
+
620
+ function playgroundStaticIssues() {
621
+ const issues = [];
622
+ const playgroundPath = path.join(packageRoot, 'playground.html');
623
+ const html = fs.readFileSync(playgroundPath, 'utf8');
624
+ const readme = fs.readFileSync(path.join(packageRoot, 'README.md'), 'utf8');
625
+ if (!pkg.files?.includes('playground.html')) issues.push('package files must include playground.html');
626
+ if (!readme.includes('[Playground](playground.html)')) issues.push('README must link to playground.html');
627
+ if (!html.includes('<meta name="viewport" content="width=device-width, initial-scale=1">')) issues.push('missing mobile viewport meta');
628
+ if (!html.includes('@media (max-width: 900px)') || !html.includes('main { grid-template-columns: 1fr; }')) {
629
+ issues.push('playground must collapse the editor/output grid on tablet/mobile widths');
630
+ }
631
+ if (!html.includes('@media (max-width: 560px)') || !html.includes('button,') || !html.includes('width: 100%')) {
632
+ issues.push('playground must make controls usable at phone widths');
633
+ }
634
+ if (!html.includes('<script type="module">')) issues.push('playground script must be an ES module');
635
+ if (!html.includes("new URL('./src/index.js', location.href)")) issues.push('playground must import the public browser API');
636
+ if (!html.includes('class="editor"') || !html.includes('id="highlight"') || !html.includes('id="source"')) {
637
+ issues.push('playground must include layered syntax-colored editor');
638
+ }
639
+ if (!html.includes('id="example-search"') || !html.includes('id="examples"')) issues.push('playground must include searchable examples');
640
+ const scriptMatch = html.match(new RegExp('<script type="module">\\n([\\s\\S]*?)\\n <\\/script>'));
641
+ if (scriptMatch == null) {
642
+ issues.push('module script not found');
643
+ } else {
644
+ const scriptFile = path.join(tmp, 'playground-script.mjs');
645
+ fs.writeFileSync(scriptFile, scriptMatch[1]);
646
+ const result = spawnSync(process.execPath, ['--check', scriptFile], { encoding: 'utf8' });
647
+ if (result.status !== 0) issues.push(`playground module syntax check failed: ${result.stderr.trim()}`);
648
+ }
649
+ return issues.sort();
582
650
  }
583
651
 
584
652
  function registeredBuiltinNames() {
@@ -765,6 +833,16 @@ function assertNotIncludes(actual, expected, label) {
765
833
  if (String(actual).includes(expected)) throw new Error(`${label} unexpectedly included ${format(expected)}\nactual: ${format(actual)}`);
766
834
  }
767
835
 
836
+ function arrayDiffMessages(actual, expected, label) {
837
+ const messages = [];
838
+ const actualSet = new Set(actual);
839
+ const expectedSet = new Set(expected);
840
+ for (const item of expected) if (!actualSet.has(item)) messages.push(`${label} missing ${item}`);
841
+ for (const item of actual) if (!expectedSet.has(item)) messages.push(`${label} has unexpected ${item}`);
842
+ if (new Set(actual).size !== actual.length) messages.push(`${label} has duplicate entries`);
843
+ return messages;
844
+ }
845
+
768
846
  function assertArrayEqual(actual, expected, label) {
769
847
  const actualText = actual.join('\n');
770
848
  const expectedText = expected.join('\n');