zeplin-extension-style-kit
Version:
Models and utilities to generate CSS-like style code in Zeplin extensions.
533 lines (452 loc) • 17 kB
text/typescript
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
};