@surrealdb/ui 1.2.3 → 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/README.md +16 -0
- package/REVIEW.md +2 -2
- package/dist/ui.css +1 -1
- package/dist/ui.d.ts +257 -110
- package/dist/ui.js +7036 -6307
- 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
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { MantineProvider, v8CssVariablesResolver } from "@mantine/core";
|
|
2
|
+
import type { MarkdownComponents } from "@src/lib/markdown";
|
|
3
|
+
import { MarkdownViewer } from "@src/primitives/MarkdownViewer";
|
|
4
|
+
import { MANTINE_THEME } from "@src/theme/mantine";
|
|
5
|
+
import { Children, isValidElement, type ReactElement, type ReactNode } 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
|
+
/**
|
|
40
|
+
* A `<Tabs>`-shaped block component that renders every item's label AND content
|
|
41
|
+
* (unlike Mantine's `Tabs`, which omits inactive panels in SSR). Lets a single
|
|
42
|
+
* `renderToString` assert the full parsed structure of all tab bodies.
|
|
43
|
+
*/
|
|
44
|
+
function EagerGroup({ children }: { children?: ReactNode }): ReactElement {
|
|
45
|
+
const items = Children.toArray(children).filter(isValidElement);
|
|
46
|
+
return (
|
|
47
|
+
<div data-eager-group>
|
|
48
|
+
<div
|
|
49
|
+
data-labels={items.map((el) => (el.props as { label?: string }).label).join("|")}
|
|
50
|
+
/>
|
|
51
|
+
{items.map((el, i) => (
|
|
52
|
+
<section
|
|
53
|
+
data-panel={i}
|
|
54
|
+
key={(el.props as { label?: string }).label ?? i}
|
|
55
|
+
>
|
|
56
|
+
{(el.props as { children?: ReactNode }).children}
|
|
57
|
+
</section>
|
|
58
|
+
))}
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function EagerItem({ children }: { children?: ReactNode }): ReactElement {
|
|
64
|
+
return <>{children}</>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const EAGER_COMPONENTS: MarkdownComponents = {
|
|
68
|
+
Tabs: { component: EagerGroup, block: true },
|
|
69
|
+
TabItem: { component: EagerItem, block: true },
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
function stripTags(html: string): string {
|
|
73
|
+
return html.replace(/<[^>]+>/g, "");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe("MarkdownViewer / JSX block nesting", () => {
|
|
77
|
+
it("pairs a Tabs block into exactly its authored TabItems (no synthetic tabs)", () => {
|
|
78
|
+
const content = `<Tabs syncKey="demo">
|
|
79
|
+
<TabItem label="Create an instance">
|
|
80
|
+
\tSurrealDB Cloud allows you to connect.
|
|
81
|
+
|
|
82
|
+
\t**1. Create Instance.**
|
|
83
|
+
|
|
84
|
+
\tWhen creating an instance, you enter the name.
|
|
85
|
+
</TabItem>
|
|
86
|
+
<TabItem label="Connect to your instance">
|
|
87
|
+
\tYou can connect via [Surrealist](/docs/x).
|
|
88
|
+
|
|
89
|
+
\tSince SurrealDB Cloud is closely integrated.
|
|
90
|
+
</TabItem>
|
|
91
|
+
</Tabs>`;
|
|
92
|
+
const html = shell(<MarkdownViewer content={content} />);
|
|
93
|
+
|
|
94
|
+
// Exactly two tab buttons with the authored labels — no paragraphs
|
|
95
|
+
// leaking out as extra "Tab N" children.
|
|
96
|
+
const tabButtons = html.match(/mantine-Tabs-tab\b/g) ?? [];
|
|
97
|
+
expect(tabButtons).toHaveLength(2);
|
|
98
|
+
const labels = [...html.matchAll(/mantine-Tabs-tabLabel">([^<]*)</g)].map((m) => m[1]);
|
|
99
|
+
expect(labels).toEqual(["Create an instance", "Connect to your instance"]);
|
|
100
|
+
|
|
101
|
+
// The active tab renders its full multi-paragraph markdown body.
|
|
102
|
+
const text = stripTags(html);
|
|
103
|
+
expect(text).toContain("SurrealDB Cloud allows you to connect.");
|
|
104
|
+
expect(text).toContain("1. Create Instance.");
|
|
105
|
+
expect(text).toContain("When creating an instance");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("renders full multi-paragraph markdown for every TabItem (via eager group)", () => {
|
|
109
|
+
const content = `<Tabs syncKey="demo">
|
|
110
|
+
<TabItem label="Create an instance">
|
|
111
|
+
\tSurrealDB Cloud allows you to connect.
|
|
112
|
+
|
|
113
|
+
\t**1. Create Instance.**
|
|
114
|
+
|
|
115
|
+
\tWhen creating an instance, you enter the name.
|
|
116
|
+
</TabItem>
|
|
117
|
+
<TabItem label="Connect to your instance">
|
|
118
|
+
\tYou can connect via [Surrealist](/docs/x).
|
|
119
|
+
|
|
120
|
+
\t[Connect via Surrealist](/docs/y)
|
|
121
|
+
|
|
122
|
+
\tSince SurrealDB Cloud is closely integrated.
|
|
123
|
+
</TabItem>
|
|
124
|
+
</Tabs>`;
|
|
125
|
+
const html = shell(
|
|
126
|
+
<MarkdownViewer
|
|
127
|
+
content={content}
|
|
128
|
+
components={EAGER_COMPONENTS}
|
|
129
|
+
/>,
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
expect(html).toContain('data-labels="Create an instance|Connect to your instance"');
|
|
133
|
+
expect(html).not.toContain("Tab 2");
|
|
134
|
+
|
|
135
|
+
// Both panels rendered, in order, with all their paragraphs.
|
|
136
|
+
const text = stripTags(html);
|
|
137
|
+
expect(text).toContain("SurrealDB Cloud allows you to connect.");
|
|
138
|
+
expect(text).toContain("1. Create Instance.");
|
|
139
|
+
expect(text).toContain("When creating an instance");
|
|
140
|
+
expect(text).toContain("You can connect via Surrealist");
|
|
141
|
+
expect(text).toContain("closely integrated");
|
|
142
|
+
// Links inside both tabs survive.
|
|
143
|
+
expect(html).toContain('href="/docs/x"');
|
|
144
|
+
expect(html).toContain('href="/docs/y"');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("renders a single-paragraph Tabs block (regression)", () => {
|
|
148
|
+
const content = `<Tabs syncKey="s">
|
|
149
|
+
<TabItem label="First">
|
|
150
|
+
Alpha content.
|
|
151
|
+
</TabItem>
|
|
152
|
+
<TabItem label="Second">
|
|
153
|
+
Beta content.
|
|
154
|
+
</TabItem>
|
|
155
|
+
</Tabs>`;
|
|
156
|
+
const html = shell(
|
|
157
|
+
<MarkdownViewer
|
|
158
|
+
content={content}
|
|
159
|
+
components={EAGER_COMPONENTS}
|
|
160
|
+
/>,
|
|
161
|
+
);
|
|
162
|
+
expect(html).toContain('data-labels="First|Second"');
|
|
163
|
+
expect(html).not.toContain("Tab 2");
|
|
164
|
+
const text = stripTags(html);
|
|
165
|
+
expect(text).toContain("Alpha content.");
|
|
166
|
+
expect(text).toContain("Beta content.");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("renders block content around a Tabs block", () => {
|
|
170
|
+
const content = `# Heading
|
|
171
|
+
|
|
172
|
+
Intro paragraph.
|
|
173
|
+
|
|
174
|
+
<Tabs syncKey="s">
|
|
175
|
+
<TabItem label="One">
|
|
176
|
+
First tab.
|
|
177
|
+
</TabItem>
|
|
178
|
+
<TabItem label="Two">
|
|
179
|
+
Second tab.
|
|
180
|
+
</TabItem>
|
|
181
|
+
</Tabs>
|
|
182
|
+
|
|
183
|
+
Outro paragraph.`;
|
|
184
|
+
const html = shell(
|
|
185
|
+
<MarkdownViewer
|
|
186
|
+
content={content}
|
|
187
|
+
components={EAGER_COMPONENTS}
|
|
188
|
+
/>,
|
|
189
|
+
);
|
|
190
|
+
const text = stripTags(html);
|
|
191
|
+
expect(text).toContain("Heading");
|
|
192
|
+
expect(text).toContain("Intro paragraph.");
|
|
193
|
+
expect(text).toContain("Outro paragraph.");
|
|
194
|
+
expect(html).toContain('data-labels="One|Two"');
|
|
195
|
+
expect(text).toContain("First tab.");
|
|
196
|
+
expect(text).toContain("Second tab.");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("renders nested block components (Tabs inside a TabItem)", () => {
|
|
200
|
+
const content = `<Tabs syncKey="outer">
|
|
201
|
+
<TabItem label="Outer A">
|
|
202
|
+
Outer A body.
|
|
203
|
+
|
|
204
|
+
<Tabs syncKey="inner">
|
|
205
|
+
<TabItem label="Inner X">
|
|
206
|
+
Inner X body.
|
|
207
|
+
</TabItem>
|
|
208
|
+
<TabItem label="Inner Y">
|
|
209
|
+
Inner Y body.
|
|
210
|
+
</TabItem>
|
|
211
|
+
</Tabs>
|
|
212
|
+
</TabItem>
|
|
213
|
+
<TabItem label="Outer B">
|
|
214
|
+
Outer B body.
|
|
215
|
+
</TabItem>
|
|
216
|
+
</Tabs>`;
|
|
217
|
+
const html = shell(
|
|
218
|
+
<MarkdownViewer
|
|
219
|
+
content={content}
|
|
220
|
+
components={EAGER_COMPONENTS}
|
|
221
|
+
/>,
|
|
222
|
+
);
|
|
223
|
+
// Outer group has exactly the two authored tabs.
|
|
224
|
+
expect(html).toContain('data-labels="Outer A|Outer B"');
|
|
225
|
+
// Inner group nested inside Outer A has its own two tabs.
|
|
226
|
+
expect(html).toContain('data-labels="Inner X|Inner Y"');
|
|
227
|
+
expect(html).not.toContain("Tab 2");
|
|
228
|
+
const text = stripTags(html);
|
|
229
|
+
expect(text).toContain("Outer A body.");
|
|
230
|
+
expect(text).toContain("Outer B body.");
|
|
231
|
+
expect(text).toContain("Inner X body.");
|
|
232
|
+
expect(text).toContain("Inner Y body.");
|
|
233
|
+
});
|
|
234
|
+
});
|
|
@@ -99,12 +99,12 @@ Hidden **bold** text
|
|
|
99
99
|
expect(headings[1]?.text).toBe("Sub");
|
|
100
100
|
});
|
|
101
101
|
|
|
102
|
-
it("
|
|
102
|
+
it("onResolveMedia rewrites image src", () => {
|
|
103
103
|
const html = shell(
|
|
104
104
|
<MarkdownViewer
|
|
105
105
|
content='<img src="@ui/pictoAI" />'
|
|
106
106
|
jsxMode="render"
|
|
107
|
-
|
|
107
|
+
onResolveMedia={(node) => ({ ...node, src: "https://example.com/resolved.png" })}
|
|
108
108
|
/>,
|
|
109
109
|
);
|
|
110
110
|
expect(html).toContain("https://example.com/resolved.png");
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { MantineProvider, v8CssVariablesResolver } from "@mantine/core";
|
|
2
|
+
import type { MarkdownComponents } from "@src/lib/markdown";
|
|
3
|
+
import { MarkdownViewer } from "@src/primitives/MarkdownViewer";
|
|
4
|
+
import { MANTINE_THEME } from "@src/theme/mantine";
|
|
5
|
+
import type { ReactElement, ReactNode } 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
|
+
function TwoColumn({ children }: { children?: ReactNode }): ReactElement {
|
|
40
|
+
return <div data-testid="two-column">{children}</div>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function ColBox({ children }: { children?: ReactNode }): ReactElement {
|
|
44
|
+
return <div data-testid="col-box">{children}</div>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const LAYOUT = `<TwoColumn>
|
|
48
|
+
<ColBox></ColBox>
|
|
49
|
+
<ColBox></ColBox>
|
|
50
|
+
<ColBox></ColBox>
|
|
51
|
+
</TwoColumn>`;
|
|
52
|
+
|
|
53
|
+
describe("MarkdownViewer / custom component preserveNewlines", () => {
|
|
54
|
+
it("does not inject <br /> between block children (they render as blocks)", () => {
|
|
55
|
+
const components: MarkdownComponents = {
|
|
56
|
+
TwoColumn: { component: TwoColumn, block: true },
|
|
57
|
+
ColBox: { component: ColBox, block: true },
|
|
58
|
+
};
|
|
59
|
+
const html = shell(
|
|
60
|
+
<MarkdownViewer
|
|
61
|
+
content={LAYOUT}
|
|
62
|
+
components={components}
|
|
63
|
+
/>,
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
// Three boxes still render, in order.
|
|
67
|
+
expect(html.match(/data-testid="col-box"/g)?.length).toBe(3);
|
|
68
|
+
// Block-level children are carved out and rendered as their own blocks:
|
|
69
|
+
// the newlines that merely separate them must NOT become <br /> elements.
|
|
70
|
+
// A stray <br /> here would also be counted as an extra child by
|
|
71
|
+
// introspecting parents (e.g. <Tabs> reading each <TabItem>), so the two
|
|
72
|
+
// behaviours are unified — `preserveNewlines` governs prose line breaks
|
|
73
|
+
// (see the prose test below), not gaps between block children.
|
|
74
|
+
const inner = html.slice(
|
|
75
|
+
html.indexOf('data-testid="two-column"'),
|
|
76
|
+
html.lastIndexOf("</div>"),
|
|
77
|
+
);
|
|
78
|
+
expect(inner).not.toMatch(/<br\s*\/?>/);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("omits <br /> between block children when preserveNewlines is false", () => {
|
|
82
|
+
const components: MarkdownComponents = {
|
|
83
|
+
TwoColumn: { component: TwoColumn, block: true, preserveNewlines: false },
|
|
84
|
+
ColBox: { component: ColBox, block: true },
|
|
85
|
+
};
|
|
86
|
+
const html = shell(
|
|
87
|
+
<MarkdownViewer
|
|
88
|
+
content={LAYOUT}
|
|
89
|
+
components={components}
|
|
90
|
+
/>,
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
// All three boxes still render.
|
|
94
|
+
expect(html.match(/data-testid="col-box"/g)?.length).toBe(3);
|
|
95
|
+
// The TwoColumn wrapper is present.
|
|
96
|
+
expect(html).toContain('data-testid="two-column"');
|
|
97
|
+
|
|
98
|
+
// No stray line breaks injected between the child blocks.
|
|
99
|
+
const inner = html.slice(
|
|
100
|
+
html.indexOf('data-testid="two-column"'),
|
|
101
|
+
html.lastIndexOf("</div>"),
|
|
102
|
+
);
|
|
103
|
+
expect(inner).not.toMatch(/<br\s*\/?>/);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("still preserves newlines for prose components by default", () => {
|
|
107
|
+
function Prose({ children }: { children?: ReactNode }): ReactElement {
|
|
108
|
+
return <section data-testid="prose">{children}</section>;
|
|
109
|
+
}
|
|
110
|
+
const components: MarkdownComponents = {
|
|
111
|
+
Prose: { component: Prose, block: true },
|
|
112
|
+
};
|
|
113
|
+
const html = shell(
|
|
114
|
+
<MarkdownViewer
|
|
115
|
+
content={"<Prose>\nLine one\nLine two\n</Prose>"}
|
|
116
|
+
components={components}
|
|
117
|
+
/>,
|
|
118
|
+
);
|
|
119
|
+
expect(html).toContain("Line one");
|
|
120
|
+
expect(html).toContain("Line two");
|
|
121
|
+
// Multi-line prose keeps its line break.
|
|
122
|
+
expect(html).toMatch(/<br\s*\/?>/);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("disabling preserveNewlines collapses multi-line text to a single run", () => {
|
|
126
|
+
function Prose({ children }: { children?: ReactNode }): ReactElement {
|
|
127
|
+
return <section data-testid="prose">{children}</section>;
|
|
128
|
+
}
|
|
129
|
+
const components: MarkdownComponents = {
|
|
130
|
+
Prose: { component: Prose, block: true, preserveNewlines: false },
|
|
131
|
+
};
|
|
132
|
+
const html = shell(
|
|
133
|
+
<MarkdownViewer
|
|
134
|
+
content={"<Prose>\nLine one\nLine two\n</Prose>"}
|
|
135
|
+
components={components}
|
|
136
|
+
/>,
|
|
137
|
+
);
|
|
138
|
+
expect(html).toContain("Line one");
|
|
139
|
+
expect(html).toContain("Line two");
|
|
140
|
+
const inner = html.slice(html.indexOf('data-testid="prose"'));
|
|
141
|
+
expect(inner).not.toMatch(/<br\s*\/?>/);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import {
|
|
2
|
+
estimateMarkdownReadTimeMinutes,
|
|
3
|
+
formatMarkdownReadTime,
|
|
4
|
+
markdownToPlainText,
|
|
5
|
+
} from "@src/lib/markdown/read-time";
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
7
|
+
|
|
8
|
+
describe("markdown read time", () => {
|
|
9
|
+
it("strips markdown syntax for word counting", () => {
|
|
10
|
+
expect(markdownToPlainText("# Title\n\n[link](https://example.com)")).toBe("Title link");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("estimates at least one minute", () => {
|
|
14
|
+
expect(estimateMarkdownReadTimeMinutes("")).toBe(1);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("formats a read-time label", () => {
|
|
18
|
+
const words = Array.from({ length: 400 }, () => "word").join(" ");
|
|
19
|
+
expect(formatMarkdownReadTime(words)).toBe("2 min read");
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -63,6 +63,22 @@ describe("MarkdownViewer / MarkdownViewerRenderers", () => {
|
|
|
63
63
|
expect(html).not.toContain("https://example.com");
|
|
64
64
|
});
|
|
65
65
|
|
|
66
|
+
it("uses table renderer override without the scroll wrapper", () => {
|
|
67
|
+
const html = shell(
|
|
68
|
+
<MarkdownViewer
|
|
69
|
+
content={`| A | B |
|
|
70
|
+
| - | - |
|
|
71
|
+
| 1 | 2 |`}
|
|
72
|
+
renderers={{
|
|
73
|
+
table: () => <span data-custom-table>TABLE_MARKER</span>,
|
|
74
|
+
}}
|
|
75
|
+
/>,
|
|
76
|
+
);
|
|
77
|
+
expect(html).toContain("TABLE_MARKER");
|
|
78
|
+
expect(html).not.toContain("<table");
|
|
79
|
+
expect(html).not.toContain("mantine-TableScrollContainer-scrollContainer");
|
|
80
|
+
});
|
|
81
|
+
|
|
66
82
|
it("uses image renderer override", () => {
|
|
67
83
|
const html = shell(
|
|
68
84
|
<MarkdownViewer
|
|
@@ -0,0 +1,73 @@
|
|
|
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
|
+
import { md } from "../../_setup/markdown-classes";
|
|
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 / syntax highlighting", () => {
|
|
40
|
+
it("highlights bash and sh fenced blocks", () => {
|
|
41
|
+
const html = shell(
|
|
42
|
+
<MarkdownViewer
|
|
43
|
+
content={`\`\`\`bash
|
|
44
|
+
cd project
|
|
45
|
+
\`\`\`
|
|
46
|
+
|
|
47
|
+
\`\`\`sh
|
|
48
|
+
export FOO=bar
|
|
49
|
+
\`\`\`
|
|
50
|
+
`}
|
|
51
|
+
/>,
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
expect(html).toContain(md.codeBlock);
|
|
55
|
+
expect(html).toMatch(/style="color:#ffd000"/);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("highlights json fenced blocks", () => {
|
|
59
|
+
const html = shell(
|
|
60
|
+
<MarkdownViewer
|
|
61
|
+
content={`\`\`\`json
|
|
62
|
+
{
|
|
63
|
+
"name": "example"
|
|
64
|
+
}
|
|
65
|
+
\`\`\`
|
|
66
|
+
`}
|
|
67
|
+
/>,
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
expect(html).toContain(md.codeBlock);
|
|
71
|
+
expect(html).toMatch(/style="color:#00ff6e"/);
|
|
72
|
+
});
|
|
73
|
+
});
|