@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,342 @@
1
+ /**
2
+ * Shared remark pipeline for the brand markdown dialect.
3
+ *
4
+ * `remark-directive` parses generic container/leaf/text directives (`:::name`,
5
+ * `::name`, `:name`). `remarkBrandDirectives` then rewrites the four brand blocks
6
+ * — :::card / :::callout / ::metric / :::timeline — into a single `<brand-directive>`
7
+ * element carrying a JSON `data-brand` payload, which the preview's components map
8
+ * turns into real @brand components. Unknown directive names are preserved + flagged
9
+ * so the preview can surface an "unknown block" error instead of silently dropping.
10
+ *
11
+ * One JSON attribute (not many) keeps it robust against Streamdown's HTML
12
+ * sanitization (we only need to allow a single attribute through). The SAME plugin
13
+ * array is fed to Streamdown (preview) and can be fed to Milkdown via `$remark`
14
+ * (editor), so both sides parse `:::card` identically.
15
+ */
16
+ import type { ReactNode } from "react";
17
+ import remarkDirective from "remark-directive";
18
+ import type { PluggableList } from "unified";
19
+ import { visit } from "unist-util-visit";
20
+
21
+ export const BRAND_DIRECTIVES = ["card", "callout", "metric", "timeline"] as const;
22
+ export type BrandDirectiveName = (typeof BRAND_DIRECTIVES)[number];
23
+
24
+ /** Custom element BLOCK / LEAF directives are rewritten to. */
25
+ export const BRAND_DIRECTIVE_TAG = "brand-directive";
26
+ /**
27
+ * Custom element INLINE (text) directives are rewritten to. A SEPARATE tag (not
28
+ * {@link BRAND_DIRECTIVE_TAG}) so the preview can render inline directives without
29
+ * the block `<div>` wrappers the annotation/search layer adds to block tags —
30
+ * keeping `:entity[name]` inside the text flow.
31
+ */
32
+ export const BRAND_DIRECTIVE_INLINE_TAG = "brand-directive-inline";
33
+ /** Rendered HTML attribute carrying the JSON payload (allow-list this in Streamdown). */
34
+ export const BRAND_DIRECTIVE_ATTR = "data-brand";
35
+ /**
36
+ * hast PROPERTY name (camelCase) for {@link BRAND_DIRECTIVE_ATTR}. This — not the
37
+ * rendered `data-brand` — is what Streamdown's `allowedTags` must list to let the
38
+ * payload survive sanitization.
39
+ */
40
+ export const BRAND_DIRECTIVE_PROP = "dataBrand";
41
+
42
+ /** Which directive syntax produced a node: `:::block`, `::leaf`, or `:inline`. */
43
+ export type MarkdownDirectiveKind = "container" | "leaf" | "inline";
44
+
45
+ export interface BrandDirectivePayload {
46
+ name: string;
47
+ known: boolean;
48
+ kind: MarkdownDirectiveKind;
49
+ attributes: Record<string, string>;
50
+ items?: { title: string; status: string }[];
51
+ /**
52
+ * RAW label source for inline/leaf directives (`:calc[85 * 32]` → `85 * 32`).
53
+ * Captured from source positions so markdown-significant characters in the
54
+ * label (`*`, `_`, `[`) survive intact — the rendered children would mangle
55
+ * them. Renderers that need the verbatim expression (calc) read this.
56
+ */
57
+ label?: string;
58
+ /**
59
+ * RAW container body source (the markdown between the `:::name` fences),
60
+ * captured only for the directive names passed in `rawBodyNames` — e.g. the
61
+ * per-cell TEMPLATE of an `:::iterate` block, which must be interpolated then
62
+ * rendered, NOT shown pre-rendered. Captured from source positions.
63
+ */
64
+ body?: string;
65
+ }
66
+
67
+ /* ------------------------------------------------------------------ */
68
+ /* Extension registry — the consumer-supplied render seam (the library */
69
+ /* RENDERS; the consumer brings the renderer + any domain hook). One */
70
+ /* registry feeds BOTH the parse-side known-set and the render dispatch. */
71
+ /* ------------------------------------------------------------------ */
72
+
73
+ /** Context handed to a consumer directive renderer. */
74
+ export interface MarkdownDirectiveContext {
75
+ name: string;
76
+ /** Which syntax matched — a renderer can branch (e.g. inline chip vs block card). */
77
+ kind: MarkdownDirectiveKind;
78
+ /** Directive attributes (`{key=value}`), strings only. */
79
+ attributes: Record<string, string>;
80
+ /** Rendered body (container), label (inline), or empty (leaf). */
81
+ children: ReactNode;
82
+ /**
83
+ * RAW label text for inline/leaf directives (markdown-significant characters
84
+ * preserved). Use this — not `children` — when you need the verbatim source
85
+ * (e.g. a calc expression `85 * 32`). `undefined` for container directives.
86
+ */
87
+ textValue?: string;
88
+ /**
89
+ * RAW container body markdown (the source between the `:::name` fences). Only
90
+ * populated for directives registered with `rawBodyNames` (e.g. `iterate` /
91
+ * `pivot`), where the body is a TEMPLATE to interpolate + render per cell, not
92
+ * to display pre-rendered. `undefined` otherwise.
93
+ */
94
+ rawBody?: string;
95
+ }
96
+
97
+ /** Register a custom `:::name` / `::name` / `:name` directive renderer. */
98
+ export interface MarkdownDirectiveRenderer {
99
+ /** Directive name, e.g. `"decision"`, `"entity"`. */
100
+ name: string;
101
+ /** Accepted syntaxes. Default: all three. */
102
+ kinds?: readonly MarkdownDirectiveKind[];
103
+ render: (ctx: MarkdownDirectiveContext) => ReactNode;
104
+ }
105
+
106
+ /** Context handed to a consumer fence renderer. */
107
+ export interface MarkdownFenceContext {
108
+ /** The fence body (trailing newline stripped). */
109
+ source: string;
110
+ /** The info-string language, e.g. `"calc"`. */
111
+ lang: string;
112
+ }
113
+
114
+ /** Register a custom ```lang fenced-block renderer (the mermaid/calc seam). */
115
+ export interface MarkdownFenceRenderer {
116
+ /** Info-string this renderer claims, e.g. `"calc"`. */
117
+ lang: string;
118
+ render: (ctx: MarkdownFenceContext) => ReactNode;
119
+ }
120
+
121
+ /**
122
+ * Consumer extensions to the brand markdown dialect — passed to `MarkdownPreview`
123
+ * via the `extensions` prop. Registered directive NAMES are also fed to the parser
124
+ * (so `:entity[…]` is rewritten, while an unregistered `:foo` in prose stays
125
+ * literal text), and the renderers drive the preview's dispatch. The engine is
126
+ * never forked: new blocks register here.
127
+ */
128
+ export interface MarkdownExtensions {
129
+ directives?: readonly MarkdownDirectiveRenderer[];
130
+ fences?: readonly MarkdownFenceRenderer[];
131
+ }
132
+
133
+ interface MdNode {
134
+ type: string;
135
+ name?: string;
136
+ value?: string;
137
+ attributes?: Record<string, string | null | undefined>;
138
+ children?: MdNode[];
139
+ position?: { start?: { offset?: number }; end?: { offset?: number } };
140
+ data?: { hName?: string; hProperties?: Record<string, unknown> };
141
+ }
142
+
143
+ const DIRECTIVE_TYPES = new Set(["containerDirective", "leafDirective", "textDirective"]);
144
+
145
+ function mdastText(node: MdNode): string {
146
+ if (typeof node.value === "string") return node.value;
147
+ if (node.children) return node.children.map(mdastText).join("");
148
+ return "";
149
+ }
150
+
151
+ function extractTimelineItems(node: MdNode): { title: string; status: string }[] {
152
+ const list = node.children?.find((c) => c.type === "list");
153
+ if (!list?.children) return [];
154
+ // Status marker is a leading `(done)` / `(active)` / `(pending)` — parentheses
155
+ // avoid markdown's `[ref]` link-reference collision. Default: pending.
156
+ const MARKER = /^\((done|complete|completed|active|current|pending|todo)\)\s*/i;
157
+ return list.children
158
+ .filter((c) => c.type === "listItem")
159
+ .map((li) => {
160
+ let title = mdastText(li).trim();
161
+ let status = "pending";
162
+ const marker = title.match(MARKER);
163
+ if (marker) {
164
+ status = marker[1]!.toLowerCase();
165
+ title = title.slice(marker[0].length).trim();
166
+ }
167
+ return { title, status };
168
+ });
169
+ }
170
+
171
+ function cleanAttributes(attrs: MdNode["attributes"]): Record<string, string> {
172
+ const out: Record<string, string> = {};
173
+ for (const [k, v] of Object.entries(attrs ?? {})) {
174
+ if (typeof v === "string") out[k] = v;
175
+ }
176
+ return out;
177
+ }
178
+
179
+ /**
180
+ * Reconstruct a directive's ORIGINAL source text. Prose colons routinely
181
+ * pattern-match remark-directive's text/leaf forms (`qwen3:0.6b` parses as a
182
+ * `:0` text directive and swallows the "0") — restoring the source slice is
183
+ * the only faithful undo.
184
+ */
185
+ function originalText(node: MdNode, source: string | undefined): string {
186
+ const start = node.position?.start?.offset;
187
+ const end = node.position?.end?.offset;
188
+ if (source != null && typeof start === "number" && typeof end === "number") {
189
+ return source.slice(start, end);
190
+ }
191
+ // Position unavailable — best-effort reconstruction.
192
+ const colons = node.type === "leafDirective" ? "::" : ":";
193
+ const label = node.children?.length ? `[${mdastText(node)}]` : "";
194
+ return `${colons}${node.name ?? ""}${label}`;
195
+ }
196
+
197
+ /** mdast node type → the directive kind it represents. */
198
+ function directiveKind(type: string): MarkdownDirectiveKind {
199
+ if (type === "containerDirective") return "container";
200
+ if (type === "leafDirective") return "leaf";
201
+ return "inline";
202
+ }
203
+
204
+ /**
205
+ * The RAW label source of a directive (`:name[label]`) — sliced from the original
206
+ * source via the label children's positions, so markdown-significant characters
207
+ * (`*`, `_`, `[`) survive instead of being parsed into emphasis/links.
208
+ */
209
+ function rawLabel(node: MdNode, source: string | undefined): string | undefined {
210
+ const kids = node.children;
211
+ if (!source || !kids || kids.length === 0) return undefined;
212
+ const start = kids[0]?.position?.start?.offset;
213
+ const end = kids[kids.length - 1]?.position?.end?.offset;
214
+ if (typeof start === "number" && typeof end === "number") return source.slice(start, end);
215
+ return undefined;
216
+ }
217
+
218
+ /**
219
+ * The RAW body source of a CONTAINER directive — the markdown between the
220
+ * `:::name{…}` opening and the closing `:::`. Skips a leading directive label
221
+ * (`:::name[label]`) child. Used for templated containers (`:::iterate`) whose
222
+ * body must be interpolated then rendered, not shown pre-rendered.
223
+ */
224
+ function rawContainerBody(node: MdNode, source: string | undefined): string | undefined {
225
+ const kids = node.children;
226
+ if (!source || !kids || kids.length === 0) return undefined;
227
+ const body = kids.filter(
228
+ (c) => !(c.data as { directiveLabel?: boolean } | undefined)?.directiveLabel,
229
+ );
230
+ if (body.length === 0) return undefined;
231
+ const start = body[0]?.position?.start?.offset;
232
+ const end = body[body.length - 1]?.position?.end?.offset;
233
+ if (typeof start === "number" && typeof end === "number") return source.slice(start, end).trim();
234
+ return undefined;
235
+ }
236
+
237
+ /**
238
+ * remark transform: brand directives → `<brand-directive data-brand="{json}">`
239
+ * (block/leaf) or `<brand-directive-inline …>` (inline).
240
+ *
241
+ * `knownNames` is the set of directive names to TREAT AS KNOWN — i.e. rewrite
242
+ * rather than restore. It defaults to the four built-ins; `MarkdownPreview`
243
+ * extends it with the names a consumer registered via `extensions`, so a
244
+ * registered `:entity[…]` is rewritten while an unregistered `:foo` (or a stray
245
+ * prose colon) is still restored to literal text.
246
+ */
247
+ export function remarkBrandDirectives(
248
+ knownNames: readonly string[] = BRAND_DIRECTIVES,
249
+ rawBodyNames: readonly string[] = [],
250
+ ) {
251
+ return (tree: unknown, file?: { value?: unknown }) => {
252
+ const source = typeof file?.value === "string" ? file.value : undefined;
253
+ visit(tree as never, (node: MdNode, index: number | undefined, parent: MdNode | undefined) => {
254
+ if (!DIRECTIVE_TYPES.has(node.type) || !node.name) return undefined;
255
+
256
+ const name = node.name;
257
+ const known = knownNames.includes(name);
258
+ const kind = directiveKind(node.type);
259
+
260
+ // Unknown text/leaf directives are almost always FALSE POSITIVES from
261
+ // ordinary colons in prose — restore them as literal text. Only an
262
+ // unknown CONTAINER (`:::name`, deliberately authored) keeps the
263
+ // explicit unknown-block error.
264
+ if (!known && node.type !== "containerDirective" && parent?.children && index != null) {
265
+ const literal: MdNode = { type: "text", value: originalText(node, source) };
266
+ parent.children.splice(
267
+ index,
268
+ 1,
269
+ node.type === "leafDirective"
270
+ ? ({ type: "paragraph", children: [literal] } as MdNode)
271
+ : literal,
272
+ );
273
+ return index + 1; // continue after the replacement
274
+ }
275
+
276
+ const payload: BrandDirectivePayload = {
277
+ name,
278
+ known,
279
+ kind,
280
+ attributes: cleanAttributes(node.attributes),
281
+ };
282
+
283
+ // Inline/leaf directives carry a verbatim label (e.g. a calc expression).
284
+ if (kind !== "container") {
285
+ const label = rawLabel(node, source);
286
+ if (label != null) payload.label = label;
287
+ }
288
+
289
+ // Templated containers (`:::iterate`/`:::pivot`) carry their RAW body so the
290
+ // renderer can interpolate + render it per cell. Clear the children so the
291
+ // template is NOT also rendered pre-interpolated (mirrors timeline).
292
+ if (kind === "container" && rawBodyNames.includes(name)) {
293
+ const body = rawContainerBody(node, source);
294
+ if (body != null) payload.body = body;
295
+ node.children = [];
296
+ }
297
+
298
+ if (name === "timeline") {
299
+ payload.items = extractTimelineItems(node);
300
+ node.children = []; // consumed into payload.items
301
+ }
302
+
303
+ const data = node.data ?? (node.data = {});
304
+ // Inline directives keep their children (the label) and use a SEPARATE tag
305
+ // so the preview renders them inline (no block wrapper).
306
+ data.hName = kind === "inline" ? BRAND_DIRECTIVE_INLINE_TAG : BRAND_DIRECTIVE_TAG;
307
+ // camelCase hast property → renders as the `data-brand` attribute.
308
+ data.hProperties = { [BRAND_DIRECTIVE_PROP]: JSON.stringify(payload) };
309
+ });
310
+ };
311
+ }
312
+
313
+ /** Options for {@link buildMarkdownPlugins}. */
314
+ export interface BuildMarkdownPluginsOptions {
315
+ /**
316
+ * Extra directive names to treat as known (rewritten, not restored as literal
317
+ * text). The four built-ins are always known; pass the names a consumer
318
+ * registered via `extensions.directives`.
319
+ */
320
+ directiveNames?: readonly string[];
321
+ /**
322
+ * Container directive names whose RAW body should be captured (as
323
+ * `payload.body` / `ctx.rawBody`) instead of pre-rendered — for templated
324
+ * blocks like `iterate` / `pivot` whose body is interpolated per cell.
325
+ */
326
+ rawBodyNames?: readonly string[];
327
+ }
328
+
329
+ /**
330
+ * The shared remark plugin array (directive parsing + brand mapping). Pass
331
+ * `directiveNames` to recognize consumer-registered directives without forking
332
+ * the engine; with no options it parses exactly the four built-ins (backward
333
+ * compatible).
334
+ */
335
+ export function buildMarkdownPlugins(options: BuildMarkdownPluginsOptions = {}): PluggableList {
336
+ const known =
337
+ options.directiveNames && options.directiveNames.length > 0
338
+ ? [...BRAND_DIRECTIVES, ...options.directiveNames]
339
+ : BRAND_DIRECTIVES;
340
+ const rawBodyNames = options.rawBodyNames ?? [];
341
+ return [remarkDirective, [remarkBrandDirectives, known, rawBodyNames]];
342
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * YAML frontmatter parse/serialize. Splits the leading `---` block off a markdown
3
+ * document so the body can be rendered/edited and the metadata can drive a form.
4
+ *
5
+ * `parseFrontmatter` throws on malformed YAML — callers (the app) catch and map it
6
+ * to a typed error for the metadata panel.
7
+ */
8
+ import { dump, load } from "js-yaml";
9
+
10
+ export interface ParsedDocument {
11
+ /** Parsed frontmatter object ({} when there is no frontmatter block). */
12
+ frontmatter: Record<string, unknown>;
13
+ /** Markdown body with the frontmatter block removed. */
14
+ content: string;
15
+ hasFrontmatter: boolean;
16
+ }
17
+
18
+ // Optional leading BOM (\uFEFF), then a `---` … `---` block at the very top.
19
+ const FRONTMATTER_RE = /^\uFEFF?---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/;
20
+
21
+ export function parseFrontmatter(source: string): ParsedDocument {
22
+ const match = source.match(FRONTMATTER_RE);
23
+ if (!match) {
24
+ return { frontmatter: {}, content: source, hasFrontmatter: false };
25
+ }
26
+
27
+ const parsed = load(match[1] ?? "");
28
+ const frontmatter =
29
+ parsed && typeof parsed === "object" && !Array.isArray(parsed)
30
+ ? (parsed as Record<string, unknown>)
31
+ : {};
32
+
33
+ return {
34
+ frontmatter,
35
+ content: source.slice(match[0].length),
36
+ hasFrontmatter: true,
37
+ };
38
+ }
39
+
40
+ export function serializeFrontmatter(
41
+ frontmatter: Record<string, unknown>,
42
+ content: string,
43
+ ): string {
44
+ const body = content.replace(/^\s+/, "");
45
+ if (!frontmatter || Object.keys(frontmatter).length === 0) {
46
+ return body;
47
+ }
48
+ const yaml = dump(frontmatter, { lineWidth: -1, noRefs: true }).trimEnd();
49
+ return `---\n${yaml}\n---\n\n${body}`;
50
+ }
@@ -0,0 +1,65 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+
4
+ import { describe, expect, it } from "vitest";
5
+
6
+ import {
7
+ MARKDOWN_HEADING_REM,
8
+ MARKDOWN_HEADING_TRACKING,
9
+ MARKDOWN_HEADING_WEIGHT,
10
+ MARKDOWN_MEASURE,
11
+ markdownScaleVars,
12
+ } from "./markdown-scale";
13
+
14
+ /**
15
+ * Drift contract for issue #18: the markdown editor (CSS) and the preview (prose
16
+ * React) must render ONE heading scale + measure. This test pins both sides to the
17
+ * shared `markdown-scale` module and fails the moment either diverges.
18
+ */
19
+
20
+ // The Tailwind step the prose `Heading` uses per level (prose.tsx HEADING_SIZE),
21
+ // expressed in rem. This is the React side's contract: keep it equal to the scale.
22
+ const PROSE_HEADING_REM: Record<1 | 2 | 3 | 4 | 5 | 6, number> = {
23
+ 1: 1.5, // text-2xl
24
+ 2: 1.25, // text-xl
25
+ 3: 1.125, // text-lg
26
+ 4: 1, // text-base
27
+ 5: 0.875, // text-sm
28
+ 6: 0.875, // text-sm
29
+ };
30
+
31
+ describe("markdown-scale (shared editor↔preview source of truth)", () => {
32
+ it("the prose heading steps equal the canonical scale", () => {
33
+ expect(MARKDOWN_HEADING_REM).toEqual(PROSE_HEADING_REM);
34
+ });
35
+
36
+ it("emits the scale as CSS vars for the editor host", () => {
37
+ const vars = markdownScaleVars();
38
+ expect(vars["--md-h1"]).toBe(`${MARKDOWN_HEADING_REM[1]}rem`);
39
+ expect(vars["--md-h6"]).toBe(`${MARKDOWN_HEADING_REM[6]}rem`);
40
+ expect(vars["--md-heading-weight"]).toBe(String(MARKDOWN_HEADING_WEIGHT));
41
+ expect(vars["--md-heading-tracking"]).toBe(MARKDOWN_HEADING_TRACKING);
42
+ expect(vars["--md-measure"]).toBe(MARKDOWN_MEASURE);
43
+ });
44
+
45
+ it("the editor CSS drives its headings + measure from the scale vars (no hardcoded sizes)", () => {
46
+ // Resolve from the @elabs-ai/components-editor package root (vitest runs with cwd = package).
47
+ const cssPath = resolve(process.cwd(), "src/markdown-editor/markdown-editor.css");
48
+ const css = readFileSync(cssPath, "utf8");
49
+
50
+ // Each heading reads its --md-* var (so the editor can't carry its own numbers).
51
+ for (const level of [1, 2, 3, 4, 5, 6] as const) {
52
+ expect(css).toContain(`font-size: var(--md-h${level}`);
53
+ }
54
+ expect(css).toContain("font-weight: var(--md-heading-weight");
55
+ expect(css).toContain("letter-spacing: var(--md-heading-tracking");
56
+ // Measure: capped at the shared --md-measure (same as the preview's max-w-3xl).
57
+ expect(css).toContain("max-width: var(--md-measure");
58
+
59
+ // Guard against re-introducing the OLD standalone heading scale (the editor
60
+ // used to carry its own 1.6 / 1.35 / 1.15 / 0.9rem heading sizes — #18).
61
+ for (const old of ["1.6rem", "1.35rem", "1.15rem", "0.9rem"]) {
62
+ expect(css).not.toContain(`font-size: ${old}`);
63
+ }
64
+ });
65
+ });
@@ -0,0 +1,62 @@
1
+ /**
2
+ * markdown-scale — the markdown visual scale shared by BOTH renderers of the
3
+ * brand markdown dialect:
4
+ *
5
+ * • the Streamdown preview (React: `prose/prose.tsx` Heading + the `components` map)
6
+ * • the Milkdown WYSIWYG editor (CSS: `markdown-editor/markdown-editor.css`)
7
+ *
8
+ * The two are independent renderers (the editor is ProseMirror-native by design and
9
+ * cannot import the React components — see directive-nodes.ts), so without a shared,
10
+ * machine-consumable scale they drift: switching Source → Split → Preview-edit
11
+ * visibly re-skins headings / measure (issue #18).
12
+ *
13
+ * Since #188 the numbers are DERIVED, not re-hardcoded: @elabs-ai/components-ui owns the canonical
14
+ * reading scale (`PROSE_HEADING_REM` in `components/typography/prose.tsx`, itself
15
+ * pinned to the `--text-<role>` tokens where the rungs coincide). This module is the
16
+ * editor-side seam: it re-exports those numbers and emits them as CSS variables (set
17
+ * on `.milkdown-host` via `markdownScaleVars()`); `markdown-scale.test.ts` fails the
18
+ * moment either renderer diverges.
19
+ */
20
+ import {
21
+ PROSE_HEADING_REM,
22
+ PROSE_HEADING_TRACKING,
23
+ PROSE_HEADING_WEIGHT,
24
+ } from "@elabs-ai/components-ui";
25
+
26
+ export type MarkdownHeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
27
+
28
+ /**
29
+ * Canonical heading font-size per level, in rem — derived from the @elabs-ai/components-ui
30
+ * prose reading scale (h2/h4/h5/h6 == the title/subtitle/body role rems;
31
+ * h1/h3 are intermediate reading rungs).
32
+ */
33
+ export const MARKDOWN_HEADING_REM: Record<MarkdownHeadingLevel, number> = PROSE_HEADING_REM;
34
+
35
+ /** Canonical heading weight (Tailwind `font-semibold`) — derived from @elabs-ai/components-ui. */
36
+ export const MARKDOWN_HEADING_WEIGHT = PROSE_HEADING_WEIGHT;
37
+
38
+ /** Canonical heading letter-spacing (Tailwind `tracking-tight`) — derived from @elabs-ai/components-ui. */
39
+ export const MARKDOWN_HEADING_TRACKING = PROSE_HEADING_TRACKING;
40
+
41
+ /** Canonical reading measure (max content width). Mirrors `max-w-3xl`. */
42
+ export const MARKDOWN_MEASURE = "48rem";
43
+
44
+ /**
45
+ * The scale as CSS custom properties, to set on the editor host (`.milkdown-host`)
46
+ * so `markdown-editor.css` reads the SAME numbers as the prose components instead of
47
+ * hardcoding its own. Spread onto a `style` prop:
48
+ * `<div className="milkdown-host" style={markdownScaleVars()} />`
49
+ */
50
+ export function markdownScaleVars(): Record<string, string> {
51
+ return {
52
+ "--md-h1": `${MARKDOWN_HEADING_REM[1]}rem`,
53
+ "--md-h2": `${MARKDOWN_HEADING_REM[2]}rem`,
54
+ "--md-h3": `${MARKDOWN_HEADING_REM[3]}rem`,
55
+ "--md-h4": `${MARKDOWN_HEADING_REM[4]}rem`,
56
+ "--md-h5": `${MARKDOWN_HEADING_REM[5]}rem`,
57
+ "--md-h6": `${MARKDOWN_HEADING_REM[6]}rem`,
58
+ "--md-heading-weight": String(MARKDOWN_HEADING_WEIGHT),
59
+ "--md-heading-tracking": MARKDOWN_HEADING_TRACKING,
60
+ "--md-measure": MARKDOWN_MEASURE,
61
+ };
62
+ }
@@ -0,0 +1,64 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { mergeNormalizedEdit } from "./merge";
4
+
5
+ // Simulated serializer drift: the editor rewrites `*` bullets as `-` but
6
+ // keeps headings, blank lines, and prose byte-exact (markdown's blank lines
7
+ // are the anchors that make block-granularity merging work).
8
+ const O = "# Title\n\n* alpha\n* beta\n\npara one\n\nthe end";
9
+ const B = "# Title\n\n- alpha\n- beta\n\npara one\n\nthe end";
10
+
11
+ describe("mergeNormalizedEdit (WI-1 — lossless WYSIWYG edits)", () => {
12
+ it("returns the original byte-exact when the user changed nothing", () => {
13
+ expect(mergeNormalizedEdit(O, B, B)).toBe(O);
14
+ });
15
+
16
+ it("returns the edit untouched when there is no normalization drift", () => {
17
+ const n = O.replace("para one", "para one EDITED");
18
+ expect(mergeNormalizedEdit(O, O, n)).toBe(n);
19
+ });
20
+
21
+ it("a one-line edit changes exactly that line — normalized bullets stay original", () => {
22
+ const n = B.replace("para one", "para one EDITED");
23
+ expect(mergeNormalizedEdit(O, B, n)).toBe(O.replace("para one", "para one EDITED"));
24
+ });
25
+
26
+ it("an appended paragraph lands at the end, the rest stays original", () => {
27
+ const n = `${B}\n\nbrand new para`;
28
+ expect(mergeNormalizedEdit(O, B, n)).toBe(`${O}\n\nbrand new para`);
29
+ });
30
+
31
+ it("deleting a line inside a normalized block keeps the rest of the doc original", () => {
32
+ const n = B.replace("- beta\n", "");
33
+ const merged = mergeNormalizedEdit(O, B, n);
34
+ // The edited block is locally normalized (documented trade-off)…
35
+ expect(merged).toContain("- alpha");
36
+ expect(merged).not.toContain("beta");
37
+ // …but everything outside it keeps original bytes.
38
+ expect(merged).toContain("# Title");
39
+ expect(merged).toContain("para one");
40
+ expect(merged).toContain("the end");
41
+ });
42
+
43
+ it("an edit inside a normalized block normalizes only that block", () => {
44
+ const n = B.replace("- beta", "- beeee");
45
+ const merged = mergeNormalizedEdit(O, B, n);
46
+ expect(merged).toContain("- alpha");
47
+ expect(merged).toContain("- beeee");
48
+ expect(merged).toContain("para one\n\nthe end");
49
+ expect(merged.startsWith("# Title")).toBe(true);
50
+ });
51
+
52
+ it("an insertion between untouched blocks preserves both neighbors byte-exact", () => {
53
+ const n = B.replace("\npara one", "\ninserted line\n\npara one");
54
+ const merged = mergeNormalizedEdit(O, B, n);
55
+ expect(merged).toContain("* alpha\n* beta");
56
+ expect(merged).toContain("inserted line");
57
+ expect(merged).toContain("para one\n\nthe end");
58
+ });
59
+
60
+ it("falls back to the edited text for oversized inputs", () => {
61
+ const big = Array.from({ length: 6000 }, (_, i) => `line ${i}`).join("\n");
62
+ expect(mergeNormalizedEdit(big, "b", "n")).toBe("n");
63
+ });
64
+ });