vowel 0.7.1-beta.0 → 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/LICENSE +21 -0
- package/README.md +41 -239
- package/bin.js +91 -31
- package/cli.js +32 -0
- package/config.js +12 -0
- package/extractDate.js +1 -1
- package/package.json +8 -34
- package/pathDate.js +69 -0
- package/plugins/assets/index.js +1 -1
- package/plugins/atproto/body.js +3 -1
- package/plugins/atproto/index.js +5 -45
- package/plugins/atproto/publish.js +42 -11
- package/plugins/atproto/record.js +47 -4
- package/plugins/deploy-cloudflare/index.js +4 -1
- package/plugins/export/index.js +131 -0
- package/plugins/html/bundle/index.js +101 -44
- 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 +77 -12
- package/plugins/html/editor/ingest.js +29 -31
- package/plugins/html/editor/main.js +5 -1
- package/plugins/html/editor/serialize.js +8 -4
- package/plugins/html/editor/socket.js +34 -4
- package/plugins/html/image.js +88 -27
- package/plugins/html/index.js +237 -109
- package/plugins/html/partials.js +41 -17
- package/plugins/html/socialLinks.js +13 -11
- package/plugins/html/writeRules.js +234 -42
- package/plugins/images/favicon.png +0 -0
- package/plugins/images/index.js +69 -8
- package/plugins/markdown/colorScheme.js +70 -14
- package/plugins/markdown/frontmatter.js +18 -8
- package/plugins/markdown/index.js +94 -25
- package/plugins/markdown/metadata.js +132 -32
- package/plugins/markdown/typography.js +11 -11
- package/plugins/styles/index.js +4 -4
- package/plugins/xml/index.js +17 -10
- package/stylesheets/DefaultStyles.css +163 -108
- package/stylesheets/TypographyStyles.css +148 -81
- package/utils.js +74 -21
|
@@ -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)
|
|
@@ -20143,7 +20156,7 @@ ${fallback_html}`;
|
|
|
20143
20156
|
const tag = element.tagName;
|
|
20144
20157
|
if (tag === "P") {
|
|
20145
20158
|
const picture = pictureIn(element);
|
|
20146
|
-
if (picture && !element.textContent.trim()) return imageFrom(
|
|
20159
|
+
if (picture && !element.textContent.trim()) return imageFrom(context)
|
|
20147
20160
|
return context.create({
|
|
20148
20161
|
type: "paragraph",
|
|
20149
20162
|
content: textValue(element, context)
|
|
@@ -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
|
}
|
|
@@ -20207,29 +20224,17 @@ ${fallback_html}`;
|
|
|
20207
20224
|
}
|
|
20208
20225
|
if (tag === "FIGURE") {
|
|
20209
20226
|
const picture = pictureIn(element);
|
|
20210
|
-
|
|
20211
|
-
if (picture) return imageFrom(picture, caption, element, context)
|
|
20227
|
+
if (picture) return imageFrom(context)
|
|
20212
20228
|
}
|
|
20213
20229
|
if (tag === "PICTURE") {
|
|
20214
|
-
return imageFrom(
|
|
20230
|
+
return imageFrom(context)
|
|
20215
20231
|
}
|
|
20216
20232
|
context.unrecognised.push(tag.toLowerCase() + (element.className ? `.${element.className}` : ""));
|
|
20217
20233
|
return null
|
|
20218
20234
|
}
|
|
20219
|
-
function imageFrom(
|
|
20220
|
-
|
|
20221
|
-
|
|
20222
|
-
if (!source) {
|
|
20223
|
-
context.unrecognised.push("picture (no data-original)");
|
|
20224
|
-
return null
|
|
20225
|
-
}
|
|
20226
|
-
return context.create({
|
|
20227
|
-
type: "image",
|
|
20228
|
-
source,
|
|
20229
|
-
alt: img ? (img.getAttribute("alt") || "") : "",
|
|
20230
|
-
caption: caption ? caption.textContent : "",
|
|
20231
|
-
html: outer.outerHTML
|
|
20232
|
-
})
|
|
20235
|
+
function imageFrom(context) {
|
|
20236
|
+
context.unrecognised.push("picture");
|
|
20237
|
+
return null
|
|
20233
20238
|
}
|
|
20234
20239
|
function ingest(contentElement, generateId) {
|
|
20235
20240
|
const nodes = {};
|
|
@@ -20818,6 +20823,7 @@ ${fallback_html}`;
|
|
|
20818
20823
|
kind: "block",
|
|
20819
20824
|
properties: {
|
|
20820
20825
|
variant: { type: "string", default: "note" },
|
|
20826
|
+
title: { type: "string", default: "" },
|
|
20821
20827
|
body: { type: "node_array", node_types: BLOCK_TYPES, default_node_type: "paragraph" }
|
|
20822
20828
|
}
|
|
20823
20829
|
},
|
|
@@ -20970,15 +20976,43 @@ ${fallback_html}`;
|
|
|
20970
20976
|
return { ...accumulated, [node.textContent.trim()]: valueFrom(value) }
|
|
20971
20977
|
}, {})
|
|
20972
20978
|
}
|
|
20979
|
+
function naturalLine(element) {
|
|
20980
|
+
if (element.tagName === "FIGURE") return figureLine(element)
|
|
20981
|
+
const property = element.getAttribute("itemprop");
|
|
20982
|
+
if (property === "date") return element.textContent.trim()
|
|
20983
|
+
if (property === "description") return `${element.textContent.trim()}\n\n---`
|
|
20984
|
+
if (property !== "image") return null
|
|
20985
|
+
if (element.tagName !== "IMG") return null
|
|
20986
|
+
const source = element.getAttribute("src");
|
|
20987
|
+
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]"))
|
|
21000
|
+
}
|
|
20973
21001
|
function readFrontmatter(main) {
|
|
20974
21002
|
const children = [...main.children];
|
|
21003
|
+
const declared = (key) => (child) => child.classList.contains(`fm_${key}`);
|
|
20975
21004
|
const heading = children.find(child => child.tagName === "H1");
|
|
20976
|
-
const date = children.find(
|
|
20977
|
-
const
|
|
20978
|
-
const
|
|
21005
|
+
const date = children.find(declared("date"));
|
|
21006
|
+
const description = children.find(declared("description"));
|
|
21007
|
+
const naturalChildren = children.filter(isNatural);
|
|
21008
|
+
const natural = naturalChildren
|
|
21009
|
+
.map(naturalLine)
|
|
21010
|
+
.filter(Boolean);
|
|
21011
|
+
const unrecognised = naturalChildren
|
|
21012
|
+
.filter(child => child.tagName === "PICTURE" || child.querySelector("picture"))
|
|
21013
|
+
.map(() => "picture");
|
|
20979
21014
|
const known = {
|
|
20980
21015
|
...(date ? { date: dateFrom(date) } : {}),
|
|
20981
|
-
...(image ? { image: image.getAttribute("data-original") } : {}),
|
|
20982
21016
|
...(description ? { description: description.textContent.trim() } : {})
|
|
20983
21017
|
};
|
|
20984
21018
|
const generic = children
|
|
@@ -20986,7 +21020,10 @@ ${fallback_html}`;
|
|
|
20986
21020
|
.reduce((accumulated, list) => ({ ...accumulated, ...objectFrom(list) }), {});
|
|
20987
21021
|
return {
|
|
20988
21022
|
title: heading ? heading.textContent.trim() : null,
|
|
20989
|
-
|
|
21023
|
+
titleDeclared: Boolean(heading?.classList.contains("fm_title")),
|
|
21024
|
+
properties: { ...known, ...generic },
|
|
21025
|
+
natural: natural.length ? `${natural.join("\n\n")}\n\n` : "",
|
|
21026
|
+
unrecognised
|
|
20990
21027
|
}
|
|
20991
21028
|
}
|
|
20992
21029
|
|
|
@@ -21010,7 +21047,10 @@ ${fallback_html}`;
|
|
|
21010
21047
|
socket.addEventListener('message', e => {
|
|
21011
21048
|
if (!isOpen()) return
|
|
21012
21049
|
const target = JSON.parse(e.data);
|
|
21013
|
-
if (target.extension !== ".html")
|
|
21050
|
+
if (target.extension !== ".html") {
|
|
21051
|
+
retryImages("/" + target.path);
|
|
21052
|
+
return
|
|
21053
|
+
}
|
|
21014
21054
|
const regex = new RegExp(window.location.pathname + "(index)?(\\.html)");
|
|
21015
21055
|
if (!("/" + target.path).match(regex)) return
|
|
21016
21056
|
updateFromTarget(target);
|
|
@@ -21097,6 +21137,19 @@ ${fallback_html}`;
|
|
|
21097
21137
|
if (!produced.has(keyOf(element)) && wasProduced(element)) element.remove();
|
|
21098
21138
|
}
|
|
21099
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
|
+
}
|
|
21100
21153
|
function wasProduced(element) {
|
|
21101
21154
|
if (element.tagName === "SCRIPT") return false
|
|
21102
21155
|
if (element.hasAttribute("data-vowel-client")) return false
|
|
@@ -21105,7 +21158,7 @@ ${fallback_html}`;
|
|
|
21105
21158
|
|
|
21106
21159
|
openSocket();
|
|
21107
21160
|
const content = document.getElementById("content");
|
|
21108
|
-
const noFrontmatter = { title: null, properties: {} };
|
|
21161
|
+
const noFrontmatter = { title: null, properties: {}, unrecognised: [] };
|
|
21109
21162
|
function startEditing() {
|
|
21110
21163
|
const { session, unrecognised } = create_session(content);
|
|
21111
21164
|
if (!session) {
|
|
@@ -21117,6 +21170,10 @@ ${fallback_html}`;
|
|
|
21117
21170
|
}
|
|
21118
21171
|
const main = content.closest("main");
|
|
21119
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
|
+
}
|
|
21120
21177
|
content.replaceChildren();
|
|
21121
21178
|
mount(Editor, { target: content, props: { session, frontmatter } });
|
|
21122
21179
|
document.documentElement.dataset.vowelEditing = "true";
|
|
@@ -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,21 +68,80 @@ 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
|
+
if (element.tagName === "FIGURE") return figureLine(element)
|
|
81
|
+
const property = element.getAttribute("itemprop")
|
|
82
|
+
if (property === "date") return element.textContent.trim()
|
|
83
|
+
if (property === "description") return `${element.textContent.trim()}\n\n---`
|
|
84
|
+
if (property !== "image") return null
|
|
85
|
+
|
|
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]"))
|
|
118
|
+
}
|
|
119
|
+
|
|
68
120
|
/**
|
|
69
121
|
* @param {Element} main
|
|
70
|
-
* @returns {{title: string|null, properties: object}}
|
|
122
|
+
* @returns {{title: string|null, titleDeclared: boolean, properties: object, natural: string, unrecognised: string[]}}
|
|
71
123
|
*/
|
|
72
124
|
export default function readFrontmatter(main) {
|
|
73
125
|
const children = [...main.children]
|
|
126
|
+
const declared = (key) => (child) => child.classList.contains(`fm_${key}`)
|
|
74
127
|
|
|
75
128
|
const heading = children.find(child => child.tagName === "H1")
|
|
76
|
-
const date = children.find(
|
|
77
|
-
const
|
|
78
|
-
|
|
129
|
+
const date = children.find(declared("date"))
|
|
130
|
+
const description = children.find(declared("description"))
|
|
131
|
+
|
|
132
|
+
const naturalChildren = children.filter(isNatural)
|
|
133
|
+
const natural = naturalChildren
|
|
134
|
+
.map(naturalLine)
|
|
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")
|
|
79
142
|
|
|
80
143
|
const known = {
|
|
81
144
|
...(date ? { date: dateFrom(date) } : {}),
|
|
82
|
-
...(image ? { image: image.getAttribute("data-original") } : {}),
|
|
83
145
|
...(description ? { description: description.textContent.trim() } : {})
|
|
84
146
|
}
|
|
85
147
|
|
|
@@ -92,6 +154,9 @@ export default function readFrontmatter(main) {
|
|
|
92
154
|
|
|
93
155
|
return {
|
|
94
156
|
title: heading ? heading.textContent.trim() : null,
|
|
95
|
-
|
|
157
|
+
titleDeclared: Boolean(heading?.classList.contains("fm_title")),
|
|
158
|
+
properties: { ...known, ...generic },
|
|
159
|
+
natural: natural.length ? `${natural.join("\n\n")}\n\n` : "",
|
|
160
|
+
unrecognised
|
|
96
161
|
}
|
|
97
162
|
}
|
|
@@ -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)
|
|
@@ -180,7 +185,7 @@ function blockFrom(element, context) {
|
|
|
180
185
|
// The image transform replaces an <img> in place, so a paragraph whose
|
|
181
186
|
// only element is a <picture> is an image block, not prose.
|
|
182
187
|
const picture = pictureIn(element)
|
|
183
|
-
if (picture && !element.textContent.trim()) return imageFrom(
|
|
188
|
+
if (picture && !element.textContent.trim()) return imageFrom(context)
|
|
184
189
|
|
|
185
190
|
return context.create({
|
|
186
191
|
type: "paragraph",
|
|
@@ -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
|
}
|
|
@@ -263,12 +274,11 @@ function blockFrom(element, context) {
|
|
|
263
274
|
|
|
264
275
|
if (tag === "FIGURE") {
|
|
265
276
|
const picture = pictureIn(element)
|
|
266
|
-
|
|
267
|
-
if (picture) return imageFrom(picture, caption, element, context)
|
|
277
|
+
if (picture) return imageFrom(context)
|
|
268
278
|
}
|
|
269
279
|
|
|
270
280
|
if (tag === "PICTURE") {
|
|
271
|
-
return imageFrom(
|
|
281
|
+
return imageFrom(context)
|
|
272
282
|
}
|
|
273
283
|
|
|
274
284
|
context.unrecognised.push(tag.toLowerCase() + (element.className ? `.${element.className}` : ""))
|
|
@@ -276,26 +286,14 @@ function blockFrom(element, context) {
|
|
|
276
286
|
}
|
|
277
287
|
|
|
278
288
|
/**
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
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.
|
|
282
293
|
*/
|
|
283
|
-
function imageFrom(
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
if (!source) {
|
|
288
|
-
context.unrecognised.push("picture (no data-original)")
|
|
289
|
-
return null
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
return context.create({
|
|
293
|
-
type: "image",
|
|
294
|
-
source,
|
|
295
|
-
alt: img ? (img.getAttribute("alt") || "") : "",
|
|
296
|
-
caption: caption ? caption.textContent : "",
|
|
297
|
-
html: outer.outerHTML
|
|
298
|
-
})
|
|
294
|
+
function imageFrom(context) {
|
|
295
|
+
context.unrecognised.push("picture")
|
|
296
|
+
return null
|
|
299
297
|
}
|
|
300
298
|
|
|
301
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.
|
|
@@ -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
|
}
|