Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)}`;
Expand Down
24 changes: 24 additions & 0 deletions src/editor/blocks/exampleMarker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { createReactBlockSpec } from "@blocknote/react";

/**
* A read-only marker rendered from the `<!-- example -->` 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 `<!-- example -->` on serialize.
*/
export const exampleMarkerBlock = createReactBlockSpec(
{ type: "exampleMarker", content: "none", propSchema: {} },
{
render: () => (
<div
className="bn-example-marker"
contentEditable={false}
suppressContentEditableWarning
draggable={false}
>
<span className="bn-example-marker__label">examples</span>
</div>
),
},
);
28 changes: 28 additions & 0 deletions src/editor/customMarkdownConverter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3182,6 +3182,34 @@ describe("test/suite metadata comments", () => {
expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe(markdown);
});

it("parses an <!-- example --> marker into an exampleMarker block", () => {
const blocks = markdownToBlocks("<!-- example -->");
expect(blocks).toEqual([
{
type: "exampleMarker",
props: {},
children: [],
},
]);
});

it("parses the plural <!-- examples --> spelling too", () => {
const blocks = markdownToBlocks("<!-- examples -->");
expect(blocks).toEqual([
{
type: "exampleMarker",
props: {},
children: [],
},
]);
});

it("round-trips an <!-- example --> marker", () => {
const markdown = "<!-- example -->";
const blocks = markdownToBlocks(markdown);
expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe(markdown);
});

it("parses a multi-line suite block with ordered fields", () => {
const markdown = [
"<!-- suite",
Expand Down
28 changes: 28 additions & 0 deletions src/editor/customMarkdownConverter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,8 @@ function serializeBlock(
}
return lines;
}
case "exampleMarker":
return ["<!-- example -->"];
case "testMeta": {
const kind = (block.props as any).metaKind === "suite" ? "suite" : "test";
const inline = Boolean((block.props as any).metaInline);
Expand Down Expand Up @@ -1435,6 +1437,23 @@ function parseParagraph(lines: string[], index: number): { block: CustomPartialB

const META_COMMENT_OPEN_REGEX = /^<!--\s*(test|suite)(?=\s|-->|$)/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*examples?\s*-->\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.
Expand Down Expand Up @@ -1691,6 +1710,15 @@ export function markdownToBlocks(markdown: string, _options?: MarkdownToBlocksOp
continue;
}

// Caught before parseParagraph so the `<!-- example -->` 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;
Expand Down
2 changes: 2 additions & 0 deletions src/editor/customSchema.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -13,6 +14,7 @@ export const customSchema = BlockNoteSchema.create({
testStep: stepBlock,
snippet: snippetBlock,
testMeta: testMetaBlock,
exampleMarker: exampleMarkerBlock,
},
});

Expand Down
66 changes: 66 additions & 0 deletions src/editor/exampleTableHighlight.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
148 changes: 148 additions & 0 deletions src/editor/exampleTableHighlight.ts
Original file line number Diff line number Diff line change
@@ -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 `<!-- example -->`
* marker: it sits between that marker and the start of the next test — i.e. the next
* `testMeta` block (`<!-- test ... -->` / `<!-- suite ... -->`) — 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
* `<!-- suite ... -->` 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 `<!-- example -->` 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<DecorationSet>("testomatioExampleTable");

function exampleTablePlugin(): Plugin<DecorationSet> {
return new Plugin<DecorationSet>({
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
* `<!-- example -->` 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();
31 changes: 31 additions & 0 deletions src/editor/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -811,6 +811,37 @@ html.dark .testomatio-editor .bn-auto-link {
opacity: 0.5;
}

/* Compact marker for `<!-- example -->` — 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 `<!-- example -->` 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 {
Expand Down
Loading
Loading