@illlia/css-merge
Version:
css-merge is a tiny package to compose css classes on the 'last class wins' basis
298 lines (278 loc) • 12.2 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.writeConfig = exports.postcssPlugin = void 0;
const postcss_1 = require("postcss");
const css_merge_1 = require("./css-merge");
const log_when_1 = require("./utils/log-when");
const assert_1 = require("./utils/assert");
const write_to_file_1 = require("./utils/write-to-file");
/**
TODO: performance: tw-merge add caching?
Comparing value & priority instead of just priority -->
i.e
most after classes have "content: var(...)" line, if value is the same for 2 classes it's ok to add them both
i.e x2
p-2, px-3 > p-2 px-3 OR px-3 ? --> p-2 px-3
px-3, p-2 > p-2 OR px-3 p-2 ? --> p-2
px-2 px-3 > px-3 OR px-2 px-3 ? -->
px-3 px-2 > px-2
>> if value is different, remove it? but how do i know if class updates other values that might be needed (p-2 pr-3)
Is there any harm in tracking "order" when checking for conflicts? Will it create a weird behavoir that will change the output classNames
based on internal component implementation + passed overrides?
Ex. ...
Goal: to make overrides non-dependent on internal component styling with deterministic results
(doesn't matter if base component is using `px-2 py-2` or `p-2`)
Ex.
const BaseComponent = tw(div)`...`
const ParentComponent = () => <BaseComponent class="..." />
TODO: handle !important modifier correctly (may not adhere to order rule)
- should it just be a logic inside tw-merge to not touch those? or should the users still be able to override them
TODO: how much perf did i save by minimizing ? compare minimized VS non-minimized config
*/
const postcssPlugin = ({ onParsed } = {
onParsed: () => panic("onParsed is not defined"),
}) => {
console.log(`
execute myCustomPlugin
`);
return {
postcssPlugin: "my-custom-plugin",
async Once(root, { result }) {
console.log(`
ONCE
`
// root,
// result
);
/**
* type Parsed = {[className]: {[Key]: AffectedProperties} }
* type Key = PseudeElementName | ''
* TODO: do i need priority
* type AffectedProperties = {[prop]: priority}
*/
const parsed = {};
/** Is keeping track of this even useful? */
let rulePriority = 0;
root.walkRules((rule) => {
const isClass = rule.selector[0] === ".";
// TODO: remove reliance on tailwind preprocessing
// @ts-ignore
const classCandidate = rule.raws?.tailwind?.classCandidate;
if (!isClass || !classCandidate)
return;
rulePriority++;
const affectedProps = rule.nodes
// TODO: handle other cases
.filter((n) => n instanceof postcss_1.Declaration)
.map((n) => [n.prop, n.value]);
const classes = rule.selector
.replaceAll("\\", "")
.split(", ")
.map((c) => {
// get "actionable" class from group-*, peer-* etc.
// Ex. group:hover .dark .group-hover\:dark\:opacity-100
c = c.includes(" ") ? c.split(" ").at(-1) : c;
// remove "." before class name
return c.slice(1);
});
const LOG = (0, log_when_1.logWhen)(false);
LOG(`
Processing rule: ${classes.join(" ")}
affected props: ${JSON.stringify(affectedProps)}
`);
classes.forEach((c) => {
// Tailwind metadata doesn't automatically include "!" important in classCandidate
const isImportant = c[c.indexOf(classCandidate) - 1] === "!";
const mainClassName = isImportant
? "!" + classCandidate
: classCandidate;
const splitter = c.startsWith(mainClassName)
? mainClassName
: ":" + mainClassName;
const [twModifiers, cssModifiers, ...rest] = c.split(splitter);
/** Order of modifers generally shouldn't matter */
const sortedModifers = twModifiers.split(":").sort().join(":");
/** Classname the way it's displayed in html */
const htmlClassName = c.slice(0, sortedModifers.length + splitter.length);
if (rest.length)
throw new Error(`Rest should be empty: ${JSON.stringify(rest)}`);
const pseudoElement = cssModifiers
?.split?.("::")[1]
?.split?.(":")?.[0];
LOG(`
${JSON.stringify({ splitter, mainClassName, isImportant })}
twModifiers: ${sortedModifers.split(":").join(", ")}
pseudoElement: ${pseudoElement}
`);
const affectedPropsMap = Object.fromEntries(affectedProps
// @ts-ignore
.flatMap(expandShorthand)
.map(([p, value]) => [
p,
{ o: rulePriority, v: value, i: isImportant },
]));
parsed[htmlClassName] ??= {};
/**
TODO: rely on css modifiers & parse media queries instead of relying to tw modifers?
TODO: or support different orders of tw modifiers when they evaluate to the same thing
For now assuming that two classes are conflicting only if they modify intersecting properties with the same
tw modifers and on the same pseudelement (or root)
*/
const key = [sortedModifers, pseudoElement ?? "root"]
.filter(Boolean)
.join("::");
parsed[htmlClassName][key] ??= {};
parsed[htmlClassName][key] = {
...parsed[htmlClassName][key],
...affectedPropsMap,
};
});
LOG(`
raws: ${JSON.stringify(rule.raws)}
classCandidate: ${
// @ts-ignore
rule.raws?.tailwind?.classCandidate}
props:
${affectedProps
.map(([prop, value]) => prop + ": " + value)
.join(";\n ")}
________________________________________________
`);
});
// TODO: should i minimize parsed object, i.e replace all properties with autogenerated values that are shorter
// to reduce memory usage and comparison efficiency, since it's doesn't matter what it is, as long as it's 1 to 1 mapping
// console.log(`
// Parsed:
// ${JSON.stringify(parsed)}
// `);
const minimized = compressConfig(flattenConfig(parsed));
// const minimized = flattenConfig(parsed);
// console.log(`
// Minimized:
// ${JSON.stringify(minimized)}
// `);
await onParsed(minimized);
console.log("Saved!");
},
};
};
exports.postcssPlugin = postcssPlugin;
exports.postcssPlugin.postcss = true;
const expandShorthand = ([property, value]) => {
// TODO: extend this
const shorthands = {
padding: ["padding-top", "padding-right", "padding-bottom", "padding-left"],
margin: ["margin-top", "margin-right", "margin-bottom", "margin-left"],
inset: ["top", "right", "bottom", "left"],
};
return (
// @ts-ignore
shorthands[property]?.map((prop) => [prop, value]) ?? [[property, value]]);
};
const flattenConfig = (config) => {
const configEntries = Object.entries(config)
// flat / expand locations
.map(([className, locations]) => {
const flatAffectedProps = Object.entries(locations).flatMap(([location, props]) => Object.entries(props).map(([prop, map]) => [
location + ">>" + prop,
map,
]));
return [className, Object.fromEntries(flatAffectedProps)];
});
return Object.fromEntries(configEntries);
};
const compressConfig = (() => {
const generateStringKey = (() => {
const alphabet = "abcdefghijklmnopqrstuvwxyz";
const numbers = "0123456789";
const specialChars = "+=_-)(*&^%$#@!~,.<>/?:;[]{}|";
const chars = (alphabet +
alphabet.toUpperCase() +
numbers +
specialChars).replaceAll(css_merge_1.SPECIAL_SPLIT_CHART, "");
(0, assert_1.assert)(!chars.includes(css_merge_1.SPECIAL_SPLIT_CHART));
const l = chars.length;
let count = -1;
return () => {
let pointer = ++count;
let id = "";
let power = 1;
let iteration = 0;
do {
iteration++;
const val = pointer % l ** power;
pointer -= val;
const i = val / l ** (power - 1) - (pointer === 0 && power > 1 ? 1 : 0);
const prevChar = chars[i];
id = prevChar + id;
power++;
} while (pointer > 0 && iteration < 10);
return id;
};
})();
const generateNumberKey = (() => {
let count = -1;
return () => ++count;
})();
const createMinimizeKey = (keyGenerator) => {
const cache = {};
return (key) => {
cache[key] ??= keyGenerator();
return cache[key];
};
};
const minimizeStringKey = createMinimizeKey(generateStringKey);
const minimizeNumberKey = createMinimizeKey(generateNumberKey);
const minimizeBoolean = (value) => (value ? 1 : 0);
return (config) => {
// TODO: replace with mapValues
const configEntries = Object.entries(config).map(([className, props]) => {
const entries = Object.entries(props).map(([prop, { v, i, o }]) => {
// update keys, encode location into prop key
return [
minimizeStringKey(prop),
// [minimizeNumberKey(v), o].concat(i ? [1] : []),
{
v: minimizeNumberKey(v),
i: minimizeBoolean(i ?? false),
// avoid adding "important" to config when it's false
// ...(i && { i: 1 }),
o,
},
];
});
// return [className, Object.fromEntries(e)];
// combine / minimize entries
const combinedEntries = entries.reduce((acc, [prop, values]) => {
const i = acc.findIndex(([, existingValues]) => valuesAreEqual(existingValues, values));
if (i !== -1) {
const entry = acc[i];
entry[0].push(prop);
}
else {
// new entry
acc.push([[prop], values]);
}
return acc;
}, []);
const joined = combinedEntries.flatMap(([props, values]) => [
props.join(css_merge_1.SPECIAL_SPLIT_CHART),
values.v,
values.o,
values.i,
]);
return [
className,
// remove last important value only when it's 0 (can be omitted)
joined.at(-1) === 0 ? joined.slice(0, joined.length - 1) : joined,
];
});
return Object.fromEntries(configEntries);
};
})();
const valuesAreEqual = (a, b) => a.v === b.v && a.o === b.o && a.i === b.i;
const panic = (errorMsg) => {
throw new Error(errorMsg);
};
const writeConfig = (path) => (data) => (0, write_to_file_1.writeToFile)(path)(`export default ${JSON.stringify(data)}`);
exports.writeConfig = writeConfig;