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 CHANGED
@@ -281,12 +281,24 @@ async function main() {
281
281
  return
282
282
  }
283
283
 
284
- // `vowel export <folder>`: the export command (plugins/export), spelled
285
- // as a word rather than --command/--payload.
286
- if (args._?.[0] === "export") {
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, { announceSecretPages: true, ...overrides })
18
+ const config = createConfig(sourceFolder, overrides)
24
19
  return startServer(config)
25
20
  }
26
21
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "vowel",
3
3
  "type": "module",
4
- "version": "0.7.3",
4
+ "version": "0.7.5",
5
5
  "engines": {
6
6
  "node": ">=26"
7
7
  },
@@ -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 <= limit) return { metadata: { 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
- /** @type {Votive.ProcessorWrite} */
44
- function writeAsset(target) {
45
- if (target.metadata?.oversized) return undefined
46
- return { data: target.buffer() }
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} */
@@ -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 `#`. No title: it is outside the section.
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 === "blockquote" || hasClass(node, "markdown-alert")) return [{ type: "blockquote", children: blocks(node.children ?? [], base) }]
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 }
@@ -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
- try {
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", `${target.source ?? target.path}: link to ${href} matches no page`)
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", `${target.source ?? target.path}: [[${name}]] matches no page`)
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", `${context.target.source ?? context.target.path}: image ${src} matches no file`)
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", `${target.source ?? target.path}: a listing by published path cannot name a secret folder (${value}); write it relative to this file, as \`./…\``)
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.path}: ${value} cannot be resolved from a page with no source file`)
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. On a parse error the node is replaced in
240
- * the tree by the markdown it would have been - rule 1 above - and
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
- try {
250
- // logLevel "error": yaml otherwise prints its own warnings through
251
- // process.emitWarning, naming no file - `date: {{ date }}` printed
252
- // "Keys with collection values will be stringified". A value vowel
253
- // reads is checked by coerce(), which reports it with the path.
254
- const parsed = yaml.parse(node.value, { logLevel: "error" })
255
- if (parsed === null || parsed === undefined) return {}
256
- if (!isPlainObject(parsed)) {
257
- report(`${filePath}: frontmatter is ${Array.isArray(parsed) ? "a list" : "a single value"}, not a mapping; rendering it as content`)
258
- return asContent(tree, node)
259
- }
260
- return parsed
261
- } catch (error) {
262
- report(`${filePath}: frontmatter could not be parsed (${String(error.message).split("\n")[0]}); rendering it as content`)
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
- if (node.type === "highlight") return h("mark", state.all(node))
153
- if (node.type === "time") return h("time", { datetime: node.datetime }, state.all(node))
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", `${filePath}: \`title:\` in a settings file does nothing - the site's name is \`name:\``)
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", `${filePath}: \`slogan:\` in a settings file does nothing - a slogan belongs to a page, e.g. home.md`)
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 parsed = parseFrontmatter(tree, node, filePath, report)
242
- if (!parsed) return
242
+ const read = parseFrontmatter(tree, node, filePath, report)
243
+ if (!read) return
243
244
 
244
- const frontmatter = coerce(parsed, filePath, folder, report)
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 {string} value
86
+ * @param {Node} text - a text node
50
87
  * @param {any} context
51
88
  * @returns {Node[] | null}
52
89
  */
53
- function applyInline(value, context) {
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.value, context)
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: the dev server prints each one, file and url,
10
- * when it first sees it - every one at startup, then each new one (or a
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
- * **Only the dev server.** `init()` turns this on with
14
- * `config.announceSecretPages`; `deploy`, `publish` and a plain build
15
- * never do. Those run in CI too, where logs are kept and can be public,
16
- * and a logged url is exactly what the hash exists to keep private.
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 (!config.announceSecretPages || config.logging === "silent") return []
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
- text-align: center;
624
- display: flex;
625
- flex-direction: column;
626
- align-items: center;
627
- gap: 2lh;
620
+ footer {
628
621
 
629
- nav ul {
630
- list-style: none;
631
- padding: 0;
622
+ text-align: center;
632
623
  display: flex;
633
- gap: 1ch;
624
+ flex-direction: column;
625
+ align-items: center;
626
+ gap: 2lh;
634
627
 
635
- img {
636
- height: 1em;
637
- width: auto;
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
- section {
642
- display: block;
643
- }
640
+ section {
641
+ display: block;
642
+ }
644
643
 
645
- a {
644
+ a {
646
645
 
647
- &,
648
- &:visited {
646
+ &,
647
+ &:visited {
649
648
 
650
- color: var(--color-link-body);
651
- }
649
+ color: var(--color-link-body);
650
+ }
652
651
 
653
- &:hover {
654
- color: var(--color-link-hover-menu);
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.