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
@@ -4,8 +4,8 @@ import { common, createLowlight } from "lowlight"
4
4
  import { toText } from "hast-util-to-text"
5
5
  import GithubSlugger from "github-slugger"
6
6
  import { createVisitor, SKIP } from "../markdown/visitor.js"
7
- import { toTitleCase, listPages } from "../../utils.js"
8
- import { globClasses } from "./editor/directives.js"
7
+ import { showPages, isPageWithoutFile } from "../../utils.js"
8
+ import { globClasses, alertLabel } from "./editor/directives.js"
9
9
  import createDynamicImage from "./image.js"
10
10
  import { displayPath } from "../../secretPaths.js"
11
11
 
@@ -35,27 +35,111 @@ const soleText = (node) => isElement(node) && node.tagName === "p" && node.child
35
35
  ? node.children[0].value
36
36
  : null
37
37
 
38
+ /** A url this site would serve: not another site, not an anchor, not mailto: and the like. */
39
+ const isInternal = (url) => typeof url === "string"
40
+ && url !== ""
41
+ && !/^[a-z][a-z0-9+.-]*:/i.test(url)
42
+ && !url.startsWith("//")
43
+ && !url.startsWith("#")
44
+ && !url.startsWith("?")
45
+
46
+ /**
47
+ * A url split into its path, decoded, and what follows it (`?query`,
48
+ * `#anchor`). `##` is a secret segment's marker, not an anchor, so it
49
+ * is kept in the path.
50
+ * @param {string} url
51
+ */
52
+ function splitURL(url) {
53
+ const masked = url.replaceAll("##", "\0\0")
54
+ const end = masked.search(/[?#]/)
55
+ const cut = end === -1 ? url.length : end
56
+ return { pathname: decodeURIComponent(url.slice(0, cut)), suffix: url.slice(cut) }
57
+ }
58
+
59
+ /** Where a target is served: a page's pretty url, anything else its path. */
60
+ const urlOf = (found) => found.metadata?.prettyURL ?? "/" + found.path.split(path.sep).join("/")
61
+
62
+ /**
63
+ * A path as the browser resolves it from this page: `pic.jpg` on
64
+ * /blog/post is /blog/pic.jpg. Decoded, from the root. `#` is encoded
65
+ * on the way in, so a secret segment's `##` stays in the path.
66
+ * @param {string} pathname - decoded, no query or anchor
67
+ * @param {any} target - the page
68
+ */
69
+ function fromPage(pathname, target) {
70
+ const base = new URL(urlOf(target), "thismessage://")
71
+ return decodeURIComponent(new URL(pathname.replaceAll("#", "%23"), base).pathname)
72
+ }
73
+
74
+ /**
75
+ * The target a browser would get for this url from this page, or null
76
+ * for a 404: resolved against the page's own url, then looked up as a
77
+ * file (`/pic.jpg`, `/feed.xml`) or a page (`/about` is about.html). A
78
+ * tracked read, so a page that appears later rewrites the one linking
79
+ * to it.
80
+ * @param {string} pathname - decoded, no query or anchor
81
+ * @param {{ target: any, api: any }} context
82
+ */
83
+ function servedTarget(pathname, { target, api }) {
84
+ const resolved = fromPage(pathname, target)
85
+ const trimmed = resolved.length > 1 ? resolved.replace(/\/+$/, "") : resolved
86
+ if (trimmed === "/") return api.target("index.html")
87
+ const file = trimmed.slice(1).split("/").join(path.sep)
88
+ return api.target(file) ?? api.target(`${file}.html`)
89
+ }
90
+
91
+ /** Per config: the extensions its plugins read in the background. */
92
+ const backgroundExtensions = new WeakMap()
93
+
94
+ /**
95
+ * Whether a missing path may only be not read *yet*: a file in the
96
+ * background (format "buffer": images, fonts, documents, video) has no
97
+ * target until its deferred read, which on a cold build runs after the
98
+ * first write pass. A miss there is not a 404, and warning about it
99
+ * flagged 71 of 98 links and images on style-builder whose files were
100
+ * there. So those go unwarned; the miss is tracked, and the page is
101
+ * rewritten with the right link when the file is read. A page link or
102
+ * a wikilink (markdown, read before any write) still warns.
103
+ * @param {string} pathname
104
+ * @param {any} config
105
+ */
106
+ function readInBackground(pathname, config) {
107
+ if (!backgroundExtensions.has(config)) {
108
+ const processors = (config.plugins ?? []).flatMap(plugin => plugin.processors ?? [])
109
+ const extensions = processors.filter(processor => processor.format === "buffer").flatMap(processor => processor.extensions ?? [])
110
+ backgroundExtensions.set(config, new Set(extensions))
111
+ }
112
+ return backgroundExtensions.get(config).has(path.extname(pathname).toLowerCase())
113
+ }
114
+
38
115
  /**
39
- * `[text](./post.md)` resolves by *source* path to the routed target -
40
- * which for a secret page is the hashed one, with no second trip
41
- * through the router. The href may arrive percent-encoded (toHast
42
- * normalizes non-ASCII). A link to a file that produces no page is the
43
- * author's to notice, not a reason to stop the build.
116
+ * A link to this site: `[text](./post.md)`, `../post.md` or
117
+ * `/blog/post.md` resolves by *source* path to the routed target - for a
118
+ * secret page the hashed one, with no second trip through the router -
119
+ * and an asset (`./pic.jpg`) to its path. Anything else (`/about`,
120
+ * `../tags/x`) is checked as the url it is. A link that goes nowhere
121
+ * is warned about and left as written: the author's to fix, not a
122
+ * reason to stop the build. A file read in the background isn't
123
+ * (readInBackground).
44
124
  * @type {import("../markdown/visitor.js").BlockRule}
45
125
  */
46
- const relativeLink = {
47
- name: "relativeLink",
48
- test: (node) => isElement(node) && node.tagName === "a" && typeof node.properties?.href === "string" && node.properties.href.startsWith("./"),
49
- transform: (node, index, parent, { target, api, config }) => {
126
+ const internalLink = {
127
+ name: "internalLink",
128
+ test: (node) => isElement(node) && node.tagName === "a" && !hasClass(node, "wikilink") && isInternal(node.properties?.href),
129
+ transform: (node, index, parent, context) => {
130
+ const { target, api, config } = context
131
+ const href = node.properties.href
132
+ const { pathname, suffix } = splitURL(href)
50
133
  // A stub has a source path but no neighbours to resolve against.
51
- if (!target.source) return
52
- const sourcePath = path.normalize(path.join(path.dirname(target.source), decodeURIComponent(node.properties.href)))
53
- const found = api.targetBySource(sourcePath)
134
+ const sourcePath = target.source && path.extname(pathname)
135
+ ? path.normalize(pathname.startsWith("/") ? pathname.slice(1) : path.join(path.dirname(target.source), pathname))
136
+ : null
137
+ const found = (sourcePath && api.targetBySource(sourcePath)) || servedTarget(pathname, context)
54
138
  if (!found) {
55
- config.log?.("warn", `${target.source}: link to ${node.properties.href} matches no page`)
139
+ if (!readInBackground(pathname, config)) config.log?.("warn", `${target.source ?? target.path}: link to ${href} matches no page`)
56
140
  return
57
141
  }
58
- node.properties.href = found.metadata.prettyURL
142
+ node.properties.href = urlOf(found) + suffix
59
143
  }
60
144
  }
61
145
 
@@ -89,7 +173,8 @@ function resolveNote(name, { api, target }) {
89
173
  recursive: true,
90
174
  query: { "|": { note_key: key, note_aliases: { "~": key } } }
91
175
  }).filter(candidate => (
92
- candidate.extension === ".html"
176
+ candidate.write !== false
177
+ && candidate.extension === ".html"
93
178
  && candidate.path !== target.path
94
179
  && (!folderHint || sourceFolder(candidate).endsWith(folderHint))
95
180
  ))
@@ -146,6 +231,8 @@ const wikilink = {
146
231
  const isFile = /\.[a-z0-9]+$/i.test(name.trim())
147
232
  const found = isFile ? resolveFile(name, context) : resolveNote(name, context)
148
233
  if (!found) {
234
+ const { target, config } = context
235
+ config.log?.("warn", `${target.source ?? target.path}: [[${name}]] matches no page`)
149
236
  delete node.properties.href
150
237
  return SKIP
151
238
  }
@@ -159,7 +246,7 @@ const wikilink = {
159
246
  /**
160
247
  * `:fa/surfer:` is `fa/surfer.svg` in the project, read through
161
248
  * api.target() so a replaced icon restales the page. The same shape as
162
- * a#logo - an <img> plus --icon-url - so a monochrome SVG can be masked
249
+ * a#logo - an <img> plus --url-icon - so a monochrome SVG can be masked
163
250
  * in currentColor. Missing: the shortcode is put back as written.
164
251
  * @type {import("../markdown/visitor.js").BlockRule}
165
252
  */
@@ -172,7 +259,7 @@ const icon = {
172
259
  if (!found) return { type: "text", value: `:${name}:` }
173
260
  const src = `/${found.path}`
174
261
  node.properties.src = src
175
- node.properties.style = `--icon-url: url("${src}")`
262
+ node.properties.style = `--url-icon: url("${src}")`
176
263
  return SKIP
177
264
  }
178
265
  }
@@ -212,10 +299,23 @@ const highlight = {
212
299
  const image = {
213
300
  name: "image",
214
301
  test: (node) => isElement(node) && node.tagName === "img" && !hasClass(node, "icon"),
215
- transform: (node, index, parent, { api }) => {
302
+ transform: (node, index, parent, context) => {
303
+ const { api, target } = context
216
304
  const { src, alt } = node.properties
217
- const picture = createDynamicImage(src, api, alt)
218
- if (!picture) return SKIP
305
+ // createDynamicImage reads a path from the root; a relative one
306
+ // (`pic.jpg`, `./pic.jpg`, `../pic.jpg`) is the page's, so resolve it
307
+ // first. It used to be looked up at the root, miss, and stay a
308
+ // plain <img>: shown, but never optimised.
309
+ const relative = isInternal(src) && !src.startsWith("/")
310
+ const picture = createDynamicImage(relative ? fromPage(splitURL(src).pathname, target) : src, api, alt)
311
+ if (!picture) {
312
+ // Left as an <img>: say so when the browser would get a 404 for it.
313
+ const { pathname } = isInternal(src) ? splitURL(src) : {}
314
+ if (pathname && !readInBackground(pathname, context.config) && !servedTarget(pathname, context)) {
315
+ context.config.log?.("warn", `${context.target.source ?? context.target.path}: image ${src} matches no file`)
316
+ }
317
+ return SKIP
318
+ }
219
319
  if (index === 0 && parent.children.length > 1) {
220
320
  const caption = parent.children.slice(1)
221
321
  parent.children = [h("figure", [picture, h("figcaption", caption)])]
@@ -280,7 +380,7 @@ const directive = {
280
380
  // Lowercased: every vowel target path is (config.js's router).
281
381
  const found = api.target(path.relative("/", path.format(info)).toLowerCase())
282
382
  if (!found) return SKIP
283
- return makeHeader(found.metadata, found.metadata.prettyURL, api, config)
383
+ return makeHeader(found.metadata, cardLink(found), api, config, found.source)
284
384
  }
285
385
 
286
386
  const folder = path.relative("/", dir)
@@ -294,20 +394,28 @@ const directive = {
294
394
 
295
395
  // A relative glob is in source space, where the author named the
296
396
  // folder salt and all, so it sees the hidden (secret) pages there;
297
- // a published-path glob never does.
397
+ // a published-path glob never does. A page hidden on purpose
398
+ // (`status: hidden`) is hidden from both.
298
399
  const listing = { folder, recursive, query, orderBy: { property: "date", direction: "desc" }, limit: count ? Number(count) : undefined }
299
400
  const targets = relative
300
- ? api.targets(listing).filter(page => page.write !== false && page.extension === ".html")
301
- : listPages(api, listing)
401
+ ? api.targets(listing).filter(page => (page.write !== false || isPageWithoutFile(page)) && page.extension === ".html" && page.metadata.fm_status !== "hidden")
402
+ : showPages(api, listing)
302
403
 
303
404
  const listClasses = globClasses({ folder, recursive, limit: count, tag, view, properties })
304
405
 
305
406
  return view === "table"
306
407
  ? makeTable(listClasses, properties.length ? properties : ["title"], targets, api)
307
- : h("ul", { class: listClasses }, targets.map(found => h("li", makeHeader(found.metadata, found.metadata.prettyURL, api, config))))
408
+ : h("ul", { class: listClasses }, targets.map(found => h("li", makeHeader(found.metadata, cardLink(found), api, config, found.source))))
308
409
  }
309
410
  }
310
411
 
412
+ /**
413
+ * Where a card links: the page's url, or nowhere for a page with no
414
+ * file (`html_file: false`), which is a card and nothing more.
415
+ * @param {any} found
416
+ */
417
+ const cardLink = (found) => isPageWithoutFile(found) ? null : found.metadata.prettyURL
418
+
311
419
  /**
312
420
  * A relative directive path as the absolute published path it names.
313
421
  * The glob's last segment (`*`, `**`) rides along untouched: the
@@ -348,29 +456,81 @@ const preview = {
348
456
  }
349
457
  }
350
458
 
459
+ /** `[!TYPE]` at the very start of an alert, and the spaces after it. */
460
+ const ALERT_MARKER = /^\[!([a-z][\w-]*)\][ \t]*/i
461
+
462
+ /** A blockquote's first block, if it is a paragraph that opens with `[!TYPE]`. */
463
+ const alertParagraph = (node) => {
464
+ const first = node.children?.find(isElement)
465
+ const lead = first?.tagName === "p" ? first.children?.[0] : null
466
+ return typeof lead?.value === "string" && ALERT_MARKER.test(lead.value) ? first : null
467
+ }
468
+
351
469
  /**
352
- * A GFM alert: a blockquote whose first line is `[!NOTE]` (or WARNING,
353
- * DANGER...) becomes an aside with the label as its heading.
470
+ * A GFM alert, or an Obsidian callout: a blockquote whose first line is
471
+ * `[!TYPE]`, any word, becomes `<aside class="alert type">` with a
472
+ * heading. The rest of that line is the heading (`> [!NOTE] How to
473
+ * render`, inline markdown and all), else the type in sentence case
474
+ * (alertLabel). Both spellings work: GitHub's, with the text on the
475
+ * next line - one paragraph, so the marker line is cut off the front of
476
+ * it - and the marker alone, then a blank `>` line.
354
477
  * @type {import("../markdown/visitor.js").BlockRule}
355
478
  */
356
479
  const alert = {
357
480
  name: "alert",
358
- test: (node) => isElement(node) && node.tagName === "blockquote"
359
- && isElement(node.children?.[1]) && node.children[1].tagName === "p"
360
- && node.children[1].children?.length === 1
361
- && /^\[!(\w+)\]$/.test(node.children[1].children[0].value ?? ""),
481
+ test: (node) => isElement(node) && node.tagName === "blockquote" && alertParagraph(node) !== null,
362
482
  transform: (node) => {
363
- const [, label] = node.children[1].children[0].value.match(/^\[!(\w+)\]$/)
483
+ const paragraph = alertParagraph(node)
484
+ const [lead, ...rest] = paragraph.children
485
+ const marker = ALERT_MARKER.exec(lead.value)
486
+ const type = marker[1].toLowerCase()
487
+
488
+ // The marker's line ends at the first newline: what comes before it
489
+ // is the title, what comes after is the paragraph.
490
+ const inline = [{ type: "text", value: lead.value.slice(marker[0].length) }, ...rest]
491
+ const breakAt = inline.findIndex(child => typeof child.value === "string" && child.value.includes("\n"))
492
+ const lineEnd = breakAt === -1 ? inline.length : breakAt
493
+ const split = breakAt === -1 ? null : inline[breakAt].value.indexOf("\n")
494
+ const title = [
495
+ ...inline.slice(0, lineEnd),
496
+ ...(split === null ? [] : [{ type: "text", value: inline[breakAt].value.slice(0, split) }])
497
+ ]
498
+ const body = split === null ? [] : [
499
+ { type: "text", value: inline[breakAt].value.slice(split + 1) },
500
+ ...inline.slice(breakAt + 1)
501
+ ]
502
+
503
+ const hasTitle = title.some(child => child.type !== "text" || child.value.trim())
504
+ const heading = h("h2", hasTitle ? trimText(title) : alertLabel(type))
505
+ const hasBody = body.some(child => child.type !== "text" || child.value.trim())
506
+
364
507
  node.tagName = "aside"
365
- node.properties = { class: `alert ${label.toLowerCase()}` }
366
- node.children.splice(0, 2, h("h2", toTitleCase(label)))
508
+ node.properties = { class: `alert ${type}` }
509
+ const index = node.children.indexOf(paragraph)
510
+ const kept = hasBody ? [{ ...paragraph, children: trimText(body) }] : []
511
+ node.children = [heading, ...node.children.slice(0, index).filter(isElement), ...kept, ...node.children.slice(index + 1)]
367
512
  }
368
513
  }
369
514
 
515
+ /**
516
+ * Inline nodes with the whitespace trimmed from both ends of the run.
517
+ * @param {any[]} nodes
518
+ */
519
+ function trimText(nodes) {
520
+ const last = nodes.length - 1
521
+ return nodes
522
+ .map((child, index) => {
523
+ if (typeof child.value !== "string" || child.type !== "text") return child
524
+ const start = index === 0 ? child.value.trimStart() : child.value
525
+ return { ...child, value: index === last ? start.trimEnd() : start }
526
+ })
527
+ .filter(child => child.type !== "text" || child.value !== "")
528
+ }
529
+
370
530
  const skipped = new Set(["pre", "code", "script", "style"])
371
531
 
372
532
  const writeWalk = createVisitor({
373
- block: [relativeLink, wikilink, icon, highlight, image, directive, preview, alert],
533
+ block: [internalLink, wikilink, icon, highlight, image, directive, preview, alert],
374
534
  skip: (node) => isElement(node) && skipped.has(node.tagName)
375
535
  })
376
536
 
Binary file
@@ -1,4 +1,5 @@
1
1
  import { hash as digest } from "node:crypto"
2
+ import { readFileSync } from "node:fs"
2
3
  import path from "node:path"
3
4
  import sharp from 'sharp'
4
5
  import { createImagePath, imageSizes, imageExts } from "./../../utils.js"
@@ -37,6 +38,18 @@ function hex(channel) {
37
38
  return Math.round(channel).toString(16).padStart(2, "0")
38
39
  }
39
40
 
41
+ /**
42
+ * A blurry stand-in for the image while it loads, inlined in the page:
43
+ * 16 px on its long side, low-quality WebP, a data URI of roughly
44
+ * 150-300 bytes. The browser smooths it as it scales up. Done here
45
+ * rather than with lqip-modern, which wraps these same sharp calls.
46
+ * @param {import("sharp").Sharp} image
47
+ */
48
+ async function placeholderURI(image) {
49
+ const bytes = await image.clone().resize(16, 16, { fit: "inside" }).webp({ quality: 20 }).toBuffer()
50
+ return `data:image/webp;base64,${bytes.toString("base64")}`
51
+ }
52
+
40
53
  /**
41
54
  * Runs deferred, with the bytes (buffer format), so this is where an
42
55
  * image is looked at. Dimensions go on <img width height> so the browser
@@ -51,8 +64,11 @@ function hex(channel) {
51
64
  async function readImagePath(source) {
52
65
  const buffer = source.buffer()
53
66
  const image = sharp(buffer)
54
- const [{ width, height }, { dominant }] = await Promise.all([image.metadata(), image.stats()])
67
+ const [{ width, height, hasAlpha }, { dominant }] = await Promise.all([image.metadata(), image.stats()])
55
68
  const dominantColor = dominant ? `#${hex(dominant.r)}${hex(dominant.g)}${hex(dominant.b)}` : undefined
69
+ // Not behind an image with transparency: the page has no script to take
70
+ // it away once the image loads, so it would show through.
71
+ const placeholder = hasAlpha ? undefined : await placeholderURI(image)
56
72
  const hash = digest("sha1", buffer).slice(0, 8)
57
73
 
58
74
  const ext = path.extname(source.target)
@@ -61,7 +77,7 @@ async function readImagePath(source) {
61
77
  metadata: { derivative: { size, format: format.replace(/^\./, "") } }
62
78
  })))
63
79
 
64
- return { metadata: { hash, width, height, dominant: dominantColor }, targets }
80
+ return { metadata: { hash, width, height, dominant: dominantColor, placeholder }, targets }
65
81
  }
66
82
 
67
83
  /**
@@ -71,6 +87,9 @@ async function readImagePath(source) {
71
87
  * @type {Votive.ProcessorWrite}
72
88
  */
73
89
  async function writeImage(target) {
90
+ // The placeholder favicon has no file to read; its bytes are its data.
91
+ if (target.metadata?.favicon) return { data: Buffer.from(target.data, "base64") }
92
+
74
93
  const bytes = target.buffer()
75
94
  const recipe = target.metadata?.derivative
76
95
  if (!recipe) return { data: bytes }
@@ -89,10 +108,29 @@ const imagesProcessor = {
89
108
  writeFile: writeImage
90
109
  }
91
110
 
111
+ /**
112
+ * Every site has Vowel's mark as a placeholder favicon,
113
+ * `favicon-placeholder.png`, published always and linked only when the
114
+ * site has no `icon` and no `favicon.png` of its own (the html plugin).
115
+ * A stub of its own source type (`.favicon`, which nothing else uses)
116
+ * read as base64 text, so it is read with the pages rather than with
117
+ * the images in the background. A write is matched on the target's
118
+ * extension, so writeImage writes it, from that text.
119
+ * @type {Votive.VotiveProcessor}
120
+ */
121
+ const faviconProcessor = {
122
+ extensions: [".favicon"],
123
+ format: "text",
124
+ router: ({ name, dir }) => ({ name, dir, ext: ".png" }),
125
+ createStubs: () => [{ path: "favicon-placeholder.favicon" }],
126
+ expandStubs: () => ({ text: readFileSync(path.join(import.meta.dirname, "favicon.png")).toString("base64") }),
127
+ readFile: (source) => ({ data: source.text, metadata: { favicon: true } })
128
+ }
129
+
92
130
  /** @type {Votive.VotivePlugin} */
93
131
  const vowelImagesPlugin = {
94
132
  name: "vowel-images",
95
- processors: [imagesProcessor]
133
+ processors: [imagesProcessor, faviconProcessor]
96
134
  }
97
135
 
98
136
  export default vowelImagesPlugin
@@ -4,16 +4,70 @@ import { fallbackColors } from "./brandColors.js"
4
4
  /**
5
5
  * Ten shades per role, named `01` (darkest) to `10` (lightest) in
6
6
  * colorhorse's own darkest-first order. DefaultStyles.css reads them
7
- * that way round: `light-dark(var(--primary-10), var(--primary-01))`
7
+ * that way round: `light-dark(var(--color-primary-10), var(--color-primary-01))`
8
8
  * is the body background. tests/colorScheme.js pins the order.
9
9
  */
10
10
  const shadeCount = 10
11
11
 
12
+ /**
13
+ * The `theme` keys that tune the scheme, each one colorhorse option, all
14
+ * numbers from 0 to 1. The defaults are vowel's, set before these were
15
+ * settings; `contrast` 0 is colorhorse's, evenly spread shades.
16
+ */
17
+ const schemeSettings = [
18
+ { key: "darkest-shade", option: "minLightness", default: 0.3 },
19
+ { key: "lightest-shade", option: "maxLightness", default: 0.99 },
20
+ { key: "min-saturation", option: "minChroma", default: 0.2 },
21
+ { key: "contrast", option: "contrast", default: 0 }
22
+ ]
23
+
24
+ const defaultOptions = Object.fromEntries(schemeSettings.map(setting => [setting.option, setting.default]))
25
+
12
26
  /** @param {number} index */
13
27
  function shadeName(index) {
14
28
  return String(index).padStart(2, "0")
15
29
  }
16
30
 
31
+ /** @param {unknown} value */
32
+ function isFraction(value) {
33
+ return typeof value === "number" && value >= 0 && value <= 1
34
+ }
35
+
36
+ /**
37
+ * The part of a theme the colors sheet is generated from, as stub
38
+ * params: the sheet re-expands when one of these changes, and only then.
39
+ * @param {Record<string, unknown>} theme - a resolved theme's config
40
+ */
41
+ function colorParams(theme) {
42
+ const settings = schemeSettings.map(setting => [setting.key, theme[setting.key] ?? null])
43
+ return { colors: theme.colors ?? null, ...Object.fromEntries(settings) }
44
+ }
45
+
46
+ /**
47
+ * colorhorse's options from a theme's settings. A value that is not a
48
+ * number from 0 to 1 is reported and left at its default, rather than
49
+ * costing the site its colors: colorhorse throws for `contrast` alone,
50
+ * and turns the rest into ramps nobody asked for.
51
+ * @param {Record<string, unknown>} theme
52
+ * @param {(error: Error) => void} onError
53
+ */
54
+ function schemeOptions(theme, onError) {
55
+ const entries = schemeSettings.map(setting => {
56
+ const value = theme[setting.key]
57
+ if (value === undefined || value === null) return [setting.option, setting.default]
58
+ if (isFraction(value)) return [setting.option, value]
59
+ onError(new Error(`ignoring theme.${setting.key} - expected a number from 0 to 1, got ${JSON.stringify(value)}`))
60
+ return [setting.option, setting.default]
61
+ })
62
+ const options = Object.fromEntries(entries)
63
+
64
+ // Reversed, colorhorse still answers, with a ramp whose "darkest"
65
+ // shade is the lighter one.
66
+ if (options.minLightness < options.maxLightness) return options
67
+ onError(new Error(`ignoring theme.darkest-shade and theme.lightest-shade - the darkest shade (${options.minLightness}) must be below the lightest (${options.maxLightness})`))
68
+ return { ...options, minLightness: defaultOptions.minLightness, maxLightness: defaultOptions.maxLightness }
69
+ }
70
+
17
71
  /**
18
72
  * Builds the `:root` block for a theme's `colors` seed pair, falling
19
73
  * back to Vowel's brand colors when a site hasn't configured any.
@@ -23,20 +77,21 @@ function shadeName(index) {
23
77
  * messages are specific ("colorOne has a chroma of zero...") and are
24
78
  * left to surface as they are.
25
79
  * @param {unknown} colors - a theme setting's `colors`
80
+ * @param {object} [options] - colorhorse's, already checked (schemeOptions)
26
81
  * @returns {string}
27
82
  */
28
- function colorSchemeCSS(colors) {
29
- if (colors === undefined || colors === null) return colorSchemeCSS(fallbackColors)
83
+ function colorSchemeCSS(colors, options = defaultOptions) {
84
+ if (colors === undefined || colors === null) return colorSchemeCSS(fallbackColors, options)
30
85
 
31
86
  if (!Array.isArray(colors) || colors.length !== 2) {
32
87
  throw new Error(`theme.colors must be an array of two "#rrggbb" colors, got ${JSON.stringify(colors)}`)
33
88
  }
34
89
 
35
90
  const [colorOne, colorTwo] = colors
36
- const scheme = getScheme(colorOne, colorTwo, { shadeCount, minLightness: 0.3, maxLightness: 0.99, minChroma: 0.2 })
91
+ const scheme = getScheme(colorOne, colorTwo, { ...options, shadeCount })
37
92
 
38
93
  const declarations = Object.entries(scheme).flatMap(([role, ramp]) => (
39
- ramp.map((color, index) => ` --${role}-${shadeName(index + 1)}: ${color};`)
94
+ ramp.map((color, index) => ` --color-${role}-${shadeName(index + 1)}: ${color};`)
40
95
  ))
41
96
 
42
97
  return `:root {\n${declarations.join("\n")}\n}\n`
@@ -45,20 +100,21 @@ function colorSchemeCSS(colors) {
45
100
  /**
46
101
  * The build-facing entry point: always returns a usable `:root` block.
47
102
  * Invalid configuration is reported through `onError` and then falls
48
- * back to the brand colors, so a typo in settings.md leaves the site
49
- * styled instead of stripping every color variable out from under
50
- * DefaultStyles.css.
51
- * @param {unknown} colors
103
+ * back - unusable colors to the brand colors, a bad setting to its
104
+ * default - so a typo in settings.md leaves the site styled instead of
105
+ * stripping every color variable out from under DefaultStyles.css.
106
+ * @param {Record<string, unknown>} theme - colorParams' shape
52
107
  * @param {(error: Error) => void} onError
53
108
  * @returns {string}
54
109
  */
55
- function themeColorSchemeCSS(colors, onError) {
110
+ function themeColorSchemeCSS(theme, onError) {
111
+ const options = schemeOptions(theme, onError)
56
112
  try {
57
- return colorSchemeCSS(colors)
113
+ return colorSchemeCSS(theme.colors, options)
58
114
  } catch (error) {
59
- onError(error)
60
- return colorSchemeCSS(fallbackColors)
115
+ onError(new Error(`ignoring theme.colors - ${error.message}`))
116
+ return colorSchemeCSS(fallbackColors, options)
61
117
  }
62
118
  }
63
119
 
64
- export { colorSchemeCSS, themeColorSchemeCSS, fallbackColors }
120
+ export { colorSchemeCSS, themeColorSchemeCSS, colorParams, fallbackColors }
@@ -33,9 +33,18 @@ import extractDate from "../../extractDate.js"
33
33
  */
34
34
 
35
35
  /**
36
- * @typedef {"string" | "date" | "path" | "boolean" | "number" | "strings" | "theme" | "links" | "publication" | "object"} Shape
36
+ * @typedef {"string" | "date" | "path" | "paths" | "boolean" | "number" | "strings" | "theme" | "links" | "publication" | "object" | "status"} Shape
37
37
  */
38
38
 
39
+ /**
40
+ * A page's visibility. `published` is the default. `hidden` writes the
41
+ * page but lists it nowhere - no menu, listing, sitemap, feed or record.
42
+ * `draft` skips the page entirely.
43
+ * `deleted` skips it too, and tells `atproto-publish` to delete its
44
+ * record.
45
+ */
46
+ const STATUSES = ["published", "draft", "hidden", "deleted"]
47
+
39
48
  /**
40
49
  * The keys vowel reads and the shape each one has. Everything else in a
41
50
  * frontmatter block is the author's own data.
@@ -45,7 +54,7 @@ const EXPECTED = {
45
54
  title: "string",
46
55
  breadcrumb: "string",
47
56
  description: "string",
48
- tagline: "string",
57
+ slogan: "string",
49
58
  name: "string",
50
59
  author: "string",
51
60
  domain: "string",
@@ -54,20 +63,23 @@ const EXPECTED = {
54
63
  logo: "path",
55
64
  wordmark: "path",
56
65
  icon: "path",
66
+ fallback_image: "path",
67
+ styles: "paths",
57
68
  tags: "strings",
58
69
  aliases: "strings",
59
- published: "boolean",
70
+ status: "status",
60
71
  html_file: "boolean",
61
72
  rss_item: "boolean",
62
73
  sitemap_item: "boolean",
63
- global_menu_item: "boolean",
64
- local_menu_item: "boolean",
74
+ global_nav_item: "boolean",
75
+ local_nav_item: "boolean",
65
76
  feed_limit: "number",
66
77
  theme: "theme",
67
78
  social_links: "links",
68
79
  robots: "object",
69
80
  atproto_did: "string",
70
81
  atproto_lexicon: "string",
82
+ atproto_content: "string",
71
83
  atproto_publication: "publication",
72
84
  cloudflare_project: "string",
73
85
  atproto_pds: "string"
@@ -132,6 +144,12 @@ function coerceOne(shape, value, folder) {
132
144
  if (inner) return { value: resolvePath(inner, folder), problem: `expected a path, got ${Array.isArray(value) ? "a list" : "an object"}; using "${inner}"` }
133
145
  return { value: undefined, problem: `expected a path, got ${JSON.stringify(value)}` }
134
146
  }
147
+ if (shape === "paths") {
148
+ const values = Array.isArray(value) ? value : [value]
149
+ const kept = values.filter(item => typeof item === "string" && item.trim()).map(item => resolvePath(item, folder))
150
+ if (!kept.length) return { value: undefined, problem: `expected a path or a list of paths, got ${JSON.stringify(value)}` }
151
+ return kept.length === values.length ? { value: kept } : { value: kept, problem: "expected a list of paths; dropped what was not" }
152
+ }
135
153
  if (shape === "boolean") {
136
154
  if (typeof value === "boolean") return { value }
137
155
  const word = String(value).trim().toLowerCase()
@@ -179,6 +197,14 @@ function coerceOne(shape, value, folder) {
179
197
  ]
180
198
  return wrong.length ? { value: kept, problem: `${wrong.join(", ")}; dropped` } : { value: kept }
181
199
  }
200
+ if (shape === "status") {
201
+ // One of vowel's four controls the page. Anything else is the
202
+ // author's own `status` - a digital garden's "evergreen" or
203
+ // "growing" - kept as written, the page published as usual.
204
+ const word = typeof value === "string" ? value.trim().toLowerCase() : undefined
205
+ if (STATUSES.includes(word)) return { value: word }
206
+ return { value }
207
+ }
182
208
  if (shape === "object") {
183
209
  if (isPlainObject(value)) return { value }
184
210
  return { value: undefined, problem: `expected an object, got ${JSON.stringify(value)}` }
@@ -250,4 +276,4 @@ function asContent(tree, node) {
250
276
  return undefined
251
277
  }
252
278
 
253
- export { EXPECTED, coerce, parseFrontmatter, resolvePath, toISODate }
279
+ export { EXPECTED, STATUSES, coerce, parseFrontmatter, resolvePath, toISODate }