mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-09-14 13:36:36 +00:00
431 lines
11 KiB
JavaScript
431 lines
11 KiB
JavaScript
// 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 0–360, chroma 0–~120, tone 0–100):
|
||
//
|
||
// --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;
|