jsmdcui 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,86 @@
2
2
 
3
3
  All notable user-visible changes to jsmdcui are documented here.
4
4
 
5
+ ## [0.4.0] - 2026-07-16
6
+
7
+ This release separates executable Markdown UI views from ordinary editable
8
+ buffers, makes encoding behavior predictable across opens, tabs, splits, and
9
+ reopens, and adds explicit demo and trusted-remote execution modes.
10
+
11
+ ### Added
12
+
13
+ - Add `--demo`. It outputs and overwrites `./testapp.md`, opens it as a terminal
14
+ Markdown UI, and writes the five generated companion files beside it.
15
+ - Add `--allow-url` for trusted remote Markdown apps. It downloads an HTTP(S)
16
+ resource into the current directory, opens the downloaded copy through the
17
+ normal local Markdown UI pipeline, writes the five generated companion files,
18
+ and permits its embedded frontend and backend code to run.
19
+ - Add regression coverage for global encoding handling, mdcui backup isolation,
20
+ the bundled demo workflow, implicit Markdown rendering, and explicit UTF-8
21
+ editing.
22
+
23
+ ### Changed
24
+
25
+ - Treat `mdcui` buffers as independent, derived, read-only views. Every mdcui
26
+ open creates a new buffer instead of entering the same-path editable-buffer
27
+ cache.
28
+ - Continue sharing ordinary buffers opened from the same absolute path.
29
+ UTF-8 and other non-mdcui views share content, modification state, saves, and
30
+ reopens.
31
+ - Re-evaluate buffer sharing after `reopen`:
32
+ - reopening an mdcui view as a normal encoding joins an existing same-path
33
+ editable buffer, or registers itself when no such buffer exists;
34
+ - reopening a shared editable view as mdcui detaches only the current pane
35
+ into a new mdcui buffer and leaves the other shared panes unchanged.
36
+ - Make interactive `open`, `tab`, `vsplit`, and `hsplit` operations perform
37
+ fresh `.md` automatic detection for files that are not already represented
38
+ by a normal cached buffer.
39
+ - Ignore a top-level global `encoding` value read from `settings.json`.
40
+ Encoding is selected per invocation or buffer; command-line encoding options
41
+ remain effective for the current run and are not persisted.
42
+ - Organize `--testapp.md` and `--demo` under a dedicated `Demo` section in
43
+ `--help`, and document commands that overwrite source or generated files.
44
+
45
+ ### Fixed
46
+
47
+ - Fix `.md` files opened from inside the editor failing to enter mdcui mode.
48
+ - Fix encoding changes leaking between independent mdcui tabs.
49
+ - Fix `reopen mdcui` mutating every pane that shared the original editable
50
+ buffer.
51
+ - Fix mdcui-to-UTF-8 reopens remaining outside the normal same-path buffer
52
+ cache.
53
+ - Delay applying an explicit reopen encoding until the reopen is confirmed, so
54
+ canceling the save prompt does not alter the shared buffer.
55
+
56
+ ### Security
57
+
58
+ - Exclude mdcui buffers completely from crash-recovery backups: they do not
59
+ create, apply, prompt for, or remove backups. This prevents a derived
60
+ read-only view from consuming or deleting a backup belonging to an editable
61
+ view of the same Markdown source.
62
+ - Remote HTTP(S) Markdown remains non-executable and does not write companion
63
+ files by default.
64
+ - `--allow-url` is an explicit trust boundary. It writes downloaded content and
65
+ generated JavaScript/HTML files into the current directory, may overwrite
66
+ files with the same names, and runs code from the remote document with the
67
+ permissions of the jsmdcui process. Use it only with trusted URLs and in a
68
+ directory where those writes are safe.
69
+
70
+ ## [0.3.1] - 2026-07-16
71
+
72
+ ### Added
73
+
74
+ - Add `--edit` to open Markdown files as editable UTF-8 source instead of
75
+ automatically entering mdcui mode.
76
+ - Add `--cat` regression coverage for implicit mdcui rendering and explicit
77
+ UTF-8 overrides.
78
+
79
+ ### Fixed
80
+
81
+ - Prevent an explicit encoding choice from being replaced by automatic mdcui
82
+ detection.
83
+ - Correct terminal raw-mode handling around log and prompt output.
84
+
5
85
  ## [0.3.0] - 2026-07-16
6
86
 
7
87
  Initial usable release, based on the bunmicro terminal editor.
package/README.md CHANGED
@@ -78,8 +78,11 @@ The command table below uses the cloned-source form. If you use npx, replace
78
78
  | Command | Result |
79
79
  | --- | --- |
80
80
  | `bun src/index.js app.md` | Render `app.md` as a read-only terminal UI and write five generated files beside it. |
81
+ | `bun src/index.js --edit app.md` | Open `app.md` as editable UTF-8 source, overriding automatic Markdown UI detection. |
81
82
  | `bun src/index.js --cat app.md` | Render the terminal version to stdout, write five generated files beside it, and exit. |
82
83
  | `bun src/index.js --testapp.md` | Write the bundled `testapp.md` source to stdout and exit. |
84
+ | `bun src/index.js --demo` | Outputs & overwrites `./testapp.md`, opens it in the terminal UI, and writes 5 generated files beside it. |
85
+ | `bun src/index.js --allow-url URL.md` | Download HTTP(S) Markdown to the current directory, write 5 generated files, and allow its embedded code to run. Only use trusted URLs. |
83
86
  | `bun src/index.js --wui` | Use local `testapp.md` when present, otherwise use the bundled demo; write five generated files in the current directory, then print and serve a random URL. |
84
87
  | `bun src/index.js --wui app.md` | Write five generated files beside `app.md`, then print and serve a random URL. |
85
88
  | `PORT=8080 bun src/index.js --wui app.md` | Start the browser UI on another port. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jsmdcui",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Markdown as a Common UI for Terminals and Web Browsers",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
package/runmd.mjs CHANGED
@@ -17,6 +17,7 @@ const TEST_COL=5
17
17
 
18
18
  function logWroteFile(label,path)
19
19
  {
20
+ if(!process.stdin.isRaw)
20
21
  csl(mda(`- Wrote to ${label} file: ${path}`))
21
22
  }
22
23
 
@@ -78,8 +78,11 @@ The command table below uses the cloned-source form. If you use npx, replace
78
78
  | Command | Result |
79
79
  | --- | --- |
80
80
  | `bun src/index.js app.md` | Render `app.md` as a read-only terminal UI and write five generated files beside it. |
81
+ | `bun src/index.js --edit app.md` | Open `app.md` as editable UTF-8 source, overriding automatic Markdown UI detection. |
81
82
  | `bun src/index.js --cat app.md` | Render the terminal version to stdout, write five generated files beside it, and exit. |
82
83
  | `bun src/index.js --testapp.md` | Write the bundled `testapp.md` source to stdout and exit. |
84
+ | `bun src/index.js --demo` | Outputs & overwrites `./testapp.md`, opens it in the terminal UI, and writes 5 generated files beside it. |
85
+ | `bun src/index.js --allow-url URL.md` | Download HTTP(S) Markdown to the current directory, write 5 generated files, and allow its embedded code to run. Only use trusted URLs. |
83
86
  | `bun src/index.js --wui` | Use local `testapp.md` when present, otherwise use the bundled demo; write five generated files in the current directory, then print and serve a random URL. |
84
87
  | `bun src/index.js --wui app.md` | Write five generated files beside `app.md`, then print and serve a random URL. |
85
88
  | `PORT=8080 bun src/index.js --wui app.md` | Start the browser UI on another port. |
@@ -5,9 +5,14 @@ import { existsSync, statSync, unlinkSync } from "node:fs";
5
5
  import { mkdir, writeFile, readFile, rename } from "node:fs/promises";
6
6
  import { homedir } from "node:os";
7
7
  import { createHash } from "node:crypto";
8
+ import { isMdcuiEncoding } from "../runtime/encodings.js";
8
9
 
9
10
  export const BACKUP_SUFFIX = ".micro-backup";
10
11
 
12
+ function isMdcuiBuffer(buf) {
13
+ return isMdcuiEncoding(buf?.encoding ?? buf?.Settings?.encoding);
14
+ }
15
+
11
16
  export function getBackupDir(buf, configDir) {
12
17
  const raw = String(buf?.Settings?.backupdir ?? "");
13
18
  if (!raw) return join(configDir, "backups");
@@ -49,7 +54,7 @@ export function determineBackupPath(backupDirPath, absPath) {
49
54
  }
50
55
 
51
56
  export async function writeBackup(buf, configDir, path = buf?.AbsPath ?? buf?.path, { force = false } = {}) {
52
- if ((!force && !buf?.Settings?.backup) || !path || buf.type !== "default") return false;
57
+ if (isMdcuiBuffer(buf) || (!force && !buf?.Settings?.backup) || !path || buf.type !== "default") return false;
53
58
  const dir = getBackupDir(buf, configDir);
54
59
  await mkdir(dir, { recursive: true });
55
60
  const { name, resolveName } = determineBackupPath(dir, path);
@@ -66,7 +71,7 @@ export async function writeBackup(buf, configDir, path = buf?.AbsPath ?? buf?.pa
66
71
  }
67
72
 
68
73
  export function removeBackup(buf, configDir, path = buf?.AbsPath ?? buf?.path) {
69
- if (buf?.Settings?.permbackup || buf?._forceKeepBackup) return;
74
+ if (isMdcuiBuffer(buf) || buf?.Settings?.permbackup || buf?._forceKeepBackup) return;
70
75
  if (!path || buf.type !== "default") return;
71
76
  const dir = getBackupDir(buf, configDir);
72
77
  const { name, resolveName } = determineBackupPath(dir, path);
@@ -77,7 +82,7 @@ export function removeBackup(buf, configDir, path = buf?.AbsPath ?? buf?.path) {
77
82
  // promptFn(msg) -> Promise<string>
78
83
  // Returns { recovered: bool, abort: bool }
79
84
  export async function applyBackup(buf, configDir, promptFn) {
80
- if (!buf?.Settings?.backup || buf?.Settings?.permbackup) return { recovered: false, abort: false };
85
+ if (isMdcuiBuffer(buf) || !buf?.Settings?.backup || buf?.Settings?.permbackup) return { recovered: false, abort: false };
81
86
  if (!buf.path || buf.type !== "default") return { recovered: false, abort: false };
82
87
 
83
88
  const dir = getBackupDir(buf, configDir);
@@ -27,6 +27,9 @@ export class Config {
27
27
  const text = await readFile(path, "utf8");
28
28
  if (text.trimStart().startsWith("null") || text.trim() === "") return;
29
29
  this.parsedSettings = Bun.JSON5.parse(text);
30
+ // jsmdcui chooses the input encoding per invocation/buffer. A persisted
31
+ // global encoding must neither affect startup nor survive the next save.
32
+ delete this.parsedSettings.encoding;
30
33
  this.applyParsedSettings(this.parsedSettings);
31
34
  }
32
35
 
@@ -98,7 +98,7 @@ export const OPTION_CHOICES = {
98
98
 
99
99
  // Settings that are buffer-local only and must never be written to the global config file.
100
100
  // Mirrors Go micro's config.LocalSettings.
101
- export const LOCAL_SETTINGS = new Set(["readonly", "filetype", "fileformat"]);
101
+ export const LOCAL_SETTINGS = new Set(["readonly", "filetype", "fileformat", "encoding"]);
102
102
 
103
103
  export function defaultAllSettings() {
104
104
  return { ...DEFAULT_COMMON_SETTINGS, ...DEFAULT_GLOBAL_ONLY_SETTINGS };
package/src/index.js CHANGED
@@ -304,14 +304,14 @@ function encodeHex3Text(text) {
304
304
  return decodeBinaryBytes(Buffer.from(text, "latin1"));
305
305
  }
306
306
 
307
- async function readTextFileWithEncoding(path, encoding = "utf-8") {
307
+ async function readTextFileWithEncoding(path, encoding = "utf-8", inferMdcui = true) {
308
308
  const bytes = new Uint8Array(await Bun.file(path).arrayBuffer());
309
- return decodeAndRenderTextBytes(bytes, encodingForPath(path, encoding), process.stdout.columns || 80, path);
309
+ return decodeAndRenderTextBytes(bytes, encodingForPath(path, encoding, inferMdcui), process.stdout.columns || 80, path);
310
310
  }
311
311
 
312
- async function fetchTextWithEncoding(url, encoding = "utf-8") {
312
+ async function fetchTextWithEncoding(url, encoding = "utf-8", inferMdcui = true) {
313
313
  const bytes = await fetchHttpBytes(url);
314
- return decodeAndRenderTextBytes(new Uint8Array(bytes), encodingForPath(url, encoding));
314
+ return decodeAndRenderTextBytes(new Uint8Array(bytes), encodingForPath(url, encoding, inferMdcui));
315
315
  }
316
316
 
317
317
  function normalizeEncodingLabel(encoding = "utf-8") {
@@ -321,9 +321,9 @@ function normalizeEncodingLabel(encoding = "utf-8") {
321
321
  return new TextDecoder(s).encoding;
322
322
  }
323
323
 
324
- function encodingForPath(pathOrUrl, encoding = DEFAULT_SETTINGS.encoding) {
324
+ function encodingForPath(pathOrUrl, encoding = DEFAULT_SETTINGS.encoding, inferMdcui = true) {
325
325
  const normalized = normalizeEncodingLabel(encoding);
326
- if (normalized !== "utf-8") return normalized;
326
+ if (normalized !== "utf-8" || !inferMdcui) return normalized;
327
327
  let pathname = String(pathOrUrl ?? "").replace(/[?#].*$/, "");
328
328
  try {
329
329
  if (isHttpUrl(pathname)) pathname = new URL(pathname).pathname;
@@ -869,6 +869,8 @@ function parseArgs(argv) {
869
869
  docs: false,
870
870
  changelog: false,
871
871
  testapp: false,
872
+ demo: false,
873
+ allowUrl: false,
872
874
  buildExe: false,
873
875
  buildFor: "",
874
876
  configDir: "",
@@ -901,9 +903,14 @@ function parseArgs(argv) {
901
903
  else if (arg === "--hex3zst") {
902
904
  flags.settings.set("encoding", "hex3zst");
903
905
  }
906
+ else if (arg === "--edit") {
907
+ flags.settings.set("encoding", "utf-8");
908
+ }
904
909
  else if (arg === "--docs" || arg === "--readme") flags.docs = true;
905
910
  else if (arg === "--changelog") flags.changelog = true;
906
911
  else if (arg === "--testapp.md") flags.testapp = true;
912
+ else if (arg === "--demo") flags.demo = true;
913
+ else if (arg === "--allow-url") flags.allowUrl = true;
907
914
  else if (arg === "--build-exe") flags.buildExe = true;
908
915
  else if (arg === "--build-for") flags.buildFor = argv[++i] ?? "";
909
916
  else if (arg === "-debug") flags.debug = true;
@@ -946,6 +953,8 @@ Modes:
946
953
  --hex3, --hex3gz, --hex3zst
947
954
  Set -encoding hex3, hex3gz, or hex3zst for this session
948
955
  hex3 shows raw bytes; gz/zst variants compress the same hex3 view
956
+ --edit
957
+ Open files as editable UTF-8 text, overriding .md mdcui detection
949
958
  -encoding mdcui
950
959
  Render Markdown through runmd.mjs#createTui; .md files use this automatically
951
960
  Writes .front.js, .back.js, .html, -rpc.js, and -server.js beside the .md file
@@ -971,11 +980,19 @@ Information:
971
980
  Show ${pkg.name}'s README.md & exit
972
981
  --changelog
973
982
  Show CHANGELOG.md & exit
974
- --testapp.md
975
- Write testapp.md to stdout & exit
976
983
  -profile, --profile
977
984
  Print startup performance profile and exit
978
985
 
986
+ Demo:
987
+ --testapp.md
988
+ Write the bundled testapp.md to stdout & exit
989
+ --demo
990
+ Outputs & overwrites ./testapp.md, opens it in the TUI, and writes 5 generated files
991
+
992
+ Remote Markdown:
993
+ --allow-url
994
+ Download HTTP(S) Markdown to cwd, write 5 generated files, and allow its code to run
995
+
979
996
  Experimental:
980
997
  --build-exe Build a Bun single-file executable and exit
981
998
  --build-for <target> Build a Bun single-file executable for target`
@@ -1129,7 +1146,7 @@ class BufferModel {
1129
1146
  let text = "";
1130
1147
  let readonly = false;
1131
1148
  let modTimeMs = null;
1132
- let encoding = context.config?.globalSettings?.encoding ?? DEFAULT_SETTINGS.encoding;
1149
+ let encoding = context.inputEncoding ?? context.config?.globalSettings?.encoding ?? DEFAULT_SETTINGS.encoding;
1133
1150
  let ansiStyleLines = null;
1134
1151
  let ansiText = null;
1135
1152
  let sourceText = null;
@@ -1140,7 +1157,7 @@ class BufferModel {
1140
1157
  if (info.isDirectory()) throw new Error(`${path} is a directory`);
1141
1158
  readonly = !canWritePath(path);
1142
1159
  modTimeMs = info.mtimeMs;
1143
- const decoded = await readTextFileWithEncoding(path, encoding);
1160
+ const decoded = await readTextFileWithEncoding(path, encoding, !context.encodingExplicit);
1144
1161
  text = decoded.text;
1145
1162
  encoding = decoded.encoding;
1146
1163
  ansiStyleLines = decoded.ansiStyleLines ?? null;
@@ -1536,7 +1553,7 @@ class BufferModel {
1536
1553
  async reopen(context = {}) {
1537
1554
  if (!this.path) return;
1538
1555
  if (isHttpUrl(this.path)) {
1539
- const decoded = await fetchTextWithEncoding(this.path, this.Settings.encoding ?? this.encoding);
1556
+ const decoded = await fetchTextWithEncoding(this.path, this.Settings.encoding ?? this.encoding, false);
1540
1557
  const text = decoded.text;
1541
1558
  this.encoding = decoded.encoding;
1542
1559
  this.Settings.encoding = decoded.encoding;
@@ -1568,7 +1585,7 @@ class BufferModel {
1568
1585
  }
1569
1586
  const info = statSync(this.path);
1570
1587
  if (info.isDirectory()) throw new Error(`${this.path} is a directory`);
1571
- const decoded = await readTextFileWithEncoding(this.path, this.Settings.encoding ?? this.encoding);
1588
+ const decoded = await readTextFileWithEncoding(this.path, this.Settings.encoding ?? this.encoding, false);
1572
1589
  const text = decoded.text;
1573
1590
  this.encoding = decoded.encoding;
1574
1591
  this.Settings.encoding = decoded.encoding;
@@ -4624,7 +4641,7 @@ class App {
4624
4641
  async openInPane(path) {
4625
4642
  try {
4626
4643
  const previous = this.pane.buffer;
4627
- const buffer = await loadBufferForPath(path, this.context);
4644
+ const buffer = await loadBufferForPath(path, this.context, {}, { interactive: true });
4628
4645
  this.pane.buffer = buffer;
4629
4646
  this.pane.selection = null;
4630
4647
  if (previous !== buffer) this._closeBufferIfUnused(previous);
@@ -4637,7 +4654,7 @@ class App {
4637
4654
 
4638
4655
  async openFile(path) {
4639
4656
  try {
4640
- const buffer = await loadBufferForPath(path, this.context);
4657
+ const buffer = await loadBufferForPath(path, this.context, {}, { interactive: true });
4641
4658
  if (isEmptyUntitledBuffer(this.buffer)) {
4642
4659
  const previous = this.pane.buffer;
4643
4660
  this.pane.buffer = buffer;
@@ -4655,6 +4672,34 @@ class App {
4655
4672
  }
4656
4673
  }
4657
4674
 
4675
+ reconcileReopenedBuffer(buffer) {
4676
+ if (!buffer || isHttpUrl(buffer.path)) return buffer;
4677
+ const map = this.context?._openBuffers;
4678
+ if (!map) return buffer;
4679
+ const absPath = resolve(buffer.AbsPath || buffer.path);
4680
+
4681
+ if (isMdcuiEncoding(buffer.encoding)) {
4682
+ if (map.get(absPath) === buffer) map.delete(absPath);
4683
+ buffer._openBufferMap = null;
4684
+ return buffer;
4685
+ }
4686
+
4687
+ const existing = map.get(absPath);
4688
+ if (existing && existing !== buffer && !isMdcuiEncoding(existing.encoding)) {
4689
+ for (const tab of this.tabs) {
4690
+ for (const pane of tab.panes()) {
4691
+ if (pane.buffer === buffer) pane.buffer = existing;
4692
+ if (pane.prevBuffer === buffer) pane.prevBuffer = existing;
4693
+ }
4694
+ }
4695
+ return existing;
4696
+ }
4697
+
4698
+ buffer._openBufferMap = map;
4699
+ map.set(absPath, buffer);
4700
+ return buffer;
4701
+ }
4702
+
4658
4703
  async save({ force = false } = {}) {
4659
4704
  if (isEditLockedBuffer(this.buffer)) { this.message = "Can't save under readonly mode"; return; }
4660
4705
  if (!force && this.buffer?.readonly) { this.message = "Can't save under readonly mode"; return; }
@@ -5110,9 +5155,10 @@ class App {
5110
5155
  }
5111
5156
  case "reopen": {
5112
5157
  if (!buf?.path) { this.message = "No file to reopen"; break; }
5158
+ let requestedEncoding = null;
5113
5159
  if (cmdArgs[0]) {
5114
5160
  try {
5115
- buf.SetOption("encoding", cmdArgs[0]);
5161
+ requestedEncoding = normalizeEncodingLabel(cmdArgs[0]);
5116
5162
  } catch (error) {
5117
5163
  this.message = String(error.message || error);
5118
5164
  break;
@@ -5120,9 +5166,24 @@ class App {
5120
5166
  }
5121
5167
  const doReopen = async () => {
5122
5168
  try {
5169
+ if (isMdcuiEncoding(requestedEncoding)) {
5170
+ const detachedContext = { ...this.context, inputEncoding: "mdcui", encodingExplicit: true };
5171
+ const reopened = await loadBufferForPath(buf.path, detachedContext);
5172
+ const previous = this.pane.buffer;
5173
+ this.pane.buffer = reopened;
5174
+ this.pane.selection = null;
5175
+ if (previous !== reopened) this._closeBufferIfUnused(previous);
5176
+ await this.context.plugins?.run("onBufferOpen", reopened);
5177
+ await this.context.jsPlugins?.run("onBufferOpen", reopened);
5178
+ this.message = `Reopened ${reopened.name} as ${reopened.encoding}`;
5179
+ this.render();
5180
+ return;
5181
+ }
5182
+ if (requestedEncoding) buf.SetOption("encoding", requestedEncoding);
5123
5183
  await buf.reopen(this.context);
5124
- if (this.pane?.buffer === buf) this.pane.selection = null;
5125
- this.message = `Reopened ${buf.name} as ${buf.encoding}`;
5184
+ const reopened = this.reconcileReopenedBuffer(buf);
5185
+ if (this.pane?.buffer === reopened) this.pane.selection = null;
5186
+ this.message = `Reopened ${reopened.name} as ${reopened.encoding}`;
5126
5187
  } catch (error) {
5127
5188
  this.message = String(error.message || error);
5128
5189
  }
@@ -5228,7 +5289,7 @@ class App {
5228
5289
  case "vsplit": {
5229
5290
  let newBuf;
5230
5291
  if (cmdArgs.length > 0) {
5231
- try { newBuf = await loadBufferForPath(resolve(expandHome(cmdArgs[0])), this.context); }
5292
+ try { newBuf = await loadBufferForPath(resolve(expandHome(cmdArgs[0])), this.context, {}, { interactive: true }); }
5232
5293
  catch (err) { this.message = err.message; break; }
5233
5294
  } else {
5234
5295
  newBuf = new BufferModel({ command: {} });
@@ -5241,7 +5302,7 @@ class App {
5241
5302
  case "hsplit": {
5242
5303
  let newBuf;
5243
5304
  if (cmdArgs.length > 0) {
5244
- try { newBuf = await loadBufferForPath(resolve(expandHome(cmdArgs[0])), this.context); }
5305
+ try { newBuf = await loadBufferForPath(resolve(expandHome(cmdArgs[0])), this.context, {}, { interactive: true }); }
5245
5306
  catch (err) { this.message = err.message; break; }
5246
5307
  } else {
5247
5308
  newBuf = new BufferModel({ command: {} });
@@ -6528,11 +6589,25 @@ function commandHasStartupJump(command = {}) {
6528
6589
  return commandHasStartCursor(command) || Boolean(command.searchRegex);
6529
6590
  }
6530
6591
 
6531
- async function loadBufferForPath(pathOrUrl, context, command = {}) {
6592
+ async function loadBufferForPath(pathOrUrl, context, command = {}, { interactive = false } = {}) {
6593
+ const loadContext = interactive
6594
+ ? { ...context, inputEncoding: defaultAllSettings().encoding, encodingExplicit: false }
6595
+ : context;
6596
+ if (isHttpUrl(pathOrUrl) && loadContext.allowUrl) {
6597
+ const url = new URL(pathOrUrl);
6598
+ let name;
6599
+ try { name = decodeURIComponent(basename(url.pathname)); }
6600
+ catch { name = basename(url.pathname); }
6601
+ if (!name) name = "remote.md";
6602
+ if (!name.toLowerCase().endsWith(".md")) name += ".md";
6603
+ const localPath = resolve(name);
6604
+ await Bun.write(localPath, await fetchHttpBytes(pathOrUrl));
6605
+ return await loadBufferForPath(localPath, loadContext, command, { interactive });
6606
+ }
6532
6607
  let buffer;
6533
6608
  if (isHttpUrl(pathOrUrl)) {
6534
- let encoding = context.config?.globalSettings?.encoding ?? DEFAULT_SETTINGS.encoding;
6535
- const decoded = await fetchTextWithEncoding(pathOrUrl, encoding);
6609
+ let encoding = loadContext.inputEncoding ?? loadContext.config?.globalSettings?.encoding ?? DEFAULT_SETTINGS.encoding;
6610
+ const decoded = await fetchTextWithEncoding(pathOrUrl, encoding, !loadContext.encodingExplicit);
6536
6611
  const text = decoded.text;
6537
6612
  encoding = decoded.encoding;
6538
6613
  const urlPath = pathOrUrl.replace(/[?#].*$/, "");
@@ -6548,13 +6623,18 @@ async function loadBufferForPath(pathOrUrl, context, command = {}) {
6548
6623
  mdcuiRenderWidth: decoded.mdcuiRenderWidth ?? 0,
6549
6624
  });
6550
6625
  buffer._configDir = context?.config?.configDir ?? null;
6551
- attachSyntax(buffer, context, urlPath, text);
6626
+ attachSyntax(buffer, loadContext, urlPath, text);
6552
6627
  } else {
6553
6628
  if (!context._openBuffers) context._openBuffers = new Map();
6554
6629
  const absPath = resolve(pathOrUrl);
6555
6630
  const existing = context._openBuffers.get(absPath);
6556
- if (existing) return existing;
6557
- buffer = await BufferModel.fromFile(absPath, command, context);
6631
+ const requestedEncoding = encodingForPath(
6632
+ absPath,
6633
+ loadContext.inputEncoding ?? loadContext.config?.globalSettings?.encoding ?? DEFAULT_SETTINGS.encoding,
6634
+ !loadContext.encodingExplicit,
6635
+ );
6636
+ if (existing && !isMdcuiEncoding(existing.encoding) && !isMdcuiEncoding(requestedEncoding)) return existing;
6637
+ buffer = await BufferModel.fromFile(absPath, command, loadContext);
6558
6638
  // Check for crash-recovery backup before returning the buffer.
6559
6639
  const promptFn = context._termPrompt;
6560
6640
  if (promptFn && buffer._configDir) {
@@ -6565,8 +6645,12 @@ async function loadBufferForPath(pathOrUrl, context, command = {}) {
6565
6645
  attachSyntax(buffer, context, absPath, buffer.lines.join("\n"));
6566
6646
  }
6567
6647
  }
6568
- buffer._openBufferMap = context._openBuffers;
6569
- context._openBuffers.set(absPath, buffer);
6648
+ // mdcui is a derived, read-only view. Keep every view independent and out
6649
+ // of the same-path cache used by normal editable buffers.
6650
+ if (!isMdcuiEncoding(buffer.encoding)) {
6651
+ buffer._openBufferMap = context._openBuffers;
6652
+ context._openBuffers.set(absPath, buffer);
6653
+ }
6570
6654
  }
6571
6655
  if (DEFAULT_SETTINGS.savecursor && !commandHasStartupJump(command) && context?.cursorStates?.[pathOrUrl]) {
6572
6656
  const saved = context.cursorStates[pathOrUrl];
@@ -7272,8 +7356,11 @@ async function printChangelogDocs() {
7272
7356
  }
7273
7357
 
7274
7358
  async function printTestappSource() {
7275
- const testapp = readInternalAssetText("testapp.md") ?? await Bun.file(join(REPO_ROOT, "testapp.md")).text();
7276
- process.stdout.write(testapp);
7359
+ process.stdout.write(await bundledTestappSource());
7360
+ }
7361
+
7362
+ async function bundledTestappSource() {
7363
+ return readInternalAssetText("testapp.md") ?? await Bun.file(join(REPO_ROOT, "testapp.md")).text();
7277
7364
  }
7278
7365
 
7279
7366
  async function main() {
@@ -7331,6 +7418,11 @@ async function main() {
7331
7418
  await printTestappSource();
7332
7419
  return;
7333
7420
  }
7421
+ if (flags.demo) {
7422
+ const demoPath = resolve("testapp.md");
7423
+ await Bun.write(demoPath, await bundledTestappSource());
7424
+ rawFiles.splice(0, rawFiles.length, demoPath);
7425
+ }
7334
7426
  if (flags.options) {
7335
7427
  for (const [key, value] of Object.entries(defaultAllSettings()).sort(([a], [b]) => a.localeCompare(b))) {
7336
7428
  console.log(`-${key} value`);
@@ -7341,6 +7433,7 @@ async function main() {
7341
7433
  addCheckpoint("Config Initialization");
7342
7434
  const config = await new Config({ configDir: flags.configDir }).init();
7343
7435
  config.applyCliSettings(flags.settings);
7436
+ const encodingExplicit = flags.settings.has("encoding") || Object.hasOwn(config.parsedSettings, "encoding");
7344
7437
  syncEditorSettings(config);
7345
7438
 
7346
7439
  addCheckpoint("Runtime Registry Init");
@@ -7352,7 +7445,7 @@ async function main() {
7352
7445
  const syntaxDefinitions = await loadSyntaxDefinitions(runtime);
7353
7446
 
7354
7447
  if (flags.cat) {
7355
- await catFiles(rawFiles, colorscheme, syntaxDefinitions, config.getGlobalOption("encoding"));
7448
+ await catFiles(rawFiles, colorscheme, syntaxDefinitions, config.getGlobalOption("encoding"), !encodingExplicit);
7356
7449
  return;
7357
7450
  }
7358
7451
 
@@ -7410,7 +7503,7 @@ async function main() {
7410
7503
 
7411
7504
  const { files, command } = parseInput(rawFiles);
7412
7505
  const jsPlugins = new JsPluginManager();
7413
- const context = { colorscheme, syntaxDefinitions, config, runtime, jsPlugins };
7506
+ const context = { colorscheme, syntaxDefinitions, config, runtime, jsPlugins, encodingExplicit, allowUrl: flags.allowUrl };
7414
7507
  jsPlugins.setContext(context);
7415
7508
  buildMicroGlobal(jsPlugins); // sets globalThis.micro
7416
7509
 
@@ -7922,30 +8015,34 @@ function syncEditorSettings(config) {
7922
8015
  }
7923
8016
  }
7924
8017
 
7925
- async function catFiles(files, colorscheme, syntaxDefinitions, encoding = DEFAULT_SETTINGS.encoding) {
8018
+ async function catFiles(files, colorscheme, syntaxDefinitions, encoding = DEFAULT_SETTINGS.encoding, inferMdcui = true) {
7926
8019
  const targets = files.length > 0 ? files.map((f) => ({ path: f, stdin: false })) : [{ path: null, stdin: true }];
7927
8020
  for (const { path: filePath, stdin } of targets) {
7928
8021
  let content;
7929
8022
  let ansiContent = null;
7930
8023
  let effectivePath = filePath;
8024
+ let effectiveEncoding = normalizeEncodingLabel(encoding);
7931
8025
  if (stdin) {
7932
8026
  const chunks = [];
7933
8027
  for await (const chunk of process.stdin) chunks.push(chunk);
7934
8028
  const decoded = await decodeAndRenderTextBytes(Buffer.concat(chunks), encoding);
7935
8029
  content = decoded.text;
7936
8030
  ansiContent = decoded.ansiText ?? null;
8031
+ effectiveEncoding = decoded.encoding;
7937
8032
  } else if (isHttpUrl(filePath)) {
7938
- const decoded = await fetchTextWithEncoding(filePath, encoding);
8033
+ const decoded = await fetchTextWithEncoding(filePath, encoding, inferMdcui);
7939
8034
  content = decoded.text;
7940
8035
  ansiContent = decoded.ansiText ?? null;
8036
+ effectiveEncoding = decoded.encoding;
7941
8037
  // Use the URL pathname for syntax/md detection (strip query/hash)
7942
8038
  try { effectivePath = new URL(filePath).pathname; } catch { effectivePath = filePath; }
7943
8039
  } else {
7944
- const decoded = await readTextFileWithEncoding(filePath, encoding);
8040
+ const decoded = await readTextFileWithEncoding(filePath, encoding, inferMdcui);
7945
8041
  content = decoded.text;
7946
8042
  ansiContent = decoded.ansiText ?? null;
8043
+ effectiveEncoding = decoded.encoding;
7947
8044
  }
7948
- if (isMdcuiEncoding(encoding)) {
8045
+ if (isMdcuiEncoding(effectiveEncoding)) {
7949
8046
  process.stdout.write(ansiContent ?? content);
7950
8047
  if (!(ansiContent ?? content).endsWith("\n")) process.stdout.write("\n");
7951
8048
  continue;
@@ -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
+ });