From 8a9572b34504f88a942e2efdf62c94e20fe11da3 Mon Sep 17 00:00:00 2001 From: littlefrontender Date: Thu, 9 Jul 2026 14:00:46 +0400 Subject: [PATCH] feat: add example marker support and table highlighting in editor - Introduced an `exampleMarker` block to render `` comments as a UI label. - Implemented `markExampleTables` functionality to flag tables following an example marker. - Enhanced the `customMarkdownConverter` to parse and serialize example markers. - Added CSS styles for the example marker and highlighted tables. - Included unit tests for the new example marker and table highlighting features. --- src/App.tsx | 3 +- src/editor/blocks/exampleMarker.tsx | 24 ++++ src/editor/customMarkdownConverter.test.ts | 28 ++++ src/editor/customMarkdownConverter.ts | 28 ++++ src/editor/customSchema.tsx | 2 + src/editor/exampleTableHighlight.test.ts | 66 +++++++++ src/editor/exampleTableHighlight.ts | 148 +++++++++++++++++++++ src/editor/styles.css | 31 +++++ src/index.ts | 6 + 9 files changed, 335 insertions(+), 1 deletion(-) create mode 100644 src/editor/blocks/exampleMarker.tsx create mode 100644 src/editor/exampleTableHighlight.test.ts create mode 100644 src/editor/exampleTableHighlight.ts 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,