diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index b37c1cb8..65de6d63 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -1,5 +1,6 @@ name: GitHub Pages + on: push: branches: master @@ -17,13 +18,13 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: actions/setup-node@v3 with: node-version: 16 - name: Setup Pages - uses: actions/configure-pages@v1 + uses: actions/configure-pages@v4 - name: Install dependencies run: npm ci @@ -35,7 +36,7 @@ jobs: run: npm run build:docs - name: Upload build artifact - uses: actions/upload-pages-artifact@v1 + uses: actions/upload-pages-artifact@v3 with: path: './demo/build' @@ -50,4 +51,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v2 + uses: actions/deploy-pages@v4 diff --git a/demo/defaultOptions.js b/demo/defaultOptions.js new file mode 100644 index 00000000..c01be7e0 --- /dev/null +++ b/demo/defaultOptions.js @@ -0,0 +1,238 @@ +export default [ + { + id: 'compressionLevels', + type: 'text', + label: 'Compression levels', + helpText: 'Comma-delimited string corresponding to compression levels. (e.g. 0-9)', + value: '4,6,9' + }, { + id: 'compressionAlgorithms', + type: 'text', + label: 'Compression algorithms', + helpText: 'Comma-delimited string corresponding to compression algorithms. (e.g. gzip,deflate)', + value: 'gzip,deflate' + }, + { + id: 'caseSensitive', + type: 'checkbox', + label: 'Case-sensitive', + helpText: 'Treat attributes in case sensitive manner (useful for custom HTML tags)' + }, + { + id: 'collapseBooleanAttributes', + type: 'checkbox', + label: 'Collapse boolean attributes', + helpText: 'Omit attribute values from boolean attributes', + checked: true + }, + { + id: 'collapseInlineTagWhitespace', + type: 'checkbox', + label: 'Collapse inline tag whitespace', + helpText: `Don't leave any spaces between display:inline; elements when collapsing. + Must be used in conjunction with collapseWhitespace=true`, + unsafe: true + }, + { + id: 'collapseWhitespace', + type: 'checkbox', + label: 'Collapse whitespace', + helpText: 'Collapse white space that contributes to text nodes in a document tree', + checked: true + }, + { + id: 'conservativeCollapse', + type: 'checkbox', + label: 'Conservative collapse', + helpText: `Always collapse to 1 space (never remove it entirely). + Must be used in conjunction with collapseWhitespace=true` + }, + { + id: 'decodeEntities', + type: 'checkbox', + label: 'Decode Entity Characters', + helpText: 'Use direct Unicode characters whenever possible', + checked: true + }, + { + id: 'html5', + type: 'checkbox', + label: 'HTML5', + helpText: 'Parse input according to HTML5 specifications', + checked: true + }, + { + id: 'includeAutoGeneratedTags', + type: 'checkbox', + label: 'Include auto-generated tags', + helpText: 'Insert tags generated by HTML parser' + }, + { + id: 'keepClosingSlash', + type: 'checkbox', + label: 'Keep closing slash', + helpText: 'Keep the trailing slash on singleton elements' + }, + { + id: 'maxLineLength', + type: 'number', + label: 'Max. line length', + helpText: 'Specify a maximum line length. Compressed output will be split by newlines at valid HTML split-points' + }, + { + id: 'minifyCSS', + type: 'checkbox', + label: 'Minify CSS', + helpText: 'Minify CSS in style elements and style attributes (uses clean-css)', + checked: true + }, + { + id: 'minifyJS', + type: 'checkbox', + label: 'Minify JavaScript', + helpText: 'Minify JavaScript in script elements and event attributes (uses Terser)', + checked: true + }, + { + id: 'minifyURLs', + type: 'checkbox', + label: 'Minify URLs', + helpText: 'Minify URLs in various attributes (uses relateurl)' + }, + { + id: 'noNewlinesBeforeTagClose', + type: 'checkbox', + label: 'No newline before Tag Close', + helpText: 'Never add a newline before a tag that closes an element' + }, + { + id: 'preserveLineBreaks', + type: 'checkbox', + label: 'Preserve line-breaks', + helpText: `Always collapse to 1 line break (never remove it entirely) when whitespace between tags include a line break. + Must be used in conjunction with collapseWhitespace=true` + }, + { + id: 'preventAttributesEscaping', + type: 'checkbox', + label: 'Prevent attributes escaping', + helpText: 'Prevents the escaping of the values of attributes', + unsafe: true + }, + { + id: 'processConditionalComments', + type: 'checkbox', + label: 'Process conditional comments', + helpText: 'Process contents of conditional comments through minifier', + checked: true + }, + { + id: 'processScripts', + type: 'text', + label: 'Process scripts', + helpText: 'Comma-delimited string corresponding to types of script elements to process through minifier (e.g. text/ng-template, text/x-handlebars-template)', + value: 'text/html' + }, + { + id: 'quoteCharacter', + type: 'text', + label: 'Quote character', + helpText: 'Type of quote to use for attribute values (\' or ")' + }, + { + id: 'removeAttributeQuotes', + type: 'checkbox', + label: 'Remove attribute quotes', + helpText: 'Remove quotes around attributes when possible', + checked: true + }, + { + id: 'removeComments', + type: 'checkbox', + label: 'Remove comments', + helpText: 'Strip HTML comments', + checked: true + }, + { + id: 'removeEmptyAttributes', + type: 'checkbox', + label: 'Remove empty attributes', + helpText: 'Remove all attributes with whitespace-only values', + checked: true + }, + { + id: 'removeEmptyElements', + type: 'checkbox', + label: 'Remove empty elements', + helpText: 'Remove all elements with empty contents', + unsafe: true + }, + { + id: 'removeOptionalTags', + type: 'checkbox', + label: 'Remove optional tags', + checked: true + }, + { + id: 'removeRedundantAttributes', + type: 'checkbox', + label: 'Remove redundant attributes', + helpText: 'Remove attributes when value matches default.', + checked: true + }, + { + id: 'removeScriptTypeAttributes', + type: 'checkbox', + label: 'Remove script type attributes', + helpText: `Remove type="text/javascript" from script tags. + Other type attribute values are left intact`, + checked: true + }, + { + id: 'removeStyleLinkTypeAttributes', + type: 'checkbox', + label: 'Remove style link type attributes', + helpText: `Remove type="text/css" from style and link tags. + Other type attribute values are left intact`, + checked: true + }, + { + id: 'removeTagWhitespace', + type: 'checkbox', + label: 'Remove tag whitespace', + helpText: `Remove space between attributes whenever possible. + Note that this will result in invalid HTML!`, + checked: true, + unsafe: true + }, + { + id: 'sortAttributes', + type: 'checkbox', + label: 'Sort attributes', + helpText: 'Sort attributes by frequency', + checked: true, + unsafe: true + }, + { + id: 'sortClassName', + type: 'checkbox', + label: 'Sort class name', + helpText: 'Sort style classes by frequency', + checked: true, + unsafe: true + }, + { + id: 'trimCustomFragments', + type: 'checkbox', + label: 'Trim white space around custom fragments', + helpText: 'Trim white space around ignoreCustomFragments.', + checked: true + }, + { + id: 'useShortDoctype', + type: 'checkbox', + label: 'Use short doctype', + helpText: 'Replaces the doctype with the short (HTML5) doctype', + checked: true + } +]; diff --git a/demo/index.html b/demo/index.html index 20d5f277..b16506ce 100644 --- a/demo/index.html +++ b/demo/index.html @@ -20,8 +20,49 @@

HTML Minifier

-
+
+ + + +
+

@@ -80,4 +121,4 @@

HTML Minifier

- + \ No newline at end of file diff --git a/demo/main.js b/demo/main.js index 46776f88..85d98c53 100644 --- a/demo/main.js +++ b/demo/main.js @@ -1,233 +1,56 @@ +import 'lodash.product'; +import _ from 'lodash'; import Alpine from 'alpinejs'; import HTMLMinifier from '../dist/htmlminifier.esm.bundle.js'; import pkg from '../package.json'; - -const defaultOptions = [ - { - id: 'caseSensitive', - type: 'checkbox', - label: 'Case-sensitive', - helpText: 'Treat attributes in case sensitive manner (useful for custom HTML tags)' - }, - { - id: 'collapseBooleanAttributes', - type: 'checkbox', - label: 'Collapse boolean attributes', - helpText: 'Omit attribute values from boolean attributes', - checked: true - }, - { - id: 'collapseInlineTagWhitespace', - type: 'checkbox', - label: 'Collapse inline tag whitespace', - helpText: `Don't leave any spaces between display:inline; elements when collapsing. - Must be used in conjunction with collapseWhitespace=true`, - unsafe: true - }, - { - id: 'collapseWhitespace', - type: 'checkbox', - label: 'Collapse whitespace', - helpText: 'Collapse white space that contributes to text nodes in a document tree', - checked: true - }, - { - id: 'conservativeCollapse', - type: 'checkbox', - label: 'Conservative collapse', - helpText: `Always collapse to 1 space (never remove it entirely). - Must be used in conjunction with collapseWhitespace=true` - }, - { - id: 'decodeEntities', - type: 'checkbox', - label: 'Decode Entity Characters', - helpText: 'Use direct Unicode characters whenever possible', - checked: true - }, - { - id: 'html5', - type: 'checkbox', - label: 'HTML5', - helpText: 'Parse input according to HTML5 specifications', - checked: true - }, - { - id: 'includeAutoGeneratedTags', - type: 'checkbox', - label: 'Include auto-generated tags', - helpText: 'Insert tags generated by HTML parser' - }, - { - id: 'keepClosingSlash', - type: 'checkbox', - label: 'Keep closing slash', - helpText: 'Keep the trailing slash on singleton elements' - }, - { - id: 'maxLineLength', - type: 'number', - label: 'Max. line length', - helpText: 'Specify a maximum line length. Compressed output will be split by newlines at valid HTML split-points' - }, - { - id: 'minifyCSS', - type: 'checkbox', - label: 'Minify CSS', - helpText: 'Minify CSS in style elements and style attributes (uses clean-css)', - checked: true - }, - { - id: 'minifyJS', - type: 'checkbox', - label: 'Minify JavaScript', - helpText: 'Minify JavaScript in script elements and event attributes (uses Terser)', - checked: true - }, - { - id: 'minifyURLs', - type: 'checkbox', - label: 'Minify URLs', - helpText: 'Minify URLs in various attributes (uses relateurl)' - }, - { - id: 'noNewlinesBeforeTagClose', - type: 'checkbox', - label: 'No newline before Tag Close', - helpText: 'Never add a newline before a tag that closes an element' - }, - { - id: 'preserveLineBreaks', - type: 'checkbox', - label: 'Preserve line-breaks', - helpText: `Always collapse to 1 line break (never remove it entirely) when whitespace between tags include a line break. - Must be used in conjunction with collapseWhitespace=true` - }, - { - id: 'preventAttributesEscaping', - type: 'checkbox', - label: 'Prevent attributes escaping', - helpText: 'Prevents the escaping of the values of attributes', - unsafe: true - }, - { - id: 'processConditionalComments', - type: 'checkbox', - label: 'Process conditional comments', - helpText: 'Process contents of conditional comments through minifier', - checked: true - }, - { - id: 'processScripts', - type: 'text', - label: 'Process scripts', - helpText: 'Comma-delimited string corresponding to types of script elements to process through minifier (e.g. text/ng-template, text/x-handlebars-template)', - value: 'text/html' - }, - { - id: 'quoteCharacter', - type: 'text', - label: 'Quote character', - helpText: 'Type of quote to use for attribute values (\' or ")' - }, - { - id: 'removeAttributeQuotes', - type: 'checkbox', - label: 'Remove attribute quotes', - helpText: 'Remove quotes around attributes when possible', - checked: true - }, - { - id: 'removeComments', - type: 'checkbox', - label: 'Remove comments', - helpText: 'Strip HTML comments', - checked: true - }, - { - id: 'removeEmptyAttributes', - type: 'checkbox', - label: 'Remove empty attributes', - helpText: 'Remove all attributes with whitespace-only values', - checked: true - }, - { - id: 'removeEmptyElements', - type: 'checkbox', - label: 'Remove empty elements', - helpText: 'Remove all elements with empty contents', - unsafe: true - }, - { - id: 'removeOptionalTags', - type: 'checkbox', - label: 'Remove optional tags', - checked: true - }, - { - id: 'removeRedundantAttributes', - type: 'checkbox', - label: 'Remove redundant attributes', - helpText: 'Remove attributes when value matches default.', - checked: true - }, - { - id: 'removeScriptTypeAttributes', - type: 'checkbox', - label: 'Remove script type attributes', - helpText: `Remove type="text/javascript" from script tags. - Other type attribute values are left intact`, - checked: true - }, - { - id: 'removeStyleLinkTypeAttributes', - type: 'checkbox', - label: 'Remove style link type attributes', - helpText: `Remove type="text/css" from style and link tags. - Other type attribute values are left intact`, - checked: true - }, - { - id: 'removeTagWhitespace', - type: 'checkbox', - label: 'Remove tag whitespace', - helpText: `Remove space between attributes whenever possible. - Note that this will result in invalid HTML!`, - checked: true, - unsafe: true - }, - { - id: 'sortAttributes', - type: 'checkbox', - label: 'Sort attributes', - helpText: 'Sort attributes by frequency', - checked: true, - unsafe: true - }, - { - id: 'sortClassName', - type: 'checkbox', - label: 'Sort class name', - helpText: 'Sort style classes by frequency', - checked: true, - unsafe: true - }, - { - id: 'trimCustomFragments', - type: 'checkbox', - label: 'Trim white space around custom fragments', - helpText: 'Trim white space around ignoreCustomFragments.', - checked: true - }, - { - id: 'useShortDoctype', - type: 'checkbox', - label: 'Use short doctype', - helpText: 'Replaces the doctype with the short (HTML5) doctype', - checked: true - } +import defaultOptions from './defaultOptions.js'; +import Pako from 'pako'; + +const minifierVariants = [ + ['raw', null], + [ + 'Sorted Attributes', { + sortAttributes: true, + sortClassName: true, + } + ], + [ + 'Unsorted Attributes', { + sortAttributes: false, + sortClassName: false, + } + ], + [ + 'Attribute With Quotes', + { + removeAttributeQuotes: false, + removeTagWhitespace: false, + } + ], + [ + 'Attribute Without Quotes', + { + removeAttributeQuotes: true, + removeTagWhitespace: true, + } + ] ]; +const fileToResult = async (file) => { + return new Promise((resolve, reject) => { + const reader = new window.FileReader(); + reader.readAsText(file); + reader.onload = () => { + resolve(reader.result); + }; + }); +}; +const percentage = (a, b) => { + const diff = a - b; + const savings = ((100 * diff) / a || 0).toFixed(2); + return savings; +}; + const sillyClone = (o) => JSON.parse(JSON.stringify(o)); const getOptions = (options) => { @@ -262,33 +85,136 @@ Alpine.data('minifier', () => ({ output: '', stats: { result: '', - text: '' + text: '', + variants: [] + }, + support: { + fileReader: 'FileReader' in window + }, + selectedVariant: null, + compress(alg, data, level) { + const start = performance.now(); + const compressed = + alg === 'raw' + ? data + : Pako[alg](data, { + level + }); + return { + size: compressed.length, + elapsed: (performance.now() - start).toFixed(2) + }; + }, + async selectFile(event) { + this.minify( + await Promise.all( + [...event.target.files].map(async (file) => ({ + name: file.name, + value: await fileToResult(file) + })) + ) + ); + }, + async minifyHTML(code, options) { + try { + return [null, await HTMLMinifier.minify(code, options)]; + } catch (error) { + return [error, code]; + } + }, + async minifyInput() { + this.minify([ + { + name: 'Input', + value: this.input + } + ]); }, - async minify() { + dataUrl() { + return `data:text/html,${encodeURIComponent(this.selectedVariant.data)}`; + }, + async variants(name, value) { + const options = getOptions(this.options); + let err = null; + const sources = await Promise.all( + minifierVariants.map(async ([name, minifierOptions]) => { + if (minifierOptions == null) { + return { name, data: value }; + } + minifierOptions = { ...options, ...minifierOptions }; + const [minifierErr, data] = await this.minifyHTML(value, minifierOptions); + err = minifierErr; + return { name, data }; + }) + ); + const levels = (options.compressionLevels || '').split(',').filter(Boolean).filter(Boolean).map( + (level) => parseInt(level) + ); + const algorithms = (options.compressionAlgorithms || '').split(','); + const algLevels = [ + ['raw', 0], + ..._.product(algorithms, levels) + ]; + const variants = _.product(sources, algLevels).map( + async ([{ name: optionName, data }, [alg, level]]) => { + const minifiedTitle = optionName === 'raw' ? '' : ` ${optionName}`; + const algTitle = alg === 'raw' ? '' : ` ${alg} ${level}`; + return ({ + name, + value, + data, + title: `${name}${minifiedTitle}${algTitle}`, + compression: this.compress(alg, data, level) + }); + } + ); + return { err, variants: await Promise.all(variants) }; + }, + async minify(values) { this.stats = { result: '', - text: '' + text: '', + variants: [] }; - - const options = getOptions(this.options); - try { - const data = await HTMLMinifier.minify(this.input, options); - - const diff = this.input.length - data.length; - const savings = this.input.length ? (100 * diff / this.input.length).toFixed(2) : 0; - - this.output = data; - this.stats.result = 'success'; - this.stats.text = `Original Size: ${this.input.length}, Minfied Size: ${data.length}, Savings: ${diff} (${savings}%)`; + const results = await Promise.all( + values.map(({ name, value }) => this.variants(name, value)) + ); + const variants = results + .flatMap((result) => result.variants) + .sort((a, b) => a.compression.size - b.compression.size); + const errors = results + .filter((result) => result.err) + .map((result) => '' + result.err); + this.stats.variants = variants; + variants && this.selectVariant(variants[0]); + if (errors.length) { + throw new Error(errors.join('\n')); + } } catch (err) { this.stats.result = 'failure'; this.stats.text = err + ''; console.error(err); } }, - + selectVariant(selectedVariant) { + this.selectedVariant = selectedVariant; + this.input = selectedVariant.value; + this.output = selectedVariant.data; + this.stats.variants.forEach((variant) => { + variant.ratio = { + size: percentage( + selectedVariant.compression.size, + variant.compression.size + ), + elapsed: percentage( + selectedVariant.compression.elapsed, + variant.compression.elapsed + ) + }; + }); + }, selectAllOptions(yes = true) { this.options = this.options.map((option) => { if (option.type !== 'checkbox') { diff --git a/package-lock.json b/package-lock.json index d2039fa1..aece8daa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,9 @@ "jest": "^29.5.0", "jest-environment-jsdom": "^29.5.0", "lint-staged": "^13.2.3", + "lodash": "^4.17.21", + "lodash.product": "^18.10.0", + "pako": "^2.1.0", "rollup": "^3.26.0", "rollup-plugin-polyfill-node": "^0.12.0", "vite": "^4.3.9" @@ -6290,6 +6293,22 @@ "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", "dev": true }, + "node_modules/lodash.product": { + "version": "18.10.0", + "resolved": "https://registry.npmjs.org/lodash.product/-/lodash.product-18.10.0.tgz", + "integrity": "sha512-QyJXtayg223OlQUpgeD0xrWnQ/5LrDmAIjTeEZm1jZdQs2laQV5OCsOxo9FmC3TQx/CGOqRuNWyyHuKF2GMGBA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/lodash": "^4", + "lodash": "^4" + }, + "peerDependenciesMeta": { + "@types/lodash": { + "optional": true + } + } + }, "node_modules/lodash.snakecase": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", @@ -6846,6 +6865,13 @@ "node": ">=6" } }, + "node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", + "dev": true, + "license": "(MIT AND Zlib)" + }, "node_modules/param-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", diff --git a/package.json b/package.json index 6110ab22..46a489db 100644 --- a/package.json +++ b/package.json @@ -98,8 +98,11 @@ "jest": "^29.5.0", "jest-environment-jsdom": "^29.5.0", "lint-staged": "^13.2.3", + "lodash": "^4.17.21", + "lodash.product": "^18.10.0", + "pako": "^2.1.0", "rollup": "^3.26.0", "rollup-plugin-polyfill-node": "^0.12.0", "vite": "^4.3.9" } -} +} \ No newline at end of file