@helping-ai-workflow/md2doc 2.8.1 → 2.10.1

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/README.md CHANGED
@@ -127,6 +127,232 @@ the PDF output.
127
127
  | `md2html foo.md --open` | `md2doc foo.md` (open is default) |
128
128
  | `md2html *.md` (output next to source) | `md2doc *.md --out ./build/` (or accept temp output) |
129
129
 
130
+ ## Editing (`--edit`)
131
+
132
+ ```bash
133
+ md2doc --edit foo.md # serve foo.md in the browser editor, open a tab
134
+ md2doc --edit foo.md bar.md # one tab per file
135
+ md2doc --edit foo.md --port 4000 # pin the server to a specific port
136
+ md2doc --edit foo.md --no-open # start the server without launching a browser tab
137
+ ```
138
+
139
+ `--edit` starts a local (`127.0.0.1`-only) server and opens the rendered document in
140
+ your browser. Click anywhere inside a paragraph, heading, list item, or table cell
141
+ to place your cursor and start typing directly — no separate "select then edit" step.
142
+ The block is live-marked as edited; focus leaving the block commits your changes
143
+ automatically. Press `Ctrl+S` to save to disk explicitly.
144
+
145
+ ### Direct editing: click to type
146
+
147
+ Every paragraph, heading, list, and table cell is editable by clicking inside it.
148
+ The rendered formatting (bold, italic, code, links) displays as you type; no
149
+ Markdown syntax characters are shown. This applies to any block that doesn't
150
+ contain unsupported content (see "Degraded blocks" below).
151
+
152
+ | Block type | Interaction |
153
+ |---|---|
154
+ | Paragraph | Click to place caret, type; Enter commits, Esc reverts |
155
+ | Heading | Click to place caret, type; Enter commits, Esc reverts |
156
+ | List | Click to place caret, type; list-specific keys below |
157
+ | Table | Click any cell to edit; Tab/Shift+Tab navigate between cells |
158
+
159
+ ### ⠿ Block menu
160
+
161
+ Every block has a ⠿ button on its left edge. Click it to open a menu with block-level
162
+ operations:
163
+
164
+ | Item | Applies to | Action |
165
+ |---|---|---|
166
+ | **−** (minus) | Heading only | Decrease heading level (# → ... → #####) |
167
+ | **+** (plus) | Heading only | Increase heading level |
168
+ | **MD 原始碼** | All blocks | Discard in-progress edits, switch to raw Markdown source editing |
169
+ | **刪除** | All blocks | Delete the block entirely (absorbs one adjacent blank line; one Ctrl+Z restores it exactly) |
170
+ | **✕** | All blocks | Close the menu |
171
+
172
+ The **MD 原始碼** button is the escape hatch: it reverts any unsaved typing in the
173
+ current block and opens the raw Markdown editor instead, letting you make changes
174
+ WYSIWYG cannot express.
175
+
176
+ ### + Insert menu
177
+
178
+ Every block also has a + button, stacked just above the ⠿ handle in the same left
179
+ gutter. Click it to open a menu for inserting a new block directly below:
180
+
181
+ | Item | Inserts |
182
+ |---|---|
183
+ | **段落** | An empty paragraph |
184
+ | **標題** | An empty `##` heading |
185
+ | **清單** | An empty single-item list |
186
+ | **表格** | A minimal 2×2 table skeleton |
187
+ | **程式碼** | An empty fenced code block |
188
+
189
+ The new block is inserted below the block whose + you clicked, and the cursor lands
190
+ directly in it — the first (body) cell for a table, the raw source editor for a code
191
+ block, and the always-on WYSIWYG surface (pre-selected, so your first keystroke
192
+ replaces the placeholder) for everything else. The whole insert is a single Ctrl+Z
193
+ step — one undo removes the block entirely.
194
+
195
+ ### Paragraph and heading editing
196
+
197
+ Click inside a paragraph or heading to place your cursor. Type and format text
198
+ normally; the rendered marks (bold, italic, code, links) show as you type.
199
+
200
+ **Selection toolbar**: when you select text inside a paragraph or heading, a
201
+ floating toolbar appears with formatting buttons:
202
+
203
+ | Button | Action |
204
+ |---|---|
205
+ | **B** | Toggle bold (`**text**`) |
206
+ | **I** | Toggle italic (`*text*`) |
207
+ | **`<>`** | Wrap selection in backticks (`` `code` ``) |
208
+ | **🔗** | Wrap selection as a link; click to edit the URL |
209
+
210
+ **Key shortcuts**:
211
+
212
+ | Key | Action |
213
+ |---|---|
214
+ | Enter | Commit and close the editor |
215
+ | Shift + Enter | Insert a line break within the paragraph |
216
+ | Esc | Revert all edits and close the editor |
217
+ | Ctrl + Z | Step backward through the paragraph's local edit history, then cascade to document-level undos once exhausted |
218
+ | Ctrl + Y (or Ctrl + Shift + Z) | Step forward through the paragraph's local edit history, then cascade to document-level redos once exhausted |
219
+
220
+ ### List editing
221
+
222
+ Click inside a list item to place your cursor and type. Lists support structural editing:
223
+
224
+ | Key | Action |
225
+ |---|---|
226
+ | Enter | Split the current item into two siblings at the caret; empty item + Enter removes it and ends the burst |
227
+ | Shift + Enter | Insert a line break (`<br>`) within the item (does not split) |
228
+ | Tab | Indent the current item (becomes a child of the previous sibling; no-op if no previous sibling) |
229
+ | Shift + Tab | Outdent the current item (moves after its parent; no-op at top level) |
230
+ | Esc | Revert all edits and close the list |
231
+ | Ctrl + Z / Ctrl + Y | Step through the list's local edit history, then cascade to document-level history |
232
+
233
+ An empty list (all items removed) is cleaned up automatically — the block is deleted
234
+ entirely and the document structure stays consistent.
235
+
236
+ ### Table editing
237
+
238
+ Click any table cell to edit it. The table is treated as a single editing unit —
239
+ focus remains inside the table until you press Esc, click outside, or navigate away.
240
+
241
+ **Cell navigation**:
242
+
243
+ | Key / Action | Effect |
244
+ |---|---|
245
+ | Click a cell | Move to that cell and edit |
246
+ | Tab | Move to the next cell (left-to-right, row by row; no-op — stays put — on the last cell) |
247
+ | Shift + Tab | Move to the previous cell |
248
+ | Enter | Insert a line break (`<br>`) within the cell (does NOT commit the table) |
249
+ | Esc | Revert the entire table session (all cells) and discard all changes |
250
+ | Ctrl + Z / Ctrl + Y | Step through the table's local edit history, then cascade to document-level history |
251
+
252
+ **Column grip**: hover any cell in a column — a small horizontal 6-dot grip handle
253
+ appears just above it (Notion-style affordance, replacing the old edge-hover-only
254
+ target). Click the grip to open the column menu:
255
+
256
+ | Option | Action |
257
+ |---|---|
258
+ | **刪除欄** | Delete the column (last column protected) |
259
+ | **對齊** | Cycle alignment: left → center → right → left (no unset state; use Ctrl+Z to revert a cycle) |
260
+
261
+ A **+** insert bubble separately appears when you hover the top boundary between
262
+ two columns — click it to insert an empty column there.
263
+
264
+ **Row grip**: hover any cell in a body row — a small vertical 6-dot grip handle
265
+ appears just left of it (the header row never gets one; it isn't deletable or
266
+ draggable). Click the grip to open the row menu:
267
+
268
+ | Option | Action |
269
+ |---|---|
270
+ | **刪除列** | Delete the row (header row and last body row protected) |
271
+ | *Drag* | Press and hold the row grip and drag up/down; drop to reorder (body rows only; header fixed) |
272
+
273
+ A **+** insert bubble separately appears when you hover the left boundary between
274
+ two rows — click it to insert an empty row there.
275
+
276
+ **Edited tables emit minimal form**: tables that you edit are saved with single-space
277
+ padding and minimal separators (`|---|`) to keep the Markdown readable and
278
+ version-control-friendly.
279
+
280
+ ### Degraded blocks: code, diagrams, images, math
281
+
282
+ Blocks containing content WYSIWYG cannot represent (code fences, Mermaid/Graphviz/
283
+ WaveDrom diagrams, images, LaTeX math, or unstyled HTML) automatically degrade to
284
+ raw-edit mode: click the block to open the raw Markdown source in a textarea, make
285
+ your changes, then press `Ctrl+Enter` to commit or `Esc` to cancel.
286
+
287
+ If a WYSIWYG session encounters unsupported content mid-edit (e.g., via a rich
288
+ paste), it automatically falls back to raw-edit with the block's untouched
289
+ on-disk source, preserving your unsaved work context.
290
+
291
+ ### Burst undo: local history + cascade
292
+
293
+ Ctrl+Z / Ctrl+Y step through a block's local edit history first (the changes you
294
+ made in the current editing session). Once that history is exhausted, the next
295
+ Ctrl+Z cascades out to the document-level undo stack, covering all committed edits.
296
+ This lets you undo/redo recent changes within a block without affecting work in
297
+ other blocks.
298
+
299
+ ### Whole-document controls
300
+
301
+ | Key / control | Action |
302
+ |---|---|
303
+ | `Ctrl`/`⌘` + `S` | Save the document to disk (explicit; changes commit locally when focus leaves) |
304
+ | `Ctrl`/`⌘` + `Z` | Undo: first steps through the focused block's local history, then cascades to document level |
305
+ | `Ctrl`/`⌘` + `Y` (or `Ctrl`/`⌘` + `Shift` + `Z`) | Redo: mirrors undo's cascade behavior |
306
+ | Click outside any block | Commit any open block if changed, dismiss the ⠿/+ menu |
307
+ | `Esc` (at document level, not in an open block) | Close the ⠿/+ menu |
308
+
309
+ ### Auto-commit on focus change
310
+
311
+ When focus leaves a block:
312
+ - If the block is **unchanged**, it closes silently.
313
+ - If the block is **changed**, it commits automatically to the undo stack (but not to disk — you must press Ctrl+S for that).
314
+
315
+ This means switching between blocks flows naturally — you never get stuck waiting to
316
+ confirm or cancel.
317
+
318
+ ### Save and conflict handling
319
+
320
+ **Save is explicit, not autosave**. Your changes are committed to the undo stack
321
+ immediately when you press Enter or focus leaves a block, but they're not written
322
+ to disk until you press `Ctrl+S`.
323
+
324
+ **Conflict detection**: each save carries the file's last-known modification time.
325
+ If the file on disk has changed since the page loaded (edited elsewhere, or saved
326
+ from another tab), the save is rejected with a conflict banner instead of silently
327
+ overwriting — reload the page to pick up the newer content, then re-apply your edit.
328
+
329
+ ### Fidelity guarantee
330
+
331
+ Only the lines inside the block(s) you actually commit are rewritten. Every other
332
+ line — including whitespace-sensitive formatting like padded table columns, trailing
333
+ spaces, and the file's original EOF-newline state — is left byte-for-byte untouched,
334
+ whether you save with zero edits or after several. This applies both to unchanged
335
+ blocks and to blocks you open and then revert.
336
+
337
+ ### Known Phase-3 limitations
338
+
339
+ **First diagram/math type requires reload**: if a committed edit introduces the
340
+ *first* occurrence of a diagram type (Mermaid or WaveDrom) that the document didn't
341
+ already contain when the page was loaded, that diagram library was never embedded
342
+ into the page, so the new block renders as raw source until you reload the browser
343
+ tab (no need to restart `md2doc --edit`). The same applies to math: if a committed
344
+ edit introduces the document's *first* `math` fence or `$…$`/`$$…$$` expression,
345
+ the KaTeX stylesheet was never injected, so the equation renders unstyled until you
346
+ reload the tab.
347
+
348
+ **Alignment cycle has no unset state**: the **對齊** button cycles through left,
349
+ center, and right alignment. To revert an unwanted alignment, use Ctrl+Z.
350
+
351
+ ---
352
+
353
+ `--edit` does not support directory inputs yet (planned for a later phase) and
354
+ cannot be combined with `--html` / `--pdf` / `--out` / `--bake-svg`.
355
+
130
356
  ## Supported diagram types
131
357
 
132
358
  Embedded in fenced code blocks inside your Markdown:
package/bin/md2doc.js CHANGED
@@ -33,6 +33,8 @@ function printHelp() {
33
33
  ' --no-open Skip the viewer launch.',
34
34
  ' --quiet Suppress per-file progress messages.',
35
35
  ' --bake-svg Pre-render mermaid/wavedrom to inert SVG (HTML output only; needs Chromium).',
36
+ ' --edit Serve listed .md files in the browser editor (localhost).',
37
+ ' --port <n> Pin the editor server port (only with --edit).',
36
38
  ' --version, -v Print version.',
37
39
  ' --help, -h Print this help.',
38
40
  ''
@@ -47,6 +49,8 @@ function parseArgs(argv) {
47
49
  let openExplicit = null; // null = unset; true/false = user-specified
48
50
  let quiet = false;
49
51
  let bakeSvg = false;
52
+ let edit = false;
53
+ let port = null;
50
54
 
51
55
  for (let i = 0; i < argv.length; i++) {
52
56
  const a = argv[i];
@@ -64,6 +68,21 @@ function parseArgs(argv) {
64
68
  if (a === '--no-open') { openExplicit = false; continue; }
65
69
  if (a === '--quiet') { quiet = true; continue; }
66
70
  if (a === '--bake-svg') { bakeSvg = true; continue; }
71
+ if (a === '--edit') { edit = true; continue; }
72
+ if (a === '--port') {
73
+ i++;
74
+ if (i >= argv.length) {
75
+ process.stderr.write('error: --port requires a value\n');
76
+ process.exit(2);
77
+ }
78
+ const n = Number(argv[i]);
79
+ if (!Number.isInteger(n) || n < 0) {
80
+ process.stderr.write('error: --port must be a non-negative integer\n');
81
+ process.exit(2);
82
+ }
83
+ port = n;
84
+ continue;
85
+ }
67
86
  if (a === '--out') {
68
87
  if (out !== null) {
69
88
  process.stderr.write('error: --out specified more than once\n');
@@ -105,7 +124,7 @@ function parseArgs(argv) {
105
124
  open = (out === null);
106
125
  }
107
126
 
108
- return { inputs, formats, formatsExplicit, out, open, quiet, bakeSvg };
127
+ return { inputs, formats, formatsExplicit, out, open, quiet, bakeSvg, edit, port };
109
128
  }
110
129
 
111
130
  function shortHash(absPath) {
@@ -200,38 +219,7 @@ function resolveOutputs(args) {
200
219
  return pairs;
201
220
  }
202
221
 
203
- function isWSL() {
204
- if (process.platform !== 'linux') return false;
205
- if (process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP) return true;
206
- try {
207
- return fs.existsSync('/proc/sys/fs/binfmt_misc/WSLInterop');
208
- } catch (_) {
209
- return false;
210
- }
211
- }
212
-
213
- function openViewer(filePath) {
214
- const platform = process.platform;
215
- let cmd, args;
216
- if (platform === 'darwin') {
217
- cmd = 'open'; args = [filePath];
218
- } else if (platform === 'win32') {
219
- cmd = 'cmd'; args = ['/c', 'start', '""', filePath];
220
- } else if (isWSL()) {
221
- const r = spawnSync('wslpath', ['-w', filePath], { encoding: 'utf8' });
222
- if (r.status === 0 && r.stdout) {
223
- cmd = 'explorer.exe'; args = [r.stdout.trim()];
224
- } else {
225
- cmd = 'xdg-open'; args = [filePath];
226
- }
227
- } else {
228
- cmd = 'xdg-open'; args = [filePath];
229
- }
230
- const r = spawnSync(cmd, args, { stdio: 'ignore' });
231
- if (r.error) {
232
- process.stderr.write('warning: could not launch viewer for ' + filePath + ': ' + r.error.message + '\n');
233
- }
234
- }
222
+ const { openViewer } = require('../lib/editor/open.js');
235
223
 
236
224
  function main() {
237
225
  const args = parseArgs(process.argv.slice(2));
@@ -243,6 +231,31 @@ function main() {
243
231
  }
244
232
  }
245
233
 
234
+ if (args.edit) {
235
+ if (args.formatsExplicit || args.out !== null || args.bakeSvg) {
236
+ process.stderr.write('error: --edit cannot be combined with --html/--pdf/--out/--bake-svg\n');
237
+ process.exit(2);
238
+ }
239
+ for (const input of args.inputs) {
240
+ if (!fs.existsSync(input)) {
241
+ process.stderr.write('error: input not found: ' + input + '\n');
242
+ process.exit(1);
243
+ }
244
+ if (fs.statSync(input).isDirectory()) {
245
+ process.stderr.write('error: --edit directory mode is not implemented yet (Phase 3)\n');
246
+ process.exit(2);
247
+ }
248
+ }
249
+ const { startEditSession } = require('../lib/editor/cli.js');
250
+ startEditSession({ files: args.inputs, port: args.port, open: args.open, quiet: args.quiet })
251
+ .catch((e) => { process.stderr.write('error: ' + e.message + '\n'); process.exit(1); });
252
+ return; // do not fall through to render pipeline
253
+ }
254
+ if (args.port !== null) {
255
+ process.stderr.write('error: --port is only valid with --edit\n');
256
+ process.exit(2);
257
+ }
258
+
246
259
  const pairs = resolveOutputs(args);
247
260
  const outputs = [];
248
261
  for (const { input, output } of pairs) {
@@ -0,0 +1,75 @@
1
+ 'use strict';
2
+ const { marked } = require('marked');
3
+
4
+ // Top-level marked tokens → 1-indexed inclusive line ranges.
5
+ // `space` tokens (blank-line runs) advance the cursor but are not blocks.
6
+ // A token's `raw` may include trailing newlines (blank lines marked folded
7
+ // in); the block's endLine excludes those so ranges never claim blank lines
8
+ // that separate blocks.
9
+
10
+ function trimmedLineCount(raw) {
11
+ const content = raw.replace(/\n+$/, '');
12
+ return content === '' ? 1 : content.split('\n').length;
13
+ }
14
+
15
+ // Recursively emit one `li` block per list item at every depth.
16
+ // Recursion order (mirrored by Task 4's render walk):
17
+ // 1. Push the item's own block.
18
+ // 2. Recurse into that item's nested child lists, left-to-right.
19
+ // 3. Move on to the next sibling item.
20
+ // `nextId` is a shared box { v: <int> } so ids stay 0..n-1 in document order.
21
+ function pushListItemBlocks(listToken, cursor, indent, blocks, nextId) {
22
+ for (const item of listToken.items) {
23
+ const childListTokens = item.tokens.filter((t) => t.type === 'list');
24
+ const totalSpan = trimmedLineCount(item.raw);
25
+ const childSpan = childListTokens.reduce((s, t) => s + trimmedLineCount(t.raw), 0);
26
+ const ownSpan = totalSpan - childSpan;
27
+ const block = {
28
+ id: nextId.v++,
29
+ type: 'li',
30
+ startLine: cursor,
31
+ endLine: cursor + ownSpan - 1,
32
+ listType: item.task ? 'task' : (listToken.ordered ? 'ol' : 'ul'),
33
+ indent,
34
+ };
35
+ if (item.task) block.checked = !!item.checked;
36
+ blocks.push(block);
37
+ let childCursor = cursor + ownSpan;
38
+ for (const ct of childListTokens) {
39
+ pushListItemBlocks(ct, childCursor, indent + 1, blocks, nextId);
40
+ childCursor += (ct.raw.match(/\n/g) || []).length;
41
+ }
42
+ // advance cursor by full item raw newlines; fall back to totalSpan if raw
43
+ // has no trailing newline (EOF item).
44
+ cursor += (item.raw.match(/\n/g) || []).length || totalSpan;
45
+ }
46
+ }
47
+
48
+ function buildBlockMap(mdText) {
49
+ const tokens = marked.lexer(mdText);
50
+ const blocks = [];
51
+ let cursor = 1; // current line number of the token's first character
52
+ const nextId = { v: 0 };
53
+ for (const t of tokens) {
54
+ const rawNewlines = (t.raw.match(/\n/g) || []).length;
55
+ if (t.type === 'list') {
56
+ pushListItemBlocks(t, cursor, 0, blocks, nextId);
57
+ } else if (t.type !== 'space') {
58
+ const content = t.raw.replace(/\n+$/, '');
59
+ const span = content === '' ? 1 : content.split('\n').length;
60
+ blocks.push({
61
+ id: nextId.v++,
62
+ type: t.type,
63
+ startLine: cursor,
64
+ endLine: cursor + span - 1,
65
+ });
66
+ }
67
+ // advance by full raw (including folded trailing blank lines).
68
+ // If raw has no trailing newline (EOF), the next token doesn't exist,
69
+ // so the off-by-one is unobservable.
70
+ cursor += rawNewlines;
71
+ }
72
+ return { blocks, lineCount: mdText.split('\n').length };
73
+ }
74
+
75
+ module.exports = { buildBlockMap };
@@ -0,0 +1,23 @@
1
+ 'use strict';
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const { createEditorServer } = require('./server.js');
5
+
6
+ const CLIENT_SRC = fs.readFileSync(path.join(__dirname, 'client.js'), 'utf8');
7
+
8
+ async function startEditSession({ files, port = null, open = true, quiet = false }) {
9
+ const srv = await createEditorServer({
10
+ files,
11
+ clientJs: CLIENT_SRC,
12
+ listenPort: port === null ? 0 : port,
13
+ });
14
+ const urls = files.map((f) => srv.urlFor(f));
15
+ for (const u of urls) process.stdout.write(u + '\n');
16
+ if (open) {
17
+ const { openViewer } = require('./open.js');
18
+ for (const u of urls) openViewer(u);
19
+ }
20
+ srv.server.on('close', () => process.exit(0));
21
+ return srv;
22
+ }
23
+ module.exports = { startEditSession };