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.
@@ -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, mirrorPath } 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).
@@ -527,6 +538,18 @@ function writeFile(target, { settings, api, config }) {
527
538
  )
528
539
  }
529
540
 
541
+ // The page's markdown mirror (markdown/mirror.js), linked only while
542
+ // it exists. A tracked read: the page is rebuilt when its mirror is
543
+ // declared or removed, and not when the mirror is rewritten.
544
+ const mirror = api.target(mirrorPath(target.path))
545
+ if (mirror) {
546
+ treeHead.children.push(h("link", {
547
+ rel: "alternate",
548
+ type: "text/markdown",
549
+ href: "/" + mirror.path.split(path.sep).join("/")
550
+ }))
551
+ }
552
+
530
553
  // The page's AT Protocol record, for readers that verify a document
531
554
  // against its site: standard.site's convention, so only for a
532
555
  // site.standard.document, and only for a page that can be one - it
@@ -545,7 +568,9 @@ function writeFile(target, { settings, api, config }) {
545
568
 
546
569
  // The page's own image, else the site's `fallback_image` (a setting,
547
570
  // so a folder can have its own), so a link preview always has one.
548
- const ogImage = metadata.image || settings.lastNonNull("fm_fallback_image")
571
+ // The largest derivative of a png or jpeg (image.js): the original
572
+ // is not published.
573
+ const ogImage = imageURL(metadata.image || settings.lastNonNull("fm_fallback_image"), api, imageSizes.at(-1))
549
574
  if (ogImage) {
550
575
  treeHead.children.push(h("meta", {
551
576
  property: "og:image",
@@ -568,7 +593,7 @@ function writeFile(target, { settings, api, config }) {
568
593
  // right one lets it pick between several. Without one, the project's
569
594
  // own favicon.png, else Vowel's placeholder (plugins/images). A
570
595
  // tracked read, so a favicon.png that appears later is linked.
571
- const icon = settings.lastNonNull("fm_icon")
596
+ const icon = imageURL(settings.lastNonNull("fm_icon"), api, imageSizes[0])
572
597
  ?? (api.target("favicon.png") ? "/favicon.png" : "/favicon-placeholder.png")
573
598
  treeHead.children.push(h("link", {
574
599
  href: icon,
@@ -609,8 +634,10 @@ function writeFile(target, { settings, api, config }) {
609
634
  const homeLink = []
610
635
 
611
636
 
612
- const logo = settings.lastNonNull("logo")
613
- const wordmark = settings.lastNonNull("fm_wordmark")
637
+ // The smallest derivative of a png or jpeg (image.js): the original
638
+ // is not published. An SVG is linked as it is.
639
+ const logo = imageURL(settings.lastNonNull("logo"), api, imageSizes[0])
640
+ const wordmark = imageURL(settings.lastNonNull("fm_wordmark"), api, imageSizes[0])
614
641
 
615
642
  const identityElements = []
616
643
 
@@ -647,7 +674,7 @@ function writeFile(target, { settings, api, config }) {
647
674
  const treeHeader = h('header', [
648
675
  identity,
649
676
  treeNav,
650
- socialLinksNav(settings)
677
+ socialLinksNav(settings, api)
651
678
  ])
652
679
 
653
680
 
@@ -699,7 +726,9 @@ function writeFile(target, { settings, api, config }) {
699
726
 
700
727
  const treeMain = h('main',
701
728
  {
702
- itemscope: true
729
+ itemscope: true,
730
+ // The page image's dominant colour, for anything in <main> to use.
731
+ style: dominantStyle(metadata.image, api)
703
732
  },
704
733
  [
705
734
  // The title is main's first child: the one piece of data hoisted out
@@ -723,7 +752,7 @@ function writeFile(target, { settings, api, config }) {
723
752
  const treeAside = asideNav ? h("aside", asideNav) : null
724
753
 
725
754
  const treeFooter = h('footer', [
726
- socialLinksNav(settings),
755
+ socialLinksNav(settings, api),
727
756
  h('section#copyright', `© ${new Date().getFullYear()}`),
728
757
  h('section#shoutout', [
729
758
  "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
@@ -69,6 +69,7 @@ const EXPECTED = {
69
69
  aliases: "strings",
70
70
  status: "status",
71
71
  html_file: "boolean",
72
+ markdown_mirror: "boolean",
72
73
  rss_item: "boolean",
73
74
  sitemap_item: "boolean",
74
75
  global_nav_item: "boolean",
@@ -16,11 +16,12 @@ 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"
23
23
  import { h } from "hastscript"
24
+ import { mirrorStubs, mirrorWriter } from "./mirror.js"
24
25
 
25
26
  import { styleText } from "node:util"
26
27
 
@@ -63,7 +64,9 @@ function readFile(source, { api, config }) {
63
64
  if (!metadata.image) {
64
65
  const firstImageParagraph = mdast.children.find(child => child.children && child.children[0].type === "image")
65
66
  if (firstImageParagraph) {
66
- metadata.first_image = firstImageParagraph.children[0].url
67
+ // A card shows it from another folder's page, so from the root.
68
+ const folder = path.dirname(targetPath ?? filePath).replace(/^\.$/, "")
69
+ metadata.first_image = imageFromPage(firstImageParagraph.children[0].url, folder)
67
70
  }
68
71
  }
69
72
 
@@ -231,6 +234,11 @@ function settingsContribution(metadata, filePath, log) {
231
234
  if (metadata.fm_slogan !== undefined) {
232
235
  log?.("warn", `${filePath}: \`slogan:\` in a settings file does nothing - a slogan belongs to a page, e.g. home.md`)
233
236
  }
237
+ // Mirrors are declared by an enumerator, which sees the root's
238
+ // settings only.
239
+ if (metadata.markdown_mirror !== undefined && filePath !== "settings.md") {
240
+ log?.("warn", `${filePath}: \`markdown_mirror:\` works only in the root settings.md or in a page's frontmatter`)
241
+ }
234
242
  const declared = Object.entries(metadata)
235
243
  .filter(([key]) => key.startsWith("fm_") || reservedProperties.includes(key))
236
244
  return {
@@ -240,19 +248,6 @@ function settingsContribution(metadata, filePath, log) {
240
248
  }
241
249
  }
242
250
 
243
- /**
244
- * The only markdown target vowel produces is settings.md itself (every
245
- * other .md file is routed to .html), so this passes its source text
246
- * through untouched - the panel edits the real file, not a rendering of
247
- * it.
248
- * @type {Votive.ProcessorWrite}
249
- */
250
- function writeMarkdown(target) {
251
- return {
252
- data: target.data,
253
- encoding: "utf-8"
254
- }
255
- }
256
251
 
257
252
 
258
253
 
@@ -489,8 +484,7 @@ const readMarkdown = {
489
484
  router,
490
485
  createStubs,
491
486
  expandStubs,
492
- readFile,
493
- writeFile: writeMarkdown
487
+ readFile
494
488
  }
495
489
 
496
490
 
@@ -498,7 +492,7 @@ const readMarkdown = {
498
492
  /** @type {Votive.VotivePlugin} */
499
493
  const vowelMarkdownPlugin = {
500
494
  name: "vowel",
501
- processors: [readMarkdown]
495
+ processors: [readMarkdown, mirrorStubs, mirrorWriter]
502
496
  }
503
497
 
504
498
  export default vowelMarkdownPlugin