try to inline linked stylesheet when in same origin

This commit is contained in:
Yanzhen Yu
2026-04-01 12:00:00 +08:00
parent 7fadc986ec
commit 51737d9b53
6 changed files with 110 additions and 33 deletions

View File

@@ -1,4 +1,15 @@
import { serializedNodeWithId, NodeType } from './types'; import { serializedNodeWithId, NodeType, tagMap, elementNode } from './types';
const tagMap: tagMap = {
script: 'noscript',
};
function getTagName(n: elementNode): string {
let tagName = tagMap[n.tagName] ? tagMap[n.tagName] : n.tagName;
if (tagName === 'link' && n.attributes._cssText) {
tagName = 'style';
}
return tagName;
}
function buildNode(n: serializedNodeWithId): Node | null { function buildNode(n: serializedNodeWithId): Node | null {
switch (n.type) { switch (n.type) {
@@ -11,14 +22,15 @@ function buildNode(n: serializedNodeWithId): Node | null {
n.systemId, n.systemId,
); );
case NodeType.Element: case NodeType.Element:
const tagName = n.tagName === 'script' ? 'noscript' : n.tagName; const tagName = getTagName(n);
const node = document.createElement(tagName); const node = document.createElement(tagName);
for (const name in n.attributes) { for (const name in n.attributes) {
if (n.attributes.hasOwnProperty(name)) { if (n.attributes.hasOwnProperty(name)) {
let value = n.attributes[name]; let value = n.attributes[name];
value = typeof value === 'boolean' ? '' : value; value = typeof value === 'boolean' ? '' : value;
// textarea hack const isTextarea = tagName === 'textarea' && name === 'value';
if (n.tagName === 'textarea' && name === 'value') { const isRemoteCss = tagName === 'style' && name === '_cssText';
if (isTextarea || isRemoteCss) {
const child = document.createTextNode(value); const child = document.createTextNode(value);
node.appendChild(child); node.appendChild(child);
continue; continue;

View File

@@ -15,7 +15,18 @@ function resetId() {
_id = 1; _id = 1;
} }
function serializeNode(n: Node): serializedNode | false { function getCssRulesString(s: CSSStyleSheet): string | null {
try {
const rules = s.rules || s.cssRules;
return rules
? Array.from(rules).reduce((prev, cur) => (prev += cur.cssText), '')
: null;
} catch (error) {
return null;
}
}
function serializeNode(n: Node, doc: Document): serializedNode | false {
switch (n.nodeType) { switch (n.nodeType) {
case n.DOCUMENT_NODE: case n.DOCUMENT_NODE:
return { return {
@@ -31,10 +42,23 @@ function serializeNode(n: Node): serializedNode | false {
}; };
case n.ELEMENT_NODE: case n.ELEMENT_NODE:
const tagName = (n as HTMLElement).tagName.toLowerCase(); const tagName = (n as HTMLElement).tagName.toLowerCase();
const attributes: attributes = {}; let attributes: attributes = {};
for (const { name, value } of Array.from((n as HTMLElement).attributes)) { for (const { name, value } of Array.from((n as HTMLElement).attributes)) {
attributes[name] = value; attributes[name] = value;
} }
// remote css
if (tagName === 'link' && attributes.hasOwnProperty('href')) {
const stylesheet = Array.from(doc.styleSheets).find(
s => s.href === attributes.href,
);
const cssText = getCssRulesString(stylesheet as CSSStyleSheet);
if (cssText) {
attributes = {
_cssText: cssText,
};
}
}
// form fields
if ( if (
tagName === 'input' || tagName === 'input' ||
tagName === 'textarea' || tagName === 'textarea' ||
@@ -91,8 +115,8 @@ function serializeNode(n: Node): serializedNode | false {
} }
} }
function _snapshot(n: Node): serializedNodeWithId | null { function _snapshot(n: Node, doc: Document): serializedNodeWithId | null {
const _serializedNode = serializeNode(n); const _serializedNode = serializeNode(n, doc);
if (!_serializedNode) { if (!_serializedNode) {
// TODO: dev only // TODO: dev only
console.warn(n, 'not serialized'); console.warn(n, 'not serialized');
@@ -106,15 +130,15 @@ function _snapshot(n: Node): serializedNodeWithId | null {
serializedNode.type === NodeType.Element serializedNode.type === NodeType.Element
) { ) {
for (const childN of Array.from(n.childNodes)) { for (const childN of Array.from(n.childNodes)) {
serializedNode.childNodes.push(_snapshot(childN)); serializedNode.childNodes.push(_snapshot(childN, doc));
} }
} }
return serializedNode; return serializedNode;
} }
function snapshot(n: Node): serializedNodeWithId | null { function snapshot(n: Document): serializedNodeWithId | null {
resetId(); resetId();
return _snapshot(n); return _snapshot(n, n);
} }
export default snapshot; export default snapshot;

View File

@@ -53,3 +53,7 @@ export type serializedNode =
| commentNode; | commentNode;
export type serializedNodeWithId = serializedNode & { id: number }; export type serializedNodeWithId = serializedNode & { id: number };
export type tagMap = {
[key: string]: string;
};

View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>with style sheet</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/pure@2.85.0/index.css">
</head>
<body>
</body>
</html>

View File

@@ -13,4 +13,23 @@
</body> </body>
</html>
<!-- TEST_DIVIDER -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>with style sheet</title>
<style>body { margin: 0px; }p { color: red; }</style>
</head>
<body>
</body>
</html> </html>

View File

@@ -64,7 +64,7 @@ describe('integration tests', () => {
before(async () => { before(async () => {
this.server = await server(); this.server = await server();
this.browser = await puppeteer.launch({ this.browser = await puppeteer.launch({
headless: false, // headless: false,
executablePath: '/home/yanzhen/Desktop/chrome-linux/chrome', executablePath: '/home/yanzhen/Desktop/chrome-linux/chrome',
}); });
@@ -79,33 +79,35 @@ describe('integration tests', () => {
this.code = code; this.code = code;
}); });
after(() => { after(async () => {
this.browser.close(); await this.browser.close();
this.server.close(); await this.server.close();
}); });
for (const html of htmls) { for (const html of htmls.slice(0, 10)) {
it('[html file]: ' + html.filePath, async () => { it('[html file]: ' + html.filePath, async () => {
const page: puppeteer.Page = await this.browser.newPage(); const page: puppeteer.Page = await this.browser.newPage();
await page.goto(`http://localhost:3030/html/${html.filePath}`); // console for debug
// tslint:disable-next-line: no-console
page.on('console', msg => console.log(msg.text()));
await page.goto(`http://localhost:3030/html`);
await page.setContent(html.src); await page.setContent(html.src);
page.once('load', async () => { await page.evaluate(() => {
await page.evaluate(() => { const x = new XMLSerializer();
const x = new XMLSerializer(); return x.serializeToString(document);
return x.serializeToString(document);
});
const rebuildHtml = (await page.evaluate(`${this.code}
const x = new XMLSerializer();
const snap = rrweb.snapshot(document);
x.serializeToString(rrweb.rebuild(snap));
`)).replace(/\n\n/g, '');
await page.goto(`data:text/html,${html.dest}`);
const destHtml = (await page.evaluate(() => {
const x = new XMLSerializer();
return x.serializeToString(document);
})).replace(/\n\n/g, '');
expect(rebuildHtml).to.equal(destHtml);
}); });
const rebuildHtml = (await page.evaluate(`${this.code}
const x = new XMLSerializer();
const snap = rrweb.snapshot(document);
x.serializeToString(rrweb.rebuild(snap));
`)).replace(/\n\n/g, '');
await page.goto(`http://localhost:3030/html`);
await page.setContent(html.dest);
const destHtml = (await page.evaluate(() => {
const x = new XMLSerializer();
return x.serializeToString(document);
})).replace(/\n\n/g, '');
expect(rebuildHtml).to.equal(destHtml);
}).timeout(5000); }).timeout(5000);
} }
}); });