lightningcss-jit-props
Version:
LightningCSS plugin to insert variables from a data source based on discovered usage. Adapted from https://github.com/GoogleChromeLabs/postcss-jit-props
458 lines (457 loc) • 16.5 kB
JavaScript
// adapted from https://github.com/GoogleChromeLabs/postcss-jit-props/blob/main/index.js
// import { readFileSync } from 'node:fs'
// import crypto from "node:crypto"
import glob from "tiny-glob/sync.js";
import { Buffer } from "node:buffer";
import { bundle, transform } from 'lightningcss';
const loc = {
column: 0,
line: 0,
source_index: 0
};
const processed = new WeakSet();
const getState = () => ({
mapped: new Set(), // track prepended props
mapped_dark: new Set(), // track dark mode prepended props
target_rule: [], // :root for props
target_rule_dark: [], // :root for dark props
target_media_dark: [], // dark media query props
keyframes: [],
custom_media: [],
media_rules: new Map()
});
function* getVars(condition) {
for (const element of getFeatures(condition)) {
if (isValidKey(element))
yield element;
}
}
function* getFeatures(condition) {
if (condition?.type === "not")
yield* getFeatures(condition.value);
else if (condition?.type === "operation") {
for (const element of condition.conditions) {
yield* getFeatures(element);
}
}
else if (condition) {
yield condition.value.name;
}
}
const isValidKey = (k) => k.startsWith('--');
function parseProps({ adaptive_prop_selector, custom_selector_dark, custom_selector, ...p }) {
const stylesheet = {
licenseComments: [],
sourceMapUrls: [],
sources: [],
rules: []
};
const props = {};
const rootSelector = [{ type: 'pseudo-class', kind: 'root' }];
adaptive_prop_selector || (adaptive_prop_selector = '-@media:dark');
const regularSelector = custom_selector || rootSelector;
const darkSelector = custom_selector_dark || regularSelector;
const styleRules = [];
const darkRules = [];
const customMedia = [];
const keyframeRules = [];
const darkKeyframeRules = [...keyframeRules];
for (const [property, value] of Object.entries(p)) {
if (!isValidKey(property))
continue;
const prop = {
dependencies: []
};
if (typeof value === "string") {
if (value.startsWith('@keyframes')) {
transform({
code: Buffer.from(value),
filename: "props.css",
visitor: {
Rule: {
keyframes(r) {
const rulesToPush = property.endsWith(adaptive_prop_selector)
? darkKeyframeRules
: keyframeRules;
rulesToPush.push(r);
props[r.value.name.value] = prop;
}
},
Variable({ name: { ident } }) {
prop.dependencies.push(ident);
},
Token: {
ident({ value }) {
prop.dependencies.push(value);
}
}
}
});
continue;
}
if (value.startsWith('@custom-media')) {
transform({
code: Buffer.from(value),
filename: "props.css",
visitor: {
Rule: {
"custom-media"(r) {
customMedia.push(r);
props[r.value.name] = prop;
}
},
Variable(v) {
prop.dependencies.push(v.name.ident);
},
Token: {
ident({ value }) {
prop.dependencies.push(value);
}
}
},
drafts: {
customMedia: true
}
});
continue;
}
}
const [rulesToPush, key] = property.endsWith(adaptive_prop_selector)
? [darkRules, property.substring(0, property.length - adaptive_prop_selector.length)]
: [styleRules, property];
transform({
filename: "props.css",
code: Buffer.from(`:root {${key}: ${value};}`),
visitor: {
Declaration(p) {
if (p.property === "custom") {
rulesToPush.push(p);
props[p.value.name] = prop;
}
},
Variable(v) {
prop.dependencies.push(v.name.ident);
},
Token: {
ident({ value }) {
prop.dependencies.push(value);
}
}
}
});
}
stylesheet.rules.push(...customMedia);
if (styleRules.length) {
stylesheet.rules.push({
type: 'style',
value: {
selectors: [regularSelector],
loc,
declarations: {
declarations: styleRules
}
}
});
}
stylesheet.rules.push(...keyframeRules);
if (darkRules.length || darkKeyframeRules.length) {
stylesheet.rules.push({
type: 'media',
value: {
loc,
rules: [
{
type: 'style',
value: {
selectors: [darkSelector],
loc,
declarations: {
declarations: darkRules
}
}
}, ...darkKeyframeRules
],
query: {
mediaQueries: [
{
mediaType: 'all',
condition: {
type: "feature",
value: {
type: 'plain',
name: 'prefers-color-scheme',
value: {
type: "ident",
value: "dark"
}
}
}
}
]
}
}
});
}
return [stylesheet, props];
}
function* purgeRules(rules, vars, addIndex) {
for (const rule of rules) {
if (!("type" in rule)) {
const result = {
...rule,
loc: {
...rule.loc,
source_index: rule.loc.source_index + addIndex
}
};
if (result.declarations) {
result.declarations = purgeDeclarationsBlock(result.declarations, vars);
}
yield result;
continue;
}
if (rule.type === "font-feature-values") {
// page margin rule
yield {
...rule,
value: {
...rule.value,
loc: {
...rule.value.loc,
source_index: rule.value.loc.source_index + addIndex
},
rules: Object.fromEntries(Object.entries(rule.value.rules).map(([key, value]) => [key, {
...value,
loc: {
...value.loc,
source_index: value.loc.source_index + addIndex,
},
}]))
}
};
continue;
}
if (rule.type === "keyframes" && !vars.has(rule.value.name.value))
continue;
if (rule.type === "custom-media" && !vars.has(rule.value.name))
continue;
if (!("value" in rule && rule.value))
continue;
const mappedRule = {
...rule,
value: {
...rule.value,
loc: {
...rule.value.loc,
source_index: rule.value.loc.source_index + addIndex
}
}
};
if ("rules" in mappedRule.value && mappedRule.value.rules) {
mappedRule.value.rules = [...purgeRules(mappedRule.value.rules, vars, addIndex)];
}
if ("declarations" in mappedRule.value && mappedRule.value.declarations) {
mappedRule.value.declarations = purgeDeclarationsBlock(mappedRule.value.declarations, vars);
}
yield mappedRule;
}
}
function purgeDeclarationsBlock(declarations, vars) {
const mapped = {
...declarations
};
if (mapped.declarations) {
mapped.declarations = [...purgeDeclarations(mapped.declarations, vars)];
}
if (mapped.importantDeclarations) {
mapped.importantDeclarations = [...purgeDeclarations(mapped.importantDeclarations, vars)];
}
return mapped;
}
function* purgeDeclarations(declarations, vars) {
for (const declaration of declarations) {
if (declaration.property === "custom") {
const name = "value" in declaration ? declaration.value.name : declaration.property;
if (!vars.has(name))
continue;
}
yield declaration;
}
}
export default function plugin(options) {
const { files, layer, targets, } = options;
// const FilePropsCache = new Map();
const [objStylesheet, UserProps] = parseProps(options);
const STATE = getState();
const propStylesheets = [
objStylesheet
];
if (!files?.length && !Object.keys(UserProps).length) {
console.warn('lightningcss-jit-props: Variable source(s) not passed.');
return {};
}
if (files?.length) {
const globs = files
.map((file) => glob(file))
.flat();
globs.forEach((file) => {
// const data = readFileSync(file)
let parent = {};
let parentProp;
bundle({
filename: file,
drafts: {
customMedia: true
},
targets,
visitor: {
StyleSheet(s) {
propStylesheets.push(s);
parent = {
rules: s.rules,
};
},
Rule(rule) {
const name = rule.type === "custom-media"
? rule.value.name
: rule.type === "keyframes"
? rule.value.name.value
: null;
if (name) {
let existing = UserProps[name];
if (!existing) {
existing = { dependencies: [] };
UserProps[name] = existing;
}
if (parentProp) {
parentProp.dependencies.push(name);
}
}
if ('value' in rule && rule.value) {
let parentRules, parentDeclarations;
if ('rules' in rule.value) {
parentRules = rule.value.rules;
}
if ('declarations' in rule.value) {
parentDeclarations = rule.value.declarations;
}
if (parentRules || parentDeclarations) {
parent = {
parent,
rules: parentRules,
declarations: parentDeclarations
};
}
}
},
RuleExit(rule) {
if (parent.parent && 'value' in rule && rule.value) {
if ('rules' in rule.value || 'declarations' in rule.value) {
parent = parent.parent;
}
}
},
Declaration(d) {
if (d.property === "custom") {
const prop = d.value;
let existing = UserProps[prop.name];
if (!existing) {
existing = {
dependencies: [],
};
UserProps[prop.name] = existing;
}
parentProp = existing;
}
},
Variable(v) {
if (parentProp) {
parentProp.dependencies.push(v.name.ident);
}
},
Token: {
ident({ value }) {
if (parentProp) {
parentProp.dependencies.push(value);
}
}
},
DeclarationExit: {
custom() {
parentProp = undefined;
}
},
}
});
});
}
function* expand(k) {
const found = UserProps[k];
if (!found)
return;
yield k;
for (const dep of found.dependencies) {
yield* expand(dep);
}
}
return {
StyleSheet() {
Object.assign(STATE, getState());
},
StyleSheetExit(stylesheet) {
if (!propStylesheets.length)
return;
const rootRules = stylesheet.rules.filter(r => r.type !== "ignored");
let rulesToAppend, rules;
if (layer) {
rulesToAppend = [];
const layerRule = {
type: 'layer-block',
value: {
loc,
name: [layer],
rules: rulesToAppend
}
};
rules = [layerRule, ...rootRules];
}
else {
rules = rootRules;
rulesToAppend = rules;
}
let sourceCount = stylesheet.sources.length;
for (const sourceStylesheet of propStylesheets) {
const purged = purgeRules(sourceStylesheet.rules, STATE.mapped, sourceCount);
rulesToAppend.unshift(...purged);
stylesheet.sources.push(...sourceStylesheet.sources);
stylesheet.sourceMapUrls.push(...sourceStylesheet.sourceMapUrls);
stylesheet.licenseComments.push(...sourceStylesheet.licenseComments);
sourceCount += stylesheet.sources.length;
}
return {
...stylesheet,
rules
};
},
MediaQuery(query) {
// bail early if possible
if (processed.has(query))
return;
for (const prop of getVars(query.condition)) {
for (const value of expand(prop)) {
STATE.mapped.add(value);
}
}
processed.add(query);
},
Variable(variable) {
if (processed.has(variable))
return;
const { name: { ident: prop } } = variable;
for (const value of expand(prop)) {
STATE.mapped.add(value);
}
processed.add(variable);
},
};
}