Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,30 @@ Check out the [tests](./src/__tests__/index.ts) for some examples.

## API

### `visualHTML(div: Element, options?: { shallow?: boolean })`
### `visualHTML(div: Element, options?: { shallow?: boolean, styleRules?: SelectorWithStyles[] })`

```javascript
visualHTML(document.body); // Returns the visual information of all nested elements in the body.
visualHTML(document.body, { shallow: true }); // Returns just visual information for the `<body>` element.
```

### `getDocumentStyleRules(document: Document)`

Every capture reads and specificity sorts the document's style rules, which is
the bulk of its work. A suite capturing many elements against the same
stylesheets can do that once and hand the result to each call:

```javascript
const styleRules = getDocumentStyleRules(document);

for (const el of elements) {
snapshot(visualHTML(el, { styleRules }));
}
```

Media conditions are evaluated as the rules are read, so parse again whenever
the viewport changes.

## 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.
Expand Down
128 changes: 127 additions & 1 deletion src/__tests__/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import visualHTML from "..";
import visualHTML, { getDocumentStyleRules } from "..";

const { matchMedia: _matchMedia } = window;
const { supports: _supports } =
Expand Down Expand Up @@ -370,3 +370,129 @@ function testHTML(html: string, styles: string = "") {
document.head.removeChild(style);
return result;
}

test("styles an element from pre-parsed rules after its stylesheet is gone", () => {
const style = document.createElement("style");
style.innerHTML = ".parsed { color: green; }";
document.head.appendChild(style);
const div = document.createElement("div");
div.className = "parsed";
document.body.appendChild(div);

const styleRules = getDocumentStyleRules(document);
document.head.removeChild(style);
const result = visualHTML(div, { styleRules });
document.body.removeChild(div);

expect(result).toMatchInlineSnapshot(`"<div style=\\"color: green\\"/>"`);
});

test("keeps urls as authored rather than resolved against the document", () => {
expect(
testHTML(`
<video src="./clip.mp4" poster="../posters/clip.png"/>
`)
).toMatchInlineSnapshot(`
"<video
poster=\\"../posters/clip.png\\"
src=\\"./clip.mp4\\"
/>"
`);
});

test("keeps img dimensions as authored rather than as loaded", () => {
expect(
testHTML(`
<img src="./cat.png" width="100" height="50"/>
`)
).toMatchInlineSnapshot(`
"<img
height=\\"50\\"
src=\\"./cat.png\\"
width=\\"100\\"
/>"
`);
});

test("omits img dimensions when the attributes are absent", () => {
expect(
testHTML(`
<img src="./cat.png"/>
`)
).toMatchInlineSnapshot(`"<img src=\\"./cat.png\\"/>"`);
});

test("includes the candidate list of a responsive image", () => {
expect(
testHTML(`
<img srcset="./cat.png 1x, ./cat@2x.png 2x" sizes="100vw"/>
`)
).toMatchInlineSnapshot(`
"<img
sizes=\\"100vw\\"
srcset=\\"./cat.png 1x, ./cat@2x.png 2x\\"
/>"
`);
});

test("keeps the href that makes an anchor a link", () => {
// Without it the anchor loses the user agent's link colour, underline and
// pointer cursor.
expect(
testHTML(`
<a href="/somewhere">link</a>
`)
).toMatchInlineSnapshot(`
"<a href=\\"/somewhere\\">
link
</a>"
`);
});

test("quotes attributes so values containing quotes survive parsing", () => {
const output = testHTML(
`<div class="quoted">text</div>`,
`.quoted {
background-image: url("cat.png?a=1&b=2");
font-family: "Market Sans", Arial;
position: absolute;
}`
);

const parsed = document.createElement("div");
parsed.innerHTML = output;
const style = (parsed.firstElementChild as HTMLElement).style;

expect(style.backgroundImage).toContain("cat.png?a=1&b=2");
expect(style.fontFamily).toContain("Market Sans");
// Everything after the quoted value used to be lost with the attribute.
expect(style.position).toBe("absolute");
});

test("does not reuse defaults across elements with different attributes", () => {
// A checkbox is border-box by default where a text input is content-box, so
// an input that declares border-box only shows up in one of the two.
expect(
testHTML(
`
<input type="checkbox" class="sized"/>
<input type="text" class="sized"/>
`,
`.sized { box-sizing: border-box; }`
)
).toMatchInlineSnapshot(`
"<input type=\\"checkbox\\"/>
<input style=\\"box-sizing: border-box\\"/>"
`);
});

test("keeps an initial the author wrote", () => {
expect(
testHTML(
`
<input type="text" class="reset"/>
`,
`.reset { background-color: initial; }`
)
).toMatchInlineSnapshot(`"<input style=\\"background-color: initial\\"/>"`);
});
8 changes: 5 additions & 3 deletions src/attributes.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { HTML_PROPERTIES } from "./html-properties";
import { DERIVED_PROPERTIES, HTML_PROPERTIES } from "./html-properties";

/**
* Given an element, returns any attributes that have a cause a visual change.
Expand All @@ -18,9 +18,11 @@ export function getVisualAttributes(el: Element) {
const { alias, tests } =
HTML_PROPERTIES[prop as keyof typeof HTML_PROPERTIES];
const name = alias || prop;
const value = el[prop];
const isDerived = DERIVED_PROPERTIES.has(prop);
const value = isDerived ? el.getAttribute(name) : el[prop];
const defaultValue = isDerived ? null : defaults[prop];

if (value !== defaults[prop]) {
if (value !== defaultValue) {
for (const test of tests) {
if (test(el as any)) {
(visualAttributes || (visualAttributes = [])).push({ name, value });
Expand Down
36 changes: 32 additions & 4 deletions src/default-styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,27 @@ let supportsPseudoElements: boolean | undefined;
* an element in an iframe without any styles and reading the default computed styles.
*/
export function getDefaultStyles(el: Element, pseudo: string | null) {
const key = `${el.namespaceURI}:${el.localName}:${pseudo}`;
const unstyled = el.cloneNode(false) as Element;
unstyled.removeAttribute("style");
// User agent styles select on attributes too: a checkbox has none of the
// border, padding and background a text input has.
const doc = el.ownerDocument!;
const key = `${el.namespaceURI}:${unstyled.outerHTML}:${pseudo}:${doc.compatMode}`;
let cached = cache[key];

if (!cached) {
const doc = el.ownerDocument!;
const frame = doc.createElement("iframe");
doc.body.appendChild(frame);
const frameDoc = frame.contentDocument!;
// A blank frame is in quirks mode, where an input is border-box rather
// than content-box; match the document being captured.
if (doc.compatMode === "CSS1Compat") {
frameDoc.open();
frameDoc.write("<!doctype html>");
frameDoc.close();
}
const frameWindow = frameDoc.defaultView!;
const clone = frameDoc.importNode(el, false);
clone.removeAttribute("style");
const clone = frameDoc.importNode(unstyled, false);
frameDoc.body.appendChild(clone);

cached = cache[key] = cloneStyles(
Expand All @@ -29,6 +39,24 @@ export function getDefaultStyles(el: Element, pseudo: string | null) {
return cached;
}

let initialStyles: { [x: string]: unknown } | undefined;

/**
* Gets what a declared `initial` resolves to. These belong to CSS rather than
* to any element, so one probe answers for every capture.
*/
export function getInitialStyles(doc: Document) {
if (!initialStyles) {
const probe = doc.createElement("div");
probe.style.setProperty("all", "initial");
doc.body.appendChild(probe);
initialStyles = cloneStyles(doc.defaultView!.getComputedStyle(probe));
doc.body.removeChild(probe);
}

return initialStyles;
}

function cloneStyles(styles: CSSStyleDeclaration) {
const result = Object.create(null) as { [x: string]: unknown };

Expand Down
31 changes: 26 additions & 5 deletions src/html-properties.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,6 @@ export const HTML_PROPERTIES = {
alias: false,
tests: [test("area")],
},
currentSrc: {
alias: "src",
tests: [test(["audio", "img", "source", "video"])],
},
data: {
alias: false,
tests: [test("object")],
Expand Down Expand Up @@ -119,6 +115,10 @@ export const HTML_PROPERTIES = {
alias: false,
tests: [test("meter")],
},
href: {
alias: false,
tests: [test(["a", "area"])],
},
inputMode: {
alias: "inputmode",
tests: [
Expand Down Expand Up @@ -211,12 +211,18 @@ export const HTML_PROPERTIES = {
},
src: {
alias: false,
tests: [test(["embed", "iframe", "track"])],
tests: [
test(["audio", "embed", "iframe", "img", "source", "track", "video"]),
],
},
srcdoc: {
alias: false,
tests: [test("iframe")],
},
srcset: {
alias: false,
tests: [test(["img", "source"])],
},
sizes: {
alias: false,
tests: [test(["img", "source"])],
Expand Down Expand Up @@ -254,6 +260,21 @@ export const HTML_PROPERTIES = {
},
} as const;

/**
* Properties reporting a value the author never wrote: urls resolved against
* the document, and the natural size of a media element once it has loaded.
* These are read from the content attribute instead.
*/
export const DERIVED_PROPERTIES = new Set([
"background",
"data",
"height",
"href",
"poster",
"src",
"width",
]);

function isInputWithBoundaries(input: HTMLInputElement) {
return /^(?:number|range|date|datetime-local|year|month|week|day|time)$/.test(
input.type
Expand Down
4 changes: 3 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from "./stylesheets";

export { VisualData, Options };
export { getDocumentStyleRules };

const ELEMENT_TYPE = 1;
const TEXT_TYPE = 3;
Expand All @@ -20,7 +21,8 @@ export default function visualHTML(el: Element, options: Options = {}) {
return stringifyVisualData(
getVisualData(el, {
...options,
styleRules: getDocumentStyleRules(el.ownerDocument!),
styleRules:
options.styleRules ?? getDocumentStyleRules(el.ownerDocument!),
})
);
}
Expand Down
Loading
Loading