@vectojs/markdown 0.1.1 → 0.2.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.
@@ -1,4 +1,4 @@
1
- import { Entity, IRenderer, type ContentProjection } from '@vectojs/core';
1
+ import { Entity, type DevtoolsDescriptor, GlyphRasterAtlas, type GlyphRasterAtlasStats, IRenderer, type ContentProjection } from '@vectojs/core';
2
2
  import { type Token } from 'marked';
3
3
  import { Stack, UIComponent } from '@vectojs/ui';
4
4
  /** Color and typography theme for Markdown rendering. */
@@ -37,6 +37,8 @@ export interface MarkdownTheme {
37
37
  export declare class CodeBlock extends UIComponent {
38
38
  private lines;
39
39
  private grid;
40
+ /** Raw (unhighlighted) lines of the last build, for prefix reuse in buildLines. */
41
+ private rawLines;
40
42
  private cellWidth;
41
43
  private source;
42
44
  private lang;
@@ -51,12 +53,43 @@ export declare class CodeBlock extends UIComponent {
51
53
  /** Enable or disable browser-native selection for this code block. */
52
54
  setSelectable(selectable: boolean): this;
53
55
  getContentProjection(): ContentProjection | null;
56
+ /**
57
+ * Re-highlight the code, reusing the highlight of any unchanged line prefix.
58
+ *
59
+ * Streaming appends to the END of a block, so all but the last line or two are
60
+ * byte-identical to the previous call — yet this used to re-highlight every
61
+ * line on every chunk, making a streamed block O(N) per append and O(N^2)
62
+ * overall. Reusing the stable prefix makes an append proportional to what
63
+ * actually changed.
64
+ *
65
+ * The last previously-seen line is deliberately NOT reused: a chunk usually
66
+ * lands mid-line, so that line's text (and therefore its tokenization) changes.
67
+ */
54
68
  private buildLines;
55
69
  private ensureGrid;
56
70
  /** Code blocks are decorative — not interactive. */
57
71
  isPointInside(): boolean;
58
72
  render(r: IRenderer): void;
59
73
  }
74
+ /**
75
+ * Instrumentation for the shared code-block glyph atlas, or `null` before first
76
+ * use.
77
+ *
78
+ * Exposed so an app or benchmark can confirm the atlas is actually active and
79
+ * reusing slots. Watch `resets`: a steadily climbing count means the glyph set is
80
+ * unbounded for the atlas size, so every reset re-rasterizes everything and the
81
+ * atlas is doing net harm rather than saving work.
82
+ */
83
+ export declare function codeAtlasStats(): GlyphRasterAtlasStats | null;
84
+ /**
85
+ * The shared code-block atlas itself, or `null` before first use.
86
+ *
87
+ * For instrumentation that must map a traced `drawImage` back to the glyph it
88
+ * painted — a blit carries only a source rect, so `slotAt()` is the only way to
89
+ * recover the cluster and its metrics. Used by `e2e/text-projection.e2e.ts` to
90
+ * keep the code-grid positioning assertions working on the blit path.
91
+ */
92
+ export declare function codeAtlas(): GlyphRasterAtlas | null;
60
93
  export interface MarkdownOptions {
61
94
  maxWidth?: number;
62
95
  theme?: MarkdownTheme;
@@ -91,6 +124,16 @@ export declare class Markdown extends UIComponent {
91
124
  private tokens;
92
125
  private appendInFlight;
93
126
  private appendPending;
127
+ /**
128
+ * Streaming counters for the DevTools inspector.
129
+ *
130
+ * Cheap enough to keep always-on (four integer increments per append) and the
131
+ * only way to see, from outside, whether incremental reuse is actually working:
132
+ * a stable-prefix ratio near 1 means the worker is matching almost everything
133
+ * and only the tail is re-lexed, while a ratio near 0 means every chunk is
134
+ * re-parsing the whole document.
135
+ */
136
+ private streamStats;
94
137
  private pendingWorkerIds;
95
138
  private readonly workerInstanceId;
96
139
  private tokenVersion;
@@ -116,6 +159,16 @@ export declare class Markdown extends UIComponent {
116
159
  * content subtree via `super.destroy()` so every block's resources are freed.
117
160
  */
118
161
  destroy(): void;
162
+ /**
163
+ * Streaming and parse state — the markdown streaming inspector.
164
+ *
165
+ * Source length, chunk count, worker in-flight state, and the stable-prefix
166
+ * versus re-lexed-tail split. That last ratio is the one worth watching: it is
167
+ * how you tell incremental reuse is working from outside, and nothing else
168
+ * surfaces it. A ratio near 1 means the worker matched almost the whole prefix
169
+ * and only re-lexed the tail; near 0 means every chunk re-parses the document.
170
+ */
171
+ getDevtoolsDescriptor(): DevtoolsDescriptor;
119
172
  /** Enable or disable native selection for existing and future Markdown text. */
120
173
  setSelectable(selectable: boolean): this;
121
174
  /** Append a markdown chunk incrementally. Reuses unchanged prefix entities. */
package/dist/index.js CHANGED
@@ -21,7 +21,9 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
23
  CodeBlock: () => CodeBlock,
24
- Markdown: () => Markdown
24
+ Markdown: () => Markdown,
25
+ codeAtlas: () => codeAtlas,
26
+ codeAtlasStats: () => codeAtlasStats
25
27
  });
26
28
  module.exports = __toCommonJS(index_exports);
27
29
 
@@ -438,6 +440,8 @@ function highlightLine(line, lang, theme) {
438
440
  var CodeBlock = class extends import_ui.UIComponent {
439
441
  lines;
440
442
  grid = null;
443
+ /** Raw (unhighlighted) lines of the last build, for prefix reuse in buildLines. */
444
+ rawLines = null;
441
445
  cellWidth = 0;
442
446
  source;
443
447
  lang;
@@ -497,9 +501,36 @@ var CodeBlock = class extends import_ui.UIComponent {
497
501
  grid
498
502
  };
499
503
  }
504
+ /**
505
+ * Re-highlight the code, reusing the highlight of any unchanged line prefix.
506
+ *
507
+ * Streaming appends to the END of a block, so all but the last line or two are
508
+ * byte-identical to the previous call — yet this used to re-highlight every
509
+ * line on every chunk, making a streamed block O(N) per append and O(N^2)
510
+ * overall. Reusing the stable prefix makes an append proportional to what
511
+ * actually changed.
512
+ *
513
+ * The last previously-seen line is deliberately NOT reused: a chunk usually
514
+ * lands mid-line, so that line's text (and therefore its tokenization) changes.
515
+ */
500
516
  buildLines(code) {
501
517
  const rawLines = code.split(/\r\n|\r|\n/);
502
- this.lines = rawLines.map((l) => highlightLine(l, this.lang, this.theme));
518
+ const previous = this.rawLines;
519
+ let reusable = 0;
520
+ if (previous && this.lines.length === previous.length) {
521
+ const limit = Math.min(previous.length - 1, rawLines.length);
522
+ while (reusable < limit && previous[reusable] === rawLines[reusable]) reusable++;
523
+ }
524
+ if (reusable > 0) {
525
+ const next = this.lines.slice(0, reusable);
526
+ for (let i = reusable; i < rawLines.length; i++) {
527
+ next.push(highlightLine(rawLines[i], this.lang, this.theme));
528
+ }
529
+ this.lines = next;
530
+ } else {
531
+ this.lines = rawLines.map((l) => highlightLine(l, this.lang, this.theme));
532
+ }
533
+ this.rawLines = rawLines;
503
534
  this.grid = null;
504
535
  this.height = this.pad * 2 + rawLines.length * this.lineH;
505
536
  }
@@ -524,6 +555,9 @@ var CodeBlock = class extends import_ui.UIComponent {
524
555
  r.roundRect(0, 0, this.width, this.height, 8);
525
556
  r.fill(this.theme.codeBgColor);
526
557
  const grid = this.ensureGrid();
558
+ const atlas = codeGlyphAtlas(r);
559
+ const atlasSource = atlas?.source ?? null;
560
+ const blit = atlas ? r.drawImageRect : void 0;
527
561
  for (let row = 0; row < grid.lines.length; row++) {
528
562
  const yBaseline = this.pad + row * this.lineH + this.lineH * 0.75;
529
563
  const segments = this.lines[row];
@@ -538,17 +572,51 @@ var CodeBlock = class extends import_ui.UIComponent {
538
572
  }
539
573
  const sourceText = this.source.slice(cell.sourceStart, cell.sourceEnd);
540
574
  if (cell.advance <= 0 || sourceText === " " || sourceText === " ") continue;
541
- r.fillText(
542
- cell.glyph,
543
- this.pad + cell.x,
544
- yBaseline,
545
- this.codeFont,
546
- segments[segmentIndex]?.color ?? this.theme.codeColor
547
- );
575
+ const color = segments[segmentIndex]?.color ?? this.theme.codeColor;
576
+ const x = this.pad + cell.x;
577
+ if (blit && atlas) {
578
+ const slot = atlas.get(this.codeFont, color, cell.glyph);
579
+ const src = atlasSource ?? atlas.source;
580
+ if (slot && src) {
581
+ blit.call(
582
+ r,
583
+ src,
584
+ slot.sx,
585
+ slot.sy,
586
+ slot.sw,
587
+ slot.sh,
588
+ x - slot.offsetX,
589
+ yBaseline - slot.offsetY,
590
+ slot.w,
591
+ slot.h
592
+ );
593
+ continue;
594
+ }
595
+ }
596
+ r.fillText(cell.glyph, x, yBaseline, this.codeFont, color);
548
597
  }
549
598
  }
550
599
  }
551
600
  };
601
+ var sharedCodeAtlas = null;
602
+ function codeGlyphAtlas(r) {
603
+ if (typeof r.drawImageRect !== "function") return void 0;
604
+ if (typeof document === "undefined") return void 0;
605
+ sharedCodeAtlas ??= new import_core.GlyphRasterAtlas({
606
+ // Match the display so a HiDPI blit stays crisp. Capped at 3 because atlas
607
+ // area grows with dpr² and a 4x display would otherwise blow the size cap
608
+ // with a few hundred glyphs.
609
+ dpr: typeof window !== "undefined" ? Math.min(window.devicePixelRatio || 1, 3) : 1,
610
+ maxSize: 2048
611
+ });
612
+ return sharedCodeAtlas;
613
+ }
614
+ function codeAtlasStats() {
615
+ return sharedCodeAtlas ? sharedCodeAtlas.stats : null;
616
+ }
617
+ function codeAtlas() {
618
+ return sharedCodeAtlas;
619
+ }
552
620
  function decodeEntities(text) {
553
621
  return text.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/g, "&");
554
622
  }
@@ -688,6 +756,23 @@ var Markdown = class extends import_ui.UIComponent {
688
756
  // with the latest accumulated text once it resolves.
689
757
  appendInFlight = false;
690
758
  appendPending = false;
759
+ /**
760
+ * Streaming counters for the DevTools inspector.
761
+ *
762
+ * Cheap enough to keep always-on (four integer increments per append) and the
763
+ * only way to see, from outside, whether incremental reuse is actually working:
764
+ * a stable-prefix ratio near 1 means the worker is matching almost everything
765
+ * and only the tail is re-lexed, while a ratio near 0 means every chunk is
766
+ * re-parsing the whole document.
767
+ */
768
+ streamStats = {
769
+ appends: 0,
770
+ workerResponses: 0,
771
+ /** Sum of `matchLen` across responses, i.e. tokens reused rather than re-lexed. */
772
+ tokensReused: 0,
773
+ /** Sum of returned tail lengths, i.e. tokens the worker had to re-lex. */
774
+ tokensRelexed: 0
775
+ };
691
776
  // Worker request ids dispatched by *this* instance that haven't resolved yet.
692
777
  // The module-level `workerCallbacks` map holds a closure capturing `this`, so
693
778
  // destroying a Markdown mid-stream would pin the whole entity (and its subtree)
@@ -780,6 +865,81 @@ var Markdown = class extends import_ui.UIComponent {
780
865
  });
781
866
  super.destroy();
782
867
  }
868
+ /**
869
+ * Streaming and parse state — the markdown streaming inspector.
870
+ *
871
+ * Source length, chunk count, worker in-flight state, and the stable-prefix
872
+ * versus re-lexed-tail split. That last ratio is the one worth watching: it is
873
+ * how you tell incremental reuse is working from outside, and nothing else
874
+ * surfaces it. A ratio near 1 means the worker matched almost the whole prefix
875
+ * and only re-lexed the tail; near 0 means every chunk re-parses the document.
876
+ */
877
+ getDevtoolsDescriptor() {
878
+ const s = this.streamStats;
879
+ const lexed = s.tokensReused + s.tokensRelexed;
880
+ const reuseRatio = lexed > 0 ? s.tokensReused / lexed : 0;
881
+ return {
882
+ kind: "Markdown",
883
+ groups: [
884
+ {
885
+ label: "Source",
886
+ fields: [
887
+ { label: "sourceLength", value: this.rawMarkdown.length, readOnly: true },
888
+ { label: "topLevelTokens", value: this.tokens.length, readOnly: true },
889
+ { label: "childEntities", value: this.content.children.length, readOnly: true },
890
+ { label: "selectable", value: this.selectable }
891
+ ]
892
+ },
893
+ {
894
+ label: "Streaming",
895
+ fields: [
896
+ { label: "appends", value: s.appends, readOnly: true },
897
+ {
898
+ label: "workerResponses",
899
+ value: s.workerResponses,
900
+ hint: "Fewer than appends means chunks were coalesced while a request was in flight",
901
+ readOnly: true
902
+ },
903
+ {
904
+ label: "appendInFlight",
905
+ value: this.appendInFlight,
906
+ hint: "One lex request at a time; the delta protocol requires it",
907
+ readOnly: true
908
+ },
909
+ { label: "appendPending", value: this.appendPending, readOnly: true }
910
+ ]
911
+ },
912
+ {
913
+ label: "Incremental reuse",
914
+ fields: [
915
+ {
916
+ label: "tokensReused",
917
+ value: s.tokensReused,
918
+ hint: "Sum of matchLen: prefix tokens the worker matched and did not re-lex",
919
+ readOnly: true
920
+ },
921
+ {
922
+ label: "tokensRelexed",
923
+ value: s.tokensRelexed,
924
+ hint: "Sum of returned tail lengths: tokens the worker had to re-lex",
925
+ readOnly: true
926
+ },
927
+ {
928
+ label: "reuseRatio",
929
+ value: Math.round(reuseRatio * 1e3) / 1e3,
930
+ hint: "reused / (reused + relexed). Near 1 is healthy; near 0 means no reuse",
931
+ readOnly: true
932
+ }
933
+ ]
934
+ }
935
+ ],
936
+ notes: s.workerResponses === 0 && s.appends > 0 ? [
937
+ "No worker responses yet: either the worker is unavailable and parsing ran synchronously on the main thread, or the first request is still in flight."
938
+ ] : reuseRatio > 0 && reuseRatio < 0.5 ? [
939
+ `Only ${Math.round(reuseRatio * 100)}% of lexed tokens were reused, so most of the document is being re-lexed per chunk. Expect O(document) work per append.`
940
+ ] : void 0
941
+ };
942
+ }
783
943
  /** Enable or disable native selection for existing and future Markdown text. */
784
944
  setSelectable(selectable) {
785
945
  this.selectable = selectable;
@@ -795,6 +955,7 @@ var Markdown = class extends import_ui.UIComponent {
795
955
  /** Append a markdown chunk incrementally. Reuses unchanged prefix entities. */
796
956
  appendMarkdown(chunk) {
797
957
  this.rawMarkdown += chunk;
958
+ this.streamStats.appends++;
798
959
  if (!markdownWorker) {
799
960
  const newTokens = import_marked.marked.lexer(this.rawMarkdown);
800
961
  this.updateTokens(newTokens);
@@ -829,9 +990,12 @@ var Markdown = class extends import_ui.UIComponent {
829
990
  workerCallbacks.set(id, {
830
991
  cb: (matchLen, tail) => {
831
992
  this.pendingWorkerIds.delete(id);
993
+ this.streamStats.workerResponses++;
994
+ this.streamStats.tokensReused += matchLen;
995
+ this.streamStats.tokensRelexed += tail.length;
832
996
  this.appendInFlight = false;
833
997
  const newTokens = [...oldTokensSnapshot.slice(0, matchLen), ...tail];
834
- this.updateTokens(newTokens);
998
+ this.updateTokens(newTokens, matchLen);
835
999
  if (this.appendPending) {
836
1000
  this.appendPending = false;
837
1001
  this.dispatchAppend();
@@ -855,21 +1019,35 @@ var Markdown = class extends import_ui.UIComponent {
855
1019
  ...sendRaws ? { oldRaws: oldTokensSnapshot.map((t) => t.raw) } : {}
856
1020
  });
857
1021
  }
858
- updateTokens(newTokens) {
1022
+ updateTokens(newTokens, knownMatchLen) {
859
1023
  const oldTokens = this.tokens;
860
1024
  const oldChildren = [...this.content.children];
861
- let matchLen = 0;
1025
+ let matchLen;
862
1026
  const minLen = Math.min(oldTokens.length, newTokens.length);
863
- for (let i = 0; i < minLen; i++) {
864
- if (oldTokens[i].raw === newTokens[i].raw) {
865
- matchLen++;
866
- } else {
867
- break;
1027
+ if (knownMatchLen !== void 0 && knownMatchLen >= 0 && knownMatchLen <= minLen) {
1028
+ matchLen = knownMatchLen;
1029
+ } else {
1030
+ matchLen = 0;
1031
+ for (let i = 0; i < minLen; i++) {
1032
+ if (oldTokens[i].raw === newTokens[i].raw) {
1033
+ matchLen++;
1034
+ } else {
1035
+ break;
1036
+ }
868
1037
  }
869
1038
  }
870
1039
  const oldTokenToChild = this.tokenChildPrefix;
871
1040
  const rawMatchLen = matchLen;
872
- if (matchLen === oldTokens.length - 1 && matchLen < newTokens.length && oldTokens[matchLen]?.type === newTokens[matchLen]?.type && newTokens[matchLen]?.type === "paragraph") {
1041
+ const lastTokenSameType = matchLen === oldTokens.length - 1 && matchLen < newTokens.length && oldTokens[matchLen]?.type === newTokens[matchLen]?.type;
1042
+ if (lastTokenSameType && newTokens[matchLen]?.type === "code") {
1043
+ const existingEntity = oldChildren[oldTokenToChild[matchLen]];
1044
+ const codeToken = newTokens[matchLen];
1045
+ if (existingEntity instanceof CodeBlock) {
1046
+ existingEntity.setCode(codeToken.text, codeToken.lang ?? void 0);
1047
+ matchLen++;
1048
+ this.content.resizeLastChild(existingEntity);
1049
+ }
1050
+ } else if (lastTokenSameType && newTokens[matchLen]?.type === "paragraph") {
873
1051
  const entityIdx = oldTokenToChild[matchLen];
874
1052
  const existingEntity = oldChildren[entityIdx];
875
1053
  if (existingEntity && "setSpans" in existingEntity) {
@@ -1182,5 +1360,7 @@ var Markdown = class extends import_ui.UIComponent {
1182
1360
  // Annotate the CommonJS export names for ESM import in node:
1183
1361
  0 && (module.exports = {
1184
1362
  CodeBlock,
1185
- Markdown
1363
+ Markdown,
1364
+ codeAtlas,
1365
+ codeAtlasStats
1186
1366
  });
package/dist/index.mjs CHANGED
@@ -2,6 +2,7 @@
2
2
  import {
3
3
  BidiResolver,
4
4
  Entity,
5
+ GlyphRasterAtlas,
5
6
  prepareContentGrid,
6
7
  SVGEntity
7
8
  } from "@vectojs/core";
@@ -416,6 +417,8 @@ function highlightLine(line, lang, theme) {
416
417
  var CodeBlock = class extends UIComponent {
417
418
  lines;
418
419
  grid = null;
420
+ /** Raw (unhighlighted) lines of the last build, for prefix reuse in buildLines. */
421
+ rawLines = null;
419
422
  cellWidth = 0;
420
423
  source;
421
424
  lang;
@@ -475,9 +478,36 @@ var CodeBlock = class extends UIComponent {
475
478
  grid
476
479
  };
477
480
  }
481
+ /**
482
+ * Re-highlight the code, reusing the highlight of any unchanged line prefix.
483
+ *
484
+ * Streaming appends to the END of a block, so all but the last line or two are
485
+ * byte-identical to the previous call — yet this used to re-highlight every
486
+ * line on every chunk, making a streamed block O(N) per append and O(N^2)
487
+ * overall. Reusing the stable prefix makes an append proportional to what
488
+ * actually changed.
489
+ *
490
+ * The last previously-seen line is deliberately NOT reused: a chunk usually
491
+ * lands mid-line, so that line's text (and therefore its tokenization) changes.
492
+ */
478
493
  buildLines(code) {
479
494
  const rawLines = code.split(/\r\n|\r|\n/);
480
- this.lines = rawLines.map((l) => highlightLine(l, this.lang, this.theme));
495
+ const previous = this.rawLines;
496
+ let reusable = 0;
497
+ if (previous && this.lines.length === previous.length) {
498
+ const limit = Math.min(previous.length - 1, rawLines.length);
499
+ while (reusable < limit && previous[reusable] === rawLines[reusable]) reusable++;
500
+ }
501
+ if (reusable > 0) {
502
+ const next = this.lines.slice(0, reusable);
503
+ for (let i = reusable; i < rawLines.length; i++) {
504
+ next.push(highlightLine(rawLines[i], this.lang, this.theme));
505
+ }
506
+ this.lines = next;
507
+ } else {
508
+ this.lines = rawLines.map((l) => highlightLine(l, this.lang, this.theme));
509
+ }
510
+ this.rawLines = rawLines;
481
511
  this.grid = null;
482
512
  this.height = this.pad * 2 + rawLines.length * this.lineH;
483
513
  }
@@ -502,6 +532,9 @@ var CodeBlock = class extends UIComponent {
502
532
  r.roundRect(0, 0, this.width, this.height, 8);
503
533
  r.fill(this.theme.codeBgColor);
504
534
  const grid = this.ensureGrid();
535
+ const atlas = codeGlyphAtlas(r);
536
+ const atlasSource = atlas?.source ?? null;
537
+ const blit = atlas ? r.drawImageRect : void 0;
505
538
  for (let row = 0; row < grid.lines.length; row++) {
506
539
  const yBaseline = this.pad + row * this.lineH + this.lineH * 0.75;
507
540
  const segments = this.lines[row];
@@ -516,17 +549,51 @@ var CodeBlock = class extends UIComponent {
516
549
  }
517
550
  const sourceText = this.source.slice(cell.sourceStart, cell.sourceEnd);
518
551
  if (cell.advance <= 0 || sourceText === " " || sourceText === " ") continue;
519
- r.fillText(
520
- cell.glyph,
521
- this.pad + cell.x,
522
- yBaseline,
523
- this.codeFont,
524
- segments[segmentIndex]?.color ?? this.theme.codeColor
525
- );
552
+ const color = segments[segmentIndex]?.color ?? this.theme.codeColor;
553
+ const x = this.pad + cell.x;
554
+ if (blit && atlas) {
555
+ const slot = atlas.get(this.codeFont, color, cell.glyph);
556
+ const src = atlasSource ?? atlas.source;
557
+ if (slot && src) {
558
+ blit.call(
559
+ r,
560
+ src,
561
+ slot.sx,
562
+ slot.sy,
563
+ slot.sw,
564
+ slot.sh,
565
+ x - slot.offsetX,
566
+ yBaseline - slot.offsetY,
567
+ slot.w,
568
+ slot.h
569
+ );
570
+ continue;
571
+ }
572
+ }
573
+ r.fillText(cell.glyph, x, yBaseline, this.codeFont, color);
526
574
  }
527
575
  }
528
576
  }
529
577
  };
578
+ var sharedCodeAtlas = null;
579
+ function codeGlyphAtlas(r) {
580
+ if (typeof r.drawImageRect !== "function") return void 0;
581
+ if (typeof document === "undefined") return void 0;
582
+ sharedCodeAtlas ??= new GlyphRasterAtlas({
583
+ // Match the display so a HiDPI blit stays crisp. Capped at 3 because atlas
584
+ // area grows with dpr² and a 4x display would otherwise blow the size cap
585
+ // with a few hundred glyphs.
586
+ dpr: typeof window !== "undefined" ? Math.min(window.devicePixelRatio || 1, 3) : 1,
587
+ maxSize: 2048
588
+ });
589
+ return sharedCodeAtlas;
590
+ }
591
+ function codeAtlasStats() {
592
+ return sharedCodeAtlas ? sharedCodeAtlas.stats : null;
593
+ }
594
+ function codeAtlas() {
595
+ return sharedCodeAtlas;
596
+ }
530
597
  function decodeEntities(text) {
531
598
  return text.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/g, "&");
532
599
  }
@@ -666,6 +733,23 @@ var Markdown = class extends UIComponent {
666
733
  // with the latest accumulated text once it resolves.
667
734
  appendInFlight = false;
668
735
  appendPending = false;
736
+ /**
737
+ * Streaming counters for the DevTools inspector.
738
+ *
739
+ * Cheap enough to keep always-on (four integer increments per append) and the
740
+ * only way to see, from outside, whether incremental reuse is actually working:
741
+ * a stable-prefix ratio near 1 means the worker is matching almost everything
742
+ * and only the tail is re-lexed, while a ratio near 0 means every chunk is
743
+ * re-parsing the whole document.
744
+ */
745
+ streamStats = {
746
+ appends: 0,
747
+ workerResponses: 0,
748
+ /** Sum of `matchLen` across responses, i.e. tokens reused rather than re-lexed. */
749
+ tokensReused: 0,
750
+ /** Sum of returned tail lengths, i.e. tokens the worker had to re-lex. */
751
+ tokensRelexed: 0
752
+ };
669
753
  // Worker request ids dispatched by *this* instance that haven't resolved yet.
670
754
  // The module-level `workerCallbacks` map holds a closure capturing `this`, so
671
755
  // destroying a Markdown mid-stream would pin the whole entity (and its subtree)
@@ -758,6 +842,81 @@ var Markdown = class extends UIComponent {
758
842
  });
759
843
  super.destroy();
760
844
  }
845
+ /**
846
+ * Streaming and parse state — the markdown streaming inspector.
847
+ *
848
+ * Source length, chunk count, worker in-flight state, and the stable-prefix
849
+ * versus re-lexed-tail split. That last ratio is the one worth watching: it is
850
+ * how you tell incremental reuse is working from outside, and nothing else
851
+ * surfaces it. A ratio near 1 means the worker matched almost the whole prefix
852
+ * and only re-lexed the tail; near 0 means every chunk re-parses the document.
853
+ */
854
+ getDevtoolsDescriptor() {
855
+ const s = this.streamStats;
856
+ const lexed = s.tokensReused + s.tokensRelexed;
857
+ const reuseRatio = lexed > 0 ? s.tokensReused / lexed : 0;
858
+ return {
859
+ kind: "Markdown",
860
+ groups: [
861
+ {
862
+ label: "Source",
863
+ fields: [
864
+ { label: "sourceLength", value: this.rawMarkdown.length, readOnly: true },
865
+ { label: "topLevelTokens", value: this.tokens.length, readOnly: true },
866
+ { label: "childEntities", value: this.content.children.length, readOnly: true },
867
+ { label: "selectable", value: this.selectable }
868
+ ]
869
+ },
870
+ {
871
+ label: "Streaming",
872
+ fields: [
873
+ { label: "appends", value: s.appends, readOnly: true },
874
+ {
875
+ label: "workerResponses",
876
+ value: s.workerResponses,
877
+ hint: "Fewer than appends means chunks were coalesced while a request was in flight",
878
+ readOnly: true
879
+ },
880
+ {
881
+ label: "appendInFlight",
882
+ value: this.appendInFlight,
883
+ hint: "One lex request at a time; the delta protocol requires it",
884
+ readOnly: true
885
+ },
886
+ { label: "appendPending", value: this.appendPending, readOnly: true }
887
+ ]
888
+ },
889
+ {
890
+ label: "Incremental reuse",
891
+ fields: [
892
+ {
893
+ label: "tokensReused",
894
+ value: s.tokensReused,
895
+ hint: "Sum of matchLen: prefix tokens the worker matched and did not re-lex",
896
+ readOnly: true
897
+ },
898
+ {
899
+ label: "tokensRelexed",
900
+ value: s.tokensRelexed,
901
+ hint: "Sum of returned tail lengths: tokens the worker had to re-lex",
902
+ readOnly: true
903
+ },
904
+ {
905
+ label: "reuseRatio",
906
+ value: Math.round(reuseRatio * 1e3) / 1e3,
907
+ hint: "reused / (reused + relexed). Near 1 is healthy; near 0 means no reuse",
908
+ readOnly: true
909
+ }
910
+ ]
911
+ }
912
+ ],
913
+ notes: s.workerResponses === 0 && s.appends > 0 ? [
914
+ "No worker responses yet: either the worker is unavailable and parsing ran synchronously on the main thread, or the first request is still in flight."
915
+ ] : reuseRatio > 0 && reuseRatio < 0.5 ? [
916
+ `Only ${Math.round(reuseRatio * 100)}% of lexed tokens were reused, so most of the document is being re-lexed per chunk. Expect O(document) work per append.`
917
+ ] : void 0
918
+ };
919
+ }
761
920
  /** Enable or disable native selection for existing and future Markdown text. */
762
921
  setSelectable(selectable) {
763
922
  this.selectable = selectable;
@@ -773,6 +932,7 @@ var Markdown = class extends UIComponent {
773
932
  /** Append a markdown chunk incrementally. Reuses unchanged prefix entities. */
774
933
  appendMarkdown(chunk) {
775
934
  this.rawMarkdown += chunk;
935
+ this.streamStats.appends++;
776
936
  if (!markdownWorker) {
777
937
  const newTokens = marked.lexer(this.rawMarkdown);
778
938
  this.updateTokens(newTokens);
@@ -807,9 +967,12 @@ var Markdown = class extends UIComponent {
807
967
  workerCallbacks.set(id, {
808
968
  cb: (matchLen, tail) => {
809
969
  this.pendingWorkerIds.delete(id);
970
+ this.streamStats.workerResponses++;
971
+ this.streamStats.tokensReused += matchLen;
972
+ this.streamStats.tokensRelexed += tail.length;
810
973
  this.appendInFlight = false;
811
974
  const newTokens = [...oldTokensSnapshot.slice(0, matchLen), ...tail];
812
- this.updateTokens(newTokens);
975
+ this.updateTokens(newTokens, matchLen);
813
976
  if (this.appendPending) {
814
977
  this.appendPending = false;
815
978
  this.dispatchAppend();
@@ -833,21 +996,35 @@ var Markdown = class extends UIComponent {
833
996
  ...sendRaws ? { oldRaws: oldTokensSnapshot.map((t) => t.raw) } : {}
834
997
  });
835
998
  }
836
- updateTokens(newTokens) {
999
+ updateTokens(newTokens, knownMatchLen) {
837
1000
  const oldTokens = this.tokens;
838
1001
  const oldChildren = [...this.content.children];
839
- let matchLen = 0;
1002
+ let matchLen;
840
1003
  const minLen = Math.min(oldTokens.length, newTokens.length);
841
- for (let i = 0; i < minLen; i++) {
842
- if (oldTokens[i].raw === newTokens[i].raw) {
843
- matchLen++;
844
- } else {
845
- break;
1004
+ if (knownMatchLen !== void 0 && knownMatchLen >= 0 && knownMatchLen <= minLen) {
1005
+ matchLen = knownMatchLen;
1006
+ } else {
1007
+ matchLen = 0;
1008
+ for (let i = 0; i < minLen; i++) {
1009
+ if (oldTokens[i].raw === newTokens[i].raw) {
1010
+ matchLen++;
1011
+ } else {
1012
+ break;
1013
+ }
846
1014
  }
847
1015
  }
848
1016
  const oldTokenToChild = this.tokenChildPrefix;
849
1017
  const rawMatchLen = matchLen;
850
- if (matchLen === oldTokens.length - 1 && matchLen < newTokens.length && oldTokens[matchLen]?.type === newTokens[matchLen]?.type && newTokens[matchLen]?.type === "paragraph") {
1018
+ const lastTokenSameType = matchLen === oldTokens.length - 1 && matchLen < newTokens.length && oldTokens[matchLen]?.type === newTokens[matchLen]?.type;
1019
+ if (lastTokenSameType && newTokens[matchLen]?.type === "code") {
1020
+ const existingEntity = oldChildren[oldTokenToChild[matchLen]];
1021
+ const codeToken = newTokens[matchLen];
1022
+ if (existingEntity instanceof CodeBlock) {
1023
+ existingEntity.setCode(codeToken.text, codeToken.lang ?? void 0);
1024
+ matchLen++;
1025
+ this.content.resizeLastChild(existingEntity);
1026
+ }
1027
+ } else if (lastTokenSameType && newTokens[matchLen]?.type === "paragraph") {
851
1028
  const entityIdx = oldTokenToChild[matchLen];
852
1029
  const existingEntity = oldChildren[entityIdx];
853
1030
  if (existingEntity && "setSpans" in existingEntity) {
@@ -1159,5 +1336,7 @@ var Markdown = class extends UIComponent {
1159
1336
  };
1160
1337
  export {
1161
1338
  CodeBlock,
1162
- Markdown
1339
+ Markdown,
1340
+ codeAtlas,
1341
+ codeAtlasStats
1163
1342
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vectojs/markdown",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },