@code-yeongyu/senpi-tui 2026.11.12 → 2026.11.13

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.
@@ -1,9 +1,12 @@
1
1
  // allow: SIZE_OK - existing markdown renderer is oversized; this merge only preserves behavior and cache-key correctness.
2
2
  import { Marked, Tokenizer } from "marked";
3
+ import { copyLineSources, getLineSources, setLineSource, setLineSources, } from "../selection-source.js";
4
+ import { concatRanges, emptyRanges, rangeAt, repeatedRange, sliceRanges } from "../source-ranges.js";
3
5
  import { getCapabilities, hyperlink, isImageLine } from "../terminal-image.js";
4
6
  import { nextRenderRevision } from "../tui.js";
5
- import { applyBackgroundToLine, flattenLines, visibleWidth, wrapTextWithAnsi } from "../utils.js";
7
+ import { applyBackgroundToLine, flattenLines, stripTerminalSequences, visibleWidth, wrapTextWithAnsi, } from "../utils.js";
6
8
  import { latexToUnicode } from "./latex.js";
9
+ import { lexWithSources, sourceExtensions, tokenSource } from "./markdown-source.js";
7
10
  const STRICT_STRIKETHROUGH_REGEX = /^(~~)(?=[^\s~])((?:\\.|[^\\])*?(?:\\.|[^\s~\\]))\1(?=[^~]|$)/;
8
11
  class StrictStrikethroughTokenizer extends Tokenizer {
9
12
  del(src) {
@@ -31,6 +34,24 @@ function isLatexToken(token) {
31
34
  "text" in token &&
32
35
  typeof token.text === "string");
33
36
  }
37
+ function formulaRanges(token, rendered) {
38
+ const source = tokenSource(token);
39
+ if (!source)
40
+ return repeatedRange(null, rendered.length);
41
+ const { raw, text } = source;
42
+ const end = raw.text.trimEnd().length;
43
+ if (raw.text === rendered)
44
+ return raw.ranges;
45
+ if (raw.text.trim() === rendered) {
46
+ return sliceRanges(raw.ranges, raw.text.length - raw.text.trimStart().length, end);
47
+ }
48
+ if (text?.text === rendered)
49
+ return text.ranges;
50
+ // A converted glyph has no reliable character-level correspondence to TeX.
51
+ const first = rangeAt(raw.ranges, 0);
52
+ const last = rangeAt(raw.ranges, end - 1);
53
+ return repeatedRange(first && last ? { start: first.start, end: last.end } : null, rendered.length);
54
+ }
34
55
  function previousRawCharacter(tokens) {
35
56
  const raw = tokens[tokens.length - 1]?.raw;
36
57
  return raw ? Array.from(raw).at(-1) : undefined;
@@ -264,20 +285,26 @@ function trimPartialClosingFences(tokens) {
264
285
  }
265
286
  const markdownParser = new Marked();
266
287
  markdownParser.use({ extensions: [blockMathTokenizer, inlineMathTokenizer] });
288
+ markdownParser.use({ extensions: sourceExtensions });
267
289
  markdownParser.setOptions({
268
290
  tokenizer: new StrictStrikethroughTokenizer(),
269
291
  });
270
292
  const RENDER_CACHE_MAX = 256;
271
293
  const PARSE_CACHE_MAX = 128;
294
+ // Original-source work is paid only on copy, with a small cache separate from normal rendering.
295
+ const SOURCE_CACHE_MAX = 8;
272
296
  const CONTENT_KEY_CACHE_MAX = 128;
273
297
  const HIGHLIGHT_CACHE_MAX = 512;
274
298
  const MAX_HIGHLIGHT_BYTES = 200_000;
275
299
  const MAX_HIGHLIGHT_LINES = 2000;
276
300
  const renderCache = new Map();
277
301
  const parseCache = new Map();
302
+ const sourceRenderCache = new Map();
303
+ const sourceParseCache = new Map();
278
304
  const contentKeyCache = new Map();
279
305
  const highlightCache = new Map();
280
306
  const objectIds = new WeakMap();
307
+ const enclosedTokens = new WeakSet();
281
308
  let nextObjectId = 0;
282
309
  let markdownHighlightCallCount = 0;
283
310
  function objectId(value) {
@@ -316,6 +343,8 @@ function contentKey(text) {
316
343
  export function clearRenderCache() {
317
344
  renderCache.clear();
318
345
  parseCache.clear();
346
+ sourceRenderCache.clear();
347
+ sourceParseCache.clear();
319
348
  contentKeyCache.clear();
320
349
  highlightCache.clear();
321
350
  }
@@ -327,6 +356,7 @@ export function resetMarkdownHighlightCallCount() {
327
356
  }
328
357
  export class Markdown {
329
358
  constructor(text, paddingX, paddingY, theme, defaultTextStyle, options) {
359
+ this.collectSources = false;
330
360
  this.revision = nextRenderRevision();
331
361
  this.text = text;
332
362
  this.paddingX = paddingX;
@@ -343,6 +373,7 @@ export class Markdown {
343
373
  this.cachedText = undefined;
344
374
  this.cachedWidth = undefined;
345
375
  this.cachedLines = undefined;
376
+ this.activeSourceDocument = undefined;
346
377
  this.markRevision();
347
378
  }
348
379
  /** Instances that expose a revision advance the shared clock; others (e.g. an animated subclass) stay local. */
@@ -370,12 +401,10 @@ export class Markdown {
370
401
  this.cachedLines = result;
371
402
  return result;
372
403
  }
373
- // Replace tabs with 3 spaces for consistent rendering
374
- const normalizedText = text.replace(/\t/g, " ");
375
- const normalizedContentKey = contentKey(normalizedText);
376
404
  const styleKey = this.defaultTextStyle ? objectId(this.defaultTextStyle) : -1;
377
405
  const renderKey = [
378
- normalizedContentKey,
406
+ contentKey(this.text),
407
+ contentKey(text),
379
408
  width,
380
409
  contentWidth,
381
410
  this.paddingX,
@@ -389,44 +418,62 @@ export class Markdown {
389
418
  getCapabilities().images ?? "",
390
419
  getCapabilities().hyperlinks ? 1 : 0,
391
420
  ].join("\x00");
392
- const cachedRender = renderCache.get(renderKey);
393
- if (cachedRender?.source === normalizedText) {
421
+ const selectedRenderCache = this.collectSources ? sourceRenderCache : renderCache;
422
+ const cachedRender = selectedRenderCache.get(renderKey);
423
+ if (cachedRender?.source === this.text && cachedRender.transformed === text) {
394
424
  this.cachedText = this.text;
395
425
  this.cachedWidth = width;
396
- this.cachedLines = cachedRender.lines;
397
- return cachedRender.lines;
426
+ this.cachedLines = this.ownSources(cachedRender.lines);
427
+ return this.cachedLines;
398
428
  }
399
429
  // Parse markdown to HTML-like tokens
400
- const cachedParse = parseCache.get(normalizedContentKey);
430
+ const parseKey = contentKey(text);
431
+ const selectedParseCache = this.collectSources ? sourceParseCache : parseCache;
432
+ const cachedParse = selectedParseCache.get(parseKey);
401
433
  let tokens;
402
- if (cachedParse?.source === normalizedText) {
434
+ if (cachedParse?.source === text) {
403
435
  tokens = cachedParse.tokens;
404
436
  }
405
437
  else {
406
- tokens = markdownParser.lexer(normalizedText);
438
+ tokens = this.collectSources
439
+ ? lexWithSources(text, markdownParser.defaults)
440
+ : markdownParser.lexer(text.replace(/\t/g, " "));
407
441
  trimPartialClosingFences(tokens);
408
- cacheSet(parseCache, normalizedContentKey, { source: normalizedText, tokens }, PARSE_CACHE_MAX);
442
+ cacheSet(selectedParseCache, parseKey, { source: text, tokens }, this.collectSources ? SOURCE_CACHE_MAX : PARSE_CACHE_MAX);
409
443
  }
410
444
  // Convert tokens to styled terminal output
445
+ this.activeSourceDocument = this.collectSources
446
+ ? (tokens.map((token) => tokenSource(token)?.raw.document).find((document) => document) ?? {
447
+ text,
448
+ enclosures: [],
449
+ })
450
+ : undefined;
411
451
  const renderedLines = [];
412
452
  for (let i = 0; i < tokens.length; i++) {
413
453
  const token = tokens[i];
414
454
  const nextToken = tokens[i + 1];
415
455
  const tokenLines = this.renderToken(token, contentWidth, nextToken?.type);
456
+ const offset = renderedLines.length;
416
457
  for (const tokenLine of tokenLines) {
417
458
  renderedLines.push(tokenLine);
418
459
  }
460
+ copyLineSources(tokenLines, renderedLines, offset);
419
461
  }
420
462
  // Wrap lines (NO padding, NO background yet)
421
463
  const wrappedLines = [];
422
- for (const line of renderedLines) {
464
+ for (let row = 0; row < renderedLines.length; row++) {
465
+ const line = renderedLines[row];
466
+ const mapping = getLineSources(renderedLines)?.[row];
467
+ const offset = wrappedLines.length;
423
468
  if (isImageLine(line)) {
424
469
  wrappedLines.push(line);
425
470
  }
426
471
  else {
427
- for (const wrappedLine of wrapTextWithAnsi(line, contentWidth)) {
472
+ const fragments = this.wrapSource(line, contentWidth, mapping);
473
+ for (const wrappedLine of fragments) {
428
474
  wrappedLines.push(wrappedLine);
429
475
  }
476
+ copyLineSources(fragments, wrappedLines, offset);
430
477
  }
431
478
  }
432
479
  // Add margins and background to each wrapped line
@@ -450,6 +497,7 @@ export class Markdown {
450
497
  contentLines.push(lineWithMargins + " ".repeat(paddingNeeded));
451
498
  }
452
499
  }
500
+ copyLineSources(wrappedLines, contentLines, 0, this.paddingX);
453
501
  // Add top/bottom padding (empty lines)
454
502
  const emptyLine = " ".repeat(width);
455
503
  const emptyLines = [];
@@ -459,13 +507,117 @@ export class Markdown {
459
507
  }
460
508
  // Combine top padding, content, and bottom padding
461
509
  const result = emptyLines.concat(contentLines, emptyLines);
510
+ copyLineSources(contentLines, result, this.paddingY);
511
+ const mapped = getLineSources(result);
512
+ const document = mapped?.find((line) => line)?.document;
513
+ if (document) {
514
+ setLineSources(result, result.map((_, row) => mapped?.[row] ?? { document, ranges: emptyRanges }));
515
+ }
516
+ if (text !== this.text) {
517
+ const transformedDocument = { text: this.text, enclosures: [] };
518
+ setLineSources(result, result.map(() => ({ document: transformedDocument, ranges: emptyRanges, renderedFallback: true })));
519
+ }
520
+ else if (!this.collectSources) {
521
+ // Capture values, not this component. Old cached frames must retain their own source.
522
+ const sourceText = this.text;
523
+ const { paddingX, paddingY, theme, defaultTextStyle } = this;
524
+ const options = { ...this.options, transform: undefined };
525
+ const deferred = {
526
+ text: sourceText,
527
+ enclosures: [],
528
+ resolveLines: () => {
529
+ const markdown = new Markdown(sourceText, paddingX, paddingY, theme, defaultTextStyle, options);
530
+ markdown.collectSources = true;
531
+ const sourceLines = markdown.render(width);
532
+ if (sourceLines.length !== result.length || sourceLines.some((line, row) => line !== result[row])) {
533
+ return undefined;
534
+ }
535
+ return getLineSources(sourceLines);
536
+ },
537
+ };
538
+ setLineSources(result, result.map((_, sourceRow) => ({
539
+ document: deferred,
540
+ ranges: emptyRanges,
541
+ sourceRow,
542
+ })));
543
+ }
462
544
  flattenLines(result);
463
545
  // Update cache
464
546
  this.cachedText = this.text;
465
547
  this.cachedWidth = width;
466
- this.cachedLines = result;
467
- cacheSet(renderCache, renderKey, { source: normalizedText, lines: result }, RENDER_CACHE_MAX);
468
- return result.length > 0 ? result : [""];
548
+ this.cachedLines = this.ownSources(result);
549
+ cacheSet(selectedRenderCache, renderKey, { source: this.text, transformed: text, lines: result }, this.collectSources ? SOURCE_CACHE_MAX : RENDER_CACHE_MAX);
550
+ return this.cachedLines.length > 0 ? this.cachedLines : [""];
551
+ }
552
+ /** Shared render caches must not make two separate messages one source interval. */
553
+ ownSources(lines) {
554
+ const mapping = getLineSources(lines);
555
+ if (!mapping)
556
+ return lines;
557
+ const original = mapping.find((line) => line)?.document;
558
+ if (!original)
559
+ return lines;
560
+ const document = { ...original };
561
+ const result = [...lines];
562
+ setLineSources(result, mapping.map((line) => line && { ...line, document }));
563
+ return result;
564
+ }
565
+ wrapSource(text, width, source) {
566
+ const mapping = [];
567
+ const lines = wrapTextWithAnsi(text, width, source
568
+ ? (start, end) => {
569
+ mapping.push({ ...source, ranges: sliceRanges(source.ranges, start, end) });
570
+ }
571
+ : undefined);
572
+ if (source === null)
573
+ setLineSources(lines, lines.map(() => null));
574
+ else if (source)
575
+ setLineSources(lines, mapping);
576
+ return lines;
577
+ }
578
+ enclose(token, ranges) {
579
+ if (![
580
+ "heading",
581
+ "strong",
582
+ "em",
583
+ "del",
584
+ "codespan",
585
+ "code",
586
+ "link",
587
+ "image",
588
+ "escape",
589
+ "latex_inline",
590
+ "latex_block",
591
+ "table",
592
+ "blockquote",
593
+ ].includes(token.type))
594
+ return;
595
+ if (enclosedTokens.has(token))
596
+ return;
597
+ const source = tokenSource(token)?.raw;
598
+ if (!source)
599
+ return;
600
+ const rawEnd = token.type === "latex_block" ? source.text.trimEnd().length : source.text.replace(/\n+$/, "").length;
601
+ const start = rangeAt(source.ranges, 0)?.start;
602
+ const end = rangeAt(source.ranges, rawEnd - 1)?.end;
603
+ if (start === undefined || end === undefined)
604
+ return;
605
+ let contentStart = Infinity;
606
+ let contentEnd = 0;
607
+ for (const { source: range } of ranges.spans) {
608
+ if (!range)
609
+ continue;
610
+ contentStart = Math.min(contentStart, range.start);
611
+ contentEnd = Math.max(contentEnd, range.end);
612
+ }
613
+ if (contentStart === Infinity)
614
+ return;
615
+ enclosedTokens.add(token);
616
+ source.document.enclosures.push({
617
+ start,
618
+ end,
619
+ content: { start: contentStart, end: contentEnd },
620
+ });
469
621
  }
470
622
  /**
471
623
  * Apply default text style to a string.
@@ -583,6 +735,8 @@ export class Markdown {
583
735
  }
584
736
  renderToken(token, width, nextTokenType, styleContext) {
585
737
  const lines = [];
738
+ const inlineRanges = this.collectSources ? [] : undefined;
739
+ const source = tokenSource(token);
586
740
  switch (token.type) {
587
741
  case "heading": {
588
742
  const headingLevel = token.depth;
@@ -601,8 +755,26 @@ export class Markdown {
601
755
  applyText: headingStyleFn,
602
756
  stylePrefix: this.getStylePrefix(headingStyleFn),
603
757
  };
604
- const headingText = this.renderInlineTokens(token.tokens || [], headingStyleContext);
758
+ const headingText = this.renderInlineTokens(token.tokens || [], headingStyleContext, inlineRanges);
605
759
  const styledHeading = headingLevel >= 3 ? headingStyleFn(headingPrefix) + headingText : headingText;
760
+ if (headingLevel >= 3 && inlineRanges) {
761
+ const prefix = /^( {0,3})(#{3,6})([ \t]*)/.exec(source?.raw.text ?? "");
762
+ if (source && prefix) {
763
+ const start = prefix[1].length;
764
+ const end = start + prefix[2].length;
765
+ const separatorStart = rangeAt(source.raw.ranges, end);
766
+ const separatorEnd = rangeAt(source.raw.ranges, end + prefix[3].length - 1);
767
+ inlineRanges.unshift(concatRanges([
768
+ sliceRanges(source.raw.ranges, start, end),
769
+ repeatedRange(prefix[3].length && separatorStart && separatorEnd
770
+ ? { start: separatorStart.start, end: separatorEnd.end }
771
+ : undefined, 1),
772
+ ]));
773
+ }
774
+ else {
775
+ inlineRanges.unshift(repeatedRange(undefined, headingPrefix.length));
776
+ }
777
+ }
606
778
  lines.push(styledHeading);
607
779
  if (nextTokenType && nextTokenType !== "space") {
608
780
  lines.push(""); // Add spacing after headings (unless space token follows)
@@ -610,7 +782,7 @@ export class Markdown {
610
782
  break;
611
783
  }
612
784
  case "paragraph": {
613
- const paragraphText = this.renderInlineTokens(token.tokens || [], styleContext);
785
+ const paragraphText = this.renderInlineTokens(token.tokens || [], styleContext, inlineRanges);
614
786
  lines.push(paragraphText);
615
787
  // Don't add spacing if next token is space or list
616
788
  if (nextTokenType && nextTokenType !== "list" && nextTokenType !== "space") {
@@ -622,17 +794,64 @@ export class Markdown {
622
794
  if (isLatexToken(token)) {
623
795
  const formula = this.options.renderLatex === false ? token.raw.trim() : latexToUnicode(token.text);
624
796
  lines.push(styleContext ? styleContext.applyText(formula) : this.applyDefaultStyle(formula));
797
+ if (inlineRanges)
798
+ inlineRanges.push(formulaRanges(token, stripTerminalSequences(lines[0])));
625
799
  if (nextTokenType && nextTokenType !== "space") {
626
800
  lines.push("");
627
801
  }
628
802
  }
629
803
  break;
630
804
  case "text":
631
- lines.push(this.renderInlineTokens([token], styleContext));
805
+ lines.push(this.renderInlineTokens([token], styleContext, inlineRanges));
632
806
  break;
633
807
  case "code": {
634
808
  const indent = this.theme.codeBlockIndent ?? " ";
635
809
  this.renderCodeBlock(lines, token.text, token.lang, indent);
810
+ if (source?.text) {
811
+ const ranges = [];
812
+ let offset = 0;
813
+ const openingLength = /^[^\n]*/.exec(source.raw.text)?.[0].length ?? 0;
814
+ const openingStart = rangeAt(source.raw.ranges, 0);
815
+ const openingEnd = rangeAt(source.raw.ranges, openingLength - 1);
816
+ const open = token.codeBlockStyle !== "indented" && openingStart && openingEnd
817
+ ? { start: openingStart.start, end: openingEnd.end }
818
+ : undefined;
819
+ ranges.push({
820
+ document: source.raw.document,
821
+ ranges: repeatedRange(open, stripTerminalSequences(lines[0]).length),
822
+ });
823
+ const codeLines = token.text.split("\n");
824
+ for (let row = 1; row < lines.length - 1; row++) {
825
+ const codeLine = codeLines[row - 1] ?? "";
826
+ const rendered = stripTerminalSequences(lines[row]).slice(indent.length);
827
+ ranges.push({
828
+ document: source.raw.document,
829
+ ranges: concatRanges([
830
+ repeatedRange(undefined, indent.length),
831
+ rendered === codeLine
832
+ ? sliceRanges(source.text.ranges, offset, offset + codeLine.length)
833
+ : repeatedRange(null, rendered.length),
834
+ ]),
835
+ });
836
+ offset += codeLine.length + 1;
837
+ }
838
+ const marker = /^ {0,3}(`{3,}|~{3,})/.exec(source.raw.text)?.[1];
839
+ const closing = marker
840
+ ? new RegExp(`\\n( {0,3}${marker[0]}{${marker.length},}[ \\t]*)\\n?$`).exec(source.raw.text)
841
+ : null;
842
+ const closeRanges = closing
843
+ ? sliceRanges(source.raw.ranges, closing.index + (closing[0].startsWith("\n") ? 1 : 0), source.raw.text.trimEnd().length)
844
+ : emptyRanges;
845
+ const closingStart = rangeAt(closeRanges, 0);
846
+ const closingEnd = rangeAt(closeRanges, closeRanges.length - 1);
847
+ const close = closingStart && closingEnd ? { start: closingStart.start, end: closingEnd.end } : undefined;
848
+ ranges.push({
849
+ document: source.raw.document,
850
+ ranges: repeatedRange(close, stripTerminalSequences(lines[lines.length - 1]).length),
851
+ });
852
+ setLineSources(lines, ranges);
853
+ this.enclose(token, concatRanges(ranges.map((line) => line.ranges)));
854
+ }
636
855
  if (nextTokenType && nextTokenType !== "space") {
637
856
  lines.push(""); // Add spacing after code blocks (unless space token follows)
638
857
  }
@@ -641,6 +860,7 @@ export class Markdown {
641
860
  case "list": {
642
861
  const listLines = this.renderList(token, 0, width, styleContext);
643
862
  lines.push(...listLines);
863
+ copyLineSources(listLines, lines);
644
864
  // Don't add spacing after lists if a space token follows
645
865
  // (the space token will handle it)
646
866
  break;
@@ -648,6 +868,7 @@ export class Markdown {
648
868
  case "table": {
649
869
  const tableLines = this.renderTable(token, width, nextTokenType, styleContext);
650
870
  lines.push(...tableLines);
871
+ copyLineSources(tableLines, lines);
651
872
  break;
652
873
  }
653
874
  case "blockquote": {
@@ -674,18 +895,24 @@ export class Markdown {
674
895
  for (let i = 0; i < quoteTokens.length; i++) {
675
896
  const quoteToken = quoteTokens[i];
676
897
  const nextQuoteToken = quoteTokens[i + 1];
677
- renderedQuoteLines.push(...this.renderToken(quoteToken, quoteContentWidth, nextQuoteToken?.type, quoteInlineStyleContext));
898
+ const childLines = this.renderToken(quoteToken, quoteContentWidth, nextQuoteToken?.type, quoteInlineStyleContext);
899
+ const offset = renderedQuoteLines.length;
900
+ renderedQuoteLines.push(...childLines);
901
+ copyLineSources(childLines, renderedQuoteLines, offset);
678
902
  }
679
903
  // Avoid rendering an extra empty quote line before the outer blockquote spacing.
680
904
  while (renderedQuoteLines.length > 0 && renderedQuoteLines[renderedQuoteLines.length - 1] === "") {
681
905
  renderedQuoteLines.pop();
682
906
  }
683
- for (const quoteLine of renderedQuoteLines) {
907
+ for (let row = 0; row < renderedQuoteLines.length; row++) {
908
+ const quoteLine = renderedQuoteLines[row];
684
909
  const styledLine = applyQuoteStyle(quoteLine);
685
- const wrappedLines = wrapTextWithAnsi(styledLine, quoteContentWidth);
910
+ const wrappedLines = this.wrapSource(styledLine, quoteContentWidth, getLineSources(renderedQuoteLines)?.[row]);
911
+ const offset = lines.length;
686
912
  for (const wrappedLine of wrappedLines) {
687
913
  lines.push(this.theme.quoteBorder("│ ") + wrappedLine);
688
914
  }
915
+ copyLineSources(wrappedLines, lines, offset, 2);
689
916
  }
690
917
  if (nextTokenType && nextTokenType !== "space") {
691
918
  lines.push(""); // Add spacing after blockquotes (unless space token follows)
@@ -694,6 +921,12 @@ export class Markdown {
694
921
  }
695
922
  case "hr":
696
923
  lines.push(this.theme.hr("─".repeat(Math.min(width, 80))));
924
+ if (inlineRanges && source) {
925
+ // Every rule glyph selects the authored rule, excluding block separators.
926
+ const first = rangeAt(source.raw.ranges, 0);
927
+ const last = rangeAt(source.raw.ranges, source.raw.text.trimEnd().length - 1);
928
+ inlineRanges.push(repeatedRange(first && last ? { start: first.start, end: last.end } : null, stripTerminalSequences(lines[0]).length));
929
+ }
697
930
  if (nextTokenType && nextTokenType !== "space") {
698
931
  lines.push(""); // Add spacing after horizontal rules (unless space token follows)
699
932
  }
@@ -714,20 +947,38 @@ export class Markdown {
714
947
  lines.push(token.text);
715
948
  }
716
949
  }
950
+ if (source && !getLineSources(lines)) {
951
+ const ranges = inlineRanges?.length
952
+ ? concatRanges(inlineRanges)
953
+ : source.text?.text === stripTerminalSequences(lines[0] ?? "")
954
+ ? source.text.ranges
955
+ : source.raw.text.trim() === stripTerminalSequences(lines[0] ?? "")
956
+ ? sliceRanges(source.raw.ranges, source.raw.text.length - source.raw.text.trimStart().length, source.raw.text.trimEnd().length)
957
+ : repeatedRange(null, stripTerminalSequences(lines[0] ?? "").length);
958
+ setLineSources(lines, lines.map((_, row) => ({ document: source.raw.document, ranges: row === 0 ? ranges : emptyRanges })));
959
+ this.enclose(token, ranges);
960
+ }
961
+ if (!source && this.activeSourceDocument) {
962
+ const document = this.activeSourceDocument;
963
+ setLineSources(lines, lines.map(() => ({ document, ranges: emptyRanges, unavailable: true })));
964
+ }
965
+ else if (token.type === "blockquote") {
966
+ this.enclose(token, concatRanges((getLineSources(lines) ?? []).map((line) => line?.ranges ?? emptyRanges)));
967
+ }
717
968
  return lines;
718
969
  }
719
970
  /**
720
971
  * Render inline `strong`/`em`/`del` through a theme hook. The hook is applied inside the surrounding style context,
721
972
  * so a color it sets wins over the heading or default text color instead of being overridden by it.
722
973
  */
723
- renderEmphasisHook(tokens, styleContext, styleFn) {
974
+ renderEmphasisHook(tokens, styleContext, styleFn, ranges) {
724
975
  const innerStyleContext = {
725
976
  applyText: (text) => styleContext.applyText(styleFn(text)),
726
977
  stylePrefix: styleContext.stylePrefix + this.getStylePrefix(styleFn),
727
978
  };
728
- return styleFn(this.renderInlineTokens(tokens, innerStyleContext)) + styleContext.stylePrefix;
979
+ return styleFn(this.renderInlineTokens(tokens, innerStyleContext, ranges)) + styleContext.stylePrefix;
729
980
  }
730
- renderInlineTokens(tokens, styleContext) {
981
+ renderInlineTokens(tokens, styleContext, ranges) {
731
982
  let result = "";
732
983
  const resolvedStyleContext = styleContext ?? this.getDefaultInlineStyleContext();
733
984
  const { applyText, stylePrefix } = resolvedStyleContext;
@@ -736,6 +987,8 @@ export class Markdown {
736
987
  return segments.map((segment) => applyText(segment)).join("\n");
737
988
  };
738
989
  for (const token of tokens) {
990
+ const start = result.length;
991
+ const childRanges = ranges ? [] : undefined;
739
992
  switch (token.type) {
740
993
  case "escape":
741
994
  result += applyTextWithNewlines(this.options.preserveBackslashEscapes ? token.raw : token.text);
@@ -743,7 +996,7 @@ export class Markdown {
743
996
  case "text":
744
997
  // Text tokens in list items can have nested tokens for inline formatting
745
998
  if (token.tokens && token.tokens.length > 0) {
746
- result += this.renderInlineTokens(token.tokens, resolvedStyleContext);
999
+ result += this.renderInlineTokens(token.tokens, resolvedStyleContext, childRanges);
747
1000
  }
748
1001
  else {
749
1002
  result += applyTextWithNewlines(token.text);
@@ -751,23 +1004,23 @@ export class Markdown {
751
1004
  break;
752
1005
  case "paragraph":
753
1006
  // Paragraph tokens contain nested inline tokens
754
- result += this.renderInlineTokens(token.tokens || [], resolvedStyleContext);
1007
+ result += this.renderInlineTokens(token.tokens || [], resolvedStyleContext, childRanges);
755
1008
  break;
756
1009
  case "strong": {
757
1010
  if (this.theme.strong) {
758
- result += this.renderEmphasisHook(token.tokens || [], resolvedStyleContext, this.theme.strong);
1011
+ result += this.renderEmphasisHook(token.tokens || [], resolvedStyleContext, this.theme.strong, childRanges);
759
1012
  break;
760
1013
  }
761
- const boldContent = this.renderInlineTokens(token.tokens || [], resolvedStyleContext);
1014
+ const boldContent = this.renderInlineTokens(token.tokens || [], resolvedStyleContext, childRanges);
762
1015
  result += this.theme.bold(boldContent) + stylePrefix;
763
1016
  break;
764
1017
  }
765
1018
  case "em": {
766
1019
  if (this.theme.emphasis) {
767
- result += this.renderEmphasisHook(token.tokens || [], resolvedStyleContext, this.theme.emphasis);
1020
+ result += this.renderEmphasisHook(token.tokens || [], resolvedStyleContext, this.theme.emphasis, childRanges);
768
1021
  break;
769
1022
  }
770
- const italicContent = this.renderInlineTokens(token.tokens || [], resolvedStyleContext);
1023
+ const italicContent = this.renderInlineTokens(token.tokens || [], resolvedStyleContext, childRanges);
771
1024
  result += this.theme.italic(italicContent) + stylePrefix;
772
1025
  break;
773
1026
  }
@@ -785,7 +1038,7 @@ export class Markdown {
785
1038
  }
786
1039
  break;
787
1040
  case "link": {
788
- const linkText = this.renderInlineTokens(token.tokens || [], resolvedStyleContext);
1041
+ const linkText = this.renderInlineTokens(token.tokens || [], resolvedStyleContext, token.autolink ? undefined : childRanges);
789
1042
  const styledLink = this.theme.link(this.theme.underline(linkText));
790
1043
  if (getCapabilities().hyperlinks) {
791
1044
  // OSC 8: render as a clickable hyperlink. The URL is not printed inline,
@@ -812,10 +1065,10 @@ export class Markdown {
812
1065
  break;
813
1066
  case "del": {
814
1067
  if (this.theme.deleted) {
815
- result += this.renderEmphasisHook(token.tokens || [], resolvedStyleContext, this.theme.deleted);
1068
+ result += this.renderEmphasisHook(token.tokens || [], resolvedStyleContext, this.theme.deleted, childRanges);
816
1069
  break;
817
1070
  }
818
- const delContent = this.renderInlineTokens(token.tokens || [], resolvedStyleContext);
1071
+ const delContent = this.renderInlineTokens(token.tokens || [], resolvedStyleContext, childRanges);
819
1072
  result += this.theme.strikethrough(delContent) + stylePrefix;
820
1073
  break;
821
1074
  }
@@ -831,6 +1084,51 @@ export class Markdown {
831
1084
  result += applyTextWithNewlines(token.text);
832
1085
  }
833
1086
  }
1087
+ if (!ranges)
1088
+ continue;
1089
+ const rendered = stripTerminalSequences(result.slice(start));
1090
+ const source = tokenSource(token);
1091
+ let mapped = childRanges?.length
1092
+ ? concatRanges(childRanges)
1093
+ : source?.text?.text === rendered
1094
+ ? source.text.ranges
1095
+ : source?.raw.text === rendered
1096
+ ? source.raw.ranges
1097
+ : repeatedRange(null, rendered.length);
1098
+ if (token.type === "latex_inline" && isLatexToken(token)) {
1099
+ mapped = formulaRanges(token, rendered);
1100
+ }
1101
+ else if (token.type === "link" && token.autolink && source?.text && rendered.startsWith(source.text.text)) {
1102
+ // Marked's synthetic label has no child lexer location. A visible
1103
+ // suffix such as " (http://www.example.com)" is presentation only.
1104
+ mapped = concatRanges([
1105
+ source.text.ranges,
1106
+ repeatedRange(undefined, rendered.length - source.text.ranges.length),
1107
+ ]);
1108
+ }
1109
+ else if (token.type === "link" && mapped.length < rendered.length && source?.href) {
1110
+ mapped = concatRanges([
1111
+ mapped,
1112
+ repeatedRange(undefined, 2),
1113
+ source.href.ranges,
1114
+ repeatedRange(undefined, 1),
1115
+ ]);
1116
+ }
1117
+ else if (token.type === "link" && mapped.length < rendered.length && source) {
1118
+ // A reference destination lives elsewhere; its visible suffix selects
1119
+ // only this link's source, never the remote definition or intervening prose.
1120
+ const first = rangeAt(source.raw.ranges, 0);
1121
+ const last = rangeAt(source.raw.ranges, source.raw.ranges.length - 1);
1122
+ mapped = concatRanges([
1123
+ mapped,
1124
+ repeatedRange(first && last ? { start: first.start, end: last.end } : null, rendered.length - mapped.length),
1125
+ ]);
1126
+ }
1127
+ if (mapped.length < rendered.length) {
1128
+ mapped = concatRanges([mapped, repeatedRange(null, rendered.length - mapped.length)]);
1129
+ }
1130
+ ranges.push(mapped);
1131
+ this.enclose(token, token.type === "link" ? (source?.text?.ranges ?? mapped) : mapped);
834
1132
  }
835
1133
  while (stylePrefix && result.endsWith(stylePrefix)) {
836
1134
  result = result.slice(0, -stylePrefix.length);
@@ -871,26 +1169,68 @@ export class Markdown {
871
1169
  let renderedAnyLine = false;
872
1170
  for (const itemToken of item.tokens) {
873
1171
  if (itemToken.type === "list") {
874
- lines.push(...this.renderList(itemToken, depth + 1, width, styleContext));
1172
+ const childLines = this.renderList(itemToken, depth + 1, width, styleContext);
1173
+ const offset = lines.length;
1174
+ lines.push(...childLines);
1175
+ copyLineSources(childLines, lines, offset);
875
1176
  renderedAnyLine = true;
876
1177
  continue;
877
1178
  }
878
1179
  const itemLines = this.renderToken(itemToken, itemWidth, undefined, styleContext);
879
- for (const line of itemLines) {
880
- for (const wrappedLine of wrapTextWithAnsi(line, itemWidth)) {
1180
+ for (let row = 0; row < itemLines.length; row++) {
1181
+ const line = itemLines[row];
1182
+ const fragments = this.wrapSource(line, itemWidth, getLineSources(itemLines)?.[row]);
1183
+ for (let part = 0; part < fragments.length; part++) {
1184
+ const wrappedLine = fragments[part];
881
1185
  const linePrefix = renderedAnyLine ? continuationPrefix : firstPrefix;
882
1186
  lines.push(linePrefix + wrappedLine);
1187
+ const source = getLineSources(fragments)?.[part];
1188
+ if (source) {
1189
+ let markerRange;
1190
+ if (!renderedAnyLine) {
1191
+ const raw = tokenSource(item)?.raw;
1192
+ const prefix = /^(?: {0,3})(?:[-+*]|\d{1,9}[.)])(?:[ \t]+|$)(?:\[[ xX]\][ \t]+)?/.exec(raw?.text ?? "");
1193
+ if (raw && prefix?.[0].length) {
1194
+ const start = rangeAt(raw.ranges, 0)?.start;
1195
+ const end = rangeAt(raw.ranges, prefix[0].length - 1)?.end;
1196
+ if (start !== undefined && end !== undefined)
1197
+ markerRange = { start, end };
1198
+ }
1199
+ }
1200
+ setLineSource(lines, lines.length - 1, {
1201
+ ...source,
1202
+ ranges: concatRanges([
1203
+ repeatedRange(undefined, indent.length),
1204
+ repeatedRange(markerRange, visibleWidth(linePrefix) - indent.length),
1205
+ source.ranges,
1206
+ ]),
1207
+ });
1208
+ }
883
1209
  renderedAnyLine = true;
884
1210
  }
885
1211
  }
886
1212
  }
887
1213
  if (!renderedAnyLine) {
888
1214
  lines.push(firstPrefix);
1215
+ const raw = tokenSource(item)?.raw;
1216
+ const start = raw && rangeAt(raw.ranges, 0)?.start;
1217
+ const end = raw && rangeAt(raw.ranges, raw.ranges.length - 1)?.end;
1218
+ if (raw && start !== undefined && end !== undefined) {
1219
+ setLineSource(lines, lines.length - 1, {
1220
+ document: raw.document,
1221
+ ranges: repeatedRange({ start, end }, visibleWidth(firstPrefix)),
1222
+ });
1223
+ }
889
1224
  }
890
1225
  if (token.loose && !isLastItem) {
891
1226
  lines.push("");
892
1227
  }
893
1228
  }
1229
+ const document = tokenSource(token)?.raw.document;
1230
+ if (document) {
1231
+ const mapping = getLineSources(lines);
1232
+ setLineSources(lines, lines.map((_, row) => mapping?.[row] ?? { document, ranges: emptyRanges }));
1233
+ }
894
1234
  return lines;
895
1235
  }
896
1236
  /**
@@ -913,13 +1253,13 @@ export class Markdown {
913
1253
  * Delegates to wrapTextWithAnsi() so ANSI codes + long tokens are handled
914
1254
  * consistently with the rest of the renderer.
915
1255
  */
916
- wrapCellText(text, maxWidth, stylePrefix = "") {
917
- const lines = wrapTextWithAnsi(text, Math.max(1, maxWidth));
918
- return lines.map((line, index) => {
1256
+ wrapCellText(text, maxWidth, stylePrefix = "", source) {
1257
+ const lines = this.wrapSource(text, Math.max(1, maxWidth), source);
1258
+ return copyLineSources(lines, lines.map((line, index) => {
919
1259
  // Reset text styles after each non-final fragment, then restore the surrounding style before padding and borders.
920
1260
  const styleReset = index < lines.length - 1 ? "\x1b[22;23;24;25;27;28;29;39m" : "";
921
1261
  return `${line}${styleReset}${stylePrefix}`;
922
- });
1262
+ }));
923
1263
  }
924
1264
  /**
925
1265
  * Render a table with width-aware cell wrapping.
@@ -927,6 +1267,7 @@ export class Markdown {
927
1267
  */
928
1268
  renderTable(token, availableWidth, nextTokenType, styleContext) {
929
1269
  const lines = [];
1270
+ const source = tokenSource(token)?.raw;
930
1271
  const numCols = token.header.length;
931
1272
  if (numCols === 0) {
932
1273
  return lines;
@@ -937,7 +1278,9 @@ export class Markdown {
937
1278
  const availableForCells = availableWidth - borderOverhead;
938
1279
  if (availableForCells < numCols) {
939
1280
  // Too narrow to render a stable table. Fall back to raw markdown.
940
- const fallbackLines = token.raw ? wrapTextWithAnsi(token.raw, availableWidth) : [];
1281
+ const fallbackLines = token.raw
1282
+ ? this.wrapSource(token.raw, availableWidth, source && { document: source.document, ranges: sliceRanges(source.ranges, 0, token.raw.length) })
1283
+ : [];
941
1284
  if (nextTokenType && nextTokenType !== "space") {
942
1285
  fallbackLines.push("");
943
1286
  }
@@ -1026,8 +1369,9 @@ export class Markdown {
1026
1369
  lines.push(`┌─${topBorderCells.join("─┬─")}─┐`);
1027
1370
  // Render header with wrapping
1028
1371
  const headerCellLines = token.header.map((cell, i) => {
1029
- const text = this.renderInlineTokens(cell.tokens || [], styleContext);
1030
- return this.wrapCellText(text, columnWidths[i], styleContext?.stylePrefix);
1372
+ const ranges = this.collectSources ? [] : undefined;
1373
+ const text = this.renderInlineTokens(cell.tokens || [], styleContext, ranges);
1374
+ return this.wrapCellText(text, columnWidths[i], styleContext?.stylePrefix, source && { document: source.document, ranges: concatRanges(ranges ?? []) });
1031
1375
  });
1032
1376
  const headerLineCount = Math.max(...headerCellLines.map((c) => c.length));
1033
1377
  for (let lineIdx = 0; lineIdx < headerLineCount; lineIdx++) {
@@ -1037,6 +1381,8 @@ export class Markdown {
1037
1381
  return this.theme.bold(padded);
1038
1382
  });
1039
1383
  lines.push(`│ ${rowParts.join(" │ ")} │`);
1384
+ if (source)
1385
+ this.mapTableRow(lines, headerCellLines, lineIdx, columnWidths, source.document);
1040
1386
  }
1041
1387
  // Render separator
1042
1388
  const separatorCells = columnWidths.map((w) => "─".repeat(w));
@@ -1046,8 +1392,9 @@ export class Markdown {
1046
1392
  for (let rowIndex = 0; rowIndex < token.rows.length; rowIndex++) {
1047
1393
  const row = token.rows[rowIndex];
1048
1394
  const rowCellLines = row.map((cell, i) => {
1049
- const text = this.renderInlineTokens(cell.tokens || [], styleContext);
1050
- return this.wrapCellText(text, columnWidths[i], styleContext?.stylePrefix);
1395
+ const ranges = this.collectSources ? [] : undefined;
1396
+ const text = this.renderInlineTokens(cell.tokens || [], styleContext, ranges);
1397
+ return this.wrapCellText(text, columnWidths[i], styleContext?.stylePrefix, source && { document: source.document, ranges: concatRanges(ranges ?? []) });
1051
1398
  });
1052
1399
  const rowLineCount = Math.max(...rowCellLines.map((c) => c.length));
1053
1400
  for (let lineIdx = 0; lineIdx < rowLineCount; lineIdx++) {
@@ -1056,6 +1403,8 @@ export class Markdown {
1056
1403
  return text + " ".repeat(Math.max(0, columnWidths[colIdx] - visibleWidth(text)));
1057
1404
  });
1058
1405
  lines.push(`│ ${rowParts.join(" │ ")} │`);
1406
+ if (source)
1407
+ this.mapTableRow(lines, rowCellLines, lineIdx, columnWidths, source.document);
1059
1408
  }
1060
1409
  if (rowIndex < token.rows.length - 1) {
1061
1410
  lines.push(separatorLine);
@@ -1067,7 +1416,22 @@ export class Markdown {
1067
1416
  if (nextTokenType && nextTokenType !== "space") {
1068
1417
  lines.push(""); // Add spacing after table
1069
1418
  }
1419
+ if (source) {
1420
+ const mapping = getLineSources(lines);
1421
+ setLineSources(lines, lines.map((_, row) => mapping?.[row] ?? { document: source.document, ranges: emptyRanges }));
1422
+ this.enclose(token, concatRanges((getLineSources(lines) ?? []).map((line) => line?.ranges ?? emptyRanges)));
1423
+ }
1070
1424
  return lines;
1071
1425
  }
1426
+ mapTableRow(lines, cells, row, widths, document) {
1427
+ const ranges = [repeatedRange(undefined, 2)];
1428
+ for (let col = 0; col < cells.length; col++) {
1429
+ const text = cells[col][row] ?? "";
1430
+ const cell = getLineSources(cells[col])?.[row];
1431
+ ranges.push(cell?.ranges ?? emptyRanges);
1432
+ ranges.push(repeatedRange(undefined, Math.max(0, widths[col] - visibleWidth(text)) + 3));
1433
+ }
1434
+ setLineSource(lines, lines.length - 1, { document, ranges: concatRanges(ranges) });
1435
+ }
1072
1436
  }
1073
1437
  //# sourceMappingURL=markdown.js.map