@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.
- package/.picto-fix-png/Cursor.png +0 -0
- package/.picto-fix-png/DocumentGradient.png +0 -0
- package/.picto-fix-png/FileGradient.png +0 -0
- package/.picto-fix-png/HealthBubbleGradient.png +0 -0
- package/.picto-fix-png/LearnGradient.png +0 -0
- package/.picto-fix-png/MediaFileGradient.png +0 -0
- package/README.md +16 -0
- package/REVIEW.md +2 -2
- package/dist/assets/00f479574770406b.webp +0 -0
- package/dist/assets/3e3150054c482d19.webp +0 -0
- package/dist/assets/4ab8aa6d05f81b2d.webp +0 -0
- package/dist/assets/9af7119697945bfe.webp +0 -0
- package/dist/assets/b8c39a75a6dfb50a.webp +0 -0
- package/dist/assets/b9123e22ce5345b5.webp +0 -0
- package/dist/assets/bb014a59e501b200.webp +0 -0
- package/dist/assets/bffb52641c4c3861.webp +0 -0
- package/dist/assets/ca65cd02fa429a01.webp +0 -0
- package/dist/assets/d95b9e2207cd1bbb.webp +0 -0
- package/dist/assets/e58704a124b17f9c.webp +0 -0
- package/dist/assets/e731247869f3839b.webp +0 -0
- package/dist/assets/f4cdd6dcd9f393b8.webp +0 -0
- package/dist/assets/fd4bfe9daca141f1.webp +0 -0
- package/dist/ui.css +1 -1
- package/dist/ui.d.ts +285 -110
- package/dist/ui.js +7036 -6280
- package/dist/ui.js.map +1 -1
- package/package.json +1 -1
- 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
- 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
- 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
- package/tests/e2e/MarkdownEditor/image-on-image.test.tsx +72 -0
- package/tests/e2e/MarkdownEditor/jsx-inline-click.test.tsx +53 -0
- package/tests/e2e/MarkdownEditor/onimage-parity.test.tsx +49 -0
- package/tests/e2e/MarkdownEditor/regressions.test.tsx +40 -0
- package/tests/unit/MarkdownConformance/error-isolation.test.tsx +43 -0
- package/tests/unit/MarkdownConformance/indentation.test.tsx +29 -0
- package/tests/unit/MarkdownConformance/jsx-attributes.test.tsx +56 -0
- package/tests/unit/MarkdownConformance/jsx-fuzz.test.tsx +165 -0
- package/tests/unit/MarkdownConformance/links.test.tsx +71 -0
- package/tests/unit/MarkdownConformance/render-helpers.tsx +88 -0
- package/tests/unit/MarkdownEditor/decorations.test.ts +2 -2
- package/tests/unit/MarkdownEditor/indented-fence.test.ts +103 -0
- package/tests/unit/MarkdownEditor/jsx-attr-scan.test.ts +1 -1
- package/tests/unit/MarkdownEditor/jsx-block-widget.test.ts +92 -0
- package/tests/unit/MarkdownEditor/jsx-tag-grammar.test.ts +2 -2
- package/tests/unit/MarkdownEditor/list-indent.test.ts +2 -2
- package/tests/unit/MarkdownEditor/slash-commands.test.ts +2 -2
- package/tests/unit/MarkdownEditor/triggers.test.ts +2 -2
- package/tests/unit/MarkdownViewer/callout.test.tsx +60 -0
- package/tests/unit/MarkdownViewer/code-highlight.test.tsx +61 -0
- package/tests/unit/MarkdownViewer/features.test.tsx +55 -1
- package/tests/unit/MarkdownViewer/indented-jsx-children.test.tsx +98 -0
- package/tests/unit/MarkdownViewer/jsx-block-nesting.test.tsx +234 -0
- package/tests/unit/MarkdownViewer/jsx.test.tsx +2 -2
- package/tests/unit/MarkdownViewer/preserve-newlines.test.tsx +143 -0
- package/tests/unit/MarkdownViewer/read-time.test.ts +21 -0
- package/tests/unit/MarkdownViewer/renderers.test.tsx +16 -0
- package/tests/unit/MarkdownViewer/syntax-highlighting.test.tsx +73 -0
- package/tests/unit/MarkdownViewer/tabs-fenced-code.test.tsx +196 -0
- package/tests/unit/MarkdownViewer/tabs.test.tsx +115 -0
- package/tools/duplicate-vector-mappings.json +79 -0
- package/tools/glass-export-overrides.json +8 -0
- package/tools/integration-gradient-exports.json +14 -1
- package/tools/picto-sync.ts +2 -1
- package/dist/assets/b5cb190d5047855e.webp +0 -0
package/package.json
CHANGED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { MantineProvider, Paper, v8CssVariablesResolver } from "@mantine/core";
|
|
2
|
+
import type { MediaDescriptor } from "@src/lib/markdown";
|
|
3
|
+
import { MarkdownEditor } from "@src/primitives/MarkdownEditor";
|
|
4
|
+
import { MANTINE_THEME } from "@src/theme/mantine";
|
|
5
|
+
import { describe, expect, it } from "vitest";
|
|
6
|
+
import { getEditorView, mountStory, nextFrame } from "../../_setup/e2e-helpers";
|
|
7
|
+
|
|
8
|
+
function toCdn(node: MediaDescriptor): MediaDescriptor {
|
|
9
|
+
return { ...node, src: `https://cdn.surrealdb.com/${node.src}.auto` };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function toCdnVideo(node: MediaDescriptor): MediaDescriptor {
|
|
13
|
+
return { ...node, src: `https://cdn.surrealdb.com/${node.src}.mp4` };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function mount(doc: string, onResolveMedia: (node: MediaDescriptor) => MediaDescriptor): void {
|
|
17
|
+
mountStory(
|
|
18
|
+
<MantineProvider
|
|
19
|
+
theme={MANTINE_THEME}
|
|
20
|
+
cssVariablesResolver={v8CssVariablesResolver}
|
|
21
|
+
forceColorScheme="dark"
|
|
22
|
+
>
|
|
23
|
+
<Paper
|
|
24
|
+
p="md"
|
|
25
|
+
withBorder
|
|
26
|
+
>
|
|
27
|
+
<MarkdownEditor
|
|
28
|
+
mode="preview"
|
|
29
|
+
document={doc}
|
|
30
|
+
onResolveMedia={onResolveMedia}
|
|
31
|
+
autoFocus={false}
|
|
32
|
+
mih={200}
|
|
33
|
+
/>
|
|
34
|
+
</Paper>
|
|
35
|
+
</MantineProvider>,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("MarkdownEditor / onImage resolution", () => {
|
|
40
|
+
it("resolves image widget sources through onResolveMedia (CDN code → URL)", async () => {
|
|
41
|
+
// Leading paragraph keeps the caret off the image so the widget renders.
|
|
42
|
+
mount("Intro\n\n", toCdn);
|
|
43
|
+
await getEditorView();
|
|
44
|
+
await nextFrame();
|
|
45
|
+
await nextFrame();
|
|
46
|
+
|
|
47
|
+
let img: HTMLImageElement | null = null;
|
|
48
|
+
for (let i = 0; i < 40 && !img; i++) {
|
|
49
|
+
img = document.querySelector<HTMLImageElement>('img[src*="cdn.surrealdb.com"]');
|
|
50
|
+
if (!img) await nextFrame();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
expect(img).not.toBeNull();
|
|
54
|
+
expect(img?.getAttribute("src")).toBe("https://cdn.surrealdb.com/cdn-code-123.auto");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("renders a video widget when onResolveMedia resolves the source to a video", async () => {
|
|
58
|
+
mount("Intro\n\n", toCdnVideo);
|
|
59
|
+
await getEditorView();
|
|
60
|
+
await nextFrame();
|
|
61
|
+
await nextFrame();
|
|
62
|
+
|
|
63
|
+
let video: HTMLVideoElement | null = null;
|
|
64
|
+
for (let i = 0; i < 40 && !video; i++) {
|
|
65
|
+
video = document.querySelector<HTMLVideoElement>("video");
|
|
66
|
+
if (!video) await nextFrame();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
expect(video).not.toBeNull();
|
|
70
|
+
expect(document.body.innerHTML).toContain("cdn.surrealdb.com/cdn-video-123.mp4");
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { MantineProvider, Paper, v8CssVariablesResolver } from "@mantine/core";
|
|
2
|
+
import { MarkdownEditor } from "@src/primitives/MarkdownEditor";
|
|
3
|
+
import { MANTINE_THEME } from "@src/theme/mantine";
|
|
4
|
+
import { page } from "@vitest/browser/context";
|
|
5
|
+
import { describe, expect, it } from "vitest";
|
|
6
|
+
import { getEditorView, mountStory, nextFrame } from "../../_setup/e2e-helpers";
|
|
7
|
+
|
|
8
|
+
const DOC = 'Text with <Since v="1.5.0" /> inline here.\n';
|
|
9
|
+
|
|
10
|
+
describe("MarkdownEditor / inline JSX click reveal", () => {
|
|
11
|
+
it("reveals the raw source when an inline component badge is clicked", async () => {
|
|
12
|
+
mountStory(
|
|
13
|
+
<MantineProvider
|
|
14
|
+
theme={MANTINE_THEME}
|
|
15
|
+
cssVariablesResolver={v8CssVariablesResolver}
|
|
16
|
+
forceColorScheme="dark"
|
|
17
|
+
>
|
|
18
|
+
<Paper
|
|
19
|
+
p="md"
|
|
20
|
+
withBorder
|
|
21
|
+
>
|
|
22
|
+
<MarkdownEditor
|
|
23
|
+
mode="preview"
|
|
24
|
+
document={DOC}
|
|
25
|
+
autoFocus={false}
|
|
26
|
+
jsxMode="render"
|
|
27
|
+
/>
|
|
28
|
+
</Paper>
|
|
29
|
+
</MantineProvider>,
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const view = await getEditorView();
|
|
33
|
+
await nextFrame();
|
|
34
|
+
|
|
35
|
+
// Inactive: the badge renders and the raw tag is hidden.
|
|
36
|
+
expect(document.querySelector(".cm-content")?.textContent).not.toContain("<Since");
|
|
37
|
+
|
|
38
|
+
// Click the rendered inline badge.
|
|
39
|
+
await page.getByText("1.5.0").click();
|
|
40
|
+
await nextFrame();
|
|
41
|
+
await nextFrame();
|
|
42
|
+
|
|
43
|
+
// The click selects the component's entire source range (not just a caret),
|
|
44
|
+
// revealing the raw markup for editing.
|
|
45
|
+
const sel = view.state.selection.main;
|
|
46
|
+
const from = DOC.indexOf("<Since");
|
|
47
|
+
const to = DOC.indexOf("/>") + 2;
|
|
48
|
+
expect(sel.from).toBe(from);
|
|
49
|
+
expect(sel.to).toBe(to);
|
|
50
|
+
expect(sel.empty).toBe(false);
|
|
51
|
+
expect(document.querySelector(".cm-content")?.textContent).toContain("<Since");
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import * as MarkdownStories from "@src/stories/playground/Markdown.stories";
|
|
2
|
+
import { composeStories } from "@storybook/react-vite";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { mountStory, nextFrame } from "../../_setup/e2e-helpers";
|
|
5
|
+
|
|
6
|
+
const Stories = composeStories(MarkdownStories);
|
|
7
|
+
|
|
8
|
+
// Resolved by the story's shared onImage resolver. The optional extension on the
|
|
9
|
+
// code selects the media type: no extension → `.auto` image, `.webp` → image,
|
|
10
|
+
// `.mp4` → video.
|
|
11
|
+
const AUTO_IMAGE_SRC = "cu72bvkattbs738rle0g.auto";
|
|
12
|
+
const WEBP_IMAGE_SRC = "cu72bvkattbs738rle0g.webp";
|
|
13
|
+
const VIDEO_SRC = "cu72bvkattbs738rle0g.mp4";
|
|
14
|
+
|
|
15
|
+
async function waitFor<T>(get: () => T | null | undefined, tries = 60): Promise<T | null> {
|
|
16
|
+
for (let i = 0; i < tries; i++) {
|
|
17
|
+
const value = get();
|
|
18
|
+
if (value) return value;
|
|
19
|
+
await nextFrame();
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("MarkdownEditor / onImage parity (side-by-side story)", () => {
|
|
25
|
+
it("resolves CDN codes to URLs by extension in both the editor preview and the viewer", async () => {
|
|
26
|
+
mountStory(<Stories.SideBySideOnImage />);
|
|
27
|
+
|
|
28
|
+
// A bare code resolves to a `.auto` image; a `.webp` code resolves to a
|
|
29
|
+
// webp image. Both the editor widget and the viewer render each — the same
|
|
30
|
+
// onImage resolver feeds both surfaces. The editor widget mounts a frame
|
|
31
|
+
// after the viewer, so poll until parity.
|
|
32
|
+
const autoImages = await waitFor(() => {
|
|
33
|
+
const found = document.querySelectorAll(`img[src*="${AUTO_IMAGE_SRC}"]`);
|
|
34
|
+
return found.length >= 2 ? found : null;
|
|
35
|
+
});
|
|
36
|
+
expect(autoImages?.length ?? 0).toBeGreaterThanOrEqual(2);
|
|
37
|
+
expect(
|
|
38
|
+
document.querySelectorAll(`img[src*="${WEBP_IMAGE_SRC}"]`).length,
|
|
39
|
+
).toBeGreaterThanOrEqual(2);
|
|
40
|
+
|
|
41
|
+
// A `.mp4` code resolves to a video container, so both surfaces render a player.
|
|
42
|
+
const videos = await waitFor(() => {
|
|
43
|
+
const found = document.querySelectorAll("video");
|
|
44
|
+
return found.length >= 2 ? found : null;
|
|
45
|
+
});
|
|
46
|
+
expect(videos?.length ?? 0).toBeGreaterThanOrEqual(2);
|
|
47
|
+
expect(document.body.innerHTML).toContain(VIDEO_SRC);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -47,6 +47,28 @@ function ScrollFixture(): ReactElement {
|
|
|
47
47
|
);
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
function WrapFixture(): ReactElement {
|
|
51
|
+
const longParagraph = "Word ".repeat(120).trim();
|
|
52
|
+
const [doc, setDoc] = useState(longParagraph);
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<MantineProvider
|
|
56
|
+
theme={MANTINE_THEME}
|
|
57
|
+
cssVariablesResolver={v8CssVariablesResolver}
|
|
58
|
+
forceColorScheme="dark"
|
|
59
|
+
>
|
|
60
|
+
<div style={{ width: 360, overflow: "hidden" }}>
|
|
61
|
+
<MarkdownEditor
|
|
62
|
+
mode="preview"
|
|
63
|
+
document={doc}
|
|
64
|
+
onChangeDocument={setDoc}
|
|
65
|
+
style={{ height: 240 }}
|
|
66
|
+
/>
|
|
67
|
+
</div>
|
|
68
|
+
</MantineProvider>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
50
72
|
describe("MarkdownEditor / regressions", () => {
|
|
51
73
|
it("task checkbox toggles between checked and unchecked in the source document", async () => {
|
|
52
74
|
mountStory(<Stories.Editor />);
|
|
@@ -179,4 +201,22 @@ describe("MarkdownEditor / regressions", () => {
|
|
|
179
201
|
expect(Math.abs(scroller.scrollTop - scrollBefore)).toBeLessThan(20);
|
|
180
202
|
expect(scroller.scrollTop).toBeGreaterThan(0);
|
|
181
203
|
});
|
|
204
|
+
|
|
205
|
+
it("wraps long paragraph lines instead of extending the editor width", async () => {
|
|
206
|
+
mountStory(<WrapFixture />);
|
|
207
|
+
await nextFrame();
|
|
208
|
+
await nextFrame();
|
|
209
|
+
|
|
210
|
+
const scroller = document.querySelector<HTMLElement>(".cm-scroller");
|
|
211
|
+
const content = document.querySelector<HTMLElement>(".cm-content");
|
|
212
|
+
|
|
213
|
+
// `EditorView.lineWrapping` applies via contentAttributes, so the
|
|
214
|
+
// `cm-lineWrapping` class lands on `.cm-content` (not the outer editor).
|
|
215
|
+
expect(content?.classList.contains("cm-lineWrapping")).toBe(true);
|
|
216
|
+
expect(scroller).toBeTruthy();
|
|
217
|
+
expect(content).toBeTruthy();
|
|
218
|
+
if (!scroller || !content) return;
|
|
219
|
+
|
|
220
|
+
expect(content.scrollWidth).toBeLessThanOrEqual(scroller.clientWidth + 2);
|
|
221
|
+
});
|
|
182
222
|
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { MarkdownComponents } from "@src/lib/markdown";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { renderMarkdownClient, visibleText } from "./render-helpers";
|
|
4
|
+
|
|
5
|
+
function Boom(): never {
|
|
6
|
+
throw new Error("boom");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const components: MarkdownComponents = { Boom };
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
vi.restoreAllMocks();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
// Error boundaries recover on the client (React does not recover them during SSR
|
|
16
|
+
// `renderToString`), so these render into jsdom via `renderMarkdownClient`.
|
|
17
|
+
describe("Conformance / error isolation", () => {
|
|
18
|
+
it("renders a fallback for a throwing component instead of crashing", () => {
|
|
19
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
20
|
+
const html = renderMarkdownClient("<Boom />", { components });
|
|
21
|
+
expect(visibleText(html)).toContain("Failed to render component");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("still renders the rest of the document around a throwing component", () => {
|
|
25
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
26
|
+
const html = renderMarkdownClient("Before\n\n<Boom />\n\nAfter", { components });
|
|
27
|
+
const text = visibleText(html);
|
|
28
|
+
expect(text).toContain("Before");
|
|
29
|
+
expect(text).toContain("After");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("renders a fallback for a throwing code renderer", () => {
|
|
33
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
34
|
+
const html = renderMarkdownClient("```boom\ncode\n```", {
|
|
35
|
+
codeRenderers: {
|
|
36
|
+
boom: () => {
|
|
37
|
+
throw new Error("renderer boom");
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
expect(visibleText(html)).toContain("Failed to render code block");
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { renderMarkdown, visibleText } from "./render-helpers";
|
|
3
|
+
|
|
4
|
+
// This library disables CommonMark indented code blocks (see SPEC.md): leading
|
|
5
|
+
// indentation nests JSX children, never forms a code block.
|
|
6
|
+
describe("Conformance / indented code blocks are disabled", () => {
|
|
7
|
+
it("does not turn a 4-space indented line into a code block", () => {
|
|
8
|
+
const html = renderMarkdown(" not code");
|
|
9
|
+
expect(html).not.toContain("<pre");
|
|
10
|
+
expect(visibleText(html)).toContain("not code");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("does not turn a tab-indented line into a code block", () => {
|
|
14
|
+
const html = renderMarkdown("\tnot code");
|
|
15
|
+
expect(html).not.toContain("<pre");
|
|
16
|
+
expect(visibleText(html)).toContain("not code");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("does not turn a deeply indented (8-space) line into a code block", () => {
|
|
20
|
+
const html = renderMarkdown(" still not code");
|
|
21
|
+
expect(html).not.toContain("<pre");
|
|
22
|
+
expect(visibleText(html)).toContain("still not code");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("still renders an explicit fenced code block", () => {
|
|
26
|
+
const html = renderMarkdown("```\nreal code\n```");
|
|
27
|
+
expect(visibleText(html)).toContain("real code");
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { MarkdownComponents } from "@src/lib/markdown";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { renderMarkdown, visibleText } from "./render-helpers";
|
|
4
|
+
|
|
5
|
+
/** Echo component that serialises the props it receives for assertion. */
|
|
6
|
+
function Echo(props: Record<string, unknown>): string {
|
|
7
|
+
const { children, ...rest } = props;
|
|
8
|
+
return `props=${JSON.stringify(rest)}`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const components: MarkdownComponents = { Echo };
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
vi.restoreAllMocks();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("Conformance / JSX attribute JSON", () => {
|
|
18
|
+
it("parses a JSON object value", () => {
|
|
19
|
+
const html = renderMarkdown('<Echo value={{ "a": 1, "b": [2, 3] }} />', { components });
|
|
20
|
+
expect(visibleText(html)).toContain('"value":{"a":1,"b":[2,3]}');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("parses JSON number, boolean, and array values", () => {
|
|
24
|
+
const html = renderMarkdown("<Echo n={42} flag={true} list={[1,2]} />", { components });
|
|
25
|
+
const text = visibleText(html);
|
|
26
|
+
expect(text).toContain('"n":42');
|
|
27
|
+
expect(text).toContain('"flag":true');
|
|
28
|
+
expect(text).toContain('"list":[1,2]');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("parses a quoted string value", () => {
|
|
32
|
+
const html = renderMarkdown('<Echo label="hello" />', { components });
|
|
33
|
+
expect(visibleText(html)).toContain('"label":"hello"');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("treats a bare attribute as a boolean flag", () => {
|
|
37
|
+
const html = renderMarkdown("<Echo disabled />", { components });
|
|
38
|
+
// Bare booleans resolve to `undefined` props (not serialised) by design.
|
|
39
|
+
expect(visibleText(html)).toContain("props=");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("drops an invalid-JSON value and warns in development", () => {
|
|
43
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
44
|
+
const html = renderMarkdown("<Echo value={{ a: 1 }} />", { components });
|
|
45
|
+
// Single-unquoted-key object is not valid JSON → the prop is dropped.
|
|
46
|
+
expect(visibleText(html)).not.toContain('"value"');
|
|
47
|
+
expect(warn).toHaveBeenCalled();
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("Conformance / inline expressions are literal", () => {
|
|
52
|
+
it("renders a lone brace expression as text", () => {
|
|
53
|
+
const html = renderMarkdown("Count: {count} items");
|
|
54
|
+
expect(visibleText(html)).toContain("Count: {count} items");
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { EditorState } from "@codemirror/state";
|
|
2
|
+
import { EditorView } from "@codemirror/view";
|
|
3
|
+
import { MantineProvider, v8CssVariablesResolver } from "@mantine/core";
|
|
4
|
+
import { markdownModeField, markdownSourceFromString, parseMarkdownTree } from "@src/lib/markdown";
|
|
5
|
+
import { mergeMarkdownComponents } from "@src/lib/markdown/components/defaults";
|
|
6
|
+
import { markdownDecorations } from "@src/lib/markdown/editor/decorations";
|
|
7
|
+
import { setMarkdownEditorFocused } from "@src/lib/markdown/editor/decorations/editor-focus";
|
|
8
|
+
import { setMarkdownMode } from "@src/lib/markdown/editor/mode";
|
|
9
|
+
import { createWidgetStore } from "@src/lib/markdown/editor/widgets/store";
|
|
10
|
+
import { Callout } from "@src/lib/markdown/grammar/callout";
|
|
11
|
+
import { createMarkdownLanguage } from "@src/lib/markdown/grammar/language";
|
|
12
|
+
import { collectBlockHtmlDescriptors } from "@src/lib/markdown/tree/html";
|
|
13
|
+
import { MarkdownViewer } from "@src/primitives/MarkdownViewer";
|
|
14
|
+
import { MANTINE_THEME } from "@src/theme/mantine";
|
|
15
|
+
import { renderToString } from "react-dom/server";
|
|
16
|
+
import { beforeAll, describe, expect, it } from "vitest";
|
|
17
|
+
|
|
18
|
+
beforeAll(() => {
|
|
19
|
+
if (typeof window !== "undefined" && typeof window.matchMedia !== "function") {
|
|
20
|
+
Object.defineProperty(window, "matchMedia", {
|
|
21
|
+
writable: true,
|
|
22
|
+
value: (query: string) => ({
|
|
23
|
+
matches: false,
|
|
24
|
+
media: query,
|
|
25
|
+
onchange: null,
|
|
26
|
+
addListener: () => {},
|
|
27
|
+
removeListener: () => {},
|
|
28
|
+
addEventListener: () => {},
|
|
29
|
+
removeEventListener: () => {},
|
|
30
|
+
dispatchEvent: () => false,
|
|
31
|
+
}),
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
/** Deterministic LCG so failures reproduce (no Math.random). */
|
|
37
|
+
function makeRng(seed: number): () => number {
|
|
38
|
+
let s = seed >>> 0;
|
|
39
|
+
return () => {
|
|
40
|
+
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
|
|
41
|
+
return s / 0x100000000;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Fragments chosen to stress tag pairing, attributes, nesting, blank lines,
|
|
46
|
+
// indentation, and malformed/adversarial shapes.
|
|
47
|
+
const FRAGMENTS = [
|
|
48
|
+
"<Tabs>",
|
|
49
|
+
"</Tabs>",
|
|
50
|
+
'<Tabs syncKey="a b c">',
|
|
51
|
+
'<TabItem label="One">',
|
|
52
|
+
'<TabItem label="Two" icon="star">',
|
|
53
|
+
"</TabItem>",
|
|
54
|
+
"<Since />",
|
|
55
|
+
'<Since v="1.0.0" />',
|
|
56
|
+
'<Button label="Go" href="https://x.example" />',
|
|
57
|
+
"<div>",
|
|
58
|
+
"</div>",
|
|
59
|
+
"<details>",
|
|
60
|
+
"<summary>Toggle</summary>",
|
|
61
|
+
"</details>",
|
|
62
|
+
"<strong>bold</strong>",
|
|
63
|
+
"<em>em",
|
|
64
|
+
"</em>",
|
|
65
|
+
'text before <TabItem label="Inline"> and after',
|
|
66
|
+
'<Echo value={{ "k": [1, 2, {"n": 3}] }} />',
|
|
67
|
+
"<Echo value={ broken json } />",
|
|
68
|
+
'<Echo value="unterminated ',
|
|
69
|
+
'<Tabs><TabItem label="Nested">deep</TabItem></Tabs>',
|
|
70
|
+
"\tindented content",
|
|
71
|
+
" four space indent",
|
|
72
|
+
"para line one",
|
|
73
|
+
"",
|
|
74
|
+
"",
|
|
75
|
+
"# heading",
|
|
76
|
+
"> quote",
|
|
77
|
+
"- list item",
|
|
78
|
+
"`code`",
|
|
79
|
+
"<https://autolink.example>",
|
|
80
|
+
"<not a tag",
|
|
81
|
+
"a > b < c",
|
|
82
|
+
"</Orphan>",
|
|
83
|
+
"<Weird a={{{}}} b='x' c=\"y\" />",
|
|
84
|
+
"| a | b |\n| - | - |\n| 1 | 2 |",
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
const COMPONENTS = mergeMarkdownComponents({
|
|
88
|
+
Echo: (props: Record<string, unknown>) => JSON.stringify(props.value ?? null),
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
function buildDoc(rng: () => number): string {
|
|
92
|
+
const lines = 1 + Math.floor(rng() * 12);
|
|
93
|
+
const parts: string[] = [];
|
|
94
|
+
for (let i = 0; i < lines; i++) {
|
|
95
|
+
parts.push(FRAGMENTS[Math.floor(rng() * FRAGMENTS.length)] ?? "");
|
|
96
|
+
}
|
|
97
|
+
return parts.join("\n");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function renderViewer(doc: string): void {
|
|
101
|
+
renderToString(
|
|
102
|
+
<MantineProvider
|
|
103
|
+
theme={MANTINE_THEME}
|
|
104
|
+
cssVariablesResolver={v8CssVariablesResolver}
|
|
105
|
+
forceColorScheme="dark"
|
|
106
|
+
>
|
|
107
|
+
<MarkdownViewer
|
|
108
|
+
content={doc}
|
|
109
|
+
components={COMPONENTS}
|
|
110
|
+
/>
|
|
111
|
+
</MantineProvider>,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function buildEditor(doc: string): void {
|
|
116
|
+
const state = EditorState.create({
|
|
117
|
+
doc,
|
|
118
|
+
extensions: [
|
|
119
|
+
createMarkdownLanguage({ extensions: [Callout] }),
|
|
120
|
+
markdownModeField,
|
|
121
|
+
markdownDecorations({ widgetStore: createWidgetStore() }),
|
|
122
|
+
],
|
|
123
|
+
});
|
|
124
|
+
const parent = document.createElement("div");
|
|
125
|
+
document.body.appendChild(parent);
|
|
126
|
+
const view = new EditorView({ state, parent });
|
|
127
|
+
view.dispatch({ effects: setMarkdownMode.of("preview") });
|
|
128
|
+
view.dispatch({ effects: setMarkdownEditorFocused.of(true) });
|
|
129
|
+
// Sweep the caret through the doc so active/reveal paths are exercised.
|
|
130
|
+
for (let pos = 0; pos <= doc.length; pos += Math.max(1, Math.floor(doc.length / 6))) {
|
|
131
|
+
view.dispatch({ selection: { anchor: pos } });
|
|
132
|
+
}
|
|
133
|
+
view.destroy();
|
|
134
|
+
parent.remove();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
describe("MarkdownConformance / JSX fuzz", () => {
|
|
138
|
+
// Each case runs a full Mantine SSR render plus an EditorView build with a
|
|
139
|
+
// caret sweep, so keep the count modest and grant a generous timeout for CI.
|
|
140
|
+
it("never throws and keeps descriptor offsets sane across random JSX/HTML docs", () => {
|
|
141
|
+
const rng = makeRng(0xc0ffee);
|
|
142
|
+
for (let i = 0; i < 200; i++) {
|
|
143
|
+
const doc = buildDoc(rng);
|
|
144
|
+
try {
|
|
145
|
+
const tree = parseMarkdownTree(doc);
|
|
146
|
+
const src = markdownSourceFromString(doc);
|
|
147
|
+
const descs = collectBlockHtmlDescriptors(tree, src, COMPONENTS);
|
|
148
|
+
for (const d of descs) {
|
|
149
|
+
expect(d.openFrom).toBeGreaterThanOrEqual(0);
|
|
150
|
+
expect(d.openTo).toBeGreaterThan(d.openFrom);
|
|
151
|
+
if (d.closeTo !== null) {
|
|
152
|
+
expect(d.closeTo).toBeGreaterThanOrEqual(d.openTo);
|
|
153
|
+
expect(d.closeTo).toBeLessThanOrEqual(doc.length);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
renderViewer(doc);
|
|
157
|
+
buildEditor(doc);
|
|
158
|
+
} catch (err) {
|
|
159
|
+
throw new Error(
|
|
160
|
+
`Fuzz case #${i} threw for input:\n${JSON.stringify(doc)}\n\n${String(err)}`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}, 30_000);
|
|
165
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { renderMarkdown, visibleText } from "./render-helpers";
|
|
3
|
+
|
|
4
|
+
describe("Conformance / autolinks", () => {
|
|
5
|
+
it("keeps an absolute URL href unchanged", () => {
|
|
6
|
+
const html = renderMarkdown("Go https://example.com now");
|
|
7
|
+
expect(html).toContain('href="https://example.com"');
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("adds https:// to a www. autolink", () => {
|
|
11
|
+
const html = renderMarkdown("Visit www.example.com today");
|
|
12
|
+
expect(html).toContain('href="https://www.example.com"');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("adds mailto: to a bare email autolink", () => {
|
|
16
|
+
const html = renderMarkdown("Mail me@example.com please");
|
|
17
|
+
expect(html).toContain('href="mailto:me@example.com"');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("renders an angle-bracket URL autolink as an anchor", () => {
|
|
21
|
+
const html = renderMarkdown("See <https://example.com> here");
|
|
22
|
+
expect(html).toContain('href="https://example.com"');
|
|
23
|
+
expect(visibleText(html)).toContain("https://example.com");
|
|
24
|
+
// The angle brackets are delimiters, not visible content.
|
|
25
|
+
expect(visibleText(html)).not.toContain("<https://example.com>");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("renders an angle-bracket email autolink with mailto:", () => {
|
|
29
|
+
const html = renderMarkdown("Mail <me@example.com> ok");
|
|
30
|
+
expect(html).toContain('href="mailto:me@example.com"');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("does not linkify a URL inside inline code", () => {
|
|
34
|
+
const html = renderMarkdown("Use `https://example.com` verbatim");
|
|
35
|
+
expect(html).not.toContain('href="https://example.com"');
|
|
36
|
+
expect(html).toContain("https://example.com");
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("Conformance / reference-style links", () => {
|
|
41
|
+
it("resolves a full reference link [text][id]", () => {
|
|
42
|
+
const html = renderMarkdown("[click here][site] now\n\n[site]: https://example.com");
|
|
43
|
+
expect(html).toContain('href="https://example.com"');
|
|
44
|
+
expect(visibleText(html)).toContain("click here");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("resolves a collapsed reference link [id][]", () => {
|
|
48
|
+
const html = renderMarkdown("[site][] now\n\n[site]: https://example.com");
|
|
49
|
+
expect(html).toContain('href="https://example.com"');
|
|
50
|
+
expect(visibleText(html)).toContain("site");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("resolves a shortcut reference link [id]", () => {
|
|
54
|
+
const html = renderMarkdown("[site] now\n\n[site]: https://example.com");
|
|
55
|
+
expect(html).toContain('href="https://example.com"');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("matches reference labels case-insensitively", () => {
|
|
59
|
+
const html = renderMarkdown("[Click][SITE] now\n\n[site]: https://example.com");
|
|
60
|
+
expect(html).toContain('href="https://example.com"');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("emits the visible text (no anchor) when the reference is undefined", () => {
|
|
64
|
+
const html = renderMarkdown("see [missing][nope] end");
|
|
65
|
+
expect(html).not.toContain("href=");
|
|
66
|
+
const text = visibleText(html);
|
|
67
|
+
expect(text).toContain("missing");
|
|
68
|
+
// Reference brackets/label are not shown as literal text.
|
|
69
|
+
expect(text).not.toContain("[missing]");
|
|
70
|
+
});
|
|
71
|
+
});
|