UNPKG

@terrazzo/parser

Version:

Parser/validator for the Design Tokens Community Group (DTCG) standard.

298 lines (296 loc) 11.9 kB
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