packaton 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/config.js ADDED
@@ -0,0 +1,72 @@
1
+ import { resolve } from 'node:path'
2
+
3
+ import { isDirectory } from './fs-utils.js'
4
+ import { openInBrowser } from './openInBrowser.js'
5
+
6
+ import { minifyJS } from './minifyJS.js'
7
+ import { minifyCSS } from './minifyCSS.js'
8
+ import { minifyHTML } from './minifyHTML.js'
9
+
10
+
11
+ /** @type {{
12
+ * [K in keyof Config]-?: [
13
+ * defaultVal: Config[K],
14
+ * validator: (val: unknown) => boolean
15
+ * ]
16
+ * }} */
17
+ const schema = {
18
+ mode: ['development', val => ['development', 'production'].includes(val)],
19
+ srcPath: [resolve('src'), isDirectory],
20
+ ignore: [/^_/, optional(RegExp)],
21
+
22
+ // Development
23
+ host: ['127.0.0.1', is(String)],
24
+ port: [0, port => Number.isInteger(port) && port >= 0 && port < 2 ** 16], // 0 means auto-assigned
25
+ onReady: [await openInBrowser, is(Function)],
26
+ hotReload: [true, is(Boolean)],
27
+
28
+ // Production
29
+ outputPath: ['dist', optional(String)], // TODO resolve
30
+ minifyJS: [minifyJS, optional(Function)],
31
+ minifyCSS: [minifyCSS, optional(Function)],
32
+ minifyHTML: [minifyHTML, optional(Function)],
33
+ sitemapDomain: ['', optional(String)],
34
+ cspMapEnabled: [true, optional(Boolean)],
35
+ }
36
+ // TODO watch New Routes?
37
+
38
+
39
+ const defaults = {}
40
+ const validators = {}
41
+ for (const [k, [defaultVal, validator]] of Object.entries(schema)) {
42
+ defaults[k] = defaultVal
43
+ validators[k] = validator
44
+ }
45
+
46
+ /** @type Config */
47
+ const config = Object.seal(defaults)
48
+
49
+
50
+ /** @param {Partial<Config>} opts */
51
+ export function setup(opts) {
52
+ Object.assign(config, opts)
53
+ validate(config, validators)
54
+
55
+ if (config.mode === 'production')
56
+ config.hotReload = false
57
+
58
+ return config
59
+ }
60
+
61
+
62
+ function validate(obj, shape) {
63
+ for (const [field, value] of Object.entries(obj))
64
+ if (!shape[field](value))
65
+ throw new TypeError(`${field}=${JSON.stringify(value)} is invalid`)
66
+ }
67
+ function is(ctor) {
68
+ return val => val.constructor === ctor
69
+ }
70
+ function optional(tester) {
71
+ return val => !val || tester(val)
72
+ }
@@ -0,0 +1,49 @@
1
+ import { readdir } from 'node:fs/promises'
2
+ import { createHash } from 'node:crypto'
3
+ import { join, dirname } from 'node:path'
4
+ import { rmSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
5
+
6
+
7
+ export const read = f => readFileSync(f, 'utf8')
8
+
9
+ export const lstat = f => lstatSync(f, { throwIfNoEntry: false })
10
+ export const isFile = path => lstat(path)?.isFile()
11
+ export const isDirectory = path => lstat(path)?.isDirectory()
12
+
13
+
14
+ export function write(fname, data) {
15
+ mkdirSync(dirname(fname), { recursive: true })
16
+ writeFileSync(fname, data, 'utf8')
17
+ }
18
+
19
+ export function removeDir(dir) {
20
+ rmSync(dir, {
21
+ recursive: true,
22
+ force: true // allows for removing non-existing directories
23
+ })
24
+ }
25
+
26
+ export const sizeOf = f => lstatSync(f).size
27
+ export const sha1 = f => createHash('sha1').update(readFileSync(f)).digest('base64url')
28
+
29
+ export async function listFiles(dir) {
30
+ return (await readdir(dir, {
31
+ withFileTypes: true,
32
+ recursive: false
33
+ }))
34
+ .filter(e => e.isFile())
35
+ .map(e => join(e.parentPath, e.name))
36
+ }
37
+
38
+
39
+ export const saveAsJSON = (name, data) => {
40
+ writeFileSync(name, JSON.stringify(data, null, '\t'), 'utf8')
41
+ }
42
+
43
+ export const replaceExt = (f, ext) => {
44
+ const parts = f.split('.')
45
+ if (parts.length > 1 && parts[0])
46
+ parts.pop()
47
+ parts.push(ext)
48
+ return parts.join('.')
49
+ }
@@ -0,0 +1,21 @@
1
+ import test, { describe } from 'node:test'
2
+ import { equal } from 'node:assert/strict'
3
+ import { replaceExt } from './fs-utils.js'
4
+
5
+
6
+ describe('replaceExt', () => {
7
+ test('replaces a simple extension', () =>
8
+ equal(replaceExt('file.txt', 'md'), 'file.md'))
9
+
10
+ test('replaces a multi-part extension', () =>
11
+ equal(replaceExt('archive.tar.gz', 'zip'), 'archive.tar.zip'))
12
+
13
+ test('adds extension when none exists', () =>
14
+ equal(replaceExt('README', 'md'), 'README.md'))
15
+
16
+ test('handles empty filename', () =>
17
+ equal(replaceExt('', 'ext'), '.ext'))
18
+
19
+ test('handles dot-files', () =>
20
+ equal(replaceExt('.env', 'txt'), '.env.txt'))
21
+ })
@@ -0,0 +1,46 @@
1
+ import fs from 'node:fs'
2
+ import { mimeFor } from './mimes.js'
3
+
4
+
5
+ export function sendError(response, error) {
6
+ response.statusCode = error?.code === 'ENOENT'
7
+ ? 404
8
+ : 500
9
+ console.error(error.message)
10
+ response.end()
11
+ }
12
+
13
+ export function sendJSON(response, payload) {
14
+ response.setHeader('Content-Type', 'application/json')
15
+ response.end(JSON.stringify(payload))
16
+ }
17
+
18
+ export function serveStaticAsset(response, file) {
19
+ response.setHeader('Content-Type', mimeFor(file))
20
+ const reader = fs.createReadStream(file)
21
+ reader.on('open', function () { this.pipe(response) })
22
+ reader.on('error', function (error) { sendError(response, error) })
23
+ }
24
+
25
+
26
+ export async function servePartialContent(response, headers, file) {
27
+ const { size } = await fs.promises.lstat(file)
28
+ let [start, end] = headers.range.replace(/bytes=/, '').split('-').map(n => parseInt(n, 10))
29
+ if (isNaN(end)) end = size - 1
30
+ if (isNaN(start)) start = size - end
31
+
32
+ if (start < 0 || start > end || start >= size || end >= size) {
33
+ response.statusCode = 416 // Range Not Satisfiable
34
+ response.setHeader('Content-Range', `bytes */${size}`)
35
+ response.end()
36
+ }
37
+ else {
38
+ response.statusCode = 206 // Partial Content
39
+ response.setHeader('Accept-Ranges', 'bytes')
40
+ response.setHeader('Content-Range', `bytes ${start}-${end}/${size}`)
41
+ response.setHeader('Content-Type', mimeFor(file))
42
+ const reader = fs.createReadStream(file, { start, end })
43
+ reader.on('open', function () { this.pipe(response) })
44
+ reader.on('error', function (error) { sendError(response, error) })
45
+ }
46
+ }
@@ -0,0 +1,51 @@
1
+ import { join, parse } from 'node:path'
2
+ import { renameSync } from 'node:fs'
3
+ import { sha1, listFiles } from './fs-utils.js'
4
+
5
+
6
+ /**
7
+ * Subdirectories are ignored
8
+ * foo.avif -> foo-<sha1>.avif
9
+ */
10
+ export async function renameMediaWithHashes(dir) {
11
+ const mediaHashes = new Map()
12
+
13
+ for (const file of await listFiles(dir)) {
14
+ const { name, base, ext } = parse(file)
15
+ const newFileName = name + '-' + sha1(file) + ext
16
+ const newFile = join(dir, newFileName)
17
+ mediaHashes.set(base, newFileName)
18
+ renameSync(file, newFile)
19
+ }
20
+
21
+ return mediaHashes
22
+ }
23
+
24
+ // TODO if media is made configurable, we'd need to espace the regex for example .media -> \.media
25
+ // Having one dir is kinda nice for nginx headers, but that's not an excuse nor solves nested dirs with same filename
26
+
27
+ // TODO for (b of base) find and replace base with new hash
28
+ // so it works in dirs outside media/
29
+ // mm currently a limitation is that the dictionary doesn't have the path, just the name, so
30
+ // filenames need to be unique, regardless of being in a subfolder
31
+ /**
32
+ * Edit the media source links in the HTML, so they have the new SHA-1 hashed
33
+ * filenames. Assumes that all the files are in "media/" (not ../media, ./media)
34
+ *
35
+ * If you want to handle CSS files, edit the regex so
36
+ * instead of checking `="` (e.g. src="img.png") also checks for `url(`
37
+ **/
38
+ export function remapMediaInHTML(mediaHashes, html) {
39
+ const reFindMedia = new RegExp('(="media/.*?)"', 'g')
40
+ const reFindMediaKey = new RegExp('="media/')
41
+
42
+ for (const [, url] of html.matchAll(reFindMedia)) {
43
+ const hashedName = mediaHashes.get(url.replace(reFindMediaKey, ''))
44
+ if (!hashedName)
45
+ throw `ERROR: Missing ${url}\n`
46
+ html = html.replace(url, `="media/${hashedName}`)
47
+ }
48
+ return html
49
+ }
50
+
51
+
@@ -0,0 +1,29 @@
1
+ import { describe, test } from 'node:test'
2
+ import { equal, throws } from 'node:assert/strict'
3
+ import { remapMediaInHTML } from './media-remaper.js'
4
+
5
+
6
+ describe('Media Remapper', () => {
7
+ const mHashes = new Map([
8
+ ['alpha.png', '0xFA.png'],
9
+ ['beta.png', '0xFB.png'],
10
+ ['chi.png', '0xFC.png']
11
+ ])
12
+
13
+ test('Throws when the file does not exist', () =>
14
+ throws(() => remapMediaInHTML(mHashes, `<video src="media/missing.mp4">`)))
15
+
16
+ test('Acceptance', () =>
17
+ equal(remapMediaInHTML(mHashes, `
18
+ <img src="media/alpha.png">
19
+ <img src="media/alpha.png">
20
+ <img src="media/beta.png">
21
+ <video poster="media/chi.png">`),
22
+ `
23
+ <img src="media/0xFA.png">
24
+ <img src="media/0xFA.png">
25
+ <img src="media/0xFB.png">
26
+ <video poster="media/0xFC.png">`))
27
+
28
+
29
+ })
package/src/mimes.js ADDED
@@ -0,0 +1,99 @@
1
+ // Generated with:
2
+ // https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types
3
+ // m = {}
4
+ // for (const row of tbody.children)
5
+ // m[row.children[0].querySelector('code').innerText] = row.children[2].querySelector('code').innerText
6
+
7
+ const mimes = {
8
+ css: 'text/css; charset=utf8',
9
+ html: 'text/html; charset=utf8',
10
+ js: 'application/javascript; charset=utf8',
11
+ json: 'application/json; charset=utf8',
12
+ svg: 'image/svg+xml; charset=utf8',
13
+ txt: 'text/plain; charset=utf8', // e.g., robots.txt when running lighthouse
14
+
15
+ '3g2': 'video/3gpp2',
16
+ '3gp': 'video/3gpp',
17
+ '7z': 'application/x-7z-compressed',
18
+ aac: 'audio/aac',
19
+ abw: 'application/x-abiword',
20
+ apng: 'image/apng',
21
+ arc: 'application/x-freearc',
22
+ avi: 'video/x-msvideo',
23
+ avif: 'image/avif',
24
+ azw: 'application/vnd.amazon.ebook',
25
+ bin: 'application/octet-stream',
26
+ bmp: 'image/bmp',
27
+ bz2: 'application/x-bzip2',
28
+ bz: 'application/x-bzip',
29
+ cda: 'application/x-cdf',
30
+ cjs: 'text/javascript',
31
+ csh: 'application/x-csh',
32
+ csv: 'text/csv',
33
+ doc: 'application/msword',
34
+ docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
35
+ eot: 'application/vnd.ms-fontobject',
36
+ epub: 'application/epub+zip',
37
+ gif: 'image/gif',
38
+ gz: 'application/gzip',
39
+ htm: 'text/html',
40
+ ico: 'image/vnd.microsoft.icon',
41
+ ics: 'text/calendar',
42
+ jar: 'application/java-archive',
43
+ jpeg: 'image/jpeg',
44
+ jpg: 'image/jpeg',
45
+ jsonld: 'application/ld+json',
46
+ mid: 'audio/midi',
47
+ midi: 'audio/midi',
48
+ mjs: 'text/javascript',
49
+ mp3: 'audio/mpeg',
50
+ mp4: 'video/mp4',
51
+ mpeg: 'video/mpeg',
52
+ mpkg: 'application/vnd.apple.installer+xml',
53
+ odp: 'application/vnd.oasis.opendocument.presentation',
54
+ ods: 'application/vnd.oasis.opendocument.spreadsheet',
55
+ odt: 'application/vnd.oasis.opendocument.text',
56
+ oga: 'audio/ogg',
57
+ ogv: 'video/ogg',
58
+ ogx: 'application/ogg',
59
+ opus: 'audio/ogg',
60
+ otf: 'font/otf',
61
+ pdf: 'application/pdf',
62
+ php: 'application/x-httpd-php',
63
+ png: 'image/png',
64
+ ppt: 'application/vnd.ms-powerpoint',
65
+ pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
66
+ rar: 'application/vnd.rar',
67
+ rtf: 'application/rtf',
68
+ sh: 'application/x-sh',
69
+ tar: 'application/x-tar',
70
+ tif: 'image/tiff',
71
+ ts: 'video/mp2t',
72
+ ttf: 'font/ttf',
73
+ vsd: 'application/vnd.visio',
74
+ wav: 'audio/wav',
75
+ weba: 'audio/webm',
76
+ webm: 'video/webm',
77
+ webp: 'image/webp',
78
+ woff2: 'font/woff2',
79
+ woff: 'font/woff',
80
+ xhtml: 'application/xhtml+xml',
81
+ xls: 'application/vnd.ms-excel',
82
+ xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
83
+ xml: 'application/xml',
84
+ xul: 'application/vnd.mozilla.xul+xml',
85
+ yaml: 'application/yaml',
86
+ yml: 'application/yaml',
87
+ zip: 'application/zip'
88
+ }
89
+
90
+ export function mimeFor(filename) {
91
+ return mimes[extname(filename)] || ''
92
+ }
93
+
94
+ function extname(filename) {
95
+ const ext = filename
96
+ .split('.').at(-1)
97
+ .split('?').at(0)
98
+ return ext
99
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * You can override this minifier in `config.minifyCSS`
3
+ *
4
+ * This program is an oversimplified CSS minifier. It doesn’t
5
+ * try to minify everything, but only what’s safe and easy to minify.
6
+ *
7
+ * Why?
8
+ * When I wrote this program, ~2018, some CSS minifiers reordered rules
9
+ * but that messed up browser-specific prefixes that were used as workarounds.
10
+ */
11
+
12
+ // TODO
13
+ // - Handle nested comments like /*/* foo */*/
14
+ // - Preserve anything within data-uri, and content strings.
15
+ // We could do like in minifyHTML. i.e., stacking all the things to
16
+ // be preserved and replace them with a magic string then pop the stack to re-replace.
17
+
18
+ const BlockComments = /\/\*(\*(?!\/)|[^*])*\*\//g
19
+ const LeadingAndTrailingWhitespace = /^\s*|\s*$/gm
20
+ const PropValueWhitespaceSeparator = /(?<=:)\s*/gm
21
+ const Newlines = /\n/gm
22
+ const WhitespaceBeforeBraces = /\s*(?=[{}])/gm
23
+ const WhitespaceAfterBraces = /(?<=[{}])\s*/gm
24
+ const LastSemicolonInSet = /;(?=})/gm
25
+ const SpacesAfterComma = /(?<=,)\s+/g
26
+
27
+
28
+ export function minifyCSS(css) {
29
+ return css
30
+ .replace(BlockComments, '')
31
+ .replace(LeadingAndTrailingWhitespace, '')
32
+ .replace(PropValueWhitespaceSeparator, '')
33
+ .replace(Newlines, '')
34
+ .replace(WhitespaceBeforeBraces, '')
35
+ .replace(WhitespaceAfterBraces, '')
36
+ .replace(LastSemicolonInSet, '')
37
+ .replace(SpacesAfterComma, '')
38
+ }
39
+
40
+ export const Testable = {
41
+ BlockComments,
42
+ LeadingAndTrailingWhitespace,
43
+ PropValueWhitespaceSeparator,
44
+ Newlines,
45
+ WhitespaceBeforeBraces,
46
+ WhitespaceAfterBraces,
47
+ LastSemicolonInSet,
48
+ SpacesAfterComma,
49
+ }
50
+
@@ -0,0 +1,124 @@
1
+ import { equal } from 'node:assert/strict'
2
+ import { describe, test } from 'node:test'
3
+ import { minifyCSS, Testable } from './minifyCSS.js'
4
+
5
+
6
+ describe('minifyCSS', () => {
7
+ test('Acceptance', () => {
8
+ equal(minifyCSS(`
9
+ .a {
10
+ color: green;
11
+ &:active {
12
+ color: red;
13
+ }
14
+ }
15
+ .b { color: orange; }
16
+ .c { color: #f00; }
17
+ .d { color: rgb(255, 255, 0); }
18
+ .e { color: #111222; }
19
+ `),
20
+ `.a{color:green;&:active{color:red}}.b{color:orange}.c{color:#f00}.d{color:rgb(255,255,0)}.e{color:#111222}`)
21
+ })
22
+
23
+ test('Comments', () => {
24
+ testRegexMatchesGetDeleted(Testable.BlockComments, `
25
+ /* Foo */
26
+ /* Multiline line 1
27
+ Line 2 */
28
+ .a { color: red; } /* Bar */
29
+ /*/*/
30
+ `,
31
+ `
32
+
33
+
34
+ .a { color: red; }
35
+
36
+ `)
37
+ })
38
+
39
+
40
+ test('Trimming', () => {
41
+ testRegexMatchesGetDeleted(Testable.LeadingAndTrailingWhitespace, `
42
+ .b .c {
43
+ color: orange;
44
+ width: 20px;
45
+ }
46
+ .d {
47
+ height: 30px;
48
+ }
49
+ `,
50
+ `.b .c {
51
+ color: orange;
52
+ width: 20px;
53
+ }
54
+ .d {
55
+ height: 30px;
56
+ }`)
57
+ })
58
+
59
+
60
+ test('Inner Prop Value space', () => {
61
+ testRegexMatchesGetDeleted(Testable.PropValueWhitespaceSeparator, `
62
+ .e {
63
+ color: #f00;
64
+ height: 100px;
65
+ width: 100px;
66
+ content: 'a';
67
+ }
68
+ `,
69
+ `
70
+ .e {
71
+ color:#f00;
72
+ height:100px;
73
+ width:100px;
74
+ content:'a';
75
+ }
76
+ `)
77
+ })
78
+
79
+
80
+ test('Newlines', () => {
81
+ testRegexMatchesGetDeleted(Testable.Newlines, `
82
+ .f {
83
+ color: blue;
84
+ height: 300px;
85
+ width: 300px;
86
+ }
87
+ `,
88
+ `.f { color: blue; height: 300px; width: 300px;}`)
89
+ })
90
+
91
+
92
+ test('White spaces before braces', () => {
93
+ testRegexMatchesGetDeleted(Testable.WhitespaceBeforeBraces, `
94
+ .g { color: pink; width: 400px; } `,
95
+ `
96
+ .g{ color: pink; width: 400px;} `)
97
+ })
98
+
99
+
100
+ test('White spaces after braces', () => {
101
+ testRegexMatchesGetDeleted(Testable.WhitespaceAfterBraces, `
102
+ .G { color: green; width: 410px; } `,
103
+ `
104
+ .G {color: green; width: 410px; }`)
105
+ })
106
+
107
+ test('Final semicolon', () => {
108
+ testRegexMatchesGetDeleted(Testable.LastSemicolonInSet,
109
+ '.h {color: cyan; width: 500px;}',
110
+ '.h {color: cyan; width: 500px}'
111
+ )
112
+ })
113
+
114
+ test('Comma + Space', () => {
115
+ testRegexMatchesGetDeleted(Testable.SpacesAfterComma,
116
+ '.H { color: rgb(255, 255, 0); }',
117
+ '.H { color: rgb(255,255,0); }'
118
+ )
119
+ })
120
+
121
+ function testRegexMatchesGetDeleted(regex, input, expected) {
122
+ equal(input.replace(regex, ''), expected)
123
+ }
124
+ })
@@ -0,0 +1,50 @@
1
+ /**
2
+ * You can override this minifier in `config.minifyHTML`
3
+ *
4
+ * This program is an oversimplified HTML minifier. It doesn’t
5
+ * try to minify everything, but only what’s safe and easy to minify.
6
+ * It's based on https://gist.github.com/espretto/1b3cb7e8b01fa7daaaac
7
+ *
8
+ * Why?
9
+ * When I wrote this program, ~2018, I tried a few libraries but some
10
+ * of them messed up relevant spaces in `<pre>` tags and between tags.
11
+ *
12
+ * We don’t remove newlines because for example `<kbd>` and `<a>`
13
+ * would need special rules to have a space in-place of that newline.
14
+ *
15
+ *
16
+ * This algorithm basically collects parts that should not be minified and
17
+ * replaces them with a known magic string "<preserved>". Then, at the end,
18
+ * replaces, in order, those magic strings with the original tag and its content.
19
+ */
20
+
21
+
22
+ const Comments = /<!--(?!\s*?\[\s*?if)[\s\S]*?-->/gi
23
+
24
+ const PreserveTags = /<(pre|style|script(?![^>]*?src))[^>]*>[\s\S]*?<\/\1>/gi
25
+ const InsertTags = /<preserved>/g
26
+
27
+ const ReduceAttributeDelimiters = /\s{2,}(?=[^<]*>)/g
28
+ const LeadingWhitespace = /^\s*/gm
29
+
30
+
31
+ export function minifyHTML(html) {
32
+ const preservedTags = []
33
+
34
+ function onPreserveTag(tag) {
35
+ preservedTags.push(tag)
36
+ return '<preserved>' // temp placeholder
37
+ }
38
+
39
+ function onInsertTag() {
40
+ return preservedTags.shift() // pops left, rewrites the placeholder back to the original tag
41
+ }
42
+
43
+ return html
44
+ .replace(Comments, '')
45
+ .replace(PreserveTags, onPreserveTag)
46
+ .replace(ReduceAttributeDelimiters, ' ')
47
+ .replace(LeadingWhitespace, '')
48
+ .replace(InsertTags, onInsertTag)
49
+ .trim()
50
+ }
@@ -0,0 +1,72 @@
1
+ import { describe, test } from 'node:test'
2
+ import { equal } from 'node:assert/strict'
3
+ import { minifyHTML } from './minifyHTML.js'
4
+
5
+
6
+ describe('minifyHTML', () => {
7
+ test('Acceptance', () => {
8
+ equal(minifyHTML(`
9
+ <!DOCTYPE html>
10
+ <html
11
+ lang="en">
12
+ <!-- Comment -->
13
+ <head>
14
+ <!-- Mulitline
15
+ Comment -->
16
+ <!-- &lt;!&ndash; deleteme ' ' .&ndash;&gt;-->
17
+ <title>Foo Bar</title>
18
+ </head>
19
+ <body>
20
+ <div>
21
+ <h1>Title</h1>
22
+ </div>
23
+ <p>Single Quotes? Keep'em a'll</p>
24
+ <pre data-custom-attr="keeps everything here <h1> <h1>">
25
+ aaa bbbb
26
+ ccc dddd
27
+
28
+ </pre>
29
+ <dd>
30
+ <kbd>A</kbd>
31
+ <kbd class="foo">B</kbd>
32
+ <kbd>Ctrl</kbd>Click
33
+ </dd>
34
+ <a>LinkA</a>
35
+ <a>LinkB</a>
36
+ <p>
37
+ Removes leading tabs.
38
+ </p>
39
+ </body>
40
+ </html>
41
+ `),
42
+
43
+ `<!DOCTYPE html>
44
+ <html lang="en">
45
+ <head>
46
+ <title>Foo Bar</title>
47
+ </head>
48
+ <body>
49
+ <div>
50
+ <h1>Title</h1>
51
+ </div>
52
+ <p>Single Quotes? Keep'em a'll</p>
53
+ <pre data-custom-attr="keeps everything here <h1> <h1>">
54
+ aaa bbbb
55
+ ccc dddd
56
+
57
+ </pre>
58
+ <dd>
59
+ <kbd>A</kbd>
60
+ <kbd class="foo">B</kbd>
61
+ <kbd>Ctrl</kbd>Click
62
+ </dd>
63
+ <a>LinkA</a>
64
+ <a>LinkB</a>
65
+ <p>
66
+ Removes leading tabs.
67
+ </p>
68
+ </body>
69
+ </html>`
70
+ )
71
+ })
72
+ })
@@ -0,0 +1,6 @@
1
+ import { minify } from 'terser'
2
+
3
+
4
+ export async function minifyJS(code, options) {
5
+ return (await minify(code, options)).code
6
+ }