@elabs-ai/components-editor 5.1.0 → 5.3.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.
package/dist/index.js CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  buildBrandThemeData,
11
11
  monacoContentAccess,
12
12
  useDataTheme
13
- } from "./chunk-FO5S3YZM.js";
13
+ } from "./chunk-VTGYYWGH.js";
14
14
 
15
15
  // src/diff-editor/diff-editor.tsx
16
16
  import { cn } from "@elabs-ai/components-ui/lib/cn";
@@ -54,7 +54,7 @@ import {
54
54
  transposeIterationValue,
55
55
  triggerQueryStart,
56
56
  useMarkdownOutline
57
- } from "../chunk-FO5S3YZM.js";
57
+ } from "../chunk-VTGYYWGH.js";
58
58
 
59
59
  // src/lib/editor-completions-monaco.ts
60
60
  import * as monaco from "monaco-editor";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elabs-ai/components-editor",
3
- "version": "5.1.0",
3
+ "version": "5.3.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -55,6 +55,8 @@
55
55
  "katex": "^0.16.11",
56
56
  "lucide-react": "^0.577.0",
57
57
  "mermaid": "^11.15.0",
58
+ "mdast-util-directive": "^3.1.0",
59
+ "micromark-extension-directive": "^4.0.0",
58
60
  "remark-directive": "^4.0.0",
59
61
  "remark-frontmatter": "^5.0.0",
60
62
  "remark-gfm": "^4.0.1",
@@ -69,8 +71,8 @@
69
71
  "monaco-editor": "^0.55.1",
70
72
  "react": "^18.2.0 || ^19.0.0",
71
73
  "react-dom": "^18.2.0 || ^19.0.0",
72
- "@elabs-ai/components-tokens": "^5.1.0",
73
- "@elabs-ai/components-ui": "^5.1.0"
74
+ "@elabs-ai/components-tokens": "^5.3.0",
75
+ "@elabs-ai/components-ui": "^5.3.0"
74
76
  },
75
77
  "devDependencies": {
76
78
  "@testing-library/jest-dom": "^6.6.3",
@@ -90,9 +92,9 @@
90
92
  "typescript": "^5.7.3",
91
93
  "vitest": "^3.0.2",
92
94
  "@elabs-ai/components-eslint-config": "0.1.0",
93
- "@elabs-ai/components-tokens": "5.1.0",
95
+ "@elabs-ai/components-tokens": "5.3.0",
94
96
  "@elabs-ai/components-typescript-config": "0.1.0",
95
- "@elabs-ai/components-ui": "5.1.0"
97
+ "@elabs-ai/components-ui": "5.3.0"
96
98
  },
97
99
  "scripts": {
98
100
  "build": "rm -rf dist && tsup && node ../../scripts/link-dist-css.mjs",
@@ -15,7 +15,9 @@
15
15
  */
16
16
  import type { MilkdownPlugin } from "@milkdown/kit/ctx";
17
17
  import { $nodeSchema, $remark } from "@milkdown/kit/utils";
18
- import remarkDirective from "remark-directive";
18
+ import { directiveFromMarkdown, directiveToMarkdown } from "mdast-util-directive";
19
+ import { directive } from "micromark-extension-directive";
20
+ import type { Processor } from "unified";
19
21
 
20
22
  interface DirectiveMdast {
21
23
  type: string;
@@ -24,8 +26,33 @@ interface DirectiveMdast {
24
26
  children?: unknown[];
25
27
  }
26
28
 
27
- /** Add remark-directive to Milkdown's unified processor (parse + stringify). */
28
- export const directiveRemark = $remark("brandDirective", () => remarkDirective);
29
+ /**
30
+ * remark-directive, BLOCK forms only (`::leaf`, `:::container`).
31
+ *
32
+ * The stock plugin also parses every inline `:word` as a TEXT directive — a
33
+ * `${{msr:id:Title}}` placeholder, `ratio :a`, an emoji shortcode — which the brand has
34
+ * no vocabulary for and which crashed the editor ("Cannot match target parser"); its
35
+ * serializer then escaped every `word:Word` as `word\:Word`. This variant drops the
36
+ * inline tokenizer and the matching escape rule, so inline colons stay ordinary text
37
+ * both ways.
38
+ */
39
+ function remarkBlockDirectives(this: Processor): void {
40
+ const data = this.data() as Record<string, unknown[] | undefined>;
41
+ const micromark = (data.micromarkExtensions ??= []);
42
+ const from = (data.fromMarkdownExtensions ??= []);
43
+ const to = (data.toMarkdownExtensions ??= []);
44
+ const { flow } = directive();
45
+ micromark.push({ flow });
46
+ from.push(directiveFromMarkdown());
47
+ const serializer = directiveToMarkdown();
48
+ serializer.unsafe = (serializer.unsafe ?? []).filter(
49
+ (rule) => !(rule.character === ":" && rule.inConstruct !== undefined && !rule.atBreak),
50
+ );
51
+ to.push(serializer);
52
+ }
53
+
54
+ /** Add block directives to Milkdown's unified processor (parse + stringify). */
55
+ export const directiveRemark = $remark("brandDirective", () => remarkBlockDirectives);
29
56
 
30
57
  /** `:::name` block directives → an editable container node with branded chrome. */
31
58
  export const containerDirectiveSchema = $nodeSchema("brand_container_directive", () => ({
@@ -103,3 +103,15 @@ test("#37 — an unknown container directive is role=note, not an assertive aler
103
103
  // It must NOT be an assertive live region (role=alert) that re-announces on re-parse.
104
104
  expect(screen.queryByRole("alert")).toBeNull();
105
105
  });
106
+
107
+ test("inline `word:Word` (e.g. `${{msr:id:Title}}` placeholders) stays plain text, unescaped, both ways", async () => {
108
+ const ref = createRef<MarkdownEditorHandle>();
109
+ const doc = "Revenue: ${{msr:rev-ytd:Revenue YTD}} and a ratio :a\n";
110
+ const { container } = render(<MarkdownEditor ref={ref} defaultValue={doc} />);
111
+ await waitFor(() => expect(container.textContent).toContain("${{msr:rev-ytd:Revenue YTD}}"));
112
+ const md = ref.current!.getMarkdown();
113
+ expect(md).toContain(":rev-ytd");
114
+ expect(md).toContain(":Revenue");
115
+ expect(md).toContain("ratio :a");
116
+ expect(md).not.toContain("\\:");
117
+ });
@@ -0,0 +1,37 @@
1
+ import { createRef } from "react";
2
+ import { cleanup, render, waitFor } from "@testing-library/react";
3
+ import { afterEach, expect, test } from "vitest";
4
+
5
+ import { MarkdownEditor, type MarkdownEditorHandle } from "./markdown-editor";
6
+
7
+ afterEach(cleanup);
8
+
9
+ async function mount(doc: string) {
10
+ const ref = createRef<MarkdownEditorHandle>();
11
+ const { container } = render(<MarkdownEditor ref={ref} defaultValue={doc} />);
12
+ await waitFor(() =>
13
+ expect(container.querySelector(".ProseMirror")?.textContent).toContain("Hello"),
14
+ );
15
+ return ref;
16
+ }
17
+
18
+ test("insertAtCursor lands a one-line fragment INLINE, not as a new block", async () => {
19
+ const ref = await mount("Hello\n");
20
+ ref.current!.focus();
21
+ ref.current!.insertAtCursor("${{variables.target}} **bold**");
22
+ await waitFor(() =>
23
+ expect(ref.current!.getMarkdown()).toContain("${{variables.target}} **bold**Hello"),
24
+ );
25
+ // One paragraph — the fragment joined the existing line.
26
+ const md = ref.current!.getMarkdown().trim();
27
+ expect(md.split(/\n\s*\n/)).toHaveLength(1);
28
+ expect(md).toContain("Hello");
29
+ });
30
+
31
+ test("insertAtCursor keeps multi-block markdown as blocks", async () => {
32
+ const ref = await mount("Hello\n");
33
+ ref.current!.focus();
34
+ ref.current!.insertAtCursor("# Title\n\nBody");
35
+ await waitFor(() => expect(ref.current!.getMarkdown()).toContain("# Title"));
36
+ expect(ref.current!.getMarkdown()).toContain("Body");
37
+ });
@@ -33,7 +33,7 @@ import { commonmark } from "@milkdown/kit/preset/commonmark";
33
33
  import { gfm } from "@milkdown/kit/preset/gfm";
34
34
  import { history } from "@milkdown/kit/plugin/history";
35
35
  import { listener, listenerCtx } from "@milkdown/kit/plugin/listener";
36
- import type { Node as ProseNode } from "@milkdown/kit/prose/model";
36
+ import { Slice, type Node as ProseNode } from "@milkdown/kit/prose/model";
37
37
  import { TextSelection } from "@milkdown/kit/prose/state";
38
38
  import type { EditorView } from "@milkdown/kit/prose/view";
39
39
  import { getMarkdown, replaceAll } from "@milkdown/kit/utils";
@@ -430,8 +430,14 @@ const MarkdownEditorView = forwardRef<MarkdownEditorHandle, ViewProps>(function
430
430
  view.dispatch(view.state.tr.insertText(md));
431
431
  return;
432
432
  }
433
- // Replace the current selection with the parsed content.
434
- const tr = view.state.tr.replaceSelectionWith(parsed);
433
+ // A single paragraph lands INLINE at the caret (a `**bold**` fragment, a
434
+ // `${{placeholder}}` from an insert rail); anything else replaces the
435
+ // selection as blocks.
436
+ const only = parsed.childCount === 1 ? parsed.firstChild : null;
437
+ const tr =
438
+ only && only.type.name === "paragraph"
439
+ ? view.state.tr.replaceSelection(new Slice(only.content, 0, 0))
440
+ : view.state.tr.replaceSelectionWith(parsed);
435
441
  view.dispatch(tr);
436
442
  });
437
443
  } catch {