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
@@ -1,10 +1,54 @@
1
1
  import { toString as mdastToString } from 'mdast-util-to-string'
2
2
  import extractDate, { dateSpan } from "./../../extractDate.js"
3
- import { testURL, toTitleCase } from "./../../utils.js"
3
+ import { testURL, labelFromName } from "./../../utils.js"
4
4
  import path from "node:path"
5
5
  import { displayPath } from "../../secretPaths.js"
6
+ import pathDate from "../../pathDate.js"
6
7
  import { coerce, parseFrontmatter, toISODate } from "./frontmatter.js"
7
8
 
9
+ /**
10
+ * An HTML comment, or several in a row with nothing else between them.
11
+ * It stays in the page, but metadata never reads it (per Sam): a
12
+ * comment is not a title, a date or a description, and it doesn't take
13
+ * a block's place in the scan for one.
14
+ * @param {any} node
15
+ */
16
+ function isComment(node) {
17
+ return node.type === "html" && /^\s*(?:<!--[\s\S]*?-->\s*)+$/.test(node.value)
18
+ }
19
+
20
+ /**
21
+ * A node's text for metadata: mdastToString with its HTML comments left
22
+ * out, at any depth. Where a comment sat between two words, the spaces
23
+ * either side of it close up to one.
24
+ * @param {any} node
25
+ * @returns {string}
26
+ */
27
+ function metadataText(node) {
28
+ if (isComment(node)) return ""
29
+ if (!node.children) return mdastToString(node)
30
+ const text = node.children.map(metadataText).join("")
31
+ if (!node.children.some(isComment)) return text
32
+ return text.replace(/[ \t]{2,}/g, " ")
33
+ }
34
+
35
+ /**
36
+ * An image the page's text names, as the browser resolves it from the
37
+ * page: `pic.jpg` and `./pic.jpg` are the page's folder's, `../pic.jpg`
38
+ * its parent's, `/pic.jpg` the root's. Stored as a path from the root,
39
+ * the way the html write resolves the same image (`fromPage` in
40
+ * writeRules.js), because a listing card and `og:image` read it from
41
+ * pages in other folders. An external url is left as written. (A
42
+ * frontmatter `image:` is a setting-style path instead: `resolvePath`.)
43
+ * @param {string} url - as written
44
+ * @param {string} folder - the page's target folder, project-relative ("" at the root)
45
+ */
46
+ export function imageFromPage(url, folder) {
47
+ if (/^[a-z][a-z\d+.-]*:/i.test(url) || url.startsWith("//")) return url
48
+ const base = new URL(folder ? `/${folder.split(path.sep).join("/")}/` : "/", "thismessage://")
49
+ return decodeURIComponent(new URL(url.replaceAll("#", "%23"), base).pathname)
50
+ }
51
+
8
52
 
9
53
  /**
10
54
  - menu_item: true | false
@@ -35,12 +79,11 @@ export const reservedProperties = [
35
79
  "rss_item",
36
80
  "sitemap_item",
37
81
  "html_file",
38
- "global_menu_item",
39
- "local_menu_item",
82
+ "global_nav_item",
83
+ "local_nav_item",
40
84
  "theme",
41
85
  "logo",
42
- "wordmark",
43
- "breadcrumb"
86
+ "wordmark"
44
87
  ]
45
88
 
46
89
  /**
@@ -50,7 +93,7 @@ export const reservedProperties = [
50
93
  * @param {object} tree
51
94
  */
52
95
  export function findTitleNode(tree) {
53
- const [first] = tree.children.filter(child => child.type !== "yaml")
96
+ const [first] = tree.children.filter(child => child.type !== "yaml" && !isComment(child))
54
97
  if (!first) return null
55
98
  if (first.type !== "heading" || first.depth !== 1) return null
56
99
  return first
@@ -72,10 +115,10 @@ export function normalizeHeadingLevels(tree) {
72
115
  }
73
116
 
74
117
  /**
75
- * Marks a recognized date in place so it renders as <time> without moving
76
- * out of the content. The editor reads the element's text back, so the
77
- * author's own wording survives the round trip and the markup is simply
78
- * re-inferred on the next build.
118
+ * Marks a recognized date so it renders as <time>, keeping the author's
119
+ * wording. The editor reads the element's text back, so that wording
120
+ * survives the round trip and the markup is simply re-inferred on the
121
+ * next build.
79
122
  * @param {object} paragraph
80
123
  * @param {Date} date
81
124
  */
@@ -88,44 +131,59 @@ function markAsTime(paragraph, date) {
88
131
  }
89
132
 
90
133
  /**
91
- * Recognizable data that may precede the content. Each block is recorded
92
- * where the author placed it and never relocated - only the title is
93
- * hoisted (see findTitleNode). Adding another inferred property later (an
94
- * ISBN, say) means adding a branch here, not new plumbing.
134
+ * Recognizable data that may precede the content. A block is data only
135
+ * when the property it would fill is still empty: a date line on a page
136
+ * whose frontmatter has a `date:`, or a second date line, is content,
137
+ * and the scan stops there (per Sam). Returns what the block is: "date"
138
+ * or "image" for natural metadata, which the html plugin lifts out of
139
+ * the content and renders with the page's metadata (the source is never
140
+ * changed); "skip" for a line that is neither metadata nor content (a
141
+ * listing, a bare url); false for content. Adding another inferred
142
+ * property later (an ISBN, say) means adding a branch here, not new
143
+ * plumbing.
95
144
  *
96
145
  * @param {object} block
97
146
  * @param {object} metadata
147
+ * @param {string} folder - the page's target folder, for a relative image
98
148
  * @returns {boolean} whether the block was recognizable data
99
149
  */
100
- function recognizeData(block, metadata) {
150
+ function recognizeData(block, metadata, folder) {
101
151
  if (block.type !== "paragraph") return false
102
- if (block.children.length !== 1) return false
152
+ const children = block.children.filter(child => !isComment(child))
153
+ const [child] = children
103
154
 
104
- const [child] = block.children
155
+ const imageFilled = Boolean(metadata.fm_image || metadata.inferred_image)
105
156
 
106
- if (child.type === "image") {
107
- metadata.inferred_image = child.url
157
+ // An image, alone or with its caption after it on the same line: the
158
+ // html write makes the pair a <figure> and lifts it whole. The caption
159
+ // is not a property of the page.
160
+ if (child?.type === "image") {
161
+ if (imageFilled) return false
162
+ metadata.inferred_image = imageFromPage(child.url, folder)
108
163
  metadata.inferred_alt_text = child.alt
109
- return true
164
+ return "image"
110
165
  }
111
166
 
112
- const text = mdastToString(block)
167
+ if (children.length !== 1) return false
168
+
169
+ const text = metadataText(block)
113
170
 
114
171
  // A reference or a listing directive (`/blog/post`, `/blog/**`,
115
172
  // `./drafts/*`) is an instruction to the html plugin, not prose - it
116
173
  // must not become the page's description, which is what a section
117
174
  // index's own listing line was doing. Same test as writeRules'
118
175
  // directive rule.
119
- if (child.type === "text" && /^(\/|\.\.?\/)\S*$/.test(text.trim())) return true
176
+ if (child.type === "text" && /^(\/|\.\.?\/)\S*$/.test(text.trim())) return "skip"
120
177
 
121
178
  if (testURL(text)) {
122
179
  const url = new URL(text)
123
180
  if (text.match(/\.(jpeg|jpg|png)$/)) {
181
+ if (imageFilled) return false
124
182
  metadata.inferred_image = url
125
183
  } else {
126
184
  metadata.inferred_link = url
127
185
  }
128
- return true
186
+ return "skip"
129
187
  }
130
188
 
131
189
  // A date is a property of the page only when the author wrote it as
@@ -144,12 +202,28 @@ function recognizeData(block, metadata) {
144
202
 
145
203
  const date = extractDate(text)
146
204
  if (!date) return false
205
+ if (metadata.fm_date || metadata.inferred_date) return false
147
206
 
148
207
  // Stored as ISO, like a frontmatter date (frontmatter.js, toISODate):
149
208
  // one format in the column, which sorts lexically.
150
209
  metadata.inferred_date = toISODate(date)
151
210
  markAsTime(block, date)
152
- return true
211
+ return "date"
212
+ }
213
+
214
+ /**
215
+ * Marks a block as natural metadata - written in the text, not the
216
+ * frontmatter - for the html plugin to lift out of the content
217
+ * (`data-metadata`, which it removes): "date", "image", "description",
218
+ * or "end" for the rule that closed the description, which renders as
219
+ * nothing. A description is marked as one where it is rendered.
220
+ * @param {object} block
221
+ * @param {"date" | "image" | "description" | "end"} kind
222
+ */
223
+ function markAsNatural(block, kind) {
224
+ const itemprop = kind === "description" ? { itemprop: "description" } : {}
225
+ const hProperties = { ...block.data?.hProperties, ...itemprop, dataMetadata: kind }
226
+ block.data = { ...block.data, hProperties }
153
227
  }
154
228
 
155
229
  /**
@@ -201,19 +275,37 @@ function getMetadata(tree, filePath, targetPath, report = () => {}) {
201
275
 
202
276
  const titleNode = findTitleNode(tree)
203
277
  if (titleNode) {
204
- metadata.inferred_title = mdastToString(titleNode) || toTitleCase(path.parse(filePath).name)
278
+ metadata.inferred_title = metadataText(titleNode).trim() || labelFromName(path.parse(filePath).name)
205
279
  }
206
280
 
207
281
  // Everything up to the first block that is not recognizable data is
208
282
  // metadata; that first block is where the content begins, and it also
209
283
  // supplies the description.
210
- const blocks = tree.children.filter(child => child.type !== "yaml" && child !== titleNode)
284
+ const blocks = tree.children.filter(child => child.type !== "yaml" && child !== titleNode && !isComment(child))
285
+
286
+ for (const [index, block] of blocks.entries()) {
287
+ const recognized = recognizeData(block, metadata, folder)
288
+ if (recognized === "skip") continue
289
+ if (recognized) {
290
+ markAsNatural(block, recognized)
291
+ continue
292
+ }
211
293
 
212
- for (const block of blocks) {
213
- const recognized = recognizeData(block, metadata)
214
- if (recognized) continue
294
+ // One paragraph and then a rule: the author ended their metadata
295
+ // with the rule, so the paragraph *is* the description - stated,
296
+ // not guessed, so it outranks a slogan. With a `description:` in the
297
+ // frontmatter it is an ordinary paragraph, and the rule an ordinary
298
+ // rule. (`text` then `---` on the next line is a heading in
299
+ // markdown, not a paragraph and a rule; it takes a blank line.)
300
+ const next = blocks[index + 1]
301
+ if (!metadata.fm_description && block.type === "paragraph" && next?.type === "thematicBreak") {
302
+ metadata.declared_description = metadataText(block).trim()
303
+ markAsNatural(block, "description")
304
+ markAsNatural(next, "end")
305
+ break
306
+ }
215
307
 
216
- if (!metadata.fm_description) metadata.inferred_description = mdastToString(block)
308
+ if (!metadata.fm_description) metadata.inferred_description = metadataText(block).trim()
217
309
  break
218
310
  }
219
311
 
@@ -229,7 +321,14 @@ function getMetadata(tree, filePath, targetPath, report = () => {}) {
229
321
  // sees, so it is the one place the salt has to be stripped.
230
322
  const pathInfo = path.parse(displayPath(filePath))
231
323
 
232
- metadata.inferred_label = toTitleCase(pathInfo.name)
324
+ // A date the author wrote wins; failing that, the one the path spells
325
+ // (`journal/2024/april/1.md`).
326
+ if (!metadata.fm_date && !metadata.inferred_date) {
327
+ const date = pathDate(displayPath(filePath))
328
+ if (date) metadata.inferred_date = toISODate(date)
329
+ }
330
+
331
+ metadata.inferred_label = labelFromName(pathInfo.name)
233
332
 
234
333
  // What a [[wikilink]] matches, the way Obsidian matches: the filename
235
334
  // without its extension, case-insensitively, plus any `aliases:` the
@@ -283,7 +382,8 @@ function selectMetadata(metadata) {
283
382
 
284
383
  const description =
285
384
  metadata.fm_description
286
- || metadata.fm_tagline
385
+ || metadata.declared_description
386
+ || metadata.fm_slogan
287
387
  || metadata.inferred_description
288
388
 
289
389
  if (description) {
@@ -9,7 +9,7 @@
9
9
  // the document for live preview, so preview and build cannot disagree.
10
10
  //
11
11
  // No bundled fonts, and no font setting: a family is CSS - the author
12
- // sets `--font-sans-brand` (and the @font-face behind it) in their own
12
+ // sets `--fontfamily-sans-brand` (and the @font-face behind it) in their own
13
13
  // stylesheet, where the sheet's stacks put a system fallback after it.
14
14
  // Fonts by url is tasks/2-in-progress/load-fonts.md. Free of node
15
15
  // builtins.
@@ -20,7 +20,7 @@
20
20
  * @property {string} variable - the custom property in TypographyStyles.css
21
21
  * @property {string} label
22
22
  * @property {string} [hint]
23
- * @property {"x" | "ch" | ""} unit - "x" is a multiple of --font-size-root
23
+ * @property {"x" | "ch" | ""} unit - "x" is a multiple of --fontsize-root
24
24
  * @property {number} min
25
25
  * @property {number} max
26
26
  * @property {number} step
@@ -29,14 +29,14 @@
29
29
 
30
30
  /** @type {TypographySetting[]} */
31
31
  export const rampSettings = [
32
- { 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 },
33
- { 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 },
34
- { 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 },
35
- { 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 },
36
- { key: "letter-spacing-decay", variable: "--letter-spacing-decay", label: "Spacing decay", unit: "", min: 0.5, max: 6, step: 0.1, default: 2 },
37
- { key: "h1-weight", variable: "--font-weight-h1", label: "h1 weight", unit: "", min: 100, max: 900, step: 10, default: 800 },
38
- { key: "h6-weight", variable: "--font-weight-h6", label: "h6 weight", unit: "", min: 100, max: 900, step: 10, default: 600 },
39
- { key: "weight-decay", variable: "--font-weight-decay", label: "Weight decay", unit: "", min: 0.5, max: 6, step: 0.1, default: 2 }
32
+ { 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 },
33
+ { 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 },
34
+ { 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 },
35
+ { key: "h6-letter-spacing", variable: "--letterspacing-h6", label: "h6 letter spacing", unit: "ch", min: -0.1, max: 0.1, step: 0.005, default: 0 },
36
+ { key: "letter-spacing-decay", variable: "--letterspacing-decay", label: "Spacing decay", unit: "", min: 0.5, max: 6, step: 0.1, default: 2 },
37
+ { key: "h1-weight", variable: "--fontweight-h1", label: "h1 weight", unit: "", min: 100, max: 900, step: 10, default: 800 },
38
+ { key: "h6-weight", variable: "--fontweight-h6", label: "h6 weight", unit: "", min: 100, max: 900, step: 10, default: 600 },
39
+ { key: "weight-decay", variable: "--fontweight-decay", label: "Weight decay", unit: "", min: 0.5, max: 6, step: 0.1, default: 2 }
40
40
  ]
41
41
 
42
42
  /**
@@ -57,7 +57,7 @@ function themeSettings(theme) {
57
57
  function rampValue(setting, value) {
58
58
  const clamped = Math.min(setting.max, Math.max(setting.min, value))
59
59
  const number = String(Number(clamped.toFixed(4)))
60
- if (setting.unit === "x") return `calc(${number} * var(--font-size-root))`
60
+ if (setting.unit === "x") return `calc(${number} * var(--fontsize-root))`
61
61
  return `${number}${setting.unit}`
62
62
  }
63
63
 
@@ -4,7 +4,7 @@ import { styleText } from "node:util"
4
4
  import { transform } from "lightningcss"
5
5
  import { hash } from "node:crypto"
6
6
  import { resolveTheme, themeStylesheets, themeKey } from "./theme.js"
7
- import { themeColorSchemeCSS } from "../markdown/colorScheme.js"
7
+ import { themeColorSchemeCSS, colorParams } from "../markdown/colorScheme.js"
8
8
  import { typographyCSS } from "../markdown/typography.js"
9
9
 
10
10
  /** @import * as Votive from "votive" */
@@ -54,7 +54,7 @@ function createStubs({ api, settings }) {
54
54
  return themeStylesheets(theme, rootTheme).map(sheet => {
55
55
  // Generated sheets carry the part of the theme they are generated
56
56
  // from, so they re-expand exactly when that part changes.
57
- if (sheet.startsWith("colors")) return { path: sheet, params: { colors: config.colors ?? null } }
57
+ if (sheet.startsWith("colors")) return { path: sheet, params: colorParams(config) }
58
58
  if (sheet.startsWith("type")) return { path: sheet, params: { theme: config } }
59
59
  return { path: sheet }
60
60
  })
@@ -80,8 +80,8 @@ function expandStubs({ path: sourcePath, params }) {
80
80
  // Reports and falls back rather than throwing: a site with unusable
81
81
  // colours should lose its palette, not its build.
82
82
  return {
83
- text: themeColorSchemeCSS(params?.colors, error => (
84
- console.warn(`${styleText("dim", "build: ")}${styleText("yellow", `ignoring theme.colors - ${error.message}`)}`)
83
+ text: themeColorSchemeCSS(params ?? {}, error => (
84
+ console.warn(`${styleText("dim", "build: ")}${styleText("yellow", error.message)}`)
85
85
  ))
86
86
  }
87
87
  }
@@ -1,5 +1,5 @@
1
1
  import xml from "xml"
2
- import { listPages } from "./../../utils.js"
2
+ import { showPages, isPageWithoutFile } from "./../../utils.js"
3
3
  import { entryContent } from "./entryContent.js"
4
4
 
5
5
  /** @import * as Votive from "votive" */
@@ -134,7 +134,10 @@ const processor = {
134
134
  // the entries kept read it. `feed_limit` in settings.md raises or
135
135
  // lowers it.
136
136
  const limit = Number(settings.lastNonNull("fm_feed_limit")) || DEFAULT_FEED_LIMIT
137
- const pages = listPages(api, {
137
+ // A page with no file (`html_file: false`) is an entry too, with
138
+ // its body and no <link>: Atom needs an <id> and content, not a
139
+ // link, which is what makes a feed-only post possible.
140
+ const pages = showPages(api, {
138
141
  query: {
139
142
  "!": {
140
143
  rss_item: false
@@ -194,21 +197,25 @@ const processor = {
194
197
 
195
198
  feed.push(
196
199
  ...pages.map((page) => {
200
+ // The url the page has, or would have: an Atom <id> is an
201
+ // identifier, so a page without a file keeps the one it would
202
+ // have, and keeps it if it gets a file later.
197
203
  const url = (new URL(page.path, domain)).href
204
+ const link = isPageWithoutFile(page) ? [] : [{
205
+ link: {
206
+ _attr: {
207
+ rel: 'alternate',
208
+ href: url
209
+ }
210
+ }
211
+ }]
198
212
 
199
213
  /** @type {object[]} */
200
214
  const entry = [
201
215
  {
202
216
  title: page.metadata.title
203
217
  },
204
- {
205
- link: {
206
- _attr: {
207
- rel: 'alternate',
208
- href: url
209
- }
210
- }
211
- },
218
+ ...link,
212
219
  { id: url },
213
220
  { updated: page.metadata.date }
214
221
  ];