@massimo-cassandro/minimo 1.33.6 → 1.34.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.
Files changed (19) hide show
  1. package/design-tokens/README.md +2 -197
  2. package/dev-tools/README.md +1 -0
  3. package/dev-tools/design-tokens-builder/README.md +208 -0
  4. package/dev-tools/design-tokens-builder/build-tokens-src/build-source-modes.mjs +152 -0
  5. package/{design-tokens/utilities → dev-tools/design-tokens-builder}/build-tokens-src/config.mjs +35 -1
  6. package/{design-tokens/utilities → dev-tools/design-tokens-builder}/build-tokens-src/formats/css.mjs +16 -4
  7. package/{design-tokens/utilities → dev-tools/design-tokens-builder}/build-tokens-src/formats/json.mjs +9 -4
  8. package/dev-tools/design-tokens-builder/build-tokens-src/merge-css.mjs +192 -0
  9. package/{design-tokens/utilities → dev-tools/design-tokens-builder}/build-tokens-src/platforms.mjs +3 -1
  10. package/{design-tokens/utilities → dev-tools/design-tokens-builder}/build-tokens.mjs +99 -45
  11. package/{design-tokens/utilities → dev-tools/design-tokens-builder}/check-unresolved-custom-props.mjs +66 -13
  12. package/{design-tokens → dev-tools/design-tokens-builder}/tokens-config-sample.mjs +39 -1
  13. package/dev-tools/starter-kit/source_files/root/package-tpl.json +2 -2
  14. package/dev-tools/starter-kit/starter-install.sh +2 -2
  15. package/package.json +6 -8
  16. package/src/web-components/s-datatable-component/src/parse-cols.js +1 -1
  17. package/design-tokens/utilities/build-tokens-src/merge-css.mjs +0 -60
  18. /package/{design-tokens/utilities → dev-tools/design-tokens-builder}/build-tokens-src/resolve-source-paths.mjs +0 -0
  19. /package/{design-tokens/utilities → dev-tools/design-tokens-builder}/build-tokens-src/transforms.mjs +0 -0
@@ -216,20 +216,25 @@ export const collectConcreteFilePaths = async (sd) => {
216
216
  // @param {string|null} jsonDestFile Aggregated file base name, or null
217
217
  // @param {'json'|'jsonc'} jsonFormat Output format
218
218
  // @param {'keep'|'calc'|'resolve'} jsonExpression Expression handling mode
219
+ // @param {string} [suffix] Appended to every destination
220
+ // filename, before the extension — used by the sourceModes build
221
+ // (build-source-modes.mjs) to produce one set of files per mode, e.g.
222
+ // "-light" -> "tokens-light.jsonc" / "size-light.jsonc" (default: '')
219
223
  // @returns {object[]} File descriptors for the Style Dictionary platform
220
224
 
221
225
  export const buildJsonFiles = (
222
226
  concreteFilePaths,
223
227
  jsonDestFile,
224
228
  jsonFormat = 'json',
225
- jsonExpression = 'keep'
229
+ jsonExpression = 'keep',
230
+ suffix = ''
226
231
  ) => {
227
232
  const ext = jsonFormat === 'jsonc' ? '.jsonc' : '.json';
228
233
  const jsonc = jsonFormat === 'jsonc';
229
234
 
230
235
  if (jsonDestFile) {
231
236
  return [{
232
- destination: jsonDestFile + ext,
237
+ destination: jsonDestFile + suffix + ext,
233
238
  format: 'json/tokens',
234
239
  options: { jsonc, jsonExpression },
235
240
  }];
@@ -242,7 +247,7 @@ export const buildJsonFiles = (
242
247
  // one silently overwrite the other.
243
248
  const pathsByDestName = new Map();
244
249
  for (const filePath of concreteFilePaths) {
245
- const destName = path.basename(filePath).replace(/\.[^.]+$/, '') + ext;
250
+ const destName = path.basename(filePath).replace(/\.[^.]+$/, '') + suffix + ext;
246
251
  if (!pathsByDestName.has(destName)) pathsByDestName.set(destName, []);
247
252
  pathsByDestName.get(destName).push(filePath);
248
253
  }
@@ -260,7 +265,7 @@ export const buildJsonFiles = (
260
265
  return concreteFilePaths.map((filePath) => {
261
266
  const absPath = path.resolve(filePath);
262
267
  return {
263
- destination: path.basename(filePath).replace(/\.[^.]+$/, '') + ext,
268
+ destination: path.basename(filePath).replace(/\.[^.]+$/, '') + suffix + ext,
264
269
  format: 'json/tokens',
265
270
  options: { jsonc, jsonExpression },
266
271
  filter: (token) => {
@@ -0,0 +1,192 @@
1
+ // build-tokens-src/merge-css.mjs
2
+ // Supports the `mergeCustomProps` config option: pre-existing custom
3
+ // properties in the CSS destination file take priority over the ones
4
+ // generated by the current build (manual overrides/additions survive rebuilds).
5
+ //
6
+ // Usage (single `source` build):
7
+ // 1. Call loadExistingCustomProps(absDestPath) BEFORE Style Dictionary runs,
8
+ // since it reads the file that is about to be overwritten.
9
+ // 2. The css/variables-sorted format (see formats/css.mjs) calls
10
+ // mergeCustomProps(generatedMap) while building the output, right before
11
+ // serialising the final CSS text.
12
+ //
13
+ // Usage (sourceModes build, see build-source-modes.mjs):
14
+ // 1. Call loadExistingCustomPropsScoped(absDestPath, baseMode) BEFORE any
15
+ // of the per-mode Style Dictionary instances run.
16
+ // 2. The css/variables-sorted format calls mergeCustomProps(generatedMap, mode)
17
+ // once per mode. Declarations are matched only within the same mode's
18
+ // scope (the base `:root { ... }` block, or the `:root { ... }` nested
19
+ // inside that mode's `@media (prefers-color-scheme: <mode>) { ... }`),
20
+ // so the same custom property name defined with different values across
21
+ // modes is never mixed up.
22
+
23
+ import { existsSync, readFileSync } from 'node:fs';
24
+
25
+ const CUSTOM_PROP_RE = /^\s*--([\w-]+):\s*(.+)$/gm;
26
+
27
+ /**
28
+ * Parses `--name: <tail>` declarations out of a CSS text block. <tail> is
29
+ * captured verbatim (value, terminating `;`, and anything else on the same
30
+ * line, e.g. a trailing comment) so it round-trips unchanged through a merge.
31
+ * Only single-line declarations are supported.
32
+ * @param {string} cssText
33
+ * @returns {Record<string,string>} name → declaration-tail map, in first-seen order
34
+ */
35
+ export const parseCustomProps = (cssText) => {
36
+ /** @type {Record<string,string>} */
37
+ const map = {};
38
+ for (const match of cssText.matchAll(CUSTOM_PROP_RE)) {
39
+ map[match[1]] = match[2].trim();
40
+ }
41
+ return map;
42
+ };
43
+
44
+ // Returns the index of the closing brace matching the `{` at openIndex, by
45
+ // simple depth counting. Braces inside comments/strings are not special-cased
46
+ // — not expected to occur in generated custom-properties CSS.
47
+ /** @param {string} text @param {number} openIndex */
48
+ const matchBraces = (text, openIndex) => {
49
+ let depth = 0;
50
+ for (let i = openIndex; i < text.length; i++) {
51
+ if (text[i] === '{') depth++;
52
+ else if (text[i] === '}') {
53
+ depth--;
54
+ if (depth === 0) return i;
55
+ }
56
+ }
57
+ return -1;
58
+ };
59
+
60
+ // Finds every @media (prefers-color-scheme: <mode>) { ... } block in a
61
+ // previously generated sourceModes CSS destFile, with absolute character
62
+ // offsets into cssText. Used to split the file into per-mode scopes (see
63
+ // splitModeScopes() below) and, by check-unresolved-custom-props.mjs, to
64
+ // tell which mode a given custom property declaration belongs to.
65
+ // An optional wrapping `@layer { ... }` (addLayer option) is transparent to
66
+ // this search, since it only looks for the `@media (prefers-color-scheme: ...)`
67
+ // pattern itself.
68
+ // @param {string} cssText
69
+ // @returns {{mode:string,start:number,end:number}[]} start/end are the
70
+ // indices of the block's outer `{`/matching `}` (inclusive)
71
+ export const findMediaModeBlocks = (cssText) => {
72
+ const MEDIA_RE = /@media\s*\(\s*prefers-color-scheme\s*:\s*([\w-]+)\s*\)\s*\{/g;
73
+ /** @type {{mode:string,start:number,end:number}[]} */
74
+ const mediaBlocks = [];
75
+ for (const match of cssText.matchAll(MEDIA_RE)) {
76
+ const openIdx = match.index + match[0].length - 1;
77
+ const closeIdx = matchBraces(cssText, openIdx);
78
+ if (closeIdx === -1) continue;
79
+ mediaBlocks.push({ mode: match[1], start: openIdx, end: closeIdx });
80
+ }
81
+ return mediaBlocks;
82
+ };
83
+
84
+ // Splits a previously generated sourceModes CSS destFile into per-mode
85
+ // scopes (see build-source-modes.mjs for the shape of the generated file):
86
+ // - the base scope is the top-level `:root { ... }` block, i.e. the one
87
+ // NOT nested inside a `@media (prefers-color-scheme: ...)` rule
88
+ // - every other scope is the `:root { ... }` block nested inside its own
89
+ // `@media (prefers-color-scheme: <mode>) { ... }` rule
90
+ // @param {string} cssText
91
+ // @returns {Record<string,string>} mode → CSS body text (base mode keyed as '__base__')
92
+ const splitModeScopes = (cssText) => {
93
+ /** @type {Record<string,string>} */
94
+ const scopes = {};
95
+
96
+ // 1. Every @media (prefers-color-scheme: <mode>) { ... } block, and the
97
+ // :root { ... } body nested inside each one.
98
+ const mediaBlocks = findMediaModeBlocks(cssText);
99
+ for (const { mode, start, end } of mediaBlocks) {
100
+ const body = cssText.slice(start + 1, end);
101
+ const rootMatch = /:root\s*\{/.exec(body);
102
+ if (rootMatch) {
103
+ const rootOpen = rootMatch.index + rootMatch[0].length - 1;
104
+ const rootClose = matchBraces(body, rootOpen);
105
+ if (rootClose !== -1) {
106
+ scopes[mode] = body.slice(rootOpen + 1, rootClose);
107
+ }
108
+ }
109
+ }
110
+
111
+ // 2. The base :root { ... } block: the first one not nested inside any of
112
+ // the media blocks found above.
113
+ const ROOT_RE = /:root\s*\{/g;
114
+ for (const match of cssText.matchAll(ROOT_RE)) {
115
+ const openIdx = match.index + match[0].length - 1;
116
+ const insideMedia = mediaBlocks.some((b) => openIdx > b.start && openIdx < b.end);
117
+ if (insideMedia) continue;
118
+ const closeIdx = matchBraces(cssText, openIdx);
119
+ if (closeIdx === -1) continue;
120
+ scopes.__base__ = cssText.slice(openIdx + 1, closeIdx);
121
+ break;
122
+ }
123
+
124
+ return scopes;
125
+ };
126
+
127
+ // null = merge disabled, or destination file not found yet (first build)
128
+ /** @type {Record<string,string>|null} */
129
+ let existingCustomProps = null;
130
+
131
+ // Per-mode equivalent of existingCustomProps, populated by
132
+ // loadExistingCustomPropsScoped() for a sourceModes build.
133
+ /** @type {Record<string, Record<string,string>>|null} */
134
+ let scopedExistingCustomProps = null;
135
+
136
+ /**
137
+ * Reads the CSS destination file (if it exists) and stores a name → value
138
+ * map of its current custom properties for later merging.
139
+ * @param {string} absDestPath absolute path of the CSS file to read
140
+ * @returns {Record<string,string>|null}
141
+ */
142
+ export const loadExistingCustomProps = (absDestPath) => {
143
+ existingCustomProps = existsSync(absDestPath)
144
+ ? parseCustomProps(readFileSync(absDestPath, 'utf8'))
145
+ : null;
146
+ return existingCustomProps;
147
+ };
148
+
149
+ /**
150
+ * sourceModes equivalent of loadExistingCustomProps(): reads the CSS
151
+ * destination file (if it exists) and stores a per-mode name → value map,
152
+ * scoped to each mode's own `:root { ... }` block (see splitModeScopes()).
153
+ * @param {string} absDestPath absolute path of the CSS file to read
154
+ * @param {string} baseMode mode whose declarations live in the top-level,
155
+ * non-nested `:root { ... }` block (no default — always passed explicitly
156
+ * by build-source-modes.mjs, from the resolved sourceModesBase)
157
+ * @returns {Record<string, Record<string,string>>|null}
158
+ */
159
+ export const loadExistingCustomPropsScoped = (absDestPath, baseMode) => {
160
+ if (!existsSync(absDestPath)) {
161
+ scopedExistingCustomProps = null;
162
+ return scopedExistingCustomProps;
163
+ }
164
+ const scopes = splitModeScopes(readFileSync(absDestPath, 'utf8'));
165
+ scopedExistingCustomProps = {};
166
+ if (scopes.__base__ !== undefined) {
167
+ scopedExistingCustomProps[baseMode] = parseCustomProps(scopes.__base__);
168
+ }
169
+ for (const [mode, body] of Object.entries(scopes)) {
170
+ if (mode === '__base__') continue;
171
+ scopedExistingCustomProps[mode] = parseCustomProps(body);
172
+ }
173
+ return scopedExistingCustomProps;
174
+ };
175
+
176
+ /**
177
+ * Merges freshly generated custom properties with the ones previously loaded
178
+ * via loadExistingCustomProps() / loadExistingCustomPropsScoped(). Pre-existing
179
+ * values take priority; custom properties found only in the pre-existing file
180
+ * (no longer generated) are kept as well.
181
+ * @param {Record<string,string>} generated name → value map produced by this build
182
+ * @param {string|null} [mode] when set (sourceModes build), looks up the
183
+ * pre-existing props scoped to this mode instead of the flat/global map (default: null)
184
+ * @returns {Record<string,string>}
185
+ */
186
+ export const mergeCustomProps = (generated, mode = null) => {
187
+ if (mode !== null) {
188
+ const existing = scopedExistingCustomProps?.[mode];
189
+ return existing ? { ...generated, ...existing } : generated;
190
+ }
191
+ return existingCustomProps ? { ...generated, ...existingCustomProps } : generated;
192
+ };
@@ -28,7 +28,9 @@ export const CSS_TRANSFORMS = [
28
28
  // Kept minimal on purpose: consuming tools expect original values (e.g. "16px"
29
29
  // not "1rem") and alias references ({...}) must be preserved to maintain
30
30
  // token links.
31
- const JSON_TRANSFORMS = [
31
+ // Exported so build-source-modes.mjs can build a matching json platform per
32
+ // sourceModes mode.
33
+ export const JSON_TRANSFORMS = [
32
34
  'attribute/cti',
33
35
  'name/kebab',
34
36
  'color/css-modern',
@@ -12,6 +12,8 @@
12
12
  // build-tokens-src/formats/css.mjs <- registers the css/variables-sorted format
13
13
  // build-tokens-src/formats/json.mjs <- registers the json/tokens format, exports helpers
14
14
  // build-tokens-src/platforms.mjs <- builds the platforms object (css + optional json)
15
+ // build-tokens-src/build-source-modes.mjs <- alternative build path used when sourceModes is set
16
+ // build-tokens-src/merge-css.mjs <- supports the mergeCustomProps option (flat and per-mode)
15
17
 
16
18
  import StyleDictionary from 'style-dictionary';
17
19
  import * as path from 'node:path';
@@ -31,6 +33,8 @@ import {
31
33
  jsonFormat,
32
34
  jsonExpression,
33
35
  source,
36
+ sourceModes,
37
+ sourceModesBase,
34
38
  mergeCustomProps,
35
39
  customPropsGroups,
36
40
  pxToRem,
@@ -52,37 +56,15 @@ import { buildJsonFiles, collectConcreteFilePaths } from './build-tokens-src/for
52
56
  // ── 5. Platforms builder ──────────────────────────────────────────────────────
53
57
  import { buildPlatforms } from './build-tokens-src/platforms.mjs';
54
58
 
55
- // ── 5b. Pre-existing custom properties (mergeCustomProps option) ────────────
56
- // Read before Style Dictionary runs, since it overwrites the destination
57
- // file. The css/variables-sorted format (formats/css.mjs) merges these
58
- // values in — pre-existing values take priority — right before serialising
59
- // the final CSS output. See merge-css.mjs.
60
- if (mergeCustomProps) {
61
- loadExistingCustomProps(path.join(buildPath, destFile));
62
- }
63
-
64
- // ── 6. Collect concrete source file paths (multi-file JSON mode only) ────────
65
- // Source entries may be glob patterns. SD expands them internally and stores
66
- // the concrete path in token.filePath. We need those concrete paths to build
67
- // the per-file descriptors for the json platform.
68
- //
69
- // SD v5 lazy-loads sources: `await sd.hasInitialized` triggers loading;
70
- // after that sd.allTokens is a plain synchronous array with filePath populated.
71
- //
72
- // In single-file mode (jsonDestFile is set) we skip this step entirely.
73
- let concreteFilePaths = [];
74
-
75
- if (jsonBuildPath && !jsonDestFile) {
76
- const sdInit = new StyleDictionary({ source, log: { verbosity: 'silent' }, platforms: {} });
77
- concreteFilePaths = await collectConcreteFilePaths(sdInit);
78
- }
59
+ // ── 5b. sourceModes builder (alternative to the single-source build below) ──
60
+ import { buildSourceModes } from './build-tokens-src/build-source-modes.mjs';
79
61
 
80
- // ── 7. Clean json output directory ───────────────────────────────────────────
62
+ // ── 6. Clean json output directory ───────────────────────────────────────────
81
63
  // Remove only previously generated *.json/*.jsonc files, so that tokens
82
64
  // removed from the source are not left behind as stale artefacts, while any
83
65
  // other file placed in jsonBuildPath by the user (e.g. a README) is left
84
66
  // untouched. Files are written flat in jsonBuildPath (no subdirectories), so
85
- // only its top level is scanned.
67
+ // only its top level is scanned. Runs regardless of sourceModes.
86
68
  if (jsonBuildPath) {
87
69
  const { readdir, rm, mkdir } = await import('fs/promises');
88
70
  const existingEntries = await readdir(jsonBuildPath, { withFileTypes: true }).catch(() => []);
@@ -94,27 +76,82 @@ if (jsonBuildPath) {
94
76
  await mkdir(jsonBuildPath, { recursive: true });
95
77
  }
96
78
 
97
- // ── 8. Build ──────────────────────────────────────────────────────────────────
98
- const sd = new StyleDictionary({
99
- source,
100
- log: { verbosity: 'verbose' },
101
- platforms: buildPlatforms({
79
+ // ── 7. Build ──────────────────────────────────────────────────────────────────
80
+ // concreteFilePaths / modeResult are used below in the log section (10).
81
+ /** @type {string[]} */
82
+ let concreteFilePaths = [];
83
+ /** @type {Awaited<ReturnType<typeof buildSourceModes>>|null} */
84
+ let modeResult = null;
85
+
86
+ if (sourceModes) {
87
+ // sourceModes build: one StyleDictionary instance per mode, composed into
88
+ // a single destFile — see build-tokens-src/build-source-modes.mjs.
89
+ modeResult = await buildSourceModes({
90
+ sourceModes,
91
+ baseMode: sourceModesBase,
102
92
  buildPath,
103
93
  destFile,
104
94
  jsonBuildPath,
105
95
  jsonDestFile,
106
96
  jsonFormat,
107
97
  jsonExpression,
108
- concreteFilePaths,
109
98
  customPropsGroups,
110
99
  pxToRem,
111
100
  addLayer,
112
- }),
113
- });
101
+ mergeCustomProps,
102
+ });
114
103
 
115
- await sd.buildAllPlatforms();
104
+ } else {
105
+ // Single-source build (default).
106
+
107
+ // Pre-existing custom properties (mergeCustomProps option): read before
108
+ // Style Dictionary runs, since it overwrites the destination file. The
109
+ // css/variables-sorted format (formats/css.mjs) merges these values in —
110
+ // pre-existing values take priority — right before serialising the final
111
+ // CSS output. See merge-css.mjs.
112
+ if (mergeCustomProps) {
113
+ loadExistingCustomProps(path.join(buildPath, destFile));
114
+ }
116
115
 
117
- // ── 9. CSS lint ───────────────────────────────────────────────────────────────
116
+ // Collect concrete source file paths (multi-file JSON mode only). Source
117
+ // entries may be glob patterns. SD expands them internally and stores the
118
+ // concrete path in token.filePath. We need those concrete paths to build
119
+ // the per-file descriptors for the json platform.
120
+ //
121
+ // SD v5 lazy-loads sources: `await sd.hasInitialized` triggers loading;
122
+ // after that sd.allTokens is a plain synchronous array with filePath populated.
123
+ //
124
+ // In single-file mode (jsonDestFile is set) we skip this step entirely.
125
+ // source is only null when sourceModes is set (see config.mjs); this
126
+ // branch runs exclusively when sourceModes is not set.
127
+ const singleSource = /** @type {string[]} */ (source);
128
+
129
+ if (jsonBuildPath && !jsonDestFile) {
130
+ const sdInit = new StyleDictionary({ source: singleSource, log: { verbosity: 'silent' }, platforms: {} });
131
+ concreteFilePaths = await collectConcreteFilePaths(sdInit);
132
+ }
133
+
134
+ const sd = new StyleDictionary({
135
+ source: singleSource,
136
+ log: { verbosity: 'verbose' },
137
+ platforms: buildPlatforms({
138
+ buildPath,
139
+ destFile,
140
+ jsonBuildPath,
141
+ jsonDestFile,
142
+ jsonFormat,
143
+ jsonExpression,
144
+ concreteFilePaths,
145
+ customPropsGroups,
146
+ pxToRem,
147
+ addLayer,
148
+ }),
149
+ });
150
+
151
+ await sd.buildAllPlatforms();
152
+ }
153
+
154
+ // ── 8. CSS lint ───────────────────────────────────────────────────────────────
118
155
  const stylelintConfig = await import(pathToFileURL(stylelintConfigPath).href).then(m => m.default);
119
156
 
120
157
 
@@ -124,24 +161,39 @@ await stylelint.lint({
124
161
  fix: true,
125
162
  });
126
163
 
127
- // ── 10. Log ────────────────────────────────────────────────────────────────────
164
+ // ── 9. Log ────────────────────────────────────────────────────────────────────
128
165
  /** @param {string} p */
129
166
  const short = (p) => p.replace(homedir(), '~');
130
167
 
131
168
  console.log(styleText(['yellow'], `[build-tokens] config file : ${short(configAbsPath)}`));
132
- console.log(styleText(['yellow'], `[build-tokens] source : ${source.map(short)}`));
169
+
170
+ if (sourceModes) {
171
+ console.log(styleText(['yellow'],
172
+ `[build-tokens] modes : ${Object.keys(sourceModes).join(', ')} (base: ${sourceModesBase})`
173
+ ));
174
+ } else {
175
+ console.log(styleText(['yellow'], `[build-tokens] source : ${(/** @type {string[]} */ (source)).map(short)}`));
176
+ }
133
177
  console.log(styleText(['yellow'], `[build-tokens] dest file : ${short(path.join(buildPath, destFile))}`));
134
178
 
135
179
  if (jsonBuildPath) {
136
- const jsonFiles = buildJsonFiles(concreteFilePaths, jsonDestFile, jsonFormat, jsonExpression);
137
- if (jsonFiles.length === 1) {
180
+ if (sourceModes && modeResult) {
181
+ const totalJsonFiles = Object.values(modeResult.jsonFilesByMode)
182
+ .reduce((count, files) => count + files.length, 0);
138
183
  console.log(styleText(['yellow'],
139
- `[build-tokens] json : ${short(path.join(jsonBuildPath, jsonFiles[0].destination))}`
184
+ `[build-tokens] json : ${totalJsonFiles} files in ${short(jsonBuildPath)} (${Object.keys(sourceModes).length} modes)`
140
185
  ));
141
186
  } else {
142
- console.log(styleText(['yellow'],
143
- `[build-tokens] json : ${jsonFiles.length} files in ${short(jsonBuildPath)}`
144
- ));
187
+ const jsonFiles = buildJsonFiles(concreteFilePaths, jsonDestFile, jsonFormat, jsonExpression);
188
+ if (jsonFiles.length === 1) {
189
+ console.log(styleText(['yellow'],
190
+ `[build-tokens] json : ${short(path.join(jsonBuildPath, jsonFiles[0].destination))}`
191
+ ));
192
+ } else {
193
+ console.log(styleText(['yellow'],
194
+ `[build-tokens] json : ${jsonFiles.length} files in ${short(jsonBuildPath)}`
195
+ ));
196
+ }
145
197
  }
146
198
  } else {
147
199
  console.log(styleText(['yellow'],
@@ -149,5 +201,7 @@ if (jsonBuildPath) {
149
201
  ));
150
202
  }
151
203
 
152
- console.log(styleText(['green'], `[build-tokens] ${customPropsCount} custom properties processed`));
204
+ console.log(styleText(['green'],
205
+ `[build-tokens] ${sourceModes && modeResult ? modeResult.totalCustomProps : customPropsCount} custom properties processed`
206
+ ));
153
207
  console.log(styleText(['green'], '**** DONE ****'));
@@ -25,6 +25,7 @@ import StyleDictionary from 'style-dictionary';
25
25
  import './build-tokens-src/transforms.mjs';
26
26
  import { CSS_TRANSFORMS } from './build-tokens-src/platforms.mjs';
27
27
  import { resolveSourcePaths } from './build-tokens-src/resolve-source-paths.mjs';
28
+ import { findMediaModeBlocks } from './build-tokens-src/merge-css.mjs';
28
29
 
29
30
  // const __filename = fileURLToPath(import.meta.url);
30
31
  // const __dirname = dirname(__filename);
@@ -90,25 +91,48 @@ async function run() {
90
91
  const definitionRegex = /--([a-z0-9-]+)(?=\s*:)/g;
91
92
 
92
93
  // defined_props keeps file/line info too, so unused properties (section 6)
93
- // can be reported with a link back to their definition.
94
- /** @type {{file: string, line: number, prop: string}[]} */
94
+ // can be reported with a link back to their definition. With sourceModes
95
+ // (see build-tokens-src/config.mjs), the same prop name can legitimately
96
+ // appear once per mode with a different value — `mode` disambiguates
97
+ // which one a given entry is, so section 5 resolves each to the correct
98
+ // per-mode source file instead of mixing them up (see findMediaModeBlocks
99
+ // in build-tokens-src/merge-css.mjs).
100
+ /** @type {{file: string, line: number, prop: string, mode: string|null}[]} */
95
101
  const defined_props = [];
96
102
 
97
103
  const cssContent = fs.readFileSync(custom_prop_file_path, 'utf-8');
98
104
  const custom_prop_relpath = path.relative(configDir, custom_prop_file_path);
105
+
106
+ // sourceModesBase mirrors the default-resolution logic in
107
+ // build-tokens-src/config.mjs: the configured sourceModesBase if it's
108
+ // actually a key of sourceModes, otherwise the first key.
109
+ const sourceModeNames = config.sourceModes ? Object.keys(config.sourceModes) : [];
110
+ const sourceModesBase = sourceModeNames.includes(config.sourceModesBase)
111
+ ? config.sourceModesBase
112
+ : sourceModeNames[0];
113
+ const mediaModeBlocks = config.sourceModes ? findMediaModeBlocks(cssContent) : [];
114
+
115
+ /** @param {number} index @returns {string|null} */
116
+ const modeAtIndex = (index) => {
117
+ if (!config.sourceModes) return null;
118
+ const block = mediaModeBlocks.find(b => index > b.start && index < b.end);
119
+ return block ? block.mode : sourceModesBase;
120
+ };
121
+
99
122
  [...cssContent.matchAll(definitionRegex)].forEach(m => {
100
123
  const line = cssContent.substring(0, m.index).split('\n').length;
101
- defined_props.push({ file: custom_prop_relpath, line, prop: `--${m[1]}` });
124
+ defined_props.push({ file: custom_prop_relpath, line, prop: `--${m[1]}`, mode: modeAtIndex(m.index) });
102
125
  });
103
126
 
104
- // Also include any extra CSS files declared in extraCustomPropsFiles
127
+ // Also include any extra CSS files declared in extraCustomPropsFiles.
128
+ // These are plain (non-sourceModes) files, so mode is always null.
105
129
  for (const extraFile of (config.extraCustomPropsFiles ?? [])) {
106
130
  const extraFilePath = path.resolve(configDir, extraFile);
107
131
  const extraContent = fs.readFileSync(extraFilePath, 'utf-8');
108
132
  const extraRelpath = path.relative(configDir, extraFilePath);
109
133
  [...extraContent.matchAll(definitionRegex)].forEach(m => {
110
134
  const line = extraContent.substring(0, m.index).split('\n').length;
111
- defined_props.push({ file: extraRelpath, line, prop: `--${m[1]}` });
135
+ defined_props.push({ file: extraRelpath, line, prop: `--${m[1]}`, mode: null });
112
136
  });
113
137
  }
114
138
 
@@ -161,28 +185,57 @@ async function run() {
161
185
  // CSS (or an extraCustomPropsFiles entry) — e.g. manual additions kept
162
186
  // across rebuilds via the mergeCustomProps option — have no token source
163
187
  // and are reported by name only.
188
+ //
189
+ // With sourceModes, resolution runs once PER MODE, keyed by mode name in
190
+ // sourceFileByPropByMode: the same prop name can be defined by a
191
+ // different source file in each mode (see the `dead: {...}` example in
192
+ // both a light and dark token file), so a single flattened name -> file
193
+ // map would silently point some entries at the wrong mode's file.
194
+ // sourceFileByProp (mode: null) covers the non-sourceModes case and
195
+ // extraCustomPropsFiles entries.
164
196
  // ---------------------------------------------------------------------------
165
197
  /** @type {Map<string, string>} */
166
198
  const sourceFileByProp = new Map();
167
-
168
- if (checkUnused && Array.isArray(config.source) && config.source.length) {
199
+ /** @type {Map<string, Map<string, string>>} */
200
+ const sourceFileByPropByMode = new Map();
201
+
202
+ /** @param {string[]} sourcePatterns @returns {Promise<Map<string,string>>} */
203
+ const resolveSourceFileMap = async (sourcePatterns) => {
204
+ /** @type {Map<string, string>} */
205
+ const map = new Map();
206
+ const resolvedPaths = resolveSourcePaths(sourcePatterns, configDir);
207
+ if (!resolvedPaths.length) return map;
169
208
  try {
170
- const tokenSourcePaths = resolveSourcePaths(config.source, configDir);
171
209
  const sd = new StyleDictionary({
172
- source: tokenSourcePaths,
210
+ source: resolvedPaths,
173
211
  log: { verbosity: 'silent' },
174
212
  platforms: { css: { transforms: CSS_TRANSFORMS } },
175
213
  });
176
214
  const dictionary = await sd.getPlatformTokens('css');
177
-
178
215
  for (const token of dictionary.allTokens) {
179
- if (token.filePath) sourceFileByProp.set(`--${token.name}`, token.filePath);
216
+ if (token.filePath) map.set(`--${token.name}`, token.filePath);
180
217
  }
181
218
  } catch {
182
219
  // Non-fatal: unused properties will be reported by name only.
183
220
  }
221
+ return map;
222
+ };
223
+
224
+ if (checkUnused && config.sourceModes) {
225
+ for (const [mode, modeSource] of Object.entries(config.sourceModes)) {
226
+ sourceFileByPropByMode.set(mode, await resolveSourceFileMap(modeSource));
227
+ }
228
+ } else if (checkUnused && Array.isArray(config.source) && config.source.length) {
229
+ const map = await resolveSourceFileMap(config.source);
230
+ for (const [prop, filePath] of map) sourceFileByProp.set(prop, filePath);
184
231
  }
185
232
 
233
+ /** @param {{prop: string, mode: string|null}} item @returns {string|undefined} */
234
+ const resolveSourceFilePath = (item) =>
235
+ item.mode
236
+ ? sourceFileByPropByMode.get(item.mode)?.get(item.prop)
237
+ : sourceFileByProp.get(item.prop);
238
+
186
239
  // ---------------------------------------------------------------------------
187
240
  // 6. Build the list of unused custom properties (defined but never
188
241
  // referenced via var() in any scanned CSS file) — the opposite check
@@ -194,7 +247,7 @@ async function run() {
194
247
  .filter(item => !used_props_set.has(item.prop))
195
248
  .filter(item => {
196
249
  if (!ignoreUnusedInNodeModules) return true;
197
- const sourceFilePath = sourceFileByProp.get(item.prop);
250
+ const sourceFilePath = resolveSourceFilePath(item);
198
251
  if (!sourceFilePath) return true;
199
252
  return !/(^|[\\/])node_modules([\\/]|$)/.test(sourceFilePath);
200
253
  })
@@ -232,7 +285,7 @@ async function run() {
232
285
  '## Unused custom properties\n\n'
233
286
  + unused_props
234
287
  .map(item => {
235
- const sourceFilePath = sourceFileByProp.get(item.prop);
288
+ const sourceFilePath = resolveSourceFilePath(item);
236
289
  if (!sourceFilePath) return `* \`${item.prop}\``;
237
290
 
238
291
  const relSource = path.relative(configDir, sourceFilePath);
@@ -46,9 +46,47 @@ const config = {
46
46
 
47
47
  // your project tokens
48
48
  './project-tokens/*.{js,mjs,jsonc,json}',
49
-
50
49
  ],
51
50
 
51
+ // Optional light/dark (or other) custom properties split — alternative to
52
+ // `source`. If this key is set (a non-null object), `source` above is
53
+ // ignored: each key is a mode name with its own array of source patterns
54
+ // (same rules as `source`: paths, globs, or a mix).
55
+ //
56
+ // The generated CSS composes all modes into a single destFile:
57
+ // - the base mode's props (see sourceModesBase below) go in a top-level
58
+ // `:root { ... }` block, with `color-scheme: <all modes>;` prepended
59
+ // (e.g. "light dark")
60
+ // - every other mode's props go in
61
+ // `@media (prefers-color-scheme: <mode>) { :root { ... } }`, with
62
+ // `color-scheme: <mode>;` prepended
63
+ // customPropsGroups, mergeCustomProps, addLayer, pxToRem etc. all apply
64
+ // the same way as with a single `source`.
65
+ //
66
+ // The JSON output (see JSON TOKENS OUTPUT below) is produced once per
67
+ // mode, with the mode name appended to each filename, e.g. with
68
+ // jsonDestFile: 'tokens' -> "tokens-light.jsonc", "tokens-dark.jsonc".
69
+ //
70
+ // sample:
71
+ // sourceModes: {
72
+ // light: [
73
+ // `${minimo_path}/design-tokens/_src/**/*.tokens.{mjs,jsonc}`,
74
+ // `${minimo_path}/src/**/*.tokens.{mjs,jsonc}`,
75
+ // './project-tokens/light/*.{js,mjs,jsonc,json}',
76
+ // ],
77
+ // dark: [
78
+ // './project-tokens/dark/*.{js,mjs,jsonc,json}',
79
+ // ],
80
+ // },
81
+ sourceModes: null,
82
+
83
+ // With sourceModes set: the mode whose declarations go in the top-level
84
+ // `:root { ... }` block, instead of nested inside its own
85
+ // `@media (prefers-color-scheme: <mode>) { ... }` rule (default: the first
86
+ // key of sourceModes, e.g. 'light'). Ignored when sourceModes is not set.
87
+ sourceModesBase: 'light',
88
+
89
+
52
90
  // If true (default), dimension token values expressed in px are converted
53
91
  // to rem in the generated CSS. Values in other units (em, %, vh, dvw, ...)
54
92
  // are never touched regardless of this option.
@@ -17,9 +17,9 @@
17
17
  "*.avif"
18
18
  ],
19
19
  "scripts": {
20
- "progetto JS webpack DEV": "npm run 'launchChrome' && NODE_ENV=development webpack serve --config ./webpack.config.mjs #--open-app-name 'Google Chrome'",
20
+ "progetto JS webpack DEV": "NODE_ENV=development webpack serve --config ./webpack.config.mjs #--open-app-name 'Google Chrome'",
21
21
  "php server": "php -S localhost:[[port8000]] # -t root_dir/",
22
- "progetto sf webpack DEV": "npm run 'launchChrome' && rm -rf ./public/_dev && NODE_ENV=development webpack serve --config ./webpack.config.mjs # http://192.168.1.135:[[port5700]",
22
+ "progetto sf webpack DEV": "rm -rf ./public/_dev && NODE_ENV=development webpack serve --config ./webpack.config.mjs # http://192.168.1.135:[[port5700]",
23
23
  "symfony server": "symfony serve --port=[[port8000]] --listen-ip=0.0.0.0 # http://192.168.1.135:[[port8000]] # -d (deamon): rimane aperto anche dopo la chiusura del terminale, terminare con `symfony server:stop`",
24
24
  "webpack PROD": "npm run 'UPD-version' && NODE_ENV=production webpack --config ./webpack.config.mjs --stats minimal",
25
25
  "eslint-check": "npx eslint app/",