feat(spa): wildcards list — syntax indicator column + nested-ref filters

Add a Syntax column showing per-row pills for outgoing @ref count, inline
{a|b|c} alternation, and incoming references. Three new extra filters in
the filter panel: "Has nested refs", "Has inline choices", "Is referenced".
Row-expansion now renders option values via RichTextPreview so chips show.

Backed by new pure helpers in src/manager/utils/wildcardSyntax.ts
(getWildcardSyntax, buildWildcardGraph, wildcardVarName) with 9 unit tests
plus a Wildcards.vue render test covering all three pill variants.
This commit is contained in:
DumiFlex
2026-04-27 06:53:13 +03:00
parent a5dfe52511
commit 80cc323f31
5 changed files with 405 additions and 2 deletions
+39
View File
@@ -77,4 +77,43 @@ describe("Wildcards.vue", () => {
await flushPromises();
expect(wrap.text()).toContain("No wildcards yet");
});
it("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).
apiMod.list.mockResolvedValue({
items: [
{
id: "wc_outfit", type: "wildcard", name: "outfit", category_id: null,
tags: [], is_favorite: false,
payload: {
options: [{ id: "o1", value: "wear a @hat with a {red|blue} scarf", weight: 1 }],
sub_categories: [],
var_binding: "outfit",
},
version: 1, description: "", created_at: "", updated_at: "",
},
{
id: "wc_hat", type: "wildcard", name: "hat", category_id: null,
tags: [], is_favorite: false,
payload: {
options: [{ id: "o1", value: "fedora", weight: 1 }],
sub_categories: [],
var_binding: "hat",
},
version: 1, description: "", created_at: "", updated_at: "",
},
],
total: 2,
});
const wrap = mountView();
await flushPromises();
const pills = wrap.findAll(".wp-syntax-pill");
// Outfit row: outgoing pill + inline pill. Hat row: incoming pill.
expect(pills.length).toBeGreaterThanOrEqual(3);
const html = wrap.html();
expect(html).toContain("wp-syntax-pill--ref");
expect(html).toContain("wp-syntax-pill--dp");
expect(html).toContain("wp-syntax-pill--in");
});
});
@@ -0,0 +1,111 @@
import { describe, expect, it } from "vitest";
import {
buildWildcardGraph,
getWildcardSyntax,
wildcardVarName,
} from "../utils/wildcardSyntax";
import type { ModuleRow, WildcardOption } from "../api/types";
function wc(
partial: Partial<ModuleRow> & {
name: string;
id?: string;
options?: WildcardOption[];
var_binding?: string | undefined;
},
): ModuleRow {
const opts = partial.options ?? [];
const payload: Record<string, unknown> = { options: opts, sub_categories: [] };
if ("var_binding" in partial) {
if (partial.var_binding !== undefined) payload.var_binding = partial.var_binding;
} else {
payload.var_binding = partial.name;
}
return {
id: partial.id ?? `wc_${partial.name}`,
type: "wildcard",
name: partial.name,
description: partial.description ?? "",
category_id: partial.category_id ?? null,
tags: partial.tags ?? [],
is_favorite: partial.is_favorite ?? false,
payload,
version: partial.version ?? 1,
created_at: partial.created_at ?? "",
updated_at: partial.updated_at ?? "",
};
}
function opt(value: string, weight = 1, id = "o1"): WildcardOption {
return { id, value, weight };
}
describe("getWildcardSyntax", () => {
it("flags no refs / no inline for a plain string", () => {
const m = wc({ name: "color", options: [opt("red"), opt("blue")] });
const sx = getWildcardSyntax(m);
expect(sx.hasRefs).toBe(false);
expect(sx.hasInline).toBe(false);
expect(sx.refTargets).toEqual([]);
});
it("detects @ref tokens in option values", () => {
const m = wc({
name: "outfit",
options: [opt("a @hat"), opt("a @shoes and a @hat"), opt("plain")],
});
const sx = getWildcardSyntax(m);
expect(sx.hasRefs).toBe(true);
// dedupes targets across options
expect(sx.refTargets.sort()).toEqual(["hat", "shoes"]);
});
it("ignores @@ literal escapes", () => {
const m = wc({ name: "msg", options: [opt("email me at name@@host")] });
const sx = getWildcardSyntax(m);
expect(sx.hasRefs).toBe(false);
});
it("detects inline {a|b|c} choice blocks", () => {
const m = wc({ name: "tone", options: [opt("look {happy|sad|angry}")] });
const sx = getWildcardSyntax(m);
expect(sx.hasInline).toBe(true);
});
it("does not flag inline for a brace block with no pipes", () => {
const m = wc({ name: "blank", options: [opt("nothing here {literal}")] });
expect(getWildcardSyntax(m).hasInline).toBe(false);
});
});
describe("buildWildcardGraph", () => {
it("creates bidirectional edges between two wildcards that reference each other", () => {
const a = wc({ name: "alpha", var_binding: "alpha", options: [opt("see @beta")] });
const b = wc({ name: "beta", var_binding: "beta", options: [opt("see @alpha")] });
const g = buildWildcardGraph([a, b]);
expect(g.outgoing.get("alpha")).toEqual(new Set(["beta"]));
expect(g.outgoing.get("beta")).toEqual(new Set(["alpha"]));
expect(g.incoming.get("alpha")).toEqual(new Set(["beta"]));
expect(g.incoming.get("beta")).toEqual(new Set(["alpha"]));
});
it("yields empty edge sets for a wildcard with no references", () => {
const a = wc({ name: "lonely", var_binding: "lonely", options: [opt("just text")] });
const g = buildWildcardGraph([a]);
expect(g.outgoing.get("lonely")?.size ?? -1).toBe(0);
expect(g.incoming.get("lonely")?.size ?? -1).toBe(0);
});
it("drops dangling @ref targets that don't resolve to a known wildcard", () => {
const a = wc({ name: "alpha", var_binding: "alpha", options: [opt("@ghost or @beta")] });
const b = wc({ name: "beta", var_binding: "beta", options: [opt("plain")] });
const g = buildWildcardGraph([a, b]);
expect(g.outgoing.get("alpha")).toEqual(new Set(["beta"]));
expect(g.incoming.get("ghost")).toBeUndefined();
});
it("falls back to slug(name) when var_binding is missing", () => {
const m = wc({ name: "Hat Color", var_binding: undefined, options: [] });
expect(wildcardVarName(m)).toBe("hat_color");
});
});
+128
View File
@@ -0,0 +1,128 @@
/**
* Wildcard syntax helpers.
*
* Pure utilities that inspect a wildcard module's option values and surface
* syntactic features used by the manager UI (nested `@ref` targets, inline
* `{a|b|c}` choice blocks). Mirrors the React reference at
* `docs/design-handoff/wildcardpipeline/project/data.jsx` so list-screen
* rendering matches the prototype exactly.
*/
import type { ModuleRow, WildcardOption, WildcardPayload } from "../api/types";
import { tokenizeRich } from "./richTokenize";
import { toIdentifier } from "./slug";
export interface SyntaxFlags {
/** Any option value contains an `@name` reference token. */
hasRefs: boolean;
/** Any option value contains an inline `{a|b|c}` choice block. */
hasInline: boolean;
/** Distinct `@name` targets across every option value (deduped, ordered). */
refTargets: string[];
}
/** Pull `@name` ref-token names out of a single value string via `tokenizeRich`. */
function refsInValue(value: string): string[] {
if (!value) return [];
const out: string[] = [];
for (const tok of tokenizeRich(value)) {
if (tok.kind === "ref" && tok.meta?.name) out.push(tok.meta.name);
}
return out;
}
/** Detect a `{a|b|c}` block — at least one `|` between the braces. */
function hasInlineChoice(value: string): boolean {
if (!value) return false;
let inline = false;
for (const tok of tokenizeRich(value)) {
// `dp-pipe` is only emitted at brace-depth zero by the tokenizer, so its
// presence guarantees a real `{a|b|c}` alternation rather than a stray `|`.
if (tok.kind === "dp-pipe") {
inline = true;
break;
}
}
return inline;
}
/** Pull the option list out of a wildcard module's payload, defensively. */
function optionsOf(mod: ModuleRow): WildcardOption[] {
const payload = mod.payload as unknown as Partial<WildcardPayload> | undefined;
return payload?.options ?? [];
}
/**
* The `$varname` other modules use to reference this wildcard. Falls back to
* `toIdentifier(name)` for legacy modules saved before `var_binding` existed.
*/
export function wildcardVarName(mod: ModuleRow): string {
const payload = mod.payload as unknown as Partial<WildcardPayload> | undefined;
const explicit = payload?.var_binding?.trim();
return explicit && explicit.length > 0 ? explicit : toIdentifier(mod.name);
}
/** Aggregate ref + inline-choice flags for a single wildcard module. */
export function getWildcardSyntax(mod: ModuleRow): SyntaxFlags {
const refs = new Set<string>();
let hasInline = false;
for (const opt of optionsOf(mod)) {
for (const r of refsInValue(opt.value)) refs.add(r);
if (!hasInline && hasInlineChoice(opt.value)) hasInline = true;
}
return {
hasRefs: refs.size > 0,
hasInline,
refTargets: [...refs],
};
}
export interface WildcardGraph {
/** outgoing[wcName] = Set of var-names it references via `@ref`. */
outgoing: Map<string, Set<string>>;
/** incoming[wcName] = Set of var-names that reference it. */
incoming: Map<string, Set<string>>;
}
/**
* Build a bidirectional reference graph keyed by wildcard `var_binding`
* (or slug-fallback). Edges are only added when the target var-name resolves
* to an actual wildcard in the input list — dangling `@ref` tokens are
* dropped so callers can rely on `incoming.get(name)` returning known peers.
*/
export function buildWildcardGraph(modules: ModuleRow[]): WildcardGraph {
const outgoing = new Map<string, Set<string>>();
const incoming = new Map<string, Set<string>>();
const wildcards = modules.filter((m) => m.type === "wildcard");
const known = new Set<string>();
const nameOf = new Map<string, string>();
for (const w of wildcards) {
const n = wildcardVarName(w);
known.add(n);
nameOf.set(w.id, n);
}
for (const w of wildcards) {
const src = nameOf.get(w.id)!;
const out = new Set<string>();
outgoing.set(src, out);
for (const target of getWildcardSyntax(w).refTargets) {
if (!known.has(target)) continue;
out.add(target);
let inSet = incoming.get(target);
if (!inSet) {
inSet = new Set<string>();
incoming.set(target, inSet);
}
inSet.add(src);
}
}
// Ensure every wildcard appears as a key in both maps for easy lookup.
for (const n of known) {
if (!outgoing.has(n)) outgoing.set(n, new Set());
if (!incoming.has(n)) incoming.set(n, new Set());
}
return { outgoing, incoming };
}
+126 -1
View File
@@ -10,9 +10,15 @@ import MultiSelect from "primevue/multiselect";
import Select from "primevue/select";
import EntityListView from "../components/EntityListView.vue";
import RelativeDate from "../components/RelativeDate.vue";
import RichTextPreview from "../components/RichTextPreview.vue";
import { useModuleStore } from "../stores/moduleStore";
import { useCategoryStore } from "../stores/categoryStore";
import type { ModuleRow, CategoryRow } from "../api/types";
import {
buildWildcardGraph,
getWildcardSyntax,
wildcardVarName,
} from "../utils/wildcardSyntax";
const router = useRouter();
const store = useModuleStore();
@@ -31,6 +37,49 @@ const allTags = computed(() => {
return Array.from(set).sort();
});
// Reactive ref-graph computed off the store. Rebuilds on every change since
// `getWildcardSyntax` is cheap (linear over tokens) and graphs are small.
const wildcardGraph = computed(() => buildWildcardGraph(store.items));
interface SyntaxView {
hasInline: boolean;
outgoing: string[];
incoming: string[];
}
function syntaxView(row: ModuleRow): SyntaxView {
const sx = getWildcardSyntax(row);
const name = wildcardVarName(row);
const out = wildcardGraph.value.outgoing.get(name);
const inc = wildcardGraph.value.incoming.get(name);
return {
hasInline: sx.hasInline,
outgoing: out ? [...out] : [],
incoming: inc ? [...inc] : [],
};
}
const extraFilters = computed(() => [
{
key: "has-refs",
label: "Has nested refs",
check: (m: ModuleRow) => getWildcardSyntax(m).hasRefs,
},
{
key: "has-inline",
label: "Has inline choices",
check: (m: ModuleRow) => getWildcardSyntax(m).hasInline,
},
{
key: "is-referenced",
label: "Is referenced",
check: (m: ModuleRow) => {
const inc = wildcardGraph.value.incoming.get(wildcardVarName(m));
return !!inc && inc.size > 0;
},
},
]);
onMounted(async () => {
store.filter.type = "wildcard";
await Promise.all([fetch(), categoryStore.fetchAll()]);
@@ -111,6 +160,7 @@ function validIcon(row: ModuleRow): string {
:items="store.items"
:loading="store.loading"
:filter="store.filter"
:extra-filters="extraFilters"
empty-message="No wildcards yet. Create your first to start building dynamic prompts."
@fetch="fetch"
@delete="del"
@@ -172,6 +222,39 @@ function validIcon(row: ModuleRow): string {
</div>
</template>
</Column>
<Column header="Syntax" header-style="width:8rem">
<template #body="{ data }">
<template v-for="v in [syntaxView(data)]" :key="data.id">
<div
v-if="v.outgoing.length || v.incoming.length || v.hasInline"
class="wp-syntax-cell"
>
<span
v-if="v.outgoing.length"
class="wp-syntax-pill wp-syntax-pill--ref"
:title="`References ${v.outgoing.length} wildcard${v.outgoing.length === 1 ? '' : 's'}: ${v.outgoing.join(', ')}`"
>
<i class="pi pi-arrow-right-arrow-left" />
{{ v.outgoing.length }}
</span>
<span
v-if="v.hasInline"
class="wp-syntax-pill wp-syntax-pill--dp"
title="Contains inline {a|b|c} alternatives"
>{{ "{ }" }}</span>
<span
v-if="v.incoming.length"
class="wp-syntax-pill wp-syntax-pill--in"
:title="`Referenced by ${v.incoming.length} wildcard${v.incoming.length === 1 ? '' : 's'}: ${v.incoming.join(', ')}`"
>
<i class="pi pi-arrow-left" />
{{ v.incoming.length }}
</span>
</div>
<span v-else class="text-wp-text3 text-sm">—</span>
</template>
</template>
</Column>
<Column field="category_id" header="Category" header-style="width:9rem" sortable>
<template #body="{ data }">
<span
@@ -230,7 +313,8 @@ function validIcon(row: ModuleRow): string {
<div v-else class="flex flex-col gap-1">
<div v-for="(opt, i) in topOptions(data)" :key="i" class="flex items-center gap-3 text-sm">
<Badge :value="String(opt.weight)" severity="secondary" class="min-w-8" />
<span class="font-mono text-wp-text">{{ opt.value || "(empty)" }}</span>
<RichTextPreview v-if="opt.value" :value="opt.value" />
<span v-else class="font-mono text-wp-text3">(empty)</span>
</div>
<span v-if="optionCount(data) > 3" class="text-xs text-wp-text3 mt-1">
… and {{ optionCount(data) - 3 }} more
@@ -258,4 +342,45 @@ function validIcon(row: ModuleRow): string {
color: var(--wp-text2);
border-radius: 3px;
}
/* Syntax indicator pills (mirrors design-handoff styles.css). */
.wp-syntax-cell {
display: inline-flex;
gap: 4px;
align-items: center;
flex-wrap: nowrap;
}
.wp-syntax-pill {
display: inline-flex;
align-items: center;
gap: 3px;
height: 20px;
padding: 0 6px;
border-radius: 999px;
font-size: 10.5px;
font-weight: 600;
font-family: var(--wp-font-mono, ui-monospace, monospace);
border: 1px solid transparent;
white-space: nowrap;
line-height: 1;
}
.wp-syntax-pill .pi { font-size: 9.5px; }
.wp-syntax-pill--ref {
/* outgoing nested @refs — pink/magenta to match @ref token color */
color: var(--wp-kind-wildcard, #f0abfc);
background: color-mix(in oklab, var(--wp-kind-wildcard, #c026d3) 14%, transparent);
border-color: color-mix(in oklab, var(--wp-kind-wildcard, #c026d3) 28%, transparent);
}
.wp-syntax-pill--in {
/* incoming references — accent (purple) */
color: var(--wp-accent-text, #c4b5fd);
background: color-mix(in oklab, var(--wp-accent-500, #8b5cf6) 14%, transparent);
border-color: color-mix(in oklab, var(--wp-accent-500, #8b5cf6) 32%, transparent);
}
.wp-syntax-pill--dp {
/* inline {a|b|c} — amber/yellow to match dp-brace token color */
color: var(--wp-warn, #fcd34d);
background: color-mix(in oklab, var(--wp-warn, #facc15) 14%, transparent);
border-color: color-mix(in oklab, var(--wp-warn, #facc15) 30%, transparent);
}
</style>
+1 -1
View File
@@ -7,7 +7,7 @@
<link rel="icon" type="image/svg+xml" href="/wp/images/favicon.svg" />
<link rel="apple-touch-icon" href="/wp/images/apple-touch-icon.png" />
<link rel="manifest" href="/wp/images/site.webmanifest" />
<script type="module" crossorigin src="/wp/assets/manager-Z-SdbdKR.js"></script>
<script type="module" crossorigin src="/wp/assets/manager-xOK4I4Wx.js"></script>
<link rel="stylesheet" crossorigin href="/wp/assets/manager-CMgfxADo.css">
</head>
<body>