@vectojs/markdown 0.1.2 → 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. */
@@ -71,6 +71,25 @@ export declare class CodeBlock extends UIComponent {
71
71
  isPointInside(): boolean;
72
72
  render(r: IRenderer): void;
73
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;
74
93
  export interface MarkdownOptions {
75
94
  maxWidth?: number;
76
95
  theme?: MarkdownTheme;
@@ -105,6 +124,16 @@ export declare class Markdown extends UIComponent {
105
124
  private tokens;
106
125
  private appendInFlight;
107
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;
108
137
  private pendingWorkerIds;
109
138
  private readonly workerInstanceId;
110
139
  private tokenVersion;
@@ -130,6 +159,16 @@ export declare class Markdown extends UIComponent {
130
159
  * content subtree via `super.destroy()` so every block's resources are freed.
131
160
  */
132
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;
133
172
  /** Enable or disable native selection for existing and future Markdown text. */
134
173
  setSelectable(selectable: boolean): this;
135
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
 
@@ -553,6 +555,9 @@ var CodeBlock = class extends import_ui.UIComponent {
553
555
  r.roundRect(0, 0, this.width, this.height, 8);
554
556
  r.fill(this.theme.codeBgColor);
555
557
  const grid = this.ensureGrid();
558
+ const atlas = codeGlyphAtlas(r);
559
+ const atlasSource = atlas?.source ?? null;
560
+ const blit = atlas ? r.drawImageRect : void 0;
556
561
  for (let row = 0; row < grid.lines.length; row++) {
557
562
  const yBaseline = this.pad + row * this.lineH + this.lineH * 0.75;
558
563
  const segments = this.lines[row];
@@ -567,17 +572,51 @@ var CodeBlock = class extends import_ui.UIComponent {
567
572
  }
568
573
  const sourceText = this.source.slice(cell.sourceStart, cell.sourceEnd);
569
574
  if (cell.advance <= 0 || sourceText === " " || sourceText === " ") continue;
570
- r.fillText(
571
- cell.glyph,
572
- this.pad + cell.x,
573
- yBaseline,
574
- this.codeFont,
575
- segments[segmentIndex]?.color ?? this.theme.codeColor
576
- );
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);
577
597
  }
578
598
  }
579
599
  }
580
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
+ }
581
620
  function decodeEntities(text) {
582
621
  return text.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/g, "&");
583
622
  }
@@ -717,6 +756,23 @@ var Markdown = class extends import_ui.UIComponent {
717
756
  // with the latest accumulated text once it resolves.
718
757
  appendInFlight = false;
719
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
+ };
720
776
  // Worker request ids dispatched by *this* instance that haven't resolved yet.
721
777
  // The module-level `workerCallbacks` map holds a closure capturing `this`, so
722
778
  // destroying a Markdown mid-stream would pin the whole entity (and its subtree)
@@ -809,6 +865,81 @@ var Markdown = class extends import_ui.UIComponent {
809
865
  });
810
866
  super.destroy();
811
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
+ }
812
943
  /** Enable or disable native selection for existing and future Markdown text. */
813
944
  setSelectable(selectable) {
814
945
  this.selectable = selectable;
@@ -824,6 +955,7 @@ var Markdown = class extends import_ui.UIComponent {
824
955
  /** Append a markdown chunk incrementally. Reuses unchanged prefix entities. */
825
956
  appendMarkdown(chunk) {
826
957
  this.rawMarkdown += chunk;
958
+ this.streamStats.appends++;
827
959
  if (!markdownWorker) {
828
960
  const newTokens = import_marked.marked.lexer(this.rawMarkdown);
829
961
  this.updateTokens(newTokens);
@@ -858,6 +990,9 @@ var Markdown = class extends import_ui.UIComponent {
858
990
  workerCallbacks.set(id, {
859
991
  cb: (matchLen, tail) => {
860
992
  this.pendingWorkerIds.delete(id);
993
+ this.streamStats.workerResponses++;
994
+ this.streamStats.tokensReused += matchLen;
995
+ this.streamStats.tokensRelexed += tail.length;
861
996
  this.appendInFlight = false;
862
997
  const newTokens = [...oldTokensSnapshot.slice(0, matchLen), ...tail];
863
998
  this.updateTokens(newTokens, matchLen);
@@ -1225,5 +1360,7 @@ var Markdown = class extends import_ui.UIComponent {
1225
1360
  // Annotate the CommonJS export names for ESM import in node:
1226
1361
  0 && (module.exports = {
1227
1362
  CodeBlock,
1228
- Markdown
1363
+ Markdown,
1364
+ codeAtlas,
1365
+ codeAtlasStats
1229
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";
@@ -531,6 +532,9 @@ var CodeBlock = class extends UIComponent {
531
532
  r.roundRect(0, 0, this.width, this.height, 8);
532
533
  r.fill(this.theme.codeBgColor);
533
534
  const grid = this.ensureGrid();
535
+ const atlas = codeGlyphAtlas(r);
536
+ const atlasSource = atlas?.source ?? null;
537
+ const blit = atlas ? r.drawImageRect : void 0;
534
538
  for (let row = 0; row < grid.lines.length; row++) {
535
539
  const yBaseline = this.pad + row * this.lineH + this.lineH * 0.75;
536
540
  const segments = this.lines[row];
@@ -545,17 +549,51 @@ var CodeBlock = class extends UIComponent {
545
549
  }
546
550
  const sourceText = this.source.slice(cell.sourceStart, cell.sourceEnd);
547
551
  if (cell.advance <= 0 || sourceText === " " || sourceText === " ") continue;
548
- r.fillText(
549
- cell.glyph,
550
- this.pad + cell.x,
551
- yBaseline,
552
- this.codeFont,
553
- segments[segmentIndex]?.color ?? this.theme.codeColor
554
- );
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);
555
574
  }
556
575
  }
557
576
  }
558
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
+ }
559
597
  function decodeEntities(text) {
560
598
  return text.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/g, "&");
561
599
  }
@@ -695,6 +733,23 @@ var Markdown = class extends UIComponent {
695
733
  // with the latest accumulated text once it resolves.
696
734
  appendInFlight = false;
697
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
+ };
698
753
  // Worker request ids dispatched by *this* instance that haven't resolved yet.
699
754
  // The module-level `workerCallbacks` map holds a closure capturing `this`, so
700
755
  // destroying a Markdown mid-stream would pin the whole entity (and its subtree)
@@ -787,6 +842,81 @@ var Markdown = class extends UIComponent {
787
842
  });
788
843
  super.destroy();
789
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
+ }
790
920
  /** Enable or disable native selection for existing and future Markdown text. */
791
921
  setSelectable(selectable) {
792
922
  this.selectable = selectable;
@@ -802,6 +932,7 @@ var Markdown = class extends UIComponent {
802
932
  /** Append a markdown chunk incrementally. Reuses unchanged prefix entities. */
803
933
  appendMarkdown(chunk) {
804
934
  this.rawMarkdown += chunk;
935
+ this.streamStats.appends++;
805
936
  if (!markdownWorker) {
806
937
  const newTokens = marked.lexer(this.rawMarkdown);
807
938
  this.updateTokens(newTokens);
@@ -836,6 +967,9 @@ var Markdown = class extends UIComponent {
836
967
  workerCallbacks.set(id, {
837
968
  cb: (matchLen, tail) => {
838
969
  this.pendingWorkerIds.delete(id);
970
+ this.streamStats.workerResponses++;
971
+ this.streamStats.tokensReused += matchLen;
972
+ this.streamStats.tokensRelexed += tail.length;
839
973
  this.appendInFlight = false;
840
974
  const newTokens = [...oldTokensSnapshot.slice(0, matchLen), ...tail];
841
975
  this.updateTokens(newTokens, matchLen);
@@ -1202,5 +1336,7 @@ var Markdown = class extends UIComponent {
1202
1336
  };
1203
1337
  export {
1204
1338
  CodeBlock,
1205
- Markdown
1339
+ Markdown,
1340
+ codeAtlas,
1341
+ codeAtlasStats
1206
1342
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vectojs/markdown",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },