@helping-ai-workflow/md2doc 2.10.1 → 2.11.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.
@@ -0,0 +1,200 @@
1
+ 'use strict';
2
+ /* UMD, same shape as lineops.js / indent-clamp.js: `require`-able in node for
3
+ the unit tests, and injected into the editor page as `window.md2docConvertMd`
4
+ (lib/editor/server.js). client.js is inlined into the page as a plain
5
+ <script>, not bundled, so a bare require('./convert-md.js') there would be
6
+ an undefined identifier in the browser. */
7
+ (function (root, factory) {
8
+ if (typeof module === 'object' && module.exports) module.exports = factory();
9
+ else root.md2docConvertMd = factory();
10
+ })(typeof self !== 'undefined' ? self : this, function () {
11
+
12
+ // Pure marker arithmetic for the S2 "轉換成" submenu (spec 3.2, 4.3).
13
+ //
14
+ // Why this module reads LINES and not the DOM: `serializeBlocks` reports any
15
+ // non-`li` block inside a span as unsupported (list-md.js:462-463), which is
16
+ // exactly the shape a conversion produces. Routing a conversion through the
17
+ // run re-serializer would make every li->heading refuse itself. Reading the
18
+ // source lines also means inline content is never re-escaped, so a `~5px` in
19
+ // the converted block survives as `~5px`.
20
+ //
21
+ // This module knows nothing about indent arithmetic. The caller owns the
22
+ // marker-width stack (spec 3.4) and passes the finished `indentPrefix` in.
23
+
24
+ const CONVERT_TARGETS = [
25
+ { id: 'text', label: '文字' },
26
+ { id: 'h1', label: '標題 1' },
27
+ { id: 'h2', label: '標題 2' },
28
+ { id: 'h3', label: '標題 3' },
29
+ { id: 'h4', label: '標題 4' },
30
+ { id: 'h5', label: '標題 5' },
31
+ { id: 'h6', label: '標題 6' },
32
+ { id: 'ul', label: '項目符號列表' },
33
+ { id: 'ol', label: '編號列表' },
34
+ { id: 'task', label: '待辦清單' },
35
+ { id: 'code', label: '程式碼' },
36
+ { id: 'quote', label: '引用' },
37
+ ];
38
+
39
+ // A bullet is -, * or +; an ordinal is digits followed by . or ). The GFM task
40
+ // checkbox is parsed out of the CONTENT, not the marker (spec 3.4 errata), so
41
+ // it is stripped as a second, separate step.
42
+ const LI_MARKER = /^(\s*)(?:[-*+]|\d{1,9}[.)])(\s+)/;
43
+ const TASK_BOX = /^\[([ xX])\]\s+/;
44
+ // An ATX heading may carry NO content at all: measured against marked 14.1.4,
45
+ // '#', '##', '###', '## ##' and '#### #' all lex as a heading whose text is
46
+ // ''. Requiring `\s+` plus content refused them, and the user saw
47
+ // 此區塊的格式無法轉換 on an empty heading. The optional closing sequence is
48
+ // stripped separately, because '## ##' has one with no content in front of it.
49
+ const ATX = /^(#{1,6})(?:[ \t]+(.*?))?[ \t]*$/;
50
+ // A closing sequence is a run of # at end of line, preceded by whitespace or
51
+ // by nothing at all. Measured: '## alpha#' keeps the '#' (no space in front)
52
+ // and '## alpha #x' keeps the whole tail (not a pure run).
53
+ const ATX_CLOSE = /(?:^|[ \t]+)#+$/;
54
+ const QUOTE = /^>\s?/;
55
+ // The OPENING fence: <=3 columns of indent, then a run of >=3 backticks or
56
+ // tildes, then an optional info string.
57
+ const FENCE_OPEN = /^( {0,3})(`{3,}|~{3,})(.*)$/;
58
+
59
+ // Does `line` close a fence opened with `openRun`? Measured against marked
60
+ // 14.1.4, all four conditions are load-bearing:
61
+ // '````\na\n```' -> the '```' survives as CONTENT (too short)
62
+ // '```\na\n~~~' -> the '~~~' survives as CONTENT (wrong character)
63
+ // '```\na\n```js' -> the '```js' survives as CONTENT (info string)
64
+ // '```\na\n ```'-> the ' ```' survives as CONTENT (4 columns)
65
+ // while '```\na\n ```' and '```js\na\n````' both DO close. Matching
66
+ // "looks like a fence" ate a line of the user's code in the first four cases.
67
+ function isClosingFence(line, openRun) {
68
+ const m = String(line).match(/^( {0,3})(`{3,}|~{3,})[ \t]*$/);
69
+ if (!m) return false;
70
+ return m[2][0] === openRun[0] && m[2].length >= openRun.length;
71
+ }
72
+
73
+ // The longest fence-shaped run of `ch` in `body` -- i.e. the longest run that
74
+ // would CLOSE a fence made of `ch`. Measured: an inline run ('a ``` b') and a
75
+ // run indented 4+ columns never close, so neither inflates the fence; a run
76
+ // indented 0-3 columns does.
77
+ function longestClosingRun(body, ch) {
78
+ const re = ch === '`' ? /^ {0,3}(`{3,})[ \t]*$/ : /^ {0,3}(~{3,})[ \t]*$/;
79
+ let longest = 0;
80
+ for (const line of body) {
81
+ const m = String(line).match(re);
82
+ if (m && m[1].length > longest) longest = m[1].length;
83
+ }
84
+ return longest;
85
+ }
86
+
87
+ function stripMarker(sourceLines, blockKind) {
88
+ const src = Array.isArray(sourceLines) ? sourceLines : [];
89
+ if (src.length === 0) return { content: [], ok: false };
90
+
91
+ if (blockKind === 'li') {
92
+ const m = src[0].match(LI_MARKER);
93
+ if (!m) return { content: [], ok: false };
94
+ // A MULTI-LINE li refuses. The previous code flattened continuations with
95
+ // replace(/^\s+/, ''), which is exactly the guess the code branch below
96
+ // refuses to make for an indented code block -- and it silently destroys
97
+ // whatever structure the continuation carried (a nested fence, an indented
98
+ // sub-block). Today the \u00a74.1 gate refuses a multi-line li as an operation
99
+ // target before this is ever reached, so this is defence in depth; dead
100
+ // code in this repo has a history of later gaining a caller, and it must
101
+ // not be a mine when it does.
102
+ if (src.length > 1) return { content: [], ok: false };
103
+ let first = src[0].slice(m[0].length);
104
+ const box = first.match(TASK_BOX);
105
+ if (box) first = first.slice(box[0].length);
106
+ return { content: [first], ok: true };
107
+ }
108
+
109
+ if (blockKind === 'heading') {
110
+ const m = src[0].match(ATX);
111
+ // A setext heading (underlined with === or ---) is two lines and has no
112
+ // marker to strip from line 1; treat it as content plus a discarded rule.
113
+ if (!m) {
114
+ if (src.length >= 2 && /^\s*(=+|-+)\s*$/.test(src[1])) {
115
+ return { content: [src[0].trim()], ok: true };
116
+ }
117
+ return { content: [], ok: false };
118
+ }
119
+ const raw = m[2] === undefined ? '' : m[2];
120
+ return { content: [raw.replace(ATX_CLOSE, '')], ok: true };
121
+ }
122
+
123
+ if (blockKind === 'blockquote') {
124
+ return { content: src.map((l) => l.replace(QUOTE, '')), ok: true };
125
+ }
126
+
127
+ if (blockKind === 'code') {
128
+ const open = src[0].match(FENCE_OPEN);
129
+ // An INDENTED code block has no fence. Stripping four spaces would be a
130
+ // guess, and a wrong one whenever the body is itself indented, so refuse.
131
+ if (!open) return { content: [], ok: false };
132
+ let end = src.length;
133
+ if (end > 1 && isClosingFence(src[end - 1], open[2])) end -= 1;
134
+ return { content: src.slice(1, end), ok: true };
135
+ }
136
+
137
+ // paragraph, html, anything else that owns its lines verbatim
138
+ return { content: src.slice(), ok: true };
139
+ }
140
+
141
+ function targetIsList(target) {
142
+ return target === 'ul' || target === 'ol' || target === 'task';
143
+ }
144
+
145
+ function listAttrsFor(target) {
146
+ if (target === 'ul') return { listType: 'ul', task: false };
147
+ if (target === 'ol') return { listType: 'ol', task: false };
148
+ if (target === 'task') return { listType: 'ul', task: true };
149
+ return null;
150
+ }
151
+
152
+ function emitAs(content, target, opts) {
153
+ const o = opts || {};
154
+ const prefix = o.indentPrefix || '';
155
+ const body = Array.isArray(content) ? content : [];
156
+
157
+ if (target === 'text') return body.slice();
158
+
159
+ if (/^h[1-6]$/.test(target)) {
160
+ // A heading is one line by definition. Joining is the only lossless move
161
+ // available; splitting would make one gesture produce two blocks.
162
+ const hashes = '#'.repeat(Number(target.slice(1)));
163
+ const text = body.join(' ');
164
+ // An EMPTY heading emits no trailing space, or converting '##' to a
165
+ // heading of the same level would rewrite the line to '## '.
166
+ return [text === '' ? prefix + hashes : prefix + hashes + ' ' + text];
167
+ }
168
+
169
+ if (target === 'quote') return body.map((l) => prefix + '> ' + l);
170
+
171
+ if (target === 'code') {
172
+ // Negotiate the fence length. A body that itself contains a fence line
173
+ // closed the block early: emitting ['```','before','```','after','```']
174
+ // lexes as code,paragraph -- 'after' escapes into the document, and the
175
+ // trailing fence opens an unterminated block. The opening run must be
176
+ // strictly longer than any run in the body that could close it. Measured:
177
+ // a TILDE run cannot close a backtick fence, so it does not count here,
178
+ // despite what the fix request assumed.
179
+ const fence = '`'.repeat(Math.max(3, longestClosingRun(body, '`') + 1));
180
+ return [prefix + fence, ...body.map((l) => prefix + l), prefix + fence];
181
+ }
182
+
183
+ if (targetIsList(target)) {
184
+ const marker = target === 'ol' ? String(o.ordinal || 1) + '. ' : '- ';
185
+ const box = target === 'task' ? (o.checked ? '[x] ' : '[ ] ') : '';
186
+ const head = prefix + marker + box + (body[0] !== undefined ? body[0] : '');
187
+ // Continuations sit under the marker so they stay inside the item. The
188
+ // checkbox is deliberately NOT counted: spec 3.4's errata measured
189
+ // `- [ ] ` as contributing 2 columns, not 6, because GFM parses the box
190
+ // out of the item's CONTENT rather than out of the CommonMark marker.
191
+ const contIndent = ' '.repeat(marker.length);
192
+ const rest = body.slice(1).map((l) => prefix + contIndent + l);
193
+ return [head, ...rest];
194
+ }
195
+
196
+ return body.slice();
197
+ }
198
+
199
+ return { CONVERT_TARGETS, stripMarker, emitAs, targetIsList, listAttrsFor };
200
+ });
@@ -0,0 +1,152 @@
1
+ 'use strict';
2
+ /* Spec §3.4 — 縮排合法性:位移後再夾取.
3
+ UMD, same shape as lineops.js: `require`-able in node for the unit tests,
4
+ and injected into the editor page as `window.md2docIndentClamp`. */
5
+ (function (root, factory) {
6
+ if (typeof module === 'object' && module.exports) module.exports = factory();
7
+ else root.md2docIndentClamp = factory();
8
+ })(typeof self !== 'undefined' ? self : this, function () {
9
+
10
+ // Markdown indent is only ever RELATIVE: an item's depth exists solely
11
+ // because some shallower item stands above it. So every structural
12
+ // operation can leave blocks it never touched with an indent that no longer
13
+ // has an anchor — and an unanchored indent is not a display glitch, it is a
14
+ // different document. Four columns of dangling indent after a paragraph
15
+ // lexes as an INDENTED CODE BLOCK; one column too many under a task marker
16
+ // makes marked swallow the sublist as literal text. The editor re-renders
17
+ // from the file after every commit, so "looks right on screen, cannot be
18
+ // saved" survives exactly one round trip.
19
+ //
20
+ // clampIndents(blocks, opIndex, opOldIndent, opts)
21
+ //
22
+ // blocks — the commit span, in document order, as plain objects:
23
+ // `{ id, type, indent }`. `type` is the block type string
24
+ // ('li' or anything else); `indent` is the POST-mutation
25
+ // value the caller has already written for whatever it
26
+ // moved. Nothing here reads the DOM.
27
+ // opIndex — index into `blocks` of the operated block, or an ARRAY of
28
+ // indices for a multi-block operation (spec §3.4 rule 3).
29
+ // opOldIndent — the operated block's indent BEFORE the operation (spec
30
+ // §3.4's global convention). For a multi-block operation
31
+ // this is the SMALLEST old indent in the set — anchoring on
32
+ // the first member instead drives later members negative on
33
+ // a delete and no-ops an entire batch Tab.
34
+ // opts.removed — the operated block(s) no longer exist.
35
+ // opts.operatedBecomes — the operated block is still there but is no
36
+ // longer a list item (§3.3 conversion); the value
37
+ // is its new `{ type }`.
38
+ //
39
+ // Returns `[{ blockId, indent }]` — one entry per block that is STILL a list
40
+ // item, in document order. A removed or converted block has no indent to
41
+ // report and is absent; every other block is present, including ones ahead
42
+ // of the operation, which never move.
43
+ function clampIndents(blocks, opIndex, opOldIndent, opts) {
44
+ const options = opts || {};
45
+ const src = (blocks || []).map((b) => ({
46
+ id: b.id,
47
+ type: b.type,
48
+ indent: typeof b.indent === 'number' ? b.indent : 0,
49
+ }));
50
+ const opIdxs = (Array.isArray(opIndex) ? opIndex.slice() : [opIndex])
51
+ .filter((i) => i >= 0 && i < src.length)
52
+ .sort((a, b) => a - b);
53
+ if (!opIdxs.length) return liEntries(src, () => true);
54
+
55
+ const removed = {};
56
+ if (options.removed) opIdxs.forEach((i) => { removed[i] = true; });
57
+ if (options.operatedBecomes) {
58
+ opIdxs.forEach((i) => { src[i].type = options.operatedBecomes.type || 'paragraph'; });
59
+ }
60
+
61
+ // A block that is gone, or is no longer a list item, cannot anchor
62
+ // anything. `null` means "the next block down may sit at indent 0 and no
63
+ // deeper" — the same answer as having no previous block at all.
64
+ function anchorBefore(idx) {
65
+ for (let k = idx - 1; k >= 0; k--) {
66
+ if (removed[k]) continue;
67
+ if (src[k].type !== 'li') return null;
68
+ return src[k].indent;
69
+ }
70
+ return null;
71
+ }
72
+ const boundAt = (idx) => {
73
+ const a = anchorBefore(idx);
74
+ return a === null ? 0 : a + 1;
75
+ };
76
+
77
+ // ── Rule 1: the operated block itself ────────────────────────────────
78
+ // Its own upper bound is the ordinary one, so a caller that optimistically
79
+ // wrote `indent + 1` for a Tab gets it taken back when nothing above can
80
+ // parent it. Clamped in document order so a multi-block set anchors on
81
+ // members the loop has already settled.
82
+ opIdxs.forEach((i) => {
83
+ if (removed[i] || src[i].type !== 'li') return;
84
+ src[i].indent = Math.max(0, Math.min(src[i].indent, boundAt(i)));
85
+ });
86
+
87
+ // ── Rule 2: the scope ────────────────────────────────────────────────
88
+ // From the block after the LAST operated one, up to (not including) the
89
+ // first block that is not a list item or whose indent is SMALLER than the
90
+ // operated block's OLD indent. "Smaller", never "smaller or equal": the
91
+ // following same-level siblings lost the same anchor the children did, and
92
+ // leaving them out is what makes the model and the bytes drift apart.
93
+ const scopeStart = opIdxs[opIdxs.length - 1] + 1;
94
+ let scopeEnd = scopeStart; // exclusive
95
+ while (scopeEnd < src.length) {
96
+ const b = src[scopeEnd];
97
+ if (b.type !== 'li') break;
98
+ if (b.indent < opOldIndent) break;
99
+ scopeEnd++;
100
+ }
101
+
102
+ // ── Rule 3: segments ─────────────────────────────────────────────────
103
+ // The first segment is the operated block's whole subtree (everything
104
+ // deeper than its old indent); each later segment is one same-level
105
+ // sibling plus ITS whole subtree. One delta per segment, never per item:
106
+ // clamping items independently lets the first child settle at 0 and the
107
+ // second stay at 1, i.e. sibling #2 gets ADOPTED by sibling #1 — a
108
+ // restructure of content the user never touched.
109
+ const segments = [];
110
+ let at = scopeStart;
111
+ if (at < scopeEnd && src[at].indent > opOldIndent) {
112
+ let end = at;
113
+ while (end < scopeEnd && src[end].indent > opOldIndent) end++;
114
+ segments.push([at, end]);
115
+ at = end;
116
+ }
117
+ while (at < scopeEnd) {
118
+ let end = at + 1;
119
+ while (end < scopeEnd && src[end].indent > src[at].indent) end++;
120
+ segments.push([at, end]);
121
+ at = end;
122
+ }
123
+
124
+ segments.forEach(([from, to]) => {
125
+ const delta = Math.max(0, src[from].indent - boundAt(from));
126
+ if (delta === 0) return;
127
+ for (let k = from; k < to; k++) src[k].indent = Math.max(0, src[k].indent - delta);
128
+ });
129
+
130
+ // ── Rule 4: per-item clamp, both bounds ──────────────────────────────
131
+ // Runs over the scope in document order so each block is measured against
132
+ // the already-settled block above it. The segment deltas above preserve
133
+ // relative depth; this only ever pulls in an item that is still deeper
134
+ // than its own parent allows.
135
+ for (let k = scopeStart; k < scopeEnd; k++) {
136
+ src[k].indent = Math.max(0, Math.min(src[k].indent, boundAt(k)));
137
+ }
138
+
139
+ return liEntries(src, (i) => !removed[i]);
140
+ }
141
+
142
+ function liEntries(src, keep) {
143
+ const out = [];
144
+ src.forEach((b, i) => {
145
+ if (!keep(i) || b.type !== 'li') return;
146
+ out.push({ blockId: b.id, indent: b.indent });
147
+ });
148
+ return out;
149
+ }
150
+
151
+ return { clampIndents };
152
+ });
@@ -196,7 +196,31 @@
196
196
  continue;
197
197
  }
198
198
  if (name === 'BR') {
199
- out += '<br>';
199
+ // Spec §3.12. Two different things arrive here as the same element:
200
+ // * a <br> the EDIT-MODE renderer marked (lib/md2doc.js's
201
+ // renderer.br) as having come from a markdown HARD BREAK — it must
202
+ // go back out as a hard break, or the block loses a source line;
203
+ // * every other <br> — one Shift+Enter inserted, one the source
204
+ // spelled out literally, or the placeholder Chromium leaves behind
205
+ // when the last character of a surface is deleted — which keeps
206
+ // emitting the literal '<br>' it always did. That is a round-trip
207
+ // contract with tests pinning it and it does not move.
208
+ //
209
+ // BACKSLASH, not two trailing spaces, and the choice is forced rather
210
+ // than stylistic: markdown's other hard-break spelling is two trailing
211
+ // spaces, and this module's own output is checked by
212
+ // assertNoTrailingWhitespace() (gate-compat.test.js's fossilized
213
+ // paperwork-gate contract), while list-md.js additionally trims every
214
+ // emitted line unconditionally. The backslash form clears both and
215
+ // re-lexes to the same `br` token (verified: marked.lexer('- a\\\n b')
216
+ // gives the item a `br`). The cost, stated in §3.12: a user who wrote
217
+ // two trailing spaces gets a backslash back — on a block they were
218
+ // editing, with the same meaning and the same line count. Untouched
219
+ // blocks are replayed byte-for-byte by §3.4's bystander rule and never
220
+ // reach this code.
221
+ const hardBreak = typeof node.getAttribute === 'function' &&
222
+ node.getAttribute('data-hard-break') === '1';
223
+ out += hardBreak ? '\\\n' : '<br>';
200
224
  firstSegment = false;
201
225
  continue;
202
226
  }