@massimo-cassandro/minimo 1.36.2 → 1.38.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.
@@ -155,217 +155,278 @@ const buildGroupTitle = (name) => {
155
155
  // Exported counter — read by build-tokens.mjs for the final log line
156
156
  export let customPropsCount = 0;
157
157
 
158
- StyleDictionary.registerFormat({
159
- name: 'css/variables-sorted',
160
- format: ({ dictionary, options }) => {
161
- const selector = options.selector ?? ':root';
158
+ // Exported live binding holding the name → declaration-tail map last computed
159
+ // by computeFinalProps() (see below), i.e. the structured result of the most
160
+ // recent 'css/variables-sorted' format call. Read by
161
+ // build-tokens-src/build-source-modes.mjs right after each per-mode
162
+ // sd.formatPlatform('css') call (synchronous, same pattern as
163
+ // customPropsCount) — needed by build-tokens-src/light-dark.mjs to reconcile
164
+ // shared light/dark properties into light-dark() calls, working on the
165
+ // structured props instead of the rendered CSS text.
166
+ export let lastFinalProps = {};
167
+
168
+ /**
169
+ * Computes the final `name -> declaration-tail` map for a token dictionary:
170
+ * resolves each token's CSS value (including the complex-type builders above),
171
+ * then merges in any pre-existing custom properties via mergeCustomProps()
172
+ * (see ../merge-css.mjs and the `mergeCustomProps` config option).
173
+ * @param {object} dictionary Style Dictionary's resolved token dictionary (format() `dictionary` arg)
174
+ * @param {object} options format() `options` arg — only `outputReferences` and `mode` are read here
175
+ * @returns {Record<string,string>} name → declaration tail (value + terminating `;` + optional trailing comment)
176
+ */
177
+ export const computeFinalProps = (dictionary, options) => {
178
+ const tokenByPath = {};
179
+ for (const token of dictionary.allTokens) {
180
+ tokenByPath[token.path.join('.')] = token;
181
+ }
182
+
183
+ const resolveRefs = makeResolveRefs(tokenByPath);
184
+
185
+ const sequenceList = [
186
+ 'xxs', 'xs', 'sm', 'base', 'md', 'lg', 'xl', 'xxl', 'xxxl', '2xl', '3xl',
187
+ 'xlight', 'light', 'regular', 'medium', 'semibold', 'bold', 'xbold'
188
+ ];
189
+
190
+ const sortFunction = (a, b) => {
191
+ const idxA = sequenceList.indexOf(a.name);
192
+ const idxB = sequenceList.indexOf(b.name);
193
+
194
+ // Entrambi nella lista → ordine arbitrario
195
+ if (idxA !== -1 && idxB !== -1) return idxA - idxB;
196
+
197
+ // Solo A nella lista → A viene prima
198
+ if (idxA !== -1) return -1;
199
+
200
+ // Solo B nella lista → B viene prima
201
+ if (idxB !== -1) return 1;
162
202
 
163
- const tokenByPath = {};
164
- for (const token of dictionary.allTokens) {
165
- tokenByPath[token.path.join('.')] = token;
203
+ // Nessuno nella lista → ordine alfabetico
204
+ return a.name.localeCompare(b.name, 'en', { numeric: true, sensitivity: 'base' });
205
+ };
206
+
207
+ // Tokens loaded via `include` (sourceModes: base-mode tokens made
208
+ // available to other modes) stay in tokenByPath, so references to them
209
+ // resolve to var(--name), but are not declared in this mode's block.
210
+ // property name → token source file, used by mergeCustomProps when the
211
+ // merge is limited to some source files (a token may generate several
212
+ // properties, e.g. typography)
213
+ /** @type {Record<string,string>} */
214
+ const sourceFileMap = {};
215
+ const trackSource = (token, tokenLines) => {
216
+ for (const name of Object.keys(parseCustomProps(tokenLines.join('\n')))) {
217
+ sourceFileMap[name] = token.filePath;
166
218
  }
219
+ return tokenLines;
220
+ };
167
221
 
168
- const resolveRefs = makeResolveRefs(tokenByPath);
169
-
170
- const sequenceList = [
171
- 'xxs', 'xs', 'sm', 'base', 'md', 'lg', 'xl', 'xxl', 'xxxl', '2xl', '3xl',
172
- 'xlight', 'light', 'regular', 'medium', 'semibold', 'bold', 'xbold'
173
- ];
174
-
175
- const sortFunction = (a, b) => {
176
- const idxA = sequenceList.indexOf(a.name);
177
- const idxB = sequenceList.indexOf(b.name);
178
-
179
- // Entrambi nella lista → ordine arbitrario
180
- if (idxA !== -1 && idxB !== -1) return idxA - idxB;
181
-
182
- // Solo A nella lista → A viene prima
183
- if (idxA !== -1) return -1;
184
-
185
- // Solo B nella lista → B viene prima
186
- if (idxB !== -1) return 1;
187
-
188
- // Nessuno nella lista → ordine alfabetico
189
- return a.name.localeCompare(b.name, 'en', { numeric: true, sensitivity: 'base' });
190
- };
191
-
192
- // Tokens loaded via `include` (sourceModes: base-mode tokens made
193
- // available to other modes) stay in tokenByPath, so references to them
194
- // resolve to var(--name), but are not declared in this mode's block.
195
- // property name → token source file, used by mergeCustomProps when the
196
- // merge is limited to some source files (a token may generate several
197
- // properties, e.g. typography)
198
- /** @type {Record<string,string>} */
199
- const sourceFileMap = {};
200
- const trackSource = (token, tokenLines) => {
201
- for (const name of Object.keys(parseCustomProps(tokenLines.join('\n')))) {
202
- sourceFileMap[name] = token.filePath;
222
+ const lines = dictionary.allTokens
223
+ .filter((token) => token.isSource !== false)
224
+ // .sort((a, b) => a.name.localeCompare(b.name, 'en', {numeric: true, sensitivity: 'base'}))
225
+ .sort((a, b) => sortFunction(a,b))
226
+ .flatMap((token) => {
227
+ const type = token.$type ?? token.type;
228
+ const orig = token.original?.$value ?? token.original?.value;
229
+ const description = token.$description ?? token.description ?? token.comment;
230
+ const commentSuffix = buildDescriptionComment(description);
231
+
232
+ if (type === 'typography') {
233
+ const typographyLines = buildTypography(token.name, orig ?? {}, resolveRefs)
234
+ .split('\n')
235
+ .map((line) => `${line}${commentSuffix}`)
236
+ .join('\n');
237
+ return trackSource(token, [typographyLines]);
203
238
  }
204
- return tokenLines;
205
- };
206
-
207
- const lines = dictionary.allTokens
208
- .filter((token) => token.isSource !== false)
209
- // .sort((a, b) => a.name.localeCompare(b.name, 'en', {numeric: true, sensitivity: 'base'}))
210
- .sort((a, b) => sortFunction(a,b))
211
- .flatMap((token) => {
212
- const type = token.$type ?? token.type;
213
- const orig = token.original?.$value ?? token.original?.value;
214
- const description = token.$description ?? token.description ?? token.comment;
215
- const commentSuffix = buildDescriptionComment(description);
216
-
217
- if (type === 'typography') {
218
- const typographyLines = buildTypography(token.name, orig ?? {}, resolveRefs)
219
- .split('\n')
220
- .map((line) => `${line}${commentSuffix}`)
221
- .join('\n');
222
- return trackSource(token, [typographyLines]);
223
- }
224
-
225
- let value;
226
-
227
- if (type === 'shadow' && options.outputReferences) {
228
- value = (typeof orig === 'string' && orig.startsWith('{'))
229
- ? resolveRefs(orig) : buildShadow(orig, resolveRefs);
230
-
231
- } else if (type === 'gradient' && options.outputReferences) {
232
- value = (typeof orig === 'string' && orig.startsWith('{'))
233
- ? resolveRefs(orig) : buildGradient(orig, resolveRefs);
234
-
235
- } else if ((type === 'border' || type === 'outline') && options.outputReferences) {
236
- value = (typeof orig === 'string' && orig.startsWith('{'))
237
- ? resolveRefs(orig) : buildBorderLike(orig, resolveRefs);
238
-
239
- } else if (type === 'transition' && options.outputReferences) {
240
- value = (typeof orig === 'string' && orig.startsWith('{'))
241
- ? resolveRefs(orig) : buildTransition(orig, resolveRefs);
242
-
243
- } else if (type === 'animation' && options.outputReferences) {
244
- value = (typeof orig === 'string' && orig.startsWith('{'))
245
- ? resolveRefs(orig) : buildAnimation(orig, resolveRefs);
246
-
247
- } else if (options.outputReferences && typeof orig === 'string' && orig.includes('{')) {
248
- // Covers both plain aliases and CSS functions containing {references}
249
- // e.g. color-mix(in srgb, {btn.secondary.background.color} 60%, #000)
250
- // {references} are replaced with var(--name); arithmetic gets calc().
251
- const resolved = resolveRefs(orig);
252
- // Strip var(...) and CSS function calls (word chars + hyphens followed by '(')
253
- // before checking for arithmetic operators, so that hyphens in names like
254
- // "color-mix" or "linear-gradient" don't trigger a spurious calc() wrap.
255
- const stripped = resolved
256
- .replace(/var\([^)]+\)/g, '0')
257
- .replace(/[\w-]+\(/g, '(');
258
- const isCalcNeeded = /[+\-*/]/.test(stripped);
259
- value = isCalcNeeded ? `calc(${resolved})` : resolved;
260
-
261
- } else {
262
- value = String(token.$value ?? token.value);
263
- }
264
-
265
- // Multi-line values (e.g. Open Props' linear() easings) are collapsed
266
- // to a single line: declarations are parsed one line at a time (see
267
- // parseCustomProps in ../merge-css.mjs), so a multi-line value would
268
- // be truncated to its first line.
269
- value = String(value)
270
- .replace(/\s*\n\s*/g, ' ')
271
- .replace(/\(\s+/g, '(')
272
- .replace(/\s+\)/g, ')');
273
-
274
- return trackSource(token, [` --${token.name}: ${value};${commentSuffix}`]);
275
- });
276
-
277
- // Build a name → declaration-tail map from the declarations generated
278
- // above, then merge with any pre-existing custom properties loaded via
279
- // loadExistingCustomProps() / loadExistingCustomPropsScoped() (see
280
- // ../merge-css.mjs). Pre-existing values (including trailing comments)
281
- // take priority when both exist; pre-existing-only properties are kept.
282
- // options.mode (sourceModes build only, see ../build-source-modes.mjs)
283
- // scopes the merge to that mode, so same-named props with different
284
- // values across modes are never mixed up.
285
- const generatedProps = parseCustomProps(lines.join('\n'));
286
- const finalProps = mergeCustomProps(generatedProps, options.mode ?? null, sourceFileMap);
287
-
288
- // Final output is always sorted alphabetically ascending by property
289
- // name, regardless of merge — this also keeps pre-existing-only
290
- // properties from merge-css.mjs in order instead of trailing at the end.
291
- const sortedProps = Object.entries(finalProps)
292
- .sort(([a], [b]) => a.localeCompare(b, 'en', { numeric: true, sensitivity: 'base' }));
293
-
294
- // customPropsGroups: properties whose first hyphen-separated name segment
295
- // matches one of a group's prefixes are pulled out, labelled with that
296
- // group's name and placed, in group-list order, at the beginning of the
297
- // output file. A property matches the first group (in list order) whose
298
- // prefixes include it.
299
- const customPropsGroups = options.customPropsGroups ?? [];
300
- const groupedProps = customPropsGroups.map(() => []);
301
- const restProps = [];
302
- for (const entry of sortedProps) {
303
- const prefix = entry[0].split('-')[0];
304
- const groupIndex = customPropsGroups.findIndex((g) => g.prefixes?.includes(prefix));
305
- if (groupIndex !== -1) {
306
- groupedProps[groupIndex].push(entry);
239
+
240
+ let value;
241
+
242
+ if (type === 'shadow' && options.outputReferences) {
243
+ value = (typeof orig === 'string' && orig.startsWith('{'))
244
+ ? resolveRefs(orig) : buildShadow(orig, resolveRefs);
245
+
246
+ } else if (type === 'gradient' && options.outputReferences) {
247
+ value = (typeof orig === 'string' && orig.startsWith('{'))
248
+ ? resolveRefs(orig) : buildGradient(orig, resolveRefs);
249
+
250
+ } else if ((type === 'border' || type === 'outline') && options.outputReferences) {
251
+ value = (typeof orig === 'string' && orig.startsWith('{'))
252
+ ? resolveRefs(orig) : buildBorderLike(orig, resolveRefs);
253
+
254
+ } else if (type === 'transition' && options.outputReferences) {
255
+ value = (typeof orig === 'string' && orig.startsWith('{'))
256
+ ? resolveRefs(orig) : buildTransition(orig, resolveRefs);
257
+
258
+ } else if (type === 'animation' && options.outputReferences) {
259
+ value = (typeof orig === 'string' && orig.startsWith('{'))
260
+ ? resolveRefs(orig) : buildAnimation(orig, resolveRefs);
261
+
262
+ } else if (options.outputReferences && typeof orig === 'string' && orig.includes('{')) {
263
+ // Covers both plain aliases and CSS functions containing {references}
264
+ // e.g. color-mix(in srgb, {btn.secondary.background.color} 60%, #000)
265
+ // {references} are replaced with var(--name); arithmetic gets calc().
266
+ const resolved = resolveRefs(orig);
267
+ // Strip var(...) and CSS function calls (word chars + hyphens followed by '(')
268
+ // before checking for arithmetic operators, so that hyphens in names like
269
+ // "color-mix" or "linear-gradient" don't trigger a spurious calc() wrap.
270
+ const stripped = resolved
271
+ .replace(/var\([^)]+\)/g, '0')
272
+ .replace(/[\w-]+\(/g, '(');
273
+ const isCalcNeeded = /[+\-*/]/.test(stripped);
274
+ value = isCalcNeeded ? `calc(${resolved})` : resolved;
275
+
307
276
  } else {
308
- restProps.push(entry);
277
+ value = String(token.$value ?? token.value);
309
278
  }
310
- }
311
- groupedProps.forEach((props, i) => {
312
- const prefixes = customPropsGroups[i].prefixes;
313
- props.sort(([a], [b]) => {
314
- const idxA = prefixes.indexOf(a.split('-')[0]);
315
- const idxB = prefixes.indexOf(b.split('-')[0]);
316
- if (idxA !== idxB) return idxA - idxB;
317
- return a.localeCompare(b, 'en', { numeric: true, sensitivity: 'base' });
318
- });
279
+
280
+ // Multi-line values (e.g. Open Props' linear() easings) are collapsed
281
+ // to a single line: declarations are parsed one line at a time (see
282
+ // parseCustomProps in ../merge-css.mjs), so a multi-line value would
283
+ // be truncated to its first line.
284
+ value = String(value)
285
+ .replace(/\s*\n\s*/g, ' ')
286
+ .replace(/\(\s+/g, '(')
287
+ .replace(/\s+\)/g, ')');
288
+
289
+ return trackSource(token, [` --${token.name}: ${value};${commentSuffix}`]);
319
290
  });
320
291
 
321
- // A blank line is inserted between blocks of properties sharing the same
322
- // first hyphen-separated segment (e.g. all `btn-*` together), to visually
323
- // group related custom properties in the generated file.
324
- const outLines = [];
325
-
326
- // options.colorScheme (sourceModes build only, see ../build-source-modes.mjs):
327
- // prepends a `color-scheme: <value>;` declaration, e.g. "light dark" for
328
- // the base mode's block, or the mode's own name (e.g. "dark") for a
329
- // block nested inside its @media (prefers-color-scheme: <mode>) rule.
330
- if (options.colorScheme) {
331
- outLines.push(` color-scheme: ${options.colorScheme};`, '');
292
+ // Build a name → declaration-tail map from the declarations generated
293
+ // above, then merge with any pre-existing custom properties loaded via
294
+ // loadExistingCustomProps() / loadExistingCustomPropsScoped() (see
295
+ // ../merge-css.mjs). Pre-existing values (including trailing comments)
296
+ // take priority when both exist; pre-existing-only properties are kept.
297
+ // options.mode (sourceModes build only, see ../build-source-modes.mjs)
298
+ // scopes the merge to that mode, so same-named props with different
299
+ // values across modes are never mixed up.
300
+ const generatedProps = parseCustomProps(lines.join('\n'));
301
+ return mergeCustomProps(generatedProps, options.mode ?? null, sourceFileMap);
302
+ };
303
+
304
+ /**
305
+ * Builds the final CSS block text (`<selector> { ... }`) from an already
306
+ * computed name → declaration-tail map (see computeFinalProps() above):
307
+ * sorts properties alphabetically, pulls out customPropsGroups, prepends an
308
+ * optional `color-scheme` declaration, and optionally wraps the result in
309
+ * `@layer <addLayer> { ... }`.
310
+ *
311
+ * Extracted as its own function (rather than inline in the format() call
312
+ * below) so build-tokens-src/light-dark.mjs can re-run it on an adjusted
313
+ * props map — e.g. after moving properties shared by `light` and `dark` into
314
+ * a single `light-dark()` declaration — and get back a properly sorted and
315
+ * grouped block, instead of appending raw text after the fact.
316
+ * @param {object} opts
317
+ * @param {Record<string,string>} opts.finalProps name → declaration tail, as returned by computeFinalProps()
318
+ * @param {string} [opts.selector] CSS selector wrapping the block (default: (default: ':root'))
319
+ * @param {string} [opts.colorScheme] optional value for a `color-scheme: <value>;` declaration prepended to the block
320
+ * @param {{name:string,prefixes:string[]}[]} [opts.customPropsGroups] named groups of name prefixes, moved to the top of the block in list order (default: [])
321
+ * @param {string|null} [opts.addLayer] wraps the block in `@layer <addLayer> { ... }` (default: null — no layer)
322
+ * @returns {string}
323
+ * @example
324
+ * buildCssBlock({
325
+ * finalProps: { 'accent-color': '#123;' },
326
+ * selector: ':root', // default: ':root'
327
+ * colorScheme: 'light dark', // default: undefined — no color-scheme line
328
+ * customPropsGroups: [], // default: []
329
+ * addLayer: null, // default: null
330
+ * });
331
+ */
332
+ export const buildCssBlock = ({
333
+ finalProps,
334
+ selector = ':root',
335
+ colorScheme,
336
+ customPropsGroups = [],
337
+ addLayer = null,
338
+ }) => {
339
+ // Final output is always sorted alphabetically ascending by property
340
+ // name, regardless of merge — this also keeps pre-existing-only
341
+ // properties from merge-css.mjs in order instead of trailing at the end.
342
+ const sortedProps = Object.entries(finalProps)
343
+ .sort(([a], [b]) => a.localeCompare(b, 'en', { numeric: true, sensitivity: 'base' }));
344
+
345
+ // customPropsGroups: properties whose first hyphen-separated name segment
346
+ // matches one of a group's prefixes are pulled out, labelled with that
347
+ // group's name and placed, in group-list order, at the beginning of the
348
+ // output file. A property matches the first group (in list order) whose
349
+ // prefixes include it.
350
+ const groupedProps = customPropsGroups.map(() => []);
351
+ const restProps = [];
352
+ for (const entry of sortedProps) {
353
+ const prefix = entry[0].split('-')[0];
354
+ const groupIndex = customPropsGroups.findIndex((g) => g.prefixes?.includes(prefix));
355
+ if (groupIndex !== -1) {
356
+ groupedProps[groupIndex].push(entry);
357
+ } else {
358
+ restProps.push(entry);
332
359
  }
360
+ }
361
+ groupedProps.forEach((props, i) => {
362
+ const prefixes = customPropsGroups[i].prefixes;
363
+ props.sort(([a], [b]) => {
364
+ const idxA = prefixes.indexOf(a.split('-')[0]);
365
+ const idxB = prefixes.indexOf(b.split('-')[0]);
366
+ if (idxA !== idxB) return idxA - idxB;
367
+ return a.localeCompare(b, 'en', { numeric: true, sensitivity: 'base' });
368
+ });
369
+ });
370
+
371
+ // A blank line is inserted between blocks of properties sharing the same
372
+ // first hyphen-separated segment (e.g. all `btn-*` together), to visually
373
+ // group related custom properties in the generated file.
374
+ const outLines = [];
333
375
 
334
- let prevPrefix = null;
335
- const appendBlock = (props) => {
336
- for (const [name, tail] of props) {
337
- const prefix = name.split('-')[0];
338
- if (prevPrefix !== null && prefix !== prevPrefix) {
339
- outLines.push('');
340
- }
341
- outLines.push(` --${name}: ${tail}`);
342
- prevPrefix = prefix;
376
+ if (colorScheme) {
377
+ outLines.push(` color-scheme: ${colorScheme};`, '');
378
+ }
379
+
380
+ let prevPrefix = null;
381
+ const appendBlock = (props) => {
382
+ for (const [name, tail] of props) {
383
+ const prefix = name.split('-')[0];
384
+ if (prevPrefix !== null && prefix !== prevPrefix) {
385
+ outLines.push('');
343
386
  }
344
- };
345
-
346
- const hasGroups = groupedProps.some((props) => props.length);
347
- let isFirstBlock = true;
348
- groupedProps.forEach((props, i) => {
349
- if (!props.length) return;
350
- if (!isFirstBlock) outLines.push('');
351
- isFirstBlock = false;
352
- outLines.push(buildGroupTitle(customPropsGroups[i].name));
353
- appendBlock(props);
354
- prevPrefix = null;
355
- });
356
- if (hasGroups && restProps.length) {
357
- outLines.push('', ' /* ---------------------- */', '');
358
- prevPrefix = null;
387
+ outLines.push(` --${name}: ${tail}`);
388
+ prevPrefix = prefix;
359
389
  }
360
- appendBlock(restProps);
390
+ };
361
391
 
362
- customPropsCount = sortedProps.length;
363
- const block = `${selector} {\n${outLines.join('\n')}\n}\n`;
392
+ const hasGroups = groupedProps.some((props) => props.length);
393
+ let isFirstBlock = true;
394
+ groupedProps.forEach((props, i) => {
395
+ if (!props.length) return;
396
+ if (!isFirstBlock) outLines.push('');
397
+ isFirstBlock = false;
398
+ outLines.push(buildGroupTitle(customPropsGroups[i].name));
399
+ appendBlock(props);
400
+ prevPrefix = null;
401
+ });
402
+ if (hasGroups && restProps.length) {
403
+ outLines.push('', ' /* ---------------------- */', '');
404
+ prevPrefix = null;
405
+ }
406
+ appendBlock(restProps);
407
+
408
+ const block = `${selector} {\n${outLines.join('\n')}\n}\n`;
409
+
410
+ // addLayer: wraps the whole block inside `@layer <name> { ... }`.
411
+ // Indentation is left to stylelint's fix step (run right after the build).
412
+ return addLayer
413
+ ? `@layer ${addLayer} {\n\n${block}}\n`
414
+ : block;
415
+ };
364
416
 
365
- // addLayer: wraps the whole block inside `@layer <name> { ... }`.
366
- // Indentation is left to stylelint's fix step (run right after the build).
367
- return options.addLayer
368
- ? `@layer ${options.addLayer} {\n\n${block}}\n`
369
- : block;
417
+ StyleDictionary.registerFormat({
418
+ name: 'css/variables-sorted',
419
+ format: ({ dictionary, options }) => {
420
+ const finalProps = computeFinalProps(dictionary, options);
421
+ lastFinalProps = finalProps;
422
+ customPropsCount = Object.keys(finalProps).length;
423
+
424
+ return buildCssBlock({
425
+ finalProps,
426
+ selector: options.selector,
427
+ colorScheme: options.colorScheme,
428
+ customPropsGroups: options.customPropsGroups,
429
+ addLayer: options.addLayer,
430
+ });
370
431
  },
371
432
  });
@@ -0,0 +1,126 @@
1
+ // build-tokens-src/light-dark.mjs
2
+ // Supports the `useLightDarkFunc` config option (see config.mjs): with a
3
+ // sourceModes build (see build-source-modes.mjs) that defines both a `light`
4
+ // and a `dark` mode, custom properties present in BOTH — matched by name —
5
+ // are written once, as `--name: light-dark(<light-value>, <dark-value>);`,
6
+ // instead of being split across the base mode's top-level `:root { ... }`
7
+ // block and a `@media (prefers-color-scheme: dark) { ... }` block.
8
+ //
9
+ // Only `light` and `dark` are affected:
10
+ // - a third mode (e.g. a custom `high-contrast` key in sourceModes) is left
11
+ // completely untouched, still nested under its own
12
+ // `@media (prefers-color-scheme: <mode>) { ... }` rule
13
+ // - a property present in only one of `light`/`dark` is left in its own
14
+ // mode's block — still behind that mode's `@media` rule, unless that mode
15
+ // happens to be the configured base mode
16
+ //
17
+ // The shared `light-dark()` declarations always land in the configured base
18
+ // mode's block (build-source-modes.mjs' `sourceModesBase`, whichever of
19
+ // `light`, `dark`, or a third mode that is) — never specifically in `light`'s
20
+ // or `dark`'s own block — since the light-dark() function resolves against
21
+ // the effective `color-scheme`, regardless of whether the declaration itself
22
+ // sits inside a `@media (prefers-color-scheme: ...)` rule or not.
23
+ //
24
+ // Interaction with `mergeCustomProps`: nothing special is needed here — the
25
+ // per-mode merge with pre-existing custom properties (see ../merge-css.mjs)
26
+ // already runs earlier, inside computeFinalProps() (formats/css.mjs), before
27
+ // this module ever sees the props. So a pre-existing file written with the
28
+ // classic `@media (prefers-color-scheme: dark)` split (i.e. built before
29
+ // useLightDarkFunc was turned on) is reconciled into light-dark() calls just
30
+ // like freshly generated values, matched by property name. As the values
31
+ // merged in this way were never meant to be combined into light-dark() calls,
32
+ // unexpected pre-existing content (e.g. a manually-added property whose name
33
+ // happens to collide across modes) can produce an invalid combined value —
34
+ // left for the user to fix, same as any other mergeCustomProps edge case.
35
+
36
+ import { buildCssBlock } from './formats/css.mjs';
37
+
38
+ // Splits a declaration tail (as produced by computeFinalProps(): value +
39
+ // terminating `;` + optional trailing comment) into its bare CSS value and
40
+ // its trailing comment, so the value can be embedded as a light-dark()
41
+ // argument without dragging the `;`/comment along.
42
+ /** @param {string} tail @returns {{value: string, comment: string}} */
43
+ const splitTail = (tail) => {
44
+ const match = /^(.*?);\s*(\/\*.*\*\/)?\s*$/.exec(tail.trim());
45
+ if (!match) return { value: tail.trim().replace(/;$/, ''), comment: '' };
46
+ return { value: match[1].trim(), comment: match[2] ?? '' };
47
+ };
48
+
49
+ /**
50
+ * @param {object} opts
51
+ * @param {Record<string, Record<string,string>>} opts.modeFinalProps mode → (name → declaration tail) map, one entry per sourceModes key — as captured from computeFinalProps()/lastFinalProps (see formats/css.mjs) right after each per-mode sd.formatPlatform('css') call in build-source-modes.mjs
52
+ * @param {string[]} opts.modeNames all sourceModes keys, in config order
53
+ * @param {string} opts.baseMode mode whose block is printed as a top-level, unconditioned `:root` (or custom selector) — see sourceModesBase in config.mjs
54
+ * @param {string} [opts.selector] customPropsSelector — passed through unchanged to buildCssBlock() (default: ':root')
55
+ * @param {{name:string,prefixes:string[]}[]} [opts.customPropsGroups]
56
+ * @returns {{blocks: Record<string,string>, counts: Record<string,number>}|null}
57
+ * Rebuilt CSS block text + custom-prop count, keyed by mode, for every mode
58
+ * touched by the reconciliation (`light`, `dark`, and `baseMode`, which may
59
+ * coincide) — or null when sourceModes does not define both `light` and
60
+ * `dark` (useLightDarkFunc then has no effect, see config.mjs)
61
+ */
62
+ export const applyLightDarkFunc = ({ modeFinalProps, modeNames, baseMode, selector, customPropsGroups }) => {
63
+ if (!modeNames.includes('light') || !modeNames.includes('dark')) return null;
64
+
65
+ const lightProps = modeFinalProps.light;
66
+ const darkProps = modeFinalProps.dark;
67
+ const commonNames = Object.keys(lightProps).filter((name) => name in darkProps);
68
+
69
+ /** @type {Record<string,string>} */
70
+ const sharedProps = {};
71
+ for (const name of commonNames) {
72
+ const light = splitTail(lightProps[name]);
73
+ const dark = splitTail(darkProps[name]);
74
+ // Prefers the light-side trailing comment (e.g. a token $description);
75
+ // falls back to the dark-side one if light has none.
76
+ const comment = light.comment || dark.comment;
77
+ sharedProps[name] = `light-dark(${light.value}, ${dark.value});${comment ? ` ${comment}` : ''}`;
78
+ }
79
+
80
+ const stripCommon = (props) => {
81
+ const copy = { ...props };
82
+ for (const name of commonNames) delete copy[name];
83
+ return copy;
84
+ };
85
+
86
+ /** @type {Record<string, Record<string,string>>} */
87
+ const updatedFinalProps = { ...modeFinalProps };
88
+ updatedFinalProps.light = stripCommon(lightProps);
89
+ updatedFinalProps.dark = stripCommon(darkProps);
90
+ // The shared declarations always land in the base mode's own map — adding
91
+ // them back in if the base mode happens to be `light` or `dark` itself
92
+ // (whose map was just stripped above), or into a third mode's map.
93
+ updatedFinalProps[baseMode] = { ...updatedFinalProps[baseMode], ...sharedProps };
94
+
95
+ // The base mode's `color-scheme` value is always normalised to read
96
+ // "light dark [...other modes]", with `light` before `dark`, regardless of
97
+ // the order sourceModes keys were declared in config or of which of the
98
+ // two is the configured base — light-dark() itself is positional
99
+ // (light-value first, dark-value second), so the accompanying
100
+ // `color-scheme` declaration follows the same, predictable reading. Modes
101
+ // other than light/dark, if any, are appended after, unaffected.
102
+ const restModes = modeNames.filter((mode) => mode !== 'light' && mode !== 'dark');
103
+ const colorScheme = ['light', 'dark', ...restModes].join(' ');
104
+
105
+ // Only `light`, `dark`, and the base mode (which may be one of the two, or
106
+ // a third mode) need re-rendering: every other sourceModes key is
107
+ // untouched and keeps its already-built block (see build-source-modes.mjs).
108
+ const modesToRebuild = new Set(['light', 'dark', baseMode]);
109
+
110
+ /** @type {Record<string,string>} */
111
+ const blocks = {};
112
+ /** @type {Record<string,number>} */
113
+ const counts = {};
114
+ for (const mode of modesToRebuild) {
115
+ const finalProps = updatedFinalProps[mode];
116
+ blocks[mode] = buildCssBlock({
117
+ finalProps,
118
+ selector,
119
+ colorScheme: mode === baseMode ? colorScheme : mode,
120
+ customPropsGroups,
121
+ });
122
+ counts[mode] = Object.keys(finalProps).length;
123
+ }
124
+
125
+ return { blocks, counts };
126
+ };