tokens-to-css 1.0.0 → 1.1.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/README.md +28 -5
- package/dist/index.d.ts +89 -56
- package/dist/index.js +639 -80
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -57,6 +57,25 @@ stylesheet — the relationship survived instead of being flattened to `#191627`
|
|
|
57
57
|
Change the primitive and everything pointing at it moves, which is the reason to
|
|
58
58
|
keep tokens in a hierarchy at all.
|
|
59
59
|
|
|
60
|
+
Composite tokens work too. Shadow, border, transition and gradient become one
|
|
61
|
+
custom property each, and typography becomes one per property it describes:
|
|
62
|
+
|
|
63
|
+
```css
|
|
64
|
+
--elevation-low: 0px 2px 6px 0px var(--color-shadow);
|
|
65
|
+
--motion-emphasized: 200ms cubic-bezier(0.2, 0, 0, 1) 0ms;
|
|
66
|
+
--type-body-font-size: 0.875rem;
|
|
67
|
+
--type-body-letter-spacing: 0.16px;
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
An aliased sub-value stays an alias there too, so a shadow still moves when its
|
|
71
|
+
colour does. And where the token genuinely does not say something — a transition
|
|
72
|
+
never says *which* property it animates — the missing part is yours to supply
|
|
73
|
+
rather than ours to invent:
|
|
74
|
+
|
|
75
|
+
```css
|
|
76
|
+
.panel { transition: opacity var(--motion-emphasized); }
|
|
77
|
+
```
|
|
78
|
+
|
|
60
79
|
A URL works wherever a path does:
|
|
61
80
|
|
|
62
81
|
```js
|
|
@@ -136,7 +155,7 @@ addresses refused — including when the URL names one literally.
|
|
|
136
155
|
| [Getting started](docs/getting-started.md) | Five steps from install to a stylesheet, including breaking it on purpose |
|
|
137
156
|
| [What it accepts](docs/formats.md) | The three shapes, the order they are checked, and everything refused |
|
|
138
157
|
| [The naming rule](docs/naming.md) | How `color.brand` becomes `--color-brand`, and why that is a promise |
|
|
139
|
-
| [Failure codes](docs/failures.md) | The
|
|
158
|
+
| [Failure codes](docs/failures.md) | The nine codes and what each one means |
|
|
140
159
|
|
|
141
160
|
## What it will not do
|
|
142
161
|
|
|
@@ -149,10 +168,14 @@ deferred, not forgotten.
|
|
|
149
168
|
- **Evaluate expressions.** `{spacing.md} * 2` is refused rather than computed.
|
|
150
169
|
There is no evaluator in this package. `calc()` and `clamp()` are valid CSS
|
|
151
170
|
and pass through untouched.
|
|
152
|
-
- **
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
171
|
+
- **Guess at a token it cannot write.** A composite missing a sub-property the
|
|
172
|
+
spec marks required, a border whose style is SVG geometry, a font weight that
|
|
173
|
+
is not a weight — each is **skipped**: left out of the stylesheet, listed in
|
|
174
|
+
`skipped` on the result, and named in a comment above `:root`, while the rest
|
|
175
|
+
of the file converts.
|
|
176
|
+
- **Emit the `font` shorthand.** Used alone it drops `letter-spacing` in
|
|
177
|
+
silence, so a typography token becomes five properties rather than five plus a
|
|
178
|
+
trap.
|
|
156
179
|
- **Pick a winner on a collision.** Two token paths that produce the same
|
|
157
180
|
custom-property name fail the conversion rather than one quietly overwriting
|
|
158
181
|
the other.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,52 +1,3 @@
|
|
|
1
|
-
//#region src/options.d.ts
|
|
2
|
-
/**
|
|
3
|
-
* What a caller passes in and gets back.
|
|
4
|
-
*
|
|
5
|
-
* These live apart from the public surface so the orchestrator can read them
|
|
6
|
-
* without importing the entry point that calls it — a cycle between the two
|
|
7
|
-
* would work in ESM and still be the wrong shape (AD-1).
|
|
8
|
-
*/
|
|
9
|
-
/** How a remote Token Source is fetched. Ignored when the source is a local path. */
|
|
10
|
-
interface HttpOptions {
|
|
11
|
-
/** Allow `http:` URLs. Off by default — only `https:` is fetched. */
|
|
12
|
-
readonly allowInsecure?: boolean;
|
|
13
|
-
/** Total budget for the request, in milliseconds. */
|
|
14
|
-
readonly timeoutMs?: number;
|
|
15
|
-
/** Largest response body accepted, in bytes. */
|
|
16
|
-
readonly maxBytes?: number;
|
|
17
|
-
/** How many redirects to follow before giving up. */
|
|
18
|
-
readonly maxRedirects?: number;
|
|
19
|
-
}
|
|
20
|
-
/** Everything a caller can adjust about a conversion. */
|
|
21
|
-
interface GenerateCssOptions {
|
|
22
|
-
/** Directory the stylesheet is written to. Defaults to `assets/css`. */
|
|
23
|
-
readonly outDir?: string;
|
|
24
|
-
/** Filename inside that directory. Defaults to `tokens.css`. */
|
|
25
|
-
readonly fileName?: string;
|
|
26
|
-
/** Base for resolving relative paths. Defaults to the current working directory. */
|
|
27
|
-
readonly baseDir?: string;
|
|
28
|
-
/** Network policy for a URL Token Source. */
|
|
29
|
-
readonly http?: HttpOptions;
|
|
30
|
-
}
|
|
31
|
-
/** What a successful conversion reports back. Deliberately carries no CSS. */
|
|
32
|
-
interface GenerateCssResult {
|
|
33
|
-
/** Absolute path of the stylesheet that was written. */
|
|
34
|
-
readonly outputPath: string;
|
|
35
|
-
/** How many custom properties it declares. */
|
|
36
|
-
readonly tokenCount: number;
|
|
37
|
-
}
|
|
38
|
-
/** The defaults a conversion uses when the caller says nothing. */
|
|
39
|
-
declare const DEFAULTS: Readonly<{
|
|
40
|
-
outDir: "assets/css";
|
|
41
|
-
fileName: "tokens.css";
|
|
42
|
-
http: Readonly<{
|
|
43
|
-
allowInsecure: false;
|
|
44
|
-
timeoutMs: 10000;
|
|
45
|
-
maxBytes: 10000000;
|
|
46
|
-
maxRedirects: 3;
|
|
47
|
-
}>;
|
|
48
|
-
}>;
|
|
49
|
-
//#endregion
|
|
50
1
|
//#region src/errors.d.ts
|
|
51
2
|
/**
|
|
52
3
|
* The failure contract — public surface, frozen by semver (AD-4).
|
|
@@ -72,15 +23,33 @@ declare const FailureCode: Readonly<{
|
|
|
72
23
|
readonly ALIAS_CYCLE: 'ALIAS_CYCLE';
|
|
73
24
|
/** An alias points at a token that does not exist. */
|
|
74
25
|
readonly ALIAS_DANGLING: 'ALIAS_DANGLING';
|
|
75
|
-
/** A value is an object, array, boolean, or null rather than a scalar. */
|
|
26
|
+
/** A value is an object, array, boolean, or null rather than a scalar — reported as a skipped token, not a failure. */
|
|
76
27
|
readonly COMPOSITE_VALUE: 'COMPOSITE_VALUE';
|
|
77
28
|
/** Two or more tokens would emit the same custom-property name. */
|
|
78
29
|
readonly NAME_COLLISION: 'NAME_COLLISION';
|
|
79
30
|
/** The stylesheet could not be written. */
|
|
80
31
|
readonly OUTPUT_WRITE_FAILED: 'OUTPUT_WRITE_FAILED';
|
|
32
|
+
/** Every token was skipped, so the stylesheet would declare nothing (FR-24). */
|
|
33
|
+
readonly NOTHING_EMITTED: 'NOTHING_EMITTED';
|
|
81
34
|
}>;
|
|
82
|
-
/** One of the
|
|
35
|
+
/** One of the nine failure codes. */
|
|
83
36
|
type FailureCode = (typeof FailureCode)[keyof typeof FailureCode];
|
|
37
|
+
/**
|
|
38
|
+
* One token that was left out of the stylesheet (FR-24).
|
|
39
|
+
*
|
|
40
|
+
* A skip is not a failure: the conversion succeeded and the document is
|
|
41
|
+
* otherwise intact. It is reported here for callers, and in a comment block
|
|
42
|
+
* above `:root` for the humans who will read the generated file — which is the
|
|
43
|
+
* copy that shows up in a pull request when a token stops being emitted.
|
|
44
|
+
*/
|
|
45
|
+
interface SkippedToken {
|
|
46
|
+
/** Dotted path of the token, spelled as the document wrote it. */
|
|
47
|
+
readonly path: string;
|
|
48
|
+
/** Why, in the same vocabulary a failure would have used. */
|
|
49
|
+
readonly code: FailureCode;
|
|
50
|
+
/** The sentence a caller would have seen had this been fatal. Wording is not contract. */
|
|
51
|
+
readonly reason: string;
|
|
52
|
+
}
|
|
84
53
|
/** What a `TokenCssError` is constructed from. */
|
|
85
54
|
interface TokenCssErrorInit {
|
|
86
55
|
/** The failure class. */
|
|
@@ -109,20 +78,84 @@ declare class TokenCssError extends Error {
|
|
|
109
78
|
constructor(message: string, init: TokenCssErrorInit);
|
|
110
79
|
}
|
|
111
80
|
//#endregion
|
|
81
|
+
//#region src/options.d.ts
|
|
82
|
+
/**
|
|
83
|
+
* What a caller passes in and gets back.
|
|
84
|
+
*
|
|
85
|
+
* These live apart from the public surface so the orchestrator can read them
|
|
86
|
+
* without importing the entry point that calls it — a cycle between the two
|
|
87
|
+
* would work in ESM and still be the wrong shape (AD-1).
|
|
88
|
+
*/
|
|
89
|
+
/** How a remote Token Source is fetched. Ignored when the source is a local path. */
|
|
90
|
+
interface HttpOptions {
|
|
91
|
+
/** Allow `http:` URLs. Off by default — only `https:` is fetched. */
|
|
92
|
+
readonly allowInsecure?: boolean;
|
|
93
|
+
/** Total budget for the request, in milliseconds. */
|
|
94
|
+
readonly timeoutMs?: number;
|
|
95
|
+
/** Largest response body accepted, in bytes. */
|
|
96
|
+
readonly maxBytes?: number;
|
|
97
|
+
/** How many redirects to follow before giving up. */
|
|
98
|
+
readonly maxRedirects?: number;
|
|
99
|
+
}
|
|
100
|
+
/** Everything a caller can adjust about a conversion. */
|
|
101
|
+
interface GenerateCssOptions {
|
|
102
|
+
/** Directory the stylesheet is written to. Defaults to `assets/css`. */
|
|
103
|
+
readonly outDir?: string;
|
|
104
|
+
/** Filename inside that directory. Defaults to `tokens.css`. */
|
|
105
|
+
readonly fileName?: string;
|
|
106
|
+
/** Base for resolving relative paths. Defaults to the current working directory. */
|
|
107
|
+
readonly baseDir?: string;
|
|
108
|
+
/** Network policy for a URL Token Source. */
|
|
109
|
+
readonly http?: HttpOptions;
|
|
110
|
+
}
|
|
111
|
+
/** What a successful conversion reports back. Deliberately carries no CSS. */
|
|
112
|
+
interface GenerateCssResult {
|
|
113
|
+
/** Absolute path of the stylesheet that was written. */
|
|
114
|
+
readonly outputPath: string;
|
|
115
|
+
/** How many custom properties it declares. */
|
|
116
|
+
readonly tokenCount: number;
|
|
117
|
+
/**
|
|
118
|
+
* Tokens the document contained that the stylesheet could not (FR-24).
|
|
119
|
+
*
|
|
120
|
+
* Empty on a conversion that lost nothing, which is the ordinary case. A
|
|
121
|
+
* caller that wants a conversion to be all-or-nothing checks this and decides
|
|
122
|
+
* for itself; the library's own answer is to convert what it can and say what
|
|
123
|
+
* it could not.
|
|
124
|
+
*/
|
|
125
|
+
readonly skipped: readonly SkippedToken[];
|
|
126
|
+
}
|
|
127
|
+
/** The defaults a conversion uses when the caller says nothing. */
|
|
128
|
+
declare const DEFAULTS: Readonly<{
|
|
129
|
+
outDir: "assets/css";
|
|
130
|
+
fileName: "tokens.css";
|
|
131
|
+
http: Readonly<{
|
|
132
|
+
allowInsecure: false;
|
|
133
|
+
timeoutMs: 10000;
|
|
134
|
+
maxBytes: 10000000;
|
|
135
|
+
maxRedirects: 3;
|
|
136
|
+
}>;
|
|
137
|
+
}>;
|
|
138
|
+
//#endregion
|
|
112
139
|
//#region src/index.d.ts
|
|
113
140
|
/**
|
|
114
141
|
* Convert a design-token document into a CSS custom-properties stylesheet.
|
|
115
142
|
*
|
|
116
143
|
* Reads the Token Source, validates it completely, and writes the stylesheet —
|
|
117
|
-
* or throws a `TokenCssError` and writes nothing at all.
|
|
118
|
-
*
|
|
119
|
-
*
|
|
144
|
+
* or throws a `TokenCssError` and writes nothing at all. A failed conversion
|
|
145
|
+
* writes nothing: a previous stylesheet at the target path is left untouched.
|
|
146
|
+
*
|
|
147
|
+
* A token whose value cannot be written as CSS does not fail the conversion
|
|
148
|
+
* (FR-24). It is left out, listed in `skipped` on the result, and named in a
|
|
149
|
+
* comment above `:root` in the stylesheet itself. Everything else — an
|
|
150
|
+
* unreadable source, a document shaped in a way this version does not accept,
|
|
151
|
+
* an alias cycle, a dangling reference, a name collision — still fails whole.
|
|
120
152
|
*
|
|
121
153
|
* @param source Path to a single local file, or a URL.
|
|
122
154
|
* @param options Output location and network policy.
|
|
123
|
-
* @returns Where the stylesheet was written,
|
|
155
|
+
* @returns Where the stylesheet was written, how many properties it holds, and
|
|
156
|
+
* which tokens it left out.
|
|
124
157
|
* @throws {TokenCssError} With a `code` naming the failure class.
|
|
125
158
|
*/
|
|
126
159
|
declare function generateCss(source: string | URL, options?: GenerateCssOptions): Promise<GenerateCssResult>;
|
|
127
160
|
//#endregion
|
|
128
|
-
export { DEFAULTS, FailureCode, type GenerateCssOptions, type GenerateCssResult, type HttpOptions, TokenCssError, type TokenCssErrorInit, generateCss };
|
|
161
|
+
export { DEFAULTS, FailureCode, type GenerateCssOptions, type GenerateCssResult, type HttpOptions, type SkippedToken, TokenCssError, type TokenCssErrorInit, generateCss };
|
package/dist/index.js
CHANGED
|
@@ -21,6 +21,13 @@ function ref(path) {
|
|
|
21
21
|
path
|
|
22
22
|
};
|
|
23
23
|
}
|
|
24
|
+
/** Builds a value assembled from literal text and references. */
|
|
25
|
+
function composite(parts) {
|
|
26
|
+
return {
|
|
27
|
+
kind: "composite",
|
|
28
|
+
parts
|
|
29
|
+
};
|
|
30
|
+
}
|
|
24
31
|
/** Builds a token node. */
|
|
25
32
|
function token(path, value) {
|
|
26
33
|
return {
|
|
@@ -32,6 +39,22 @@ function token(path, value) {
|
|
|
32
39
|
function isRef(value) {
|
|
33
40
|
return value.kind === "ref";
|
|
34
41
|
}
|
|
42
|
+
/** Narrows a value to a composite. */
|
|
43
|
+
function isComposite(value) {
|
|
44
|
+
return value.kind === "composite";
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Every reference a token makes, in order.
|
|
48
|
+
*
|
|
49
|
+
* A scalar makes at most one; a composite makes as many as it has aliased
|
|
50
|
+
* sub-values. Both validators read the graph through this, so neither has to
|
|
51
|
+
* know that composites exist.
|
|
52
|
+
*/
|
|
53
|
+
function referencesOf(value) {
|
|
54
|
+
if (isRef(value)) return [value];
|
|
55
|
+
if (isComposite(value)) return value.parts.filter((part) => typeof part !== "string");
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
35
58
|
/**
|
|
36
59
|
* Renders a path the way the token document wrote it, for humans.
|
|
37
60
|
*
|
|
@@ -67,12 +90,14 @@ const FailureCode = Object.freeze({
|
|
|
67
90
|
ALIAS_CYCLE: "ALIAS_CYCLE",
|
|
68
91
|
/** An alias points at a token that does not exist. */
|
|
69
92
|
ALIAS_DANGLING: "ALIAS_DANGLING",
|
|
70
|
-
/** A value is an object, array, boolean, or null rather than a scalar. */
|
|
93
|
+
/** A value is an object, array, boolean, or null rather than a scalar — reported as a skipped token, not a failure. */
|
|
71
94
|
COMPOSITE_VALUE: "COMPOSITE_VALUE",
|
|
72
95
|
/** Two or more tokens would emit the same custom-property name. */
|
|
73
96
|
NAME_COLLISION: "NAME_COLLISION",
|
|
74
97
|
/** The stylesheet could not be written. */
|
|
75
|
-
OUTPUT_WRITE_FAILED: "OUTPUT_WRITE_FAILED"
|
|
98
|
+
OUTPUT_WRITE_FAILED: "OUTPUT_WRITE_FAILED",
|
|
99
|
+
/** Every token was skipped, so the stylesheet would declare nothing (FR-24). */
|
|
100
|
+
NOTHING_EMITTED: "NOTHING_EMITTED"
|
|
76
101
|
});
|
|
77
102
|
/**
|
|
78
103
|
* The only error this library throws.
|
|
@@ -199,40 +224,58 @@ function customPropertyName(path, source) {
|
|
|
199
224
|
}
|
|
200
225
|
//#endregion
|
|
201
226
|
//#region src/emit/css.ts
|
|
227
|
+
/** Two spaces. Fixed: a configurable indent would make goldens negotiable. */
|
|
228
|
+
const INDENT = " ";
|
|
202
229
|
/**
|
|
203
|
-
*
|
|
230
|
+
* The block that announces skipped tokens (FR-24).
|
|
204
231
|
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
232
|
+
* This is the half of the skip report that reaches people rather than callers.
|
|
233
|
+
* A generated stylesheet lives in a repository, so a token that stopped being
|
|
234
|
+
* emitted shows up here in the next diff — where somebody is already looking —
|
|
235
|
+
* instead of only in a return value most callers never read.
|
|
207
236
|
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
* in this module reads the clock, the filesystem, the environment, or the
|
|
211
|
-
* order a runtime happens to iterate keys — declaration order is the order of
|
|
212
|
-
* the array it was handed, and that is document order.
|
|
237
|
+
* Nothing is emitted when nothing was skipped. That is not a nicety: it is what
|
|
238
|
+
* keeps every document that converted before this existed byte-identical.
|
|
213
239
|
*/
|
|
214
|
-
|
|
215
|
-
|
|
240
|
+
function skipComment(skipped) {
|
|
241
|
+
if (skipped.length === 0) return [];
|
|
242
|
+
return [
|
|
243
|
+
`/* ${skipped.length === 1 ? "1 token was skipped:" : `${skipped.length} tokens were skipped:`}`,
|
|
244
|
+
...skipped.map((s) => ` * ${s.reason}`),
|
|
245
|
+
" */"
|
|
246
|
+
];
|
|
247
|
+
}
|
|
216
248
|
/**
|
|
217
249
|
* Renders a normalized document as a stylesheet.
|
|
218
250
|
*
|
|
219
251
|
* A token whose value points at another token is written as `var(--target)`,
|
|
220
252
|
* never as the target's value. That is the whole point of the product: the
|
|
221
253
|
* relationship the token file expressed survives into the CSS, so changing a
|
|
222
|
-
* primitive still moves everything that referred to it.
|
|
254
|
+
* primitive still moves everything that referred to it. A composite is the same
|
|
255
|
+
* promise applied piecewise — an aliased sub-value stays `var(--target)` inside
|
|
256
|
+
* the larger value, so a shadow still moves when its colour does.
|
|
257
|
+
*
|
|
258
|
+
* A document carrying skipped tokens is preceded by a comment naming them, so
|
|
259
|
+
* the stylesheet says what it is missing rather than quietly being short.
|
|
223
260
|
*
|
|
224
261
|
* @param doc The normalized document, in document order.
|
|
262
|
+
* @param skipped Tokens the document held that the stylesheet cannot, announced
|
|
263
|
+
* in a comment above the rule. Empty for a document that lost nothing, and then
|
|
264
|
+
* no comment is written at all.
|
|
225
265
|
* @param source The Token Source, carried only so a naming failure can name it.
|
|
226
266
|
* @returns The complete stylesheet text, ending in exactly one newline.
|
|
227
267
|
*/
|
|
228
|
-
function emitStylesheet(doc, source) {
|
|
268
|
+
function emitStylesheet(doc, skipped, source) {
|
|
269
|
+
const reference = (path) => `var(${customPropertyName(path, source)})`;
|
|
270
|
+
const declarations = doc.tokens.map((node) => {
|
|
271
|
+
const property = customPropertyName(node.path, source);
|
|
272
|
+
const value = isRef(node.value) ? reference(node.value.path) : isComposite(node.value) ? node.value.parts.map((part) => typeof part === "string" ? part : reference(part.path)).join("") : stringifyLiteral(node.value.value);
|
|
273
|
+
return `${INDENT}${property}: ${value};`;
|
|
274
|
+
});
|
|
229
275
|
return [
|
|
276
|
+
...skipComment(skipped),
|
|
230
277
|
":root {",
|
|
231
|
-
...
|
|
232
|
-
const property = customPropertyName(node.path, source);
|
|
233
|
-
const value = isRef(node.value) ? `var(${customPropertyName(node.value.path, source)})` : stringifyLiteral(node.value.value);
|
|
234
|
-
return `${INDENT}${property}: ${value};`;
|
|
235
|
-
}),
|
|
278
|
+
...declarations,
|
|
236
279
|
"}",
|
|
237
280
|
""
|
|
238
281
|
].join("\n");
|
|
@@ -253,17 +296,20 @@ const DEFAULTS = Object.freeze({
|
|
|
253
296
|
//#endregion
|
|
254
297
|
//#region src/dialects/values.ts
|
|
255
298
|
/**
|
|
256
|
-
*
|
|
299
|
+
* Scalars the spec writes in an expanded notation (FR-23, FR-26).
|
|
257
300
|
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
301
|
+
* Four values in this format are one CSS value each, written as an object or an
|
|
302
|
+
* array rather than a string:
|
|
260
303
|
*
|
|
261
304
|
* { "colorSpace": "srgb", "components": [0, 0, 0], "alpha": 1, "hex": "#000" }
|
|
262
305
|
* { "value": 0.25, "unit": "rem" }
|
|
306
|
+
* ["Monaco", "Consolas", "monospace"]
|
|
307
|
+
* [0, 0, 1, 1]
|
|
263
308
|
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
* system published against the current spec
|
|
309
|
+
* All four are scalars written with more ceremony, not composites. FR-23
|
|
310
|
+
* accepted the two written as objects, because refusing them meant refusing
|
|
311
|
+
* every design system published against the current spec; the 2026-09-02 survey
|
|
312
|
+
* found the same mistake still standing for the two written as arrays.
|
|
267
313
|
*
|
|
268
314
|
* Two properties of this module matter more than the conversion itself:
|
|
269
315
|
*
|
|
@@ -373,22 +419,427 @@ function colorToCss(raw, path, source) {
|
|
|
373
419
|
return `color(${space} ${rendered.join(" ")}${suffix})`;
|
|
374
420
|
}
|
|
375
421
|
/**
|
|
376
|
-
*
|
|
377
|
-
*
|
|
378
|
-
* @returns The CSS value, or `null` when the object is not a scalar in object
|
|
379
|
-
* form — a typography or shadow block, which stays a composite and is refused
|
|
380
|
-
* by the caller.
|
|
381
|
-
* @throws {TokenCssError} `FORMAT_NOT_ALLOWED` when the object *is* one of these
|
|
382
|
-
* shapes but is malformed or unusable in CSS.
|
|
422
|
+
* Families CSS resolves itself. Quoting one turns the generic family into the
|
|
423
|
+
* name of a font that does not exist, so these are never quoted.
|
|
383
424
|
*/
|
|
384
|
-
|
|
385
|
-
|
|
425
|
+
const GENERIC_FAMILIES = /* @__PURE__ */ new Set([
|
|
426
|
+
"serif",
|
|
427
|
+
"sans-serif",
|
|
428
|
+
"monospace",
|
|
429
|
+
"cursive",
|
|
430
|
+
"fantasy",
|
|
431
|
+
"system-ui",
|
|
432
|
+
"ui-serif",
|
|
433
|
+
"ui-sans-serif",
|
|
434
|
+
"ui-monospace",
|
|
435
|
+
"ui-rounded",
|
|
436
|
+
"math",
|
|
437
|
+
"emoji",
|
|
438
|
+
"fangsong"
|
|
439
|
+
]);
|
|
440
|
+
/** Quoted, or they are read as the keyword rather than as a family name. */
|
|
441
|
+
const CSS_WIDE_KEYWORDS = /* @__PURE__ */ new Set([
|
|
442
|
+
"inherit",
|
|
443
|
+
"initial",
|
|
444
|
+
"unset",
|
|
445
|
+
"revert",
|
|
446
|
+
"revert-layer",
|
|
447
|
+
"default"
|
|
448
|
+
]);
|
|
449
|
+
/** A name that needs no quotes: one identifier, no spaces, not starting with a digit. */
|
|
450
|
+
const BARE_NAME = /^-?[a-zA-Z_][a-zA-Z0-9_-]*$/;
|
|
451
|
+
/** A comma or a quote means the author already wrote CSS here, not one font name. */
|
|
452
|
+
const ALREADY_CSS = /[,'"]/;
|
|
453
|
+
/**
|
|
454
|
+
* Renders one entry of a font family list.
|
|
455
|
+
*
|
|
456
|
+
* The 2026-09-02 survey found four notations in the wild, and two of them —
|
|
457
|
+
* Microsoft Fluent's and GitHub Primer's — put an entire pre-quoted stack where
|
|
458
|
+
* the spec says one name goes. A quoting rule applied to those would wrap the
|
|
459
|
+
* whole stack in quotes and produce a single bogus font name, in silence. So an
|
|
460
|
+
* entry that already contains a comma or a quote is passed through as written:
|
|
461
|
+
* it is CSS the author wrote, and it is correct as it stands.
|
|
462
|
+
*/
|
|
463
|
+
function fontFamilyEntry(name) {
|
|
464
|
+
if (ALREADY_CSS.test(name)) return name;
|
|
465
|
+
if (GENERIC_FAMILIES.has(name.toLowerCase())) return name;
|
|
466
|
+
if (CSS_WIDE_KEYWORDS.has(name.toLowerCase())) return `"${name}"`;
|
|
467
|
+
return BARE_NAME.test(name) ? name : `"${name}"`;
|
|
468
|
+
}
|
|
469
|
+
/** An array of names — every element a non-empty string with no reference in it. */
|
|
470
|
+
function isFontFamily(raw) {
|
|
471
|
+
return raw.length > 0 && raw.every((n) => typeof n === "string" && n.trim() !== "" && !/[{}]/.test(n));
|
|
472
|
+
}
|
|
473
|
+
/** Four finite numbers, and nothing else in DTCG is written that way. */
|
|
474
|
+
function isCubicBezier(raw) {
|
|
475
|
+
return raw.length === 4 && raw.every((n) => typeof n === "number" && Number.isFinite(n));
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Converts a scalar written in an expanded notation into stylesheet text.
|
|
479
|
+
*
|
|
480
|
+
* Recognition reads the **shape** and never the declared `$type`, which is what
|
|
481
|
+
* keeps "a `16` never becomes `16px`" true: `{colorSpace, components}` can only
|
|
482
|
+
* be a colour, `{value, unit}` can only be a dimension, an array of names can
|
|
483
|
+
* only be a font family, and four numbers can only be a curve.
|
|
484
|
+
*
|
|
485
|
+
* @returns The CSS value, or `null` when this is not a scalar in disguise — a
|
|
486
|
+
* typography block, a shadow list, an array holding a reference. The caller
|
|
487
|
+
* skips those (FR-24) rather than guessing at them.
|
|
488
|
+
* @throws {TokenCssError} `FORMAT_NOT_ALLOWED` when the value *is* one of these
|
|
489
|
+
* shapes but is malformed or unusable in CSS, which is a different thing from
|
|
490
|
+
* being unrecognized and gets its own message.
|
|
491
|
+
*/
|
|
492
|
+
function scalarToCss(raw, path, source) {
|
|
493
|
+
if (typeof raw !== "object" || raw === null) return null;
|
|
494
|
+
if (Array.isArray(raw)) {
|
|
495
|
+
if (isFontFamily(raw)) return raw.map(fontFamilyEntry).join(", ");
|
|
496
|
+
if (isCubicBezier(raw)) return `cubic-bezier(${raw.join(", ")})`;
|
|
497
|
+
return null;
|
|
498
|
+
}
|
|
386
499
|
const record = raw;
|
|
387
500
|
if (isColor(record)) return colorToCss(record, path, source);
|
|
388
501
|
if (isDimension(record)) return dimensionToCss(record, path, source);
|
|
389
502
|
return null;
|
|
390
503
|
}
|
|
391
504
|
//#endregion
|
|
505
|
+
//#region src/dialects/composites.ts
|
|
506
|
+
/**
|
|
507
|
+
* Composite tokens that fit one CSS value (FR-25).
|
|
508
|
+
*
|
|
509
|
+
* Four DTCG types describe several sub-values that CSS writes as a single
|
|
510
|
+
* property: shadow, border, transition and gradient. Each becomes one custom
|
|
511
|
+
* property here. The types that describe *several* CSS properties — typography,
|
|
512
|
+
* and the object form of strokeStyle — cannot, and are not this module's
|
|
513
|
+
* business.
|
|
514
|
+
*
|
|
515
|
+
* Two rules run through everything below.
|
|
516
|
+
*
|
|
517
|
+
* **Component order is public contract.** Once a shadow ships as
|
|
518
|
+
* `offsetX offsetY blur spread color`, every stylesheet in the world holds that
|
|
519
|
+
* order, and changing it is a major version. It is CSS's order, not ours, which
|
|
520
|
+
* is the only defensible way to pick one.
|
|
521
|
+
*
|
|
522
|
+
* **Nothing absent is invented.** Every sub-property the spec marks `MUST` has
|
|
523
|
+
* to be there or the token is skipped: a `spread` assumed to be `0` would be
|
|
524
|
+
* this product's first inference, and there is no principled second place to
|
|
525
|
+
* stop. Where the token genuinely does not say something — the axis of a
|
|
526
|
+
* gradient, the property a transition animates — the emitted value is the part
|
|
527
|
+
* it did say, and the consumer supplies the rest.
|
|
528
|
+
*/
|
|
529
|
+
/** A whole-string reference, matching the one the walk uses. */
|
|
530
|
+
const WHOLE_REFERENCE$1 = /^\{([^{}]+)\}$/;
|
|
531
|
+
/** Raised inside this module and caught at its edge; never escapes. */
|
|
532
|
+
var Unwritable = class extends Error {};
|
|
533
|
+
const unwritable = (why) => {
|
|
534
|
+
throw new Unwritable(why);
|
|
535
|
+
};
|
|
536
|
+
/**
|
|
537
|
+
* Renders one sub-value: a reference stays a reference, anything else becomes
|
|
538
|
+
* the CSS text for it.
|
|
539
|
+
*
|
|
540
|
+
* This is where "an aliased sub-value stays an alias" is implemented — the one
|
|
541
|
+
* thing that keeps expansion from duplicating the primitives it was built from.
|
|
542
|
+
*/
|
|
543
|
+
function sub(raw, what) {
|
|
544
|
+
if (typeof raw === "string") {
|
|
545
|
+
const whole = WHOLE_REFERENCE$1.exec(raw.trim());
|
|
546
|
+
if (whole) return ref(whole[1].split("."));
|
|
547
|
+
if (/[{}]/.test(raw)) unwritable(`its ${what} has a reference inside a larger value`);
|
|
548
|
+
return raw;
|
|
549
|
+
}
|
|
550
|
+
if (typeof raw === "number") return String(raw);
|
|
551
|
+
const scalar = scalarToCss(raw, [], "");
|
|
552
|
+
if (scalar === null) unwritable(`its ${what} is not a value CSS can hold`);
|
|
553
|
+
return scalar;
|
|
554
|
+
}
|
|
555
|
+
/** Appends one part, merging it into the previous one when both are text. */
|
|
556
|
+
function push(out, part) {
|
|
557
|
+
const last = out.at(-1);
|
|
558
|
+
if (typeof part === "string" && typeof last === "string") out[out.length - 1] = last + part;
|
|
559
|
+
else out.push(part);
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* Joins groups of parts with a separator **between the groups**.
|
|
563
|
+
*
|
|
564
|
+
* A group is one sub-value, and it may already be several parts — a shadow's
|
|
565
|
+
* colour is one group whether it is a literal or a reference. Flattening before
|
|
566
|
+
* separating would put the separator between the pieces of a sub-value instead
|
|
567
|
+
* of between sub-values, which is how a shadow list first came out as
|
|
568
|
+
* `0, 1px, 2px, 0, #000`.
|
|
569
|
+
*/
|
|
570
|
+
function join$1(groups, separator) {
|
|
571
|
+
const out = [];
|
|
572
|
+
groups.forEach((group, index) => {
|
|
573
|
+
if (index > 0 && separator !== "") push(out, separator);
|
|
574
|
+
for (const part of Array.isArray(group) ? group : [group]) push(out, part);
|
|
575
|
+
});
|
|
576
|
+
return out;
|
|
577
|
+
}
|
|
578
|
+
/** Requires every sub-property the spec marks MUST. */
|
|
579
|
+
function require_(value, keys, kind) {
|
|
580
|
+
const missing = keys.filter((k) => !(k in value));
|
|
581
|
+
if (missing.length > 0) unwritable(`its ${kind} is missing ${missing.map((m) => `"${m}"`).join(", ")}`);
|
|
582
|
+
}
|
|
583
|
+
/** `offsetX offsetY blur spread color`, with `inset` in front when true. */
|
|
584
|
+
function shadowToParts(raw) {
|
|
585
|
+
require_(raw, [
|
|
586
|
+
"color",
|
|
587
|
+
"offsetX",
|
|
588
|
+
"offsetY",
|
|
589
|
+
"blur",
|
|
590
|
+
"spread"
|
|
591
|
+
], "shadow");
|
|
592
|
+
const parts = join$1([...[
|
|
593
|
+
"offsetX",
|
|
594
|
+
"offsetY",
|
|
595
|
+
"blur",
|
|
596
|
+
"spread"
|
|
597
|
+
].map((k) => sub(raw[k], k)), sub(raw["color"], "color")], " ");
|
|
598
|
+
return raw["inset"] === true ? join$1(["inset", ...parts], " ") : parts;
|
|
599
|
+
}
|
|
600
|
+
/** `width style color`. */
|
|
601
|
+
function borderToParts(raw) {
|
|
602
|
+
require_(raw, [
|
|
603
|
+
"color",
|
|
604
|
+
"width",
|
|
605
|
+
"style"
|
|
606
|
+
], "border");
|
|
607
|
+
const style = raw["style"];
|
|
608
|
+
if (style !== null && typeof style === "object") unwritable("its style is written as an object, which a CSS border cannot express");
|
|
609
|
+
return join$1([
|
|
610
|
+
sub(raw["width"], "width"),
|
|
611
|
+
sub(style, "style"),
|
|
612
|
+
sub(raw["color"], "color")
|
|
613
|
+
], " ");
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* `duration timingFunction delay` — the tail of the `transition` shorthand.
|
|
617
|
+
*
|
|
618
|
+
* The token never says *which* property is animated, so what is emitted is
|
|
619
|
+
* everything else, used as `transition: opacity var(--…)`. In that shorthand
|
|
620
|
+
* the first time is the duration and the second is the delay, which is why the
|
|
621
|
+
* order is not the one the object is written in.
|
|
622
|
+
*/
|
|
623
|
+
function transitionToParts(raw) {
|
|
624
|
+
require_(raw, [
|
|
625
|
+
"duration",
|
|
626
|
+
"delay",
|
|
627
|
+
"timingFunction"
|
|
628
|
+
], "transition");
|
|
629
|
+
return join$1([
|
|
630
|
+
sub(raw["duration"], "duration"),
|
|
631
|
+
sub(raw["timingFunction"], "timing function"),
|
|
632
|
+
sub(raw["delay"], "delay")
|
|
633
|
+
], " ");
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* The stop list of a gradient, without an axis.
|
|
637
|
+
*
|
|
638
|
+
* A DTCG gradient is stops and nothing else — no direction, no angle. Emitting
|
|
639
|
+
* `linear-gradient(…)` would mean inventing an axis the token never stated, so
|
|
640
|
+
* the value is the stops, used as `background: linear-gradient(to right, var(--…))`.
|
|
641
|
+
*
|
|
642
|
+
* `position` is 0–1 in the token and a percentage in CSS, and the spec says an
|
|
643
|
+
* out-of-range number is clamped rather than refused.
|
|
644
|
+
*/
|
|
645
|
+
function gradientToParts(raw) {
|
|
646
|
+
if (raw.length === 0) unwritable("it is an empty gradient");
|
|
647
|
+
return join$1(raw.map((stop) => {
|
|
648
|
+
if (typeof stop === "string") {
|
|
649
|
+
const whole = WHOLE_REFERENCE$1.exec(stop.trim());
|
|
650
|
+
if (whole) return [ref(whole[1].split("."))];
|
|
651
|
+
return unwritable("one of its stops is neither a stop nor a reference");
|
|
652
|
+
}
|
|
653
|
+
if (stop === null || typeof stop !== "object" || Array.isArray(stop)) return unwritable("one of its stops is not a gradient stop");
|
|
654
|
+
const record = stop;
|
|
655
|
+
require_(record, ["color", "position"], "gradient stop");
|
|
656
|
+
const position = record["position"];
|
|
657
|
+
if (typeof position !== "number" || !Number.isFinite(position)) return unwritable("one of its stop positions is not a number");
|
|
658
|
+
const clamped = Math.min(1, Math.max(0, position));
|
|
659
|
+
return join$1([sub(record["color"], "colour"), `${clamped * 100}%`], " ");
|
|
660
|
+
}), ", ");
|
|
661
|
+
}
|
|
662
|
+
/** True for `[{...}, ...]` — a list of shadows rather than one. */
|
|
663
|
+
const isObjectList = (raw) => raw.length > 0 && raw.every((e) => e !== null && typeof e === "object" && !Array.isArray(e));
|
|
664
|
+
/** Dispatches on shape. Throws {@link Unwritable} for a recognized-but-broken value. */
|
|
665
|
+
function build(raw) {
|
|
666
|
+
if (Array.isArray(raw)) {
|
|
667
|
+
if (!isObjectList(raw)) return null;
|
|
668
|
+
const first = raw[0];
|
|
669
|
+
if ("position" in first) return gradientToParts(raw);
|
|
670
|
+
if ("offsetX" in first || "offsetY" in first) return join$1(raw.map((s) => shadowToParts(s)), ", ");
|
|
671
|
+
return null;
|
|
672
|
+
}
|
|
673
|
+
if (raw === null || typeof raw !== "object") return null;
|
|
674
|
+
const record = raw;
|
|
675
|
+
if ("offsetX" in record || "offsetY" in record || "blur" in record) return shadowToParts(record);
|
|
676
|
+
if ("width" in record && "style" in record) return borderToParts(record);
|
|
677
|
+
if ("duration" in record || "timingFunction" in record) return transitionToParts(record);
|
|
678
|
+
return null;
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* Converts a composite that fits one CSS value.
|
|
682
|
+
*
|
|
683
|
+
* Recognition is by shape, never by `$type` — the same rule the object and
|
|
684
|
+
* array scalars follow. A shadow has offsets and a blur; a border has a width
|
|
685
|
+
* and a style; a transition has a duration and a timing function; a gradient is
|
|
686
|
+
* a list of stops. Nothing else is any of those.
|
|
687
|
+
*/
|
|
688
|
+
function compositeToParts(raw) {
|
|
689
|
+
try {
|
|
690
|
+
const parts = build(raw);
|
|
691
|
+
return parts === null ? { kind: "unrecognized" } : {
|
|
692
|
+
kind: "parts",
|
|
693
|
+
parts
|
|
694
|
+
};
|
|
695
|
+
} catch (err) {
|
|
696
|
+
if (err instanceof Unwritable) return {
|
|
697
|
+
kind: "unwritable",
|
|
698
|
+
reason: err.message
|
|
699
|
+
};
|
|
700
|
+
throw err;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* Sub-property to name suffix — public contract, seven entries.
|
|
705
|
+
*
|
|
706
|
+
* The 2026-09-02 design claimed the suffix would fall out of the existing
|
|
707
|
+
* naming rule for free. It does not: that rule lowercases and splits on
|
|
708
|
+
* non-alphanumerics, so `fontSize` becomes `fontsize`, and teaching it to split
|
|
709
|
+
* camelCase would rename every token already emitted — a major version.
|
|
710
|
+
*
|
|
711
|
+
* So there is a table, and each entry is the **CSS property the sub-value
|
|
712
|
+
* feeds**, which is the one mapping that is not arbitrary: the token is spelled
|
|
713
|
+
* the way the declaration that uses it is spelled.
|
|
714
|
+
*
|
|
715
|
+
* font-size: var(--type-body-font-size);
|
|
716
|
+
*
|
|
717
|
+
* Adding an entry later is additive. Changing one is a major version.
|
|
718
|
+
*/
|
|
719
|
+
const CSS_PROPERTY = Object.freeze({
|
|
720
|
+
fontFamily: "font-family",
|
|
721
|
+
fontSize: "font-size",
|
|
722
|
+
fontWeight: "font-weight",
|
|
723
|
+
letterSpacing: "letter-spacing",
|
|
724
|
+
lineHeight: "line-height",
|
|
725
|
+
dashArray: "dash-array",
|
|
726
|
+
lineCap: "line-cap"
|
|
727
|
+
});
|
|
728
|
+
/**
|
|
729
|
+
* The spec's closed alias table for font weights.
|
|
730
|
+
*
|
|
731
|
+
* A word outside it skips the token. Passing it through would emit
|
|
732
|
+
* `font-weight: regular`, which is invalid CSS a browser ignores in silence —
|
|
733
|
+
* and inventing a number for an unknown word would be worse.
|
|
734
|
+
*/
|
|
735
|
+
const FONT_WEIGHTS = Object.freeze({
|
|
736
|
+
thin: 100,
|
|
737
|
+
hairline: 100,
|
|
738
|
+
"extra-light": 200,
|
|
739
|
+
"ultra-light": 200,
|
|
740
|
+
light: 300,
|
|
741
|
+
normal: 400,
|
|
742
|
+
regular: 400,
|
|
743
|
+
book: 400,
|
|
744
|
+
medium: 500,
|
|
745
|
+
"semi-bold": 600,
|
|
746
|
+
"demi-bold": 600,
|
|
747
|
+
bold: 700,
|
|
748
|
+
"extra-bold": 800,
|
|
749
|
+
"ultra-bold": 800,
|
|
750
|
+
black: 900,
|
|
751
|
+
heavy: 900,
|
|
752
|
+
"extra-black": 950,
|
|
753
|
+
"ultra-black": 950
|
|
754
|
+
});
|
|
755
|
+
/** A font weight: a number, a reference, or one of the spec's words. */
|
|
756
|
+
function fontWeight(raw) {
|
|
757
|
+
if (typeof raw === "string" && !WHOLE_REFERENCE$1.test(raw.trim())) {
|
|
758
|
+
const mapped = FONT_WEIGHTS[raw.toLowerCase()];
|
|
759
|
+
if (mapped === void 0) unwritable(`its font weight is "${raw}", which is not a weight the spec defines`);
|
|
760
|
+
return [String(mapped)];
|
|
761
|
+
}
|
|
762
|
+
return [sub(raw, "font weight")];
|
|
763
|
+
}
|
|
764
|
+
/**
|
|
765
|
+
* Typography: five CSS properties, so five custom properties.
|
|
766
|
+
*
|
|
767
|
+
* The `font` shorthand is not emitted, not even as an extra convenience
|
|
768
|
+
* property. Used alone it drops `letter-spacing` in silence, which is the exact
|
|
769
|
+
* failure this product exists to prevent.
|
|
770
|
+
*/
|
|
771
|
+
function typographyToExpansion(raw) {
|
|
772
|
+
require_(raw, [
|
|
773
|
+
"fontFamily",
|
|
774
|
+
"fontSize",
|
|
775
|
+
"fontWeight",
|
|
776
|
+
"letterSpacing",
|
|
777
|
+
"lineHeight"
|
|
778
|
+
], "typography");
|
|
779
|
+
return [
|
|
780
|
+
{
|
|
781
|
+
suffix: CSS_PROPERTY["fontFamily"],
|
|
782
|
+
parts: [sub(raw["fontFamily"], "font family")]
|
|
783
|
+
},
|
|
784
|
+
{
|
|
785
|
+
suffix: CSS_PROPERTY["fontSize"],
|
|
786
|
+
parts: [sub(raw["fontSize"], "font size")]
|
|
787
|
+
},
|
|
788
|
+
{
|
|
789
|
+
suffix: CSS_PROPERTY["fontWeight"],
|
|
790
|
+
parts: fontWeight(raw["fontWeight"])
|
|
791
|
+
},
|
|
792
|
+
{
|
|
793
|
+
suffix: CSS_PROPERTY["letterSpacing"],
|
|
794
|
+
parts: [sub(raw["letterSpacing"], "letter spacing")]
|
|
795
|
+
},
|
|
796
|
+
{
|
|
797
|
+
suffix: CSS_PROPERTY["lineHeight"],
|
|
798
|
+
parts: [sub(raw["lineHeight"], "line height")]
|
|
799
|
+
}
|
|
800
|
+
];
|
|
801
|
+
}
|
|
802
|
+
/** Stroke style in object form: two SVG properties, so two custom properties. */
|
|
803
|
+
function strokeStyleToExpansion(raw) {
|
|
804
|
+
require_(raw, ["dashArray", "lineCap"], "stroke style");
|
|
805
|
+
const dashes = raw["dashArray"];
|
|
806
|
+
if (!Array.isArray(dashes) || dashes.length === 0) unwritable("its dash array is not a list of lengths");
|
|
807
|
+
return [{
|
|
808
|
+
suffix: CSS_PROPERTY["dashArray"],
|
|
809
|
+
parts: join$1(dashes.map((d) => [sub(d, "dash length")]), " ")
|
|
810
|
+
}, {
|
|
811
|
+
suffix: CSS_PROPERTY["lineCap"],
|
|
812
|
+
parts: [sub(raw["lineCap"], "line cap")]
|
|
813
|
+
}];
|
|
814
|
+
}
|
|
815
|
+
/**
|
|
816
|
+
* Converts a composite that describes more than one CSS property (FR-25).
|
|
817
|
+
*
|
|
818
|
+
* Recognition is by shape, as everywhere else: only typography has a font size
|
|
819
|
+
* and a line height, and only an object-form stroke style has a dash array.
|
|
820
|
+
*/
|
|
821
|
+
function compositeToExpansion(raw) {
|
|
822
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return { kind: "unrecognized" };
|
|
823
|
+
const record = raw;
|
|
824
|
+
try {
|
|
825
|
+
if ("fontFamily" in record || "fontSize" in record || "lineHeight" in record) return {
|
|
826
|
+
kind: "expanded",
|
|
827
|
+
expanded: typographyToExpansion(record)
|
|
828
|
+
};
|
|
829
|
+
if ("dashArray" in record || "lineCap" in record) return {
|
|
830
|
+
kind: "expanded",
|
|
831
|
+
expanded: strokeStyleToExpansion(record)
|
|
832
|
+
};
|
|
833
|
+
return { kind: "unrecognized" };
|
|
834
|
+
} catch (err) {
|
|
835
|
+
if (err instanceof Unwritable) return {
|
|
836
|
+
kind: "unwritable",
|
|
837
|
+
reason: err.message
|
|
838
|
+
};
|
|
839
|
+
throw err;
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
//#endregion
|
|
392
843
|
//#region src/dialects/walk.ts
|
|
393
844
|
/**
|
|
394
845
|
* The shared tree walk every dialect uses (AD-2, AD-9, AD-20).
|
|
@@ -443,27 +894,82 @@ function toTokenValue(raw, path, source) {
|
|
|
443
894
|
}
|
|
444
895
|
if (ANY_BRACE.test(raw)) fail(`token "${formatPath(path)}" has a reference inside a larger value ("${raw}"). This version emits a reference only when the whole value is one`, source, path);
|
|
445
896
|
}
|
|
446
|
-
const
|
|
447
|
-
if (
|
|
897
|
+
const asScalar = scalarToCss(raw, path, source);
|
|
898
|
+
if (asScalar !== null) return literal(asScalar);
|
|
899
|
+
const asComposite = compositeToParts(raw);
|
|
900
|
+
if (asComposite.kind === "parts") return composite(asComposite.parts);
|
|
901
|
+
if (asComposite.kind === "unwritable") throw new TokenCssError(`token "${formatPath(path)}" is a composite, but ${asComposite.reason}`, {
|
|
902
|
+
code: FailureCode.COMPOSITE_VALUE,
|
|
903
|
+
source,
|
|
904
|
+
tokenPaths: [formatPath(path)]
|
|
905
|
+
});
|
|
448
906
|
return literal(assertScalar(raw, path, source));
|
|
449
907
|
}
|
|
450
908
|
/**
|
|
909
|
+
* Reads one token node into the custom properties it becomes.
|
|
910
|
+
*
|
|
911
|
+
* Almost always one. A composite that describes several CSS properties becomes
|
|
912
|
+
* several (FR-25), each named by appending the sub-property's suffix to the
|
|
913
|
+
* token's path — so naming, collision detection and the alias graph all keep
|
|
914
|
+
* working on ordinary paths and never learn that expansion exists.
|
|
915
|
+
*
|
|
916
|
+
* @throws {TokenCssError} `COMPOSITE_VALUE` when the value cannot be written,
|
|
917
|
+
* which the caller turns into a skip.
|
|
918
|
+
*/
|
|
919
|
+
function readToken(raw, path, source) {
|
|
920
|
+
const expansion = compositeToExpansion(raw);
|
|
921
|
+
if (expansion.kind === "expanded") return expansion.expanded.map((one) => token([...path, one.suffix], composite(one.parts)));
|
|
922
|
+
if (expansion.kind === "unwritable") throw new TokenCssError(`token "${formatPath(path)}" is a composite, but ${expansion.reason}`, {
|
|
923
|
+
code: FailureCode.COMPOSITE_VALUE,
|
|
924
|
+
source,
|
|
925
|
+
tokenPaths: [formatPath(path)]
|
|
926
|
+
});
|
|
927
|
+
return [token(path, toTokenValue(raw, path, source))];
|
|
928
|
+
}
|
|
929
|
+
/**
|
|
451
930
|
* Walks a token document into a flat, ordered list of tokens.
|
|
452
931
|
*
|
|
453
|
-
* Nothing is dropped quietly
|
|
454
|
-
*
|
|
455
|
-
*
|
|
932
|
+
* Nothing is dropped quietly, but not everything is fatal any more (FR-24).
|
|
933
|
+
* One code is skippable and the rest are not:
|
|
934
|
+
*
|
|
935
|
+
* **`COMPOSITE_VALUE`** — the value is an object, an array, a boolean or null,
|
|
936
|
+
* so there is no CSS to write for it. That is scoped to one token and says
|
|
937
|
+
* nothing about the document around it, so the token is left out and recorded
|
|
938
|
+
* while the rest converts.
|
|
939
|
+
*
|
|
940
|
+
* **Everything else stays fatal**, including the token-scoped ones: a non-CSS
|
|
941
|
+
* unit, a reference buried in a larger string, Tokens Studio arithmetic. Each
|
|
942
|
+
* of those has its own message explaining the specific thing that is wrong, and
|
|
943
|
+
* folding them into a generic skip would replace an explanation with a shrug —
|
|
944
|
+
* arithmetic would be reported as an embedded reference, which is true and
|
|
945
|
+
* useless. A document whose shape this version does not accept is refused by
|
|
946
|
+
* name, the way it always has been.
|
|
947
|
+
*
|
|
948
|
+
* The line is drawn on the code rather than on where the failure was raised.
|
|
949
|
+
* Raising position is an implementation detail that moves; the code is public
|
|
950
|
+
* surface that cannot.
|
|
456
951
|
*
|
|
457
952
|
* @throws {TokenCssError} `FORMAT_NOT_ALLOWED` for unsafe keys, stray scalars,
|
|
458
|
-
*
|
|
459
|
-
* registry, because they can appear in a
|
|
953
|
+
* malformed references and values this version does not accept. Multi-file
|
|
954
|
+
* constructs are caught earlier, by the registry, because they can appear in a
|
|
955
|
+
* document no dialect claims.
|
|
460
956
|
*/
|
|
461
957
|
function walkTokenTree(root, source, reader) {
|
|
462
958
|
const tokens = [];
|
|
959
|
+
const skipped = [];
|
|
463
960
|
const visit = (node, path) => {
|
|
464
961
|
const value = reader.read(node);
|
|
465
962
|
if (value.found) {
|
|
466
|
-
|
|
963
|
+
try {
|
|
964
|
+
tokens.push(...readToken(value.raw, path, source));
|
|
965
|
+
} catch (err) {
|
|
966
|
+
if (!(err instanceof TokenCssError) || err.code !== FailureCode.COMPOSITE_VALUE) throw err;
|
|
967
|
+
skipped.push({
|
|
968
|
+
path: formatPath(path),
|
|
969
|
+
code: err.code,
|
|
970
|
+
reason: err.message
|
|
971
|
+
});
|
|
972
|
+
}
|
|
467
973
|
return;
|
|
468
974
|
}
|
|
469
975
|
for (const key of Object.keys(node)) {
|
|
@@ -476,7 +982,10 @@ function walkTokenTree(root, source, reader) {
|
|
|
476
982
|
}
|
|
477
983
|
};
|
|
478
984
|
visit(root, []);
|
|
479
|
-
return
|
|
985
|
+
return {
|
|
986
|
+
tokens,
|
|
987
|
+
skipped
|
|
988
|
+
};
|
|
480
989
|
}
|
|
481
990
|
//#endregion
|
|
482
991
|
//#region src/dialects/dtcg.ts
|
|
@@ -536,7 +1045,11 @@ function looksLikeDtcg(root) {
|
|
|
536
1045
|
}
|
|
537
1046
|
/** Normalizes a DTCG document into the internal representation. */
|
|
538
1047
|
function normalizeDtcg(root, source) {
|
|
539
|
-
|
|
1048
|
+
const { tokens, skipped } = walkTokenTree(root, source, reader$2);
|
|
1049
|
+
return {
|
|
1050
|
+
doc: { tokens },
|
|
1051
|
+
skipped
|
|
1052
|
+
};
|
|
540
1053
|
}
|
|
541
1054
|
//#endregion
|
|
542
1055
|
//#region src/dialects/sd-legacy.ts
|
|
@@ -575,7 +1088,11 @@ function looksLikeSdLegacy(root) {
|
|
|
575
1088
|
}
|
|
576
1089
|
/** Normalizes a Style Dictionary legacy document into the internal representation. */
|
|
577
1090
|
function normalizeSdLegacy(root, source) {
|
|
578
|
-
|
|
1091
|
+
const { tokens, skipped } = walkTokenTree(root, source, reader$1);
|
|
1092
|
+
return {
|
|
1093
|
+
doc: { tokens },
|
|
1094
|
+
skipped
|
|
1095
|
+
};
|
|
579
1096
|
}
|
|
580
1097
|
/**
|
|
581
1098
|
* Finds a token node that speaks both dialects at once.
|
|
@@ -690,9 +1207,12 @@ function normalizeTokensStudio(root, source) {
|
|
|
690
1207
|
code: FailureCode.FORMAT_NOT_ALLOWED,
|
|
691
1208
|
source
|
|
692
1209
|
});
|
|
693
|
-
const tokens = walkTokenTree(set, source, reader);
|
|
1210
|
+
const { tokens, skipped } = walkTokenTree(set, source, reader);
|
|
694
1211
|
for (const node of tokens) refuseExpressions(node, source);
|
|
695
|
-
return {
|
|
1212
|
+
return {
|
|
1213
|
+
doc: { tokens },
|
|
1214
|
+
skipped
|
|
1215
|
+
};
|
|
696
1216
|
}
|
|
697
1217
|
//#endregion
|
|
698
1218
|
//#region src/dialects/registry.ts
|
|
@@ -757,9 +1277,16 @@ function normalizeDocument(root, source) {
|
|
|
757
1277
|
tokenPaths: [mixed.join(".")]
|
|
758
1278
|
});
|
|
759
1279
|
for (const dialect of DIALECTS) if (dialect.matches(root)) {
|
|
760
|
-
const
|
|
761
|
-
if (doc.tokens.length === 0)
|
|
762
|
-
|
|
1280
|
+
const read = dialect.normalize(root, source);
|
|
1281
|
+
if (read.doc.tokens.length === 0) {
|
|
1282
|
+
if (read.skipped.length > 0) throw new TokenCssError(`every token in this document was skipped, so the stylesheet would declare nothing:\n` + read.skipped.map((skip) => ` ${skip.reason}`).join("\n"), {
|
|
1283
|
+
code: FailureCode.NOTHING_EMITTED,
|
|
1284
|
+
source,
|
|
1285
|
+
tokenPaths: read.skipped.map((skip) => skip.path)
|
|
1286
|
+
});
|
|
1287
|
+
break;
|
|
1288
|
+
}
|
|
1289
|
+
return read;
|
|
763
1290
|
}
|
|
764
1291
|
throw new TokenCssError(`no tokens were recognized in this document. This version reads: ${DIALECTS.map((d) => d.describedAs).join("; ")}`, {
|
|
765
1292
|
code: FailureCode.FORMAT_NOT_ALLOWED,
|
|
@@ -1117,13 +1644,14 @@ function resolveOutputPath(outDir, fileName, baseDir) {
|
|
|
1117
1644
|
* confusing half-cycles, and a developer with a typo should be told about the
|
|
1118
1645
|
* typo rather than about a loop that only exists because of it.
|
|
1119
1646
|
*
|
|
1120
|
-
*
|
|
1121
|
-
*
|
|
1122
|
-
*
|
|
1647
|
+
* A token has as many outgoing edges as its value has references: none for a
|
|
1648
|
+
* literal, one for an alias, and one per aliased sub-value for a composite
|
|
1649
|
+
* (FR-25). Both passes read those edges through `referencesOf`, so neither
|
|
1650
|
+
* needs to know which kind of value produced them.
|
|
1123
1651
|
*/
|
|
1124
|
-
/**
|
|
1125
|
-
function
|
|
1126
|
-
return
|
|
1652
|
+
/** Every token this one points at, as dotted paths, in order. */
|
|
1653
|
+
function targetsOf(node) {
|
|
1654
|
+
return referencesOf(node.value).map((r) => formatPath(r.path));
|
|
1127
1655
|
}
|
|
1128
1656
|
/**
|
|
1129
1657
|
* Reports every reference whose target is not a token in this document.
|
|
@@ -1137,12 +1665,13 @@ function checkDangling(doc, byPath, source) {
|
|
|
1137
1665
|
const problems = [];
|
|
1138
1666
|
const offenders = [];
|
|
1139
1667
|
for (const node of doc.tokens) {
|
|
1140
|
-
const target = targetOf(node);
|
|
1141
|
-
if (target === void 0 || byPath.has(target)) continue;
|
|
1142
1668
|
const from = formatPath(node.path);
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1669
|
+
for (const target of targetsOf(node)) {
|
|
1670
|
+
if (byPath.has(target)) continue;
|
|
1671
|
+
if (!offenders.includes(from)) offenders.push(from);
|
|
1672
|
+
const isGroup = [...byPath.keys()].some((known) => known.startsWith(`${target}.`));
|
|
1673
|
+
problems.push(isGroup ? `"${from}" references "${target}", which is a group of tokens rather than a token` : `"${from}" references "${target}", which does not exist`);
|
|
1674
|
+
}
|
|
1146
1675
|
}
|
|
1147
1676
|
if (problems.length > 0) throw new TokenCssError(`${problems.length} ${problems.length === 1 ? "reference points" : "references point"} nowhere:\n ${problems.join("\n ")}`, {
|
|
1148
1677
|
code: FailureCode.ALIAS_DANGLING,
|
|
@@ -1156,6 +1685,11 @@ const SETTLED = 2;
|
|
|
1156
1685
|
/**
|
|
1157
1686
|
* Reports every cycle in the reference graph.
|
|
1158
1687
|
*
|
|
1688
|
+
* Depth-first with three colours: a node still on the current path that is
|
|
1689
|
+
* reached again closes a cycle. This replaced a linear sweep when composites
|
|
1690
|
+
* arrived — the sweep followed one edge per token, which is correct only while
|
|
1691
|
+
* a token can make at most one reference, and a composite makes several.
|
|
1692
|
+
*
|
|
1159
1693
|
* Iterative rather than recursive: a document is allowed to be a chain of ten
|
|
1160
1694
|
* thousand tokens, and that is a stack overflow rather than a clear failure if
|
|
1161
1695
|
* this walks itself.
|
|
@@ -1163,19 +1697,36 @@ const SETTLED = 2;
|
|
|
1163
1697
|
function checkCycles(doc, byPath, source) {
|
|
1164
1698
|
const state = /* @__PURE__ */ new Map();
|
|
1165
1699
|
const cycles = [];
|
|
1700
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1166
1701
|
for (const start of doc.tokens) {
|
|
1167
1702
|
const startKey = formatPath(start.path);
|
|
1168
|
-
if (state.get(startKey) !==
|
|
1169
|
-
const
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1703
|
+
if ((state.get(startKey) ?? UNVISITED) !== UNVISITED) continue;
|
|
1704
|
+
const path = [startKey];
|
|
1705
|
+
const frames = [[...targetsOf(byPath.get(startKey))].reverse()];
|
|
1706
|
+
state.set(startKey, ON_PATH);
|
|
1707
|
+
while (path.length > 0) {
|
|
1708
|
+
const next = frames[frames.length - 1].pop();
|
|
1709
|
+
if (next === void 0) {
|
|
1710
|
+
state.set(path.pop(), SETTLED);
|
|
1711
|
+
frames.pop();
|
|
1712
|
+
continue;
|
|
1713
|
+
}
|
|
1714
|
+
const node = byPath.get(next);
|
|
1715
|
+
if (node === void 0) continue;
|
|
1716
|
+
if (state.get(next) === ON_PATH) {
|
|
1717
|
+
const cycle = path.slice(path.indexOf(next));
|
|
1718
|
+
const key = [...cycle].sort().join("\0");
|
|
1719
|
+
if (!seen.has(key)) {
|
|
1720
|
+
seen.add(key);
|
|
1721
|
+
cycles.push(cycle);
|
|
1722
|
+
}
|
|
1723
|
+
continue;
|
|
1724
|
+
}
|
|
1725
|
+
if (state.get(next) === SETTLED) continue;
|
|
1726
|
+
state.set(next, ON_PATH);
|
|
1727
|
+
path.push(next);
|
|
1728
|
+
frames.push([...targetsOf(node)].reverse());
|
|
1176
1729
|
}
|
|
1177
|
-
if (cursor !== void 0 && state.get(cursor) === ON_PATH) cycles.push(chain.slice(chain.indexOf(cursor)));
|
|
1178
|
-
for (const key of chain) state.set(key, SETTLED);
|
|
1179
1730
|
}
|
|
1180
1731
|
if (cycles.length > 0) {
|
|
1181
1732
|
const described = cycles.map((cycle) => ` ${[...cycle, cycle[0]].join(" → ")}`);
|
|
@@ -1317,24 +1868,26 @@ async function writeStylesheet(targetPath, contents, source) {
|
|
|
1317
1868
|
* divergence the fixed order exists to prevent.
|
|
1318
1869
|
*/
|
|
1319
1870
|
function convertDocument(raw, source) {
|
|
1320
|
-
const doc = normalizeDocument(raw, source);
|
|
1871
|
+
const { doc, skipped } = normalizeDocument(raw, source);
|
|
1321
1872
|
validateAliasGraph(doc, source);
|
|
1322
1873
|
validateNoCollisions(doc, source);
|
|
1323
1874
|
return {
|
|
1324
|
-
css: emitStylesheet(doc, source),
|
|
1325
|
-
tokenCount: doc.tokens.length
|
|
1875
|
+
css: emitStylesheet(doc, skipped, source),
|
|
1876
|
+
tokenCount: doc.tokens.length,
|
|
1877
|
+
skipped
|
|
1326
1878
|
};
|
|
1327
1879
|
}
|
|
1328
1880
|
async function runConversion(source, options = {}) {
|
|
1329
1881
|
const display = String(source);
|
|
1330
1882
|
const baseDir = options.baseDir ?? process.cwd();
|
|
1331
1883
|
const resolved = resolveSource(source, baseDir);
|
|
1332
|
-
const { css, tokenCount } = convertDocument(parseTokenJson(resolved.kind === "url" ? await fetchTokenDocument(resolved.url, display, options.http ?? {}) : await readTokenFile(resolved.path, display), display), display);
|
|
1884
|
+
const { css, tokenCount, skipped } = convertDocument(parseTokenJson(resolved.kind === "url" ? await fetchTokenDocument(resolved.url, display, options.http ?? {}) : await readTokenFile(resolved.path, display), display), display);
|
|
1333
1885
|
const outputPath = resolveOutputPath(options.outDir ?? DEFAULTS.outDir, options.fileName ?? DEFAULTS.fileName, baseDir);
|
|
1334
1886
|
await writeStylesheet(outputPath, css, display);
|
|
1335
1887
|
return {
|
|
1336
1888
|
outputPath,
|
|
1337
|
-
tokenCount
|
|
1889
|
+
tokenCount,
|
|
1890
|
+
skipped
|
|
1338
1891
|
};
|
|
1339
1892
|
}
|
|
1340
1893
|
//#endregion
|
|
@@ -1350,13 +1903,19 @@ async function runConversion(source, options = {}) {
|
|
|
1350
1903
|
* Convert a design-token document into a CSS custom-properties stylesheet.
|
|
1351
1904
|
*
|
|
1352
1905
|
* Reads the Token Source, validates it completely, and writes the stylesheet —
|
|
1353
|
-
* or throws a `TokenCssError` and writes nothing at all.
|
|
1354
|
-
*
|
|
1355
|
-
*
|
|
1906
|
+
* or throws a `TokenCssError` and writes nothing at all. A failed conversion
|
|
1907
|
+
* writes nothing: a previous stylesheet at the target path is left untouched.
|
|
1908
|
+
*
|
|
1909
|
+
* A token whose value cannot be written as CSS does not fail the conversion
|
|
1910
|
+
* (FR-24). It is left out, listed in `skipped` on the result, and named in a
|
|
1911
|
+
* comment above `:root` in the stylesheet itself. Everything else — an
|
|
1912
|
+
* unreadable source, a document shaped in a way this version does not accept,
|
|
1913
|
+
* an alias cycle, a dangling reference, a name collision — still fails whole.
|
|
1356
1914
|
*
|
|
1357
1915
|
* @param source Path to a single local file, or a URL.
|
|
1358
1916
|
* @param options Output location and network policy.
|
|
1359
|
-
* @returns Where the stylesheet was written,
|
|
1917
|
+
* @returns Where the stylesheet was written, how many properties it holds, and
|
|
1918
|
+
* which tokens it left out.
|
|
1360
1919
|
* @throws {TokenCssError} With a `code` naming the failure class.
|
|
1361
1920
|
*/
|
|
1362
1921
|
function generateCss(source, options) {
|