@helping-ai-workflow/md2doc 3.13.0 → 3.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,37 @@ ${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
+ parts.push(
1399
+ `<div class="ed-block" data-block-id="${b.id}" data-block-type="${b.type}">` +
1400
+ inner + '</div>'
1401
+ );
1402
+ }
1403
+ } finally {
1404
+ blockMathText = null;
1240
1405
  }
1241
1406
  // Development-time alignment guard: the render walk and blockmap.js's
1242
1407
  // pushListItemBlocks must consume blocks[] in lockstep. A mismatch means
@@ -1270,7 +1435,12 @@ ${itemsHtml}
1270
1435
  }
1271
1436
  bodyHtml = parts.join('\n');
1272
1437
  } else {
1273
- bodyHtml = marked.parse(mdPre);
1438
+ try {
1439
+ blockMathText = appendSectionText;
1440
+ bodyHtml = marked.parse(mdPre);
1441
+ } finally {
1442
+ blockMathText = null;
1443
+ }
1274
1444
  }
1275
1445
  // Both the ```math fence and the $/$$ extension emit class="katex" — a single
1276
1446
  // post-parse scan is the source of truth for conditional CSS injection.
@@ -1286,6 +1456,11 @@ ${itemsHtml}
1286
1456
  ).replace(/</g, '\\u003c');
1287
1457
  if (tocItems.length > 0) {
1288
1458
  const tocTree = buildTocTree(tocItems);
1459
+ // v3.13.1 (R3): two or more H1s kept as the TOC's top level are
1460
+ // chapters; the marker lets CSS set each chapter apart with a rule and
1461
+ // a gap. A flattened single-H1 TOC (its H2s on top) never carries it.
1462
+ const tocRoots = flattenSingleRoot(tocTree);
1463
+ const tocChapters = tocRoots === tocTree && tocTree.filter((n) => n.depth === 1).length >= 2;
1289
1464
  tocHtml = `<aside class="reader-sidebar" data-reader-sidebar>
1290
1465
  <section class="reader-tools">
1291
1466
  <label class="reader-search-label" for="doc-search-input">Search</label>
@@ -1304,7 +1479,7 @@ ${itemsHtml}
1304
1479
  </div>
1305
1480
  <div id="search-results-list"></div>
1306
1481
  </section>
1307
- <nav class="toc" aria-label="Table of contents" data-reader-toc>
1482
+ <nav class="toc" aria-label="Table of contents" data-reader-toc${tocChapters ? ' data-toc-chapters' : ''}>
1308
1483
  <div class="toc-header">
1309
1484
  <span class="toc-title">Contents</span>
1310
1485
  <div class="toc-header-actions">
@@ -1314,7 +1489,7 @@ ${itemsHtml}
1314
1489
  </div>
1315
1490
  <div class="toc-breadcrumb" data-toc-breadcrumb aria-label="Current location"></div>
1316
1491
  </div>
1317
- ${renderTocNodes(flattenSingleRoot(tocTree))}
1492
+ ${renderTocNodes(tocRoots)}
1318
1493
  </nav>
1319
1494
  </aside>
1320
1495
  <div class="sidebar-splitter" id="sidebar-splitter" role="separator" aria-orientation="vertical" aria-label="Resize sidebar"></div>`;
@@ -2539,6 +2714,7 @@ const html = `<!DOCTYPE html>
2539
2714
  padding: 0 2px;
2540
2715
  border-radius: 3px;
2541
2716
  }
2717
+ mark.search-hit-block { display: block; }
2542
2718
  .toc {
2543
2719
  display: flex;
2544
2720
  flex-direction: column;
@@ -2674,6 +2850,12 @@ const html = `<!DOCTYPE html>
2674
2850
  .toc-list-level-1 > .toc-item > details > summary > a {
2675
2851
  font-size: 0.95em;
2676
2852
  }
2853
+ /* v3.13.1 (R3): chapters of a multi-H1 TOC, set apart by a rule and a gap; weight unchanged. */
2854
+ .toc[data-toc-chapters] > .toc-list-level-1 > .toc-item + .toc-item {
2855
+ margin-top: 10px;
2856
+ padding-top: 6px;
2857
+ border-top: 1px solid #d8dee4;
2858
+ }
2677
2859
  .toc-list-level-2 > .toc-item > a,
2678
2860
  .toc-list-level-2 > .toc-item > details > summary > a {
2679
2861
  font-size: 0.9em;
@@ -3995,6 +4177,7 @@ ${mermaidInitTag}`)
3995
4177
  results: [],
3996
4178
  selectedResultIndex: -1,
3997
4179
  activeHighlight: null,
4180
+ activeHighlights: [],
3998
4181
  };
3999
4182
  window.__readerState = readerState;
4000
4183
 
@@ -4137,14 +4320,18 @@ ${mermaidInitTag}`)
4137
4320
  // already above the line and would steal the highlight from the row that was
4138
4321
  // just clicked. The clicked heading stays the answer for as long as it is
4139
4322
  // 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.
4323
+ // it out of that band hands tracking back to the line. The last sections of
4324
+ // a document can be too short to ever reach the line: at the bottom of the
4325
+ // page a clicked heading anywhere on screen keeps its row lit. Only a click
4326
+ // pins, so a plain scroll to the bottom still follows the line.
4141
4327
  let pinnedHeadingId = null;
4142
4328
  function readingLineHeading() {
4143
4329
  const line = window.innerHeight * 0.35;
4144
4330
  if (pinnedHeadingId) {
4145
4331
  const pinned = document.getElementById(pinnedHeadingId);
4146
4332
  const top = pinned ? pinned.getBoundingClientRect().top : Infinity;
4147
- if (pinned && tocLinks.has(pinned.id) && top >= -40 && top <= line) return pinned;
4333
+ const atBottom = window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 2;
4334
+ if (pinned && tocLinks.has(pinned.id) && top >= -40 && (top <= line || (atBottom && top < window.innerHeight))) return pinned;
4148
4335
  pinnedHeadingId = null;
4149
4336
  }
4150
4337
  let first = null;
@@ -4183,15 +4370,54 @@ ${mermaidInitTag}`)
4183
4370
  syncActiveHeading(node.id);
4184
4371
  syncReadingTitle(node);
4185
4372
  }
4186
- window.addEventListener('scroll', () => {
4373
+ // Scrolls are not the only thing that moves a heading across the line: a
4374
+ // height-only window resize moves the line itself (and reflows nothing, so
4375
+ // no scroll follows), and a column resize reflows the text under it.
4376
+ function scheduleReadingLinePass() {
4187
4377
  if (readingLinePending) return;
4188
4378
  readingLinePending = true;
4189
4379
  requestAnimationFrame(readingLinePass);
4190
- }, { passive: true });
4380
+ }
4381
+ window.addEventListener('scroll', scheduleReadingLinePass, { passive: true });
4382
+ window.addEventListener('resize', scheduleReadingLinePass);
4383
+ // Ends the freeze a selected search result put on the highlight. The freeze
4384
+ // also kept the TOC from opening the hit's path, and the next pass may find
4385
+ // the same section (the reader is still inside the hit's section), which
4386
+ // syncActiveHeading skips as no change: so the next frame re-syncs the row
4387
+ // itself, opening its path and centring it.
4388
+ function endSearchFreeze() {
4389
+ observerFrozen = false;
4390
+ requestAnimationFrame(resyncTocToScroll);
4391
+ }
4392
+ // A TOC click is the reader leaving a search hit: it also ends the freeze,
4393
+ // so the clicked row lights.
4191
4394
  document.addEventListener('click', (event) => {
4192
4395
  const link = event.target && event.target.closest && event.target.closest('.toc a[href^="#"]');
4193
- if (link) pinnedHeadingId = link.getAttribute('href').slice(1);
4396
+ if (!link) return;
4397
+ pinnedHeadingId = link.getAttribute('href').slice(1);
4398
+ endSearchFreeze();
4194
4399
  });
4400
+ // So does a scroll of the page by hand (R6b, R17): a wheel, a touch drag or
4401
+ // a scroll key. Inside the sidebar (the results list, the TOC) or the
4402
+ // lightbox the same gestures work that control, and keep the freeze; so do
4403
+ // a sideways wheel (a wide table's swipe), ctrl+wheel (zoom) and a key typed
4404
+ // into a field. A programmatic scroll (the result jump itself) fires none of
4405
+ // these events.
4406
+ const SCROLL_KEYS = new Set(['PageUp', 'PageDown', 'Home', 'End', 'ArrowUp', 'ArrowDown', ' ', 'Spacebar']);
4407
+ const unfreezeOnUserScroll = (event) => {
4408
+ if (!observerFrozen) return;
4409
+ const target = event.target;
4410
+ if (event.type === 'wheel' && (event.deltaY === 0 || event.ctrlKey)) return;
4411
+ if (event.type === 'keydown') {
4412
+ if (!SCROLL_KEYS.has(event.key) || lightboxIsOpen()) return;
4413
+ if (target instanceof Element && (target.closest('input, textarea, select') || target.isContentEditable)) return;
4414
+ }
4415
+ if (target instanceof Element && target.closest('.reader-sidebar, .lightbox')) return;
4416
+ endSearchFreeze();
4417
+ };
4418
+ window.addEventListener('wheel', unfreezeOnUserScroll, { passive: true });
4419
+ window.addEventListener('touchmove', unfreezeOnUserScroll, { passive: true });
4420
+ window.addEventListener('keydown', unfreezeOnUserScroll);
4195
4421
  function bindHeadingObserver() {
4196
4422
  readingLinePass();
4197
4423
  }
@@ -4296,13 +4522,22 @@ ${mermaidInitTag}`)
4296
4522
  });
4297
4523
  }
4298
4524
 
4525
+ // A hit that crosses inline markup is several marks (see
4526
+ // highlightFirstOccurrence); each is unwrapped and its own parent normalised,
4527
+ // which rejoins the text it was cut from, so main.content is byte-identical.
4528
+ // A math mark wraps a whole formula and cut nothing, so it is only unwrapped.
4299
4529
  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
- }
4530
+ const marks = readerState.activeHighlights.length
4531
+ ? readerState.activeHighlights
4532
+ : (readerState.activeHighlight ? [readerState.activeHighlight] : []);
4533
+ marks.forEach((mark) => {
4534
+ const parent = mark.parentNode;
4535
+ if (!parent) return;
4536
+ while (mark.firstChild) parent.insertBefore(mark.firstChild, mark);
4537
+ parent.removeChild(mark);
4538
+ if (!mark.classList.contains('search-hit-math')) parent.normalize();
4539
+ });
4540
+ readerState.activeHighlights = [];
4306
4541
  readerState.activeHighlight = null;
4307
4542
  }
4308
4543
 
@@ -4368,61 +4603,153 @@ ${mermaidInitTag}`)
4368
4603
  return { start, end };
4369
4604
  }
4370
4605
 
4606
+ // A whitespace-only text node is the real space between words when it sits in
4607
+ // an inline element or between two inline siblings ("**bold** *italic*").
4608
+ // Between blocks (and inside list / table structure) it is markup layout:
4609
+ // block boundaries get a virtual space instead, and a mark must never land
4610
+ // there (a mark as a direct child of a ul or tr).
4611
+ 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';
4612
+ function isInlineSibling(node) {
4613
+ if (!node) return false;
4614
+ if (node.nodeType === Node.TEXT_NODE) return !!node.nodeValue.trim();
4615
+ return node.nodeType === Node.ELEMENT_NODE && node.matches(INLINE_SELECTOR);
4616
+ }
4617
+ function isInlineSpace(node) {
4618
+ const parent = node.parentElement;
4619
+ if (!parent) return false;
4620
+ if (parent.matches(INLINE_SELECTOR)) return true;
4621
+ return isInlineSibling(node.previousSibling) && isInlineSibling(node.nextSibling);
4622
+ }
4623
+
4624
+ // The section's text as one string, read the way the search index reads it
4625
+ // (renderer side, sectionInlineText): whitespace runs fold to one space and
4626
+ // letters to lower case, as normalizeText folds the query; a br element is a
4627
+ // space; a formula (.katex) is its TeX source, taken from its annotation, and
4628
+ // its MathML and glyph markup are never walked; two blocks are joined by a
4629
+ // space. Every flat position maps back to its (node, offset), so a phrase can
4630
+ // cross a heading's .sec number, inline code, bold, a soft or hard line break
4631
+ // or a formula. Spaces that stand for a br or a block boundary map to no node
4632
+ // and are never wrapped. The walk stops at the first node that completes a
4633
+ // hit, so a long section is not read past its first occurrence.
4371
4634
  function highlightFirstOccurrence(sectionId, query) {
4372
4635
  const bounds = sectionBoundary(sectionId);
4373
4636
  if (!bounds) return null;
4374
4637
  const { start, end } = bounds;
4375
4638
  const container = start.parentNode;
4376
4639
  if (!container) return null;
4377
- const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, {
4640
+ const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, {
4378
4641
  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;
4642
+ if (node.nodeType === Node.ELEMENT_NODE) {
4643
+ if (node.matches(SKIP_SELECTOR) || node.matches('.reader-sidebar, .heading-anchor')) return NodeFilter.FILTER_REJECT;
4644
+ return node.matches('br, .katex') ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP;
4645
+ }
4646
+ if (!node.nodeValue) return NodeFilter.FILTER_REJECT;
4647
+ if (!node.nodeValue.trim() && !isInlineSpace(node)) return NodeFilter.FILTER_REJECT;
4382
4648
  return NodeFilter.FILTER_ACCEPT;
4383
4649
  },
4384
4650
  });
4385
- let inRange = false;
4386
- const queryLower = query.toLowerCase();
4651
+ // Nothing before the heading is in range, so the walk starts at it.
4652
+ walker.currentNode = start;
4653
+ const BLOCK = 'p, li, td, th, h1, h2, h3, h4, h5, h6, pre, blockquote, dt, dd, figcaption, .katex-display';
4654
+ const nodes = [];
4655
+ let flat = '';
4656
+ const at = [];
4657
+ let space = true;
4658
+ let lastBlock = null;
4659
+ let idx = -1;
4387
4660
  while (walker.nextNode()) {
4388
4661
  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
4662
  if (end) {
4400
4663
  const pos = end.compareDocumentPosition(node);
4401
4664
  if (end === node || end.contains(node) || (pos & Node.DOCUMENT_POSITION_FOLLOWING)) {
4402
4665
  break;
4403
4666
  }
4404
4667
  }
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);
4668
+ if (node.nodeName === 'BR') {
4669
+ if (!space) {
4670
+ flat += ' ';
4671
+ at.push(null);
4672
+ space = true;
4673
+ }
4674
+ continue;
4675
+ }
4676
+ const isMath = node.nodeType === Node.ELEMENT_NODE;
4677
+ let text = node.nodeValue;
4678
+ if (isMath) {
4679
+ const tex = node.querySelector('annotation[encoding="application/x-tex"]');
4680
+ text = tex ? tex.textContent : '';
4681
+ // Resume the walk after the formula's last descendant.
4682
+ let last = node;
4683
+ while (last.lastChild) last = last.lastChild;
4684
+ walker.currentNode = last;
4685
+ }
4686
+ const ni = nodes.length;
4687
+ nodes.push(node);
4688
+ const prevLen = flat.length;
4689
+ const block = (node.parentElement && node.parentElement.closest(BLOCK)) || (isMath ? node : null);
4690
+ if (ni && block !== lastBlock && !space) {
4691
+ flat += ' ';
4692
+ at.push(null);
4693
+ space = true;
4694
+ }
4695
+ lastBlock = block;
4696
+ for (let i = 0; i < text.length; i++) {
4697
+ if (/\\s/.test(text[i])) {
4698
+ if (space) continue;
4699
+ flat += ' ';
4700
+ at.push([ni, i]);
4701
+ space = true;
4702
+ } else {
4703
+ // A few characters lower-case to two code units; map each to i.
4704
+ const lower = text[i].toLowerCase();
4705
+ flat += lower;
4706
+ for (let u = 0; u < lower.length; u++) at.push([ni, i]);
4707
+ space = false;
4708
+ }
4709
+ }
4710
+ // Only a hit that ends in this node's text can be new.
4711
+ idx = flat.indexOf(query, Math.max(0, prevLen - query.length + 1));
4712
+ if (idx !== -1) break;
4713
+ }
4714
+ if (idx === -1) return null;
4715
+ // node index -> [first offset, last offset] of the hit inside that node
4716
+ const spans = new Map();
4717
+ for (let k = idx; k < idx + query.length; k++) {
4718
+ const p = at[k];
4719
+ if (!p) continue;
4720
+ const span = spans.get(p[0]) || [p[1], p[1]];
4721
+ span[1] = p[1];
4722
+ spans.set(p[0], span);
4723
+ }
4724
+ // One mark per node, wrapping only that node's slice, so the .sec / code /
4725
+ // bold wrappers stay whole siblings and clearing restores the markup
4726
+ // exactly. Last node first; each node is split only once anyway. A formula
4727
+ // is marked whole from outside, so KaTeX's own markup is never cut; a
4728
+ // display formula's mark goes around its .katex-display (KaTeX styles
4729
+ // .katex-display > .katex) and is a block, so its background shows.
4730
+ const marks = [];
4731
+ Array.from(spans.keys()).sort((a, b) => b - a).forEach((ni) => {
4732
+ const node = nodes[ni];
4411
4733
  const mark = document.createElement('mark');
4412
4734
  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;
4735
+ if (node.nodeType === Node.ELEMENT_NODE) {
4736
+ const display = node.parentElement && node.parentElement.classList.contains('katex-display') ? node.parentElement : null;
4737
+ const target = display || node;
4738
+ mark.classList.add('search-hit-math');
4739
+ if (display) mark.classList.add('search-hit-block');
4740
+ target.parentNode.insertBefore(mark, target);
4741
+ mark.appendChild(target);
4420
4742
  } else {
4421
- parent.removeChild(node);
4743
+ const span = spans.get(ni);
4744
+ const range = document.createRange();
4745
+ range.setStart(node, span[0]);
4746
+ range.setEnd(node, span[1] + 1);
4747
+ range.surroundContents(mark);
4422
4748
  }
4423
- return mark;
4424
- }
4425
- return null;
4749
+ marks.unshift(mark);
4750
+ });
4751
+ readerState.activeHighlights = marks;
4752
+ return marks[0] || null;
4426
4753
  }
4427
4754
 
4428
4755
  function jumpToAndHighlight(result) {
@@ -4670,6 +4997,11 @@ ${mermaidInitTag}`)
4670
4997
  var anchorTop = 0;
4671
4998
  var anchorFrame = 0;
4672
4999
  var suppressAnchorCapture = false;
5000
+ // Set by any scroll that is not our own restore, cleared by a capture: the
5001
+ // anchor then describes a position the page has already left (R1, R9).
5002
+ var anchorStale = false;
5003
+ // scrollY when the anchor was captured or last restored by us.
5004
+ var anchorScrollY = 0;
4673
5005
  // Column width the anchor was last captured/held at; -1 until the column
4674
5006
  // ResizeObserver below is installed. See captureScrollAnchor's first guard.
4675
5007
  var lastAnchorColW = -1;
@@ -4715,8 +5047,13 @@ ${mermaidInitTag}`)
4715
5047
  }
4716
5048
  }
4717
5049
  if (!node) return;
5050
+ // A display formula's search mark is itself a child of the column, and
5051
+ // clearing the search removes it: pin the formula inside it instead.
5052
+ if (node.matches('mark.search-hit') && node.firstElementChild) node = node.firstElementChild;
4718
5053
  anchorNode = node;
4719
5054
  anchorTop = node.getBoundingClientRect().top;
5055
+ anchorScrollY = window.scrollY;
5056
+ anchorStale = false;
4720
5057
  }
4721
5058
 
4722
5059
  function restoreScrollAnchor() {
@@ -4724,9 +5061,13 @@ ${mermaidInitTag}`)
4724
5061
  var delta = anchorNode.getBoundingClientRect().top - anchorTop;
4725
5062
  if (!delta) return;
4726
5063
  window.scrollTo(window.scrollX, window.scrollY + delta);
5064
+ // Our own correction is not a newer scroll: without this, the next frame
5065
+ // of a splitter drag reads it as one and drops the anchor.
5066
+ anchorScrollY = window.scrollY;
4727
5067
  }
4728
5068
 
4729
5069
  window.addEventListener('scroll', function () {
5070
+ if (!suppressAnchorCapture) anchorStale = true;
4730
5071
  if (anchorFrame) return;
4731
5072
  anchorFrame = window.requestAnimationFrame(function () {
4732
5073
  anchorFrame = 0;
@@ -4735,6 +5076,13 @@ ${mermaidInitTag}`)
4735
5076
  }, { passive: true });
4736
5077
 
4737
5078
  function holdScrollAnchor() {
5079
+ // A scroll issued before this reflow's frame may not have dispatched its
5080
+ // event yet (resize runs before scroll in a frame): compare offsets too.
5081
+ // A shorter document clamping the offset is not somebody else's scroll.
5082
+ var maxY = document.documentElement.scrollHeight - window.innerHeight;
5083
+ var moved = Math.abs(window.scrollY - anchorScrollY) > 1;
5084
+ var clamped = window.scrollY < anchorScrollY && window.scrollY >= maxY - 1;
5085
+ if (anchorStale || (moved && !clamped)) anchorNode = null;
4738
5086
  // Hold the anchor across the whole reflow: the scrollTo below fires scroll
4739
5087
  // events that would otherwise re-capture a mid-reflow position.
4740
5088
  suppressAnchorCapture = true;
@@ -4774,6 +5122,7 @@ ${mermaidInitTag}`)
4774
5122
  if (w === lastAnchorColW) return;
4775
5123
  lastAnchorColW = w;
4776
5124
  holdScrollAnchor();
5125
+ scheduleReadingLinePass();
4777
5126
  }).observe(contentEl);
4778
5127
  }
4779
5128