jsmdcui 0.7.0 → 0.9.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 +127 -0
- package/README.md +189 -19
- package/cdp-maze.js +141 -0
- package/demos/maze.md +149 -0
- package/demos/todo-zh.md +98 -0
- package/demos/todo.md +98 -0
- package/edit +9 -0
- package/llm-maze.txt +94 -0
- package/package.json +1 -1
- package/runmd.mjs +44 -2
- package/runtime/help/cdp.md +5 -0
- package/runtime/help/help.md +189 -19
- package/runtime/jsplugins/cdp/cdp-server.js +26 -2
- package/runtime/jsplugins/cdp/cdp.js +60 -51
- package/runtime/jsplugins/chapter/chapter.js +6 -3
- package/runtime/jsplugins/example/example.js +12 -7
- package/runtime/syntax/markdown.yaml +1 -0
- package/single-exe/README.md +223 -45
- package/single-exe/compiled.js +6 -3
- package/single-exe/entry.mjs +4 -3
- package/single-exe/packAssets.sh +1 -1
- package/src/cui/fence-events.mjs +106 -0
- package/src/cui/id-collision.mjs +10 -28
- package/src/cui/kitty-debug.mjs +1 -1
- package/src/cui/kitty-images.mjs +11 -1
- package/src/cui/rpc.mjs +6 -3
- package/src/cui/server.mjs +13 -2
- package/src/index.js +425 -72
- package/src/lua/engine.js +1 -4
- package/src/platform/terminal.js +5 -0
- package/src/plugins/js-bridge.js +157 -8
- package/tests/compiled-runtime.test.js +8 -0
- package/tests/demo.test.js +40 -0
- package/tests/fence-events.test.js +405 -0
- package/tests/id-collision.test.js +11 -0
- package/tests/js-plugin-prompts.test.js +46 -0
- package/tests/key-event.md +10 -0
- package/tests/kitty-demo.md +1 -1
- package/tests/kitty-images.test.js +22 -0
- package/tests/platform-terminal.test.js +8 -0
- package/tests/textarea.md +8 -0
- package/tests/wui.test.js +16 -1
- package/demo.resized.jpg +0 -0
- package/src/runtime/compiled.js +0 -25
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { fenceEventMap, inlineFenceEventCode, parseFenceDeclarations } from "../src/cui/fence-events.mjs";
|
|
6
|
+
import { evalFront } from "../src/cui/rpc.mjs";
|
|
7
|
+
import { buildTuiBlockIndex, createTuiSelector, findTuiBlockAtLine, findTuiBlockInIndex, insertTuiTextareaNewline, mergeTuiTextareaBackward, mergeTuiTextareaForward } from "../src/plugins/js-bridge.js";
|
|
8
|
+
import { convertWuiTextareas } from "../runmd.mjs";
|
|
9
|
+
|
|
10
|
+
const tui = join(import.meta.dir, "..", "tui");
|
|
11
|
+
const bunBin = Bun.which("bun") || process.argv0;
|
|
12
|
+
|
|
13
|
+
async function waitFor(check, timeout = 5000) {
|
|
14
|
+
const deadline = Date.now() + timeout;
|
|
15
|
+
while (Date.now() < deadline) {
|
|
16
|
+
if (check()) return;
|
|
17
|
+
await Bun.sleep(20);
|
|
18
|
+
}
|
|
19
|
+
throw new Error("timed out waiting for PTY output");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
test("fenced text controls retain quoted inline keydown code after the identity", () => {
|
|
23
|
+
const markdown = '```text#myid.field @keydown="first(); second(event); show(\\"done\\")"\nvalue\n```\n';
|
|
24
|
+
const declarations = parseFenceDeclarations(markdown);
|
|
25
|
+
expect(declarations).toHaveLength(1);
|
|
26
|
+
expect(declarations[0]).toMatchObject({
|
|
27
|
+
tag: "text",
|
|
28
|
+
id: "myid",
|
|
29
|
+
classes: ["field"],
|
|
30
|
+
});
|
|
31
|
+
expect(declarations[0].events.get("keydown")).toEqual({
|
|
32
|
+
code: 'first(); second(event); show("done")',
|
|
33
|
+
modifiers: [],
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("prevent modifier prepends preventDefault to inline event code", () => {
|
|
38
|
+
const markdown = '```text#myid @keydown.prevent="submit(event)"\nvalue\n```\n';
|
|
39
|
+
const handler = fenceEventMap(markdown).get("myid").events.get("keydown");
|
|
40
|
+
expect(handler).toEqual({ code: "submit(event)", modifiers: ["prevent"] });
|
|
41
|
+
expect(inlineFenceEventCode(handler)).toBe("event.preventDefault();submit(event)");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("WUI writes keyboard code as native inline handlers with a mobile beforeinput fallback", () => {
|
|
45
|
+
const markdown = '```text#myid.field @keydown.prevent="guard(event)"\nvalue\n```\n';
|
|
46
|
+
const html = convertWuiTextareas(Bun.markdown.html(markdown), fenceEventMap(markdown));
|
|
47
|
+
expect(html).toContain('id="myid"');
|
|
48
|
+
expect(html).toContain('const __mdcuiKeyCode=Number(event.keyCode||event.which||0);');
|
|
49
|
+
expect(html).toContain('if(event.key!=="Unidentified"){\nObject.defineProperty(event,"toJSON"');
|
|
50
|
+
expect(html).toContain('event.preventDefault();guard(event)\n}"');
|
|
51
|
+
expect(html).toContain('onbeforeinput="if(!this.__mdcuiIdentifiedKeydown&&event.data!=null');
|
|
52
|
+
expect(html).toContain('ctrlKey:{configurable:true,value:!!m.ctrlKey}');
|
|
53
|
+
expect(html).toContain('metaKey:{configurable:true,value:!!m.metaKey}');
|
|
54
|
+
expect(html).not.toContain("onkeyup=");
|
|
55
|
+
expect(html).not.toContain("addEventListener");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("keyup declarations are unsupported in both interfaces", () => {
|
|
59
|
+
const markdown = '```text#myid @keyup="update(event)"\nvalue\n```\n';
|
|
60
|
+
const declarations = parseFenceDeclarations(markdown);
|
|
61
|
+
const html = convertWuiTextareas(Bun.markdown.html(markdown), fenceEventMap(markdown));
|
|
62
|
+
expect(declarations[0].events.size).toBe(0);
|
|
63
|
+
expect(fenceEventMap(markdown).has("myid")).toBeFalse();
|
|
64
|
+
expect(html).not.toContain("onkeyup=");
|
|
65
|
+
expect(html).not.toContain("onkeydown=");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("WUI keeps a trailing line comment inside the generated keydown block", () => {
|
|
69
|
+
const markdown = '```text#myid @keydown="guard(event); // trailing comment"\nvalue\n```\n';
|
|
70
|
+
const html = convertWuiTextareas(Bun.markdown.html(markdown), fenceEventMap(markdown));
|
|
71
|
+
const code = (html.match(/onkeydown="([^"]*)"/)?.[1] ?? "")
|
|
72
|
+
.replaceAll(""", '"')
|
|
73
|
+
.replaceAll(">", ">")
|
|
74
|
+
.replaceAll("<", "<")
|
|
75
|
+
.replaceAll("&", "&");
|
|
76
|
+
expect(() => new Function("event", "guard", code)).not.toThrow();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("WUI restores modifier letters from keyCode or code during keydown", () => {
|
|
80
|
+
const markdown = '```text#myid @keydown.prevent="guard(event)"\nvalue\n```\n';
|
|
81
|
+
const html = convertWuiTextareas(Bun.markdown.html(markdown), fenceEventMap(markdown));
|
|
82
|
+
const decodeAttribute = (value) => value
|
|
83
|
+
.replaceAll(""", '"')
|
|
84
|
+
.replaceAll(">", ">")
|
|
85
|
+
.replaceAll("<", "<")
|
|
86
|
+
.replaceAll("&", "&");
|
|
87
|
+
const keydownCode = decodeAttribute(html.match(/onkeydown="([^"]*)"/)?.[1] ?? "");
|
|
88
|
+
const beforeInputCode = decodeAttribute(html.match(/onbeforeinput="([^"]*)"/)?.[1] ?? "");
|
|
89
|
+
const seen = [];
|
|
90
|
+
const element = {};
|
|
91
|
+
const runKeydown = new Function("event", "guard", keydownCode);
|
|
92
|
+
element.onkeydown = (event) => runKeydown.call(element, event, (current) => seen.push({
|
|
93
|
+
key: current.key,
|
|
94
|
+
ctrlKey: current.ctrlKey,
|
|
95
|
+
shiftKey: current.shiftKey,
|
|
96
|
+
altKey: current.altKey,
|
|
97
|
+
metaKey: current.metaKey,
|
|
98
|
+
json: JSON.parse(JSON.stringify(current)),
|
|
99
|
+
toJSONEnumerable: Object.keys(current).includes("toJSON"),
|
|
100
|
+
}));
|
|
101
|
+
|
|
102
|
+
const unidentified = {
|
|
103
|
+
key: "ß",
|
|
104
|
+
keyCode: 0,
|
|
105
|
+
code: "KeyS",
|
|
106
|
+
ctrlKey: true,
|
|
107
|
+
shiftKey: false,
|
|
108
|
+
altKey: true,
|
|
109
|
+
metaKey: true,
|
|
110
|
+
defaultPrevented: false,
|
|
111
|
+
preventDefault() { this.defaultPrevented = true; },
|
|
112
|
+
};
|
|
113
|
+
element.onkeydown(unidentified);
|
|
114
|
+
expect(unidentified.defaultPrevented).toBeTrue();
|
|
115
|
+
expect(unidentified.key).toBe("s");
|
|
116
|
+
|
|
117
|
+
const beforeInput = {
|
|
118
|
+
data: "β",
|
|
119
|
+
defaultPrevented: false,
|
|
120
|
+
preventDefault() { this.defaultPrevented = true; },
|
|
121
|
+
};
|
|
122
|
+
new Function("event", beforeInputCode).call(element, beforeInput);
|
|
123
|
+
expect(seen).toEqual([{
|
|
124
|
+
key: "s",
|
|
125
|
+
ctrlKey: true,
|
|
126
|
+
shiftKey: false,
|
|
127
|
+
altKey: true,
|
|
128
|
+
metaKey: true,
|
|
129
|
+
json: {
|
|
130
|
+
type: "",
|
|
131
|
+
key: "s",
|
|
132
|
+
code: "KeyS",
|
|
133
|
+
raw: "",
|
|
134
|
+
ctrlKey: true,
|
|
135
|
+
shiftKey: false,
|
|
136
|
+
altKey: true,
|
|
137
|
+
metaKey: true,
|
|
138
|
+
repeat: false,
|
|
139
|
+
defaultPrevented: true,
|
|
140
|
+
target: { id: "", tagName: "", className: "", value: "" },
|
|
141
|
+
},
|
|
142
|
+
toJSONEnumerable: false,
|
|
143
|
+
}]);
|
|
144
|
+
expect(beforeInput.key).toBeUndefined();
|
|
145
|
+
expect(beforeInput.defaultPrevented).toBeFalse();
|
|
146
|
+
clearTimeout(element.__mdcuiKeydownReset);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("WUI beforeinput keeps composed text for AltGraph and unmodified input", () => {
|
|
150
|
+
const markdown = '```text#myid @keydown="guard(event)"\nvalue\n```\n';
|
|
151
|
+
const html = convertWuiTextareas(Bun.markdown.html(markdown), fenceEventMap(markdown));
|
|
152
|
+
const decodeAttribute = (value) => value
|
|
153
|
+
.replaceAll(""", '"')
|
|
154
|
+
.replaceAll(">", ">")
|
|
155
|
+
.replaceAll("<", "<")
|
|
156
|
+
.replaceAll("&", "&");
|
|
157
|
+
const keydownCode = decodeAttribute(html.match(/onkeydown="([^"]*)"/)?.[1] ?? "");
|
|
158
|
+
const beforeInputCode = decodeAttribute(html.match(/onbeforeinput="([^"]*)"/)?.[1] ?? "");
|
|
159
|
+
const seen = [];
|
|
160
|
+
const element = {};
|
|
161
|
+
const runKeydown = new Function("event", "guard", keydownCode);
|
|
162
|
+
element.onkeydown = (event) => runKeydown.call(element, event, current => seen.push(current.key));
|
|
163
|
+
const retry = (keydown, data) => {
|
|
164
|
+
element.onkeydown({ key: "Unidentified", ...keydown });
|
|
165
|
+
new Function("event", beforeInputCode).call(element, { data });
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
retry({ keyCode: 83, altKey: true, getModifierState: name => name === "AltGraph" }, "β");
|
|
169
|
+
retry({ keyCode: 83 }, "絲");
|
|
170
|
+
|
|
171
|
+
expect(seen).toEqual(["β", "絲"]);
|
|
172
|
+
clearTimeout(element.__mdcuiKeydownReset);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("TUI finds the event target only while the cursor is inside the framed body", () => {
|
|
176
|
+
const lines = ["┌─ text#myid.field", "│ value", "└─"];
|
|
177
|
+
expect(findTuiBlockAtLine(lines, 0)).toBeNull();
|
|
178
|
+
expect(findTuiBlockAtLine(lines, 1)?.header).toMatchObject({ tag: "text", id: "myid" });
|
|
179
|
+
expect(findTuiBlockAtLine(lines, 2)).toBeNull();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("TUI textarea val changes participate in undo and redo", () => {
|
|
183
|
+
const buffer = {
|
|
184
|
+
lines: ["┌─ textarea#field", "│ old", "└─"],
|
|
185
|
+
cursor: { x: 2, y: 1 },
|
|
186
|
+
undoStack: [],
|
|
187
|
+
redoStack: [],
|
|
188
|
+
pushUndo(force) {
|
|
189
|
+
expect(force).toBeTrue();
|
|
190
|
+
this.undoStack.push(this.lines.slice());
|
|
191
|
+
this.redoStack = [];
|
|
192
|
+
},
|
|
193
|
+
undo() {
|
|
194
|
+
this.redoStack.push(this.lines.slice());
|
|
195
|
+
this.lines = this.undoStack.pop();
|
|
196
|
+
},
|
|
197
|
+
redo() {
|
|
198
|
+
this.undoStack.push(this.lines.slice());
|
|
199
|
+
this.lines = this.redoStack.pop();
|
|
200
|
+
},
|
|
201
|
+
invalidateHighlightFrom() {},
|
|
202
|
+
ensureCursor() {},
|
|
203
|
+
};
|
|
204
|
+
const $ = createTuiSelector(() => buffer);
|
|
205
|
+
|
|
206
|
+
$("textarea#field").val("new\nrow");
|
|
207
|
+
expect($("textarea#field").val()).toBe("new\nrow");
|
|
208
|
+
buffer.undo();
|
|
209
|
+
expect($("textarea#field").val()).toBe("old");
|
|
210
|
+
buffer.redo();
|
|
211
|
+
expect($("textarea#field").val()).toBe("new\nrow");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("TUI textarea Enter splits a body row and Backspace joins it", () => {
|
|
215
|
+
const buffer = {
|
|
216
|
+
lines: ["┌─ textarea#field", "│ abcd", "└─", "after"],
|
|
217
|
+
cursor: { x: 4, y: 1 },
|
|
218
|
+
_mdcuiFenceBlockIndex: { stale: true },
|
|
219
|
+
invalidateHighlightFrom() {},
|
|
220
|
+
ensureCursor() {},
|
|
221
|
+
};
|
|
222
|
+
const block = findTuiBlockAtLine(buffer.lines, buffer.cursor.y);
|
|
223
|
+
|
|
224
|
+
expect(insertTuiTextareaNewline(buffer, block)).toBeTrue();
|
|
225
|
+
expect(buffer.lines).toEqual([
|
|
226
|
+
"┌─ textarea#field",
|
|
227
|
+
"│ ab",
|
|
228
|
+
"│ cd",
|
|
229
|
+
"└─",
|
|
230
|
+
"after",
|
|
231
|
+
]);
|
|
232
|
+
expect(buffer.cursor).toEqual({ x: 2, y: 2 });
|
|
233
|
+
expect(buffer._mdcuiFenceBlockIndex).toBeNull();
|
|
234
|
+
|
|
235
|
+
expect(mergeTuiTextareaBackward(buffer, findTuiBlockAtLine(buffer.lines, buffer.cursor.y))).toBeTrue();
|
|
236
|
+
expect(buffer.lines).toEqual(["┌─ textarea#field", "│ abcd", "└─", "after"]);
|
|
237
|
+
expect(buffer.cursor).toEqual({ x: 4, y: 1 });
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("TUI textarea Delete at line end joins the next body row", () => {
|
|
241
|
+
const buffer = {
|
|
242
|
+
lines: ["┌─ textarea#field", "│ ab", "│ cd", "└─", "after"],
|
|
243
|
+
cursor: { x: 4, y: 1 },
|
|
244
|
+
_mdcuiFenceBlockIndex: { stale: true },
|
|
245
|
+
_mdcuiControlBlockIndex: { stale: true },
|
|
246
|
+
invalidateHighlightFrom() {},
|
|
247
|
+
ensureCursor() {},
|
|
248
|
+
};
|
|
249
|
+
const block = findTuiBlockAtLine(buffer.lines, buffer.cursor.y);
|
|
250
|
+
|
|
251
|
+
expect(mergeTuiTextareaForward(buffer, block)).toBeTrue();
|
|
252
|
+
expect(buffer.lines).toEqual(["┌─ textarea#field", "│ abcd", "└─", "after"]);
|
|
253
|
+
expect(buffer.cursor).toEqual({ x: 4, y: 1 });
|
|
254
|
+
expect(buffer._mdcuiFenceBlockIndex).toBeNull();
|
|
255
|
+
expect(buffer._mdcuiControlBlockIndex).toBeNull();
|
|
256
|
+
expect(mergeTuiTextareaForward(buffer, findTuiBlockAtLine(buffer.lines, buffer.cursor.y))).toBeFalse();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("TUI keyboard lookup indexes event blocks once and uses binary lookup", () => {
|
|
260
|
+
const lines = [
|
|
261
|
+
"heading",
|
|
262
|
+
"┌─ text#plain",
|
|
263
|
+
"│ no event",
|
|
264
|
+
"└─",
|
|
265
|
+
...Array.from({ length: 5_000 }, (_, index) => `line ${index}`),
|
|
266
|
+
"┌─ text#target",
|
|
267
|
+
"│ event body",
|
|
268
|
+
"└─",
|
|
269
|
+
];
|
|
270
|
+
const declarations = new Map([
|
|
271
|
+
["target", { tag: "text", events: new Map([["keydown", { code: "hit()" }]]) }],
|
|
272
|
+
]);
|
|
273
|
+
const blocks = buildTuiBlockIndex(lines, declarations);
|
|
274
|
+
|
|
275
|
+
expect(blocks).toHaveLength(1);
|
|
276
|
+
expect(blocks[0].header).toMatchObject({ tag: "text", id: "target" });
|
|
277
|
+
expect(findTuiBlockInIndex(blocks, lines.length - 2)).toBe(blocks[0]);
|
|
278
|
+
expect(findTuiBlockInIndex(blocks, 2)).toBeNull();
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("front evaluation exposes the TUI event scope to inline statements", async () => {
|
|
282
|
+
const seen = [];
|
|
283
|
+
const event = { key: "Enter" };
|
|
284
|
+
const result = await evalFront(
|
|
285
|
+
{ record(value) { seen.push(value); } },
|
|
286
|
+
"record(event.key)",
|
|
287
|
+
{ event },
|
|
288
|
+
);
|
|
289
|
+
expect(result).toBeUndefined();
|
|
290
|
+
expect(seen).toEqual(["Enter"]);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("TUI keydown.prevent runs before editing and blocks text input", async () => {
|
|
294
|
+
const dir = await mkdtemp(join(tmpdir(), "jsmdcui-keydown-"));
|
|
295
|
+
const markdownPath = join(dir, "app.md");
|
|
296
|
+
const markdown = [
|
|
297
|
+
'```text#field @keydown.prevent="record(event)"',
|
|
298
|
+
"a",
|
|
299
|
+
"```",
|
|
300
|
+
"",
|
|
301
|
+
"```text#status",
|
|
302
|
+
"waiting",
|
|
303
|
+
"```",
|
|
304
|
+
"",
|
|
305
|
+
"```js front",
|
|
306
|
+
"export function record(event) {",
|
|
307
|
+
" const data = JSON.parse(JSON.stringify(event));",
|
|
308
|
+
" $('#status').val(`${data.key}:${data.target.id}:${data.target.value}:${data.defaultPrevented}:${Object.keys(event).includes('toJSON')}`);",
|
|
309
|
+
"}",
|
|
310
|
+
"```",
|
|
311
|
+
"",
|
|
312
|
+
].join("\n");
|
|
313
|
+
let output = "";
|
|
314
|
+
let proc;
|
|
315
|
+
let terminal;
|
|
316
|
+
try {
|
|
317
|
+
await writeFile(markdownPath, markdown);
|
|
318
|
+
terminal = new Bun.Terminal({
|
|
319
|
+
cols: 60,
|
|
320
|
+
rows: 16,
|
|
321
|
+
data(_terminal, data) {
|
|
322
|
+
output += Buffer.from(data).toString();
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
proc = Bun.spawn({
|
|
326
|
+
cmd: [bunBin, tui, "+2:4", markdownPath],
|
|
327
|
+
cwd: dir,
|
|
328
|
+
terminal,
|
|
329
|
+
env: { ...process.env, TERM: "xterm-256color", COLUMNS: "60", LINES: "16" },
|
|
330
|
+
});
|
|
331
|
+
await waitFor(() => Bun.stripANSI(output).includes("waiting"));
|
|
332
|
+
terminal.write("x");
|
|
333
|
+
await waitFor(() => Bun.stripANSI(output).includes("x:field:a:true:false"));
|
|
334
|
+
terminal.write("\x11");
|
|
335
|
+
await Promise.race([proc.exited, Bun.sleep(2000)]);
|
|
336
|
+
expect(Bun.stripANSI(output)).toContain("x:field:a:true:false");
|
|
337
|
+
} finally {
|
|
338
|
+
if (proc && proc.exitCode == null) proc.kill();
|
|
339
|
+
terminal?.close();
|
|
340
|
+
await rm(dir, { recursive: true, force: true });
|
|
341
|
+
}
|
|
342
|
+
}, 10000);
|
|
343
|
+
|
|
344
|
+
test("TUI keydown alert releases and restores terminal input", async () => {
|
|
345
|
+
const dir = await mkdtemp(join(tmpdir(), "jsmdcui-keydown-alert-"));
|
|
346
|
+
const markdownPath = join(dir, "app.md");
|
|
347
|
+
const markdown = [
|
|
348
|
+
'```text#field @keydown.prevent="celebrate(event)"',
|
|
349
|
+
"focus here",
|
|
350
|
+
"```",
|
|
351
|
+
"",
|
|
352
|
+
"```text#status",
|
|
353
|
+
"waiting",
|
|
354
|
+
"```",
|
|
355
|
+
"",
|
|
356
|
+
"```js front",
|
|
357
|
+
"export function celebrate(event) {",
|
|
358
|
+
" if (event.key === 'x') {",
|
|
359
|
+
" alert('KEYDOWN ALERT');",
|
|
360
|
+
" $('#status').val('alert closed');",
|
|
361
|
+
" return;",
|
|
362
|
+
" }",
|
|
363
|
+
" $('#status').val(`second input: ${event.key}`);",
|
|
364
|
+
"}",
|
|
365
|
+
"```",
|
|
366
|
+
"",
|
|
367
|
+
].join("\n");
|
|
368
|
+
let output = "";
|
|
369
|
+
let proc;
|
|
370
|
+
let terminal;
|
|
371
|
+
try {
|
|
372
|
+
await writeFile(markdownPath, markdown);
|
|
373
|
+
terminal = new Bun.Terminal({
|
|
374
|
+
cols: 60,
|
|
375
|
+
rows: 16,
|
|
376
|
+
data(_terminal, data) {
|
|
377
|
+
output += Buffer.from(data).toString();
|
|
378
|
+
},
|
|
379
|
+
});
|
|
380
|
+
proc = Bun.spawn({
|
|
381
|
+
cmd: [bunBin, tui, "+2:4", markdownPath],
|
|
382
|
+
cwd: dir,
|
|
383
|
+
terminal,
|
|
384
|
+
env: { ...process.env, TERM: "xterm-256color", COLUMNS: "60", LINES: "16" },
|
|
385
|
+
});
|
|
386
|
+
await waitFor(() => Bun.stripANSI(output).includes("waiting"));
|
|
387
|
+
terminal.write("x");
|
|
388
|
+
await waitFor(() => Bun.stripANSI(output).includes("KEYDOWN ALERT"));
|
|
389
|
+
terminal.write("\r");
|
|
390
|
+
await waitFor(() => Bun.stripANSI(output).includes("alert closed"));
|
|
391
|
+
terminal.write("z");
|
|
392
|
+
await waitFor(() => Bun.stripANSI(output).includes("second input:z"));
|
|
393
|
+
terminal.write("\x11");
|
|
394
|
+
const exitCode = await Promise.race([
|
|
395
|
+
proc.exited,
|
|
396
|
+
Bun.sleep(2000).then(() => null),
|
|
397
|
+
]);
|
|
398
|
+
expect(exitCode).toBe(0);
|
|
399
|
+
expect(Bun.stripANSI(output)).toContain("second input:z");
|
|
400
|
+
} finally {
|
|
401
|
+
if (proc && proc.exitCode == null) proc.kill();
|
|
402
|
+
terminal?.close();
|
|
403
|
+
await rm(dir, { recursive: true, force: true });
|
|
404
|
+
}
|
|
405
|
+
}, 10000);
|
|
@@ -43,6 +43,17 @@ test("--check reports heading/fenced-block collisions with line details", async
|
|
|
43
43
|
expect(output.lastIndexOf("FAILED")).toBeGreaterThan(output.lastIndexOf("Suggested fix"));
|
|
44
44
|
});
|
|
45
45
|
|
|
46
|
+
test("--check keeps fenced IDs that are followed by inline event attributes", async () => {
|
|
47
|
+
const result = await runCheck(
|
|
48
|
+
'## Write Status\n\n```text#write-status @keydown="refresh(); validate(event)"\nwaiting\n```\n',
|
|
49
|
+
);
|
|
50
|
+
const output = Bun.stripANSI(result.stdout.toString());
|
|
51
|
+
expect(result.exitCode).toBe(1);
|
|
52
|
+
expect(output).toContain("ID #write-status");
|
|
53
|
+
expect(output).toContain("Type: text fenced block");
|
|
54
|
+
expect(output).toContain('Source: ```text#write-status @keydown="refresh(); validate(event)"');
|
|
55
|
+
});
|
|
56
|
+
|
|
46
57
|
test("--check reports duplicate fenced-block IDs", async () => {
|
|
47
58
|
const result = await runCheck("```text#myid\na\n```\n\n```textarea#myid\nb\n```\n");
|
|
48
59
|
expect(result.exitCode).toBe(1);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { buildMicroGlobal } from "../src/plugins/js-bridge.js";
|
|
3
|
+
|
|
4
|
+
test("micro prompt APIs delegate and return synchronously", () => {
|
|
5
|
+
const previousMicro = globalThis.micro;
|
|
6
|
+
const previousSelector = globalThis.$;
|
|
7
|
+
const calls = [];
|
|
8
|
+
const app = {
|
|
9
|
+
protectedAlert(message) {
|
|
10
|
+
calls.push(["alert", message]);
|
|
11
|
+
return undefined;
|
|
12
|
+
},
|
|
13
|
+
protectedConfirm(message) {
|
|
14
|
+
calls.push(["confirm", message]);
|
|
15
|
+
return true;
|
|
16
|
+
},
|
|
17
|
+
protectedPrompt(message, defaultValue) {
|
|
18
|
+
calls.push(["prompt", message, defaultValue]);
|
|
19
|
+
return "answer";
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const micro = buildMicroGlobal({ _app: app, _ctx: null, on() {} });
|
|
25
|
+
const alertResult = micro.alert("hello");
|
|
26
|
+
const confirmResult = micro.confirm("continue?");
|
|
27
|
+
const promptResult = micro.prompt("name", "Ada");
|
|
28
|
+
|
|
29
|
+
expect(alertResult).toBeUndefined();
|
|
30
|
+
expect(confirmResult).toBe(true);
|
|
31
|
+
expect(promptResult).toBe("answer");
|
|
32
|
+
expect(alertResult?.then).toBeUndefined();
|
|
33
|
+
expect(confirmResult?.then).toBeUndefined();
|
|
34
|
+
expect(promptResult?.then).toBeUndefined();
|
|
35
|
+
expect(calls).toEqual([
|
|
36
|
+
["alert", "hello"],
|
|
37
|
+
["confirm", "continue?"],
|
|
38
|
+
["prompt", "name", "Ada"],
|
|
39
|
+
]);
|
|
40
|
+
} finally {
|
|
41
|
+
if (previousMicro === undefined) delete globalThis.micro;
|
|
42
|
+
else globalThis.micro = previousMicro;
|
|
43
|
+
if (previousSelector === undefined) delete globalThis.$;
|
|
44
|
+
else globalThis.$ = previousSelector;
|
|
45
|
+
}
|
|
46
|
+
});
|
package/tests/kitty-demo.md
CHANGED
|
@@ -26,6 +26,28 @@ test("Bun-rendered Markdown image links reserve rows and retain Kitty data", asy
|
|
|
26
26
|
expect(Bun.stripANSI(result.rendered)).toContain("📷 pixel");
|
|
27
27
|
});
|
|
28
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("", {
|
|
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
|
+
|
|
29
51
|
test("remote Kitty images require allowUrl and use the configured HTTP byte fetcher", async () => {
|
|
30
52
|
const imageUrl = "https://example.test/assets/pixel.png";
|
|
31
53
|
const ansi = Bun.markdown.ansi(``, {
|
|
@@ -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
|
+
});
|
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/demo.resized.jpg
DELETED
|
Binary file
|
package/src/runtime/compiled.js
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
import { basename, dirname, resolve } from "node:path";
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
3
|
-
import { fileURLToPath } from "node:url";
|
|
4
|
-
|
|
5
|
-
export function isCompiledBinary(argv = process.argv) {
|
|
6
|
-
return Boolean(argv?.[1]?.startsWith?.("/$bunfs/"));
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export function resolveCompiledBaseDir({ argv = process.argv, execPath = process.execPath } = {}) {
|
|
10
|
-
const bn = basename(execPath);
|
|
11
|
-
if (bn.startsWith("ld") ||
|
|
12
|
-
bn.startsWith("libld") ||
|
|
13
|
-
bn.startsWith("linker") ) {
|
|
14
|
-
const realArgv = readFileSync("/proc/self/cmdline", "utf8").match(/[^\0]+/g);
|
|
15
|
-
return dirname(realArgv?.[1] ?? execPath);
|
|
16
|
-
}
|
|
17
|
-
return dirname(execPath) || process.cwd();
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function resolveRepoRoot(importMetaUrl, options = {}) {
|
|
21
|
-
if (isCompiledBinary(options.argv)) {
|
|
22
|
-
return resolveCompiledBaseDir(options);
|
|
23
|
-
}
|
|
24
|
-
return resolve(dirname(fileURLToPath(importMetaUrl)), "..");
|
|
25
|
-
}
|