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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mothwing",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "description": "A small static site builder for pages as light as a moth's wing",
5
5
  "keywords": [
6
6
  "static-site-generator",
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 && !(name in route.props) && !route.sources.includes(file)) route.sources.push(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 when it's a function
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 value = (await import(pathToFileURL(file).href)).default
20
- return typeof value === "function" ? await value() : value
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
- * @returns {Promise<Record<string, any>>}
30
+ * @param {(name: string, file: string, value: any, sources: string[]) => void} add Gets each file and folder in it
27
31
  */
28
- async function readFolder(dir, from) {
29
- /** @type {Record<string, any>} */
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 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)
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
- if (!fs.existsSync(dir)) return { values: {}, files: {} }
64
+ /** @type {SiteData} */
65
+ const data = { values: {}, files: {}, sources: {} }
66
+ if (!fs.existsSync(dir)) return data
48
67
 
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 }
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
- for (const name of state.used.get(source) ?? []) {
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 dataFile = siteData.files[list.split(".")[0]]
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
- return { source, output, url: outputUrl(output), props: { page: item }, sources: [dataFile] }
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, the sitemaps and robots.txt.
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
- log("SITE", "Writing CNAME, the sitemaps and robots.txt")
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 value = (name) => {
76
- const found = find(values, name, { from, used })
77
- if (found === undefined) throw new Error(`${from}: nothing to fill {{ ${name} }} with`)
78
- return String(found)
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 = /^([ \t]*)\{\{\{\s*([\w.]+)\s*\}\}\}[ \t]*$|\{\{\{\s*([\w.]+)\s*\}\}\}|\{\{\s*([\w.]+)\s*\}\}/gm
83
- return html.replace(placeholder, (_, indent, line, inline, text) => {
84
- if (line) return value(line).split("\n").map(l => l.trim() ? indent + l : "").join("\n")
85
- return inline ? value(inline) : escapeHtml(value(text))
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, the sitemaps and robots.txt.
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
@@ -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