jsmdcui 0.5.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.
package/src/index.js CHANGED
@@ -98,6 +98,8 @@ import { mkdir } from "node:fs/promises";
98
98
  import { dirname, basename, join, resolve, sep } from "node:path";
99
99
  import { fileURLToPath, pathToFileURL } from "node:url";
100
100
  import process from "node:process";
101
+ import { toggleTaskCheckboxBeforeColumn } from "./cui/task-checkbox.mjs";
102
+ import { checkMarkdownIdCollisions, formatMarkdownIdCheckAnsi } from "./cui/id-collision.mjs";
101
103
  import { Config } from "./config/config.js";
102
104
  import { defaultAllSettings, OPTION_CHOICES, LOCAL_SETTINGS } from "./config/defaults.js";
103
105
  import { cleanConfig } from "./config/clean.js";
@@ -947,11 +949,16 @@ function parseArgs(argv) {
947
949
  options: false,
948
950
  help: false,
949
951
  clean: false,
952
+ check: false,
950
953
  cat: false,
951
954
  docs: false,
955
+ exportReadme: false,
952
956
  changelog: false,
953
957
  testapp: false,
954
958
  demo: false,
959
+ demoSelect: false,
960
+ demoImgtool: false,
961
+ demoImgtoolZh: false,
955
962
  allowUrl: false,
956
963
  buildExe: false,
957
964
  buildFor: "",
@@ -971,6 +978,7 @@ function parseArgs(argv) {
971
978
  else if (arg === "-options") flags.options = true;
972
979
  else if (arg === "-help" || arg === "--help" || arg === "-h") flags.help = true;
973
980
  else if (arg === "-clean") flags.clean = true;
981
+ else if (arg === "--check") flags.check = true;
974
982
  else if (arg === "--cat" || arg === "-cat" || arg === "--ccat" || arg === "-ccat" || arg === "--bat" || arg === "-bat" || arg === "--glow" || arg === "-glow") flags.cat = true;
975
983
  else if (arg === "--xxd" || arg === "--hexdump") {
976
984
  flags.cat = true;
@@ -989,9 +997,13 @@ function parseArgs(argv) {
989
997
  flags.settings.set("encoding", "utf-8");
990
998
  }
991
999
  else if (arg === "--docs" || arg === "--readme") flags.docs = true;
1000
+ else if (arg === "--export-readme") flags.exportReadme = true;
992
1001
  else if (arg === "--changelog") flags.changelog = true;
993
1002
  else if (arg === "--testapp.md") flags.testapp = true;
994
1003
  else if (arg === "--demo") flags.demo = true;
1004
+ else if (arg === "--demo-select") flags.demoSelect = true;
1005
+ else if (arg === "--demo-imgtool") flags.demoImgtool = true;
1006
+ else if (arg === "--demo-imgtool-zh") flags.demoImgtoolZh = true;
995
1007
  else if (arg === "--allow-url") flags.allowUrl = true;
996
1008
  else if (arg === "--build-exe") flags.buildExe = true;
997
1009
  else if (arg === "--build-for") flags.buildFor = argv[++i] ?? "";
@@ -1024,9 +1036,13 @@ function usage() {
1024
1036
  ${pkg.name} --wui [FILE.md]
1025
1037
 
1026
1038
  Modes:
1039
+ --check FILE.md
1040
+ Check heading and fenced-block IDs for collisions, print details, and exit
1041
+ Exits 0 when IDs are unique, 1 on collisions, and 2 on usage/read errors
1027
1042
  --wui [FILE.md]
1028
1043
  Generate or overwrite Markdown UI files beside FILE.md and start the server
1029
- Without FILE.md, use testapp.md and write generated files in the current directory
1044
+ Without FILE.md, use the existing ./testapp.md without overwriting it
1045
+ If ./testapp.md is missing, write the bundled demo there first
1030
1046
  --cat, --ccat, --bat, --glow
1031
1047
  Render file(s) and write to stdout, then exit (.md uses mdcui/createTui)
1032
1048
  A local .md file also writes or overwrites five generated files beside it
@@ -1060,6 +1076,8 @@ Information:
1060
1076
  Show version+backend info & exit
1061
1077
  --docs, --readme
1062
1078
  Show ${pkg.name}'s README.md & exit
1079
+ --export-readme
1080
+ Write or overwrite ./README.md with the bundled README.md & exit
1063
1081
  --changelog
1064
1082
  Show CHANGELOG.md & exit
1065
1083
  -profile, --profile
@@ -1069,7 +1087,17 @@ Demo:
1069
1087
  --testapp.md
1070
1088
  Write the bundled testapp.md to stdout & exit
1071
1089
  --demo
1072
- Outputs & overwrites ./testapp.md, opens it in the TUI, and writes 5 generated files
1090
+ Use the existing ./testapp.md without overwriting it, or write the bundled demo if missing
1091
+ Open it in the TUI and write 5 generated files beside it
1092
+ --demo-select
1093
+ Use the existing ./select.md without overwriting it, or write the bundled demo if missing
1094
+ Open it in the TUI and write 5 generated files beside it
1095
+ --demo-imgtool
1096
+ Use the existing ./image-processor.md without overwriting it, or write the bundled demo if missing
1097
+ Open the Bun.Image processor in the TUI and write 5 generated files beside it
1098
+ --demo-imgtool-zh
1099
+ Use the existing ./image-processor.zh-TW.md without overwriting it, or write the bundled demo if missing
1100
+ Open the Traditional Chinese Bun.Image processor in the TUI and write 5 generated files beside it
1073
1101
 
1074
1102
  Remote Markdown:
1075
1103
  --allow-url
@@ -4062,6 +4090,12 @@ class App {
4062
4090
  return true;
4063
4091
  }
4064
4092
  }
4093
+
4094
+ const checkboxResult = toggleTaskCheckboxBeforeColumn(payload.line, x);
4095
+ if (checkboxResult.toggled) {
4096
+ buf.lines[y] = checkboxResult.line;
4097
+ buf.modified = true;
4098
+ }
4065
4099
 
4066
4100
  this.message = `event ${payload.trigger}:`+
4067
4101
  `(${payload.row},${payload.col})`+
@@ -7498,8 +7532,17 @@ async function loadBuffers(files, command) {
7498
7532
  }
7499
7533
 
7500
7534
  async function printReadmeDocs() {
7501
- const readme = readInternalAssetText("README.md") ?? await Bun.file(join(REPO_ROOT, "README.md")).text();
7502
- process.stdout.write(Bun.markdown.ansi(readme, { hyperlinks: true }));
7535
+ process.stdout.write(Bun.markdown.ansi(await bundledReadmeSource(), { hyperlinks: true }));
7536
+ }
7537
+
7538
+ async function bundledReadmeSource() {
7539
+ return readInternalAssetText("README.md") ?? await Bun.file(join(REPO_ROOT, "README.md")).text();
7540
+ }
7541
+
7542
+ async function exportReadme() {
7543
+ const readmePath = resolve("README.md");
7544
+ await Bun.write(readmePath, await bundledReadmeSource());
7545
+ console.log(`Wrote ${readmePath}`);
7503
7546
  }
7504
7547
 
7505
7548
  async function printChangelogDocs() {
@@ -7508,11 +7551,11 @@ async function printChangelogDocs() {
7508
7551
  }
7509
7552
 
7510
7553
  async function printTestappSource() {
7511
- process.stdout.write(await bundledTestappSource());
7554
+ process.stdout.write(await bundledMarkdownSource("testapp.md"));
7512
7555
  }
7513
7556
 
7514
- async function bundledTestappSource() {
7515
- return readInternalAssetText("testapp.md") ?? await Bun.file(join(REPO_ROOT, "testapp.md")).text();
7557
+ async function bundledMarkdownSource(filename) {
7558
+ return readInternalAssetText(filename) ?? await Bun.file(join(REPO_ROOT, filename)).text();
7516
7559
  }
7517
7560
 
7518
7561
  async function main() {
@@ -7558,10 +7601,33 @@ async function main() {
7558
7601
  console.log("Clipboard:", backends);
7559
7602
  return;
7560
7603
  }
7604
+ if (flags.check) {
7605
+ if (rawFiles.length !== 1) {
7606
+ console.error("Usage: jsmdcui --check FILE.md");
7607
+ process.exitCode = 2;
7608
+ return;
7609
+ }
7610
+ const checkPath = resolve(rawFiles[0]);
7611
+ try {
7612
+ const file = Bun.file(checkPath);
7613
+ if (!(await file.exists())) throw new Error("file not found");
7614
+ const result = checkMarkdownIdCollisions(await file.text());
7615
+ process.stdout.write(formatMarkdownIdCheckAnsi(checkPath, result));
7616
+ if (result.collisions.length) process.exitCode = 1;
7617
+ } catch (error) {
7618
+ console.error(`Cannot check ${checkPath}: ${error?.message || error}`);
7619
+ process.exitCode = 2;
7620
+ }
7621
+ return;
7622
+ }
7561
7623
  if (flags.docs) {
7562
7624
  await printReadmeDocs();
7563
7625
  return;
7564
7626
  }
7627
+ if (flags.exportReadme) {
7628
+ await exportReadme();
7629
+ return;
7630
+ }
7565
7631
  if (flags.changelog) {
7566
7632
  await printChangelogDocs();
7567
7633
  return;
@@ -7570,9 +7636,20 @@ async function main() {
7570
7636
  await printTestappSource();
7571
7637
  return;
7572
7638
  }
7573
- if (flags.demo) {
7574
- const demoPath = resolve("testapp.md");
7575
- await Bun.write(demoPath, await bundledTestappSource());
7639
+ const demoFilename = flags.demo
7640
+ ? "testapp.md"
7641
+ : flags.demoSelect
7642
+ ? "select.md"
7643
+ : flags.demoImgtool
7644
+ ? "image-processor.md"
7645
+ : flags.demoImgtoolZh
7646
+ ? "image-processor.zh-TW.md"
7647
+ : "";
7648
+ if (demoFilename) {
7649
+ const demoPath = resolve(demoFilename);
7650
+ if (!(await Bun.file(demoPath).exists())) {
7651
+ await Bun.write(demoPath, await bundledMarkdownSource(demoFilename));
7652
+ }
7576
7653
  rawFiles.splice(0, rawFiles.length, demoPath);
7577
7654
  }
7578
7655
  if (flags.options) {
@@ -770,6 +770,100 @@ function _blockValue(lines, selector) {
770
770
  return value.join("\n");
771
771
  }
772
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
+
773
867
  function _spliceBufferLines(buffer, start, deleteCount, replacement) {
774
868
  const oldCursor = buffer.cursor ? { ...buffer.cursor } : null;
775
869
  buffer.lines.splice(start, deleteCount, ...replacement);
@@ -828,11 +922,32 @@ export function createTuiSelector(getBuffer) {
828
922
  return function $(selector) {
829
923
  const parsedSelector = _parseBlockIdentity(selector, { selector: true });
830
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
+ },
831
940
  val(...args) {
832
941
  try {
833
- if (!parsedSelector) return args.length > 0 ? selection : "";
834
942
  const buffer = getBuffer?.();
835
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 : "";
836
951
  const lines = Array.isArray(buffer.lines)
837
952
  ? buffer.lines
838
953
  : String(buffer).replace(/\r\n?/g, "\n").split("\n");
@@ -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
+ });