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/render.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import crypto from "crypto"
|
|
2
|
+
import fs from "fs"
|
|
3
|
+
import path from "path"
|
|
4
|
+
import { posix } from "./log.js"
|
|
5
|
+
import { seoTags } from "./seo.js"
|
|
6
|
+
import { includeHtml, parseAttrs, parseTemplate, renderNodes } from "./template.js"
|
|
7
|
+
|
|
8
|
+
/*
|
|
9
|
+
Tags, usable in any page or partial. Paths start at src, which the build mirrors:
|
|
10
|
+
- <x-html src="partials/nav.html"/> pastes in the partial, partials can include partials
|
|
11
|
+
- <x-each>, <x-if> loops and conditions, see template.js
|
|
12
|
+
- <x-js src="/js/index.js"/> <script src="/js/index.js?v=hash">, add `module` for one of the config's
|
|
13
|
+
modules, which also gets a modulepreload for each chunk it imports up front,
|
|
14
|
+
and preload="/js/data/x.json" adds one for a file it import()s later
|
|
15
|
+
- <x-css src="/css/index.css"/> <link href="/css/index.css?v=hash" rel="stylesheet">
|
|
16
|
+
- <x-seo title="..." description="..."/> see seo.js
|
|
17
|
+
- the config's own tags
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* What one build has worked out so far, shared by its pages.
|
|
22
|
+
* @typedef {object} BuildState
|
|
23
|
+
* @property {import("./data.js").SiteData} data The data folder
|
|
24
|
+
* @property {Map<string, string>} assetUrls "/js/index.js" -> "/js/index.js?v=hash"
|
|
25
|
+
* @property {Map<string, { nodes: import("./template.js").Node[], partials: Set<string> }>} templates A page's file ->
|
|
26
|
+
* its HTML with the partials pasted in, and which those were
|
|
27
|
+
* @property {Map<string, Set<string>>} used A page's file -> the names its pages filled in, for what they depend on
|
|
28
|
+
* @property {Map<string, string[]>} chunkImports "/js/gathering.js" -> the chunks it imports up front
|
|
29
|
+
* @property {Map<string, string>} entryChunks "/js/data/x.json" -> "/js/chunks/x-hash.js"
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** @returns {BuildState} */
|
|
33
|
+
export const buildState = () => ({
|
|
34
|
+
data: { values: {}, files: {} },
|
|
35
|
+
assetUrls: new Map(),
|
|
36
|
+
templates: new Map(),
|
|
37
|
+
used: new Map(),
|
|
38
|
+
chunkImports: new Map(),
|
|
39
|
+
entryChunks: new Map()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param {string} file "js/index.js" or "/js/index.js"
|
|
44
|
+
* @returns {string} "/js/index.js"
|
|
45
|
+
*/
|
|
46
|
+
const sitePath = (file) => file.startsWith("/") ? file : `/${file}`
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {import("./config.js").Site} site
|
|
50
|
+
* @param {BuildState} state
|
|
51
|
+
* @param {string} from The page, for errors
|
|
52
|
+
* @param {boolean} hashed Production builds add ?v=hash, so browsers and CDNs fetch it fresh after a deploy
|
|
53
|
+
* @returns {Record<string, import("./config.js").TagFunction>}
|
|
54
|
+
*/
|
|
55
|
+
function coreTags(site, state, from, hashed) {
|
|
56
|
+
/**
|
|
57
|
+
* @param {string} tag
|
|
58
|
+
* @param {Record<string, string | true>} attrs
|
|
59
|
+
* @param {string} name
|
|
60
|
+
*/
|
|
61
|
+
const need = (tag, attrs, name) => {
|
|
62
|
+
const value = attrs[name]
|
|
63
|
+
if (typeof value !== "string") throw new Error(`${from}: <${tag}> needs a ${name}="..."`)
|
|
64
|
+
return value
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @param {string} tag
|
|
69
|
+
* @param {string} file "/js/index.js"
|
|
70
|
+
*/
|
|
71
|
+
const assetUrl = (tag, file) => {
|
|
72
|
+
if (!hashed) return file
|
|
73
|
+
let url = state.assetUrls.get(file)
|
|
74
|
+
if (!url) {
|
|
75
|
+
const built = path.join(site.out, file)
|
|
76
|
+
if (!fs.existsSync(built)) throw new Error(`${from}: <${tag}> can't find ${file} in the build`)
|
|
77
|
+
url = `${file}?v=${crypto.createHash("md5").update(fs.readFileSync(built)).digest("hex").slice(0, 8)}`
|
|
78
|
+
state.assetUrls.set(file, url)
|
|
79
|
+
}
|
|
80
|
+
return url
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
"x-seo": ({ attrs, route }) => seoTags(site, route, attrs),
|
|
85
|
+
"x-js": ({ attrs }) => {
|
|
86
|
+
const file = sitePath(need("x-js", attrs, "src"))
|
|
87
|
+
const preload = attrs.preload ? sitePath(need("x-js", attrs, "preload")) : null
|
|
88
|
+
// Only known when the JS was built in this process, so not on dev renders
|
|
89
|
+
if (preload && state.entryChunks.size && !state.entryChunks.has(preload)) {
|
|
90
|
+
throw new Error(`${from}: <x-js preload="${preload}"> isn't imported by any of the modules`)
|
|
91
|
+
}
|
|
92
|
+
const chunks = [
|
|
93
|
+
...(attrs.module ? state.chunkImports.get(file) ?? [] : []),
|
|
94
|
+
...preload ? [state.entryChunks.get(preload) ?? []].flat() : []
|
|
95
|
+
]
|
|
96
|
+
return [
|
|
97
|
+
...chunks.map(chunk => `<link rel="modulepreload" href="${chunk}">`),
|
|
98
|
+
`<script${attrs.module ? " type=\"module\"" : ""} src="${assetUrl("x-js", file)}"></script>`
|
|
99
|
+
]
|
|
100
|
+
},
|
|
101
|
+
"x-css": ({ attrs }) => `<link href="${assetUrl("x-css", sitePath(need("x-css", attrs, "src")))}" type="text/css" rel="stylesheet">`
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* @param {import("./config.js").ActiveLink} decide The config's own say
|
|
107
|
+
* @param {import("./routes.js").Route} route
|
|
108
|
+
* @param {string} href
|
|
109
|
+
* @returns {"page" | "section" | false}
|
|
110
|
+
*/
|
|
111
|
+
function activeLink(decide, route, href) {
|
|
112
|
+
const own = decide(href, route)
|
|
113
|
+
if (own !== undefined) return own
|
|
114
|
+
if (href === route.url) return "page"
|
|
115
|
+
if (href !== "/" && href.endsWith("/") && route.url.startsWith(href)) return "section"
|
|
116
|
+
return false
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* @param {import("./config.js").ActiveLink} decide
|
|
121
|
+
* @param {import("./routes.js").Route} route
|
|
122
|
+
* @param {string} html
|
|
123
|
+
* @returns {string}
|
|
124
|
+
*/
|
|
125
|
+
const markActiveLinks = (decide, route, html) => html.replace(/<a\b([^>]*\bhref="([^"]+)"[^>]*)>/g, (tag, attrs, href) => {
|
|
126
|
+
const active = activeLink(decide, route, href)
|
|
127
|
+
return active ? `<a${attrs} aria-current="${active === "page" ? "page" : "true"}">` : tag
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* @param {import("./config.js").Site} site
|
|
132
|
+
* @param {BuildState} state
|
|
133
|
+
* @param {import("./routes.js").Route} route
|
|
134
|
+
* @param {boolean} hashed Add ?v=hash to the assets
|
|
135
|
+
* @returns {string} The finished HTML, before minifying
|
|
136
|
+
*/
|
|
137
|
+
export function renderPage(site, state, route, hashed) {
|
|
138
|
+
const from = posix(path.relative(site.src, path.join(site.pages, route.source)))
|
|
139
|
+
let template = state.templates.get(route.source)
|
|
140
|
+
if (template === undefined) {
|
|
141
|
+
const partials = new Set()
|
|
142
|
+
const html = includeHtml(site.src, fs.readFileSync(path.join(site.pages, route.source), "utf8"), from, partials)
|
|
143
|
+
template = { nodes: parseTemplate(html, from), partials }
|
|
144
|
+
state.templates.set(route.source, template)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const used = state.used.get(route.source) ?? new Set()
|
|
148
|
+
state.used.set(route.source, used)
|
|
149
|
+
const filled = renderNodes(template.nodes, { ...state.data.values, ...route.props }, { from, used })
|
|
150
|
+
|
|
151
|
+
const tags = { ...site.tags, ...coreTags(site, state, from, hashed) }
|
|
152
|
+
const transformed = site.transforms.reduce((html, transform) => transform(html, route, site), filled)
|
|
153
|
+
|
|
154
|
+
// Tags on a line of their own are indented like it, every line of what they turn into
|
|
155
|
+
const html = transformed.replace(/([ \t]*)<(x-[a-z][a-z-]*)((?:\s+[\w-]+(?:="[^"]*")?)*)\s*\/?>/g, (tag, space, name, attrs, offset, page) => {
|
|
156
|
+
if (!tags[name]) throw new Error(`${from}: unknown tag <${name}>`)
|
|
157
|
+
const lineStart = offset === 0 || page[offset - 1] === "\n"
|
|
158
|
+
const output = [tags[name]({ attrs: parseAttrs(attrs), route, site })].flat()
|
|
159
|
+
return lineStart ? output.map(line => space + line).join("\n") : space + output.join("\n")
|
|
160
|
+
})
|
|
161
|
+
return site.activeLinks ? markActiveLinks(site.activeLinks, route, html) : html
|
|
162
|
+
}
|
package/src/routes.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import fs from "fs"
|
|
2
|
+
import path from "path"
|
|
3
|
+
import { pathToFileURL } from "url"
|
|
4
|
+
import { slug } from "./html.js"
|
|
5
|
+
import { posix } from "./log.js"
|
|
6
|
+
import { lookup } from "./template.js"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A page to write. Most come one to a file in the pages folder, a [name].html file gives one per item.
|
|
10
|
+
* Its props can have `files`, more files to write, by their path in the build.
|
|
11
|
+
* @typedef {object} Route
|
|
12
|
+
* @property {string} source "gathering/[item].html", relative to the pages folder
|
|
13
|
+
* @property {string} output Where it goes in the build: "gathering/cedar-log/index.html"
|
|
14
|
+
* @property {string} url "/gathering/cedar-log/"
|
|
15
|
+
* @property {Record<string, any>} props Its own values, the data folder's are added when it's rendered
|
|
16
|
+
* @property {string[]} sources Files its content comes from, relative to src, for the sitemap's lastmod
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {string} dir
|
|
21
|
+
* @param {string} pages The pages folder
|
|
22
|
+
* @returns {string[]} Every .html file in it and its subfolders, relative to the pages folder
|
|
23
|
+
*/
|
|
24
|
+
const htmlFiles = (dir, pages = dir) => fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => {
|
|
25
|
+
const file = path.join(dir, entry.name)
|
|
26
|
+
if (entry.isDirectory()) return htmlFiles(file, pages)
|
|
27
|
+
return entry.name.endsWith(".html") ? [posix(path.relative(pages, file))] : []
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {import("./config.js").Site} site
|
|
32
|
+
* @param {string} file "gathering/index.html", "404.html"
|
|
33
|
+
* @returns {string} Where it goes in the build. Other names get their own folder, "about.html" -> "about/index.html"
|
|
34
|
+
*/
|
|
35
|
+
const pageOutput = (site, file) => file === site.notFound || file.endsWith("index.html") ? file : file.replace(/\.html$/, "/index.html")
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {string} output "gathering/index.html"
|
|
39
|
+
* @returns {string} "/gathering/", "/" for index.html
|
|
40
|
+
*/
|
|
41
|
+
export const outputUrl = (output) => "/" + output.replace(/(^|\/)index\.html$/, "$1")
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Every page, each folder's index.html first, so the front page leads the sitemap and a list comes before its items.
|
|
45
|
+
* - about.html is one page. Its .data.js, when it has one, default exports `() => values`
|
|
46
|
+
* - [projects.name].html is a page per item of the data folder's projects, by its name, the item is {{ page }}
|
|
47
|
+
* - [name].html is a page per item its .data.js returns, each `{ slug, ...values }`
|
|
48
|
+
* A .data.js can return a `sources` list (relative to src) of the files it's made from, for the sitemap's dates.
|
|
49
|
+
* @param {import("./config.js").Site} site
|
|
50
|
+
* @param {import("./data.js").SiteData} siteData
|
|
51
|
+
* @param {Set<string>} [only] Just these pages, relative to the pages folder
|
|
52
|
+
* @returns {Promise<Route[]>}
|
|
53
|
+
*/
|
|
54
|
+
export async function listRoutes(site, siteData, only) {
|
|
55
|
+
const pagesDir = posix(path.relative(site.src, site.pages))
|
|
56
|
+
/** @param {string} file */
|
|
57
|
+
const sortKey = (file) => file.replace(/index\.html$/, "")
|
|
58
|
+
const files = htmlFiles(site.pages)
|
|
59
|
+
.filter(file => !only || only.has(file))
|
|
60
|
+
.sort((a, b) => sortKey(a).localeCompare(sortKey(b)))
|
|
61
|
+
const routes = []
|
|
62
|
+
|
|
63
|
+
for (const source of files) {
|
|
64
|
+
const dataFile = source.replace(/\.html$/, ".data.js")
|
|
65
|
+
const hasData = fs.existsSync(path.join(site.pages, dataFile))
|
|
66
|
+
const own = [`${pagesDir}/${source}`, ...hasData ? [`${pagesDir}/${dataFile}`] : []]
|
|
67
|
+
|
|
68
|
+
const fromData = source.match(/\[([\w.]+)\.(\w+)\]\.html$/)
|
|
69
|
+
if (fromData) {
|
|
70
|
+
if (hasData) throw new Error(`${pagesDir}/${source}: gets its pages from ${fromData[1]}, so it can't have a .data.js too`)
|
|
71
|
+
routes.push(...pagesFromData(site, siteData, source, fromData[1], fromData[2]))
|
|
72
|
+
continue
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const data = hasData ? await (await import(pathToFileURL(path.join(site.pages, dataFile)).href)).default() : {}
|
|
76
|
+
|
|
77
|
+
if (!/\[\w+\]\.html$/.test(source)) {
|
|
78
|
+
const output = pageOutput(site, source)
|
|
79
|
+
routes.push({ source, output, url: outputUrl(output), props: data, sources: [...own, ...data.sources ?? []] })
|
|
80
|
+
continue
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!hasData) {
|
|
84
|
+
const name = source.match(/\[(\w+)\]\.html$/)?.[1]
|
|
85
|
+
throw new Error(`${pagesDir}/${source}: needs to say what it makes pages of, like [projects.${name}].html for one per item of ${site.data}/projects, or have a .data.js that returns them`)
|
|
86
|
+
}
|
|
87
|
+
if (!Array.isArray(data)) throw new Error(`${pagesDir}/${dataFile}: a [name].html page needs a list of pages`)
|
|
88
|
+
const seen = new Set()
|
|
89
|
+
for (const { slug, sources = [], ...props } of data) {
|
|
90
|
+
if (seen.has(slug)) throw new Error(`${pagesDir}/${dataFile}: two pages are both "${slug}"`)
|
|
91
|
+
seen.add(slug)
|
|
92
|
+
const output = source.replace(/\[\w+\]\.html$/, `${slug}/index.html`)
|
|
93
|
+
// Dated by its data alone, a template tweak would otherwise date thousands of pages at once
|
|
94
|
+
routes.push({ source, output, url: outputUrl(output), props, sources: sources.length ? sources : own })
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return routes
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A page per item of a list in the data folder.
|
|
102
|
+
* @param {import("./config.js").Site} site
|
|
103
|
+
* @param {import("./data.js").SiteData} siteData
|
|
104
|
+
* @param {string} source "projects/[projects.name].html"
|
|
105
|
+
* @param {string} list "projects"
|
|
106
|
+
* @param {string} key "name", what each item's slug is made from
|
|
107
|
+
* @returns {Route[]}
|
|
108
|
+
*/
|
|
109
|
+
function pagesFromData(site, siteData, source, list, key) {
|
|
110
|
+
const from = posix(path.relative(site.src, path.join(site.pages, source)))
|
|
111
|
+
const items = lookup(siteData.values, list)
|
|
112
|
+
if (!Array.isArray(items)) throw new Error(`${from}: ${site.data} has no list called ${list}`)
|
|
113
|
+
const dataFile = siteData.files[list.split(".")[0]]
|
|
114
|
+
|
|
115
|
+
const seen = new Set()
|
|
116
|
+
return items.map((item, i) => {
|
|
117
|
+
if (item?.[key] === undefined) throw new Error(`${from}: item ${i} of ${list} has no ${key}`)
|
|
118
|
+
const pageSlug = slug(String(item[key]))
|
|
119
|
+
if (seen.has(pageSlug)) throw new Error(`${from}: two of ${list} are both "${pageSlug}"`)
|
|
120
|
+
seen.add(pageSlug)
|
|
121
|
+
const output = source.replace(/\[[\w.]+\]\.html$/, `${pageSlug}/index.html`)
|
|
122
|
+
// Dated by its data alone, a template tweak would otherwise date every page at once
|
|
123
|
+
return { source, output, url: outputUrl(output), props: { page: item }, sources: [dataFile] }
|
|
124
|
+
})
|
|
125
|
+
}
|
package/src/seo.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { escapeHtml, unescapeHtml } from "./html.js"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @param {string} description HTML, as the <x-seo> attribute has it
|
|
5
|
+
* @param {number} max
|
|
6
|
+
* @returns {string} At most `max` characters of text, cut at a word so it ends on "...", still HTML
|
|
7
|
+
*/
|
|
8
|
+
export function fitDescription(description, max) {
|
|
9
|
+
const text = unescapeHtml(description)
|
|
10
|
+
if (text.length <= max) return description
|
|
11
|
+
const cut = text.slice(0, max - 3)
|
|
12
|
+
return escapeHtml(`${cut.slice(0, cut.lastIndexOf(" ")).replace(/[\s,.;:-]+$/, "")}...`)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The <title>, description, canonical URL, link preview tags and breadcrumbs for <x-seo title="..." description="..."/>.
|
|
17
|
+
* Optional: image="..." image-alt="..." image-size="WxH" for its own preview image, and section="..." name="..." for its
|
|
18
|
+
* breadcrumb in search results.
|
|
19
|
+
* @param {import("./config.js").Site} site
|
|
20
|
+
* @param {import("./routes.js").Route} route
|
|
21
|
+
* @param {Record<string, string | true>} attrs
|
|
22
|
+
* @returns {string[]} The tags, one per line
|
|
23
|
+
*/
|
|
24
|
+
export function seoTags(site, route, attrs) {
|
|
25
|
+
/**
|
|
26
|
+
* @param {string} name
|
|
27
|
+
* @returns {string | undefined}
|
|
28
|
+
*/
|
|
29
|
+
const attr = (name) => {
|
|
30
|
+
const value = attrs[name]
|
|
31
|
+
return typeof value === "string" ? value : undefined
|
|
32
|
+
}
|
|
33
|
+
const { siteName, locale, maxTitle, maxDescription } = site.seo
|
|
34
|
+
const pageTitle = attr("title")
|
|
35
|
+
const pageDescription = attr("description")
|
|
36
|
+
if (!pageTitle || !pageDescription) throw new Error(`${route.source}: <x-seo> needs a title and a description`)
|
|
37
|
+
const description = fitDescription(pageDescription, maxDescription)
|
|
38
|
+
// The front page keeps it, for people searching for the site by its name
|
|
39
|
+
const named = `${pageTitle} - ${siteName}`
|
|
40
|
+
const title = route.url === "/" || unescapeHtml(named).length <= maxTitle ? named : pageTitle
|
|
41
|
+
|
|
42
|
+
const url = `${site.url}${route.url}`
|
|
43
|
+
/**
|
|
44
|
+
* @param {string} key
|
|
45
|
+
* @param {string} name
|
|
46
|
+
* @param {string | number} content
|
|
47
|
+
*/
|
|
48
|
+
const meta = (key, name, content) => `<meta ${key}="${name}" content="${content}">`
|
|
49
|
+
// The attributes are HTML, the JSON is in a <script>, where only "</script" could end it early
|
|
50
|
+
/** @param {object} data */
|
|
51
|
+
const jsonLd = (data) => `<script type="application/ld+json">${JSON.stringify(data).replaceAll("<", "\\u003c")}</script>`
|
|
52
|
+
|
|
53
|
+
// Lets Google show the site's name in results, it only reads this from the front page
|
|
54
|
+
const siteNameLd = route.url === "/"
|
|
55
|
+
? [jsonLd({ "@context": "https://schema.org", "@type": "WebSite", name: siteName, url })]
|
|
56
|
+
: []
|
|
57
|
+
|
|
58
|
+
// section="Gathering" name="Cedar Log" shows as site › Gathering › Cedar Log in results
|
|
59
|
+
const section = attr("section")
|
|
60
|
+
const name = attr("name")
|
|
61
|
+
const crumbs = [
|
|
62
|
+
[siteName, `${site.url}/`],
|
|
63
|
+
...section ? [[section, `${site.url}${route.url.replace(/[^/]+\/$/, "")}`]] : [],
|
|
64
|
+
[name ?? "", url]
|
|
65
|
+
]
|
|
66
|
+
const breadcrumbs = name
|
|
67
|
+
? [jsonLd({
|
|
68
|
+
"@context": "https://schema.org",
|
|
69
|
+
"@type": "BreadcrumbList",
|
|
70
|
+
itemListElement: crumbs.map(([name, item], i) => ({ "@type": "ListItem", position: i + 1, name: unescapeHtml(name), item }))
|
|
71
|
+
})]
|
|
72
|
+
: []
|
|
73
|
+
|
|
74
|
+
const [width, height] = (attr("image-size") ?? "").split("x").map(Number)
|
|
75
|
+
const image = attr("image")
|
|
76
|
+
? { src: `${site.url}${attr("image")}`, width, height, alt: attr("image-alt") ?? "" }
|
|
77
|
+
: site.seo.image && { ...site.seo.image, src: `${site.url}${site.seo.image.src}` }
|
|
78
|
+
// A wide image shows across the whole preview, a square one as a thumbnail next to the text
|
|
79
|
+
const card = image && image.width > image.height ? "summary_large_image" : "summary"
|
|
80
|
+
|
|
81
|
+
return [
|
|
82
|
+
`<title>${title}</title>`,
|
|
83
|
+
meta("name", "description", description),
|
|
84
|
+
`<link rel="canonical" href="${url}">`,
|
|
85
|
+
// Lets Google show the preview image big, in results and Discover, it otherwise picks a size itself
|
|
86
|
+
meta("name", "robots", "max-image-preview:large"),
|
|
87
|
+
meta("property", "og:locale", locale),
|
|
88
|
+
meta("property", "og:type", "website"),
|
|
89
|
+
meta("property", "og:site_name", siteName),
|
|
90
|
+
meta("property", "og:title", title),
|
|
91
|
+
meta("property", "og:description", description),
|
|
92
|
+
meta("property", "og:url", url),
|
|
93
|
+
...image
|
|
94
|
+
? [
|
|
95
|
+
meta("property", "og:image", image.src),
|
|
96
|
+
meta("property", "og:image:width", image.width),
|
|
97
|
+
meta("property", "og:image:height", image.height),
|
|
98
|
+
meta("property", "og:image:alt", image.alt)
|
|
99
|
+
]
|
|
100
|
+
: [],
|
|
101
|
+
meta("name", "twitter:card", card),
|
|
102
|
+
...siteNameLd,
|
|
103
|
+
...breadcrumbs
|
|
104
|
+
]
|
|
105
|
+
}
|
package/src/site.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { execFileSync } from "child_process"
|
|
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 {Map<string, string | null>} cache Sorted file list -> its lastmod, many pages are made from the same files
|
|
9
|
+
* @param {string[]} files Relative to src
|
|
10
|
+
* @returns {string | null} YYYY-MM-DD of the last commit that changed one of them, null without git
|
|
11
|
+
*/
|
|
12
|
+
function lastModified(site, cache, files) {
|
|
13
|
+
const key = [...new Set(files)].sort().join("|")
|
|
14
|
+
if (!cache.has(key)) {
|
|
15
|
+
try {
|
|
16
|
+
const date = execFileSync("git", ["log", "-1", "--format=%cs", "--", ...key.split("|").map(f => posix(path.relative(site.root, path.join(site.src, f))))], {
|
|
17
|
+
cwd: site.root,
|
|
18
|
+
encoding: "utf8",
|
|
19
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
20
|
+
}).trim()
|
|
21
|
+
// Not committed yet, so changed today
|
|
22
|
+
cache.set(key, date || new Date().toISOString().slice(0, 10))
|
|
23
|
+
} catch {
|
|
24
|
+
cache.set(key, null)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return cache.get(key) ?? null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Writes CNAME, the sitemaps and robots.txt.
|
|
32
|
+
* @param {import("./config.js").Site} site
|
|
33
|
+
* @param {import("./routes.js").Route[]} routes
|
|
34
|
+
* @param {boolean} [dated] Add each page's lastmod, from git, which takes a moment
|
|
35
|
+
*/
|
|
36
|
+
export async function writeSiteFiles(site, routes, dated = true) {
|
|
37
|
+
log("SITE", "Writing CNAME, the sitemaps and robots.txt")
|
|
38
|
+
|
|
39
|
+
// Every GitHub Pages deploy wipes the branch, so the custom domain has to be written each time
|
|
40
|
+
if (site.cname) fs.writeFileSync(path.join(site.out, "CNAME"), site.domain + "\n")
|
|
41
|
+
|
|
42
|
+
/** @type {Map<string, { url: string, lastmod: string | null | false }[]>} */
|
|
43
|
+
const sitemaps = new Map()
|
|
44
|
+
if (site.sitemap) {
|
|
45
|
+
const cache = new Map()
|
|
46
|
+
// A sitemap per section behind a sitemap index, so Search Console shows how each one is indexed on its own
|
|
47
|
+
for (const route of routes.filter(route => route.source !== site.notFound)) {
|
|
48
|
+
const section = route.url.split("/")[1]
|
|
49
|
+
const name = site.sitemap.sections.includes(section) ? section : "pages"
|
|
50
|
+
const pages = sitemaps.get(name) ?? []
|
|
51
|
+
sitemaps.set(name, pages)
|
|
52
|
+
pages.push({ url: route.url, lastmod: dated && site.sitemap.dates && lastModified(site, cache, route.sources) })
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @param {string} tag
|
|
57
|
+
* @param {string[]} entries
|
|
58
|
+
*/
|
|
59
|
+
const xml = (tag, entries) => [
|
|
60
|
+
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
|
|
61
|
+
`<${tag} xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">`,
|
|
62
|
+
...entries,
|
|
63
|
+
`</${tag}>`,
|
|
64
|
+
""
|
|
65
|
+
].join("\n")
|
|
66
|
+
/**
|
|
67
|
+
* @param {string} tag
|
|
68
|
+
* @param {string} loc
|
|
69
|
+
* @param {string | null | false | undefined} lastmod
|
|
70
|
+
*/
|
|
71
|
+
const entry = (tag, loc, lastmod) => ` <${tag}><loc>${loc}</loc>${lastmod ? `<lastmod>${lastmod}</lastmod>` : ""}</${tag}>`
|
|
72
|
+
|
|
73
|
+
const index = []
|
|
74
|
+
for (const [name, pages] of sitemaps) {
|
|
75
|
+
const file = `sitemap-${name}.xml`
|
|
76
|
+
fs.writeFileSync(path.join(site.out, file), xml("urlset", pages.map(p => entry("url", `${site.url}${p.url}`, p.lastmod))))
|
|
77
|
+
// YYYY-MM-DD sorts as text, so the newest is the last
|
|
78
|
+
const newest = pages.map(p => p.lastmod).filter(Boolean).sort().at(-1)
|
|
79
|
+
index.push(entry("sitemap", `${site.url}/${file}`, newest))
|
|
80
|
+
}
|
|
81
|
+
fs.writeFileSync(path.join(site.out, "sitemap.xml"), xml("sitemapindex", index))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (site.robots) {
|
|
85
|
+
fs.writeFileSync(path.join(site.out, "robots.txt"), [
|
|
86
|
+
...site.robots.block.flatMap(bot => [`User-agent: ${bot}`, "Disallow: /", ""]),
|
|
87
|
+
"User-agent: *",
|
|
88
|
+
"Allow: /",
|
|
89
|
+
...site.robots.disallow.map(dir => `Disallow: ${dir}`),
|
|
90
|
+
"",
|
|
91
|
+
...site.sitemap ? [`Sitemap: ${site.url}/sitemap.xml`, ""] : []
|
|
92
|
+
].join("\n"))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
log("SITE", `Done, ${[...sitemaps.values()].flat().length} pages in the sitemap`)
|
|
96
|
+
}
|