diff --git a/src/App.tsx b/src/App.tsx
index c7a10db..f6fc25e 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -22,6 +22,7 @@ import { createMarkdownPasteHandler } from "./editor/createMarkdownPasteHandler"
import { customSchema, type CustomEditor } from "./editor/customSchema";
import { tagBadgeExtension } from "./editor/tagBadge";
import { autoLinkExtension } from "./editor/autoLink";
+import { exampleTableHighlightExtension } from "./editor/exampleTableHighlight";
import { setStepsFetcher, type StepJsonApiDocument } from "./editor/stepAutocomplete";
import { setSnippetFetcher, type SnippetJsonApiDocument } from "./editor/snippetAutocomplete";
import {
@@ -500,7 +501,7 @@ function CustomSlashMenu() {
function App() {
const editor = useCreateBlockNote({
schema: customSchema,
- extensions: [tagBadgeExtension(), autoLinkExtension()],
+ extensions: [tagBadgeExtension(), autoLinkExtension(), exampleTableHighlightExtension()],
pasteHandler: createMarkdownPasteHandler(markdownToBlocks),
uploadFile: async (file: File) => {
const url = `https://placehold.co/600x400?text=${encodeURIComponent(file.name)}`;
diff --git a/src/editor/blocks/exampleMarker.tsx b/src/editor/blocks/exampleMarker.tsx
new file mode 100644
index 0000000..d39824a
--- /dev/null
+++ b/src/editor/blocks/exampleMarker.tsx
@@ -0,0 +1,24 @@
+import { createReactBlockSpec } from "@blocknote/react";
+
+/**
+ * A read-only marker rendered from the `` HTML comment that
+ * precedes a data/examples table in a testomat.io test file. It shows a compact
+ * "examples" panel (a cropped variant of the SUITE/TEST metadata bar) so the
+ * marker reads as a UI label instead of raw comment text. It has no fields and
+ * round-trips back to `` on serialize.
+ */
+export const exampleMarkerBlock = createReactBlockSpec(
+ { type: "exampleMarker", content: "none", propSchema: {} },
+ {
+ render: () => (
+
+ examples
+
+ ),
+ },
+);
diff --git a/src/editor/customMarkdownConverter.test.ts b/src/editor/customMarkdownConverter.test.ts
index efdffd7..ba1a02c 100644
--- a/src/editor/customMarkdownConverter.test.ts
+++ b/src/editor/customMarkdownConverter.test.ts
@@ -3182,6 +3182,34 @@ describe("test/suite metadata comments", () => {
expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe(markdown);
});
+ it("parses an marker into an exampleMarker block", () => {
+ const blocks = markdownToBlocks("");
+ expect(blocks).toEqual([
+ {
+ type: "exampleMarker",
+ props: {},
+ children: [],
+ },
+ ]);
+ });
+
+ it("parses the plural spelling too", () => {
+ const blocks = markdownToBlocks("");
+ expect(blocks).toEqual([
+ {
+ type: "exampleMarker",
+ props: {},
+ children: [],
+ },
+ ]);
+ });
+
+ it("round-trips an marker", () => {
+ const markdown = "";
+ const blocks = markdownToBlocks(markdown);
+ expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe(markdown);
+ });
+
it("parses a multi-line suite block with ordered fields", () => {
const markdown = [
""];
case "testMeta": {
const kind = (block.props as any).metaKind === "suite" ? "suite" : "test";
const inline = Boolean((block.props as any).metaInline);
@@ -1435,6 +1437,23 @@ function parseParagraph(lines: string[], index: number): { block: CustomPartialB
const META_COMMENT_OPEN_REGEX = /^|$)/i;
+// Marks the data/examples table that follows in a testomat.io test file. Accepts
+// both the singular `example` and plural `examples` spellings.
+const EXAMPLE_MARKER_REGEX = /^\s*$/i;
+
+function parseExampleMarker(
+ lines: string[],
+ index: number,
+): { block: CustomPartialBlock; nextIndex: number } | null {
+ if (!EXAMPLE_MARKER_REGEX.test(lines[index].trim())) {
+ return null;
+ }
+ return {
+ block: { type: "exampleMarker", props: {}, children: [] } as CustomPartialBlock,
+ nextIndex: index + 1,
+ };
+}
+
// Keys whose value is a YAML-style list (`key:` followed by indented `- item`
// lines) in the testomat.io comment format — e.g. `issues` holding URLs. These
// 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
continue;
}
+ // Caught before parseParagraph so the `` marker renders as
+ // an "examples" panel instead of raw comment text.
+ const exampleMarker = parseExampleMarker(lines, index);
+ if (exampleMarker) {
+ blocks.push(exampleMarker.block);
+ index = exampleMarker.nextIndex;
+ continue;
+ }
+
const snippetWrapper = stepsHeadingLevel !== null
? parseSnippetWrapper(lines, index)
: null;
diff --git a/src/editor/customSchema.tsx b/src/editor/customSchema.tsx
index c5f52ea..62178cc 100644
--- a/src/editor/customSchema.tsx
+++ b/src/editor/customSchema.tsx
@@ -3,6 +3,7 @@ import { BlockNoteSchema } from "@blocknote/core";
import { stepBlock } from "./blocks/step";
import { snippetBlock } from "./blocks/snippet";
import { testMetaBlock } from "./blocks/testMeta";
+import { exampleMarkerBlock } from "./blocks/exampleMarker";
import { fileBlock } from "./blocks/fileBlock";
import { htmlToMarkdown, markdownToHtml } from "./blocks/markdown";
@@ -13,6 +14,7 @@ export const customSchema = BlockNoteSchema.create({
testStep: stepBlock,
snippet: snippetBlock,
testMeta: testMetaBlock,
+ exampleMarker: exampleMarkerBlock,
},
});
diff --git a/src/editor/exampleTableHighlight.test.ts b/src/editor/exampleTableHighlight.test.ts
new file mode 100644
index 0000000..35cd607
--- /dev/null
+++ b/src/editor/exampleTableHighlight.test.ts
@@ -0,0 +1,66 @@
+import { describe, expect, it } from "vitest";
+import { isSuiteDocument, markExampleTables } from "./exampleTableHighlight";
+
+describe("isSuiteDocument", () => {
+ it("is true only when the first block is a suite testMeta", () => {
+ expect(isSuiteDocument("testMeta", "suite")).toBe(true);
+ });
+
+ it("is false for a document opening with a test (not a suite)", () => {
+ expect(isSuiteDocument("testMeta", "test")).toBe(false);
+ });
+
+ it("is false when the first block is not a testMeta", () => {
+ expect(isSuiteDocument("heading", undefined)).toBe(false);
+ expect(isSuiteDocument("paragraph", "suite")).toBe(false);
+ });
+
+ it("is false for an empty document", () => {
+ expect(isSuiteDocument(undefined, undefined)).toBe(false);
+ });
+});
+
+describe("markExampleTables", () => {
+ it("flags a table that follows an example marker", () => {
+ expect(markExampleTables(["exampleMarker", "paragraph", "table"])).toEqual([
+ false,
+ false,
+ true,
+ ]);
+ });
+
+ it("stops the region at the next test/suite comment", () => {
+ // First table is in the example region; the testMeta closes it, so the
+ // second table (belonging to the next test) is not flagged.
+ expect(
+ markExampleTables(["exampleMarker", "table", "testMeta", "table"]),
+ ).toEqual([false, true, false, false]);
+ });
+
+ it("does not flag tables without a preceding example marker", () => {
+ expect(markExampleTables(["table"])).toEqual([false]);
+ expect(markExampleTables(["testMeta", "table"])).toEqual([false, false]);
+ });
+
+ it("keeps the region open across headings and other blocks", () => {
+ expect(
+ markExampleTables(["exampleMarker", "heading", "paragraph", "table"]),
+ ).toEqual([false, false, false, true]);
+ });
+
+ it("flags every table in the region until the next test", () => {
+ expect(
+ markExampleTables(["exampleMarker", "table", "table", "testMeta", "table"]),
+ ).toEqual([false, true, true, false, false]);
+ });
+
+ it("reopens the region when a new example marker appears", () => {
+ expect(
+ markExampleTables(["exampleMarker", "table", "testMeta", "exampleMarker", "table"]),
+ ).toEqual([false, true, false, false, true]);
+ });
+
+ it("returns an empty array for no blocks", () => {
+ expect(markExampleTables([])).toEqual([]);
+ });
+});
diff --git a/src/editor/exampleTableHighlight.ts b/src/editor/exampleTableHighlight.ts
new file mode 100644
index 0000000..889f2f3
--- /dev/null
+++ b/src/editor/exampleTableHighlight.ts
@@ -0,0 +1,148 @@
+import { BlockNoteExtension } from "@blocknote/core";
+import type { Node as PMNode } from "prosemirror-model";
+import { Plugin, PluginKey } from "prosemirror-state";
+import { Decoration, DecorationSet } from "prosemirror-view";
+
+/**
+ * An "example table" is a data table that belongs to a testomat.io ``
+ * marker: it sits between that marker and the start of the next test — i.e. the next
+ * `testMeta` block (`` / ``) — or the end of the
+ * document. Such tables are painted with a gray cell background so they read as the
+ * marker's data table.
+ *
+ * Given the block content-type names in document order, returns a boolean[] where
+ * entry `i` is `true` iff a `"table"` at index `i` falls inside an example region.
+ * `exampleMarker` opens a region; `testMeta` closes it; everything else (headings,
+ * paragraphs, …) leaves the region unchanged.
+ *
+ * Pure and DOM-free so it can be unit-tested directly.
+ */
+export function markExampleTables(types: readonly string[]): boolean[] {
+ const flags: boolean[] = [];
+ let active = false;
+ for (const name of types) {
+ if (name === "exampleMarker") {
+ active = true;
+ flags.push(false);
+ } else if (name === "testMeta") {
+ active = false;
+ flags.push(false);
+ } else {
+ flags.push(name === "table" && active);
+ }
+ }
+ return flags;
+}
+
+/**
+ * Example tables only occur in a *suite* document — one that opens with a
+ * `` comment (a `testMeta` block whose `metaKind` is `"suite"`).
+ * Gating on the first block lets the plugin skip the whole scan for any other
+ * document (a lone test, plain notes, …) instead of walking it on every change.
+ *
+ * Pure so it can be unit-tested directly.
+ */
+export function isSuiteDocument(
+ firstBlockType: string | undefined,
+ firstBlockMetaKind: string | undefined,
+): boolean {
+ return firstBlockType === "testMeta" && firstBlockMetaKind === "suite";
+}
+
+/**
+ * Build node decorations that add `.bn-example-table` to every table in an example
+ * region. Tables are only *painted* — the document is untouched, so markdown
+ * serialization round-trips unchanged.
+ *
+ * The `` marker, the metadata comment, and the table each become a
+ * top-level block whose content node is named by its block type (`exampleMarker`,
+ * `testMeta`, `table`), so a single document-order walk yields them directly.
+ *
+ * Nothing is highlighted unless the document is a suite (see `isSuiteDocument`).
+ */
+function buildExampleTableDecorations(doc: PMNode): DecorationSet {
+ const entries: { pos: number; size: number; name: string }[] = [];
+ let firstBlockType: string | undefined;
+ let firstBlockMetaKind: string | undefined;
+
+ doc.descendants((node, pos, parent) => {
+ // The first content node encountered under a blockContainer is the document's
+ // first block — capture its type/metaKind for the suite gate below.
+ if (firstBlockType === undefined && parent?.type.name === "blockContainer") {
+ firstBlockType = node.type.name;
+ const metaKind = node.attrs?.metaKind;
+ firstBlockMetaKind = typeof metaKind === "string" ? metaKind : undefined;
+ }
+
+ const name = node.type.name;
+ if (name === "exampleMarker" || name === "testMeta" || name === "table") {
+ entries.push({ pos, size: node.nodeSize, name });
+ // No need to descend into table cells or the marker's (empty) internals.
+ return false;
+ }
+ // Descend through the blockGroup / blockContainer wrappers to reach content.
+ return undefined;
+ });
+
+ if (!isSuiteDocument(firstBlockType, firstBlockMetaKind)) {
+ return DecorationSet.empty;
+ }
+
+ const flags = markExampleTables(entries.map((entry) => entry.name));
+ const decorations = entries
+ .filter((_, index) => flags[index])
+ .map((entry) =>
+ Decoration.node(entry.pos, entry.pos + entry.size, {
+ class: "bn-example-table",
+ }),
+ );
+
+ return DecorationSet.create(doc, decorations);
+}
+
+const exampleTablePluginKey = new PluginKey("testomatioExampleTable");
+
+function exampleTablePlugin(): Plugin {
+ return new Plugin({
+ key: exampleTablePluginKey,
+ state: {
+ init: (_config, state) => buildExampleTableDecorations(state.doc),
+ apply: (tr, value) =>
+ tr.docChanged ? buildExampleTableDecorations(tr.doc) : value,
+ },
+ props: {
+ decorations(state) {
+ return exampleTablePluginKey.getState(state);
+ },
+ },
+ });
+}
+
+/**
+ * BlockNote extension that grays the cells of the table following an
+ * `` marker (bounded by the next test/suite comment or the end of
+ * the document).
+ *
+ * Editor extensions are supplied at editor-creation time and cannot be carried by
+ * the schema, so consumers must add this to their `useCreateBlockNote` call:
+ *
+ * ```ts
+ * useCreateBlockNote({
+ * schema: customSchema,
+ * extensions: [exampleTableHighlightExtension()],
+ * });
+ * ```
+ */
+export class ExampleTableHighlightExtension extends BlockNoteExtension {
+ static key() {
+ return "exampleTableHighlight";
+ }
+
+ constructor() {
+ super();
+ this.addProsemirrorPlugin(exampleTablePlugin());
+ }
+}
+
+/** Factory for the `extensions` option of `useCreateBlockNote`. */
+export const exampleTableHighlightExtension = () => new ExampleTableHighlightExtension();
diff --git a/src/editor/styles.css b/src/editor/styles.css
index dde66a3..48b5d66 100644
--- a/src/editor/styles.css
+++ b/src/editor/styles.css
@@ -811,6 +811,37 @@ html.dark .testomatio-editor .bn-auto-link {
opacity: 0.5;
}
+/* Compact marker for `` — a cropped variant of the testMeta bar
+ (fit-content width, not full editor width) labelling the table below it. */
+.bn-example-marker {
+ display: inline-flex;
+ align-items: center;
+ width: fit-content;
+ box-sizing: border-box;
+ padding: 4px 12px;
+ background: var(--bg-muted);
+ border-top: 3px solid var(--color-slate-400);
+ margin-top: 1rem;
+ opacity: 0.6;
+}
+
+.bn-example-marker__label {
+ font-size: 11px;
+ font-weight: 600;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: var(--text-muted);
+}
+
+/* Gray cells for the data table that follows an `` marker. The
+ `.bn-example-table` class is applied by a ProseMirror decoration (see
+ exampleTableHighlight.ts), bounded to tables between the marker and the next
+ test/suite comment (or end of document). */
+.bn-editor [data-content-type="table"].bn-example-table td,
+.bn-editor [data-content-type="table"].bn-example-table th {
+ background: var(--bg-muted);
+}
+
/* Header line: `TEST @T1233456 ............ [+]` — label, id, and add button
always share one row. */
.bn-testmeta__header {
diff --git a/src/index.ts b/src/index.ts
index 2a00d69..e00e244 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -30,6 +30,12 @@ export {
type LinkMatch,
} from "./editor/autoLink";
+export {
+ exampleTableHighlightExtension,
+ ExampleTableHighlightExtension,
+ markExampleTables,
+} from "./editor/exampleTableHighlight";
+
export {
blocksToMarkdown,
markdownToBlocks,