tokens-to-css 1.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/LICENSE +21 -0
- package/README.md +191 -0
- package/dist/index.d.ts +128 -0
- package/dist/index.js +1366 -0
- package/package.json +57 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1366 @@
|
|
|
1
|
+
import { mkdir, open, readFile, rename, unlink } from "node:fs/promises";
|
|
2
|
+
import { request } from "node:http";
|
|
3
|
+
import { request as request$1 } from "node:https";
|
|
4
|
+
import { lookup } from "node:dns";
|
|
5
|
+
import { BlockList, isIP, isIPv4 } from "node:net";
|
|
6
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { randomUUID } from "node:crypto";
|
|
9
|
+
//#region src/model/index.ts
|
|
10
|
+
/** Builds a literal value. */
|
|
11
|
+
function literal(value) {
|
|
12
|
+
return {
|
|
13
|
+
kind: "literal",
|
|
14
|
+
value
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
/** Builds a reference to another token. */
|
|
18
|
+
function ref(path) {
|
|
19
|
+
return {
|
|
20
|
+
kind: "ref",
|
|
21
|
+
path
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/** Builds a token node. */
|
|
25
|
+
function token(path, value) {
|
|
26
|
+
return {
|
|
27
|
+
path,
|
|
28
|
+
value
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/** Narrows a value to a reference. */
|
|
32
|
+
function isRef(value) {
|
|
33
|
+
return value.kind === "ref";
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Renders a path the way the token document wrote it, for humans.
|
|
37
|
+
*
|
|
38
|
+
* This is how a token is named in an error message — it is *not* the custom
|
|
39
|
+
* property name, which follows the naming rule and lives in the emitter.
|
|
40
|
+
*/
|
|
41
|
+
function formatPath(path) {
|
|
42
|
+
return path.join(".");
|
|
43
|
+
}
|
|
44
|
+
//#endregion
|
|
45
|
+
//#region src/errors.ts
|
|
46
|
+
/**
|
|
47
|
+
* The failure contract — public surface, frozen by semver (AD-4).
|
|
48
|
+
*
|
|
49
|
+
* Every failure in this library is a `TokenCssError` carrying one of the codes
|
|
50
|
+
* below. Callers branch on `code`; they never match on message text, so message
|
|
51
|
+
* wording stays free to improve. Renaming or merging a code is a major version.
|
|
52
|
+
*/
|
|
53
|
+
/**
|
|
54
|
+
* The complete set of ways a conversion can fail.
|
|
55
|
+
*
|
|
56
|
+
* One code per failure class in the PRD. Adding a code is a minor version;
|
|
57
|
+
* renaming, merging, or removing one is a major version.
|
|
58
|
+
*/
|
|
59
|
+
const FailureCode = Object.freeze({
|
|
60
|
+
/** The path or URL could not be read: missing, denied, unreachable, timed out, oversized, or refused by network policy. */
|
|
61
|
+
SOURCE_UNREADABLE: "SOURCE_UNREADABLE",
|
|
62
|
+
/** The source was read but its content is not valid JSON. */
|
|
63
|
+
SOURCE_INVALID_JSON: "SOURCE_INVALID_JSON",
|
|
64
|
+
/** The document is not a shape this version accepts. */
|
|
65
|
+
FORMAT_NOT_ALLOWED: "FORMAT_NOT_ALLOWED",
|
|
66
|
+
/** Aliases reference each other in a loop. */
|
|
67
|
+
ALIAS_CYCLE: "ALIAS_CYCLE",
|
|
68
|
+
/** An alias points at a token that does not exist. */
|
|
69
|
+
ALIAS_DANGLING: "ALIAS_DANGLING",
|
|
70
|
+
/** A value is an object, array, boolean, or null rather than a scalar. */
|
|
71
|
+
COMPOSITE_VALUE: "COMPOSITE_VALUE",
|
|
72
|
+
/** Two or more tokens would emit the same custom-property name. */
|
|
73
|
+
NAME_COLLISION: "NAME_COLLISION",
|
|
74
|
+
/** The stylesheet could not be written. */
|
|
75
|
+
OUTPUT_WRITE_FAILED: "OUTPUT_WRITE_FAILED"
|
|
76
|
+
});
|
|
77
|
+
/**
|
|
78
|
+
* The only error this library throws.
|
|
79
|
+
*
|
|
80
|
+
* There are no subclasses: the `code` is what callers branch on, and one type
|
|
81
|
+
* means a caller never has to ask which error shape it caught.
|
|
82
|
+
*/
|
|
83
|
+
var TokenCssError = class extends Error {
|
|
84
|
+
name = "TokenCssError";
|
|
85
|
+
/** The failure class. Stable across minor versions. */
|
|
86
|
+
code;
|
|
87
|
+
/** The Token Source being converted when this failed. */
|
|
88
|
+
source;
|
|
89
|
+
/** Offending token paths — empty for failures that are not token-scoped. */
|
|
90
|
+
tokenPaths;
|
|
91
|
+
constructor(message, init) {
|
|
92
|
+
super(message, init.cause === void 0 ? void 0 : { cause: init.cause });
|
|
93
|
+
this.code = init.code;
|
|
94
|
+
this.source = init.source;
|
|
95
|
+
this.tokenPaths = init.tokenPaths ?? [];
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
//#endregion
|
|
99
|
+
//#region src/emit/literal.ts
|
|
100
|
+
/**
|
|
101
|
+
* Turning a token value into stylesheet text (AD-19).
|
|
102
|
+
*
|
|
103
|
+
* One rule, no options: write what the token said. A string goes out verbatim,
|
|
104
|
+
* a number goes out as its plain decimal form. Nothing is inferred from a
|
|
105
|
+
* `$type`, which is why the internal model does not carry one — the moment this
|
|
106
|
+
* function could see that a token is a "dimension", someone would reasonably
|
|
107
|
+
* append `px`, and two implementations would disagree about what `16` means.
|
|
108
|
+
*/
|
|
109
|
+
/** Whether a raw JSON value can be written into a stylesheet at all. */
|
|
110
|
+
function isScalar(value) {
|
|
111
|
+
return typeof value === "string" || typeof value === "number" && Number.isFinite(value);
|
|
112
|
+
}
|
|
113
|
+
/** How a rejected value is described in the failure message. */
|
|
114
|
+
function describe(value) {
|
|
115
|
+
if (value === null) return "null";
|
|
116
|
+
if (Array.isArray(value)) return "an array";
|
|
117
|
+
if (typeof value === "object") return "an object";
|
|
118
|
+
return `a ${typeof value}`;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Accepts a raw value only if it is a scalar (FR-20).
|
|
122
|
+
*
|
|
123
|
+
* @throws {TokenCssError} `COMPOSITE_VALUE` for objects, arrays, booleans and
|
|
124
|
+
* null. A composite has no single stylesheet value, and stringifying one
|
|
125
|
+
* produces `[object Object]` under a successful-looking run.
|
|
126
|
+
*/
|
|
127
|
+
function assertScalar(value, path, source) {
|
|
128
|
+
if (isScalar(value)) return value;
|
|
129
|
+
throw new TokenCssError(`token "${formatPath(path)}" has ${describe(value)} as its value, but this version writes one custom property per scalar token`, {
|
|
130
|
+
code: FailureCode.COMPOSITE_VALUE,
|
|
131
|
+
source,
|
|
132
|
+
tokenPaths: [formatPath(path)]
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Renders a scalar as stylesheet text.
|
|
137
|
+
*
|
|
138
|
+
* Verbatim for strings — no quoting, no trimming, no escaping: a token that
|
|
139
|
+
* says `1px solid red` means exactly that, and a token with a trailing space
|
|
140
|
+
* keeps it, because the golden files are byte-exact and silently tidying the
|
|
141
|
+
* input would make the output depend on this function's taste.
|
|
142
|
+
*/
|
|
143
|
+
function stringifyLiteral(value) {
|
|
144
|
+
return typeof value === "string" ? value : String(value);
|
|
145
|
+
}
|
|
146
|
+
//#endregion
|
|
147
|
+
//#region src/emit/name.ts
|
|
148
|
+
/**
|
|
149
|
+
* The custom-property naming rule (FR-9, AD-11).
|
|
150
|
+
*
|
|
151
|
+
* **This is public contract.** The names produced here appear in the stylesheet
|
|
152
|
+
* and in the stylesheets of everyone using it, so changing this function's
|
|
153
|
+
* output is a major version — no exceptions, no options, no configurable prefix.
|
|
154
|
+
*
|
|
155
|
+
* The rule is deliberately lossy: anything outside `[a-z0-9]` collapses. Two
|
|
156
|
+
* paths can therefore normalize to the same name, which is not this module's
|
|
157
|
+
* problem to solve — the collision pass runs on the names this returns and
|
|
158
|
+
* fails clearly rather than dropping a token (FR-21).
|
|
159
|
+
*/
|
|
160
|
+
/**
|
|
161
|
+
* Normalizes one path segment.
|
|
162
|
+
*
|
|
163
|
+
* Unicode is normalized to NFC first, and that is not decoration. The same
|
|
164
|
+
* word can arrive composed or decomposed depending on which tool wrote the
|
|
165
|
+
* file — `é` as one code point, or `e` followed by a combining accent. Without
|
|
166
|
+
* NFC those two produce different custom-property names from identical-looking
|
|
167
|
+
* source, which would make the output depend on the editor rather than the
|
|
168
|
+
* tokens.
|
|
169
|
+
*/
|
|
170
|
+
function normalizeSegment(segment) {
|
|
171
|
+
return segment.normalize("NFC").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Builds the custom-property name for a token path.
|
|
175
|
+
*
|
|
176
|
+
* `['color', 'brand', 'primary']` becomes `--color-brand-primary`.
|
|
177
|
+
*
|
|
178
|
+
* @param path Path segments from the document root.
|
|
179
|
+
* @param source The Token Source, carried only so a failure can name it.
|
|
180
|
+
* @throws {TokenCssError} `FORMAT_NOT_ALLOWED` when a segment has nothing left
|
|
181
|
+
* after normalization. Dropping it would silently rename the token.
|
|
182
|
+
*/
|
|
183
|
+
function customPropertyName(path, source) {
|
|
184
|
+
const segments = path.map((segment) => {
|
|
185
|
+
const normalized = normalizeSegment(segment);
|
|
186
|
+
if (normalized === "") throw new TokenCssError(`token "${formatPath(path)}" has a path segment ("${segment}") with no letters or digits, so it cannot become part of a custom-property name`, {
|
|
187
|
+
code: FailureCode.FORMAT_NOT_ALLOWED,
|
|
188
|
+
source,
|
|
189
|
+
tokenPaths: [formatPath(path)]
|
|
190
|
+
});
|
|
191
|
+
return normalized;
|
|
192
|
+
});
|
|
193
|
+
if (segments.length === 0) throw new TokenCssError("a token has an empty path, so it cannot be named", {
|
|
194
|
+
code: FailureCode.FORMAT_NOT_ALLOWED,
|
|
195
|
+
source,
|
|
196
|
+
tokenPaths: [""]
|
|
197
|
+
});
|
|
198
|
+
return `--${segments.join("-")}`;
|
|
199
|
+
}
|
|
200
|
+
//#endregion
|
|
201
|
+
//#region src/emit/css.ts
|
|
202
|
+
/**
|
|
203
|
+
* Writing the stylesheet (FR-9, FR-10, AD-10).
|
|
204
|
+
*
|
|
205
|
+
* The whole document becomes one string before anything reaches disk (AD-6), so
|
|
206
|
+
* a failure late in emission cannot leave half a stylesheet behind.
|
|
207
|
+
*
|
|
208
|
+
* The exact bytes produced here are the contract the golden files hold. Two
|
|
209
|
+
* runs over the same document must produce identical text, which is why nothing
|
|
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.
|
|
213
|
+
*/
|
|
214
|
+
/** Two spaces. Fixed: a configurable indent would make goldens negotiable. */
|
|
215
|
+
const INDENT = " ";
|
|
216
|
+
/**
|
|
217
|
+
* Renders a normalized document as a stylesheet.
|
|
218
|
+
*
|
|
219
|
+
* A token whose value points at another token is written as `var(--target)`,
|
|
220
|
+
* never as the target's value. That is the whole point of the product: the
|
|
221
|
+
* relationship the token file expressed survives into the CSS, so changing a
|
|
222
|
+
* primitive still moves everything that referred to it.
|
|
223
|
+
*
|
|
224
|
+
* @param doc The normalized document, in document order.
|
|
225
|
+
* @param source The Token Source, carried only so a naming failure can name it.
|
|
226
|
+
* @returns The complete stylesheet text, ending in exactly one newline.
|
|
227
|
+
*/
|
|
228
|
+
function emitStylesheet(doc, source) {
|
|
229
|
+
return [
|
|
230
|
+
":root {",
|
|
231
|
+
...doc.tokens.map((node) => {
|
|
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
|
+
}),
|
|
236
|
+
"}",
|
|
237
|
+
""
|
|
238
|
+
].join("\n");
|
|
239
|
+
}
|
|
240
|
+
//#endregion
|
|
241
|
+
//#region src/options.ts
|
|
242
|
+
/** The defaults a conversion uses when the caller says nothing. */
|
|
243
|
+
const DEFAULTS = Object.freeze({
|
|
244
|
+
outDir: "assets/css",
|
|
245
|
+
fileName: "tokens.css",
|
|
246
|
+
http: Object.freeze({
|
|
247
|
+
allowInsecure: false,
|
|
248
|
+
timeoutMs: 1e4,
|
|
249
|
+
maxBytes: 1e7,
|
|
250
|
+
maxRedirects: 3
|
|
251
|
+
})
|
|
252
|
+
});
|
|
253
|
+
//#endregion
|
|
254
|
+
//#region src/dialects/values.ts
|
|
255
|
+
/**
|
|
256
|
+
* Object-form scalar values (FR-23).
|
|
257
|
+
*
|
|
258
|
+
* The current DTCG spec writes a colour and a dimension as objects rather than
|
|
259
|
+
* strings:
|
|
260
|
+
*
|
|
261
|
+
* { "colorSpace": "srgb", "components": [0, 0, 0], "alpha": 1, "hex": "#000" }
|
|
262
|
+
* { "value": 0.25, "unit": "rem" }
|
|
263
|
+
*
|
|
264
|
+
* Both still describe **one** CSS value. They are scalars written with more
|
|
265
|
+
* ceremony, not composites, and refusing them meant refusing every design
|
|
266
|
+
* system published against the current spec.
|
|
267
|
+
*
|
|
268
|
+
* Two properties of this module matter more than the conversion itself:
|
|
269
|
+
*
|
|
270
|
+
* **It reads the shape, never the `$type`.** `{colorSpace, components}` can only
|
|
271
|
+
* be a colour; `{value, unit}` can only be a dimension. So nothing here infers
|
|
272
|
+
* anything from a declared type, and the rule that a `16` never becomes `16px`
|
|
273
|
+
* survives untouched.
|
|
274
|
+
*
|
|
275
|
+
* **It runs at normalization.** What reaches the internal representation is
|
|
276
|
+
* still a plain string, so no stage downstream learns that object values exist.
|
|
277
|
+
*/
|
|
278
|
+
/** Units CSS understands. `dp` and friends belong to other platforms. */
|
|
279
|
+
const CSS_UNITS = /* @__PURE__ */ new Set([
|
|
280
|
+
"px",
|
|
281
|
+
"rem",
|
|
282
|
+
"em",
|
|
283
|
+
"ex",
|
|
284
|
+
"ch",
|
|
285
|
+
"cap",
|
|
286
|
+
"ic",
|
|
287
|
+
"lh",
|
|
288
|
+
"rlh",
|
|
289
|
+
"%",
|
|
290
|
+
"vw",
|
|
291
|
+
"vh",
|
|
292
|
+
"vmin",
|
|
293
|
+
"vmax",
|
|
294
|
+
"svw",
|
|
295
|
+
"svh",
|
|
296
|
+
"lvw",
|
|
297
|
+
"lvh",
|
|
298
|
+
"dvw",
|
|
299
|
+
"dvh",
|
|
300
|
+
"cqw",
|
|
301
|
+
"cqh",
|
|
302
|
+
"cqi",
|
|
303
|
+
"cqb",
|
|
304
|
+
"cqmin",
|
|
305
|
+
"cqmax",
|
|
306
|
+
"cm",
|
|
307
|
+
"mm",
|
|
308
|
+
"q",
|
|
309
|
+
"in",
|
|
310
|
+
"pt",
|
|
311
|
+
"pc",
|
|
312
|
+
"deg",
|
|
313
|
+
"grad",
|
|
314
|
+
"rad",
|
|
315
|
+
"turn",
|
|
316
|
+
"s",
|
|
317
|
+
"ms",
|
|
318
|
+
"fr"
|
|
319
|
+
]);
|
|
320
|
+
const refuse$1 = (message, path, source) => {
|
|
321
|
+
throw new TokenCssError(message, {
|
|
322
|
+
code: FailureCode.FORMAT_NOT_ALLOWED,
|
|
323
|
+
source,
|
|
324
|
+
tokenPaths: [formatPath(path)]
|
|
325
|
+
});
|
|
326
|
+
};
|
|
327
|
+
/** `{ value, unit }` — a dimension or a duration. */
|
|
328
|
+
function isDimension(raw) {
|
|
329
|
+
const keys = Object.keys(raw);
|
|
330
|
+
return keys.includes("value") && keys.every((key) => key === "value" || key === "unit");
|
|
331
|
+
}
|
|
332
|
+
function dimensionToCss(raw, path, source) {
|
|
333
|
+
const { value, unit } = raw;
|
|
334
|
+
if (typeof value !== "number" || !Number.isFinite(value)) refuse$1(`token "${formatPath(path)}" has a dimension whose value is not a number`, path, source);
|
|
335
|
+
if (typeof unit !== "string") refuse$1(`token "${formatPath(path)}" has a dimension whose unit is not text`, path, source);
|
|
336
|
+
if (unit === "") return String(value);
|
|
337
|
+
if (!CSS_UNITS.has(unit)) refuse$1(`token "${formatPath(path)}" is measured in "${unit}", which is not a CSS unit`, path, source);
|
|
338
|
+
return `${value}${unit}`;
|
|
339
|
+
}
|
|
340
|
+
/** `{ colorSpace, components, alpha?, hex? }`. */
|
|
341
|
+
function isColor(raw) {
|
|
342
|
+
return "colorSpace" in raw && "components" in raw;
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Renders a colour.
|
|
346
|
+
*
|
|
347
|
+
* sRGB becomes `rgb()`, which every browser has understood for decades. The
|
|
348
|
+
* components are 0–1 in the token and 0–255 in CSS, and across every published
|
|
349
|
+
* design system checked while deciding this, that multiplication reproduces the
|
|
350
|
+
* `hex` the file itself declares — exactly, in all 689 cases. So the familiar
|
|
351
|
+
* notation costs nothing.
|
|
352
|
+
*
|
|
353
|
+
* Any other colour space becomes `color(space …)` with the numbers untouched,
|
|
354
|
+
* because there is no lossless shorter form.
|
|
355
|
+
*
|
|
356
|
+
* `hex` is read and ignored, the way `$description` is. It is optional in the
|
|
357
|
+
* spec, and a value that changed shape depending on whether an optional field
|
|
358
|
+
* happened to be present would be worse than one that never uses it.
|
|
359
|
+
*/
|
|
360
|
+
function colorToCss(raw, path, source) {
|
|
361
|
+
const space = raw["colorSpace"];
|
|
362
|
+
const components = raw["components"];
|
|
363
|
+
const alpha = raw["alpha"];
|
|
364
|
+
if (typeof space !== "string" || space === "") refuse$1(`token "${formatPath(path)}" has a colour with no colour space`, path, source);
|
|
365
|
+
if (!Array.isArray(components) || components.length !== 3) refuse$1(`token "${formatPath(path)}" has a colour with ${Array.isArray(components) ? `${components.length} components` : "no components"}; three are expected`, path, source);
|
|
366
|
+
const rendered = components.map((component, index) => {
|
|
367
|
+
if (component === "none") return "none";
|
|
368
|
+
if (typeof component !== "number" || !Number.isFinite(component)) refuse$1(`token "${formatPath(path)}" has a colour whose component ${index + 1} is not a number`, path, source);
|
|
369
|
+
return component;
|
|
370
|
+
});
|
|
371
|
+
const suffix = typeof alpha === "number" && alpha !== 1 ? ` / ${alpha}` : alpha === "none" ? " / none" : "";
|
|
372
|
+
if (space === "srgb") return `rgb(${rendered.map((c) => c === "none" ? "none" : Math.round(c * 255)).join(" ")}${suffix})`;
|
|
373
|
+
return `color(${space} ${rendered.join(" ")}${suffix})`;
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Converts an object-form scalar into stylesheet text.
|
|
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.
|
|
383
|
+
*/
|
|
384
|
+
function objectValueToCss(raw, path, source) {
|
|
385
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
|
|
386
|
+
const record = raw;
|
|
387
|
+
if (isColor(record)) return colorToCss(record, path, source);
|
|
388
|
+
if (isDimension(record)) return dimensionToCss(record, path, source);
|
|
389
|
+
return null;
|
|
390
|
+
}
|
|
391
|
+
//#endregion
|
|
392
|
+
//#region src/dialects/walk.ts
|
|
393
|
+
/**
|
|
394
|
+
* The shared tree walk every dialect uses (AD-2, AD-9, AD-20).
|
|
395
|
+
*
|
|
396
|
+
* Dialects differ in one thing: which key marks a node as a token, and how to
|
|
397
|
+
* read its value out. Everything else — recursion, prototype safety, reference
|
|
398
|
+
* syntax, refusing to drop things silently — is identical, and lives here so
|
|
399
|
+
* three dialects cannot disagree about it.
|
|
400
|
+
*/
|
|
401
|
+
/**
|
|
402
|
+
* Keys that must never be walked into or copied.
|
|
403
|
+
*
|
|
404
|
+
* `JSON.parse` puts `__proto__` on the object as an ordinary own property
|
|
405
|
+
* rather than invoking the setter, so it arrives here intact — which is exactly
|
|
406
|
+
* why it has to be refused explicitly rather than assumed impossible.
|
|
407
|
+
*/
|
|
408
|
+
const UNSAFE_KEYS = /* @__PURE__ */ new Set([
|
|
409
|
+
"__proto__",
|
|
410
|
+
"constructor",
|
|
411
|
+
"prototype"
|
|
412
|
+
]);
|
|
413
|
+
/** A whole-string reference, and nothing else: `{color.brand.primary}`. */
|
|
414
|
+
const WHOLE_REFERENCE = /^\{([^{}]+)\}$/;
|
|
415
|
+
/** Any brace at all, used to catch references embedded in a larger string. */
|
|
416
|
+
const ANY_BRACE = /[{}]/;
|
|
417
|
+
const fail = (message, source, path) => {
|
|
418
|
+
throw new TokenCssError(message, {
|
|
419
|
+
code: FailureCode.FORMAT_NOT_ALLOWED,
|
|
420
|
+
source,
|
|
421
|
+
tokenPaths: [formatPath(path)]
|
|
422
|
+
});
|
|
423
|
+
};
|
|
424
|
+
/** True for a plain object — not null, not an array. */
|
|
425
|
+
function isPlainObject(value) {
|
|
426
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Turns a raw token value into a reference or a literal.
|
|
430
|
+
*
|
|
431
|
+
* A value is a reference only when the entire string is one. A string that
|
|
432
|
+
* merely contains `{…}` — `1px solid {color.border}` — is refused: emitting it
|
|
433
|
+
* verbatim would produce syntactically valid CSS that silently does nothing,
|
|
434
|
+
* which is the failure mode this library exists to prevent.
|
|
435
|
+
*/
|
|
436
|
+
function toTokenValue(raw, path, source) {
|
|
437
|
+
if (typeof raw === "string") {
|
|
438
|
+
const whole = WHOLE_REFERENCE.exec(raw.trim());
|
|
439
|
+
if (whole) {
|
|
440
|
+
const target = whole[1].split(".");
|
|
441
|
+
if (target.some((segment) => segment.trim() === "")) fail(`token "${formatPath(path)}" references "${raw.trim()}", which is not a token path`, source, path);
|
|
442
|
+
return ref(target);
|
|
443
|
+
}
|
|
444
|
+
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
|
+
}
|
|
446
|
+
const asObjectScalar = objectValueToCss(raw, path, source);
|
|
447
|
+
if (asObjectScalar !== null) return literal(asObjectScalar);
|
|
448
|
+
return literal(assertScalar(raw, path, source));
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Walks a token document into a flat, ordered list of tokens.
|
|
452
|
+
*
|
|
453
|
+
* Nothing is dropped quietly. A scalar sitting where a group should be is a
|
|
454
|
+
* failure, not a skipped entry, because a token that vanishes between the file
|
|
455
|
+
* and the stylesheet is the silent breakage the Reliability requirement forbids.
|
|
456
|
+
*
|
|
457
|
+
* @throws {TokenCssError} `FORMAT_NOT_ALLOWED` for unsafe keys, stray scalars,
|
|
458
|
+
* and malformed references. Multi-file constructs are caught earlier, by the
|
|
459
|
+
* registry, because they can appear in a document no dialect claims.
|
|
460
|
+
*/
|
|
461
|
+
function walkTokenTree(root, source, reader) {
|
|
462
|
+
const tokens = [];
|
|
463
|
+
const visit = (node, path) => {
|
|
464
|
+
const value = reader.read(node);
|
|
465
|
+
if (value.found) {
|
|
466
|
+
tokens.push(token(path, toTokenValue(value.raw, path, source)));
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
for (const key of Object.keys(node)) {
|
|
470
|
+
const childPath = [...path, key];
|
|
471
|
+
if (UNSAFE_KEYS.has(key)) fail(`token document contains the key "${key}" at "${formatPath(childPath)}", which is refused`, source, childPath);
|
|
472
|
+
if (reader.isMetadataKey(key)) continue;
|
|
473
|
+
const child = node[key];
|
|
474
|
+
if (isPlainObject(child)) visit(child, childPath);
|
|
475
|
+
else fail(`"${formatPath(childPath)}" is a ${Array.isArray(child) ? "list" : typeof child}, but a group or a token was expected here`, source, childPath);
|
|
476
|
+
}
|
|
477
|
+
};
|
|
478
|
+
visit(root, []);
|
|
479
|
+
return tokens;
|
|
480
|
+
}
|
|
481
|
+
//#endregion
|
|
482
|
+
//#region src/dialects/dtcg.ts
|
|
483
|
+
/** Keys DTCG reserves. Inside a token node they are metadata; at group level, document metadata. */
|
|
484
|
+
const isDollarKey = (key) => key.startsWith("$");
|
|
485
|
+
const reader$2 = {
|
|
486
|
+
read: (node) => "$value" in node ? {
|
|
487
|
+
found: true,
|
|
488
|
+
raw: node["$value"]
|
|
489
|
+
} : {
|
|
490
|
+
found: false,
|
|
491
|
+
raw: void 0
|
|
492
|
+
},
|
|
493
|
+
isMetadataKey: isDollarKey
|
|
494
|
+
};
|
|
495
|
+
/**
|
|
496
|
+
* Finds a multi-file construct, wherever it sits.
|
|
497
|
+
*
|
|
498
|
+
* This runs before dialect detection, and it has to. A resolver document
|
|
499
|
+
* carries no `$value` anywhere, so no dialect claims it and the tree walk that
|
|
500
|
+
* holds the specific message never runs — the developer would be told "no
|
|
501
|
+
* tokens were recognized", which is true and useless when the real answer is
|
|
502
|
+
* "this file points at other files, and this version reads one".
|
|
503
|
+
*
|
|
504
|
+
* @returns The path where the construct was found, or `null`.
|
|
505
|
+
*/
|
|
506
|
+
function findMultiFileConstruct(root) {
|
|
507
|
+
const seen = /* @__PURE__ */ new Set();
|
|
508
|
+
const scan = (node, path) => {
|
|
509
|
+
if (seen.has(node)) return null;
|
|
510
|
+
seen.add(node);
|
|
511
|
+
for (const key of Object.keys(node)) {
|
|
512
|
+
if (key === "$ref") return [...path, key];
|
|
513
|
+
const child = node[key];
|
|
514
|
+
if (isPlainObject(child)) {
|
|
515
|
+
const found = scan(child, [...path, key]);
|
|
516
|
+
if (found) return found;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
return null;
|
|
520
|
+
};
|
|
521
|
+
return scan(root, []);
|
|
522
|
+
}
|
|
523
|
+
/** True when any node in the document carries a `$value`. */
|
|
524
|
+
function looksLikeDtcg(root) {
|
|
525
|
+
const seen = /* @__PURE__ */ new Set();
|
|
526
|
+
const scan = (node) => {
|
|
527
|
+
if (seen.has(node)) return false;
|
|
528
|
+
seen.add(node);
|
|
529
|
+
if ("$value" in node) return true;
|
|
530
|
+
return Object.keys(node).some((key) => {
|
|
531
|
+
const child = node[key];
|
|
532
|
+
return isPlainObject(child) && scan(child);
|
|
533
|
+
});
|
|
534
|
+
};
|
|
535
|
+
return scan(root);
|
|
536
|
+
}
|
|
537
|
+
/** Normalizes a DTCG document into the internal representation. */
|
|
538
|
+
function normalizeDtcg(root, source) {
|
|
539
|
+
return { tokens: walkTokenTree(root, source, reader$2) };
|
|
540
|
+
}
|
|
541
|
+
//#endregion
|
|
542
|
+
//#region src/dialects/sd-legacy.ts
|
|
543
|
+
/**
|
|
544
|
+
* A node holding `value` is a token.
|
|
545
|
+
*
|
|
546
|
+
* There is a trade-off buried here. A group literally named `value` would be
|
|
547
|
+
* misread as a token — but a composite `value` (a shadow, a typography block)
|
|
548
|
+
* is far more common than a group by that name, and it must reach the composite
|
|
549
|
+
* check rather than be walked into as a group. The pathological case fails
|
|
550
|
+
* clearly, naming the token, rather than converting into something wrong.
|
|
551
|
+
*/
|
|
552
|
+
const reader$1 = {
|
|
553
|
+
read: (node) => "value" in node ? {
|
|
554
|
+
found: true,
|
|
555
|
+
raw: node["value"]
|
|
556
|
+
} : {
|
|
557
|
+
found: false,
|
|
558
|
+
raw: void 0
|
|
559
|
+
},
|
|
560
|
+
isMetadataKey: (key) => key.startsWith("$")
|
|
561
|
+
};
|
|
562
|
+
/** True when any node in the document carries a `value`. */
|
|
563
|
+
function looksLikeSdLegacy(root) {
|
|
564
|
+
const seen = /* @__PURE__ */ new Set();
|
|
565
|
+
const scan = (node) => {
|
|
566
|
+
if (seen.has(node)) return false;
|
|
567
|
+
seen.add(node);
|
|
568
|
+
if ("value" in node) return true;
|
|
569
|
+
return Object.keys(node).some((key) => {
|
|
570
|
+
const child = node[key];
|
|
571
|
+
return isPlainObject(child) && scan(child);
|
|
572
|
+
});
|
|
573
|
+
};
|
|
574
|
+
return scan(root);
|
|
575
|
+
}
|
|
576
|
+
/** Normalizes a Style Dictionary legacy document into the internal representation. */
|
|
577
|
+
function normalizeSdLegacy(root, source) {
|
|
578
|
+
return { tokens: walkTokenTree(root, source, reader$1) };
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* Finds a token node that speaks both dialects at once.
|
|
582
|
+
*
|
|
583
|
+
* Detection is first-match-wins, so a node carrying `$value` and `value`
|
|
584
|
+
* together would be read as DTCG and its `value` quietly ignored. A key that
|
|
585
|
+
* disappears between the file and the stylesheet is the silent breakage this
|
|
586
|
+
* library refuses to produce, so the ambiguity is refused instead of resolved.
|
|
587
|
+
*
|
|
588
|
+
* @returns The path of the offending node, or `null`.
|
|
589
|
+
*/
|
|
590
|
+
function findMixedDialectNode(root) {
|
|
591
|
+
const seen = /* @__PURE__ */ new Set();
|
|
592
|
+
const scan = (node, path) => {
|
|
593
|
+
if (seen.has(node)) return null;
|
|
594
|
+
seen.add(node);
|
|
595
|
+
if ("$value" in node && "value" in node) return path;
|
|
596
|
+
for (const key of Object.keys(node)) {
|
|
597
|
+
const child = node[key];
|
|
598
|
+
if (isPlainObject(child)) {
|
|
599
|
+
const found = scan(child, [...path, key]);
|
|
600
|
+
if (found) return found;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
return null;
|
|
604
|
+
};
|
|
605
|
+
return scan(root, []);
|
|
606
|
+
}
|
|
607
|
+
//#endregion
|
|
608
|
+
//#region src/dialects/tokens-studio.ts
|
|
609
|
+
/**
|
|
610
|
+
* Tokens Studio exports — shape A3 of the Format Allowlist.
|
|
611
|
+
*
|
|
612
|
+
* What arrives from Tokens Studio is a DTCG or legacy tree wrapped in a token
|
|
613
|
+
* set, alongside the plugin's own bookkeeping. The bookkeeping is read and
|
|
614
|
+
* dropped; the wrapper is dropped from the emitted names.
|
|
615
|
+
*
|
|
616
|
+
* That last part is the whole reason this needs its own entry rather than
|
|
617
|
+
* falling through to another dialect. Left in place, the set name folds into
|
|
618
|
+
* every custom property — `global.color.brand` becoming `--global-color-brand`
|
|
619
|
+
* instead of `--color-brand` — which converts perfectly happily and is wrong
|
|
620
|
+
* everywhere at once.
|
|
621
|
+
*/
|
|
622
|
+
/** The plugin's own keys, read and ignored. */
|
|
623
|
+
const BOOKKEEPING = /* @__PURE__ */ new Set(["$themes", "$metadata"]);
|
|
624
|
+
/** A token node in either dialect the wrapper may contain. */
|
|
625
|
+
const reader = {
|
|
626
|
+
read: (node) => {
|
|
627
|
+
if ("$value" in node) return {
|
|
628
|
+
found: true,
|
|
629
|
+
raw: node["$value"]
|
|
630
|
+
};
|
|
631
|
+
if ("value" in node) return {
|
|
632
|
+
found: true,
|
|
633
|
+
raw: node["value"]
|
|
634
|
+
};
|
|
635
|
+
return {
|
|
636
|
+
found: false,
|
|
637
|
+
raw: void 0
|
|
638
|
+
};
|
|
639
|
+
},
|
|
640
|
+
isMetadataKey: (key) => key.startsWith("$")
|
|
641
|
+
};
|
|
642
|
+
/** True when the document carries the plugin's bookkeeping. */
|
|
643
|
+
function looksLikeTokensStudio(root) {
|
|
644
|
+
return [...BOOKKEEPING].some((key) => key in root);
|
|
645
|
+
}
|
|
646
|
+
/** Arithmetic written as a string: `2 * 4`, `16 / 2`, `(8 + 8)`. */
|
|
647
|
+
const ARITHMETIC = /^\(?\s*-?\d+(\.\d+)?\s*([+\-*/]\s*\(?\s*-?\d+(\.\d+)?\s*\)?\s*)+$/;
|
|
648
|
+
/** A helper Tokens Studio provides and this version does not evaluate. */
|
|
649
|
+
const STUDIO_FUNCTION = /^\s*roundTo\s*\(/;
|
|
650
|
+
/**
|
|
651
|
+
* Refuses a value that is an expression rather than a value.
|
|
652
|
+
*
|
|
653
|
+
* No expression is evaluated here or anywhere else — there is no evaluator in
|
|
654
|
+
* this package, deliberately. Tokens Studio can resolve its own math on export;
|
|
655
|
+
* a converter that quietly implemented a second, slightly different arithmetic
|
|
656
|
+
* would produce numbers nobody could account for.
|
|
657
|
+
*
|
|
658
|
+
* Deliberately narrow. `calc(100% - 16px)` and `clamp(1rem, 2vw, 3rem)` are
|
|
659
|
+
* valid CSS and pass through untouched; only arithmetic that CSS would not
|
|
660
|
+
* accept, and the plugin's own helpers, are refused.
|
|
661
|
+
*/
|
|
662
|
+
function refuseExpressions(node, source) {
|
|
663
|
+
if (node.value.kind !== "literal" || typeof node.value.value !== "string") return;
|
|
664
|
+
const text = node.value.value;
|
|
665
|
+
if (!ARITHMETIC.test(text) && !STUDIO_FUNCTION.test(text)) return;
|
|
666
|
+
throw new TokenCssError(`token "${formatPath(node.path)}" is an expression ("${text}"). This version writes token values as they are and evaluates nothing — resolve it in Tokens Studio before exporting`, {
|
|
667
|
+
code: FailureCode.FORMAT_NOT_ALLOWED,
|
|
668
|
+
source,
|
|
669
|
+
tokenPaths: [formatPath(node.path)]
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* Normalizes a Tokens Studio export.
|
|
674
|
+
*
|
|
675
|
+
* @throws {TokenCssError} `FORMAT_NOT_ALLOWED` when the export holds more than
|
|
676
|
+
* one token set, or when a value is an expression.
|
|
677
|
+
*/
|
|
678
|
+
function normalizeTokensStudio(root, source) {
|
|
679
|
+
const setNames = Object.keys(root).filter((key) => !BOOKKEEPING.has(key));
|
|
680
|
+
if (setNames.length === 0) throw new TokenCssError(`this Tokens Studio export contains no token set — only the plugin's own metadata`, {
|
|
681
|
+
code: FailureCode.FORMAT_NOT_ALLOWED,
|
|
682
|
+
source
|
|
683
|
+
});
|
|
684
|
+
if (setNames.length > 1) throw new TokenCssError(`this Tokens Studio export contains ${setNames.length} token sets (${setNames.join(", ")}). This version converts one set at a time — merging sets would need to decide which one wins, and that is a choice the export does not record`, {
|
|
685
|
+
code: FailureCode.FORMAT_NOT_ALLOWED,
|
|
686
|
+
source
|
|
687
|
+
});
|
|
688
|
+
const set = root[setNames[0]];
|
|
689
|
+
if (!isPlainObject(set)) throw new TokenCssError(`the token set "${setNames[0]}" is not a group of tokens`, {
|
|
690
|
+
code: FailureCode.FORMAT_NOT_ALLOWED,
|
|
691
|
+
source
|
|
692
|
+
});
|
|
693
|
+
const tokens = walkTokenTree(set, source, reader);
|
|
694
|
+
for (const node of tokens) refuseExpressions(node, source);
|
|
695
|
+
return { tokens };
|
|
696
|
+
}
|
|
697
|
+
//#endregion
|
|
698
|
+
//#region src/dialects/registry.ts
|
|
699
|
+
/**
|
|
700
|
+
* The Format Allowlist, as a closed ordered registry (AD-3).
|
|
701
|
+
*
|
|
702
|
+
* Every accepted shape is one entry. Detection walks the list in a fixed order
|
|
703
|
+
* and the first match wins, so a document that could be read two ways is always
|
|
704
|
+
* read the same way. Adding a shape means adding an entry and its fixtures —
|
|
705
|
+
* there is nowhere else to change, which is what keeps the allowlist a decision
|
|
706
|
+
* rather than an accumulation.
|
|
707
|
+
*/
|
|
708
|
+
/**
|
|
709
|
+
* Detection order: Tokens Studio, then DTCG, then Style Dictionary legacy.
|
|
710
|
+
*
|
|
711
|
+
* Tokens Studio comes first because its documents *contain* DTCG or legacy
|
|
712
|
+
* nodes — checking it later would match the inner shape and lose the wrappers.
|
|
713
|
+
* The Epic 2 dialects slot in around this entry without reordering it.
|
|
714
|
+
*/
|
|
715
|
+
const DIALECTS = [
|
|
716
|
+
{
|
|
717
|
+
id: "tokens-studio",
|
|
718
|
+
describedAs: "Tokens Studio exports, one token set per file",
|
|
719
|
+
matches: looksLikeTokensStudio,
|
|
720
|
+
normalize: normalizeTokensStudio
|
|
721
|
+
},
|
|
722
|
+
{
|
|
723
|
+
id: "dtcg",
|
|
724
|
+
describedAs: "DTCG single-file documents using $value",
|
|
725
|
+
matches: looksLikeDtcg,
|
|
726
|
+
normalize: normalizeDtcg
|
|
727
|
+
},
|
|
728
|
+
{
|
|
729
|
+
id: "sd-legacy",
|
|
730
|
+
describedAs: "Style Dictionary legacy documents using value/type without the dollar",
|
|
731
|
+
matches: looksLikeSdLegacy,
|
|
732
|
+
normalize: normalizeSdLegacy
|
|
733
|
+
}
|
|
734
|
+
];
|
|
735
|
+
/**
|
|
736
|
+
* Reads a parsed JSON document into the internal representation.
|
|
737
|
+
*
|
|
738
|
+
* @throws {TokenCssError} `FORMAT_NOT_ALLOWED` when the root is not an object,
|
|
739
|
+
* or when no allowlisted shape matches. The message lists what is accepted, so
|
|
740
|
+
* a developer can act on it without reading this source.
|
|
741
|
+
*/
|
|
742
|
+
function normalizeDocument(root, source) {
|
|
743
|
+
if (!isPlainObject(root)) throw new TokenCssError(`the token source is ${Array.isArray(root) ? "a list" : `a ${root === null ? "null" : typeof root}`}, but a token document must be a JSON object`, {
|
|
744
|
+
code: FailureCode.FORMAT_NOT_ALLOWED,
|
|
745
|
+
source
|
|
746
|
+
});
|
|
747
|
+
const multiFile = findMultiFileConstruct(root);
|
|
748
|
+
if (multiFile) throw new TokenCssError(`token document uses "$ref" at "${multiFile.join(".")}". Multi-file documents and resolver manifests are not supported — pass a single self-contained file`, {
|
|
749
|
+
code: FailureCode.FORMAT_NOT_ALLOWED,
|
|
750
|
+
source,
|
|
751
|
+
tokenPaths: [multiFile.join(".")]
|
|
752
|
+
});
|
|
753
|
+
const mixed = findMixedDialectNode(root);
|
|
754
|
+
if (mixed) throw new TokenCssError(`${mixed.length === 0 ? "the document root" : `"${mixed.join(".")}"`} carries both "$value" and "value". A token speaks one dialect or the other — remove whichever one is not meant to be there`, {
|
|
755
|
+
code: FailureCode.FORMAT_NOT_ALLOWED,
|
|
756
|
+
source,
|
|
757
|
+
tokenPaths: [mixed.join(".")]
|
|
758
|
+
});
|
|
759
|
+
for (const dialect of DIALECTS) if (dialect.matches(root)) {
|
|
760
|
+
const doc = dialect.normalize(root, source);
|
|
761
|
+
if (doc.tokens.length === 0) break;
|
|
762
|
+
return doc;
|
|
763
|
+
}
|
|
764
|
+
throw new TokenCssError(`no tokens were recognized in this document. This version reads: ${DIALECTS.map((d) => d.describedAs).join("; ")}`, {
|
|
765
|
+
code: FailureCode.FORMAT_NOT_ALLOWED,
|
|
766
|
+
source
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
//#endregion
|
|
770
|
+
//#region src/source/file.ts
|
|
771
|
+
/**
|
|
772
|
+
* Reading a local token file (FR-12, FR-13).
|
|
773
|
+
*
|
|
774
|
+
* The failure classes here are deliberately distinguishable: a missing file, a
|
|
775
|
+
* file you may not read, and a directory are three different mistakes with
|
|
776
|
+
* three different fixes, and telling them apart is most of what makes an error
|
|
777
|
+
* message worth reading.
|
|
778
|
+
*/
|
|
779
|
+
/**
|
|
780
|
+
* Reads a token file from disk.
|
|
781
|
+
*
|
|
782
|
+
* @throws {TokenCssError} `SOURCE_UNREADABLE` when the file is missing or
|
|
783
|
+
* cannot be read; `FORMAT_NOT_ALLOWED` when the path is a directory, which is
|
|
784
|
+
* a different mistake with a different fix.
|
|
785
|
+
*/
|
|
786
|
+
async function readTokenFile(path, source) {
|
|
787
|
+
try {
|
|
788
|
+
return await readFile(path, "utf8");
|
|
789
|
+
} catch (err) {
|
|
790
|
+
const code = err.code;
|
|
791
|
+
if (code === "EISDIR") throw new TokenCssError(`"${source}" is a directory. This version converts one token file at a time — pass the file itself`, {
|
|
792
|
+
code: FailureCode.FORMAT_NOT_ALLOWED,
|
|
793
|
+
source,
|
|
794
|
+
cause: err
|
|
795
|
+
});
|
|
796
|
+
throw new TokenCssError(`could not read "${source}": ${code === "ENOENT" ? "there is no file there" : code === "EACCES" || code === "EPERM" ? "permission was denied" : `reading it failed (${code ?? "unknown error"})`}`, {
|
|
797
|
+
code: FailureCode.SOURCE_UNREADABLE,
|
|
798
|
+
source,
|
|
799
|
+
cause: err
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
/**
|
|
804
|
+
* Parses token JSON.
|
|
805
|
+
*
|
|
806
|
+
* @throws {TokenCssError} `SOURCE_INVALID_JSON`, carrying the parser's own
|
|
807
|
+
* message, which names the position when the runtime provides it.
|
|
808
|
+
*/
|
|
809
|
+
function parseTokenJson(text, source) {
|
|
810
|
+
try {
|
|
811
|
+
return JSON.parse(text);
|
|
812
|
+
} catch (err) {
|
|
813
|
+
throw new TokenCssError(`"${source}" is not valid JSON: ${err.message}`, {
|
|
814
|
+
code: FailureCode.SOURCE_INVALID_JSON,
|
|
815
|
+
source,
|
|
816
|
+
cause: err
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
//#endregion
|
|
821
|
+
//#region src/source/http.ts
|
|
822
|
+
/**
|
|
823
|
+
* The guarded remote adapter (FR-1, FR-12, AD-8, AD-13).
|
|
824
|
+
*
|
|
825
|
+
* This is the only module in the package that opens a socket, and the only one
|
|
826
|
+
* that needs to be read as security code.
|
|
827
|
+
*
|
|
828
|
+
* It is built on `node:https` rather than global `fetch` for one reason:
|
|
829
|
+
* `fetch` never tells you which address it resolved, and pinning a connection
|
|
830
|
+
* to an address you have checked requires a dispatcher that is not a Node
|
|
831
|
+
* builtin. Taking that dependency would break the package's zero-dependency
|
|
832
|
+
* promise, so the choice is between a real guard and a convenient client.
|
|
833
|
+
*
|
|
834
|
+
* The shape of the guard:
|
|
835
|
+
*
|
|
836
|
+
* - `https:` only, unless the caller explicitly allows `http:`.
|
|
837
|
+
* - Every hop is re-validated. A redirect is a new request to a new host, and
|
|
838
|
+
* treating it as a continuation of an approved one is how a benign URL
|
|
839
|
+
* reaches a metadata endpoint.
|
|
840
|
+
* - The address is checked in a custom `lookup`, and the address the check
|
|
841
|
+
* approved is the address handed to the socket. Resolving once to validate
|
|
842
|
+
* and again to connect leaves a window between the two.
|
|
843
|
+
* - One deadline covers the whole exchange, redirects included.
|
|
844
|
+
* - The body is counted while it arrives and the connection is dropped the
|
|
845
|
+
* moment it is too big, rather than after it has all been buffered.
|
|
846
|
+
*/
|
|
847
|
+
/**
|
|
848
|
+
* Address ranges a token URL has no business reaching.
|
|
849
|
+
*
|
|
850
|
+
* The one that matters most is `169.254.0.0/16`: cloud metadata services live
|
|
851
|
+
* at `169.254.169.254` and hand out credentials to whoever asks from inside the
|
|
852
|
+
* host. A library that fetches a URL somebody else supplied is exactly the
|
|
853
|
+
* thing an attacker would like to point at it.
|
|
854
|
+
*/
|
|
855
|
+
function buildBlockList() {
|
|
856
|
+
const list = new BlockList();
|
|
857
|
+
for (const [network, prefix] of [
|
|
858
|
+
["0.0.0.0", 8],
|
|
859
|
+
["10.0.0.0", 8],
|
|
860
|
+
["100.64.0.0", 10],
|
|
861
|
+
["127.0.0.0", 8],
|
|
862
|
+
["169.254.0.0", 16],
|
|
863
|
+
["172.16.0.0", 12],
|
|
864
|
+
["192.0.0.0", 24],
|
|
865
|
+
["192.168.0.0", 16],
|
|
866
|
+
["198.18.0.0", 15],
|
|
867
|
+
["224.0.0.0", 4],
|
|
868
|
+
["240.0.0.0", 4]
|
|
869
|
+
]) list.addSubnet(network, prefix, "ipv4");
|
|
870
|
+
for (const [network, prefix] of [
|
|
871
|
+
["::", 128],
|
|
872
|
+
["::1", 128],
|
|
873
|
+
["fc00::", 7],
|
|
874
|
+
["fe80::", 10],
|
|
875
|
+
["ff00::", 8]
|
|
876
|
+
]) list.addSubnet(network, prefix, "ipv6");
|
|
877
|
+
return list;
|
|
878
|
+
}
|
|
879
|
+
const BLOCKED = buildBlockList();
|
|
880
|
+
/** `::ffff:169.254.169.254` is the same address as `169.254.169.254`. */
|
|
881
|
+
function unwrapMapped(address) {
|
|
882
|
+
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(address);
|
|
883
|
+
if (mapped) return {
|
|
884
|
+
address: mapped[1],
|
|
885
|
+
family: 4
|
|
886
|
+
};
|
|
887
|
+
return {
|
|
888
|
+
address,
|
|
889
|
+
family: isIPv4(address) ? 4 : 6
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
const STRICT = { allowInternalAddresses: false };
|
|
893
|
+
/** Whether an address is inside a range this adapter refuses to reach. */
|
|
894
|
+
function isBlockedAddress(address) {
|
|
895
|
+
const { address: plain, family } = unwrapMapped(address);
|
|
896
|
+
return BLOCKED.check(plain, family === 4 ? "ipv4" : "ipv6");
|
|
897
|
+
}
|
|
898
|
+
/**
|
|
899
|
+
* Refuses a host that is already a literal address.
|
|
900
|
+
*
|
|
901
|
+
* Node skips DNS entirely when the host is an IP, so the custom `lookup` never
|
|
902
|
+
* runs and the address check with it. `http://169.254.169.254/` — the most
|
|
903
|
+
* obvious payload there is — would otherwise walk straight past the guard and
|
|
904
|
+
* sit there until something timed out.
|
|
905
|
+
*/
|
|
906
|
+
function checkLiteralHost(url, source, policy) {
|
|
907
|
+
if (policy.allowInternalAddresses) return;
|
|
908
|
+
const host = url.hostname.replace(/^\[|\]$/g, "");
|
|
909
|
+
if (isIP(host) === 0) return;
|
|
910
|
+
if (isBlockedAddress(host)) throw unreadable(`refusing to fetch "${url.href}": ${host} is a loopback, private, link-local or otherwise internal address`, source);
|
|
911
|
+
}
|
|
912
|
+
const unreadable = (message, source, cause) => new TokenCssError(message, {
|
|
913
|
+
code: FailureCode.SOURCE_UNREADABLE,
|
|
914
|
+
source,
|
|
915
|
+
...cause === void 0 ? {} : { cause }
|
|
916
|
+
});
|
|
917
|
+
/** Rejects a URL whose scheme this adapter will not fetch. */
|
|
918
|
+
function checkScheme(url, allowInsecure, source) {
|
|
919
|
+
if (url.protocol === "https:") return;
|
|
920
|
+
if (url.protocol === "http:" && allowInsecure) return;
|
|
921
|
+
if (url.protocol === "http:") throw unreadable(`"${url.href}" uses http:. Tokens are fetched over https by default — pass http: { allowInsecure: true } if you really mean to read this over plain http`, source);
|
|
922
|
+
throw unreadable(`"${url.href}" uses the "${url.protocol}" scheme, which is not fetched`, source);
|
|
923
|
+
}
|
|
924
|
+
/** One hop: connect, check the address, read the body or the `Location`. */
|
|
925
|
+
function fetchOnce(attempt, policy) {
|
|
926
|
+
const { url, deadline, maxBytes, source } = attempt;
|
|
927
|
+
return new Promise((resolve, reject) => {
|
|
928
|
+
const remaining = deadline - Date.now();
|
|
929
|
+
if (remaining <= 0) {
|
|
930
|
+
reject(unreadable(`fetching "${source}" took too long`, source));
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
const request$2 = (url.protocol === "https:" ? request$1 : request)(url, { lookup: (hostname, options, callback) => {
|
|
934
|
+
lookup(hostname, {
|
|
935
|
+
...options,
|
|
936
|
+
all: true
|
|
937
|
+
}, (err, addresses) => {
|
|
938
|
+
if (err) {
|
|
939
|
+
callback(err, "", 4);
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
const internal = addresses.find((entry) => isBlockedAddress(entry.address));
|
|
943
|
+
if (internal !== void 0 && !policy.allowInternalAddresses) {
|
|
944
|
+
callback(/* @__PURE__ */ new Error(`"${hostname}" resolves to ${internal.address}, which is a loopback, private, link-local or otherwise internal address`), "", 4);
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
if (options.all === true) {
|
|
948
|
+
callback(null, addresses, 0);
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
const first = addresses[0];
|
|
952
|
+
if (first === void 0) {
|
|
953
|
+
callback(/* @__PURE__ */ new Error(`"${hostname}" resolved to no addresses`), "", 4);
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
callback(null, first.address, first.family);
|
|
957
|
+
});
|
|
958
|
+
} }, (response) => {
|
|
959
|
+
const status = response.statusCode ?? 0;
|
|
960
|
+
if (status >= 300 && status < 400) {
|
|
961
|
+
const location = response.headers.location;
|
|
962
|
+
response.resume();
|
|
963
|
+
if (location === void 0) {
|
|
964
|
+
reject(unreadable(`"${url.href}" redirected without saying where`, source));
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
resolve({
|
|
968
|
+
body: null,
|
|
969
|
+
redirectTo: new URL(location, url).href
|
|
970
|
+
});
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
if (status < 200 || status >= 300) {
|
|
974
|
+
response.resume();
|
|
975
|
+
reject(unreadable(`"${url.href}" answered ${status}`, source));
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
const chunks = [];
|
|
979
|
+
let size = 0;
|
|
980
|
+
response.on("data", (chunk) => {
|
|
981
|
+
size += chunk.length;
|
|
982
|
+
if (size > maxBytes) {
|
|
983
|
+
request$2.destroy();
|
|
984
|
+
reject(unreadable(`"${url.href}" is larger than the ${maxBytes} byte limit for a token document`, source));
|
|
985
|
+
return;
|
|
986
|
+
}
|
|
987
|
+
chunks.push(chunk);
|
|
988
|
+
});
|
|
989
|
+
response.on("end", () => resolve({
|
|
990
|
+
body: Buffer.concat(chunks),
|
|
991
|
+
redirectTo: null
|
|
992
|
+
}));
|
|
993
|
+
response.on("error", (err) => reject(unreadable(`reading "${url.href}" failed`, source, err)));
|
|
994
|
+
});
|
|
995
|
+
request$2.setTimeout(remaining, () => {
|
|
996
|
+
request$2.destroy();
|
|
997
|
+
reject(unreadable(`fetching "${source}" took longer than the time allowed`, source));
|
|
998
|
+
});
|
|
999
|
+
request$2.on("error", (err) => {
|
|
1000
|
+
if (/resolves to .* which is a/.test(err.message)) {
|
|
1001
|
+
reject(unreadable(`refusing to fetch "${url.href}": ${err.message}`, source, err));
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
reject(unreadable(`could not reach "${url.href}" (${err.code ?? err.message})`, source, err));
|
|
1005
|
+
});
|
|
1006
|
+
request$2.end();
|
|
1007
|
+
});
|
|
1008
|
+
}
|
|
1009
|
+
/**
|
|
1010
|
+
* Fetches a token document, following redirects under the guard.
|
|
1011
|
+
*
|
|
1012
|
+
* @returns The document text. The core never sees the URL — only bytes that
|
|
1013
|
+
* passed every check.
|
|
1014
|
+
* @throws {TokenCssError} `SOURCE_UNREADABLE` for every network failure, so a
|
|
1015
|
+
* caller can tell "I could not read it" from "I read it and it was wrong".
|
|
1016
|
+
*/
|
|
1017
|
+
async function fetchTokenDocument(url, source, options = {}) {
|
|
1018
|
+
return fetchWithPolicy(url, source, options, STRICT);
|
|
1019
|
+
}
|
|
1020
|
+
/** The same fetch, with the address rule supplied. Internal; see {@link AddressPolicy}. */
|
|
1021
|
+
async function fetchWithPolicy(url, source, options, policy) {
|
|
1022
|
+
const allowInsecure = options.allowInsecure ?? DEFAULTS.http.allowInsecure;
|
|
1023
|
+
const maxRedirects = options.maxRedirects ?? DEFAULTS.http.maxRedirects;
|
|
1024
|
+
const deadline = Date.now() + (options.timeoutMs ?? DEFAULTS.http.timeoutMs);
|
|
1025
|
+
const maxBytes = options.maxBytes ?? DEFAULTS.http.maxBytes;
|
|
1026
|
+
let current = url;
|
|
1027
|
+
for (let hop = 0; hop <= maxRedirects; hop++) {
|
|
1028
|
+
checkScheme(current, allowInsecure, source);
|
|
1029
|
+
checkLiteralHost(current, source, policy);
|
|
1030
|
+
const { body, redirectTo } = await fetchOnce({
|
|
1031
|
+
url: current,
|
|
1032
|
+
deadline,
|
|
1033
|
+
maxBytes,
|
|
1034
|
+
source
|
|
1035
|
+
}, policy);
|
|
1036
|
+
if (body !== null) return body.toString("utf8");
|
|
1037
|
+
current = new URL(redirectTo);
|
|
1038
|
+
}
|
|
1039
|
+
throw unreadable(`"${source}" redirected more than ${maxRedirects} times without arriving anywhere`, source);
|
|
1040
|
+
}
|
|
1041
|
+
//#endregion
|
|
1042
|
+
//#region src/source/resolve.ts
|
|
1043
|
+
/**
|
|
1044
|
+
* Working out what the caller pointed at (FR-1, FR-3).
|
|
1045
|
+
*
|
|
1046
|
+
* Path resolution happens here, once, at the edge. Everything inside the
|
|
1047
|
+
* pipeline sees absolute paths, so no pure stage has to know what the current
|
|
1048
|
+
* working directory is.
|
|
1049
|
+
*/
|
|
1050
|
+
/** Characters that make a path a pattern rather than a file. */
|
|
1051
|
+
const GLOB = /[*?[\]{}]/;
|
|
1052
|
+
/**
|
|
1053
|
+
* A scheme, but not a Windows drive letter.
|
|
1054
|
+
*
|
|
1055
|
+
* `C:\tokens.json` parses as a URL with protocol `c:`, so requiring at least
|
|
1056
|
+
* two characters keeps a Windows path a path.
|
|
1057
|
+
*/
|
|
1058
|
+
const SCHEME = /^([a-z][a-z0-9+.-]+):/i;
|
|
1059
|
+
const refuse = (message, source) => {
|
|
1060
|
+
throw new TokenCssError(message, {
|
|
1061
|
+
code: FailureCode.FORMAT_NOT_ALLOWED,
|
|
1062
|
+
source
|
|
1063
|
+
});
|
|
1064
|
+
};
|
|
1065
|
+
/**
|
|
1066
|
+
* Turns whatever the caller passed into an absolute path or a URL.
|
|
1067
|
+
*
|
|
1068
|
+
* @param source A path or a URL, as the caller wrote it.
|
|
1069
|
+
* @param baseDir What relative paths resolve against.
|
|
1070
|
+
* @throws {TokenCssError} `FORMAT_NOT_ALLOWED` for globs, unsupported schemes,
|
|
1071
|
+
* and anything that is not a single file.
|
|
1072
|
+
*/
|
|
1073
|
+
function resolveSource(source, baseDir) {
|
|
1074
|
+
const display = String(source);
|
|
1075
|
+
if (source instanceof URL) return fromUrl(source, display);
|
|
1076
|
+
if (SCHEME.exec(source)) {
|
|
1077
|
+
let url;
|
|
1078
|
+
try {
|
|
1079
|
+
url = new URL(source);
|
|
1080
|
+
} catch {
|
|
1081
|
+
return refuse(`"${display}" looks like a URL but cannot be parsed as one`, display);
|
|
1082
|
+
}
|
|
1083
|
+
return fromUrl(url, display);
|
|
1084
|
+
}
|
|
1085
|
+
if (GLOB.test(source)) refuse(`"${display}" looks like a pattern. This version converts one token file at a time — pass a single path, not a glob`, display);
|
|
1086
|
+
return {
|
|
1087
|
+
kind: "file",
|
|
1088
|
+
path: isAbsolute(source) ? source : resolve(baseDir, source)
|
|
1089
|
+
};
|
|
1090
|
+
}
|
|
1091
|
+
function fromUrl(url, display) {
|
|
1092
|
+
if (url.protocol === "file:") return {
|
|
1093
|
+
kind: "file",
|
|
1094
|
+
path: fileURLToPath(url)
|
|
1095
|
+
};
|
|
1096
|
+
if (url.protocol === "http:" || url.protocol === "https:") return {
|
|
1097
|
+
kind: "url",
|
|
1098
|
+
url
|
|
1099
|
+
};
|
|
1100
|
+
return refuse(`"${display}" uses the "${url.protocol}" scheme. This version reads a local path, an https: URL, or an http: URL when explicitly allowed`, display);
|
|
1101
|
+
}
|
|
1102
|
+
/** Where the stylesheet goes, resolved the same way as the input. */
|
|
1103
|
+
function resolveOutputPath(outDir, fileName, baseDir) {
|
|
1104
|
+
return resolve(isAbsolute(outDir) ? outDir : resolve(baseDir, outDir), fileName);
|
|
1105
|
+
}
|
|
1106
|
+
//#endregion
|
|
1107
|
+
//#region src/validate/alias-graph.ts
|
|
1108
|
+
/**
|
|
1109
|
+
* Alias Graph Validation (FR-15, FR-22, AD-5).
|
|
1110
|
+
*
|
|
1111
|
+
* This checks that the reference graph is sound. It never resolves anything:
|
|
1112
|
+
* "validate" here means *the edges make sense*, not *replace the edge with the
|
|
1113
|
+
* value it points at*. Emission keeps every reference as `var(--target)`.
|
|
1114
|
+
*
|
|
1115
|
+
* Two passes, in a fixed order, each exhaustive within its class. Dangling runs
|
|
1116
|
+
* first — running cycle detection over a graph with missing nodes reports
|
|
1117
|
+
* confusing half-cycles, and a developer with a typo should be told about the
|
|
1118
|
+
* typo rather than about a loop that only exists because of it.
|
|
1119
|
+
*
|
|
1120
|
+
* Every token has at most one outgoing edge — a value is either a literal or a
|
|
1121
|
+
* single reference — so the graph is a chain per token, and one linear sweep
|
|
1122
|
+
* finds every cycle.
|
|
1123
|
+
*/
|
|
1124
|
+
/** Where a token's reference points, or `undefined` when it holds a literal. */
|
|
1125
|
+
function targetOf(node) {
|
|
1126
|
+
return isRef(node.value) ? formatPath(node.value.path) : void 0;
|
|
1127
|
+
}
|
|
1128
|
+
/**
|
|
1129
|
+
* Reports every reference whose target is not a token in this document.
|
|
1130
|
+
*
|
|
1131
|
+
* A reference to a *group* is reported differently from a reference to nothing
|
|
1132
|
+
* at all: seeing "color.brand does not exist" when `color.brand` is visibly
|
|
1133
|
+
* there in the file is the kind of message that sends people to read library
|
|
1134
|
+
* source.
|
|
1135
|
+
*/
|
|
1136
|
+
function checkDangling(doc, byPath, source) {
|
|
1137
|
+
const problems = [];
|
|
1138
|
+
const offenders = [];
|
|
1139
|
+
for (const node of doc.tokens) {
|
|
1140
|
+
const target = targetOf(node);
|
|
1141
|
+
if (target === void 0 || byPath.has(target)) continue;
|
|
1142
|
+
const from = formatPath(node.path);
|
|
1143
|
+
offenders.push(from);
|
|
1144
|
+
const isGroup = [...byPath.keys()].some((known) => known.startsWith(`${target}.`));
|
|
1145
|
+
problems.push(isGroup ? `"${from}" references "${target}", which is a group of tokens rather than a token` : `"${from}" references "${target}", which does not exist`);
|
|
1146
|
+
}
|
|
1147
|
+
if (problems.length > 0) throw new TokenCssError(`${problems.length} ${problems.length === 1 ? "reference points" : "references point"} nowhere:\n ${problems.join("\n ")}`, {
|
|
1148
|
+
code: FailureCode.ALIAS_DANGLING,
|
|
1149
|
+
source,
|
|
1150
|
+
tokenPaths: offenders
|
|
1151
|
+
});
|
|
1152
|
+
}
|
|
1153
|
+
const UNVISITED = 0;
|
|
1154
|
+
const ON_PATH = 1;
|
|
1155
|
+
const SETTLED = 2;
|
|
1156
|
+
/**
|
|
1157
|
+
* Reports every cycle in the reference graph.
|
|
1158
|
+
*
|
|
1159
|
+
* Iterative rather than recursive: a document is allowed to be a chain of ten
|
|
1160
|
+
* thousand tokens, and that is a stack overflow rather than a clear failure if
|
|
1161
|
+
* this walks itself.
|
|
1162
|
+
*/
|
|
1163
|
+
function checkCycles(doc, byPath, source) {
|
|
1164
|
+
const state = /* @__PURE__ */ new Map();
|
|
1165
|
+
const cycles = [];
|
|
1166
|
+
for (const start of doc.tokens) {
|
|
1167
|
+
const startKey = formatPath(start.path);
|
|
1168
|
+
if (state.get(startKey) !== void 0) continue;
|
|
1169
|
+
const chain = [];
|
|
1170
|
+
let cursor = startKey;
|
|
1171
|
+
while (cursor !== void 0 && (state.get(cursor) ?? UNVISITED) === UNVISITED) {
|
|
1172
|
+
state.set(cursor, ON_PATH);
|
|
1173
|
+
chain.push(cursor);
|
|
1174
|
+
const node = byPath.get(cursor);
|
|
1175
|
+
cursor = node === void 0 ? void 0 : targetOf(node);
|
|
1176
|
+
}
|
|
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
|
+
}
|
|
1180
|
+
if (cycles.length > 0) {
|
|
1181
|
+
const described = cycles.map((cycle) => ` ${[...cycle, cycle[0]].join(" → ")}`);
|
|
1182
|
+
throw new TokenCssError(`${cycles.length} alias cycle${cycles.length === 1 ? "" : "s"} found:\n` + described.join("\n"), {
|
|
1183
|
+
code: FailureCode.ALIAS_CYCLE,
|
|
1184
|
+
source,
|
|
1185
|
+
tokenPaths: cycles.flat()
|
|
1186
|
+
});
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
/**
|
|
1190
|
+
* Validates the whole reference graph.
|
|
1191
|
+
*
|
|
1192
|
+
* @throws {TokenCssError} `ALIAS_DANGLING` or `ALIAS_CYCLE`, each listing every
|
|
1193
|
+
* offender of its class so one run tells the developer everything of that kind
|
|
1194
|
+
* that is wrong.
|
|
1195
|
+
*/
|
|
1196
|
+
function validateAliasGraph(doc, source) {
|
|
1197
|
+
const byPath = /* @__PURE__ */ new Map();
|
|
1198
|
+
for (const node of doc.tokens) byPath.set(formatPath(node.path), node);
|
|
1199
|
+
checkDangling(doc, byPath, source);
|
|
1200
|
+
checkCycles(doc, byPath, source);
|
|
1201
|
+
}
|
|
1202
|
+
//#endregion
|
|
1203
|
+
//#region src/validate/collisions.ts
|
|
1204
|
+
/**
|
|
1205
|
+
* Name collision detection (FR-21, AD-12).
|
|
1206
|
+
*
|
|
1207
|
+
* The naming rule is deliberately lossy — everything outside `[a-z0-9]`
|
|
1208
|
+
* collapses — so two different token paths can arrive at the same custom
|
|
1209
|
+
* property. `color.brand.primary` and `color.brand-primary` both become
|
|
1210
|
+
* `--color-brand-primary`, and so do `cafe` and `café`.
|
|
1211
|
+
*
|
|
1212
|
+
* That is fine, as long as it is never silent. Emitting both would let the
|
|
1213
|
+
* second declaration quietly win and ship a theme that reports success while a
|
|
1214
|
+
* token has vanished — the exact failure the Reliability requirement forbids.
|
|
1215
|
+
*
|
|
1216
|
+
* This runs on the **final emitted names**, not on the paths. Comparing paths
|
|
1217
|
+
* would find the identical ones and miss every collision the naming rule
|
|
1218
|
+
* itself creates, which are the ones nobody expects.
|
|
1219
|
+
*/
|
|
1220
|
+
/**
|
|
1221
|
+
* Checks that every token emits a distinct custom property.
|
|
1222
|
+
*
|
|
1223
|
+
* @throws {TokenCssError} `NAME_COLLISION` listing every colliding group, so
|
|
1224
|
+
* one run tells the developer about all of them.
|
|
1225
|
+
*/
|
|
1226
|
+
function validateNoCollisions(doc, source) {
|
|
1227
|
+
const byName = /* @__PURE__ */ new Map();
|
|
1228
|
+
for (const node of doc.tokens) {
|
|
1229
|
+
const name = customPropertyName(node.path, source);
|
|
1230
|
+
const paths = byName.get(name);
|
|
1231
|
+
if (paths === void 0) byName.set(name, [formatPath(node.path)]);
|
|
1232
|
+
else paths.push(formatPath(node.path));
|
|
1233
|
+
}
|
|
1234
|
+
const collisions = [...byName].filter(([, paths]) => paths.length > 1);
|
|
1235
|
+
if (collisions.length === 0) return;
|
|
1236
|
+
const described = collisions.map(([name, paths]) => ` ${name} ← ${paths.map((p) => `"${p}"`).join(", ")}`);
|
|
1237
|
+
throw new TokenCssError(`${collisions.length} custom ${collisions.length === 1 ? "property is" : "properties are"} claimed by more than one token:\n${described.join("\n")}\nRename one of each pair — this version will not pick a winner for you.`, {
|
|
1238
|
+
code: FailureCode.NAME_COLLISION,
|
|
1239
|
+
source,
|
|
1240
|
+
tokenPaths: collisions.flatMap(([, paths]) => paths)
|
|
1241
|
+
});
|
|
1242
|
+
}
|
|
1243
|
+
//#endregion
|
|
1244
|
+
//#region src/write/atomic.ts
|
|
1245
|
+
/**
|
|
1246
|
+
* Writing the stylesheet without ever leaving a half-written one (FR-2, FR-19, AD-7).
|
|
1247
|
+
*
|
|
1248
|
+
* The stylesheet is written to a temporary file beside the target and renamed
|
|
1249
|
+
* into place. `rename` within a directory is atomic, so a reader sees either
|
|
1250
|
+
* the old file or the new one — never a truncated one, whatever happens
|
|
1251
|
+
* mid-write.
|
|
1252
|
+
*
|
|
1253
|
+
* The temporary file goes in the **target directory**, not the system temp
|
|
1254
|
+
* directory: `rename` across filesystems is not atomic and often is not even
|
|
1255
|
+
* possible, and the two are frequently on different filesystems.
|
|
1256
|
+
*/
|
|
1257
|
+
function explain(code, targetPath) {
|
|
1258
|
+
switch (code) {
|
|
1259
|
+
case "EACCES":
|
|
1260
|
+
case "EPERM": return `permission was denied writing to "${targetPath}"`;
|
|
1261
|
+
case "ENOSPC": return `the disk is full`;
|
|
1262
|
+
case "EROFS": return `"${targetPath}" is on a read-only filesystem`;
|
|
1263
|
+
case "ENOTDIR": return `part of the path to "${targetPath}" is a file, not a directory`;
|
|
1264
|
+
default: return `writing "${targetPath}" failed (${code ?? "unknown error"})`;
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
/**
|
|
1268
|
+
* Writes the stylesheet, replacing whatever was there.
|
|
1269
|
+
*
|
|
1270
|
+
* Creates the output directory if it does not exist. On any failure the
|
|
1271
|
+
* temporary file is removed and the previous stylesheet is left exactly as it
|
|
1272
|
+
* was — a failed run never costs you the last good output.
|
|
1273
|
+
*
|
|
1274
|
+
* @throws {TokenCssError} `OUTPUT_WRITE_FAILED`, naming the reason.
|
|
1275
|
+
*/
|
|
1276
|
+
async function writeStylesheet(targetPath, contents, source) {
|
|
1277
|
+
const directory = dirname(targetPath);
|
|
1278
|
+
const temporary = join(directory, `.${randomUUID()}.tmp`);
|
|
1279
|
+
try {
|
|
1280
|
+
await mkdir(directory, { recursive: true });
|
|
1281
|
+
const handle = await open(temporary, "wx");
|
|
1282
|
+
try {
|
|
1283
|
+
await handle.writeFile(contents, "utf8");
|
|
1284
|
+
await handle.sync();
|
|
1285
|
+
} finally {
|
|
1286
|
+
await handle.close();
|
|
1287
|
+
}
|
|
1288
|
+
await rename(temporary, targetPath);
|
|
1289
|
+
} catch (err) {
|
|
1290
|
+
await unlink(temporary).catch(() => {});
|
|
1291
|
+
throw new TokenCssError(explain(err.code, targetPath), {
|
|
1292
|
+
code: FailureCode.OUTPUT_WRITE_FAILED,
|
|
1293
|
+
source,
|
|
1294
|
+
cause: err
|
|
1295
|
+
});
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
//#endregion
|
|
1299
|
+
//#region src/pipeline.ts
|
|
1300
|
+
/**
|
|
1301
|
+
* The conversion pipeline (AD-1, AD-5, AD-6).
|
|
1302
|
+
*
|
|
1303
|
+
* Six stages, in one fixed order, sequenced only here. Reading and writing
|
|
1304
|
+
* happen at the two ends; everything between them is a pure function of what
|
|
1305
|
+
* the stage before it returned.
|
|
1306
|
+
*
|
|
1307
|
+
* Nothing touches the output path until the whole stylesheet exists in memory
|
|
1308
|
+
* and every check has passed. That is what makes "it failed" and "your previous
|
|
1309
|
+
* stylesheet is intact" the same sentence.
|
|
1310
|
+
*/
|
|
1311
|
+
/**
|
|
1312
|
+
* Everything between reading and writing: detect, normalize, validate, emit.
|
|
1313
|
+
*
|
|
1314
|
+
* Exported so the fixture corpus exercises the real stage order rather than
|
|
1315
|
+
* re-implementing it. A corpus that sequenced the passes itself could stay green
|
|
1316
|
+
* while the pipeline ran them in a different order, which is precisely the
|
|
1317
|
+
* divergence the fixed order exists to prevent.
|
|
1318
|
+
*/
|
|
1319
|
+
function convertDocument(raw, source) {
|
|
1320
|
+
const doc = normalizeDocument(raw, source);
|
|
1321
|
+
validateAliasGraph(doc, source);
|
|
1322
|
+
validateNoCollisions(doc, source);
|
|
1323
|
+
return {
|
|
1324
|
+
css: emitStylesheet(doc, source),
|
|
1325
|
+
tokenCount: doc.tokens.length
|
|
1326
|
+
};
|
|
1327
|
+
}
|
|
1328
|
+
async function runConversion(source, options = {}) {
|
|
1329
|
+
const display = String(source);
|
|
1330
|
+
const baseDir = options.baseDir ?? process.cwd();
|
|
1331
|
+
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);
|
|
1333
|
+
const outputPath = resolveOutputPath(options.outDir ?? DEFAULTS.outDir, options.fileName ?? DEFAULTS.fileName, baseDir);
|
|
1334
|
+
await writeStylesheet(outputPath, css, display);
|
|
1335
|
+
return {
|
|
1336
|
+
outputPath,
|
|
1337
|
+
tokenCount
|
|
1338
|
+
};
|
|
1339
|
+
}
|
|
1340
|
+
//#endregion
|
|
1341
|
+
//#region src/index.ts
|
|
1342
|
+
/**
|
|
1343
|
+
* tokens-to-css — the public surface (AD-14).
|
|
1344
|
+
*
|
|
1345
|
+
* Everything a caller can reach is declared here. Nothing else in `src/` is
|
|
1346
|
+
* exported, and `package.json` publishes no subpaths, so internals stay free to
|
|
1347
|
+
* move without a major version.
|
|
1348
|
+
*/
|
|
1349
|
+
/**
|
|
1350
|
+
* Convert a design-token document into a CSS custom-properties stylesheet.
|
|
1351
|
+
*
|
|
1352
|
+
* Reads the Token Source, validates it completely, and writes the stylesheet —
|
|
1353
|
+
* or throws a `TokenCssError` and writes nothing at all. There is no partial
|
|
1354
|
+
* success: a previous stylesheet at the target path is left untouched whenever
|
|
1355
|
+
* the conversion fails.
|
|
1356
|
+
*
|
|
1357
|
+
* @param source Path to a single local file, or a URL.
|
|
1358
|
+
* @param options Output location and network policy.
|
|
1359
|
+
* @returns Where the stylesheet was written, and how many properties it holds.
|
|
1360
|
+
* @throws {TokenCssError} With a `code` naming the failure class.
|
|
1361
|
+
*/
|
|
1362
|
+
function generateCss(source, options) {
|
|
1363
|
+
return runConversion(source, options);
|
|
1364
|
+
}
|
|
1365
|
+
//#endregion
|
|
1366
|
+
export { DEFAULTS, FailureCode, TokenCssError, generateCss };
|