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/.editorconfig
ADDED
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2022 Eric Fortis
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Packaton WIP
|
|
2
|
+
|
|
3
|
+
Static Pages Bundler.
|
|
4
|
+
|
|
5
|
+
## HTML Template
|
|
6
|
+
Optionally, you can create an HTML template.
|
|
7
|
+
For example, to handle the common header, navigation, and footer.
|
|
8
|
+
|
|
9
|
+
## Assets and CSP
|
|
10
|
+
The production bundler inlines the JavaScript and CSS. Also, it
|
|
11
|
+
computes their corresponding CSP nonce and injects it as well.
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
## Images and Videos (immutable naming)
|
|
15
|
+
For long-term caching, [media-remaper.js](src/media-remaper.js) appends a SHA-1 hash
|
|
16
|
+
to the filenames and takes care of rewriting their `src` in HTML (**only in HTML**).
|
|
17
|
+
|
|
18
|
+
If you want to use media files in CSS, create a similar function to
|
|
19
|
+
`remapMediaInHTML` but with a regex for replacing the `url(...)` content.
|
|
20
|
+
|
|
21
|
+
## Production Build
|
|
22
|
+
It crawls the dev server, and saves each route as static html page.
|
|
23
|
+
It saves the pages without the `.html` extension for pretty URLs.
|
|
24
|
+
See [Pretty routes for static HTML](https://blog.uxtly.com/pretty-routes-for-static-html)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
## Minifiers
|
|
29
|
+
|
|
30
|
+
```js
|
|
31
|
+
import { minify } from 'terser'
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
const terserOptions = {}
|
|
35
|
+
|
|
36
|
+
Packaton({
|
|
37
|
+
minifyJS: async js => (await minify(js, terserOptions)).code
|
|
38
|
+
})
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
To avoid minifying, you can pass `a=>a`
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
## Caveats
|
|
45
|
+
- can't write inline scripts or css (all must be in an external file, packaton inlines them)
|
|
46
|
+
- must have an index
|
|
47
|
+
- Ignored Documents start with `_`, so you can't have routes that begin with _
|
|
48
|
+
- Non-Documents and Files outside .media are not automatically copied over,
|
|
49
|
+
you need to specify them.
|
|
50
|
+
- src/media only files at the top level get hashed-named. But files within subdirs are not (by design).
|
package/TODO.md
ADDED
package/index.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface Config {
|
|
2
|
+
mode?: 'development' | 'production';
|
|
3
|
+
srcPath?: string
|
|
4
|
+
ignore?: RegExp
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
// Dev
|
|
8
|
+
host?: string,
|
|
9
|
+
port?: number
|
|
10
|
+
onReady?: (address: string) => void
|
|
11
|
+
hotReload?: boolean // For UI dev purposes only
|
|
12
|
+
|
|
13
|
+
// Production
|
|
14
|
+
outputPath?: string
|
|
15
|
+
minifyJS?: (js: string) => Promise<string>
|
|
16
|
+
minifyCSS?: (css: string) => Promise<string>
|
|
17
|
+
minifyHTML?: (html: string) => Promise<string>
|
|
18
|
+
sitemapDomain?: string
|
|
19
|
+
cspMapEnabled?: boolean
|
|
20
|
+
}
|
|
21
|
+
|
package/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { Packaton } from './src/app.js'
|
|
2
|
+
export { HtmlCompiler } from './src/HtmlCompiler.js'
|
|
3
|
+
export { minifyHTML } from './src/minifyHTML.js'
|
|
4
|
+
export { minifyCSS } from './src/minifyCSS.js'
|
|
5
|
+
export { minifyJS } from './src/minifyJS.js'
|
|
6
|
+
export { reportSizes } from './src/reportSizes.js'
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "packaton",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"author": "Eric Fortis",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"test": "node --test",
|
|
9
|
+
"outdated": "npm outdated --parseable | awk -F: '{ printf \"npm i %-30s ;# %s\\n\", $4, $2 }'"
|
|
10
|
+
},
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"import": "./index.js",
|
|
14
|
+
"types": "./index.d.ts"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"peerDependencies": {
|
|
18
|
+
"terser": "^5"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"terser": "5.44.1"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
|
|
4
|
+
import { read } from './fs-utils.js'
|
|
5
|
+
import { remapMediaInHTML } from './media-remaper.js'
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
export class HtmlCompiler {
|
|
9
|
+
html = ''
|
|
10
|
+
pSource = ''
|
|
11
|
+
css = ''
|
|
12
|
+
scriptsJs = ''
|
|
13
|
+
scriptsNonJs = ''
|
|
14
|
+
externalScripts = []
|
|
15
|
+
externalCSS = []
|
|
16
|
+
#minifyJS = a => a
|
|
17
|
+
#minifyCSS = a => a
|
|
18
|
+
#minifyHTML = a => a
|
|
19
|
+
|
|
20
|
+
constructor(html, pSource = '', { minifyJS, minifyCSS, minifyHTML }) {
|
|
21
|
+
this.html = html
|
|
22
|
+
this.pSource = pSource
|
|
23
|
+
this.#minifyJS = minifyJS
|
|
24
|
+
this.#minifyCSS = minifyCSS
|
|
25
|
+
this.#minifyHTML = minifyHTML
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Removes comments and format multi-line tags (needed for `removeLineContaining`)
|
|
29
|
+
async minifyHTML() {
|
|
30
|
+
this.html = await this.#minifyHTML(this.html)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
remapMedia(mediaHashes) {
|
|
34
|
+
this.html = remapMediaInHTML(mediaHashes, this.html)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async inlineMinifiedCSS() {
|
|
38
|
+
for (const sheet of this.extractStyleSheetHrefs()) {
|
|
39
|
+
if (sheet.startsWith('http')) // TODO clean
|
|
40
|
+
this.externalCSS.push(sheet)
|
|
41
|
+
else {
|
|
42
|
+
this.css += read(join(this.pSource, sheet))
|
|
43
|
+
this.removeLineContaining(`href="${sheet}"`)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (this.css) {
|
|
47
|
+
this.css = await this.#minifyCSS(this.css)
|
|
48
|
+
this.html = this.html.replace('<head>', `<head><style>${this.css}</style>`)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async inlineMinifiedJS() {
|
|
53
|
+
const scripts = []
|
|
54
|
+
for (const [src, type] of this.extractScriptSources()) {
|
|
55
|
+
if (src.startsWith('http')) // TODO clean
|
|
56
|
+
this.externalScripts.push(src)
|
|
57
|
+
else {
|
|
58
|
+
this.removeLineContaining(`src="${src}"`)
|
|
59
|
+
scripts.push([type, read(join(this.pSource, src))])
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
this.scriptsJs = await this.#minifyJS(scripts
|
|
64
|
+
.filter(([type]) => type === 'application/javascript')
|
|
65
|
+
.map(([, body]) => body)
|
|
66
|
+
.join('\n'))
|
|
67
|
+
|
|
68
|
+
this.scriptsNonJs = scripts
|
|
69
|
+
.filter(([type]) => type !== 'application/javascript')
|
|
70
|
+
|
|
71
|
+
if (this.scriptsJs)
|
|
72
|
+
this.html = this.html.replace('</body>', `<script>${this.scriptsJs}</script></body>`)
|
|
73
|
+
|
|
74
|
+
for (const [type, body] of this.scriptsNonJs)
|
|
75
|
+
this.html = this.html.replace('</body>', `\n<script type="${type}">${body}</script></body>`)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
csp() {
|
|
79
|
+
const cssHash = this.css
|
|
80
|
+
? `'${this.hash256(this.css)}'`
|
|
81
|
+
: '' // TODO maybe self?
|
|
82
|
+
const jsScriptHash = this.scriptsJs
|
|
83
|
+
? `'${this.hash256(this.scriptsJs)}'`
|
|
84
|
+
: '' // TODO maybe self?
|
|
85
|
+
const nonJsScriptHashes = this.scriptsNonJs
|
|
86
|
+
.map(([, body]) => `'${this.hash256(body)}'`).join(' ')
|
|
87
|
+
const externalScriptHashes = this.externalScripts.map(url => `${new URL(url).origin}`).join(' ')
|
|
88
|
+
return [
|
|
89
|
+
`default-src 'self'`,
|
|
90
|
+
`img-src 'self' data:`, // data: is for Safari's video player icons and for CSS bg images
|
|
91
|
+
`style-src ${cssHash}`,
|
|
92
|
+
`script-src ${nonJsScriptHashes} ${jsScriptHash} ${externalScriptHashes}`,
|
|
93
|
+
`frame-ancestors 'none'`
|
|
94
|
+
].join('; ')
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
hash256(data) {
|
|
98
|
+
return data
|
|
99
|
+
? 'sha256-' + createHash('sha256').update(data).digest('base64')
|
|
100
|
+
: ''
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
removeLineContaining(str) {
|
|
104
|
+
this.html = this.html.replace(new RegExp('^.*' + str + '.*\n', 'm'), '')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
extractStyleSheetHrefs() {
|
|
108
|
+
const reExtractStyleSheets = /(?<=<link\s.*href=")[^"]+\.css/g
|
|
109
|
+
return Array.from(this.html.matchAll(reExtractStyleSheets), m => m[0])
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* NOTE: Run minifyHTML first, because using any of these functions
|
|
115
|
+
* because they don't support tags within comments nor multiline ones.
|
|
116
|
+
* @returns {[src:string, type:string][]}
|
|
117
|
+
*/
|
|
118
|
+
extractScriptSources() {
|
|
119
|
+
const reExtractScripts = /<script\b([^>]*?)src="([^"]+)"([^>]*)>/g
|
|
120
|
+
return Array.from(this.html.matchAll(reExtractScripts), m => {
|
|
121
|
+
const pre = m[1]
|
|
122
|
+
const post = m[3]
|
|
123
|
+
const typeMatch = (pre + post).match(/type="([^"]+)"/)
|
|
124
|
+
return [
|
|
125
|
+
m[2], // src
|
|
126
|
+
typeMatch
|
|
127
|
+
? typeMatch[1]
|
|
128
|
+
: 'application/javascript'
|
|
129
|
+
]
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { deepEqual } from 'node:assert/strict'
|
|
2
|
+
import { describe, test } from 'node:test'
|
|
3
|
+
import { HtmlCompiler } from './HtmlCompiler.js'
|
|
4
|
+
import { minifyJS } from './minifyJS.js'
|
|
5
|
+
import { minifyCSS } from './minifyCSS.js'
|
|
6
|
+
import { minifyHTML } from './minifyHTML.js'
|
|
7
|
+
|
|
8
|
+
const opts = {
|
|
9
|
+
minifyJS,
|
|
10
|
+
minifyCSS,
|
|
11
|
+
minifyHTML
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('HtmlCompiler', () => {
|
|
15
|
+
const HTML = `
|
|
16
|
+
<html lang="en">
|
|
17
|
+
<head>
|
|
18
|
+
<link rel="stylesheet" href="0.css">
|
|
19
|
+
<link href="../1.css" rel="stylesheet" >
|
|
20
|
+
<link href="2.css" rel="stylesheet">
|
|
21
|
+
<link rel="dns-prefetch" href="https://my.uxtly.com">
|
|
22
|
+
</head>
|
|
23
|
+
<body>
|
|
24
|
+
|
|
25
|
+
<footer> Footer </footer>
|
|
26
|
+
|
|
27
|
+
<script src="0.js"></script>
|
|
28
|
+
<script src="../1.js" ></script>
|
|
29
|
+
<script type="module" src="2.js"></script>
|
|
30
|
+
<script src="3.json" type="speculationrules"></script>
|
|
31
|
+
</body>
|
|
32
|
+
</html>
|
|
33
|
+
`
|
|
34
|
+
|
|
35
|
+
test('Extracts CSS files', () =>
|
|
36
|
+
deepEqual(new HtmlCompiler(HTML, '', opts).extractStyleSheetHrefs(), [
|
|
37
|
+
'0.css', '../1.css', '2.css'
|
|
38
|
+
]))
|
|
39
|
+
|
|
40
|
+
test('Extracts Script files', () =>
|
|
41
|
+
deepEqual(new HtmlCompiler(HTML, '', opts).extractScriptSources(), [
|
|
42
|
+
['0.js', 'application/javascript'],
|
|
43
|
+
['../1.js', 'application/javascript'],
|
|
44
|
+
['2.js', 'module'],
|
|
45
|
+
['3.json', 'speculationrules']
|
|
46
|
+
]))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
test('Removes line containing X', () => {
|
|
50
|
+
const doc = new HtmlCompiler(HTML, '', opts)
|
|
51
|
+
doc.removeLineContaining('href="0.css"')
|
|
52
|
+
deepEqual(doc.html, `
|
|
53
|
+
<html lang="en">
|
|
54
|
+
<head>
|
|
55
|
+
<link href="../1.css" rel="stylesheet" >
|
|
56
|
+
<link href="2.css" rel="stylesheet">
|
|
57
|
+
<link rel="dns-prefetch" href="https://my.uxtly.com">
|
|
58
|
+
</head>
|
|
59
|
+
<body>
|
|
60
|
+
|
|
61
|
+
<footer> Footer </footer>
|
|
62
|
+
|
|
63
|
+
<script src="0.js"></script>
|
|
64
|
+
<script src="../1.js" ></script>
|
|
65
|
+
<script type="module" src="2.js"></script>
|
|
66
|
+
<script src="3.json" type="speculationrules"></script>
|
|
67
|
+
</body>
|
|
68
|
+
</html>
|
|
69
|
+
`)
|
|
70
|
+
})
|
|
71
|
+
})
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
import { watch } from 'node:fs'
|
|
3
|
+
import { EventEmitter } from 'node:events'
|
|
4
|
+
import { docs } from './app.js'
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
export const devClientWatcher = new class extends EventEmitter {
|
|
8
|
+
emit(file) { super.emit('RELOAD', file) }
|
|
9
|
+
subscribe(listener) { this.once('RELOAD', listener) }
|
|
10
|
+
unsubscribe(listener) { this.removeListener('RELOAD', listener) }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function watchDev(rootPath) {
|
|
14
|
+
watch(rootPath, { recursive: true }, (_, file) => {
|
|
15
|
+
docs.onWatch(join(rootPath, file))
|
|
16
|
+
devClientWatcher.emit(file)
|
|
17
|
+
})
|
|
18
|
+
}
|
package/src/app-dev.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import http from 'node:http'
|
|
2
|
+
|
|
3
|
+
import { router } from './app-router.js'
|
|
4
|
+
import { watchDev } from './WatcherDevClient.js'
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @param {Partial<Config>} config
|
|
9
|
+
* @returns {Promise<Server | undefined>}
|
|
10
|
+
*/
|
|
11
|
+
export function devStaticPages(config) {
|
|
12
|
+
return new Promise((resolve, reject) => {
|
|
13
|
+
if (config.hotReload)
|
|
14
|
+
watchDev(config.srcPath)
|
|
15
|
+
|
|
16
|
+
const server = http.createServer(router(config))
|
|
17
|
+
server.on('error', reject)
|
|
18
|
+
server.listen(config.port, config.host, () => {
|
|
19
|
+
const addr = `http://${server.address().address}:${server.address().port}`
|
|
20
|
+
config.onReady(addr)
|
|
21
|
+
console.log(addr)
|
|
22
|
+
resolve(server)
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
|
package/src/app-prod.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import http from 'node:http'
|
|
2
|
+
import { cpSync } from 'node:fs'
|
|
3
|
+
import { basename, join, dirname } from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { docs } from './app.js'
|
|
6
|
+
import { router } from './app-router.js'
|
|
7
|
+
import { reportSizes } from './reportSizes.js'
|
|
8
|
+
import { HtmlCompiler } from './HtmlCompiler.js'
|
|
9
|
+
import { write, removeDir } from './fs-utils.js'
|
|
10
|
+
import { renameMediaWithHashes } from './media-remaper.js'
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {Partial<Config>} opts
|
|
15
|
+
* @returns {Promise<unknown>}
|
|
16
|
+
*/
|
|
17
|
+
export async function buildStaticPages(config) {
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
const pSource = config.srcPath
|
|
20
|
+
const pDist = config.outputPath
|
|
21
|
+
const pDistMedia = join(config.outputPath, 'media')
|
|
22
|
+
const pDistSitemap = join(pDist, 'sitemap.txt')
|
|
23
|
+
const pDistCspNginxMap = join(pDist, '.csp-map.nginx')
|
|
24
|
+
const pSizesReport = 'packed-sizes.json'
|
|
25
|
+
|
|
26
|
+
const server = http.createServer(router(config))
|
|
27
|
+
server.listen(0, '127.0.0.1', async error => {
|
|
28
|
+
docs.init(config.srcPath, config.ignore)
|
|
29
|
+
try {
|
|
30
|
+
if (error) {
|
|
31
|
+
reject(error)
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
removeDir(pDist)
|
|
36
|
+
cpSync(join(pSource, 'media'), pDistMedia, {
|
|
37
|
+
recursive: true,
|
|
38
|
+
dereference: true,
|
|
39
|
+
filter(src) {
|
|
40
|
+
const f = basename(src)
|
|
41
|
+
return f !== '.DS_Store' && !f.startsWith('_')
|
|
42
|
+
// TODO
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
const mediaHashes = await renameMediaWithHashes(pDistMedia) // only on top dir
|
|
47
|
+
const pages = await crawlRoutes(server.address(), docs.routes)
|
|
48
|
+
|
|
49
|
+
const cspByRoute = []
|
|
50
|
+
for (const [route, rawHtml] of pages) {
|
|
51
|
+
const doc = new HtmlCompiler(rawHtml, join(pSource, dirname(route)), config)
|
|
52
|
+
await doc.minifyHTML()
|
|
53
|
+
doc.remapMedia(mediaHashes)
|
|
54
|
+
// TODO remap media in css and js
|
|
55
|
+
await doc.inlineMinifiedCSS()
|
|
56
|
+
await doc.inlineMinifiedJS()
|
|
57
|
+
write(pDist + route, doc.html)
|
|
58
|
+
cspByRoute.push([route, doc.csp()])
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (config.sitemapDomain)
|
|
62
|
+
write(pDistSitemap, docs.routes
|
|
63
|
+
.filter(r => r !== '/index')
|
|
64
|
+
.map(r => `https://${config.sitemapDomain + r}`)
|
|
65
|
+
.join('\n'))
|
|
66
|
+
|
|
67
|
+
if (config.cspMapEnabled)
|
|
68
|
+
write(pDistCspNginxMap, cspByRoute.map(([route, csp]) =>
|
|
69
|
+
`${route} "${csp}";`).join('\n'))
|
|
70
|
+
|
|
71
|
+
reportSizes(pSizesReport, pDist, docs.routes)
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
reject(error)
|
|
75
|
+
console.error(error)
|
|
76
|
+
process.exitCode = 1
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
server.close()
|
|
80
|
+
resolve()
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
async function crawlRoutes({ address, port }, routes) {
|
|
88
|
+
const pages = []
|
|
89
|
+
for (const route of routes)
|
|
90
|
+
try {
|
|
91
|
+
const resp = await fetch(`http://${address}:${port}` + route)
|
|
92
|
+
if (!resp.ok) throw resp.statusText
|
|
93
|
+
pages.push([route, await resp.text()])
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
pages.push([route, ''])
|
|
97
|
+
console.warn(`Route: ${route} ${error?.message || error}`)
|
|
98
|
+
}
|
|
99
|
+
return pages
|
|
100
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
import { readFile } from 'node:fs/promises'
|
|
3
|
+
|
|
4
|
+
import { docs } from './app.js'
|
|
5
|
+
import { mimeFor } from './mimes.js'
|
|
6
|
+
import { devClientWatcher } from './WatcherDevClient.js'
|
|
7
|
+
import { sendError, sendJSON, servePartialContent, serveStaticAsset } from './http-response.js'
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
const API = {
|
|
11
|
+
watchDev: '/packaton/watch-dev'
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const WATCHER_DEV = '/watcherDev.js'
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
/** @param {Config} config */
|
|
18
|
+
export function router({ srcPath, ignore, mode }) {
|
|
19
|
+
docs.init(srcPath, ignore)
|
|
20
|
+
return async function (req, response) {
|
|
21
|
+
let url = new URL(req.url, 'http://_').pathname
|
|
22
|
+
try {
|
|
23
|
+
if (url === API.watchDev) {
|
|
24
|
+
longPollDevHotReload(req, response)
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
if (url === WATCHER_DEV) {
|
|
28
|
+
serveStaticAsset(response, join(import.meta.dirname, url))
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (url === '/')
|
|
33
|
+
url = '/index'
|
|
34
|
+
|
|
35
|
+
const file = join(srcPath, url)
|
|
36
|
+
if (docs.hasRoute(url))
|
|
37
|
+
await serveDocument(response, docs.fileFor(url), mode === 'development')
|
|
38
|
+
else if (req.headers.range)
|
|
39
|
+
await servePartialContent(response, req.headers, file)
|
|
40
|
+
else
|
|
41
|
+
serveStaticAsset(response, file)
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
sendError(response, error)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function serveDocument(response, file, isDev) {
|
|
50
|
+
let html = file.endsWith('.html')
|
|
51
|
+
? await readFile(file, 'utf8')
|
|
52
|
+
: (await import(file + '?' + Date.now())).default()
|
|
53
|
+
if (isDev)
|
|
54
|
+
html += `<script type="module" src="${WATCHER_DEV}"></script>`
|
|
55
|
+
response.setHeader('Content-Type', mimeFor('html'))
|
|
56
|
+
response.end(html)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
const LONG_POLL_SERVER_TIMEOUT = 8000
|
|
61
|
+
|
|
62
|
+
function longPollDevHotReload(req, response) {
|
|
63
|
+
function onDevChange(file) {
|
|
64
|
+
devClientWatcher.unsubscribe(onDevChange)
|
|
65
|
+
sendJSON(response, file)
|
|
66
|
+
}
|
|
67
|
+
response.setTimeout(LONG_POLL_SERVER_TIMEOUT, () => {
|
|
68
|
+
devClientWatcher.unsubscribe(onDevChange)
|
|
69
|
+
sendJSON(response, '')
|
|
70
|
+
})
|
|
71
|
+
req.on('error', () => {
|
|
72
|
+
devClientWatcher.unsubscribe(onDevChange)
|
|
73
|
+
response.destroy()
|
|
74
|
+
})
|
|
75
|
+
devClientWatcher.subscribe(onDevChange)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
package/src/app.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { readdirSync } from 'node:fs'
|
|
2
|
+
import { basename, join, dirname } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { setup } from './config.js'
|
|
5
|
+
import { isFile } from './fs-utils.js'
|
|
6
|
+
import { devStaticPages } from './app-dev.js'
|
|
7
|
+
import { buildStaticPages } from './app-prod.js'
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {Partial<Config>} opts
|
|
12
|
+
* @returns {Promise<Server | undefined>}
|
|
13
|
+
*/
|
|
14
|
+
export function Packaton(opts) {
|
|
15
|
+
const config = setup(opts)
|
|
16
|
+
return config.mode === 'development'
|
|
17
|
+
? devStaticPages(config)
|
|
18
|
+
: buildStaticPages(config)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
export const docs = new class {
|
|
23
|
+
#srcPath = ''
|
|
24
|
+
#ignore = /.*/
|
|
25
|
+
#extensions = ['.html', '.html.js', '.html.ts']
|
|
26
|
+
#delay = Number(process.env.PACKATON_WATCHER_DEBOUNCE_MS ?? 80)
|
|
27
|
+
|
|
28
|
+
#routeToFileMap = new Map()
|
|
29
|
+
init(srcPath, ignore) {
|
|
30
|
+
this.#srcPath = srcPath
|
|
31
|
+
this.#ignore = ignore
|
|
32
|
+
this.#registerDocs()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
get routes() { return Array.from(this.#routeToFileMap.keys()) }
|
|
36
|
+
fileFor = url => this.#routeToFileMap.get(url)
|
|
37
|
+
hasRoute = url => this.#routeToFileMap.has(url)
|
|
38
|
+
|
|
39
|
+
onWatch = /** @type {function} */ this.#debounce(f => {
|
|
40
|
+
if (this.#hasDocExt(f) && !this.hasRoute(this.#routeFor(f)) && isFile(f))
|
|
41
|
+
this.#registerDocs()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
#registerDocs() {
|
|
45
|
+
const files = readdirSync(this.#srcPath, { recursive: true })
|
|
46
|
+
.filter(f => this.#hasDocExt(f) && !this.#ignore.test(f))
|
|
47
|
+
|
|
48
|
+
this.#routeToFileMap = new Map(files.map(f => [
|
|
49
|
+
this.#routeFor(f),
|
|
50
|
+
join(this.#srcPath, f)
|
|
51
|
+
]))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
#routeFor = f => '/' + join(dirname(f), this.#removeDocExt(f))
|
|
55
|
+
#hasDocExt = f => this.#extensions.some(ext => f.endsWith(ext))
|
|
56
|
+
#findDocExt = f => this.#extensions.find(ext => f.endsWith(ext))
|
|
57
|
+
#removeDocExt = f => basename(f, this.#findDocExt(f))
|
|
58
|
+
|
|
59
|
+
#debounce(fn) {
|
|
60
|
+
let timer
|
|
61
|
+
return arg => {
|
|
62
|
+
clearTimeout(timer)
|
|
63
|
+
timer = setTimeout(() => fn(arg), this.#delay)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|