Files
easy-png-tools2/web/scripts/postcss-hct.mjs
T

431 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// postcss-hct: PostCSS plugin that lets design tokens be authored as hct() and
// emits plain sRGB hex colors.
//
// Supported syntax (Material Design 3 HCT; hue 0360, chroma 0~120, tone 0100):
//
// --color-success: hct(155 40 55); // literal
// --color-muted: hct(from var(--brand-main) h 6 97); // take hue from seed
// --brand-alt: hct(from var(--brand-main) h c calc(t + 36)); // channel math
//
// `from` seeds are resolved from sibling custom properties in the same
// stylesheet (:root tokens plus the current rule's own tokens). Seeds may be
// #hex, oklch(), rgb() or another hct(). Anything the plugin cannot resolve is
// left as-is. The emitter is sRGB hex; HCT stays the single source of truth.
import { Color } from "@panmdaa/colors";
const HCT_RE = /\bhct\(/;
const VAR_RE = /^var\(\s*(--[\w-]+)\s*\)$/;
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function wrapHue(degrees) {
return ((degrees % 360) + 360) % 360;
}
// --- postcss plugin entry -------------------------------------------------
const postcssHct = {
postcssPlugin: "postcss-hct",
Once(root, { result }) {
// Fallback environment: tokens from :root (highest-level definitions).
const rootEnv = collectTokensFromSelector(root, ":root");
root.walkRules((rule) => {
const env = new Map(rootEnv);
const decls = [];
rule.nodes?.forEach((node) => {
if (node.type === "decl" && node.prop.startsWith("--")) {
env.set(node.prop, node.value);
decls.push(node);
}
});
for (const decl of decls) {
if (HCT_RE.test(decl.value)) {
const next = replaceHctValues(decl, decl.value, env, result);
if (next !== decl.value) {
decl.value = next;
}
}
}
});
},
};
function collectTokensFromSelector(root, selector) {
const tokens = new Map();
root.walkRules((rule) => {
if (
typeof rule.selector === "string" &&
rule.selector
.split(",")
.map((s) => s.trim())
.includes(selector)
) {
rule.walkDecls((decl) => {
if (decl.prop.startsWith("--")) tokens.set(decl.prop, decl.value);
});
}
});
return tokens;
}
// --- value scanning -------------------------------------------------------
function findBalancedClosing(text, openParenIndex) {
let depth = 0;
for (let i = openParenIndex; i < text.length; i++) {
if (text[i] === "(") depth++;
else if (text[i] === ")") {
depth--;
if (depth === 0) return i;
}
}
return -1;
}
function replaceHctValues(node, value, env, result) {
let out = "";
let cursor = 0;
for (;;) {
const start = value.indexOf("hct(", cursor);
if (start === -1) {
out += value.slice(cursor);
break;
}
const end = findBalancedClosing(value, start + 3);
if (end === -1) {
out += value.slice(cursor);
break;
}
out += value.slice(cursor, start);
const inner = value.slice(start + 4, end);
const hct = resolveHctBody(inner, env, new Set());
if (hct) {
out += Color.fromHct(hct.h, hct.c, hct.t).toHexColor();
} else {
result.warn(`postcss-hct: could not resolve hct(${inner})`, { node });
out += value.slice(start, end + 1);
}
cursor = end + 1;
}
return out;
}
// --- hct() body parsing ---------------------------------------------------
// Resolve the body of one hct(...) call to { h, c, t } or null.
function resolveHctBody(inner, env, seen) {
const text = inner.trim();
if (!text) return null;
if (text.startsWith("from ")) {
const rest = text.slice(5).trimStart();
if (!rest) return null;
const scan = readSourceToken(rest);
if (!scan) return null;
const { source, remainder } = scan;
const seed = resolveSeed(source, env, seen);
if (!seed) return null;
const parts = splitTopLevel(remainder, " ");
if (parts.length !== 3) return null;
const h = safeNumber(evalComponent(parts[0], seed));
const c = safeNumber(evalComponent(parts[1], seed));
const t = safeNumber(evalComponent(parts[2], seed));
if (h === null || c === null || t === null) return null;
return {
h: wrapHue(h),
c: Math.max(0, c),
t: clamp(t, 0, 100),
};
}
// Literal: hct(h c t)
const parts = splitTopLevel(text, " ");
if (parts.length !== 3) return null;
const nums = parts.map((p) => Number.parseFloat(p));
if (nums.some((n) => !Number.isFinite(n))) return null;
return {
h: wrapHue(nums[0]),
c: Math.max(0, nums[1]),
t: clamp(nums[2], 0, 100),
};
}
function safeNumber(value) {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
// Read "var(--x)", "hct(...)", a color function or a bare token off the front.
function readSourceToken(text) {
const first = text[0];
if (first === "v") {
const match = text.match(/^var\(\s*--[\w-]+\s*\)/);
if (!match) return null;
return {
source: match[0],
remainder: text.slice(match[0].length).trimStart(),
};
}
if (first === "(" || first === "#" || /[a-z]/i.test(first)) {
const match = text.match(/^[a-z]+\(/i);
if (match) {
const end = findBalancedClosing(text, text.indexOf("("));
if (end === -1) return null;
return {
source: text.slice(0, end + 1),
remainder: text.slice(end + 1).trimStart(),
};
}
const bare = text.match(/^(#[\w.]+|-?[\d.]+)/i);
if (!bare) return null;
return {
source: bare[1],
remainder: text.slice(bare[1].length).trimStart(),
};
}
return null;
}
// Split on a delimiter, ignoring delimiters inside balanced parentheses.
function splitTopLevel(text, delimiter) {
const parts = [];
let depth = 0;
let current = "";
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (ch === "(") depth++;
else if (ch === ")") depth--;
if (ch === delimiter && depth === 0) {
if (current.trim()) parts.push(current.trim());
current = "";
} else {
current += ch;
}
}
if (current.trim()) parts.push(current.trim());
return parts;
}
// --- seed resolution ------------------------------------------------------
function resolveSeed(source, env, seen) {
if (VAR_RE.test(source.trim())) {
const name = source.trim().match(VAR_RE)[1];
const raw = env.get(name);
if (raw === undefined) return null;
if (seen.has(name)) return null; // circular reference
seen.add(name);
return resolveRawValue(raw, env, seen);
}
return parseColorValue(source.trim());
}
function resolveRawValue(raw, env, seen) {
const text = raw.trim();
if (text.startsWith("hct(") && text.endsWith(")")) {
const inner = text.slice(4, -1);
return resolveHctBody(inner, env, seen);
}
return parseColorValue(text);
}
// Parse a concrete color into HCT channels, or null.
function parseColorValue(text) {
const value = text.trim();
if (/^#[0-9a-f]{3}$/i.test(value)) {
return channelsFromHex(expandShortHex(value));
}
if (/^#[0-9a-f]{6}$/i.test(value)) {
return channelsFromHex(value);
}
// Split on whitespace; parseFloat drops "%"/"deg" units.
const oklch = value.match(/^oklch\(([^)]+)\)/i);
if (oklch) {
const channels = oklch[1]
.trim()
.split(/\s+/)
.map((ch) => Number.parseFloat(ch));
if (channels.length !== 3 || channels.some((n) => !Number.isFinite(n)))
return null;
const [L, C, h] = channels;
const hex = oklchToHex(L > 1 ? L / 100 : L, C, h);
if (hex) return channelsFromHex(hex);
}
const rgb = value.match(/^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/i);
if (rgb) {
const [r, g, b] = rgb.slice(1).map(Number);
return channelsFromHex(hexFromRgb(r, g, b));
}
return null;
}
function channelsFromHex(hex) {
try {
const color = Color.from(hex);
return { h: color.hue, c: color.chroma, t: color.tone };
} catch {
return null;
}
}
function expandShortHex(hex) {
return (
"#" +
hex
.slice(1)
.split("")
.map((ch) => ch + ch)
.join("")
);
}
function oklchToHex(L, C, hDeg) {
const hr = (hDeg * Math.PI) / 180;
const a = C * Math.cos(hr);
const b = C * Math.sin(hr);
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
const s_ = L - 0.0894841775 * a - 1.291485548 * b;
const l = l_ ** 3;
const m = m_ ** 3;
const s = s_ ** 3;
const r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s;
const g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s;
const bl = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s;
return hexFromRgb(r * 255, g * 255, bl * 255);
}
function hexFromRgb(r, g, b) {
const toByte = (v) =>
clamp(Math.round(v), 0, 255).toString(16).padStart(2, "0");
return `#${toByte(r)}${toByte(g)}${toByte(b)}`;
}
// --- channel arithmetic ---------------------------------------------------
// Evaluate one hct() component: a channel ref (h/c/t), a number, or a calc()
// expression over the seed channels.
function evalComponent(text, seed) {
const value = text.trim();
if (value === "h" || value === "c" || value === "t") {
return seed[value];
}
if (/^[-+]?[\d.]/.test(value)) {
// unit-bearing number like "36deg"
return Number.parseFloat(value);
}
return evaluateExpr(value, seed);
}
// --- tiny math evaluator for calc()/min()/max()/clamp() ---------------------
const MATH_FUNCS = new Set(["calc", "min", "max", "clamp"]);
// Lex and evaluate a calc() expression. Supported: numbers, + - * /,
// parentheses, channel refs (h/c/t) and the calc/min/max/clamp functions.
function evaluateExpr(text, seed) {
return new ExprParser(text, seed).parse();
}
function tokenizeExpression(text) {
const tokens = [];
const re = /(\d+(?:\.\d+)?|\.\d+|[+\-*/(),]|[a-z]+)/g;
let match;
while ((match = re.exec(text)) !== null) {
tokens.push(match[1]);
}
return tokens;
}
class ExprParser {
constructor(text, seed) {
this.tokens = tokenizeExpression(text);
this.pos = 0;
this.seed = seed;
}
peek() {
return this.tokens[this.pos];
}
next() {
return this.tokens[this.pos++];
}
parse() {
const value = this.parseAdditive();
return this.pos === this.tokens.length ? value : NaN;
}
parseAdditive() {
let value = this.parseMulDiv();
for (;;) {
const op = this.peek();
if (op !== "+" && op !== "-") break;
this.pos++;
const rhs = this.parseMulDiv();
value = op === "+" ? value + rhs : value - rhs;
}
return value;
}
parseMulDiv() {
let value = this.parseAtom();
for (;;) {
const op = this.peek();
if (op !== "*" && op !== "/") break;
this.pos++;
const rhs = this.parseAtom();
value = op === "*" ? value * rhs : value / rhs;
}
return value;
}
parseAtom() {
const token = this.next();
if (token === undefined) return NaN;
if (token === "h" || token === "c" || token === "t") {
return this.seed[token];
}
if (/^\d/.test(token)) return Number.parseFloat(token);
if (token === "-") return -this.parseAtom();
if (token === "+") return this.parseAtom();
if (token === "(") {
const value = this.parseAdditive();
if (this.next() !== ")") return NaN;
return value;
}
if (MATH_FUNCS.has(token)) {
return this.parseFunction(token);
}
return NaN;
}
parseFunction(name) {
if (this.next() !== "(") return NaN;
const args = [this.parseAdditive()];
while (this.peek() === ",") {
this.pos++;
args.push(this.parseAdditive());
}
if (this.next() !== ")") return NaN;
if (name === "calc") return args[0];
if (name === "min") return Math.min(...args);
if (name === "max") return Math.max(...args);
if (name === "clamp") {
const [min, value, max] = args;
return clamp(value, min, max);
}
return NaN;
}
}
export default postcssHct;