vowel 0.7.1-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 (38) 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 +12 -0
  6. package/extractDate.js +1 -1
  7. package/package.json +8 -33
  8. package/pathDate.js +69 -0
  9. package/plugins/assets/index.js +1 -1
  10. package/plugins/atproto/index.js +5 -45
  11. package/plugins/atproto/publish.js +34 -9
  12. package/plugins/atproto/record.js +47 -4
  13. package/plugins/deploy-cloudflare/index.js +4 -1
  14. package/plugins/export/index.js +131 -0
  15. package/plugins/html/bundle/index.js +57 -23
  16. package/plugins/html/editor/Editor.svelte +9 -5
  17. package/plugins/html/editor/create-session.js +3 -1
  18. package/plugins/html/editor/directives.js +12 -0
  19. package/plugins/html/editor/frontmatter.js +42 -11
  20. package/plugins/html/editor/ingest.js +19 -8
  21. package/plugins/html/editor/serialize.js +8 -4
  22. package/plugins/html/image.js +11 -5
  23. package/plugins/html/index.js +211 -100
  24. package/plugins/html/partials.js +14 -8
  25. package/plugins/html/socialLinks.js +5 -8
  26. package/plugins/html/writeRules.js +193 -35
  27. package/plugins/images/favicon.png +0 -0
  28. package/plugins/images/index.js +41 -3
  29. package/plugins/markdown/colorScheme.js +70 -14
  30. package/plugins/markdown/frontmatter.js +18 -8
  31. package/plugins/markdown/index.js +90 -23
  32. package/plugins/markdown/metadata.js +74 -22
  33. package/plugins/markdown/typography.js +11 -11
  34. package/plugins/styles/index.js +4 -4
  35. package/plugins/xml/index.js +17 -10
  36. package/stylesheets/DefaultStyles.css +158 -105
  37. package/stylesheets/TypographyStyles.css +143 -76
  38. 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} */
@@ -289,11 +365,26 @@ function writeFile(target, { settings, api, config }) {
289
365
  // setting); the root's decides which theme gets the plain file names.
290
366
  const themeSheets = themeStylesheets(settings.lastNonNull("theme"), settings.raw("theme")?.[0]?.at(-1))
291
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
+
292
383
  // A stub stylesheet has a source path like any other source, so the
293
384
  // `sheet.source` test alone no longer separates vowel's sheets from the
294
385
  // project's - exclude them by name instead, or every theme sheet would
295
386
  // be linked twice.
296
- 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])]
297
388
  const projectHash = new Map(projectSheets.map(sheet => [sheet.path, sheet.hash]))
298
389
 
299
390
  sheets.forEach(sheet => {
@@ -321,25 +412,70 @@ function writeFile(target, { settings, api, config }) {
321
412
  })
322
413
  )
323
414
  })
415
+ external.forEach(href => treeStyleSheets.push(h('link', { rel: "stylesheet", href })))
324
416
 
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.
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.
329
422
  function createTitle() {
330
423
  if (isRoot) {
331
- return [site || metadata?.title, metadata?.fm_tagline]
424
+ return [site || metadata?.title, metadata?.fm_slogan]
332
425
  .filter(a => a)
333
426
  .join(" - ")
334
427
  }
335
428
 
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)
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)
339
437
  return parts.length ? parts.join(" - ") : "Website"
340
438
  }
341
439
 
342
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
+
343
479
  const site = siteName(settings, api)
344
480
  const title = createTitle()
345
481
 
@@ -365,6 +501,9 @@ function writeFile(target, { settings, api, config }) {
365
501
  property: "og:description",
366
502
  content: metadata.description,
367
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 })] : []),
368
507
  ...treeStyleSheets,
369
508
  ])
370
509
 
@@ -391,20 +530,26 @@ function writeFile(target, { settings, api, config }) {
391
530
  // The page's AT Protocol record, for readers that verify a document
392
531
  // against its site: standard.site's convention, so only for a
393
532
  // 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"
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"
397
539
  if (standardDocument && metadata.title && metadata.date) {
398
540
  treeHead.children.push(h("link", {
399
541
  rel: "site.standard.document",
400
- href: metadata.atUri
542
+ href: record.uri
401
543
  }))
402
544
  }
403
545
 
404
- 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) {
405
550
  treeHead.children.push(h("meta", {
406
551
  property: "og:image",
407
- content: metadata.image
552
+ content: ogImage
408
553
  }))
409
554
  }
410
555
 
@@ -420,15 +565,16 @@ function writeFile(target, { settings, api, config }) {
420
565
  // `icon` is a path, everywhere (Sam, Sept 19) - a URL path from the
421
566
  // root, resolved at read like `logo` (frontmatter.js). The type is
422
567
  // taken from its extension; a browser ignores a wrong one, but a
423
- // 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.
424
571
  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
- }
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
+ }))
432
578
 
433
579
  // The header nav is a partial: the lists for this page's folder chain,
434
580
  // computed once per pass by createPartialStubs and rendered once by
@@ -436,55 +582,18 @@ function writeFile(target, { settings, api, config }) {
436
582
  // beside shop/) takes that folder's chain, which ends in its own
437
583
  // list; otherwise its folder's. The first lookup may miss - votive
438
584
  // tracks the miss, so the page is rebuilt if that folder appears.
439
- const treeNav = navPartial ? withCurrent(navPartial.metadata.hast, metadata.prettyURL) : h("nav")
585
+ const treeNav = navWithChoices(navPartial ? withCurrent(navPartial.metadata.hast, metadata.prettyURL) : null)
440
586
 
441
587
  let treeBreadcrumbs = []
442
588
 
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
-
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.
481
592
  treeBreadcrumbs.push(
482
593
  ...ancestorFolders
483
594
  .map(folderCrumb)
484
595
  .filter(crumb => crumb.label)
485
- .map(crumb => crumb.href
486
- ? h('a', { href: crumb.href }, crumb.label)
487
- : h('span', crumb.label))
596
+ .map(crumb => h('a', crumb.href ? { href: crumb.href } : {}, crumb.label))
488
597
  )
489
598
 
490
599
  if (!isRoot) {
@@ -509,7 +618,7 @@ function writeFile(target, { settings, api, config }) {
509
618
  identityElements.push(h("img#logo", {
510
619
  src: logo,
511
620
  "aria-label": "Logo",
512
- style: `--logo-url: url("${logo}")`,
621
+ style: `--url-logo: url("${logo}")`,
513
622
  alt: ""
514
623
  }))
515
624
  }
@@ -560,10 +669,6 @@ function writeFile(target, { settings, api, config }) {
560
669
 
561
670
  const treeTableOfContents = treeContentSlugged.children.shift()
562
671
 
563
- const treeMainHead = makeFrontmatter(metadata, api, config)
564
-
565
- treeMainHead.push(treeTableOfContents)
566
-
567
672
  // One walk for everything that renders against other targets, settings
568
673
  // or the url store: relative links, wikilinks, icons, code highlighting,
569
674
  // images, directives, link previews and alerts. See writeRules.js.
@@ -571,6 +676,13 @@ function writeFile(target, { settings, api, config }) {
571
676
  const walked = { target, api, config, settings, makeHeader, makeTable, highlighted: false }
572
677
  writeWalk(abstract, walked)
573
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
+
574
686
  if (walked.highlighted) {
575
687
  // syntax-highlighting.css is always declared by the styles processor
576
688
  // (a stub); a page only decides whether to link it.
@@ -592,20 +704,23 @@ function writeFile(target, { settings, api, config }) {
592
704
  [
593
705
  // The title is main's first child: the one piece of data hoisted out
594
706
  // 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', {
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', {
599
712
  'aria-label': 'Breadcrumbs'
600
- }, treeBreadcrumbs),
713
+ }, treeBreadcrumbs)),
601
714
  treeMainHead,
602
715
  h('section#content', abstract),
603
716
  treeBacklinks
604
717
  ].filter(Boolean))
605
718
 
606
- // 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.
607
721
  const asidePartial = api.target(ASIDE_PARTIAL)
608
- 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
609
724
 
610
725
  const treeFooter = h('footer', [
611
726
  socialLinksNav(settings),
@@ -618,14 +733,7 @@ function writeFile(target, { settings, api, config }) {
618
733
  ])
619
734
  ])
620
735
 
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}`, [
736
+ const treeBody = h("body", { className: pageClasses(target.metadata.prettyURL, target.source) }, [
629
737
  treeHeader,
630
738
  treeMain,
631
739
  treeAside,
@@ -682,6 +790,7 @@ function makeTable(classes, properties, targets, api) {
682
790
 
683
791
  const cell = (target, name) => {
684
792
  const value = valueOf(target.metadata, name)
793
+ if (name === "title" && isPageWithoutFile(target)) return h("td", value ?? "")
685
794
  if (name === "title") return h("td", h("a", { href: target.metadata.prettyURL }, value ?? target.metadata.prettyURL))
686
795
  if (value === undefined || value === null) return h("td")
687
796
  if (name === "image") return h("td", createDynamicImage(String(value), api, "") ?? String(value))
@@ -690,10 +799,12 @@ function makeTable(classes, properties, targets, api) {
690
799
  return h("td", String(value))
691
800
  }
692
801
 
693
- 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 }, [
694
805
  h("thead", h("tr", properties.map(name => h("th", name)))),
695
806
  h("tbody", targets.map(target => h("tr", properties.map(name => cell(target, name)))))
696
- ])
807
+ ]))
697
808
  }
698
809
 
699
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
  }