mnfst 0.5.232 → 0.5.233

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.
@@ -19,6 +19,37 @@
19
19
  const MARKS = { STRONG: '**', B: '**', EM: '*', I: '*', S: '~~', DEL: '~~', CODE: '`' };
20
20
  const INLINE_OK = new Set([...Object.keys(INLINE_TAGS).map(t => t.toUpperCase()), 'BR', 'WBR', 'A', 'IMG']);
21
21
 
22
+ /* ---- Adjacent marks ----
23
+ Neighbouring inline elements alike (same tag and attributes; for markdown, the same
24
+ delimiter) are one: merged, children first, the selection kept on the nodes it was in. */
25
+ const sameInline = (a, b) => !!b && b.nodeType === 1 && a.tagName === b.tagName
26
+ && a.attributes.length === b.attributes.length && [...a.attributes].every(x => b.getAttribute(x.name) === x.value);
27
+ const sameDelimiter = (a, b) => !!b && b.nodeType === 1 && (a.tagName === 'A' ? sameInline(a, b) : !!MARKS[a.tagName] && MARKS[a.tagName] === MARKS[b.tagName]);
28
+ function mergeAdjacent(root, same = sameInline) {
29
+ const r = root.ownerDocument === document && root.isConnected ? range() : null;
30
+ const keep = r && root.contains(r.startContainer) ? [r.startContainer, r.startOffset, r.endContainer, r.endOffset] : null;
31
+ let any = false;
32
+ const walk = (p) => {
33
+ let merged = false;
34
+ [...p.children].forEach(walk);
35
+ for (let c = p.firstChild; c; c = c.nextSibling) {
36
+ while (c.nodeType === 1 && INLINE_OK.has(c.tagName) && !/^(BR|WBR|IMG)$/.test(c.tagName)) {
37
+ const gaps = [];
38
+ let n = c.nextSibling;
39
+ while (n && n.nodeType === 3 && !n.length) { gaps.push(n); n = n.nextSibling; } // empty text between them is nothing
40
+ if (!same(c, n)) break;
41
+ const base = c.childNodes.length;
42
+ if (keep) for (const i of [0, 2]) { if (gaps.includes(keep[i])) { keep[i] = c; keep[i + 1] = base; } else if (keep[i] === n) { keep[i] = c; keep[i + 1] += base; } }
43
+ gaps.forEach(g => g.remove()); c.append(...n.childNodes); n.remove(); merged = any = true;
44
+ }
45
+ }
46
+ if (merged) walk(p);
47
+ };
48
+ walk(root);
49
+ if (any && keep && keep[0].isConnected && keep[2].isConnected) try { const nr = document.createRange(); nr.setStart(keep[0], keep[1]); nr.setEnd(keep[2], keep[3]); const s = sel(); s.removeAllRanges(); s.addRange(nr); } catch { }
50
+ return any;
51
+ }
52
+
22
53
  /* ---- URL policy ---- */
23
54
  // No script schemes; data: only as an image.
24
55
  function unsafeUrl(url, image) {
@@ -32,16 +63,44 @@
32
63
 
33
64
  /* ---- HTML → Markdown ---- */
34
65
  const ENTITY = /&(?=#?[a-z0-9]+;)/gi;
35
- const esc = (t) => t.replace(/([\\`*_~[\]<>])/g, '\\$1').replace(ENTITY, '\\&');
66
+ const MD_PUNCT = /[!-\/:-@\[-`{-~\u2000-\u206f\u2e00-\u2e7f]/;
67
+ // An _ between letters or digits cannot open or close emphasis: it is left as written
68
+ const esc = (t) => t.replace(/[\\`*_~[\]<>]/g, (c, i) => c === '_' && /[\p{L}\p{N}]/u.test(t[i - 1] || '') && /[\p{L}\p{N}]/u.test(t[i + 1] || '') ? c : '\\' + c).replace(ENTITY, '\\&');
36
69
  // Line starts that would parse as a heading, list or rule
37
70
  const escLead = (text) => text.split('\n').map(l => l
38
71
  .replace(/^(\s*)(#{1,6})(?=\s|$)/, '$1\\$2')
39
72
  .replace(/^(\s*)([-+])(?=\s)/, '$1\\$2')
40
73
  .replace(/^(\s*)-(?=-{2,}\s*$)/, '$1\\-')
41
74
  .replace(/^(\s*)(\d+)\.(?=\s)/, '$1$2\\.')).join('\n');
42
- const mdUrl = (u) => u.replace(/[\s()<>]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0')).replace(ENTITY, '\\&');
75
+ // A link destination that reads back to the same URL: spaces and angle brackets encoded,
76
+ // backslashes and unbalanced parentheses escaped (balanced ones stay, as CommonMark reads them)
77
+ const mdUrl = (u) => {
78
+ const s = String(u).replace(/[\s<>]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0'));
79
+ const open = [], loose = new Set();
80
+ for (let i = 0; i < s.length; i++) { if (s[i] === '(') open.push(i); else if (s[i] === ')') { if (open.length) open.pop(); else loose.add(i); } }
81
+ open.forEach(i => loose.add(i));
82
+ return s.split('').map((c, i) => c === '\\' ? '\\\\' : loose.has(i) ? '\\' + c : c).join('').replace(ENTITY, '\\&');
83
+ };
43
84
 
44
- function inline(node) {
85
+ // `ctx`: the characters just outside this content, and whether an `_` mark encloses it.
86
+ // em/strong use _/__ where no word character is outside them (and none encloses them), so
87
+ // their delimiters cannot pair with an inner * run; *, ** otherwise.
88
+ const wordChar = (c) => !!c && !/\s/.test(c) && !MD_PUNCT.test(c);
89
+ const textEdge = (n, last) => {
90
+ if (!n) return null;
91
+ if (n.nodeType === 3) return n.nodeValue ? (last ? n.nodeValue[n.nodeValue.length - 1] : n.nodeValue[0]) : textEdge(last ? n.previousSibling : n.nextSibling, last);
92
+ if (n.nodeType !== 1) return textEdge(last ? n.previousSibling : n.nextSibling, last);
93
+ if (n.tagName === 'BR') return '\n';
94
+ if (n.tagName === 'IMG') return last ? ')' : '!';
95
+ if (MARKS[n.tagName] || n.tagName === 'A') return last ? '*' : '*'; // a delimiter or bracket: punctuation
96
+ const t = n.textContent; return t ? (last ? t[t.length - 1] : t[0]) : textEdge(last ? n.previousSibling : n.nextSibling, last);
97
+ };
98
+ // A * mark inside with text right against it: its delimiters could close an enclosing * mark
99
+ const riskyInside = (n) => [...n.querySelectorAll('strong, b, em, i')].some(d => {
100
+ const before = d.previousSibling ? textEdge(d.previousSibling, true) : null, after = d.nextSibling ? textEdge(d.nextSibling, false) : null;
101
+ return (before != null && !/\s/.test(before)) || (after != null && !/\s/.test(after));
102
+ });
103
+ function inlineRaw(node, ctx) {
45
104
  let out = '';
46
105
  node.childNodes.forEach(n => {
47
106
  if (n.nodeType === 3) { out += esc(n.textContent); return; }
@@ -49,15 +108,87 @@
49
108
  const tag = n.tagName;
50
109
  if (tag === 'BR') { out += ' \n'; return; }
51
110
  if (tag === 'IMG') { const src = n.getAttribute('src') || ''; out += src && !unsafeUrl(src, true) ? `![${esc(n.getAttribute('alt') || '')}](${mdUrl(src)})` : ''; return; }
52
- if (tag === 'A') { const h = n.getAttribute('href') || ''; out += h && !unsafeUrl(h) ? `[${inline(n)}](${mdUrl(h)})` : inline(n); return; }
53
- const m = MARKS[tag];
54
- if (!m) { out += inline(n); return; }
55
- const body = tag === 'CODE' ? n.textContent : inline(n);
56
- out += body.trim() ? m + body + m : body;
111
+ if (tag === 'A' && /(^|[^\\])!$/.test(out) && n.getAttribute('href')) out = out.slice(0, -1) + '\\!'; // not an image
112
+ if (tag === 'A' && !n.hasAttribute('title') && n.children.length === 0 && /^[a-z][a-z0-9+.-]{1,31}:[^\s<>]*$/i.test(n.getAttribute('href') || '') && n.textContent === n.getAttribute('href') && !unsafeUrl(n.textContent)) { out += '<' + n.textContent + '>'; return; }
113
+ if (tag === 'A') {
114
+ const h = n.getAttribute('href') || '', title = n.getAttribute('title');
115
+ const t = title != null ? ` "${title.replace(/[\\"]/g, '\\$&').replace(/\n/g, ' ')}"` : '';
116
+ out += h && !unsafeUrl(h) ? `[${inlineRaw(n, { ...ctx, prev: '[', next: ']', under: ctx.under, star: ctx.star })}](${mdUrl(h)}${t})` : inlineRaw(n, ctx);
117
+ return;
118
+ }
119
+ let m = MARKS[tag];
120
+ if (!m) { out += inlineRaw(n, ctx); return; }
121
+ if (tag === 'CODE') {
122
+ // A fence longer than any backtick run inside; padded where the code starts or ends with one
123
+ const code = n.textContent; if (!code) return;
124
+ const fence = '`'.repeat(Math.max(0, ...(code.match(/`+/g) || []).map(r => r.length)) + 1);
125
+ const pad = /^`|`$/.test(code) || (/^ /.test(code) && / $/.test(code) && code.trim()) ? ' ' : '';
126
+ out += fence + pad + code + pad + fence;
127
+ return;
128
+ }
129
+ if (!n.textContent.trim()) { out += inlineRaw(n, ctx); return; }
130
+ let body = inlineRaw(n, { ...ctx, prev: m[0], next: m[0], under: ctx.under });
131
+ // Delimiters hug the text: spaces go outside
132
+ let [, lead, core, trail] = body.match(/^(\s*)([\s\S]*?)(\s*)$/);
133
+ // _ where * would touch text (it could pair with an enclosing *), or encloses a mark that does
134
+ const after = textEdge(n.nextSibling, false);
135
+ const prev = lead ? ' ' : (out ? out[out.length - 1] : ctx.prev), next = trail ? ' ' : (after == null ? ctx.next : after);
136
+ const nonspace = /\S/.test(prev || '') || /\S/.test(next || '');
137
+ if (m[0] === '*' && (ctx.html === 'all' || (ctx.html === 'touch' && nonspace))) {
138
+ const t = tag === 'STRONG' || tag === 'B' ? 'strong' : 'em';
139
+ [, lead, core, trail] = inlineRaw(n, { ...ctx, prev: '>', next: '<', star: false }).match(/^(\s*)([\s\S]*?)(\s*)$/);
140
+ out += lead + `<${t}>` + core + `</${t}>` + trail;
141
+ return;
142
+ }
143
+ const allowed = m[0] === '*' && !ctx.under && !wordChar(prev) && !wordChar(next) && (!!lead || !/(^|[^\\])_$/.test(out));
144
+ // * unless it would not read back: right after another * run, around a * mark with text
145
+ // against it, or with text against it inside a * mark
146
+ const touching = (!lead && /\S$/.test(out)) || (!trail && after != null && /\S/.test(after));
147
+ if (allowed && ((!lead && /(^|[^\\])\*$/.test(out)) || riskyInside(n) || (ctx.star && touching))) {
148
+ m = m.replace(/\*/g, '_');
149
+ [, lead, core, trail] = inlineRaw(n, { ...ctx, prev: '_', next: '_', under: true, star: ctx.star }).match(/^(\s*)([\s\S]*?)(\s*)$/);
150
+ } else if (m[0] === '*' && ctx.star && touching) {
151
+ // Inside a * mark with text against it, a * run could close the enclosing one: no
152
+ // markdown form says this, so it is written as HTML (which markdown carries)
153
+ const t = tag === 'STRONG' || tag === 'B' ? 'strong' : 'em';
154
+ [, lead, core, trail] = inlineRaw(n, { ...ctx, prev: '>', next: '<', under: ctx.under, star: false }).match(/^(\s*)([\s\S]*?)(\s*)$/);
155
+ out += lead + `<${t}>` + core + `</${t}>` + trail;
156
+ return;
157
+ } else if (m[0] === '*') [, lead, core, trail] = inlineRaw(n, { ...ctx, prev: '*', next: '*', under: ctx.under, star: true }).match(/^(\s*)([\s\S]*?)(\s*)$/);
158
+ out += lead + m + core + m + trail;
57
159
  });
58
160
  return out;
59
161
  }
60
162
 
163
+ // Inline markdown for a node, checked by reading it back: in the rare arrangement where a
164
+ // * or _ run would pair differently, em/strong touching text go as HTML, else all of them
165
+ function inline(node) {
166
+ const want = shapeOf(node), ctx = { prev: '', next: '', under: false, star: false, html: '' };
167
+ let first = null;
168
+ for (const html of ['', 'touch', 'all']) {
169
+ const md = inlineRaw(node, { ...ctx, html });
170
+ if (first == null) first = md;
171
+ const t = document.createElement('template'); t.innerHTML = inlineHtml(md);
172
+ if (shapeOf(t.content) === want) return md;
173
+ }
174
+ return first; // what no form can carry (an unsafe link, say) is not a reason to change notation
175
+ }
176
+ // Every character with the formatting it shows (strong/em/strike on spaces show nothing)
177
+ function shapeOf(root) {
178
+ const out = [], KIND = { STRONG: 'b', B: 'b', EM: 'i', I: 'i', S: 's', DEL: 's', CODE: 'c' };
179
+ const walk = (n, f) => n.childNodes.forEach(c => {
180
+ if (c.nodeType === 3) { for (const ch of c.nodeValue) out.push(ch + (/\s/.test(ch) ? f.filter(x => x[0] !== 'b' && x[0] !== 'i' && x[0] !== 's') : f).join()); return; }
181
+ if (c.nodeType !== 1) return;
182
+ if (c.tagName === 'BR') { out.push('\n'); return; }
183
+ if (c.tagName === 'IMG') { const src = c.getAttribute('src') || ''; if (src && !unsafeUrl(src, true)) out.push('img:' + src + '|' + (c.getAttribute('alt') || '')); return; }
184
+ const href = c.tagName === 'A' ? c.getAttribute('href') || '' : '';
185
+ const k = KIND[c.tagName], h = href && !unsafeUrl(href) ? 'a:' + href : null;
186
+ walk(c, [...new Set([...f, ...(k ? [k] : []), ...(h ? [h] : [])])].sort());
187
+ });
188
+ walk(root, []);
189
+ return out.join('\u0001').replace(/^(\s(\u0001|$))+|(\u0001\s)+$/g, '');
190
+ }
191
+
61
192
  const preText = (n) => [...n.childNodes].map(c =>
62
193
  c.nodeName === 'BR' ? '\n' : c.nodeType === 3 ? c.nodeValue : preText(c)).join('');
63
194
 
@@ -66,7 +197,7 @@
66
197
  if (node.nodeType !== 1) return '';
67
198
  const tag = node.tagName;
68
199
  if (tag === 'HR') return '---';
69
- if (tag === 'PRE') return '```\n' + preText(node).replace(/\n+$/, '') + '\n```';
200
+ if (tag === 'PRE') return '```' + (node.getAttribute('data-lang') || '').replace(/[^\w+#.-]/g, '') + '\n' + preText(node).replace(/\n+$/, '') + '\n```';
70
201
  if (/^H[1-6]$/.test(tag)) return '#'.repeat(+tag[1]) + ' ' + inline(node).trim();
71
202
  if (tag === 'BLOCKQUOTE') return blocks(node, depth).split('\n').map(l => ('> ' + l).trimEnd()).join('\n');
72
203
  if (tag === 'TABLE') return tableLines(node).map(cells => escLead(cells.map(cellMarkdown).join(CELL_SEP))).join('\n\n');
@@ -110,11 +241,18 @@
110
241
  return parts.join('\n\n');
111
242
  }
112
243
 
113
- const toMarkdown = (root) => blocks(root, 0).replace(/\n{3,}/g, '\n\n').trim();
244
+ // Markdown carries a mark once: one inside another with the same delimiter adds nothing
245
+ const flattenMarks = (root) => [...root.querySelectorAll('*')].forEach(el => {
246
+ const m = MARKS[el.tagName]; if (!m) return;
247
+ for (let p = el.parentElement; p && p !== root; p = p.parentElement) if (MARKS[p.tagName] === m) { el.replaceWith(...el.childNodes); return; }
248
+ });
249
+ const mdCopy = (root) => { const c = root.cloneNode(true); flattenMarks(c); mergeAdjacent(c, sameDelimiter); return c; };
250
+ const toMarkdown = (root) => blocks(mdCopy(root), 0).replace(/\n{3,}/g, '\n\n').trim();
114
251
  // Single-line values never parse block syntax, so line starts need no escape
115
252
  const inlineMarkdown = (root) => {
116
253
  const flat = document.createElement('div');
117
254
  flat.innerHTML = flattenLines(root.innerHTML, true);
255
+ flattenMarks(flat); mergeAdjacent(flat, sameDelimiter);
118
256
  return inline(flat).trim();
119
257
  };
120
258
 
@@ -181,19 +319,146 @@
181
319
  /* ---- Markdown → HTML ---- */
182
320
  const escHtml = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
183
321
 
184
- // Escapes and code spans (verbatim) are held out of the mark passes, left to right, and restored last
185
- const HOLD = '';
186
- function inlineHtml(s) {
187
- const held = [];
188
- return escHtml(s)
189
- .replace(/\\(&lt;|&gt;|&amp;|[\\`*_~[\]#+.-])|`([^`]+)`/g, (_, c, code) => HOLD + (held.push(code != null ? `<code>${code}</code>` : c) - 1) + HOLD)
190
- .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
191
- .replace(/~~([^~]+)~~/g, '<s>$1</s>')
192
- .replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em>$2</em>')
193
- .replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (_, alt, src) => unsafeUrl(src, true) ? '' : `<img src="${src}" alt="${alt}">`)
194
- .replace(/\[([^\]]*)\]\(([^)\s]+)\)/g, (_, t, h) => unsafeUrl(h) ? t : `<a href="${h}">${t}</a>`)
195
- .replace(/ {2}\n/g, '<br>')
196
- .replace(new RegExp(HOLD + '(\\d+)' + HOLD, 'g'), (_, i) => held[+i]);
322
+ /* Inline markdown: a small CommonMark-style reader. Code spans, links and images are found
323
+ left to right; runs of * _ ~ are delimiters, matched innermost first with CommonMark's
324
+ rules (the rule of 3 included). `*` and `~~` open before any non-space and close after
325
+ one (CommonMark also weighs punctuation); `_` keeps CommonMark's word-boundary rules. */
326
+ const PUNCT = /[!-\/:-@\[-`{-~ -⸀-⹿]/;
327
+ const isSpace = (c) => !c || /\s/.test(c);
328
+ // opts.raw: backslashes are text and no inline HTML is read (markdown being typed);
329
+ // opts.after: the character after the source, for the flanking of a run that ends it
330
+ function inlineHtml(src, opts = {}) {
331
+ const s = String(src == null ? '' : src);
332
+ const parts = [], delims = [];
333
+ let text = '';
334
+ const flush = () => { if (text) { parts.push(escHtml(text)); text = ''; } };
335
+ // A code span's closing run: the next backtick run of the same length
336
+ const codeEnd = (from, len) => {
337
+ for (let j = from; j < s.length;) {
338
+ const k = s.indexOf('`', j); if (k < 0) return -1;
339
+ const n = s.slice(k).match(/^`+/)[0].length;
340
+ if (n === len) return k;
341
+ j = k + n;
342
+ }
343
+ return -1;
344
+ };
345
+ // A link destination from the "(" at `at`: <…>, or balanced parentheses; an optional title is skipped
346
+ const destination = (at) => {
347
+ let j = at + 1, url;
348
+ while (s[j] === ' ' || s[j] === '\t') j++;
349
+ if (s[j] === '<') {
350
+ const e = s.indexOf('>', j); if (e < 0 || /[\n<]/.test(s.slice(j + 1, e))) return null;
351
+ url = s.slice(j + 1, e); j = e + 1;
352
+ } else {
353
+ const from = j;
354
+ for (let depth = 0; j < s.length; j++) {
355
+ const c = s[j];
356
+ if (c === '\\' && /[!-\/:-@\[-`{-~]/.test(s[j + 1] || '')) { j++; continue; }
357
+ if (/[\s\x00-\x1f]/.test(c)) break;
358
+ if (c === '(') depth++;
359
+ else if (c === ')') { if (!depth) break; depth--; }
360
+ }
361
+ url = s.slice(from, j);
362
+ }
363
+ const gap = j;
364
+ while (/[ \t\n]/.test(s[j] || '')) j++;
365
+ const q = s[j];
366
+ let title = null;
367
+ if (j > gap && (q === '"' || q === "'" || q === '(')) {
368
+ const close = q === '(' ? ')' : q;
369
+ let e = j + 1; for (; e < s.length && s[e] !== close; e++) if (s[e] === '\\') e++;
370
+ if (e >= s.length) return null;
371
+ title = s.slice(j + 1, e).replace(/\\([!-\/:-@\[-`{-~])/g, '$1');
372
+ j = e + 1; while (/[ \t\n]/.test(s[j] || '')) j++;
373
+ }
374
+ if (s[j] !== ')') return null;
375
+ return { url: url.replace(/\\([!-\/:-@\[-`{-~])/g, '$1'), title, end: j + 1 };
376
+ };
377
+ // A bracket's matching close, skipping escapes and code spans
378
+ const closeBracket = (from) => {
379
+ for (let i = from, depth = 0; i < s.length; i++) {
380
+ const c = s[i];
381
+ if (c === '\\') { i++; continue; }
382
+ if (c === '`') { const n = s.slice(i).match(/^`+/)[0].length, e = codeEnd(i + n, n); i = (e < 0 ? i + n : e + n) - 1; continue; }
383
+ if (c === '[') depth++;
384
+ else if (c === ']') { if (!depth) return i; depth--; }
385
+ }
386
+ return -1;
387
+ };
388
+ for (let i = 0; i < s.length;) {
389
+ const c = s[i];
390
+ if (c === '\\' && !opts.raw && i + 1 < s.length && /[!-\/:-@\[-`{-~]/.test(s[i + 1])) { text += s[i + 1]; i += 2; continue; }
391
+ if (c === '`') {
392
+ const run = s.slice(i).match(/^`+/)[0], end = codeEnd(i + run.length, run.length);
393
+ if (end < 0) { text += run; i += run.length; continue; }
394
+ let code = s.slice(i + run.length, end).replace(/\n/g, ' ');
395
+ if (code.length > 1 && code[0] === ' ' && code[code.length - 1] === ' ' && code.trim()) code = code.slice(1, -1);
396
+ flush(); parts.push(`<code>${escHtml(code)}</code>`);
397
+ i = end + run.length; continue;
398
+ }
399
+ if (c === '[' || (c === '!' && s[i + 1] === '[')) {
400
+ const img = c === '!', open = i + (img ? 2 : 1), close = closeBracket(open);
401
+ const m = close > 0 && s[close + 1] === '(' && destination(close + 1);
402
+ if (m) {
403
+ const url = m.url, body = s.slice(open, close);
404
+ flush();
405
+ if (img) parts.push(unsafeUrl(url, true) ? '' : `<img src="${escHtml(url)}" alt="${escHtml(body.replace(/\\([!-\/:-@\[-`{-~])/g, '$1'))}">`);
406
+ else parts.push(unsafeUrl(url) || !url ? inlineHtml(body, { raw: opts.raw }) : `<a href="${escHtml(url)}"${m.title != null ? ` title="${escHtml(m.title)}"` : ''}>${inlineHtml(body, { raw: opts.raw })}</a>`);
407
+ i = m.end; continue;
408
+ }
409
+ }
410
+ // The inline HTML this writer uses where markdown has no form (raw tags, as CommonMark passes them)
411
+ if (c === '<' && !opts.raw) {
412
+ // Inline HTML for marks markdown has (as CommonMark passes it); other tags stay text
413
+ const t = s.slice(i).match(/^<(\/?)(strong|em|b|i|s|del|code)>/i);
414
+ if (t) { const tag = { b: 'strong', i: 'em', del: 's' }[t[2].toLowerCase()] || t[2].toLowerCase(); flush(); parts.push(`<${t[1]}${tag}>`); i += t[0].length; continue; }
415
+ // An autolink: <scheme:…>
416
+ const a = s.slice(i).match(/^<([a-z][a-z0-9+.-]{1,31}:[^\s<>]*)>/i);
417
+ if (a && !unsafeUrl(a[1])) { flush(); parts.push(`<a href="${escHtml(a[1])}">${escHtml(a[1])}</a>`); i += a[0].length; continue; }
418
+ }
419
+ if (c === '\n' && / {2,}$/.test(text)) { text = text.replace(/ +$/, ''); flush(); parts.push('<br>'); i++; continue; }
420
+ if (c === '*' || c === '_' || c === '~') {
421
+ const run = s.slice(i).match(c === '*' ? /^\*+/ : c === '_' ? /^_+/ : /^~+/)[0];
422
+ const before = i ? s[i - 1] : '', after = i + run.length < s.length ? s[i + run.length] : (opts.after || '');
423
+ let open, close;
424
+ if (c === '_') {
425
+ const left = !isSpace(after) && (!PUNCT.test(after) || isSpace(before) || PUNCT.test(before));
426
+ const right = !isSpace(before) && (!PUNCT.test(before) || isSpace(after) || PUNCT.test(after));
427
+ open = left && (!right || PUNCT.test(before)); close = right && (!left || PUNCT.test(after));
428
+ } else { open = !isSpace(after); close = !isSpace(before); }
429
+ if (c === '~' && run.length !== 2) { open = close = false; }
430
+ if (!open && !close) { text += run; i += run.length; continue; }
431
+ flush();
432
+ const d = { ch: c, count: run.length, orig: run.length, open, close };
433
+ parts.push(d); delims.push(d);
434
+ i += run.length; continue;
435
+ }
436
+ text += c; i++;
437
+ }
438
+ flush();
439
+ // Innermost first: each closer takes the nearest opener it can pair with
440
+ for (let ci = 0; ci < delims.length; ci++) {
441
+ const closer = delims[ci];
442
+ while (closer.close && closer.count) {
443
+ let oi = ci - 1;
444
+ for (; oi >= 0; oi--) {
445
+ const o = delims[oi];
446
+ if (o.ch !== closer.ch || !o.open || !o.count) continue;
447
+ if (closer.ch === '~') { if (o.count === 2 && closer.count === 2) break; continue; }
448
+ if ((o.close || closer.open) && (o.orig + closer.orig) % 3 === 0 && !(o.orig % 3 === 0 && closer.orig % 3 === 0)) continue;
449
+ break;
450
+ }
451
+ if (oi < 0) break;
452
+ const opener = delims[oi], use = closer.ch === '~' ? 2 : (opener.count >= 2 && closer.count >= 2 ? 2 : 1);
453
+ const tag = closer.ch === '~' ? 's' : use === 2 ? 'strong' : 'em';
454
+ const a = parts.indexOf(opener), b = parts.indexOf(closer);
455
+ const inner = parts.splice(a + 1, b - a - 1).map(p => typeof p === 'string' ? p : p.ch.repeat(p.count)).join('');
456
+ parts.splice(a + 1, 0, `<${tag}>${inner}</${tag}>`);
457
+ for (let k = oi + 1; k < ci; k++) delims[k].count = 0; // delimiters inside are text now (already rendered)
458
+ opener.count -= use; closer.count -= use;
459
+ }
460
+ }
461
+ return parts.map(p => typeof p === 'string' ? p : p.ch.repeat(p.count)).join('');
197
462
  }
198
463
 
199
464
  // Four spaces per nesting level.
@@ -220,7 +485,7 @@
220
485
  function fromMarkdown(md) {
221
486
  const lines = String(md == null ? '' : md).replace(/\r\n?/g, '\n').split('\n');
222
487
  const out = [];
223
- let para = [], list = null, quote = null, fence = null;
488
+ let para = [], list = null, quote = null, fence = null, lang = '';
224
489
  const flushPara = () => { if (para.length) { out.push(`<p>${inlineHtml(para.join('\n'))}</p>`); para = []; } };
225
490
  const flushList = () => { if (!list) return; for (let i = 0; i < list.length;) { const [html, next] = renderList(list, i, list[i].depth); out.push(html); i = next > i ? next : i + 1; } list = null; };
226
491
  const flushQuote = () => { if (quote) { out.push(`<blockquote>${fromMarkdown(quote.join('\n'))}</blockquote>`); quote = null; } };
@@ -228,8 +493,8 @@
228
493
 
229
494
  for (const raw of lines) {
230
495
  const line = raw.replace(/[^\S\n]+$/, m => m.length >= 2 ? ' ' : '');
231
- if (fence !== null) { if (/^```/.test(line)) { out.push(`<pre><code>${escHtml(fence.join('\n'))}</code></pre>`); fence = null; } else fence.push(raw); continue; }
232
- if (/^```/.test(line)) { flushAll(); fence = []; continue; }
496
+ if (fence !== null) { if (/^```\s*$/.test(line)) { out.push(`<pre${lang ? ` data-lang="${escHtml(lang)}"` : ''}><code>${escHtml(fence.join('\n'))}</code></pre>`); fence = null; } else fence.push(raw); continue; }
497
+ if (/^```/.test(line)) { flushAll(); fence = []; lang = (line.slice(3).trim().match(/^[\w+#.-]+/) || [''])[0]; continue; }
233
498
  if (!line.trim()) { flushAll(); continue; }
234
499
  const h = line.match(/^(#{1,6})\s+(.*)$/);
235
500
  if (h) { flushAll(); out.push(`<h${h[1].length}>${inlineHtml(h[2])}</h${h[1].length}>`); continue; }
@@ -245,7 +510,7 @@
245
510
  flushList(); flushQuote();
246
511
  para.push(line);
247
512
  }
248
- if (fence) out.push(`<pre><code>${escHtml(fence.join('\n'))}</code></pre>`);
513
+ if (fence) out.push(`<pre${lang ? ` data-lang="${escHtml(lang)}"` : ''}><code>${escHtml(fence.join('\n'))}</code></pre>`);
249
514
  flushAll();
250
515
  return out.join('');
251
516
  }
@@ -268,9 +533,10 @@
268
533
  const meta = INLINE_TAGS[c.tagName.toLowerCase()];
269
534
  [...c.attributes].forEach(a => {
270
535
  const n = a.name.toLowerCase();
271
- if (c.tagName === 'A' && n === 'href' && !unsafeUrl(a.value)) return;
536
+ if (c.tagName === 'A' && ((n === 'href' && !unsafeUrl(a.value)) || n === 'title')) return;
272
537
  if (c.tagName === 'IMG' && (n === 'alt' || (n === 'src' && !unsafeUrl(a.value, true)))) return;
273
538
  if (c.tagName === 'INPUT' && (n === 'type' || n === 'checked')) return;
539
+ if (c.tagName === 'PRE' && n === 'data-lang' && /^[\w+#.-]{1,40}$/.test(a.value)) return; // a code fence's language
274
540
  if (/^(TD|TH)$/.test(c.tagName) && (n === 'colspan' || n === 'rowspan') && /^[1-9]\d{0,2}$/.test(a.value)) return;
275
541
  if (meta && meta.attr === n && (n !== 'dir' || /^(ltr|rtl|auto)$/i.test(a.value))) return;
276
542
  if (n === 'style' && keepStyle && !/expression|url\s*\(|javascript:|@import/i.test(a.value)) return;
@@ -278,6 +544,7 @@
278
544
  });
279
545
  });
280
546
  walk(t.content);
547
+ mergeAdjacent(t.content);
281
548
  return t.innerHTML;
282
549
  }
283
550
 
@@ -392,6 +659,28 @@
392
659
  return pre.toString().length;
393
660
  }
394
661
 
662
+ // The selection as text offsets in root, and back
663
+ function textSpan(root) {
664
+ const r = range(); if (!r || !root.contains(r.startContainer) || !root.contains(r.endContainer)) return null;
665
+ const at = (node, off) => { const pre = document.createRange(); pre.selectNodeContents(root); pre.setEnd(node, off); return pre.toString().length; };
666
+ return [at(r.startContainer, r.startOffset), at(r.endContainer, r.endOffset)];
667
+ }
668
+ function selectSpan(root, [from, to]) {
669
+ const point = (offset, end) => {
670
+ const w = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
671
+ let seen = 0, last = null;
672
+ for (let n = w.nextNode(); n; n = w.nextNode()) {
673
+ last = n;
674
+ if (end ? seen + n.length >= offset : seen + n.length > offset) return [n, offset - seen];
675
+ seen += n.length;
676
+ }
677
+ return last ? [last, last.length] : [root, 0];
678
+ };
679
+ const r = document.createRange(), a = point(from, false), b = point(to, true);
680
+ r.setStart(a[0], a[1]); r.setEnd(b[0], b[1]);
681
+ const s = sel(); s.removeAllRanges(); s.addRange(r);
682
+ }
683
+
395
684
  function caretIn(root, offset) {
396
685
  const s = sel(), r = document.createRange();
397
686
  if (offset == null) offset = 0;
@@ -630,7 +919,9 @@
630
919
  const cellAt = () => ancestor('td, th') || (cellDrag && cellDrag.anchor.isConnected ? cellDrag.anchor : null);
631
920
  const tableAt = () => { const c = cellAt(); return c && c.closest('table'); };
632
921
  const rowsOf = (table) => [...table.rows];
633
- const emptyCell = () => { const td = document.createElement('td'); td.appendChild(document.createElement('br')); return td; };
922
+ const emptyCell = (tag = 'td') => { const td = document.createElement(tag); td.appendChild(document.createElement('br')); return td; };
923
+ // A header row (in a thead, or of header cells only) is padded with header cells
924
+ const rowCellTag = (row) => row.parentElement && row.parentElement.tagName === 'THEAD' || (row.cells.length && [...row.cells].every(c => c.tagName === 'TH')) ? 'th' : 'td';
634
925
 
635
926
  // Occupancy grid: every slot maps to the cell covering it, spans included
636
927
  function tableGrid(table) {
@@ -674,7 +965,7 @@
674
965
  const width = Math.max(0, ...grid.map(r => r.length));
675
966
  rows.forEach((row, r) => {
676
967
  const have = (grid[r] || []).filter(Boolean).length;
677
- for (let i = have; i < width; i++) row.appendChild(emptyCell());
968
+ for (let i = have; i < width; i++) row.appendChild(emptyCell(rowCellTag(row)));
678
969
  });
679
970
  if (!table.querySelector('tbody, thead')) {
680
971
  const body = document.createElement('tbody');
@@ -783,13 +1074,14 @@
783
1074
  const cols = cell.colSpan || 1, rows = cell.rowSpan || 1;
784
1075
  if (cols < 2 && rows < 2) return;
785
1076
  cell.removeAttribute('colspan'); cell.removeAttribute('rowspan');
786
- for (let i = 1; i < cols; i++) cell.after(emptyCell());
1077
+ // A header cell splits into header cells; rows below take their row's kind
1078
+ for (let i = 1; i < cols; i++) cell.after(emptyCell(cell.tagName.toLowerCase()));
787
1079
  if (rows > 1) {
788
1080
  const table = cell.closest('table'), { row, col } = gridPosition(cell);
789
1081
  for (let r = 1; r < rows; r++) {
790
1082
  const target = rowsOf(table)[row + r]; if (!target) break;
791
1083
  const at = cellCovering(target, col);
792
- const fresh = emptyCell();
1084
+ const fresh = emptyCell(rowCellTag(target));
793
1085
  if (at) at.before(fresh); else target.appendChild(fresh);
794
1086
  }
795
1087
  }
@@ -1278,6 +1570,13 @@
1278
1570
  if (!frag.lastChild) return;
1279
1571
  record(area);
1280
1572
  r.deleteContents();
1573
+ // A caret between a block field's blocks (an empty field clicked) goes into the block beside it
1574
+ const c0 = range();
1575
+ if (c0 && c0.startContainer === area && !(area._rt && area._rt.inline)) {
1576
+ const into = (b) => b && b.nodeType === 1 && /^(P|H[1-6]|BLOCKQUOTE|PRE)$/.test(b.tagName);
1577
+ const before = area.childNodes[c0.startOffset - 1], after = area.childNodes[c0.startOffset];
1578
+ if (into(before)) caretIn(before, before.textContent.length); else if (into(after)) caretIn(after, 0);
1579
+ }
1281
1580
  // An empty block's placeholder <br> goes; at a block's start, so does the content's leading space
1282
1581
  const holder = blockEl() || area;
1283
1582
  if (!holder.textContent.length && !holder.querySelector('img')) holder.querySelectorAll('br').forEach(b => b.remove());
@@ -1287,7 +1586,16 @@
1287
1586
  }
1288
1587
  let end = frag.lastChild;
1289
1588
  if (!end) return;
1589
+ const lastTop = end;
1290
1590
  const cr = range(); if (cr && area.contains(cr.startContainer)) cr.insertNode(frag); else area.appendChild(frag);
1591
+ // Pasted content ending in a mark: the caret sits after it, as after a conversion
1592
+ if (lastTop.nodeType === 1 && INLINE_OK.has(lastTop.tagName) && !/^(BR|WBR|IMG)$/.test(lastTop.tagName) && lastTop.isConnected) {
1593
+ caretAt(tailOf(lastTop), 0);
1594
+ area._exit = lastTop;
1595
+ record(area);
1596
+ area.dispatchEvent(new Event('input', { bubbles: true }));
1597
+ return;
1598
+ }
1291
1599
  // The caret ends inside the last text, never at an element boundary (an IME composition there is left behind)
1292
1600
  const s = sel(), nr = document.createRange();
1293
1601
  if (end.nodeType === 1) { const w = document.createTreeWalker(end, NodeFilter.SHOW_TEXT); let t = null; for (let n = w.nextNode(); n; n = w.nextNode()) t = n; if (t) end = t; }
@@ -1357,7 +1665,10 @@
1357
1665
  const n = (v) => Math.max(1, Math.min(20, parseInt(v, 10) || 3));
1358
1666
  const cols = n(parts[0]), rows = n(parts[1]);
1359
1667
  const row = `<tr>${'<td><br></td>'.repeat(cols)}</tr>`;
1360
- insertHTML(`<table><tbody>${row.repeat(rows)}</tbody></table><p><br></p>`);
1668
+ insertHTML(`<table data-rich-text-new><tbody>${row.repeat(rows)}</tbody></table><p><br></p>`);
1669
+ // The caret goes into the first cell, so typing and table commands start there
1670
+ const made = document.querySelector('table[data-rich-text-new]');
1671
+ if (made) { made.removeAttribute('data-rich-text-new'); const first = made.querySelector('td, th'); if (first) caretIn(first, 0); }
1361
1672
  }
1362
1673
 
1363
1674
  // A trailing table, rule or similar block gets a line after it to land on
@@ -1412,6 +1723,17 @@
1412
1723
  const next = values && values[k];
1413
1724
  if (next) area.style.setProperty(v, next); else area.style.removeProperty(v);
1414
1725
  });
1726
+ const allKey = (area) => JSON.stringify(allStyle(area));
1727
+ // .all styles set from code: every history entry takes the change, so undo never removes it
1728
+ function rebaseAll(area, was, now) {
1729
+ const h = area._history; if (!h) return;
1730
+ const keys = [...new Set([...Object.keys(was), ...Object.keys(now)])].filter(k => was[k] !== now[k]);
1731
+ h.stack.forEach(e => {
1732
+ const o = e.all ? JSON.parse(e.all) : {};
1733
+ keys.forEach(k => { if (now[k]) o[k] = now[k]; else delete o[k]; });
1734
+ e.all = JSON.stringify(Object.fromEntries(Object.keys(ALL_VARS).filter(k => o[k]).map(k => [k, o[k]])));
1735
+ });
1736
+ }
1415
1737
 
1416
1738
  /* ---- Command table ---- */
1417
1739
  const COMMANDS = Object.create(null);
@@ -1592,20 +1914,10 @@
1592
1914
  { re: /^\d+\.\s+/, make: (b) => listify(b, 'ol') },
1593
1915
  { re: /^\[([ xX])\]\s+/, make: (b, m) => listify(b, 'ul', /[xX]/.test(m[1])) },
1594
1916
  { re: /^>\s+/, make: (b) => retagBlock(b, 'blockquote') },
1595
- { re: /^```\s*$/, make: (b) => retagBlock(b, 'pre') },
1917
+ { re: /^```([\w+#.-]{0,40})\s*$/, make: (b, m) => { const pre = retagBlock(b, 'pre'); if (m[1]) pre.setAttribute('data-lang', m[1]); return pre; } },
1596
1918
  { re: /^(-{3,}|\*{3,}|_{3,})\s*$/, make: (b) => { const hr = document.createElement('hr'); b.replaceWith(hr); return hr; } }
1597
1919
  ];
1598
1920
 
1599
- const INLINE_RULES = [
1600
- { re: /\*\*([^*]+)\*\*/, tag: 'strong' },
1601
- { re: /__([^_]+)__/, tag: 'strong' },
1602
- { re: /(^|[^*])\*([^*\s][^*]*)\*/, tag: 'em', pre: 1, content: 2 },
1603
- { re: /~~([^~]+)~~/, tag: 'del' },
1604
- { re: /`([^`]+)`/, tag: 'code' },
1605
- { re: /!\[([^\]]*)\]\(([^)\s]+)\)/, tag: 'img', alt: 1, src: 2 },
1606
- { re: /\[([^\]]+)\]\(([^)\s]+)\)/, tag: 'a', href: 2 }
1607
- ];
1608
-
1609
1921
  function retagBlock(b, tag) {
1610
1922
  const n = document.createElement(tag);
1611
1923
  while (b.firstChild) n.appendChild(b.firstChild);
@@ -1640,44 +1952,35 @@
1640
1952
  }
1641
1953
  }
1642
1954
 
1643
- function inlineEl(rule, m) {
1644
- const el = document.createElement(rule.tag);
1645
- if (rule.tag === 'img') {
1646
- if (unsafeUrl(m[rule.src], true)) return null;
1647
- el.setAttribute('src', m[rule.src]); el.setAttribute('alt', m[rule.alt] || '');
1648
- return el;
1649
- }
1650
- el.textContent = m[rule.content || 1];
1651
- if (rule.href) { if (unsafeUrl(m[rule.href])) return null; el.setAttribute('href', m[rule.href]); }
1652
- return el;
1653
- }
1654
-
1655
- function firstInline(text) {
1656
- let best = null;
1657
- for (const rule of INLINE_RULES) {
1658
- const m = text.match(rule.re); if (!m) continue;
1659
- const skip = rule.pre ? m[rule.pre].length : 0;
1660
- const start = m.index + skip;
1661
- if (!best || start < best.start) best = { rule, m, start, len: m[0].length - skip };
1662
- }
1663
- return best;
1664
- }
1665
-
1666
- // Only a pattern ending exactly at the caret; earliest start wins (image over link)
1667
- function endInline(text) {
1668
- let best = null;
1669
- for (const rule of INLINE_RULES) {
1670
- const re = new RegExp(rule.re.source, 'g');
1671
- let m;
1672
- while ((m = re.exec(text))) {
1673
- if (m.index + m[0].length !== text.length) continue;
1674
- const skip = rule.pre ? m[rule.pre].length : 0;
1675
- const start = m.index + skip;
1676
- if (!best || start < best.start) best = { rule, m, start, len: m[0].length - skip };
1677
- break;
1678
- }
1955
+ // Markdown typed in a run of inline content (up to the caret, or all of it) re-read with the
1956
+ // markdown reader, so what converts while typing is what a saved value reads back as. Elements
1957
+ // already there go through as opaque characters. Null when nothing new would be made.
1958
+ const ATOM = 0xE000;
1959
+ function reparse(box, until, upto, after) {
1960
+ const kids = [...box.childNodes], end = until ? kids.indexOf(until) : kids.length;
1961
+ let from = 0;
1962
+ for (let i = 0; i < end; i++) if (kids[i].nodeType === 1 && (kids[i].tagName === 'BR' || BLOCK.includes(kids[i].tagName))) from = i + 1;
1963
+ const run = kids.slice(from, end), atoms = [];
1964
+ let src = '';
1965
+ for (const n of run) {
1966
+ if (n.nodeType === 3) { if (/[\uE000-\uF8FF]/.test(n.nodeValue)) return null; src += n.nodeValue; }
1967
+ else if (n.nodeType === 1) { if (atoms.length > 6000) return null; src += String.fromCharCode(ATOM + atoms.length); atoms.push(n); }
1679
1968
  }
1680
- return best;
1969
+ if (until) src += until.nodeValue.slice(0, upto);
1970
+ if (!/[*_~`\[]/.test(src)) return null;
1971
+ const t = document.createElement('template');
1972
+ t.innerHTML = inlineHtml(src, { raw: true, after });
1973
+ const made = [...t.content.querySelectorAll('*')];
1974
+ if (!made.length) return null;
1975
+ // Elements already there land whole and once, never in a code span or an attribute
1976
+ if (made.some(e => [...e.attributes].some(x => /[\uE000-\uF8FF]/.test(x.value)))) return null;
1977
+ const texts = []; const w = document.createTreeWalker(t.content, NodeFilter.SHOW_TEXT);
1978
+ for (let n = w.nextNode(); n; n = w.nextNode()) if (/[\uE000-\uF8FF]/.test(n.nodeValue)) { if (n.parentElement && n.parentElement.closest('code')) return null; texts.push(n); }
1979
+ const found = texts.flatMap(n => n.nodeValue.match(/[\uE000-\uF8FF]/g)).map(c => c.charCodeAt(0) - ATOM);
1980
+ if (found.length !== atoms.length || new Set(found).size !== atoms.length) return null;
1981
+ // The elements move in only when the conversion is taken
1982
+ const fill = () => texts.forEach(n => n.replaceWith(...n.nodeValue.split(/([\uE000-\uF8FF])/).filter(Boolean).map(p => p.length === 1 && p.charCodeAt(0) >= ATOM && p.charCodeAt(0) - ATOM < atoms.length ? atoms[p.charCodeAt(0) - ATOM] : document.createTextNode(p))));
1983
+ return { run, frag: t.content, fill };
1681
1984
  }
1682
1985
 
1683
1986
  function convertInlineAtCaret(area) {
@@ -1686,17 +1989,24 @@
1686
1989
  if (!node || node.nodeType !== 3 || !node.parentElement) return false;
1687
1990
  const host = node.parentElement.closest('code, pre, a');
1688
1991
  if (host && area.contains(host)) return false;
1689
- const hit = endInline(node.nodeValue.slice(0, r.startOffset));
1690
- if (!hit) return false;
1691
- const el = inlineEl(hit.rule, hit.m); if (!el) return false;
1992
+ if (!/[*_~`)]/.test(node.nodeValue[r.startOffset - 1] || '')) return false;
1993
+ const next = node.nodeValue[r.startOffset] != null ? node.nodeValue[r.startOffset] : textEdge(node.nextSibling, false);
1994
+ const hit = reparse(node.parentElement, node, r.startOffset, next == null ? '' : next);
1995
+ // Only a construct closed by the character just typed, and whole: an opener with marker
1996
+ // left over (`**test*`, so far) waits for the rest of the closer
1997
+ const last = hit && hit.frag.lastChild, typed = node.nodeValue[r.startOffset - 1];
1998
+ if (!last || last.nodeType !== 1 || (!last.textContent && last.tagName !== 'IMG')) return false;
1999
+ const before = last.previousSibling;
2000
+ if (/[*_~]/.test(typed) && before && before.nodeType === 3 && before.nodeValue.endsWith(typed)) return false;
1692
2001
  record(area);
1693
- const tail = node.splitText(hit.start);
1694
- tail.deleteData(0, hit.len);
1695
- tail.parentNode.insertBefore(el, tail);
2002
+ const tail = node.splitText(r.startOffset);
2003
+ hit.run.forEach(n => n.remove()); node.remove();
2004
+ hit.fill();
2005
+ tail.parentNode.insertBefore(hit.frag, tail);
1696
2006
  const s = sel(), nr = document.createRange();
1697
2007
  nr.setStart(tail, 0); nr.collapse(true);
1698
2008
  s.removeAllRanges(); s.addRange(nr);
1699
- area._exit = el.tagName === 'IMG' ? null : el;
2009
+ area._exit = last.tagName === 'IMG' ? null : last;
1700
2010
  record(area);
1701
2011
  return true;
1702
2012
  }
@@ -1775,21 +2085,26 @@
1775
2085
  return true;
1776
2086
  }
1777
2087
 
2088
+ // A finished line's markdown, in each run of inline content (marks' own content first)
1778
2089
  function convertInline(root) {
1779
- const nodes = [];
1780
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
1781
- for (let n = walker.nextNode(); n; n = walker.nextNode()) nodes.push(n);
1782
- nodes.forEach(node => {
1783
- if (!node.parentElement || node.parentElement.closest('code, pre, a')) return;
1784
- let cur = node;
1785
- for (let guard = 0; guard < 50; guard++) {
1786
- const hit = firstInline(cur.nodeValue); if (!hit) return;
1787
- const el = inlineEl(hit.rule, hit.m); if (!el) return;
1788
- const tail = cur.splitText(hit.start);
1789
- tail.deleteData(0, hit.len);
1790
- tail.parentNode.insertBefore(el, tail);
1791
- cur = tail;
2090
+ [...root.children].forEach(c => { if (!/^(CODE|PRE|A|BR|IMG)$/.test(c.tagName)) convertInline(c); });
2091
+ if (root.closest('code, pre, a')) return;
2092
+ const segments = [];
2093
+ let start = root.firstChild;
2094
+ for (let n = root.firstChild; ; n = n ? n.nextSibling : null) {
2095
+ if (!n || (n.nodeType === 1 && (n.tagName === 'BR' || BLOCK.includes(n.tagName)))) {
2096
+ if (start !== n) segments.push([start, n]);
2097
+ if (!n) break;
2098
+ start = n.nextSibling;
1792
2099
  }
2100
+ }
2101
+ segments.forEach(([first, stop]) => {
2102
+ const box = document.createElement('span'), anchor = document.createComment('');
2103
+ first.before(anchor);
2104
+ for (let n = first; n && n !== stop;) { const nx = n.nextSibling; box.appendChild(n); n = nx; }
2105
+ const hit = reparse(box, null, 0, '');
2106
+ if (hit) { hit.fill(); box.replaceChildren(hit.frag); }
2107
+ anchor.replaceWith(...box.childNodes);
1793
2108
  });
1794
2109
  }
1795
2110
 
@@ -1972,16 +2287,16 @@
1972
2287
  const h = area._history; if (!h || h.restoring) return;
1973
2288
  clearTimeout(h.timer); h.timer = null;
1974
2289
  if (area._rt && area._rt.settle) area._rt.settle();
1975
- const html = area.innerHTML;
2290
+ const html = area.innerHTML, all = allKey(area);
1976
2291
  // Unchanged: the entry learns where the next edit starts (`before`, for undo);
1977
2292
  // `caret` stays where the edit that produced it ended (for redo)
1978
- if (h.stack[h.at] && h.stack[h.at].html === html) {
2293
+ if (h.stack[h.at] && h.stack[h.at].html === html && h.stack[h.at].all === all) {
1979
2294
  const caret = offsetIn(area);
1980
2295
  if (caret != null) h.stack[h.at].before = caret;
1981
2296
  return;
1982
2297
  }
1983
2298
  h.stack.length = h.at + 1;
1984
- h.stack.push({ html, caret: offsetIn(area) });
2299
+ h.stack.push({ html, caret: offsetIn(area), all });
1985
2300
  if (h.stack.length > HISTORY_CAP) h.stack.shift();
1986
2301
  h.at = h.stack.length - 1;
1987
2302
  }
@@ -2003,12 +2318,14 @@
2003
2318
  function step(area, by) {
2004
2319
  const h = area._history; if (!h) return;
2005
2320
  // Land a pending burst; walking history alone leaves each entry's carets as recorded
2006
- if (h.timer || !h.stack[h.at] || h.stack[h.at].html !== area.innerHTML) record(area);
2321
+ if (h.timer || !h.stack[h.at] || h.stack[h.at].html !== area.innerHTML || h.stack[h.at].all !== allKey(area)) record(area);
2007
2322
  const next = h.at + by;
2008
2323
  if (next < 0 || next >= h.stack.length) return;
2009
2324
  h.at = next; h.restoring = true;
2010
2325
  const entry = h.stack[next];
2011
2326
  area.innerHTML = entry.html;
2327
+ // .all styles are steps too
2328
+ if (entry.all != null && entry.all !== allKey(area)) { setAllStyle(area, JSON.parse(entry.all)); area.dispatchEvent(new CustomEvent('rich-text:all', { bubbles: true, detail: allStyle(area) })); }
2012
2329
  if (area._rt && area._rt.settle) { area._rt.settle(); entry.html = area.innerHTML; }
2013
2330
  area.focus();
2014
2331
  caretIn(area, by < 0 && entry.before != null ? entry.before : entry.caret);
@@ -2142,8 +2459,10 @@
2142
2459
  }
2143
2460
  if (!allows(area, id, ctrl)) return;
2144
2461
  if (all) {
2462
+ record(area);
2145
2463
  area.style.setProperty(ALL_VARS[id], id === 'size' ? px(arg) : arg);
2146
2464
  if (!arg) area.style.removeProperty(ALL_VARS[id]);
2465
+ record(area);
2147
2466
  area.dispatchEvent(new CustomEvent('rich-text:all', { bubbles: true, detail: allStyle(area) }));
2148
2467
  sync();
2149
2468
  return;
@@ -2151,7 +2470,12 @@
2151
2470
  const arming = INLINE_TAGS[id] && !hasSelection();
2152
2471
  if (arming) { spec.run(arg, area); sync(); return; }
2153
2472
  if (!spec.history) record(area);
2473
+ const before = textSpan(area);
2154
2474
  spec.run(arg, area);
2475
+ // A command that rebuilt the blocks under the selection (a block style) leaves it where it was
2476
+ const after = range();
2477
+ if (before && before[1] > before[0] && (!after || !area.contains(after.startContainer) || after.collapsed)) selectSpan(area, before);
2478
+ mergeAdjacent(area);
2155
2479
  normalizeLists(area);
2156
2480
  if (!spec.history) record(area);
2157
2481
  saveRange(area);
@@ -2159,8 +2483,23 @@
2159
2483
  sync();
2160
2484
  }
2161
2485
 
2486
+ // $rich's reads (value, selection, can, active, …) depend on one reactive beat, bumped
2487
+ // (once a microtask) whenever the selection, value or formatting may have changed
2488
+ let pulse = null, beating = false;
2489
+ const watch = () => { if (pulse) void pulse.n; };
2490
+ // Bumped only when what $rich reports changed, so an effect that reads and writes it settles
2491
+ let lastSurface = null;
2492
+ const selKey = (x) => x ? [x.collapsed, x.text, x.x, x.y, x.width, x.height].join() : '';
2493
+ const surface = () => [...areas].map(a => [a === lastFocused, a === lastFocused ? String(textSpan(a)) : '', a.innerHTML, allKey(a), selKey(a._selection), a._pending ? [...a._pending.tags].join() : ''].join('\u0001')).join('\u0002');
2494
+ const beat = () => {
2495
+ if (!pulse || beating) return;
2496
+ beating = true;
2497
+ queueMicrotask(() => { beating = false; const now = surface(); if (now === lastSurface) return; lastSurface = now; pulse.n++; });
2498
+ };
2499
+
2162
2500
  // Reflect caret state onto every control
2163
2501
  function sync() {
2502
+ beat();
2164
2503
  controls.forEach(c => {
2165
2504
  const cfg = c._rt, area = resolve(c);
2166
2505
  const usable = !!area && allows(area, cfg.id, cfg);
@@ -2235,6 +2574,7 @@
2235
2574
  if (!window.Alpine || !Alpine.directive) return;
2236
2575
  // Alpine walks only x-data trees: a page with none still gets its fields
2237
2576
  if (document.body && !document.body.hasAttribute('x-data')) document.body.setAttribute('x-data', '{}');
2577
+ if (Alpine.reactive && !pulse) pulse = Alpine.reactive({ n: 0 });
2238
2578
  listen();
2239
2579
 
2240
2580
  Alpine.directive('rich-text', (el, { expression, modifiers }, { effect, evaluateLater, evaluate, cleanup }) => {
@@ -2252,20 +2592,37 @@
2252
2592
  Alpine.directive('rich-text-menu', (el, _, { cleanup }) => { if (!el._rtMenu) selectionMenu(el, cleanup); });
2253
2593
 
2254
2594
  // $rich: the area this element sits in, else the one controls would resolve to
2595
+ // Unresolved, it depends on the beat, so it resolves once a field is there
2255
2596
  const richMagic = (el) => {
2256
- const own = el.closest('[data-rich-text]');
2257
- const a = own && areas.has(own) ? own : resolve(el);
2597
+ const a = fieldFor(el);
2598
+ if (!a) watch();
2258
2599
  return a ? a._rt.api : idleApi();
2259
2600
  };
2260
2601
  Alpine.magic('rich', richMagic);
2261
2602
  // The loader's stand-in answered $rich until now: it hands over, and effects that read it
2262
2603
  // run again once adopted areas exist
2263
2604
  window.__manifestRichMagic = richMagic;
2264
- if (window.__manifestRichStub) setTimeout(() => { window.__manifestRichStub.v++; }, 0);
2605
+ if (window.__manifestRichStub) setTimeout(() => { flushStubWrites(); window.__manifestRichStub.v++; }, 0);
2265
2606
 
2266
2607
  adopt();
2267
2608
  }
2268
2609
 
2610
+ // $rich's field for an element: the one it sits in, else the one controls would resolve to
2611
+ const fieldFor = (el) => { const own = el.closest('[data-rich-text]'); return own && areas.has(own) ? own : resolve(el); };
2612
+ // Writes made on the loader's stand-in ($rich.value = … before Rich Text arrived) land on
2613
+ // their element's field once it exists
2614
+ function flushStubWrites() {
2615
+ const w = window.__manifestRichStubWrites; if (!w || !w.length) return;
2616
+ for (let i = 0; i < w.length;) {
2617
+ const [ref, k, v] = w[i], el = ref && typeof ref.deref === 'function' ? ref.deref() : ref;
2618
+ if (!el || !el.isConnected) { w.splice(i, 1); continue; }
2619
+ const a = fieldFor(el);
2620
+ if (!a) { i++; continue; }
2621
+ w.splice(i, 1);
2622
+ try { a._rt.api[k] = v; } catch { }
2623
+ }
2624
+ }
2625
+
2269
2626
  // Loaded after Alpine started: elements it already walked get their x-rich-text*
2270
2627
  // attributes re-set, which Alpine handles as newly added directives
2271
2628
  function adopt() {
@@ -2318,12 +2675,40 @@
2318
2675
  t.style.setProperty('--rich-text-selection-center', px(shape.x + shape.width / 2));
2319
2676
  }
2320
2677
 
2678
+ // While a field holds a selection, scrolling and resizing re-report its geometry (once a frame)
2679
+ let tracked = null, trackFrame = 0;
2680
+ const untrack = () => {
2681
+ if (!tracked) return;
2682
+ tracked = null;
2683
+ window.removeEventListener('scroll', follow, { capture: true });
2684
+ window.removeEventListener('resize', follow);
2685
+ };
2686
+ function follow() {
2687
+ if (trackFrame) return;
2688
+ trackFrame = requestAnimationFrame(() => {
2689
+ trackFrame = 0;
2690
+ const area = tracked, shape = area && area.isConnected ? selectionOf(area) : null;
2691
+ if (!shape || shape.collapsed) return untrack();
2692
+ report(area);
2693
+ });
2694
+ }
2695
+ function track(area, on) {
2696
+ if (!on) { if (tracked === area) untrack(); return; }
2697
+ if (!tracked) {
2698
+ window.addEventListener('scroll', follow, { capture: true, passive: true });
2699
+ window.addEventListener('resize', follow, { passive: true });
2700
+ }
2701
+ tracked = area;
2702
+ }
2703
+
2321
2704
  function report(area) {
2322
2705
  const live = sel();
2323
2706
  if (cellDrag && live && live.isCollapsed) { const c = cellOf(live.anchorNode); if (c) cellDrag = null; }
2324
2707
  if (area.querySelector('table')) paintCells(area);
2325
2708
  const shape = selectionOf(area);
2326
2709
  area._selection = shape;
2710
+ beat();
2711
+ track(area, !!shape && !shape.collapsed);
2327
2712
  place(area, shape);
2328
2713
  area.toggleAttribute('data-rich-text-selected', !!shape && !shape.collapsed);
2329
2714
  document.documentElement.toggleAttribute('data-rich-text-selected', !!shape && !shape.collapsed);
@@ -2335,29 +2720,10 @@
2335
2720
  el._rtMenu = true;
2336
2721
  if (!el.hasAttribute('popover')) el.setAttribute('popover', 'manual');
2337
2722
  el.setAttribute('data-rich-text-menu', '');
2338
- let shown = null, frame = 0;
2339
- const follow = () => {
2340
- if (frame) return;
2341
- frame = requestAnimationFrame(() => { frame = 0; if (shown && shown.isConnected) place(shown, selectionOf(shown)); });
2342
- };
2343
- const track = (area) => {
2344
- if (!shown) {
2345
- window.addEventListener('scroll', follow, { capture: true, passive: true });
2346
- window.addEventListener('resize', follow, { passive: true });
2347
- }
2348
- shown = area;
2349
- };
2350
- const untrack = () => {
2351
- if (!shown) return;
2352
- shown = null;
2353
- window.removeEventListener('scroll', follow, { capture: true });
2354
- window.removeEventListener('resize', follow);
2355
- };
2356
2723
  const onSel = (e) => {
2357
2724
  const area = elementAt(e.target) && elementAt(e.target).closest('[data-rich-text]');
2358
2725
  if (!area || resolve(el) !== area) return;
2359
2726
  const want = !!e.detail && !e.detail.collapsed;
2360
- if (want) track(area); else untrack();
2361
2727
  try {
2362
2728
  if (want && !el.matches(':popover-open')) el.showPopover();
2363
2729
  if (!want && el.matches(':popover-open')) el.hidePopover();
@@ -2366,7 +2732,6 @@
2366
2732
  document.addEventListener('rich-text:selection', onSel);
2367
2733
  cleanup(() => {
2368
2734
  document.removeEventListener('rich-text:selection', onSel);
2369
- untrack(); if (frame) cancelAnimationFrame(frame);
2370
2735
  try { el.hidePopover(); } catch { }
2371
2736
  });
2372
2737
  }
@@ -2523,8 +2888,8 @@
2523
2888
  }
2524
2889
  }
2525
2890
 
2526
- el._history = { stack: [{ html: el.innerHTML, caret: 0 }], at: 0, timer: null, restoring: false };
2527
- const resetHistory = () => { clearTimeout(el._history.timer); el._history.timer = null; el._history.stack = [{ html: el.innerHTML, caret: 0 }]; el._history.at = 0; };
2891
+ el._history = { stack: [{ html: el.innerHTML, caret: 0, all: allKey(el) }], at: 0, timer: null, restoring: false };
2892
+ const resetHistory = () => { clearTimeout(el._history.timer); el._history.timer = null; el._history.stack = [{ html: el.innerHTML, caret: 0, all: allKey(el) }]; el._history.at = 0; };
2528
2893
 
2529
2894
  // Empty flag for the placeholder; a wiped document restarts as a paragraph
2530
2895
  function settle() {
@@ -2544,7 +2909,9 @@
2544
2909
  if (el.querySelector('table')) normalizeTables(el);
2545
2910
  if (format !== 'plain') looseInline(el);
2546
2911
  ensureTrailingBlock(el);
2547
- if (!el.textContent.trim() && !el.querySelector('img, table, hr')) {
2912
+ // A code block just opened (``` + Enter) holds the caret: it stays
2913
+ const r = range(), first = el.firstElementChild, opened = !!first && first.tagName === 'PRE' && !!r && first.contains(r.startContainer);
2914
+ if (!opened && !el.textContent.trim() && !el.querySelector('img, table, hr')) {
2548
2915
  if (el.children.length !== 1 || el.firstElementChild.tagName !== 'P') el.replaceChildren();
2549
2916
  if (!el.firstChild) { el.innerHTML = '<p><br></p>'; if (document.activeElement === el) caretIn(el.firstElementChild, 0); }
2550
2917
  el.toggleAttribute('data-rich-text-empty', true);
@@ -2555,6 +2922,8 @@
2555
2922
  writing = true; write(v); writing = false;
2556
2923
  resetHistory();
2557
2924
  settle();
2925
+ el._selection = selectionOf(el);
2926
+ sync();
2558
2927
  };
2559
2928
 
2560
2929
  const lineBreak = () => { record(el); insertBreak(el); record(el); el.dispatchEvent(new Event('input', { bubbles: true })); };
@@ -2608,7 +2977,7 @@
2608
2977
  on('input', (e) => {
2609
2978
  // Mid-composition the IME owns the text
2610
2979
  if (!writing && e.data && !e.isComposing) {
2611
- if (applyPending(el, e.data)) commit();
2980
+ if (applyPending(el, e.data)) { mergeAdjacent(el); commit(); }
2612
2981
  if (leaveMark(el, e.data)) commit();
2613
2982
  if (autoformat && convertInlineAtCaret(el)) commit();
2614
2983
  }
@@ -2622,6 +2991,8 @@
2622
2991
  on('compositionend', (e) => {
2623
2992
  if (!writing && e.data && applyPending(el, e.data)) { commit(); sync(); }
2624
2993
  else if (!writing && e.data && el._exit && leaveMark(el, e.data)) { commit(); sync(); }
2994
+ // A closing marker committed through an IME converts as a typed one does
2995
+ else if (!writing && autoformat && e.data && /[*_~`)]$/.test(e.data) && convertInlineAtCaret(el)) { commit(); sync(); }
2625
2996
  if (!writing) relaxSpaces(el);
2626
2997
  });
2627
2998
  on('change', e => {
@@ -2762,7 +3133,7 @@
2762
3133
  });
2763
3134
 
2764
3135
  const api = {
2765
- get value() { return read(); },
3136
+ get value() { watch(); return read(); },
2766
3137
  // The value it already holds keeps the markup and caret; history restarts either way
2767
3138
  set value(v) {
2768
3139
  if (v !== read()) write(v);
@@ -2772,15 +3143,25 @@
2772
3143
  el.dispatchEvent(new Event('input', { bubbles: true }));
2773
3144
  el._history.restoring = false;
2774
3145
  },
2775
- get all() { return allStyle(el); },
2776
- set all(v) { if (!allStyles) return; setAllStyle(el, v); sync(); },
2777
- get selection() { return el._selection || null; },
2778
- get link() { return linkHref(); },
2779
- set link(v) { run(el, 'a', v); },
3146
+ get all() { watch(); return allStyle(el); },
3147
+ // Page styling, not an edit: no history step (the history takes it as its baseline)
3148
+ set all(v) {
3149
+ if (!allStyles) return;
3150
+ const was = allStyle(el);
3151
+ setAllStyle(el, v);
3152
+ const now = allStyle(el);
3153
+ if (JSON.stringify(now) === JSON.stringify(was)) return;
3154
+ rebaseAll(el, was, now);
3155
+ el.dispatchEvent(new CustomEvent('rich-text:all', { bubbles: true, detail: now }));
3156
+ sync();
3157
+ },
3158
+ get selection() { watch(); return el._selection || null; },
3159
+ get link() { watch(); return linkHref(); },
3160
+ set link(v) { if ((v == null ? '' : String(v)) === (linkHref() || '')) return; run(el, 'a', v); },
2780
3161
  focus: () => el.focus(),
2781
3162
  run: (id, arg) => run(el, id, arg),
2782
- active: (id) => allows(el, id) && COMMANDS[id].active(undefined, el),
2783
- can: (id) => allows(el, id),
3163
+ active: (id) => { watch(); return allows(el, id) && COMMANDS[id].active(undefined, el); },
3164
+ can: (id) => { watch(); return allows(el, id); },
2784
3165
  selectAll: () => { el.focus(); selectNode(el); saveRange(el); sync(); },
2785
3166
  // The newest step in this field's history (a pending typing burst lands first)
2786
3167
  newest: () => { const h = el._history; if (h.timer || h.stack[h.at].html !== el.innerHTML) record(el); return h.stack[h.stack.length - 1].html; },
@@ -2792,12 +3173,14 @@
2792
3173
 
2793
3174
  el._rt = { format, inline: single, breaks: lines, noAll: !allStyles, api, settle };
2794
3175
  areas.add(el);
3176
+ if (window.__manifestRichStubWrites && window.__manifestRichStubWrites.length) setTimeout(flushStubWrites, 0);
2795
3177
  settle();
2796
- el._history.stack[0].html = el.innerHTML;
3178
+ el._history.stack[0].html = el.innerHTML; el._history.stack[0].all = allKey(el);
2797
3179
  setTimeout(sync, 0);
2798
3180
 
2799
3181
  const destroy = () => {
2800
3182
  if (!areas.has(el)) return;
3183
+ if (tracked === el) untrack();
2801
3184
  ac.abort();
2802
3185
  clearTimeout(el._history && el._history.timer);
2803
3186
  if (lastFocused === el) release(el);