@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.
@@ -1,5 +1,5 @@
1
1
  import * as momoa from '@humanwhocodes/momoa';
2
- import { getObjMember, parseRef, type RefMap } from '@terrazzo/json-schema-tools';
2
+ import { getObjMember, type InputSourceWithDocument, parseRef, type RefMap } from '@terrazzo/json-schema-tools';
3
3
  import {
4
4
  type GroupNormalized,
5
5
  isAlias,
@@ -9,7 +9,7 @@ import {
9
9
  } from '@terrazzo/token-tools';
10
10
  import wcmatch from 'wildcard-match';
11
11
  import type { default as Logger } from '../logger.js';
12
- import type { Config, InputSource, ReferenceObject } from '../types.js';
12
+ import type { Config, ReferenceObject } from '../types.js';
13
13
 
14
14
  /** Convert valid DTCG alias to $ref */
15
15
  export function aliasToRef(alias: string, mode?: string): ReferenceObject | undefined {
@@ -26,7 +26,7 @@ export function aliasToRef(alias: string, mode?: string): ReferenceObject | unde
26
26
  export interface TokenFromNodeOptions {
27
27
  groups: Record<string, GroupNormalized>;
28
28
  path: string[];
29
- source: InputSource;
29
+ source: InputSourceWithDocument;
30
30
  ignore: Config['ignore'];
31
31
  }
32
32
 
@@ -51,7 +51,7 @@ export function tokenFromNode(
51
51
  group.tokens.push(id);
52
52
  }
53
53
 
54
- const nodeSource = { filename: source.filename?.href, node };
54
+ const nodeSource = { filename: source.filename.href, node };
55
55
  const token: TokenNormalized = {
56
56
  id,
57
57
  $type: originalToken.$type || group.$type,
@@ -211,7 +211,7 @@ export function groupFromNode(
211
211
 
212
212
  export interface GraphAliasesOptions {
213
213
  tokens: TokenNormalizedSet;
214
- sources: Record<string, InputSource>;
214
+ sources: Record<string, InputSourceWithDocument>;
215
215
  logger: Logger;
216
216
  }
217
217
 
@@ -425,7 +425,7 @@ const EXPECTED_NESTED_ALIAS: Record<string, Record<string, string[]>> = {
425
425
  */
426
426
  export function resolveAliases(
427
427
  tokens: TokenNormalizedSet,
428
- { logger, refMap, sources }: { logger: Logger; refMap: RefMap; sources: Record<string, InputSource> },
428
+ { logger, refMap, sources }: { logger: Logger; refMap: RefMap; sources: Record<string, InputSourceWithDocument> },
429
429
  ): void {
430
430
  for (const token of Object.values(tokens)) {
431
431
  const aliasEntry = {
@@ -437,7 +437,11 @@ export function resolveAliases(
437
437
 
438
438
  for (const mode of Object.keys(token.mode)) {
439
439
  function resolveInner(alias: string, refChain: string[]): string {
440
- const nextRef = aliasToRef(alias, mode)?.$ref!;
440
+ const nextRef = aliasToRef(alias, mode)?.$ref;
441
+ if (!nextRef) {
442
+ logger.error({ ...aliasEntry, message: `Internal error resolving ${JSON.stringify(refChain)}` });
443
+ throw new Error('Internal error');
444
+ }
441
445
  if (refChain.includes(nextRef)) {
442
446
  logger.error({ ...aliasEntry, message: 'Circular alias detected.' });
443
447
  }
@@ -464,6 +468,7 @@ export function resolveAliases(
464
468
  continue;
465
469
  }
466
470
  value[i] = traverseAndResolve(value[i], {
471
+ // biome-ignore lint/suspicious/noNonNullAssertedOptionalChain: we checked for this earlier
467
472
  node: (node as momoa.ArrayNode).elements?.[i]?.value!,
468
473
  // special case: cubicBezier
469
474
  expectedTypes: expectedTypes?.includes('cubicBezier') ? ['number'] : expectedTypes,
@@ -0,0 +1,86 @@
1
+ import * as momoa from '@humanwhocodes/momoa';
2
+ import type { InputSourceWithDocument } from '@terrazzo/json-schema-tools';
3
+ import type Logger from '../logger.js';
4
+ import type { ConfigInit, Group, Resolver, TokenNormalized, TokenNormalizedSet } from '../types.js';
5
+ import { createResolver } from './load.js';
6
+ import { normalizeResolver } from './normalize.js';
7
+
8
+ export interface CreateSyntheticResolverOptions {
9
+ config: ConfigInit;
10
+ logger: Logger;
11
+ req: (url: URL, origin: URL) => Promise<string>;
12
+ sources: InputSourceWithDocument[];
13
+ }
14
+
15
+ /**
16
+ * Interop layer upgrading legacy Terrazzo modes to resolvers
17
+ */
18
+ export async function createSyntheticResolver(
19
+ tokens: TokenNormalizedSet,
20
+ { config, logger, req, sources }: CreateSyntheticResolverOptions,
21
+ ): Promise<Resolver> {
22
+ const contexts: Record<string, any[]> = {};
23
+ for (const token of Object.values(tokens)) {
24
+ for (const [mode, value] of Object.entries(token.mode)) {
25
+ if (mode === '.') {
26
+ continue;
27
+ }
28
+ if (!(mode in contexts)) {
29
+ contexts[mode] = [{}];
30
+ }
31
+ addToken(contexts[mode]![0], { ...token, $value: value.$value }, { logger });
32
+ }
33
+ }
34
+
35
+ const src = JSON.stringify(
36
+ {
37
+ name: 'Terrazzo',
38
+ version: '2025.10',
39
+ resolutionOrder: [{ $ref: '#/sets/allTokens' }, { $ref: '#/modifiers/tzMode' }],
40
+ sets: {
41
+ allTokens: { sources: [simpleFlatten(tokens, { logger })] },
42
+ },
43
+ modifiers: {
44
+ tzMode: {
45
+ description: 'Automatically built from $extensions.mode',
46
+ contexts,
47
+ },
48
+ },
49
+ },
50
+ undefined,
51
+ 2,
52
+ );
53
+ const normalized = await normalizeResolver(momoa.parse(src), {
54
+ filename: new URL('file:///virtual:resolver.json'),
55
+ logger,
56
+ req,
57
+ src,
58
+ });
59
+ return createResolver(normalized, { config, logger, sources });
60
+ }
61
+
62
+ /** Add a normalized token back into an arbitrary, hierarchial structure */
63
+ function addToken(structure: any, token: TokenNormalized, { logger }: { logger: Logger }): void {
64
+ let node = structure;
65
+ const parts = token.id.split('.');
66
+ const localID = parts.pop()!;
67
+ for (const part of parts) {
68
+ if (!(part in node)) {
69
+ node[part] = {};
70
+ }
71
+ node = node[part];
72
+ }
73
+ if (localID in node) {
74
+ logger.error({ group: 'parser', label: 'resolver', message: `${localID} already exists!` });
75
+ }
76
+ node[localID] = { $type: token.$type, $value: token.$value };
77
+ }
78
+
79
+ /** Downconvert normalized tokens back into a simplified, hierarchial shape. This is extremely lossy, and only done to build a resolver. */
80
+ function simpleFlatten(tokens: TokenNormalizedSet, { logger }: { logger: Logger }): Group {
81
+ const group: Group = {};
82
+ for (const token of Object.values(tokens)) {
83
+ addToken(group, token, { logger });
84
+ }
85
+ return group;
86
+ }
@@ -0,0 +1,7 @@
1
+ import { loadResolver } from './load.js';
2
+
3
+ export * from './load.js';
4
+ export * from './normalize.js';
5
+ export * from './validate.js';
6
+
7
+ export default loadResolver;
@@ -0,0 +1,216 @@
1
+ import * as momoa from '@humanwhocodes/momoa';
2
+ import { type InputSource, type InputSourceWithDocument, maybeRawJSON } from '@terrazzo/json-schema-tools';
3
+ import type { TokenNormalizedSet } from '@terrazzo/token-tools';
4
+ import { merge } from 'merge-anything';
5
+ import type yamlToMomoa from 'yaml-to-momoa';
6
+ import { toMomoa } from '../lib/momoa.js';
7
+ import { makeInputKey } from '../lib/resolver-utils.js';
8
+ import type Logger from '../logger.js';
9
+ import { processTokens } from '../parse/process.js';
10
+ import type { ConfigInit, Resolver, ResolverSourceNormalized } from '../types.js';
11
+ import { normalizeResolver } from './normalize.js';
12
+ import { isLikelyResolver, validateResolver } from './validate.js';
13
+
14
+ export interface LoadResolverOptions {
15
+ config: ConfigInit;
16
+ logger: Logger;
17
+ req: (url: URL, origin: URL) => Promise<string>;
18
+ yamlToMomoa?: typeof yamlToMomoa;
19
+ }
20
+
21
+ /** Quick-parse input sources and find a resolver */
22
+ export async function loadResolver(
23
+ inputs: InputSource[],
24
+ { config, logger, req, yamlToMomoa }: LoadResolverOptions,
25
+ ): Promise<{ resolver: Resolver | undefined; tokens: TokenNormalizedSet; sources: InputSourceWithDocument[] }> {
26
+ let resolverDoc: momoa.DocumentNode | undefined;
27
+ let tokens: TokenNormalizedSet = {};
28
+ const entry = {
29
+ group: 'parser',
30
+ label: 'init',
31
+ } as const;
32
+
33
+ for (const input of inputs) {
34
+ let document: momoa.DocumentNode | undefined;
35
+ if (typeof input.src === 'string') {
36
+ if (maybeRawJSON(input.src)) {
37
+ document = toMomoa(input.src);
38
+ } else if (yamlToMomoa) {
39
+ document = yamlToMomoa(input.src);
40
+ } else {
41
+ logger.error({
42
+ ...entry,
43
+ message: `Install yaml-to-momoa package to parse YAML, and pass in as option, e.g.:
44
+
45
+ import { bundle } from '@terrazzo/json-schema-tools';
46
+ import yamlToMomoa from 'yaml-to-momoa';
47
+
48
+ bundle(yamlString, { yamlToMomoa });`,
49
+ });
50
+ }
51
+ } else if (input.src && typeof input.src === 'object') {
52
+ document = toMomoa(JSON.stringify(input.src, undefined, 2));
53
+ } else {
54
+ logger.error({ ...entry, message: `Could not parse ${input.filename}. Is this valid JSON or YAML?` });
55
+ }
56
+ if (!document || !isLikelyResolver(document)) {
57
+ continue;
58
+ }
59
+ if (inputs.length > 1) {
60
+ logger.error({ ...entry, message: `Resolver must be the only input, found ${inputs.length} sources.` });
61
+ }
62
+ resolverDoc = document;
63
+ break;
64
+ }
65
+
66
+ let resolver: Resolver | undefined;
67
+ if (resolverDoc) {
68
+ validateResolver(resolverDoc, { logger, src: inputs[0]!.src });
69
+ const normalized = await normalizeResolver(resolverDoc, {
70
+ filename: inputs[0]!.filename!,
71
+ logger,
72
+ req,
73
+ src: inputs[0]!.src,
74
+ yamlToMomoa,
75
+ });
76
+ resolver = createResolver(normalized, { config, logger, sources: [{ ...inputs[0]!, document: resolverDoc }] });
77
+
78
+ // If a resolver is present, load a single permutation to get a base token set.
79
+ const firstInput: Record<string, string> = {};
80
+ for (const m of resolver.source.resolutionOrder) {
81
+ if (m.type !== 'modifier') {
82
+ continue;
83
+ }
84
+ firstInput[m.name] = typeof m.default === 'string' ? m.default : Object.keys(m.contexts)[0]!;
85
+ }
86
+ tokens = resolver.apply(firstInput);
87
+ }
88
+
89
+ return {
90
+ resolver,
91
+ tokens,
92
+ sources: [{ ...inputs[0]!, document: resolverDoc! }],
93
+ };
94
+ }
95
+
96
+ export interface CreateResolverOptions {
97
+ config: ConfigInit;
98
+ logger: Logger;
99
+ sources: InputSourceWithDocument[];
100
+ }
101
+
102
+ /** Create an interface to resolve permutations */
103
+ export function createResolver(
104
+ resolverSource: ResolverSourceNormalized,
105
+ { config, logger, sources }: CreateResolverOptions,
106
+ ): Resolver {
107
+ const inputDefaults: Record<string, string> = {};
108
+ const validContexts: Record<string, string[]> = {};
109
+ const allPermutations: Record<string, string>[] = [];
110
+
111
+ const resolverCache: Record<string, any> = {};
112
+
113
+ for (const m of resolverSource.resolutionOrder) {
114
+ if (m.type === 'modifier') {
115
+ if (typeof m.default === 'string') {
116
+ inputDefaults[m.name] = m.default!;
117
+ }
118
+ validContexts[m.name] = Object.keys(m.contexts);
119
+ }
120
+ }
121
+
122
+ return {
123
+ apply(inputRaw): TokenNormalizedSet {
124
+ let tokensRaw: TokenNormalizedSet = {};
125
+ const input = { ...inputDefaults, ...inputRaw };
126
+ const inputKey = makeInputKey(input);
127
+
128
+ if (resolverCache[inputKey]) {
129
+ return resolverCache[inputKey];
130
+ }
131
+
132
+ for (const item of resolverSource.resolutionOrder) {
133
+ switch (item.type) {
134
+ case 'set': {
135
+ for (const s of item.sources) {
136
+ tokensRaw = merge(tokensRaw, s) as TokenNormalizedSet;
137
+ }
138
+ break;
139
+ }
140
+ case 'modifier': {
141
+ const context = input[item.name]!;
142
+ const sources = item.contexts[context];
143
+ if (!sources) {
144
+ logger.error({
145
+ group: 'parser',
146
+ label: 'resolver',
147
+ message: `Modifier ${item.name} has no context ${JSON.stringify(context)}.`,
148
+ });
149
+ }
150
+ for (const s of sources ?? []) {
151
+ tokensRaw = merge(tokensRaw, s) as TokenNormalizedSet;
152
+ }
153
+ break;
154
+ }
155
+ }
156
+ }
157
+
158
+ const src = JSON.stringify(tokensRaw, undefined, 2);
159
+ const rootSource = { filename: resolverSource._source.filename!, document: toMomoa(src), src };
160
+ const tokens = processTokens(rootSource, {
161
+ config,
162
+ logger,
163
+ sourceByFilename: {},
164
+ refMap: {},
165
+ sources,
166
+ });
167
+ resolverCache[inputKey] = tokens;
168
+ return tokens;
169
+ },
170
+ source: resolverSource,
171
+ listPermutations() {
172
+ // only do work on first call, then cache subsequent work. this could be thousands of possible values!
173
+ if (!allPermutations.length) {
174
+ allPermutations.push(...calculatePermutations(Object.entries(validContexts)));
175
+ }
176
+ return allPermutations;
177
+ },
178
+ isValidInput(input: Record<string, string>) {
179
+ if (!input || typeof input !== 'object') {
180
+ logger.error({ group: 'parser', label: 'resolver', message: `Invalid input: ${JSON.stringify(input)}.` });
181
+ }
182
+ if (!Object.keys(input).every((k) => k in validContexts)) {
183
+ return false; // 1. invalid if unknown modifier name
184
+ }
185
+ for (const [name, contexts] of Object.entries(validContexts)) {
186
+ // Note: empty strings are valid! Don’t check for truthiness.
187
+ if (name in input) {
188
+ if (!contexts.includes(input[name]!)) {
189
+ return false; // 2. invalid if unknown context
190
+ }
191
+ } else if (!(name in inputDefaults)) {
192
+ return false; // 3. invalid if omitted, and no default
193
+ }
194
+ }
195
+ return true;
196
+ },
197
+ };
198
+ }
199
+
200
+ /** Calculate all permutations */
201
+ export function calculatePermutations(options: [string, string[]][]) {
202
+ const permutationCount = [1];
203
+ for (const [_name, contexts] of options) {
204
+ permutationCount.push(contexts.length * (permutationCount.at(-1) || 1));
205
+ }
206
+ const permutations: Record<string, string>[] = [];
207
+ for (let i = 0; i < permutationCount.at(-1)!; i++) {
208
+ const input: Record<string, string> = {};
209
+ for (let j = 0; j < options.length; j++) {
210
+ const [name, contexts] = options[j]!;
211
+ input[name] = contexts[Math.floor(i / permutationCount[j]!) % contexts.length]!;
212
+ }
213
+ permutations.push(input);
214
+ }
215
+ return permutations.length ? permutations : [{}];
216
+ }
@@ -0,0 +1,106 @@
1
+ import * as momoa from '@humanwhocodes/momoa';
2
+ import { bundle, getObjMember, getObjMembers, parseRef } from '@terrazzo/json-schema-tools';
3
+ import type yamlToMomoa from 'yaml-to-momoa';
4
+ import type Logger from '../logger.js';
5
+ import type {
6
+ ResolverModifierInline,
7
+ ResolverModifierNormalized,
8
+ ResolverSetInline,
9
+ ResolverSetNormalized,
10
+ ResolverSourceNormalized,
11
+ } from '../types.js';
12
+ import { validateModifier, validateSet } from './validate.js';
13
+
14
+ export interface NormalizeResolverOptions {
15
+ logger: Logger;
16
+ yamlToMomoa?: typeof yamlToMomoa;
17
+ filename: URL;
18
+ req: (url: URL, origin: URL) => Promise<string>;
19
+ src?: any;
20
+ }
21
+
22
+ /** Normalize resolver (assuming it’s been validated) */
23
+ export async function normalizeResolver(
24
+ node: momoa.DocumentNode,
25
+ { filename, req, src, logger, yamlToMomoa }: NormalizeResolverOptions,
26
+ ): Promise<ResolverSourceNormalized> {
27
+ const resolverSource = momoa.evaluate(node) as unknown as ResolverSourceNormalized;
28
+ const resolutionOrder = getObjMember(node.body as momoa.ObjectNode, 'resolutionOrder') as momoa.ArrayNode;
29
+
30
+ return {
31
+ name: resolverSource.name,
32
+ version: resolverSource.version,
33
+ description: resolverSource.description,
34
+ sets: resolverSource.sets,
35
+ modifiers: resolverSource.modifiers,
36
+ resolutionOrder: await Promise.all(
37
+ resolutionOrder.elements.map(async (element, i) => {
38
+ const layer = element.value as momoa.ObjectNode;
39
+ const members = getObjMembers(layer);
40
+
41
+ // If this is an inline set or modifier it’s already been validated; we only need
42
+ // to resolve & validate $refs here which haven’t yet been parsed
43
+ let item = layer as unknown as ResolverSetInline | ResolverModifierInline;
44
+
45
+ // 1. $ref
46
+ if (members.$ref) {
47
+ const entry = { group: 'parser', label: 'init', node: members.$ref, src } as const;
48
+ const { url, subpath } = parseRef((members.$ref as unknown as momoa.StringNode).value);
49
+ if (url === '.') {
50
+ // 1a. local $ref: pull from local document
51
+ if (!subpath?.[0]) {
52
+ logger.error({ ...entry, message: '$ref can’t refer to the root document.' });
53
+ } else if (subpath[0] !== 'sets' && subpath[0] !== 'modifiers') {
54
+ // Note: technically we could allow $defs, but that’s just unnecessary shenanigans here.
55
+ logger.error({
56
+ ...entry,
57
+ message: 'Local $ref in resolutionOrder must point to either #/sets/[set] or #/modifiers/[modifiers].',
58
+ });
59
+ } else {
60
+ const resolvedItem = resolverSource[subpath[0] as 'sets' | 'modifiers']?.[subpath[1]!];
61
+ if (!resolvedItem) {
62
+ logger.error({ ...entry, message: 'Invalid $ref' });
63
+ } else {
64
+ item = {
65
+ type: subpath[0] === 'sets' ? 'set' : 'modifier',
66
+ name: subpath[1],
67
+ ...(resolvedItem as any), // Note: as long as this exists, this has already been validated to be correct
68
+ };
69
+ }
70
+ }
71
+ } else {
72
+ // 1b. remote $ref: load and validate
73
+ const result = await bundle(
74
+ [{ filename: new URL(url, filename), src: resolverSource.resolutionOrder[i]! }],
75
+ {
76
+ req,
77
+ yamlToMomoa,
78
+ },
79
+ );
80
+ if (result.document.body.type === 'Object') {
81
+ const type = getObjMember(result.document.body, 'type');
82
+ if (type?.type === 'String' && type.value === 'set') {
83
+ validateSet(result.document.body as momoa.ObjectNode, true, src);
84
+ item = momoa.evaluate(result.document.body) as unknown as ResolverSetInline;
85
+ } else if (type?.type === 'String' && type.value === 'modifier') {
86
+ validateModifier(result.document.body as momoa.ObjectNode, true, src);
87
+ item = momoa.evaluate(result.document.body) as unknown as ResolverModifierInline;
88
+ }
89
+ }
90
+ logger.error({ ...entry, message: '$ref did not resolve to a valid Set or Modifier.' });
91
+ }
92
+ }
93
+
94
+ // 2. resolve inline sources & contexts
95
+ const finalResult = await bundle([{ filename, src: item }], { req, yamlToMomoa });
96
+ return momoa.evaluate(finalResult.document.body) as unknown as
97
+ | ResolverSetNormalized
98
+ | ResolverModifierNormalized;
99
+ }),
100
+ ),
101
+ _source: {
102
+ filename,
103
+ node,
104
+ },
105
+ };
106
+ }