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/CHANGELOG.md CHANGED
@@ -2,6 +2,67 @@
2
2
 
3
3
  All notable user-visible changes to jsmdcui are documented here.
4
4
 
5
+ ## [0.8.0] - 2026-07-20
6
+
7
+ This update adds portable inline keydown handling to Markdown text controls,
8
+ including mobile software-keyboard fallback behavior, and makes interactive
9
+ prompts safe across every TUI frontend action path.
10
+
11
+ ### Added
12
+
13
+ - Add quoted `@keydown="..."` attributes to named `text` and `textarea`
14
+ fences. The TUI preserves source event metadata and evaluates it before key
15
+ handling; the WUI emits a native inline event attribute. Add `.prevent` to
16
+ apply `event.preventDefault()` before the handler runs. Keyup is intentionally
17
+ not exposed because traditional terminals do not report key releases
18
+ reliably.
19
+ - In the WUI, automatically map `beforeinput.data` back through `onkeydown`
20
+ when a mobile software keyboard reports `event.key` as `Unidentified`.
21
+ Unidentified events are hidden from application handlers, while identified
22
+ desktop keydowns are marked briefly to prevent duplicate calls.
23
+ - Add native multiline editing to TUI `textarea` controls. Enter splits a body
24
+ row and expands the frame; Backspace at the start of a later row and Delete
25
+ at the end of a row join adjacent body rows without exposing frame borders.
26
+ - Add the `bun ./edit` launcher as a short equivalent of
27
+ `bun src/index.js --edit`, opening Markdown as ordinary editable UTF-8 source.
28
+ - Add the automatically discovered `--demo-maze` example, demonstrating
29
+ portable `@keydown.prevent` controls with keyboard and arrow-key navigation;
30
+ add `Ctrl-R` as a keyboard shortcut for resetting the maze.
31
+
32
+ ### Changed
33
+
34
+ - Install protected `alert`, `confirm`, and `prompt` globals once for the TUI
35
+ lifetime and share them across OSC 8 actions, fenced keyboard handlers, and
36
+ JS plugins. Restore Bun's original globals when the TUI stops.
37
+ - Make `micro.alert()`, `micro.confirm()`, and `micro.prompt()` synchronous like
38
+ their native counterparts, and update the bundled JS plugin examples so they
39
+ do not `await` these calls.
40
+ - Add TUI `.val(value)` replacements to Undo/Redo history, including multiline
41
+ layout, ANSI rendering metadata, image positions, and task-list anchors.
42
+
43
+ ### Fixed
44
+
45
+ - Prevent `alert()` called from a keydown handler from competing with the TUI
46
+ for stdin by suspending raw mode and screen rendering around native prompts.
47
+ - Restore the terminal's previous raw-mode and input-listener state after a
48
+ protected prompt, including setup, prompt, and cleanup error paths.
49
+ - Index fenced keyboard-event regions once and use binary lookup for each key,
50
+ instead of rescanning the rendered Markdown document on every keypress.
51
+ - Open Windows controlling-terminal input through read-only `CONIN$` when stdin
52
+ is redirected, so piped Markdown retains keyboard and protected-prompt input.
53
+ - Preserve `ctrlKey`, `shiftKey`, `altKey`, and `metaKey` when the WUI maps an
54
+ unidentified mobile keydown through its `beforeinput` fallback.
55
+ - Recover modified A-Z keys in the WUI from key codes 65-90 or physical
56
+ `KeyA`-`KeyZ` codes, including Android browsers that transform Alt-S into
57
+ `ß`; exclude AltGraph so international text composition remains intact.
58
+ - Add matching non-enumerable `event.toJSON()` methods to TUI and WUI keydown
59
+ events, allowing `JSON.stringify(event)` to produce portable event details.
60
+ - Keep fenced-block IDs visible to collision checking when the info string also
61
+ contains inline event attributes such as `@keydown="..."`.
62
+ - When the WUI's default port 3000 is already in use, retry with port 0 so the
63
+ operating system selects an available port, and print that actual port in the
64
+ generated URL. Other server startup errors continue to fail normally.
65
+
5
66
  ## [0.7.0] - 2026-07-20
6
67
 
7
68
  This update adds native Kitty image rendering, turns heading task lists into
package/README.md CHANGED
@@ -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:
package/demos/maze.md ADDED
@@ -0,0 +1,144 @@
1
+ ```text#character
2
+ 🧱😀🧱🧱🧱🧱🧱🧱🧱🧱🧱🧱
3
+ 🧱 🧱 🧱
4
+ 🧱 🧱🧱🧱 🧱 🧱🧱🧱🧱
5
+ 🧱 🧱 🧱 🧱
6
+ 🧱🧱🧱 🧱🧱🧱🧱🧱 🧱🧱
7
+ 🧱 🧱 🧱 🧱
8
+ 🧱 🧱🧱🧱 🧱 🧱 🧱🧱
9
+ 🧱 🧱 🧱 🧱
10
+ 🧱 🧱🧱🧱🧱🧱🧱🧱 🧱🧱
11
+ 🧱 🧱 🧱
12
+ 🧱 🧱 🧱🧱🧱 🧱🧱🧱🧱
13
+ 🧱 🧱 🧱
14
+ 🧱🧱🧱🧱🧱🧱🧱🧱🧱 🧱🧱
15
+ ```
16
+
17
+ ```text#controls @keydown.prevent="handle(event)"
18
+ Put the cursor here, then press:
19
+ 將遊標放在這裡,然後按:
20
+
21
+ L't half of QWERTY ← move left
22
+ R't half of QWERTY → move right
23
+ T / Y ↑ move up
24
+ Space ↓ move down
25
+
26
+ 鍵盤左半邊 ← 向左移動
27
+ 鍵盤右半邊 → 向右移動
28
+ T / Y 向上移動
29
+ 空白鍵向下移動
30
+
31
+ Arrow keys ←↑→↓ / 正常方向移動
32
+ Ctrl-R Reset maze / 重新開始
33
+
34
+ Do not hit 🧱 / 不能撞牆
35
+ ```
36
+
37
+ ```text#last-key
38
+ Waiting for input / 等待輸入
39
+ ```
40
+
41
+ [🔄 Reset maze / 重新開始](javascript:reset())
42
+
43
+ ```js front
44
+ const LEFT_KEYS = new Set([
45
+ ...'`12345qwerasdfgzxcvb',
46
+ 'escape', 'tab',
47
+ ]);
48
+ const RIGHT_KEYS = new Set([
49
+ ...'67890-=uiop[]\\hjkl;\'nm,./',
50
+ 'backspace', 'enter', 'delete',
51
+ 'home', 'end', 'pageup', 'pagedown',
52
+ ]);
53
+ const MAZE = [
54
+ '# ##########',
55
+ '# # #',
56
+ '# ### # ####',
57
+ '# # # #',
58
+ '### ##### ##',
59
+ '# # # #',
60
+ '# ### # # ##',
61
+ '# # # #',
62
+ '# ####### ##',
63
+ '# # #',
64
+ '# # ### ####',
65
+ '# # #',
66
+ '######### ##',
67
+ ];
68
+ const GOAL = { row: MAZE.length - 1, col: 9 };
69
+ const player = { row: 0, col: 1 };
70
+ let completed = false;
71
+
72
+ function drawMaze() {
73
+ const screen = MAZE.map((line, row) => [...line].map((cell, col) => {
74
+ if (row === player.row && col === player.col) return '😀';
75
+ return cell === '#' ? '🧱' : ' ';
76
+ }).join('')).join('\n');
77
+ $('#character').val(screen);
78
+ }
79
+
80
+ export function reset() {
81
+ player.row = 0;
82
+ player.col = 1;
83
+ completed = false;
84
+ drawMaze();
85
+ $('#last-key').val('Waiting for input / 等待輸入');
86
+ }
87
+
88
+ export function handle(event) {
89
+ const key = String(event.key ?? '').toLowerCase();
90
+ if (event.ctrlKey && key === 'r') {
91
+ reset();
92
+ return;
93
+ }
94
+ if (completed) return;
95
+
96
+ let row = player.row;
97
+ let col = player.col;
98
+ let direction;
99
+
100
+ if (key === 't' || key === 'y' || key === 'arrowup') {
101
+ row--;
102
+ direction = 'up / 上';
103
+ } else if (key === ' ' || key === 'arrowdown') {
104
+ row++;
105
+ direction = 'down / 下';
106
+ } else if (key === 'arrowleft' || LEFT_KEYS.has(key)) {
107
+ col--;
108
+ direction = 'left / 左';
109
+ } else if (key === 'arrowright' || RIGHT_KEYS.has(key)) {
110
+ col++;
111
+ direction = 'right / 右';
112
+ } else {
113
+ $('#last-key').val(`Ignored / 忽略:${event.key}`);
114
+ return;
115
+ }
116
+
117
+ if (MAZE[row]?.[col] !== ' ') {
118
+ $('#last-key').val(`${event.key === ' ' ? 'Space' : event.key} → 🧱 Wall / 撞牆`);
119
+ return;
120
+ }
121
+
122
+ player.row = row;
123
+ player.col = col;
124
+ drawMaze();
125
+
126
+ if (row === GOAL.row && col === GOAL.col) {
127
+ completed = true;
128
+ $('#last-key').val('🎉🏆🌟 Escaped the maze! / 成功走出迷宮!🌟🏆🎉');
129
+ alert([
130
+ '🎆🎇🎉 恭 喜 過 關 ! 🎉🎇🎆',
131
+ '',
132
+ '🥳 你成功逃出迷宮了! 🥳',
133
+ '👑 🏆 ⭐ 🌟 💫 🌈 🦄',
134
+ '👏👏👏 AMAZING! 👏👏👏',
135
+ '🎈 🎊 🎁 🪩 🎁 🎊 🎈',
136
+ '',
137
+ '🚀 ESCAPED THE MAZE! 🚀',
138
+ ].join('\n'));
139
+ } else {
140
+ const label = event.key === ' ' ? 'Space' : event.key;
141
+ $('#last-key').val(`${label} → ${direction} (${row}, ${col})`);
142
+ }
143
+ }
144
+ ```
package/demos/todo-zh.md CHANGED
@@ -18,10 +18,25 @@ Fly to the moon
18
18
  在上方輸入 Todo 文字,再選擇新增或移除。移除會刪除第一個文字完全相同的項目。
19
19
 
20
20
  - [新增 Todo](javascript:addTodo())
21
+ - .
21
22
  - [移除 Todo](javascript:removeTodo())
23
+ - .
22
24
  - [顯示已完成](javascript:showCompleted())
25
+ - .
23
26
  - [顯示未完成](javascript:showPending())
24
27
 
28
+ ## 存檔/讀檔
29
+
30
+ ```text#todo-file
31
+ todo_list.json
32
+ ```
33
+
34
+ 在上方輸入 JSON 檔案路徑。存檔會寫入人類可讀的 JSON;讀檔會在確認後取代目前的 Todo 清單。
35
+
36
+ - [儲存 Todos](javascript:saveTodos())
37
+ - .
38
+ - [讀取 Todos](javascript:loadTodos())
39
+
25
40
  ## 操作結果:
26
41
 
27
42
  ```text#todo-status
@@ -51,6 +66,14 @@ function showItems(title, checked) {
51
66
  $('#todo-status').val(`找到 ${items.length} 個${title}項目`);
52
67
  }
53
68
 
69
+ function todoFile() {
70
+ return $('#todo-file').val().trim();
71
+ }
72
+
73
+ function describeError(error) {
74
+ return error?.message || String(error);
75
+ }
76
+
54
77
  export function addTodo() {
55
78
  const value = todoText();
56
79
  if (!value) {
@@ -89,4 +112,79 @@ export function showCompleted() {
89
112
  export function showPending() {
90
113
  showItems('未完成', false);
91
114
  }
115
+
116
+ export async function saveTodos() {
117
+ const file = todoFile();
118
+ if (!file) {
119
+ $('#todo-status').val('存檔失敗:請先輸入 JSON 檔案路徑');
120
+ return;
121
+ }
122
+
123
+ try {
124
+ const obj = { todos: $('#todos').slice() };
125
+ const result = await rpc.saveTodoList(file, obj);
126
+ $('#todo-status').val(`已將 ${obj.todos.length} 個 Todo 儲存至 ${result.path}`);
127
+ } catch (error) {
128
+ $('#todo-status').val(`存檔失敗:${describeError(error)}`);
129
+ }
130
+ }
131
+
132
+ export async function loadTodos() {
133
+ const file = todoFile();
134
+ if (!file) {
135
+ $('#todo-status').val('讀檔失敗:請先輸入 JSON 檔案路徑');
136
+ return;
137
+ }
138
+ try {
139
+ const result = await rpc.loadTodoList(file);
140
+ const json = JSON.stringify(result.obj, null, 1);
141
+ if (!confirm(`要用「${file}」中的以下 JSON 取代目前的 Todo 清單嗎?\n\n${json}`)) {
142
+ $('#todo-status').val('已取消讀檔,目前的 Todos 未變更');
143
+ return;
144
+ }
145
+ const current = $('#todos').slice();
146
+ $('#todos').splice(0, current.length, ...result.obj.todos);
147
+ $('#todo-status').val(`已從 ${result.path} 讀取 ${result.obj.todos.length} 個 Todo`);
148
+ } catch (error) {
149
+ $('#todo-status').val(`讀檔失敗:${describeError(error)}`);
150
+ }
151
+ }
152
+ ```
153
+
154
+ ```js back
155
+ import { resolve } from 'node:path';
156
+
157
+ function todoPath(input) {
158
+ const value = String(input ?? '').trim();
159
+ if (!value) throw new Error('請先輸入 JSON 檔案路徑');
160
+ return resolve(value);
161
+ }
162
+
163
+ function validateTodoObject(obj) {
164
+ if (!obj || typeof obj !== 'object' || !Array.isArray(obj.todos))
165
+ throw new Error('Todo JSON 必須是包含 todos 陣列的物件');
166
+
167
+ return {
168
+ todos: obj.todos.map((item, index) => {
169
+ if (!item || typeof item !== 'object' || typeof item.value !== 'string')
170
+ throw new Error(`第 ${index + 1} 個 Todo 項目必須包含字串 value`);
171
+ return { value: item.value, checked: Boolean(item.checked) };
172
+ }),
173
+ };
174
+ }
175
+
176
+ export async function saveTodoList(input, obj) {
177
+ const path = todoPath(input);
178
+ const validated = validateTodoObject(obj);
179
+ await Bun.write(path, JSON.stringify(validated, null, 1) + '\n');
180
+ return { path };
181
+ }
182
+
183
+ export async function loadTodoList(input) {
184
+ const path = todoPath(input);
185
+ const file = Bun.file(path);
186
+ if (!await file.exists()) throw new Error(`找不到 Todo 檔案:${path}`);
187
+ const obj = validateTodoObject(JSON.parse(await file.text()));
188
+ return { path, obj };
189
+ }
92
190
  ```
package/demos/todo.md CHANGED
@@ -18,10 +18,25 @@ Fly to the moon
18
18
  Enter Todo text above, then choose Add or Remove. Remove deletes the first item whose text matches exactly.
19
19
 
20
20
  - [Add Todo](javascript:addTodo())
21
+ - .
21
22
  - [Remove Todo](javascript:removeTodo())
23
+ - .
22
24
  - [Show Completed](javascript:showCompleted())
25
+ - .
23
26
  - [Show Pending](javascript:showPending())
24
27
 
28
+ ## Save / Load
29
+
30
+ ```text#todo-file
31
+ todo_list.json
32
+ ```
33
+
34
+ Enter a JSON file path above. Saving writes human-readable JSON; loading replaces the current Todo list after confirmation.
35
+
36
+ - [Save Todos](javascript:saveTodos())
37
+ - .
38
+ - [Load Todos](javascript:loadTodos())
39
+
25
40
  ## Operation Result
26
41
 
27
42
  ```text#todo-status
@@ -52,6 +67,14 @@ function showItems(title, checked) {
52
67
  $('#todo-status').val(`Found ${items.length} ${title.toLowerCase()} ${noun}`);
53
68
  }
54
69
 
70
+ function todoFile() {
71
+ return $('#todo-file').val().trim();
72
+ }
73
+
74
+ function describeError(error) {
75
+ return error?.message || String(error);
76
+ }
77
+
55
78
  export function addTodo() {
56
79
  const value = todoText();
57
80
  if (!value) {
@@ -90,4 +113,79 @@ export function showCompleted() {
90
113
  export function showPending() {
91
114
  showItems('Pending', false);
92
115
  }
116
+
117
+ export async function saveTodos() {
118
+ const file = todoFile();
119
+ if (!file) {
120
+ $('#todo-status').val('Save failed: enter a JSON file path first');
121
+ return;
122
+ }
123
+
124
+ try {
125
+ const obj = { todos: $('#todos').slice() };
126
+ const result = await rpc.saveTodoList(file, obj);
127
+ $('#todo-status').val(`Saved ${obj.todos.length} Todos to ${result.path}`);
128
+ } catch (error) {
129
+ $('#todo-status').val(`Save failed: ${describeError(error)}`);
130
+ }
131
+ }
132
+
133
+ export async function loadTodos() {
134
+ const file = todoFile();
135
+ if (!file) {
136
+ $('#todo-status').val('Load failed: enter a JSON file path first');
137
+ return;
138
+ }
139
+ try {
140
+ const result = await rpc.loadTodoList(file);
141
+ const json = JSON.stringify(result.obj, null, 1);
142
+ if (!confirm(`Replace the current Todo list with this JSON from “${file}”?\n\n${json}`)) {
143
+ $('#todo-status').val('Load cancelled; current Todos were not changed');
144
+ return;
145
+ }
146
+ const current = $('#todos').slice();
147
+ $('#todos').splice(0, current.length, ...result.obj.todos);
148
+ $('#todo-status').val(`Loaded ${result.obj.todos.length} Todos from ${result.path}`);
149
+ } catch (error) {
150
+ $('#todo-status').val(`Load failed: ${describeError(error)}`);
151
+ }
152
+ }
153
+ ```
154
+
155
+ ```js back
156
+ import { resolve } from 'node:path';
157
+
158
+ function todoPath(input) {
159
+ const value = String(input ?? '').trim();
160
+ if (!value) throw new Error('Enter a JSON file path first');
161
+ return resolve(value);
162
+ }
163
+
164
+ function validateTodoObject(obj) {
165
+ if (!obj || typeof obj !== 'object' || !Array.isArray(obj.todos))
166
+ throw new Error('Todo JSON must be an object containing a todos array');
167
+
168
+ return {
169
+ todos: obj.todos.map((item, index) => {
170
+ if (!item || typeof item !== 'object' || typeof item.value !== 'string')
171
+ throw new Error(`Todo item ${index + 1} must contain a string value`);
172
+ return { value: item.value, checked: Boolean(item.checked) };
173
+ }),
174
+ };
175
+ }
176
+
177
+ export async function saveTodoList(input, obj) {
178
+ const path = todoPath(input);
179
+ const validated = validateTodoObject(obj);
180
+ await Bun.write(path, JSON.stringify(validated, null, 1) + '\n');
181
+ return { path };
182
+ }
183
+
184
+ export async function loadTodoList(input) {
185
+ const path = todoPath(input);
186
+ const file = Bun.file(path);
187
+ if (!await file.exists()) throw new Error(`Todo file not found: ${path}`);
188
+ const obj = validateTodoObject(JSON.parse(await file.text()));
189
+ return { path, obj };
190
+ }
93
191
  ```
package/edit ADDED
@@ -0,0 +1,9 @@
1
+ #!/bin/sh
2
+
3
+ ':' //; sd=$(dirname "$(realpath "$0")")
4
+
5
+ ':' //; exec bun "$sd"/src/index.js --edit "$@"
6
+
7
+ process.argv.splice(2,0,'--edit')
8
+
9
+ await import('./src/index.js')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jsmdcui",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Markdown as a Common UI for Terminals and Web Browsers",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",