vowel 0.7.0-beta.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +41 -239
- package/bin.js +73 -31
- package/cli.js +32 -0
- package/config.js +14 -0
- package/extractDate.js +2 -2
- package/index.js +6 -1
- package/package.json +9 -34
- package/pathDate.js +69 -0
- package/plugins/assets/index.js +1 -1
- package/plugins/atproto/body.js +413 -0
- package/plugins/atproto/index.js +10 -44
- package/plugins/atproto/lexicons.js +179 -0
- package/plugins/atproto/mapper.js +399 -0
- package/plugins/atproto/publish.js +195 -105
- package/plugins/atproto/record.js +57 -68
- package/plugins/deploy-cloudflare/index.js +4 -1
- package/plugins/export/index.js +131 -0
- package/plugins/html/bundle/index.js +57 -23
- package/plugins/html/editor/Editor.svelte +9 -5
- package/plugins/html/editor/create-session.js +3 -1
- package/plugins/html/editor/directives.js +12 -0
- package/plugins/html/editor/frontmatter.js +42 -11
- package/plugins/html/editor/ingest.js +19 -8
- package/plugins/html/editor/serialize.js +8 -4
- package/plugins/html/image.js +11 -5
- package/plugins/html/index.js +217 -100
- package/plugins/html/partials.js +14 -8
- package/plugins/html/socialLinks.js +5 -8
- package/plugins/html/writeRules.js +197 -37
- package/plugins/images/favicon.png +0 -0
- package/plugins/images/index.js +41 -3
- package/plugins/markdown/colorScheme.js +70 -14
- package/plugins/markdown/frontmatter.js +32 -6
- package/plugins/markdown/index.js +105 -20
- package/plugins/markdown/metadata.js +74 -22
- package/plugins/markdown/typography.js +11 -11
- package/plugins/secret-pages/index.js +75 -0
- package/plugins/styles/index.js +4 -4
- package/plugins/xml/entryContent.js +22 -11
- package/plugins/xml/index.js +19 -11
- package/stylesheets/DefaultStyles.css +198 -111
- package/stylesheets/ResetStyles.css +2 -1
- package/stylesheets/TypographyStyles.css +153 -74
- package/utils.js +74 -1
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { cp, readdir, stat } from "node:fs/promises"
|
|
2
|
+
import { realpathSync } from "node:fs"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
import votive from "votive"
|
|
5
|
+
import { systemDirectoryFor } from "../../systemPaths.js"
|
|
6
|
+
|
|
7
|
+
/** @import * as Votive from "votive" */
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `vowel export <folder>`: build the site and copy it to a folder of
|
|
11
|
+
* your choosing - to upload anywhere, archive, or hand to someone. The
|
|
12
|
+
* same command a desktop "Export" button would run
|
|
13
|
+
* (`vowel --command export --payload '{"path": "…"}'`).
|
|
14
|
+
*
|
|
15
|
+
* It copies the output folder whole. If the output ever holds files it
|
|
16
|
+
* shouldn't, that is the output's problem to fix, not the export's.
|
|
17
|
+
*
|
|
18
|
+
* **Where it won't write**, checked before anything is built, on real
|
|
19
|
+
* paths so a symlink can't hide the answer:
|
|
20
|
+
* - inside the project: the next build would read the export as content,
|
|
21
|
+
* and each export would nest inside the next
|
|
22
|
+
* - a folder that contains the project, or is it
|
|
23
|
+
* - vowel's own system folder (the output, the database, the cache)
|
|
24
|
+
* - a folder that isn't empty: nothing an export writes should mix with,
|
|
25
|
+
* or silently replace, what's already there
|
|
26
|
+
*
|
|
27
|
+
* A destination that doesn't exist yet is created.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* `child` is `parent` or somewhere inside it.
|
|
32
|
+
* @param {string} child
|
|
33
|
+
* @param {string} parent
|
|
34
|
+
*/
|
|
35
|
+
function within(child, parent) {
|
|
36
|
+
const relative = path.relative(parent, child)
|
|
37
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A path made real as far as it exists: the deepest existing ancestor
|
|
42
|
+
* through realpath (symlinks resolved, and on a case-insensitive disk
|
|
43
|
+
* its true case), then the segments that don't exist yet.
|
|
44
|
+
* @param {string} absolute
|
|
45
|
+
* @returns {string}
|
|
46
|
+
*/
|
|
47
|
+
function realAsFarAsItExists(absolute) {
|
|
48
|
+
try {
|
|
49
|
+
return realpathSync.native(absolute)
|
|
50
|
+
} catch {
|
|
51
|
+
const parent = path.dirname(absolute)
|
|
52
|
+
if (parent === absolute) return absolute
|
|
53
|
+
return path.join(realAsFarAsItExists(parent), path.basename(absolute))
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Where an export may go, or why it may not, as a sentence. Checks the
|
|
59
|
+
* places only; whether the folder is empty is `checkEmpty`'s.
|
|
60
|
+
* @param {string} destination - as given; relative to the working directory
|
|
61
|
+
* @param {{ sourceFolder: string, targetFolder: string }} config
|
|
62
|
+
* @returns {{ path: string } | { refused: string }}
|
|
63
|
+
*/
|
|
64
|
+
function checkDestination(destination, config) {
|
|
65
|
+
if (typeof destination !== "string" || !destination.trim()) {
|
|
66
|
+
return { refused: "say where to export to, e.g. `vowel export ../my-site`" }
|
|
67
|
+
}
|
|
68
|
+
const real = realAsFarAsItExists(path.resolve(destination))
|
|
69
|
+
const source = realAsFarAsItExists(config.sourceFolder)
|
|
70
|
+
const system = realAsFarAsItExists(systemDirectoryFor(config.sourceFolder))
|
|
71
|
+
const output = realAsFarAsItExists(config.targetFolder)
|
|
72
|
+
|
|
73
|
+
if (within(real, source)) {
|
|
74
|
+
return { refused: `${destination} is inside the project (${source}); the next build would read the export as part of the site. Export somewhere outside it.` }
|
|
75
|
+
}
|
|
76
|
+
if (within(source, real)) {
|
|
77
|
+
return { refused: `${destination} contains the project (${source}). Export to a folder of its own.` }
|
|
78
|
+
}
|
|
79
|
+
if (within(real, system) || within(real, output) || within(output, real)) {
|
|
80
|
+
return { refused: `${destination} is where vowel keeps its own build (${system}). Export somewhere else.` }
|
|
81
|
+
}
|
|
82
|
+
return { path: real }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @param {string} folder - real, absolute
|
|
87
|
+
* @param {string} destination - as given, for the message
|
|
88
|
+
* @returns {Promise<string | undefined>} why it can't be used, if it can't
|
|
89
|
+
*/
|
|
90
|
+
async function checkEmpty(folder, destination) {
|
|
91
|
+
const info = await stat(folder).catch(() => undefined)
|
|
92
|
+
if (!info) return undefined
|
|
93
|
+
if (!info.isDirectory()) return `${destination} is a file, not a folder.`
|
|
94
|
+
const entries = await readdir(folder)
|
|
95
|
+
if (entries.length) return `${destination} isn't empty. Export to a new or empty folder.`
|
|
96
|
+
return undefined
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @param {{ path?: string } | undefined} payload
|
|
101
|
+
* @param {{ config: Votive.VotiveConfig, notify: (message: object) => void }} context
|
|
102
|
+
*/
|
|
103
|
+
async function exportSite(payload, { config, notify }) {
|
|
104
|
+
const checked = checkDestination(payload?.path, config)
|
|
105
|
+
if ("refused" in checked) throw new Error(`export: ${checked.refused}`)
|
|
106
|
+
const notEmpty = await checkEmpty(checked.path, payload.path)
|
|
107
|
+
if (notEmpty) throw new Error(`export: ${notEmpty}`)
|
|
108
|
+
|
|
109
|
+
notify({ status: "progress", message: "Building..." })
|
|
110
|
+
// As deploy does: everything finished, deferred work (image
|
|
111
|
+
// derivatives, link previews) included, and the database closed.
|
|
112
|
+
const site = await votive({ ...config, verbose: false })
|
|
113
|
+
await (await site.build()).deferred
|
|
114
|
+
await site.close()
|
|
115
|
+
|
|
116
|
+
notify({ status: "progress", message: `Copying to ${checked.path}...` })
|
|
117
|
+
await cp(config.targetFolder, checked.path, { recursive: true })
|
|
118
|
+
const files = await readdir(checked.path, { recursive: true, withFileTypes: true })
|
|
119
|
+
const count = files.filter(entry => entry.isFile()).length
|
|
120
|
+
notify({ status: "done", message: `Exported ${count} files to ${checked.path}` })
|
|
121
|
+
return { exported: checked.path, files: count }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** @type {Votive.VotivePlugin} */
|
|
125
|
+
const vowelExportPlugin = {
|
|
126
|
+
name: "vowel-export",
|
|
127
|
+
commands: { export: exportSite }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export default vowelExportPlugin
|
|
131
|
+
export { exportSite, checkDestination, within }
|
|
@@ -18761,9 +18761,10 @@ ${fallback_html}`;
|
|
|
18761
18761
|
return { type: "blockquote", children: node.body.nodes.map(child => blockFrom$1(child, nodes)) }
|
|
18762
18762
|
}
|
|
18763
18763
|
if (node.type === "alert") {
|
|
18764
|
+
const title = node.title ? ` ${node.title}` : "";
|
|
18764
18765
|
const marker = {
|
|
18765
18766
|
type: "html",
|
|
18766
|
-
value: `[!${(node.variant || "note").toUpperCase()}]`
|
|
18767
|
+
value: `[!${(node.variant || "note").toUpperCase()}]${title}`
|
|
18767
18768
|
};
|
|
18768
18769
|
const body = node.body.nodes.map(child => blockFrom$1(child, nodes));
|
|
18769
18770
|
return { type: "blockquote", children: [marker, ...body] }
|
|
@@ -18798,11 +18799,12 @@ ${fallback_html}`;
|
|
|
18798
18799
|
rule: "-"
|
|
18799
18800
|
});
|
|
18800
18801
|
const heading = frontmatter.title ? `# ${frontmatter.title}\n\n` : "";
|
|
18802
|
+
const natural = frontmatter.natural || "";
|
|
18801
18803
|
const properties = frontmatter.properties || {};
|
|
18802
18804
|
const yaml = Object.keys(properties).length
|
|
18803
18805
|
? `---\n${stringify(properties)}---\n\n`
|
|
18804
18806
|
: "";
|
|
18805
|
-
return yaml + heading + body
|
|
18807
|
+
return yaml + heading + natural + body
|
|
18806
18808
|
}
|
|
18807
18809
|
|
|
18808
18810
|
const divider = ($$anchor) => {
|
|
@@ -19533,9 +19535,11 @@ ${fallback_html}`;
|
|
|
19533
19535
|
// socket pushes, so it is read at save time rather than when the
|
|
19534
19536
|
// editor opened. Taking the frontmatter from the source rather than
|
|
19535
19537
|
// the rendered page is what lets a key that never renders survive a
|
|
19536
|
-
// save.
|
|
19537
|
-
//
|
|
19538
|
-
//
|
|
19538
|
+
// save. A title declared in the frontmatter (`h1.fm_title`) stays a
|
|
19539
|
+
// `title:` key; one from a `#` heading is written back as one. The
|
|
19540
|
+
// natural metadata - a date line, an image, a description and its
|
|
19541
|
+
// rule, lifted out of the content when the page was built - goes back
|
|
19542
|
+
// between the heading and the body.
|
|
19539
19543
|
async function save() {
|
|
19540
19544
|
const { path, markdown: text } = getSource();
|
|
19541
19545
|
|
|
@@ -19545,8 +19549,10 @@ ${fallback_html}`;
|
|
|
19545
19549
|
return;
|
|
19546
19550
|
}
|
|
19547
19551
|
|
|
19548
|
-
const { title:
|
|
19549
|
-
const
|
|
19552
|
+
const { title: sourceTitle, ...rest } = splitFrontmatter(text).data;
|
|
19553
|
+
const properties = $$props.frontmatter.titleDeclared ? { title: sourceTitle, ...rest } : rest;
|
|
19554
|
+
const title = $$props.frontmatter.titleDeclared ? null : $$props.frontmatter.title;
|
|
19555
|
+
const markdown = serialize($$props.session.doc, { title, natural: $$props.frontmatter.natural, properties });
|
|
19550
19556
|
|
|
19551
19557
|
await writeSettings(path, markdown);
|
|
19552
19558
|
console.info("[vowel] saved %s", path);
|
|
@@ -19684,14 +19690,14 @@ ${fallback_html}`;
|
|
|
19684
19690
|
delegate(['click']);
|
|
19685
19691
|
|
|
19686
19692
|
const rampSettings = [
|
|
19687
|
-
{ key: "heading-size", variable: "--
|
|
19688
|
-
{ key: "heading-size-decay", variable: "--
|
|
19689
|
-
{ key: "h1-letter-spacing", variable: "--
|
|
19690
|
-
{ key: "h6-letter-spacing", variable: "--
|
|
19691
|
-
{ key: "letter-spacing-decay", variable: "--
|
|
19692
|
-
{ key: "h1-weight", variable: "--
|
|
19693
|
-
{ key: "h6-weight", variable: "--
|
|
19694
|
-
{ key: "weight-decay", variable: "--
|
|
19693
|
+
{ key: "heading-size", variable: "--fontsize-delta", label: "Heading size", hint: "How much larger h1 is than body text", unit: "x", min: 0.25, max: 4, step: 0.05, default: 2 },
|
|
19694
|
+
{ key: "heading-size-decay", variable: "--fontsize-decay", label: "Size decay", hint: "Higher makes subheadings smaller", unit: "", min: 0.5, max: 6, step: 0.1, default: 3 },
|
|
19695
|
+
{ key: "h1-letter-spacing", variable: "--letterspacing-h1", label: "h1 letter spacing", unit: "ch", min: -0.1, max: 0.1, step: 0.005, default: -0.01 },
|
|
19696
|
+
{ key: "h6-letter-spacing", variable: "--letterspacing-h6", label: "h6 letter spacing", unit: "ch", min: -0.1, max: 0.1, step: 0.005, default: 0 },
|
|
19697
|
+
{ key: "letter-spacing-decay", variable: "--letterspacing-decay", label: "Spacing decay", unit: "", min: 0.5, max: 6, step: 0.1, default: 2 },
|
|
19698
|
+
{ key: "h1-weight", variable: "--fontweight-h1", label: "h1 weight", unit: "", min: 100, max: 900, step: 10, default: 800 },
|
|
19699
|
+
{ key: "h6-weight", variable: "--fontweight-h6", label: "h6 weight", unit: "", min: 100, max: 900, step: 10, default: 600 },
|
|
19700
|
+
{ key: "weight-decay", variable: "--fontweight-decay", label: "Weight decay", unit: "", min: 0.5, max: 6, step: 0.1, default: 2 }
|
|
19695
19701
|
];
|
|
19696
19702
|
function themeSettings(theme) {
|
|
19697
19703
|
return theme && typeof theme === "object" && !Array.isArray(theme) ? (theme) : {}
|
|
@@ -19699,7 +19705,7 @@ ${fallback_html}`;
|
|
|
19699
19705
|
function rampValue(setting, value) {
|
|
19700
19706
|
const clamped = Math.min(setting.max, Math.max(setting.min, value));
|
|
19701
19707
|
const number = String(Number(clamped.toFixed(4)));
|
|
19702
|
-
if (setting.unit === "x") return `calc(${number} * var(--
|
|
19708
|
+
if (setting.unit === "x") return `calc(${number} * var(--fontsize-root))`
|
|
19703
19709
|
return `${number}${setting.unit}`
|
|
19704
19710
|
}
|
|
19705
19711
|
function typographyVariables(theme) {
|
|
@@ -20036,6 +20042,10 @@ ${fallback_html}`;
|
|
|
20036
20042
|
if (properties.length) url.searchParams.set("properties", properties.join(","));
|
|
20037
20043
|
return decodeURIComponent(url.pathname + url.search)
|
|
20038
20044
|
}
|
|
20045
|
+
function alertLabel(type) {
|
|
20046
|
+
const spaced = type.replace(/[-_]+/g, " ");
|
|
20047
|
+
return spaced.charAt(0).toUpperCase() + spaced.slice(1)
|
|
20048
|
+
}
|
|
20039
20049
|
|
|
20040
20050
|
const TEXT_NODE = 3;
|
|
20041
20051
|
const ELEMENT_NODE = 1;
|
|
@@ -20063,6 +20073,9 @@ ${fallback_html}`;
|
|
|
20063
20073
|
}
|
|
20064
20074
|
function expansionDirective(element) {
|
|
20065
20075
|
const classes = classList(element);
|
|
20076
|
+
if (element.tagName === "FIGURE" && element.children.length === 1 && element.firstElementChild.tagName === "TABLE") {
|
|
20077
|
+
return expansionDirective(element.firstElementChild)
|
|
20078
|
+
}
|
|
20066
20079
|
if ((element.tagName === "UL" || element.tagName === "TABLE") && classes.some(name => name.startsWith("_"))) {
|
|
20067
20080
|
const params = globParams(classes);
|
|
20068
20081
|
return params && globDirective(params)
|
|
@@ -20172,14 +20185,18 @@ ${fallback_html}`;
|
|
|
20172
20185
|
})
|
|
20173
20186
|
}
|
|
20174
20187
|
if (tag === "ASIDE" && classList(element).includes("alert")) {
|
|
20175
|
-
const variant = classList(element).find(name => name !== "alert");
|
|
20188
|
+
const variant = classList(element).find(name => name !== "alert") || "note";
|
|
20189
|
+
const heading = [...element.children].find(child => child.tagName === "H2");
|
|
20190
|
+
const text = heading?.textContent.trim() ?? "";
|
|
20191
|
+
const title = text === alertLabel(variant) ? "" : text;
|
|
20176
20192
|
const body = [...element.children]
|
|
20177
|
-
.filter(child => child
|
|
20193
|
+
.filter(child => child !== heading)
|
|
20178
20194
|
.map(child => blockFrom(child, context));
|
|
20179
20195
|
if (body.some(id => !id)) return null
|
|
20180
20196
|
return context.create({
|
|
20181
20197
|
type: "alert",
|
|
20182
|
-
variant
|
|
20198
|
+
variant,
|
|
20199
|
+
title,
|
|
20183
20200
|
body: { nodes: body, marks: [], annotations: [] }
|
|
20184
20201
|
})
|
|
20185
20202
|
}
|
|
@@ -20818,6 +20835,7 @@ ${fallback_html}`;
|
|
|
20818
20835
|
kind: "block",
|
|
20819
20836
|
properties: {
|
|
20820
20837
|
variant: { type: "string", default: "note" },
|
|
20838
|
+
title: { type: "string", default: "" },
|
|
20821
20839
|
body: { type: "node_array", node_types: BLOCK_TYPES, default_node_type: "paragraph" }
|
|
20822
20840
|
}
|
|
20823
20841
|
},
|
|
@@ -20970,12 +20988,26 @@ ${fallback_html}`;
|
|
|
20970
20988
|
return { ...accumulated, [node.textContent.trim()]: valueFrom(value) }
|
|
20971
20989
|
}, {})
|
|
20972
20990
|
}
|
|
20991
|
+
function naturalLine(element) {
|
|
20992
|
+
const property = element.getAttribute("itemprop");
|
|
20993
|
+
if (property === "date") return element.textContent.trim()
|
|
20994
|
+
if (property === "description") return `${element.textContent.trim()}\n\n---`
|
|
20995
|
+
if (property !== "image") return null
|
|
20996
|
+
const img = element.tagName === "IMG" ? element : element.querySelector("img");
|
|
20997
|
+
const source = element.getAttribute("data-original") ?? img?.getAttribute("src");
|
|
20998
|
+
return source ? `` : null
|
|
20999
|
+
}
|
|
20973
21000
|
function readFrontmatter(main) {
|
|
20974
21001
|
const children = [...main.children];
|
|
21002
|
+
const declared = (key) => (child) => child.classList.contains(`fm_${key}`);
|
|
20975
21003
|
const heading = children.find(child => child.tagName === "H1");
|
|
20976
|
-
const date = children.find(
|
|
20977
|
-
const image = children.find(
|
|
20978
|
-
const description = children.find(
|
|
21004
|
+
const date = children.find(declared("date"));
|
|
21005
|
+
const image = children.find(declared("image"));
|
|
21006
|
+
const description = children.find(declared("description"));
|
|
21007
|
+
const natural = children
|
|
21008
|
+
.filter(child => child.hasAttribute("itemprop") && ![...child.classList].some(name => name.startsWith("fm_")))
|
|
21009
|
+
.map(naturalLine)
|
|
21010
|
+
.filter(Boolean);
|
|
20979
21011
|
const known = {
|
|
20980
21012
|
...(date ? { date: dateFrom(date) } : {}),
|
|
20981
21013
|
...(image ? { image: image.getAttribute("data-original") } : {}),
|
|
@@ -20986,7 +21018,9 @@ ${fallback_html}`;
|
|
|
20986
21018
|
.reduce((accumulated, list) => ({ ...accumulated, ...objectFrom(list) }), {});
|
|
20987
21019
|
return {
|
|
20988
21020
|
title: heading ? heading.textContent.trim() : null,
|
|
20989
|
-
|
|
21021
|
+
titleDeclared: Boolean(heading?.classList.contains("fm_title")),
|
|
21022
|
+
properties: { ...known, ...generic },
|
|
21023
|
+
natural: natural.length ? `${natural.join("\n\n")}\n\n` : ""
|
|
20990
21024
|
}
|
|
20991
21025
|
}
|
|
20992
21026
|
|
|
@@ -29,17 +29,21 @@
|
|
|
29
29
|
// socket pushes, so it is read at save time rather than when the
|
|
30
30
|
// editor opened. Taking the frontmatter from the source rather than
|
|
31
31
|
// the rendered page is what lets a key that never renders survive a
|
|
32
|
-
// save.
|
|
33
|
-
//
|
|
34
|
-
//
|
|
32
|
+
// save. A title declared in the frontmatter (`h1.fm_title`) stays a
|
|
33
|
+
// `title:` key; one from a `#` heading is written back as one. The
|
|
34
|
+
// natural metadata - a date line, an image, a description and its
|
|
35
|
+
// rule, lifted out of the content when the page was built - goes back
|
|
36
|
+
// between the heading and the body.
|
|
35
37
|
async function save() {
|
|
36
38
|
const { path, markdown: text } = getSource()
|
|
37
39
|
if (!path || typeof text !== "string") {
|
|
38
40
|
console.error("[vowel] not saved: this page has no source to save to")
|
|
39
41
|
return
|
|
40
42
|
}
|
|
41
|
-
const { title:
|
|
42
|
-
const
|
|
43
|
+
const { title: sourceTitle, ...rest } = splitFrontmatter(text).data
|
|
44
|
+
const properties = frontmatter.titleDeclared ? { title: sourceTitle, ...rest } : rest
|
|
45
|
+
const title = frontmatter.titleDeclared ? null : frontmatter.title
|
|
46
|
+
const markdown = serialize(session.doc, { title, natural: frontmatter.natural, properties })
|
|
43
47
|
await writeFile(path, markdown)
|
|
44
48
|
console.info("[vowel] saved %s", path)
|
|
45
49
|
}
|
|
@@ -95,11 +95,13 @@ const document_schema = define_document_schema({
|
|
|
95
95
|
body: { type: "node_array", node_types: BLOCK_TYPES, default_node_type: "paragraph" }
|
|
96
96
|
}
|
|
97
97
|
},
|
|
98
|
-
// > [!NOTE] - the variant is what the html plugin encodes in the
|
|
98
|
+
// > [!NOTE] Title - the variant is what the html plugin encodes in the
|
|
99
|
+
// class; the title is its heading, "" when it is the type's default.
|
|
99
100
|
alert: {
|
|
100
101
|
kind: "block",
|
|
101
102
|
properties: {
|
|
102
103
|
variant: { type: "string", default: "note" },
|
|
104
|
+
title: { type: "string", default: "" },
|
|
103
105
|
body: { type: "node_array", node_types: BLOCK_TYPES, default_node_type: "paragraph" }
|
|
104
106
|
}
|
|
105
107
|
},
|
|
@@ -126,3 +126,15 @@ export function globDirective({ folder, recursive, limit, tag, view = null, prop
|
|
|
126
126
|
if (properties.length) url.searchParams.set("properties", properties.join(","))
|
|
127
127
|
return decodeURIComponent(url.pathname + url.search)
|
|
128
128
|
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* An alert's heading when the author gave it no title: its type in
|
|
132
|
+
* sentence case, `[!NOTE]` → "Note", `[!my-card]` → "My card". Here so
|
|
133
|
+
* the html plugin (which writes it) and the editor (which leaves it out
|
|
134
|
+
* on save) agree.
|
|
135
|
+
* @param {string} type - lowercase
|
|
136
|
+
*/
|
|
137
|
+
export function alertLabel(type) {
|
|
138
|
+
const spaced = type.replace(/[-_]+/g, " ")
|
|
139
|
+
return spaced.charAt(0).toUpperCase() + spaced.slice(1)
|
|
140
|
+
}
|
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
// <main>'s children -> the page's frontmatter.
|
|
1
|
+
// <main>'s children -> the page's frontmatter, and its natural metadata.
|
|
2
2
|
//
|
|
3
|
-
// The inverse of makeFrontmatter in plugins/html/index.js.
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
3
|
+
// The inverse of makeFrontmatter in plugins/html/index.js. A property
|
|
4
|
+
// from the frontmatter carries its key as a class (`fm_date`,
|
|
5
|
+
// `dl.fm_client`). The same properties written in the text - a date
|
|
6
|
+
// line, an image, a description ended with a rule - are direct children
|
|
7
|
+
// of <main> too, with their itemprop and no `fm_` class: those are
|
|
8
|
+
// rebuilt as markdown, to go back at the top of the body. The title is
|
|
9
|
+
// main's first <h1>: from the frontmatter when it has `fm_title`, else
|
|
10
|
+
// from a "#" heading, and written back the same way.
|
|
8
11
|
|
|
9
12
|
/** @param {Element} element */
|
|
10
13
|
function scalarFrom(element) {
|
|
@@ -65,17 +68,43 @@ function objectFrom(list) {
|
|
|
65
68
|
}, {})
|
|
66
69
|
}
|
|
67
70
|
|
|
71
|
+
/**
|
|
72
|
+
* One piece of natural metadata, back as the markdown that made it: the
|
|
73
|
+
* date line as the author wrote it, the image, the description and the
|
|
74
|
+
* rule that ended it. A description's inline marks are flattened to its
|
|
75
|
+
* text, the editor's usual lossy rule.
|
|
76
|
+
* @param {Element} element
|
|
77
|
+
* @returns {string|null}
|
|
78
|
+
*/
|
|
79
|
+
function naturalLine(element) {
|
|
80
|
+
const property = element.getAttribute("itemprop")
|
|
81
|
+
if (property === "date") return element.textContent.trim()
|
|
82
|
+
if (property === "description") return `${element.textContent.trim()}\n\n---`
|
|
83
|
+
if (property !== "image") return null
|
|
84
|
+
|
|
85
|
+
// A <picture>, or a plain <img> until the image has been read.
|
|
86
|
+
const img = element.tagName === "IMG" ? element : element.querySelector("img")
|
|
87
|
+
const source = element.getAttribute("data-original") ?? img?.getAttribute("src")
|
|
88
|
+
return source ? `` : null
|
|
89
|
+
}
|
|
90
|
+
|
|
68
91
|
/**
|
|
69
92
|
* @param {Element} main
|
|
70
|
-
* @returns {{title: string|null, properties: object}}
|
|
93
|
+
* @returns {{title: string|null, titleDeclared: boolean, properties: object, natural: string}}
|
|
71
94
|
*/
|
|
72
95
|
export default function readFrontmatter(main) {
|
|
73
96
|
const children = [...main.children]
|
|
97
|
+
const declared = (key) => (child) => child.classList.contains(`fm_${key}`)
|
|
74
98
|
|
|
75
99
|
const heading = children.find(child => child.tagName === "H1")
|
|
76
|
-
const date = children.find(
|
|
77
|
-
const image = children.find(
|
|
78
|
-
const description = children.find(
|
|
100
|
+
const date = children.find(declared("date"))
|
|
101
|
+
const image = children.find(declared("image"))
|
|
102
|
+
const description = children.find(declared("description"))
|
|
103
|
+
|
|
104
|
+
const natural = children
|
|
105
|
+
.filter(child => child.hasAttribute("itemprop") && ![...child.classList].some(name => name.startsWith("fm_")))
|
|
106
|
+
.map(naturalLine)
|
|
107
|
+
.filter(Boolean)
|
|
79
108
|
|
|
80
109
|
const known = {
|
|
81
110
|
...(date ? { date: dateFrom(date) } : {}),
|
|
@@ -92,6 +121,8 @@ export default function readFrontmatter(main) {
|
|
|
92
121
|
|
|
93
122
|
return {
|
|
94
123
|
title: heading ? heading.textContent.trim() : null,
|
|
95
|
-
|
|
124
|
+
titleDeclared: Boolean(heading?.classList.contains("fm_title")),
|
|
125
|
+
properties: { ...known, ...generic },
|
|
126
|
+
natural: natural.length ? `${natural.join("\n\n")}\n\n` : ""
|
|
96
127
|
}
|
|
97
128
|
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// guess round-trips to markdown that silently replaces the author's
|
|
9
9
|
// content on save.
|
|
10
10
|
|
|
11
|
-
import { globParams, globDirective } from "./directives.js"
|
|
11
|
+
import { globParams, globDirective, alertLabel } from "./directives.js"
|
|
12
12
|
|
|
13
13
|
const TEXT_NODE = 3
|
|
14
14
|
const ELEMENT_NODE = 1
|
|
@@ -51,7 +51,12 @@ function expansionDirective(element) {
|
|
|
51
51
|
const classes = classList(element)
|
|
52
52
|
|
|
53
53
|
// A glob list is a <ul>, or a <table> in its ?view=table form; the
|
|
54
|
-
// parameters are in the classes either way.
|
|
54
|
+
// parameters are in the classes either way. The table comes in a
|
|
55
|
+
// <figure>, which is only its frame.
|
|
56
|
+
if (element.tagName === "FIGURE" && element.children.length === 1 && element.firstElementChild.tagName === "TABLE") {
|
|
57
|
+
return expansionDirective(element.firstElementChild)
|
|
58
|
+
}
|
|
59
|
+
|
|
55
60
|
if ((element.tagName === "UL" || element.tagName === "TABLE") && classes.some(name => name.startsWith("_"))) {
|
|
56
61
|
const params = globParams(classes)
|
|
57
62
|
return params && globDirective(params)
|
|
@@ -215,19 +220,25 @@ function blockFrom(element, context) {
|
|
|
215
220
|
}
|
|
216
221
|
|
|
217
222
|
if (tag === "ASIDE" && classList(element).includes("alert")) {
|
|
218
|
-
// > [!NOTE] became <aside class="alert note"
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
|
|
223
|
+
// > [!NOTE] Title became <aside class="alert note"> with the title as
|
|
224
|
+
// an injected <h2>. The variant is the class. The heading is kept as
|
|
225
|
+
// the title, as plain text (inline marks in it are flattened, like
|
|
226
|
+
// any mark the editor can't hold), unless it is the default the html
|
|
227
|
+
// plugin made from the type - then there was no title to keep.
|
|
228
|
+
const variant = classList(element).find(name => name !== "alert") || "note"
|
|
229
|
+
const heading = [...element.children].find(child => child.tagName === "H2")
|
|
230
|
+
const text = heading?.textContent.trim() ?? ""
|
|
231
|
+
const title = text === alertLabel(variant) ? "" : text
|
|
222
232
|
const body = [...element.children]
|
|
223
|
-
.filter(child => child
|
|
233
|
+
.filter(child => child !== heading)
|
|
224
234
|
.map(child => blockFrom(child, context))
|
|
225
235
|
|
|
226
236
|
if (body.some(id => !id)) return null
|
|
227
237
|
|
|
228
238
|
return context.create({
|
|
229
239
|
type: "alert",
|
|
230
|
-
variant
|
|
240
|
+
variant,
|
|
241
|
+
title,
|
|
231
242
|
body: { nodes: body, marks: [], annotations: [] }
|
|
232
243
|
})
|
|
233
244
|
}
|
|
@@ -93,9 +93,10 @@ function blockFrom(id, nodes) {
|
|
|
93
93
|
// The marker line the html plugin consumed is regenerated here. It is
|
|
94
94
|
// emitted raw rather than as text: escaping would make it "\[!NOTE]",
|
|
95
95
|
// which survives vowel's own parser but is not the literal GFM syntax.
|
|
96
|
+
const title = node.title ? ` ${node.title}` : ""
|
|
96
97
|
const marker = {
|
|
97
98
|
type: "html",
|
|
98
|
-
value: `[!${(node.variant || "note").toUpperCase()}]`
|
|
99
|
+
value: `[!${(node.variant || "note").toUpperCase()}]${title}`
|
|
99
100
|
}
|
|
100
101
|
const body = node.body.nodes.map(child => blockFrom(child, nodes))
|
|
101
102
|
return { type: "blockquote", children: [marker, ...body] }
|
|
@@ -144,14 +145,17 @@ export default function serialize(doc, frontmatter = { title: null, properties:
|
|
|
144
145
|
rule: "-"
|
|
145
146
|
})
|
|
146
147
|
|
|
147
|
-
//
|
|
148
|
-
//
|
|
148
|
+
// A title from a `#` heading is written back as one; a declared title
|
|
149
|
+
// arrives as null and stays in the properties.
|
|
149
150
|
const heading = frontmatter.title ? `# ${frontmatter.title}\n\n` : ""
|
|
150
151
|
|
|
152
|
+
// The natural metadata (readFrontmatter), as markdown, already spaced.
|
|
153
|
+
const natural = frontmatter.natural || ""
|
|
154
|
+
|
|
151
155
|
const properties = frontmatter.properties || {}
|
|
152
156
|
const yaml = Object.keys(properties).length
|
|
153
157
|
? `---\n${stringifyYAML(properties)}---\n\n`
|
|
154
158
|
: ""
|
|
155
159
|
|
|
156
|
-
return yaml + heading + body
|
|
160
|
+
return yaml + heading + natural + body
|
|
157
161
|
}
|
package/plugins/html/image.js
CHANGED
|
@@ -69,7 +69,7 @@ function createDynamicImage(imagePath, api, alt, itemprop) {
|
|
|
69
69
|
// Intrinsic size, measured at read (plugins/images): the browser
|
|
70
70
|
// reserves the right box before the bytes arrive, and the reset's
|
|
71
71
|
// `img { height: auto }` keeps the ratio when the width is capped.
|
|
72
|
-
const { width, height, dominant } = image.metadata
|
|
72
|
+
const { width, height, dominant, placeholder } = image.metadata
|
|
73
73
|
|
|
74
74
|
const sources = formats.map((format, index) => {
|
|
75
75
|
const isImg = index === formats.length - 1
|
|
@@ -91,13 +91,19 @@ function createDynamicImage(imagePath, api, alt, itemprop) {
|
|
|
91
91
|
// The derivative paths in srcset are hash-based, so the original asset
|
|
92
92
|
// path cannot be recovered from them. Carried here rather than as a
|
|
93
93
|
// <source>, which is a loading candidate a browser could select.
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
94
|
+
// Two values for a stylesheet to show behind the image while it loads:
|
|
95
|
+
// its dominant colour, and a tiny blurry copy (plugins/images; none
|
|
96
|
+
// for an image with transparency). Nothing here decides that it should.
|
|
97
|
+
const style = [
|
|
98
|
+
dominant && `--color-image-dominant: ${dominant}`,
|
|
99
|
+
// Unquoted: base64 has no character url() needs quoted, and the
|
|
100
|
+
// minifier would spell each quote as `"`.
|
|
101
|
+
placeholder && `--url-image-placeholder: url(${placeholder})`
|
|
102
|
+
].filter(Boolean).join("; ")
|
|
97
103
|
return h("picture", {
|
|
98
104
|
itemprop: itemprop && "image",
|
|
99
105
|
"data-original": imagePath,
|
|
100
|
-
style:
|
|
106
|
+
style: style || undefined
|
|
101
107
|
}, sources)
|
|
102
108
|
}
|
|
103
109
|
|