jsmdcui 0.6.3 → 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.
Files changed (46) hide show
  1. package/CHANGELOG.md +121 -0
  2. package/README.md +216 -33
  3. package/clean.sh +9 -0
  4. package/demo.resized.jpg +0 -0
  5. package/{image-processor.md → demos/image-processor.md} +1 -0
  6. package/{image-processor.zh-TW.md → demos/image-processor.zh-TW.md} +1 -0
  7. package/demos/maze.md +144 -0
  8. package/{select.md → demos/select.md} +2 -0
  9. package/demos/todo-zh.md +190 -0
  10. package/demos/todo.md +191 -0
  11. package/edit +9 -0
  12. package/package.json +1 -1
  13. package/runmd.mjs +77 -16
  14. package/runtime/help/help.md +216 -33
  15. package/runtime/jsplugins/cdp/cdp.js +5 -2
  16. package/runtime/jsplugins/chapter/chapter.js +4 -2
  17. package/runtime/jsplugins/example/example.js +10 -7
  18. package/runtime/syntax/markdown.yaml +1 -0
  19. package/single-exe/packAssets.sh +1 -1
  20. package/src/cui/fence-events.mjs +106 -0
  21. package/src/cui/id-collision.mjs +10 -28
  22. package/src/cui/kitty-debug.mjs +25 -0
  23. package/src/cui/kitty-images.mjs +200 -0
  24. package/src/cui/rpc.mjs +169 -10
  25. package/src/cui/server.mjs +14 -3
  26. package/src/cui/task-checkbox.mjs +44 -0
  27. package/src/index.js +615 -101
  28. package/src/platform/terminal.js +5 -0
  29. package/src/plugins/js-bridge.js +354 -21
  30. package/src/screen/screen.js +108 -1
  31. package/tests/cat-markdown.test.js +6 -5
  32. package/tests/demo.test.js +99 -9
  33. package/tests/fence-events.test.js +405 -0
  34. package/tests/heading-list-selector.test.js +346 -0
  35. package/tests/id-collision.test.js +14 -2
  36. package/tests/js-plugin-prompts.test.js +46 -0
  37. package/tests/key-event.md +10 -0
  38. package/tests/kitty-demo.md +184 -0
  39. package/tests/kitty-images.test.js +169 -0
  40. package/tests/platform-terminal.test.js +8 -0
  41. package/tests/task-checkbox.test.js +33 -0
  42. package/tests/textarea.md +8 -0
  43. package/tests/wui-responsive-images.test.js +35 -0
  44. package/tests/wui.test.js +16 -1
  45. package/tui +4 -2
  46. package/wui +6 -2
@@ -0,0 +1,169 @@
1
+ import { expect, test } from "bun:test";
2
+ import { mkdtemp, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { fitKittyImageToWidth, prepareKittyImages } from "../src/cui/kitty-images.mjs";
6
+ import { Screen } from "../src/screen/screen.js";
7
+
8
+ const ONE_PIXEL_PNG = Buffer.from(
9
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
10
+ "base64",
11
+ );
12
+
13
+ test("Bun-rendered Markdown image links reserve rows and retain Kitty data", async () => {
14
+ const dir = await mkdtemp(join(tmpdir(), "jsmdcui-kitty-"));
15
+ const markdownPath = join(dir, "app.md");
16
+ await writeFile(join(dir, "pixel.png"), ONE_PIXEL_PNG);
17
+ const ansi = Bun.markdown.ansi("before\n\n![pixel](pixel.png)\n\nafter\n", {
18
+ hyperlinks: true,
19
+ columns: 40,
20
+ });
21
+ const result = await prepareKittyImages(ansi, markdownPath, 40);
22
+
23
+ expect(result.images).toHaveLength(1);
24
+ expect(result.images[0]).toMatchObject({ mime: "image/png", cols: 1, rows: 1 });
25
+ expect(result.images[0].data.equals(ONE_PIXEL_PNG)).toBe(true);
26
+ expect(Bun.stripANSI(result.rendered)).toContain("📷 pixel");
27
+ });
28
+
29
+ test("Kitty compat converts compressed images to standard PNG payloads", async () => {
30
+ const dir = await mkdtemp(join(tmpdir(), "jsmdcui-kitty-compat-"));
31
+ const markdownPath = join(dir, "app.md");
32
+ const jpeg = Buffer.from(await new Bun.Image(ONE_PIXEL_PNG).jpeg().toBuffer());
33
+ await writeFile(join(dir, "pixel.jpg"), jpeg);
34
+ const ansi = Bun.markdown.ansi("![pixel](pixel.jpg)", {
35
+ hyperlinks: true,
36
+ columns: 40,
37
+ });
38
+
39
+ const result = await prepareKittyImages(ansi, markdownPath, 40, {
40
+ kittyMode: "compat",
41
+ });
42
+
43
+ expect(result.images).toHaveLength(1);
44
+ expect(result.images[0].mime).toBe("image/png");
45
+ expect(result.images[0].data.subarray(0, 8).equals(Buffer.from([
46
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
47
+ ]))).toBe(true);
48
+ expect(result.images[0].data.equals(jpeg)).toBe(false);
49
+ });
50
+
51
+ test("remote Kitty images require allowUrl and use the configured HTTP byte fetcher", async () => {
52
+ const imageUrl = "https://example.test/assets/pixel.png";
53
+ const ansi = Bun.markdown.ansi(`![pixel](${imageUrl})`, {
54
+ hyperlinks: true,
55
+ columns: 40,
56
+ });
57
+ const requests = [];
58
+ const fetchHttpBytes = async (url) => {
59
+ requests.push(url);
60
+ return ONE_PIXEL_PNG;
61
+ };
62
+
63
+ const blocked = await prepareKittyImages(ansi, "/tmp/app.md", 40, { fetchHttpBytes });
64
+ expect(blocked.images).toHaveLength(0);
65
+ expect(requests).toHaveLength(0);
66
+
67
+ const allowed = await prepareKittyImages(ansi, "/tmp/app.md", 40, {
68
+ allowUrl: true,
69
+ fetchHttpBytes,
70
+ });
71
+ expect(requests).toEqual([imageUrl]);
72
+ expect(allowed.images).toHaveLength(1);
73
+ expect(allowed.images[0].path).toBe(imageUrl);
74
+ });
75
+
76
+ test("remote Markdown resolves relative Kitty image URLs against its source URL", async () => {
77
+ const ansi = Bun.markdown.ansi("![pixel](../images/pixel.png?size=1)", {
78
+ hyperlinks: true,
79
+ columns: 40,
80
+ });
81
+ const requests = [];
82
+ const result = await prepareKittyImages(
83
+ ansi,
84
+ "https://example.test/docs/guide/app.md",
85
+ 40,
86
+ {
87
+ allowUrl: true,
88
+ fetchHttpBytes: async (url) => {
89
+ requests.push(url);
90
+ return ONE_PIXEL_PNG;
91
+ },
92
+ },
93
+ );
94
+
95
+ expect(requests).toEqual(["https://example.test/docs/images/pixel.png?size=1"]);
96
+ expect(result.images).toHaveLength(1);
97
+ });
98
+
99
+ test("Kitty image sizing subtracts the gutter width without depending on remaining rows", () => {
100
+ // A 100-column pane with a 5-column gutter has 95 usable image columns.
101
+ expect(fitKittyImageToWidth({ cols: 100, rows: 40 }, 95)).toEqual({ cols: 95, rows: 38 });
102
+ expect(fitKittyImageToWidth({ cols: 62, rows: 21 }, 95)).toEqual({ cols: 62, rows: 21 });
103
+ expect(fitKittyImageToWidth({ cols: 62, rows: 21 }, 40)).toEqual({ cols: 40, rows: 14 });
104
+ });
105
+
106
+ test("Screen emits chunked Kitty placement at the requested cell and deletes only its IDs", () => {
107
+ const screen = new Screen({ mouse: false, kittyMode: "extended" });
108
+ const writes = [];
109
+ screen.write = (value) => writes.push(String(value));
110
+ screen.setKittyImages([{
111
+ id: 77,
112
+ x: 3,
113
+ y: 4,
114
+ cols: 5,
115
+ rows: 2,
116
+ sourceX: 0,
117
+ sourceY: 0,
118
+ sourceWidth: 1,
119
+ sourceHeight: 1,
120
+ mime: "image/png",
121
+ data: ONE_PIXEL_PNG,
122
+ }]);
123
+ screen.Show();
124
+ const first = writes.join("");
125
+ expect(first).toContain("\x1b[5;4H");
126
+ expect(first).toContain("\x1b_Ga=T,f=100,t=d,i=77,p=77,q=2,x=0,y=0,w=1,h=1,c=5,r=2,C=1,U=image/png,m=0;");
127
+
128
+ writes.length = 0;
129
+ screen.setKittyImages([]);
130
+ screen.Show();
131
+ expect(writes.join("")).toContain("\x1b_Ga=d,d=i,i=77,q=2;");
132
+ expect(writes.join("")).not.toContain("d=a");
133
+
134
+ writes.length = 0;
135
+ screen.setKittyImages([{
136
+ id: 77, x: 3, y: 2, cols: 5, rows: 2,
137
+ sourceX: 0, sourceY: 0, sourceWidth: 1, sourceHeight: 1,
138
+ mime: "image/png", data: ONE_PIXEL_PNG,
139
+ }]);
140
+ screen.Show();
141
+ const replaced = writes.join("");
142
+ expect(replaced).toContain("\x1b_Ga=p,i=77,p=77,q=2,x=0,y=0,w=1,h=1,c=5,r=2,C=1,U=image/png;");
143
+ expect(replaced).not.toContain("a=T");
144
+ expect(replaced).not.toContain(ONE_PIXEL_PNG.toString("base64"));
145
+ });
146
+
147
+ test("Screen gates Kitty output by mode and compat mode omits the MIME U extension", () => {
148
+ const image = {
149
+ id: 88, x: 0, y: 0, cols: 1, rows: 1,
150
+ sourceX: 0, sourceY: 0, sourceWidth: 1, sourceHeight: 1,
151
+ mime: "image/jpeg", data: ONE_PIXEL_PNG,
152
+ };
153
+
154
+ const off = new Screen({ mouse: false });
155
+ const offWrites = [];
156
+ off.write = (value) => offWrites.push(String(value));
157
+ off.setKittyImages([image]);
158
+ off.Show();
159
+ expect(offWrites.join("")).not.toContain("\x1b_G");
160
+
161
+ const compat = new Screen({ mouse: false, kittyMode: "compat" });
162
+ const compatWrites = [];
163
+ compat.write = (value) => compatWrites.push(String(value));
164
+ compat.setKittyImages([image]);
165
+ compat.Show();
166
+ const output = compatWrites.join("");
167
+ expect(output).toContain("\x1b_Ga=T");
168
+ expect(output).not.toContain("U=image/jpeg");
169
+ });
@@ -0,0 +1,8 @@
1
+ import { expect, test } from "bun:test";
2
+ import { controllingTerminalInputPath } from "../src/platform/terminal.js";
3
+
4
+ test("redirected stdin uses the platform controlling-terminal input device", () => {
5
+ expect(controllingTerminalInputPath("win32")).toBe("CONIN$");
6
+ expect(controllingTerminalInputPath("linux")).toBe("/dev/tty");
7
+ expect(controllingTerminalInputPath("darwin")).toBe("/dev/tty");
8
+ });
@@ -0,0 +1,33 @@
1
+ import { expect, test } from "bun:test";
2
+ import {
3
+ toggleTaskCheckboxBeforeColumn,
4
+ updateAnsiTaskCheckbox,
5
+ } from "../src/cui/task-checkbox.mjs";
6
+
7
+ test("task checkbox toggle reports its position and new checked state", () => {
8
+ expect(toggleTaskCheckboxBeforeColumn(" ☐ todo", 4)).toEqual({
9
+ line: " ☒ todo",
10
+ toggled: true,
11
+ checkboxAt: 2,
12
+ checked: true,
13
+ });
14
+ expect(toggleTaskCheckboxBeforeColumn(" ☒ todo", 4)).toEqual({
15
+ line: " ☐ todo",
16
+ toggled: true,
17
+ checkboxAt: 2,
18
+ checked: false,
19
+ });
20
+ });
21
+
22
+ test("task checkbox toggle updates Bun Markdown ANSI color with the glyph", () => {
23
+ const unchecked = " \x1b[2m☐ \x1b[0mtodo";
24
+ const checked = " \x1b[32m☒ \x1b[0mtodo";
25
+ expect(updateAnsiTaskCheckbox(unchecked, 2, true)).toBe(checked);
26
+ expect(updateAnsiTaskCheckbox(checked, 2, false)).toBe(unchecked);
27
+ });
28
+
29
+ test("ANSI checkbox lookup ignores OSC 8 hyperlink metadata", () => {
30
+ const prefix = "\x1b]8;;https://example.test\x1b\\link\x1b]8;;\x1b\\ ";
31
+ expect(updateAnsiTaskCheckbox(`${prefix}\x1b[2m☐ \x1b[0mtodo`, 5, true))
32
+ .toBe(`${prefix}\x1b[32m☒ \x1b[0mtodo`);
33
+ });
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env jsmdcui
2
+
3
+ ```textarea
4
+ hello
5
+ ```
6
+
7
+ - [show text](javascript:alert($('textarea').val()))
8
+ - [show hello](javascript:alert('hello'))
@@ -0,0 +1,35 @@
1
+ import { afterEach, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { createWui } from "../runmd.mjs";
6
+
7
+ const temporaryDirectories = [];
8
+
9
+ afterEach(() => {
10
+ for (const directory of temporaryDirectories.splice(0)) {
11
+ rmSync(directory, { recursive: true, force: true });
12
+ }
13
+ });
14
+
15
+ test("createWui constrains images to their content width", async () => {
16
+ const directory = mkdtempSync(join(tmpdir(), "jsmdcui-wui-image-"));
17
+ temporaryDirectories.push(directory);
18
+ const markdownPath = join(directory, "image.md");
19
+ const html = await createWui("![wide](wide.png)", markdownPath);
20
+
21
+ expect(html).toContain("img {\n max-width: 100%;\n height: auto;\n}");
22
+ expect(html.match(/max-width: 100%/g)).toHaveLength(1);
23
+ expect(html.match(/image\.md\.front\.js/g)).toHaveLength(1);
24
+ });
25
+
26
+ test("createWui injects the image rule into an existing document head", async () => {
27
+ const directory = mkdtempSync(join(tmpdir(), "jsmdcui-wui-document-"));
28
+ temporaryDirectories.push(directory);
29
+ const markdownPath = join(directory, "document.md");
30
+ const source = "<!doctype html><html><head><title>x</title></head><body><img src=\"wide.png\"></body></html>";
31
+ const html = await createWui(source, markdownPath);
32
+
33
+ expect(html.indexOf("max-width: 100%")).toBeLessThan(html.indexOf("</head>"));
34
+ expect(html.match(/document\.md\.front\.js/g)).toHaveLength(1);
35
+ });
package/tests/wui.test.js CHANGED
@@ -2,7 +2,7 @@ import { expect, test } from "bun:test";
2
2
  import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { readMarkdownInput } from "../runmd.mjs";
5
+ import { readMarkdownInput, writeRuntimeFiles } from "../runmd.mjs";
6
6
 
7
7
  test("WUI writes the bundled testapp.md when it is missing", async () => {
8
8
  const dir = await mkdtemp(join(tmpdir(), "jsmdcui-wui-"));
@@ -28,3 +28,18 @@ test("WUI preserves an existing testapp.md", async () => {
28
28
  await rm(dir, { recursive: true, force: true });
29
29
  }
30
30
  });
31
+
32
+ test("generated WUI server falls back to a system port when 3000 is occupied", async () => {
33
+ const dir = await mkdtemp(join(tmpdir(), "jsmdcui-wui-port-"));
34
+ const mdpath = join(dir, "app.md");
35
+ try {
36
+ await writeRuntimeFiles(mdpath);
37
+ const source = await readFile(`${mdpath}-server.js`, "utf8");
38
+ expect(source).toContain('error?.code === "EADDRINUSE"');
39
+ expect(source).toContain('serverOptions.port !== 3000 || !addressInUse');
40
+ expect(source).toContain('Bun.serve({ ...serverOptions, port: 0 })');
41
+ expect(source).toContain('localhost:${server.port}');
42
+ } finally {
43
+ await rm(dir, { recursive: true, force: true });
44
+ }
45
+ });
package/tui CHANGED
@@ -1,5 +1,7 @@
1
1
  #!/bin/sh
2
2
 
3
- sd=$(dirname "$(realpath "$0")")
3
+ ':' //; sd=$(dirname "$(realpath "$0")")
4
4
 
5
- exec bun "$sd"/src/index.js "$@"
5
+ ':' //; exec bun "$sd"/src/index.js "$@"
6
+
7
+ await import('./src/index.js')
package/wui CHANGED
@@ -1,5 +1,9 @@
1
1
  #!/bin/sh
2
2
 
3
- sd=$(dirname "$(realpath "$0")")
3
+ ':' //; sd=$(dirname "$(realpath "$0")")
4
4
 
5
- exec bun "$sd"/src/index.js --wui "$@"
5
+ ':' //; exec bun "$sd"/src/index.js --wui "$@"
6
+
7
+ process.argv.splice(2,0,'--wui')
8
+
9
+ await import('./src/index.js')