@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.
Files changed (43) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/index.d.ts +82 -333
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +2203 -3660
  5. package/dist/index.js.map +1 -1
  6. package/package.json +6 -5
  7. package/src/build/index.ts +32 -41
  8. package/src/config.ts +13 -6
  9. package/src/lib/code-frame.ts +5 -2
  10. package/src/lib/momoa.ts +10 -0
  11. package/src/lint/index.ts +41 -37
  12. package/src/lint/plugin-core/index.ts +73 -16
  13. package/src/lint/plugin-core/rules/colorspace.ts +4 -0
  14. package/src/lint/plugin-core/rules/duplicate-values.ts +2 -0
  15. package/src/lint/plugin-core/rules/max-gamut.ts +24 -4
  16. package/src/lint/plugin-core/rules/no-type-on-alias.ts +29 -0
  17. package/src/lint/plugin-core/rules/required-modes.ts +2 -0
  18. package/src/lint/plugin-core/rules/required-typography-properties.ts +13 -3
  19. package/src/lint/plugin-core/rules/valid-boolean.ts +41 -0
  20. package/src/lint/plugin-core/rules/valid-border.ts +57 -0
  21. package/src/lint/plugin-core/rules/valid-color.ts +265 -0
  22. package/src/lint/plugin-core/rules/valid-cubic-bezier.ts +83 -0
  23. package/src/lint/plugin-core/rules/valid-dimension.ts +199 -0
  24. package/src/lint/plugin-core/rules/valid-duration.ts +123 -0
  25. package/src/lint/plugin-core/rules/valid-font-family.ts +68 -0
  26. package/src/lint/plugin-core/rules/valid-font-weight.ts +89 -0
  27. package/src/lint/plugin-core/rules/valid-gradient.ts +79 -0
  28. package/src/lint/plugin-core/rules/valid-link.ts +41 -0
  29. package/src/lint/plugin-core/rules/valid-number.ts +63 -0
  30. package/src/lint/plugin-core/rules/valid-shadow.ts +67 -0
  31. package/src/lint/plugin-core/rules/valid-string.ts +41 -0
  32. package/src/lint/plugin-core/rules/valid-stroke-style.ts +104 -0
  33. package/src/lint/plugin-core/rules/valid-transition.ts +61 -0
  34. package/src/lint/plugin-core/rules/valid-typography.ts +67 -0
  35. package/src/logger.ts +70 -59
  36. package/src/parse/index.ts +23 -318
  37. package/src/parse/load.ts +257 -0
  38. package/src/parse/normalize.ts +134 -170
  39. package/src/parse/token.ts +530 -0
  40. package/src/types.ts +76 -10
  41. package/src/parse/alias.ts +0 -369
  42. package/src/parse/json.ts +0 -211
  43. package/src/parse/validate.ts +0 -961
@@ -1,961 +0,0 @@
1
- import {
2
- type AnyNode,
3
- type BooleanNode,
4
- evaluate,
5
- type MemberNode,
6
- type ObjectNode,
7
- print,
8
- type StringNode,
9
- type ValueNode,
10
- } from '@humanwhocodes/momoa';
11
- import { isAlias, splitID, type Token, type TokenNormalized } from '@terrazzo/token-tools';
12
- import wcmatch from 'wildcard-match';
13
- import type Logger from '../logger.js';
14
- import type { ConfigInit } from '../types.js';
15
- import { getObjMembers, injectObjMembers } from './json.js';
16
-
17
- const listFormat = new Intl.ListFormat('en-us', { type: 'disjunction' });
18
-
19
- export interface ValidateOptions {
20
- filename?: URL;
21
- src: string;
22
- logger: Logger;
23
- }
24
-
25
- export interface Visitors {
26
- color?: Visitor;
27
- dimension?: Visitor;
28
- fontFamily?: Visitor;
29
- fontWeight?: Visitor;
30
- duration?: Visitor;
31
- cubicBezier?: Visitor;
32
- number?: Visitor;
33
- link?: Visitor;
34
- boolean?: Visitor;
35
- strokeStyle?: Visitor;
36
- border?: Visitor;
37
- transition?: Visitor;
38
- shadow?: Visitor;
39
- gradient?: Visitor;
40
- typography?: Visitor;
41
- root?: Visitor;
42
- group?: Visitor;
43
- [key: string]: Visitor | undefined;
44
- }
45
-
46
- export type Visitor = (json: any, path: string, ast: AnyNode) => any | undefined | null;
47
-
48
- export const VALID_COLORSPACES = new Set([
49
- 'adobe-rgb',
50
- 'display-p3',
51
- 'hsl',
52
- 'hwb',
53
- 'lab',
54
- 'lch',
55
- 'oklab',
56
- 'oklch',
57
- 'prophoto',
58
- 'rec2020',
59
- 'srgb',
60
- 'srgb-linear',
61
- 'xyz',
62
- 'xyz-d50',
63
- 'xyz-d65',
64
- ]);
65
-
66
- export const FONT_WEIGHT_VALUES = new Set([
67
- 'thin',
68
- 'hairline',
69
- 'extra-light',
70
- 'ultra-light',
71
- 'light',
72
- 'normal',
73
- 'regular',
74
- 'book',
75
- 'medium',
76
- 'semi-bold',
77
- 'demi-bold',
78
- 'bold',
79
- 'extra-bold',
80
- 'ultra-bold',
81
- 'black',
82
- 'heavy',
83
- 'extra-black',
84
- 'ultra-black',
85
- ]);
86
-
87
- export const STROKE_STYLE_VALUES = new Set([
88
- 'solid',
89
- 'dashed',
90
- 'dotted',
91
- 'double',
92
- 'groove',
93
- 'ridge',
94
- 'outset',
95
- 'inset',
96
- ]);
97
- export const STROKE_STYLE_LINE_CAP_VALUES = new Set(['round', 'butt', 'square']);
98
-
99
- /** Distinct from isAlias() in that this accepts malformed aliases */
100
- function isMaybeAlias(node: AnyNode) {
101
- if (node?.type === 'String') {
102
- return node.value.startsWith('{');
103
- }
104
- return false;
105
- }
106
-
107
- /** Assert object members match given types */
108
- function validateMembersAs(
109
- $value: ObjectNode,
110
- properties: Record<string, { validator: typeof validateAliasSyntax; required?: boolean }>,
111
- node: AnyNode,
112
- { filename, src, logger }: ValidateOptions,
113
- ) {
114
- const members = getObjMembers($value);
115
- for (const [name, value] of Object.entries(properties)) {
116
- const { validator, required } = value;
117
- if (!members[name]) {
118
- if (required) {
119
- logger.error({
120
- group: 'parser',
121
- label: 'validate',
122
- message: `Missing required property "${name}"`,
123
- filename,
124
- node: $value,
125
- src,
126
- });
127
- }
128
- continue;
129
- }
130
- const memberValue = members[name];
131
- if (isMaybeAlias(memberValue)) {
132
- validateAliasSyntax(memberValue, node, { filename, src, logger });
133
- } else {
134
- validator(memberValue, node, { filename, src, logger });
135
- }
136
- }
137
- }
138
-
139
- /** Verify an Alias $value is formatted correctly */
140
- export function validateAliasSyntax($value: ValueNode, _node: AnyNode, { filename, src, logger }: ValidateOptions) {
141
- if ($value.type !== 'String' || !isAlias($value.value)) {
142
- logger.error({
143
- group: 'parser',
144
- label: 'validate',
145
- message: `Invalid alias: ${print($value)}`,
146
- filename,
147
- node: $value,
148
- src,
149
- });
150
- }
151
- }
152
-
153
- /** Verify a Border token is valid */
154
- export function validateBorder($value: ValueNode, node: AnyNode, { filename, src, logger }: ValidateOptions) {
155
- if ($value.type !== 'Object') {
156
- logger.error({
157
- group: 'parser',
158
- label: 'validate',
159
- message: `Expected object, received ${$value.type}`,
160
- filename,
161
- node: $value,
162
- src,
163
- });
164
- } else {
165
- validateMembersAs(
166
- $value,
167
- {
168
- color: { validator: validateColor, required: true },
169
- style: { validator: validateStrokeStyle, required: true },
170
- width: { validator: validateDimension, required: true },
171
- },
172
- node,
173
- { filename, src, logger },
174
- );
175
- }
176
- }
177
-
178
- /** Verify a Color token is valid */
179
- export function validateColor($value: ValueNode, node: AnyNode, { filename, src, logger }: ValidateOptions) {
180
- const baseMessage = { group: 'parser' as const, label: 'validate', filename, node: $value, src };
181
- if ($value.type === 'String') {
182
- // TODO: enable when object notation is finalized
183
- // logger.warn({
184
- // filename,
185
- // message: 'String colors are no longer recommended; please use the object notation instead.',
186
- // node: $value,
187
- // src,
188
- // });
189
- if ($value.value === '') {
190
- logger.error({ ...baseMessage, message: 'Expected color, received empty string' });
191
- }
192
- } else if ($value.type === 'Object') {
193
- // allow "channels" but raise warning. also rename as a workaround (mutating the AST is a bad idea in general, but this is safe)
194
- const channelMemberI = $value.members.findIndex((m) => m.name.type === 'String' && m.name.value === 'channels');
195
-
196
- if (channelMemberI !== -1) {
197
- logger.warn({ ...baseMessage, message: '"channels" is deprecated; rename "channels" to "components"' });
198
- ($value.members[channelMemberI]!.name as StringNode).value = 'components';
199
- }
200
-
201
- validateMembersAs(
202
- $value,
203
- {
204
- colorSpace: {
205
- validator: (v) => {
206
- if (v.type !== 'String') {
207
- logger.error({
208
- ...baseMessage,
209
- message: `Expected string, received ${print(v)}`,
210
- node: v,
211
- });
212
- }
213
- if (!VALID_COLORSPACES.has((v as StringNode).value)) {
214
- logger.error({
215
- ...baseMessage,
216
- message: `Unsupported colorspace ${print(v)}`,
217
- node: v,
218
- });
219
- }
220
- },
221
- required: true,
222
- },
223
- components: {
224
- validator: (v) => {
225
- if (v.type !== 'Array') {
226
- logger.error({
227
- ...baseMessage,
228
- message: `Expected array, received ${print(v)}`,
229
- node: v,
230
- });
231
- } else {
232
- // note: in the future, length will change depending on colorSpace, e.g. CMYK
233
- // but in the current spec it’s 3 for now.
234
- if (v.elements?.length !== 3) {
235
- logger.error({
236
- ...baseMessage,
237
- message: `Expected 3 components, received ${v.elements?.length ?? 0}`,
238
- node: v,
239
- });
240
- }
241
- for (const element of v.elements) {
242
- if (element.value.type !== 'Number') {
243
- logger.error({
244
- ...baseMessage,
245
- message: `Expected number, received ${print(element.value)}`,
246
- node: element,
247
- });
248
- }
249
- }
250
- }
251
- },
252
- required: true,
253
- },
254
- hex: {
255
- validator: (v) => {
256
- if (
257
- v.type !== 'String' ||
258
- // this is a weird one—with the RegEx we test, it will work for
259
- // lengths of 3, 4, 6, and 8 (but not 5 or 7). So we check length
260
- // here, to keep the RegEx simple and readable. The "+ 1" is just
261
- // accounting for the '#' prefix character.
262
- v.value.length === 5 + 1 ||
263
- v.value.length === 7 + 1 ||
264
- !/^#[a-f0-9]{3,8}$/i.test(v.value)
265
- ) {
266
- logger.error({
267
- ...baseMessage,
268
- message: `Invalid hex color ${print(v)}`,
269
- node: v,
270
- });
271
- }
272
- },
273
- },
274
- alpha: { validator: validateNumber },
275
- },
276
- node,
277
- { filename, src, logger },
278
- );
279
- } else {
280
- logger.error({
281
- ...baseMessage,
282
- message: `Expected object, received ${$value.type}`,
283
- node: $value,
284
- });
285
- }
286
- }
287
-
288
- /** Verify a Cubic Bézier token is valid */
289
- export function validateCubicBezier($value: ValueNode, _node: AnyNode, { filename, src, logger }: ValidateOptions) {
290
- const baseMessage = { group: 'parser' as const, label: 'validate', filename, node: $value, src };
291
- if ($value.type !== 'Array') {
292
- logger.error({ ...baseMessage, message: `Expected array of numbers, received ${print($value)}` });
293
- } else if (!$value.elements.every((e) => e.value.type === 'Number')) {
294
- logger.error({ ...baseMessage, message: 'Expected an array of 4 numbers, received some non-numbers' });
295
- } else if ($value.elements.length !== 4) {
296
- logger.error({ ...baseMessage, message: `Expected an array of 4 numbers, received ${$value.elements.length}` });
297
- }
298
- }
299
-
300
- /** Verify a Dimension token is valid */
301
- export function validateDimension($value: ValueNode, _node: AnyNode, { filename, src, logger }: ValidateOptions) {
302
- if ($value.type === 'Number' && $value.value === 0) {
303
- return; // `0` is a valid number
304
- }
305
-
306
- const baseMessage = { group: 'parser' as const, label: 'validate', filename, node: $value, src };
307
-
308
- // Give priority to object notation because it’s a faster code path
309
- if ($value.type === 'Object') {
310
- const { value, unit } = getObjMembers($value);
311
- if (!value) {
312
- logger.error({ ...baseMessage, message: 'Missing required property "value".' });
313
- }
314
- if (!unit) {
315
- logger.error({ ...baseMessage, message: 'Missing required property "unit".' });
316
- }
317
- if (value!.type !== 'Number') {
318
- logger.error({
319
- ...baseMessage,
320
- message: `Expected number, received ${value!.type}`,
321
- node: value,
322
- });
323
- }
324
- if (!['px', 'em', 'rem'].includes((unit as StringNode).value)) {
325
- logger.error({
326
- ...baseMessage,
327
- message: `Expected unit "px", "em", or "rem", received ${print(unit as StringNode)}`,
328
- node: unit,
329
- });
330
- }
331
- return;
332
- }
333
-
334
- // Backwards compat: string
335
- if ($value.type !== 'String') {
336
- logger.error({ ...baseMessage, message: `Expected string, received ${$value.type}` });
337
- }
338
- const value = ($value as StringNode).value.match(/^-?[0-9.]+/)?.[0];
339
- const unit = ($value as StringNode).value.replace(value!, '');
340
- if (($value as StringNode).value === '') {
341
- logger.error({ ...baseMessage, message: 'Expected dimension, received empty string' });
342
- } else if (!['px', 'em', 'rem'].includes(unit)) {
343
- logger.error({
344
- ...baseMessage,
345
- message: `Expected unit "px", "em", or "rem", received ${JSON.stringify(unit || ($value as StringNode).value)}`,
346
- });
347
- } else if (!Number.isFinite(Number.parseFloat(value!))) {
348
- logger.error({ ...baseMessage, message: `Expected dimension with units, received ${print($value)}` });
349
- }
350
- }
351
-
352
- /** Verify a Duration token is valid */
353
- export function validateDuration($value: ValueNode, _node: AnyNode, { filename, src, logger }: ValidateOptions) {
354
- if ($value.type === 'Number' && $value.value === 0) {
355
- return; // `0` is a valid number
356
- }
357
-
358
- const baseMessage = { group: 'parser' as const, label: 'validate', filename, node: $value, src };
359
-
360
- // Give priority to object notation because it’s a faster code path
361
- if ($value.type === 'Object') {
362
- const { value, unit } = getObjMembers($value);
363
- if (!value) {
364
- logger.error({ ...baseMessage, message: 'Missing required property "value".' });
365
- }
366
- if (!unit) {
367
- logger.error({ ...baseMessage, message: 'Missing required property "unit".' });
368
- }
369
- if (value?.type !== 'Number') {
370
- logger.error({
371
- ...baseMessage,
372
- message: `Expected number, received ${value?.type}`,
373
- node: value,
374
- });
375
- }
376
- if (!['ms', 's'].includes((unit as StringNode).value)) {
377
- logger.error({
378
- ...baseMessage,
379
- message: `Expected unit "ms" or "s", received ${print(unit!)}`,
380
- node: unit,
381
- });
382
- }
383
- return;
384
- }
385
-
386
- // Backwards compat: string
387
- if ($value.type !== 'String') {
388
- logger.error({ ...baseMessage, message: `Expected string, received ${$value.type}` });
389
- }
390
- const value = ($value as StringNode).value.match(/^-?[0-9.]+/)?.[0]!;
391
- const unit = ($value as StringNode).value.replace(value, '');
392
- if (($value as StringNode).value === '') {
393
- logger.error({ ...baseMessage, message: 'Expected duration, received empty string' });
394
- } else if (!['ms', 's'].includes(unit)) {
395
- logger.error({
396
- ...baseMessage,
397
- message: `Expected unit "ms" or "s", received ${JSON.stringify(unit || ($value as StringNode).value)}`,
398
- });
399
- } else if (!Number.isFinite(Number.parseFloat(value))) {
400
- logger.error({ ...baseMessage, message: `Expected duration with units, received ${print($value)}` });
401
- }
402
- }
403
-
404
- /** Verify a Font Family token is valid */
405
- export function validateFontFamily($value: ValueNode, _node: AnyNode, { filename, src, logger }: ValidateOptions) {
406
- const baseMessage = { group: 'parser' as const, label: 'validate', filename, node: $value, src };
407
- if ($value.type !== 'String' && $value.type !== 'Array') {
408
- logger.error({ ...baseMessage, message: `Expected string or array of strings, received ${$value.type}` });
409
- }
410
- if ($value.type === 'String' && $value.value === '') {
411
- logger.error({ ...baseMessage, message: 'Expected font family name, received empty string' });
412
- }
413
- if ($value.type === 'Array' && !$value.elements.every((e) => e.value.type === 'String' && e.value.value !== '')) {
414
- logger.error({
415
- ...baseMessage,
416
- message: 'Expected an array of strings, received some non-strings or empty strings',
417
- });
418
- }
419
- }
420
-
421
- /** Verify a Font Weight token is valid */
422
- export function validateFontWeight($value: ValueNode, _node: AnyNode, { filename, src, logger }: ValidateOptions) {
423
- const baseMessage = { group: 'parser' as const, label: 'validate', filename, node: $value, src };
424
- if ($value.type !== 'String' && $value.type !== 'Number') {
425
- logger.error({ ...baseMessage, message: `Expected a font weight name or number 0–1000, received ${$value.type}` });
426
- }
427
- if ($value.type === 'String' && !FONT_WEIGHT_VALUES.has($value.value)) {
428
- logger.error({
429
- ...baseMessage,
430
- message: `Unknown font weight ${print($value)}. Expected one of: ${listFormat.format([...FONT_WEIGHT_VALUES])}.`,
431
- });
432
- }
433
- if ($value.type === 'Number' && ($value.value < 0 || $value.value > 1000)) {
434
- logger.error({ ...baseMessage, message: `Expected number 0–1000, received ${print($value)}` });
435
- }
436
- }
437
-
438
- /** Verify a Gradient token is valid */
439
- export function validateGradient($value: ValueNode, _node: AnyNode, { filename, src, logger }: ValidateOptions) {
440
- const baseMessage = { group: 'parser' as const, label: 'validate', filename, node: $value, src };
441
-
442
- if ($value.type !== 'Array') {
443
- logger.error({ ...baseMessage, message: `Expected array of gradient stops, received ${$value.type}` });
444
- } else {
445
- for (let i = 0; i < $value.elements.length; i++) {
446
- const element = $value.elements[i]!;
447
- if (element.value.type !== 'Object') {
448
- logger.error({
449
- ...baseMessage,
450
- message: `Stop #${i + 1}: Expected gradient stop, received ${element.value.type}`,
451
- node: element,
452
- });
453
- break;
454
- }
455
- validateMembersAs(
456
- element.value,
457
- {
458
- color: { validator: validateColor, required: true },
459
- position: { validator: validateNumber, required: true },
460
- },
461
- element,
462
- { filename, src, logger },
463
- );
464
- }
465
- }
466
- }
467
-
468
- /** Verify a Number token is valid */
469
- export function validateNumber($value: ValueNode, _node: AnyNode, { filename, src, logger }: ValidateOptions) {
470
- if ($value.type !== 'Number') {
471
- logger.error({
472
- group: 'parser',
473
- label: 'validate',
474
- message: `Expected number, received ${$value.type}`,
475
- filename,
476
- node: $value,
477
- src,
478
- });
479
- }
480
- }
481
-
482
- /** Verify a Boolean token is valid */
483
- export function validateBoolean($value: ValueNode, _node: AnyNode, { filename, src, logger }: ValidateOptions) {
484
- if ($value.type !== 'Boolean') {
485
- logger.error({
486
- group: 'parser',
487
- label: 'validate',
488
- message: `Expected boolean, received ${$value.type}`,
489
- filename,
490
- node: $value,
491
- src,
492
- });
493
- }
494
- }
495
-
496
- /** Verify a Shadow token’s value is valid */
497
- export function validateShadowLayer($value: ValueNode, node: AnyNode, { filename, src, logger }: ValidateOptions) {
498
- if ($value.type !== 'Object') {
499
- logger.error({
500
- group: 'parser',
501
- label: 'validate',
502
- message: `Expected Object, received ${$value.type}`,
503
- filename,
504
- node: $value,
505
- src,
506
- });
507
- } else {
508
- validateMembersAs(
509
- $value,
510
- {
511
- color: { validator: validateColor, required: true },
512
- offsetX: { validator: validateDimension, required: true },
513
- offsetY: { validator: validateDimension, required: true },
514
- blur: { validator: validateDimension },
515
- spread: { validator: validateDimension },
516
- inset: { validator: validateBoolean },
517
- },
518
- node,
519
- { filename, src, logger },
520
- );
521
- }
522
- }
523
-
524
- /** Verify a Stroke Style token is valid. */
525
- export function validateStrokeStyle($value: ValueNode, node: AnyNode, { filename, src, logger }: ValidateOptions) {
526
- const baseMessage = { group: 'parser' as const, label: 'validate', filename, node: $value, src };
527
-
528
- // note: strokeStyle’s values are NOT aliasable (unless by string, but that breaks validations)
529
- if ($value.type === 'String') {
530
- if (!STROKE_STYLE_VALUES.has($value.value)) {
531
- logger.error({
532
- ...baseMessage,
533
- message: `Unknown stroke style ${print($value)}. Expected one of: ${listFormat.format([
534
- ...STROKE_STYLE_VALUES,
535
- ])}.`,
536
- });
537
- }
538
- } else if ($value.type === 'Object') {
539
- const strokeMembers = getObjMembers($value);
540
- for (const property of ['dashArray', 'lineCap']) {
541
- if (!strokeMembers[property]) {
542
- logger.error({ ...baseMessage, message: `Missing required property "${property}"` });
543
- }
544
- }
545
- const { lineCap, dashArray } = strokeMembers;
546
- if (lineCap?.type !== 'String' || !STROKE_STYLE_LINE_CAP_VALUES.has(lineCap.value)) {
547
- logger.error({
548
- ...baseMessage,
549
- message: `Unknown lineCap value ${print(lineCap!)}. Expected one of: ${listFormat.format([
550
- ...STROKE_STYLE_LINE_CAP_VALUES,
551
- ])}.`,
552
- node,
553
- });
554
- }
555
- if (dashArray?.type === 'Array') {
556
- for (const element of dashArray.elements) {
557
- if (element.value.type === 'String' && element.value.value !== '') {
558
- if (isMaybeAlias(element.value)) {
559
- validateAliasSyntax(element.value, node, { logger, src });
560
- } else {
561
- validateDimension(element.value, node, { logger, src });
562
- }
563
- } else if (element.value.type === 'Object') {
564
- validateDimension(element.value, node, { logger, src });
565
- } else {
566
- logger.error({
567
- ...baseMessage,
568
- message: `Expected array of dimensions, received ${element.value.type}.`,
569
- node: element,
570
- });
571
- }
572
- }
573
- } else {
574
- logger.error({ ...baseMessage, message: `Expected array of strings, received ${dashArray!.type}` });
575
- }
576
- } else {
577
- logger.error({ ...baseMessage, message: `Expected string or object, received ${$value.type}` });
578
- }
579
- }
580
-
581
- /** Verify a Transition token is valid */
582
- export function validateTransition($value: ValueNode, node: AnyNode, { filename, src, logger }: ValidateOptions) {
583
- if ($value.type !== 'Object') {
584
- logger.error({
585
- group: 'parser',
586
- label: 'validate',
587
- message: `Expected object, received ${$value.type}`,
588
- filename,
589
- node: $value,
590
- src,
591
- });
592
- } else {
593
- validateMembersAs(
594
- $value,
595
- {
596
- duration: { validator: validateDuration, required: true },
597
- delay: { validator: validateDuration, required: false }, // note: spec says delay is required, but Terrazzo makes delay optional
598
- timingFunction: { validator: validateCubicBezier, required: true },
599
- },
600
- node,
601
- { filename, src, logger },
602
- );
603
- }
604
- }
605
-
606
- /**
607
- * Validate a MemberNode (the entire token object, plus its key in the parent
608
- * object) to see if it’s a valid DTCG token or not. Keeping the parent key
609
- * really helps in debug messages.
610
- */
611
- export function validateTokenMemberNode(node: MemberNode, { filename, src, logger }: ValidateOptions) {
612
- const baseMessage = { group: 'parser' as const, label: 'validate', filename, node, src };
613
-
614
- if (node.type !== 'Member' && node.type !== 'Object') {
615
- logger.error({
616
- ...baseMessage,
617
- message: `Expected Object, received ${JSON.stringify(node.type)}`,
618
- });
619
- }
620
-
621
- const rootMembers = node.value.type === 'Object' ? getObjMembers(node.value) : {};
622
- const $value = rootMembers.$value as ValueNode;
623
- const $type = rootMembers.$type as StringNode;
624
-
625
- if (!$value) {
626
- logger.error({ ...baseMessage, message: 'Token missing $value' });
627
- }
628
- // If top-level value is a valid alias, this is valid (no need for $type)
629
- // ⚠️ Important: ALL Object and Array nodes below will need to check for aliases within!
630
- if (isMaybeAlias($value)) {
631
- validateAliasSyntax($value, node, { logger, src });
632
- return;
633
- }
634
-
635
- if (!$type) {
636
- logger.error({ ...baseMessage, message: 'Token missing $type' });
637
- }
638
-
639
- switch ($type.value) {
640
- case 'color': {
641
- validateColor($value, node, { logger, src });
642
- break;
643
- }
644
- case 'cubicBezier': {
645
- validateCubicBezier($value, node, { logger, src });
646
- break;
647
- }
648
- case 'dimension': {
649
- validateDimension($value, node, { logger, src });
650
- break;
651
- }
652
- case 'duration': {
653
- validateDuration($value, node, { logger, src });
654
- break;
655
- }
656
- case 'fontFamily': {
657
- validateFontFamily($value, node, { logger, src });
658
- break;
659
- }
660
- case 'fontWeight': {
661
- validateFontWeight($value, node, { logger, src });
662
- break;
663
- }
664
- case 'number': {
665
- validateNumber($value, node, { logger, src });
666
- break;
667
- }
668
- case 'shadow': {
669
- if ($value.type === 'Object') {
670
- validateShadowLayer($value, node, { logger, src });
671
- } else if ($value.type === 'Array') {
672
- for (const element of $value.elements) {
673
- validateShadowLayer(element.value, $value, { logger, src });
674
- }
675
- } else {
676
- logger.error({
677
- ...baseMessage,
678
- message: `Expected shadow object or array of shadow objects, received ${$value.type}`,
679
- node: $value,
680
- });
681
- }
682
- break;
683
- }
684
-
685
- // extensions
686
- case 'boolean': {
687
- if ($value.type !== 'Boolean') {
688
- logger.error({
689
- ...baseMessage,
690
- message: `Expected boolean, received ${$value.type}`,
691
- node: $value,
692
- });
693
- }
694
- break;
695
- }
696
- case 'link': {
697
- if ($value.type !== 'String') {
698
- logger.error({
699
- ...baseMessage,
700
- message: `Expected string, received ${$value.type}`,
701
- node: $value,
702
- });
703
- } else if ($value.value === '') {
704
- logger.error({
705
- ...baseMessage,
706
- message: 'Expected URL, received empty string',
707
- node: $value,
708
- });
709
- }
710
- break;
711
- }
712
- case 'string': {
713
- if ($value.type !== 'String') {
714
- logger.error({
715
- ...baseMessage,
716
- message: `Expected string, received ${$value.type}`,
717
- node: $value,
718
- });
719
- }
720
- break;
721
- }
722
-
723
- // composite types
724
- case 'border': {
725
- validateBorder($value, node, { filename, src, logger });
726
- break;
727
- }
728
- case 'gradient': {
729
- validateGradient($value, node, { filename, src, logger });
730
- break;
731
- }
732
- case 'strokeStyle': {
733
- validateStrokeStyle($value, node, { filename, src, logger });
734
- break;
735
- }
736
- case 'transition': {
737
- validateTransition($value, node, { filename, src, logger });
738
- break;
739
- }
740
- case 'typography': {
741
- if ($value.type !== 'Object') {
742
- logger.error({
743
- ...baseMessage,
744
- message: `Expected object, received ${$value.type}`,
745
- node: $value,
746
- });
747
- break;
748
- }
749
- if ($value.members.length === 0) {
750
- logger.error({
751
- ...baseMessage,
752
- message: 'Empty typography token. Must contain at least 1 property.',
753
- node: $value,
754
- });
755
- }
756
- validateMembersAs(
757
- $value,
758
- {
759
- fontFamily: { validator: validateFontFamily },
760
- fontWeight: { validator: validateFontWeight },
761
- },
762
- node,
763
- { filename, src, logger },
764
- );
765
- break;
766
- }
767
-
768
- default: {
769
- // noop
770
- break;
771
- }
772
- }
773
- }
774
-
775
- /** Return whether a MemberNode is a group (has no `$value`).
776
- * Groups can have properties that their child nodes will inherit. */
777
- export function isGroupNode(node: ObjectNode): boolean {
778
- if (node.type !== 'Object') {
779
- return false;
780
- }
781
-
782
- // check for $value
783
- const has$value = node.members.some((m) => m.name.type === 'String' && m.name.value === '$value');
784
- return !has$value;
785
- }
786
-
787
- /** Check if a token node has the specified property name, and if it does, stores
788
- * the value in the `inherited` object as a side effect for future use. If not,
789
- * traverses the `inherited` object to find the closest parent that has the property.
790
- *
791
- * Returns the property value if found locally or in a parent, otherwise undefined. */
792
- export function computeInheritedProperty(
793
- node: MemberNode,
794
- propertyName: string,
795
- { subpath, inherited }: { subpath: string[]; inherited?: Record<string, MemberNode> },
796
- ): MemberNode | undefined {
797
- if (node.value.type !== 'Object') {
798
- return;
799
- }
800
-
801
- // if property exists locally in the token node, add it to the inherited tree
802
- const property = node.value.members.find((m) => m.name.type === 'String' && m.name.value === propertyName);
803
- if (inherited && property && isGroupNode(node.value)) {
804
- // this is where the side effect occurs
805
- inherited[subpath.join('.') || '.'] = property;
806
-
807
- // We know this is the closest property, so return early
808
- return property;
809
- }
810
-
811
- // get parent type by taking the closest-scoped $type (length === closer)
812
- const id = subpath.join('.');
813
- let parent$type: MemberNode | undefined;
814
- let longestPath = '';
815
- for (const [k, v] of Object.entries(inherited ?? {})) {
816
- if (k === '.' || id.startsWith(k)) {
817
- if (k.length > longestPath.length) {
818
- parent$type = v;
819
- longestPath = k;
820
- }
821
- }
822
- }
823
-
824
- return parent$type;
825
- }
826
-
827
- export interface ValidateTokenNodeOptions {
828
- subpath: string[];
829
- src: string;
830
- filename: URL;
831
- config: ConfigInit;
832
- logger: Logger;
833
- parent: AnyNode | undefined;
834
- transform?: Visitors;
835
- inheritedDeprecatedNode?: MemberNode;
836
- inheritedTypeNode?: MemberNode;
837
- }
838
-
839
- /**
840
- * Validate does a little more than validate; it also converts to TokenNormalized
841
- * and sets up the basic data structure. But aliases are unresolved, and we need
842
- * a 2nd normalization pass afterward.
843
- */
844
- export default function validateTokenNode(
845
- node: MemberNode,
846
- {
847
- config,
848
- filename,
849
- logger,
850
- parent,
851
- inheritedDeprecatedNode,
852
- inheritedTypeNode,
853
- src,
854
- subpath,
855
- }: ValidateTokenNodeOptions,
856
- ): TokenNormalized | undefined {
857
- // don’t validate $value
858
- if (subpath.includes('$value') || node.value.type !== 'Object') {
859
- return;
860
- }
861
-
862
- const members = getObjMembers(node.value);
863
-
864
- // don’t validate $extensions or $defs
865
- if (!members.$value || subpath.includes('$extensions') || subpath.includes('$deps')) {
866
- return;
867
- }
868
-
869
- const id = subpath.join('.');
870
-
871
- if (!subpath.includes('.$value') && members.value) {
872
- logger.warn({
873
- group: 'parser',
874
- label: 'validate',
875
- message: `Group ${id} has "value". Did you mean "$value"?`,
876
- filename,
877
- node,
878
- src,
879
- });
880
- }
881
-
882
- const nodeWithType = structuredClone(node);
883
- // inject $type that can be inherited in the DTCG format
884
- let $type = (members.$type?.type === 'String' && members.$type.value) || undefined;
885
- if (inheritedTypeNode && !members.$type) {
886
- injectObjMembers(nodeWithType.value as ObjectNode, [inheritedTypeNode]);
887
- $type = (inheritedTypeNode.value as StringNode).value;
888
- }
889
-
890
- // inject $deprecated that can also be inherited
891
- let $deprecated = members.$deprecated
892
- ? members.$deprecated?.type === 'String'
893
- ? (members.$deprecated as StringNode).value
894
- : (members.$deprecated as BooleanNode).value
895
- : undefined;
896
- if (inheritedDeprecatedNode && !members.$deprecated) {
897
- injectObjMembers(nodeWithType.value as ObjectNode, [inheritedDeprecatedNode]);
898
- $deprecated =
899
- inheritedDeprecatedNode.value.type === 'String'
900
- ? (inheritedDeprecatedNode.value as StringNode).value
901
- : (inheritedDeprecatedNode.value as BooleanNode).value;
902
- }
903
-
904
- // validate once after injecting all inherited properties
905
- validateTokenMemberNode(nodeWithType, { filename, src, logger });
906
-
907
- // All tokens must be valid, so we want to validate it up till this
908
- // point. However, if we are ignoring this token (or respecting
909
- // $deprecated, we can omit it from the output.
910
- if ((config.ignore.deprecated && $deprecated) || (config.ignore.tokens && wcmatch(config.ignore.tokens)(id))) {
911
- return;
912
- }
913
-
914
- const group: TokenNormalized['group'] = { id: splitID(id).group!, tokens: [] };
915
- if (inheritedTypeNode && inheritedTypeNode.value.type === 'String') {
916
- group.$type = inheritedTypeNode.value.value as Token['$type'];
917
- }
918
- // note: this will also include sibling tokens, so be selective about only accessing group-specific properties
919
- const groupMembers = getObjMembers(parent as ObjectNode);
920
- if (groupMembers.$description) {
921
- group.$description = evaluate(groupMembers.$description) as string;
922
- }
923
- if (groupMembers.$extensions) {
924
- group.$extensions = evaluate(groupMembers.$extensions) as Record<string, unknown>;
925
- }
926
- const $value = evaluate(members.$value!);
927
- const token = {
928
- $type,
929
- $value,
930
- $deprecated,
931
- id,
932
- mode: {},
933
- originalValue: evaluate(node.value),
934
- group,
935
- source: {
936
- loc: filename?.href,
937
- node: nodeWithType.value as ObjectNode,
938
- },
939
- } as unknown as TokenNormalized;
940
- if (members.$description?.type === 'String' && members.$description.value) {
941
- token.$description = members.$description.value;
942
- }
943
-
944
- // handle modes
945
- // note that circular refs are avoided here, such as not duplicating `modes`
946
- const extensions = members.$extensions ? getObjMembers(members.$extensions as ObjectNode) : undefined;
947
- const modeValues = extensions?.mode ? getObjMembers(extensions.mode as any) : {};
948
- for (const mode of ['.', ...Object.keys(modeValues)]) {
949
- const modeValue = mode === '.' ? token.$value : (evaluate((modeValues as any)[mode]) as any);
950
- token.mode[mode] = {
951
- $value: modeValue,
952
- originalValue: modeValue,
953
- source: {
954
- loc: filename?.href,
955
- node: modeValues[mode] as ObjectNode,
956
- },
957
- };
958
- }
959
-
960
- return token;
961
- }