Fix HTML export missing a bunch of Compound variables (#12774)
This commit is contained in:
parent
38e1da5626
commit
b4ef5d3cc3
8 changed files with 315 additions and 173 deletions
|
@ -91,6 +91,7 @@
|
|||
"classnames": "^2.2.6",
|
||||
"commonmark": "^0.31.0",
|
||||
"counterpart": "^0.18.6",
|
||||
"css-tree": "^2.3.1",
|
||||
"diff-dom": "^5.0.0",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
"emojibase-regex": "15.3.2",
|
||||
|
@ -167,6 +168,7 @@
|
|||
"@types/commonmark": "^0.27.4",
|
||||
"@types/content-type": "^1.1.5",
|
||||
"@types/counterpart": "^0.18.1",
|
||||
"@types/css-tree": "^2.3.8",
|
||||
"@types/diff-match-patch": "^1.0.32",
|
||||
"@types/escape-html": "^1.0.1",
|
||||
"@types/express": "^4.17.21",
|
||||
|
|
132
playwright/e2e/chat-export/html-export.spec.ts
Normal file
132
playwright/e2e/chat-export/html-export.spec.ts
Normal file
|
@ -0,0 +1,132 @@
|
|||
/*
|
||||
Copyright 2024 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import * as fsp from "node:fs/promises";
|
||||
import * as fs from "node:fs";
|
||||
import JSZip from "jszip";
|
||||
|
||||
import { test, expect } from "../../element-web-test";
|
||||
|
||||
// Based on https://github.com/Stuk/jszip/issues/466#issuecomment-2097061912
|
||||
async function extractZipFileToPath(file: string, outputPath: string): Promise<JSZip> {
|
||||
if (!fs.existsSync(outputPath)) {
|
||||
fs.mkdirSync(outputPath, { recursive: true });
|
||||
}
|
||||
|
||||
const data = await fsp.readFile(file);
|
||||
const zip = await JSZip.loadAsync(data, { createFolders: true });
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let entryCount = 0;
|
||||
let errorOut = false;
|
||||
|
||||
zip.forEach(() => {
|
||||
entryCount++;
|
||||
}); // there is no other way to count the number of entries within the zip file.
|
||||
|
||||
zip.forEach((relativePath, zipEntry) => {
|
||||
if (errorOut) {
|
||||
return;
|
||||
}
|
||||
|
||||
const outputEntryPath = path.join(outputPath, relativePath);
|
||||
if (zipEntry.dir) {
|
||||
if (!fs.existsSync(outputEntryPath)) {
|
||||
fs.mkdirSync(outputEntryPath, { recursive: true });
|
||||
}
|
||||
|
||||
entryCount--;
|
||||
|
||||
if (entryCount === 0) {
|
||||
resolve();
|
||||
}
|
||||
} else {
|
||||
void zipEntry
|
||||
.async("blob")
|
||||
.then(async (content) => Buffer.from(await content.arrayBuffer()))
|
||||
.then((buffer) => {
|
||||
const stream = fs.createWriteStream(outputEntryPath);
|
||||
stream.write(buffer, (error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
errorOut = true;
|
||||
}
|
||||
});
|
||||
stream.on("finish", () => {
|
||||
entryCount--;
|
||||
|
||||
if (entryCount === 0) {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
stream.end(); // extremely important on Windows. On Mac / Linux, not so much since those platforms allow multiple apps to read from the same file. Windows doesn't allow that.
|
||||
})
|
||||
.catch((e) => {
|
||||
errorOut = true;
|
||||
reject(e);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return zip;
|
||||
}
|
||||
|
||||
test.describe("HTML Export", () => {
|
||||
test.use({
|
||||
displayName: "Alice",
|
||||
room: async ({ app, user }, use) => {
|
||||
const roomId = await app.client.createRoom({ name: "Important Room" });
|
||||
await app.viewRoomByName("Important Room");
|
||||
await use({ roomId });
|
||||
},
|
||||
});
|
||||
|
||||
test("should export html successfully and match screenshot", async ({ page, app, room }) => {
|
||||
// Send a bunch of messages to populate the room
|
||||
for (let i = 1; i < 10; i++) {
|
||||
await app.client.sendMessage(room.roomId, { body: `Testing ${i}`, msgtype: "m.text" });
|
||||
}
|
||||
|
||||
// Wait for all the messages to be displayed
|
||||
await expect(
|
||||
page.locator(".mx_EventTile_last .mx_MTextBody .mx_EventTile_body").getByText("Testing 9"),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Room info" }).click();
|
||||
await page.getByRole("menuitem", { name: "Export Chat" }).click();
|
||||
|
||||
const downloadPromise = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: "Export", exact: true }).click();
|
||||
const download = await downloadPromise;
|
||||
|
||||
const dirPath = path.join(os.tmpdir(), "html-export-test");
|
||||
const zipPath = `${dirPath}.zip`;
|
||||
await download.saveAs(zipPath);
|
||||
|
||||
const zip = await extractZipFileToPath(zipPath, dirPath);
|
||||
await page.goto(`file://${dirPath}/${Object.keys(zip.files)[0]}/messages.html`);
|
||||
await expect(page).toMatchScreenshot("html-export.png", {
|
||||
mask: [
|
||||
page.getByText("This is the start of export", { exact: false }),
|
||||
page.locator(".mx_DateSeparator_dateHeading"),
|
||||
page.locator(".mx_MessageTimestamp"),
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
Binary file not shown.
After Width: | Height: | Size: 40 KiB |
|
@ -163,81 +163,77 @@ export default class HTMLExporter extends Exporter {
|
|||
<script src="js/script.js"></script>
|
||||
<title>${_t("export_chat|html_title")}</title>
|
||||
</head>
|
||||
<body style="height: 100vh;">
|
||||
<div
|
||||
id="matrixchat"
|
||||
style="height: 100%; overflow: auto"
|
||||
class="notranslate"
|
||||
>
|
||||
<div class="mx_MatrixChat_wrapper" aria-hidden="false">
|
||||
<div class="mx_MatrixChat">
|
||||
<main class="mx_RoomView">
|
||||
<div class="mx_LegacyRoomHeader light-panel">
|
||||
<div class="mx_LegacyRoomHeader_wrapper" aria-owns="mx_RightPanel">
|
||||
<div class="mx_LegacyRoomHeader_avatar">
|
||||
<div class="mx_DecoratedRoomAvatar">
|
||||
${roomAvatar}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mx_LegacyRoomHeader_name">
|
||||
<div
|
||||
dir="auto"
|
||||
class="mx_LegacyRoomHeader_nametext"
|
||||
title="${safeRoomName}"
|
||||
>
|
||||
${safeRoomName}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mx_LegacyRoomHeader_topic" dir="auto"> ${safeTopic} </div>
|
||||
</div>
|
||||
</div>
|
||||
${previousMessagesLink}
|
||||
<div class="mx_MainSplit">
|
||||
<div class="mx_RoomView_body">
|
||||
<div
|
||||
class="mx_RoomView_timeline mx_RoomView_timeline_rr_enabled"
|
||||
>
|
||||
<div
|
||||
class="
|
||||
mx_AutoHideScrollbar
|
||||
mx_ScrollPanel
|
||||
mx_RoomView_messagePanel
|
||||
"
|
||||
>
|
||||
<div class="mx_RoomView_messageListWrapper">
|
||||
<ol
|
||||
class="mx_RoomView_MessageList"
|
||||
aria-live="polite"
|
||||
role="list"
|
||||
<body style="height: 100vh;" class="cpd-theme-light">
|
||||
<div id="matrixchat" style="height: 100%; overflow: auto">
|
||||
<div class="mx_MatrixChat_wrapper" aria-hidden="false">
|
||||
<div class="mx_MatrixChat">
|
||||
<main class="mx_RoomView">
|
||||
<div class="mx_LegacyRoomHeader light-panel">
|
||||
<div class="mx_LegacyRoomHeader_wrapper" aria-owns="mx_RightPanel">
|
||||
<div class="mx_LegacyRoomHeader_avatar">
|
||||
<div class="mx_DecoratedRoomAvatar">
|
||||
${roomAvatar}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mx_LegacyRoomHeader_name">
|
||||
<div
|
||||
dir="auto"
|
||||
class="mx_LegacyRoomHeader_nametext"
|
||||
title="${safeRoomName}"
|
||||
>
|
||||
${
|
||||
currentPage == 0
|
||||
? `<div class="mx_NewRoomIntro">
|
||||
${roomAvatar}
|
||||
<h2> ${safeRoomName} </h2>
|
||||
<p> ${safeCreatedText} <br/><br/> ${safeExportedText} </p>
|
||||
<br/>
|
||||
<p> ${safeTopicText} </p>
|
||||
</div>`
|
||||
: ""
|
||||
}
|
||||
${content}
|
||||
</ol>
|
||||
${safeRoomName}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mx_LegacyRoomHeader_topic" dir="auto"> ${safeTopic} </div>
|
||||
</div>
|
||||
</div>
|
||||
${previousMessagesLink}
|
||||
<div class="mx_MainSplit">
|
||||
<div class="mx_RoomView_body">
|
||||
<div
|
||||
class="mx_RoomView_timeline mx_RoomView_timeline_rr_enabled"
|
||||
>
|
||||
<div
|
||||
class="
|
||||
mx_AutoHideScrollbar
|
||||
mx_ScrollPanel
|
||||
mx_RoomView_messagePanel
|
||||
"
|
||||
>
|
||||
<div class="mx_RoomView_messageListWrapper">
|
||||
<ol
|
||||
class="mx_RoomView_MessageList"
|
||||
aria-live="polite"
|
||||
role="list"
|
||||
>
|
||||
${
|
||||
currentPage == 0
|
||||
? `<div class="mx_NewRoomIntro">
|
||||
${roomAvatar}
|
||||
<h2> ${safeRoomName} </h2>
|
||||
<p> ${safeCreatedText} <br/><br/> ${safeExportedText} </p>
|
||||
<br/>
|
||||
<p> ${safeTopicText} </p>
|
||||
</div>`
|
||||
: ""
|
||||
}
|
||||
${content}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mx_RoomView_statusArea">
|
||||
<div class="mx_RoomView_statusAreaBox">
|
||||
<div class="mx_RoomView_statusAreaBox_line"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mx_RoomView_statusArea">
|
||||
<div class="mx_RoomView_statusAreaBox">
|
||||
<div class="mx_RoomView_statusAreaBox_line"></div>
|
||||
</div>
|
||||
</div>
|
||||
${nextMessagesLink}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
${nextMessagesLink}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="snackbar"/>
|
||||
</body>
|
||||
</html>`;
|
||||
|
|
|
@ -14,74 +14,80 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import type { Rule, StyleSheet } from "css-tree";
|
||||
|
||||
import customCSS from "!!raw-loader!./exportCustomCSS.css";
|
||||
|
||||
const cssSelectorTextClassesRegex = /\.[\w-]+/g;
|
||||
|
||||
function mutateCssText(css: string): string {
|
||||
// replace used fonts so that we don't have to bundle Inter & Inconsalata
|
||||
const sansFont = `-apple-system, BlinkMacSystemFont, avenir next,
|
||||
avenir, segoe ui, helvetica neue, helvetica, Ubuntu, roboto, noto, arial, sans-serif`;
|
||||
return css
|
||||
.replace(
|
||||
/font-family: ?(Inter|'Inter'|"Inter")/g,
|
||||
`font-family: -apple-system, BlinkMacSystemFont, avenir next,
|
||||
avenir, segoe ui, helvetica neue, helvetica, Ubuntu, roboto, noto, arial, sans-serif`,
|
||||
)
|
||||
.replace(/font-family: ?(Inter|'Inter'|"Inter")/g, `font-family: ${sansFont}`)
|
||||
.replace(/--cpd-font-family-sans: ?(Inter|'Inter'|"Inter")/g, `--cpd-font-family-sans: ${sansFont}`)
|
||||
.replace(
|
||||
/font-family: ?Inconsolata/g,
|
||||
"font-family: Menlo, Consolas, Monaco, Liberation Mono, Lucida Console, monospace",
|
||||
);
|
||||
}
|
||||
|
||||
function isLightTheme(sheet: CSSStyleSheet): boolean {
|
||||
return (<HTMLStyleElement>sheet.ownerNode)?.dataset.mxTheme?.toLowerCase() === "light";
|
||||
}
|
||||
function includeRule(rule: Rule, usedClasses: Set<string>): boolean {
|
||||
if (rule.prelude.type === "Raw") {
|
||||
// cull empty rules
|
||||
if (rule.block.children.isEmpty) return false;
|
||||
|
||||
async function getRulesFromCssFile(path: string): Promise<CSSStyleSheet> {
|
||||
const doc = document.implementation.createHTMLDocument("");
|
||||
const styleElement = document.createElement("style");
|
||||
|
||||
const res = await fetch(path);
|
||||
styleElement.textContent = await res.text();
|
||||
// the style will only be parsed once it is added to a document
|
||||
doc.body.appendChild(styleElement);
|
||||
|
||||
return styleElement.sheet!;
|
||||
return rule.prelude.value.split(",").some((subselector) => {
|
||||
const classes = subselector.trim().match(cssSelectorTextClassesRegex);
|
||||
if (classes && !classes.every((c) => usedClasses.has(c.substring(1)))) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// naively culls unused css rules based on which classes are present in the html,
|
||||
// doesn't cull rules which won't apply due to the full selector not matching but gets rid of a LOT of cruft anyway.
|
||||
// We cannot use document.styleSheets as it does not handle variables in shorthand properties sanely,
|
||||
// see https://github.com/element-hq/element-web/issues/26761
|
||||
const getExportCSS = async (usedClasses: Set<string>): Promise<string> => {
|
||||
// only include bundle.css and the data-mx-theme=light styling
|
||||
const stylesheets = Array.from(document.styleSheets).filter((s) => {
|
||||
return s.href?.endsWith("bundle.css") || isLightTheme(s);
|
||||
const csstree = await import("css-tree");
|
||||
|
||||
// only include bundle.css and light theme styling
|
||||
const hrefs = ["bundle.css", "theme-light.css"].map((name) => {
|
||||
return document.querySelector<HTMLLinkElement>(`link[rel="stylesheet"][href$="${name}"]`)?.href;
|
||||
});
|
||||
|
||||
// If the light theme isn't loaded we will have to fetch & parse it manually
|
||||
if (!stylesheets.some(isLightTheme)) {
|
||||
const href = document.querySelector<HTMLLinkElement>('link[rel="stylesheet"][href$="theme-light.css"]')?.href;
|
||||
if (href) stylesheets.push(await getRulesFromCssFile(href));
|
||||
}
|
||||
|
||||
let css = "";
|
||||
for (const stylesheet of stylesheets) {
|
||||
for (const rule of stylesheet.cssRules) {
|
||||
if (rule instanceof CSSFontFaceRule) continue; // we don't want to bundle any fonts
|
||||
|
||||
const selectorText = (rule as CSSStyleRule).selectorText;
|
||||
for (const href of hrefs) {
|
||||
if (!href) continue;
|
||||
const res = await fetch(href);
|
||||
const text = await res.text();
|
||||
|
||||
// only skip the rule if all branches (,) of the selector are redundant
|
||||
if (
|
||||
selectorText?.split(",").every((selector) => {
|
||||
const classes = selector.match(cssSelectorTextClassesRegex);
|
||||
if (classes && !classes.every((c) => usedClasses.has(c.substring(1)))) {
|
||||
return true; // signal as a redundant selector
|
||||
}
|
||||
})
|
||||
) {
|
||||
continue; // skip this rule as it is redundant
|
||||
const ast = csstree.parse(text, {
|
||||
context: "stylesheet",
|
||||
parseAtrulePrelude: false,
|
||||
parseRulePrelude: false,
|
||||
parseValue: false,
|
||||
parseCustomProperty: false,
|
||||
}) as StyleSheet;
|
||||
|
||||
for (const rule of ast.children) {
|
||||
if (rule.type === "Atrule") {
|
||||
if (rule.name === "font-face") {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
css += mutateCssText(rule.cssText) + "\n";
|
||||
if (rule.type === "Rule" && !includeRule(rule, usedClasses)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
css += mutateCssText(csstree.generate(rule));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -18,6 +18,11 @@ limitations under the License.
|
|||
This file is raw-imported (imported as plain text) for the export bundle, which is the reason for the .css format and the colours being hard-coded hard-coded.
|
||||
*/
|
||||
|
||||
html,
|
||||
body {
|
||||
font-size: var(--cpd-font-size-root) !important;
|
||||
}
|
||||
|
||||
#snackbar {
|
||||
display: flex;
|
||||
visibility: hidden;
|
||||
|
|
File diff suppressed because one or more lines are too long
|
@ -2479,6 +2479,11 @@
|
|||
resolved "https://registry.yarnpkg.com/@types/counterpart/-/counterpart-0.18.4.tgz#e3e331b7e0d5496873d417839f3b2bbcf555bb73"
|
||||
integrity sha512-aqBg5oAGo/qh/+wxUfuMadDu2WO0MEWOblyzwaM1Ske2xilUxBfgPqapAFVAfrVTDMVwa0UMarzGot8m64IAzA==
|
||||
|
||||
"@types/css-tree@^2.3.8":
|
||||
version "2.3.8"
|
||||
resolved "https://registry.yarnpkg.com/@types/css-tree/-/css-tree-2.3.8.tgz#0eabc115e45051b2f7abe51ee1531074b234ed19"
|
||||
integrity sha512-zABG3nI2UENsx7AQv63tI5/ptoAG/7kQR1H0OvG+WTWYHOR5pfAT3cGgC8SdyCrgX/TTxJBZNmx82IjCXs1juQ==
|
||||
|
||||
"@types/diff-match-patch@^1.0.32":
|
||||
version "1.0.36"
|
||||
resolved "https://registry.yarnpkg.com/@types/diff-match-patch/-/diff-match-patch-1.0.36.tgz#dcef10a69d357fe9d43ac4ff2eca6b85dbf466af"
|
||||
|
|
Loading…
Reference in a new issue