feat(manager): surface-conditional @ autocomplete + warning highlights

- Add `surface: SurfaceKind` prop (default "wildcard") to RichTextInput +
  RichTextPreview. When surface !== "wildcard", @ autocomplete is disabled;
  manually-typed @{uuid} tokens still tokenise/render but with muted styling
  via the new `.wp-rt-ref--ignored` CSS class.
- Add `warnings: ResolveWarning[]` prop to both components. Each warning
  renders a `.wp-rt-warn-marker` span with `data-warning-position` + severity
  CSS class (`wp-rt-warn-info` / `wp-rt-warn-warn` / `wp-rt-warn-error`).
- Add `uuidToName: Map<string, string>` prop for UUID→display-name lookup.
  Mirror layer renders `@{uuid}` refs as `@name` when the map resolves them.
- Autocomplete insert for @ trigger now writes `@{uuid}` raw text (not @name).
- Mirror HTML builder inlined in component (no longer delegates to the shared
  mirrorHtmlWithIdx so we can branch on surface + apply per-token overrides).
- CSS: new warn-marker rules + wp-rt-ref--ignored muted chip added to
  rich-text.css (shared by RichTextInput and RichTextPreview).

Tests:
- Create src/manager/__tests__/components/RichTextInput.warnings.test.ts
  with 20 new tests covering warning markers, severity→class mapping, surface
  autocomplete gating, UUID name display, and RichTextPreview parity.
- Un-skip "paints chip markup" test in RichTextInput.test.ts, updated to use
  @{8hex} UUID form (old @name form no longer tokenised as ref).
- Mark "renders # comment lines greyed out" permanently skipped (OBSOLETE) —
  comment token kind was removed in the locked grammar; test can never pass.
- Wildcards.test.ts skip comment updated with DEFERRED rationale (Wildcards.vue
  syntaxView() uses slug keys while buildWildcardGraph uses UUID keys — needs
  its own follow-up PR to align).
- Delete 3 dead skipped tests from richTokenize.test.ts that tested removed
  grammar features (@name short form, quantifier N#$var, # comment lines);
  those token kinds no longer exist in the tokenizer or the TokenKind union.
  Rationale left as inline comments where the tests were.
This commit is contained in:
DumiFlex
2026-04-28 07:31:30 +03:00
parent c6795e7b5e
commit 7f6f5b7192
7 changed files with 537 additions and 50 deletions
+10 -8
View File
@@ -38,11 +38,10 @@ describe("RichTextInput.vue", () => {
expect(wrap.find("textarea").attributes("rows")).toBe("6");
});
// TODO(syntax-task-19): re-enable after RichTextInput is updated for @{8hex} UUID refs
// The test uses the legacy @name short form which is no longer tokenised as ref.
it.skip("paints chip markup in the mirror layer for $var/@ref/{a|b|c}", () => {
it("paints chip markup in the mirror layer for $var/@{uuid}/{a|b|c}", () => {
// Uses @{8hex} UUID form — the locked grammar. Legacy @name falls to text.
const wrap = mount(RichTextInput, {
props: { modelValue: "$person sees @colors of {red|blue}" },
props: { modelValue: "$person sees @{1a2b3c4d} of {red|blue}" },
});
const mirror = wrap.find(".wp-rt__mirror");
expect(mirror.exists()).toBe(true);
@@ -50,7 +49,9 @@ describe("RichTextInput.vue", () => {
expect(html).toContain("wp-rt-var");
expect(html).toContain("wp-rt-ref");
expect(html).toContain("wp-rt-dp-brace");
expect(html).toContain("wp-rt-dp-pipe");
// dp-pipe is no longer emitted as a separate token; dp-brace covers the whole block.
// Confirm the brace block is present via dp-brace class:
expect(html).toContain("wp-rt-dp-brace");
});
it("renders $$ escapes as escape spans (not vars)", () => {
@@ -63,9 +64,10 @@ describe("RichTextInput.vue", () => {
expect(html).not.toMatch(/wp-rt-var[^]*\$\$/);
});
// TODO(syntax-task-19): re-enable after comment syntax is removed from RichTextInput styling
// The comment token kind is no longer emitted; # lines are plain text in the new grammar.
it.skip("renders # comment lines greyed out", () => {
// OBSOLETE (task-19 confirmed): Comment token kind was removed in the locked grammar.
// `# lines` are now plain text. This test can never pass with the current tokenizer.
// Keeping skipped rather than deleted so the intent is visible in git history.
it.skip("renders # comment lines greyed out [OBSOLETE — comment syntax removed]", () => {
const wrap = mount(RichTextInput, {
props: { modelValue: "# noted", multiline: true },
});
+4 -3
View File
@@ -74,9 +74,10 @@ describe("Wildcards.vue", () => {
expect(wrap.text()).toContain("No wildcards yet");
});
// TODO(syntax-task-19): re-enable after wildcardSyntax.ts + Wildcards.vue UUID-graph rewrite
// The test uses legacy @name short form refs (e.g. "@hat") which are no longer tokenised
// as ref tokens. The syntax-pill logic depends on ref detection via wildcardSyntax.ts.
// DEFERRED (task-19): The test uses legacy @name short form refs (e.g. "@hat") which are no
// longer tokenised as ref tokens. Additionally Wildcards.vue syntaxView() uses wildcardVarName
// (string slug) as the graph key, while buildWildcardGraph keys by UUID (mod.id). A follow-up
// task must align Wildcards.vue to the UUID graph and update the fixture data to use @{8hex}.
it.skip("renders syntax indicator pills for refs / inline / referenced-by", async () => {
// Two wildcards: `outfit` references `@hat` (outgoing) and contains an
// inline `{a|b}` choice; `hat` is referenced by `outfit` (incoming).
@@ -0,0 +1,322 @@
import { mount, flushPromises } from "@vue/test-utils";
import { describe, expect, it } from "vitest";
import RichTextInput from "../../components/RichTextInput.vue";
import RichTextPreview from "../../components/RichTextPreview.vue";
import type { ResolveWarning } from "../../utils/resolveTokens";
const tick = flushPromises;
// Helper to build a minimal ResolveWarning.
function warn(
position: number,
severity: "info" | "warn" | "error",
message = "test warning",
): ResolveWarning {
return {
type: "test",
severity,
module_id: "00000000",
source_field: "value",
position,
token_index: null,
detail: {},
message,
};
}
// ---------------------------------------------------------------------------
// Warning marker rendering
// ---------------------------------------------------------------------------
describe("RichTextInput — warning markers", () => {
it("renders a marker span for each warning with data-warning-position", () => {
const wrap = mount(RichTextInput, {
props: {
modelValue: "hello $name",
warnings: [warn(6, "warn"), warn(0, "info")],
},
});
const markers = wrap.findAll(".wp-rt-warn-marker");
expect(markers).toHaveLength(2);
const positions = markers.map((m) => m.attributes("data-warning-position"));
expect(positions).toContain("6");
expect(positions).toContain("0");
});
it("applies severity → CSS class: info → wp-rt-warn-info", () => {
const wrap = mount(RichTextInput, {
props: {
modelValue: "x",
warnings: [warn(0, "info")],
},
});
const marker = wrap.find(".wp-rt-warn-marker");
expect(marker.classes()).toContain("wp-rt-warn-info");
expect(marker.classes()).not.toContain("wp-rt-warn-warn");
expect(marker.classes()).not.toContain("wp-rt-warn-error");
});
it("applies severity → CSS class: warn → wp-rt-warn-warn", () => {
const wrap = mount(RichTextInput, {
props: {
modelValue: "x",
warnings: [warn(0, "warn")],
},
});
const marker = wrap.find(".wp-rt-warn-marker");
expect(marker.classes()).toContain("wp-rt-warn-warn");
});
it("applies severity → CSS class: error → wp-rt-warn-error", () => {
const wrap = mount(RichTextInput, {
props: {
modelValue: "x",
warnings: [warn(0, "error")],
},
});
const marker = wrap.find(".wp-rt-warn-marker");
expect(marker.classes()).toContain("wp-rt-warn-error");
});
it("renders no warning markers when warnings prop is empty", () => {
const wrap = mount(RichTextInput, {
props: { modelValue: "x", warnings: [] },
});
expect(wrap.findAll(".wp-rt-warn-marker")).toHaveLength(0);
});
it("warning marker title matches the warning message", () => {
const wrap = mount(RichTextInput, {
props: {
modelValue: "x",
warnings: [warn(0, "error", "ref not found")],
},
});
const marker = wrap.find(".wp-rt-warn-marker");
expect(marker.attributes("title")).toBe("ref not found");
});
});
// ---------------------------------------------------------------------------
// Surface-conditional autocomplete
// ---------------------------------------------------------------------------
function popoverExists(): boolean {
return document.querySelector(".wp-rt-suggestions") !== null;
}
describe("RichTextInput — surface prop", () => {
it("defaults surface to wildcard — @ autocomplete enabled", async () => {
const wrap = mount(RichTextInput, {
props: {
modelValue: "",
refSuggestions: ["1a2b3c4d"],
},
attachTo: document.body,
});
const input = wrap.find("input");
const el = input.element as HTMLInputElement;
el.value = "@1";
el.setSelectionRange(2, 2);
await input.trigger("input");
await tick();
expect(popoverExists()).toBe(true);
wrap.unmount();
});
it("surface=wildcard: @ autocomplete shows suggestions", async () => {
const wrap = mount(RichTextInput, {
props: {
modelValue: "",
surface: "wildcard" as const,
refSuggestions: ["aabbccdd", "11223344"],
},
attachTo: document.body,
});
const input = wrap.find("input");
const el = input.element as HTMLInputElement;
el.value = "@aa";
el.setSelectionRange(3, 3);
await input.trigger("input");
await tick();
expect(popoverExists()).toBe(true);
wrap.unmount();
});
it("surface=combine: @ autocomplete is disabled", async () => {
const wrap = mount(RichTextInput, {
props: {
modelValue: "",
surface: "combine" as const,
refSuggestions: ["aabbccdd"],
},
attachTo: document.body,
});
const input = wrap.find("input");
const el = input.element as HTMLInputElement;
el.value = "@aa";
el.setSelectionRange(3, 3);
await input.trigger("input");
await tick();
expect(popoverExists()).toBe(false);
wrap.unmount();
});
it("surface=derivation: @ autocomplete is disabled", async () => {
const wrap = mount(RichTextInput, {
props: {
modelValue: "",
surface: "derivation" as const,
refSuggestions: ["aabbccdd"],
},
attachTo: document.body,
});
const input = wrap.find("input");
const el = input.element as HTMLInputElement;
el.value = "@aa";
el.setSelectionRange(3, 3);
await input.trigger("input");
await tick();
expect(popoverExists()).toBe(false);
wrap.unmount();
});
it("surface=assembler: @ autocomplete is disabled", async () => {
const wrap = mount(RichTextInput, {
props: {
modelValue: "",
surface: "assembler" as const,
refSuggestions: ["aabbccdd"],
},
attachTo: document.body,
});
const input = wrap.find("input");
const el = input.element as HTMLInputElement;
el.value = "@aa";
el.setSelectionRange(3, 3);
await input.trigger("input");
await tick();
expect(popoverExists()).toBe(false);
wrap.unmount();
});
it("non-wildcard surface: $ autocomplete still works", async () => {
const wrap = mount(RichTextInput, {
props: {
modelValue: "",
surface: "combine" as const,
varSuggestions: ["person"],
},
attachTo: document.body,
});
const input = wrap.find("input");
const el = input.element as HTMLInputElement;
el.value = "$pe";
el.setSelectionRange(3, 3);
await input.trigger("input");
await tick();
expect(popoverExists()).toBe(true);
wrap.unmount();
});
it("non-wildcard surface: @{uuid} tokens in mirror get wp-rt-ref--ignored class", () => {
const wrap = mount(RichTextInput, {
props: {
modelValue: "@{1a2b3c4d} text",
surface: "combine" as const,
},
});
const mirror = wrap.find(".wp-rt__mirror");
expect(mirror.html()).toContain("wp-rt-ref--ignored");
});
it("wildcard surface: @{uuid} tokens in mirror do NOT get wp-rt-ref--ignored class", () => {
const wrap = mount(RichTextInput, {
props: {
modelValue: "@{1a2b3c4d} text",
surface: "wildcard" as const,
},
});
const mirror = wrap.find(".wp-rt__mirror");
expect(mirror.html()).not.toContain("wp-rt-ref--ignored");
});
});
// ---------------------------------------------------------------------------
// UUID → name display in mirror
// ---------------------------------------------------------------------------
describe("RichTextInput — UUID name display", () => {
it("renders @name display form when uuidToName provides a match", () => {
const map = new Map([["1a2b3c4d", "hat"]]);
const wrap = mount(RichTextInput, {
props: {
modelValue: "wear @{1a2b3c4d}",
uuidToName: map,
},
});
const mirror = wrap.find(".wp-rt__mirror");
expect(mirror.html()).toContain("@hat");
});
it("falls back to raw @{uuid} when uuid not in map", () => {
const wrap = mount(RichTextInput, {
props: {
modelValue: "wear @{1a2b3c4d}",
uuidToName: new Map(),
},
});
const mirror = wrap.find(".wp-rt__mirror");
expect(mirror.html()).toContain("@{1a2b3c4d}");
});
});
// ---------------------------------------------------------------------------
// RichTextPreview — surface + warnings
// ---------------------------------------------------------------------------
describe("RichTextPreview — surface + warnings", () => {
it("renders warning markers with data-warning-position", () => {
const wrap = mount(RichTextPreview, {
props: {
value: "hello",
warnings: [warn(0, "warn"), warn(3, "error")],
},
});
const markers = wrap.findAll(".wp-rt-warn-marker");
expect(markers).toHaveLength(2);
const positions = markers.map((m) => m.attributes("data-warning-position"));
expect(positions).toContain("0");
expect(positions).toContain("3");
});
it("non-wildcard surface: ref tokens get wp-rt-ref--ignored class", () => {
const wrap = mount(RichTextPreview, {
props: {
value: "@{1a2b3c4d}",
surface: "combine" as const,
},
});
expect(wrap.html()).toContain("wp-rt-ref--ignored");
});
it("wildcard surface: ref tokens do not get wp-rt-ref--ignored class", () => {
const wrap = mount(RichTextPreview, {
props: {
value: "@{1a2b3c4d}",
surface: "wildcard" as const,
},
});
expect(wrap.html()).not.toContain("wp-rt-ref--ignored");
});
it("renders display name from uuidToName map", () => {
const wrap = mount(RichTextPreview, {
props: {
value: "@{deadbeef}",
uuidToName: new Map([["deadbeef", "outfit"]]),
},
});
expect(wrap.html()).toContain("@outfit");
});
});
+9 -29
View File
@@ -22,15 +22,9 @@ describe("tokenizeRich", () => {
expect(v?.end).toBe(9);
});
// TODO(syntax-task-19): re-enable after wildcardSyntax.ts UUID-graph rewrite
// The old @name short form is no longer recognised as ref; it falls through to text.
it.skip("recognises @ref tokens (legacy @name short form)", () => {
const tokens = tokenizeRich("see @colors");
const r = tokens.find((t) => t.kind === "ref");
expect(r).toBeDefined();
expect(r?.raw).toBe("@colors");
expect(r?.meta?.name).toBe("colors");
});
// DELETED (task-19): "recognises @ref tokens (legacy @name short form)" — the @name
// short form was removed from the grammar in the locked tokenizer (Tasks 3-7). The
// test was already skipped with a TODO; deleting it here rather than leaving dead code.
it("recognises @{8hex} UUID ref tokens", () => {
const tokens = tokenizeRich("see @{1a2b3c4d}");
@@ -94,27 +88,13 @@ describe("tokenizeRich", () => {
expect(m.meta?.branches).toEqual(["a", "b"]);
});
// TODO(syntax-task-19): re-enable after quantifier is re-evaluated for the new grammar
// The quantifier prefix N#$var is no longer recognised; it becomes plain text.
it.skip("recognises a quantifier prefix N#$var (legacy)", () => {
const tokens = tokenizeRich("5#$person");
// Cast to string so TypeScript does not reject the removed kind literal.
const q = tokens.find((t) => (t.kind as string) === "quantifier");
const v = tokens.find((t) => t.kind === "var");
expect(q).toBeDefined();
expect(q?.raw).toBe("5#");
expect((q?.meta as Record<string, unknown>)?.count).toBe(5);
expect(v?.raw).toBe("$person");
});
// DELETED (task-19): "recognises a quantifier prefix N#$var (legacy)" — the quantifier
// N#$var syntax was removed in the locked grammar (Tasks 3-5). The kind "quantifier" no
// longer exists in TokenKind. Deleting rather than keeping dead skipped test.
// TODO(syntax-task-19): re-enable after comment syntax is re-evaluated for the new grammar
// The # comment line syntax is no longer recognised.
it.skip("recognises # comment lines (legacy)", () => {
const tokens = tokenizeRich("# this is a comment\nrest");
// Cast to string so TypeScript does not reject the removed kind literal.
expect(tokens[0].kind as string).toBe("comment");
expect(tokens[0].raw).toBe("# this is a comment");
});
// DELETED (task-19): "recognises # comment lines (legacy)" — the # comment line syntax
// was removed in the locked grammar. The kind "comment" no longer exists in TokenKind.
// Deleting rather than keeping dead skipped test.
it("treats # at line start as plain text (no comment syntax)", () => {
const tokens = tokenizeRich("# noted");
+91 -4
View File
@@ -15,25 +15,33 @@
* Reference: docs/design-handoff/wildcardpipeline/project/rich-input.jsx.
*/
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
import { tokenizeRich, mirrorHtmlWithIdx } from "../utils/richTokenize";
import { tokenizeRich } from "../utils/richTokenize";
import type { SurfaceKind, ResolveWarning } from "../utils/resolveTokens";
interface Props {
modelValue: string;
surface?: SurfaceKind;
warnings?: ResolveWarning[];
multiline?: boolean;
rows?: number;
placeholder?: string;
varSuggestions?: string[];
refSuggestions?: string[];
/** Map from UUID to display name; used to render `@{uuid}` refs as human labels. */
uuidToName?: Map<string, string>;
ariaLabel?: string;
disabled?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
surface: "wildcard",
warnings: () => [],
multiline: false,
rows: 4,
placeholder: "",
varSuggestions: () => [],
refSuggestions: () => [],
uuidToName: () => new Map(),
ariaLabel: undefined,
disabled: false,
});
@@ -68,11 +76,49 @@ const tokens = computed(() => tokenizeRich(props.modelValue || ""));
// SAFE: `mirrorHtmlWithIdx` HTML-escapes every `raw` payload before
// concatenation. The resulting string only contains tags we generated, so
// `v-html` cannot inject user-controlled markup.
const mirrorHtml = computed(() => mirrorHtmlWithIdx(tokens.value));
// When `surface !== "wildcard"`, ref tokens get an extra "ignored" class
// so they render with muted styling.
const mirrorHtml = computed(() => {
const toks = tokens.value;
const isWildcard = props.surface === "wildcard";
const map = props.uuidToName;
let html = "";
for (let i = 0; i < toks.length; i++) {
const t = toks[i];
let cls = `wp-rt-${t.kind}`;
let display = t.raw;
if (t.kind === "ref") {
if (!isWildcard) cls += " wp-rt-ref--ignored";
// Replace `@{uuid}` with `@name` display form when name is known.
const uuid = t.meta?.uuid;
if (uuid && map.has(uuid)) {
const name = map.get(uuid)!;
// Escape the name portion (uuid chars are hex-safe but name may not be).
display = "@" + name.replace(/[&<>"']/g, (c) => (
{ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c] ?? c
));
} else {
// Fall back to escaped raw form.
display = t.raw.replace(/[&<>"']/g, (c) => (
{ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c] ?? c
));
}
html += `<span class="${cls}" data-idx="${i}">${display}</span>`;
} else {
html += `<span class="${cls}" data-idx="${i}">${t.raw.replace(/[&<>"']/g, (c) => (
{ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c] ?? c
))}</span>`;
}
}
html += '<span class="wp-rt-tail">&#x200B;</span>';
return html;
});
// --- Suggestion list filtering. ---
// `@` autocomplete is only available in the "wildcard" surface.
const acItems = computed(() => {
if (!acOpen.value) return [];
if (acTrigger.value === "@" && props.surface !== "wildcard") return [];
const pool = acTrigger.value === "@" ? props.refSuggestions : props.varSuggestions;
const q = acQuery.value.toLowerCase();
return pool
@@ -141,6 +187,11 @@ function onInput(e: Event): void {
const caret = target.selectionStart ?? next.length;
const hit = probeAutocomplete(next, caret);
if (hit) {
// Gate `@` autocomplete — only available in the "wildcard" surface.
if (hit.trigger === "@" && props.surface !== "wildcard") {
acOpen.value = false;
return;
}
acOpen.value = true;
acStart.value = hit.start;
acQuery.value = hit.query;
@@ -158,6 +209,11 @@ function refreshAutocompleteFromCaret(): void {
const caret = el.selectionStart ?? 0;
const hit = probeAutocomplete(props.modelValue || "", caret);
if (hit) {
// Gate `@` autocomplete — only available in the "wildcard" surface.
if (hit.trigger === "@" && props.surface !== "wildcard") {
acOpen.value = false;
return;
}
acOpen.value = true;
acStart.value = hit.start;
acQuery.value = hit.query;
@@ -207,7 +263,11 @@ function applyAutocomplete(label: string | undefined): void {
const caret = el.selectionStart ?? value.length;
const before = value.slice(0, acStart.value);
const after = value.slice(caret);
const inserted = acTrigger.value + label;
// For `@` trigger in wildcard surface, `label` is a UUID; wrap it in `@{uuid}`.
// For `$` trigger, insert `$name` as before.
const inserted = acTrigger.value === "@"
? `@{${label}}`
: acTrigger.value + label;
const next = before + inserted + after;
emit("update:modelValue", next);
acOpen.value = false;
@@ -286,7 +346,7 @@ onBeforeUnmount(() => {
:data-focused="focused ? '' : null"
>
<!-- The mirror is read-only chrome; pointer-events:none in CSS.
`v-html` is safe because mirrorHtmlWithIdx() escapes all token text. -->
`v-html` is safe because the computed mirrorHtml escapes all token text. -->
<div
ref="mirrorEl"
class="wp-rt__mirror"
@@ -295,6 +355,24 @@ onBeforeUnmount(() => {
v-html="mirrorHtml"
/>
<!-- Warning markers overlay. Each marker is a zero-width inline element
anchored at the UTF-16 offset corresponding to the warning position.
`data-warning-position` records the original code-point index for tests. -->
<div
v-if="warnings.length > 0"
class="wp-rt__warnings"
aria-hidden="true"
>
<span
v-for="w in warnings"
:key="`${w.position}-${w.severity}`"
class="wp-rt-warn-marker"
:class="`wp-rt-warn-${w.severity}`"
:data-warning-position="w.position"
:title="w.message"
/>
</div>
<textarea
v-if="multiline"
ref="inputEl"
@@ -467,6 +545,15 @@ onBeforeUnmount(() => {
/* Token span chrome (.wp-rt-var / .wp-rt-ref / .wp-rt-dp-* / quantifier
colour + chip backgrounds + focus/rest padding) lives in the global
styles/rich-text.css so RichTextInput and RichTextPreview stay in sync. */
/* Warning markers overlay — sits atop the mirror, pointer-events: none so
it does not block textarea interaction. */
.wp-rt__warnings {
position: absolute;
inset: 0;
pointer-events: none;
user-select: none;
}
</style>
<style>
+55 -6
View File
@@ -9,23 +9,72 @@
* styled chips at rest.
*/
import { computed } from "vue";
import { tokenizeRich, mirrorHtmlWithIdx } from "../utils/richTokenize";
import { tokenizeRich } from "../utils/richTokenize";
import type { SurfaceKind, ResolveWarning } from "../utils/resolveTokens";
interface Props {
value: string;
surface?: SurfaceKind;
warnings?: ResolveWarning[];
/** Map from UUID to display name; used to render `@{uuid}` refs as human labels. */
uuidToName?: Map<string, string>;
}
const props = defineProps<Props>();
const props = withDefaults(defineProps<Props>(), {
surface: "wildcard",
warnings: () => [],
uuidToName: () => new Map(),
});
const HTML_ESC: Record<string, string> = {
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
};
function esc(s: string): string {
return s.replace(/[&<>"']/g, (c) => HTML_ESC[c] ?? c);
}
const tokens = computed(() => tokenizeRich(props.value || ""));
// SAFE: `mirrorHtmlWithIdx` HTML-escapes every `raw` payload before
// concatenation. The resulting string only contains tags we generated, so
// `v-html` cannot inject user-controlled markup.
const html = computed(() => mirrorHtmlWithIdx(tokens.value));
// SAFE: all token text is HTML-escaped before concatenation.
// `@{uuid}` ref tokens are rendered with a human-readable display name
// when `uuidToName` provides one. Non-wildcard surfaces get a muted class.
const html = computed(() => {
const toks = tokens.value;
const isWildcard = props.surface === "wildcard";
const map = props.uuidToName;
let out = "";
for (let i = 0; i < toks.length; i++) {
const t = toks[i];
let cls = `wp-rt-${t.kind}`;
let display: string;
if (t.kind === "ref") {
if (!isWildcard) cls += " wp-rt-ref--ignored";
const uuid = t.meta?.uuid;
if (uuid && map.has(uuid)) {
display = "@" + esc(map.get(uuid)!);
} else {
display = esc(t.raw);
}
} else {
display = esc(t.raw);
}
out += `<span class="${cls}" data-idx="${i}">${display}</span>`;
}
out += '<span class="wp-rt-tail">&#x200B;</span>';
return out;
});
</script>
<template>
<span class="wp-rt-preview wp-rt--rest" v-html="html" />
<span
v-for="w in warnings"
:key="`${w.position}-${w.severity}`"
class="wp-rt-warn-marker"
:class="`wp-rt-warn-${w.severity}`"
:data-warning-position="w.position"
:title="w.message"
aria-hidden="true"
/>
</template>
<style scoped>
+46
View File
@@ -120,6 +120,52 @@
padding: 0 3px;
}
/* ── Warning marker spans ── *
* Emitted by RichTextInput + RichTextPreview when `warnings` prop is set.
* Markers are zero-width spans with a coloured bottom border and a small
* icon fragment so they are visible without disrupting text flow.
* Severity → class mapping:
* info → wp-rt-warn-info (blue)
* warn → wp-rt-warn-warn (amber)
* error → wp-rt-warn-error (red)
* -------------------------------------------------------------------- */
.wp-rt-warn-marker {
display: inline-block;
width: 0;
height: 1em;
vertical-align: middle;
border-left: 2px solid transparent;
border-bottom: 2px solid transparent;
position: relative;
pointer-events: auto;
}
.wp-rt-warn-info {
border-color: var(--wp-info, #60a5fa);
border-bottom-color: var(--wp-info, #60a5fa);
}
.wp-rt-warn-warn {
border-color: var(--wp-warn, #fcd34d);
border-bottom-color: var(--wp-warn, #fcd34d);
}
.wp-rt-warn-error {
border-color: var(--wp-error, #f87171);
border-bottom-color: var(--wp-error, #f87171);
}
/* ── Muted ref chip — used when surface !== "wildcard" ── *
* Inherits the same chip shape as .wp-rt-ref but tones down the colour
* to signal that `@{uuid}` tokens are ignored on this surface.
* -------------------------------------------------------------------- */
.wp-rt .wp-rt-ref--ignored,
.wp-rt-preview .wp-rt-ref--ignored {
color: var(--wp-text-dim, #6e6e7c);
background: rgba(107, 114, 128, 0.12);
background: color-mix(in oklab, var(--wp-text-muted, #6e6e7c) 12%, transparent);
box-shadow: inset 0 0 0 1px rgba(107, 114, 128, 0.22);
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--wp-text-muted, #6e6e7c) 22%, transparent);
opacity: 0.75;
}
/* ── Light-mode overrides: rich-text token chrome ── */
.wp-theme-light .wp-rt .wp-rt-var,
.wp-theme-light .wp-rt-preview .wp-rt-var {