@hexclave/shared 1.0.14 → 1.0.15
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/dist/apps/apps-config.d.ts +6 -0
- package/dist/apps/apps-config.js +6 -0
- package/dist/apps/apps-config.js.map +1 -1
- package/dist/config/schema.d.ts +68 -68
- package/dist/config-authoring.d.ts.map +1 -1
- package/dist/config-authoring.js.map +1 -1
- package/dist/config-authoring.test.js +1 -1
- package/dist/config-authoring.test.js.map +1 -1
- package/dist/config-rendering.d.ts +2 -2
- package/dist/config-rendering.js +16 -0
- package/dist/config-rendering.js.map +1 -1
- package/dist/esm/apps/apps-config.d.ts +6 -0
- package/dist/esm/apps/apps-config.js +6 -0
- package/dist/esm/apps/apps-config.js.map +1 -1
- package/dist/esm/config/schema.d.ts +68 -68
- package/dist/esm/config-authoring.d.ts.map +1 -1
- package/dist/esm/config-authoring.js.map +1 -1
- package/dist/esm/config-authoring.test.js +1 -1
- package/dist/esm/config-authoring.test.js.map +1 -1
- package/dist/esm/config-rendering.d.ts +2 -2
- package/dist/esm/config-rendering.js +16 -2
- package/dist/esm/config-rendering.js.map +1 -1
- package/dist/esm/hexclave-config-file.d.ts +24 -1
- package/dist/esm/hexclave-config-file.d.ts.map +1 -1
- package/dist/esm/hexclave-config-file.js +69 -2
- package/dist/esm/hexclave-config-file.js.map +1 -1
- package/dist/esm/interface/admin-interface.d.ts +19 -1
- package/dist/esm/interface/admin-interface.d.ts.map +1 -1
- package/dist/esm/interface/admin-interface.js +14 -0
- package/dist/esm/interface/admin-interface.js.map +1 -1
- package/dist/esm/interface/admin-metrics.d.ts +101 -7
- package/dist/esm/interface/admin-metrics.d.ts.map +1 -1
- package/dist/esm/interface/admin-metrics.js +61 -1
- package/dist/esm/interface/admin-metrics.js.map +1 -1
- package/dist/esm/interface/conversations.d.ts +38 -38
- package/dist/esm/interface/crud/current-user.d.ts +2 -2
- package/dist/esm/interface/crud/email-outbox.d.ts +150 -150
- package/dist/esm/interface/crud/invoices.d.ts +2 -2
- package/dist/esm/interface/crud/products.d.ts +4 -4
- package/dist/esm/interface/crud/project-api-keys.d.ts +2 -2
- package/dist/esm/interface/crud/projects.d.ts +51 -51
- package/dist/esm/interface/crud/team-member-profiles.d.ts +10 -10
- package/dist/esm/interface/crud/transactions.d.ts +2 -2
- package/dist/esm/interface/crud/users.d.ts +10 -10
- package/dist/esm/interface/webhooks.d.ts +2 -2
- package/dist/esm/schema-fields.d.ts +3 -3
- package/dist/esm/sessions.d.ts +2 -2
- package/dist/esm/utils/analytics-clickmap-overlay.d.ts +18 -0
- package/dist/esm/utils/analytics-clickmap-overlay.d.ts.map +1 -0
- package/dist/esm/utils/analytics-clickmap-overlay.js +19 -0
- package/dist/esm/utils/analytics-clickmap-overlay.js.map +1 -0
- package/dist/esm/utils/dev-tool.d.ts +31 -0
- package/dist/esm/utils/dev-tool.d.ts.map +1 -0
- package/dist/esm/utils/dev-tool.js +32 -0
- package/dist/esm/utils/dev-tool.js.map +1 -0
- package/dist/esm/utils/dom.d.ts +7 -1
- package/dist/esm/utils/dom.d.ts.map +1 -1
- package/dist/esm/utils/dom.js +20 -1
- package/dist/esm/utils/dom.js.map +1 -1
- package/dist/esm/utils/elements-chain.d.ts +38 -0
- package/dist/esm/utils/elements-chain.d.ts.map +1 -0
- package/dist/esm/utils/elements-chain.js +297 -0
- package/dist/esm/utils/elements-chain.js.map +1 -0
- package/dist/hexclave-config-file.d.ts +24 -1
- package/dist/hexclave-config-file.d.ts.map +1 -1
- package/dist/hexclave-config-file.js +71 -1
- package/dist/hexclave-config-file.js.map +1 -1
- package/dist/interface/admin-interface.d.ts +19 -1
- package/dist/interface/admin-interface.d.ts.map +1 -1
- package/dist/interface/admin-interface.js +14 -0
- package/dist/interface/admin-interface.js.map +1 -1
- package/dist/interface/admin-metrics.d.ts +101 -7
- package/dist/interface/admin-metrics.d.ts.map +1 -1
- package/dist/interface/admin-metrics.js +65 -0
- package/dist/interface/admin-metrics.js.map +1 -1
- package/dist/interface/conversations.d.ts +38 -38
- package/dist/interface/crud/current-user.d.ts +2 -2
- package/dist/interface/crud/email-outbox.d.ts +150 -150
- package/dist/interface/crud/invoices.d.ts +2 -2
- package/dist/interface/crud/products.d.ts +4 -4
- package/dist/interface/crud/project-api-keys.d.ts +2 -2
- package/dist/interface/crud/projects.d.ts +51 -51
- package/dist/interface/crud/team-member-profiles.d.ts +10 -10
- package/dist/interface/crud/transactions.d.ts +2 -2
- package/dist/interface/crud/users.d.ts +10 -10
- package/dist/interface/webhooks.d.ts +2 -2
- package/dist/schema-fields.d.ts +3 -3
- package/dist/sessions.d.ts +2 -2
- package/dist/utils/analytics-clickmap-overlay.d.ts +18 -0
- package/dist/utils/analytics-clickmap-overlay.d.ts.map +1 -0
- package/dist/utils/analytics-clickmap-overlay.js +23 -0
- package/dist/utils/analytics-clickmap-overlay.js.map +1 -0
- package/dist/utils/dev-tool.d.ts +31 -0
- package/dist/utils/dev-tool.d.ts.map +1 -0
- package/dist/utils/dev-tool.js +37 -0
- package/dist/utils/dev-tool.js.map +1 -0
- package/dist/utils/dom.d.ts +7 -1
- package/dist/utils/dom.d.ts.map +1 -1
- package/dist/utils/dom.js +20 -0
- package/dist/utils/dom.js.map +1 -1
- package/dist/utils/elements-chain.d.ts +38 -0
- package/dist/utils/elements-chain.d.ts.map +1 -0
- package/dist/utils/elements-chain.js +304 -0
- package/dist/utils/elements-chain.js.map +1 -0
- package/package.json +1 -1
- package/src/apps/apps-config.ts +6 -0
- package/src/config-authoring.test.ts +1 -1
- package/src/config-authoring.ts +6 -6
- package/src/config-rendering.ts +24 -2
- package/src/hexclave-config-file.ts +90 -1
- package/src/interface/admin-interface.ts +43 -1
- package/src/interface/admin-metrics.ts +92 -4
- package/src/utils/analytics-clickmap-overlay.tsx +15 -0
- package/src/utils/dev-tool.tsx +30 -0
- package/src/utils/dom.tsx +42 -0
- package/src/utils/elements-chain.tsx +349 -0
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PostHog-style `elements_chain` format — the single owner of both halves of the
|
|
3
|
+
* contract. The event tracker {@link buildElementsChain serializes} a clicked
|
|
4
|
+
* element (and its ancestors) into a string; the clickmap overlay
|
|
5
|
+
* {@link parseElementsChain parses} that string back into structured segments so
|
|
6
|
+
* it can re-locate the element in a live DOM.
|
|
7
|
+
*
|
|
8
|
+
* Encode and decode MUST round-trip exactly, which is why they live together:
|
|
9
|
+
* the escaping applied here on the write side is reversed by the parser below,
|
|
10
|
+
* and a single round-trip test in `elements-chain.test.tsx` guards the pair.
|
|
11
|
+
*
|
|
12
|
+
* Segment shape (leaf-first, joined by `;`):
|
|
13
|
+
* tag.class1.class2:nth-child="2":nth-of-type="1":text="Save":attr__id="x":href="..."
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export type ElementsChainSegment = {
|
|
17
|
+
tag: string,
|
|
18
|
+
classes: string[],
|
|
19
|
+
attrs: Record<string, string>,
|
|
20
|
+
text: string | null,
|
|
21
|
+
nthChild: number | null,
|
|
22
|
+
nthOfType: number | null,
|
|
23
|
+
href: string | null,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const ELEMENTS_CHAIN_MAX_DEPTH = 8;
|
|
27
|
+
export const ELEMENTS_CHAIN_TEXT_MAX = 80;
|
|
28
|
+
export const ELEMENTS_CHAIN_ATTR_MAX = 200;
|
|
29
|
+
|
|
30
|
+
// Attributes we serialise into elements_chain. Mirrors the set PostHog persists:
|
|
31
|
+
// stable identifiers (id, data-testid), semantics (role, type, name, aria-label),
|
|
32
|
+
// and a few we expect downstream tooling to want to match against.
|
|
33
|
+
export const ELEMENTS_CHAIN_ATTRS = [
|
|
34
|
+
"id",
|
|
35
|
+
"data-testid",
|
|
36
|
+
"data-test-id",
|
|
37
|
+
"data-hexclave-id",
|
|
38
|
+
"name",
|
|
39
|
+
"type",
|
|
40
|
+
"role",
|
|
41
|
+
"aria-label",
|
|
42
|
+
"placeholder",
|
|
43
|
+
"title",
|
|
44
|
+
] as const;
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Serialization (DOM element -> elements_chain string)
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
function escapeElementsChainValue(value: string): string {
|
|
51
|
+
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Class tokens are written into the unquoted, dot-joined prefix of a segment, so
|
|
55
|
+
// any "." or ":" inside a class (e.g. Tailwind variants like `md:hover:bg-blue-500`
|
|
56
|
+
// or arbitrary values like `w-[1.5rem]`) must be escaped to round-trip through the
|
|
57
|
+
// parser, which splits the prefix on unescaped "." and the segment on unescaped ":".
|
|
58
|
+
function escapeElementsChainClass(value: string): string {
|
|
59
|
+
return value.replace(/\\/g, "\\\\").replace(/\./g, "\\.").replace(/:/g, "\\:");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function getElementClasses(element: Element): string[] {
|
|
63
|
+
const className = (element as HTMLElement).className;
|
|
64
|
+
if (typeof className !== "string" || className.trim() === "") {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
return className.trim().split(/\s+/).filter(Boolean).slice(0, 4);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function getNthChildIndex(element: Element): number | null {
|
|
71
|
+
const parent = element.parentElement;
|
|
72
|
+
if (parent == null) return null;
|
|
73
|
+
const index = Array.prototype.indexOf.call(parent.children, element);
|
|
74
|
+
return index >= 0 ? index + 1 : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function getNthOfTypeIndex(element: Element): number | null {
|
|
78
|
+
const parent = element.parentElement;
|
|
79
|
+
if (parent == null) return null;
|
|
80
|
+
const tagName = element.tagName;
|
|
81
|
+
const siblings = Array.from(parent.children).filter((child) => child.tagName === tagName);
|
|
82
|
+
if (siblings.length <= 1) return null;
|
|
83
|
+
const index = siblings.indexOf(element);
|
|
84
|
+
return index >= 0 ? index + 1 : null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function serializeElementsChainSegment(element: Element): string {
|
|
88
|
+
const parts: string[] = [];
|
|
89
|
+
parts.push(element.tagName.toLowerCase());
|
|
90
|
+
const classes = getElementClasses(element);
|
|
91
|
+
if (classes.length > 0) {
|
|
92
|
+
parts.push(`.${classes.map(escapeElementsChainClass).join(".")}`);
|
|
93
|
+
}
|
|
94
|
+
const text = element.textContent.trim().replace(/\s+/g, " ").slice(0, ELEMENTS_CHAIN_TEXT_MAX);
|
|
95
|
+
const nthChild = getNthChildIndex(element);
|
|
96
|
+
const nthOfType = getNthOfTypeIndex(element);
|
|
97
|
+
const attrPairs: string[] = [];
|
|
98
|
+
if (nthChild != null) attrPairs.push(`nth-child="${nthChild}"`);
|
|
99
|
+
if (nthOfType != null) attrPairs.push(`nth-of-type="${nthOfType}"`);
|
|
100
|
+
if (text !== "") attrPairs.push(`text="${escapeElementsChainValue(text)}"`);
|
|
101
|
+
for (const attrName of ELEMENTS_CHAIN_ATTRS) {
|
|
102
|
+
const value = element.getAttribute(attrName);
|
|
103
|
+
if (value == null || value === "") continue;
|
|
104
|
+
attrPairs.push(`attr__${attrName}="${escapeElementsChainValue(value.slice(0, ELEMENTS_CHAIN_ATTR_MAX))}"`);
|
|
105
|
+
}
|
|
106
|
+
if (element.tagName === "A") {
|
|
107
|
+
const href = element.getAttribute("href");
|
|
108
|
+
if (href != null && href !== "") {
|
|
109
|
+
attrPairs.push(`href="${escapeElementsChainValue(href.slice(0, ELEMENTS_CHAIN_ATTR_MAX))}"`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (attrPairs.length > 0) {
|
|
113
|
+
parts.push(`:${attrPairs.join(":")}`);
|
|
114
|
+
}
|
|
115
|
+
return parts.join("");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Serialise a clicked element and up to {@link ELEMENTS_CHAIN_MAX_DEPTH}
|
|
120
|
+
* ancestors (leaf-first) into an `elements_chain` string.
|
|
121
|
+
*/
|
|
122
|
+
export function buildElementsChain(element: Element): string {
|
|
123
|
+
const segments: string[] = [];
|
|
124
|
+
let current: Element | null = element;
|
|
125
|
+
let depth = 0;
|
|
126
|
+
while (current != null && depth < ELEMENTS_CHAIN_MAX_DEPTH && current !== document.documentElement) {
|
|
127
|
+
segments.push(serializeElementsChainSegment(current));
|
|
128
|
+
current = current.parentElement;
|
|
129
|
+
depth += 1;
|
|
130
|
+
}
|
|
131
|
+
return segments.join(";");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// Parsing (elements_chain string -> structured segments)
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
// Split a string on unescaped occurrences of `.`, unescaping `\.`, `\:` and `\\`
|
|
139
|
+
// back to their literal characters. Reverses `escapeElementsChainClass`.
|
|
140
|
+
function splitEscapedDots(input: string): string[] {
|
|
141
|
+
const out: string[] = [];
|
|
142
|
+
let cur = '';
|
|
143
|
+
for (let i = 0; i < input.length; i++) {
|
|
144
|
+
const ch = input[i];
|
|
145
|
+
if (ch === '\\' && i + 1 < input.length) {
|
|
146
|
+
cur += input[i + 1];
|
|
147
|
+
i += 1;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (ch === '.') {
|
|
151
|
+
out.push(cur);
|
|
152
|
+
cur = '';
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
cur += ch;
|
|
156
|
+
}
|
|
157
|
+
out.push(cur);
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
type ElementsChainAttrResult = {
|
|
162
|
+
nthChild?: number,
|
|
163
|
+
nthOfType?: number,
|
|
164
|
+
text?: string,
|
|
165
|
+
href?: string,
|
|
166
|
+
attrKey?: string,
|
|
167
|
+
attrValue?: string,
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
function applyElementsChainAttr(key: string, value: string): ElementsChainAttrResult {
|
|
171
|
+
if (key === 'nth-child') {
|
|
172
|
+
const n = Number.parseInt(value, 10);
|
|
173
|
+
return Number.isFinite(n) ? { nthChild: n } : {};
|
|
174
|
+
}
|
|
175
|
+
if (key === 'nth-of-type') {
|
|
176
|
+
const n = Number.parseInt(value, 10);
|
|
177
|
+
return Number.isFinite(n) ? { nthOfType: n } : {};
|
|
178
|
+
}
|
|
179
|
+
if (key === 'text') {
|
|
180
|
+
return { text: value };
|
|
181
|
+
}
|
|
182
|
+
if (key === 'href') {
|
|
183
|
+
return { href: value, attrKey: key, attrValue: value };
|
|
184
|
+
}
|
|
185
|
+
if (key.startsWith('attr__')) {
|
|
186
|
+
return { attrKey: key.slice('attr__'.length), attrValue: value };
|
|
187
|
+
}
|
|
188
|
+
return { attrKey: key, attrValue: value };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function parseElementsChainSegment(segment: string): ElementsChainSegment | null {
|
|
192
|
+
const trimmed = segment.trim();
|
|
193
|
+
if (trimmed === '') return null;
|
|
194
|
+
|
|
195
|
+
// Find first ':' at top level — separates tag/classes prefix from attribute pairs.
|
|
196
|
+
let prefixEnd = trimmed.length;
|
|
197
|
+
let inQuotes = false;
|
|
198
|
+
for (let i = 0; i < trimmed.length; i++) {
|
|
199
|
+
const ch = trimmed[i];
|
|
200
|
+
if (ch === '\\' && i + 1 < trimmed.length) {
|
|
201
|
+
i += 1;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (ch === '"') {
|
|
205
|
+
inQuotes = !inQuotes;
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
if (ch === ':' && !inQuotes) {
|
|
209
|
+
prefixEnd = i;
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const prefix = trimmed.slice(0, prefixEnd);
|
|
215
|
+
const rest = trimmed.slice(prefixEnd);
|
|
216
|
+
const prefixParts = splitEscapedDots(prefix);
|
|
217
|
+
const tag = prefixParts[0].trim().toLowerCase();
|
|
218
|
+
if (tag === '') return null;
|
|
219
|
+
const classes = prefixParts.slice(1).map((c) => c.trim()).filter((c) => c !== '');
|
|
220
|
+
|
|
221
|
+
const attrs: Record<string, string> = {};
|
|
222
|
+
let nthChild: number | null = null;
|
|
223
|
+
let nthOfType: number | null = null;
|
|
224
|
+
let text: string | null = null;
|
|
225
|
+
let href: string | null = null;
|
|
226
|
+
|
|
227
|
+
// Parse :key="value" pairs from rest.
|
|
228
|
+
let i = 0;
|
|
229
|
+
while (i < rest.length) {
|
|
230
|
+
if (rest[i] !== ':') {
|
|
231
|
+
i += 1;
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
i += 1; // skip ':'
|
|
235
|
+
// read key up to '='
|
|
236
|
+
let keyEnd = i;
|
|
237
|
+
while (keyEnd < rest.length && rest[keyEnd] !== '=' && rest[keyEnd] !== ':') keyEnd += 1;
|
|
238
|
+
const key = rest.slice(i, keyEnd).trim();
|
|
239
|
+
if (keyEnd >= rest.length || rest[keyEnd] !== '=') {
|
|
240
|
+
i = keyEnd;
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
let valStart = keyEnd + 1;
|
|
244
|
+
if (rest[valStart] !== '"') {
|
|
245
|
+
// unquoted — read until next ':' at top level
|
|
246
|
+
let end = valStart;
|
|
247
|
+
while (end < rest.length && rest[end] !== ':') end += 1;
|
|
248
|
+
const value = rest.slice(valStart, end);
|
|
249
|
+
const result = applyElementsChainAttr(key, value);
|
|
250
|
+
if (result.nthChild != null) nthChild = result.nthChild;
|
|
251
|
+
if (result.nthOfType != null) nthOfType = result.nthOfType;
|
|
252
|
+
if (result.text != null) text = result.text;
|
|
253
|
+
if (result.href != null) href = result.href;
|
|
254
|
+
if (result.attrKey != null) attrs[result.attrKey] = result.attrValue ?? '';
|
|
255
|
+
i = end;
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
// quoted value — find unescaped closing quote
|
|
259
|
+
valStart += 1;
|
|
260
|
+
let end = valStart;
|
|
261
|
+
let value = '';
|
|
262
|
+
while (end < rest.length) {
|
|
263
|
+
const ch = rest[end];
|
|
264
|
+
if (ch === '\\' && end + 1 < rest.length) {
|
|
265
|
+
const next = rest[end + 1];
|
|
266
|
+
if (next === '"' || next === '\\') {
|
|
267
|
+
value += next;
|
|
268
|
+
end += 2;
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
value += ch;
|
|
272
|
+
end += 1;
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
if (ch === '"') break;
|
|
276
|
+
value += ch;
|
|
277
|
+
end += 1;
|
|
278
|
+
}
|
|
279
|
+
const result = applyElementsChainAttr(key, value);
|
|
280
|
+
if (result.nthChild != null) nthChild = result.nthChild;
|
|
281
|
+
if (result.nthOfType != null) nthOfType = result.nthOfType;
|
|
282
|
+
if (result.text != null) text = result.text;
|
|
283
|
+
if (result.href != null) href = result.href;
|
|
284
|
+
if (result.attrKey != null) attrs[result.attrKey] = result.attrValue ?? '';
|
|
285
|
+
i = end + 1; // skip closing quote
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return { tag, classes, attrs, text, nthChild, nthOfType, href };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Parse an `elements_chain` string into structured, leaf-first segments. */
|
|
292
|
+
export function parseElementsChain(chain: string): ElementsChainSegment[] {
|
|
293
|
+
// Split top-level by ';' respecting quoted strings.
|
|
294
|
+
const segments: string[] = [];
|
|
295
|
+
let current = '';
|
|
296
|
+
let inQuotes = false;
|
|
297
|
+
for (let i = 0; i < chain.length; i++) {
|
|
298
|
+
const ch = chain[i];
|
|
299
|
+
if (ch === '\\' && i + 1 < chain.length) {
|
|
300
|
+
current += ch + chain[i + 1];
|
|
301
|
+
i += 1;
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
if (ch === '"') {
|
|
305
|
+
inQuotes = !inQuotes;
|
|
306
|
+
current += ch;
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
if (ch === ';' && !inQuotes) {
|
|
310
|
+
segments.push(current);
|
|
311
|
+
current = '';
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
current += ch;
|
|
315
|
+
}
|
|
316
|
+
if (current.length > 0) {
|
|
317
|
+
segments.push(current);
|
|
318
|
+
}
|
|
319
|
+
return segments.map(parseElementsChainSegment).filter((segment): segment is ElementsChainSegment => segment != null);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
import.meta.vitest?.test("parseElementsChain parses a simple leaf+ancestor chain", ({ expect }) => {
|
|
323
|
+
const parsed = parseElementsChain('button.btn.btn-primary:nth-of-type="1":text="Save":attr__id="save-btn";div.container');
|
|
324
|
+
expect(parsed).toEqual([
|
|
325
|
+
{ tag: "button", classes: ["btn", "btn-primary"], attrs: { id: "save-btn" }, text: "Save", nthChild: null, nthOfType: 1, href: null },
|
|
326
|
+
{ tag: "div", classes: ["container"], attrs: {}, text: null, nthChild: null, nthOfType: null, href: null },
|
|
327
|
+
]);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
import.meta.vitest?.test("parseElementsChain reverses class escaping for Tailwind-style tokens", ({ expect }) => {
|
|
331
|
+
// `md:hover:bg-blue-500` and `w-[1.5rem]` contain the prefix delimiters `:`/`.`,
|
|
332
|
+
// so the serializer escapes them; the parser must recover the literal classes.
|
|
333
|
+
const parsed = parseElementsChain('a.md\\:hover\\:bg-blue-500.w-\\[1\\.5rem\\]:href="/p?a=1"');
|
|
334
|
+
expect(parsed).toEqual([
|
|
335
|
+
{ tag: "a", classes: ["md:hover:bg-blue-500", "w-[1.5rem]"], attrs: { href: "/p?a=1" }, text: null, nthChild: null, nthOfType: null, href: "/p?a=1" },
|
|
336
|
+
]);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
import.meta.vitest?.test("parseElementsChain unescapes quotes/backslashes and ignores ';' inside quoted values", ({ expect }) => {
|
|
340
|
+
const parsed = parseElementsChain('span:text="a \\"b\\"; c \\\\ d"');
|
|
341
|
+
expect(parsed).toEqual([
|
|
342
|
+
{ tag: "span", classes: [], attrs: {}, text: 'a "b"; c \\ d', nthChild: null, nthOfType: null, href: null },
|
|
343
|
+
]);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
import.meta.vitest?.test("parseElementsChain drops empty/tagless segments", ({ expect }) => {
|
|
347
|
+
expect(parseElementsChain("")).toEqual([]);
|
|
348
|
+
expect(parseElementsChain(";;")).toEqual([]);
|
|
349
|
+
});
|