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

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,359 @@
1
+ import type { DocumentNode, ObjectNode } 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: 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: 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 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: 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(node: ObjectNode, isInline = false, { src }: ValidateResolverOptions): LogEntry[] {
281
+ const errors: LogEntry[] = [];
282
+ const entry = { group: 'parser', label: 'resolver', src } as const;
283
+ let hasName = !isInline;
284
+ let hasType = !isInline;
285
+ let hasContexts = false;
286
+ for (const member of node.members) {
287
+ if (member.name.type !== 'String') {
288
+ continue;
289
+ }
290
+ switch (member.name.value) {
291
+ case 'name': {
292
+ hasName = true;
293
+ if (member.value.type !== 'String') {
294
+ errors.push({ ...entry, message: MESSAGE_EXPECTED.STRING, node: member.value });
295
+ }
296
+ break;
297
+ }
298
+ case 'description': {
299
+ if (member.value.type !== 'String') {
300
+ errors.push({ ...entry, message: MESSAGE_EXPECTED.STRING, node: member.value });
301
+ }
302
+ break;
303
+ }
304
+ case 'type': {
305
+ hasType = true;
306
+ if (member.value.type !== 'String') {
307
+ errors.push({ ...entry, message: MESSAGE_EXPECTED.STRING, node: member.value });
308
+ } else if (member.value.value !== 'modifier') {
309
+ errors.push({ ...entry, message: '"type" must be "modifier".' });
310
+ }
311
+ break;
312
+ }
313
+ case 'contexts': {
314
+ hasContexts = true;
315
+ if (member.value.type !== 'Object') {
316
+ errors.push({ ...entry, message: MESSAGE_EXPECTED.OBJECT, node: member.value });
317
+ } else if (member.value.members.length === 0) {
318
+ errors.push({ ...entry, message: `"contexts" can’t be empty object.`, node: member.value });
319
+ } else {
320
+ for (const context of member.value.members) {
321
+ if (context.value.type !== 'Array') {
322
+ errors.push({ ...entry, message: MESSAGE_EXPECTED.ARRAY, node: context.value });
323
+ }
324
+ }
325
+ }
326
+ break;
327
+ }
328
+ case '$defs':
329
+ case '$extensions':
330
+ if (member.value.type !== 'Object') {
331
+ errors.push({ ...entry, message: `Expected object`, node: member.value });
332
+ }
333
+ break;
334
+ case '$ref': {
335
+ if (member.value.type !== 'String') {
336
+ errors.push({ ...entry, message: `Expected string`, node: member.value });
337
+ }
338
+ break;
339
+ }
340
+ default: {
341
+ errors.push({ ...entry, message: `Unknown key ${JSON.stringify(member.name.value)}`, node: member.name });
342
+ break;
343
+ }
344
+ }
345
+ }
346
+
347
+ // handle required keys
348
+ if (!hasName) {
349
+ errors.push({ ...entry, message: `Missing "name".`, node });
350
+ }
351
+ if (!hasType) {
352
+ errors.push({ ...entry, message: `"type": "modifier" missing.`, node });
353
+ }
354
+ if (!hasContexts) {
355
+ errors.push({ ...entry, message: `Missing "contexts".`, node });
356
+ }
357
+
358
+ return errors;
359
+ }
package/src/types.ts CHANGED
@@ -1,8 +1,23 @@
1
1
  import type * as momoa from '@humanwhocodes/momoa';
2
- import type { TokenNormalized } from '@terrazzo/token-tools';
2
+ import type {
3
+ Group,
4
+ TokenNormalized,
5
+ TokenNormalizedSet,
6
+ TokenTransformed,
7
+ TokenTransformedBase,
8
+ } from '@terrazzo/token-tools';
3
9
  import type ytm from 'yaml-to-momoa';
4
10
  import type Logger from './logger.js';
5
11
 
12
+ // Export some types as a convenience, because they originally came from this package
13
+ export type {
14
+ Group,
15
+ TokenNormalized,
16
+ TokenNormalizedSet,
17
+ TokenTransformed,
18
+ TokenTransformedBase,
19
+ } from '@terrazzo/token-tools';
20
+
6
21
  export interface PluginHookContext {
7
22
  logger: Logger;
8
23
  }
@@ -231,7 +246,7 @@ export interface LintRuleMetaData<
231
246
  docs?: LintRuleDocs & LintRuleMetaDataDocs;
232
247
  /**
233
248
  * A map of messages which the rule can report. The key is the messageId, and
234
- * the string is the parameterised error string.
249
+ * the string is the parameterized error string.
235
250
  */
236
251
  messages?: Record<MessageIds, string>;
237
252
  /**
@@ -270,6 +285,12 @@ export interface OutputFileExpanded extends OutputFile {
270
285
  export interface ParseOptions {
271
286
  logger?: Logger;
272
287
  config: ConfigInit;
288
+ /**
289
+ * Handle requests to loading remote files, either from a remote URL or on the filesystem.
290
+ * - Remote requests will have an "https:' protocol
291
+ * - Filesystem files will have a "file:" protocol
292
+ */
293
+ req?: (src: URL, origin: URL) => Promise<string>;
273
294
  /**
274
295
  * Skip lint step
275
296
  * @default false
@@ -310,42 +331,95 @@ export interface Plugin {
310
331
  buildEnd?(options: BuildEndHookOptions): Promise<void>;
311
332
  }
312
333
 
313
- interface TokenTransformedBase {
314
- /** Original Token ID */
315
- id: string;
316
- /** ID unique to this format. */
317
- localID?: string;
334
+ export interface ReferenceObject {
335
+ $ref: string;
336
+ }
337
+
338
+ export interface Resolver<
339
+ Inputs extends Record<string, string[]> = Record<string, string[]>,
340
+ Input = Record<keyof Inputs, Inputs[keyof Inputs][number]>,
341
+ > {
342
+ /** Supply values to modifiers to produce a final tokens set */
343
+ apply: (input: Partial<Input>) => TokenNormalizedSet;
344
+ /** List all possible valid input combinations. Ignores default values, as they would duplicate some other permutations. */
345
+ permutations: Input[];
346
+ /** The original resolver document, simplified */
347
+ source: ResolverSourceNormalized;
348
+ /** Helper function for permutations—see if a particular input is valid. Automatically applies default values. */
349
+ isValidInput: (input: Input) => boolean;
350
+ }
351
+
352
+ export interface ResolverSource {
353
+ /** Human-friendly name of this resolver */
354
+ name?: string;
355
+ /** DTCG version */
356
+ version: '2025.10';
357
+ /** Description of this resolver */
358
+ description?: string;
359
+ /** Mapping of sets */
360
+ sets?: Record<string, ResolverSet>;
361
+ /** Mapping of modifiers */
362
+ modifiers?: Record<string, ResolverModifier>;
363
+ resolutionOrder: (ResolverSetInline | ResolverModifierInline | ReferenceObject)[];
364
+ $extensions?: Record<string, unknown>;
365
+ $defs?: Record<string, unknown>;
366
+ }
367
+
368
+ /** Resolver where all tokens are loaded and flattened in-memory, so only the final merging is left */
369
+ export interface ResolverSourceNormalized {
370
+ name: string | undefined;
371
+ version: '2025.10';
372
+ description: string | undefined;
373
+ sets: Record<string, ResolverSet> | undefined;
374
+ modifiers: Record<string, ResolverModifier> | undefined;
318
375
  /**
319
- * The mode of this value
320
- * @default "."
376
+ * Array of all sets and modifiers that have been converted to inline,
377
+ * regardless of original declaration. In a normalized resolver, only a single
378
+ * pass over the resolutionOrder array is needed.
321
379
  */
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 };
333
- };
380
+ resolutionOrder: (ResolverSetNormalized | ResolverModifierNormalized)[];
334
381
  }
335
382
 
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;
383
+ export interface ResolverModifier<Context extends string = string> {
384
+ description?: string;
385
+ contexts: Record<Context, (Group | ReferenceObject)[]>;
386
+ default?: Context;
387
+ $extensions?: Record<string, unknown>;
388
+ $defs?: Record<string, unknown>;
340
389
  }
341
390
 
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>;
391
+ export type ResolverModifierInline<Context extends string = string> = ResolverModifier<Context> & {
392
+ name: string;
393
+ type: 'modifier';
394
+ };
395
+
396
+ export interface ResolverModifierNormalized {
397
+ name: string;
398
+ type: 'modifier';
399
+ description: string | undefined;
400
+ contexts: Record<string, Group[]>;
401
+ default: string | undefined;
402
+ $extensions: Record<string, unknown> | undefined;
403
+ $defs: Record<string, unknown> | undefined;
346
404
  }
347
405
 
348
- export type TokenTransformed = TokenTransformedSingleValue | TokenTransformedMultiValue;
406
+ export interface ResolverSet {
407
+ description?: string;
408
+ sources: (Group | ReferenceObject)[];
409
+ $extensions?: Record<string, unknown>;
410
+ $defs?: Record<string, unknown>;
411
+ }
412
+
413
+ export type ResolverSetInline = ResolverSet & { name: string; type: 'set' };
414
+
415
+ export interface ResolverSetNormalized {
416
+ name: string;
417
+ type: 'set';
418
+ description: string | undefined;
419
+ sources: Group[];
420
+ $extensions: Record<string, unknown> | undefined;
421
+ $defs: Record<string, unknown> | undefined;
422
+ }
349
423
 
350
424
  export interface TransformParams {
351
425
  /** ID of an existing format */
@@ -382,7 +456,3 @@ export interface TransformHookOptions {
382
456
  /** Momoa documents */
383
457
  sources: InputSource[];
384
458
  }
385
-
386
- export interface ReferenceObject {
387
- $ref: string;
388
- }