jsmdcui 0.6.3 → 0.7.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 +60 -0
- package/README.md +105 -16
- package/clean.sh +9 -0
- package/demo.resized.jpg +0 -0
- package/{image-processor.md → demos/image-processor.md} +1 -0
- package/{image-processor.zh-TW.md → demos/image-processor.zh-TW.md} +1 -0
- package/{select.md → demos/select.md} +2 -0
- package/demos/todo-zh.md +92 -0
- package/demos/todo.md +93 -0
- package/package.json +1 -1
- package/runmd.mjs +33 -14
- package/runtime/help/help.md +105 -16
- package/single-exe/packAssets.sh +1 -1
- package/src/cui/kitty-debug.mjs +25 -0
- package/src/cui/kitty-images.mjs +190 -0
- package/src/cui/rpc.mjs +163 -7
- package/src/cui/server.mjs +1 -1
- package/src/cui/task-checkbox.mjs +44 -0
- package/src/index.js +236 -39
- package/src/plugins/js-bridge.js +244 -17
- package/src/screen/screen.js +108 -1
- package/tests/cat-markdown.test.js +6 -5
- package/tests/demo.test.js +99 -9
- package/tests/heading-list-selector.test.js +346 -0
- package/tests/id-collision.test.js +3 -2
- package/tests/kitty-demo.md +184 -0
- package/tests/kitty-images.test.js +147 -0
- package/tests/task-checkbox.test.js +33 -0
- package/tests/wui-responsive-images.test.js +35 -0
- package/tui +4 -2
- package/wui +6 -2
package/runtime/help/help.md
CHANGED
|
@@ -47,9 +47,10 @@ On other platforms, follow the [official Bun installation guide](https://bun.com
|
|
|
47
47
|
Choose either of these two ways to run jsmdcui.
|
|
48
48
|
|
|
49
49
|
> **Important:** Opening or rendering a local `.md` file writes or overwrites
|
|
50
|
-
> 5 generated files beside it.
|
|
51
|
-
>
|
|
52
|
-
>
|
|
50
|
+
> 5 generated files beside it.
|
|
51
|
+
> Starting `--wui` without a file or starting all the `--demo`s writes the
|
|
52
|
+
> 5 files generated from `testapp.md` in the current directory.
|
|
53
|
+
> The source Markdown is not changed, but you should run the demo in a directory where
|
|
53
54
|
> overwriting generated files is safe.
|
|
54
55
|
|
|
55
56
|
### Route 1: Run with npx
|
|
@@ -72,9 +73,10 @@ npx jsmdcui
|
|
|
72
73
|
npx jsmdcui --demo
|
|
73
74
|
```
|
|
74
75
|
|
|
76
|
+
List every bundled demo and its command-line option:
|
|
77
|
+
|
|
75
78
|
```sh
|
|
76
|
-
|
|
77
|
-
npx jsmdcui --demo-select
|
|
79
|
+
npx jsmdcui --demo-list
|
|
78
80
|
```
|
|
79
81
|
|
|
80
82
|
```sh
|
|
@@ -121,22 +123,31 @@ bun src/index.js --wui testapp.md
|
|
|
121
123
|
|
|
122
124
|
### Usage table
|
|
123
125
|
|
|
124
|
-
- The command table below
|
|
126
|
+
- The command table below assumes you're running from a cloned repository
|
|
125
127
|
- If you use npx, replace `bun src/index.js` with `npx jsmdcui`
|
|
126
128
|
|
|
129
|
+
- I've also provided short aliases
|
|
130
|
+
* bun ./tui = bun src/index.js
|
|
131
|
+
* bun ./wui = bun src/index.js --wui
|
|
132
|
+
|
|
127
133
|
| Command | Result |
|
|
128
134
|
| --- | --- |
|
|
129
135
|
| `bun src/index.js app.md` | Render `app.md` as a read-only terminal UI and write five generated files beside it. |
|
|
136
|
+
| `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. |
|
|
138
|
+
| `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
|
+
| `JSMDCUI_KITTY_DEBUG=1 bun src/index.js --kitty app.md` | Enable Kitty image placement logging to `kitty-placement.log`. |
|
|
130
140
|
| `bun src/index.js --check app.md` | Check heading and fenced-block IDs for collisions, print line-by-line details, and exit. |
|
|
131
141
|
| `bun src/index.js --edit app.md` | Open `app.md` as editable UTF-8 source, overriding automatic Markdown UI detection. |
|
|
132
142
|
| `bun src/index.js --cat app.md` | Render the terminal version to stdout, write five generated files beside it, and exit. |
|
|
133
143
|
| `bun src/index.js --testapp.md` | Write the bundled `testapp.md` source to stdout and exit. |
|
|
134
144
|
| `bun src/index.js --export-readme` | Write or overwrite `./README.md` with the bundled README source and exit. |
|
|
145
|
+
| `bun src/index.js --demo-list` | List `testapp.md` and every bundled `demos/*.md` example with its command-line option, then exit. |
|
|
135
146
|
| `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
|
|
137
|
-
| `bun src/index.js --demo-imgtool` |
|
|
138
|
-
| `bun src/index.js --demo-imgtool-zh` |
|
|
139
|
-
| `bun src/index.js --allow-url URL.md` | Download HTTP(S) Markdown to the current directory, write 5 generated files
|
|
147
|
+
| `bun src/index.js --demo-<filename>` | Load `demos/<filename>.md`; preserve an existing local copy or write the bundled copy, then open it and generate its five companion files. New files added under `demos/` work automatically. |
|
|
148
|
+
| `bun src/index.js --demo-imgtool` | Compatibility alias for `--demo-image-processor`. |
|
|
149
|
+
| `bun src/index.js --demo-imgtool-zh` | Compatibility alias for `--demo-image-processor.zh-TW`. |
|
|
150
|
+
| `bun src/index.js --allow-url URL.md` | Download HTTP(S) Markdown to the current directory and, with Kitty mode enabled, download its HTTP(S) images; write 5 generated files and allow embedded code to run. Only use trusted URLs. |
|
|
140
151
|
| `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. |
|
|
141
152
|
| `bun src/index.js --wui app.md` | Write five generated files beside `app.md`, then print and serve a random URL. |
|
|
142
153
|
| `PORT=8080 bun src/index.js --wui app.md` | Start the browser UI on another port. |
|
|
@@ -239,9 +250,10 @@ non-empty content is never removed.
|
|
|
239
250
|
|
|
240
251
|
Headings can act as form-group selectors. jsmdcui uses the IDs generated by
|
|
241
252
|
`Bun.markdown.html(..., { headings: { ids: true } })`; for example,
|
|
242
|
-
`## Select Color` becomes `#select-color`. A heading selection reads
|
|
243
|
-
task list
|
|
244
|
-
|
|
253
|
+
`## Select Color` becomes `#select-color`. A heading selection reads direct
|
|
254
|
+
task items from the first list following that heading and stops at the next
|
|
255
|
+
heading. In the TUI, the first rendered `☐` or `☒` establishes that list and
|
|
256
|
+
its indentation. Nested task items are not included in the outer list's value.
|
|
245
257
|
|
|
246
258
|
Heading IDs share the same selector namespace as all explicitly named fenced
|
|
247
259
|
blocks, not only `text` and `textarea`, so avoid name collisions between them.
|
|
@@ -306,14 +318,65 @@ $('#select-color').val() // "Green"
|
|
|
306
318
|
$('#features').val() // ["Search", "Offline mode"]
|
|
307
319
|
```
|
|
308
320
|
|
|
309
|
-
The
|
|
321
|
+
The first direct task list belonging to a heading can also be changed with
|
|
322
|
+
Array-style methods. String arguments create unchecked items. Pass an object
|
|
323
|
+
to choose the initial checked state:
|
|
324
|
+
|
|
325
|
+
```js
|
|
326
|
+
$('#features').push('Export')
|
|
327
|
+
$('#features').unshift({ value: 'Import', checked: true })
|
|
328
|
+
$('#features').splice(1, 2, 'Replacement')
|
|
329
|
+
$('#features').slice(0, 2)
|
|
330
|
+
$('#features').pop()
|
|
331
|
+
$('#features').shift()
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
Like the corresponding `Array.prototype` methods, `.push(...items)` and
|
|
335
|
+
`.unshift(...items)` accept multiple items and return the new number of direct
|
|
336
|
+
items. `.pop()` and `.shift()` return the removed item's visible label, or
|
|
337
|
+
`undefined` when the list is empty. Nested task items are part of their parent
|
|
338
|
+
item: they are not counted separately, and are removed together with that
|
|
339
|
+
parent. These methods change the rendered TUI/WUI state; they do not rewrite
|
|
340
|
+
the source Markdown file. A heading must already have a task list before items
|
|
341
|
+
can be added.
|
|
342
|
+
|
|
343
|
+
`.splice(start, deleteCount, ...items)` follows `Array.prototype.splice()`:
|
|
344
|
+
negative indexes count from the end, omitting `deleteCount` removes through the
|
|
345
|
+
end, and the return value is an array containing the removed visible labels.
|
|
346
|
+
|
|
347
|
+
`.slice(start, end)` is read-only and follows `Array.prototype.slice()`. It
|
|
348
|
+
returns fresh item snapshots, including unchecked items, so changing the
|
|
349
|
+
returned array or its objects does not change the rendered list:
|
|
350
|
+
|
|
351
|
+
```js
|
|
352
|
+
$('#features').slice()
|
|
353
|
+
// [
|
|
354
|
+
// { value: 'Search', checked: true },
|
|
355
|
+
// { value: 'Notifications', checked: false },
|
|
356
|
+
// ]
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
`demos/todo.md` and `demos/todo-zh.md` are runnable Todo examples that
|
|
360
|
+
demonstrate `.push()`,
|
|
361
|
+
`.splice()`, `.slice()`, and `{ value, checked }` snapshots using editable text
|
|
362
|
+
controls instead of `prompt()` dialogs. Materialize one in the current
|
|
363
|
+
directory with its demo flag, or open the source-tree copy directly:
|
|
364
|
+
|
|
365
|
+
```sh
|
|
366
|
+
bun src/index.js --demo-todo
|
|
367
|
+
bun src/index.js --demo-todo-zh
|
|
368
|
+
bun src/index.js demos/todo.md
|
|
369
|
+
bun src/index.js --wui demos/todo-zh.md
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
The bundled `demos/select.md` is a multilevel runnable example. Use `--demo-select`
|
|
310
373
|
to write it into the current directory when missing and open it in the TUI, or
|
|
311
374
|
open it explicitly in either interface:
|
|
312
375
|
|
|
313
376
|
```sh
|
|
314
377
|
bun src/index.js --demo-select
|
|
315
|
-
bun src/index.js select.md
|
|
316
|
-
bun src/index.js --wui select.md
|
|
378
|
+
bun src/index.js demos/select.md
|
|
379
|
+
bun src/index.js --wui demos/select.md
|
|
317
380
|
```
|
|
318
381
|
|
|
319
382
|
The available selector methods are:
|
|
@@ -324,6 +387,12 @@ The available selector methods are:
|
|
|
324
387
|
| `.val(value)` | Replace text-block contents and resize multiline values. | Set textarea/control values and resize textareas. |
|
|
325
388
|
| `.html()` | Return a selected heading's inner HTML. | Return any successfully selected DOM element's `innerHTML`. |
|
|
326
389
|
| `.line()` | Return a heading's current 1-based TUI row, or `0` if missing. | Not available. |
|
|
390
|
+
| `.push(...items)` | Append unchecked strings or `{ value, checked }` task items; return the new direct-item count. | Same. |
|
|
391
|
+
| `.pop()` | Remove and return the last direct task item's label, or `undefined`. | Same. |
|
|
392
|
+
| `.shift()` | Remove and return the first direct task item's label, or `undefined`. | Same. |
|
|
393
|
+
| `.unshift(...items)` | Prepend unchecked strings or `{ value, checked }` task items; return the new direct-item count. | Same. |
|
|
394
|
+
| `.splice(start, deleteCount, ...items)` | Remove and insert direct task items; return the removed labels as an array. | Same. |
|
|
395
|
+
| `.slice(start, end)` | Return `{ value, checked }` snapshots without changing the direct task items. | Same. |
|
|
327
396
|
|
|
328
397
|
TUI heading rows are recalculated after text-block rows are added, removed, or
|
|
329
398
|
replaced with multiline `.val(value)` content.
|
|
@@ -398,6 +467,19 @@ The terminal automatically reflows the Markdown when its width changes.
|
|
|
398
467
|
Only `javascript:` links execute in the TUI; ordinary web links behave as
|
|
399
468
|
normal links in the browser.
|
|
400
469
|
|
|
470
|
+
Local Markdown images are displayed automatically in terminals that support
|
|
471
|
+
the Kitty graphics protocol. Relative image paths are resolved from the
|
|
472
|
+
Markdown file's directory. jsmdcui reads the image dimensions, reserves the
|
|
473
|
+
corresponding terminal rows, and updates the placement when the document is
|
|
474
|
+
scrolled, resized, or shown in a split pane. Unsupported or missing images, as
|
|
475
|
+
well as remote images not authorized with `--allow-url`, retain Bun's normal
|
|
476
|
+
linked `📷` fallback. To download trusted remote Markdown and display its
|
|
477
|
+
supported HTTP(S) images with Kitty graphics, combine the options:
|
|
478
|
+
|
|
479
|
+
```sh
|
|
480
|
+
bun src/index.js --kitty --allow-url https://example.com/app.md
|
|
481
|
+
```
|
|
482
|
+
|
|
401
483
|
## Browser interaction
|
|
402
484
|
|
|
403
485
|
The WUI uses normal browser mouse and keyboard behavior. Clicking a
|
|
@@ -431,6 +513,13 @@ app.md-server.js
|
|
|
431
513
|
They are regenerated from `app.md`, so edit the Markdown source rather than the
|
|
432
514
|
generated files. The source directory must be writable.
|
|
433
515
|
|
|
516
|
+
From the project directory, remove generated `*.md.*` and `*.md-*` companion
|
|
517
|
+
files while keeping the Markdown source files:
|
|
518
|
+
|
|
519
|
+
```sh
|
|
520
|
+
bun ./clean.sh
|
|
521
|
+
```
|
|
522
|
+
|
|
434
523
|
### Generated heading sections
|
|
435
524
|
|
|
436
525
|
WUI output wraps each h1-h6 and its content in a hierarchical `<section>`.
|
package/single-exe/packAssets.sh
CHANGED
|
@@ -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 demos runtime README.md CHANGELOG.md src/cui/server.mjs src/cui/rpc.mjs testapp.md
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { appendFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
export const KITTY_DEBUG_LOG = resolve(import.meta.dir, "../..", "kitty-placement.log");
|
|
5
|
+
export const KITTY_DEBUG_ENABLED = /^(1|true|yes|on)$/i.test(
|
|
6
|
+
String(process.env.JSMDCUI_KITTY_DEBUG ?? ""),
|
|
7
|
+
);
|
|
8
|
+
|
|
9
|
+
if (KITTY_DEBUG_ENABLED) {
|
|
10
|
+
try {
|
|
11
|
+
writeFileSync(KITTY_DEBUG_LOG, "");
|
|
12
|
+
} catch {}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function logKittyPlacement(stage, details = {}) {
|
|
16
|
+
if (!KITTY_DEBUG_ENABLED) return;
|
|
17
|
+
try {
|
|
18
|
+
appendFileSync(KITTY_DEBUG_LOG, JSON.stringify({
|
|
19
|
+
time: new Date().toISOString(),
|
|
20
|
+
pid: process.pid,
|
|
21
|
+
stage,
|
|
22
|
+
...details,
|
|
23
|
+
}) + "\n");
|
|
24
|
+
} catch {}
|
|
25
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { dirname, resolve } from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { logKittyPlacement } from "./kitty-debug.mjs";
|
|
4
|
+
import { fetchHttpBytes as defaultFetchHttpBytes } from "../platform/commands.js";
|
|
5
|
+
|
|
6
|
+
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
7
|
+
|
|
8
|
+
function imageSize(buffer) {
|
|
9
|
+
if (buffer.length >= 24 && buffer.subarray(0, 8).equals(PNG)) {
|
|
10
|
+
return { mime: "image/png", width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
|
|
11
|
+
}
|
|
12
|
+
const gif = buffer.toString("ascii", 0, 6);
|
|
13
|
+
if (buffer.length >= 10 && (gif === "GIF87a" || gif === "GIF89a")) {
|
|
14
|
+
return { mime: "image/gif", width: buffer.readUInt16LE(6), height: buffer.readUInt16LE(8) };
|
|
15
|
+
}
|
|
16
|
+
if (buffer.length >= 26 && buffer.toString("ascii", 0, 2) === "BM") {
|
|
17
|
+
return { mime: "image/bmp", width: Math.abs(buffer.readInt32LE(18)), height: Math.abs(buffer.readInt32LE(22)) };
|
|
18
|
+
}
|
|
19
|
+
if (buffer.length >= 16 && buffer.toString("ascii", 0, 4) === "RIFF" && buffer.toString("ascii", 8, 12) === "WEBP") {
|
|
20
|
+
const kind = buffer.toString("ascii", 12, 16);
|
|
21
|
+
if (kind === "VP8 " && buffer.length >= 30) {
|
|
22
|
+
return {
|
|
23
|
+
mime: "image/webp",
|
|
24
|
+
width: buffer.readUInt16LE(26) & 0x3fff,
|
|
25
|
+
height: buffer.readUInt16LE(28) & 0x3fff,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
if (kind === "VP8L" && buffer.length >= 25) {
|
|
29
|
+
const bits = buffer[21] | (buffer[22] << 8) | (buffer[23] << 16) | (buffer[24] << 24);
|
|
30
|
+
return {
|
|
31
|
+
mime: "image/webp",
|
|
32
|
+
width: (bits & 0x3fff) + 1,
|
|
33
|
+
height: ((bits >> 14) & 0x3fff) + 1,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
if (kind === "VP8X" && buffer.length >= 30) {
|
|
37
|
+
return {
|
|
38
|
+
mime: "image/webp",
|
|
39
|
+
width: 1 + buffer.readUIntLE(24, 3),
|
|
40
|
+
height: 1 + buffer.readUIntLE(27, 3),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (buffer.length >= 4 && buffer[0] === 0xff && buffer[1] === 0xd8) {
|
|
45
|
+
let offset = 2;
|
|
46
|
+
while (offset + 8 < buffer.length) {
|
|
47
|
+
if (buffer[offset] !== 0xff) { offset++; continue; }
|
|
48
|
+
const marker = buffer[offset + 1];
|
|
49
|
+
offset += 2;
|
|
50
|
+
if (marker === 0xd8 || marker === 0xd9 || (marker >= 0xd0 && marker <= 0xd7)) continue;
|
|
51
|
+
if (offset + 2 > buffer.length) break;
|
|
52
|
+
const length = buffer.readUInt16BE(offset);
|
|
53
|
+
if (length < 2 || offset + length > buffer.length) break;
|
|
54
|
+
const sof = (marker >= 0xc0 && marker <= 0xc3) ||
|
|
55
|
+
(marker >= 0xc5 && marker <= 0xc7) ||
|
|
56
|
+
(marker >= 0xc9 && marker <= 0xcb) ||
|
|
57
|
+
(marker >= 0xcd && marker <= 0xcf);
|
|
58
|
+
if (sof && length >= 7) {
|
|
59
|
+
return { mime: "image/jpeg", width: buffer.readUInt16BE(offset + 5), height: buffer.readUInt16BE(offset + 3) };
|
|
60
|
+
}
|
|
61
|
+
offset += length;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const svg = buffer.subarray(0, Math.min(buffer.length, 8192)).toString("utf8");
|
|
65
|
+
if (svg.includes("<svg")) {
|
|
66
|
+
const width = svg.match(/\bwidth=["']([0-9.]+)(?:px)?["']/i);
|
|
67
|
+
const height = svg.match(/\bheight=["']([0-9.]+)(?:px)?["']/i);
|
|
68
|
+
if (width && height) {
|
|
69
|
+
return { mime: "image/svg+xml", width: Math.round(Number(width[1])), height: Math.round(Number(height[1])) };
|
|
70
|
+
}
|
|
71
|
+
const viewBox = svg.match(/\bviewBox=["'][^"']*?([0-9.]+)[ ,]+([0-9.]+)\s*["']/i);
|
|
72
|
+
if (viewBox) {
|
|
73
|
+
return { mime: "image/svg+xml", width: Math.round(Number(viewBox[1])), height: Math.round(Number(viewBox[2])) };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isHttpUrl(value) {
|
|
80
|
+
return /^https?:\/\//i.test(String(value ?? ""));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function imageSource(href, markdownPath, allowUrl) {
|
|
84
|
+
try {
|
|
85
|
+
if (href.startsWith("file:")) return { kind: "local", value: fileURLToPath(href) };
|
|
86
|
+
if (process.platform === "win32" && /^[A-Za-z]:[\\/]/.test(href)) {
|
|
87
|
+
return { kind: "local", value: resolve(href) };
|
|
88
|
+
}
|
|
89
|
+
if (isHttpUrl(href)) {
|
|
90
|
+
return allowUrl ? { kind: "remote", value: new URL(href).href } : null;
|
|
91
|
+
}
|
|
92
|
+
if (href.startsWith("//")) {
|
|
93
|
+
if (!allowUrl) return null;
|
|
94
|
+
const protocol = isHttpUrl(markdownPath) ? new URL(markdownPath).protocol : "https:";
|
|
95
|
+
return { kind: "remote", value: new URL(`${protocol}${href}`).href };
|
|
96
|
+
}
|
|
97
|
+
if (/^[A-Za-z][A-Za-z\d+.-]*:/.test(href)) return null;
|
|
98
|
+
if (isHttpUrl(markdownPath)) {
|
|
99
|
+
return allowUrl ? { kind: "remote", value: new URL(href, markdownPath).href } : null;
|
|
100
|
+
}
|
|
101
|
+
const decoded = decodeURIComponent(href.replace(/[?#].*$/, ""));
|
|
102
|
+
return {
|
|
103
|
+
kind: "local",
|
|
104
|
+
value: resolve(markdownPath ? dirname(markdownPath) : process.cwd(), decoded),
|
|
105
|
+
};
|
|
106
|
+
} catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function stableImageId(pathname, line, data) {
|
|
112
|
+
let hash = 2166136261;
|
|
113
|
+
for (const byte of Buffer.concat([Buffer.from(`${pathname}\0${line}\0`), data])) {
|
|
114
|
+
hash ^= byte;
|
|
115
|
+
hash = Math.imul(hash, 16777619);
|
|
116
|
+
}
|
|
117
|
+
return (hash >>> 0) % 2147483646 + 1;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function fitKittyImageToWidth(image, maxCols) {
|
|
121
|
+
const originalCols = Math.max(1, Math.trunc(Number(image?.cols) || 1));
|
|
122
|
+
const originalRows = Math.max(1, Math.trunc(Number(image?.rows) || 1));
|
|
123
|
+
const availableCols = Math.max(1, Math.trunc(Number(maxCols) || 1));
|
|
124
|
+
const cols = Math.min(originalCols, availableCols);
|
|
125
|
+
const rows = Math.max(1, Math.round(originalRows * cols / originalCols));
|
|
126
|
+
return { cols, rows };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function prepareKittyImages(ansiText, markdownPath, terminalCols = 80, options = {}) {
|
|
130
|
+
const inputLines = String(ansiText).split("\n");
|
|
131
|
+
const outputLines = [];
|
|
132
|
+
const images = [];
|
|
133
|
+
const maxCols = Math.max(1, Math.trunc(Number(terminalCols) || 80));
|
|
134
|
+
const oscImage = /\x1b\]8;;([^\x1b]*)\x1b\\(?=[^\n]*📷)/;
|
|
135
|
+
|
|
136
|
+
for (let sourceLine = 0; sourceLine < inputLines.length; sourceLine++) {
|
|
137
|
+
const line = inputLines[sourceLine];
|
|
138
|
+
const match = line.match(oscImage);
|
|
139
|
+
const source = match ? imageSource(match[1], markdownPath, options.allowUrl === true) : null;
|
|
140
|
+
if (!source) {
|
|
141
|
+
outputLines.push(line);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
let data;
|
|
146
|
+
if (source.kind === "remote") {
|
|
147
|
+
const fetchBytes = options.fetchHttpBytes ?? defaultFetchHttpBytes;
|
|
148
|
+
data = Buffer.from(await fetchBytes(source.value));
|
|
149
|
+
} else {
|
|
150
|
+
const file = Bun.file(source.value);
|
|
151
|
+
if (!(await file.exists())) throw new Error("missing image");
|
|
152
|
+
data = Buffer.from(await file.arrayBuffer());
|
|
153
|
+
}
|
|
154
|
+
const size = imageSize(data);
|
|
155
|
+
if (!size?.width || !size?.height) throw new Error("unsupported image");
|
|
156
|
+
const estimatedCols = Math.max(1, Math.round(size.width / 8));
|
|
157
|
+
const cols = Math.min(maxCols, estimatedCols);
|
|
158
|
+
const rows = Math.max(1, Math.round((cols * size.height) / size.width / 2));
|
|
159
|
+
const lineIndex = outputLines.length;
|
|
160
|
+
outputLines.push(line, ...Array(Math.max(0, rows - 1)).fill(""));
|
|
161
|
+
images.push({
|
|
162
|
+
id: stableImageId(source.value, sourceLine, data),
|
|
163
|
+
line: lineIndex,
|
|
164
|
+
cols,
|
|
165
|
+
rows,
|
|
166
|
+
pixelWidth: size.width,
|
|
167
|
+
pixelHeight: size.height,
|
|
168
|
+
mime: size.mime,
|
|
169
|
+
data,
|
|
170
|
+
path: source.value,
|
|
171
|
+
});
|
|
172
|
+
logKittyPlacement("image-prepared", {
|
|
173
|
+
path: source.value,
|
|
174
|
+
sourceKind: source.kind,
|
|
175
|
+
sourceLine,
|
|
176
|
+
renderedLine: lineIndex,
|
|
177
|
+
imageId: images.at(-1).id,
|
|
178
|
+
intrinsicWidth: size.width,
|
|
179
|
+
intrinsicHeight: size.height,
|
|
180
|
+
cols,
|
|
181
|
+
rows,
|
|
182
|
+
terminalCols: maxCols + 1,
|
|
183
|
+
});
|
|
184
|
+
} catch {
|
|
185
|
+
// An unavailable or unsupported image remains the normal Markdown link.
|
|
186
|
+
outputLines.push(line);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return { rendered: outputLines.join("\n"), images };
|
|
190
|
+
}
|
package/src/cui/rpc.mjs
CHANGED
|
@@ -71,16 +71,13 @@ function isWebHeading(element)
|
|
|
71
71
|
return /^h[1-6]$/i.test(String(element?.tagName ?? ""));
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
function
|
|
74
|
+
function firstHeadingList(heading)
|
|
75
75
|
{
|
|
76
76
|
for (let sibling = heading?.nextElementSibling; sibling; sibling = sibling.nextElementSibling) {
|
|
77
77
|
if (isWebHeading(sibling) || String(sibling.tagName ?? "").toLowerCase() === "section")
|
|
78
78
|
break;
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
: sibling.querySelector?.("li.task-list-item");
|
|
82
|
-
if (!task) continue;
|
|
83
|
-
const list = task.closest?.("ul, ol");
|
|
79
|
+
if (sibling.matches?.("ul, ol")) return sibling;
|
|
80
|
+
const list = sibling.querySelector?.("ul, ol");
|
|
84
81
|
if (list) return list;
|
|
85
82
|
}
|
|
86
83
|
return null;
|
|
@@ -109,7 +106,7 @@ function webTaskItemValue(item, checkbox)
|
|
|
109
106
|
function webHeadingValue(heading)
|
|
110
107
|
{
|
|
111
108
|
const single = String(heading?.id ?? "").startsWith("select");
|
|
112
|
-
const list =
|
|
109
|
+
const list = firstHeadingList(heading);
|
|
113
110
|
if (!list) return single ? null : [];
|
|
114
111
|
|
|
115
112
|
const selected = [];
|
|
@@ -124,6 +121,114 @@ function webHeadingValue(heading)
|
|
|
124
121
|
return single ? null : selected;
|
|
125
122
|
}
|
|
126
123
|
|
|
124
|
+
function directWebTaskItems(list)
|
|
125
|
+
{
|
|
126
|
+
const items = [];
|
|
127
|
+
for (const item of list?.children ?? []) {
|
|
128
|
+
if (!item.matches?.("li.task-list-item")) continue;
|
|
129
|
+
if (directTaskCheckbox(item)) items.push(item);
|
|
130
|
+
}
|
|
131
|
+
return items;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function webTaskItemSnapshot(item)
|
|
135
|
+
{
|
|
136
|
+
const checkbox = directTaskCheckbox(item);
|
|
137
|
+
return {
|
|
138
|
+
value: webTaskItemValue(item, checkbox),
|
|
139
|
+
checked: Boolean(checkbox?.checked),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function normalizedTaskItem(input)
|
|
144
|
+
{
|
|
145
|
+
if (input && typeof input === "object") {
|
|
146
|
+
return {
|
|
147
|
+
value: String(input.value ?? input.label ?? "").replace(/\r?\n/g, " "),
|
|
148
|
+
checked: Boolean(input.checked),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
value: String(input ?? "").replace(/\r?\n/g, " "),
|
|
153
|
+
checked: false,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function normalizedSpliceRange(length, argumentCount, start, deleteCount)
|
|
158
|
+
{
|
|
159
|
+
if (argumentCount === 0) return { start: 0, deleteCount: 0 };
|
|
160
|
+
let relativeStart = Number(start);
|
|
161
|
+
if (Number.isNaN(relativeStart)) relativeStart = 0;
|
|
162
|
+
relativeStart = Math.trunc(relativeStart);
|
|
163
|
+
const actualStart = relativeStart < 0
|
|
164
|
+
? Math.max(length + relativeStart, 0)
|
|
165
|
+
: Math.min(relativeStart, length);
|
|
166
|
+
if (argumentCount === 1) return { start: actualStart, deleteCount: length - actualStart };
|
|
167
|
+
let requestedDelete = Number(deleteCount);
|
|
168
|
+
if (Number.isNaN(requestedDelete)) requestedDelete = 0;
|
|
169
|
+
requestedDelete = Math.max(0, Math.trunc(requestedDelete));
|
|
170
|
+
return {
|
|
171
|
+
start: actualStart,
|
|
172
|
+
deleteCount: Math.min(requestedDelete, length - actualStart),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function appendWebTaskItem(list, input, before = null)
|
|
177
|
+
{
|
|
178
|
+
const documentObject = list?.ownerDocument;
|
|
179
|
+
if (!documentObject?.createElement || !documentObject?.createTextNode) return false;
|
|
180
|
+
|
|
181
|
+
const itemValue = normalizedTaskItem(input);
|
|
182
|
+
const item = documentObject.createElement("li");
|
|
183
|
+
item.classList?.add("task-list-item");
|
|
184
|
+
const label = documentObject.createElement("label");
|
|
185
|
+
const checkbox = documentObject.createElement("input");
|
|
186
|
+
checkbox.type = "checkbox";
|
|
187
|
+
checkbox.classList?.add("task-list-item-checkbox");
|
|
188
|
+
checkbox.checked = itemValue.checked;
|
|
189
|
+
label.append(checkbox, documentObject.createTextNode(itemValue.value));
|
|
190
|
+
item.append(label);
|
|
191
|
+
list.insertBefore(item, before);
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function mutateWebHeadingList(heading, method, args)
|
|
196
|
+
{
|
|
197
|
+
const list = firstHeadingList(heading);
|
|
198
|
+
if (!list) {
|
|
199
|
+
if (method === "push" || method === "unshift") return 0;
|
|
200
|
+
if (method === "splice") return [];
|
|
201
|
+
return undefined;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const items = directWebTaskItems(list);
|
|
205
|
+
if (method === "splice") {
|
|
206
|
+
const range = normalizedSpliceRange(items.length, args.length, args[0], args[1]);
|
|
207
|
+
const removed = items
|
|
208
|
+
.slice(range.start, range.start + range.deleteCount)
|
|
209
|
+
.map((item) => webTaskItemValue(item, directTaskCheckbox(item)));
|
|
210
|
+
const before = items[range.start] ?? null;
|
|
211
|
+
for (const input of args.slice(2)) appendWebTaskItem(list, input, before);
|
|
212
|
+
for (const item of items.slice(range.start, range.start + range.deleteCount)) item.remove?.();
|
|
213
|
+
return removed;
|
|
214
|
+
}
|
|
215
|
+
if (method === "pop" || method === "shift") {
|
|
216
|
+
const item = method === "pop" ? items.at(-1) : items[0];
|
|
217
|
+
if (!item) return undefined;
|
|
218
|
+
const value = webTaskItemValue(item, directTaskCheckbox(item));
|
|
219
|
+
item.remove?.();
|
|
220
|
+
return value;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (method === "push") {
|
|
224
|
+
for (const input of args) appendWebTaskItem(list, input);
|
|
225
|
+
} else {
|
|
226
|
+
const before = items[0] ?? list.firstChild ?? null;
|
|
227
|
+
for (const input of args) appendWebTaskItem(list, input, before);
|
|
228
|
+
}
|
|
229
|
+
return directWebTaskItems(list).length;
|
|
230
|
+
}
|
|
231
|
+
|
|
127
232
|
function resizeWebTextarea(element)
|
|
128
233
|
{
|
|
129
234
|
if (!element || String(element.tagName ?? "").toLowerCase() !== "textarea") return;
|
|
@@ -194,6 +299,57 @@ export function createWebDollar(documentObject = globalThis.document)
|
|
|
194
299
|
return args.length > 0 ? selection : "";
|
|
195
300
|
}
|
|
196
301
|
},
|
|
302
|
+
push(...items) {
|
|
303
|
+
try {
|
|
304
|
+
const element = findWebDollarElement(documentObject, selectorText, selector);
|
|
305
|
+
return isWebHeading(element) ? mutateWebHeadingList(element, "push", items) : 0;
|
|
306
|
+
} catch {
|
|
307
|
+
return 0;
|
|
308
|
+
}
|
|
309
|
+
},
|
|
310
|
+
pop() {
|
|
311
|
+
try {
|
|
312
|
+
const element = findWebDollarElement(documentObject, selectorText, selector);
|
|
313
|
+
return isWebHeading(element) ? mutateWebHeadingList(element, "pop", []) : undefined;
|
|
314
|
+
} catch {
|
|
315
|
+
return undefined;
|
|
316
|
+
}
|
|
317
|
+
},
|
|
318
|
+
shift() {
|
|
319
|
+
try {
|
|
320
|
+
const element = findWebDollarElement(documentObject, selectorText, selector);
|
|
321
|
+
return isWebHeading(element) ? mutateWebHeadingList(element, "shift", []) : undefined;
|
|
322
|
+
} catch {
|
|
323
|
+
return undefined;
|
|
324
|
+
}
|
|
325
|
+
},
|
|
326
|
+
unshift(...items) {
|
|
327
|
+
try {
|
|
328
|
+
const element = findWebDollarElement(documentObject, selectorText, selector);
|
|
329
|
+
return isWebHeading(element) ? mutateWebHeadingList(element, "unshift", items) : 0;
|
|
330
|
+
} catch {
|
|
331
|
+
return 0;
|
|
332
|
+
}
|
|
333
|
+
},
|
|
334
|
+
splice(...args) {
|
|
335
|
+
try {
|
|
336
|
+
const element = findWebDollarElement(documentObject, selectorText, selector);
|
|
337
|
+
return isWebHeading(element) ? mutateWebHeadingList(element, "splice", args) : [];
|
|
338
|
+
} catch {
|
|
339
|
+
return [];
|
|
340
|
+
}
|
|
341
|
+
},
|
|
342
|
+
slice(...args) {
|
|
343
|
+
try {
|
|
344
|
+
const element = findWebDollarElement(documentObject, selectorText, selector);
|
|
345
|
+
if (!isWebHeading(element)) return [];
|
|
346
|
+
const list = firstHeadingList(element);
|
|
347
|
+
if (!list) return [];
|
|
348
|
+
return directWebTaskItems(list).map(webTaskItemSnapshot).slice(...args);
|
|
349
|
+
} catch {
|
|
350
|
+
return [];
|
|
351
|
+
}
|
|
352
|
+
},
|
|
197
353
|
};
|
|
198
354
|
return selection;
|
|
199
355
|
};
|
package/src/cui/server.mjs
CHANGED
|
@@ -12,5 +12,49 @@ export function toggleTaskCheckboxBeforeColumn(line, column) {
|
|
|
12
12
|
return {
|
|
13
13
|
line: text.slice(0, checkboxAt) + replacement + text.slice(checkboxAt + 1),
|
|
14
14
|
toggled: true,
|
|
15
|
+
checkboxAt,
|
|
16
|
+
checked: replacement === "☒",
|
|
15
17
|
};
|
|
16
18
|
}
|
|
19
|
+
|
|
20
|
+
export function updateAnsiTaskCheckbox(ansiLine, checkboxAt, checked) {
|
|
21
|
+
const input = String(ansiLine ?? "");
|
|
22
|
+
const target = checked ? "☒" : "☐";
|
|
23
|
+
const sgr = `\x1b[${checked ? "32" : "2"}m`;
|
|
24
|
+
let plainIndex = 0;
|
|
25
|
+
|
|
26
|
+
for (let i = 0; i < input.length;) {
|
|
27
|
+
if (input[i] === "\x1b" && input[i + 1] === "[") {
|
|
28
|
+
let end = i + 2;
|
|
29
|
+
while (end < input.length) {
|
|
30
|
+
const code = input.charCodeAt(end);
|
|
31
|
+
if (code >= 0x40 && code <= 0x7e) { end++; break; }
|
|
32
|
+
end++;
|
|
33
|
+
}
|
|
34
|
+
i = end;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (input[i] === "\x1b" && input[i + 1] === "]") {
|
|
38
|
+
const bel = input.indexOf("\x07", i + 2);
|
|
39
|
+
const st = input.indexOf("\x1b\\", i + 2);
|
|
40
|
+
if (bel < 0 && st < 0) break;
|
|
41
|
+
i = bel >= 0 && (st < 0 || bel < st) ? bel + 1 : st + 2;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const codePoint = input.codePointAt(i);
|
|
46
|
+
const charLength = codePoint > 0xffff ? 2 : 1;
|
|
47
|
+
if (plainIndex === checkboxAt && (input[i] === "☐" || input[i] === "☒")) {
|
|
48
|
+
const prefix = input.slice(0, i);
|
|
49
|
+
const immediateSgr = prefix.match(/\x1b\[[0-9;]*m$/);
|
|
50
|
+
const beforeStyle = immediateSgr
|
|
51
|
+
? prefix.slice(0, prefix.length - immediateSgr[0].length) + sgr
|
|
52
|
+
: prefix;
|
|
53
|
+
return beforeStyle + target + input.slice(i + charLength);
|
|
54
|
+
}
|
|
55
|
+
plainIndex += charLength;
|
|
56
|
+
i += charLength;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return input;
|
|
60
|
+
}
|