@elabs-ai/components-editor 4.0.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.
Files changed (207) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +149 -0
  3. package/dist/chunk-LBC5VJBD.js +3748 -0
  4. package/dist/chunk-LBC5VJBD.js.map +1 -0
  5. package/dist/index.css +233 -0
  6. package/dist/index.css.map +1 -0
  7. package/dist/index.d.ts +199 -0
  8. package/dist/index.js +362 -0
  9. package/dist/index.js.map +1 -0
  10. package/dist/lib/monaco-environment.d.ts +2 -0
  11. package/dist/lib/monaco-environment.js +44 -0
  12. package/dist/lib/monaco-environment.js.map +1 -0
  13. package/dist/markdown/frontmatter.d.ts +11 -0
  14. package/dist/markdown/frontmatter.js +33 -0
  15. package/dist/markdown/frontmatter.js.map +1 -0
  16. package/dist/markdown/index.css +233 -0
  17. package/dist/markdown/index.css.map +1 -0
  18. package/dist/markdown/index.d.ts +1486 -0
  19. package/dist/markdown/index.js +4882 -0
  20. package/dist/markdown/index.js.map +1 -0
  21. package/dist/markdown/parse.d.ts +25 -0
  22. package/dist/markdown/parse.js +14 -0
  23. package/dist/markdown/parse.js.map +1 -0
  24. package/dist/markdown-editor-DfBZibAn.d.ts +702 -0
  25. package/package.json +101 -0
  26. package/src/ai-objects/ai-objects-integration.test.tsx +128 -0
  27. package/src/ai-objects/ai-objects.stories.tsx +233 -0
  28. package/src/ai-objects/ai-objects.test.tsx +205 -0
  29. package/src/ai-objects/decision-card.tsx +178 -0
  30. package/src/ai-objects/directives.ts +136 -0
  31. package/src/ai-objects/entity.tsx +181 -0
  32. package/src/ai-objects/index.ts +34 -0
  33. package/src/ai-objects/knowledge-card.tsx +143 -0
  34. package/src/calc-block/calc-block.stories.tsx +297 -0
  35. package/src/calc-block/calc-block.test.tsx +164 -0
  36. package/src/calc-block/calc-block.tsx +398 -0
  37. package/src/calc-block/calc-editor-monaco.ts +214 -0
  38. package/src/calc-block/calc-editor-prose.ts +128 -0
  39. package/src/calc-block/calc-editor.css +80 -0
  40. package/src/calc-block/calc-editor.stories.tsx +272 -0
  41. package/src/calc-block/calc-editor.test.ts +228 -0
  42. package/src/calc-block/calc-editor.ts +270 -0
  43. package/src/calc-block/calc-inline.stories.tsx +44 -0
  44. package/src/calc-block/calc-inline.test.tsx +44 -0
  45. package/src/calc-block/calc-inline.tsx +75 -0
  46. package/src/calc-block/index.ts +20 -0
  47. package/src/calc-block/types.ts +164 -0
  48. package/src/code-editor/code-editor.stories.tsx +218 -0
  49. package/src/code-editor/code-editor.test.tsx +134 -0
  50. package/src/code-editor/code-editor.tsx +250 -0
  51. package/src/code-editor/index.ts +6 -0
  52. package/src/code-workspace/code-workspace.stories.tsx +51 -0
  53. package/src/code-workspace/code-workspace.test.tsx +146 -0
  54. package/src/code-workspace/code-workspace.tsx +253 -0
  55. package/src/code-workspace/index.ts +6 -0
  56. package/src/copy-button/copy-button.tsx +51 -0
  57. package/src/copy-button/index.ts +1 -0
  58. package/src/css.d.ts +3 -0
  59. package/src/diff-editor/diff-editor.stories.tsx +44 -0
  60. package/src/diff-editor/diff-editor.test.tsx +68 -0
  61. package/src/diff-editor/diff-editor.tsx +151 -0
  62. package/src/diff-editor/index.ts +1 -0
  63. package/src/editor-context-menu/editor-context-menu.tsx +120 -0
  64. package/src/editor-context-menu/index.ts +1 -0
  65. package/src/editor-toolbar/editor-toolbar.tsx +83 -0
  66. package/src/editor-toolbar/index.ts +1 -0
  67. package/src/index.ts +48 -0
  68. package/src/lib/editor-completions-monaco.test.ts +206 -0
  69. package/src/lib/editor-completions-monaco.ts +127 -0
  70. package/src/lib/editor-completions.test.ts +156 -0
  71. package/src/lib/editor-completions.ts +153 -0
  72. package/src/lib/editor-content-access-prose.test.ts +411 -0
  73. package/src/lib/editor-content-access-prose.ts +172 -0
  74. package/src/lib/editor-content-access.test.ts +182 -0
  75. package/src/lib/editor-content-access.ts +109 -0
  76. package/src/lib/languages.ts +32 -0
  77. package/src/lib/markdown/diff.test.ts +80 -0
  78. package/src/lib/markdown/diff.ts +194 -0
  79. package/src/lib/markdown/directives.ts +342 -0
  80. package/src/lib/markdown/frontmatter.ts +50 -0
  81. package/src/lib/markdown/markdown-scale.test.ts +65 -0
  82. package/src/lib/markdown/markdown-scale.ts +62 -0
  83. package/src/lib/markdown/merge.test.ts +64 -0
  84. package/src/lib/markdown/merge.ts +158 -0
  85. package/src/lib/markdown/slugify.test.ts +156 -0
  86. package/src/lib/markdown/slugify.ts +47 -0
  87. package/src/lib/monaco-environment.ts +103 -0
  88. package/src/lib/monaco-theme-bridge.ts +293 -0
  89. package/src/lib/use-data-theme.ts +61 -0
  90. package/src/markdown/frontmatter.ts +14 -0
  91. package/src/markdown/index.ts +323 -0
  92. package/src/markdown/parse.test.ts +60 -0
  93. package/src/markdown/parse.ts +38 -0
  94. package/src/markdown-academic/citations.test.ts +77 -0
  95. package/src/markdown-academic/citations.tsx +442 -0
  96. package/src/markdown-academic/footnotes.tsx +235 -0
  97. package/src/markdown-academic/index.ts +32 -0
  98. package/src/markdown-academic/math.tsx +163 -0
  99. package/src/markdown-academic/toc.tsx +88 -0
  100. package/src/markdown-editor/completions/completions-menu.tsx +96 -0
  101. package/src/markdown-editor/completions/completions-prose.test.ts +356 -0
  102. package/src/markdown-editor/completions/completions-prose.ts +313 -0
  103. package/src/markdown-editor/completions/completions-widget.tsx +75 -0
  104. package/src/markdown-editor/completions/index.ts +37 -0
  105. package/src/markdown-editor/directive-nodes.ts +151 -0
  106. package/src/markdown-editor/directive-views.test.tsx +189 -0
  107. package/src/markdown-editor/directive-views.tsx +795 -0
  108. package/src/markdown-editor/exit-keymap.test.ts +88 -0
  109. package/src/markdown-editor/exit-keymap.ts +87 -0
  110. package/src/markdown-editor/index.ts +6 -0
  111. package/src/markdown-editor/markdown-editor.css +250 -0
  112. package/src/markdown-editor/markdown-editor.directives.test.tsx +105 -0
  113. package/src/markdown-editor/markdown-editor.fill.test.ts +34 -0
  114. package/src/markdown-editor/markdown-editor.paste-embed.test.tsx +297 -0
  115. package/src/markdown-editor/markdown-editor.stories.tsx +560 -0
  116. package/src/markdown-editor/markdown-editor.strictmode.test.tsx +38 -0
  117. package/src/markdown-editor/markdown-editor.table.test.tsx +287 -0
  118. package/src/markdown-editor/markdown-editor.tsx +560 -0
  119. package/src/markdown-editor/milkdown-react/editor.tsx +33 -0
  120. package/src/markdown-editor/milkdown-react/index.ts +8 -0
  121. package/src/markdown-editor/milkdown-react/types.ts +27 -0
  122. package/src/markdown-editor/milkdown-react/use-editor.ts +27 -0
  123. package/src/markdown-editor/milkdown-react/use-get-editor.ts +56 -0
  124. package/src/markdown-editor/milkdown-react/use-instance.ts +23 -0
  125. package/src/markdown-editor/paste-embed.ts +355 -0
  126. package/src/markdown-editor/slash/brand-slash-commands.test.ts +194 -0
  127. package/src/markdown-editor/slash/brand-slash-commands.ts +324 -0
  128. package/src/markdown-editor/slash/brand-slash-plugin.test.ts +254 -0
  129. package/src/markdown-editor/slash/brand-slash-plugin.ts +385 -0
  130. package/src/markdown-editor/slash/index.ts +107 -0
  131. package/src/markdown-editor/slash/insert-directive.test.ts +263 -0
  132. package/src/markdown-editor/slash/insert-directive.ts +345 -0
  133. package/src/markdown-editor/slash/monaco-slash-menu.test.tsx +198 -0
  134. package/src/markdown-editor/slash/monaco-slash-menu.tsx +356 -0
  135. package/src/markdown-editor/slash/shortcut-monaco.ts +69 -0
  136. package/src/markdown-editor/slash/shortcut.test.ts +157 -0
  137. package/src/markdown-editor/slash/shortcut.ts +57 -0
  138. package/src/markdown-editor/slash/slash-menu.stories.tsx +139 -0
  139. package/src/markdown-editor/slash/slash-menu.tsx +115 -0
  140. package/src/markdown-editor/slash/slash-scroll.test.tsx +52 -0
  141. package/src/markdown-editor/slash/slash-widget.tsx +97 -0
  142. package/src/markdown-editor/slash/source-slash-trigger.test.ts +42 -0
  143. package/src/markdown-editor/slash/source-slash-trigger.ts +46 -0
  144. package/src/markdown-editor/table-view.tsx +249 -0
  145. package/src/markdown-iteration/directive.tsx +92 -0
  146. package/src/markdown-iteration/edit-context.ts +56 -0
  147. package/src/markdown-iteration/index.ts +24 -0
  148. package/src/markdown-iteration/iteration-block.test.tsx +138 -0
  149. package/src/markdown-iteration/iteration-builder-dialog.stories.tsx +293 -0
  150. package/src/markdown-iteration/iteration-builder-dialog.tsx +312 -0
  151. package/src/markdown-iteration/iteration-builder.test.ts +252 -0
  152. package/src/markdown-iteration/iteration-builder.ts +303 -0
  153. package/src/markdown-iteration/iteration.test.ts +56 -0
  154. package/src/markdown-iteration/iteration.tsx +333 -0
  155. package/src/markdown-iteration/template-dialog.stories.tsx +63 -0
  156. package/src/markdown-iteration/template-dialog.test.tsx +104 -0
  157. package/src/markdown-iteration/template-dialog.tsx +116 -0
  158. package/src/markdown-outline/document-outline.stories.tsx +76 -0
  159. package/src/markdown-outline/document-outline.tsx +83 -0
  160. package/src/markdown-outline/index.ts +2 -0
  161. package/src/markdown-outline/markdown-outline.test.ts +49 -0
  162. package/src/markdown-outline/markdown-outline.ts +60 -0
  163. package/src/markdown-preview/code-fence.tsx +193 -0
  164. package/src/markdown-preview/index.ts +8 -0
  165. package/src/markdown-preview/markdown-preview-academic.stories.tsx +203 -0
  166. package/src/markdown-preview/markdown-preview-academic.test.tsx +257 -0
  167. package/src/markdown-preview/markdown-preview-iteration-calc.test.tsx +71 -0
  168. package/src/markdown-preview/markdown-preview-iteration-nested.test.tsx +58 -0
  169. package/src/markdown-preview/markdown-preview-iteration.stories.tsx +187 -0
  170. package/src/markdown-preview/markdown-preview-iteration.test.tsx +90 -0
  171. package/src/markdown-preview/markdown-preview-linking.test.tsx +61 -0
  172. package/src/markdown-preview/markdown-preview-transclusion.test.tsx +33 -0
  173. package/src/markdown-preview/markdown-preview.stories.tsx +108 -0
  174. package/src/markdown-preview/markdown-preview.test.tsx +411 -0
  175. package/src/markdown-preview/markdown-preview.tsx +1541 -0
  176. package/src/markdown-toolbar/index.ts +8 -0
  177. package/src/markdown-toolbar/markdown-commands.test.ts +84 -0
  178. package/src/markdown-toolbar/markdown-commands.ts +104 -0
  179. package/src/markdown-toolbar/markdown-toolbar.stories.tsx +56 -0
  180. package/src/markdown-toolbar/markdown-toolbar.tsx +263 -0
  181. package/src/markdown-workspace/focus-writing.test.ts +52 -0
  182. package/src/markdown-workspace/focus-writing.ts +38 -0
  183. package/src/markdown-workspace/index.ts +6 -0
  184. package/src/markdown-workspace/markdown-workspace.stories.tsx +688 -0
  185. package/src/markdown-workspace/markdown-workspace.test.tsx +326 -0
  186. package/src/markdown-workspace/markdown-workspace.tsx +787 -0
  187. package/src/mermaid-diagram/index.ts +1 -0
  188. package/src/mermaid-diagram/mermaid-diagram.stories.tsx +42 -0
  189. package/src/mermaid-diagram/mermaid-diagram.test.tsx +86 -0
  190. package/src/mermaid-diagram/mermaid-diagram.tsx +318 -0
  191. package/src/mermaid-diagram/mermaid-viewer.test.tsx +89 -0
  192. package/src/mermaid-diagram/mermaid-viewer.tsx +367 -0
  193. package/src/mermaid-diagram/real-parse.test.ts +42 -0
  194. package/src/mermaid-diagram/remediate.test.ts +60 -0
  195. package/src/mermaid-diagram/remediate.ts +124 -0
  196. package/src/mermaid-workspace/index.ts +1 -0
  197. package/src/mermaid-workspace/mermaid-workspace.stories.tsx +28 -0
  198. package/src/mermaid-workspace/mermaid-workspace.tsx +71 -0
  199. package/src/metric-block/index.ts +1 -0
  200. package/src/metric-block/metric-block.stories.tsx +40 -0
  201. package/src/metric-block/metric-block.tsx +14 -0
  202. package/src/monaco-workers.d.ts +10 -0
  203. package/src/prose/index.ts +14 -0
  204. package/src/prose/prose.stories.tsx +44 -0
  205. package/src/prose/prose.test.tsx +44 -0
  206. package/src/prose/prose.tsx +21 -0
  207. package/src/timeline/index.ts +18 -0
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Normalization-aware merge for WYSIWYG markdown editing (review WI-1).
3
+ *
4
+ * Problem: Milkdown serializes the WHOLE document on every edit, normalizing
5
+ * formatting it never touched (list markers, wrapping, spacing). Feeding that
6
+ * back into the buffer turns a one-line edit into a whole-file rewrite —
7
+ * destroying git blame and making PR review impossible.
8
+ *
9
+ * Fix: three-way line merge.
10
+ * - `original` — the byte-exact source the editor was opened with.
11
+ * - `baseline` — the editor's serialization of `original` BEFORE any edit
12
+ * (pure normalization drift).
13
+ * - `edited` — the editor's serialization after user edits.
14
+ *
15
+ * `diff(baseline, edited)` isolates what the USER changed; an
16
+ * `original ↔ baseline` alignment maps those hunks back onto `original`.
17
+ * Everything the user didn't touch keeps its original bytes. Granularity is
18
+ * the contiguous normalized run — markdown's blank lines survive
19
+ * serialization byte-exact, so in practice that's a single block.
20
+ *
21
+ * Guarantees:
22
+ * - no user edit (`baseline === edited`) → returns `original` byte-exact;
23
+ * - no normalization drift (`original === baseline`) → returns `edited`;
24
+ * - a user hunk inside a normalized block replaces exactly that block;
25
+ * - oversized inputs (> {@link MAX_DIFF_LINES}) fall back to `edited`.
26
+ */
27
+
28
+ import { lcsPairs, MAX_DIFF_LINES } from "./diff";
29
+
30
+ /** A contiguous span of the baseline mapped to a span of the original. */
31
+ interface Region {
32
+ /** Baseline range [bStart, bEnd) — may be empty (pure original deletion). */
33
+ bStart: number;
34
+ bEnd: number;
35
+ /** Original lines this region carries. */
36
+ oLines: string[];
37
+ /** True when the region is a 1:1 byte-equal line pair. */
38
+ exact: boolean;
39
+ }
40
+
41
+ /** Build the ordered original↔baseline region list (covers both fully). */
42
+ function alignRegions(o: string[], b: string[]): Region[] {
43
+ const pairs = lcsPairs(o, b);
44
+ const regions: Region[] = [];
45
+ let prevO = -1;
46
+ let prevB = -1;
47
+ const walk: [number, number][] = [...pairs, [o.length, b.length]];
48
+ for (const [oi, bi] of walk) {
49
+ if (oi - prevO > 1 || bi - prevB > 1) {
50
+ // Normalization-changed run (possibly empty on one side).
51
+ regions.push({
52
+ bStart: prevB + 1,
53
+ bEnd: bi,
54
+ oLines: o.slice(prevO + 1, oi),
55
+ exact: false,
56
+ });
57
+ }
58
+ if (oi < o.length && bi < b.length) {
59
+ regions.push({ bStart: bi, bEnd: bi + 1, oLines: [o[oi]!], exact: true });
60
+ }
61
+ prevO = oi;
62
+ prevB = bi;
63
+ }
64
+ return regions;
65
+ }
66
+
67
+ /**
68
+ * Merge a WYSIWYG serialization back onto the original source, keeping
69
+ * original bytes for everything the user didn't touch.
70
+ */
71
+ export function mergeNormalizedEdit(original: string, baseline: string, edited: string): string {
72
+ if (baseline === edited) return original;
73
+ if (original === baseline) return edited;
74
+
75
+ const o = original.split("\n");
76
+ const b = baseline.split("\n");
77
+ const n = edited.split("\n");
78
+ if (o.length > MAX_DIFF_LINES || b.length > MAX_DIFF_LINES || n.length > MAX_DIFF_LINES) {
79
+ return edited;
80
+ }
81
+
82
+ // Which baseline lines did the user keep, and where do their insertions go?
83
+ const keptB = new Uint8Array(b.length);
84
+ /** N-lines the user inserted, anchored BEFORE baseline index `atB`. */
85
+ const inserts = new Map<number, string[]>();
86
+ {
87
+ const pairs = lcsPairs(b, n);
88
+ let prevB = -1;
89
+ let prevN = -1;
90
+ const walk: [number, number][] = [...pairs, [b.length, n.length]];
91
+ for (const [bi, ni] of walk) {
92
+ if (ni - prevN > 1) inserts.set(bi, n.slice(prevN + 1, ni));
93
+ if (bi < b.length) keptB[bi] = 1;
94
+ prevB = bi;
95
+ prevN = ni;
96
+ }
97
+ void prevB;
98
+ }
99
+
100
+ const regions = alignRegions(o, b);
101
+ const out: string[] = [];
102
+
103
+ const flushInsertsBefore = (bIndex: number, pendingFrom: number): number => {
104
+ for (let at = pendingFrom; at <= bIndex; at++) {
105
+ const ins = inserts.get(at);
106
+ if (ins) out.push(...ins);
107
+ }
108
+ return bIndex + 1;
109
+ };
110
+
111
+ let insertCursor = 0;
112
+ for (const region of regions) {
113
+ if (region.bStart === region.bEnd) {
114
+ // Pure original deletion by normalization (no baseline lines). Keep the
115
+ // original lines when the surrounding context is untouched by the user.
116
+ const before = region.bStart - 1;
117
+ const contextKept =
118
+ (before < 0 || keptB[before] === 1) &&
119
+ (region.bStart >= b.length || keptB[region.bStart] === 1);
120
+ insertCursor = flushInsertsBefore(region.bStart - 1, insertCursor);
121
+ if (contextKept) out.push(...region.oLines);
122
+ continue;
123
+ }
124
+
125
+ let allKept = true;
126
+ for (let bi = region.bStart; bi < region.bEnd; bi++) {
127
+ if (keptB[bi] !== 1) {
128
+ allKept = false;
129
+ break;
130
+ }
131
+ }
132
+
133
+ if (region.exact || allKept) {
134
+ // Untouched by the user → original bytes win. (For exact regions the
135
+ // texts are identical anyway; for normalized runs this UNDOES the
136
+ // serializer's drift.) Emit interleaved insertions at their anchors.
137
+ for (let bi = region.bStart; bi < region.bEnd; bi++) {
138
+ insertCursor = flushInsertsBefore(bi, insertCursor);
139
+ if (keptB[bi] === 1) {
140
+ if (region.exact) out.push(...region.oLines);
141
+ }
142
+ }
143
+ if (!region.exact) out.push(...region.oLines);
144
+ } else {
145
+ // The user edited inside this region → the edited serialization wins
146
+ // for the whole region (locally normalized; the rest of the document
147
+ // stays byte-exact). Kept lines inside it come from the baseline text.
148
+ for (let bi = region.bStart; bi < region.bEnd; bi++) {
149
+ insertCursor = flushInsertsBefore(bi, insertCursor);
150
+ if (keptB[bi] === 1) out.push(b[bi]!);
151
+ }
152
+ }
153
+ }
154
+ // Trailing insertions (anchored at b.length).
155
+ flushInsertsBefore(b.length, insertCursor);
156
+
157
+ return out.join("\n");
158
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Unit tests for the shared slugify helpers (#273).
3
+ *
4
+ * The key invariant: slugifyHeading + uniqueSlug must produce IDENTICAL ids to
5
+ * parseMarkdownOutline so that scrollToHeading(slug) finds the right ProseMirror
6
+ * node when called with an id from the outline.
7
+ */
8
+ import { describe, expect, test } from "vitest";
9
+
10
+ import { parseMarkdownOutline } from "../../markdown-outline/markdown-outline";
11
+ import { plainText, slugifyHeading, uniqueSlug } from "./slugify";
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // plainText
15
+ // ---------------------------------------------------------------------------
16
+
17
+ describe("plainText", () => {
18
+ test("strips inline code backticks", () => {
19
+ expect(plainText("`foo`")).toBe("foo");
20
+ });
21
+
22
+ test("strips links, keeps text", () => {
23
+ expect(plainText("[My Section](https://example.com)")).toBe("My Section");
24
+ });
25
+
26
+ test("strips images, keeps alt", () => {
27
+ expect(plainText("![alt text](img.png)")).toBe("alt text");
28
+ });
29
+
30
+ test("strips bold/italic markers", () => {
31
+ expect(plainText("**bold** and _italic_")).toBe("bold and italic");
32
+ });
33
+
34
+ test("trims surrounding whitespace", () => {
35
+ expect(plainText(" hello ")).toBe("hello");
36
+ });
37
+ });
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // slugifyHeading
41
+ // ---------------------------------------------------------------------------
42
+
43
+ describe("slugifyHeading", () => {
44
+ test("lowercases and replaces spaces with hyphens", () => {
45
+ expect(slugifyHeading("Hello World")).toBe("hello-world");
46
+ });
47
+
48
+ test("strips punctuation that is not letter/number/hyphen", () => {
49
+ expect(slugifyHeading("Hello, World!")).toBe("hello-world");
50
+ });
51
+
52
+ test("preserves hyphens", () => {
53
+ expect(slugifyHeading("step-by-step")).toBe("step-by-step");
54
+ });
55
+
56
+ test("falls back to 'section' for empty text", () => {
57
+ expect(slugifyHeading("")).toBe("section");
58
+ expect(slugifyHeading("!!!")).toBe("section");
59
+ });
60
+
61
+ test("handles unicode letters", () => {
62
+ // Unicode letter characters should be kept
63
+ const slug = slugifyHeading("Über die Änderungen");
64
+ expect(slug).toContain("ber");
65
+ });
66
+ });
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // uniqueSlug — deduplication
70
+ // ---------------------------------------------------------------------------
71
+
72
+ describe("uniqueSlug", () => {
73
+ test("first occurrence is the bare slug", () => {
74
+ const used = new Map<string, number>();
75
+ expect(uniqueSlug("intro", used)).toBe("intro");
76
+ });
77
+
78
+ test("second occurrence gets -1 suffix", () => {
79
+ const used = new Map<string, number>();
80
+ uniqueSlug("intro", used);
81
+ expect(uniqueSlug("intro", used)).toBe("intro-1");
82
+ });
83
+
84
+ test("third occurrence gets -2 suffix", () => {
85
+ const used = new Map<string, number>();
86
+ uniqueSlug("intro", used);
87
+ uniqueSlug("intro", used);
88
+ expect(uniqueSlug("intro", used)).toBe("intro-2");
89
+ });
90
+
91
+ test("different base slugs dedupe independently", () => {
92
+ const used = new Map<string, number>();
93
+ expect(uniqueSlug("a", used)).toBe("a");
94
+ expect(uniqueSlug("b", used)).toBe("b");
95
+ expect(uniqueSlug("a", used)).toBe("a-1");
96
+ expect(uniqueSlug("b", used)).toBe("b-1");
97
+ });
98
+ });
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Invariant: slugifyHeading + uniqueSlug == parseMarkdownOutline ids
102
+ // ---------------------------------------------------------------------------
103
+
104
+ describe("slug invariant — shared helpers match parseMarkdownOutline ids", () => {
105
+ /**
106
+ * Simulate exactly what parseMarkdownOutline does, using the shared helpers.
107
+ * Must produce identical id sequences.
108
+ */
109
+ function simulateSlugs(headingTexts: string[]): string[] {
110
+ const used = new Map<string, number>();
111
+ return headingTexts.map((text) => uniqueSlug(slugifyHeading(plainText(text)), used));
112
+ }
113
+
114
+ test("unique headings produce matching slugs", () => {
115
+ const markdown = `# Introduction\n\n## Getting Started\n\n### Advanced Usage\n`;
116
+ const outline = parseMarkdownOutline(markdown);
117
+ const simulated = simulateSlugs(["Introduction", "Getting Started", "Advanced Usage"]);
118
+ expect(outline.map((i) => i.id)).toEqual(simulated);
119
+ });
120
+
121
+ test("duplicate headings get -1, -2 suffixes in both paths", () => {
122
+ const markdown = `# Methods\n\n## Methods\n\n### Methods\n`;
123
+ const outline = parseMarkdownOutline(markdown);
124
+ const simulated = simulateSlugs(["Methods", "Methods", "Methods"]);
125
+ expect(outline.map((i) => i.id)).toEqual(simulated);
126
+ expect(outline[0]!.id).toBe("methods");
127
+ expect(outline[1]!.id).toBe("methods-1");
128
+ expect(outline[2]!.id).toBe("methods-2");
129
+ });
130
+
131
+ test("inline-markdown decorated headings produce matching slugs", () => {
132
+ // parseMarkdownOutline strips inline markdown from the heading text
133
+ const markdown = `# **Bold** Heading\n\n## [Link](https://example.com) Heading\n`;
134
+ const outline = parseMarkdownOutline(markdown);
135
+ const simulated = simulateSlugs(["**Bold** Heading", "[Link](https://example.com) Heading"]);
136
+ expect(outline.map((i) => i.id)).toEqual(simulated);
137
+ });
138
+
139
+ test("headings inside fenced code blocks are ignored", () => {
140
+ const markdown = `# Real\n\n\`\`\`\n# Not a heading\n\`\`\`\n\n## Also Real\n`;
141
+ const outline = parseMarkdownOutline(markdown);
142
+ expect(outline).toHaveLength(2);
143
+ expect(outline[0]!.id).toBe("real");
144
+ expect(outline[1]!.id).toBe("also-real");
145
+ });
146
+
147
+ test("frontmatter headings are excluded, stripped-line coordinates are correct", () => {
148
+ const markdown = `---\ntitle: Doc\n---\n\n# First\n\n## Second\n`;
149
+ const outline = parseMarkdownOutline(markdown);
150
+ // 3 lines frontmatter (---\ntitle\n---) + blank = offset 4
151
+ // But parseMarkdownOutline operates on the stripped body, so lines start at 1
152
+ expect(outline[0]!.id).toBe("first");
153
+ expect(outline[1]!.id).toBe("second");
154
+ expect(outline[0]!.line).toBe(2); // blank line 1, heading line 2 in stripped body
155
+ });
156
+ });
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Shared heading-slug helpers.
3
+ *
4
+ * The same slug algorithm is used by:
5
+ * - `parseMarkdownOutline` (static markdown → outline items, slug as `id`)
6
+ * - `MarkdownEditorHandle.scrollToHeading` (ProseMirror doc walk, WYSIWYG)
7
+ *
8
+ * Keeping both on a single helper guarantees the slugs agree so
9
+ * `scrollToHeading(slug)` finds the right node when called with an id produced
10
+ * by `parseMarkdownOutline`. (#273)
11
+ */
12
+
13
+ /** Strip the inline-markdown syntax that commonly decorates headings. */
14
+ export function plainText(raw: string): string {
15
+ return raw
16
+ .replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
17
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
18
+ .replace(/[`*_~]/g, "")
19
+ .trim();
20
+ }
21
+
22
+ /**
23
+ * GitHub-style heading slug: lowercase, strip non-letter/number/space/hyphen,
24
+ * collapse whitespace to hyphens. Falls back to `"section"` for empty text.
25
+ */
26
+ export function slugifyHeading(text: string): string {
27
+ return (
28
+ text
29
+ .toLowerCase()
30
+ .replace(/[^\p{L}\p{N}\s-]/gu, "")
31
+ .trim()
32
+ .replace(/\s+/g, "-") || "section"
33
+ );
34
+ }
35
+
36
+ /**
37
+ * Produce a unique slug for a heading, given a Map of already-used base slugs
38
+ * (values are the count seen so far). Mutates `used`.
39
+ *
40
+ * The algorithm matches `parseMarkdownOutline`: first occurrence is the bare
41
+ * slug; duplicates get a `-n` numeric suffix starting at 1.
42
+ */
43
+ export function uniqueSlug(base: string, used: Map<string, number>): string {
44
+ const seen = used.get(base) ?? 0;
45
+ used.set(base, seen + 1);
46
+ return seen === 0 ? base : `${base}-${seen}`;
47
+ }
@@ -0,0 +1,103 @@
1
+ // eslint-disable-next-line @typescript-eslint/triple-slash-reference -- carry the `*?worker` ambient type into any program that imports this module (apps' tsc).
2
+ /// <reference path="../monaco-workers.d.ts" />
3
+ /**
4
+ * Monaco web-worker wiring for Vite apps.
5
+ *
6
+ * Monaco runs language intelligence (TS/JS diagnostics, JSON schema, CSS/HTML
7
+ * services) in web workers. This module registers a `MonacoEnvironment.getWorker`
8
+ * factory using Vite's `?worker` import suffix, which makes Vite bundle each
9
+ * worker entry. Import it ONCE at the app entry, before any editor mounts:
10
+ *
11
+ * import "@elabs-ai/components-editor/monaco-environment";
12
+ *
13
+ * Without this, the editor still renders + highlights, but completions,
14
+ * diagnostics and hovers are unavailable.
15
+ *
16
+ * NOTE: `?worker` is a Vite-only transform. Non-Vite consumers must provide
17
+ * their own `self.MonacoEnvironment.getWorker(Url)` — see the package README.
18
+ */
19
+ import type { Environment } from "monaco-editor";
20
+ import EditorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker";
21
+ import JsonWorker from "monaco-editor/esm/vs/language/json/json.worker?worker";
22
+ import CssWorker from "monaco-editor/esm/vs/language/css/css.worker?worker";
23
+ import HtmlWorker from "monaco-editor/esm/vs/language/html/html.worker?worker";
24
+ import TsWorker from "monaco-editor/esm/vs/language/typescript/ts.worker?worker";
25
+
26
+ /**
27
+ * Monaco mints its Trusted-Types policies through this hook when it is present
28
+ * (`vs/base/browser/trustedTypes.js`, and the pre-bundled copy in
29
+ * `vs/common/workers.js`), falling back to `trustedTypes.createPolicy` when it is
30
+ * not. We supply it to **de-duplicate by name**.
31
+ *
32
+ * Why: `monaco-editor@0.55` ships the "mint `defaultWorkerFactory` at module
33
+ * init" block TWICE, in two different source files —
34
+ * `vs/base/browser/webWorkerFactory.js` (reached from `editorWorkerService`) and
35
+ * `vs/common/workers.js` (the pre-bundled `createWebWorker` API surface reached
36
+ * from `editor.api`). Both are in the main-thread graph, so both run. No bundler
37
+ * can dedupe them: they are distinct source files, not one module resolved twice.
38
+ *
39
+ * Under `require-trusted-types-for 'script'` the second call is refused —
40
+ * "a TrustedTypePolicy with that name already exists and the directive does not
41
+ * contain 'allow-duplicates'" — and Monaco reports it through
42
+ * `onUnexpectedError`. Memoizing here fixes it at the source instead of making
43
+ * every consumer widen their CSP with `'allow-duplicates'`, which would let ANY
44
+ * script re-register a policy name.
45
+ *
46
+ * A no-op where Trusted Types is unsupported (Firefox, Safari): returns
47
+ * `undefined`, exactly as Monaco's own fallback does, and Monaco then uses the
48
+ * raw string. A refused name also returns `undefined` (the browser still logs the
49
+ * CSP violation, so a missing allowlist entry stays visible) rather than throwing.
50
+ *
51
+ * See `docs/CSP-AND-NETWORK.md` §2.6.
52
+ */
53
+ type TrustedTypesHook = NonNullable<Environment["createTrustedTypesPolicy"]>;
54
+ type MonacoPolicy = ReturnType<TrustedTypesHook>;
55
+ type PolicyOptions = Parameters<TrustedTypesHook>[1];
56
+
57
+ /** `lib.dom` carries no Trusted-Types types, so the factory is typed structurally. */
58
+ interface TrustedTypesGlobal {
59
+ trustedTypes?: { createPolicy(name: string, options?: PolicyOptions): MonacoPolicy };
60
+ }
61
+
62
+ const policyCache = new Map<string, MonacoPolicy>();
63
+
64
+ const monacoEnvironment: Environment = {
65
+ createTrustedTypesPolicy(policyName, policyOptions) {
66
+ if (policyCache.has(policyName)) return policyCache.get(policyName);
67
+
68
+ let policy: MonacoPolicy;
69
+ try {
70
+ policy = (globalThis as unknown as TrustedTypesGlobal).trustedTypes?.createPolicy(
71
+ policyName,
72
+ policyOptions,
73
+ );
74
+ } catch {
75
+ policy = undefined;
76
+ }
77
+
78
+ policyCache.set(policyName, policy);
79
+ return policy;
80
+ },
81
+
82
+ getWorker(_workerId: string, label: string): Worker {
83
+ switch (label) {
84
+ case "json":
85
+ return new JsonWorker();
86
+ case "css":
87
+ case "scss":
88
+ case "less":
89
+ return new CssWorker();
90
+ case "html":
91
+ case "handlebars":
92
+ case "razor":
93
+ return new HtmlWorker();
94
+ case "typescript":
95
+ case "javascript":
96
+ return new TsWorker();
97
+ default:
98
+ return new EditorWorker();
99
+ }
100
+ },
101
+ };
102
+
103
+ (globalThis as unknown as { MonacoEnvironment: Environment }).MonacoEnvironment = monacoEnvironment;