UNPKG

@terrazzo/parser

Version:

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

1,505 lines 137 kB
import { BORDER_REQUIRED_PROPERTIES, COLOR_SPACE, CachedWildcardMatcher, FONT_WEIGHTS, GRADIENT_REQUIRED_STOP_PROPERTIES, SHADOW_REQUIRED_PROPERTIES, STROKE_STYLE_LINE_CAP_VALUES, STROKE_STYLE_OBJECT_REQUIRED_PROPERTIES, STROKE_STYLE_STRING_VALUES, TRANSITION_REQUIRED_PROPERTIES, isAlias, parseAlias, parseColor, pluralize, tokenToColor } from "@terrazzo/token-tools"; import * as momoa from "@humanwhocodes/momoa"; import pc from "picocolors"; import { merge } from "merge-anything"; import { contrastWCAG21, inGamut } from "colorjs.io/fn"; import { camelCase, kebabCase, pascalCase, snakeCase } from "scule"; import { bundle, encodeFragment, findNode, getObjMember, getObjMembers, isPure$ref, maybeRawJSON, mergeObjects, parseRef, replaceNode, traverse } from "@terrazzo/json-schema-tools"; //#region src/lib/code-frame.ts /** * Extract what lines should be marked and highlighted. */ function getMarkerLines(loc, source, opts = {}) { const startLoc = { column: 0, line: -1, ...loc.start }; const endLoc = { ...startLoc, ...loc.end }; const { linesAbove = 2, linesBelow = 3 } = opts || {}; const startLine = startLoc.line; const startColumn = startLoc.column; const endLine = endLoc.line; const endColumn = endLoc.column; let start = Math.max(startLine - (linesAbove + 1), 0); let end = Math.min(source.length, endLine + linesBelow); if (startLine === -1) start = 0; if (endLine === -1) end = source.length; const lineDiff = endLine - startLine; const markerLines = {}; if (lineDiff) for (let i = 0; i <= lineDiff; i++) { const lineNumber = i + startLine; if (!startColumn) markerLines[lineNumber] = true; else if (i === 0) markerLines[lineNumber] = [startColumn, source[lineNumber - 1].length - startColumn + 1]; else if (i === lineDiff) markerLines[lineNumber] = [0, endColumn]; else markerLines[lineNumber] = [0, source[lineNumber - i].length]; } else if (startColumn === endColumn) if (startColumn) markerLines[startLine] = [startColumn, 0]; else markerLines[startLine] = true; else markerLines[startLine] = [startColumn, endColumn - startColumn]; return { start, end, markerLines }; } /** * RegExp to test for newlines in terminal. */ const NEWLINE = /\r\n|[\n\r\u2028\u2029]/; function codeFrameColumns(rawLines, loc, opts = {}) { if (typeof rawLines !== "string") throw new Error(`Expected string, got ${rawLines}`); const { start, end, markerLines } = getMarkerLines(loc, rawLines.split(NEWLINE), opts); const hasColumns = loc.start && typeof loc.start.column === "number"; const numberMaxWidth = String(end).length; let frame = rawLines.split(NEWLINE, end).slice(start, end).map((line, index) => { const number = start + 1 + index; const gutter = ` ${` ${number}`.slice(-numberMaxWidth)} |`; const hasMarker = markerLines[number]; const lastMarkerLine = !markerLines[number + 1]; if (hasMarker) { let markerLine = ""; if (Array.isArray(hasMarker)) { const markerSpacing = line.slice(0, Math.max(hasMarker[0] - 1, 0)).replace(/[^\t]/g, " "); const numberOfMarkers = hasMarker[1] || 1; markerLine = [ "\n ", gutter.replace(/\d/g, " "), " ", markerSpacing, "^".repeat(numberOfMarkers) ].join(""); if (lastMarkerLine && opts.message) markerLine += ` ${opts.message}`; } return [ ">", gutter, line.length > 0 ? ` ${line}` : "", markerLine ].join(""); } else return ` ${gutter}${line.length > 0 ? ` ${line}` : ""}`; }).join("\n"); if (opts.message && !hasColumns) frame = `${" ".repeat(numberMaxWidth + 1)}${opts.message}\n${frame}`; return frame; } //#endregion //#region src/logger.ts const LOG_ORDER = [ "error", "warn", "info", "debug" ]; const GROUP_COLOR = { config: pc.cyan, import: pc.green, lint: pc.yellowBright, parser: pc.magenta, plugin: pc.greenBright, resolver: pc.magentaBright, server: pc.gray }; const MESSAGE_COLOR = { error: pc.red, warn: pc.yellow, info: (msg) => msg, debug: pc.gray }; const timeFormatter = new Intl.DateTimeFormat("en-us", { hour: "numeric", hour12: false, minute: "numeric", second: "numeric", fractionalSecondDigits: 3 }); /** * @param {Entry} entry * @param {Severity} severity * @return {string} */ function formatMessage(entry, severity) { const groupColor = GROUP_COLOR[entry.group]; const messageColor = MESSAGE_COLOR[severity]; let message = entry.message; message = `${groupColor(`${entry.group}${entry.label ? `:${entry.label}` : ""}:`)} ${messageColor(message)}`; if (typeof entry.timing === "number") message = `${message} ${formatTiming(entry.timing)}`; if (entry.node) { const start = entry.node?.loc?.start ?? { line: 0, column: 0 }; const loc = entry.filename ? `${entry.filename?.href.replace(/^file:\/\//, "")}:${start?.line ?? 0}:${start?.column ?? 0}\n\n` : ""; const codeFrame = codeFrameColumns(entry.src ?? momoa.print(entry.node, { indent: 2 }), { start }, { highlightCode: false }); message = `${message}\n\n${loc}${codeFrame}`; } return message; } const debugMatch = new CachedWildcardMatcher(); var Logger = class { level = "info"; debugScope = "*"; errorCount = 0; warnCount = 0; infoCount = 0; debugCount = 0; constructor(options) { if (options?.level) this.level = options.level; if (options?.debugScope) this.debugScope = options.debugScope; } setLevel(level) { this.level = level; } /** Log an error message (always; can’t be silenced) */ error(...entries) { const message = []; let firstNode; for (const entry of entries) { this.errorCount++; message.push(formatMessage(entry, "error")); if (entry.node) firstNode = entry.node; } if (entries.every((e) => e.continueOnError)) console.error(message.join("\n\n")); else throw firstNode ? new TokensJSONError(message.join("\n\n")) : new Error(message.join("\n\n")); } /** Log an info message (if logging level permits) */ info(...entries) { for (const entry of entries) { this.infoCount++; if (this.level === "silent" || LOG_ORDER.indexOf(this.level) < LOG_ORDER.indexOf("info")) return; const message = formatMessage(entry, "info"); console.log(message); } } /** Log a warning message (if logging level permits) */ warn(...entries) { for (const entry of entries) { this.warnCount++; if (this.level === "silent" || LOG_ORDER.indexOf(this.level) < LOG_ORDER.indexOf("warn")) return; const message = formatMessage(entry, "warn"); console.warn(message); } } /** Log a diagnostics message (if logging level permits) */ debug(...entries) { for (const entry of entries) { if (this.level === "silent" || LOG_ORDER.indexOf(this.level) < LOG_ORDER.indexOf("debug")) return; this.debugCount++; let message = formatMessage(entry, "debug"); const debugPrefix = entry.label ? `${entry.group}:${entry.label}` : entry.group; if (this.debugScope !== "*" && !debugMatch.match(this.debugScope)(debugPrefix)) return; message.replace(/\[config[^\]]+\]/, (match) => pc.green(match)).replace(/\[parser[^\]]+\]/, (match) => pc.magenta(match)).replace(/\[lint[^\]]+\]/, (match) => pc.yellow(match)).replace(/\[plugin[^\]]+\]/, (match) => pc.cyan(match)); message = `${pc.dim(timeFormatter.format(performance.now()))} ${message}`; if (typeof entry.timing === "number") message = `${message} ${formatTiming(entry.timing)}`; console.log(message); } } /** Get stats for current logger instance */ stats() { return { errorCount: this.errorCount, warnCount: this.warnCount, infoCount: this.infoCount, debugCount: this.debugCount }; } }; function formatTiming(timing) { let output = ""; if (timing < 1e3) output = `${Math.round(timing * 100) / 100}ms`; else if (timing < 6e4) output = `${Math.round(timing) / 1e3}s`; else output = `${Math.round(timing / 1e3) / 60}m`; return pc.dim(`[${output}]`); } var TokensJSONError = class extends Error { constructor(message) { super(message); this.name = "TokensJSONError"; } }; //#endregion //#region src/build/index.ts const SINGLE_VALUE = "SINGLE_VALUE"; const MULTI_VALUE = "MULTI_VALUE"; /** Validate plugin setTransform() calls for immediate feedback */ function validateTransformParams({ params, logger, pluginName }) { const baseMessage = { group: "plugin", label: pluginName, message: "" }; if (!params.value || typeof params.value !== "string" && typeof params.value !== "object" || Array.isArray(params.value)) logger.error({ ...baseMessage, message: `setTransform() value expected string or object of strings, received ${Array.isArray(params.value) ? "Array" : typeof params.value}` }); if (typeof params.value === "object" && Object.values(params.value).some((v) => typeof v !== "string")) logger.error({ ...baseMessage, message: "setTransform() value expected object of strings, received some non-string values" }); } const FALLBACK_PERMUTATION_ID = JSON.stringify({ tzMode: "*" }); const cachedMatcher$1 = new CachedWildcardMatcher(); /** Run build stage */ async function build(tokens, { resolver, sources, logger = new Logger(), config }) { const formats = {}; const result = { outputFiles: [] }; function getTransforms(plugin) { return function getTransforms(params) { if (!params?.format) { logger.warn({ group: "plugin", label: plugin, message: "\"format\" missing from getTransforms(), no tokens returned." }); return []; } const isLegacyModes = params.input && Object.keys(params.input).length === 1 && "tzMode" in params.input; const permutationID = params.input && !isLegacyModes ? resolver.getPermutationID(params.input) : FALLBACK_PERMUTATION_ID; const mode = params.mode || isLegacyModes && params.input.tzMode || void 0; const singleTokenID = typeof params.id === "string" && tokens[params.id]?.id || Array.isArray(params.id) && params.id.length === 1 && tokens[params.id[0]]?.id || void 0; const $type = typeof params.$type === "string" && [params.$type] || Array.isArray(params.$type) && params.$type || void 0; const idMatcher = params.id && !singleTokenID && !isFullWildcard(params.id) ? cachedMatcher$1.tokenIDMatch(params.id) : null; const modeMatcher = mode && mode !== "." && !isFullWildcard(mode) ? cachedMatcher$1.match(mode) : null; return (formats[params.format]?.[permutationID] ?? []).filter((token) => { if (singleTokenID && token.id !== singleTokenID || idMatcher && !idMatcher(token.id)) return false; if (params.$type && !$type?.some((value) => token.token.$type === value)) return false; if (mode === "." && token.mode !== "." || modeMatcher && !modeMatcher(token.mode)) return false; return true; }); }; } let transformsLocked = false; const startTransform = performance.now(); for (const plugin of config.plugins) if (typeof plugin.transform === "function") { const pt = performance.now(); await plugin.transform({ context: { logger }, tokens, sources, getTransforms: getTransforms(plugin.name), setTransform(id, params) { if (transformsLocked) { logger.warn({ message: "Attempted to call setTransform() after transform step has completed.", group: "plugin", label: plugin.name }); return; } const token = tokens[id]; if (!token) logger.error({ group: "plugin", label: plugin.name, message: `No token "${id}"` }); const isLegacyModes = params.input && Object.keys(params.input).length === 1 && "tzMode" in params.input; const permutationID = params.input && !isLegacyModes ? resolver.getPermutationID(params.input) : FALLBACK_PERMUTATION_ID; const mode = params.mode || isLegacyModes && params.input.tzMode || void 0; const cleanValue = typeof params.value === "string" ? params.value : { ...params.value }; validateTransformParams({ logger, params: { ...params, value: cleanValue }, pluginName: plugin.name }); if (!formats[params.format]) formats[params.format] = { [FALLBACK_PERMUTATION_ID]: [] }; if (!formats[params.format][permutationID]) formats[params.format][permutationID] = []; const foundTokenI = formats[params.format][permutationID].findIndex((t) => id === t.id && (!params.localID || params.localID === t.localID) && (!mode || t.mode === mode)); if (foundTokenI === -1) { const transformedToken = { ...params, id, value: cleanValue, type: typeof cleanValue === "string" ? SINGLE_VALUE : MULTI_VALUE, mode: mode || ".", token: makeReadOnlyToken(token), permutationID, input: JSON.parse(permutationID) }; formats[params.format][permutationID].push(transformedToken); if (params.input && !Object.keys(params.input).length && permutationID !== FALLBACK_PERMUTATION_ID) formats[params.format][FALLBACK_PERMUTATION_ID].push(transformedToken); } else { formats[params.format][permutationID][foundTokenI].value = cleanValue; formats[params.format][permutationID][foundTokenI].type = typeof cleanValue === "string" ? SINGLE_VALUE : MULTI_VALUE; } }, resolver }); logger.debug({ group: "plugin", label: plugin.name, message: "transform()", timing: performance.now() - pt }); } transformsLocked = true; logger.debug({ group: "parser", label: "transform", message: "All plugins finished transform()", timing: performance.now() - startTransform }); const startBuild = performance.now(); await Promise.all(config.plugins.map(async (plugin) => { if (typeof plugin.build === "function") { const pb = performance.now(); await plugin.build({ context: { logger }, tokens, sources, getTransforms: getTransforms(plugin.name), resolver, outputFile(filename, contents) { const resolved = new URL(filename, config.outDir); if (result.outputFiles.some((f) => new URL(f.filename, config.outDir).href === resolved.href)) logger.error({ group: "plugin", message: `Can’t overwrite file "${filename}"`, label: plugin.name }); result.outputFiles.push({ filename, contents, plugin: plugin.name, time: performance.now() - pb }); } }); logger.debug({ group: "plugin", label: plugin.name, message: "build()", timing: performance.now() - pb }); } })); logger.debug({ group: "parser", label: "build", message: "All plugins finished build()", timing: performance.now() - startBuild }); cachedMatcher$1.reset(); const startBuildEnd = performance.now(); await Promise.all(config.plugins.map(async (plugin) => plugin.buildEnd?.({ context: { logger }, tokens, getTransforms: getTransforms(plugin.name), sources, outputFiles: structuredClone(result.outputFiles) }))); logger.debug({ group: "parser", label: "build", message: "buildEnd() step", timing: performance.now() - startBuildEnd }); return result; } function isFullWildcard(value) { return typeof value === "string" && (value === "*" || value === "**") || Array.isArray(value) && value.some((v) => v === "*" || v === "**"); } /** Generate getters for transformed tokens. Reduces memory usage while improving accuracy. Provides some safety for read-only values. */ function makeReadOnlyToken(token) { return { get id() { return token.id; }, get $value() { return token.$value; }, get $type() { return token.$type; }, get $description() { return token.$description; }, get $deprecated() { return token.$deprecated; }, get $extends() { return token.$extends; }, get $extensions() { return token.$extensions; }, get mode() { return token.mode; }, get originalValue() { return token.originalValue; }, get aliasChain() { return token.aliasChain; }, get aliasOf() { return token.aliasOf; }, get partialAliasOf() { return token.partialAliasOf; }, get aliasedBy() { return token.aliasedBy; }, get group() { return token.group; }, get source() { return token.source; }, get jsonID() { return token.jsonID; }, get dependencies() { return token.dependencies; } }; } //#endregion //#region src/lint/plugin-core/lib/docs.ts function docsLink(ruleName) { return `https://terrazzo.app/docs/linting#${ruleName.replaceAll("/", "")}`; } //#endregion //#region src/lint/plugin-core/rules/a11y-min-contrast.ts const A11Y_MIN_CONTRAST = "a11y/min-contrast"; const WCAG2_MIN_CONTRAST = { AA: { default: 4.5, large: 3 }, AAA: { default: 7, large: 4.5 } }; const ERROR_INSUFFICIENT_CONTRAST = "INSUFFICIENT_CONTRAST"; const rule$26 = { meta: { messages: { [ERROR_INSUFFICIENT_CONTRAST]: "Pair {{ index }} failed; expected {{ expected }}, got {{ actual }} ({{ level }})" }, docs: { description: "Enforce colors meet minimum contrast checks for WCAG 2.", url: docsLink(A11Y_MIN_CONTRAST) } }, defaultOptions: { level: "AA", pairs: [] }, create({ tokens, options, report }) { for (let i = 0; i < options.pairs.length; i++) { const { foreground, background, largeText } = options.pairs[i]; if (!tokens[foreground]) throw new Error(`Token ${foreground} does not exist`); if (tokens[foreground].$type !== "color") throw new Error(`Token ${foreground} isn’t a color`); if (!tokens[background]) throw new Error(`Token ${background} does not exist`); if (tokens[background].$type !== "color") throw new Error(`Token ${background} isn’t a color`); const contrast = contrastWCAG21(tokenToColor(tokens[foreground].$value), tokenToColor(tokens[background].$value)); const min = WCAG2_MIN_CONTRAST[options.level ?? "AA"][largeText ? "large" : "default"]; if (contrast < min) report({ messageId: ERROR_INSUFFICIENT_CONTRAST, data: { index: i + 1, expected: min, actual: Math.round(contrast * 100) / 100, level: options.level } }); } } }; //#endregion //#region src/lint/plugin-core/lib/matchers.ts /** * Share one cached matcher factory for all lint plugins. * * Creating matchers is CPU-intensive, however, if we made one matcher for very * getTransform plugin query, we could end up with tens of thousands of * matchers, all taking up space in memory, but without providing any caching * benefits if a matcher is used only once. So a reasonable balance is we * maintain one cache per task category, and we garbage-collect everything after * it’s done. Lint tasks are likely to have frequently-occurring patterns. So * we’d expect for most use cases a shared lint cache has benefits, but only * so long as this doesn’t spread to other plugins and other task categories. */ const cachedLintMatcher = new CachedWildcardMatcher(); //#endregion //#region src/lint/plugin-core/rules/a11y-min-font-size.ts const A11Y_MIN_FONT_SIZE = "a11y/min-font-size"; const ERROR_TOO_SMALL = "TOO_SMALL"; const rule$25 = { meta: { messages: { [ERROR_TOO_SMALL]: "{{ id }} font size too small. Expected minimum of {{ min }}" }, docs: { description: "Enforce font sizes are no smaller than the given value.", url: docsLink(A11Y_MIN_FONT_SIZE) } }, defaultOptions: {}, create({ tokens, options, report }) { if (!options.minSizePx && !options.minSizeRem) throw new Error("Must specify at least one of minSizePx or minSizeRem"); const shouldIgnore = options.ignore ? cachedLintMatcher.tokenIDMatch(options.ignore) : null; for (const t of Object.values(tokens)) { if (shouldIgnore?.(t.id)) continue; if (t.aliasOf) continue; if (t.$type === "typography" && "fontSize" in t.$value) { const fontSize = t.$value.fontSize; if (fontSize.unit === "px" && options.minSizePx && fontSize.value < options.minSizePx || fontSize.unit === "rem" && options.minSizeRem && fontSize.value < options.minSizeRem) report({ messageId: ERROR_TOO_SMALL, data: { id: t.id, min: options.minSizePx ? `${options.minSizePx}px` : `${options.minSizeRem}rem` } }); } } } }; //#endregion //#region src/lint/plugin-core/rules/colorspace.ts const COLORSPACE = "core/colorspace"; const ERROR_COLOR$1 = "COLOR"; const ERROR_BORDER$1 = "BORDER"; const ERROR_GRADIENT$1 = "GRADIENT"; const ERROR_SHADOW$1 = "SHADOW"; const rule$24 = { meta: { messages: { [ERROR_COLOR$1]: "Color {{ id }} not in colorspace {{ colorSpace }}", [ERROR_BORDER$1]: "Border {{ id }} not in colorspace {{ colorSpace }}", [ERROR_GRADIENT$1]: "Gradient {{ id }} not in colorspace {{ colorSpace }}", [ERROR_SHADOW$1]: "Shadow {{ id }} not in colorspace {{ colorSpace }}" }, docs: { description: "Enforce that all colors are in a specific colorspace.", url: docsLink(COLORSPACE) } }, defaultOptions: { colorSpace: "srgb" }, create({ tokens, options, report }) { if (!options.colorSpace) return; const shouldIgnore = options.ignore ? cachedLintMatcher.tokenIDMatch(options.ignore) : null; for (const t of Object.values(tokens)) { if (shouldIgnore?.(t.id)) continue; if (t.aliasOf) continue; switch (t.$type) { case "color": if (t.$value.colorSpace !== options.colorSpace) report({ messageId: ERROR_COLOR$1, data: { id: t.id, colorSpace: options.colorSpace }, node: t.source.node, filename: t.source.filename }); break; case "border": if (!t.partialAliasOf?.color && t.$value.color.colorSpace !== options.colorSpace) report({ messageId: ERROR_BORDER$1, data: { id: t.id, colorSpace: options.colorSpace }, node: t.source.node, filename: t.source.filename }); break; case "gradient": for (let stopI = 0; stopI < t.$value.length; stopI++) if (!t.partialAliasOf?.[stopI]?.color && t.$value[stopI].color.colorSpace !== options.colorSpace) report({ messageId: ERROR_GRADIENT$1, data: { id: t.id, colorSpace: options.colorSpace }, node: t.source.node, filename: t.source.filename }); break; case "shadow": for (let shadowI = 0; shadowI < t.$value.length; shadowI++) if (!t.partialAliasOf?.[shadowI]?.color && t.$value[shadowI].color.colorSpace !== options.colorSpace) report({ messageId: ERROR_SHADOW$1, data: { id: t.id, colorSpace: options.colorSpace }, node: t.source.node, filename: t.source.filename }); break; } } } }; //#endregion //#region src/lint/plugin-core/rules/consistent-naming.ts const CONSISTENT_NAMING = "core/consistent-naming"; const ERROR_WRONG_FORMAT = "ERROR_WRONG_FORMAT"; const rule$23 = { meta: { messages: { [ERROR_WRONG_FORMAT]: "{{ id }} doesn’t match format {{ format }}" }, docs: { description: "Enforce consistent naming for tokens.", url: docsLink(CONSISTENT_NAMING) } }, defaultOptions: { format: "kebab-case" }, create({ tokens, options, report }) { const basicFormatter = { "kebab-case": kebabCase, camelCase, PascalCase: pascalCase, snake_case: snakeCase, SCREAMING_SNAKE_CASE: (name) => snakeCase(name).toLocaleUpperCase() }[String(options.format)]; for (const t of Object.values(tokens)) if (basicFormatter) { if (!t.id.split(".").every((part) => basicFormatter(part) === part)) report({ messageId: ERROR_WRONG_FORMAT, data: { id: t.id, format: options.format }, node: t.source.node }); } else if (typeof options.format === "function") { if (options.format(t.id)) report({ messageId: ERROR_WRONG_FORMAT, data: { id: t.id, format: "(custom)" }, node: t.source.node }); } } }; //#endregion //#region src/lint/plugin-core/rules/descriptions.ts const DESCRIPTIONS = "core/descriptions"; const ERROR_MISSING_DESCRIPTION = "MISSING_DESCRIPTION"; const rule$22 = { meta: { messages: { [ERROR_MISSING_DESCRIPTION]: "{{ id }} missing description" }, docs: { description: "Enforce tokens have descriptions.", url: docsLink(DESCRIPTIONS) } }, defaultOptions: {}, create({ tokens, options, report }) { const shouldIgnore = options.ignore ? cachedLintMatcher.tokenIDMatch(options.ignore) : null; for (const t of Object.values(tokens)) { if (shouldIgnore?.(t.id)) continue; if (!t.$description) report({ messageId: ERROR_MISSING_DESCRIPTION, data: { id: t.id }, node: t.source.node }); } } }; //#endregion //#region src/lint/plugin-core/rules/duplicate-values.ts const DUPLICATE_VALUES = "core/duplicate-values"; const ERROR_DUPLICATE_VALUE = "ERROR_DUPLICATE_VALUE"; const rule$21 = { meta: { messages: { [ERROR_DUPLICATE_VALUE]: "{{ id }} declared a duplicate value" }, docs: { description: "Enforce tokens can’t redeclare the same value (excludes aliases).", url: docsLink(DUPLICATE_VALUES) } }, defaultOptions: {}, create({ report, tokens, options }) { const values = {}; const shouldIgnore = options.ignore ? cachedLintMatcher.tokenIDMatch(options.ignore) : null; for (const t of Object.values(tokens)) { if (shouldIgnore?.(t.id)) continue; if (!values[t.$type]) values[t.$type] = /* @__PURE__ */ new Set(); if (t.$type === "boolean" || t.$type === "duration" || t.$type === "fontWeight" || t.$type === "link" || t.$type === "number" || t.$type === "string") { if (typeof t.aliasOf === "string" && isAlias(t.aliasOf)) continue; if (values[t.$type]?.has(t.$value)) report({ messageId: ERROR_DUPLICATE_VALUE, data: { id: t.id }, node: t.source.node, filename: t.source.filename }); values[t.$type]?.add(t.$value); } else { for (const v of values[t.$type].values() ?? []) if (JSON.stringify(t.$value) === JSON.stringify(v)) { report({ messageId: ERROR_DUPLICATE_VALUE, data: { id: t.id }, node: t.source.node, filename: t.source.filename }); break; } values[t.$type].add(t.$value); } } } }; //#endregion //#region src/lint/plugin-core/rules/max-gamut.ts const MAX_GAMUT = "core/max-gamut"; const ERROR_COLOR = "COLOR"; const ERROR_BORDER = "BORDER"; const ERROR_GRADIENT = "GRADIENT"; const ERROR_SHADOW = "SHADOW"; const rule$20 = { meta: { messages: { [ERROR_COLOR]: "Color {{ id }} is outside {{ gamut }} gamut", [ERROR_BORDER]: "Border {{ id }} is outside {{ gamut }} gamut", [ERROR_GRADIENT]: "Gradient {{ id }} is outside {{ gamut }} gamut", [ERROR_SHADOW]: "Shadow {{ id }} is outside {{ gamut }} gamut" }, docs: { description: "Enforce colors are within the specified gamut.", url: docsLink(MAX_GAMUT) } }, defaultOptions: { gamut: "rec2020" }, create({ tokens, options, report }) { if (!options?.gamut) return; if (options.gamut !== "srgb" && options.gamut !== "p3" && options.gamut !== "rec2020") throw new Error(`Unknown gamut "${options.gamut}". Options are "srgb", "p3", or "rec2020"`); const shouldIgnore = options.ignore ? cachedLintMatcher.tokenIDMatch(options.ignore) : null; for (const t of Object.values(tokens)) { if (shouldIgnore?.(t.id)) continue; if (t.aliasOf) continue; switch (t.$type) { case "color": if (!inGamut(tokenToColor(t.$value), options.gamut)) report({ messageId: ERROR_COLOR, data: { id: t.id, gamut: options.gamut }, node: t.source.node, filename: t.source.filename }); break; case "border": if (!t.partialAliasOf?.color && !inGamut(tokenToColor(t.$value.color), options.gamut)) report({ messageId: ERROR_BORDER, data: { id: t.id, gamut: options.gamut }, node: t.source.node, filename: t.source.filename }); break; case "gradient": for (let stopI = 0; stopI < t.$value.length; stopI++) if (!t.partialAliasOf?.[stopI]?.color && !inGamut(tokenToColor(t.$value[stopI].color), options.gamut)) report({ messageId: ERROR_GRADIENT, data: { id: t.id, gamut: options.gamut }, node: t.source.node, filename: t.source.filename }); break; case "shadow": for (let shadowI = 0; shadowI < t.$value.length; shadowI++) if (!t.partialAliasOf?.[shadowI]?.color && !inGamut(tokenToColor(t.$value[shadowI].color), options.gamut)) report({ messageId: ERROR_SHADOW, data: { id: t.id, gamut: options.gamut }, node: t.source.node, filename: t.source.filename }); break; } } } }; //#endregion //#region src/lint/plugin-core/rules/required-children.ts const REQUIRED_CHILDREN = "core/required-children"; const ERROR_EMPTY_MATCH = "EMPTY_MATCH"; const ERROR_MISSING_REQUIRED_TOKENS = "MISSING_REQUIRED_TOKENS"; const ERROR_MISSING_REQUIRED_GROUP = "MISSING_REQUIRED_GROUP"; const rule$19 = { meta: { messages: { [ERROR_EMPTY_MATCH]: "No tokens matched {{ matcher }}", [ERROR_MISSING_REQUIRED_TOKENS]: "Match {{ index }}: some groups missing required token \"{{ token }}\"", [ERROR_MISSING_REQUIRED_GROUP]: "Match {{ index }}: some tokens missing required group \"{{ group }}\"" }, docs: { description: "Enforce token groups have specific children, whether tokens and/or groups.", url: docsLink(REQUIRED_CHILDREN) } }, defaultOptions: { matches: [] }, create({ tokens, options, report }) { if (!options.matches?.length) throw new Error("Invalid config. Missing `matches: […]`"); for (let matchI = 0; matchI < options.matches.length; matchI++) { const { match, requiredTokens, requiredGroups } = options.matches[matchI]; if (!match.length) throw new Error(`Match ${matchI}: must declare \`match: […]\``); if (!requiredTokens?.length && !requiredGroups?.length) throw new Error(`Match ${matchI}: must declare either \`requiredTokens: […]\` or \`requiredGroups: […]\``); const matcher = cachedLintMatcher.tokenIDMatch(match); const matchGroups = []; const matchTokens = []; let tokensMatched = false; for (const t of Object.values(tokens)) { if (!matcher(t.id)) continue; tokensMatched = true; const groups = t.id.split("."); matchTokens.push(groups.pop()); matchGroups.push(...groups); } if (!tokensMatched) { report({ messageId: ERROR_EMPTY_MATCH, data: { matcher: JSON.stringify(match) } }); continue; } if (requiredTokens) { for (const id of requiredTokens) if (!matchTokens.includes(id)) report({ messageId: ERROR_MISSING_REQUIRED_TOKENS, data: { index: matchI, token: id } }); } if (requiredGroups) { for (const groupName of requiredGroups) if (!matchGroups.includes(groupName)) report({ messageId: ERROR_MISSING_REQUIRED_GROUP, data: { index: matchI, group: groupName } }); } } } }; //#endregion //#region src/lint/plugin-core/rules/required-modes.ts const REQUIRED_MODES = "core/required-modes"; const rule$18 = { meta: { docs: { description: "Enforce certain tokens have specific modes.", url: docsLink(REQUIRED_MODES) } }, defaultOptions: { matches: [] }, create({ tokens, options, report }) { if (!options?.matches?.length) throw new Error("Invalid config. Missing `matches: […]`"); for (let matchI = 0; matchI < options.matches.length; matchI++) { const { match, modes } = options.matches[matchI]; if (!match.length) throw new Error(`Match ${matchI}: must declare \`match: […]\``); if (!modes?.length) throw new Error(`Match ${matchI}: must declare \`modes: […]\``); const matcher = cachedLintMatcher.tokenIDMatch(match); let tokensMatched = false; for (const t of Object.values(tokens)) { if (!matcher(t.id)) continue; tokensMatched = true; for (const mode of modes) if (!t.mode?.[mode]) report({ message: `Token ${t.id}: missing required mode "${mode}"`, node: t.source.node, filename: t.source.filename }); if (!tokensMatched) report({ message: `Match "${matchI}": no tokens matched ${JSON.stringify(match)}`, node: t.source.node, filename: t.source.filename }); } } } }; //#endregion //#region src/lint/plugin-core/rules/required-type.ts const REQUIRED_TYPE = "core/required-type"; const ERROR$10 = "ERROR"; const rule$17 = { meta: { messages: { [ERROR$10]: "Token missing $type." }, docs: { description: "Requiring every token to have $type, even aliases, simplifies computation.", url: docsLink(REQUIRED_TYPE) } }, defaultOptions: {}, create({ tokens, report }) { for (const t of Object.values(tokens)) if (!t.originalValue?.$type) report({ messageId: ERROR$10, node: t.source.node, filename: t.source.filename }); } }; //#endregion //#region src/lint/plugin-core/rules/required-typography-properties.ts const REQUIRED_TYPOGRAPHY_PROPERTIES = "core/required-typography-properties"; /** @deprecated Use core/valid-typography instead */ const rule$16 = { meta: { docs: { description: "Enforce typography tokens have required properties.", url: docsLink(REQUIRED_TYPOGRAPHY_PROPERTIES) } }, defaultOptions: { properties: [ "fontFamily", "fontSize", "fontWeight", "letterSpacing", "lineHeight" ] }, create({ tokens, options, report }) { if (!options) return; if (!options.properties.length) throw new Error(`"properties" can’t be empty`); const shouldIgnore = options.ignore ? cachedLintMatcher.tokenIDMatch(options.ignore) : null; for (const t of Object.values(tokens)) { if (shouldIgnore?.(t.id)) continue; if (t.$type !== "typography") continue; if (t.aliasOf) continue; for (const p of options.properties) if (!t.partialAliasOf?.[p] && !(p in t.$value)) report({ message: `This rule is deprecated. Use core/valid-typography. Missing required typographic property "${p}"`, node: t.source.node, filename: t.source.filename }); } } }; //#endregion //#region src/lint/plugin-core/rules/valid-font-family.ts const VALID_FONT_FAMILY = "core/valid-font-family"; const ERROR$9 = "ERROR"; const rule$15 = { meta: { messages: { [ERROR$9]: "Must be a string, or array of strings." }, docs: { description: "Require fontFamily tokens to follow the format.", url: docsLink(VALID_FONT_FAMILY) } }, defaultOptions: {}, create({ tokens, report }) { for (const t of Object.values(tokens)) { if (t.aliasOf || !t.originalValue) continue; switch (t.$type) { case "fontFamily": validateFontFamily(t.originalValue.$value, { node: getObjMember(t.source.node, "$value"), filename: t.source.filename }); break; case "typography": if (typeof t.originalValue.$value === "object" && t.originalValue.$value.fontFamily) { if (t.partialAliasOf?.fontFamily) continue; const properties = getObjMembers(getObjMember(t.source.node, "$value")); validateFontFamily(t.originalValue.$value.fontFamily, { node: properties.fontFamily, filename: t.source.filename }); } break; } function validateFontFamily(value, { node, filename }) { if (typeof value === "string") { if (!value) report({ messageId: ERROR$9, node, filename }); } else if (Array.isArray(value)) { if (!value.every((v) => v && typeof v === "string")) report({ messageId: ERROR$9, node, filename }); } else report({ messageId: ERROR$9, node, filename }); } } } }; //#endregion //#region src/lint/plugin-core/rules/valid-font-weight.ts const VALID_FONT_WEIGHT = "core/valid-font-weight"; const ERROR$8 = "ERROR"; const ERROR_STYLE = "ERROR_STYLE"; const rule$14 = { meta: { messages: { [ERROR$8]: `Must either be a valid number (0 - 999) or a valid font weight: ${new Intl.ListFormat("en-us", { type: "disjunction" }).format(Object.keys(FONT_WEIGHTS))}.`, [ERROR_STYLE]: "Expected style {{ style }}, received {{ value }}." }, docs: { description: "Require number tokens to follow the format.", url: docsLink(VALID_FONT_WEIGHT) } }, defaultOptions: { style: void 0 }, create({ tokens, options, report }) { for (const t of Object.values(tokens)) { if (t.aliasOf || !t.originalValue) continue; switch (t.$type) { case "fontWeight": validateFontWeight(t.originalValue.$value, { node: getObjMember(t.source.node, "$value"), filename: t.source.filename }); break; case "typography": if (typeof t.originalValue.$value === "object" && t.originalValue.$value.fontWeight) { if (t.partialAliasOf?.fontWeight) continue; const properties = getObjMembers(getObjMember(t.source.node, "$value")); validateFontWeight(t.originalValue.$value.fontWeight, { node: properties.fontWeight, filename: t.source.filename }); } break; } function validateFontWeight(value, { node, filename }) { if (typeof value === "string") { if (options.style === "numbers") report({ messageId: ERROR_STYLE, data: { style: "numbers", value }, node, filename }); else if (!(value in FONT_WEIGHTS)) report({ messageId: ERROR$8, node, filename }); } else if (typeof value === "number") { if (options.style === "names") report({ messageId: ERROR_STYLE, data: { style: "names", value }, node, filename }); else if (!(value >= 0 && value < 1e3)) report({ messageId: ERROR$8, node, filename }); } else report({ messageId: ERROR$8, node, filename }); } } } }; //#endregion //#region src/lint/plugin-core/rules/valid-gradient.ts const VALID_GRADIENT = "core/valid-gradient"; const ERROR_MISSING$1 = "ERROR_MISSING"; const ERROR_POSITION = "ERROR_POSITION"; const ERROR_INVALID_PROP$7 = "ERROR_INVALID_PROP"; const rule$13 = { meta: { messages: { [ERROR_MISSING$1]: "Must be an array of { color, position } objects.", [ERROR_POSITION]: "Expected number 0-1, received {{ value }}.", [ERROR_INVALID_PROP$7]: "Unknown property {{ key }}." }, docs: { description: "Require gradient tokens to follow the format.", url: docsLink(VALID_GRADIENT) } }, defaultOptions: {}, create({ tokens, report }) { for (const t of Object.values(tokens)) { if (t.aliasOf || !t.originalValue || t.$type !== "gradient") continue; validateGradient(t.originalValue.$value, { node: getObjMember(t.source.node, "$value"), filename: t.source.filename }); function validateGradient(value, { node, filename }) { if (Array.isArray(value)) for (let i = 0; i < value.length; i++) { const stop = value[i]; if (!stop || typeof stop !== "object") { report({ messageId: ERROR_MISSING$1, node, filename }); continue; } for (const property of GRADIENT_REQUIRED_STOP_PROPERTIES) if (!(property in stop)) report({ messageId: ERROR_MISSING$1, node: node.elements[i], filename }); for (const key of Object.keys(stop)) if (!GRADIENT_REQUIRED_STOP_PROPERTIES.includes(key)) report({ messageId: ERROR_INVALID_PROP$7, data: { key: JSON.stringify(key) }, node: node.elements[i], filename }); if ("position" in stop && typeof stop.position !== "number" && !isAlias(stop.position)) report({ messageId: ERROR_POSITION, data: { value: stop.position }, node: getObjMember(node.elements[i].value, "position"), filename }); } else report({ messageId: ERROR_MISSING$1, node, filename }); } } } }; //#endregion //#region src/lint/plugin-core/rules/valid-link.ts const VALID_LINK = "core/valid-link"; const ERROR$7 = "ERROR"; const rule$12 = { meta: { messages: { [ERROR$7]: "Must be a string." }, docs: { description: "Require link tokens to follow the Terrazzo extension.", url: docsLink(VALID_LINK) } }, defaultOptions: {}, create({ tokens, report }) { for (const t of Object.values(tokens)) { if (t.aliasOf || !t.originalValue || t.$type !== "link") continue; validateLink(t.originalValue.$value, { node: getObjMember(t.source.node, "$value"), filename: t.source.filename }); function validateLink(value, { node, filename }) { if (!value || typeof value !== "string") report({ messageId: ERROR$7, node, filename }); } } } }; //#endregion //#region src/lint/plugin-core/rules/valid-number.ts const VALID_NUMBER = "core/valid-number"; const ERROR_NAN = "ERROR_NAN"; const rule$11 = { meta: { messages: { [ERROR_NAN]: "Must be a number." }, docs: { description: "Require number tokens to follow the format.", url: docsLink(VALID_NUMBER) } }, defaultOptions: {}, create({ tokens, report }) { for (const t of Object.values(tokens)) { if (t.aliasOf || !t.originalValue) continue; switch (t.$type) { case "number": validateNumber(t.originalValue.$value, { node: getObjMember(t.source.node, "$value"), filename: t.source.filename }); break; case "typography": { const $valueNode = getObjMember(t.source.node, "$value"); if (typeof t.originalValue.$value === "object") { if (t.originalValue.$value.lineHeight && !isAlias(t.originalValue.$value.lineHeight) && typeof t.originalValue.$value.lineHeight !== "object") validateNumber(t.originalValue.$value.lineHeight, { node: getObjMember($valueNode, "lineHeight"), filename: t.source.filename }); } } } function validateNumber(value, { node, filename }) { if (typeof value !== "number" || Number.isNaN(value)) report({ messageId: ERROR_NAN, node, filename }); } } } }; //#endregion //#region src/lint/plugin-core/rules/valid-shadow.ts const VALID_SHADOW = "core/valid-shadow"; const ERROR$6 = "ERROR"; const ERROR_INVALID_PROP$6 = "ERROR_INVALID_PROP"; const rule$10 = { meta: { messages: { [ERROR$6]: `Missing required properties: ${new Intl.ListFormat("en-us", { type: "conjunction" }).format(SHADOW_REQUIRED_PROPERTIES)}.`, [ERROR_INVALID_PROP$6]: "Unknown property {{ key }}." }, docs: { description: "Require shadow tokens to follow the format.", url: docsLink(VALID_SHADOW) } }, defaultOptions: {}, create({ tokens, report }) { for (const t of Object.values(tokens)) { if (t.aliasOf || !t.originalValue || t.$type !== "shadow") continue; validateShadow(t.originalValue.$value, { node: getObjMember(t.source.node, "$value"), filename: t.source.filename }); function validateShadow(value, { node, filename }) { const wrappedValue = Array.isArray(value) ? value : [value]; for (let i = 0; i < wrappedValue.length; i++) if (!wrappedValue[i] || typeof wrappedValue[i] !== "object" || !SHADOW_REQUIRED_PROPERTIES.every((property) => property in wrappedValue[i])) report({ messageId: ERROR$6, node, filename }); else for (const key of Object.keys(wrappedValue[i])) if (![...SHADOW_REQUIRED_PROPERTIES, "inset"].includes(key)) report({ messageId: ERROR_INVALID_PROP$6, data: { key: JSON.stringify(key) }, node: getObjMember(node.type === "Array" ? node.elements[i].value : node, key), filename }); } } } }; //#endregion //#region src/lint/plugin-core/rules/valid-string.ts const VALID_STRING = "core/valid-string"; const ERROR$5 = "ERROR"; const rule$9 = { meta: { messages: { [ERROR$5]: "Must be a string." }, docs: { description: "Require string tokens to follow the Terrazzo extension.", url: docsLink(VALID_STRING) } }, defaultOptions: {}, create({ tokens, report }) { for (const t of Object.values(tokens)) { if (t.aliasOf || !t.originalValue || t.$type !== "string") continue; validateString(t.originalValue.$value, { node: getObjMember(t.source.node, "$value"), filename: t.source.filename }); function validateString(value, { node, filename }) { if (typeof value !== "string") report({ messageId: ERROR$5, node, filename }); } } } }; //#endregion //#region src/lint/plugin-core/rules/valid-stroke-style.ts const VALID_STROKE_STYLE = "core/valid-stroke-style"; const ERROR_STR = "ERROR_STR"; const ERROR_OBJ = "ERROR_OBJ"; const ERROR_LINE_CAP = "ERROR_LINE_CAP"; const ERROR_INVALID_PROP$5 = "ERROR_INVALID_PROP"; const rule$8 = { meta: { messages: { [ERROR_STR]: `Value most be one of ${new Intl.ListFormat("en-us", { type: "disjunction" }).format(STROKE_STYLE_STRING_VALUES)}.`, [ERROR_OBJ]: `Missing required properties: ${new Intl.ListFormat("en-us", { type: "conjunction" }).format(TRANSITION_REQUIRED_PROPERTIES)}.`, [ERROR_LINE_CAP]: `lineCap must be one of ${new Intl.ListFormat("en-us", { type: "disjunction" }).format(STROKE_STYLE_LINE_CAP_VALUES)}.`, [ERROR_INVALID_PROP$5]: "Unknown property: {{ key }}." }, docs: { description: "Require strokeStyle tokens to follow the format.", url: docsLink(VALID_STROKE_STYLE) } }, defaultOptions: {}, create({ tokens, report }) { for (const t of Object.values(tokens)) { if (t.aliasOf || !t.originalValue) continue; switch (t.$type) { case "strokeStyle": validateStrokeStyle(t.originalValue.$value, { node: getObjMember(t.source.node, "$value"), filename: t.source.filename }); break; case "border": if (t.originalValue.$value && typeof t.originalValue.$value === "object") { const $valueNode = getObjMember(t.source.node, "$value"); if (t.originalValue.$value.style) validateStrokeStyle(t.originalValue.$value.style, { node: getObjMember($valueNode, "style"), filename: t.source.filename }); } break; } function validateStrokeStyle(value, { node, filename }) { if (typeof value === "string") { if (!isAlias(value) && !STROKE_STYLE_STRING_VALUES.includes(value)) { report({ messageId: ERROR_STR, node, filename }); return; } } else if (value && typeof value === "object") { if (!STROKE_STYLE_OBJECT_REQUIRED_PROPERTIES.every((property) => property in value)) report({ messageId: ERROR_OBJ, node, filename }); if (!Array.isArray(value.dashArray)) report({ messageId: ERROR_OBJ, node: getObjMember(node, "dashArray"), filename }); if (!STROKE_STYLE_LINE_CAP_VALUES.includes(value.lineCap)) report({ messageId: ERROR_OBJ, node: getObjMember(node, "lineCap"), filename }); for (const key of Object.keys(value)) if (!["dashArray", "lineCap"].includes(key)) report({ messageId: ERROR_INVALID_PROP$5, data: { key: JSON.stringify(key) }, node: getObjMember(node, key), filename }); } else report({ messageId: ERROR_OBJ, node, filename }); } } } }; //#endregion //#region src/lint/plugin-core/rules/valid-transition.ts const VALID_TRANSITION = "core/valid-transition"; const ERROR$4 = "ERROR"; const ERROR_INVALID_PROP$4 = "ERROR_INVALID_PROP"; const rule$7 = { meta: { messages: { [ERROR$4]: `Missing required properties: ${new Intl.ListFormat("en-us", { type: "conjunction" }).format(TRANSITION_REQUIRED_PROPERTIES)}.`, [ERROR_INVALID_PROP$4]: "Unknown property: {{ key }}." }, docs: { description: "Require transition tokens to follow the format.", url: docsLink(VALID_TRANSITION) } }, defaultOptions: {}, create({ tokens, report }) { for (const t of Object.values(tokens)) { if (t.aliasOf || !t.originalValue || t.$type !== "transition") continue; validateTransition(t.originalValue.$value, { node: getObjMember(t.source.node, "$value"), filename: t.source.filename }); } function validateTransition(value, { node, filename }) { if (!value || typeof value !== "object" || !TRANSITION_REQUIRED_PROPERTIES.every((property) => property in value)) report({ messageId: ERROR$4, node, filename }); else for (const key of Object.keys(value)) if (!TRANSITION_REQUIRED_PROPERTIES.includes(key)) report({ messageId: ERROR_INVALID_PROP$4, data: { key: JSON.stringify(key) }, node: getObjMember(node, key), filename }); } } }; //#endregion //#region src/lint/plugin-core/rules/valid-typography.ts const VALID_TYPOGRAPHY = "core/valid-typography"; const ERROR$3 = "ERROR"; const ERROR_MISSING = "ERROR_MISSING"; const rule$6 = { meta: { messages: { [ERROR$3]: `Expected object, received {{ value }}.`, [ERROR_MISSING]: `Missing required property "{{ property }}".` }, docs: { description: "Require typography tokens to follow the format.", url: docsLink(VALID_TYPOGRAPHY) } }, defaultOptions: { requiredProperties: [ "fontFamily", "fontSize", "fontWeight", "letterSpacing", "lineHeight" ] }, create({ tokens, options, report }) { const isIgnored = options.ignore ? cachedLintMatcher.tokenIDMatch(options.ignore) : () => false; for (const t of Object.values(tokens)) { if (t.aliasOf || !t.originalValue || t.$type !== "typography" || isIgnored(t.id)) continue; validateTypography(t.originalValue.$value, { node: getObjMember(t.source.node, "$value"), filename: t.source.filename }); function validateTypography(value, { node, filename }) { if (value && typeof value === "object") { for (const property of options.requiredProperties) if (!(property in value)) report({ messageId: ERROR_MISSING, data: { property }, node, filename }); } else report({ messageId: ERROR$3, data: { value: JSON.stringify(value) }, node, filename }); } } } }; //#endregion //#region src/lint/plugin-core/rules/valid-boolean.ts const VALID_BOOLEAN = "core/valid-boolean"; const ERROR$2 = "ERROR"; const rule$5 = { meta: { messages: { [ERROR$2]: "Must be a boolean." }, docs: { description: "Require boolean tokens to follow the Terrazzo extension.", url: docsLink(VALID_BOOLEAN) } }, defaultOptions: {}, create({ tokens, report }) { for (const t of Object.values(tokens)) { if (t.aliasOf || !t.originalValue || t.$type !== "boolean") continue; validateBoolean(t.originalValue.$value, { node: getObjMember(t.source.node, "$value"), filename