UNPKG

zeplin-extension-style-kit

Version:

Models and utilities to generate CSS-like style code in Zeplin extensions.

533 lines (452 loc) 17 kB
import { ColorFormat, ContextParams, NamingScheme, StyleDeclaration } from "./common.js"; import { Barrel, Color, Context, Layer, Project, Styleguide, TextStyle, Variable, VariableCollection, VariableMode, VariableValue } from "@zeplin/extension-model"; import { FontFace } from "./elements/fontFace.js"; import { OPTION_NAMES } from "./constants.js"; import cssEscape from "css.escape"; const TEXT_RELATED_TAGS = new Set([ "a", "abbr", "address", "article", "aside", "b", "bdi", "bdo", "blockquote", "button", "caption", "cite", "code", "data", "dd", "del", "details", "dfn", "dialog", "div", "dt", "em", "figcaption", "footer", "h1", "h2", "h3", "h4", "h5", "h6", "header", "i", "input", "ins", "kbd", "label", "legend", "li", "link", "main", "mark", "meter", "nav", "option", "output", "p", "pre", "q", "rp", "rt", "ruby", "s", "samp", "section", "slot", "small", "span", "strong", "sub", "summary", "sup", "td", "textarea", "th", "time", "u", "var" ]); const HTML_TAGS = new Set([ ...TEXT_RELATED_TAGS, "audio", "body", "canvas", "datalist", "dl", "embed", "fieldset", "figure", "form", "iframe", "noscript", "ol", "optgroup", "progress", "select", "table", "tbody", "tfoot", "thead", "tr", "ul" ]); const NOT_INHERITED_PROPS = new Set([ "-webkit-background-clip", "background-clip", "-webkit-text-fill-color" ]); /** * Checks if a string is a valid HTML tag * @param str - The string to check * @returns True if the string is a valid HTML tag */ function isHtmlTag(str: string): boolean { return HTML_TAGS.has(str.toLowerCase().trim()); } /** * Checks if a string is a text-related HTML tag * @param str - The string to check * @returns True if the string is a text-related HTML tag */ function isTextRelatedTag(str: string): boolean { return TEXT_RELATED_TAGS.has(str.toLowerCase().trim()); } /** * Checks if a CSS property is inherited by default * @param propName - The CSS property name to check * @returns True if the property is inherited */ function isDeclarationInherited(propName: string): boolean { return !NOT_INHERITED_PROPS.has(propName); } /** * Blends multiple colors together * @param colors - Array of Color objects to blend * @returns A new Color representing the blend */ function blendColors(colors: Color[]): Color { return colors.reduce((blendedColor, color) => blendedColor.blend(color)); } function lowercaseFirst(s: string): string { return s.charAt(0).toLowerCase() + s.substring(1); } function uppercaseFirst(s: string): string { return s.charAt(0).toUpperCase() + s.substring(1); } function joinTokens(tokens: string[], namingScheme: NamingScheme): string { switch (namingScheme) { case "constant": return tokens.map(val => val.toUpperCase()).join("_"); case "snake": return tokens.join("_"); case "pascal": return tokens.map(uppercaseFirst).join(""); case "camel": return lowercaseFirst(tokens.map(uppercaseFirst).join("")); default: case "kebab": return tokens.join("-"); } } function tokensForString(str: string): string[] { const tokenizer = /\d+|[a-z]+|[A-Z]+(?![a-z])|[A-Z][a-z]+/g; const matchResult = str.match(tokenizer); if (!matchResult) { return ["invalid", "name"]; } return matchResult.map(token => token.toLowerCase()); } function generateVariableName(name: string, namingScheme: NamingScheme): string { return joinTokens(tokensForString(name), namingScheme); } function generateIdentifier(str: string): string { const escapedStr = str.trim() .replace(/[^\s\w-]/g, "") .replace(/^(-?\d+)+/, "") .replace(/\s+/g, "-"); return cssEscape(escapedStr); } /** * Converts a string to a valid CSS selector * @param str - The string to convert * @returns A valid CSS selector */ function selectorize(str: string): string { if (!str) { return ""; } let selectorizedStr = str.trim(); if (isHtmlTag(selectorizedStr)) { return selectorizedStr.toLowerCase(); } if (/^[#.]/.test(selectorizedStr)) { const name = generateIdentifier(selectorizedStr.substr(1)); if (name) { return selectorizedStr[0] + name; } } selectorizedStr = generateIdentifier(selectorizedStr); return selectorizedStr && `.${selectorizedStr}`; } /** * Wraps a style declaration with -webkit- prefix * @param DeclaredClass - The style declaration class to wrap * @returns A new class that applies -webkit- prefixed versions */ function webkit<T extends new (...args: any[]) => StyleDeclaration>(DeclaredClass: T) { return class WebkitDeclaration extends (DeclaredClass as new (...args: any[]) => any) { originalName: string; constructor(...args: ConstructorParameters<T>) { super(...args); this.originalName = super.name; } get name(): string { return `-webkit-${super.name}`; } } as new (...args: ConstructorParameters<T>) => InstanceType<T> & { originalName: string }; } /** * Gets unique text styles from a layer * @param layer - The layer to get text styles from * @returns Array of unique TextStyle objects */ function getUniqueLayerTextStyles(layer: Layer): TextStyle[] { const uniqueTextStyles: TextStyle[] = []; layer.textStyles?.forEach(({ textStyle }) => { const found = uniqueTextStyles.some(textStyle.equals, textStyle); if (found) { return; } uniqueTextStyles.push(textStyle); }); return uniqueTextStyles; } function getFontFaces(textStyles: TextStyle[]): FontFace[] { const textStyleSet: FontFace[] = []; textStyles.forEach(textStyle => { if (textStyleSet.find(ts => ts.font.fontFace === textStyle.fontFace)) { return; } textStyleSet.push(new FontFace(textStyle)); }); return textStyleSet.sort(FontFace.comparator); } type ResourceType = "styleguide" | "project"; type ResourceContainer = { container: Barrel; type: ResourceType }; /** * Gets the resource container (styleguide) from a context * @param context - The context containing an optional styleguide * @returns The styleguide if available, otherwise undefined */ function getResourceContainer( context: Context ): ResourceContainer { if (context.styleguide) { return { container: context.styleguide, type: "styleguide" }; } return { container: context.project!, type: "project" }; } type ArrayBarrelKeys = NonNullable<{ [K in keyof Barrel]: Barrel[K] extends any[] ? K : never; }[keyof Barrel]>; type BarrelKey = Exclude<ArrayBarrelKeys, "linkedStyleguide" | "parent">; function getLinkedResources<T extends Barrel[BarrelKey]> ({ context, resourceFn }: { resourceFn: (b: Barrel) => T; context: Context; }): T { const { container, type } = getResourceContainer(context); let resources = resourceFn(container); let parentBarrel = type === "project" ? container.linkedStyleguide : container.parent; while (parentBarrel) { const linkedResources = resourceFn(parentBarrel); if (linkedResources) { resources = [...resources, ...linkedResources] as T; } parentBarrel = parentBarrel.parent; } return resources; } function getResources<T extends Barrel[BarrelKey]>({ context, useLinkedStyleguides, resourceFn }: { resourceFn: (b: Barrel) => T; context: Context; useLinkedStyleguides: boolean; }): T { if (useLinkedStyleguides) { return getLinkedResources({ context, resourceFn }); } const { container } = getResourceContainer(context); return resourceFn(container); } function getParams(context: Context): ContextParams { const { container } = getResourceContainer(context); return { densityDivisor: container.densityDivisor, useLinkedStyleguides: context.getOption(OPTION_NAMES.USE_LINKED_STYLEGUIDES) as boolean, colorFormat: context.getOption(OPTION_NAMES.COLOR_FORMAT) as ColorFormat, variableNameFormat: context.getOption(OPTION_NAMES.VARIABLE_NAME_FORMAT) as NamingScheme, showDimensions: context.getOption(OPTION_NAMES.SHOW_DIMENSIONS) as boolean, showDefaultValues: context.getOption(OPTION_NAMES.SHOW_DEFAULT_VALUES) as boolean, unitlessLineHeight: context.getOption(OPTION_NAMES.UNITLESS_LINE_HEIGHT) as boolean, useMixin: context.getOption(OPTION_NAMES.MIXIN) as boolean, remPreferences: context.getOption(OPTION_NAMES.USE_REM_UNIT) ? container.remPreferences : undefined, showPaddingMargin: context.getOption(OPTION_NAMES.SHOW_PADDING_MARGIN) as boolean }; } /** * Gets unique items from an array based on a custom equality function * @param array - The array to process * @param equalityFunction - Function to determine item equality * @returns Array of unique items */ function getUniqueFirstItems<T>( array: T[], equalityFunction: (a: T, b: T) => boolean ): T[] { if (!Array.isArray(array)) return []; return array.filter((item, index, self) => index === self.findIndex(other => equalityFunction(item, other)) ); } /** * Parameters for generating a color name resolver */ export interface GenerateColorNameFinderParams { /** The context containing the styleguide */ container: Barrel; /** Whether to include linked styleguides */ useLinkedStyleguides: boolean; /** Function to format variable names */ formatVariableName: (color: Color, options?: { defaultColorStringByFormat?: string }) => string; } /** * Generates a function that resolves color names based on styleguide variables * @param params - Configuration parameters * @returns A function that resolves a color to its variable name */ function generateColorNameResolver( params: GenerateColorNameFinderParams ): (color: Color) => string { const { container, useLinkedStyleguides, formatVariableName } = params; return color => { const matchedColor = (container as Styleguide | Project).findColorEqual(color, useLinkedStyleguides); if (matchedColor) { return formatVariableName(matchedColor); } return ""; }; } /** * Parameters for generating a color name resolver */ export interface GenerateLinkedColorVariableNameFinderParams { /** The context containing the styleguide */ container: Barrel; /** Whether to include linked styleguides */ useLinkedStyleguides: boolean; /** Color format */ colorFormat: ColorFormat; /** Function to format variable names */ formatVariableName: (color: Color, options?: { defaultColorStringByFormat?: string }) => string; } function generateLinkedColorVariableNameResolver(params: GenerateLinkedColorVariableNameFinderParams) { const { container, useLinkedStyleguides, colorFormat, formatVariableName } = params; return (color: Color, shouldDisplayDefaultValue?: boolean): string => { const matchedColor = (container as Project | Styleguide).findLinkedColorVariableEqual(color, useLinkedStyleguides); if (matchedColor) { return formatVariableName(matchedColor, { defaultColorStringByFormat: shouldDisplayDefaultValue ? getColorStringByFormat(color, colorFormat) : undefined }); } return ""; }; } type VariableWithRemote = Variable & { remote: boolean; }; type ColorDetails = { colorValue?: { r: number; g: number; b: number; a?: number; }; isRemote: boolean; }; function getVariableBySourceId(variableCollections: VariableCollection[]) { return variableCollections .flatMap(({ groups, remote }) => groups .flatMap(({ variables }) => variables .flatMap(variable => { return { ...variable, remote }; }))) .reduce((acc, variable) => { acc[variable.sourceId] = variable; return acc; }, {} as Record<string, VariableWithRemote>); } function getModeByModeId(variableCollections: VariableCollection[]) { return variableCollections .flatMap(({ modes }) => modes .flatMap(mode => mode)) .reduce((acc, mode) => { acc[mode.id] = mode; return acc; }, {} as Record<string, VariableMode>); } function getColorDetailsFromVariableValue( variableValue: VariableValue, variableBySourceId: Record<string, VariableWithRemote>, modeByModeId: Record<string, VariableMode> ): ColorDetails | undefined { if (variableValue.type === "color" && variableValue.color) { return { colorValue: variableValue.color, isRemote: false }; } if (!variableValue.variableSourceId) { return; } const modeName = modeByModeId[variableValue.modeId]; const nestedVariable = variableBySourceId[variableValue.variableSourceId]; if (!nestedVariable) { return; } let colorValue; let fallbackColorValue; for (const value of nestedVariable.values) { const nestedValueModeName = modeByModeId[value.modeId]; // TODO: There might be a need to format mode names before comparing them. if (nestedValueModeName && nestedValueModeName.name === modeName.name) { colorValue = getColorDetailsFromVariableValue(value, variableBySourceId, modeByModeId)?.colorValue; } else if (!fallbackColorValue) { fallbackColorValue = getColorDetailsFromVariableValue(value, variableBySourceId, modeByModeId)?.colorValue; } } return { colorValue: colorValue || fallbackColorValue, isRemote: nestedVariable.remote }; } type ColorDetailsByModeName = Record<string, { color: Color; shouldDisplayDefaultValue: boolean }[]>; function setColorObjectsForVariable(variable: Variable, variableBySourceId: Record<string, VariableWithRemote>, modeByModeId: Record<string, VariableMode>, colorDetailsByModeName: ColorDetailsByModeName) { for (const value of variable.values) { const { colorValue, isRemote } = getColorDetailsFromVariableValue( value, variableBySourceId, modeByModeId ) || {}; if (colorValue) { const colorObject = value.generateColorObject(variable, colorValue); if (colorObject) { const mode = modeByModeId[value.modeId]; if (mode) { if (colorDetailsByModeName[mode.name]) { colorDetailsByModeName[mode.name].push({ color: colorObject, shouldDisplayDefaultValue: isRemote! }); } else { colorDetailsByModeName[mode.name] = [{ color: colorObject, shouldDisplayDefaultValue: isRemote! }]; } } } } } } function generateColorDetailsByModeName(variableCollections: VariableCollection[]): ColorDetailsByModeName | undefined { if (!variableCollections) { return; } const colorDetailsByModeName: ColorDetailsByModeName = {}; const variableBySourceId = getVariableBySourceId(variableCollections); const modeByModeId = getModeByModeId(variableCollections); for (const variableCollection of variableCollections) { if (!variableCollection.remote) { for (const group of variableCollection.groups) { for (const variable of group.variables) { setColorObjectsForVariable(variable, variableBySourceId, modeByModeId, colorDetailsByModeName); } } } } return colorDetailsByModeName; } function getColorStringByFormat(color: Color, colorFormat: ColorFormat) { if (!("r" in color && "g" in color && "b" in color && "a" in color)) { return ""; } switch (colorFormat) { case "hex": return color.toHexString(); case "rgb": return color.toRGBAString(); case "hsl": return color.toHSLString(); default: return color.a < 1 ? color.toRGBAString() : color.toHexString(); } } export { blendColors, getUniqueLayerTextStyles, getFontFaces, isHtmlTag, isTextRelatedTag, isDeclarationInherited, selectorize, generateIdentifier, generateVariableName, webkit, getResources, getResourceContainer, getParams, getUniqueFirstItems, generateColorNameResolver, generateLinkedColorVariableNameResolver, generateColorDetailsByModeName, getColorStringByFormat };