vowel 0.7.1 → 0.7.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/bin.js CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  import fs from "node:fs/promises"
4
4
  import os from "node:os"
5
+ import path from "node:path"
5
6
  import { intro, note, text, select, confirm, spinner, outro, box, isCancel, cancel } from "@clack/prompts"
6
7
  import { parse } from "@bomb.sh/args"
7
8
  import t from "@bomb.sh/tab"
@@ -55,6 +56,22 @@ async function removeDatabase(verbose) {
55
56
  }
56
57
  }
57
58
 
59
+ /**
60
+ * Empties the buffer cache: an image's measurements and derivative list,
61
+ * cached by votive so a warm start doesn't decode it again. Without
62
+ * this, a reset still reused every image's old read. Only the `.json`
63
+ * results go. The `cache-key` file stays, because votive empties a
64
+ * folder that has no key, so removing it would discard the next
65
+ * launch's fresh cache. URL fetches are not cached here.
66
+ * @param {boolean} verbose
67
+ */
68
+ async function removeBufferCache(verbose) {
69
+ const entries = await fs.readdir(config.cacheDirectory).catch(() => [])
70
+ const results = entries.filter(name => name.endsWith(".json"))
71
+ await Promise.all(results.map(name => fs.rm(path.join(config.cacheDirectory, name), { force: true })))
72
+ if (verbose) console.info(`${styleText("dim", "loading:")} ${styleText("green", results.length ? "buffer cache cleared" : "no buffer cache found")}`)
73
+ }
74
+
58
75
  /** @param {boolean} verbose */
59
76
  async function removeOutput(verbose) {
60
77
  try {
@@ -290,6 +307,7 @@ async function main() {
290
307
 
291
308
  if (shouldClear(args)) {
292
309
  await removeDatabase(args.logging === "verbose")
310
+ await removeBufferCache(args.logging === "verbose")
293
311
  await removeOutput(args.logging === "verbose")
294
312
  }
295
313
  await fs.mkdir(config.targetFolder, { recursive: true })
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "vowel",
3
3
  "type": "module",
4
- "version": "0.7.1",
4
+ "version": "0.7.3",
5
5
  "engines": {
6
6
  "node": ">=26"
7
7
  },
@@ -67,8 +67,7 @@
67
67
  "unified": "^11.0.5",
68
68
  "unist-util-visit": "^5.0.0",
69
69
  "unpic": "^4.2.2",
70
- "url-metadata": "^5.4.1",
71
- "votive": "0.5.2",
70
+ "votive": "0.5",
72
71
  "xml": "^1.0.1",
73
72
  "yaml": "^2.8.2"
74
73
  },
@@ -113,7 +113,9 @@ function phrase(node, base) {
113
113
  }
114
114
  if (tag === "picture") {
115
115
  const img = (node.children ?? []).find(child => is(child, "img"))
116
- const src = node.properties?.dataOriginal ?? img?.properties?.src
116
+ // The <img>'s src is the largest derivative in the image's own
117
+ // format; the original is not published (plugins/images).
118
+ const src = img?.properties?.src
117
119
  return src ? [{ type: "image", url: absolute(String(src), base), alt: String(img?.properties?.alt ?? "") }] : []
118
120
  }
119
121
  if (tag === "a") {
@@ -11,6 +11,8 @@ import { PUBLICATION, siteOrigin, publicationURI, pageRecordURI } from "./record
11
11
  import { createLexiconResolver, resolvePDS } from "./lexicons.js"
12
12
  import { mapRecord } from "./mapper.js"
13
13
  import { markdownBody, richTextBody } from "./body.js"
14
+ import { imageURL } from "../html/image.js"
15
+ import { imageSizes } from "../../utils.js"
14
16
 
15
17
  /**
16
18
  * `vowel --command atproto-publish`: make the account's records match the
@@ -267,7 +269,9 @@ function recordPages(database) {
267
269
  * its status, and what mapper.js reads - the site's own settings, and
268
270
  * the publication's name, description and icon. The icon is read from
269
271
  * the build's output - it is a URL path from the root (frontmatter.js
270
- * resolves it), which is where the output has it. Built into a scratch
272
+ * resolves it), which is where the output has it; a png or jpeg is read
273
+ * from its largest derivative, since the original is not published
274
+ * (plugins/images). Built into a scratch
271
275
  * folder that is removed afterwards.
272
276
  * @param {import("votive").VotiveConfig} config
273
277
  */
@@ -306,7 +310,9 @@ async function collectPages(config) {
306
310
  const home = site.database.target.get("index.html")
307
311
  const name = root.raw("fm_name")?.[0]?.at(-1) ?? (home?.source ? home.metadata.title : undefined)
308
312
  const iconPath = settings.icon ?? root.lastNonNull("logo")
309
- const icon = iconPath ? await readIcon(path.join(scratch, "output"), iconPath) : undefined
313
+ // imageURL asks an api for the target; the database answers the same.
314
+ const builtIcon = imageURL(iconPath, { target: (targetPath) => site.database.target.get(targetPath) }, imageSizes.at(-1))
315
+ const icon = builtIcon ? await readIcon(path.join(scratch, "output"), builtIcon) : undefined
310
316
  const publication = { name, description: settings.description, iconPath, icon }
311
317
  return { did, pds, domain, pages, publication }
312
318
  } finally {
@@ -20156,7 +20156,7 @@ ${fallback_html}`;
20156
20156
  const tag = element.tagName;
20157
20157
  if (tag === "P") {
20158
20158
  const picture = pictureIn(element);
20159
- if (picture && !element.textContent.trim()) return imageFrom(picture, null, element, context)
20159
+ if (picture && !element.textContent.trim()) return imageFrom(context)
20160
20160
  return context.create({
20161
20161
  type: "paragraph",
20162
20162
  content: textValue(element, context)
@@ -20224,29 +20224,17 @@ ${fallback_html}`;
20224
20224
  }
20225
20225
  if (tag === "FIGURE") {
20226
20226
  const picture = pictureIn(element);
20227
- const caption = element.querySelector("figcaption");
20228
- if (picture) return imageFrom(picture, caption, element, context)
20227
+ if (picture) return imageFrom(context)
20229
20228
  }
20230
20229
  if (tag === "PICTURE") {
20231
- return imageFrom(element, null, element, context)
20230
+ return imageFrom(context)
20232
20231
  }
20233
20232
  context.unrecognised.push(tag.toLowerCase() + (element.className ? `.${element.className}` : ""));
20234
20233
  return null
20235
20234
  }
20236
- function imageFrom(picture, caption, outer, context) {
20237
- const img = picture.querySelector("img");
20238
- const source = picture.getAttribute("data-original");
20239
- if (!source) {
20240
- context.unrecognised.push("picture (no data-original)");
20241
- return null
20242
- }
20243
- return context.create({
20244
- type: "image",
20245
- source,
20246
- alt: img ? (img.getAttribute("alt") || "") : "",
20247
- caption: caption ? caption.textContent : "",
20248
- html: outer.outerHTML
20249
- })
20235
+ function imageFrom(context) {
20236
+ context.unrecognised.push("picture");
20237
+ return null
20250
20238
  }
20251
20239
  function ingest(contentElement, generateId) {
20252
20240
  const nodes = {};
@@ -20989,28 +20977,42 @@ ${fallback_html}`;
20989
20977
  }, {})
20990
20978
  }
20991
20979
  function naturalLine(element) {
20980
+ if (element.tagName === "FIGURE") return figureLine(element)
20992
20981
  const property = element.getAttribute("itemprop");
20993
20982
  if (property === "date") return element.textContent.trim()
20994
20983
  if (property === "description") return `${element.textContent.trim()}\n\n---`
20995
20984
  if (property !== "image") return null
20996
- const img = element.tagName === "IMG" ? element : element.querySelector("img");
20997
- const source = element.getAttribute("data-original") ?? img?.getAttribute("src");
20998
- return source ? `![${img?.getAttribute("alt") ?? ""}](${source})` : null
20985
+ if (element.tagName !== "IMG") return null
20986
+ const source = element.getAttribute("src");
20987
+ return source ? `![${element.getAttribute("alt") ?? ""}](${source})` : null
20988
+ }
20989
+ function figureLine(figure) {
20990
+ const image = figure.querySelector(":scope > [itemprop=image]");
20991
+ const line = image ? naturalLine(image) : null;
20992
+ if (!line) return null
20993
+ const caption = figure.querySelector(":scope > figcaption")?.textContent.trim();
20994
+ return caption ? `${line} ${caption}` : line
20995
+ }
20996
+ function isNatural(child) {
20997
+ if ([...child.classList].some(name => name.startsWith("fm_"))) return false
20998
+ if (child.hasAttribute("itemprop")) return true
20999
+ return child.tagName === "FIGURE" && Boolean(child.querySelector(":scope > [itemprop=image]"))
20999
21000
  }
21000
21001
  function readFrontmatter(main) {
21001
21002
  const children = [...main.children];
21002
21003
  const declared = (key) => (child) => child.classList.contains(`fm_${key}`);
21003
21004
  const heading = children.find(child => child.tagName === "H1");
21004
21005
  const date = children.find(declared("date"));
21005
- const image = children.find(declared("image"));
21006
21006
  const description = children.find(declared("description"));
21007
- const natural = children
21008
- .filter(child => child.hasAttribute("itemprop") && ![...child.classList].some(name => name.startsWith("fm_")))
21007
+ const naturalChildren = children.filter(isNatural);
21008
+ const natural = naturalChildren
21009
21009
  .map(naturalLine)
21010
21010
  .filter(Boolean);
21011
+ const unrecognised = naturalChildren
21012
+ .filter(child => child.tagName === "PICTURE" || child.querySelector("picture"))
21013
+ .map(() => "picture");
21011
21014
  const known = {
21012
21015
  ...(date ? { date: dateFrom(date) } : {}),
21013
- ...(image ? { image: image.getAttribute("data-original") } : {}),
21014
21016
  ...(description ? { description: description.textContent.trim() } : {})
21015
21017
  };
21016
21018
  const generic = children
@@ -21020,7 +21022,8 @@ ${fallback_html}`;
21020
21022
  title: heading ? heading.textContent.trim() : null,
21021
21023
  titleDeclared: Boolean(heading?.classList.contains("fm_title")),
21022
21024
  properties: { ...known, ...generic },
21023
- natural: natural.length ? `${natural.join("\n\n")}\n\n` : ""
21025
+ natural: natural.length ? `${natural.join("\n\n")}\n\n` : "",
21026
+ unrecognised
21024
21027
  }
21025
21028
  }
21026
21029
 
@@ -21044,7 +21047,10 @@ ${fallback_html}`;
21044
21047
  socket.addEventListener('message', e => {
21045
21048
  if (!isOpen()) return
21046
21049
  const target = JSON.parse(e.data);
21047
- if (target.extension !== ".html") return
21050
+ if (target.extension !== ".html") {
21051
+ retryImages("/" + target.path);
21052
+ return
21053
+ }
21048
21054
  const regex = new RegExp(window.location.pathname + "(index)?(\\.html)");
21049
21055
  if (!("/" + target.path).match(regex)) return
21050
21056
  updateFromTarget(target);
@@ -21131,6 +21137,19 @@ ${fallback_html}`;
21131
21137
  if (!produced.has(keyOf(element)) && wasProduced(element)) element.remove();
21132
21138
  }
21133
21139
  }
21140
+ function retryImages(url) {
21141
+ const failed = [...document.images].filter(img => img.complete && img.naturalWidth === 0);
21142
+ const elements = failed.map(img => img.parentElement?.tagName === "PICTURE" ? img.parentElement : img);
21143
+ const naming = elements.filter(element => urlsOf(element).includes(url));
21144
+ for (const element of naming) element.replaceWith(element.cloneNode(true));
21145
+ }
21146
+ function urlsOf(element) {
21147
+ const nodes = [element, ...element.querySelectorAll("source, img")];
21148
+ return nodes.flatMap(node => {
21149
+ const candidates = (node.getAttribute("srcset") ?? "").split(",").map(candidate => candidate.trim().split(/\s+/)[0]);
21150
+ return [node.getAttribute("src"), ...candidates].filter(Boolean)
21151
+ })
21152
+ }
21134
21153
  function wasProduced(element) {
21135
21154
  if (element.tagName === "SCRIPT") return false
21136
21155
  if (element.hasAttribute("data-vowel-client")) return false
@@ -21139,7 +21158,7 @@ ${fallback_html}`;
21139
21158
 
21140
21159
  openSocket();
21141
21160
  const content = document.getElementById("content");
21142
- const noFrontmatter = { title: null, properties: {} };
21161
+ const noFrontmatter = { title: null, properties: {}, unrecognised: [] };
21143
21162
  function startEditing() {
21144
21163
  const { session, unrecognised } = create_session(content);
21145
21164
  if (!session) {
@@ -21151,6 +21170,10 @@ ${fallback_html}`;
21151
21170
  }
21152
21171
  const main = content.closest("main");
21153
21172
  const frontmatter = main ? readFrontmatter(main) : noFrontmatter;
21173
+ if (frontmatter.unrecognised.length) {
21174
+ console.warn("[vowel] editor stayed read-only: <main> holds natural metadata it can't write back:", frontmatter.unrecognised);
21175
+ return frontmatter.unrecognised
21176
+ }
21154
21177
  content.replaceChildren();
21155
21178
  mount(Editor, { target: content, props: { session, frontmatter } });
21156
21179
  document.documentElement.dataset.vowelEditing = "true";
@@ -77,20 +77,49 @@ function objectFrom(list) {
77
77
  * @returns {string|null}
78
78
  */
79
79
  function naturalLine(element) {
80
+ if (element.tagName === "FIGURE") return figureLine(element)
80
81
  const property = element.getAttribute("itemprop")
81
82
  if (property === "date") return element.textContent.trim()
82
83
  if (property === "description") return `${element.textContent.trim()}\n\n---`
83
84
  if (property !== "image") return null
84
85
 
85
- // A <picture>, or a plain <img> until the image has been read.
86
- const img = element.tagName === "IMG" ? element : element.querySelector("img")
87
- const source = element.getAttribute("data-original") ?? img?.getAttribute("src")
88
- return source ? `![${img?.getAttribute("alt") ?? ""}](${source})` : null
86
+ // A plain <img> links the path as written. A <picture> names only its
87
+ // derivatives, so it can't be written back, and readFrontmatter
88
+ // reports it.
89
+ if (element.tagName !== "IMG") return null
90
+ const source = element.getAttribute("src")
91
+ return source ? `![${element.getAttribute("alt") ?? ""}](${source})` : null
92
+ }
93
+
94
+ /**
95
+ * A captioned image, back as the image and its caption on one line. The
96
+ * caption's inline marks are flattened to its text, as a description's
97
+ * are and as ingest does for a captioned image in the content.
98
+ * @param {Element} figure
99
+ * @returns {string|null}
100
+ */
101
+ function figureLine(figure) {
102
+ const image = figure.querySelector(":scope > [itemprop=image]")
103
+ const line = image ? naturalLine(image) : null
104
+ if (!line) return null
105
+ const caption = figure.querySelector(":scope > figcaption")?.textContent.trim()
106
+ return caption ? `${line} ${caption}` : line
107
+ }
108
+
109
+ /**
110
+ * A captioned image carries its itemprop on the image inside its
111
+ * <figure>, so the figure is natural metadata when that image is.
112
+ * @param {Element} child - of <main>
113
+ */
114
+ function isNatural(child) {
115
+ if ([...child.classList].some(name => name.startsWith("fm_"))) return false
116
+ if (child.hasAttribute("itemprop")) return true
117
+ return child.tagName === "FIGURE" && Boolean(child.querySelector(":scope > [itemprop=image]"))
89
118
  }
90
119
 
91
120
  /**
92
121
  * @param {Element} main
93
- * @returns {{title: string|null, titleDeclared: boolean, properties: object, natural: string}}
122
+ * @returns {{title: string|null, titleDeclared: boolean, properties: object, natural: string, unrecognised: string[]}}
94
123
  */
95
124
  export default function readFrontmatter(main) {
96
125
  const children = [...main.children]
@@ -98,17 +127,21 @@ export default function readFrontmatter(main) {
98
127
 
99
128
  const heading = children.find(child => child.tagName === "H1")
100
129
  const date = children.find(declared("date"))
101
- const image = children.find(declared("image"))
102
130
  const description = children.find(declared("description"))
103
131
 
104
- const natural = children
105
- .filter(child => child.hasAttribute("itemprop") && ![...child.classList].some(name => name.startsWith("fm_")))
132
+ const naturalChildren = children.filter(isNatural)
133
+ const natural = naturalChildren
106
134
  .map(naturalLine)
107
135
  .filter(Boolean)
136
+ // A natural image that is a <picture> can't be written back
137
+ // (naturalLine), so the page stays read-only rather than losing the
138
+ // line on save.
139
+ const unrecognised = naturalChildren
140
+ .filter(child => child.tagName === "PICTURE" || child.querySelector("picture"))
141
+ .map(() => "picture")
108
142
 
109
143
  const known = {
110
144
  ...(date ? { date: dateFrom(date) } : {}),
111
- ...(image ? { image: image.getAttribute("data-original") } : {}),
112
145
  ...(description ? { description: description.textContent.trim() } : {})
113
146
  }
114
147
 
@@ -123,6 +156,7 @@ export default function readFrontmatter(main) {
123
156
  title: heading ? heading.textContent.trim() : null,
124
157
  titleDeclared: Boolean(heading?.classList.contains("fm_title")),
125
158
  properties: { ...known, ...generic },
126
- natural: natural.length ? `${natural.join("\n\n")}\n\n` : ""
159
+ natural: natural.length ? `${natural.join("\n\n")}\n\n` : "",
160
+ unrecognised
127
161
  }
128
162
  }
@@ -185,7 +185,7 @@ function blockFrom(element, context) {
185
185
  // The image transform replaces an <img> in place, so a paragraph whose
186
186
  // only element is a <picture> is an image block, not prose.
187
187
  const picture = pictureIn(element)
188
- if (picture && !element.textContent.trim()) return imageFrom(picture, null, element, context)
188
+ if (picture && !element.textContent.trim()) return imageFrom(context)
189
189
 
190
190
  return context.create({
191
191
  type: "paragraph",
@@ -274,12 +274,11 @@ function blockFrom(element, context) {
274
274
 
275
275
  if (tag === "FIGURE") {
276
276
  const picture = pictureIn(element)
277
- const caption = element.querySelector("figcaption")
278
- if (picture) return imageFrom(picture, caption, element, context)
277
+ if (picture) return imageFrom(context)
279
278
  }
280
279
 
281
280
  if (tag === "PICTURE") {
282
- return imageFrom(element, null, element, context)
281
+ return imageFrom(context)
283
282
  }
284
283
 
285
284
  context.unrecognised.push(tag.toLowerCase() + (element.className ? `.${element.className}` : ""))
@@ -287,26 +286,14 @@ function blockFrom(element, context) {
287
286
  }
288
287
 
289
288
  /**
290
- * @param {Element} picture
291
- * @param {Element|null} caption
292
- * @param {Element} outer the element whose markup is kept for rendering
289
+ * An image can't be edited yet. Its <picture> names only the image's
290
+ * derivatives, and the path the author wrote is not in the page (per
291
+ * Sam, the output carries no data-original for the editor), so a save
292
+ * could only guess it. It is reported, which leaves the page read-only.
293
293
  */
294
- function imageFrom(picture, caption, outer, context) {
295
- const img = picture.querySelector("img")
296
- const source = picture.getAttribute("data-original")
297
-
298
- if (!source) {
299
- context.unrecognised.push("picture (no data-original)")
300
- return null
301
- }
302
-
303
- return context.create({
304
- type: "image",
305
- source,
306
- alt: img ? (img.getAttribute("alt") || "") : "",
307
- caption: caption ? caption.textContent : "",
308
- html: outer.outerHTML
309
- })
294
+ function imageFrom(context) {
295
+ context.unrecognised.push("picture")
296
+ return null
310
297
  }
311
298
 
312
299
  /**
@@ -15,7 +15,7 @@ openSocket()
15
15
  // document's source of truth (see ingest.js).
16
16
  const content = document.getElementById("content")
17
17
 
18
- const noFrontmatter = { title: null, properties: {} }
18
+ const noFrontmatter = { title: null, properties: {}, unrecognised: [] }
19
19
 
20
20
  /**
21
21
  * Swaps the server-rendered body for the editor. A previewed page runs
@@ -50,6 +50,10 @@ function startEditing() {
50
50
  // them is what lets a save reproduce the whole file.
51
51
  const main = content.closest("main")
52
52
  const frontmatter = main ? readFrontmatter(main) : noFrontmatter
53
+ if (frontmatter.unrecognised.length) {
54
+ console.warn("[vowel] editor stayed read-only: <main> holds natural metadata it can't write back:", frontmatter.unrecognised)
55
+ return frontmatter.unrecognised
56
+ }
53
57
 
54
58
  // Svedit renders the document itself rather than hydrating over the
55
59
  // server output, so the original children go before it mounts.
@@ -51,10 +51,12 @@ export default function openSocket() {
51
51
 
52
52
  const target = JSON.parse(e.data)
53
53
 
54
- // Nothing here knows what a non-html target should do on change yet -
55
- // that's each content type's own concern to add, same as html's own
56
- // patch logic below isn't generic.
57
- if (target.extension !== ".html") return
54
+ // A file that isn't a page can only matter to this page as something
55
+ // it loads: an image the page named before its file existed.
56
+ if (target.extension !== ".html") {
57
+ retryImages("/" + target.path)
58
+ return
59
+ }
58
60
 
59
61
  const regex = new RegExp(window.location.pathname + "(index)?(\\.html)")
60
62
  if (!("/" + target.path).match(regex)) return
@@ -185,6 +187,34 @@ function patchBody(body, nextBody) {
185
187
  }
186
188
  }
187
189
 
190
+ /**
191
+ * A page can arrive before the image files it names: an image's
192
+ * derivatives are encoded after the page is written, and a browser never
193
+ * asks again for an image that failed. When a file lands, every image
194
+ * that failed and names it is replaced with a copy of itself, which
195
+ * asks again. A <picture> is copied whole, so its <source>s are chosen
196
+ * from again too.
197
+ * @param {string} url - the file's url, from the root
198
+ */
199
+ function retryImages(url) {
200
+ const failed = [...document.images].filter(img => img.complete && img.naturalWidth === 0)
201
+ const elements = failed.map(img => img.parentElement?.tagName === "PICTURE" ? img.parentElement : img)
202
+ const naming = elements.filter(element => urlsOf(element).includes(url))
203
+ for (const element of naming) element.replaceWith(element.cloneNode(true))
204
+ }
205
+
206
+ /**
207
+ * Every url an <img> or a <picture> could load.
208
+ * @param {Element} element
209
+ */
210
+ function urlsOf(element) {
211
+ const nodes = [element, ...element.querySelectorAll("source, img")]
212
+ return nodes.flatMap(node => {
213
+ const candidates = (node.getAttribute("srcset") ?? "").split(",").map(candidate => candidate.trim().split(/\s+/)[0])
214
+ return [node.getAttribute("src"), ...candidates].filter(Boolean)
215
+ })
216
+ }
217
+
188
218
  /**
189
219
  * The client adds elements that aren't the build's: the editor's own
190
220
  * <script type="module">, and whatever Svelte mounts into the body (the
@@ -1,10 +1,71 @@
1
- import { testURL, createImagePaths, imageSizes, imageExts } from "./../../utils.js"
1
+ import { testURL, imageSizes, imageExts } from "./../../utils.js"
2
2
  import path from "node:path"
3
3
  import { h } from 'hastscript'
4
4
  import * as unpic from "unpic"
5
5
 
6
6
  /** @import * as Votive from "votive" */
7
7
 
8
+ /**
9
+ * The image target a path names, from the root. Lowercased: every vowel
10
+ * target path is (config.js's router), and the author may well have
11
+ * typed the file's real name.
12
+ * @param {string} imagePath
13
+ * @param {import("votive").PluginAPI} api
14
+ */
15
+ function findImage(imagePath, api) {
16
+ const relativePath = (imagePath.startsWith("/") ? path.relative("/", imagePath) : imagePath).toLowerCase()
17
+ return api.target(relativePath)
18
+ }
19
+
20
+ /**
21
+ * The derivative to link where one file goes rather than a srcset: in
22
+ * the image's own format, at `size` (one of imageSizes). Undefined for
23
+ * an image without derivatives (plugins/images: a format that isn't
24
+ * optimised, the root favicon.png, or an image not read yet).
25
+ * @param {any} image - the image's target
26
+ * @param {number} size
27
+ */
28
+ function derivativeURL(image, size) {
29
+ const format = path.extname(image.path).slice(1)
30
+ const derivative = image.metadata?.derivatives?.find(entry => entry.format === format && entry.size === size)
31
+ if (!derivative) return
32
+ return "/" + derivative.path
33
+ }
34
+
35
+ /**
36
+ * The url to put where an image path goes as one file - og:image, the
37
+ * logo, an icon. A png or jpeg is not published at full size
38
+ * (plugins/images), so the path names a derivative; anything else, an
39
+ * external url included, is returned as it is. A tracked read, so the
40
+ * page is rewritten when the image is read or changes.
41
+ * @param {string | undefined} imagePath - from the root
42
+ * @param {import("votive").PluginAPI} api
43
+ * @param {number} size - one of imageSizes
44
+ */
45
+ function imageURL(imagePath, api, size) {
46
+ if (!imagePath || testURL(imagePath) || !/\.(png|jpe?g)$/i.test(imagePath)) return imagePath
47
+ const image = findImage(imagePath, api)
48
+ if (!image) return imagePath
49
+ return derivativeURL(image, size) ?? imagePath
50
+ }
51
+
52
+ /**
53
+ * The page image's dominant colour as a style for the element that
54
+ * holds it - a page's <main>, a card's <article> - so CSS outside the
55
+ * image can use it (text that contrasts with it, say). The <picture>
56
+ * carries it too, for its own placeholder. Nothing here decides that
57
+ * anything should use it. Undefined for no image, an external one, or
58
+ * one not read yet.
59
+ * @param {string | undefined} imagePath - from the root
60
+ * @param {import("votive").PluginAPI} api
61
+ */
62
+ function dominantStyle(imagePath, api) {
63
+ if (!imagePath || testURL(imagePath)) return
64
+ const dominant = findImage(imagePath, api)?.metadata.dominant
65
+ if (!dominant) return
66
+ return `--color-image-dominant: ${dominant}`
67
+ }
68
+
8
69
  /**
9
70
  * @param {string} imagePath
10
71
  * @param {import("votive").PluginAPI} api
@@ -45,26 +106,23 @@ function createDynamicImage(imagePath, api, alt, itemprop) {
45
106
  })
46
107
  })
47
108
 
48
- // The derivative paths in srcset are hash-based, so the original asset
49
- // path cannot be recovered from them. Carried here rather than as a
50
- // <source>, which is a loading candidate a browser could select.
51
- return h("picture", { itemprop: itemprop && "image", "data-original": imagePath }, sources)
109
+ return h("picture", { itemprop: itemprop && "image" }, sources)
52
110
  }
53
111
  }
54
112
 
55
113
  if (!isImg) return
56
114
 
57
- // Lowercased: every vowel target path is (config.js's router), and
58
- // the author may well have typed the file's real name.
59
- const relativePath = (imagePath.startsWith("/") ? path.relative("/", imagePath) : imagePath).toLowerCase()
60
- const image = api.target(relativePath)
115
+ const image = findImage(imagePath, api)
61
116
  if (!image) return
62
- // Until the image has been read (a deferred buffer read) it has no
63
- // hash and no derivatives; a plain <img> stands in, and the tracked
64
- // read of `hash` rewrites this page when they arrive. Derivatives are
65
- // named from the *target* path, as the images processor declares them.
66
- if (!image.metadata.hash) return h("img", { src: "/" + image.path, alt, loading: "lazy" })
67
- const formats = createImagePaths(image.path, "./", image.metadata.hash)
117
+ // An image without derivatives is published as it is (the root
118
+ // favicon.png), and a plain <img> links it. The tracked read of
119
+ // `derivatives` rewrites this page if they arrive.
120
+ const { derivatives } = image.metadata
121
+ if (!derivatives) return h("img", { src: "/" + image.path, alt, loading: "lazy" })
122
+ // One group per format, in the order the read lists them, which is
123
+ // <picture> order: avif, webp, then the image's own format as the <img>.
124
+ const formats = [...new Set(derivatives.map(entry => entry.format))]
125
+ .map(format => derivatives.filter(entry => entry.format === format))
68
126
 
69
127
  // Intrinsic size, measured at read (plugins/images): the browser
70
128
  // reserves the right box before the bytes arrive, and the reset's
@@ -74,13 +132,13 @@ function createDynamicImage(imagePath, api, alt, itemprop) {
74
132
  const sources = formats.map((format, index) => {
75
133
  const isImg = index === formats.length - 1
76
134
 
77
- const sizes = format.map((size, index) => `/${size} ${imageSizes[index]}w`).join(", ")
78
- const type = "image/" + path.extname(format[0]).slice(1)
135
+ const sizes = format.map(entry => `/${entry.path} ${entry.size}w`).join(", ")
136
+ const type = "image/" + format[0].format
79
137
  return h(isImg ? "img" : "source", {
80
138
  type,
81
139
  srcset: sizes,
82
140
  loading: isImg && "lazy",
83
- src: isImg && "/" + format.at(-1),
141
+ src: isImg && "/" + format.at(-1).path,
84
142
  sizes: "100vw",
85
143
  width: isImg && width,
86
144
  height: isImg && height,
@@ -88,9 +146,6 @@ function createDynamicImage(imagePath, api, alt, itemprop) {
88
146
  })
89
147
  })
90
148
 
91
- // The derivative paths in srcset are hash-based, so the original asset
92
- // path cannot be recovered from them. Carried here rather than as a
93
- // <source>, which is a loading candidate a browser could select.
94
149
  // Two values for a stylesheet to show behind the image while it loads:
95
150
  // its dominant colour, and a tiny blurry copy (plugins/images; none
96
151
  // for an image with transparency). Nothing here decides that it should.
@@ -102,9 +157,9 @@ function createDynamicImage(imagePath, api, alt, itemprop) {
102
157
  ].filter(Boolean).join("; ")
103
158
  return h("picture", {
104
159
  itemprop: itemprop && "image",
105
- "data-original": imagePath,
106
160
  style: style || undefined
107
161
  }, sources)
108
162
  }
109
163
 
164
+ export { dominantStyle, imageURL, derivativeURL }
110
165
  export default createDynamicImage
@@ -13,14 +13,14 @@ import { gfmTable } from 'micromark-extension-gfm-table'
13
13
  import { gfmTableFromMarkdown } from 'mdast-util-gfm-table'
14
14
  import { h } from 'hastscript'
15
15
  import { readFileSync } from "fs"
16
- import { testURL, testHashtags, createHashtagPage, labelFromName, isPageWithoutFile, pageClasses, hashtagRegexSingle, createImagePaths, imageSizes, imageExts } from "./../../utils.js"
16
+ import { testURL, testHashtags, createHashtagPage, labelFromName, isPageWithoutFile, pageClasses, hashtagRegexSingle, imageSizes } from "./../../utils.js"
17
17
  import extractDate from "./../../extractDate.js"
18
18
  import { reservedProperties, hiddenProperties } from "./../markdown/metadata.js"
19
19
  import { toString as hastToString } from 'hast-util-to-string'
20
20
  import { unified } from "unified"
21
21
  import toc from "@jsdevtools/rehype-toc"
22
22
  import slug from "rehype-slug"
23
- import createDynamicImage from "./image.js"
23
+ import createDynamicImage, { dominantStyle, imageURL } from "./image.js"
24
24
  import { listPages } from "./../../utils.js"
25
25
  import { pageRecordURI } from "../atproto/record.js"
26
26
  import { STATUSES } from "../markdown/frontmatter.js"
@@ -92,9 +92,8 @@ function makeHeader(metadata, url, api, config, source) {
92
92
  treeMainHead.push(makeTime(date))
93
93
  }
94
94
 
95
- if (metadata.image || metadata.first_image) {
96
- const metaImage = metadata.image || metadata.first_image
97
-
95
+ const metaImage = metadata.image || metadata.first_image
96
+ if (metaImage) {
98
97
  treeMainHead.push(
99
98
  createDynamicImage(metaImage, api, null, true)
100
99
  )
@@ -113,7 +112,12 @@ function makeHeader(metadata, url, api, config, source) {
113
112
  // an item of its own: the itemprops inside it (date, description,
114
113
  // image) describe that page, not the page it's listed on. A page with
115
114
  // no file (`html_file: false`) has no url, so its card links nowhere.
116
- const article = h('article', { className: pageClasses(metadata.prettyURL, source), itemscope: true }, treeMainHead)
115
+ // Its image's dominant colour is the card's, for the card's own CSS.
116
+ const article = h('article', {
117
+ className: pageClasses(metadata.prettyURL, source),
118
+ itemscope: true,
119
+ style: dominantStyle(metaImage, api)
120
+ }, treeMainHead)
117
121
  return url ? h('a', { href: url }, article) : article
118
122
  }
119
123
 
@@ -251,6 +255,13 @@ function naturalMetadata(tree) {
251
255
  const { dataMetadata: kind, ...properties } = node.properties
252
256
  if (kind === "end") return []
253
257
  if (kind === "description") return [{ ...node, properties }]
258
+ // A captioned image is a <figure> (writeRules.js) and is lifted
259
+ // whole. The itemprop goes on its image, not the figure: an image is
260
+ // the property, and the caption is not part of it.
261
+ if (node.tagName === "figure") {
262
+ const [image, ...caption] = node.children
263
+ return [{ ...node, properties, children: [{ ...image, properties: { ...image.properties, itemprop: kind } }, ...caption] }]
264
+ }
254
265
  // A date or an image paragraph only wrapped the one element that is
255
266
  // the metadata: the <time>, or the <picture> (an <img> until the
256
267
  // image has been read).
@@ -545,7 +556,9 @@ function writeFile(target, { settings, api, config }) {
545
556
 
546
557
  // The page's own image, else the site's `fallback_image` (a setting,
547
558
  // so a folder can have its own), so a link preview always has one.
548
- const ogImage = metadata.image || settings.lastNonNull("fm_fallback_image")
559
+ // The largest derivative of a png or jpeg (image.js): the original
560
+ // is not published.
561
+ const ogImage = imageURL(metadata.image || settings.lastNonNull("fm_fallback_image"), api, imageSizes.at(-1))
549
562
  if (ogImage) {
550
563
  treeHead.children.push(h("meta", {
551
564
  property: "og:image",
@@ -568,7 +581,7 @@ function writeFile(target, { settings, api, config }) {
568
581
  // right one lets it pick between several. Without one, the project's
569
582
  // own favicon.png, else Vowel's placeholder (plugins/images). A
570
583
  // tracked read, so a favicon.png that appears later is linked.
571
- const icon = settings.lastNonNull("fm_icon")
584
+ const icon = imageURL(settings.lastNonNull("fm_icon"), api, imageSizes[0])
572
585
  ?? (api.target("favicon.png") ? "/favicon.png" : "/favicon-placeholder.png")
573
586
  treeHead.children.push(h("link", {
574
587
  href: icon,
@@ -609,8 +622,10 @@ function writeFile(target, { settings, api, config }) {
609
622
  const homeLink = []
610
623
 
611
624
 
612
- const logo = settings.lastNonNull("logo")
613
- const wordmark = settings.lastNonNull("fm_wordmark")
625
+ // The smallest derivative of a png or jpeg (image.js): the original
626
+ // is not published. An SVG is linked as it is.
627
+ const logo = imageURL(settings.lastNonNull("logo"), api, imageSizes[0])
628
+ const wordmark = imageURL(settings.lastNonNull("fm_wordmark"), api, imageSizes[0])
614
629
 
615
630
  const identityElements = []
616
631
 
@@ -647,7 +662,7 @@ function writeFile(target, { settings, api, config }) {
647
662
  const treeHeader = h('header', [
648
663
  identity,
649
664
  treeNav,
650
- socialLinksNav(settings)
665
+ socialLinksNav(settings, api)
651
666
  ])
652
667
 
653
668
 
@@ -699,7 +714,9 @@ function writeFile(target, { settings, api, config }) {
699
714
 
700
715
  const treeMain = h('main',
701
716
  {
702
- itemscope: true
717
+ itemscope: true,
718
+ // The page image's dominant colour, for anything in <main> to use.
719
+ style: dominantStyle(metadata.image, api)
703
720
  },
704
721
  [
705
722
  // The title is main's first child: the one piece of data hoisted out
@@ -723,7 +740,7 @@ function writeFile(target, { settings, api, config }) {
723
740
  const treeAside = asideNav ? h("aside", asideNav) : null
724
741
 
725
742
  const treeFooter = h('footer', [
726
- socialLinksNav(settings),
743
+ socialLinksNav(settings, api),
727
744
  h('section#copyright', `© ${new Date().getFullYear()}`),
728
745
  h('section#shoutout', [
729
746
  "Made with ",
@@ -41,9 +41,23 @@ const PROJECT_SHEET = "styles.css"
41
41
 
42
42
  const ASIDE_PARTIAL = path.join("partials", "aside.partial")
43
43
 
44
- /** A page with more siblings than this is left out of the aside. */
44
+ /** A page with more siblings than this is left out of the aside and the header nav. */
45
45
  const MAX_SIBLINGS = 18
46
46
 
47
+ /**
48
+ * A folder's pages as a menu, or none: a page with more than
49
+ * MAX_SIBLINGS siblings is left out, and so are its siblings. Their
50
+ * folder's own entry stays, and its pages are a listing's job, not a
51
+ * menu's. Per Sam: "exclude pages with 19 siblings or more", and the
52
+ * header nav follows the same rule as the aside. Without it, a folder
53
+ * of 4,000 undated notes put a 4,000-link nav on each of them: 352 KB of
54
+ * a 355 KB page, and a build that grew with the square of the folder.
55
+ * @template T
56
+ * @param {T[]} pages - one folder's
57
+ * @returns {T[]}
58
+ */
59
+ const asMenu = (pages) => pages.length - 1 > MAX_SIBLINGS ? [] : pages
60
+
47
61
  /** @param {string} targetPath - the page's own target path */
48
62
  function backlinksPartialPath(targetPath) {
49
63
  return path.join("partials", "backlinks", `${targetPath}.partial`)
@@ -103,19 +117,16 @@ function asideTree(pages) {
103
117
  if (!byFolder.has(url)) byFolder.set(url, [])
104
118
  byFolder.get(url).push(page)
105
119
  }
106
- // A page with more than MAX_SIBLINGS siblings is left out, and so are
107
- // its siblings: the folder's own entry stays, and its pages are a
108
- // listing's job, not a menu's. Per Sam: "exclude pages with 19
109
- // siblings or more".
120
+ // A folder with too many pages for a menu shows its own entry only (asMenu).
110
121
  const children = (parent) => {
111
122
  const pages = byFolder.get(parent.metadata.prettyURL) ?? []
112
- return pages.length - 1 > MAX_SIBLINGS ? [] : pages.map(node)
123
+ return asMenu(pages).map(node)
113
124
  }
114
125
 
115
126
  function node(page) {
116
127
  const below = children(page)
117
128
  // The aside links the file, not the pretty url - kept as it was
118
- // (see the note in the commit); the home entry is the bare path.
129
+ // (see the note in the commit).
119
130
  return { url: "/" + page.path, label: page.metadata.breadcrumb, ...(below.length ? { children: below } : {}) }
120
131
  }
121
132
 
@@ -124,7 +135,9 @@ function asideTree(pages) {
124
135
  && isOn(page.metadata.global_nav_item) && isOn(page.metadata.html_file))
125
136
  .map(node)
126
137
 
127
- return [...(home ? [{ url: home.path, label: home.metadata.breadcrumb }] : []), ...top]
138
+ // The home entry is the root's url: its bare path, `index.html`, is
139
+ // relative, and from /blog/post it named /blog/index.html.
140
+ return [...(home ? [item(home)] : []), ...top]
128
141
  }
129
142
 
130
143
  /**
@@ -224,7 +237,12 @@ function createPartialStubs(api) {
224
237
  const navs = [...folders].sort().map(folder => ({
225
238
  path: navPartialPath(folder),
226
239
  params: {
227
- levels: chain(folder).map(level => (lists.get(level) ?? []).map(item)),
240
+ // The root's pages are the top of the menu, as in the aside; below
241
+ // it, a folder with too many pages shows none of them (asMenu).
242
+ levels: chain(folder).map(level => {
243
+ const list = lists.get(level) ?? []
244
+ return (level ? asMenu(list) : list).map(item)
245
+ }),
228
246
  sheets: chain(folder).flatMap(level => sheets.get(level) ?? [])
229
247
  }
230
248
  }))
@@ -1,4 +1,6 @@
1
1
  import { h } from "hastscript"
2
+ import { imageSizes } from "../../utils.js"
3
+ import { imageURL } from "./image.js"
2
4
 
3
5
  /**
4
6
  * Social links, from the `social_links` setting:
@@ -45,16 +47,19 @@ function socialLinks(settings) {
45
47
  /**
46
48
  * A `<nav>` of the links, or null when there are none.
47
49
  * @param {any} settings
50
+ * @param {import("votive").PluginAPI} api
48
51
  */
49
- function socialLinksNav(settings) {
52
+ function socialLinksNav(settings, api) {
50
53
  const links = socialLinks(settings)
51
54
  if (!links.length) return null
52
55
 
53
56
  return h("nav.social-links", { "aria-label": "Social links" },
54
57
  h("ul", links.map(({ label, url, icon }) => {
55
58
  const text = label ?? url
56
- // A URL path from the root, resolved at read (frontmatter.js).
57
- const iconURL = icon ? String(icon) : null
59
+ // A URL path from the root, resolved at read (frontmatter.js); a
60
+ // png or jpeg's smallest derivative, since the original is not
61
+ // published (image.js).
62
+ const iconURL = icon ? imageURL(String(icon), api, imageSizes[0]) : null
58
63
  const image = iconURL && h("img", { src: iconURL, alt: text, style: `--url-icon: url("${iconURL}")` })
59
64
  return h("li", h("a", { href: url, rel: "me" }, image || text))
60
65
  }))
@@ -4,9 +4,9 @@ import { common, createLowlight } from "lowlight"
4
4
  import { toText } from "hast-util-to-text"
5
5
  import GithubSlugger from "github-slugger"
6
6
  import { createVisitor, SKIP } from "../markdown/visitor.js"
7
- import { showPages, isPageWithoutFile } from "../../utils.js"
7
+ import { showPages, isPageWithoutFile, imageSizes } from "../../utils.js"
8
8
  import { globClasses, alertLabel } from "./editor/directives.js"
9
- import createDynamicImage from "./image.js"
9
+ import createDynamicImage, { derivativeURL } from "./image.js"
10
10
  import { displayPath } from "../../secretPaths.js"
11
11
 
12
12
  /**
@@ -139,7 +139,9 @@ const internalLink = {
139
139
  if (!readInBackground(pathname, config)) config.log?.("warn", `${target.source ?? target.path}: link to ${href} matches no page`)
140
140
  return
141
141
  }
142
- node.properties.href = urlOf(found) + suffix
142
+ // A link to a png or jpeg opens its largest derivative: the
143
+ // original is not published (image.js).
144
+ node.properties.href = (derivativeURL(found, imageSizes.at(-1)) ?? urlOf(found)) + suffix
143
145
  }
144
146
  }
145
147
 
@@ -291,14 +293,42 @@ const highlight = {
291
293
  }
292
294
  }
293
295
 
296
+ /** The stand-in size of an image not read yet: 3:2, the commonest photo shape. */
297
+ const PENDING_WIDTH = 1500
298
+ const PENDING_HEIGHT = 1000
299
+
300
+ /** An image in the text, not an inline icon (`:folder/name:`). @param {any} node */
301
+ const isImage = (node) => isElement(node) && node.tagName === "img" && !hasClass(node, "icon")
302
+
294
303
  /**
295
- * A project image becomes a responsive <picture>; first in its
296
- * paragraph with siblings after it, those are its caption.
304
+ * A paragraph that starts with an image and goes on is a captioned
305
+ * image: the paragraph becomes a <figure>, and what follows the image
306
+ * its <figcaption>. In place, so the walk goes on into both (the image
307
+ * rule makes the <picture>, the inline rules read the caption) and a
308
+ * natural image's mark (`data-metadata`) stays on the element the html
309
+ * plugin lifts. The <figure> used to go inside the <p>, where it is not
310
+ * allowed: a browser closed the paragraph before it and left an empty
311
+ * one behind.
312
+ * @type {import("../markdown/visitor.js").BlockRule}
313
+ */
314
+ const captionedImage = {
315
+ name: "captioned image",
316
+ test: (node) => isElement(node) && node.tagName === "p" && isImage(node.children?.[0])
317
+ && node.children.slice(1).some(child => child.type !== "text" || child.value.trim()),
318
+ transform: (node) => {
319
+ const [img, ...caption] = node.children
320
+ node.tagName = "figure"
321
+ node.children = [img, h("figcaption", caption)]
322
+ }
323
+ }
324
+
325
+ /**
326
+ * A project image becomes a responsive <picture>.
297
327
  * @type {import("../markdown/visitor.js").BlockRule}
298
328
  */
299
329
  const image = {
300
330
  name: "image",
301
- test: (node) => isElement(node) && node.tagName === "img" && !hasClass(node, "icon"),
331
+ test: isImage,
302
332
  transform: (node, index, parent, context) => {
303
333
  const { api, target } = context
304
334
  const { src, alt } = node.properties
@@ -309,16 +339,20 @@ const image = {
309
339
  const relative = isInternal(src) && !src.startsWith("/")
310
340
  const picture = createDynamicImage(relative ? fromPage(splitURL(src).pathname, target) : src, api, alt)
311
341
  if (!picture) {
312
- // Left as an <img>: say so when the browser would get a 404 for it.
342
+ // Left as an <img>. One whose file is read in the background may
343
+ // only be not read yet; any other miss is a 404, said so.
313
344
  const { pathname } = isInternal(src) ? splitURL(src) : {}
314
- if (pathname && !readInBackground(pathname, context.config) && !servedTarget(pathname, context)) {
345
+ if (!pathname || servedTarget(pathname, context)) return SKIP
346
+ if (!readInBackground(pathname, context.config)) {
315
347
  context.config.log?.("warn", `${context.target.source ?? context.target.path}: image ${src} matches no file`)
348
+ return SKIP
316
349
  }
317
- return SKIP
318
- }
319
- if (index === 0 && parent.children.length > 1) {
320
- const caption = parent.children.slice(1)
321
- parent.children = [h("figure", [picture, h("figcaption", caption)])]
350
+ // Not read yet, so its size is unknown. A 3:2 box stands in (the
351
+ // reset sizes an <img> from its attributes, capped at the column),
352
+ // so the page doesn't jump when the read rewrites it at its own size.
353
+ if (node.properties.width !== undefined || node.properties.height !== undefined) return SKIP
354
+ node.properties.width = PENDING_WIDTH
355
+ node.properties.height = PENDING_HEIGHT
322
356
  return SKIP
323
357
  }
324
358
  return picture
@@ -530,7 +564,7 @@ function trimText(nodes) {
530
564
  const skipped = new Set(["pre", "code", "script", "style"])
531
565
 
532
566
  const writeWalk = createVisitor({
533
- block: [internalLink, wikilink, icon, highlight, image, directive, preview, alert],
567
+ block: [internalLink, wikilink, icon, highlight, captionedImage, image, directive, preview, alert],
534
568
  skip: (node) => isElement(node) && skipped.has(node.tagName)
535
569
  })
536
570
 
@@ -72,23 +72,46 @@ async function readImagePath(source) {
72
72
  const hash = digest("sha1", buffer).slice(0, 8)
73
73
 
74
74
  const ext = path.extname(source.target)
75
- const targets = imageSizes.flatMap(size => [...imageExts, ext].map(format => ({
75
+ const formats = [...imageExts, ext.replace(/^\./, "")]
76
+ const targets = formats.flatMap(format => imageSizes.map(size => ({
76
77
  path: createImagePath({ targetFilePath: source.target, size, ext: format, targetDirectory: "", uuid: hash }),
77
- metadata: { derivative: { size, format: format.replace(/^\./, "") } }
78
+ metadata: { derivative: { size, format } }
78
79
  })))
80
+ const metadata = { hash, width, height, dominant: dominantColor, placeholder }
79
81
 
80
- return { metadata: { hash, width, height, dominant: dominantColor, placeholder }, targets }
82
+ if (!isOptimized(source)) return { metadata, targets }
83
+
84
+ // Grouped by format in <picture> order (avif, webp, its own), each
85
+ // smallest first. This list is the one place a derivative's path is
86
+ // decided: plugins/html/image.js links what it names.
87
+ const derivatives = targets.map(({ path, metadata }) => ({ path, ...metadata.derivative }))
88
+ return { metadata: { ...metadata, derivatives }, targets, write: false }
89
+ }
90
+
91
+ /**
92
+ * A png or jpeg is never published at full size: its derivatives are
93
+ * linked everywhere instead, and the original is a virtual target. The
94
+ * root favicon.png is exempt, because it is linked as it is, as the
95
+ * site's icon. The other formats are copied through as before; their
96
+ * derivatives are made but nothing links them yet.
97
+ * @param {{ path: string, target: string }} source
98
+ */
99
+ function isOptimized(source) {
100
+ if (source.path === "favicon.png") return false
101
+ return /^\.(png|jpe?g)$/.test(path.extname(source.target))
81
102
  }
82
103
 
83
104
  /**
84
- * An original is copied through. A derivative is resized and converted
85
- * from its owner's bytes - which is what target.buffer() reads, because
86
- * votive stored the owner as the derivative's source.
105
+ * A derivative is resized and converted from its owner's bytes - which
106
+ * is what target.buffer() reads, because votive stored the owner as the
107
+ * derivative's source. An original with derivatives is virtual and
108
+ * writes nothing; any other original is copied through.
87
109
  * @type {Votive.ProcessorWrite}
88
110
  */
89
111
  async function writeImage(target) {
90
112
  // The placeholder favicon has no file to read; its bytes are its data.
91
113
  if (target.metadata?.favicon) return { data: Buffer.from(target.data, "base64") }
114
+ if (target.metadata?.derivatives) return
92
115
 
93
116
  const bytes = target.buffer()
94
117
  const recipe = target.metadata?.derivative
@@ -16,7 +16,7 @@ import { readFileSync } from "fs"
16
16
  import { createHashtagPage, labelFromName, showPages } from "./../../utils.js"
17
17
  import { toHast, defaultHandlers } from 'mdast-util-to-hast'
18
18
  import { toString as hastToString } from 'hast-util-to-string'
19
- import getMetadata, { reservedProperties } from "./metadata.js"
19
+ import getMetadata, { imageFromPage, reservedProperties } from "./metadata.js"
20
20
  import { isSecretPath, secretRouter, displayPath } from "./../../secretPaths.js"
21
21
  import { collectLinks } from "./links.js"
22
22
  import { readWalk } from "./readRules.js"
@@ -63,7 +63,9 @@ function readFile(source, { api, config }) {
63
63
  if (!metadata.image) {
64
64
  const firstImageParagraph = mdast.children.find(child => child.children && child.children[0].type === "image")
65
65
  if (firstImageParagraph) {
66
- metadata.first_image = firstImageParagraph.children[0].url
66
+ // A card shows it from another folder's page, so from the root.
67
+ const folder = path.dirname(targetPath ?? filePath).replace(/^\.$/, "")
68
+ metadata.first_image = imageFromPage(firstImageParagraph.children[0].url, folder)
67
69
  }
68
70
  }
69
71
 
@@ -6,6 +6,49 @@ import { displayPath } from "../../secretPaths.js"
6
6
  import pathDate from "../../pathDate.js"
7
7
  import { coerce, parseFrontmatter, toISODate } from "./frontmatter.js"
8
8
 
9
+ /**
10
+ * An HTML comment, or several in a row with nothing else between them.
11
+ * It stays in the page, but metadata never reads it (per Sam): a
12
+ * comment is not a title, a date or a description, and it doesn't take
13
+ * a block's place in the scan for one.
14
+ * @param {any} node
15
+ */
16
+ function isComment(node) {
17
+ return node.type === "html" && /^\s*(?:<!--[\s\S]*?-->\s*)+$/.test(node.value)
18
+ }
19
+
20
+ /**
21
+ * A node's text for metadata: mdastToString with its HTML comments left
22
+ * out, at any depth. Where a comment sat between two words, the spaces
23
+ * either side of it close up to one.
24
+ * @param {any} node
25
+ * @returns {string}
26
+ */
27
+ function metadataText(node) {
28
+ if (isComment(node)) return ""
29
+ if (!node.children) return mdastToString(node)
30
+ const text = node.children.map(metadataText).join("")
31
+ if (!node.children.some(isComment)) return text
32
+ return text.replace(/[ \t]{2,}/g, " ")
33
+ }
34
+
35
+ /**
36
+ * An image the page's text names, as the browser resolves it from the
37
+ * page: `pic.jpg` and `./pic.jpg` are the page's folder's, `../pic.jpg`
38
+ * its parent's, `/pic.jpg` the root's. Stored as a path from the root,
39
+ * the way the html write resolves the same image (`fromPage` in
40
+ * writeRules.js), because a listing card and `og:image` read it from
41
+ * pages in other folders. An external url is left as written. (A
42
+ * frontmatter `image:` is a setting-style path instead: `resolvePath`.)
43
+ * @param {string} url - as written
44
+ * @param {string} folder - the page's target folder, project-relative ("" at the root)
45
+ */
46
+ export function imageFromPage(url, folder) {
47
+ if (/^[a-z][a-z\d+.-]*:/i.test(url) || url.startsWith("//")) return url
48
+ const base = new URL(folder ? `/${folder.split(path.sep).join("/")}/` : "/", "thismessage://")
49
+ return decodeURIComponent(new URL(url.replaceAll("#", "%23"), base).pathname)
50
+ }
51
+
9
52
 
10
53
  /**
11
54
  - menu_item: true | false
@@ -50,7 +93,7 @@ export const reservedProperties = [
50
93
  * @param {object} tree
51
94
  */
52
95
  export function findTitleNode(tree) {
53
- const [first] = tree.children.filter(child => child.type !== "yaml")
96
+ const [first] = tree.children.filter(child => child.type !== "yaml" && !isComment(child))
54
97
  if (!first) return null
55
98
  if (first.type !== "heading" || first.depth !== 1) return null
56
99
  return first
@@ -101,24 +144,29 @@ function markAsTime(paragraph, date) {
101
144
  *
102
145
  * @param {object} block
103
146
  * @param {object} metadata
147
+ * @param {string} folder - the page's target folder, for a relative image
104
148
  * @returns {boolean} whether the block was recognizable data
105
149
  */
106
- function recognizeData(block, metadata) {
150
+ function recognizeData(block, metadata, folder) {
107
151
  if (block.type !== "paragraph") return false
108
- if (block.children.length !== 1) return false
109
-
110
- const [child] = block.children
152
+ const children = block.children.filter(child => !isComment(child))
153
+ const [child] = children
111
154
 
112
155
  const imageFilled = Boolean(metadata.fm_image || metadata.inferred_image)
113
156
 
114
- if (child.type === "image") {
157
+ // An image, alone or with its caption after it on the same line: the
158
+ // html write makes the pair a <figure> and lifts it whole. The caption
159
+ // is not a property of the page.
160
+ if (child?.type === "image") {
115
161
  if (imageFilled) return false
116
- metadata.inferred_image = child.url
162
+ metadata.inferred_image = imageFromPage(child.url, folder)
117
163
  metadata.inferred_alt_text = child.alt
118
164
  return "image"
119
165
  }
120
166
 
121
- const text = mdastToString(block)
167
+ if (children.length !== 1) return false
168
+
169
+ const text = metadataText(block)
122
170
 
123
171
  // A reference or a listing directive (`/blog/post`, `/blog/**`,
124
172
  // `./drafts/*`) is an instruction to the html plugin, not prose - it
@@ -227,16 +275,16 @@ function getMetadata(tree, filePath, targetPath, report = () => {}) {
227
275
 
228
276
  const titleNode = findTitleNode(tree)
229
277
  if (titleNode) {
230
- metadata.inferred_title = mdastToString(titleNode) || labelFromName(path.parse(filePath).name)
278
+ metadata.inferred_title = metadataText(titleNode).trim() || labelFromName(path.parse(filePath).name)
231
279
  }
232
280
 
233
281
  // Everything up to the first block that is not recognizable data is
234
282
  // metadata; that first block is where the content begins, and it also
235
283
  // supplies the description.
236
- const blocks = tree.children.filter(child => child.type !== "yaml" && child !== titleNode)
284
+ const blocks = tree.children.filter(child => child.type !== "yaml" && child !== titleNode && !isComment(child))
237
285
 
238
286
  for (const [index, block] of blocks.entries()) {
239
- const recognized = recognizeData(block, metadata)
287
+ const recognized = recognizeData(block, metadata, folder)
240
288
  if (recognized === "skip") continue
241
289
  if (recognized) {
242
290
  markAsNatural(block, recognized)
@@ -251,13 +299,13 @@ function getMetadata(tree, filePath, targetPath, report = () => {}) {
251
299
  // markdown, not a paragraph and a rule; it takes a blank line.)
252
300
  const next = blocks[index + 1]
253
301
  if (!metadata.fm_description && block.type === "paragraph" && next?.type === "thematicBreak") {
254
- metadata.declared_description = mdastToString(block)
302
+ metadata.declared_description = metadataText(block).trim()
255
303
  markAsNatural(block, "description")
256
304
  markAsNatural(next, "end")
257
305
  break
258
306
  }
259
307
 
260
- if (!metadata.fm_description) metadata.inferred_description = mdastToString(block)
308
+ if (!metadata.fm_description) metadata.inferred_description = metadataText(block).trim()
261
309
  break
262
310
  }
263
311
 
@@ -311,6 +311,7 @@
311
311
  ul {
312
312
  list-style: none;
313
313
  display: flex;
314
+ flex-wrap: wrap;
314
315
  gap: 2ch;
315
316
  }
316
317
 
@@ -438,6 +439,11 @@
438
439
  }
439
440
  }
440
441
 
442
+ /* the page's own image, beside its title, as well as the content's */
443
+ img {
444
+ border-radius: var(--borderradius-tight);
445
+ }
446
+
441
447
  /* content */
442
448
 
443
449
  section#content {
@@ -454,10 +460,6 @@
454
460
  }
455
461
  }
456
462
 
457
- img {
458
- border-radius: var(--borderradius-tight);
459
- }
460
-
461
463
  mark,
462
464
  code {
463
465
  padding: 0.1cap 0.5ch;
@@ -334,15 +334,15 @@
334
334
  &>section {
335
335
  margin-top: 1lh;
336
336
  }
337
- }
338
-
339
- /* content */
340
- section {
341
337
 
342
- /* image caption space */
338
+ /* image caption space: the page's own captioned image, beside its title, as well as the content's */
343
339
  figure {
344
340
  gap: 1lh;
345
341
  }
342
+ }
343
+
344
+ /* content */
345
+ section {
346
346
 
347
347
  /* list spacing */
348
348
  ul,
package/utils.js CHANGED
@@ -23,26 +23,6 @@ export function createImagePath({ targetFilePath, size, ext, targetDirectory, uu
23
23
  })
24
24
  }
25
25
 
26
- /**
27
- * @param {string} targetFilePath
28
- * @param {string} targetDirectory
29
- * @param {string} uuid
30
- */
31
- export function createImagePaths(targetFilePath, targetDirectory, uuid) {
32
- const defaultExt = path.extname(targetFilePath)
33
- return [...imageExts, defaultExt].map(ext => {
34
- return imageSizes.map(size => {
35
- return createImagePath({
36
- targetFilePath,
37
- size,
38
- ext,
39
- targetDirectory,
40
- uuid
41
- })
42
- })
43
- })
44
- }
45
-
46
26
  /** @param {string} text */
47
27
  export function testURL(text) {
48
28
  if (text.match(/^https?:\/\/[\S]+$/)) {