-
Notifications
You must be signed in to change notification settings - Fork 2
Css Layering that simulate scoping #112
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
35603c9
2d8895e
d987749
cbf0129
23e0af8
10ea95c
08cb9c4
41a241b
01542b0
645dd28
6498f25
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,7 @@ | ||
| const Montage = require("./core").Montage; | ||
| const Promise = require("./promise").Promise; | ||
| const URL = require("./mini-url"); | ||
| const currentEnvironment = require("./environment").currentEnvironment; | ||
|
|
||
| exports.DocumentResources = class DocumentResources extends Montage { | ||
| static getInstanceForDocument(_document) { | ||
|
|
@@ -15,28 +16,14 @@ exports.DocumentResources = class DocumentResources extends Montage { | |
|
|
||
| static { | ||
| Montage.defineProperties(this.prototype, { | ||
| wrapsComponentStylesheetsInCSSLayer: { value: true }, | ||
| domain: { value: global.location?.origin ?? "" }, | ||
| _isPollingDocumentStyleSheets: { value: false }, | ||
| _SCRIPT_TIMEOUT: { value: 5_000 }, | ||
| _expectedStyles: { value: null }, | ||
| _resources: { value: null }, | ||
| _preloaded: { value: null }, | ||
| _document: { value: null }, | ||
|
|
||
| // Scope and Layering configuration | ||
| /** | ||
| * #WARNING - EXPERIMENTAL if true, it will trigger the use of the _scopeStylesheetRulesWithSelectorInCSSLayerName() method | ||
| * above to wrap an component's CSS into a @scope rule. modifying selectors such that they work within the new @scope, meaning | ||
| * using pseudo selector :scope as necessary. | ||
| * | ||
| * This works in some limited use cases and would need a lot more subtlety to be robust, reliable | ||
| * and useful | ||
| * | ||
| * @property {boolean} | ||
| */ | ||
| automaticallyAddsCSSLayerToUnscoppedCSS: { value: true }, | ||
| _scopeSelectorRegExp: { value: /scope\(([^()]*)\)/g }, | ||
| automaticallyAddsCSSScope: { value: false }, | ||
| }); | ||
| } | ||
|
|
||
|
|
@@ -125,85 +112,14 @@ exports.DocumentResources = class DocumentResources extends Montage { | |
|
|
||
| if (index >= 0) { | ||
| this._expectedStyles.splice(index, 1); | ||
|
|
||
| const cssContext = this.cssContextForResource(target.href); | ||
| const classListScope = cssContext.classListScope; | ||
| const cssLayerName = cssContext.cssLayerName; | ||
| const stylesheet = target.sheet; | ||
| const cssRules = stylesheet.cssRules; | ||
|
|
||
| /** | ||
| * Adding CSS Layers, and Scoping for components in dev mode. | ||
| * When we mop, we'll add it in the CSS. | ||
| * | ||
| * target.ownerDocument is the page's document. | ||
| * We captured the Component's element's classes before we got here, in this._resources[target.href] | ||
| * | ||
| * @scope (.ComponentElementClass1.ComponentElementClass2) { | ||
| * -> All Component's CSS file's rules needs to be relocated here <- | ||
| * } | ||
| * | ||
| * target.ownerDocument.styleSheets, but we need the component's element's classList | ||
| */ | ||
|
|
||
| if (classListScope && stylesheet.disabled === false && typeof CSSScopeRule === "function") { | ||
| let iStart = 0; | ||
|
|
||
| // Insert the scope rule, after any CSSImportRule | ||
| while (cssRules[iStart] instanceof CSSImportRule) { | ||
| iStart++; | ||
| } | ||
|
|
||
| // If it's not using CSS Layers | ||
| if (!(cssRules[iStart] instanceof CSSLayerBlockRule)) { | ||
| // If it's not using CSSScope | ||
| if (!(cssRules[iStart] instanceof CSSScopeRule) && this.automaticallyAddsCSSScope) { | ||
| this._scopeStylesheetRulesWithSelectorInCSSLayerName( | ||
| stylesheet, | ||
| classListScope, | ||
| cssLayerName, | ||
| ); | ||
| } else if (cssRules[iStart] instanceof CSSScopeRule) { | ||
| // Add the layer name in scope | ||
| const scopeSelectorRegExp = this._scopeSelectorRegExp; | ||
| const scopeRule = stylesheet.cssRules[iStart]; | ||
| const scopeRuleCSSText = scopeRule.cssText; | ||
| let scopeSelector; | ||
| let match; | ||
|
|
||
| // Delete current scopeRule | ||
| stylesheet.deleteRule(iStart); | ||
|
|
||
| while ((match = scopeSelectorRegExp.exec(scopeRuleCSSText)) !== null) { | ||
| scopeSelector = `.${cssLayerName}${match[1]}`; | ||
| scopeRuleCSSText = scopeRuleCSSText.replace(match[1], scopeSelector); | ||
| } | ||
|
|
||
| stylesheet.insertRule(scopeRuleCSSText); | ||
| } | ||
|
|
||
| let scopeRule = stylesheet.cssRules[iStart]; | ||
|
|
||
| // If the CSS is scoped, we move it into the CSSLayerBlockRule | ||
| if (scopeRule && scopeRule instanceof CSSScopeRule) { | ||
| stylesheet.insertRule(`@layer ${cssLayerName} {}`, iStart); | ||
| let packageLayer = stylesheet.cssRules[iStart]; | ||
|
|
||
| scopeRule = stylesheet.cssRules[++iStart]; | ||
|
|
||
| stylesheet.deleteRule(iStart); | ||
| packageLayer.insertRule(scopeRule.cssText); | ||
| } else if (this.automaticallyAddsCSSLayerToUnscoppedCSS) { | ||
| stylesheet.insertRule(`@layer ${cssLayerName} {}`, iStart); | ||
| let packageLayer = stylesheet.cssRules[iStart]; | ||
|
|
||
| // We layer all rules | ||
| for (let i = cssRules.length - 1; i > iStart; i--) { | ||
| packageLayer.insertRule(cssRules[i].cssText); | ||
| stylesheet.deleteRule(i); | ||
| } | ||
| } | ||
| } | ||
| if (cssContext && typeof cssContext === "object") { | ||
| const stylesheet = target.sheet; | ||
|
|
||
| // Adding CSS Layers, and Scoping for components in dev mode. | ||
| // When we mop, we'll add it in the CSS. | ||
| this._wrapStyleSheetInLayer(stylesheet, cssContext); | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -212,15 +128,15 @@ exports.DocumentResources = class DocumentResources extends Montage { | |
| } | ||
| } | ||
|
|
||
| addStyle(element, DOMParent, classListScope, cssLayerName) { | ||
| addStyle(element, DOMParent, context) { | ||
| let url = element.getAttribute("href"); | ||
|
|
||
| if (url) { | ||
| url = this.normalizeUrl(url); | ||
|
|
||
| if (this.hasResource(url)) return; | ||
|
|
||
| this._addResource(url, classListScope, cssLayerName); | ||
| this._addResource(url, context); | ||
| this._expectedStyles.push(url); | ||
|
|
||
| if (!this._isPollingDocumentStyleSheets) { | ||
|
|
@@ -385,34 +301,76 @@ exports.DocumentResources = class DocumentResources extends Montage { | |
| return promise; | ||
| } | ||
|
|
||
| _addResource(url, classListScope, cssLayerName) { | ||
| this._resources[url] = { classListScope, cssLayerName }; | ||
| /** | ||
| * Registers a resource with its associated context information | ||
| * | ||
| * @param {string} url The URL of the resource. | ||
| * @param {{}} [resourceContext={}] An optional context object containing resource related information, | ||
| * such as moduleLayerClassName and moduleLayerPath when importing a stylesheet resource. | ||
| */ | ||
| _addResource(url, resourceContext = {}) { | ||
| this._resources[url] = resourceContext; | ||
| } | ||
|
|
||
| _scopeStylesheetRulesWithSelectorInCSSLayerName(stylesheet, classListScope, cssLayerName) { | ||
| if (classListScope && stylesheet.disabled === false && typeof CSSScopeRule === "function") { | ||
| const classListScopeRegexp = new RegExp(`(${classListScope})+(?=$)|(${classListScope})+(?= >)`, "dg"); | ||
| const classListScopeContentRegexp = new RegExp(`(${classListScope})+(?=[.,:,\s,>]|$)`, "dg"); | ||
| const cssRules = stylesheet.cssRules; | ||
| let iStart = 0; | ||
|
|
||
| // Insert the scope rule, but after any CSSImportRule | ||
| while (cssRules[iStart] instanceof CSSImportRule) { | ||
| iStart++; | ||
| /** | ||
| * Modifies an existing CSSStyleSheet in-place to wrap it in a scoped layer structure. | ||
| * | ||
| * @param {CSSStyleSheet} sheet - The existing CSSStyleSheet to modify. | ||
| * @param {{moduleLayerClassName: string, moduleLayerPath: string}} cssContext - The CSS context. | ||
| * @returns {CSSStyleSheet} The modified stylesheet instance. | ||
| */ | ||
| _wrapStyleSheetInLayer(sheet, cssContext) { | ||
thibaultzanini marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| // Validate requirements for scoping and layering | ||
| if (!currentEnvironment.isLocalModding || !CSSLayerBlockRule || !CSSScopeRule || sheet.disabled) return; | ||
|
|
||
| try { | ||
| const { moduleLayerClassName, moduleLayerPath } = cssContext; | ||
| const rulesToWrap = []; | ||
| let insertionIndex = 0; | ||
|
|
||
| // Iterate backwards so deleting rules doesn't shift indices of unvisited rules | ||
| for (let i = sheet.cssRules.length - 1; i >= 0; i--) { | ||
| const rule = sheet.cssRules[i]; | ||
|
|
||
| // TODO: this is an incomplete list of possibilities | ||
| // that part is experimental, we might need to add more constraints. | ||
| const isImport = rule instanceof CSSImportRule; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. rule can only be an instance of one of those, no? or are those subclasses? So shouldn't that be a case or if/else/else?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this is an incomplete list of possibilities. That part is experimental, we might need to add more constraints in the near future. I left some js comment. |
||
| const isLayer = rule instanceof CSSLayerBlockRule || rule instanceof CSSLayerStatementRule; | ||
| const isRoot = rule instanceof CSSStyleRule && rule.selectorText?.startsWith(":root"); | ||
|
|
||
| if (!isImport && !isLayer && !isRoot) { | ||
| // Unshift to maintain the original top-to-bottom order | ||
| rulesToWrap.unshift(rule.cssText); | ||
| sheet.deleteRule(i); | ||
| insertionIndex = i; | ||
| } | ||
| } | ||
|
|
||
| stylesheet.insertRule(`@scope (.${cssLayerName}${classListScope}) {}`, iStart); | ||
| const scopeRule = cssRules[iStart]; | ||
| if (rulesToWrap.length === 0) return sheet; | ||
|
|
||
| // Now loop on rules to move - re-create them as there's no other way :-( | ||
| for (let i = cssRules.length - 1; i > iStart; i--) { | ||
| cssRules[i].selectorText = cssRules[i].selectorText | ||
| .replaceAll(classListScopeRegexp, ":scope") | ||
| .replaceAll(classListScopeContentRegexp, ""); | ||
| const extractedCssText = rulesToWrap.join("\n"); | ||
|
|
||
| scopeRule.insertRule(cssRules[i].cssText); | ||
| stylesheet.deleteRule(i); | ||
| } | ||
| // Create the new wrapped CSS string | ||
| const wrappedCss = `@layer ${moduleLayerPath} { | ||
| @scope (.${moduleLayerClassName}) { | ||
| :scope, * { all: revert-layer !important; } | ||
| } | ||
|
|
||
| @layer style { | ||
| * { | ||
| all: revert; | ||
| } | ||
|
|
||
| ${extractedCssText} | ||
| } | ||
| }`; | ||
|
|
||
| // Insert the new wrapped CSS into the existing stylesheet | ||
| sheet.insertRule(wrappedCss, insertionIndex); | ||
| } catch (error) { | ||
| console.error("Unable to wrap scoped stylesheet (likely cross-origin)", error); | ||
| } | ||
|
|
||
| return sheet; | ||
| } | ||
| }; | ||
thibaultzanini marked this conversation as resolved.
Show resolved
Hide resolved
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| if (!window.__mod__styleImportantForcerInitialized) { | ||
| window.__mod__styleImportantForcerInitialized = true; | ||
|
|
||
| const styleObserver = new MutationObserver((mutations) => { | ||
| for (const mutation of mutations) { | ||
| const element = mutation.target; | ||
| const style = element.style; | ||
| const length = style.length; | ||
|
|
||
| // Iterate through all CSS properties currently applied to the element inline | ||
| for (let i = 0; i < length; i++) { | ||
| const propName = style[i]; | ||
| const priority = style.getPropertyPriority(propName); | ||
|
|
||
| // If the property doesn't have the '!important' flag, force it | ||
| if (priority !== "important") { | ||
| const value = style.getPropertyValue(propName); | ||
| style.setProperty(propName, value, "important"); | ||
| } | ||
| } | ||
| } | ||
| }); | ||
|
|
||
| styleObserver.observe(document.documentElement, { | ||
| attributes: true, | ||
| // Only listen for 'style' changes to save performance | ||
| attributeFilter: ["style"], | ||
| subtree: true, | ||
| }); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| <!doctype html> | ||
| <html> | ||
| <head> | ||
| <title>Scoping App Demo</title> | ||
| <link rel="stylesheet" href="styles.css" /> | ||
| </head> | ||
| <body> | ||
| <script src="../../montage.js"></script> | ||
| <script type="text/mod-serialization"> | ||
| { | ||
| "owner": { | ||
| "prototype": "mod/ui/loader.mod" | ||
| } | ||
| } | ||
| </script> | ||
| </head> | ||
| <body> | ||
| <span class="loading"></span> | ||
| </body> | ||
| </html> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| { | ||
| "name": "scoping-app", | ||
| "version": "1.0.0", | ||
| "dependencies": { | ||
| "mod": "*" | ||
| }, | ||
| "mappings": { | ||
| "mod": "../../" | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| body { | ||
| font-family: system-ui, sans-serif; | ||
| background-color: #1a1a1a; | ||
| color: #eee; | ||
| height: 100vh; | ||
| margin: 0; | ||
| display: flex; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| .Circle { | ||
| display: flex; | ||
| justify-content: center; | ||
| align-items: center; | ||
| width: 92cqi; | ||
| height: 92cqi; | ||
| border-radius: 50%; | ||
| background-color: lightblue; | ||
| border: 4cqi solid white; | ||
|
|
||
| .inner { | ||
| width: 35cqi; | ||
| height: 35cqi; | ||
| background-color: white; | ||
| border-radius: 50%; | ||
| display: flex; | ||
| justify-content: center; | ||
| align-items: center; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| <!doctype html> | ||
| <html> | ||
| <head> | ||
| <title>Montage Hello</title> | ||
| <link rel="stylesheet" href="circle.css" /> | ||
| <script type="text/mod-serialization"> | ||
| { | ||
| "owner": { | ||
| "values": { | ||
| "element": {"#": "owner"}, | ||
| "enclosesSize": true | ||
| } | ||
| } | ||
| } | ||
| </script> | ||
| </head> | ||
| <body> | ||
| <div data-mod-id="owner" class="Circle"> | ||
| <div class="inner"> | ||
| <span style="color: red !important">Circle</span> | ||
| </div> | ||
| </div> | ||
| </body> | ||
| </html> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| /** | ||
| * @module "ui/circle.mod" | ||
| */ | ||
| const Component = require("mod/ui/component").Component; | ||
|
|
||
| exports.Circle = class Circle extends Component {}; |
Uh oh!
There was an error while loading. Please reload this page.