@styleframe/cli 3.0.0 → 4.0.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.
- package/CHANGELOG.md +90 -0
- package/dist/build-BIWOTFZD.cjs +49 -0
- package/dist/{build-BFZSJ2Zh.js → build-CANA04j1.js} +1 -1
- package/dist/export-BMneJTdq.cjs +517 -0
- package/dist/export-Cx6awh55.js +517 -0
- package/dist/import-BLbc2zWU.cjs +90 -0
- package/dist/{import-CwuwczM7.js → import-a5DtGEAY.js} +1 -1
- package/dist/index-BTHfb82h.js +14 -0
- package/dist/index-jMzviwjD.cjs +14 -0
- package/dist/index.cjs +21 -8669
- package/dist/index.js +4 -5
- package/dist/{init-DnrkQJYO.js → init-CKeTXHp5.js} +10 -10
- package/dist/init-CO7VnQKe.cjs +234 -0
- package/package.json +5 -4
- package/dist/export-SH70kD-5.js +0 -139
- package/dist/index-C3Gqfamh.js +0 -3689
- package/dist/index-DH3Hm47n.js +0 -14
- package/dist/index-DtEAy_us.js +0 -4475
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,95 @@
|
|
|
1
1
|
# @styleframe/cli
|
|
2
2
|
|
|
3
|
+
## 4.0.0
|
|
4
|
+
|
|
5
|
+
### Major Changes
|
|
6
|
+
|
|
7
|
+
- [#211](https://github.com/styleframe-dev/styleframe/pull/211) [`8826eda`](https://github.com/styleframe-dev/styleframe/commit/8826edad3fcb2e969024a586a20c2059229d958f) Thanks [@alexgrozav](https://github.com/alexgrozav)! - **BREAKING**: rename `styleframe figma export` → `styleframe dtcg export`.
|
|
8
|
+
|
|
9
|
+
The CLI command now lives under a top-level `dtcg` subcommand to reflect that it produces a generic spec-conformant DTCG document, not a Figma-specific format. The DTCG → Figma value conversion (rem→px, ms passthrough, etc.) lives in the `@styleframe/figma` plugin, which consumes the JSON.
|
|
10
|
+
|
|
11
|
+
`styleframe figma import` is unchanged — it generates Styleframe TypeScript code from a Figma-flavoured DTCG export.
|
|
12
|
+
|
|
13
|
+
Migration:
|
|
14
|
+
|
|
15
|
+
```diff
|
|
16
|
+
- styleframe figma export -o tokens.json
|
|
17
|
+
+ styleframe dtcg export -o tokens.json
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The `--baseFontSize` flag has been removed from the export — base font size is applied by the Figma plugin during import (default 16). Any CI scripts referring to the old command must be updated.
|
|
21
|
+
|
|
22
|
+
### Minor Changes
|
|
23
|
+
|
|
24
|
+
- [#213](https://github.com/styleframe-dev/styleframe/pull/213) [`24eebba`](https://github.com/styleframe-dev/styleframe/commit/24eebba87c8fa6fc6822e18d67f4c0412192e793) Thanks [@alexgrozav](https://github.com/alexgrozav)! - **BREAKING**: rename `styleframe figma import` → `styleframe dtcg import`.
|
|
25
|
+
|
|
26
|
+
The CLI command now lives under the `dtcg` subcommand alongside `dtcg export`. The command takes a generic spec-conformant DTCG JSON file (whether produced by the `@styleframe/figma` plugin or any other DTCG-compatible tool) and generates Styleframe TypeScript code — there is nothing Figma-specific about it. The top-level `figma` subcommand has been removed; the namespace is reserved for future commands that genuinely interact with the Figma API.
|
|
27
|
+
|
|
28
|
+
All flags are unchanged (`--input`/`-i`, `--output`/`-o`, `--composables`, `--rem`, `--baseFontSize`, `--instanceName`).
|
|
29
|
+
|
|
30
|
+
Migration:
|
|
31
|
+
|
|
32
|
+
```diff
|
|
33
|
+
- styleframe figma import -i tokens.json
|
|
34
|
+
+ styleframe dtcg import -i tokens.json
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Any CI scripts referring to the old command must be updated.
|
|
38
|
+
|
|
39
|
+
### Patch Changes
|
|
40
|
+
|
|
41
|
+
- [#211](https://github.com/styleframe-dev/styleframe/pull/211) [`8826eda`](https://github.com/styleframe-dev/styleframe/commit/8826edad3fcb2e969024a586a20c2059229d958f) Thanks [@alexgrozav](https://github.com/alexgrozav)! - Fix DTCG export from `@styleframe/cli`: variables now get the correct `$type` and references are preserved as aliases.
|
|
42
|
+
|
|
43
|
+
The previous CLI flow piped Styleframe AST through a lossy `FigmaExportFormat` intermediate, then through `figmaTypeToDtcg("STRING") → "fontFamily"` — so most variables ended up tagged as `fontFamily` in the resulting JSON. References were silently replaced with empty strings, and CSS template expressions like `${ref(a)} * ${ref(b)}` were joined into garbage like `" * "`.
|
|
44
|
+
|
|
45
|
+
**`@styleframe/dtcg`** gains a new `classifyValue(value, {path?})` helper plus `parseCubicBezier` and `easingKeywordToBezier`. The classifier combines value-content detection with optional path heuristics so callers get a single canonical `{type, value}` pair without per-package guessing.
|
|
46
|
+
|
|
47
|
+
**`@styleframe/cli`** now builds the DTCG document directly from the Styleframe `Root`. A small evaluator pre-resolves `Reference` chains and pure-arithmetic CSS templates so computed variables (e.g. `scale.min-powers.*`) emit concrete numbers instead of being skipped or corrupted. Unevaluable expressions (involving `clamp()`, `vw`, etc.) are preserved with a `dev.styleframe.expression` extension, surfaced in a diagnostics summary at the end of the run.
|
|
48
|
+
|
|
49
|
+
**`@styleframe/figma`** uses the same classifier with the Figma variable name as a path hint. `figmaTypeToDtcg("STRING")` no longer claims everything is a `fontFamily`; instead callers should use the new `classifyFigmaVariable(variable, value)` which correctly identifies durations under `duration/*`, easings under `easing/*`, stroke styles, and font weights. STRING values whose name gives no usable hint are emitted with a `dev.styleframe.unknownType` extension rather than silently mistyped.
|
|
50
|
+
|
|
51
|
+
Round-trip lossiness across Styleframe ↔ DTCG ↔ Figma is now documented in `tooling/dtcg/AGENTS.md`.
|
|
52
|
+
|
|
53
|
+
- [#211](https://github.com/styleframe-dev/styleframe/pull/211) [`8826eda`](https://github.com/styleframe-dev/styleframe/commit/8826edad3fcb2e969024a586a20c2059229d958f) Thanks [@alexgrozav](https://github.com/alexgrozav)! - Fix `dtcg export`: fluid font-size tokens (and any other tokens whose value reduces to a `calc()` mixing `100vw`, `rem`, and `px` literals) are now normalised to a concrete pixel value using the project's `fluid.max-width` (default 1440) instead of being emitted as opaque `calc(...)` expressions with a `dev.styleframe.unknownType` extension.
|
|
54
|
+
|
|
55
|
+
Previously every `font-size.*` token in a config that used `useFluidFontSizeDesignTokens` shipped to Figma as a useless `STRING` variable holding the raw `calc(...)` formula. They now ship as spec-conformant `dimension` tokens (`{value, unit: "px"}`) that the Figma plugin renders as real numeric variables (`font-size/md = 18`, `font-size/lg = 22.5`, etc.).
|
|
56
|
+
|
|
57
|
+
The substitution runs only as a fallback after the standard arithmetic check fails, so well-formed numeric expressions are unaffected. Affected tokens carry a `dev.styleframe.fluidBound: "max"` extension so the derivation is auditable. The export run reports `Normalised <n> fluid token(s) using max viewport (<n>px).`
|
|
58
|
+
|
|
59
|
+
- [#211](https://github.com/styleframe-dev/styleframe/pull/211) [`8826eda`](https://github.com/styleframe-dev/styleframe/commit/8826edad3fcb2e969024a586a20c2059229d958f) Thanks [@alexgrozav](https://github.com/alexgrozav)! - Fix dark mode round-trip from Styleframe → DTCG → Figma.
|
|
60
|
+
|
|
61
|
+
Previously, `styleframe dtcg export` emitted a `tokens.resolver.json` that the Figma plugin had no way to consume — the plugin UI accepted only one file and its import handler always called the single-mode `fromDTCG()`. As a result, dark mode values never reached Figma. The exported resolver was also malformed: `default` referenced a non-existent context, and the resolver lacked a `set` linking to the base `tokens.json`, so any spec-conformant consumer would have failed too.
|
|
62
|
+
|
|
63
|
+
**`@styleframe/cli`** — `buildDTCG` now emits a self-contained resolver: `resolutionOrder` begins with a `set` referencing the base tokens file (controllable via `tokensSourceRef`), every theme contributes a capitalized context (`dark` → `Dark`), and a `Default` context (with no overrides) covers the unthemed mode. `default` correctly points at `Default`. Override tokens also carry `$type` so the `mergeDocuments` token-level replacement preserves typing — without this, dark color values lost their `color` type and downstream consumers (e.g. Figma) couldn't convert them, falling back to default white.
|
|
64
|
+
|
|
65
|
+
**`@styleframe/figma`** — the plugin UI exposes a second drop slot for `tokens.resolver.json`. When a resolver is provided, `code.ts` routes through `fromDTCGResolver` with an in-memory file loader so each declared context becomes a Figma mode populated with the correct per-mode values.
|
|
66
|
+
|
|
67
|
+
- [#211](https://github.com/styleframe-dev/styleframe/pull/211) [`8826eda`](https://github.com/styleframe-dev/styleframe/commit/8826edad3fcb2e969024a586a20c2059229d958f) Thanks [@alexgrozav](https://github.com/alexgrozav)! - Fix two regressions in DTCG export uncovered by dogfooding the storybook config:
|
|
68
|
+
|
|
69
|
+
**Keyword-classification ambiguity.** The previous classifier matched `FONT_WEIGHT_KEYWORDS` and `STROKE_STYLE_KEYWORDS` on value content alone — but `"normal"` is a perfectly valid value for `letter-spacing`, `font-style`, and `line-height`, not just `font-weight`. Same for `"thin"`/`"medium"`/`"thick"` which are CSS border-width shorthand keywords. The result was that `border-width`, `font-style`, `letter-spacing`, and similar tokens were misclassified as `fontWeight` whenever their alias chain bottomed out at one of these CSS keywords.
|
|
70
|
+
|
|
71
|
+
The fix: keyword-based detectors now only fire when the path also indicates the corresponding category (`font-weight` segment for fontWeight, `border-style`/`outline-style`/`stroke` for strokeStyle, `font-family` for fontFamily). Callers that don't supply a path still get the keyword-based behaviour for back-compat. Tokens with no resolvable type emit a `dev.styleframe.unknownType` extension instead of a wrong `$type`.
|
|
72
|
+
|
|
73
|
+
**Parent/child path collisions wiped out children.** Styleframe configs commonly define both a parent variable (e.g. `border-width` aliased to `border-width.thin`) AND its child variants (`border-width.thin`, `border-width.medium`, `border-width.thick`). The previous `setNestedToken` logic unconditionally overwrote the slot, so depending on processing order either the parent or all the children were silently lost.
|
|
74
|
+
|
|
75
|
+
The fix: `setNestedToken` now uses the spec's reserved `$root` slot for parent tokens that coexist with siblings — a pattern explicitly defined in the Format Module 2025.10 for "group with a base value plus variants". Children survive alongside the parent, and the walker / validator / Figma plugin import all recognise `$root` as the parent's effective leaf.
|
|
76
|
+
|
|
77
|
+
The CLI also gains a one-line tip in its diagnostic output explaining that "untyped" tokens are usually CSS keywords with no DTCG equivalent.
|
|
78
|
+
|
|
79
|
+
**Numeric-string misclassification as colour.** `classifyValue("100")` and similar 3-character numeric strings were incorrectly classified as `$type: "color"` because culori's `parse()` interprets them as shorthand CSS hex (`"100"` → `#100`). Z-index tokens like `z-index.dropdown = "100"` consequently appeared as sRGB color variables in the exported JSON.
|
|
80
|
+
|
|
81
|
+
The fix: `classifyValue` now only forwards strings to culori that structurally look like CSS colors — those starting with `#`, containing `(` (function notation), or beginning with an ASCII letter (named color). Plain numeric strings are excluded before reaching the parser.
|
|
82
|
+
|
|
83
|
+
**Plugin import crash on viewport-relative units.** Importing a `tokens.json` that contained any `{value: 100, unit: "vw"}` dimension token caused the entire Figma plugin import to throw `Cannot convert "100vw" to a Figma FLOAT`. The exception propagated through the entire import, leaving all variables (including colors) at their default `#FFFFFF`.
|
|
84
|
+
|
|
85
|
+
The fix: `dtcgDimensionToFloat` now returns `undefined` for unsupported units (vw, vh, dvw, svw, etc.) instead of throwing, and `fromDTCG` silently skips variables whose value cannot be converted rather than crashing the whole batch. Colors and other tokens now import correctly even when the document contains fluid/viewport tokens.
|
|
86
|
+
|
|
87
|
+
**Out-of-gamut sRGB clamping.** Converting oklch colours to Figma's sRGB RGBA could produce slightly negative channel values (e.g. `r = -4.21e-15`) due to floating-point rounding in the matrix multiplication. The fix clamps `r`, `g`, and `b` to `[0, 1]` in `dtcgColorToFigmaRgba`.
|
|
88
|
+
|
|
89
|
+
- Updated dependencies [[`8826eda`](https://github.com/styleframe-dev/styleframe/commit/8826edad3fcb2e969024a586a20c2059229d958f), [`8826eda`](https://github.com/styleframe-dev/styleframe/commit/8826edad3fcb2e969024a586a20c2059229d958f), [`8826eda`](https://github.com/styleframe-dev/styleframe/commit/8826edad3fcb2e969024a586a20c2059229d958f), [`8826eda`](https://github.com/styleframe-dev/styleframe/commit/8826edad3fcb2e969024a586a20c2059229d958f), [`8826eda`](https://github.com/styleframe-dev/styleframe/commit/8826edad3fcb2e969024a586a20c2059229d958f)]:
|
|
90
|
+
- @styleframe/dtcg@1.1.0
|
|
91
|
+
- @styleframe/figma@2.0.0
|
|
92
|
+
|
|
3
93
|
## 3.0.0
|
|
4
94
|
|
|
5
95
|
### Major Changes
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
3
|
+
const path = require("node:path");
|
|
4
|
+
const loader = require("@styleframe/loader");
|
|
5
|
+
const citty = require("citty");
|
|
6
|
+
const consola = require("consola");
|
|
7
|
+
const build = citty.defineCommand({
|
|
8
|
+
meta: {
|
|
9
|
+
name: "build",
|
|
10
|
+
description: "Build Styleframe project from source files"
|
|
11
|
+
},
|
|
12
|
+
args: {
|
|
13
|
+
entry: {
|
|
14
|
+
type: "positional",
|
|
15
|
+
description: "Entry point file(s) for the build",
|
|
16
|
+
default: "styleframe.config.ts",
|
|
17
|
+
valueHint: "path"
|
|
18
|
+
},
|
|
19
|
+
outputDir: {
|
|
20
|
+
type: "string",
|
|
21
|
+
description: "Output directory for built files",
|
|
22
|
+
default: "styleframe",
|
|
23
|
+
alias: ["o", "out"],
|
|
24
|
+
valueHint: "path"
|
|
25
|
+
},
|
|
26
|
+
clean: {
|
|
27
|
+
type: "boolean",
|
|
28
|
+
description: "Clean output directory before build",
|
|
29
|
+
default: false
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
async run({ args }) {
|
|
33
|
+
consola.info(
|
|
34
|
+
`Loading configuration from "${path.relative(process.cwd(), path.resolve(args.entry))}"...`
|
|
35
|
+
);
|
|
36
|
+
const instance = await loader.loadConfiguration({ entry: args.entry });
|
|
37
|
+
consola.info("Building styleframe...");
|
|
38
|
+
try {
|
|
39
|
+
await loader.build(instance, {
|
|
40
|
+
outputDir: args.outputDir,
|
|
41
|
+
clean: args.clean
|
|
42
|
+
});
|
|
43
|
+
consola.success("Styleframe built successfully!");
|
|
44
|
+
} catch (error) {
|
|
45
|
+
consola.error("Failed to build Styleframe:", error);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
exports.default = build;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
-
import {
|
|
2
|
+
import { loadConfiguration, build as build$1 } from "@styleframe/loader";
|
|
3
3
|
import { defineCommand } from "citty";
|
|
4
4
|
import consola from "consola";
|
|
5
5
|
const build = defineCommand({
|
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
3
|
+
const promises = require("node:fs/promises");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const loader = require("@styleframe/loader");
|
|
6
|
+
const citty = require("citty");
|
|
7
|
+
const consola = require("consola");
|
|
8
|
+
const dtcg = require("@styleframe/dtcg");
|
|
9
|
+
function isReference(value) {
|
|
10
|
+
return typeof value === "object" && value !== null && "type" in value && value.type === "reference";
|
|
11
|
+
}
|
|
12
|
+
function isCss(value) {
|
|
13
|
+
return typeof value === "object" && value !== null && "type" in value && value.type === "css";
|
|
14
|
+
}
|
|
15
|
+
function isPrimitive(value) {
|
|
16
|
+
return typeof value === "string" || typeof value === "number" || typeof value === "boolean";
|
|
17
|
+
}
|
|
18
|
+
function resolveReferenceTarget(ref, context) {
|
|
19
|
+
const visited = context.visited ?? /* @__PURE__ */ new Set();
|
|
20
|
+
if (visited.has(ref.name)) {
|
|
21
|
+
return {
|
|
22
|
+
resolved: null,
|
|
23
|
+
reason: `Reference cycle: ${[...visited, ref.name].join(" → ")}`
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
const target = context.variables.get(ref.name);
|
|
27
|
+
if (!target) {
|
|
28
|
+
if (ref.fallback !== void 0 && ref.fallback !== null) {
|
|
29
|
+
return evaluateValue(ref.fallback, context);
|
|
30
|
+
}
|
|
31
|
+
return { resolved: null, reason: `Unknown reference target: ${ref.name}` };
|
|
32
|
+
}
|
|
33
|
+
const nextContext = {
|
|
34
|
+
variables: context.variables,
|
|
35
|
+
visited: /* @__PURE__ */ new Set([...visited, ref.name]),
|
|
36
|
+
maxViewport: context.maxViewport
|
|
37
|
+
};
|
|
38
|
+
return evaluateValue(target.value, nextContext);
|
|
39
|
+
}
|
|
40
|
+
function evaluateCss(css, context) {
|
|
41
|
+
const parts = [];
|
|
42
|
+
let unevaluable = false;
|
|
43
|
+
let unevaluableReason;
|
|
44
|
+
for (const part of css.value) {
|
|
45
|
+
if (typeof part === "string") {
|
|
46
|
+
parts.push({ kind: "literal", text: part });
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const evaluated = evaluateValue(part, context);
|
|
50
|
+
if (evaluated.resolved === null) {
|
|
51
|
+
unevaluable = true;
|
|
52
|
+
unevaluableReason = evaluated.reason;
|
|
53
|
+
parts.push({ kind: "value", text: "" });
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const text = String(evaluated.resolved);
|
|
57
|
+
const numeric = typeof evaluated.resolved === "number" ? evaluated.resolved : void 0;
|
|
58
|
+
parts.push({ kind: "value", text, numeric });
|
|
59
|
+
}
|
|
60
|
+
const folded = parts.map((p) => p.text).join("");
|
|
61
|
+
if (unevaluable) {
|
|
62
|
+
return {
|
|
63
|
+
resolved: null,
|
|
64
|
+
cssExpression: folded,
|
|
65
|
+
reason: unevaluableReason ?? "Computed expression includes an unresolvable reference"
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
const isPureArithmetic = parts.every((p) => {
|
|
69
|
+
if (p.kind === "value") return p.numeric !== void 0;
|
|
70
|
+
return /^[\d.\s+\-*/()]*$/.test(p.text);
|
|
71
|
+
});
|
|
72
|
+
if (isPureArithmetic && parts.some((p) => p.kind === "value")) {
|
|
73
|
+
try {
|
|
74
|
+
const result = safeArithmetic(folded.trim());
|
|
75
|
+
if (typeof result === "number" && Number.isFinite(result)) {
|
|
76
|
+
return { resolved: result };
|
|
77
|
+
}
|
|
78
|
+
} catch (err) {
|
|
79
|
+
return {
|
|
80
|
+
resolved: null,
|
|
81
|
+
cssExpression: folded,
|
|
82
|
+
reason: `Arithmetic evaluation failed: ${err.message}`
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const subResult = substituteFluidUnits(folded, context.maxViewport ?? 1440);
|
|
87
|
+
if (subResult && /^[\d.\s+\-*/()]+$/.test(subResult.rebased)) {
|
|
88
|
+
try {
|
|
89
|
+
const result = safeArithmetic(subResult.rebased.trim());
|
|
90
|
+
if (typeof result === "number" && Number.isFinite(result)) {
|
|
91
|
+
return {
|
|
92
|
+
resolved: result,
|
|
93
|
+
...subResult.substituted ? { normalisationSource: "fluid-max" } : {}
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
} catch {
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return { resolved: folded };
|
|
100
|
+
}
|
|
101
|
+
function substituteFluidUnits(expression, maxViewport) {
|
|
102
|
+
let substituted = false;
|
|
103
|
+
let rebased = expression.replace(/\bcalc\b/g, "");
|
|
104
|
+
if (rebased.includes("100vw")) {
|
|
105
|
+
rebased = rebased.replace(/100vw/g, String(maxViewport));
|
|
106
|
+
substituted = true;
|
|
107
|
+
}
|
|
108
|
+
if (/(-?\d*\.?\d+)\s*rem/.test(rebased)) {
|
|
109
|
+
rebased = rebased.replace(/(-?\d*\.?\d+)\s*rem/g, "($1 * 16)");
|
|
110
|
+
substituted = true;
|
|
111
|
+
}
|
|
112
|
+
if (/(-?\d*\.?\d+)\s*px/.test(rebased)) {
|
|
113
|
+
rebased = rebased.replace(/(-?\d*\.?\d+)\s*px/g, "$1");
|
|
114
|
+
substituted = true;
|
|
115
|
+
}
|
|
116
|
+
if (/[a-zA-Z%]/.test(rebased)) return null;
|
|
117
|
+
return { rebased, substituted };
|
|
118
|
+
}
|
|
119
|
+
function safeArithmetic(expression) {
|
|
120
|
+
if (!/^[\d.\s+\-*/()]+$/.test(expression)) {
|
|
121
|
+
throw new Error(`Disallowed characters in expression: "${expression}"`);
|
|
122
|
+
}
|
|
123
|
+
const fn = new Function(`return (${expression});`);
|
|
124
|
+
return fn();
|
|
125
|
+
}
|
|
126
|
+
function evaluateValue(value, context) {
|
|
127
|
+
if (value === null || value === void 0) {
|
|
128
|
+
return { resolved: null, reason: "Value is null/undefined" };
|
|
129
|
+
}
|
|
130
|
+
if (isPrimitive(value)) {
|
|
131
|
+
return { resolved: value };
|
|
132
|
+
}
|
|
133
|
+
if (isReference(value)) {
|
|
134
|
+
const targetResolution = resolveReferenceTarget(value, context);
|
|
135
|
+
const isTopLevel = !context.visited || context.visited.size === 0;
|
|
136
|
+
if (isTopLevel && targetResolution.resolved !== null) {
|
|
137
|
+
return { ...targetResolution, aliasTarget: value.name };
|
|
138
|
+
}
|
|
139
|
+
return targetResolution;
|
|
140
|
+
}
|
|
141
|
+
if (isCss(value)) {
|
|
142
|
+
return evaluateCss(value, context);
|
|
143
|
+
}
|
|
144
|
+
if (Array.isArray(value)) {
|
|
145
|
+
return {
|
|
146
|
+
resolved: null,
|
|
147
|
+
reason: "Heterogeneous array — DTCG composite encoding not yet implemented"
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
resolved: null,
|
|
152
|
+
reason: `Unsupported value shape: ${typeof value}`
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
const TOKENS_SCHEMA_URL = "https://design-tokens.org/schemas/2025.10/tokens.json";
|
|
156
|
+
const RESOLVER_SCHEMA_URL = "https://www.designtokens.org/schemas/2025.10/resolver.json";
|
|
157
|
+
function buildVariableMap(root) {
|
|
158
|
+
const map = /* @__PURE__ */ new Map();
|
|
159
|
+
for (const v of root.variables) map.set(v.name, v);
|
|
160
|
+
for (const theme of root.themes) {
|
|
161
|
+
for (const v of theme.variables) {
|
|
162
|
+
if (!map.has(v.name)) map.set(v.name, v);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return map;
|
|
166
|
+
}
|
|
167
|
+
function processVariable(variable, context) {
|
|
168
|
+
const evaluation = evaluateValue(variable.value, context);
|
|
169
|
+
const classification = classifyValueForVariable(variable.name, evaluation);
|
|
170
|
+
return { name: variable.name, evaluation, classification };
|
|
171
|
+
}
|
|
172
|
+
function classifyValueForVariable(name, evaluation) {
|
|
173
|
+
if (evaluation.resolved === null) return void 0;
|
|
174
|
+
return dtcg.classifyValue(evaluation.resolved, { path: name });
|
|
175
|
+
}
|
|
176
|
+
function setNestedToken(doc, dotPath, token) {
|
|
177
|
+
const segments = dotPath.split(".");
|
|
178
|
+
let cursor = doc;
|
|
179
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
180
|
+
const segment = segments[i];
|
|
181
|
+
const next = cursor[segment];
|
|
182
|
+
if (typeof next !== "object" || next === null || Array.isArray(next)) {
|
|
183
|
+
cursor[segment] = {};
|
|
184
|
+
} else if ("$value" in next) {
|
|
185
|
+
cursor[segment] = { $root: next };
|
|
186
|
+
}
|
|
187
|
+
cursor = cursor[segment];
|
|
188
|
+
}
|
|
189
|
+
const leaf = segments[segments.length - 1];
|
|
190
|
+
const existing = cursor[leaf];
|
|
191
|
+
if (typeof existing === "object" && existing !== null && !Array.isArray(existing) && !("$value" in existing)) {
|
|
192
|
+
existing.$root = token;
|
|
193
|
+
} else {
|
|
194
|
+
cursor[leaf] = token;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
function setNestedOverride(context, dotPath, value, type) {
|
|
198
|
+
const segments = dotPath.split(".");
|
|
199
|
+
let cursor = context;
|
|
200
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
201
|
+
const segment = segments[i];
|
|
202
|
+
const next = cursor[segment];
|
|
203
|
+
if (typeof next !== "object" || next === null || Array.isArray(next) || "$value" in next) {
|
|
204
|
+
cursor[segment] = {};
|
|
205
|
+
}
|
|
206
|
+
cursor = cursor[segment];
|
|
207
|
+
}
|
|
208
|
+
const token = { $value: value };
|
|
209
|
+
if (type !== void 0) token.$type = type;
|
|
210
|
+
cursor[segments[segments.length - 1]] = token;
|
|
211
|
+
}
|
|
212
|
+
function makeAliasToken(target, type, description) {
|
|
213
|
+
const token = { $value: dtcg.formatAlias(target) };
|
|
214
|
+
if (type !== void 0) token.$type = type;
|
|
215
|
+
return token;
|
|
216
|
+
}
|
|
217
|
+
function makeValueToken(classification, description, fluidBound) {
|
|
218
|
+
const token = {
|
|
219
|
+
$value: classification.value,
|
|
220
|
+
$type: classification.type
|
|
221
|
+
};
|
|
222
|
+
if (fluidBound) {
|
|
223
|
+
token.$extensions = {
|
|
224
|
+
"dev.styleframe": { fluidBound }
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
return token;
|
|
228
|
+
}
|
|
229
|
+
function makeExpressionToken(expression, description) {
|
|
230
|
+
const token = {
|
|
231
|
+
$value: expression,
|
|
232
|
+
$extensions: {
|
|
233
|
+
"dev.styleframe": { expression }
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
return token;
|
|
237
|
+
}
|
|
238
|
+
function valuesEqual(a, b) {
|
|
239
|
+
if (a === b) return true;
|
|
240
|
+
if (typeof a !== typeof b) return false;
|
|
241
|
+
if (typeof a === "object") return JSON.stringify(a) === JSON.stringify(b);
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
function capitalizeContextName(name) {
|
|
245
|
+
if (name.length === 0) return name;
|
|
246
|
+
return name.charAt(0).toUpperCase() + name.slice(1);
|
|
247
|
+
}
|
|
248
|
+
function buildDTCG(root, options = {}) {
|
|
249
|
+
const includeSchema = options.includeSchema ?? true;
|
|
250
|
+
const schemaUrl = options.schemaUrl ?? TOKENS_SCHEMA_URL;
|
|
251
|
+
const modifierName = options.modifierName ?? "theme";
|
|
252
|
+
const collectionName = options.collectionName ?? "Design Tokens";
|
|
253
|
+
const defaultModeName = options.defaultModeName ?? "Default";
|
|
254
|
+
const tokensSourceRef = options.tokensSourceRef ?? "tokens.json";
|
|
255
|
+
const variableMap = buildVariableMap(root);
|
|
256
|
+
const fluidMaxVariable = variableMap.get("fluid.max-width");
|
|
257
|
+
const fluidMaxResolved = fluidMaxVariable ? evaluateValue(fluidMaxVariable.value, { variables: variableMap }).resolved : null;
|
|
258
|
+
const maxViewport = typeof fluidMaxResolved === "number" ? fluidMaxResolved : 1440;
|
|
259
|
+
const context = { variables: variableMap, maxViewport };
|
|
260
|
+
const processed = /* @__PURE__ */ new Map();
|
|
261
|
+
const typeMap = /* @__PURE__ */ new Map();
|
|
262
|
+
for (const variable of root.variables) {
|
|
263
|
+
const p = processVariable(variable, context);
|
|
264
|
+
processed.set(variable.name, p);
|
|
265
|
+
if (p.classification) typeMap.set(variable.name, p.classification.type);
|
|
266
|
+
}
|
|
267
|
+
const resolveTypeFromAliasChain = (name, seen) => {
|
|
268
|
+
if (seen.has(name)) return void 0;
|
|
269
|
+
const direct = typeMap.get(name);
|
|
270
|
+
if (direct) return direct;
|
|
271
|
+
const p = processed.get(name);
|
|
272
|
+
if (p?.evaluation.aliasTarget) {
|
|
273
|
+
return resolveTypeFromAliasChain(
|
|
274
|
+
p.evaluation.aliasTarget,
|
|
275
|
+
/* @__PURE__ */ new Set([...seen, name])
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
return void 0;
|
|
279
|
+
};
|
|
280
|
+
const tokens = {};
|
|
281
|
+
if (includeSchema) tokens.$schema = schemaUrl;
|
|
282
|
+
tokens.$extensions = {
|
|
283
|
+
"dev.styleframe": { collection: collectionName }
|
|
284
|
+
};
|
|
285
|
+
const diagnostics = [];
|
|
286
|
+
let emittedCount = 0;
|
|
287
|
+
let fluidNormalisedCount = 0;
|
|
288
|
+
for (const variable of root.variables) {
|
|
289
|
+
const p = processed.get(variable.name);
|
|
290
|
+
if (!p) continue;
|
|
291
|
+
const { evaluation, classification } = p;
|
|
292
|
+
if (evaluation.aliasTarget) {
|
|
293
|
+
const targetType = resolveTypeFromAliasChain(
|
|
294
|
+
evaluation.aliasTarget,
|
|
295
|
+
/* @__PURE__ */ new Set()
|
|
296
|
+
);
|
|
297
|
+
setNestedToken(
|
|
298
|
+
tokens,
|
|
299
|
+
variable.name,
|
|
300
|
+
makeAliasToken(evaluation.aliasTarget, targetType)
|
|
301
|
+
);
|
|
302
|
+
emittedCount++;
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
if (classification) {
|
|
306
|
+
const fluidBound = evaluation.normalisationSource === "fluid-max" ? "max" : void 0;
|
|
307
|
+
const promoted = fluidBound && classification.type === "number" && typeof classification.value === "number" ? {
|
|
308
|
+
type: "dimension",
|
|
309
|
+
value: { value: classification.value, unit: "px" }
|
|
310
|
+
} : classification;
|
|
311
|
+
setNestedToken(
|
|
312
|
+
tokens,
|
|
313
|
+
variable.name,
|
|
314
|
+
makeValueToken(promoted, void 0, fluidBound)
|
|
315
|
+
);
|
|
316
|
+
if (fluidBound) fluidNormalisedCount++;
|
|
317
|
+
emittedCount++;
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
if (evaluation.cssExpression) {
|
|
321
|
+
setNestedToken(
|
|
322
|
+
tokens,
|
|
323
|
+
variable.name,
|
|
324
|
+
makeExpressionToken(evaluation.cssExpression)
|
|
325
|
+
);
|
|
326
|
+
diagnostics.push({
|
|
327
|
+
name: variable.name,
|
|
328
|
+
level: "warn",
|
|
329
|
+
reason: evaluation.reason ?? "Computed expression — preserved as dev.styleframe.expression extension"
|
|
330
|
+
});
|
|
331
|
+
emittedCount++;
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
if (evaluation.resolved !== null && typeof evaluation.resolved !== "boolean") {
|
|
335
|
+
setNestedToken(tokens, variable.name, {
|
|
336
|
+
$value: String(evaluation.resolved),
|
|
337
|
+
$extensions: { "dev.styleframe": { unknownType: true } }
|
|
338
|
+
});
|
|
339
|
+
diagnostics.push({
|
|
340
|
+
name: variable.name,
|
|
341
|
+
level: "warn",
|
|
342
|
+
reason: "Could not infer DTCG $type — emitted as untyped string"
|
|
343
|
+
});
|
|
344
|
+
emittedCount++;
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
diagnostics.push({
|
|
348
|
+
name: variable.name,
|
|
349
|
+
level: "warn",
|
|
350
|
+
reason: evaluation.reason ?? "Unrepresentable value — skipped"
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
const themedThemes = root.themes.filter(
|
|
354
|
+
(t) => t.variables.length > 0
|
|
355
|
+
);
|
|
356
|
+
let resolver;
|
|
357
|
+
if (themedThemes.length > 0) {
|
|
358
|
+
const contexts = {};
|
|
359
|
+
for (const theme of themedThemes) {
|
|
360
|
+
const contextDoc = {};
|
|
361
|
+
for (const variable of theme.variables) {
|
|
362
|
+
const themeProcessed = processVariable(variable, context);
|
|
363
|
+
const defaultProcessed = processed.get(variable.name);
|
|
364
|
+
let themeValue;
|
|
365
|
+
let themeType;
|
|
366
|
+
if (themeProcessed.evaluation.aliasTarget) {
|
|
367
|
+
themeValue = dtcg.formatAlias(themeProcessed.evaluation.aliasTarget);
|
|
368
|
+
themeType = resolveTypeFromAliasChain(
|
|
369
|
+
themeProcessed.evaluation.aliasTarget,
|
|
370
|
+
/* @__PURE__ */ new Set()
|
|
371
|
+
);
|
|
372
|
+
} else if (themeProcessed.classification) {
|
|
373
|
+
themeValue = themeProcessed.classification.value;
|
|
374
|
+
themeType = themeProcessed.classification.type;
|
|
375
|
+
}
|
|
376
|
+
if (themeValue === void 0) continue;
|
|
377
|
+
let defaultValue;
|
|
378
|
+
if (defaultProcessed?.evaluation.aliasTarget) {
|
|
379
|
+
defaultValue = dtcg.formatAlias(defaultProcessed.evaluation.aliasTarget);
|
|
380
|
+
} else if (defaultProcessed?.classification) {
|
|
381
|
+
defaultValue = defaultProcessed.classification.value;
|
|
382
|
+
}
|
|
383
|
+
if (defaultValue !== void 0 && valuesEqual(themeValue, defaultValue)) {
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
setNestedOverride(
|
|
387
|
+
contextDoc,
|
|
388
|
+
variable.name,
|
|
389
|
+
themeValue,
|
|
390
|
+
themeType
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
contexts[capitalizeContextName(theme.name)] = contextDoc;
|
|
394
|
+
}
|
|
395
|
+
for (const key of Object.keys(contexts)) {
|
|
396
|
+
if (Object.keys(contexts[key]).length === 0)
|
|
397
|
+
delete contexts[key];
|
|
398
|
+
}
|
|
399
|
+
const themeContextNames = Object.keys(contexts);
|
|
400
|
+
if (themeContextNames.length > 0) {
|
|
401
|
+
const allContexts = {
|
|
402
|
+
[defaultModeName]: []
|
|
403
|
+
};
|
|
404
|
+
for (const name of themeContextNames) {
|
|
405
|
+
allContexts[name] = [contexts[name]];
|
|
406
|
+
}
|
|
407
|
+
resolver = {
|
|
408
|
+
$schema: RESOLVER_SCHEMA_URL,
|
|
409
|
+
version: "2025.10",
|
|
410
|
+
modifiers: {
|
|
411
|
+
[modifierName]: {
|
|
412
|
+
contexts: allContexts,
|
|
413
|
+
default: defaultModeName
|
|
414
|
+
}
|
|
415
|
+
},
|
|
416
|
+
resolutionOrder: [
|
|
417
|
+
{ type: "set", sources: [{ $ref: tokensSourceRef }] },
|
|
418
|
+
{ $ref: `#/modifiers/${modifierName}` }
|
|
419
|
+
]
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
return {
|
|
424
|
+
tokens,
|
|
425
|
+
resolver,
|
|
426
|
+
diagnostics,
|
|
427
|
+
emittedCount,
|
|
428
|
+
fluidNormalisedCount,
|
|
429
|
+
maxViewport
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
const _export = citty.defineCommand({
|
|
433
|
+
meta: {
|
|
434
|
+
name: "export",
|
|
435
|
+
description: "Export Styleframe variables to spec-conformant DTCG JSON"
|
|
436
|
+
},
|
|
437
|
+
args: {
|
|
438
|
+
config: {
|
|
439
|
+
type: "string",
|
|
440
|
+
description: "Path to the Styleframe config file",
|
|
441
|
+
default: "styleframe.config.ts",
|
|
442
|
+
alias: ["c"],
|
|
443
|
+
valueHint: "path"
|
|
444
|
+
},
|
|
445
|
+
output: {
|
|
446
|
+
type: "string",
|
|
447
|
+
description: "Output JSON file path",
|
|
448
|
+
default: "tokens.json",
|
|
449
|
+
alias: ["o"],
|
|
450
|
+
valueHint: "path"
|
|
451
|
+
},
|
|
452
|
+
collection: {
|
|
453
|
+
type: "string",
|
|
454
|
+
description: "Collection name embedded in the export",
|
|
455
|
+
default: "Design Tokens",
|
|
456
|
+
alias: ["n", "name"]
|
|
457
|
+
}
|
|
458
|
+
},
|
|
459
|
+
async run({ args }) {
|
|
460
|
+
const configPath = path.resolve(args.config);
|
|
461
|
+
const outputPath = path.resolve(args.output);
|
|
462
|
+
consola.info(
|
|
463
|
+
`Loading configuration from "${path.relative(process.cwd(), configPath)}"...`
|
|
464
|
+
);
|
|
465
|
+
const instance = await loader.loadConfiguration({ entry: configPath });
|
|
466
|
+
const root = instance.root;
|
|
467
|
+
consola.info("Building DTCG document...");
|
|
468
|
+
const {
|
|
469
|
+
tokens,
|
|
470
|
+
resolver,
|
|
471
|
+
diagnostics,
|
|
472
|
+
emittedCount,
|
|
473
|
+
fluidNormalisedCount,
|
|
474
|
+
maxViewport
|
|
475
|
+
} = buildDTCG(root, {
|
|
476
|
+
collectionName: args.collection,
|
|
477
|
+
tokensSourceRef: path.basename(outputPath)
|
|
478
|
+
});
|
|
479
|
+
await promises.writeFile(outputPath, `${JSON.stringify(tokens, null, 2)}
|
|
480
|
+
`);
|
|
481
|
+
consola.info(
|
|
482
|
+
`Wrote ${emittedCount} tokens to "${path.relative(process.cwd(), outputPath)}"`
|
|
483
|
+
);
|
|
484
|
+
if (fluidNormalisedCount > 0) {
|
|
485
|
+
consola.info(
|
|
486
|
+
`Normalised ${fluidNormalisedCount} fluid token(s) using max viewport (${maxViewport}px).`
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
if (resolver) {
|
|
490
|
+
const resolverPath = outputPath.replace(/\.json$/, ".resolver.json");
|
|
491
|
+
await promises.writeFile(resolverPath, `${JSON.stringify(resolver, null, 2)}
|
|
492
|
+
`);
|
|
493
|
+
consola.info(
|
|
494
|
+
`Wrote resolver to "${path.relative(process.cwd(), resolverPath)}"`
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
const warnings = diagnostics.filter((d) => d.level === "warn");
|
|
498
|
+
if (warnings.length > 0) {
|
|
499
|
+
consola.warn(
|
|
500
|
+
`${warnings.length} variable(s) needed special handling — see Styleframe ↔ DTCG round-trip notes`
|
|
501
|
+
);
|
|
502
|
+
for (const w of warnings.slice(0, 10)) {
|
|
503
|
+
consola.info(` - ${w.name}: ${w.reason}`);
|
|
504
|
+
}
|
|
505
|
+
if (warnings.length > 10) {
|
|
506
|
+
consola.info(` ... and ${warnings.length - 10} more`);
|
|
507
|
+
}
|
|
508
|
+
consola.info(
|
|
509
|
+
'Tip: untyped tokens are usually CSS keywords (e.g. "normal", "thin", "italic") with no DTCG equivalent — they round-trip to Figma as STRING variables.'
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
consola.success(
|
|
513
|
+
`Exported ${emittedCount} tokens in DTCG format to "${path.relative(process.cwd(), outputPath)}"`
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
});
|
|
517
|
+
exports.default = _export;
|