@surrealdb/ui 1.2.11 → 1.2.13
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/.claude/launch.json +17 -0
- package/.oxfmtrc.json +27 -0
- package/.oxlintrc.json +23 -0
- package/.zed/settings.json +34 -34
- package/AGENTS.md +21 -19
- package/README.md +15 -10
- package/dist/assets/245b1ccaa4927818.svg +3 -0
- package/dist/assets/333e1fe6e67ca4a2.svg +10 -0
- package/dist/assets/4b0e10c655814886.svg +21 -0
- package/dist/assets/9932da008ea5dd32.svg +3 -0
- package/dist/assets/a372168d5789daa2.svg +4 -0
- package/dist/assets/b140e44ee4ae7e35.svg +5 -0
- package/dist/assets/e028075817d928e4.svg +3 -0
- package/dist/assets/ee1065ad5a8f7f14.svg +6 -0
- package/dist/ui.css +1 -1
- package/dist/ui.d.ts +579 -1
- package/dist/ui.js +11553 -8988
- package/dist/ui.js.map +1 -1
- package/doc/code-style.md +264 -0
- package/doc/stacker-plan.md +219 -0
- package/package.json +119 -115
- package/res/_mixins.scss +15 -11
- package/tests/_setup/e2e-helpers.tsx +85 -85
- package/tests/_setup/portable-stories.ts +11 -10
- package/tests/e2e/MarkdownEditor/content-blocks.test.tsx +140 -139
- package/tests/e2e/MarkdownEditor/edits.test.tsx +101 -100
- package/tests/e2e/MarkdownEditor/heading-fold.test.tsx +35 -34
- package/tests/e2e/MarkdownEditor/hybrid-widgets.test.tsx +178 -177
- package/tests/e2e/MarkdownEditor/image-on-image.test.tsx +50 -49
- package/tests/e2e/MarkdownEditor/jsx-block-content.test.tsx +195 -194
- package/tests/e2e/MarkdownEditor/jsx-highlight.test.tsx +47 -46
- package/tests/e2e/MarkdownEditor/jsx-inline-badges.test.tsx +35 -34
- package/tests/e2e/MarkdownEditor/jsx-inline-click.test.tsx +39 -38
- package/tests/e2e/MarkdownEditor/jsx-selection.test.tsx +28 -27
- package/tests/e2e/MarkdownEditor/link-placeholder.test.tsx +49 -48
- package/tests/e2e/MarkdownEditor/media-align.test.tsx +40 -39
- package/tests/e2e/MarkdownEditor/media-edit.test.tsx +49 -48
- package/tests/e2e/MarkdownEditor/media-sizing.test.tsx +108 -107
- package/tests/e2e/MarkdownEditor/modes.test.tsx +79 -78
- package/tests/e2e/MarkdownEditor/onimage-parity.test.tsx +29 -28
- package/tests/e2e/MarkdownEditor/regressions.test.tsx +194 -193
- package/tests/e2e/MarkdownEditor/slash-commands.test.tsx +81 -80
- package/tests/e2e/MarkdownEditor/table-click.test.tsx +36 -35
- package/tests/e2e/MarkdownEditor/table-controls.test.tsx +44 -43
- package/tests/e2e/MarkdownEditor/table-format.test.tsx +28 -27
- package/tests/e2e/MarkdownEditor/undo-redo.test.tsx +22 -21
- package/tests/e2e/MarkdownViewer/parity.test.tsx +144 -143
- package/tests/e2e/MarkdownViewer/render.test.tsx +20 -19
- package/tests/e2e/Stacker/stacker.test.tsx +304 -0
- package/tests/unit/Editor/helpers.test.ts +26 -26
- package/tests/unit/MarkdownConformance/error-isolation.test.tsx +26 -25
- package/tests/unit/MarkdownConformance/indentation.test.tsx +20 -19
- package/tests/unit/MarkdownConformance/inline-code-fuzz.test.tsx +255 -0
- package/tests/unit/MarkdownConformance/jsx-attributes.test.tsx +39 -38
- package/tests/unit/MarkdownConformance/jsx-fuzz.test.tsx +122 -122
- package/tests/unit/MarkdownConformance/links.test.tsx +78 -54
- package/tests/unit/MarkdownConformance/render-helpers.tsx +60 -60
- package/tests/unit/MarkdownEditor/code-info.test.ts +58 -58
- package/tests/unit/MarkdownEditor/decorations.test.ts +423 -421
- package/tests/unit/MarkdownEditor/editor-ready.test.ts +22 -22
- package/tests/unit/MarkdownEditor/html-descriptors.test.ts +70 -70
- package/tests/unit/MarkdownEditor/indented-fence.test.ts +80 -80
- package/tests/unit/MarkdownEditor/jsx-attr-scan.test.ts +87 -87
- package/tests/unit/MarkdownEditor/jsx-block-widget.test.ts +51 -51
- package/tests/unit/MarkdownEditor/jsx-tag-grammar.test.ts +63 -63
- package/tests/unit/MarkdownEditor/list-indent.test.ts +67 -67
- package/tests/unit/MarkdownEditor/slash-commands.test.ts +150 -150
- package/tests/unit/MarkdownEditor/table-format.test.ts +67 -67
- package/tests/unit/MarkdownEditor/table.test.ts +96 -96
- package/tests/unit/MarkdownEditor/triggers.test.ts +167 -167
- package/tests/unit/MarkdownEditor/widget-store.test.ts +90 -90
- package/tests/unit/MarkdownViewer/callout.test.tsx +42 -42
- package/tests/unit/MarkdownViewer/code-highlight.test.tsx +41 -41
- package/tests/unit/MarkdownViewer/code-title.test.tsx +45 -45
- package/tests/unit/MarkdownViewer/features.test.tsx +129 -129
- package/tests/unit/MarkdownViewer/headings.test.tsx +28 -28
- package/tests/unit/MarkdownViewer/indented-jsx-children.test.tsx +53 -53
- package/tests/unit/MarkdownViewer/jsx-block-nesting.test.tsx +137 -137
- package/tests/unit/MarkdownViewer/jsx.test.tsx +188 -188
- package/tests/unit/MarkdownViewer/list-bullets.test.tsx +31 -31
- package/tests/unit/MarkdownViewer/list-code.test.tsx +45 -45
- package/tests/unit/MarkdownViewer/preserve-newlines.test.tsx +109 -109
- package/tests/unit/MarkdownViewer/read-time.test.ts +13 -13
- package/tests/unit/MarkdownViewer/renderers.test.tsx +75 -75
- package/tests/unit/MarkdownViewer/runnable.test.tsx +51 -51
- package/tests/unit/MarkdownViewer/ssr.test.tsx +56 -55
- package/tests/unit/MarkdownViewer/syntax-highlighting.test.tsx +43 -42
- package/tests/unit/MarkdownViewer/tabs-fenced-code.test.tsx +120 -120
- package/tests/unit/MarkdownViewer/tabs.test.tsx +83 -82
- package/tests/unit/Stacker/drag.test.ts +21 -0
- package/tests/unit/Stacker/helpers.test.ts +105 -0
- package/tests/unit/Stacker/reducer.test.ts +107 -0
- package/tools/duplicate-vector-mappings.json +141 -141
- package/tools/glass-export-overrides.json +6 -6
- package/tools/icon-parser.ts +41 -40
- package/tools/integration-gradient-exports.json +55 -55
- package/tools/picto-compare.ts +32 -32
- package/tools/picto-merge-urls.ts +19 -19
- package/tools/picto-sync.ts +543 -543
- package/tools/picto-verify-gradients.ts +50 -50
- package/tools/vector-export-nodes.json +975 -975
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { EditorState } from "@codemirror/state";
|
|
2
|
+
import { EditorView } from "@codemirror/view";
|
|
3
|
+
import { MantineProvider, v8CssVariablesResolver } from "@mantine/core";
|
|
4
|
+
import { markdownModeField } 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 { MarkdownViewer } from "@src/primitives/MarkdownViewer";
|
|
13
|
+
import { MANTINE_THEME } from "@src/theme/mantine";
|
|
14
|
+
import { renderToString } from "react-dom/server";
|
|
15
|
+
import { beforeAll, describe, expect, it } from "vitest";
|
|
16
|
+
|
|
17
|
+
beforeAll(() => {
|
|
18
|
+
if (typeof window !== "undefined" && typeof window.matchMedia !== "function") {
|
|
19
|
+
Object.defineProperty(window, "matchMedia", {
|
|
20
|
+
writable: true,
|
|
21
|
+
value: (query: string) => ({
|
|
22
|
+
matches: false,
|
|
23
|
+
media: query,
|
|
24
|
+
onchange: null,
|
|
25
|
+
addListener: () => {},
|
|
26
|
+
removeListener: () => {},
|
|
27
|
+
addEventListener: () => {},
|
|
28
|
+
removeEventListener: () => {},
|
|
29
|
+
dispatchEvent: () => false,
|
|
30
|
+
}),
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
/** Deterministic LCG so failures reproduce (no Math.random). */
|
|
36
|
+
function makeRng(seed: number): () => number {
|
|
37
|
+
let s = seed >>> 0;
|
|
38
|
+
return () => {
|
|
39
|
+
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
|
|
40
|
+
return s / 0x100000000;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const COMPONENTS = mergeMarkdownComponents({});
|
|
45
|
+
|
|
46
|
+
function renderViewerHtml(doc: string): string {
|
|
47
|
+
return renderToString(
|
|
48
|
+
<MantineProvider
|
|
49
|
+
theme={MANTINE_THEME}
|
|
50
|
+
cssVariablesResolver={v8CssVariablesResolver}
|
|
51
|
+
forceColorScheme="dark"
|
|
52
|
+
>
|
|
53
|
+
<MarkdownViewer
|
|
54
|
+
content={doc}
|
|
55
|
+
components={COMPONENTS}
|
|
56
|
+
/>
|
|
57
|
+
</MantineProvider>,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Build an editor over the doc and sweep the caret so decoration paths run. */
|
|
62
|
+
function buildEditor(doc: string): void {
|
|
63
|
+
const state = EditorState.create({
|
|
64
|
+
doc,
|
|
65
|
+
extensions: [
|
|
66
|
+
createMarkdownLanguage({ extensions: [Callout] }),
|
|
67
|
+
markdownModeField,
|
|
68
|
+
markdownDecorations({ widgetStore: createWidgetStore() }),
|
|
69
|
+
],
|
|
70
|
+
});
|
|
71
|
+
const parent = document.createElement("div");
|
|
72
|
+
document.body.appendChild(parent);
|
|
73
|
+
const view = new EditorView({ state, parent });
|
|
74
|
+
view.dispatch({ effects: setMarkdownMode.of("preview") });
|
|
75
|
+
view.dispatch({ effects: setMarkdownEditorFocused.of(true) });
|
|
76
|
+
for (let pos = 0; pos <= doc.length; pos += Math.max(1, Math.floor(doc.length / 6))) {
|
|
77
|
+
view.dispatch({ selection: { anchor: pos } });
|
|
78
|
+
}
|
|
79
|
+
view.destroy();
|
|
80
|
+
parent.remove();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function decodeEntities(text: string): string {
|
|
84
|
+
return text
|
|
85
|
+
.replace(/</g, "<")
|
|
86
|
+
.replace(/>/g, ">")
|
|
87
|
+
.replace(/"/g, '"')
|
|
88
|
+
.replace(/'/g, "'")
|
|
89
|
+
.replace(/'/g, "'")
|
|
90
|
+
.replace(/&/g, "&");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Decoded text content of every inline `<code>` element in the HTML. */
|
|
94
|
+
function codeContents(html: string): string[] {
|
|
95
|
+
const out: string[] = [];
|
|
96
|
+
const re = /<code\b[^>]*>([\s\S]*?)<\/code>/g;
|
|
97
|
+
let m: RegExpExecArray | null;
|
|
98
|
+
// biome-ignore lint/suspicious/noAssignInExpressions: standard regex iteration
|
|
99
|
+
while ((m = re.exec(html)) !== null) {
|
|
100
|
+
out.push(decodeEntities(m[1] ?? ""));
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Visible text: strip styles, all tags, then decode entities. */
|
|
106
|
+
function visibleText(html: string): string {
|
|
107
|
+
return decodeEntities(
|
|
108
|
+
html.replace(/<style[^>]*>[\s\S]*?<\/style>/g, "").replace(/<[^>]+>/g, ""),
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Inner contents for inline code spans — all rich in the characters that
|
|
113
|
+
// previously derailed the paragraph scanner (`<`, `>`, `{`, `}`, `=`).
|
|
114
|
+
const CODE_INNERS = [
|
|
115
|
+
"<target>",
|
|
116
|
+
"<T>",
|
|
117
|
+
"<a>",
|
|
118
|
+
"<div>",
|
|
119
|
+
"<Tabs>",
|
|
120
|
+
"</Tabs>",
|
|
121
|
+
"<Tabs><TabItem/></Tabs>",
|
|
122
|
+
".{..+collect}",
|
|
123
|
+
".{..+shortest=<target>}",
|
|
124
|
+
"a < b > c",
|
|
125
|
+
"x <= y >= z",
|
|
126
|
+
"map<string, int>",
|
|
127
|
+
"Array<Record<string, T>>",
|
|
128
|
+
"<not closed",
|
|
129
|
+
"unclosed>",
|
|
130
|
+
"<<multi>>",
|
|
131
|
+
"{...spread}",
|
|
132
|
+
'<a href="x">',
|
|
133
|
+
"<!-- comment -->",
|
|
134
|
+
"generic<Foo<Bar>>",
|
|
135
|
+
"i++ < len && j-- > 0",
|
|
136
|
+
];
|
|
137
|
+
|
|
138
|
+
// Plain words with no markdown-significant characters.
|
|
139
|
+
const WORDS = ["returns", "the", "unique", "node", "set", "path", "and", "every", "shortest"];
|
|
140
|
+
|
|
141
|
+
// Real inline HTML tags that should still pair when they sit beside code.
|
|
142
|
+
const REAL_TAGS = ["<strong>bold</strong>", "<em>italic</em>", "text <b>x</b> more"];
|
|
143
|
+
|
|
144
|
+
const BLOCK_PREFIXES = ["", "", "", "> ", "# ", "- ", "1. "];
|
|
145
|
+
|
|
146
|
+
function buildParagraph(rng: () => number): { doc: string; expectedCodes: string[] } {
|
|
147
|
+
const tokenCount = 1 + Math.floor(rng() * 6);
|
|
148
|
+
const parts: string[] = [];
|
|
149
|
+
const expectedCodes: string[] = [];
|
|
150
|
+
|
|
151
|
+
for (let i = 0; i < tokenCount; i++) {
|
|
152
|
+
const roll = rng();
|
|
153
|
+
if (roll < 0.55) {
|
|
154
|
+
const inner = CODE_INNERS[Math.floor(rng() * CODE_INNERS.length)] ?? "";
|
|
155
|
+
// Occasionally use double-backtick fencing.
|
|
156
|
+
if (rng() < 0.25) {
|
|
157
|
+
parts.push(`\`\` ${inner} \`\``);
|
|
158
|
+
expectedCodes.push(inner);
|
|
159
|
+
} else {
|
|
160
|
+
parts.push(`\`${inner}\``);
|
|
161
|
+
expectedCodes.push(inner);
|
|
162
|
+
}
|
|
163
|
+
} else if (roll < 0.85) {
|
|
164
|
+
parts.push(WORDS[Math.floor(rng() * WORDS.length)] ?? "");
|
|
165
|
+
} else {
|
|
166
|
+
parts.push(REAL_TAGS[Math.floor(rng() * REAL_TAGS.length)] ?? "");
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const prefix = BLOCK_PREFIXES[Math.floor(rng() * BLOCK_PREFIXES.length)] ?? "";
|
|
171
|
+
return { doc: prefix + parts.join(" "), expectedCodes };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function buildDoc(rng: () => number): { doc: string; expectedCodes: string[] } {
|
|
175
|
+
// Optionally prepend a fenced code block — the reported bug appeared right
|
|
176
|
+
// after one, so keep exercising that adjacency.
|
|
177
|
+
const paras: string[] = [];
|
|
178
|
+
const expectedCodes: string[] = [];
|
|
179
|
+
if (rng() < 0.4) {
|
|
180
|
+
paras.push("```surql\nSELECT * FROM person WHERE age > 18;\n```");
|
|
181
|
+
}
|
|
182
|
+
const count = 1 + Math.floor(rng() * 2);
|
|
183
|
+
for (let i = 0; i < count; i++) {
|
|
184
|
+
const { doc, expectedCodes: codes } = buildParagraph(rng);
|
|
185
|
+
paras.push(doc);
|
|
186
|
+
expectedCodes.push(...codes);
|
|
187
|
+
}
|
|
188
|
+
return { doc: paras.join("\n\n"), expectedCodes };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
describe("MarkdownConformance / inline-code-with-angle-brackets fuzz", () => {
|
|
192
|
+
it("never leaks backticks and preserves every inline code span", () => {
|
|
193
|
+
const rng = makeRng(0x5eed42);
|
|
194
|
+
for (let i = 0; i < 400; i++) {
|
|
195
|
+
const { doc, expectedCodes } = buildDoc(rng);
|
|
196
|
+
let html = "";
|
|
197
|
+
try {
|
|
198
|
+
html = renderViewerHtml(doc);
|
|
199
|
+
buildEditor(doc);
|
|
200
|
+
} catch (err) {
|
|
201
|
+
throw new Error(`Fuzz #${i} threw for:\n${JSON.stringify(doc)}\n\n${String(err)}`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const visible = visibleText(html);
|
|
205
|
+
// The backtick delimiters must never survive into visible output.
|
|
206
|
+
expect(
|
|
207
|
+
visible.includes("`"),
|
|
208
|
+
`Fuzz #${i} leaked a backtick.\nDOC: ${JSON.stringify(doc)}\nVISIBLE: ${JSON.stringify(visible)}`,
|
|
209
|
+
).toBe(false);
|
|
210
|
+
|
|
211
|
+
// Every inline code's content must appear inside a <code> element.
|
|
212
|
+
const rendered = codeContents(html);
|
|
213
|
+
for (const expected of expectedCodes) {
|
|
214
|
+
const found = rendered.some((c) => c.includes(expected));
|
|
215
|
+
expect(
|
|
216
|
+
found,
|
|
217
|
+
`Fuzz #${i} lost inline code ${JSON.stringify(expected)}.\nDOC: ${JSON.stringify(doc)}\nCODES: ${JSON.stringify(rendered)}`,
|
|
218
|
+
).toBe(true);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}, 60_000);
|
|
222
|
+
|
|
223
|
+
it("handles hand-picked adversarial inline-code snippets", () => {
|
|
224
|
+
const cases: Array<{ md: string; code: string }> = [
|
|
225
|
+
{ md: "`<target>`", code: "<target>" },
|
|
226
|
+
{ md: "`.{..+shortest=<target>}`", code: ".{..+shortest=<target>}" },
|
|
227
|
+
{ md: "before `<div>` after", code: "<div>" },
|
|
228
|
+
{ md: "`</Tabs>` orphan close", code: "</Tabs>" },
|
|
229
|
+
{ md: "`Array<Record<string, T>>`", code: "Array<Record<string, T>>" },
|
|
230
|
+
{ md: "a `x < y` and `p > q` end", code: "x < y" },
|
|
231
|
+
{ md: "*em with `<code>` inside*", code: "<code>" },
|
|
232
|
+
{ md: "[link `<x>` text](https://s.example)", code: "<x>" },
|
|
233
|
+
{ md: "> quote `<target>` here", code: "<target>" },
|
|
234
|
+
{ md: "# heading `<T>` end", code: "<T>" },
|
|
235
|
+
{ md: "- item `<li>` text", code: "<li>" },
|
|
236
|
+
{ md: '`<a href="x">` unbalanced open', code: '<a href="x">' },
|
|
237
|
+
{ md: "`<<nested>>` chevrons", code: "<<nested>>" },
|
|
238
|
+
{ md: "| head | `<td>` |\n| - | - |\n| a | `<b>` |", code: "<td>" },
|
|
239
|
+
];
|
|
240
|
+
|
|
241
|
+
for (const { md, code } of cases) {
|
|
242
|
+
const html = renderViewerHtml(md);
|
|
243
|
+
const visible = visibleText(html);
|
|
244
|
+
expect(visible.includes("`"), `backtick leaked for ${JSON.stringify(md)}`).toBe(false);
|
|
245
|
+
const found = codeContents(html).some((c) => c.includes(code));
|
|
246
|
+
expect(found, `lost ${JSON.stringify(code)} for ${JSON.stringify(md)}`).toBe(true);
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("still renders a real inline tag adjacent to bracketed inline code", () => {
|
|
251
|
+
const html = renderViewerHtml("`<target>` then <strong>go</strong> now");
|
|
252
|
+
expect(html).toContain("<strong>go</strong>");
|
|
253
|
+
expect(codeContents(html).some((c) => c.includes("<target>"))).toBe(true);
|
|
254
|
+
});
|
|
255
|
+
});
|
|
@@ -1,56 +1,57 @@
|
|
|
1
1
|
import type { MarkdownComponents } from "@src/lib/markdown";
|
|
2
2
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
3
4
|
import { renderMarkdown, visibleText } from "./render-helpers";
|
|
4
5
|
|
|
5
6
|
/** Echo component that serialises the props it receives for assertion. */
|
|
6
7
|
function Echo(props: Record<string, unknown>): string {
|
|
7
|
-
|
|
8
|
-
|
|
8
|
+
const { children: _, ...rest } = props;
|
|
9
|
+
return `props=${JSON.stringify(rest)}`;
|
|
9
10
|
}
|
|
10
11
|
|
|
11
12
|
const components: MarkdownComponents = { Echo };
|
|
12
13
|
|
|
13
14
|
afterEach(() => {
|
|
14
|
-
|
|
15
|
+
vi.restoreAllMocks();
|
|
15
16
|
});
|
|
16
17
|
|
|
17
18
|
describe("Conformance / JSX attribute JSON", () => {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
19
|
+
it("parses a JSON object value", () => {
|
|
20
|
+
const html = renderMarkdown('<Echo value={{ "a": 1, "b": [2, 3] }} />', { components });
|
|
21
|
+
expect(visibleText(html)).toContain('"value":{"a":1,"b":[2,3]}');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("parses JSON number, boolean, and array values", () => {
|
|
25
|
+
const html = renderMarkdown("<Echo n={42} flag={true} list={[1,2]} />", { components });
|
|
26
|
+
const text = visibleText(html);
|
|
27
|
+
expect(text).toContain('"n":42');
|
|
28
|
+
expect(text).toContain('"flag":true');
|
|
29
|
+
expect(text).toContain('"list":[1,2]');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("parses a quoted string value", () => {
|
|
33
|
+
const html = renderMarkdown('<Echo label="hello" />', { components });
|
|
34
|
+
expect(visibleText(html)).toContain('"label":"hello"');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("treats a bare attribute as a boolean flag", () => {
|
|
38
|
+
const html = renderMarkdown("<Echo disabled />", { components });
|
|
39
|
+
// Bare booleans resolve to `undefined` props (not serialised) by design.
|
|
40
|
+
expect(visibleText(html)).toContain("props=");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("drops an invalid-JSON value and warns in development", () => {
|
|
44
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
45
|
+
const html = renderMarkdown("<Echo value={{ a: 1 }} />", { components });
|
|
46
|
+
// Single-unquoted-key object is not valid JSON → the prop is dropped.
|
|
47
|
+
expect(visibleText(html)).not.toContain('"value"');
|
|
48
|
+
expect(warn).toHaveBeenCalled();
|
|
49
|
+
});
|
|
49
50
|
});
|
|
50
51
|
|
|
51
52
|
describe("Conformance / inline expressions are literal", () => {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
53
|
+
it("renders a lone brace expression as text", () => {
|
|
54
|
+
const html = renderMarkdown("Count: {count} items");
|
|
55
|
+
expect(visibleText(html)).toContain("Count: {count} items");
|
|
56
|
+
});
|
|
56
57
|
});
|
|
@@ -16,150 +16,150 @@ import { renderToString } from "react-dom/server";
|
|
|
16
16
|
import { beforeAll, describe, expect, it } from "vitest";
|
|
17
17
|
|
|
18
18
|
beforeAll(() => {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
34
|
});
|
|
35
35
|
|
|
36
36
|
/** Deterministic LCG so failures reproduce (no Math.random). */
|
|
37
37
|
function makeRng(seed: number): () => number {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
38
|
+
let s = seed >>> 0;
|
|
39
|
+
return () => {
|
|
40
|
+
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
|
|
41
|
+
return s / 0x100000000;
|
|
42
|
+
};
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
// Fragments chosen to stress tag pairing, attributes, nesting, blank lines,
|
|
46
46
|
// indentation, and malformed/adversarial shapes.
|
|
47
47
|
const FRAGMENTS = [
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
85
|
];
|
|
86
86
|
|
|
87
87
|
const COMPONENTS = mergeMarkdownComponents({
|
|
88
|
-
|
|
88
|
+
Echo: (props: Record<string, unknown>) => JSON.stringify(props.value ?? null),
|
|
89
89
|
});
|
|
90
90
|
|
|
91
91
|
function buildDoc(rng: () => number): string {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
98
|
}
|
|
99
99
|
|
|
100
100
|
function renderViewer(doc: string): void {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
113
|
}
|
|
114
114
|
|
|
115
115
|
function buildEditor(doc: string): void {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
135
|
}
|
|
136
136
|
|
|
137
137
|
describe("MarkdownConformance / JSX fuzz", () => {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
165
|
});
|