vowel 0.7.1 → 0.7.4
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/bin.js +18 -0
- package/package.json +2 -3
- package/plugins/atproto/body.js +32 -3
- package/plugins/atproto/publish.js +8 -2
- package/plugins/html/bundle/index.js +51 -28
- package/plugins/html/editor/frontmatter.js +44 -10
- package/plugins/html/editor/ingest.js +10 -23
- package/plugins/html/editor/main.js +5 -1
- package/plugins/html/editor/socket.js +34 -4
- package/plugins/html/image.js +77 -22
- package/plugins/html/index.js +42 -13
- package/plugins/html/partials.js +27 -9
- package/plugins/html/socialLinks.js +8 -3
- package/plugins/html/writeRules.js +48 -14
- package/plugins/images/index.js +29 -6
- package/plugins/markdown/frontmatter.js +1 -0
- package/plugins/markdown/index.js +12 -18
- package/plugins/markdown/metadata.js +62 -13
- package/plugins/markdown/mirror.js +121 -0
- package/stylesheets/DefaultStyles.css +58 -56
- package/stylesheets/TypographyStyles.css +5 -5
- package/utils.js +26 -20
package/plugins/html/image.js
CHANGED
|
@@ -1,10 +1,71 @@
|
|
|
1
|
-
import { testURL,
|
|
1
|
+
import { testURL, imageSizes, imageExts } from "./../../utils.js"
|
|
2
2
|
import path from "node:path"
|
|
3
3
|
import { h } from 'hastscript'
|
|
4
4
|
import * as unpic from "unpic"
|
|
5
5
|
|
|
6
6
|
/** @import * as Votive from "votive" */
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* The image target a path names, from the root. Lowercased: every vowel
|
|
10
|
+
* target path is (config.js's router), and the author may well have
|
|
11
|
+
* typed the file's real name.
|
|
12
|
+
* @param {string} imagePath
|
|
13
|
+
* @param {import("votive").PluginAPI} api
|
|
14
|
+
*/
|
|
15
|
+
function findImage(imagePath, api) {
|
|
16
|
+
const relativePath = (imagePath.startsWith("/") ? path.relative("/", imagePath) : imagePath).toLowerCase()
|
|
17
|
+
return api.target(relativePath)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The derivative to link where one file goes rather than a srcset: in
|
|
22
|
+
* the image's own format, at `size` (one of imageSizes). Undefined for
|
|
23
|
+
* an image without derivatives (plugins/images: a format that isn't
|
|
24
|
+
* optimised, the root favicon.png, or an image not read yet).
|
|
25
|
+
* @param {any} image - the image's target
|
|
26
|
+
* @param {number} size
|
|
27
|
+
*/
|
|
28
|
+
function derivativeURL(image, size) {
|
|
29
|
+
const format = path.extname(image.path).slice(1)
|
|
30
|
+
const derivative = image.metadata?.derivatives?.find(entry => entry.format === format && entry.size === size)
|
|
31
|
+
if (!derivative) return
|
|
32
|
+
return "/" + derivative.path
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The url to put where an image path goes as one file - og:image, the
|
|
37
|
+
* logo, an icon. A png or jpeg is not published at full size
|
|
38
|
+
* (plugins/images), so the path names a derivative; anything else, an
|
|
39
|
+
* external url included, is returned as it is. A tracked read, so the
|
|
40
|
+
* page is rewritten when the image is read or changes.
|
|
41
|
+
* @param {string | undefined} imagePath - from the root
|
|
42
|
+
* @param {import("votive").PluginAPI} api
|
|
43
|
+
* @param {number} size - one of imageSizes
|
|
44
|
+
*/
|
|
45
|
+
function imageURL(imagePath, api, size) {
|
|
46
|
+
if (!imagePath || testURL(imagePath) || !/\.(png|jpe?g)$/i.test(imagePath)) return imagePath
|
|
47
|
+
const image = findImage(imagePath, api)
|
|
48
|
+
if (!image) return imagePath
|
|
49
|
+
return derivativeURL(image, size) ?? imagePath
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The page image's dominant colour as a style for the element that
|
|
54
|
+
* holds it - a page's <main>, a card's <article> - so CSS outside the
|
|
55
|
+
* image can use it (text that contrasts with it, say). The <picture>
|
|
56
|
+
* carries it too, for its own placeholder. Nothing here decides that
|
|
57
|
+
* anything should use it. Undefined for no image, an external one, or
|
|
58
|
+
* one not read yet.
|
|
59
|
+
* @param {string | undefined} imagePath - from the root
|
|
60
|
+
* @param {import("votive").PluginAPI} api
|
|
61
|
+
*/
|
|
62
|
+
function dominantStyle(imagePath, api) {
|
|
63
|
+
if (!imagePath || testURL(imagePath)) return
|
|
64
|
+
const dominant = findImage(imagePath, api)?.metadata.dominant
|
|
65
|
+
if (!dominant) return
|
|
66
|
+
return `--color-image-dominant: ${dominant}`
|
|
67
|
+
}
|
|
68
|
+
|
|
8
69
|
/**
|
|
9
70
|
* @param {string} imagePath
|
|
10
71
|
* @param {import("votive").PluginAPI} api
|
|
@@ -45,26 +106,23 @@ function createDynamicImage(imagePath, api, alt, itemprop) {
|
|
|
45
106
|
})
|
|
46
107
|
})
|
|
47
108
|
|
|
48
|
-
|
|
49
|
-
// path cannot be recovered from them. Carried here rather than as a
|
|
50
|
-
// <source>, which is a loading candidate a browser could select.
|
|
51
|
-
return h("picture", { itemprop: itemprop && "image", "data-original": imagePath }, sources)
|
|
109
|
+
return h("picture", { itemprop: itemprop && "image" }, sources)
|
|
52
110
|
}
|
|
53
111
|
}
|
|
54
112
|
|
|
55
113
|
if (!isImg) return
|
|
56
114
|
|
|
57
|
-
|
|
58
|
-
// the author may well have typed the file's real name.
|
|
59
|
-
const relativePath = (imagePath.startsWith("/") ? path.relative("/", imagePath) : imagePath).toLowerCase()
|
|
60
|
-
const image = api.target(relativePath)
|
|
115
|
+
const image = findImage(imagePath, api)
|
|
61
116
|
if (!image) return
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
|
|
66
|
-
if (!
|
|
67
|
-
|
|
117
|
+
// An image without derivatives is published as it is (the root
|
|
118
|
+
// favicon.png), and a plain <img> links it. The tracked read of
|
|
119
|
+
// `derivatives` rewrites this page if they arrive.
|
|
120
|
+
const { derivatives } = image.metadata
|
|
121
|
+
if (!derivatives) return h("img", { src: "/" + image.path, alt, loading: "lazy" })
|
|
122
|
+
// One group per format, in the order the read lists them, which is
|
|
123
|
+
// <picture> order: avif, webp, then the image's own format as the <img>.
|
|
124
|
+
const formats = [...new Set(derivatives.map(entry => entry.format))]
|
|
125
|
+
.map(format => derivatives.filter(entry => entry.format === format))
|
|
68
126
|
|
|
69
127
|
// Intrinsic size, measured at read (plugins/images): the browser
|
|
70
128
|
// reserves the right box before the bytes arrive, and the reset's
|
|
@@ -74,13 +132,13 @@ function createDynamicImage(imagePath, api, alt, itemprop) {
|
|
|
74
132
|
const sources = formats.map((format, index) => {
|
|
75
133
|
const isImg = index === formats.length - 1
|
|
76
134
|
|
|
77
|
-
const sizes = format.map(
|
|
78
|
-
const type = "image/" +
|
|
135
|
+
const sizes = format.map(entry => `/${entry.path} ${entry.size}w`).join(", ")
|
|
136
|
+
const type = "image/" + format[0].format
|
|
79
137
|
return h(isImg ? "img" : "source", {
|
|
80
138
|
type,
|
|
81
139
|
srcset: sizes,
|
|
82
140
|
loading: isImg && "lazy",
|
|
83
|
-
src: isImg && "/" + format.at(-1),
|
|
141
|
+
src: isImg && "/" + format.at(-1).path,
|
|
84
142
|
sizes: "100vw",
|
|
85
143
|
width: isImg && width,
|
|
86
144
|
height: isImg && height,
|
|
@@ -88,9 +146,6 @@ function createDynamicImage(imagePath, api, alt, itemprop) {
|
|
|
88
146
|
})
|
|
89
147
|
})
|
|
90
148
|
|
|
91
|
-
// The derivative paths in srcset are hash-based, so the original asset
|
|
92
|
-
// path cannot be recovered from them. Carried here rather than as a
|
|
93
|
-
// <source>, which is a loading candidate a browser could select.
|
|
94
149
|
// Two values for a stylesheet to show behind the image while it loads:
|
|
95
150
|
// its dominant colour, and a tiny blurry copy (plugins/images; none
|
|
96
151
|
// for an image with transparency). Nothing here decides that it should.
|
|
@@ -102,9 +157,9 @@ function createDynamicImage(imagePath, api, alt, itemprop) {
|
|
|
102
157
|
].filter(Boolean).join("; ")
|
|
103
158
|
return h("picture", {
|
|
104
159
|
itemprop: itemprop && "image",
|
|
105
|
-
"data-original": imagePath,
|
|
106
160
|
style: style || undefined
|
|
107
161
|
}, sources)
|
|
108
162
|
}
|
|
109
163
|
|
|
164
|
+
export { dominantStyle, imageURL, derivativeURL }
|
|
110
165
|
export default createDynamicImage
|
package/plugins/html/index.js
CHANGED
|
@@ -13,14 +13,14 @@ 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, labelFromName, isPageWithoutFile, pageClasses, hashtagRegexSingle,
|
|
16
|
+
import { testURL, testHashtags, createHashtagPage, labelFromName, isPageWithoutFile, pageClasses, hashtagRegexSingle, imageSizes, mirrorPath } from "./../../utils.js"
|
|
17
17
|
import extractDate from "./../../extractDate.js"
|
|
18
18
|
import { reservedProperties, hiddenProperties } from "./../markdown/metadata.js"
|
|
19
19
|
import { toString as hastToString } from 'hast-util-to-string'
|
|
20
20
|
import { unified } from "unified"
|
|
21
21
|
import toc from "@jsdevtools/rehype-toc"
|
|
22
22
|
import slug from "rehype-slug"
|
|
23
|
-
import createDynamicImage from "./image.js"
|
|
23
|
+
import createDynamicImage, { dominantStyle, imageURL } from "./image.js"
|
|
24
24
|
import { listPages } from "./../../utils.js"
|
|
25
25
|
import { pageRecordURI } from "../atproto/record.js"
|
|
26
26
|
import { STATUSES } from "../markdown/frontmatter.js"
|
|
@@ -92,9 +92,8 @@ function makeHeader(metadata, url, api, config, source) {
|
|
|
92
92
|
treeMainHead.push(makeTime(date))
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
95
|
+
const metaImage = metadata.image || metadata.first_image
|
|
96
|
+
if (metaImage) {
|
|
98
97
|
treeMainHead.push(
|
|
99
98
|
createDynamicImage(metaImage, api, null, true)
|
|
100
99
|
)
|
|
@@ -113,7 +112,12 @@ function makeHeader(metadata, url, api, config, source) {
|
|
|
113
112
|
// an item of its own: the itemprops inside it (date, description,
|
|
114
113
|
// image) describe that page, not the page it's listed on. A page with
|
|
115
114
|
// no file (`html_file: false`) has no url, so its card links nowhere.
|
|
116
|
-
|
|
115
|
+
// Its image's dominant colour is the card's, for the card's own CSS.
|
|
116
|
+
const article = h('article', {
|
|
117
|
+
className: pageClasses(metadata.prettyURL, source),
|
|
118
|
+
itemscope: true,
|
|
119
|
+
style: dominantStyle(metaImage, api)
|
|
120
|
+
}, treeMainHead)
|
|
117
121
|
return url ? h('a', { href: url }, article) : article
|
|
118
122
|
}
|
|
119
123
|
|
|
@@ -251,6 +255,13 @@ function naturalMetadata(tree) {
|
|
|
251
255
|
const { dataMetadata: kind, ...properties } = node.properties
|
|
252
256
|
if (kind === "end") return []
|
|
253
257
|
if (kind === "description") return [{ ...node, properties }]
|
|
258
|
+
// A captioned image is a <figure> (writeRules.js) and is lifted
|
|
259
|
+
// whole. The itemprop goes on its image, not the figure: an image is
|
|
260
|
+
// the property, and the caption is not part of it.
|
|
261
|
+
if (node.tagName === "figure") {
|
|
262
|
+
const [image, ...caption] = node.children
|
|
263
|
+
return [{ ...node, properties, children: [{ ...image, properties: { ...image.properties, itemprop: kind } }, ...caption] }]
|
|
264
|
+
}
|
|
254
265
|
// A date or an image paragraph only wrapped the one element that is
|
|
255
266
|
// the metadata: the <time>, or the <picture> (an <img> until the
|
|
256
267
|
// image has been read).
|
|
@@ -527,6 +538,18 @@ function writeFile(target, { settings, api, config }) {
|
|
|
527
538
|
)
|
|
528
539
|
}
|
|
529
540
|
|
|
541
|
+
// The page's markdown mirror (markdown/mirror.js), linked only while
|
|
542
|
+
// it exists. A tracked read: the page is rebuilt when its mirror is
|
|
543
|
+
// declared or removed, and not when the mirror is rewritten.
|
|
544
|
+
const mirror = api.target(mirrorPath(target.path))
|
|
545
|
+
if (mirror) {
|
|
546
|
+
treeHead.children.push(h("link", {
|
|
547
|
+
rel: "alternate",
|
|
548
|
+
type: "text/markdown",
|
|
549
|
+
href: "/" + mirror.path.split(path.sep).join("/")
|
|
550
|
+
}))
|
|
551
|
+
}
|
|
552
|
+
|
|
530
553
|
// The page's AT Protocol record, for readers that verify a document
|
|
531
554
|
// against its site: standard.site's convention, so only for a
|
|
532
555
|
// site.standard.document, and only for a page that can be one - it
|
|
@@ -545,7 +568,9 @@ function writeFile(target, { settings, api, config }) {
|
|
|
545
568
|
|
|
546
569
|
// The page's own image, else the site's `fallback_image` (a setting,
|
|
547
570
|
// so a folder can have its own), so a link preview always has one.
|
|
548
|
-
|
|
571
|
+
// The largest derivative of a png or jpeg (image.js): the original
|
|
572
|
+
// is not published.
|
|
573
|
+
const ogImage = imageURL(metadata.image || settings.lastNonNull("fm_fallback_image"), api, imageSizes.at(-1))
|
|
549
574
|
if (ogImage) {
|
|
550
575
|
treeHead.children.push(h("meta", {
|
|
551
576
|
property: "og:image",
|
|
@@ -568,7 +593,7 @@ function writeFile(target, { settings, api, config }) {
|
|
|
568
593
|
// right one lets it pick between several. Without one, the project's
|
|
569
594
|
// own favicon.png, else Vowel's placeholder (plugins/images). A
|
|
570
595
|
// tracked read, so a favicon.png that appears later is linked.
|
|
571
|
-
const icon = settings.lastNonNull("fm_icon")
|
|
596
|
+
const icon = imageURL(settings.lastNonNull("fm_icon"), api, imageSizes[0])
|
|
572
597
|
?? (api.target("favicon.png") ? "/favicon.png" : "/favicon-placeholder.png")
|
|
573
598
|
treeHead.children.push(h("link", {
|
|
574
599
|
href: icon,
|
|
@@ -609,8 +634,10 @@ function writeFile(target, { settings, api, config }) {
|
|
|
609
634
|
const homeLink = []
|
|
610
635
|
|
|
611
636
|
|
|
612
|
-
|
|
613
|
-
|
|
637
|
+
// The smallest derivative of a png or jpeg (image.js): the original
|
|
638
|
+
// is not published. An SVG is linked as it is.
|
|
639
|
+
const logo = imageURL(settings.lastNonNull("logo"), api, imageSizes[0])
|
|
640
|
+
const wordmark = imageURL(settings.lastNonNull("fm_wordmark"), api, imageSizes[0])
|
|
614
641
|
|
|
615
642
|
const identityElements = []
|
|
616
643
|
|
|
@@ -647,7 +674,7 @@ function writeFile(target, { settings, api, config }) {
|
|
|
647
674
|
const treeHeader = h('header', [
|
|
648
675
|
identity,
|
|
649
676
|
treeNav,
|
|
650
|
-
socialLinksNav(settings)
|
|
677
|
+
socialLinksNav(settings, api)
|
|
651
678
|
])
|
|
652
679
|
|
|
653
680
|
|
|
@@ -699,7 +726,9 @@ function writeFile(target, { settings, api, config }) {
|
|
|
699
726
|
|
|
700
727
|
const treeMain = h('main',
|
|
701
728
|
{
|
|
702
|
-
itemscope: true
|
|
729
|
+
itemscope: true,
|
|
730
|
+
// The page image's dominant colour, for anything in <main> to use.
|
|
731
|
+
style: dominantStyle(metadata.image, api)
|
|
703
732
|
},
|
|
704
733
|
[
|
|
705
734
|
// The title is main's first child: the one piece of data hoisted out
|
|
@@ -723,7 +752,7 @@ function writeFile(target, { settings, api, config }) {
|
|
|
723
752
|
const treeAside = asideNav ? h("aside", asideNav) : null
|
|
724
753
|
|
|
725
754
|
const treeFooter = h('footer', [
|
|
726
|
-
socialLinksNav(settings),
|
|
755
|
+
socialLinksNav(settings, api),
|
|
727
756
|
h('section#copyright', `© ${new Date().getFullYear()}`),
|
|
728
757
|
h('section#shoutout', [
|
|
729
758
|
"Made with ",
|
package/plugins/html/partials.js
CHANGED
|
@@ -41,9 +41,23 @@ const PROJECT_SHEET = "styles.css"
|
|
|
41
41
|
|
|
42
42
|
const ASIDE_PARTIAL = path.join("partials", "aside.partial")
|
|
43
43
|
|
|
44
|
-
/** A page with more siblings than this is left out of the aside. */
|
|
44
|
+
/** A page with more siblings than this is left out of the aside and the header nav. */
|
|
45
45
|
const MAX_SIBLINGS = 18
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* A folder's pages as a menu, or none: a page with more than
|
|
49
|
+
* MAX_SIBLINGS siblings is left out, and so are its siblings. Their
|
|
50
|
+
* folder's own entry stays, and its pages are a listing's job, not a
|
|
51
|
+
* menu's. Per Sam: "exclude pages with 19 siblings or more", and the
|
|
52
|
+
* header nav follows the same rule as the aside. Without it, a folder
|
|
53
|
+
* of 4,000 undated notes put a 4,000-link nav on each of them: 352 KB of
|
|
54
|
+
* a 355 KB page, and a build that grew with the square of the folder.
|
|
55
|
+
* @template T
|
|
56
|
+
* @param {T[]} pages - one folder's
|
|
57
|
+
* @returns {T[]}
|
|
58
|
+
*/
|
|
59
|
+
const asMenu = (pages) => pages.length - 1 > MAX_SIBLINGS ? [] : pages
|
|
60
|
+
|
|
47
61
|
/** @param {string} targetPath - the page's own target path */
|
|
48
62
|
function backlinksPartialPath(targetPath) {
|
|
49
63
|
return path.join("partials", "backlinks", `${targetPath}.partial`)
|
|
@@ -103,19 +117,16 @@ function asideTree(pages) {
|
|
|
103
117
|
if (!byFolder.has(url)) byFolder.set(url, [])
|
|
104
118
|
byFolder.get(url).push(page)
|
|
105
119
|
}
|
|
106
|
-
// A
|
|
107
|
-
// its siblings: the folder's own entry stays, and its pages are a
|
|
108
|
-
// listing's job, not a menu's. Per Sam: "exclude pages with 19
|
|
109
|
-
// siblings or more".
|
|
120
|
+
// A folder with too many pages for a menu shows its own entry only (asMenu).
|
|
110
121
|
const children = (parent) => {
|
|
111
122
|
const pages = byFolder.get(parent.metadata.prettyURL) ?? []
|
|
112
|
-
return pages.
|
|
123
|
+
return asMenu(pages).map(node)
|
|
113
124
|
}
|
|
114
125
|
|
|
115
126
|
function node(page) {
|
|
116
127
|
const below = children(page)
|
|
117
128
|
// The aside links the file, not the pretty url - kept as it was
|
|
118
|
-
// (see the note in the commit)
|
|
129
|
+
// (see the note in the commit).
|
|
119
130
|
return { url: "/" + page.path, label: page.metadata.breadcrumb, ...(below.length ? { children: below } : {}) }
|
|
120
131
|
}
|
|
121
132
|
|
|
@@ -124,7 +135,9 @@ function asideTree(pages) {
|
|
|
124
135
|
&& isOn(page.metadata.global_nav_item) && isOn(page.metadata.html_file))
|
|
125
136
|
.map(node)
|
|
126
137
|
|
|
127
|
-
|
|
138
|
+
// The home entry is the root's url: its bare path, `index.html`, is
|
|
139
|
+
// relative, and from /blog/post it named /blog/index.html.
|
|
140
|
+
return [...(home ? [item(home)] : []), ...top]
|
|
128
141
|
}
|
|
129
142
|
|
|
130
143
|
/**
|
|
@@ -224,7 +237,12 @@ function createPartialStubs(api) {
|
|
|
224
237
|
const navs = [...folders].sort().map(folder => ({
|
|
225
238
|
path: navPartialPath(folder),
|
|
226
239
|
params: {
|
|
227
|
-
|
|
240
|
+
// The root's pages are the top of the menu, as in the aside; below
|
|
241
|
+
// it, a folder with too many pages shows none of them (asMenu).
|
|
242
|
+
levels: chain(folder).map(level => {
|
|
243
|
+
const list = lists.get(level) ?? []
|
|
244
|
+
return (level ? asMenu(list) : list).map(item)
|
|
245
|
+
}),
|
|
228
246
|
sheets: chain(folder).flatMap(level => sheets.get(level) ?? [])
|
|
229
247
|
}
|
|
230
248
|
}))
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { h } from "hastscript"
|
|
2
|
+
import { imageSizes } from "../../utils.js"
|
|
3
|
+
import { imageURL } from "./image.js"
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* Social links, from the `social_links` setting:
|
|
@@ -45,16 +47,19 @@ function socialLinks(settings) {
|
|
|
45
47
|
/**
|
|
46
48
|
* A `<nav>` of the links, or null when there are none.
|
|
47
49
|
* @param {any} settings
|
|
50
|
+
* @param {import("votive").PluginAPI} api
|
|
48
51
|
*/
|
|
49
|
-
function socialLinksNav(settings) {
|
|
52
|
+
function socialLinksNav(settings, api) {
|
|
50
53
|
const links = socialLinks(settings)
|
|
51
54
|
if (!links.length) return null
|
|
52
55
|
|
|
53
56
|
return h("nav.social-links", { "aria-label": "Social links" },
|
|
54
57
|
h("ul", links.map(({ label, url, icon }) => {
|
|
55
58
|
const text = label ?? url
|
|
56
|
-
// A URL path from the root, resolved at read (frontmatter.js)
|
|
57
|
-
|
|
59
|
+
// A URL path from the root, resolved at read (frontmatter.js); a
|
|
60
|
+
// png or jpeg's smallest derivative, since the original is not
|
|
61
|
+
// published (image.js).
|
|
62
|
+
const iconURL = icon ? imageURL(String(icon), api, imageSizes[0]) : null
|
|
58
63
|
const image = iconURL && h("img", { src: iconURL, alt: text, style: `--url-icon: url("${iconURL}")` })
|
|
59
64
|
return h("li", h("a", { href: url, rel: "me" }, image || text))
|
|
60
65
|
}))
|
|
@@ -4,9 +4,9 @@ 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 { showPages, isPageWithoutFile } from "../../utils.js"
|
|
7
|
+
import { showPages, isPageWithoutFile, imageSizes } from "../../utils.js"
|
|
8
8
|
import { globClasses, alertLabel } from "./editor/directives.js"
|
|
9
|
-
import createDynamicImage from "./image.js"
|
|
9
|
+
import createDynamicImage, { derivativeURL } from "./image.js"
|
|
10
10
|
import { displayPath } from "../../secretPaths.js"
|
|
11
11
|
|
|
12
12
|
/**
|
|
@@ -139,7 +139,9 @@ const internalLink = {
|
|
|
139
139
|
if (!readInBackground(pathname, config)) config.log?.("warn", `${target.source ?? target.path}: link to ${href} matches no page`)
|
|
140
140
|
return
|
|
141
141
|
}
|
|
142
|
-
|
|
142
|
+
// A link to a png or jpeg opens its largest derivative: the
|
|
143
|
+
// original is not published (image.js).
|
|
144
|
+
node.properties.href = (derivativeURL(found, imageSizes.at(-1)) ?? urlOf(found)) + suffix
|
|
143
145
|
}
|
|
144
146
|
}
|
|
145
147
|
|
|
@@ -291,14 +293,42 @@ const highlight = {
|
|
|
291
293
|
}
|
|
292
294
|
}
|
|
293
295
|
|
|
296
|
+
/** The stand-in size of an image not read yet: 3:2, the commonest photo shape. */
|
|
297
|
+
const PENDING_WIDTH = 1500
|
|
298
|
+
const PENDING_HEIGHT = 1000
|
|
299
|
+
|
|
300
|
+
/** An image in the text, not an inline icon (`:folder/name:`). @param {any} node */
|
|
301
|
+
const isImage = (node) => isElement(node) && node.tagName === "img" && !hasClass(node, "icon")
|
|
302
|
+
|
|
294
303
|
/**
|
|
295
|
-
* A
|
|
296
|
-
* paragraph
|
|
304
|
+
* A paragraph that starts with an image and goes on is a captioned
|
|
305
|
+
* image: the paragraph becomes a <figure>, and what follows the image
|
|
306
|
+
* its <figcaption>. In place, so the walk goes on into both (the image
|
|
307
|
+
* rule makes the <picture>, the inline rules read the caption) and a
|
|
308
|
+
* natural image's mark (`data-metadata`) stays on the element the html
|
|
309
|
+
* plugin lifts. The <figure> used to go inside the <p>, where it is not
|
|
310
|
+
* allowed: a browser closed the paragraph before it and left an empty
|
|
311
|
+
* one behind.
|
|
312
|
+
* @type {import("../markdown/visitor.js").BlockRule}
|
|
313
|
+
*/
|
|
314
|
+
const captionedImage = {
|
|
315
|
+
name: "captioned image",
|
|
316
|
+
test: (node) => isElement(node) && node.tagName === "p" && isImage(node.children?.[0])
|
|
317
|
+
&& node.children.slice(1).some(child => child.type !== "text" || child.value.trim()),
|
|
318
|
+
transform: (node) => {
|
|
319
|
+
const [img, ...caption] = node.children
|
|
320
|
+
node.tagName = "figure"
|
|
321
|
+
node.children = [img, h("figcaption", caption)]
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* A project image becomes a responsive <picture>.
|
|
297
327
|
* @type {import("../markdown/visitor.js").BlockRule}
|
|
298
328
|
*/
|
|
299
329
|
const image = {
|
|
300
330
|
name: "image",
|
|
301
|
-
test:
|
|
331
|
+
test: isImage,
|
|
302
332
|
transform: (node, index, parent, context) => {
|
|
303
333
|
const { api, target } = context
|
|
304
334
|
const { src, alt } = node.properties
|
|
@@ -309,16 +339,20 @@ const image = {
|
|
|
309
339
|
const relative = isInternal(src) && !src.startsWith("/")
|
|
310
340
|
const picture = createDynamicImage(relative ? fromPage(splitURL(src).pathname, target) : src, api, alt)
|
|
311
341
|
if (!picture) {
|
|
312
|
-
// Left as an <img
|
|
342
|
+
// Left as an <img>. One whose file is read in the background may
|
|
343
|
+
// only be not read yet; any other miss is a 404, said so.
|
|
313
344
|
const { pathname } = isInternal(src) ? splitURL(src) : {}
|
|
314
|
-
if (
|
|
345
|
+
if (!pathname || servedTarget(pathname, context)) return SKIP
|
|
346
|
+
if (!readInBackground(pathname, context.config)) {
|
|
315
347
|
context.config.log?.("warn", `${context.target.source ?? context.target.path}: image ${src} matches no file`)
|
|
348
|
+
return SKIP
|
|
316
349
|
}
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
350
|
+
// Not read yet, so its size is unknown. A 3:2 box stands in (the
|
|
351
|
+
// reset sizes an <img> from its attributes, capped at the column),
|
|
352
|
+
// so the page doesn't jump when the read rewrites it at its own size.
|
|
353
|
+
if (node.properties.width !== undefined || node.properties.height !== undefined) return SKIP
|
|
354
|
+
node.properties.width = PENDING_WIDTH
|
|
355
|
+
node.properties.height = PENDING_HEIGHT
|
|
322
356
|
return SKIP
|
|
323
357
|
}
|
|
324
358
|
return picture
|
|
@@ -530,7 +564,7 @@ function trimText(nodes) {
|
|
|
530
564
|
const skipped = new Set(["pre", "code", "script", "style"])
|
|
531
565
|
|
|
532
566
|
const writeWalk = createVisitor({
|
|
533
|
-
block: [internalLink, wikilink, icon, highlight, image, directive, preview, alert],
|
|
567
|
+
block: [internalLink, wikilink, icon, highlight, captionedImage, image, directive, preview, alert],
|
|
534
568
|
skip: (node) => isElement(node) && skipped.has(node.tagName)
|
|
535
569
|
})
|
|
536
570
|
|
package/plugins/images/index.js
CHANGED
|
@@ -72,23 +72,46 @@ async function readImagePath(source) {
|
|
|
72
72
|
const hash = digest("sha1", buffer).slice(0, 8)
|
|
73
73
|
|
|
74
74
|
const ext = path.extname(source.target)
|
|
75
|
-
const
|
|
75
|
+
const formats = [...imageExts, ext.replace(/^\./, "")]
|
|
76
|
+
const targets = formats.flatMap(format => imageSizes.map(size => ({
|
|
76
77
|
path: createImagePath({ targetFilePath: source.target, size, ext: format, targetDirectory: "", uuid: hash }),
|
|
77
|
-
metadata: { derivative: { size, format
|
|
78
|
+
metadata: { derivative: { size, format } }
|
|
78
79
|
})))
|
|
80
|
+
const metadata = { hash, width, height, dominant: dominantColor, placeholder }
|
|
79
81
|
|
|
80
|
-
return { metadata
|
|
82
|
+
if (!isOptimized(source)) return { metadata, targets }
|
|
83
|
+
|
|
84
|
+
// Grouped by format in <picture> order (avif, webp, its own), each
|
|
85
|
+
// smallest first. This list is the one place a derivative's path is
|
|
86
|
+
// decided: plugins/html/image.js links what it names.
|
|
87
|
+
const derivatives = targets.map(({ path, metadata }) => ({ path, ...metadata.derivative }))
|
|
88
|
+
return { metadata: { ...metadata, derivatives }, targets, write: false }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A png or jpeg is never published at full size: its derivatives are
|
|
93
|
+
* linked everywhere instead, and the original is a virtual target. The
|
|
94
|
+
* root favicon.png is exempt, because it is linked as it is, as the
|
|
95
|
+
* site's icon. The other formats are copied through as before; their
|
|
96
|
+
* derivatives are made but nothing links them yet.
|
|
97
|
+
* @param {{ path: string, target: string }} source
|
|
98
|
+
*/
|
|
99
|
+
function isOptimized(source) {
|
|
100
|
+
if (source.path === "favicon.png") return false
|
|
101
|
+
return /^\.(png|jpe?g)$/.test(path.extname(source.target))
|
|
81
102
|
}
|
|
82
103
|
|
|
83
104
|
/**
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
105
|
+
* A derivative is resized and converted from its owner's bytes - which
|
|
106
|
+
* is what target.buffer() reads, because votive stored the owner as the
|
|
107
|
+
* derivative's source. An original with derivatives is virtual and
|
|
108
|
+
* writes nothing; any other original is copied through.
|
|
87
109
|
* @type {Votive.ProcessorWrite}
|
|
88
110
|
*/
|
|
89
111
|
async function writeImage(target) {
|
|
90
112
|
// The placeholder favicon has no file to read; its bytes are its data.
|
|
91
113
|
if (target.metadata?.favicon) return { data: Buffer.from(target.data, "base64") }
|
|
114
|
+
if (target.metadata?.derivatives) return
|
|
92
115
|
|
|
93
116
|
const bytes = target.buffer()
|
|
94
117
|
const recipe = target.metadata?.derivative
|
|
@@ -16,11 +16,12 @@ import { readFileSync } from "fs"
|
|
|
16
16
|
import { createHashtagPage, labelFromName, showPages } from "./../../utils.js"
|
|
17
17
|
import { toHast, defaultHandlers } from 'mdast-util-to-hast'
|
|
18
18
|
import { toString as hastToString } from 'hast-util-to-string'
|
|
19
|
-
import getMetadata, { reservedProperties } from "./metadata.js"
|
|
19
|
+
import getMetadata, { imageFromPage, reservedProperties } from "./metadata.js"
|
|
20
20
|
import { isSecretPath, secretRouter, displayPath } from "./../../secretPaths.js"
|
|
21
21
|
import { collectLinks } from "./links.js"
|
|
22
22
|
import { readWalk } from "./readRules.js"
|
|
23
23
|
import { h } from "hastscript"
|
|
24
|
+
import { mirrorStubs, mirrorWriter } from "./mirror.js"
|
|
24
25
|
|
|
25
26
|
import { styleText } from "node:util"
|
|
26
27
|
|
|
@@ -63,7 +64,9 @@ function readFile(source, { api, config }) {
|
|
|
63
64
|
if (!metadata.image) {
|
|
64
65
|
const firstImageParagraph = mdast.children.find(child => child.children && child.children[0].type === "image")
|
|
65
66
|
if (firstImageParagraph) {
|
|
66
|
-
|
|
67
|
+
// A card shows it from another folder's page, so from the root.
|
|
68
|
+
const folder = path.dirname(targetPath ?? filePath).replace(/^\.$/, "")
|
|
69
|
+
metadata.first_image = imageFromPage(firstImageParagraph.children[0].url, folder)
|
|
67
70
|
}
|
|
68
71
|
}
|
|
69
72
|
|
|
@@ -231,6 +234,11 @@ function settingsContribution(metadata, filePath, log) {
|
|
|
231
234
|
if (metadata.fm_slogan !== undefined) {
|
|
232
235
|
log?.("warn", `${filePath}: \`slogan:\` in a settings file does nothing - a slogan belongs to a page, e.g. home.md`)
|
|
233
236
|
}
|
|
237
|
+
// Mirrors are declared by an enumerator, which sees the root's
|
|
238
|
+
// settings only.
|
|
239
|
+
if (metadata.markdown_mirror !== undefined && filePath !== "settings.md") {
|
|
240
|
+
log?.("warn", `${filePath}: \`markdown_mirror:\` works only in the root settings.md or in a page's frontmatter`)
|
|
241
|
+
}
|
|
234
242
|
const declared = Object.entries(metadata)
|
|
235
243
|
.filter(([key]) => key.startsWith("fm_") || reservedProperties.includes(key))
|
|
236
244
|
return {
|
|
@@ -240,19 +248,6 @@ function settingsContribution(metadata, filePath, log) {
|
|
|
240
248
|
}
|
|
241
249
|
}
|
|
242
250
|
|
|
243
|
-
/**
|
|
244
|
-
* The only markdown target vowel produces is settings.md itself (every
|
|
245
|
-
* other .md file is routed to .html), so this passes its source text
|
|
246
|
-
* through untouched - the panel edits the real file, not a rendering of
|
|
247
|
-
* it.
|
|
248
|
-
* @type {Votive.ProcessorWrite}
|
|
249
|
-
*/
|
|
250
|
-
function writeMarkdown(target) {
|
|
251
|
-
return {
|
|
252
|
-
data: target.data,
|
|
253
|
-
encoding: "utf-8"
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
251
|
|
|
257
252
|
|
|
258
253
|
|
|
@@ -489,8 +484,7 @@ const readMarkdown = {
|
|
|
489
484
|
router,
|
|
490
485
|
createStubs,
|
|
491
486
|
expandStubs,
|
|
492
|
-
readFile
|
|
493
|
-
writeFile: writeMarkdown
|
|
487
|
+
readFile
|
|
494
488
|
}
|
|
495
489
|
|
|
496
490
|
|
|
@@ -498,7 +492,7 @@ const readMarkdown = {
|
|
|
498
492
|
/** @type {Votive.VotivePlugin} */
|
|
499
493
|
const vowelMarkdownPlugin = {
|
|
500
494
|
name: "vowel",
|
|
501
|
-
processors: [readMarkdown]
|
|
495
|
+
processors: [readMarkdown, mirrorStubs, mirrorWriter]
|
|
502
496
|
}
|
|
503
497
|
|
|
504
498
|
export default vowelMarkdownPlugin
|