diff --git a/README.md b/README.md index 6af51f3..06c3cc3 100644 --- a/README.md +++ b/README.md @@ -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 `