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
256 changes: 142 additions & 114 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];
if (cssContext && typeof cssContext === "object") {
const stylesheet = target.sheet;

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

const extractedCssText = rulesToWrap.join("\n");

/*
WIP / TODO:

WARNING The first 2 if() bellow are hard coded to test within a specific private app to diagnose the issue.
THEY HAVE TO GO:
this is a proof of concept fix for an oversight, the css encapsulation messes with template arguments.
The solution is to use a "donut" hole - see https://css-tricks.com/solved-by-css-donuts-scopes/
and https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@scope,
carve out the scope so the injected content stays in the scope where it was created
and where it is likely styles.

generalize: we need to have a relaiable way to know where the hole starts / end, but right now, mod doesn't keep the template arguments dom elements.
They are replaced by the content itsels.

So we need to change that, keep the DOM elements with the data-param, like the catch all:

<div data-param="*"></div>

or named ones:

<span data-param="leftSide"></span>

and to make it "transparent" to not cause regressions, which we can now do using

// 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, "");
display: contents;

—a "magical" new display value that essentially makes the container disappear, making the child elements children of the element the next level up in the DOM.

See:
- https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/display#display_contents
- https://css-tricks.com/get-ready-for-display-contents/

*/

if(moduleLayerPath === "ford-path.ui.common.scroller.mod") {
// Create the new wrapped CSS string
var wrappedCss = `@layer ${moduleLayerPath} {
@scope (.${moduleLayerClassName}) to (.Scroller-content) {
:scope, * { all: revert-layer !important; background-color: red;}
}

@layer style {
* {
all: revert;
}

${extractedCssText}
}
}`;

}
else if(moduleLayerPath === "ford-path.ui.frames.screen-frame.mod") {
// Create the new wrapped CSS string
var wrappedCss = `@layer ${moduleLayerPath} {
@scope (.${moduleLayerClassName}) to (.ScreenFrame-subtitleContainer + *) {
:scope, * { all: revert-layer !important; background-color: red;}
}

@layer style {
* {
all: revert;
}

${extractedCssText}
}
}`;

} else {
// Create the new wrapped CSS string
var wrappedCss = `@layer ${moduleLayerPath} {
@scope (.${moduleLayerClassName}) {
:scope, * { all: revert-layer !important; }
}

@layer style {
* {
all: revert;
}

${extractedCssText}
}
}`;

scopeRule.insertRule(cssRules[i].cssText);
stylesheet.deleteRule(i);
}

// 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 (typeof window !== "undefined" && !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;
}
Loading