diff --git a/package.json b/package.json
index a8b19b7c..5acbbe77 100644
--- a/package.json
+++ b/package.json
@@ -29,6 +29,7 @@
"@babel/preset-env": "^7.11.0",
"@rollup/plugin-babel": "^5.1.0",
"@rollup/plugin-commonjs": "^15.0.0",
+ "@rollup/plugin-json": "^4.1.0",
"@rollup/plugin-multi-entry": "^4.0.0",
"@rollup/plugin-node-resolve": "^9.0.0",
"autoprefixer": "^10.2.6",
@@ -40,6 +41,7 @@
"finalhandler": "^1.1.1",
"impunity": "^1.0.0",
"mdn-polyfills": "^5.20.0",
+ "node-html-parser": "^4.0.0",
"postcss": "^8.1.1",
"postcss-css-variables": "^0.17.0",
"postcss-flexbugs-fixes": "^4.2.1",
@@ -52,12 +54,13 @@
"xxhashjs": "^0.2.2"
},
"dependencies": {
+ "@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.3.tgz",
"aes-js": "^3.1.2",
"another-json": "^0.2.0",
"base64-arraybuffer": "^0.2.0",
"bs58": "^4.0.1",
+ "dompurify": "^2.3.0",
"es6-promise": "https://github.com/bwindels/es6-promise.git#bwindels/expose-flush",
- "@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.3.tgz",
"text-encoding": "^0.7.0"
}
}
diff --git a/scripts/post-install.js b/scripts/post-install.js
index 150807c3..fe8743b6 100644
--- a/scripts/post-install.js
+++ b/scripts/post-install.js
@@ -22,6 +22,7 @@ const { fileURLToPath } = require('url');
const { dirname } = require('path');
// needed to translate commonjs modules to esm
const commonjs = require('@rollup/plugin-commonjs');
+const json = require('@rollup/plugin-json');
const { nodeResolve } = require('@rollup/plugin-node-resolve');
const projectDir = path.join(__dirname, "../");
@@ -53,7 +54,7 @@ async function commonjsToESM(src, dst) {
const bundle = await rollup({
treeshake: {moduleSideEffects: false},
input: src,
- plugins: [commonjs(), nodeResolve({
+ plugins: [commonjs(), json(), nodeResolve({
browser: true,
preferBuiltins: false,
customResolveOptions: {packageIterator}
@@ -76,6 +77,18 @@ async function populateLib() {
for (const file of ["olm.js", "olm.wasm", "olm_legacy.js"]) {
await fs.symlink(path.join(olmSrcDir, file), path.join(olmDstDir, file));
}
+ // transpile node-html-parser to esm
+ await fs.mkdir(path.join(libDir, "node-html-parser/"));
+ await commonjsToESM(
+ require.resolve('node-html-parser/dist/index.js'),
+ path.join(libDir, "node-html-parser/index.js")
+ );
+ // Symlink dompurify
+ await fs.mkdir(path.join(libDir, "dompurify/"));
+ await fs.symlink(
+ require.resolve('dompurify/dist/purify.es.js'),
+ path.join(libDir, "dompurify/index.js")
+ );
// transpile another-json to esm
await fs.mkdir(path.join(libDir, "another-json/"));
await commonjsToESM(
diff --git a/src/domain/session/room/timeline/FORMATTED.md b/src/domain/session/room/timeline/FORMATTED.md
new file mode 100644
index 00000000..576d2729
--- /dev/null
+++ b/src/domain/session/room/timeline/FORMATTED.md
@@ -0,0 +1,25 @@
+# Ideas for formatted messages
+
+* Seems like a good idea to take some
+ inspiration from [Pandoc's AST](https://hackage.haskell.org/package/pandoc-types-1.22/docs/Text-Pandoc-Definition.html#t:Block).
+ Then, much like Pandoc AST, we can turn our representation into
+ markdown in the editor, or HTML in the view.
+ * This is good for both serialization and deserialization.
+ We can use the representation when going input -> formatted
+ message, which would allow other, non-web platforms
+ to rely on non-Markdown input types. We can also
+ use it going formatted message -> display, since a frontend
+ can then choose to use non-HTML output (GTK JS bindings?).
+* As such, we represent formatting by nesting parts
+ (we'd have a `ItalicsPart`, `BoldPart`, etc.)
+* We keep the "inline"/"block" distinction, but only
+ track it as a property, so that we can avoid adding
+ block parts to elements that cannot contain blocks
+ (headers, for instance, cannot contain blocks, but
+ lists -- themselves blocks -- can).
+* When parsing, we may need some sort of "permanent" context:
+ if we're parsing a header, and we are inside 3 layers of other
+ "inline" things (italics, strikethrough, and bold, for example),
+ and we encounter a block, that's still not valid.
+ * Element seems to not care at all about the validity of what
+ it's parsing. Do we assume the HTML is well-formatted, then?
diff --git a/src/domain/session/room/timeline/MessageBody.js b/src/domain/session/room/timeline/MessageBody.js
index 42f3c951..d6a61e69 100644
--- a/src/domain/session/room/timeline/MessageBody.js
+++ b/src/domain/session/room/timeline/MessageBody.js
@@ -1,4 +1,5 @@
import { linkify } from "./linkify/linkify.js";
+import { getIdentifierColorNumber, avatarInitials } from "../../../avatar.js";
/**
* Parse text into parts such as newline, links and text.
@@ -12,7 +13,7 @@ export function parsePlainBody(body) {
// create callback outside of loop
const linkifyCallback = (text, isLink) => {
if (isLink) {
- parts.push(new LinkPart(text, text));
+ parts.push(new LinkPart(text, [new TextPart(text)]));
} else {
parts.push(new TextPart(text));
}
@@ -36,20 +37,99 @@ export function stringAsBody(body) {
return new MessageBody(body, [new TextPart(body)]);
}
-class NewLinePart {
+export class HeaderBlock {
+ constructor(level, inlines) {
+ this.level = level;
+ this.inlines = inlines;
+ }
+
+ get type() { return "header"; }
+}
+
+export class CodeBlock {
+ constructor(language, text) {
+ this.language = language;
+ this.text = text;
+ }
+
+ get type() { return "codeblock"; }
+}
+
+export class ListBlock {
+ constructor(startOffset, items) {
+ this.items = items;
+ this.startOffset = startOffset;
+ }
+
+ get type() { return "list"; }
+}
+
+export class TableBlock {
+ constructor(head, body) {
+ this.head = head;
+ this.body = body;
+ }
+
+ get type() { return "table"; }
+}
+
+export class RulePart {
+ get type( ) { return "rule"; }
+}
+
+export class NewLinePart {
get type() { return "newline"; }
}
-class LinkPart {
- constructor(url, text) {
+export class FormatPart {
+ constructor(format, children) {
+ this.format = format.toLowerCase();
+ this.children = children;
+ }
+
+ get type() { return "format"; }
+}
+
+export class ImagePart {
+ constructor(src, width, height, alt, title) {
+ this.src = src;
+ this.width = width;
+ this.height = height;
+ this.alt = alt;
+ this.title = title;
+ }
+
+ get type() { return "image"; }
+}
+
+export class PillPart {
+ constructor(id, href, children) {
+ this.id = id;
+ this.href = href;
+ this.children = children;
+ }
+
+ get type() { return "pill"; }
+
+ get avatarColorNumber() {
+ return getIdentifierColorNumber(this.id);
+ }
+
+ get avatarInitials() {
+ return avatarInitials(this.id);
+ }
+}
+
+export class LinkPart {
+ constructor(url, inlines) {
this.url = url;
- this.text = text;
+ this.inlines = inlines;
}
get type() { return "link"; }
}
-class TextPart {
+export class TextPart {
constructor(text) {
this.text = text;
}
@@ -57,7 +137,7 @@ class TextPart {
get type() { return "text"; }
}
-class MessageBody {
+export class MessageBody {
constructor(sourceString, parts) {
this.sourceString = sourceString;
this.parts = parts;
diff --git a/src/domain/session/room/timeline/deserialize.js b/src/domain/session/room/timeline/deserialize.js
new file mode 100644
index 00000000..d7f81316
--- /dev/null
+++ b/src/domain/session/room/timeline/deserialize.js
@@ -0,0 +1,501 @@
+/*
+Copyright 2021 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 { MessageBody, HeaderBlock, TableBlock, ListBlock, CodeBlock, PillPart, FormatPart, NewLinePart, RulePart, TextPart, LinkPart, ImagePart } from "./MessageBody.js"
+import { linkify } from "./linkify/linkify.js";
+
+/* At the time of writing (Jul 1 2021), Matrix Spec recommends
+ * allowing the following HTML tags:
+ * font, del, h1, h2, h3, h4, h5, h6, blockquote, p, a, ul, ol, sup, sub, li, b, i, u,
+ * strong, em, strike, code, hr, br, div, table, thead, tbody, tr, th, td, caption, pre, span, img
+ */
+
+/**
+ * Nodes that don't have any properties to them other than their tag.
+ * While has `href`, and hello world has `src`, these have... themselves.
+ */
+const basicInline = ["EM", "STRONG", "CODE", "DEL", "SPAN" ];
+const basicBlock = ["DIV", "BLOCKQUOTE"];
+const safeSchemas = ["https", "http", "ftp", "mailto", "magnet"].map(name => `${name}://`);
+const baseUrl = 'https://matrix.to';
+const linkPrefix = `${baseUrl}/#/`;
+
+class Deserializer {
+ constructor(result, mediaRepository) {
+ this.result = result;
+ this.mediaRepository = mediaRepository;
+ }
+
+ parsePillLink(link) {
+ if (!link.startsWith(linkPrefix)) {
+ return null;
+ }
+ const contents = link.substring(linkPrefix.length);
+ if (contents[0] === '@') {
+ return contents;
+ }
+ return null;
+ }
+
+ parseLink(node, children) {
+ const href = this.result.getAttributeValue(node, "href");
+ const lcUrl = href?.toLowerCase();
+ // urls should be absolute and with a safe schema, as listed in the spec
+ if (!lcUrl || !safeSchemas.some(schema => lcUrl.startsWith(schema))) {
+ return new FormatPart("span", children);
+ }
+ const pillId = this.parsePillLink(href);
+ if (pillId) {
+ return new PillPart(pillId, href, children);
+ }
+ return new LinkPart(href, children);
+ }
+
+ parseList(node) {
+ const result = this.result;
+ let start = null;
+ if (result.getNodeElementName(node) === "OL") {
+ // Will return 1 for, say, '1A', which may not be intended?
+ start = parseInt(result.getAttributeValue(node, "start")) || 1;
+ }
+ const items = [];
+ for (const child of result.getChildNodes(node)) {
+ if (result.getNodeElementName(child) !== "LI") {
+ continue;
+ }
+ const item = this.parseAnyNodes(result.getChildNodes(child));
+ items.push(item);
+ }
+ return new ListBlock(start, items);
+ }
+
+ _ensureElement(node, tag) {
+ return node &&
+ this.result.isElementNode(node) &&
+ this.result.getNodeElementName(node) === tag;
+ }
+
+ parseCodeBlock(node) {
+ const result = this.result;
+ let codeNode;
+ for (const child of result.getChildNodes(node)) {
+ codeNode = child;
+ break;
+ }
+ let language = null;
+ if (!this._ensureElement(codeNode, "CODE")) {
+ return new CodeBlock(language, this.result.getNodeText(node));
+ }
+ const cl = result.getAttributeValue(codeNode, "class") || ""
+ for (const clname of cl.split(" ")) {
+ if (clname.startsWith("language-") && !clname.startsWith("language-_")) {
+ language = clname.substring(9) // "language-".length
+ break;
+ }
+ }
+ return new CodeBlock(language, this.result.getNodeText(codeNode));
+ }
+
+ parseImage(node) {
+ const result = this.result;
+ const src = result.getAttributeValue(node, "src") || "";
+ const url = this.mediaRepository.mxcUrl(src);
+ // We just ignore non-mxc `src` attributes.
+ if (!url) {
+ return null;
+ }
+ const width = parseInt(result.getAttributeValue(node, "width")) || null;
+ const height = parseInt(result.getAttributeValue(node, "height")) || null;
+ const alt = result.getAttributeValue(node, "alt");
+ const title = result.getAttributeValue(node, "title");
+ return new ImagePart(url, width, height, alt, title);
+ }
+
+ parseTableRow(row, tag) {
+ const cells = [];
+ for (const node of this.result.getChildNodes(row)) {
+ if(!this._ensureElement(node, tag)) {
+ continue;
+ }
+ const children = this.result.getChildNodes(node);
+ const inlines = this.parseInlineNodes(children);
+ cells.push(inlines);
+ }
+ return cells;
+ }
+
+ parseTableHead(head) {
+ let headRow = null;
+ for (const node of this.result.getChildNodes(head)) {
+ headRow = node;
+ break;
+ }
+ if (this._ensureElement(headRow, "TR")) {
+ return this.parseTableRow(headRow, "TH");
+ }
+ return null;
+ }
+
+ parseTableBody(body) {
+ const rows = [];
+ for (const node of this.result.getChildNodes(body)) {
+ if(!this._ensureElement(node, "TR")) {
+ continue;
+ }
+ rows.push(this.parseTableRow(node, "TD"));
+ }
+ return rows;
+ }
+
+ parseTable(node) {
+ // We are only assuming iterable, so convert to arrary for indexing.
+ const children = Array.from(this.result.getChildNodes(node));
+ let head, body;
+ if (this._ensureElement(children[0], "THEAD") && this._ensureElement(children[1], "TBODY")) {
+ head = this.parseTableHead(children[0]);
+ body = this.parseTableBody(children[1]);
+ } else if (this._ensureElement(children[0], "TBODY")) {
+ head = null;
+ body = this.parseTableBody(children[0]);
+ }
+ return new TableBlock(head, body);
+ }
+
+ /** Once a node is known to be an element,
+ * attempt to interpret it as an inline element.
+ *
+ * @returns the inline message part, or null if the element
+ * is not inline or not allowed.
+ */
+ parseInlineElement(node) {
+ const result = this.result;
+ const tag = result.getNodeElementName(node);
+ const children = result.getChildNodes(node);
+ switch (tag) {
+ case "A": {
+ const inlines = this.parseInlineNodes(children);
+ return this.parseLink(node, inlines);
+ }
+ case "BR":
+ return new NewLinePart();
+ default: {
+ if (!basicInline.includes(tag)) {
+ return null;
+ }
+ const inlines = this.parseInlineNodes(children);
+ return new FormatPart(tag, inlines);
+ }
+ }
+ }
+
+ /** Attempt to interpret a node as inline.
+ *
+ * @returns the inline message part, or null if the
+ * element is not inline or not allowed.
+ */
+ parseInlineNode(node) {
+ if (this.result.isElementNode(node)) {
+ return this.parseInlineElement(node);
+ }
+ return null;
+ }
+
+ /** Once a node is known to be an element, attempt
+ * to interpret it as a block element.
+ *
+ * @returns the block message part, or null of the
+ * element is not a block or not allowed.
+ */
+ parseBlockElement(node) {
+ const result = this.result;
+ const tag = result.getNodeElementName(node);
+ const children = result.getChildNodes(node);
+ switch (tag) {
+ case "H1":
+ case "H2":
+ case "H3":
+ case "H4":
+ case "H5":
+ case "H6": {
+ const inlines = this.parseInlineNodes(children);
+ return new HeaderBlock(parseInt(tag[1]), inlines)
+ }
+ case "UL":
+ case "OL":
+ return this.parseList(node);
+ case "PRE":
+ return this.parseCodeBlock(node);
+ case "HR":
+ return new RulePart();
+ case "IMG":
+ return this.parseImage(node);
+ case "P": {
+ const inlines = this.parseInlineNodes(children);
+ return new FormatPart(tag, inlines);
+ }
+ case "TABLE":
+ return this.parseTable(node);
+ default: {
+ if (!basicBlock.includes(tag)) {
+ return null;
+ }
+ const blocks = this.parseAnyNodes(children);
+ return new FormatPart(tag, blocks);
+ }
+ }
+ }
+
+ /** Attempt to parse a node as a block.
+ *
+ * @return the block message part, or null if the node
+ * is not a block element.
+ */
+ parseBlockNode(node) {
+ if (this.result.isElementNode(node)) {
+ return this.parseBlockElement(node);
+ }
+ return null;
+ }
+
+ _parseTextParts(node, into) {
+ if(!this.result.isTextNode(node)) {
+ return false;
+ }
+
+ // XXX pretty much identical to `MessageBody`'s.
+ const linkifyCallback = (text, isLink) => {
+ if (isLink) {
+ into.push(new LinkPart(text, [new TextPart(text)]));
+ } else {
+ into.push(new TextPart(text));
+ }
+ };
+ linkify(this.result.getNodeText(node), linkifyCallback);
+ return true;
+ }
+
+ _parseInlineNodes(nodes, into) {
+ for (const htmlNode of nodes) {
+ if (this._parseTextParts(htmlNode, into)) {
+ // This was a text node, and we already
+ // dumped its parts into our list.
+ continue;
+ }
+ const node = this.parseInlineNode(htmlNode);
+ if (node) {
+ into.push(node);
+ continue;
+ }
+ // Node is either block or unrecognized. In
+ // both cases, just move on to its children.
+ this._parseInlineNodes(this.result.getChildNodes(htmlNode), into);
+ }
+ }
+
+ parseInlineNodes(nodes) {
+ const into = [];
+ this._parseInlineNodes(nodes, into);
+ return into;
+ }
+
+ // XXX very similar to `_parseInlineNodes`.
+ _parseAnyNodes(nodes, into) {
+ for (const htmlNode of nodes) {
+ if (this._parseTextParts(htmlNode, into)) {
+ // This was a text node, and we already
+ // dumped its parts into our list.
+ continue;
+ }
+ const node = this.parseInlineNode(htmlNode) || this.parseBlockNode(htmlNode);
+ if (node) {
+ into.push(node);
+ continue;
+ }
+ // Node is unrecognized. Just move on to its children.
+ this._parseAnyNodes(this.result.getChildNodes(htmlNode), into);
+ }
+ }
+
+ parseAnyNodes(nodes) {
+ const into = [];
+ this._parseAnyNodes(nodes, into);
+ return into;
+ }
+}
+
+export function parseHTMLBody(platform, mediaRepository, html) {
+ const parseResult = platform.parseHTML(html);
+ const deserializer = new Deserializer(parseResult, mediaRepository);
+ const parts = deserializer.parseAnyNodes(parseResult.rootNodes);
+ return new MessageBody(html, parts);
+}
+
+import parse from '../../../../../lib/node-html-parser/index.js';
+
+export function tests() {
+ class HTMLParseResult {
+ constructor(bodyNode) {
+ this._bodyNode = bodyNode;
+ }
+
+ get rootNodes() {
+ return this._bodyNode.childNodes;
+ }
+
+ getChildNodes(node) {
+ return node.childNodes;
+ }
+
+ getAttributeNames(node) {
+ return node.getAttributeNames();
+ }
+
+ getAttributeValue(node, attr) {
+ return node.getAttribute(attr);
+ }
+
+ isTextNode(node) {
+ return !node.tagName;
+ }
+
+ getNodeText(node) {
+ return node.text;
+ }
+
+ isElementNode(node) {
+ return !!node.tagName;
+ }
+
+ getNodeElementName(node) {
+ return node.tagName;
+ }
+ }
+
+ const platform = {
+ parseHTML: (html) => new HTMLParseResult(parse(html))
+ };
+
+ function test(assert, input, output) {
+ assert.deepEqual(parseHTMLBody(platform, null, input), new MessageBody(input, output));
+ }
+
+ return {
+ "Text only": assert => {
+ const input = "This is a sentence";
+ const output = [new TextPart(input)];
+ test(assert, input, output);
+ },
+ "Text with inline code format": assert => {
+ const input = "Here's some
code
!";
+ const output = [
+ new TextPart("Here's "),
+ new FormatPart("em", [new TextPart("some")]),
+ new TextPart(" "),
+ new FormatPart("code", [new TextPart("code")]),
+ new TextPart("!")
+ ];
+ test(assert, input, output);
+ },
+ "Text with ordered list with no attributes": assert => {
+ const input = "
";
+ const output = [
+ new ListBlock(1, [
+ [ new TextPart("Lorem") ],
+ [ new TextPart("Ipsum") ]
+ ])
+ ];
+ test(assert, input, output);
+ },
+ "Text with ordered list starting at 3": assert => {
+ const input = '
';
+ const output = [
+ new ListBlock(3, [
+ [ new TextPart("Lorem") ],
+ [ new TextPart("Ipsum") ]
+ ])
+ ];
+ test(assert, input, output);
+ },
+ "Text with unordered list": assert => {
+ const input = '
';
+ const output = [
+ new ListBlock(null, [
+ [ new TextPart("Lorem") ],
+ [ new TextPart("Ipsum") ]
+ ])
+ ];
+ test(assert, input, output);
+ },
+ "Auto-closed tags": assert => {
+ const input = '
Hello
Hello
';
+ const output = [
+ new FormatPart("span", [
+ new FormatPart("code", [new TextPart("Hello")]),
+ new FormatPart("em", [new TextPart("World")])
+ ])
+ ];
+ test(assert, input, output);
+ },
+ "Unknown and invalid attributes are stripped": assert => {
+ const input = 'Hello';
+ const output = [
+ new FormatPart("em", [new TextPart("Hello")])
+ ];
+ test(assert, input, output);
+ },
+ "Text with code block but no tag": assert => {
+ const code = 'main :: IO ()\nmain = putStrLn "Hello"'
+ const input = `${code}
`;
+ const output = [
+ new CodeBlock(null, code)
+ ];
+ test(assert, input, output);
+ },
+ "Text with code block and 'unsupported' tag": assert => {
+ const code = 'Hello, world'
+ const input = `${code}
`;
+ const output = [
+ new CodeBlock(null, code)
+ ];
+ test(assert, input, output);
+ }
+ /* Doesnt work: HTML library doesn't handle properly.
+ "Text with code block": assert => {
+ const code = 'main :: IO ()\nmain = putStrLn "Hello"'
+ const input = `${code}
`;
+ const output = [
+ new CodeBlock(null, code)
+ ];
+ test(assert, input, output);
+ }
+ */
+ };
+}
diff --git a/src/domain/session/room/timeline/tiles/BaseTextTile.js b/src/domain/session/room/timeline/tiles/BaseTextTile.js
index 401fd719..fb61cb4b 100644
--- a/src/domain/session/room/timeline/tiles/BaseTextTile.js
+++ b/src/domain/session/room/timeline/tiles/BaseTextTile.js
@@ -16,31 +16,43 @@ limitations under the License.
import {BaseMessageTile} from "./BaseMessageTile.js";
import {stringAsBody} from "../MessageBody.js";
+import {createEnum} from "../../../../../utils/enum.js";
+
+export const BodyFormat = createEnum("Plain", "Html");
export class BaseTextTile extends BaseMessageTile {
constructor(options) {
super(options);
this._messageBody = null;
+ this._format = null
}
get shape() {
return "message";
}
- _parseBody(bodyString) {
- return stringAsBody(bodyString);
+ _parseBody(body) {
+ return stringAsBody(body);
+ }
+
+ _getBodyFormat() {
+ return BodyFormat.Plain;
}
get body() {
- const body = this._getBodyAsString();
+ const body = this._getBody();
+ const format = this._getBodyFormat();
// body is a string, so we can check for difference by just
// doing an equality check
- if (!this._messageBody || this._messageBody.sourceString !== body) {
+ // Even if the body hasn't changed, but the format has, we need
+ // to re-fill our cache.
+ if (!this._messageBody || this._messageBody.sourceString !== body || this._format !== format) {
// body with markup is an array of parts,
// so we should not recreate it for the same body string,
// or else the equality check in the binding will always fail.
// So cache it here.
- this._messageBody = this._parseBody(body);
+ this._messageBody = this._parseBody(body, format);
+ this._format = format;
}
return this._messageBody;
}
diff --git a/src/domain/session/room/timeline/tiles/EncryptedEventTile.js b/src/domain/session/room/timeline/tiles/EncryptedEventTile.js
index 155ce4a9..07dd763b 100644
--- a/src/domain/session/room/timeline/tiles/EncryptedEventTile.js
+++ b/src/domain/session/room/timeline/tiles/EncryptedEventTile.js
@@ -33,13 +33,15 @@ export class EncryptedEventTile extends BaseTextTile {
return "message-status"
}
- _getBodyAsString() {
+ _getBody() {
const decryptionError = this._entry.decryptionError;
const code = decryptionError?.code;
+ let string;
if (code === "MEGOLM_NO_SESSION") {
- return this.i18n`The sender hasn't sent us the key for this message yet.`;
+ string = this.i18n`The sender hasn't sent us the key for this message yet.`;
} else {
- return decryptionError?.message || this.i18n`Could not decrypt message because of unknown reason.`;
+ string = decryptionError?.message || this.i18n`Could not decrypt message because of unknown reason.`;
}
+ return string;
}
}
diff --git a/src/domain/session/room/timeline/tiles/TextTile.js b/src/domain/session/room/timeline/tiles/TextTile.js
index 07e7ce8f..ffd06c1a 100644
--- a/src/domain/session/room/timeline/tiles/TextTile.js
+++ b/src/domain/session/room/timeline/tiles/TextTile.js
@@ -14,20 +14,49 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-import {BaseTextTile} from "./BaseTextTile.js";
+import {BaseTextTile, BodyFormat} from "./BaseTextTile.js";
import {parsePlainBody} from "../MessageBody.js";
+import {parseHTMLBody} from "../deserialize.js";
export class TextTile extends BaseTextTile {
- _getBodyAsString() {
+ _getContentString(key) {
const content = this._getContent();
- let body = content?.body || "";
+ let val = content?.[key] || "";
if (content.msgtype === "m.emote") {
- body = `* ${this.displayName} ${body}`;
+ val = `* ${this.displayName} ${val}`;
}
- return body;
+ return val;
}
- _parseBody(bodyString) {
- return parsePlainBody(bodyString);
+ _getPlainBody() {
+ return this._getContentString("body");
+ }
+
+ _getFormattedBody() {
+ return this._getContentString("formatted_body");
+ }
+
+ _getBody() {
+ if (this._getBodyFormat() === BodyFormat.Html) {
+ return this._getFormattedBody();
+ } else {
+ return this._getPlainBody();
+ }
+ }
+
+ _getBodyFormat() {
+ if (this._getContent()?.format === "org.matrix.custom.html") {
+ return BodyFormat.Html;
+ } else {
+ return BodyFormat.Plain;
+ }
+ }
+
+ _parseBody(body, format) {
+ if (format === BodyFormat.Html) {
+ return parseHTMLBody(this.platform, this._mediaRepository, body);
+ } else {
+ return parsePlainBody(body);
+ }
}
}
diff --git a/src/platform/web/Platform.js b/src/platform/web/Platform.js
index 49a90dd5..f1410106 100644
--- a/src/platform/web/Platform.js
+++ b/src/platform/web/Platform.js
@@ -36,6 +36,7 @@ import {BlobHandle} from "./dom/BlobHandle.js";
import {hasReadPixelPermission, ImageHandle, VideoHandle} from "./dom/ImageHandle.js";
import {downloadInIframe} from "./dom/download.js";
import {Disposables} from "../../utils/Disposables.js";
+import {parseHTML} from "./parsehtml.js";
import {handleAvatarError} from "./ui/avatar.js";
function addScript(src) {
@@ -239,6 +240,10 @@ export class Platform {
return promise;
}
+ parseHTML(html) {
+ return parseHTML(html);
+ }
+
async loadImage(blob) {
return ImageHandle.fromBlob(blob);
}
diff --git a/src/platform/web/parsehtml.js b/src/platform/web/parsehtml.js
new file mode 100644
index 00000000..b86ab4d9
--- /dev/null
+++ b/src/platform/web/parsehtml.js
@@ -0,0 +1,67 @@
+/*
+Copyright 2021 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 DOMPurify from "../../../../../lib/dompurify/index.js"
+
+class HTMLParseResult {
+ constructor(bodyNode) {
+ this._bodyNode = bodyNode;
+ }
+
+ get rootNodes() {
+ return Array.from(this._bodyNode.childNodes);
+ }
+
+ getChildNodes(node) {
+ return Array.from(node.childNodes);
+ }
+
+ getAttributeNames(node) {
+ return Array.from(node.getAttributeNames());
+ }
+
+ getAttributeValue(node, attr) {
+ return node.getAttribute(attr);
+ }
+
+ isTextNode(node) {
+ return node.nodeType === Node.TEXT_NODE;
+ }
+
+ getNodeText(node) {
+ return node.textContent;
+ }
+
+ isElementNode(node) {
+ return node.nodeType === Node.ELEMENT_NODE;
+ }
+
+ getNodeElementName(node) {
+ return node.tagName;
+ }
+}
+
+const sanitizeConfig = {
+ ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|cid|xmpp|xxx|mxc):|[^a-z]|[a-z+.-]+(?:[^a-z+.-:]|$))/i,
+}
+
+export function parseHTML(html) {
+ // If DOMPurify uses DOMParser, can't we just get the built tree from it
+ // instead of re-parsing?
+ const sanitized = DOMPurify.sanitize(html, sanitizeConfig);
+ const bodyNode = new DOMParser().parseFromString(sanitized, "text/html").body;
+ return new HTMLParseResult(bodyNode);
+}
diff --git a/src/platform/web/ui/css/avatar.css b/src/platform/web/ui/css/avatar.css
index d369f85f..143ea899 100644
--- a/src/platform/web/ui/css/avatar.css
+++ b/src/platform/web/ui/css/avatar.css
@@ -76,3 +76,11 @@ limitations under the License.
line-height: var(--avatar-size);
font-size: calc(var(--avatar-size) * 0.6);
}
+
+.hydrogen .avatar.size-12 {
+ --avatar-size: 12px;
+ width: var(--avatar-size);
+ height: var(--avatar-size);
+ line-height: var(--avatar-size);
+ font-size: calc(var(--avatar-size) * 0.6);
+}
diff --git a/src/platform/web/ui/css/themes/element/timeline.css b/src/platform/web/ui/css/themes/element/timeline.css
index fef9598b..29e2fbf0 100644
--- a/src/platform/web/ui/css/themes/element/timeline.css
+++ b/src/platform/web/ui/css/themes/element/timeline.css
@@ -126,6 +126,8 @@ limitations under the License.
line-height: 2.2rem;
/* so the .media can grow horizontally and its spacer can grow vertically */
width: 100%;
+ /* Fix for pre overflow */
+ min-width: 0;
}
.hydrogen .Timeline_messageSender.usercolor1 { color: var(--usercolor1); }
@@ -137,10 +139,32 @@ limitations under the License.
.hydrogen .Timeline_messageSender.usercolor7 { color: var(--usercolor7); }
.hydrogen .Timeline_messageSender.usercolor8 { color: var(--usercolor8); }
+.Timeline_messageBody h1,
+.Timeline_messageBody h2,
+.Timeline_messageBody h3,
+.Timeline_messageBody h4,
+.Timeline_messageBody h5,
+.Timeline_messageBody h6 {
+ font-weight: bold;
+ margin: 0.7em 0;
+}
+
+.Timeline_messageBody h1 { font-size: 1.6em; }
+.Timeline_messageBody h2 { font-size: 1.5em; }
+.Timeline_messageBody h3 { font-size: 1.4em; }
+.Timeline_messageBody h4 { font-size: 1.3em; }
+.Timeline_messageBody h5 { font-size: 1.2em; }
+.Timeline_messageBody h6 { font-size: 1.1em; }
+
.Timeline_messageBody a {
word-break: break-all;
}
+.Timeline_messageBody a.link {
+ color: #238cf5;
+ text-decoration: none;
+}
+
.Timeline_messageBody .media {
display: grid;
margin-top: 4px;
@@ -209,6 +233,73 @@ only loads when the top comes into view*/
/* don't stretch height as it is a spacer, just in case it doesn't match with image height */
align-self: start;
}
+.Timeline_messageBody code, .Timeline_messageBody pre {
+ background-color: #f8f8f8;
+ font-family: monospace;
+ font-size: 0.9em;
+}
+
+.Timeline_messageBody code {
+ border-radius: 3px;
+ padding: .2em .3em;
+ margin: 0;
+}
+
+.Timeline_messageBody pre {
+ border: 1px solid rgb(229, 229, 229);
+ padding: 0.5em;
+ max-height: 30em;
+ overflow: auto;
+}
+
+.Timeline_messageBody pre > code {
+ background-color: unset;
+ border-radius: unset;
+ display: block;
+ font-size: unset;
+}
+
+.Timeline_messageBody blockquote {
+ margin-left: 0;
+ padding-left: 20px;
+ border-left: 4px solid rgb(229, 229, 229);
+}
+
+.Timeline_messageBody table {
+ border: 1px solid rgb(206, 206, 206);
+ border-radius: 2px;
+ border-spacing: 0;
+}
+
+.Timeline_messageBody thead th {
+ border-bottom: 1px solid rgb(206, 206, 206);
+}
+
+.Timeline_messageBody td, .Timeline_messageBody th {
+ padding: 2px 5px 2px 5px;
+}
+
+.Timeline_messageBody tbody tr:nth-child(2n) {
+ background-color: #f6f6f6;
+}
+
+.Timeline_messageBody .pill {
+ padding: 0px 5px;
+ border-radius: 15px;
+ background-color: #f6f6f6;
+ border: 1px solid rgb(206, 206, 206);
+ text-decoration: none;
+ display: inline-flex;
+ align-items: center;
+ line-height: 2rem;
+ vertical-align: top;
+ margin: 1px;
+}
+
+.Timeline_messageBody .pill div.avatar {
+ display: inline-block;
+ margin-right: 3px;
+}
.Timeline_message.unsent .Timeline_messageBody {
color: #ccc;
diff --git a/src/platform/web/ui/css/timeline.css b/src/platform/web/ui/css/timeline.css
index 60af23d9..af2d6c14 100644
--- a/src/platform/web/ui/css/timeline.css
+++ b/src/platform/web/ui/css/timeline.css
@@ -15,7 +15,7 @@ limitations under the License.
*/
-.RoomView_body ul {
+.RoomView_body > ul {
overflow-y: auto;
overscroll-behavior: contain;
list-style: none;
@@ -61,3 +61,8 @@ limitations under the License.
.GapView > :nth-child(2) {
flex: 1;
}
+
+.Timeline_messageBody img {
+ max-width: 400px;
+ max-height: 300px;
+}
diff --git a/src/platform/web/ui/general/html.js b/src/platform/web/ui/general/html.js
index 19b670d7..4be81e3b 100644
--- a/src/platform/web/ui/general/html.js
+++ b/src/platform/web/ui/general/html.js
@@ -93,8 +93,9 @@ export const SVG_NS = "http://www.w3.org/2000/svg";
export const TAG_NAMES = {
[HTML_NS]: [
"br", "a", "ol", "ul", "li", "div", "h1", "h2", "h3", "h4", "h5", "h6",
- "p", "strong", "em", "span", "img", "section", "main", "article", "aside",
- "pre", "button", "time", "input", "textarea", "label", "form", "progress", "output", "video"],
+ "p", "strong", "em", "span", "img", "section", "main", "article", "aside", "del", "blockquote",
+ "table", "thead", "tbody", "tr", "th", "td",
+ "pre", "code", "button", "time", "input", "textarea", "label", "form", "progress", "output", "video"],
[SVG_NS]: ["svg", "circle"]
};
diff --git a/src/platform/web/ui/session/room/timeline/TextMessageView.js b/src/platform/web/ui/session/room/timeline/TextMessageView.js
index 88d97b3f..5a1ac374 100644
--- a/src/platform/web/ui/session/room/timeline/TextMessageView.js
+++ b/src/platform/web/ui/session/room/timeline/TextMessageView.js
@@ -14,41 +14,99 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-import {StaticView} from "../../../general/StaticView.js";
import {tag, text} from "../../../general/html.js";
import {BaseMessageView} from "./BaseMessageView.js";
export class TextMessageView extends BaseMessageView {
renderMessageBody(t, vm) {
- return t.p({
+ const time = t.time({className: {hidden: !vm.date}}, vm.date + " " + vm.time);
+ const container = t.div({
className: {
"Timeline_messageBody": true,
statusMessage: vm => vm.shape === "message-status",
},
- }, [
- t.mapView(vm => vm.body, body => new BodyView(body)),
- t.time({className: {hidden: !vm.date}}, vm.date + " " + vm.time)
- ]);
+ });
+ t.mapSideEffect(vm => vm.body, body => {
+ while (container.lastChild) {
+ container.removeChild(container.lastChild);
+ }
+ for (const part of body.parts) {
+ container.appendChild(renderPart(part));
+ }
+ container.appendChild(time);
+ });
+ return container;
}
}
+function renderList(listBlock) {
+ const items = listBlock.items.map(item => tag.li(renderParts(item)));
+ const start = listBlock.startOffset;
+ if (start) {
+ return tag.ol({ start }, items);
+ } else {
+ return tag.ul(items);
+ }
+}
+
+function renderImage(imagePart) {
+ const attributes = { src: imagePart.src };
+ if (imagePart.width) { attributes.width = imagePart.width }
+ if (imagePart.height) { attributes.height = imagePart.height }
+ if (imagePart.alt) { attributes.alt = imagePart.alt }
+ if (imagePart.title) { attributes.title = imagePart.title }
+ return tag.img(attributes);
+}
+
+function renderPill(pillPart) {
+ // The classes and structure are borrowed from avatar.js;
+ // We don't call renderStaticAvatar because that would require
+ // an intermediate object that has getAvatarUrl etc.
+ const classes = `avatar size-12 usercolor${pillPart.avatarColorNumber}`;
+ const avatar = tag.div({class: classes}, text(pillPart.avatarInitials));
+ const children = renderParts(pillPart.children);
+ children.unshift(avatar);
+ return tag.a({class: "pill", href: pillPart.href, rel: "noopener", target: "_blank"}, children);
+}
+
+function renderTable(tablePart) {
+ const children = [];
+ if (tablePart.head) {
+ const headers = tablePart.head
+ .map(cell => tag.th(renderParts(cell)));
+ children.push(tag.thead(tag.tr(headers)))
+ }
+ const rows = [];
+ for (const row of tablePart.body) {
+ const data = row.map(cell => tag.td(renderParts(cell)));
+ rows.push(tag.tr(data));
+ }
+ children.push(tag.tbody(rows));
+ return tag.table(children);
+}
+
/**
* Map from part to function that outputs DOM for the part
*/
const formatFunction = {
+ header: headerBlock => tag["h" + Math.min(6,headerBlock.level)](renderParts(headerBlock.inlines)),
+ codeblock: codeBlock => tag.pre(tag.code(text(codeBlock.text))),
+ table: tableBlock => renderTable(tableBlock),
+ code: codePart => tag.code(text(codePart.text)),
text: textPart => text(textPart.text),
- link: linkPart => tag.a({ href: linkPart.url, target: "_blank", rel: "noopener" }, [linkPart.text]),
+ link: linkPart => tag.a({href: linkPart.url, className: "link", target: "_blank", rel: "noopener" }, renderParts(linkPart.inlines)),
+ pill: renderPill,
+ format: formatPart => tag[formatPart.format](renderParts(formatPart.children)),
+ list: renderList,
+ image: renderImage,
newline: () => tag.br()
};
-class BodyView extends StaticView {
- render(t, messageBody) {
- const container = t.span();
- for (const part of messageBody.parts) {
- const f = formatFunction[part.type];
- const element = f(part);
- container.appendChild(element);
- }
- return container;
- }
+function renderPart(part) {
+ const f = formatFunction[part.type];
+ return f(part);
+}
+
+function renderParts(parts) {
+ return Array.from(parts, renderPart);
}
diff --git a/yarn.lock b/yarn.lock
index c1e14fe4..843a19bc 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -886,6 +886,13 @@
magic-string "^0.25.7"
resolve "^1.17.0"
+"@rollup/plugin-json@^4.1.0":
+ version "4.1.0"
+ resolved "https://registry.yarnpkg.com/@rollup/plugin-json/-/plugin-json-4.1.0.tgz#54e09867ae6963c593844d8bd7a9c718294496f3"
+ integrity sha512-yfLbTdNS6amI/2OpmbiBoW12vngr5NW2jCJVZSBEz+H5KfUJZ2M7sDjk0U6GOOdCWFVScShte29o9NezJ53TPw==
+ dependencies:
+ "@rollup/pluginutils" "^3.0.8"
+
"@rollup/plugin-multi-entry@^4.0.0":
version "4.0.0"
resolved "https://registry.yarnpkg.com/@rollup/plugin-multi-entry/-/plugin-multi-entry-4.0.0.tgz#8e105f16ec1bb26639eb3302c8db5665f44b9939"
@@ -911,7 +918,7 @@
resolved "https://registry.yarnpkg.com/@rollup/plugin-virtual/-/plugin-virtual-2.0.3.tgz#0afc88d75c1e1378ab290b8e9898d4edb5be0d74"
integrity sha512-pw6ziJcyjZtntQ//bkad9qXaBx665SgEL8C8KI5wO8G5iU5MPxvdWrQyVaAvjojGm9tJoS8M9Z/EEepbqieYmw==
-"@rollup/pluginutils@^3.1.0":
+"@rollup/pluginutils@^3.0.8", "@rollup/pluginutils@^3.1.0":
version "3.1.0"
resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-3.1.0.tgz#706b4524ee6dc8b103b3c995533e5ad680c02b9b"
integrity sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==
@@ -1059,7 +1066,7 @@ base64-arraybuffer@^0.2.0:
resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-0.2.0.tgz#4b944fac0191aa5907afe2d8c999ccc57ce80f45"
integrity sha512-7emyCsu1/xiBXgQZrscw/8KPRT44I4Yq9Pe6EGs3aPRTsWuggML1/1DTuZUuIaJPIm1FTDUVXl4x/yW8s0kQDQ==
-boolbase@~1.0.0:
+boolbase@^1.0.0, boolbase@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e"
integrity sha1-aN/1++YMUes3cl6p4+0xDcwed24=
@@ -1237,6 +1244,17 @@ cross-spawn@^7.0.2:
shebang-command "^2.0.0"
which "^2.0.1"
+css-select@^4.1.3:
+ version "4.1.3"
+ resolved "https://registry.yarnpkg.com/css-select/-/css-select-4.1.3.tgz#a70440f70317f2669118ad74ff105e65849c7067"
+ integrity sha512-gT3wBNd9Nj49rAbmtFHj1cljIAOLYSX1nZ8CB7TBO3INYckygm5B7LISU/szY//YmdiSLbJvDLOx9VnMVpMBxA==
+ dependencies:
+ boolbase "^1.0.0"
+ css-what "^5.0.0"
+ domhandler "^4.2.0"
+ domutils "^2.6.0"
+ nth-check "^2.0.0"
+
css-select@~1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/css-select/-/css-select-1.2.0.tgz#2b3a110539c5355f1cd8d314623e870b121ec858"
@@ -1252,6 +1270,11 @@ css-what@2.1:
resolved "https://registry.yarnpkg.com/css-what/-/css-what-2.1.3.tgz#a6d7604573365fe74686c3f311c56513d88285f2"
integrity sha512-a+EPoD+uZiNfh+5fxw2nO9QwFa6nJe2Or35fGY6Ipw1R3R4AGz1d1TEZrCegvw2YTmZ0jXirGYlzxxpYSHwpEg==
+css-what@^5.0.0:
+ version "5.0.1"
+ resolved "https://registry.yarnpkg.com/css-what/-/css-what-5.0.1.tgz#3efa820131f4669a8ac2408f9c32e7c7de9f4cad"
+ integrity sha512-FYDTSHb/7KXsWICVsxdmiExPjCfRC4qRFBdVwv7Ax9hMnvMmEjP9RfxTEZ3qPZGmADDn2vAKSo9UcN1jKVYscg==
+
cuint@^0.2.2:
version "0.2.2"
resolved "https://registry.yarnpkg.com/cuint/-/cuint-0.2.2.tgz#408086d409550c2631155619e9fa7bcadc3b991b"
@@ -1320,6 +1343,15 @@ dom-serializer@0:
domelementtype "^2.0.1"
entities "^2.0.0"
+dom-serializer@^1.0.1:
+ version "1.3.2"
+ resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-1.3.2.tgz#6206437d32ceefaec7161803230c7a20bc1b4d91"
+ integrity sha512-5c54Bk5Dw4qAxNOI1pFEizPSjVsx5+bpJKmL2kPn8JhBUq2q09tTCa3mjijun2NfK78NMouDYNMBkOrPZiS+ig==
+ dependencies:
+ domelementtype "^2.0.1"
+ domhandler "^4.2.0"
+ entities "^2.0.0"
+
dom-serializer@~0.1.1:
version "0.1.1"
resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.1.1.tgz#1ec4059e284babed36eec2941d4a970a189ce7c0"
@@ -1338,6 +1370,11 @@ domelementtype@^2.0.1:
resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.0.1.tgz#1f8bdfe91f5a78063274e803b4bdcedf6e94f94d"
integrity sha512-5HOHUDsYZWV8FGWN0Njbr/Rn7f/eWSQi1v7+HsUVwXgn8nWWlL64zKDkS0n8ZmQ3mlWOMuXOnR+7Nx/5tMO5AQ==
+domelementtype@^2.2.0:
+ version "2.2.0"
+ resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.2.0.tgz#9a0b6c2782ed6a1c7323d42267183df9bd8b1d57"
+ integrity sha512-DtBMo82pv1dFtUmHyr48beiuq792Sxohr+8Hm9zoxklYPfa6n0Z3Byjj2IV7bmr2IyqClnqEQhfgHJJ5QF0R5A==
+
domexception@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/domexception/-/domexception-1.0.1.tgz#937442644ca6a31261ef36e3ec677fe805582c90"
@@ -1352,6 +1389,18 @@ domhandler@^2.3.0:
dependencies:
domelementtype "1"
+domhandler@^4.2.0:
+ version "4.2.0"
+ resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-4.2.0.tgz#f9768a5f034be60a89a27c2e4d0f74eba0d8b059"
+ integrity sha512-zk7sgt970kzPks2Bf+dwT/PLzghLnsivb9CcxkvR8Mzr66Olr0Ofd8neSbglHJHaHa2MadfoSdNlKYAaafmWfA==
+ dependencies:
+ domelementtype "^2.2.0"
+
+dompurify@^2.3.0:
+ version "2.3.0"
+ resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-2.3.0.tgz#07bb39515e491588e5756b1d3e8375b5964814e2"
+ integrity sha512-VV5C6Kr53YVHGOBKO/F86OYX6/iLTw2yVSI721gKetxpHCK/V5TaLEf9ODjRgl1KLSWRMY6cUhAbv/c+IUnwQw==
+
domutils@1.5.1:
version "1.5.1"
resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.5.1.tgz#dcd8488a26f563d61079e48c9f7b7e32373682cf"
@@ -1368,6 +1417,15 @@ domutils@^1.5.1:
dom-serializer "0"
domelementtype "1"
+domutils@^2.6.0:
+ version "2.7.0"
+ resolved "https://registry.yarnpkg.com/domutils/-/domutils-2.7.0.tgz#8ebaf0c41ebafcf55b0b72ec31c56323712c5442"
+ integrity sha512-8eaHa17IwJUPAiB+SoTYBo5mCdeMgdcAoXJ59m6DT1vw+5iLS3gNoqYaRowaBKtGVrOF1Jz4yDTgYKLK2kvfJg==
+ dependencies:
+ dom-serializer "^1.0.1"
+ domelementtype "^2.2.0"
+ domhandler "^4.2.0"
+
ee-first@1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
@@ -1726,6 +1784,11 @@ has-symbols@^1.0.0:
resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.1.tgz#9f5214758a44196c406d9bd76cebf81ec2dd31e8"
integrity sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==
+he@1.2.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f"
+ integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==
+
htmlparser2@^3.9.1:
version "3.10.1"
resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-3.10.1.tgz#bd679dc3f59897b6a34bb10749c855bb53a9392f"
@@ -2036,6 +2099,14 @@ natural-compare@^1.4.0:
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=
+node-html-parser@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/node-html-parser/-/node-html-parser-4.0.0.tgz#95e4fec010c48425821d5659eaf39e27b0781b6d"
+ integrity sha512-vuOcp3u4GrfYOcqe+FpUffQKnfVBq781MbtFEcTR6fJYnYRE2qUPdDaDk7TGdq6H9r2B3TbyU6K9Rah6/C7qvg==
+ dependencies:
+ css-select "^4.1.3"
+ he "1.2.0"
+
node-releases@^1.1.60:
version "1.1.60"
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.60.tgz#6948bdfce8286f0b5d0e5a88e8384e954dfe7084"
@@ -2051,6 +2122,13 @@ normalize-range@^0.1.2:
resolved "https://registry.yarnpkg.com/normalize-range/-/normalize-range-0.1.2.tgz#2d10c06bdfd312ea9777695a4d28439456b75942"
integrity sha1-LRDAa9/TEuqXd2laTShDlFa3WUI=
+nth-check@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-2.0.0.tgz#1bb4f6dac70072fc313e8c9cd1417b5074c0a125"
+ integrity sha512-i4sc/Kj8htBrAiH1viZ0TgU8Y5XqCaV/FziYK6TBczxmeKm3AEFWqqF3195yKudrarqy7Zu80Ra5dobFjn9X/Q==
+ dependencies:
+ boolbase "^1.0.0"
+
nth-check@~1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-1.0.2.tgz#b2bd295c37e3dd58a3bf0700376663ba4d9cf05c"