diff --git a/.eslintignore b/.eslintignore
index 98a26ca19..6289b3250 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -856,6 +856,9 @@ packages/generator-joplin/generators/app/templates/api_index.js.map
packages/generator-joplin/generators/app/templates/src/index.d.ts
packages/generator-joplin/generators/app/templates/src/index.js
packages/generator-joplin/generators/app/templates/src/index.js.map
+packages/htmlpack/src/index.d.ts
+packages/htmlpack/src/index.js
+packages/htmlpack/src/index.js.map
packages/lib/AsyncActionQueue.d.ts
packages/lib/AsyncActionQueue.js
packages/lib/AsyncActionQueue.js.map
diff --git a/.gitignore b/.gitignore
index c5ea28c9b..e84d7d321 100644
--- a/.gitignore
+++ b/.gitignore
@@ -841,6 +841,9 @@ packages/generator-joplin/generators/app/templates/api_index.js.map
packages/generator-joplin/generators/app/templates/src/index.d.ts
packages/generator-joplin/generators/app/templates/src/index.js
packages/generator-joplin/generators/app/templates/src/index.js.map
+packages/htmlpack/src/index.d.ts
+packages/htmlpack/src/index.js
+packages/htmlpack/src/index.js.map
packages/lib/AsyncActionQueue.d.ts
packages/lib/AsyncActionQueue.js
packages/lib/AsyncActionQueue.js.map
diff --git a/packages/htmlpack/.gitignore b/packages/htmlpack/.gitignore
new file mode 100644
index 000000000..4be6e160a
--- /dev/null
+++ b/packages/htmlpack/.gitignore
@@ -0,0 +1 @@
+dist/*
\ No newline at end of file
diff --git a/packages/htmlpack/README.md b/packages/htmlpack/README.md
new file mode 100644
index 000000000..9cee19788
--- /dev/null
+++ b/packages/htmlpack/README.md
@@ -0,0 +1,19 @@
+# HTMLPACK
+
+Pack an HTML and all its JavaScript, CSS, image, fonts, and external files into a single HTML file. JavaScript and CSS is embedded in STYLE and SCRIPT tags, while all other files and images are converted to dataUri format and embedded in the document.
+
+## Usage
+
+```javascript
+import htmlpack from '@joplin/htmlpack';
+htmlpack('/path/to/input.html', '/path/to/output.html');
+```
+
+## Notes
+
+- The script works in synchronous way so it will block the calling process while running.
+- No security check on what's included.
+
+## License
+
+MIT
diff --git a/packages/htmlpack/package-lock.json b/packages/htmlpack/package-lock.json
new file mode 100644
index 000000000..bf923d0e6
--- /dev/null
+++ b/packages/htmlpack/package-lock.json
@@ -0,0 +1,489 @@
+{
+ "name": "@joplin/htmlpack",
+ "version": "1.0.0",
+ "lockfileVersion": 2,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "@joplin/htmlpack",
+ "version": "1.0.0",
+ "license": "MIT",
+ "dependencies": {
+ "@joplin/fork-htmlparser2": "^4.1.34",
+ "css": "^3.0.0",
+ "datauri": "^4.1.0",
+ "fs-extra": "^10.0.0",
+ "html-entities": "^1.2.1"
+ },
+ "devDependencies": {
+ "@types/fs-extra": "^9.0.6"
+ }
+ },
+ "../fork-htmlparser2": {
+ "name": "@joplin/fork-htmlparser2",
+ "version": "4.1.34",
+ "extraneous": true,
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.0.1",
+ "domhandler": "^3.0.0",
+ "domutils": "^2.0.0",
+ "entities": "^2.0.0",
+ "fs-extra": "^10.0.0"
+ },
+ "devDependencies": {
+ "@types/jest": "^25.1.3",
+ "@types/node": "^13.1.1",
+ "@typescript-eslint/eslint-plugin": "^1.13.0",
+ "@typescript-eslint/parser": "^1.13.0",
+ "coveralls": "^3.0.1",
+ "eslint": "^6.0.0",
+ "eslint-config-prettier": "^6.0.0",
+ "jest": "^26.6.3",
+ "prettier": "^1.18.2",
+ "ts-jest": "^24.0.2",
+ "typescript": "^3.5.3"
+ }
+ },
+ "node_modules/@joplin/fork-htmlparser2": {
+ "version": "4.1.34",
+ "resolved": "https://registry.npmjs.org/@joplin/fork-htmlparser2/-/fork-htmlparser2-4.1.34.tgz",
+ "integrity": "sha512-1/tQZEDnI36RaEJte0eumw1/c8OhmJOpgFyW+Nxsk2u/vvcgnEvjFjauiH2ZxtO5FTJB3BMQ4M23+Y5dw2cnnw==",
+ "dependencies": {
+ "domelementtype": "^2.0.1",
+ "domhandler": "^3.0.0",
+ "domutils": "^2.0.0",
+ "entities": "^2.0.0"
+ }
+ },
+ "node_modules/@types/fs-extra": {
+ "version": "9.0.13",
+ "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz",
+ "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==",
+ "dev": true,
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "16.10.1",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-16.10.1.tgz",
+ "integrity": "sha512-4/Z9DMPKFexZj/Gn3LylFgamNKHm4K3QDi0gz9B26Uk0c8izYf97B5fxfpspMNkWlFupblKM/nV8+NA9Ffvr+w==",
+ "dev": true
+ },
+ "node_modules/atob": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz",
+ "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==",
+ "bin": {
+ "atob": "bin/atob.js"
+ },
+ "engines": {
+ "node": ">= 4.5.0"
+ }
+ },
+ "node_modules/css": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/css/-/css-3.0.0.tgz",
+ "integrity": "sha512-DG9pFfwOrzc+hawpmqX/dHYHJG+Bsdb0klhyi1sDneOgGOXy9wQIC8hzyVp1e4NRYDBdxcylvywPkkXCHAzTyQ==",
+ "dependencies": {
+ "inherits": "^2.0.4",
+ "source-map": "^0.6.1",
+ "source-map-resolve": "^0.6.0"
+ }
+ },
+ "node_modules/datauri": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/datauri/-/datauri-4.1.0.tgz",
+ "integrity": "sha512-y17kh32+I82G+ED9MNWFkZiP/Cq/vO1hN9+tSZsT9C9qn3NrvcBnh7crSepg0AQPge1hXx2Ca44s1FRdv0gFWA==",
+ "dependencies": {
+ "image-size": "1.0.0",
+ "mimer": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/decode-uri-component": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz",
+ "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=",
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/dom-serializer": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.3.2.tgz",
+ "integrity": "sha512-5c54Bk5Dw4qAxNOI1pFEizPSjVsx5+bpJKmL2kPn8JhBUq2q09tTCa3mjijun2NfK78NMouDYNMBkOrPZiS+ig==",
+ "dependencies": {
+ "domelementtype": "^2.0.1",
+ "domhandler": "^4.2.0",
+ "entities": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+ }
+ },
+ "node_modules/dom-serializer/node_modules/domhandler": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.2.2.tgz",
+ "integrity": "sha512-PzE9aBMsdZO8TK4BnuJwH0QT41wgMbRzuZrHUcpYncEjmQazq8QEaBWgLG7ZyC/DAZKEgglpIA6j4Qn/HmxS3w==",
+ "dependencies": {
+ "domelementtype": "^2.2.0"
+ },
+ "engines": {
+ "node": ">= 4"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
+ }
+ },
+ "node_modules/domelementtype": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.2.0.tgz",
+ "integrity": "sha512-DtBMo82pv1dFtUmHyr48beiuq792Sxohr+8Hm9zoxklYPfa6n0Z3Byjj2IV7bmr2IyqClnqEQhfgHJJ5QF0R5A==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ]
+ },
+ "node_modules/domhandler": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-3.3.0.tgz",
+ "integrity": "sha512-J1C5rIANUbuYK+FuFL98650rihynUOEzRLxW+90bKZRWB6A1X1Tf82GxR1qAWLyfNPRvjqfip3Q5tdYlmAa9lA==",
+ "dependencies": {
+ "domelementtype": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 4"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
+ }
+ },
+ "node_modules/domutils": {
+ "version": "2.8.0",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz",
+ "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==",
+ "dependencies": {
+ "dom-serializer": "^1.0.1",
+ "domelementtype": "^2.2.0",
+ "domhandler": "^4.2.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domutils?sponsor=1"
+ }
+ },
+ "node_modules/domutils/node_modules/domhandler": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.2.2.tgz",
+ "integrity": "sha512-PzE9aBMsdZO8TK4BnuJwH0QT41wgMbRzuZrHUcpYncEjmQazq8QEaBWgLG7ZyC/DAZKEgglpIA6j4Qn/HmxS3w==",
+ "dependencies": {
+ "domelementtype": "^2.2.0"
+ },
+ "engines": {
+ "node": ">= 4"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
+ }
+ },
+ "node_modules/entities": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz",
+ "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==",
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/fs-extra": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.0.0.tgz",
+ "integrity": "sha512-C5owb14u9eJwizKGdchcDUQeFtlSHHthBk8pbX9Vc1PFZrLombudjDnNns88aYslCyF6IY5SUw3Roz6xShcEIQ==",
+ "dependencies": {
+ "graceful-fs": "^4.2.0",
+ "jsonfile": "^6.0.1",
+ "universalify": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.8.tgz",
+ "integrity": "sha512-qkIilPUYcNhJpd33n0GBXTB1MMPp14TxEsEs0pTrsSVucApsYzW5V+Q8Qxhik6KU3evy+qkAAowTByymK0avdg=="
+ },
+ "node_modules/html-entities": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-1.4.0.tgz",
+ "integrity": "sha512-8nxjcBcd8wovbeKx7h3wTji4e6+rhaVuPNpMqwWgnHh+N9ToqsCs6XztWRBPQ+UtzsoMAdKZtUENoVzU/EMtZA=="
+ },
+ "node_modules/image-size": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/image-size/-/image-size-1.0.0.tgz",
+ "integrity": "sha512-JLJ6OwBfO1KcA+TvJT+v8gbE6iWbj24LyDNFgFEN0lzegn6cC6a/p3NIDaepMsJjQjlUWqIC7wJv8lBFxPNjcw==",
+ "dependencies": {
+ "queue": "6.0.2"
+ },
+ "bin": {
+ "image-size": "bin/image-size.js"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
+ },
+ "node_modules/jsonfile": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz",
+ "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==",
+ "dependencies": {
+ "universalify": "^2.0.0"
+ },
+ "optionalDependencies": {
+ "graceful-fs": "^4.1.6"
+ }
+ },
+ "node_modules/mimer": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/mimer/-/mimer-2.0.2.tgz",
+ "integrity": "sha512-izxvjsB7Ur5HrTbPu6VKTrzxSMBFBqyZQc6dWlZNQ4/wAvf886fD4lrjtFd8IQ8/WmZKdxKjUtqFFNaj3hQ52g==",
+ "bin": {
+ "mimer": "bin/mimer"
+ },
+ "engines": {
+ "node": ">= 12"
+ }
+ },
+ "node_modules/queue": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
+ "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
+ "dependencies": {
+ "inherits": "~2.0.3"
+ }
+ },
+ "node_modules/source-map": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/source-map-resolve": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.6.0.tgz",
+ "integrity": "sha512-KXBr9d/fO/bWo97NXsPIAW1bFSBOuCnjbNTBMO7N59hsv5i9yzRDfcYwwt0l04+VqnKC+EwzvJZIP/qkuMgR/w==",
+ "dependencies": {
+ "atob": "^2.1.2",
+ "decode-uri-component": "^0.2.0"
+ }
+ },
+ "node_modules/universalify": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz",
+ "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==",
+ "engines": {
+ "node": ">= 10.0.0"
+ }
+ }
+ },
+ "dependencies": {
+ "@joplin/fork-htmlparser2": {
+ "version": "4.1.34",
+ "resolved": "https://registry.npmjs.org/@joplin/fork-htmlparser2/-/fork-htmlparser2-4.1.34.tgz",
+ "integrity": "sha512-1/tQZEDnI36RaEJte0eumw1/c8OhmJOpgFyW+Nxsk2u/vvcgnEvjFjauiH2ZxtO5FTJB3BMQ4M23+Y5dw2cnnw==",
+ "requires": {
+ "domelementtype": "^2.0.1",
+ "domhandler": "^3.0.0",
+ "domutils": "^2.0.0",
+ "entities": "^2.0.0"
+ }
+ },
+ "@types/fs-extra": {
+ "version": "9.0.13",
+ "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz",
+ "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==",
+ "dev": true,
+ "requires": {
+ "@types/node": "*"
+ }
+ },
+ "@types/node": {
+ "version": "16.10.1",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-16.10.1.tgz",
+ "integrity": "sha512-4/Z9DMPKFexZj/Gn3LylFgamNKHm4K3QDi0gz9B26Uk0c8izYf97B5fxfpspMNkWlFupblKM/nV8+NA9Ffvr+w==",
+ "dev": true
+ },
+ "atob": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz",
+ "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg=="
+ },
+ "css": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/css/-/css-3.0.0.tgz",
+ "integrity": "sha512-DG9pFfwOrzc+hawpmqX/dHYHJG+Bsdb0klhyi1sDneOgGOXy9wQIC8hzyVp1e4NRYDBdxcylvywPkkXCHAzTyQ==",
+ "requires": {
+ "inherits": "^2.0.4",
+ "source-map": "^0.6.1",
+ "source-map-resolve": "^0.6.0"
+ }
+ },
+ "datauri": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/datauri/-/datauri-4.1.0.tgz",
+ "integrity": "sha512-y17kh32+I82G+ED9MNWFkZiP/Cq/vO1hN9+tSZsT9C9qn3NrvcBnh7crSepg0AQPge1hXx2Ca44s1FRdv0gFWA==",
+ "requires": {
+ "image-size": "1.0.0",
+ "mimer": "^2.0.2"
+ }
+ },
+ "decode-uri-component": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz",
+ "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU="
+ },
+ "dom-serializer": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.3.2.tgz",
+ "integrity": "sha512-5c54Bk5Dw4qAxNOI1pFEizPSjVsx5+bpJKmL2kPn8JhBUq2q09tTCa3mjijun2NfK78NMouDYNMBkOrPZiS+ig==",
+ "requires": {
+ "domelementtype": "^2.0.1",
+ "domhandler": "^4.2.0",
+ "entities": "^2.0.0"
+ },
+ "dependencies": {
+ "domhandler": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.2.2.tgz",
+ "integrity": "sha512-PzE9aBMsdZO8TK4BnuJwH0QT41wgMbRzuZrHUcpYncEjmQazq8QEaBWgLG7ZyC/DAZKEgglpIA6j4Qn/HmxS3w==",
+ "requires": {
+ "domelementtype": "^2.2.0"
+ }
+ }
+ }
+ },
+ "domelementtype": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.2.0.tgz",
+ "integrity": "sha512-DtBMo82pv1dFtUmHyr48beiuq792Sxohr+8Hm9zoxklYPfa6n0Z3Byjj2IV7bmr2IyqClnqEQhfgHJJ5QF0R5A=="
+ },
+ "domhandler": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-3.3.0.tgz",
+ "integrity": "sha512-J1C5rIANUbuYK+FuFL98650rihynUOEzRLxW+90bKZRWB6A1X1Tf82GxR1qAWLyfNPRvjqfip3Q5tdYlmAa9lA==",
+ "requires": {
+ "domelementtype": "^2.0.1"
+ }
+ },
+ "domutils": {
+ "version": "2.8.0",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz",
+ "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==",
+ "requires": {
+ "dom-serializer": "^1.0.1",
+ "domelementtype": "^2.2.0",
+ "domhandler": "^4.2.0"
+ },
+ "dependencies": {
+ "domhandler": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.2.2.tgz",
+ "integrity": "sha512-PzE9aBMsdZO8TK4BnuJwH0QT41wgMbRzuZrHUcpYncEjmQazq8QEaBWgLG7ZyC/DAZKEgglpIA6j4Qn/HmxS3w==",
+ "requires": {
+ "domelementtype": "^2.2.0"
+ }
+ }
+ }
+ },
+ "entities": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz",
+ "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="
+ },
+ "fs-extra": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.0.0.tgz",
+ "integrity": "sha512-C5owb14u9eJwizKGdchcDUQeFtlSHHthBk8pbX9Vc1PFZrLombudjDnNns88aYslCyF6IY5SUw3Roz6xShcEIQ==",
+ "requires": {
+ "graceful-fs": "^4.2.0",
+ "jsonfile": "^6.0.1",
+ "universalify": "^2.0.0"
+ }
+ },
+ "graceful-fs": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.8.tgz",
+ "integrity": "sha512-qkIilPUYcNhJpd33n0GBXTB1MMPp14TxEsEs0pTrsSVucApsYzW5V+Q8Qxhik6KU3evy+qkAAowTByymK0avdg=="
+ },
+ "html-entities": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-1.4.0.tgz",
+ "integrity": "sha512-8nxjcBcd8wovbeKx7h3wTji4e6+rhaVuPNpMqwWgnHh+N9ToqsCs6XztWRBPQ+UtzsoMAdKZtUENoVzU/EMtZA=="
+ },
+ "image-size": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/image-size/-/image-size-1.0.0.tgz",
+ "integrity": "sha512-JLJ6OwBfO1KcA+TvJT+v8gbE6iWbj24LyDNFgFEN0lzegn6cC6a/p3NIDaepMsJjQjlUWqIC7wJv8lBFxPNjcw==",
+ "requires": {
+ "queue": "6.0.2"
+ }
+ },
+ "inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
+ },
+ "jsonfile": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz",
+ "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==",
+ "requires": {
+ "graceful-fs": "^4.1.6",
+ "universalify": "^2.0.0"
+ }
+ },
+ "mimer": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/mimer/-/mimer-2.0.2.tgz",
+ "integrity": "sha512-izxvjsB7Ur5HrTbPu6VKTrzxSMBFBqyZQc6dWlZNQ4/wAvf886fD4lrjtFd8IQ8/WmZKdxKjUtqFFNaj3hQ52g=="
+ },
+ "queue": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
+ "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
+ "requires": {
+ "inherits": "~2.0.3"
+ }
+ },
+ "source-map": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="
+ },
+ "source-map-resolve": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.6.0.tgz",
+ "integrity": "sha512-KXBr9d/fO/bWo97NXsPIAW1bFSBOuCnjbNTBMO7N59hsv5i9yzRDfcYwwt0l04+VqnKC+EwzvJZIP/qkuMgR/w==",
+ "requires": {
+ "atob": "^2.1.2",
+ "decode-uri-component": "^0.2.0"
+ }
+ },
+ "universalify": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz",
+ "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ=="
+ }
+ }
+}
diff --git a/packages/htmlpack/package.json b/packages/htmlpack/package.json
new file mode 100644
index 000000000..24f813596
--- /dev/null
+++ b/packages/htmlpack/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "@joplin/htmlpack",
+ "version": "1.0.0",
+ "description": "Pack an HTML file and all its linked resources into a single HTML file",
+ "main": "dist/index.js",
+ "scripts": {
+ "tsc": "tsc --project tsconfig.json",
+ "watch": "tsc --watch --project tsconfig.json"
+ },
+ "author": "Laurent Czoic",
+ "license": "MIT",
+ "dependencies": {
+ "@joplin/fork-htmlparser2": "^4.1.34",
+ "css": "^3.0.0",
+ "datauri": "^4.1.0",
+ "fs-extra": "^10.0.0",
+ "html-entities": "^1.2.1"
+ },
+ "devDependencies": {
+ "@types/fs-extra": "^9.0.6"
+ }
+}
diff --git a/packages/htmlpack/src/index.ts b/packages/htmlpack/src/index.ts
new file mode 100644
index 000000000..643e4ac0c
--- /dev/null
+++ b/packages/htmlpack/src/index.ts
@@ -0,0 +1,218 @@
+import * as fs from 'fs-extra';
+const Entities = require('html-entities').AllHtmlEntities;
+const htmlparser2 = require('@joplin/fork-htmlparser2');
+const Datauri = require('datauri/sync');
+const cssParse = require('css/lib/parse');
+const cssStringify = require('css/lib/stringify');
+
+const selfClosingElements = [
+ 'area',
+ 'base',
+ 'basefont',
+ 'br',
+ 'col',
+ 'command',
+ 'embed',
+ 'frame',
+ 'hr',
+ 'img',
+ 'input',
+ 'isindex',
+ 'keygen',
+ 'link',
+ 'meta',
+ 'param',
+ 'source',
+ 'track',
+ 'wbr',
+];
+
+const htmlentities = (s: string): string => {
+ const output = (new Entities()).encode(s);
+ return output.replace(/	/ig, '\t');
+};
+
+const dataUriEncode = (filePath: string): string => {
+ const result = Datauri(filePath);
+ return result.content;
+};
+
+const attributesHtml = (attr: any) => {
+ const output = [];
+
+ for (const n in attr) {
+ if (!attr.hasOwnProperty(n)) continue;
+ output.push(`${n}="${htmlentities(attr[n])}"`);
+ }
+
+ return output.join(' ');
+};
+
+const attrValue = (attrs: any, name: string): string => {
+ if (!attrs[name]) return '';
+ return attrs[name].toLowerCase();
+};
+
+const isSelfClosingTag = (tagName: string) => {
+ return selfClosingElements.includes(tagName.toLowerCase());
+};
+
+const processCssContent = (cssBaseDir: string, content: string): string => {
+ const o = cssParse(content, {
+ silent: false,
+ });
+
+ for (const rule of o.stylesheet.rules) {
+ if (rule.type === 'font-face') {
+ for (const declaration of rule.declarations) {
+ if (declaration.property === 'src') {
+ declaration.value = declaration.value.replace(/url\((.*?)\)/g, (_v: any, url: string) => {
+ const cssFilePath = `${cssBaseDir}/${url}`;
+ if (fs.existsSync(cssFilePath)) {
+ return `url(${dataUriEncode(cssFilePath)})`;
+ } else {
+ return `url(${url})`;
+ }
+ });
+ }
+ }
+ }
+ }
+
+ return cssStringify(o);
+};
+
+const processLinkTag = (baseDir: string, _name: string, attrs: any): string => {
+ const href = attrValue(attrs, 'href');
+ if (!href) return null;
+
+ const filePath = `${baseDir}/${href}`;
+ const content = fs.readFileSync(filePath, 'utf8');
+ return ``;
+};
+
+const processScriptTag = (baseDir: string, _name: string, attrs: any): string => {
+ const src = attrValue(attrs, 'src');
+ if (!src) return null;
+
+ const content = fs.readFileSync(`${baseDir}/${src}`, 'utf8');
+ return ``;
+};
+
+const processImgTag = (baseDir: string, _name: string, attrs: any): string => {
+ const src = attrValue(attrs, 'src');
+ if (!src) return null;
+
+ const filePath = `${baseDir}/${src}`;
+ if (!fs.existsSync(filePath)) return null;
+
+ const modAttrs = { ...attrs };
+ delete modAttrs.src;
+ return ``;
+};
+
+const processAnchorTag = (baseDir: string, _name: string, attrs: any): string => {
+ const href = attrValue(attrs, 'href');
+ if (!href) return null;
+
+ const filePath = `${baseDir}/${href}`;
+ if (!fs.existsSync(filePath)) return null;
+
+ const modAttrs = { ...attrs };
+ modAttrs.href = dataUriEncode(filePath);
+ modAttrs.download = basename(filePath);
+ return ``;
+};
+
+function basename(path: string) {
+ if (!path) throw new Error('Path is empty');
+ const s = path.split(/\/|\\/);
+ return s[s.length - 1];
+}
+
+function dirname(path: string) {
+ if (!path) throw new Error('Path is empty');
+ const s = path.split(/\/|\\/);
+ s.pop();
+ return s.join('/');
+}
+
+export default async function htmlpack(inputFile: string, outputFile: string) {
+ const inputHtml = await fs.readFile(inputFile, 'utf8');
+ const baseDir = dirname(inputFile);
+
+ const output: string[] = [];
+
+ interface Tag {
+ name: string;
+ }
+
+ const tagStack: Tag[] = [];
+
+ const currentTag = () => {
+ if (!tagStack.length) return { name: '', processed: false };
+ return tagStack[tagStack.length - 1];
+ };
+
+ const parser = new htmlparser2.Parser({
+
+ onopentag: (name: string, attrs: any) => {
+ name = name.toLowerCase();
+
+ let processedResult = '';
+
+ if (name === 'link') {
+ processedResult = processLinkTag(baseDir, name, attrs);
+ }
+
+ if (name === 'script') {
+ processedResult = processScriptTag(baseDir, name, attrs);
+ }
+
+ if (name === 'img') {
+ processedResult = processImgTag(baseDir, name, attrs);
+ }
+
+ if (name === 'a') {
+ processedResult = processAnchorTag(baseDir, name, attrs);
+ }
+
+ tagStack.push({ name });
+
+ if (processedResult) {
+ output.push(processedResult);
+ } else {
+ let attrHtml = attributesHtml(attrs);
+ if (attrHtml) attrHtml = ` ${attrHtml}`;
+ const closingSign = isSelfClosingTag(name) ? '/>' : '>';
+ output.push(`<${name}${attrHtml}${closingSign}`);
+ }
+ },
+
+ ontext: (decodedText: string) => {
+ if (currentTag().name === 'style') {
+ // For CSS, we have to put the style as-is inside the tag because if we html-entities encode
+ // it, it's not going to work. But it's ok because JavaScript won't run within the style tag.
+ // Ideally CSS should be loaded from an external file.
+ output.push(decodedText);
+ } else {
+ output.push(htmlentities(decodedText));
+ }
+ },
+
+ onclosetag: (name: string) => {
+ const current = currentTag();
+
+ if (current.name === name.toLowerCase()) tagStack.pop();
+
+ if (isSelfClosingTag(name)) return;
+ output.push(`${name}>`);
+ },
+
+ }, { decodeEntities: true });
+
+ parser.write(inputHtml);
+ parser.end();
+
+ await fs.writeFile(outputFile, output.join(''), 'utf8');
+}
diff --git a/packages/htmlpack/tsconfig.json b/packages/htmlpack/tsconfig.json
new file mode 100644
index 000000000..45f86cf71
--- /dev/null
+++ b/packages/htmlpack/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "outDir": "dist"
+ },
+ "rootDir": ".",
+ "include": [
+ "**/*.ts",
+ "**/*.tsx",
+ ],
+ "exclude": [
+ "**/node_modules",
+ ],
+}
\ No newline at end of file