@surrealdb/ui 1.2.12 → 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/ui.css +1 -1
- package/dist/ui.d.ts +234 -1
- package/dist/ui.js +8217 -7329
- 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 -116
- 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
|
@@ -4,33 +4,33 @@ import { getMarkdownEditorReady, markdownEditorReady } from "@src/lib/markdown/e
|
|
|
4
4
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
5
|
|
|
6
6
|
describe("markdownEditorReady", () => {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
vi.useRealTimers();
|
|
9
|
+
});
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
it("sets data-markdown-ready after the first animation frame", async () => {
|
|
12
|
+
vi.useFakeTimers({ toFake: ["requestAnimationFrame"] });
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
const parent = document.createElement("div");
|
|
15
|
+
document.body.appendChild(parent);
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
17
|
+
const view = new EditorView({
|
|
18
|
+
state: EditorState.create({
|
|
19
|
+
doc: "# Title",
|
|
20
|
+
extensions: [markdownEditorReady()],
|
|
21
|
+
}),
|
|
22
|
+
parent,
|
|
23
|
+
});
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
expect(getMarkdownEditorReady(view.state)).toBe(false);
|
|
26
|
+
expect(view.dom.getAttribute("data-markdown-ready")).toBeNull();
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
await vi.runAllTimersAsync();
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
expect(getMarkdownEditorReady(view.state)).toBe(true);
|
|
31
|
+
expect(view.dom.getAttribute("data-markdown-ready")).toBe("true");
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
view.destroy();
|
|
34
|
+
parent.remove();
|
|
35
|
+
});
|
|
36
36
|
});
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
2
|
+
collectHtmlBlockTokens,
|
|
3
|
+
collectParagraphJsxDescriptors,
|
|
4
|
+
findDescriptorForOpenBlock,
|
|
5
|
+
isBlockHtmlNodeDescriptor,
|
|
6
|
+
outermostHtmlDescriptors,
|
|
7
|
+
pairDocumentHtmlBlocks,
|
|
8
|
+
pairHtmlTokens,
|
|
9
|
+
pairInlineHtmlTags,
|
|
10
10
|
} from "@src/lib/markdown/tree/html";
|
|
11
11
|
import { parseMarkdownTree } from "@src/lib/markdown/tree/parse";
|
|
12
12
|
import { markdownSourceFromString } from "@src/lib/markdown/tree/source";
|
|
@@ -18,77 +18,77 @@ const TABLE_MARKDOWN = `<table>
|
|
|
18
18
|
</table>`;
|
|
19
19
|
|
|
20
20
|
describe("HTML descriptors", () => {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
21
|
+
it("findDescriptorForOpenBlock picks the outermost table, not nested th/td", () => {
|
|
22
|
+
const tree = parseMarkdownTree(TABLE_MARKDOWN);
|
|
23
|
+
const node = tree.topNode.firstChild;
|
|
24
|
+
expect(node?.name).toBe("HTMLBlock");
|
|
25
|
+
if (!node) return;
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
const source = markdownSourceFromString(TABLE_MARKDOWN);
|
|
28
|
+
const cross = pairDocumentHtmlBlocks([node], source);
|
|
29
|
+
const picked = findDescriptorForOpenBlock(node, cross);
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
expect(picked?.tagName).toBe("table");
|
|
32
|
+
expect(picked?.closeTo).not.toBeNull();
|
|
33
|
+
});
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
35
|
+
it("outermostHtmlDescriptors keeps only the table for a single HTML block", () => {
|
|
36
|
+
const tree = parseMarkdownTree(TABLE_MARKDOWN);
|
|
37
|
+
const node = tree.topNode.firstChild;
|
|
38
|
+
expect(node).toBeDefined();
|
|
39
|
+
if (!node) return;
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
const source = markdownSourceFromString(TABLE_MARKDOWN);
|
|
42
|
+
const tokens = collectHtmlBlockTokens(node, source);
|
|
43
|
+
const outer = outermostHtmlDescriptors(pairHtmlTokens(tokens));
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
45
|
+
expect(outer).toHaveLength(1);
|
|
46
|
+
expect(outer[0]?.tagName).toBe("table");
|
|
47
|
+
});
|
|
48
48
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
49
|
+
it("treats inline <Since /> as non-block inside a paragraph", () => {
|
|
50
|
+
const text = 'Text <Since v="1" /> end.';
|
|
51
|
+
const source = markdownSourceFromString(text);
|
|
52
|
+
const tree = parseMarkdownTree(text);
|
|
53
|
+
const para = tree.topNode.firstChild;
|
|
54
|
+
expect(para?.name).toBe("Paragraph");
|
|
55
|
+
if (!para) return;
|
|
56
56
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
57
|
+
const descriptors = pairInlineHtmlTags(para, source);
|
|
58
|
+
const since = descriptors.find((d) => d.isJsx && d.rawTagName === "Since");
|
|
59
|
+
expect(since).toBeDefined();
|
|
60
|
+
// Unregistered components default to inline.
|
|
61
|
+
expect(since && isBlockHtmlNodeDescriptor(since, undefined)).toBe(false);
|
|
62
|
+
// Explicitly registering as block flips the resolved block-ness.
|
|
63
|
+
expect(
|
|
64
|
+
since &&
|
|
65
|
+
isBlockHtmlNodeDescriptor(since, {
|
|
66
|
+
Since: { component: () => null, block: true },
|
|
67
|
+
}),
|
|
68
|
+
).toBe(true);
|
|
69
|
+
});
|
|
70
70
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
71
|
+
it("pairs multiple self-closing tags in one paragraph", () => {
|
|
72
|
+
const text = '<Label label="Required" /><Label label="Optional" />';
|
|
73
|
+
const source = markdownSourceFromString(text);
|
|
74
|
+
const tree = parseMarkdownTree(text);
|
|
75
|
+
const para = tree.topNode.firstChild;
|
|
76
|
+
expect(para?.name).toBe("Paragraph");
|
|
77
|
+
if (!para) return;
|
|
78
78
|
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
expect(pairInlineHtmlTags(para, source)).toHaveLength(2);
|
|
80
|
+
});
|
|
81
81
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
82
|
+
it("collectParagraphJsxDescriptors scans plain paragraphs without HTMLTag nodes", () => {
|
|
83
|
+
const text = 'Before <Card label="x" /> after.';
|
|
84
|
+
const source = markdownSourceFromString(text);
|
|
85
|
+
const tree = parseMarkdownTree(text);
|
|
86
|
+
const para = tree.topNode.firstChild;
|
|
87
|
+
expect(para?.name).toBe("Paragraph");
|
|
88
|
+
if (!para) return;
|
|
89
89
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
90
|
+
const descriptors = collectParagraphJsxDescriptors(para, source);
|
|
91
|
+
expect(descriptors).toHaveLength(1);
|
|
92
|
+
expect(descriptors[0]?.rawTagName).toBe("Card");
|
|
93
|
+
});
|
|
94
94
|
});
|
|
@@ -4,100 +4,100 @@ import { describe, expect, it } from "vitest";
|
|
|
4
4
|
|
|
5
5
|
/** Reconstructed body text of the (single) FencedCode node. */
|
|
6
6
|
function fencedCodeBody(text: string): string | null {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
16
|
}
|
|
17
17
|
|
|
18
18
|
/** Top-level node names of a parsed document. */
|
|
19
19
|
function topLevelNodes(text: string): { name: string; text: string }[] {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
29
|
}
|
|
30
30
|
|
|
31
31
|
/** The source text of the (single) FencedCode node, or null if none. */
|
|
32
32
|
function fencedCodeText(text: string): string | null {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
42
|
}
|
|
43
43
|
|
|
44
44
|
describe("MarkdownEditor / indented fenced code closing", () => {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
55
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
62
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
81
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
86
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
93
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
103
|
});
|
|
@@ -1,115 +1,115 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
buildJsxTagHighlights,
|
|
3
|
+
findJsonAttributeClose,
|
|
4
|
+
parseAttributes,
|
|
5
|
+
parseJsonAttribute,
|
|
6
|
+
resolveAttributeValue,
|
|
7
7
|
} from "@src/lib/markdown/grammar/html-attrs";
|
|
8
8
|
import { describe, expect, it } from "vitest";
|
|
9
9
|
|
|
10
10
|
describe("findJsonAttributeClose", () => {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
11
|
+
it("finds the closing brace for nested JSON objects and strings", () => {
|
|
12
|
+
const json = '{"version": "2.0.0", "items": [{"id": 1}]}';
|
|
13
|
+
const full = `{${json}}`;
|
|
14
|
+
const bounds = findJsonAttributeClose(`${full} trailing`, 0);
|
|
15
|
+
expect(bounds).toEqual({
|
|
16
|
+
contentFrom: 1,
|
|
17
|
+
contentTo: full.length - 1,
|
|
18
|
+
closeBrace: full.length - 1,
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("handles braces inside JSON strings", () => {
|
|
23
|
+
const json = '{"note": "}"}';
|
|
24
|
+
const full = `{${json}}`;
|
|
25
|
+
const bounds = findJsonAttributeClose(full, 0);
|
|
26
|
+
expect(bounds?.closeBrace).toBe(full.length - 1);
|
|
27
|
+
});
|
|
28
28
|
});
|
|
29
29
|
|
|
30
30
|
describe("parseJsonAttribute", () => {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
31
|
+
it("parses JSON literals without evaluation", () => {
|
|
32
|
+
expect(parseJsonAttribute("42")).toBe(42);
|
|
33
|
+
expect(parseJsonAttribute('"hello"')).toBe("hello");
|
|
34
|
+
expect(parseJsonAttribute('{"enabled": true}')).toEqual({ enabled: true });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("returns undefined for invalid JSON", () => {
|
|
38
|
+
expect(parseJsonAttribute("{ foo: 1 }")).toBeUndefined();
|
|
39
|
+
expect(parseJsonAttribute("count")).toBeUndefined();
|
|
40
|
+
});
|
|
41
41
|
});
|
|
42
42
|
|
|
43
43
|
describe("resolveAttributeValue", () => {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
44
|
+
it("resolves JSON attribute values", () => {
|
|
45
|
+
expect(resolveAttributeValue({ type: "json", value: "100" })).toBe(100);
|
|
46
|
+
expect(resolveAttributeValue({ type: "json", value: '"2.0.0"' })).toBe("2.0.0");
|
|
47
|
+
});
|
|
48
48
|
});
|
|
49
49
|
|
|
50
50
|
describe("buildJsxTagHighlights", () => {
|
|
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
|
-
|
|
51
|
+
const tag = '<Since v={"2.0.0"} t="true" b="false" />';
|
|
52
|
+
|
|
53
|
+
it("covers the attribute section without gaps for the nested HTML parser", () => {
|
|
54
|
+
const highlights = buildJsxTagHighlights(tag);
|
|
55
|
+
const attrStart = tag.indexOf(" v=");
|
|
56
|
+
const attrEnd = tag.lastIndexOf(">");
|
|
57
|
+
|
|
58
|
+
const gaps = leftoverGaps(attrStart, attrEnd, highlights);
|
|
59
|
+
expect(gaps).toEqual([]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("labels attribute names and quoted literals after a JSON value", () => {
|
|
63
|
+
const highlights = buildJsxTagHighlights(tag);
|
|
64
|
+
const names = textAtKinds(tag, highlights, "name");
|
|
65
|
+
const strings = textAtKinds(tag, highlights, "string");
|
|
66
|
+
|
|
67
|
+
expect(names).toEqual(["v", "t", "b"]);
|
|
68
|
+
expect(strings).toEqual(['"true"', '"false"']);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("matches parseAttributes JSON boundaries", () => {
|
|
72
|
+
const { endIndex } = readTagNameForTest(tag);
|
|
73
|
+
const attrString = tag.slice(endIndex, tag.lastIndexOf(">"));
|
|
74
|
+
const attrs = parseAttributes(attrString);
|
|
75
|
+
const expr = buildJsxTagHighlights(tag).find((h) => h.kind === "expression-content");
|
|
76
|
+
|
|
77
|
+
expect(attrs.v).toEqual({ type: "json", value: '"2.0.0"' });
|
|
78
|
+
expect(expr).toBeDefined();
|
|
79
|
+
if (!expr) return;
|
|
80
|
+
expect(tag.slice(expr.from, expr.to)).toBe('"2.0.0"');
|
|
81
|
+
expect(attrs.t).toBe("true");
|
|
82
|
+
expect(attrs.b).toBe("false");
|
|
83
|
+
});
|
|
84
84
|
});
|
|
85
85
|
|
|
86
86
|
function readTagNameForTest(text: string): { endIndex: number } {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
87
|
+
let i = 1;
|
|
88
|
+
while (i < text.length && !/[\s/>]/.test(text[i] ?? "")) i++;
|
|
89
|
+
return { endIndex: i };
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
function leftoverGaps(
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
93
|
+
from: number,
|
|
94
|
+
to: number,
|
|
95
|
+
spans: { from: number; to: number }[],
|
|
96
96
|
): { from: number; to: number }[] {
|
|
97
|
-
|
|
98
|
-
|
|
97
|
+
const gaps: { from: number; to: number }[] = [];
|
|
98
|
+
let pos = from;
|
|
99
99
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
100
|
+
for (const span of spans) {
|
|
101
|
+
if (span.from > pos) gaps.push({ from: pos, to: span.from });
|
|
102
|
+
pos = Math.max(pos, span.to);
|
|
103
|
+
}
|
|
104
104
|
|
|
105
|
-
|
|
106
|
-
|
|
105
|
+
if (pos < to) gaps.push({ from: pos, to });
|
|
106
|
+
return gaps;
|
|
107
107
|
}
|
|
108
108
|
|
|
109
109
|
function textAtKinds(
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
110
|
+
text: string,
|
|
111
|
+
highlights: ReturnType<typeof buildJsxTagHighlights>,
|
|
112
|
+
kind: "name" | "string",
|
|
113
113
|
): string[] {
|
|
114
|
-
|
|
114
|
+
return highlights.filter((h) => h.kind === kind).map((h) => text.slice(h.from, h.to));
|
|
115
115
|
}
|