Create htmlFilter{Node,Styling} for better separation of concerns

This commit is contained in:
Henrik Giesel 2021-03-24 23:35:51 +01:00 committed by Damien Elmes
parent d3d3720b39
commit 39aa549ac9
6 changed files with 162 additions and 114 deletions

View file

@ -7,6 +7,10 @@ export function nodeIsElement(node: Node): node is Element {
return node.nodeType === Node.ELEMENT_NODE;
}
export function isHTMLElement(elem: Element): elem is HTMLElement {
return elem instanceof HTMLElement;
}
const INLINE_TAGS = [
"A",
"ABBR",

View file

@ -1,68 +1,56 @@
/* Copyright: Ankitects Pty Ltd and contributors
* License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */
import { nodeIsElement } from "./helpers";
import { filterElement } from "./htmlFilterElement";
import {
filterElementBasic,
filterElementExtended,
filterElementInternal,
} from "./htmlFilterElement";
import { filterNode } from "./htmlFilterNode";
////////////////////// //////////////////// ////////////////////
function isHTMLElement(elem: Element): elem is HTMLElement {
return elem instanceof HTMLElement;
enum FilterMode {
Basic,
Extended,
Internal,
}
// filtering from another field
function filterInternalNode(elem: Element): void {
if (isHTMLElement(elem)) {
elem.style.removeProperty("background-color");
elem.style.removeProperty("font-size");
elem.style.removeProperty("font-family");
}
// recurse
for (let i = 0; i < elem.children.length; i++) {
const child = elem.children[i];
filterInternalNode(child);
}
const filters: Record<FilterMode, (element: Element) => void> = {
[FilterMode.Basic]: filterElementBasic,
[FilterMode.Extended]: filterElementExtended,
[FilterMode.Internal]: filterElementInternal,
};
const whitespace = /[\n\t ]+/g;
function collapseWhitespace(value: string): string {
return value.replace(whitespace, " ");
}
// filtering from external sources
function filterNode(node: Node, extendedMode: boolean): void {
if (node.nodeType === Node.COMMENT_NODE) {
node.parentNode.removeChild(node);
return;
}
if (!nodeIsElement(node)) {
return;
}
// descend first, and take a copy of the child nodes as the loop will skip
// elements due to node modifications otherwise
for (const child of [...node.childNodes]) {
filterNode(child, extendedMode);
}
filterElement(node, extendedMode);
function trim(value: string): string {
return value.trim();
}
const outputHTMLProcessors: Record<FilterMode, (outputHTML: string) => string> = {
[FilterMode.Basic]: (outputHTML: string): string =>
trim(collapseWhitespace(outputHTML)),
[FilterMode.Extended]: trim,
[FilterMode.Internal]: trim,
};
export function filterHTML(
html: string,
internal: boolean,
extendedMode: boolean
): string {
// wrap it in <top> as we aren't allowed to change top level elements
const top = document.createElement("ankitop");
top.innerHTML = html;
export function filterHTML(html: string, internal: boolean, extended: boolean): string {
const template = document.createElement("template");
template.innerHTML = html;
if (internal) {
filterInternalNode(top);
} else {
filterNode(top, extendedMode);
}
let outHtml = top.innerHTML;
if (!extendedMode && !internal) {
// collapse whitespace
outHtml = outHtml.replace(/[\n\t ]+/g, " ");
}
outHtml = outHtml.trim();
return outHtml;
const mode = internal
? FilterMode.Internal
: extended
? FilterMode.Extended
: FilterMode.Basic;
const content = template.content;
const filter = filterNode(filters[mode]);
filter(content);
return outputHTMLProcessors[mode](template.innerHTML);
}

View file

@ -1,4 +1,10 @@
import { filterSpan } from "./htmlFilterSpan";
import { isNightMode, isHTMLElement } from "./helpers";
import { removeNode as removeElement } from "./htmlFilterNode";
import {
filterStylingNightMode,
filterStylingLightMode,
filterStylingInternal,
} from "./htmlFilterStyling";
interface TagsAllowed {
[tagName: string]: FilterMethod;
@ -6,8 +12,6 @@ interface TagsAllowed {
type FilterMethod = (element: Element) => void;
function doNothing() {}
function filterOutAttributes(
attributePredicate: (attributeName: string) => boolean,
element: Element
@ -33,17 +37,19 @@ function blockExcept(attrs: string[]): FilterMethod {
);
}
function removeElement(element: Element): void {
element.parentNode?.removeChild(element);
}
function unwrapElement(element: Element): void {
element.outerHTML = element.innerHTML;
}
function filterSpan(element: Element): void {
const filterAttrs = blockExcept(["STYLE"]);
filterAttrs(element);
const filterStyle = isNightMode() ? filterStylingNightMode : filterStylingLightMode;
filterStyle(element as HTMLSpanElement);
}
const tagsAllowedBasic: TagsAllowed = {
ANKITOP: doNothing,
BR: blockAll,
IMG: blockExcept(["SRC"]),
DIV: blockAll,
@ -84,17 +90,25 @@ const tagsAllowedExtended: TagsAllowed = {
UL: blockAll,
};
export function filterElement(element: Element, extendedMode: boolean): void {
const filterElementTagsAllowed = (tagsAllowed: TagsAllowed) => (
element: Element
): void => {
const tagName = element.tagName;
const tagsAllowed = extendedMode ? tagsAllowedExtended : tagsAllowedBasic;
if (tagsAllowed.hasOwnProperty(tagName)) {
tagsAllowed[tagName](element);
}
else if (element.innerHTML) {
} else if (element.innerHTML) {
removeElement(element);
}
else {
} else {
unwrapElement(element);
}
};
export const filterElementBasic = filterElementTagsAllowed(tagsAllowedBasic);
export const filterElementExtended = filterElementTagsAllowed(tagsAllowedExtended);
export function filterElementInternal(element: Element): void {
if (isHTMLElement(element)) {
filterStylingInternal(element);
}
}

View file

@ -0,0 +1,32 @@
export function removeNode(element: Node): void {
element.parentNode?.removeChild(element);
}
function iterateElement(
filter: (node: Node) => void,
fragment: DocumentFragment | Element
): void {
for (const child of [...fragment.childNodes]) {
filter(child);
}
}
export const filterNode = (elementFilter: (element: Element) => void) => (
node: Node
): void => {
switch (node.nodeType) {
case Node.COMMENT_NODE:
removeNode(node);
break;
case Node.DOCUMENT_FRAGMENT_NODE:
iterateElement(filterNode(elementFilter), node as DocumentFragment);
case Node.ELEMENT_NODE:
iterateElement(filterNode(elementFilter), node as Element);
elementFilter(node as Element);
default:
// do nothing
}
};

View file

@ -1,44 +0,0 @@
import { isNightMode } from "./helpers";
/* keys are allowed properties, values are blocked values */
const stylingAllowListNightMode = {
"font-weight": [],
"font-style": [],
"text-decoration-line": [],
};
const stylingAllowList = {
color: [],
"background-color": ["transparent"],
...stylingAllowListNightMode,
};
function isStylingAllowed(property: string, value: string): boolean {
const allowList = isNightMode() ? stylingAllowListNightMode : stylingAllowList;
return allowList.hasOwnProperty(property) && !allowList[property].includes(value);
}
const allowedAttrs = ["STYLE"];
export function filterSpan(element: Element): void {
// filter out attributes
for (const attr of [...element.attributes]) {
const attrName = attr.name.toUpperCase();
if (!allowedAttrs.includes(attrName)) {
element.removeAttributeNode(attr);
}
}
// filter styling
const elementStyle = (element as HTMLSpanElement).style;
for (const property of [...elementStyle]) {
const value = elementStyle.getPropertyValue(name);
if (!isStylingAllowed(property, value)) {
elementStyle.removeProperty(property);
}
}
}

View file

@ -0,0 +1,54 @@
interface AllowPropertiesBlockValues {
[property: string]: string[];
}
type BlockProperties = string[];
type StylingPredicate = (property: string, value: string) => boolean;
const stylingNightMode: AllowPropertiesBlockValues = {
"font-weight": [],
"font-style": [],
"text-decoration-line": [],
};
const stylingLightMode: AllowPropertiesBlockValues = {
color: [],
"background-color": ["transparent"],
...stylingNightMode,
};
const stylingInternal: BlockProperties = [
"background-color",
"font-size",
"font-family",
];
const allowPropertiesBlockValues = (
allowBlock: AllowPropertiesBlockValues
): StylingPredicate => (property: string, value: string): boolean =>
allowBlock.hasOwnProperty(property) && !allowBlock[property].includes(value);
const blockProperties = (block: BlockProperties): StylingPredicate => (
property: string
): boolean => !block.includes(property);
const filterStyling = (predicate: (property: string, value: string) => boolean) => (
element: HTMLElement
): void => {
for (const property of [...element.style]) {
const value = element.style.getPropertyValue(name);
if (!predicate(property, value)) {
element.style.removeProperty(property);
}
}
};
export const filterStylingNightMode = filterStyling(
allowPropertiesBlockValues(stylingNightMode)
);
export const filterStylingLightMode = filterStyling(
allowPropertiesBlockValues(stylingLightMode)
);
export const filterStylingInternal = filterStyling(blockProperties(stylingInternal));