@helping-ai-workflow/md2doc 3.13.0 → 3.14.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/md2doc.js CHANGED
@@ -185,6 +185,9 @@ function buildKatexStyleTag() {
185
185
  // it per-call would grow the registry unboundedly in a long-lived process
186
186
  // (the editor server calls renderMarkdown() once per edit).
187
187
  let marked, Renderer;
188
+ // v3.13.1 (T9b): set by renderMarkdown() around its synchronous parse — see the
189
+ // blockKatex renderer below. Null at every other time.
190
+ let blockMathText = null;
188
191
  try {
189
192
  ({ marked, Renderer } = require('marked'));
190
193
 
@@ -232,6 +235,14 @@ try {
232
235
  // default no-space-adjacency rules keep prose currency ($5 to $10) unrendered.
233
236
  const markedKatex = require('marked-katex-extension');
234
237
  marked.use(markedKatex({ throwOnError: false }));
238
+ // v3.13.1 (T9b): a $$ block renders through the extension's own renderer,
239
+ // which the per-call renderer overrides never reach, so the search index
240
+ // would miss it. This one hands its TeX to the render in progress, then
241
+ // returns false so the extension's renderer still draws the formula.
242
+ marked.use({ extensions: [{ name: 'blockKatex', renderer(token) {
243
+ if (blockMathText) blockMathText(token.text);
244
+ return false;
245
+ } }] });
235
246
  } catch (e) {
236
247
  console.error('[ERROR] marked not found — install with: npm install marked');
237
248
  console.error(e.message);
@@ -335,7 +346,10 @@ function renderEditModeList(listToken, blocks, biRef, out) {
335
346
  let inner;
336
347
  if (!item.loose && ownTokens.length === 1 && ownTokens[0].type === 'text') {
337
348
  // Tight item with a single text token: use static parseInline to get
338
- // clean inline HTML without a <p> wrapper.
349
+ // clean inline HTML without a <p> wrapper. parseInline never reaches the
350
+ // block renderers, so the item's text is indexed for search here
351
+ // (v3.13.1 T9b; biRef.indexInline is set by renderMarkdown).
352
+ if (biRef.indexInline) biRef.indexInline(ownTokens[0].tokens);
339
353
  inner = marked.Parser.parseInline(ownTokens[0].tokens);
340
354
  } else {
341
355
  // Loose item or non-standard own-token shape: use marked.parser, which
@@ -345,7 +359,10 @@ function renderEditModeList(listToken, blocks, biRef, out) {
345
359
  // marked pretty-prints a trailing "\n" after the closing tag, which is
346
360
  // insignificant to every serializer (list-md.js drops it via isBlankText)
347
361
  // but would render as a phantom blank line inside the item.
348
- inner = marked.parser(ownTokens).trim();
362
+ // biRef.parseItem (renderMarkdown) is marked.parser with the search
363
+ // index told that a text run here is wrapped in a made-up paragraph,
364
+ // as in a loose item, so each item is indexed once (v3.13.1 T9b).
365
+ inner = (biRef.parseItem ? biRef.parseItem(ownTokens) : marked.parser(ownTokens)).trim();
349
366
  }
350
367
  // The receiving half of list-md.js's SETEXT ESCAPE (see
351
368
  // escapeSetextHazard() there): an empty list item that is the first child
@@ -732,21 +749,27 @@ async function renderMarkdown(mdText, srcPath, opts = {}) {
732
749
  const sections = [];
733
750
  let currentSection = null;
734
751
 
735
- function startSection({ id, depth, text }) {
752
+ // The search index: one string per section, built from the text the
753
+ // reader sees, the way the in-text marks read it (highlightFirstOccurrence
754
+ // in the reader runtime). Both sides must agree, or the results list finds
755
+ // a phrase the reader cannot mark.
756
+ function startSection({ id, depth, text, search }) {
736
757
  currentSection = {
737
758
  id,
738
759
  depth,
739
760
  title: text,
740
- searchTextParts: [text],
761
+ searchTextParts: [search === undefined ? text : search.replace(/\s+/g, ' ').trim()],
741
762
  };
742
763
  sections.push(currentSection);
743
764
  }
744
765
 
766
+ // Takes plain text (sectionInlineText, code, TeX): no tag stripping here,
767
+ // so code such as "if (a<b && c>d)" is indexed as written.
745
768
  function appendSectionText(value) {
746
769
  if (!currentSection || !value) {
747
770
  return;
748
771
  }
749
- const clean = stripHtmlTags(value).replace(/\s+/g, ' ').trim();
772
+ const clean = String(value).replace(/\s+/g, ' ').trim();
750
773
  if (clean) {
751
774
  currentSection.searchTextParts.push(clean);
752
775
  }
@@ -758,13 +781,37 @@ async function renderMarkdown(mdText, srcPath, opts = {}) {
758
781
  }
759
782
  for (const cell of cells) {
760
783
  if (cell && Array.isArray(cell.tokens)) {
761
- appendSectionText(flattenTokenText(cell.tokens));
784
+ appendSectionText(sectionInlineText(cell.tokens));
762
785
  } else if (cell && typeof cell.text === 'string') {
763
- appendSectionText(cell.text);
786
+ appendSectionText(stripHtmlTags(cell.text));
764
787
  }
765
788
  }
766
789
  }
767
790
 
791
+ // v3.13.1 (T9b): inline tokens as the reader sees them, for the search
792
+ // index. Inline math is its TeX source (the reader runtime reads a formula
793
+ // from its TeX annotation); a hard break or a literal <br> is a space (the
794
+ // runtime reads a br element as one); other inline html carries no text;
795
+ // an image's alt text is not on the page. A text token's text is escaped
796
+ // by marked, so a tag in it is real markup (the checkbox a loose task item
797
+ // gets) and is stripped before the entities are decoded.
798
+ function sectionInlineText(tokens) {
799
+ if (!Array.isArray(tokens)) {
800
+ return '';
801
+ }
802
+ return tokens
803
+ .map((item) => {
804
+ if (item.type === 'br') return ' ';
805
+ if (item.type === 'html') return /^<br\b/i.test(item.text || '') ? ' ' : '';
806
+ if (item.type === 'image') return '';
807
+ if (item.type === 'inlineKatex') return item.text || '';
808
+ if (item.type === 'codespan') return unescapeHtml(item.text || '');
809
+ if (item.tokens) return sectionInlineText(item.tokens);
810
+ return unescapeHtml(stripHtmlTags(String(item.text || '').replace(/<br\b[^>]*>/gi, ' ')));
811
+ })
812
+ .join('');
813
+ }
814
+
768
815
  function buildTocTree(items) {
769
816
  const root = [];
770
817
  const stack = [{ depth: 0, children: root }];
@@ -978,9 +1025,64 @@ ${itemsHtml}
978
1025
  return out + '>';
979
1026
  };
980
1027
 
1028
+ // R7 甲 (v3.13.1): a block that is nothing but one or more native <svg>
1029
+ // elements gets the class inline-svg, so dark mode can put it on a white
1030
+ // plate like images and draw.io. Decided on the block's own source, not
1031
+ // its tokens: marked lexes '<svg><text>x</text></svg>' on one line as a
1032
+ // paragraph of html,html,text,html,html tokens, and '<svg ...>' alone on
1033
+ // its first line as one block html token. Outside the svg elements only
1034
+ // whitespace may appear, so an svg inside prose is never wrapped. Reader
1035
+ // output only: edit mode keeps its block DOM exactly as it was.
1036
+ // Not covered: an svg in a tight list item or a table cell reaches the
1037
+ // renderer as inline tokens with no block of its own, so it gets no plate.
1038
+ // HTML comments are dropped before any <svg> is looked at: '<!-- <svg> -->'
1039
+ // opens nothing.
1040
+ function noComments(src) { return String(src || '').replace(/<!--[\s\S]*?-->/g, ''); }
1041
+ function isSvgOnly(src) {
1042
+ const s = noComments(src).trim();
1043
+ if (!/^<svg[\s>]/i.test(s) || !/<\/svg>$/i.test(s)) return false;
1044
+ const tag = /<(\/?)svg\b[^>]*?(\/?)>/gi;
1045
+ let depth = 0, last = 0, m;
1046
+ while ((m = tag.exec(s)) !== null) {
1047
+ if (depth === 0 && s.slice(last, m.index).trim()) return false;
1048
+ if (m[1]) depth--; else if (!m[2]) depth++;
1049
+ if (depth < 0) return false;
1050
+ last = tag.lastIndex;
1051
+ }
1052
+ return depth === 0 && !s.slice(last).trim();
1053
+ }
1054
+ // An outer <svg> that a blank line split into several html blocks is still
1055
+ // open while its later blocks render; a <div> there would close it early, so
1056
+ // no html block is wrapped until it closes. Only raw html blocks can keep an
1057
+ // svg open: a paragraph's <p> and </p> end any open svg in the browser's
1058
+ // parser (measured in Chromium and WebKit), so a paragraph always resets the
1059
+ // count, and text in a code span or an svg left unclosed inside a paragraph
1060
+ // never holds later blocks hostage. Counted per block, in document order.
1061
+ let openSvg = 0;
1062
+ function svgNet(src) {
1063
+ const tag = /<(\/?)svg\b[^>]*?(\/?)>/gi;
1064
+ let n = 0, m;
1065
+ const s = noComments(src);
1066
+ while ((m = tag.exec(s)) !== null) { if (m[1]) n--; else if (!m[2]) n++; }
1067
+ return n;
1068
+ }
1069
+ function wrapsSvg(src) {
1070
+ const wrap = openSvg === 0 && isSvgOnly(src);
1071
+ openSvg = Math.max(0, openSvg + svgNet(src));
1072
+ return wrap;
1073
+ }
1074
+ function wrapsSvgParagraph(src) {
1075
+ openSvg = 0;
1076
+ return isSvgOnly(src);
1077
+ }
1078
+
981
1079
  const baseHtml = renderer.html.bind(renderer);
982
1080
  renderer.html = function(token) {
983
- return inlineImagesInHtmlChunk(baseHtml.apply(this, arguments));
1081
+ const out = inlineImagesInHtmlChunk(baseHtml.apply(this, arguments));
1082
+ if (!opts.editMode && token && token.block && wrapsSvg(token.text)) {
1083
+ return '<div class="inline-svg">' + out.trim() + '</div>\n';
1084
+ }
1085
+ return out;
984
1086
  };
985
1087
 
986
1088
  // Spec §3.12 — a markdown HARD BREAK and a literal '<br>' the source spelled
@@ -1048,6 +1150,8 @@ ${itemsHtml}
1048
1150
  return `\n<div class="mermaid">\n${escapeHtml(code)}\n</div>\n`;
1049
1151
  }
1050
1152
  if (lang === 'math') {
1153
+ // Indexed as its TeX, like inline and $$ math (T9b).
1154
+ appendSectionText(code);
1051
1155
  // KaTeX renders synchronously to static HTML (class="katex"); no client
1052
1156
  // runtime, no async post-pass. throwOnError:false degrades a bad formula
1053
1157
  // to red error text instead of crashing the whole render.
@@ -1068,7 +1172,9 @@ ${itemsHtml}
1068
1172
  }
1069
1173
  // Default: fenced code. v3.10.0 colours registered languages here, at
1070
1174
  // render time (lib/highlight.js); an unknown or missing language stays
1071
- // plain escaped text.
1175
+ // plain escaped text. v3.13.1 (T9b): indexed for search as written, so a
1176
+ // name that only a code block holds is found and marked.
1177
+ appendSectionText(code);
1072
1178
  const coloured = highlightCode(code, lang);
1073
1179
  // The info string goes into an attribute: escape it (an info string such
1074
1180
  // as x" onmouseover=… must stay text). Ordinary language names are unchanged.
@@ -1103,21 +1209,54 @@ ${itemsHtml}
1103
1209
  id: headingId,
1104
1210
  text: headingText,
1105
1211
  });
1106
- startSection({ depth, id: headingId, text: headingText });
1212
+ startSection({ depth, id: headingId, text: headingText, search: sectionInlineText(token.tokens) });
1107
1213
 
1108
1214
  return `<h${depth} id="${headingId}" class="heading-with-anchor" data-reader-heading data-reader-depth="${depth}">${headingHtml}<a class="heading-anchor" href="#${headingId}" aria-label="Link to this section">#</a></h${depth}>\n`;
1109
1215
  };
1110
1216
 
1217
+ // A list item's text is indexed as it renders, in reading order with its
1218
+ // block children: marked's parser hands each of the item's block-level
1219
+ // text tokens (the only text tokens that carry inline .tokens) to
1220
+ // renderer.text. In a loose item the parser then wraps that run of text in
1221
+ // a paragraph it makes up from the HTML it just rendered (a formula as
1222
+ // KaTeX markup); renderer.text arms a one-shot skip for that paragraph.
1223
+ // Real paragraph tokens (in a loose item, those after a nested list) are
1224
+ // indexed by renderer.paragraph as usual.
1225
+ let looseItemText = false;
1226
+ let skipMadeUpParagraph = false;
1227
+ const baseText = renderer.text.bind(renderer);
1228
+ renderer.text = function(token) {
1229
+ if (token && Array.isArray(token.tokens)) {
1230
+ appendSectionText(sectionInlineText(token.tokens));
1231
+ if (looseItemText) skipMadeUpParagraph = true;
1232
+ }
1233
+ return baseText(token);
1234
+ };
1111
1235
  const baseParagraph = renderer.paragraph.bind(renderer);
1112
1236
  renderer.paragraph = function(token) {
1113
- appendSectionText(flattenTokenText(token.tokens));
1237
+ if (skipMadeUpParagraph) {
1238
+ skipMadeUpParagraph = false;
1239
+ } else {
1240
+ appendSectionText(sectionInlineText(token.tokens));
1241
+ }
1242
+ // R7 甲: see isSvgOnly above renderer.html.
1243
+ if (!opts.editMode && wrapsSvgParagraph(token.raw)) {
1244
+ return baseParagraph(token).replace(/^<p>/, '<p class="inline-svg">');
1245
+ }
1114
1246
  return baseParagraph(token);
1115
1247
  };
1116
1248
 
1117
1249
  const baseListitem = renderer.listitem.bind(renderer);
1118
1250
  renderer.listitem = function(token) {
1119
- appendSectionText(flattenTokenText(token.tokens));
1120
- const html = baseListitem(token);
1251
+ // Indexes nothing itself: see renderer.text above.
1252
+ const outerLoose = looseItemText;
1253
+ looseItemText = !!token.loose;
1254
+ let html;
1255
+ try {
1256
+ html = baseListitem(token);
1257
+ } finally {
1258
+ looseItemText = outerLoose;
1259
+ }
1121
1260
  if (!token.task) return html;
1122
1261
  // v3.10.0: a task item drops its bullet and keeps its own text in a span,
1123
1262
  // so a checked item's strike-through stops at its own words — CSS
@@ -1133,8 +1272,14 @@ ${itemsHtml}
1133
1272
 
1134
1273
  const baseBlockquote = renderer.blockquote.bind(renderer);
1135
1274
  renderer.blockquote = function(token) {
1136
- appendSectionText(flattenTokenText(token.tokens));
1137
- return baseBlockquote(token);
1275
+ // Its paragraphs index themselves (indexing them here too doubled them).
1276
+ const outerLoose = looseItemText;
1277
+ looseItemText = false;
1278
+ try {
1279
+ return baseBlockquote(token);
1280
+ } finally {
1281
+ looseItemText = outerLoose;
1282
+ }
1138
1283
  };
1139
1284
 
1140
1285
  renderer.table = function(token) {
@@ -1205,6 +1350,8 @@ ${itemsHtml}
1205
1350
  return `<a href="#${escAttr(slug)}">[${escAttr(body)}]</a>`;
1206
1351
  });
1207
1352
 
1353
+ // A $$ block's TeX reaches the index through the blockKatex hook at the
1354
+ // top of the file, set only while this render's synchronous parse runs.
1208
1355
  if (opts.editMode) {
1209
1356
  // Ranges are computed on mdPre. The [[...]] preprocessing above only
1210
1357
  // replaces text within single lines and never adds/removes newlines,
@@ -1224,19 +1371,41 @@ ${itemsHtml}
1224
1371
  // flat list renderer above threads through its own recursion (see
1225
1372
  // liRunStartsHere()).
1226
1373
  const biRef = { v: 0, prevLi: null, types: [] };
1227
- for (const t of tokens) {
1228
- if (t.type === 'space') continue;
1229
- if (t.type === 'list') {
1230
- parts.push(...renderEditModeList(t, blocks, biRef));
1231
- continue;
1374
+ // The edit page searches this index too: renderEditModeList indexes each
1375
+ // item through these two, once, in the edit DOM's order (an item's own
1376
+ // block, then its nested lists).
1377
+ biRef.indexInline = (inlineTokens) => appendSectionText(sectionInlineText(inlineTokens));
1378
+ biRef.parseItem = (ownTokens) => {
1379
+ const outerLoose = looseItemText;
1380
+ looseItemText = true;
1381
+ try {
1382
+ return marked.parser(ownTokens);
1383
+ } finally {
1384
+ looseItemText = outerLoose;
1232
1385
  }
1233
- resetLiRunState(biRef); // §3.8 rule (c): a non-li block ends every run
1234
- const b = blocks[biRef.v++];
1235
- const inner = marked.parser([t]);
1236
- parts.push(
1237
- `<div class="ed-block" data-block-id="${b.id}" data-block-type="${b.type}">` +
1238
- inner + '</div>'
1239
- );
1386
+ };
1387
+ try {
1388
+ blockMathText = appendSectionText;
1389
+ for (const t of tokens) {
1390
+ if (t.type === 'space') continue;
1391
+ if (t.type === 'list') {
1392
+ parts.push(...renderEditModeList(t, blocks, biRef));
1393
+ continue;
1394
+ }
1395
+ resetLiRunState(biRef); // §3.8 rule (c): a non-li block ends every run
1396
+ const b = blocks[biRef.v++];
1397
+ const inner = marked.parser([t]);
1398
+ // v3.14.0 T10: the reader wraps an svg-only block in .inline-svg for the
1399
+ // dark white plate; edit mode keeps its DOM, so it marks the wrapper.
1400
+ const svgPlate = ((t.type === 'html' && t.block && wrapsSvg(t.text)) ||
1401
+ (t.type === 'paragraph' && wrapsSvgParagraph(t.raw))) ? ' data-svg-plate="1"' : '';
1402
+ parts.push(
1403
+ `<div class="ed-block" data-block-id="${b.id}" data-block-type="${b.type}"${svgPlate}>` +
1404
+ inner + '</div>'
1405
+ );
1406
+ }
1407
+ } finally {
1408
+ blockMathText = null;
1240
1409
  }
1241
1410
  // Development-time alignment guard: the render walk and blockmap.js's
1242
1411
  // pushListItemBlocks must consume blocks[] in lockstep. A mismatch means
@@ -1270,7 +1439,12 @@ ${itemsHtml}
1270
1439
  }
1271
1440
  bodyHtml = parts.join('\n');
1272
1441
  } else {
1273
- bodyHtml = marked.parse(mdPre);
1442
+ try {
1443
+ blockMathText = appendSectionText;
1444
+ bodyHtml = marked.parse(mdPre);
1445
+ } finally {
1446
+ blockMathText = null;
1447
+ }
1274
1448
  }
1275
1449
  // Both the ```math fence and the $/$$ extension emit class="katex" — a single
1276
1450
  // post-parse scan is the source of truth for conditional CSS injection.
@@ -1286,6 +1460,11 @@ ${itemsHtml}
1286
1460
  ).replace(/</g, '\\u003c');
1287
1461
  if (tocItems.length > 0) {
1288
1462
  const tocTree = buildTocTree(tocItems);
1463
+ // v3.13.1 (R3): two or more H1s kept as the TOC's top level are
1464
+ // chapters; the marker lets CSS set each chapter apart with a rule and
1465
+ // a gap. A flattened single-H1 TOC (its H2s on top) never carries it.
1466
+ const tocRoots = flattenSingleRoot(tocTree);
1467
+ const tocChapters = tocRoots === tocTree && tocTree.filter((n) => n.depth === 1).length >= 2;
1289
1468
  tocHtml = `<aside class="reader-sidebar" data-reader-sidebar>
1290
1469
  <section class="reader-tools">
1291
1470
  <label class="reader-search-label" for="doc-search-input">Search</label>
@@ -1304,7 +1483,7 @@ ${itemsHtml}
1304
1483
  </div>
1305
1484
  <div id="search-results-list"></div>
1306
1485
  </section>
1307
- <nav class="toc" aria-label="Table of contents" data-reader-toc>
1486
+ <nav class="toc" aria-label="Table of contents" data-reader-toc${tocChapters ? ' data-toc-chapters' : ''}>
1308
1487
  <div class="toc-header">
1309
1488
  <span class="toc-title">Contents</span>
1310
1489
  <div class="toc-header-actions">
@@ -1314,7 +1493,7 @@ ${itemsHtml}
1314
1493
  </div>
1315
1494
  <div class="toc-breadcrumb" data-toc-breadcrumb aria-label="Current location"></div>
1316
1495
  </div>
1317
- ${renderTocNodes(flattenSingleRoot(tocTree))}
1496
+ ${renderTocNodes(tocRoots)}
1318
1497
  </nav>
1319
1498
  </aside>
1320
1499
  <div class="sidebar-splitter" id="sidebar-splitter" role="separator" aria-orientation="vertical" aria-label="Resize sidebar"></div>`;
@@ -2539,6 +2718,7 @@ const html = `<!DOCTYPE html>
2539
2718
  padding: 0 2px;
2540
2719
  border-radius: 3px;
2541
2720
  }
2721
+ mark.search-hit-block { display: block; }
2542
2722
  .toc {
2543
2723
  display: flex;
2544
2724
  flex-direction: column;
@@ -2674,6 +2854,12 @@ const html = `<!DOCTYPE html>
2674
2854
  .toc-list-level-1 > .toc-item > details > summary > a {
2675
2855
  font-size: 0.95em;
2676
2856
  }
2857
+ /* v3.13.1 (R3): chapters of a multi-H1 TOC, set apart by a rule and a gap; weight unchanged. */
2858
+ .toc[data-toc-chapters] > .toc-list-level-1 > .toc-item + .toc-item {
2859
+ margin-top: 10px;
2860
+ padding-top: 6px;
2861
+ border-top: 1px solid #d8dee4;
2862
+ }
2677
2863
  .toc-list-level-2 > .toc-item > a,
2678
2864
  .toc-list-level-2 > .toc-item > details > summary > a {
2679
2865
  font-size: 0.9em;
@@ -3264,6 +3450,16 @@ ${usesDrawio ? ` /* v3.4.0 §4: a document with no drawio reference must not pa
3264
3450
  outline: 2px solid var(--md-accent); outline-offset: 2px;
3265
3451
  caret-color: var(--md-accent);
3266
3452
  }
3453
+ /* v3.14.0 T4: the empty-paragraph hint. syncEmptyMark() in the client sets
3454
+ data-ed-empty on a paragraph surface with no text; a real paragraph shows
3455
+ the hint while focused, the transient (an empty document's, or one Enter
3456
+ left behind) shows it even unfocused so a blank line is never a blank
3457
+ page. An empty transient also gets a line of height of its own. */
3458
+ .ed-block[data-block-type="paragraph"] > .ed-wys-armed[data-ed-empty]:focus::before,
3459
+ .ed-block[data-ed-transient] > .ed-wys-armed[data-ed-empty]::before {
3460
+ content: attr(data-placeholder); color: var(--md-muted-2); pointer-events: none;
3461
+ }
3462
+ .ed-block[data-ed-transient] > .ed-wys-armed { min-height: 1.6em; }
3267
3463
  /* Task 5: table cells armed PERMANENTLY (one edit surface per CELL, not
3268
3464
  per table root — see armEditables()'s 'table' branch in the client
3269
3465
  runtime) — same "no persistent outline, blue on :focus" language as
@@ -3995,6 +4191,7 @@ ${mermaidInitTag}`)
3995
4191
  results: [],
3996
4192
  selectedResultIndex: -1,
3997
4193
  activeHighlight: null,
4194
+ activeHighlights: [],
3998
4195
  };
3999
4196
  window.__readerState = readerState;
4000
4197
 
@@ -4137,14 +4334,18 @@ ${mermaidInitTag}`)
4137
4334
  // already above the line and would steal the highlight from the row that was
4138
4335
  // just clicked. The clicked heading stays the answer for as long as it is
4139
4336
  // still between the page top and the line, and the first scroll that moves
4140
- // it out of that band hands tracking back to the line.
4337
+ // it out of that band hands tracking back to the line. The last sections of
4338
+ // a document can be too short to ever reach the line: at the bottom of the
4339
+ // page a clicked heading anywhere on screen keeps its row lit. Only a click
4340
+ // pins, so a plain scroll to the bottom still follows the line.
4141
4341
  let pinnedHeadingId = null;
4142
4342
  function readingLineHeading() {
4143
4343
  const line = window.innerHeight * 0.35;
4144
4344
  if (pinnedHeadingId) {
4145
4345
  const pinned = document.getElementById(pinnedHeadingId);
4146
4346
  const top = pinned ? pinned.getBoundingClientRect().top : Infinity;
4147
- if (pinned && tocLinks.has(pinned.id) && top >= -40 && top <= line) return pinned;
4347
+ const atBottom = window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 2;
4348
+ if (pinned && tocLinks.has(pinned.id) && top >= -40 && (top <= line || (atBottom && top < window.innerHeight))) return pinned;
4148
4349
  pinnedHeadingId = null;
4149
4350
  }
4150
4351
  let first = null;
@@ -4183,15 +4384,54 @@ ${mermaidInitTag}`)
4183
4384
  syncActiveHeading(node.id);
4184
4385
  syncReadingTitle(node);
4185
4386
  }
4186
- window.addEventListener('scroll', () => {
4387
+ // Scrolls are not the only thing that moves a heading across the line: a
4388
+ // height-only window resize moves the line itself (and reflows nothing, so
4389
+ // no scroll follows), and a column resize reflows the text under it.
4390
+ function scheduleReadingLinePass() {
4187
4391
  if (readingLinePending) return;
4188
4392
  readingLinePending = true;
4189
4393
  requestAnimationFrame(readingLinePass);
4190
- }, { passive: true });
4394
+ }
4395
+ window.addEventListener('scroll', scheduleReadingLinePass, { passive: true });
4396
+ window.addEventListener('resize', scheduleReadingLinePass);
4397
+ // Ends the freeze a selected search result put on the highlight. The freeze
4398
+ // also kept the TOC from opening the hit's path, and the next pass may find
4399
+ // the same section (the reader is still inside the hit's section), which
4400
+ // syncActiveHeading skips as no change: so the next frame re-syncs the row
4401
+ // itself, opening its path and centring it.
4402
+ function endSearchFreeze() {
4403
+ observerFrozen = false;
4404
+ requestAnimationFrame(resyncTocToScroll);
4405
+ }
4406
+ // A TOC click is the reader leaving a search hit: it also ends the freeze,
4407
+ // so the clicked row lights.
4191
4408
  document.addEventListener('click', (event) => {
4192
4409
  const link = event.target && event.target.closest && event.target.closest('.toc a[href^="#"]');
4193
- if (link) pinnedHeadingId = link.getAttribute('href').slice(1);
4410
+ if (!link) return;
4411
+ pinnedHeadingId = link.getAttribute('href').slice(1);
4412
+ endSearchFreeze();
4194
4413
  });
4414
+ // So does a scroll of the page by hand (R6b, R17): a wheel, a touch drag or
4415
+ // a scroll key. Inside the sidebar (the results list, the TOC) or the
4416
+ // lightbox the same gestures work that control, and keep the freeze; so do
4417
+ // a sideways wheel (a wide table's swipe), ctrl+wheel (zoom) and a key typed
4418
+ // into a field. A programmatic scroll (the result jump itself) fires none of
4419
+ // these events.
4420
+ const SCROLL_KEYS = new Set(['PageUp', 'PageDown', 'Home', 'End', 'ArrowUp', 'ArrowDown', ' ', 'Spacebar']);
4421
+ const unfreezeOnUserScroll = (event) => {
4422
+ if (!observerFrozen) return;
4423
+ const target = event.target;
4424
+ if (event.type === 'wheel' && (event.deltaY === 0 || event.ctrlKey)) return;
4425
+ if (event.type === 'keydown') {
4426
+ if (!SCROLL_KEYS.has(event.key) || lightboxIsOpen()) return;
4427
+ if (target instanceof Element && (target.closest('input, textarea, select') || target.isContentEditable)) return;
4428
+ }
4429
+ if (target instanceof Element && target.closest('.reader-sidebar, .lightbox')) return;
4430
+ endSearchFreeze();
4431
+ };
4432
+ window.addEventListener('wheel', unfreezeOnUserScroll, { passive: true });
4433
+ window.addEventListener('touchmove', unfreezeOnUserScroll, { passive: true });
4434
+ window.addEventListener('keydown', unfreezeOnUserScroll);
4195
4435
  function bindHeadingObserver() {
4196
4436
  readingLinePass();
4197
4437
  }
@@ -4296,13 +4536,22 @@ ${mermaidInitTag}`)
4296
4536
  });
4297
4537
  }
4298
4538
 
4539
+ // A hit that crosses inline markup is several marks (see
4540
+ // highlightFirstOccurrence); each is unwrapped and its own parent normalised,
4541
+ // which rejoins the text it was cut from, so main.content is byte-identical.
4542
+ // A math mark wraps a whole formula and cut nothing, so it is only unwrapped.
4299
4543
  function clearSelectedHighlight() {
4300
- const mark = readerState.activeHighlight;
4301
- if (mark && mark.parentNode) {
4302
- const text = document.createTextNode(mark.textContent || '');
4303
- mark.parentNode.replaceChild(text, mark);
4304
- text.parentNode.normalize();
4305
- }
4544
+ const marks = readerState.activeHighlights.length
4545
+ ? readerState.activeHighlights
4546
+ : (readerState.activeHighlight ? [readerState.activeHighlight] : []);
4547
+ marks.forEach((mark) => {
4548
+ const parent = mark.parentNode;
4549
+ if (!parent) return;
4550
+ while (mark.firstChild) parent.insertBefore(mark.firstChild, mark);
4551
+ parent.removeChild(mark);
4552
+ if (!mark.classList.contains('search-hit-math')) parent.normalize();
4553
+ });
4554
+ readerState.activeHighlights = [];
4306
4555
  readerState.activeHighlight = null;
4307
4556
  }
4308
4557
 
@@ -4349,80 +4598,179 @@ ${mermaidInitTag}`)
4349
4598
  )).join('');
4350
4599
  }
4351
4600
 
4601
+ // On the edit page every block sits in a .ed-block wrapper, so the heading's
4602
+ // siblings are wrappers: the section ends at the next wrapper that is, or
4603
+ // directly holds, a heading of the same or a shallower depth. In the reader
4604
+ // the unit is the heading itself and the walk is the one it always was.
4352
4605
  function sectionBoundary(sectionId) {
4353
4606
  const start = document.getElementById(sectionId);
4354
4607
  if (!start) return null;
4355
4608
  const startDepth = Number(start.getAttribute('data-reader-depth') || '1');
4609
+ const unit = start.closest('.ed-block') || start;
4356
4610
  let end = null;
4357
- let node = start.nextElementSibling;
4611
+ let node = unit.nextElementSibling;
4358
4612
  while (node) {
4359
- if (node.matches && node.matches('[data-reader-heading]')) {
4360
- const depth = Number(node.getAttribute('data-reader-depth') || '1');
4613
+ let heading = null;
4614
+ if (node.matches && node.matches('[data-reader-heading]')) heading = node;
4615
+ else if (unit !== start && node.querySelector) heading = node.querySelector(':scope > [data-reader-heading]');
4616
+ if (heading) {
4617
+ const depth = Number(heading.getAttribute('data-reader-depth') || '1');
4361
4618
  if (depth <= startDepth) {
4362
- end = node;
4619
+ end = heading;
4363
4620
  break;
4364
4621
  }
4365
4622
  }
4366
4623
  node = node.nextElementSibling;
4367
4624
  }
4368
- return { start, end };
4369
- }
4370
-
4625
+ return { start, end, container: unit.parentNode };
4626
+ }
4627
+
4628
+ // A whitespace-only text node is the real space between words when it sits in
4629
+ // an inline element or between two inline siblings ("**bold** *italic*").
4630
+ // Between blocks (and inside list / table structure) it is markup layout:
4631
+ // block boundaries get a virtual space instead, and a mark must never land
4632
+ // there (a mark as a direct child of a ul or tr).
4633
+ const INLINE_SELECTOR = 'a, abbr, b, bdi, bdo, cite, code, data, del, dfn, em, i, img, ins, kbd, mark, q, s, samp, small, span, strong, sub, sup, time, u, var';
4634
+ function isInlineSibling(node) {
4635
+ if (!node) return false;
4636
+ if (node.nodeType === Node.TEXT_NODE) return !!node.nodeValue.trim();
4637
+ return node.nodeType === Node.ELEMENT_NODE && node.matches(INLINE_SELECTOR);
4638
+ }
4639
+ function isInlineSpace(node) {
4640
+ const parent = node.parentElement;
4641
+ if (!parent) return false;
4642
+ if (parent.matches(INLINE_SELECTOR)) return true;
4643
+ return isInlineSibling(node.previousSibling) && isInlineSibling(node.nextSibling);
4644
+ }
4645
+
4646
+ // The section's text as one string, read the way the search index reads it
4647
+ // (renderer side, sectionInlineText): whitespace runs fold to one space and
4648
+ // letters to lower case, as normalizeText folds the query; a br element is a
4649
+ // space; a formula (.katex) is its TeX source, taken from its annotation, and
4650
+ // its MathML and glyph markup are never walked; two blocks are joined by a
4651
+ // space. Every flat position maps back to its (node, offset), so a phrase can
4652
+ // cross a heading's .sec number, inline code, bold, a soft or hard line break
4653
+ // or a formula. Spaces that stand for a br or a block boundary map to no node
4654
+ // and are never wrapped. The walk stops at the first node that completes a
4655
+ // hit, so a long section is not read past its first occurrence.
4371
4656
  function highlightFirstOccurrence(sectionId, query) {
4372
4657
  const bounds = sectionBoundary(sectionId);
4373
4658
  if (!bounds) return null;
4374
- const { start, end } = bounds;
4375
- const container = start.parentNode;
4659
+ const { start, end, container } = bounds;
4376
4660
  if (!container) return null;
4377
- const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, {
4661
+ const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, {
4378
4662
  acceptNode(node) {
4379
- if (!node.nodeValue || !node.nodeValue.trim()) return NodeFilter.FILTER_REJECT;
4380
- if (node.parentElement && node.parentElement.closest(SKIP_SELECTOR)) return NodeFilter.FILTER_REJECT;
4381
- if (node.parentElement && node.parentElement.closest('.reader-sidebar')) return NodeFilter.FILTER_REJECT;
4663
+ if (node.nodeType === Node.ELEMENT_NODE) {
4664
+ if (node.matches(SKIP_SELECTOR) || node.matches('.reader-sidebar, .heading-anchor, button')) return NodeFilter.FILTER_REJECT;
4665
+ return node.matches('br, .katex') ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP;
4666
+ }
4667
+ if (!node.nodeValue) return NodeFilter.FILTER_REJECT;
4668
+ if (!node.nodeValue.trim() && !isInlineSpace(node)) return NodeFilter.FILTER_REJECT;
4382
4669
  return NodeFilter.FILTER_ACCEPT;
4383
4670
  },
4384
4671
  });
4385
- let inRange = false;
4386
- const queryLower = query.toLowerCase();
4672
+ // Nothing before the heading is in range, so the walk starts at it.
4673
+ walker.currentNode = start;
4674
+ const BLOCK = 'p, li, .ed-li-text, td, th, h1, h2, h3, h4, h5, h6, pre, blockquote, dt, dd, figcaption, .katex-display';
4675
+ const nodes = [];
4676
+ let flat = '';
4677
+ const at = [];
4678
+ let space = true;
4679
+ let lastBlock = null;
4680
+ let idx = -1;
4387
4681
  while (walker.nextNode()) {
4388
4682
  const node = walker.currentNode;
4389
- if (!inRange) {
4390
- if (start.contains(node)) {
4391
- inRange = true;
4392
- } else if (start.compareDocumentPosition(node) & Node.DOCUMENT_POSITION_FOLLOWING) {
4393
- inRange = true;
4394
- } else {
4395
- continue;
4396
- }
4397
- }
4398
- if (node.parentElement && node.parentElement.closest('.heading-anchor')) continue;
4399
4683
  if (end) {
4400
4684
  const pos = end.compareDocumentPosition(node);
4401
4685
  if (end === node || end.contains(node) || (pos & Node.DOCUMENT_POSITION_FOLLOWING)) {
4402
4686
  break;
4403
4687
  }
4404
4688
  }
4405
- const text = node.nodeValue;
4406
- const idx = text.toLowerCase().indexOf(queryLower);
4407
- if (idx === -1) continue;
4408
- const before = text.slice(0, idx);
4409
- const match = text.slice(idx, idx + query.length);
4410
- const after = text.slice(idx + query.length);
4689
+ if (node.nodeName === 'BR') {
4690
+ if (!space) {
4691
+ flat += ' ';
4692
+ at.push(null);
4693
+ space = true;
4694
+ }
4695
+ continue;
4696
+ }
4697
+ const isMath = node.nodeType === Node.ELEMENT_NODE;
4698
+ let text = node.nodeValue;
4699
+ if (isMath) {
4700
+ const tex = node.querySelector('annotation[encoding="application/x-tex"]');
4701
+ text = tex ? tex.textContent : '';
4702
+ // Resume the walk after the formula's last descendant.
4703
+ let last = node;
4704
+ while (last.lastChild) last = last.lastChild;
4705
+ walker.currentNode = last;
4706
+ }
4707
+ const ni = nodes.length;
4708
+ nodes.push(node);
4709
+ const prevLen = flat.length;
4710
+ const block = (node.parentElement && node.parentElement.closest(BLOCK)) || (isMath ? node : null);
4711
+ if (ni && block !== lastBlock && !space) {
4712
+ flat += ' ';
4713
+ at.push(null);
4714
+ space = true;
4715
+ }
4716
+ lastBlock = block;
4717
+ for (let i = 0; i < text.length; i++) {
4718
+ if (/\\s/.test(text[i])) {
4719
+ if (space) continue;
4720
+ flat += ' ';
4721
+ at.push([ni, i]);
4722
+ space = true;
4723
+ } else {
4724
+ // A few characters lower-case to two code units; map each to i.
4725
+ const lower = text[i].toLowerCase();
4726
+ flat += lower;
4727
+ for (let u = 0; u < lower.length; u++) at.push([ni, i]);
4728
+ space = false;
4729
+ }
4730
+ }
4731
+ // Only a hit that ends in this node's text can be new.
4732
+ idx = flat.indexOf(query, Math.max(0, prevLen - query.length + 1));
4733
+ if (idx !== -1) break;
4734
+ }
4735
+ if (idx === -1) return null;
4736
+ // node index -> [first offset, last offset] of the hit inside that node
4737
+ const spans = new Map();
4738
+ for (let k = idx; k < idx + query.length; k++) {
4739
+ const p = at[k];
4740
+ if (!p) continue;
4741
+ const span = spans.get(p[0]) || [p[1], p[1]];
4742
+ span[1] = p[1];
4743
+ spans.set(p[0], span);
4744
+ }
4745
+ // One mark per node, wrapping only that node's slice, so the .sec / code /
4746
+ // bold wrappers stay whole siblings and clearing restores the markup
4747
+ // exactly. Last node first; each node is split only once anyway. A formula
4748
+ // is marked whole from outside, so KaTeX's own markup is never cut; a
4749
+ // display formula's mark goes around its .katex-display (KaTeX styles
4750
+ // .katex-display > .katex) and is a block, so its background shows.
4751
+ const marks = [];
4752
+ Array.from(spans.keys()).sort((a, b) => b - a).forEach((ni) => {
4753
+ const node = nodes[ni];
4411
4754
  const mark = document.createElement('mark');
4412
4755
  mark.className = 'search-hit is-selected';
4413
- mark.textContent = match;
4414
- const parent = node.parentNode;
4415
- if (!parent) return null;
4416
- if (before) parent.insertBefore(document.createTextNode(before), node);
4417
- parent.insertBefore(mark, node);
4418
- if (after) {
4419
- node.nodeValue = after;
4756
+ if (node.nodeType === Node.ELEMENT_NODE) {
4757
+ const display = node.parentElement && node.parentElement.classList.contains('katex-display') ? node.parentElement : null;
4758
+ const target = display || node;
4759
+ mark.classList.add('search-hit-math');
4760
+ if (display) mark.classList.add('search-hit-block');
4761
+ target.parentNode.insertBefore(mark, target);
4762
+ mark.appendChild(target);
4420
4763
  } else {
4421
- parent.removeChild(node);
4764
+ const span = spans.get(ni);
4765
+ const range = document.createRange();
4766
+ range.setStart(node, span[0]);
4767
+ range.setEnd(node, span[1] + 1);
4768
+ range.surroundContents(mark);
4422
4769
  }
4423
- return mark;
4424
- }
4425
- return null;
4770
+ marks.unshift(mark);
4771
+ });
4772
+ readerState.activeHighlights = marks;
4773
+ return marks[0] || null;
4426
4774
  }
4427
4775
 
4428
4776
  function jumpToAndHighlight(result) {
@@ -4670,6 +5018,11 @@ ${mermaidInitTag}`)
4670
5018
  var anchorTop = 0;
4671
5019
  var anchorFrame = 0;
4672
5020
  var suppressAnchorCapture = false;
5021
+ // Set by any scroll that is not our own restore, cleared by a capture: the
5022
+ // anchor then describes a position the page has already left (R1, R9).
5023
+ var anchorStale = false;
5024
+ // scrollY when the anchor was captured or last restored by us.
5025
+ var anchorScrollY = 0;
4673
5026
  // Column width the anchor was last captured/held at; -1 until the column
4674
5027
  // ResizeObserver below is installed. See captureScrollAnchor's first guard.
4675
5028
  var lastAnchorColW = -1;
@@ -4715,8 +5068,13 @@ ${mermaidInitTag}`)
4715
5068
  }
4716
5069
  }
4717
5070
  if (!node) return;
5071
+ // A display formula's search mark is itself a child of the column, and
5072
+ // clearing the search removes it: pin the formula inside it instead.
5073
+ if (node.matches('mark.search-hit') && node.firstElementChild) node = node.firstElementChild;
4718
5074
  anchorNode = node;
4719
5075
  anchorTop = node.getBoundingClientRect().top;
5076
+ anchorScrollY = window.scrollY;
5077
+ anchorStale = false;
4720
5078
  }
4721
5079
 
4722
5080
  function restoreScrollAnchor() {
@@ -4724,9 +5082,13 @@ ${mermaidInitTag}`)
4724
5082
  var delta = anchorNode.getBoundingClientRect().top - anchorTop;
4725
5083
  if (!delta) return;
4726
5084
  window.scrollTo(window.scrollX, window.scrollY + delta);
5085
+ // Our own correction is not a newer scroll: without this, the next frame
5086
+ // of a splitter drag reads it as one and drops the anchor.
5087
+ anchorScrollY = window.scrollY;
4727
5088
  }
4728
5089
 
4729
5090
  window.addEventListener('scroll', function () {
5091
+ if (!suppressAnchorCapture) anchorStale = true;
4730
5092
  if (anchorFrame) return;
4731
5093
  anchorFrame = window.requestAnimationFrame(function () {
4732
5094
  anchorFrame = 0;
@@ -4735,6 +5097,13 @@ ${mermaidInitTag}`)
4735
5097
  }, { passive: true });
4736
5098
 
4737
5099
  function holdScrollAnchor() {
5100
+ // A scroll issued before this reflow's frame may not have dispatched its
5101
+ // event yet (resize runs before scroll in a frame): compare offsets too.
5102
+ // A shorter document clamping the offset is not somebody else's scroll.
5103
+ var maxY = document.documentElement.scrollHeight - window.innerHeight;
5104
+ var moved = Math.abs(window.scrollY - anchorScrollY) > 1;
5105
+ var clamped = window.scrollY < anchorScrollY && window.scrollY >= maxY - 1;
5106
+ if (anchorStale || (moved && !clamped)) anchorNode = null;
4738
5107
  // Hold the anchor across the whole reflow: the scrollTo below fires scroll
4739
5108
  // events that would otherwise re-capture a mid-reflow position.
4740
5109
  suppressAnchorCapture = true;
@@ -4774,6 +5143,7 @@ ${mermaidInitTag}`)
4774
5143
  if (w === lastAnchorColW) return;
4775
5144
  lastAnchorColW = w;
4776
5145
  holdScrollAnchor();
5146
+ scheduleReadingLinePass();
4777
5147
  }).observe(contentEl);
4778
5148
  }
4779
5149