jsmdcui 0.3.0 → 0.5.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 +137 -0
- package/README.md +56 -2
- package/package.json +1 -1
- package/runmd.mjs +71 -7
- package/runtime/help/help.md +56 -2
- package/runtime/syntax/markdown.yaml +12 -0
- package/src/buffer/backup.js +8 -3
- package/src/config/config.js +3 -0
- package/src/config/defaults.js +2 -2
- package/src/cui/rpc.mjs +137 -2
- package/src/index.js +301 -47
- package/src/plugins/js-bridge.js +168 -0
- package/testapp.md +23 -0
- package/tests/backup.test.js +25 -0
- package/tests/cat-markdown.test.js +79 -0
- package/tests/config-settings.test.js +52 -0
- package/tests/demo.test.js +24 -0
package/src/plugins/js-bridge.js
CHANGED
|
@@ -685,11 +685,178 @@ function _deleteSel(buf, pane) {
|
|
|
685
685
|
buf.ensureCursor?.();
|
|
686
686
|
}
|
|
687
687
|
|
|
688
|
+
// ── mdcui block selector ────────────────────────────────────────────────────
|
|
689
|
+
|
|
690
|
+
function _parseBlockIdentity(input, { selector = false } = {}) {
|
|
691
|
+
const text = String(input ?? "").trim();
|
|
692
|
+
const match = text.match(/^([A-Za-z_][\w:-]*)?(?:#([A-Za-z_][\w:-]*))?((?:\.[A-Za-z_][\w:-]*)*)$/);
|
|
693
|
+
if (!match || (!match[1] && !match[2] && !match[3])) return null;
|
|
694
|
+
if (!selector && !match[1]) return null;
|
|
695
|
+
return {
|
|
696
|
+
tag: match[1] || null,
|
|
697
|
+
id: match[2] || null,
|
|
698
|
+
classes: match[3] ? match[3].slice(1).split(".") : [],
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function _blockHeader(line) {
|
|
703
|
+
const text = String(line ?? "");
|
|
704
|
+
const framed = text.match(/^(\s*)(┌─|╭─|\+-)\s*(\S+)\s*$/);
|
|
705
|
+
if (framed) {
|
|
706
|
+
const identity = _parseBlockIdentity(framed[3]);
|
|
707
|
+
return identity
|
|
708
|
+
? { kind: "framed", indent: framed[1], bodyMarker: framed[2] === "+-" ? "|" : "│", ...identity }
|
|
709
|
+
: null;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
const fenced = text.match(/^(\s*)(`{3,})\s*(\S+)\s*$/);
|
|
713
|
+
if (fenced) {
|
|
714
|
+
const identity = _parseBlockIdentity(fenced[3]);
|
|
715
|
+
return identity
|
|
716
|
+
? { kind: "fenced", indent: fenced[1], fenceLength: fenced[2].length, ...identity }
|
|
717
|
+
: null;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
return null;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function _matchesBlock(header, selector) {
|
|
724
|
+
if (selector.tag && header.tag !== selector.tag) return false;
|
|
725
|
+
if (selector.id && header.id !== selector.id) return false;
|
|
726
|
+
return selector.classes.every((name) => header.classes.includes(name));
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function _findBlock(lines, selector) {
|
|
730
|
+
for (let start = 0; start < lines.length; start++) {
|
|
731
|
+
const header = _blockHeader(lines[start]);
|
|
732
|
+
if (!header || !_matchesBlock(header, selector)) continue;
|
|
733
|
+
|
|
734
|
+
for (let y = start + 1; y < lines.length; y++) {
|
|
735
|
+
const line = String(lines[y] ?? "");
|
|
736
|
+
const rest = line.startsWith(header.indent)
|
|
737
|
+
? line.slice(header.indent.length)
|
|
738
|
+
: line;
|
|
739
|
+
|
|
740
|
+
if (header.kind === "fenced") {
|
|
741
|
+
const closing = rest.match(/^(`{3,})\s*$/);
|
|
742
|
+
if (closing && closing[1].length >= header.fenceLength)
|
|
743
|
+
return { start, end: y, header };
|
|
744
|
+
continue;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
if (/^(?:└─|╰─|\+-)\s*$/.test(rest))
|
|
748
|
+
return { start, end: y, header };
|
|
749
|
+
}
|
|
750
|
+
return { start, end: lines.length, header };
|
|
751
|
+
}
|
|
752
|
+
return null;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function _blockValue(lines, selector) {
|
|
756
|
+
const block = _findBlock(lines, selector);
|
|
757
|
+
if (!block) return undefined;
|
|
758
|
+
const value = [];
|
|
759
|
+
for (let y = block.start + 1; y < block.end; y++) {
|
|
760
|
+
const line = String(lines[y] ?? "");
|
|
761
|
+
const rest = line.startsWith(block.header.indent)
|
|
762
|
+
? line.slice(block.header.indent.length)
|
|
763
|
+
: line;
|
|
764
|
+
if (block.header.kind === "fenced") value.push(rest);
|
|
765
|
+
else {
|
|
766
|
+
const body = rest.match(/^(?:│|\|)(?: ?)(.*)$/);
|
|
767
|
+
value.push(body ? body[1] : rest);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
return value.join("\n");
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function _spliceBufferLines(buffer, start, deleteCount, replacement) {
|
|
774
|
+
const oldCursor = buffer.cursor ? { ...buffer.cursor } : null;
|
|
775
|
+
buffer.lines.splice(start, deleteCount, ...replacement);
|
|
776
|
+
|
|
777
|
+
if (Array.isArray(buffer._ansiStyleLines)) {
|
|
778
|
+
const template = buffer._ansiStyleLines[start] ?? null;
|
|
779
|
+
buffer._ansiStyleLines.splice(
|
|
780
|
+
start,
|
|
781
|
+
deleteCount,
|
|
782
|
+
...replacement.map(() => template),
|
|
783
|
+
);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
if (typeof buffer._mdcuiAnsiText === "string") {
|
|
787
|
+
const ansiLines = buffer._mdcuiAnsiText.split("\n");
|
|
788
|
+
ansiLines.splice(start, deleteCount, ...replacement);
|
|
789
|
+
buffer._mdcuiAnsiText = ansiLines.join("\n");
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
buffer.invalidateHighlightFrom?.(start, { force: replacement.length !== deleteCount });
|
|
793
|
+
if (oldCursor) {
|
|
794
|
+
const oldEnd = start + deleteCount;
|
|
795
|
+
if (oldCursor.y >= oldEnd) {
|
|
796
|
+
buffer.cursor.y = oldCursor.y + replacement.length - deleteCount;
|
|
797
|
+
} else if (oldCursor.y >= start) {
|
|
798
|
+
const relativeY = Math.min(oldCursor.y - start, Math.max(0, replacement.length - 1));
|
|
799
|
+
buffer.cursor.y = start + relativeY;
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
buffer.modified = true;
|
|
803
|
+
buffer.ensureCursor?.();
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function _setBlockValue(buffer, selector, value) {
|
|
807
|
+
const lines = buffer.lines;
|
|
808
|
+
const block = _findBlock(lines, selector);
|
|
809
|
+
if (!block) return false;
|
|
810
|
+
|
|
811
|
+
const values = String(value ?? "").replace(/\r\n?/g, "\n").split("\n");
|
|
812
|
+
const contentStart = block.start + 1;
|
|
813
|
+
const capacity = Math.max(0, block.end - contentStart);
|
|
814
|
+
|
|
815
|
+
if (block.header.kind === "fenced") {
|
|
816
|
+
const replacement = values.map((line) => block.header.indent + line);
|
|
817
|
+
_spliceBufferLines(buffer, contentStart, capacity, replacement);
|
|
818
|
+
return true;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
const rowPrefix = block.header.indent + block.header.bodyMarker + " ";
|
|
822
|
+
const replacement = values.map((line) => rowPrefix + line);
|
|
823
|
+
_spliceBufferLines(buffer, contentStart, capacity, replacement);
|
|
824
|
+
return true;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
export function createTuiSelector(getBuffer) {
|
|
828
|
+
return function $(selector) {
|
|
829
|
+
const parsedSelector = _parseBlockIdentity(selector, { selector: true });
|
|
830
|
+
const selection = {
|
|
831
|
+
val(...args) {
|
|
832
|
+
try {
|
|
833
|
+
if (!parsedSelector) return args.length > 0 ? selection : "";
|
|
834
|
+
const buffer = getBuffer?.();
|
|
835
|
+
if (!buffer) return args.length > 0 ? selection : "";
|
|
836
|
+
const lines = Array.isArray(buffer.lines)
|
|
837
|
+
? buffer.lines
|
|
838
|
+
: String(buffer).replace(/\r\n?/g, "\n").split("\n");
|
|
839
|
+
if (args.length > 0) {
|
|
840
|
+
if (Array.isArray(buffer.lines))
|
|
841
|
+
_setBlockValue(buffer, parsedSelector, args[0]);
|
|
842
|
+
return selection;
|
|
843
|
+
}
|
|
844
|
+
return _blockValue(lines, parsedSelector) ?? "";
|
|
845
|
+
} catch {
|
|
846
|
+
return args.length > 0 ? selection : "";
|
|
847
|
+
}
|
|
848
|
+
},
|
|
849
|
+
};
|
|
850
|
+
return selection;
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
|
|
688
854
|
// ── micro global object ───────────────────────────────────────────────────────
|
|
689
855
|
|
|
690
856
|
export function buildMicroGlobal(jsManager) {
|
|
691
857
|
const getApp = () => jsManager._app;
|
|
692
858
|
const getCtx = () => jsManager._ctx;
|
|
859
|
+
const $ = createTuiSelector(() => getApp()?.buffer);
|
|
693
860
|
|
|
694
861
|
// Converts cmd args to a safe command string for handleCommand
|
|
695
862
|
function buildCmdString(name, args) {
|
|
@@ -921,6 +1088,7 @@ export function buildMicroGlobal(jsManager) {
|
|
|
921
1088
|
};
|
|
922
1089
|
|
|
923
1090
|
globalThis.micro = micro;
|
|
1091
|
+
globalThis.$ = $;
|
|
924
1092
|
return micro;
|
|
925
1093
|
}
|
|
926
1094
|
|
package/testapp.md
CHANGED
|
@@ -9,6 +9,20 @@
|
|
|
9
9
|
- [Print process.argv](javascript:pav())
|
|
10
10
|
- [Calculator🧮計算機](javascript:calc())
|
|
11
11
|
* Use cos sin PI directly
|
|
12
|
+
- [Show ↓ text](javascript:alert($('text').val()))
|
|
13
|
+
|
|
14
|
+
```text
|
|
15
|
+
Text edit. TUI: Click ↙ to inc lines ↖ to dec
|
|
16
|
+
可編輯文字框 TUI:按↙擴充行數 ↖減少行數
|
|
17
|
+
```
|
|
18
|
+
## Question 問題
|
|
19
|
+
- What is 1+2+3+4+..+..+∞
|
|
20
|
+
|
|
21
|
+
```text#ans
|
|
22
|
+
-1/12
|
|
23
|
+
```
|
|
24
|
+
- [Submit 提交](javascript:checkAns())
|
|
25
|
+
|
|
12
26
|
## Task list
|
|
13
27
|
- [X] task1
|
|
14
28
|
- [ ] task2
|
|
@@ -43,6 +57,15 @@ export async function calc()
|
|
|
43
57
|
|
|
44
58
|
);
|
|
45
59
|
}
|
|
60
|
+
|
|
61
|
+
export function checkAns()
|
|
62
|
+
{
|
|
63
|
+
if($('#ans').val().trim()=='-1/12')
|
|
64
|
+
$('#ans').val('答對🥳Right!');
|
|
65
|
+
else
|
|
66
|
+
$('#ans').val('答錯😫Wrong!');
|
|
67
|
+
}
|
|
68
|
+
|
|
46
69
|
```
|
|
47
70
|
|
|
48
71
|
|
package/tests/backup.test.js
CHANGED
|
@@ -130,4 +130,29 @@ describe("backup lifecycle", () => {
|
|
|
130
130
|
expect(await writeBackup(buf, root, buf.AbsPath, { force: true })).toBe(true);
|
|
131
131
|
expect(existsSync(determineBackupPath(backupDir, buf.AbsPath).name)).toBe(true);
|
|
132
132
|
});
|
|
133
|
+
|
|
134
|
+
test("mdcui buffers never write, recover, or remove backups", async () => {
|
|
135
|
+
const root = await tempDir();
|
|
136
|
+
const backupDir = join(root, "backups");
|
|
137
|
+
await mkdir(backupDir);
|
|
138
|
+
const buf = buffer("/tmp/file.md", "rendered markdown");
|
|
139
|
+
buf.Settings.backupdir = backupDir;
|
|
140
|
+
buf.encoding = "mdcui";
|
|
141
|
+
buf.Settings.encoding = "mdcui";
|
|
142
|
+
const target = determineBackupPath(backupDir, buf.AbsPath);
|
|
143
|
+
|
|
144
|
+
expect(await writeBackup(buf, root)).toBe(false);
|
|
145
|
+
expect(await writeBackup(buf, root, buf.AbsPath, { force: true })).toBe(false);
|
|
146
|
+
expect(existsSync(target.name)).toBe(false);
|
|
147
|
+
|
|
148
|
+
await writeFile(target.name, "utf8 editor recovery");
|
|
149
|
+
let prompted = false;
|
|
150
|
+
expect(await applyBackup(buf, root, async () => { prompted = true; return "recover"; }))
|
|
151
|
+
.toEqual({ recovered: false, abort: false });
|
|
152
|
+
expect(prompted).toBe(false);
|
|
153
|
+
expect(buf.lines).toEqual(["rendered markdown"]);
|
|
154
|
+
|
|
155
|
+
removeBackup(buf, root);
|
|
156
|
+
expect(existsSync(target.name)).toBe(true);
|
|
157
|
+
});
|
|
133
158
|
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
const tui = join(import.meta.dir, "..", "tui");
|
|
8
|
+
|
|
9
|
+
test("cat renders .md files once with the implicit mdcui encoding", async () => {
|
|
10
|
+
const dir = await mkdtemp(join(tmpdir(), "jsmdcui-cat-"));
|
|
11
|
+
const markdownPath = join(dir, "sample.md");
|
|
12
|
+
await writeFile(markdownPath, "# Heading\n\n- one\n- two\n");
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
const implicit = Bun.spawnSync([tui, "-cat", markdownPath], {
|
|
16
|
+
cwd: dir,
|
|
17
|
+
stdout: "pipe",
|
|
18
|
+
stderr: "pipe",
|
|
19
|
+
});
|
|
20
|
+
const explicit = Bun.spawnSync([tui, "-cat", "-encoding", "mdcui", markdownPath], {
|
|
21
|
+
cwd: dir,
|
|
22
|
+
stdout: "pipe",
|
|
23
|
+
stderr: "pipe",
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
expect(implicit.exitCode).toBe(0);
|
|
27
|
+
expect(explicit.exitCode).toBe(0);
|
|
28
|
+
expect(implicit.stdout.toString()).toBe(explicit.stdout.toString());
|
|
29
|
+
} finally {
|
|
30
|
+
await rm(dir, { recursive: true, force: true });
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("an explicit utf8 encoding overrides the .md mdcui default", async () => {
|
|
35
|
+
const dir = await mkdtemp(join(tmpdir(), "jsmdcui-cat-utf8-"));
|
|
36
|
+
const markdownPath = join(dir, "sample.md");
|
|
37
|
+
await writeFile(markdownPath, "# Heading\n\n```js front\nalert('kept')\n```\n");
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const result = Bun.spawnSync([tui, "-cat", "-encoding", "utf8", markdownPath], {
|
|
41
|
+
cwd: dir,
|
|
42
|
+
stdout: "pipe",
|
|
43
|
+
stderr: "pipe",
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
expect(result.exitCode).toBe(0);
|
|
47
|
+
expect(Bun.stripANSI(result.stdout.toString())).toContain("alert('kept')");
|
|
48
|
+
expect(existsSync(markdownPath + ".front.js")).toBe(false);
|
|
49
|
+
expect(existsSync(markdownPath + ".html")).toBe(false);
|
|
50
|
+
} finally {
|
|
51
|
+
await rm(dir, { recursive: true, force: true });
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("--edit overrides the .md mdcui default with utf8", async () => {
|
|
56
|
+
const dir = await mkdtemp(join(tmpdir(), "jsmdcui-cat-edit-"));
|
|
57
|
+
const markdownPath = join(dir, "sample.md");
|
|
58
|
+
await writeFile(markdownPath, "# Heading\n\n```js front\nalert('editable')\n```\n");
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const edit = Bun.spawnSync([tui, "-cat", "--edit", markdownPath], {
|
|
62
|
+
cwd: dir,
|
|
63
|
+
stdout: "pipe",
|
|
64
|
+
stderr: "pipe",
|
|
65
|
+
});
|
|
66
|
+
const utf8 = Bun.spawnSync([tui, "-cat", "-encoding", "utf8", markdownPath], {
|
|
67
|
+
cwd: dir,
|
|
68
|
+
stdout: "pipe",
|
|
69
|
+
stderr: "pipe",
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
expect(edit.exitCode).toBe(0);
|
|
73
|
+
expect(edit.stdout.toString()).toBe(utf8.stdout.toString());
|
|
74
|
+
expect(Bun.stripANSI(edit.stdout.toString())).toContain("alert('editable')");
|
|
75
|
+
expect(existsSync(markdownPath + ".front.js")).toBe(false);
|
|
76
|
+
} finally {
|
|
77
|
+
await rm(dir, { recursive: true, force: true });
|
|
78
|
+
}
|
|
79
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { afterEach, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { Config } from "../src/config/config.js";
|
|
6
|
+
|
|
7
|
+
const tempDirs = [];
|
|
8
|
+
|
|
9
|
+
afterEach(async () => {
|
|
10
|
+
await Promise.all(tempDirs.splice(0).map((path) => rm(path, { recursive: true, force: true })));
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
async function loadConfig(settings) {
|
|
14
|
+
const configDir = await mkdtemp(join(tmpdir(), "jsmdcui-config-"));
|
|
15
|
+
tempDirs.push(configDir);
|
|
16
|
+
await writeFile(join(configDir, "settings.json"), JSON.stringify(settings), "utf8");
|
|
17
|
+
return await new Config({ configDir }).init();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
test("Config.init discards top-level encoding from settings.json in memory", async () => {
|
|
21
|
+
const config = await loadConfig({
|
|
22
|
+
encoding: "big5",
|
|
23
|
+
ruler: false,
|
|
24
|
+
"ft:markdown": { encoding: "big5", tabsize: 2 },
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
expect(config.getGlobalOption("encoding")).toBe("utf-8");
|
|
28
|
+
expect(config.parsedSettings).not.toHaveProperty("encoding");
|
|
29
|
+
expect(config.parsedSettings["ft:markdown"].encoding).toBe("big5");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("Config.saveSettings removes the top-level encoding discarded during init", async () => {
|
|
33
|
+
const config = await loadConfig({
|
|
34
|
+
encoding: "big5",
|
|
35
|
+
ruler: false,
|
|
36
|
+
"ft:markdown": { encoding: "big5", tabsize: 2 },
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
await config.saveSettings();
|
|
40
|
+
const saved = JSON.parse(await readFile(join(config.configDir, "settings.json"), "utf8"));
|
|
41
|
+
expect(saved).toEqual({ ruler: false, "ft:markdown": { encoding: "big5", tabsize: 2 } });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("CLI encoding applies to the current invocation but saveSettings never persists it", async () => {
|
|
45
|
+
const config = await loadConfig({ ruler: false });
|
|
46
|
+
config.applyCliSettings(new Map([["encoding", "big5"]]));
|
|
47
|
+
|
|
48
|
+
expect(config.getGlobalOption("encoding")).toBe("big5");
|
|
49
|
+
await config.saveSettings();
|
|
50
|
+
const saved = JSON.parse(await readFile(join(config.configDir, "settings.json"), "utf8"));
|
|
51
|
+
expect(saved).toEqual({ ruler: false });
|
|
52
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
const tui = join(import.meta.dir, "..", "tui");
|
|
7
|
+
|
|
8
|
+
test("--demo writes bundled testapp.md to cwd before opening it", async () => {
|
|
9
|
+
const dir = await mkdtemp(join(tmpdir(), "jsmdcui-demo-"));
|
|
10
|
+
try {
|
|
11
|
+
const result = Bun.spawnSync([tui, "--demo", "-cat", "-encoding", "utf8"], {
|
|
12
|
+
cwd: dir,
|
|
13
|
+
stdout: "pipe",
|
|
14
|
+
stderr: "pipe",
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
expect(result.exitCode).toBe(0);
|
|
18
|
+
const written = await readFile(join(dir, "testapp.md"), "utf8");
|
|
19
|
+
expect(written).toContain("# jsmdcui");
|
|
20
|
+
expect(Bun.stripANSI(result.stdout.toString())).toContain("jsmdcui");
|
|
21
|
+
} finally {
|
|
22
|
+
await rm(dir, { recursive: true, force: true });
|
|
23
|
+
}
|
|
24
|
+
});
|