packaton 0.0.24 → 0.0.26
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/README.md +2 -3
- package/index.d.ts +7 -8
- package/package.json +1 -1
- package/src/app-prod.js +26 -5
- package/src/config.js +1 -0
- package/src/plugins-dev/cache-bust-resolver.js +1 -1
- package/src/plugins-dev/watcherDev.js +8 -7
- package/src/plugins-prod/HtmlCompiler.js +7 -7
- package/src/plugins-prod/minifyCSS.js +3 -3
- package/src/plugins-prod/minifyHTML.js +3 -3
- package/src/plugins-prod/netiflyAndCloudflareHeadersPlugin.js +8 -26
- package/src/plugins-prod/sitemapPlugin.js +7 -5
- package/src/utils/fs-utils.js +5 -5
- package/src/utils/fs-utils.test.js +4 -4
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Packaton WIP
|
|
2
2
|
|
|
3
|
-
Static Site Generator (SSG). Inlines CSS and JS and
|
|
3
|
+
Static Site Generator (SSG). Inlines CSS and JS and
|
|
4
4
|
creates a header file with their corresponding CSP hashes.
|
|
5
5
|
|
|
6
6
|
## Usage Example
|
|
@@ -32,11 +32,10 @@ If you want to use media files in CSS, create a similar function to
|
|
|
32
32
|
|
|
33
33
|
## Production Build
|
|
34
34
|
It crawls the dev server, and saves each route as static html page.
|
|
35
|
-
It saves the pages without the `.html` extension for pretty URLs.
|
|
35
|
+
It saves the pages without the `.html` extension for pretty URLs.
|
|
36
36
|
See [Pretty routes for static HTML](https://blog.uxtly.com/pretty-routes-for-static-html)
|
|
37
37
|
|
|
38
38
|
|
|
39
|
-
|
|
40
39
|
## Minifiers
|
|
41
40
|
|
|
42
41
|
```js
|
package/index.d.ts
CHANGED
|
@@ -3,15 +3,14 @@ export interface Config {
|
|
|
3
3
|
srcPath?: string
|
|
4
4
|
assetsDir?: string
|
|
5
5
|
ignore?: RegExp
|
|
6
|
-
|
|
7
6
|
|
|
8
7
|
// Dev
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
watchIgnore?: Array<string|RegExp>
|
|
14
|
-
|
|
8
|
+
host?: string
|
|
9
|
+
port?: number
|
|
10
|
+
onReady?: (address: string) => void
|
|
11
|
+
hotReload?: boolean // For UI dev purposes only
|
|
12
|
+
watchIgnore?: Array<string | RegExp>
|
|
13
|
+
|
|
15
14
|
// Production
|
|
16
15
|
outputDir?: string
|
|
17
16
|
outputExtension?: string
|
|
@@ -20,5 +19,5 @@ export interface Config {
|
|
|
20
19
|
minifyHTML?: (html: string) => Promise<string>
|
|
21
20
|
sitemapDomain?: string
|
|
22
21
|
cspMapEnabled?: boolean
|
|
22
|
+
routeHeaders?: [name: string, value: string][]
|
|
23
23
|
}
|
|
24
|
-
|
package/package.json
CHANGED
package/src/app-prod.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { cpSync } from 'node:fs'
|
|
2
1
|
import { createServer } from 'node:http'
|
|
3
2
|
import { basename, join } from 'node:path'
|
|
3
|
+
import { cpSync, existsSync } from 'node:fs'
|
|
4
4
|
|
|
5
5
|
import { docs } from './app.js'
|
|
6
6
|
import { router } from './router.js'
|
|
@@ -41,9 +41,25 @@ export async function buildStaticPages(config) {
|
|
|
41
41
|
}
|
|
42
42
|
})
|
|
43
43
|
|
|
44
|
+
|
|
45
|
+
const wellKnownDir = join(pSource, '.well-known')
|
|
46
|
+
if (existsSync(wellKnownDir))
|
|
47
|
+
cpSync(wellKnownDir, join(pDist, '.well-known'), {
|
|
48
|
+
recursive: true,
|
|
49
|
+
dereference: true,
|
|
50
|
+
filter(src) {
|
|
51
|
+
return basename(src) !== '.DS_Store'
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
|
|
44
55
|
const pages = await crawlRoutes(server.address(), docs.routes)
|
|
45
56
|
const mediaHashes = await renameMediaWithHashes(pDist, MEDIA_REL_URL)
|
|
46
|
-
|
|
57
|
+
|
|
58
|
+
const headers = {
|
|
59
|
+
['/' + MEDIA_REL_URL + '/*']: [
|
|
60
|
+
['Cache-Control', 'public,max-age=31536000,immutable']
|
|
61
|
+
]
|
|
62
|
+
}
|
|
47
63
|
const cspByRoute = []
|
|
48
64
|
for (const [route, rawHtml] of pages) {
|
|
49
65
|
const doc = new HtmlCompiler(rawHtml, pSource, {
|
|
@@ -52,7 +68,7 @@ export async function buildStaticPages(config) {
|
|
|
52
68
|
minifyHTML: config.minifyHTML,
|
|
53
69
|
mediaRelUrl: MEDIA_REL_URL,
|
|
54
70
|
mediaHashes
|
|
55
|
-
|
|
71
|
+
|
|
56
72
|
})
|
|
57
73
|
await doc.minifyHTML()
|
|
58
74
|
doc.remapMedia()
|
|
@@ -60,13 +76,18 @@ export async function buildStaticPages(config) {
|
|
|
60
76
|
await doc.inlineMinifiedCSS()
|
|
61
77
|
await doc.inlineMinifiedJS()
|
|
62
78
|
write(join(pDist, route + config.outputExtension), doc.html)
|
|
63
|
-
|
|
79
|
+
|
|
80
|
+
const r = route === '/index' ? '/' : route
|
|
81
|
+
headers[r] ??= []
|
|
82
|
+
headers[r].push(['Content-Security-Policy', doc.csp()])
|
|
83
|
+
for (const h of config.routeHeaders)
|
|
84
|
+
headers[r].push(h)
|
|
64
85
|
}
|
|
65
86
|
|
|
66
87
|
sitemapPlugin(config, docs.routes)
|
|
67
88
|
reportSizesPlugin(config, docs.routes)
|
|
68
89
|
cspNginxMapPlugin(config, cspByRoute)
|
|
69
|
-
|
|
90
|
+
write(join(config.outputDir, '_headers'), netiflyAndCloudflareHeadersPlugin(headers))
|
|
70
91
|
}
|
|
71
92
|
catch (error) {
|
|
72
93
|
reject(error)
|
package/src/config.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// We register this hook at runtime so it doesn’t interfere with non-dynamic imports.
|
|
2
2
|
export async function resolve(specifier, context, nextResolve) {
|
|
3
|
-
const result = await nextResolve(specifier, context)
|
|
3
|
+
const result = await nextResolve(specifier, context)
|
|
4
4
|
if (result.url?.startsWith('file:')) {
|
|
5
5
|
const url = new URL(result.url)
|
|
6
6
|
url.searchParams.set('t', performance.now())
|
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
const WATCH_API = '/packaton/watch-dev'
|
|
2
2
|
|
|
3
|
-
let
|
|
3
|
+
let conn = null
|
|
4
4
|
let timer = null
|
|
5
5
|
|
|
6
6
|
window.addEventListener('beforeunload', teardown)
|
|
7
7
|
connect()
|
|
8
8
|
function connect() {
|
|
9
|
-
if (
|
|
9
|
+
if (conn)
|
|
10
|
+
return
|
|
10
11
|
|
|
11
12
|
clearTimeout(timer)
|
|
12
|
-
|
|
13
|
+
conn = new EventSource(WATCH_API)
|
|
13
14
|
|
|
14
|
-
|
|
15
|
+
conn.onmessage = function (event) {
|
|
15
16
|
const file = event.data
|
|
16
17
|
if (file.endsWith('.css'))
|
|
17
18
|
hotReloadCSS(file)
|
|
@@ -19,7 +20,7 @@ function connect() {
|
|
|
19
20
|
location.reload()
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
|
|
23
|
+
conn.onerror = function () {
|
|
23
24
|
console.error('hot reload')
|
|
24
25
|
teardown()
|
|
25
26
|
timer = setTimeout(connect, 3000)
|
|
@@ -28,8 +29,8 @@ function connect() {
|
|
|
28
29
|
|
|
29
30
|
function teardown() {
|
|
30
31
|
clearTimeout(timer)
|
|
31
|
-
|
|
32
|
-
|
|
32
|
+
conn?.close()
|
|
33
|
+
conn = null
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
async function hotReloadCSS(file) {
|
|
@@ -72,7 +72,7 @@ export class HtmlCompiler {
|
|
|
72
72
|
this.scriptsModuleJs = await Promise.all(scripts
|
|
73
73
|
.filter(([type]) => type === 'module')
|
|
74
74
|
.map(([, body]) => this.#minifyJS(body, Boolean('isModule'))))
|
|
75
|
-
|
|
75
|
+
|
|
76
76
|
this.scriptsNonJs = scripts
|
|
77
77
|
.filter(([type]) => type !== 'application/javascript' && type !== 'module')
|
|
78
78
|
|
|
@@ -89,13 +89,13 @@ export class HtmlCompiler {
|
|
|
89
89
|
|
|
90
90
|
csp() {
|
|
91
91
|
const cssHash = this.css ? `'${this.hash256(this.css)}'` : '' // TODO maybe self?
|
|
92
|
-
|
|
92
|
+
|
|
93
93
|
const jsScriptHash = this.scriptsJs ? `'${this.hash256(this.scriptsJs)}'` : '' // TODO maybe self?
|
|
94
94
|
const jsModulesHashes = this.scriptsModuleJs.map(body => `'${this.hash256(body)}'`).join(' ')
|
|
95
95
|
const nonJsScriptHashes = this.scriptsNonJs.map(([, body]) => `'${this.hash256(body)}'`).join(' ')
|
|
96
96
|
const inlineScriptsHashes = this.extractInlineScripts().map(body => `'${this.hash256(body)}'`).join(' ')
|
|
97
97
|
const externalScriptDomains = this.externalScripts.map(url => `${new URL(url).origin}`).join(' ')
|
|
98
|
-
|
|
98
|
+
|
|
99
99
|
return [
|
|
100
100
|
`default-src 'self'`,
|
|
101
101
|
`img-src 'self' data:`, // data: is for Safari's video player icons and for CSS bg images
|
|
@@ -121,7 +121,7 @@ export class HtmlCompiler {
|
|
|
121
121
|
this.html = this.html.replace(new RegExp('^.*' + str + '.*\n', 'm'), '')
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
|
|
124
|
+
|
|
125
125
|
extractStyleSheetHrefs() {
|
|
126
126
|
const reExtractStyleSheets = /(?<=<link\s.*href=")[^"]+\.css/g
|
|
127
127
|
return Array.from(this.html.matchAll(reExtractStyleSheets), m => m[0])
|
|
@@ -147,14 +147,14 @@ export class HtmlCompiler {
|
|
|
147
147
|
]
|
|
148
148
|
})
|
|
149
149
|
}
|
|
150
|
-
|
|
150
|
+
|
|
151
151
|
extractInlineScripts() {
|
|
152
152
|
const reExtractInlineScripts = /<script\b([^>]*?)>([\s\S]*?)<\/script>/g
|
|
153
153
|
return Array.from(this.html.matchAll(reExtractInlineScripts), m => {
|
|
154
154
|
const attrs = m[1]
|
|
155
155
|
const body = m[2]
|
|
156
|
-
return attrs.includes('src=')
|
|
157
|
-
? null
|
|
156
|
+
return attrs.includes('src=')
|
|
157
|
+
? null
|
|
158
158
|
: body
|
|
159
159
|
}).filter(Boolean)
|
|
160
160
|
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* You can override this minifier in `config.minifyCSS`
|
|
3
|
-
*
|
|
3
|
+
*
|
|
4
4
|
* This program is an oversimplified CSS minifier. It doesn’t
|
|
5
5
|
* try to minify everything, but only what’s safe and easy to minify.
|
|
6
|
-
*
|
|
6
|
+
*
|
|
7
7
|
* Why?
|
|
8
|
-
* When I wrote this program, ~2018, some CSS minifiers reordered rules
|
|
8
|
+
* When I wrote this program, ~2018, some CSS minifiers reordered rules
|
|
9
9
|
* but that messed up browser-specific prefixes that were used as workarounds.
|
|
10
10
|
*/
|
|
11
11
|
|
|
@@ -6,15 +6,15 @@
|
|
|
6
6
|
* It's based on https://gist.github.com/espretto/1b3cb7e8b01fa7daaaac
|
|
7
7
|
*
|
|
8
8
|
* Why?
|
|
9
|
-
* When I wrote this program, ~2018, I tried a few libraries but some
|
|
9
|
+
* When I wrote this program, ~2018, I tried a few libraries but some
|
|
10
10
|
* of them messed up relevant spaces in `<pre>` tags and between tags.
|
|
11
11
|
*
|
|
12
12
|
* We don’t remove newlines because for example `<kbd>` and `<a>`
|
|
13
13
|
* would need special rules to have a space in-place of that newline.
|
|
14
14
|
*
|
|
15
|
-
*
|
|
15
|
+
*
|
|
16
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,
|
|
17
|
+
* replaces them with a known magic string "<preserved>". Then, at the end,
|
|
18
18
|
* replaces, in order, those magic strings with the original tag and its content.
|
|
19
19
|
*/
|
|
20
20
|
|
|
@@ -1,28 +1,10 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
*/
|
|
10
|
-
export function netiflyAndCloudflareHeadersPlugin(config, cspByRoute, relMediaURL) {
|
|
11
|
-
const out = join(join(config.outputDir, config.assetsDir), '_headers')
|
|
12
|
-
|
|
13
|
-
const cspHeaders = cspByRoute.map(([route, csp]) => {
|
|
14
|
-
const r = route === '/index'
|
|
15
|
-
? '/'
|
|
16
|
-
: route
|
|
17
|
-
return [
|
|
18
|
-
r,
|
|
19
|
-
` Content-Security-Policy: ${csp}`,
|
|
20
|
-
` Cache-Control: public,max-age=60`
|
|
21
|
-
].join('\n')
|
|
22
|
-
})
|
|
23
|
-
cspHeaders.push(`/${relMediaURL}/*`)
|
|
24
|
-
cspHeaders.push(' Cache-Control: public,max-age=31536000,immutable')
|
|
25
|
-
|
|
26
|
-
write(out, cspHeaders.join('\n'))
|
|
1
|
+
export function netiflyAndCloudflareHeadersPlugin(opts) {
|
|
2
|
+
let result = []
|
|
3
|
+
for (const [route, headers] of Object.entries(opts)) {
|
|
4
|
+
result.push(route)
|
|
5
|
+
for (const [h, v] of headers)
|
|
6
|
+
result.push(` ${h}: ${v}`)
|
|
7
|
+
}
|
|
8
|
+
return result.join('\n')
|
|
27
9
|
}
|
|
28
10
|
|
|
@@ -3,18 +3,20 @@ import { write, isFile } from '../utils/fs-utils.js'
|
|
|
3
3
|
|
|
4
4
|
|
|
5
5
|
export function sitemapPlugin(config, routes) {
|
|
6
|
-
if (!config.sitemapDomain)
|
|
6
|
+
if (!config.sitemapDomain)
|
|
7
7
|
return
|
|
8
|
-
|
|
8
|
+
|
|
9
9
|
const outMap = join(config.outputDir, 'sitemap.txt')
|
|
10
10
|
const outRobots = join(config.outputDir, 'robots.txt')
|
|
11
|
-
|
|
11
|
+
|
|
12
12
|
write(outMap, routes
|
|
13
13
|
.filter(r => r !== '/index')
|
|
14
14
|
.map(r => `https://${config.sitemapDomain + r}`)
|
|
15
15
|
.join('\n'))
|
|
16
16
|
|
|
17
17
|
if (!isFile(outRobots))
|
|
18
|
-
write(outRobots,
|
|
19
|
-
|
|
18
|
+
write(outRobots, `
|
|
19
|
+
User-agent: *
|
|
20
|
+
Sitemap: https://${config.sitemapDomain}/sitemap.txt
|
|
21
|
+
`.trim())
|
|
20
22
|
}
|
package/src/utils/fs-utils.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readdir } from 'node:fs/promises'
|
|
2
2
|
import { createHash } from 'node:crypto'
|
|
3
3
|
import { join, dirname } from 'node:path'
|
|
4
|
-
import { rmSync, lstatSync, mkdirSync, readFileSync, writeFileSync
|
|
4
|
+
import { rmSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
export const read = f => readFileSync(f, 'utf8')
|
|
@@ -16,11 +16,11 @@ export function write(fname, data) {
|
|
|
16
16
|
writeFileSync(fname, data, 'utf8')
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
export function removeDir(dir) {
|
|
20
|
-
rmSync(dir, {
|
|
21
|
-
recursive: true,
|
|
19
|
+
export function removeDir(dir) {
|
|
20
|
+
rmSync(dir, {
|
|
21
|
+
recursive: true,
|
|
22
22
|
force: true // allows for removing non-existing directories
|
|
23
|
-
})
|
|
23
|
+
})
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
export const sizeOf = f => lstatSync(f).size
|
|
@@ -4,18 +4,18 @@ import { replaceExt } from './fs-utils.js'
|
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
describe('replaceExt', () => {
|
|
7
|
-
test('replaces a simple extension', () =>
|
|
7
|
+
test('replaces a simple extension', () =>
|
|
8
8
|
equal(replaceExt('file.txt', 'md'), 'file.md'))
|
|
9
9
|
|
|
10
|
-
test('replaces a multi-part extension', () =>
|
|
10
|
+
test('replaces a multi-part extension', () =>
|
|
11
11
|
equal(replaceExt('archive.tar.gz', 'zip'), 'archive.tar.zip'))
|
|
12
12
|
|
|
13
|
-
test('adds extension when none exists', () =>
|
|
13
|
+
test('adds extension when none exists', () =>
|
|
14
14
|
equal(replaceExt('README', 'md'), 'README.md'))
|
|
15
15
|
|
|
16
16
|
test('handles empty filename', () =>
|
|
17
17
|
equal(replaceExt('', 'ext'), '.ext'))
|
|
18
|
-
|
|
18
|
+
|
|
19
19
|
test('handles dot-files', () =>
|
|
20
20
|
equal(replaceExt('.env', 'txt'), '.env.txt'))
|
|
21
21
|
})
|