@terrazzo/parser
Version:
Parser/validator for the Design Tokens Community Group (DTCG) standard.
301 lines (282 loc) • 10 kB
text/typescript
import { merge } from 'merge-anything';
import coreLintPlugin from './lint/plugin-core/index.js';
import Logger from './logger.js';
import type { Config, ConfigInit, ConfigOptions, LintRuleSeverity } from './types.js';
const TRAILING_SLASH_RE = /\/*$/;
/**
* Validate and normalize a config
*/
export default function defineConfig(
rawConfig: Config,
{ logger = new Logger(), cwd }: ConfigOptions = {} as ConfigOptions,
): ConfigInit {
const configStart = performance.now();
if (!cwd) {
logger.error({ group: 'config', label: 'core', message: 'defineConfig() missing `cwd` for JS API' });
}
const config = merge({}, rawConfig) as unknown as ConfigInit;
// 1. normalize and init
normalizeTokens({ rawConfig, config, logger, cwd });
normalizeOutDir({ config, cwd, logger });
normalizePlugins({ config, logger });
normalizeLint({ config, logger });
normalizeIgnore({ config, logger });
// 2. Start build by calling config()
for (const plugin of config.plugins) {
plugin.config?.({ ...config });
}
// 3. finish
logger.debug({
group: 'parser',
label: 'config',
message: 'Finish config validation',
timing: performance.now() - configStart,
});
return config;
}
/** Normalize config.tokens */
function normalizeTokens({
rawConfig,
config,
logger,
cwd,
}: { rawConfig: Config; config: ConfigInit; logger: Logger; cwd: URL }) {
if (rawConfig.tokens === undefined) {
config.tokens = [
// @ts-ignore we’ll normalize in next step
'./tokens.json',
];
} else if (typeof rawConfig.tokens === 'string') {
config.tokens = [
// @ts-ignore we’ll normalize in next step
rawConfig.tokens,
];
} else if (Array.isArray(rawConfig.tokens)) {
config.tokens = [];
for (const file of rawConfig.tokens) {
if (typeof file === 'string' || (file as URL) instanceof URL) {
config.tokens.push(
// @ts-ignore we’ll normalize in next step
file,
);
} else {
logger.error({
group: 'config',
label: 'tokens',
message: `Expected array of strings, encountered ${JSON.stringify(file)}`,
});
}
}
} else {
logger.error({
group: 'config',
label: 'tokens',
message: `Expected string or array of strings, received ${typeof rawConfig.tokens}`,
});
}
for (let i = 0; i < config.tokens!.length; i++) {
const filepath = config.tokens[i]!;
if (filepath instanceof URL) {
continue; // skip if already resolved
}
try {
config.tokens[i] = new URL(filepath, cwd);
} catch (err) {
logger.error({ group: 'config', label: 'tokens', message: `Invalid URL ${filepath}` });
}
}
}
/** Normalize config.outDir */
function normalizeOutDir({ config, cwd, logger }: { config: ConfigInit; logger: Logger; cwd: URL }) {
if (config.outDir instanceof URL) {
// noop
} else if (typeof config.outDir === 'undefined') {
config.outDir = new URL('./tokens/', cwd);
} else if (typeof config.outDir !== 'string') {
logger.error({
group: 'config',
label: 'outDir',
message: `Expected string, received ${JSON.stringify(config.outDir)}`,
});
} else {
config.outDir = new URL(config.outDir, cwd);
// always add trailing slash so URL treats it as a directory.
// do AFTER it has been normalized to POSIX paths with `href` (don’t use Node internals here! This may run in the browser)
config.outDir = new URL(config.outDir.href.replace(TRAILING_SLASH_RE, '/'));
}
}
/** Normalize config.plugins */
function normalizePlugins({ config, logger }: { config: ConfigInit; logger: Logger }) {
if (typeof config.plugins === 'undefined') {
config.plugins = [];
}
if (!Array.isArray(config.plugins)) {
logger.error({
group: 'config',
label: 'plugins',
message: `Expected array of plugins, received ${JSON.stringify(config.plugins)}`,
});
}
config.plugins.push(coreLintPlugin());
for (let n = 0; n < config.plugins.length; n++) {
const plugin = config.plugins[n];
if (typeof plugin !== 'object') {
logger.error({
group: 'config',
label: `plugin[${n}]`,
message: `Expected output plugin, received ${JSON.stringify(plugin)}`,
});
} else if (!plugin.name) {
logger.error({ group: 'config', label: `plugin[${n}]`, message: `Missing "name"` });
}
}
// order plugins with "enforce"
config.plugins.sort((a, b) => {
if (a.enforce === 'pre' && b.enforce !== 'pre') {
return -1;
} else if (a.enforce === 'post' && b.enforce !== 'post') {
return 1;
}
return 0;
});
}
function normalizeLint({ config, logger }: { config: ConfigInit; logger: Logger }) {
if (config.lint !== undefined) {
if (config.lint === null || typeof config.lint !== 'object' || Array.isArray(config.lint)) {
logger.error({ group: 'config', label: 'lint', message: 'Must be an object' });
}
if (!config.lint.build) {
config.lint.build = { enabled: true };
}
if (config.lint.build.enabled !== undefined) {
if (typeof config.lint.build.enabled !== 'boolean') {
logger.error({
group: 'config',
label: 'lint › build › enabled',
message: `Expected boolean, received ${JSON.stringify(config.lint.build)}`,
});
}
} else {
config.lint.build.enabled = true;
}
if (config.lint.rules === undefined) {
config.lint.rules = {};
} else {
if (config.lint.rules === null || typeof config.lint.rules !== 'object' || Array.isArray(config.lint.rules)) {
logger.error({
group: 'config',
label: 'lint › rules',
message: `Expected object, received ${JSON.stringify(config.lint.rules)}`,
});
return;
}
const allRules = new Map<string, string>();
for (const plugin of config.plugins) {
if (typeof plugin.lint !== 'function') {
continue;
}
const pluginRules = plugin.lint();
if (!pluginRules || Array.isArray(pluginRules) || typeof pluginRules !== 'object') {
logger.error({
group: 'config',
label: `plugin › ${plugin.name}`,
message: `Expected object for lint() received ${JSON.stringify(pluginRules)}`,
});
continue;
}
for (const rule of Object.keys(pluginRules)) {
// Note: sometimes plugins will be loaded multiple times, in which case it’s expected
// they’re register rules again for lint(). Only throw an error if plugin A and plugin B’s
// rules conflict.
if (allRules.get(rule) && allRules.get(rule) !== plugin.name) {
logger.error({
group: 'config',
label: `plugin › ${plugin.name}`,
message: `Duplicate rule ${rule} already registered by plugin ${allRules.get(rule)}`,
});
}
allRules.set(rule, plugin.name);
}
}
for (const id of Object.keys(config.lint.rules)) {
if (!allRules.has(id)) {
logger.error({
group: 'config',
label: `lint › rule › ${id}`,
message: 'Unknown rule. Is the plugin installed?',
});
}
const value = config.lint.rules[id];
let severity: LintRuleSeverity = 'off';
let options: any;
if (typeof value === 'number' || typeof value === 'string') {
severity = value;
} else if (Array.isArray(value)) {
severity = value[0] as LintRuleSeverity;
options = value[1];
} else if (value !== undefined) {
logger.error({
group: 'config',
label: `lint › rule › ${id}`,
message: `Invalid eyntax. Expected \`string | number | Array\`, received ${JSON.stringify(value)}}`,
});
}
config.lint.rules[id] = [severity, options];
if (typeof severity === 'number') {
if (severity !== 0 && severity !== 1 && severity !== 2) {
logger.error({
group: 'config',
label: `lint › rule › ${id}`,
message: `Invalid number ${severity}. Specify 0 (off), 1 (warn), or 2 (error).`,
});
}
config.lint.rules[id]![0] = (['off', 'warn', 'error'] as const)[severity]!;
} else if (typeof severity === 'string') {
if (severity !== 'off' && severity !== 'warn' && severity !== 'error') {
logger.error({
group: 'config',
label: `lint › rule › ${id}`,
message: `Invalid string ${JSON.stringify(severity)}. Specify "off", "warn", or "error".`,
});
}
} else if (value !== null) {
logger.error({
group: 'config',
label: `lint › rule › ${id}`,
message: `Expected string or number, received ${JSON.stringify(value)}`,
});
}
}
}
} else {
config.lint = {
build: { enabled: true },
rules: {},
};
}
}
function normalizeIgnore({ config, logger }: { config: ConfigInit; logger: Logger }) {
if (!config.ignore) {
config.ignore = {} as typeof config.ignore;
}
config.ignore.tokens ??= [];
config.ignore.deprecated ??= false;
if (!Array.isArray(config.ignore.tokens) || config.ignore.tokens.some((x) => typeof x !== 'string')) {
logger.error({
group: 'config',
label: 'ignore › tokens',
message: `Expected array of strings, received ${JSON.stringify(config.ignore.tokens)}`,
});
}
if (typeof config.ignore.deprecated !== 'boolean') {
logger.error({
group: 'config',
label: 'ignore › deprecated',
message: `Expected boolean, received ${JSON.stringify(config.ignore.deprecated)}`,
});
}
}
/** Merge configs */
export function mergeConfigs(a: Config, b: Config): Config {
return merge(a, b);
}