@surrealdb/ui 1.1.0 → 1.2.0
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/.zed/settings.json +36 -0
- package/AGENTS.md +10 -5
- package/README.md +30 -0
- package/REVIEW.md +1 -1
- package/dist/assets/0d2c2f665b0f41ed.woff2 +0 -0
- package/dist/assets/12b57c6beacdbca0.woff2 +0 -0
- package/dist/assets/23645aad5ccc2b92.woff2 +0 -0
- package/dist/assets/8bbfa6e01a9e6a0f.woff2 +0 -0
- package/dist/assets/93fc40a807be6880.woff2 +0 -0
- package/dist/assets/9c9751ca111e97c2.woff2 +0 -0
- package/dist/assets/9ff55a8a9670220d.woff2 +0 -0
- package/dist/assets/a865edea076e0166.woff2 +0 -0
- package/dist/assets/b921df26851c5aca.woff2 +0 -0
- package/dist/assets/c6a3f4e555097159.woff2 +0 -0
- package/dist/assets/c6c31cb1350b2544.woff2 +0 -0
- package/dist/fonts.css +1 -0
- package/dist/{yoopta.d.ts → fonts.d.ts} +6 -6
- package/dist/fonts.js +2 -0
- package/dist/fonts.js.map +1 -0
- package/dist/icons.d.ts +33 -6
- package/dist/icons.js +180 -167
- package/dist/icons.js.map +1 -1
- package/dist/ui.css +1 -1
- package/dist/ui.d.ts +570 -531
- package/dist/ui.js +16261 -14582
- package/dist/ui.js.map +1 -1
- package/package.json +22 -24
- package/tests/_setup/e2e-helpers.tsx +169 -0
- package/tests/_setup/markdown-classes.ts +3 -0
- package/tests/_setup/portable-stories.ts +10 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/content-blocks.test.tsx/MarkdownEditor---content-blocks-continues-an-ordered-list-when-Enter-is-pressed-at-the-end-of-a-numbered-line-1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/content-blocks.test.tsx/MarkdownEditor---content-blocks-renders-a-diagram-for-the-fenced-mermaid-block-1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/content-blocks.test.tsx/MarkdownEditor---content-blocks-renders-a-horizontal-rule-as-a-separator-1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/content-blocks.test.tsx/MarkdownEditor---content-blocks-renders-a-read-only-table-preview-when-the-table-block-is-inactive-1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/content-blocks.test.tsx/MarkdownEditor---content-blocks-shows-blockquote-text-from-the-sample-document-1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/content-blocks.test.tsx/MarkdownEditor---content-blocks-shows-fenced-TypeScript-sample-code-in-the-document-1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/edits.test.tsx/MarkdownEditor---edits-auto-continues-a-bullet-list-when-Enter-is-pressed-at-the-end-of-a-list-item-1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/edits.test.tsx/MarkdownEditor---edits-expands-triple-backtick-into-a-fenced-code-block-with-the-caret-on-the-empty-body-line-1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/edits.test.tsx/MarkdownEditor---edits-reflects-typed-characters-in-the-underlying-document-1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/heading-fold.test.tsx/MarkdownEditor---heading-folds-viewer--folds-and-unfolds-a-heading-section-via-the-margin-control-1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/hybrid-widgets.test.tsx/MarkdownEditor---hybrid-widgets-clicking-a-callout-header-focuses-the-editor-and-parks-the-caret-inside-the-callout--REASONING--4--1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/hybrid-widgets.test.tsx/MarkdownEditor---hybrid-widgets-keeps-list-bullets-consistent-after-hopping-the-caret-across-items-several-times-1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/hybrid-widgets.test.tsx/MarkdownEditor---hybrid-widgets-reveals-heading-marks-when-the-caret-enters-the-heading-line-and-hides-them-when-it-leaves-1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/hybrid-widgets.test.tsx/MarkdownEditor---hybrid-widgets-shows-the-right-callout-title-when-moving-focus-between-consecutive-callouts-1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/hybrid-widgets.test.tsx/MarkdownEditor---preview-widgets-clicking-a-callout-header-focuses-the-editor-with-the-caret-in-that-callout-1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/hybrid-widgets.test.tsx/MarkdownEditor---preview-widgets-keeps-list-bullets-consistent-after-hopping-the-caret-across-items-several-times-1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/hybrid-widgets.test.tsx/MarkdownEditor---preview-widgets-shows-the-right-callout-title-when-moving-focus-between-consecutive-callouts-1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/jsx-block-content.test.tsx/MarkdownEditor---block-JSX-components-clicking-the-edit-source-action-selects-the-component-source-range-1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/jsx-block-content.test.tsx/MarkdownEditor---block-JSX-components-renders-block-components-via-JsxBlockWidget-with-an-edit-source-action-1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/jsx-block-content.test.tsx/MarkdownEditor---inline-JSX-rendering-block-content-keeps-multiple-inline-JSX-widgets-on-the-same-line-side-by-side-1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/jsx-highlight.test.tsx/MarkdownEditor---JSX-attribute-highlighting-does-not-let-the-nested-HTML-parser-mis-highlight-attributes-after-an-expression-1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/jsx-selection.test.tsx/MarkdownEditor---JSX-selection-reveal-shows-JSX-widget-when-inactive-and-raw-source-when-caret-is-inside-the-tag-1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/media-sizing.test.tsx/MarkdownEditor---media-sizing-images-and-videos-do-not-exceed-the-editor-content-width-1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/media-sizing.test.tsx/MarkdownEditor---media-sizing-matches-MarkdownViewer-image-width-in-the-split-playground-1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/modes.test.tsx/MarkdownEditor---modes-standalone-MarkdownViewer-is-not-a-CodeMirror-surface-1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/modes.test.tsx/MarkdownEditor---modes-toggling-the-SegmentedControl-swaps-mode-without-remounting-the-editor-1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/regressions.test.tsx/MarkdownEditor---regressions-repeated-checkbox-toggles-do-not-duplicate-or-drift-task-markers-1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/regressions.test.tsx/MarkdownEditor---regressions-scroll-position-is-preserved-when-the-document-is-edited--chat-92584463--1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/regressions.test.tsx/MarkdownEditor---regressions-task-checkbox-toggles-between-checked-and-unchecked-in-the-source-document-1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/regressions.test.tsx/MarkdownEditor---regressions-toggle-via-checkbox-preserves-task-marker-widgets-visible-from-the-previous-caret-position-1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/slash-commands.test.tsx/MarkdownEditor---slash-commands-dismisses-on-Escape-and-leaves-the-slash-as-literal-text-1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/slash-commands.test.tsx/MarkdownEditor---slash-commands-highlights-the-slash-and-shows-an-Enter-command-placeholder-until-text-is-typed-1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/slash-commands.test.tsx/MarkdownEditor---slash-commands-opens-a-filtered--keyboard-navigable-menu-and-inserts-boilerplate-on-Enter-1.png +0 -0
- package/tests/e2e/MarkdownEditor/__screenshots__/undo-redo.test.tsx/MarkdownEditor---undo-and-redo-restores-undone-document-edits-via-redo-1.png +0 -0
- package/tests/e2e/MarkdownEditor/content-blocks.test.tsx +152 -0
- package/tests/e2e/MarkdownEditor/edits.test.tsx +111 -0
- package/tests/e2e/MarkdownEditor/heading-fold.test.tsx +44 -0
- package/tests/e2e/MarkdownEditor/hybrid-widgets.test.tsx +192 -0
- package/tests/e2e/MarkdownEditor/jsx-block-content.test.tsx +242 -0
- package/tests/e2e/MarkdownEditor/jsx-highlight.test.tsx +68 -0
- package/tests/e2e/MarkdownEditor/jsx-inline-badges.test.tsx +59 -0
- package/tests/e2e/MarkdownEditor/jsx-selection.test.tsx +43 -0
- package/tests/e2e/MarkdownEditor/link-placeholder.test.tsx +67 -0
- package/tests/e2e/MarkdownEditor/media-align.test.tsx +57 -0
- package/tests/e2e/MarkdownEditor/media-edit.test.tsx +63 -0
- package/tests/e2e/MarkdownEditor/media-sizing.test.tsx +123 -0
- package/tests/e2e/MarkdownEditor/modes.test.tsx +93 -0
- package/tests/e2e/MarkdownEditor/regressions.test.tsx +182 -0
- package/tests/e2e/MarkdownEditor/slash-commands.test.tsx +99 -0
- package/tests/e2e/MarkdownEditor/table-click.test.tsx +47 -0
- package/tests/e2e/MarkdownEditor/table-controls.test.tsx +56 -0
- package/tests/e2e/MarkdownEditor/table-format.test.tsx +41 -0
- package/tests/e2e/MarkdownEditor/undo-redo.test.tsx +38 -0
- package/tests/e2e/MarkdownViewer/__screenshots__/parity.test.tsx/MarkdownViewer---editor-parity-matches-computed-font-size-on-first-heading-1.png +0 -0
- package/tests/e2e/MarkdownViewer/__screenshots__/parity.test.tsx/MarkdownViewer---editor-parity-matches-counts-for-shared-structural-classes-1.png +0 -0
- package/tests/e2e/MarkdownViewer/__screenshots__/parity.test.tsx/MarkdownViewer---editor-parity-matches-visible-text-between-preview-editor--blurred--and-MarkdownViewer-1.png +0 -0
- package/tests/e2e/MarkdownViewer/__screenshots__/render.test.tsx/MarkdownViewer---render-exercises-shared-preview-class-names-without-mounting-CodeMirror-1.png +0 -0
- package/tests/e2e/MarkdownViewer/parity.test.tsx +190 -0
- package/tests/e2e/MarkdownViewer/render.test.tsx +35 -0
- package/tests/unit/Editor/helpers.test.ts +42 -0
- package/tests/unit/MarkdownEditor/code-info.test.ts +63 -0
- package/tests/unit/MarkdownEditor/decorations.test.ts +488 -0
- package/tests/unit/MarkdownEditor/editor-ready.test.ts +36 -0
- package/tests/unit/MarkdownEditor/html-descriptors.test.ts +94 -0
- package/tests/unit/MarkdownEditor/jsx-attr-scan.test.ts +115 -0
- package/tests/unit/MarkdownEditor/jsx-tag-grammar.test.ts +88 -0
- package/tests/unit/MarkdownEditor/list-indent.test.ts +95 -0
- package/tests/unit/MarkdownEditor/slash-commands.test.ts +213 -0
- package/tests/unit/MarkdownEditor/table-format.test.ts +83 -0
- package/tests/unit/MarkdownEditor/table.test.ts +119 -0
- package/tests/unit/MarkdownEditor/triggers.test.ts +244 -0
- package/tests/unit/MarkdownEditor/widget-store.test.ts +105 -0
- package/tests/unit/MarkdownViewer/code-title.test.tsx +62 -0
- package/tests/unit/MarkdownViewer/features.test.tsx +110 -0
- package/tests/unit/MarkdownViewer/headings.test.tsx +40 -0
- package/tests/unit/MarkdownViewer/jsx.test.tsx +211 -0
- package/tests/unit/MarkdownViewer/list-bullets.test.tsx +49 -0
- package/tests/unit/MarkdownViewer/list-code.test.tsx +65 -0
- package/tests/unit/MarkdownViewer/renderers.test.tsx +79 -0
- package/tests/unit/MarkdownViewer/runnable.test.tsx +69 -0
- package/tests/unit/MarkdownViewer/ssr.test.tsx +93 -0
- package/dist/yoopta.css +0 -1
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
import { syntaxTree } from "@codemirror/language";
|
|
2
|
+
import { EditorSelection, EditorState } from "@codemirror/state";
|
|
3
|
+
import { type Decoration, type DecorationSet, EditorView } from "@codemirror/view";
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_MARKDOWN_MODE,
|
|
6
|
+
type MarkdownEditMode,
|
|
7
|
+
markdownModeField,
|
|
8
|
+
setMarkdownMode,
|
|
9
|
+
} from "@src/lib/markdown";
|
|
10
|
+
import { Callout } from "@src/lib/markdown/editor/callout";
|
|
11
|
+
import { markdownDecorations } from "@src/lib/markdown/editor/decorations";
|
|
12
|
+
import { setMarkdownEditorFocused } from "@src/lib/markdown/editor/decorations/editor-focus";
|
|
13
|
+
import { createMarkdownLanguage } from "@src/lib/markdown/editor/language";
|
|
14
|
+
import { CalloutHeaderWidget } from "@src/lib/markdown/editor/widgets/callout-header-widget";
|
|
15
|
+
import { CheckboxWidget } from "@src/lib/markdown/editor/widgets/checkbox-widget";
|
|
16
|
+
import {
|
|
17
|
+
CodeFenceCopyWidget,
|
|
18
|
+
CodeFenceLanguageMenuWidget,
|
|
19
|
+
} from "@src/lib/markdown/editor/widgets/code-fence-widget";
|
|
20
|
+
import { CustomCodeBlockWidget } from "@src/lib/markdown/editor/widgets/custom-code-block-widget";
|
|
21
|
+
import { HeadingFoldWidget } from "@src/lib/markdown/editor/widgets/heading-fold-widget";
|
|
22
|
+
import { ListBulletWidget } from "@src/lib/markdown/editor/widgets/list-bullet-widget";
|
|
23
|
+
import { createWidgetStore } from "@src/lib/markdown/editor/widgets/store";
|
|
24
|
+
import { TableWidget } from "@src/lib/markdown/editor/widgets/table-widget";
|
|
25
|
+
import type { CodeRenderer } from "@src/lib/markdown/types";
|
|
26
|
+
import { describe, expect, it } from "vitest";
|
|
27
|
+
import { md } from "../../_setup/markdown-classes";
|
|
28
|
+
|
|
29
|
+
interface DecorationDescriptor {
|
|
30
|
+
from: number;
|
|
31
|
+
to: number;
|
|
32
|
+
spec: ReturnType<Decoration["spec"]> | Decoration["spec"];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function createView(
|
|
36
|
+
doc: string,
|
|
37
|
+
mode: MarkdownEditMode = DEFAULT_MARKDOWN_MODE,
|
|
38
|
+
options: { codeRenderers?: Record<string, CodeRenderer> } = {},
|
|
39
|
+
): EditorView {
|
|
40
|
+
const widgetStore = createWidgetStore();
|
|
41
|
+
const state = EditorState.create({
|
|
42
|
+
doc,
|
|
43
|
+
extensions: [
|
|
44
|
+
createMarkdownLanguage({ extensions: [Callout] }),
|
|
45
|
+
markdownModeField,
|
|
46
|
+
markdownDecorations({ widgetStore, codeRenderers: options.codeRenderers }),
|
|
47
|
+
],
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const parent = document.createElement("div");
|
|
51
|
+
document.body.appendChild(parent);
|
|
52
|
+
|
|
53
|
+
const view = new EditorView({
|
|
54
|
+
state,
|
|
55
|
+
parent,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
if (mode !== DEFAULT_MARKDOWN_MODE) {
|
|
59
|
+
view.dispatch({ effects: setMarkdownMode.of(mode) });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
view.focus();
|
|
63
|
+
view.dispatch({ effects: setMarkdownEditorFocused.of(true) });
|
|
64
|
+
|
|
65
|
+
return view;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function getDecorations(view: EditorView): DecorationDescriptor[] {
|
|
69
|
+
const out: DecorationDescriptor[] = [];
|
|
70
|
+
for (const value of (view as unknown as { docView: { decorations: DecorationSet[] } }).docView
|
|
71
|
+
.decorations) {
|
|
72
|
+
const cursor = value.iter();
|
|
73
|
+
while (cursor.value) {
|
|
74
|
+
out.push({ from: cursor.from, to: cursor.to, spec: cursor.value.spec });
|
|
75
|
+
cursor.next();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function collectWidgets(view: EditorView): unknown[] {
|
|
82
|
+
return getDecorations(view)
|
|
83
|
+
.map((d) => (d.spec as { widget?: unknown }).widget)
|
|
84
|
+
.filter((value): value is object => value != null);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function getDecorationClasses(view: EditorView): string[] {
|
|
88
|
+
return getDecorations(view)
|
|
89
|
+
.map((d) => (d.spec as { class?: string }).class)
|
|
90
|
+
.filter((value): value is string => typeof value === "string");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function getHiddenRanges(view: EditorView): Array<{ from: number; to: number }> {
|
|
94
|
+
return getDecorations(view)
|
|
95
|
+
.filter((d) => (d.spec as { class?: string }).class === md.markHidden)
|
|
96
|
+
.map((d) => ({ from: d.from, to: d.to }));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
describe("CheckboxWidget", () => {
|
|
100
|
+
it("compare is referential so TileCache must not skip updateDOM for a fresh instance", () => {
|
|
101
|
+
const store = createWidgetStore();
|
|
102
|
+
const a = new CheckboxWidget(
|
|
103
|
+
{ id: "checkbox:10-13", checked: true, markerFrom: 11, markerTo: 12 },
|
|
104
|
+
store,
|
|
105
|
+
);
|
|
106
|
+
const b = new CheckboxWidget(
|
|
107
|
+
{ id: "checkbox:10-13", checked: true, markerFrom: 11, markerTo: 12 },
|
|
108
|
+
store,
|
|
109
|
+
);
|
|
110
|
+
expect(a.compare(b)).toBe(false);
|
|
111
|
+
expect(a.compare(a)).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("markdownDecorations", () => {
|
|
116
|
+
it("emits heading line classes", () => {
|
|
117
|
+
const view = createView("# Hello world\n\nbody");
|
|
118
|
+
const classes = getDecorationClasses(view);
|
|
119
|
+
expect(classes.some((c) => c.includes(md.h1))).toBe(true);
|
|
120
|
+
view.destroy();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("hides heading marks when block is inactive in preview mode", () => {
|
|
124
|
+
const view = createView("# Title\n\nparagraph after the cursor");
|
|
125
|
+
view.dispatch({ selection: EditorSelection.cursor(view.state.doc.length) });
|
|
126
|
+
const classes = getDecorationClasses(view);
|
|
127
|
+
expect(classes).toContain(md.markHidden);
|
|
128
|
+
view.destroy();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("shows heading marks when cursor is on the heading line in preview mode", () => {
|
|
132
|
+
const view = createView("# Title\n");
|
|
133
|
+
view.dispatch({ selection: EditorSelection.cursor(3) });
|
|
134
|
+
const classes = getDecorationClasses(view);
|
|
135
|
+
expect(classes).toContain(md.markShown);
|
|
136
|
+
expect(classes).not.toContain(md.markHidden);
|
|
137
|
+
view.destroy();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("hides heading hashes in preview when unfocused even if the caret is on the heading line", () => {
|
|
141
|
+
const view = createView("# Title\n");
|
|
142
|
+
view.dispatch({
|
|
143
|
+
selection: EditorSelection.cursor(3),
|
|
144
|
+
effects: setMarkdownEditorFocused.of(false),
|
|
145
|
+
});
|
|
146
|
+
const classes = getDecorationClasses(view);
|
|
147
|
+
expect(classes).toContain(md.markHidden);
|
|
148
|
+
expect(classes).not.toContain(md.markShown);
|
|
149
|
+
view.destroy();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("hides custom {#id} heading suffix when the heading line is inactive in preview mode", () => {
|
|
153
|
+
const doc = "## Playground {#playground-anchor}\n\nbody";
|
|
154
|
+
const view = createView(doc);
|
|
155
|
+
view.dispatch({ selection: EditorSelection.cursor(doc.length) });
|
|
156
|
+
const hidden = getHiddenRanges(view);
|
|
157
|
+
const suffixFrom = doc.indexOf("{#playground-anchor}");
|
|
158
|
+
const suffixTo = suffixFrom + "{#playground-anchor}".length;
|
|
159
|
+
expect(hidden.some((r) => r.from <= suffixFrom && r.to >= suffixTo)).toBe(true);
|
|
160
|
+
view.destroy();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("shows custom {#id} heading suffix when the heading line is active in preview mode", () => {
|
|
164
|
+
const doc = "## Playground {#playground-anchor}\n";
|
|
165
|
+
const view = createView(doc);
|
|
166
|
+
view.dispatch({ selection: EditorSelection.cursor(doc.indexOf("Playground") + 2) });
|
|
167
|
+
const classes = getDecorationClasses(view);
|
|
168
|
+
expect(classes).toContain(md.markShown);
|
|
169
|
+
view.destroy();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("does not emit hidden marks in source mode", () => {
|
|
173
|
+
const view = createView("# Title\n\n**bold**", "source");
|
|
174
|
+
const classes = getDecorationClasses(view);
|
|
175
|
+
expect(classes).not.toContain(md.markHidden);
|
|
176
|
+
view.destroy();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("reveals inline strong markers only when caret intersects the node", () => {
|
|
180
|
+
// Document: "before **bold** after"
|
|
181
|
+
// positions: 0 4 8 10 14 16 ...
|
|
182
|
+
const view = createView("before **bold** after");
|
|
183
|
+
// Caret on the word "bold" (inside the StrongEmphasis node).
|
|
184
|
+
view.dispatch({ selection: EditorSelection.cursor(11) });
|
|
185
|
+
let classes = getDecorationClasses(view);
|
|
186
|
+
expect(classes).toContain(md.markShown);
|
|
187
|
+
expect(classes).not.toContain(md.markHidden);
|
|
188
|
+
|
|
189
|
+
// Caret elsewhere on the same line but outside the **bold** node.
|
|
190
|
+
view.dispatch({ selection: EditorSelection.cursor(2) });
|
|
191
|
+
classes = getDecorationClasses(view);
|
|
192
|
+
expect(classes).toContain(md.markHidden);
|
|
193
|
+
expect(classes).not.toContain(md.markShown);
|
|
194
|
+
|
|
195
|
+
view.destroy();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("keeps inline source visible when caret sits at the node boundary", () => {
|
|
199
|
+
const view = createView("before **bold** after");
|
|
200
|
+
// The StrongEmphasis node spans positions 7..15 (inclusive of `**`).
|
|
201
|
+
// Caret immediately before the opening `**` should still reveal it.
|
|
202
|
+
view.dispatch({ selection: EditorSelection.cursor(7) });
|
|
203
|
+
let classes = getDecorationClasses(view);
|
|
204
|
+
expect(classes).toContain(md.markShown);
|
|
205
|
+
expect(classes).not.toContain(md.markHidden);
|
|
206
|
+
|
|
207
|
+
// And likewise immediately after the closing `**`.
|
|
208
|
+
view.dispatch({ selection: EditorSelection.cursor(15) });
|
|
209
|
+
classes = getDecorationClasses(view);
|
|
210
|
+
expect(classes).toContain(md.markShown);
|
|
211
|
+
expect(classes).not.toContain(md.markHidden);
|
|
212
|
+
|
|
213
|
+
view.destroy();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("does not emit a ListBulletWidget for task list items in preview mode", () => {
|
|
217
|
+
// Cursor on line 1 keeps the list line (line 3) inactive so that
|
|
218
|
+
// both the bullet and checkbox widgets would normally render.
|
|
219
|
+
const view = createView("body\n\n- [ ] item");
|
|
220
|
+
view.dispatch({ selection: EditorSelection.cursor(0) });
|
|
221
|
+
|
|
222
|
+
const widgets = collectWidgets(view);
|
|
223
|
+
expect(widgets.some((w) => w instanceof CheckboxWidget)).toBe(true);
|
|
224
|
+
expect(widgets.some((w) => w instanceof ListBulletWidget)).toBe(false);
|
|
225
|
+
|
|
226
|
+
view.destroy();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("still emits a ListBulletWidget for plain bullet list items", () => {
|
|
230
|
+
const view = createView("body\n\n- item");
|
|
231
|
+
view.dispatch({ selection: EditorSelection.cursor(0) });
|
|
232
|
+
|
|
233
|
+
const widgets = collectWidgets(view);
|
|
234
|
+
expect(widgets.some((w) => w instanceof ListBulletWidget)).toBe(true);
|
|
235
|
+
expect(widgets.some((w) => w instanceof CheckboxWidget)).toBe(false);
|
|
236
|
+
|
|
237
|
+
view.destroy();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("keeps ListBulletWidget when the caret is in the list item body in preview mode", () => {
|
|
241
|
+
const view = createView("- item\n");
|
|
242
|
+
const listLine = view.state.doc.line(1);
|
|
243
|
+
const caretInBody = listLine.from + "- item".indexOf("i");
|
|
244
|
+
view.dispatch({ selection: EditorSelection.cursor(caretInBody) });
|
|
245
|
+
|
|
246
|
+
expect(collectWidgets(view).some((w) => w instanceof ListBulletWidget)).toBe(true);
|
|
247
|
+
|
|
248
|
+
view.destroy();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("omits ListBulletWidget when the caret is inside the list mark in preview mode", () => {
|
|
252
|
+
const view = createView("- item\n");
|
|
253
|
+
let listMarkFrom = -1;
|
|
254
|
+
syntaxTree(view.state).iterate({
|
|
255
|
+
from: 0,
|
|
256
|
+
to: view.state.doc.length,
|
|
257
|
+
enter(n) {
|
|
258
|
+
if (n.name === "ListMark") listMarkFrom = n.from;
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
expect(listMarkFrom).toBeGreaterThanOrEqual(0);
|
|
262
|
+
view.dispatch({ selection: EditorSelection.cursor(listMarkFrom + 1) });
|
|
263
|
+
view.dispatch({ selection: EditorSelection.cursor(listMarkFrom) });
|
|
264
|
+
|
|
265
|
+
expect(collectWidgets(view).some((w) => w instanceof ListBulletWidget)).toBe(false);
|
|
266
|
+
|
|
267
|
+
view.destroy();
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("keeps CheckboxWidget when the caret is in the task item body in preview mode", () => {
|
|
271
|
+
const view = createView("- [ ] task\n");
|
|
272
|
+
const listLine = view.state.doc.line(1);
|
|
273
|
+
const caretInBody = listLine.from + "- [ ] task".indexOf("t");
|
|
274
|
+
view.dispatch({ selection: EditorSelection.cursor(caretInBody) });
|
|
275
|
+
|
|
276
|
+
expect(collectWidgets(view).some((w) => w instanceof CheckboxWidget)).toBe(true);
|
|
277
|
+
|
|
278
|
+
view.destroy();
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("omits CheckboxWidget when the caret is inside the task marker in preview mode", () => {
|
|
282
|
+
const view = createView("- [ ] task\n");
|
|
283
|
+
const bracketPos = view.state.doc.toString().indexOf("[");
|
|
284
|
+
view.dispatch({ selection: EditorSelection.cursor(bracketPos) });
|
|
285
|
+
|
|
286
|
+
expect(collectWidgets(view).some((w) => w instanceof CheckboxWidget)).toBe(false);
|
|
287
|
+
|
|
288
|
+
view.destroy();
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("does not emit HeadingFoldWidget in preview or source mode", () => {
|
|
292
|
+
const preview = createView("# Heading\n\nbody", "preview");
|
|
293
|
+
expect(collectWidgets(preview).some((w) => w instanceof HeadingFoldWidget)).toBe(false);
|
|
294
|
+
preview.destroy();
|
|
295
|
+
|
|
296
|
+
const source = createView("# Heading\n\nbody", "source");
|
|
297
|
+
expect(collectWidgets(source).some((w) => w instanceof HeadingFoldWidget)).toBe(false);
|
|
298
|
+
source.destroy();
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("does not emit a CalloutHeaderWidget when the caret is on the callout title row", () => {
|
|
302
|
+
// `> [!warning] Heads up\n> body`
|
|
303
|
+
// Position 2 is `[`; position 13 is somewhere inside `Heads up`.
|
|
304
|
+
const view = createView("> [!warning] Heads up\n> body");
|
|
305
|
+
view.dispatch({ selection: EditorSelection.cursor(13) });
|
|
306
|
+
|
|
307
|
+
const widgets = collectWidgets(view);
|
|
308
|
+
expect(widgets.some((w) => w instanceof CalloutHeaderWidget)).toBe(false);
|
|
309
|
+
|
|
310
|
+
view.destroy();
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("emits a CalloutHeaderWidget when the caret is far from the callout", () => {
|
|
314
|
+
const view = createView("body line\n\n> [!warning] Heads up\n> inside");
|
|
315
|
+
view.dispatch({ selection: EditorSelection.cursor(0) });
|
|
316
|
+
|
|
317
|
+
const widgets = collectWidgets(view);
|
|
318
|
+
expect(widgets.some((w) => w instanceof CalloutHeaderWidget)).toBe(true);
|
|
319
|
+
|
|
320
|
+
view.destroy();
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("does not emit the CalloutHeaderWidget when the caret sits at the title's end-of-line", () => {
|
|
324
|
+
// Caret at the end of the first line (right after `Heads up`)
|
|
325
|
+
// should still count as touching the header range.
|
|
326
|
+
const view = createView("> [!warning] Heads up\n> body");
|
|
327
|
+
const eol = view.state.doc.line(1).to;
|
|
328
|
+
view.dispatch({ selection: EditorSelection.cursor(eol) });
|
|
329
|
+
|
|
330
|
+
const widgets = collectWidgets(view);
|
|
331
|
+
expect(widgets.some((w) => w instanceof CalloutHeaderWidget)).toBe(false);
|
|
332
|
+
|
|
333
|
+
view.destroy();
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it("does not emit a CalloutHeaderWidget when the caret is anywhere inside the callout block", () => {
|
|
337
|
+
const view = createView("> [!warning] Heads up\n> body");
|
|
338
|
+
const bodyStart = view.state.doc.line(2).from + 2;
|
|
339
|
+
view.dispatch({ selection: EditorSelection.cursor(bodyStart) });
|
|
340
|
+
|
|
341
|
+
const widgets = collectWidgets(view);
|
|
342
|
+
expect(widgets.some((w) => w instanceof CalloutHeaderWidget)).toBe(false);
|
|
343
|
+
|
|
344
|
+
view.destroy();
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("emits blockquote / callout line decorations", () => {
|
|
348
|
+
const view = createView("> [!warning] Heads up\n> body");
|
|
349
|
+
const decorations = getDecorations(view);
|
|
350
|
+
const callout = decorations.find(
|
|
351
|
+
(d) => (d.spec as { class?: string }).class === md.callout,
|
|
352
|
+
);
|
|
353
|
+
expect(callout).toBeDefined();
|
|
354
|
+
expect(
|
|
355
|
+
(callout?.spec as { attributes?: Record<string, string> }).attributes?.[
|
|
356
|
+
"data-callout-kind"
|
|
357
|
+
],
|
|
358
|
+
).toBe("warning");
|
|
359
|
+
view.destroy();
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
describe("markdownDecorations / GFM tables", () => {
|
|
364
|
+
const TABLE = `| Feature | Status |
|
|
365
|
+
| --- | --- |
|
|
366
|
+
| Headers | Live |`;
|
|
367
|
+
|
|
368
|
+
it("emits TableWidget when the table block is inactive in preview mode", () => {
|
|
369
|
+
const view = createView(`intro\n\n${TABLE}\n\nafter`);
|
|
370
|
+
view.dispatch({ selection: EditorSelection.cursor(0) });
|
|
371
|
+
|
|
372
|
+
expect(collectWidgets(view).some((w) => w instanceof TableWidget)).toBe(true);
|
|
373
|
+
|
|
374
|
+
view.destroy();
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it("omits TableWidget when the caret is inside the table block in preview mode", () => {
|
|
378
|
+
const view = createView(TABLE);
|
|
379
|
+
const inside = view.state.doc.toString().indexOf("Feature") + 2;
|
|
380
|
+
view.dispatch({ selection: EditorSelection.cursor(inside) });
|
|
381
|
+
|
|
382
|
+
expect(collectWidgets(view).some((w) => w instanceof TableWidget)).toBe(false);
|
|
383
|
+
|
|
384
|
+
view.destroy();
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it("does not emit TableWidget in source mode", () => {
|
|
388
|
+
const view = createView(TABLE, "source");
|
|
389
|
+
expect(collectWidgets(view).some((w) => w instanceof TableWidget)).toBe(false);
|
|
390
|
+
view.destroy();
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it("applies monospace to the table and colours delimiters when the block is active", () => {
|
|
394
|
+
const view = createView(TABLE);
|
|
395
|
+
const inside = view.state.doc.toString().indexOf("Feature") + 2;
|
|
396
|
+
view.dispatch({ selection: EditorSelection.cursor(inside) });
|
|
397
|
+
|
|
398
|
+
const classes = getDecorationClasses(view);
|
|
399
|
+
expect(classes).toContain(md.tableSource);
|
|
400
|
+
expect(classes).toContain(md.tableDelimiter);
|
|
401
|
+
|
|
402
|
+
view.destroy();
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it("applies monospace but not delimiter colour when the block is inactive in preview mode", () => {
|
|
406
|
+
const view = createView(`intro\n\n${TABLE}\n\nafter`);
|
|
407
|
+
view.dispatch({ selection: EditorSelection.cursor(0) });
|
|
408
|
+
|
|
409
|
+
const classes = getDecorationClasses(view);
|
|
410
|
+
expect(classes).toContain(md.tableSource);
|
|
411
|
+
expect(classes).not.toContain(md.tableDelimiter);
|
|
412
|
+
|
|
413
|
+
view.destroy();
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
describe("markdownDecorations / fenced code", () => {
|
|
418
|
+
const FENCED = "```rust\nfn main() {}\n```";
|
|
419
|
+
|
|
420
|
+
it("hides fence and language marks when the block is inactive in preview mode", () => {
|
|
421
|
+
const view = createView(`paragraph\n\n${FENCED}\n`, "preview");
|
|
422
|
+
view.dispatch({ selection: EditorSelection.cursor(0) });
|
|
423
|
+
|
|
424
|
+
const classes = getDecorationClasses(view);
|
|
425
|
+
expect(classes).toContain(md.markHidden);
|
|
426
|
+
|
|
427
|
+
const widgets = collectWidgets(view);
|
|
428
|
+
expect(widgets.some((w) => w instanceof CodeFenceCopyWidget)).toBe(true);
|
|
429
|
+
expect(widgets.some((w) => w instanceof CodeFenceLanguageMenuWidget)).toBe(false);
|
|
430
|
+
|
|
431
|
+
view.destroy();
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it("renders CodeFenceLanguageMenuWidget when the block is active in preview focused mode", () => {
|
|
435
|
+
const view = createView(FENCED, "preview");
|
|
436
|
+
const codeBody = view.state.doc.toString().indexOf("fn ");
|
|
437
|
+
view.dispatch({ selection: EditorSelection.cursor(codeBody) });
|
|
438
|
+
|
|
439
|
+
const widgets = collectWidgets(view);
|
|
440
|
+
expect(widgets.some((w) => w instanceof CodeFenceLanguageMenuWidget)).toBe(true);
|
|
441
|
+
expect(widgets.some((w) => w instanceof CodeFenceCopyWidget)).toBe(false);
|
|
442
|
+
|
|
443
|
+
view.destroy();
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it("does not emit fenced-code chrome widgets in source mode", () => {
|
|
447
|
+
const view = createView(FENCED, "source");
|
|
448
|
+
const widgets = collectWidgets(view);
|
|
449
|
+
expect(widgets.some((w) => w instanceof CodeFenceLanguageMenuWidget)).toBe(false);
|
|
450
|
+
expect(widgets.some((w) => w instanceof CodeFenceCopyWidget)).toBe(false);
|
|
451
|
+
view.destroy();
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it("invokes a registered code renderer for matching languages when inactive", () => {
|
|
455
|
+
const renderer: CodeRenderer = () => null;
|
|
456
|
+
const view = createView(`paragraph\n\n\`\`\`mermaid\ngraph TD;A-->B\n\`\`\`\n`, "preview", {
|
|
457
|
+
codeRenderers: { mermaid: renderer },
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
const widgets = collectWidgets(view);
|
|
461
|
+
expect(widgets.some((w) => w instanceof CustomCodeBlockWidget)).toBe(true);
|
|
462
|
+
|
|
463
|
+
view.destroy();
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it("falls back to fence widgets when an active block has a custom renderer", () => {
|
|
467
|
+
const renderer: CodeRenderer = () => null;
|
|
468
|
+
const doc = `\`\`\`mermaid\ngraph TD;A-->B\n\`\`\`\n`;
|
|
469
|
+
const view = createView(doc, "preview", { codeRenderers: { mermaid: renderer } });
|
|
470
|
+
const codeBody = view.state.doc.toString().indexOf("graph");
|
|
471
|
+
view.dispatch({ selection: EditorSelection.cursor(codeBody) });
|
|
472
|
+
|
|
473
|
+
const widgets = collectWidgets(view);
|
|
474
|
+
expect(widgets.some((w) => w instanceof CustomCodeBlockWidget)).toBe(false);
|
|
475
|
+
expect(widgets.some((w) => w instanceof CodeFenceLanguageMenuWidget)).toBe(true);
|
|
476
|
+
|
|
477
|
+
view.destroy();
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it("emits code-block line classes regardless of mode", () => {
|
|
481
|
+
for (const mode of ["source", "preview"] as const) {
|
|
482
|
+
const view = createView(FENCED, mode);
|
|
483
|
+
const classes = getDecorationClasses(view);
|
|
484
|
+
expect(classes).toContain(md.codeBlock);
|
|
485
|
+
view.destroy();
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { EditorState } from "@codemirror/state";
|
|
2
|
+
import { EditorView } from "@codemirror/view";
|
|
3
|
+
import { getMarkdownEditorReady, markdownEditorReady } from "@src/lib/markdown/editor/editor-ready";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
|
|
6
|
+
describe("markdownEditorReady", () => {
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
vi.useRealTimers();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("sets data-markdown-ready after the first animation frame", async () => {
|
|
12
|
+
vi.useFakeTimers({ toFake: ["requestAnimationFrame"] });
|
|
13
|
+
|
|
14
|
+
const parent = document.createElement("div");
|
|
15
|
+
document.body.appendChild(parent);
|
|
16
|
+
|
|
17
|
+
const view = new EditorView({
|
|
18
|
+
state: EditorState.create({
|
|
19
|
+
doc: "# Title",
|
|
20
|
+
extensions: [markdownEditorReady()],
|
|
21
|
+
}),
|
|
22
|
+
parent,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
expect(getMarkdownEditorReady(view.state)).toBe(false);
|
|
26
|
+
expect(view.dom.getAttribute("data-markdown-ready")).toBeNull();
|
|
27
|
+
|
|
28
|
+
await vi.runAllTimersAsync();
|
|
29
|
+
|
|
30
|
+
expect(getMarkdownEditorReady(view.state)).toBe(true);
|
|
31
|
+
expect(view.dom.getAttribute("data-markdown-ready")).toBe("true");
|
|
32
|
+
|
|
33
|
+
view.destroy();
|
|
34
|
+
parent.remove();
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import {
|
|
2
|
+
collectHtmlBlockTokens,
|
|
3
|
+
collectParagraphJsxDescriptors,
|
|
4
|
+
findDescriptorForOpenBlock,
|
|
5
|
+
isBlockHtmlNodeDescriptor,
|
|
6
|
+
outermostHtmlDescriptors,
|
|
7
|
+
pairDocumentHtmlBlocks,
|
|
8
|
+
pairHtmlTokens,
|
|
9
|
+
pairInlineHtmlTags,
|
|
10
|
+
} from "@src/lib/markdown/tree/html";
|
|
11
|
+
import { parseMarkdownTree } from "@src/lib/markdown/tree/parse";
|
|
12
|
+
import { markdownSourceFromString } from "@src/lib/markdown/tree/source";
|
|
13
|
+
import { describe, expect, it } from "vitest";
|
|
14
|
+
|
|
15
|
+
const TABLE_MARKDOWN = `<table>
|
|
16
|
+
<thead><tr><th>Fn</th></tr></thead>
|
|
17
|
+
<tbody><tr><td>**bold**</td></tr></tbody>
|
|
18
|
+
</table>`;
|
|
19
|
+
|
|
20
|
+
describe("HTML descriptors", () => {
|
|
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
|
+
|
|
27
|
+
const source = markdownSourceFromString(TABLE_MARKDOWN);
|
|
28
|
+
const cross = pairDocumentHtmlBlocks([node], source);
|
|
29
|
+
const picked = findDescriptorForOpenBlock(node, cross);
|
|
30
|
+
|
|
31
|
+
expect(picked?.tagName).toBe("table");
|
|
32
|
+
expect(picked?.closeTo).not.toBeNull();
|
|
33
|
+
});
|
|
34
|
+
|
|
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
|
+
|
|
41
|
+
const source = markdownSourceFromString(TABLE_MARKDOWN);
|
|
42
|
+
const tokens = collectHtmlBlockTokens(node, source);
|
|
43
|
+
const outer = outermostHtmlDescriptors(pairHtmlTokens(tokens));
|
|
44
|
+
|
|
45
|
+
expect(outer).toHaveLength(1);
|
|
46
|
+
expect(outer[0]?.tagName).toBe("table");
|
|
47
|
+
});
|
|
48
|
+
|
|
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
|
+
|
|
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
|
+
|
|
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
|
+
|
|
79
|
+
expect(pairInlineHtmlTags(para, source)).toHaveLength(2);
|
|
80
|
+
});
|
|
81
|
+
|
|
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
|
+
|
|
90
|
+
const descriptors = collectParagraphJsxDescriptors(para, source);
|
|
91
|
+
expect(descriptors).toHaveLength(1);
|
|
92
|
+
expect(descriptors[0]?.rawTagName).toBe("Card");
|
|
93
|
+
});
|
|
94
|
+
});
|