@massimo-cassandro/minimo 0.3.6 → 0.4.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 (99) hide show
  1. package/README.md +1 -1
  2. package/design-tokens/alerts.minimo.tokens.jsonc +28 -0
  3. package/design-tokens/badge.minimo.tokens.jsonc +42 -0
  4. package/design-tokens/basic.minimo.tokens.jsonc +58 -0
  5. package/design-tokens/btn-close.minimo.tokens.jsonc +96 -0
  6. package/design-tokens/btn-primary.minimo.tokens.jsonc +96 -0
  7. package/design-tokens/btn.minimo.tokens.jsonc +196 -0
  8. package/design-tokens/color-accent.minimo.tokens.jsonc +18 -0
  9. package/design-tokens/color-neutral.minimo.tokens.jsonc +18 -0
  10. package/design-tokens/color-primary.minimo.tokens.jsonc +18 -0
  11. package/design-tokens/color-secondary.minimo.tokens.jsonc +18 -0
  12. package/design-tokens/components/recaptcha.minimo.tokens.jsonc +28 -0
  13. package/design-tokens/components/sf-macro.minimo.tokens.jsonc +66 -0
  14. package/design-tokens/components/spinner.minimo.tokens.jsonc +24 -0
  15. package/design-tokens/components/toast.minimo.tokens.jsonc +32 -0
  16. package/design-tokens/flex-grid.minimo.tokens.jsonc +24 -0
  17. package/design-tokens/form.minimo.tokens.jsonc +304 -0
  18. package/design-tokens/headings.minimo.tokens.jsonc +62 -0
  19. package/design-tokens/link.minimo.tokens.jsonc +44 -0
  20. package/design-tokens/radius.minimo.tokens.jsonc +42 -0
  21. package/design-tokens/recaptcha.minimo.tokens.jsonc +28 -0
  22. package/design-tokens/sf-macro.minimo.tokens.jsonc +66 -0
  23. package/design-tokens/simple-datatable.minimo.tokens.jsonc +51 -0
  24. package/design-tokens/size.minimo.tokens.jsonc +46 -0
  25. package/design-tokens/spinner.minimo.tokens.jsonc +24 -0
  26. package/design-tokens/status.minimo.tokens.jsonc +98 -0
  27. package/design-tokens/table.minimo.tokens.jsonc +64 -0
  28. package/design-tokens/toast.minimo.tokens.jsonc +32 -0
  29. package/design-tokens/typography.minimo.tokens.jsonc +102 -0
  30. package/design-tokens/utility.minimo.tokens.jsonc +14 -0
  31. package/design-tokens/web-components/simple-datatable.minimo.tokens.jsonc +51 -0
  32. package/design-tokens/z-index.minimo.tokens.jsonc +14 -0
  33. package/design-tokens-src/_readme.md +27 -0
  34. package/design-tokens-src/alerts.minimo.tokens.mjs +25 -0
  35. package/design-tokens-src/size.minimo.tokens.jsonc +105 -0
  36. package/design-tokens-utilities/build-tokens-src/config.mjs +108 -0
  37. package/design-tokens-utilities/build-tokens-src/formats/css.mjs +201 -0
  38. package/design-tokens-utilities/build-tokens-src/formats/penpot.mjs +262 -0
  39. package/design-tokens-utilities/build-tokens-src/platforms.mjs +80 -0
  40. package/design-tokens-utilities/build-tokens-src/transforms.mjs +200 -0
  41. package/design-tokens-utilities/build-tokens.mjs +114 -0
  42. package/design-tokens-utilities/check-unresolved-custom-props.mjs +137 -0
  43. package/design-tokens-utilities/readme.md +185 -0
  44. package/design-tokens-utilities/tokens-config-sample.mjs +98 -0
  45. package/package.json +10 -6
  46. package/src/components/{overlay.js → overlay/overlay.js} +1 -1
  47. package/src/{css/dialog-content.css → components/popup-page/popup-page.css} +1 -1
  48. package/src/components/{dialog-content.js → popup-page/popup-page.js} +6 -4
  49. package/src/custom-properties-default.css +5 -5
  50. package/src/icons/x.svg +1 -0
  51. package/src/web-components/simple-datatable/simple-datatable.js +2 -2
  52. package/src/web-components/simple-datatable/simple-datatable.module.css +3 -3
  53. package/src/web-components/simple-datatable/src/parse-cols.js +2 -2
  54. package/src/design-tokens/TODO.md +0 -2
  55. package/src/design-tokens/alerts.minimo.tokens.jsonc +0 -25
  56. package/src/design-tokens/size.minimo.tokens.jsonc +0 -101
  57. package/src/design-tokens-utilities/build-tokens.mjs +0 -503
  58. package/src/design-tokens-utilities/check-unresolved-custom-props.mjs +0 -131
  59. package/src/design-tokens-utilities/config-templates/tokens-config.mjs +0 -34
  60. package/src/design-tokens-utilities/readme.md +0 -12
  61. /package/{src/design-tokens → design-tokens-src}/badge.minimo.tokens.jsonc +0 -0
  62. /package/{src/design-tokens → design-tokens-src}/basic.minimo.tokens.jsonc +0 -0
  63. /package/{src/design-tokens → design-tokens-src}/btn-close.minimo.tokens.jsonc +0 -0
  64. /package/{src/design-tokens → design-tokens-src}/btn-primary.minimo.tokens.jsonc +0 -0
  65. /package/{src/design-tokens → design-tokens-src}/btn-secondary.minimo.tokens.jsonc +0 -0
  66. /package/{src/design-tokens → design-tokens-src}/btn.minimo.tokens.jsonc +0 -0
  67. /package/{src/design-tokens → design-tokens-src}/color-accent.minimo.tokens.jsonc +0 -0
  68. /package/{src/design-tokens → design-tokens-src}/color-neutral.minimo.tokens.jsonc +0 -0
  69. /package/{src/design-tokens → design-tokens-src}/color-primary.minimo.tokens.jsonc +0 -0
  70. /package/{src/design-tokens → design-tokens-src}/color-secondary.minimo.tokens.jsonc +0 -0
  71. /package/{src/components/recaptcha → design-tokens-src/components}/recaptcha.minimo.tokens.jsonc +0 -0
  72. /package/{src/components/sf-macro → design-tokens-src/components}/sf-macro.minimo.tokens.jsonc +0 -0
  73. /package/{src/components/spinner → design-tokens-src/components}/spinner.minimo.tokens.jsonc +0 -0
  74. /package/{src/components/toast → design-tokens-src/components}/toast.minimo.tokens.jsonc +0 -0
  75. /package/{src/design-tokens → design-tokens-src}/flex-grid.minimo.tokens.jsonc +0 -0
  76. /package/{src/design-tokens → design-tokens-src}/form.minimo.tokens.jsonc +0 -0
  77. /package/{src/design-tokens → design-tokens-src}/gradients-shadowes.minimo.tokens.jsonc +0 -0
  78. /package/{src/design-tokens → design-tokens-src}/headings.minimo.tokens.jsonc +0 -0
  79. /package/{src/design-tokens → design-tokens-src}/link.minimo.tokens.jsonc +0 -0
  80. /package/{src/design-tokens → design-tokens-src}/radius.minimo.tokens.jsonc +0 -0
  81. /package/{src/design-tokens → design-tokens-src}/status.minimo.tokens.jsonc +0 -0
  82. /package/{src/design-tokens → design-tokens-src}/table.minimo.tokens.jsonc +0 -0
  83. /package/{src/design-tokens → design-tokens-src}/typography.minimo.tokens.jsonc +0 -0
  84. /package/{src/design-tokens → design-tokens-src}/utility.minimo.tokens.jsonc +0 -0
  85. /package/{src/web-components/simple-datatable → design-tokens-src/web-components}/simple-datatable.minimo.tokens.jsonc +0 -0
  86. /package/{src/design-tokens → design-tokens-src}/z-index.minimo.tokens.jsonc +0 -0
  87. /package/src/{css → components/overlay}/overlay.css +0 -0
  88. /package/src/{web-components/simple-datatable/icons → icons}/arrow-down.svg +0 -0
  89. /package/src/{web-components/simple-datatable/icons → icons}/arrow-up.svg +0 -0
  90. /package/src/{web-components/simple-datatable/icons → icons}/arrows-down-up.svg +0 -0
  91. /package/src/{web-components/simple-datatable/icons → icons}/caret-left.svg +0 -0
  92. /package/src/{web-components/simple-datatable/icons → icons}/caret-right.svg +0 -0
  93. /package/src/{web-components/simple-datatable/icons → icons}/check-bold.svg +0 -0
  94. /package/src/{components/modal-alert/svg → icons}/check-circle-duotone.svg +0 -0
  95. /package/src/{components/modal-alert/svg → icons}/info-duotone.svg +0 -0
  96. /package/src/{components/modal-alert/svg → icons}/question-duotone.svg +0 -0
  97. /package/src/{components/modal-alert/svg → icons}/warning-duotone.svg +0 -0
  98. /package/src/{web-components/simple-datatable/icons → icons}/x-bold.svg +0 -0
  99. /package/src/{components/modal-alert/svg → icons}/x-circle-duotone.svg +0 -0
@@ -0,0 +1,108 @@
1
+ // build-tokens-src/config.mjs
2
+ // Reads the --config flag from argv, imports the project config file,
3
+ // resolves all paths and exports them for build-tokens.mjs.
4
+ //
5
+ // Keeping this logic separate from the entry point makes it easier to test
6
+ // and reuse in other scripts.
7
+
8
+ /* globals process */
9
+
10
+ import path from 'path';
11
+ import { pathToFileURL } from 'url';
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Read the --config flag
15
+ // ---------------------------------------------------------------------------
16
+ const args = process.argv.slice(2);
17
+ const configFlagIndex = args.indexOf('--config');
18
+
19
+ if (configFlagIndex === -1 || !args[configFlagIndex + 1]) {
20
+ // eslint-disable-next-line no-console
21
+ console.error(
22
+ 'Error: you must specify the config file with --config ./path/to/config/file'
23
+ );
24
+ process.exit(1);
25
+ }
26
+
27
+ const configArgPath = args[configFlagIndex + 1];
28
+ export const configAbsPath = path.resolve(process.cwd(), configArgPath);
29
+ export const configDir = path.dirname(configAbsPath);
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Import the project config
33
+ // ---------------------------------------------------------------------------
34
+ const buildConfig = (await import(pathToFileURL(configAbsPath))).default;
35
+
36
+ // Helper: resolves a path relative to the config directory.
37
+ // Leaves already-absolute paths untouched.
38
+ const resolveFromConfig = (p) => path.resolve(configDir, p);
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Path resolution
42
+ // ---------------------------------------------------------------------------
43
+
44
+ // CSS output
45
+ export const buildPath = resolveFromConfig(buildConfig.buildPath);
46
+ export const destFile = buildConfig.destFile;
47
+
48
+ // Stylelint
49
+ export const stylelintConfigPath = resolveFromConfig(buildConfig.stylelintConfigPath);
50
+
51
+ // Penpot — platform is active only if penpotBuildPath is set in the config
52
+ const penpotBuildPathRaw = buildConfig.penpotBuildPath ?? null;
53
+ export const penpotBuildPath = penpotBuildPathRaw ? resolveFromConfig(penpotBuildPathRaw) : null;
54
+
55
+ // penpotDestFile: base name (no extension) for the aggregated output file.
56
+ // null → one file per source (mirrors the source structure)
57
+ // string → single aggregated file (e.g. 'tokens' → 'tokens.jsonc')
58
+ // The extension is added automatically based on penpotFormat.
59
+ export const penpotDestFile = buildConfig.penpotDestFile ?? null;
60
+
61
+ // penpotFormat: controls output format for Penpot token files.
62
+ // 'jsonc' → .jsonc extension + generated-file disclaimer header
63
+ // 'json' → plain .json, no disclaimer
64
+ // Any value other than 'jsonc' is normalised to 'json'.
65
+ export const penpotFormat = buildConfig.penpotFormat === 'jsonc' ? 'jsonc' : 'json';
66
+
67
+ // penpotExpressions: controls how math expressions in dimension token values are handled.
68
+ // 'keep' (default) — write the expression as-is
69
+ // 'calc' — wrap in CSS calc()
70
+ // 'resolve' — evaluate numerically; unit inherited from the first referenced token
71
+ const VALID_EXPR_MODES = ['keep', 'calc', 'resolve'];
72
+ export const penpotExpressions = VALID_EXPR_MODES.includes(buildConfig.penpotExpressions)
73
+ ? buildConfig.penpotExpressions
74
+ : 'resolve';
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Source: resolve patterns relative to the config directory and normalise to
78
+ // forward-slash absolute paths (fast-glob, used internally by Style Dictionary,
79
+ // requires forward slashes even on Windows).
80
+ //
81
+ // Glob patterns (containing *, ?, {, [) are NOT passed through path.join,
82
+ // because path.join can normalise/collapse sequences that carry syntactic
83
+ // meaning in a glob (e.g. "**"). They are only prefixed with configDir and
84
+ // converted to forward slashes.
85
+ // Concrete paths (no glob characters) are resolved canonically with path.resolve.
86
+ //
87
+ // WARNING: in the project config file, avoid building glob patterns with
88
+ // path.join() — it may corrupt the pattern syntax. Use template literals:
89
+ // OK: `${minimo_path}/**/*.{json,mjs}`
90
+ // NO: path.join(minimo_path, '/**/*.{json,mjs}')
91
+ // ---------------------------------------------------------------------------
92
+ const GLOB_CHARS = /[*?{[]/;
93
+
94
+ const resolveSource = (s) => {
95
+ if (path.isAbsolute(s)) {
96
+ // Already absolute: only normalise separators (needed on Windows)
97
+ return s.split(path.sep).join('/');
98
+ }
99
+ if (GLOB_CHARS.test(s)) {
100
+ // Glob pattern: prefix with configDir without touching the pattern
101
+ const prefix = configDir.split(path.sep).join('/');
102
+ return `${prefix}/${s}`;
103
+ }
104
+ // Concrete relative path: canonical resolution
105
+ return path.resolve(configDir, s).split(path.sep).join('/');
106
+ };
107
+
108
+ export const source = buildConfig.source.map(resolveSource);
@@ -0,0 +1,201 @@
1
+ // build-tokens-src/formats/css.mjs
2
+ // Registers the 'css/variables-sorted' format.
3
+ // Generates a :root { ... } block with custom properties sorted alphabetically.
4
+ // Typography tokens are exploded into multiple CSS properties:
5
+ // - a `font` shorthand (when both fontSize and fontFamily are present)
6
+ // - individual properties for letterSpacing, textTransform, textDecoration
7
+
8
+ import StyleDictionary from 'style-dictionary';
9
+
10
+ const BASE_FONT_SIZE = 16;
11
+
12
+ // Converts a px value to rem. Returns the original string for non-px values.
13
+ const toRem = (val) => {
14
+ if (val === undefined || val === null) return '0';
15
+ const str = String(val).trim();
16
+ const num = parseFloat(str);
17
+ if (isNaN(num) || num === 0) return num === 0 ? '0' : str;
18
+ if (str.endsWith('px')) return `${num / BASE_FONT_SIZE}rem`;
19
+ return str;
20
+ };
21
+
22
+ // Replaces {a.b.c} references with var(--token-name) using the tokenByPath map.
23
+ const makeResolveRefs = (tokenByPath) => (str) =>
24
+ String(str).replace(/\{([^}]+)\}/g, (_, p) => {
25
+ const ref = tokenByPath[p];
26
+ return ref ? `var(--${ref.name})` : `{${p}}`;
27
+ });
28
+
29
+ // ── Complex-type builders ────────────────────────────────────────────────────
30
+
31
+ const buildShadow = (original, resolveRefs) => {
32
+ const shadows = Array.isArray(original) ? original : [original];
33
+ return shadows.map((s) => {
34
+ const offsetX = toRem(s.offsetX ?? s.x);
35
+ const offsetY = toRem(s.offsetY ?? s.y);
36
+ const blur = toRem(s.blur);
37
+ const spread = toRem(s.spread);
38
+ const color = resolveRefs(s.color ?? 'transparent');
39
+ const inset = s.inset ? 'inset ' : '';
40
+ return `${inset}${offsetX} ${offsetY} ${blur} ${spread} ${color}`;
41
+ }).join(', ');
42
+ };
43
+
44
+ const buildGradient = (original, resolveRefs) => {
45
+ const { type = 'linear', angle = 90, stops = [] } = original;
46
+ const stopsList = stops.map((s) => {
47
+ let color = resolveRefs(s.color ?? 'transparent');
48
+ if (s.alpha !== undefined) {
49
+ color = `color-mix(in srgb, ${color} ${s.alpha * 100}%, transparent)`;
50
+ }
51
+ const position = s.position !== undefined ? ` ${s.position * 100}%` : '';
52
+ return `${color}${position}`;
53
+ }).join(', ');
54
+
55
+ if (type === 'radial') return `radial-gradient(circle, ${stopsList})`;
56
+ if (type === 'conic') return `conic-gradient(from ${angle}deg, ${stopsList})`;
57
+ return `linear-gradient(${angle}deg, ${stopsList})`;
58
+ };
59
+
60
+ const buildBorderLike = (original, resolveRefs) => {
61
+ const parts = [resolveRefs(String(original.width ?? '1px'))];
62
+ if (original.style !== undefined && original.style !== null && original.style !== '') {
63
+ parts.push(resolveRefs(String(original.style)));
64
+ }
65
+ parts.push(resolveRefs(String(original.color ?? 'transparent')));
66
+ return parts.join(' ');
67
+ };
68
+
69
+ const buildTransition = (original, resolveRefs) => [
70
+ resolveRefs(String(original.duration ?? '0s')),
71
+ resolveRefs(String(original.timingFunction ?? 'ease')),
72
+ resolveRefs(String(original.delay ?? '0s')),
73
+ resolveRefs(String(original.property ?? 'all')),
74
+ ].join(' ');
75
+
76
+ const buildAnimation = (original, resolveRefs) => [
77
+ resolveRefs(String(original.duration ?? '0s')),
78
+ resolveRefs(String(original.timingFunction ?? 'ease')),
79
+ resolveRefs(String(original.delay ?? '0s')),
80
+ resolveRefs(String(original.iterationCount ?? '1')),
81
+ resolveRefs(String(original.direction ?? 'normal')),
82
+ resolveRefs(String(original.fillMode ?? 'none')),
83
+ resolveRefs(String(original.playState ?? 'running')),
84
+ resolveRefs(String(original.name ?? 'none')),
85
+ ].join(' ');
86
+
87
+ // Builds CSS custom properties for a typography token.
88
+ // Produces a `font` shorthand when both fontSize and fontFamily are present,
89
+ // plus separate properties for letterSpacing, textTransform and textDecoration.
90
+ const buildTypography = (tokenName, original, resolveRefs) => {
91
+ const {
92
+ fontStyle, fontVariant, fontWeight, fontStretch,
93
+ fontSize, lineHeight, fontFamily,
94
+ letterSpacing, textTransform, textDecoration,
95
+ } = original;
96
+
97
+ const lines = [];
98
+
99
+ if (fontSize && fontFamily) {
100
+ const parts = [];
101
+ if (fontStyle) parts.push(resolveRefs(String(fontStyle)));
102
+ if (fontVariant) parts.push(resolveRefs(String(fontVariant)));
103
+ if (fontWeight) parts.push(resolveRefs(String(fontWeight)));
104
+ if (fontStretch) parts.push(resolveRefs(String(fontStretch)));
105
+
106
+ const sizeSlash = lineHeight
107
+ ? `${resolveRefs(String(fontSize))}/${resolveRefs(String(lineHeight))}`
108
+ : resolveRefs(String(fontSize));
109
+
110
+ parts.push(sizeSlash);
111
+ parts.push(resolveRefs(String(fontFamily)));
112
+ lines.push(` --${tokenName}-font: ${parts.join(' ')};`);
113
+ }
114
+
115
+ const EXTRA_PROPS = {
116
+ letterSpacing: ['letter-spacing', letterSpacing],
117
+ textTransform: ['text-transform', textTransform],
118
+ textDecoration: ['text-decoration', textDecoration],
119
+ // Fallback: expose size/family individually if the shorthand could not be built
120
+ ...(!fontSize ? { fontSize: ['font-size', original.fontSize] } : {}),
121
+ ...(!fontFamily ? { fontFamily: ['font-family', original.fontFamily] } : {}),
122
+ };
123
+
124
+ for (const [, [cssProp, val]] of Object.entries(EXTRA_PROPS)) {
125
+ if (val !== undefined) {
126
+ lines.push(` --${tokenName}-${cssProp}: ${resolveRefs(String(val))};`);
127
+ }
128
+ }
129
+
130
+ return lines.join('\n');
131
+ };
132
+
133
+ // ── Format registration ──────────────────────────────────────────────────────
134
+
135
+ // Exported counter — read by build-tokens.mjs for the final log line
136
+ export let customPropsCount = 0;
137
+
138
+ StyleDictionary.registerFormat({
139
+ name: 'css/variables-sorted',
140
+ format: ({ dictionary, options }) => {
141
+ const selector = options.selector ?? ':root';
142
+
143
+ const tokenByPath = {};
144
+ for (const token of dictionary.allTokens) {
145
+ tokenByPath[token.path.join('.')] = token;
146
+ }
147
+
148
+ const resolveRefs = makeResolveRefs(tokenByPath);
149
+
150
+ const lines = dictionary.allTokens
151
+ .slice()
152
+ .sort((a, b) => a.name.localeCompare(b.name))
153
+ .flatMap((token) => {
154
+ const type = token.$type ?? token.type;
155
+ const orig = token.original?.$value ?? token.original?.value;
156
+
157
+ if (type === 'typography') {
158
+ return [buildTypography(token.name, orig ?? {}, resolveRefs)];
159
+ }
160
+
161
+ let value;
162
+
163
+ if (type === 'shadow' && options.outputReferences) {
164
+ value = (typeof orig === 'string' && orig.startsWith('{'))
165
+ ? resolveRefs(orig) : buildShadow(orig, resolveRefs);
166
+
167
+ } else if (type === 'gradient' && options.outputReferences) {
168
+ value = (typeof orig === 'string' && orig.startsWith('{'))
169
+ ? resolveRefs(orig) : buildGradient(orig, resolveRefs);
170
+
171
+ } else if ((type === 'border' || type === 'outline') && options.outputReferences) {
172
+ value = (typeof orig === 'string' && orig.startsWith('{'))
173
+ ? resolveRefs(orig) : buildBorderLike(orig, resolveRefs);
174
+
175
+ } else if (type === 'transition' && options.outputReferences) {
176
+ value = (typeof orig === 'string' && orig.startsWith('{'))
177
+ ? resolveRefs(orig) : buildTransition(orig, resolveRefs);
178
+
179
+ } else if (type === 'animation' && options.outputReferences) {
180
+ value = (typeof orig === 'string' && orig.startsWith('{'))
181
+ ? resolveRefs(orig) : buildAnimation(orig, resolveRefs);
182
+
183
+ } else if (options.outputReferences && typeof orig === 'string' && orig.includes('{')) {
184
+ // Resolve {references} to var(--name). If arithmetic operators remain
185
+ // after substitution the value is a math expression and must be wrapped
186
+ // in calc() to produce valid CSS.
187
+ const resolved = resolveRefs(orig);
188
+ const isCalcNeeded = /[+\-*/]/.test(resolved.replace(/var\([^)]+\)/g, '0'));
189
+ value = isCalcNeeded ? `calc(${resolved})` : resolved;
190
+
191
+ } else {
192
+ value = String(token.$value ?? token.value);
193
+ }
194
+
195
+ return [` --${token.name}: ${value};`];
196
+ });
197
+
198
+ customPropsCount = lines.length;
199
+ return `${selector} {\n${lines.join('\n')}\n}\n`;
200
+ },
201
+ });
@@ -0,0 +1,262 @@
1
+ // build-tokens-src/formats/penpot.mjs
2
+ // Registers the 'json/penpot' format and exports buildPenpotFiles() and
3
+ // collectConcreteFilePaths().
4
+ //
5
+ // The format produces a W3C DTCG nested file importable into Penpot via the
6
+ // "Design Tokens" plugin (also compatible with Token Studio and Supernova).
7
+ //
8
+ // Output format is controlled by the penpotFormat option in the project config:
9
+ // 'jsonc' -> .jsonc extension + generated-file disclaimer header
10
+ // 'json' -> plain .json, no disclaimer
11
+ //
12
+ // buildPenpotFiles() builds the `files` array for the penpot platform:
13
+ // penpotDestFile set -> single aggregated file
14
+ // penpotDestFile null -> one file per concrete source file, mirroring the
15
+ // subdirectory structure of the source tree
16
+ //
17
+ // ── Expression handling (penpotExpressions option) ───────────────────────────
18
+ //
19
+ // Dimension tokens may carry math expressions in their $value, e.g.:
20
+ // { $type: "dimension", $value: "{size.base} * .25" }
21
+ //
22
+ // The penpotExpressions config option controls how these are handled:
23
+ //
24
+ // 'keep' (default) — write the expression as-is; Penpot or the consuming
25
+ // tool is expected to evaluate it.
26
+ // 'calc' — wrap in a CSS calc(): "calc({size.base} * .25)"
27
+ // aliases are left as {references} for Penpot to resolve.
28
+ // 'resolve' — evaluate the expression numerically and write a concrete value.
29
+ // The unit is inherited from the first dimension token
30
+ // referenced in the expression (e.g. {size.base} = 16px
31
+ // -> 16 * .25 = 4 -> "4px"). If evaluation fails the
32
+ // original expression is kept with a warning.
33
+
34
+ import StyleDictionary from 'style-dictionary';
35
+ import path from 'path';
36
+
37
+ // Disclaimer prepended to every generated file when penpotFormat is 'jsonc'
38
+ const DISCLAIMER = [
39
+ '// -----------------------------------------------------------------------',
40
+ '// Generated file — do not edit manually.',
41
+ '// This file is produced by the design-token build pipeline.',
42
+ '// Source of truth: the token files in the project source directory.',
43
+ '// Re-generate with: node build-tokens.mjs --config <path/to/config>',
44
+ '// -----------------------------------------------------------------------',
45
+ '',
46
+ ].join('\n');
47
+
48
+ // ── Expression helpers ────────────────────────────────────────────────────────
49
+
50
+ // Returns true if the string contains a math expression — i.e. it has at least
51
+ // one operator (+, -, *, /) outside of a token reference ({...}).
52
+ const isExpression = (str) => {
53
+ if (typeof str !== 'string') return false;
54
+ // Strip token references, then check for operators
55
+ const stripped = str.replace(/\{[^}]+\}/g, '0');
56
+ return /[+\-*/]/.test(stripped);
57
+ };
58
+
59
+ // Builds a token lookup map (dot-path -> token) from the dictionary.
60
+ const makeTokenMap = (dictionary) => {
61
+ const map = {};
62
+ for (const token of dictionary.allTokens) {
63
+ map[token.path.join('.')] = token;
64
+ }
65
+ return map;
66
+ };
67
+
68
+ // Resolves a dimension expression to a concrete value string (e.g. "4px").
69
+ //
70
+ // Strategy:
71
+ // 1. Find the first {reference} in the expression.
72
+ // 2. Look it up in the token map to obtain its resolved numeric value and unit.
73
+ // 3. Replace ALL {references} in the expression with their numeric values.
74
+ // 4. Evaluate the resulting arithmetic expression with Function().
75
+ // 5. Reattach the unit from step 2.
76
+ //
77
+ // Returns null if resolution fails (caller falls back to the original string).
78
+ const resolveExpression = (orig, tokenMap) => {
79
+ // Collect all {reference} keys in order of appearance
80
+ const refPattern = /\{([^}]+)\}/g;
81
+ const refs = [...orig.matchAll(refPattern)];
82
+ if (refs.length === 0) return null;
83
+
84
+ // Determine unit from the first referenced dimension token
85
+ const firstRef = tokenMap[refs[0][1]];
86
+ if (!firstRef) return null;
87
+
88
+ const firstVal = String(firstRef.$value ?? firstRef.value ?? '');
89
+ const unitMatch = firstVal.match(/[a-z%]+$/i);
90
+ const unit = unitMatch ? unitMatch[0] : '';
91
+ const baseNum = parseFloat(firstVal);
92
+ if (isNaN(baseNum)) return null;
93
+
94
+ // Replace every {ref} with its numeric value
95
+ let expr = orig;
96
+ for (const match of refs) {
97
+ const refKey = match[1];
98
+ const refToken = tokenMap[refKey];
99
+ if (!refToken) return null;
100
+ const refVal = parseFloat(String(refToken.$value ?? refToken.value ?? ''));
101
+ if (isNaN(refVal)) return null;
102
+ expr = expr.replace(match[0], String(refVal));
103
+ }
104
+
105
+ // Evaluate — only allow digits, whitespace and arithmetic operators
106
+ if (!/^[\d\s+\-*/.()]+$/.test(expr)) return null;
107
+
108
+ try {
109
+ // eslint-disable-next-line no-new-func
110
+ const result = Function(`"use strict"; return (${expr})`)();
111
+ if (typeof result !== 'number' || !isFinite(result)) return null;
112
+ // Round to a reasonable precision to avoid floating-point noise
113
+ const rounded = parseFloat(result.toPrecision(10));
114
+ return unit ? `${rounded}${unit}` : String(rounded);
115
+ } catch {
116
+ return null;
117
+ }
118
+ };
119
+
120
+ // Wraps a dimension expression in CSS calc(), leaving {references} intact.
121
+ const toCalc = (orig) => `calc(${orig})`;
122
+
123
+ // ── Format ───────────────────────────────────────────────────────────────────
124
+
125
+ StyleDictionary.registerFormat({
126
+ name: 'json/penpot',
127
+ format: ({ dictionary, options }) => {
128
+ const root = {};
129
+ const expressionMode = options.penpotExpressions ?? 'keep'; // 'keep' | 'calc' | 'resolve'
130
+ const tokenMap = (expressionMode === 'resolve') ? makeTokenMap(dictionary) : null;
131
+
132
+ for (const token of dictionary.allTokens) {
133
+ // Rebuild the nested tree from the token path
134
+ let node = root;
135
+ for (let i = 0; i < token.path.length - 1; i++) {
136
+ const segment = token.path[i];
137
+ node[segment] = node[segment] ?? {};
138
+ node = node[segment];
139
+ }
140
+
141
+ const leafKey = token.path[token.path.length - 1];
142
+ const type = token.$type ?? token.type;
143
+ const orig = token.original?.$value ?? token.original?.value;
144
+
145
+ // ── Value resolution ────────────────────────────────────────────────────
146
+ let value;
147
+
148
+ if (
149
+ type === 'dimension' &&
150
+ typeof orig === 'string' &&
151
+ isExpression(orig)
152
+ ) {
153
+ // The original $value is a math expression
154
+ if (expressionMode === 'resolve') {
155
+ const resolved = resolveExpression(orig, tokenMap);
156
+ if (resolved !== null) {
157
+ value = resolved;
158
+ } else {
159
+ // Resolution failed — keep the original and emit a warning
160
+ console.warn(`[build-tokens] penpot: could not resolve expression "${orig}" for token "${token.path.join('.')}". Keeping original.`);
161
+ value = orig;
162
+ }
163
+ } else if (expressionMode === 'calc') {
164
+ // Replace {refs} with their alias notation and wrap in calc()
165
+ value = toCalc(orig);
166
+ } else {
167
+ // 'keep' — write the expression unchanged
168
+ value = orig;
169
+ }
170
+ } else {
171
+ // Non-expression value: preserve aliases for Penpot token links
172
+ const isAlias = typeof orig === 'string' && orig.startsWith('{') && !orig.includes(' ');
173
+ value = isAlias ? orig : (token.$value ?? token.value);
174
+ }
175
+
176
+ const entry = { $type: type, $value: value };
177
+
178
+ const desc = token.$description ?? token.description ?? token.comment;
179
+ if (desc) entry.$description = desc;
180
+
181
+ node[leafKey] = entry;
182
+ }
183
+
184
+ const body = JSON.stringify(root, null, 2) + '\n';
185
+ return options.jsonc ? DISCLAIMER + body : body;
186
+ },
187
+ });
188
+
189
+ // ── commonDir ────────────────────────────────────────────────────────────────
190
+ // Returns the longest common directory prefix shared by all given file paths.
191
+ // Used to compute relative paths that mirror the source subdirectory structure.
192
+
193
+ const commonDir = (filePaths) => {
194
+ if (filePaths.length === 0) return '';
195
+ if (filePaths.length === 1) return path.dirname(filePaths[0]);
196
+
197
+ const dirs = filePaths.map(fp => path.dirname(path.resolve(fp)));
198
+ const parts = dirs[0].split(path.sep);
199
+
200
+ let i = 0;
201
+ while (i < parts.length && dirs.every(d => d.split(path.sep)[i] === parts[i])) i++;
202
+
203
+ return parts.slice(0, i).join(path.sep);
204
+ };
205
+
206
+ // ── collectConcreteFilePaths ──────────────────────────────────────────────────
207
+ //
208
+ // Extracts the unique concrete file paths from a Style Dictionary instance.
209
+ // Must be called AFTER `await sd.hasInitialized`.
210
+
211
+ export const collectConcreteFilePaths = async (sd) => {
212
+ await sd.hasInitialized;
213
+ const paths = new Set();
214
+ for (const token of sd.allTokens) {
215
+ if (token.filePath) paths.add(token.filePath);
216
+ }
217
+ return [...paths].sort();
218
+ };
219
+
220
+ // ── buildPenpotFiles ──────────────────────────────────────────────────────────
221
+ //
222
+ // @param {string[]} concreteFilePaths From collectConcreteFilePaths()
223
+ // @param {string|null} penpotDestFile Aggregated file base name, or null
224
+ // @param {'json'|'jsonc'} penpotFormat Output format
225
+ // @param {'keep'|'calc'|'resolve'} penpotExpressions Expression handling mode
226
+ // @returns {object[]} File descriptors for the Style Dictionary platform
227
+
228
+ export const buildPenpotFiles = (
229
+ concreteFilePaths,
230
+ penpotDestFile,
231
+ penpotFormat = 'json',
232
+ penpotExpressions = 'keep'
233
+ ) => {
234
+ const ext = penpotFormat === 'jsonc' ? '.jsonc' : '.json';
235
+ const jsonc = penpotFormat === 'jsonc';
236
+
237
+ if (penpotDestFile) {
238
+ return [{
239
+ destination: penpotDestFile + ext,
240
+ format: 'json/penpot',
241
+ options: { jsonc, penpotExpressions },
242
+ }];
243
+ }
244
+
245
+ const sourceRoot = commonDir(concreteFilePaths);
246
+
247
+ return concreteFilePaths.map((filePath) => {
248
+ const absPath = path.resolve(filePath);
249
+ const relPath = path.relative(sourceRoot, absPath);
250
+ const destName = relPath.replace(/\.[^.]+$/, '') + ext;
251
+
252
+ return {
253
+ destination: destName,
254
+ format: 'json/penpot',
255
+ options: { jsonc, penpotExpressions },
256
+ filter: (token) => {
257
+ const fp = token.filePath ? path.resolve(token.filePath) : '';
258
+ return fp === absPath;
259
+ },
260
+ };
261
+ });
262
+ };
@@ -0,0 +1,80 @@
1
+ // build-tokens-src/platforms.mjs
2
+ // Builds and returns the `platforms` object for the Style Dictionary config.
3
+ //
4
+ // The `penpot` platform is included only if penpotBuildPath is set.
5
+ // Output format (json/jsonc) and file splitting are delegated to buildPenpotFiles().
6
+
7
+ import { buildPenpotFiles } from './formats/penpot.mjs';
8
+
9
+ // Transforms applied to the CSS platform
10
+ const CSS_TRANSFORMS = [
11
+ 'attribute/cti',
12
+ 'name/kebab',
13
+ 'time/seconds',
14
+ 'asset/url',
15
+ 'size/pxToRem-smart',
16
+ 'color/css-modern',
17
+ 'shadow/css',
18
+ 'gradient/css',
19
+ 'composite/css',
20
+ 'typography/css',
21
+ ];
22
+
23
+ // Transforms applied to the Penpot platform.
24
+ // Kept minimal on purpose: Penpot expects original values (e.g. "16px" not "1rem")
25
+ // and alias references ({...}) must be preserved to maintain token links.
26
+ const PENPOT_TRANSFORMS = [
27
+ 'attribute/cti',
28
+ 'name/kebab',
29
+ 'color/css-modern',
30
+ ];
31
+
32
+ /**
33
+ * @param {object} opts
34
+ * @param {string} opts.buildPath Absolute path for CSS output directory
35
+ * @param {string} opts.destFile CSS output filename (e.g. "tokens.css")
36
+ * @param {string|null} opts.penpotBuildPath Absolute path for Penpot output (null = disabled)
37
+ * @param {string|null} opts.penpotDestFile Base name for aggregated file; null = one file per source
38
+ * @param {'json'|'jsonc'} opts.penpotFormat Output format for Penpot files
39
+ * @param {string[]} opts.concreteFilePaths Concrete (expanded) source file paths for multi-file mode.
40
+ * Collected from sd.allTokens after SD initialisation.
41
+ * Ignored when penpotDestFile is set.
42
+ * @param {'keep'|'calc'|'resolve'} opts.penpotExpressions How to handle math expressions in dimension tokens.
43
+ * @returns {object} platforms object ready for Style Dictionary config
44
+ */
45
+ export const buildPlatforms = ({
46
+ buildPath,
47
+ destFile,
48
+ penpotBuildPath,
49
+ penpotDestFile,
50
+ penpotFormat,
51
+ penpotExpressions = 'keep',
52
+ concreteFilePaths = [],
53
+ }) => {
54
+ const platforms = {
55
+ css: {
56
+ buildPath: buildPath + '/',
57
+ transforms: CSS_TRANSFORMS,
58
+ files: [
59
+ {
60
+ destination: destFile,
61
+ format: 'css/variables-sorted',
62
+ options: {
63
+ outputReferences: true,
64
+ showFileHeader: true,
65
+ },
66
+ },
67
+ ],
68
+ },
69
+ };
70
+
71
+ if (penpotBuildPath) {
72
+ platforms.penpot = {
73
+ buildPath: penpotBuildPath + '/',
74
+ transforms: PENPOT_TRANSFORMS,
75
+ files: buildPenpotFiles(concreteFilePaths, penpotDestFile, penpotFormat, penpotExpressions),
76
+ };
77
+ }
78
+
79
+ return platforms;
80
+ };