mothwing 0.0.0-stage → 0.0.2
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/LICENSE +21 -0
- package/README.md +160 -3
- package/bin/mothwing.js +45 -0
- package/package.json +73 -6
- package/src/assets.js +122 -0
- package/src/build.js +104 -0
- package/src/config.js +206 -0
- package/src/csp.js +21 -0
- package/src/data.js +54 -0
- package/src/deps.js +76 -0
- package/src/dev.js +145 -0
- package/src/html.js +67 -0
- package/src/index.js +20 -0
- package/src/log.js +24 -0
- package/src/render.js +162 -0
- package/src/routes.js +125 -0
- package/src/seo.js +105 -0
- package/src/site.js +98 -0
- package/src/template.js +207 -0
- package/types/assets.d.ts +23 -0
- package/types/build.d.ts +34 -0
- package/types/config.d.ts +240 -0
- package/types/csp.d.ts +8 -0
- package/types/data.d.ts +18 -0
- package/types/deps.d.ts +10 -0
- package/types/dev.d.ts +9 -0
- package/types/html.d.ts +12 -0
- package/types/index.d.ts +17 -0
- package/types/log.d.ts +6 -0
- package/types/render.d.ts +42 -0
- package/types/routes.d.ts +39 -0
- package/types/seo.d.ts +16 -0
- package/types/site.d.ts +7 -0
- package/types/template.d.ts +64 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AlexFlipnote
|
|
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
CHANGED
|
@@ -1,3 +1,160 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
# mothwing
|
|
2
|
+
A small static site builder for pages as light as a moth's wing. Plain HTML in, plain HTML out, with hashed asset links and modulepreloads for code split JS. The rest is there when you ask for it: SEO tags, a Content-Security-Policy that allows only your own inline scripts, sitemaps dated from git, robots.txt and minified HTML.
|
|
3
|
+
|
|
4
|
+
It was born as the build script of [ffxiv.watch](https://ffxiv.watch), and got tweaked over time until I was a little too proud of it to keep it there. So it branched out into its own thing, for any site that wants the same.
|
|
5
|
+
|
|
6
|
+
## What it isn't
|
|
7
|
+
mothwing isn't a framework. There are no components, and nothing runs in the browser that you didn't write. If you want those, Astro and SvelteKit are great.
|
|
8
|
+
|
|
9
|
+
I did try them, and others, before making my own. They're good at what they do, but I wanted full control of the output: every byte of HTML exactly as I wrote it, and as few dependencies as possible between my files and the finished pages. mothwing is for when you want the same.
|
|
10
|
+
|
|
11
|
+
## Getting started
|
|
12
|
+
```sh
|
|
13
|
+
npm install --save-dev mothwing
|
|
14
|
+
npx mothwing dev # serves on http://localhost:8080, a change renders just the pages it's in
|
|
15
|
+
npx mothwing build # production build into dist/
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
A site is a `mothwing.config.js` and a `src/` folder. The build mirrors `src/`, so `src/images/logo.png` is `/images/logo.png`:
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
mothwing.config.js
|
|
22
|
+
src/
|
|
23
|
+
pages/ every page, the folders are the URLs: about.html -> /about/
|
|
24
|
+
partials/ bits of HTML the pages share
|
|
25
|
+
data/ JSON and JS the pages are filled from
|
|
26
|
+
js/ bundled by esbuild
|
|
27
|
+
... everything else is copied as it is
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
```js
|
|
31
|
+
import { defineConfig } from "mothwing"
|
|
32
|
+
|
|
33
|
+
export default defineConfig({
|
|
34
|
+
domain: "example.com",
|
|
35
|
+
js: { scripts: ["js/index.js"] },
|
|
36
|
+
exclude: ["js"]
|
|
37
|
+
})
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`defineConfig` does nothing but give your editor the config's types, so every option autocompletes and a typo is underlined. Without the import, a JSDoc comment does the same:
|
|
41
|
+
|
|
42
|
+
```js
|
|
43
|
+
/** @type {import("mothwing").Config} */
|
|
44
|
+
export default { ... }
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
An option mothwing doesn't know fails the build either way.
|
|
48
|
+
|
|
49
|
+
## Data
|
|
50
|
+
Every file in `src/data/` is in every page by its name. `projects.json` is `{{ projects }}`, a folder is an object of its files. A `.js` file's default export is its value, called first when it's a function, for data worked out at build time:
|
|
51
|
+
|
|
52
|
+
```js
|
|
53
|
+
// src/data/site.js
|
|
54
|
+
export default () => ({ year: new Date().getFullYear() })
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
```html
|
|
58
|
+
<h1>{{ title }}</h1> <!-- as text, escaped -->
|
|
59
|
+
{{{ intro }}} <!-- as HTML, every line indented like the placeholder -->
|
|
60
|
+
<footer>{{ site.year }}</footer> <!-- into objects with dots -->
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Loops
|
|
64
|
+
```html
|
|
65
|
+
<ul>
|
|
66
|
+
<x-each items="projects" as="project">
|
|
67
|
+
<li>
|
|
68
|
+
<x-if value="project.url"><a href="{{ project.url }}">{{ project.name }}</a></x-if>
|
|
69
|
+
<x-if not="project.url">{{ project.name }}</x-if>
|
|
70
|
+
</li>
|
|
71
|
+
</x-each>
|
|
72
|
+
</ul>
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`<x-each>` repeats its content per item, `index="i"` names the item's position. `<x-if value="...">` shows its content when the value is there and not empty, `not="..."` when it isn't. On lines of their own, they leave no empty lines behind and their content keeps the indent it would have by hand.
|
|
76
|
+
|
|
77
|
+
`items`, `value` and `not` take a name as it is, `items="projects"`, not a placeholder. The rest of the tags' attributes can have placeholders, `<x-seo title="{{ page.name }}"/>`.
|
|
78
|
+
|
|
79
|
+
Every tag is ordinary HTML to an editor or formatter. Templates formatted by Prettier build to the same pages.
|
|
80
|
+
|
|
81
|
+
## Pages
|
|
82
|
+
`src/pages/about.html` is `/about/`, `src/pages/blog/index.html` is `/blog/`.
|
|
83
|
+
|
|
84
|
+
`[projects.id].html` makes a page per item of `projects` in the data folder, with its `id` as the URL: `src/pages/projects/[projects.id].html` is `/projects/hexclock/` for `{ "id": "hexclock" }`. The item is `{{ page }}`, and the list links to the pages by the same field, `/projects/{{ project.id }}/`.
|
|
85
|
+
|
|
86
|
+
### .data.js
|
|
87
|
+
For values that take more than the data folder, a page can have a `.data.js` next to it, whose default export returns them. Its values come before the data folder's:
|
|
88
|
+
|
|
89
|
+
```js
|
|
90
|
+
// src/pages/index.data.js
|
|
91
|
+
export default () => ({ updated: new Date().toISOString() })
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`[name].html` with a `.data.js` makes a page per item that returns, each with a `slug`. `posts/[post].html` with a slug of `first` becomes `/posts/first/`. Besides its values, an item can have:
|
|
95
|
+
|
|
96
|
+
| Key | |
|
|
97
|
+
| --- | --- |
|
|
98
|
+
| `slug` | The page's URL part |
|
|
99
|
+
| `sources` | The files its content comes from, relative to `src/`, which the sitemap dates the page by |
|
|
100
|
+
| `files` | More files to write, by their path in the build: `{ "rows/list.html": "..." }` |
|
|
101
|
+
|
|
102
|
+
For building HTML in a `.data.js`, `mothwing/html` has an `html` tagged template, which escapes what goes into it unless it's HTML already, and `slug()`.
|
|
103
|
+
|
|
104
|
+
## Tags
|
|
105
|
+
Paths in tags start at `src/`, the same as the URLs in the build.
|
|
106
|
+
|
|
107
|
+
| Tag | Does |
|
|
108
|
+
| --- | ---- |
|
|
109
|
+
| `<x-html src="partials/nav.html"/>` | Pastes in a partial, indented like the tag |
|
|
110
|
+
| `<x-js src="/js/index.js"/>` | `<script>` with a `?v=hash` |
|
|
111
|
+
| `<x-js src="/js/app.js" module/>` | One of your `modules`, with a modulepreload for each chunk it imports. `preload="/js/data/x.js"` adds one for a file it `import()`s later |
|
|
112
|
+
| `<x-css src="/css/index.css"/>` | Stylesheet `<link>` with a `?v=hash` |
|
|
113
|
+
| `<x-seo title="..." description="..."/>` | Title, description, canonical URL, link preview tags. `image`, `image-alt` and `image-size="WxH"` for its own preview, `section` and `name` for breadcrumbs in search results |
|
|
114
|
+
|
|
115
|
+
A tag mothwing doesn't know, or one missing what it needs, fails the build with the page it's on.
|
|
116
|
+
|
|
117
|
+
## Config
|
|
118
|
+
Only `domain` is needed. Folders are relative to `src/`.
|
|
119
|
+
|
|
120
|
+
| Option | Default | |
|
|
121
|
+
| --- | --- | --- |
|
|
122
|
+
| `src`, `out` | `"src"`, `"dist"` | Relative to the config |
|
|
123
|
+
| `pages` | `"pages"` | |
|
|
124
|
+
| `data` | `"data"` | Its files are in every page, it isn't copied to the build |
|
|
125
|
+
| `notFound` | `"404.html"` | Kept in the build's root, out of the sitemap |
|
|
126
|
+
| `js` | | `scripts` are bundled on their own, `modules` together, sharing their common code in chunks. `chunks` sets the chunks' folder, next to the modules by default |
|
|
127
|
+
| `sass` | `{}` | Sass to compile, `{ "scss/index.scss": "css/index.css" }`, needs `sass` installed |
|
|
128
|
+
| `exclude` | `[]` | Folders left out of the build, like the JS sources: `["js"]`. Pages and partials always are |
|
|
129
|
+
| `rerender` | `[]` | Folders your `.data.js` reads with `fs`, whose changes render every page in dev. What pages import, include or use from the data folder is followed on its own |
|
|
130
|
+
| `seo` | | For `<x-seo>`: `siteName`, `locale`, `image` (the default preview), `maxTitle`, `maxDescription` |
|
|
131
|
+
| `esbuild` | `{}` | esbuild options for every bundle |
|
|
132
|
+
|
|
133
|
+
### Extras
|
|
134
|
+
Off until you turn them on. `true` turns one on as it comes, an object changes how it works.
|
|
135
|
+
|
|
136
|
+
| Option | Turned on | Object |
|
|
137
|
+
| --- | --- | --- |
|
|
138
|
+
| `minify` | Minifies the HTML of production builds | html-minifier-terser options |
|
|
139
|
+
| `csp` | A `<meta>` Content-Security-Policy: only your own files, and your inline scripts by their hash | Directives over the defaults, `{ "style-src": "'self' 'unsafe-inline'" }` |
|
|
140
|
+
| `sitemap` | `sitemap.xml`, each page dated by the last commit to its files | `sections` that get a sitemap each, `dates: false` to leave git out |
|
|
141
|
+
| `robots` | `robots.txt` that allows everything, pointing at the sitemap | `block` crawlers by name, `disallow` paths |
|
|
142
|
+
| `cname` | `CNAME` with the domain, for GitHub Pages | |
|
|
143
|
+
| `activeLinks` | `aria-current="page"` on links to the page they're on, `"true"` on links to its section | A function `(href, route)` returning `"page"`, `"section"` or `false`, `undefined` keeps the default |
|
|
144
|
+
|
|
145
|
+
## Making it yours
|
|
146
|
+
For what's only your site's:
|
|
147
|
+
|
|
148
|
+
```js
|
|
149
|
+
export default defineConfig({
|
|
150
|
+
domain: "example.com",
|
|
151
|
+
// <x-emoji name="coffee"/>
|
|
152
|
+
tags: {
|
|
153
|
+
"x-emoji": ({ attrs }) => `<img src="/emoji/${attrs.name}.png" alt="">`
|
|
154
|
+
},
|
|
155
|
+
// Every page, after its placeholders are filled
|
|
156
|
+
transforms: [(html, route) => html.replace("<body>", `<body data-page="${route.url}">`)],
|
|
157
|
+
// Once the pages are written, `dev` is true on dev renders
|
|
158
|
+
after: [async ({ site, routes, dev }) => { /* draw preview images, ... */ }]
|
|
159
|
+
})
|
|
160
|
+
```
|
package/bin/mothwing.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { build, clean, dev, loadConfig, renderSite } from "../src/index.js"
|
|
3
|
+
|
|
4
|
+
const USAGE = `Usage: mothwing <command> [--root <dir>]
|
|
5
|
+
|
|
6
|
+
Commands:
|
|
7
|
+
build Build the site for production
|
|
8
|
+
dev Serve it and rebuild on changes, takes --host and --port
|
|
9
|
+
clean Remove the build folder
|
|
10
|
+
html Render only the pages, into an existing build, --pages a.html,b.html for just those`
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {string} name
|
|
14
|
+
* @returns {string | undefined}
|
|
15
|
+
*/
|
|
16
|
+
const flag = (name) => {
|
|
17
|
+
const i = process.argv.indexOf(`--${name}`)
|
|
18
|
+
return i > -1 ? process.argv[i + 1] : undefined
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** @type {Record<string, (site: import("../src/index.js").Site) => Promise<void>>} */
|
|
22
|
+
const commands = {
|
|
23
|
+
build: (site) => build(site),
|
|
24
|
+
dev: (site) => dev(site, { host: flag("host"), port: Number(flag("port")) || undefined }),
|
|
25
|
+
clean: (site) => clean(site),
|
|
26
|
+
html: async (site) => {
|
|
27
|
+
const pages = flag("pages")
|
|
28
|
+
const deps = await renderSite(site, { only: pages ? new Set(pages.split(",")) : undefined })
|
|
29
|
+
// Run by dev, which keeps track of what the pages are made from
|
|
30
|
+
process.send?.({ deps })
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const command = commands[process.argv[2]]
|
|
34
|
+
|
|
35
|
+
if (!command) {
|
|
36
|
+
console.log(USAGE)
|
|
37
|
+
process.exit(process.argv[2] ? 1 : 0)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
await command(await loadConfig(flag("root")))
|
|
42
|
+
} catch (err) {
|
|
43
|
+
console.error(`mothwing: ${/** @type {Error} */ (err).message}`)
|
|
44
|
+
process.exit(1)
|
|
45
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,73 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "mothwing",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "mothwing",
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"description": "A small static site builder for pages as light as a moth's wing",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"static-site-generator",
|
|
7
|
+
"ssg",
|
|
8
|
+
"html",
|
|
9
|
+
"esbuild",
|
|
10
|
+
"csp",
|
|
11
|
+
"sitemap"
|
|
12
|
+
],
|
|
13
|
+
"author": "AlexFlipnote",
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/AlexFlipnote/mothwing.git"
|
|
18
|
+
},
|
|
19
|
+
"homepage": "https://github.com/AlexFlipnote/mothwing#readme",
|
|
20
|
+
"bugs": {
|
|
21
|
+
"url": "https://github.com/AlexFlipnote/mothwing/issues"
|
|
22
|
+
},
|
|
23
|
+
"type": "module",
|
|
24
|
+
"bin": {
|
|
25
|
+
"mothwing": "bin/mothwing.js"
|
|
26
|
+
},
|
|
27
|
+
"exports": {
|
|
28
|
+
".": {
|
|
29
|
+
"types": "./types/index.d.ts",
|
|
30
|
+
"default": "./src/index.js"
|
|
31
|
+
},
|
|
32
|
+
"./html": {
|
|
33
|
+
"types": "./types/html.d.ts",
|
|
34
|
+
"default": "./src/html.js"
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"bin",
|
|
39
|
+
"src",
|
|
40
|
+
"types"
|
|
41
|
+
],
|
|
42
|
+
"engines": {
|
|
43
|
+
"node": ">=20.11"
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"test": "node --test test/*.test.js",
|
|
47
|
+
"lint": "eslint .",
|
|
48
|
+
"types": "tsc src/index.js src/html.js --allowJs --declaration --emitDeclarationOnly --module nodenext --moduleResolution nodenext --target es2022 --strict --types node --skipLibCheck --outDir types",
|
|
49
|
+
"prepack": "npm run types",
|
|
50
|
+
"typecheck": "tsc -p jsconfig.json"
|
|
51
|
+
},
|
|
52
|
+
"dependencies": {
|
|
53
|
+
"@types/html-minifier-terser": "^7.0.2",
|
|
54
|
+
"esbuild": "0.28.1",
|
|
55
|
+
"html-minifier-terser": "7.2.0"
|
|
56
|
+
},
|
|
57
|
+
"peerDependencies": {
|
|
58
|
+
"sass": "^1.93.3"
|
|
59
|
+
},
|
|
60
|
+
"peerDependenciesMeta": {
|
|
61
|
+
"sass": {
|
|
62
|
+
"optional": true
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
"devDependencies": {
|
|
66
|
+
"@eslint/js": "^9.39.5",
|
|
67
|
+
"@types/node": "^22.20.5",
|
|
68
|
+
"eslint": "^9.39.5",
|
|
69
|
+
"globals": "^16.5.0",
|
|
70
|
+
"sass": "^1.105.2",
|
|
71
|
+
"typescript": "^6.0.3"
|
|
72
|
+
}
|
|
73
|
+
}
|
package/src/assets.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import esbuild from "esbuild"
|
|
2
|
+
import fs from "fs"
|
|
3
|
+
import path from "path"
|
|
4
|
+
import { log, posix } from "./log.js"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {import("./config.js").Site} site
|
|
8
|
+
* @param {string} file Absolute path
|
|
9
|
+
* @returns {boolean} In one of the config's exclude folders
|
|
10
|
+
*/
|
|
11
|
+
export const isExcluded = (site, file) => {
|
|
12
|
+
const relative = posix(path.relative(site.src, file))
|
|
13
|
+
return site.exclude.some(dir => relative === dir || relative.startsWith(`${dir}/`))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** @param {import("./config.js").Site} site */
|
|
17
|
+
export async function clean(site) {
|
|
18
|
+
log("CLEAN", "Removing build folder...")
|
|
19
|
+
// Retries, as the dev server can be writing a file into it right then
|
|
20
|
+
await fs.promises.rm(site.out, { recursive: true, force: true, maxRetries: 5 })
|
|
21
|
+
log("CLEAN", "Done")
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Copies src as it is into the build, leaving out the excluded folders, pages and Sass.
|
|
26
|
+
* @param {import("./config.js").Site} site
|
|
27
|
+
*/
|
|
28
|
+
export async function copyAssets(site) {
|
|
29
|
+
log("ASSET", "Copying assets...")
|
|
30
|
+
fs.cpSync(site.src, site.out, {
|
|
31
|
+
recursive: true,
|
|
32
|
+
filter: (file) => !isExcluded(site, file) && !/\.(html|scss|sass)$/.test(file)
|
|
33
|
+
})
|
|
34
|
+
log("ASSET", "Done copying assets")
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {string[]} files "js/a.js", "js/pages/b.js"
|
|
39
|
+
* @returns {string} The folder they're all in, "js"
|
|
40
|
+
*/
|
|
41
|
+
function commonFolder(files) {
|
|
42
|
+
const [first = [], ...rest] = files.map(file => path.posix.dirname(file).split("/"))
|
|
43
|
+
let depth = first.length
|
|
44
|
+
for (const parts of rest) {
|
|
45
|
+
while (depth && parts.slice(0, depth).join("/") !== first.slice(0, depth).join("/")) depth--
|
|
46
|
+
}
|
|
47
|
+
return first.slice(0, depth).join("/").replace(/^\.$/, "")
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The esbuild builds for the config's js: one per script, and one for all the modules, so they can share chunks.
|
|
52
|
+
* @param {import("./config.js").Site} site
|
|
53
|
+
* @param {import("esbuild").BuildOptions} [overrides]
|
|
54
|
+
* @returns {import("esbuild").BuildOptions[]}
|
|
55
|
+
*/
|
|
56
|
+
export function jsBuilds(site, overrides = {}) {
|
|
57
|
+
/** @param {import("esbuild").BuildOptions} build */
|
|
58
|
+
const options = (build) => ({
|
|
59
|
+
bundle: true,
|
|
60
|
+
minify: true,
|
|
61
|
+
sourcemap: false,
|
|
62
|
+
target: ["es2020"],
|
|
63
|
+
...site.esbuild,
|
|
64
|
+
...build,
|
|
65
|
+
...overrides
|
|
66
|
+
})
|
|
67
|
+
const { scripts, modules, chunks } = site.js
|
|
68
|
+
// From the modules' own folder, as esbuild's chunk hashes depend on the paths the build is laid out with
|
|
69
|
+
const base = commonFolder(modules)
|
|
70
|
+
|
|
71
|
+
return [
|
|
72
|
+
...scripts.map(file => options({ entryPoints: [path.join(site.src, file)], outfile: path.join(site.out, file), format: "iife" })),
|
|
73
|
+
...modules.length
|
|
74
|
+
? [options({
|
|
75
|
+
entryPoints: modules.map(file => path.join(site.src, file)),
|
|
76
|
+
outbase: path.join(site.src, base),
|
|
77
|
+
outdir: path.join(site.out, base),
|
|
78
|
+
format: "esm",
|
|
79
|
+
splitting: true,
|
|
80
|
+
chunkNames: `${path.posix.relative(base, chunks) || "."}/[name]-[hash]`
|
|
81
|
+
})]
|
|
82
|
+
: []
|
|
83
|
+
]
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Bundles the JS, and notes which chunks each file imports, for the modulepreload links.
|
|
88
|
+
* @param {import("./config.js").Site} site
|
|
89
|
+
* @param {import("./render.js").BuildState} state
|
|
90
|
+
*/
|
|
91
|
+
export async function buildJS(site, state) {
|
|
92
|
+
const builds = jsBuilds(site, { metafile: true })
|
|
93
|
+
if (!builds.length) return
|
|
94
|
+
log("JS", "Building JS...")
|
|
95
|
+
const results = await Promise.all(builds.map(build => esbuild.build(build)))
|
|
96
|
+
|
|
97
|
+
/** @param {string} file */
|
|
98
|
+
const url = (file) => "/" + posix(path.relative(site.out, path.resolve(file)))
|
|
99
|
+
for (const [file, output] of results.flatMap(r => Object.entries(r.metafile?.outputs ?? {}))) {
|
|
100
|
+
const imports = output.imports.filter(i => i.kind === "import-statement").map(i => url(i.path))
|
|
101
|
+
if (imports.length) state.chunkImports.set(url(file), imports)
|
|
102
|
+
if (output.entryPoint) state.entryChunks.set("/" + posix(path.relative(site.src, output.entryPoint)), url(file))
|
|
103
|
+
}
|
|
104
|
+
log("JS", "Done building the JS")
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** @param {import("./config.js").Site} site */
|
|
108
|
+
export async function buildSass(site) {
|
|
109
|
+
const files = Object.entries(site.sass)
|
|
110
|
+
if (!files.length) return
|
|
111
|
+
log("CSS", "Converting SCSS to CSS")
|
|
112
|
+
const sass = await import("sass").catch(() => {
|
|
113
|
+
throw new Error("The config has sass to compile, which needs it installed: npm install sass")
|
|
114
|
+
})
|
|
115
|
+
for (const [from, to] of files) {
|
|
116
|
+
const result = sass.compile(path.join(site.src, from), { style: "compressed" })
|
|
117
|
+
const outputFile = path.join(site.out, to)
|
|
118
|
+
fs.mkdirSync(path.dirname(outputFile), { recursive: true })
|
|
119
|
+
fs.writeFileSync(outputFile, result.css)
|
|
120
|
+
}
|
|
121
|
+
log("CSS", "Done building the CSS")
|
|
122
|
+
}
|
package/src/build.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import fs from "fs"
|
|
2
|
+
import { minify } from "html-minifier-terser"
|
|
3
|
+
import path from "path"
|
|
4
|
+
import { buildJS, buildSass, clean, copyAssets } from "./assets.js"
|
|
5
|
+
import { withCsp } from "./csp.js"
|
|
6
|
+
import { loadData } from "./data.js"
|
|
7
|
+
import { dependencies } from "./deps.js"
|
|
8
|
+
import { log } from "./log.js"
|
|
9
|
+
import { buildState, renderPage } from "./render.js"
|
|
10
|
+
import { listRoutes } from "./routes.js"
|
|
11
|
+
import { writeSiteFiles } from "./site.js"
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Writes a file unless it already has this content. Writing is much slower than reading on Windows, and a dev render
|
|
15
|
+
* of a whole site mostly writes what's already there.
|
|
16
|
+
* @param {string} file
|
|
17
|
+
* @param {string} content
|
|
18
|
+
* @returns {boolean} Whether it was written
|
|
19
|
+
*/
|
|
20
|
+
export function writeIfChanged(file, content) {
|
|
21
|
+
try {
|
|
22
|
+
if (fs.readFileSync(file, "utf8") === content) return false
|
|
23
|
+
} catch {
|
|
24
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
25
|
+
}
|
|
26
|
+
fs.writeFileSync(file, content)
|
|
27
|
+
return true
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {import("./config.js").Site} site
|
|
32
|
+
* @param {import("./render.js").BuildState} state
|
|
33
|
+
* @param {{ hashed?: boolean, only?: Set<string> }} [options] `hashed` for production: hashed asset links and minified
|
|
34
|
+
* HTML. `only` renders just these pages, relative to the pages folder
|
|
35
|
+
* @returns {Promise<import("./routes.js").Route[]>} The pages rendered
|
|
36
|
+
*/
|
|
37
|
+
export async function buildHTML(site, state, { hashed = true, only } = {}) {
|
|
38
|
+
log("HTML", only ? `Rendering ${[...only].join(", ")}...` : "Rendering pages...")
|
|
39
|
+
state.assetUrls.clear()
|
|
40
|
+
state.templates.clear()
|
|
41
|
+
state.used.clear()
|
|
42
|
+
state.data = await loadData(site)
|
|
43
|
+
|
|
44
|
+
const routes = await listRoutes(site, state.data, only)
|
|
45
|
+
let written = 0
|
|
46
|
+
for (const route of routes) {
|
|
47
|
+
const html = renderPage(site, state, route, hashed)
|
|
48
|
+
const finished = hashed && site.minify ? await minify(html, site.minify) : html
|
|
49
|
+
if (writeIfChanged(path.join(site.out, route.output), site.csp ? withCsp(site.csp, finished) : finished)) written++
|
|
50
|
+
|
|
51
|
+
for (const [file, content] of Object.entries(route.props.files ?? {})) writeIfChanged(path.join(site.out, file), content)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// A page that used the data folder is dated by it too
|
|
55
|
+
for (const route of routes) {
|
|
56
|
+
if (route.props.page) continue
|
|
57
|
+
for (const name of state.used.get(route.source) ?? []) {
|
|
58
|
+
const file = state.data.files[name]
|
|
59
|
+
if (file && !(name in route.props) && !route.sources.includes(file)) route.sources.push(file)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
log("HTML", `Done rendering ${routes.length} pages${written < routes.length ? `, ${written} changed` : ""}`)
|
|
63
|
+
return routes
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @param {import("./config.js").Site} site
|
|
68
|
+
* @param {import("./routes.js").Route[]} routes
|
|
69
|
+
* @param {boolean} dev
|
|
70
|
+
*/
|
|
71
|
+
async function runAfter(site, routes, dev) {
|
|
72
|
+
for (const hook of site.after) await hook({ site, routes, dev })
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Renders the pages into an existing build, as dev does after a change. With `only`, just those pages, and the
|
|
77
|
+
* sitemaps are left as they are.
|
|
78
|
+
* @param {import("./config.js").Site} site
|
|
79
|
+
* @param {{ only?: Set<string> }} [options]
|
|
80
|
+
* @returns {Promise<Record<string, string[]>>} What each page rendered is made from, see deps.js
|
|
81
|
+
*/
|
|
82
|
+
export async function renderSite(site, { only } = {}) {
|
|
83
|
+
const state = buildState()
|
|
84
|
+
const routes = await buildHTML(site, state, { hashed: false, only })
|
|
85
|
+
if (!only) await writeSiteFiles(site, routes, false)
|
|
86
|
+
await runAfter(site, routes, true)
|
|
87
|
+
return dependencies(site, state, routes)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The production build, from an empty build folder.
|
|
92
|
+
* @param {import("./config.js").Site} site
|
|
93
|
+
*/
|
|
94
|
+
export async function build(site) {
|
|
95
|
+
const state = buildState()
|
|
96
|
+
await clean(site)
|
|
97
|
+
await copyAssets(site)
|
|
98
|
+
await buildJS(site, state)
|
|
99
|
+
await buildSass(site)
|
|
100
|
+
// After JS and CSS, since the ?v=hash is made from the built files
|
|
101
|
+
const routes = await buildHTML(site, state)
|
|
102
|
+
await writeSiteFiles(site, routes)
|
|
103
|
+
await runAfter(site, routes, false)
|
|
104
|
+
}
|