@massimo-cassandro/minimo 1.36.2 → 1.37.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
 
@@ -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
@@ -126,14 +126,33 @@ export const customPropsGroups = Array.isArray(buildConfig.customPropsGroups)
126
126
  // properties split — see build-tokens-src/build-source-modes.mjs. When set
127
127
  // (buildConfig.sourceModes is a non-null object), `source` is ignored and
128
128
  // each mode's own array of source patterns is resolved the same way.
129
+ //
130
+ // Entries of `source` / of each mode can also be `{ src, prefix }` objects,
131
+ // to render the custom properties of those files with a prefix (see
132
+ // source-prefixes.mjs). They are flattened here into plain patterns for
133
+ // Style Dictionary; the prefixed ones are also collected in
134
+ // `prefixedSources`, to be registered by build-tokens.mjs.
129
135
  // ---------------------------------------------------------------------------
130
136
  const sourceModesRaw = buildConfig.sourceModes ?? null;
131
137
 
138
+ /** @type {import('./source-prefixes.mjs').PrefixedSource[]} */
139
+ export const prefixedSources = [];
140
+
141
+ /**
142
+ * @param {Parameters<typeof splitSourceEntries>[0]} entries
143
+ * @returns {string[]}
144
+ */
145
+ const parseSourceEntries = (entries) => {
146
+ const { patterns, prefixed } = splitSourceEntries(entries, configDir);
147
+ prefixedSources.push(...prefixed);
148
+ return patterns;
149
+ };
150
+
132
151
  export const sourceModes = sourceModesRaw
133
152
  ? Object.fromEntries(
134
153
  Object.entries(sourceModesRaw).map(([mode, modeSource]) => [
135
154
  mode,
136
- resolveSourcePaths(modeSource, configDir),
155
+ parseSourceEntries(modeSource),
137
156
  ])
138
157
  )
139
158
  : null;
@@ -156,4 +175,4 @@ if (sourceModes && buildConfig.sourceModesBase && sourceModesBase !== buildConfi
156
175
  );
157
176
  }
158
177
 
159
- export const source = sourceModes ? null : resolveSourcePaths(buildConfig.source, configDir);
178
+ export const source = sourceModes ? null : parseSourceEntries(buildConfig.source);
@@ -11,9 +11,10 @@ import { buildJsonFiles } from './formats/json.mjs';
11
11
  // Transforms applied to the CSS platform.
12
12
  // Exported so other scripts (e.g. check-unresolved-custom-props.mjs) can
13
13
  // build a Style Dictionary instance with matching transformed token names.
14
+ // name/kebab-prefixed = name/kebab + per-source prefix (see source-prefixes.mjs).
14
15
  export const CSS_TRANSFORMS = [
15
16
  'attribute/cti',
16
- 'name/kebab',
17
+ 'name/kebab-prefixed',
17
18
  'time/seconds',
18
19
  'asset/url',
19
20
  'size/pxToRem-smart',
@@ -27,7 +28,8 @@ export const CSS_TRANSFORMS = [
27
28
  // Transforms applied to the JSON platform.
28
29
  // Kept minimal on purpose: consuming tools expect original values (e.g. "16px"
29
30
  // not "1rem") and alias references ({...}) must be preserved to maintain
30
- // token links.
31
+ // token links. Source prefixes are not applied here (plain name/kebab): the
32
+ // JSON output keeps the original token tree.
31
33
  // Exported so build-source-modes.mjs can build a matching json platform per
32
34
  // sourceModes mode.
33
35
  export const JSON_TRANSFORMS = [
@@ -0,0 +1,129 @@
1
+ // build-tokens-src/source-prefixes.mjs
2
+ // Supports per-source custom property prefixes: an entry of `source` (or of a
3
+ // `sourceModes` mode) can be an object `{ src, prefix }` instead of a plain
4
+ // string, e.g.
5
+ //
6
+ // { src: 'node_modules/open-props/open-props.style-dictionary-tokens.json', prefix: 'op' }
7
+ //
8
+ // so that every token defined by those files is rendered as `--op-<name>`.
9
+ //
10
+ // How it works: the prefix is applied to the token NAME only (see the
11
+ // name/kebab-prefixed transform in transforms.mjs), never to its path.
12
+ // {references} are resolved through the token path and rendered with the
13
+ // already-prefixed name, so a token of another source referencing e.g.
14
+ // {gray.5} becomes `var(--op-gray-5)` with no change to the token files. The
15
+ // JSON output keeps the original (unprefixed) tree structure.
16
+ //
17
+ // The name transform only knows the concrete file a token comes from
18
+ // (token.filePath), while `src` may contain glob patterns: registerSourcePrefixes()
19
+ // expands them (through Style Dictionary itself, so the result matches
20
+ // token.filePath exactly) into a file path -> prefix map.
21
+
22
+ import StyleDictionary from 'style-dictionary';
23
+ import { LEGACY_TOKENS_PARSER_NAME } from './legacy-tokens-parser.mjs';
24
+ import { resolveSourcePaths } from './resolve-source-paths.mjs';
25
+ import { collectConcreteFilePaths } from './formats/json.mjs';
26
+
27
+ /**
28
+ * @typedef {object} PrefixedSource
29
+ * @property {string[]} src Source paths/globs, already resolved (see resolveSourcePaths)
30
+ * @property {string} prefix Normalised prefix, without leading `--` or trailing `-`
31
+ */
32
+
33
+ /** @type {Map<string, string>} */
34
+ let prefixByFile = new Map();
35
+
36
+ /**
37
+ * Normalises a user-provided prefix: 'op', 'op-' and '--op-' all become 'op'.
38
+ * @param {unknown} prefix
39
+ * @returns {string}
40
+ * @throws {Error} if the result is not a valid custom property name segment
41
+ */
42
+ export const normalizePrefix = (prefix) => {
43
+ const value = String(prefix).trim().replace(/^-+/, '').replace(/-+$/, '');
44
+ if (!/^[a-z_][\w-]*$/i.test(value)) {
45
+ throw new Error(`[build-tokens] config: invalid source prefix "${prefix}"`);
46
+ }
47
+ return value;
48
+ };
49
+
50
+ /**
51
+ * Splits a `source` (or `sourceModes[mode]`) array, whose entries can be
52
+ * strings or `{ src, prefix }` objects, into the flat list of patterns to pass
53
+ * to Style Dictionary and the list of the prefixed entries. Patterns are
54
+ * resolved relative to baseDir (see resolveSourcePaths).
55
+ * @param {(string|{src: string|string[], prefix?: string})[]} entries
56
+ * @param {string} baseDir
57
+ * @returns {{patterns: string[], prefixed: PrefixedSource[]}}
58
+ */
59
+ export const splitSourceEntries = (entries, baseDir) => {
60
+ /** @type {string[]} */
61
+ const patterns = [];
62
+ /** @type {PrefixedSource[]} */
63
+ const prefixed = [];
64
+
65
+ for (const entry of entries ?? []) {
66
+ if (typeof entry === 'string') {
67
+ patterns.push(...resolveSourcePaths([entry], baseDir));
68
+ continue;
69
+ }
70
+
71
+ const isObjectEntry = entry !== null && typeof entry === 'object'
72
+ && (typeof entry.src === 'string' || Array.isArray(entry.src));
73
+ if (!isObjectEntry) {
74
+ throw new Error(
75
+ `[build-tokens] config: invalid source entry ${JSON.stringify(entry)} `
76
+ + '(expected a string or an object { src, prefix })'
77
+ );
78
+ }
79
+
80
+ const src = resolveSourcePaths([].concat(entry.src), baseDir);
81
+ patterns.push(...src);
82
+
83
+ if (entry.prefix !== undefined && entry.prefix !== null && entry.prefix !== '') {
84
+ prefixed.push({ src, prefix: normalizePrefix(entry.prefix) });
85
+ }
86
+ }
87
+
88
+ return { patterns, prefixed };
89
+ };
90
+
91
+ /**
92
+ * Expands the `src` patterns of every prefixed entry into concrete file paths
93
+ * and stores the file path -> prefix map used by getSourcePrefix().
94
+ * Must be called after registerLegacyTokensParser() and before any build.
95
+ * @param {PrefixedSource[]} prefixedSources
96
+ * @returns {Promise<void>}
97
+ * @throws {Error} if the same file is given two different prefixes
98
+ */
99
+ export const registerSourcePrefixes = async (prefixedSources) => {
100
+ /** @type {Map<string, string>} */
101
+ const map = new Map();
102
+
103
+ for (const { src, prefix } of prefixedSources) {
104
+ const sd = new StyleDictionary({
105
+ source: src,
106
+ parsers: [LEGACY_TOKENS_PARSER_NAME],
107
+ log: { verbosity: 'silent' },
108
+ platforms: {},
109
+ });
110
+
111
+ for (const filePath of await collectConcreteFilePaths(sd)) {
112
+ const existing = map.get(filePath);
113
+ if (existing !== undefined && existing !== prefix) {
114
+ throw new Error(
115
+ `[build-tokens] config: "${filePath}" has two different prefixes ("${existing}" and "${prefix}")`
116
+ );
117
+ }
118
+ map.set(filePath, prefix);
119
+ }
120
+ }
121
+
122
+ prefixByFile = map;
123
+ };
124
+
125
+ /**
126
+ * @param {string|undefined} filePath token.filePath
127
+ * @returns {string|undefined} the prefix registered for that file, if any
128
+ */
129
+ export const getSourcePrefix = (filePath) => (filePath ? prefixByFile.get(filePath) : undefined);
@@ -3,6 +3,7 @@
3
3
  // Imported once by build-tokens.mjs before buildAllPlatforms() is called.
4
4
 
5
5
  import StyleDictionary from 'style-dictionary';
6
+ import { getSourcePrefix } from './source-prefixes.mjs';
6
7
 
7
8
  const BASE_FONT_SIZE = 16;
8
9
 
@@ -212,3 +213,22 @@ StyleDictionary.registerTransform({
212
213
  filter: (token) => token.$type === 'typography' || token.type === 'typography',
213
214
  transform: (token) => token.$value ?? token.value,
214
215
  });
216
+
217
+ // ---------------------------------------------------------------------------
218
+ // 7. Kebab-case name with per-source prefix
219
+ // Same as the built-in name/kebab, but prepends the prefix registered for the
220
+ // token's source file (`{ src, prefix }` entries of `source`/`sourceModes`, see
221
+ // source-prefixes.mjs), e.g. Open Props' gray.0 with prefix 'op' -> op-gray-0.
222
+ // Only the name changes: the token path is untouched, so {references} keep
223
+ // resolving and are rendered as var(--<prefixed-name>). Files without a
224
+ // prefix get exactly the built-in name/kebab result.
225
+ // ---------------------------------------------------------------------------
226
+ StyleDictionary.registerTransform({
227
+ name: 'name/kebab-prefixed',
228
+ type: 'name',
229
+ transform: (token, options) => {
230
+ const kebab = StyleDictionary.hooks.transforms['name/kebab'].transform;
231
+ const prefix = getSourcePrefix(token.filePath);
232
+ return kebab(prefix ? { ...token, path: [prefix, ...token.path] } : token, options);
233
+ },
234
+ });
@@ -14,6 +14,7 @@
14
14
  // build-tokens-src/platforms.mjs <- builds the platforms object (css + optional json)
15
15
  // build-tokens-src/build-source-modes.mjs <- alternative build path used when sourceModes is set
16
16
  // build-tokens-src/merge-css.mjs <- supports the mergeCustomProps option (flat and per-mode)
17
+ // build-tokens-src/source-prefixes.mjs <- supports `{ src, prefix }` entries in source/sourceModes
17
18
 
18
19
  import StyleDictionary from 'style-dictionary';
19
20
  import * as path from 'node:path';
@@ -35,6 +36,7 @@ import {
35
36
  source,
36
37
  sourceModes,
37
38
  sourceModesBase,
39
+ prefixedSources,
38
40
  mergeCustomProps,
39
41
  customPropsGroups,
40
42
  pxToRem,
@@ -50,6 +52,13 @@ import './build-tokens-src/transforms.mjs';
50
52
  import { LEGACY_TOKENS_PARSER_NAME, registerLegacyTokensParser } from './build-tokens-src/legacy-tokens-parser.mjs';
51
53
  registerLegacyTokensParser();
52
54
 
55
+ // ── 2c. Per-source custom property prefixes ──────────────────────────────────
56
+ // Expands the `{ src, prefix }` entries of source/sourceModes into the file ->
57
+ // prefix map read by the name/kebab-prefixed transform (needs the legacy
58
+ // parser above, to load legacy .json sources) — see source-prefixes.mjs.
59
+ import { registerSourcePrefixes } from './build-tokens-src/source-prefixes.mjs';
60
+ await registerSourcePrefixes(prefixedSources);
61
+
53
62
  // ── 3. CSS format ─────────────────────────────────────────────────────────────
54
63
  // customPropsCount is updated by the format at render time
55
64
  // loadExistingCustomProps supports the mergeCustomProps option (see below)
@@ -24,7 +24,7 @@ import { styleText } from 'node:util';
24
24
  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
- import { resolveSourcePaths } from './build-tokens-src/resolve-source-paths.mjs';
27
+ import { splitSourceEntries, registerSourcePrefixes } from './build-tokens-src/source-prefixes.mjs';
28
28
  import { findMediaModeBlocks } from './build-tokens-src/merge-css.mjs';
29
29
  import { LEGACY_TOKENS_PARSER_NAME, registerLegacyTokensParser } from './build-tokens-src/legacy-tokens-parser.mjs';
30
30
 
@@ -202,17 +202,22 @@ async function run() {
202
202
  /** @type {Map<string, Map<string, string>>} */
203
203
  const sourceFileByPropByMode = new Map();
204
204
 
205
- // includePatterns: base-mode sources, passed as `include` for non-base
205
+ // includeEntries: base-mode sources, passed as `include` for non-base
206
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
+ /** @param {Parameters<typeof splitSourceEntries>[0]} sourceEntries @param {Parameters<typeof splitSourceEntries>[0]} [includeEntries] @returns {Promise<Map<string,string>>} */
208
+ // Entries can be strings or `{ src, prefix }` objects (see
209
+ // build-tokens-src/source-prefixes.mjs): the prefixes are registered
210
+ // before the transform pipeline runs, so token names match the CSS.
211
+ const resolveSourceFileMap = async (sourceEntries, includeEntries = []) => {
209
212
  /** @type {Map<string, string>} */
210
213
  const map = new Map();
211
- const resolvedPaths = resolveSourcePaths(sourcePatterns, configDir);
214
+ const { patterns: resolvedPaths, prefixed } = splitSourceEntries(sourceEntries, configDir);
215
+ const { patterns: includePaths, prefixed: includePrefixed } = splitSourceEntries(includeEntries, configDir);
212
216
  if (!resolvedPaths.length) return map;
213
217
  try {
218
+ await registerSourcePrefixes([...prefixed, ...includePrefixed]);
214
219
  const sd = new StyleDictionary({
215
- include: resolveSourcePaths(includePatterns, configDir),
220
+ include: includePaths,
216
221
  source: resolvedPaths,
217
222
  parsers: [LEGACY_TOKENS_PARSER_NAME],
218
223
  log: { verbosity: 'silent' },
@@ -230,8 +235,8 @@ async function run() {
230
235
 
231
236
  if (checkUnused && config.sourceModes) {
232
237
  for (const [mode, modeSource] of Object.entries(config.sourceModes)) {
233
- const includePatterns = mode === sourceModesBase ? [] : config.sourceModes[sourceModesBase];
234
- sourceFileByPropByMode.set(mode, await resolveSourceFileMap(modeSource, includePatterns));
238
+ const includeEntries = mode === sourceModesBase ? [] : config.sourceModes[sourceModesBase];
239
+ sourceFileByPropByMode.set(mode, await resolveSourceFileMap(modeSource, includeEntries));
235
240
  }
236
241
  } else if (checkUnused && Array.isArray(config.source) && config.source.length) {
237
242
  const map = await resolveSourceFileMap(config.source);
@@ -51,12 +51,21 @@ const config = {
51
51
  // the pattern syntax. Use template literals instead:
52
52
  // OK: `${minimo_path}/**/*.{json,mjs}`
53
53
  // NO: path.join(minimo_path, '/**/*.{json,mjs}')
54
+ //
55
+ // An entry can also be an object `{ src, prefix }` (src: string or array of
56
+ // strings/globs), to render the custom properties of those files with a
57
+ // prefix: e.g. { src: '...open-props...json', prefix: 'op' } turns Open
58
+ // Props' gray.0 into --op-gray-0. Only the property name changes:
59
+ // {references} to those tokens are rendered as var(--op-gray-0), and the JSON
60
+ // output stays unprefixed. 'op', 'op-' and '--op-' are equivalent. The same
61
+ // syntax works inside sourceModes. Two different prefixes for the same file
62
+ // make the build fail.
54
63
  source: [
55
64
  `${minimo_path}/design-tokens/_src/**/*.tokens.{mjs,jsonc}`, // main tokens
56
65
  `${minimo_path}/src/**/*.tokens.{mjs,jsonc}`, // components tokens
57
66
 
58
67
  // optional extra token source (in this example, openProps, https://open-props.style/)
59
- // `${node_modules_path}/open-props/open-props.style-dictionary-tokens.json`,
68
+ // { src: `${node_modules_path}/open-props/open-props.style-dictionary-tokens.json`, prefix: 'op' },
60
69
 
61
70
  // your project tokens
62
71
  './project-tokens/*.{js,mjs,jsonc,json}',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@massimo-cassandro/minimo",
3
- "version": "1.36.2",
3
+ "version": "1.37.0",
4
4
  "description": "Light css framework & utilities",
5
5
  "type": "module",
6
6
  "main": "./index.js",