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.
- package/LICENSE +21 -0
- package/README.md +41 -239
- package/bin.js +73 -31
- package/cli.js +32 -0
- package/config.js +14 -0
- package/extractDate.js +2 -2
- package/index.js +6 -1
- package/package.json +9 -34
- package/pathDate.js +69 -0
- package/plugins/assets/index.js +1 -1
- package/plugins/atproto/body.js +413 -0
- package/plugins/atproto/index.js +10 -44
- package/plugins/atproto/lexicons.js +179 -0
- package/plugins/atproto/mapper.js +399 -0
- package/plugins/atproto/publish.js +195 -105
- package/plugins/atproto/record.js +57 -68
- package/plugins/deploy-cloudflare/index.js +4 -1
- package/plugins/export/index.js +131 -0
- package/plugins/html/bundle/index.js +57 -23
- package/plugins/html/editor/Editor.svelte +9 -5
- package/plugins/html/editor/create-session.js +3 -1
- package/plugins/html/editor/directives.js +12 -0
- package/plugins/html/editor/frontmatter.js +42 -11
- package/plugins/html/editor/ingest.js +19 -8
- package/plugins/html/editor/serialize.js +8 -4
- package/plugins/html/image.js +11 -5
- package/plugins/html/index.js +217 -100
- package/plugins/html/partials.js +14 -8
- package/plugins/html/socialLinks.js +5 -8
- package/plugins/html/writeRules.js +197 -37
- package/plugins/images/favicon.png +0 -0
- package/plugins/images/index.js +41 -3
- package/plugins/markdown/colorScheme.js +70 -14
- package/plugins/markdown/frontmatter.js +32 -6
- package/plugins/markdown/index.js +105 -20
- package/plugins/markdown/metadata.js +74 -22
- package/plugins/markdown/typography.js +11 -11
- package/plugins/secret-pages/index.js +75 -0
- package/plugins/styles/index.js +4 -4
- package/plugins/xml/entryContent.js +22 -11
- package/plugins/xml/index.js +19 -11
- package/stylesheets/DefaultStyles.css +198 -111
- package/stylesheets/ResetStyles.css +2 -1
- package/stylesheets/TypographyStyles.css +153 -74
- 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 {
|
|
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)`
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
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
|
|
47
|
-
name: "
|
|
48
|
-
test: (node) => isElement(node) && node.tagName === "a" &&
|
|
49
|
-
transform: (node, index, parent,
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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 ${
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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,
|
|
302
|
+
transform: (node, index, parent, context) => {
|
|
303
|
+
const { api, target } = context
|
|
216
304
|
const { src, alt } = node.properties
|
|
217
|
-
|
|
218
|
-
|
|
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
|
|
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
|
-
:
|
|
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
|
|
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
|
|
353
|
-
*
|
|
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
|
|
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 ${
|
|
366
|
-
node.children.
|
|
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: [
|
|
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
|
package/plugins/images/index.js
CHANGED
|
@@ -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, {
|
|
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) => `
|
|
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,
|
|
49
|
-
*
|
|
50
|
-
* DefaultStyles.css.
|
|
51
|
-
* @param {unknown}
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
70
|
+
status: "status",
|
|
60
71
|
html_file: "boolean",
|
|
61
72
|
rss_item: "boolean",
|
|
62
73
|
sitemap_item: "boolean",
|
|
63
|
-
|
|
64
|
-
|
|
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 }
|