UNPKG

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
// 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); }, }; }