vowel 0.7.0-beta.0 → 0.7.1

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 (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +41 -239
  3. package/bin.js +73 -31
  4. package/cli.js +32 -0
  5. package/config.js +14 -0
  6. package/extractDate.js +2 -2
  7. package/index.js +6 -1
  8. package/package.json +9 -34
  9. package/pathDate.js +69 -0
  10. package/plugins/assets/index.js +1 -1
  11. package/plugins/atproto/body.js +413 -0
  12. package/plugins/atproto/index.js +10 -44
  13. package/plugins/atproto/lexicons.js +179 -0
  14. package/plugins/atproto/mapper.js +399 -0
  15. package/plugins/atproto/publish.js +195 -105
  16. package/plugins/atproto/record.js +57 -68
  17. package/plugins/deploy-cloudflare/index.js +4 -1
  18. package/plugins/export/index.js +131 -0
  19. package/plugins/html/bundle/index.js +57 -23
  20. package/plugins/html/editor/Editor.svelte +9 -5
  21. package/plugins/html/editor/create-session.js +3 -1
  22. package/plugins/html/editor/directives.js +12 -0
  23. package/plugins/html/editor/frontmatter.js +42 -11
  24. package/plugins/html/editor/ingest.js +19 -8
  25. package/plugins/html/editor/serialize.js +8 -4
  26. package/plugins/html/image.js +11 -5
  27. package/plugins/html/index.js +217 -100
  28. package/plugins/html/partials.js +14 -8
  29. package/plugins/html/socialLinks.js +5 -8
  30. package/plugins/html/writeRules.js +197 -37
  31. package/plugins/images/favicon.png +0 -0
  32. package/plugins/images/index.js +41 -3
  33. package/plugins/markdown/colorScheme.js +70 -14
  34. package/plugins/markdown/frontmatter.js +32 -6
  35. package/plugins/markdown/index.js +105 -20
  36. package/plugins/markdown/metadata.js +74 -22
  37. package/plugins/markdown/typography.js +11 -11
  38. package/plugins/secret-pages/index.js +75 -0
  39. package/plugins/styles/index.js +4 -4
  40. package/plugins/xml/entryContent.js +22 -11
  41. package/plugins/xml/index.js +19 -11
  42. package/stylesheets/DefaultStyles.css +198 -111
  43. package/stylesheets/ResetStyles.css +2 -1
  44. package/stylesheets/TypographyStyles.css +153 -74
  45. package/utils.js +74 -1
@@ -13,7 +13,7 @@ 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, createImagePaths, imageSizes, imageExts } 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'
@@ -22,6 +22,8 @@ import toc from "@jsdevtools/rehype-toc"
22
22
  import slug from "rehype-slug"
23
23
  import createDynamicImage 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,7 +92,7 @@ function makeHeader(metadata, url, api, config) {
89
92
  treeMainHead.push(makeTime(date))
90
93
  }
91
94
 
92
- if (metadata.image || (metadata.first_image && url)) {
95
+ if (metadata.image || metadata.first_image) {
93
96
  const metaImage = metadata.image || metadata.first_image
94
97
 
95
98
  treeMainHead.push(
@@ -106,9 +109,36 @@ function makeHeader(metadata, url, api, config) {
106
109
  )
107
110
  )
108
111
 
109
- if (url) return h('a', { href: url }, h('article', treeMainHead))
112
+ // A card carries its page's classes, as the page's <body> does, and is
113
+ // an item of its own: the itemprops inside it (date, description,
114
+ // image) describe that page, not the page it's listed on. A page with
115
+ // 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)
117
+ return url ? h('a', { href: url }, article) : article
118
+ }
110
119
 
111
- return treeMainHead
120
+ /**
121
+ * How many links a hast tree holds.
122
+ * @param {any} node
123
+ * @returns {number}
124
+ */
125
+ function linkCount(node) {
126
+ const own = node.tagName === "a" ? 1 : 0
127
+ const children = node.children ?? []
128
+ return children.reduce((sum, child) => sum + linkCount(child), own)
129
+ }
130
+
131
+ /**
132
+ * The nav, or null when it has fewer than two links: a nav is only
133
+ * rendered when there is somewhere else to go (per Sam). A one-entry
134
+ * menu, a trail that is only the home crumb, a table of contents with
135
+ * one heading are left out of the page, rather than hidden with CSS.
136
+ * Social links are exempt: one link is still a `rel="me"` verification.
137
+ * @param {any} nav - a hast element, or null
138
+ */
139
+ function navWithChoices(nav) {
140
+ if (!nav) return null
141
+ return linkCount(nav) > 1 ? nav : null
112
142
  }
113
143
 
114
144
  /**
@@ -187,35 +217,81 @@ function makeValue(value) {
187
217
  * @param {import("votive").PluginAPI} api
188
218
  * @param {Votive.VotiveConfig} config
189
219
  */
190
- function makeFrontmatter(metadata, api, config) {
191
- const handled = ["title", "tagline", "date", "image", "description"]
220
+ /**
221
+ * An element with one more class. Frontmatter properties carry their key
222
+ * this way (`fm_date`, `fm_description`), which is how the editor tells
223
+ * them from the same properties written in the text: those have the
224
+ * itemprop and no class.
225
+ * @param {any} node - a hast element, or nothing
226
+ * @param {string} name
227
+ */
228
+ function withClass(node, name) {
229
+ if (!node) return node
230
+ const className = [...(node.properties?.className ?? []), name]
231
+ return { ...node, properties: { ...node.properties, className } }
232
+ }
233
+
234
+ /**
235
+ * Natural metadata, lifted out of the content: the date line, image and
236
+ * description the author wrote in the text, which the read marked
237
+ * (`data-metadata`, metadata.js) and the write walk has already turned
238
+ * into a <time>, a <picture> and a paragraph. Each becomes a direct
239
+ * child of <main>, like its frontmatter equivalent but without the
240
+ * `fm_` class; the rule that closed a description renders as nothing.
241
+ * The source is untouched - only where the output puts them.
242
+ * @param {any} tree - the walked content; the marked blocks are removed from it
243
+ * @returns {any[]}
244
+ */
245
+ function naturalMetadata(tree) {
246
+ const isMarked = (node) => Boolean(node.properties?.dataMetadata)
247
+ const marked = tree.children.filter(isMarked)
248
+ tree.children = tree.children.filter(node => !isMarked(node))
249
+
250
+ return marked.flatMap(node => {
251
+ const { dataMetadata: kind, ...properties } = node.properties
252
+ if (kind === "end") return []
253
+ if (kind === "description") return [{ ...node, properties }]
254
+ // A date or an image paragraph only wrapped the one element that is
255
+ // the metadata: the <time>, or the <picture> (an <img> until the
256
+ // image has been read).
257
+ const element = node.children.find(child => child.type === "element")
258
+ if (!element) return []
259
+ return [{ ...element, properties: { ...element.properties, itemprop: kind } }]
260
+ })
261
+ }
262
+
263
+ function makeFrontmatter(metadata, api, config, natural = []) {
264
+ const handled = ["title", "slogan", "date", "image", "description"]
192
265
 
193
266
  // hiddenProperties never render: the editor takes frontmatter from
194
267
  // the source file, so nothing is lost by leaving a key off the page.
268
+ // `status` is shown only when it's the author's own word ("evergreen");
269
+ // vowel's four (published, hidden, …) are instructions, not content.
270
+ const ownStatus = !STATUSES.includes(metadata.fm_status)
195
271
  const properties = (metadata.frontmatter_keys || []).filter(key => {
196
- return !handled.includes(key) && !hiddenProperties.includes(key)
272
+ return !handled.includes(key) && !hiddenProperties.includes(key) && (key !== "status" || ownStatus)
197
273
  })
198
274
 
199
275
  const known = []
200
276
 
201
277
  if (metadata.fm_date) {
202
- known.push(makeTime(new Date(metadata.fm_date)))
278
+ known.push(withClass(makeTime(new Date(metadata.fm_date)), "fm_date"))
203
279
  }
204
280
 
205
281
  if (metadata.fm_image) {
206
- known.push(createDynamicImage(metadata.fm_image, api, null, true))
282
+ known.push(withClass(createDynamicImage(metadata.fm_image, api, null, true), "fm_image"))
207
283
  }
208
284
 
209
285
  if (metadata.fm_description) {
210
- known.push(h("p", { itemprop: "description" }, metadata.fm_description))
286
+ known.push(h("p.fm_description", { itemprop: "description" }, metadata.fm_description))
211
287
  }
212
288
 
213
289
  const generic = properties.map(key => {
214
290
  const name = reservedProperties.includes(key) ? key : "fm_" + key
215
- return h("dl", [h("dt", key), h("dd", makeValue(metadata[name]))])
291
+ return h(`dl.fm_${key}`, [h("dt", key), h("dd", makeValue(metadata[name]))])
216
292
  })
217
293
 
218
- return [...known, ...generic]
294
+ return [...known, ...natural, ...generic]
219
295
  }
220
296
 
221
297
  /** @type {Votive.ProcessorWrite} */
@@ -231,6 +307,9 @@ function writeFile(target, { settings, api, config }) {
231
307
  // the moment it does not. No self-deleting targets.
232
308
 
233
309
  const { metadata, ...rest } = target
310
+ // A deleted page (`status: deleted`) is kept only for atproto-publish;
311
+ // there is nothing to render.
312
+ if (metadata.fm_status === "deleted") return undefined
234
313
  const abstract = metadata.hastAbstract
235
314
 
236
315
  /** @param {string} filePath */
@@ -286,11 +365,26 @@ function writeFile(target, { settings, api, config }) {
286
365
  // setting); the root's decides which theme gets the plain file names.
287
366
  const themeSheets = themeStylesheets(settings.lastNonNull("theme"), settings.raw("theme")?.[0]?.at(-1))
288
367
 
368
+ // The sheets a `styles:` names: each settings.md's along the chain,
369
+ // root first, then the page's own frontmatter. A path is a URL path
370
+ // from the root (frontmatter.js resolves it); a URL - a web font's
371
+ // stylesheet - is linked as written. A path to no file is skipped,
372
+ // with a warning: the <link> would 404. api.target() tracks it, so
373
+ // the file appearing later relinks the page.
374
+ const listed = [...new Set([...(settings.flat("fm_styles") ?? []), ...(metadata.fm_styles ?? [])])]
375
+ const external = listed.filter(entry => /^[a-z][a-z0-9+.-]*:|^\/\//i.test(entry))
376
+ const named = listed
377
+ .filter(entry => !external.includes(entry))
378
+ .map(entry => ({ entry, sheet: entry.replace(/^\/+/, "").split("/").join(path.sep) }))
379
+ const missing = named.filter(({ sheet }) => !api.target(sheet))
380
+ missing.forEach(({ entry }) => config.log?.("warn", `${target.path}: styles names ${entry}, which isn't in the project`))
381
+ const namedSheets = named.filter(({ sheet }) => !missing.some(gone => gone.sheet === sheet)).map(({ sheet }) => sheet)
382
+
289
383
  // A stub stylesheet has a source path like any other source, so the
290
384
  // `sheet.source` test alone no longer separates vowel's sheets from the
291
385
  // project's - exclude them by name instead, or every theme sheet would
292
386
  // be linked twice.
293
- const sheets = [...themeSheets, ...projectSheets.map(sheet => sheet.path).filter(sheet => !themeSheets.includes(sheet))]
387
+ const sheets = [...new Set([...themeSheets, ...projectSheets.map(sheet => sheet.path), ...namedSheets])]
294
388
  const projectHash = new Map(projectSheets.map(sheet => [sheet.path, sheet.hash]))
295
389
 
296
390
  sheets.forEach(sheet => {
@@ -318,25 +412,70 @@ function writeFile(target, { settings, api, config }) {
318
412
  })
319
413
  )
320
414
  })
415
+ external.forEach(href => treeStyleSheets.push(h('link', { rel: "stylesheet", href })))
321
416
 
322
- // The document title: the page's own title, then every `name:` from
323
- // the page's folder up to the root, then the page's own tagline,
324
- // hyphen-delimited - "Hats - Shop - Site - Warm heads". The homepage is
325
- // the site's front door and shows the site's name and its tagline.
417
+ // The document title: the page's own title, then every folder from
418
+ // the page's up to the root, then the site's name, then the page's
419
+ // own slogan, hyphen-delimited - "Hats - Shop - Site - Warm heads".
420
+ // The homepage is the site's front door and shows the site's name and
421
+ // its slogan.
326
422
  function createTitle() {
327
423
  if (isRoot) {
328
- return [site || metadata?.title, metadata?.fm_tagline]
424
+ return [site || metadata?.title, metadata?.fm_slogan]
329
425
  .filter(a => a)
330
426
  .join(" - ")
331
427
  }
332
428
 
333
- const names = settings.flat("fm_name").reverse()
334
- const chain = names.length ? names : (site ? [site] : [])
335
- const parts = [metadata.title, ...chain, metadata.fm_tagline].filter(a => a)
429
+ // Every folder between the page and the root, leaf first, by the
430
+ // label its breadcrumb shows: its index page's. `name:` is the
431
+ // site's, so the root (index 0) is left to `site`.
432
+ const folders = ancestorFolders
433
+ .map((folder, index) => index === 0 ? null : folderCrumb(folder, index).label)
434
+ .slice(1)
435
+ .reverse()
436
+ const parts = [metadata.title, ...folders, site, metadata.fm_slogan].filter(a => a)
336
437
  return parts.length ? parts.join(" - ") : "Website"
337
438
  }
338
439
 
339
440
 
441
+ /**
442
+ * A folder's crumb, deduced from the path at render time rather than
443
+ * read from a `breadcrumbs` setting the folder pass used to write.
444
+ *
445
+ * The folder pass is gone, and this is a better place for it anyway:
446
+ * the lookup is tracked, so renaming a section's index page restales
447
+ * every page whose breadcrumb names it - which the setting never did.
448
+ *
449
+ * A folder with an index page is a link and takes that page's own
450
+ * breadcrumb. A folder without one is just a label: vowel no longer
451
+ * generates a page per folder, so linking there would 404.
452
+ */
453
+ // A secret folder's target segment is a hash, so its readable name has
454
+ // to come from the page's own *source* path - de-salted, because the
455
+ // source keeps the salt and the salt must never reach the page. The
456
+ // page has one ancestor per target segment and one per source segment,
457
+ // aligned by depth, which is what lets the two be zipped.
458
+ const sourceFolderNames = target.source
459
+ ? displayPath(target.source).split(path.sep).slice(0, -1)
460
+ : []
461
+
462
+ function folderCrumb(folderPath, index) {
463
+ if (!folderPath) {
464
+ const home = api.target("index.html")
465
+ return { label: home?.metadata?.breadcrumb || "Home", href: "/" }
466
+ }
467
+
468
+ // A folder's index is `<folder>.html`, from an authored `<folder>.md`
469
+ // beside it or the generated stub.
470
+ const indexTarget = api.target(`${folderPath}.html`)
471
+ // index 0 is the root, so ancestor i is source segment i - 1.
472
+ const sourceName = sourceFolderNames[index - 1]
473
+ const label = indexTarget?.metadata?.breadcrumb
474
+ || labelFromName(sourceName ?? folderPath.split(path.sep).at(-1))
475
+ // folderPath is a stored folder (path.sep); an href is a url.
476
+ return { label, href: indexTarget ? "/" + folderPath.split(path.sep).join("/") : null }
477
+ }
478
+
340
479
  const site = siteName(settings, api)
341
480
  const title = createTitle()
342
481
 
@@ -362,6 +501,9 @@ function writeFile(target, { settings, api, config }) {
362
501
  property: "og:description",
363
502
  content: metadata.description,
364
503
  }),
504
+ // The plain description most search engines read; og: is for
505
+ // link previews.
506
+ ...(metadata.description ? [h('meta', { name: "description", content: metadata.description })] : []),
365
507
  ...treeStyleSheets,
366
508
  ])
367
509
 
@@ -386,19 +528,28 @@ function writeFile(target, { settings, api, config }) {
386
528
  }
387
529
 
388
530
  // The page's AT Protocol record, for readers that verify a document
389
- // against its site (site.standard). Set by the atproto plugin's
390
- // transform only when the page has a record to point at.
391
- if (metadata.atUri) {
531
+ // against its site: standard.site's convention, so only for a
532
+ // site.standard.document, and only for a page that can be one - it
533
+ // needs a title and a date (its `publishedAt`). Worked out here, from
534
+ // the settings this write reads and so is rebuilt by, rather than
535
+ // stored by a transform that only reruns when the page is re-read.
536
+ const record = pageRecordURI(target, settings)
537
+ if (record.warning) config.log?.("warn", `${target.path}: ${record.warning}`)
538
+ const standardDocument = typeof record.uri === "string" && record.uri.split("/")[3] === "site.standard.document"
539
+ if (standardDocument && metadata.title && metadata.date) {
392
540
  treeHead.children.push(h("link", {
393
541
  rel: "site.standard.document",
394
- href: metadata.atUri
542
+ href: record.uri
395
543
  }))
396
544
  }
397
545
 
398
- if (metadata.image) {
546
+ // The page's own image, else the site's `fallback_image` (a setting,
547
+ // so a folder can have its own), so a link preview always has one.
548
+ const ogImage = metadata.image || settings.lastNonNull("fm_fallback_image")
549
+ if (ogImage) {
399
550
  treeHead.children.push(h("meta", {
400
551
  property: "og:image",
401
- content: metadata.image
552
+ content: ogImage
402
553
  }))
403
554
  }
404
555
 
@@ -414,15 +565,16 @@ function writeFile(target, { settings, api, config }) {
414
565
  // `icon` is a path, everywhere (Sam, Sept 19) - a URL path from the
415
566
  // root, resolved at read like `logo` (frontmatter.js). The type is
416
567
  // taken from its extension; a browser ignores a wrong one, but a
417
- // right one lets it pick between several.
568
+ // right one lets it pick between several. Without one, the project's
569
+ // own favicon.png, else Vowel's placeholder (plugins/images). A
570
+ // tracked read, so a favicon.png that appears later is linked.
418
571
  const icon = settings.lastNonNull("fm_icon")
419
- if (icon) {
420
- treeHead.children.push(h("link", {
421
- href: icon,
422
- rel: "icon",
423
- type: iconType(icon)
424
- }))
425
- }
572
+ ?? (api.target("favicon.png") ? "/favicon.png" : "/favicon-placeholder.png")
573
+ treeHead.children.push(h("link", {
574
+ href: icon,
575
+ rel: "icon",
576
+ type: iconType(icon)
577
+ }))
426
578
 
427
579
  // The header nav is a partial: the lists for this page's folder chain,
428
580
  // computed once per pass by createPartialStubs and rendered once by
@@ -430,55 +582,18 @@ function writeFile(target, { settings, api, config }) {
430
582
  // beside shop/) takes that folder's chain, which ends in its own
431
583
  // list; otherwise its folder's. The first lookup may miss - votive
432
584
  // tracks the miss, so the page is rebuilt if that folder appears.
433
- const treeNav = navPartial ? withCurrent(navPartial.metadata.hast, metadata.prettyURL) : h("nav")
585
+ const treeNav = navWithChoices(navPartial ? withCurrent(navPartial.metadata.hast, metadata.prettyURL) : null)
434
586
 
435
587
  let treeBreadcrumbs = []
436
588
 
437
- /**
438
- * A folder's crumb, deduced from the path at render time rather than
439
- * read from a `breadcrumbs` setting the folder pass used to write.
440
- *
441
- * The folder pass is gone, and this is a better place for it anyway:
442
- * the lookup is tracked, so renaming a section's index page restales
443
- * every page whose breadcrumb names it - which the setting never did.
444
- *
445
- * A folder with an index page is a link and takes that page's own
446
- * breadcrumb. A folder without one is just a label: vowel no longer
447
- * generates a page per folder, so linking there would 404.
448
- */
449
- // A secret folder's target segment is a hash, so its readable name has
450
- // to come from the page's own *source* path - de-salted, because the
451
- // source keeps the salt and the salt must never reach the page. The
452
- // page has one ancestor per target segment and one per source segment,
453
- // aligned by depth, which is what lets the two be zipped.
454
- const sourceFolderNames = target.source
455
- ? displayPath(target.source).split(path.sep).slice(0, -1)
456
- : []
457
-
458
- function folderCrumb(folderPath, index) {
459
- if (!folderPath) {
460
- const home = api.target("index.html")
461
- return { label: home?.metadata?.breadcrumb || "Home", href: "/" }
462
- }
463
-
464
- // A folder's index is `<folder>.html`, from an authored `<folder>.md`
465
- // beside it or the generated stub.
466
- const indexTarget = api.target(`${folderPath}.html`)
467
- // index 0 is the root, so ancestor i is source segment i - 1.
468
- const sourceName = sourceFolderNames[index - 1]
469
- const label = indexTarget?.metadata?.breadcrumb
470
- || toTitleCase(sourceName ?? folderPath.split(path.sep).at(-1))
471
- // folderPath is a stored folder (path.sep); an href is a url.
472
- return { label, href: indexTarget ? "/" + folderPath.split(path.sep).join("/") : null }
473
- }
474
-
589
+ // Always an <a>, with or without an href: the breadcrumb's CSS styles
590
+ // its links, and a <span> among them broke it. A crumb has no href
591
+ // only when its folder has no index page to go to.
475
592
  treeBreadcrumbs.push(
476
593
  ...ancestorFolders
477
594
  .map(folderCrumb)
478
595
  .filter(crumb => crumb.label)
479
- .map(crumb => crumb.href
480
- ? h('a', { href: crumb.href }, crumb.label)
481
- : h('span', crumb.label))
596
+ .map(crumb => h('a', crumb.href ? { href: crumb.href } : {}, crumb.label))
482
597
  )
483
598
 
484
599
  if (!isRoot) {
@@ -503,7 +618,7 @@ function writeFile(target, { settings, api, config }) {
503
618
  identityElements.push(h("img#logo", {
504
619
  src: logo,
505
620
  "aria-label": "Logo",
506
- style: `--logo-url: url("${logo}")`,
621
+ style: `--url-logo: url("${logo}")`,
507
622
  alt: ""
508
623
  }))
509
624
  }
@@ -554,10 +669,6 @@ function writeFile(target, { settings, api, config }) {
554
669
 
555
670
  const treeTableOfContents = treeContentSlugged.children.shift()
556
671
 
557
- const treeMainHead = makeFrontmatter(metadata, api, config)
558
-
559
- treeMainHead.push(treeTableOfContents)
560
-
561
672
  // One walk for everything that renders against other targets, settings
562
673
  // or the url store: relative links, wikilinks, icons, code highlighting,
563
674
  // images, directives, link previews and alerts. See writeRules.js.
@@ -565,6 +676,13 @@ function writeFile(target, { settings, api, config }) {
565
676
  const walked = { target, api, config, settings, makeHeader, makeTable, highlighted: false }
566
677
  writeWalk(abstract, walked)
567
678
 
679
+ // After the walk, so a natural image is already its <picture>.
680
+ const natural = naturalMetadata(abstract)
681
+ const treeMainHead = makeFrontmatter(metadata, api, config, natural)
682
+
683
+ const tableOfContents = navWithChoices(treeTableOfContents)
684
+ if (tableOfContents) treeMainHead.push(tableOfContents)
685
+
568
686
  if (walked.highlighted) {
569
687
  // syntax-highlighting.css is always declared by the styles processor
570
688
  // (a stub); a page only decides whether to link it.
@@ -586,20 +704,23 @@ function writeFile(target, { settings, api, config }) {
586
704
  [
587
705
  // The title is main's first child: the one piece of data hoisted out
588
706
  // of the content regardless of where the author placed it.
589
- ...(metadata.title ? [h("h1", metadata.title)] : []),
590
- // A tagline is the page's, not the site's: under its title.
591
- ...(metadata.fm_tagline ? [h("p#tagline", metadata.fm_tagline)] : []),
592
- h('nav', {
707
+ ...(metadata.title ? [h("h1", metadata.fm_title ? { className: ["fm_title"] } : {}, metadata.title)] : []),
708
+ // A slogan is the page's, not the site's: under its title. An
709
+ // itemprop, not an id: a listing can hold several, one per card.
710
+ ...(metadata.fm_slogan ? [h("p.fm_slogan", { itemprop: "slogan" }, metadata.fm_slogan)] : []),
711
+ navWithChoices(h('nav', {
593
712
  'aria-label': 'Breadcrumbs'
594
- }, treeBreadcrumbs),
713
+ }, treeBreadcrumbs)),
595
714
  treeMainHead,
596
715
  h('section#content', abstract),
597
716
  treeBacklinks
598
717
  ].filter(Boolean))
599
718
 
600
- // The aside tree is one partial, the same on every page.
719
+ // The aside tree is one partial, the same on every page. Without a
720
+ // nav worth rendering there is no aside at all.
601
721
  const asidePartial = api.target(ASIDE_PARTIAL)
602
- const treeAside = h("aside", asidePartial ? asidePartial.metadata.hast : h("nav", h("ul")))
722
+ const asideNav = navWithChoices(asidePartial ? asidePartial.metadata.hast : null)
723
+ const treeAside = asideNav ? h("aside", asideNav) : null
603
724
 
604
725
  const treeFooter = h('footer', [
605
726
  socialLinksNav(settings),
@@ -612,14 +733,7 @@ function writeFile(target, { settings, api, config }) {
612
733
  ])
613
734
  ])
614
735
 
615
- const pageClass = target.metadata.prettyURL
616
- .split("/")
617
- .filter(a => a)
618
- .map(a => a.replace("_", ""))
619
- .join("_")
620
- || "home"
621
-
622
- const treeBody = h(`body.${pageClass}`, [
736
+ const treeBody = h("body", { className: pageClasses(target.metadata.prettyURL, target.source) }, [
623
737
  treeHeader,
624
738
  treeMain,
625
739
  treeAside,
@@ -676,6 +790,7 @@ function makeTable(classes, properties, targets, api) {
676
790
 
677
791
  const cell = (target, name) => {
678
792
  const value = valueOf(target.metadata, name)
793
+ if (name === "title" && isPageWithoutFile(target)) return h("td", value ?? "")
679
794
  if (name === "title") return h("td", h("a", { href: target.metadata.prettyURL }, value ?? target.metadata.prettyURL))
680
795
  if (value === undefined || value === null) return h("td")
681
796
  if (name === "image") return h("td", createDynamicImage(String(value), api, "") ?? String(value))
@@ -684,10 +799,12 @@ function makeTable(classes, properties, targets, api) {
684
799
  return h("td", String(value))
685
800
  }
686
801
 
687
- return h("table", { class: classes }, [
802
+ // In a <figure>, like a markdown table (markdown/index.js). The
803
+ // directive's parameters stay on the <table>, where the editor reads them.
804
+ return h("figure", h("table", { class: classes }, [
688
805
  h("thead", h("tr", properties.map(name => h("th", name)))),
689
806
  h("tbody", targets.map(target => h("tr", properties.map(name => cell(target, name)))))
690
- ])
807
+ ]))
691
808
  }
692
809
 
693
810
  /**
@@ -3,6 +3,9 @@ import { h } from "hastscript"
3
3
  import { isListedPage } from "../../utils.js"
4
4
  import { isVowelStylesheet } from "../styles/theme.js"
5
5
 
6
+ /** The one stylesheet name linked without being asked for. */
7
+ const PROJECT_SHEET = "styles.css"
8
+
6
9
  /**
7
10
  * Partials: rendered fragments shared by many pages, built once per
8
11
  * pass instead of once per page.
@@ -20,8 +23,8 @@ import { isVowelStylesheet } from "../styles/theme.js"
20
23
  * Paths: `partials/nav.partial` is the header nav for pages at the
21
24
  * root, `partials/nav/<folder>.partial` for pages in a folder - the
22
25
  * lists for every level from the root down to that folder, and the
23
- * project's own stylesheets along that chain (a `.css` the author wrote
24
- * in any of those folders, with its content hash for the cache-buster).
26
+ * project's own stylesheets along that chain (a `styles.css` in any of
27
+ * those folders, with its content hash for the cache-buster).
25
28
  * Both are "what this page's folder chain contributes", so they share a
26
29
  * stub; the stylesheet half used to be one listing per ancestor per
27
30
  * page, whose folder-membership edge restaled every page whenever any
@@ -60,10 +63,9 @@ function inHeaderNav(page) {
60
63
  && page.extension === ".html"
61
64
  && !page.metadata.date
62
65
  && page.path !== "index.html"
63
- && page.path !== "404.html"
64
66
  && page.path !== "tags.html"
65
67
  && page.dir !== "tags"
66
- && isOn(page.metadata.local_menu_item)
68
+ && isOn(page.metadata.local_nav_item)
67
69
  && isOn(page.metadata.html_file)
68
70
  }
69
71
 
@@ -118,8 +120,8 @@ function asideTree(pages) {
118
120
  }
119
121
 
120
122
  const top = undated
121
- .filter(page => page.dir === "" && page.path !== "index.html" && page.path !== "404.html" && page.path !== "tags.html"
122
- && isOn(page.metadata.global_menu_item) && isOn(page.metadata.html_file))
123
+ .filter(page => page.dir === "" && page.path !== "index.html" && page.path !== "tags.html"
124
+ && isOn(page.metadata.global_nav_item) && isOn(page.metadata.html_file))
123
125
  .map(node)
124
126
 
125
127
  return [...(home ? [{ url: home.path, label: home.metadata.breadcrumb }] : []), ...top]
@@ -205,11 +207,15 @@ function createPartialStubs(api) {
205
207
  const folders = new Set([""])
206
208
  for (const page of pages) for (const folder of chain(page.dir)) folders.add(folder)
207
209
 
208
- // The project's stylesheets by folder: a .css the author wrote (has a
209
- // source, is not one vowel generates), with the hash its read recorded.
210
+ // The project's stylesheets by folder: a `styles.css` the author wrote
211
+ // (has a source, is not one vowel generates), with the hash its read
212
+ // recorded. Only that name: any other .css is published but linked only
213
+ // where a `styles:` names it (the html plugin) - a sheet dropped into a
214
+ // folder used to restyle every page below it.
210
215
  const sheets = new Map()
211
216
  for (const sheet of all) {
212
217
  if (!sheet.source || sheet.extension !== ".css" || isVowelStylesheet(sheet.path)) continue
218
+ if (path.basename(sheet.path) !== PROJECT_SHEET) continue
213
219
  if (!sheets.has(sheet.dir)) sheets.set(sheet.dir, [])
214
220
  sheets.get(sheet.dir).push({ path: sheet.path, hash: sheet.metadata?.hash ?? "" })
215
221
  }
@@ -22,9 +22,9 @@ import { h } from "hastscript"
22
22
  *
23
23
  * No built-in icons, by decision: an author who wants one drops the SVG
24
24
  * in the project and names it. With an icon the label is the image's
25
- * alt text; without one it is the link text. The anchor carries the
26
- * icon as `--icon-url` too, the same arrangement as `a#logo`, so a
27
- * monochrome SVG can be masked in `currentColor` by the stylesheet.
25
+ * alt text; without one it is the link text. The icon's <img> carries
26
+ * its own url as `--url-icon`, the way `img#logo` carries `--url-logo`,
27
+ * so the stylesheet can mask it in `currentColor`.
28
28
  * `rel="me"` is the identity-link convention (IndieAuth, Mastodon
29
29
  * verification) and costs nothing.
30
30
  */
@@ -55,11 +55,8 @@ function socialLinksNav(settings) {
55
55
  const text = label ?? url
56
56
  // A URL path from the root, resolved at read (frontmatter.js).
57
57
  const iconURL = icon ? String(icon) : null
58
- return h("li", h("a", {
59
- href: url,
60
- rel: "me",
61
- style: iconURL && `--icon-url: url("${iconURL}")`
62
- }, iconURL ? h("img", { src: iconURL, alt: text }) : text))
58
+ const image = iconURL && h("img", { src: iconURL, alt: text, style: `--url-icon: url("${iconURL}")` })
59
+ return h("li", h("a", { href: url, rel: "me" }, image || text))
63
60
  }))
64
61
  )
65
62
  }