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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +41 -239
  3. package/bin.js +73 -31
  4. package/cli.js +32 -0
  5. package/config.js +14 -0
  6. package/extractDate.js +2 -2
  7. package/index.js +6 -1
  8. package/package.json +9 -34
  9. package/pathDate.js +69 -0
  10. package/plugins/assets/index.js +1 -1
  11. package/plugins/atproto/body.js +413 -0
  12. package/plugins/atproto/index.js +10 -44
  13. package/plugins/atproto/lexicons.js +179 -0
  14. package/plugins/atproto/mapper.js +399 -0
  15. package/plugins/atproto/publish.js +195 -105
  16. package/plugins/atproto/record.js +57 -68
  17. package/plugins/deploy-cloudflare/index.js +4 -1
  18. package/plugins/export/index.js +131 -0
  19. package/plugins/html/bundle/index.js +57 -23
  20. package/plugins/html/editor/Editor.svelte +9 -5
  21. package/plugins/html/editor/create-session.js +3 -1
  22. package/plugins/html/editor/directives.js +12 -0
  23. package/plugins/html/editor/frontmatter.js +42 -11
  24. package/plugins/html/editor/ingest.js +19 -8
  25. package/plugins/html/editor/serialize.js +8 -4
  26. package/plugins/html/image.js +11 -5
  27. package/plugins/html/index.js +217 -100
  28. package/plugins/html/partials.js +14 -8
  29. package/plugins/html/socialLinks.js +5 -8
  30. package/plugins/html/writeRules.js +197 -37
  31. package/plugins/images/favicon.png +0 -0
  32. package/plugins/images/index.js +41 -3
  33. package/plugins/markdown/colorScheme.js +70 -14
  34. package/plugins/markdown/frontmatter.js +32 -6
  35. package/plugins/markdown/index.js +105 -20
  36. package/plugins/markdown/metadata.js +74 -22
  37. package/plugins/markdown/typography.js +11 -11
  38. package/plugins/secret-pages/index.js +75 -0
  39. package/plugins/styles/index.js +4 -4
  40. package/plugins/xml/entryContent.js +22 -11
  41. package/plugins/xml/index.js +19 -11
  42. package/stylesheets/DefaultStyles.css +198 -111
  43. package/stylesheets/ResetStyles.css +2 -1
  44. package/stylesheets/TypographyStyles.css +153 -74
  45. 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. The title is the one key that isn't kept: it is written back
19537
- // as a `#` heading, never as `title:`, so a file whose title lived
19538
- // only in frontmatter gains a heading and loses the key.
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: ignoredTitle, ...properties } = splitFrontmatter(text).data;
19549
- const markdown = serialize($$props.session.doc, { title: $$props.frontmatter.title, properties });
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: "--font-size-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 },
19688
- { key: "heading-size-decay", variable: "--font-size-decay", label: "Size decay", hint: "Higher makes subheadings smaller", unit: "", min: 0.5, max: 6, step: 0.1, default: 3 },
19689
- { key: "h1-letter-spacing", variable: "--letter-spacing-h1", label: "h1 letter spacing", unit: "ch", min: -0.1, max: 0.1, step: 0.005, default: -0.01 },
19690
- { key: "h6-letter-spacing", variable: "--letter-spacing-h6", label: "h6 letter spacing", unit: "ch", min: -0.1, max: 0.1, step: 0.005, default: 0 },
19691
- { key: "letter-spacing-decay", variable: "--letter-spacing-decay", label: "Spacing decay", unit: "", min: 0.5, max: 6, step: 0.1, default: 2 },
19692
- { key: "h1-weight", variable: "--font-weight-h1", label: "h1 weight", unit: "", min: 100, max: 900, step: 10, default: 800 },
19693
- { key: "h6-weight", variable: "--font-weight-h6", label: "h6 weight", unit: "", min: 100, max: 900, step: 10, default: 600 },
19694
- { key: "weight-decay", variable: "--font-weight-decay", label: "Weight decay", unit: "", min: 0.5, max: 6, step: 0.1, default: 2 }
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(--font-size-root))`
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.tagName !== "H2")
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: variant || "note",
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 ? `![${img?.getAttribute("alt") ?? ""}](${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(child => child.matches('time[itemprop="date"]'));
20977
- const image = children.find(child => child.matches('picture[itemprop="image"]'));
20978
- const description = children.find(child => child.matches('p[itemprop="description"]'));
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
- properties: { ...known, ...generic }
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. The title is the one key that isn't kept: it is written back
33
- // as a `#` heading, never as `title:`, so a file whose title lived
34
- // only in frontmatter gains a heading and loses the key.
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: ignoredTitle, ...properties } = splitFrontmatter(text).data
42
- const markdown = serialize(session.doc, { title: frontmatter.title, properties })
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 class.
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. Each property
4
- // is its own single-item <dl>, except the ones vowel handles specially,
5
- // which get their own element identified by itemprop. The title is main's
6
- // first <h1> - the one piece of data hoisted out of the content - and is
7
- // written back as a "#" heading rather than a frontmatter key.
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 ? `![${img?.getAttribute("alt") ?? ""}](${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(child => child.matches('time[itemprop="date"]'))
77
- const image = children.find(child => child.matches('picture[itemprop="image"]'))
78
- const description = children.find(child => child.matches('p[itemprop="description"]'))
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
- properties: { ...known, ...generic }
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">, with the marker line
219
- // replaced by an injected <h2>. The variant is the class; the injected
220
- // heading is dropped and regenerated on the way out.
221
- const variant = classList(element).find(name => name !== "alert")
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.tagName !== "H2")
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: variant || "note",
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
- // The title is written as a heading rather than a frontmatter key, so a
148
- // page whose title only ever lived in frontmatter gains one here.
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
  }
@@ -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
- // --dominant is the image's dominant colour, for a stylesheet to use
95
- // as the placeholder behind a lazy image (`picture { background:
96
- // var(--dominant) }`); nothing here decides that it should.
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 `&#34`.
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: dominant && `--dominant: ${dominant}`
106
+ style: style || undefined
101
107
  }, sources)
102
108
  }
103
109