@helping-ai-workflow/md2doc 2.10.0 → 2.11.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/lib/editor/blockmap.js +126 -8
- package/lib/editor/client.js +2863 -732
- package/lib/editor/convert-md.js +200 -0
- package/lib/editor/indent-clamp.js +152 -0
- package/lib/editor/inline-md.js +25 -1
- package/lib/editor/list-md.js +500 -4
- package/lib/editor/server.js +69 -2
- package/lib/editor/table-md.js +13 -1
- package/lib/md2doc.js +375 -52
- package/package.json +2 -2
|
@@ -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
|
+
});
|
package/lib/editor/inline-md.js
CHANGED
|
@@ -196,7 +196,31 @@
|
|
|
196
196
|
continue;
|
|
197
197
|
}
|
|
198
198
|
if (name === 'BR') {
|
|
199
|
-
|
|
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
|
}
|