@terrazzo/parser 0.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.
@@ -0,0 +1,620 @@
1
+ import { print } from '@humanwhocodes/momoa';
2
+ import { isAlias } from '@terrazzo/token-tools';
3
+ import { getObjMembers } from './json.js';
4
+
5
+ const listFormat = new Intl.ListFormat('en-us', { type: 'disjunction' });
6
+
7
+ /**
8
+ * @typedef {import("@humanwhocodes/momoa").AnyNode} AnyNode
9
+ * @typedef {import("@humanwhocodes/momoa").ObjectNode} ObjectNode
10
+ * @typedef {import("@humanwhocodes/momoa").ValueNode} ValueNode
11
+ * @typedef {import("@babel/code-frame").SourceLocation} SourceLocation
12
+ */
13
+
14
+ export const FONT_WEIGHT_VALUES = new Set([
15
+ 'thin',
16
+ 'hairline',
17
+ 'extra-light',
18
+ 'ultra-light',
19
+ 'light',
20
+ 'normal',
21
+ 'regular',
22
+ 'book',
23
+ 'medium',
24
+ 'semi-bold',
25
+ 'demi-bold',
26
+ 'bold',
27
+ 'extra-bold',
28
+ 'ultra-bold',
29
+ 'black',
30
+ 'heavy',
31
+ 'extra-black',
32
+ 'ultra-black',
33
+ ]);
34
+
35
+ export const STROKE_STYLE_VALUES = new Set([
36
+ 'solid',
37
+ 'dashed',
38
+ 'dotted',
39
+ 'double',
40
+ 'groove',
41
+ 'ridge',
42
+ 'outset',
43
+ 'inset',
44
+ ]);
45
+ export const STROKE_STYLE_LINE_CAP_VALUES = new Set(['round', 'butt', 'square']);
46
+
47
+ /**
48
+ * Get the location of a JSON node
49
+ * @param {AnyNode} node
50
+ * @return {SourceLocation}
51
+ */
52
+ function getLoc(node) {
53
+ return { line: node.loc?.start.line ?? 1, column: node.loc?.start.column };
54
+ }
55
+
56
+ /**
57
+ * Distinct from isAlias() in that this accepts malformed aliases
58
+ * @param {AnyNode} node
59
+ * @return {boolean}
60
+ */
61
+ function isMaybeAlias(node) {
62
+ if (node?.type === 'String') {
63
+ return node.value.startsWith('{');
64
+ }
65
+ return false;
66
+ }
67
+
68
+ /**
69
+ * Assert object members match given types
70
+ * @param {ObjectNode} $value
71
+ * @param {Record<string, { validator: typeof validateAlias; required?: boolean }>} properties
72
+ * @param {AnyNode} node
73
+ * @param {ValidateOptions} options
74
+ * @return {void}
75
+ */
76
+ function validateMembersAs($value, properties, node, { ast, logger }) {
77
+ const members = getObjMembers($value);
78
+ for (const property in properties) {
79
+ const { validator, required } = properties[property];
80
+ if (!members[property]) {
81
+ if (required) {
82
+ logger.error({ message: `Missing required property "${property}"`, node, ast, loc: getLoc($value) });
83
+ }
84
+ continue;
85
+ }
86
+ const value = members[property];
87
+ if (isMaybeAlias(value)) {
88
+ validateAlias(value, node, { ast, logger });
89
+ } else {
90
+ validator(value, node, { ast, logger });
91
+ }
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Verify an Alias token is valid
97
+ * @param {ValueNode} $value
98
+ * @param {AnyNode} node
99
+ * @param {ValidateOptions} options
100
+ * @return {void}
101
+ */
102
+ export function validateAlias($value, node, { ast, logger }) {
103
+ if ($value.type !== 'String' || !isAlias($value.value)) {
104
+ logger.error({ message: `Invalid alias: ${print($value)}`, node, ast, loc: getLoc($value) });
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Verify a Border token is valid
110
+ * @param {ValueNode} $value
111
+ * @param {AnyNode} node
112
+ * @param {ValidateOptions} options
113
+ * @return {void}
114
+ */
115
+ export function validateBorder($value, node, { ast, logger }) {
116
+ if ($value.type !== 'Object') {
117
+ logger.error({ message: `Expected object, received ${$value.type}`, node, ast, loc: getLoc($value) });
118
+ return;
119
+ }
120
+ validateMembersAs(
121
+ $value,
122
+ {
123
+ color: { validator: validateColor, required: true },
124
+ style: { validator: validateStrokeStyle, required: true },
125
+ width: { validator: validateDimension, required: true },
126
+ },
127
+ node,
128
+ { ast, logger },
129
+ );
130
+ }
131
+
132
+ /**
133
+ * Verify a Color token is valid
134
+ * @param {ValueNode} $value
135
+ * @param {AnyNode} node
136
+ * @param {ValidateOptions} options
137
+ * @return {void}
138
+ */
139
+ export function validateColor($value, node, { ast, logger }) {
140
+ if ($value.type !== 'String') {
141
+ logger.error({ message: `Expected string, received ${$value.type}`, node, ast, loc: getLoc($value) });
142
+ } else if ($value.value === '') {
143
+ logger.error({ message: 'Expected color, received empty string', node, ast, loc: getLoc($value) });
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Verify a Cubic Bézier token is valid
149
+ * @param {ValueNode} $value
150
+ * @param {AnyNode} node
151
+ * @param {ValidateOptions} options
152
+ * @return {void}
153
+ */
154
+ export function validateCubicBézier($value, node, { ast, logger }) {
155
+ if ($value.type !== 'Array') {
156
+ logger.error({
157
+ message: `Expected array of strings, received ${print($value)}`,
158
+ node,
159
+ ast,
160
+ loc: getLoc($value),
161
+ });
162
+ } else if (
163
+ !$value.elements.every((e) => e.value.type === 'Number' || (e.value.type === 'String' && isAlias(e.value.value)))
164
+ ) {
165
+ logger.error({
166
+ message: 'Expected an array of 4 numbers, received some non-numbers',
167
+ node,
168
+ ast,
169
+ loc: getLoc($value),
170
+ });
171
+ } else if ($value.elements.length !== 4) {
172
+ logger.error({
173
+ message: `Expected an array of 4 numbers, received ${$value.elements.length}`,
174
+ node,
175
+ ast,
176
+ loc: getLoc($value),
177
+ });
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Verify a Dimension token is valid
183
+ * @param {ValueNode} $value
184
+ * @param {AnyNode} node
185
+ * @param {ValidateOptions} options
186
+ * @return {void}
187
+ */
188
+ export function validateDimension($value, node, { ast, logger }) {
189
+ if ($value.type === 'Number' && $value.value === 0) {
190
+ return; // `0` is a valid number
191
+ }
192
+ if ($value.type !== 'String') {
193
+ logger.error({ message: `Expected string, received ${$value.type}`, node, ast, loc: getLoc($value) });
194
+ } else if ($value.value === '') {
195
+ logger.error({ message: 'Expected dimension, received empty string', node, ast, loc: getLoc($value) });
196
+ } else if (String(Number($value.value)) === $value.value) {
197
+ logger.error({ message: 'Missing units', node, ast, loc: getLoc($value) });
198
+ } else if (!/^[0-9]+/.test($value.value)) {
199
+ logger.error({
200
+ message: `Expected dimension with units, received ${print($value)}`,
201
+ node,
202
+ ast,
203
+ loc: getLoc($value),
204
+ });
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Verify a Duration token is valid
210
+ * @param {ValueNode} $value
211
+ * @param {AnyNode} node
212
+ * @param {ValidateOptions} options
213
+ * @return {void}
214
+ */
215
+ export function validateDuration($value, node, { ast, logger }) {
216
+ if ($value.type === 'Number' && $value.value === 0) {
217
+ return; // `0` is a valid number
218
+ }
219
+ if ($value.type !== 'String') {
220
+ logger.error({ message: `Expected string, received ${$value.type}`, node, ast, loc: getLoc($value) });
221
+ } else if ($value.value === '') {
222
+ logger.error({ message: 'Expected duration, received empty string', node, ast, loc: getLoc($value) });
223
+ } else if (!/m?s$/.test($value.value)) {
224
+ logger.error({ message: 'Missing unit "ms" or "s"', node, ast, loc: getLoc($value) });
225
+ } else if (!/^[0-9]+/.test($value.value)) {
226
+ logger.error({
227
+ message: `Expected duration in \`ms\` or \`s\`, received ${print($value)}`,
228
+ node,
229
+ ast,
230
+ loc: getLoc($value),
231
+ });
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Verify a Font Family token is valid
237
+ * @param {ValueNode} $value
238
+ * @param {AnyNode} node
239
+ * @param {ValidateOptions} options
240
+ * @return {void}
241
+ */
242
+ export function validateFontFamily($value, node, { ast, logger }) {
243
+ if ($value.type !== 'String' && $value.type !== 'Array') {
244
+ logger.error({
245
+ message: `Expected string or array of strings, received ${$value.type}`,
246
+ node,
247
+ ast,
248
+ loc: getLoc($value),
249
+ });
250
+ }
251
+ if ($value.type === 'String' && $value.value === '') {
252
+ logger.error({ message: 'Expected font family name, received empty string', node, ast, loc: getLoc($value) });
253
+ }
254
+ if ($value.type === 'Array' && !$value.elements.every((e) => e.value.type === 'String' && e.value.value !== '')) {
255
+ logger.error({
256
+ message: 'Expected an array of strings, received some non-strings or empty strings',
257
+ node,
258
+ ast,
259
+ loc: getLoc($value),
260
+ });
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Verify a Font Weight token is valid
266
+ * @param {ValueNode} $value
267
+ * @param {AnyNode} node
268
+ * @param {ValidateOptions} options
269
+ * @return {void}
270
+ */
271
+ export function validateFontWeight($value, node, { ast, logger }) {
272
+ if ($value.type !== 'String' && $value.type !== 'Number') {
273
+ logger.error({
274
+ message: `Expected a font weight name or number 0–1000, received ${$value.type}`,
275
+ node,
276
+ ast,
277
+ loc: getLoc($value),
278
+ });
279
+ }
280
+ if ($value.type === 'String' && !FONT_WEIGHT_VALUES.has($value.value)) {
281
+ logger.error({
282
+ message: `Unknown font weight ${print($value)}. Expected one of: ${listFormat.format([...FONT_WEIGHT_VALUES])}.`,
283
+ node,
284
+ ast,
285
+ loc: getLoc($value),
286
+ });
287
+ }
288
+ if ($value.type === 'Number' && ($value.value < 0 || $value.value > 1000)) {
289
+ logger.error({ message: `Expected number 0–1000, received ${print($value)}`, node, ast, loc: getLoc($value) });
290
+ }
291
+ }
292
+
293
+ /**
294
+ * Verify a Gradient token is valid
295
+ * @param {ValueNode} $value
296
+ * @param {AnyNode} node
297
+ * @param {ValidateOptions} options
298
+ * @return {void}
299
+ */
300
+ export function validateGradient($value, node, { ast, logger }) {
301
+ if ($value.type !== 'Array') {
302
+ logger.error({
303
+ message: `Expected array of gradient stops, received ${$value.type}`,
304
+ node,
305
+ ast,
306
+ loc: getLoc($value),
307
+ });
308
+ return;
309
+ }
310
+ for (let i = 0; i < $value.elements.length; i++) {
311
+ const element = $value.elements[i];
312
+ if (element.value.type !== 'Object') {
313
+ logger.error({
314
+ message: `Stop #${i + 1}: Expected gradient stop, received ${element.value.type}`,
315
+ node,
316
+ ast,
317
+ loc: getLoc(element),
318
+ });
319
+ break;
320
+ }
321
+ validateMembersAs(
322
+ element.value,
323
+ {
324
+ color: { validator: validateColor, required: true },
325
+ position: { validator: validateNumber, required: true },
326
+ },
327
+ node,
328
+ { ast, logger },
329
+ );
330
+ }
331
+ }
332
+
333
+ /**
334
+ * Verify a Number token is valid
335
+ * @param {ValueNode} $value
336
+ * @param {AnyNode} node
337
+ * @param {ValidateOptions} options
338
+ * @return {void}
339
+ */
340
+ export function validateNumber($value, node, { ast, logger }) {
341
+ if ($value.type !== 'Number') {
342
+ logger.error({ message: `Expected number, received ${$value.type}`, node, ast, loc: getLoc($value) });
343
+ }
344
+ }
345
+
346
+ /**
347
+ * Verify a Shadow token’s value is valid
348
+ * @param {ValueNode} $value
349
+ * @param {AnyNode} node
350
+ * @param {ValidateOptions} options
351
+ * @return {void}
352
+ */
353
+ export function validateShadowLayer($value, node, { ast, logger }) {
354
+ if ($value.type !== 'Object') {
355
+ logger.error({ message: `Expected Object, received ${$value.type}`, node, ast, loc: getLoc($value) });
356
+ return;
357
+ }
358
+ validateMembersAs(
359
+ $value,
360
+ {
361
+ color: { validator: validateColor, required: true },
362
+ offsetX: { validator: validateDimension, required: true },
363
+ offsetY: { validator: validateDimension, required: true },
364
+ blur: { validator: validateDimension },
365
+ spread: { validator: validateDimension },
366
+ },
367
+ node,
368
+ { ast, logger },
369
+ );
370
+ }
371
+
372
+ /**
373
+ * Verify a Stroke Style token is valid.
374
+ * @param {ValueNode} $value
375
+ * @param {AnyNode} node
376
+ * @param {ValidateOptions} options
377
+ * @return {void}
378
+ */
379
+ export function validateStrokeStyle($value, node, { ast, logger }) {
380
+ // note: strokeStyle’s values are NOT aliasable (unless by string, but that breaks validations)
381
+ if ($value.type === 'String') {
382
+ if (!STROKE_STYLE_VALUES.has($value.value)) {
383
+ logger.error({
384
+ message: `Unknown stroke style ${print($value)}. Expected one of: ${listFormat.format([
385
+ ...STROKE_STYLE_VALUES,
386
+ ])}.`,
387
+ node,
388
+ ast,
389
+ loc: getLoc($value),
390
+ });
391
+ }
392
+ } else if ($value.type === 'Object') {
393
+ const strokeMembers = getObjMembers($value);
394
+ for (const property of ['dashArray', 'lineCap']) {
395
+ if (!strokeMembers[property]) {
396
+ logger.error({
397
+ message: `Missing required property "${property}"`,
398
+ node,
399
+ ast,
400
+ loc: getLoc($value),
401
+ });
402
+ }
403
+ }
404
+ const { lineCap, dashArray } = strokeMembers;
405
+ if (lineCap?.type !== 'String' || !STROKE_STYLE_LINE_CAP_VALUES.has(lineCap.value)) {
406
+ logger.error({
407
+ message: `Unknown lineCap value ${print(lineCap)}. Expected one of: ${listFormat.format([
408
+ ...STROKE_STYLE_LINE_CAP_VALUES,
409
+ ])}.`,
410
+ });
411
+ }
412
+ if (dashArray?.type === 'Array') {
413
+ for (const element of dashArray.elements) {
414
+ if (element.value.type === 'String' && element.value.value !== '') {
415
+ if (isMaybeAlias(element.value)) {
416
+ validateAlias(element.value, node, { logger, ast });
417
+ } else {
418
+ validateDimension(element.value, node, { logger, ast });
419
+ }
420
+ } else {
421
+ logger.error({
422
+ message: 'Expected array of strings, recieved some non-strings or empty strings.',
423
+ node,
424
+ ast,
425
+ loc: getLoc(element),
426
+ });
427
+ }
428
+ }
429
+ } else {
430
+ logger.error({
431
+ message: `Expected array of strings, received ${dashArray.type}`,
432
+ node,
433
+ ast,
434
+ loc: getLoc($value),
435
+ });
436
+ }
437
+ } else {
438
+ logger.error({ message: `Expected string or object, received ${$value.type}`, node, ast, loc: getLoc($value) });
439
+ }
440
+ }
441
+
442
+ /**
443
+ * Verify a Transition token is valid
444
+ * @param {ValueNode} $value
445
+ * @param {AnyNode} node
446
+ * @param {ValidateOptions} options
447
+ * @return {void}
448
+ */
449
+ export function validateTransition($value, node, { ast, logger }) {
450
+ if ($value.type !== 'Object') {
451
+ logger.error({ message: `Expected object, received ${$value.type}`, node, ast, loc: getLoc($value) });
452
+ return;
453
+ }
454
+ validateMembersAs(
455
+ $value,
456
+ {
457
+ duration: { validator: validateDuration, required: true },
458
+ delay: { validator: validateDuration, required: false }, // note: spec says delay is required, but Terrazzo makes delay optional
459
+ timingFunction: { validator: validateCubicBézier, required: true },
460
+ },
461
+ node,
462
+ { ast, logger },
463
+ );
464
+ }
465
+
466
+ /**
467
+ * Validate a MemberNode (the entire token object, plus its key in the parent object) to see if it’s a valid DTCG token or not.
468
+ * Keeping the parent key really helps in debug messages.
469
+ * @param {AnyNode} node
470
+ * @param {ValidateOptions} options
471
+ * @return {void}
472
+ */
473
+ export default function validate(node, { ast, logger }) {
474
+ if (node.type !== 'Member' && node.type !== 'Object') {
475
+ logger.error({
476
+ message: `Expected Object, received ${JSON.stringify(node.type)}`,
477
+ node,
478
+ ast,
479
+ loc: { line: 1 },
480
+ });
481
+ return;
482
+ }
483
+
484
+ const rootMembers = node.value.type === 'Object' ? getObjMembers(node.value) : {};
485
+ const $value = rootMembers.$value;
486
+ const $type = rootMembers.$type;
487
+
488
+ if (!$value) {
489
+ logger.error({ message: 'Token missing $value', node, ast, loc: getLoc(node) });
490
+ return;
491
+ }
492
+ // If top-level value is a valid alias, this is valid (no need for $type)
493
+ // ⚠️ Important: ALL Object and Array nodes below will need to check for aliases within!
494
+ if (isMaybeAlias($value)) {
495
+ validateAlias($value, node, { logger, ast });
496
+ return;
497
+ }
498
+
499
+ if (!$type) {
500
+ logger.error({ message: 'Token missing $type', node, ast, loc: getLoc(node) });
501
+ return;
502
+ }
503
+
504
+ switch ($type.value) {
505
+ case 'color': {
506
+ validateColor($value, node, { logger, ast });
507
+ break;
508
+ }
509
+ case 'cubicBezier': {
510
+ validateCubicBézier($value, node, { logger, ast });
511
+ break;
512
+ }
513
+ case 'dimension': {
514
+ validateDimension($value, node, { logger, ast });
515
+ break;
516
+ }
517
+ case 'duration': {
518
+ validateDuration($value, node, { logger, ast });
519
+ break;
520
+ }
521
+ case 'fontFamily': {
522
+ validateFontFamily($value, node, { logger, ast });
523
+ break;
524
+ }
525
+ case 'fontWeight': {
526
+ validateFontWeight($value, node, { logger, ast });
527
+ break;
528
+ }
529
+ case 'number': {
530
+ validateNumber($value, node, { logger, ast });
531
+ break;
532
+ }
533
+ case 'shadow': {
534
+ if ($value.type === 'Object') {
535
+ validateShadowLayer($value, node, { logger, ast });
536
+ } else if ($value.type === 'Array') {
537
+ for (const element of $value.elements) {
538
+ validateShadowLayer(element.value, $value, { logger, ast });
539
+ }
540
+ } else {
541
+ logger.error({
542
+ message: `Expected shadow object or array of shadow objects, received ${$value.type}`,
543
+ node,
544
+ ast,
545
+ loc: getLoc($value),
546
+ });
547
+ }
548
+ break;
549
+ }
550
+
551
+ // extensions
552
+ case 'boolean': {
553
+ if ($value.type !== 'Boolean') {
554
+ logger.error({ message: `Expected boolean, received ${$value.type}`, node, ast, loc: getLoc($value) });
555
+ }
556
+ break;
557
+ }
558
+ case 'link': {
559
+ if ($value.type !== 'String') {
560
+ logger.error({ message: `Expected string, received ${$value.type}`, node, ast, loc: getLoc($value) });
561
+ } else if ($value.value === '') {
562
+ logger.error({ message: 'Expected URL, received empty string', node, ast, loc: getLoc($value) });
563
+ }
564
+ break;
565
+ }
566
+ case 'string': {
567
+ if ($value.type !== 'String') {
568
+ logger.error({ message: `Expected string, received ${$value.type}`, node, ast, loc: getLoc($value) });
569
+ }
570
+ break;
571
+ }
572
+
573
+ // composite types
574
+ case 'border': {
575
+ validateBorder($value, node, { ast, logger });
576
+ break;
577
+ }
578
+ case 'gradient': {
579
+ validateGradient($value, node, { ast, logger });
580
+ break;
581
+ }
582
+ case 'strokeStyle': {
583
+ validateStrokeStyle($value, node, { ast, logger });
584
+ break;
585
+ }
586
+ case 'transition': {
587
+ validateTransition($value, node, { ast, logger });
588
+ break;
589
+ }
590
+ case 'typography': {
591
+ if ($value.type !== 'Object') {
592
+ logger.error({ message: `Expected object, received ${$value.type}`, node, ast, loc: getLoc($value) });
593
+ break;
594
+ }
595
+ if ($value.members.length === 0) {
596
+ logger.error({
597
+ message: 'Empty typography token. Must contain at least 1 property.',
598
+ node,
599
+ ast,
600
+ loc: getLoc($value),
601
+ });
602
+ }
603
+ validateMembersAs(
604
+ $value,
605
+ {
606
+ fontFamily: { validator: validateFontFamily },
607
+ fontWeight: { validator: validateFontWeight },
608
+ },
609
+ node,
610
+ { ast, logger },
611
+ );
612
+ break;
613
+ }
614
+
615
+ default: {
616
+ // noop
617
+ break;
618
+ }
619
+ }
620
+ }
@@ -0,0 +1,11 @@
1
+ import type { DocumentNode } from '@humanwhocodes/momoa';
2
+ import type Logger from '../logger.js';
3
+
4
+ export interface ParseYAMLOptions {
5
+ logger: Logger;
6
+ }
7
+
8
+ /**
9
+ * Take a YAML document and create a Momoa JSON AST from it
10
+ */
11
+ export default function yamlToAST(input: string, options: ParseYAMLOptions): DocumentNode;
package/parse/yaml.js ADDED
@@ -0,0 +1,45 @@
1
+ import { Composer, Parser } from 'yaml';
2
+
3
+ /**
4
+ * @typedef {import("yaml").YAMLError} YAMLError
5
+ */
6
+
7
+ /**
8
+ * Convert YAML position to line, column
9
+ * @param {string} input
10
+ * @param {YAMLError{"pos"]} pos
11
+ * @return {import("@babel/code-frame").SourceLocation["start"]}
12
+ */
13
+ function posToLoc(input, pos) {
14
+ let line = 1;
15
+ let column = 0;
16
+ for (let i = 0; i <= (pos[0] || 0); i++) {
17
+ const c = input[i];
18
+ if (c === '\n') {
19
+ line++;
20
+ column = 0;
21
+ }
22
+ column++;
23
+ }
24
+ return { line, column };
25
+ }
26
+
27
+ /**
28
+ * Take a YAML document and create a Momoa JSON AST from it
29
+ */
30
+ export default function yamlToAST(input, { logger }) {
31
+ const parser = new Parser();
32
+ const composer = new Composer();
33
+ for (const node of composer.compose(parser.parse(input))) {
34
+ if (node.errors) {
35
+ for (const error of node.errors) {
36
+ logger.error({
37
+ label: 'parse:yaml',
38
+ message: `${error.code} ${error.message}`,
39
+ code: input,
40
+ loc: posToLoc(input, error.pos),
41
+ });
42
+ }
43
+ }
44
+ }
45
+ }