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.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +41 -239
  3. package/bin.js +91 -31
  4. package/cli.js +32 -0
  5. package/config.js +12 -0
  6. package/extractDate.js +1 -1
  7. package/package.json +8 -34
  8. package/pathDate.js +69 -0
  9. package/plugins/assets/index.js +1 -1
  10. package/plugins/atproto/body.js +3 -1
  11. package/plugins/atproto/index.js +5 -45
  12. package/plugins/atproto/publish.js +42 -11
  13. package/plugins/atproto/record.js +47 -4
  14. package/plugins/deploy-cloudflare/index.js +4 -1
  15. package/plugins/export/index.js +131 -0
  16. package/plugins/html/bundle/index.js +101 -44
  17. package/plugins/html/editor/Editor.svelte +9 -5
  18. package/plugins/html/editor/create-session.js +3 -1
  19. package/plugins/html/editor/directives.js +12 -0
  20. package/plugins/html/editor/frontmatter.js +77 -12
  21. package/plugins/html/editor/ingest.js +29 -31
  22. package/plugins/html/editor/main.js +5 -1
  23. package/plugins/html/editor/serialize.js +8 -4
  24. package/plugins/html/editor/socket.js +34 -4
  25. package/plugins/html/image.js +88 -27
  26. package/plugins/html/index.js +237 -109
  27. package/plugins/html/partials.js +41 -17
  28. package/plugins/html/socialLinks.js +13 -11
  29. package/plugins/html/writeRules.js +234 -42
  30. package/plugins/images/favicon.png +0 -0
  31. package/plugins/images/index.js +69 -8
  32. package/plugins/markdown/colorScheme.js +70 -14
  33. package/plugins/markdown/frontmatter.js +18 -8
  34. package/plugins/markdown/index.js +94 -25
  35. package/plugins/markdown/metadata.js +132 -32
  36. package/plugins/markdown/typography.js +11 -11
  37. package/plugins/styles/index.js +4 -4
  38. package/plugins/xml/index.js +17 -10
  39. package/stylesheets/DefaultStyles.css +163 -108
  40. package/stylesheets/TypographyStyles.css +148 -81
  41. 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. 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)
@@ -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(picture, null, element, context)
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.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
  }
@@ -20207,29 +20224,17 @@ ${fallback_html}`;
20207
20224
  }
20208
20225
  if (tag === "FIGURE") {
20209
20226
  const picture = pictureIn(element);
20210
- const caption = element.querySelector("figcaption");
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(element, null, element, context)
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(picture, caption, outer, context) {
20220
- const img = picture.querySelector("img");
20221
- const source = picture.getAttribute("data-original");
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 ? `![${element.getAttribute("alt") ?? ""}](${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(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"]'));
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
- properties: { ...known, ...generic }
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") return
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. 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,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 ? `![${element.getAttribute("alt") ?? ""}](${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(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"]'))
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
- properties: { ...known, ...generic }
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(picture, null, element, context)
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">, 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
  }
@@ -263,12 +274,11 @@ function blockFrom(element, context) {
263
274
 
264
275
  if (tag === "FIGURE") {
265
276
  const picture = pictureIn(element)
266
- const caption = element.querySelector("figcaption")
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(element, null, element, context)
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
- * @param {Element} picture
280
- * @param {Element|null} caption
281
- * @param {Element} outer the element whose markup is kept for rendering
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(picture, caption, outer, context) {
284
- const img = picture.querySelector("img")
285
- const source = picture.getAttribute("data-original")
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
- // 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
  }