@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.
@@ -0,0 +1,363 @@
1
+ import type * as momoa from '@humanwhocodes/momoa';
2
+ import { getObjMember, getObjMembers } from '@terrazzo/json-schema-tools';
3
+ import type { LogEntry, default as Logger } from '../logger.js';
4
+
5
+ /**
6
+ * Determine whether this is likely a resolver
7
+ * We use terms the word “likely” because this occurs before validation. Since
8
+ * we may be dealing with a doc _intended_ to be a resolver, but may be lacking
9
+ * some critical information, how can we determine intent? There’s a bit of
10
+ * guesswork here, but we try and find a reasonable edge case where we sniff out
11
+ * invalid DTCG syntax that a resolver doc would have.
12
+ */
13
+ export function isLikelyResolver(doc: momoa.DocumentNode): boolean {
14
+ if (doc.body.type !== 'Object') {
15
+ return false;
16
+ }
17
+ // This is a resolver if…
18
+ for (const member of doc.body.members) {
19
+ if (member.name.type !== 'String') {
20
+ continue;
21
+ }
22
+ switch (member.name.value) {
23
+ case 'name':
24
+ case 'description':
25
+ case 'version': {
26
+ // 1. name, description, or version are a string
27
+ if (member.name.type === 'String') {
28
+ return true;
29
+ }
30
+ break;
31
+ }
32
+ case 'sets':
33
+ case 'modifiers': {
34
+ if (member.value.type !== 'Object') {
35
+ continue;
36
+ }
37
+ // 2. sets.description or modifiers.description is a string
38
+ if (getObjMember(member.value, 'description')?.type === 'String') {
39
+ return true;
40
+ }
41
+ // 3. sets.sources is an array
42
+ if (member.name.value === 'sets' && getObjMember(member.value, 'sources')?.type === 'Array') {
43
+ return true;
44
+ } else if (member.name.value === 'modifiers') {
45
+ const contexts = getObjMember(member.value, 'contexts');
46
+ if (contexts?.type === 'Object' && contexts.members.some((m) => m.value.type === 'Array')) {
47
+ // 4. contexts[key] is an array
48
+ // (note: modifiers.contexts as an object is technically valid token format! We need to check for the array)
49
+ return true;
50
+ }
51
+ }
52
+ break;
53
+ }
54
+ case 'resolutionOrder': {
55
+ // 4. resolutionOrder is an array
56
+ if (member.value.type === 'Array') {
57
+ return true;
58
+ }
59
+ break;
60
+ }
61
+ }
62
+ }
63
+
64
+ return false;
65
+ }
66
+
67
+ export interface ValidateResolverOptions {
68
+ logger: Logger;
69
+ src: string;
70
+ }
71
+
72
+ const MESSAGE_EXPECTED = {
73
+ STRING: 'Expected string.',
74
+ OBJECT: 'Expected object.',
75
+ ARRAY: 'Expected array.',
76
+ };
77
+
78
+ /**
79
+ * Validate a resolver document.
80
+ * There’s a ton of boilerplate here, only to surface detailed code frames. Is there a better abstraction?
81
+ */
82
+ export function validateResolver(node: momoa.DocumentNode, { logger, src }: ValidateResolverOptions) {
83
+ const entry = { group: 'parser', label: 'resolver', src } as const;
84
+ if (node.body.type !== 'Object') {
85
+ logger.error({ ...entry, message: MESSAGE_EXPECTED.OBJECT, node });
86
+ }
87
+ const errors: LogEntry[] = [];
88
+
89
+ let hasVersion = false;
90
+ let hasResolutionOrder = false;
91
+
92
+ for (const member of (node.body as momoa.ObjectNode).members) {
93
+ if (member.name.type !== 'String') {
94
+ continue; // IDK, don’t ask
95
+ }
96
+
97
+ switch (member.name.value) {
98
+ case 'name':
99
+ case 'description': {
100
+ if (member.value.type !== 'String') {
101
+ errors.push({ ...entry, message: MESSAGE_EXPECTED.STRING });
102
+ }
103
+ break;
104
+ }
105
+
106
+ case 'version': {
107
+ hasVersion = true;
108
+ if (member.value.type !== 'String' || member.value.value !== '2025.10') {
109
+ errors.push({ ...entry, message: `Expected "version" to be "2025.10".`, node: member.value });
110
+ }
111
+ break;
112
+ }
113
+
114
+ case 'sets':
115
+ case 'modifiers': {
116
+ if (member.value.type !== 'Object') {
117
+ errors.push({ ...entry, message: MESSAGE_EXPECTED.OBJECT, node: member.value });
118
+ } else {
119
+ for (const item of member.value.members) {
120
+ if (item.value.type !== 'Object') {
121
+ errors.push({ ...entry, message: MESSAGE_EXPECTED.OBJECT, node: item.value });
122
+ } else {
123
+ const validator = member.name.value === 'sets' ? validateSet : validateModifier;
124
+ errors.push(...validator(item.value, false, { logger, src }));
125
+ }
126
+ }
127
+ }
128
+ break;
129
+ }
130
+
131
+ case 'resolutionOrder': {
132
+ hasResolutionOrder = true;
133
+ if (member.value.type !== 'Array') {
134
+ errors.push({ ...entry, message: MESSAGE_EXPECTED.ARRAY, node: member.value });
135
+ } else if (member.value.elements.length === 0) {
136
+ errors.push({ ...entry, message: `"resolutionOrder" can’t be empty array.`, node: member.value });
137
+ } else {
138
+ for (const item of member.value.elements) {
139
+ if (item.value.type !== 'Object') {
140
+ errors.push({ ...entry, message: MESSAGE_EXPECTED.OBJECT, node: item.value });
141
+ } else {
142
+ const itemMembers = getObjMembers(item.value);
143
+ if (itemMembers.$ref?.type === 'String') {
144
+ continue; // we can’t validate this just yet, assume it’s correct
145
+ }
146
+ // Validate "type"
147
+ if (itemMembers.type?.type === 'String') {
148
+ if (itemMembers.type.value === 'set') {
149
+ validateSet(item.value, true, { logger, src });
150
+ } else if (itemMembers.type.value === 'modifier') {
151
+ validateModifier(item.value, true, { logger, src });
152
+ } else {
153
+ errors.push({
154
+ ...entry,
155
+ message: `Unknown type ${JSON.stringify(itemMembers.type.value)}`,
156
+ node: itemMembers.type,
157
+ });
158
+ }
159
+ }
160
+ // validate sets & modifiers if they’re missing "type"
161
+ if (itemMembers.sources?.type === 'Array') {
162
+ validateSet(item.value, true, { logger, src });
163
+ } else if (itemMembers.contexts?.type === 'Object') {
164
+ validateModifier(item.value, true, { logger, src });
165
+ } else if (itemMembers.name?.type === 'String' || itemMembers.description?.type === 'String') {
166
+ validateSet(item.value, true, { logger, src }); // if this has a "name" or "description", guess set
167
+ }
168
+ }
169
+ }
170
+ }
171
+ break;
172
+ }
173
+ case '$defs':
174
+ case '$extensions':
175
+ if (member.value.type !== 'Object') {
176
+ errors.push({ ...entry, message: `Expected object`, node: member.value });
177
+ }
178
+ break;
179
+ case '$ref': {
180
+ if (member.value.type !== 'String') {
181
+ errors.push({ ...entry, message: `Expected string`, node: member.value });
182
+ }
183
+ break;
184
+ }
185
+ default: {
186
+ errors.push({ ...entry, message: `Unknown key ${JSON.stringify(member.name.value)}`, node: member.name, src });
187
+ break;
188
+ }
189
+ }
190
+ }
191
+
192
+ // handle required keys
193
+ if (!hasVersion) {
194
+ errors.push({ ...entry, message: `Missing "version".`, node, src });
195
+ }
196
+ if (!hasResolutionOrder) {
197
+ errors.push({ ...entry, message: `Missing "resolutionOrder".`, node, src });
198
+ }
199
+
200
+ if (errors.length) {
201
+ logger.error(...errors);
202
+ }
203
+ }
204
+
205
+ export function validateSet(node: momoa.ObjectNode, isInline = false, { src }: ValidateResolverOptions): LogEntry[] {
206
+ const entry = { group: 'parser', label: 'resolver', src } as const;
207
+ const errors: LogEntry[] = [];
208
+ let hasName = !isInline;
209
+ let hasType = !isInline;
210
+ let hasSources = false;
211
+ for (const member of node.members) {
212
+ if (member.name.type !== 'String') {
213
+ continue;
214
+ }
215
+ switch (member.name.value) {
216
+ case 'name': {
217
+ hasName = true;
218
+ if (member.value.type !== 'String') {
219
+ errors.push({ ...entry, message: MESSAGE_EXPECTED.STRING, node: member.value });
220
+ }
221
+ break;
222
+ }
223
+ case 'description': {
224
+ if (member.value.type !== 'String') {
225
+ errors.push({ ...entry, message: MESSAGE_EXPECTED.STRING, node: member.value });
226
+ }
227
+ break;
228
+ }
229
+ case 'type': {
230
+ hasType = true;
231
+ if (member.value.type !== 'String') {
232
+ errors.push({ ...entry, message: MESSAGE_EXPECTED.STRING, node: member.value });
233
+ } else if (member.value.value !== 'set') {
234
+ errors.push({ ...entry, message: '"type" must be "set".' });
235
+ }
236
+ break;
237
+ }
238
+ case 'sources': {
239
+ hasSources = true;
240
+ if (member.value.type !== 'Array') {
241
+ errors.push({ ...entry, message: MESSAGE_EXPECTED.ARRAY, node: member.value });
242
+ } else if (member.value.elements.length === 0) {
243
+ errors.push({ ...entry, message: `"sources" can’t be empty array.`, node: member.value });
244
+ }
245
+ break;
246
+ }
247
+ case '$defs':
248
+ case '$extensions':
249
+ if (member.value.type !== 'Object') {
250
+ errors.push({ ...entry, message: `Expected object`, node: member.value });
251
+ }
252
+ break;
253
+ case '$ref': {
254
+ if (member.value.type !== 'String') {
255
+ errors.push({ ...entry, message: `Expected string`, node: member.value });
256
+ }
257
+ break;
258
+ }
259
+ default: {
260
+ errors.push({ ...entry, message: `Unknown key ${JSON.stringify(member.name.value)}`, node: member.name });
261
+ break;
262
+ }
263
+ }
264
+ }
265
+
266
+ // handle required keys
267
+ if (!hasName) {
268
+ errors.push({ ...entry, message: `Missing "name".`, node });
269
+ }
270
+ if (!hasType) {
271
+ errors.push({ ...entry, message: `"type": "set" missing.`, node });
272
+ }
273
+ if (!hasSources) {
274
+ errors.push({ ...entry, message: `Missing "sources".`, node });
275
+ }
276
+
277
+ return errors;
278
+ }
279
+
280
+ export function validateModifier(
281
+ node: momoa.ObjectNode,
282
+ isInline = false,
283
+ { src }: ValidateResolverOptions,
284
+ ): LogEntry[] {
285
+ const errors: LogEntry[] = [];
286
+ const entry = { group: 'parser', label: 'resolver', src } as const;
287
+ let hasName = !isInline;
288
+ let hasType = !isInline;
289
+ let hasContexts = false;
290
+ for (const member of node.members) {
291
+ if (member.name.type !== 'String') {
292
+ continue;
293
+ }
294
+ switch (member.name.value) {
295
+ case 'name': {
296
+ hasName = true;
297
+ if (member.value.type !== 'String') {
298
+ errors.push({ ...entry, message: MESSAGE_EXPECTED.STRING, node: member.value });
299
+ }
300
+ break;
301
+ }
302
+ case 'description': {
303
+ if (member.value.type !== 'String') {
304
+ errors.push({ ...entry, message: MESSAGE_EXPECTED.STRING, node: member.value });
305
+ }
306
+ break;
307
+ }
308
+ case 'type': {
309
+ hasType = true;
310
+ if (member.value.type !== 'String') {
311
+ errors.push({ ...entry, message: MESSAGE_EXPECTED.STRING, node: member.value });
312
+ } else if (member.value.value !== 'modifier') {
313
+ errors.push({ ...entry, message: '"type" must be "modifier".' });
314
+ }
315
+ break;
316
+ }
317
+ case 'contexts': {
318
+ hasContexts = true;
319
+ if (member.value.type !== 'Object') {
320
+ errors.push({ ...entry, message: MESSAGE_EXPECTED.OBJECT, node: member.value });
321
+ } else if (member.value.members.length === 0) {
322
+ errors.push({ ...entry, message: `"contexts" can’t be empty object.`, node: member.value });
323
+ } else {
324
+ for (const context of member.value.members) {
325
+ if (context.value.type !== 'Array') {
326
+ errors.push({ ...entry, message: MESSAGE_EXPECTED.ARRAY, node: context.value });
327
+ }
328
+ }
329
+ }
330
+ break;
331
+ }
332
+ case '$defs':
333
+ case '$extensions':
334
+ if (member.value.type !== 'Object') {
335
+ errors.push({ ...entry, message: `Expected object`, node: member.value });
336
+ }
337
+ break;
338
+ case '$ref': {
339
+ if (member.value.type !== 'String') {
340
+ errors.push({ ...entry, message: `Expected string`, node: member.value });
341
+ }
342
+ break;
343
+ }
344
+ default: {
345
+ errors.push({ ...entry, message: `Unknown key ${JSON.stringify(member.name.value)}`, node: member.name });
346
+ break;
347
+ }
348
+ }
349
+ }
350
+
351
+ // handle required keys
352
+ if (!hasName) {
353
+ errors.push({ ...entry, message: `Missing "name".`, node });
354
+ }
355
+ if (!hasType) {
356
+ errors.push({ ...entry, message: `"type": "modifier" missing.`, node });
357
+ }
358
+ if (!hasContexts) {
359
+ errors.push({ ...entry, message: `Missing "contexts".`, node });
360
+ }
361
+
362
+ return errors;
363
+ }
package/src/types.ts CHANGED
@@ -1,8 +1,24 @@
1
1
  import type * as momoa from '@humanwhocodes/momoa';
2
- import type { TokenNormalized } from '@terrazzo/token-tools';
2
+ import type { InputSourceWithDocument } from '@terrazzo/json-schema-tools';
3
+ import type {
4
+ Group,
5
+ TokenNormalized,
6
+ TokenNormalizedSet,
7
+ TokenTransformed,
8
+ TokenTransformedBase,
9
+ } from '@terrazzo/token-tools';
3
10
  import type ytm from 'yaml-to-momoa';
4
11
  import type Logger from './logger.js';
5
12
 
13
+ // Export some types as a convenience, because they originally came from this package
14
+ export type {
15
+ Group,
16
+ TokenNormalized,
17
+ TokenNormalizedSet,
18
+ TokenTransformed,
19
+ TokenTransformedBase,
20
+ } from '@terrazzo/token-tools';
21
+
6
22
  export interface PluginHookContext {
7
23
  logger: Logger;
8
24
  }
@@ -15,7 +31,7 @@ export interface BuildHookOptions {
15
31
  /** Query transformed values */
16
32
  getTransforms(params: TransformParams): TokenTransformed[];
17
33
  /** Momoa documents */
18
- sources: InputSource[];
34
+ sources: InputSourceWithDocument[];
19
35
  outputFile: (
20
36
  /** Filename to output (relative to outDir) */
21
37
  filename: string,
@@ -36,7 +52,7 @@ export interface BuildEndHookOptions {
36
52
  /** Query transformed values */
37
53
  getTransforms(params: TransformParams): TokenTransformed[];
38
54
  /** Momoa documents */
39
- sources: InputSource[];
55
+ sources: InputSourceWithDocument[];
40
56
  /** Final files to be written */
41
57
  outputFiles: OutputFileExpanded[];
42
58
  }
@@ -132,12 +148,6 @@ export interface ConfigOptions {
132
148
  cwd: URL;
133
149
  }
134
150
 
135
- export interface InputSource {
136
- filename?: URL;
137
- src: any;
138
- document: momoa.DocumentNode;
139
- }
140
-
141
151
  export interface LintNotice {
142
152
  /** Lint message shown to the user */
143
153
  message: string;
@@ -211,7 +221,7 @@ export interface LintRuleContext<MessageIds extends string, LintRuleOptions exte
211
221
  * All source files present in this run. To find the original source, match a
212
222
  * token’s `source.loc` filename to one of the source’s `filename`s.
213
223
  */
214
- sources: InputSource[];
224
+ sources: InputSourceWithDocument[];
215
225
  /** Source file location. */
216
226
  filename?: URL;
217
227
  /** ID:Token map of all tokens. */
@@ -231,7 +241,7 @@ export interface LintRuleMetaData<
231
241
  docs?: LintRuleDocs & LintRuleMetaDataDocs;
232
242
  /**
233
243
  * A map of messages which the rule can report. The key is the messageId, and
234
- * the string is the parameterised error string.
244
+ * the string is the parameterized error string.
235
245
  */
236
246
  messages?: Record<MessageIds, string>;
237
247
  /**
@@ -270,6 +280,12 @@ export interface OutputFileExpanded extends OutputFile {
270
280
  export interface ParseOptions {
271
281
  logger?: Logger;
272
282
  config: ConfigInit;
283
+ /**
284
+ * Handle requests to loading remote files, either from a remote URL or on the filesystem.
285
+ * - Remote requests will have an "https:' protocol
286
+ * - Filesystem files will have a "file:" protocol
287
+ */
288
+ req?: (src: URL, origin: URL) => Promise<string>;
273
289
  /**
274
290
  * Skip lint step
275
291
  * @default false
@@ -288,7 +304,7 @@ export interface ParseOptions {
288
304
  */
289
305
  transform?: TransformVisitors;
290
306
  /** (internal cache; do not use) */
291
- _sources?: Record<string, InputSource>;
307
+ _sources?: Record<string, InputSourceWithDocument>;
292
308
  }
293
309
 
294
310
  export interface Plugin {
@@ -310,42 +326,99 @@ export interface Plugin {
310
326
  buildEnd?(options: BuildEndHookOptions): Promise<void>;
311
327
  }
312
328
 
313
- interface TokenTransformedBase {
314
- /** Original Token ID */
315
- id: string;
316
- /** ID unique to this format. */
317
- localID?: string;
329
+ export interface ReferenceObject {
330
+ $ref: string;
331
+ }
332
+
333
+ export interface Resolver<
334
+ Inputs extends Record<string, string[]> = Record<string, string[]>,
335
+ Input = Record<keyof Inputs, Inputs[keyof Inputs][number]>,
336
+ > {
337
+ /** Supply values to modifiers to produce a final tokens set */
338
+ apply: (input: Partial<Input>) => TokenNormalizedSet;
339
+ /** List all possible valid input combinations. Ignores default values, as they would duplicate some other permutations. */
340
+ listPermutations: () => Input[];
341
+ /** The original resolver document, simplified */
342
+ source: ResolverSourceNormalized;
343
+ /** Helper function for permutations—see if a particular input is valid. Automatically applies default values. */
344
+ isValidInput: (input: Input) => boolean;
345
+ }
346
+
347
+ export interface ResolverSource {
348
+ /** Human-friendly name of this resolver */
349
+ name?: string;
350
+ /** DTCG version */
351
+ version: '2025.10';
352
+ /** Description of this resolver */
353
+ description?: string;
354
+ /** Mapping of sets */
355
+ sets?: Record<string, ResolverSet>;
356
+ /** Mapping of modifiers */
357
+ modifiers?: Record<string, ResolverModifier>;
358
+ resolutionOrder: (ResolverSetInline | ResolverModifierInline | ReferenceObject)[];
359
+ $extensions?: Record<string, unknown>;
360
+ $defs?: Record<string, unknown>;
361
+ }
362
+
363
+ /** Resolver where all tokens are loaded and flattened in-memory, so only the final merging is left */
364
+ export interface ResolverSourceNormalized {
365
+ name: string | undefined;
366
+ version: '2025.10';
367
+ description: string | undefined;
368
+ sets: Record<string, ResolverSet> | undefined;
369
+ modifiers: Record<string, ResolverModifier> | undefined;
318
370
  /**
319
- * The mode of this value
320
- * @default "."
371
+ * Array of all sets and modifiers that have been converted to inline,
372
+ * regardless of original declaration. In a normalized resolver, only a single
373
+ * pass over the resolutionOrder array is needed.
321
374
  */
322
- mode: string;
323
- /** The original token. */
324
- token: TokenNormalized;
325
- /** Arbitrary metadata set by plugins. */
326
- meta?: Record<string | number | symbol, unknown> & {
327
- /**
328
- * Metadata for the token-listing plugin. Plugins can
329
- * set this to be the name of a token as it appears in code,
330
- * and the token-listing plugin will pick it up and use it.
331
- */
332
- 'token-listing'?: { name: string | undefined };
375
+ resolutionOrder: (ResolverSetNormalized | ResolverModifierNormalized)[];
376
+ _source: {
377
+ filename?: URL;
378
+ node: momoa.DocumentNode;
333
379
  };
334
380
  }
335
381
 
336
- /** Transformed token with a single value. Note that this may be any type! */
337
- export interface TokenTransformedSingleValue extends TokenTransformedBase {
338
- type: 'SINGLE_VALUE';
339
- value: string;
382
+ export interface ResolverModifier<Context extends string = string> {
383
+ description?: string;
384
+ contexts: Record<Context, (Group | ReferenceObject)[]>;
385
+ default?: Context;
386
+ $extensions?: Record<string, unknown>;
387
+ $defs?: Record<string, unknown>;
388
+ }
389
+
390
+ export type ResolverModifierInline<Context extends string = string> = ResolverModifier<Context> & {
391
+ name: string;
392
+ type: 'modifier';
393
+ };
394
+
395
+ export interface ResolverModifierNormalized {
396
+ name: string;
397
+ type: 'modifier';
398
+ description: string | undefined;
399
+ contexts: Record<string, Group[]>;
400
+ default: string | undefined;
401
+ $extensions: Record<string, unknown> | undefined;
402
+ $defs: Record<string, unknown> | undefined;
340
403
  }
341
404
 
342
- /** Transformed token with multiple values. Note that this may be any type! */
343
- export interface TokenTransformedMultiValue extends TokenTransformedBase {
344
- type: 'MULTI_VALUE';
345
- value: Record<string, string>;
405
+ export interface ResolverSet {
406
+ description?: string;
407
+ sources: (Group | ReferenceObject)[];
408
+ $extensions?: Record<string, unknown>;
409
+ $defs?: Record<string, unknown>;
346
410
  }
347
411
 
348
- export type TokenTransformed = TokenTransformedSingleValue | TokenTransformedMultiValue;
412
+ export type ResolverSetInline = ResolverSet & { name: string; type: 'set' };
413
+
414
+ export interface ResolverSetNormalized {
415
+ name: string;
416
+ type: 'set';
417
+ description: string | undefined;
418
+ sources: Group[];
419
+ $extensions: Record<string, unknown> | undefined;
420
+ $defs: Record<string, unknown> | undefined;
421
+ }
349
422
 
350
423
  export interface TransformParams {
351
424
  /** ID of an existing format */
@@ -380,9 +453,5 @@ export interface TransformHookOptions {
380
453
  },
381
454
  ): void;
382
455
  /** Momoa documents */
383
- sources: InputSource[];
384
- }
385
-
386
- export interface ReferenceObject {
387
- $ref: string;
456
+ sources: InputSourceWithDocument[];
388
457
  }