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/.editorconfig +11 -0
- package/LICENSE +21 -0
- package/README.md +50 -0
- package/TODO.md +8 -0
- package/index.d.ts +21 -0
- package/index.js +6 -0
- package/package.json +23 -0
- package/src/HtmlCompiler.js +132 -0
- package/src/HtmlCompiler.test.js +71 -0
- package/src/WatcherDevClient.js +18 -0
- package/src/app-dev.js +26 -0
- package/src/app-prod.js +100 -0
- package/src/app-router.js +78 -0
- package/src/app.js +66 -0
- package/src/config.js +72 -0
- package/src/fs-utils.js +49 -0
- package/src/fs-utils.test.js +21 -0
- package/src/http-response.js +46 -0
- package/src/media-remaper.js +51 -0
- package/src/media-remaper.test.js +29 -0
- package/src/mimes.js +99 -0
- package/src/minifyCSS.js +50 -0
- package/src/minifyCSS.test.js +124 -0
- package/src/minifyHTML.js +50 -0
- package/src/minifyHTML.test.js +72 -0
- package/src/minifyJS.js +6 -0
- package/src/openInBrowser.js +23 -0
- package/src/reportSizes.js +23 -0
- package/src/watcherDev.js +32 -0
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
|
+
}
|
package/src/fs-utils.js
ADDED
|
@@ -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
|
+
}
|
package/src/minifyCSS.js
ADDED
|
@@ -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
|
+
<!-- <!– deleteme ' ' .–>-->
|
|
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
|
+
})
|