UNPKG

@cobalt-ui/plugin-sass

Version:

Generate scss/sass from your design tokens schema (requires @cobalt-ui/cli)

318 lines 14.8 kB
/** * @module @cobalt-ui/plugin-sass * @license MIT License * * Copyright (c) 2021 Drew Powers * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all * copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ import pluginCSS, { _INTERNAL_makeNameGenerator, transformColor, transformCubicBezier, transformDimension, transformDuration, transformFontFamily, transformFontWeight, transformLink, transformNumber, transformStrokeStyle, varRef, } from '@cobalt-ui/plugin-css'; import { indent, isAlias, parseAlias } from '@cobalt-ui/utils'; import { encode, formatFontFamilyNames } from './util.js'; const CAMELCASE_RE = /([^A-Z])([A-Z])/g; const VAR_TOKENS = '__token-values'; const VAR_TYPOGRAPHY = '__token-typography-mixins'; const VAR_ERROR = '__cobalt-error'; const TRAILING_WS_RE = /\s+$/gm; const DEPENDENCIES = ['sass:list', 'sass:map']; const DEFAULT_KEY = '"."'; export default function pluginSass(options) { let config; const ext = options?.indentedSyntax ? '.sass' : '.scss'; const filename = `${options?.filename?.replace(/(\.(sass|scss))?$/, '') || 'index'}${ext}`; const colorFormat = options?.colorFormat ?? 'hex'; const cssPlugin = options?.pluginCSS ? pluginCSS(options.pluginCSS) : undefined; const semi = options?.indentedSyntax ? '' : ';'; const cbOpen = options?.indentedSyntax ? '' : ' {'; const cbClose = options?.indentedSyntax ? '' : '} '; const TOKEN_FN = `@function token($tokenName, $modeName: ${DEFAULT_KEY})${cbOpen} @if map.has-key($${VAR_TOKENS}, $tokenName) == false${cbOpen} @error "No token named \\"#{$tokenName}\\""${semi} ${cbClose} $_token: map.get($${VAR_TOKENS}, $tokenName)${semi} @if map.has-key($_token, "__cobalt-error")${cbOpen} @error map.get($_token, "__cobalt-error")${semi} ${cbClose} @if map.has-key($_token, $modeName) { @return map.get($_token, $modeName)${semi} ${cbClose}@else${cbOpen} @return map.get($_token, ${DEFAULT_KEY})${semi} ${cbClose} ${cbClose}` .trim() .replace(TRAILING_WS_RE, ''); const LIST_MODES_FN = `@function listModes($tokenName)${cbOpen} @if map.has-key($${VAR_TOKENS}, $tokenName) == false${cbOpen} @error "No token named \\"#{$tokenName}\\""${semi} ${cbClose} $_modes: (); @each $k in map.get($${VAR_TOKENS}, $tokenName)${cbOpen} @if $k != ${DEFAULT_KEY}${cbOpen} $_modes: list.append($_modes, $k); ${cbClose} ${cbClose} @return $_modes; ${cbClose}` .trim() .replace(TRAILING_WS_RE, ''); const TYPOGRAPHY_MIXIN = `@mixin typography($tokenName, $modeName: ${DEFAULT_KEY})${cbOpen} @if map.has-key($${VAR_TYPOGRAPHY}, $tokenName) == false${cbOpen} @error "No typography mixin named \\"#{$tokenName}\\""${semi} ${cbClose} $_mixin: map.get($${VAR_TYPOGRAPHY}, $tokenName)${semi} $_properties: map.get($_mixin, ${DEFAULT_KEY})${semi} @if map.has-key($_mixin, $modeName)${cbOpen} $_properties: map.get($_mixin, $modeName)${semi} ${cbClose} @each $_property, $_value in $_properties${cbOpen} #{$_property}: #{$_value}${semi} ${cbClose} ${cbClose}` .trim() .replace(TRAILING_WS_RE, ''); return { name: '@cobalt-ui/plugin-sass', config(c) { config = c; if (cssPlugin && typeof cssPlugin.config === 'function') { cssPlugin.config(c); } }, async build({ tokens, metadata, rawSchema }) { const output = []; const typographyTokens = []; const prefix = options?.pluginCSS?.prefix || ''; const generateName = _INTERNAL_makeNameGenerator(options?.pluginCSS?.generateName, prefix); // metadata (SassDoc) output.push('////'); output.push(`/// ${metadata.name || 'Design Tokens'}`); output.push('/// Autogenerated from tokens.json.'); output.push('/// DO NOT EDIT!'); output.push('////'); output.push(''); // basic tokens output.push(...DEPENDENCIES.map((name) => `@use "${name}"${semi}`)); output.push(''); output.push(indent(`$${VAR_TOKENS}: (`, 0)); for (const token of tokens) { // special case: typography tokens needs @mixins, so bypass normal route if (token.$type === 'typography') { typographyTokens.push(token); output.push(indent(`"${token.id}": (`, 1)); output.push(indent(`"${VAR_ERROR}": "This is a typography mixin. Use \`@include typography(\\"${token.id}\\")\` instead.",`, 2)); output.push(indent('),', 1)); continue; } output.push(indent(`"${token.id}": (`, 1)); let value; if (cssPlugin) { value = varRef(token.id, { prefix, tokens, generateName }); } else { value = await options?.transform?.(token); if (value === undefined || value === null) { value = defaultTransformer(token, { colorFormat }); } } if (token.$type === 'link' && options?.embedFiles) { value = encode(value, config.outDir); } output.push(indent(`${DEFAULT_KEY}: (${value}),`, 2)); // modes for (const modeName in token.$extensions?.mode || {}) { let modeValue; if (cssPlugin) { const rawValue = token._original.$extensions.mode[modeName]; if (typeof rawValue === 'string' && isAlias(rawValue)) { const { id: aliasID } = parseAlias(rawValue); modeValue = varRef(aliasID, { tokens, generateName }); } else { modeValue = varRef(token.id, { tokens, generateName }); } } else { modeValue = options?.transform?.(token, modeName); if (modeValue === undefined || modeValue === null) { modeValue = defaultTransformer(token, { colorFormat, mode: modeName }); } } if (token.$type === 'link' && options?.embedFiles) { modeValue = encode(modeValue, config.outDir); } output.push(indent(`"${modeName}": (${modeValue}),`, 2)); } output.push(indent('),', 1)); } output.push(`)${semi}`); output.push(''); // typography tokens output.push(`$${VAR_TYPOGRAPHY}: (`); for (const token of typographyTokens) { output.push(indent(`"${token.id}": (`, 1)); output.push(indent(`${DEFAULT_KEY}: (`, 2)); const defaultProperties = Object.entries(token.$value); // legacy: support camelCase properties defaultProperties.sort(([a], [b]) => a.localeCompare(b)); for (const [k, value] of defaultProperties) { const property = k.replace(CAMELCASE_RE, '$1-$2').toLocaleLowerCase(); if (cssPlugin) { output.push(indent(`"${property}": (${varRef(token.id, { prefix, generateName, suffix: property, tokens })}),`, 3)); } else { output.push(indent(`"${property}": (${Array.isArray(value) ? formatFontFamilyNames(value) : value}),`, 3)); } } output.push(indent('),', 2)); for (const mode in token.$extensions?.mode || {}) { const modeValue = token.$extensions?.mode?.[mode] || ''; output.push(indent(`"${mode}": (`, 2)); const modeProperties = Object.entries(modeValue); modeProperties.sort(([a], [b]) => a.localeCompare(b)); for (const [k, value] of modeProperties) { const property = k.replace(CAMELCASE_RE, '$1-$2').toLocaleLowerCase(); output.push(indent(`"${property}": (${Array.isArray(value) ? formatFontFamilyNames(value) : value}),`, 3)); } output.push(indent('),', 2)); } output.push(indent('),', 1)); } output.push(`)${semi}`); output.push(''); // utilities output.push(TOKEN_FN); output.push(''); output.push(LIST_MODES_FN); output.push(''); output.push(TYPOGRAPHY_MIXIN); output.push(''); return [ { filename, contents: output.join('\n'), }, // build pluginCSS (if used) ...((cssPlugin && (await cssPlugin.build?.({ tokens, metadata, rawSchema }))) || []), ]; }, }; } export function defaultTransformer(token, { colorFormat, mode }) { switch (token.$type) { case 'color': { const { value, originalVal } = getMode(token, mode); return transformColor(isAlias(originalVal) ? value : originalVal, colorFormat); // note: use original value because it may have been normalized to hex (which matters if it wasn’t in sRGB gamut to begin with) } case 'dimension': { const { value } = getMode(token, mode); return transformDimension(value); } case 'duration': { const { value } = getMode(token, mode); return transformDuration(value); } case 'font': case 'fontFamily': { const { value } = getMode(token, mode); return transformFontFamily(value); } case 'fontWeight': { const { value } = getMode(token, mode); return transformFontWeight(value); } case 'cubicBezier': { const { value } = getMode(token, mode); return transformCubicBezier(value); } case 'number': { const { value } = getMode(token, mode); return transformNumber(value); } case 'link': { const { value } = getMode(token, mode); return transformLink(value); } case 'strokeStyle': { const { value } = getMode(token, mode); return transformStrokeStyle(value); } // composite tokens case 'border': { const { value, originalVal } = getMode(token, mode); const width = transformDimension(value.width); const color = transformColor(typeof originalVal === 'string' || isAlias(originalVal.color) ? value.color : originalVal.color, colorFormat); const style = transformStrokeStyle(value.style); return `${width} ${style} ${color}`; } case 'shadow': { let { value, originalVal } = getMode(token, mode); // handle backwards compat for previous versions that didn’t always return array if (!Array.isArray(value)) { value = [value]; } if (!Array.isArray(originalVal)) { originalVal = [originalVal]; } return value .map((shadow, i) => { const origShadow = originalVal[i]; const offsetX = transformDimension(shadow.offsetX); const offsetY = transformDimension(shadow.offsetY); const blur = transformDimension(shadow.blur); const spread = transformDimension(shadow.spread); const color = transformColor(typeof origShadow === 'string' || isAlias(origShadow.color) ? shadow.color : origShadow.color, colorFormat); return `${shadow.inset ? 'inset ' : ''}${offsetX} ${offsetY} ${blur} ${spread} ${color}`; }) .join(', '); } case 'gradient': { const { value, originalVal } = getMode(token, mode); return value .map((gradient, i) => { const origGradient = originalVal[i]; const color = transformColor(typeof origGradient === 'string' || isAlias(origGradient.color) ? gradient.color : origGradient.color, colorFormat); const stop = `${100 * gradient.position}%`; return `${color} ${stop}`; }) .join(', '); } case 'transition': { const { value } = getMode(token, mode); const duration = transformDuration(value.duration); const delay = value.delay ? transformDuration(value.delay) : undefined; const timingFunction = transformCubicBezier(value.timingFunction); return `${duration} ${delay ?? ''} ${timingFunction}`; } default: { throw new Error(`No transformer defined for $type: ${token.$type} tokens`); } } } function getMode(token, mode) { if (mode) { if (!token.$extensions?.mode || !token.$extensions.mode[mode]) { throw new Error(`Token ${token.id} missing "$extensions.mode.${mode}"`); } return { value: token.$extensions.mode[mode], originalVal: token._original.$extensions.mode[mode], }; } return { value: token.$value, originalVal: token._original.$value }; } //# sourceMappingURL=index.js.map