@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.
@@ -61,6 +61,7 @@ npx buildTokens --config ./path/to/tokens-config.mjs
61
61
  The `source` array accepts any combination of:
62
62
  - Concrete file paths: `'./tokens/colors.jsonc'`
63
63
  - Glob patterns: `'./tokens/**/*.{json,jsonc,mjs}'`
64
+ - `{ src, prefix }` objects, to add a prefix to the custom properties of those files (see [Source prefixes](#source-prefixes))
64
65
 
65
66
  **Supported formats:**
66
67
  - `.json` / `.jsonc` — parsed directly as token data
@@ -70,6 +71,27 @@ The `source` array accepts any combination of:
70
71
 
71
72
  Known limitation: a legacy node that is at the same time a token (own `value`/`type`) *and* a group with further nested children (e.g. Open Props' `other.ease.out`, which has its own value plus `out.1` ... `out.5`) can't be represented in DTCG v5 — a node with `$value` is always a leaf, Style Dictionary doesn't descend into its children. Only the node's own value is converted; the nested children are silently lost.
72
73
 
74
+ #### Source prefixes
75
+
76
+ An entry of `source` (or of a `sourceModes` mode) can be an object `{ src, prefix }` instead of a string: every custom property generated from the files matched by `src` (a path, a glob or an array of them) is rendered with the given prefix. Useful to avoid name collisions with third-party token sets, or to make their origin explicit:
77
+
78
+ ```js
79
+ source: [
80
+ './my-tokens/*.mjs',
81
+ { src: `${node_modules_path}/open-props/open-props.style-dictionary-tokens.json`, prefix: 'op' },
82
+ ],
83
+ ```
84
+
85
+ With this config Open Props' `gray.0` becomes `--op-gray-0`. `prefix` accepts `'op'`, `'op-'` or `'--op-'` (all equivalent).
86
+
87
+ - Only the custom property **name** changes, not the token path: `{references}` keep working, and a token of another source referencing `{gray.0}` is rendered as `var(--op-gray-0)`.
88
+ - The JSON output (`jsonBuildPath`) is **not** prefixed: it keeps the original token tree.
89
+ - `customPropsGroups` match the prefixed name, so a group can use `prefixes: ['op']`.
90
+ - Giving two different prefixes to the same file fails the build with an error.
91
+ - `check-unresolved-custom-props.mjs` understands the same syntax. Note that `extraCustomPropsFiles` entries are plain CSS files and are never prefixed.
92
+
93
+ See `build-tokens-src/source-prefixes.mjs`.
94
+
73
95
 
74
96
  #### Token file format (JS example)
75
97
 
@@ -4,18 +4,25 @@
4
4
  // light/dark custom-properties split.
5
5
  //
6
6
  // For each mode, a separate StyleDictionary instance is built from that
7
- // mode's own `source` array. The generated `:root { ... }` CSS blocks are
8
- // then composed into a single destFile:
9
- // - the base mode's block stays as a plain `:root { ... }` rule, with a
10
- // `color-scheme: <all modes joined>;` declaration prepended
11
- // (e.g. "light dark")
7
+ // mode's own `source` array. The generated `<selector> { ... }` CSS blocks
8
+ // (selector: customPropsSelector, default ':root' — see config.mjs) are then
9
+ // composed into a single destFile:
10
+ // - the base mode's block stays as a plain `<selector> { ... }` rule, with
11
+ // a `color-scheme: <all modes joined>;` declaration prepended (e.g.
12
+ // "light dark")
12
13
  // - every other mode's block is wrapped in
13
- // `@media (prefers-color-scheme: <mode>) { :root { ... } }`, with a
14
+ // `@media (prefers-color-scheme: <mode>) { <selector> { ... } }`, with a
14
15
  // `color-scheme: <mode>;` declaration prepended
15
16
  // customPropsGroups, mergeCustomProps and pxToRem apply per mode, exactly as
16
17
  // in the single-source build. addLayer (if set) wraps the whole composed
17
18
  // result in a single `@layer`, rather than one per mode.
18
19
  //
20
+ // When sourceModes defines both a `light` and a `dark` key and
21
+ // useLightDarkFunc is true (default), custom properties shared by both are
22
+ // reconciled into a single `light-dark()` declaration instead of being split
23
+ // across the base block and a `@media (prefers-color-scheme: dark)` rule —
24
+ // see light-dark.mjs.
25
+ //
19
26
  // JSON tokens (if jsonBuildPath is set) are produced per mode: one set of
20
27
  // files per mode, with `-<mode>` appended to each filename (see
21
28
  // buildJsonFiles() in formats/json.mjs), e.g. "tokens-light.jsonc" /
@@ -26,15 +33,16 @@ import StyleDictionary from 'style-dictionary';
26
33
  import * as path from 'node:path';
27
34
  import { mkdir, writeFile } from 'node:fs/promises';
28
35
  import { CSS_TRANSFORMS, JSON_TRANSFORMS } from './platforms.mjs';
29
- import { customPropsCount } from './formats/css.mjs';
36
+ import { customPropsCount, lastFinalProps } from './formats/css.mjs';
30
37
  import { buildJsonFiles, collectConcreteFilePaths } from './formats/json.mjs';
31
38
  import { loadExistingCustomPropsScoped } from './merge-css.mjs';
32
39
  import { LEGACY_TOKENS_PARSER_NAME } from './legacy-tokens-parser.mjs';
40
+ import { applyLightDarkFunc } from './light-dark.mjs';
33
41
 
34
42
  /**
35
43
  * @param {object} opts
36
44
  * @param {Record<string,string[]>} opts.sourceModes Resolved source paths per mode (see config.mjs)
37
- * @param {string} opts.baseMode Mode whose block stays a top-level `:root { ... }` rule
45
+ * @param {string} opts.baseMode Mode whose block stays a top-level `<selector> { ... }` rule
38
46
  * @param {string} opts.buildPath Absolute path for CSS output directory
39
47
  * @param {string} opts.destFile CSS output filename
40
48
  * @param {string|null} opts.jsonBuildPath Absolute path for JSON output (null = disabled)
@@ -45,6 +53,8 @@ import { LEGACY_TOKENS_PARSER_NAME } from './legacy-tokens-parser.mjs';
45
53
  * @param {boolean} opts.pxToRem
46
54
  * @param {string|null} opts.addLayer
47
55
  * @param {boolean|(string|RegExp)[]} opts.mergeCustomProps true = merge all sources, array = only the matching token source files
56
+ * @param {boolean} opts.useLightDarkFunc reconcile shared `light`/`dark` properties into `light-dark()` calls (default: true — see config.mjs and light-dark.mjs). Ignored unless sourceModes defines both a `light` and a `dark` key.
57
+ * @param {string} opts.customPropsSelector CSS selector wrapping the generated block(s) (default: ':root' — see config.mjs)
48
58
  * @returns {Promise<{cssDestPath: string, totalCustomProps: number, jsonFilesByMode: Record<string, object[]>}>}
49
59
  */
50
60
  export const buildSourceModes = async ({
@@ -60,13 +70,15 @@ export const buildSourceModes = async ({
60
70
  pxToRem,
61
71
  addLayer,
62
72
  mergeCustomProps,
73
+ useLightDarkFunc,
74
+ customPropsSelector,
63
75
  }) => {
64
76
  const modeNames = Object.keys(sourceModes);
65
77
 
66
78
  // Read the pre-existing destFile (if any) BEFORE any Style Dictionary
67
79
  // instance runs, since the first one to write would overwrite it.
68
80
  if (mergeCustomProps) {
69
- loadExistingCustomPropsScoped(path.join(buildPath, destFile), baseMode, mergeCustomProps);
81
+ loadExistingCustomPropsScoped(path.join(buildPath, destFile), baseMode, mergeCustomProps, customPropsSelector);
70
82
  }
71
83
 
72
84
  const cssTransforms = pxToRem
@@ -75,13 +87,22 @@ export const buildSourceModes = async ({
75
87
 
76
88
  /** @type {Record<string,string>} */
77
89
  const modeBlocks = {};
78
- let totalCustomProps = 0;
90
+ // Per-mode custom-prop count and structured (name -> tail) props map,
91
+ // captured right after each per-mode format call (customPropsCount and
92
+ // lastFinalProps are live bindings, updated synchronously by the format
93
+ // function — see formats/css.mjs). Kept per mode (rather than summed on
94
+ // the fly) so applyLightDarkFunc() can later overwrite just the entries it
95
+ // actually touches (light, dark, and the base mode).
96
+ /** @type {Record<string,number>} */
97
+ const modeCounts = {};
98
+ /** @type {Record<string, Record<string,string>>} */
99
+ const modeFinalProps = {};
79
100
  /** @type {Record<string, object[]>} */
80
101
  const jsonFilesByMode = {};
81
102
 
82
103
  for (const mode of modeNames) {
83
104
  // Non-base modes may reference base-mode tokens (always defined, since
84
- // the base block is the unconditional top-level `:root`). The base
105
+ // the base block is the unconditional top-level `<selector>`). The base
85
106
  // sources are passed as `include`: available for reference resolution
86
107
  // but flagged isSource: false, and skipped by the css/json formats.
87
108
  // Base-mode-only on purpose: a reference to a token that exists only in
@@ -102,6 +123,7 @@ export const buildSourceModes = async ({
102
123
  format: 'css/variables-sorted',
103
124
  options: {
104
125
  outputReferences: true,
126
+ selector: customPropsSelector,
105
127
  customPropsGroups,
106
128
  mode,
107
129
  colorScheme: mode === baseMode ? modeNames.join(' ') : mode,
@@ -116,9 +138,10 @@ export const buildSourceModes = async ({
116
138
  // the composed multi-mode file is written once, at the end, below.
117
139
  const [formatted] = await sd.formatPlatform('css');
118
140
  modeBlocks[mode] = /** @type {string} */ (formatted.output);
119
- // customPropsCount is a live binding, updated synchronously by the
120
- // format function called above (see formats/css.mjs).
121
- totalCustomProps += customPropsCount;
141
+ // customPropsCount / lastFinalProps are live bindings, updated
142
+ // synchronously by the format function called above (see formats/css.mjs).
143
+ modeCounts[mode] = customPropsCount;
144
+ modeFinalProps[mode] = lastFinalProps;
122
145
 
123
146
  if (jsonBuildPath) {
124
147
  const concreteFilePaths = jsonDestFile ? [] : await collectConcreteFilePaths(sd);
@@ -142,6 +165,26 @@ export const buildSourceModes = async ({
142
165
  }
143
166
  }
144
167
 
168
+ // ── light-dark() reconciliation (light/dark modes only) ──────────────────
169
+ // Rebuilds just the `light`, `dark` and base-mode blocks from the adjusted
170
+ // props maps — see light-dark.mjs. No-op (returns null) unless sourceModes
171
+ // defines both a `light` and a `dark` key.
172
+ if (useLightDarkFunc) {
173
+ const reconciled = applyLightDarkFunc({
174
+ modeFinalProps,
175
+ modeNames,
176
+ baseMode,
177
+ selector: customPropsSelector,
178
+ customPropsGroups,
179
+ });
180
+ if (reconciled) {
181
+ Object.assign(modeBlocks, reconciled.blocks);
182
+ Object.assign(modeCounts, reconciled.counts);
183
+ }
184
+ }
185
+
186
+ const totalCustomProps = Object.values(modeCounts).reduce((sum, n) => sum + n, 0);
187
+
145
188
  // ── Compose the final CSS ────────────────────────────────────────────────
146
189
  const otherModes = modeNames.filter((mode) => mode !== baseMode);
147
190
 
@@ -10,7 +10,7 @@
10
10
 
11
11
  import * as path from 'node:path';
12
12
  import { pathToFileURL } from 'node:url';
13
- import { resolveSourcePaths } from './resolve-source-paths.mjs';
13
+ import { splitSourceEntries } from './source-prefixes.mjs';
14
14
 
15
15
  // ---------------------------------------------------------------------------
16
16
  // Read the --config flag
@@ -103,6 +103,26 @@ export const mergeCustomProps = (() => {
103
103
  // Set to null, undefined or false (default) to emit no layer.
104
104
  export const addLayer = buildConfig.addLayer ? String(buildConfig.addLayer).trim() : null;
105
105
 
106
+ // customPropsSelector: CSS selector wrapping the generated custom properties
107
+ // block(s) (see formats/css.mjs buildCssBlock()), instead of the default
108
+ // `:root`. Applies uniformly to a single-source build and to a sourceModes
109
+ // build (the base mode's top-level block and every other mode's block
110
+ // nested under its own `@media (prefers-color-scheme: <mode>)` rule all use
111
+ // the same selector). Falsy or blank values fall back to ':root'.
112
+ export const customPropsSelector = typeof buildConfig.customPropsSelector === 'string'
113
+ && buildConfig.customPropsSelector.trim()
114
+ ? buildConfig.customPropsSelector.trim()
115
+ : ':root';
116
+
117
+ // useLightDarkFunc: with sourceModes set (see below) and defining both a
118
+ // `light` and a `dark` key, custom properties present in both are written
119
+ // once as `--name: light-dark(<light>, <dark>);` instead of being split
120
+ // across the base mode's block and a
121
+ // `@media (prefers-color-scheme: dark) { ... }` rule (see light-dark.mjs).
122
+ // Ignored when sourceModes is not set, or does not define both a light and a
123
+ // dark mode. Default: true.
124
+ export const useLightDarkFunc = buildConfig.useLightDarkFunc !== false;
125
+
106
126
  // customPropsGroups: custom properties whose first hyphen-separated name
107
127
  // segment matches one of a group's prefixes are pulled out, labelled with
108
128
  // that group's name and moved to the beginning of the generated CSS file, in
@@ -126,14 +146,33 @@ export const customPropsGroups = Array.isArray(buildConfig.customPropsGroups)
126
146
  // properties split — see build-tokens-src/build-source-modes.mjs. When set
127
147
  // (buildConfig.sourceModes is a non-null object), `source` is ignored and
128
148
  // each mode's own array of source patterns is resolved the same way.
149
+ //
150
+ // Entries of `source` / of each mode can also be `{ src, prefix }` objects,
151
+ // to render the custom properties of those files with a prefix (see
152
+ // source-prefixes.mjs). They are flattened here into plain patterns for
153
+ // Style Dictionary; the prefixed ones are also collected in
154
+ // `prefixedSources`, to be registered by build-tokens.mjs.
129
155
  // ---------------------------------------------------------------------------
130
156
  const sourceModesRaw = buildConfig.sourceModes ?? null;
131
157
 
158
+ /** @type {import('./source-prefixes.mjs').PrefixedSource[]} */
159
+ export const prefixedSources = [];
160
+
161
+ /**
162
+ * @param {Parameters<typeof splitSourceEntries>[0]} entries
163
+ * @returns {string[]}
164
+ */
165
+ const parseSourceEntries = (entries) => {
166
+ const { patterns, prefixed } = splitSourceEntries(entries, configDir);
167
+ prefixedSources.push(...prefixed);
168
+ return patterns;
169
+ };
170
+
132
171
  export const sourceModes = sourceModesRaw
133
172
  ? Object.fromEntries(
134
173
  Object.entries(sourceModesRaw).map(([mode, modeSource]) => [
135
174
  mode,
136
- resolveSourcePaths(modeSource, configDir),
175
+ parseSourceEntries(modeSource),
137
176
  ])
138
177
  )
139
178
  : null;
@@ -156,4 +195,4 @@ if (sourceModes && buildConfig.sourceModesBase && sourceModesBase !== buildConfi
156
195
  );
157
196
  }
158
197
 
159
- export const source = sourceModes ? null : resolveSourcePaths(buildConfig.source, configDir);
198
+ export const source = sourceModes ? null : parseSourceEntries(buildConfig.source);