jsmdcui 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +61 -0
- package/README.md +112 -18
- package/demos/maze.md +144 -0
- package/demos/todo-zh.md +98 -0
- package/demos/todo.md +98 -0
- package/edit +9 -0
- package/package.json +1 -1
- package/runmd.mjs +44 -2
- package/runtime/help/help.md +112 -18
- package/runtime/jsplugins/cdp/cdp.js +5 -2
- package/runtime/jsplugins/chapter/chapter.js +4 -2
- package/runtime/jsplugins/example/example.js +10 -7
- package/runtime/syntax/markdown.yaml +1 -0
- package/src/cui/fence-events.mjs +106 -0
- package/src/cui/id-collision.mjs +10 -28
- 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 +379 -62
- package/src/platform/terminal.js +5 -0
- package/src/plugins/js-bridge.js +114 -8
- 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-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/src/plugins/js-bridge.js
CHANGED
|
@@ -752,6 +752,56 @@ function _findBlock(lines, selector) {
|
|
|
752
752
|
return null;
|
|
753
753
|
}
|
|
754
754
|
|
|
755
|
+
export function findTuiBlockAtLine(lines, lineIndex) {
|
|
756
|
+
return findTuiBlockInIndex(buildTuiBlockIndex(lines), lineIndex);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
export function buildTuiBlockIndex(lines, declarations = null) {
|
|
760
|
+
const blocks = [];
|
|
761
|
+
for (let start = 0; start < lines.length; start++) {
|
|
762
|
+
const header = _blockHeader(lines[start]);
|
|
763
|
+
if (!header) continue;
|
|
764
|
+
let end = lines.length;
|
|
765
|
+
for (let y = start + 1; y < lines.length; y++) {
|
|
766
|
+
const line = String(lines[y] ?? "");
|
|
767
|
+
const rest = line.startsWith(header.indent)
|
|
768
|
+
? line.slice(header.indent.length)
|
|
769
|
+
: line;
|
|
770
|
+
if (header.kind === "fenced") {
|
|
771
|
+
const closing = rest.match(/^(`{3,})\s*$/);
|
|
772
|
+
if (!closing || closing[1].length < header.fenceLength) continue;
|
|
773
|
+
} else if (!/^(?:└─|╰─|\+-)\s*$/.test(rest)) {
|
|
774
|
+
continue;
|
|
775
|
+
}
|
|
776
|
+
end = y;
|
|
777
|
+
break;
|
|
778
|
+
}
|
|
779
|
+
const declaration = header.id ? declarations?.get(header.id) : null;
|
|
780
|
+
if (
|
|
781
|
+
declarations == null
|
|
782
|
+
|| (
|
|
783
|
+
declaration?.tag === header.tag
|
|
784
|
+
&& declaration.events?.size > 0
|
|
785
|
+
)
|
|
786
|
+
) blocks.push({ start, end, header });
|
|
787
|
+
start = Math.max(start, end);
|
|
788
|
+
}
|
|
789
|
+
return blocks;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
export function findTuiBlockInIndex(blocks, lineIndex) {
|
|
793
|
+
let low = 0;
|
|
794
|
+
let high = blocks.length - 1;
|
|
795
|
+
while (low <= high) {
|
|
796
|
+
const middle = (low + high) >> 1;
|
|
797
|
+
const block = blocks[middle];
|
|
798
|
+
if (lineIndex <= block.start) high = middle - 1;
|
|
799
|
+
else if (lineIndex >= block.end) low = middle + 1;
|
|
800
|
+
else return block;
|
|
801
|
+
}
|
|
802
|
+
return null;
|
|
803
|
+
}
|
|
804
|
+
|
|
755
805
|
function _blockValue(lines, selector) {
|
|
756
806
|
const block = _findBlock(lines, selector);
|
|
757
807
|
if (!block) return undefined;
|
|
@@ -983,7 +1033,7 @@ function _mutateTuiHeadingList(buffer, heading, method, args) {
|
|
|
983
1033
|
buffer._mdcuiHeadingTaskListAnchors = new Map();
|
|
984
1034
|
buffer._mdcuiHeadingTaskListAnchors.set(heading.ordinal, { index: start, indent: list.indent });
|
|
985
1035
|
}
|
|
986
|
-
|
|
1036
|
+
spliceTuiBufferLines(buffer, start, end - start, replacement.lines, {
|
|
987
1037
|
styles: replacement.styles,
|
|
988
1038
|
ansi: replacement.ansi,
|
|
989
1039
|
});
|
|
@@ -1003,14 +1053,14 @@ function _mutateTuiHeadingList(buffer, heading, method, args) {
|
|
|
1003
1053
|
indent: list.indent,
|
|
1004
1054
|
});
|
|
1005
1055
|
}
|
|
1006
|
-
|
|
1056
|
+
spliceTuiBufferLines(buffer, item.start, item.end - item.start, []);
|
|
1007
1057
|
return item.value;
|
|
1008
1058
|
}
|
|
1009
1059
|
|
|
1010
1060
|
const replacement = _tuiTaskItemReplacement(list.indent, args);
|
|
1011
1061
|
if (replacement.lines.length === 0) return list.items.length;
|
|
1012
1062
|
const start = method === "push" ? list.end : list.first;
|
|
1013
|
-
|
|
1063
|
+
spliceTuiBufferLines(buffer, start, 0, replacement.lines, {
|
|
1014
1064
|
styles: replacement.styles,
|
|
1015
1065
|
ansi: replacement.ansi,
|
|
1016
1066
|
});
|
|
@@ -1018,8 +1068,10 @@ function _mutateTuiHeadingList(buffer, heading, method, args) {
|
|
|
1018
1068
|
return list.items.length + replacement.lines.length;
|
|
1019
1069
|
}
|
|
1020
1070
|
|
|
1021
|
-
function
|
|
1071
|
+
export function spliceTuiBufferLines(buffer, start, deleteCount, replacement, replacementMeta = {}) {
|
|
1022
1072
|
const oldCursor = buffer.cursor ? { ...buffer.cursor } : null;
|
|
1073
|
+
buffer._mdcuiFenceBlockIndex = null;
|
|
1074
|
+
buffer._mdcuiControlBlockIndex = null;
|
|
1023
1075
|
buffer.lines.splice(start, deleteCount, ...replacement);
|
|
1024
1076
|
|
|
1025
1077
|
if (Array.isArray(buffer._ansiStyleLines)) {
|
|
@@ -1065,24 +1117,70 @@ function _spliceBufferLines(buffer, start, deleteCount, replacement, replacement
|
|
|
1065
1117
|
buffer.ensureCursor?.();
|
|
1066
1118
|
}
|
|
1067
1119
|
|
|
1120
|
+
export function insertTuiTextareaNewline(buffer, block) {
|
|
1121
|
+
if (!buffer || block?.header?.tag !== "textarea") return false;
|
|
1122
|
+
const prefix = block.header.indent + block.header.bodyMarker + " ";
|
|
1123
|
+
const line = String(buffer.lines?.[buffer.cursor.y] ?? "");
|
|
1124
|
+
const left = line.slice(0, buffer.cursor.x);
|
|
1125
|
+
const right = line.slice(buffer.cursor.x);
|
|
1126
|
+
buffer.lines[buffer.cursor.y] = left;
|
|
1127
|
+
spliceTuiBufferLines(buffer, buffer.cursor.y + 1, 0, [prefix + right]);
|
|
1128
|
+
buffer.cursor.y++;
|
|
1129
|
+
buffer.cursor.x = prefix.length;
|
|
1130
|
+
buffer.ensureCursor?.();
|
|
1131
|
+
return true;
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
export function mergeTuiTextareaBackward(buffer, block) {
|
|
1135
|
+
if (!buffer || block?.header?.tag !== "textarea") return false;
|
|
1136
|
+
const prefix = block.header.indent + block.header.bodyMarker + " ";
|
|
1137
|
+
if (buffer.cursor.x > prefix.length || buffer.cursor.y <= block.start + 1) return false;
|
|
1138
|
+
const previousY = buffer.cursor.y - 1;
|
|
1139
|
+
const previousLength = String(buffer.lines?.[previousY] ?? "").length;
|
|
1140
|
+
const line = String(buffer.lines?.[buffer.cursor.y] ?? "");
|
|
1141
|
+
const body = line.startsWith(prefix) ? line.slice(prefix.length) : line;
|
|
1142
|
+
buffer.lines[previousY] += body;
|
|
1143
|
+
spliceTuiBufferLines(buffer, buffer.cursor.y, 1, []);
|
|
1144
|
+
buffer.cursor.y = previousY;
|
|
1145
|
+
buffer.cursor.x = previousLength;
|
|
1146
|
+
buffer.ensureCursor?.();
|
|
1147
|
+
return true;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
export function mergeTuiTextareaForward(buffer, block) {
|
|
1151
|
+
if (!buffer || block?.header?.tag !== "textarea") return false;
|
|
1152
|
+
const line = String(buffer.lines?.[buffer.cursor.y] ?? "");
|
|
1153
|
+
if (buffer.cursor.x < line.length || buffer.cursor.y >= block.end - 1) return false;
|
|
1154
|
+
const prefix = block.header.indent + block.header.bodyMarker + " ";
|
|
1155
|
+
const nextLine = String(buffer.lines?.[buffer.cursor.y + 1] ?? "");
|
|
1156
|
+
const body = nextLine.startsWith(prefix) ? nextLine.slice(prefix.length) : nextLine;
|
|
1157
|
+
buffer.lines[buffer.cursor.y] += body;
|
|
1158
|
+
spliceTuiBufferLines(buffer, buffer.cursor.y + 1, 1, []);
|
|
1159
|
+
buffer.ensureCursor?.();
|
|
1160
|
+
return true;
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1068
1163
|
function _setBlockValue(buffer, selector, value) {
|
|
1069
1164
|
const lines = buffer.lines;
|
|
1070
1165
|
const block = _findBlock(lines, selector);
|
|
1071
1166
|
if (!block) return false;
|
|
1072
1167
|
|
|
1073
|
-
const
|
|
1168
|
+
const normalizedValue = String(value ?? "").replace(/\r\n?/g, "\n");
|
|
1169
|
+
if ((_blockValue(lines, selector) ?? "") === normalizedValue) return true;
|
|
1170
|
+
buffer.pushUndo?.(true);
|
|
1171
|
+
const values = normalizedValue.split("\n");
|
|
1074
1172
|
const contentStart = block.start + 1;
|
|
1075
1173
|
const capacity = Math.max(0, block.end - contentStart);
|
|
1076
1174
|
|
|
1077
1175
|
if (block.header.kind === "fenced") {
|
|
1078
1176
|
const replacement = values.map((line) => block.header.indent + line);
|
|
1079
|
-
|
|
1177
|
+
spliceTuiBufferLines(buffer, contentStart, capacity, replacement);
|
|
1080
1178
|
return true;
|
|
1081
1179
|
}
|
|
1082
1180
|
|
|
1083
1181
|
const rowPrefix = block.header.indent + block.header.bodyMarker + " ";
|
|
1084
1182
|
const replacement = values.map((line) => rowPrefix + line);
|
|
1085
|
-
|
|
1183
|
+
spliceTuiBufferLines(buffer, contentStart, capacity, replacement);
|
|
1086
1184
|
return true;
|
|
1087
1185
|
}
|
|
1088
1186
|
|
|
@@ -1229,7 +1327,12 @@ export function buildMicroGlobal(jsManager) {
|
|
|
1229
1327
|
// ── Messaging ─────────────────────────────────────────────────
|
|
1230
1328
|
Log: (...args) => console.log(...args),
|
|
1231
1329
|
TermMessage: (msg) => { const app = getApp(); if (app) { app.message = String(msg); if (app._started) app.render?.(); } },
|
|
1232
|
-
alert:
|
|
1330
|
+
alert: (msg) => { const app = getApp(); return app ? app.protectedAlert(msg) : console.log(String(msg)); },
|
|
1331
|
+
confirm: (msg) => { const app = getApp(); return app ? app.protectedConfirm(msg) : false; },
|
|
1332
|
+
prompt: (msg, defaultValue = "") => {
|
|
1333
|
+
const app = getApp();
|
|
1334
|
+
return app ? app.protectedPrompt(msg, defaultValue) : defaultValue;
|
|
1335
|
+
},
|
|
1233
1336
|
|
|
1234
1337
|
// ── Buffer line access (1-based line numbers; omit → cursor line) ─
|
|
1235
1338
|
|
|
@@ -1250,6 +1353,7 @@ export function buildMicroGlobal(jsManager) {
|
|
|
1250
1353
|
const y = lineNumber != null ? Number(lineNumber) - 1 : buf.cursor.y;
|
|
1251
1354
|
if (y < 0 || y >= buf.lines.length) return;
|
|
1252
1355
|
buf.pushUndo?.();
|
|
1356
|
+
buf._mdcuiFenceBlockIndex = null;
|
|
1253
1357
|
const parts = String(text).replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
|
1254
1358
|
buf.lines.splice(y, 1, ...parts);
|
|
1255
1359
|
buf.invalidateHighlightFrom?.(y, { force: parts.length > 1 });
|
|
@@ -1266,6 +1370,7 @@ export function buildMicroGlobal(jsManager) {
|
|
|
1266
1370
|
const y = lineNumber != null ? Number(lineNumber) - 1 : buf.cursor.y;
|
|
1267
1371
|
if (y < 0 || y >= buf.lines.length) return;
|
|
1268
1372
|
buf.pushUndo?.();
|
|
1373
|
+
buf._mdcuiFenceBlockIndex = null;
|
|
1269
1374
|
if (buf.lines.length === 1) {
|
|
1270
1375
|
buf.lines[0] = "";
|
|
1271
1376
|
} else {
|
|
@@ -1306,6 +1411,7 @@ export function buildMicroGlobal(jsManager) {
|
|
|
1306
1411
|
if (!app?.buffer) return;
|
|
1307
1412
|
const buf = app.buffer;
|
|
1308
1413
|
buf.pushUndo?.();
|
|
1414
|
+
buf._mdcuiFenceBlockIndex = null;
|
|
1309
1415
|
buf.lines = String(text).replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
|
1310
1416
|
if (buf.lines.length === 0) buf.lines = [""];
|
|
1311
1417
|
buf.invalidateHighlightFrom?.(0, { force: true });
|
|
@@ -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
|
+
});
|