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/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.4",
|
|
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
|
@@ -5,6 +5,7 @@ import { gfmFootnoteToMarkdown } from "mdast-util-gfm-footnote"
|
|
|
5
5
|
import { gfmTaskListItemToMarkdown } from "mdast-util-gfm-task-list-item"
|
|
6
6
|
import { toText } from "hast-util-to-text"
|
|
7
7
|
import { emojiFor } from "../markdown/emoji.js"
|
|
8
|
+
import { alertLabel } from "../html/editor/directives.js"
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* A page's body as a record carries it, from the rendered
|
|
@@ -17,7 +18,8 @@ import { emojiFor } from "../markdown/emoji.js"
|
|
|
17
18
|
* and footnotes vowel itself reads. Links and images are absolute; an
|
|
18
19
|
* image links to its copy on the site. An icon is its emoji, or its
|
|
19
20
|
* shortcode as text. A listing is a list of links. A hashtag is a link
|
|
20
|
-
* whose text keeps its `#`.
|
|
21
|
+
* whose text keeps its `#`. An alert is GFM's (`> [!NOTE]`). No
|
|
22
|
+
* title: it is outside the section.
|
|
21
23
|
* - **Rich text** (`richTextBody`), for a `text` field with `facets`
|
|
22
24
|
* beside it: plain text, and a facet for each link, hashtag and mark
|
|
23
25
|
* the lexicon has a feature for. The rest is dropped, images included.
|
|
@@ -113,7 +115,9 @@ function phrase(node, base) {
|
|
|
113
115
|
}
|
|
114
116
|
if (tag === "picture") {
|
|
115
117
|
const img = (node.children ?? []).find(child => is(child, "img"))
|
|
116
|
-
|
|
118
|
+
// The <img>'s src is the largest derivative in the image's own
|
|
119
|
+
// format; the original is not published (plugins/images).
|
|
120
|
+
const src = img?.properties?.src
|
|
117
121
|
return src ? [{ type: "image", url: absolute(String(src), base), alt: String(img?.properties?.alt ?? "") }] : []
|
|
118
122
|
}
|
|
119
123
|
if (tag === "a") {
|
|
@@ -179,7 +183,8 @@ function block(node, base) {
|
|
|
179
183
|
if (/^h[1-6]$/.test(tag)) return [{ type: "heading", depth: Number(tag[1]), children: trimPhrasing(phrasing(node.children ?? [], base)) }]
|
|
180
184
|
if (tag === "hr") return [{ type: "thematicBreak" }]
|
|
181
185
|
if (tag === "picture") return [paragraph([node], base)]
|
|
182
|
-
if (tag === "
|
|
186
|
+
if (tag === "aside" && hasClass(node, "alert")) return [alert(node, base)]
|
|
187
|
+
if (tag === "blockquote") return [{ type: "blockquote", children: blocks(node.children ?? [], base) }]
|
|
183
188
|
if (tag === "pre") {
|
|
184
189
|
const code = (node.children ?? []).find(child => is(child, "code")) ?? node
|
|
185
190
|
const language = classes(code).find(name => name.startsWith("language-"))?.slice("language-".length)
|
|
@@ -191,6 +196,30 @@ function block(node, base) {
|
|
|
191
196
|
return blocks(node.children ?? [], base)
|
|
192
197
|
}
|
|
193
198
|
|
|
199
|
+
/**
|
|
200
|
+
* An alert, `<aside class="alert note">` with its heading first, back in
|
|
201
|
+
* GFM's form: `> [!NOTE]`, then the text on the next line. A heading the
|
|
202
|
+
* author wrote stays on the marker's line (`> [!NOTE] How to render`,
|
|
203
|
+
* Obsidian's title, which vowel reads too); the default one, the type in
|
|
204
|
+
* sentence case, is left off. The marker is raw `html` so that it is
|
|
205
|
+
* not escaped as a link reference.
|
|
206
|
+
* @param {any} node
|
|
207
|
+
* @param {string} base
|
|
208
|
+
*/
|
|
209
|
+
function alert(node, base) {
|
|
210
|
+
const type = classes(node).find(name => name !== "alert") ?? "note"
|
|
211
|
+
const [first, ...rest] = (node.children ?? []).filter(child => child.type === "element")
|
|
212
|
+
const hasHeading = /^h[1-6]$/.test(first?.tagName ?? "")
|
|
213
|
+
const title = hasHeading && toText(first).trim() !== alertLabel(type) ? trimPhrasing(phrasing(first.children ?? [], base)) : []
|
|
214
|
+
const marker = [{ type: "html", value: `[!${type.toUpperCase()}]` }, ...(title.length ? [{ type: "text", value: " " }, ...title] : [])]
|
|
215
|
+
const body = blocks(hasHeading ? rest : [first, ...rest].filter(child => child), base)
|
|
216
|
+
const [lead, ...after] = body
|
|
217
|
+
const children = lead?.type === "paragraph"
|
|
218
|
+
? [{ ...lead, children: [...marker, { type: "text", value: "\n" }, ...lead.children] }, ...after]
|
|
219
|
+
: [{ type: "paragraph", children: marker }, ...body]
|
|
220
|
+
return { type: "blockquote", children }
|
|
221
|
+
}
|
|
222
|
+
|
|
194
223
|
/** @param {any} node @param {string} base */
|
|
195
224
|
function list(node, base) {
|
|
196
225
|
const items = (node.children ?? []).filter(child => is(child, "li"))
|
|
@@ -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
|