@terrazzo/parser 0.10.3 → 2.0.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/dist/index.d.ts +82 -333
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2203 -3660
- package/dist/index.js.map +1 -1
- package/package.json +6 -5
- package/src/build/index.ts +32 -41
- package/src/config.ts +13 -6
- package/src/lib/code-frame.ts +5 -2
- package/src/lib/momoa.ts +10 -0
- package/src/lint/index.ts +41 -37
- package/src/lint/plugin-core/index.ts +73 -16
- package/src/lint/plugin-core/rules/colorspace.ts +4 -0
- package/src/lint/plugin-core/rules/duplicate-values.ts +2 -0
- package/src/lint/plugin-core/rules/max-gamut.ts +24 -4
- package/src/lint/plugin-core/rules/no-type-on-alias.ts +29 -0
- package/src/lint/plugin-core/rules/required-modes.ts +2 -0
- package/src/lint/plugin-core/rules/required-typography-properties.ts +13 -3
- package/src/lint/plugin-core/rules/valid-boolean.ts +41 -0
- package/src/lint/plugin-core/rules/valid-border.ts +57 -0
- package/src/lint/plugin-core/rules/valid-color.ts +265 -0
- package/src/lint/plugin-core/rules/valid-cubic-bezier.ts +83 -0
- package/src/lint/plugin-core/rules/valid-dimension.ts +199 -0
- package/src/lint/plugin-core/rules/valid-duration.ts +123 -0
- package/src/lint/plugin-core/rules/valid-font-family.ts +68 -0
- package/src/lint/plugin-core/rules/valid-font-weight.ts +89 -0
- package/src/lint/plugin-core/rules/valid-gradient.ts +79 -0
- package/src/lint/plugin-core/rules/valid-link.ts +41 -0
- package/src/lint/plugin-core/rules/valid-number.ts +63 -0
- package/src/lint/plugin-core/rules/valid-shadow.ts +67 -0
- package/src/lint/plugin-core/rules/valid-string.ts +41 -0
- package/src/lint/plugin-core/rules/valid-stroke-style.ts +104 -0
- package/src/lint/plugin-core/rules/valid-transition.ts +61 -0
- package/src/lint/plugin-core/rules/valid-typography.ts +67 -0
- package/src/logger.ts +70 -59
- package/src/parse/index.ts +23 -318
- package/src/parse/load.ts +257 -0
- package/src/parse/normalize.ts +134 -170
- package/src/parse/token.ts +530 -0
- package/src/types.ts +76 -10
- package/src/parse/alias.ts +0 -369
- package/src/parse/json.ts +0 -211
- package/src/parse/validate.ts +0 -961
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
import * as momoa from '@humanwhocodes/momoa';
|
|
2
|
+
import { getObjMember, parseRef, type RefMap } from '@terrazzo/json-schema-tools';
|
|
3
|
+
import {
|
|
4
|
+
type GroupNormalized,
|
|
5
|
+
isAlias,
|
|
6
|
+
parseAlias,
|
|
7
|
+
type TokenNormalized,
|
|
8
|
+
type TokenNormalizedSet,
|
|
9
|
+
} from '@terrazzo/token-tools';
|
|
10
|
+
import wcmatch from 'wildcard-match';
|
|
11
|
+
import type { default as Logger } from '../logger.js';
|
|
12
|
+
import type { Config, InputSource, ReferenceObject } from '../types.js';
|
|
13
|
+
|
|
14
|
+
/** Convert valid DTCG alias to $ref */
|
|
15
|
+
export function aliasToRef(alias: string, mode?: string): ReferenceObject | undefined {
|
|
16
|
+
const id = parseAlias(alias);
|
|
17
|
+
// if this is invalid, stop
|
|
18
|
+
if (id === alias) {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
$ref: `#/${id.replace(/~/g, '~0').replace(/\//g, '~1').replace(/\./g, '/')}${mode && mode !== '.' ? `/$extensions/mode/${mode}` : ''}/$value`,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface TokenFromNodeOptions {
|
|
27
|
+
groups: Record<string, GroupNormalized>;
|
|
28
|
+
path: string[];
|
|
29
|
+
source: InputSource;
|
|
30
|
+
ignore: Config['ignore'];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Generate a TokenNormalized from a Momoa node */
|
|
34
|
+
export function tokenFromNode(
|
|
35
|
+
node: momoa.AnyNode,
|
|
36
|
+
{ groups, path, source, ignore }: TokenFromNodeOptions,
|
|
37
|
+
): TokenNormalized | undefined {
|
|
38
|
+
const isToken = node.type === 'Object' && getObjMember(node, '$value') && !path.includes('$extensions');
|
|
39
|
+
if (!isToken) {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const jsonID = `#/${path.join('/')}`;
|
|
44
|
+
const id = path.join('.');
|
|
45
|
+
|
|
46
|
+
const originalToken = momoa.evaluate(node) as any;
|
|
47
|
+
|
|
48
|
+
const groupID = `#/${path.slice(0, -1).join('/')}`;
|
|
49
|
+
const group = groups[groupID]!;
|
|
50
|
+
if (group?.tokens && !group.tokens.includes(id)) {
|
|
51
|
+
group.tokens.push(id);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const nodeSource = { filename: source.filename?.href, node };
|
|
55
|
+
const token: TokenNormalized = {
|
|
56
|
+
id,
|
|
57
|
+
$type: originalToken.$type || group.$type,
|
|
58
|
+
$description: originalToken.$description || undefined,
|
|
59
|
+
$deprecated: originalToken.$deprecated ?? group.$deprecated ?? undefined, // ⚠️ MUST use ?? here to inherit false correctly
|
|
60
|
+
$value: originalToken.$value,
|
|
61
|
+
$extensions: originalToken.$extensions || undefined,
|
|
62
|
+
aliasChain: undefined,
|
|
63
|
+
aliasedBy: undefined,
|
|
64
|
+
aliasOf: undefined,
|
|
65
|
+
partialAliasOf: undefined,
|
|
66
|
+
dependencies: undefined,
|
|
67
|
+
group,
|
|
68
|
+
originalValue: undefined, // undefined because we are not sure if the value has been modified or not
|
|
69
|
+
source: nodeSource,
|
|
70
|
+
jsonID,
|
|
71
|
+
mode: {
|
|
72
|
+
'.': {
|
|
73
|
+
$value: originalToken.$value,
|
|
74
|
+
aliasOf: undefined,
|
|
75
|
+
aliasChain: undefined,
|
|
76
|
+
partialAliasOf: undefined,
|
|
77
|
+
aliasedBy: undefined,
|
|
78
|
+
originalValue: undefined,
|
|
79
|
+
dependencies: undefined,
|
|
80
|
+
source: {
|
|
81
|
+
...nodeSource,
|
|
82
|
+
node: (getObjMember(nodeSource.node, '$value') ?? nodeSource.node) as momoa.ObjectNode,
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// after assembling token, handle ignores to see if the final result should be ignored or not
|
|
89
|
+
// filter out ignored
|
|
90
|
+
if ((ignore?.deprecated && token.$deprecated) || (ignore?.tokens && wcmatch(ignore.tokens)(token.id))) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const $extensions = getObjMember(node, '$extensions');
|
|
95
|
+
if ($extensions) {
|
|
96
|
+
const modeNode = getObjMember($extensions as momoa.ObjectNode, 'mode') as momoa.ObjectNode;
|
|
97
|
+
for (const mode of Object.keys((token.$extensions as any).mode)) {
|
|
98
|
+
const modeValue = (token.$extensions as any).mode[mode];
|
|
99
|
+
token.mode[mode] = {
|
|
100
|
+
$value: modeValue,
|
|
101
|
+
aliasOf: undefined,
|
|
102
|
+
aliasChain: undefined,
|
|
103
|
+
partialAliasOf: undefined,
|
|
104
|
+
aliasedBy: undefined,
|
|
105
|
+
originalValue: undefined,
|
|
106
|
+
dependencies: undefined,
|
|
107
|
+
source: {
|
|
108
|
+
...nodeSource,
|
|
109
|
+
node: getObjMember(modeNode, mode) as any,
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return token;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface TokenRawValues {
|
|
118
|
+
jsonID: string;
|
|
119
|
+
originalValue: any;
|
|
120
|
+
source: TokenNormalized['source'];
|
|
121
|
+
mode: Record<string, { originalValue: any; source: TokenNormalized['source'] }>;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Generate originalValue and source from node */
|
|
125
|
+
export function tokenRawValuesFromNode(
|
|
126
|
+
node: momoa.AnyNode,
|
|
127
|
+
{ filename, path }: { filename: string; path: string[] },
|
|
128
|
+
): TokenRawValues | undefined {
|
|
129
|
+
const isToken = node.type === 'Object' && getObjMember(node, '$value') && !path.includes('$extensions');
|
|
130
|
+
if (!isToken) {
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const jsonID = `#/${path.join('/')}`;
|
|
135
|
+
const rawValues: TokenRawValues = {
|
|
136
|
+
jsonID,
|
|
137
|
+
originalValue: momoa.evaluate(node),
|
|
138
|
+
source: { loc: filename, filename, node: node as momoa.ObjectNode },
|
|
139
|
+
mode: {},
|
|
140
|
+
};
|
|
141
|
+
rawValues.mode['.'] = {
|
|
142
|
+
originalValue: rawValues.originalValue.$value,
|
|
143
|
+
source: { ...rawValues.source, node: getObjMember(node as momoa.ObjectNode, '$value') as momoa.ObjectNode },
|
|
144
|
+
};
|
|
145
|
+
const $extensions = getObjMember(node, '$extensions');
|
|
146
|
+
if ($extensions) {
|
|
147
|
+
const modes = getObjMember($extensions as momoa.ObjectNode, 'mode');
|
|
148
|
+
if (modes) {
|
|
149
|
+
for (const modeMember of (modes as momoa.ObjectNode).members) {
|
|
150
|
+
const mode = (modeMember.name as momoa.StringNode).value;
|
|
151
|
+
rawValues.mode[mode] = {
|
|
152
|
+
originalValue: momoa.evaluate(modeMember.value),
|
|
153
|
+
source: { loc: filename, filename, node: modeMember.value as momoa.ObjectNode },
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return rawValues;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Arbitrary keys that should be associated with a token group */
|
|
163
|
+
const GROUP_PROPERTIES = ['$deprecated', '$description', '$extensions', '$type'];
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Generate a group from a node.
|
|
167
|
+
* This method mutates the groups index as it goes because of group inheritance.
|
|
168
|
+
* As it encounters new groups it may have to update other groups.
|
|
169
|
+
*/
|
|
170
|
+
export function groupFromNode(
|
|
171
|
+
node: momoa.ObjectNode,
|
|
172
|
+
{ path, groups }: { path: string[]; groups: Record<string, GroupNormalized> },
|
|
173
|
+
): GroupNormalized {
|
|
174
|
+
const id = path.join('.');
|
|
175
|
+
const jsonID = `#/${path.join('/')}`;
|
|
176
|
+
|
|
177
|
+
// group
|
|
178
|
+
if (!groups[jsonID]) {
|
|
179
|
+
groups[jsonID] = {
|
|
180
|
+
id,
|
|
181
|
+
$deprecated: undefined,
|
|
182
|
+
$description: undefined,
|
|
183
|
+
$extensions: undefined,
|
|
184
|
+
$type: undefined,
|
|
185
|
+
tokens: [],
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// first, copy all parent groups’ properties into local, since they cascade
|
|
190
|
+
const groupIDs = Object.keys(groups);
|
|
191
|
+
groupIDs.sort(); // these may not be sorted; re-sort just in case (order determines final values)
|
|
192
|
+
for (const groupID of groupIDs) {
|
|
193
|
+
const isParentGroup = jsonID.startsWith(groupID) && groupID !== jsonID;
|
|
194
|
+
if (isParentGroup) {
|
|
195
|
+
groups[jsonID].$deprecated = groups[groupID]?.$deprecated ?? groups[jsonID].$deprecated;
|
|
196
|
+
groups[jsonID].$description = groups[groupID]?.$description ?? groups[jsonID].$description;
|
|
197
|
+
groups[jsonID].$type = groups[groupID]?.$type ?? groups[jsonID].$type;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// next, override cascading values with local
|
|
202
|
+
for (const m of node.members) {
|
|
203
|
+
if (m.name.type !== 'String' || !GROUP_PROPERTIES.includes(m.name.value)) {
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
(groups as any)[jsonID]![m.name.value] = momoa.evaluate(m.value);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return groups[jsonID]!;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export interface GraphAliasesOptions {
|
|
213
|
+
tokens: TokenNormalizedSet;
|
|
214
|
+
sources: Record<string, InputSource>;
|
|
215
|
+
logger: Logger;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Link and reverse-link tokens in one pass.
|
|
220
|
+
*/
|
|
221
|
+
export function graphAliases(refMap: RefMap, { tokens, logger, sources }: GraphAliasesOptions) {
|
|
222
|
+
// mini-helper that probably shouldn’t be used outside this function
|
|
223
|
+
const getTokenRef = (ref: string) => ref.replace(/\/(\$value|\$extensions)\/?.*/, '');
|
|
224
|
+
|
|
225
|
+
for (const [jsonID, { refChain }] of Object.entries(refMap)) {
|
|
226
|
+
if (!refChain.length) {
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const mode = jsonID.match(/\/\$extensions\/mode\/([^/]+)/)?.[1] || '.';
|
|
231
|
+
const rootRef = getTokenRef(jsonID);
|
|
232
|
+
const modeValue = tokens[rootRef]?.mode[mode];
|
|
233
|
+
if (!modeValue) {
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// aliasChain + dependencies
|
|
238
|
+
if (!modeValue.dependencies) {
|
|
239
|
+
modeValue.dependencies = [];
|
|
240
|
+
}
|
|
241
|
+
modeValue.dependencies.push(...refChain.filter((r) => !modeValue.dependencies!.includes(r)));
|
|
242
|
+
modeValue.dependencies.sort((a, b) => a.localeCompare(b, 'en-us', { numeric: true }));
|
|
243
|
+
|
|
244
|
+
// Top alias
|
|
245
|
+
const isTopLevelAlias = jsonID.endsWith('/$value') || tokens[jsonID];
|
|
246
|
+
if (isTopLevelAlias) {
|
|
247
|
+
modeValue.aliasOf = refToTokenID(refChain.at(-1)!);
|
|
248
|
+
const aliasChain = refChain.map(refToTokenID) as string[];
|
|
249
|
+
modeValue.aliasChain = [...aliasChain];
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Partial alias
|
|
253
|
+
const partial = jsonID
|
|
254
|
+
.replace(/.*\/\$value\/?/, '')
|
|
255
|
+
.split('/')
|
|
256
|
+
.filter(Boolean);
|
|
257
|
+
if (partial.length && modeValue.$value && typeof modeValue.$value === 'object') {
|
|
258
|
+
let node: any = modeValue.$value;
|
|
259
|
+
let sourceNode = modeValue.source.node as momoa.AnyNode;
|
|
260
|
+
if (!modeValue.partialAliasOf) {
|
|
261
|
+
modeValue.partialAliasOf = Array.isArray(modeValue.$value) || tokens[rootRef]?.$type === 'shadow' ? [] : {};
|
|
262
|
+
}
|
|
263
|
+
let partialAliasOf = modeValue.partialAliasOf as any;
|
|
264
|
+
// special case: for shadows, normalize object to array
|
|
265
|
+
if (tokens[rootRef]?.$type === 'shadow' && !Array.isArray(node)) {
|
|
266
|
+
if (Array.isArray(modeValue.partialAliasOf) && !modeValue.partialAliasOf.length) {
|
|
267
|
+
modeValue.partialAliasOf.push({} as any);
|
|
268
|
+
}
|
|
269
|
+
partialAliasOf = (modeValue.partialAliasOf as any)[0]!;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
for (let i = 0; i < partial.length; i++) {
|
|
273
|
+
let key = partial[i] as string | number;
|
|
274
|
+
if (String(Number(key)) === key) {
|
|
275
|
+
key = Number(key);
|
|
276
|
+
}
|
|
277
|
+
if (key in node && typeof node[key] !== 'undefined') {
|
|
278
|
+
node = node[key];
|
|
279
|
+
if (sourceNode.type === 'Object') {
|
|
280
|
+
sourceNode = getObjMember(sourceNode, key as string) ?? sourceNode;
|
|
281
|
+
} else if (sourceNode.type === 'Array') {
|
|
282
|
+
sourceNode = sourceNode.elements[key as number]?.value ?? sourceNode;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
// last node: apply partial alias
|
|
286
|
+
if (i === partial.length - 1) {
|
|
287
|
+
const aliasedID = getTokenRef(refChain.at(-1)!);
|
|
288
|
+
if (!(aliasedID in tokens)) {
|
|
289
|
+
logger.error({
|
|
290
|
+
group: 'parser',
|
|
291
|
+
label: 'init',
|
|
292
|
+
message: `Invalid alias: ${aliasedID}`,
|
|
293
|
+
node: sourceNode,
|
|
294
|
+
src: sources[tokens[rootRef]!.source.filename!]?.src,
|
|
295
|
+
});
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
partialAliasOf[key] = refToTokenID(aliasedID);
|
|
299
|
+
}
|
|
300
|
+
// otherwise, create deeper structure and continue traversing
|
|
301
|
+
if (!(key in partialAliasOf)) {
|
|
302
|
+
partialAliasOf[key] = Array.isArray(node) ? [] : {};
|
|
303
|
+
}
|
|
304
|
+
partialAliasOf = partialAliasOf[key];
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// aliasedBy (reversed)
|
|
309
|
+
const aliasedByRefs = [jsonID, ...refChain].reverse();
|
|
310
|
+
for (let i = 0; i < aliasedByRefs.length; i++) {
|
|
311
|
+
const baseRef = getTokenRef(aliasedByRefs[i]!);
|
|
312
|
+
const baseToken = tokens[baseRef]?.mode[mode] || tokens[baseRef];
|
|
313
|
+
if (!baseToken) {
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
const upstream = aliasedByRefs.slice(i + 1);
|
|
317
|
+
if (!upstream.length) {
|
|
318
|
+
break;
|
|
319
|
+
}
|
|
320
|
+
if (!baseToken.aliasedBy) {
|
|
321
|
+
baseToken.aliasedBy = [];
|
|
322
|
+
}
|
|
323
|
+
for (let j = 0; j < upstream.length; j++) {
|
|
324
|
+
const downstream = refToTokenID(upstream[j]!)!;
|
|
325
|
+
if (!baseToken.aliasedBy.includes(downstream)) {
|
|
326
|
+
baseToken.aliasedBy.push(downstream);
|
|
327
|
+
if (mode === '.') {
|
|
328
|
+
tokens[baseRef]!.aliasedBy = baseToken.aliasedBy;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
baseToken.aliasedBy.sort((a, b) => a.localeCompare(b, 'en-us', { numeric: true })); // sort, because the ordering is arbitrary and flaky
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (mode === '.') {
|
|
336
|
+
tokens[rootRef]!.aliasChain = modeValue.aliasChain;
|
|
337
|
+
tokens[rootRef]!.aliasedBy = modeValue.aliasedBy;
|
|
338
|
+
tokens[rootRef]!.aliasOf = modeValue.aliasOf;
|
|
339
|
+
tokens[rootRef]!.dependencies = modeValue.dependencies;
|
|
340
|
+
tokens[rootRef]!.partialAliasOf = modeValue.partialAliasOf;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Convert valid DTCG alias to $ref Momoa Node */
|
|
346
|
+
export function aliasToMomoa(
|
|
347
|
+
alias: string,
|
|
348
|
+
loc: momoa.ObjectNode['loc'] = {
|
|
349
|
+
start: { line: -1, column: -1, offset: 0 },
|
|
350
|
+
end: { line: -1, column: -1, offset: 0 },
|
|
351
|
+
},
|
|
352
|
+
): momoa.ObjectNode | undefined {
|
|
353
|
+
const $ref = aliasToRef(alias);
|
|
354
|
+
if (!$ref) {
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
return {
|
|
358
|
+
type: 'Object',
|
|
359
|
+
members: [
|
|
360
|
+
{
|
|
361
|
+
type: 'Member',
|
|
362
|
+
name: { type: 'String', value: '$ref', loc },
|
|
363
|
+
value: { type: 'String', value: $ref.$ref, loc },
|
|
364
|
+
loc,
|
|
365
|
+
},
|
|
366
|
+
],
|
|
367
|
+
loc,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Convert Reference Object to token ID.
|
|
373
|
+
* This can then be turned into an alias by surrounding with { … }
|
|
374
|
+
* ⚠️ This is not mode-aware. This will flatten multiple modes into the same root token.
|
|
375
|
+
*/
|
|
376
|
+
export function refToTokenID($ref: ReferenceObject | string): string | undefined {
|
|
377
|
+
const path = typeof $ref === 'object' ? $ref.$ref : $ref;
|
|
378
|
+
if (typeof path !== 'string') {
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
const { subpath } = parseRef(path);
|
|
382
|
+
return (subpath?.length && subpath.join('.').replace(/\.(\$value|\$extensions).*$/, '')) || undefined;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const EXPECTED_NESTED_ALIAS: Record<string, Record<string, string[]>> = {
|
|
386
|
+
border: {
|
|
387
|
+
color: ['color'],
|
|
388
|
+
stroke: ['strokeStyle'],
|
|
389
|
+
width: ['dimension'],
|
|
390
|
+
},
|
|
391
|
+
gradient: {
|
|
392
|
+
color: ['color'],
|
|
393
|
+
position: ['number'],
|
|
394
|
+
},
|
|
395
|
+
shadow: {
|
|
396
|
+
color: ['color'],
|
|
397
|
+
offsetX: ['dimension'],
|
|
398
|
+
offsetY: ['dimension'],
|
|
399
|
+
blur: ['dimension'],
|
|
400
|
+
spread: ['dimension'],
|
|
401
|
+
inset: ['boolean'],
|
|
402
|
+
},
|
|
403
|
+
strokeStyle: {
|
|
404
|
+
dashArray: ['dimension'],
|
|
405
|
+
},
|
|
406
|
+
transition: {
|
|
407
|
+
duration: ['duration'],
|
|
408
|
+
delay: ['duration'],
|
|
409
|
+
timingFunction: ['cubicBezier'],
|
|
410
|
+
},
|
|
411
|
+
typography: {
|
|
412
|
+
fontFamily: ['fontFamily'],
|
|
413
|
+
fontWeight: ['fontWeight'],
|
|
414
|
+
fontSize: ['dimension'],
|
|
415
|
+
lineHeight: ['dimension', 'number'],
|
|
416
|
+
letterSpacing: ['dimension'],
|
|
417
|
+
},
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Resolve DTCG aliases
|
|
422
|
+
*/
|
|
423
|
+
export function resolveAliases(
|
|
424
|
+
tokens: TokenNormalizedSet,
|
|
425
|
+
{ logger, refMap, sources }: { logger: Logger; refMap: RefMap; sources: Record<string, InputSource> },
|
|
426
|
+
): void {
|
|
427
|
+
for (const token of Object.values(tokens)) {
|
|
428
|
+
const aliasEntry = {
|
|
429
|
+
group: 'parser' as const,
|
|
430
|
+
label: 'init',
|
|
431
|
+
src: sources[token.source.filename!]?.src,
|
|
432
|
+
node: getObjMember(token.source.node, '$value'),
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
for (const mode of Object.keys(token.mode)) {
|
|
436
|
+
function resolveInner(alias: string, refChain: string[]): string {
|
|
437
|
+
const nextRef = aliasToRef(alias, mode)?.$ref!;
|
|
438
|
+
if (refChain.includes(nextRef)) {
|
|
439
|
+
logger.error({ ...aliasEntry, message: 'Circular alias detected.' });
|
|
440
|
+
}
|
|
441
|
+
const nextJSONID = nextRef.replace(/\/(\$value|\$extensions).*/, '');
|
|
442
|
+
const nextToken = tokens[nextJSONID]?.mode[mode] || tokens[nextJSONID]?.mode['.'];
|
|
443
|
+
if (!nextToken) {
|
|
444
|
+
logger.error({ ...aliasEntry, message: `Could not resolve alias ${alias}.` });
|
|
445
|
+
}
|
|
446
|
+
refChain.push(nextRef);
|
|
447
|
+
if (isAlias(nextToken!.originalValue! as string)) {
|
|
448
|
+
return resolveInner(nextToken!.originalValue! as string, refChain);
|
|
449
|
+
}
|
|
450
|
+
return nextJSONID;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function traverseAndResolve(
|
|
454
|
+
value: any,
|
|
455
|
+
{ node, expectedTypes, path }: { node: momoa.AnyNode; expectedTypes?: string[]; path: (string | number)[] },
|
|
456
|
+
): any {
|
|
457
|
+
if (typeof value !== 'string') {
|
|
458
|
+
if (Array.isArray(value)) {
|
|
459
|
+
for (let i = 0; i < value.length; i++) {
|
|
460
|
+
if (!value[i]) {
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
value[i] = traverseAndResolve(value[i], {
|
|
464
|
+
node: (node as momoa.ArrayNode).elements?.[i]?.value!,
|
|
465
|
+
// special case: cubicBezier
|
|
466
|
+
expectedTypes: expectedTypes?.includes('cubicBezier') ? ['number'] : expectedTypes,
|
|
467
|
+
path: [...path, i],
|
|
468
|
+
}).$value;
|
|
469
|
+
}
|
|
470
|
+
} else if (typeof value === 'object') {
|
|
471
|
+
for (const key of Object.keys(value)) {
|
|
472
|
+
if (!expectedTypes?.length || !EXPECTED_NESTED_ALIAS[expectedTypes[0]!]) {
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
value[key] = traverseAndResolve(value[key], {
|
|
476
|
+
node: getObjMember(node as momoa.ObjectNode, key)!,
|
|
477
|
+
expectedTypes: EXPECTED_NESTED_ALIAS[expectedTypes[0]!]![key],
|
|
478
|
+
path: [...path, key],
|
|
479
|
+
}).$value;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
return { $value: value };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
if (!isAlias(value)) {
|
|
486
|
+
if (!expectedTypes?.includes('string') && (value.includes('{') || value.includes('}'))) {
|
|
487
|
+
logger.error({ ...aliasEntry, message: 'Invalid alias syntax.', node });
|
|
488
|
+
}
|
|
489
|
+
return { $value: value };
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const refChain: string[] = [];
|
|
493
|
+
const resolvedID = resolveInner(value, refChain);
|
|
494
|
+
if (expectedTypes?.length && !expectedTypes.includes(tokens[resolvedID]!.$type)) {
|
|
495
|
+
logger.error({
|
|
496
|
+
...aliasEntry,
|
|
497
|
+
message: `Cannot alias to $type "${tokens[resolvedID]!.$type}" from $type "${expectedTypes.join(' / ')}".`,
|
|
498
|
+
node,
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
refMap[path.join('/')] = { filename: token.source.filename!, refChain };
|
|
503
|
+
|
|
504
|
+
return {
|
|
505
|
+
$type: tokens[resolvedID]!.$type,
|
|
506
|
+
$value: tokens[resolvedID]!.mode[mode]?.$value || tokens[resolvedID]!.$value,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// resolve DTCG aliases without
|
|
511
|
+
const pathBase = mode === '.' ? token.jsonID : `${token.jsonID}/$extensions/mode/${mode}`;
|
|
512
|
+
const { $type, $value } = traverseAndResolve(token.mode[mode]!.$value, {
|
|
513
|
+
node: aliasEntry.node!,
|
|
514
|
+
expectedTypes: token.$type ? [token.$type] : undefined,
|
|
515
|
+
path: [pathBase, '$value'],
|
|
516
|
+
});
|
|
517
|
+
if (!token.$type) {
|
|
518
|
+
(token as any).$type = $type;
|
|
519
|
+
}
|
|
520
|
+
if ($value) {
|
|
521
|
+
token.mode[mode]!.$value = $value;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// fill in $type and $value
|
|
525
|
+
if (mode === '.') {
|
|
526
|
+
token.$value = token.mode[mode]!.$value;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import type * as momoa from '@humanwhocodes/momoa';
|
|
2
2
|
import type { TokenNormalized } from '@terrazzo/token-tools';
|
|
3
|
+
import type ytm from 'yaml-to-momoa';
|
|
3
4
|
import type Logger from './logger.js';
|
|
4
5
|
|
|
5
6
|
export interface BuildHookOptions {
|
|
@@ -68,6 +69,40 @@ export interface Config {
|
|
|
68
69
|
};
|
|
69
70
|
}
|
|
70
71
|
|
|
72
|
+
export interface VisitorContext {
|
|
73
|
+
parent?: momoa.AnyNode;
|
|
74
|
+
filename: URL;
|
|
75
|
+
path: string[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type Visitor<T extends momoa.AnyNode = momoa.ObjectNode | momoa.DocumentNode> = (
|
|
79
|
+
node: T,
|
|
80
|
+
context: VisitorContext,
|
|
81
|
+
// biome-ignore lint/suspicious/noConfusingVoidType: TS requires void
|
|
82
|
+
) => T | void | null | undefined;
|
|
83
|
+
|
|
84
|
+
export interface TransformVisitors {
|
|
85
|
+
boolean?: Visitor;
|
|
86
|
+
border?: Visitor;
|
|
87
|
+
color?: Visitor;
|
|
88
|
+
cubicBezier?: Visitor;
|
|
89
|
+
dimension?: Visitor;
|
|
90
|
+
duration?: Visitor;
|
|
91
|
+
fontFamily?: Visitor;
|
|
92
|
+
fontWeight?: Visitor;
|
|
93
|
+
gradient?: Visitor;
|
|
94
|
+
group?: Visitor;
|
|
95
|
+
link?: Visitor;
|
|
96
|
+
number?: Visitor;
|
|
97
|
+
root?: Visitor;
|
|
98
|
+
shadow?: Visitor;
|
|
99
|
+
strokeStyle?: Visitor;
|
|
100
|
+
token?: Visitor;
|
|
101
|
+
transition?: Visitor;
|
|
102
|
+
typography?: Visitor;
|
|
103
|
+
[key: string]: Visitor | undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
71
106
|
// normalized, finalized config
|
|
72
107
|
export interface ConfigInit {
|
|
73
108
|
tokens: URL[];
|
|
@@ -92,14 +127,14 @@ export interface ConfigOptions {
|
|
|
92
127
|
export interface InputSource {
|
|
93
128
|
filename?: URL;
|
|
94
129
|
src: any;
|
|
95
|
-
document: DocumentNode;
|
|
130
|
+
document: momoa.DocumentNode;
|
|
96
131
|
}
|
|
97
132
|
|
|
98
133
|
export interface LintNotice {
|
|
99
134
|
/** Lint message shown to the user */
|
|
100
135
|
message: string;
|
|
101
136
|
/** Erring node (used to point to a specific line) */
|
|
102
|
-
node?: AnyNode;
|
|
137
|
+
node?: momoa.AnyNode;
|
|
103
138
|
}
|
|
104
139
|
|
|
105
140
|
export type LintRuleSeverity = 'error' | 'warn' | 'off';
|
|
@@ -113,10 +148,10 @@ export interface LintRuleNormalized<O = any> {
|
|
|
113
148
|
}
|
|
114
149
|
|
|
115
150
|
export type LintReportDescriptor<MessageIds extends string> = {
|
|
116
|
-
/** To error on a specific token source file, provide
|
|
117
|
-
node?: AnyNode;
|
|
118
|
-
/** To
|
|
119
|
-
|
|
151
|
+
/** To error on a specific token source file, provide a Momoa node */
|
|
152
|
+
node?: momoa.AnyNode;
|
|
153
|
+
/** To provide correct line numbers, specify the filename (usually found on `token.source.loc`) */
|
|
154
|
+
filename?: string;
|
|
120
155
|
/** Provide data for messages */
|
|
121
156
|
data?: Record<string, unknown>;
|
|
122
157
|
} & (
|
|
@@ -139,7 +174,7 @@ export type LintReportDescriptor<MessageIds extends string> = {
|
|
|
139
174
|
|
|
140
175
|
export interface LintRule<
|
|
141
176
|
MessageIds extends string,
|
|
142
|
-
LintRuleOptions extends
|
|
177
|
+
LintRuleOptions extends Record<string, any> = Record<string, never>,
|
|
143
178
|
LintRuleDocs = unknown,
|
|
144
179
|
> {
|
|
145
180
|
meta?: LintRuleMetaData<MessageIds, LintRuleOptions, LintRuleDocs>;
|
|
@@ -164,8 +199,11 @@ export interface LintRuleContext<MessageIds extends string, LintRuleOptions exte
|
|
|
164
199
|
options: LintRuleOptions;
|
|
165
200
|
/** The current working directory. */
|
|
166
201
|
cwd?: URL;
|
|
167
|
-
/**
|
|
168
|
-
|
|
202
|
+
/**
|
|
203
|
+
* All source files present in this run. To find the original source, match a
|
|
204
|
+
* token’s `source.loc` filename to one of the source’s `filename`s.
|
|
205
|
+
*/
|
|
206
|
+
sources: InputSource[];
|
|
169
207
|
/** Source file location. */
|
|
170
208
|
filename?: URL;
|
|
171
209
|
/** ID:Token map of all tokens. */
|
|
@@ -221,6 +259,30 @@ export interface OutputFileExpanded extends OutputFile {
|
|
|
221
259
|
time: number;
|
|
222
260
|
}
|
|
223
261
|
|
|
262
|
+
export interface ParseOptions {
|
|
263
|
+
logger?: Logger;
|
|
264
|
+
config: ConfigInit;
|
|
265
|
+
/**
|
|
266
|
+
* Skip lint step
|
|
267
|
+
* @default false
|
|
268
|
+
*/
|
|
269
|
+
skipLint?: boolean;
|
|
270
|
+
/**
|
|
271
|
+
* Continue on error? (Useful for `tz check`)
|
|
272
|
+
* @default false
|
|
273
|
+
*/
|
|
274
|
+
continueOnError?: boolean;
|
|
275
|
+
/** Provide yamlToMomoa module to parse YAML (by default, this isn’t shipped to cut down on package weight) */
|
|
276
|
+
yamlToMomoa?: typeof ytm;
|
|
277
|
+
/**
|
|
278
|
+
* Transform API
|
|
279
|
+
* @see https://terrazzo.app/docs/api/js#transform-api
|
|
280
|
+
*/
|
|
281
|
+
transform?: TransformVisitors;
|
|
282
|
+
/** (internal cache; do not use) */
|
|
283
|
+
_sources?: Record<string, InputSource>;
|
|
284
|
+
}
|
|
285
|
+
|
|
224
286
|
export interface Plugin {
|
|
225
287
|
name: string;
|
|
226
288
|
/** Read config, and optionally modify */
|
|
@@ -308,3 +370,7 @@ export interface TransformHookOptions {
|
|
|
308
370
|
/** Momoa documents */
|
|
309
371
|
sources: InputSource[];
|
|
310
372
|
}
|
|
373
|
+
|
|
374
|
+
export interface ReferenceObject {
|
|
375
|
+
$ref: string;
|
|
376
|
+
}
|