testomatio-editor-blocks 0.4.80 → 0.4.82

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.
@@ -0,0 +1,19 @@
1
+ /**
2
+ * A read-only marker rendered from the `<!-- example -->` HTML comment that
3
+ * precedes a data/examples table in a testomat.io test file. It shows a compact
4
+ * "examples" panel (a cropped variant of the SUITE/TEST metadata bar) so the
5
+ * marker reads as a UI label instead of raw comment text. It has no fields and
6
+ * round-trips back to `<!-- example -->` on serialize.
7
+ */
8
+ export declare const exampleMarkerBlock: {
9
+ config: {
10
+ readonly type: "exampleMarker";
11
+ readonly content: "none";
12
+ readonly propSchema: {};
13
+ };
14
+ implementation: import("@blocknote/core").TiptapBlockImplementation<{
15
+ readonly type: "exampleMarker";
16
+ readonly content: "none";
17
+ readonly propSchema: {};
18
+ }, any, import("@blocknote/core").InlineContentSchema, import("@blocknote/core").StyleSchema>;
19
+ };
@@ -0,0 +1,12 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { createReactBlockSpec } from "@blocknote/react";
3
+ /**
4
+ * A read-only marker rendered from the `<!-- example -->` HTML comment that
5
+ * precedes a data/examples table in a testomat.io test file. It shows a compact
6
+ * "examples" panel (a cropped variant of the SUITE/TEST metadata bar) so the
7
+ * marker reads as a UI label instead of raw comment text. It has no fields and
8
+ * round-trips back to `<!-- example -->` on serialize.
9
+ */
10
+ export const exampleMarkerBlock = createReactBlockSpec({ type: "exampleMarker", content: "none", propSchema: {} }, {
11
+ render: () => (_jsx("div", { className: "bn-example-marker", contentEditable: false, suppressContentEditableWarning: true, draggable: false, children: _jsx("span", { className: "bn-example-marker__label", children: "examples" }) })),
12
+ });
@@ -354,6 +354,8 @@ function serializeBlock(block, ctx, orderedIndex, stepIndex) {
354
354
  }
355
355
  return lines;
356
356
  }
357
+ case "exampleMarker":
358
+ return ["<!-- example -->"];
357
359
  case "testMeta": {
358
360
  const kind = block.props.metaKind === "suite" ? "suite" : "test";
359
361
  const inline = Boolean(block.props.metaInline);
@@ -1223,6 +1225,18 @@ function parseParagraph(lines, index) {
1223
1225
  };
1224
1226
  }
1225
1227
  const META_COMMENT_OPEN_REGEX = /^<!--\s*(test|suite)(?=\s|-->|$)/i;
1228
+ // Marks the data/examples table that follows in a testomat.io test file. Accepts
1229
+ // both the singular `example` and plural `examples` spellings.
1230
+ const EXAMPLE_MARKER_REGEX = /^<!--\s*examples?\s*-->\s*$/i;
1231
+ function parseExampleMarker(lines, index) {
1232
+ if (!EXAMPLE_MARKER_REGEX.test(lines[index].trim())) {
1233
+ return null;
1234
+ }
1235
+ return {
1236
+ block: { type: "exampleMarker", props: {}, children: [] },
1237
+ nextIndex: index + 1,
1238
+ };
1239
+ }
1226
1240
  // Keys whose value is a YAML-style list (`key:` followed by indented `- item`
1227
1241
  // lines) in the testomat.io comment format — e.g. `issues` holding URLs. These
1228
1242
  // round-trip back to a list on serialize; everything else stays a flat line.
@@ -1440,6 +1454,14 @@ export function markdownToBlocks(markdown, _options) {
1440
1454
  index = metaComment.nextIndex;
1441
1455
  continue;
1442
1456
  }
1457
+ // Caught before parseParagraph so the `<!-- example -->` marker renders as
1458
+ // an "examples" panel instead of raw comment text.
1459
+ const exampleMarker = parseExampleMarker(lines, index);
1460
+ if (exampleMarker) {
1461
+ blocks.push(exampleMarker.block);
1462
+ index = exampleMarker.nextIndex;
1463
+ continue;
1464
+ }
1443
1465
  const snippetWrapper = stepsHeadingLevel !== null
1444
1466
  ? parseSnippetWrapper(lines, index)
1445
1467
  : null;
@@ -149,6 +149,18 @@ export declare const customSchema: BlockNoteSchema<import("@blocknote/core").Blo
149
149
  };
150
150
  }, any, import("@blocknote/core").InlineContentSchema, import("@blocknote/core").StyleSchema>;
151
151
  };
152
+ exampleMarker: {
153
+ config: {
154
+ readonly type: "exampleMarker";
155
+ readonly content: "none";
156
+ readonly propSchema: {};
157
+ };
158
+ implementation: import("@blocknote/core").TiptapBlockImplementation<{
159
+ readonly type: "exampleMarker";
160
+ readonly content: "none";
161
+ readonly propSchema: {};
162
+ }, any, import("@blocknote/core").InlineContentSchema, import("@blocknote/core").StyleSchema>;
163
+ };
152
164
  paragraph: {
153
165
  config: {
154
166
  type: "paragraph";
@@ -3,6 +3,7 @@ import { BlockNoteSchema } from "@blocknote/core";
3
3
  import { stepBlock } from "./blocks/step";
4
4
  import { snippetBlock } from "./blocks/snippet";
5
5
  import { testMetaBlock } from "./blocks/testMeta";
6
+ import { exampleMarkerBlock } from "./blocks/exampleMarker";
6
7
  import { fileBlock } from "./blocks/fileBlock";
7
8
  import { htmlToMarkdown, markdownToHtml } from "./blocks/markdown";
8
9
  export const customSchema = BlockNoteSchema.create({
@@ -12,6 +13,7 @@ export const customSchema = BlockNoteSchema.create({
12
13
  testStep: stepBlock,
13
14
  snippet: snippetBlock,
14
15
  testMeta: testMetaBlock,
16
+ exampleMarker: exampleMarkerBlock,
15
17
  },
16
18
  });
17
19
  export const __markdownTestUtils = {
@@ -0,0 +1,46 @@
1
+ import { BlockNoteExtension } from "@blocknote/core";
2
+ /**
3
+ * An "example table" is a data table that belongs to a testomat.io `<!-- example -->`
4
+ * marker: it sits between that marker and the start of the next test — i.e. the next
5
+ * `testMeta` block (`<!-- test ... -->` / `<!-- suite ... -->`) — or the end of the
6
+ * document. Such tables are painted with a gray cell background so they read as the
7
+ * marker's data table.
8
+ *
9
+ * Given the block content-type names in document order, returns a boolean[] where
10
+ * entry `i` is `true` iff a `"table"` at index `i` falls inside an example region.
11
+ * `exampleMarker` opens a region; `testMeta` closes it; everything else (headings,
12
+ * paragraphs, …) leaves the region unchanged.
13
+ *
14
+ * Pure and DOM-free so it can be unit-tested directly.
15
+ */
16
+ export declare function markExampleTables(types: readonly string[]): boolean[];
17
+ /**
18
+ * Example tables only occur in a *suite* document — one that opens with a
19
+ * `<!-- suite ... -->` comment (a `testMeta` block whose `metaKind` is `"suite"`).
20
+ * Gating on the first block lets the plugin skip the whole scan for any other
21
+ * document (a lone test, plain notes, …) instead of walking it on every change.
22
+ *
23
+ * Pure so it can be unit-tested directly.
24
+ */
25
+ export declare function isSuiteDocument(firstBlockType: string | undefined, firstBlockMetaKind: string | undefined): boolean;
26
+ /**
27
+ * BlockNote extension that grays the cells of the table following an
28
+ * `<!-- example -->` marker (bounded by the next test/suite comment or the end of
29
+ * the document).
30
+ *
31
+ * Editor extensions are supplied at editor-creation time and cannot be carried by
32
+ * the schema, so consumers must add this to their `useCreateBlockNote` call:
33
+ *
34
+ * ```ts
35
+ * useCreateBlockNote({
36
+ * schema: customSchema,
37
+ * extensions: [exampleTableHighlightExtension()],
38
+ * });
39
+ * ```
40
+ */
41
+ export declare class ExampleTableHighlightExtension extends BlockNoteExtension {
42
+ static key(): string;
43
+ constructor();
44
+ }
45
+ /** Factory for the `extensions` option of `useCreateBlockNote`. */
46
+ export declare const exampleTableHighlightExtension: () => ExampleTableHighlightExtension;
@@ -0,0 +1,131 @@
1
+ import { BlockNoteExtension } from "@blocknote/core";
2
+ import { Plugin, PluginKey } from "prosemirror-state";
3
+ import { Decoration, DecorationSet } from "prosemirror-view";
4
+ /**
5
+ * An "example table" is a data table that belongs to a testomat.io `<!-- example -->`
6
+ * marker: it sits between that marker and the start of the next test — i.e. the next
7
+ * `testMeta` block (`<!-- test ... -->` / `<!-- suite ... -->`) — or the end of the
8
+ * document. Such tables are painted with a gray cell background so they read as the
9
+ * marker's data table.
10
+ *
11
+ * Given the block content-type names in document order, returns a boolean[] where
12
+ * entry `i` is `true` iff a `"table"` at index `i` falls inside an example region.
13
+ * `exampleMarker` opens a region; `testMeta` closes it; everything else (headings,
14
+ * paragraphs, …) leaves the region unchanged.
15
+ *
16
+ * Pure and DOM-free so it can be unit-tested directly.
17
+ */
18
+ export function markExampleTables(types) {
19
+ const flags = [];
20
+ let active = false;
21
+ for (const name of types) {
22
+ if (name === "exampleMarker") {
23
+ active = true;
24
+ flags.push(false);
25
+ }
26
+ else if (name === "testMeta") {
27
+ active = false;
28
+ flags.push(false);
29
+ }
30
+ else {
31
+ flags.push(name === "table" && active);
32
+ }
33
+ }
34
+ return flags;
35
+ }
36
+ /**
37
+ * Example tables only occur in a *suite* document — one that opens with a
38
+ * `<!-- suite ... -->` comment (a `testMeta` block whose `metaKind` is `"suite"`).
39
+ * Gating on the first block lets the plugin skip the whole scan for any other
40
+ * document (a lone test, plain notes, …) instead of walking it on every change.
41
+ *
42
+ * Pure so it can be unit-tested directly.
43
+ */
44
+ export function isSuiteDocument(firstBlockType, firstBlockMetaKind) {
45
+ return firstBlockType === "testMeta" && firstBlockMetaKind === "suite";
46
+ }
47
+ /**
48
+ * Build node decorations that add `.bn-example-table` to every table in an example
49
+ * region. Tables are only *painted* — the document is untouched, so markdown
50
+ * serialization round-trips unchanged.
51
+ *
52
+ * The `<!-- example -->` marker, the metadata comment, and the table each become a
53
+ * top-level block whose content node is named by its block type (`exampleMarker`,
54
+ * `testMeta`, `table`), so a single document-order walk yields them directly.
55
+ *
56
+ * Nothing is highlighted unless the document is a suite (see `isSuiteDocument`).
57
+ */
58
+ function buildExampleTableDecorations(doc) {
59
+ const entries = [];
60
+ let firstBlockType;
61
+ let firstBlockMetaKind;
62
+ doc.descendants((node, pos, parent) => {
63
+ var _a;
64
+ // The first content node encountered under a blockContainer is the document's
65
+ // first block — capture its type/metaKind for the suite gate below.
66
+ if (firstBlockType === undefined && (parent === null || parent === void 0 ? void 0 : parent.type.name) === "blockContainer") {
67
+ firstBlockType = node.type.name;
68
+ const metaKind = (_a = node.attrs) === null || _a === void 0 ? void 0 : _a.metaKind;
69
+ firstBlockMetaKind = typeof metaKind === "string" ? metaKind : undefined;
70
+ }
71
+ const name = node.type.name;
72
+ if (name === "exampleMarker" || name === "testMeta" || name === "table") {
73
+ entries.push({ pos, size: node.nodeSize, name });
74
+ // No need to descend into table cells or the marker's (empty) internals.
75
+ return false;
76
+ }
77
+ // Descend through the blockGroup / blockContainer wrappers to reach content.
78
+ return undefined;
79
+ });
80
+ if (!isSuiteDocument(firstBlockType, firstBlockMetaKind)) {
81
+ return DecorationSet.empty;
82
+ }
83
+ const flags = markExampleTables(entries.map((entry) => entry.name));
84
+ const decorations = entries
85
+ .filter((_, index) => flags[index])
86
+ .map((entry) => Decoration.node(entry.pos, entry.pos + entry.size, {
87
+ class: "bn-example-table",
88
+ }));
89
+ return DecorationSet.create(doc, decorations);
90
+ }
91
+ const exampleTablePluginKey = new PluginKey("testomatioExampleTable");
92
+ function exampleTablePlugin() {
93
+ return new Plugin({
94
+ key: exampleTablePluginKey,
95
+ state: {
96
+ init: (_config, state) => buildExampleTableDecorations(state.doc),
97
+ apply: (tr, value) => tr.docChanged ? buildExampleTableDecorations(tr.doc) : value,
98
+ },
99
+ props: {
100
+ decorations(state) {
101
+ return exampleTablePluginKey.getState(state);
102
+ },
103
+ },
104
+ });
105
+ }
106
+ /**
107
+ * BlockNote extension that grays the cells of the table following an
108
+ * `<!-- example -->` marker (bounded by the next test/suite comment or the end of
109
+ * the document).
110
+ *
111
+ * Editor extensions are supplied at editor-creation time and cannot be carried by
112
+ * the schema, so consumers must add this to their `useCreateBlockNote` call:
113
+ *
114
+ * ```ts
115
+ * useCreateBlockNote({
116
+ * schema: customSchema,
117
+ * extensions: [exampleTableHighlightExtension()],
118
+ * });
119
+ * ```
120
+ */
121
+ export class ExampleTableHighlightExtension extends BlockNoteExtension {
122
+ static key() {
123
+ return "exampleTableHighlight";
124
+ }
125
+ constructor() {
126
+ super();
127
+ this.addProsemirrorPlugin(exampleTablePlugin());
128
+ }
129
+ }
130
+ /** Factory for the `extensions` option of `useCreateBlockNote`. */
131
+ export const exampleTableHighlightExtension = () => new ExampleTableHighlightExtension();
@@ -6,6 +6,7 @@ export { setMetaFieldSuggestions, getMetaFieldSuggestions, type MetaFieldSuggest
6
6
  export { markdownToHtml, htmlToMarkdown } from "./editor/blocks/markdown";
7
7
  export { tagBadgeExtension, TagBadgeExtension, TAGS_DETECT_REGEXP, detectTags, type TagMatch, } from "./editor/tagBadge";
8
8
  export { autoLinkExtension, AutoLinkExtension, detectLinks, type LinkMatch, } from "./editor/autoLink";
9
+ export { exampleTableHighlightExtension, ExampleTableHighlightExtension, markExampleTables, } from "./editor/exampleTableHighlight";
9
10
  export { blocksToMarkdown, markdownToBlocks, type CustomEditorBlock, type CustomPartialBlock, type MarkdownToBlocksOptions, } from "./editor/customMarkdownConverter";
10
11
  export { useStepAutocomplete, parseStepsFromJsonApi, setStepsFetcher, type StepSuggestion, type StepJsonApiDocument, type StepJsonApiResource, } from "./editor/stepAutocomplete";
11
12
  export { useStepImageUpload, setImageUploadHandler, type StepImageUploadHandler, } from "./editor/stepImageUpload";
package/package/index.js CHANGED
@@ -6,6 +6,7 @@ export { setMetaFieldSuggestions, getMetaFieldSuggestions, } from "./editor/test
6
6
  export { markdownToHtml, htmlToMarkdown } from "./editor/blocks/markdown";
7
7
  export { tagBadgeExtension, TagBadgeExtension, TAGS_DETECT_REGEXP, detectTags, } from "./editor/tagBadge";
8
8
  export { autoLinkExtension, AutoLinkExtension, detectLinks, } from "./editor/autoLink";
9
+ export { exampleTableHighlightExtension, ExampleTableHighlightExtension, markExampleTables, } from "./editor/exampleTableHighlight";
9
10
  export { blocksToMarkdown, markdownToBlocks, } from "./editor/customMarkdownConverter";
10
11
  export { useStepAutocomplete, parseStepsFromJsonApi, setStepsFetcher, } from "./editor/stepAutocomplete";
11
12
  export { useStepImageUpload, setImageUploadHandler, } from "./editor/stepImageUpload";
@@ -811,6 +811,37 @@ html.dark .testomatio-editor .bn-auto-link {
811
811
  opacity: 0.5;
812
812
  }
813
813
 
814
+ /* Compact marker for `<!-- example -->` — a cropped variant of the testMeta bar
815
+ (fit-content width, not full editor width) labelling the table below it. */
816
+ .bn-example-marker {
817
+ display: inline-flex;
818
+ align-items: center;
819
+ width: fit-content;
820
+ box-sizing: border-box;
821
+ padding: 4px 12px;
822
+ background: var(--bg-muted);
823
+ border-top: 3px solid var(--color-slate-400);
824
+ margin-top: 1rem;
825
+ opacity: 0.6;
826
+ }
827
+
828
+ .bn-example-marker__label {
829
+ font-size: 11px;
830
+ font-weight: 600;
831
+ letter-spacing: 0.04em;
832
+ text-transform: uppercase;
833
+ color: var(--text-muted);
834
+ }
835
+
836
+ /* Gray cells for the data table that follows an `<!-- example -->` marker. The
837
+ `.bn-example-table` class is applied by a ProseMirror decoration (see
838
+ exampleTableHighlight.ts), bounded to tables between the marker and the next
839
+ test/suite comment (or end of document). */
840
+ .bn-editor [data-content-type="table"].bn-example-table td,
841
+ .bn-editor [data-content-type="table"].bn-example-table th {
842
+ background: var(--bg-muted);
843
+ }
844
+
814
845
  /* Header line: `TEST @T1233456 ............ [+]` — label, id, and add button
815
846
  always share one row. */
816
847
  .bn-testmeta__header {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testomatio-editor-blocks",
3
- "version": "0.4.80",
3
+ "version": "0.4.82",
4
4
  "description": "Custom BlockNote schema, markdown conversion helpers, and UI for Testomatio-style test cases and steps.",
5
5
  "type": "module",
6
6
  "main": "./package/index.js",
package/src/App.tsx CHANGED
@@ -22,6 +22,7 @@ import { createMarkdownPasteHandler } from "./editor/createMarkdownPasteHandler"
22
22
  import { customSchema, type CustomEditor } from "./editor/customSchema";
23
23
  import { tagBadgeExtension } from "./editor/tagBadge";
24
24
  import { autoLinkExtension } from "./editor/autoLink";
25
+ import { exampleTableHighlightExtension } from "./editor/exampleTableHighlight";
25
26
  import { setStepsFetcher, type StepJsonApiDocument } from "./editor/stepAutocomplete";
26
27
  import { setSnippetFetcher, type SnippetJsonApiDocument } from "./editor/snippetAutocomplete";
27
28
  import {
@@ -500,7 +501,7 @@ function CustomSlashMenu() {
500
501
  function App() {
501
502
  const editor = useCreateBlockNote({
502
503
  schema: customSchema,
503
- extensions: [tagBadgeExtension(), autoLinkExtension()],
504
+ extensions: [tagBadgeExtension(), autoLinkExtension(), exampleTableHighlightExtension()],
504
505
  pasteHandler: createMarkdownPasteHandler(markdownToBlocks),
505
506
  uploadFile: async (file: File) => {
506
507
  const url = `https://placehold.co/600x400?text=${encodeURIComponent(file.name)}`;
@@ -0,0 +1,24 @@
1
+ import { createReactBlockSpec } from "@blocknote/react";
2
+
3
+ /**
4
+ * A read-only marker rendered from the `<!-- example -->` HTML comment that
5
+ * precedes a data/examples table in a testomat.io test file. It shows a compact
6
+ * "examples" panel (a cropped variant of the SUITE/TEST metadata bar) so the
7
+ * marker reads as a UI label instead of raw comment text. It has no fields and
8
+ * round-trips back to `<!-- example -->` on serialize.
9
+ */
10
+ export const exampleMarkerBlock = createReactBlockSpec(
11
+ { type: "exampleMarker", content: "none", propSchema: {} },
12
+ {
13
+ render: () => (
14
+ <div
15
+ className="bn-example-marker"
16
+ contentEditable={false}
17
+ suppressContentEditableWarning
18
+ draggable={false}
19
+ >
20
+ <span className="bn-example-marker__label">examples</span>
21
+ </div>
22
+ ),
23
+ },
24
+ );
@@ -3182,6 +3182,34 @@ describe("test/suite metadata comments", () => {
3182
3182
  expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe(markdown);
3183
3183
  });
3184
3184
 
3185
+ it("parses an <!-- example --> marker into an exampleMarker block", () => {
3186
+ const blocks = markdownToBlocks("<!-- example -->");
3187
+ expect(blocks).toEqual([
3188
+ {
3189
+ type: "exampleMarker",
3190
+ props: {},
3191
+ children: [],
3192
+ },
3193
+ ]);
3194
+ });
3195
+
3196
+ it("parses the plural <!-- examples --> spelling too", () => {
3197
+ const blocks = markdownToBlocks("<!-- examples -->");
3198
+ expect(blocks).toEqual([
3199
+ {
3200
+ type: "exampleMarker",
3201
+ props: {},
3202
+ children: [],
3203
+ },
3204
+ ]);
3205
+ });
3206
+
3207
+ it("round-trips an <!-- example --> marker", () => {
3208
+ const markdown = "<!-- example -->";
3209
+ const blocks = markdownToBlocks(markdown);
3210
+ expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe(markdown);
3211
+ });
3212
+
3185
3213
  it("parses a multi-line suite block with ordered fields", () => {
3186
3214
  const markdown = [
3187
3215
  "<!-- suite",
@@ -425,6 +425,8 @@ function serializeBlock(
425
425
  }
426
426
  return lines;
427
427
  }
428
+ case "exampleMarker":
429
+ return ["<!-- example -->"];
428
430
  case "testMeta": {
429
431
  const kind = (block.props as any).metaKind === "suite" ? "suite" : "test";
430
432
  const inline = Boolean((block.props as any).metaInline);
@@ -1435,6 +1437,23 @@ function parseParagraph(lines: string[], index: number): { block: CustomPartialB
1435
1437
 
1436
1438
  const META_COMMENT_OPEN_REGEX = /^<!--\s*(test|suite)(?=\s|-->|$)/i;
1437
1439
 
1440
+ // Marks the data/examples table that follows in a testomat.io test file. Accepts
1441
+ // both the singular `example` and plural `examples` spellings.
1442
+ const EXAMPLE_MARKER_REGEX = /^<!--\s*examples?\s*-->\s*$/i;
1443
+
1444
+ function parseExampleMarker(
1445
+ lines: string[],
1446
+ index: number,
1447
+ ): { block: CustomPartialBlock; nextIndex: number } | null {
1448
+ if (!EXAMPLE_MARKER_REGEX.test(lines[index].trim())) {
1449
+ return null;
1450
+ }
1451
+ return {
1452
+ block: { type: "exampleMarker", props: {}, children: [] } as CustomPartialBlock,
1453
+ nextIndex: index + 1,
1454
+ };
1455
+ }
1456
+
1438
1457
  // Keys whose value is a YAML-style list (`key:` followed by indented `- item`
1439
1458
  // lines) in the testomat.io comment format — e.g. `issues` holding URLs. These
1440
1459
  // round-trip back to a list on serialize; everything else stays a flat line.
@@ -1691,6 +1710,15 @@ export function markdownToBlocks(markdown: string, _options?: MarkdownToBlocksOp
1691
1710
  continue;
1692
1711
  }
1693
1712
 
1713
+ // Caught before parseParagraph so the `<!-- example -->` marker renders as
1714
+ // an "examples" panel instead of raw comment text.
1715
+ const exampleMarker = parseExampleMarker(lines, index);
1716
+ if (exampleMarker) {
1717
+ blocks.push(exampleMarker.block);
1718
+ index = exampleMarker.nextIndex;
1719
+ continue;
1720
+ }
1721
+
1694
1722
  const snippetWrapper = stepsHeadingLevel !== null
1695
1723
  ? parseSnippetWrapper(lines, index)
1696
1724
  : null;
@@ -3,6 +3,7 @@ import { BlockNoteSchema } from "@blocknote/core";
3
3
  import { stepBlock } from "./blocks/step";
4
4
  import { snippetBlock } from "./blocks/snippet";
5
5
  import { testMetaBlock } from "./blocks/testMeta";
6
+ import { exampleMarkerBlock } from "./blocks/exampleMarker";
6
7
  import { fileBlock } from "./blocks/fileBlock";
7
8
  import { htmlToMarkdown, markdownToHtml } from "./blocks/markdown";
8
9
 
@@ -13,6 +14,7 @@ export const customSchema = BlockNoteSchema.create({
13
14
  testStep: stepBlock,
14
15
  snippet: snippetBlock,
15
16
  testMeta: testMetaBlock,
17
+ exampleMarker: exampleMarkerBlock,
16
18
  },
17
19
  });
18
20
 
@@ -0,0 +1,66 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { isSuiteDocument, markExampleTables } from "./exampleTableHighlight";
3
+
4
+ describe("isSuiteDocument", () => {
5
+ it("is true only when the first block is a suite testMeta", () => {
6
+ expect(isSuiteDocument("testMeta", "suite")).toBe(true);
7
+ });
8
+
9
+ it("is false for a document opening with a test (not a suite)", () => {
10
+ expect(isSuiteDocument("testMeta", "test")).toBe(false);
11
+ });
12
+
13
+ it("is false when the first block is not a testMeta", () => {
14
+ expect(isSuiteDocument("heading", undefined)).toBe(false);
15
+ expect(isSuiteDocument("paragraph", "suite")).toBe(false);
16
+ });
17
+
18
+ it("is false for an empty document", () => {
19
+ expect(isSuiteDocument(undefined, undefined)).toBe(false);
20
+ });
21
+ });
22
+
23
+ describe("markExampleTables", () => {
24
+ it("flags a table that follows an example marker", () => {
25
+ expect(markExampleTables(["exampleMarker", "paragraph", "table"])).toEqual([
26
+ false,
27
+ false,
28
+ true,
29
+ ]);
30
+ });
31
+
32
+ it("stops the region at the next test/suite comment", () => {
33
+ // First table is in the example region; the testMeta closes it, so the
34
+ // second table (belonging to the next test) is not flagged.
35
+ expect(
36
+ markExampleTables(["exampleMarker", "table", "testMeta", "table"]),
37
+ ).toEqual([false, true, false, false]);
38
+ });
39
+
40
+ it("does not flag tables without a preceding example marker", () => {
41
+ expect(markExampleTables(["table"])).toEqual([false]);
42
+ expect(markExampleTables(["testMeta", "table"])).toEqual([false, false]);
43
+ });
44
+
45
+ it("keeps the region open across headings and other blocks", () => {
46
+ expect(
47
+ markExampleTables(["exampleMarker", "heading", "paragraph", "table"]),
48
+ ).toEqual([false, false, false, true]);
49
+ });
50
+
51
+ it("flags every table in the region until the next test", () => {
52
+ expect(
53
+ markExampleTables(["exampleMarker", "table", "table", "testMeta", "table"]),
54
+ ).toEqual([false, true, true, false, false]);
55
+ });
56
+
57
+ it("reopens the region when a new example marker appears", () => {
58
+ expect(
59
+ markExampleTables(["exampleMarker", "table", "testMeta", "exampleMarker", "table"]),
60
+ ).toEqual([false, true, false, false, true]);
61
+ });
62
+
63
+ it("returns an empty array for no blocks", () => {
64
+ expect(markExampleTables([])).toEqual([]);
65
+ });
66
+ });
@@ -0,0 +1,148 @@
1
+ import { BlockNoteExtension } from "@blocknote/core";
2
+ import type { Node as PMNode } from "prosemirror-model";
3
+ import { Plugin, PluginKey } from "prosemirror-state";
4
+ import { Decoration, DecorationSet } from "prosemirror-view";
5
+
6
+ /**
7
+ * An "example table" is a data table that belongs to a testomat.io `<!-- example -->`
8
+ * marker: it sits between that marker and the start of the next test — i.e. the next
9
+ * `testMeta` block (`<!-- test ... -->` / `<!-- suite ... -->`) — or the end of the
10
+ * document. Such tables are painted with a gray cell background so they read as the
11
+ * marker's data table.
12
+ *
13
+ * Given the block content-type names in document order, returns a boolean[] where
14
+ * entry `i` is `true` iff a `"table"` at index `i` falls inside an example region.
15
+ * `exampleMarker` opens a region; `testMeta` closes it; everything else (headings,
16
+ * paragraphs, …) leaves the region unchanged.
17
+ *
18
+ * Pure and DOM-free so it can be unit-tested directly.
19
+ */
20
+ export function markExampleTables(types: readonly string[]): boolean[] {
21
+ const flags: boolean[] = [];
22
+ let active = false;
23
+ for (const name of types) {
24
+ if (name === "exampleMarker") {
25
+ active = true;
26
+ flags.push(false);
27
+ } else if (name === "testMeta") {
28
+ active = false;
29
+ flags.push(false);
30
+ } else {
31
+ flags.push(name === "table" && active);
32
+ }
33
+ }
34
+ return flags;
35
+ }
36
+
37
+ /**
38
+ * Example tables only occur in a *suite* document — one that opens with a
39
+ * `<!-- suite ... -->` comment (a `testMeta` block whose `metaKind` is `"suite"`).
40
+ * Gating on the first block lets the plugin skip the whole scan for any other
41
+ * document (a lone test, plain notes, …) instead of walking it on every change.
42
+ *
43
+ * Pure so it can be unit-tested directly.
44
+ */
45
+ export function isSuiteDocument(
46
+ firstBlockType: string | undefined,
47
+ firstBlockMetaKind: string | undefined,
48
+ ): boolean {
49
+ return firstBlockType === "testMeta" && firstBlockMetaKind === "suite";
50
+ }
51
+
52
+ /**
53
+ * Build node decorations that add `.bn-example-table` to every table in an example
54
+ * region. Tables are only *painted* — the document is untouched, so markdown
55
+ * serialization round-trips unchanged.
56
+ *
57
+ * The `<!-- example -->` marker, the metadata comment, and the table each become a
58
+ * top-level block whose content node is named by its block type (`exampleMarker`,
59
+ * `testMeta`, `table`), so a single document-order walk yields them directly.
60
+ *
61
+ * Nothing is highlighted unless the document is a suite (see `isSuiteDocument`).
62
+ */
63
+ function buildExampleTableDecorations(doc: PMNode): DecorationSet {
64
+ const entries: { pos: number; size: number; name: string }[] = [];
65
+ let firstBlockType: string | undefined;
66
+ let firstBlockMetaKind: string | undefined;
67
+
68
+ doc.descendants((node, pos, parent) => {
69
+ // The first content node encountered under a blockContainer is the document's
70
+ // first block — capture its type/metaKind for the suite gate below.
71
+ if (firstBlockType === undefined && parent?.type.name === "blockContainer") {
72
+ firstBlockType = node.type.name;
73
+ const metaKind = node.attrs?.metaKind;
74
+ firstBlockMetaKind = typeof metaKind === "string" ? metaKind : undefined;
75
+ }
76
+
77
+ const name = node.type.name;
78
+ if (name === "exampleMarker" || name === "testMeta" || name === "table") {
79
+ entries.push({ pos, size: node.nodeSize, name });
80
+ // No need to descend into table cells or the marker's (empty) internals.
81
+ return false;
82
+ }
83
+ // Descend through the blockGroup / blockContainer wrappers to reach content.
84
+ return undefined;
85
+ });
86
+
87
+ if (!isSuiteDocument(firstBlockType, firstBlockMetaKind)) {
88
+ return DecorationSet.empty;
89
+ }
90
+
91
+ const flags = markExampleTables(entries.map((entry) => entry.name));
92
+ const decorations = entries
93
+ .filter((_, index) => flags[index])
94
+ .map((entry) =>
95
+ Decoration.node(entry.pos, entry.pos + entry.size, {
96
+ class: "bn-example-table",
97
+ }),
98
+ );
99
+
100
+ return DecorationSet.create(doc, decorations);
101
+ }
102
+
103
+ const exampleTablePluginKey = new PluginKey<DecorationSet>("testomatioExampleTable");
104
+
105
+ function exampleTablePlugin(): Plugin<DecorationSet> {
106
+ return new Plugin<DecorationSet>({
107
+ key: exampleTablePluginKey,
108
+ state: {
109
+ init: (_config, state) => buildExampleTableDecorations(state.doc),
110
+ apply: (tr, value) =>
111
+ tr.docChanged ? buildExampleTableDecorations(tr.doc) : value,
112
+ },
113
+ props: {
114
+ decorations(state) {
115
+ return exampleTablePluginKey.getState(state);
116
+ },
117
+ },
118
+ });
119
+ }
120
+
121
+ /**
122
+ * BlockNote extension that grays the cells of the table following an
123
+ * `<!-- example -->` marker (bounded by the next test/suite comment or the end of
124
+ * the document).
125
+ *
126
+ * Editor extensions are supplied at editor-creation time and cannot be carried by
127
+ * the schema, so consumers must add this to their `useCreateBlockNote` call:
128
+ *
129
+ * ```ts
130
+ * useCreateBlockNote({
131
+ * schema: customSchema,
132
+ * extensions: [exampleTableHighlightExtension()],
133
+ * });
134
+ * ```
135
+ */
136
+ export class ExampleTableHighlightExtension extends BlockNoteExtension {
137
+ static key() {
138
+ return "exampleTableHighlight";
139
+ }
140
+
141
+ constructor() {
142
+ super();
143
+ this.addProsemirrorPlugin(exampleTablePlugin());
144
+ }
145
+ }
146
+
147
+ /** Factory for the `extensions` option of `useCreateBlockNote`. */
148
+ export const exampleTableHighlightExtension = () => new ExampleTableHighlightExtension();
@@ -811,6 +811,37 @@ html.dark .testomatio-editor .bn-auto-link {
811
811
  opacity: 0.5;
812
812
  }
813
813
 
814
+ /* Compact marker for `<!-- example -->` — a cropped variant of the testMeta bar
815
+ (fit-content width, not full editor width) labelling the table below it. */
816
+ .bn-example-marker {
817
+ display: inline-flex;
818
+ align-items: center;
819
+ width: fit-content;
820
+ box-sizing: border-box;
821
+ padding: 4px 12px;
822
+ background: var(--bg-muted);
823
+ border-top: 3px solid var(--color-slate-400);
824
+ margin-top: 1rem;
825
+ opacity: 0.6;
826
+ }
827
+
828
+ .bn-example-marker__label {
829
+ font-size: 11px;
830
+ font-weight: 600;
831
+ letter-spacing: 0.04em;
832
+ text-transform: uppercase;
833
+ color: var(--text-muted);
834
+ }
835
+
836
+ /* Gray cells for the data table that follows an `<!-- example -->` marker. The
837
+ `.bn-example-table` class is applied by a ProseMirror decoration (see
838
+ exampleTableHighlight.ts), bounded to tables between the marker and the next
839
+ test/suite comment (or end of document). */
840
+ .bn-editor [data-content-type="table"].bn-example-table td,
841
+ .bn-editor [data-content-type="table"].bn-example-table th {
842
+ background: var(--bg-muted);
843
+ }
844
+
814
845
  /* Header line: `TEST @T1233456 ............ [+]` — label, id, and add button
815
846
  always share one row. */
816
847
  .bn-testmeta__header {
package/src/index.ts CHANGED
@@ -30,6 +30,12 @@ export {
30
30
  type LinkMatch,
31
31
  } from "./editor/autoLink";
32
32
 
33
+ export {
34
+ exampleTableHighlightExtension,
35
+ ExampleTableHighlightExtension,
36
+ markExampleTables,
37
+ } from "./editor/exampleTableHighlight";
38
+
33
39
  export {
34
40
  blocksToMarkdown,
35
41
  markdownToBlocks,