testomatio-editor-blocks 0.4.65 → 0.4.67

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.
Files changed (33) hide show
  1. package/package/editor/blocks/step.d.ts +5 -0
  2. package/package/editor/blocks/step.js +273 -213
  3. package/package/editor/blocks/stepHorizontalView.d.ts +1 -0
  4. package/package/editor/blocks/stepHorizontalView.js +2 -2
  5. package/package/editor/blocks/testMeta.d.ts +37 -0
  6. package/package/editor/blocks/testMeta.js +111 -0
  7. package/package/editor/blocks/useDeferredMount.d.ts +26 -0
  8. package/package/editor/blocks/useDeferredMount.js +54 -0
  9. package/package/editor/createMarkdownPasteHandler.js +56 -9
  10. package/package/editor/customMarkdownConverter.js +127 -10
  11. package/package/editor/customSchema.d.ts +32 -0
  12. package/package/editor/customSchema.js +2 -0
  13. package/package/editor/testMetaFields.d.ts +17 -0
  14. package/package/editor/testMetaFields.js +33 -0
  15. package/package/index.d.ts +2 -0
  16. package/package/index.js +2 -0
  17. package/package/styles.css +215 -0
  18. package/package.json +1 -1
  19. package/src/App.tsx +54 -15
  20. package/src/editor/blocks/step.tsx +198 -47
  21. package/src/editor/blocks/stepHorizontalView.tsx +3 -0
  22. package/src/editor/blocks/stepNumber.test.ts +39 -0
  23. package/src/editor/blocks/testMeta.tsx +242 -0
  24. package/src/editor/blocks/useDeferredMount.ts +66 -0
  25. package/src/editor/createMarkdownPasteHandler.test.ts +126 -0
  26. package/src/editor/createMarkdownPasteHandler.ts +60 -8
  27. package/src/editor/customMarkdownConverter.test.ts +135 -0
  28. package/src/editor/customMarkdownConverter.ts +125 -0
  29. package/src/editor/customSchema.tsx +2 -0
  30. package/src/editor/renderingPerf.test.ts +59 -0
  31. package/src/editor/styles.css +215 -0
  32. package/src/editor/testMetaFields.ts +53 -0
  33. package/src/index.ts +7 -0
@@ -0,0 +1,111 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { createReactBlockSpec } from "@blocknote/react";
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
+ import { getMetaFieldSuggestions } from "../testMetaFields";
5
+ const ID_KEYS = new Set(["id"]);
6
+ function parseMetaFields(raw) {
7
+ if (typeof raw !== "string" || raw.trim().length === 0) {
8
+ return [];
9
+ }
10
+ try {
11
+ const parsed = JSON.parse(raw);
12
+ if (!Array.isArray(parsed)) {
13
+ return [];
14
+ }
15
+ return parsed
16
+ .filter((item) => item && typeof item === "object")
17
+ .map((item) => ({
18
+ key: typeof item.key === "string" ? item.key : "",
19
+ value: typeof item.value === "string" ? item.value : "",
20
+ }));
21
+ }
22
+ catch {
23
+ return [];
24
+ }
25
+ }
26
+ export function serializeMetaFields(fields) {
27
+ return JSON.stringify(fields);
28
+ }
29
+ function AddFieldMenu({ kind, usedKeys, onPick }) {
30
+ const [isOpen, setIsOpen] = useState(false);
31
+ const containerRef = useRef(null);
32
+ useEffect(() => {
33
+ if (!isOpen)
34
+ return;
35
+ const handleMouseDown = (event) => {
36
+ if (containerRef.current && !containerRef.current.contains(event.target)) {
37
+ setIsOpen(false);
38
+ }
39
+ };
40
+ document.addEventListener("mousedown", handleMouseDown);
41
+ return () => document.removeEventListener("mousedown", handleMouseDown);
42
+ }, [isOpen]);
43
+ const available = useMemo(() => {
44
+ const used = new Set(usedKeys.map((k) => k.trim().toLowerCase()));
45
+ return getMetaFieldSuggestions(kind).filter((s) => !used.has(s.key.trim().toLowerCase()));
46
+ }, [kind, usedKeys]);
47
+ const pick = useCallback((key) => {
48
+ onPick(key);
49
+ setIsOpen(false);
50
+ }, [onPick]);
51
+ return (_jsxs("div", { className: "bn-testmeta__add-wrap", ref: containerRef, children: [_jsx("button", { type: "button", className: "bn-testmeta__add", "aria-label": "Add field", title: "Add field", onClick: () => setIsOpen((prev) => !prev), children: "+" }), isOpen && (_jsxs("div", { className: "bn-testmeta__menu", role: "listbox", children: [available.map((suggestion) => {
52
+ var _a;
53
+ return (_jsx("button", { type: "button", role: "option", className: "bn-testmeta__menu-item", onMouseDown: (event) => {
54
+ event.preventDefault();
55
+ pick(suggestion.key);
56
+ }, children: (_a = suggestion.label) !== null && _a !== void 0 ? _a : suggestion.key }, suggestion.key));
57
+ }), _jsx("button", { type: "button", className: "bn-testmeta__menu-item bn-testmeta__menu-item--custom", onMouseDown: (event) => {
58
+ event.preventDefault();
59
+ pick("");
60
+ }, children: "Custom field\u2026" })] }))] }));
61
+ }
62
+ export const testMetaBlock = createReactBlockSpec({
63
+ type: "testMeta",
64
+ content: "none",
65
+ propSchema: {
66
+ // "test" | "suite" — which keyword the comment opened with.
67
+ metaKind: {
68
+ default: "test",
69
+ },
70
+ // JSON-encoded MetaField[] so insertion order is preserved.
71
+ metaFields: {
72
+ default: "[]",
73
+ },
74
+ // true when the source comment was a one-liner (`<!-- test id: @T.. -->`).
75
+ metaInline: {
76
+ default: false,
77
+ },
78
+ },
79
+ }, {
80
+ render: ({ block, editor }) => {
81
+ const kind = block.props.metaKind === "suite" ? "suite" : "test";
82
+ const fields = useMemo(() => parseMetaFields(block.props.metaFields), [block.props.metaFields]);
83
+ const commitFields = useCallback((next) => {
84
+ editor.updateBlock(block.id, {
85
+ props: { metaFields: serializeMetaFields(next) },
86
+ });
87
+ }, [block.id, editor]);
88
+ const handleValueChange = useCallback((index, value) => {
89
+ const next = fields.map((field, i) => i === index ? { ...field, value } : field);
90
+ commitFields(next);
91
+ }, [fields, commitFields]);
92
+ const handleKeyChange = useCallback((index, key) => {
93
+ const next = fields.map((field, i) => i === index ? { ...field, key } : field);
94
+ commitFields(next);
95
+ }, [fields, commitFields]);
96
+ const handleRemove = useCallback((index) => {
97
+ commitFields(fields.filter((_, i) => i !== index));
98
+ }, [fields, commitFields]);
99
+ const handleAddField = useCallback((key) => {
100
+ commitFields([...fields, { key, value: "" }]);
101
+ }, [fields, commitFields]);
102
+ const usedKeys = useMemo(() => fields.map((f) => f.key), [fields]);
103
+ // The read-only `id` is shown inline on the header line next to the kind
104
+ // label; every other field renders as an editable row below.
105
+ const idField = fields.find((f) => ID_KEYS.has(f.key.trim().toLowerCase()));
106
+ const editableFields = fields
107
+ .map((field, index) => ({ field, index }))
108
+ .filter(({ field }) => !ID_KEYS.has(field.key.trim().toLowerCase()));
109
+ return (_jsxs("div", { className: "bn-testmeta", "data-block-id": block.id, "data-kind": kind, contentEditable: false, suppressContentEditableWarning: true, draggable: false, children: [_jsxs("div", { className: "bn-testmeta__header", children: [_jsx("span", { className: "bn-testmeta__label", children: kind.toUpperCase() }), (idField === null || idField === void 0 ? void 0 : idField.value) && _jsx("span", { className: "bn-testmeta__id", children: idField.value }), _jsx(AddFieldMenu, { kind: kind, usedKeys: usedKeys, onPick: handleAddField })] }), editableFields.length > 0 && (_jsx("div", { className: "bn-testmeta__rows", children: editableFields.map(({ field, index }) => (_jsxs("div", { className: "bn-testmeta__row", children: [_jsx("input", { className: "bn-testmeta__key bn-testmeta__key--input", type: "text", value: field.key, placeholder: "key", spellCheck: false, onChange: (e) => handleKeyChange(index, e.target.value) }), _jsx("input", { className: "bn-testmeta__value", type: "text", value: field.value, placeholder: "value", spellCheck: false, onChange: (e) => handleValueChange(index, e.target.value) }), _jsx("button", { type: "button", className: "bn-testmeta__remove", "aria-label": "Remove field", title: "Remove field", onClick: () => handleRemove(index), children: "\u00D7" })] }, index))) }))] }));
110
+ },
111
+ });
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Defers mounting of expensive block content until the element is at (or near)
3
+ * the viewport. Heavy blocks (e.g. test steps that each spin up an OverType
4
+ * editor) render a cheap placeholder first; the real interactive content is
5
+ * mounted only once the block scrolls into view. This keeps pasting/loading a
6
+ * large document fast — only the visible steps pay the editor-init cost up
7
+ * front, the rest are upgraded lazily as the user scrolls.
8
+ *
9
+ * Returns a ref to attach to the wrapper element and a boolean that flips to
10
+ * `true` once (and stays true — we never tear an editor back down).
11
+ *
12
+ * `activate(focus)` lets the caller upgrade eagerly on interaction. Passing
13
+ * `focus: true` (a click/focus on the placeholder) records that the freshly
14
+ * mounted content should take focus, so a single click on a preview starts
15
+ * editing. Passive activation (hover pre-warm, scroll-into-view) leaves focus
16
+ * alone via `shouldFocusOnActivate === false`.
17
+ */
18
+ export declare function useDeferredMount<T extends HTMLElement>(options?: {
19
+ rootMargin?: string;
20
+ initiallyActive?: boolean;
21
+ }): {
22
+ ref: React.RefObject<T | null>;
23
+ active: boolean;
24
+ activate: (focus?: boolean) => void;
25
+ shouldFocusOnActivate: boolean;
26
+ };
@@ -0,0 +1,54 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ /**
3
+ * Defers mounting of expensive block content until the element is at (or near)
4
+ * the viewport. Heavy blocks (e.g. test steps that each spin up an OverType
5
+ * editor) render a cheap placeholder first; the real interactive content is
6
+ * mounted only once the block scrolls into view. This keeps pasting/loading a
7
+ * large document fast — only the visible steps pay the editor-init cost up
8
+ * front, the rest are upgraded lazily as the user scrolls.
9
+ *
10
+ * Returns a ref to attach to the wrapper element and a boolean that flips to
11
+ * `true` once (and stays true — we never tear an editor back down).
12
+ *
13
+ * `activate(focus)` lets the caller upgrade eagerly on interaction. Passing
14
+ * `focus: true` (a click/focus on the placeholder) records that the freshly
15
+ * mounted content should take focus, so a single click on a preview starts
16
+ * editing. Passive activation (hover pre-warm, scroll-into-view) leaves focus
17
+ * alone via `shouldFocusOnActivate === false`.
18
+ */
19
+ export function useDeferredMount(options = {}) {
20
+ const { rootMargin = "300px 0px", initiallyActive = false } = options;
21
+ const ref = useRef(null);
22
+ const [active, setActive] = useState(initiallyActive);
23
+ const activeRef = useRef(active);
24
+ activeRef.current = active;
25
+ const focusOnActivateRef = useRef(false);
26
+ const activate = (focus = false) => {
27
+ if (activeRef.current)
28
+ return;
29
+ if (focus)
30
+ focusOnActivateRef.current = true;
31
+ setActive(true);
32
+ };
33
+ useEffect(() => {
34
+ if (activeRef.current)
35
+ return;
36
+ const el = ref.current;
37
+ if (!el)
38
+ return;
39
+ // Environments without IntersectionObserver (or SSR) just mount eagerly.
40
+ if (typeof IntersectionObserver === "undefined") {
41
+ setActive(true);
42
+ return;
43
+ }
44
+ const observer = new IntersectionObserver((entries) => {
45
+ if (entries.some((entry) => entry.isIntersecting)) {
46
+ setActive(true);
47
+ observer.disconnect();
48
+ }
49
+ }, { rootMargin });
50
+ observer.observe(el);
51
+ return () => observer.disconnect();
52
+ }, [rootMargin]);
53
+ return { ref, active, activate, shouldFocusOnActivate: focusOnActivateRef.current };
54
+ }
@@ -1,4 +1,18 @@
1
1
  const BLOCK_MARKDOWN_PREFIX = /^(\s*)(#{1,6}\s|[-*+]\s|\d+[.)]\s|>\s|```|~~~|\||!\[)/;
2
+ // For large pastes, only the first chunk is inserted synchronously (enough to
3
+ // fill the viewport); the remaining blocks are streamed in during idle time so
4
+ // the editor stays responsive and the user sees content immediately instead of
5
+ // the main thread freezing while a thousand-block document is built at once.
6
+ const CHUNK_THRESHOLD = 150;
7
+ const FIRST_CHUNK = 50;
8
+ const REST_CHUNK = 40;
9
+ const scheduleIdle = typeof window !== "undefined" && typeof window.requestIdleCallback === "function"
10
+ ? (cb) => window.requestIdleCallback(() => cb(), { timeout: 200 })
11
+ : (cb) => setTimeout(cb, 0);
12
+ function lastBlockId(blocks) {
13
+ var _a;
14
+ return blocks.length ? (_a = blocks[blocks.length - 1]) === null || _a === void 0 ? void 0 : _a.id : undefined;
15
+ }
2
16
  function isInlineOnlyPaste(plainText, parsedBlocks) {
3
17
  if (parsedBlocks.length !== 1)
4
18
  return false;
@@ -41,21 +55,54 @@ export function createMarkdownPasteHandler(converter) {
41
55
  }
42
56
  const selection = editor.getSelection();
43
57
  const selectedIds = (_h = (_g = selection === null || selection === void 0 ? void 0 : selection.blocks) === null || _g === void 0 ? void 0 : _g.map((block) => block.id).filter((id) => Boolean(id))) !== null && _h !== void 0 ? _h : [];
44
- if (selectedIds.length > 0) {
45
- editor.replaceBlocks(selectedIds, parsedBlocks);
46
- }
47
- else {
58
+ // Insert the initial set of blocks at the paste location, returning the
59
+ // inserted blocks so subsequent chunks can be appended after the last one.
60
+ const insertInitial = (blocksToInsert) => {
61
+ if (selectedIds.length > 0) {
62
+ return editor.replaceBlocks(selectedIds, blocksToInsert).insertedBlocks;
63
+ }
48
64
  const cursorBlock = editor.getTextCursorPosition().block;
49
65
  if (cursorBlock) {
50
- editor.replaceBlocks([cursorBlock.id], parsedBlocks);
66
+ return editor.replaceBlocks([cursorBlock.id], blocksToInsert).insertedBlocks;
51
67
  }
52
- else if (editor.document.length > 0) {
68
+ if (editor.document.length > 0) {
53
69
  const reference = editor.document[editor.document.length - 1];
54
- editor.insertBlocks(parsedBlocks, reference.id, "after");
70
+ return editor.insertBlocks(blocksToInsert, reference.id, "after");
55
71
  }
56
- else {
72
+ return null;
73
+ };
74
+ if (parsedBlocks.length <= CHUNK_THRESHOLD) {
75
+ // Small paste: insert everything in one transaction (original behaviour).
76
+ if (insertInitial(parsedBlocks) === null)
57
77
  return defaultPasteHandler();
58
- }
78
+ }
79
+ else {
80
+ // Large paste: render the first screenful now, stream the rest in idle
81
+ // time so the main thread is never blocked building the whole document.
82
+ const firstChunk = parsedBlocks.slice(0, FIRST_CHUNK);
83
+ const rest = parsedBlocks.slice(FIRST_CHUNK);
84
+ const inserted = insertInitial(firstChunk);
85
+ if (inserted === null)
86
+ return defaultPasteHandler();
87
+ let anchorId = lastBlockId(inserted);
88
+ let cursor = 0;
89
+ const pump = () => {
90
+ var _a;
91
+ if (!anchorId || cursor >= rest.length)
92
+ return;
93
+ const batch = rest.slice(cursor, cursor + REST_CHUNK);
94
+ cursor += REST_CHUNK;
95
+ try {
96
+ const insertedBatch = editor.insertBlocks(batch, anchorId, "after");
97
+ anchorId = (_a = lastBlockId(insertedBatch)) !== null && _a !== void 0 ? _a : anchorId;
98
+ }
99
+ catch {
100
+ return; // stop streaming on any structural error
101
+ }
102
+ if (cursor < rest.length)
103
+ scheduleIdle(pump);
104
+ };
105
+ scheduleIdle(pump);
59
106
  }
60
107
  editor.focus();
61
108
  return true;
@@ -224,7 +224,7 @@ function serializeChildren(block, ctx) {
224
224
  return serializeBlocks(block.children, childCtx);
225
225
  }
226
226
  function serializeBlock(block, ctx, orderedIndex, stepIndex) {
227
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o;
227
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p;
228
228
  const lines = [];
229
229
  const indent = ctx.listDepth > 0 ? " ".repeat(ctx.listDepth) : "";
230
230
  switch (block.type) {
@@ -316,19 +316,48 @@ function serializeBlock(block, ctx, orderedIndex, stepIndex) {
316
316
  }
317
317
  return lines;
318
318
  }
319
+ case "testMeta": {
320
+ const kind = block.props.metaKind === "suite" ? "suite" : "test";
321
+ const inline = Boolean(block.props.metaInline);
322
+ let fields = [];
323
+ try {
324
+ const parsed = JSON.parse(((_e = block.props.metaFields) !== null && _e !== void 0 ? _e : "[]"));
325
+ if (Array.isArray(parsed)) {
326
+ fields = parsed
327
+ .filter((f) => f && typeof f === "object" && typeof f.key === "string")
328
+ .map((f) => ({ key: f.key.trim(), value: typeof f.value === "string" ? f.value.trim() : "" }))
329
+ // Skip incomplete fields: both a key and a value are required.
330
+ .filter((f) => f.key.length > 0 && f.value.length > 0);
331
+ }
332
+ }
333
+ catch {
334
+ fields = [];
335
+ }
336
+ // Preserve the one-liner form only when it still fits on a single line
337
+ // (a one-liner holds at most one `key: value` pair).
338
+ if (inline && fields.length <= 1) {
339
+ const field = fields[0];
340
+ lines.push(field ? `<!-- ${kind} ${field.key}: ${field.value} -->` : `<!-- ${kind} -->`);
341
+ return lines;
342
+ }
343
+ lines.push(`<!-- ${kind}`);
344
+ fields.forEach((field) => lines.push(`${field.key}: ${field.value}`));
345
+ lines.push("-->");
346
+ return lines;
347
+ }
319
348
  case "testStep":
320
349
  case "snippet": {
321
350
  const isSnippet = block.type === "snippet";
322
- const snippetId = isSnippet ? ((_e = block.props.snippetId) !== null && _e !== void 0 ? _e : "").trim() : "";
351
+ const snippetId = isSnippet ? ((_f = block.props.snippetId) !== null && _f !== void 0 ? _f : "").trim() : "";
323
352
  const stepTitle = isSnippet
324
- ? ((_f = block.props.snippetTitle) !== null && _f !== void 0 ? _f : "").trim()
325
- : ((_g = block.props.stepTitle) !== null && _g !== void 0 ? _g : "").trim();
353
+ ? ((_g = block.props.snippetTitle) !== null && _g !== void 0 ? _g : "").trim()
354
+ : ((_h = block.props.stepTitle) !== null && _h !== void 0 ? _h : "").trim();
326
355
  const stepData = isSnippet
327
- ? ((_h = block.props.snippetData) !== null && _h !== void 0 ? _h : "").trim()
328
- : ((_j = block.props.stepData) !== null && _j !== void 0 ? _j : "").trim();
356
+ ? ((_j = block.props.snippetData) !== null && _j !== void 0 ? _j : "").trim()
357
+ : ((_k = block.props.stepData) !== null && _k !== void 0 ? _k : "").trim();
329
358
  const expectedResult = isSnippet
330
- ? ((_k = block.props.snippetExpectedResult) !== null && _k !== void 0 ? _k : "").trim()
331
- : ((_l = block.props.expectedResult) !== null && _l !== void 0 ? _l : "").trim();
359
+ ? ((_l = block.props.snippetExpectedResult) !== null && _l !== void 0 ? _l : "").trim()
360
+ : ((_m = block.props.expectedResult) !== null && _m !== void 0 ? _m : "").trim();
332
361
  if (isSnippet) {
333
362
  if (snippetId) {
334
363
  lines.push(`<!-- begin snippet #${snippetId} -->`);
@@ -352,7 +381,7 @@ function serializeBlock(block, ctx, orderedIndex, stepIndex) {
352
381
  const normalizedExpectedForCheck = stripExpectedPrefix(expectedResult).trim();
353
382
  const hasContent = stepData.length > 0 || normalizedExpectedForCheck.length > 0;
354
383
  if (normalizedTitle.length > 0 || hasContent) {
355
- const listStyle = (_m = block.props.listStyle) !== null && _m !== void 0 ? _m : "bullet";
384
+ const listStyle = (_o = block.props.listStyle) !== null && _o !== void 0 ? _o : "bullet";
356
385
  const prefix = listStyle === "ordered" ? `${(stepIndex !== null && stepIndex !== void 0 ? stepIndex : 0) + 1}.` : "*";
357
386
  lines.push(normalizedTitle.length > 0 ? `${prefix} ${escapeStepContent(normalizedTitle)}` : `${prefix} `);
358
387
  }
@@ -406,7 +435,7 @@ function serializeBlock(block, ctx, orderedIndex, stepIndex) {
406
435
  return lines;
407
436
  }
408
437
  const headerRowCount = rows.length
409
- ? Math.min(rows.length, Math.max((_o = tableContent.headerRows) !== null && _o !== void 0 ? _o : 1, 1))
438
+ ? Math.min(rows.length, Math.max((_p = tableContent.headerRows) !== null && _p !== void 0 ? _p : 1, 1))
410
439
  : 0;
411
440
  const columnAlignments = new Array(columnCount).fill("left");
412
441
  const getCellAlignment = (cell) => {
@@ -1145,6 +1174,86 @@ function parseParagraph(lines, index) {
1145
1174
  nextIndex: index + 1,
1146
1175
  };
1147
1176
  }
1177
+ const META_COMMENT_OPEN_REGEX = /^<!--\s*(test|suite)(?=\s|-->|$)/i;
1178
+ function metaFieldsFromBody(bodyLines) {
1179
+ const fields = [];
1180
+ for (const raw of bodyLines) {
1181
+ const line = raw.trim();
1182
+ if (!line)
1183
+ continue;
1184
+ const colon = line.indexOf(":");
1185
+ // "Each line is `key: value`; lines without `:` are ignored."
1186
+ if (colon === -1)
1187
+ continue;
1188
+ const key = line.slice(0, colon).trim();
1189
+ const value = line.slice(colon + 1).trim();
1190
+ if (!key)
1191
+ continue;
1192
+ fields.push({ key, value });
1193
+ }
1194
+ return fields;
1195
+ }
1196
+ function parseMetaComment(lines, index) {
1197
+ const first = lines[index];
1198
+ const openMatch = first.match(META_COMMENT_OPEN_REGEX);
1199
+ if (!openMatch) {
1200
+ return null;
1201
+ }
1202
+ const kind = openMatch[1].toLowerCase();
1203
+ let bodyLines = [];
1204
+ let inline = false;
1205
+ let nextIndex;
1206
+ // One-liner: opening and closing markers on the same line.
1207
+ const oneLine = first.match(/^<!--\s*(?:test|suite)\b\s*([\s\S]*?)\s*-->\s*$/i);
1208
+ if (oneLine) {
1209
+ inline = true;
1210
+ if (oneLine[1].trim()) {
1211
+ bodyLines = [oneLine[1].trim()];
1212
+ }
1213
+ nextIndex = index + 1;
1214
+ }
1215
+ else {
1216
+ // Block form: keyword line, fields on their own lines, closing `-->`.
1217
+ const afterKeyword = first.replace(/^<!--\s*(?:test|suite)\b/i, "").trim();
1218
+ if (afterKeyword) {
1219
+ bodyLines.push(afterKeyword);
1220
+ }
1221
+ let next = index + 1;
1222
+ let closed = false;
1223
+ while (next < lines.length) {
1224
+ const current = lines[next];
1225
+ if (/-->\s*$/.test(current)) {
1226
+ const beforeClose = current.replace(/-->\s*$/, "").trim();
1227
+ if (beforeClose) {
1228
+ bodyLines.push(beforeClose);
1229
+ }
1230
+ next += 1;
1231
+ closed = true;
1232
+ break;
1233
+ }
1234
+ bodyLines.push(current);
1235
+ next += 1;
1236
+ }
1237
+ if (!closed) {
1238
+ // Unterminated comment — let normal parsing handle these lines.
1239
+ return null;
1240
+ }
1241
+ nextIndex = next;
1242
+ }
1243
+ const fields = metaFieldsFromBody(bodyLines);
1244
+ return {
1245
+ block: {
1246
+ type: "testMeta",
1247
+ props: {
1248
+ metaKind: kind,
1249
+ metaFields: JSON.stringify(fields),
1250
+ metaInline: inline,
1251
+ },
1252
+ children: [],
1253
+ },
1254
+ nextIndex,
1255
+ };
1256
+ }
1148
1257
  function parseSnippetWrapper(lines, index) {
1149
1258
  const trimmed = lines[index].trim();
1150
1259
  const startMatch = trimmed.match(/^<!--\s*begin snippet\s*#?\s*([^\s>]+)\s*-->/i);
@@ -1257,6 +1366,14 @@ export function markdownToBlocks(markdown, _options) {
1257
1366
  index += 1;
1258
1367
  continue;
1259
1368
  }
1369
+ // Test/suite metadata comments can appear anywhere (typically at the top of
1370
+ // a document or right after a heading), so this runs ungated.
1371
+ const metaComment = parseMetaComment(lines, index);
1372
+ if (metaComment) {
1373
+ blocks.push(metaComment.block);
1374
+ index = metaComment.nextIndex;
1375
+ continue;
1376
+ }
1260
1377
  const snippetWrapper = stepsHeadingLevel !== null
1261
1378
  ? parseSnippetWrapper(lines, index)
1262
1379
  : null;
@@ -117,6 +117,38 @@ export declare const customSchema: BlockNoteSchema<import("@blocknote/core").Blo
117
117
  };
118
118
  }, any, import("@blocknote/core").InlineContentSchema, import("@blocknote/core").StyleSchema>;
119
119
  };
120
+ testMeta: {
121
+ config: {
122
+ readonly type: "testMeta";
123
+ readonly content: "none";
124
+ readonly propSchema: {
125
+ readonly metaKind: {
126
+ readonly default: "test";
127
+ };
128
+ readonly metaFields: {
129
+ readonly default: "[]";
130
+ };
131
+ readonly metaInline: {
132
+ readonly default: false;
133
+ };
134
+ };
135
+ };
136
+ implementation: import("@blocknote/core").TiptapBlockImplementation<{
137
+ readonly type: "testMeta";
138
+ readonly content: "none";
139
+ readonly propSchema: {
140
+ readonly metaKind: {
141
+ readonly default: "test";
142
+ };
143
+ readonly metaFields: {
144
+ readonly default: "[]";
145
+ };
146
+ readonly metaInline: {
147
+ readonly default: false;
148
+ };
149
+ };
150
+ }, any, import("@blocknote/core").InlineContentSchema, import("@blocknote/core").StyleSchema>;
151
+ };
120
152
  paragraph: {
121
153
  config: {
122
154
  type: "paragraph";
@@ -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
  export const customSchema = BlockNoteSchema.create({
@@ -10,6 +11,7 @@ export const customSchema = BlockNoteSchema.create({
10
11
  file: fileBlock,
11
12
  testStep: stepBlock,
12
13
  snippet: snippetBlock,
14
+ testMeta: testMetaBlock,
13
15
  },
14
16
  });
15
17
  export const __markdownTestUtils = {
@@ -0,0 +1,17 @@
1
+ export type MetaFieldSuggestion = {
2
+ /** The field key that gets inserted, e.g. "priority". */
3
+ key: string;
4
+ /** Optional display label; defaults to `key`. */
5
+ label?: string;
6
+ };
7
+ /**
8
+ * Either a flat list (applied to both test and suite blocks) or per-kind lists.
9
+ * Configure from the host app via `setMetaFieldSuggestions` so embedders can
10
+ * plug in their own set of suggested metadata fields.
11
+ */
12
+ export type MetaFieldSuggestionsConfig = MetaFieldSuggestion[] | {
13
+ test?: MetaFieldSuggestion[];
14
+ suite?: MetaFieldSuggestion[];
15
+ };
16
+ export declare function setMetaFieldSuggestions(config: MetaFieldSuggestionsConfig | null): void;
17
+ export declare function getMetaFieldSuggestions(kind: "test" | "suite"): MetaFieldSuggestion[];
@@ -0,0 +1,33 @@
1
+ // Defaults follow the classical Testomatio markdown format. `id` is intentionally
2
+ // omitted: it is a read-only, system-assigned field, not something users add.
3
+ const DEFAULT_TEST_FIELDS = [
4
+ { key: "priority" },
5
+ { key: "type" },
6
+ { key: "tags" },
7
+ { key: "labels" },
8
+ { key: "assignee" },
9
+ { key: "creator" },
10
+ { key: "shared" },
11
+ ];
12
+ const DEFAULT_SUITE_FIELDS = [
13
+ { key: "emoji" },
14
+ { key: "tags" },
15
+ { key: "labels" },
16
+ { key: "assignee" },
17
+ ];
18
+ let configured = null;
19
+ export function setMetaFieldSuggestions(config) {
20
+ configured = config;
21
+ }
22
+ export function getMetaFieldSuggestions(kind) {
23
+ if (configured) {
24
+ if (Array.isArray(configured)) {
25
+ return configured;
26
+ }
27
+ const list = kind === "suite" ? configured.suite : configured.test;
28
+ if (list) {
29
+ return list;
30
+ }
31
+ }
32
+ return kind === "suite" ? DEFAULT_SUITE_FIELDS : DEFAULT_TEST_FIELDS;
33
+ }
@@ -1,6 +1,8 @@
1
1
  export { customSchema, type CustomSchema, type CustomBlock, type CustomEditor, } from "./editor/customSchema";
2
2
  export { stepBlock, canInsertStepOrSnippet, isStepsHeading, addStepsBlock, addSnippetBlock } from "./editor/blocks/step";
3
3
  export { snippetBlock } from "./editor/blocks/snippet";
4
+ export { testMetaBlock } from "./editor/blocks/testMeta";
5
+ export { setMetaFieldSuggestions, getMetaFieldSuggestions, type MetaFieldSuggestion, type MetaFieldSuggestionsConfig, } from "./editor/testMetaFields";
4
6
  export { markdownToHtml, htmlToMarkdown } from "./editor/blocks/markdown";
5
7
  export { blocksToMarkdown, markdownToBlocks, type CustomEditorBlock, type CustomPartialBlock, type MarkdownToBlocksOptions, } from "./editor/customMarkdownConverter";
6
8
  export { useStepAutocomplete, parseStepsFromJsonApi, setStepsFetcher, type StepSuggestion, type StepJsonApiDocument, type StepJsonApiResource, } from "./editor/stepAutocomplete";
package/package/index.js CHANGED
@@ -1,6 +1,8 @@
1
1
  export { customSchema, } from "./editor/customSchema";
2
2
  export { stepBlock, canInsertStepOrSnippet, isStepsHeading, addStepsBlock, addSnippetBlock } from "./editor/blocks/step";
3
3
  export { snippetBlock } from "./editor/blocks/snippet";
4
+ export { testMetaBlock } from "./editor/blocks/testMeta";
5
+ export { setMetaFieldSuggestions, getMetaFieldSuggestions, } from "./editor/testMetaFields";
4
6
  export { markdownToHtml, htmlToMarkdown } from "./editor/blocks/markdown";
5
7
  export { blocksToMarkdown, markdownToBlocks, } from "./editor/customMarkdownConverter";
6
8
  export { useStepAutocomplete, parseStepsFromJsonApi, setStepsFetcher, } from "./editor/stepAutocomplete";