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.
@@ -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
 
@@ -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
+ });