@terrazzo/parser 2.0.0-alpha.2 → 2.0.0-alpha.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@terrazzo/parser",
3
- "version": "2.0.0-alpha.2",
3
+ "version": "2.0.0-alpha.4",
4
4
  "description": "Parser/validator for the Design Tokens Community Group (DTCG) standard.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -27,26 +27,27 @@
27
27
  ".": "./dist/index.js",
28
28
  "./package.json": "./package.json"
29
29
  },
30
- "homepage": "https://terrazzo.app/docs/cli/api/js",
30
+ "homepage": "https://terrazzo.app/docs/reference/js-api",
31
31
  "repository": {
32
32
  "type": "git",
33
33
  "url": "https://github.com/terrazzoapp/terrazzo.git",
34
34
  "directory": "./packages/parser/"
35
35
  },
36
36
  "dependencies": {
37
- "@humanwhocodes/momoa": "^3.3.9",
37
+ "@humanwhocodes/momoa": "^3.3.10",
38
38
  "@types/babel__code-frame": "^7.0.6",
39
39
  "@types/culori": "^4.0.1",
40
40
  "culori": "^4.0.2",
41
+ "fast-deep-equal": "^3.1.3",
41
42
  "merge-anything": "^5.1.7",
42
43
  "picocolors": "^1.1.1",
43
44
  "scule": "^1.3.0",
44
45
  "wildcard-match": "^5.1.4",
45
- "@terrazzo/json-schema-tools": "^0.0.1",
46
- "@terrazzo/token-tools": "^2.0.0-alpha.2"
46
+ "@terrazzo/json-schema-tools": "^0.1.0-alpha.0",
47
+ "@terrazzo/token-tools": "^2.0.0-alpha.4"
47
48
  },
48
49
  "devDependencies": {
49
- "yaml-to-momoa": "0.0.6"
50
+ "yaml-to-momoa": "0.0.8"
50
51
  },
51
52
  "scripts": {
52
53
  "build": "rolldown -c && attw --profile esm-only --pack .",
@@ -1,11 +1,11 @@
1
- import type * as momoa from '@humanwhocodes/momoa';
1
+ import type { InputSourceWithDocument } from '@terrazzo/json-schema-tools';
2
2
  import type { TokenNormalized } from '@terrazzo/token-tools';
3
3
  import wcmatch from 'wildcard-match';
4
4
  import Logger, { type LogEntry } from '../logger.js';
5
5
  import type { BuildRunnerResult, ConfigInit, TokenTransformed, TransformParams } from '../types.js';
6
6
 
7
7
  export interface BuildRunnerOptions {
8
- sources: { filename?: URL; src: string; document: momoa.DocumentNode }[];
8
+ sources: InputSourceWithDocument[];
9
9
  config: ConfigInit;
10
10
  logger?: Logger;
11
11
  }
package/src/config.ts CHANGED
@@ -206,7 +206,6 @@ function normalizeLint({ config, logger }: { config: ConfigInit; logger: Logger
206
206
  // Note: sometimes plugins will be loaded multiple times, in which case it’s expected
207
207
  // they’re register rules again for lint(). Only throw an error if plugin A and plugin B’s
208
208
  // rules conflict.
209
-
210
209
  if (allRules.get(rule) && allRules.get(rule) !== plugin.name) {
211
210
  logger.error({
212
211
  group: 'config',
@@ -229,7 +228,7 @@ function normalizeLint({ config, logger }: { config: ConfigInit; logger: Logger
229
228
 
230
229
  const value = config.lint.rules[id];
231
230
  let severity: LintRuleSeverity = 'off';
232
- let options: any;
231
+ let options: any = {};
233
232
  if (typeof value === 'number' || typeof value === 'string') {
234
233
  severity = value;
235
234
  } else if (Array.isArray(value)) {
package/src/index.ts CHANGED
@@ -90,4 +90,6 @@ export { default as Logger } from './logger.js';
90
90
  export * from './parse/index.js';
91
91
  export { default as parse } from './parse/index.js';
92
92
 
93
+ export * from './resolver/index.js';
94
+
93
95
  export * from './types.js';
@@ -0,0 +1,35 @@
1
+ /**
2
+ * If tokens are found inside a resolver, strip out the resolver paths (don’t
3
+ * include "sets"/"modifiers" in the token ID etc.)
4
+ */
5
+ export function filterResolverPaths(path: string[]): string[] {
6
+ switch (path[0]) {
7
+ case 'sets': {
8
+ return path.slice(4);
9
+ }
10
+ case 'modifiers': {
11
+ return path.slice(5);
12
+ }
13
+ case 'resolutionOrder': {
14
+ switch (path[2]) {
15
+ case 'sources': {
16
+ return path.slice(4);
17
+ }
18
+ case 'contexts': {
19
+ return path.slice(5);
20
+ }
21
+ }
22
+ break;
23
+ }
24
+ }
25
+ return path;
26
+ }
27
+
28
+ /**
29
+ * Make a deterministic string from an object
30
+ */
31
+ export function makeInputKey(input: Record<string, string | undefined>): string {
32
+ return JSON.stringify(
33
+ Object.fromEntries(Object.entries(input).sort((a, b) => a[0].localeCompare(b[0], 'en-us', { numeric: true }))),
34
+ );
35
+ }
package/src/lint/index.ts CHANGED
@@ -1,7 +1,8 @@
1
+ import type { InputSourceWithDocument } from '@terrazzo/json-schema-tools';
1
2
  import { pluralize, type TokenNormalizedSet } from '@terrazzo/token-tools';
2
3
  import { merge } from 'merge-anything';
3
4
  import type { LogEntry, default as Logger } from '../logger.js';
4
- import type { ConfigInit, InputSource } from '../types.js';
5
+ import type { ConfigInit } from '../types.js';
5
6
 
6
7
  export { RECOMMENDED_CONFIG } from './plugin-core/index.js';
7
8
 
@@ -9,7 +10,7 @@ export interface LintRunnerOptions {
9
10
  tokens: TokenNormalizedSet;
10
11
  filename?: URL;
11
12
  config: ConfigInit;
12
- sources: InputSource[];
13
+ sources: InputSourceWithDocument[];
13
14
  logger: Logger;
14
15
  }
15
16
 
@@ -21,7 +22,7 @@ export default async function lintRunner({
21
22
  logger,
22
23
  }: LintRunnerOptions): Promise<void> {
23
24
  const { plugins = [], lint } = config;
24
- const sourceByFilename: Record<string, InputSource> = {};
25
+ const sourceByFilename: Record<string, InputSourceWithDocument> = {};
25
26
  for (const source of sources) {
26
27
  sourceByFilename[source.filename!.href] = source;
27
28
  }
@@ -8,9 +8,9 @@ export * from './rules/consistent-naming.js';
8
8
  export * from './rules/descriptions.js';
9
9
  export * from './rules/duplicate-values.js';
10
10
  export * from './rules/max-gamut.js';
11
- export * from './rules/no-type-on-alias.js';
12
11
  export * from './rules/required-children.js';
13
12
  export * from './rules/required-modes.js';
13
+ export * from './rules/required-type.js';
14
14
  export * from './rules/required-typography-properties.js';
15
15
 
16
16
  import a11yMinContrast, { A11Y_MIN_CONTRAST } from './rules/a11y-min-contrast.js';
@@ -20,9 +20,9 @@ import consistentNaming, { CONSISTENT_NAMING } from './rules/consistent-naming.j
20
20
  import descriptions, { DESCRIPTIONS } from './rules/descriptions.js';
21
21
  import duplicateValues, { DUPLICATE_VALUES } from './rules/duplicate-values.js';
22
22
  import maxGamut, { MAX_GAMUT } from './rules/max-gamut.js';
23
- import noTypeOnAlias, { NO_TYPE_ON_ALIAS } from './rules/no-type-on-alias.js';
24
23
  import requiredChildren, { REQUIRED_CHILDREN } from './rules/required-children.js';
25
24
  import requiredModes, { REQUIRED_MODES } from './rules/required-modes.js';
25
+ import requiredType, { REQUIRED_TYPE } from './rules/required-type.js';
26
26
  import requiredTypographyProperties, {
27
27
  REQUIRED_TYPOGRAPHY_PROPERTIES,
28
28
  } from './rules/required-typography-properties.js';
@@ -65,9 +65,9 @@ const ALL_RULES = {
65
65
  [DESCRIPTIONS]: descriptions,
66
66
  [DUPLICATE_VALUES]: duplicateValues,
67
67
  [MAX_GAMUT]: maxGamut,
68
- [NO_TYPE_ON_ALIAS]: noTypeOnAlias,
69
68
  [REQUIRED_CHILDREN]: requiredChildren,
70
69
  [REQUIRED_MODES]: requiredModes,
70
+ [REQUIRED_TYPE]: requiredType,
71
71
  [REQUIRED_TYPOGRAPHY_PROPERTIES]: requiredTypographyProperties,
72
72
  [A11Y_MIN_CONTRAST]: a11yMinContrast,
73
73
  [A11Y_MIN_FONT_SIZE]: a11yMinFontSize,
@@ -100,5 +100,4 @@ export const RECOMMENDED_CONFIG: Record<string, LintRuleLonghand> = {
100
100
  [VALID_GRADIENT]: ['error', {}],
101
101
  [VALID_TYPOGRAPHY]: ['error', {}],
102
102
  [CONSISTENT_NAMING]: ['warn', { format: 'kebab-case' }],
103
- [NO_TYPE_ON_ALIAS]: ['warn', {}],
104
103
  };
@@ -1,3 +1,3 @@
1
1
  export function docsLink(ruleName: string): string {
2
- return `https://terrazzo.app/docs/cli/lint#${ruleName.replaceAll('/', '')}`;
2
+ return `https://terrazzo.app/docs/linting#${ruleName.replaceAll('/', '')}`;
3
3
  }
@@ -1,25 +1,24 @@
1
- import { isAlias } from '@terrazzo/token-tools';
2
1
  import type { LintRule } from '../../../types.js';
3
2
  import { docsLink } from '../lib/docs.js';
4
3
 
5
- export const NO_TYPE_ON_ALIAS = 'core/no-type-on-alias';
4
+ export const REQUIRED_TYPE = 'core/required-type';
6
5
 
7
6
  export const ERROR = 'ERROR';
8
7
 
9
8
  const rule: LintRule<typeof ERROR> = {
10
9
  meta: {
11
10
  messages: {
12
- [ERROR]: 'Remove $type from aliased value.',
11
+ [ERROR]: 'Token missing $type.',
13
12
  },
14
13
  docs: {
15
- description: 'If a $value is aliased it already has a $type defined.',
16
- url: docsLink(NO_TYPE_ON_ALIAS),
14
+ description: 'Requiring every token to have $type, even aliases, simplifies computation.',
15
+ url: docsLink(REQUIRED_TYPE),
17
16
  },
18
17
  },
19
18
  defaultOptions: {},
20
19
  create({ tokens, report }) {
21
20
  for (const t of Object.values(tokens)) {
22
- if (isAlias(t.originalValue!.$value as any) && t.originalValue?.$type) {
21
+ if (!t.originalValue?.$type) {
23
22
  report({ messageId: ERROR, node: t.source.node, filename: t.source.filename });
24
23
  }
25
24
  }
@@ -1,19 +1,25 @@
1
+ import type fsType from 'node:fs/promises';
2
+ import type { InputSource, InputSourceWithDocument } from '@terrazzo/json-schema-tools';
1
3
  import { pluralize, type TokenNormalizedSet } from '@terrazzo/token-tools';
2
4
  import lintRunner from '../lint/index.js';
3
5
  import Logger from '../logger.js';
4
- import type { ConfigInit, InputSource, ParseOptions } from '../types.js';
6
+ import { createSyntheticResolver } from '../resolver/create-synthetic-resolver.js';
7
+ import { loadResolver } from '../resolver/load.js';
8
+ import type { ConfigInit, ParseOptions, Resolver } from '../types.js';
5
9
  import { loadSources } from './load.js';
6
10
 
7
11
  export interface ParseResult {
8
12
  tokens: TokenNormalizedSet;
9
- sources: InputSource[];
13
+ sources: InputSourceWithDocument[];
14
+ resolver: Resolver;
10
15
  }
11
16
 
12
17
  /** Parse */
13
18
  export default async function parse(
14
- _input: Omit<InputSource, 'document'> | Omit<InputSource, 'document'>[],
19
+ _input: InputSource | InputSource[],
15
20
  {
16
21
  logger = new Logger(),
22
+ req = defaultReq,
17
23
  skipLint = false,
18
24
  config = {} as ConfigInit,
19
25
  continueOnError = false,
@@ -22,10 +28,33 @@ export default async function parse(
22
28
  }: ParseOptions = {} as ParseOptions,
23
29
  ): Promise<ParseResult> {
24
30
  const inputs = Array.isArray(_input) ? _input : [_input];
31
+ let tokens: TokenNormalizedSet = {};
32
+ let resolver: Resolver | undefined;
33
+ let sources: InputSourceWithDocument[] = [];
25
34
 
26
35
  const totalStart = performance.now();
36
+
37
+ // 1. Load tokens
27
38
  const initStart = performance.now();
28
- const { tokens, sources } = await loadSources(inputs, { logger, config, continueOnError, yamlToMomoa, transform });
39
+ const resolverResult = await loadResolver(inputs, { config, logger, req, yamlToMomoa });
40
+ // 1a. Resolver
41
+ if (resolverResult.resolver) {
42
+ tokens = resolverResult.tokens;
43
+ sources = resolverResult.sources;
44
+ resolver = resolverResult.resolver;
45
+ } else {
46
+ // 1b. No resolver
47
+ const tokenResult = await loadSources(inputs, {
48
+ req,
49
+ logger,
50
+ config,
51
+ continueOnError,
52
+ yamlToMomoa,
53
+ transform,
54
+ });
55
+ tokens = tokenResult.tokens;
56
+ sources = tokenResult.sources;
57
+ }
29
58
  logger.debug({
30
59
  message: 'Loaded tokens',
31
60
  group: 'parser',
@@ -64,5 +93,23 @@ export default async function parse(
64
93
  return {
65
94
  tokens,
66
95
  sources,
96
+ resolver: resolver || (await createSyntheticResolver(tokens, { config, logger, req, sources })),
67
97
  };
68
98
  }
99
+
100
+ let fs: typeof fsType | undefined;
101
+
102
+ /** Fallback req */
103
+ async function defaultReq(src: URL, _origin: URL) {
104
+ if (src.protocol === 'file:') {
105
+ if (!fs) {
106
+ fs = await import('node:fs/promises');
107
+ }
108
+ return await fs.readFile(src, 'utf8');
109
+ }
110
+ const res = await fetch(src);
111
+ if (!res.ok) {
112
+ throw new Error(`${src} responded with ${res.status}\n${await res.text()}`);
113
+ }
114
+ return await res.text();
115
+ }
package/src/parse/load.ts CHANGED
@@ -3,23 +3,19 @@ import {
3
3
  type BundleOptions,
4
4
  bundle,
5
5
  getObjMember,
6
+ type InputSource,
7
+ type InputSourceWithDocument,
6
8
  type RefMap,
7
9
  replaceNode,
8
- traverseAsync,
10
+ traverse,
9
11
  } from '@terrazzo/json-schema-tools';
10
- import type { GroupNormalized, TokenNormalized, TokenNormalizedSet } from '@terrazzo/token-tools';
12
+ import type { TokenNormalized, TokenNormalizedSet } from '@terrazzo/token-tools';
11
13
  import { toMomoa } from '../lib/momoa.js';
14
+ import { filterResolverPaths } from '../lib/resolver-utils.js';
12
15
  import type Logger from '../logger.js';
13
- import type { InputSource, ParseOptions, TransformVisitors } from '../types.js';
14
- import { normalize } from './normalize.js';
15
- import {
16
- graphAliases,
17
- groupFromNode,
18
- refToTokenID,
19
- resolveAliases,
20
- tokenFromNode,
21
- tokenRawValuesFromNode,
22
- } from './token.js';
16
+ import { isLikelyResolver } from '../resolver/validate.js';
17
+ import type { ParseOptions, TransformVisitors } from '../types.js';
18
+ import { processTokens } from './process.js';
23
19
 
24
20
  /** Ephemeral format that only exists while parsing the document. This is not confirmed to be DTCG yet. */
25
21
  export interface IntermediaryToken {
@@ -51,14 +47,20 @@ export interface IntermediaryToken {
51
47
  }
52
48
 
53
49
  export interface LoadOptions extends Pick<ParseOptions, 'config' | 'continueOnError' | 'yamlToMomoa' | 'transform'> {
50
+ req: NonNullable<ParseOptions['req']>;
54
51
  logger: Logger;
55
52
  }
56
53
 
54
+ export interface LoadSourcesResult {
55
+ tokens: TokenNormalizedSet;
56
+ sources: InputSourceWithDocument[];
57
+ }
58
+
57
59
  /** Load from multiple entries, while resolving remote files */
58
60
  export async function loadSources(
59
- inputs: Omit<InputSource, 'document'>[],
60
- { config, logger, continueOnError, yamlToMomoa, transform }: LoadOptions,
61
- ): Promise<{ tokens: TokenNormalizedSet; sources: InputSource[] }> {
61
+ inputs: InputSource[],
62
+ { config, logger, req, continueOnError, yamlToMomoa, transform }: LoadOptions,
63
+ ): Promise<LoadSourcesResult> {
62
64
  const entry = { group: 'parser' as const, label: 'init' };
63
65
 
64
66
  // 1. Bundle root documents together
@@ -72,12 +74,13 @@ export async function loadSources(
72
74
  filename: input.filename || new URL(`virtual:${i}`), // for objects created in memory, an index-based ID helps associate tokens with these
73
75
  }));
74
76
  /** The sources array, indexed by filename */
75
- let sourceByFilename: Record<string, InputSource> = {};
77
+ let sourceByFilename: Record<string, InputSourceWithDocument> = {};
76
78
  /** Mapping of all final $ref resolutions. This will be used to generate the graph later. */
77
79
  let refMap: RefMap = {};
78
80
 
79
81
  try {
80
82
  const result = await bundle(sources, {
83
+ req,
81
84
  parse: transform ? transformer(transform) : undefined,
82
85
  yamlToMomoa,
83
86
  });
@@ -106,102 +109,11 @@ export async function loadSources(
106
109
  src,
107
110
  });
108
111
  }
109
-
110
112
  logger.debug({ ...entry, message: `JSON loaded`, timing: performance.now() - firstLoad });
111
- const artificialSource = { src: momoa.print(document, { indent: 2 }), document };
112
-
113
- // 2. Parse
114
- const firstPass = performance.now();
115
- const tokens: TokenNormalizedSet = {};
116
- // micro-optimization: while we’re iterating over tokens, keeping a “hot”
117
- // array in memory saves recreating arrays from object keys over and over again.
118
- // it does produce a noticeable speedup > 1,000 tokens.
119
- const tokenIDs: string[] = [];
120
- const groups: Record<string, GroupNormalized> = {};
121
-
122
- // 2a. Token & group population
123
- await traverseAsync(document, {
124
- async enter(node, _parent, path) {
125
- if (node.type !== 'Object') {
126
- return;
127
- }
128
- groupFromNode(node, { path, groups });
129
- const token = tokenFromNode(node, {
130
- groups,
131
- ignore: config.ignore,
132
- path,
133
- source: { src: artificialSource, document },
134
- });
135
- if (token) {
136
- tokenIDs.push(token.jsonID);
137
- tokens[token.jsonID] = token;
138
- }
139
- },
140
- });
141
-
142
- logger.debug({ ...entry, message: 'Parsing: 1st pass', timing: performance.now() - firstPass });
143
- const secondPass = performance.now();
144
-
145
- // 2b. Resolve originalValue and original sources
146
- for (const source of Object.values(sourceByFilename)) {
147
- await traverseAsync(source.document, {
148
- async enter(node, _parent, path) {
149
- if (node.type !== 'Object') {
150
- return;
151
- }
152
-
153
- const tokenRawValues = tokenRawValuesFromNode(node, { filename: source.filename!.href, path });
154
- if (tokenRawValues && tokens[tokenRawValues?.jsonID]) {
155
- tokens[tokenRawValues.jsonID]!.originalValue = tokenRawValues.originalValue;
156
- tokens[tokenRawValues.jsonID]!.source = tokenRawValues.source;
157
- for (const mode of Object.keys(tokenRawValues.mode)) {
158
- tokens[tokenRawValues.jsonID]!.mode[mode]!.originalValue = tokenRawValues.mode[mode]!.originalValue;
159
- tokens[tokenRawValues.jsonID]!.mode[mode]!.source = tokenRawValues.mode[mode]!.source;
160
- }
161
- }
162
- },
163
- });
164
- }
165
-
166
- // 2c. DTCG alias resolution
167
- // Unlike $refs which can be resolved as we go, these can’t happen until the final, flattened set
168
- resolveAliases(tokens, { logger, sources: sourceByFilename, refMap });
169
- logger.debug({ ...entry, message: 'Parsing: 2nd pass', timing: performance.now() - secondPass });
170
-
171
- // 3. Alias graph
172
- // We’ve resolved aliases, but we need this pass for reverse linking i.e. “aliasedBy”
173
- const aliasStart = performance.now();
174
- graphAliases(refMap, { tokens, logger, sources: sourceByFilename });
175
- logger.debug({ ...entry, message: 'Alias graph built', timing: performance.now() - aliasStart });
176
-
177
- // 4. normalize
178
- // Allow for some minor variance in inputs, and be nice to folks.
179
- const normalizeStart = performance.now();
180
- for (const id of tokenIDs) {
181
- const token = tokens[id]!;
182
- normalize(token as any, { logger, src: sourceByFilename[token.source.filename!]?.src });
183
- }
184
- logger.debug({ ...entry, message: 'Normalized values', timing: performance.now() - normalizeStart });
185
-
186
- // 5. alphabetize & filter
187
- // This can’t happen until the last step, where we’re 100% sure we’ve resolved everything.
188
- const tokensSorted: TokenNormalizedSet = {};
189
- tokenIDs.sort((a, b) => a.localeCompare(b, 'en-us', { numeric: true }));
190
- for (const path of tokenIDs) {
191
- // Filter out any tokens in $defs (we needed to reference them earlier, but shouldn’t include them in the final assortment)
192
- if (path.includes('/$defs/')) {
193
- continue;
194
- }
195
- const id = refToTokenID(path)!;
196
- tokensSorted[id] = tokens[path]!;
197
- }
198
- // Sort group IDs once, too
199
- for (const group of Object.values(groups)) {
200
- group.tokens.sort((a, b) => a.localeCompare(b, 'en-us', { numeric: true }));
201
- }
202
113
 
114
+ const rootSource = { filename: sources[0]!.filename!, document, src: momoa.print(document, { indent: 2 }) };
203
115
  return {
204
- tokens: tokensSorted,
116
+ tokens: processTokens(rootSource, { config, logger, refMap, sources, sourceByFilename }),
205
117
  sources,
206
118
  };
207
119
  }
@@ -219,8 +131,10 @@ function transformer(transform: TransformVisitors): BundleOptions['parse'] {
219
131
  }
220
132
  }
221
133
 
222
- await traverseAsync(document, {
223
- async enter(node, parent, path) {
134
+ const isResolver = isLikelyResolver(document);
135
+ traverse(document, {
136
+ enter(node, parent, rawPath) {
137
+ const path = isResolver ? filterResolverPaths(rawPath) : rawPath;
224
138
  if (node.type !== 'Object' || !path.length) {
225
139
  return;
226
140
  }
@@ -0,0 +1,124 @@
1
+ import { type InputSourceWithDocument, type RefMap, traverse } from '@terrazzo/json-schema-tools';
2
+ import type { GroupNormalized, TokenNormalizedSet } from '@terrazzo/token-tools';
3
+ import { filterResolverPaths } from '../lib/resolver-utils.js';
4
+ import type Logger from '../logger.js';
5
+ import { isLikelyResolver } from '../resolver/validate.js';
6
+ import type { ConfigInit } from '../types.js';
7
+ import { normalize } from './normalize.js';
8
+ import {
9
+ graphAliases,
10
+ groupFromNode,
11
+ refToTokenID,
12
+ resolveAliases,
13
+ tokenFromNode,
14
+ tokenRawValuesFromNode,
15
+ } from './token.js';
16
+
17
+ export interface ProcessTokensOptions {
18
+ config: ConfigInit;
19
+ logger: Logger;
20
+ sourceByFilename: Record<string, InputSourceWithDocument>;
21
+ refMap: RefMap;
22
+ sources: InputSourceWithDocument[];
23
+ }
24
+
25
+ export function processTokens(
26
+ rootSource: InputSourceWithDocument,
27
+ { config, logger, sourceByFilename, refMap }: ProcessTokensOptions,
28
+ ): TokenNormalizedSet {
29
+ const entry = { group: 'parser' as const, label: 'init' };
30
+
31
+ // 2. Parse
32
+ const firstPass = performance.now();
33
+ const tokens: TokenNormalizedSet = {};
34
+ // micro-optimization: while we’re iterating over tokens, keeping a “hot”
35
+ // array in memory saves recreating arrays from object keys over and over again.
36
+ // it does produce a noticeable speedup > 1,000 tokens.
37
+ const tokenIDs: string[] = [];
38
+ const groups: Record<string, GroupNormalized> = {};
39
+
40
+ // 2a. Token & group population
41
+ const isResolver = isLikelyResolver(rootSource.document);
42
+ traverse(rootSource.document, {
43
+ enter(node, _parent, rawPath) {
44
+ if (node.type !== 'Object') {
45
+ return;
46
+ }
47
+ const path = isResolver ? filterResolverPaths(rawPath) : rawPath;
48
+ groupFromNode(node, { path, groups });
49
+ const token = tokenFromNode(node, {
50
+ groups,
51
+ ignore: config.ignore,
52
+ path,
53
+ source: rootSource,
54
+ });
55
+ if (token) {
56
+ tokenIDs.push(token.jsonID);
57
+ tokens[token.jsonID] = token;
58
+ }
59
+ },
60
+ });
61
+
62
+ logger.debug({ ...entry, message: 'Parsing: 1st pass', timing: performance.now() - firstPass });
63
+ const secondPass = performance.now();
64
+
65
+ // 2b. Resolve originalValue and original sources
66
+ for (const source of Object.values(sourceByFilename)) {
67
+ traverse(source.document, {
68
+ enter(node, _parent, path) {
69
+ if (node.type !== 'Object') {
70
+ return;
71
+ }
72
+
73
+ const tokenRawValues = tokenRawValuesFromNode(node, { filename: source.filename!.href, path });
74
+ if (tokenRawValues && tokens[tokenRawValues?.jsonID]) {
75
+ tokens[tokenRawValues.jsonID]!.originalValue = tokenRawValues.originalValue;
76
+ tokens[tokenRawValues.jsonID]!.source = tokenRawValues.source;
77
+ for (const mode of Object.keys(tokenRawValues.mode)) {
78
+ tokens[tokenRawValues.jsonID]!.mode[mode]!.originalValue = tokenRawValues.mode[mode]!.originalValue;
79
+ tokens[tokenRawValues.jsonID]!.mode[mode]!.source = tokenRawValues.mode[mode]!.source;
80
+ }
81
+ }
82
+ },
83
+ });
84
+ }
85
+
86
+ // 2c. DTCG alias resolution
87
+ // Unlike $refs which can be resolved as we go, these can’t happen until the final, flattened set
88
+ resolveAliases(tokens, { logger, sources: sourceByFilename, refMap });
89
+ logger.debug({ ...entry, message: 'Parsing: 2nd pass', timing: performance.now() - secondPass });
90
+
91
+ // 3. Alias graph
92
+ // We’ve resolved aliases, but we need this pass for reverse linking i.e. “aliasedBy”
93
+ const aliasStart = performance.now();
94
+ graphAliases(refMap, { tokens, logger, sources: sourceByFilename });
95
+ logger.debug({ ...entry, message: 'Alias graph built', timing: performance.now() - aliasStart });
96
+
97
+ // 4. normalize
98
+ // Allow for some minor variance in inputs, and be nice to folks.
99
+ const normalizeStart = performance.now();
100
+ for (const id of tokenIDs) {
101
+ const token = tokens[id]!;
102
+ normalize(token as any, { logger, src: sourceByFilename[token.source.filename!]?.src });
103
+ }
104
+ logger.debug({ ...entry, message: 'Normalized values', timing: performance.now() - normalizeStart });
105
+
106
+ // 5. alphabetize & filter
107
+ // This can’t happen until the last step, where we’re 100% sure we’ve resolved everything.
108
+ const tokensSorted: TokenNormalizedSet = {};
109
+ tokenIDs.sort((a, b) => a.localeCompare(b, 'en-us', { numeric: true }));
110
+ for (const path of tokenIDs) {
111
+ // Filter out any tokens in $defs (we needed to reference them earlier, but shouldn’t include them in the final assortment)
112
+ if (path.includes('/$defs/')) {
113
+ continue;
114
+ }
115
+ const id = refToTokenID(path)!;
116
+ tokensSorted[id] = tokens[path]!;
117
+ }
118
+ // Sort group IDs once, too
119
+ for (const group of Object.values(groups)) {
120
+ group.tokens.sort((a, b) => a.localeCompare(b, 'en-us', { numeric: true }));
121
+ }
122
+
123
+ return tokensSorted;
124
+ }