@terrazzo/parser
Version:
Parser/validator for the Design Tokens Community Group (DTCG) standard.
294 lines (275 loc) • 8.55 kB
text/typescript
import type { DocumentNode, ObjectNode } from '@humanwhocodes/momoa';
import { type Token, type TokenNormalized, pluralize, splitID } from '@terrazzo/token-tools';
import type ytm from 'yaml-to-momoa';
import lintRunner from '../lint/index.js';
import Logger from '../logger.js';
import type { ConfigInit, InputSource } from '../types.js';
import applyAliases from './alias.js';
import { getObjMembers, toMomoa, traverse } from './json.js';
import normalize from './normalize.js';
import validateTokenNode from './validate.js';
export * from './alias.js';
export * from './normalize.js';
export * from './json.js';
export * from './validate.js';
export { normalize, validateTokenNode };
export interface ParseOptions {
logger?: Logger;
config: ConfigInit;
/**
* Skip lint step
* @default false
*/
skipLint?: boolean;
/**
* Continue on error? (Useful for `tz check`)
* @default false
*/
continueOnError?: boolean;
/** Provide yamlToMomoa module to parse YAML (by default, this isn’t shipped to cut down on package weight) */
yamlToMomoa?: typeof ytm;
/** (internal cache; do not use) */
_sources?: Record<string, InputSource>;
}
export interface ParseResult {
tokens: Record<string, TokenNormalized>;
sources: InputSource[];
}
/** Parse */
export default async function parse(
_input: Omit<InputSource, 'document'> | Omit<InputSource, 'document'>[],
{
logger = new Logger(),
skipLint = false,
config = {} as ConfigInit,
continueOnError = false,
yamlToMomoa,
_sources = {},
}: ParseOptions = {} as ParseOptions,
): Promise<ParseResult> {
const input = Array.isArray(_input) ? _input : [_input];
let tokensSet: Record<string, TokenNormalized> = {};
if (!Array.isArray(input)) {
logger.error({ group: 'parser', label: 'init', message: 'Input must be an array of input objects.' });
}
await Promise.all(
input.map(async (src, i) => {
if (!src || typeof src !== 'object') {
logger.error({ group: 'parser', label: 'init', message: `Input (${i}) must be an object.` });
}
if (!src.src || (typeof src.src !== 'string' && typeof src.src !== 'object')) {
logger.error({
message: `Input (${i}) missing "src" with a JSON/YAML string, or JSON object.`,
group: 'parser',
label: 'init',
});
}
if (src.filename) {
if (!(src.filename instanceof URL)) {
logger.error({
message: `Input (${i}) "filename" must be a URL (remote or file URL).`,
group: 'parser',
label: 'init',
});
}
// if already parsed/scanned, skip
if (_sources[src.filename.href]) {
return;
}
}
const result = await parseSingle(src.src, {
filename: src.filename!,
logger,
config,
skipLint,
continueOnError,
yamlToMomoa,
});
tokensSet = Object.assign(tokensSet, result.tokens);
if (src.filename) {
_sources[src.filename.href] = {
filename: src.filename,
src: result.src,
document: result.document,
};
}
}),
);
const totalStart = performance.now();
// 5. Resolve aliases and populate groups
const aliasesStart = performance.now();
let aliasCount = 0;
for (const [id, token] of Object.entries(tokensSet)) {
applyAliases(token, {
tokensSet,
filename: _sources[token.source.loc!]?.filename!,
src: _sources[token.source.loc!]?.src as string,
node: (getObjMembers(token.source.node).$value as any) || token.source.node,
logger,
});
aliasCount++;
const { group: parentGroup } = splitID(id);
if (parentGroup) {
for (const siblingID of Object.keys(tokensSet)) {
const { group: siblingGroup } = splitID(siblingID);
if (siblingGroup?.startsWith(parentGroup)) {
token.group.tokens.push(siblingID);
}
}
}
}
logger.debug({
message: `Resolved ${aliasCount} aliases`,
group: 'parser',
label: 'alias',
timing: performance.now() - aliasesStart,
});
logger.debug({
message: 'Finish all parser tasks',
group: 'parser',
label: 'core',
timing: performance.now() - totalStart,
});
if (continueOnError) {
const { errorCount } = logger.stats();
if (errorCount > 0) {
logger.error({
group: 'parser',
message: `Parser encountered ${errorCount} ${pluralize(errorCount, 'error', 'errors')}. Exiting.`,
});
}
}
return {
tokens: tokensSet,
sources: Object.values(_sources),
};
}
/** Parse a single input */
async function parseSingle(
input: string | Record<string, any>,
{
filename,
logger,
config,
skipLint,
continueOnError = false,
yamlToMomoa, // optional dependency, declared here so the parser itself doesn’t have to load a heavy dep in-browser
}: {
filename: URL;
logger: Logger;
config: ConfigInit;
skipLint: boolean;
continueOnError?: boolean;
yamlToMomoa?: typeof ytm;
},
): Promise<{ tokens: Record<string, Token>; document: DocumentNode; src?: string }> {
// 1. Build AST
const startParsing = performance.now();
const { src, document } = toMomoa(input, { filename, logger, continueOnError, yamlToMomoa });
logger.debug({
group: 'parser',
label: 'json',
message: 'Finish JSON parsing',
timing: performance.now() - startParsing,
});
const tokensSet: Record<string, TokenNormalized> = {};
// 2. Walk AST to validate tokens
let tokenCount = 0;
const startValidate = performance.now();
const $typeInheritance: Record<string, Token['$type']> = {};
traverse(document, {
enter(node, parent, subpath) {
// if $type appears at root of tokens.json, collect it
if (node.type === 'Document' && node.body.type === 'Object' && node.body.members) {
const members = getObjMembers(node.body);
if (members.$type && members.$type.type === 'String' && !members.$value) {
// @ts-ignore
$typeInheritance['.'] = node.body.members.find((m) => m.name.value === '$type');
}
}
// handle tokens
if (node.type === 'Member') {
const token = validateTokenNode(node, { filename, src, config, logger, parent, subpath, $typeInheritance });
if (token) {
tokensSet[token.id] = token;
tokenCount++;
}
}
},
});
logger.debug({
message: `Validated ${tokenCount} tokens`,
group: 'parser',
label: 'validate',
timing: performance.now() - startValidate,
});
// 3. normalize values
const normalizeStart = performance.now();
for (const [id, token] of Object.entries(tokensSet)) {
try {
tokensSet[id]!.$value = normalize(token);
} catch (err) {
let { node } = token.source;
const members = getObjMembers(node);
if (members.$value) {
node = members.$value as ObjectNode;
}
logger.error({
group: 'parser',
label: 'normalize',
message: (err as Error).message,
filename,
src,
node,
continueOnError,
});
}
for (const [mode, modeValue] of Object.entries(token.mode)) {
if (mode === '.') {
continue;
}
try {
tokensSet[id]!.mode[mode]!.$value = normalize({ $type: token.$type, ...modeValue });
} catch (err) {
let { node } = token.source;
const members = getObjMembers(node);
if (members.$value) {
node = members.$value as ObjectNode;
}
logger.error({
group: 'parser',
label: 'normalize',
message: (err as Error).message,
filename,
src,
node: modeValue.source.node,
continueOnError,
});
}
}
}
logger.debug({
message: `Normalized ${tokenCount} tokens`,
group: 'parser',
label: 'normalize',
timing: performance.now() - normalizeStart,
});
// 4. Execute lint runner with loaded plugins
if (!skipLint && config?.plugins?.length) {
const lintStart = performance.now();
await lintRunner({ tokens: tokensSet, src, config, logger });
logger.debug({
message: `Linted ${tokenCount} tokens`,
group: 'parser',
label: 'lint',
timing: performance.now() - lintStart,
});
} else {
logger.debug({ message: 'Linting skipped', group: 'parser', label: 'lint' });
}
return {
tokens: tokensSet,
document,
src,
};
}