testomatio-editor-blocks 0.4.65 → 0.4.66
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/package/editor/blocks/testMeta.d.ts +37 -0
- package/package/editor/blocks/testMeta.js +111 -0
- package/package/editor/customMarkdownConverter.js +127 -10
- package/package/editor/customSchema.d.ts +32 -0
- package/package/editor/customSchema.js +2 -0
- package/package/editor/testMetaFields.d.ts +17 -0
- package/package/editor/testMetaFields.js +33 -0
- package/package/index.d.ts +2 -0
- package/package/index.js +2 -0
- package/package/styles.css +201 -0
- package/package.json +1 -1
- package/src/App.tsx +21 -2
- package/src/editor/blocks/testMeta.tsx +242 -0
- package/src/editor/customMarkdownConverter.test.ts +135 -0
- package/src/editor/customMarkdownConverter.ts +125 -0
- package/src/editor/customSchema.tsx +2 -0
- package/src/editor/styles.css +201 -0
- package/src/editor/testMetaFields.ts +53 -0
- package/src/index.ts +7 -0
package/src/App.tsx
CHANGED
|
@@ -347,11 +347,30 @@ function CustomSlashMenu() {
|
|
|
347
347
|
},
|
|
348
348
|
};
|
|
349
349
|
|
|
350
|
+
const addTestItem = {
|
|
351
|
+
key: "add_test" as any,
|
|
352
|
+
title: "Add Test",
|
|
353
|
+
subtext: "Insert test metadata (id, priority, tags…)",
|
|
354
|
+
group: "Test documentation",
|
|
355
|
+
icon: <span className="bn-suggestion-icon">@T</span>,
|
|
356
|
+
aliases: ["test", "metadata", "meta", "test id"],
|
|
357
|
+
onItemClick: () => {
|
|
358
|
+
insertOrUpdateBlock(editor, {
|
|
359
|
+
type: "testMeta",
|
|
360
|
+
props: {
|
|
361
|
+
metaKind: "test",
|
|
362
|
+
metaFields: "[]",
|
|
363
|
+
metaInline: false,
|
|
364
|
+
},
|
|
365
|
+
});
|
|
366
|
+
},
|
|
367
|
+
};
|
|
368
|
+
|
|
350
369
|
const currentBlock = editor.getTextCursorPosition().block;
|
|
351
370
|
const canInsert = canInsertStepOrSnippet(editor, currentBlock.id);
|
|
352
371
|
const items = canInsert
|
|
353
|
-
? [...defaultItems, stepItem, snippetItem]
|
|
354
|
-
: defaultItems;
|
|
372
|
+
? [...defaultItems, stepItem, snippetItem, addTestItem]
|
|
373
|
+
: [...defaultItems, addTestItem];
|
|
355
374
|
return filterSuggestionItems(items, query);
|
|
356
375
|
};
|
|
357
376
|
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { createReactBlockSpec } from "@blocknote/react";
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
import type { ChangeEvent } from "react";
|
|
4
|
+
import { getMetaFieldSuggestions } from "../testMetaFields";
|
|
5
|
+
|
|
6
|
+
export type MetaField = { key: string; value: string };
|
|
7
|
+
|
|
8
|
+
const ID_KEYS = new Set(["id"]);
|
|
9
|
+
|
|
10
|
+
function parseMetaFields(raw: unknown): MetaField[] {
|
|
11
|
+
if (typeof raw !== "string" || raw.trim().length === 0) {
|
|
12
|
+
return [];
|
|
13
|
+
}
|
|
14
|
+
try {
|
|
15
|
+
const parsed = JSON.parse(raw);
|
|
16
|
+
if (!Array.isArray(parsed)) {
|
|
17
|
+
return [];
|
|
18
|
+
}
|
|
19
|
+
return parsed
|
|
20
|
+
.filter((item) => item && typeof item === "object")
|
|
21
|
+
.map((item) => ({
|
|
22
|
+
key: typeof item.key === "string" ? item.key : "",
|
|
23
|
+
value: typeof item.value === "string" ? item.value : "",
|
|
24
|
+
}));
|
|
25
|
+
} catch {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function serializeMetaFields(fields: MetaField[]): string {
|
|
31
|
+
return JSON.stringify(fields);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type AddFieldMenuProps = {
|
|
35
|
+
kind: "test" | "suite";
|
|
36
|
+
usedKeys: string[];
|
|
37
|
+
onPick: (key: string) => void;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function AddFieldMenu({ kind, usedKeys, onPick }: AddFieldMenuProps) {
|
|
41
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
42
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (!isOpen) return;
|
|
46
|
+
const handleMouseDown = (event: MouseEvent) => {
|
|
47
|
+
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
|
48
|
+
setIsOpen(false);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
document.addEventListener("mousedown", handleMouseDown);
|
|
52
|
+
return () => document.removeEventListener("mousedown", handleMouseDown);
|
|
53
|
+
}, [isOpen]);
|
|
54
|
+
|
|
55
|
+
const available = useMemo(() => {
|
|
56
|
+
const used = new Set(usedKeys.map((k) => k.trim().toLowerCase()));
|
|
57
|
+
return getMetaFieldSuggestions(kind).filter((s) => !used.has(s.key.trim().toLowerCase()));
|
|
58
|
+
}, [kind, usedKeys]);
|
|
59
|
+
|
|
60
|
+
const pick = useCallback(
|
|
61
|
+
(key: string) => {
|
|
62
|
+
onPick(key);
|
|
63
|
+
setIsOpen(false);
|
|
64
|
+
},
|
|
65
|
+
[onPick],
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<div className="bn-testmeta__add-wrap" ref={containerRef}>
|
|
70
|
+
<button
|
|
71
|
+
type="button"
|
|
72
|
+
className="bn-testmeta__add"
|
|
73
|
+
aria-label="Add field"
|
|
74
|
+
title="Add field"
|
|
75
|
+
onClick={() => setIsOpen((prev) => !prev)}
|
|
76
|
+
>
|
|
77
|
+
+
|
|
78
|
+
</button>
|
|
79
|
+
{isOpen && (
|
|
80
|
+
<div className="bn-testmeta__menu" role="listbox">
|
|
81
|
+
{available.map((suggestion) => (
|
|
82
|
+
<button
|
|
83
|
+
type="button"
|
|
84
|
+
key={suggestion.key}
|
|
85
|
+
role="option"
|
|
86
|
+
className="bn-testmeta__menu-item"
|
|
87
|
+
onMouseDown={(event) => {
|
|
88
|
+
event.preventDefault();
|
|
89
|
+
pick(suggestion.key);
|
|
90
|
+
}}
|
|
91
|
+
>
|
|
92
|
+
{suggestion.label ?? suggestion.key}
|
|
93
|
+
</button>
|
|
94
|
+
))}
|
|
95
|
+
<button
|
|
96
|
+
type="button"
|
|
97
|
+
className="bn-testmeta__menu-item bn-testmeta__menu-item--custom"
|
|
98
|
+
onMouseDown={(event) => {
|
|
99
|
+
event.preventDefault();
|
|
100
|
+
pick("");
|
|
101
|
+
}}
|
|
102
|
+
>
|
|
103
|
+
Custom field…
|
|
104
|
+
</button>
|
|
105
|
+
</div>
|
|
106
|
+
)}
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export const testMetaBlock = createReactBlockSpec(
|
|
112
|
+
{
|
|
113
|
+
type: "testMeta",
|
|
114
|
+
content: "none",
|
|
115
|
+
propSchema: {
|
|
116
|
+
// "test" | "suite" — which keyword the comment opened with.
|
|
117
|
+
metaKind: {
|
|
118
|
+
default: "test",
|
|
119
|
+
},
|
|
120
|
+
// JSON-encoded MetaField[] so insertion order is preserved.
|
|
121
|
+
metaFields: {
|
|
122
|
+
default: "[]",
|
|
123
|
+
},
|
|
124
|
+
// true when the source comment was a one-liner (`<!-- test id: @T.. -->`).
|
|
125
|
+
metaInline: {
|
|
126
|
+
default: false,
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
render: ({ block, editor }) => {
|
|
132
|
+
const kind = (block.props.metaKind as string) === "suite" ? "suite" : "test";
|
|
133
|
+
const fields = useMemo(
|
|
134
|
+
() => parseMetaFields(block.props.metaFields),
|
|
135
|
+
[block.props.metaFields],
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
const commitFields = useCallback(
|
|
139
|
+
(next: MetaField[]) => {
|
|
140
|
+
editor.updateBlock(block.id, {
|
|
141
|
+
props: { metaFields: serializeMetaFields(next) } as any,
|
|
142
|
+
});
|
|
143
|
+
},
|
|
144
|
+
[block.id, editor],
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const handleValueChange = useCallback(
|
|
148
|
+
(index: number, value: string) => {
|
|
149
|
+
const next = fields.map((field, i) =>
|
|
150
|
+
i === index ? { ...field, value } : field,
|
|
151
|
+
);
|
|
152
|
+
commitFields(next);
|
|
153
|
+
},
|
|
154
|
+
[fields, commitFields],
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
const handleKeyChange = useCallback(
|
|
158
|
+
(index: number, key: string) => {
|
|
159
|
+
const next = fields.map((field, i) =>
|
|
160
|
+
i === index ? { ...field, key } : field,
|
|
161
|
+
);
|
|
162
|
+
commitFields(next);
|
|
163
|
+
},
|
|
164
|
+
[fields, commitFields],
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
const handleRemove = useCallback(
|
|
168
|
+
(index: number) => {
|
|
169
|
+
commitFields(fields.filter((_, i) => i !== index));
|
|
170
|
+
},
|
|
171
|
+
[fields, commitFields],
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
const handleAddField = useCallback(
|
|
175
|
+
(key: string) => {
|
|
176
|
+
commitFields([...fields, { key, value: "" }]);
|
|
177
|
+
},
|
|
178
|
+
[fields, commitFields],
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
const usedKeys = useMemo(() => fields.map((f) => f.key), [fields]);
|
|
182
|
+
|
|
183
|
+
// The read-only `id` is shown inline on the header line next to the kind
|
|
184
|
+
// label; every other field renders as an editable row below.
|
|
185
|
+
const idField = fields.find((f) => ID_KEYS.has(f.key.trim().toLowerCase()));
|
|
186
|
+
const editableFields = fields
|
|
187
|
+
.map((field, index) => ({ field, index }))
|
|
188
|
+
.filter(({ field }) => !ID_KEYS.has(field.key.trim().toLowerCase()));
|
|
189
|
+
|
|
190
|
+
return (
|
|
191
|
+
<div
|
|
192
|
+
className="bn-testmeta"
|
|
193
|
+
data-block-id={block.id}
|
|
194
|
+
data-kind={kind}
|
|
195
|
+
contentEditable={false}
|
|
196
|
+
suppressContentEditableWarning
|
|
197
|
+
draggable={false}
|
|
198
|
+
>
|
|
199
|
+
<div className="bn-testmeta__header">
|
|
200
|
+
<span className="bn-testmeta__label">{kind.toUpperCase()}</span>
|
|
201
|
+
{idField?.value && <span className="bn-testmeta__id">{idField.value}</span>}
|
|
202
|
+
<AddFieldMenu kind={kind} usedKeys={usedKeys} onPick={handleAddField} />
|
|
203
|
+
</div>
|
|
204
|
+
|
|
205
|
+
{editableFields.length > 0 && (
|
|
206
|
+
<div className="bn-testmeta__rows">
|
|
207
|
+
{editableFields.map(({ field, index }) => (
|
|
208
|
+
<div className="bn-testmeta__row" key={index}>
|
|
209
|
+
<input
|
|
210
|
+
className="bn-testmeta__key bn-testmeta__key--input"
|
|
211
|
+
type="text"
|
|
212
|
+
value={field.key}
|
|
213
|
+
placeholder="key"
|
|
214
|
+
spellCheck={false}
|
|
215
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) => handleKeyChange(index, e.target.value)}
|
|
216
|
+
/>
|
|
217
|
+
<input
|
|
218
|
+
className="bn-testmeta__value"
|
|
219
|
+
type="text"
|
|
220
|
+
value={field.value}
|
|
221
|
+
placeholder="value"
|
|
222
|
+
spellCheck={false}
|
|
223
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) => handleValueChange(index, e.target.value)}
|
|
224
|
+
/>
|
|
225
|
+
<button
|
|
226
|
+
type="button"
|
|
227
|
+
className="bn-testmeta__remove"
|
|
228
|
+
aria-label="Remove field"
|
|
229
|
+
title="Remove field"
|
|
230
|
+
onClick={() => handleRemove(index)}
|
|
231
|
+
>
|
|
232
|
+
×
|
|
233
|
+
</button>
|
|
234
|
+
</div>
|
|
235
|
+
))}
|
|
236
|
+
</div>
|
|
237
|
+
)}
|
|
238
|
+
</div>
|
|
239
|
+
);
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
);
|
|
@@ -3053,3 +3053,138 @@ describe("blank line <-> empty paragraph mapping", () => {
|
|
|
3053
3053
|
expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe(markdown);
|
|
3054
3054
|
});
|
|
3055
3055
|
});
|
|
3056
|
+
|
|
3057
|
+
describe("test/suite metadata comments", () => {
|
|
3058
|
+
it("parses a one-liner test comment into a testMeta block", () => {
|
|
3059
|
+
const blocks = markdownToBlocks("<!-- test id: @T12345678 -->");
|
|
3060
|
+
expect(blocks).toEqual([
|
|
3061
|
+
{
|
|
3062
|
+
type: "testMeta",
|
|
3063
|
+
props: {
|
|
3064
|
+
metaKind: "test",
|
|
3065
|
+
metaFields: JSON.stringify([{ key: "id", value: "@T12345678" }]),
|
|
3066
|
+
metaInline: true,
|
|
3067
|
+
},
|
|
3068
|
+
children: [],
|
|
3069
|
+
},
|
|
3070
|
+
]);
|
|
3071
|
+
});
|
|
3072
|
+
|
|
3073
|
+
it("round-trips a one-liner test comment", () => {
|
|
3074
|
+
const markdown = "<!-- test id: @T12345678 -->";
|
|
3075
|
+
const blocks = markdownToBlocks(markdown);
|
|
3076
|
+
expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe(markdown);
|
|
3077
|
+
});
|
|
3078
|
+
|
|
3079
|
+
it("parses a multi-line suite block with ordered fields", () => {
|
|
3080
|
+
const markdown = [
|
|
3081
|
+
"<!-- suite",
|
|
3082
|
+
"id: @S12345678",
|
|
3083
|
+
"emoji: 🔐",
|
|
3084
|
+
"tags: smoke, regression",
|
|
3085
|
+
"assignee: qa@example.com",
|
|
3086
|
+
"-->",
|
|
3087
|
+
].join("\n");
|
|
3088
|
+
const blocks = markdownToBlocks(markdown);
|
|
3089
|
+
expect(blocks).toEqual([
|
|
3090
|
+
{
|
|
3091
|
+
type: "testMeta",
|
|
3092
|
+
props: {
|
|
3093
|
+
metaKind: "suite",
|
|
3094
|
+
metaFields: JSON.stringify([
|
|
3095
|
+
{ key: "id", value: "@S12345678" },
|
|
3096
|
+
{ key: "emoji", value: "🔐" },
|
|
3097
|
+
{ key: "tags", value: "smoke, regression" },
|
|
3098
|
+
{ key: "assignee", value: "qa@example.com" },
|
|
3099
|
+
]),
|
|
3100
|
+
metaInline: false,
|
|
3101
|
+
},
|
|
3102
|
+
children: [],
|
|
3103
|
+
},
|
|
3104
|
+
]);
|
|
3105
|
+
});
|
|
3106
|
+
|
|
3107
|
+
it("round-trips a multi-line suite block", () => {
|
|
3108
|
+
const markdown = [
|
|
3109
|
+
"<!-- suite",
|
|
3110
|
+
"id: @S12345678",
|
|
3111
|
+
"emoji: 🔐",
|
|
3112
|
+
"tags: smoke, regression",
|
|
3113
|
+
"assignee: qa@example.com",
|
|
3114
|
+
"-->",
|
|
3115
|
+
].join("\n");
|
|
3116
|
+
const blocks = markdownToBlocks(markdown);
|
|
3117
|
+
expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe(markdown);
|
|
3118
|
+
});
|
|
3119
|
+
|
|
3120
|
+
it("ignores lines without a colon inside a metadata block", () => {
|
|
3121
|
+
const markdown = [
|
|
3122
|
+
"<!-- test",
|
|
3123
|
+
"id: @T12345678",
|
|
3124
|
+
"this line is ignored",
|
|
3125
|
+
"priority: high",
|
|
3126
|
+
"-->",
|
|
3127
|
+
].join("\n");
|
|
3128
|
+
const blocks = markdownToBlocks(markdown);
|
|
3129
|
+
expect((blocks[0].props as any).metaFields).toBe(
|
|
3130
|
+
JSON.stringify([
|
|
3131
|
+
{ key: "id", value: "@T12345678" },
|
|
3132
|
+
{ key: "priority", value: "high" },
|
|
3133
|
+
]),
|
|
3134
|
+
);
|
|
3135
|
+
});
|
|
3136
|
+
|
|
3137
|
+
it("serializes a one-liner that gained extra fields as a block", () => {
|
|
3138
|
+
const blocks: CustomEditorBlock[] = [
|
|
3139
|
+
{
|
|
3140
|
+
id: "m1",
|
|
3141
|
+
type: "testMeta",
|
|
3142
|
+
props: {
|
|
3143
|
+
metaKind: "test",
|
|
3144
|
+
metaFields: JSON.stringify([
|
|
3145
|
+
{ key: "id", value: "@T12345678" },
|
|
3146
|
+
{ key: "priority", value: "high" },
|
|
3147
|
+
]),
|
|
3148
|
+
metaInline: true,
|
|
3149
|
+
} as any,
|
|
3150
|
+
content: undefined as any,
|
|
3151
|
+
children: [],
|
|
3152
|
+
},
|
|
3153
|
+
];
|
|
3154
|
+
expect(blocksToMarkdown(blocks)).toBe(
|
|
3155
|
+
["<!-- test", "id: @T12345678", "priority: high", "-->"].join("\n"),
|
|
3156
|
+
);
|
|
3157
|
+
});
|
|
3158
|
+
|
|
3159
|
+
it("skips fields with empty values when serializing", () => {
|
|
3160
|
+
const blocks: CustomEditorBlock[] = [
|
|
3161
|
+
{
|
|
3162
|
+
id: "m2",
|
|
3163
|
+
type: "testMeta",
|
|
3164
|
+
props: {
|
|
3165
|
+
metaKind: "test",
|
|
3166
|
+
metaFields: JSON.stringify([
|
|
3167
|
+
{ key: "id", value: "@T12345678" },
|
|
3168
|
+
{ key: "priority", value: "high" },
|
|
3169
|
+
{ key: "tags", value: "" },
|
|
3170
|
+
{ key: "", value: "orphan" },
|
|
3171
|
+
]),
|
|
3172
|
+
metaInline: false,
|
|
3173
|
+
} as any,
|
|
3174
|
+
content: undefined as any,
|
|
3175
|
+
children: [],
|
|
3176
|
+
},
|
|
3177
|
+
];
|
|
3178
|
+
expect(blocksToMarkdown(blocks)).toBe(
|
|
3179
|
+
["<!-- test", "id: @T12345678", "priority: high", "-->"].join("\n"),
|
|
3180
|
+
);
|
|
3181
|
+
});
|
|
3182
|
+
|
|
3183
|
+
it("leaves a generic HTML comment as paragraph text", () => {
|
|
3184
|
+
const blocks = markdownToBlocks("<!-- ai/agent generated description -->");
|
|
3185
|
+
expect(blocks[0].type).toBe("paragraph");
|
|
3186
|
+
expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe(
|
|
3187
|
+
"<!-- ai/agent generated description -->",
|
|
3188
|
+
);
|
|
3189
|
+
});
|
|
3190
|
+
});
|
|
@@ -389,6 +389,36 @@ function serializeBlock(
|
|
|
389
389
|
}
|
|
390
390
|
return lines;
|
|
391
391
|
}
|
|
392
|
+
case "testMeta": {
|
|
393
|
+
const kind = (block.props as any).metaKind === "suite" ? "suite" : "test";
|
|
394
|
+
const inline = Boolean((block.props as any).metaInline);
|
|
395
|
+
let fields: { key: string; value: string }[] = [];
|
|
396
|
+
try {
|
|
397
|
+
const parsed = JSON.parse(((block.props as any).metaFields ?? "[]") as string);
|
|
398
|
+
if (Array.isArray(parsed)) {
|
|
399
|
+
fields = parsed
|
|
400
|
+
.filter((f) => f && typeof f === "object" && typeof f.key === "string")
|
|
401
|
+
.map((f) => ({ key: f.key.trim(), value: typeof f.value === "string" ? f.value.trim() : "" }))
|
|
402
|
+
// Skip incomplete fields: both a key and a value are required.
|
|
403
|
+
.filter((f) => f.key.length > 0 && f.value.length > 0);
|
|
404
|
+
}
|
|
405
|
+
} catch {
|
|
406
|
+
fields = [];
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Preserve the one-liner form only when it still fits on a single line
|
|
410
|
+
// (a one-liner holds at most one `key: value` pair).
|
|
411
|
+
if (inline && fields.length <= 1) {
|
|
412
|
+
const field = fields[0];
|
|
413
|
+
lines.push(field ? `<!-- ${kind} ${field.key}: ${field.value} -->` : `<!-- ${kind} -->`);
|
|
414
|
+
return lines;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
lines.push(`<!-- ${kind}`);
|
|
418
|
+
fields.forEach((field) => lines.push(`${field.key}: ${field.value}`));
|
|
419
|
+
lines.push("-->");
|
|
420
|
+
return lines;
|
|
421
|
+
}
|
|
392
422
|
case "testStep":
|
|
393
423
|
case "snippet": {
|
|
394
424
|
const isSnippet = block.type === "snippet";
|
|
@@ -1355,6 +1385,92 @@ function parseParagraph(lines: string[], index: number): { block: CustomPartialB
|
|
|
1355
1385
|
};
|
|
1356
1386
|
}
|
|
1357
1387
|
|
|
1388
|
+
const META_COMMENT_OPEN_REGEX = /^<!--\s*(test|suite)(?=\s|-->|$)/i;
|
|
1389
|
+
|
|
1390
|
+
function metaFieldsFromBody(bodyLines: string[]): { key: string; value: string }[] {
|
|
1391
|
+
const fields: { key: string; value: string }[] = [];
|
|
1392
|
+
for (const raw of bodyLines) {
|
|
1393
|
+
const line = raw.trim();
|
|
1394
|
+
if (!line) continue;
|
|
1395
|
+
const colon = line.indexOf(":");
|
|
1396
|
+
// "Each line is `key: value`; lines without `:` are ignored."
|
|
1397
|
+
if (colon === -1) continue;
|
|
1398
|
+
const key = line.slice(0, colon).trim();
|
|
1399
|
+
const value = line.slice(colon + 1).trim();
|
|
1400
|
+
if (!key) continue;
|
|
1401
|
+
fields.push({ key, value });
|
|
1402
|
+
}
|
|
1403
|
+
return fields;
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
function parseMetaComment(
|
|
1407
|
+
lines: string[],
|
|
1408
|
+
index: number,
|
|
1409
|
+
): { block: CustomPartialBlock; nextIndex: number } | null {
|
|
1410
|
+
const first = lines[index];
|
|
1411
|
+
const openMatch = first.match(META_COMMENT_OPEN_REGEX);
|
|
1412
|
+
if (!openMatch) {
|
|
1413
|
+
return null;
|
|
1414
|
+
}
|
|
1415
|
+
const kind = openMatch[1].toLowerCase();
|
|
1416
|
+
|
|
1417
|
+
let bodyLines: string[] = [];
|
|
1418
|
+
let inline = false;
|
|
1419
|
+
let nextIndex: number;
|
|
1420
|
+
|
|
1421
|
+
// One-liner: opening and closing markers on the same line.
|
|
1422
|
+
const oneLine = first.match(/^<!--\s*(?:test|suite)\b\s*([\s\S]*?)\s*-->\s*$/i);
|
|
1423
|
+
if (oneLine) {
|
|
1424
|
+
inline = true;
|
|
1425
|
+
if (oneLine[1].trim()) {
|
|
1426
|
+
bodyLines = [oneLine[1].trim()];
|
|
1427
|
+
}
|
|
1428
|
+
nextIndex = index + 1;
|
|
1429
|
+
} else {
|
|
1430
|
+
// Block form: keyword line, fields on their own lines, closing `-->`.
|
|
1431
|
+
const afterKeyword = first.replace(/^<!--\s*(?:test|suite)\b/i, "").trim();
|
|
1432
|
+
if (afterKeyword) {
|
|
1433
|
+
bodyLines.push(afterKeyword);
|
|
1434
|
+
}
|
|
1435
|
+
let next = index + 1;
|
|
1436
|
+
let closed = false;
|
|
1437
|
+
while (next < lines.length) {
|
|
1438
|
+
const current = lines[next];
|
|
1439
|
+
if (/-->\s*$/.test(current)) {
|
|
1440
|
+
const beforeClose = current.replace(/-->\s*$/, "").trim();
|
|
1441
|
+
if (beforeClose) {
|
|
1442
|
+
bodyLines.push(beforeClose);
|
|
1443
|
+
}
|
|
1444
|
+
next += 1;
|
|
1445
|
+
closed = true;
|
|
1446
|
+
break;
|
|
1447
|
+
}
|
|
1448
|
+
bodyLines.push(current);
|
|
1449
|
+
next += 1;
|
|
1450
|
+
}
|
|
1451
|
+
if (!closed) {
|
|
1452
|
+
// Unterminated comment — let normal parsing handle these lines.
|
|
1453
|
+
return null;
|
|
1454
|
+
}
|
|
1455
|
+
nextIndex = next;
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
const fields = metaFieldsFromBody(bodyLines);
|
|
1459
|
+
|
|
1460
|
+
return {
|
|
1461
|
+
block: {
|
|
1462
|
+
type: "testMeta",
|
|
1463
|
+
props: {
|
|
1464
|
+
metaKind: kind,
|
|
1465
|
+
metaFields: JSON.stringify(fields),
|
|
1466
|
+
metaInline: inline,
|
|
1467
|
+
},
|
|
1468
|
+
children: [],
|
|
1469
|
+
} as CustomPartialBlock,
|
|
1470
|
+
nextIndex,
|
|
1471
|
+
};
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1358
1474
|
function parseSnippetWrapper(
|
|
1359
1475
|
lines: string[],
|
|
1360
1476
|
index: number,
|
|
@@ -1497,6 +1613,15 @@ export function markdownToBlocks(markdown: string, _options?: MarkdownToBlocksOp
|
|
|
1497
1613
|
continue;
|
|
1498
1614
|
}
|
|
1499
1615
|
|
|
1616
|
+
// Test/suite metadata comments can appear anywhere (typically at the top of
|
|
1617
|
+
// a document or right after a heading), so this runs ungated.
|
|
1618
|
+
const metaComment = parseMetaComment(lines, index);
|
|
1619
|
+
if (metaComment) {
|
|
1620
|
+
blocks.push(metaComment.block);
|
|
1621
|
+
index = metaComment.nextIndex;
|
|
1622
|
+
continue;
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1500
1625
|
const snippetWrapper = stepsHeadingLevel !== null
|
|
1501
1626
|
? parseSnippetWrapper(lines, index)
|
|
1502
1627
|
: null;
|
|
@@ -2,6 +2,7 @@ import { defaultBlockSpecs } from "@blocknote/core";
|
|
|
2
2
|
import { BlockNoteSchema } from "@blocknote/core";
|
|
3
3
|
import { stepBlock } from "./blocks/step";
|
|
4
4
|
import { snippetBlock } from "./blocks/snippet";
|
|
5
|
+
import { testMetaBlock } from "./blocks/testMeta";
|
|
5
6
|
import { fileBlock } from "./blocks/fileBlock";
|
|
6
7
|
import { htmlToMarkdown, markdownToHtml } from "./blocks/markdown";
|
|
7
8
|
|
|
@@ -11,6 +12,7 @@ export const customSchema = BlockNoteSchema.create({
|
|
|
11
12
|
file: fileBlock,
|
|
12
13
|
testStep: stepBlock,
|
|
13
14
|
snippet: snippetBlock,
|
|
15
|
+
testMeta: testMetaBlock,
|
|
14
16
|
},
|
|
15
17
|
});
|
|
16
18
|
|