UNPKG

@terrazzo/parser

Version:

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

547 lines 23.5 kB
import * as momoa from '@humanwhocodes/momoa'; import { encodeFragment, getObjMember, parseRef, } from '@terrazzo/json-schema-tools'; import { CachedWildcardMatcher, isAlias, isValidDTCGType, parseAlias, } from '@terrazzo/token-tools'; import { alphaComparator } from '../lib/array.js'; /** Convert valid DTCG alias to $ref */ export function aliasToGroupRef(alias) { const id = parseAlias(alias); // if this is invalid, stop if (id === alias) { return; } return { $ref: `#/${id.replaceAll('~', '~0').replaceAll('/', '~1').replaceAll('.', '/')}` }; } /** Convert valid DTCG alias to $ref */ export function aliasToTokenRef(alias, mode) { const id = parseAlias(alias); // if this is invalid, stop if (id === alias) { return; } return { $ref: `#/${id.replaceAll('~', '~0').replaceAll('/', '~1').replaceAll('.', '/')}${mode && mode !== '.' ? `/$extensions/mode/${mode}` : ''}/$value`, }; } const cachedMatcher = new CachedWildcardMatcher(); /** Generate a TokenNormalized from a Momoa node */ export function tokenFromNode(node, { groups, path, source, ignore }) { const isToken = node.type === 'Object' && !!getObjMember(node, '$value') && !path.includes('$extensions'); if (!isToken) { return undefined; } const jsonID = encodeFragment(path); const id = path.join('.').replace(/\.\$root$/, ''); const originalToken = momoa.evaluate(node); const groupID = encodeFragment(path.slice(0, -1)); const group = groups[groupID]; if (group?.tokens && !group.tokens.includes(id)) { group.tokens.push(id); } const nodeSource = { filename: source.filename.href, node }; const token = { id, $type: originalToken.$type || group.$type, $description: originalToken.$description || undefined, $deprecated: originalToken.$deprecated ?? group.$deprecated ?? undefined, // ⚠️ MUST use ?? here to inherit false correctly get $value() { return this.mode['.'].$value; }, $extensions: originalToken.$extensions || undefined, $extends: originalToken.$extends || undefined, get aliasChain() { return this.mode['.'].aliasChain; }, get aliasedBy() { return this.mode['.'].aliasedBy; }, get aliasOf() { return this.mode['.'].aliasOf; }, get partialAliasOf() { return this.mode['.'].partialAliasOf; }, get dependencies() { return this.mode['.'].dependencies; }, group, originalValue: undefined, // This is different from mode’s originalValue and includes the entire token shape, thus can’t be a getter source: nodeSource, jsonID, mode: { '.': { $value: originalToken.$value, aliasChain: undefined, aliasedBy: undefined, aliasOf: undefined, partialAliasOf: undefined, dependencies: undefined, originalValue: undefined, // undefined because we are not sure if the value has been modified or not source: { ...nodeSource, node: (getObjMember(nodeSource.node, '$value') ?? nodeSource.node), }, }, }, }; // after assembling token, handle ignores to see if the final result should be ignored or not // filter out ignored if ((ignore?.deprecated && token.$deprecated) || (ignore?.tokens && cachedMatcher.tokenIDMatch(ignore.tokens)(token.id))) { return; } const $extensions = getObjMember(node, '$extensions'); if ($extensions) { const modeNode = getObjMember($extensions, 'mode'); if (modeNode && modeNode.type === 'Object') { const modes = (token.$extensions.mode ?? {}); for (const [mode, modeValue] of Object.entries(modes)) { token.mode[mode] = { $value: modeValue, aliasOf: undefined, aliasChain: undefined, partialAliasOf: undefined, aliasedBy: undefined, originalValue: undefined, dependencies: undefined, source: { ...nodeSource, node: getObjMember(modeNode, mode), }, }; } } } return token; } /** Generate originalValue and source from node */ export function tokenRawValuesFromNode(node, { filename, path }) { const isToken = node.type === 'Object' && getObjMember(node, '$value') && !path.includes('$extensions'); if (!isToken) { return undefined; } const jsonID = encodeFragment(path); const rawValues = { jsonID, originalValue: momoa.evaluate(node), source: { loc: filename, filename, node: node }, mode: {}, }; rawValues.mode['.'] = { originalValue: rawValues.originalValue.$value, source: { ...rawValues.source, node: getObjMember(node, '$value'), }, }; const $extensions = getObjMember(node, '$extensions'); if ($extensions) { const modes = getObjMember($extensions, 'mode'); if (modes && modes.type === 'Object') { for (const modeMember of modes.members) { const mode = modeMember.name.value; rawValues.mode[mode] = { originalValue: momoa.evaluate(modeMember.value), source: { loc: filename, filename, node: modeMember.value }, }; } } } return rawValues; } /** Arbitrary keys that should be associated with a token group */ const GROUP_PROPERTIES = ['$deprecated', '$description', '$extensions', '$type']; /** * Generate a group from a node. * This method mutates the groups index as it goes because of group inheritance. * As it encounters new groups it may have to update other groups. */ export function groupFromNode(node, { path, groups }) { const id = path.join('.'); const jsonID = encodeFragment(path); // group if (!groups[jsonID]) { groups[jsonID] = { id, $deprecated: undefined, $description: undefined, $extensions: undefined, $type: undefined, tokens: [], }; } // first, copy all parent groups’ properties into local, since they cascade const groupIDs = Object.keys(groups); groupIDs.sort(); // these may not be sorted; re-sort just in case (order determines final values) for (const groupID of groupIDs) { const isParentGroup = jsonID.startsWith(groupID) && groupID !== jsonID; if (isParentGroup) { groups[jsonID].$deprecated = groups[groupID]?.$deprecated ?? groups[jsonID].$deprecated; groups[jsonID].$description = groups[groupID]?.$description ?? groups[jsonID].$description; groups[jsonID].$type = groups[groupID]?.$type ?? groups[jsonID].$type; } } // next, override cascading values with local for (const m of node.members) { if (m.name.type !== 'String' || !GROUP_PROPERTIES.includes(m.name.value)) { continue; } groups[jsonID][m.name.value] = momoa.evaluate(m.value); } return groups[jsonID]; } /** * Link and reverse-link tokens in one pass. */ export function graphAliases(refMap, { tokens, logger, sources }) { for (const [jsonID, { refChain }] of Object.entries(refMap)) { if (refChain.length === 0) { continue; } const mode = jsonID.match(/\/\$extensions\/mode\/([^/]+)/)?.[1] || '.'; const rootRef = _getGraphAliasTokenRef(jsonID); const modeValue = tokens[rootRef]?.mode[mode]; if (!modeValue) { continue; } // aliasChain + dependencies if (!modeValue.dependencies) { modeValue.dependencies = []; } modeValue.dependencies.push(...refChain.filter((r) => !modeValue.dependencies.includes(r))); modeValue.dependencies.sort(alphaComparator); // Top alias const isTopLevelAlias = jsonID.endsWith('/$value') || tokens[jsonID]; const isSameModeAlias = mode !== '.' && jsonID.endsWith(`/$extensions/mode/${mode}`) && refChain.at(-1)?.endsWith(`/$extensions/mode/${mode}`); if (isTopLevelAlias || isSameModeAlias) { modeValue.aliasOf = refToTokenID(refChain.at(-1)); const aliasChain = refChain.map(refToTokenID); modeValue.aliasChain = [...aliasChain]; } // Partial alias const partial = jsonID .replace(/.*\/\$value\/?/, '') .replace(/.*\/\$extensions\/mode\/[^/]+/, '') .split('/') .filter(Boolean); if (partial.length > 0 && modeValue.$value && typeof modeValue.$value === 'object') { let node = modeValue.$value; let sourceNode = modeValue.source.node; if (!modeValue.partialAliasOf) { modeValue.partialAliasOf = Array.isArray(modeValue.$value) || tokens[rootRef]?.$type === 'shadow' ? [] : {}; } let partialAliasOf = modeValue.partialAliasOf; // special case: for shadows, normalize object to array if (tokens[rootRef]?.$type === 'shadow' && !Array.isArray(node)) { if (Array.isArray(modeValue.partialAliasOf) && modeValue.partialAliasOf.length === 0) { modeValue.partialAliasOf.push({}); } partialAliasOf = modeValue.partialAliasOf[0]; } for (let i = 0; i < partial.length; i++) { let key = partial[i]; if (String(Number(key)) === key) { key = Number(key); } if (key in node && node[key] !== undefined) { node = node[key]; if (sourceNode.type === 'Object') { sourceNode = getObjMember(sourceNode, key) ?? sourceNode; } else if (sourceNode.type === 'Array') { sourceNode = sourceNode.elements[key]?.value ?? sourceNode; } } // last node: apply partial alias if (i === partial.length - 1) { // important: we want to get only the immediate alias [0], not the final one [.length - 1]. // if we resolve this too far, we could get incorrect values especially in plugin-css if a // user is applying cascades to the intermediate aliases but not the final one const aliasedID = _getGraphAliasTokenRef(refChain[0]); if (!(aliasedID in tokens)) { logger.error({ group: 'parser', label: 'init', message: `Invalid alias: ${aliasedID}`, node: sourceNode, src: sources[tokens[rootRef].source.filename]?.src, }); break; } partialAliasOf[key] = refToTokenID(aliasedID); } // otherwise, create deeper structure and continue traversing if (!(key in partialAliasOf)) { partialAliasOf[key] = Array.isArray(node) ? [] : {}; } partialAliasOf = partialAliasOf[key]; } } // aliasedBy (reversed) const aliasedByRefs = [jsonID, ...refChain].toReversed(); for (let i = 0; i < aliasedByRefs.length; i++) { const baseRef = _getGraphAliasTokenRef(aliasedByRefs[i]); const baseToken = tokens[baseRef]?.mode[mode] || tokens[baseRef]?.mode['.']; if (!baseToken) { continue; } const upstream = aliasedByRefs.slice(i + 1); if (upstream.length === 0) { break; } if (!baseToken.aliasedBy) { baseToken.aliasedBy = []; } for (let j = 0; j < upstream.length; j++) { const downstream = refToTokenID(upstream[j]); if (!baseToken.aliasedBy.includes(downstream)) { baseToken.aliasedBy.push(downstream); } } baseToken.aliasedBy.sort(alphaComparator); // sort, because the ordering is arbitrary and flaky } } } /** Convert valid DTCG alias to $ref Momoa Node */ export function aliasToMomoa(alias, // oxlint-disable-next-line no-object-as-default-parameter loc = { start: { line: -1, column: -1, offset: 0 }, end: { line: -1, column: -1, offset: 0 }, }) { const $ref = aliasToTokenRef(alias); if (!$ref) { return; } return { type: 'Object', members: [ { type: 'Member', name: { type: 'String', value: '$ref', loc }, value: { type: 'String', value: $ref.$ref, loc }, loc, }, ], loc, }; } /** * Convert Reference Object to token ID. * This can then be turned into an alias by surrounding with { … } * ⚠️ This is not mode-aware. This will flatten multiple modes into the same root token. */ export function refToTokenID($ref) { const path = typeof $ref === 'object' ? $ref.$ref : $ref; if (typeof path !== 'string') { return; } const { subpath } = parseRef(path); // if this ID comes from #/$defs/…, strip the first 2 segments to get the global ID if (subpath?.[0] === '$defs') { subpath.splice(0, 2); } return ((subpath?.length && subpath.join('.').replace(/\.(\$root|\$value|\$extensions).*$/, '')) || undefined); } const EXPECTED_NESTED_ALIAS = { border: { color: ['color'], stroke: ['strokeStyle'], width: ['dimension'], }, gradient: { color: ['color'], position: ['number'], }, shadow: { color: ['color'], offsetX: ['dimension'], offsetY: ['dimension'], blur: ['dimension'], spread: ['dimension'], inset: ['boolean'], }, strokeStyle: { dashArray: ['dimension'], }, transition: { duration: ['duration'], delay: ['duration'], timingFunction: ['cubicBezier'], }, typography: { fontFamily: ['fontFamily'], fontWeight: ['fontWeight'], fontSize: ['dimension'], lineHeight: ['dimension', 'number'], letterSpacing: ['dimension'], // CSS extensions (that aren’t "string") paragraphSpacing: ['dimension', 'string'], wordSpacing: ['dimension', 'string'], }, }; /** * Resolve DTCG aliases, $extends, and $ref */ export function resolveAliases(tokens, { logger, refMap, sources, }) { for (const token of Object.values(tokens)) { const aliasEntry = { group: 'parser', label: 'init', src: sources[token.source.filename]?.src, node: getObjMember(token.source.node, '$value'), }; for (const mode of Object.keys(token.mode)) { // oxlint-disable-next-line no-inner-declarations function resolveInner(alias, refChain) { const nextRef = aliasToTokenRef(alias, mode)?.$ref; if (!nextRef) { logger.error({ ...aliasEntry, message: `Internal error resolving ${JSON.stringify(refChain)}`, }); throw new Error('Internal error'); } if (refChain.includes(nextRef)) { logger.error({ ...aliasEntry, message: 'Circular alias detected.' }); } let nextJSONID = nextRef.replace(/\/(\$value|\$extensions).*/, ''); let nextToken = tokens[nextJSONID]?.mode[mode] || tokens[nextJSONID]?.mode['.']; // before throwing an error, see if this is a /$root token if (!nextToken) { nextJSONID = `${nextJSONID}/$root`; nextToken = tokens[nextJSONID]?.mode[mode] || tokens[nextJSONID]?.mode['.']; } if (!nextToken) { logger.error({ ...aliasEntry, message: `Could not resolve alias ${alias}.` }); } refChain.push(nextRef); if (isAlias(nextToken.originalValue)) { return resolveInner(nextToken.originalValue, refChain); } return nextJSONID; } // oxlint-disable-next-line no-inner-declarations function traverseAndResolve(value, { node, expectedTypes, path, }) { if (typeof value !== 'string') { if (Array.isArray(value)) { for (let i = 0; i < value.length; i++) { if (!value[i]) { continue; } value[i] = traverseAndResolve(value[i], { // oxlint-disable-next-line no-non-null-asserted-optional-chain -- we checked for this earlier node: node.elements?.[i]?.value, // special case: cubicBezier expectedTypes: expectedTypes?.includes('cubicBezier') ? ['number'] : expectedTypes, path: [...path, i], }).$value; } } else if (typeof value === 'object') { for (const key of Object.keys(value)) { if (!expectedTypes?.length || !EXPECTED_NESTED_ALIAS[expectedTypes[0]]) { continue; } value[key] = traverseAndResolve(value[key], { node: getObjMember(node, key), expectedTypes: EXPECTED_NESTED_ALIAS[expectedTypes[0]][key], path: [...path, key], }).$value; } } return { $value: value }; } if (!isAlias(value)) { if ((!expectedTypes?.[0] || isValidDTCGType(expectedTypes[0])) && (value.includes('{') || value.includes('}'))) { logger.error({ ...aliasEntry, message: 'Invalid alias syntax.', node }); } return { $value: value }; } const refChain = []; const resolvedID = resolveInner(value, refChain); if (expectedTypes?.length && !expectedTypes.includes(tokens[resolvedID].$type)) { logger.error({ ...aliasEntry, message: `Cannot alias to $type "${tokens[resolvedID].$type}" from $type "${expectedTypes.join(' / ')}".`, node, }); } refMap[path.join('/')] = { filename: token.source.filename, refChain }; // handle legacy $extensions.modes on upstream alias, that may/may not exist on downstream token const anyUpstreamHasModes = refChain.some((ref) => { const jsonID = ref.replace(/\/(\$value|\$extensions\/mode\/).*/, ''); return Object.keys(tokens[jsonID]?.mode ?? {}).length > 1; }); const currentHasModes = Object.keys(token.mode).length > 1; if (mode === '.' && !currentHasModes && anyUpstreamHasModes) { for (const altMode of Object.keys(tokens[resolvedID].mode)) { if (altMode === '.') { continue; } const altModePath = _injectMode(path.join('/'), altMode); const partialProperty = altModePath.match(/\$extensions\/mode\/[^/]+\/(.*)/)?.[1]; // append, never overwrite if (!refMap[altModePath]) { refMap[altModePath] = { filename: token.source.filename, refChain: refChain.map((id) => _injectMode(id, altMode)), }; if (!token.mode[altMode]) { token.mode[altMode] = { ...token.mode['.'], aliasOf: undefined, partialAliasOf: undefined, aliasedBy: undefined, }; } if (partialProperty) { if (typeof token.mode[altMode].$value === 'object' && // oxlint-disable-next-line no-unsafe-optional-chaining (tokens[resolvedID]?.mode[altMode]?.$value)[partialProperty]) { token.mode[altMode].$value[partialProperty] = // oxlint-disable-next-line no-unsafe-optional-chaining (tokens[resolvedID]?.mode[altMode]?.$value)[partialProperty]; } } else { token.mode[altMode].$value = tokens[resolvedID].mode[altMode].$value; } } } } return { $type: tokens[resolvedID].$type, $value: tokens[resolvedID].mode[mode]?.$value || tokens[resolvedID].$value, }; } const pathBase = mode === '.' ? token.jsonID : `${token.jsonID}/$extensions/mode/${mode}`; const { $type, $value } = traverseAndResolve(token.mode[mode].$value, { node: aliasEntry.node, expectedTypes: token.$type ? [token.$type] : undefined, path: [pathBase, '$value'], }); if (!token.$type) { token.$type = $type; } if ($value) { token.mode[mode].$value = $value; } } } } /** ⚠️ Don’t export. */ function _getGraphAliasTokenRef(ref) { return ref.replace(/\/(\$value|\$extensions)\/?.*/, ''); } /** ⚠️ Don’t use outside this context. This is for edge cases with legacy modes where we need to hoist modes to downstream aliases. */ function _injectMode(ref, mode) { return ref .replace(/\/\$value$/, `/$extensions/mode/${mode}`) .replace(/\/\$value\//, `/$extensions/mode/${mode}/`); } //# sourceMappingURL=token.js.map