fix(editor): mark-boundary-safe inline scan shared by highlights + cycle

A {{.Var}} (or <tag>) split across text nodes by a mark — e.g. a user
styles one brace — was missed by the per-text-node scan: the cycle then
inserted a duplicate while the panel still counted it used, and the
highlight silently dropped on the fragment.

- new collectInlineMatches (editor-inline-scan.ts) scans each textblock's
  concatenated inline text and maps offsets back to doc positions, so a
  split token reunites in one block string.
- VariableHighlight, TagHighlight and findVariableOccurrences all use it.
- share one variableUseRegex + VARIABLE_RE between the editor and
  settings-prompt (countVariableUses + the plain-mode cycle), replacing the
  hand-synced duplicate regexes.
- test: a brace-styled {{.Foo}} is now found (was 0 before).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Sergey Kozyrenko
2026-06-30 12:17:33 +07:00
co-authored by Claude Opus 4.8
parent 55f105ffa1
commit 69f86ae566
5 changed files with 110 additions and 81 deletions
@@ -0,0 +1,50 @@
import type { Node as PMNode } from '@tiptap/pm/model';
export interface InlineMatch {
from: number;
text: string;
to: number;
}
// Find `regex` matches in each textblock's inline text, mapped to document positions. Unlike a per-text-node
// scan, this reunites a token split across text nodes by a mark (e.g. a user styles one brace of `{{.Var}}`,
// so ProseMirror splits it) — the brace and the rest sit in one block string again. ProseMirror positions are
// one per UTF-16 unit and mark-independent, so offset `i` in the block string maps to `blockStart + i`; a
// token never spans a non-text inline node (image/hard-break), so `to = from + length` holds. `regex` MUST be
// global (`/g`). Scanning per textblock — NOT over `doc.textContent` — keeps positions aligned across blocks.
export const collectInlineMatches = (doc: PMNode, regex: RegExp): InlineMatch[] => {
const matches: InlineMatch[] = [];
doc.descendants((node, pos) => {
if (!node.isTextblock) {
return; // a container — recurse into it to reach its textblocks
}
let text = '';
const positions: number[] = [];
let inlinePos = pos + 1;
node.forEach((child) => {
if (child.isText && child.text) {
for (let i = 0; i < child.text.length; i += 1) {
text += child.text[i];
positions.push(inlinePos + i);
}
}
inlinePos += child.nodeSize;
});
for (const match of text.matchAll(regex)) {
const from = positions[match.index ?? 0];
if (from !== undefined) {
matches.push({ from, text: match[0], to: from + match[0].length });
}
}
return false; // inline content handled — don't recurse into the text nodes
});
return matches;
};
@@ -4,6 +4,8 @@ import { Extension } from '@tiptap/core';
import { Plugin, PluginKey } from '@tiptap/pm/state';
import { Decoration, DecorationSet } from '@tiptap/pm/view';
import { collectInlineMatches } from './editor-inline-scan';
// Highlights xml-like tags (<container_environment>, </language_policy>, <specialist name="x">)
// as VIEW-ONLY decorations — same byte-neutral approach as editor-variable-highlight. A node would
// have to tell a structural tag from a tag NAME quoted inline as documentation (the prompts do both,
@@ -14,23 +16,11 @@ const tagHighlightKey = new PluginKey('tagHighlight');
// `[^<>]` for the attribute span keeps the scan linear and stops at the next angle bracket.
export const TAG_RE = /<\/?[a-zA-Z][\w-]*(?:\s[^<>]*)?\/?>/g;
const buildDecorations = (doc: PMNode): DecorationSet => {
const decorations: Decoration[] = [];
doc.descendants((node, pos) => {
if (!node.isText || !node.text) {
return;
}
for (const match of node.text.matchAll(TAG_RE)) {
const from = pos + (match.index ?? 0);
decorations.push(Decoration.inline(from, from + match[0].length, { class: 'template-tag' }));
}
});
return DecorationSet.create(doc, decorations);
};
const buildDecorations = (doc: PMNode): DecorationSet =>
DecorationSet.create(
doc,
collectInlineMatches(doc, TAG_RE).map(({ from, to }) => Decoration.inline(from, to, { class: 'template-tag' })),
);
export const TagHighlight = Extension.create({
addProseMirrorPlugins() {
@@ -4,55 +4,32 @@ import { Extension } from '@tiptap/core';
import { Plugin, PluginKey } from '@tiptap/pm/state';
import { Decoration, DecorationSet } from '@tiptap/pm/view';
// Highlights Go-template actions ({{.Var}}, {{- if .X}}, {{end}}, {{.A | upper}}, …) as
// VIEW-ONLY decorations. Decorations never touch the document, so getMarkdown() stays
// byte-identical — and {{ }} already round-trips verbatim (prosemirror-markdown esc()
// escapes only ` * \ ~ [ ] _, tiptap-markdown escapeHTML only < >; neither touches { }).
// A node/mark would gain nothing here and would break the variables side-panel, which
// inserts {{.X}} as plain text and cycles uses by regex over the serialized string.
import { collectInlineMatches } from './editor-inline-scan';
// Highlights Go-template actions ({{.Var}}, {{- if .X}}, {{end}}, {{.A | upper}}, …) as VIEW-ONLY
// decorations. Decorations never touch the document, so getMarkdown() stays byte-identical, and {{ }}
// already round-trips verbatim. A node/mark would gain nothing here and would break the variables
// side-panel, which inserts {{.X}} as plain text and finds its uses by scanning, not by node identity.
const variableHighlightKey = new PluginKey('variableHighlight');
// `[^{}]` keeps the scan linear (no catastrophic backtracking); Go actions never nest braces.
export const VARIABLE_RE = /\{\{[^{}]*\}\}/g;
// Scan per text node — NOT over doc.textContent — or inline positions misalign across blocks.
const buildDecorations = (doc: PMNode): DecorationSet => {
const decorations: Decoration[] = [];
// Matches one variable's `{{ … .Name … }}` use. Shared with settings-prompt.tsx (panel cycle + count) so the
// panel's "used" badge and the editor cycle agree on what counts as a use.
export const variableUseRegex = (variable: string): RegExp =>
new RegExp(`\\{\\{[^{}]*?\\.${variable}\\b[^{}]*?\\}\\}`, 'g');
doc.descendants((node, pos) => {
if (!node.isText || !node.text) {
return;
}
const buildDecorations = (doc: PMNode): DecorationSet =>
DecorationSet.create(
doc,
collectInlineMatches(doc, VARIABLE_RE).map(({ from, to }) =>
Decoration.inline(from, to, { class: 'template-variable' }),
),
);
for (const match of node.text.matchAll(VARIABLE_RE)) {
const from = pos + (match.index ?? 0);
decorations.push(Decoration.inline(from, from + match[0].length, { class: 'template-variable' }));
}
});
return DecorationSet.create(doc, decorations);
};
// Regex mirrors settings-prompt.tsx countVariableUses so the panel's "used" badge and this cycle agree.
export const findVariableOccurrences = (doc: PMNode, variable: string): { from: number; to: number }[] => {
const pattern = new RegExp(`\\{\\{[^{}]*?\\.${variable}\\b[^{}]*?\\}\\}`, 'g');
const occurrences: { from: number; to: number }[] = [];
doc.descendants((node, pos) => {
if (!node.isText || !node.text) {
return;
}
for (const match of node.text.matchAll(pattern)) {
const from = pos + (match.index ?? 0);
occurrences.push({ from, to: from + match[0].length });
}
});
return occurrences;
};
export const findVariableOccurrences = (doc: PMNode, variable: string): { from: number; to: number }[] =>
collectInlineMatches(doc, variableUseRegex(variable)).map(({ from, to }) => ({ from, to }));
export const VariableHighlight = Extension.create({
addProseMirrorPlugins() {
@@ -158,4 +158,19 @@ describe('findVariableOccurrences — doc spans for the Available-variables cycl
expect(findVariableOccurrences(doc, 'Enabled')).toHaveLength(1);
});
it('finds a {{.Var}} split across text nodes by a mark (mark-boundary fix)', () => {
const doc = docOf('**{{**.Foo}} tail');
// sanity — the variable really is split: bolding a brace gives the textblock >1 inline child
expect(doc.firstChild?.childCount ?? 0).toBeGreaterThan(1);
const foo = findVariableOccurrences(doc, 'Foo');
expect(foo).toHaveLength(1);
for (const hit of foo) {
expect(doc.textBetween(hit.from, hit.to)).toBe('{{.Foo}}');
}
});
});
+19 -22
View File
@@ -52,6 +52,7 @@ import {
AppHeaderTitle,
} from '@/components/layouts/app/app-header';
import ConfirmationDialog from '@/components/shared/confirmation-dialog';
import { VARIABLE_RE, variableUseRegex } from '@/components/shared/editor-variable-highlight';
import { UnsavedChangesDialog, useUnsavedChangesGuard } from '@/components/shared/unsaved-changes';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
@@ -122,6 +123,23 @@ type HumanFormData = z.infer<typeof humanFormSchema>;
type SystemFormData = z.infer<typeof systemFormSchema>;
// One pass over the `{{ … }}` blocks — the naive per-variable `.match` over the whole template is
// O(variables × length) on every keystroke.
function countVariableUses(template: string, variables: string[]): Record<string, number> {
const probes = variables.map((variable) => [variable, new RegExp(`\\.${variable}\\b`)] as const);
const counts: Record<string, number> = {};
for (const block of template.match(VARIABLE_RE) ?? []) {
for (const [variable, probe] of probes) {
if (probe.test(block)) {
counts[variable] = (counts[variable] ?? 0) + 1;
}
}
}
return counts;
}
function FormCodeItem<T extends FieldValues>({
control,
disabled,
@@ -192,27 +210,6 @@ function FormTextareaItem<T extends FieldValues>({
);
}
// .<variable> used in any {{ }} form (bare, if/range/with, nested) — intentionally broader than bare {{.X}}.
// `[^{}]` (not `[^}]`) keeps a stray unclosed `{{` from driving quadratic backtracking across the whole template.
const variableActionRegex = (variable: string): RegExp => new RegExp(`\\{\\{[^{}]*?\\.${variable}\\b[^{}]*?\\}\\}`);
// One pass over the `{{ … }}` blocks — the naive per-variable `.match` over the whole template is
// O(variables × length) on every keystroke.
function countVariableUses(template: string, variables: string[]): Record<string, number> {
const probes = variables.map((variable) => [variable, new RegExp(`\\.${variable}\\b`)] as const);
const counts: Record<string, number> = {};
for (const block of template.match(/\{\{[^{}]*\}\}/g) ?? []) {
for (const [variable, probe] of probes) {
if (probe.test(block)) {
counts[variable] = (counts[variable] ?? 0) + 1;
}
}
}
return counts;
}
// Pixel offset of `position` from the textarea content top, measured via a hidden mirror so
// soft-wrapped lines count — a logical-line count undershoots the scroll badly for wrapped templates.
const caretOffsetTop = (textarea: HTMLTextAreaElement, position: number): number => {
@@ -320,7 +317,7 @@ function SettingsPrompt() {
if (textarea) {
const currentValue = field.value || '';
const variablePattern = `{{.${variable}}}`;
const matches = [...currentValue.matchAll(new RegExp(variableActionRegex(variable).source, 'g'))];
const matches = [...currentValue.matchAll(variableUseRegex(variable))];
if (matches.length > 0) {
const { selectionEnd, selectionStart } = textarea;