├── .npmignore
├── jsconfig.json
├── .dependabot
└── config.yml
├── .editorconfig
├── src
├── load-remote-asset.js
├── cli.js
├── utils.js
├── cache.js
├── inline-js.js
├── main.js
└── inline-css.js
├── .github
└── workflows
│ └── nodejs.yml
├── LICENSE
├── fixtures
└── sample.html
├── package.json
├── .gitignore
├── tests
├── inline-js.js
├── main.js
└── inline-css.js
├── types.d.ts
└── README.md
/.npmignore:
--------------------------------------------------------------------------------
1 | tests
2 | fixtures
3 | dist
4 | .editorconfig
5 | .inline-remote-asset-cache
6 | .remote-asset-cache
7 | .github
8 | .dependabot
9 |
--------------------------------------------------------------------------------
/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "checkJs": true,
4 | },
5 | "exclude": [
6 | "node_modules",
7 | "**/node_modules/*"
8 | ]
9 | }
10 |
--------------------------------------------------------------------------------
/.dependabot/config.yml:
--------------------------------------------------------------------------------
1 | version: 1
2 | update_configs:
3 | - package_manager: "javascript"
4 | update_schedule: "daily"
5 | directory: "."
6 | default_assignees:
7 | # update to your username :party:
8 | - "HugoDF"
9 | automerged_updates:
10 | - match:
11 | dependency_type: "all"
12 | update_type: "all"
13 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # http://editorconfig.org
2 | root = true
3 |
4 | [*]
5 | indent_style = space
6 | indent_size = 2
7 | end_of_line = lf
8 | charset = utf-8
9 | trim_trailing_whitespace = true
10 | insert_final_newline = true
11 |
12 | # The JSON files contain newlines inconsistently
13 | [*.json]
14 | insert_final_newline = ignore
15 |
16 | [*.md]
17 | trim_trailing_whitespace = false
18 |
19 |
--------------------------------------------------------------------------------
/src/load-remote-asset.js:
--------------------------------------------------------------------------------
1 | const fetch = require('node-fetch');
2 | const cache = require('./cache');
3 | const {digest} = require('./utils');
4 |
5 | /**
6 | * Load & cache (to fs) remote stylesheets
7 | * @param {string} url - Stylesheet URL to load
8 | * @returns {Promise<{ size: number, value: string }>}
9 | */
10 | async function loadRemoteAsset(url) {
11 | const assetKey = digest(url);
12 |
13 | const cachedVersion = await cache.get(assetKey);
14 | if (cachedVersion) {
15 | return cachedVersion;
16 | }
17 |
18 | // @ts-ignore
19 | const value = await fetch(url).then((response) => response.text());
20 | // Cache loaded resource & compute size
21 | const {size} = await cache.set(assetKey, value);
22 |
23 | return {value, size};
24 | }
25 |
26 | module.exports = loadRemoteAsset;
27 |
--------------------------------------------------------------------------------
/.github/workflows/nodejs.yml:
--------------------------------------------------------------------------------
1 | name: test
2 |
3 | on: [push]
4 |
5 | jobs:
6 | build:
7 |
8 | runs-on: ubuntu-latest
9 |
10 | strategy:
11 | matrix:
12 | node-version: [12.x, 14.x]
13 | env:
14 | CI: true
15 |
16 | steps:
17 | - uses: actions/checkout@v1
18 | - name: Use Node.js ${{ matrix.node-version }}
19 | uses: actions/setup-node@v1
20 | with:
21 | node-version: ${{ matrix.node-version }}
22 | - name: Cache dependencies paths
23 | uses: actions/cache@v2
24 | with:
25 | path: |
26 | ./node_modules
27 | key: ${{ runner.os }}-${{ matrix.node-version }}-${{ hashFiles('./yarn.lock') }}
28 | - name: Install dependencies
29 | run: yarn
30 | - name: Sanity check
31 | run: yarn example
32 | - name: Code Quality
33 | run: yarn lint
34 | - name: Test (uncached)
35 | run: |
36 | rm -rf node_modules/.cache
37 | yarn test
38 | - name: Test (cached)
39 | run: yarn test
40 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 | Copyright (c) 2019-2020 Hugo Di Francesco
3 |
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,
16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
17 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
18 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
19 | DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
20 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
21 | OR OTHER DEALINGS IN THE SOFTWARE.
22 |
23 |
--------------------------------------------------------------------------------
/fixtures/sample.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
10 |
14 |
15 |
16 |
20 |
21 |
22 |
23 |
30 |
31 |
With Tailwind typography plugin from CDN
32 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "inline-remote-assets",
3 | "version": "0.7.0",
4 | "description": "Improve load performance by inlining and optimising remote assets loaded through CDN",
5 | "main": "src/main.js",
6 | "types": "./types.d.ts",
7 | "bin": "./src/cli.js",
8 | "scripts": {
9 | "test": "uvu tests",
10 | "build": "jsdoc -t node_modules/tsd-jsdoc/dist -r src -d .",
11 | "lint": "xo src tests",
12 | "format": "xo src tests --fix",
13 | "fmt": "yarn format",
14 | "example:uncached": "rm -rf .remote-asset-cache && yarn example",
15 | "example": "rm -rf dist && src/cli.js 'fixtures/*.html' -o dist",
16 | "prepack": "yarn build",
17 | "release": "np"
18 | },
19 | "dependencies": {
20 | "find-cache-dir": "^3.3.1",
21 | "meow": "^8.0.0",
22 | "node-fetch": "^2.6.0",
23 | "purgecss": "^3.0.0",
24 | "tiny-glob": "^0.2.6"
25 | },
26 | "devDependencies": {
27 | "jsdoc": "^3.6.4",
28 | "np": "^7.0.0",
29 | "tsd-jsdoc": "^2.5.0",
30 | "uvu": "^0.5.1",
31 | "xo": "^0.37.1"
32 | },
33 | "xo": {
34 | "prettier": true,
35 | "space": true,
36 | "globals": []
37 | },
38 | "publishConfig": {
39 | "registry": "https://registry.npmjs.org"
40 | },
41 | "license": "MIT"
42 | }
43 |
--------------------------------------------------------------------------------
/src/cli.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 | 'use strict';
3 | const meow = require('meow');
4 | const {main} = require('./main');
5 |
6 | const cli = meow(
7 | `
8 | Usage
9 |
10 | $ inline-remote-assets
11 |
12 | Options
13 | --max-size, -m Maximum size of asset to be inlined (in bytes), default 20000 (20kb)
14 | --output, -o Define a different output directory, default is to write files in place
15 | --css-max-size Maximum size of CSS asset to be inlined (in bytes), overwrites maxSize, defaults to maxSize value (20kb)
16 |
17 | Examples
18 | Inline CSS and JS for .html files in dist (write in place):
19 | $ inline-remote-assets 'dist/**/*.html'
20 |
21 | Set JS and CSS max size to inline to 75kb:
22 | $ inline-remote-assets 'dist/**/*.html' --max-size 75000
23 |
24 | Inline HTML files in dist and output to public
25 | $ inline-remote-assets 'dist/**/*.html' --output public
26 |
27 | Set JS max size to inline 75kb and CSS max size to inline to 100kb.
28 | $ inline-remote-assets 'dist/**/*.html' --max-size 75000 --css-max-size 100000
29 | `,
30 | {
31 | flags: {
32 | output: {
33 | type: 'string',
34 | alias: 'o'
35 | },
36 | maxSize: {
37 | type: 'number',
38 | default: 20000, // 20kb
39 | alias: 'm'
40 | },
41 | cssMaxSize: {
42 | type: 'number'
43 | }
44 | }
45 | }
46 | );
47 |
48 | main(cli.input[0], cli.flags);
49 |
--------------------------------------------------------------------------------
/src/utils.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs').promises;
2 | const path = require('path');
3 | const crypto = require('crypto');
4 |
5 | /**
6 | *
7 | * @param {string} tag
8 | * @param {RegExp} resourceLocationRegex
9 | * @returns {string|null}
10 | */
11 |
12 | function matchRemoteResource(tag, resourceLocationRegex) {
13 | const locationMatches = tag.match(resourceLocationRegex);
14 | if (!locationMatches) return null;
15 | const [location] = locationMatches;
16 | return location.startsWith('http') ? location : null;
17 | }
18 |
19 | /**
20 | *
21 | * @param {string} string - string to digest
22 | * @returns {string}
23 | */
24 | function digest(string) {
25 | return crypto.createHash('sha256').update(string).digest('hex');
26 | }
27 |
28 | /**
29 | * @param {string} p - Path to check
30 | */
31 | const isPathWriteable = async (p) =>
32 | fs
33 | .stat(p)
34 | .then(() => true)
35 | .catch(() => false);
36 |
37 | /**
38 | *
39 | * @param {string} filePath
40 | * @return {Promise}
41 | */
42 | async function ensureWriteablePath(filePath) {
43 | if (!(await isPathWriteable(filePath))) {
44 | await fs.mkdir(path.dirname(filePath), {recursive: true});
45 | }
46 | }
47 |
48 | /**
49 | * @param {Array<{ url: string, asset: object }>} urlsWithAssets
50 | * @returns {Record}
51 | */
52 | function urlsToAssets(urlsWithAssets) {
53 | const urlToAssets = {};
54 | urlsWithAssets.forEach(({url, asset}) => {
55 | urlToAssets[url] = asset;
56 | });
57 | return urlToAssets;
58 | }
59 |
60 | module.exports = {
61 | matchRemoteResource,
62 | digest,
63 | ensureWriteablePath,
64 | urlsToAssets
65 | };
66 |
--------------------------------------------------------------------------------
/src/cache.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs').promises;
2 | const pkg = require('../package.json');
3 | const path = require('path');
4 | const findCacheDir = require('find-cache-dir');
5 |
6 | const cachePath = findCacheDir({name: pkg.name, create: true});
7 | const inMemory = new Map();
8 |
9 | /**
10 | * @property {Function} set
11 | * @property {Function} get
12 | */
13 | const cache = {
14 | /**
15 | *
16 | * @param {string} key
17 | * @param {string} value
18 | * @return {Promise<{ size: number, value: string }>}
19 | */
20 | async set(key, value) {
21 | const cacheFilePath = path.join(cachePath, key);
22 | await fs.writeFile(cacheFilePath, value, 'utf8');
23 | const {size} = await fs.stat(cacheFilePath);
24 | inMemory.set(key, {
25 | size,
26 | value
27 | });
28 | return {size, value};
29 | },
30 | /**
31 | *
32 | * @param {string} key
33 | * @returns {Promise<{ size: number, value: string }>}
34 | */
35 | async get(key) {
36 | let cachedVersion = inMemory.get(key);
37 | if (!cachedVersion) {
38 | // Not cached in memory
39 | try {
40 | const cacheFilePath = path.join(cachePath, key);
41 | const {size} = await fs.stat(cacheFilePath);
42 | const value = await fs.readFile(cacheFilePath, 'utf8');
43 | cachedVersion = {
44 | size,
45 | value
46 | };
47 | } catch {
48 | // No cache for resource, init the directory, if it doesn't exist
49 | fs.mkdir(cachePath).catch(() => {});
50 | }
51 | }
52 |
53 | inMemory.set(key, cachedVersion);
54 |
55 | return cachedVersion;
56 | }
57 | };
58 |
59 | module.exports = cache;
60 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | lerna-debug.log*
8 |
9 | # Diagnostic reports (https://nodejs.org/api/report.html)
10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
11 |
12 | # Runtime data
13 | pids
14 | *.pid
15 | *.seed
16 | *.pid.lock
17 |
18 | # Directory for instrumented libs generated by jscoverage/JSCover
19 | lib-cov
20 |
21 | # Coverage directory used by tools like istanbul
22 | coverage
23 | *.lcov
24 |
25 | # nyc test coverage
26 | .nyc_output
27 |
28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
29 | .grunt
30 |
31 | # Bower dependency directory (https://bower.io/)
32 | bower_components
33 |
34 | # node-waf configuration
35 | .lock-wscript
36 |
37 | # Compiled binary addons (https://nodejs.org/api/addons.html)
38 | build/Release
39 |
40 | # Dependency directories
41 | node_modules/
42 | jspm_packages/
43 |
44 | # TypeScript v1 declaration files
45 | typings/
46 |
47 | # TypeScript cache
48 | *.tsbuildinfo
49 |
50 | # Optional npm cache directory
51 | .npm
52 |
53 | # Optional eslint cache
54 | .eslintcache
55 |
56 | # Optional REPL history
57 | .node_repl_history
58 |
59 | # Output of 'npm pack'
60 | *.tgz
61 |
62 | # Yarn Integrity file
63 | .yarn-integrity
64 |
65 | # dotenv environment variables file
66 | .env
67 | .env.test
68 |
69 | # parcel-bundler cache (https://parceljs.org/)
70 | .cache
71 |
72 | # next.js build output
73 | .next
74 |
75 | # nuxt.js build output
76 | .nuxt
77 |
78 | # vuepress build output
79 | .vuepress/dist
80 |
81 | # Serverless directories
82 | .serverless/
83 |
84 | # FuseBox cache
85 | .fusebox/
86 |
87 | # DynamoDB Local files
88 | .dynamodb/
89 | .remote-asset-cache
90 | dist
91 |
--------------------------------------------------------------------------------
/src/inline-js.js:
--------------------------------------------------------------------------------
1 | const loadRemoteAsset = require('./load-remote-asset');
2 | const {matchRemoteResource, urlsToAssets} = require('./utils');
3 |
4 | const scriptRegex = /` tag
57 | return ``;
10 |
11 | test('inlines modules smaller than passed "maxSize" option', async () => {
12 | assert.fixture(
13 | await inlineJs(remoteInclude, {maxSize: 20000}),
14 | ``
17 | );
18 | });
19 |
20 | test('keeps CDN tag for modules larger than "maxSize" option', async () => {
21 | assert.fixture(await inlineJs(remoteInclude, {maxSize: 10}), remoteInclude);
22 | });
23 |
24 | const relativeScriptInclude = ``;
25 |
26 | test('keeps relative includes', async () => {
27 | assert.is(
28 | await inlineJs(relativeScriptInclude, defaultOptions),
29 | relativeScriptInclude
30 | );
31 | });
32 |
33 | const sampleInlineScript = ``;
34 |
35 | test('keeps inline scripts', async () => {
36 | assert.is(
37 | await inlineJs(sampleInlineScript, defaultOptions),
38 | sampleInlineScript
39 | );
40 | });
41 |
42 | test.run();
43 |
--------------------------------------------------------------------------------
/src/main.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Inline Remote Assets
3 | * @module inline-remote-assets/main
4 | */
5 | 'use strict';
6 | const inlineCss = require('./inline-css');
7 | const inlineJs = require('./inline-js');
8 | const {ensureWriteablePath} = require('./utils');
9 | const glob = require('tiny-glob');
10 | const fs = require('fs').promises;
11 | const path = require('path');
12 |
13 | module.exports = {
14 | inlineCss,
15 | inlineJs,
16 | /**
17 | *
18 | * @param {string} globPattern
19 | * @param {object} options
20 | * @param {number} options.maxSize - Maximum size of asset to be inlined (in bytes)
21 | * @param {string} [options.output]
22 | * @param {number} [options.cssMaxSize]
23 | * @returns {Promise}
24 | */
25 | async main(globPattern, options) {
26 | if (globPattern.startsWith('.')) {
27 | console.warn(
28 | `[inline-remote-assets]: Pattern "${globPattern}", starts with "." which might not work as expected, try without "."`
29 | );
30 | }
31 |
32 | const paths = await glob(globPattern);
33 | await Promise.all(
34 | paths.map(async (inputPath) => {
35 | try {
36 | let outputPath = inputPath;
37 | let timerName = inputPath;
38 | if (options.output) {
39 | outputPath = path.join(options.output, path.basename(inputPath));
40 | timerName = `${inputPath} -> ${outputPath}`;
41 | }
42 |
43 | console.time(timerName);
44 | // Read file
45 | const initialHtml = await fs.readFile(inputPath, 'utf8');
46 | // Run transforms
47 | const newHtml = await Promise.resolve(initialHtml)
48 | .then((html) => inlineCss(html, options))
49 | .then((html) => inlineJs(html, options));
50 |
51 | // Create directories if necessary, useful when output is defined
52 | await ensureWriteablePath(outputPath);
53 | // Write back to file
54 | await fs.writeFile(outputPath, newHtml, 'utf8');
55 | console.timeEnd(timerName);
56 | } catch (error) {
57 | console.error(`${inputPath}: ${error.stack}`);
58 | }
59 | })
60 | );
61 | }
62 | };
63 |
--------------------------------------------------------------------------------
/types.d.ts:
--------------------------------------------------------------------------------
1 | declare const cache: {
2 | set: (...params: any[]) => any;
3 | get: (...params: any[]) => any;
4 | };
5 |
6 | declare function matchRemoteHref(tag: string): string | null;
7 |
8 | declare function purgeStyles(html: string, styleSheetContents: { url: string; asset: { value: string; size: number; }; }[]): Promise<{ url: string; asset: { value: string; size: number; }; }[]>;
9 |
10 | /**
11 | * Inline & purge CSS rules from CDN/remote includes into HTML
12 | * @param html - HTML document string into which to inline remote asset
13 | */
14 | declare function inlineCss(html: string, options: {
15 | maxSize?: number;
16 | cssMaxSize?: number;
17 | }): Promise;
18 |
19 | declare function matchRemoteSrc(tag: string): string | null;
20 |
21 | /**
22 | * Inline JavaScript loaded from CDN/remote into HTML script directly
23 | * @param html - HTML document string
24 | * @param options.maxSize - Maximum size of asset to be inlined (in bytes)
25 | */
26 | declare function inlineJs(html: string, options: {
27 | maxSize: number;
28 | }): Promise;
29 |
30 | /**
31 | * Load & cache (to fs) remote stylesheets
32 | * @param url - Stylesheet URL to load
33 | */
34 | declare function loadRemoteAsset(url: string): Promise<{ size: number; value: string; }>;
35 |
36 | /**
37 | * Inline Remote Assets
38 | */
39 | declare module "inline-remote-assets/main" {
40 | /**
41 | * @param options.maxSize - Maximum size of asset to be inlined (in bytes)
42 | */
43 | function main(globPattern: string, options: {
44 | maxSize: number;
45 | output?: string;
46 | cssMaxSize?: number;
47 | }): Promise;
48 | }
49 |
50 | declare function matchRemoteResource(tag: string, resourceLocationRegex: RegExp): string | null;
51 |
52 | /**
53 | * @param string - string to digest
54 | */
55 | declare function digest(string: string): string;
56 |
57 | /**
58 | * @param p - Path to check
59 | */
60 | declare function isPathWriteable(p: string): void;
61 |
62 | declare function ensureWriteablePath(filePath: string): Promise;
63 |
64 | declare function urlsToAssets(urlsWithAssets: { url: string; asset: object; }[]): Record;
65 |
66 |
--------------------------------------------------------------------------------
/tests/main.js:
--------------------------------------------------------------------------------
1 | const {test} = require('uvu');
2 | const assert = require('uvu/assert');
3 | const fs = require('fs').promises;
4 | const path = require('path');
5 | const {ensureWriteablePath} = require('../src/utils');
6 | const {main} = require('../src/main');
7 |
8 | const sampleHtml = ``;
12 |
13 | test.before(async () => {
14 | await ensureWriteablePath('dist/index.html');
15 | });
16 |
17 | test('works with different output directory', async () => {
18 | const dir = await fs.mkdtemp('dist/origin-dir');
19 | const outDir = await fs.mkdtemp('dist/output-dir');
20 | await fs.writeFile(path.join(dir, 'test.html'), sampleHtml, 'utf8');
21 | await main(`${dir}/*.html`, {maxSize: 20000, output: outDir});
22 | const original = await fs.readFile(path.join(dir, 'test.html'), 'utf8');
23 | const output = await fs.readFile(path.join(outDir, 'test.html'), 'utf8');
24 |
25 | assert.is(original, sampleHtml);
26 | assert.is(
27 | output,
28 | ''
29 | );
30 | });
31 |
32 | test('works when writing in-place', async () => {
33 | const dir = await fs.mkdtemp('dist/in-place');
34 |
35 | await fs.writeFile(path.join(dir, 'test.html'), sampleHtml, 'utf8');
36 | await main(`${dir}/*.html`, {maxSize: 20000});
37 | const original = await fs.readFile(path.join(dir, 'test.html'), 'utf8');
38 |
39 | assert.is(
40 | original,
41 | ''
42 | );
43 | });
44 |
45 | test.run();
46 |
--------------------------------------------------------------------------------
/src/inline-css.js:
--------------------------------------------------------------------------------
1 | const PurgeCSS = require('purgecss').default;
2 | const loadRemoteAsset = require('./load-remote-asset');
3 | const {matchRemoteResource, digest, urlsToAssets} = require('./utils');
4 | const cache = require('./cache');
5 |
6 | const styleLinkRegex = /]*rel="stylesheet"[^>]*>/gm;
7 | const extractHrefRegex = /(?<=href=").*(?=")/gm;
8 |
9 | /**
10 | *
11 | * @param {string} tag
12 | * @returns {string|null}
13 | */
14 | function matchRemoteHref(tag) {
15 | return matchRemoteResource(tag, extractHrefRegex);
16 | }
17 |
18 | /**
19 | *
20 | * @param {string} html
21 | * @param {Array<{ url: string, asset: { value: string, size: number } }>} styleSheetContents
22 | * @returns {Promise>}
23 | */
24 | async function purgeStyles(html, styleSheetContents) {
25 | const htmlDigest = digest(html);
26 |
27 | const cachedPurgedAssets = [];
28 | const assetsToPurge = [];
29 |
30 | /**
31 | * @param {string} url - URL of the stylesheet being digested
32 | * @returns {string}
33 | */
34 | const getStyleDigest = (url) => `${htmlDigest}${digest(url)}`;
35 |
36 | // Get cached assets
37 | for await (const {url, asset} of styleSheetContents) {
38 | const cached = await cache.get(getStyleDigest(url));
39 | if (cached) {
40 | cachedPurgedAssets.push({
41 | url,
42 | asset: cached
43 | });
44 | } else {
45 | assetsToPurge.push({url, asset});
46 | }
47 | }
48 |
49 | // Purge remaining assets
50 | const purgedStyles = await new PurgeCSS().purge({
51 | defaultExtractor: (content) => content.match(/[\w-/:]+(? ({
59 | raw: asset.value,
60 | extension: 'css'
61 | }))
62 | });
63 |
64 | // Shape the purgedStyles
65 | let purgedAssets = purgedStyles.map(({css}, i) => {
66 | return {
67 | url: assetsToPurge[i].url,
68 | asset: {
69 | value: css,
70 | size: 0
71 | }
72 | };
73 | });
74 |
75 | // Go through again to cache our output & populate `size`.
76 | purgedAssets = await Promise.all(
77 | purgedAssets.map(async ({url, asset}) => {
78 | const {size} = await cache.set(getStyleDigest(url), asset.value);
79 | return {
80 | url,
81 | asset: {
82 | ...asset,
83 | size
84 | }
85 | };
86 | })
87 | );
88 |
89 | return [...cachedPurgedAssets, ...purgedAssets];
90 | }
91 |
92 | /**
93 | * Inline & purge CSS rules from CDN/remote includes into HTML
94 | * @param {string} html - HTML document string into which to inline remote asset
95 | * @param {object} options
96 | * @param {number} [options.maxSize]
97 | * @param {number} [options.cssMaxSize]
98 | * @returns {Promise}
99 | */
100 |
101 | async function inlineCss(html, options) {
102 | const maxSize = options.cssMaxSize || options.maxSize || 20000;
103 | const linkTags = html.match(styleLinkRegex);
104 |
105 | if (!linkTags || linkTags.length === 0) {
106 | console.warn('HTML did not contain any link tags');
107 | return html;
108 | }
109 |
110 | const styleSheetUrls = linkTags
111 | .map((tag) => matchRemoteHref(tag))
112 | .filter(Boolean);
113 |
114 | const styleSheetContents = await Promise.all(
115 | styleSheetUrls.map(async (url) => {
116 | return {
117 | url,
118 | asset: await loadRemoteAsset(url)
119 | };
120 | })
121 | );
122 |
123 | const purgedAssets = await purgeStyles(html, styleSheetContents);
124 |
125 | const urlToAsset = urlsToAssets(purgedAssets);
126 |
127 | return html.replace(styleLinkRegex, (linkTag) => {
128 | const href = matchRemoteHref(linkTag);
129 | if (!href) {
130 | return linkTag;
131 | }
132 |
133 | const asset = urlToAsset[href];
134 | if (asset.size > maxSize) {
135 | return linkTag;
136 | }
137 |
138 | return ``;
139 | });
140 | }
141 |
142 | module.exports = inlineCss;
143 |
--------------------------------------------------------------------------------
/tests/inline-css.js:
--------------------------------------------------------------------------------
1 | const {test} = require('uvu');
2 | const assert = require('uvu/assert');
3 | const {inlineCss} = require('../src/main');
4 |
5 | const defaultOptions = {};
6 |
7 | const sampleCssInclude = ``;
11 |
12 | const sampleInline =
13 | '';
14 |
15 | test('replaces with ', async () => {
16 | const output = await inlineCss(sampleCssInclude, defaultOptions);
17 | assert.is(
18 | output.includes(
19 | 'href="https://cdn.jsdelivr.net/npm/tailwindcss@1.7.x/dist/tailwind.min.css"'
20 | ),
21 | false
22 | );
23 | assert.is(output, sampleInline);
24 | });
25 |
26 | const sampleTailwindUsage = `${sampleCssInclude}
27 |
28 |
35 | `;
36 |
37 | test('keeps used Tailwind classes', async () => {
38 | const output = await inlineCss(sampleTailwindUsage, defaultOptions);
39 | const outputHead = output.split('/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */body{margin:0}a{background-color:transparent}*,::after,::before{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e2e8f0}a{color:inherit;text-decoration:inherit}.border-gray-600{--border-opacity:1;border-color:#718096;border-color:rgba(113,128,150,var(--border-opacity))}.border-solid{border-style:solid}.border{border-width:1px}.border-b{border-bottom-width:1px}.flex{display:flex}.flex-col{flex-direction:column}.font-semibold{font-weight:600}.text-xl{font-size:1.25rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.px-10{padding-left:2.5rem;padding-right:2.5rem}.text-gray-900{--text-opacity:1;color:#1a202c;color:rgba(26,32,44,var(--text-opacity))}.w-full{width:100%}@keyframes spin{to{transform:rotate(360deg)}}@keyframes ping{100%,75%{transform:scale(2);opacity:0}}@keyframes pulse{50%{opacity:.5}}@keyframes bounce{0%,100%{transform:translateY(-25%);animation-timing-function:cubic-bezier(.8,0,1,1)}50%{transform:none;animation-timing-function:cubic-bezier(0,0,.2,1)}}'
43 | );
44 | });
45 |
46 | const relativeStyleSheet = ``;
47 |
48 | test('keeps relative stylesheet includes', async () => {
49 | assert.is(
50 | await inlineCss(relativeStyleSheet, defaultOptions),
51 | relativeStyleSheet
52 | );
53 | });
54 |
55 | const styleSheetNoHref = ``;
56 |
57 | test('keeps stylesheets without href', async () => {
58 | assert.is(
59 | await inlineCss(styleSheetNoHref, defaultOptions),
60 | styleSheetNoHref
61 | );
62 | });
63 |
64 | test('inlines stylesheets smaller than passed "maxSize" option', async () => {
65 | assert.is(await inlineCss(sampleCssInclude, {maxSize: 20000}), sampleInline);
66 | });
67 |
68 | test('keeps CDN tag for stylesheets larger than "maxSize" option', async () => {
69 | assert.is(await inlineCss(sampleCssInclude, {maxSize: 5}), sampleCssInclude);
70 | });
71 |
72 | test('inlines stylesheets when size > cssMaxSize but size < maxSize', async () => {
73 | assert.is(
74 | await inlineCss(sampleCssInclude, {cssMaxSize: 20000, maxSize: 5}),
75 | sampleInline
76 | );
77 | });
78 |
79 | test('keeps CDN tag for stylesheets when size < cssMaxSize even if size > maxSize', async () => {
80 | assert.is(
81 | await inlineCss(sampleCssInclude, {cssMaxSize: 5, maxSize: 20000}),
82 | sampleCssInclude
83 | );
84 | });
85 |
86 | test.run();
87 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 | 
3 |
4 | # Inline remote assets
5 |
6 | Improve load performance by inlining and optimising remote assets loaded through CDN.
7 |
8 | 0-config CSS and JavaScript inlining and purging, designed for users of Alpine.js and TailwindCSS.
9 |
10 | ## Quickstart
11 |
12 | If you have a directory of HTML files at `dist` you can purge/inline the CSS and inline JavaScript CDN includes less than 20kb with:
13 |
14 | ```sh
15 | npx inline-remote-assets 'dist/**/*.html'
16 | ```
17 |
18 | Example input HTML file with Tailwind, Tailwind Typography, Alpine.js, styleNames and Spruce:
19 |
20 | ```html
21 |
22 |
23 |
24 |
25 |
26 |
30 |
34 |
35 |
36 |
40 |
41 |
42 |
43 |
50 |
51 |
With Tailwind typography plugin from CDN
52 |
53 |
54 |
55 | ```
56 |
57 | Example output HTML file (after `npx inline-remote-assets dist/*.html`). We've saved loading 3 assets (TailwindCSS, Spruce and stylenames) but Alpine.js, being over 20kb still gets loaded from CDN.
58 |
59 | ```html
60 |
61 |
62 |
63 |
64 |
65 |
66 |
69 |
72 |
76 |
77 |
78 |
79 |
86 |
87 |
88 | ```
89 |
90 |
91 | ## Installation
92 |
93 | You can run the script using `npx`:
94 |
95 | ```sh
96 | npx inline-remote-assets 'dist/**/*.html'
97 | ```
98 |
99 | You can also install it globally:
100 |
101 | ```sh
102 | npm install -g inline-remote-assets
103 | # or using Yarn
104 | yarn global add inline-remote-assets
105 | ```
106 |
107 | It's now runnable as `inline-remote-assets`.
108 |
109 | ## Options
110 |
111 | ### --output, -o
112 |
113 | `--output` or `-o` can be used to define a different output directory, default is to write files in place (overwrite).
114 |
115 | Example to output the matched `dist` files to `public`:
116 |
117 | ```sh
118 | inline-remote-assets 'dist/**/*.html' --output public
119 |
120 | dist/sample.html -> public/sample.html: 1141.294ms
121 | ```
122 |
123 | ### --max-size, -m
124 |
125 | `--max-size` or `-m` can be used to define the maximum size of JavaScript/CSS assets to be inlined (in bytes), default 20000 (20kb).
126 |
127 | Example to set the max-size of JS files to inline to 75kb:
128 |
129 | ```sh
130 | inline-remote-assets 'dist/**/*.html' --max-size 75000
131 |
132 | dist/sample.html: 953.339ms
133 | ```
134 |
135 | ### --css-max-size
136 |
137 | `--css-max-size` can be used to define the maximum size of CSS assets to be inlined (in bytes), it **supercedes** `maxSize` when set, defaults to the value of maxSize or 20000 (20kb).
138 |
139 | ```sh
140 | $ inline-remote-assets 'dist/**/*.html' --max-size 75000 --css-max-size 100000
141 |
142 | dist/sample.html: 17.221ms
143 | ```
144 |
145 | # Contributing
146 |
147 | ## Requirements
148 |
149 | - Node 12
150 | - Yarn 1.x or npm
151 |
152 | ## Setup
153 |
154 | 1. Clone the repository
155 | 2. Run `yarn` or `npm install` installs all required dependencies.
156 |
157 | ## npm scripts
158 |
159 | > Equivalent `npm run