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
@@ -3,6 +3,9 @@ import { h } from "hastscript"
3
3
  import { isListedPage } from "../../utils.js"
4
4
  import { isVowelStylesheet } from "../styles/theme.js"
5
5
 
6
+ /** The one stylesheet name linked without being asked for. */
7
+ const PROJECT_SHEET = "styles.css"
8
+
6
9
  /**
7
10
  * Partials: rendered fragments shared by many pages, built once per
8
11
  * pass instead of once per page.
@@ -20,8 +23,8 @@ import { isVowelStylesheet } from "../styles/theme.js"
20
23
  * Paths: `partials/nav.partial` is the header nav for pages at the
21
24
  * root, `partials/nav/<folder>.partial` for pages in a folder - the
22
25
  * lists for every level from the root down to that folder, and the
23
- * project's own stylesheets along that chain (a `.css` the author wrote
24
- * in any of those folders, with its content hash for the cache-buster).
26
+ * project's own stylesheets along that chain (a `styles.css` in any of
27
+ * those folders, with its content hash for the cache-buster).
25
28
  * Both are "what this page's folder chain contributes", so they share a
26
29
  * stub; the stylesheet half used to be one listing per ancestor per
27
30
  * page, whose folder-membership edge restaled every page whenever any
@@ -38,9 +41,23 @@ import { isVowelStylesheet } from "../styles/theme.js"
38
41
 
39
42
  const ASIDE_PARTIAL = path.join("partials", "aside.partial")
40
43
 
41
- /** A page with more siblings than this is left out of the aside. */
44
+ /** A page with more siblings than this is left out of the aside and the header nav. */
42
45
  const MAX_SIBLINGS = 18
43
46
 
47
+ /**
48
+ * A folder's pages as a menu, or none: a page with more than
49
+ * MAX_SIBLINGS siblings is left out, and so are its siblings. Their
50
+ * folder's own entry stays, and its pages are a listing's job, not a
51
+ * menu's. Per Sam: "exclude pages with 19 siblings or more", and the
52
+ * header nav follows the same rule as the aside. Without it, a folder
53
+ * of 4,000 undated notes put a 4,000-link nav on each of them: 352 KB of
54
+ * a 355 KB page, and a build that grew with the square of the folder.
55
+ * @template T
56
+ * @param {T[]} pages - one folder's
57
+ * @returns {T[]}
58
+ */
59
+ const asMenu = (pages) => pages.length - 1 > MAX_SIBLINGS ? [] : pages
60
+
44
61
  /** @param {string} targetPath - the page's own target path */
45
62
  function backlinksPartialPath(targetPath) {
46
63
  return path.join("partials", "backlinks", `${targetPath}.partial`)
@@ -60,10 +77,9 @@ function inHeaderNav(page) {
60
77
  && page.extension === ".html"
61
78
  && !page.metadata.date
62
79
  && page.path !== "index.html"
63
- && page.path !== "404.html"
64
80
  && page.path !== "tags.html"
65
81
  && page.dir !== "tags"
66
- && isOn(page.metadata.local_menu_item)
82
+ && isOn(page.metadata.local_nav_item)
67
83
  && isOn(page.metadata.html_file)
68
84
  }
69
85
 
@@ -101,28 +117,27 @@ function asideTree(pages) {
101
117
  if (!byFolder.has(url)) byFolder.set(url, [])
102
118
  byFolder.get(url).push(page)
103
119
  }
104
- // A page with more than MAX_SIBLINGS siblings is left out, and so are
105
- // its siblings: the folder's own entry stays, and its pages are a
106
- // listing's job, not a menu's. Per Sam: "exclude pages with 19
107
- // siblings or more".
120
+ // A folder with too many pages for a menu shows its own entry only (asMenu).
108
121
  const children = (parent) => {
109
122
  const pages = byFolder.get(parent.metadata.prettyURL) ?? []
110
- return pages.length - 1 > MAX_SIBLINGS ? [] : pages.map(node)
123
+ return asMenu(pages).map(node)
111
124
  }
112
125
 
113
126
  function node(page) {
114
127
  const below = children(page)
115
128
  // The aside links the file, not the pretty url - kept as it was
116
- // (see the note in the commit); the home entry is the bare path.
129
+ // (see the note in the commit).
117
130
  return { url: "/" + page.path, label: page.metadata.breadcrumb, ...(below.length ? { children: below } : {}) }
118
131
  }
119
132
 
120
133
  const top = undated
121
- .filter(page => page.dir === "" && page.path !== "index.html" && page.path !== "404.html" && page.path !== "tags.html"
122
- && isOn(page.metadata.global_menu_item) && isOn(page.metadata.html_file))
134
+ .filter(page => page.dir === "" && page.path !== "index.html" && page.path !== "tags.html"
135
+ && isOn(page.metadata.global_nav_item) && isOn(page.metadata.html_file))
123
136
  .map(node)
124
137
 
125
- return [...(home ? [{ url: home.path, label: home.metadata.breadcrumb }] : []), ...top]
138
+ // The home entry is the root's url: its bare path, `index.html`, is
139
+ // relative, and from /blog/post it named /blog/index.html.
140
+ return [...(home ? [item(home)] : []), ...top]
126
141
  }
127
142
 
128
143
  /**
@@ -205,11 +220,15 @@ function createPartialStubs(api) {
205
220
  const folders = new Set([""])
206
221
  for (const page of pages) for (const folder of chain(page.dir)) folders.add(folder)
207
222
 
208
- // The project's stylesheets by folder: a .css the author wrote (has a
209
- // source, is not one vowel generates), with the hash its read recorded.
223
+ // The project's stylesheets by folder: a `styles.css` the author wrote
224
+ // (has a source, is not one vowel generates), with the hash its read
225
+ // recorded. Only that name: any other .css is published but linked only
226
+ // where a `styles:` names it (the html plugin) - a sheet dropped into a
227
+ // folder used to restyle every page below it.
210
228
  const sheets = new Map()
211
229
  for (const sheet of all) {
212
230
  if (!sheet.source || sheet.extension !== ".css" || isVowelStylesheet(sheet.path)) continue
231
+ if (path.basename(sheet.path) !== PROJECT_SHEET) continue
213
232
  if (!sheets.has(sheet.dir)) sheets.set(sheet.dir, [])
214
233
  sheets.get(sheet.dir).push({ path: sheet.path, hash: sheet.metadata?.hash ?? "" })
215
234
  }
@@ -218,7 +237,12 @@ function createPartialStubs(api) {
218
237
  const navs = [...folders].sort().map(folder => ({
219
238
  path: navPartialPath(folder),
220
239
  params: {
221
- levels: chain(folder).map(level => (lists.get(level) ?? []).map(item)),
240
+ // The root's pages are the top of the menu, as in the aside; below
241
+ // it, a folder with too many pages shows none of them (asMenu).
242
+ levels: chain(folder).map(level => {
243
+ const list = lists.get(level) ?? []
244
+ return (level ? asMenu(list) : list).map(item)
245
+ }),
222
246
  sheets: chain(folder).flatMap(level => sheets.get(level) ?? [])
223
247
  }
224
248
  }))
@@ -1,4 +1,6 @@
1
1
  import { h } from "hastscript"
2
+ import { imageSizes } from "../../utils.js"
3
+ import { imageURL } from "./image.js"
2
4
 
3
5
  /**
4
6
  * Social links, from the `social_links` setting:
@@ -22,9 +24,9 @@ import { h } from "hastscript"
22
24
  *
23
25
  * No built-in icons, by decision: an author who wants one drops the SVG
24
26
  * in the project and names it. With an icon the label is the image's
25
- * alt text; without one it is the link text. The anchor carries the
26
- * icon as `--icon-url` too, the same arrangement as `a#logo`, so a
27
- * monochrome SVG can be masked in `currentColor` by the stylesheet.
27
+ * alt text; without one it is the link text. The icon's <img> carries
28
+ * its own url as `--url-icon`, the way `img#logo` carries `--url-logo`,
29
+ * so the stylesheet can mask it in `currentColor`.
28
30
  * `rel="me"` is the identity-link convention (IndieAuth, Mastodon
29
31
  * verification) and costs nothing.
30
32
  */
@@ -45,21 +47,21 @@ function socialLinks(settings) {
45
47
  /**
46
48
  * A `<nav>` of the links, or null when there are none.
47
49
  * @param {any} settings
50
+ * @param {import("votive").PluginAPI} api
48
51
  */
49
- function socialLinksNav(settings) {
52
+ function socialLinksNav(settings, api) {
50
53
  const links = socialLinks(settings)
51
54
  if (!links.length) return null
52
55
 
53
56
  return h("nav.social-links", { "aria-label": "Social links" },
54
57
  h("ul", links.map(({ label, url, icon }) => {
55
58
  const text = label ?? url
56
- // A URL path from the root, resolved at read (frontmatter.js).
57
- const iconURL = icon ? String(icon) : null
58
- return h("li", h("a", {
59
- href: url,
60
- rel: "me",
61
- style: iconURL && `--icon-url: url("${iconURL}")`
62
- }, iconURL ? h("img", { src: iconURL, alt: text }) : text))
59
+ // A URL path from the root, resolved at read (frontmatter.js); a
60
+ // png or jpeg's smallest derivative, since the original is not
61
+ // published (image.js).
62
+ const iconURL = icon ? imageURL(String(icon), api, imageSizes[0]) : null
63
+ const image = iconURL && h("img", { src: iconURL, alt: text, style: `--url-icon: url("${iconURL}")` })
64
+ return h("li", h("a", { href: url, rel: "me" }, image || text))
63
65
  }))
64
66
  )
65
67
  }
@@ -4,9 +4,9 @@ import { common, createLowlight } from "lowlight"
4
4
  import { toText } from "hast-util-to-text"
5
5
  import GithubSlugger from "github-slugger"
6
6
  import { createVisitor, SKIP } from "../markdown/visitor.js"
7
- import { toTitleCase, listPages } from "../../utils.js"
8
- import { globClasses } from "./editor/directives.js"
9
- import createDynamicImage from "./image.js"
7
+ import { showPages, isPageWithoutFile, imageSizes } from "../../utils.js"
8
+ import { globClasses, alertLabel } from "./editor/directives.js"
9
+ import createDynamicImage, { derivativeURL } from "./image.js"
10
10
  import { displayPath } from "../../secretPaths.js"
11
11
 
12
12
  /**
@@ -35,27 +35,113 @@ const soleText = (node) => isElement(node) && node.tagName === "p" && node.child
35
35
  ? node.children[0].value
36
36
  : null
37
37
 
38
+ /** A url this site would serve: not another site, not an anchor, not mailto: and the like. */
39
+ const isInternal = (url) => typeof url === "string"
40
+ && url !== ""
41
+ && !/^[a-z][a-z0-9+.-]*:/i.test(url)
42
+ && !url.startsWith("//")
43
+ && !url.startsWith("#")
44
+ && !url.startsWith("?")
45
+
46
+ /**
47
+ * A url split into its path, decoded, and what follows it (`?query`,
48
+ * `#anchor`). `##` is a secret segment's marker, not an anchor, so it
49
+ * is kept in the path.
50
+ * @param {string} url
51
+ */
52
+ function splitURL(url) {
53
+ const masked = url.replaceAll("##", "\0\0")
54
+ const end = masked.search(/[?#]/)
55
+ const cut = end === -1 ? url.length : end
56
+ return { pathname: decodeURIComponent(url.slice(0, cut)), suffix: url.slice(cut) }
57
+ }
58
+
59
+ /** Where a target is served: a page's pretty url, anything else its path. */
60
+ const urlOf = (found) => found.metadata?.prettyURL ?? "/" + found.path.split(path.sep).join("/")
61
+
38
62
  /**
39
- * `[text](./post.md)` resolves by *source* path to the routed target -
40
- * which for a secret page is the hashed one, with no second trip
41
- * through the router. The href may arrive percent-encoded (toHast
42
- * normalizes non-ASCII). A link to a file that produces no page is the
43
- * author's to notice, not a reason to stop the build.
63
+ * A path as the browser resolves it from this page: `pic.jpg` on
64
+ * /blog/post is /blog/pic.jpg. Decoded, from the root. `#` is encoded
65
+ * on the way in, so a secret segment's `##` stays in the path.
66
+ * @param {string} pathname - decoded, no query or anchor
67
+ * @param {any} target - the page
68
+ */
69
+ function fromPage(pathname, target) {
70
+ const base = new URL(urlOf(target), "thismessage://")
71
+ return decodeURIComponent(new URL(pathname.replaceAll("#", "%23"), base).pathname)
72
+ }
73
+
74
+ /**
75
+ * The target a browser would get for this url from this page, or null
76
+ * for a 404: resolved against the page's own url, then looked up as a
77
+ * file (`/pic.jpg`, `/feed.xml`) or a page (`/about` is about.html). A
78
+ * tracked read, so a page that appears later rewrites the one linking
79
+ * to it.
80
+ * @param {string} pathname - decoded, no query or anchor
81
+ * @param {{ target: any, api: any }} context
82
+ */
83
+ function servedTarget(pathname, { target, api }) {
84
+ const resolved = fromPage(pathname, target)
85
+ const trimmed = resolved.length > 1 ? resolved.replace(/\/+$/, "") : resolved
86
+ if (trimmed === "/") return api.target("index.html")
87
+ const file = trimmed.slice(1).split("/").join(path.sep)
88
+ return api.target(file) ?? api.target(`${file}.html`)
89
+ }
90
+
91
+ /** Per config: the extensions its plugins read in the background. */
92
+ const backgroundExtensions = new WeakMap()
93
+
94
+ /**
95
+ * Whether a missing path may only be not read *yet*: a file in the
96
+ * background (format "buffer": images, fonts, documents, video) has no
97
+ * target until its deferred read, which on a cold build runs after the
98
+ * first write pass. A miss there is not a 404, and warning about it
99
+ * flagged 71 of 98 links and images on style-builder whose files were
100
+ * there. So those go unwarned; the miss is tracked, and the page is
101
+ * rewritten with the right link when the file is read. A page link or
102
+ * a wikilink (markdown, read before any write) still warns.
103
+ * @param {string} pathname
104
+ * @param {any} config
105
+ */
106
+ function readInBackground(pathname, config) {
107
+ if (!backgroundExtensions.has(config)) {
108
+ const processors = (config.plugins ?? []).flatMap(plugin => plugin.processors ?? [])
109
+ const extensions = processors.filter(processor => processor.format === "buffer").flatMap(processor => processor.extensions ?? [])
110
+ backgroundExtensions.set(config, new Set(extensions))
111
+ }
112
+ return backgroundExtensions.get(config).has(path.extname(pathname).toLowerCase())
113
+ }
114
+
115
+ /**
116
+ * A link to this site: `[text](./post.md)`, `../post.md` or
117
+ * `/blog/post.md` resolves by *source* path to the routed target - for a
118
+ * secret page the hashed one, with no second trip through the router -
119
+ * and an asset (`./pic.jpg`) to its path. Anything else (`/about`,
120
+ * `../tags/x`) is checked as the url it is. A link that goes nowhere
121
+ * is warned about and left as written: the author's to fix, not a
122
+ * reason to stop the build. A file read in the background isn't
123
+ * (readInBackground).
44
124
  * @type {import("../markdown/visitor.js").BlockRule}
45
125
  */
46
- const relativeLink = {
47
- name: "relativeLink",
48
- test: (node) => isElement(node) && node.tagName === "a" && typeof node.properties?.href === "string" && node.properties.href.startsWith("./"),
49
- transform: (node, index, parent, { target, api, config }) => {
126
+ const internalLink = {
127
+ name: "internalLink",
128
+ test: (node) => isElement(node) && node.tagName === "a" && !hasClass(node, "wikilink") && isInternal(node.properties?.href),
129
+ transform: (node, index, parent, context) => {
130
+ const { target, api, config } = context
131
+ const href = node.properties.href
132
+ const { pathname, suffix } = splitURL(href)
50
133
  // A stub has a source path but no neighbours to resolve against.
51
- if (!target.source) return
52
- const sourcePath = path.normalize(path.join(path.dirname(target.source), decodeURIComponent(node.properties.href)))
53
- const found = api.targetBySource(sourcePath)
134
+ const sourcePath = target.source && path.extname(pathname)
135
+ ? path.normalize(pathname.startsWith("/") ? pathname.slice(1) : path.join(path.dirname(target.source), pathname))
136
+ : null
137
+ const found = (sourcePath && api.targetBySource(sourcePath)) || servedTarget(pathname, context)
54
138
  if (!found) {
55
- config.log?.("warn", `${target.source}: link to ${node.properties.href} matches no page`)
139
+ if (!readInBackground(pathname, config)) config.log?.("warn", `${target.source ?? target.path}: link to ${href} matches no page`)
56
140
  return
57
141
  }
58
- node.properties.href = found.metadata.prettyURL
142
+ // A link to a png or jpeg opens its largest derivative: the
143
+ // original is not published (image.js).
144
+ node.properties.href = (derivativeURL(found, imageSizes.at(-1)) ?? urlOf(found)) + suffix
59
145
  }
60
146
  }
61
147
 
@@ -147,6 +233,8 @@ const wikilink = {
147
233
  const isFile = /\.[a-z0-9]+$/i.test(name.trim())
148
234
  const found = isFile ? resolveFile(name, context) : resolveNote(name, context)
149
235
  if (!found) {
236
+ const { target, config } = context
237
+ config.log?.("warn", `${target.source ?? target.path}: [[${name}]] matches no page`)
150
238
  delete node.properties.href
151
239
  return SKIP
152
240
  }
@@ -160,7 +248,7 @@ const wikilink = {
160
248
  /**
161
249
  * `:fa/surfer:` is `fa/surfer.svg` in the project, read through
162
250
  * api.target() so a replaced icon restales the page. The same shape as
163
- * a#logo - an <img> plus --icon-url - so a monochrome SVG can be masked
251
+ * a#logo - an <img> plus --url-icon - so a monochrome SVG can be masked
164
252
  * in currentColor. Missing: the shortcode is put back as written.
165
253
  * @type {import("../markdown/visitor.js").BlockRule}
166
254
  */
@@ -173,7 +261,7 @@ const icon = {
173
261
  if (!found) return { type: "text", value: `:${name}:` }
174
262
  const src = `/${found.path}`
175
263
  node.properties.src = src
176
- node.properties.style = `--icon-url: url("${src}")`
264
+ node.properties.style = `--url-icon: url("${src}")`
177
265
  return SKIP
178
266
  }
179
267
  }
@@ -205,21 +293,66 @@ const highlight = {
205
293
  }
206
294
  }
207
295
 
296
+ /** The stand-in size of an image not read yet: 3:2, the commonest photo shape. */
297
+ const PENDING_WIDTH = 1500
298
+ const PENDING_HEIGHT = 1000
299
+
300
+ /** An image in the text, not an inline icon (`:folder/name:`). @param {any} node */
301
+ const isImage = (node) => isElement(node) && node.tagName === "img" && !hasClass(node, "icon")
302
+
208
303
  /**
209
- * A project image becomes a responsive <picture>; first in its
210
- * paragraph with siblings after it, those are its caption.
304
+ * A paragraph that starts with an image and goes on is a captioned
305
+ * image: the paragraph becomes a <figure>, and what follows the image
306
+ * its <figcaption>. In place, so the walk goes on into both (the image
307
+ * rule makes the <picture>, the inline rules read the caption) and a
308
+ * natural image's mark (`data-metadata`) stays on the element the html
309
+ * plugin lifts. The <figure> used to go inside the <p>, where it is not
310
+ * allowed: a browser closed the paragraph before it and left an empty
311
+ * one behind.
312
+ * @type {import("../markdown/visitor.js").BlockRule}
313
+ */
314
+ const captionedImage = {
315
+ name: "captioned image",
316
+ test: (node) => isElement(node) && node.tagName === "p" && isImage(node.children?.[0])
317
+ && node.children.slice(1).some(child => child.type !== "text" || child.value.trim()),
318
+ transform: (node) => {
319
+ const [img, ...caption] = node.children
320
+ node.tagName = "figure"
321
+ node.children = [img, h("figcaption", caption)]
322
+ }
323
+ }
324
+
325
+ /**
326
+ * A project image becomes a responsive <picture>.
211
327
  * @type {import("../markdown/visitor.js").BlockRule}
212
328
  */
213
329
  const image = {
214
330
  name: "image",
215
- test: (node) => isElement(node) && node.tagName === "img" && !hasClass(node, "icon"),
216
- transform: (node, index, parent, { api }) => {
331
+ test: isImage,
332
+ transform: (node, index, parent, context) => {
333
+ const { api, target } = context
217
334
  const { src, alt } = node.properties
218
- const picture = createDynamicImage(src, api, alt)
219
- if (!picture) return SKIP
220
- if (index === 0 && parent.children.length > 1) {
221
- const caption = parent.children.slice(1)
222
- parent.children = [h("figure", [picture, h("figcaption", caption)])]
335
+ // createDynamicImage reads a path from the root; a relative one
336
+ // (`pic.jpg`, `./pic.jpg`, `../pic.jpg`) is the page's, so resolve it
337
+ // first. It used to be looked up at the root, miss, and stay a
338
+ // plain <img>: shown, but never optimised.
339
+ const relative = isInternal(src) && !src.startsWith("/")
340
+ const picture = createDynamicImage(relative ? fromPage(splitURL(src).pathname, target) : src, api, alt)
341
+ if (!picture) {
342
+ // Left as an <img>. One whose file is read in the background may
343
+ // only be not read yet; any other miss is a 404, said so.
344
+ const { pathname } = isInternal(src) ? splitURL(src) : {}
345
+ if (!pathname || servedTarget(pathname, context)) return SKIP
346
+ if (!readInBackground(pathname, context.config)) {
347
+ context.config.log?.("warn", `${context.target.source ?? context.target.path}: image ${src} matches no file`)
348
+ return SKIP
349
+ }
350
+ // Not read yet, so its size is unknown. A 3:2 box stands in (the
351
+ // reset sizes an <img> from its attributes, capped at the column),
352
+ // so the page doesn't jump when the read rewrites it at its own size.
353
+ if (node.properties.width !== undefined || node.properties.height !== undefined) return SKIP
354
+ node.properties.width = PENDING_WIDTH
355
+ node.properties.height = PENDING_HEIGHT
223
356
  return SKIP
224
357
  }
225
358
  return picture
@@ -281,7 +414,7 @@ const directive = {
281
414
  // Lowercased: every vowel target path is (config.js's router).
282
415
  const found = api.target(path.relative("/", path.format(info)).toLowerCase())
283
416
  if (!found) return SKIP
284
- return makeHeader(found.metadata, found.metadata.prettyURL, api, config)
417
+ return makeHeader(found.metadata, cardLink(found), api, config, found.source)
285
418
  }
286
419
 
287
420
  const folder = path.relative("/", dir)
@@ -299,17 +432,24 @@ const directive = {
299
432
  // (`status: hidden`) is hidden from both.
300
433
  const listing = { folder, recursive, query, orderBy: { property: "date", direction: "desc" }, limit: count ? Number(count) : undefined }
301
434
  const targets = relative
302
- ? api.targets(listing).filter(page => page.write !== false && page.extension === ".html" && page.metadata.fm_status !== "hidden")
303
- : listPages(api, listing)
435
+ ? api.targets(listing).filter(page => (page.write !== false || isPageWithoutFile(page)) && page.extension === ".html" && page.metadata.fm_status !== "hidden")
436
+ : showPages(api, listing)
304
437
 
305
438
  const listClasses = globClasses({ folder, recursive, limit: count, tag, view, properties })
306
439
 
307
440
  return view === "table"
308
441
  ? makeTable(listClasses, properties.length ? properties : ["title"], targets, api)
309
- : h("ul", { class: listClasses }, targets.map(found => h("li", makeHeader(found.metadata, found.metadata.prettyURL, api, config))))
442
+ : h("ul", { class: listClasses }, targets.map(found => h("li", makeHeader(found.metadata, cardLink(found), api, config, found.source))))
310
443
  }
311
444
  }
312
445
 
446
+ /**
447
+ * Where a card links: the page's url, or nowhere for a page with no
448
+ * file (`html_file: false`), which is a card and nothing more.
449
+ * @param {any} found
450
+ */
451
+ const cardLink = (found) => isPageWithoutFile(found) ? null : found.metadata.prettyURL
452
+
313
453
  /**
314
454
  * A relative directive path as the absolute published path it names.
315
455
  * The glob's last segment (`*`, `**`) rides along untouched: the
@@ -350,29 +490,81 @@ const preview = {
350
490
  }
351
491
  }
352
492
 
493
+ /** `[!TYPE]` at the very start of an alert, and the spaces after it. */
494
+ const ALERT_MARKER = /^\[!([a-z][\w-]*)\][ \t]*/i
495
+
496
+ /** A blockquote's first block, if it is a paragraph that opens with `[!TYPE]`. */
497
+ const alertParagraph = (node) => {
498
+ const first = node.children?.find(isElement)
499
+ const lead = first?.tagName === "p" ? first.children?.[0] : null
500
+ return typeof lead?.value === "string" && ALERT_MARKER.test(lead.value) ? first : null
501
+ }
502
+
353
503
  /**
354
- * A GFM alert: a blockquote whose first line is `[!NOTE]` (or WARNING,
355
- * DANGER...) becomes an aside with the label as its heading.
504
+ * A GFM alert, or an Obsidian callout: a blockquote whose first line is
505
+ * `[!TYPE]`, any word, becomes `<aside class="alert type">` with a
506
+ * heading. The rest of that line is the heading (`> [!NOTE] How to
507
+ * render`, inline markdown and all), else the type in sentence case
508
+ * (alertLabel). Both spellings work: GitHub's, with the text on the
509
+ * next line - one paragraph, so the marker line is cut off the front of
510
+ * it - and the marker alone, then a blank `>` line.
356
511
  * @type {import("../markdown/visitor.js").BlockRule}
357
512
  */
358
513
  const alert = {
359
514
  name: "alert",
360
- test: (node) => isElement(node) && node.tagName === "blockquote"
361
- && isElement(node.children?.[1]) && node.children[1].tagName === "p"
362
- && node.children[1].children?.length === 1
363
- && /^\[!(\w+)\]$/.test(node.children[1].children[0].value ?? ""),
515
+ test: (node) => isElement(node) && node.tagName === "blockquote" && alertParagraph(node) !== null,
364
516
  transform: (node) => {
365
- const [, label] = node.children[1].children[0].value.match(/^\[!(\w+)\]$/)
517
+ const paragraph = alertParagraph(node)
518
+ const [lead, ...rest] = paragraph.children
519
+ const marker = ALERT_MARKER.exec(lead.value)
520
+ const type = marker[1].toLowerCase()
521
+
522
+ // The marker's line ends at the first newline: what comes before it
523
+ // is the title, what comes after is the paragraph.
524
+ const inline = [{ type: "text", value: lead.value.slice(marker[0].length) }, ...rest]
525
+ const breakAt = inline.findIndex(child => typeof child.value === "string" && child.value.includes("\n"))
526
+ const lineEnd = breakAt === -1 ? inline.length : breakAt
527
+ const split = breakAt === -1 ? null : inline[breakAt].value.indexOf("\n")
528
+ const title = [
529
+ ...inline.slice(0, lineEnd),
530
+ ...(split === null ? [] : [{ type: "text", value: inline[breakAt].value.slice(0, split) }])
531
+ ]
532
+ const body = split === null ? [] : [
533
+ { type: "text", value: inline[breakAt].value.slice(split + 1) },
534
+ ...inline.slice(breakAt + 1)
535
+ ]
536
+
537
+ const hasTitle = title.some(child => child.type !== "text" || child.value.trim())
538
+ const heading = h("h2", hasTitle ? trimText(title) : alertLabel(type))
539
+ const hasBody = body.some(child => child.type !== "text" || child.value.trim())
540
+
366
541
  node.tagName = "aside"
367
- node.properties = { class: `alert ${label.toLowerCase()}` }
368
- node.children.splice(0, 2, h("h2", toTitleCase(label)))
542
+ node.properties = { class: `alert ${type}` }
543
+ const index = node.children.indexOf(paragraph)
544
+ const kept = hasBody ? [{ ...paragraph, children: trimText(body) }] : []
545
+ node.children = [heading, ...node.children.slice(0, index).filter(isElement), ...kept, ...node.children.slice(index + 1)]
369
546
  }
370
547
  }
371
548
 
549
+ /**
550
+ * Inline nodes with the whitespace trimmed from both ends of the run.
551
+ * @param {any[]} nodes
552
+ */
553
+ function trimText(nodes) {
554
+ const last = nodes.length - 1
555
+ return nodes
556
+ .map((child, index) => {
557
+ if (typeof child.value !== "string" || child.type !== "text") return child
558
+ const start = index === 0 ? child.value.trimStart() : child.value
559
+ return { ...child, value: index === last ? start.trimEnd() : start }
560
+ })
561
+ .filter(child => child.type !== "text" || child.value !== "")
562
+ }
563
+
372
564
  const skipped = new Set(["pre", "code", "script", "style"])
373
565
 
374
566
  const writeWalk = createVisitor({
375
- block: [relativeLink, wikilink, icon, highlight, image, directive, preview, alert],
567
+ block: [internalLink, wikilink, icon, highlight, captionedImage, image, directive, preview, alert],
376
568
  skip: (node) => isElement(node) && skipped.has(node.tagName)
377
569
  })
378
570
 
Binary file