@terrazzo/parser
Version:
Parser/validator for the Design Tokens Community Group (DTCG) standard.
298 lines (296 loc) • 11.9 kB
JavaScript
import { maybeRawJSON, } from '@terrazzo/json-schema-tools';
import { toMomoa } from '../lib/momoa.js';
import { destructiveMerge, getPermutationID } from '../lib/resolver-utils.js';
import { processTokens } from '../parse/process.js';
import { normalizeResolver } from './normalize.js';
import { isLikelyResolver, validateResolver } from './validate.js';
/** Quick-parse input sources and find a resolver */
export async function loadResolver(inputs, { config, logger, req, yamlToMomoa }) {
let resolverDoc;
let tokens = {};
const entry = {
group: 'parser',
label: 'init',
};
for (const input of inputs) {
let document;
if (typeof input.src === 'string') {
if (maybeRawJSON(input.src)) {
document = toMomoa(input.src);
}
else if (yamlToMomoa) {
document = yamlToMomoa(input.src);
}
else {
logger.error({
...entry,
message: `Install yaml-to-momoa package to parse YAML, and pass in as option, e.g.:
import { bundle } from '@terrazzo/json-schema-tools';
import yamlToMomoa from 'yaml-to-momoa';
bundle(yamlString, { yamlToMomoa });`,
});
}
}
else if (input.src && typeof input.src === 'object') {
document = toMomoa(JSON.stringify(input.src, undefined, 2));
}
else {
logger.error({
...entry,
message: `Could not parse ${input.filename}. Is this valid JSON or YAML?`,
});
}
if (!document || !isLikelyResolver(document)) {
continue;
}
if (inputs.length > 1) {
logger.error({
...entry,
message: `Resolver must be the only input, found ${inputs.length} sources.`,
});
}
resolverDoc = document;
break;
}
let resolver;
if (resolverDoc) {
validateResolver(resolverDoc, { logger, src: inputs[0].src });
const normalized = await normalizeResolver(resolverDoc, {
filename: inputs[0].filename,
logger,
req,
src: inputs[0].src,
yamlToMomoa,
});
resolver = createResolver(normalized, {
config,
logger,
sources: [{ ...inputs[0], document: resolverDoc }],
orthogonal: false, // we’ll override this in the next step
});
// Load initial tokens
const firstInput = {};
for (const m of resolver.source.resolutionOrder) {
if (m.type !== 'modifier') {
continue;
}
firstInput[m.name] = typeof m.default === 'string' ? m.default : Object.keys(m.contexts)[0];
}
tokens = resolver.apply(firstInput);
// Determine orthogonality
resolver.orthogonal = isResolverOrthogonal(normalized, logger);
}
return {
resolver,
tokens,
sources: [{ ...inputs[0], document: resolverDoc }],
};
}
/** Create an interface to resolve permutations */
export function createResolver(resolverSource, { config, logger, sources, orthogonal }) {
const inputDefaults = {};
const validContexts = {};
const allPermutations = [];
const resolverCache = {};
// Important: by iterating over resolutionOrder, we
// filter out unused modifiers/irrelevant contexts.
for (const m of resolverSource.resolutionOrder) {
if (m.type === 'modifier') {
if (typeof m.default === 'string') {
inputDefaults[m.name] = m.default;
}
validContexts[m.name] = Object.keys(m.contexts);
}
}
const permutationCount = Object.values(validContexts).reduce((acc, context) => acc * context.length, 1);
return {
apply(inputRaw, options) {
const tokensRaw = {};
const input = { ...inputDefaults, ...inputRaw };
const permutationID = getPermutationID(input, options);
if (resolverCache[permutationID]) {
return resolverCache[permutationID];
}
for (const item of resolverSource.resolutionOrder) {
switch (item.type) {
case 'set': {
if (Array.isArray(options?.sets) && !options.sets.includes(item.name)) {
continue;
}
for (const s of item.sources) {
destructiveMerge(tokensRaw, s);
}
break;
}
case 'modifier': {
if (Array.isArray(options?.modifiers) && !options.modifiers.includes(item.name)) {
continue;
}
const context = input[item.name];
const resolverSources = item.contexts[context];
if (!resolverSources) {
logger.error({
group: 'resolver',
message: `Modifier ${item.name} has no context ${JSON.stringify(context)}.`,
});
}
for (const s of resolverSources ?? []) {
destructiveMerge(tokensRaw, s);
}
break;
}
}
}
const src = JSON.stringify(tokensRaw, undefined, 2);
const rootSource = {
filename: resolverSource._source.filename,
document: toMomoa(src),
src,
};
const tokens = processTokens(rootSource, {
config,
logger,
sourceByFilename: { [resolverSource._source.filename.href]: rootSource },
isResolver: true,
resolveAliases: options?.resolveAliases ?? true,
sources,
});
resolverCache[permutationID] = tokens;
return tokens;
},
orthogonal,
source: resolverSource,
listPermutations: permutationCount <= config.permutationLimit
? () => {
// only do work on first call, then cache subsequent work. this could be thousands of possible values!
if (allPermutations.length === 0) {
allPermutations.push(...calculatePermutations(Object.entries(validContexts)));
}
return allPermutations;
}
: undefined,
isValidInput(input, throwError = false) {
if (!input || typeof input !== 'object') {
logger.error({ group: 'resolver', message: `Invalid input: ${JSON.stringify(input)}.` });
}
for (const k of Object.keys(input)) {
if (!(k in validContexts)) {
if (throwError) {
logger.error({ group: 'resolver', message: `No such modifier ${JSON.stringify(k)}` });
}
return false; // 1. invalid if unknown modifier name
}
}
for (const [name, contexts] of Object.entries(validContexts)) {
// Note: empty strings are valid! Don’t check for truthiness.
if (name in input) {
if (name === 'tzMode') {
continue; // reserved modifier
}
if (!contexts.includes(input[name])) {
if (throwError) {
logger.error({
group: 'resolver',
message: `Modifier "${name}" has no context ${JSON.stringify(input[name])}.`,
});
}
return false; // 2. invalid if unknown context
}
}
else if (!(name in inputDefaults)) {
if (throwError) {
logger.error({
group: 'resolver',
message: `Modifier "${name}" missing value (no default set).`,
});
}
return false; // 3. invalid if omitted, and no default
}
}
return true;
},
getPermutationID(input) {
this.isValidInput(input, true);
return getPermutationID({ ...inputDefaults, ...input });
},
};
}
/** Calculate all permutations */
export function calculatePermutations(options) {
const permutationCount = [1];
for (const [_name, contexts] of options) {
permutationCount.push(contexts.length * (permutationCount.at(-1) || 1));
}
const permutations = [];
for (let i = 0; i < permutationCount.at(-1); i++) {
const input = {};
for (let j = 0; j < options.length; j++) {
const [name, contexts] = options[j];
input[name] = contexts[Math.floor(i / permutationCount[j]) % contexts.length];
}
permutations.push(input);
}
return permutations.length > 0 ? permutations : [{}];
}
/** Determine Resolver orthogonality using as little work as possible */
function isResolverOrthogonal(resolver, logger) {
// Keep a record of which tokens are in which modifier.
// Note that modifiers are allowed to have multiple appearances of the same
// token! So don’t simply return `false` on the reappearance of the same
// token, only return `false` for a token that appeared in another modifier.
const tokensByModifier = {};
// Note: this is a muuuuch lighter-weight walking utility than we need
// anywhere else. We want this to be as fast as possible, and do as little
// work as possible, and also have the unique property of stopping the walk
// under certain conditions.
function discoverTokens(node, onVisit, path = []) {
if (!node || typeof node !== 'object') {
return true;
}
const keys = Object.keys(node);
for (const key of keys) {
if (key === '$extends') {
logger.warn({
group: 'parser',
label: 'init',
message: `Can’t determine orthogonality with $extends.`,
});
}
// "$value" marks a token
else if (key === '$value') {
const shouldContinue = onVisit(path.join('.'));
if (shouldContinue === false) {
return false;
}
}
else {
const shouldContinue = discoverTokens(node[key], onVisit, [...path, key]);
if (shouldContinue === false) {
return false;
}
}
}
return true;
}
for (const modifier of resolver.resolutionOrder) {
if (modifier.type !== 'modifier') {
continue;
}
for (const sources of Object.values(modifier.contexts)) {
for (const source of sources) {
const didComplete = discoverTokens(source, (id) => {
if (!tokensByModifier[id]) {
tokensByModifier[id] = modifier.name;
return true;
}
return tokensByModifier[id] === modifier.name;
});
if (!didComplete) {
return false;
}
}
}
}
return true;
}
//# sourceMappingURL=load.js.map