@terrazzo/parser 0.0.18 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # @terrazzo/parser
2
2
 
3
+ ## 0.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#319](https://github.com/terrazzoapp/terrazzo/pull/319) [`e7f272d`](https://github.com/terrazzoapp/terrazzo/commit/e7f272defcd889f5a410fdbd30497cf704671b32) Thanks [@drwpow](https://github.com/drwpow)! - ⚠️ Breaking change: dimension and duration tokens normalize to object syntax in plugins (following upcoming changes in DTCG spec; see https://github.com/design-tokens/community-group/pull/244).
8
+
9
+ ### Patch Changes
10
+
11
+ - Updated dependencies [[`e7f272d`](https://github.com/terrazzoapp/terrazzo/commit/e7f272defcd889f5a410fdbd30497cf704671b32)]:
12
+ - @terrazzo/token-tools@0.1.0
13
+
14
+ ## 0.0.19
15
+
16
+ ### Patch Changes
17
+
18
+ - [#313](https://github.com/terrazzoapp/terrazzo/pull/313) [`1408594`](https://github.com/terrazzoapp/terrazzo/commit/1408594de029f57137c936dc2ff9ab949f039215) Thanks [@drwpow](https://github.com/drwpow)! - Fix bug in gradient position aliasing
19
+
20
+ - [#313](https://github.com/terrazzoapp/terrazzo/pull/313) [`1408594`](https://github.com/terrazzoapp/terrazzo/commit/1408594de029f57137c936dc2ff9ab949f039215) Thanks [@drwpow](https://github.com/drwpow)! - Improve alias type validation
21
+
3
22
  ## 0.0.18
4
23
 
5
24
  ### Patch Changes
@@ -0,0 +1,25 @@
1
+ # Contributing to @terrazzo/parser
2
+
3
+ This document contains developer notes and context for @terrazzo/parser. For general contribution guidelines, see [CONTRIBUTING.md](../../CONTRIBUTING.md) in the root.
4
+
5
+ ## What this package does
6
+
7
+ - Parses, validates, and normalizes tokens.json
8
+ - Executes plugins and builds output
9
+ - Executes linting
10
+
11
+ ## What this package DOES NOT do
12
+
13
+ - Write files to disk (that’s a job for Node.js; this can be run in a browser)
14
+
15
+ ## Manual JS and DTS
16
+
17
+ This package is unique in its manual handling of `.js` and `.d.ts` files (TypeScript definitions). The expected norm is to write `.ts`, and have `.js` and `.d.ts` be generated automatically from source. But in this project, we write `.js` by hand because this specific module has several unique concerns:
18
+
19
+ 1. **It’s dealing with external (possibly invalid) code.** Now, you’d think that `.ts` would be better at handling this, but the reality when dealing with user-submitted code is if you type it too strongly, TS lulls you into a false sense of security and misses necessary assertions. If you type it weakly, then TS requires _too many_ assertions and you end up with boilerplate and noise. Or if you don’t type it at all, then TS doesn’t really provide any value in the first place, and you might as well skip it.
20
+
21
+ 2. **It’s dealing with ASTs.** We’re parsing JSON ASTs using Momoa, and building ad hoc structures on-the-fly. ASTs usually trip TS up because the discrimination isn’t clear (e.g. if `.type == 'foo'`, then `[property]` exists). Most ASTs don’t have perfect types out-of-the-box, and attempting to re-type an AST yourself is overkill. Further, to the previous point, we may be dealing with invalid code, and the AST may not always be in predictable shapes! When faced with these problems, TS at best requires extra boilerplate; at worst guides you into incorrect decisions.
22
+
23
+ Given both of these, you usually end up with so many `// @ts-ignore`s or `as any`s that TS doesn’t provide benefit.
24
+
25
+ But this package is unique! Look in any other package in this monorepo, and you’ll see it’s fully written in `.ts` source files. Different concerns require different approaches.
package/logger.js CHANGED
@@ -25,8 +25,12 @@ export function formatMessage(entry, severity) {
25
25
  }
26
26
  if (entry.src) {
27
27
  const start = entry.node?.loc?.start;
28
- // note: strip "file://" protocol, but not href
29
- message = `${message}\n\n${entry.filename ? `${entry.filename.href.replace(/^file:\/\//, '')}:${start?.line ?? 0}:${start?.column ?? 0}\n\n` : ''}${codeFrameColumns(entry.src, { start }, { highlightCode: false })}`;
28
+ // strip "file://" protocol, but not href
29
+ const loc = entry.filename
30
+ ? `${entry.filename?.href.replace(/^file:\/\//, '')}:${start?.line ?? 0}:${start?.column ?? 0}\n\n`
31
+ : '';
32
+ const codeFrame = codeFrameColumns(entry.src, { start }, { highlightCode: false });
33
+ message = `${message}\n\n${loc}${codeFrame}`;
30
34
  }
31
35
  return message;
32
36
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@terrazzo/parser",
3
- "version": "0.0.18",
3
+ "version": "0.1.0",
4
4
  "description": "Parser/validator for the Design Tokens Community Group (DTCG) standard.",
5
5
  "type": "module",
6
6
  "author": {
@@ -30,16 +30,16 @@
30
30
  },
31
31
  "license": "MIT",
32
32
  "dependencies": {
33
- "@humanwhocodes/momoa": "^3.2.1",
33
+ "@humanwhocodes/momoa": "^3.3.0",
34
34
  "@types/babel__code-frame": "^7.0.6",
35
35
  "@types/culori": "^2.1.1",
36
36
  "culori": "^4.0.1",
37
37
  "is-what": "^4.1.16",
38
38
  "merge-anything": "^5.1.7",
39
- "picocolors": "^1.1.0",
39
+ "picocolors": "^1.1.1",
40
40
  "wildcard-match": "^5.1.3",
41
- "yaml": "^2.5.1",
42
- "@terrazzo/token-tools": "^0.0.9"
41
+ "yaml": "^2.6.0",
42
+ "@terrazzo/token-tools": "^0.1.0"
43
43
  },
44
44
  "devDependencies": {
45
45
  "esbuild": "^0.23.1",
package/parse/index.js CHANGED
@@ -131,7 +131,12 @@ export default async function parse(
131
131
  if (mode === '.') {
132
132
  continue; // skip shadow of root value
133
133
  }
134
- applyAliases(tokens[id].mode[mode], { tokens, node: tokens[id].mode[mode].source.node, logger });
134
+ applyAliases(tokens[id].mode[mode], {
135
+ tokens,
136
+ node: tokens[id].mode[mode].source.node,
137
+ logger,
138
+ src: sources[tokens[id].source.loc]?.src,
139
+ });
135
140
  }
136
141
  }
137
142
  logger.debug({
@@ -235,10 +240,11 @@ async function parseSingle(input, { filename, logger, config, skipLint, continue
235
240
  $typeInheritance[path.join('.') || '.'] = node.value.members.find((m) => m.name.value === '$type');
236
241
  }
237
242
 
243
+ const id = path.join('.');
244
+
238
245
  if (members.$value) {
239
246
  const extensions = members.$extensions ? getObjMembers(members.$extensions) : undefined;
240
247
  const sourceNode = structuredClone(node);
241
- const id = path.join('.');
242
248
 
243
249
  // get parent type by taking the closest-scoped $type (length === closer)
244
250
  let parent$type;
@@ -304,7 +310,7 @@ async function parseSingle(input, { filename, logger, config, skipLint, continue
304
310
  }
305
311
 
306
312
  tokens[id] = token;
307
- } else if (members.value) {
313
+ } else if (!id.includes('.$value') && members.value) {
308
314
  logger.warn({ message: `Group ${id} has "value". Did you mean "$value"?`, filename, node, src });
309
315
  }
310
316
  }
@@ -419,10 +425,10 @@ export function maybeJSONString(input) {
419
425
  export function resolveAlias(alias, { tokens, logger, filename, src, node, scanned = [] }) {
420
426
  const { id } = parseAlias(alias);
421
427
  if (!tokens[id]) {
422
- logger.error({ message: `Alias "${alias}" not found`, filename, src, node });
428
+ logger.error({ message: `Alias "${alias}" not found.`, filename, src, node });
423
429
  }
424
430
  if (scanned.includes(id)) {
425
- logger.error({ message: `Circular alias detected from "${alias}"`, filename, src, node });
431
+ logger.error({ message: `Circular alias detected from "${alias}".`, filename, src, node });
426
432
  }
427
433
  const token = tokens[id];
428
434
  if (!isAlias(token.$value)) {
@@ -431,8 +437,43 @@ export function resolveAlias(alias, { tokens, logger, filename, src, node, scann
431
437
  return resolveAlias(token.$value, { tokens, logger, filename, node, src, scanned: [...scanned, id] });
432
438
  }
433
439
 
440
+ /** Throw error if resolved alias for composite properties doesn’t match expected $type. */
441
+ const COMPOSITE_TYPE_VALUES = {
442
+ border: {
443
+ color: ['color'],
444
+ width: ['dimension'],
445
+ strokeStyle: ['strokeStyle'],
446
+ },
447
+ gradient: {
448
+ color: ['color'],
449
+ position: ['number'],
450
+ },
451
+ shadow: {
452
+ color: ['color'],
453
+ position: ['dimension'],
454
+ },
455
+ strokeStyle: {
456
+ dashArray: ['dimension'],
457
+ },
458
+ transition: {
459
+ duration: ['duration'],
460
+ delay: ['duration'],
461
+ timingFunction: ['cubicBezier'],
462
+ },
463
+ typography: {
464
+ fontFamily: ['fontFamily'],
465
+ fontSize: ['dimension'],
466
+ fontWeight: ['fontWeight'],
467
+ letterSpacing: ['dimension'],
468
+ lineHeight: ['dimension', 'number'],
469
+ },
470
+ };
471
+
434
472
  /** Resolve aliases, update values, and mutate `token` to add `aliasOf` / `partialAliasOf` */
435
473
  function applyAliases(token, { tokens, logger, filename, src, node }) {
474
+ const $valueNode = (node.type === 'Object' && node.members.find((m) => m.name.value === '$value')?.value) || node;
475
+ const expectedAliasTypes = COMPOSITE_TYPE_VALUES[token.$type];
476
+
436
477
  // handle simple aliases
437
478
  if (isAlias(token.$value)) {
438
479
  const aliasOfID = resolveAlias(token.$value, { tokens, logger, filename, node, src });
@@ -441,15 +482,16 @@ function applyAliases(token, { tokens, logger, filename, src, node }) {
441
482
  token.aliasOf = aliasOfID;
442
483
  token.$value = aliasOf.mode[aliasMode]?.$value || aliasOf.$value;
443
484
  if (token.$type && token.$type !== aliasOf.$type) {
444
- logger.warn({
445
- message: `Token ${token.id} has $type "${token.$type}" but aliased ${aliasOfID} of $type "${aliasOf.$type}"`,
446
- node,
485
+ logger.error({
486
+ message: `Invalid alias: expected $type: "${token.$type}", received $type: "${aliasOf.$type}".`,
487
+ node: $valueNode,
488
+ filename,
489
+ src,
447
490
  });
448
- token.$type = aliasOf.$type;
449
- } else {
450
- token.$type = aliasOf.$type;
451
491
  }
492
+ token.$type = aliasOf.$type;
452
493
  }
494
+
453
495
  // handle aliases within array values (e.g. cubicBezier, gradient)
454
496
  else if (Array.isArray(token.$value)) {
455
497
  // some arrays are primitives, some are objects. handle both
@@ -473,8 +515,20 @@ function applyAliases(token, { tokens, logger, filename, src, node }) {
473
515
  }
474
516
  const aliasOfID = resolveAlias(token.$value[i][property], { tokens, logger, filename, node, src });
475
517
  const { id: aliasID, mode: aliasMode } = parseAlias(token.$value[i][property]);
518
+ const aliasToken = tokens[aliasOfID];
519
+
520
+ if (expectedAliasTypes?.[property] && !expectedAliasTypes[property].includes(aliasToken.$type)) {
521
+ const elementNode = $valueNode.elements[i].value;
522
+ logger.error({
523
+ message: `Invalid alias: expected $type: "${expectedAliasTypes[property].join('" or "')}", received $type: "${aliasToken.$type}".`,
524
+ node: elementNode.members.find((m) => m.name.value === property).value,
525
+ filename,
526
+ src,
527
+ });
528
+ }
529
+
476
530
  token.partialAliasOf[i][property] = aliasID; // also keep the shallow alias here, too!
477
- token.$value[i][property] = tokens[aliasOfID].mode[aliasMode]?.$value || tokens[aliasOfID].$value;
531
+ token.$value[i][property] = aliasToken.mode[aliasMode]?.$value || aliasToken.$value;
478
532
  }
479
533
  }
480
534
  }
@@ -494,8 +548,18 @@ function applyAliases(token, { tokens, logger, filename, src, node }) {
494
548
  const aliasOfID = resolveAlias(token.$value[property], { tokens, logger, filename, node, src });
495
549
  const { id: aliasID, mode: aliasMode } = parseAlias(token.$value[property]);
496
550
  token.partialAliasOf[property] = aliasID; // keep the shallow alias!
497
- token.$value[property] = tokens[aliasOfID].mode[aliasMode]?.$value || tokens[aliasOfID].$value;
551
+ const aliasToken = tokens[aliasOfID];
552
+ if (expectedAliasTypes?.[property] && !expectedAliasTypes[property].includes(aliasToken.$type)) {
553
+ logger.error({
554
+ message: `Invalid alias: expected $type: "${expectedAliasTypes[property].join('" or "')}", received $type: "${aliasToken.$type}".`,
555
+ node: $valueNode.members.find((m) => m.name.value === property).value,
556
+ filename,
557
+ src,
558
+ });
559
+ }
560
+ token.$value[property] = aliasToken.mode[aliasMode]?.$value || aliasToken.$value;
498
561
  }
562
+
499
563
  // strokeStyle has an array within an object
500
564
  else if (Array.isArray(token.$value[property])) {
501
565
  for (let i = 0; i < token.$value[property].length; i++) {
@@ -509,6 +573,16 @@ function applyAliases(token, { tokens, logger, filename, src, node }) {
509
573
  }
510
574
  const { id: aliasID, mode: aliasMode } = parseAlias(token.$value[property][i]);
511
575
  token.partialAliasOf[property][i] = aliasID; // keep the shallow alias!
576
+ const aliasToken = tokens[aliasOfID];
577
+ if (expectedAliasTypes?.[property] && !expectedAliasTypes[property].includes(aliasToken.$type)) {
578
+ const arrayNode = $valueNode.members.find((m) => m.name.value === property).value;
579
+ logger.error({
580
+ message: `Invalid alias: expected $type: "${expectedAliasTypes[property].join('" or "')}", received $type: "${aliasToken.$type}".`,
581
+ node: arrayNode.elements[i],
582
+ filename,
583
+ src,
584
+ });
585
+ }
512
586
  token.$value[property][i] = tokens[aliasOfID].mode[aliasMode]?.$value || tokens[aliasOfID].$value;
513
587
  }
514
588
  }
@@ -21,6 +21,8 @@ export const FONT_WEIGHT_MAP = {
21
21
  'ultra-black': 950,
22
22
  };
23
23
 
24
+ const NUMBER_WITH_UNIT_RE = /(-?\d*\.?\d+)(.*)/;
25
+
24
26
  export default function normalizeValue(token) {
25
27
  if (isAlias(token.$value)) {
26
28
  return token.$value;
@@ -49,15 +51,25 @@ export default function normalizeValue(token) {
49
51
  }
50
52
  case 'dimension': {
51
53
  if (token.$value === 0) {
52
- return '0';
54
+ return { value: 0, unit: 'px' };
55
+ }
56
+ // Backwards compat: handle string
57
+ if (typeof token.$value === 'string') {
58
+ const match = token.$value.match(NUMBER_WITH_UNIT_RE);
59
+ return { value: Number.parseFloat(match?.[1] || token.$value), unit: match[2] || 'px' };
53
60
  }
54
- return typeof token.$value === 'number' ? `${token.$value}px` : token.$value;
61
+ return token.$value;
55
62
  }
56
63
  case 'duration': {
57
64
  if (token.$value === 0) {
58
- return 0;
65
+ return { value: 0, unit: 'ms' };
59
66
  }
60
- return typeof token.$value === 'number' ? `${token.$value}ms` : token.$value;
67
+ // Backwards compat: handle string
68
+ if (typeof token.$value === 'string') {
69
+ const match = token.$value.match(NUMBER_WITH_UNIT_RE);
70
+ return { value: Number.parseFloat(match?.[1] || token.$value), unit: match[2] || 'ms' };
71
+ }
72
+ return token.$value;
61
73
  }
62
74
  case 'fontFamily': {
63
75
  return Array.isArray(token.$value) ? token.$value : [token.$value];
@@ -73,7 +85,7 @@ export default function normalizeValue(token) {
73
85
  for (let i = 0; i < token.$value.length; i++) {
74
86
  const stop = { ...token.$value[i] };
75
87
  stop.color = normalizeValue({ $type: 'color', $value: stop.color });
76
- if (typeof stop.position !== 'number') {
88
+ if (stop.position === undefined) {
77
89
  stop.position = i / (token.$value.length - 1);
78
90
  }
79
91
  output.push(stop);
@@ -12,7 +12,7 @@ export interface ValidateOptions {
12
12
  logger: Logger;
13
13
  }
14
14
 
15
- export function validateAlias($value: ValueNode, node: AnyNode, options: ValidateOptions): void;
15
+ export function validateAliasSyntax($value: ValueNode, node: AnyNode, options: ValidateOptions): void;
16
16
 
17
17
  export function validateBorder($value: ValueNode, node: AnyNode, options: ValidateOptions): void;
18
18
 
package/parse/validate.js CHANGED
@@ -92,7 +92,7 @@ function validateMembersAs($value, properties, node, { filename, src, logger })
92
92
  }
93
93
  const value = members[property];
94
94
  if (isMaybeAlias(value)) {
95
- validateAlias(value, node, { filename, src, logger });
95
+ validateAliasSyntax(value, node, { filename, src, logger });
96
96
  } else {
97
97
  validator(value, node, { filename, src, logger });
98
98
  }
@@ -100,13 +100,13 @@ function validateMembersAs($value, properties, node, { filename, src, logger })
100
100
  }
101
101
 
102
102
  /**
103
- * Verify an Alias token is valid
103
+ * Verify an Alias $value is formatted correctly
104
104
  * @param {ValueNode} $value
105
105
  * @param {AnyNode} node
106
106
  * @param {ValidateOptions} options
107
107
  * @return {void}
108
108
  */
109
- export function validateAlias($value, node, { filename, src, logger }) {
109
+ export function validateAliasSyntax($value, node, { filename, src, logger }) {
110
110
  if ($value.type !== 'String' || !isAlias($value.value)) {
111
111
  logger.error({ message: `Invalid alias: ${print($value)}`, filename, node: $value, src });
112
112
  }
@@ -262,13 +262,43 @@ export function validateDimension($value, node, { filename, src, logger }) {
262
262
  if ($value.type === 'Number' && $value.value === 0) {
263
263
  return; // `0` is a valid number
264
264
  }
265
+ if ($value.type === 'Object') {
266
+ const { value, unit } = getObjMembers($value);
267
+ if (!value) {
268
+ logger.error({ message: 'Missing required property "value".', filename, node: $value, src });
269
+ }
270
+ if (!unit) {
271
+ logger.error({ message: 'Missing required property "unit".', filename, node: $value, src });
272
+ }
273
+ if (value.type !== 'Number') {
274
+ logger.error({ message: `Expected number, received ${value.type}`, filename, node: value, src });
275
+ }
276
+ if (!['px', 'em', 'rem'].includes(unit.value)) {
277
+ logger.error({
278
+ message: `Expected unit "px", "em", or "rem", received ${print(unit)}`,
279
+ filename,
280
+ node: unit,
281
+ src,
282
+ });
283
+ }
284
+ return;
285
+ }
286
+ // Backwards compat: string
265
287
  if ($value.type !== 'String') {
266
288
  logger.error({ message: `Expected string, received ${$value.type}`, filename, node: $value, src });
267
- } else if ($value.value === '') {
289
+ }
290
+ const value = $value.value.match(/^-?[0-9.]+/)?.[0];
291
+ const unit = $value.value.replace(value, '');
292
+ if ($value.value === '') {
268
293
  logger.error({ message: 'Expected dimension, received empty string', filename, node: $value, src });
269
- } else if (String(Number.parseFloat($value.value)) === $value.value) {
270
- logger.error({ message: 'Missing units', filename, node: $value, src });
271
- } else if (!/^-?[0-9]+(\.[0-9]+)?/.test($value.value)) {
294
+ } else if (!['px', 'em', 'rem'].includes(unit)) {
295
+ logger.error({
296
+ message: `Expected unit "px", "em", or "rem", received ${JSON.stringify(unit || $value.value)}`,
297
+ filename,
298
+ node: $value,
299
+ src,
300
+ });
301
+ } else if (!Number.isFinite(Number.parseFloat(value))) {
272
302
  logger.error({ message: `Expected dimension with units, received ${print($value)}`, filename, node: $value, src });
273
303
  }
274
304
  }
@@ -284,19 +314,39 @@ export function validateDuration($value, node, { filename, src, logger }) {
284
314
  if ($value.type === 'Number' && $value.value === 0) {
285
315
  return; // `0` is a valid number
286
316
  }
317
+ if ($value.type === 'Object') {
318
+ const { value, unit } = getObjMembers($value);
319
+ if (!value) {
320
+ logger.error({ message: 'Missing required property "value".', filename, node: $value, src });
321
+ }
322
+ if (!unit) {
323
+ logger.error({ message: 'Missing required property "unit".', filename, node: $value, src });
324
+ }
325
+ if (value.type !== 'Number') {
326
+ logger.error({ message: `Expected number, received ${value.type}`, filename, node: value, src });
327
+ }
328
+ if (!['ms', 's'].includes(unit.value)) {
329
+ logger.error({ message: `Expected unit "ms" or "s", received ${print(unit)}`, filename, node: unit, src });
330
+ }
331
+ return;
332
+ }
333
+ // Backwards compat: string
287
334
  if ($value.type !== 'String') {
288
335
  logger.error({ message: `Expected string, received ${$value.type}`, filename, node: $value, src });
289
- } else if ($value.value === '') {
336
+ }
337
+ const value = $value.value.match(/^-?[0-9.]+/)?.[0];
338
+ const unit = $value.value.replace(value, '');
339
+ if ($value.value === '') {
290
340
  logger.error({ message: 'Expected duration, received empty string', filename, node: $value, src });
291
- } else if (!/m?s$/.test($value.value)) {
292
- logger.error({ message: 'Missing unit "ms" or "s"', filename, node: $value, src });
293
- } else if (!/^[0-9]+/.test($value.value)) {
341
+ } else if (!['ms', 's'].includes(unit)) {
294
342
  logger.error({
295
- message: `Expected duration in \`ms\` or \`s\`, received ${print($value)}`,
343
+ message: `Expected unit "ms" or "s", received ${JSON.stringify(unit || $value.value)}`,
296
344
  filename,
297
345
  node: $value,
298
346
  src,
299
347
  });
348
+ } else if (!Number.isFinite(Number.parseFloat(value))) {
349
+ logger.error({ message: `Expected duration with units, received ${print($value)}`, filename, node: $value, src });
300
350
  }
301
351
  }
302
352
 
@@ -476,7 +526,7 @@ export function validateStrokeStyle($value, node, { filename, src, logger }) {
476
526
  for (const element of dashArray.elements) {
477
527
  if (element.value.type === 'String' && element.value.value !== '') {
478
528
  if (isMaybeAlias(element.value)) {
479
- validateAlias(element.value, node, { logger, src });
529
+ validateAliasSyntax(element.value, node, { logger, src });
480
530
  } else {
481
531
  validateDimension(element.value, node, { logger, src });
482
532
  }
@@ -550,7 +600,7 @@ export default function validate(node, { filename, src, logger }) {
550
600
  // If top-level value is a valid alias, this is valid (no need for $type)
551
601
  // ⚠️ Important: ALL Object and Array nodes below will need to check for aliases within!
552
602
  if (isMaybeAlias($value)) {
553
- validateAlias($value, node, { logger, src });
603
+ validateAliasSyntax($value, node, { logger, src });
554
604
  return;
555
605
  }
556
606