vowel 0.7.1 → 0.7.3
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 +3 -1
- 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 +30 -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/index.js +4 -2
- package/plugins/markdown/metadata.js +61 -13
- package/stylesheets/DefaultStyles.css +6 -4
- package/stylesheets/TypographyStyles.css +5 -5
- package/utils.js +0 -20
package/bin.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import fs from "node:fs/promises"
|
|
4
4
|
import os from "node:os"
|
|
5
|
+
import path from "node:path"
|
|
5
6
|
import { intro, note, text, select, confirm, spinner, outro, box, isCancel, cancel } from "@clack/prompts"
|
|
6
7
|
import { parse } from "@bomb.sh/args"
|
|
7
8
|
import t from "@bomb.sh/tab"
|
|
@@ -55,6 +56,22 @@ async function removeDatabase(verbose) {
|
|
|
55
56
|
}
|
|
56
57
|
}
|
|
57
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Empties the buffer cache: an image's measurements and derivative list,
|
|
61
|
+
* cached by votive so a warm start doesn't decode it again. Without
|
|
62
|
+
* this, a reset still reused every image's old read. Only the `.json`
|
|
63
|
+
* results go. The `cache-key` file stays, because votive empties a
|
|
64
|
+
* folder that has no key, so removing it would discard the next
|
|
65
|
+
* launch's fresh cache. URL fetches are not cached here.
|
|
66
|
+
* @param {boolean} verbose
|
|
67
|
+
*/
|
|
68
|
+
async function removeBufferCache(verbose) {
|
|
69
|
+
const entries = await fs.readdir(config.cacheDirectory).catch(() => [])
|
|
70
|
+
const results = entries.filter(name => name.endsWith(".json"))
|
|
71
|
+
await Promise.all(results.map(name => fs.rm(path.join(config.cacheDirectory, name), { force: true })))
|
|
72
|
+
if (verbose) console.info(`${styleText("dim", "loading:")} ${styleText("green", results.length ? "buffer cache cleared" : "no buffer cache found")}`)
|
|
73
|
+
}
|
|
74
|
+
|
|
58
75
|
/** @param {boolean} verbose */
|
|
59
76
|
async function removeOutput(verbose) {
|
|
60
77
|
try {
|
|
@@ -290,6 +307,7 @@ async function main() {
|
|
|
290
307
|
|
|
291
308
|
if (shouldClear(args)) {
|
|
292
309
|
await removeDatabase(args.logging === "verbose")
|
|
310
|
+
await removeBufferCache(args.logging === "verbose")
|
|
293
311
|
await removeOutput(args.logging === "verbose")
|
|
294
312
|
}
|
|
295
313
|
await fs.mkdir(config.targetFolder, { recursive: true })
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vowel",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.7.
|
|
4
|
+
"version": "0.7.3",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=26"
|
|
7
7
|
},
|
|
@@ -67,8 +67,7 @@
|
|
|
67
67
|
"unified": "^11.0.5",
|
|
68
68
|
"unist-util-visit": "^5.0.0",
|
|
69
69
|
"unpic": "^4.2.2",
|
|
70
|
-
"
|
|
71
|
-
"votive": "0.5.2",
|
|
70
|
+
"votive": "0.5",
|
|
72
71
|
"xml": "^1.0.1",
|
|
73
72
|
"yaml": "^2.8.2"
|
|
74
73
|
},
|
package/plugins/atproto/body.js
CHANGED
|
@@ -113,7 +113,9 @@ function phrase(node, base) {
|
|
|
113
113
|
}
|
|
114
114
|
if (tag === "picture") {
|
|
115
115
|
const img = (node.children ?? []).find(child => is(child, "img"))
|
|
116
|
-
|
|
116
|
+
// The <img>'s src is the largest derivative in the image's own
|
|
117
|
+
// format; the original is not published (plugins/images).
|
|
118
|
+
const src = img?.properties?.src
|
|
117
119
|
return src ? [{ type: "image", url: absolute(String(src), base), alt: String(img?.properties?.alt ?? "") }] : []
|
|
118
120
|
}
|
|
119
121
|
if (tag === "a") {
|
|
@@ -11,6 +11,8 @@ import { PUBLICATION, siteOrigin, publicationURI, pageRecordURI } from "./record
|
|
|
11
11
|
import { createLexiconResolver, resolvePDS } from "./lexicons.js"
|
|
12
12
|
import { mapRecord } from "./mapper.js"
|
|
13
13
|
import { markdownBody, richTextBody } from "./body.js"
|
|
14
|
+
import { imageURL } from "../html/image.js"
|
|
15
|
+
import { imageSizes } from "../../utils.js"
|
|
14
16
|
|
|
15
17
|
/**
|
|
16
18
|
* `vowel --command atproto-publish`: make the account's records match the
|
|
@@ -267,7 +269,9 @@ function recordPages(database) {
|
|
|
267
269
|
* its status, and what mapper.js reads - the site's own settings, and
|
|
268
270
|
* the publication's name, description and icon. The icon is read from
|
|
269
271
|
* the build's output - it is a URL path from the root (frontmatter.js
|
|
270
|
-
* resolves it), which is where the output has it
|
|
272
|
+
* resolves it), which is where the output has it; a png or jpeg is read
|
|
273
|
+
* from its largest derivative, since the original is not published
|
|
274
|
+
* (plugins/images). Built into a scratch
|
|
271
275
|
* folder that is removed afterwards.
|
|
272
276
|
* @param {import("votive").VotiveConfig} config
|
|
273
277
|
*/
|
|
@@ -306,7 +310,9 @@ async function collectPages(config) {
|
|
|
306
310
|
const home = site.database.target.get("index.html")
|
|
307
311
|
const name = root.raw("fm_name")?.[0]?.at(-1) ?? (home?.source ? home.metadata.title : undefined)
|
|
308
312
|
const iconPath = settings.icon ?? root.lastNonNull("logo")
|
|
309
|
-
|
|
313
|
+
// imageURL asks an api for the target; the database answers the same.
|
|
314
|
+
const builtIcon = imageURL(iconPath, { target: (targetPath) => site.database.target.get(targetPath) }, imageSizes.at(-1))
|
|
315
|
+
const icon = builtIcon ? await readIcon(path.join(scratch, "output"), builtIcon) : undefined
|
|
310
316
|
const publication = { name, description: settings.description, iconPath, icon }
|
|
311
317
|
return { did, pds, domain, pages, publication }
|
|
312
318
|
} finally {
|
|
@@ -20156,7 +20156,7 @@ ${fallback_html}`;
|
|
|
20156
20156
|
const tag = element.tagName;
|
|
20157
20157
|
if (tag === "P") {
|
|
20158
20158
|
const picture = pictureIn(element);
|
|
20159
|
-
if (picture && !element.textContent.trim()) return imageFrom(
|
|
20159
|
+
if (picture && !element.textContent.trim()) return imageFrom(context)
|
|
20160
20160
|
return context.create({
|
|
20161
20161
|
type: "paragraph",
|
|
20162
20162
|
content: textValue(element, context)
|
|
@@ -20224,29 +20224,17 @@ ${fallback_html}`;
|
|
|
20224
20224
|
}
|
|
20225
20225
|
if (tag === "FIGURE") {
|
|
20226
20226
|
const picture = pictureIn(element);
|
|
20227
|
-
|
|
20228
|
-
if (picture) return imageFrom(picture, caption, element, context)
|
|
20227
|
+
if (picture) return imageFrom(context)
|
|
20229
20228
|
}
|
|
20230
20229
|
if (tag === "PICTURE") {
|
|
20231
|
-
return imageFrom(
|
|
20230
|
+
return imageFrom(context)
|
|
20232
20231
|
}
|
|
20233
20232
|
context.unrecognised.push(tag.toLowerCase() + (element.className ? `.${element.className}` : ""));
|
|
20234
20233
|
return null
|
|
20235
20234
|
}
|
|
20236
|
-
function imageFrom(
|
|
20237
|
-
|
|
20238
|
-
|
|
20239
|
-
if (!source) {
|
|
20240
|
-
context.unrecognised.push("picture (no data-original)");
|
|
20241
|
-
return null
|
|
20242
|
-
}
|
|
20243
|
-
return context.create({
|
|
20244
|
-
type: "image",
|
|
20245
|
-
source,
|
|
20246
|
-
alt: img ? (img.getAttribute("alt") || "") : "",
|
|
20247
|
-
caption: caption ? caption.textContent : "",
|
|
20248
|
-
html: outer.outerHTML
|
|
20249
|
-
})
|
|
20235
|
+
function imageFrom(context) {
|
|
20236
|
+
context.unrecognised.push("picture");
|
|
20237
|
+
return null
|
|
20250
20238
|
}
|
|
20251
20239
|
function ingest(contentElement, generateId) {
|
|
20252
20240
|
const nodes = {};
|
|
@@ -20989,28 +20977,42 @@ ${fallback_html}`;
|
|
|
20989
20977
|
}, {})
|
|
20990
20978
|
}
|
|
20991
20979
|
function naturalLine(element) {
|
|
20980
|
+
if (element.tagName === "FIGURE") return figureLine(element)
|
|
20992
20981
|
const property = element.getAttribute("itemprop");
|
|
20993
20982
|
if (property === "date") return element.textContent.trim()
|
|
20994
20983
|
if (property === "description") return `${element.textContent.trim()}\n\n---`
|
|
20995
20984
|
if (property !== "image") return null
|
|
20996
|
-
|
|
20997
|
-
const source = element.getAttribute("
|
|
20998
|
-
return source ? `` : null
|
|
20988
|
+
}
|
|
20989
|
+
function figureLine(figure) {
|
|
20990
|
+
const image = figure.querySelector(":scope > [itemprop=image]");
|
|
20991
|
+
const line = image ? naturalLine(image) : null;
|
|
20992
|
+
if (!line) return null
|
|
20993
|
+
const caption = figure.querySelector(":scope > figcaption")?.textContent.trim();
|
|
20994
|
+
return caption ? `${line} ${caption}` : line
|
|
20995
|
+
}
|
|
20996
|
+
function isNatural(child) {
|
|
20997
|
+
if ([...child.classList].some(name => name.startsWith("fm_"))) return false
|
|
20998
|
+
if (child.hasAttribute("itemprop")) return true
|
|
20999
|
+
return child.tagName === "FIGURE" && Boolean(child.querySelector(":scope > [itemprop=image]"))
|
|
20999
21000
|
}
|
|
21000
21001
|
function readFrontmatter(main) {
|
|
21001
21002
|
const children = [...main.children];
|
|
21002
21003
|
const declared = (key) => (child) => child.classList.contains(`fm_${key}`);
|
|
21003
21004
|
const heading = children.find(child => child.tagName === "H1");
|
|
21004
21005
|
const date = children.find(declared("date"));
|
|
21005
|
-
const image = children.find(declared("image"));
|
|
21006
21006
|
const description = children.find(declared("description"));
|
|
21007
|
-
const
|
|
21008
|
-
|
|
21007
|
+
const naturalChildren = children.filter(isNatural);
|
|
21008
|
+
const natural = naturalChildren
|
|
21009
21009
|
.map(naturalLine)
|
|
21010
21010
|
.filter(Boolean);
|
|
21011
|
+
const unrecognised = naturalChildren
|
|
21012
|
+
.filter(child => child.tagName === "PICTURE" || child.querySelector("picture"))
|
|
21013
|
+
.map(() => "picture");
|
|
21011
21014
|
const known = {
|
|
21012
21015
|
...(date ? { date: dateFrom(date) } : {}),
|
|
21013
|
-
...(image ? { image: image.getAttribute("data-original") } : {}),
|
|
21014
21016
|
...(description ? { description: description.textContent.trim() } : {})
|
|
21015
21017
|
};
|
|
21016
21018
|
const generic = children
|
|
@@ -21020,7 +21022,8 @@ ${fallback_html}`;
|
|
|
21020
21022
|
title: heading ? heading.textContent.trim() : null,
|
|
21021
21023
|
titleDeclared: Boolean(heading?.classList.contains("fm_title")),
|
|
21022
21024
|
properties: { ...known, ...generic },
|
|
21023
|
-
natural: natural.length ? `${natural.join("\n\n")}\n\n` : ""
|
|
21025
|
+
natural: natural.length ? `${natural.join("\n\n")}\n\n` : "",
|
|
21026
|
+
unrecognised
|
|
21024
21027
|
}
|
|
21025
21028
|
}
|
|
21026
21029
|
|
|
@@ -21044,7 +21047,10 @@ ${fallback_html}`;
|
|
|
21044
21047
|
socket.addEventListener('message', e => {
|
|
21045
21048
|
if (!isOpen()) return
|
|
21046
21049
|
const target = JSON.parse(e.data);
|
|
21047
|
-
if (target.extension !== ".html")
|
|
21050
|
+
if (target.extension !== ".html") {
|
|
21051
|
+
retryImages("/" + target.path);
|
|
21052
|
+
return
|
|
21053
|
+
}
|
|
21048
21054
|
const regex = new RegExp(window.location.pathname + "(index)?(\\.html)");
|
|
21049
21055
|
if (!("/" + target.path).match(regex)) return
|
|
21050
21056
|
updateFromTarget(target);
|
|
@@ -21131,6 +21137,19 @@ ${fallback_html}`;
|
|
|
21131
21137
|
if (!produced.has(keyOf(element)) && wasProduced(element)) element.remove();
|
|
21132
21138
|
}
|
|
21133
21139
|
}
|
|
21140
|
+
function retryImages(url) {
|
|
21141
|
+
const failed = [...document.images].filter(img => img.complete && img.naturalWidth === 0);
|
|
21142
|
+
const elements = failed.map(img => img.parentElement?.tagName === "PICTURE" ? img.parentElement : img);
|
|
21143
|
+
const naming = elements.filter(element => urlsOf(element).includes(url));
|
|
21144
|
+
for (const element of naming) element.replaceWith(element.cloneNode(true));
|
|
21145
|
+
}
|
|
21146
|
+
function urlsOf(element) {
|
|
21147
|
+
const nodes = [element, ...element.querySelectorAll("source, img")];
|
|
21148
|
+
return nodes.flatMap(node => {
|
|
21149
|
+
const candidates = (node.getAttribute("srcset") ?? "").split(",").map(candidate => candidate.trim().split(/\s+/)[0]);
|
|
21150
|
+
return [node.getAttribute("src"), ...candidates].filter(Boolean)
|
|
21151
|
+
})
|
|
21152
|
+
}
|
|
21134
21153
|
function wasProduced(element) {
|
|
21135
21154
|
if (element.tagName === "SCRIPT") return false
|
|
21136
21155
|
if (element.hasAttribute("data-vowel-client")) return false
|
|
@@ -21139,7 +21158,7 @@ ${fallback_html}`;
|
|
|
21139
21158
|
|
|
21140
21159
|
openSocket();
|
|
21141
21160
|
const content = document.getElementById("content");
|
|
21142
|
-
const noFrontmatter = { title: null, properties: {} };
|
|
21161
|
+
const noFrontmatter = { title: null, properties: {}, unrecognised: [] };
|
|
21143
21162
|
function startEditing() {
|
|
21144
21163
|
const { session, unrecognised } = create_session(content);
|
|
21145
21164
|
if (!session) {
|
|
@@ -21151,6 +21170,10 @@ ${fallback_html}`;
|
|
|
21151
21170
|
}
|
|
21152
21171
|
const main = content.closest("main");
|
|
21153
21172
|
const frontmatter = main ? readFrontmatter(main) : noFrontmatter;
|
|
21173
|
+
if (frontmatter.unrecognised.length) {
|
|
21174
|
+
console.warn("[vowel] editor stayed read-only: <main> holds natural metadata it can't write back:", frontmatter.unrecognised);
|
|
21175
|
+
return frontmatter.unrecognised
|
|
21176
|
+
}
|
|
21154
21177
|
content.replaceChildren();
|
|
21155
21178
|
mount(Editor, { target: content, props: { session, frontmatter } });
|
|
21156
21179
|
document.documentElement.dataset.vowelEditing = "true";
|
|
@@ -77,20 +77,49 @@ function objectFrom(list) {
|
|
|
77
77
|
* @returns {string|null}
|
|
78
78
|
*/
|
|
79
79
|
function naturalLine(element) {
|
|
80
|
+
if (element.tagName === "FIGURE") return figureLine(element)
|
|
80
81
|
const property = element.getAttribute("itemprop")
|
|
81
82
|
if (property === "date") return element.textContent.trim()
|
|
82
83
|
if (property === "description") return `${element.textContent.trim()}\n\n---`
|
|
83
84
|
if (property !== "image") return null
|
|
84
85
|
|
|
85
|
-
// A
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
86
|
+
// A plain <img> links the path as written. A <picture> names only its
|
|
87
|
+
// derivatives, so it can't be written back, and readFrontmatter
|
|
88
|
+
// reports it.
|
|
89
|
+
if (element.tagName !== "IMG") return null
|
|
90
|
+
const source = element.getAttribute("src")
|
|
91
|
+
return source ? `` : null
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* A captioned image, back as the image and its caption on one line. The
|
|
96
|
+
* caption's inline marks are flattened to its text, as a description's
|
|
97
|
+
* are and as ingest does for a captioned image in the content.
|
|
98
|
+
* @param {Element} figure
|
|
99
|
+
* @returns {string|null}
|
|
100
|
+
*/
|
|
101
|
+
function figureLine(figure) {
|
|
102
|
+
const image = figure.querySelector(":scope > [itemprop=image]")
|
|
103
|
+
const line = image ? naturalLine(image) : null
|
|
104
|
+
if (!line) return null
|
|
105
|
+
const caption = figure.querySelector(":scope > figcaption")?.textContent.trim()
|
|
106
|
+
return caption ? `${line} ${caption}` : line
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* A captioned image carries its itemprop on the image inside its
|
|
111
|
+
* <figure>, so the figure is natural metadata when that image is.
|
|
112
|
+
* @param {Element} child - of <main>
|
|
113
|
+
*/
|
|
114
|
+
function isNatural(child) {
|
|
115
|
+
if ([...child.classList].some(name => name.startsWith("fm_"))) return false
|
|
116
|
+
if (child.hasAttribute("itemprop")) return true
|
|
117
|
+
return child.tagName === "FIGURE" && Boolean(child.querySelector(":scope > [itemprop=image]"))
|
|
89
118
|
}
|
|
90
119
|
|
|
91
120
|
/**
|
|
92
121
|
* @param {Element} main
|
|
93
|
-
* @returns {{title: string|null, titleDeclared: boolean, properties: object, natural: string}}
|
|
122
|
+
* @returns {{title: string|null, titleDeclared: boolean, properties: object, natural: string, unrecognised: string[]}}
|
|
94
123
|
*/
|
|
95
124
|
export default function readFrontmatter(main) {
|
|
96
125
|
const children = [...main.children]
|
|
@@ -98,17 +127,21 @@ export default function readFrontmatter(main) {
|
|
|
98
127
|
|
|
99
128
|
const heading = children.find(child => child.tagName === "H1")
|
|
100
129
|
const date = children.find(declared("date"))
|
|
101
|
-
const image = children.find(declared("image"))
|
|
102
130
|
const description = children.find(declared("description"))
|
|
103
131
|
|
|
104
|
-
const
|
|
105
|
-
|
|
132
|
+
const naturalChildren = children.filter(isNatural)
|
|
133
|
+
const natural = naturalChildren
|
|
106
134
|
.map(naturalLine)
|
|
107
135
|
.filter(Boolean)
|
|
136
|
+
// A natural image that is a <picture> can't be written back
|
|
137
|
+
// (naturalLine), so the page stays read-only rather than losing the
|
|
138
|
+
// line on save.
|
|
139
|
+
const unrecognised = naturalChildren
|
|
140
|
+
.filter(child => child.tagName === "PICTURE" || child.querySelector("picture"))
|
|
141
|
+
.map(() => "picture")
|
|
108
142
|
|
|
109
143
|
const known = {
|
|
110
144
|
...(date ? { date: dateFrom(date) } : {}),
|
|
111
|
-
...(image ? { image: image.getAttribute("data-original") } : {}),
|
|
112
145
|
...(description ? { description: description.textContent.trim() } : {})
|
|
113
146
|
}
|
|
114
147
|
|
|
@@ -123,6 +156,7 @@ export default function readFrontmatter(main) {
|
|
|
123
156
|
title: heading ? heading.textContent.trim() : null,
|
|
124
157
|
titleDeclared: Boolean(heading?.classList.contains("fm_title")),
|
|
125
158
|
properties: { ...known, ...generic },
|
|
126
|
-
natural: natural.length ? `${natural.join("\n\n")}\n\n` : ""
|
|
159
|
+
natural: natural.length ? `${natural.join("\n\n")}\n\n` : "",
|
|
160
|
+
unrecognised
|
|
127
161
|
}
|
|
128
162
|
}
|
|
@@ -185,7 +185,7 @@ function blockFrom(element, context) {
|
|
|
185
185
|
// The image transform replaces an <img> in place, so a paragraph whose
|
|
186
186
|
// only element is a <picture> is an image block, not prose.
|
|
187
187
|
const picture = pictureIn(element)
|
|
188
|
-
if (picture && !element.textContent.trim()) return imageFrom(
|
|
188
|
+
if (picture && !element.textContent.trim()) return imageFrom(context)
|
|
189
189
|
|
|
190
190
|
return context.create({
|
|
191
191
|
type: "paragraph",
|
|
@@ -274,12 +274,11 @@ function blockFrom(element, context) {
|
|
|
274
274
|
|
|
275
275
|
if (tag === "FIGURE") {
|
|
276
276
|
const picture = pictureIn(element)
|
|
277
|
-
|
|
278
|
-
if (picture) return imageFrom(picture, caption, element, context)
|
|
277
|
+
if (picture) return imageFrom(context)
|
|
279
278
|
}
|
|
280
279
|
|
|
281
280
|
if (tag === "PICTURE") {
|
|
282
|
-
return imageFrom(
|
|
281
|
+
return imageFrom(context)
|
|
283
282
|
}
|
|
284
283
|
|
|
285
284
|
context.unrecognised.push(tag.toLowerCase() + (element.className ? `.${element.className}` : ""))
|
|
@@ -287,26 +286,14 @@ function blockFrom(element, context) {
|
|
|
287
286
|
}
|
|
288
287
|
|
|
289
288
|
/**
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
*
|
|
289
|
+
* An image can't be edited yet. Its <picture> names only the image's
|
|
290
|
+
* derivatives, and the path the author wrote is not in the page (per
|
|
291
|
+
* Sam, the output carries no data-original for the editor), so a save
|
|
292
|
+
* could only guess it. It is reported, which leaves the page read-only.
|
|
293
293
|
*/
|
|
294
|
-
function imageFrom(
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
if (!source) {
|
|
299
|
-
context.unrecognised.push("picture (no data-original)")
|
|
300
|
-
return null
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
return context.create({
|
|
304
|
-
type: "image",
|
|
305
|
-
source,
|
|
306
|
-
alt: img ? (img.getAttribute("alt") || "") : "",
|
|
307
|
-
caption: caption ? caption.textContent : "",
|
|
308
|
-
html: outer.outerHTML
|
|
309
|
-
})
|
|
294
|
+
function imageFrom(context) {
|
|
295
|
+
context.unrecognised.push("picture")
|
|
296
|
+
return null
|
|
310
297
|
}
|
|
311
298
|
|
|
312
299
|
/**
|
|
@@ -15,7 +15,7 @@ openSocket()
|
|
|
15
15
|
// document's source of truth (see ingest.js).
|
|
16
16
|
const content = document.getElementById("content")
|
|
17
17
|
|
|
18
|
-
const noFrontmatter = { title: null, properties: {} }
|
|
18
|
+
const noFrontmatter = { title: null, properties: {}, unrecognised: [] }
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* Swaps the server-rendered body for the editor. A previewed page runs
|
|
@@ -50,6 +50,10 @@ function startEditing() {
|
|
|
50
50
|
// them is what lets a save reproduce the whole file.
|
|
51
51
|
const main = content.closest("main")
|
|
52
52
|
const frontmatter = main ? readFrontmatter(main) : noFrontmatter
|
|
53
|
+
if (frontmatter.unrecognised.length) {
|
|
54
|
+
console.warn("[vowel] editor stayed read-only: <main> holds natural metadata it can't write back:", frontmatter.unrecognised)
|
|
55
|
+
return frontmatter.unrecognised
|
|
56
|
+
}
|
|
53
57
|
|
|
54
58
|
// Svedit renders the document itself rather than hydrating over the
|
|
55
59
|
// server output, so the original children go before it mounts.
|
|
@@ -51,10 +51,12 @@ export default function openSocket() {
|
|
|
51
51
|
|
|
52
52
|
const target = JSON.parse(e.data)
|
|
53
53
|
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
|
|
57
|
-
|
|
54
|
+
// A file that isn't a page can only matter to this page as something
|
|
55
|
+
// it loads: an image the page named before its file existed.
|
|
56
|
+
if (target.extension !== ".html") {
|
|
57
|
+
retryImages("/" + target.path)
|
|
58
|
+
return
|
|
59
|
+
}
|
|
58
60
|
|
|
59
61
|
const regex = new RegExp(window.location.pathname + "(index)?(\\.html)")
|
|
60
62
|
if (!("/" + target.path).match(regex)) return
|
|
@@ -185,6 +187,34 @@ function patchBody(body, nextBody) {
|
|
|
185
187
|
}
|
|
186
188
|
}
|
|
187
189
|
|
|
190
|
+
/**
|
|
191
|
+
* A page can arrive before the image files it names: an image's
|
|
192
|
+
* derivatives are encoded after the page is written, and a browser never
|
|
193
|
+
* asks again for an image that failed. When a file lands, every image
|
|
194
|
+
* that failed and names it is replaced with a copy of itself, which
|
|
195
|
+
* asks again. A <picture> is copied whole, so its <source>s are chosen
|
|
196
|
+
* from again too.
|
|
197
|
+
* @param {string} url - the file's url, from the root
|
|
198
|
+
*/
|
|
199
|
+
function retryImages(url) {
|
|
200
|
+
const failed = [...document.images].filter(img => img.complete && img.naturalWidth === 0)
|
|
201
|
+
const elements = failed.map(img => img.parentElement?.tagName === "PICTURE" ? img.parentElement : img)
|
|
202
|
+
const naming = elements.filter(element => urlsOf(element).includes(url))
|
|
203
|
+
for (const element of naming) element.replaceWith(element.cloneNode(true))
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Every url an <img> or a <picture> could load.
|
|
208
|
+
* @param {Element} element
|
|
209
|
+
*/
|
|
210
|
+
function urlsOf(element) {
|
|
211
|
+
const nodes = [element, ...element.querySelectorAll("source, img")]
|
|
212
|
+
return nodes.flatMap(node => {
|
|
213
|
+
const candidates = (node.getAttribute("srcset") ?? "").split(",").map(candidate => candidate.trim().split(/\s+/)[0])
|
|
214
|
+
return [node.getAttribute("src"), ...candidates].filter(Boolean)
|
|
215
|
+
})
|
|
216
|
+
}
|
|
217
|
+
|
|
188
218
|
/**
|
|
189
219
|
* The client adds elements that aren't the build's: the editor's own
|
|
190
220
|
* <script type="module">, and whatever Svelte mounts into the body (the
|
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 } 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).
|
|
@@ -545,7 +556,9 @@ function writeFile(target, { settings, api, config }) {
|
|
|
545
556
|
|
|
546
557
|
// The page's own image, else the site's `fallback_image` (a setting,
|
|
547
558
|
// so a folder can have its own), so a link preview always has one.
|
|
548
|
-
|
|
559
|
+
// The largest derivative of a png or jpeg (image.js): the original
|
|
560
|
+
// is not published.
|
|
561
|
+
const ogImage = imageURL(metadata.image || settings.lastNonNull("fm_fallback_image"), api, imageSizes.at(-1))
|
|
549
562
|
if (ogImage) {
|
|
550
563
|
treeHead.children.push(h("meta", {
|
|
551
564
|
property: "og:image",
|
|
@@ -568,7 +581,7 @@ function writeFile(target, { settings, api, config }) {
|
|
|
568
581
|
// right one lets it pick between several. Without one, the project's
|
|
569
582
|
// own favicon.png, else Vowel's placeholder (plugins/images). A
|
|
570
583
|
// tracked read, so a favicon.png that appears later is linked.
|
|
571
|
-
const icon = settings.lastNonNull("fm_icon")
|
|
584
|
+
const icon = imageURL(settings.lastNonNull("fm_icon"), api, imageSizes[0])
|
|
572
585
|
?? (api.target("favicon.png") ? "/favicon.png" : "/favicon-placeholder.png")
|
|
573
586
|
treeHead.children.push(h("link", {
|
|
574
587
|
href: icon,
|
|
@@ -609,8 +622,10 @@ function writeFile(target, { settings, api, config }) {
|
|
|
609
622
|
const homeLink = []
|
|
610
623
|
|
|
611
624
|
|
|
612
|
-
|
|
613
|
-
|
|
625
|
+
// The smallest derivative of a png or jpeg (image.js): the original
|
|
626
|
+
// is not published. An SVG is linked as it is.
|
|
627
|
+
const logo = imageURL(settings.lastNonNull("logo"), api, imageSizes[0])
|
|
628
|
+
const wordmark = imageURL(settings.lastNonNull("fm_wordmark"), api, imageSizes[0])
|
|
614
629
|
|
|
615
630
|
const identityElements = []
|
|
616
631
|
|
|
@@ -647,7 +662,7 @@ function writeFile(target, { settings, api, config }) {
|
|
|
647
662
|
const treeHeader = h('header', [
|
|
648
663
|
identity,
|
|
649
664
|
treeNav,
|
|
650
|
-
socialLinksNav(settings)
|
|
665
|
+
socialLinksNav(settings, api)
|
|
651
666
|
])
|
|
652
667
|
|
|
653
668
|
|
|
@@ -699,7 +714,9 @@ function writeFile(target, { settings, api, config }) {
|
|
|
699
714
|
|
|
700
715
|
const treeMain = h('main',
|
|
701
716
|
{
|
|
702
|
-
itemscope: true
|
|
717
|
+
itemscope: true,
|
|
718
|
+
// The page image's dominant colour, for anything in <main> to use.
|
|
719
|
+
style: dominantStyle(metadata.image, api)
|
|
703
720
|
},
|
|
704
721
|
[
|
|
705
722
|
// The title is main's first child: the one piece of data hoisted out
|
|
@@ -723,7 +740,7 @@ function writeFile(target, { settings, api, config }) {
|
|
|
723
740
|
const treeAside = asideNav ? h("aside", asideNav) : null
|
|
724
741
|
|
|
725
742
|
const treeFooter = h('footer', [
|
|
726
|
-
socialLinksNav(settings),
|
|
743
|
+
socialLinksNav(settings, api),
|
|
727
744
|
h('section#copyright', `© ${new Date().getFullYear()}`),
|
|
728
745
|
h('section#shoutout', [
|
|
729
746
|
"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,7 +16,7 @@ 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"
|
|
@@ -63,7 +63,9 @@ function readFile(source, { api, config }) {
|
|
|
63
63
|
if (!metadata.image) {
|
|
64
64
|
const firstImageParagraph = mdast.children.find(child => child.children && child.children[0].type === "image")
|
|
65
65
|
if (firstImageParagraph) {
|
|
66
|
-
|
|
66
|
+
// A card shows it from another folder's page, so from the root.
|
|
67
|
+
const folder = path.dirname(targetPath ?? filePath).replace(/^\.$/, "")
|
|
68
|
+
metadata.first_image = imageFromPage(firstImageParagraph.children[0].url, folder)
|
|
67
69
|
}
|
|
68
70
|
}
|
|
69
71
|
|
|
@@ -6,6 +6,49 @@ import { displayPath } from "../../secretPaths.js"
|
|
|
6
6
|
import pathDate from "../../pathDate.js"
|
|
7
7
|
import { coerce, parseFrontmatter, toISODate } from "./frontmatter.js"
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* An HTML comment, or several in a row with nothing else between them.
|
|
11
|
+
* It stays in the page, but metadata never reads it (per Sam): a
|
|
12
|
+
* comment is not a title, a date or a description, and it doesn't take
|
|
13
|
+
* a block's place in the scan for one.
|
|
14
|
+
* @param {any} node
|
|
15
|
+
*/
|
|
16
|
+
function isComment(node) {
|
|
17
|
+
return node.type === "html" && /^\s*(?:<!--[\s\S]*?-->\s*)+$/.test(node.value)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A node's text for metadata: mdastToString with its HTML comments left
|
|
22
|
+
* out, at any depth. Where a comment sat between two words, the spaces
|
|
23
|
+
* either side of it close up to one.
|
|
24
|
+
* @param {any} node
|
|
25
|
+
* @returns {string}
|
|
26
|
+
*/
|
|
27
|
+
function metadataText(node) {
|
|
28
|
+
if (isComment(node)) return ""
|
|
29
|
+
if (!node.children) return mdastToString(node)
|
|
30
|
+
const text = node.children.map(metadataText).join("")
|
|
31
|
+
if (!node.children.some(isComment)) return text
|
|
32
|
+
return text.replace(/[ \t]{2,}/g, " ")
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* An image the page's text names, as the browser resolves it from the
|
|
37
|
+
* page: `pic.jpg` and `./pic.jpg` are the page's folder's, `../pic.jpg`
|
|
38
|
+
* its parent's, `/pic.jpg` the root's. Stored as a path from the root,
|
|
39
|
+
* the way the html write resolves the same image (`fromPage` in
|
|
40
|
+
* writeRules.js), because a listing card and `og:image` read it from
|
|
41
|
+
* pages in other folders. An external url is left as written. (A
|
|
42
|
+
* frontmatter `image:` is a setting-style path instead: `resolvePath`.)
|
|
43
|
+
* @param {string} url - as written
|
|
44
|
+
* @param {string} folder - the page's target folder, project-relative ("" at the root)
|
|
45
|
+
*/
|
|
46
|
+
export function imageFromPage(url, folder) {
|
|
47
|
+
if (/^[a-z][a-z\d+.-]*:/i.test(url) || url.startsWith("//")) return url
|
|
48
|
+
const base = new URL(folder ? `/${folder.split(path.sep).join("/")}/` : "/", "thismessage://")
|
|
49
|
+
return decodeURIComponent(new URL(url.replaceAll("#", "%23"), base).pathname)
|
|
50
|
+
}
|
|
51
|
+
|
|
9
52
|
|
|
10
53
|
/**
|
|
11
54
|
- menu_item: true | false
|
|
@@ -50,7 +93,7 @@ export const reservedProperties = [
|
|
|
50
93
|
* @param {object} tree
|
|
51
94
|
*/
|
|
52
95
|
export function findTitleNode(tree) {
|
|
53
|
-
const [first] = tree.children.filter(child => child.type !== "yaml")
|
|
96
|
+
const [first] = tree.children.filter(child => child.type !== "yaml" && !isComment(child))
|
|
54
97
|
if (!first) return null
|
|
55
98
|
if (first.type !== "heading" || first.depth !== 1) return null
|
|
56
99
|
return first
|
|
@@ -101,24 +144,29 @@ function markAsTime(paragraph, date) {
|
|
|
101
144
|
*
|
|
102
145
|
* @param {object} block
|
|
103
146
|
* @param {object} metadata
|
|
147
|
+
* @param {string} folder - the page's target folder, for a relative image
|
|
104
148
|
* @returns {boolean} whether the block was recognizable data
|
|
105
149
|
*/
|
|
106
|
-
function recognizeData(block, metadata) {
|
|
150
|
+
function recognizeData(block, metadata, folder) {
|
|
107
151
|
if (block.type !== "paragraph") return false
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
const [child] = block.children
|
|
152
|
+
const children = block.children.filter(child => !isComment(child))
|
|
153
|
+
const [child] = children
|
|
111
154
|
|
|
112
155
|
const imageFilled = Boolean(metadata.fm_image || metadata.inferred_image)
|
|
113
156
|
|
|
114
|
-
|
|
157
|
+
// An image, alone or with its caption after it on the same line: the
|
|
158
|
+
// html write makes the pair a <figure> and lifts it whole. The caption
|
|
159
|
+
// is not a property of the page.
|
|
160
|
+
if (child?.type === "image") {
|
|
115
161
|
if (imageFilled) return false
|
|
116
|
-
metadata.inferred_image = child.url
|
|
162
|
+
metadata.inferred_image = imageFromPage(child.url, folder)
|
|
117
163
|
metadata.inferred_alt_text = child.alt
|
|
118
164
|
return "image"
|
|
119
165
|
}
|
|
120
166
|
|
|
121
|
-
|
|
167
|
+
if (children.length !== 1) return false
|
|
168
|
+
|
|
169
|
+
const text = metadataText(block)
|
|
122
170
|
|
|
123
171
|
// A reference or a listing directive (`/blog/post`, `/blog/**`,
|
|
124
172
|
// `./drafts/*`) is an instruction to the html plugin, not prose - it
|
|
@@ -227,16 +275,16 @@ function getMetadata(tree, filePath, targetPath, report = () => {}) {
|
|
|
227
275
|
|
|
228
276
|
const titleNode = findTitleNode(tree)
|
|
229
277
|
if (titleNode) {
|
|
230
|
-
metadata.inferred_title =
|
|
278
|
+
metadata.inferred_title = metadataText(titleNode).trim() || labelFromName(path.parse(filePath).name)
|
|
231
279
|
}
|
|
232
280
|
|
|
233
281
|
// Everything up to the first block that is not recognizable data is
|
|
234
282
|
// metadata; that first block is where the content begins, and it also
|
|
235
283
|
// supplies the description.
|
|
236
|
-
const blocks = tree.children.filter(child => child.type !== "yaml" && child !== titleNode)
|
|
284
|
+
const blocks = tree.children.filter(child => child.type !== "yaml" && child !== titleNode && !isComment(child))
|
|
237
285
|
|
|
238
286
|
for (const [index, block] of blocks.entries()) {
|
|
239
|
-
const recognized = recognizeData(block, metadata)
|
|
287
|
+
const recognized = recognizeData(block, metadata, folder)
|
|
240
288
|
if (recognized === "skip") continue
|
|
241
289
|
if (recognized) {
|
|
242
290
|
markAsNatural(block, recognized)
|
|
@@ -251,13 +299,13 @@ function getMetadata(tree, filePath, targetPath, report = () => {}) {
|
|
|
251
299
|
// markdown, not a paragraph and a rule; it takes a blank line.)
|
|
252
300
|
const next = blocks[index + 1]
|
|
253
301
|
if (!metadata.fm_description && block.type === "paragraph" && next?.type === "thematicBreak") {
|
|
254
|
-
metadata.declared_description =
|
|
302
|
+
metadata.declared_description = metadataText(block).trim()
|
|
255
303
|
markAsNatural(block, "description")
|
|
256
304
|
markAsNatural(next, "end")
|
|
257
305
|
break
|
|
258
306
|
}
|
|
259
307
|
|
|
260
|
-
if (!metadata.fm_description) metadata.inferred_description =
|
|
308
|
+
if (!metadata.fm_description) metadata.inferred_description = metadataText(block).trim()
|
|
261
309
|
break
|
|
262
310
|
}
|
|
263
311
|
|
|
@@ -311,6 +311,7 @@
|
|
|
311
311
|
ul {
|
|
312
312
|
list-style: none;
|
|
313
313
|
display: flex;
|
|
314
|
+
flex-wrap: wrap;
|
|
314
315
|
gap: 2ch;
|
|
315
316
|
}
|
|
316
317
|
|
|
@@ -438,6 +439,11 @@
|
|
|
438
439
|
}
|
|
439
440
|
}
|
|
440
441
|
|
|
442
|
+
/* the page's own image, beside its title, as well as the content's */
|
|
443
|
+
img {
|
|
444
|
+
border-radius: var(--borderradius-tight);
|
|
445
|
+
}
|
|
446
|
+
|
|
441
447
|
/* content */
|
|
442
448
|
|
|
443
449
|
section#content {
|
|
@@ -454,10 +460,6 @@
|
|
|
454
460
|
}
|
|
455
461
|
}
|
|
456
462
|
|
|
457
|
-
img {
|
|
458
|
-
border-radius: var(--borderradius-tight);
|
|
459
|
-
}
|
|
460
|
-
|
|
461
463
|
mark,
|
|
462
464
|
code {
|
|
463
465
|
padding: 0.1cap 0.5ch;
|
|
@@ -334,15 +334,15 @@
|
|
|
334
334
|
&>section {
|
|
335
335
|
margin-top: 1lh;
|
|
336
336
|
}
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
/* content */
|
|
340
|
-
section {
|
|
341
337
|
|
|
342
|
-
/* image caption space */
|
|
338
|
+
/* image caption space: the page's own captioned image, beside its title, as well as the content's */
|
|
343
339
|
figure {
|
|
344
340
|
gap: 1lh;
|
|
345
341
|
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/* content */
|
|
345
|
+
section {
|
|
346
346
|
|
|
347
347
|
/* list spacing */
|
|
348
348
|
ul,
|
package/utils.js
CHANGED
|
@@ -23,26 +23,6 @@ export function createImagePath({ targetFilePath, size, ext, targetDirectory, uu
|
|
|
23
23
|
})
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
/**
|
|
27
|
-
* @param {string} targetFilePath
|
|
28
|
-
* @param {string} targetDirectory
|
|
29
|
-
* @param {string} uuid
|
|
30
|
-
*/
|
|
31
|
-
export function createImagePaths(targetFilePath, targetDirectory, uuid) {
|
|
32
|
-
const defaultExt = path.extname(targetFilePath)
|
|
33
|
-
return [...imageExts, defaultExt].map(ext => {
|
|
34
|
-
return imageSizes.map(size => {
|
|
35
|
-
return createImagePath({
|
|
36
|
-
targetFilePath,
|
|
37
|
-
size,
|
|
38
|
-
ext,
|
|
39
|
-
targetDirectory,
|
|
40
|
-
uuid
|
|
41
|
-
})
|
|
42
|
-
})
|
|
43
|
-
})
|
|
44
|
-
}
|
|
45
|
-
|
|
46
26
|
/** @param {string} text */
|
|
47
27
|
export function testURL(text) {
|
|
48
28
|
if (text.match(/^https?:\/\/[\S]+$/)) {
|