testomatio-editor-blocks 0.4.71 → 0.4.73

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.
@@ -115,10 +115,13 @@ export const testMetaBlock = createReactBlockSpec({
115
115
  .filter(({ field }) => field.key.trim().length > 0 && field.value.trim().length > 0)
116
116
  .map(({ field }) => `${field.key}: ${field.value}`)
117
117
  .join(" · ");
118
- // Nothing readable to summarise (no fields, or only empty values) -> start
119
- // expanded so the block is immediately editable. `expanded` is UI-only
120
- // state never serialized.
121
- const [expanded, setExpanded] = useState(() => summaryText.length === 0);
118
+ // Default to the compact (collapsed) view whenever the block carries any
119
+ // field this keeps suite blocks compact too, even when their fields have
120
+ // no value yet (e.g. a seeded `emoji:` row) and so produce an empty
121
+ // summary. Only a genuinely empty block (no fields at all) starts expanded
122
+ // so it's immediately editable. `expanded` is UI-only state — never
123
+ // serialized.
124
+ const [expanded, setExpanded] = useState(() => fields.length === 0);
122
125
  return (_jsxs("div", { className: `bn-testmeta${expanded ? " bn-testmeta--expanded" : " bn-testmeta--collapsed"}`, "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 }), !expanded && (_jsx("button", { type: "button", className: "bn-testmeta__summary", title: summaryText || "No metadata yet", onClick: () => setExpanded(true), children: summaryText || _jsx("span", { className: "bn-testmeta__summary--empty", children: "No metadata" }) })), _jsxs("div", { className: "bn-testmeta__actions", children: [expanded && _jsx(AddFieldMenu, { kind: kind, usedKeys: usedKeys, onPick: handleAddField }), _jsx("button", { type: "button", className: `bn-testmeta__toggle${expanded ? " bn-testmeta__toggle--expanded" : ""}`, "aria-expanded": expanded, "aria-label": expanded ? "Collapse metadata" : "Expand metadata", title: expanded ? "Collapse" : "Expand", onClick: () => setExpanded((prev) => !prev), children: _jsx("svg", { width: "14", height: "14", viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true", children: _jsx("path", { d: "M4 6L8 10L12 6", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round" }) }) })] })] }), expanded && 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))) }))] }));
123
126
  },
124
127
  });
@@ -387,13 +387,21 @@ function serializeBlock(block, ctx, orderedIndex, stepIndex) {
387
387
  }
388
388
  if (stepData.length > 0) {
389
389
  const dataLines = stepData.split(/\r?\n/);
390
+ let insideCodeFence = false;
390
391
  dataLines.forEach((dataLine) => {
391
392
  const trimmedLine = dataLine.trim();
392
- if (trimmedLine.length > 0) {
393
- lines.push(` ${escapeStepContent(trimmedLine)}`);
394
- }
395
- else {
393
+ if (trimmedLine.length === 0) {
396
394
  lines.push(" ");
395
+ return;
396
+ }
397
+ // Don't escape dots inside fenced code blocks (or on the fence lines
398
+ // themselves) — Markdown ignores backslash escapes there, so `\.`
399
+ // would render literally.
400
+ const isFence = trimmedLine.startsWith("```");
401
+ const content = insideCodeFence || isFence ? trimmedLine : escapeStepContent(trimmedLine);
402
+ lines.push(` ${content}`);
403
+ if (isFence) {
404
+ insideCodeFence = !insideCodeFence;
397
405
  }
398
406
  });
399
407
  }
@@ -401,16 +409,23 @@ function serializeBlock(block, ctx, orderedIndex, stepIndex) {
401
409
  if (normalizedExpected.length > 0) {
402
410
  const expectedLines = normalizedExpected.split(/\r?\n/);
403
411
  const label = "*Expected result*";
412
+ let insideCodeFence = false;
404
413
  expectedLines.forEach((expectedLine, index) => {
405
414
  const trimmedLine = expectedLine.trim();
406
415
  if (trimmedLine.length === 0) {
407
416
  return;
408
417
  }
418
+ // As with step data, leave dots untouched inside fenced code blocks.
419
+ const isFence = trimmedLine.startsWith("```");
420
+ const content = insideCodeFence || isFence ? trimmedLine : escapeStepContent(trimmedLine);
409
421
  if (index === 0) {
410
- lines.push(` ${label}: ${escapeStepContent(trimmedLine)}`);
422
+ lines.push(` ${label}: ${content}`);
411
423
  }
412
424
  else {
413
- lines.push(` ${escapeStepContent(trimmedLine)}`);
425
+ lines.push(` ${content}`);
426
+ }
427
+ if (isFence) {
428
+ insideCodeFence = !insideCodeFence;
414
429
  }
415
430
  });
416
431
  }
@@ -0,0 +1,35 @@
1
+ import { BlockNoteExtension } from "@blocknote/core";
2
+ /** Matches a tag (capture group 1) preceded by start-of-string or whitespace. */
3
+ export declare const TAGS_DETECT_REGEXP: RegExp;
4
+ export interface TagMatch {
5
+ /** Offset of the `@` within the scanned string (the leading space is excluded). */
6
+ start: number;
7
+ /** Offset just past the last character of the tag. */
8
+ end: number;
9
+ /** The matched tag text, including the leading `@`. */
10
+ tag: string;
11
+ }
12
+ /**
13
+ * Find all `@tag` tokens inside a plain string and return their offsets.
14
+ * Pure and DOM-free so it can be unit-tested directly.
15
+ */
16
+ export declare function detectTags(text: string): TagMatch[];
17
+ /**
18
+ * BlockNote extension that renders `@tags` inside headings as badges.
19
+ *
20
+ * Editor extensions are supplied at editor-creation time and cannot be carried
21
+ * by the schema, so consumers must add this to their `useCreateBlockNote` call:
22
+ *
23
+ * ```ts
24
+ * useCreateBlockNote({
25
+ * schema: customSchema,
26
+ * extensions: [tagBadgeExtension()],
27
+ * });
28
+ * ```
29
+ */
30
+ export declare class TagBadgeExtension extends BlockNoteExtension {
31
+ static key(): string;
32
+ constructor();
33
+ }
34
+ /** Factory for the `extensions` option of `useCreateBlockNote`. */
35
+ export declare const tagBadgeExtension: () => TagBadgeExtension;
@@ -0,0 +1,114 @@
1
+ import { BlockNoteExtension } from "@blocknote/core";
2
+ import { Plugin, PluginKey } from "prosemirror-state";
3
+ import { Decoration, DecorationSet } from "prosemirror-view";
4
+ /**
5
+ * Tags are tokens that start with `@` and may contain alphanumerics plus a few
6
+ * symbols (`= - _ ( ) . : &`), e.g. `@smoke`, `@severity:high`, `@T1234abcd`.
7
+ *
8
+ * This is a faithful JS port of the backend (Ruby) tag regexes:
9
+ * TAG_PREFIX_REGEXP = /(?:^|[ \t])/
10
+ * TAG_ALLOWED_SYMBOLS_REGEXP = /[\w\d\=\-\_\(\)\.\:\&]*[\w\d\)]/
11
+ * TAGS_DETECT_REGEXP = /(?:^|[ \t])(\@<allowed>)/
12
+ *
13
+ * `\w` already covers `[0-9_]`, so the symbol class is kept minimal while
14
+ * staying faithful to the allowed characters. A tag must be preceded by the
15
+ * start of the string or whitespace — so email-like text (`user@example.com`)
16
+ * is correctly ignored.
17
+ */
18
+ const TAG_ALLOWED_SYMBOLS = String.raw `[\w=\-_().:&]*[\w)]`;
19
+ /** Matches a tag (capture group 1) preceded by start-of-string or whitespace. */
20
+ export const TAGS_DETECT_REGEXP = new RegExp(String.raw `(?:^|[ \t])(@${TAG_ALLOWED_SYMBOLS})`, "g");
21
+ /**
22
+ * Find all `@tag` tokens inside a plain string and return their offsets.
23
+ * Pure and DOM-free so it can be unit-tested directly.
24
+ */
25
+ export function detectTags(text) {
26
+ const matches = [];
27
+ // Use a fresh regex each call so the shared `lastIndex` state never leaks
28
+ // between invocations.
29
+ const regexp = new RegExp(TAGS_DETECT_REGEXP.source, "g");
30
+ let match;
31
+ while ((match = regexp.exec(text)) !== null) {
32
+ const tag = match[1];
33
+ // The match may include a leading space/tab consumed by the prefix; the tag
34
+ // itself starts that many characters into the overall match.
35
+ const start = match.index + (match[0].length - tag.length);
36
+ matches.push({ start, end: start + tag.length, tag });
37
+ // Defensive guard against a zero-length match looping forever (a tag always
38
+ // starts with `@`, so this should never trigger).
39
+ if (regexp.lastIndex === match.index) {
40
+ regexp.lastIndex++;
41
+ }
42
+ }
43
+ return matches;
44
+ }
45
+ const tagBadgePluginKey = new PluginKey("testomatioTagBadge");
46
+ /**
47
+ * Build inline decorations for every `@tag` found inside heading blocks. Tags
48
+ * are only *painted* — the underlying text is untouched, so markdown
49
+ * serialization round-trips unchanged.
50
+ */
51
+ function buildHeadingTagDecorations(doc) {
52
+ const decorations = [];
53
+ doc.descendants((node, pos) => {
54
+ if (node.type.name !== "heading") {
55
+ return undefined;
56
+ }
57
+ // Walk the heading's inline children so positions stay correct even when it
58
+ // contains links or inline images alongside text.
59
+ node.forEach((child, offset) => {
60
+ if (!child.isText || !child.text) {
61
+ return;
62
+ }
63
+ // `pos + 1` steps inside the heading content node; `offset` is the child's
64
+ // position relative to the node's content.
65
+ const base = pos + 1 + offset;
66
+ for (const { start, end } of detectTags(child.text)) {
67
+ decorations.push(Decoration.inline(base + start, base + end, {
68
+ class: "bn-tag-badge",
69
+ }));
70
+ }
71
+ });
72
+ // Headings only hold inline content; no need to descend further.
73
+ return false;
74
+ });
75
+ return DecorationSet.create(doc, decorations);
76
+ }
77
+ function tagBadgePlugin() {
78
+ return new Plugin({
79
+ key: tagBadgePluginKey,
80
+ state: {
81
+ init: (_config, state) => buildHeadingTagDecorations(state.doc),
82
+ apply: (tr, value) => tr.docChanged ? buildHeadingTagDecorations(tr.doc) : value,
83
+ },
84
+ props: {
85
+ decorations(state) {
86
+ return tagBadgePluginKey.getState(state);
87
+ },
88
+ },
89
+ });
90
+ }
91
+ /**
92
+ * BlockNote extension that renders `@tags` inside headings as badges.
93
+ *
94
+ * Editor extensions are supplied at editor-creation time and cannot be carried
95
+ * by the schema, so consumers must add this to their `useCreateBlockNote` call:
96
+ *
97
+ * ```ts
98
+ * useCreateBlockNote({
99
+ * schema: customSchema,
100
+ * extensions: [tagBadgeExtension()],
101
+ * });
102
+ * ```
103
+ */
104
+ export class TagBadgeExtension extends BlockNoteExtension {
105
+ static key() {
106
+ return "tagBadge";
107
+ }
108
+ constructor() {
109
+ super();
110
+ this.addProsemirrorPlugin(tagBadgePlugin());
111
+ }
112
+ }
113
+ /** Factory for the `extensions` option of `useCreateBlockNote`. */
114
+ export const tagBadgeExtension = () => new TagBadgeExtension();
@@ -4,6 +4,7 @@ export { snippetBlock } from "./editor/blocks/snippet";
4
4
  export { testMetaBlock } from "./editor/blocks/testMeta";
5
5
  export { setMetaFieldSuggestions, getMetaFieldSuggestions, type MetaFieldSuggestion, type MetaFieldSuggestionsConfig, } from "./editor/testMetaFields";
6
6
  export { markdownToHtml, htmlToMarkdown } from "./editor/blocks/markdown";
7
+ export { tagBadgeExtension, TagBadgeExtension, TAGS_DETECT_REGEXP, detectTags, type TagMatch, } from "./editor/tagBadge";
7
8
  export { blocksToMarkdown, markdownToBlocks, type CustomEditorBlock, type CustomPartialBlock, type MarkdownToBlocksOptions, } from "./editor/customMarkdownConverter";
8
9
  export { useStepAutocomplete, parseStepsFromJsonApi, setStepsFetcher, type StepSuggestion, type StepJsonApiDocument, type StepJsonApiResource, } from "./editor/stepAutocomplete";
9
10
  export { useStepImageUpload, setImageUploadHandler, type StepImageUploadHandler, } from "./editor/stepImageUpload";
package/package/index.js CHANGED
@@ -4,6 +4,7 @@ export { snippetBlock } from "./editor/blocks/snippet";
4
4
  export { testMetaBlock } from "./editor/blocks/testMeta";
5
5
  export { setMetaFieldSuggestions, getMetaFieldSuggestions, } from "./editor/testMetaFields";
6
6
  export { markdownToHtml, htmlToMarkdown } from "./editor/blocks/markdown";
7
+ export { tagBadgeExtension, TagBadgeExtension, TAGS_DETECT_REGEXP, detectTags, } from "./editor/tagBadge";
7
8
  export { blocksToMarkdown, markdownToBlocks, } from "./editor/customMarkdownConverter";
8
9
  export { useStepAutocomplete, parseStepsFromJsonApi, setStepsFetcher, } from "./editor/stepAutocomplete";
9
10
  export { useStepImageUpload, setImageUploadHandler, } from "./editor/stepImageUpload";
@@ -723,6 +723,30 @@ html.dark .bn-teststep__view-toggle--compact svg {
723
723
  --prev-level: 18px;
724
724
  }
725
725
 
726
+ /* ============================================
727
+ TAG BADGES IN HEADINGS
728
+ `@tag` tokens inside headings are painted as compact badges by a ProseMirror
729
+ inline decoration (see src/editor/tagBadge.ts). The text stays editable; only
730
+ the styling is applied.
731
+ ============================================ */
732
+ .testomatio-editor [data-content-type="heading"] .bn-tag-badge {
733
+ font-size: 0.78em;
734
+ font-weight: 500;
735
+ line-height: 1.2;
736
+ background: var(--bg-muted);
737
+ color: var(--text-muted);
738
+ border: 1px solid var(--border-light);
739
+ border-radius: 6px;
740
+ padding: 0.05em 0.4em;
741
+ vertical-align: middle;
742
+ white-space: nowrap;
743
+ }
744
+ html.dark .testomatio-editor [data-content-type="heading"] .bn-tag-badge {
745
+ background: rgba(255, 255, 255, 0.08);
746
+ color: rgba(255, 255, 255, 0.6);
747
+ border-color: rgba(255, 255, 255, 0.12);
748
+ }
749
+
726
750
  /* ============================================
727
751
  TEST / SUITE METADATA BLOCK
728
752
  Dimmed card for `<!-- test ... -->` / `<!-- suite ... -->` comments.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testomatio-editor-blocks",
3
- "version": "0.4.71",
3
+ "version": "0.4.73",
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
@@ -20,6 +20,7 @@ import {
20
20
  } from "./editor/customMarkdownConverter";
21
21
  import { createMarkdownPasteHandler } from "./editor/createMarkdownPasteHandler";
22
22
  import { customSchema, type CustomEditor } from "./editor/customSchema";
23
+ import { tagBadgeExtension } from "./editor/tagBadge";
23
24
  import { setStepsFetcher, type StepJsonApiDocument } from "./editor/stepAutocomplete";
24
25
  import { setSnippetFetcher, type SnippetJsonApiDocument } from "./editor/snippetAutocomplete";
25
26
  import { setImageUploadHandler } from "./editor/stepImageUpload";
@@ -404,6 +405,7 @@ function CustomSlashMenu() {
404
405
  function App() {
405
406
  const editor = useCreateBlockNote({
406
407
  schema: customSchema,
408
+ extensions: [tagBadgeExtension()],
407
409
  pasteHandler: createMarkdownPasteHandler(markdownToBlocks),
408
410
  uploadFile: async (file: File) => {
409
411
  const url = `https://placehold.co/600x400?text=${encodeURIComponent(file.name)}`;
@@ -196,10 +196,13 @@ export const testMetaBlock = createReactBlockSpec(
196
196
  .filter(({ field }) => field.key.trim().length > 0 && field.value.trim().length > 0)
197
197
  .map(({ field }) => `${field.key}: ${field.value}`)
198
198
  .join(" · ");
199
- // Nothing readable to summarise (no fields, or only empty values) -> start
200
- // expanded so the block is immediately editable. `expanded` is UI-only
201
- // state never serialized.
202
- const [expanded, setExpanded] = useState(() => summaryText.length === 0);
199
+ // Default to the compact (collapsed) view whenever the block carries any
200
+ // field this keeps suite blocks compact too, even when their fields have
201
+ // no value yet (e.g. a seeded `emoji:` row) and so produce an empty
202
+ // summary. Only a genuinely empty block (no fields at all) starts expanded
203
+ // so it's immediately editable. `expanded` is UI-only state — never
204
+ // serialized.
205
+ const [expanded, setExpanded] = useState(() => fields.length === 0);
203
206
 
204
207
  return (
205
208
  <div
@@ -1249,6 +1249,53 @@ describe("markdownToBlocks", () => {
1249
1249
  );
1250
1250
  });
1251
1251
 
1252
+ it("does not escape dots inside fenced code blocks in step data", () => {
1253
+ const stepData = [
1254
+ "```",
1255
+ "curl 'https://stable.testomat.io/api/runs/54?page=1&entry=' \\",
1256
+ " -H 'accept-language: en-US,en;q=0.9' \\",
1257
+ " -b 'gclau=1.1.1681594017.1781077572;'",
1258
+ "```",
1259
+ ].join("\n");
1260
+
1261
+ const markdown = blocksToMarkdown([
1262
+ {
1263
+ id: "step1",
1264
+ type: "testStep",
1265
+ props: {
1266
+ stepTitle: "Run the request.",
1267
+ stepData,
1268
+ expectedResult: "",
1269
+ listStyle: "bullet",
1270
+ },
1271
+ content: undefined,
1272
+ children: [],
1273
+ },
1274
+ ]);
1275
+
1276
+ expect(markdown).toBe(
1277
+ [
1278
+ // Title outside the fence is still escaped.
1279
+ "* Run the request\\.",
1280
+ // Dots inside the fence stay literal.
1281
+ " ```",
1282
+ " curl 'https://stable.testomat.io/api/runs/54?page=1&entry=' \\",
1283
+ " -H 'accept-language: en-US,en;q=0.9' \\",
1284
+ " -b 'gclau=1.1.1681594017.1781077572;'",
1285
+ " ```",
1286
+ ].join("\n"),
1287
+ );
1288
+
1289
+ // Round-trip stays stable.
1290
+ const roundTrip = blocksToMarkdown(
1291
+ markdownToBlocks(["### Steps", "", markdown].join("\n")) as CustomEditorBlock[],
1292
+ );
1293
+ expect(roundTrip).toContain(
1294
+ " curl 'https://stable.testomat.io/api/runs/54?page=1&entry=' \\",
1295
+ );
1296
+ expect(roundTrip).toContain(" -b 'gclau=1.1.1681594017.1781077572;'");
1297
+ });
1298
+
1252
1299
  it("does not include content after a blank line in step data", () => {
1253
1300
  const markdown = [
1254
1301
  "### Steps",
@@ -469,12 +469,22 @@ function serializeBlock(
469
469
 
470
470
  if (stepData.length > 0) {
471
471
  const dataLines = stepData.split(/\r?\n/);
472
+ let insideCodeFence = false;
472
473
  dataLines.forEach((dataLine: string) => {
473
474
  const trimmedLine = dataLine.trim();
474
- if (trimmedLine.length > 0) {
475
- lines.push(` ${escapeStepContent(trimmedLine)}`);
476
- } else {
475
+ if (trimmedLine.length === 0) {
477
476
  lines.push(" ");
477
+ return;
478
+ }
479
+ // Don't escape dots inside fenced code blocks (or on the fence lines
480
+ // themselves) — Markdown ignores backslash escapes there, so `\.`
481
+ // would render literally.
482
+ const isFence = trimmedLine.startsWith("```");
483
+ const content =
484
+ insideCodeFence || isFence ? trimmedLine : escapeStepContent(trimmedLine);
485
+ lines.push(` ${content}`);
486
+ if (isFence) {
487
+ insideCodeFence = !insideCodeFence;
478
488
  }
479
489
  });
480
490
  }
@@ -483,16 +493,24 @@ function serializeBlock(
483
493
  if (normalizedExpected.length > 0) {
484
494
  const expectedLines = normalizedExpected.split(/\r?\n/);
485
495
  const label = "*Expected result*";
496
+ let insideCodeFence = false;
486
497
  expectedLines.forEach((expectedLine: string, index: number) => {
487
498
  const trimmedLine = expectedLine.trim();
488
499
  if (trimmedLine.length === 0) {
489
500
  return;
490
501
  }
491
502
 
503
+ // As with step data, leave dots untouched inside fenced code blocks.
504
+ const isFence = trimmedLine.startsWith("```");
505
+ const content =
506
+ insideCodeFence || isFence ? trimmedLine : escapeStepContent(trimmedLine);
492
507
  if (index === 0) {
493
- lines.push(` ${label}: ${escapeStepContent(trimmedLine)}`);
508
+ lines.push(` ${label}: ${content}`);
494
509
  } else {
495
- lines.push(` ${escapeStepContent(trimmedLine)}`);
510
+ lines.push(` ${content}`);
511
+ }
512
+ if (isFence) {
513
+ insideCodeFence = !insideCodeFence;
496
514
  }
497
515
  });
498
516
  }
@@ -723,6 +723,30 @@ html.dark .bn-teststep__view-toggle--compact svg {
723
723
  --prev-level: 18px;
724
724
  }
725
725
 
726
+ /* ============================================
727
+ TAG BADGES IN HEADINGS
728
+ `@tag` tokens inside headings are painted as compact badges by a ProseMirror
729
+ inline decoration (see src/editor/tagBadge.ts). The text stays editable; only
730
+ the styling is applied.
731
+ ============================================ */
732
+ .testomatio-editor [data-content-type="heading"] .bn-tag-badge {
733
+ font-size: 0.78em;
734
+ font-weight: 500;
735
+ line-height: 1.2;
736
+ background: var(--bg-muted);
737
+ color: var(--text-muted);
738
+ border: 1px solid var(--border-light);
739
+ border-radius: 6px;
740
+ padding: 0.05em 0.4em;
741
+ vertical-align: middle;
742
+ white-space: nowrap;
743
+ }
744
+ html.dark .testomatio-editor [data-content-type="heading"] .bn-tag-badge {
745
+ background: rgba(255, 255, 255, 0.08);
746
+ color: rgba(255, 255, 255, 0.6);
747
+ border-color: rgba(255, 255, 255, 0.12);
748
+ }
749
+
726
750
  /* ============================================
727
751
  TEST / SUITE METADATA BLOCK
728
752
  Dimmed card for `<!-- test ... -->` / `<!-- suite ... -->` comments.
@@ -0,0 +1,75 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { detectTags } from "./tagBadge";
3
+
4
+ describe("detectTags", () => {
5
+ it("detects a single tag at the start of the string", () => {
6
+ expect(detectTags("@smoke")).toEqual([{ start: 0, end: 6, tag: "@smoke" }]);
7
+ });
8
+
9
+ it("detects a tag that follows a space", () => {
10
+ // "Login flow @smoke" — the @ is at index 11.
11
+ expect(detectTags("Login flow @smoke")).toEqual([
12
+ { start: 11, end: 17, tag: "@smoke" },
13
+ ]);
14
+ });
15
+
16
+ it("detects multiple tags in one title", () => {
17
+ const matches = detectTags("Login flow @smoke @regression");
18
+ expect(matches).toEqual([
19
+ { start: 11, end: 17, tag: "@smoke" },
20
+ { start: 18, end: 29, tag: "@regression" },
21
+ ]);
22
+ });
23
+
24
+ it("detects tags that contain allowed symbols", () => {
25
+ expect(detectTags("@severity:high")).toEqual([
26
+ { start: 0, end: 14, tag: "@severity:high" },
27
+ ]);
28
+ expect(detectTags("@T1234abcd")).toEqual([
29
+ { start: 0, end: 10, tag: "@T1234abcd" },
30
+ ]);
31
+ expect(detectTags("@a-b_c")).toEqual([{ start: 0, end: 6, tag: "@a-b_c" }]);
32
+ expect(detectTags("@group(1)")).toEqual([
33
+ { start: 0, end: 9, tag: "@group(1)" },
34
+ ]);
35
+ expect(detectTags("@key=value")).toEqual([
36
+ { start: 0, end: 10, tag: "@key=value" },
37
+ ]);
38
+ });
39
+
40
+ it("detects a tag after a tab character", () => {
41
+ expect(detectTags("title\t@flaky")).toEqual([
42
+ { start: 6, end: 12, tag: "@flaky" },
43
+ ]);
44
+ });
45
+
46
+ it("does not match an email address", () => {
47
+ // The `@` is preceded by a word character, not start/whitespace.
48
+ expect(detectTags("Contact user@example.com")).toEqual([]);
49
+ });
50
+
51
+ it("does not match a bare @ with no body", () => {
52
+ expect(detectTags("just an @ symbol")).toEqual([]);
53
+ });
54
+
55
+ it("does not match @ in the middle of a word", () => {
56
+ expect(detectTags("foo@bar")).toEqual([]);
57
+ });
58
+
59
+ it("returns an empty array for text without tags", () => {
60
+ expect(detectTags("A plain heading title")).toEqual([]);
61
+ });
62
+
63
+ it("ignores a trailing dot that is not a valid tag terminator", () => {
64
+ // The tag must end with a word char or `)`, so the trailing `.` is excluded.
65
+ expect(detectTags("end of @smoke.")).toEqual([
66
+ { start: 7, end: 13, tag: "@smoke" },
67
+ ]);
68
+ });
69
+
70
+ it("does not keep stale regex state across calls", () => {
71
+ // Guards against a shared lastIndex leaking between invocations.
72
+ expect(detectTags("@one")).toEqual([{ start: 0, end: 4, tag: "@one" }]);
73
+ expect(detectTags("@two")).toEqual([{ start: 0, end: 4, tag: "@two" }]);
74
+ });
75
+ });
@@ -0,0 +1,143 @@
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
+ * Tags are tokens that start with `@` and may contain alphanumerics plus a few
8
+ * symbols (`= - _ ( ) . : &`), e.g. `@smoke`, `@severity:high`, `@T1234abcd`.
9
+ *
10
+ * This is a faithful JS port of the backend (Ruby) tag regexes:
11
+ * TAG_PREFIX_REGEXP = /(?:^|[ \t])/
12
+ * TAG_ALLOWED_SYMBOLS_REGEXP = /[\w\d\=\-\_\(\)\.\:\&]*[\w\d\)]/
13
+ * TAGS_DETECT_REGEXP = /(?:^|[ \t])(\@<allowed>)/
14
+ *
15
+ * `\w` already covers `[0-9_]`, so the symbol class is kept minimal while
16
+ * staying faithful to the allowed characters. A tag must be preceded by the
17
+ * start of the string or whitespace — so email-like text (`user@example.com`)
18
+ * is correctly ignored.
19
+ */
20
+ const TAG_ALLOWED_SYMBOLS = String.raw`[\w=\-_().:&]*[\w)]`;
21
+
22
+ /** Matches a tag (capture group 1) preceded by start-of-string or whitespace. */
23
+ export const TAGS_DETECT_REGEXP = new RegExp(
24
+ String.raw`(?:^|[ \t])(@${TAG_ALLOWED_SYMBOLS})`,
25
+ "g",
26
+ );
27
+
28
+ export interface TagMatch {
29
+ /** Offset of the `@` within the scanned string (the leading space is excluded). */
30
+ start: number;
31
+ /** Offset just past the last character of the tag. */
32
+ end: number;
33
+ /** The matched tag text, including the leading `@`. */
34
+ tag: string;
35
+ }
36
+
37
+ /**
38
+ * Find all `@tag` tokens inside a plain string and return their offsets.
39
+ * Pure and DOM-free so it can be unit-tested directly.
40
+ */
41
+ export function detectTags(text: string): TagMatch[] {
42
+ const matches: TagMatch[] = [];
43
+ // Use a fresh regex each call so the shared `lastIndex` state never leaks
44
+ // between invocations.
45
+ const regexp = new RegExp(TAGS_DETECT_REGEXP.source, "g");
46
+ let match: RegExpExecArray | null;
47
+ while ((match = regexp.exec(text)) !== null) {
48
+ const tag = match[1];
49
+ // The match may include a leading space/tab consumed by the prefix; the tag
50
+ // itself starts that many characters into the overall match.
51
+ const start = match.index + (match[0].length - tag.length);
52
+ matches.push({ start, end: start + tag.length, tag });
53
+ // Defensive guard against a zero-length match looping forever (a tag always
54
+ // starts with `@`, so this should never trigger).
55
+ if (regexp.lastIndex === match.index) {
56
+ regexp.lastIndex++;
57
+ }
58
+ }
59
+ return matches;
60
+ }
61
+
62
+ const tagBadgePluginKey = new PluginKey<DecorationSet>("testomatioTagBadge");
63
+
64
+ /**
65
+ * Build inline decorations for every `@tag` found inside heading blocks. Tags
66
+ * are only *painted* — the underlying text is untouched, so markdown
67
+ * serialization round-trips unchanged.
68
+ */
69
+ function buildHeadingTagDecorations(doc: PMNode): DecorationSet {
70
+ const decorations: Decoration[] = [];
71
+
72
+ doc.descendants((node, pos) => {
73
+ if (node.type.name !== "heading") {
74
+ return undefined;
75
+ }
76
+
77
+ // Walk the heading's inline children so positions stay correct even when it
78
+ // contains links or inline images alongside text.
79
+ node.forEach((child, offset) => {
80
+ if (!child.isText || !child.text) {
81
+ return;
82
+ }
83
+ // `pos + 1` steps inside the heading content node; `offset` is the child's
84
+ // position relative to the node's content.
85
+ const base = pos + 1 + offset;
86
+ for (const { start, end } of detectTags(child.text)) {
87
+ decorations.push(
88
+ Decoration.inline(base + start, base + end, {
89
+ class: "bn-tag-badge",
90
+ }),
91
+ );
92
+ }
93
+ });
94
+
95
+ // Headings only hold inline content; no need to descend further.
96
+ return false;
97
+ });
98
+
99
+ return DecorationSet.create(doc, decorations);
100
+ }
101
+
102
+ function tagBadgePlugin(): Plugin<DecorationSet> {
103
+ return new Plugin<DecorationSet>({
104
+ key: tagBadgePluginKey,
105
+ state: {
106
+ init: (_config, state) => buildHeadingTagDecorations(state.doc),
107
+ apply: (tr, value) =>
108
+ tr.docChanged ? buildHeadingTagDecorations(tr.doc) : value,
109
+ },
110
+ props: {
111
+ decorations(state) {
112
+ return tagBadgePluginKey.getState(state);
113
+ },
114
+ },
115
+ });
116
+ }
117
+
118
+ /**
119
+ * BlockNote extension that renders `@tags` inside headings as badges.
120
+ *
121
+ * Editor extensions are supplied at editor-creation time and cannot be carried
122
+ * by the schema, so consumers must add this to their `useCreateBlockNote` call:
123
+ *
124
+ * ```ts
125
+ * useCreateBlockNote({
126
+ * schema: customSchema,
127
+ * extensions: [tagBadgeExtension()],
128
+ * });
129
+ * ```
130
+ */
131
+ export class TagBadgeExtension extends BlockNoteExtension {
132
+ static key() {
133
+ return "tagBadge";
134
+ }
135
+
136
+ constructor() {
137
+ super();
138
+ this.addProsemirrorPlugin(tagBadgePlugin());
139
+ }
140
+ }
141
+
142
+ /** Factory for the `extensions` option of `useCreateBlockNote`. */
143
+ export const tagBadgeExtension = () => new TagBadgeExtension();
package/src/index.ts CHANGED
@@ -15,6 +15,14 @@ export {
15
15
  } from "./editor/testMetaFields";
16
16
  export { markdownToHtml, htmlToMarkdown } from "./editor/blocks/markdown";
17
17
 
18
+ export {
19
+ tagBadgeExtension,
20
+ TagBadgeExtension,
21
+ TAGS_DETECT_REGEXP,
22
+ detectTags,
23
+ type TagMatch,
24
+ } from "./editor/tagBadge";
25
+
18
26
  export {
19
27
  blocksToMarkdown,
20
28
  markdownToBlocks,