lightningcss-plugin-utopia
Version:
A LightningCSS plugin to generate fluid typography and scale based on Utopia.fyi
275 lines (260 loc) • 7.18 kB
JavaScript
import { calculateClamps, calculateSpaceScale, calculateTypeScale } from "utopia-core";
/**
* @param {import("lightningcss").TokenOrValue[]} tokens
* @returns {number}
*/
const parseNumber = (tokens) => {
const firstToken = tokens[0];
if (firstToken.type !== "token") {
throw "[lightningcss-plugin-utopia] Invalid type `" + firstToken.type + "`, expected `token`";
}
if (firstToken.value.type !== "number") {
throw "[lightningcss-plugin-utopia] Invalid token type `" + firstToken.value.type + "`, expected `number`";
}
return firstToken.value.value;
};
/**
* @param {import("lightningcss").TokenOrValue[]} tokens
* @returns {string}
*/
const parseString = (tokens) => {
const firstToken = tokens[0];
if (firstToken.type !== "token") {
throw "[lightningcss-plugin-utopia] Invalid type `" + firstToken.type + "`, expected `token`";
}
if (firstToken.value.type !== "string") {
throw "[lightningcss-plugin-utopia] Invalid token type `" + firstToken.value.type + "`, expected `string`";
}
return firstToken.value.value;
};
/**
* @param {import("lightningcss").TokenOrValue[]} tokens
* @returns {number[]}
*/
const parseNumbersArray = (tokens) => {
const result = [];
for (const token of tokens) {
if (token.type === "token" && token.value.type === "number") {
result.push(token.value.value);
}
}
return result;
};
/**
* @param {import("lightningcss").TokenOrValue[]} tokens
* @returns {[number, number][]}
*/
const parseNumberPairsArray = (tokens) => {
/** @type {number[][]} */
let result = undefined;
for (const token of tokens) {
if (token.type === "token") {
switch (token.value.type) {
case "square-bracket-block":
if (result === undefined) {
result = [];
} else {
result.push([]);
}
break;
case "number":
result[result.length - 1].push(token.value.value);
break;
}
}
}
return result;
};
/**
* @param {import("lightningcss").TokenOrValue[]} tokens
* @returns {string[]}
*/
const parseStringsArray = (tokens) => {
const result = [];
for (const token of tokens) {
if (token.type === "token" && token.value.type === "string") {
result.push(token.value.value);
}
}
return result;
};
/**
* @typedef {{property: "custom", value: import("lightningcss").CustomProperty}} CustomDeclaration
* @typedef {import("utopia-core").UtopiaSpaceConfig & {"prefix": string}} UtopiaSpaceConfig
* @typedef {import("utopia-core").UtopiaTypeConfig & {"prefix": string}} UtopiaTypeConfig
* @typedef {import("utopia-core").UtopiaClampsConfig & {"prefix": string}} UtopiaClampsConfig
*/
/**
* @param {CustomDeclaration[]} declarations
* @returns {UtopiaSpaceConfig}
*/
const extractSpaceScaleConfig = (declarations) => {
/** @type {UtopiaSpaceConfig} */
const config = {};
for (const { value: { name, value } } of declarations) {
/** @type {keyof UtopiaSpaceConfig} */
const field = name;
switch (field) {
case "minWidth":
config[field] = parseNumber(value);
break;
case "maxWidth":
config[field] = parseNumber(value);
break;
case "minSize":
config[field] = parseNumber(value);
break;
case "maxSize":
config[field] = parseNumber(value);
break;
case "negativeSteps":
config[field] = parseNumbersArray(value);
break;
case "positiveSteps":
config[field] = parseNumbersArray(value);
break;
case "customSizes":
config[field] = parseStringsArray(value);
break;
case "relativeTo":
config[field] = parseString(value);
break;
case "prefix":
config[field] = parseString(value);
break;
}
}
return config;
};
/**
* @param {CustomDeclaration[]} declarations
* @returns {UtopiaTypeConfig}
*/
const extractTypeScaleConfig = (declarations) => {
/** @type {UtopiaTypeConfig} */
const config = {};
for (const { value: { name, value } } of declarations) {
/** @type {keyof UtopiaTypeConfig} */
const field = name;
switch (field) {
case "minWidth":
config[field] = parseNumber(value);
break;
case "maxWidth":
config[field] = parseNumber(value);
break;
case "negativeSteps":
config[field] = parseNumber(value);
break;
case "positiveSteps":
config[field] = parseNumber(value);
break;
case "minFontSize":
config[field] = parseNumber(value);
break;
case "maxFontSize":
config[field] = parseNumber(value);
break;
case "minTypeScale":
config[field] = parseNumber(value);
break;
case "maxTypeScale":
config[field] = parseNumber(value);
break;
case "relativeTo":
config[field] = parseString(value);
break;
case "labelStyle":
config[field] = parseString(value);
break;
case "prefix":
config[field] = parseString(value);
break;
}
}
return config;
};
/**
* @param {CustomDeclaration[]} declarations
* @returns {UtopiaClampsConfig}
*/
const extractClampsConfig = (declarations) => {
/** @type {UtopiaClampsConfig} */
const config = {};
for (const { value: { name, value } } of declarations) {
/** @type {keyof UtopiaClampsConfig} */
const field = name;
switch (field) {
case "minWidth":
config[field] = parseNumber(value);
break;
case "maxWidth":
config[field] = parseNumber(value);
break;
case "relativeTo":
config[field] = parseString(value);
break;
case "pairs":
config[field] = parseNumberPairsArray(value);
break;
case "prefix":
config[field] = parseString(value);
break;
}
}
return config;
};
/** @type {import("lightningcss").Visitor} */
export const utopiaVisitor = {
Rule: {
custom: {
utopia(rule) {
/** @type {import("lightningcss").ReturnedDeclaration[]} */
let declarations = [];
if (rule.prelude.value === "spaceScale") {
const config = extractSpaceScaleConfig(rule.body.value.declarations);
const spaceScale = calculateSpaceScale(config);
declarations = [...spaceScale.sizes, ...spaceScale.oneUpPairs, ...spaceScale.customPairs]
.map(step => ({
property: `--${config.prefix || "space"}-${step.label}`,
raw: step.clamp,
}));
}
else if (rule.prelude.value === "typeScale") {
const config = extractTypeScaleConfig(rule.body.value.declarations);
const typeScale = calculateTypeScale(config);
declarations = typeScale.map(step => ({
property: `--${config.prefix || "type"}-${step.label}`,
raw: step.clamp,
}));
}
else if (rule.prelude.value === "clamps") {
const config = extractClampsConfig(rule.body.value.declarations);
const clamps = calculateClamps(config);
declarations = clamps.map(step => ({
property: `--${config.prefix || "step"}-${step.label}`,
raw: step.clamp,
}));
}
else {
throw "[lightningcss-plugin-utopia] Unsupported @utopia rule: " + rule.prelude.value;
}
return {
type: "style",
value: {
loc: rule.loc,
selectors: [[{ type: "nesting" }]],
declarations: { declarations }
}
};
}
}
}
};
/** @type {import("lightningcss").CustomAtRules} */
export const customAtRules = {
utopia: {
prelude: "<custom-ident>",
body: "declaration-list"
},
};