jsmdcui 0.4.0 → 0.6.3

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,293 @@ 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 _headingSelectorId(input) {
774
+ const match = String(input ?? "").trim().match(/^#([^\s]+)$/);
775
+ return match?.[1] ?? null;
776
+ }
777
+
778
+ function _findHeading(buffer, selector) {
779
+ const id = _headingSelectorId(selector);
780
+ const markdown = buffer?._mdcuiTuiSourceText;
781
+ if (!id || markdown == null || typeof Bun?.markdown?.html !== "function") return undefined;
782
+
783
+ const html = String(Bun.markdown.html(markdown, { headings: { ids: true } }));
784
+ const headings = /<h([1-6])\b([^>]*)>([^]*?)<\/h\1\s*>/gi;
785
+ let match;
786
+ let ordinal = 0;
787
+ while ((match = headings.exec(html)) !== null) {
788
+ const headingId = match[2].match(/\bid="([^"]*)"/i)?.[1];
789
+ if (headingId === id) {
790
+ return {
791
+ html: match[3],
792
+ level: Number(match[1]),
793
+ ordinal,
794
+ };
795
+ }
796
+ ordinal++;
797
+ }
798
+ return undefined;
799
+ }
800
+
801
+ let _headingAnsiPrefixes;
802
+ function _tuiHeadingAnsiPrefixes() {
803
+ if (_headingAnsiPrefixes) return _headingAnsiPrefixes;
804
+ const marker = "MDCUI_HEADING_LINE_PROBE";
805
+ _headingAnsiPrefixes = new Map();
806
+ for (let level = 1; level <= 6; level++) {
807
+ const rendered = String(Bun.markdown.ansi(
808
+ `${"#".repeat(level)} ${marker}`,
809
+ { hyperlinks: true, columns: 80 },
810
+ ));
811
+ const line = rendered.split("\n").find((item) => item.includes(marker));
812
+ if (line) _headingAnsiPrefixes.set(level, line.slice(0, line.indexOf(marker)));
813
+ }
814
+ return _headingAnsiPrefixes;
815
+ }
816
+
817
+ function _headingTuiLine(buffer, heading) {
818
+ const ansiText = buffer?._mdcuiAnsiText;
819
+ if (!heading || typeof ansiText !== "string") return 0;
820
+ const tuiHeadings = _tuiHeadingLines(buffer);
821
+ const exact = tuiHeadings[heading.ordinal];
822
+ if (exact?.level === heading.level) return exact.line;
823
+ return 0;
824
+ }
825
+
826
+ function _tuiHeadingLines(buffer) {
827
+ const ansiText = buffer?._mdcuiAnsiText;
828
+ if (typeof ansiText !== "string") return [];
829
+ const prefixes = _tuiHeadingAnsiPrefixes();
830
+ const tuiHeadings = [];
831
+ for (const [lineIndex, line] of ansiText.split("\n").entries()) {
832
+ for (const [level, prefix] of prefixes) {
833
+ if (line.startsWith(prefix)) {
834
+ tuiHeadings.push({ level, line: lineIndex + 1 });
835
+ break;
836
+ }
837
+ }
838
+ }
839
+ return tuiHeadings;
840
+ }
841
+
842
+ function _headingCheckboxValue(buffer, heading, id) {
843
+ const headingLine = _headingTuiLine(buffer, heading);
844
+ if (!headingLine || !Array.isArray(buffer?.lines))
845
+ return id.startsWith("select") ? null : [];
846
+
847
+ const tuiHeadings = _tuiHeadingLines(buffer);
848
+ const nextHeading = tuiHeadings[heading.ordinal + 1];
849
+ const endLine = nextHeading?.line ?? buffer.lines.length + 1;
850
+
851
+ let optionIndent = null;
852
+ const selected = [];
853
+ for (let lineNumber = headingLine + 1; lineNumber < endLine; lineNumber++) {
854
+ const line = String(buffer.lines[lineNumber - 1] ?? "");
855
+ const checkbox = line.match(/^(\s*)([☐☒])(?:\s+|$)(.*)$/);
856
+ if (!checkbox) continue;
857
+ const indent = checkbox[1].length;
858
+ if (optionIndent == null) optionIndent = indent;
859
+ if (indent !== optionIndent || checkbox[2] !== "☒") continue;
860
+ const value = checkbox[3].trim();
861
+ if (id.startsWith("select")) return value;
862
+ selected.push(value);
863
+ }
864
+ return id.startsWith("select") ? null : selected;
865
+ }
866
+
867
+ function _spliceBufferLines(buffer, start, deleteCount, replacement) {
868
+ const oldCursor = buffer.cursor ? { ...buffer.cursor } : null;
869
+ buffer.lines.splice(start, deleteCount, ...replacement);
870
+
871
+ if (Array.isArray(buffer._ansiStyleLines)) {
872
+ const template = buffer._ansiStyleLines[start] ?? null;
873
+ buffer._ansiStyleLines.splice(
874
+ start,
875
+ deleteCount,
876
+ ...replacement.map(() => template),
877
+ );
878
+ }
879
+
880
+ if (typeof buffer._mdcuiAnsiText === "string") {
881
+ const ansiLines = buffer._mdcuiAnsiText.split("\n");
882
+ ansiLines.splice(start, deleteCount, ...replacement);
883
+ buffer._mdcuiAnsiText = ansiLines.join("\n");
884
+ }
885
+
886
+ buffer.invalidateHighlightFrom?.(start, { force: replacement.length !== deleteCount });
887
+ if (oldCursor) {
888
+ const oldEnd = start + deleteCount;
889
+ if (oldCursor.y >= oldEnd) {
890
+ buffer.cursor.y = oldCursor.y + replacement.length - deleteCount;
891
+ } else if (oldCursor.y >= start) {
892
+ const relativeY = Math.min(oldCursor.y - start, Math.max(0, replacement.length - 1));
893
+ buffer.cursor.y = start + relativeY;
894
+ }
895
+ }
896
+ buffer.modified = true;
897
+ buffer.ensureCursor?.();
898
+ }
899
+
900
+ function _setBlockValue(buffer, selector, value) {
901
+ const lines = buffer.lines;
902
+ const block = _findBlock(lines, selector);
903
+ if (!block) return false;
904
+
905
+ const values = String(value ?? "").replace(/\r\n?/g, "\n").split("\n");
906
+ const contentStart = block.start + 1;
907
+ const capacity = Math.max(0, block.end - contentStart);
908
+
909
+ if (block.header.kind === "fenced") {
910
+ const replacement = values.map((line) => block.header.indent + line);
911
+ _spliceBufferLines(buffer, contentStart, capacity, replacement);
912
+ return true;
913
+ }
914
+
915
+ const rowPrefix = block.header.indent + block.header.bodyMarker + " ";
916
+ const replacement = values.map((line) => rowPrefix + line);
917
+ _spliceBufferLines(buffer, contentStart, capacity, replacement);
918
+ return true;
919
+ }
920
+
921
+ export function createTuiSelector(getBuffer) {
922
+ return function $(selector) {
923
+ const parsedSelector = _parseBlockIdentity(selector, { selector: true });
924
+ const selection = {
925
+ html() {
926
+ try {
927
+ return _findHeading(getBuffer?.(), selector)?.html ?? "";
928
+ } catch {
929
+ return "";
930
+ }
931
+ },
932
+ line() {
933
+ try {
934
+ const buffer = getBuffer?.();
935
+ return _headingTuiLine(buffer, _findHeading(buffer, selector));
936
+ } catch {
937
+ return 0;
938
+ }
939
+ },
940
+ val(...args) {
941
+ try {
942
+ const buffer = getBuffer?.();
943
+ if (!buffer) return args.length > 0 ? selection : "";
944
+ const heading = _findHeading(buffer, selector);
945
+ const headingId = _headingSelectorId(selector);
946
+ if (heading && headingId) {
947
+ if (args.length > 0) return selection;
948
+ return _headingCheckboxValue(buffer, heading, headingId);
949
+ }
950
+ if (!parsedSelector) return args.length > 0 ? selection : "";
951
+ const lines = Array.isArray(buffer.lines)
952
+ ? buffer.lines
953
+ : String(buffer).replace(/\r\n?/g, "\n").split("\n");
954
+ if (args.length > 0) {
955
+ if (Array.isArray(buffer.lines))
956
+ _setBlockValue(buffer, parsedSelector, args[0]);
957
+ return selection;
958
+ }
959
+ return _blockValue(lines, parsedSelector) ?? "";
960
+ } catch {
961
+ return args.length > 0 ? selection : "";
962
+ }
963
+ },
964
+ };
965
+ return selection;
966
+ };
967
+ }
968
+
688
969
  // ── micro global object ───────────────────────────────────────────────────────
689
970
 
690
971
  export function buildMicroGlobal(jsManager) {
691
972
  const getApp = () => jsManager._app;
692
973
  const getCtx = () => jsManager._ctx;
974
+ const $ = createTuiSelector(() => getApp()?.buffer);
693
975
 
694
976
  // Converts cmd args to a safe command string for handleCommand
695
977
  function buildCmdString(name, args) {
@@ -921,6 +1203,7 @@ export function buildMicroGlobal(jsManager) {
921
1203
  };
922
1204
 
923
1205
  globalThis.micro = micro;
1206
+ globalThis.$ = $;
924
1207
  return micro;
925
1208
  }
926
1209
 
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
 
@@ -1,10 +1,24 @@
1
1
  import { expect, test } from "bun:test";
2
- import { mkdtemp, readFile, rm } from "node:fs/promises";
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
5
 
6
6
  const tui = join(import.meta.dir, "..", "tui");
7
7
 
8
+ test("--help describes the non-overwriting demo behavior", () => {
9
+ const result = Bun.spawnSync([tui, "--help"], {
10
+ stdout: "pipe",
11
+ stderr: "pipe",
12
+ });
13
+
14
+ expect(result.exitCode).toBe(0);
15
+ const output = result.stdout.toString();
16
+ expect(output).toContain("use the existing ./testapp.md without overwriting it");
17
+ expect(output).toContain("If ./testapp.md is missing, write the bundled demo there first");
18
+ expect(output).toContain("--demo-imgtool");
19
+ expect(output).toContain("--demo-imgtool-zh");
20
+ });
21
+
8
22
  test("--demo writes bundled testapp.md to cwd before opening it", async () => {
9
23
  const dir = await mkdtemp(join(tmpdir(), "jsmdcui-demo-"));
10
24
  try {
@@ -22,3 +36,98 @@ test("--demo writes bundled testapp.md to cwd before opening it", async () => {
22
36
  await rm(dir, { recursive: true, force: true });
23
37
  }
24
38
  });
39
+
40
+ test("--demo preserves an existing testapp.md", async () => {
41
+ const dir = await mkdtemp(join(tmpdir(), "jsmdcui-demo-existing-"));
42
+ const existing = "# Keep my demo\n";
43
+ try {
44
+ await writeFile(join(dir, "testapp.md"), existing);
45
+ const result = Bun.spawnSync([tui, "--demo", "-cat", "-encoding", "utf8"], {
46
+ cwd: dir,
47
+ stdout: "pipe",
48
+ stderr: "pipe",
49
+ });
50
+
51
+ expect(result.exitCode).toBe(0);
52
+ expect(await readFile(join(dir, "testapp.md"), "utf8")).toBe(existing);
53
+ expect(Bun.stripANSI(result.stdout.toString())).toContain("Keep my demo");
54
+ } finally {
55
+ await rm(dir, { recursive: true, force: true });
56
+ }
57
+ });
58
+
59
+ test("--demo-imgtool writes the bundled image processor to cwd", async () => {
60
+ const dir = await mkdtemp(join(tmpdir(), "jsmdcui-imgtool-"));
61
+ try {
62
+ const result = Bun.spawnSync([tui, "--demo-imgtool", "-cat", "-encoding", "utf8"], {
63
+ cwd: dir,
64
+ stdout: "pipe",
65
+ stderr: "pipe",
66
+ });
67
+
68
+ expect(result.exitCode).toBe(0);
69
+ const written = await readFile(join(dir, "image-processor.md"), "utf8");
70
+ expect(written).toContain("# Bun.Image Processor");
71
+ expect(written).toContain("javascript:readMetadata()");
72
+ expect(Bun.stripANSI(result.stdout.toString())).toContain("Bun.Image Processor");
73
+ } finally {
74
+ await rm(dir, { recursive: true, force: true });
75
+ }
76
+ });
77
+
78
+ test("--demo-imgtool preserves an existing image-processor.md", async () => {
79
+ const dir = await mkdtemp(join(tmpdir(), "jsmdcui-imgtool-existing-"));
80
+ const existing = "# Keep my image tool\n";
81
+ try {
82
+ await writeFile(join(dir, "image-processor.md"), existing);
83
+ const result = Bun.spawnSync([tui, "--demo-imgtool", "-cat", "-encoding", "utf8"], {
84
+ cwd: dir,
85
+ stdout: "pipe",
86
+ stderr: "pipe",
87
+ });
88
+
89
+ expect(result.exitCode).toBe(0);
90
+ expect(await readFile(join(dir, "image-processor.md"), "utf8")).toBe(existing);
91
+ expect(Bun.stripANSI(result.stdout.toString())).toContain("Keep my image tool");
92
+ } finally {
93
+ await rm(dir, { recursive: true, force: true });
94
+ }
95
+ });
96
+
97
+ test("--demo-imgtool-zh writes the bundled Traditional Chinese image processor to cwd", async () => {
98
+ const dir = await mkdtemp(join(tmpdir(), "jsmdcui-imgtool-zh-"));
99
+ try {
100
+ const result = Bun.spawnSync([tui, "--demo-imgtool-zh", "-cat", "-encoding", "utf8"], {
101
+ cwd: dir,
102
+ stdout: "pipe",
103
+ stderr: "pipe",
104
+ });
105
+
106
+ expect(result.exitCode).toBe(0);
107
+ const written = await readFile(join(dir, "image-processor.zh-TW.md"), "utf8");
108
+ expect(written).toContain("先把本機圖片路徑貼到下方");
109
+ expect(written).toContain("javascript:readMetadata()");
110
+ expect(Bun.stripANSI(result.stdout.toString())).toContain("先把本機圖片路徑貼到下方");
111
+ } finally {
112
+ await rm(dir, { recursive: true, force: true });
113
+ }
114
+ });
115
+
116
+ test("--demo-imgtool-zh preserves an existing image-processor.zh-TW.md", async () => {
117
+ const dir = await mkdtemp(join(tmpdir(), "jsmdcui-imgtool-zh-existing-"));
118
+ const existing = "# 保留我的圖片工具\n";
119
+ try {
120
+ await writeFile(join(dir, "image-processor.zh-TW.md"), existing);
121
+ const result = Bun.spawnSync([tui, "--demo-imgtool-zh", "-cat", "-encoding", "utf8"], {
122
+ cwd: dir,
123
+ stdout: "pipe",
124
+ stderr: "pipe",
125
+ });
126
+
127
+ expect(result.exitCode).toBe(0);
128
+ expect(await readFile(join(dir, "image-processor.zh-TW.md"), "utf8")).toBe(existing);
129
+ expect(Bun.stripANSI(result.stdout.toString())).toContain("保留我的圖片工具");
130
+ } finally {
131
+ await rm(dir, { recursive: true, force: true });
132
+ }
133
+ });
@@ -0,0 +1,146 @@
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 { createTuiSelector } from "../src/plugins/js-bridge.js";
6
+
7
+ const tui = join(import.meta.dir, "..", "tui");
8
+
9
+ async function runCheck(markdown) {
10
+ const dir = await mkdtemp(join(tmpdir(), "jsmdcui-check-"));
11
+ const file = join(dir, "app.md");
12
+ await writeFile(file, markdown);
13
+ const result = Bun.spawnSync([tui, "--check", file], { stdout: "pipe", stderr: "pipe" });
14
+ await rm(dir, { recursive: true, force: true });
15
+ return result;
16
+ }
17
+
18
+ test("--check exits 0 and reports unique IDs", async () => {
19
+ const result = await runCheck("## Input Path\n\n```text#output-path\nvalue\n```\n");
20
+ expect(result.exitCode).toBe(0);
21
+ const raw = result.stdout.toString();
22
+ const output = Bun.stripANSI(raw);
23
+ expect(output).toContain("No ID collisions found");
24
+ expect(output).toContain("PASSED");
25
+ expect(raw).toContain(`${Bun.color("#00d75f", "ansi-16m")}\x1b[1mPASSED\x1b[0m`);
26
+ });
27
+
28
+ test("--check reports heading/fenced-block collisions with line details", async () => {
29
+ const result = await runCheck("## Write Status\n\n```text#write-status\nwaiting\n```\n");
30
+ const output = Bun.stripANSI(result.stdout.toString());
31
+ expect(result.exitCode).toBe(1);
32
+ expect(output).toContain("FAIL — Found 1 colliding ID(s)");
33
+ expect(output).toContain("ID #write-status");
34
+ expect(output).toContain("Declarations: 2");
35
+ expect(output).toContain("Line 1");
36
+ expect(output).toContain("Type: heading");
37
+ expect(output).toContain("Source: ## Write Status");
38
+ expect(output).toContain("Line 3");
39
+ expect(output).toContain("Type: text fenced block");
40
+ expect(output).toContain("Source: ```text#write-status");
41
+ expect(result.stdout.toString()).toContain(`${Bun.color("#ff3030", "ansi-16m")}\x1b[1mFAILED\x1b[0m`);
42
+ expect(output.lastIndexOf("FAILED")).toBeGreaterThan(output.lastIndexOf("Suggested fix"));
43
+ });
44
+
45
+ test("--check reports duplicate fenced-block IDs", async () => {
46
+ const result = await runCheck("```text#myid\na\n```\n\n```textarea#myid\nb\n```\n");
47
+ expect(result.exitCode).toBe(1);
48
+ const output = Bun.stripANSI(result.stdout.toString());
49
+ expect(output).toContain("ID #myid");
50
+ expect(output).toContain("Declarations: 2");
51
+ expect(output).toContain("FAILED");
52
+ });
53
+
54
+ test("--check includes IDs on arbitrary fenced-block tags", async () => {
55
+ const result = await runCheck("```hello#myid\nyou\n```\n\n# myid\n");
56
+ const output = Bun.stripANSI(result.stdout.toString());
57
+ expect(result.exitCode).toBe(1);
58
+ expect(output).toContain("ID #myid");
59
+ expect(output).toContain("Declarations: 2");
60
+ expect(output).toContain("Type: hello fenced block");
61
+ expect(output).toContain("Fenced blocks: 1");
62
+ expect(output).toContain("FAILED");
63
+ });
64
+
65
+ test("--check rejects duplicate Markdown heading IDs before Bun adds suffixes", async () => {
66
+ const result = await runCheck("# myid\n\n# myid\n");
67
+ const output = Bun.stripANSI(result.stdout.toString());
68
+ expect(result.exitCode).toBe(1);
69
+ expect(output).toContain("ID #myid");
70
+ expect(output).toContain("Declarations: 2");
71
+ expect(output).toContain("Line 1");
72
+ expect(output).toContain("Line 3");
73
+ expect(output).toContain("FAILED");
74
+ });
75
+
76
+ const identityMatrix = [
77
+ {
78
+ name: "tag without class",
79
+ info: "text#myid",
80
+ selectable: true,
81
+ },
82
+ {
83
+ name: "tag with class",
84
+ info: "text#myid.field",
85
+ selectable: true,
86
+ },
87
+ {
88
+ name: "no tag and no class",
89
+ info: "#myid",
90
+ selectable: false,
91
+ },
92
+ {
93
+ name: "no tag with class",
94
+ info: "#myid.field",
95
+ selectable: false,
96
+ },
97
+ ];
98
+
99
+ for (const scenario of identityMatrix) {
100
+ test(`--check identity matrix: ${scenario.name}`, async () => {
101
+ const result = await runCheck(`\`\`\`${scenario.info}\nvalue\n\`\`\`\n\n# myid\n`);
102
+ const output = Bun.stripANSI(result.stdout.toString());
103
+ expect(result.exitCode).toBe(scenario.selectable ? 1 : 0);
104
+ expect(output).toContain(`Fenced blocks: ${scenario.selectable ? 1 : 0}`);
105
+ if (scenario.selectable) {
106
+ expect(output).toContain("ID #myid");
107
+ expect(output).toContain("Declarations: 2");
108
+ expect(output).toContain("FAILED");
109
+ } else {
110
+ expect(output).toContain("Selectable IDs: 1");
111
+ expect(output).toContain("PASSED");
112
+ }
113
+ });
114
+ }
115
+
116
+ test("--check ignores tag and class differences when fenced-block IDs collide", async () => {
117
+ const result = await runCheck(
118
+ "```text#myid.left.primary\na\n```\n\n```json#myid.right.secondary\nb\n```\n",
119
+ );
120
+ const output = Bun.stripANSI(result.stdout.toString());
121
+ expect(result.exitCode).toBe(1);
122
+ expect(output).toContain("ID #myid");
123
+ expect(output).toContain("Declarations: 2");
124
+ expect(output).toContain("Type: text fenced block");
125
+ expect(output).toContain("Type: json fenced block");
126
+ expect(output).toContain("FAILED");
127
+ });
128
+
129
+ test("TUI $ selector finds one ID across every tag/class query combination", () => {
130
+ const markdown = "```text#myid.field.primary\nvalue\n```\n";
131
+ const buffer = { lines: markdown.trimEnd().split("\n") };
132
+ const $ = createTuiSelector(() => buffer);
133
+ const selectors = [
134
+ "text#myid.field",
135
+ "text#myid",
136
+ "#myid.field",
137
+ "#myid",
138
+ ];
139
+ for (const selector of selectors) expect($(selector).val()).toBe("value");
140
+ });
141
+
142
+ test("--check requires exactly one file", () => {
143
+ const result = Bun.spawnSync([tui, "--check"], { stdout: "pipe", stderr: "pipe" });
144
+ expect(result.exitCode).toBe(2);
145
+ expect(result.stderr.toString()).toContain("Usage: jsmdcui --check FILE.md");
146
+ });
@@ -0,0 +1,30 @@
1
+ import { 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 { readMarkdownInput } from "../runmd.mjs";
6
+
7
+ test("WUI writes the bundled testapp.md when it is missing", async () => {
8
+ const dir = await mkdtemp(join(tmpdir(), "jsmdcui-wui-"));
9
+ const mdpath = join(dir, "testapp.md");
10
+ try {
11
+ const source = await readMarkdownInput(mdpath);
12
+ expect(source).toContain("# jsmdcui");
13
+ expect(await readFile(mdpath, "utf8")).toBe(source);
14
+ } finally {
15
+ await rm(dir, { recursive: true, force: true });
16
+ }
17
+ });
18
+
19
+ test("WUI preserves an existing testapp.md", async () => {
20
+ const dir = await mkdtemp(join(tmpdir(), "jsmdcui-wui-existing-"));
21
+ const mdpath = join(dir, "testapp.md");
22
+ const existing = "# Keep my WUI demo\n";
23
+ try {
24
+ await writeFile(mdpath, existing);
25
+ expect(await readMarkdownInput(mdpath)).toBe(existing);
26
+ expect(await readFile(mdpath, "utf8")).toBe(existing);
27
+ } finally {
28
+ await rm(dir, { recursive: true, force: true });
29
+ }
30
+ });