vowel 0.7.1-beta.0 → 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.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +41 -239
  3. package/bin.js +91 -31
  4. package/cli.js +32 -0
  5. package/config.js +12 -0
  6. package/extractDate.js +1 -1
  7. package/package.json +8 -34
  8. package/pathDate.js +69 -0
  9. package/plugins/assets/index.js +1 -1
  10. package/plugins/atproto/body.js +3 -1
  11. package/plugins/atproto/index.js +5 -45
  12. package/plugins/atproto/publish.js +42 -11
  13. package/plugins/atproto/record.js +47 -4
  14. package/plugins/deploy-cloudflare/index.js +4 -1
  15. package/plugins/export/index.js +131 -0
  16. package/plugins/html/bundle/index.js +101 -44
  17. package/plugins/html/editor/Editor.svelte +9 -5
  18. package/plugins/html/editor/create-session.js +3 -1
  19. package/plugins/html/editor/directives.js +12 -0
  20. package/plugins/html/editor/frontmatter.js +77 -12
  21. package/plugins/html/editor/ingest.js +29 -31
  22. package/plugins/html/editor/main.js +5 -1
  23. package/plugins/html/editor/serialize.js +8 -4
  24. package/plugins/html/editor/socket.js +34 -4
  25. package/plugins/html/image.js +88 -27
  26. package/plugins/html/index.js +237 -109
  27. package/plugins/html/partials.js +41 -17
  28. package/plugins/html/socialLinks.js +13 -11
  29. package/plugins/html/writeRules.js +234 -42
  30. package/plugins/images/favicon.png +0 -0
  31. package/plugins/images/index.js +69 -8
  32. package/plugins/markdown/colorScheme.js +70 -14
  33. package/plugins/markdown/frontmatter.js +18 -8
  34. package/plugins/markdown/index.js +94 -25
  35. package/plugins/markdown/metadata.js +132 -32
  36. package/plugins/markdown/typography.js +11 -11
  37. package/plugins/styles/index.js +4 -4
  38. package/plugins/xml/index.js +17 -10
  39. package/stylesheets/DefaultStyles.css +163 -108
  40. package/stylesheets/TypographyStyles.css +148 -81
  41. package/utils.js +74 -21
@@ -13,15 +13,17 @@ 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, toTitleCase, 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
+ import { pageRecordURI } from "../atproto/record.js"
26
+ import { STATUSES } from "../markdown/frontmatter.js"
25
27
  import { themeStylesheets, isVowelStylesheet } from "../styles/theme.js"
26
28
  import { displayPath } from "../../secretPaths.js"
27
29
  import { socialLinksNav } from "./socialLinks.js"
@@ -75,8 +77,9 @@ function siteName(settings, api) {
75
77
  * @param {string} url
76
78
  * @param {import("votive").PluginAPI} api
77
79
  * @param {Votive.VotiveConfig} config
80
+ * @param {string} [source] - the page's source path, for its classes
78
81
  */
79
- function makeHeader(metadata, url, api, config) {
82
+ function makeHeader(metadata, url, api, config, source) {
80
83
 
81
84
  const treeMainHead = []
82
85
 
@@ -89,9 +92,8 @@ function makeHeader(metadata, url, api, config) {
89
92
  treeMainHead.push(makeTime(date))
90
93
  }
91
94
 
92
- if (metadata.image || (metadata.first_image && url)) {
93
- const metaImage = metadata.image || metadata.first_image
94
-
95
+ const metaImage = metadata.image || metadata.first_image
96
+ if (metaImage) {
95
97
  treeMainHead.push(
96
98
  createDynamicImage(metaImage, api, null, true)
97
99
  )
@@ -106,9 +108,41 @@ function makeHeader(metadata, url, api, config) {
106
108
  )
107
109
  )
108
110
 
109
- if (url) return h('a', { href: url }, h('article', treeMainHead))
111
+ // A card carries its page's classes, as the page's <body> does, and is
112
+ // an item of its own: the itemprops inside it (date, description,
113
+ // image) describe that page, not the page it's listed on. A page with
114
+ // no file (`html_file: false`) has no url, so its card links nowhere.
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)
121
+ return url ? h('a', { href: url }, article) : article
122
+ }
110
123
 
111
- return treeMainHead
124
+ /**
125
+ * How many links a hast tree holds.
126
+ * @param {any} node
127
+ * @returns {number}
128
+ */
129
+ function linkCount(node) {
130
+ const own = node.tagName === "a" ? 1 : 0
131
+ const children = node.children ?? []
132
+ return children.reduce((sum, child) => sum + linkCount(child), own)
133
+ }
134
+
135
+ /**
136
+ * The nav, or null when it has fewer than two links: a nav is only
137
+ * rendered when there is somewhere else to go (per Sam). A one-entry
138
+ * menu, a trail that is only the home crumb, a table of contents with
139
+ * one heading are left out of the page, rather than hidden with CSS.
140
+ * Social links are exempt: one link is still a `rel="me"` verification.
141
+ * @param {any} nav - a hast element, or null
142
+ */
143
+ function navWithChoices(nav) {
144
+ if (!nav) return null
145
+ return linkCount(nav) > 1 ? nav : null
112
146
  }
113
147
 
114
148
  /**
@@ -187,35 +221,88 @@ function makeValue(value) {
187
221
  * @param {import("votive").PluginAPI} api
188
222
  * @param {Votive.VotiveConfig} config
189
223
  */
190
- function makeFrontmatter(metadata, api, config) {
191
- const handled = ["title", "tagline", "date", "image", "description"]
224
+ /**
225
+ * An element with one more class. Frontmatter properties carry their key
226
+ * this way (`fm_date`, `fm_description`), which is how the editor tells
227
+ * them from the same properties written in the text: those have the
228
+ * itemprop and no class.
229
+ * @param {any} node - a hast element, or nothing
230
+ * @param {string} name
231
+ */
232
+ function withClass(node, name) {
233
+ if (!node) return node
234
+ const className = [...(node.properties?.className ?? []), name]
235
+ return { ...node, properties: { ...node.properties, className } }
236
+ }
237
+
238
+ /**
239
+ * Natural metadata, lifted out of the content: the date line, image and
240
+ * description the author wrote in the text, which the read marked
241
+ * (`data-metadata`, metadata.js) and the write walk has already turned
242
+ * into a <time>, a <picture> and a paragraph. Each becomes a direct
243
+ * child of <main>, like its frontmatter equivalent but without the
244
+ * `fm_` class; the rule that closed a description renders as nothing.
245
+ * The source is untouched - only where the output puts them.
246
+ * @param {any} tree - the walked content; the marked blocks are removed from it
247
+ * @returns {any[]}
248
+ */
249
+ function naturalMetadata(tree) {
250
+ const isMarked = (node) => Boolean(node.properties?.dataMetadata)
251
+ const marked = tree.children.filter(isMarked)
252
+ tree.children = tree.children.filter(node => !isMarked(node))
253
+
254
+ return marked.flatMap(node => {
255
+ const { dataMetadata: kind, ...properties } = node.properties
256
+ if (kind === "end") return []
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
+ }
265
+ // A date or an image paragraph only wrapped the one element that is
266
+ // the metadata: the <time>, or the <picture> (an <img> until the
267
+ // image has been read).
268
+ const element = node.children.find(child => child.type === "element")
269
+ if (!element) return []
270
+ return [{ ...element, properties: { ...element.properties, itemprop: kind } }]
271
+ })
272
+ }
273
+
274
+ function makeFrontmatter(metadata, api, config, natural = []) {
275
+ const handled = ["title", "slogan", "date", "image", "description"]
192
276
 
193
277
  // hiddenProperties never render: the editor takes frontmatter from
194
278
  // the source file, so nothing is lost by leaving a key off the page.
279
+ // `status` is shown only when it's the author's own word ("evergreen");
280
+ // vowel's four (published, hidden, …) are instructions, not content.
281
+ const ownStatus = !STATUSES.includes(metadata.fm_status)
195
282
  const properties = (metadata.frontmatter_keys || []).filter(key => {
196
- return !handled.includes(key) && !hiddenProperties.includes(key)
283
+ return !handled.includes(key) && !hiddenProperties.includes(key) && (key !== "status" || ownStatus)
197
284
  })
198
285
 
199
286
  const known = []
200
287
 
201
288
  if (metadata.fm_date) {
202
- known.push(makeTime(new Date(metadata.fm_date)))
289
+ known.push(withClass(makeTime(new Date(metadata.fm_date)), "fm_date"))
203
290
  }
204
291
 
205
292
  if (metadata.fm_image) {
206
- known.push(createDynamicImage(metadata.fm_image, api, null, true))
293
+ known.push(withClass(createDynamicImage(metadata.fm_image, api, null, true), "fm_image"))
207
294
  }
208
295
 
209
296
  if (metadata.fm_description) {
210
- known.push(h("p", { itemprop: "description" }, metadata.fm_description))
297
+ known.push(h("p.fm_description", { itemprop: "description" }, metadata.fm_description))
211
298
  }
212
299
 
213
300
  const generic = properties.map(key => {
214
301
  const name = reservedProperties.includes(key) ? key : "fm_" + key
215
- return h("dl", [h("dt", key), h("dd", makeValue(metadata[name]))])
302
+ return h(`dl.fm_${key}`, [h("dt", key), h("dd", makeValue(metadata[name]))])
216
303
  })
217
304
 
218
- return [...known, ...generic]
305
+ return [...known, ...natural, ...generic]
219
306
  }
220
307
 
221
308
  /** @type {Votive.ProcessorWrite} */
@@ -289,11 +376,26 @@ function writeFile(target, { settings, api, config }) {
289
376
  // setting); the root's decides which theme gets the plain file names.
290
377
  const themeSheets = themeStylesheets(settings.lastNonNull("theme"), settings.raw("theme")?.[0]?.at(-1))
291
378
 
379
+ // The sheets a `styles:` names: each settings.md's along the chain,
380
+ // root first, then the page's own frontmatter. A path is a URL path
381
+ // from the root (frontmatter.js resolves it); a URL - a web font's
382
+ // stylesheet - is linked as written. A path to no file is skipped,
383
+ // with a warning: the <link> would 404. api.target() tracks it, so
384
+ // the file appearing later relinks the page.
385
+ const listed = [...new Set([...(settings.flat("fm_styles") ?? []), ...(metadata.fm_styles ?? [])])]
386
+ const external = listed.filter(entry => /^[a-z][a-z0-9+.-]*:|^\/\//i.test(entry))
387
+ const named = listed
388
+ .filter(entry => !external.includes(entry))
389
+ .map(entry => ({ entry, sheet: entry.replace(/^\/+/, "").split("/").join(path.sep) }))
390
+ const missing = named.filter(({ sheet }) => !api.target(sheet))
391
+ missing.forEach(({ entry }) => config.log?.("warn", `${target.path}: styles names ${entry}, which isn't in the project`))
392
+ const namedSheets = named.filter(({ sheet }) => !missing.some(gone => gone.sheet === sheet)).map(({ sheet }) => sheet)
393
+
292
394
  // A stub stylesheet has a source path like any other source, so the
293
395
  // `sheet.source` test alone no longer separates vowel's sheets from the
294
396
  // project's - exclude them by name instead, or every theme sheet would
295
397
  // be linked twice.
296
- const sheets = [...themeSheets, ...projectSheets.map(sheet => sheet.path).filter(sheet => !themeSheets.includes(sheet))]
398
+ const sheets = [...new Set([...themeSheets, ...projectSheets.map(sheet => sheet.path), ...namedSheets])]
297
399
  const projectHash = new Map(projectSheets.map(sheet => [sheet.path, sheet.hash]))
298
400
 
299
401
  sheets.forEach(sheet => {
@@ -321,25 +423,70 @@ function writeFile(target, { settings, api, config }) {
321
423
  })
322
424
  )
323
425
  })
426
+ external.forEach(href => treeStyleSheets.push(h('link', { rel: "stylesheet", href })))
324
427
 
325
- // The document title: the page's own title, then every `name:` from
326
- // the page's folder up to the root, then the page's own tagline,
327
- // hyphen-delimited - "Hats - Shop - Site - Warm heads". The homepage is
328
- // the site's front door and shows the site's name and its tagline.
428
+ // The document title: the page's own title, then every folder from
429
+ // the page's up to the root, then the site's name, then the page's
430
+ // own slogan, hyphen-delimited - "Hats - Shop - Site - Warm heads".
431
+ // The homepage is the site's front door and shows the site's name and
432
+ // its slogan.
329
433
  function createTitle() {
330
434
  if (isRoot) {
331
- return [site || metadata?.title, metadata?.fm_tagline]
435
+ return [site || metadata?.title, metadata?.fm_slogan]
332
436
  .filter(a => a)
333
437
  .join(" - ")
334
438
  }
335
439
 
336
- const names = settings.flat("fm_name").reverse()
337
- const chain = names.length ? names : (site ? [site] : [])
338
- const parts = [metadata.title, ...chain, metadata.fm_tagline].filter(a => a)
440
+ // Every folder between the page and the root, leaf first, by the
441
+ // label its breadcrumb shows: its index page's. `name:` is the
442
+ // site's, so the root (index 0) is left to `site`.
443
+ const folders = ancestorFolders
444
+ .map((folder, index) => index === 0 ? null : folderCrumb(folder, index).label)
445
+ .slice(1)
446
+ .reverse()
447
+ const parts = [metadata.title, ...folders, site, metadata.fm_slogan].filter(a => a)
339
448
  return parts.length ? parts.join(" - ") : "Website"
340
449
  }
341
450
 
342
451
 
452
+ /**
453
+ * A folder's crumb, deduced from the path at render time rather than
454
+ * read from a `breadcrumbs` setting the folder pass used to write.
455
+ *
456
+ * The folder pass is gone, and this is a better place for it anyway:
457
+ * the lookup is tracked, so renaming a section's index page restales
458
+ * every page whose breadcrumb names it - which the setting never did.
459
+ *
460
+ * A folder with an index page is a link and takes that page's own
461
+ * breadcrumb. A folder without one is just a label: vowel no longer
462
+ * generates a page per folder, so linking there would 404.
463
+ */
464
+ // A secret folder's target segment is a hash, so its readable name has
465
+ // to come from the page's own *source* path - de-salted, because the
466
+ // source keeps the salt and the salt must never reach the page. The
467
+ // page has one ancestor per target segment and one per source segment,
468
+ // aligned by depth, which is what lets the two be zipped.
469
+ const sourceFolderNames = target.source
470
+ ? displayPath(target.source).split(path.sep).slice(0, -1)
471
+ : []
472
+
473
+ function folderCrumb(folderPath, index) {
474
+ if (!folderPath) {
475
+ const home = api.target("index.html")
476
+ return { label: home?.metadata?.breadcrumb || "Home", href: "/" }
477
+ }
478
+
479
+ // A folder's index is `<folder>.html`, from an authored `<folder>.md`
480
+ // beside it or the generated stub.
481
+ const indexTarget = api.target(`${folderPath}.html`)
482
+ // index 0 is the root, so ancestor i is source segment i - 1.
483
+ const sourceName = sourceFolderNames[index - 1]
484
+ const label = indexTarget?.metadata?.breadcrumb
485
+ || labelFromName(sourceName ?? folderPath.split(path.sep).at(-1))
486
+ // folderPath is a stored folder (path.sep); an href is a url.
487
+ return { label, href: indexTarget ? "/" + folderPath.split(path.sep).join("/") : null }
488
+ }
489
+
343
490
  const site = siteName(settings, api)
344
491
  const title = createTitle()
345
492
 
@@ -365,6 +512,9 @@ function writeFile(target, { settings, api, config }) {
365
512
  property: "og:description",
366
513
  content: metadata.description,
367
514
  }),
515
+ // The plain description most search engines read; og: is for
516
+ // link previews.
517
+ ...(metadata.description ? [h('meta', { name: "description", content: metadata.description })] : []),
368
518
  ...treeStyleSheets,
369
519
  ])
370
520
 
@@ -391,20 +541,28 @@ function writeFile(target, { settings, api, config }) {
391
541
  // The page's AT Protocol record, for readers that verify a document
392
542
  // against its site: standard.site's convention, so only for a
393
543
  // site.standard.document, and only for a page that can be one - it
394
- // needs a title and a date (its `publishedAt`). Set by the atproto
395
- // plugin's transform for a page under a lexicon.
396
- const standardDocument = typeof metadata.atUri === "string" && metadata.atUri.split("/")[3] === "site.standard.document"
544
+ // needs a title and a date (its `publishedAt`). Worked out here, from
545
+ // the settings this write reads and so is rebuilt by, rather than
546
+ // stored by a transform that only reruns when the page is re-read.
547
+ const record = pageRecordURI(target, settings)
548
+ if (record.warning) config.log?.("warn", `${target.path}: ${record.warning}`)
549
+ const standardDocument = typeof record.uri === "string" && record.uri.split("/")[3] === "site.standard.document"
397
550
  if (standardDocument && metadata.title && metadata.date) {
398
551
  treeHead.children.push(h("link", {
399
552
  rel: "site.standard.document",
400
- href: metadata.atUri
553
+ href: record.uri
401
554
  }))
402
555
  }
403
556
 
404
- if (metadata.image) {
557
+ // The page's own image, else the site's `fallback_image` (a setting,
558
+ // so a folder can have its own), so a link preview always has one.
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))
562
+ if (ogImage) {
405
563
  treeHead.children.push(h("meta", {
406
564
  property: "og:image",
407
- content: metadata.image
565
+ content: ogImage
408
566
  }))
409
567
  }
410
568
 
@@ -420,15 +578,16 @@ function writeFile(target, { settings, api, config }) {
420
578
  // `icon` is a path, everywhere (Sam, Sept 19) - a URL path from the
421
579
  // root, resolved at read like `logo` (frontmatter.js). The type is
422
580
  // taken from its extension; a browser ignores a wrong one, but a
423
- // right one lets it pick between several.
424
- const icon = settings.lastNonNull("fm_icon")
425
- if (icon) {
426
- treeHead.children.push(h("link", {
427
- href: icon,
428
- rel: "icon",
429
- type: iconType(icon)
430
- }))
431
- }
581
+ // right one lets it pick between several. Without one, the project's
582
+ // own favicon.png, else Vowel's placeholder (plugins/images). A
583
+ // tracked read, so a favicon.png that appears later is linked.
584
+ const icon = imageURL(settings.lastNonNull("fm_icon"), api, imageSizes[0])
585
+ ?? (api.target("favicon.png") ? "/favicon.png" : "/favicon-placeholder.png")
586
+ treeHead.children.push(h("link", {
587
+ href: icon,
588
+ rel: "icon",
589
+ type: iconType(icon)
590
+ }))
432
591
 
433
592
  // The header nav is a partial: the lists for this page's folder chain,
434
593
  // computed once per pass by createPartialStubs and rendered once by
@@ -436,55 +595,18 @@ function writeFile(target, { settings, api, config }) {
436
595
  // beside shop/) takes that folder's chain, which ends in its own
437
596
  // list; otherwise its folder's. The first lookup may miss - votive
438
597
  // tracks the miss, so the page is rebuilt if that folder appears.
439
- const treeNav = navPartial ? withCurrent(navPartial.metadata.hast, metadata.prettyURL) : h("nav")
598
+ const treeNav = navWithChoices(navPartial ? withCurrent(navPartial.metadata.hast, metadata.prettyURL) : null)
440
599
 
441
600
  let treeBreadcrumbs = []
442
601
 
443
- /**
444
- * A folder's crumb, deduced from the path at render time rather than
445
- * read from a `breadcrumbs` setting the folder pass used to write.
446
- *
447
- * The folder pass is gone, and this is a better place for it anyway:
448
- * the lookup is tracked, so renaming a section's index page restales
449
- * every page whose breadcrumb names it - which the setting never did.
450
- *
451
- * A folder with an index page is a link and takes that page's own
452
- * breadcrumb. A folder without one is just a label: vowel no longer
453
- * generates a page per folder, so linking there would 404.
454
- */
455
- // A secret folder's target segment is a hash, so its readable name has
456
- // to come from the page's own *source* path - de-salted, because the
457
- // source keeps the salt and the salt must never reach the page. The
458
- // page has one ancestor per target segment and one per source segment,
459
- // aligned by depth, which is what lets the two be zipped.
460
- const sourceFolderNames = target.source
461
- ? displayPath(target.source).split(path.sep).slice(0, -1)
462
- : []
463
-
464
- function folderCrumb(folderPath, index) {
465
- if (!folderPath) {
466
- const home = api.target("index.html")
467
- return { label: home?.metadata?.breadcrumb || "Home", href: "/" }
468
- }
469
-
470
- // A folder's index is `<folder>.html`, from an authored `<folder>.md`
471
- // beside it or the generated stub.
472
- const indexTarget = api.target(`${folderPath}.html`)
473
- // index 0 is the root, so ancestor i is source segment i - 1.
474
- const sourceName = sourceFolderNames[index - 1]
475
- const label = indexTarget?.metadata?.breadcrumb
476
- || toTitleCase(sourceName ?? folderPath.split(path.sep).at(-1))
477
- // folderPath is a stored folder (path.sep); an href is a url.
478
- return { label, href: indexTarget ? "/" + folderPath.split(path.sep).join("/") : null }
479
- }
480
-
602
+ // Always an <a>, with or without an href: the breadcrumb's CSS styles
603
+ // its links, and a <span> among them broke it. A crumb has no href
604
+ // only when its folder has no index page to go to.
481
605
  treeBreadcrumbs.push(
482
606
  ...ancestorFolders
483
607
  .map(folderCrumb)
484
608
  .filter(crumb => crumb.label)
485
- .map(crumb => crumb.href
486
- ? h('a', { href: crumb.href }, crumb.label)
487
- : h('span', crumb.label))
609
+ .map(crumb => h('a', crumb.href ? { href: crumb.href } : {}, crumb.label))
488
610
  )
489
611
 
490
612
  if (!isRoot) {
@@ -500,8 +622,10 @@ function writeFile(target, { settings, api, config }) {
500
622
  const homeLink = []
501
623
 
502
624
 
503
- const logo = settings.lastNonNull("logo")
504
- 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])
505
629
 
506
630
  const identityElements = []
507
631
 
@@ -509,7 +633,7 @@ function writeFile(target, { settings, api, config }) {
509
633
  identityElements.push(h("img#logo", {
510
634
  src: logo,
511
635
  "aria-label": "Logo",
512
- style: `--logo-url: url("${logo}")`,
636
+ style: `--url-logo: url("${logo}")`,
513
637
  alt: ""
514
638
  }))
515
639
  }
@@ -538,7 +662,7 @@ function writeFile(target, { settings, api, config }) {
538
662
  const treeHeader = h('header', [
539
663
  identity,
540
664
  treeNav,
541
- socialLinksNav(settings)
665
+ socialLinksNav(settings, api)
542
666
  ])
543
667
 
544
668
 
@@ -560,10 +684,6 @@ function writeFile(target, { settings, api, config }) {
560
684
 
561
685
  const treeTableOfContents = treeContentSlugged.children.shift()
562
686
 
563
- const treeMainHead = makeFrontmatter(metadata, api, config)
564
-
565
- treeMainHead.push(treeTableOfContents)
566
-
567
687
  // One walk for everything that renders against other targets, settings
568
688
  // or the url store: relative links, wikilinks, icons, code highlighting,
569
689
  // images, directives, link previews and alerts. See writeRules.js.
@@ -571,6 +691,13 @@ function writeFile(target, { settings, api, config }) {
571
691
  const walked = { target, api, config, settings, makeHeader, makeTable, highlighted: false }
572
692
  writeWalk(abstract, walked)
573
693
 
694
+ // After the walk, so a natural image is already its <picture>.
695
+ const natural = naturalMetadata(abstract)
696
+ const treeMainHead = makeFrontmatter(metadata, api, config, natural)
697
+
698
+ const tableOfContents = navWithChoices(treeTableOfContents)
699
+ if (tableOfContents) treeMainHead.push(tableOfContents)
700
+
574
701
  if (walked.highlighted) {
575
702
  // syntax-highlighting.css is always declared by the styles processor
576
703
  // (a stub); a page only decides whether to link it.
@@ -587,28 +714,33 @@ function writeFile(target, { settings, api, config }) {
587
714
 
588
715
  const treeMain = h('main',
589
716
  {
590
- itemscope: true
717
+ itemscope: true,
718
+ // The page image's dominant colour, for anything in <main> to use.
719
+ style: dominantStyle(metadata.image, api)
591
720
  },
592
721
  [
593
722
  // The title is main's first child: the one piece of data hoisted out
594
723
  // of the content regardless of where the author placed it.
595
- ...(metadata.title ? [h("h1", metadata.title)] : []),
596
- // A tagline is the page's, not the site's: under its title.
597
- ...(metadata.fm_tagline ? [h("p#tagline", metadata.fm_tagline)] : []),
598
- h('nav', {
724
+ ...(metadata.title ? [h("h1", metadata.fm_title ? { className: ["fm_title"] } : {}, metadata.title)] : []),
725
+ // A slogan is the page's, not the site's: under its title. An
726
+ // itemprop, not an id: a listing can hold several, one per card.
727
+ ...(metadata.fm_slogan ? [h("p.fm_slogan", { itemprop: "slogan" }, metadata.fm_slogan)] : []),
728
+ navWithChoices(h('nav', {
599
729
  'aria-label': 'Breadcrumbs'
600
- }, treeBreadcrumbs),
730
+ }, treeBreadcrumbs)),
601
731
  treeMainHead,
602
732
  h('section#content', abstract),
603
733
  treeBacklinks
604
734
  ].filter(Boolean))
605
735
 
606
- // The aside tree is one partial, the same on every page.
736
+ // The aside tree is one partial, the same on every page. Without a
737
+ // nav worth rendering there is no aside at all.
607
738
  const asidePartial = api.target(ASIDE_PARTIAL)
608
- const treeAside = h("aside", asidePartial ? asidePartial.metadata.hast : h("nav", h("ul")))
739
+ const asideNav = navWithChoices(asidePartial ? asidePartial.metadata.hast : null)
740
+ const treeAside = asideNav ? h("aside", asideNav) : null
609
741
 
610
742
  const treeFooter = h('footer', [
611
- socialLinksNav(settings),
743
+ socialLinksNav(settings, api),
612
744
  h('section#copyright', `© ${new Date().getFullYear()}`),
613
745
  h('section#shoutout', [
614
746
  "Made with ",
@@ -618,14 +750,7 @@ function writeFile(target, { settings, api, config }) {
618
750
  ])
619
751
  ])
620
752
 
621
- const pageClass = target.metadata.prettyURL
622
- .split("/")
623
- .filter(a => a)
624
- .map(a => a.replace("_", ""))
625
- .join("_")
626
- || "home"
627
-
628
- const treeBody = h(`body.${pageClass}`, [
753
+ const treeBody = h("body", { className: pageClasses(target.metadata.prettyURL, target.source) }, [
629
754
  treeHeader,
630
755
  treeMain,
631
756
  treeAside,
@@ -682,6 +807,7 @@ function makeTable(classes, properties, targets, api) {
682
807
 
683
808
  const cell = (target, name) => {
684
809
  const value = valueOf(target.metadata, name)
810
+ if (name === "title" && isPageWithoutFile(target)) return h("td", value ?? "")
685
811
  if (name === "title") return h("td", h("a", { href: target.metadata.prettyURL }, value ?? target.metadata.prettyURL))
686
812
  if (value === undefined || value === null) return h("td")
687
813
  if (name === "image") return h("td", createDynamicImage(String(value), api, "") ?? String(value))
@@ -690,10 +816,12 @@ function makeTable(classes, properties, targets, api) {
690
816
  return h("td", String(value))
691
817
  }
692
818
 
693
- return h("table", { class: classes }, [
819
+ // In a <figure>, like a markdown table (markdown/index.js). The
820
+ // directive's parameters stay on the <table>, where the editor reads them.
821
+ return h("figure", h("table", { class: classes }, [
694
822
  h("thead", h("tr", properties.map(name => h("th", name)))),
695
823
  h("tbody", targets.map(target => h("tr", properties.map(name => cell(target, name)))))
696
- ])
824
+ ]))
697
825
  }
698
826
 
699
827
  /**