vowel 0.7.1-beta.0 → 0.7.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +41 -239
- package/bin.js +91 -31
- package/cli.js +32 -0
- package/config.js +12 -0
- package/extractDate.js +1 -1
- package/package.json +8 -34
- package/pathDate.js +69 -0
- package/plugins/assets/index.js +1 -1
- package/plugins/atproto/body.js +3 -1
- package/plugins/atproto/index.js +5 -45
- package/plugins/atproto/publish.js +42 -11
- package/plugins/atproto/record.js +47 -4
- package/plugins/deploy-cloudflare/index.js +4 -1
- package/plugins/export/index.js +131 -0
- package/plugins/html/bundle/index.js +101 -44
- package/plugins/html/editor/Editor.svelte +9 -5
- package/plugins/html/editor/create-session.js +3 -1
- package/plugins/html/editor/directives.js +12 -0
- package/plugins/html/editor/frontmatter.js +77 -12
- package/plugins/html/editor/ingest.js +29 -31
- package/plugins/html/editor/main.js +5 -1
- package/plugins/html/editor/serialize.js +8 -4
- package/plugins/html/editor/socket.js +34 -4
- package/plugins/html/image.js +88 -27
- package/plugins/html/index.js +237 -109
- package/plugins/html/partials.js +41 -17
- package/plugins/html/socialLinks.js +13 -11
- package/plugins/html/writeRules.js +234 -42
- package/plugins/images/favicon.png +0 -0
- package/plugins/images/index.js +69 -8
- package/plugins/markdown/colorScheme.js +70 -14
- package/plugins/markdown/frontmatter.js +18 -8
- package/plugins/markdown/index.js +94 -25
- package/plugins/markdown/metadata.js +132 -32
- package/plugins/markdown/typography.js +11 -11
- package/plugins/styles/index.js +4 -4
- package/plugins/xml/index.js +17 -10
- package/stylesheets/DefaultStyles.css +163 -108
- package/stylesheets/TypographyStyles.css +148 -81
- package/utils.js +74 -21
package/plugins/html/partials.js
CHANGED
|
@@ -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
|
|
24
|
-
*
|
|
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.
|
|
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
|
|
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.
|
|
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)
|
|
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 !== "
|
|
122
|
-
&& isOn(page.metadata.
|
|
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
|
-
|
|
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
|
|
209
|
-
// source, is not one vowel generates), with the hash its read
|
|
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
|
-
|
|
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
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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 {
|
|
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
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
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
|
|
47
|
-
name: "
|
|
48
|
-
test: (node) => isElement(node) && node.tagName === "a" &&
|
|
49
|
-
transform: (node, index, parent,
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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 ${
|
|
139
|
+
if (!readInBackground(pathname, config)) config.log?.("warn", `${target.source ?? target.path}: link to ${href} matches no page`)
|
|
56
140
|
return
|
|
57
141
|
}
|
|
58
|
-
|
|
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
|
|
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
|
|
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
|
|
210
|
-
* paragraph
|
|
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:
|
|
216
|
-
transform: (node, index, parent,
|
|
331
|
+
test: isImage,
|
|
332
|
+
transform: (node, index, parent, context) => {
|
|
333
|
+
const { api, target } = context
|
|
217
334
|
const { src, alt } = node.properties
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
|
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
|
-
:
|
|
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
|
|
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
|
|
355
|
-
*
|
|
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
|
|
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 ${
|
|
368
|
-
node.children.
|
|
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: [
|
|
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
|