vowel 0.7.3 → 0.7.5
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/bin.js +15 -3
- package/config.js +2 -0
- package/index.js +1 -6
- package/package.json +1 -1
- package/plugins/assets/index.js +15 -8
- package/plugins/atproto/body.js +29 -2
- package/plugins/check/index.js +98 -0
- package/plugins/html/index.js +17 -8
- package/plugins/html/writeRules.js +15 -6
- package/plugins/markdown/frontmatter.js +74 -22
- package/plugins/markdown/index.js +28 -25
- package/plugins/markdown/metadata.js +5 -3
- package/plugins/markdown/mirror.js +121 -0
- package/plugins/markdown/visitor.js +42 -4
- package/plugins/secret-pages/index.js +10 -8
- package/stylesheets/DefaultStyles.css +52 -52
- package/utils.js +43 -0
package/bin.js
CHANGED
|
@@ -281,12 +281,24 @@ async function main() {
|
|
|
281
281
|
return
|
|
282
282
|
}
|
|
283
283
|
|
|
284
|
-
// `vowel export <folder
|
|
285
|
-
//
|
|
286
|
-
|
|
284
|
+
// `vowel export <folder>` and `vowel check`: two commands spelled as
|
|
285
|
+
// words rather than --command/--payload. Any other word is a mistake,
|
|
286
|
+
// said so: it used to be ignored and start the preview, which never
|
|
287
|
+
// exits - under CI, a job that hung until it timed out.
|
|
288
|
+
const word = args._?.[0]
|
|
289
|
+
if (word === "export") {
|
|
287
290
|
await runCLICommand("export", JSON.stringify({ path: args._[1] }))
|
|
288
291
|
return
|
|
289
292
|
}
|
|
293
|
+
if (word === "check") {
|
|
294
|
+
await runCLICommand("check")
|
|
295
|
+
return
|
|
296
|
+
}
|
|
297
|
+
if (word !== undefined) {
|
|
298
|
+
console.error(`vowel: unknown command "${word}". The words are \`vowel export <folder>\` and \`vowel check\`; any other command is \`vowel --command <name>\`.`)
|
|
299
|
+
process.exitCode = 1
|
|
300
|
+
return
|
|
301
|
+
}
|
|
290
302
|
|
|
291
303
|
// A site is a folder with a settings.md; the wizard writes one. Not "no
|
|
292
304
|
// database yet", which was also true of every site cloned onto a new
|
package/config.js
CHANGED
|
@@ -18,6 +18,7 @@ import vowelPublishPlugin from "./plugins/publish/index.js"
|
|
|
18
18
|
import vowelURLsPlugin from "./plugins/urls/index.js"
|
|
19
19
|
import vowelSecretPagesPlugin from "./plugins/secret-pages/index.js"
|
|
20
20
|
import vowelExportPlugin from "./plugins/export/index.js"
|
|
21
|
+
import vowelCheckPlugin from "./plugins/check/index.js"
|
|
21
22
|
|
|
22
23
|
/** @import {VotiveConfig} from "votive" */
|
|
23
24
|
|
|
@@ -54,6 +55,7 @@ const defaultPlugins = [
|
|
|
54
55
|
vowelAssetsPlugin,
|
|
55
56
|
vowelDeployCloudflarePlugin,
|
|
56
57
|
vowelExportPlugin,
|
|
58
|
+
vowelCheckPlugin,
|
|
57
59
|
vowelURLsPlugin,
|
|
58
60
|
vowelAtprotoPlugin,
|
|
59
61
|
vowelPublishPlugin,
|
package/index.js
CHANGED
|
@@ -12,15 +12,10 @@ import { createConfig } from "./config.js"
|
|
|
12
12
|
* differ from the one requested; `close()` releases the watchers and the
|
|
13
13
|
* listener, which a host serving more than one project over a session
|
|
14
14
|
* needs before opening the next one.
|
|
15
|
-
*
|
|
16
|
-
* The dev server prints each secret page's url as it first sees it
|
|
17
|
-
* (plugins/secret-pages), which is how an author finds them. Only here:
|
|
18
|
-
* a build that deploys or publishes may be logged somewhere public.
|
|
19
|
-
* Pass `announceSecretPages: false` to turn it off.
|
|
20
15
|
*/
|
|
21
16
|
async function init(options = {}) {
|
|
22
17
|
const { sourceFolder, ...overrides } = options
|
|
23
|
-
const config = createConfig(sourceFolder,
|
|
18
|
+
const config = createConfig(sourceFolder, overrides)
|
|
24
19
|
return startServer(config)
|
|
25
20
|
}
|
|
26
21
|
|
package/package.json
CHANGED
package/plugins/assets/index.js
CHANGED
|
@@ -28,22 +28,29 @@ const DEFAULT_SIZE_LIMIT = 25 * 1024 * 1024
|
|
|
28
28
|
/** @param {number} bytes */
|
|
29
29
|
const megabytes = (bytes) => `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
|
30
30
|
|
|
31
|
+
/** @param {Votive.VotiveConfig} config */
|
|
32
|
+
const sizeLimit = (config) => Number(config.assetSizeLimit) || DEFAULT_SIZE_LIMIT
|
|
33
|
+
|
|
31
34
|
/**
|
|
32
35
|
* @type {Votive.ProcessorRead}
|
|
33
36
|
*/
|
|
34
37
|
function readAsset(source, { config }) {
|
|
35
|
-
const limit = Number(config.assetSizeLimit) || DEFAULT_SIZE_LIMIT
|
|
36
38
|
const { size } = statSync(path.join(config.sourceFolder, source.path))
|
|
37
|
-
if (size <=
|
|
38
|
-
|
|
39
|
-
config.log?.("error", `${source.path} is ${megabytes(size)}; files over ${megabytes(limit)} are not published (Cloudflare Pages refuses them). Host it elsewhere and link to it.`)
|
|
39
|
+
if (size <= sizeLimit(config)) return { metadata: { size } }
|
|
40
40
|
return { metadata: { size, oversized: true }, write: false }
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
/**
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
43
|
+
/**
|
|
44
|
+
* An oversized file is reported here, not at its read: a buffer read
|
|
45
|
+
* is cached, and a cached read never calls the hook again, so a check
|
|
46
|
+
* on a warm cache (`vowel check`) would never have heard of it. Every
|
|
47
|
+
* fresh build writes every target.
|
|
48
|
+
* @type {Votive.ProcessorWrite}
|
|
49
|
+
*/
|
|
50
|
+
function writeAsset(target, { config }) {
|
|
51
|
+
if (!target.metadata?.oversized) return { data: target.buffer() }
|
|
52
|
+
config.log?.("error", `${target.source ?? target.path} is ${megabytes(target.metadata.size)}; files over ${megabytes(sizeLimit(config))} are not published (Cloudflare Pages refuses them). Host it elsewhere and link to it.`)
|
|
53
|
+
return undefined
|
|
47
54
|
}
|
|
48
55
|
|
|
49
56
|
/** @type {Votive.VotiveProcessor} */
|
package/plugins/atproto/body.js
CHANGED
|
@@ -5,6 +5,7 @@ import { gfmFootnoteToMarkdown } from "mdast-util-gfm-footnote"
|
|
|
5
5
|
import { gfmTaskListItemToMarkdown } from "mdast-util-gfm-task-list-item"
|
|
6
6
|
import { toText } from "hast-util-to-text"
|
|
7
7
|
import { emojiFor } from "../markdown/emoji.js"
|
|
8
|
+
import { alertLabel } from "../html/editor/directives.js"
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* A page's body as a record carries it, from the rendered
|
|
@@ -17,7 +18,8 @@ import { emojiFor } from "../markdown/emoji.js"
|
|
|
17
18
|
* and footnotes vowel itself reads. Links and images are absolute; an
|
|
18
19
|
* image links to its copy on the site. An icon is its emoji, or its
|
|
19
20
|
* shortcode as text. A listing is a list of links. A hashtag is a link
|
|
20
|
-
* whose text keeps its `#`.
|
|
21
|
+
* whose text keeps its `#`. An alert is GFM's (`> [!NOTE]`). No
|
|
22
|
+
* title: it is outside the section.
|
|
21
23
|
* - **Rich text** (`richTextBody`), for a `text` field with `facets`
|
|
22
24
|
* beside it: plain text, and a facet for each link, hashtag and mark
|
|
23
25
|
* the lexicon has a feature for. The rest is dropped, images included.
|
|
@@ -181,7 +183,8 @@ function block(node, base) {
|
|
|
181
183
|
if (/^h[1-6]$/.test(tag)) return [{ type: "heading", depth: Number(tag[1]), children: trimPhrasing(phrasing(node.children ?? [], base)) }]
|
|
182
184
|
if (tag === "hr") return [{ type: "thematicBreak" }]
|
|
183
185
|
if (tag === "picture") return [paragraph([node], base)]
|
|
184
|
-
if (tag === "
|
|
186
|
+
if (tag === "aside" && hasClass(node, "alert")) return [alert(node, base)]
|
|
187
|
+
if (tag === "blockquote") return [{ type: "blockquote", children: blocks(node.children ?? [], base) }]
|
|
185
188
|
if (tag === "pre") {
|
|
186
189
|
const code = (node.children ?? []).find(child => is(child, "code")) ?? node
|
|
187
190
|
const language = classes(code).find(name => name.startsWith("language-"))?.slice("language-".length)
|
|
@@ -193,6 +196,30 @@ function block(node, base) {
|
|
|
193
196
|
return blocks(node.children ?? [], base)
|
|
194
197
|
}
|
|
195
198
|
|
|
199
|
+
/**
|
|
200
|
+
* An alert, `<aside class="alert note">` with its heading first, back in
|
|
201
|
+
* GFM's form: `> [!NOTE]`, then the text on the next line. A heading the
|
|
202
|
+
* author wrote stays on the marker's line (`> [!NOTE] How to render`,
|
|
203
|
+
* Obsidian's title, which vowel reads too); the default one, the type in
|
|
204
|
+
* sentence case, is left off. The marker is raw `html` so that it is
|
|
205
|
+
* not escaped as a link reference.
|
|
206
|
+
* @param {any} node
|
|
207
|
+
* @param {string} base
|
|
208
|
+
*/
|
|
209
|
+
function alert(node, base) {
|
|
210
|
+
const type = classes(node).find(name => name !== "alert") ?? "note"
|
|
211
|
+
const [first, ...rest] = (node.children ?? []).filter(child => child.type === "element")
|
|
212
|
+
const hasHeading = /^h[1-6]$/.test(first?.tagName ?? "")
|
|
213
|
+
const title = hasHeading && toText(first).trim() !== alertLabel(type) ? trimPhrasing(phrasing(first.children ?? [], base)) : []
|
|
214
|
+
const marker = [{ type: "html", value: `[!${type.toUpperCase()}]` }, ...(title.length ? [{ type: "text", value: " " }, ...title] : [])]
|
|
215
|
+
const body = blocks(hasHeading ? rest : [first, ...rest].filter(child => child), base)
|
|
216
|
+
const [lead, ...after] = body
|
|
217
|
+
const children = lead?.type === "paragraph"
|
|
218
|
+
? [{ ...lead, children: [...marker, { type: "text", value: "\n" }, ...lead.children] }, ...after]
|
|
219
|
+
: [{ type: "paragraph", children: marker }, ...body]
|
|
220
|
+
return { type: "blockquote", children }
|
|
221
|
+
}
|
|
222
|
+
|
|
196
223
|
/** @param {any} node @param {string} base */
|
|
197
224
|
function list(node, base) {
|
|
198
225
|
const items = (node.children ?? []).filter(child => is(child, "li"))
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { mkdtemp, rm } from "node:fs/promises"
|
|
2
|
+
import { tmpdir } from "node:os"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
import votive from "votive"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `vowel check` (or `vowel --command check`): builds the site once and
|
|
8
|
+
* lists every warning and error the build logged, each at its place in
|
|
9
|
+
* the source (`home.md:6:15: link to /nowhere matches no page`), then
|
|
10
|
+
* fails when there were any. A person sees what to fix; an agent or CI
|
|
11
|
+
* gets an exit code. Warnings fail it too: a link that would 404 is only
|
|
12
|
+
* a warning, and it is what a check is for.
|
|
13
|
+
*
|
|
14
|
+
* Every warning is already reported through `config.log`, so the check
|
|
15
|
+
* is a log that collects rather than prints. The build is a fresh one -
|
|
16
|
+
* an in-memory database and a throwaway output folder - because a warm
|
|
17
|
+
* build reads only what changed, and the warnings of everything else
|
|
18
|
+
* would never be said. The buffer cache is shared, so images are not
|
|
19
|
+
* decoded again; nothing a buffer read decides is reported at the read
|
|
20
|
+
* (plugins/assets reports a file that is too large at its write).
|
|
21
|
+
*
|
|
22
|
+
* @param {unknown} payload
|
|
23
|
+
* @param {{ config: import("votive").VotiveConfig, notify: (message: object) => void }} context
|
|
24
|
+
*/
|
|
25
|
+
async function check(payload, { config, notify }) {
|
|
26
|
+
notify({ status: "progress", message: "Checking..." })
|
|
27
|
+
|
|
28
|
+
const problems = new Map()
|
|
29
|
+
/** @type {(level: "error" | "warn" | "info", message: string) => void} */
|
|
30
|
+
const log = (level, message) => {
|
|
31
|
+
if (level === "info") return
|
|
32
|
+
// A page written again in the follow-up build warns again.
|
|
33
|
+
problems.set(`${level} ${message}`, { level, message })
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const targetFolder = await mkdtemp(path.join(tmpdir(), "vowel-check-"))
|
|
37
|
+
try {
|
|
38
|
+
const site = await votive({ ...config, log, targetFolder, databasePath: ":memory:" })
|
|
39
|
+
try {
|
|
40
|
+
await (await site.build()).deferred
|
|
41
|
+
} finally {
|
|
42
|
+
await site.close()
|
|
43
|
+
}
|
|
44
|
+
} finally {
|
|
45
|
+
await rm(targetFolder, { recursive: true, force: true })
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const sorted = [...problems.values()].sort(bySource)
|
|
49
|
+
sorted.forEach(({ level, message }) => notify({ status: "progress", message: `${level === "error" ? "error" : "warning"} ${message}` }))
|
|
50
|
+
|
|
51
|
+
const errors = sorted.filter(({ level }) => level === "error").length
|
|
52
|
+
const warnings = sorted.length - errors
|
|
53
|
+
if (!sorted.length) {
|
|
54
|
+
notify({ status: "done", message: "No problems found." })
|
|
55
|
+
return undefined
|
|
56
|
+
}
|
|
57
|
+
throw new Error(`${count(errors, "error")} and ${count(warnings, "warning")}`)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param {number} n
|
|
62
|
+
* @param {string} noun
|
|
63
|
+
*/
|
|
64
|
+
const count = (n, noun) => `${n} ${noun}${n === 1 ? "" : "s"}`
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Where a message says it is: `home.md:6:15: …`, `settings.md: …`, or,
|
|
68
|
+
* for a message that names no file first, the message itself.
|
|
69
|
+
* @param {string} message
|
|
70
|
+
*/
|
|
71
|
+
function sourceOf(message) {
|
|
72
|
+
const match = /^(.+?)(?::(\d+))?(?::(\d+))?: /.exec(message)
|
|
73
|
+
if (!match) return { file: message, line: 0, column: 0 }
|
|
74
|
+
return { file: match[1], line: Number(match[2] ?? 0), column: Number(match[3] ?? 0) }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* By file, then line and column, so a file's problems read top to
|
|
79
|
+
* bottom and the order doesn't depend on which page was written first.
|
|
80
|
+
* @param {{ message: string }} a
|
|
81
|
+
* @param {{ message: string }} b
|
|
82
|
+
*/
|
|
83
|
+
function bySource(a, b) {
|
|
84
|
+
const left = sourceOf(a.message)
|
|
85
|
+
const right = sourceOf(b.message)
|
|
86
|
+
if (left.file !== right.file) return left.file < right.file ? -1 : 1
|
|
87
|
+
if (left.line !== right.line) return left.line - right.line
|
|
88
|
+
return left.column - right.column
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** @type {import("votive").VotivePlugin} */
|
|
92
|
+
const vowelCheckPlugin = {
|
|
93
|
+
name: "vowel-check",
|
|
94
|
+
commands: { check }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export default vowelCheckPlugin
|
|
98
|
+
export { check }
|
package/plugins/html/index.js
CHANGED
|
@@ -13,7 +13,7 @@ import { gfmTable } from 'micromark-extension-gfm-table'
|
|
|
13
13
|
import { gfmTableFromMarkdown } from 'mdast-util-gfm-table'
|
|
14
14
|
import { h } from 'hastscript'
|
|
15
15
|
import { readFileSync } from "fs"
|
|
16
|
-
import { testURL, testHashtags, createHashtagPage, labelFromName, isPageWithoutFile, pageClasses, hashtagRegexSingle, imageSizes } from "./../../utils.js"
|
|
16
|
+
import { testURL, testHashtags, createHashtagPage, labelFromName, isPageWithoutFile, pageClasses, hashtagRegexSingle, imageSizes, mirrorPath } from "./../../utils.js"
|
|
17
17
|
import extractDate from "./../../extractDate.js"
|
|
18
18
|
import { reservedProperties, hiddenProperties } from "./../markdown/metadata.js"
|
|
19
19
|
import { toString as hastToString } from 'hast-util-to-string'
|
|
@@ -388,7 +388,7 @@ function writeFile(target, { settings, api, config }) {
|
|
|
388
388
|
.filter(entry => !external.includes(entry))
|
|
389
389
|
.map(entry => ({ entry, sheet: entry.replace(/^\/+/, "").split("/").join(path.sep) }))
|
|
390
390
|
const missing = named.filter(({ sheet }) => !api.target(sheet))
|
|
391
|
-
missing.forEach(({ entry }) => config.log?.("warn", `${target.path}: styles names ${entry}, which isn't in the project`))
|
|
391
|
+
missing.forEach(({ entry }) => config.log?.("warn", `${target.source ?? target.path}: styles names ${entry}, which isn't in the project`))
|
|
392
392
|
const namedSheets = named.filter(({ sheet }) => !missing.some(gone => gone.sheet === sheet)).map(({ sheet }) => sheet)
|
|
393
393
|
|
|
394
394
|
// A stub stylesheet has a source path like any other source, so the
|
|
@@ -538,6 +538,18 @@ function writeFile(target, { settings, api, config }) {
|
|
|
538
538
|
)
|
|
539
539
|
}
|
|
540
540
|
|
|
541
|
+
// The page's markdown mirror (markdown/mirror.js), linked only while
|
|
542
|
+
// it exists. A tracked read: the page is rebuilt when its mirror is
|
|
543
|
+
// declared or removed, and not when the mirror is rewritten.
|
|
544
|
+
const mirror = api.target(mirrorPath(target.path))
|
|
545
|
+
if (mirror) {
|
|
546
|
+
treeHead.children.push(h("link", {
|
|
547
|
+
rel: "alternate",
|
|
548
|
+
type: "text/markdown",
|
|
549
|
+
href: "/" + mirror.path.split(path.sep).join("/")
|
|
550
|
+
}))
|
|
551
|
+
}
|
|
552
|
+
|
|
541
553
|
// The page's AT Protocol record, for readers that verify a document
|
|
542
554
|
// against its site: standard.site's convention, so only for a
|
|
543
555
|
// site.standard.document, and only for a page that can be one - it
|
|
@@ -776,8 +788,9 @@ function writeFile(target, { settings, api, config }) {
|
|
|
776
788
|
]
|
|
777
789
|
)
|
|
778
790
|
|
|
779
|
-
|
|
780
|
-
|
|
791
|
+
// Not caught here: a throw is votive's to report (attempt.js), as one
|
|
792
|
+
// error line naming the page, and the page stays stale. Caught and
|
|
793
|
+
// logged here, it was marked fresh, and the old page stayed up.
|
|
781
794
|
const data = unified()
|
|
782
795
|
.use(rehypePresetMinify)
|
|
783
796
|
.use(rehypeStringify)
|
|
@@ -786,10 +799,6 @@ function writeFile(target, { settings, api, config }) {
|
|
|
786
799
|
return {
|
|
787
800
|
data
|
|
788
801
|
}
|
|
789
|
-
|
|
790
|
-
} catch(e) {
|
|
791
|
-
console.log({ tree, target, e })
|
|
792
|
-
}
|
|
793
802
|
}
|
|
794
803
|
|
|
795
804
|
/**
|
|
@@ -4,11 +4,20 @@ 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 { showPages, isPageWithoutFile, imageSizes } from "../../utils.js"
|
|
7
|
+
import { showPages, isPageWithoutFile, imageSizes, sourceLocation } from "../../utils.js"
|
|
8
8
|
import { globClasses, alertLabel } from "./editor/directives.js"
|
|
9
9
|
import createDynamicImage, { derivativeURL } from "./image.js"
|
|
10
10
|
import { displayPath } from "../../secretPaths.js"
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* The page's source, at the node a warning is about (utils.js). Nodes
|
|
14
|
+
* parsed from the page carry their line and column; a node the build
|
|
15
|
+
* made does not, and the warning names the file alone.
|
|
16
|
+
* @param {any} node
|
|
17
|
+
* @param {any} target
|
|
18
|
+
*/
|
|
19
|
+
const at = (node, target) => sourceLocation(target.source ?? target.path, node?.position?.start)
|
|
20
|
+
|
|
12
21
|
/**
|
|
13
22
|
* The write walk: one pass over a page's hast for everything that
|
|
14
23
|
* renders against other targets, settings or the url store - which is
|
|
@@ -136,7 +145,7 @@ const internalLink = {
|
|
|
136
145
|
: null
|
|
137
146
|
const found = (sourcePath && api.targetBySource(sourcePath)) || servedTarget(pathname, context)
|
|
138
147
|
if (!found) {
|
|
139
|
-
if (!readInBackground(pathname, config)) config.log?.("warn", `${
|
|
148
|
+
if (!readInBackground(pathname, config)) config.log?.("warn", `${at(node, target)}: link to ${href} matches no page`)
|
|
140
149
|
return
|
|
141
150
|
}
|
|
142
151
|
// A link to a png or jpeg opens its largest derivative: the
|
|
@@ -234,7 +243,7 @@ const wikilink = {
|
|
|
234
243
|
const found = isFile ? resolveFile(name, context) : resolveNote(name, context)
|
|
235
244
|
if (!found) {
|
|
236
245
|
const { target, config } = context
|
|
237
|
-
config.log?.("warn", `${
|
|
246
|
+
config.log?.("warn", `${at(node, target)}: [[${name}]] matches no page`)
|
|
238
247
|
delete node.properties.href
|
|
239
248
|
return SKIP
|
|
240
249
|
}
|
|
@@ -344,7 +353,7 @@ const image = {
|
|
|
344
353
|
const { pathname } = isInternal(src) ? splitURL(src) : {}
|
|
345
354
|
if (!pathname || servedTarget(pathname, context)) return SKIP
|
|
346
355
|
if (!readInBackground(pathname, context.config)) {
|
|
347
|
-
context.config.log?.("warn", `${
|
|
356
|
+
context.config.log?.("warn", `${at(node, context.target)}: image ${src} matches no file`)
|
|
348
357
|
return SKIP
|
|
349
358
|
}
|
|
350
359
|
// Not read yet, so its size is unknown. A 3:2 box stands in (the
|
|
@@ -384,7 +393,7 @@ const directive = {
|
|
|
384
393
|
const relative = !value.startsWith("/")
|
|
385
394
|
|
|
386
395
|
if (!relative && value.includes("##")) {
|
|
387
|
-
config.log?.("error", `${
|
|
396
|
+
config.log?.("error", `${at(node, target)}: a listing by published path cannot name a secret folder (${value}); write it relative to this file, as \`./…\``)
|
|
388
397
|
return []
|
|
389
398
|
}
|
|
390
399
|
|
|
@@ -398,7 +407,7 @@ const directive = {
|
|
|
398
407
|
? routeRelative(pathPart, target, config)
|
|
399
408
|
: pathPart
|
|
400
409
|
if (routed === undefined) {
|
|
401
|
-
config.log?.("warn", `${target
|
|
410
|
+
config.log?.("warn", `${at(node, target)}: ${value} cannot be resolved from a page with no source file`)
|
|
402
411
|
return SKIP
|
|
403
412
|
}
|
|
404
413
|
const url = new URL(routed, "thismessage://")
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import yaml from "yaml"
|
|
1
|
+
import yaml, { LineCounter } from "yaml"
|
|
2
2
|
import path from "node:path"
|
|
3
3
|
import { fromMarkdown } from "mdast-util-from-markdown"
|
|
4
4
|
import extractDate from "../../extractDate.js"
|
|
5
|
+
import { sourceLocation } from "../../utils.js"
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* How vowel handles a frontmatter block. Stated once, here, per Sam
|
|
@@ -69,6 +70,7 @@ const EXPECTED = {
|
|
|
69
70
|
aliases: "strings",
|
|
70
71
|
status: "status",
|
|
71
72
|
html_file: "boolean",
|
|
73
|
+
markdown_mirror: "boolean",
|
|
72
74
|
rss_item: "boolean",
|
|
73
75
|
sitemap_item: "boolean",
|
|
74
76
|
global_nav_item: "boolean",
|
|
@@ -219,8 +221,10 @@ function coerceOne(shape, value, folder) {
|
|
|
219
221
|
* @param {string} filePath - project-relative, for the error line
|
|
220
222
|
* @param {string} folder - the folder paths resolve against
|
|
221
223
|
* @param {(message: string) => void} report
|
|
224
|
+
* @param {Record<string, Point>} [points] - each key's place in the file,
|
|
225
|
+
* so the error line names it
|
|
222
226
|
*/
|
|
223
|
-
function coerce(frontmatter, filePath, folder, report) {
|
|
227
|
+
function coerce(frontmatter, filePath, folder, report, points = {}) {
|
|
224
228
|
const result = {}
|
|
225
229
|
for (const key in frontmatter) {
|
|
226
230
|
const shape = EXPECTED[key]
|
|
@@ -229,39 +233,87 @@ function coerce(frontmatter, filePath, folder, report) {
|
|
|
229
233
|
continue
|
|
230
234
|
}
|
|
231
235
|
const { value, problem } = coerceOne(shape, frontmatter[key], folder)
|
|
232
|
-
if (problem) report(`${filePath}: \`${key}:\` ${problem}${value === undefined ? "; ignoring it" : ""}`)
|
|
236
|
+
if (problem) report(`${sourceLocation(filePath, points[key])}: \`${key}:\` ${problem}${value === undefined ? "; ignoring it" : ""}`)
|
|
233
237
|
if (value !== undefined) result[key] = value
|
|
234
238
|
}
|
|
235
239
|
return result
|
|
236
240
|
}
|
|
237
241
|
|
|
242
|
+
/** @typedef {{ line: number, column: number }} Point */
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* A point in the YAML, as a point in the file: the block's first line
|
|
246
|
+
* is the one after its opening `---`.
|
|
247
|
+
* @param {object} node - the `yaml` node
|
|
248
|
+
* @param {{ line: number, col: number }} [linePos] - yaml's, 1-based
|
|
249
|
+
* @returns {Point | undefined}
|
|
250
|
+
*/
|
|
251
|
+
function filePoint(node, linePos) {
|
|
252
|
+
const start = node.position?.start
|
|
253
|
+
if (!start || !linePos) return undefined
|
|
254
|
+
return { line: start.line + linePos.line, column: linePos.col }
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Where each top-level key is written, in the file.
|
|
259
|
+
* @param {object} node - the `yaml` node
|
|
260
|
+
* @param {import("yaml").Document} document
|
|
261
|
+
* @param {LineCounter} lines
|
|
262
|
+
* @returns {Record<string, Point>}
|
|
263
|
+
*/
|
|
264
|
+
function keyPoints(node, document, lines) {
|
|
265
|
+
const items = yaml.isMap(document.contents) ? document.contents.items : []
|
|
266
|
+
const located = items
|
|
267
|
+
.filter(item => yaml.isScalar(item.key) && item.key.range)
|
|
268
|
+
.map(item => [String(item.key.value), filePoint(node, lines.linePos(item.key.range[0]))])
|
|
269
|
+
return Object.fromEntries(located)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Where each top-level key of a frontmatter block is written, for a
|
|
274
|
+
* warning about a key that is read somewhere else (a settings.md's
|
|
275
|
+
* `title:`). Parses the block again, so it is for the rare warning,
|
|
276
|
+
* not for every read.
|
|
277
|
+
* @param {object} node - the `yaml` node
|
|
278
|
+
* @returns {Record<string, Point>}
|
|
279
|
+
*/
|
|
280
|
+
function frontmatterPoints(node) {
|
|
281
|
+
const lines = new LineCounter()
|
|
282
|
+
const document = yaml.parseDocument(node.value, { lineCounter: lines, logLevel: "error" })
|
|
283
|
+
return keyPoints(node, document, lines)
|
|
284
|
+
}
|
|
285
|
+
|
|
238
286
|
/**
|
|
239
|
-
* Parses a frontmatter node
|
|
240
|
-
*
|
|
241
|
-
* nothing is returned.
|
|
287
|
+
* Parses a frontmatter node, with each key's place in the file. On a
|
|
288
|
+
* parse error the node is replaced in the tree by the markdown it would
|
|
289
|
+
* have been - rule 1 above - and nothing is returned.
|
|
242
290
|
* @param {object} tree - the mdast root
|
|
243
291
|
* @param {object} node - its `yaml` child
|
|
244
292
|
* @param {string} filePath
|
|
245
293
|
* @param {(message: string) => void} report
|
|
246
|
-
* @returns {Record<string, unknown> | undefined}
|
|
294
|
+
* @returns {{ values: Record<string, unknown>, points: Record<string, Point> } | undefined}
|
|
247
295
|
*/
|
|
248
296
|
function parseFrontmatter(tree, node, filePath, report) {
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
}
|
|
260
|
-
return
|
|
261
|
-
}
|
|
262
|
-
|
|
297
|
+
// logLevel "error": yaml otherwise prints its own warnings through
|
|
298
|
+
// process.emitWarning, naming no file - `date: {{ date }}` printed
|
|
299
|
+
// "Keys with collection values will be stringified". A value vowel
|
|
300
|
+
// reads is checked by coerce(), which reports it with the path.
|
|
301
|
+
const lines = new LineCounter()
|
|
302
|
+
const document = yaml.parseDocument(node.value, { lineCounter: lines, logLevel: "error" })
|
|
303
|
+
const [error] = document.errors
|
|
304
|
+
if (error) {
|
|
305
|
+
// Its own "at line 1, column 17" counts from the block, not the file.
|
|
306
|
+
const detail = String(error.message).split("\n")[0].replace(/ at line \d+, column \d+:?$/, "")
|
|
307
|
+
report(`${sourceLocation(filePath, filePoint(node, error.linePos?.[0]))}: frontmatter could not be parsed (${detail}); rendering it as content`)
|
|
308
|
+
return asContent(tree, node)
|
|
309
|
+
}
|
|
310
|
+
const parsed = document.toJS()
|
|
311
|
+
if (parsed === null || parsed === undefined) return { values: {}, points: {} }
|
|
312
|
+
if (!isPlainObject(parsed)) {
|
|
313
|
+
report(`${sourceLocation(filePath, node.position?.start)}: frontmatter is ${Array.isArray(parsed) ? "a list" : "a single value"}, not a mapping; rendering it as content`)
|
|
263
314
|
return asContent(tree, node)
|
|
264
315
|
}
|
|
316
|
+
return { values: parsed, points: keyPoints(node, document, lines) }
|
|
265
317
|
}
|
|
266
318
|
|
|
267
319
|
/**
|
|
@@ -276,4 +328,4 @@ function asContent(tree, node) {
|
|
|
276
328
|
return undefined
|
|
277
329
|
}
|
|
278
330
|
|
|
279
|
-
export { EXPECTED, STATUSES, coerce, parseFrontmatter, resolvePath, toISODate }
|
|
331
|
+
export { EXPECTED, STATUSES, coerce, parseFrontmatter, frontmatterPoints, resolvePath, toISODate }
|
|
@@ -13,7 +13,8 @@ import { gfmTable } from 'micromark-extension-gfm-table'
|
|
|
13
13
|
import { gfmTableFromMarkdown } from 'mdast-util-gfm-table'
|
|
14
14
|
import { normalizeHeadingLevels } from './metadata.js'
|
|
15
15
|
import { readFileSync } from "fs"
|
|
16
|
-
import { createHashtagPage, labelFromName, showPages } from "./../../utils.js"
|
|
16
|
+
import { createHashtagPage, labelFromName, showPages, sourceLocation } from "./../../utils.js"
|
|
17
|
+
import { frontmatterPoints } from "./frontmatter.js"
|
|
17
18
|
import { toHast, defaultHandlers } from 'mdast-util-to-hast'
|
|
18
19
|
import { toString as hastToString } from 'hast-util-to-string'
|
|
19
20
|
import getMetadata, { imageFromPage, reservedProperties } from "./metadata.js"
|
|
@@ -21,6 +22,7 @@ import { isSecretPath, secretRouter, displayPath } from "./../../secretPaths.js"
|
|
|
21
22
|
import { collectLinks } from "./links.js"
|
|
22
23
|
import { readWalk } from "./readRules.js"
|
|
23
24
|
import { h } from "hastscript"
|
|
25
|
+
import { mirrorStubs, mirrorWriter } from "./mirror.js"
|
|
24
26
|
|
|
25
27
|
import { styleText } from "node:util"
|
|
26
28
|
|
|
@@ -148,14 +150,20 @@ function readFile(source, { api, config }) {
|
|
|
148
150
|
// highlight contains anything else: `**March 4, 2026**` threw
|
|
149
151
|
// "Cannot compile unknown node `strong`", and a link, emphasis or
|
|
150
152
|
// inline code in the same position threw on their own types.
|
|
153
|
+
// Each element keeps its node's position, as the built-in handlers'
|
|
154
|
+
// do, so a warning at write names the line (an unresolved wikilink).
|
|
151
155
|
unknownHandler: (state, node) => {
|
|
152
|
-
|
|
153
|
-
|
|
156
|
+
const positioned = (element) => {
|
|
157
|
+
state.patch(node, element)
|
|
158
|
+
return element
|
|
159
|
+
}
|
|
160
|
+
if (node.type === "highlight") return positioned(h("mark", state.all(node)))
|
|
161
|
+
if (node.type === "time") return positioned(h("time", { datetime: node.datetime }, state.all(node)))
|
|
154
162
|
// Parsed at read, resolved at write (writeRules.js): the element
|
|
155
163
|
// carries what the write needs, and what the editor needs to put
|
|
156
164
|
// the directive back.
|
|
157
|
-
if (node.type === "wikilink") return h("a.wikilink", { "data-note": node.name, "data-section": node.section }, state.all(node))
|
|
158
|
-
if (node.type === "icon") return h("img.icon", { "data-icon": node.name, alt: node.name.split("/").at(-1) })
|
|
165
|
+
if (node.type === "wikilink") return positioned(h("a.wikilink", { "data-note": node.name, "data-section": node.section }, state.all(node)))
|
|
166
|
+
if (node.type === "icon") return positioned(h("img.icon", { "data-icon": node.name, alt: node.name.split("/").at(-1) }))
|
|
159
167
|
}
|
|
160
168
|
})
|
|
161
169
|
|
|
@@ -189,7 +197,7 @@ function readFile(source, { api, config }) {
|
|
|
189
197
|
data: string,
|
|
190
198
|
write: metadata.html_file ?? true,
|
|
191
199
|
metadata: withDeclaredTypes({ ...targetMetadata, hastAbstract: hast }),
|
|
192
|
-
settings: pathInfo.base === "settings.md" ? settingsContribution(metadata, filePath, config?.log) : undefined
|
|
200
|
+
settings: pathInfo.base === "settings.md" ? settingsContribution(metadata, filePath, config?.log, mdast.children.find(child => child.type === "yaml")) : undefined
|
|
193
201
|
}
|
|
194
202
|
}
|
|
195
203
|
|
|
@@ -225,13 +233,22 @@ function withDeclaredTypes(metadata) {
|
|
|
225
233
|
* @param {Record<string, any>} metadata
|
|
226
234
|
* @param {string} filePath
|
|
227
235
|
* @param {(level: string, message: string) => void} [log]
|
|
236
|
+
* @param {object} [yamlNode] - its frontmatter, for the warnings' lines
|
|
228
237
|
*/
|
|
229
|
-
function settingsContribution(metadata, filePath, log) {
|
|
238
|
+
function settingsContribution(metadata, filePath, log, yamlNode) {
|
|
239
|
+
// Each warning names the key's line. Found only when there is one to
|
|
240
|
+
// give, since it parses the block again.
|
|
241
|
+
const at = (key) => sourceLocation(filePath, yamlNode ? frontmatterPoints(yamlNode)[key] : undefined)
|
|
230
242
|
if (metadata.fm_title !== undefined && metadata.fm_name === undefined) {
|
|
231
|
-
log?.("warn", `${
|
|
243
|
+
log?.("warn", `${at("title")}: \`title:\` in a settings file does nothing - the site's name is \`name:\``)
|
|
232
244
|
}
|
|
233
245
|
if (metadata.fm_slogan !== undefined) {
|
|
234
|
-
log?.("warn", `${
|
|
246
|
+
log?.("warn", `${at("slogan")}: \`slogan:\` in a settings file does nothing - a slogan belongs to a page, e.g. home.md`)
|
|
247
|
+
}
|
|
248
|
+
// Mirrors are declared by an enumerator, which sees the root's
|
|
249
|
+
// settings only.
|
|
250
|
+
if (metadata.markdown_mirror !== undefined && filePath !== "settings.md") {
|
|
251
|
+
log?.("warn", `${at("markdown_mirror")}: \`markdown_mirror:\` works only in the root settings.md or in a page's frontmatter`)
|
|
235
252
|
}
|
|
236
253
|
const declared = Object.entries(metadata)
|
|
237
254
|
.filter(([key]) => key.startsWith("fm_") || reservedProperties.includes(key))
|
|
@@ -242,19 +259,6 @@ function settingsContribution(metadata, filePath, log) {
|
|
|
242
259
|
}
|
|
243
260
|
}
|
|
244
261
|
|
|
245
|
-
/**
|
|
246
|
-
* The only markdown target vowel produces is settings.md itself (every
|
|
247
|
-
* other .md file is routed to .html), so this passes its source text
|
|
248
|
-
* through untouched - the panel edits the real file, not a rendering of
|
|
249
|
-
* it.
|
|
250
|
-
* @type {Votive.ProcessorWrite}
|
|
251
|
-
*/
|
|
252
|
-
function writeMarkdown(target) {
|
|
253
|
-
return {
|
|
254
|
-
data: target.data,
|
|
255
|
-
encoding: "utf-8"
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
262
|
|
|
259
263
|
|
|
260
264
|
|
|
@@ -491,8 +495,7 @@ const readMarkdown = {
|
|
|
491
495
|
router,
|
|
492
496
|
createStubs,
|
|
493
497
|
expandStubs,
|
|
494
|
-
readFile
|
|
495
|
-
writeFile: writeMarkdown
|
|
498
|
+
readFile
|
|
496
499
|
}
|
|
497
500
|
|
|
498
501
|
|
|
@@ -500,7 +503,7 @@ const readMarkdown = {
|
|
|
500
503
|
/** @type {Votive.VotivePlugin} */
|
|
501
504
|
const vowelMarkdownPlugin = {
|
|
502
505
|
name: "vowel",
|
|
503
|
-
processors: [readMarkdown]
|
|
506
|
+
processors: [readMarkdown, mirrorStubs, mirrorWriter]
|
|
504
507
|
}
|
|
505
508
|
|
|
506
509
|
export default vowelMarkdownPlugin
|
|
@@ -79,6 +79,7 @@ export const reservedProperties = [
|
|
|
79
79
|
"rss_item",
|
|
80
80
|
"sitemap_item",
|
|
81
81
|
"html_file",
|
|
82
|
+
"markdown_mirror",
|
|
82
83
|
"global_nav_item",
|
|
83
84
|
"local_nav_item",
|
|
84
85
|
"theme",
|
|
@@ -238,10 +239,11 @@ function markAsNatural(block, kind) {
|
|
|
238
239
|
* @param {(message: string) => void} report
|
|
239
240
|
*/
|
|
240
241
|
function readFrontmatter(tree, node, metadata, filePath, folder, report) {
|
|
241
|
-
const
|
|
242
|
-
if (!
|
|
242
|
+
const read = parseFrontmatter(tree, node, filePath, report)
|
|
243
|
+
if (!read) return
|
|
243
244
|
|
|
244
|
-
const
|
|
245
|
+
const parsed = read.values
|
|
246
|
+
const frontmatter = coerce(parsed, filePath, folder, report, read.points)
|
|
245
247
|
|
|
246
248
|
for (const key in frontmatter) {
|
|
247
249
|
const name = reservedProperties.includes(key) ? key : "fm_" + key
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import path from "node:path"
|
|
2
|
+
import { contentSection } from "../xml/entryContent.js"
|
|
3
|
+
import { markdownBody } from "../atproto/body.js"
|
|
4
|
+
import { isMirrored } from "../../utils.js"
|
|
5
|
+
|
|
6
|
+
/** @import * as Votive from "votive" */
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A page's markdown mirror: `/blog/post.md` beside `/blog/post`, the post
|
|
10
|
+
* as the reader sees it, in portable markdown. Off by default. The root
|
|
11
|
+
* settings.md's `markdown_mirror: true` mirrors every page, and a page's
|
|
12
|
+
* own `markdown_mirror` overrides it; a page with `html_file: false`
|
|
13
|
+
* is mirrored only when it asks itself, which makes a page whose only
|
|
14
|
+
* file is its markdown.
|
|
15
|
+
*
|
|
16
|
+
* A mirror is a stub of its own source type, `.markdownmirror`, so the
|
|
17
|
+
* markdown processor never reads it as a page. Its stub path is the
|
|
18
|
+
* page's *target* path, which routing has already lowercased and
|
|
19
|
+
* hashed, so a secret page's mirror sits beside its hashed url. A page
|
|
20
|
+
* that stops being mirrored is no longer declared, and votive removes
|
|
21
|
+
* its mirror and the file.
|
|
22
|
+
*
|
|
23
|
+
* The enumerator sees only the root's settings, so a subfolder's
|
|
24
|
+
* settings.md cannot switch mirrors (the markdown read warns).
|
|
25
|
+
* @type {Votive.ProcessorStubs}
|
|
26
|
+
*/
|
|
27
|
+
function createStubs({ api, settings }) {
|
|
28
|
+
const siteMirrors = settings.lastNonNull("markdown_mirror") === true
|
|
29
|
+
|
|
30
|
+
// Off by default, and then only a page that asks for its own mirror
|
|
31
|
+
// can have one: one filtered query rather than every page on every pass.
|
|
32
|
+
const candidates = siteMirrors
|
|
33
|
+
? api.targets({ folder: "", recursive: true })
|
|
34
|
+
: api.targets({ folder: "", recursive: true, query: { markdown_mirror: true } })
|
|
35
|
+
|
|
36
|
+
const pages = candidates.filter(page => isMirrored(page, siteMirrors))
|
|
37
|
+
return pages.map(page => ({
|
|
38
|
+
path: page.path.replace(/\.html$/, ".markdownmirror"),
|
|
39
|
+
params: { page: page.path }
|
|
40
|
+
}))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** @type {Votive.ProcessorExpand} */
|
|
44
|
+
function expandStubs({ params }) {
|
|
45
|
+
return { text: params.page }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A long date in English, as the page shows it, read in UTC: a date is
|
|
50
|
+
* stored as UTC midnight, and local time west of UTC is the day before.
|
|
51
|
+
* @param {string} iso
|
|
52
|
+
*/
|
|
53
|
+
function longDate(iso) {
|
|
54
|
+
const date = new Date(iso)
|
|
55
|
+
if (isNaN(Number(date))) return undefined
|
|
56
|
+
return date.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric", timeZone: "UTC" })
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The mirror is made from the page's rendered `section#content`, not
|
|
61
|
+
* from the author's source, whose wikilinks, listing directives and
|
|
62
|
+
* `:icons:` mean nothing outside vowel. The page is a tracked read, so
|
|
63
|
+
* re-rendering it rewrites its mirror.
|
|
64
|
+
*
|
|
65
|
+
* On a pass that writes the mirror before its page, the page's `data` is
|
|
66
|
+
* still its markdown and has no `section#content`. Then nothing is
|
|
67
|
+
* written: the page's write stores its HTML, which restales the mirror,
|
|
68
|
+
* and the same pass writes it again.
|
|
69
|
+
* @type {Votive.ProcessorWrite}
|
|
70
|
+
*/
|
|
71
|
+
function writeMirror(target, { api, settings }) {
|
|
72
|
+
const page = api.target(target.metadata.markdown_mirror_of)
|
|
73
|
+
if (!page) return
|
|
74
|
+
|
|
75
|
+
const section = contentSection(page.data)
|
|
76
|
+
if (!section) return
|
|
77
|
+
|
|
78
|
+
// A page without a file has no url of its own, so its mirror names
|
|
79
|
+
// itself.
|
|
80
|
+
const ownURL = "/" + String(target.path).split(path.sep).join("/")
|
|
81
|
+
const pagePath = page.write === false ? ownURL : page.metadata.prettyURL
|
|
82
|
+
const domain = settings.lastNonNull("fm_domain")
|
|
83
|
+
const origin = domain ? (String(domain).startsWith("http") ? String(domain) : "https://" + domain) : undefined
|
|
84
|
+
const url = origin ? new URL(pagePath, origin).href : pagePath
|
|
85
|
+
|
|
86
|
+
const title = page.metadata.title
|
|
87
|
+
const date = page.metadata.date ? longDate(page.metadata.date) : undefined
|
|
88
|
+
const byline = [date, url].filter(part => part).join(" · ")
|
|
89
|
+
const body = markdownBody(section, url)
|
|
90
|
+
|
|
91
|
+
const parts = [title ? `# ${title}` : undefined, byline, body].filter(part => part)
|
|
92
|
+
return { data: parts.join("\n\n") + "\n" }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The stub's processor. Its router puts `blog/post.markdownmirror` at
|
|
97
|
+
* `blog/post.md`.
|
|
98
|
+
* @type {Votive.VotiveProcessor}
|
|
99
|
+
*/
|
|
100
|
+
const mirrorStubs = {
|
|
101
|
+
extensions: [".markdownmirror"],
|
|
102
|
+
format: "text",
|
|
103
|
+
router: ({ dir, name }) => ({ dir, name, ext: ".md" }),
|
|
104
|
+
createStubs,
|
|
105
|
+
expandStubs,
|
|
106
|
+
readFile: (source) => ({ data: "", metadata: { markdown_mirror_of: source.text } })
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The only processor that writes `.md` targets. It reads nothing, so it
|
|
111
|
+
* needs no format: every `.md` source is the markdown processor's. A
|
|
112
|
+
* write is matched on the target's extension, so a second `writeFile`
|
|
113
|
+
* for `.md` would run on every mirror too.
|
|
114
|
+
* @type {Votive.VotiveProcessor}
|
|
115
|
+
*/
|
|
116
|
+
const mirrorWriter = {
|
|
117
|
+
extensions: [".md"],
|
|
118
|
+
writeFile: writeMirror
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export { mirrorStubs, mirrorWriter }
|
|
@@ -33,6 +33,43 @@
|
|
|
33
33
|
|
|
34
34
|
const SKIP = Symbol("skip")
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* The point `index` characters into a text node, from the node's own
|
|
38
|
+
* start: what an inline rule's node is given as its position, so a
|
|
39
|
+
* warning about it (an unresolved wikilink) can name its line. Exact
|
|
40
|
+
* for the line; the column can be off where the text's value differs
|
|
41
|
+
* from the source - an escape earlier on the line, or a `> ` the
|
|
42
|
+
* value doesn't hold.
|
|
43
|
+
* @param {{ line: number, column: number, offset?: number }} start
|
|
44
|
+
* @param {string} value
|
|
45
|
+
* @param {number} index
|
|
46
|
+
*/
|
|
47
|
+
function pointAt(start, value, index) {
|
|
48
|
+
const before = value.slice(0, index)
|
|
49
|
+
const lines = before.split("\n")
|
|
50
|
+
const offset = start.offset === undefined ? undefined : start.offset + index
|
|
51
|
+
if (lines.length === 1) return { line: start.line, column: start.column + index, offset }
|
|
52
|
+
return { line: start.line + lines.length - 1, column: lines.at(-1).length + 1, offset }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Gives a rule's nodes the span of their match, unless the rule set
|
|
57
|
+
* one. Nothing when the text node had no position (a node the build
|
|
58
|
+
* made).
|
|
59
|
+
* @param {Node[]} nodes
|
|
60
|
+
* @param {Node} text - the text node the match is in
|
|
61
|
+
* @param {RegExpExecArray} match
|
|
62
|
+
*/
|
|
63
|
+
function withPosition(nodes, text, match) {
|
|
64
|
+
const start = text.position?.start
|
|
65
|
+
if (!start) return nodes
|
|
66
|
+
const span = {
|
|
67
|
+
start: pointAt(start, text.value, match.index),
|
|
68
|
+
end: pointAt(start, text.value, match.index + match[0].length)
|
|
69
|
+
}
|
|
70
|
+
return nodes.map(node => node.position || node.type === "text" ? node : { ...node, position: span })
|
|
71
|
+
}
|
|
72
|
+
|
|
36
73
|
/**
|
|
37
74
|
* @param {object} rules
|
|
38
75
|
* @param {InlineRule[]} [rules.inline]
|
|
@@ -46,11 +83,12 @@ function createVisitor({ inline = [], block = [], skip = () => false } = {}) {
|
|
|
46
83
|
/**
|
|
47
84
|
* Text into the pieces the inline rules make of it. Returns null when
|
|
48
85
|
* no rule matched, so an untouched node is left in place unchanged.
|
|
49
|
-
* @param {
|
|
86
|
+
* @param {Node} text - a text node
|
|
50
87
|
* @param {any} context
|
|
51
88
|
* @returns {Node[] | null}
|
|
52
89
|
*/
|
|
53
|
-
function applyInline(
|
|
90
|
+
function applyInline(text, context) {
|
|
91
|
+
const value = text.value
|
|
54
92
|
const pieces = []
|
|
55
93
|
let cursor = 0
|
|
56
94
|
let touched = false
|
|
@@ -79,7 +117,7 @@ function createVisitor({ inline = [], block = [], skip = () => false } = {}) {
|
|
|
79
117
|
touched = true
|
|
80
118
|
const before = value.slice(pieces.length ? pieces.at(-1).end : 0, match.index)
|
|
81
119
|
if (before) pieces.push({ end: match.index, nodes: [{ type: "text", value: before }] })
|
|
82
|
-
pieces.push({ end, nodes: Array.isArray(resolved) ? resolved : [resolved] })
|
|
120
|
+
pieces.push({ end, nodes: withPosition(Array.isArray(resolved) ? resolved : [resolved], text, match) })
|
|
83
121
|
cursor = end
|
|
84
122
|
}
|
|
85
123
|
|
|
@@ -113,7 +151,7 @@ function createVisitor({ inline = [], block = [], skip = () => false } = {}) {
|
|
|
113
151
|
if (skip(node)) return 1
|
|
114
152
|
|
|
115
153
|
if (node.type === "text" && typeof node.value === "string") {
|
|
116
|
-
const pieces = applyInline(node
|
|
154
|
+
const pieces = applyInline(node, context)
|
|
117
155
|
if (!pieces) return 1
|
|
118
156
|
parent.children.splice(index, 1, ...pieces)
|
|
119
157
|
return pieces.length
|
|
@@ -6,14 +6,16 @@ import { isSecretPath } from "../../secretPaths.js"
|
|
|
6
6
|
/**
|
|
7
7
|
* Where your secret pages are. A page under a `##` segment is published
|
|
8
8
|
* at a hashed url that nothing on the site links to, so the author needs
|
|
9
|
-
* another way to find it:
|
|
10
|
-
* when it first sees it - every one at
|
|
11
|
-
* renamed one, whose url changed) as it appears.
|
|
9
|
+
* another way to find it: every build prints each one, file and url,
|
|
10
|
+
* when it first sees it - every one at the first pass, then each new one
|
|
11
|
+
* (or a renamed one, whose url changed) as it appears.
|
|
12
12
|
*
|
|
13
|
-
* **
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* **Every build, CI included** (per Sam, Nov 5 2026). It used to be the
|
|
14
|
+
* dev server's alone, to keep the urls out of logs that might be public.
|
|
15
|
+
* That was theatre: a secret page's url is an unkeyed hash of its source
|
|
16
|
+
* path, so whoever has the source has the url, and hiding it from a log
|
|
17
|
+
* suggested a protection that isn't there. Only `logging: "silent"`
|
|
18
|
+
* turns it off.
|
|
17
19
|
*
|
|
18
20
|
* It is a stub enumerator that declares no stubs: the one hook that runs
|
|
19
21
|
* on every pass and can list pages. It lists only the hidden ones - a
|
|
@@ -41,7 +43,7 @@ function pageURL(domain, prettyURL) {
|
|
|
41
43
|
|
|
42
44
|
/** @type {Votive.ProcessorStubs} */
|
|
43
45
|
function createStubs({ api, settings, config }) {
|
|
44
|
-
if (
|
|
46
|
+
if (config.logging === "silent") return []
|
|
45
47
|
|
|
46
48
|
const seen = announced.get(config) ?? new Set()
|
|
47
49
|
announced.set(config, seen)
|
|
@@ -614,78 +614,78 @@
|
|
|
614
614
|
display: none;
|
|
615
615
|
}
|
|
616
616
|
}
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
/* footer */
|
|
620
617
|
|
|
621
|
-
footer
|
|
618
|
+
/* footer */
|
|
622
619
|
|
|
623
|
-
|
|
624
|
-
display: flex;
|
|
625
|
-
flex-direction: column;
|
|
626
|
-
align-items: center;
|
|
627
|
-
gap: 2lh;
|
|
620
|
+
footer {
|
|
628
621
|
|
|
629
|
-
|
|
630
|
-
list-style: none;
|
|
631
|
-
padding: 0;
|
|
622
|
+
text-align: center;
|
|
632
623
|
display: flex;
|
|
633
|
-
|
|
624
|
+
flex-direction: column;
|
|
625
|
+
align-items: center;
|
|
626
|
+
gap: 2lh;
|
|
634
627
|
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
628
|
+
nav ul {
|
|
629
|
+
list-style: none;
|
|
630
|
+
padding: 0;
|
|
631
|
+
display: flex;
|
|
632
|
+
gap: 1ch;
|
|
633
|
+
|
|
634
|
+
img {
|
|
635
|
+
height: 1em;
|
|
636
|
+
width: auto;
|
|
637
|
+
}
|
|
638
638
|
}
|
|
639
|
-
}
|
|
640
639
|
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
640
|
+
section {
|
|
641
|
+
display: block;
|
|
642
|
+
}
|
|
644
643
|
|
|
645
|
-
|
|
644
|
+
a {
|
|
646
645
|
|
|
647
|
-
|
|
648
|
-
|
|
646
|
+
&,
|
|
647
|
+
&:visited {
|
|
649
648
|
|
|
650
|
-
|
|
651
|
-
|
|
649
|
+
color: var(--color-link-body);
|
|
650
|
+
}
|
|
652
651
|
|
|
653
|
-
|
|
654
|
-
|
|
652
|
+
&:hover {
|
|
653
|
+
color: var(--color-link-hover-menu);
|
|
654
|
+
}
|
|
655
655
|
}
|
|
656
|
+
|
|
656
657
|
}
|
|
657
658
|
|
|
658
|
-
|
|
659
|
+
/* homepage */
|
|
660
|
+
@media (min-width: 140rch) {
|
|
661
|
+
body.page_home {
|
|
662
|
+
grid-template-columns: 1fr 15rch 70rch 15rch 1fr;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
659
665
|
|
|
660
|
-
/* homepage */
|
|
661
|
-
@media (min-width: 140rch) {
|
|
662
666
|
body.page_home {
|
|
663
|
-
grid-template-columns: 1fr 15rch 70rch 15rch 1fr;
|
|
664
|
-
}
|
|
665
|
-
}
|
|
666
667
|
|
|
667
|
-
body.page_home {
|
|
668
668
|
|
|
669
|
+
/*
|
|
670
|
+
The homepage's slogan is the hero
|
|
671
|
+
*/
|
|
672
|
+
main>[itemprop="slogan"] {
|
|
673
|
+
font-size: 4em;
|
|
674
|
+
font-weight: bold;
|
|
675
|
+
padding-block: 0.5lh;
|
|
669
676
|
|
|
670
|
-
/*
|
|
671
|
-
The homepage's slogan is the hero
|
|
672
|
-
*/
|
|
673
|
-
main>[itemprop="slogan"] {
|
|
674
|
-
font-size: 4em;
|
|
675
|
-
font-weight: bold;
|
|
676
|
-
padding-block: 0.5lh;
|
|
677
677
|
|
|
678
|
+
}
|
|
678
679
|
|
|
679
|
-
|
|
680
|
+
/*
|
|
681
|
+
Hide breadcrumbs
|
|
682
|
+
*/
|
|
683
|
+
[aria-label=Breadcrumbs],
|
|
684
|
+
[aria-label=Contents],
|
|
685
|
+
&>aside>nav,
|
|
686
|
+
&>main>h1 {
|
|
687
|
+
display: none;
|
|
688
|
+
}
|
|
680
689
|
|
|
681
|
-
/*
|
|
682
|
-
Hide breadcrumbs
|
|
683
|
-
*/
|
|
684
|
-
[aria-label=Breadcrumbs],
|
|
685
|
-
[aria-label=Contents],
|
|
686
|
-
&>aside>nav,
|
|
687
|
-
&>main>h1 {
|
|
688
|
-
display: none;
|
|
689
690
|
}
|
|
690
|
-
|
|
691
|
-
}
|
|
691
|
+
}
|
package/utils.js
CHANGED
|
@@ -99,6 +99,23 @@ export function toTitleCase(string) {
|
|
|
99
99
|
}).join(" ")
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Where a problem is, the way editors and terminals read it:
|
|
104
|
+
* `blog/post.md:6:15`, which VS Code's terminal opens at that line and
|
|
105
|
+
* column. The file alone when there is no position: a problem with the
|
|
106
|
+
* whole file, or a node the build made rather than read. The path is
|
|
107
|
+
* the source's as written, salt and all: a secret page is not secret
|
|
108
|
+
* from whoever can read the project, so hiding it here would only
|
|
109
|
+
* pretend otherwise (per Sam).
|
|
110
|
+
* @param {string} file - a source path
|
|
111
|
+
* @param {{ line?: number, column?: number }} [point] - a node's `position.start`
|
|
112
|
+
*/
|
|
113
|
+
export function sourceLocation(file, point) {
|
|
114
|
+
if (!point?.line) return file
|
|
115
|
+
if (!point.column) return `${file}:${point.line}`
|
|
116
|
+
return `${file}:${point.line}:${point.column}`
|
|
117
|
+
}
|
|
118
|
+
|
|
102
119
|
/**
|
|
103
120
|
* A tag page's markdown: a title and the glob directive that lists every
|
|
104
121
|
* page carrying the tag.
|
|
@@ -171,6 +188,32 @@ export function isPageWithoutFile(target) {
|
|
|
171
188
|
return target.write === false && target.metadata?.html_file === false
|
|
172
189
|
}
|
|
173
190
|
|
|
191
|
+
/**
|
|
192
|
+
* Whether a page has a markdown mirror (plugins/markdown/mirror.js).
|
|
193
|
+
* The root settings.md's `markdown_mirror` switches every page, and the
|
|
194
|
+
* page's own key overrides it. A page without a file is mirrored only
|
|
195
|
+
* when it asks itself: `html_file: false` used to mean "published
|
|
196
|
+
* nowhere", and switching mirrors on for a site must not publish it.
|
|
197
|
+
* Another virtual page (`status: deleted`) is never mirrored.
|
|
198
|
+
* @param {any} page
|
|
199
|
+
* @param {boolean} siteMirrors - the root settings.md's `markdown_mirror`
|
|
200
|
+
*/
|
|
201
|
+
export function isMirrored(page, siteMirrors) {
|
|
202
|
+
if (page.extension !== ".html") return false
|
|
203
|
+
const own = page.metadata?.markdown_mirror
|
|
204
|
+
if (isPageWithoutFile(page)) return own === true
|
|
205
|
+
if (page.write === false) return false
|
|
206
|
+
return typeof own === "boolean" ? own : siteMirrors
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Where a page's mirror is: `blog/post.html` → `blog/post.md`.
|
|
211
|
+
* @param {string} pagePath - a stored target path
|
|
212
|
+
*/
|
|
213
|
+
export function mirrorPath(pagePath) {
|
|
214
|
+
return pagePath.replace(/\.html$/, ".md")
|
|
215
|
+
}
|
|
216
|
+
|
|
174
217
|
/**
|
|
175
218
|
* listPages, plus pages without a file: for listings, cards and the
|
|
176
219
|
* feed, which can show a page without linking to it.
|