vowel 0.7.1 → 0.7.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/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.4",
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
  },
@@ -5,6 +5,7 @@ import { gfmFootnoteToMarkdown } from "mdast-util-gfm-footnote"
5
5
  import { gfmTaskListItemToMarkdown } from "mdast-util-gfm-task-list-item"
6
6
  import { toText } from "hast-util-to-text"
7
7
  import { emojiFor } from "../markdown/emoji.js"
8
+ import { alertLabel } from "../html/editor/directives.js"
8
9
 
9
10
  /**
10
11
  * A page's body as a record carries it, from the rendered
@@ -17,7 +18,8 @@ import { emojiFor } from "../markdown/emoji.js"
17
18
  * and footnotes vowel itself reads. Links and images are absolute; an
18
19
  * image links to its copy on the site. An icon is its emoji, or its
19
20
  * shortcode as text. A listing is a list of links. A hashtag is a link
20
- * whose text keeps its `#`. No title: it is outside the section.
21
+ * whose text keeps its `#`. An alert is GFM's (`> [!NOTE]`). No
22
+ * title: it is outside the section.
21
23
  * - **Rich text** (`richTextBody`), for a `text` field with `facets`
22
24
  * beside it: plain text, and a facet for each link, hashtag and mark
23
25
  * the lexicon has a feature for. The rest is dropped, images included.
@@ -113,7 +115,9 @@ function phrase(node, base) {
113
115
  }
114
116
  if (tag === "picture") {
115
117
  const img = (node.children ?? []).find(child => is(child, "img"))
116
- const src = node.properties?.dataOriginal ?? img?.properties?.src
118
+ // The <img>'s src is the largest derivative in the image's own
119
+ // format; the original is not published (plugins/images).
120
+ const src = img?.properties?.src
117
121
  return src ? [{ type: "image", url: absolute(String(src), base), alt: String(img?.properties?.alt ?? "") }] : []
118
122
  }
119
123
  if (tag === "a") {
@@ -179,7 +183,8 @@ function block(node, base) {
179
183
  if (/^h[1-6]$/.test(tag)) return [{ type: "heading", depth: Number(tag[1]), children: trimPhrasing(phrasing(node.children ?? [], base)) }]
180
184
  if (tag === "hr") return [{ type: "thematicBreak" }]
181
185
  if (tag === "picture") return [paragraph([node], base)]
182
- if (tag === "blockquote" || hasClass(node, "markdown-alert")) return [{ type: "blockquote", children: blocks(node.children ?? [], base) }]
186
+ if (tag === "aside" && hasClass(node, "alert")) return [alert(node, base)]
187
+ if (tag === "blockquote") return [{ type: "blockquote", children: blocks(node.children ?? [], base) }]
183
188
  if (tag === "pre") {
184
189
  const code = (node.children ?? []).find(child => is(child, "code")) ?? node
185
190
  const language = classes(code).find(name => name.startsWith("language-"))?.slice("language-".length)
@@ -191,6 +196,30 @@ function block(node, base) {
191
196
  return blocks(node.children ?? [], base)
192
197
  }
193
198
 
199
+ /**
200
+ * An alert, `<aside class="alert note">` with its heading first, back in
201
+ * GFM's form: `> [!NOTE]`, then the text on the next line. A heading the
202
+ * author wrote stays on the marker's line (`> [!NOTE] How to render`,
203
+ * Obsidian's title, which vowel reads too); the default one, the type in
204
+ * sentence case, is left off. The marker is raw `html` so that it is
205
+ * not escaped as a link reference.
206
+ * @param {any} node
207
+ * @param {string} base
208
+ */
209
+ function alert(node, base) {
210
+ const type = classes(node).find(name => name !== "alert") ?? "note"
211
+ const [first, ...rest] = (node.children ?? []).filter(child => child.type === "element")
212
+ const hasHeading = /^h[1-6]$/.test(first?.tagName ?? "")
213
+ const title = hasHeading && toText(first).trim() !== alertLabel(type) ? trimPhrasing(phrasing(first.children ?? [], base)) : []
214
+ const marker = [{ type: "html", value: `[!${type.toUpperCase()}]` }, ...(title.length ? [{ type: "text", value: " " }, ...title] : [])]
215
+ const body = blocks(hasHeading ? rest : [first, ...rest].filter(child => child), base)
216
+ const [lead, ...after] = body
217
+ const children = lead?.type === "paragraph"
218
+ ? [{ ...lead, children: [...marker, { type: "text", value: "\n" }, ...lead.children] }, ...after]
219
+ : [{ type: "paragraph", children: marker }, ...body]
220
+ return { type: "blockquote", children }
221
+ }
222
+
194
223
  /** @param {any} node @param {string} base */
195
224
  function list(node, base) {
196
225
  const items = (node.children ?? []).filter(child => is(child, "li"))
@@ -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