├── .commitlintrc.json
├── .github
├── ISSUE_TEMPLATE
│ ├── Bug_report.md
│ └── Feature_request.md
├── PULL_REQUEST_TEMPLATE.md
└── workflows
│ └── ci.yml
├── .gitignore
├── .huskyrc
├── .lintstagedrc
├── .prettierignore
├── CHANGELOG.md
├── LICENSE
├── README.md
├── assets
└── logo.png
├── jest.config.js
├── package-lock.json
├── package.json
├── src
├── __tests__
│ ├── examples.ts
│ └── index.ts
├── attributes.ts
├── default-styles.ts
├── html-properties.ts
├── index.ts
├── split-selector.ts
├── stringify.ts
├── stylesheets.ts
└── types.ts
├── tsconfig.json
└── tslint.json
/.commitlintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["@commitlint/config-conventional"]
3 | }
4 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/Bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: "\U0001F41BBug report"
3 | about: Something isn't working right
4 | ---
5 |
6 | ## Version: x.x.x
7 |
8 |
9 |
10 | ### Details
11 |
12 |
13 |
14 | ### Expected Behavior
15 |
16 |
17 |
18 | ### Actual Behavior
19 |
20 |
21 |
22 | ### Possible Fix
23 |
24 |
25 |
26 | Additional Info
27 |
28 | ### Your Environment
29 |
30 |
31 |
32 | - Environment name and version (e.g. Chrome 39, node.js 5.4):
33 | - Operating System and version (desktop or mobile):
34 | - Link to your project:
35 |
36 | ### Steps to Reproduce
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 | 1. first...
45 | 2.
46 | 3.
47 | 4.
48 |
49 | ### Stack Trace
50 |
51 |
52 |
53 |
54 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/Feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: "\U0001F680Feature request"
3 | about: Suggest an idea for a package in this repo
4 | ---
5 |
6 | ### Description
7 |
8 |
9 |
10 | ### Why
11 |
12 |
13 |
14 |
15 |
16 | ### Possible Implementation & Open Questions
17 |
18 |
19 |
20 |
21 |
22 | ### Is this something you're interested in working on?
23 |
24 |
25 |
--------------------------------------------------------------------------------
/.github/PULL_REQUEST_TEMPLATE.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | ## Description
4 |
5 |
6 |
7 | ## Motivation and Context
8 |
9 |
10 |
11 |
12 |
13 | ## Screenshots (if appropriate):
14 |
15 | ## Checklist:
16 |
17 |
18 |
19 |
20 |
21 | - [ ] I have updated/added documentation affected by my changes.
22 | - [ ] I have added tests to cover my changes.
23 |
24 |
27 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | pull_request:
5 | types: [opened, synchronize]
6 | push:
7 | branches: [main]
8 |
9 | concurrency:
10 | group: "${{ github.workflow }}-${{ github.event_name == 'pull_request_target' && github.head_ref || github.ref }}"
11 | cancel-in-progress: true
12 |
13 | jobs:
14 | format:
15 | runs-on: ubuntu-latest
16 | steps:
17 | - name: Checkout code
18 | uses: actions/checkout@v3
19 | with:
20 | ref: ${{ github.head_ref }}
21 | - name: Use node
22 | uses: actions/setup-node@v3
23 | with:
24 | node-version: 16
25 | cache: npm
26 | - name: Install dependencies
27 | run: npm ci
28 | - name: Format Code
29 | run: npm run format
30 | - name: Lint Code
31 | run: npm run lint
32 | - name: Commit changes
33 | uses: stefanzweifel/git-auto-commit-action@v4
34 | with:
35 | commit_message: "[ci] format"
36 | commit_user_name: "github-actions[bot]"
37 | commit_user_email: "github-actions[bot]@users.noreply.github.com"
38 | env:
39 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
40 | test:
41 | runs-on: ubuntu-latest
42 | name: "test: node@${{ matrix.node }}"
43 | strategy:
44 | fail-fast: false
45 | matrix:
46 | node: [14, 16, 18]
47 | steps:
48 | - name: Checkout code
49 | uses: actions/checkout@v3
50 | - name: Use node@${{ matrix.node }}
51 | uses: actions/setup-node@v3
52 | with:
53 | node-version: ${{ matrix.node }}
54 | cache: npm
55 | - name: Install dependencies
56 | run: npm ci
57 | - name: Run tests
58 | run: npm run ci:test
59 | - name: Report code coverage
60 | uses: codecov/codecov-action@v2
61 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Editor
2 | *.sublime*
3 | .vscode
4 |
5 | # OSX
6 | *.DS_Store
7 |
8 | # NPM
9 | node_modules
10 | npm-debug.log
11 |
12 | # Build
13 | dist
14 |
15 | # Coverage
16 | coverage
17 | .nyc_output
18 |
19 | # Test
20 | *.actual.*
--------------------------------------------------------------------------------
/.huskyrc:
--------------------------------------------------------------------------------
1 | {
2 | "hooks": {
3 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS",
4 | "pre-commit": "lint-staged"
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/.lintstagedrc:
--------------------------------------------------------------------------------
1 | {
2 | "*.ts": ["prettier --write", "tslint -t codeFrame -c tslint.json", "git add"],
3 | "*.{js,json,md}": ["prettier --write", "git add"]
4 | }
5 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | .vscode
2 | package.json
3 | package-lock.json
4 | CHANGELOG.md
5 | node_modules
6 | coverage
7 | dist
8 | __snapshots__
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
4 |
5 | ### [2.1.3](https://github.com/eBay/visual-html/compare/v2.1.2...v2.1.3) (2025-05-23)
6 |
7 |
8 | ### Bug Fixes
9 |
10 | * **split-selectors:** updated to match latest CSS spec. Also updated specificity package ([#27](https://github.com/eBay/visual-html/issues/27)) ([e155bdf](https://github.com/eBay/visual-html/commit/e155bdf3633b319fa210b2f2b582e2408e043858))
11 |
12 | ### [2.1.2](https://github.com/eBay/visual-html/compare/v2.1.1...v2.1.2) (2022-08-19)
13 |
14 |
15 | ### Bug Fixes
16 |
17 | * upgrade deps and improve jsdom support ([#21](https://github.com/eBay/visual-html/issues/21)) ([bdee9af](https://github.com/eBay/visual-html/commit/bdee9afd0e664224b8613c5d045ad51d0f2605e4))
18 |
19 | ### [2.1.1](https://github.com/eBay/visual-html/compare/v2.1.0...v2.1.1) (2019-08-27)
20 |
21 | ## [2.1.0](https://github.com/eBay/visual-html/compare/v2.0.2...v2.1.0) (2019-08-27)
22 |
23 |
24 | ### Features
25 |
26 | * removes applied style properties if they are the default ([8372924](https://github.com/eBay/visual-html/commit/8372924))
27 |
28 | ### [2.0.2](https://github.com/eBay/visual-html/compare/v2.0.1...v2.0.2) (2019-08-27)
29 |
30 |
31 | ### Bug Fixes
32 |
33 | * issue with removing attributes from non html elements ([ad05146](https://github.com/eBay/visual-html/commit/ad05146))
34 |
35 | ### [2.0.1](https://github.com/eBay/visual-html/compare/v2.0.0...v2.0.1) (2019-08-27)
36 |
37 |
38 | ### Bug Fixes
39 |
40 | * support for multiple selectors in one rule ([326dc2c](https://github.com/eBay/visual-html/commit/326dc2c))
41 |
42 | ## [2.0.0](https://github.com/eBay/visual-html/compare/v1.0.1...v2.0.0) (2019-08-26)
43 |
44 |
45 | ### ⚠ BREAKING CHANGES
46 |
47 | * snapshot output has changed
48 | * snapshot output has changed
49 |
50 | ### Features
51 |
52 | * attribute whitelist ([2d5e0e1](https://github.com/eBay/visual-html/commit/2d5e0e1))
53 |
54 |
55 | * Merge pull request #2 from eBay/attribute-whitelist ([cb77746](https://github.com/eBay/visual-html/commit/cb77746)), closes [#2](https://github.com/eBay/visual-html/issues/2)
56 |
57 | ### [1.0.1](https://github.com/eBay/visual-html/compare/v1.0.0...v1.0.1) (2019-08-20)
58 |
59 |
60 | ### Bug Fixes
61 |
62 | * format package.json, ensure build called on publish ([bea8845](https://github.com/eBay/visual-html/commit/bea8845))
63 |
64 | ## 1.0.0 (2019-08-19)
65 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 eBay Inc. and contributors
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
36 |
37 | Blazing fast visual regression testing without the flakiness.
38 |
39 | # Installation
40 |
41 | ```console
42 | npm install visual-html
43 | ```
44 |
45 | ## Features
46 |
47 | - Works in modern browsers and JSDOM (assuming you are loading styles in your tests).
48 | - Snapshots are able to be rendered by a real browser, useful for debugging!
49 | - Easily test and mock `@media` and `@supports` styles (see tests folder).
50 | - Reduces implementation details in your snapshots.
51 | - Supports inline styles and stylesheets/blocks.
52 | - Supports CSS in JS or CSS in CSS!
53 | - Supports pseudo elements.
54 |
55 | Check out the [tests](./src/__tests__/index.ts) for some examples.
56 |
57 | ## API
58 |
59 | ### `visualHTML(div: Element, options?: { shallow?: boolean })`
60 |
61 | ```javascript
62 | visualHTML(document.body); // Returns the visual information of all nested elements in the body.
63 | visualHTML(document.body, { shallow: true }); // Returns just visual information for the `` element.
64 | ```
65 |
66 | ## How it works
67 |
68 | `visual-html` works by building up an HTML representation of the DOM including only attributes that account for the visual display of the element.
69 | 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.
70 |
71 | Lets look at an example.
72 |
73 | ```html
74 |
85 |
86 |
91 | Hello!
92 |
93 |
111 |
112 | ```
113 |
114 | Passing the `div` element above to `visual-html` would yield the following:
115 |
116 | ```javascript
117 | import visualHTML from "visual-html";
118 |
119 | visualHTML(div); // Returns the html below as string.
120 | ```
121 |
122 | ```html
123 |
131 | Hello!
132 |
147 |
148 | ```
149 |
150 | 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.
151 |
152 | ## How is this different than x!?
153 |
154 | ### using an actual image based visual regression tool? (eg. puppeteer)
155 |
156 | At the end of the day we are trying to test that our components display correctly, and to catch visual regressions, so why not use an image based visual regression tool? These tools require a real browser, and often can be slow and unreliable. Specifically browsers rely heavily on the operating system to render parts of the page such as fonts which can cause slight differences between screenshots taken from your local machine and your CI. You can get around this last part by having a CI or a local docker image but either way your compromising the speed of your tests and development workflow.
157 |
158 | With this module we are not rendering actual pixels. Instead it pulls all visual information from the DOM and aggregates it into an HTML snapshot. You can build and compare these text based snapshots during your tests which is quick and repeatable. This allows you to have increased confidence in your tests without slowing down or complicating your work flow.
159 |
160 | ### inlining styles and snapshoting the elements HTML directly?
161 |
162 | The key with snapshots is to avoid allowing the implementation details of your tests to leak in.
163 | Snapshots are easy to update, but if too much is leaking in they can often be hard to review.
164 |
165 | In an ideal world a snapshot would automatically include just the critical assertions for what
166 | you are testing so that you can confidently refactor your code without breaking your tests.
167 |
168 | This is where `visual-html` comes in. It is a solution for testing the visual aspect of your components
169 | and works to create a snapshot containing only visually relevant information.
170 |
171 | ### writing tests for classes on an element?
172 |
173 | Testing the exact classes applied to an element often provides little value.
174 | A simple way to determine the value of a test is to think about when it would break, and in turn which issues it would catch.
175 |
176 | In the example below the tests do not know anything about `some-class`, what it does, or if it even has a matching css selector.
177 |
178 | Imagine that `some-class` is really just a utility to visually highlight our element, our tests do not capture that at all.
179 | By instead testing the applied styles you know that your CSS is actually hooked up properly, and you can have a better idea of
180 | how the element would be visually displayed to the user.
181 |
182 | ```javascript
183 | test("it has the right class", () => {
184 | const { container } = render(MyComponent);
185 |
186 | expect(container).not.toHaveClass("some-class");
187 | doThing();
188 | expect(container).toHaveClass("some-class");
189 | });
190 | ```
191 |
192 | vs
193 |
194 | ```javascript
195 | import snapshotDiff from "snapshot-diff";
196 |
197 | test("it is highlighted after the thing", () => {
198 | const { container } = render(MyComponent);
199 | const originalView = visualHTML(container);
200 | doThing();
201 | const updatedView = visualHTML(container);
202 |
203 | expect(snapshotDiff(originalView, updatedView)).toMatchInlineSnapshot(`
204 | "Snapshot Diff:
205 | - First value
206 | + Second value
207 |
208 | -
209 | +
"
210 | `);
211 | });
212 | ```
213 |
214 | With the above you can refactor the way the element is highlighted (different class, inline styles, etc) and as long
215 | as the element is still ultimately displayed the same, your test will continue to pass.
216 |
217 | ## Code of Conduct
218 |
219 | This project adheres to the [eBay Code of Conduct](http://ebay.github.io/codeofconduct). By participating in this project you agree to abide by its terms.
220 |
221 | ## License
222 |
223 | Copyright 2019 eBay Inc.
224 | Author/Developer: Dylan Piercey, Michael Rawlings
225 |
226 | Use of this source code is governed by an MIT-style license that can be found in the LICENSE file or at https://opensource.org/licenses/MIT.
227 |
--------------------------------------------------------------------------------
/assets/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/eBay/visual-html/a9fafc7b8f9bfc05063022e7bb00abc23a03089b/assets/logo.png
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | preset: "ts-jest",
3 | testEnvironment: "jsdom",
4 | };
5 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "visual-html",
3 | "description": "Visual regression testing without the flakiness.",
4 | "version": "2.1.3",
5 | "author": "Dylan Piercey ",
6 | "bugs": "https://github.com/eBay/visual-html/issues",
7 | "dependencies": {
8 | "specificity": "^1.0.0"
9 | },
10 | "devDependencies": {
11 | "@commitlint/cli": "^17.0.3",
12 | "@commitlint/config-conventional": "^17.0.3",
13 | "@types/jest": "^28.1.7",
14 | "@types/node": "^18.7.7",
15 | "husky": "^8.0.1",
16 | "jest": "^28.1.3",
17 | "jest-environment-jsdom": "^28.1.3",
18 | "lint-staged": "^13.0.3",
19 | "prettier": "^2.7.1",
20 | "snapshot-diff": "^0.9.0",
21 | "standard-version": "^9.5.0",
22 | "ts-jest": "^28.0.8",
23 | "tslint": "^5.20.1",
24 | "tslint-config-prettier": "^1.18.0",
25 | "typescript": "^4.7.4"
26 | },
27 | "files": [
28 | "dist"
29 | ],
30 | "homepage": "https://github.com/eBay/visual-html",
31 | "keywords": [
32 | "computed",
33 | "regression",
34 | "styles",
35 | "visual"
36 | ],
37 | "license": "MIT",
38 | "main": "dist/index.js",
39 | "repository": {
40 | "type": "git",
41 | "url": "https://github.com/eBay/visual-html"
42 | },
43 | "scripts": {
44 | "build": "tsc",
45 | "ci:report": "cat coverage/lcov.info | coveralls",
46 | "ci:test": "jest --ci --coverage",
47 | "format": "prettier \"**/*.{json,md,js,ts}\" --write",
48 | "lint": "tsc --noEmit && tslint -t codeFrame -c tslint.json 'src/**/*.ts'",
49 | "prepublishOnly": "npm run build",
50 | "release": "standard-version",
51 | "test": "jest -o",
52 | "test:watch": "jest -o --watch"
53 | },
54 | "types": "dist/index.d.ts"
55 | }
56 |
--------------------------------------------------------------------------------
/src/__tests__/examples.ts:
--------------------------------------------------------------------------------
1 | import snapshotDiff from "snapshot-diff";
2 | import visualHTML from "..";
3 |
4 | test("runs the first example", () => {
5 | expect(
6 | testHTML(
7 | `
8 |
12 | Hello!
13 |
14 |
32 |
33 | `,
34 | `
35 | .my-component {
36 | width: 100px;
37 | height: 200px;
38 | background: red;
39 | }
40 |
41 | .my-component span {
42 | color: #333;
43 | }
44 | `
45 | )
46 | ).toMatchInlineSnapshot(`
47 | "
53 |
54 | Hello!
55 |
56 |
73 |
"
74 | `);
75 | });
76 |
77 | test("works with diff snapshots", () => {
78 | const styles = `
79 | .my-component.highlight {
80 | border: 2px solid green;
81 | }
82 | `;
83 |
84 | expect(
85 | snapshotDiff(
86 | testHTML(`
`, styles),
87 | testHTML(`
`, styles)
88 | )
89 | ).toMatchInlineSnapshot(`
90 | "Snapshot Diff:
91 | - First value
92 | + Second value
93 |
94 | -
95 | +
"
96 | `);
97 | });
98 |
99 | function testHTML(html: string, styles: string = "") {
100 | const div = document.createElement("div");
101 | const style = document.createElement("style");
102 | style.innerHTML = styles;
103 | div.innerHTML = html;
104 | document.head.appendChild(style);
105 | document.body.appendChild(div);
106 | const result = Array.from(div.children)
107 | .map((el) => visualHTML(el))
108 | .join("\n");
109 | document.body.removeChild(div);
110 | document.head.removeChild(style);
111 | return result;
112 | }
113 |
--------------------------------------------------------------------------------
/src/__tests__/index.ts:
--------------------------------------------------------------------------------
1 | import visualHTML from "..";
2 |
3 | const { matchMedia: _matchMedia } = window;
4 | const { supports: _supports } =
5 | window.CSS ||
6 | (window.CSS = {
7 | supports() {
8 | return false;
9 | },
10 | } as any);
11 |
12 | afterEach(() => {
13 | window.matchMedia = _matchMedia;
14 | CSS.supports = _supports;
15 | });
16 |
17 | test("removes any properties that do not apply user agent styles", () => {
18 | expect(
19 | testHTML(`
20 |
21 | `)
22 | ).toMatchInlineSnapshot(`" "`);
23 | });
24 |
25 | test("preserves any properties that do apply user agent styles", () => {
26 | expect(
27 | testHTML(`
28 |
29 | `)
30 | ).toMatchInlineSnapshot(`
31 | " "
35 | `);
36 | });
37 |
38 | test("preserves any inline styles", () => {
39 | expect(
40 | testHTML(`
41 |
42 | `)
43 | ).toMatchInlineSnapshot(`"
"`);
44 | });
45 |
46 | test("inline styles override applied styles", () => {
47 | expect(
48 | testHTML(
49 | `
50 |
51 | `,
52 | `
53 | div {
54 | background: red;
55 | color: blue;
56 | }
57 | `
58 | )
59 | ).toMatchInlineSnapshot(`
60 | "
"
64 | `);
65 | });
66 |
67 | test("accounts for !important", () => {
68 | expect(
69 | testHTML(
70 | `
71 |
72 | `,
73 | `
74 | div {
75 | background: blue !important;
76 | color: red !important;
77 | }
78 | `
79 | )
80 | ).toMatchInlineSnapshot(`
81 | "
"
85 | `);
86 | });
87 |
88 | test("copies properties from applied styles", () => {
89 | expect(
90 | testHTML(
91 | `
92 |
93 | `,
94 | `
95 | .test {
96 | color: green;
97 | }
98 | `
99 | )
100 | ).toMatchInlineSnapshot(`"
"`);
101 | });
102 |
103 | test("accounts for applied style specificity", () => {
104 | expect(
105 | testHTML(
106 | `
107 |
108 | `,
109 | `
110 | div.test {
111 | color: blue;
112 | }
113 |
114 | .test {
115 | color: green;
116 | }
117 | `
118 | )
119 | ).toMatchInlineSnapshot(`"
"`);
120 | });
121 |
122 | test("accounts for multiple selectors in one rule", () => {
123 | expect(
124 | testHTML(
125 | `
126 |
127 | `,
128 | `
129 | div, div.test {
130 | color: blue;
131 | }
132 |
133 | .test {
134 | color: green;
135 | }
136 | `
137 | )
138 | ).toMatchInlineSnapshot(`"
"`);
139 | });
140 |
141 | test("supports multiple applied styles", () => {
142 | expect(
143 | testHTML(
144 | `
145 |
146 | `,
147 | `
148 | div.test {
149 | color: blue;
150 | background-color: red;
151 | font-size: 1rem;
152 | }
153 | `
154 | )
155 | ).toMatchInlineSnapshot(`
156 | "
"
161 | `);
162 | });
163 |
164 | test("includes children", () => {
165 | expect(
166 | testHTML(
167 | `
168 |
169 | A
170 | B
171 | C
172 |
173 | `,
174 | `
175 | .parent {
176 | background: red;
177 | }
178 |
179 | .child-a {
180 | color: green;
181 | }
182 |
183 | .child-b {
184 | color: red;
185 | }
186 |
187 | .child-c {
188 | color: blue;
189 | }
190 | `
191 | )
192 | ).toMatchInlineSnapshot(`
193 | "
194 |
195 | A
196 |
197 |
198 | B
199 |
200 |
201 | C
202 |
203 |
"
204 | `);
205 | });
206 |
207 | test("evaluates media queries", () => {
208 | window.matchMedia = jest
209 | .fn()
210 | .mockReturnValueOnce({ matches: false })
211 | .mockReturnValueOnce({ matches: true });
212 |
213 | const html = `
214 |
215 | `;
216 |
217 | const styles = `
218 | .test {
219 | color: green;
220 | }
221 |
222 | @media(max-width: 600px) {
223 | .test {
224 | color: blue;
225 | }
226 | }
227 | `;
228 |
229 | expect(testHTML(html, styles)).toMatchInlineSnapshot(
230 | `"
"`
231 | );
232 |
233 | expect(testHTML(html, styles)).toMatchInlineSnapshot(
234 | `"
"`
235 | );
236 | });
237 |
238 | test("evaluates supports queries", () => {
239 | window.CSS.supports = jest
240 | .fn()
241 | .mockReturnValueOnce(false)
242 | .mockReturnValueOnce(true);
243 |
244 | const html = `
245 |
246 | `;
247 |
248 | const styles = `
249 | .test {
250 | color: green;
251 | }
252 |
253 | @supports(something-new-feature: blue) {
254 | .test {
255 | color: blue;
256 | }
257 | }
258 | `;
259 |
260 | expect(testHTML(html, styles)).toMatchInlineSnapshot(
261 | `"
"`
262 | );
263 |
264 | expect(testHTML(html, styles)).toMatchInlineSnapshot(
265 | `"
"`
266 | );
267 | });
268 |
269 | test("includes pseudo elements", () => {
270 | const html = `
271 |
272 | Content
273 |
274 | `;
275 |
276 | const styles = `
277 | .test::after {
278 | content: "hello";
279 | color: green;
280 | }
281 |
282 | .test::selection {
283 | background: red;
284 | }
285 |
286 | ::selection {
287 | background: blue;
288 | }
289 |
290 | p::selection {
291 | background: green;
292 | }
293 | `;
294 |
295 | expect(testHTML(html, styles)).toMatchInlineSnapshot(`
296 | "
297 |
304 |
305 |
308 | Content
309 |
310 |
"
311 | `);
312 | });
313 |
314 | function testHTML(html: string, styles: string = "") {
315 | const div = document.createElement("div");
316 | const style = document.createElement("style");
317 | style.innerHTML = styles;
318 | div.innerHTML = html;
319 | document.head.appendChild(style);
320 | document.body.appendChild(div);
321 | const result = Array.from(div.children)
322 | .map((el) => visualHTML(el))
323 | .join("\n");
324 | document.body.removeChild(div);
325 | document.head.removeChild(style);
326 | return result;
327 | }
328 |
--------------------------------------------------------------------------------
/src/attributes.ts:
--------------------------------------------------------------------------------
1 | import { HTML_PROPERTIES } from "./html-properties";
2 |
3 | /**
4 | * Given an element, returns any attributes that have a cause a visual change.
5 | * This works by checking against a whitelist of known visual properties, and
6 | * their related attribute name.
7 | */
8 | export function getVisualAttributes(el: Element) {
9 | let visualAttributes: Array<{
10 | name: string;
11 | value: string | boolean | null;
12 | }> | null = null;
13 | if (!el.namespaceURI || el.namespaceURI === "http://www.w3.org/1999/xhtml") {
14 | // For HTML elements we look at a whitelist of properties and compare against the default value.
15 | const defaults = el.ownerDocument!.createElement(el.localName);
16 |
17 | for (const prop in HTML_PROPERTIES) {
18 | const { alias, tests } =
19 | HTML_PROPERTIES[prop as keyof typeof HTML_PROPERTIES];
20 | const name = alias || prop;
21 | const value = el[prop];
22 |
23 | if (value !== defaults[prop]) {
24 | for (const test of tests) {
25 | if (test(el as any)) {
26 | (visualAttributes || (visualAttributes = [])).push({ name, value });
27 | break;
28 | }
29 | }
30 | }
31 | }
32 | } else {
33 | // For other namespaces we assume all attributes are visual, except for a blacklist.
34 | const { attributes } = el;
35 |
36 | for (let i = 0, len = attributes.length; i < len; i++) {
37 | const { name, value } = attributes[i];
38 |
39 | if (
40 | !(
41 | (/^(?:xlink:)?href$/i.test(name) &&
42 | el.localName !== "a" &&
43 | el.localName !== "use") ||
44 | /^(?:class|id|style|lang|target|xmlns(?::.+)?|xlink:.+|xml:(?:lang|base)|on.+|(?:aria|data)-.+)$/i.test(
45 | name
46 | )
47 | )
48 | ) {
49 | (visualAttributes || (visualAttributes = [])).push({ name, value });
50 | }
51 | }
52 | }
53 |
54 | return visualAttributes;
55 | }
56 |
--------------------------------------------------------------------------------
/src/default-styles.ts:
--------------------------------------------------------------------------------
1 | const cache: { [x: string]: { [x: string]: unknown } } = Object.create(null);
2 | let supportsPseudoElements: boolean | undefined;
3 |
4 | /**
5 | * Gets the default styles for an element or pseudo element. Works by creating
6 | * an element in an iframe without any styles and reading the default computed styles.
7 | */
8 | export function getDefaultStyles(el: Element, pseudo: string | null) {
9 | const key = `${el.namespaceURI}:${el.localName}:${pseudo}`;
10 | let cached = cache[key];
11 |
12 | if (!cached) {
13 | const doc = el.ownerDocument!;
14 | const frame = doc.createElement("iframe");
15 | doc.body.appendChild(frame);
16 | const frameDoc = frame.contentDocument!;
17 | const frameWindow = frameDoc.defaultView!;
18 | const clone = frameDoc.importNode(el, false);
19 | clone.removeAttribute("style");
20 | frameDoc.body.appendChild(clone);
21 |
22 | cached = cache[key] = cloneStyles(
23 | getComputedStyle(frameWindow, clone, pseudo)
24 | );
25 |
26 | doc.body.removeChild(frame);
27 | }
28 |
29 | return cached;
30 | }
31 |
32 | function cloneStyles(styles: CSSStyleDeclaration) {
33 | const result = Object.create(null) as { [x: string]: unknown };
34 |
35 | for (let i = styles.length; i--; ) {
36 | const name = styles[i];
37 | result[name] = styles.getPropertyValue(name);
38 | }
39 |
40 | return result;
41 | }
42 |
43 | function getComputedStyle(window: Window, el: Element, pseudo: string | null) {
44 | if (supportsPseudoElements === undefined) {
45 | // JSDOM cannot use getComputedStyle for pseudo elements.
46 | supportsPseudoElements = !navigator.userAgent.includes("jsdom");
47 | }
48 |
49 | return window.getComputedStyle(el, supportsPseudoElements ? pseudo : null);
50 | }
51 |
--------------------------------------------------------------------------------
/src/html-properties.ts:
--------------------------------------------------------------------------------
1 | export const HTML_PROPERTIES = {
2 | align: {
3 | alias: false,
4 | tests: [
5 | test([
6 | "applet",
7 | "caption",
8 | "col",
9 | "colgroup",
10 | "hr",
11 | "iframe",
12 | "img",
13 | "table",
14 | "tbody",
15 | "td",
16 | "tfoot",
17 | "th",
18 | "thead",
19 | "tr",
20 | ]),
21 | ],
22 | },
23 | autoplay: {
24 | alias: false,
25 | tests: [test(["audio", "video"])],
26 | },
27 | background: {
28 | alias: false,
29 | tests: [test(["body", "table", "td", "th"])],
30 | },
31 | bgColor: {
32 | alias: "bgcolor",
33 | tests: [
34 | test([
35 | "body",
36 | "col",
37 | "colgroup",
38 | "table",
39 | "tbody",
40 | "tfoot",
41 | "td",
42 | "th",
43 | "tr",
44 | ]),
45 | ],
46 | },
47 | border: {
48 | alias: false,
49 | tests: [test(["img", "object", "table"])],
50 | },
51 | checked: {
52 | alias: false,
53 | tests: [
54 | test("input", (it: HTMLInputElement) =>
55 | /^(?:checkbox|radio)$/.test(it.type)),
56 | ],
57 | },
58 | color: {
59 | alias: false,
60 | tests: [test(["basefont", "font", "hr"])],
61 | },
62 | cols: {
63 | alias: false,
64 | tests: [test("textarea")],
65 | },
66 | colSpan: {
67 | alias: "colspan",
68 | tests: [test(["td", "th"])],
69 | },
70 | controls: {
71 | alias: false,
72 | tests: [test(["audio", "video"])],
73 | },
74 | coords: {
75 | alias: false,
76 | tests: [test("area")],
77 | },
78 | currentSrc: {
79 | alias: "src",
80 | tests: [test(["audio", "img", "source", "video"])],
81 | },
82 | data: {
83 | alias: false,
84 | tests: [test("object")],
85 | },
86 | default: {
87 | alias: false,
88 | tests: [test("track")],
89 | },
90 | dir: {
91 | alias: false,
92 | tests: [test(/./)],
93 | },
94 | disabled: {
95 | alias: false,
96 | tests: [
97 | test([
98 | "button",
99 | "fieldset",
100 | "input",
101 | "optgroup",
102 | "option",
103 | "select",
104 | "textarea",
105 | ]),
106 | ],
107 | },
108 | height: {
109 | alias: false,
110 | tests: [
111 | test(["canvas", "embed", "iframe", "img", "input", "object", "video"]),
112 | ],
113 | },
114 | hidden: {
115 | alias: false,
116 | tests: [test(/./)],
117 | },
118 | high: {
119 | alias: false,
120 | tests: [test("meter")],
121 | },
122 | inputMode: {
123 | alias: "inputmode",
124 | tests: [
125 | test("textarea"),
126 | test(/./, (it: HTMLElement) => it.isContentEditable),
127 | ],
128 | },
129 | kind: {
130 | alias: false,
131 | tests: [test("track")],
132 | },
133 | label: {
134 | alias: false,
135 | tests: [test(["optgroup", "option", "track"])],
136 | },
137 | loop: {
138 | alias: false,
139 | tests: [test(["audio", "video"])],
140 | },
141 | low: {
142 | alias: false,
143 | tests: [test("meter")],
144 | },
145 | max: {
146 | alias: false,
147 | tests: [test("input", isInputWithBoundaries), test(["meter", "progress"])],
148 | },
149 | maxLength: {
150 | alias: "maxlength",
151 | tests: [test("input", isInputWithPlainText), test("textarea")],
152 | },
153 | minLength: {
154 | alias: "minlength",
155 | tests: [test("input", isInputWithPlainText), test("textarea")],
156 | },
157 | min: {
158 | alias: false,
159 | tests: [test("meter"), test("input", isInputWithBoundaries)],
160 | },
161 | multiple: {
162 | alias: false,
163 | tests: [
164 | test("input", (it: HTMLInputElement) => it.type === "file"),
165 | test("select"),
166 | ],
167 | },
168 | open: {
169 | alias: false,
170 | tests: [test(["details", "dialog"])],
171 | },
172 | optimum: {
173 | alias: false,
174 | tests: [test("meter")],
175 | },
176 | placeholder: {
177 | alias: false,
178 | tests: [test(["input", "textarea"])],
179 | },
180 | poster: {
181 | alias: false,
182 | tests: [test("video")],
183 | },
184 | readOnly: {
185 | alias: "readonly",
186 | tests: [test(["input", "textarea"])],
187 | },
188 | reversed: {
189 | alias: false,
190 | tests: [test("ol")],
191 | },
192 | rows: {
193 | alias: false,
194 | tests: [test("textarea")],
195 | },
196 | rowSpan: {
197 | alias: "rowspan",
198 | tests: [test(["td", "th"])],
199 | },
200 | selected: {
201 | alias: false,
202 | tests: [test("option")],
203 | },
204 | size: {
205 | alias: false,
206 | tests: [test("input", isInputWithPlainText), test("select")],
207 | },
208 | span: {
209 | alias: false,
210 | tests: [test(["col", "colgroup"])],
211 | },
212 | src: {
213 | alias: false,
214 | tests: [test(["embed", "iframe", "track"])],
215 | },
216 | srcdoc: {
217 | alias: false,
218 | tests: [test("iframe")],
219 | },
220 | sizes: {
221 | alias: false,
222 | tests: [test(["img", "source"])],
223 | },
224 | start: {
225 | alias: false,
226 | tests: [test("ol")],
227 | },
228 | title: {
229 | alias: false,
230 | tests: [test("abbr")],
231 | },
232 | type: {
233 | alias: false,
234 | tests: [test("input"), test("ol")],
235 | },
236 | value: {
237 | alias: false,
238 | tests: [
239 | test("input", (it: HTMLInputElement) =>
240 | /^(?!checkbox|radio)$/.test(it.type)),
241 | test(["meter", "progress"]),
242 | test("li", (it: HTMLLIElement) => it.parentElement!.localName === "ol"),
243 | ],
244 | },
245 | width: {
246 | alias: false,
247 | tests: [
248 | test(["canvas", "embed", "iframe", "img", "input", "object", "video"]),
249 | ],
250 | },
251 | wrap: {
252 | alias: false,
253 | tests: [test("textarea")],
254 | },
255 | } as const;
256 |
257 | function isInputWithBoundaries(input: HTMLInputElement) {
258 | return /^(?:number|range|date|datetime-local|year|month|week|day|time)$/.test(
259 | input.type
260 | );
261 | }
262 |
263 | function isInputWithPlainText(input: HTMLInputElement) {
264 | return /^(?:text|search|tel|email|password|url)$/.test(input.type);
265 | }
266 |
267 | function test(
268 | localNames: RegExp | string[] | string,
269 | check: (instance: T) => boolean = pass
270 | ) {
271 | if (typeof localNames === "string") {
272 | localNames = [localNames];
273 | }
274 |
275 | const reg = Array.isArray(localNames)
276 | ? new RegExp(`^(?:${localNames.join("|")})$`)
277 | : localNames;
278 | return (instance: T) => reg.test(instance.localName) && check(instance);
279 | }
280 |
281 | function pass() {
282 | return true;
283 | }
284 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import { VisualData, Options, SelectorWithStyles } from "./types";
2 | import { stringifyVisualData } from "./stringify";
3 | import { getVisualAttributes } from "./attributes";
4 | import {
5 | getDocumentStyleRules,
6 | getElementStyles,
7 | getPseudoElementStyles,
8 | } from "./stylesheets";
9 |
10 | export { VisualData, Options };
11 |
12 | const ELEMENT_TYPE = 1;
13 | const TEXT_TYPE = 3;
14 |
15 | /**
16 | * Given an element, returns a string of HTML text containing only
17 | * attributes and styles that convey visual information.
18 | */
19 | export default function visualHTML(el: Element, options: Options = {}) {
20 | return stringifyVisualData(
21 | getVisualData(el, {
22 | ...options,
23 | styleRules: getDocumentStyleRules(el.ownerDocument!),
24 | })
25 | );
26 | }
27 |
28 | /**
29 | * Given an element, returns an object with information about the visual aspects
30 | * including styles, psuedo elements, and text content of the element.
31 | */
32 | function getVisualData(
33 | el: T,
34 | options: Options & { styleRules: SelectorWithStyles[] }
35 | ) {
36 | const window = el.ownerDocument!.defaultView!;
37 | let childrenVisualData: Array | null = null;
38 |
39 | if (window.getComputedStyle(el).display === "none") {
40 | return null;
41 | }
42 |
43 | if (!options.shallow && el.firstChild) {
44 | let curNode: ChildNode | null = el.firstChild;
45 | childrenVisualData = [];
46 |
47 | do {
48 | switch (curNode.nodeType) {
49 | case ELEMENT_TYPE:
50 | const childDisplayData = getVisualData(curNode as Element, options);
51 | if (childDisplayData) {
52 | childrenVisualData.push(childDisplayData);
53 | }
54 | break;
55 | case TEXT_TYPE:
56 | childrenVisualData.push(curNode!.nodeValue as string);
57 | break;
58 | }
59 |
60 | curNode = curNode.nextSibling;
61 | } while (curNode);
62 | }
63 |
64 | return {
65 | tagName: el.tagName,
66 | styles: getElementStyles(el, options.styleRules),
67 | pseudoStyles: getPseudoElementStyles(el, options.styleRules),
68 | attributes: getVisualAttributes(el),
69 | children: childrenVisualData,
70 | } as VisualData;
71 | }
72 |
--------------------------------------------------------------------------------
/src/split-selector.ts:
--------------------------------------------------------------------------------
1 | export default function splitSelectors(selectorText: string) {
2 | const selectors: string[] = [];
3 | let current = "";
4 | let depth = 0;
5 | let inAttr = false;
6 | let inQuote = false;
7 | let quoteChar = "";
8 | let escape = false;
9 | let i = 0;
10 |
11 | while (i < selectorText.length) {
12 | const char = selectorText[i];
13 | const nextChar = selectorText[i + 1];
14 |
15 | if (escape) {
16 | current += char;
17 | escape = false;
18 | } else if (char === "\\") {
19 | escape = true;
20 | current += char;
21 | } else if (inQuote) {
22 | current += char;
23 | if (char === quoteChar) {
24 | inQuote = false;
25 | quoteChar = "";
26 | }
27 | } else if (char === '"' || char === "'") {
28 | inQuote = true;
29 | quoteChar = char;
30 | current += char;
31 | } else if (char === "/" && nextChar === "*") {
32 | // Skip over comment block
33 | i += 2;
34 | while (
35 | i < selectorText.length &&
36 | !(selectorText[i] === "*" && selectorText[i + 1] === "/")
37 | ) {
38 | i++;
39 | }
40 | i += 1; // Skip closing /
41 | } else if (char === "[") {
42 | inAttr = true;
43 | current += char;
44 | } else if (char === "]") {
45 | inAttr = false;
46 | current += char;
47 | } else if (char === "(" && !inAttr) {
48 | depth++;
49 | current += char;
50 | } else if (char === ")" && !inAttr) {
51 | depth--;
52 | current += char;
53 | } else if (char === "," && depth === 0 && !inAttr) {
54 | selectors.push(current.trim());
55 | current = "";
56 | } else {
57 | current += char;
58 | }
59 |
60 | i++;
61 | }
62 |
63 | if (current.trim()) {
64 | selectors.push(current.trim());
65 | }
66 |
67 | return selectors;
68 | }
69 |
--------------------------------------------------------------------------------
/src/stringify.ts:
--------------------------------------------------------------------------------
1 | import { VisualData } from "./types";
2 |
3 | /**
4 | * Given the object representation of the visual data for an element
5 | * a string of a pretty printed HTML representation will be returned.
6 | */
7 | export function stringifyVisualData(data: VisualData | string | null) {
8 | if (!data) {
9 | return "";
10 | }
11 |
12 | if (typeof data === "string") {
13 | return data.trim();
14 | }
15 |
16 | const tagName = data.tagName.toLowerCase();
17 | const attrs = printAttributes(data);
18 | const children = printChildren(data);
19 |
20 | return `<${
21 | tagName +
22 | (attrs.length
23 | ? attrs.length === 1
24 | ? ` ${attrs[0]}`
25 | : `\n${indent(attrs.join("\n"))}\n`
26 | : "") +
27 | (children.length
28 | ? `>\n${indent(children.join("\n"))}\n${tagName}>`
29 | : "/>")
30 | }`;
31 | }
32 |
33 | function printAttributes(data: VisualData) {
34 | const { styles, attributes } = data;
35 | const parts: string[] = [];
36 |
37 | if (attributes) {
38 | for (const { name, value } of attributes) {
39 | parts.push(
40 | name +
41 | (value === true || value === "" ? "" : `=${JSON.stringify(value)}`)
42 | );
43 | }
44 | }
45 |
46 | if (styles) {
47 | parts.push(printStyle(data));
48 | }
49 |
50 | return parts.sort();
51 | }
52 |
53 | function printStyle({ styles }: VisualData) {
54 | if (!styles) {
55 | return "";
56 | }
57 |
58 | return `style="${printProperties(styles)}"`;
59 | }
60 |
61 | function printChildren(data: VisualData) {
62 | const { children } = data;
63 | return [printPseudoElements(data)]
64 | .concat((children || []).map(stringifyVisualData))
65 | .filter(Boolean);
66 | }
67 |
68 | function printPseudoElements(data: VisualData) {
69 | const { pseudoStyles } = data;
70 |
71 | if (!pseudoStyles) {
72 | return "";
73 | }
74 |
75 | return ``;
79 | }
80 |
81 | function printProperties(styles: { [x: string]: string }) {
82 | const parts: string[] = [];
83 |
84 | for (const name in styles) {
85 | parts.push(`${name}: ${styles[name]}`);
86 | }
87 |
88 | return parts.length === 1
89 | ? parts[0]
90 | : `\n${indent(parts.sort().join(";\n"))}\n`;
91 | }
92 |
93 | function indent(str: string) {
94 | return str.replace(/^/gm, " ");
95 | }
96 |
--------------------------------------------------------------------------------
/src/stylesheets.ts:
--------------------------------------------------------------------------------
1 | import { compare, calculate } from "specificity";
2 | import splitSelectors from "./split-selector";
3 | import { SelectorWithStyles } from "./types";
4 | import { getDefaultStyles } from "./default-styles";
5 | const pseudoElementRegex =
6 | /::?(before|after|first-letter|first-line|selection|backdrop|placeholder|marker|spelling-error|grammar-error)/gi;
7 |
8 | /**
9 | * Given a document, reads all style sheets returns extracts all CSSRules
10 | * in specificity order.
11 | */
12 | export function getDocumentStyleRules(document: Document) {
13 | return Array.from(document.styleSheets)
14 | .map((sheet) =>
15 | getStyleRulesFromSheet(sheet as CSSStyleSheet, document.defaultView!)
16 | )
17 | .reduce(flatten, [])
18 | .sort((a, b) =>
19 | compare(calculate(b.selectorText), calculate(a.selectorText))
20 | );
21 | }
22 |
23 | /**
24 | * Given an element and global css rules, finds rules that apply to that
25 | * element (including the inline styles) and returns the specified css
26 | * properties as an object.
27 | */
28 | export function getElementStyles(el: Element, rules: SelectorWithStyles[]) {
29 | return getAppliedStylesForElement(
30 | el,
31 | null,
32 | [(el as HTMLElement).style].concat(
33 | rules
34 | .filter((rule) => el.matches(rule.selectorText))
35 | .map(({ style }) => style)
36 | )
37 | );
38 | }
39 |
40 | /**
41 | * Given an element and global css rules, finds rules with pseudo elements
42 | * that apply to the element. Returns map containing the list of pseudo elements
43 | * with their applied css properties.
44 | */
45 | export function getPseudoElementStyles(
46 | el: Element,
47 | rules: SelectorWithStyles[]
48 | ) {
49 | const stylesByPseudoElement = rules.reduce((rulesByPseudoElement, rule) => {
50 | const { selectorText, style } = rule;
51 | let baseSelector = selectorText;
52 | let match: RegExpExecArray | null = null;
53 | let seenPseudos: string[] | null = null;
54 |
55 | while ((match = pseudoElementRegex.exec(selectorText))) {
56 | const name = `::${match[1]}`;
57 |
58 | if (seenPseudos) {
59 | if (!seenPseudos.includes(name)) {
60 | seenPseudos.push(name);
61 | }
62 | } else {
63 | seenPseudos = [name];
64 | }
65 |
66 | baseSelector =
67 | selectorText.slice(0, match.index) +
68 | selectorText.slice(match.index + match[0].length);
69 | }
70 |
71 | if (seenPseudos && el.matches(baseSelector || "*")) {
72 | for (const name of seenPseudos) {
73 | (rulesByPseudoElement[name] || (rulesByPseudoElement[name] = [])).push(
74 | style
75 | );
76 | }
77 | }
78 |
79 | return rulesByPseudoElement;
80 | }, {});
81 |
82 | const foundPseudoElements = Object.keys(stylesByPseudoElement);
83 |
84 | if (!foundPseudoElements.length) {
85 | return null;
86 | }
87 |
88 | return foundPseudoElements.reduce((styleByPseudoElement, name) => {
89 | styleByPseudoElement[name] = getAppliedStylesForElement(
90 | el,
91 | name,
92 | stylesByPseudoElement[name]
93 | )!;
94 | return styleByPseudoElement;
95 | }, {} as { [x: string]: { [x: string]: string } });
96 | }
97 |
98 | /**
99 | * Given a stylesheet returns all css rules including rules from
100 | * nested stylesheets such as media queries or supports.
101 | */
102 | function getStyleRulesFromSheet(
103 | sheet: CSSStyleSheet | CSSMediaRule | CSSSupportsRule,
104 | window: Window
105 | ) {
106 | const styleRules: SelectorWithStyles[] = [];
107 | const curRules = sheet.cssRules;
108 | for (let i = curRules.length; i--; ) {
109 | const rule = curRules[i];
110 |
111 | if (isStyleRule(rule)) {
112 | for (const selector of splitSelectors(rule.selectorText) as string[]) {
113 | styleRules.push({ selectorText: selector, style: rule.style });
114 | }
115 | } else if (isMediaRule(rule) && window.matchMedia) {
116 | if (window.matchMedia(rule.media.mediaText).matches) {
117 | styleRules.push(...getStyleRulesFromSheet(rule, window));
118 | }
119 | } else if (isSupportsRule(rule)) {
120 | if (CSS.supports(rule.conditionText)) {
121 | styleRules.push(...getStyleRulesFromSheet(rule, window));
122 | }
123 | }
124 | }
125 |
126 | return styleRules;
127 | }
128 |
129 | /**
130 | * Given a list of css rules (in specificity order) returns the properties
131 | * applied accounting for !important values.
132 | */
133 | function getAppliedStylesForElement(
134 | el: Element,
135 | pseudo: string | null,
136 | styles: CSSStyleDeclaration[]
137 | ) {
138 | let properties: { [x: string]: string } | null = null;
139 | const defaults = getDefaultStyles(el, pseudo);
140 | const seen: Set = new Set();
141 | const important: Set = new Set();
142 |
143 | for (const style of styles) {
144 | for (let i = 0, len = style.length; i < len; i++) {
145 | const name = style[i];
146 | const value = style.getPropertyValue(name);
147 |
148 | if (value !== "initial" && value !== defaults[name]) {
149 | const isImportant = style.getPropertyPriority(name) === "important";
150 |
151 | if (properties) {
152 | if (!seen.has(name) || (isImportant && !important.has(name))) {
153 | properties[name] = value;
154 | }
155 | } else {
156 | properties = { [name]: value };
157 | }
158 |
159 | if (isImportant) {
160 | important.add(name);
161 | }
162 | }
163 |
164 | seen.add(name);
165 | }
166 | }
167 |
168 | return properties;
169 | }
170 |
171 | function isStyleRule(rule: CSSRule): rule is CSSStyleRule {
172 | return rule.type === 1;
173 | }
174 |
175 | function isMediaRule(rule: CSSRule): rule is CSSMediaRule {
176 | return rule.type === 4;
177 | }
178 |
179 | function isSupportsRule(rule: CSSRule): rule is CSSSupportsRule {
180 | return rule.type === 12;
181 | }
182 |
183 | function flatten(a: T[], b: T[]): T[] {
184 | return a.concat(b);
185 | }
186 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | declare global {
2 | interface CSSRule {
3 | selectorText: string;
4 | }
5 | }
6 |
7 | export interface VisualData {
8 | tagName: string;
9 | attributes: Array<{ name: string; value: string | boolean | null }> | null;
10 | styles: { [x: string]: string } | null;
11 | pseudoStyles: { [x: string]: { [x: string]: string } } | null;
12 | children: Array | null;
13 | }
14 | export interface Options {
15 | shallow?: boolean;
16 | }
17 |
18 | export interface SelectorWithStyles {
19 | selectorText: string;
20 | style: CSSStyleDeclaration;
21 | }
22 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": ["dom", "es2015", "scripthost"],
4 | "pretty": true,
5 | "target": "ES2020",
6 | "outDir": "./dist",
7 | "sourceMap": true,
8 | "declaration": true,
9 | "stripInternal": true,
10 | "importHelpers": true,
11 | "esModuleInterop": true,
12 | "strictNullChecks": true,
13 | "resolveJsonModule": true,
14 | "moduleResolution": "node",
15 | "forceConsistentCasingInFileNames": true
16 | },
17 | "include": ["./src/**/*"],
18 | "exclude": ["**/__tests__"]
19 | }
20 |
--------------------------------------------------------------------------------
/tslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "defaultSeverity": "error",
3 | "extends": ["tslint:recommended", "tslint-config-prettier"],
4 | "rules": {
5 | "forin": false,
6 | "no-empty": false,
7 | "no-console": false,
8 | "no-namespace": false,
9 | "variable-name": false,
10 | "interface-name": false,
11 | "ordered-imports": false,
12 | "object-literal-sort-keys": false,
13 | "no-conditional-assignment": false
14 | }
15 | }
16 |
--------------------------------------------------------------------------------