@helping-ai-workflow/md2doc 3.0.2 → 3.2.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,105 @@
1
+ 'use strict';
2
+ /* UMD, same shape as convert-md.js / lineops.js: `require`-able in node for
3
+ the unit tests, and injected into the editor page as
4
+ `window.md2docDocSource` (lib/editor/server.js). client.js is inlined
5
+ into the page as a plain <script>, not bundled, so a bare top-level
6
+ require('./docsource.js') there would be an undefined identifier in the
7
+ browser and would kill the whole injected script. */
8
+ (function (root, factory) {
9
+ if (typeof module === 'object' && module.exports) module.exports = factory();
10
+ else root.md2docDocSource = factory();
11
+ })(typeof self !== 'undefined' ? self : this, function () {
12
+
13
+ // Pure state machine + lines[]<->string conversion for the "追加 4 全文原始碼
14
+ // 逃生口" (whole-document source escape hatch): 編輯 ⇄ 原始碼 ⇄ 預覽.
15
+ //
16
+ // EOL representation: `eol` is always a single literal separator string --
17
+ // '\n', '\r\n' or '\r' -- the same convention server.js already establishes
18
+ // at load time (majority-vote across the file, ties go to '\n'; see
19
+ // server.js's `eol` derivation next to its own `mdText.split(/\r\n|\r|\n/)`).
20
+ // This module does not re-derive eol and does not guess per-line; it is
21
+ // handed the file's one canonical eol and uses it for every join.
22
+ //
23
+ // Splitting, though, is always done with the UNIVERSAL three-way regex
24
+ // `/\r\n|\r|\n/`, never with a literal `eol`-only split. Two reasons:
25
+ // 1. Symmetry with how `lines[]` was produced in the first place
26
+ // (server.js:154) -- if load used the universal regex, from-source
27
+ // must use the same one, or a file whose canonical eol is '\r\n' but
28
+ // whose textarea content picked up a stray '\n' (browsers normalize
29
+ // textarea .value to '\n' on some paste paths) would silently lose a
30
+ // line instead of being read back correctly.
31
+ // 2. `\r\n` is listed before the two single-character alternatives, so a
32
+ // CRLF pair is always consumed as ONE separator, never split into a
33
+ // spurious blank line between a lone \r and a lone \n.
34
+ const MODES = ['edit', 'source', 'preview'];
35
+
36
+ // Three-state cycle: edit -> source -> preview -> edit. An unrecognized
37
+ // mode (defensive: a caller passing something stale) falls back to the
38
+ // first state rather than throwing, matching how the rest of this module
39
+ // prefers a safe default over a thrown exception for malformed input.
40
+ function next(mode) {
41
+ const idx = MODES.indexOf(mode);
42
+ return MODES[(idx + 1) % MODES.length];
43
+ }
44
+
45
+ // lines[] -> the whole document as one string, for seeding the source
46
+ // textarea. `lines` mirrors the app's canonical model (server.js's
47
+ // `mdText.split(/\r\n|\r|\n/)`): a trailing '' entry represents a final
48
+ // trailing newline in the source file, no trailing '' entry represents a
49
+ // file with no trailing newline. Plain join reproduces exactly that.
50
+ function toSource(lines, eol) {
51
+ const arr = Array.isArray(lines) ? lines : [];
52
+ const sep = typeof eol === 'string' && eol !== '' ? eol : '\n';
53
+ return arr.join(sep);
54
+ }
55
+
56
+ function linesEqual(a, b) {
57
+ if (a.length !== b.length) return false;
58
+ for (let i = 0; i < a.length; i += 1) {
59
+ if (a[i] !== b[i]) return false;
60
+ }
61
+ return true;
62
+ }
63
+
64
+ // string -> {lines, changed}, the inverse of toSource. `lines` is always a
65
+ // faithful parse of `text` (universal-regex split, see the module header),
66
+ // independent of `prevLines` -- what `prevLines` controls is only the
67
+ // `changed` flag's meaning, per the plan-owner ruling below.
68
+ //
69
+ // CONTRACT (plan-owner ruling, fix round 1 -- do not "optimize" this back):
70
+ // `prevLines` is the lines[] the source textarea was seeded from -- the
71
+ // pre-edit state a real caller (client.js) always has on hand when it
72
+ // calls this on the way OUT of source mode. `prevLines` is optional in
73
+ // ARITY ONLY, so the name `fromSource(text, eol)` and every two-arg call
74
+ // site keep parsing correctly; it is NOT optional in the sense of "safe to
75
+ // omit and get a sensible answer for `changed`":
76
+ //
77
+ // - `prevLines` GIVEN (array): `changed` is a real content diff against
78
+ // it (`!linesEqual(lines, prevLines)`). This is the only form that can
79
+ // tell "the user edited a line" apart from "untouched," and it is the
80
+ // form the integration task (client.js) must call.
81
+ // - `prevLines` OMITTED: `changed` is unconditionally `true`.
82
+ //
83
+ // Why `true` and not a "did this round-trip cleanly" fidelity check (the
84
+ // round 1 defect): a pure function of `(text, eol)` alone has nothing to
85
+ // diff an edit against, and a same-eol in-place character edit -- the
86
+ // overwhelming majority of real edits -- round-trips through split+join
87
+ // exactly as cleanly as untouched text does. A fidelity-based `changed`
88
+ // therefore reports `false` (unchanged) for most real edits too, and the
89
+ // caller's contract is "changed: false means do not write the file" --
90
+ // which silently discards the user's edit. That is a data-loss default.
91
+ // `changed: true` is the fail-safe direction: the unknown case reads as
92
+ // "assume it was edited," so the caller writes; the worst case is one
93
+ // needless write of byte-identical content, never a dropped edit.
94
+ function fromSource(text, eol, prevLines) {
95
+ // `eol` is accepted for API symmetry with `toSource(lines, eol)` and is
96
+ // NOT used here: parsing is always the universal three-way split (module
97
+ // header), never a literal-`eol` split, so there is nothing for it to do.
98
+ const s = typeof text === 'string' ? text : String(text == null ? '' : text);
99
+ const lines = s.split(/\r\n|\r|\n/);
100
+ const changed = Array.isArray(prevLines) ? !linesEqual(lines, prevLines) : true;
101
+ return { lines, changed };
102
+ }
103
+
104
+ return { MODES, next, toSource, fromSource };
105
+ });
@@ -0,0 +1,275 @@
1
+ 'use strict';
2
+ /* UMD, same shape as lineops.js / indent-clamp.js / convert-md.js:
3
+ `require`-able in node for the unit tests, and injected into the editor
4
+ page as `window.md2docPasteMd` (lib/editor/server.js). client.js is
5
+ inlined into the page as a plain <script>, not bundled, so a bare
6
+ require('./paste-md.js') there would be an undefined identifier in the
7
+ browser. */
8
+ (function (root, factory) {
9
+ if (typeof module === 'object' && module.exports) module.exports = factory();
10
+ else root.md2docPasteMd = factory();
11
+ })(typeof self !== 'undefined' ? self : this, function () {
12
+
13
+ // This module has one external dependency: turndown (HTML -> Markdown).
14
+ // server.js injects every editor module into the page as a plain <script>
15
+ // tag, in a fixed order (lib/editor/server.js) -- turndown's browser UMD
16
+ // build (node_modules/turndown/lib/turndown.browser.umd.js, which defines
17
+ // the global `TurndownService`) is one more such tag. Resolving turndown at
18
+ // FACTORY time -- a top-level `require('turndown')`, or reading
19
+ // `TurndownService` at module-eval time -- would silently coupled this
20
+ // module's *load order* to turndown's <script> tag landing before this
21
+ // one; reorder server.js's injection list and the factory throws before a
22
+ // single test runs, or before the page even paints. Resolving lazily,
23
+ // inside htmlToMarkdown, defers that lookup to the first call, by which
24
+ // point both node's `require` cache (node side) and the browser page's
25
+ // <script> tags (browser side) have already settled. There is no existing
26
+ // precedent for an external-module lookup in this codebase's editor
27
+ // modules (convert-md.js / lineops.js / indent-clamp.js / selection.js all
28
+ // have zero external dependencies), so this is a fresh pattern, not a
29
+ // copy of one.
30
+ function getTurndownService() {
31
+ // Recomputed here (not read from the outer UMD wrapper's `root` param)
32
+ // because that param belongs to the wrapper function, not to this
33
+ // factory's lexical scope -- the factory is a sibling argument at the
34
+ // IIFE call site, so it does not close over it.
35
+ var root = typeof self !== 'undefined' ? self : this;
36
+ return typeof require === 'function' ? require('turndown') : root.TurndownService;
37
+ }
38
+
39
+ // --- GFM extras turndown's core does not ship ------------------------------
40
+ //
41
+ // turndown's core rule set has no rule for <del>/<s>/<strike> or for
42
+ // <table>. The obvious fix is the `turndown-plugin-gfm` package, but the
43
+ // brief scopes package.json to exactly one new dependency (turndown) --
44
+ // adding a second package is out of scope for this track. These two rules
45
+ // are a deliberately small, from-scratch reimplementation of the same
46
+ // GFM shapes (not a copy of turndown-plugin-gfm's source), added via
47
+ // turndownService.addRule the same way any consumer of turndown extends it.
48
+
49
+ function addStrikethroughRule(service) {
50
+ service.addRule('strikethrough', {
51
+ filter: ['del', 's', 'strike'],
52
+ replacement: function (content) {
53
+ return '~~' + content + '~~';
54
+ }
55
+ });
56
+ }
57
+
58
+ function isFirstTbody(el) {
59
+ var prev = el.previousSibling;
60
+ return (
61
+ el.nodeName === 'TBODY' &&
62
+ (!prev || (prev.nodeName === 'THEAD' && /^\s*$/.test(prev.textContent)))
63
+ );
64
+ }
65
+
66
+ function isHeadingRow(tr) {
67
+ var parent = tr.parentNode;
68
+ if (!parent) return false;
69
+ if (parent.nodeName === 'THEAD') return true;
70
+ if (parent.firstChild !== tr) return false;
71
+ if (parent.nodeName !== 'TABLE' && !isFirstTbody(parent)) return false;
72
+ for (var i = 0; i < tr.childNodes.length; i += 1) {
73
+ if (tr.childNodes[i].nodeName !== 'TH') return false;
74
+ }
75
+ return tr.childNodes.length > 0;
76
+ }
77
+
78
+ // Fix-round-1 (2026-09-03), Critical: a literal `|` in cell content used to
79
+ // pass through unescaped, so e.g. a Windows path or a bitwise-or expression
80
+ // silently added a phantom column -- every column to its right misaligned
81
+ // against the header. Escape any `|` that isn't already an escaped `\|`
82
+ // (the latter can arrive when a nested table's own already-escaped cell
83
+ // content is flattened into its parent cell, see cellMarkdown below).
84
+ function escapePipes(s) {
85
+ return String(s).replace(/(?<!\\)\|/g, '\\|');
86
+ }
87
+
88
+ // Only the ROWS THIS TABLE OWNS -- direct <tr> children, and <tr> children
89
+ // one level down inside a <thead>/<tbody>/<tfoot>. A nested <table> sitting
90
+ // inside one of this table's <td> cells is two levels further down (tr ->
91
+ // td -> table -> tbody -> tr), so it is never reached by this walk and its
92
+ // rows can never be mistaken for this table's own rows.
93
+ function collectRows(table) {
94
+ var rows = [];
95
+ for (var i = 0; i < table.childNodes.length; i += 1) {
96
+ var child = table.childNodes[i];
97
+ if (child.nodeName === 'TR') {
98
+ rows.push(child);
99
+ } else if (child.nodeName === 'THEAD' || child.nodeName === 'TBODY' || child.nodeName === 'TFOOT') {
100
+ for (var j = 0; j < child.childNodes.length; j += 1) {
101
+ if (child.childNodes[j].nodeName === 'TR') rows.push(child.childNodes[j]);
102
+ }
103
+ }
104
+ }
105
+ return rows;
106
+ }
107
+
108
+ function cellColSpan(cellNode) {
109
+ var n = typeof cellNode.colSpan === 'number' ? cellNode.colSpan
110
+ : parseInt(cellNode.getAttribute && cellNode.getAttribute('colspan'), 10);
111
+ return n > 0 ? n : 1;
112
+ }
113
+
114
+ // Converts ONE cell's own content (recursing through `service`, so a
115
+ // nested <table> inside this cell is itself converted by this same table
116
+ // rule -- see the module comment above addTableRules). The result is
117
+ // flattened to one line and pipe-escaped, so a nested table collapses into
118
+ // one escaped, single-line cell instead of leaking a stream of stray pipes
119
+ // into the parent row.
120
+ function cellMarkdown(cellNode, service) {
121
+ var md = service.turndown(cellNode);
122
+ return escapePipes(String(md).trim().replace(/\s*\n+\s*/g, ' '));
123
+ }
124
+
125
+ // Fix-round-1, Important: a colspan="N" cell used to emit as a single
126
+ // cell, leaving the row N-1 columns short of every other row it should
127
+ // align with. Ruling: expand it into N cells (content in the first, empty
128
+ // in the rest) rather than modeling a real merged-cell grid. rowspan is
129
+ // deliberately NOT expanded (no full grid model) -- a row it shortens is
130
+ // simply padded to the table's width by padRow below. Lossy, but every row
131
+ // still lexes as a well-formed GFM row.
132
+ function expandRowCells(row, service) {
133
+ var out = [];
134
+ for (var i = 0; i < row.childNodes.length; i += 1) {
135
+ var cellNode = row.childNodes[i];
136
+ if (cellNode.nodeName !== 'TH' && cellNode.nodeName !== 'TD') continue;
137
+ var span = cellColSpan(cellNode);
138
+ out.push(cellMarkdown(cellNode, service));
139
+ for (var s = 1; s < span; s += 1) out.push('');
140
+ }
141
+ return out;
142
+ }
143
+
144
+ function renderRowLine(cells) {
145
+ return '| ' + cells.join(' | ') + ' |';
146
+ }
147
+
148
+ function padRow(cells, width) {
149
+ var out = cells.slice();
150
+ while (out.length < width) out.push('');
151
+ return out;
152
+ }
153
+
154
+ // Fix-round-1, Important: rebuilds the WHOLE table from the DOM directly
155
+ // (rather than composing per-cell/per-row replacement strings the way the
156
+ // pre-fix version did) so column count can be normalized across every row
157
+ // before any markdown is emitted -- expanding colspan and padding every
158
+ // row, header included, out to the table's widest row (see
159
+ // expandRowCells/padRow above). That decision needs the full row set in
160
+ // hand; it cannot be made one row/cell at a time.
161
+ function renderTable(table, service) {
162
+ var rows = collectRows(table);
163
+ if (rows.length === 0) return '';
164
+
165
+ var expanded = rows.map(function (row) {
166
+ return { row: row, cells: expandRowCells(row, service) };
167
+ });
168
+
169
+ var width = 0;
170
+ expanded.forEach(function (r) { if (r.cells.length > width) width = r.cells.length; });
171
+ if (width === 0) return '';
172
+
173
+ var headerIdx = -1;
174
+ for (var i = 0; i < expanded.length; i += 1) {
175
+ if (isHeadingRow(expanded[i].row)) { headerIdx = i; break; }
176
+ }
177
+ // No <thead>/<th> anywhere: promote the first row to the header slot so
178
+ // the table still lexes as GFM, same as the pre-fix behavior.
179
+ if (headerIdx === -1) headerIdx = 0;
180
+
181
+ var lines = [];
182
+ lines.push(renderRowLine(padRow(expanded[headerIdx].cells, width)));
183
+ lines.push(renderRowLine(new Array(width).fill('---')));
184
+ expanded.forEach(function (r, idx) {
185
+ if (idx === headerIdx) return;
186
+ lines.push(renderRowLine(padRow(r.cells, width)));
187
+ });
188
+
189
+ return '\n\n' + lines.join('\n') + '\n\n';
190
+ }
191
+
192
+ function addTableRules(service) {
193
+ service.addRule('table', {
194
+ filter: function (node) {
195
+ return node.nodeName === 'TABLE';
196
+ },
197
+ replacement: function (content, node) {
198
+ return renderTable(node, service);
199
+ }
200
+ });
201
+ }
202
+
203
+ function htmlToMarkdown(html) {
204
+ var TurndownService = getTurndownService();
205
+ var service = new TurndownService({
206
+ headingStyle: 'atx',
207
+ bulletListMarker: '-',
208
+ codeBlockStyle: 'fenced',
209
+ emDelimiter: '*'
210
+ });
211
+ addStrikethroughRule(service);
212
+ addTableRules(service);
213
+ return service.turndown(html == null ? '' : String(html));
214
+ }
215
+
216
+ // --- clipboard payload selection --------------------------------------------
217
+ //
218
+ // Pure decision logic, no DOM/ClipboardEvent access here -- the caller
219
+ // (client.js, a later track) reads the real `ClipboardEvent#clipboardData`
220
+ // / `navigator.clipboard.read()` result into this plain
221
+ // { 'mime/type': string | Blob } shape and hands it in.
222
+ //
223
+ // Fix-round-1 (2026-09-03), ruling on the priority order: `plainOnly`
224
+ // (Ctrl+Shift+V, an explicit "give me text" gesture) wins over an image
225
+ // whenever the clipboard carries ANY text flavour (`text/plain` OR
226
+ // `text/html`) -- honour the gesture whenever there is text to honour it
227
+ // with. Fall through to the image only when there is no text flavour at
228
+ // all, since there is then no text to honour the gesture with.
229
+ //
230
+ // Fix-round-2 (2026-09-03), ruling extended to the default (non-plainOnly)
231
+ // path: `text/html` now beats an image when both are present. Round 1 left
232
+ // an unconditional image-first rule there. But a `text/html` clipboard is a
233
+ // rich-text copy (Word / a browser / Confluence), and those sources
234
+ // routinely put a bitmap of the SAME content alongside the HTML -- with
235
+ // image-first, the headline case this track exists for (paste from Word,
236
+ // get markdown) instead landed a screenshot of the Word content, which is
237
+ // worse than the plain-text behavior it was meant to replace. `text/html`
238
+ // -> `<img src="...">` converts losslessly to `![](...)`, keeping a remote
239
+ // URL instead of re-uploading a copy of it. A screenshot from a snipping
240
+ // tool carries no `text/html` at all, so it is unaffected and still takes
241
+ // the image branch (reaching the asset endpoint, a later track's concern,
242
+ // not this module's).
243
+ function pickPayload(clipboardItems, plainOnly) {
244
+ var items = clipboardItems || {};
245
+
246
+ var imageKey = null;
247
+ for (var k in items) {
248
+ if (Object.prototype.hasOwnProperty.call(items, k) &&
249
+ k.indexOf('image/') === 0 && items[k] != null) {
250
+ imageKey = k;
251
+ break;
252
+ }
253
+ }
254
+
255
+ var html = items['text/html'];
256
+ var text = items['text/plain'];
257
+ var hasText = (typeof text === 'string' && text !== '') || (typeof html === 'string' && html !== '');
258
+
259
+ if (plainOnly) {
260
+ if (hasText) return { kind: 'text', value: typeof text === 'string' ? text : '' };
261
+ if (imageKey) return { kind: 'image', blob: items[imageKey] };
262
+ return { kind: 'text', value: '' };
263
+ }
264
+
265
+ if (typeof html === 'string' && html !== '') {
266
+ return { kind: 'markdown', value: htmlToMarkdown(html) };
267
+ }
268
+
269
+ if (imageKey) return { kind: 'image', blob: items[imageKey] };
270
+
271
+ return { kind: 'text', value: typeof text === 'string' ? text : '' };
272
+ }
273
+
274
+ return { htmlToMarkdown: htmlToMarkdown, pickPayload: pickPayload };
275
+ });
@@ -0,0 +1,83 @@
1
+ 'use strict';
2
+ /* UMD, same shape as convert-md.js / lineops.js: require-able in node for the
3
+ unit tests, and injected into the editor page as `window.md2docPatchmap`
4
+ (lib/editor/server.js). client.js is inlined as a plain <script>, not
5
+ bundled, so a bare require() here would be undefined in the browser. */
6
+ (function (root, factory) {
7
+ if (typeof module === 'object' && module.exports) module.exports = factory();
8
+ else root.md2docPatchmap = factory();
9
+ })(typeof self !== 'undefined' ? self : this, function () {
10
+
11
+ // The ONLY normalisation applied before comparing two part strings.
12
+ //
13
+ // `data-block-id` is a POSITIONAL ordinal — blockmap.js:170 starts a shared
14
+ // { v: 0 } box and :141/:179 do `id: nextId.v++`, so `blocks[i].id === i` is
15
+ // an identity. Any edit that changes the BLOCK COUNT therefore rewrites the
16
+ // id of every part after the edit point, and without this strip the suffix
17
+ // never matches and the widen swallows the document to its end (measured:
18
+ // keepSuffix 0 vs 14 on the same edit).
19
+ //
20
+ // It is the ONLY field that may be normalised away: no CSS rule anywhere
21
+ // keys on it (grep data-block-id lib/md2doc.js hits only the two emit sites,
22
+ // :303 and :911), so its value changing does not change how the node paints.
23
+ // The other renderer-owned positional attributes — data-list-start,
24
+ // data-run-start, data-indent, data-list-type — DO change the rendering when
25
+ // their value changes, so a mismatch there must widen. Do not extend this.
26
+ const BLOCK_ID_RE = / data-block-id="\d+"/g;
27
+ function stripBlockId(partStr) {
28
+ return String(partStr).replace(BLOCK_ID_RE, '');
29
+ }
30
+
31
+ function patchmap(input) {
32
+ const oldBlocks = input && input.oldBlocks;
33
+ const newBlocks = input && input.newBlocks;
34
+ const oldParts = input && input.oldParts;
35
+ const newParts = input && input.newParts;
36
+
37
+ // Structural guards. `oldParts` is absent on the first render of a session
38
+ // (the bootstrap payload carries no parts — see the plan's Task C), which
39
+ // is a legitimate fallback, not an error.
40
+ if (!Array.isArray(oldParts) || !Array.isArray(newParts)) return null;
41
+ if (!Array.isArray(oldBlocks) || !Array.isArray(newBlocks)) return null;
42
+ if (oldParts.length !== oldBlocks.length) return null;
43
+ if (newParts.length !== newBlocks.length) return null;
44
+
45
+ const oldN = oldParts.length;
46
+ const newN = newParts.length;
47
+ const o = oldParts.map(stripBlockId);
48
+ const n = newParts.map(stripBlockId);
49
+
50
+ let keepPrefix = 0;
51
+ while (keepPrefix < oldN && keepPrefix < newN && o[keepPrefix] === n[keepPrefix]) keepPrefix++;
52
+
53
+ let keepSuffix = 0;
54
+ while (
55
+ keepSuffix < oldN - keepPrefix &&
56
+ keepSuffix < newN - keepPrefix &&
57
+ o[oldN - 1 - keepSuffix] === n[newN - 1 - keepSuffix]
58
+ ) keepSuffix++;
59
+
60
+ // The middle is a single contiguous covering interval by construction:
61
+ // prefix and suffix are a true prefix and a true suffix, so everything
62
+ // between them is replaced even if some of it happened to be unchanged.
63
+ // An insertion yields oldEnd === oldStart - 1 (a zero-width span).
64
+ const oldStart = keepPrefix;
65
+ const oldEnd = oldN - keepSuffix - 1;
66
+
67
+ return {
68
+ keepPrefix: keepPrefix,
69
+ keepSuffix: keepSuffix,
70
+ replaceSpan: {
71
+ oldStart: oldStart,
72
+ oldEnd: oldEnd,
73
+ newParts: newParts.slice(keepPrefix, newN - keepSuffix),
74
+ },
75
+ // Applied by the caller to every KEPT SUFFIX node, rewriting its
76
+ // data-block-id to oldId + idDelta. The prefix never needs it: id is the
77
+ // index, and the prefix occupies 0..k-1 in both arrays.
78
+ idDelta: newN - oldN,
79
+ };
80
+ }
81
+
82
+ return { stripBlockId: stripBlockId, patchmap: patchmap };
83
+ });