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/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
+ }