jsmdcui 0.7.0 → 0.8.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/runmd.mjs CHANGED
@@ -3,6 +3,7 @@
3
3
  import path from 'node:path'
4
4
  import { pathToFileURL } from 'node:url'
5
5
  import { readInternalAssetText } from './src/runtime/assets.js'
6
+ import { fenceEventMap } from './src/cui/fence-events.mjs'
6
7
  import { REPO_ROOT } from './single-exe/compiled.js'
7
8
 
8
9
  const csl=console.log
@@ -220,7 +221,7 @@ function escapeHtmlAttribute(value)
220
221
  .replaceAll(">", ">");
221
222
  }
222
223
 
223
- export function convertWuiTextareas(html)
224
+ export function convertWuiTextareas(html, eventsById = new Map())
224
225
  {
225
226
  return String(html).replace(
226
227
  /<pre><code class="language-([^"]+)">([^]*?)<\/code><\/pre>/g,
@@ -230,10 +231,50 @@ export function convertWuiTextareas(html)
230
231
  const value = content.replace(/\n$/, "");
231
232
  const contentLines = value.split("\n");
232
233
  const cols = Math.max(1, ...contentLines.map(line => [...line].length));
234
+ const declaration = identity.id && eventsById.get(identity.id)?.tag === identity.tag
235
+ ? eventsById.get(identity.id)
236
+ : null;
237
+ const keydownHandler = declaration?.events.get("keydown");
238
+ const keydownCode = keydownHandler
239
+ ? [
240
+ "const __mdcuiKeyCode=Number(event.keyCode||event.which||0);",
241
+ "const __mdcuiCodeLetter=/^Key[A-Z]$/.test(event.code||\"\")?event.code.charCodeAt(3):0;",
242
+ "const __mdcuiLetterCode=__mdcuiKeyCode>=65&&__mdcuiKeyCode<=90?__mdcuiKeyCode:__mdcuiCodeLetter;",
243
+ "const __mdcuiAltGraph=!!event.getModifierState&&event.getModifierState(\"AltGraph\");",
244
+ "const __mdcuiLetter=!__mdcuiAltGraph&&(event.ctrlKey||event.altKey||event.metaKey)&&__mdcuiLetterCode>=65&&__mdcuiLetterCode<=90;",
245
+ "if(__mdcuiLetter)Object.defineProperty(event,\"key\",{configurable:true,value:String.fromCharCode(__mdcuiLetterCode+(event.shiftKey?0:32))});",
246
+ "this.__mdcuiIdentifiedKeydown=!!event.key&&event.key!==\"Unidentified\";",
247
+ "this.__mdcuiUnidentifiedKeydown=event.key===\"Unidentified\"?{keyCode:__mdcuiKeyCode,ctrlKey:!!event.ctrlKey,shiftKey:!!event.shiftKey,altKey:!!event.altKey,metaKey:!!event.metaKey,altGraph:__mdcuiAltGraph}:null;",
248
+ "clearTimeout(this.__mdcuiKeydownReset);",
249
+ "this.__mdcuiKeydownReset=setTimeout(()=>{this.__mdcuiIdentifiedKeydown=false;this.__mdcuiUnidentifiedKeydown=null},0);",
250
+ "if(event.key!==\"Unidentified\"){\n",
251
+ "Object.defineProperty(event,\"toJSON\",{configurable:true,value:function(){const t=this.target||{};return{type:String(this.type||\"\"),key:String(this.key||\"\"),code:String(this.code||\"\"),raw:String(this.raw||\"\"),ctrlKey:!!this.ctrlKey,shiftKey:!!this.shiftKey,altKey:!!this.altKey,metaKey:!!this.metaKey,repeat:!!this.repeat,defaultPrevented:!!this.defaultPrevented,target:{id:String(t.id||\"\"),tagName:String(t.tagName||\"\"),className:String(t.className||\"\"),value:String(t.value??\"\")}}}});",
252
+ keydownHandler.modifiers.includes("prevent")
253
+ ? "event.preventDefault();"
254
+ : "",
255
+ keydownHandler.code,
256
+ "\n}",
257
+ ].join("")
258
+ : "";
259
+ const beforeInputCode = keydownHandler
260
+ ? [
261
+ "if(!this.__mdcuiIdentifiedKeydown&&event.data!=null&&event.data!==\"\"){",
262
+ "const m=this.__mdcuiUnidentifiedKeydown||{};",
263
+ "const letter=!m.altGraph&&(m.ctrlKey||m.altKey||m.metaKey)&&m.keyCode>=65&&m.keyCode<=90?String.fromCharCode(m.keyCode+(m.shiftKey?0:32)):String(event.data);",
264
+ "Object.defineProperties(event,{key:{configurable:true,value:letter},ctrlKey:{configurable:true,value:!!m.ctrlKey},shiftKey:{configurable:true,value:!!m.shiftKey},altKey:{configurable:true,value:!!m.altKey},metaKey:{configurable:true,value:!!m.metaKey}});",
265
+ "this.onkeydown(event)",
266
+ "}",
267
+ ].join("")
268
+ : "";
269
+ const inlineEventAttrs = [
270
+ keydownCode ? `onkeydown="${escapeHtmlAttribute(keydownCode)}"` : "",
271
+ beforeInputCode ? `onbeforeinput="${escapeHtmlAttribute(beforeInputCode)}"` : "",
272
+ ];
233
273
  const attrs = [
234
274
  `data-mdcui-tag="${identity.tag}"`,
235
275
  `data-mdcui-language="${escapeHtmlAttribute(info)}"`,
236
276
  identity.id ? `id="${escapeHtmlAttribute(identity.id)}"` : "",
277
+ ...inlineEventAttrs,
237
278
  `class="${escapeHtmlAttribute([
238
279
  `language-${info}`,
239
280
  ...identity.classes,
@@ -306,6 +347,7 @@ export function wrapWuiHeadingSections(html)
306
347
 
307
348
  export async function createWui(md,mdpath) // HTML
308
349
  {
350
+ const eventsById = fenceEventMap(md)
309
351
 
310
352
  const opts = {
311
353
  headings: { ids: true }
@@ -335,7 +377,7 @@ export async function createWui(md,mdpath) // HTML
335
377
  }
336
378
  )
337
379
 
338
- md = convertWuiTextareas(md)
380
+ md = convertWuiTextareas(md, eventsById)
339
381
  md = wrapWuiHeadingSections(md)
340
382
 
341
383
  const mdb = path.basename(mdpath);
@@ -89,6 +89,11 @@ npx jsmdcui --demo-imgtool
89
89
  npx jsmdcui --demo-imgtool-zh
90
90
  ```
91
91
 
92
+ ```sh
93
+ # Maze game demo
94
+ npx jsmdcui --demo-maze
95
+ ```
96
+
92
97
  ```sh
93
98
  # WUI(Web User Interface) Demo
94
99
  npx jsmdcui --wui
@@ -134,7 +139,7 @@ bun src/index.js --wui testapp.md
134
139
  | --- | --- |
135
140
  | `bun src/index.js app.md` | Render `app.md` as a read-only terminal UI and write five generated files beside it. |
136
141
  | `bun src/index.js --kitty app.md` | Display Markdown images with Kitty graphics and the jsgotty MIME extension. |
137
- | `bun src/index.js --kitty-compat app.md` | Display Markdown images with Kitty graphics without the non-standard MIME `U` field. |
142
+ | `bun src/index.js --kitty-compat app.md` | Convert Markdown images to PNG with `Bun.Image` and display them using the standard Kitty graphics protocol without the non-standard MIME `U` field. |
138
143
  | `bun src/index.js --kitty --allow-url URL.md` | Download trusted HTTP(S) Markdown and its HTTP(S) images, then display supported images with Kitty graphics. |
139
144
  | `JSMDCUI_KITTY_DEBUG=1 bun src/index.js --kitty app.md` | Enable Kitty image placement logging to `kitty-placement.log`. |
140
145
  | `bun src/index.js --check app.md` | Check heading and fenced-block IDs for collisions, print line-by-line details, and exit. |
@@ -239,12 +244,81 @@ native `<textarea>` with the declared ID and classes. Long text wraps
239
244
  automatically, and the field height is recalculated when the user types, the
240
245
  window is resized, or frontend code calls `.val(value)`.
241
246
 
247
+ In the TUI, `text` remains a single-line control while `textarea` supports
248
+ native multiline editing. Enter splits the current body row and grows the
249
+ frame, Backspace at the start of a later row joins it to the previous row, and
250
+ Delete at the end of a row joins the following row. When the expanded control
251
+ does not fit on screen, the document viewport scrolls to keep the cursor
252
+ visible. The closing border and following Markdown content move with the
253
+ resized control.
254
+
255
+ A named `text` or `textarea` block can run inline front-end code before it
256
+ handles a key by placing a quoted HTML-style `@keydown` attribute after its
257
+ identity:
258
+
259
+ ````md
260
+ ```text#command.field @keydown="handleCommand(event)"
261
+ Initial value
262
+ ```
263
+
264
+ ```js front
265
+ export function handleCommand(event) {
266
+ if (event.key !== 'Enter') return;
267
+ event.preventDefault();
268
+ alert(`Command: ${$('#command').val()}`);
269
+ }
270
+ ```
271
+ ````
272
+
273
+ The block must have a unique ID. In the WUI, jsmdcui writes the code directly
274
+ to the generated textarea's native `onkeydown` attribute. Mobile browsers may
275
+ report ordinary software-keyboard letters as `Unidentified`, so the WUI also
276
+ generates an `onbeforeinput` fallback. The unidentified event is hidden from
277
+ the handler; `beforeinput.data` becomes `event.key`, then the same keydown code
278
+ runs once. The fallback also preserves `ctrlKey`, `shiftKey`, `altKey`, and
279
+ `metaKey` from the unidentified keydown. A short zero-delay timer distinguishes
280
+ it from the `beforeinput` that normally follows an already identified keydown,
281
+ preventing duplicate calls.
282
+
283
+ On Android browsers, `Alt-E`, `Alt-N`, `Alt-U`, and `Alt-I` may be consumed or
284
+ transformed by the software keyboard and therefore cannot always be observed
285
+ reliably by a keydown handler. Avoid relying on these combinations for portable
286
+ WUI controls.
287
+
288
+ In the TUI, the full source info string is kept in an event table before Bun
289
+ renders it. The keydown statements run with a synthetic `event` before the
290
+ text control handles the key. Both interfaces expose `event.key`, modifier
291
+ flags, `event.target.id`, and `event.target.value`. Use `event`, the native
292
+ inline-handler variable, rather than Vue's `$event` alias. Double quotes
293
+ delimit the handler; use single-quoted JavaScript strings inside it or escape
294
+ an embedded double quote as `\"`.
295
+
296
+ Both interfaces add a non-enumerable `event.toJSON()` method with matching
297
+ keyboard, modifier, prevention, and target fields. `JSON.stringify(event)`
298
+ calls it automatically, so the resulting JSON is portable between the TUI and
299
+ WUI.
300
+
301
+ A keydown handler can call `event.preventDefault()` to stop text insertion or
302
+ cursor movement. The `.prevent` modifier applies this automatically:
303
+
304
+ ````md
305
+ ```text#command @keydown.prevent="handleCommand(event)"
306
+ ```
307
+ ````
308
+
309
+ `@keydown` is the only keyboard event exposed by jsmdcui. Traditional terminal
310
+ input does not report physical key releases reliably, so jsmdcui does not
311
+ provide or emulate `@keyup` in either interface.
312
+
242
313
  In the terminal TUI, only content after the protected `│ ` or `| ` prefix can
243
- be edited. The frame prefix cannot be deleted, Enter cannot insert a newline,
244
- Delete at the end of a row cannot join the next row, and multiline paste is
245
- blocked. Activate the lower-left frame corner to add a row. Activate the
246
- upper-left frame corner to remove the trailing row only when it is empty;
247
- non-empty content is never removed.
314
+ be edited, and the frame prefix cannot be deleted. Multiline paste remains
315
+ blocked. For single-line `text` controls, activate the lower-left frame corner
316
+ to add a row. Activate the upper-left frame corner to remove the trailing row
317
+ only when it is empty; non-empty content is never removed.
318
+
319
+ TUI text-control changes made through `.val(value)` participate in the same
320
+ history as direct editing. `Ctrl-Z` undoes the replacement and `Ctrl-Y` redoes
321
+ it, including multiline frame resizing and its associated rendered metadata.
248
322
 
249
323
  ### Heading task lists and selector API
250
324
 
@@ -384,7 +458,7 @@ The available selector methods are:
384
458
  | Method | TUI | WUI |
385
459
  | --- | --- | --- |
386
460
  | `.val()` | Read text blocks or heading task-list values. | Read textareas/controls or heading task-list values. |
387
- | `.val(value)` | Replace text-block contents and resize multiline values. | Set textarea/control values and resize textareas. |
461
+ | `.val(value)` | Replace text-block contents, resize multiline values, and record Undo/Redo history. | Set textarea/control values and resize textareas. |
388
462
  | `.html()` | Return a selected heading's inner HTML. | Return any successfully selected DOM element's `innerHTML`. |
389
463
  | `.line()` | Return a heading's current 1-based TUI row, or `0` if missing. | Not available. |
390
464
  | `.push(...items)` | Append unchecked strings or `{ value, checked }` task items; return the new direct-item count. | Same. |
@@ -397,16 +471,16 @@ The available selector methods are:
397
471
  TUI heading rows are recalculated after text-block rows are added, removed, or
398
472
  replaced with multiline `.val(value)` content.
399
473
 
400
- The three UI building blocks are:
474
+ The 3 UI building blocks are:
401
475
 
402
- - Regular Markdown provides headings, text, lists, task checkboxes, code, and
476
+ - 1. `Regular Markdown` provides headings, text, lists, task checkboxes, code, and
403
477
  links.
404
- - A `js front` block contains UI code. Exported functions can use
478
+ - 2. `js front` block contains UI code. Exported functions can use
405
479
  `alert`, `confirm`, `prompt`, and the generated `rpc` client.
406
- - A front module may export `async function onMdcuiExit({ reason, path, $ })`.
407
- The terminal UI awaits it before closing an `mdcui` buffer. Modified
408
- `mdcui` buffers close without a save prompt.
409
- - A `js back` block exports trusted backend functions. In the browser WUI,
480
+ * A front module may export `async function onMdcuiExit({ reason, path, $ })`.
481
+ * The terminal UI awaits it before closing an `mdcui` buffer.
482
+ * Modified `mdcui` buffers close without a save prompt.
483
+ - 3. `js back` block exports trusted backend functions. In the browser WUI,
410
484
  `rpc` publishes only exported functions whose exported names do not start
411
485
  with `_`. Call a published function from the front end with
412
486
  `await rpc.functionName(arg1, arg2)`.
@@ -489,15 +563,35 @@ its associated text. `alert`, `confirm`, and `prompt` use the browser's built-in
489
563
  dialogs. Checkbox changes exist only in the current page: refreshing does not
490
564
  preserve them and does not update the Markdown file.
491
565
 
492
- The WUI uses port `3000` by default and accepts connections through the
493
- machine's available network interfaces. The printed `localhost` URL is for the
494
- same machine. From another device on the same network, replace `localhost` with
495
- the server machine's IP address and keep the same port and full path.
566
+ The WUI first tries port `3000` and accepts connections through the machine's
567
+ available network interfaces. If port `3000` is already in use, the operating
568
+ system selects an available port instead; the printed URL always contains the
569
+ actual port. Set `PORT` to request another fixed port. The printed `localhost`
570
+ URL is for the same machine. From another device on the same network, replace
571
+ `localhost` with the server machine's IP address and keep the printed port and
572
+ full path.
496
573
 
497
574
  Each server start prints a new random path. The old URL stops working after the
498
575
  server is stopped or restarted. Keep the process running while using the page,
499
576
  and press `Ctrl-C` in its terminal to stop it.
500
577
 
578
+ ## Editing Markdown source
579
+
580
+ From a cloned repository, use the `edit` launcher to open a Markdown file as
581
+ ordinary editable UTF-8 source instead of rendering it as a Markdown UI:
582
+
583
+ ```sh
584
+ bun ./edit app.md
585
+ ```
586
+
587
+ This is equivalent to:
588
+
589
+ ```sh
590
+ bun src/index.js --edit app.md
591
+ ```
592
+
593
+ Additional file and cursor arguments are forwarded unchanged.
594
+
501
595
  ## Generated files
502
596
 
503
597
  Opening a local Markdown UI generates these files beside the source file:
@@ -44,7 +44,10 @@ Other micro APIs:
44
44
  e.g. for command "js 1+1": args.raw = "js 1+1", args.raw.slice(3) = "1+1"
45
45
  micro.RegisterAction(name, fn) — register bindable action
46
46
  micro.TermMessage(msg) — show msg in editor status row
47
- micro.alert(msg) — suspend editor, print msg, wait for Enter
47
+ micro.alert(msg) — synchronous; suspend editor, print msg, wait for Enter
48
+ e.g. micro.alert("Done") (do not await)
49
+ micro.confirm(msg) — synchronous boolean result
50
+ micro.prompt(msg, default?) — synchronous string or null result
48
51
  micro.Log(...args) — console.log passthrough
49
52
  micro.GetOption(name) micro.SetOption(name, value)
50
53
  micro.cmd.save() — call any editor command via proxy
@@ -169,7 +172,7 @@ micro.on("init", () => {
169
172
  const addr = isPublic ? `0.0.0.0:${port}` : `127.0.0.1:${port}`;
170
173
  micro.TermMessage(`CDP@${addr} server running 伺服器啟動了`)
171
174
 
172
- //await micro.alert(CdpServer)
175
+ //micro.alert(CdpServer)
173
176
  } // server not running
174
177
  else
175
178
  {
@@ -43,7 +43,10 @@ Other micro APIs:
43
43
  micro.MakeCommand(name, fn) — register Ctrl+E command; fn(bp, args[])
44
44
  micro.RegisterAction(name, fn) — register bindable action
45
45
  micro.TermMessage(msg) — show msg in editor status row
46
- micro.alert(msg) — suspend editor, print msg, wait for Enter
46
+ micro.alert(msg) — synchronous; suspend editor, print msg, wait for Enter
47
+ e.g. micro.alert("Done") (do not await)
48
+ micro.confirm(msg) — synchronous boolean result
49
+ micro.prompt(msg, default?) — synchronous string or null result
47
50
  micro.Log(...args) — console.log passthrough
48
51
  micro.GetOption(name) micro.SetOption(name, value)
49
52
  micro.cmd.save() — call any editor command via proxy
@@ -105,4 +108,3 @@ micro.on("init", () => {
105
108
 
106
109
  });
107
110
 
108
-
@@ -45,7 +45,10 @@ Other micro APIs:
45
45
  e.g. for command "js 1+1": args.raw = "js 1+1", args.raw.slice(3) = "1+1"
46
46
  micro.RegisterAction(name, fn) — register bindable action
47
47
  micro.TermMessage(msg) — show msg in editor status row
48
- micro.alert(msg) — suspend editor, print msg, wait for Enter
48
+ micro.alert(msg) — synchronous; suspend editor, print msg, wait for Enter
49
+ e.g. micro.alert("Done") (do not await)
50
+ micro.confirm(msg) — synchronous boolean result
51
+ micro.prompt(msg, default?) — synchronous string or null result
49
52
  micro.Log(...args) — console.log passthrough
50
53
  micro.GetOption(name) micro.SetOption(name, value)
51
54
  micro.cmd.save() — call any editor command via proxy
@@ -58,14 +61,14 @@ Other micro APIs:
58
61
 
59
62
  micro.on("init", () => {
60
63
  // Register a custom Ctrl+E command
61
- micro.MakeCommand("showpath", async (bp, args) =>
64
+ micro.MakeCommand("showpath", (bp, args) =>
62
65
  {
63
66
  //micro.TermMessage("Hello from JS plugin! Args: " + args.join(", "));
64
67
 
65
- //await micro.alert(micro.getLine())
68
+ //micro.alert(micro.getLine())
66
69
  const path = bp?.Buf?.Path || "(no path)";
67
70
  const loc = bp?.CursorLocation?.() || "+1.0:1";
68
- await micro.alert(`${path}\n${loc}`)
71
+ micro.alert(`${path}\n${loc}`)
69
72
 
70
73
  });
71
74
 
@@ -75,12 +78,12 @@ micro.on("init", () => {
75
78
  // args.raw is the full original input string, e.g. "js console.log('hi')"
76
79
  // slice(3) skips the "js " prefix (2-char name + 1 space)
77
80
  const scriptText = args.raw?.slice(3) ?? args.join(" ");
78
- await micro.alert(await eval(scriptText));
81
+ micro.alert(await eval(scriptText));
79
82
  });
80
83
 
81
84
 
82
85
  // greet: shows a message via micro.alert (leaves editor, shows text, Enter returns)
83
- micro.MakeCommand("greet", async (bp, args) =>
86
+ micro.MakeCommand("greet", (bp, args) =>
84
87
  {
85
88
  const name = args.length ?
86
89
  args.join(" ") : "world";
@@ -89,7 +92,7 @@ micro.on("init", () => {
89
92
  '# Hello\n- '+name
90
93
  );
91
94
 
92
- await micro.alert(s);
95
+ micro.alert(s);
93
96
  });
94
97
 
95
98
  // Register a custom action (can be bound to a key in keybindings.json)
@@ -52,6 +52,7 @@ rules:
52
52
  # keywords
53
53
  # javascript alert confirm prompt rpc
54
54
  # front back
55
+ # export function keydown
55
56
  # console log error JSON stringify parse
56
57
  # async await Promise
57
58
  # textarea text password search
@@ -0,0 +1,106 @@
1
+ const IDENTITY_RE = /^([A-Za-z_][\w:-]*)(?:#([A-Za-z_][\w:-]*))?((?:\.[A-Za-z_][\w:-]*)*)$/;
2
+
3
+ function parseIdentity(value) {
4
+ const match = String(value ?? "").match(IDENTITY_RE);
5
+ if (!match) return null;
6
+ return {
7
+ tag: match[1],
8
+ id: match[2] || null,
9
+ classes: match[3] ? match[3].slice(1).split(".") : [],
10
+ };
11
+ }
12
+
13
+ function parseEventAttributes(text) {
14
+ const events = new Map();
15
+ let offset = 0;
16
+ while (offset < text.length) {
17
+ while (/\s/.test(text[offset] ?? "")) offset++;
18
+ if (offset >= text.length || text[offset] !== "@") break;
19
+ offset++;
20
+ const nameStart = offset;
21
+ while (/[A-Za-z0-9_.:-]/.test(text[offset] ?? "")) offset++;
22
+ const name = text.slice(nameStart, offset);
23
+ while (/\s/.test(text[offset] ?? "")) offset++;
24
+ if (!name || text[offset] !== "=") break;
25
+ offset++;
26
+ while (/\s/.test(text[offset] ?? "")) offset++;
27
+ if (text[offset] !== '"') break;
28
+ offset++;
29
+
30
+ let code = "";
31
+ let closed = false;
32
+ while (offset < text.length) {
33
+ const ch = text[offset++];
34
+ if (ch === '"') {
35
+ closed = true;
36
+ break;
37
+ }
38
+ if (ch === "\\" && offset < text.length) {
39
+ const next = text[offset++];
40
+ code += next === '"' || next === "\\" ? next : `\\${next}`;
41
+ } else {
42
+ code += ch;
43
+ }
44
+ }
45
+ if (!closed) break;
46
+ const [eventName, ...modifiers] = name.split(".");
47
+ if (eventName === "keydown")
48
+ events.set(eventName, { code, modifiers: [...new Set(modifiers.filter(Boolean))] });
49
+ }
50
+ return events;
51
+ }
52
+
53
+ export function parseFenceDeclarations(markdown) {
54
+ const lines = String(markdown ?? "").split(/\r?\n/);
55
+ const declarations = [];
56
+ let fence = null;
57
+
58
+ for (let index = 0; index < lines.length; index++) {
59
+ const line = lines[index];
60
+ if (fence) {
61
+ const closing = line.match(/^ {0,3}(`{3,}|~{3,})\s*$/);
62
+ if (closing && closing[1][0] === fence.marker && closing[1].length >= fence.length)
63
+ fence = null;
64
+ continue;
65
+ }
66
+
67
+ const opening = line.match(/^ {0,3}(`{3,}|~{3,})([^]*)$/);
68
+ if (!opening) continue;
69
+ fence = { marker: opening[1][0], length: opening[1].length };
70
+ const info = String(opening[2] ?? "").trim();
71
+ const firstSpace = info.search(/\s/);
72
+ const identityText = firstSpace < 0 ? info : info.slice(0, firstSpace);
73
+ const attributesText = firstSpace < 0 ? "" : info.slice(firstSpace);
74
+ const identity = parseIdentity(identityText);
75
+ if (!identity) continue;
76
+ declarations.push({
77
+ ...identity,
78
+ identity: identityText,
79
+ events: parseEventAttributes(attributesText),
80
+ line: index + 1,
81
+ source: line.trim(),
82
+ });
83
+ }
84
+
85
+ return declarations;
86
+ }
87
+
88
+ export function fenceEventMap(markdown) {
89
+ const result = new Map();
90
+ for (const declaration of parseFenceDeclarations(markdown)) {
91
+ if (
92
+ !["text", "textarea"].includes(declaration.tag)
93
+ || !declaration.id
94
+ || declaration.events.size === 0
95
+ || result.has(declaration.id)
96
+ ) continue;
97
+ result.set(declaration.id, declaration);
98
+ }
99
+ return result;
100
+ }
101
+
102
+ export function inlineFenceEventCode(handler) {
103
+ if (!handler) return "";
104
+ const prefix = handler.modifiers.includes("prevent") ? "event.preventDefault();" : "";
105
+ return prefix + handler.code;
106
+ }
@@ -1,3 +1,5 @@
1
+ import { parseFenceDeclarations } from "./fence-events.mjs";
2
+
1
3
  function markdownHeadingDeclarations(markdown) {
2
4
  const lines = String(markdown).split(/\r?\n/);
3
5
  const declarations = [];
@@ -51,34 +53,14 @@ function markdownHeadingDeclarations(markdown) {
51
53
  }
52
54
 
53
55
  function fencedBlockDeclarations(markdown) {
54
- const lines = String(markdown).split(/\r?\n/);
55
- const declarations = [];
56
- let fence = null;
57
-
58
- for (let index = 0; index < lines.length; index++) {
59
- const line = lines[index];
60
- if (!fence) {
61
- const opening = line.match(/^ {0,3}(`{3,}|~{3,})\s*(\S+)?\s*$/);
62
- if (!opening) continue;
63
- fence = { marker: opening[1][0], length: opening[1].length };
64
- const identity = String(opening[2] ?? "").match(/^([A-Za-z_][\w:-]*)(?:#([A-Za-z_][\w:-]*))?(?:\.[A-Za-z_][\w:-]*)*$/);
65
- if (identity?.[2]) {
66
- declarations.push({
67
- id: identity[2],
68
- kind: `${identity[1]} fenced block`,
69
- line: index + 1,
70
- source: line.trim(),
71
- });
72
- }
73
- continue;
74
- }
75
-
76
- const closing = line.match(/^ {0,3}(`{3,}|~{3,})\s*$/);
77
- if (closing && closing[1][0] === fence.marker && closing[1].length >= fence.length)
78
- fence = null;
79
- }
80
-
81
- return declarations;
56
+ return parseFenceDeclarations(markdown)
57
+ .filter((declaration) => declaration.id)
58
+ .map((declaration) => ({
59
+ id: declaration.id,
60
+ kind: `${declaration.tag} fenced block`,
61
+ line: declaration.line,
62
+ source: declaration.source,
63
+ }));
82
64
  }
83
65
 
84
66
  export function checkMarkdownIdCollisions(markdown) {
@@ -117,6 +117,10 @@ function stableImageId(pathname, line, data) {
117
117
  return (hash >>> 0) % 2147483646 + 1;
118
118
  }
119
119
 
120
+ async function convertToKittyPng(data) {
121
+ return Buffer.from(await new Bun.Image(data).png().toBuffer());
122
+ }
123
+
120
124
  export function fitKittyImageToWidth(image, maxCols) {
121
125
  const originalCols = Math.max(1, Math.trunc(Number(image?.cols) || 1));
122
126
  const originalRows = Math.max(1, Math.trunc(Number(image?.rows) || 1));
@@ -151,8 +155,14 @@ export async function prepareKittyImages(ansiText, markdownPath, terminalCols =
151
155
  if (!(await file.exists())) throw new Error("missing image");
152
156
  data = Buffer.from(await file.arrayBuffer());
153
157
  }
154
- const size = imageSize(data);
158
+ let size = imageSize(data);
155
159
  if (!size?.width || !size?.height) throw new Error("unsupported image");
160
+ if (options.kittyMode === "compat" && size.mime !== "image/png") {
161
+ data = await convertToKittyPng(data);
162
+ size = imageSize(data);
163
+ if (size?.mime !== "image/png" || !size.width || !size.height)
164
+ throw new Error("PNG conversion failed");
165
+ }
156
166
  const estimatedCols = Math.max(1, Math.round(size.width / 8));
157
167
  const cols = Math.min(maxCols, estimatedCols);
158
168
  const rows = Math.max(1, Math.round((cols * size.height) / size.width / 2));
package/src/cui/rpc.mjs CHANGED
@@ -451,15 +451,18 @@ function safeFrontValue(value)
451
451
  };
452
452
  }
453
453
 
454
- export async function evalFront(mod, text)
454
+ export async function evalFront(mod, text, scope = {})
455
455
  {
456
456
  try{
457
457
 
458
458
  text = text.replace(/^javascript:/, "");
459
459
 
460
- const entries = Object.entries(mod).filter(([name]) => name !== "$");
460
+ const entryMap = new Map(Object.entries(mod).filter(([name]) => name !== "$"));
461
461
  if (typeof globalThis.$ === "function")
462
- entries.push(["$", globalThis.$]);
462
+ entryMap.set("$", globalThis.$);
463
+ for (const [name, value] of Object.entries(scope))
464
+ entryMap.set(name, value);
465
+ const entries = [...entryMap];
463
466
  const names = entries.map(([name]) => name);
464
467
  const values = entries.map(([, value]) => safeFrontValue(value));
465
468
 
@@ -50,7 +50,7 @@ export function main()
50
50
 
51
51
  const basePath = "/" + crypto.randomUUID()
52
52
  const pathname = basePath + "/"
53
- const server = Bun.serve({
53
+ const serverOptions = {
54
54
  port: Number(process.env.PORT || 3000),
55
55
  development: {
56
56
  hmr: true,
@@ -101,7 +101,18 @@ const server = Bun.serve({
101
101
  fetch() {
102
102
  return new Response("Not Found", { status: 404 });
103
103
  },
104
- });
104
+ };
105
+
106
+ let server;
107
+ try {
108
+ server = Bun.serve(serverOptions);
109
+ } catch (error) {
110
+ const addressInUse = error?.code === "EADDRINUSE"
111
+ || error?.cause?.code === "EADDRINUSE"
112
+ || /EADDRINUSE|address already in use/i.test(String(error?.message || error));
113
+ if (serverOptions.port !== 3000 || !addressInUse) throw error;
114
+ server = Bun.serve({ ...serverOptions, port: 0 });
115
+ }
105
116
 
106
117
  console.error(mda("- Bun RPC server listening on"));
107
118
  console.log(`http://localhost:${server.port}${pathname}`);