@massimo-cassandro/minimo 1.38.4 → 1.38.5

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.
@@ -69,7 +69,7 @@ The `source` array accepts any combination of:
69
69
 
70
70
  **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`.
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
+ A legacy node that is at the same time a token (own `value`/`type`) *and* a group with 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, where a node with `$value` is always a leaf. Its own value is moved to a child token named `default` (`other.ease.out.default`, i.e. `--other-ease-out-default`), or `base` if a `default` child already exists; if both exist the build fails with an explicit error. References to such a node from the same file are rewritten accordingly; from other files they must use the child name explicitly.
73
73
 
74
74
  #### Source prefixes
75
75
 
@@ -25,6 +25,12 @@
25
25
  // the parser is a safe no-op for files that don't need conversion — no
26
26
  // config flag or file list is required, detection is fully automatic.
27
27
  //
28
+ // "Hybrid" nodes (a token with its own value that is also a group with
29
+ // nested children, e.g. Open Props' other.ease.out) can't be represented in
30
+ // DTCG v5, where a node with $value is a leaf. The own value is moved to a
31
+ // child token named `default` (or `base` if `default` is already a child
32
+ // name; the build fails if both are taken) — see collectHybridNodes().
33
+ //
28
34
  // Limitation: `.json` files handled by this parser are parsed with plain
29
35
  // JSON.parse (no comments, no trailing commas), unlike Style Dictionary's
30
36
  // own built-in loader (which uses JSON5 for .json/.jsonc/.json5). This keeps
@@ -39,58 +45,127 @@ export const LEGACY_TOKENS_PARSER_NAME = 'minimo/legacy-tokens';
39
45
  // Matches "{some.token.path.value}" -> captures "some.token.path"
40
46
  const LEGACY_REF = /\{([^}]+)\.value\}/g;
41
47
 
42
- /** @param {unknown} value @returns {unknown} */
43
- const convertLegacyRefs = (value) => {
48
+ // Name of the child token that receives the own value of a "hybrid" node
49
+ // (see collectHybridNodes()); FALLBACK_LEAF is used when a child with the
50
+ // preferred name already exists.
51
+ const PREFERRED_LEAF = 'default';
52
+ const FALLBACK_LEAF = 'base';
53
+
54
+ /** @param {unknown} node @returns {node is Record<string, unknown>} */
55
+ const isPlainObject = (node) => node !== null && typeof node === 'object' && !Array.isArray(node);
56
+
57
+ // True if the node is (or contains, at any depth) a token, legacy or DTCG.
58
+ /** @param {unknown} node @returns {boolean} */
59
+ const hasTokenDescendant = (node) => isPlainObject(node)
60
+ && (Object.hasOwn(node, 'value') || Object.hasOwn(node, '$value')
61
+ || Object.values(node).some(hasTokenDescendant));
62
+
63
+ /*
64
+ * A "hybrid" legacy node is a token (own `value`) that is also a group, i.e.
65
+ * has children that are tokens or groups of tokens (e.g. Open Props'
66
+ * other.ease.out, with its own value plus out.1 ... out.5). The own value is
67
+ * moved to a child token: `default`, or `base` if `default` is already a
68
+ * child name. If both are taken the build fails. Returns a map
69
+ * dot.path -> chosen child name, used to convert the node itself and to
70
+ * rewrite references pointing to it (references are only rewritten within
71
+ * the same file: a reference from another file to a hybrid node must use the
72
+ * child name explicitly).
73
+ */
74
+ /**
75
+ * @param {unknown} node
76
+ * @param {string[]} path
77
+ * @param {string} filePath
78
+ * @param {Map<string,string>} [hybrids]
79
+ * @returns {Map<string,string>}
80
+ */
81
+ const collectHybridNodes = (node, path, filePath, hybrids = new Map()) => {
82
+ if (!isPlainObject(node) || Object.hasOwn(node, '$value')) return hybrids;
83
+
84
+ if (Object.hasOwn(node, 'value')
85
+ && Object.entries(node).some(([key, child]) => key !== 'value' && hasTokenDescendant(child))) {
86
+ const leaf = [PREFERRED_LEAF, FALLBACK_LEAF].find((name) => !Object.hasOwn(node, name));
87
+ if (!leaf) {
88
+ throw new Error(
89
+ `[build-tokens] legacy tokens: node "${path.join('.')}" in ${filePath} has its own value and children, `
90
+ + `but both "${PREFERRED_LEAF}" and "${FALLBACK_LEAF}" child names are already taken`
91
+ );
92
+ }
93
+ hybrids.set(path.join('.'), leaf);
94
+ }
95
+
96
+ for (const [key, child] of Object.entries(node)) {
97
+ collectHybridNodes(child, [...path, key], filePath, hybrids);
98
+ }
99
+ return hybrids;
100
+ };
101
+
102
+ /**
103
+ * @param {unknown} value
104
+ * @param {Map<string,string>} hybrids
105
+ * @returns {unknown}
106
+ */
107
+ const convertLegacyRefs = (value, hybrids) => {
44
108
  if (typeof value === 'string') {
45
- return value.replace(LEGACY_REF, '{$1}');
109
+ return value.replace(LEGACY_REF, (_, ref) => (
110
+ hybrids.has(ref) ? `{${ref}.${hybrids.get(ref)}}` : `{${ref}}`
111
+ ));
46
112
  }
47
113
  if (Array.isArray(value)) {
48
- return value.map(convertLegacyRefs);
114
+ return value.map((v) => convertLegacyRefs(v, hybrids));
49
115
  }
50
116
  if (value !== null && typeof value === 'object') {
51
117
  return Object.fromEntries(
52
- Object.entries(value).map(([key, v]) => [key, convertLegacyRefs(v)])
118
+ Object.entries(value).map(([key, v]) => [key, convertLegacyRefs(v, hybrids)])
53
119
  );
54
120
  }
55
121
  return value;
56
122
  };
57
123
 
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) => {
124
+ /**
125
+ * @param {unknown} node
126
+ * @param {string[]} path
127
+ * @param {Map<string,string>} hybrids
128
+ * @returns {unknown}
129
+ */
130
+ const convertLegacyNode = (node, path, hybrids) => {
63
131
  if (!isPlainObject(node)) return node;
64
132
 
65
133
  // Already DTCG v5 syntax: leave untouched.
66
134
  if (Object.hasOwn(node, '$value')) return node;
67
135
 
68
136
  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
137
  const { value, type, comment, description, ...rest } = node;
75
- const convertedRest = Object.fromEntries(
76
- Object.entries(rest).map(([key, child]) => [key, convertLegacyNode(child)])
77
- );
78
138
  const desc = comment ?? description;
79
-
80
- return {
81
- ...convertedRest,
82
- $value: convertLegacyRefs(value),
139
+ const token = {
140
+ $value: convertLegacyRefs(value, hybrids),
83
141
  ...(type !== undefined ? { $type: type } : {}),
84
142
  ...(desc !== undefined ? { $description: desc } : {}),
85
143
  };
144
+
145
+ const hybridLeaf = hybrids.get(path.join('.'));
146
+ if (hybridLeaf === undefined) return token;
147
+
148
+ // Hybrid node: becomes a group, its own value moves to a child token.
149
+ return {
150
+ ...Object.fromEntries(
151
+ Object.entries(rest).map(([key, child]) => [key, convertLegacyNode(child, [...path, key], hybrids)])
152
+ ),
153
+ [hybridLeaf]: token,
154
+ };
86
155
  }
87
156
 
88
157
  // Group node: recurse into children.
89
158
  return Object.fromEntries(
90
- Object.entries(node).map(([key, child]) => [key, convertLegacyNode(child)])
159
+ Object.entries(node).map(([key, child]) => [key, convertLegacyNode(child, [...path, key], hybrids)])
91
160
  );
92
161
  };
93
162
 
163
+ /** @param {string} contents @param {string} filePath @returns {unknown} */
164
+ const parseLegacyFile = (contents, filePath) => {
165
+ const tree = JSON.parse(contents);
166
+ return convertLegacyNode(tree, [], collectHybridNodes(tree, [], filePath));
167
+ };
168
+
94
169
  // Registers the parser globally on the StyleDictionary class. To actually
95
170
  // run it, a Style Dictionary instance must also opt in via
96
171
  // `parsers: [LEGACY_TOKENS_PARSER_NAME]` in its config (registering alone
@@ -100,6 +175,6 @@ export const registerLegacyTokensParser = () => {
100
175
  StyleDictionary.registerParser({
101
176
  name: LEGACY_TOKENS_PARSER_NAME,
102
177
  pattern: /\.json$/,
103
- parser: ({ contents }) => convertLegacyNode(JSON.parse(contents)),
178
+ parser: ({ contents, filePath }) => parseLegacyFile(contents, filePath ?? ''),
104
179
  });
105
180
  };
@@ -83,14 +83,17 @@ import { buildSourceModes } from './build-tokens-src/build-source-modes.mjs';
83
83
  // untouched. Files are written flat in jsonBuildPath (no subdirectories), so
84
84
  // only its top level is scanned. Runs regardless of sourceModes.
85
85
  if (jsonBuildPath) {
86
+ // Local const: narrowing of the imported `jsonBuildPath` binding is lost
87
+ // inside the .map() callback below.
88
+ const jsonDir = jsonBuildPath;
86
89
  const { readdir, rm, mkdir } = await import('fs/promises');
87
- const existingEntries = await readdir(jsonBuildPath, { withFileTypes: true }).catch(() => []);
90
+ const existingEntries = await readdir(jsonDir, { withFileTypes: true }).catch(() => []);
88
91
  await Promise.all(
89
92
  existingEntries
90
93
  .filter((entry) => entry.isFile() && /\.jsonc?$/.test(entry.name))
91
- .map((entry) => rm(path.join(jsonBuildPath, entry.name)))
94
+ .map((entry) => rm(path.join(jsonDir, entry.name)))
92
95
  );
93
- await mkdir(jsonBuildPath, { recursive: true });
96
+ await mkdir(jsonDir, { recursive: true });
94
97
  }
95
98
 
96
99
  // ── 7. Build ──────────────────────────────────────────────────────────────────
@@ -58,7 +58,6 @@ async function run() {
58
58
 
59
59
  // Path of the generated CSS file containing all custom property definitions
60
60
  const custom_prop_file_path = path.resolve(configDir, path.join(config.buildPath, config.destFile));
61
- const custom_prop_filename = path.basename(custom_prop_file_path);
62
61
 
63
62
  // Directory to scan for CSS files
64
63
  const checkdir = path.resolve(configDir, config.dirToCheck);
@@ -69,7 +68,7 @@ async function run() {
69
68
 
70
69
  // Collect all .css files in the scan directory, excluding any file under a
71
70
  // directory whose name starts with "TODO" (work-in-progress folders).
72
- // The token file itself (custom_prop_filename) is intentionally included:
71
+ // The token file itself (custom_prop_file_path) is intentionally included:
73
72
  // custom properties are often composed from other custom properties
74
73
  // (e.g. `--malert-box-shadow: ... var(--malert-box-shadow-color) ...`)
75
74
  // and those internal var() references must count as usage, otherwise the
@@ -40,12 +40,14 @@ const config = {
40
40
  // .jsonc, reserved for hand-authored DTCG sources. These .json files are
41
41
  // parsed with plain JSON.parse (no comments, no trailing commas).
42
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.
43
+ // A legacy node that is at the same time a token (own value/type) AND a
44
+ // group with nested children (e.g. Open Props' other.ease.out, which has
45
+ // its own value plus out.1 ... out.5) cannot be represented in DTCG v5 —
46
+ // a node with $value is always a leaf. Its own value is moved to a child
47
+ // token named `default` (other.ease.out.default, i.e. --other-ease-out-default),
48
+ // or `base` if a `default` child already exists; if both exist the build
49
+ // fails. References to such a node from the same file are rewritten
50
+ // accordingly; from other files they must use the child name explicitly.
49
51
  //
50
52
  // WARNING: do not use path.join() to build glob patterns — it may corrupt
51
53
  // the pattern syntax. Use template literals instead:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@massimo-cassandro/minimo",
3
- "version": "1.38.4",
3
+ "version": "1.38.5",
4
4
  "description": "Light css framework & utilities",
5
5
  "type": "module",
6
6
  "main": "./index.js",