@terrazzo/parser
Version:
Parser/validator for the Design Tokens Community Group (DTCG) standard.
290 lines • 11.2 kB
JavaScript
import { merge } from 'merge-anything';
import coreLintPlugin from './lint/plugin-core/index.js';
import Logger from './logger.js';
const TRAILING_SLASH_RE = /\/*$/;
/**
* Validate and normalize a config
*/
export default function defineConfig(rawConfig, { logger = new Logger(), cwd } = {}) {
const configStart = performance.now();
if (!cwd) {
logger.error({ group: 'config', label: 'core', message: 'defineConfig() missing `cwd` for JS API' });
}
const config = merge({}, rawConfig);
// 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, }) {
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 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 }) {
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 }) {
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 }) {
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();
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 = 'off';
let options;
if (typeof value === 'number' || typeof value === 'string') {
severity = value;
}
else if (Array.isArray(value)) {
severity = value[0];
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'][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 }) {
if (!config.ignore) {
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, b) {
return merge(a, b);
}
//# sourceMappingURL=config.js.map