@terrazzo/parser 0.10.4 → 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 (42) hide show
  1. package/dist/index.d.ts +82 -307
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +2186 -3621
  4. package/dist/index.js.map +1 -1
  5. package/package.json +4 -3
  6. package/src/build/index.ts +32 -41
  7. package/src/config.ts +13 -6
  8. package/src/lib/code-frame.ts +3 -0
  9. package/src/lib/momoa.ts +10 -0
  10. package/src/lint/index.ts +41 -37
  11. package/src/lint/plugin-core/index.ts +73 -16
  12. package/src/lint/plugin-core/rules/colorspace.ts +4 -0
  13. package/src/lint/plugin-core/rules/duplicate-values.ts +2 -0
  14. package/src/lint/plugin-core/rules/max-gamut.ts +24 -4
  15. package/src/lint/plugin-core/rules/no-type-on-alias.ts +29 -0
  16. package/src/lint/plugin-core/rules/required-modes.ts +2 -0
  17. package/src/lint/plugin-core/rules/required-typography-properties.ts +13 -3
  18. package/src/lint/plugin-core/rules/valid-boolean.ts +41 -0
  19. package/src/lint/plugin-core/rules/valid-border.ts +57 -0
  20. package/src/lint/plugin-core/rules/valid-color.ts +265 -0
  21. package/src/lint/plugin-core/rules/valid-cubic-bezier.ts +83 -0
  22. package/src/lint/plugin-core/rules/valid-dimension.ts +199 -0
  23. package/src/lint/plugin-core/rules/valid-duration.ts +123 -0
  24. package/src/lint/plugin-core/rules/valid-font-family.ts +68 -0
  25. package/src/lint/plugin-core/rules/valid-font-weight.ts +89 -0
  26. package/src/lint/plugin-core/rules/valid-gradient.ts +79 -0
  27. package/src/lint/plugin-core/rules/valid-link.ts +41 -0
  28. package/src/lint/plugin-core/rules/valid-number.ts +63 -0
  29. package/src/lint/plugin-core/rules/valid-shadow.ts +67 -0
  30. package/src/lint/plugin-core/rules/valid-string.ts +41 -0
  31. package/src/lint/plugin-core/rules/valid-stroke-style.ts +104 -0
  32. package/src/lint/plugin-core/rules/valid-transition.ts +61 -0
  33. package/src/lint/plugin-core/rules/valid-typography.ts +67 -0
  34. package/src/logger.ts +70 -59
  35. package/src/parse/index.ts +23 -328
  36. package/src/parse/load.ts +257 -0
  37. package/src/parse/normalize.ts +134 -170
  38. package/src/parse/token.ts +530 -0
  39. package/src/types.ts +76 -10
  40. package/src/parse/alias.ts +0 -369
  41. package/src/parse/json.ts +0 -211
  42. package/src/parse/validate.ts +0 -961
@@ -0,0 +1,61 @@
1
+ import type * as momoa from '@humanwhocodes/momoa';
2
+ import { getObjMember } from '@terrazzo/json-schema-tools';
3
+ import { TRANSITION_REQUIRED_PROPERTIES } from '@terrazzo/token-tools';
4
+ import type { LintRule } from '../../../types.js';
5
+ import { docsLink } from '../lib/docs.js';
6
+
7
+ export const VALID_TRANSITION = 'core/valid-transition';
8
+
9
+ const ERROR = 'ERROR';
10
+ const ERROR_INVALID_PROP = 'ERROR_INVALID_PROP';
11
+
12
+ const rule: LintRule<typeof ERROR | typeof ERROR_INVALID_PROP> = {
13
+ meta: {
14
+ messages: {
15
+ [ERROR]: `Missing required properties: ${new Intl.ListFormat(undefined, { type: 'conjunction' }).format(TRANSITION_REQUIRED_PROPERTIES)}.`,
16
+ [ERROR_INVALID_PROP]: 'Unknown property: {{ key }}.',
17
+ },
18
+ docs: {
19
+ description: 'Require transition tokens to follow the format.',
20
+ url: docsLink(VALID_TRANSITION),
21
+ },
22
+ },
23
+ defaultOptions: {},
24
+ create({ tokens, report }) {
25
+ for (const t of Object.values(tokens)) {
26
+ if (t.aliasOf || !t.originalValue || t.$type !== 'transition') {
27
+ continue;
28
+ }
29
+
30
+ validateTransition(t.originalValue.$value, {
31
+ node: getObjMember(t.source.node, '$value') as momoa.ObjectNode,
32
+ filename: t.source.filename,
33
+ });
34
+ }
35
+
36
+ // Note: we validate sub-properties using other checks like valid-dimension, valid-font-family, etc.
37
+ // The only thing remaining is to check that all properties exist (since missing properties won’t appear as invalid)
38
+ function validateTransition(value: unknown, { node, filename }: { node: momoa.ObjectNode; filename?: string }) {
39
+ if (
40
+ !value ||
41
+ typeof value !== 'object' ||
42
+ !TRANSITION_REQUIRED_PROPERTIES.every((property) => property in value)
43
+ ) {
44
+ report({ messageId: ERROR, node, filename });
45
+ } else {
46
+ for (const key of Object.keys(value)) {
47
+ if (!TRANSITION_REQUIRED_PROPERTIES.includes(key as (typeof TRANSITION_REQUIRED_PROPERTIES)[number])) {
48
+ report({
49
+ messageId: ERROR_INVALID_PROP,
50
+ data: { key: JSON.stringify(key) },
51
+ node: getObjMember(node, key),
52
+ filename,
53
+ });
54
+ }
55
+ }
56
+ }
57
+ }
58
+ },
59
+ };
60
+
61
+ export default rule;
@@ -0,0 +1,67 @@
1
+ import type * as momoa from '@humanwhocodes/momoa';
2
+ import { getObjMember } from '@terrazzo/json-schema-tools';
3
+ import wcmatch from 'wildcard-match';
4
+ import type { LintRule } from '../../../types.js';
5
+ import { docsLink } from '../lib/docs.js';
6
+
7
+ export const VALID_TYPOGRAPHY = 'core/valid-typography';
8
+
9
+ const ERROR = 'ERROR';
10
+ const ERROR_MISSING = 'ERROR_MISSING';
11
+
12
+ export interface RuleRequiredTypographyPropertiesOptions {
13
+ /** Required typography properties */
14
+ requiredProperties: string[];
15
+ /** Token globs to ignore */
16
+ ignore?: string[];
17
+ }
18
+
19
+ const rule: LintRule<typeof ERROR | typeof ERROR_MISSING, RuleRequiredTypographyPropertiesOptions> = {
20
+ meta: {
21
+ messages: {
22
+ [ERROR]: `Expected object, received {{ value }}.`,
23
+ [ERROR_MISSING]: `Missing required property "{{ property }}".`,
24
+ },
25
+ docs: {
26
+ description: 'Require typography tokens to follow the format.',
27
+ url: docsLink(VALID_TYPOGRAPHY),
28
+ },
29
+ },
30
+ defaultOptions: {
31
+ requiredProperties: ['fontFamily', 'fontSize', 'fontWeight', 'letterSpacing', 'lineHeight'],
32
+ },
33
+ create({ tokens, options, report }) {
34
+ const isIgnored = options.ignore ? wcmatch(options.ignore) : () => false;
35
+ for (const t of Object.values(tokens)) {
36
+ if (t.aliasOf || !t.originalValue || t.$type !== 'typography' || isIgnored(t.id)) {
37
+ continue;
38
+ }
39
+
40
+ validateTypography(t.originalValue.$value, {
41
+ node: getObjMember(t.source.node, '$value') as momoa.ObjectNode,
42
+ filename: t.source.filename,
43
+ });
44
+
45
+ // Note: we validate sub-properties using other checks like valid-dimension, valid-font-family, etc.
46
+ // The only thing remaining is to check that all properties exist (since missing properties won’t appear as invalid)
47
+ function validateTypography(value: unknown, { node, filename }: { node: momoa.ObjectNode; filename?: string }) {
48
+ if (value && typeof value === 'object') {
49
+ for (const property of options.requiredProperties) {
50
+ if (!(property in value)) {
51
+ report({ messageId: ERROR_MISSING, data: { property }, node, filename });
52
+ }
53
+ }
54
+ } else {
55
+ report({
56
+ messageId: ERROR,
57
+ data: { value: JSON.stringify(value) },
58
+ node,
59
+ filename,
60
+ });
61
+ }
62
+ }
63
+ }
64
+ },
65
+ };
66
+
67
+ export default rule;
package/src/logger.ts CHANGED
@@ -1,14 +1,11 @@
1
- import type { AnyNode } from '@humanwhocodes/momoa';
1
+ import * as momoa from '@humanwhocodes/momoa';
2
2
  import pc from 'picocolors';
3
3
  import wcmatch from 'wildcard-match';
4
4
  import { codeFrameColumns } from './lib/code-frame.js';
5
5
 
6
6
  export const LOG_ORDER = ['error', 'warn', 'info', 'debug'] as const;
7
-
8
7
  export type LogSeverity = 'error' | 'warn' | 'info' | 'debug';
9
-
10
8
  export type LogLevel = LogSeverity | 'silent';
11
-
12
9
  export type LogGroup = 'config' | 'parser' | 'lint' | 'plugin' | 'server';
13
10
 
14
11
  export interface LogEntry {
@@ -26,7 +23,7 @@ export interface LogEntry {
26
23
  */
27
24
  continueOnError?: boolean;
28
25
  /** Show a code frame for the erring node */
29
- node?: AnyNode;
26
+ node?: momoa.AnyNode;
30
27
  /** To show a code frame, provide the original source code */
31
28
  src?: string;
32
29
  }
@@ -64,13 +61,17 @@ export function formatMessage(entry: LogEntry, severity: LogSeverity) {
64
61
  if (severity in MESSAGE_COLOR) {
65
62
  message = MESSAGE_COLOR[severity]!(message);
66
63
  }
67
- if (entry.src) {
64
+ if (entry.node) {
68
65
  const start = entry.node?.loc?.start ?? { line: 0, column: 0 };
69
66
  // strip "file://" protocol, but not href
70
67
  const loc = entry.filename
71
68
  ? `${entry.filename?.href.replace(/^file:\/\//, '')}:${start?.line ?? 0}:${start?.column ?? 0}\n\n`
72
69
  : '';
73
- const codeFrame = codeFrameColumns(entry.src, { start }, { highlightCode: false });
70
+ const codeFrame = codeFrameColumns(
71
+ entry.src ?? momoa.print(entry.node, { indent: 2 }),
72
+ { start },
73
+ { highlightCode: false },
74
+ );
74
75
  message = `${message}\n\n${loc}${codeFrame}`;
75
76
  }
76
77
  return message;
@@ -98,77 +99,87 @@ export default class Logger {
98
99
  }
99
100
 
100
101
  /** Log an error message (always; can’t be silenced) */
101
- error(entry: LogEntry) {
102
- this.errorCount++;
103
- const message = formatMessage(entry, 'error');
104
- if (entry.continueOnError) {
105
- // biome-ignore lint/suspicious/noConsole: this is a logger
106
- console.error(message);
107
- return;
102
+ error(...entries: LogEntry[]) {
103
+ const message: string[] = [];
104
+ let firstNode: momoa.AnyNode | undefined;
105
+ for (const entry of entries) {
106
+ this.errorCount++;
107
+ message.push(formatMessage(entry, 'error'));
108
+ if (entry.node) {
109
+ firstNode = entry.node;
110
+ }
108
111
  }
109
- if (entry.node) {
110
- throw new TokensJSONError(message);
112
+ if (entries.every((e) => e.continueOnError)) {
113
+ // biome-ignore lint/suspicious/noConsole: this is a logger
114
+ console.error(message.join('\n\n'));
111
115
  } else {
112
- throw new Error(message);
116
+ const e = firstNode ? new TokensJSONError(message.join('\n\n')) : new Error(message.join('\n\n'));
117
+ throw e;
113
118
  }
114
119
  }
115
120
 
116
121
  /** Log an info message (if logging level permits) */
117
- info(entry: LogEntry) {
118
- this.infoCount++;
119
- if (this.level === 'silent' || LOG_ORDER.indexOf(this.level) < LOG_ORDER.indexOf('info')) {
120
- return;
122
+ info(...entries: LogEntry[]) {
123
+ for (const entry of entries) {
124
+ this.infoCount++;
125
+ if (this.level === 'silent' || LOG_ORDER.indexOf(this.level) < LOG_ORDER.indexOf('info')) {
126
+ return;
127
+ }
128
+ const message = formatMessage(entry, 'info');
129
+ // biome-ignore lint/suspicious/noConsole: this is a logger
130
+ console.log(message);
121
131
  }
122
- const message = formatMessage(entry, 'info');
123
- // biome-ignore lint/suspicious/noConsole: this is a logger
124
- console.log(message);
125
132
  }
126
133
 
127
134
  /** Log a warning message (if logging level permits) */
128
- warn(entry: LogEntry) {
129
- this.warnCount++;
130
- if (this.level === 'silent' || LOG_ORDER.indexOf(this.level) < LOG_ORDER.indexOf('warn')) {
131
- return;
135
+ warn(...entries: LogEntry[]) {
136
+ for (const entry of entries) {
137
+ this.warnCount++;
138
+ if (this.level === 'silent' || LOG_ORDER.indexOf(this.level) < LOG_ORDER.indexOf('warn')) {
139
+ return;
140
+ }
141
+ const message = formatMessage(entry, 'warn');
142
+ // biome-ignore lint/suspicious/noConsole: this is a logger
143
+ console.warn(message);
132
144
  }
133
- const message = formatMessage(entry, 'warn');
134
- // biome-ignore lint/suspicious/noConsole: this is a logger
135
- console.warn(message);
136
145
  }
137
146
 
138
147
  /** Log a diagnostics message (if logging level permits) */
139
- debug(entry: DebugEntry) {
140
- if (this.level === 'silent' || LOG_ORDER.indexOf(this.level) < LOG_ORDER.indexOf('debug')) {
141
- return;
142
- }
143
- this.debugCount++;
148
+ debug(...entries: DebugEntry[]) {
149
+ for (const entry of entries) {
150
+ if (this.level === 'silent' || LOG_ORDER.indexOf(this.level) < LOG_ORDER.indexOf('debug')) {
151
+ return;
152
+ }
153
+ this.debugCount++;
144
154
 
145
- let message = formatMessage(entry, 'debug');
155
+ let message = formatMessage(entry, 'debug');
146
156
 
147
- const debugPrefix = entry.label ? `${entry.group}:${entry.label}` : entry.group;
148
- if (this.debugScope !== '*' && !wcmatch(this.debugScope)(debugPrefix)) {
149
- return;
150
- }
157
+ const debugPrefix = entry.label ? `${entry.group}:${entry.label}` : entry.group;
158
+ if (this.debugScope !== '*' && !wcmatch(this.debugScope)(debugPrefix)) {
159
+ return;
160
+ }
151
161
 
152
- // debug color
153
- message
154
- .replace(/\[config[^\]]+\]/, (match) => pc.green(match))
155
- .replace(/\[parser[^\]]+\]/, (match) => pc.magenta(match))
156
- .replace(/\[lint[^\]]+\]/, (match) => pc.yellow(match))
157
- .replace(/\[plugin[^\]]+\]/, (match) => pc.cyan(match));
158
-
159
- message = `${pc.dim(timeFormatter.format(performance.now()))} ${message}`;
160
- if (typeof entry.timing === 'number') {
161
- let timing = '';
162
- if (entry.timing < 1_000) {
163
- timing = `${Math.round(entry.timing * 100) / 100}ms`;
164
- } else if (entry.timing < 60_000) {
165
- timing = `${Math.round(entry.timing * 100) / 100_000}s`;
162
+ // debug color
163
+ message
164
+ .replace(/\[config[^\]]+\]/, (match) => pc.green(match))
165
+ .replace(/\[parser[^\]]+\]/, (match) => pc.magenta(match))
166
+ .replace(/\[lint[^\]]+\]/, (match) => pc.yellow(match))
167
+ .replace(/\[plugin[^\]]+\]/, (match) => pc.cyan(match));
168
+
169
+ message = `${pc.dim(timeFormatter.format(performance.now()))} ${message}`;
170
+ if (typeof entry.timing === 'number') {
171
+ let timing = '';
172
+ if (entry.timing < 1_000) {
173
+ timing = `${Math.round(entry.timing * 100) / 100}ms`;
174
+ } else if (entry.timing < 60_000) {
175
+ timing = `${Math.round(entry.timing * 100) / 100_000}s`;
176
+ }
177
+ message = `${message}${timing ? pc.dim(` [${timing}]`) : ''}`;
166
178
  }
167
- message = `${message} ${pc.dim(`[${timing}]`)}`;
168
- }
169
179
 
170
- // biome-ignore lint/suspicious/noConsole: this is a logger
171
- console.log(message);
180
+ // biome-ignore lint/suspicious/noConsole: this is a logger
181
+ console.log(message);
182
+ }
172
183
  }
173
184
 
174
185
  /** Get stats for current logger instance */
@@ -1,46 +1,11 @@
1
- import { type DocumentNode, evaluate, type MemberNode, type ObjectNode } from '@humanwhocodes/momoa';
2
- import { pluralize, splitID, type Token, type TokenNormalized } from '@terrazzo/token-tools';
3
- import type ytm from 'yaml-to-momoa';
1
+ import { pluralize, type TokenNormalizedSet } from '@terrazzo/token-tools';
4
2
  import lintRunner from '../lint/index.js';
5
3
  import Logger from '../logger.js';
6
- import type { ConfigInit, InputSource } from '../types.js';
7
- import applyAliases from './alias.js';
8
- import { getObjMembers, parseJSON, replaceObjMembers, toMomoa, traverse } from './json.js';
9
- import normalize from './normalize.js';
10
- import validateTokenNode, { computeInheritedProperty, isGroupNode, type Visitors } from './validate.js';
11
-
12
- export * from './alias.js';
13
- export * from './json.js';
14
- export * from './normalize.js';
15
- export * from './validate.js';
16
- export { normalize, validateTokenNode };
17
-
18
- export interface ParseOptions {
19
- logger?: Logger;
20
- config: ConfigInit;
21
- /**
22
- * Skip lint step
23
- * @default false
24
- */
25
- skipLint?: boolean;
26
- /**
27
- * Continue on error? (Useful for `tz check`)
28
- * @default false
29
- */
30
- continueOnError?: boolean;
31
- /** Provide yamlToMomoa module to parse YAML (by default, this isn’t shipped to cut down on package weight) */
32
- yamlToMomoa?: typeof ytm;
33
- /**
34
- * Transform API
35
- * @see https://terrazzo.app/docs/api/js#transform-api
36
- */
37
- transform?: Visitors;
38
- /** (internal cache; do not use) */
39
- _sources?: Record<string, InputSource>;
40
- }
4
+ import type { ConfigInit, InputSource, ParseOptions } from '../types.js';
5
+ import { loadSources } from './load.js';
41
6
 
42
7
  export interface ParseResult {
43
- tokens: Record<string, TokenNormalized>;
8
+ tokens: TokenNormalizedSet;
44
9
  sources: InputSource[];
45
10
  }
46
11
 
@@ -54,93 +19,31 @@ export default async function parse(
54
19
  continueOnError = false,
55
20
  yamlToMomoa,
56
21
  transform,
57
- _sources = {},
58
22
  }: ParseOptions = {} as ParseOptions,
59
23
  ): Promise<ParseResult> {
60
- const input = Array.isArray(_input) ? _input : [_input];
61
- let tokensSet: Record<string, TokenNormalized> = {};
62
-
63
- if (!Array.isArray(input)) {
64
- logger.error({ group: 'parser', label: 'init', message: 'Input must be an array of input objects.' });
65
- }
66
- await Promise.all(
67
- input.map(async (src, i) => {
68
- if (!src || typeof src !== 'object') {
69
- logger.error({ group: 'parser', label: 'init', message: `Input (${i}) must be an object.` });
70
- }
71
- if (!src.src || (typeof src.src !== 'string' && typeof src.src !== 'object')) {
72
- logger.error({
73
- message: `Input (${i}) missing "src" with a JSON/YAML string, or JSON object.`,
74
- group: 'parser',
75
- label: 'init',
76
- });
77
- }
78
- if (src.filename) {
79
- if (!(src.filename instanceof URL)) {
80
- logger.error({
81
- message: `Input (${i}) "filename" must be a URL (remote or file URL).`,
82
- group: 'parser',
83
- label: 'init',
84
- });
85
- }
86
-
87
- // if already parsed/scanned, skip
88
- if (_sources[src.filename.href]) {
89
- return;
90
- }
91
- }
92
-
93
- const result = await parseSingle(src.src, {
94
- filename: src.filename!,
95
- logger,
96
- config,
97
- skipLint,
98
- continueOnError,
99
- yamlToMomoa,
100
- transform,
101
- });
102
- tokensSet = Object.assign(tokensSet, result.tokens);
103
- if (src.filename) {
104
- _sources[src.filename.href] = {
105
- filename: src.filename,
106
- src: result.src,
107
- document: result.document,
108
- };
109
- }
110
- }),
111
- );
24
+ const inputs = Array.isArray(_input) ? _input : [_input];
112
25
 
113
26
  const totalStart = performance.now();
114
-
115
- // 5. Resolve aliases and populate groups
116
- const aliasesStart = performance.now();
117
- let aliasCount = 0;
118
- for (const [id, token] of Object.entries(tokensSet)) {
119
- applyAliases(token, {
120
- tokensSet,
121
- filename: _sources[token.source.loc!]?.filename!,
122
- src: _sources[token.source.loc!]?.src as string,
123
- node: (getObjMembers(token.source.node).$value as any) || token.source.node,
124
- logger,
125
- });
126
- aliasCount++;
127
- const { group: parentGroup } = splitID(id);
128
- if (parentGroup) {
129
- for (const siblingID of Object.keys(tokensSet)) {
130
- const { group: siblingGroup } = splitID(siblingID);
131
- if (siblingGroup?.startsWith(parentGroup)) {
132
- token.group.tokens.push(siblingID);
133
- }
134
- }
135
- }
136
- }
27
+ const initStart = performance.now();
28
+ const { tokens, sources } = await loadSources(inputs, { logger, config, continueOnError, yamlToMomoa, transform });
137
29
  logger.debug({
138
- message: `Resolved ${aliasCount} aliases`,
30
+ message: 'Loaded tokens',
139
31
  group: 'parser',
140
- label: 'alias',
141
- timing: performance.now() - aliasesStart,
32
+ label: 'core',
33
+ timing: performance.now() - initStart,
142
34
  });
143
35
 
36
+ if (skipLint !== true && config?.plugins?.length) {
37
+ const lintStart = performance.now();
38
+ await lintRunner({ tokens, sources, config, logger });
39
+ logger.debug({
40
+ message: 'Lint finished',
41
+ group: 'plugin',
42
+ label: 'lint',
43
+ timing: performance.now() - lintStart,
44
+ });
45
+ }
46
+
144
47
  logger.debug({
145
48
  message: 'Finish all parser tasks',
146
49
  group: 'parser',
@@ -159,215 +62,7 @@ export default async function parse(
159
62
  }
160
63
 
161
64
  return {
162
- tokens: tokensSet,
163
- sources: Object.values(_sources),
164
- };
165
- }
166
-
167
- /** Parse a single input */
168
- async function parseSingle(
169
- input: string | Record<string, any>,
170
- {
171
- filename,
172
- logger,
173
- config,
174
- skipLint,
175
- continueOnError = false,
176
- transform,
177
- yamlToMomoa, // optional dependency, declared here so the parser itself doesn’t have to load a heavy dep in-browser
178
- }: {
179
- filename: URL;
180
- logger: Logger;
181
- config: ConfigInit;
182
- skipLint: boolean;
183
- continueOnError?: boolean;
184
- transform: ParseOptions['transform'] | undefined;
185
- yamlToMomoa?: typeof ytm;
186
- },
187
- ): Promise<{ tokens: Record<string, Token>; document: DocumentNode; src?: string }> {
188
- // 1. Build AST
189
- const startParsing = performance.now();
190
- let { src, document } = toMomoa(input, { filename, logger, continueOnError, yamlToMomoa });
191
- logger.debug({
192
- group: 'parser',
193
- label: 'json',
194
- message: 'Finish JSON parsing',
195
- timing: performance.now() - startParsing,
196
- });
197
- const tokensSet: Record<string, TokenNormalized> = {};
198
-
199
- // 1a. if there’s a root() transformer, then re-parse
200
- if (transform?.root) {
201
- const json = typeof input === 'string' ? JSON.parse(input) : input;
202
- const result = transform?.root(json, '.', document);
203
- if (result) {
204
- const reRunResult = toMomoa(result, { filename, logger, continueOnError /* YAML not needed in transform() */ });
205
- src = reRunResult.src;
206
- document = reRunResult.document;
207
- }
208
- }
209
-
210
- // 2. Walk AST to validate tokens
211
- let tokenCount = 0;
212
- const startValidate = performance.now();
213
- // $type and $deprecated can be applied at group level to target all child tokens,
214
- // these two objects keep track of inherited prop values as we traverse the token tree
215
- const $typeInheritance: Record<string, MemberNode> = {};
216
- const $deprecatedInheritance: Record<string, MemberNode> = {};
217
- traverse(document, {
218
- enter(node, parent, subpath) {
219
- // if $type appears at root of tokens.json, collect it
220
- if (node.type === 'Document' && node.body.type === 'Object' && node.body.members) {
221
- const { members: rootMembers } = node.body;
222
- if (isGroupNode(node.body)) {
223
- const root$type = rootMembers.find((m) => m.name.type === 'String' && m.name.value === '$type');
224
- if (root$type) {
225
- $typeInheritance['.'] = root$type;
226
- }
227
- const root$deprecated = rootMembers.find((m) => m.name.type === 'String' && m.name.value === '$deprecated');
228
- if (root$deprecated) {
229
- $deprecatedInheritance['.'] = root$deprecated;
230
- }
231
- }
232
- }
233
-
234
- // for transform() visitors, all non-tokens MUST be handled at this point (besides "root", which was handled above)
235
- if (
236
- node.type === 'Object' && // JSON object
237
- subpath.length &&
238
- !node.members.some((m) => m.name.type === 'String' && m.name.value === '$value') && // not the token node itself
239
- !subpath.includes('$value') && // not a child of a token node, either
240
- !subpath.includes('$extensions') // not metadata
241
- ) {
242
- if (transform?.group) {
243
- const newJSON = transform?.group(evaluate(node), subpath.join('.'), node);
244
- if (newJSON) {
245
- replaceObjMembers(node, parseJSON(newJSON));
246
- }
247
- }
248
- }
249
-
250
- // handle tokens
251
- if (node.type === 'Member') {
252
- const inheritedDeprecatedNode = computeInheritedProperty(node, '$deprecated', {
253
- subpath,
254
- inherited: $deprecatedInheritance,
255
- });
256
- const inheritedTypeNode = computeInheritedProperty(node, '$type', { subpath, inherited: $typeInheritance });
257
- if (node.value.type === 'Object') {
258
- const $value = node.value.members.find((m) => m.name.type === 'String' && m.name.value === '$value');
259
-
260
- // Call transform using either inherited type or token-level type
261
- let typeNode = inheritedTypeNode;
262
- if (!typeNode) {
263
- const local$type = node.value.members.find((m) => m.name.type === 'String' && m.name.value === '$type');
264
- if (local$type) {
265
- typeNode = local$type as MemberNode;
266
- }
267
- }
268
-
269
- if ($value && typeNode?.value.type === 'String' && transform?.[typeNode.value.value]) {
270
- const result = transform[typeNode.value.value]?.(evaluate(node.value), subpath.join('.'), node);
271
- if (result) {
272
- node.value = parseJSON(result).body;
273
- }
274
- }
275
-
276
- const token = validateTokenNode(node, {
277
- filename,
278
- src,
279
- config,
280
- logger,
281
- parent,
282
- subpath,
283
- transform,
284
- inheritedDeprecatedNode,
285
- inheritedTypeNode,
286
- });
287
- if (token) {
288
- tokensSet[token.id] = token;
289
- tokenCount++;
290
- }
291
- }
292
- }
293
- },
294
- });
295
- logger.debug({
296
- message: `Validated ${tokenCount} tokens`,
297
- group: 'parser',
298
- label: 'validate',
299
- timing: performance.now() - startValidate,
300
- });
301
-
302
- // 3. normalize values
303
- const normalizeStart = performance.now();
304
- for (const [id, token] of Object.entries(tokensSet)) {
305
- try {
306
- tokensSet[id]!.$value = normalize(token);
307
- } catch (err) {
308
- let { node } = token.source;
309
- const members = getObjMembers(node);
310
- if (members.$value) {
311
- node = members.$value as ObjectNode;
312
- }
313
- logger.error({
314
- group: 'parser',
315
- label: 'normalize',
316
- message: (err as Error).message,
317
- filename,
318
- src,
319
- node,
320
- continueOnError,
321
- });
322
- }
323
- for (const [mode, modeValue] of Object.entries(token.mode)) {
324
- if (mode === '.') {
325
- continue;
326
- }
327
- try {
328
- tokensSet[id]!.mode[mode]!.$value = normalize({ $type: token.$type, ...modeValue });
329
- } catch (err) {
330
- let { node } = token.source;
331
- const members = getObjMembers(node);
332
- if (members.$value) {
333
- node = members.$value as ObjectNode;
334
- }
335
- logger.error({
336
- group: 'parser',
337
- label: 'normalize',
338
- message: (err as Error).message,
339
- filename,
340
- src,
341
- node: modeValue.source.node,
342
- continueOnError,
343
- });
344
- }
345
- }
346
- }
347
- logger.debug({
348
- message: `Normalized ${tokenCount} tokens`,
349
- group: 'parser',
350
- label: 'normalize',
351
- timing: performance.now() - normalizeStart,
352
- });
353
-
354
- // 4. Execute lint runner with loaded plugins
355
- if (!skipLint && config?.plugins?.length) {
356
- const lintStart = performance.now();
357
- await lintRunner({ tokens: tokensSet, src, config, logger });
358
- logger.debug({
359
- message: `Linted ${tokenCount} tokens`,
360
- group: 'parser',
361
- label: 'lint',
362
- timing: performance.now() - lintStart,
363
- });
364
- } else {
365
- logger.debug({ message: 'Linting skipped', group: 'parser', label: 'lint' });
366
- }
367
-
368
- return {
369
- tokens: tokensSet,
370
- document,
371
- src,
65
+ tokens,
66
+ sources,
372
67
  };
373
68
  }