From 2d5e0e1ca3f3d2d9ea670144e82876ad81ff4fc3 Mon Sep 17 00:00:00 2001 From: Dylan Piercey Date: Tue, 20 Aug 2019 15:23:41 -0700 Subject: [PATCH] feat: attribute whitelist feat: sort attributes and css properties BREAKING CHANGE: snapshot output has changed --- README.md | 18 ++- src/__tests__/examples.ts | 56 ++++---- src/__tests__/index.ts | 69 +++++----- src/attributes.ts | 100 ++++++-------- src/html-properties.ts | 283 ++++++++++++++++++++++++++++++++++++++ src/stringify.ts | 10 +- src/types.ts | 2 +- 7 files changed, 407 insertions(+), 131 deletions(-) create mode 100644 src/html-properties.ts diff --git a/README.md b/README.md index 6af51f3..2ea90f8 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ visualHTML(document.body, { shallow: true }); // Returns just visual information ## How it works `visual-html` works by building up an HTML representation of the DOM including only attributes that account for the visual display of the element. -It will scan through all style sheets and inline the applied styles for an element, and strip any attributes that do not come with user agent styles. +It will scan through all style sheets and inline the applied styles for an element. Then it reads all properties that change the visuals of the element and includes the corresponding attribute in the HTML snapshot. Lets look at an example. @@ -120,14 +120,12 @@ visualHTML(div); // Returns the html below as string. ``` ```html -
+ transform: translateX(-100px); + width: 100px +"> Hello! @@ -138,7 +136,7 @@ visualHTML(div); // Returns the html below as string.
``` -In the above output you can see that the majority of attributes have been removed, and styles are now included inline. The `type="checkbox"` is still present on the `Remember Me:` checkbox as it causes the browser to display the textbox differently. The default `type` for an `input` is `text`, and a `type="password"` is visually identical to `type="text"` unless you've styled it differently yourself in which case an inline style attribute would be present. +In the above output you can see that the majority of attributes have been removed, and styles are now included inline. The `type="text"` on the first `input` was removed since it is a default. All attributes and properties are also sorted alphabetically to be more stable. ## How is this different than x!? diff --git a/src/__tests__/examples.ts b/src/__tests__/examples.ts index 6582326..4b7f568 100644 --- a/src/__tests__/examples.ts +++ b/src/__tests__/examples.ts @@ -44,34 +44,34 @@ test("runs the first example", () => { ` ) ).toMatchInlineSnapshot(` - "
- - Hello! - -
- - - - -
-
" - `); + "
+ + Hello! + +
+ + + + +
+
" + `); }); test("works with diff snapshots", () => { diff --git a/src/__tests__/index.ts b/src/__tests__/index.ts index 01c86d7..f2cfcf7 100644 --- a/src/__tests__/index.ts +++ b/src/__tests__/index.ts @@ -23,7 +23,7 @@ afterEach(() => { test("removes any properties that do not apply user agent styles", () => { expect( testHTML(` - + `) ).toMatchInlineSnapshot(`""`); }); @@ -31,9 +31,14 @@ test("removes any properties that do not apply user agent styles", () => { test("preserves any properties that do apply user agent styles", () => { expect( testHTML(` - + `) - ).toMatchInlineSnapshot(`""`); + ).toMatchInlineSnapshot(` + "" + `); }); test("preserves any inline styles", () => { @@ -58,11 +63,11 @@ test("inline styles override applied styles", () => { ` ) ).toMatchInlineSnapshot(` - "
" - `); + "
" + `); }); test("accounts for !important", () => { @@ -80,8 +85,8 @@ test("accounts for !important", () => { ) ).toMatchInlineSnapshot(` "
" `); }); @@ -135,12 +140,12 @@ test("supports multiple applied styles", () => { ` ) ).toMatchInlineSnapshot(` - "
" - `); + "
" + `); }); test("includes children", () => { @@ -275,22 +280,22 @@ test("includes pseudo elements", () => { `; expect(testHTML(html, styles)).toMatchInlineSnapshot(` - "
- - - - Content - -
" - `); + "
+ + + + Content + +
" + `); }); function testHTML(html: string, styles: string = "") { diff --git a/src/attributes.ts b/src/attributes.ts index 2586753..cda4a7a 100644 --- a/src/attributes.ts +++ b/src/attributes.ts @@ -1,68 +1,54 @@ -let FRAME: HTMLIFrameElement | null = null; +import { HTML_PROPERTIES } from "./html-properties"; /** * Given an element, returns any attributes that have a cause a visual change. - * This works by copying the element to an iframe without any styles and - * testing the computed styles while toggling the attributes. + * This works by checking against a whitelist of known visual properties, and + * their related attribute name. */ export function getVisualAttributes(el: Element) { - let visualAttributes: Array<{ name: string; value: string }> | null = null; - const document = el.ownerDocument!; - FRAME = FRAME || document.createElement("iframe"); - - document.body.appendChild(FRAME); - - const contentDocument = FRAME.contentDocument!; - const contentWindow = contentDocument.defaultView!; - const clone = contentDocument.importNode(el, false); - const { attributes } = clone; - - contentDocument.body.appendChild(clone); - - const defaultStyles = contentWindow.getComputedStyle(clone); - - for (let i = attributes.length; i--; ) { - const attr = attributes[i]; - - if (attr.name === "style") { - continue; + let visualAttributes: Array<{ + name: string; + value: string | boolean | null; + }> | null = null; + if (!el.namespaceURI || el.namespaceURI === "http://www.w3.org/1999/xhtml") { + // For HTML elements we look at a whitelist of properties and compare against the default value. + const defaults = el.ownerDocument!.createElement(el.localName); + + for (const prop in HTML_PROPERTIES) { + const { alias, tests } = HTML_PROPERTIES[ + prop as keyof typeof HTML_PROPERTIES + ]; + const name = alias || prop; + const value = el[prop]; + + if (value !== defaults[prop]) { + for (const test of tests) { + if (test(el)) { + (visualAttributes || (visualAttributes = [])).push({ name, value }); + break; + } + } + } } - - clone.removeAttributeNode(attr); - - if ( - !computedStylesEqual(defaultStyles, contentWindow.getComputedStyle(clone)) - ) { - (visualAttributes || (visualAttributes = [])).push({ - name: attr.name, - value: attr.value - }); + } else { + // For other namespaces we assume all attributes are visual, except for a blacklist. + const { attributes } = el; + + for (let i = 0, len = attributes.length; i < len; i++) { + const { name, value } = attributes[i]; + + if ( + !( + (el.localName === "a" && /^(?:xmlns:)?href$/i.test(name)) || + /^(?:class|id|style|lang|target|xmlns(?::.+)?|xlink:(?!href).+|xml:(?:lang|base)|on*|aria-*|data-*)$/i.test( + name + ) + ) + ) { + (visualAttributes || (visualAttributes = [])).push({ name, value }); + } } - - clone.setAttributeNode(attr); } - contentDocument.body.removeChild(clone); - document.body.removeChild(FRAME); - return visualAttributes; } - -/** - * Checks if two CSSStyleDeclarations have the same styles applied. - */ -function computedStylesEqual(a: CSSStyleDeclaration, b: CSSStyleDeclaration) { - if (a.length !== b.length) { - return false; - } - - for (let i = a.length; i--; ) { - const name = a[i]; - - if (a[name] !== b[name]) { - return false; - } - } - - return true; -} diff --git a/src/html-properties.ts b/src/html-properties.ts new file mode 100644 index 0000000..458b22b --- /dev/null +++ b/src/html-properties.ts @@ -0,0 +1,283 @@ +export const HTML_PROPERTIES = { + align: { + alias: false, + tests: [ + test([ + "applet", + "caption", + "col", + "colgroup", + "hr", + "iframe", + "img", + "table", + "tbody", + "td", + "tfoot", + "th", + "thead", + "tr" + ]) + ] + }, + autoplay: { + alias: false, + tests: [test(["audio", "video"])] + }, + background: { + alias: false, + tests: [test(["body", "table", "td", "th"])] + }, + bgColor: { + alias: "bgcolor", + tests: [ + test([ + "body", + "col", + "colgroup", + "table", + "tbody", + "tfoot", + "td", + "th", + "tr" + ]) + ] + }, + border: { + alias: false, + tests: [test(["img", "object", "table"])] + }, + checked: { + alias: false, + tests: [ + test("input", (it: HTMLInputElement) => + /^(?:checkbox|radio)$/.test(it.type)) + ] + }, + color: { + alias: false, + tests: [test(["basefont", "font", "hr"])] + }, + cols: { + alias: false, + tests: [test("textarea")] + }, + colSpan: { + alias: "colspan", + tests: [test(["td", "th"])] + }, + controls: { + alias: false, + tests: [test(["audio", "video"])] + }, + coords: { + alias: false, + tests: [test("area")] + }, + currentSrc: { + alias: "src", + tests: [test(["audio", "img", "source", "video"])] + }, + data: { + alias: false, + tests: [test("object")] + }, + default: { + alias: false, + tests: [test("track")] + }, + dir: { + alias: false, + tests: [test(/./)] + }, + disabled: { + alias: false, + tests: [ + test([ + "button", + "fieldset", + "input", + "optgroup", + "option", + "select", + "textarea" + ]) + ] + }, + height: { + alias: false, + tests: [ + test(["canvas", "embed", "iframe", "img", "input", "object", "video"]) + ] + }, + hidden: { + alias: false, + tests: [test(/./)] + }, + high: { + alias: false, + tests: [test("meter")] + }, + inputMode: { + alias: "inputmode", + tests: [ + test("textarea"), + test(/./, (it: HTMLElement) => it.isContentEditable) + ] + }, + kind: { + alias: false, + tests: [test("track")] + }, + label: { + alias: false, + tests: [test(["optgroup", "option", "track"])] + }, + loop: { + alias: false, + tests: [test(["audio", "video"])] + }, + low: { + alias: false, + tests: [test("meter")] + }, + max: { + alias: false, + tests: [test("input", isInputWithBoundaries), test(["meter", "progress"])] + }, + maxLength: { + alias: "maxlength", + tests: [test("input", isInputWithPlainText), test("textarea")] + }, + minLength: { + alias: "minlength", + tests: [test("input", isInputWithPlainText), test("textarea")] + }, + min: { + alias: false, + tests: [test("meter"), test("input", isInputWithBoundaries)] + }, + multiple: { + alias: false, + tests: [ + test("input", (it: HTMLInputElement) => it.type === "file"), + test("select") + ] + }, + open: { + alias: false, + tests: [test(["details", "dialog"])] + }, + optimum: { + alias: false, + tests: [test("meter")] + }, + placeholder: { + alias: false, + tests: [test(["input", "textarea"])] + }, + poster: { + alias: false, + tests: [test("video")] + }, + readOnly: { + alias: "readonly", + tests: [test(["input", "textarea"])] + }, + reversed: { + alias: false, + tests: [test("ol")] + }, + rows: { + alias: false, + tests: [test("textarea")] + }, + rowSpan: { + alias: "rowspan", + tests: [test(["td", "th"])] + }, + selected: { + alias: false, + tests: [test("option")] + }, + size: { + alias: false, + tests: [test("input", isInputWithPlainText), test("select")] + }, + span: { + alias: false, + tests: [test(["col", "colgroup"])] + }, + src: { + alias: false, + tests: [test(["embed", "iframe", "track"])] + }, + srcdoc: { + alias: false, + tests: [test("iframe")] + }, + sizes: { + alias: false, + tests: [test(["img", "source"])] + }, + start: { + alias: false, + tests: [test("ol")] + }, + title: { + alias: false, + tests: [test("abbr")] + }, + type: { + alias: false, + tests: [test("input"), test("ol")] + }, + value: { + alias: false, + tests: [ + test("input", (it: HTMLInputElement) => + /^(?!checkbox|radio)$/.test(it.type)), + test(["meter", "progress"]), + test("li", (it: HTMLLIElement) => it.parentElement!.localName === "ol") + ] + }, + width: { + alias: false, + tests: [ + test(["canvas", "embed", "iframe", "img", "input", "object", "video"]) + ] + }, + wrap: { + alias: false, + tests: [test("textarea")] + } +} as const; + +function isInputWithBoundaries(input: HTMLInputElement) { + return /^(?:number|range|date|datetime-local|year|month|week|day|time)$/.test( + input.type + ); +} + +function isInputWithPlainText(input: HTMLInputElement) { + return /^(?:text|search|tel|email|password|url)$/.test(input.type); +} + +function test( + localNames: RegExp | string[] | string, + check: (instance: T) => boolean = pass +) { + if (typeof localNames === "string") { + localNames = [localNames]; + } + + const reg = Array.isArray(localNames) + ? new RegExp(`^(?:${localNames.join("|")})$`) + : localNames; + return (instance: T) => reg.test(instance.localName) && check(instance); +} + +function pass() { + return true; +} diff --git a/src/stringify.ts b/src/stringify.ts index b6d9e16..d2237fd 100644 --- a/src/stringify.ts +++ b/src/stringify.ts @@ -37,7 +37,10 @@ function printAttributes(data: VisualData) { if (attributes) { for (const { name, value } of attributes) { - parts.push(name + (value === "" ? "" : `=${JSON.stringify(value)}`)); + parts.push( + name + + (value === true || value === "" ? "" : `=${JSON.stringify(value)}`) + ); } } @@ -45,7 +48,7 @@ function printAttributes(data: VisualData) { parts.push(printStyle(data)); } - return parts; + return parts.sort(); } function printStyle({ styles }: VisualData) { @@ -71,6 +74,7 @@ function printPseudoElements(data: VisualData) { } return `