@terrazzo/parser
Version:
Parser/validator for the Design Tokens Community Group (DTCG) standard.
547 lines • 23.5 kB
JavaScript
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