@surrealdb/ui 1.1.1 → 1.2.1

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 (109) hide show
  1. package/.zed/settings.json +36 -0
  2. package/AGENTS.md +10 -5
  3. package/README.md +30 -0
  4. package/REVIEW.md +1 -1
  5. package/dist/assets/0d2c2f665b0f41ed.woff2 +0 -0
  6. package/dist/assets/12b57c6beacdbca0.woff2 +0 -0
  7. package/dist/assets/23645aad5ccc2b92.woff2 +0 -0
  8. package/dist/assets/8bbfa6e01a9e6a0f.woff2 +0 -0
  9. package/dist/assets/93fc40a807be6880.woff2 +0 -0
  10. package/dist/assets/9c9751ca111e97c2.woff2 +0 -0
  11. package/dist/assets/9ff55a8a9670220d.woff2 +0 -0
  12. package/dist/assets/a865edea076e0166.woff2 +0 -0
  13. package/dist/assets/b921df26851c5aca.woff2 +0 -0
  14. package/dist/assets/c6a3f4e555097159.woff2 +0 -0
  15. package/dist/assets/c6c31cb1350b2544.woff2 +0 -0
  16. package/dist/fonts.css +1 -0
  17. package/dist/fonts.js +2 -0
  18. package/dist/fonts.js.map +1 -0
  19. package/dist/ui.css +1 -1
  20. package/dist/ui.d.ts +537 -523
  21. package/dist/ui.js +16328 -14682
  22. package/dist/ui.js.map +1 -1
  23. package/package.json +22 -24
  24. package/tests/_setup/e2e-helpers.tsx +169 -0
  25. package/tests/_setup/markdown-classes.ts +3 -0
  26. package/tests/_setup/portable-stories.ts +10 -0
  27. package/tests/e2e/MarkdownEditor/__screenshots__/content-blocks.test.tsx/MarkdownEditor---content-blocks-continues-an-ordered-list-when-Enter-is-pressed-at-the-end-of-a-numbered-line-1.png +0 -0
  28. package/tests/e2e/MarkdownEditor/__screenshots__/content-blocks.test.tsx/MarkdownEditor---content-blocks-renders-a-diagram-for-the-fenced-mermaid-block-1.png +0 -0
  29. package/tests/e2e/MarkdownEditor/__screenshots__/content-blocks.test.tsx/MarkdownEditor---content-blocks-renders-a-horizontal-rule-as-a-separator-1.png +0 -0
  30. package/tests/e2e/MarkdownEditor/__screenshots__/content-blocks.test.tsx/MarkdownEditor---content-blocks-renders-a-read-only-table-preview-when-the-table-block-is-inactive-1.png +0 -0
  31. package/tests/e2e/MarkdownEditor/__screenshots__/content-blocks.test.tsx/MarkdownEditor---content-blocks-shows-blockquote-text-from-the-sample-document-1.png +0 -0
  32. package/tests/e2e/MarkdownEditor/__screenshots__/content-blocks.test.tsx/MarkdownEditor---content-blocks-shows-fenced-TypeScript-sample-code-in-the-document-1.png +0 -0
  33. package/tests/e2e/MarkdownEditor/__screenshots__/edits.test.tsx/MarkdownEditor---edits-auto-continues-a-bullet-list-when-Enter-is-pressed-at-the-end-of-a-list-item-1.png +0 -0
  34. package/tests/e2e/MarkdownEditor/__screenshots__/edits.test.tsx/MarkdownEditor---edits-expands-triple-backtick-into-a-fenced-code-block-with-the-caret-on-the-empty-body-line-1.png +0 -0
  35. package/tests/e2e/MarkdownEditor/__screenshots__/edits.test.tsx/MarkdownEditor---edits-reflects-typed-characters-in-the-underlying-document-1.png +0 -0
  36. package/tests/e2e/MarkdownEditor/__screenshots__/heading-fold.test.tsx/MarkdownEditor---heading-folds-viewer--folds-and-unfolds-a-heading-section-via-the-margin-control-1.png +0 -0
  37. package/tests/e2e/MarkdownEditor/__screenshots__/hybrid-widgets.test.tsx/MarkdownEditor---hybrid-widgets-clicking-a-callout-header-focuses-the-editor-and-parks-the-caret-inside-the-callout--REASONING--4--1.png +0 -0
  38. package/tests/e2e/MarkdownEditor/__screenshots__/hybrid-widgets.test.tsx/MarkdownEditor---hybrid-widgets-keeps-list-bullets-consistent-after-hopping-the-caret-across-items-several-times-1.png +0 -0
  39. package/tests/e2e/MarkdownEditor/__screenshots__/hybrid-widgets.test.tsx/MarkdownEditor---hybrid-widgets-reveals-heading-marks-when-the-caret-enters-the-heading-line-and-hides-them-when-it-leaves-1.png +0 -0
  40. package/tests/e2e/MarkdownEditor/__screenshots__/hybrid-widgets.test.tsx/MarkdownEditor---hybrid-widgets-shows-the-right-callout-title-when-moving-focus-between-consecutive-callouts-1.png +0 -0
  41. package/tests/e2e/MarkdownEditor/__screenshots__/hybrid-widgets.test.tsx/MarkdownEditor---preview-widgets-clicking-a-callout-header-focuses-the-editor-with-the-caret-in-that-callout-1.png +0 -0
  42. package/tests/e2e/MarkdownEditor/__screenshots__/hybrid-widgets.test.tsx/MarkdownEditor---preview-widgets-keeps-list-bullets-consistent-after-hopping-the-caret-across-items-several-times-1.png +0 -0
  43. package/tests/e2e/MarkdownEditor/__screenshots__/hybrid-widgets.test.tsx/MarkdownEditor---preview-widgets-shows-the-right-callout-title-when-moving-focus-between-consecutive-callouts-1.png +0 -0
  44. package/tests/e2e/MarkdownEditor/__screenshots__/jsx-block-content.test.tsx/MarkdownEditor---block-JSX-components-clicking-the-edit-source-action-selects-the-component-source-range-1.png +0 -0
  45. package/tests/e2e/MarkdownEditor/__screenshots__/jsx-block-content.test.tsx/MarkdownEditor---block-JSX-components-renders-block-components-via-JsxBlockWidget-with-an-edit-source-action-1.png +0 -0
  46. package/tests/e2e/MarkdownEditor/__screenshots__/jsx-block-content.test.tsx/MarkdownEditor---inline-JSX-rendering-block-content-keeps-multiple-inline-JSX-widgets-on-the-same-line-side-by-side-1.png +0 -0
  47. package/tests/e2e/MarkdownEditor/__screenshots__/jsx-highlight.test.tsx/MarkdownEditor---JSX-attribute-highlighting-does-not-let-the-nested-HTML-parser-mis-highlight-attributes-after-an-expression-1.png +0 -0
  48. package/tests/e2e/MarkdownEditor/__screenshots__/jsx-selection.test.tsx/MarkdownEditor---JSX-selection-reveal-shows-JSX-widget-when-inactive-and-raw-source-when-caret-is-inside-the-tag-1.png +0 -0
  49. package/tests/e2e/MarkdownEditor/__screenshots__/media-sizing.test.tsx/MarkdownEditor---media-sizing-images-and-videos-do-not-exceed-the-editor-content-width-1.png +0 -0
  50. package/tests/e2e/MarkdownEditor/__screenshots__/media-sizing.test.tsx/MarkdownEditor---media-sizing-matches-MarkdownViewer-image-width-in-the-split-playground-1.png +0 -0
  51. package/tests/e2e/MarkdownEditor/__screenshots__/modes.test.tsx/MarkdownEditor---modes-standalone-MarkdownViewer-is-not-a-CodeMirror-surface-1.png +0 -0
  52. package/tests/e2e/MarkdownEditor/__screenshots__/modes.test.tsx/MarkdownEditor---modes-toggling-the-SegmentedControl-swaps-mode-without-remounting-the-editor-1.png +0 -0
  53. package/tests/e2e/MarkdownEditor/__screenshots__/regressions.test.tsx/MarkdownEditor---regressions-repeated-checkbox-toggles-do-not-duplicate-or-drift-task-markers-1.png +0 -0
  54. package/tests/e2e/MarkdownEditor/__screenshots__/regressions.test.tsx/MarkdownEditor---regressions-scroll-position-is-preserved-when-the-document-is-edited--chat-92584463--1.png +0 -0
  55. package/tests/e2e/MarkdownEditor/__screenshots__/regressions.test.tsx/MarkdownEditor---regressions-task-checkbox-toggles-between-checked-and-unchecked-in-the-source-document-1.png +0 -0
  56. package/tests/e2e/MarkdownEditor/__screenshots__/regressions.test.tsx/MarkdownEditor---regressions-toggle-via-checkbox-preserves-task-marker-widgets-visible-from-the-previous-caret-position-1.png +0 -0
  57. package/tests/e2e/MarkdownEditor/__screenshots__/slash-commands.test.tsx/MarkdownEditor---slash-commands-dismisses-on-Escape-and-leaves-the-slash-as-literal-text-1.png +0 -0
  58. package/tests/e2e/MarkdownEditor/__screenshots__/slash-commands.test.tsx/MarkdownEditor---slash-commands-highlights-the-slash-and-shows-an-Enter-command-placeholder-until-text-is-typed-1.png +0 -0
  59. package/tests/e2e/MarkdownEditor/__screenshots__/slash-commands.test.tsx/MarkdownEditor---slash-commands-opens-a-filtered--keyboard-navigable-menu-and-inserts-boilerplate-on-Enter-1.png +0 -0
  60. package/tests/e2e/MarkdownEditor/__screenshots__/undo-redo.test.tsx/MarkdownEditor---undo-and-redo-restores-undone-document-edits-via-redo-1.png +0 -0
  61. package/tests/e2e/MarkdownEditor/content-blocks.test.tsx +152 -0
  62. package/tests/e2e/MarkdownEditor/edits.test.tsx +111 -0
  63. package/tests/e2e/MarkdownEditor/heading-fold.test.tsx +44 -0
  64. package/tests/e2e/MarkdownEditor/hybrid-widgets.test.tsx +192 -0
  65. package/tests/e2e/MarkdownEditor/jsx-block-content.test.tsx +242 -0
  66. package/tests/e2e/MarkdownEditor/jsx-highlight.test.tsx +68 -0
  67. package/tests/e2e/MarkdownEditor/jsx-inline-badges.test.tsx +59 -0
  68. package/tests/e2e/MarkdownEditor/jsx-selection.test.tsx +43 -0
  69. package/tests/e2e/MarkdownEditor/link-placeholder.test.tsx +67 -0
  70. package/tests/e2e/MarkdownEditor/media-align.test.tsx +57 -0
  71. package/tests/e2e/MarkdownEditor/media-edit.test.tsx +63 -0
  72. package/tests/e2e/MarkdownEditor/media-sizing.test.tsx +123 -0
  73. package/tests/e2e/MarkdownEditor/modes.test.tsx +93 -0
  74. package/tests/e2e/MarkdownEditor/regressions.test.tsx +182 -0
  75. package/tests/e2e/MarkdownEditor/slash-commands.test.tsx +99 -0
  76. package/tests/e2e/MarkdownEditor/table-click.test.tsx +47 -0
  77. package/tests/e2e/MarkdownEditor/table-controls.test.tsx +56 -0
  78. package/tests/e2e/MarkdownEditor/table-format.test.tsx +41 -0
  79. package/tests/e2e/MarkdownEditor/undo-redo.test.tsx +38 -0
  80. package/tests/e2e/MarkdownViewer/__screenshots__/parity.test.tsx/MarkdownViewer---editor-parity-matches-computed-font-size-on-first-heading-1.png +0 -0
  81. package/tests/e2e/MarkdownViewer/__screenshots__/parity.test.tsx/MarkdownViewer---editor-parity-matches-counts-for-shared-structural-classes-1.png +0 -0
  82. package/tests/e2e/MarkdownViewer/__screenshots__/parity.test.tsx/MarkdownViewer---editor-parity-matches-visible-text-between-preview-editor--blurred--and-MarkdownViewer-1.png +0 -0
  83. package/tests/e2e/MarkdownViewer/__screenshots__/render.test.tsx/MarkdownViewer---render-exercises-shared-preview-class-names-without-mounting-CodeMirror-1.png +0 -0
  84. package/tests/e2e/MarkdownViewer/parity.test.tsx +190 -0
  85. package/tests/e2e/MarkdownViewer/render.test.tsx +35 -0
  86. package/tests/unit/Editor/helpers.test.ts +42 -0
  87. package/tests/unit/MarkdownEditor/code-info.test.ts +63 -0
  88. package/tests/unit/MarkdownEditor/decorations.test.ts +488 -0
  89. package/tests/unit/MarkdownEditor/editor-ready.test.ts +36 -0
  90. package/tests/unit/MarkdownEditor/html-descriptors.test.ts +94 -0
  91. package/tests/unit/MarkdownEditor/jsx-attr-scan.test.ts +115 -0
  92. package/tests/unit/MarkdownEditor/jsx-tag-grammar.test.ts +88 -0
  93. package/tests/unit/MarkdownEditor/list-indent.test.ts +95 -0
  94. package/tests/unit/MarkdownEditor/slash-commands.test.ts +213 -0
  95. package/tests/unit/MarkdownEditor/table-format.test.ts +83 -0
  96. package/tests/unit/MarkdownEditor/table.test.ts +119 -0
  97. package/tests/unit/MarkdownEditor/triggers.test.ts +244 -0
  98. package/tests/unit/MarkdownEditor/widget-store.test.ts +105 -0
  99. package/tests/unit/MarkdownViewer/code-title.test.tsx +62 -0
  100. package/tests/unit/MarkdownViewer/features.test.tsx +110 -0
  101. package/tests/unit/MarkdownViewer/headings.test.tsx +40 -0
  102. package/tests/unit/MarkdownViewer/jsx.test.tsx +211 -0
  103. package/tests/unit/MarkdownViewer/list-bullets.test.tsx +49 -0
  104. package/tests/unit/MarkdownViewer/list-code.test.tsx +65 -0
  105. package/tests/unit/MarkdownViewer/renderers.test.tsx +79 -0
  106. package/tests/unit/MarkdownViewer/runnable.test.tsx +69 -0
  107. package/tests/unit/MarkdownViewer/ssr.test.tsx +93 -0
  108. package/dist/yoopta.css +0 -1
  109. /package/dist/{yoopta.d.ts → fonts.d.ts} +0 -0
@@ -0,0 +1,119 @@
1
+ import {
2
+ gfmTableAddColumn,
3
+ gfmTableAddRow,
4
+ gfmTableRemoveColumn,
5
+ gfmTableRemoveRow,
6
+ locateGfmTableCells,
7
+ parseGfmTable,
8
+ serialiseGfmTable,
9
+ type TableModel,
10
+ } from "@src/lib/markdown/tree/gfm-table";
11
+ import { describe, expect, it } from "vitest";
12
+
13
+ describe("GFM table", () => {
14
+ it("parses simple GFM tables", () => {
15
+ const source = "| a | b |\n| - | - |\n| 1 | 2 |";
16
+ const model = parseGfmTable(source);
17
+ expect(model).not.toBeNull();
18
+ expect(model?.headers).toEqual(["a", "b"]);
19
+ expect(model?.rows).toEqual([["1", "2"]]);
20
+ });
21
+
22
+ it("respects alignment markers", () => {
23
+ const source = "| a | b | c |\n| :- | :-: | -: |\n| 1 | 2 | 3 |";
24
+ const model = parseGfmTable(source);
25
+ expect(model?.alignments).toEqual(["left", "center", "right"]);
26
+ });
27
+
28
+ it("round trips a table preserving content", () => {
29
+ const source = "| Name | Score |\n| :--- | ----: |\n| Ari | 12 |\n| Nova | 8 |";
30
+ const model = parseGfmTable(source);
31
+ if (!model) throw new Error("expected parse");
32
+ const serialised = serialiseGfmTable(model);
33
+
34
+ const reparsed = parseGfmTable(serialised);
35
+ expect(reparsed).not.toBeNull();
36
+ expect(reparsed?.headers).toEqual(model.headers);
37
+ expect(reparsed?.alignments).toEqual(model.alignments);
38
+ expect(reparsed?.rows).toEqual(model.rows);
39
+ });
40
+
41
+ it("returns null for non-table input", () => {
42
+ expect(parseGfmTable("just a paragraph")).toBeNull();
43
+ });
44
+ });
45
+
46
+ describe("locateGfmTableCells", () => {
47
+ it("locates the content start offset of each cell", () => {
48
+ const source = "| a | b |\n| - | - |\n| 1 | 2 |";
49
+ const cells = locateGfmTableCells(source);
50
+ expect(cells).not.toBeNull();
51
+ // Header content starts: 'a' at 2, 'b' at 6.
52
+ expect(cells?.headerOffsets).toEqual([2, 6]);
53
+ expect(source[cells?.headerOffsets[0] ?? -1]).toBe("a");
54
+ expect(source[cells?.headerOffsets[1] ?? -1]).toBe("b");
55
+ // Body row content starts: '1' and '2'.
56
+ expect(source[cells?.rowOffsets[0]?.[0] ?? -1]).toBe("1");
57
+ expect(source[cells?.rowOffsets[0]?.[1] ?? -1]).toBe("2");
58
+ });
59
+
60
+ it("handles ragged padding and missing outer pipes", () => {
61
+ const source = "Name | Score\n--- | ---\nAri | 12";
62
+ const cells = locateGfmTableCells(source);
63
+ expect(cells).not.toBeNull();
64
+ expect(source.slice(cells?.headerOffsets[0] ?? 0, (cells?.headerOffsets[0] ?? 0) + 4)).toBe(
65
+ "Name",
66
+ );
67
+ expect(source.slice(cells?.headerOffsets[1] ?? 0, (cells?.headerOffsets[1] ?? 0) + 5)).toBe(
68
+ "Score",
69
+ );
70
+ expect(source[cells?.rowOffsets[0]?.[1] ?? -1]).toBe("1");
71
+ });
72
+
73
+ it("returns null for non-table input", () => {
74
+ expect(locateGfmTableCells("just a paragraph")).toBeNull();
75
+ });
76
+ });
77
+
78
+ describe("GFM table edits", () => {
79
+ const base: TableModel = {
80
+ headers: ["a", "b"],
81
+ alignments: ["default", "default"],
82
+ rows: [["1", "2"]],
83
+ };
84
+
85
+ it("adds an empty trailing column", () => {
86
+ const next = gfmTableAddColumn(base);
87
+ expect(next.headers).toEqual(["a", "b", ""]);
88
+ expect(next.alignments).toHaveLength(3);
89
+ expect(next.rows).toEqual([["1", "2", ""]]);
90
+ });
91
+
92
+ it("removes the last column but keeps at least one", () => {
93
+ const next = gfmTableRemoveColumn(base);
94
+ expect(next.headers).toEqual(["a"]);
95
+ expect(next.rows).toEqual([["1"]]);
96
+ const single: TableModel = { headers: ["only"], alignments: ["default"], rows: [["x"]] };
97
+ expect(gfmTableRemoveColumn(single)).toEqual(single);
98
+ });
99
+
100
+ it("adds an empty trailing row sized to the columns", () => {
101
+ const next = gfmTableAddRow(base);
102
+ expect(next.rows).toEqual([
103
+ ["1", "2"],
104
+ ["", ""],
105
+ ]);
106
+ });
107
+
108
+ it("removes the last row and is a no-op with no rows", () => {
109
+ expect(gfmTableRemoveRow(base).rows).toEqual([]);
110
+ const empty: TableModel = { headers: ["a"], alignments: ["default"], rows: [] };
111
+ expect(gfmTableRemoveRow(empty)).toEqual(empty);
112
+ });
113
+
114
+ it("round-trips edits through serialise/parse", () => {
115
+ const widened = serialiseGfmTable(gfmTableAddColumn(base));
116
+ const reparsed = parseGfmTable(widened);
117
+ expect(reparsed?.headers).toEqual(["a", "b", ""]);
118
+ });
119
+ });
@@ -0,0 +1,244 @@
1
+ import { EditorSelection, EditorState } from "@codemirror/state";
2
+ import { EditorView, runScopeHandlers } from "@codemirror/view";
3
+ import { Callout } from "@src/lib/markdown/editor/callout";
4
+ import { createMarkdownLanguage } from "@src/lib/markdown/editor/language";
5
+ import { markdownPairKeymap, markdownSnippetHandler } from "@src/lib/markdown/editor/snippets";
6
+ import { describe, expect, it } from "vitest";
7
+
8
+ function createView(doc: string, cursor?: number | { anchor: number; head: number }): EditorView {
9
+ const state = EditorState.create({
10
+ doc,
11
+ extensions: [
12
+ createMarkdownLanguage({ extensions: [Callout] }),
13
+ markdownSnippetHandler,
14
+ markdownPairKeymap,
15
+ ],
16
+ selection:
17
+ typeof cursor === "number"
18
+ ? EditorSelection.cursor(cursor)
19
+ : cursor
20
+ ? EditorSelection.range(cursor.anchor, cursor.head)
21
+ : undefined,
22
+ });
23
+
24
+ const parent = document.createElement("div");
25
+ document.body.appendChild(parent);
26
+
27
+ return new EditorView({ state, parent });
28
+ }
29
+
30
+ /** Dispatch a Backspace through the registered key handlers. */
31
+ function backspace(view: EditorView): boolean {
32
+ return runScopeHandlers(view, new KeyboardEvent("keydown", { key: "Backspace" }), "editor");
33
+ }
34
+
35
+ /**
36
+ * Simulate a single-character user input by walking the same input-handler
37
+ * pipeline the editor uses. Falls back to a default insert when no handler
38
+ * consumes the event so we exercise the trigger but otherwise behave like a
39
+ * normal keystroke.
40
+ */
41
+ function type(view: EditorView, text: string): void {
42
+ const main = view.state.selection.main;
43
+ const handlers = view.state.facet(EditorView.inputHandler);
44
+ const buildTransaction = () => view.state.update(view.state.replaceSelection(text));
45
+ for (const handler of handlers) {
46
+ if (handler(view, main.from, main.to, text, buildTransaction)) {
47
+ return;
48
+ }
49
+ }
50
+ view.dispatch(buildTransaction());
51
+ }
52
+
53
+ describe("markdownSnippetHandler", () => {
54
+ it("expands triple backtick into a fenced code block with caret on the empty content line", () => {
55
+ const view = createView("``", 2);
56
+ type(view, "`");
57
+
58
+ expect(view.state.doc.toString()).toBe("```\n\n```");
59
+ expect(view.state.selection.main.empty).toBe(true);
60
+ // Caret on the empty middle line, after "```\n".
61
+ expect(view.state.selection.main.from).toBe(4);
62
+
63
+ view.destroy();
64
+ });
65
+
66
+ it("preserves leading indent when expanding a fence inside a nested context", () => {
67
+ const view = createView(" ``", 4);
68
+ type(view, "`");
69
+
70
+ expect(view.state.doc.toString()).toBe(" ```\n \n ```");
71
+ // Caret on the indented content line.
72
+ expect(view.state.selection.main.from).toBe(8);
73
+
74
+ view.destroy();
75
+ });
76
+
77
+ it("expands a triple backtick typed one keystroke at a time", () => {
78
+ const view = createView("", 0);
79
+ type(view, "`");
80
+ type(view, "`");
81
+ type(view, "`");
82
+
83
+ expect(view.state.doc.toString()).toBe("```\n\n```");
84
+ expect(view.state.selection.main.from).toBe(4);
85
+
86
+ view.destroy();
87
+ });
88
+
89
+ it("does not expand a fence from a single backtick after text", () => {
90
+ const view = createView("abc", 3);
91
+ type(view, "`");
92
+
93
+ // A lone backtick must auto-pair, never clobber the preceding text.
94
+ expect(view.state.doc.toString()).toBe("abc``");
95
+ expect(view.state.selection.main.from).toBe(4);
96
+
97
+ view.destroy();
98
+ });
99
+
100
+ it("does not expand a fence when the backticks are not at the line start", () => {
101
+ const view = createView("x ``", 4);
102
+ type(view, "`");
103
+
104
+ expect(view.state.doc.toString()).toBe("x ```");
105
+
106
+ view.destroy();
107
+ });
108
+
109
+ it("does not expand a fence when there is content after the cursor on the same line", () => {
110
+ const view = createView("``rest", 2);
111
+ type(view, "`");
112
+
113
+ expect(view.state.doc.toString()).toBe("```rest");
114
+
115
+ view.destroy();
116
+ });
117
+
118
+ it("does not expand a fence when typing inside an existing fenced code block", () => {
119
+ const view = createView("```js\nlet x``\n```", 13);
120
+ type(view, "`");
121
+
122
+ expect(view.state.doc.toString()).toBe("```js\nlet x```\n```");
123
+
124
+ view.destroy();
125
+ });
126
+
127
+ it("wraps a non-empty selection with backticks", () => {
128
+ const view = createView("hello world", { anchor: 0, head: 5 });
129
+ type(view, "`");
130
+
131
+ expect(view.state.doc.toString()).toBe("`hello` world");
132
+ expect(view.state.selection.main.from).toBe(1);
133
+ expect(view.state.selection.main.to).toBe(6);
134
+
135
+ view.destroy();
136
+ });
137
+
138
+ it("wraps a selection with asterisks for italic and again for bold", () => {
139
+ const view = createView("hello world", { anchor: 0, head: 5 });
140
+ type(view, "*");
141
+ expect(view.state.doc.toString()).toBe("*hello* world");
142
+
143
+ type(view, "*");
144
+ expect(view.state.doc.toString()).toBe("**hello** world");
145
+
146
+ view.destroy();
147
+ });
148
+
149
+ it("wraps a selection with tildes for strikethrough", () => {
150
+ const view = createView("hello world", { anchor: 0, head: 5 });
151
+ type(view, "~");
152
+ type(view, "~");
153
+
154
+ expect(view.state.doc.toString()).toBe("~~hello~~ world");
155
+
156
+ view.destroy();
157
+ });
158
+
159
+ it("wraps a selection as a markdown link with caret between the parens", () => {
160
+ const view = createView("hello world", { anchor: 0, head: 5 });
161
+ type(view, "[");
162
+
163
+ expect(view.state.doc.toString()).toBe("[hello]() world");
164
+ expect(view.state.selection.main.empty).toBe(true);
165
+ expect(view.state.selection.main.from).toBe(8);
166
+
167
+ view.destroy();
168
+ });
169
+
170
+ it("does not wrap when the selection is inside a fenced code block", () => {
171
+ const view = createView("```js\nlet x = 1\n```", { anchor: 6, head: 11 });
172
+ type(view, "*");
173
+
174
+ const doc = view.state.doc.toString();
175
+ expect(doc).not.toContain("*let x *");
176
+ expect(doc).not.toContain("*let x ");
177
+
178
+ view.destroy();
179
+ });
180
+
181
+ it("auto-pairs a lone backtick for inline code", () => {
182
+ const view = createView("run ", 4);
183
+ type(view, "`");
184
+
185
+ expect(view.state.doc.toString()).toBe("run ``");
186
+ expect(view.state.selection.main.from).toBe(5);
187
+
188
+ view.destroy();
189
+ });
190
+
191
+ it("auto-pairs and steps over brackets", () => {
192
+ const view = createView("", 0);
193
+ type(view, "(");
194
+ expect(view.state.doc.toString()).toBe("()");
195
+ expect(view.state.selection.main.from).toBe(1);
196
+
197
+ type(view, ")");
198
+ expect(view.state.doc.toString()).toBe("()");
199
+ expect(view.state.selection.main.from).toBe(2);
200
+
201
+ view.destroy();
202
+ });
203
+
204
+ it("does not double a closing bracket that was not auto-paired", () => {
205
+ const view = createView("foo", 3);
206
+ type(view, ")");
207
+
208
+ expect(view.state.doc.toString()).toBe("foo)");
209
+
210
+ view.destroy();
211
+ });
212
+
213
+ it("suppresses emphasis auto-pairing directly in front of a word", () => {
214
+ const view = createView("word", 0);
215
+ type(view, "*");
216
+
217
+ expect(view.state.doc.toString()).toBe("*word");
218
+ expect(view.state.selection.main.from).toBe(1);
219
+
220
+ view.destroy();
221
+ });
222
+
223
+ it("deletes an empty auto-inserted pair with a single backspace", () => {
224
+ const view = createView("", 0);
225
+ type(view, "(");
226
+ expect(view.state.doc.toString()).toBe("()");
227
+
228
+ backspace(view);
229
+ expect(view.state.doc.toString()).toBe("");
230
+ expect(view.state.selection.main.from).toBe(0);
231
+
232
+ view.destroy();
233
+ });
234
+
235
+ it("leaves a non-empty pair to the default backspace", () => {
236
+ const view = createView("(x)", 2);
237
+ // The pair handler declines (no empty pair around the caret), so the
238
+ // default backspace binding is left to run.
239
+ expect(backspace(view)).toBe(false);
240
+ expect(view.state.doc.toString()).toBe("(x)");
241
+
242
+ view.destroy();
243
+ });
244
+ });
@@ -0,0 +1,105 @@
1
+ import { createWidgetStore } from "@src/lib/markdown/editor/widgets/store";
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ describe("createWidgetStore", () => {
5
+ it("unregisterHost removes only the portal whose host identity matches registration", () => {
6
+ const store = createWidgetStore();
7
+ const host214 = document.createElement("span");
8
+ const host227 = document.createElement("span");
9
+ store.register({
10
+ portalKey: 1,
11
+ id: "list-bullet:214",
12
+ host: host214,
13
+ render: () => null,
14
+ });
15
+ store.register({
16
+ portalKey: 2,
17
+ id: "list-bullet:227",
18
+ host: host227,
19
+ render: () => null,
20
+ });
21
+ expect(store.getSnapshot()).toHaveLength(2);
22
+
23
+ expect(store.hasPortalKey(1)).toBe(true);
24
+ expect(store.hasPortalKey(99)).toBe(false);
25
+
26
+ store.unregisterHost(host214);
27
+ expect(store.getSnapshot()).toHaveLength(1);
28
+ expect(store.getSnapshot()[0]?.id).toBe("list-bullet:227");
29
+
30
+ store.unregisterHost(host227);
31
+ expect(store.getSnapshot()).toHaveLength(0);
32
+ });
33
+
34
+ it("same logical id on two hosts keeps two portals until each host unregisters", () => {
35
+ const store = createWidgetStore();
36
+ const hostA = document.createElement("span");
37
+ const hostB = document.createElement("span");
38
+ store.register({
39
+ portalKey: 10,
40
+ id: "list-bullet:214",
41
+ host: hostA,
42
+ render: () => null,
43
+ });
44
+ store.register({
45
+ portalKey: 11,
46
+ id: "list-bullet:214",
47
+ host: hostB,
48
+ render: () => null,
49
+ });
50
+ expect(store.getSnapshot()).toHaveLength(2);
51
+
52
+ store.unregisterHost(hostA);
53
+ expect(store.getSnapshot()).toHaveLength(1);
54
+ expect(store.getSnapshot()[0]?.host).toBe(hostB);
55
+
56
+ store.unregisterHost(hostB);
57
+ expect(store.getSnapshot()).toHaveLength(0);
58
+ });
59
+
60
+ it("re-registering the same host evicts the previous portalKey (one portal per host)", () => {
61
+ const store = createWidgetStore();
62
+ const host = document.createElement("span");
63
+ store.register({
64
+ portalKey: 1,
65
+ id: "checkbox:2-5",
66
+ host,
67
+ render: () => null,
68
+ });
69
+ expect(store.getSnapshot()).toHaveLength(1);
70
+
71
+ store.register({
72
+ portalKey: 2,
73
+ id: "checkbox:2-5",
74
+ host,
75
+ render: () => null,
76
+ });
77
+ expect(store.getSnapshot()).toHaveLength(1);
78
+ expect(store.getSnapshot()[0]?.portalKey).toBe(2);
79
+ expect(store.hasPortalKey(1)).toBe(false);
80
+ expect(store.hasPortalKey(2)).toBe(true);
81
+ });
82
+
83
+ it("unregisterHost with expectedPortalKey skips when a newer registration owns the host", () => {
84
+ const store = createWidgetStore();
85
+ const host = document.createElement("span");
86
+ store.register({
87
+ portalKey: 10,
88
+ id: "checkbox:2-5",
89
+ host,
90
+ render: () => null,
91
+ });
92
+ store.register({
93
+ portalKey: 11,
94
+ id: "checkbox:2-5",
95
+ host,
96
+ render: () => null,
97
+ });
98
+ store.unregisterHost(host, 10);
99
+ expect(store.getSnapshot()).toHaveLength(1);
100
+ expect(store.getSnapshot()[0]?.portalKey).toBe(11);
101
+
102
+ store.unregisterHost(host, 11);
103
+ expect(store.getSnapshot()).toHaveLength(0);
104
+ });
105
+ });
@@ -0,0 +1,62 @@
1
+ import { MantineProvider, v8CssVariablesResolver } from "@mantine/core";
2
+ import { MarkdownViewer } from "@src/primitives/MarkdownViewer";
3
+ import { MANTINE_THEME } from "@src/theme/mantine";
4
+ import type { ReactElement } from "react";
5
+ import { renderToString } from "react-dom/server";
6
+ import { beforeAll, describe, expect, it } from "vitest";
7
+
8
+ beforeAll(() => {
9
+ if (typeof window !== "undefined" && typeof window.matchMedia !== "function") {
10
+ Object.defineProperty(window, "matchMedia", {
11
+ writable: true,
12
+ value: (query: string) => ({
13
+ matches: false,
14
+ media: query,
15
+ onchange: null,
16
+ addListener: () => {},
17
+ removeListener: () => {},
18
+ addEventListener: () => {},
19
+ removeEventListener: () => {},
20
+ dispatchEvent: () => false,
21
+ }),
22
+ });
23
+ }
24
+ });
25
+
26
+ function shell(node: ReactElement): string {
27
+ return renderToString(
28
+ <MantineProvider
29
+ theme={MANTINE_THEME}
30
+ cssVariablesResolver={v8CssVariablesResolver}
31
+ forceColorScheme="dark"
32
+ >
33
+ {node}
34
+ </MantineProvider>,
35
+ );
36
+ }
37
+
38
+ describe("MarkdownViewer / code title", () => {
39
+ it("renders a title attribute as a caption above the code block", () => {
40
+ const html = shell(
41
+ <MarkdownViewer content={'```ts title="Example file"\nconst x = 1;\n```'} />,
42
+ );
43
+ expect(html).toContain("Example file");
44
+ });
45
+
46
+ it("omits the caption when no title is present", () => {
47
+ const html = shell(<MarkdownViewer content={"```ts\nconst x = 1;\n```"} />);
48
+ expect(html).not.toContain("code-block-caption");
49
+ });
50
+
51
+ it("passes the title through the code renderer override", () => {
52
+ const html = shell(
53
+ <MarkdownViewer
54
+ content={'```ts title="From override"\nconst x = 1;\n```'}
55
+ renderers={{
56
+ code: ({ title }) => <span data-title={title}>{title}</span>,
57
+ }}
58
+ />,
59
+ );
60
+ expect(html).toContain("From override");
61
+ });
62
+ });
@@ -0,0 +1,110 @@
1
+ import { MantineProvider, v8CssVariablesResolver } from "@mantine/core";
2
+ import { extractHeadings, markdownSourceFromString, parseMarkdownTree } from "@src/lib/markdown";
3
+ import { MarkdownViewer } from "@src/primitives/MarkdownViewer";
4
+ import { MANTINE_THEME } from "@src/theme/mantine";
5
+ import type { ReactElement } from "react";
6
+ import { renderToString } from "react-dom/server";
7
+ import { beforeAll, describe, expect, it } from "vitest";
8
+
9
+ beforeAll(() => {
10
+ if (typeof window !== "undefined" && typeof window.matchMedia !== "function") {
11
+ Object.defineProperty(window, "matchMedia", {
12
+ writable: true,
13
+ value: (query: string) => ({
14
+ matches: false,
15
+ media: query,
16
+ onchange: null,
17
+ addListener: () => {},
18
+ removeListener: () => {},
19
+ addEventListener: () => {},
20
+ removeEventListener: () => {},
21
+ dispatchEvent: () => false,
22
+ }),
23
+ });
24
+ }
25
+ });
26
+
27
+ function shell(node: ReactElement): string {
28
+ return renderToString(
29
+ <MantineProvider
30
+ theme={MANTINE_THEME}
31
+ cssVariablesResolver={v8CssVariablesResolver}
32
+ forceColorScheme="dark"
33
+ >
34
+ {node}
35
+ </MantineProvider>,
36
+ );
37
+ }
38
+
39
+ describe("MarkdownViewer / features", () => {
40
+ it("renders bare URL autolinks as links", () => {
41
+ const html = shell(<MarkdownViewer content="Visit https://example.com today." />);
42
+ expect(html).toContain('href="https://example.com"');
43
+ expect(html).toContain("https://example.com");
44
+ });
45
+
46
+ it("renders www autolinks with https href", () => {
47
+ const html = shell(<MarkdownViewer content="Go to www.example.com" />);
48
+ expect(html).toContain('href="https://www.example.com"');
49
+ });
50
+
51
+ it("renders inline <u> and inline <Since /> with default components", () => {
52
+ const html = shell(
53
+ <MarkdownViewer content={'Text <u>underlined</u> and <Since v="2.0.0" /> here.'} />,
54
+ );
55
+ expect(html).toContain("<u>");
56
+ expect(html).toContain("underlined");
57
+ expect(html).toContain("2.0.0");
58
+ expect(html).not.toContain("<Since");
59
+ expect(html).not.toMatch(/<p[^>]*>underlined<\/p>/);
60
+ });
61
+
62
+ it("uses custom heading id from {#suffix}", () => {
63
+ const html = shell(<MarkdownViewer content="## Visible {#my-anchor}" />);
64
+ expect(html).toMatch(/id="my-anchor"/);
65
+ expect(html).toContain('href="#my-anchor"');
66
+ expect(html).not.toContain("{#my-anchor}");
67
+ });
68
+
69
+ it("extractHeadings respects custom {#id}", () => {
70
+ const source = "## Title {#custom}\n";
71
+ const tree = parseMarkdownTree(source);
72
+ const headings = extractHeadings(tree, markdownSourceFromString(source));
73
+ expect(headings[0]?.id).toBe("custom");
74
+ expect(headings[0]?.text).toBe("Title");
75
+ });
76
+
77
+ it("preserves ordered list start attribute", () => {
78
+ const html = shell(
79
+ <MarkdownViewer
80
+ content={`3. Third step
81
+ 4. Fourth step`}
82
+ />,
83
+ );
84
+ expect(html).toMatch(/start="3"/);
85
+ expect(html).toContain("3.");
86
+ expect(html).toContain("4.");
87
+ });
88
+
89
+ it("renders HTML table cells with markdown and inline HTML", () => {
90
+ const html = shell(
91
+ <MarkdownViewer
92
+ content={`<table>
93
+ <thead><tr><th>Fn</th></tr></thead>
94
+ <tbody><tr><td>**bold** and <code>x</code></td></tr></tbody>
95
+ </table>`}
96
+ />,
97
+ );
98
+ expect(html).toContain("<table");
99
+ expect(html).toContain("bold");
100
+ expect(html).toContain("<code");
101
+ });
102
+
103
+ it("renders default Button component", () => {
104
+ const html = shell(
105
+ <MarkdownViewer content={'<Button label="Go" url="https://surrealdb.com" />'} />,
106
+ );
107
+ expect(html).toContain("Go");
108
+ expect(html).toContain('href="https://surrealdb.com"');
109
+ });
110
+ });
@@ -0,0 +1,40 @@
1
+ import { extractHeadings, markdownSourceFromString, parseMarkdownTree } from "@src/lib/markdown";
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ describe("extractHeadings", () => {
5
+ it("finds headings nested inside blockquotes", () => {
6
+ const source = "> ## Inside quote\n\n## Top";
7
+ const tree = parseMarkdownTree(source);
8
+ const headings = extractHeadings(tree, markdownSourceFromString(source));
9
+
10
+ expect(headings).toHaveLength(2);
11
+ expect(headings.map((h) => h.text)).toEqual(["Inside quote", "Top"]);
12
+ });
13
+
14
+ it("finds headings between details HTML blocks", () => {
15
+ const source = `<details>
16
+ <summary>Toggle</summary>
17
+
18
+ ### Inside details
19
+
20
+ </details>`;
21
+ const tree = parseMarkdownTree(source);
22
+ const headings = extractHeadings(tree, markdownSourceFromString(source), {
23
+ depths: [2, 3],
24
+ });
25
+
26
+ expect(headings).toHaveLength(1);
27
+ expect(headings[0]?.text).toBe("Inside details");
28
+ expect(headings[0]?.depth).toBe(3);
29
+ });
30
+
31
+ it("strips inline emphasis marks from heading text", () => {
32
+ const source = "## Hello **world**";
33
+ const tree = parseMarkdownTree(source);
34
+ const headings = extractHeadings(tree, markdownSourceFromString(source));
35
+
36
+ expect(headings).toHaveLength(1);
37
+ expect(headings[0]?.text).toBe("Hello world");
38
+ expect(headings[0]?.id).toBe("hello-world");
39
+ });
40
+ });