Skip to content

Commit d744b5d

Browse files
committed
Surface duplicate token conflicts in registry
1 parent 26e12e3 commit d744b5d

File tree

3 files changed

+182
-14
lines changed

3 files changed

+182
-14
lines changed

supersede-css-jlg-enhanced/assets/js/tokens.js

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -133,10 +133,6 @@
133133
}
134134

135135
function notifyDuplicateError(labels, customMessage) {
136-
if (typeof window.sscToast !== 'function') {
137-
return;
138-
}
139-
140136
const fallbackMessage = i18n.duplicateError || 'Certains tokens utilisent le même nom. Corrigez les doublons avant d’enregistrer.';
141137
const message = (typeof customMessage === 'string' && customMessage.trim() !== '') ? customMessage : fallbackMessage;
142138
const normalizedLabels = Array.isArray(labels)
@@ -155,7 +151,13 @@
155151
finalMessage = message + ' ' + prefix + ' ' + normalizedLabels.join(', ');
156152
}
157153

158-
window.sscToast(finalMessage, { politeness: 'assertive', role: 'alert' });
154+
if (typeof window.sscToast === 'function') {
155+
window.sscToast(finalMessage, { politeness: 'assertive', role: 'alert' });
156+
}
157+
158+
if (window.wp && window.wp.a11y && typeof window.wp.a11y.speak === 'function') {
159+
window.wp.a11y.speak(finalMessage, 'assertive');
160+
}
159161
}
160162

161163
function handleDuplicateConflict(duplicateKeys, labels, message) {
@@ -197,6 +199,18 @@
197199
labels.push(trimmed);
198200
}
199201
});
202+
}
203+
204+
if (Array.isArray(item.conflicts)) {
205+
item.conflicts.forEach(function(conflict) {
206+
if (!conflict || typeof conflict !== 'object') {
207+
return;
208+
}
209+
const rawName = typeof conflict.name === 'string' ? conflict.name.trim() : '';
210+
if (rawName !== '' && labels.indexOf(rawName) === -1) {
211+
labels.push(rawName);
212+
}
213+
});
200214
} else if (typeof item.canonical === 'string') {
201215
const trimmedCanonical = item.canonical.trim();
202216
if (trimmedCanonical !== '' && labels.indexOf(trimmedCanonical) === -1) {

supersede-css-jlg-enhanced/src/Support/TokenRegistry.php

Lines changed: 46 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ public static function getDefaultRegistry(): array
102102

103103
/**
104104
* @param array<int, array{name?: mixed, value?: mixed, type?: mixed, description?: mixed, group?: mixed}> $tokens
105-
* @return array{tokens: array<int, array{name: string, value: string, type: string, description: string, group: string}>, duplicates: array<int, array{canonical: string, variants: array<int, string>}>}
105+
* @return array{tokens: array<int, array{name: string, value: string, type: string, description: string, group: string}>, duplicates: array<int, array{canonical: string, variants: array<int, string>, conflicts: array<int, array{name: string, value: string}>}>}
106106
*/
107107
public static function saveRegistry(array $tokens): array
108108
{
@@ -139,13 +139,14 @@ public static function getSupportedTypes(): array
139139

140140
/**
141141
* @param array<int, array{name?: mixed, value?: mixed, type?: mixed, description?: mixed, group?: mixed}> $tokens
142-
* @return array{tokens: array<int, array{name: string, value: string, type: string, description: string, group: string}>, duplicates: array<int, array{canonical: string, variants: array<int, string>}>}
142+
* @return array{tokens: array<int, array{name: string, value: string, type: string, description: string, group: string}>, duplicates: array<int, array{canonical: string, variants: array<int, string>, conflicts: array<int, array{name: string, value: string}>}>}
143143
*/
144144
public static function normalizeRegistry(array $tokens): array
145145
{
146146
$normalizedByName = [];
147147
$duplicateKeys = [];
148148
$variantsByKey = [];
149+
$conflictTokensByKey = [];
149150

150151
foreach ($tokens as $token) {
151152
if (!is_array($token)) {
@@ -172,6 +173,10 @@ public static function normalizeRegistry(array $tokens): array
172173
$variantsByKey[$normalizedKey] = [];
173174
}
174175
$variantsByKey[$normalizedKey][] = $normalizedName;
176+
if (!isset($conflictTokensByKey[$normalizedKey])) {
177+
$conflictTokensByKey[$normalizedKey] = [];
178+
}
179+
$conflictTokensByKey[$normalizedKey][] = $token;
175180

176181
$valueRaw = isset($token['value']) ? (string) $token['value'] : '';
177182
$value = trim(sanitize_textarea_field($valueRaw));
@@ -200,7 +205,7 @@ public static function normalizeRegistry(array $tokens): array
200205

201206
if (array_key_exists($normalizedKey, $normalizedByName)) {
202207
$duplicateKeys[$normalizedKey] = true;
203-
unset($normalizedByName[$normalizedKey]);
208+
continue;
204209
}
205210

206211
$normalizedByName[$normalizedKey] = $normalizedToken;
@@ -211,10 +216,46 @@ public static function normalizeRegistry(array $tokens): array
211216
foreach (array_keys($duplicateKeys) as $duplicateKey) {
212217
$variants = $variantsByKey[$duplicateKey] ?? [];
213218
$variants = array_values(array_unique($variants));
219+
$canonical = $normalizedByName[$duplicateKey]['name'] ?? ($variants[0] ?? $duplicateKey);
220+
221+
$conflictDetails = array_values(array_filter(array_map(
222+
static function (array $original): ?array {
223+
if (!is_array($original)) {
224+
return null;
225+
}
226+
227+
return [
228+
'name' => isset($original['name']) ? (string) $original['name'] : '',
229+
'value' => isset($original['value']) ? (string) $original['value'] : '',
230+
];
231+
},
232+
$conflictTokensByKey[$duplicateKey] ?? []
233+
)));
234+
235+
$uniqueConflicts = [];
236+
foreach ($conflictDetails as $conflict) {
237+
if (!isset($conflict['name'])) {
238+
continue;
239+
}
240+
$nameValue = trim((string) $conflict['name']);
241+
if ($nameValue === '') {
242+
continue;
243+
}
244+
$nameKey = strtolower($nameValue);
245+
$valueKey = isset($conflict['value']) ? trim((string) $conflict['value']) : '';
246+
$hash = $nameKey . '|' . $valueKey;
247+
if (!isset($uniqueConflicts[$hash])) {
248+
$uniqueConflicts[$hash] = [
249+
'name' => $nameValue,
250+
'value' => $valueKey,
251+
];
252+
}
253+
}
214254

215255
$duplicates[] = [
216-
'canonical' => $duplicateKey,
256+
'canonical' => $canonical,
217257
'variants' => $variants,
258+
'conflicts' => array_values($uniqueConflicts),
218259
];
219260
}
220261

@@ -397,11 +438,7 @@ public static function convertCssToRegistry(string $css): array
397438
'group' => 'Legacy',
398439
];
399440

400-
if (array_key_exists($name, $tokensByName)) {
401-
unset($tokensByName[$name]);
402-
}
403-
404-
$tokensByName[$name] = $token;
441+
$tokensByName[] = $token;
405442
}
406443

407444
if ($index < $length && ($css[$index] === ';' || $css[$index] === '}')) {

supersede-css-jlg-enhanced/tests/ui/tokens.spec.js

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,25 @@ test.describe('Token manager admin UI', () => {
230230
};
231231
});
232232

233+
await page.evaluate(() => {
234+
const spoken = [];
235+
window.__sscTestSpokenMessages = spoken;
236+
if (!window.wp) {
237+
window.wp = {};
238+
}
239+
if (!window.wp.a11y) {
240+
window.wp.a11y = {};
241+
}
242+
const originalSpeak = typeof window.wp.a11y.speak === 'function' ? window.wp.a11y.speak : null;
243+
window.wp.a11y.speak = function speakOverride(message, politeness) {
244+
spoken.push(String(message));
245+
if (originalSpeak) {
246+
return originalSpeak.call(this, message, politeness);
247+
}
248+
return undefined;
249+
};
250+
});
251+
233252
const observedRequests = [];
234253
const requestListener = (request) => {
235254
if (request.url().startsWith(tokensEndpoint) && request.method() === 'POST') {
@@ -248,6 +267,9 @@ test.describe('Token manager admin UI', () => {
248267
const duplicateToast = page.locator('.ssc-toast').last();
249268
await expect(duplicateToast).toHaveText(expectedDuplicateToast);
250269

270+
const spokenMessages = await page.evaluate(() => window.__sscTestSpokenMessages || []);
271+
expect(spokenMessages).toContain(expectedDuplicateToast);
272+
251273
const firstRowName = rows.first().locator('.token-name');
252274
await expect(firstRowName).toHaveAttribute('aria-invalid', 'true');
253275
await expect(duplicateRow.locator('.token-name')).toHaveAttribute('aria-invalid', 'true');
@@ -289,4 +311,99 @@ test.describe('Token manager admin UI', () => {
289311
const names = finalJson.tokens.map((token) => token.name).sort();
290312
expect(names).toEqual(['--primary-color', '--secondary-color']);
291313
});
314+
315+
test('API surfaces duplicate conflicts when normalization detects collisions', async ({ page }, testInfo) => {
316+
const adminTokensUrl = getAdminTokensUrl(testInfo);
317+
318+
await authenticate(page, adminTokensUrl, {
319+
username: DEFAULT_USERNAME,
320+
password: DEFAULT_PASSWORD,
321+
});
322+
323+
let { restRoot, nonce } = await waitForPluginReady(page);
324+
let tokensEndpoint = new URL('tokens', restRoot).toString();
325+
326+
const baseTokens = [
327+
{
328+
name: '--primary-color',
329+
value: '#123456',
330+
type: 'color',
331+
description: 'Primary brand color',
332+
group: 'Brand',
333+
},
334+
{
335+
name: '--secondary-color',
336+
value: '#abcdef',
337+
type: 'color',
338+
description: 'Secondary brand color',
339+
group: 'Brand',
340+
},
341+
];
342+
343+
await seedTokens(page, tokensEndpoint, nonce, baseTokens);
344+
345+
await page.reload({ waitUntil: 'networkidle' });
346+
({ restRoot, nonce } = await waitForPluginReady(page));
347+
tokensEndpoint = new URL('tokens', restRoot).toString();
348+
349+
const beforeResponse = await page.request.get(tokensEndpoint, {
350+
headers: {
351+
'X-WP-Nonce': nonce,
352+
},
353+
});
354+
expect(beforeResponse.ok()).toBeTruthy();
355+
const beforeJson = await beforeResponse.json();
356+
const beforeSnapshot = JSON.stringify(beforeJson.tokens);
357+
358+
const duplicatePayload = [
359+
{
360+
name: '--SpacingLarge',
361+
value: '4rem',
362+
type: 'text',
363+
description: 'Large spacing token',
364+
group: 'Spacing',
365+
},
366+
{
367+
name: 'spacing-large',
368+
value: '6rem',
369+
type: 'text',
370+
description: 'Duplicate spacing token',
371+
group: 'Spacing',
372+
},
373+
];
374+
375+
const duplicateResponse = await page.request.post(tokensEndpoint, {
376+
headers: {
377+
'Content-Type': 'application/json',
378+
'X-WP-Nonce': nonce,
379+
},
380+
data: { tokens: duplicatePayload },
381+
});
382+
383+
expect(duplicateResponse.status()).toBe(422);
384+
const duplicateJson = await duplicateResponse.json();
385+
expect(duplicateJson.ok).toBeFalsy();
386+
expect(Array.isArray(duplicateJson.duplicates)).toBeTruthy();
387+
expect(duplicateJson.duplicates.length).toBeGreaterThan(0);
388+
const firstDuplicate = duplicateJson.duplicates[0];
389+
expect(firstDuplicate.canonical).toBe('--SpacingLarge');
390+
expect(Array.isArray(firstDuplicate.variants)).toBeTruthy();
391+
expect(firstDuplicate.variants).toEqual(
392+
expect.arrayContaining(['--SpacingLarge', '--spacing-large'])
393+
);
394+
expect(Array.isArray(firstDuplicate.conflicts)).toBeTruthy();
395+
const conflictNames = firstDuplicate.conflicts.map((conflict) => conflict.name);
396+
expect(conflictNames).toEqual(
397+
expect.arrayContaining(['--SpacingLarge', 'spacing-large'])
398+
);
399+
400+
const afterResponse = await page.request.get(tokensEndpoint, {
401+
headers: {
402+
'X-WP-Nonce': nonce,
403+
},
404+
});
405+
expect(afterResponse.ok()).toBeTruthy();
406+
const afterJson = await afterResponse.json();
407+
expect(JSON.stringify(afterJson.tokens)).toBe(beforeSnapshot);
408+
});
292409
});

0 commit comments

Comments
 (0)