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/CHANGELOG.md +168 -0
- package/README.md +214 -23
- package/image-processor.md +360 -0
- package/image-processor.zh-TW.md +360 -0
- package/package.json +1 -1
- package/runmd.mjs +77 -3
- package/runtime/help/help.md +214 -23
- package/select.md +61 -0
- package/single-exe/packAssets.sh +1 -1
- package/src/cui/id-collision.mjs +151 -0
- package/src/cui/rpc.mjs +71 -1
- package/src/cui/task-checkbox.mjs +16 -0
- package/src/index.js +87 -10
- package/src/plugins/js-bridge.js +116 -1
- package/tests/demo.test.js +110 -1
- package/tests/id-collision.test.js +146 -0
- package/tests/wui.test.js +30 -0
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
|
|
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
|
-
|
|
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
|
-
|
|
7502
|
-
|
|
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
|
|
7554
|
+
process.stdout.write(await bundledMarkdownSource("testapp.md"));
|
|
7512
7555
|
}
|
|
7513
7556
|
|
|
7514
|
-
async function
|
|
7515
|
-
return readInternalAssetText(
|
|
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
|
-
|
|
7574
|
-
|
|
7575
|
-
|
|
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) {
|
package/src/plugins/js-bridge.js
CHANGED
|
@@ -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");
|
package/tests/demo.test.js
CHANGED
|
@@ -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
|
+
});
|