@dumiflex/wp-kit 0.3.0 → 0.5.0
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.
- package/CHANGELOG.md +92 -51
- package/LICENSE +674 -674
- package/README.md +73 -76
- package/package.json +78 -77
- package/src/Avatar.vue +83 -83
- package/src/Badge.vue +97 -97
- package/src/Banner.vue +118 -118
- package/src/BookmarkButton.vue +27 -27
- package/src/BundleMeta.vue +51 -51
- package/src/Button.vue +103 -103
- package/src/Card.vue +68 -68
- package/src/Checkbox.vue +109 -107
- package/src/Chip.vue +163 -97
- package/src/ChoiceCards.vue +133 -133
- package/src/CloseButton.vue +69 -69
- package/src/CodeBlock.vue +146 -119
- package/src/ColorField.vue +188 -206
- package/src/ColorPicker.vue +201 -201
- package/src/Combobox.vue +331 -334
- package/src/ConfirmDialog.vue +165 -163
- package/src/ConfirmModal.vue +147 -147
- package/src/ConstraintMatrixPreview.vue +708 -708
- package/src/ContextMenu.vue +386 -379
- package/src/CopyButton.vue +156 -156
- package/src/DataTable.vue +293 -292
- package/src/DatePicker.vue +234 -234
- package/src/DescriptionList.vue +73 -73
- package/src/DiffView.vue +68 -68
- package/src/Disclosure.vue +166 -166
- package/src/DragHandle.vue +49 -49
- package/src/Drawer.vue +131 -131
- package/src/DropZone.vue +196 -196
- package/src/DropdownMenu.vue +72 -72
- package/src/EmptyState.vue +111 -111
- package/src/Field.vue +28 -28
- package/src/HsvPicker.vue +208 -208
- package/src/Icon.vue +39 -39
- package/src/IconPicker.vue +209 -209
- package/src/ImageGrid.vue +127 -125
- package/src/InlineEdit.vue +183 -183
- package/src/Input.vue +99 -99
- package/src/Kbd.vue +56 -56
- package/src/Lightbox.vue +176 -173
- package/src/ListFilter.vue +85 -85
- package/src/ListRow.vue +204 -204
- package/src/LoadingMark.vue +217 -161
- package/src/Logo.vue +60 -60
- package/src/Modal.vue +78 -76
- package/src/ModalShell.vue +109 -107
- package/src/Pagination.vue +106 -106
- package/src/Popover.vue +180 -171
- package/src/PostCard.vue +210 -211
- package/src/ProgressBar.vue +90 -90
- package/src/RadioGroup.vue +177 -177
- package/src/RangeSlider.vue +183 -183
- package/src/RelativeDate.vue +35 -35
- package/src/ResizeHandle.vue +143 -143
- package/src/SearchInput.vue +192 -199
- package/src/Select.vue +644 -641
- package/src/Separator.vue +54 -54
- package/src/Skeleton.vue +98 -82
- package/src/Slider.vue +198 -198
- package/src/SortableList.vue +260 -260
- package/src/SplitPane.vue +68 -68
- package/src/StarButton.vue +40 -40
- package/src/Stepper.vue +141 -141
- package/src/SupportLinks.vue +93 -93
- package/src/TagInput.vue +297 -300
- package/src/Textarea.vue +57 -57
- package/src/Timeline.vue +92 -92
- package/src/ToastCard.vue +119 -119
- package/src/ToastHost.vue +40 -40
- package/src/Toggle.vue +36 -36
- package/src/TokenizedValue.vue +310 -310
- package/src/Toolbar.vue +110 -110
- package/src/VirtualList.vue +87 -87
- package/src/VisuallyHidden.vue +29 -29
- package/src/WpButton.vue +75 -75
- package/src/WpCheck.vue +104 -102
- package/src/WpIconButton.vue +107 -107
- package/src/WpImageZoom.vue +544 -544
- package/src/WpNumber.vue +178 -183
- package/src/WpSegmented.vue +175 -175
- package/src/WpSelect.vue +93 -89
- package/src/WpSwitch.vue +163 -163
- package/src/WpTabs.vue +188 -188
- package/src/_modal-motion.css +33 -33
- package/src/_skeleton.css +50 -50
- package/src/axis-color.ts +42 -42
- package/src/button.css +169 -169
- package/src/calendar.ts +59 -59
- package/src/clipboard.ts +50 -50
- package/src/colour-presets.ts +12 -12
- package/src/diff.ts +51 -51
- package/src/drop-zone.ts +41 -41
- package/src/field.css +50 -0
- package/src/flip.ts +237 -237
- package/src/floating-layer.ts +48 -0
- package/src/floating.ts +99 -99
- package/src/highlight.ts +199 -0
- package/src/hsv.ts +100 -100
- package/src/icon-catalog.ts +52 -52
- package/src/image-zoom.ts +93 -93
- package/src/kind-colour.ts +28 -0
- package/src/loading-mark-motion.ts +249 -0
- package/src/logo-paths.ts +21 -21
- package/src/matrix-axis.ts +91 -91
- package/src/pagination.ts +27 -27
- package/src/platform-keys.ts +29 -29
- package/src/rich-tokenize.ts +357 -357
- package/src/select-types.ts +32 -32
- package/src/slider-math.ts +45 -45
- package/src/sortable.ts +22 -22
- package/src/theme/fonts/OFL-Fraunces.txt +93 -93
- package/src/theme/fonts/OFL-JetBrainsMono.txt +93 -93
- package/src/theme/fonts/OFL-Manrope.txt +93 -93
- package/src/theme/looks.css +428 -428
- package/src/theme/palettes.css +254 -254
- package/src/theme/tokens.css +2896 -2894
- package/src/tokens.ts +56 -56
- package/src/tooltip.css +43 -43
- package/src/tooltip.ts +146 -146
- package/src/useDialogFocus.ts +73 -73
- package/src/useGrowableField.ts +357 -357
- package/src/useModalKeyShield.ts +116 -116
- package/src/useToast.ts +48 -48
- package/src/wp-logo.svg +14 -14
- package/src/ChipToggle.vue +0 -92
- package/src/KindChip.vue +0 -53
- package/src/RatingBadge.vue +0 -42
package/src/rich-tokenize.ts
CHANGED
|
@@ -1,357 +1,357 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Wildcard Pipeline rich-text tokenizer: the pure tokenizing surface of the
|
|
3
|
-
* extension's `src/widgets/richTokenize.ts` lexer (same grammar). It has no
|
|
4
|
-
* resolution helpers and no HTML emitters: TokenizedValue walks these tokens
|
|
5
|
-
* in a template, so nothing here needs `v-html`.
|
|
6
|
-
*
|
|
7
|
-
* Recognises the following syntactic surface (locked grammar, Tasks 3-7):
|
|
8
|
-
*
|
|
9
|
-
* $varname subject placeholder (var)
|
|
10
|
-
* @{8hex} nested wildcard reference by UUID (ref)
|
|
11
|
-
* $$ / @@ literal-escape sequences (escape)
|
|
12
|
-
* {a|b|c} inline-choice block (dp-brace, single token)
|
|
13
|
-
* {N$$sep$$a|b|c} multi-select with optional separator (dp-multi)
|
|
14
|
-
*
|
|
15
|
-
* Single-token emission for brace blocks: `{a|b|c}` produces ONE `dp-brace`
|
|
16
|
-
* token whose `meta.branches` lists the raw branch strings. This mirrors the
|
|
17
|
-
* Python tokenizer exactly and is the contract the corpus (tests/fixtures/
|
|
18
|
-
* syntax-corpus.json) locks in.
|
|
19
|
-
*
|
|
20
|
-
* Lossless invariant: joining all `.raw` fields reproduces the input exactly.
|
|
21
|
-
*
|
|
22
|
-
* REMOVED (legacy syntax no longer supported):
|
|
23
|
-
* @name short-form ref - use @{8hex} UUID form instead
|
|
24
|
-
* N#$var / N#@ref / N#{...} quantifier prefix
|
|
25
|
-
* # comment to end of line greyed-out comment line
|
|
26
|
-
* dp-pipe / dp-weight sub-tokens inside brace blocks
|
|
27
|
-
*
|
|
28
|
-
* Note: `dp-pipe` and `dp-weight` remain in the TokenKind union for backwards
|
|
29
|
-
* compatibility with any consumers that reference the type, but the tokenizer
|
|
30
|
-
* no longer emits them.
|
|
31
|
-
*/
|
|
32
|
-
|
|
33
|
-
export type TokenKind =
|
|
34
|
-
| "text"
|
|
35
|
-
| "var"
|
|
36
|
-
| "ref"
|
|
37
|
-
| "dp-brace"
|
|
38
|
-
| "dp-pipe" // retained in type only; not emitted by tokenizeRich
|
|
39
|
-
| "dp-multi"
|
|
40
|
-
| "dp-weight" // retained in type only; not emitted by tokenizeRich
|
|
41
|
-
| "escape";
|
|
42
|
-
// REMOVED: "comment", "quantifier" - dropped in locked grammar (Tasks 3-5)
|
|
43
|
-
|
|
44
|
-
export interface TokenMeta {
|
|
45
|
-
// var tokens
|
|
46
|
-
name?: string;
|
|
47
|
-
/** SP2a list accessor: `$name.K` -> 0-based index K (omitted when absent). */
|
|
48
|
-
index?: number;
|
|
49
|
-
/** Schema v8: `$name.neg` / `$name.K.neg` reads the variable's negatives. */
|
|
50
|
-
neg?: boolean;
|
|
51
|
-
// ref tokens
|
|
52
|
-
uuid?: string;
|
|
53
|
-
sub_categories?: string[];
|
|
54
|
-
// escape tokens
|
|
55
|
-
literal?: string;
|
|
56
|
-
// dp-brace / dp-multi tokens
|
|
57
|
-
branches?: string[];
|
|
58
|
-
// dp-multi tokens
|
|
59
|
-
count?: number;
|
|
60
|
-
sep?: string;
|
|
61
|
-
/** SP2b: count range — `min`==`max` for a fixed `{N$$}`, `min<max` for a
|
|
62
|
-
* `{N-M$$}` range. `independent` is the `~` flag (repeats allowed). */
|
|
63
|
-
min?: number;
|
|
64
|
-
max?: number;
|
|
65
|
-
independent?: boolean;
|
|
66
|
-
// dp-weight tokens (type kept for compat; not emitted)
|
|
67
|
-
weight?: number;
|
|
68
|
-
range?: string;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/** SP2a: reduce a var reference to its BASE name — drop an optional leading
|
|
72
|
-
* `$` and an optional trailing `.K` list accessor. `$mood.0` -> `mood`;
|
|
73
|
-
* `mood` -> `mood`; `weird.name` (non-digit suffix) -> unchanged. Used by
|
|
74
|
-
* validation + conflict scanning so a `.K` accessor resolves against the
|
|
75
|
-
* bound base var, not a phantom `mood.0`. */
|
|
76
|
-
export function varBaseName(raw: string): string {
|
|
77
|
-
return raw.replace(/^\$/, "").replace(/\.neg$/, "").replace(/\.\d+$/, "").trim();
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
export interface RichToken {
|
|
81
|
-
kind: TokenKind;
|
|
82
|
-
start: number;
|
|
83
|
-
end: number;
|
|
84
|
-
raw: string;
|
|
85
|
-
meta?: TokenMeta;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/** Peeled view of a nested ref's `:`-filter body. */
|
|
89
|
-
export interface RefFilterParts {
|
|
90
|
-
/** Boolean sub-category expression, with the `!null` marker removed. */
|
|
91
|
-
expr: string;
|
|
92
|
-
/** True when the body carried the trailing `!null` exclude-null marker. */
|
|
93
|
-
excludeNull: boolean;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Peel a trailing `!null` exclude marker off a ref's raw `:`-body, returning
|
|
98
|
-
* the pure boolean expression + the exclude-null flag:
|
|
99
|
-
* `"warm or intense!null"` → `{ expr: "warm or intense", excludeNull: true }`
|
|
100
|
-
* `"warm or cool"` → `{ expr: "warm or cool", excludeNull: false }`
|
|
101
|
-
*
|
|
102
|
-
* Sub-category names and the boolean expression never contain `!` (see the
|
|
103
|
-
* grammar in `subcatFilter.ts`), so the only `!` in a serialized body is the
|
|
104
|
-
* null marker. Single source for the peel that RefChip, the canvas OptionRow,
|
|
105
|
-
* and RichTextInput's editor model all need — keeps them from re-deriving it
|
|
106
|
-
* (and drifting) independently.
|
|
107
|
-
*/
|
|
108
|
-
export function splitRefFilter(body: string): RefFilterParts {
|
|
109
|
-
const bang = body.lastIndexOf("!");
|
|
110
|
-
if (bang >= 0) {
|
|
111
|
-
return { expr: body.slice(0, bang).trim(), excludeNull: body.slice(bang + 1) === "null" };
|
|
112
|
-
}
|
|
113
|
-
return { expr: body.trim(), excludeNull: false };
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// ---------------------------------------------------------------------------
|
|
117
|
-
// Internal helpers (mirrors Python engine/syntax/tokenize.py)
|
|
118
|
-
// ---------------------------------------------------------------------------
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* Split `s` on `|` at brace-depth zero only. Nested `{a|b}` stays intact.
|
|
122
|
-
* Mirrors Python `_split_top_level_pipes`.
|
|
123
|
-
*/
|
|
124
|
-
function splitTopLevelPipes(s: string): string[] {
|
|
125
|
-
const parts: string[] = [];
|
|
126
|
-
let depth = 0;
|
|
127
|
-
let last = 0;
|
|
128
|
-
for (let idx = 0; idx < s.length; idx++) {
|
|
129
|
-
const c = s[idx];
|
|
130
|
-
if (c === "{") depth++;
|
|
131
|
-
else if (c === "}") depth--;
|
|
132
|
-
else if (c === "|" && depth === 0) {
|
|
133
|
-
parts.push(s.slice(last, idx));
|
|
134
|
-
last = idx + 1;
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
parts.push(s.slice(last));
|
|
138
|
-
return parts;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// Multi-pick prefix: {N$$sep$$ or {N-M~$$sep$$ — group 1 is the count (fixed
|
|
142
|
-
// `N` or a `N-M` range), group 2 is the optional `~` independent flag (absent
|
|
143
|
-
// = unique), group 3 is the separator. Applied against the whole raw block
|
|
144
|
-
// including the leading `{`. SP2b.
|
|
145
|
-
const MULTI_PREFIX_RE = /^\{(\d+(?:-\d+)?)(~?)\$\$([\s\S]*?)\$\$/;
|
|
146
|
-
|
|
147
|
-
/**
|
|
148
|
-
* Try to scan a `{...}` block starting at `text[start]` (must be `{`).
|
|
149
|
-
*
|
|
150
|
-
* Returns `[endIndex, branches, count | null, sep | null]` on success, or
|
|
151
|
-
* `null` if the block is malformed (unclosed, or no top-level pipes for a
|
|
152
|
-
* plain pick). Mirrors Python `_scan_brace_block`.
|
|
153
|
-
*/
|
|
154
|
-
function scanBraceBlock(
|
|
155
|
-
text: string,
|
|
156
|
-
start: number,
|
|
157
|
-
): [number, string[], [number, number, boolean] | null, string | null] | null {
|
|
158
|
-
const n = text.length;
|
|
159
|
-
if (start >= n || text[start] !== "{") return null;
|
|
160
|
-
|
|
161
|
-
// Walk to the matching `}` tracking depth.
|
|
162
|
-
let i = start + 1;
|
|
163
|
-
let depth = 1;
|
|
164
|
-
while (i < n && depth > 0) {
|
|
165
|
-
const c = text[i];
|
|
166
|
-
if (c === "{") depth++;
|
|
167
|
-
else if (c === "}") depth--;
|
|
168
|
-
if (depth > 0) i++;
|
|
169
|
-
}
|
|
170
|
-
if (depth !== 0) return null; // unclosed
|
|
171
|
-
|
|
172
|
-
const bodyStart = start + 1;
|
|
173
|
-
const bodyEnd = i; // index of closing `}`
|
|
174
|
-
const endIndex = i + 1;
|
|
175
|
-
const body = text.slice(bodyStart, bodyEnd);
|
|
176
|
-
|
|
177
|
-
// Detect multi-pick prefix: {N$$sep$$...
|
|
178
|
-
const raw = text.slice(start, endIndex);
|
|
179
|
-
const multiMatch = raw.match(MULTI_PREFIX_RE);
|
|
180
|
-
if (multiMatch) {
|
|
181
|
-
const countRaw = multiMatch[1]; // "N" or "N-M"
|
|
182
|
-
const independent = multiMatch[2] === "~"; // SP2b: repeats allowed
|
|
183
|
-
const sep = multiMatch[3];
|
|
184
|
-
// Prefix length excluding the leading `{`
|
|
185
|
-
const prefixLen = multiMatch[0].length - 1;
|
|
186
|
-
const rest = body.slice(prefixLen);
|
|
187
|
-
const branches = splitTopLevelPipes(rest);
|
|
188
|
-
const [lo, hi] = countRaw.includes("-")
|
|
189
|
-
? countRaw.split("-")
|
|
190
|
-
: [countRaw, countRaw];
|
|
191
|
-
let cmin = parseInt(lo, 10);
|
|
192
|
-
let cmax = parseInt(hi, 10);
|
|
193
|
-
if (cmin > cmax) { const t = cmin; cmin = cmax; cmax = t; }
|
|
194
|
-
return [endIndex, branches, [cmin, cmax, independent], sep];
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// Plain pick: must contain at least one top-level `|`
|
|
198
|
-
const branches = splitTopLevelPipes(body);
|
|
199
|
-
if (branches.length < 2) return null; // fall back to literal text
|
|
200
|
-
|
|
201
|
-
return [endIndex, branches, null, null];
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
// ---------------------------------------------------------------------------
|
|
205
|
-
// Public API
|
|
206
|
-
// ---------------------------------------------------------------------------
|
|
207
|
-
|
|
208
|
-
/**
|
|
209
|
-
* Walk `text` once and emit a flat list of {@link RichToken}s.
|
|
210
|
-
*
|
|
211
|
-
* Token shapes mirror the Python tokenizer (`engine/syntax/tokenize.py`) so
|
|
212
|
-
* the corpus contract holds across both languages.
|
|
213
|
-
*/
|
|
214
|
-
export function tokenizeRich(text: string): RichToken[] {
|
|
215
|
-
const out: RichToken[] = [];
|
|
216
|
-
if (!text) return out;
|
|
217
|
-
|
|
218
|
-
const N = text.length;
|
|
219
|
-
let i = 0;
|
|
220
|
-
// Accumulated start of current literal-text run (null = no run in progress)
|
|
221
|
-
let textStart: number | null = null;
|
|
222
|
-
|
|
223
|
-
function flushText(endAt: number): void {
|
|
224
|
-
if (textStart === null) return;
|
|
225
|
-
if (endAt > textStart) {
|
|
226
|
-
out.push({
|
|
227
|
-
kind: "text",
|
|
228
|
-
raw: text.slice(textStart, endAt),
|
|
229
|
-
start: textStart,
|
|
230
|
-
end: endAt,
|
|
231
|
-
meta: {},
|
|
232
|
-
});
|
|
233
|
-
}
|
|
234
|
-
textStart = null;
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
while (i < N) {
|
|
238
|
-
const ch = text[i];
|
|
239
|
-
|
|
240
|
-
// -- Escape sequences: $$ -> literal $, @@ -> literal @ ----------------
|
|
241
|
-
if (ch === "$" && i + 1 < N && text[i + 1] === "$") {
|
|
242
|
-
flushText(i);
|
|
243
|
-
out.push({ kind: "escape", raw: "$$", start: i, end: i + 2, meta: { literal: "$" } });
|
|
244
|
-
i += 2;
|
|
245
|
-
continue;
|
|
246
|
-
}
|
|
247
|
-
if (ch === "@" && i + 1 < N && text[i + 1] === "@") {
|
|
248
|
-
flushText(i);
|
|
249
|
-
out.push({ kind: "escape", raw: "@@", start: i, end: i + 2, meta: { literal: "@" } });
|
|
250
|
-
i += 2;
|
|
251
|
-
continue;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
// -- Variable: $name or $name.K (SP2a list accessor) --------------------
|
|
255
|
-
if (ch === "$") {
|
|
256
|
-
// `.match` (not `.exec`) keeps the matcher off the security-hook's radar
|
|
257
|
-
// while giving the same match-array shape. Group 2 = optional `.K` index.
|
|
258
|
-
// Group 3 = optional `.neg` (schema v8 negatives read).
|
|
259
|
-
const m = text
|
|
260
|
-
.slice(i + 1)
|
|
261
|
-
.match(/^([A-Za-z_][A-Za-z0-9_]*)(?:\.(\d+))?(\.neg(?![A-Za-z0-9_]))?/);
|
|
262
|
-
if (m) {
|
|
263
|
-
flushText(i);
|
|
264
|
-
const raw =
|
|
265
|
-
"$" + m[1] + (m[2] !== undefined ? "." + m[2] : "") + (m[3] ?? "");
|
|
266
|
-
const meta: { name: string; index?: number; neg?: boolean } = { name: m[1] };
|
|
267
|
-
if (m[2] !== undefined) meta.index = parseInt(m[2], 10);
|
|
268
|
-
if (m[3] !== undefined) meta.neg = true;
|
|
269
|
-
out.push({
|
|
270
|
-
kind: "var",
|
|
271
|
-
raw,
|
|
272
|
-
start: i,
|
|
273
|
-
end: i + raw.length,
|
|
274
|
-
meta,
|
|
275
|
-
});
|
|
276
|
-
i += raw.length;
|
|
277
|
-
continue;
|
|
278
|
-
}
|
|
279
|
-
// Lone $ or $ not followed by ident -- fall through to text accumulation
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
// -- Ref: @{8hex} or @{8hex#name:expr!null} -----------------------------
|
|
283
|
-
// Optional `:expr` is an SP1 boolean sub-category filter (`warm or
|
|
284
|
-
// intense`, `not cool`, parens; comma = or) with an optional trailing
|
|
285
|
-
// `!null` exclude-null marker. The lexer does NOT parse the grammar — it
|
|
286
|
-
// just captures the raw `:`-body, comma-split into `sub_categories` for
|
|
287
|
-
// legacy callers. Downstream (RichTextInput, validateModule) rejoins on
|
|
288
|
-
// `,`, peels `!null`, and hands the rest to the shared subcat-filter
|
|
289
|
-
// parser. Keeps one library wildcard reusable while each call site
|
|
290
|
-
// narrows surgically (e.g. `@{color:warm}` vs `@{color:cool or warm}`).
|
|
291
|
-
// Empty filter → no filter.
|
|
292
|
-
if (ch === "@") {
|
|
293
|
-
// Groups: 1=uuid, 2=optional `#name` cache, 3=optional subcat filter.
|
|
294
|
-
const refMatch = text.slice(i).match(/^@\{([0-9a-f]{8})(?:#([^#:}@{]*))?(?::([^}]*))?\}/);
|
|
295
|
-
if (refMatch) {
|
|
296
|
-
flushText(i);
|
|
297
|
-
const filterRaw = refMatch[3];
|
|
298
|
-
const subCategories =
|
|
299
|
-
typeof filterRaw === "string"
|
|
300
|
-
? filterRaw.split(",").map((s) => s.trim()).filter(Boolean)
|
|
301
|
-
: [];
|
|
302
|
-
const meta: { uuid: string; name?: string; sub_categories?: string[] } = { uuid: refMatch[1] };
|
|
303
|
-
if (refMatch[2]) meta.name = refMatch[2];
|
|
304
|
-
if (subCategories.length > 0) meta.sub_categories = subCategories;
|
|
305
|
-
out.push({
|
|
306
|
-
kind: "ref",
|
|
307
|
-
raw: refMatch[0],
|
|
308
|
-
start: i,
|
|
309
|
-
end: i + refMatch[0].length,
|
|
310
|
-
meta,
|
|
311
|
-
});
|
|
312
|
-
i += refMatch[0].length;
|
|
313
|
-
continue;
|
|
314
|
-
}
|
|
315
|
-
// Not a ref -- fall through to text accumulation
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
// -- Brace block: {a|b|c} or {N$$sep$$a|b|c} ---------------------------
|
|
319
|
-
if (ch === "{") {
|
|
320
|
-
const scanned = scanBraceBlock(text, i);
|
|
321
|
-
if (scanned !== null) {
|
|
322
|
-
const [endIndex, branches, count, sep] = scanned;
|
|
323
|
-
flushText(i);
|
|
324
|
-
if (count === null) {
|
|
325
|
-
out.push({
|
|
326
|
-
kind: "dp-brace",
|
|
327
|
-
raw: text.slice(i, endIndex),
|
|
328
|
-
start: i,
|
|
329
|
-
end: endIndex,
|
|
330
|
-
meta: { branches },
|
|
331
|
-
});
|
|
332
|
-
} else {
|
|
333
|
-
const [cmin, cmax, independent] = count;
|
|
334
|
-
out.push({
|
|
335
|
-
kind: "dp-multi",
|
|
336
|
-
raw: text.slice(i, endIndex),
|
|
337
|
-
start: i,
|
|
338
|
-
end: endIndex,
|
|
339
|
-
// `count` kept == max for back-compat; min/max carry the range,
|
|
340
|
-
// independent carries the `~` flag (SP2b). Mirrors the Python meta.
|
|
341
|
-
meta: { min: cmin, max: cmax, independent, count: cmax, sep: sep ?? "", branches },
|
|
342
|
-
});
|
|
343
|
-
}
|
|
344
|
-
i = endIndex;
|
|
345
|
-
continue;
|
|
346
|
-
}
|
|
347
|
-
// Malformed brace -- fall through to literal text
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
// -- Default: accumulate literal text ------------------------------------
|
|
351
|
-
if (textStart === null) textStart = i;
|
|
352
|
-
i++;
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
flushText(N);
|
|
356
|
-
return out;
|
|
357
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Wildcard Pipeline rich-text tokenizer: the pure tokenizing surface of the
|
|
3
|
+
* extension's `src/widgets/richTokenize.ts` lexer (same grammar). It has no
|
|
4
|
+
* resolution helpers and no HTML emitters: TokenizedValue walks these tokens
|
|
5
|
+
* in a template, so nothing here needs `v-html`.
|
|
6
|
+
*
|
|
7
|
+
* Recognises the following syntactic surface (locked grammar, Tasks 3-7):
|
|
8
|
+
*
|
|
9
|
+
* $varname subject placeholder (var)
|
|
10
|
+
* @{8hex} nested wildcard reference by UUID (ref)
|
|
11
|
+
* $$ / @@ literal-escape sequences (escape)
|
|
12
|
+
* {a|b|c} inline-choice block (dp-brace, single token)
|
|
13
|
+
* {N$$sep$$a|b|c} multi-select with optional separator (dp-multi)
|
|
14
|
+
*
|
|
15
|
+
* Single-token emission for brace blocks: `{a|b|c}` produces ONE `dp-brace`
|
|
16
|
+
* token whose `meta.branches` lists the raw branch strings. This mirrors the
|
|
17
|
+
* Python tokenizer exactly and is the contract the corpus (tests/fixtures/
|
|
18
|
+
* syntax-corpus.json) locks in.
|
|
19
|
+
*
|
|
20
|
+
* Lossless invariant: joining all `.raw` fields reproduces the input exactly.
|
|
21
|
+
*
|
|
22
|
+
* REMOVED (legacy syntax no longer supported):
|
|
23
|
+
* @name short-form ref - use @{8hex} UUID form instead
|
|
24
|
+
* N#$var / N#@ref / N#{...} quantifier prefix
|
|
25
|
+
* # comment to end of line greyed-out comment line
|
|
26
|
+
* dp-pipe / dp-weight sub-tokens inside brace blocks
|
|
27
|
+
*
|
|
28
|
+
* Note: `dp-pipe` and `dp-weight` remain in the TokenKind union for backwards
|
|
29
|
+
* compatibility with any consumers that reference the type, but the tokenizer
|
|
30
|
+
* no longer emits them.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
export type TokenKind =
|
|
34
|
+
| "text"
|
|
35
|
+
| "var"
|
|
36
|
+
| "ref"
|
|
37
|
+
| "dp-brace"
|
|
38
|
+
| "dp-pipe" // retained in type only; not emitted by tokenizeRich
|
|
39
|
+
| "dp-multi"
|
|
40
|
+
| "dp-weight" // retained in type only; not emitted by tokenizeRich
|
|
41
|
+
| "escape";
|
|
42
|
+
// REMOVED: "comment", "quantifier" - dropped in locked grammar (Tasks 3-5)
|
|
43
|
+
|
|
44
|
+
export interface TokenMeta {
|
|
45
|
+
// var tokens
|
|
46
|
+
name?: string;
|
|
47
|
+
/** SP2a list accessor: `$name.K` -> 0-based index K (omitted when absent). */
|
|
48
|
+
index?: number;
|
|
49
|
+
/** Schema v8: `$name.neg` / `$name.K.neg` reads the variable's negatives. */
|
|
50
|
+
neg?: boolean;
|
|
51
|
+
// ref tokens
|
|
52
|
+
uuid?: string;
|
|
53
|
+
sub_categories?: string[];
|
|
54
|
+
// escape tokens
|
|
55
|
+
literal?: string;
|
|
56
|
+
// dp-brace / dp-multi tokens
|
|
57
|
+
branches?: string[];
|
|
58
|
+
// dp-multi tokens
|
|
59
|
+
count?: number;
|
|
60
|
+
sep?: string;
|
|
61
|
+
/** SP2b: count range — `min`==`max` for a fixed `{N$$}`, `min<max` for a
|
|
62
|
+
* `{N-M$$}` range. `independent` is the `~` flag (repeats allowed). */
|
|
63
|
+
min?: number;
|
|
64
|
+
max?: number;
|
|
65
|
+
independent?: boolean;
|
|
66
|
+
// dp-weight tokens (type kept for compat; not emitted)
|
|
67
|
+
weight?: number;
|
|
68
|
+
range?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** SP2a: reduce a var reference to its BASE name — drop an optional leading
|
|
72
|
+
* `$` and an optional trailing `.K` list accessor. `$mood.0` -> `mood`;
|
|
73
|
+
* `mood` -> `mood`; `weird.name` (non-digit suffix) -> unchanged. Used by
|
|
74
|
+
* validation + conflict scanning so a `.K` accessor resolves against the
|
|
75
|
+
* bound base var, not a phantom `mood.0`. */
|
|
76
|
+
export function varBaseName(raw: string): string {
|
|
77
|
+
return raw.replace(/^\$/, "").replace(/\.neg$/, "").replace(/\.\d+$/, "").trim();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface RichToken {
|
|
81
|
+
kind: TokenKind;
|
|
82
|
+
start: number;
|
|
83
|
+
end: number;
|
|
84
|
+
raw: string;
|
|
85
|
+
meta?: TokenMeta;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Peeled view of a nested ref's `:`-filter body. */
|
|
89
|
+
export interface RefFilterParts {
|
|
90
|
+
/** Boolean sub-category expression, with the `!null` marker removed. */
|
|
91
|
+
expr: string;
|
|
92
|
+
/** True when the body carried the trailing `!null` exclude-null marker. */
|
|
93
|
+
excludeNull: boolean;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Peel a trailing `!null` exclude marker off a ref's raw `:`-body, returning
|
|
98
|
+
* the pure boolean expression + the exclude-null flag:
|
|
99
|
+
* `"warm or intense!null"` → `{ expr: "warm or intense", excludeNull: true }`
|
|
100
|
+
* `"warm or cool"` → `{ expr: "warm or cool", excludeNull: false }`
|
|
101
|
+
*
|
|
102
|
+
* Sub-category names and the boolean expression never contain `!` (see the
|
|
103
|
+
* grammar in `subcatFilter.ts`), so the only `!` in a serialized body is the
|
|
104
|
+
* null marker. Single source for the peel that RefChip, the canvas OptionRow,
|
|
105
|
+
* and RichTextInput's editor model all need — keeps them from re-deriving it
|
|
106
|
+
* (and drifting) independently.
|
|
107
|
+
*/
|
|
108
|
+
export function splitRefFilter(body: string): RefFilterParts {
|
|
109
|
+
const bang = body.lastIndexOf("!");
|
|
110
|
+
if (bang >= 0) {
|
|
111
|
+
return { expr: body.slice(0, bang).trim(), excludeNull: body.slice(bang + 1) === "null" };
|
|
112
|
+
}
|
|
113
|
+
return { expr: body.trim(), excludeNull: false };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// Internal helpers (mirrors Python engine/syntax/tokenize.py)
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Split `s` on `|` at brace-depth zero only. Nested `{a|b}` stays intact.
|
|
122
|
+
* Mirrors Python `_split_top_level_pipes`.
|
|
123
|
+
*/
|
|
124
|
+
function splitTopLevelPipes(s: string): string[] {
|
|
125
|
+
const parts: string[] = [];
|
|
126
|
+
let depth = 0;
|
|
127
|
+
let last = 0;
|
|
128
|
+
for (let idx = 0; idx < s.length; idx++) {
|
|
129
|
+
const c = s[idx];
|
|
130
|
+
if (c === "{") depth++;
|
|
131
|
+
else if (c === "}") depth--;
|
|
132
|
+
else if (c === "|" && depth === 0) {
|
|
133
|
+
parts.push(s.slice(last, idx));
|
|
134
|
+
last = idx + 1;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
parts.push(s.slice(last));
|
|
138
|
+
return parts;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Multi-pick prefix: {N$$sep$$ or {N-M~$$sep$$ — group 1 is the count (fixed
|
|
142
|
+
// `N` or a `N-M` range), group 2 is the optional `~` independent flag (absent
|
|
143
|
+
// = unique), group 3 is the separator. Applied against the whole raw block
|
|
144
|
+
// including the leading `{`. SP2b.
|
|
145
|
+
const MULTI_PREFIX_RE = /^\{(\d+(?:-\d+)?)(~?)\$\$([\s\S]*?)\$\$/;
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Try to scan a `{...}` block starting at `text[start]` (must be `{`).
|
|
149
|
+
*
|
|
150
|
+
* Returns `[endIndex, branches, count | null, sep | null]` on success, or
|
|
151
|
+
* `null` if the block is malformed (unclosed, or no top-level pipes for a
|
|
152
|
+
* plain pick). Mirrors Python `_scan_brace_block`.
|
|
153
|
+
*/
|
|
154
|
+
function scanBraceBlock(
|
|
155
|
+
text: string,
|
|
156
|
+
start: number,
|
|
157
|
+
): [number, string[], [number, number, boolean] | null, string | null] | null {
|
|
158
|
+
const n = text.length;
|
|
159
|
+
if (start >= n || text[start] !== "{") return null;
|
|
160
|
+
|
|
161
|
+
// Walk to the matching `}` tracking depth.
|
|
162
|
+
let i = start + 1;
|
|
163
|
+
let depth = 1;
|
|
164
|
+
while (i < n && depth > 0) {
|
|
165
|
+
const c = text[i];
|
|
166
|
+
if (c === "{") depth++;
|
|
167
|
+
else if (c === "}") depth--;
|
|
168
|
+
if (depth > 0) i++;
|
|
169
|
+
}
|
|
170
|
+
if (depth !== 0) return null; // unclosed
|
|
171
|
+
|
|
172
|
+
const bodyStart = start + 1;
|
|
173
|
+
const bodyEnd = i; // index of closing `}`
|
|
174
|
+
const endIndex = i + 1;
|
|
175
|
+
const body = text.slice(bodyStart, bodyEnd);
|
|
176
|
+
|
|
177
|
+
// Detect multi-pick prefix: {N$$sep$$...
|
|
178
|
+
const raw = text.slice(start, endIndex);
|
|
179
|
+
const multiMatch = raw.match(MULTI_PREFIX_RE);
|
|
180
|
+
if (multiMatch) {
|
|
181
|
+
const countRaw = multiMatch[1]; // "N" or "N-M"
|
|
182
|
+
const independent = multiMatch[2] === "~"; // SP2b: repeats allowed
|
|
183
|
+
const sep = multiMatch[3];
|
|
184
|
+
// Prefix length excluding the leading `{`
|
|
185
|
+
const prefixLen = multiMatch[0].length - 1;
|
|
186
|
+
const rest = body.slice(prefixLen);
|
|
187
|
+
const branches = splitTopLevelPipes(rest);
|
|
188
|
+
const [lo, hi] = countRaw.includes("-")
|
|
189
|
+
? countRaw.split("-")
|
|
190
|
+
: [countRaw, countRaw];
|
|
191
|
+
let cmin = parseInt(lo, 10);
|
|
192
|
+
let cmax = parseInt(hi, 10);
|
|
193
|
+
if (cmin > cmax) { const t = cmin; cmin = cmax; cmax = t; }
|
|
194
|
+
return [endIndex, branches, [cmin, cmax, independent], sep];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Plain pick: must contain at least one top-level `|`
|
|
198
|
+
const branches = splitTopLevelPipes(body);
|
|
199
|
+
if (branches.length < 2) return null; // fall back to literal text
|
|
200
|
+
|
|
201
|
+
return [endIndex, branches, null, null];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
// Public API
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Walk `text` once and emit a flat list of {@link RichToken}s.
|
|
210
|
+
*
|
|
211
|
+
* Token shapes mirror the Python tokenizer (`engine/syntax/tokenize.py`) so
|
|
212
|
+
* the corpus contract holds across both languages.
|
|
213
|
+
*/
|
|
214
|
+
export function tokenizeRich(text: string): RichToken[] {
|
|
215
|
+
const out: RichToken[] = [];
|
|
216
|
+
if (!text) return out;
|
|
217
|
+
|
|
218
|
+
const N = text.length;
|
|
219
|
+
let i = 0;
|
|
220
|
+
// Accumulated start of current literal-text run (null = no run in progress)
|
|
221
|
+
let textStart: number | null = null;
|
|
222
|
+
|
|
223
|
+
function flushText(endAt: number): void {
|
|
224
|
+
if (textStart === null) return;
|
|
225
|
+
if (endAt > textStart) {
|
|
226
|
+
out.push({
|
|
227
|
+
kind: "text",
|
|
228
|
+
raw: text.slice(textStart, endAt),
|
|
229
|
+
start: textStart,
|
|
230
|
+
end: endAt,
|
|
231
|
+
meta: {},
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
textStart = null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
while (i < N) {
|
|
238
|
+
const ch = text[i];
|
|
239
|
+
|
|
240
|
+
// -- Escape sequences: $$ -> literal $, @@ -> literal @ ----------------
|
|
241
|
+
if (ch === "$" && i + 1 < N && text[i + 1] === "$") {
|
|
242
|
+
flushText(i);
|
|
243
|
+
out.push({ kind: "escape", raw: "$$", start: i, end: i + 2, meta: { literal: "$" } });
|
|
244
|
+
i += 2;
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
if (ch === "@" && i + 1 < N && text[i + 1] === "@") {
|
|
248
|
+
flushText(i);
|
|
249
|
+
out.push({ kind: "escape", raw: "@@", start: i, end: i + 2, meta: { literal: "@" } });
|
|
250
|
+
i += 2;
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// -- Variable: $name or $name.K (SP2a list accessor) --------------------
|
|
255
|
+
if (ch === "$") {
|
|
256
|
+
// `.match` (not `.exec`) keeps the matcher off the security-hook's radar
|
|
257
|
+
// while giving the same match-array shape. Group 2 = optional `.K` index.
|
|
258
|
+
// Group 3 = optional `.neg` (schema v8 negatives read).
|
|
259
|
+
const m = text
|
|
260
|
+
.slice(i + 1)
|
|
261
|
+
.match(/^([A-Za-z_][A-Za-z0-9_]*)(?:\.(\d+))?(\.neg(?![A-Za-z0-9_]))?/);
|
|
262
|
+
if (m) {
|
|
263
|
+
flushText(i);
|
|
264
|
+
const raw =
|
|
265
|
+
"$" + m[1] + (m[2] !== undefined ? "." + m[2] : "") + (m[3] ?? "");
|
|
266
|
+
const meta: { name: string; index?: number; neg?: boolean } = { name: m[1] };
|
|
267
|
+
if (m[2] !== undefined) meta.index = parseInt(m[2], 10);
|
|
268
|
+
if (m[3] !== undefined) meta.neg = true;
|
|
269
|
+
out.push({
|
|
270
|
+
kind: "var",
|
|
271
|
+
raw,
|
|
272
|
+
start: i,
|
|
273
|
+
end: i + raw.length,
|
|
274
|
+
meta,
|
|
275
|
+
});
|
|
276
|
+
i += raw.length;
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
// Lone $ or $ not followed by ident -- fall through to text accumulation
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// -- Ref: @{8hex} or @{8hex#name:expr!null} -----------------------------
|
|
283
|
+
// Optional `:expr` is an SP1 boolean sub-category filter (`warm or
|
|
284
|
+
// intense`, `not cool`, parens; comma = or) with an optional trailing
|
|
285
|
+
// `!null` exclude-null marker. The lexer does NOT parse the grammar — it
|
|
286
|
+
// just captures the raw `:`-body, comma-split into `sub_categories` for
|
|
287
|
+
// legacy callers. Downstream (RichTextInput, validateModule) rejoins on
|
|
288
|
+
// `,`, peels `!null`, and hands the rest to the shared subcat-filter
|
|
289
|
+
// parser. Keeps one library wildcard reusable while each call site
|
|
290
|
+
// narrows surgically (e.g. `@{color:warm}` vs `@{color:cool or warm}`).
|
|
291
|
+
// Empty filter → no filter.
|
|
292
|
+
if (ch === "@") {
|
|
293
|
+
// Groups: 1=uuid, 2=optional `#name` cache, 3=optional subcat filter.
|
|
294
|
+
const refMatch = text.slice(i).match(/^@\{([0-9a-f]{8})(?:#([^#:}@{]*))?(?::([^}]*))?\}/);
|
|
295
|
+
if (refMatch) {
|
|
296
|
+
flushText(i);
|
|
297
|
+
const filterRaw = refMatch[3];
|
|
298
|
+
const subCategories =
|
|
299
|
+
typeof filterRaw === "string"
|
|
300
|
+
? filterRaw.split(",").map((s) => s.trim()).filter(Boolean)
|
|
301
|
+
: [];
|
|
302
|
+
const meta: { uuid: string; name?: string; sub_categories?: string[] } = { uuid: refMatch[1] };
|
|
303
|
+
if (refMatch[2]) meta.name = refMatch[2];
|
|
304
|
+
if (subCategories.length > 0) meta.sub_categories = subCategories;
|
|
305
|
+
out.push({
|
|
306
|
+
kind: "ref",
|
|
307
|
+
raw: refMatch[0],
|
|
308
|
+
start: i,
|
|
309
|
+
end: i + refMatch[0].length,
|
|
310
|
+
meta,
|
|
311
|
+
});
|
|
312
|
+
i += refMatch[0].length;
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
// Not a ref -- fall through to text accumulation
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// -- Brace block: {a|b|c} or {N$$sep$$a|b|c} ---------------------------
|
|
319
|
+
if (ch === "{") {
|
|
320
|
+
const scanned = scanBraceBlock(text, i);
|
|
321
|
+
if (scanned !== null) {
|
|
322
|
+
const [endIndex, branches, count, sep] = scanned;
|
|
323
|
+
flushText(i);
|
|
324
|
+
if (count === null) {
|
|
325
|
+
out.push({
|
|
326
|
+
kind: "dp-brace",
|
|
327
|
+
raw: text.slice(i, endIndex),
|
|
328
|
+
start: i,
|
|
329
|
+
end: endIndex,
|
|
330
|
+
meta: { branches },
|
|
331
|
+
});
|
|
332
|
+
} else {
|
|
333
|
+
const [cmin, cmax, independent] = count;
|
|
334
|
+
out.push({
|
|
335
|
+
kind: "dp-multi",
|
|
336
|
+
raw: text.slice(i, endIndex),
|
|
337
|
+
start: i,
|
|
338
|
+
end: endIndex,
|
|
339
|
+
// `count` kept == max for back-compat; min/max carry the range,
|
|
340
|
+
// independent carries the `~` flag (SP2b). Mirrors the Python meta.
|
|
341
|
+
meta: { min: cmin, max: cmax, independent, count: cmax, sep: sep ?? "", branches },
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
i = endIndex;
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
// Malformed brace -- fall through to literal text
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// -- Default: accumulate literal text ------------------------------------
|
|
351
|
+
if (textStart === null) textStart = i;
|
|
352
|
+
i++;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
flushText(N);
|
|
356
|
+
return out;
|
|
357
|
+
}
|