Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions core/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,7 @@ require("./shim/array");
require("./extras/object");
// require("./extras/date");
require("./extras/element");
require("./extras/style-observer");
require("./extras/function");
require("./extras/map");
require("./extras/regexp");
Expand Down
190 changes: 74 additions & 116 deletions core/document-resources.js
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) {
Expand All @@ -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 },
});
}

Expand Down Expand Up @@ -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);
}
}

Expand All @@ -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) {
Expand Down Expand Up @@ -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) {
// 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;
Copy link
Contributor

Choose a reason for hiding this comment

The 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?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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;
}
};
30 changes: 30 additions & 0 deletions core/extras/style-observer.js
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,
});
}
20 changes: 20 additions & 0 deletions examples/scoping-app/index.html
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>
10 changes: 10 additions & 0 deletions examples/scoping-app/package.json
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": "../../"
}
}
8 changes: 8 additions & 0 deletions examples/scoping-app/styles.css
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;
}
20 changes: 20 additions & 0 deletions examples/scoping-app/ui/circle.mod/circle.css
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;
}
}
24 changes: 24 additions & 0 deletions examples/scoping-app/ui/circle.mod/circle.html
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>
6 changes: 6 additions & 0 deletions examples/scoping-app/ui/circle.mod/circle.js
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 {};
Loading