@massimo-cassandro/minimo 1.33.7 → 1.35.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 (20) 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 +212 -0
  4. package/dev-tools/design-tokens-builder/build-tokens-src/build-source-modes.mjs +155 -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/legacy-tokens-parser.mjs +105 -0
  9. package/dev-tools/design-tokens-builder/build-tokens-src/merge-css.mjs +192 -0
  10. package/{design-tokens/utilities → dev-tools/design-tokens-builder}/build-tokens-src/platforms.mjs +3 -1
  11. package/dev-tools/design-tokens-builder/build-tokens.mjs +219 -0
  12. package/{design-tokens/utilities → dev-tools/design-tokens-builder}/check-unresolved-custom-props.mjs +70 -13
  13. package/{design-tokens → dev-tools/design-tokens-builder}/tokens-config-sample.mjs +55 -3
  14. package/dev-tools/starter-kit/source_files/root/__project__.code-workspace +4 -0
  15. package/dev-tools/starter-kit/starter-install.sh +2 -2
  16. package/package.json +6 -8
  17. package/design-tokens/utilities/build-tokens-src/merge-css.mjs +0 -60
  18. package/design-tokens/utilities/build-tokens.mjs +0 -153
  19. /package/{design-tokens/utilities → dev-tools/design-tokens-builder}/build-tokens-src/resolve-source-paths.mjs +0 -0
  20. /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,105 @@
1
+ // build-tokens-src/legacy-tokens-parser.mjs
2
+ // Registers a Style Dictionary parser that bridges "legacy" token files
3
+ // (Style Dictionary v3/v4-style syntax: no `$` prefix on `value`/`type`,
4
+ // references written as `{group.token.value}`) into the W3C DTCG syntax
5
+ // (`$value`/`$type`, references as `{group.token}`) required by Style
6
+ // Dictionary v5.
7
+ //
8
+ // Why this is needed: Style Dictionary v5 decides whether a build "usesDtcg"
9
+ // ONCE per build, from whichever source file is processed first (see
10
+ // detectDtcgSyntax() called inside combineJSON.js in style-dictionary) — it
11
+ // is a single global flag, not a per-file check. Mixing a legacy-syntax file
12
+ // (e.g. a file exported from Open Props, or from an old Style Dictionary
13
+ // v3/v4 project) together with DTCG sources therefore causes the legacy
14
+ // file's tokens to be silently dropped regardless of file order: they never
15
+ // carry the `$value` key combineJSON's own tagging (and the rest of the
16
+ // pipeline) expects.
17
+ //
18
+ // This parser intercepts every `.json` source file (not `.jsonc` — that
19
+ // extension is reserved in this project for hand-authored DTCG sources,
20
+ // already handled correctly by Style Dictionary's own built-in loader) and
21
+ // converts, node by node:
22
+ // value -> $value, type -> $type, comment/description -> $description
23
+ // "{group.token.value}" -> "{group.token}" inside reference strings
24
+ // Nodes that already use `$value` (DTCG v5 syntax) are left untouched, so
25
+ // the parser is a safe no-op for files that don't need conversion — no
26
+ // config flag or file list is required, detection is fully automatic.
27
+ //
28
+ // Limitation: `.json` files handled by this parser are parsed with plain
29
+ // JSON.parse (no comments, no trailing commas), unlike Style Dictionary's
30
+ // own built-in loader (which uses JSON5 for .json/.jsonc/.json5). This keeps
31
+ // the builder dependency-free — legacy token exports (Open Props, old Style
32
+ // Dictionary v3/v4 projects) are virtually always strict JSON anyway. Use
33
+ // `.jsonc` or `.mjs` for hand-authored DTCG sources that need comments.
34
+
35
+ import StyleDictionary from 'style-dictionary';
36
+
37
+ export const LEGACY_TOKENS_PARSER_NAME = 'minimo/legacy-tokens';
38
+
39
+ // Matches "{some.token.path.value}" -> captures "some.token.path"
40
+ const LEGACY_REF = /\{([^}]+)\.value\}/g;
41
+
42
+ /** @param {unknown} value @returns {unknown} */
43
+ const convertLegacyRefs = (value) => {
44
+ if (typeof value === 'string') {
45
+ return value.replace(LEGACY_REF, '{$1}');
46
+ }
47
+ if (Array.isArray(value)) {
48
+ return value.map(convertLegacyRefs);
49
+ }
50
+ if (value !== null && typeof value === 'object') {
51
+ return Object.fromEntries(
52
+ Object.entries(value).map(([key, v]) => [key, convertLegacyRefs(v)])
53
+ );
54
+ }
55
+ return value;
56
+ };
57
+
58
+ /** @param {unknown} node @returns {node is Record<string, unknown>} */
59
+ const isPlainObject = (node) => node !== null && typeof node === 'object' && !Array.isArray(node);
60
+
61
+ /** @param {unknown} node @returns {unknown} */
62
+ const convertLegacyNode = (node) => {
63
+ if (!isPlainObject(node)) return node;
64
+
65
+ // Already DTCG v5 syntax: leave untouched.
66
+ if (Object.hasOwn(node, '$value')) return node;
67
+
68
+ if (Object.hasOwn(node, 'value')) {
69
+ // Legacy token leaf. Some legacy files (e.g. Open Props) also attach
70
+ // sibling numbered/named children to a node that carries its own
71
+ // `value`/`type` (a group that is also a default token) — those
72
+ // children are converted too, even though Style Dictionary itself
73
+ // cannot represent "group + own $value" ambiguity natively.
74
+ const { value, type, comment, description, ...rest } = node;
75
+ const convertedRest = Object.fromEntries(
76
+ Object.entries(rest).map(([key, child]) => [key, convertLegacyNode(child)])
77
+ );
78
+ const desc = comment ?? description;
79
+
80
+ return {
81
+ ...convertedRest,
82
+ $value: convertLegacyRefs(value),
83
+ ...(type !== undefined ? { $type: type } : {}),
84
+ ...(desc !== undefined ? { $description: desc } : {}),
85
+ };
86
+ }
87
+
88
+ // Group node: recurse into children.
89
+ return Object.fromEntries(
90
+ Object.entries(node).map(([key, child]) => [key, convertLegacyNode(child)])
91
+ );
92
+ };
93
+
94
+ // Registers the parser globally on the StyleDictionary class. To actually
95
+ // run it, a Style Dictionary instance must also opt in via
96
+ // `parsers: [LEGACY_TOKENS_PARSER_NAME]` in its config (registering alone
97
+ // does not activate it for every instance — see Style Dictionary's parser
98
+ // hooks docs).
99
+ export const registerLegacyTokensParser = () => {
100
+ StyleDictionary.registerParser({
101
+ name: LEGACY_TOKENS_PARSER_NAME,
102
+ pattern: /\.json$/,
103
+ parser: ({ contents }) => convertLegacyNode(JSON.parse(contents)),
104
+ });
105
+ };
@@ -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',
@@ -0,0 +1,219 @@
1
+ #!/usr/bin/env node
2
+ /// <reference types="node" />
3
+ /* eslint-disable no-console */
4
+
5
+ // build-tokens.mjs
6
+ // Entry point: imports modules, runs the build, lints the CSS output, prints the log.
7
+ //
8
+ // Project structure:
9
+ // build-tokens.mjs <- this file
10
+ // build-tokens-src/config.mjs <- reads --config flag and resolves all paths
11
+ // build-tokens-src/transforms.mjs <- registers custom Style Dictionary transforms
12
+ // build-tokens-src/formats/css.mjs <- registers the css/variables-sorted format
13
+ // build-tokens-src/formats/json.mjs <- registers the json/tokens format, exports helpers
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)
17
+
18
+ import StyleDictionary from 'style-dictionary';
19
+ import * as path from 'node:path';
20
+ import { styleText } from 'node:util';
21
+ import { homedir } from 'node:os';
22
+ import stylelint from 'stylelint';
23
+ import { pathToFileURL } from 'node:url';
24
+
25
+ // ── 1. Configuration (args + resolved paths) ────────────────────────────────
26
+ import {
27
+ configAbsPath,
28
+ buildPath,
29
+ destFile,
30
+ stylelintConfigPath,
31
+ jsonBuildPath,
32
+ jsonDestFile,
33
+ jsonFormat,
34
+ jsonExpression,
35
+ source,
36
+ sourceModes,
37
+ sourceModesBase,
38
+ mergeCustomProps,
39
+ customPropsGroups,
40
+ pxToRem,
41
+ addLayer,
42
+ } from './build-tokens-src/config.mjs';
43
+
44
+ // ── 2. Custom transforms ─────────────────────────────────────────────────────
45
+ import './build-tokens-src/transforms.mjs';
46
+
47
+ // ── 2b. Legacy token syntax bridge ───────────────────────────────────────────
48
+ // Converts legacy (non-DTCG, e.g. Open Props) .json/.jsonc source files to
49
+ // DTCG v5 syntax at parse time — see build-tokens-src/legacy-tokens-parser.mjs.
50
+ import { LEGACY_TOKENS_PARSER_NAME, registerLegacyTokensParser } from './build-tokens-src/legacy-tokens-parser.mjs';
51
+ registerLegacyTokensParser();
52
+
53
+ // ── 3. CSS format ─────────────────────────────────────────────────────────────
54
+ // customPropsCount is updated by the format at render time
55
+ // loadExistingCustomProps supports the mergeCustomProps option (see below)
56
+ import { customPropsCount } from './build-tokens-src/formats/css.mjs';
57
+ import { loadExistingCustomProps } from './build-tokens-src/merge-css.mjs';
58
+
59
+ // ── 4. JSON format ────────────────────────────────────────────────────────────
60
+ import { buildJsonFiles, collectConcreteFilePaths } from './build-tokens-src/formats/json.mjs';
61
+
62
+ // ── 5. Platforms builder ──────────────────────────────────────────────────────
63
+ import { buildPlatforms } from './build-tokens-src/platforms.mjs';
64
+
65
+ // ── 5b. sourceModes builder (alternative to the single-source build below) ──
66
+ import { buildSourceModes } from './build-tokens-src/build-source-modes.mjs';
67
+
68
+ // ── 6. Clean json output directory ───────────────────────────────────────────
69
+ // Remove only previously generated *.json/*.jsonc files, so that tokens
70
+ // removed from the source are not left behind as stale artefacts, while any
71
+ // other file placed in jsonBuildPath by the user (e.g. a README) is left
72
+ // untouched. Files are written flat in jsonBuildPath (no subdirectories), so
73
+ // only its top level is scanned. Runs regardless of sourceModes.
74
+ if (jsonBuildPath) {
75
+ const { readdir, rm, mkdir } = await import('fs/promises');
76
+ const existingEntries = await readdir(jsonBuildPath, { withFileTypes: true }).catch(() => []);
77
+ await Promise.all(
78
+ existingEntries
79
+ .filter((entry) => entry.isFile() && /\.jsonc?$/.test(entry.name))
80
+ .map((entry) => rm(path.join(jsonBuildPath, entry.name)))
81
+ );
82
+ await mkdir(jsonBuildPath, { recursive: true });
83
+ }
84
+
85
+ // ── 7. Build ──────────────────────────────────────────────────────────────────
86
+ // concreteFilePaths / modeResult are used below in the log section (10).
87
+ /** @type {string[]} */
88
+ let concreteFilePaths = [];
89
+ /** @type {Awaited<ReturnType<typeof buildSourceModes>>|null} */
90
+ let modeResult = null;
91
+
92
+ if (sourceModes) {
93
+ // sourceModes build: one StyleDictionary instance per mode, composed into
94
+ // a single destFile — see build-tokens-src/build-source-modes.mjs.
95
+ modeResult = await buildSourceModes({
96
+ sourceModes,
97
+ baseMode: sourceModesBase,
98
+ buildPath,
99
+ destFile,
100
+ jsonBuildPath,
101
+ jsonDestFile,
102
+ jsonFormat,
103
+ jsonExpression,
104
+ customPropsGroups,
105
+ pxToRem,
106
+ addLayer,
107
+ mergeCustomProps,
108
+ });
109
+
110
+ } else {
111
+ // Single-source build (default).
112
+
113
+ // Pre-existing custom properties (mergeCustomProps option): read before
114
+ // Style Dictionary runs, since it overwrites the destination file. The
115
+ // css/variables-sorted format (formats/css.mjs) merges these values in —
116
+ // pre-existing values take priority — right before serialising the final
117
+ // CSS output. See merge-css.mjs.
118
+ if (mergeCustomProps) {
119
+ loadExistingCustomProps(path.join(buildPath, destFile));
120
+ }
121
+
122
+ // Collect concrete source file paths (multi-file JSON mode only). Source
123
+ // entries may be glob patterns. SD expands them internally and stores the
124
+ // concrete path in token.filePath. We need those concrete paths to build
125
+ // the per-file descriptors for the json platform.
126
+ //
127
+ // SD v5 lazy-loads sources: `await sd.hasInitialized` triggers loading;
128
+ // after that sd.allTokens is a plain synchronous array with filePath populated.
129
+ //
130
+ // In single-file mode (jsonDestFile is set) we skip this step entirely.
131
+ // source is only null when sourceModes is set (see config.mjs); this
132
+ // branch runs exclusively when sourceModes is not set.
133
+ const singleSource = /** @type {string[]} */ (source);
134
+
135
+ if (jsonBuildPath && !jsonDestFile) {
136
+ const sdInit = new StyleDictionary({
137
+ source: singleSource,
138
+ parsers: [LEGACY_TOKENS_PARSER_NAME],
139
+ log: { verbosity: 'silent' },
140
+ platforms: {},
141
+ });
142
+ concreteFilePaths = await collectConcreteFilePaths(sdInit);
143
+ }
144
+
145
+ const sd = new StyleDictionary({
146
+ source: singleSource,
147
+ parsers: [LEGACY_TOKENS_PARSER_NAME],
148
+ log: { verbosity: 'verbose' },
149
+ platforms: buildPlatforms({
150
+ buildPath,
151
+ destFile,
152
+ jsonBuildPath,
153
+ jsonDestFile,
154
+ jsonFormat,
155
+ jsonExpression,
156
+ concreteFilePaths,
157
+ customPropsGroups,
158
+ pxToRem,
159
+ addLayer,
160
+ }),
161
+ });
162
+
163
+ await sd.buildAllPlatforms();
164
+ }
165
+
166
+ // ── 8. CSS lint ───────────────────────────────────────────────────────────────
167
+ const stylelintConfig = await import(pathToFileURL(stylelintConfigPath).href).then(m => m.default);
168
+
169
+
170
+ await stylelint.lint({
171
+ config: stylelintConfig,
172
+ files: [path.join(buildPath, destFile)],
173
+ fix: true,
174
+ });
175
+
176
+ // ── 9. Log ────────────────────────────────────────────────────────────────────
177
+ /** @param {string} p */
178
+ const short = (p) => p.replace(homedir(), '~');
179
+
180
+ console.log(styleText(['yellow'], `[build-tokens] config file : ${short(configAbsPath)}`));
181
+
182
+ if (sourceModes) {
183
+ console.log(styleText(['yellow'],
184
+ `[build-tokens] modes : ${Object.keys(sourceModes).join(', ')} (base: ${sourceModesBase})`
185
+ ));
186
+ } else {
187
+ console.log(styleText(['yellow'], `[build-tokens] source : ${(/** @type {string[]} */ (source)).map(short)}`));
188
+ }
189
+ console.log(styleText(['yellow'], `[build-tokens] dest file : ${short(path.join(buildPath, destFile))}`));
190
+
191
+ if (jsonBuildPath) {
192
+ if (sourceModes && modeResult) {
193
+ const totalJsonFiles = Object.values(modeResult.jsonFilesByMode)
194
+ .reduce((count, files) => count + files.length, 0);
195
+ console.log(styleText(['yellow'],
196
+ `[build-tokens] json : ${totalJsonFiles} files in ${short(jsonBuildPath)} (${Object.keys(sourceModes).length} modes)`
197
+ ));
198
+ } else {
199
+ const jsonFiles = buildJsonFiles(concreteFilePaths, jsonDestFile, jsonFormat, jsonExpression);
200
+ if (jsonFiles.length === 1) {
201
+ console.log(styleText(['yellow'],
202
+ `[build-tokens] json : ${short(path.join(jsonBuildPath, jsonFiles[0].destination))}`
203
+ ));
204
+ } else {
205
+ console.log(styleText(['yellow'],
206
+ `[build-tokens] json : ${jsonFiles.length} files in ${short(jsonBuildPath)}`
207
+ ));
208
+ }
209
+ }
210
+ } else {
211
+ console.log(styleText(['yellow'],
212
+ '[build-tokens] json : (disabled — jsonBuildPath not set)'
213
+ ));
214
+ }
215
+
216
+ console.log(styleText(['green'],
217
+ `[build-tokens] ${sourceModes && modeResult ? modeResult.totalCustomProps : customPropsCount} custom properties processed`
218
+ ));
219
+ console.log(styleText(['green'], '**** DONE ****'));