@terrazzo/parser 0.0.10 → 0.0.12

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/parse/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { evaluate, parse as parseJSON, print } from '@humanwhocodes/momoa';
2
2
  import { isAlias, parseAlias, pluralize, splitID } from '@terrazzo/token-tools';
3
+ import { fileURLToPath } from 'node:url';
3
4
  import lintRunner from '../lint/index.js';
4
5
  import Logger from '../logger.js';
5
6
  import normalize from './normalize.js';
@@ -9,50 +10,189 @@ import { getObjMembers, injectObjMembers, traverse } from './json.js';
9
10
 
10
11
  export * from './validate.js';
11
12
 
13
+ /** @typedef {import("@humanwhocodes/momoa").DocumentNode} DocumentNode */
14
+ /** @typedef {import("../config.js").Plugin} Plugin */
15
+ /** @typedef {import("../types.js").TokenNormalized} TokenNormalized */
12
16
  /**
13
- * @typedef {import("@humanwhocodes/momoa").DocumentNode} DocumentNode
14
- * @typedef {import("../config.js").Plugin} Plugin
15
- * @typedef {import("../types.js").TokenNormalized} TokenNormalized
16
- * @typedef {object} ParseOptions
17
- * @typedef {Logger} ParseOptions.logger
18
- * @typedef {boolean=false} ParseOptions.skipLint
19
- * @typedef {Plugin[]} ParseOptions.plugins
20
17
  * @typedef {object} ParseResult
21
- * @typedef {Record<string, TokenNormalized} ParseResult.tokens
22
- * @typedef {DocumentNode} ParseResult.ast
18
+ * @property {Record<string, TokenNormalized} tokens
19
+ * @property {Object[]} src
20
+ */
21
+ /**
22
+ * @typedef {object} ParseInput
23
+ * @property {string | object} src
24
+ * @property {URL} [filename]
25
+ */
26
+ /**
27
+ * @typedef {object} ParseOptions
28
+ * @property {Logger} logger
29
+ * @property {import("../config.js").Config} config
30
+ * @property {boolean} [skipLint=false]
31
+ * @property {boolean} [continueOnError=false]
23
32
  */
24
-
25
33
  /**
26
34
  * Parse
27
- * @param {string | object} input
28
- * @param {ParseOptions} options
35
+ * @param {ParseInput[]} input
36
+ * @param {ParseOptions} [options]
29
37
  * @return {Promise<ParseResult>}
30
38
  */
31
39
  export default async function parse(
32
40
  input,
33
- { logger = new Logger(), skipLint = false, config, continueOnError = false } = {},
41
+ { logger = new Logger(), skipLint = false, config = {}, continueOnError = false } = {},
34
42
  ) {
35
- const { plugins } = config;
43
+ let tokens = {};
44
+ // note: only keeps track of sources with locations on disk; in-memory sources are discarded
45
+ // (it’s only for reporting line numbers, which doesn’t mean as much for dynamic sources)
46
+ const sources = {};
47
+
48
+ if (!Array.isArray(input)) {
49
+ logger.error({ group: 'parser', task: 'init', message: 'Input must be an array of input objects.' });
50
+ }
51
+ for (let i = 0; i < input.length; i++) {
52
+ if (!input[i] || typeof input[i] !== 'object') {
53
+ logger.error({ group: 'parser', task: 'init', message: `Input (${i}) must be an object.` });
54
+ }
55
+ if (!input[i].src || (typeof input[i].src !== 'string' && typeof input[i].src !== 'object')) {
56
+ logger.error({
57
+ group: 'parser',
58
+ task: 'init',
59
+ message: `Input (${i}) missing "src" with a JSON/YAML string, or JSON object.`,
60
+ });
61
+ }
62
+ if (input[i].filename && !(input[i].filename instanceof URL)) {
63
+ logger.error({
64
+ group: 'parser',
65
+ task: 'init',
66
+ message: `Input (${i}) "filename" must be a URL (remote or file URL).`,
67
+ });
68
+ }
69
+
70
+ const result = await parseSingle(input[i].src, {
71
+ filename: input[i].filename,
72
+ logger,
73
+ config,
74
+ skipLint,
75
+ continueOnError,
76
+ });
77
+
78
+ tokens = Object.assign(tokens, result.tokens);
79
+ if (input[i].filename) {
80
+ sources[input[i].filename.protocol === 'file:' ? fileURLToPath(input[i].filename) : input[i].filename.href] = {
81
+ filename: input[i].filename,
82
+ src: result.src,
83
+ document: result.document,
84
+ };
85
+ }
86
+ }
36
87
 
37
88
  const totalStart = performance.now();
38
89
 
90
+ // 5. Resolve aliases and populate groups
91
+ for (const id in tokens) {
92
+ if (!Object.hasOwn(tokens, id)) {
93
+ continue;
94
+ }
95
+ const token = tokens[id];
96
+ applyAliases(token, {
97
+ tokens,
98
+ filename: sources[token.source.loc]?.filename,
99
+ src: sources[token.source.loc]?.src,
100
+ node: token.source.node,
101
+ logger,
102
+ });
103
+ token.mode['.'].$value = token.$value;
104
+ if (token.aliasOf) {
105
+ token.mode['.'].aliasOf = token.aliasOf;
106
+ }
107
+ if (token.partialAliasOf) {
108
+ token.mode['.'].partialAliasOf = token.partialAliasOf;
109
+ }
110
+ const { group: parentGroup } = splitID(id);
111
+ for (const siblingID in tokens) {
112
+ const { group: siblingGroup } = splitID(siblingID);
113
+ if (siblingGroup?.startsWith(parentGroup)) {
114
+ token.group.tokens.push(siblingID);
115
+ }
116
+ }
117
+ }
118
+
119
+ // 6. resolve mode aliases
120
+ const modesStart = performance.now();
121
+ logger.debug({
122
+ group: 'parser',
123
+ task: 'modes',
124
+ message: 'Start mode resolution',
125
+ });
126
+ for (const id in tokens) {
127
+ if (!Object.hasOwn(tokens, id)) {
128
+ continue;
129
+ }
130
+ for (const mode in tokens[id].mode) {
131
+ if (mode === '.') {
132
+ continue; // skip shadow of root value
133
+ }
134
+ applyAliases(tokens[id].mode[mode], { tokens, node: tokens[id].mode[mode].source.node, logger });
135
+ }
136
+ }
137
+ logger.debug({
138
+ group: 'parser',
139
+ task: 'modes',
140
+ message: 'Finish token modes',
141
+ timing: performance.now() - modesStart,
142
+ });
143
+
144
+ logger.debug({
145
+ group: 'parser',
146
+ task: 'core',
147
+ message: 'Finish all parser tasks',
148
+ timing: performance.now() - totalStart,
149
+ });
150
+
151
+ if (continueOnError) {
152
+ const { errorCount } = logger.stats();
153
+ if (errorCount > 0) {
154
+ logger.error({
155
+ message: `Parser encountered ${errorCount} ${pluralize(errorCount, 'error', 'errors')}. Exiting.`,
156
+ });
157
+ }
158
+ }
159
+
160
+ return {
161
+ tokens,
162
+ sources,
163
+ };
164
+ }
165
+
166
+ /**
167
+ * Parse a single input
168
+ * @param {string | object} input
169
+ * @param {object} options
170
+ * @param {URL} [options.filename]
171
+ * @param {Logger} [options.logger]
172
+ * @param {import("../config.js").Config} [options.config]
173
+ * @param {boolean} [options.skipLint]
174
+ */
175
+ async function parseSingle(input, { filename, logger, config, skipLint, continueOnError = false }) {
39
176
  // 1. Build AST
40
- let source;
177
+ let src;
41
178
  if (typeof input === 'string') {
42
- source = input;
179
+ src = input;
43
180
  }
44
181
  const startParsing = performance.now();
45
182
  logger.debug({ group: 'parser', task: 'parse', message: 'Start tokens parsing' });
46
- let ast;
183
+ let document;
47
184
  if (typeof input === 'string' && !maybeJSONString(input)) {
48
- ast = parseYAML(input, { logger }); // if string, but not JSON, attempt YAML
185
+ document = parseYAML(input, { logger }); // if string, but not JSON, attempt YAML
49
186
  } else {
50
- ast = parseJSON(typeof input === 'string' ? input : JSON.stringify(input, undefined, 2), {
51
- mode: 'jsonc',
52
- }); // everything else: assert it’s JSON-serializable
187
+ document = parseJSON(
188
+ typeof input === 'string' ? input : JSON.stringify(input, undefined, 2), // everything else: assert it’s JSON-serializable
189
+ {
190
+ mode: 'jsonc',
191
+ },
192
+ );
53
193
  }
54
- if (!source) {
55
- source = print(ast, { indent: 2 });
194
+ if (!src) {
195
+ src = print(document, { indent: 2 });
56
196
  }
57
197
  logger.debug({
58
198
  group: 'parser',
@@ -67,7 +207,7 @@ export default async function parse(
67
207
  const startValidation = performance.now();
68
208
  logger.debug({ group: 'parser', task: 'validate', message: 'Start tokens validation' });
69
209
  const $typeInheritance = {};
70
- traverse(ast, {
210
+ traverse(document, {
71
211
  enter(node, parent, path) {
72
212
  if (node.type === 'Member' && node.value.type === 'Object' && node.value.members) {
73
213
  const members = getObjMembers(node.value);
@@ -96,7 +236,8 @@ export default async function parse(
96
236
  if (parent$type && !members.$type) {
97
237
  sourceNode.value = injectObjMembers(sourceNode.value, [parent$type]);
98
238
  }
99
- validate(sourceNode, { source, logger });
239
+
240
+ validate(sourceNode, { filename, src, logger });
100
241
 
101
242
  const group = { id: splitID(id).group, tokens: [] };
102
243
  if (parent$type) {
@@ -117,7 +258,10 @@ export default async function parse(
117
258
  mode: {},
118
259
  originalValue: evaluate(node.value),
119
260
  group,
120
- sourceNode: sourceNode.value,
261
+ source: {
262
+ loc: filename ? fileURLToPath(filename) : undefined,
263
+ node: sourceNode.value,
264
+ },
121
265
  };
122
266
  if (members.$description?.value) {
123
267
  token.$description = members.$description.value;
@@ -131,7 +275,10 @@ export default async function parse(
131
275
  id: token.id,
132
276
  $type: token.$type,
133
277
  $value: mode === '.' ? token.$value : evaluate(modeValues[mode]),
134
- sourceNode: mode === '.' ? structuredClone(token.sourceNode) : modeValues[mode],
278
+ source: {
279
+ loc: filename ? fileURLToPath(filename) : undefined,
280
+ node: mode === '.' ? structuredClone(token.source.node) : modeValues[mode],
281
+ },
135
282
  };
136
283
  if (token.$description) {
137
284
  token.mode[mode].$description = token.$description;
@@ -140,7 +287,7 @@ export default async function parse(
140
287
 
141
288
  tokens[id] = token;
142
289
  } else if (members.value) {
143
- logger.warn({ message: `Group ${id} has "value". Did you mean "$value"?`, node, source });
290
+ logger.warn({ message: `Group ${id} has "value". Did you mean "$value"?`, filename, node, src });
144
291
  }
145
292
  }
146
293
 
@@ -161,14 +308,14 @@ export default async function parse(
161
308
  });
162
309
 
163
310
  // 3. Execute lint runner with loaded plugins
164
- if (!skipLint && plugins?.length) {
311
+ if (!skipLint && config?.plugins?.length) {
165
312
  const lintStart = performance.now();
166
313
  logger.debug({
167
314
  group: 'parser',
168
315
  task: 'validate',
169
316
  message: 'Start token linting',
170
317
  });
171
- await lintRunner({ ast, source, config, logger });
318
+ await lintRunner({ document, filename, src, config, logger });
172
319
  logger.debug({
173
320
  group: 'parser',
174
321
  task: 'validate',
@@ -191,12 +338,12 @@ export default async function parse(
191
338
  try {
192
339
  tokens[id].$value = normalize(tokens[id]);
193
340
  } catch (err) {
194
- let node = tokens[id].sourceNode;
341
+ let { node } = tokens[id].source;
195
342
  const members = getObjMembers(node);
196
343
  if (members.$value) {
197
344
  node = members.$value;
198
345
  }
199
- logger.error({ message: err.message, source, node, continueOnError });
346
+ logger.error({ message: err.message, filename, src, node, continueOnError });
200
347
  }
201
348
  for (const mode in tokens[id].mode) {
202
349
  if (mode === '.') {
@@ -205,12 +352,18 @@ export default async function parse(
205
352
  try {
206
353
  tokens[id].mode[mode].$value = normalize(tokens[id].mode[mode]);
207
354
  } catch (err) {
208
- let node = tokens[id].sourceNode;
355
+ let { node } = tokens[id].source;
209
356
  const members = getObjMembers(node);
210
357
  if (members.$value) {
211
358
  node = members.$value;
212
359
  }
213
- logger.error({ message: err.message, source, node: tokens[id].mode[mode].sourceNode, continueOnError });
360
+ logger.error({
361
+ message: err.message,
362
+ filename,
363
+ src,
364
+ node: tokens[id].mode[mode].source.node,
365
+ continueOnError,
366
+ });
214
367
  }
215
368
  }
216
369
  }
@@ -221,74 +374,7 @@ export default async function parse(
221
374
  timing: performance.now() - normalizeStart,
222
375
  });
223
376
 
224
- // 5. Resolve aliases and populate groups
225
- for (const id in tokens) {
226
- if (!Object.hasOwn(tokens, id)) {
227
- continue;
228
- }
229
- const token = tokens[id];
230
- applyAliases(token, { tokens, source, node: token.sourceNode, logger });
231
- token.mode['.'].$value = token.$value;
232
- if (token.aliasOf) {
233
- token.mode['.'].aliasOf = token.aliasOf;
234
- }
235
- if (token.partialAliasOf) {
236
- token.mode['.'].partialAliasOf = token.partialAliasOf;
237
- }
238
- const { group: parentGroup } = splitID(id);
239
- for (const siblingID in tokens) {
240
- const { group: siblingGroup } = splitID(siblingID);
241
- if (siblingGroup?.startsWith(parentGroup)) {
242
- token.group.tokens.push(siblingID);
243
- }
244
- }
245
- }
246
-
247
- // 6. resolve mode aliases
248
- const modesStart = performance.now();
249
- logger.debug({
250
- group: 'parser',
251
- task: 'modes',
252
- message: 'Start mode resolution',
253
- });
254
- for (const id in tokens) {
255
- if (!Object.hasOwn(tokens, id)) {
256
- continue;
257
- }
258
- for (const mode in tokens[id].mode) {
259
- if (mode === '.') {
260
- continue; // skip shadow of root value
261
- }
262
- applyAliases(tokens[id].mode[mode], { tokens, source, node: tokens[id].mode[mode].sourceNode, logger });
263
- }
264
- }
265
- logger.debug({
266
- group: 'parser',
267
- task: 'modes',
268
- message: 'Finish token modes',
269
- timing: performance.now() - modesStart,
270
- });
271
-
272
- logger.debug({
273
- group: 'parser',
274
- task: 'core',
275
- message: 'Finish all parser tasks',
276
- timing: performance.now() - totalStart,
277
- });
278
-
279
- if (continueOnError) {
280
- const { errorCount } = logger.stats();
281
- if (errorCount > 0) {
282
- logger.error({
283
- message: `Parser encountered ${errorCount} ${pluralize(errorCount, 'error', 'errors')}. Exiting.`,
284
- });
285
- }
286
- }
287
-
288
- return {
289
- tokens,
290
- ast,
291
- };
377
+ return { tokens, document, src };
292
378
  }
293
379
 
294
380
  /**
@@ -306,31 +392,32 @@ export function maybeJSONString(input) {
306
392
  * @param {Object} options
307
393
  * @param {Record<string, TokenNormalized>} options.tokens
308
394
  * @param {Logger} options.logger
395
+ * @param {string} [options.filename]
309
396
  * @param {AnyNode} [options.node]
310
397
  * @param {string} [options.string]
311
- * @param {string[]=[]} options.scanned
398
+ * @param {string} [options.scanned=[]]
312
399
  * @param {string}
313
400
  */
314
- export function resolveAlias(alias, { tokens, logger, source, node, scanned = [] }) {
401
+ export function resolveAlias(alias, { tokens, logger, filename, src, node, scanned = [] }) {
315
402
  const { id } = parseAlias(alias);
316
403
  if (!tokens[id]) {
317
- logger.error({ message: `Alias "${alias}" not found`, source, node });
404
+ logger.error({ message: `Alias "${alias}" not found`, filename, src, node });
318
405
  }
319
406
  if (scanned.includes(id)) {
320
- logger.error({ message: `Circular alias detected from "${alias}"`, source, node });
407
+ logger.error({ message: `Circular alias detected from "${alias}"`, filename, src, node });
321
408
  }
322
409
  const token = tokens[id];
323
410
  if (!isAlias(token.$value)) {
324
411
  return id;
325
412
  }
326
- return resolveAlias(token.$value, { tokens, logger, node, source, scanned: [...scanned, id] });
413
+ return resolveAlias(token.$value, { tokens, logger, filename, node, src, scanned: [...scanned, id] });
327
414
  }
328
415
 
329
416
  /** Resolve aliases, update values, and mutate `token` to add `aliasOf` / `partialAliasOf` */
330
- function applyAliases(token, { tokens, logger, source, node }) {
417
+ function applyAliases(token, { tokens, logger, filename, src, node }) {
331
418
  // handle simple aliases
332
419
  if (isAlias(token.$value)) {
333
- const aliasOfID = resolveAlias(token.$value, { tokens, logger, node, source });
420
+ const aliasOfID = resolveAlias(token.$value, { tokens, logger, filename, node, src });
334
421
  const { mode: aliasMode } = parseAlias(token.$value);
335
422
  const aliasOf = tokens[aliasOfID];
336
423
  token.aliasOf = aliasOfID;
@@ -353,7 +440,7 @@ function applyAliases(token, { tokens, logger, source, node }) {
353
440
  if (!token.partialAliasOf) {
354
441
  token.partialAliasOf = [];
355
442
  }
356
- const aliasOfID = resolveAlias(token.$value[i], { tokens, logger, node, source });
443
+ const aliasOfID = resolveAlias(token.$value[i], { tokens, logger, filename, node, src });
357
444
  const { mode: aliasMode } = parseAlias(token.$value[i]);
358
445
  token.partialAliasOf[i] = aliasOfID;
359
446
  token.$value[i] = tokens[aliasOfID].mode[aliasMode]?.$value || tokens[aliasOfID].$value;
@@ -366,7 +453,7 @@ function applyAliases(token, { tokens, logger, source, node }) {
366
453
  if (!token.partialAliasOf[i]) {
367
454
  token.partialAliasOf[i] = {};
368
455
  }
369
- const aliasOfID = resolveAlias(token.$value[i][property], { tokens, logger, node, source });
456
+ const aliasOfID = resolveAlias(token.$value[i][property], { tokens, logger, filename, node, src });
370
457
  const { mode: aliasMode } = parseAlias(token.$value[i][property]);
371
458
  token.$value[i][property] = tokens[aliasOfID].mode[aliasMode]?.$value || tokens[aliasOfID].$value;
372
459
  token.partialAliasOf[i][property] = aliasOfID;
@@ -386,7 +473,7 @@ function applyAliases(token, { tokens, logger, source, node }) {
386
473
  if (!token.partialAliasOf) {
387
474
  token.partialAliasOf = {};
388
475
  }
389
- const aliasOfID = resolveAlias(token.$value[property], { tokens, logger, node, source });
476
+ const aliasOfID = resolveAlias(token.$value[property], { tokens, logger, filename, node, src });
390
477
  const { mode: aliasMode } = parseAlias(token.$value[property]);
391
478
  token.partialAliasOf[property] = aliasOfID;
392
479
  token.$value[property] = tokens[aliasOfID].mode[aliasMode]?.$value || tokens[aliasOfID].$value;
@@ -395,7 +482,7 @@ function applyAliases(token, { tokens, logger, source, node }) {
395
482
  else if (Array.isArray(token.$value[property])) {
396
483
  for (let i = 0; i < token.$value[property].length; i++) {
397
484
  if (isAlias(token.$value[property][i])) {
398
- const aliasOfID = resolveAlias(token.$value[property][i], { tokens, logger, node, source });
485
+ const aliasOfID = resolveAlias(token.$value[property][i], { tokens, logger, filename, node, src });
399
486
  if (!token.partialAliasOf) {
400
487
  token.partialAliasOf = {};
401
488
  }
@@ -49,7 +49,7 @@ export default function normalizeValue(token) {
49
49
  }
50
50
  case 'dimension': {
51
51
  if (token.$value === 0) {
52
- return 0;
52
+ return '0';
53
53
  }
54
54
  return typeof token.$value === 'number' ? `${token.$value}px` : token.$value;
55
55
  }
@@ -84,7 +84,13 @@ export default function normalizeValue(token) {
84
84
  return typeof token.$value === 'number' ? token.$value : Number.parseFloat(token.$value);
85
85
  }
86
86
  case 'shadow': {
87
- return Array.isArray(token.$value) ? token.$value : [token.$value];
87
+ return (Array.isArray(token.$value) ? token.$value : [token.$value]).map((layer) => ({
88
+ color: normalizeValue({ $type: 'color', $value: layer.color }),
89
+ offsetX: normalizeValue({ $type: 'dimension', $value: layer.offsetX ?? 0 }),
90
+ offsetY: normalizeValue({ $type: 'dimension', $value: layer.offsetY ?? 0 }),
91
+ blur: normalizeValue({ $type: 'dimension', $value: layer.blur ?? 0 }),
92
+ spread: normalizeValue({ $type: 'dimension', $value: layer.spread ?? 0 }),
93
+ }));
88
94
  }
89
95
  case 'strokeStyle': {
90
96
  return token.$value;
@@ -1,4 +1,4 @@
1
- import type { AnyNode, DocumentNode, MemberNode, ValueNode } from '@humanwhocodes/momoa';
1
+ import type { AnyNode, MemberNode, ValueNode } from '@humanwhocodes/momoa';
2
2
  import type Logger from '../logger.js';
3
3
 
4
4
  declare const FONT_WEIGHT_VALUES: Set<string>;
@@ -7,7 +7,8 @@ declare const STROKE_STYLE_VALUES: Set<string>;
7
7
  declare const STROKE_STYLE_LINE_CAP_VALUES: Set<string>;
8
8
 
9
9
  export interface ValidateOptions {
10
- source: string;
10
+ filename?: URL;
11
+ src: string;
11
12
  logger: Logger;
12
13
  }
13
14
 
@@ -17,7 +18,7 @@ export function validateBorder($value: ValueNode, node: AnyNode, options: Valida
17
18
 
18
19
  export function validateColor($value: ValueNode, node: AnyNode, options: ValidateOptions): void;
19
20
 
20
- export function validateCubicBézier($value: ValueNode, node: AnyNode, options: ValidateOptions): void;
21
+ export function validateCubicBezier($value: ValueNode, node: AnyNode, options: ValidateOptions): void;
21
22
 
22
23
  export function validateDimension($value: ValueNode, node: AnyNode, options: ValidateOptions): void;
23
24