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/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 = { "&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&#39;" }
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("/")