@surrealdb/ui 1.2.2 → 1.2.4

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 (65) hide show
  1. package/.picto-fix-png/Cursor.png +0 -0
  2. package/.picto-fix-png/DocumentGradient.png +0 -0
  3. package/.picto-fix-png/FileGradient.png +0 -0
  4. package/.picto-fix-png/HealthBubbleGradient.png +0 -0
  5. package/.picto-fix-png/LearnGradient.png +0 -0
  6. package/.picto-fix-png/MediaFileGradient.png +0 -0
  7. package/README.md +16 -0
  8. package/REVIEW.md +2 -2
  9. package/dist/assets/00f479574770406b.webp +0 -0
  10. package/dist/assets/3e3150054c482d19.webp +0 -0
  11. package/dist/assets/4ab8aa6d05f81b2d.webp +0 -0
  12. package/dist/assets/9af7119697945bfe.webp +0 -0
  13. package/dist/assets/b8c39a75a6dfb50a.webp +0 -0
  14. package/dist/assets/b9123e22ce5345b5.webp +0 -0
  15. package/dist/assets/bb014a59e501b200.webp +0 -0
  16. package/dist/assets/bffb52641c4c3861.webp +0 -0
  17. package/dist/assets/ca65cd02fa429a01.webp +0 -0
  18. package/dist/assets/d95b9e2207cd1bbb.webp +0 -0
  19. package/dist/assets/e58704a124b17f9c.webp +0 -0
  20. package/dist/assets/e731247869f3839b.webp +0 -0
  21. package/dist/assets/f4cdd6dcd9f393b8.webp +0 -0
  22. package/dist/assets/fd4bfe9daca141f1.webp +0 -0
  23. package/dist/ui.css +1 -1
  24. package/dist/ui.d.ts +285 -110
  25. package/dist/ui.js +7036 -6280
  26. package/dist/ui.js.map +1 -1
  27. package/package.json +1 -1
  28. package/tests/e2e/MarkdownEditor/__screenshots__/jsx-inline-click.test.tsx/MarkdownEditor---inline-JSX-click-reveal-reveals-the-raw-source-when-an-inline-component-badge-is-clicked-1.png +0 -0
  29. package/tests/e2e/MarkdownEditor/__screenshots__/jsx-inline-click.test.tsx/MarkdownEditor---inline-JSX-click-reveal-reveals-the-raw-source-when-an-inline-component-badge-is-clicked-2.png +0 -0
  30. package/tests/e2e/MarkdownEditor/__screenshots__/jsx-inline-click.test.tsx/MarkdownEditor---inline-JSX-click-reveal-reveals-the-raw-source-when-an-inline-component-badge-is-clicked-3.png +0 -0
  31. package/tests/e2e/MarkdownEditor/image-on-image.test.tsx +72 -0
  32. package/tests/e2e/MarkdownEditor/jsx-inline-click.test.tsx +53 -0
  33. package/tests/e2e/MarkdownEditor/onimage-parity.test.tsx +49 -0
  34. package/tests/e2e/MarkdownEditor/regressions.test.tsx +40 -0
  35. package/tests/unit/MarkdownConformance/error-isolation.test.tsx +43 -0
  36. package/tests/unit/MarkdownConformance/indentation.test.tsx +29 -0
  37. package/tests/unit/MarkdownConformance/jsx-attributes.test.tsx +56 -0
  38. package/tests/unit/MarkdownConformance/jsx-fuzz.test.tsx +165 -0
  39. package/tests/unit/MarkdownConformance/links.test.tsx +71 -0
  40. package/tests/unit/MarkdownConformance/render-helpers.tsx +88 -0
  41. package/tests/unit/MarkdownEditor/decorations.test.ts +2 -2
  42. package/tests/unit/MarkdownEditor/indented-fence.test.ts +103 -0
  43. package/tests/unit/MarkdownEditor/jsx-attr-scan.test.ts +1 -1
  44. package/tests/unit/MarkdownEditor/jsx-block-widget.test.ts +92 -0
  45. package/tests/unit/MarkdownEditor/jsx-tag-grammar.test.ts +2 -2
  46. package/tests/unit/MarkdownEditor/list-indent.test.ts +2 -2
  47. package/tests/unit/MarkdownEditor/slash-commands.test.ts +2 -2
  48. package/tests/unit/MarkdownEditor/triggers.test.ts +2 -2
  49. package/tests/unit/MarkdownViewer/callout.test.tsx +60 -0
  50. package/tests/unit/MarkdownViewer/code-highlight.test.tsx +61 -0
  51. package/tests/unit/MarkdownViewer/features.test.tsx +55 -1
  52. package/tests/unit/MarkdownViewer/indented-jsx-children.test.tsx +98 -0
  53. package/tests/unit/MarkdownViewer/jsx-block-nesting.test.tsx +234 -0
  54. package/tests/unit/MarkdownViewer/jsx.test.tsx +2 -2
  55. package/tests/unit/MarkdownViewer/preserve-newlines.test.tsx +143 -0
  56. package/tests/unit/MarkdownViewer/read-time.test.ts +21 -0
  57. package/tests/unit/MarkdownViewer/renderers.test.tsx +16 -0
  58. package/tests/unit/MarkdownViewer/syntax-highlighting.test.tsx +73 -0
  59. package/tests/unit/MarkdownViewer/tabs-fenced-code.test.tsx +196 -0
  60. package/tests/unit/MarkdownViewer/tabs.test.tsx +115 -0
  61. package/tools/duplicate-vector-mappings.json +79 -0
  62. package/tools/glass-export-overrides.json +8 -0
  63. package/tools/integration-gradient-exports.json +14 -1
  64. package/tools/picto-sync.ts +2 -1
  65. package/dist/assets/b5cb190d5047855e.webp +0 -0
@@ -0,0 +1,88 @@
1
+ import { MantineProvider, v8CssVariablesResolver } from "@mantine/core";
2
+ import type { MarkdownViewerProps } from "@src/primitives/MarkdownViewer";
3
+ import { MarkdownViewer } from "@src/primitives/MarkdownViewer";
4
+ import { MANTINE_THEME } from "@src/theme/mantine";
5
+ import { act } from "react";
6
+ import { createRoot } from "react-dom/client";
7
+ import { renderToString } from "react-dom/server";
8
+
9
+ // jsdom lacks matchMedia, which Mantine touches during render.
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
+ /** Render a `MarkdownViewer` to an HTML string inside a Mantine provider. */
27
+ export function renderMarkdown(content: string, props?: Partial<MarkdownViewerProps>): string {
28
+ return renderToString(
29
+ <MantineProvider
30
+ theme={MANTINE_THEME}
31
+ cssVariablesResolver={v8CssVariablesResolver}
32
+ forceColorScheme="dark"
33
+ >
34
+ <MarkdownViewer
35
+ content={content}
36
+ {...props}
37
+ />
38
+ </MantineProvider>,
39
+ );
40
+ }
41
+
42
+ /**
43
+ * Client-render a `MarkdownViewer` into jsdom and return its HTML. Unlike SSR
44
+ * `renderToString`, this exercises React error boundaries, which recover on the
45
+ * client (the environment where a throwing embedded component would blank the UI).
46
+ */
47
+ export function renderMarkdownClient(
48
+ content: string,
49
+ props?: Partial<MarkdownViewerProps>,
50
+ ): string {
51
+ const container = document.createElement("div");
52
+ document.body.appendChild(container);
53
+ const root = createRoot(container);
54
+ act(() => {
55
+ root.render(
56
+ <MantineProvider
57
+ theme={MANTINE_THEME}
58
+ cssVariablesResolver={v8CssVariablesResolver}
59
+ forceColorScheme="dark"
60
+ >
61
+ <MarkdownViewer
62
+ content={content}
63
+ {...props}
64
+ />
65
+ </MantineProvider>,
66
+ );
67
+ });
68
+ const html = container.innerHTML;
69
+ act(() => {
70
+ root.unmount();
71
+ });
72
+ container.remove();
73
+ return html;
74
+ }
75
+
76
+ /** Strip all HTML tags and decode common entities, returning the visible text. */
77
+ export function visibleText(html: string): string {
78
+ return html
79
+ .replace(/<style[^>]*>[\s\S]*?<\/style>/g, "")
80
+ .replace(/<[^>]+>/g, "")
81
+ .replace(/&quot;/g, '"')
82
+ .replace(/&#39;/g, "'")
83
+ .replace(/&lt;/g, "<")
84
+ .replace(/&gt;/g, ">")
85
+ .replace(/&amp;/g, "&")
86
+ .replace(/\s+/g, " ")
87
+ .trim();
88
+ }
@@ -7,10 +7,8 @@ import {
7
7
  markdownModeField,
8
8
  setMarkdownMode,
9
9
  } from "@src/lib/markdown";
10
- import { Callout } from "@src/lib/markdown/editor/callout";
11
10
  import { markdownDecorations } from "@src/lib/markdown/editor/decorations";
12
11
  import { setMarkdownEditorFocused } from "@src/lib/markdown/editor/decorations/editor-focus";
13
- import { createMarkdownLanguage } from "@src/lib/markdown/editor/language";
14
12
  import { CalloutHeaderWidget } from "@src/lib/markdown/editor/widgets/callout-header-widget";
15
13
  import { CheckboxWidget } from "@src/lib/markdown/editor/widgets/checkbox-widget";
16
14
  import {
@@ -22,6 +20,8 @@ import { HeadingFoldWidget } from "@src/lib/markdown/editor/widgets/heading-fold
22
20
  import { ListBulletWidget } from "@src/lib/markdown/editor/widgets/list-bullet-widget";
23
21
  import { createWidgetStore } from "@src/lib/markdown/editor/widgets/store";
24
22
  import { TableWidget } from "@src/lib/markdown/editor/widgets/table-widget";
23
+ import { Callout } from "@src/lib/markdown/grammar/callout";
24
+ import { createMarkdownLanguage } from "@src/lib/markdown/grammar/language";
25
25
  import type { CodeRenderer } from "@src/lib/markdown/types";
26
26
  import { describe, expect, it } from "vitest";
27
27
  import { md } from "../../_setup/markdown-classes";
@@ -0,0 +1,103 @@
1
+ import { markdownSourceFromString, parseMarkdownTree } from "@src/lib/markdown";
2
+ import { readCodeText } from "@src/lib/markdown/tree/code-node";
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ /** Reconstructed body text of the (single) FencedCode node. */
6
+ function fencedCodeBody(text: string): string | null {
7
+ const tree = parseMarkdownTree(text);
8
+ const src = markdownSourceFromString(text);
9
+ let body: string | null = null;
10
+ tree.iterate({
11
+ enter: (n) => {
12
+ if (n.name === "FencedCode" && body === null) body = readCodeText(n.node, src);
13
+ },
14
+ });
15
+ return body;
16
+ }
17
+
18
+ /** Top-level node names of a parsed document. */
19
+ function topLevelNodes(text: string): { name: string; text: string }[] {
20
+ const tree = parseMarkdownTree(text);
21
+ const src = markdownSourceFromString(text);
22
+ const out: { name: string; text: string }[] = [];
23
+ let ch = tree.topNode.firstChild;
24
+ while (ch) {
25
+ out.push({ name: ch.name, text: src.slice(ch.from, ch.to) });
26
+ ch = ch.nextSibling;
27
+ }
28
+ return out;
29
+ }
30
+
31
+ /** The source text of the (single) FencedCode node, or null if none. */
32
+ function fencedCodeText(text: string): string | null {
33
+ const tree = parseMarkdownTree(text);
34
+ const src = markdownSourceFromString(text);
35
+ let found: string | null = null;
36
+ tree.iterate({
37
+ enter: (n) => {
38
+ if (n.name === "FencedCode" && found === null) found = src.slice(n.from, n.to);
39
+ },
40
+ });
41
+ return found;
42
+ }
43
+
44
+ describe("MarkdownEditor / indented fenced code closing", () => {
45
+ it("closes a tab-indented fence and does not swallow following content", () => {
46
+ const text = "\t```ts\n\tcode\n\t```\ntext";
47
+ const fence = fencedCodeText(text);
48
+ expect(fence).not.toBeNull();
49
+ expect(fence).not.toContain("text");
50
+ // The trailing paragraph survives as its own node.
51
+ expect(
52
+ topLevelNodes(text).some((n) => n.name === "Paragraph" && n.text.includes("text")),
53
+ ).toBe(true);
54
+ });
55
+
56
+ it("closes a 4-space-indented fence and does not swallow following content", () => {
57
+ const text = " ```ts\n code\n ```\ntext";
58
+ const fence = fencedCodeText(text);
59
+ expect(fence).not.toBeNull();
60
+ expect(fence).not.toContain("text");
61
+ });
62
+
63
+ it("does not let an indented fence swallow the closing </Tabs> tag", () => {
64
+ const text = [
65
+ "<Tabs>",
66
+ '\t<TabItem value="esm" label="ESM">',
67
+ "\t```ts",
68
+ "\tconst db = 1;",
69
+ "\t```",
70
+ "\t</TabItem>",
71
+ "</Tabs>",
72
+ ].join("\n");
73
+ // </Tabs> and </TabItem> must be their own HTMLBlock nodes, not buried
74
+ // inside a runaway FencedCode.
75
+ const fence = fencedCodeText(text);
76
+ expect(fence).not.toContain("</TabItem>");
77
+ expect(fence).not.toContain("</Tabs>");
78
+ const names = topLevelNodes(text).map((n) => n.name);
79
+ expect(names.filter((n) => n === "HTMLBlock").length).toBeGreaterThanOrEqual(4);
80
+ });
81
+
82
+ it("still parses a flush fence normally (regression)", () => {
83
+ const text = "```ts\ncode\n```\ntext";
84
+ expect(fencedCodeText(text)).toBe("```ts\ncode\n```");
85
+ });
86
+
87
+ it("still parses a fence nested in a list item (regression)", () => {
88
+ const text = ["- item", " ```js", " code;", " ```", "", "after"].join("\n");
89
+ const fence = fencedCodeText(text);
90
+ expect(fence).not.toBeNull();
91
+ expect(fence).not.toContain("after");
92
+ });
93
+
94
+ // Matches MDX/micromark: exactly the opening fence's indentation is removed
95
+ // from each content line; deeper indentation is preserved.
96
+ it("strips the opening fence indent from content, preserving deeper indent", () => {
97
+ const text =
98
+ "\t```ts\n\timport { Surreal } from 'surrealdb';\n\t\tconst indented = 1;\n\t```";
99
+ expect(fencedCodeBody(text)).toBe(
100
+ "import { Surreal } from 'surrealdb';\n\tconst indented = 1;",
101
+ );
102
+ });
103
+ });
@@ -4,7 +4,7 @@ import {
4
4
  parseAttributes,
5
5
  parseJsonAttribute,
6
6
  resolveAttributeValue,
7
- } from "@src/lib/markdown/tree/html-attrs";
7
+ } from "@src/lib/markdown/grammar/html-attrs";
8
8
  import { describe, expect, it } from "vitest";
9
9
 
10
10
  describe("findJsonAttributeClose", () => {
@@ -0,0 +1,92 @@
1
+ import { EditorSelection, EditorState } from "@codemirror/state";
2
+ import { type Decoration, type DecorationSet, EditorView } from "@codemirror/view";
3
+ import { markdownModeField, setMarkdownMode } from "@src/lib/markdown";
4
+ import { markdownDecorations } from "@src/lib/markdown/editor/decorations";
5
+ import { setMarkdownEditorFocused } from "@src/lib/markdown/editor/decorations/editor-focus";
6
+ import { JsxBlockWidget } from "@src/lib/markdown/editor/widgets/jsx-widget";
7
+ import { createWidgetStore } from "@src/lib/markdown/editor/widgets/store";
8
+ import { Callout } from "@src/lib/markdown/grammar/callout";
9
+ import { createMarkdownLanguage } from "@src/lib/markdown/grammar/language";
10
+ import { afterEach, describe, expect, it } from "vitest";
11
+
12
+ const views: EditorView[] = [];
13
+
14
+ afterEach(() => {
15
+ for (const v of views.splice(0)) v.destroy();
16
+ });
17
+
18
+ /** Build a focused, preview-mode editor with the caret at `cursor`. */
19
+ function preview(doc: string, cursor: number): EditorView {
20
+ const state = EditorState.create({
21
+ doc,
22
+ extensions: [
23
+ createMarkdownLanguage({ extensions: [Callout] }),
24
+ markdownModeField,
25
+ markdownDecorations({ widgetStore: createWidgetStore() }),
26
+ ],
27
+ });
28
+ const parent = document.createElement("div");
29
+ document.body.appendChild(parent);
30
+ const view = new EditorView({ state, parent });
31
+ views.push(view);
32
+ view.dispatch({ effects: setMarkdownMode.of("preview") });
33
+ view.focus();
34
+ view.dispatch({ effects: setMarkdownEditorFocused.of(true) });
35
+ view.dispatch({ selection: EditorSelection.cursor(cursor) });
36
+ return view;
37
+ }
38
+
39
+ function jsxBlockRanges(view: EditorView): Array<{ from: number; to: number }> {
40
+ const out: Array<{ from: number; to: number }> = [];
41
+ for (const set of (view as unknown as { docView: { decorations: DecorationSet[] } }).docView
42
+ .decorations) {
43
+ const cursor = set.iter();
44
+ while (cursor.value) {
45
+ const widget = (cursor.value.spec as { widget?: Decoration }).widget;
46
+ if (widget instanceof JsxBlockWidget) {
47
+ out.push({ from: cursor.from, to: cursor.to });
48
+ }
49
+ cursor.next();
50
+ }
51
+ }
52
+ return out;
53
+ }
54
+
55
+ const TABS = `<Tabs syncKey="demo">
56
+ <TabItem label="A">
57
+ Alpha para one.
58
+
59
+ Alpha para two.
60
+ </TabItem>
61
+ <TabItem label="B">
62
+ Beta para one.
63
+ </TabItem>
64
+ </Tabs>`;
65
+
66
+ // Some prose before the block so the caret can sit fully outside it.
67
+ const TABS_WITH_PROSE = `Intro line.\n\n${TABS}`;
68
+
69
+ describe("MarkdownEditor / JSX block widget", () => {
70
+ it("renders a multi-paragraph Tabs block as a single widget when the caret is outside", () => {
71
+ const view = preview(TABS_WITH_PROSE, 0); // caret in the intro line
72
+ const ranges = jsxBlockRanges(view);
73
+ expect(ranges).toHaveLength(1);
74
+ // Covers the whole <Tabs>…</Tabs> span, not a fragment of it.
75
+ const tabsStart = TABS_WITH_PROSE.indexOf("<Tabs");
76
+ expect(ranges[0]?.from).toBe(tabsStart);
77
+ expect(ranges[0]?.to).toBe(TABS_WITH_PROSE.length);
78
+ });
79
+
80
+ it("reveals the entire block as source (no partial widgets) when the caret is inside", () => {
81
+ // Caret inside the first tab's content.
82
+ const inside = TABS_WITH_PROSE.indexOf("Alpha para one.") + 3;
83
+ const view = preview(TABS_WITH_PROSE, inside);
84
+ expect(jsxBlockRanges(view)).toHaveLength(0);
85
+ });
86
+
87
+ it("keeps nested TabItem tags raw when the caret is on the closing tag line", () => {
88
+ const onClose = TABS_WITH_PROSE.indexOf("</Tabs>") + 1;
89
+ const view = preview(TABS_WITH_PROSE, onClose);
90
+ expect(jsxBlockRanges(view)).toHaveLength(0);
91
+ });
92
+ });
@@ -3,8 +3,8 @@ import {
3
3
  JSX_ATTR_STRING_NODE,
4
4
  JSX_EXPRESSION_CONTENT_NODE,
5
5
  JSX_EXPRESSION_MARK_NODE,
6
- } from "@src/lib/markdown/editor/jsx-tag";
7
- import { createMarkdownLanguage } from "@src/lib/markdown/editor/language";
6
+ } from "@src/lib/markdown/grammar/jsx-tag";
7
+ import { createMarkdownLanguage } from "@src/lib/markdown/grammar/language";
8
8
  import { describe, expect, it } from "vitest";
9
9
 
10
10
  function parse(text: string) {
@@ -1,8 +1,8 @@
1
1
  import { EditorSelection, EditorState } from "@codemirror/state";
2
2
  import { EditorView } from "@codemirror/view";
3
- import { Callout } from "@src/lib/markdown/editor/callout";
4
- import { createMarkdownLanguage } from "@src/lib/markdown/editor/language";
5
3
  import { indentMarkdownList } from "@src/lib/markdown/editor/list-indent";
4
+ import { Callout } from "@src/lib/markdown/grammar/callout";
5
+ import { createMarkdownLanguage } from "@src/lib/markdown/grammar/language";
6
6
  import { afterEach, describe, expect, it } from "vitest";
7
7
 
8
8
  let view: EditorView;
@@ -1,10 +1,10 @@
1
1
  import { EditorSelection, EditorState } from "@codemirror/state";
2
2
  import { EditorView } from "@codemirror/view";
3
- import { Callout } from "@src/lib/markdown/editor/callout";
4
- import { createMarkdownLanguage } from "@src/lib/markdown/editor/language";
5
3
  import { DEFAULT_SLASH_COMMANDS } from "@src/lib/markdown/editor/slash/commands";
6
4
  import { slashDecorationsField } from "@src/lib/markdown/editor/slash/decorations";
7
5
  import { getSlashSession, slashSessionField } from "@src/lib/markdown/editor/slash/field";
6
+ import { Callout } from "@src/lib/markdown/grammar/callout";
7
+ import { createMarkdownLanguage } from "@src/lib/markdown/grammar/language";
8
8
  import { describe, expect, it } from "vitest";
9
9
 
10
10
  function createView(doc: string, cursor = doc.length): EditorView {
@@ -1,8 +1,8 @@
1
1
  import { EditorSelection, EditorState } from "@codemirror/state";
2
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
3
  import { markdownPairKeymap, markdownSnippetHandler } from "@src/lib/markdown/editor/snippets";
4
+ import { Callout } from "@src/lib/markdown/grammar/callout";
5
+ import { createMarkdownLanguage } from "@src/lib/markdown/grammar/language";
6
6
  import { describe, expect, it } from "vitest";
7
7
 
8
8
  function createView(doc: string, cursor?: number | { anchor: number; head: number }): EditorView {
@@ -0,0 +1,60 @@
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 / callouts", () => {
39
+ it("renders the title and body without a leading <br> between them", () => {
40
+ const html = shell(
41
+ <MarkdownViewer
42
+ content={"> [!NOTE]\n> Sandbox data is **not persistent**: it is reset later."}
43
+ />,
44
+ );
45
+
46
+ expect(html).toContain("Note");
47
+ expect(html).toContain("Sandbox data is");
48
+ expect(html).toContain("not persistent");
49
+
50
+ // The body paragraph must not open with a stray line break.
51
+ expect(html).not.toMatch(/<p[^>]*><br\s*\/?>/);
52
+ });
53
+
54
+ it("supports the title and body on adjacent lines", () => {
55
+ const html = shell(<MarkdownViewer content={"> [!WARNING]\n> Be careful here."} />);
56
+ expect(html).toContain("Warning");
57
+ expect(html).toContain("Be careful here.");
58
+ expect(html).not.toMatch(/<p[^>]*><br\s*\/?>/);
59
+ });
60
+ });
@@ -0,0 +1,61 @@
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
+ // Dark-scheme syntax colors (see src/syntax/theme.ts).
39
+ const STRING_COLOR = "#00ff6e";
40
+ const PROPERTY_COLOR = "#e06c75";
41
+
42
+ describe("MarkdownViewer / code syntax highlighting", () => {
43
+ it("highlights object keys as property names (not bare identifiers)", () => {
44
+ const code = ["{", ' "monthly_sales": "DEFINE TABLE monthly_sales TYPE NORMAL"', "}"].join(
45
+ "\n",
46
+ );
47
+ const html = shell(<MarkdownViewer content={`\`\`\`json\n${code}\n\`\`\``} />);
48
+ expect(html).toContain(PROPERTY_COLOR);
49
+ expect(html).toContain(STRING_COLOR);
50
+ });
51
+
52
+ it("keeps a multi-line string token highlighted as one string across lines", () => {
53
+ const code = ["const q = `DEFINE TABLE wishlist", "SCHEMAFULL PERMISSIONS NONE`;"].join(
54
+ "\n",
55
+ );
56
+ const html = shell(<MarkdownViewer content={`\`\`\`js\n${code}\n\`\`\``} />);
57
+ // Continuation line content must be inside a string-colored span — proof
58
+ // the whole block was parsed once rather than line by line.
59
+ expect(html).toMatch(new RegExp(`color:${STRING_COLOR}[^>]*>SCHEMAFULL PERMISSIONS NONE`));
60
+ });
61
+ });
@@ -100,11 +100,65 @@ describe("MarkdownViewer / features", () => {
100
100
  expect(html).toContain("<code");
101
101
  });
102
102
 
103
+ it("wraps a GFM table in a horizontal scroll container", () => {
104
+ const html = shell(
105
+ <MarkdownViewer
106
+ content={`| A | B |
107
+ | - | - |
108
+ | 1 | 2 |`}
109
+ />,
110
+ );
111
+ expect(html).toContain("mantine-TableScrollContainer-scrollContainer");
112
+ expect(html).toContain("<table");
113
+ expect(html).toContain("1");
114
+ });
115
+
116
+ it("wraps an HTML table in a horizontal scroll container", () => {
117
+ const html = shell(
118
+ <MarkdownViewer
119
+ content={`<table>
120
+ <tbody><tr><td>cell</td></tr></tbody>
121
+ </table>`}
122
+ />,
123
+ );
124
+ expect(html).toContain("mantine-TableScrollContainer-scrollContainer");
125
+ expect(html).toContain("<table");
126
+ expect(html).toContain("cell");
127
+ });
128
+
103
129
  it("renders default Button component", () => {
104
130
  const html = shell(
105
- <MarkdownViewer content={'<Button label="Go" url="https://surrealdb.com" />'} />,
131
+ <MarkdownViewer content={'<Button label="Go" href="https://surrealdb.com" />'} />,
106
132
  );
107
133
  expect(html).toContain("Go");
108
134
  expect(html).toContain('href="https://surrealdb.com"');
109
135
  });
136
+
137
+ it("resolves image sources through onImage (e.g. CDN code → URL)", () => {
138
+ const html = shell(
139
+ <MarkdownViewer
140
+ content={"![Example](cdn-code-123)"}
141
+ onResolveMedia={(node) => ({
142
+ ...node,
143
+ src: `https://cdn.surrealdb.com/${node.src}.auto`,
144
+ })}
145
+ />,
146
+ );
147
+ expect(html).toContain("cdn.surrealdb.com/cdn-code-123.auto");
148
+ expect(html).toContain("Example");
149
+ });
150
+
151
+ it("renders a video player when onImage resolves a source to a video container", () => {
152
+ const html = shell(
153
+ <MarkdownViewer
154
+ content={"![clip](cdn-video-123)"}
155
+ onResolveMedia={(node) => ({
156
+ ...node,
157
+ src: `https://cdn.surrealdb.com/${node.src}.mp4`,
158
+ })}
159
+ />,
160
+ );
161
+ expect(html).toContain("cdn.surrealdb.com/cdn-video-123.mp4");
162
+ expect(html).toContain("<video");
163
+ });
110
164
  });
@@ -0,0 +1,98 @@
1
+ import { MantineProvider, v8CssVariablesResolver } from "@mantine/core";
2
+ import type { MarkdownComponents } from "@src/lib/markdown";
3
+ import { parseMarkdownTree } from "@src/lib/markdown";
4
+ import { MarkdownViewer } from "@src/primitives/MarkdownViewer";
5
+ import { MANTINE_THEME } from "@src/theme/mantine";
6
+ import type { ReactElement, ReactNode } from "react";
7
+ import { renderToString } from "react-dom/server";
8
+ import { beforeAll, describe, expect, it } from "vitest";
9
+
10
+ beforeAll(() => {
11
+ if (typeof window !== "undefined" && typeof window.matchMedia !== "function") {
12
+ Object.defineProperty(window, "matchMedia", {
13
+ writable: true,
14
+ value: (query: string) => ({
15
+ matches: false,
16
+ media: query,
17
+ onchange: null,
18
+ addListener: () => {},
19
+ removeListener: () => {},
20
+ addEventListener: () => {},
21
+ removeEventListener: () => {},
22
+ dispatchEvent: () => false,
23
+ }),
24
+ });
25
+ }
26
+ });
27
+
28
+ function shell(node: ReactElement): string {
29
+ return renderToString(
30
+ <MantineProvider
31
+ theme={MANTINE_THEME}
32
+ cssVariablesResolver={v8CssVariablesResolver}
33
+ forceColorScheme="dark"
34
+ >
35
+ {node}
36
+ </MantineProvider>,
37
+ );
38
+ }
39
+
40
+ function nodeNames(text: string): string[] {
41
+ const tree = parseMarkdownTree(text);
42
+ const names: string[] = [];
43
+ const cursor = tree.cursor();
44
+ do {
45
+ names.push(cursor.name);
46
+ } while (cursor.next());
47
+ return names;
48
+ }
49
+
50
+ function Boxes({ children }: { children?: ReactNode }): ReactElement {
51
+ return <div data-boxes>{children}</div>;
52
+ }
53
+ function IconBox({ title }: { title?: string }): ReactElement {
54
+ return <div data-iconbox>{title}</div>;
55
+ }
56
+
57
+ const components: MarkdownComponents = {
58
+ Boxes: { component: Boxes, block: true },
59
+ IconBox: { component: IconBox, block: true },
60
+ };
61
+
62
+ // Children indented with tabs, exactly as authored in docs' index.mdx.
63
+ const CONTENT = `<Boxes>
64
+ \t<IconBox
65
+ \t\ttitle="Querying"
66
+ \t\tdescription="Learn to query your data."
67
+ \t\thref="/docs/learn/querying"
68
+ \t\ticon="@ui/pictoSpeed"
69
+ \t/>
70
+ \t<IconBox
71
+ \t\ttitle="Schema management"
72
+ \t\tdescription="Learn how to shape data."
73
+ \t\thref="/docs/learn/schema-management"
74
+ \t\ticon="@ui/pictoPath"
75
+ \t/>
76
+ </Boxes>`;
77
+
78
+ describe("MarkdownViewer / no indented code blocks", () => {
79
+ it("does not parse tab/4-space indented lines as code blocks", () => {
80
+ expect(nodeNames("\t<IconBox />")).not.toContain("CodeBlock");
81
+ expect(nodeNames(" plain indented text")).not.toContain("CodeBlock");
82
+ // Fenced code blocks still work.
83
+ expect(nodeNames("```\ncode\n```")).toContain("FencedCode");
84
+ });
85
+
86
+ it("renders tab-indented IconBox children inside a Boxes block", () => {
87
+ const html = shell(
88
+ <MarkdownViewer
89
+ content={CONTENT}
90
+ components={components}
91
+ />,
92
+ );
93
+ expect(html).toContain("data-boxes");
94
+ expect(html).toContain("data-iconbox");
95
+ expect(html).toContain("Querying");
96
+ expect(html).toContain("Schema management");
97
+ });
98
+ });