@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.
- package/CHANGELOG.md +5 -12
- package/README.md +1 -1
- package/dist/index.d.ts +159 -40
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +711 -58
- package/dist/index.js.map +1 -1
- package/package.json +6 -5
- package/src/config.ts +1 -2
- package/src/index.ts +2 -0
- package/src/lint/plugin-core/index.ts +3 -4
- package/src/lint/plugin-core/lib/docs.ts +1 -1
- package/src/lint/plugin-core/rules/{no-type-on-alias.ts → required-type.ts} +5 -6
- package/src/parse/index.ts +36 -2
- package/src/parse/load.ts +10 -6
- package/src/parse/token.ts +6 -1
- package/src/resolver/index.ts +7 -0
- package/src/resolver/load.ts +161 -0
- package/src/resolver/normalize.ts +99 -0
- package/src/resolver/validate.ts +359 -0
- package/src/types.ts +104 -34
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@terrazzo/parser",
|
|
3
|
-
"version": "2.0.0-alpha.
|
|
3
|
+
"version": "2.0.0-alpha.3",
|
|
4
4
|
"description": "Parser/validator for the Design Tokens Community Group (DTCG) standard.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -27,23 +27,24 @@
|
|
|
27
27
|
".": "./dist/index.js",
|
|
28
28
|
"./package.json": "./package.json"
|
|
29
29
|
},
|
|
30
|
-
"homepage": "https://terrazzo.app/docs/
|
|
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.
|
|
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
|
|
46
|
-
"@terrazzo/token-tools": "^2.0.0-alpha.
|
|
46
|
+
"@terrazzo/json-schema-tools": "^0.1.0",
|
|
47
|
+
"@terrazzo/token-tools": "^2.0.0-alpha.3"
|
|
47
48
|
},
|
|
48
49
|
"devDependencies": {
|
|
49
50
|
"yaml-to-momoa": "0.0.6"
|
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
|
@@ -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,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
|
|
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]: '
|
|
11
|
+
[ERROR]: 'Token missing $type.',
|
|
13
12
|
},
|
|
14
13
|
docs: {
|
|
15
|
-
description: '
|
|
16
|
-
url: docsLink(
|
|
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 (
|
|
21
|
+
if (!t.originalValue?.$type) {
|
|
23
22
|
report({ messageId: ERROR, node: t.source.node, filename: t.source.filename });
|
|
24
23
|
}
|
|
25
24
|
}
|
package/src/parse/index.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
|
+
import type fsType from 'node:fs/promises';
|
|
1
2
|
import { pluralize, type TokenNormalizedSet } from '@terrazzo/token-tools';
|
|
2
3
|
import lintRunner from '../lint/index.js';
|
|
3
4
|
import Logger from '../logger.js';
|
|
4
|
-
import
|
|
5
|
+
import { loadResolver } from '../resolver/load.js';
|
|
6
|
+
import type { ConfigInit, InputSource, ParseOptions, Resolver } from '../types.js';
|
|
5
7
|
import { loadSources } from './load.js';
|
|
6
8
|
|
|
7
9
|
export interface ParseResult {
|
|
8
10
|
tokens: TokenNormalizedSet;
|
|
9
11
|
sources: InputSource[];
|
|
12
|
+
resolver?: Resolver | undefined;
|
|
10
13
|
}
|
|
11
14
|
|
|
12
15
|
/** Parse */
|
|
@@ -14,6 +17,7 @@ export default async function parse(
|
|
|
14
17
|
_input: Omit<InputSource, 'document'> | Omit<InputSource, 'document'>[],
|
|
15
18
|
{
|
|
16
19
|
logger = new Logger(),
|
|
20
|
+
req = defaultReq,
|
|
17
21
|
skipLint = false,
|
|
18
22
|
config = {} as ConfigInit,
|
|
19
23
|
continueOnError = false,
|
|
@@ -24,8 +28,20 @@ export default async function parse(
|
|
|
24
28
|
const inputs = Array.isArray(_input) ? _input : [_input];
|
|
25
29
|
|
|
26
30
|
const totalStart = performance.now();
|
|
31
|
+
|
|
32
|
+
// 1. Resolver
|
|
33
|
+
const resolver = await loadResolver(inputs, { logger, req, yamlToMomoa });
|
|
34
|
+
|
|
35
|
+
// 2. No resolver (tokens)
|
|
27
36
|
const initStart = performance.now();
|
|
28
|
-
const { tokens, sources } = await loadSources(inputs, {
|
|
37
|
+
const { tokens, sources } = await loadSources(inputs, {
|
|
38
|
+
req,
|
|
39
|
+
logger,
|
|
40
|
+
config,
|
|
41
|
+
continueOnError,
|
|
42
|
+
yamlToMomoa,
|
|
43
|
+
transform,
|
|
44
|
+
});
|
|
29
45
|
logger.debug({
|
|
30
46
|
message: 'Loaded tokens',
|
|
31
47
|
group: 'parser',
|
|
@@ -64,5 +80,23 @@ export default async function parse(
|
|
|
64
80
|
return {
|
|
65
81
|
tokens,
|
|
66
82
|
sources,
|
|
83
|
+
resolver,
|
|
67
84
|
};
|
|
68
85
|
}
|
|
86
|
+
|
|
87
|
+
let fs: typeof fsType | undefined;
|
|
88
|
+
|
|
89
|
+
/** Fallback req */
|
|
90
|
+
async function defaultReq(src: URL, _origin: URL) {
|
|
91
|
+
if (src.protocol === 'file:') {
|
|
92
|
+
if (!fs) {
|
|
93
|
+
fs = await import('node:fs/promises');
|
|
94
|
+
}
|
|
95
|
+
return await fs.readFile(src, 'utf8');
|
|
96
|
+
}
|
|
97
|
+
const res = await fetch(src);
|
|
98
|
+
if (!res.ok) {
|
|
99
|
+
throw new Error(`${src} responded with ${res.status}\n${await res.text()}`);
|
|
100
|
+
}
|
|
101
|
+
return await res.text();
|
|
102
|
+
}
|
package/src/parse/load.ts
CHANGED
|
@@ -51,14 +51,20 @@ export interface IntermediaryToken {
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
export interface LoadOptions extends Pick<ParseOptions, 'config' | 'continueOnError' | 'yamlToMomoa' | 'transform'> {
|
|
54
|
+
req: NonNullable<ParseOptions['req']>;
|
|
54
55
|
logger: Logger;
|
|
55
56
|
}
|
|
56
57
|
|
|
58
|
+
export interface LoadSourcesResult {
|
|
59
|
+
tokens: TokenNormalizedSet;
|
|
60
|
+
sources: InputSource[];
|
|
61
|
+
}
|
|
62
|
+
|
|
57
63
|
/** Load from multiple entries, while resolving remote files */
|
|
58
64
|
export async function loadSources(
|
|
59
65
|
inputs: Omit<InputSource, 'document'>[],
|
|
60
|
-
{ config, logger, continueOnError, yamlToMomoa, transform }: LoadOptions,
|
|
61
|
-
): Promise<
|
|
66
|
+
{ config, logger, req, continueOnError, yamlToMomoa, transform }: LoadOptions,
|
|
67
|
+
): Promise<LoadSourcesResult> {
|
|
62
68
|
const entry = { group: 'parser' as const, label: 'init' };
|
|
63
69
|
|
|
64
70
|
// 1. Bundle root documents together
|
|
@@ -78,6 +84,7 @@ export async function loadSources(
|
|
|
78
84
|
|
|
79
85
|
try {
|
|
80
86
|
const result = await bundle(sources, {
|
|
87
|
+
req,
|
|
81
88
|
parse: transform ? transformer(transform) : undefined,
|
|
82
89
|
yamlToMomoa,
|
|
83
90
|
});
|
|
@@ -200,10 +207,7 @@ export async function loadSources(
|
|
|
200
207
|
group.tokens.sort((a, b) => a.localeCompare(b, 'en-us', { numeric: true }));
|
|
201
208
|
}
|
|
202
209
|
|
|
203
|
-
return {
|
|
204
|
-
tokens: tokensSorted,
|
|
205
|
-
sources,
|
|
206
|
-
};
|
|
210
|
+
return { tokens: tokensSorted, sources };
|
|
207
211
|
}
|
|
208
212
|
|
|
209
213
|
function transformer(transform: TransformVisitors): BundleOptions['parse'] {
|
package/src/parse/token.ts
CHANGED
|
@@ -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,161 @@
|
|
|
1
|
+
import type { DocumentNode } from '@humanwhocodes/momoa';
|
|
2
|
+
import { maybeRawJSON } from '@terrazzo/json-schema-tools';
|
|
3
|
+
import type { TokenNormalizedSet } from '@terrazzo/token-tools';
|
|
4
|
+
import eq from 'fast-deep-equal';
|
|
5
|
+
import { merge } from 'merge-anything';
|
|
6
|
+
import type yamlToMomoa from 'yaml-to-momoa';
|
|
7
|
+
import { toMomoa } from '../lib/momoa.js';
|
|
8
|
+
import type Logger from '../logger.js';
|
|
9
|
+
import type { InputSource, Resolver, ResolverSourceNormalized } from '../types.js';
|
|
10
|
+
import { normalizeResolver } from './normalize.js';
|
|
11
|
+
import { isLikelyResolver, validateResolver } from './validate.js';
|
|
12
|
+
|
|
13
|
+
export interface LoadResolverOptions {
|
|
14
|
+
logger: Logger;
|
|
15
|
+
req: (url: URL, origin: URL) => Promise<string>;
|
|
16
|
+
yamlToMomoa?: typeof yamlToMomoa;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Quick-parse input sources and find a resolver */
|
|
20
|
+
export async function loadResolver(
|
|
21
|
+
inputs: Omit<InputSource, 'document'>[],
|
|
22
|
+
{ logger, req, yamlToMomoa }: LoadResolverOptions,
|
|
23
|
+
): Promise<Resolver | undefined> {
|
|
24
|
+
let resolverDoc: DocumentNode | undefined;
|
|
25
|
+
const entry = {
|
|
26
|
+
group: 'parser',
|
|
27
|
+
label: 'init',
|
|
28
|
+
} as const;
|
|
29
|
+
|
|
30
|
+
for (const input of inputs) {
|
|
31
|
+
let document: DocumentNode | undefined;
|
|
32
|
+
if (typeof input.src === 'string') {
|
|
33
|
+
if (maybeRawJSON(input.src)) {
|
|
34
|
+
document = toMomoa(input.src);
|
|
35
|
+
} else if (yamlToMomoa) {
|
|
36
|
+
document = yamlToMomoa(input.src);
|
|
37
|
+
} else {
|
|
38
|
+
logger.error({
|
|
39
|
+
...entry,
|
|
40
|
+
message: `Install yaml-to-momoa package to parse YAML, and pass in as option, e.g.:
|
|
41
|
+
|
|
42
|
+
import { bundle } from '@terrazzo/json-schema-tools';
|
|
43
|
+
import yamlToMomoa from 'yaml-to-momoa';
|
|
44
|
+
|
|
45
|
+
bundle(yamlString, { yamlToMomoa });`,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
} else if (input.src && typeof input.src === 'object') {
|
|
49
|
+
document = toMomoa(JSON.stringify(input.src, undefined, 2));
|
|
50
|
+
} else {
|
|
51
|
+
logger.error({ ...entry, message: `Could not parse ${input.filename}. Is this valid JSON or YAML?` });
|
|
52
|
+
}
|
|
53
|
+
if (!document || !isLikelyResolver(document)) {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (inputs.length > 1) {
|
|
57
|
+
logger.error({ ...entry, message: `Resolver must be the only input, found ${inputs.length} sources.` });
|
|
58
|
+
}
|
|
59
|
+
resolverDoc = document;
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (resolverDoc) {
|
|
64
|
+
validateResolver(resolverDoc, { logger, src: inputs[0]!.src });
|
|
65
|
+
const normalized = await normalizeResolver(resolverDoc, {
|
|
66
|
+
filename: inputs[0]!.filename!,
|
|
67
|
+
logger,
|
|
68
|
+
req,
|
|
69
|
+
src: inputs[0]!.src,
|
|
70
|
+
yamlToMomoa,
|
|
71
|
+
});
|
|
72
|
+
return createResolver(normalized, { logger });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface CreateResolverOptions {
|
|
77
|
+
logger: Logger;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Create an interface to resolve permutations */
|
|
81
|
+
export function createResolver(resolverSource: ResolverSourceNormalized, { logger }: CreateResolverOptions): Resolver {
|
|
82
|
+
const inputDefaults: Record<string, string> = {};
|
|
83
|
+
const modifierPermutations: [string, string[]][] = []; // figure out modifiers
|
|
84
|
+
for (const [name, m] of Object.entries(resolverSource.modifiers ?? {})) {
|
|
85
|
+
if (typeof m.default === 'string') {
|
|
86
|
+
inputDefaults[name] = m.default;
|
|
87
|
+
}
|
|
88
|
+
modifierPermutations.push([name, Object.keys(m.contexts)]);
|
|
89
|
+
}
|
|
90
|
+
for (const m of resolverSource.resolutionOrder) {
|
|
91
|
+
if (!('type' in m) || m.type !== 'modifier') {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (typeof m.default === 'string') {
|
|
95
|
+
inputDefaults[m.name] = m.default;
|
|
96
|
+
}
|
|
97
|
+
modifierPermutations.push([m.name, Object.keys(m.contexts)]);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const permutations = calculatePermutations(modifierPermutations);
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
apply(inputRaw): TokenNormalizedSet {
|
|
104
|
+
let tokens: TokenNormalizedSet = {};
|
|
105
|
+
const input = { ...inputDefaults, ...inputRaw };
|
|
106
|
+
for (const item of resolverSource.resolutionOrder) {
|
|
107
|
+
switch (item.type) {
|
|
108
|
+
case 'set': {
|
|
109
|
+
for (const s of item.sources) {
|
|
110
|
+
tokens = merge(tokens, s) as TokenNormalizedSet;
|
|
111
|
+
}
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
case 'modifier': {
|
|
115
|
+
const context = input[item.name]!;
|
|
116
|
+
const sources = item.contexts[context];
|
|
117
|
+
if (!sources) {
|
|
118
|
+
logger.error({
|
|
119
|
+
group: 'parser',
|
|
120
|
+
label: 'resolver',
|
|
121
|
+
message: `Modifier ${item.name} has no context ${JSON.stringify(context)}.`,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
for (const s of sources ?? []) {
|
|
125
|
+
tokens = merge(tokens, s) as TokenNormalizedSet;
|
|
126
|
+
}
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return tokens;
|
|
132
|
+
},
|
|
133
|
+
source: resolverSource,
|
|
134
|
+
permutations,
|
|
135
|
+
isValidInput(inputRaw: Record<string, string>) {
|
|
136
|
+
if (!inputRaw || typeof inputRaw !== 'object') {
|
|
137
|
+
logger.error({ group: 'parser', label: 'resolver', message: `Invalid input: ${JSON.stringify(inputRaw)}.` });
|
|
138
|
+
}
|
|
139
|
+
const input = { ...inputDefaults, ...inputRaw };
|
|
140
|
+
return permutations.findIndex((p) => eq(input, p)) !== -1;
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Calculate all permutations */
|
|
146
|
+
export function calculatePermutations(options: [string, string[]][]) {
|
|
147
|
+
const permutationCount = [1];
|
|
148
|
+
for (const [_name, contexts] of options) {
|
|
149
|
+
permutationCount.push(contexts.length * (permutationCount.at(-1) || 1));
|
|
150
|
+
}
|
|
151
|
+
const permutations: Record<string, string>[] = [];
|
|
152
|
+
for (let i = 0; i < permutationCount.at(-1)!; i++) {
|
|
153
|
+
const input: Record<string, string> = {};
|
|
154
|
+
for (let j = 0; j < options.length; j++) {
|
|
155
|
+
const [name, contexts] = options[j]!;
|
|
156
|
+
input[name] = contexts[Math.floor(i / permutationCount[j]!) % contexts.length]!;
|
|
157
|
+
}
|
|
158
|
+
permutations.push(input);
|
|
159
|
+
}
|
|
160
|
+
return permutations.length ? permutations : [{}];
|
|
161
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { type ArrayNode, type DocumentNode, evaluate, type ObjectNode, type StringNode } 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: DocumentNode,
|
|
25
|
+
{ filename, req, src, logger, yamlToMomoa }: NormalizeResolverOptions,
|
|
26
|
+
): Promise<ResolverSourceNormalized> {
|
|
27
|
+
const resolverSource = evaluate(node) as unknown as ResolverSourceNormalized;
|
|
28
|
+
const resolutionOrder = getObjMember(node.body as ObjectNode, 'resolutionOrder') as 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 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 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
|
+
...(resolvedItem as any), // Note: as long as this exists, this has already been validated to be correct
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
} else {
|
|
71
|
+
// 1b. remote $ref: load and validate
|
|
72
|
+
const result = await bundle(
|
|
73
|
+
[{ filename: new URL(url, filename), src: resolverSource.resolutionOrder[i]! }],
|
|
74
|
+
{
|
|
75
|
+
req,
|
|
76
|
+
yamlToMomoa,
|
|
77
|
+
},
|
|
78
|
+
);
|
|
79
|
+
if (result.document.body.type === 'Object') {
|
|
80
|
+
const type = getObjMember(result.document.body, 'type');
|
|
81
|
+
if (type?.type === 'String' && type.value === 'set') {
|
|
82
|
+
validateSet(result.document.body as ObjectNode, true, src);
|
|
83
|
+
item = evaluate(result.document.body) as unknown as ResolverSetInline;
|
|
84
|
+
} else if (type?.type === 'String' && type.value === 'modifier') {
|
|
85
|
+
validateModifier(result.document.body as ObjectNode, true, src);
|
|
86
|
+
item = evaluate(result.document.body) as unknown as ResolverModifierInline;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
logger.error({ ...entry, message: '$ref did not resolve to a valid Set or Modifier.' });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 2. resolve inline sources & contexts
|
|
94
|
+
const finalResult = await bundle([{ filename, src: item }], { req, yamlToMomoa });
|
|
95
|
+
return evaluate(finalResult.document.body) as unknown as ResolverSetNormalized | ResolverModifierNormalized;
|
|
96
|
+
}),
|
|
97
|
+
),
|
|
98
|
+
};
|
|
99
|
+
}
|