jsmdcui 0.5.0 → 0.6.3

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.
@@ -1,3 +1,20 @@
1
+ ![Demo](https://raw.githubusercontent.com/jjtseng93/jsmdcui/main/demo.jpg)
2
+
3
+ # Demo App
4
+ ## My app
5
+
6
+ - [Say hello](javascript:sayHello())
7
+ - [Get server time](javascript:showServerTime())
8
+ - [Update text box](javascript:updateText())
9
+ - [Show selected](javascript:alert(JSON.stringify($('#my-app').val())))
10
+ - [x] task1
11
+ - [x] task2
12
+ - [ ] unselected task
13
+
14
+ ```text#myid.myclass
15
+ Editable in both TUI and WUI
16
+ ```
17
+
1
18
  # Introduction
2
19
  - jsmdcui stands for:
3
20
  - JavaScript Markdown Cross-environment User Interface
@@ -10,6 +27,12 @@
10
27
  - Original projects:
11
28
  - https://github.com/jjtseng93/bunmicro
12
29
  - https://github.com/micro-editor/micro
30
+ - .
31
+ - This README is itself a runnable app.
32
+
33
+ ```sh
34
+ npx jsmdcui@latest README.md
35
+ ```
13
36
 
14
37
  ## Quick start
15
38
 
@@ -24,28 +47,52 @@ On other platforms, follow the [official Bun installation guide](https://bun.com
24
47
  Choose either of these two ways to run jsmdcui.
25
48
 
26
49
  > **Important:** Opening or rendering a local `.md` file writes or overwrites
27
- > five generated files beside it. Starting `--wui` without a file writes the
28
- > five files generated from `testapp.md` in the current directory. The source
50
+ > 5 generated files beside it. Starting `--wui` without a file writes the
51
+ > 5 files generated from `testapp.md` in the current directory. The source
29
52
  > Markdown is not changed, but you should run the demo in a directory where
30
53
  > overwriting generated files is safe.
31
54
 
32
55
  ### Route 1: Run with npx
33
56
 
34
- This route requires both `npx` and `bun` on your `PATH`; no source checkout is
35
- required. Open the normal terminal editor:
57
+ - This route requires both `npx` and `bun` on your `PATH`; no source checkout is
58
+ required.
59
+
60
+ #### Open the normal terminal editor
36
61
 
37
62
  ```sh
38
63
  npx jsmdcui
39
64
  ```
40
65
 
41
- Or start the default browser demo:
66
+ #### Start `testapp.md` as the TUI/WUI demo
67
+ - If `testapp.md` is missing, the bundled copy is written to the current working directory
68
+ - If `testapp.md` already exists, it will be used and won't be overwritten.
42
69
 
43
70
  ```sh
71
+ # TUI(Terminal User Interface) Demo
72
+ npx jsmdcui --demo
73
+ ```
74
+
75
+ ```sh
76
+ # Heading and task-list selector demo
77
+ npx jsmdcui --demo-select
78
+ ```
79
+
80
+ ```sh
81
+ # Bun.Image processor demo
82
+ npx jsmdcui --demo-imgtool
83
+ ```
84
+
85
+ ```sh
86
+ # Traditional Chinese Bun.Image processor demo
87
+ npx jsmdcui --demo-imgtool-zh
88
+ ```
89
+
90
+ ```sh
91
+ # WUI(Web User Interface) Demo
44
92
  npx jsmdcui --wui
45
93
  ```
46
94
 
47
- If the current directory already contains `testapp.md`, that local file is used
48
- instead of the bundled demo. To open your own Markdown UI, pass its path:
95
+ #### Open your own Markdown CUI App
49
96
 
50
97
  ```sh
51
98
  npx jsmdcui app.md
@@ -60,30 +107,37 @@ cd jsmdcui
60
107
  bun src/index.js testapp.md
61
108
  ```
62
109
 
63
- The last command opens the included demo in the terminal. Use the arrow keys to
64
- move, `Enter` or `Space` to activate an item, and `Ctrl-Q` to quit. To open the
65
- same demo in a browser instead:
110
+ - The last command opens the included demo in the terminal
111
+ - Use arrow keys to move around
112
+ - `Enter`, `Space`, or mouse click to activate an item, and `Ctrl-Q` to quit.
113
+ - To open the same demo in a browser instead:
66
114
 
67
115
  ```sh
68
116
  bun src/index.js --wui testapp.md
69
117
  ```
70
118
 
71
- After starting `--wui`, open the last printed `http://...` URL in a browser.
72
- Keep the command running while using the page, and press `Ctrl-C` in that
73
- terminal to stop the server.
119
+ - After starting `--wui`, open the last printed `http://...` URL in a browser.
120
+ - Keep the command running while using the CUI App, and press `Ctrl-C` in that terminal to stop the server.
121
+
122
+ ### Usage table
74
123
 
75
- The command table below uses the cloned-source form. If you use npx, replace
76
- `bun src/index.js` with `npx jsmdcui`.
124
+ - The command table below uses the cloned-source form.
125
+ - If you use npx, replace `bun src/index.js` with `npx jsmdcui`
77
126
 
78
127
  | Command | Result |
79
128
  | --- | --- |
80
129
  | `bun src/index.js app.md` | Render `app.md` as a read-only terminal UI and write five generated files beside it. |
130
+ | `bun src/index.js --check app.md` | Check heading and fenced-block IDs for collisions, print line-by-line details, and exit. |
81
131
  | `bun src/index.js --edit app.md` | Open `app.md` as editable UTF-8 source, overriding automatic Markdown UI detection. |
82
132
  | `bun src/index.js --cat app.md` | Render the terminal version to stdout, write five generated files beside it, and exit. |
83
133
  | `bun src/index.js --testapp.md` | Write the bundled `testapp.md` source to stdout and exit. |
84
- | `bun src/index.js --demo` | Outputs & overwrites `./testapp.md`, opens it in the terminal UI, and writes 5 generated files beside it. |
134
+ | `bun src/index.js --export-readme` | Write or overwrite `./README.md` with the bundled README source and exit. |
135
+ | `bun src/index.js --demo` | Use local `testapp.md` when present, otherwise write the bundled demo; open it in the terminal UI and write five generated files beside it. |
136
+ | `bun src/index.js --demo-select` | Use local `select.md` when present, otherwise write the bundled selector demo; open it in the terminal UI and write five generated files beside it. |
137
+ | `bun src/index.js --demo-imgtool` | Use local `image-processor.md` when present, otherwise write the bundled Bun.Image processor; open it in the terminal UI and write five generated files beside it. |
138
+ | `bun src/index.js --demo-imgtool-zh` | Use local `image-processor.zh-TW.md` when present, otherwise write the bundled Traditional Chinese Bun.Image processor; open it in the terminal UI and write five generated files beside it. |
85
139
  | `bun src/index.js --allow-url URL.md` | Download HTTP(S) Markdown to the current directory, write 5 generated files, and allow its embedded code to run. Only use trusted URLs. |
86
- | `bun src/index.js --wui` | Use local `testapp.md` when present, otherwise use the bundled demo; write five generated files in the current directory, then print and serve a random URL. |
140
+ | `bun src/index.js --wui` | Use local `testapp.md` when present, otherwise write the bundled demo; write five generated files in the current directory, then print and serve a random URL. |
87
141
  | `bun src/index.js --wui app.md` | Write five generated files beside `app.md`, then print and serve a random URL. |
88
142
  | `PORT=8080 bun src/index.js --wui app.md` | Start the browser UI on another port. |
89
143
  | `bun src/index.js` | Open the normal terminal editor with an empty buffer. |
@@ -96,11 +150,15 @@ options.
96
150
  Create `app.md`:
97
151
 
98
152
  ````md
99
- # My app
153
+ ## My app
100
154
 
101
155
  - [Say hello](javascript:sayHello())
102
156
  - [Get server time](javascript:showServerTime())
103
157
  - [Update text box](javascript:updateText())
158
+ - [Show selected](javascript:alert(JSON.stringify($('#my-app').val())))
159
+ - [x] task1
160
+ - [x] task2
161
+ - [ ] unselected task
104
162
 
105
163
  ```text#myid.myclass
106
164
  Editable in both TUI and WUI
@@ -136,6 +194,11 @@ bun src/index.js app.md
136
194
  bun src/index.js --wui app.md
137
195
  ```
138
196
 
197
+ - The resulting App UI is shown at the beginning of this README.md
198
+ - Run this demo app directly by:
199
+ * bun src/index.js README.md
200
+ * If you didn't clone the repo, use --export-readme to write README.md to the current folder
201
+
139
202
  ### Text blocks
140
203
 
141
204
  Both `text` and `textarea` fenced blocks define editable text fields:
@@ -146,6 +209,20 @@ Initial value
146
209
  ```
147
210
  ````
148
211
 
212
+ A fenced-block declaration must include a tag. Its supported identity syntax is
213
+ `tag`, optionally followed by `#id` and one or more `.class` names, for
214
+ example `text#message.note` or `textarea#notes.readonly`. Tags, IDs, and class
215
+ names must begin with an ASCII letter or underscore; their remaining
216
+ characters may also include digits, `_`, `-`, and `:`. Declarations such as
217
+ `#message` and `#message.note` have no tag and are therefore not recognized as
218
+ selectable fenced blocks.
219
+
220
+ The currently supported fenced-block tags are only `text` and `textarea` so
221
+ the same control works consistently in both the TUI and WUI. Selector queries
222
+ may omit the tag or classes after a valid declaration, so all of
223
+ `$('text#message.note')`, `$('text#message')`, `$('#message.note')`, and
224
+ `$('#message')` can select the example above.
225
+
149
226
  The same Markdown works in both interfaces. In the browser WUI it becomes a
150
227
  native `<textarea>` with the declared ID and classes. Long text wraps
151
228
  automatically, and the field height is recalculated when the user types, the
@@ -158,6 +235,99 @@ blocked. Activate the lower-left frame corner to add a row. Activate the
158
235
  upper-left frame corner to remove the trailing row only when it is empty;
159
236
  non-empty content is never removed.
160
237
 
238
+ ### Heading task lists and selector API
239
+
240
+ Headings can act as form-group selectors. jsmdcui uses the IDs generated by
241
+ `Bun.markdown.html(..., { headings: { ids: true } })`; for example,
242
+ `## Select Color` becomes `#select-color`. A heading selection reads the first
243
+ task list directly following that heading and stops at the next heading. Nested
244
+ task items are not included in the outer list's value.
245
+
246
+ Heading IDs share the same selector namespace as all explicitly named fenced
247
+ blocks, not only `text` and `textarea`, so avoid name collisions between them.
248
+ For example, `## Write Status` generates `#write-status` and must not be used
249
+ together with a block such as `text#write-status` or
250
+ `textarea#write-status`.
251
+ Otherwise, `$('#write-status')` may select the heading instead of the block and
252
+ updates can appear to do nothing. Rename either declaration so every
253
+ selectable ID is unique.
254
+
255
+ Duplicate source headings are also treated as collisions when they generate
256
+ the same base ID. Although Bun would automatically rename later headings with
257
+ suffixes such as `-1` and `-2`, those implicit selector names are not visible
258
+ in the Markdown source and should not be relied on. Give each heading a name
259
+ that produces a unique ID before rendering.
260
+
261
+ Run `bun src/index.js --check app.md` (or `jsmdcui --check app.md`) to
262
+ check these IDs without opening either UI or writing generated files. The
263
+ command prints each collision with its source type, line number, and original
264
+ declaration, then exits immediately. Its exit status is `0` when all IDs are
265
+ unique, `1` when collisions are found, and `2` for invalid arguments or read
266
+ errors.
267
+
268
+ If the heading ID begins with `select`, `.val()` behaves like a single select:
269
+ it returns the first checked item or `null`. Other heading IDs behave like a
270
+ multiple select and return all checked items as an array, or `[]` when none are
271
+ checked.
272
+
273
+ Heading values contain the complete visible label, including any explanatory
274
+ text. When interpreting a selection, prefer `value.includes(...)` (or another
275
+ deliberate prefix/token parser) unless labels are guaranteed to be exact,
276
+ stable identifiers. For example, `yes(flip vertically)` should not be tested
277
+ with `value === "yes"`.
278
+
279
+ ````md
280
+ ## Select Color
281
+
282
+ - [ ] Red
283
+ - [x] Green
284
+ - [ ] Blue
285
+
286
+ ## Features
287
+
288
+ - [x] Search
289
+ - [ ] Notifications
290
+ - [x] Offline mode
291
+
292
+ ```js front
293
+ export function showValues() {
294
+ alert(JSON.stringify({
295
+ color: $('#select-color').val(),
296
+ features: $('#features').val(),
297
+ }));
298
+ }
299
+ ```
300
+ ````
301
+
302
+ The same getter works in TUI and WUI:
303
+
304
+ ```js
305
+ $('#select-color').val() // "Green"
306
+ $('#features').val() // ["Search", "Offline mode"]
307
+ ```
308
+
309
+ The bundled `select.md` is a multilevel runnable example. Use `--demo-select`
310
+ to write it into the current directory when missing and open it in the TUI, or
311
+ open it explicitly in either interface:
312
+
313
+ ```sh
314
+ bun src/index.js --demo-select
315
+ bun src/index.js select.md
316
+ bun src/index.js --wui select.md
317
+ ```
318
+
319
+ The available selector methods are:
320
+
321
+ | Method | TUI | WUI |
322
+ | --- | --- | --- |
323
+ | `.val()` | Read text blocks or heading task-list values. | Read textareas/controls or heading task-list values. |
324
+ | `.val(value)` | Replace text-block contents and resize multiline values. | Set textarea/control values and resize textareas. |
325
+ | `.html()` | Return a selected heading's inner HTML. | Return any successfully selected DOM element's `innerHTML`. |
326
+ | `.line()` | Return a heading's current 1-based TUI row, or `0` if missing. | Not available. |
327
+
328
+ TUI heading rows are recalculated after text-block rows are added, removed, or
329
+ replaced with multiline `.val(value)` content.
330
+
161
331
  The three UI building blocks are:
162
332
 
163
333
  - Regular Markdown provides headings, text, lists, task checkboxes, code, and
@@ -213,8 +383,8 @@ selection, search, and copy remain available.
213
383
  | Input | Result |
214
384
  | --- | --- |
215
385
  | Arrow keys, `Home`, `End`, `PageUp`, `PageDown` | Move through the rendered UI. |
216
- | `Enter` or `Space` | Activate the cell under the cursor. Put the cursor on a `javascript:` link to run it. |
217
- | Left click | Move to and activate the clicked cell. A `javascript:` link runs immediately. |
386
+ | `Enter` or `Space` | Activate the cell under the cursor. Put the cursor on a `javascript:` link to run it, or on or to the right of `☐` or `☒` to toggle that task. |
387
+ | Left click | Move to and activate the clicked cell. A `javascript:` link runs immediately; clicking on or to the right of `☐` or `☒` toggles that task. |
218
388
  | Mouse wheel | Scroll three rows at a time. |
219
389
  | `Shift` + arrow keys | Select rendered text. |
220
390
  | `Ctrl-C` | Copy the selection, or the current line when nothing is selected. |
@@ -232,9 +402,10 @@ normal links in the browser.
232
402
 
233
403
  The WUI uses normal browser mouse and keyboard behavior. Clicking a
234
404
  `javascript:` link calls its front-end function, regular links navigate
235
- normally, and task checkboxes can be toggled. `alert`, `confirm`, and `prompt`
236
- use the browser's built-in dialogs. Checkbox changes exist only in the current
237
- page: refreshing does not preserve them and does not update the Markdown file.
405
+ normally, and task checkboxes can be toggled by clicking either the checkbox or
406
+ its associated text. `alert`, `confirm`, and `prompt` use the browser's built-in
407
+ dialogs. Checkbox changes exist only in the current page: refreshing does not
408
+ preserve them and does not update the Markdown file.
238
409
 
239
410
  The WUI uses port `3000` by default and accepts connections through the
240
411
  machine's available network interfaces. The printed `localhost` URL is for the
@@ -260,6 +431,26 @@ app.md-server.js
260
431
  They are regenerated from `app.md`, so edit the Markdown source rather than the
261
432
  generated files. The source directory must be writable.
262
433
 
434
+ ### Generated heading sections
435
+
436
+ WUI output wraps each h1-h6 and its content in a hierarchical `<section>`.
437
+ Lower-level headings create nested sections; the next equal or higher-level
438
+ heading closes the applicable sections. Content before the first heading stays
439
+ outside all generated sections.
440
+
441
+ ```html
442
+ <section>
443
+ <h1 id="chapter">Chapter</h1>
444
+ <section>
445
+ <h2 id="topic">Topic</h2>
446
+ </section>
447
+ </section>
448
+ ```
449
+
450
+ This structure provides stable boundaries for CSS, DOM queries, and heading
451
+ task-list values. Heading-like text inside comments, scripts, styles, code,
452
+ textareas, and templates is protected and does not create sections.
453
+
263
454
  ## Security
264
455
 
265
456
  A Markdown UI is an executable application, not a passive document. Starting a
package/select.md ADDED
@@ -0,0 +1,61 @@
1
+ # Select and Task List Demo
2
+
3
+ Use `Enter` or `Space` to toggle a task item in the TUI, then activate these
4
+ links to read the values under headings at different levels.
5
+
6
+ - [Read primary color (h2 single value)](javascript:showHeadingValue('select-primary-color'))
7
+ - [Read enabled features (h3 array)](javascript:showHeadingValue('enabled-features'))
8
+ - [Read log level (h4 single value)](javascript:showHeadingValue('select-log-level'))
9
+ - [Read permissions (h2 array)](javascript:showHeadingValue('permissions'))
10
+ - [Read every group](javascript:showAllHeadingValues())
11
+
12
+ ## Select Primary Color
13
+
14
+ - [ ] Red
15
+ - [x] Green
16
+ - [x] Nested green detail (ignored)
17
+ - [ ] Another nested detail (ignored)
18
+ - [x] Blue (ignored because select returns the first checked value)
19
+
20
+ ### Enabled Features
21
+
22
+ - [x] Search
23
+ - [ ] Notifications
24
+ - [x] Nested notification option (ignored)
25
+ - [x] Offline mode
26
+ - [x] Sync
27
+
28
+ #### Select Log Level
29
+
30
+ - [ ] Debug
31
+ - [x] Info
32
+ - [ ] Warning
33
+ - [ ] Error
34
+
35
+ ## Permissions
36
+
37
+ - [x] Read
38
+ - [x] Write
39
+ - [x] Write temporary files (ignored)
40
+ - [ ] Write system files (ignored)
41
+ - [ ] Execute
42
+ - [x] Share
43
+
44
+ ```js front
45
+ export function showHeadingValue(id)
46
+ {
47
+ const value = $('#'+id).val();
48
+ alert(id+': '+JSON.stringify(value));
49
+ }
50
+
51
+ export function showAllHeadingValues()
52
+ {
53
+ const values = {
54
+ primaryColor: $('#select-primary-color').val(),
55
+ enabledFeatures: $('#enabled-features').val(),
56
+ logLevel: $('#select-log-level').val(),
57
+ permissions: $('#permissions').val(),
58
+ };
59
+ alert(JSON.stringify(values, null, 2));
60
+ }
61
+ ```
@@ -4,4 +4,4 @@ sd=$(dirname "$0")
4
4
 
5
5
  cd "$sd"/..
6
6
 
7
- tar -cvf single-exe/assets.tar runtime README.md CHANGELOG.md src/cui/server.mjs src/cui/rpc.mjs testapp.md
7
+ tar -cvf single-exe/assets.tar runtime README.md CHANGELOG.md src/cui/server.mjs src/cui/rpc.mjs testapp.md select.md image-processor.md image-processor.zh-TW.md
@@ -0,0 +1,151 @@
1
+ function markdownHeadingDeclarations(markdown) {
2
+ const lines = String(markdown).split(/\r?\n/);
3
+ const declarations = [];
4
+ let fence = null;
5
+
6
+ for (let index = 0; index < lines.length; index++) {
7
+ const line = lines[index];
8
+ const fenceMatch = line.match(/^ {0,3}(`{3,}|~{3,})/);
9
+ if (fenceMatch) {
10
+ const marker = fenceMatch[1][0];
11
+ const length = fenceMatch[1].length;
12
+ if (!fence) fence = { marker, length };
13
+ else if (marker === fence.marker && length >= fence.length) fence = null;
14
+ continue;
15
+ }
16
+ if (fence) continue;
17
+
18
+ const atx = line.match(/^ {0,3}(#{1,6})(?:[ \t]+(.*?)\s*#*\s*|[ \t]*)$/);
19
+ if (atx) {
20
+ declarations.push({
21
+ line: index + 1,
22
+ source: line.trim(),
23
+ markdown: line,
24
+ });
25
+ continue;
26
+ }
27
+
28
+ if (index + 1 < lines.length && line.trim() && /^ {0,3}(?:=+|-+)\s*$/.test(lines[index + 1])) {
29
+ declarations.push({
30
+ line: index + 1,
31
+ source: `${line.trim()} / ${lines[index + 1].trim()}`,
32
+ markdown: `${line}\n${lines[index + 1]}`,
33
+ });
34
+ index++;
35
+ }
36
+ }
37
+
38
+ if (!declarations.length) return [];
39
+ return declarations.map((item) => {
40
+ // Render headings independently so Bun cannot hide a source-level
41
+ // collision by suffixing later duplicates as "-1", "-2", and so on.
42
+ const html = String(Bun.markdown.html(item.markdown, { headings: { ids: true } }));
43
+ const id = html.match(/<h[1-6]\b[^>]*\bid="([^"]*)"[^>]*>/i)?.[1] ?? "";
44
+ return {
45
+ id,
46
+ kind: "heading",
47
+ line: item.line,
48
+ source: item.source,
49
+ };
50
+ }).filter((item) => item.id);
51
+ }
52
+
53
+ 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;
82
+ }
83
+
84
+ export function checkMarkdownIdCollisions(markdown) {
85
+ const declarations = [
86
+ ...markdownHeadingDeclarations(markdown),
87
+ ...fencedBlockDeclarations(markdown),
88
+ ].sort((a, b) => a.line - b.line);
89
+ const byId = new Map();
90
+ for (const declaration of declarations) {
91
+ if (!byId.has(declaration.id)) byId.set(declaration.id, []);
92
+ byId.get(declaration.id).push(declaration);
93
+ }
94
+ const collisions = [...byId.entries()]
95
+ .filter(([, items]) => items.length > 1)
96
+ .map(([id, items]) => ({ id, declarations: items }));
97
+ return { declarations, collisions };
98
+ }
99
+
100
+ function inlineCode(value) {
101
+ const text = String(value);
102
+ const longest = Math.max(0, ...[...text.matchAll(/`+/g)].map((match) => match[0].length));
103
+ const delimiter = "`".repeat(longest + 1);
104
+ const padding = text.startsWith("`") || text.endsWith("`") ? " " : "";
105
+ return `${delimiter}${padding}${text}${padding}${delimiter}`;
106
+ }
107
+
108
+ export function formatMarkdownIdCheck(path, result) {
109
+ const headings = result.declarations.filter((item) => item.kind === "heading").length;
110
+ const fencedBlocks = result.declarations.length - headings;
111
+ const lines = [
112
+ "# Markdown UI ID Check",
113
+ "",
114
+ `- File: ${inlineCode(path)}`,
115
+ `- Selectable IDs: **${result.declarations.length}**`,
116
+ ` * Headings: **${headings}**`,
117
+ ` * Fenced blocks: **${fencedBlocks}**`,
118
+ ];
119
+
120
+ if (!result.collisions.length) {
121
+ lines.push("", "## No ID collisions found");
122
+ return lines.join("\n");
123
+ }
124
+
125
+ lines.push("", `## FAIL — Found ${result.collisions.length} colliding ID(s)`);
126
+ for (const collision of result.collisions) {
127
+ lines.push("", `### ID ${inlineCode(`#${collision.id}`)}`);
128
+ lines.push("", `- Declarations: **${collision.declarations.length}**`);
129
+ for (const declaration of collision.declarations) {
130
+ lines.push(` * Line **${declaration.line}**`);
131
+ lines.push(` - Type: ${declaration.kind}`);
132
+ lines.push(` - Source: ${inlineCode(declaration.source)}`);
133
+ }
134
+ }
135
+ lines.push("", "## Suggested fix", "", "- Rename the heading or fenced block so every selectable ID is unique.");
136
+ return lines.join("\n");
137
+ }
138
+
139
+ function statusBanner(label, color, fallbackColor) {
140
+ const foreground = Bun.color?.(color, "ansi-16m") || fallbackColor;
141
+ return `${foreground}\x1b[1m${label}\x1b[0m\n${foreground}\x1b[2m${"═".repeat(label.length)}\x1b[0m\n`;
142
+ }
143
+
144
+ export function formatMarkdownIdCheckAnsi(path, result) {
145
+ let output = String(Bun.markdown.ansi(formatMarkdownIdCheck(path, result)));
146
+ if (!output.endsWith("\n")) output += "\n";
147
+ output += "\n" + (result.collisions.length
148
+ ? statusBanner("FAILED", "#ff3030", "\x1b[31m")
149
+ : statusBanner("PASSED", "#00d75f", "\x1b[32m"));
150
+ return output;
151
+ }
package/src/cui/rpc.mjs CHANGED
@@ -66,6 +66,64 @@ function webDollarValue(element)
66
66
  return String(element?.textContent ?? "").replace(/\n$/, "");
67
67
  }
68
68
 
69
+ function isWebHeading(element)
70
+ {
71
+ return /^h[1-6]$/i.test(String(element?.tagName ?? ""));
72
+ }
73
+
74
+ function firstHeadingTaskList(heading)
75
+ {
76
+ for (let sibling = heading?.nextElementSibling; sibling; sibling = sibling.nextElementSibling) {
77
+ if (isWebHeading(sibling) || String(sibling.tagName ?? "").toLowerCase() === "section")
78
+ break;
79
+ const task = sibling.matches?.("li.task-list-item")
80
+ ? sibling
81
+ : sibling.querySelector?.("li.task-list-item");
82
+ if (!task) continue;
83
+ const list = task.closest?.("ul, ol");
84
+ if (list) return list;
85
+ }
86
+ return null;
87
+ }
88
+
89
+ function directTaskCheckbox(item)
90
+ {
91
+ for (const checkbox of item?.querySelectorAll?.('input[type="checkbox"]') ?? []) {
92
+ if (checkbox.closest?.("li.task-list-item") === item) return checkbox;
93
+ }
94
+ return null;
95
+ }
96
+
97
+ function webTaskItemValue(item, checkbox)
98
+ {
99
+ const label = checkbox?.closest?.("label");
100
+ if (label && label.closest?.("li.task-list-item") === item)
101
+ return String(label.textContent ?? "").trim();
102
+
103
+ const copy = item.cloneNode?.(true);
104
+ for (const nested of copy?.querySelectorAll?.("ul, ol") ?? []) nested.remove?.();
105
+ for (const input of copy?.querySelectorAll?.('input[type="checkbox"]') ?? []) input.remove?.();
106
+ return String(copy?.textContent ?? "").trim();
107
+ }
108
+
109
+ function webHeadingValue(heading)
110
+ {
111
+ const single = String(heading?.id ?? "").startsWith("select");
112
+ const list = firstHeadingTaskList(heading);
113
+ if (!list) return single ? null : [];
114
+
115
+ const selected = [];
116
+ for (const item of list.children ?? []) {
117
+ if (!item.matches?.("li.task-list-item")) continue;
118
+ const checkbox = directTaskCheckbox(item);
119
+ if (!checkbox?.checked) continue;
120
+ const value = webTaskItemValue(item, checkbox);
121
+ if (single) return value;
122
+ selected.push(value);
123
+ }
124
+ return single ? null : selected;
125
+ }
126
+
69
127
  function resizeWebTextarea(element)
70
128
  {
71
129
  if (!element || String(element.tagName ?? "").toLowerCase() !== "textarea") return;
@@ -105,11 +163,23 @@ export function createWebDollar(documentObject = globalThis.document)
105
163
  return function $(selectorText) {
106
164
  const selector = parseDollarIdentity(selectorText, { selector: true });
107
165
  const selection = {
166
+ html() {
167
+ try {
168
+ const element = findWebDollarElement(documentObject, selectorText, selector);
169
+ return element ? String(element.innerHTML ?? "") : "";
170
+ } catch {
171
+ return "";
172
+ }
173
+ },
108
174
  val(...args) {
109
175
  try {
110
- if (!selector) return args.length > 0 ? selection : "";
111
176
  const element = findWebDollarElement(documentObject, selectorText, selector);
112
177
  if (!element) return args.length > 0 ? selection : "";
178
+ if (isWebHeading(element)) {
179
+ if (args.length > 0) return selection;
180
+ return webHeadingValue(element);
181
+ }
182
+ if (!selector) return args.length > 0 ? selection : "";
113
183
  if (args.length > 0) {
114
184
  const value = String(args[0] ?? "");
115
185
  if ("value" in element) {
@@ -0,0 +1,16 @@
1
+ export function toggleTaskCheckboxBeforeColumn(line, column) {
2
+ const text = String(line ?? "");
3
+ const end = Math.max(0, Math.min(text.length, Math.trunc(Number(column) || 0) + 1));
4
+ const prefix = text.slice(0, end);
5
+ const uncheckedAt = prefix.lastIndexOf("☐");
6
+ const checkedAt = prefix.lastIndexOf("☒");
7
+ const checkboxAt = Math.max(uncheckedAt, checkedAt);
8
+
9
+ if (checkboxAt < 0) return { line: text, toggled: false };
10
+
11
+ const replacement = text[checkboxAt] === "☐" ? "☒" : "☐";
12
+ return {
13
+ line: text.slice(0, checkboxAt) + replacement + text.slice(checkboxAt + 1),
14
+ toggled: true,
15
+ };
16
+ }