@massimo-cassandro/minimo 1.35.0 → 1.36.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.
@@ -161,7 +161,9 @@ When set, each mode has its own `source` array, and the generated CSS composes a
161
161
  - the base mode's props go in a top-level `:root { ... }` block, with `color-scheme: <all modes>;` prepended
162
162
  - every other mode's props go in `@media (prefers-color-scheme: <mode>) { :root { ... } }`, with `color-scheme: <mode>;` prepended
163
163
 
164
- `customPropsGroups`, `mergeCustomProps`, `addLayer`, `pxToRem` etc. all apply the same way as with a single `source`. The JSON output (when `jsonBuildPath` is set) is produced once per mode, with the mode name appended to each filename (e.g. `tokens-light.jsonc` / `tokens-dark.jsonc`).
164
+ Tokens of a non-base mode can reference tokens defined in the base mode (e.g. a dark token using `{color.1}` defined in light, output as `var(--color-1)`). References to a non-base mode's tokens (from the base mode or from another non-base mode) fail with a "reference not defined" error, since the target would be undefined outside its own `@media` block.
165
+
166
+ `customPropsGroups`, `mergeCustomProps`, `addLayer`, `pxToRem` etc. all apply the same way as with a single `source` (`mergeCustomProps` set to an array of strings/RegExps matches the token files of every mode). The JSON output (when `jsonBuildPath` is set) is produced once per mode, with the mode name appended to each filename (e.g. `tokens-light.jsonc` / `tokens-dark.jsonc`).
165
167
 
166
168
  ---
167
169
 
@@ -44,7 +44,7 @@ import { LEGACY_TOKENS_PARSER_NAME } from './legacy-tokens-parser.mjs';
44
44
  * @param {{name:string,prefixes:string[]}[]} opts.customPropsGroups
45
45
  * @param {boolean} opts.pxToRem
46
46
  * @param {string|null} opts.addLayer
47
- * @param {boolean} opts.mergeCustomProps
47
+ * @param {boolean|(string|RegExp)[]} opts.mergeCustomProps true = merge all sources, array = only the matching token source files
48
48
  * @returns {Promise<{cssDestPath: string, totalCustomProps: number, jsonFilesByMode: Record<string, object[]>}>}
49
49
  */
50
50
  export const buildSourceModes = async ({
@@ -66,7 +66,7 @@ export const buildSourceModes = async ({
66
66
  // Read the pre-existing destFile (if any) BEFORE any Style Dictionary
67
67
  // instance runs, since the first one to write would overwrite it.
68
68
  if (mergeCustomProps) {
69
- loadExistingCustomPropsScoped(path.join(buildPath, destFile), baseMode);
69
+ loadExistingCustomPropsScoped(path.join(buildPath, destFile), baseMode, mergeCustomProps);
70
70
  }
71
71
 
72
72
  const cssTransforms = pxToRem
@@ -80,7 +80,16 @@ export const buildSourceModes = async ({
80
80
  const jsonFilesByMode = {};
81
81
 
82
82
  for (const mode of modeNames) {
83
+ // Non-base modes may reference base-mode tokens (always defined, since
84
+ // the base block is the unconditional top-level `:root`). The base
85
+ // sources are passed as `include`: available for reference resolution
86
+ // but flagged isSource: false, and skipped by the css/json formats.
87
+ // Base-mode-only on purpose: a reference to a token that exists only in
88
+ // another `@media` block would build fine but be undefined at runtime.
89
+ const include = mode === baseMode ? [] : sourceModes[baseMode];
90
+
83
91
  const sd = new StyleDictionary({
92
+ include,
84
93
  source: sourceModes[mode],
85
94
  parsers: [LEGACY_TOKENS_PARSER_NAME],
86
95
  log: { verbosity: 'verbose' },
@@ -116,6 +125,7 @@ export const buildSourceModes = async ({
116
125
  const files = buildJsonFiles(concreteFilePaths, jsonDestFile, jsonFormat, jsonExpression, `-${mode}`);
117
126
 
118
127
  const jsonSd = new StyleDictionary({
128
+ include,
119
129
  source: sourceModes[mode],
120
130
  parsers: [LEGACY_TOKENS_PARSER_NAME],
121
131
  log: { verbosity: 'silent' },
@@ -84,8 +84,19 @@ export const pxToRem = buildConfig.pxToRem !== false;
84
84
 
85
85
  // mergeCustomProps: if true, custom properties already present in the CSS
86
86
  // destination file take priority over the ones generated by this build (see
87
- // build-tokens-src/merge-css.mjs). Default: false.
88
- export const mergeCustomProps = buildConfig.mergeCustomProps === true;
87
+ // build-tokens-src/merge-css.mjs). It can also be an array of strings/RegExps:
88
+ // only the properties generated by the token source files matching one of its
89
+ // entries are merged. An empty or invalid value means false. Default: false.
90
+ /** @type {boolean|(string|RegExp)[]} */
91
+ export const mergeCustomProps = (() => {
92
+ const value = buildConfig.mergeCustomProps;
93
+ if (value === true) return true;
94
+ if (Array.isArray(value)) {
95
+ const entries = value.filter((entry) => typeof entry === 'string' || entry instanceof RegExp);
96
+ return entries.length ? entries : false;
97
+ }
98
+ return false;
99
+ })();
89
100
 
90
101
  // addLayer: wraps the generated custom properties inside a CSS `@layer`
91
102
  // at-rule (see formats/css.mjs), e.g. `@layer project { :root { ... } }`.
@@ -189,8 +189,23 @@ StyleDictionary.registerFormat({
189
189
  return a.name.localeCompare(b.name, 'en', { numeric: true, sensitivity: 'base' });
190
190
  };
191
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;
203
+ }
204
+ return tokenLines;
205
+ };
206
+
192
207
  const lines = dictionary.allTokens
193
- .slice()
208
+ .filter((token) => token.isSource !== false)
194
209
  // .sort((a, b) => a.name.localeCompare(b.name, 'en', {numeric: true, sensitivity: 'base'}))
195
210
  .sort((a, b) => sortFunction(a,b))
196
211
  .flatMap((token) => {
@@ -204,7 +219,7 @@ StyleDictionary.registerFormat({
204
219
  .split('\n')
205
220
  .map((line) => `${line}${commentSuffix}`)
206
221
  .join('\n');
207
- return [typographyLines];
222
+ return trackSource(token, [typographyLines]);
208
223
  }
209
224
 
210
225
  let value;
@@ -247,7 +262,16 @@ StyleDictionary.registerFormat({
247
262
  value = String(token.$value ?? token.value);
248
263
  }
249
264
 
250
- return [` --${token.name}: ${value};${commentSuffix}`];
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}`]);
251
275
  });
252
276
 
253
277
  // Build a name → declaration-tail map from the declarations generated
@@ -259,7 +283,7 @@ StyleDictionary.registerFormat({
259
283
  // scopes the merge to that mode, so same-named props with different
260
284
  // values across modes are never mixed up.
261
285
  const generatedProps = parseCustomProps(lines.join('\n'));
262
- const finalProps = mergeCustomProps(generatedProps, options.mode ?? null);
286
+ const finalProps = mergeCustomProps(generatedProps, options.mode ?? null, sourceFileMap);
263
287
 
264
288
  // Final output is always sorted alphabetically ascending by property
265
289
  // name, regardless of merge — this also keeps pre-existing-only
@@ -136,6 +136,10 @@ StyleDictionary.registerFormat({
136
136
  const tokenMap = (expressionMode === 'resolve') ? makeTokenMap(dictionary) : null;
137
137
 
138
138
  for (const token of dictionary.allTokens) {
139
+ // Tokens loaded via `include` (sourceModes: base-mode tokens made
140
+ // available to other modes for reference resolution) are not output.
141
+ if (token.isSource === false) continue;
142
+
139
143
  // Rebuild the nested tree from the token path
140
144
  let node = root;
141
145
  for (let i = 0; i < token.path.length - 1; i++) {
@@ -205,7 +209,7 @@ export const collectConcreteFilePaths = async (sd) => {
205
209
  await sd.hasInitialized;
206
210
  const paths = new Set();
207
211
  for (const token of sd.allTokens) {
208
- if (token.filePath) paths.add(token.filePath);
212
+ if (token.filePath && token.isSource !== false) paths.add(token.filePath);
209
213
  }
210
214
  return [...paths].sort();
211
215
  };
@@ -10,6 +10,12 @@
10
10
  // mergeCustomProps(generatedMap) while building the output, right before
11
11
  // serialising the final CSS text.
12
12
  //
13
+ // `mergeCustomProps` may also be an array of strings/RegExps: in that case
14
+ // only the generated properties whose token source file matches one of the
15
+ // entries are merged (pre-existing value wins); the others are overwritten by
16
+ // the generated value. Pre-existing properties that are no longer generated
17
+ // (no token, hence no source file) are always kept. See matchesSource().
18
+ //
13
19
  // Usage (sourceModes build, see build-source-modes.mjs):
14
20
  // 1. Call loadExistingCustomPropsScoped(absDestPath, baseMode) BEFORE any
15
21
  // of the per-mode Style Dictionary instances run.
@@ -36,7 +42,11 @@ export const parseCustomProps = (cssText) => {
36
42
  /** @type {Record<string,string>} */
37
43
  const map = {};
38
44
  for (const match of cssText.matchAll(CUSTOM_PROP_RE)) {
39
- map[match[1]] = match[2].trim();
45
+ let tail = match[2].trim();
46
+ // A multi-line comment is cut at the end of the line (only single-line
47
+ // declarations are parsed): close it, or the resulting CSS would be invalid
48
+ if (tail.lastIndexOf('/*') > tail.lastIndexOf('*/')) tail += ' */';
49
+ map[match[1]] = tail;
40
50
  }
41
51
  return map;
42
52
  };
@@ -124,6 +134,32 @@ const splitModeScopes = (cssText) => {
124
134
  return scopes;
125
135
  };
126
136
 
137
+ // `true` = merge every source; an array = merge only the properties generated
138
+ // by the token source files matching one of its entries. Set by the
139
+ // loadExistingCustomProps*() functions.
140
+ /** @type {true|(string|RegExp)[]} */
141
+ let mergeSources = true;
142
+
143
+ /**
144
+ * Whether a token source file matches one of the `mergeCustomProps` array
145
+ * entries: a string matches if it is contained in the file path (path
146
+ * separators normalised to `/`), a RegExp if it tests true against it.
147
+ * @param {(string|RegExp)[]} sources
148
+ * @param {string|undefined} filePath token source file path
149
+ * @returns {boolean}
150
+ */
151
+ const matchesSource = (sources, filePath) => {
152
+ if (!filePath) return false;
153
+ const normalized = filePath.replace(/\\/g, '/');
154
+ return sources.some((entry) => {
155
+ if (entry instanceof RegExp) {
156
+ entry.lastIndex = 0;
157
+ return entry.test(normalized);
158
+ }
159
+ return normalized.includes(entry.replace(/\\/g, '/'));
160
+ });
161
+ };
162
+
127
163
  // null = merge disabled, or destination file not found yet (first build)
128
164
  /** @type {Record<string,string>|null} */
129
165
  let existingCustomProps = null;
@@ -137,9 +173,12 @@ let scopedExistingCustomProps = null;
137
173
  * Reads the CSS destination file (if it exists) and stores a name → value
138
174
  * map of its current custom properties for later merging.
139
175
  * @param {string} absDestPath absolute path of the CSS file to read
176
+ * @param {true|(string|RegExp)[]} [sources] `true` merges every source, an
177
+ * array only the token source files matching one of its entries (default: true)
140
178
  * @returns {Record<string,string>|null}
141
179
  */
142
- export const loadExistingCustomProps = (absDestPath) => {
180
+ export const loadExistingCustomProps = (absDestPath, sources = true) => {
181
+ mergeSources = sources;
143
182
  existingCustomProps = existsSync(absDestPath)
144
183
  ? parseCustomProps(readFileSync(absDestPath, 'utf8'))
145
184
  : null;
@@ -154,9 +193,12 @@ export const loadExistingCustomProps = (absDestPath) => {
154
193
  * @param {string} baseMode mode whose declarations live in the top-level,
155
194
  * non-nested `:root { ... }` block (no default — always passed explicitly
156
195
  * by build-source-modes.mjs, from the resolved sourceModesBase)
196
+ * @param {true|(string|RegExp)[]} [sources] `true` merges every source, an
197
+ * array only the token source files matching one of its entries (default: true)
157
198
  * @returns {Record<string, Record<string,string>>|null}
158
199
  */
159
- export const loadExistingCustomPropsScoped = (absDestPath, baseMode) => {
200
+ export const loadExistingCustomPropsScoped = (absDestPath, baseMode, sources = true) => {
201
+ mergeSources = sources;
160
202
  if (!existsSync(absDestPath)) {
161
203
  scopedExistingCustomProps = null;
162
204
  return scopedExistingCustomProps;
@@ -177,16 +219,26 @@ export const loadExistingCustomPropsScoped = (absDestPath, baseMode) => {
177
219
  * Merges freshly generated custom properties with the ones previously loaded
178
220
  * via loadExistingCustomProps() / loadExistingCustomPropsScoped(). Pre-existing
179
221
  * values take priority; custom properties found only in the pre-existing file
180
- * (no longer generated) are kept as well.
222
+ * (no longer generated) are kept as well. When the merge is limited to some
223
+ * token source files (array option), a generated property whose source file
224
+ * does not match is not overridden by its pre-existing value.
181
225
  * @param {Record<string,string>} generated name → value map produced by this build
182
226
  * @param {string|null} [mode] when set (sourceModes build), looks up the
183
227
  * pre-existing props scoped to this mode instead of the flat/global map (default: null)
228
+ * @param {Record<string,string>} [sourceFileMap] property name → token source
229
+ * file path, used only when the merge is limited to some sources (default: {})
184
230
  * @returns {Record<string,string>}
185
231
  */
186
- export const mergeCustomProps = (generated, mode = null) => {
187
- if (mode !== null) {
188
- const existing = scopedExistingCustomProps?.[mode];
189
- return existing ? { ...generated, ...existing } : generated;
232
+ export const mergeCustomProps = (generated, mode = null, sourceFileMap = {}) => {
233
+ const existing = mode !== null ? scopedExistingCustomProps?.[mode] : existingCustomProps;
234
+ if (!existing) return generated;
235
+ if (mergeSources === true) return { ...generated, ...existing };
236
+
237
+ const merged = { ...generated };
238
+ for (const [name, tail] of Object.entries(existing)) {
239
+ if (!(name in generated) || matchesSource(mergeSources, sourceFileMap[name])) {
240
+ merged[name] = tail;
241
+ }
190
242
  }
191
- return existingCustomProps ? { ...generated, ...existingCustomProps } : generated;
243
+ return merged;
192
244
  };
@@ -116,7 +116,7 @@ if (sourceModes) {
116
116
  // pre-existing values take priority — right before serialising the final
117
117
  // CSS output. See merge-css.mjs.
118
118
  if (mergeCustomProps) {
119
- loadExistingCustomProps(path.join(buildPath, destFile));
119
+ loadExistingCustomProps(path.join(buildPath, destFile), mergeCustomProps);
120
120
  }
121
121
 
122
122
  // Collect concrete source file paths (multi-file JSON mode only). Source
@@ -202,14 +202,17 @@ async function run() {
202
202
  /** @type {Map<string, Map<string, string>>} */
203
203
  const sourceFileByPropByMode = new Map();
204
204
 
205
- /** @param {string[]} sourcePatterns @returns {Promise<Map<string,string>>} */
206
- const resolveSourceFileMap = async (sourcePatterns) => {
205
+ // includePatterns: base-mode sources, passed as `include` for non-base
206
+ // modes so cross-mode references resolve (same as build-source-modes.mjs).
207
+ /** @param {string[]} sourcePatterns @param {string[]} [includePatterns] @returns {Promise<Map<string,string>>} */
208
+ const resolveSourceFileMap = async (sourcePatterns, includePatterns = []) => {
207
209
  /** @type {Map<string, string>} */
208
210
  const map = new Map();
209
211
  const resolvedPaths = resolveSourcePaths(sourcePatterns, configDir);
210
212
  if (!resolvedPaths.length) return map;
211
213
  try {
212
214
  const sd = new StyleDictionary({
215
+ include: resolveSourcePaths(includePatterns, configDir),
213
216
  source: resolvedPaths,
214
217
  parsers: [LEGACY_TOKENS_PARSER_NAME],
215
218
  log: { verbosity: 'silent' },
@@ -217,7 +220,7 @@ async function run() {
217
220
  });
218
221
  const dictionary = await sd.getPlatformTokens('css');
219
222
  for (const token of dictionary.allTokens) {
220
- if (token.filePath) map.set(`--${token.name}`, token.filePath);
223
+ if (token.filePath && token.isSource !== false) map.set(`--${token.name}`, token.filePath);
221
224
  }
222
225
  } catch {
223
226
  // Non-fatal: unused properties will be reported by name only.
@@ -227,7 +230,8 @@ async function run() {
227
230
 
228
231
  if (checkUnused && config.sourceModes) {
229
232
  for (const [mode, modeSource] of Object.entries(config.sourceModes)) {
230
- sourceFileByPropByMode.set(mode, await resolveSourceFileMap(modeSource));
233
+ const includePatterns = mode === sourceModesBase ? [] : config.sourceModes[sourceModesBase];
234
+ sourceFileByPropByMode.set(mode, await resolveSourceFileMap(modeSource, includePatterns));
231
235
  }
232
236
  } else if (checkUnused && Array.isArray(config.source) && config.source.length) {
233
237
  const map = await resolveSourceFileMap(config.source);
@@ -77,6 +77,13 @@ const config = {
77
77
  // customPropsGroups, mergeCustomProps, addLayer, pxToRem etc. all apply
78
78
  // the same way as with a single `source`.
79
79
  //
80
+ // Cross-mode references: tokens of a non-base mode can reference tokens
81
+ // defined in the base mode (e.g. a dark token using {color.1} defined in
82
+ // light); it resolves to var(--color-1). The reverse (or between two
83
+ // non-base modes) is not supported and fails with a "reference not
84
+ // defined" error, since the target would be undefined outside its own
85
+ // @media block.
86
+ //
80
87
  // The JSON output (see JSON TOKENS OUTPUT below) is produced once per
81
88
  // mode, with the mode name appended to each filename, e.g. with
82
89
  // jsonDestFile: 'tokens' -> "tokens-light.jsonc", "tokens-dark.jsonc".
@@ -106,13 +113,25 @@ const config = {
106
113
  // are never touched regardless of this option.
107
114
  pxToRem: false,
108
115
 
109
- // If true, custom properties already present in the CSS destination file
110
- // (destFile, from a previous build) take priority over the ones generated
111
- // by this build. Properties found only in the pre-existing file (no longer
112
- // generated) are kept too. Useful to preserve manual overrides/additions
113
- // made directly in the compiled CSS across rebuilds. Ignored on the first
114
- // build, when destFile does not exist yet. Default: false.
115
- // Comments at the end of custom properties are preserved, while full-line ones will be lost.
116
+ // Preserves custom properties already present in the CSS destination file
117
+ // (destFile, from a previous build) across rebuilds: their values take
118
+ // priority over the ones generated by this build. Useful to keep manual
119
+ // overrides/additions made directly in the compiled CSS. Ignored on the
120
+ // first build, when destFile does not exist yet. Accepted values:
121
+ // false (default) - no merge: the destination file is fully regenerated
122
+ // true - merge all the sources
123
+ // array - strings and/or RegExps matched against the path of the
124
+ // token source files (those in `source` or `sourceModes`):
125
+ // only the properties generated by a matching file are
126
+ // merged, the others are overwritten by the generated value.
127
+ // A string matches if contained in the file path.
128
+ // An empty array is treated as false.
129
+ // e.g. ['/theme.minimo.tokens', /colors\.tokens\.(mjs|jsonc?)$/]
130
+ // With true or an array, properties found only in the pre-existing file (no
131
+ // longer generated, e.g. added by hand) are always kept.
132
+ // Comments at the end of a custom property line are preserved; full-line
133
+ // comments are lost, and a multi-line comment is cut at the end of its first
134
+ // line (and closed, to keep the CSS valid).
116
135
  mergeCustomProps: false,
117
136
 
118
137
  // Wraps the generated custom properties inside a CSS `@layer` at-rule,
@@ -195,11 +214,11 @@ const config = {
195
214
  // Additional CSS files that define custom properties used in the project
196
215
  // but not generated by build-tokens (e.g. third-party libraries)
197
216
  extraCustomPropsFiles: [
198
- // '../../node_modules/open-props/open-props.min.css'
217
+ // `${node_modules_path}/open-props/open-props.min.css`
199
218
  ],
200
219
 
201
220
  // Directory containing the CSS files to scan for unresolved custom properties
202
- dirToCheck: '../src',
221
+ dirToCheck: '../../app',
203
222
 
204
223
  // Regular expressions for custom property names to exclude from the check.
205
224
  // Useful for intentionally private/internal props (e.g. prefixed with --_).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@massimo-cassandro/minimo",
3
- "version": "1.35.0",
3
+ "version": "1.36.0",
4
4
  "description": "Light css framework & utilities",
5
5
  "type": "module",
6
6
  "main": "./index.js",