mothwing 0.0.1 → 0.0.3
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 +15 -1
- package/package.json +1 -1
- package/src/build.js +5 -2
- package/src/data.js +37 -17
- package/src/deps.js +4 -1
- package/src/render.js +1 -1
- package/src/routes.js +6 -3
- package/src/site.js +5 -3
- package/src/template.js +15 -9
- package/types/data.d.ts +4 -0
- package/types/site.d.ts +1 -1
- package/types/template.d.ts +2 -1
package/README.md
CHANGED
|
@@ -60,6 +60,20 @@ export default () => ({ year: new Date().getFullYear() })
|
|
|
60
60
|
<footer>{{ site.year }}</footer> <!-- into objects with dots -->
|
|
61
61
|
```
|
|
62
62
|
|
|
63
|
+
An attribute that's a placeholder and nothing else follows its value: `false` or `null` leaves it out, and `true` makes it a bare attribute.
|
|
64
|
+
|
|
65
|
+
```html
|
|
66
|
+
<option value="{{ group.slug }}" selected="{{ group.selected }}"> <!-- selected, or nothing -->
|
|
67
|
+
<section hidden="{{ events.none }}"> <!-- hidden, or nothing -->
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
A `.js` file can also say what it reads, relative to `src/`. The pages using it are dated by those files in the sitemap, and render again in dev when they change:
|
|
71
|
+
|
|
72
|
+
```js
|
|
73
|
+
// src/data/events.js
|
|
74
|
+
export const sources = ["js/data/lodestone.json"]
|
|
75
|
+
```
|
|
76
|
+
|
|
63
77
|
## Loops
|
|
64
78
|
```html
|
|
65
79
|
<ul>
|
|
@@ -81,7 +95,7 @@ Every tag is ordinary HTML to an editor or formatter. Templates formatted by Pre
|
|
|
81
95
|
## Pages
|
|
82
96
|
`src/pages/about.html` is `/about/`, `src/pages/blog/index.html` is `/blog/`.
|
|
83
97
|
|
|
84
|
-
`[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 }}/`.
|
|
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.
|
|
85
99
|
|
|
86
100
|
### .data.js
|
|
87
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:
|
package/package.json
CHANGED
package/src/build.js
CHANGED
|
@@ -51,12 +51,15 @@ export async function buildHTML(site, state, { hashed = true, only } = {}) {
|
|
|
51
51
|
for (const [file, content] of Object.entries(route.props.files ?? {})) writeIfChanged(path.join(site.out, file), content)
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
// A page that used the data folder is dated by it too
|
|
54
|
+
// A page that used the data folder is dated by it too, and by what its files say they're made from
|
|
55
55
|
for (const route of routes) {
|
|
56
56
|
if (route.props.page) continue
|
|
57
57
|
for (const name of state.used.get(route.source) ?? []) {
|
|
58
58
|
const file = state.data.files[name]
|
|
59
|
-
if (file
|
|
59
|
+
if (!file || name in route.props) continue
|
|
60
|
+
for (const source of [file, ...state.data.sources[name] ?? []]) {
|
|
61
|
+
if (!route.sources.includes(source)) route.sources.push(source)
|
|
62
|
+
}
|
|
60
63
|
}
|
|
61
64
|
}
|
|
62
65
|
log("HTML", `Done rendering ${routes.length} pages${written < routes.length ? `, ${written} changed` : ""}`)
|
package/src/data.js
CHANGED
|
@@ -8,34 +8,51 @@ import { posix } from "./log.js"
|
|
|
8
8
|
* @typedef {object} SiteData
|
|
9
9
|
* @property {Record<string, any>} values projects.json -> values.projects, a folder is an object of its files
|
|
10
10
|
* @property {Record<string, string>} files Each top level name -> its file or folder, relative to src
|
|
11
|
+
* @property {Record<string, string[]>} sources Each top level name -> what its JS files say they're made from, see read
|
|
11
12
|
*/
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* @param {string} file
|
|
15
|
-
* @returns {Promise<any>} A JSON file as it is, a JS file's default export, called first
|
|
16
|
+
* @returns {Promise<{ value: any, sources: string[] }>} A JSON file as it is, a JS file's default export, called first
|
|
17
|
+
* when it's a function. A JS file can also `export const sources`, the files it reads (relative to src), so the pages
|
|
18
|
+
* using it are dated by them too, and render again in dev when they change
|
|
16
19
|
*/
|
|
17
20
|
async function read(file) {
|
|
18
|
-
if (file.endsWith(".json")) return JSON.parse(fs.readFileSync(file, "utf8"))
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
+
if (file.endsWith(".json")) return { value: JSON.parse(fs.readFileSync(file, "utf8")), sources: [] }
|
|
22
|
+
const module = await import(pathToFileURL(file).href)
|
|
23
|
+
const value = typeof module.default === "function" ? await module.default() : module.default
|
|
24
|
+
return { value, sources: Array.isArray(module.sources) ? module.sources : [] }
|
|
21
25
|
}
|
|
22
26
|
|
|
23
27
|
/**
|
|
24
28
|
* @param {string} dir
|
|
25
29
|
* @param {string} from For errors, the folder relative to src
|
|
26
|
-
* @
|
|
30
|
+
* @param {(name: string, file: string, value: any, sources: string[]) => void} add Gets each file and folder in it
|
|
27
31
|
*/
|
|
28
|
-
async function
|
|
29
|
-
|
|
30
|
-
const values = {}
|
|
32
|
+
async function readEach(dir, from, add) {
|
|
33
|
+
const seen = new Set()
|
|
31
34
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
32
35
|
const file = path.join(dir, entry.name)
|
|
33
36
|
const name = entry.isDirectory() ? entry.name : entry.name.replace(/\.(json|js)$/, "")
|
|
34
37
|
if (!entry.isDirectory() && name === entry.name) continue
|
|
35
|
-
if (name
|
|
36
|
-
|
|
38
|
+
if (seen.has(name)) throw new Error(`${from}/${entry.name}: there's more than one ${name} in ${from}`)
|
|
39
|
+
seen.add(name)
|
|
40
|
+
|
|
41
|
+
if (entry.isDirectory()) {
|
|
42
|
+
/** @type {Record<string, any>} */
|
|
43
|
+
const values = {}
|
|
44
|
+
/** @type {string[]} */
|
|
45
|
+
const sources = []
|
|
46
|
+
await readEach(file, `${from}/${entry.name}`, (inner, _, value, own) => {
|
|
47
|
+
values[inner] = value
|
|
48
|
+
sources.push(...own)
|
|
49
|
+
})
|
|
50
|
+
add(name, file, values, sources)
|
|
51
|
+
} else {
|
|
52
|
+
const { value, sources } = await read(file)
|
|
53
|
+
add(name, file, value, sources)
|
|
54
|
+
}
|
|
37
55
|
}
|
|
38
|
-
return values
|
|
39
56
|
}
|
|
40
57
|
|
|
41
58
|
/**
|
|
@@ -44,11 +61,14 @@ async function readFolder(dir, from) {
|
|
|
44
61
|
*/
|
|
45
62
|
export async function loadData(site) {
|
|
46
63
|
const dir = path.join(site.src, site.data)
|
|
47
|
-
|
|
64
|
+
/** @type {SiteData} */
|
|
65
|
+
const data = { values: {}, files: {}, sources: {} }
|
|
66
|
+
if (!fs.existsSync(dir)) return data
|
|
48
67
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
.
|
|
52
|
-
.
|
|
53
|
-
|
|
68
|
+
await readEach(dir, site.data, (name, file, value, sources) => {
|
|
69
|
+
data.values[name] = value
|
|
70
|
+
data.files[name] = posix(path.relative(site.src, file))
|
|
71
|
+
data.sources[name] = [...new Set(sources)]
|
|
72
|
+
})
|
|
73
|
+
return data
|
|
54
74
|
}
|
package/src/deps.js
CHANGED
|
@@ -55,10 +55,13 @@ export async function dependencies(site, state, routes) {
|
|
|
55
55
|
const dataFile = `${pagesDir}/${source.replace(/\.html$/, ".data.js")}`
|
|
56
56
|
if (fs.existsSync(path.join(site.src, dataFile))) (await importsCached(dataFile)).forEach(file => files.add(file))
|
|
57
57
|
|
|
58
|
-
|
|
58
|
+
// The data it used, and for a [projects.id].html page the list it's made from
|
|
59
|
+
const fromData = source.match(/\[([\w.]+)\.\w+\]\.html$/)?.[1].split(".")[0]
|
|
60
|
+
for (const name of new Set([...state.used.get(source) ?? [], ...fromData ? [fromData] : []])) {
|
|
59
61
|
const file = state.data.files[name]
|
|
60
62
|
if (!file) continue
|
|
61
63
|
files.add(file)
|
|
64
|
+
state.data.sources[name]?.forEach(input => files.add(input))
|
|
62
65
|
if (file.endsWith(".js")) (await importsCached(file)).forEach(input => files.add(input))
|
|
63
66
|
}
|
|
64
67
|
deps[source] = [...files]
|
package/src/render.js
CHANGED
|
@@ -31,7 +31,7 @@ import { includeHtml, parseAttrs, parseTemplate, renderNodes } from "./template.
|
|
|
31
31
|
|
|
32
32
|
/** @returns {BuildState} */
|
|
33
33
|
export const buildState = () => ({
|
|
34
|
-
data: { values: {}, files: {} },
|
|
34
|
+
data: { values: {}, files: {}, sources: {} },
|
|
35
35
|
assetUrls: new Map(),
|
|
36
36
|
templates: new Map(),
|
|
37
37
|
used: new Map(),
|
package/src/routes.js
CHANGED
|
@@ -110,7 +110,8 @@ function pagesFromData(site, siteData, source, list, key) {
|
|
|
110
110
|
const from = posix(path.relative(site.src, path.join(site.pages, source)))
|
|
111
111
|
const items = lookup(siteData.values, list)
|
|
112
112
|
if (!Array.isArray(items)) throw new Error(`${from}: ${site.data} has no list called ${list}`)
|
|
113
|
-
const
|
|
113
|
+
const top = list.split(".")[0]
|
|
114
|
+
const dataSources = [siteData.files[top], ...siteData.sources[top] ?? []]
|
|
114
115
|
|
|
115
116
|
const seen = new Set()
|
|
116
117
|
return items.map((item, i) => {
|
|
@@ -119,7 +120,9 @@ function pagesFromData(site, siteData, source, list, key) {
|
|
|
119
120
|
if (seen.has(pageSlug)) throw new Error(`${from}: two of ${list} are both "${pageSlug}"`)
|
|
120
121
|
seen.add(pageSlug)
|
|
121
122
|
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
|
-
|
|
123
|
+
// Dated by its data alone, a template tweak would otherwise date every page at once. An item can say which of the
|
|
124
|
+
// data it's made from, so a change to one doesn't date them all
|
|
125
|
+
const sources = Array.isArray(item.sources) && item.sources.length ? item.sources : dataSources
|
|
126
|
+
return { source, output, url: outputUrl(output), props: { page: item }, sources }
|
|
124
127
|
})
|
|
125
128
|
}
|
package/src/site.js
CHANGED
|
@@ -28,13 +28,15 @@ function lastModified(site, cache, files) {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
|
-
* Writes CNAME,
|
|
31
|
+
* Writes the CNAME, sitemaps and robots.txt, those the config turned on.
|
|
32
32
|
* @param {import("./config.js").Site} site
|
|
33
33
|
* @param {import("./routes.js").Route[]} routes
|
|
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
|
-
|
|
37
|
+
const files = [site.cname && "CNAME", site.sitemap && "the sitemaps", site.robots && "robots.txt"].filter(Boolean)
|
|
38
|
+
if (!files.length) return
|
|
39
|
+
log("SITE", `Writing ${files.length > 1 ? `${files.slice(0, -1).join(", ")} and ${files.at(-1)}` : files[0]}`)
|
|
38
40
|
|
|
39
41
|
// Every GitHub Pages deploy wipes the branch, so the custom domain has to be written each time
|
|
40
42
|
if (site.cname) fs.writeFileSync(path.join(site.out, "CNAME"), site.domain + "\n")
|
|
@@ -92,5 +94,5 @@ export async function writeSiteFiles(site, routes, dated = true) {
|
|
|
92
94
|
].join("\n"))
|
|
93
95
|
}
|
|
94
96
|
|
|
95
|
-
log("SITE", `Done, ${[...sitemaps.values()].flat().length} pages in the sitemap`)
|
|
97
|
+
log("SITE", site.sitemap ? `Done, ${[...sitemaps.values()].flat().length} pages in the sitemap` : "Done")
|
|
96
98
|
}
|
package/src/template.js
CHANGED
|
@@ -63,7 +63,8 @@ function find(values, name, context) {
|
|
|
63
63
|
|
|
64
64
|
/**
|
|
65
65
|
* {{ name }} is filled with the value as text, {{{ name }}} as HTML, on a line of its own each line indented like it.
|
|
66
|
-
* Names can go into objects: {{ project.name }}.
|
|
66
|
+
* Names can go into objects: {{ project.name }}. An attribute that's one placeholder and nothing else follows its
|
|
67
|
+
* value: false or null leaves it out, true makes it a bare one, `selected="{{ group.selected }}"` -> `selected`.
|
|
67
68
|
* @param {string} html
|
|
68
69
|
* @param {Record<string, any>} values
|
|
69
70
|
* @param {string} from For errors, the page's file
|
|
@@ -72,17 +73,22 @@ function find(values, name, context) {
|
|
|
72
73
|
*/
|
|
73
74
|
export function fillPlaceholders(html, values, from, used = new Set()) {
|
|
74
75
|
/** @param {string} name */
|
|
75
|
-
const
|
|
76
|
-
const
|
|
77
|
-
if (
|
|
78
|
-
return
|
|
76
|
+
const found = (name) => {
|
|
77
|
+
const value = find(values, name, { from, used })
|
|
78
|
+
if (value === undefined) throw new Error(`${from}: nothing to fill {{ ${name} }} with`)
|
|
79
|
+
return value
|
|
79
80
|
}
|
|
80
81
|
|
|
81
82
|
// One pass, so what's filled in is never searched for placeholders itself
|
|
82
|
-
const placeholder =
|
|
83
|
-
return html.replace(placeholder, (_, indent, line, inline, text) => {
|
|
84
|
-
if (
|
|
85
|
-
|
|
83
|
+
const placeholder = /(\s+)([\w:-]+)="\{\{\s*([\w.]+)\s*\}\}"|^([ \t]*)\{\{\{\s*([\w.]+)\s*\}\}\}[ \t]*$|\{\{\{\s*([\w.]+)\s*\}\}\}|\{\{\s*([\w.]+)\s*\}\}/gm
|
|
84
|
+
return html.replace(placeholder, (_, space, attr, attrValue, indent, line, inline, text) => {
|
|
85
|
+
if (attr) {
|
|
86
|
+
const value = found(attrValue)
|
|
87
|
+
if (value === false || value === null) return ""
|
|
88
|
+
return value === true ? `${space}${attr}` : `${space}${attr}="${escapeHtml(String(value))}"`
|
|
89
|
+
}
|
|
90
|
+
if (line) return String(found(line)).split("\n").map(l => l.trim() ? indent + l : "").join("\n")
|
|
91
|
+
return inline ? String(found(inline)) : escapeHtml(String(found(text)))
|
|
86
92
|
})
|
|
87
93
|
}
|
|
88
94
|
|
package/types/data.d.ts
CHANGED
|
@@ -15,4 +15,8 @@ export type SiteData = {
|
|
|
15
15
|
* Each top level name -> its file or folder, relative to src
|
|
16
16
|
*/
|
|
17
17
|
files: Record<string, string>;
|
|
18
|
+
/**
|
|
19
|
+
* Each top level name -> what its JS files say they're made from, see read
|
|
20
|
+
*/
|
|
21
|
+
sources: Record<string, string[]>;
|
|
18
22
|
};
|
package/types/site.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Writes CNAME,
|
|
2
|
+
* Writes the CNAME, sitemaps and robots.txt, those the config turned on.
|
|
3
3
|
* @param {import("./config.js").Site} site
|
|
4
4
|
* @param {import("./routes.js").Route[]} routes
|
|
5
5
|
* @param {boolean} [dated] Add each page's lastmod, from git, which takes a moment
|
package/types/template.d.ts
CHANGED
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
export function includeHtml(src: string, html: string, from: string, used?: Set<string>, depth?: number): string;
|
|
12
12
|
/**
|
|
13
13
|
* {{ name }} is filled with the value as text, {{{ name }}} as HTML, on a line of its own each line indented like it.
|
|
14
|
-
* Names can go into objects: {{ project.name }}.
|
|
14
|
+
* Names can go into objects: {{ project.name }}. An attribute that's one placeholder and nothing else follows its
|
|
15
|
+
* value: false or null leaves it out, true makes it a bare one, `selected="{{ group.selected }}"` -> `selected`.
|
|
15
16
|
* @param {string} html
|
|
16
17
|
* @param {Record<string, any>} values
|
|
17
18
|
* @param {string} from For errors, the page's file
|