@massimo-cassandro/minimo 1.34.0 → 1.35.1

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.
@@ -66,6 +66,10 @@ The `source` array accepts any combination of:
66
66
  - `.json` / `.jsonc` — parsed directly as token data
67
67
  - `.mjs` / `.js` — imported as ES modules; must export a W3C DTCG-compliant object as `default`
68
68
 
69
+ **Legacy token syntax (auto-detected, `.json` only):** `.json` source files written in the older, non-DTCG Style Dictionary syntax (`value`/`type` instead of `$value`/`$type`, references as `{group.token.value}` instead of `{group.token}` — e.g. a file exported from [Open Props](https://open-props.style/)) are converted to DTCG v5 syntax automatically at parse time, node by node. Files already using `$value`/`$type` are left untouched, so no config flag or file list is needed — mixing legacy and DTCG sources in the same `source` array just works. `.jsonc` is not covered (reserved for hand-authored DTCG sources, parsed by Style Dictionary's own loader) and these `.json` files are parsed with plain `JSON.parse` (no comments, no trailing commas). See `build-tokens-src/legacy-tokens-parser.mjs`.
70
+
71
+ 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
+
69
73
 
70
74
  #### Token file format (JS example)
71
75
 
@@ -157,6 +161,8 @@ When set, each mode has its own `source` array, and the generated CSS composes a
157
161
  - the base mode's props go in a top-level `:root { ... }` block, with `color-scheme: <all modes>;` prepended
158
162
  - every other mode's props go in `@media (prefers-color-scheme: <mode>) { :root { ... } }`, with `color-scheme: <mode>;` prepended
159
163
 
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
+
160
166
  `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`).
161
167
 
162
168
  ---
@@ -29,6 +29,7 @@ import { CSS_TRANSFORMS, JSON_TRANSFORMS } from './platforms.mjs';
29
29
  import { customPropsCount } from './formats/css.mjs';
30
30
  import { buildJsonFiles, collectConcreteFilePaths } from './formats/json.mjs';
31
31
  import { loadExistingCustomPropsScoped } from './merge-css.mjs';
32
+ import { LEGACY_TOKENS_PARSER_NAME } from './legacy-tokens-parser.mjs';
32
33
 
33
34
  /**
34
35
  * @param {object} opts
@@ -79,8 +80,18 @@ export const buildSourceModes = async ({
79
80
  const jsonFilesByMode = {};
80
81
 
81
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
+
82
91
  const sd = new StyleDictionary({
92
+ include,
83
93
  source: sourceModes[mode],
94
+ parsers: [LEGACY_TOKENS_PARSER_NAME],
84
95
  log: { verbosity: 'verbose' },
85
96
  platforms: {
86
97
  css: {
@@ -114,7 +125,9 @@ export const buildSourceModes = async ({
114
125
  const files = buildJsonFiles(concreteFilePaths, jsonDestFile, jsonFormat, jsonExpression, `-${mode}`);
115
126
 
116
127
  const jsonSd = new StyleDictionary({
128
+ include,
117
129
  source: sourceModes[mode],
130
+ parsers: [LEGACY_TOKENS_PARSER_NAME],
118
131
  log: { verbosity: 'silent' },
119
132
  platforms: {
120
133
  json: {
@@ -189,8 +189,11 @@ 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.
192
195
  const lines = dictionary.allTokens
193
- .slice()
196
+ .filter((token) => token.isSource !== false)
194
197
  // .sort((a, b) => a.name.localeCompare(b.name, 'en', {numeric: true, sensitivity: 'base'}))
195
198
  .sort((a, b) => sortFunction(a,b))
196
199
  .flatMap((token) => {
@@ -247,6 +250,15 @@ StyleDictionary.registerFormat({
247
250
  value = String(token.$value ?? token.value);
248
251
  }
249
252
 
253
+ // Multi-line values (e.g. Open Props' linear() easings) are collapsed
254
+ // to a single line: declarations are parsed one line at a time (see
255
+ // parseCustomProps in ../merge-css.mjs), so a multi-line value would
256
+ // be truncated to its first line.
257
+ value = String(value)
258
+ .replace(/\s*\n\s*/g, ' ')
259
+ .replace(/\(\s+/g, '(')
260
+ .replace(/\s+\)/g, ')');
261
+
250
262
  return [` --${token.name}: ${value};${commentSuffix}`];
251
263
  });
252
264
 
@@ -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
  };
@@ -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
+ };
@@ -44,6 +44,12 @@ import {
44
44
  // ── 2. Custom transforms ─────────────────────────────────────────────────────
45
45
  import './build-tokens-src/transforms.mjs';
46
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
+
47
53
  // ── 3. CSS format ─────────────────────────────────────────────────────────────
48
54
  // customPropsCount is updated by the format at render time
49
55
  // loadExistingCustomProps supports the mergeCustomProps option (see below)
@@ -127,12 +133,18 @@ if (sourceModes) {
127
133
  const singleSource = /** @type {string[]} */ (source);
128
134
 
129
135
  if (jsonBuildPath && !jsonDestFile) {
130
- const sdInit = new StyleDictionary({ source: singleSource, log: { verbosity: 'silent' }, platforms: {} });
136
+ const sdInit = new StyleDictionary({
137
+ source: singleSource,
138
+ parsers: [LEGACY_TOKENS_PARSER_NAME],
139
+ log: { verbosity: 'silent' },
140
+ platforms: {},
141
+ });
131
142
  concreteFilePaths = await collectConcreteFilePaths(sdInit);
132
143
  }
133
144
 
134
145
  const sd = new StyleDictionary({
135
146
  source: singleSource,
147
+ parsers: [LEGACY_TOKENS_PARSER_NAME],
136
148
  log: { verbosity: 'verbose' },
137
149
  platforms: buildPlatforms({
138
150
  buildPath,
@@ -26,6 +26,9 @@ 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
28
  import { findMediaModeBlocks } from './build-tokens-src/merge-css.mjs';
29
+ import { LEGACY_TOKENS_PARSER_NAME, registerLegacyTokensParser } from './build-tokens-src/legacy-tokens-parser.mjs';
30
+
31
+ registerLegacyTokensParser();
29
32
 
30
33
  // const __filename = fileURLToPath(import.meta.url);
31
34
  // const __dirname = dirname(__filename);
@@ -199,21 +202,25 @@ async function run() {
199
202
  /** @type {Map<string, Map<string, string>>} */
200
203
  const sourceFileByPropByMode = new Map();
201
204
 
202
- /** @param {string[]} sourcePatterns @returns {Promise<Map<string,string>>} */
203
- 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 = []) => {
204
209
  /** @type {Map<string, string>} */
205
210
  const map = new Map();
206
211
  const resolvedPaths = resolveSourcePaths(sourcePatterns, configDir);
207
212
  if (!resolvedPaths.length) return map;
208
213
  try {
209
214
  const sd = new StyleDictionary({
215
+ include: resolveSourcePaths(includePatterns, configDir),
210
216
  source: resolvedPaths,
217
+ parsers: [LEGACY_TOKENS_PARSER_NAME],
211
218
  log: { verbosity: 'silent' },
212
219
  platforms: { css: { transforms: CSS_TRANSFORMS } },
213
220
  });
214
221
  const dictionary = await sd.getPlatformTokens('css');
215
222
  for (const token of dictionary.allTokens) {
216
- if (token.filePath) map.set(`--${token.name}`, token.filePath);
223
+ if (token.filePath && token.isSource !== false) map.set(`--${token.name}`, token.filePath);
217
224
  }
218
225
  } catch {
219
226
  // Non-fatal: unused properties will be reported by name only.
@@ -223,7 +230,8 @@ async function run() {
223
230
 
224
231
  if (checkUnused && config.sourceModes) {
225
232
  for (const [mode, modeSource] of Object.entries(config.sourceModes)) {
226
- sourceFileByPropByMode.set(mode, await resolveSourceFileMap(modeSource));
233
+ const includePatterns = mode === sourceModesBase ? [] : config.sourceModes[sourceModesBase];
234
+ sourceFileByPropByMode.set(mode, await resolveSourceFileMap(modeSource, includePatterns));
227
235
  }
228
236
  } else if (checkUnused && Array.isArray(config.source) && config.source.length) {
229
237
  const map = await resolveSourceFileMap(config.source);
@@ -13,7 +13,8 @@
13
13
 
14
14
 
15
15
  // minimo package path in node_modules
16
- const minimo_path = '../../node_modules/@massimo-cassandro/minimo';
16
+ const node_modules_path = '../../node_modules',
17
+ minimo_path = `${node_modules_path}/@massimo-cassandro/minimo`;
17
18
 
18
19
  const config = {
19
20
 
@@ -33,6 +34,19 @@ const config = {
33
34
  // Source token files — accept paths, globs, or a mix of both.
34
35
  // Supported formats: .json, .jsonc, .mjs (must export a DTCG-compliant default object)
35
36
  //
37
+ // Legacy (non-DTCG) .json files — e.g. exported from Open Props — are
38
+ // auto-detected and converted to DTCG v5 syntax at parse time, so they can
39
+ // be freely mixed with regular DTCG sources (see README.md). Not covered:
40
+ // .jsonc, reserved for hand-authored DTCG sources. These .json files are
41
+ // parsed with plain JSON.parse (no comments, no trailing commas).
42
+ //
43
+ // LIMITATION: a legacy node that is at the same time a token (own
44
+ // value/type) AND a group with further nested children (e.g. Open Props'
45
+ // other.ease.out, which has its own value plus out.1 ... out.5) cannot be
46
+ // represented in DTCG v5 — a node with $value is always a leaf, Style
47
+ // Dictionary does not descend into its children. In that case only the
48
+ // node's own value is converted; the nested children are silently lost.
49
+ //
36
50
  // WARNING: do not use path.join() to build glob patterns — it may corrupt
37
51
  // the pattern syntax. Use template literals instead:
38
52
  // OK: `${minimo_path}/**/*.{json,mjs}`
@@ -42,7 +56,7 @@ const config = {
42
56
  `${minimo_path}/src/**/*.tokens.{mjs,jsonc}`, // components tokens
43
57
 
44
58
  // optional extra token source (in this example, openProps, https://open-props.style/)
45
- // '../../node_modules/open-props/open-props.style-dictionary-tokens.json',
59
+ // `${node_modules_path}/open-props/open-props.style-dictionary-tokens.json`,
46
60
 
47
61
  // your project tokens
48
62
  './project-tokens/*.{js,mjs,jsonc,json}',
@@ -63,6 +77,13 @@ const config = {
63
77
  // customPropsGroups, mergeCustomProps, addLayer, pxToRem etc. all apply
64
78
  // the same way as with a single `source`.
65
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
+ //
66
87
  // The JSON output (see JSON TOKENS OUTPUT below) is produced once per
67
88
  // mode, with the mode name appended to each filename, e.g. with
68
89
  // jsonDestFile: 'tokens' -> "tokens-light.jsonc", "tokens-dark.jsonc".
@@ -37,6 +37,10 @@
37
37
  {
38
38
  "filePath": "node_modules/open-props",
39
39
  "group": "Default"
40
+ },
41
+ {
42
+ "filePath": "node_modules/open-props/open-props.style-dictionary-tokens.json",
43
+ "group": "Default"
40
44
  }
41
45
  ],
42
46
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@massimo-cassandro/minimo",
3
- "version": "1.34.0",
3
+ "version": "1.35.1",
4
4
  "description": "Light css framework & utilities",
5
5
  "type": "module",
6
6
  "main": "./index.js",