UNPKG

@master/css-runtime

Version:

Run Master CSS right in the browser

480 lines (470 loc) 20.8 kB
'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;