UNPKG

@terrazzo/parser

Version:

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

899 lines (843 loc) 28.7 kB
import { type AnyNode, type MemberNode, type ObjectNode, type StringNode, type ValueNode, evaluate, print, } from '@humanwhocodes/momoa'; import { type Token, type TokenNormalized, isAlias, isTokenMatch, splitID } from '@terrazzo/token-tools'; import type Logger from '../logger.js'; import type { ConfigInit } from '../types.js'; import { getObjMembers, injectObjMembers } from './json.js'; const listFormat = new Intl.ListFormat('en-us', { type: 'disjunction' }); export interface ValidateOptions { filename?: URL; src: string; logger: Logger; } export const VALID_COLORSPACES = new Set([ 'adobe-rgb', 'display-p3', 'hsl', 'hwb', 'lab', 'lch', 'oklab', 'oklch', 'prophoto', 'rec2020', 'srgb', 'srgb-linear', 'xyz', 'xyz-d50', 'xyz-d65', ]); export const FONT_WEIGHT_VALUES = new Set([ 'thin', 'hairline', 'extra-light', 'ultra-light', 'light', 'normal', 'regular', 'book', 'medium', 'semi-bold', 'demi-bold', 'bold', 'extra-bold', 'ultra-bold', 'black', 'heavy', 'extra-black', 'ultra-black', ]); export const STROKE_STYLE_VALUES = new Set([ 'solid', 'dashed', 'dotted', 'double', 'groove', 'ridge', 'outset', 'inset', ]); export const STROKE_STYLE_LINE_CAP_VALUES = new Set(['round', 'butt', 'square']); /** Distinct from isAlias() in that this accepts malformed aliases */ function isMaybeAlias(node: AnyNode) { if (node?.type === 'String') { return node.value.startsWith('{'); } return false; } /** Assert object members match given types */ function validateMembersAs( $value: ObjectNode, properties: Record<string, { validator: typeof validateAliasSyntax; required?: boolean }>, node: AnyNode, { filename, src, logger }: ValidateOptions, ) { const members = getObjMembers($value); for (const [name, value] of Object.entries(properties)) { const { validator, required } = value; if (!members[name]) { if (required) { logger.error({ group: 'parser', label: 'validate', message: `Missing required property "${name}"`, filename, node: $value, src, }); } continue; } const memberValue = members[name]; if (isMaybeAlias(memberValue)) { validateAliasSyntax(memberValue, node, { filename, src, logger }); } else { validator(memberValue, node, { filename, src, logger }); } } } /** Verify an Alias $value is formatted correctly */ export function validateAliasSyntax($value: ValueNode, _node: AnyNode, { filename, src, logger }: ValidateOptions) { if ($value.type !== 'String' || !isAlias($value.value)) { logger.error({ group: 'parser', label: 'validate', message: `Invalid alias: ${print($value)}`, filename, node: $value, src, }); } } /** Verify a Border token is valid */ export function validateBorder($value: ValueNode, node: AnyNode, { filename, src, logger }: ValidateOptions) { if ($value.type !== 'Object') { logger.error({ group: 'parser', label: 'validate', message: `Expected object, received ${$value.type}`, filename, node: $value, src, }); } else { validateMembersAs( $value, { color: { validator: validateColor, required: true }, style: { validator: validateStrokeStyle, required: true }, width: { validator: validateDimension, required: true }, }, node, { filename, src, logger }, ); } } /** Verify a Color token is valid */ export function validateColor($value: ValueNode, node: AnyNode, { filename, src, logger }: ValidateOptions) { const baseMessage = { group: 'parser' as const, label: 'validate', filename, node: $value, src }; if ($value.type === 'String') { // TODO: enable when object notation is finalized // logger.warn({ // filename, // message: 'String colors are no longer recommended; please use the object notation instead.', // node: $value, // src, // }); if ($value.value === '') { logger.error({ ...baseMessage, message: 'Expected color, received empty string' }); } } else if ($value.type === 'Object') { // allow "channels" but raise warning. also rename as a workaround (mutating the AST is a bad idea in general, but this is safe) const channelMemberI = $value.members.findIndex((m) => m.name.type === 'String' && m.name.value === 'channels'); if (channelMemberI !== -1) { logger.warn({ ...baseMessage, message: '"channels" is deprecated; rename "channels" to "components"' }); ($value.members[channelMemberI]!.name as StringNode).value = 'components'; } validateMembersAs( $value, { colorSpace: { validator: (v) => { if (v.type !== 'String') { logger.error({ ...baseMessage, message: `Expected string, received ${print(v)}`, node: v, }); } if (!VALID_COLORSPACES.has((v as StringNode).value)) { logger.error({ ...baseMessage, message: `Unsupported colorspace ${print(v)}`, node: v, }); } }, required: true, }, components: { validator: (v) => { if (v.type !== 'Array') { logger.error({ ...baseMessage, message: `Expected array, received ${print(v)}`, node: v, }); } else { // note: in the future, length will change depending on colorSpace, e.g. CMYK // but in the current spec it’s 3 for now. if (v.elements?.length !== 3) { logger.error({ ...baseMessage, message: `Expected 3 components, received ${v.elements?.length ?? 0}`, node: v, }); } for (const element of v.elements) { if (element.value.type !== 'Number') { logger.error({ ...baseMessage, message: `Expected number, received ${print(element.value)}`, node: element, }); } } } }, required: true, }, hex: { validator: (v) => { if ( v.type !== 'String' || // this is a weird one—with the RegEx we test, it will work for // lengths of 3, 4, 6, and 8 (but not 5 or 7). So we check length // here, to keep the RegEx simple and readable. The "+ 1" is just // accounting for the '#' prefix character. v.value.length === 5 + 1 || v.value.length === 7 + 1 || !/^#[a-f0-9]{3,8}$/i.test(v.value) ) { logger.error({ ...baseMessage, message: `Invalid hex color ${print(v)}`, node: v, }); } }, }, alpha: { validator: validateNumber }, }, node, { filename, src, logger }, ); } else { logger.error({ ...baseMessage, message: `Expected object, received ${$value.type}`, node: $value, }); } } /** Verify a Cubic Bézier token is valid */ export function validateCubicBezier($value: ValueNode, _node: AnyNode, { filename, src, logger }: ValidateOptions) { const baseMessage = { group: 'parser' as const, label: 'validate', filename, node: $value, src }; if ($value.type !== 'Array') { logger.error({ ...baseMessage, message: `Expected array of numbers, received ${print($value)}` }); } else if (!$value.elements.every((e) => e.value.type === 'Number')) { logger.error({ ...baseMessage, message: 'Expected an array of 4 numbers, received some non-numbers' }); } else if ($value.elements.length !== 4) { logger.error({ ...baseMessage, message: `Expected an array of 4 numbers, received ${$value.elements.length}` }); } } /** Verify a Dimension token is valid */ export function validateDimension($value: ValueNode, _node: AnyNode, { filename, src, logger }: ValidateOptions) { if ($value.type === 'Number' && $value.value === 0) { return; // `0` is a valid number } const baseMessage = { group: 'parser' as const, label: 'validate', filename, node: $value, src }; // Give priority to object notation because it’s a faster code path if ($value.type === 'Object') { const { value, unit } = getObjMembers($value); if (!value) { logger.error({ ...baseMessage, message: 'Missing required property "value".' }); } if (!unit) { logger.error({ ...baseMessage, message: 'Missing required property "unit".' }); } if (value!.type !== 'Number') { logger.error({ ...baseMessage, message: `Expected number, received ${value!.type}`, node: value, }); } if (!['px', 'em', 'rem'].includes((unit as StringNode).value)) { logger.error({ ...baseMessage, message: `Expected unit "px", "em", or "rem", received ${print(unit as StringNode)}`, node: unit, }); } return; } // Backwards compat: string if ($value.type !== 'String') { logger.error({ ...baseMessage, message: `Expected string, received ${$value.type}` }); } const value = ($value as StringNode).value.match(/^-?[0-9.]+/)?.[0]; const unit = ($value as StringNode).value.replace(value!, ''); if (($value as StringNode).value === '') { logger.error({ ...baseMessage, message: 'Expected dimension, received empty string' }); } else if (!['px', 'em', 'rem'].includes(unit)) { logger.error({ ...baseMessage, message: `Expected unit "px", "em", or "rem", received ${JSON.stringify(unit || ($value as StringNode).value)}`, }); } else if (!Number.isFinite(Number.parseFloat(value!))) { logger.error({ ...baseMessage, message: `Expected dimension with units, received ${print($value)}` }); } } /** Verify a Duration token is valid */ export function validateDuration($value: ValueNode, _node: AnyNode, { filename, src, logger }: ValidateOptions) { if ($value.type === 'Number' && $value.value === 0) { return; // `0` is a valid number } const baseMessage = { group: 'parser' as const, label: 'validate', filename, node: $value, src }; // Give priority to object notation because it’s a faster code path if ($value.type === 'Object') { const { value, unit } = getObjMembers($value); if (!value) { logger.error({ ...baseMessage, message: 'Missing required property "value".' }); } if (!unit) { logger.error({ ...baseMessage, message: 'Missing required property "unit".' }); } if (value?.type !== 'Number') { logger.error({ ...baseMessage, message: `Expected number, received ${value?.type}`, node: value, }); } if (!['ms', 's'].includes((unit as StringNode).value)) { logger.error({ ...baseMessage, message: `Expected unit "ms" or "s", received ${print(unit!)}`, node: unit, }); } return; } // Backwards compat: string if ($value.type !== 'String') { logger.error({ ...baseMessage, message: `Expected string, received ${$value.type}` }); } const value = ($value as StringNode).value.match(/^-?[0-9.]+/)?.[0]!; const unit = ($value as StringNode).value.replace(value, ''); if (($value as StringNode).value === '') { logger.error({ ...baseMessage, message: 'Expected duration, received empty string' }); } else if (!['ms', 's'].includes(unit)) { logger.error({ ...baseMessage, message: `Expected unit "ms" or "s", received ${JSON.stringify(unit || ($value as StringNode).value)}`, }); } else if (!Number.isFinite(Number.parseFloat(value))) { logger.error({ ...baseMessage, message: `Expected duration with units, received ${print($value)}` }); } } /** Verify a Font Family token is valid */ export function validateFontFamily($value: ValueNode, _node: AnyNode, { filename, src, logger }: ValidateOptions) { const baseMessage = { group: 'parser' as const, label: 'validate', filename, node: $value, src }; if ($value.type !== 'String' && $value.type !== 'Array') { logger.error({ ...baseMessage, message: `Expected string or array of strings, received ${$value.type}` }); } if ($value.type === 'String' && $value.value === '') { logger.error({ ...baseMessage, message: 'Expected font family name, received empty string' }); } if ($value.type === 'Array' && !$value.elements.every((e) => e.value.type === 'String' && e.value.value !== '')) { logger.error({ ...baseMessage, message: 'Expected an array of strings, received some non-strings or empty strings', }); } } /** Verify a Font Weight token is valid */ export function validateFontWeight($value: ValueNode, _node: AnyNode, { filename, src, logger }: ValidateOptions) { const baseMessage = { group: 'parser' as const, label: 'validate', filename, node: $value, src }; if ($value.type !== 'String' && $value.type !== 'Number') { logger.error({ ...baseMessage, message: `Expected a font weight name or number 01000, received ${$value.type}` }); } if ($value.type === 'String' && !FONT_WEIGHT_VALUES.has($value.value)) { logger.error({ ...baseMessage, message: `Unknown font weight ${print($value)}. Expected one of: ${listFormat.format([...FONT_WEIGHT_VALUES])}.`, }); } if ($value.type === 'Number' && ($value.value < 0 || $value.value > 1000)) { logger.error({ ...baseMessage, message: `Expected number 01000, received ${print($value)}` }); } } /** Verify a Gradient token is valid */ export function validateGradient($value: ValueNode, _node: AnyNode, { filename, src, logger }: ValidateOptions) { const baseMessage = { group: 'parser' as const, label: 'validate', filename, node: $value, src }; if ($value.type !== 'Array') { logger.error({ ...baseMessage, message: `Expected array of gradient stops, received ${$value.type}` }); } else { for (let i = 0; i < $value.elements.length; i++) { const element = $value.elements[i]!; if (element.value.type !== 'Object') { logger.error({ ...baseMessage, message: `Stop #${i + 1}: Expected gradient stop, received ${element.value.type}`, node: element, }); break; } validateMembersAs( element.value, { color: { validator: validateColor, required: true }, position: { validator: validateNumber, required: true }, }, element, { filename, src, logger }, ); } } } /** Verify a Number token is valid */ export function validateNumber($value: ValueNode, _node: AnyNode, { filename, src, logger }: ValidateOptions) { if ($value.type !== 'Number') { logger.error({ group: 'parser', label: 'validate', message: `Expected number, received ${$value.type}`, filename, node: $value, src, }); } } /** Verify a Boolean token is valid */ export function validateBoolean($value: ValueNode, _node: AnyNode, { filename, src, logger }: ValidateOptions) { if ($value.type !== 'Boolean') { logger.error({ group: 'parser', label: 'validate', message: `Expected boolean, received ${$value.type}`, filename, node: $value, src, }); } } /** Verify a Shadow token’s value is valid */ export function validateShadowLayer($value: ValueNode, node: AnyNode, { filename, src, logger }: ValidateOptions) { if ($value.type !== 'Object') { logger.error({ group: 'parser', label: 'validate', message: `Expected Object, received ${$value.type}`, filename, node: $value, src, }); } else { validateMembersAs( $value, { color: { validator: validateColor, required: true }, offsetX: { validator: validateDimension, required: true }, offsetY: { validator: validateDimension, required: true }, blur: { validator: validateDimension }, spread: { validator: validateDimension }, inset: { validator: validateBoolean }, }, node, { filename, src, logger }, ); } } /** Verify a Stroke Style token is valid. */ export function validateStrokeStyle($value: ValueNode, node: AnyNode, { filename, src, logger }: ValidateOptions) { const baseMessage = { group: 'parser' as const, label: 'validate', filename, node: $value, src }; // note: strokeStyle’s values are NOT aliasable (unless by string, but that breaks validations) if ($value.type === 'String') { if (!STROKE_STYLE_VALUES.has($value.value)) { logger.error({ ...baseMessage, message: `Unknown stroke style ${print($value)}. Expected one of: ${listFormat.format([ ...STROKE_STYLE_VALUES, ])}.`, }); } } else if ($value.type === 'Object') { const strokeMembers = getObjMembers($value); for (const property of ['dashArray', 'lineCap']) { if (!strokeMembers[property]) { logger.error({ ...baseMessage, message: `Missing required property "${property}"` }); } } const { lineCap, dashArray } = strokeMembers; if (lineCap?.type !== 'String' || !STROKE_STYLE_LINE_CAP_VALUES.has(lineCap.value)) { logger.error({ ...baseMessage, message: `Unknown lineCap value ${print(lineCap!)}. Expected one of: ${listFormat.format([ ...STROKE_STYLE_LINE_CAP_VALUES, ])}.`, node, }); } if (dashArray?.type === 'Array') { for (const element of dashArray.elements) { if (element.value.type === 'String' && element.value.value !== '') { if (isMaybeAlias(element.value)) { validateAliasSyntax(element.value, node, { logger, src }); } else { validateDimension(element.value, node, { logger, src }); } } else { logger.error({ ...baseMessage, message: 'Expected array of strings, recieved some non-strings or empty strings.', node: element, }); } } } else { logger.error({ ...baseMessage, message: `Expected array of strings, received ${dashArray!.type}` }); } } else { logger.error({ ...baseMessage, message: `Expected string or object, received ${$value.type}` }); } } /** Verify a Transition token is valid */ export function validateTransition($value: ValueNode, node: AnyNode, { filename, src, logger }: ValidateOptions) { if ($value.type !== 'Object') { logger.error({ group: 'parser', label: 'validate', message: `Expected object, received ${$value.type}`, filename, node: $value, src, }); } else { validateMembersAs( $value, { duration: { validator: validateDuration, required: true }, delay: { validator: validateDuration, required: false }, // note: spec says delay is required, but Terrazzo makes delay optional timingFunction: { validator: validateCubicBezier, required: true }, }, node, { filename, src, logger }, ); } } /** * Validate a MemberNode (the entire token object, plus its key in the parent * object) to see if it’s a valid DTCG token or not. Keeping the parent key * really helps in debug messages. */ export function validateTokenMemberNode(node: MemberNode, { filename, src, logger }: ValidateOptions) { const baseMessage = { group: 'parser' as const, label: 'validate', filename, node, src }; if (node.type !== 'Member' && node.type !== 'Object') { logger.error({ ...baseMessage, message: `Expected Object, received ${JSON.stringify( // @ts-ignore Yes, TypeScript, this SHOULD be unexpected. This is why we’re validating. node.type, )}`, }); } const rootMembers = node.value.type === 'Object' ? getObjMembers(node.value) : {}; const $value = rootMembers.$value as ValueNode; const $type = rootMembers.$type as StringNode; if (!$value) { logger.error({ ...baseMessage, message: 'Token missing $value' }); } // If top-level value is a valid alias, this is valid (no need for $type) // ⚠️ Important: ALL Object and Array nodes below will need to check for aliases within! if (isMaybeAlias($value)) { validateAliasSyntax($value, node, { logger, src }); return; } if (!$type) { logger.error({ ...baseMessage, message: 'Token missing $type' }); } switch ($type.value) { case 'color': { validateColor($value, node, { logger, src }); break; } case 'cubicBezier': { validateCubicBezier($value, node, { logger, src }); break; } case 'dimension': { validateDimension($value, node, { logger, src }); break; } case 'duration': { validateDuration($value, node, { logger, src }); break; } case 'fontFamily': { validateFontFamily($value, node, { logger, src }); break; } case 'fontWeight': { validateFontWeight($value, node, { logger, src }); break; } case 'number': { validateNumber($value, node, { logger, src }); break; } case 'shadow': { if ($value.type === 'Object') { validateShadowLayer($value, node, { logger, src }); } else if ($value.type === 'Array') { for (const element of $value.elements) { validateShadowLayer(element.value, $value, { logger, src }); } } else { logger.error({ ...baseMessage, message: `Expected shadow object or array of shadow objects, received ${$value.type}`, node: $value, }); } break; } // extensions case 'boolean': { if ($value.type !== 'Boolean') { logger.error({ ...baseMessage, message: `Expected boolean, received ${$value.type}`, node: $value, }); } break; } case 'link': { if ($value.type !== 'String') { logger.error({ ...baseMessage, message: `Expected string, received ${$value.type}`, node: $value, }); } else if ($value.value === '') { logger.error({ ...baseMessage, message: 'Expected URL, received empty string', node: $value, }); } break; } case 'string': { if ($value.type !== 'String') { logger.error({ ...baseMessage, message: `Expected string, received ${$value.type}`, node: $value, }); } break; } // composite types case 'border': { validateBorder($value, node, { filename, src, logger }); break; } case 'gradient': { validateGradient($value, node, { filename, src, logger }); break; } case 'strokeStyle': { validateStrokeStyle($value, node, { filename, src, logger }); break; } case 'transition': { validateTransition($value, node, { filename, src, logger }); break; } case 'typography': { if ($value.type !== 'Object') { logger.error({ ...baseMessage, message: `Expected object, received ${$value.type}`, node: $value, }); break; } if ($value.members.length === 0) { logger.error({ ...baseMessage, message: 'Empty typography token. Must contain at least 1 property.', node: $value, }); } validateMembersAs( $value, { fontFamily: { validator: validateFontFamily }, fontWeight: { validator: validateFontWeight }, }, node, { filename, src, logger }, ); break; } default: { // noop break; } } } export interface ValidateTokenNodeOptions { subpath: string[]; src: string; filename: URL; config: ConfigInit; logger: Logger; parent?: AnyNode; $typeInheritance?: Record<string, Token['$type']>; } /** * Validate does a little more than validate; it also converts to TokenNormalized * and sets up the basic data structure. But aliases are unresolved, and we need * a 2nd normalization pass afterward. */ export default function validateTokenNode( node: MemberNode, { config, filename, logger, parent, src, subpath, $typeInheritance }: ValidateTokenNodeOptions, ): TokenNormalized | undefined { // const start = performance.now(); // don’t validate $value if (subpath.includes('$value') || node.value.type !== 'Object') { return; } const members = getObjMembers(node.value); // keep track of $types if ($typeInheritance && members.$type && members.$type.type === 'String' && !members.$value) { // @ts-ignore $typeInheritance[subpath.join('.') || '.'] = node.value.members.find((m) => m.name.value === '$type'); } // don’t validate $extensions or $defs if (!members.$value || subpath.includes('$extensions') || subpath.includes('$deps')) { return; } const id = subpath.join('.'); if (!subpath.includes('.$value') && members.value) { logger.warn({ group: 'parser', label: 'validate', message: `Group ${id} has "value". Did you mean "$value"?`, filename, node, src, }); } const extensions = members.$extensions ? getObjMembers(members.$extensions as ObjectNode) : undefined; const sourceNode = structuredClone(node); // get parent type by taking the closest-scoped $type (length === closer) let parent$type: Token['$type'] | undefined; let longestPath = ''; for (const [k, v] of Object.entries($typeInheritance ?? {})) { if (k === '.' || id.startsWith(k)) { if (k.length > longestPath.length) { parent$type = v; longestPath = k; } } } if (parent$type && !members.$type) { injectObjMembers( // @ts-ignore sourceNode.value, [parent$type], ); } validateTokenMemberNode(sourceNode, { filename, src, logger }); // All tokens must be valid, so we want to validate it up till this // point. However, if we are ignoring this token (or respecting // $deprecated, we can omit it from the output. const $deprecated = members.$deprecated && (evaluate(members.$deprecated) as string | boolean | undefined); if ((config.ignore.deprecated && $deprecated) || (config.ignore.tokens && isTokenMatch(id, config.ignore.tokens))) { return; } const group: TokenNormalized['group'] = { id: splitID(id).group!, tokens: [] }; if (parent$type) { group.$type = // @ts-ignore parent$type.value.value; } // note: this will also include sibling tokens, so be selective about only accessing group-specific properties const groupMembers = getObjMembers( // @ts-ignore parent, ); if (groupMembers.$description) { group.$description = evaluate(groupMembers.$description) as string; } if (groupMembers.$extensions) { group.$extensions = evaluate(groupMembers.$extensions) as Record<string, unknown>; } const token: TokenNormalized = { // @ts-ignore $type: members.$type?.value ?? parent$type?.value.value, // @ts-ignore $value: evaluate(members.$value), id, // @ts-ignore mode: {}, // @ts-ignore originalValue: evaluate(node.value), group, source: { loc: filename ? filename.href : undefined, // @ts-ignore node: sourceNode.value, }, }; // @ts-ignore if (members.$description?.value) { // @ts-ignore token.$description = members.$description.value; } // handle modes // note that circular refs are avoided here, such as not duplicating `modes` const modeValues = extensions?.mode ? getObjMembers(extensions.mode as any) : {}; for (const mode of ['.', ...Object.keys(modeValues)]) { const modeValue = mode === '.' ? token.$value : (evaluate((modeValues as any)[mode]) as any); token.mode[mode] = { $value: modeValue, originalValue: modeValue, source: { loc: filename ? filename.href : undefined, // @ts-ignore node: modeValues[mode], }, }; } // logger.debug({ // message: `${token.id}: validateTokenNode`, // group: 'parser', label: 'validate', // label: 'validate', // timing: performance.now() - start, // }); return token; }