Skip to content

Commit aa2d1f6

Browse files
committed
(feat): Create avif-in-css plugin
1 parent 85abd8e commit aa2d1f6

13 files changed

+417
-2
lines changed

.editorconfig

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
root = true
2+
3+
[*]
4+
indent_style = space
5+
indent_size = 2
6+
end_of_line = lf
7+
charset = utf-8
8+
trim_trailing_whitespace = true
9+
insert_final_newline = true

.eslintignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
polyfill.js

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
.vscode
2+
package-lock.json
3+
node_modules
4+
coverage

.npmignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
assets
2+
coverage
3+
test
4+
package-lock.json
5+
.editorconfig

README.md

Lines changed: 132 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,132 @@
1-
# avif-in-css
2-
PostCSS plugin to use AVIF in CSS background
1+
# AVIF in CSS
2+
3+
<img src="./assets/av1.svg" align="right"
4+
alt="AVIF logo" width="180" height="100">
5+
6+
[PostCSS] plugin and tiny JS script *(315B gzipped)* to use [AVIF] image format in CSS background.
7+
8+
With this PostCSS Plugin you can use **AVIF** image format in your CSS background in [Supported Browsers](#supported-browsers), and fallback with the original image.
9+
10+
> AVIF offers significant compression gains vs. JPEG and WebP, with a recent Netflix study showing 50% savings vs. standard JPEG and > 60% savings on 4:4:4 content
11+
12+
## How works?
13+
14+
You add `require('avif-in-css')` to your JS bundle and write CSS like:
15+
16+
```css
17+
.logo {
18+
width: 80px;
19+
height: 80px;
20+
background-image: url(logo.jpg);
21+
}
22+
```
23+
24+
The script will set `avif` or `no-avif` class on `<body>` and PostCSS plugin will generate:
25+
26+
```css
27+
.logo {
28+
width: 80px;
29+
height: 80px;
30+
}
31+
body.avif .logo {
32+
background-image: url(logo.avif);
33+
}
34+
body.no-avif .logo {
35+
background-image: url(logo.jpg);
36+
}
37+
```
38+
39+
## Usage
40+
### 1. Convert to AVIF
41+
42+
Convert you images to AVIF format, you can use [Squoosh], [Avif.app], [Convertio.co], [avif.io] or any other tool. **Important**: this don't convert the images to AVIF format.
43+
44+
### 2. Install `avif-in-css`
45+
46+
```sh
47+
npm install --save-dev webp-in-css
48+
```
49+
#### 2.1 Load the polyfill
50+
51+
Add the JS script to your client-side JS bundle:
52+
53+
```diff js
54+
+ require('avif-in-css')
55+
```
56+
57+
Since JS script is very small (315B gzipped), the best way for landings
58+
is to inline it to HTML:
59+
60+
```diff html
61+
+ <script><%= readFile('node_modules/AVIF-in-css') %></script>
62+
</head>
63+
```
64+
65+
You can load the script via CDN:
66+
67+
```diff html
68+
+ <script src=""></script>
69+
</head>
70+
```
71+
72+
#### 2.2 Load the PostCSS plugin
73+
74+
Check do you use PostCSS already in your bundler. You can check `postcss.config.js` in the project root, `"postcss"` section in `package.json` or `postcss` in bundle config.
75+
76+
If you don’t have it already, add PostCSS to your bundle:
77+
78+
* For webpack see [postcss-loader] docs.
79+
* For Parcel create `postcss.config.js` file.
80+
It already has PostCSS support.
81+
#### Add `avif-in-css` to PostCSS plugins
82+
83+
```diff js
84+
module.exports = {
85+
plugins: [
86+
+ require('avif-in-css'),
87+
require('autoprefixer')
88+
]
89+
}
90+
```
91+
If you use CSS Modules in webpack add `modules: true` option:
92+
93+
```diff js
94+
module.exports = {
95+
plugins: [
96+
- require(avif-in-css'),
97+
+ require(avif-in-css')({ modules: true }),
98+
require('autoprefixer')
99+
]
100+
}
101+
```
102+
103+
## PostCSS Options
104+
105+
```js
106+
module.exports = {
107+
plugins: [
108+
require('avif-in-css')({ /* options */ }),
109+
]
110+
}
111+
```
112+
113+
* `modules` boolean: wrap classes to `:global()` to support CSS Modules.
114+
`false` by default.
115+
* `avifClass` string: class name for browser with AVIF support.
116+
* `noAvifClass` string: class name for browser without AVIF support.
117+
* `rename` function: get a new file name from old name, like `(oldName: string) => string`, then `url(./image.png)``url(./image.png.avif)`.
118+
119+
## Supported browsers
120+
121+
* Chrome Desktop 85+
122+
* Firefox 63+ (with `media.av1.enabled` activated)
123+
* Firefox for Android 64+ (with `media.av1.enabled` and `media.av1.use-dav1d` activated)
124+
* Edge 18+ (with `AV1 Video Extension` installed)
125+
126+
[PostCSS]: https://github.com/postcss/postcss
127+
[AVIF]: https://aomediacodec.github.io/av1-avif/
128+
[Squoosh]: https://squoosh.app/
129+
[Avif.app]: https://avif.app
130+
[Convertio.co]: https://convertio.co/avif-converter/
131+
[avif.io]: https://avif.io/
132+
[postcss-loader]: https://github.com/postcss/postcss-loader#usage

assets/AV1.jpg

2.89 KB
Loading

assets/AV1.svg

Lines changed: 14 additions & 0 deletions
Loading

index.js

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
const DEFAULT_OTIONS = {
2+
modules: false,
3+
noAvifClass: 'no-avif',
4+
avifClass: 'avif',
5+
rename: oldName => oldName.replace(/\.(jpe?g|png)/gi, '.avif')
6+
}
7+
8+
module.exports = (opts = {}) => {
9+
const {modules, noAvifClass, avifClass, rename} = {...DEFAULT_OTIONS, ...opts}
10+
11+
function addClass(selector, className) {
12+
className = modules ? `:global(.${className})` : `.${className}`
13+
14+
return selector.includes('html')
15+
? selector.replace(/html[^ ]*/, `$& body${className}`)
16+
: `body${className} ` + selector
17+
}
18+
19+
return {
20+
postcssPlugin: 'avif-in-css',
21+
Declaration(decl) {
22+
if (/\.(jpe?g|png)(?!(\.avif|.*[&?]format=avif))/i.test(decl.value)) {
23+
const rule = decl.parent
24+
if (rule.selector.includes(`.${noAvifClass}`)) return
25+
const avif = rule.cloneAfter()
26+
avif.each(i => {
27+
if (i.prop !== decl.prop && i.value !== decl.value) i.remove()
28+
})
29+
avif.selectors = avif.selectors.map(i => addClass(i, avifClass))
30+
avif.each(i => {
31+
if (
32+
rename &&
33+
Object.prototype.toString.call(rename) === '[object Function]'
34+
) {
35+
i.value = rename(i.value)
36+
}
37+
})
38+
const noAvif = rule.cloneAfter()
39+
noAvif.each(i => {
40+
if (i.prop !== decl.prop && i.value !== decl.value) i.remove()
41+
})
42+
noAvif.selectors = noAvif.selectors.map(i => addClass(i, noAvifClass))
43+
decl.remove()
44+
if (rule.nodes.length === 0) rule.remove()
45+
}
46+
}
47+
}
48+
}
49+
50+
module.exports.postcss = true

package.json

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
{
2+
"name": "avif-in-css",
3+
"version": "0.1.0",
4+
"description": "PostCSS plugin to use AVIF image format in CSS background",
5+
"main": "index.js",
6+
"scripts": {
7+
"lint": "sui-lint js",
8+
"test": "npm run lint && npm run test:coverage",
9+
"test:coverage": "jest-ci --coverage"
10+
},
11+
"repository": {
12+
"type": "git",
13+
"url": "git+https://github.com/nucliweb/avif-in-css.git"
14+
},
15+
"keywords": [
16+
"avif",
17+
"background",
18+
"css",
19+
"image",
20+
"polyfill",
21+
"postcss",
22+
"postcss-plugin"
23+
],
24+
"author": {
25+
"name": "Joan León",
26+
"email": "[email protected]",
27+
"twitter": "@nucliweb",
28+
"web": "https://joanleon.dev"
29+
},
30+
"license": "MIT",
31+
"bugs": {
32+
"url": "https://github.com/nucliweb/avif-in-css/issues"
33+
},
34+
"homepage": "https://github.com/nucliweb/avif-in-css#readme",
35+
"eslintConfig": {
36+
"extends": [
37+
"./node_modules/@s-ui/lint/eslintrc.js"
38+
]
39+
},
40+
"eslintIgnore": [
41+
"polyfill.js"
42+
],
43+
"prettier": "./node_modules/@s-ui/lint/.prettierrc.js",
44+
"stylelint": {
45+
"extends": "./node_modules/@s-ui/lint/stylelint.config.js"
46+
},
47+
"devDependencies": {
48+
"@s-ui/lint": "3",
49+
"jest": "^26.6.3",
50+
"jest-ci": "^0.1.1",
51+
"jest-cli": "^26.6.3",
52+
"nanodelay": "^1.0.6",
53+
"postcss": "^8.1.14"
54+
},
55+
"jest": {
56+
"coverageThreshold": {
57+
"global": {
58+
"statements": 100
59+
}
60+
}
61+
}
62+
}

polyfill.js

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

test/index.test.js

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/**
2+
* @jest-environment jsdom
3+
*/
4+
const postcss = require('postcss')
5+
6+
const plugin = require('..')
7+
8+
function run(input, output, options) {
9+
expect(postcss([plugin(options)]).process(input).css).toBe(output)
10+
}
11+
12+
it('adds classes and AVIF link', () => {
13+
run(
14+
'@media screen { a, b { color: black; background: url(./image.jpg) } }',
15+
'@media screen { ' +
16+
'a, b { color: black } ' +
17+
'body.no-avif a, body.no-avif b { background: url(./image.jpg) } ' +
18+
'body.avif a, body.avif b { background: url(./image.avif) } ' +
19+
'}'
20+
)
21+
})
22+
23+
it('should work with jpeg, png', () => {
24+
run(
25+
'@media screen { a, b { color: black; background: url(./image.jpeg) } }',
26+
'@media screen { ' +
27+
'a, b { color: black } ' +
28+
'body.no-avif a, body.no-avif b { background: url(./image.jpeg) } ' +
29+
'body.avif a, body.avif b { background: url(./image.avif) } ' +
30+
'}'
31+
)
32+
})
33+
34+
it('should skip urls with [&?]format=avif', () => {
35+
run(
36+
'@media screen { a, b { color: black; background: url(./image.jpeg?format=avif) } }',
37+
'@media screen { a, b { color: black; background: url(./image.jpeg?format=avif) } }'
38+
)
39+
})
40+
41+
it('removes empty rule', () => {
42+
run(
43+
'a,b { background: url(./image.PNG) }',
44+
'body.no-avif a,body.no-avif b { background: url(./image.PNG) }' +
45+
'body.avif a,body.avif b { background: url(./image.avif) }'
46+
)
47+
})
48+
49+
it('does not dublicate html tag', () => {
50+
run(
51+
'html[lang=en] .icon { background: url(./image.jpg) }',
52+
'html[lang=en] body.no-avif .icon { background: url(./image.jpg) }' +
53+
'html[lang=en] body.avif .icon { background: url(./image.avif) }'
54+
)
55+
})
56+
57+
describe('options', () => {
58+
it('should add :global() scope when css modules enabled', () => {
59+
run(
60+
'a { background: url(./image.png) }',
61+
'body:global(.no-avif) a { background: url(./image.png) }' +
62+
'body:global(.avif) a { background: url(./image.avif) }',
63+
{modules: true}
64+
)
65+
})
66+
67+
it('should use passed classNames', () => {
68+
run(
69+
'.c { background: url(./image.png) }',
70+
'body.without-avif .c { background: url(./image.png) }' +
71+
'body.has-avif .c { background: url(./image.avif) }',
72+
{noAvifClass: 'without-avif', avifClass: 'has-avif'}
73+
)
74+
})
75+
76+
it('set rename function', () => {
77+
run(
78+
'.c { background: url(./image.png) }',
79+
'body.no-avif .c { background: url(./image.png) }' +
80+
'body.avif .c { background: url(./image.png.avif) }',
81+
{
82+
rename: oldName => {
83+
return oldName.replace(/\.(jpg|png)/gi, '.$1.avif')
84+
}
85+
}
86+
)
87+
})
88+
})

0 commit comments

Comments
 (0)