mothwing 0.0.0-stage → 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/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 +96 -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/src/config.js
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import fs from "fs"
|
|
2
|
+
import path from "path"
|
|
3
|
+
import { pathToFileURL } from "url"
|
|
4
|
+
|
|
5
|
+
export const CONFIG_FILE = "mothwing.config.js"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* What a site's mothwing.config.js default exports. Only `domain` is needed. Paths are relative to `src` unless they
|
|
9
|
+
* say otherwise. The extras (minify to activeLinks) are off until asked for: `true` turns one on as it comes, an object
|
|
10
|
+
* with its own settings.
|
|
11
|
+
* @typedef {object} Config
|
|
12
|
+
* @property {string} domain "example.com", for the canonical URLs, sitemaps and CNAME
|
|
13
|
+
* @property {string} [src] The source folder, relative to the config. "src"
|
|
14
|
+
* @property {string} [out] The build folder, relative to the config. "dist"
|
|
15
|
+
* @property {string} [pages] The pages folder. "pages"
|
|
16
|
+
* @property {string} [data] The data folder, its files are in every page by their name: projects.json -> {{ projects }}.
|
|
17
|
+
* "data"
|
|
18
|
+
* @property {string} [notFound] The 404 page, in the pages folder. Kept in the build's root, out of the sitemap. "404.html"
|
|
19
|
+
* @property {JsConfig} [js] The JS to bundle
|
|
20
|
+
* @property {Record<string, string>} [sass] Sass to compile, source to output: { "scss/index.scss": "css/index.css" }
|
|
21
|
+
* @property {string[]} [exclude] Folders left out of the build, on top of pages, partials and data: ["js", "scss"]
|
|
22
|
+
* @property {string[]} [rerender] Folders the pages read with fs, whose changes render every page again in dev. What
|
|
23
|
+
* the pages import, include or use from the data folder is followed on its own
|
|
24
|
+
* @property {SeoConfig} [seo] For <x-seo>
|
|
25
|
+
* @property {boolean | import("html-minifier-terser").Options} [minify] Minify the HTML of production builds
|
|
26
|
+
* @property {boolean | Record<string, string>} [csp] A Content-Security-Policy <meta> that allows only the site's own
|
|
27
|
+
* files and inline scripts. An object sets directives over the defaults: { "style-src": "'self' 'unsafe-inline'" }
|
|
28
|
+
* @property {boolean | SitemapConfig} [sitemap] sitemap.xml
|
|
29
|
+
* @property {boolean | RobotsConfig} [robots] robots.txt
|
|
30
|
+
* @property {boolean} [cname] A CNAME file with the domain, for GitHub Pages
|
|
31
|
+
* @property {boolean | ActiveLink} [activeLinks] aria-current on links to the page they're on and to its section
|
|
32
|
+
* @property {Record<string, TagFunction>} [tags] Your own tags: { "x-name": ({ attrs }) => "<b>html</b>" }
|
|
33
|
+
* @property {Transform[]} [transforms] Run on every page's HTML once its placeholders are filled
|
|
34
|
+
* @property {Hook[]} [after] Run once the pages are written
|
|
35
|
+
* @property {import("esbuild").BuildOptions} [esbuild] esbuild options for every bundle, over mothwing's
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Each file is bundled to the same path in the build, js/index.js -> /js/index.js.
|
|
40
|
+
* @typedef {object} JsConfig
|
|
41
|
+
* @property {string[]} [scripts] For <x-js src="..."/>, bundled on their own
|
|
42
|
+
* @property {string[]} [modules] For <x-js src="..." module/>, ES modules that share their common code in chunks
|
|
43
|
+
* @property {string} [chunks] Where the shared chunks go. A chunks folder next to the first module
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @typedef {object} SeoConfig
|
|
48
|
+
* @property {string} [siteName] Added to titles that fit, and the link previews' site name. The domain
|
|
49
|
+
* @property {string} [locale] "en_US"
|
|
50
|
+
* @property {{ src: string, width: number, height: number, alt: string } | null} [image] The link preview for pages
|
|
51
|
+
* without their own
|
|
52
|
+
* @property {number} [maxTitle] Titles longer than this leave out the site name, search results cut them off. 60
|
|
53
|
+
* @property {number} [maxDescription] Longer descriptions are cut at a word. 160
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @typedef {object} SitemapConfig
|
|
58
|
+
* @property {string[]} [sections] Sections that get a sitemap of their own, the rest share sitemap-pages.xml
|
|
59
|
+
* @property {boolean} [dates] Date each page by the last git commit to its files. true
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @typedef {object} RobotsConfig
|
|
64
|
+
* @property {string[]} [block] Crawlers turned away from the whole site, by name
|
|
65
|
+
* @property {string[]} [disallow] Paths kept out of every crawler
|
|
66
|
+
*/
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Returns the HTML a tag turns into. A list is one line each, indented like the tag.
|
|
70
|
+
* @typedef {(tag: { attrs: Record<string, string | true>, route: import("./routes.js").Route, site: Site }) => string | string[]} TagFunction
|
|
71
|
+
* @typedef {(html: string, route: import("./routes.js").Route, site: Site) => string} Transform
|
|
72
|
+
* @typedef {(build: { site: Site, routes: import("./routes.js").Route[], dev: boolean }) => any} Hook
|
|
73
|
+
*/
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Decides what a link on a page is to it: "page" for the page itself, "section" for a section it's in, false for
|
|
77
|
+
* neither. undefined leaves it to the default.
|
|
78
|
+
* @typedef {(href: string, route: import("./routes.js").Route) => "page" | "section" | false | undefined} ActiveLink
|
|
79
|
+
*/
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The config with its defaults filled in, its folders absolute, and each extra either false or its settings.
|
|
83
|
+
* @typedef {object} Site
|
|
84
|
+
* @property {string} domain
|
|
85
|
+
* @property {string} url "https://example.com"
|
|
86
|
+
* @property {string} root The config's folder
|
|
87
|
+
* @property {string} src
|
|
88
|
+
* @property {string} out
|
|
89
|
+
* @property {string} pages
|
|
90
|
+
* @property {string} data Relative to src
|
|
91
|
+
* @property {string} notFound
|
|
92
|
+
* @property {Required<JsConfig>} js
|
|
93
|
+
* @property {Record<string, string>} sass
|
|
94
|
+
* @property {string[]} exclude Folders, with pages, partials and data
|
|
95
|
+
* @property {string[]} rerender Folders
|
|
96
|
+
* @property {Required<SeoConfig>} seo
|
|
97
|
+
* @property {import("html-minifier-terser").Options | false} minify
|
|
98
|
+
* @property {Record<string, string> | false} csp
|
|
99
|
+
* @property {Required<SitemapConfig> | false} sitemap
|
|
100
|
+
* @property {Required<RobotsConfig> | false} robots
|
|
101
|
+
* @property {boolean} cname
|
|
102
|
+
* @property {ActiveLink | false} activeLinks
|
|
103
|
+
* @property {Record<string, TagFunction>} tags
|
|
104
|
+
* @property {Transform[]} transforms
|
|
105
|
+
* @property {Hook[]} after
|
|
106
|
+
* @property {import("esbuild").BuildOptions} esbuild
|
|
107
|
+
*/
|
|
108
|
+
|
|
109
|
+
const OPTIONS = [
|
|
110
|
+
"domain", "src", "out", "pages", "data", "notFound", "js", "sass", "exclude", "rerender", "seo", "minify", "csp", "sitemap",
|
|
111
|
+
"robots", "cname", "activeLinks", "tags", "transforms", "after", "esbuild"
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
const DEFAULT_CSP = {
|
|
115
|
+
"default-src": "'self'",
|
|
116
|
+
"script-src": "'self'",
|
|
117
|
+
"style-src": "'self'",
|
|
118
|
+
"img-src": "'self' data:",
|
|
119
|
+
"object-src": "'none'",
|
|
120
|
+
"base-uri": "'none'",
|
|
121
|
+
"form-action": "'none'"
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const DEFAULT_MINIFY = {
|
|
125
|
+
collapseWhitespace: true,
|
|
126
|
+
removeComments: true,
|
|
127
|
+
minifyJS: true,
|
|
128
|
+
minifyCSS: true
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* @template T
|
|
133
|
+
* @param {boolean | T | undefined} value
|
|
134
|
+
* @returns {Partial<T>} The settings of an extra turned on with an object, none for `true`
|
|
135
|
+
*/
|
|
136
|
+
const settings = (value) => typeof value === "object" ? value : {}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Gives the config its types in your editor, it's returned as it is.
|
|
140
|
+
* @param {Config} config
|
|
141
|
+
* @returns {Config}
|
|
142
|
+
*/
|
|
143
|
+
export const defineConfig = (config) => config
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* @param {Config} config
|
|
147
|
+
* @param {string} root The config's folder
|
|
148
|
+
* @returns {Site}
|
|
149
|
+
*/
|
|
150
|
+
export function resolveConfig(config, root) {
|
|
151
|
+
if (!config?.domain) throw new Error(`${CONFIG_FILE}: needs a domain`)
|
|
152
|
+
const unknown = Object.keys(config).filter(key => !OPTIONS.includes(key))
|
|
153
|
+
if (unknown.length) throw new Error(`${CONFIG_FILE}: unknown option ${unknown.map(key => `"${key}"`).join(", ")}, the options are ${OPTIONS.join(", ")}`)
|
|
154
|
+
|
|
155
|
+
const src = path.resolve(root, config.src ?? "src")
|
|
156
|
+
const pages = config.pages ?? "pages"
|
|
157
|
+
const data = config.data ?? "data"
|
|
158
|
+
const modules = config.js?.modules ?? []
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
domain: config.domain,
|
|
162
|
+
url: `https://${config.domain}`,
|
|
163
|
+
root,
|
|
164
|
+
src,
|
|
165
|
+
out: path.resolve(root, config.out ?? "dist"),
|
|
166
|
+
pages: path.join(src, pages),
|
|
167
|
+
data,
|
|
168
|
+
notFound: config.notFound ?? "404.html",
|
|
169
|
+
js: {
|
|
170
|
+
scripts: config.js?.scripts ?? [],
|
|
171
|
+
modules,
|
|
172
|
+
chunks: config.js?.chunks ?? (modules.length ? path.posix.join(path.posix.dirname(modules[0]), "chunks") : "chunks")
|
|
173
|
+
},
|
|
174
|
+
sass: config.sass ?? {},
|
|
175
|
+
exclude: [...new Set([pages, "partials", data, ...config.exclude ?? []])],
|
|
176
|
+
rerender: config.rerender ?? [],
|
|
177
|
+
seo: {
|
|
178
|
+
siteName: config.domain,
|
|
179
|
+
locale: "en_US",
|
|
180
|
+
image: null,
|
|
181
|
+
maxTitle: 60,
|
|
182
|
+
maxDescription: 160,
|
|
183
|
+
...config.seo
|
|
184
|
+
},
|
|
185
|
+
minify: config.minify ? { ...DEFAULT_MINIFY, ...settings(config.minify) } : false,
|
|
186
|
+
csp: config.csp ? { ...DEFAULT_CSP, ...settings(config.csp) } : false,
|
|
187
|
+
sitemap: config.sitemap ? { sections: [], dates: true, ...settings(config.sitemap) } : false,
|
|
188
|
+
robots: config.robots ? { block: [], disallow: [], ...settings(config.robots) } : false,
|
|
189
|
+
cname: config.cname ?? false,
|
|
190
|
+
activeLinks: config.activeLinks === true ? () => undefined : config.activeLinks || false,
|
|
191
|
+
tags: config.tags ?? {},
|
|
192
|
+
transforms: config.transforms ?? [],
|
|
193
|
+
after: config.after ?? [],
|
|
194
|
+
esbuild: config.esbuild ?? {}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* @param {string} [root] The folder with mothwing.config.js
|
|
200
|
+
* @returns {Promise<Site>}
|
|
201
|
+
*/
|
|
202
|
+
export async function loadConfig(root = process.cwd()) {
|
|
203
|
+
const file = path.join(root, CONFIG_FILE)
|
|
204
|
+
if (!fs.existsSync(file)) throw new Error(`No ${CONFIG_FILE} in ${root}`)
|
|
205
|
+
return resolveConfig((await import(pathToFileURL(file).href)).default, path.resolve(root))
|
|
206
|
+
}
|
package/src/csp.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import crypto from "crypto"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Adds a Content-Security-Policy <meta>, for hosts like GitHub Pages that can't send headers. The inline scripts are
|
|
5
|
+
* allowed by their hash, so it has to run on the finished HTML, after minifying changes them.
|
|
6
|
+
* @param {Record<string, string>} directives
|
|
7
|
+
* @param {string} html The finished page
|
|
8
|
+
* @returns {string}
|
|
9
|
+
*/
|
|
10
|
+
export function withCsp(directives, html) {
|
|
11
|
+
const hashes = [...html.matchAll(/<script(?![^>]*\ssrc=)([^>]*)>([\s\S]*?)<\/script>/g)]
|
|
12
|
+
// JSON-LD is data, it never runs
|
|
13
|
+
.filter(([, attrs]) => !attrs.includes("application/ld+json"))
|
|
14
|
+
// Over the text as the browser reads it, which turns every line break into a \n
|
|
15
|
+
.map(([, , code]) => `'sha256-${crypto.createHash("sha256").update(code.replace(/\r\n?/g, "\n")).digest("base64")}'`)
|
|
16
|
+
|
|
17
|
+
const policy = Object.entries(directives)
|
|
18
|
+
.map(([name, value]) => name === "script-src" && hashes.length ? `${name} ${value} ${[...new Set(hashes)].join(" ")}` : `${name} ${value}`)
|
|
19
|
+
.join("; ")
|
|
20
|
+
return html.replace(/<meta charset[^>]*>/, (charset) => `${charset}<meta http-equiv="Content-Security-Policy" content="${policy}">`)
|
|
21
|
+
}
|
package/src/data.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import fs from "fs"
|
|
2
|
+
import path from "path"
|
|
3
|
+
import { pathToFileURL } from "url"
|
|
4
|
+
import { posix } from "./log.js"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The site's data folder, for every page.
|
|
8
|
+
* @typedef {object} SiteData
|
|
9
|
+
* @property {Record<string, any>} values projects.json -> values.projects, a folder is an object of its files
|
|
10
|
+
* @property {Record<string, string>} files Each top level name -> its file or folder, relative to src
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {string} file
|
|
15
|
+
* @returns {Promise<any>} A JSON file as it is, a JS file's default export, called first when it's a function
|
|
16
|
+
*/
|
|
17
|
+
async function read(file) {
|
|
18
|
+
if (file.endsWith(".json")) return JSON.parse(fs.readFileSync(file, "utf8"))
|
|
19
|
+
const value = (await import(pathToFileURL(file).href)).default
|
|
20
|
+
return typeof value === "function" ? await value() : value
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {string} dir
|
|
25
|
+
* @param {string} from For errors, the folder relative to src
|
|
26
|
+
* @returns {Promise<Record<string, any>>}
|
|
27
|
+
*/
|
|
28
|
+
async function readFolder(dir, from) {
|
|
29
|
+
/** @type {Record<string, any>} */
|
|
30
|
+
const values = {}
|
|
31
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
32
|
+
const file = path.join(dir, entry.name)
|
|
33
|
+
const name = entry.isDirectory() ? entry.name : entry.name.replace(/\.(json|js)$/, "")
|
|
34
|
+
if (!entry.isDirectory() && name === entry.name) continue
|
|
35
|
+
if (name in values) throw new Error(`${from}/${entry.name}: there's more than one ${name} in ${from}`)
|
|
36
|
+
values[name] = entry.isDirectory() ? await readFolder(file, `${from}/${entry.name}`) : await read(file)
|
|
37
|
+
}
|
|
38
|
+
return values
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {import("./config.js").Site} site
|
|
43
|
+
* @returns {Promise<SiteData>}
|
|
44
|
+
*/
|
|
45
|
+
export async function loadData(site) {
|
|
46
|
+
const dir = path.join(site.src, site.data)
|
|
47
|
+
if (!fs.existsSync(dir)) return { values: {}, files: {} }
|
|
48
|
+
|
|
49
|
+
const values = await readFolder(dir, site.data)
|
|
50
|
+
const files = Object.fromEntries(fs.readdirSync(dir, { withFileTypes: true })
|
|
51
|
+
.filter(entry => entry.isDirectory() || /\.(json|js)$/.test(entry.name))
|
|
52
|
+
.map(entry => [entry.isDirectory() ? entry.name : entry.name.replace(/\.(json|js)$/, ""), posix(path.join(site.data, entry.name))]))
|
|
53
|
+
return { values, files }
|
|
54
|
+
}
|
package/src/deps.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import esbuild from "esbuild"
|
|
2
|
+
import fs from "fs"
|
|
3
|
+
import path from "path"
|
|
4
|
+
import { posix } from "./log.js"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {import("./config.js").Site} site
|
|
8
|
+
* @param {string} file Relative to src
|
|
9
|
+
* @returns {Promise<string[]>} It and every file in src it imports, however deep, relative to src
|
|
10
|
+
*/
|
|
11
|
+
async function importsOf(site, file) {
|
|
12
|
+
try {
|
|
13
|
+
const { metafile } = await esbuild.build({
|
|
14
|
+
entryPoints: [path.join(site.src, file)],
|
|
15
|
+
bundle: true,
|
|
16
|
+
write: false,
|
|
17
|
+
metafile: true,
|
|
18
|
+
platform: "node",
|
|
19
|
+
format: "esm",
|
|
20
|
+
packages: "external",
|
|
21
|
+
logLevel: "silent"
|
|
22
|
+
})
|
|
23
|
+
return Object.keys(metafile.inputs)
|
|
24
|
+
.map(input => posix(path.relative(site.src, path.resolve(input))))
|
|
25
|
+
.filter(input => !input.startsWith("../"))
|
|
26
|
+
} catch {
|
|
27
|
+
return [file]
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The files each page is made from, so dev can render just the pages a change is in: the page, its partials, its
|
|
33
|
+
* .data.js and what that imports, its sources, and the data files it used.
|
|
34
|
+
* @param {import("./config.js").Site} site
|
|
35
|
+
* @param {import("./render.js").BuildState} state
|
|
36
|
+
* @param {import("./routes.js").Route[]} routes
|
|
37
|
+
* @returns {Promise<Record<string, string[]>>} A page's file, relative to the pages folder -> files relative to src
|
|
38
|
+
*/
|
|
39
|
+
export async function dependencies(site, state, routes) {
|
|
40
|
+
const pagesDir = posix(path.relative(site.src, site.pages))
|
|
41
|
+
/** @type {Map<string, Promise<string[]>>} */
|
|
42
|
+
const imports = new Map()
|
|
43
|
+
/** @param {string} file */
|
|
44
|
+
const importsCached = (file) => {
|
|
45
|
+
if (!imports.has(file)) imports.set(file, importsOf(site, file))
|
|
46
|
+
return /** @type {Promise<string[]>} */ (imports.get(file))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** @type {Record<string, string[]>} */
|
|
50
|
+
const deps = {}
|
|
51
|
+
for (const source of new Set(routes.map(route => route.source))) {
|
|
52
|
+
const files = new Set([`${pagesDir}/${source}`, ...state.templates.get(source)?.partials ?? []])
|
|
53
|
+
for (const route of routes.filter(route => route.source === source)) route.sources.forEach(file => files.add(file))
|
|
54
|
+
|
|
55
|
+
const dataFile = `${pagesDir}/${source.replace(/\.html$/, ".data.js")}`
|
|
56
|
+
if (fs.existsSync(path.join(site.src, dataFile))) (await importsCached(dataFile)).forEach(file => files.add(file))
|
|
57
|
+
|
|
58
|
+
for (const name of state.used.get(source) ?? []) {
|
|
59
|
+
const file = state.data.files[name]
|
|
60
|
+
if (!file) continue
|
|
61
|
+
files.add(file)
|
|
62
|
+
if (file.endsWith(".js")) (await importsCached(file)).forEach(input => files.add(input))
|
|
63
|
+
}
|
|
64
|
+
deps[source] = [...files]
|
|
65
|
+
}
|
|
66
|
+
return deps
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @param {Record<string, string[]>} deps From dependencies
|
|
71
|
+
* @param {string} file A changed file, relative to src
|
|
72
|
+
* @returns {string[]} The pages made from it. A data folder counts for every file in it
|
|
73
|
+
*/
|
|
74
|
+
export const pagesUsing = (deps, file) => Object.entries(deps)
|
|
75
|
+
.filter(([, files]) => files.some(dep => dep === file || file.startsWith(`${dep}/`)))
|
|
76
|
+
.map(([source]) => source)
|
package/src/dev.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { spawn } from "child_process"
|
|
2
|
+
import esbuild from "esbuild"
|
|
3
|
+
import fs from "fs"
|
|
4
|
+
import net from "net"
|
|
5
|
+
import os from "os"
|
|
6
|
+
import path from "path"
|
|
7
|
+
import { fileURLToPath } from "url"
|
|
8
|
+
import { buildSass, clean, copyAssets, isExcluded, jsBuilds } from "./assets.js"
|
|
9
|
+
import { renderSite } from "./build.js"
|
|
10
|
+
import { pagesUsing } from "./deps.js"
|
|
11
|
+
import { log, posix } from "./log.js"
|
|
12
|
+
|
|
13
|
+
const CLI = fileURLToPath(new URL("../bin/mothwing.js", import.meta.url))
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {number} port
|
|
17
|
+
* @param {string} host
|
|
18
|
+
* @returns {Promise<void>} Rejects when something is already listening on it
|
|
19
|
+
*/
|
|
20
|
+
const portFree = (port, host) => new Promise((resolve, reject) => {
|
|
21
|
+
const server = net.createServer()
|
|
22
|
+
server.once("error", reject)
|
|
23
|
+
server.listen(port, host, () => server.close(() => resolve()))
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
/** @returns {string[]} This machine's addresses on the local network, to open the dev server from a phone */
|
|
27
|
+
const lanAddresses = () => Object.values(os.networkInterfaces()).flatMap((list) => list ?? [])
|
|
28
|
+
.filter((i) => i.family === "IPv4" && !i.internal)
|
|
29
|
+
.map((i) => i.address)
|
|
30
|
+
|
|
31
|
+
/** @type {import("esbuild").Plugin} */
|
|
32
|
+
const jsWatchPlugin = {
|
|
33
|
+
name: "watch-log",
|
|
34
|
+
setup(build) {
|
|
35
|
+
build.onStart(() => log("JS", "Building JS..."))
|
|
36
|
+
build.onEnd(result => log("JS", result.errors.length ? "Error" : "Done building"))
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Builds, serves and rebuilds what changes.
|
|
42
|
+
* @param {import("./config.js").Site} site
|
|
43
|
+
* @param {{ host?: string, port?: number }} [options]
|
|
44
|
+
*/
|
|
45
|
+
export async function dev(site, { host = "localhost", port = 8080 } = {}) {
|
|
46
|
+
log("WATCH", "Starting to watch for changes...")
|
|
47
|
+
|
|
48
|
+
const contexts = await Promise.all(jsBuilds(site, {
|
|
49
|
+
minify: false,
|
|
50
|
+
sourcemap: true,
|
|
51
|
+
plugins: [jsWatchPlugin]
|
|
52
|
+
}).map(build => esbuild.context(build)))
|
|
53
|
+
|
|
54
|
+
// The port first, so a second dev server stops here, before it wipes the files the running one serves
|
|
55
|
+
try {
|
|
56
|
+
await portFree(port, host)
|
|
57
|
+
} catch (err) {
|
|
58
|
+
await Promise.all(contexts.map(ctx => ctx.dispose()))
|
|
59
|
+
log("SERVE", `Port ${port} is taken, is the dev server already running? (${/** @type {Error} */ (err).message})`)
|
|
60
|
+
process.exit(1)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
await clean(site)
|
|
64
|
+
await copyAssets(site)
|
|
65
|
+
await buildSass(site)
|
|
66
|
+
// What each page is made from, so a change renders just the pages it's in
|
|
67
|
+
let deps = await renderSite(site)
|
|
68
|
+
|
|
69
|
+
// Served only now: a tab left open asks for the page at once, and the server builds the JS for it, which would
|
|
70
|
+
// land in the folder while it's being wiped
|
|
71
|
+
const server = contexts[0] ?? await esbuild.context({})
|
|
72
|
+
await server.serve({ servedir: site.out, host, port })
|
|
73
|
+
await Promise.all(contexts.map(ctx => ctx.watch()))
|
|
74
|
+
const hosts = host === "0.0.0.0" ? ["localhost", ...lanAddresses()] : [host]
|
|
75
|
+
for (const name of hosts) log("SERVE", `http://${name}:${port}/`)
|
|
76
|
+
|
|
77
|
+
/** @type {import("child_process").ChildProcess | null} */
|
|
78
|
+
let rendering = null
|
|
79
|
+
/** @type {Set<string> | "all" | null} The pages to render next, or all of them */
|
|
80
|
+
let pending = null
|
|
81
|
+
/** @type {NodeJS.Timeout | undefined} */
|
|
82
|
+
let soon
|
|
83
|
+
|
|
84
|
+
// In a new process, so the .data.js files and what they import load fresh, Node would otherwise keep the first copy
|
|
85
|
+
const startRender = () => {
|
|
86
|
+
if (rendering || !pending) return
|
|
87
|
+
const job = pending
|
|
88
|
+
pending = null
|
|
89
|
+
const only = job === "all" ? [] : ["--pages", [...job].join(",")]
|
|
90
|
+
rendering = spawn(process.execPath, [CLI, "html", "--root", site.root, ...only], { stdio: ["inherit", "inherit", "inherit", "ipc"] })
|
|
91
|
+
rendering.on("message", (/** @type {{ deps?: Record<string, string[]> }} */ message) => {
|
|
92
|
+
if (message.deps) deps = job === "all" ? message.deps : { ...deps, ...message.deps }
|
|
93
|
+
})
|
|
94
|
+
rendering.on("exit", () => {
|
|
95
|
+
rendering = null
|
|
96
|
+
startRender()
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** @param {string[] | "all"} pages Relative to the pages folder */
|
|
101
|
+
const render = (pages) => {
|
|
102
|
+
pending = pages === "all" || pending === "all" ? "all" : new Set([...pending ?? [], ...pages])
|
|
103
|
+
// A save fires a few events at once, they're rendered together
|
|
104
|
+
clearTimeout(soon)
|
|
105
|
+
soon = setTimeout(startRender, 50)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// A deleted or renamed file would leave its old output behind, so that starts over from an empty folder
|
|
109
|
+
const rebuildAll = async () => {
|
|
110
|
+
log("WATCH", "A file was removed, building everything again...")
|
|
111
|
+
await clean(site)
|
|
112
|
+
await copyAssets(site)
|
|
113
|
+
await buildSass(site)
|
|
114
|
+
await Promise.all(contexts.map(ctx => ctx.rebuild()))
|
|
115
|
+
render("all")
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const pagesDir = posix(path.relative(site.src, site.pages))
|
|
119
|
+
/** @type {NodeJS.Timeout | undefined} */
|
|
120
|
+
let restart
|
|
121
|
+
fs.watch(site.src, { recursive: true }, (_, filename) => {
|
|
122
|
+
if (!filename) return
|
|
123
|
+
const file = path.join(site.src, filename)
|
|
124
|
+
const relative = posix(filename)
|
|
125
|
+
if (!fs.existsSync(file)) {
|
|
126
|
+
clearTimeout(restart)
|
|
127
|
+
restart = setTimeout(() => rebuildAll().catch(err => log("WATCH", err.message)), 200)
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
if (fs.statSync(file).isDirectory()) return
|
|
131
|
+
|
|
132
|
+
const page = relative.startsWith(`${pagesDir}/`) && relative.slice(pagesDir.length + 1).replace(/\.data\.js$/, ".html")
|
|
133
|
+
const using = pagesUsing(deps, relative)
|
|
134
|
+
if (/\.(scss|sass)$/.test(file))
|
|
135
|
+
buildSass(site).catch(err => log("CSS", err.message))
|
|
136
|
+
else if (page && page.endsWith(".html"))
|
|
137
|
+
render([page])
|
|
138
|
+
else if (using.length)
|
|
139
|
+
render(using)
|
|
140
|
+
else if (site.rerender.some(dir => relative === dir || relative.startsWith(`${dir}/`)))
|
|
141
|
+
render("all")
|
|
142
|
+
else if (!isExcluded(site, file))
|
|
143
|
+
copyAssets(site).catch(err => log("ASSET", err.message))
|
|
144
|
+
})
|
|
145
|
+
}
|
package/src/html.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/*
|
|
2
|
+
HTML helpers for a site's .data.js files, also what the builder escapes with.
|
|
3
|
+
html`...` escapes what goes into it, unless it's HTML already: another html`...`, raw(), or a list of those.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** HTML that's safe to put in as-is. */
|
|
7
|
+
export class Html {
|
|
8
|
+
/** @param {string} value */
|
|
9
|
+
constructor(value) {
|
|
10
|
+
this.value = value
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
toString() {
|
|
14
|
+
return this.value
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** @type {Record<string, string>} */
|
|
19
|
+
const ENTITIES = { "&": "&", "<": "<", ">": ">", "\"": """, "'": "'" }
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {string} text
|
|
23
|
+
* @returns {string}
|
|
24
|
+
*/
|
|
25
|
+
export const escapeHtml = (text) => String(text).replace(/[&<>"']/g, (c) => ENTITIES[c])
|
|
26
|
+
|
|
27
|
+
const CHARACTERS = Object.fromEntries(Object.entries(ENTITIES).map(([c, entity]) => [entity, c]))
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {string} html What escapeHtml made
|
|
31
|
+
* @returns {string} The text back
|
|
32
|
+
*/
|
|
33
|
+
export const unescapeHtml = (html) => html.replace(/&(amp|lt|gt|quot|#39);/g, (entity) => CHARACTERS[entity])
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {string} value
|
|
37
|
+
* @returns {Html} Put in as it is, not escaped
|
|
38
|
+
*/
|
|
39
|
+
export const raw = (value) => new Html(value)
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {any} value
|
|
43
|
+
* @returns {string} null, undefined and false are left out, lists are joined
|
|
44
|
+
*/
|
|
45
|
+
const toHtml = (value) => {
|
|
46
|
+
if (value == null || value === false) return ""
|
|
47
|
+
if (Array.isArray(value)) return value.map(toHtml).join("")
|
|
48
|
+
return value instanceof Html ? value.value : escapeHtml(value)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @param {string} name
|
|
53
|
+
* @returns {string} The name as a URL part: "Rak'tika Greatwood" -> "raktika-greatwood"
|
|
54
|
+
*/
|
|
55
|
+
export const slug = (name) => name
|
|
56
|
+
.normalize("NFD")
|
|
57
|
+
.replace(/[̀-ͯ'’*]/g, "")
|
|
58
|
+
.toLowerCase()
|
|
59
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
60
|
+
.replace(/^-|-$/g, "")
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @param {TemplateStringsArray} strings
|
|
64
|
+
* @param {...any} values
|
|
65
|
+
* @returns {Html}
|
|
66
|
+
*/
|
|
67
|
+
export const html = (strings, ...values) => raw(strings.reduce((out, string, i) => out + toHtml(values[i - 1]) + string))
|
package/src/index.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export { clean } from "./assets.js"
|
|
2
|
+
export { build, renderSite } from "./build.js"
|
|
3
|
+
export { defineConfig, loadConfig, resolveConfig } from "./config.js"
|
|
4
|
+
export { dev } from "./dev.js"
|
|
5
|
+
export { escapeHtml, html, raw, unescapeHtml } from "./html.js"
|
|
6
|
+
export { log } from "./log.js"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @typedef {import("./config.js").Config} Config
|
|
10
|
+
* @typedef {import("./config.js").Site} Site
|
|
11
|
+
* @typedef {import("./config.js").JsConfig} JsConfig
|
|
12
|
+
* @typedef {import("./config.js").SeoConfig} SeoConfig
|
|
13
|
+
* @typedef {import("./config.js").SitemapConfig} SitemapConfig
|
|
14
|
+
* @typedef {import("./config.js").RobotsConfig} RobotsConfig
|
|
15
|
+
* @typedef {import("./config.js").ActiveLink} ActiveLink
|
|
16
|
+
* @typedef {import("./config.js").TagFunction} TagFunction
|
|
17
|
+
* @typedef {import("./config.js").Transform} Transform
|
|
18
|
+
* @typedef {import("./config.js").Hook} Hook
|
|
19
|
+
* @typedef {import("./routes.js").Route} Route
|
|
20
|
+
*/
|
package/src/log.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {string} type Short tag, like "HTML"
|
|
3
|
+
* @param {string} message
|
|
4
|
+
*/
|
|
5
|
+
export function log(type, message) {
|
|
6
|
+
const now = new Date()
|
|
7
|
+
/** @param {number} num */
|
|
8
|
+
const pad = (num) => String(num).padStart(2, "0")
|
|
9
|
+
const formattedDate =
|
|
10
|
+
now.getFullYear() + "-" +
|
|
11
|
+
pad(now.getMonth() + 1) + "-" +
|
|
12
|
+
pad(now.getDate()) + " " +
|
|
13
|
+
pad(now.getHours()) + ":" +
|
|
14
|
+
pad(now.getMinutes()) + ":" +
|
|
15
|
+
pad(now.getSeconds())
|
|
16
|
+
|
|
17
|
+
console.log(`[ ${type.padStart(5)} ] ${formattedDate} ${message}`)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {string} file
|
|
22
|
+
* @returns {string} With / separators, however the OS writes it
|
|
23
|
+
*/
|
|
24
|
+
export const posix = (file) => file.split("\\").join("/")
|