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.
Password:
-
+
Remember Me:
@@ -151,7 +149,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 `