mothwing 0.0.3 → 0.0.4
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/README.md +3 -3
- package/package.json +1 -1
- package/src/config.js +2 -1
- package/src/routes.js +3 -1
- package/src/seo.js +2 -2
- package/src/site.js +19 -10
- package/types/config.d.ts +2 -1
package/README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# mothwing
|
|
2
|
-
A small static site builder for pages as light as a moth's wing. Plain HTML in, plain HTML out, with hashed asset links and modulepreloads for code split JS. The rest is there when you ask for it: SEO tags, a Content-Security-Policy that allows only your own inline scripts, sitemaps dated from git, robots.txt and minified HTML.
|
|
2
|
+
**Build, don't re-learn.** A small static site builder for pages as light as a moth's wing. Plain HTML in, plain HTML out, with hashed asset links and modulepreloads for code split JS. The rest is there when you ask for it: SEO tags, a Content-Security-Policy that allows only your own inline scripts, sitemaps dated from git, robots.txt and minified HTML.
|
|
3
3
|
|
|
4
4
|
It was born as the build script of [ffxiv.watch](https://ffxiv.watch), and got tweaked over time until I was a little too proud of it to keep it there. So it branched out into its own thing, for any site that wants the same.
|
|
5
5
|
|
|
@@ -95,7 +95,7 @@ Every tag is ordinary HTML to an editor or formatter. Templates formatted by Pre
|
|
|
95
95
|
## Pages
|
|
96
96
|
`src/pages/about.html` is `/about/`, `src/pages/blog/index.html` is `/blog/`.
|
|
97
97
|
|
|
98
|
-
`[projects.id].html` makes a page per item of `projects` in the data folder, with its `id` as the URL: `src/pages/projects/[projects.id].html` is `/projects/hexclock/` for `{ "id": "hexclock" }`. The item is `{{ page }}`, and the list links to the pages by the same field, `/projects/{{ project.id }}/`. An item with a `sources` list is dated by those files instead of the whole data file, so editing one item doesn't date every page.
|
|
98
|
+
`[projects.id].html` makes a page per item of `projects` in the data folder, with its `id` as the URL: `src/pages/projects/[projects.id].html` is `/projects/hexclock/` for `{ "id": "hexclock" }`. An id that's fine in a URL is kept as it is, `create_ssh_key` included, anything else is made into one: `Hex Clock` is `hex-clock`. The item is `{{ page }}`, and the list links to the pages by the same field, `/projects/{{ project.id }}/`. An item with a `sources` list is dated by those files instead of the whole data file, so editing one item doesn't date every page.
|
|
99
99
|
|
|
100
100
|
### .data.js
|
|
101
101
|
For values that take more than the data folder, a page can have a `.data.js` next to it, whose default export returns them. Its values come before the data folder's:
|
|
@@ -151,7 +151,7 @@ Off until you turn them on. `true` turns one on as it comes, an object changes h
|
|
|
151
151
|
| --- | --- | --- |
|
|
152
152
|
| `minify` | Minifies the HTML of production builds | html-minifier-terser options |
|
|
153
153
|
| `csp` | A `<meta>` Content-Security-Policy: only your own files, and your inline scripts by their hash | Directives over the defaults, `{ "style-src": "'self' 'unsafe-inline'" }` |
|
|
154
|
-
| `sitemap` | `sitemap.xml
|
|
154
|
+
| `sitemap` | `sitemap.xml` with every page, each dated by the last commit to its files | `sections` that get a sitemap each, behind a sitemap index, for big sites. `dates: false` to leave git out |
|
|
155
155
|
| `robots` | `robots.txt` that allows everything, pointing at the sitemap | `block` crawlers by name, `disallow` paths |
|
|
156
156
|
| `cname` | `CNAME` with the domain, for GitHub Pages | |
|
|
157
157
|
| `activeLinks` | `aria-current="page"` on links to the page they're on, `"true"` on links to its section | A function `(href, route)` returning `"page"`, `"section"` or `false`, `undefined` keeps the default |
|
package/package.json
CHANGED
package/src/config.js
CHANGED
|
@@ -55,7 +55,8 @@ export const CONFIG_FILE = "mothwing.config.js"
|
|
|
55
55
|
|
|
56
56
|
/**
|
|
57
57
|
* @typedef {object} SitemapConfig
|
|
58
|
-
* @property {string[]} [sections] Sections that get a sitemap of their own, the rest share
|
|
58
|
+
* @property {string[]} [sections] Sections that get a sitemap of their own behind a sitemap index, the rest share
|
|
59
|
+
* sitemap-pages.xml. Without any, sitemap.xml has every page itself
|
|
59
60
|
* @property {boolean} [dates] Date each page by the last git commit to its files. true
|
|
60
61
|
*/
|
|
61
62
|
|
package/src/routes.js
CHANGED
|
@@ -116,7 +116,9 @@ function pagesFromData(site, siteData, source, list, key) {
|
|
|
116
116
|
const seen = new Set()
|
|
117
117
|
return items.map((item, i) => {
|
|
118
118
|
if (item?.[key] === undefined) throw new Error(`${from}: item ${i} of ${list} has no ${key}`)
|
|
119
|
-
|
|
119
|
+
// A value that's fine in a URL already is kept as it is, so existing links like /docs/create_ssh_key/ keep working
|
|
120
|
+
const value = String(item[key])
|
|
121
|
+
const pageSlug = /^[\w.-]+$/.test(value) ? value : slug(value)
|
|
120
122
|
if (seen.has(pageSlug)) throw new Error(`${from}: two of ${list} are both "${pageSlug}"`)
|
|
121
123
|
seen.add(pageSlug)
|
|
122
124
|
const output = source.replace(/\[[\w.]+\]\.html$/, `${pageSlug}/index.html`)
|
package/src/seo.js
CHANGED
|
@@ -35,8 +35,8 @@ export function seoTags(site, route, attrs) {
|
|
|
35
35
|
const pageDescription = attr("description")
|
|
36
36
|
if (!pageTitle || !pageDescription) throw new Error(`${route.source}: <x-seo> needs a title and a description`)
|
|
37
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}`
|
|
38
|
+
// The front page keeps it, for people searching for the site by its name, unless it's the site's name already
|
|
39
|
+
const named = unescapeHtml(pageTitle) === siteName ? pageTitle : `${pageTitle} - ${siteName}`
|
|
40
40
|
const title = route.url === "/" || unescapeHtml(named).length <= maxTitle ? named : pageTitle
|
|
41
41
|
|
|
42
42
|
const url = `${site.url}${route.url}`
|
package/src/site.js
CHANGED
|
@@ -34,7 +34,8 @@ function lastModified(site, cache, files) {
|
|
|
34
34
|
* @param {boolean} [dated] Add each page's lastmod, from git, which takes a moment
|
|
35
35
|
*/
|
|
36
36
|
export async function writeSiteFiles(site, routes, dated = true) {
|
|
37
|
-
const
|
|
37
|
+
const sitemapFiles = site.sitemap && (site.sitemap.sections.length ? "the sitemaps" : "the sitemap")
|
|
38
|
+
const files = [site.cname && "CNAME", sitemapFiles, site.robots && "robots.txt"].filter(Boolean)
|
|
38
39
|
if (!files.length) return
|
|
39
40
|
log("SITE", `Writing ${files.length > 1 ? `${files.slice(0, -1).join(", ")} and ${files.at(-1)}` : files[0]}`)
|
|
40
41
|
|
|
@@ -45,7 +46,7 @@ export async function writeSiteFiles(site, routes, dated = true) {
|
|
|
45
46
|
const sitemaps = new Map()
|
|
46
47
|
if (site.sitemap) {
|
|
47
48
|
const cache = new Map()
|
|
48
|
-
//
|
|
49
|
+
// With sections, a sitemap per section behind a sitemap index, so Search Console shows how each one is indexed on its own
|
|
49
50
|
for (const route of routes.filter(route => route.source !== site.notFound)) {
|
|
50
51
|
const section = route.url.split("/")[1]
|
|
51
52
|
const name = site.sitemap.sections.includes(section) ? section : "pages"
|
|
@@ -72,15 +73,23 @@ export async function writeSiteFiles(site, routes, dated = true) {
|
|
|
72
73
|
*/
|
|
73
74
|
const entry = (tag, loc, lastmod) => ` <${tag}><loc>${loc}</loc>${lastmod ? `<lastmod>${lastmod}</lastmod>` : ""}</${tag}>`
|
|
74
75
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
76
|
+
/** @param {{ url: string, lastmod: string | null | false }[]} pages */
|
|
77
|
+
const urlset = (pages) => xml("urlset", pages.map(p => entry("url", `${site.url}${p.url}`, p.lastmod)))
|
|
78
|
+
|
|
79
|
+
// Without sections, it's one sitemap.xml with every page, all a small site needs
|
|
80
|
+
if (!site.sitemap.sections.length) {
|
|
81
|
+
fs.writeFileSync(path.join(site.out, "sitemap.xml"), urlset(sitemaps.get("pages") ?? []))
|
|
82
|
+
} else {
|
|
83
|
+
const index = []
|
|
84
|
+
for (const [name, pages] of sitemaps) {
|
|
85
|
+
const file = `sitemap-${name}.xml`
|
|
86
|
+
fs.writeFileSync(path.join(site.out, file), urlset(pages))
|
|
87
|
+
// YYYY-MM-DD sorts as text, so the newest is the last
|
|
88
|
+
const newest = pages.map(p => p.lastmod).filter(Boolean).sort().at(-1)
|
|
89
|
+
index.push(entry("sitemap", `${site.url}/${file}`, newest))
|
|
90
|
+
}
|
|
91
|
+
fs.writeFileSync(path.join(site.out, "sitemap.xml"), xml("sitemapindex", index))
|
|
82
92
|
}
|
|
83
|
-
fs.writeFileSync(path.join(site.out, "sitemap.xml"), xml("sitemapindex", index))
|
|
84
93
|
}
|
|
85
94
|
|
|
86
95
|
if (site.robots) {
|
package/types/config.d.ts
CHANGED
|
@@ -152,7 +152,8 @@ export type SeoConfig = {
|
|
|
152
152
|
};
|
|
153
153
|
export type SitemapConfig = {
|
|
154
154
|
/**
|
|
155
|
-
* Sections that get a sitemap of their own, the rest share
|
|
155
|
+
* Sections that get a sitemap of their own behind a sitemap index, the rest share
|
|
156
|
+
* sitemap-pages.xml. Without any, sitemap.xml has every page itself
|
|
156
157
|
*/
|
|
157
158
|
sections?: string[] | undefined;
|
|
158
159
|
/**
|