dom: apply dynamically linked stylesheets
This commit is contained in:
1 parent
085c1458c2
commit
fa6503c560
2 files changed
+204
-6
No files matched your search
Vendored
+139
-6
@@ -295,11 +295,127 @@ function _resolveResourceUrl(src) {
|
|||||||
} catch(e) { return src; }
|
} catch(e) { return src; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// A dynamically-inserted <link rel="stylesheet" href> must fetch and fire
|
const _linkedStylesheetNodes = new WeakMap();
|
||||||
// load/error so frameworks awaiting the link's onload (Promise.all of lazy
|
|
||||||
// CSS + JS, antd/bootstrap loaders, etc.) resolve instead of hanging forever.
|
// A fetched sheet becomes an inline <style>, so relative url() references
|
||||||
// There is no layout engine to apply the CSS, but the load-event contract
|
// must keep resolving against the stylesheet URL rather than document.URL.
|
||||||
// matches Chrome. Issue #409.
|
// Scan instead of using a regexp: data URLs and quoted URLs can contain
|
||||||
|
// parentheses, quotes, and whitespace.
|
||||||
|
function _rebaseCssUrls(css, baseUrl) {
|
||||||
|
let out = "";
|
||||||
|
let i = 0;
|
||||||
|
let quote = "";
|
||||||
|
let comment = false;
|
||||||
|
while (i < css.length) {
|
||||||
|
if (comment) {
|
||||||
|
if (css[i] === "*" && css[i + 1] === "/") {
|
||||||
|
out += "*/"; i += 2; comment = false;
|
||||||
|
} else {
|
||||||
|
out += css[i++];
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (quote) {
|
||||||
|
const ch = css[i++];
|
||||||
|
out += ch;
|
||||||
|
if (ch === "\\" && i < css.length) out += css[i++];
|
||||||
|
else if (ch === quote) quote = "";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (css[i] === "/" && css[i + 1] === "*") {
|
||||||
|
out += "/*"; i += 2; comment = true; continue;
|
||||||
|
}
|
||||||
|
if (css[i] === '"' || css[i] === "'") {
|
||||||
|
quote = css[i]; out += css[i++]; continue;
|
||||||
|
}
|
||||||
|
if (css.slice(i, i + 4).toLowerCase() !== "url(") {
|
||||||
|
out += css[i++]; continue;
|
||||||
|
}
|
||||||
|
let end = i + 4;
|
||||||
|
let innerQuote = "";
|
||||||
|
while (end < css.length) {
|
||||||
|
const ch = css[end];
|
||||||
|
if (innerQuote) {
|
||||||
|
if (ch === "\\") { end += 2; continue; }
|
||||||
|
if (ch === innerQuote) innerQuote = "";
|
||||||
|
} else if (ch === '"' || ch === "'") {
|
||||||
|
innerQuote = ch;
|
||||||
|
} else if (ch === ")") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
end++;
|
||||||
|
}
|
||||||
|
if (end >= css.length) {
|
||||||
|
out += css.slice(i);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const raw = css.slice(i + 4, end).trim();
|
||||||
|
const value = raw.length >= 2
|
||||||
|
&& ((raw[0] === '"' && raw[raw.length - 1] === '"')
|
||||||
|
|| (raw[0] === "'" && raw[raw.length - 1] === "'"))
|
||||||
|
? raw.slice(1, -1)
|
||||||
|
: raw;
|
||||||
|
let resolved = value;
|
||||||
|
if (value && !/^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i.test(value)) {
|
||||||
|
try { resolved = new URL(value, baseUrl).href; } catch(e) {}
|
||||||
|
}
|
||||||
|
out += `url("${resolved.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}")`;
|
||||||
|
i = end + 1;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _cssImportApplies(media) {
|
||||||
|
const compact = media.replace(/\s+/g, "").toLowerCase();
|
||||||
|
if (!compact) return true;
|
||||||
|
if (compact.includes("prefers-color-scheme:dark")) return false;
|
||||||
|
if (compact.includes("print")
|
||||||
|
&& !compact.includes("screen")
|
||||||
|
&& !compact.includes("all")) return false;
|
||||||
|
if (compact.includes("min-width") || compact.includes("max-width")
|
||||||
|
|| compact.includes("prefers-")) {
|
||||||
|
try { return matchMedia(media).matches; } catch(e) {}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _fetchLinkedCss(url, pageOrigin, depth = 0, seen = new Set()) {
|
||||||
|
if (depth > 4 || seen.has(url)) return "";
|
||||||
|
seen.add(url);
|
||||||
|
const raw = await Deno.core.ops.op_fetch_url(
|
||||||
|
url, "GET", "{}", "", pageOrigin, "no-cors"
|
||||||
|
);
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (parsed.blocked || parsed.status >= 400 || parsed.status === 0) {
|
||||||
|
throw new Error("Stylesheet fetch failed: " + url);
|
||||||
|
}
|
||||||
|
let css = parsed.body || "";
|
||||||
|
const imports = [];
|
||||||
|
// @import is only valid before ordinary rules. Removing it here lets the
|
||||||
|
// renderer consume the imported rules from the materialized <style>.
|
||||||
|
css = css.replace(
|
||||||
|
/@import\s+(?:url\(\s*)?(?:"([^"]+)"|'([^']+)'|([^'"\s;)]+))\s*\)?\s*([^;]*);/gi,
|
||||||
|
(statement, doubleQuoted, singleQuoted, bare, media) => {
|
||||||
|
const target = doubleQuoted || singleQuoted || bare || "";
|
||||||
|
if (_cssImportApplies(media || "")) {
|
||||||
|
try {
|
||||||
|
imports.push(new URL(target, url).href);
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const imported = await Promise.all(imports.map(importUrl =>
|
||||||
|
_fetchLinkedCss(importUrl, pageOrigin, depth + 1, new Set(seen))
|
||||||
|
));
|
||||||
|
imported.push(_rebaseCssUrls(css, url));
|
||||||
|
return imported.filter(Boolean).join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// A dynamically-inserted <link rel="stylesheet" href> must fetch, enter the
|
||||||
|
// live cascade, and then fire load. Framework route chunks commonly await this
|
||||||
|
// event before revealing their content; firing it while discarding the CSS
|
||||||
|
// left the DOM loaded but unstyled. Issue #409.
|
||||||
async function _loadLinkedStylesheet(c) {
|
async function _loadLinkedStylesheet(c) {
|
||||||
// obscura does not yet reflect the `rel` IDL attribute back to the content
|
// obscura does not yet reflect the `rel` IDL attribute back to the content
|
||||||
// attribute, so `link.rel = "stylesheet"` leaves getAttribute('rel') null.
|
// attribute, so `link.rel = "stylesheet"` leaves getAttribute('rel') null.
|
||||||
@@ -313,7 +429,17 @@ async function _loadLinkedStylesheet(c) {
|
|||||||
let pageOrigin = "";
|
let pageOrigin = "";
|
||||||
try { pageOrigin = new URL(fullUrl).origin; } catch(e) {}
|
try { pageOrigin = new URL(fullUrl).origin; } catch(e) {}
|
||||||
try {
|
try {
|
||||||
await Deno.core.ops.op_fetch_url(fullUrl, "GET", "{}", "", pageOrigin, "no-cors");
|
const css = await _fetchLinkedCss(fullUrl, pageOrigin);
|
||||||
|
const previous = _linkedStylesheetNodes.get(c);
|
||||||
|
if (previous?.parentNode) previous.parentNode.removeChild(previous);
|
||||||
|
const media = c.getAttribute("media") || "";
|
||||||
|
if (c.parentNode && !c.disabled && _cssImportApplies(media)) {
|
||||||
|
const style = document.createElement("style");
|
||||||
|
style.setAttribute("data-obscura-linked", fullUrl);
|
||||||
|
style.textContent = css;
|
||||||
|
c.parentNode.insertBefore(style, c.nextSibling);
|
||||||
|
_linkedStylesheetNodes.set(c, style);
|
||||||
|
}
|
||||||
try { c.dispatchEvent(new Event('load', { bubbles: true })); } catch(e) {}
|
try { c.dispatchEvent(new Event('load', { bubbles: true })); } catch(e) {}
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
try { c.dispatchEvent(new Event('error', { bubbles: true })); } catch(e) {}
|
try { c.dispatchEvent(new Event('error', { bubbles: true })); } catch(e) {}
|
||||||
@@ -1003,6 +1129,13 @@ class Node {
|
|||||||
}
|
}
|
||||||
removeChild(c) {
|
removeChild(c) {
|
||||||
if (!c) return c;
|
if (!c) return c;
|
||||||
|
const linkedStyle = c instanceof Element
|
||||||
|
? _linkedStylesheetNodes.get(c)
|
||||||
|
: null;
|
||||||
|
if (linkedStyle?.parentNode === this) {
|
||||||
|
_dom("remove_child", linkedStyle._nid);
|
||||||
|
_linkedStylesheetNodes.delete(c);
|
||||||
|
}
|
||||||
_dom("remove_child", c._nid);
|
_dom("remove_child", c._nid);
|
||||||
if (globalThis.__mutationObservers?.length) globalThis.__notifyMutation('childList', this._nid, [], [c._nid]);
|
if (globalThis.__mutationObservers?.length) globalThis.__notifyMutation('childList', this._nid, [], [c._nid]);
|
||||||
return c;
|
return c;
|
||||||
|
|||||||
@@ -3838,6 +3838,71 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn dynamic_linked_stylesheet_enters_the_live_dom_with_imports_rebased() {
|
||||||
|
let mut rt = setup_runtime(
|
||||||
|
"<html><head></head><body><div class=\"card\"></div></body></html>",
|
||||||
|
);
|
||||||
|
let result = rt
|
||||||
|
.call_function_on_for_cdp(
|
||||||
|
r#"async () => {
|
||||||
|
const originalFetchOp = Deno.core.ops.op_fetch_url;
|
||||||
|
try {
|
||||||
|
Deno.core.ops.op_fetch_url = (url) => JSON.stringify({
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "text/css" },
|
||||||
|
body: url.endsWith("/assets/route.css")
|
||||||
|
? '@import "./theme/base.css"; .card { display:grid; background-image:url("../img/card.png") }'
|
||||||
|
: '.card { color:red; background-image:url("./grain.png") }',
|
||||||
|
url,
|
||||||
|
});
|
||||||
|
const link = document.createElement("link");
|
||||||
|
link.setAttribute("rel", "stylesheet");
|
||||||
|
link.setAttribute("href", "/assets/route.css");
|
||||||
|
const loaded = new Promise(resolve => {
|
||||||
|
link.onload = () => resolve();
|
||||||
|
});
|
||||||
|
document.head.appendChild(link);
|
||||||
|
await loaded;
|
||||||
|
const style = document.querySelector("style[data-obscura-linked]");
|
||||||
|
const css = style.textContent;
|
||||||
|
const afterLink = link.nextSibling === style;
|
||||||
|
link.remove();
|
||||||
|
return {
|
||||||
|
afterLink,
|
||||||
|
importedBeforeRoute:
|
||||||
|
css.indexOf("color:red") < css.indexOf("display:grid"),
|
||||||
|
importedUrl:
|
||||||
|
css.includes("http://example.com/assets/theme/grain.png"),
|
||||||
|
routeUrl:
|
||||||
|
css.includes("http://example.com/img/card.png"),
|
||||||
|
removedWithLink:
|
||||||
|
!document.querySelector("style[data-obscura-linked]"),
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
Deno.core.ops.op_fetch_url = originalFetchOp;
|
||||||
|
}
|
||||||
|
}"#,
|
||||||
|
None,
|
||||||
|
&[],
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result.value.unwrap(),
|
||||||
|
serde_json::json!({
|
||||||
|
"afterLink": true,
|
||||||
|
"importedBeforeRoute": true,
|
||||||
|
"importedUrl": true,
|
||||||
|
"routeUrl": true,
|
||||||
|
"removedWithLink": true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "current_thread")]
|
#[tokio::test(flavor = "current_thread")]
|
||||||
async fn test_response_array_buffer_preserves_typed_array_view() {
|
async fn test_response_array_buffer_preserves_typed_array_view() {
|
||||||
let mut rt = setup_runtime("<html><body></body></html>");
|
let mut rt = setup_runtime("<html><body></body></html>");
|
||||||
|
|||||||
Reference in new issue
Block a user