@helping-ai-workflow/md2doc 2.8.1 → 2.10.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.
@@ -0,0 +1,59 @@
1
+ 'use strict';
2
+ const fs = require('fs');
3
+ const { spawnSync } = require('child_process');
4
+
5
+ function isWSL() {
6
+ if (process.platform !== 'linux') return false;
7
+ if (process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP) return true;
8
+ try {
9
+ return fs.existsSync('/proc/sys/fs/binfmt_misc/WSLInterop');
10
+ } catch (_) {
11
+ return false;
12
+ }
13
+ }
14
+
15
+ const URL_RE = /^https?:\/\//i;
16
+
17
+ function defaultWslpath(target) {
18
+ return spawnSync('wslpath', ['-w', target], { encoding: 'utf8' });
19
+ }
20
+
21
+ // Pure decision helper: given a target (file path or URL) and injectable
22
+ // environment probes, returns the { cmd, args } to spawn. No side effects
23
+ // beyond the injected wslpathFn (which itself may spawn `wslpath`).
24
+ //
25
+ // WSL note: `wslpath -w '<a URL>'` does NOT fail — it mangles the string
26
+ // into a garbage Windows-flavoured path (e.g. 'http://127.0.0.1:1234/edit/0'
27
+ // -> 'http\127.0.0.11234\edit\0') and still exits 0. So a URL target must be
28
+ // detected up front and routed straight to explorer.exe, which accepts URLs
29
+ // directly on Windows; the wslpath conversion path is for real file paths
30
+ // only.
31
+ function resolveViewerCommand(target, { platform = process.platform, wsl = isWSL(), wslpathFn = defaultWslpath } = {}) {
32
+ if (platform === 'darwin') {
33
+ return { cmd: 'open', args: [target] };
34
+ }
35
+ if (platform === 'win32') {
36
+ return { cmd: 'cmd', args: ['/c', 'start', '""', target] };
37
+ }
38
+ if (wsl) {
39
+ if (URL_RE.test(target)) {
40
+ return { cmd: 'explorer.exe', args: [target] };
41
+ }
42
+ const r = wslpathFn(target);
43
+ if (r.status === 0 && r.stdout) {
44
+ return { cmd: 'explorer.exe', args: [r.stdout.trim()] };
45
+ }
46
+ return { cmd: 'xdg-open', args: [target] };
47
+ }
48
+ return { cmd: 'xdg-open', args: [target] };
49
+ }
50
+
51
+ function openViewer(filePath) {
52
+ const { cmd, args } = resolveViewerCommand(filePath);
53
+ const r = spawnSync(cmd, args, { stdio: 'ignore' });
54
+ if (r.error) {
55
+ process.stderr.write('warning: could not launch viewer for ' + filePath + ': ' + r.error.message + '\n');
56
+ }
57
+ }
58
+
59
+ module.exports = { openViewer, isWSL, resolveViewerCommand };
@@ -0,0 +1,190 @@
1
+ 'use strict';
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const http = require('http');
5
+ const { renderMarkdown } = require('../md2doc.js');
6
+
7
+ const LINEOPS_SRC = fs.readFileSync(path.join(__dirname, 'lineops.js'), 'utf8');
8
+ const INLINE_MD_SRC = fs.readFileSync(path.join(__dirname, 'inline-md.js'), 'utf8');
9
+ const TABLE_MD_SRC = fs.readFileSync(path.join(__dirname, 'table-md.js'), 'utf8');
10
+ // Task 4 (Phase 3): list-md.js's browser factory reads `root.md2docInlineMd`
11
+ // directly (same pattern table-md.js uses) — must land after inline-md.js,
12
+ // same as table-md.js above. Order relative to table-md.js itself doesn't
13
+ // matter (list-md.js never reads window.md2docTableMd), but it's injected
14
+ // right after it to keep the two sibling serializers grouped together.
15
+ const LIST_MD_SRC = fs.readFileSync(path.join(__dirname, 'list-md.js'), 'utf8');
16
+ const HISTORY_SRC = fs.readFileSync(path.join(__dirname, 'history.js'), 'utf8');
17
+
18
+ function readJson(req, limitBytes = 50 * 1024 * 1024) {
19
+ return new Promise((resolve, reject) => {
20
+ // Accumulate raw Buffers and decode ONCE at the end. Per-chunk string
21
+ // concatenation (`buf += chunk`) decodes each chunk independently, so a
22
+ // multi-byte UTF-8 character straddling a TCP chunk boundary becomes
23
+ // U+FFFD — on a multi-MB CJK document that silently corrupts one
24
+ // character per unlucky chunk boundary (seen in the wild: 9 mangled
25
+ // chars in one zero-edit save).
26
+ const chunks = [];
27
+ let total = 0;
28
+ req.on('data', (c) => {
29
+ chunks.push(c);
30
+ total += c.length;
31
+ if (total > limitBytes) { reject(new Error('payload too large')); req.destroy(); }
32
+ });
33
+ req.on('end', () => {
34
+ try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); } catch (e) { reject(e); }
35
+ });
36
+ });
37
+ }
38
+
39
+ function send(res, status, obj) {
40
+ const body = JSON.stringify(obj);
41
+ res.writeHead(status, { 'content-type': 'application/json' });
42
+ res.end(body);
43
+ }
44
+
45
+ async function createEditorServer({ files, idleTimeoutMs = 30000, clientJs = '', listenPort = 0 }) {
46
+ const absFiles = files.map((f) => path.resolve(f));
47
+ let idleTimer = null;
48
+ let started = false;
49
+
50
+ function bumpIdle(server) {
51
+ if (!started) return;
52
+ if (idleTimer) clearTimeout(idleTimer);
53
+ idleTimer = setTimeout(() => server.close(), idleTimeoutMs);
54
+ if (idleTimer.unref) idleTimer.unref();
55
+ }
56
+
57
+ const server = http.createServer(async (req, res) => {
58
+ try {
59
+ const url = new URL(req.url, 'http://127.0.0.1');
60
+ const editMatch = url.pathname.match(/^\/edit\/(\d+)$/);
61
+
62
+ // Cross-origin defense for every state-changing POST route: a
63
+ // cross-site "simple" POST (form submission, no CORS preflight) cannot
64
+ // set a non-simple content-type header like application/json, so
65
+ // requiring it here means a browser blocks the request before it ever
66
+ // reaches this server — this server never sends CORS headers, so the
67
+ // browser would otherwise let the request fire-and-forget cross-origin.
68
+ // /api/ping is included; lib/editor/client.js's fetch calls already
69
+ // send this header for render, save, and ping.
70
+ const STATE_CHANGING_POST_PATHS = new Set(['/api/render', '/api/save', '/api/ping']);
71
+ if (req.method === 'POST' && STATE_CHANGING_POST_PATHS.has(url.pathname)) {
72
+ const contentType = String(req.headers['content-type'] || '');
73
+ if (!/^application\/json\b/i.test(contentType)) {
74
+ return send(res, 415, { error: 'content-type must be application/json' });
75
+ }
76
+ }
77
+
78
+ if (req.method === 'GET' && editMatch) {
79
+ const fileId = Number(editMatch[1]);
80
+ const file = absFiles[fileId];
81
+ if (!file || !fs.existsSync(file)) return send(res, 404, { error: 'unknown file' });
82
+ const mdText = fs.readFileSync(file, 'utf8');
83
+ const mtimeMs = fs.statSync(file).mtimeMs;
84
+ const { html, blocks } = await renderMarkdown(mdText, file, { editMode: true });
85
+ const payload = JSON.stringify({
86
+ fileId, mtimeMs, lines: mdText.split('\n'), blocks,
87
+ });
88
+ const inject =
89
+ `<script>window.__ED__ = ${payload.replace(/</g, '\\u003c')}</script>\n` +
90
+ `<script>${LINEOPS_SRC}</script>\n` +
91
+ `<script>${INLINE_MD_SRC}</script>\n` +
92
+ `<script>${TABLE_MD_SRC}</script>\n` +
93
+ `<script>${LIST_MD_SRC}</script>\n` +
94
+ `<script>${HISTORY_SRC}</script>\n` +
95
+ `<script>${clientJs}</script>\n`;
96
+ // Splice at the LAST "</body>" — the document's real closing tag.
97
+ // The first occurrence can sit inside an inlined diagram bundle's JS
98
+ // string literal (mermaid's DOMPurify source contains "</body>");
99
+ // String.replace would inject __ED__ mid-bundle and break every
100
+ // script on the page. Also avoids replace()'s "$" substitution rules.
101
+ const bodyAt = html.lastIndexOf('</body>');
102
+ const out = bodyAt !== -1
103
+ ? html.slice(0, bodyAt) + inject + html.slice(bodyAt)
104
+ : html + inject;
105
+ started = true;
106
+ bumpIdle(server);
107
+ // no-store: the page embeds a snapshot of the client runtime AND the
108
+ // file's lines/mtime — a cached copy is stale code + a guaranteed
109
+ // mtime conflict after any external edit.
110
+ res.writeHead(200, {
111
+ 'content-type': 'text/html; charset=utf-8',
112
+ 'cache-control': 'no-store',
113
+ });
114
+ return res.end(out);
115
+ }
116
+
117
+ if (req.method === 'POST' && url.pathname === '/api/render') {
118
+ const { fileId, content } = await readJson(req);
119
+ const file = absFiles[fileId];
120
+ if (!file) return send(res, 404, { error: 'unknown file' });
121
+ const { bodyHtml, blocks } = await renderMarkdown(content, file, { editMode: true });
122
+ bumpIdle(server);
123
+ return send(res, 200, { bodyHtml, blocks });
124
+ }
125
+
126
+ if (req.method === 'POST' && url.pathname === '/api/save') {
127
+ const { fileId, content, baseMtimeMs } = await readJson(req);
128
+ const file = absFiles[fileId];
129
+ if (!file) return send(res, 404, { error: 'unknown file' });
130
+ // baseMtimeMs is REQUIRED, not optional: skipping the compare when
131
+ // it's missing would let a stale editor tab silently clobber a
132
+ // newer on-disk edit (the whole point of the mtime guard).
133
+ if (baseMtimeMs === undefined || baseMtimeMs === null) {
134
+ return send(res, 400, { error: 'baseMtimeMs is required' });
135
+ }
136
+ const cur = fs.statSync(file).mtimeMs;
137
+ if (cur !== baseMtimeMs) {
138
+ return send(res, 409, { error: 'mtime-conflict', mtimeMs: cur });
139
+ }
140
+ const tmp = file + '.md2doc-tmp';
141
+ fs.writeFileSync(tmp, content, 'utf8');
142
+ fs.renameSync(tmp, file);
143
+ bumpIdle(server);
144
+ return send(res, 200, { mtimeMs: fs.statSync(file).mtimeMs });
145
+ }
146
+
147
+ if (req.method === 'POST' && url.pathname === '/api/ping') {
148
+ bumpIdle(server);
149
+ res.writeHead(204);
150
+ return res.end();
151
+ }
152
+
153
+ return send(res, 404, { error: 'not found' });
154
+ } catch (e) {
155
+ return send(res, 500, { error: String((e && e.message) || e) });
156
+ }
157
+ });
158
+
159
+ // EADDRINUSE (or any other listen-time error, e.g. a pinned --port that's
160
+ // already taken) previously fired as an uncaught 'error' event on `server`
161
+ // with nothing listening, since a plain `server.listen(..., resolve)`
162
+ // Promise never rejects — it only ever resolves on the 'listening' event.
163
+ // That crashed the whole process, bypassing bin's `.catch`. Listen for
164
+ // 'error' too and reject the promise so the caller gets a normal rejection.
165
+ await new Promise((resolve, reject) => {
166
+ function onError(err) {
167
+ server.removeListener('listening', onListening);
168
+ reject(err);
169
+ }
170
+ function onListening() {
171
+ server.removeListener('error', onError);
172
+ resolve();
173
+ }
174
+ server.once('error', onError);
175
+ server.once('listening', onListening);
176
+ server.listen(listenPort, '127.0.0.1');
177
+ });
178
+ const port = server.address().port;
179
+ return {
180
+ server,
181
+ port,
182
+ urlFor(absPath) {
183
+ const i = absFiles.indexOf(path.resolve(absPath));
184
+ return i === -1 ? null : `http://127.0.0.1:${port}/edit/${i}`;
185
+ },
186
+ close() { if (idleTimer) clearTimeout(idleTimer); server.close(); },
187
+ };
188
+ }
189
+
190
+ module.exports = { createEditorServer };
@@ -0,0 +1,169 @@
1
+ 'use strict';
2
+ (function (root, factory) {
3
+ if (typeof module === 'object' && module.exports) {
4
+ module.exports = factory(require('./inline-md.js'));
5
+ } else {
6
+ root.md2docTableMd = factory(root.md2docInlineMd);
7
+ }
8
+ })(typeof self !== 'undefined' ? self : this, function (inlineMd) {
9
+
10
+ // table-md.js — table DOM -> minimal-form GFM markdown (Phase-2 Task 5).
11
+ //
12
+ // ── Renderer finding (verified against lib/md2doc.js's renderer.table,
13
+ // ~line 650, BEFORE writing serializeTable below — required by the
14
+ // task brief) ───────────────────────────────────────────────────────
15
+ // Per-column alignment: renderer.table's `alignStyle(cell)` helper emits
16
+ // `style="text-align:<left|right|center>"` on BOTH <th> and <td> for that
17
+ // column ONLY when marked's lexed `cell.align` is truthy (an explicit GFM
18
+ // `:---` / `---:` / `:---:` separator was used). A column with the plain
19
+ // `---` separator (marked's `cell.align === null`) gets NO style
20
+ // attribute at all — not `style="text-align:left"`, nothing. So
21
+ // alignment must be read from the PRESENCE/VALUE of the `style`
22
+ // attribute — read here from the header row's <th> cells, since
23
+ // alignStyle() is applied identically per-column to every row — never
24
+ // from a class or an `align=` attribute; renderer.table emits neither.
25
+ //
26
+ // classifyColumns() (same file) ALSO stamps `class="cell-narrow"` /
27
+ // `class="cell-prose"` per column — those are rendering-WIDTH hints (see
28
+ // `.content table col.col-narrow { width: 1% }` in the CSS block) with
29
+ // nothing to do with text alignment. Reading them as alignment would be a
30
+ // bug; they are deliberately never consulted below.
31
+ //
32
+ // ── Emission form (spec §4 / task-5 brief) ─────────────────────────────
33
+ // '| a | b |' rows (single-space padding); '|---|' separator row — no
34
+ // padding around the dashes, ':' alignment variants (':---' / '---:' /
35
+ // ':---:'), dashes never stretched to content width; literal '|' inside
36
+ // a cell -> '&#124;'; a cell newline is the literal '<br>' inline-md.js's
37
+ // serializeInline() already emits for both a real <br> node and a
38
+ // contenteditable <div> line-split boundary — AND (final-review Finding 1
39
+ // defense-in-depth) any raw '\n'/'\r' that still reaches a text node is
40
+ // collapsed to '<br>' here too, via escapeNewlines() below, so a cell can
41
+ // never split serializeTable()'s output into more physical lines than
42
+ // it has rows. No trailing whitespace on any emitted line (every row/
43
+ // separator line is built as '|'-delimited fields, so it always ends in
44
+ // '|', never in whitespace, by construction). A '|' inside a <code> span
45
+ // has no faithful gate-safe emission — see cellHasCodePipe() below —
46
+ // and reports 'CODE' unsupported instead of corrupting it.
47
+ //
48
+ // Constrained (same as inline-md.js, and for the same reason: the node
49
+ // test drives this with the hand-rolled element stub from
50
+ // test/inline-md.test.js) to childNodes / nodeType / nodeName /
51
+ // textContent / getAttribute — NO querySelector/querySelectorAll.
52
+
53
+ function elementChildren(node) {
54
+ const out = [];
55
+ for (let i = 0; i < node.childNodes.length; i++) {
56
+ const c = node.childNodes[i];
57
+ if (c.nodeType === 1) out.push(c);
58
+ }
59
+ return out;
60
+ }
61
+
62
+ function firstChildNamed(node, name) {
63
+ const kids = elementChildren(node);
64
+ for (let i = 0; i < kids.length; i++) {
65
+ if (kids[i].nodeName === name) return kids[i];
66
+ }
67
+ return null;
68
+ }
69
+
70
+ function childrenNamed(node, name) {
71
+ return elementChildren(node).filter((c) => c.nodeName === name);
72
+ }
73
+
74
+ function isCell(node) {
75
+ return node.nodeName === 'TH' || node.nodeName === 'TD';
76
+ }
77
+
78
+ // '&#124;' — the postprocess literal-pipe escape (spec §4). Applied AFTER
79
+ // inline serialization: inline-md.js's own escaping table has no reason
80
+ // to know about the table-cell delimiter, so it never touches a bare '|'.
81
+ function escapePipes(s) {
82
+ return s.split('|').join('&#124;');
83
+ }
84
+
85
+ // Finding 1 (final-review, defense in depth): inline-md.js's escapeText()
86
+ // and serializeCode() never touch '\n' — a text node carrying a raw
87
+ // embedded newline (e.g. a paste path that bypassed client.js's own
88
+ // <br>-segmentation, or any future caller of serializeInline()) would
89
+ // otherwise leak a literal line break into this cell's md, splitting one
90
+ // table row into an orphan-cell-line the gate can't parse. Collapse any
91
+ // raw newline sequence to the same literal '<br>' token a real <br>
92
+ // element already serializes to (case 4 in table-md.test.js / gate-compat
93
+ // .test.js) — applied at the same postprocess spot as escapePipes().
94
+ function escapeNewlines(s) {
95
+ return s.replace(/\r\n|\r|\n/g, '<br>');
96
+ }
97
+
98
+ // Finding 4: a '|' inside a <code> span has no faithful gate-safe
99
+ // emission — escapePipes() above runs on the WHOLE cell's serialized md
100
+ // and can't tell a code span's content from plain text, so naively
101
+ // applying it would corrupt the code span (turning `` `a|b` `` into the
102
+ // broken `` `a&#124;b` ``, which never decodes back inside a code span).
103
+ // `\|` (some other renderers' escape) is the gate's documented trap, so
104
+ // there's no safe escape at all here — degrade-never-lose: detect a CODE
105
+ // element anywhere in the cell whose textContent contains '|' and report
106
+ // it unsupported instead, same signal an IMG/SPAN/etc. already uses to
107
+ // degrade the whole table to raw-edit (see canWysiwygForTable() in
108
+ // client.js). Recurses through the cell's element children looking for
109
+ // CODE — constrained to childNodes/nodeType/nodeName like the rest of
110
+ // this file (no querySelector, per the node-test stub).
111
+ function cellHasCodePipe(node) {
112
+ const kids = elementChildren(node);
113
+ for (let i = 0; i < kids.length; i++) {
114
+ const k = kids[i];
115
+ if (k.nodeName === 'CODE') {
116
+ if (k.textContent.indexOf('|') !== -1) return true;
117
+ } else if (cellHasCodePipe(k)) {
118
+ return true;
119
+ }
120
+ }
121
+ return false;
122
+ }
123
+
124
+ function cellAlign(cell) {
125
+ const style = cell.getAttribute('style');
126
+ if (!style) return null;
127
+ const m = /text-align\s*:\s*(left|right|center)/.exec(style);
128
+ return m ? m[1] : null;
129
+ }
130
+
131
+ function sepCellFor(align) {
132
+ if (align === 'left') return ':---';
133
+ if (align === 'right') return '---:';
134
+ if (align === 'center') return ':---:';
135
+ return '---';
136
+ }
137
+
138
+ function serializeRow(cells, unsupported) {
139
+ const parts = cells.map((cell) => {
140
+ if (cellHasCodePipe(cell)) unsupported.push('CODE');
141
+ const res = inlineMd.serializeInline(cell);
142
+ res.unsupported.forEach((u) => unsupported.push(u));
143
+ return escapeNewlines(escapePipes(res.md));
144
+ });
145
+ return '| ' + parts.join(' | ') + ' |';
146
+ }
147
+
148
+ function serializeTable(tableEl) {
149
+ const unsupported = [];
150
+ const thead = firstChildNamed(tableEl, 'THEAD');
151
+ const tbody = firstChildNamed(tableEl, 'TBODY');
152
+ const headerRow = thead ? firstChildNamed(thead, 'TR') : null;
153
+ const headerCells = headerRow ? elementChildren(headerRow).filter(isCell) : [];
154
+
155
+ const lines = [];
156
+ lines.push(serializeRow(headerCells, unsupported));
157
+ lines.push('|' + headerCells.map((c) => sepCellFor(cellAlign(c))).join('|') + '|');
158
+
159
+ const bodyRows = tbody ? childrenNamed(tbody, 'TR') : [];
160
+ bodyRows.forEach((tr) => {
161
+ const cells = elementChildren(tr).filter(isCell);
162
+ lines.push(serializeRow(cells, unsupported));
163
+ });
164
+
165
+ return { md: lines.join('\n'), unsupported };
166
+ }
167
+
168
+ return { serializeTable };
169
+ });