@master/css-runtime
Version:
Run Master CSS right in the browser
480 lines (470 loc) • 20.8 kB
JavaScript
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
var css = require('@master/css');
var cssDevtoolsHook = require('@master/css-devtools-hook');
function registerGlobal(CSSRuntime) {
if (!globalThis.CSSRuntime) globalThis.CSSRuntime = CSSRuntime;
cssDevtoolsHook.installHook();
}
function findNativeCSSRuleIndex(cssRules, nativeRule) {
for(let i = 0; i < cssRules.length; i++){
if (cssRules[i] === nativeRule) {
return i;
}
}
return -1;
}
class RuntimeLayer extends css.Layer {
name;
cssRuntime;
rules;
tokenCounts;
native;
constructor(name, cssRuntime){
super(name, cssRuntime), this.name = name, this.cssRuntime = cssRuntime, this.rules = [], this.tokenCounts = new Map(), this.native = null;
}
attach() {
super.attach();
const nativeSheet = this.cssRuntime.style?.sheet;
if (nativeSheet && !this.native?.parentStyleSheet) {
const insertedIndex = nativeSheet.insertRule(this.text, nativeSheet.cssRules.length);
this.native = nativeSheet.cssRules.item(insertedIndex);
}
}
insert(rule, index = this.rules.length) {
const insertedIndex = super.insert(rule, index);
if (insertedIndex === undefined || !this.native) return;
const insertRuleSafely = (text, position)=>{
// Checks if the rule is inserted in a native CSS rule with this.attach()
if (this.rules.length === 1) {
return this.native.cssRules.item(position);
} else {
try {
const insertedIndex = this.native.insertRule(text, position);
return this.native.cssRules.item(insertedIndex);
} catch (error) {
console.error(error, rule);
/**
* If the rule is invalid, remove it from the rules array.
* It's important to note that the rule may break the entire CSS runtime,
*/ this.rules.splice(insertedIndex, 1);
return;
}
}
};
if ('nodes' in rule) {
let currentIndex = insertedIndex;
rule.nodes.forEach((node)=>{
node.native = insertRuleSafely(node.text, currentIndex);
if (node.native) currentIndex++;
});
} else {
rule.native = insertRuleSafely(rule.text, insertedIndex);
}
return insertedIndex;
}
detach() {
super.detach();
const nativeSheet = this.cssRuntime.style?.sheet;
if (nativeSheet && this.native?.parentStyleSheet) {
const foundIndex = findNativeCSSRuleIndex(nativeSheet.cssRules, this.native);
if (foundIndex !== -1) {
nativeSheet.deleteRule(foundIndex);
}
}
}
delete(key) {
const deletedRule = super.delete(key);
if (!deletedRule || !this.native) return;
const deleteRuleSafely = (rule)=>{
if (!rule) return;
const foundIndex = findNativeCSSRuleIndex(this.native.cssRules, rule);
if (foundIndex !== -1) {
try {
this.native.deleteRule(foundIndex);
} catch (error) {
console.error(error, rule);
}
}
};
if ('nodes' in deletedRule) {
deletedRule.nodes.forEach((node)=>deleteRuleSafely(node.native));
} else {
deleteRuleSafely(deletedRule.native);
}
return deletedRule;
}
reset() {
super.reset();
const nativeSheet = this.cssRuntime.style?.sheet;
if (this.native && nativeSheet) {
const foundIndex = findNativeCSSRuleIndex(nativeSheet.cssRules, this.native);
if (foundIndex !== -1) {
nativeSheet.deleteRule(foundIndex);
}
this.native = null;
}
}
}
const RuntimeSyntaxLayer = css.withSyntaxLayer(RuntimeLayer);
class CSSRuntime extends css.MasterCSS {
root;
customConfig;
static instances = new WeakMap();
host;
container;
baseLayer;
themeLayer;
presetLayer;
componentsLayer;
generalLayer;
classCounts;
observer;
progressive;
observing;
constructor(root = document, customConfig = css.config){
super(customConfig), this.root = root, this.customConfig = customConfig, this.baseLayer = new RuntimeSyntaxLayer('base', this), this.themeLayer = new RuntimeLayer('theme', this), this.presetLayer = new RuntimeSyntaxLayer('preset', this), this.componentsLayer = new RuntimeSyntaxLayer('components', this), this.generalLayer = new RuntimeSyntaxLayer('general', this), this.classCounts = new Map(), this.progressive = false, this.observing = false;
if (this.root instanceof Document || this.root instanceof HTMLDocument) {
this.root.defaultView.globalThis.cssRuntime = this;
this.container = this.root.head;
this.host = this.root.documentElement;
} else {
this.container = this.root;
this.host = this.root.host;
}
globalThis.CSSRuntime.instances.set(this.root, this);
__MASTER_CSS_DEVTOOLS_HOOK__?.emit('runtime:created', {
cssRuntime: this
});
}
/**
* Observe the DOM for changes and update the running stylesheet. (browser only)
* @param options mutation observer options
* @returns this
*/ observe() {
if (this.observing) return this;
// Detect prerendered stylesheet
if (this.root.styleSheets) {
for (const sheet of this.root.styleSheets){
const { ownerNode } = sheet;
if (ownerNode instanceof HTMLStyleElement && ownerNode.id === 'master') {
this.style = ownerNode;
if (this.style.sheet?.cssRules.length) {
this.progressive = true;
}
break;
}
}
}
// Prepare snapshot map
const elementClasses = new WeakMap();
// Initial scan and populate counts + snapshot
const connectedNames = new Set();
const increaseClassCount = (className)=>{
const count = this.classCounts.get(className) || 0;
if (!count) connectedNames.add(className);
this.classCounts.set(className, count + 1);
};
const rootEl = this.root instanceof Document || this.root instanceof HTMLDocument ? this.root : this.container;
const elementsWithClass = rootEl.querySelectorAll('[class]');
elementsWithClass.forEach((el)=>{
const clsList = el.classList;
if (clsList) {
el.classList.forEach(increaseClassCount);
}
elementClasses.set(el, new Set(clsList));
});
// Hydration or style creation
if (this.progressive) {
const hydrateResult = this.hydrate(this.style.sheet.cssRules);
for (const cls of connectedNames){
if (!hydrateResult.allSyntaxRules.find((r)=>(r.fixedClass || r.name) === cls)) {
this.add(cls);
}
}
} else {
this.style = document.createElement('style');
this.style.id = 'master';
this.style.setAttribute('blocking', 'render');
this.container.append(this.style);
this.style.sheet.insertRule(this.layerStatementRule.text);
this.layerStatementRule.native = this.style.sheet.cssRules.item(0);
connectedNames.forEach((cls)=>this.add(cls));
}
this.observer = new MutationObserver((records)=>{
const deltaCounts = new Map();
const nodeMap = new Map();
const attrRecords = new Set();
const visited = new WeakSet();
const updateDelta = (cls, delta)=>deltaCounts.set(cls, (deltaCounts.get(cls) || 0) + delta);
const diffAndSnapshot = (el)=>{
const prev = new Set(elementClasses.get(el) || []);
const next = new Set(el.classList);
for (const c of next)if (!prev.has(c)) updateDelta(c, 1);
for (const c of prev)if (!next.has(c)) updateDelta(c, -1);
elementClasses.set(el, next);
};
const removeSnapshot = (el)=>{
for (const c of elementClasses.get(el) || [])updateDelta(c, -1);
elementClasses.delete(el);
};
for (const record of records){
if (record.type === 'childList') {
for (const node of record.addedNodes)if (node instanceof Element && node.isConnected) nodeMap.set(node, (nodeMap.get(node) || 0) + 1);
for (const node of record.removedNodes)if (node instanceof Element && !node.isConnected) nodeMap.set(node, (nodeMap.get(node) || 0) - 1);
} else if (record.type === 'attributes' && record.attributeName === 'class') {
attrRecords.add(record.target);
}
}
const traverseIncludingSelf = (el, fn, visited)=>{
if (!visited.has(el)) {
visited.add(el);
fn(el);
for (const child of el.children){
traverseIncludingSelf(child, fn, visited);
}
} else {
nodeMap.delete(el);
}
};
for (const [node, count] of nodeMap){
if (count > 0) {
traverseIncludingSelf(node, diffAndSnapshot, visited);
} else if (count < 0 && !node.isConnected) {
traverseIncludingSelf(node, removeSnapshot, visited);
}
}
for (const el of attrRecords){
if (!visited.has(el)) diffAndSnapshot(el);
}
for (const [cls, change] of deltaCounts){
const current = this.classCounts.get(cls) || 0;
const next = current + change;
if (next > 0) {
this.classCounts.set(cls, next);
if (current === 0) this.add(cls);
} else {
this.classCounts.delete(cls);
this.remove(cls);
}
}
globalThis.__MASTER_CSS_DEVTOOLS_HOOK__?.emit('runtime:mutated', {
records,
classCounts: deltaCounts,
cssRuntime: this
});
});
this.observer.observe(this.root, {
childList: true,
attributes: true,
attributeFilter: [
'class'
],
attributeOldValue: true,
subtree: true
});
if (!this.progressive) this.host.removeAttribute('hidden');
this.observing = true;
globalThis.__MASTER_CSS_DEVTOOLS_HOOK__?.emit('runtime:observed', {
cssRuntime: this
});
return this;
}
hydrate(nativeLayerRules) {
const cssLayerRules = [];
const checkSheet = new CSSStyleSheet();
const result = {
allSyntaxRules: []
};
for(let i = 0; i < nativeLayerRules.length; i++){
const eachNativeCSSRule = nativeLayerRules[i];
if (eachNativeCSSRule.constructor.name === 'CSSLayerBlockRule') {
const eachCSSLayerRule = eachNativeCSSRule;
if (eachNativeCSSRule.name === 'theme') {
this.themeLayer.native = eachCSSLayerRule;
let variableRule;
const unresolvedCSSRules = new Map();
for (const rule of eachCSSLayerRule.cssRules){
// trim() for fix the firefox bug that the cssText ends with \n\n
unresolvedCSSRules.set(rule.cssText.trim(), rule);
}
for (const cssRule of eachCSSLayerRule.cssRules){
if (!unresolvedCSSRules.has(cssRule.cssText)) continue;
const variableCSSRule = cssRule.constructor.name === 'CSSMediaRule' ? cssRule.cssRules[0] : cssRule;
const variableName = variableCSSRule.style[0].slice(2);
const variable = this.variables.get(variableName);
if (!variable) continue;
variableRule = new css.VariableRule(variableName, variable, this);
this.themeLayer.rules.push(variableRule);
this.themeLayer.tokenCounts.set(variableRule.name, 0);
variableRule.nodes.forEach((node)=>{
const checkRuleIndex = checkSheet.insertRule(node.text);
const checkNodeNativeRule = checkSheet.cssRules.item(checkRuleIndex);
if (checkNodeNativeRule) {
const checkNodeNativeRuleText = checkNodeNativeRule.cssText.trim();
const match = unresolvedCSSRules.get(checkNodeNativeRuleText);
if (match) {
node.native = match;
unresolvedCSSRules.delete(checkNodeNativeRuleText);
return;
}
}
});
}
if (this.themeLayer.rules.length) this.rules.push(this.themeLayer);
} else {
cssLayerRules.push(eachCSSLayerRule);
}
} else if (eachNativeCSSRule.constructor.name === 'CSSLayerStatementRule') {
this.layerStatementRule.native = eachNativeCSSRule;
} else if (eachNativeCSSRule.constructor.name === 'CSSKeyframesRule') {
const nativeKeyframsRule = eachNativeCSSRule;
const keyframes = this.animations.get(nativeKeyframsRule.name);
if (!keyframes) continue;
const animationRule = new css.AnimationRule(nativeKeyframsRule.name, keyframes, this);
animationRule.native = nativeKeyframsRule;
this.animationsNonLayer.rules.push(animationRule);
this.rules.push(animationRule);
this.animationsNonLayer.tokenCounts.set(animationRule.name, 0);
}
}
for (const eachCSSLayerRule of cssLayerRules){
let layer;
switch(eachCSSLayerRule.name){
case 'base':
layer = this.baseLayer;
break;
case 'preset':
layer = this.presetLayer;
break;
case 'components':
layer = this.componentsLayer;
break;
case 'general':
layer = this.generalLayer;
break;
default:
console.error(`Cannot recognize the layer \`${eachCSSLayerRule.name}\`. (https://rc.css.master.co/messages/hydration-errors)`);
continue;
}
layer.native = eachCSSLayerRule;
const unresolvedCSSRules = new Map();
for (const rule of eachCSSLayerRule.cssRules){
// trim() for fix the firefox bug that the cssText ends with \n\n
unresolvedCSSRules.set(rule.cssText.trim(), rule);
}
for (const eachNativeLayerRule of eachCSSLayerRule.cssRules){
if (!unresolvedCSSRules.has(eachNativeLayerRule.cssText)) continue;
const selectorText = this.getSelectorText(eachNativeLayerRule);
if (!selectorText) {
console.error(`Cannot get the selector text from \`${eachNativeLayerRule.cssText}\`. (${layer.name}) (https://rc.css.master.co/messages/hydration-errors)`);
continue;
}
const createdRules = this.createFromSelectorText(selectorText);
if (createdRules) {
for (const createdRule of createdRules){
layer.rules.push(createdRule);
layer.insertVariables(createdRule);
layer.insertAnimations(createdRule);
result.allSyntaxRules.push(createdRule);
try {
const checkRuleIndex = checkSheet.insertRule(createdRule.text);
const checkNodeNativeRule = checkSheet.cssRules.item(checkRuleIndex);
if (checkNodeNativeRule) {
const checkNodeNativeRuleText = checkNodeNativeRule.cssText.trim();
const match = unresolvedCSSRules.get(checkNodeNativeRuleText);
if (match) {
createdRule.native = match;
unresolvedCSSRules.delete(checkNodeNativeRuleText);
continue;
}
}
console.error(`Cannot retrieve CSS rule for \`${createdRule.text}\`. (${layer.name}) (https://rc.css.master.co/messages/hydration-errors)`);
} catch (error) {
}
}
} else {
console.error(`Cannot recognize \`${eachNativeLayerRule.cssText}\`. (${layer.name}) (https://rc.css.master.co/messages/hydration-errors)`);
}
}
if (layer.rules.length) this.rules.push(layer);
}
globalThis.__MASTER_CSS_DEVTOOLS_HOOK__?.emit('runtime:hydrated', {
cssRuntime: this,
result
});
return result;
}
getSelectorText(cssRule) {
if (cssRule instanceof CSSStyleRule) {
return cssRule.selectorText;
} else if (cssRule instanceof CSSGroupingRule) {
return this.getSelectorText(cssRule.cssRules.item(0));
}
}
disconnect() {
if (!this.observing) return;
if (this.observer) {
this.observer.disconnect();
this.observer = undefined;
}
// @ts-ignore
this.observing = false;
this.reset();
this.classCounts.clear();
if (!this.progressive) {
this.style?.remove();
this.style = null;
}
globalThis.__MASTER_CSS_DEVTOOLS_HOOK__?.emit('runtime:disconnected', {
cssRuntime: this
});
return this;
}
refresh(customConfig = this.customConfig) {
if (!this.observing || !this.style.sheet) return this;
for(let i = 1; i <= this.style.sheet.cssRules.length - 1; i++){
this.style.sheet.deleteRule(i);
}
super.refresh(customConfig);
/**
* 拿當前所有的 classNames 按照最新的 colors, config.rules 匹配並生成新的 style
* 所以 refresh 過後 rules 可能會變多也可能會變少
*/ this.classCounts.forEach((_, className)=>{
this.add(className);
});
globalThis.__MASTER_CSS_DEVTOOLS_HOOK__?.emit('runtime:refreshed', {
cssRuntime: this,
customConfig
});
return this;
}
destroy() {
this.disconnect();
globalThis.CSSRuntime.instances.delete(this.root);
globalThis.__MASTER_CSS_DEVTOOLS_HOOK__?.emit('runtime:destroyed', {
cssRuntime: this
});
return this;
}
}
(function(CSSRuntime) {
registerGlobal(CSSRuntime);
})(CSSRuntime);
/**
* Initialize a new CSSRuntime instance and observe the target root
* @param config master css config
* @param root target root to observe
* @param autoObserve auto observe the target root
* @returns master css instance
*/ function initCSSRuntime(config, root = document, autoObserve = true) {
let cssRuntime = globalThis.CSSRuntime.instances.get(root);
if (cssRuntime) return cssRuntime;
cssRuntime = new CSSRuntime(root, config);
if (autoObserve) cssRuntime.observe();
return cssRuntime;
}
exports.CSSRuntime = CSSRuntime;
exports.RuntimeSyntaxLayer = RuntimeSyntaxLayer;
exports.default = CSSRuntime;
exports.initCSSRuntime = initCSSRuntime;