vowel 0.7.3 → 0.7.4

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/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.4",
5
5
  "engines": {
6
6
  "node": ">=26"
7
7
  },
@@ -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"))
@@ -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'
@@ -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
@@ -69,6 +69,7 @@ const EXPECTED = {
69
69
  aliases: "strings",
70
70
  status: "status",
71
71
  html_file: "boolean",
72
+ markdown_mirror: "boolean",
72
73
  rss_item: "boolean",
73
74
  sitemap_item: "boolean",
74
75
  global_nav_item: "boolean",
@@ -21,6 +21,7 @@ import { isSecretPath, secretRouter, displayPath } from "./../../secretPaths.js"
21
21
  import { collectLinks } from "./links.js"
22
22
  import { readWalk } from "./readRules.js"
23
23
  import { h } from "hastscript"
24
+ import { mirrorStubs, mirrorWriter } from "./mirror.js"
24
25
 
25
26
  import { styleText } from "node:util"
26
27
 
@@ -233,6 +234,11 @@ function settingsContribution(metadata, filePath, log) {
233
234
  if (metadata.fm_slogan !== undefined) {
234
235
  log?.("warn", `${filePath}: \`slogan:\` in a settings file does nothing - a slogan belongs to a page, e.g. home.md`)
235
236
  }
237
+ // Mirrors are declared by an enumerator, which sees the root's
238
+ // settings only.
239
+ if (metadata.markdown_mirror !== undefined && filePath !== "settings.md") {
240
+ log?.("warn", `${filePath}: \`markdown_mirror:\` works only in the root settings.md or in a page's frontmatter`)
241
+ }
236
242
  const declared = Object.entries(metadata)
237
243
  .filter(([key]) => key.startsWith("fm_") || reservedProperties.includes(key))
238
244
  return {
@@ -242,19 +248,6 @@ function settingsContribution(metadata, filePath, log) {
242
248
  }
243
249
  }
244
250
 
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
251
 
259
252
 
260
253
 
@@ -491,8 +484,7 @@ const readMarkdown = {
491
484
  router,
492
485
  createStubs,
493
486
  expandStubs,
494
- readFile,
495
- writeFile: writeMarkdown
487
+ readFile
496
488
  }
497
489
 
498
490
 
@@ -500,7 +492,7 @@ const readMarkdown = {
500
492
  /** @type {Votive.VotivePlugin} */
501
493
  const vowelMarkdownPlugin = {
502
494
  name: "vowel",
503
- processors: [readMarkdown]
495
+ processors: [readMarkdown, mirrorStubs, mirrorWriter]
504
496
  }
505
497
 
506
498
  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",
@@ -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 }
@@ -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
@@ -171,6 +171,32 @@ export function isPageWithoutFile(target) {
171
171
  return target.write === false && target.metadata?.html_file === false
172
172
  }
173
173
 
174
+ /**
175
+ * Whether a page has a markdown mirror (plugins/markdown/mirror.js).
176
+ * The root settings.md's `markdown_mirror` switches every page, and the
177
+ * page's own key overrides it. A page without a file is mirrored only
178
+ * when it asks itself: `html_file: false` used to mean "published
179
+ * nowhere", and switching mirrors on for a site must not publish it.
180
+ * Another virtual page (`status: deleted`) is never mirrored.
181
+ * @param {any} page
182
+ * @param {boolean} siteMirrors - the root settings.md's `markdown_mirror`
183
+ */
184
+ export function isMirrored(page, siteMirrors) {
185
+ if (page.extension !== ".html") return false
186
+ const own = page.metadata?.markdown_mirror
187
+ if (isPageWithoutFile(page)) return own === true
188
+ if (page.write === false) return false
189
+ return typeof own === "boolean" ? own : siteMirrors
190
+ }
191
+
192
+ /**
193
+ * Where a page's mirror is: `blog/post.html` → `blog/post.md`.
194
+ * @param {string} pagePath - a stored target path
195
+ */
196
+ export function mirrorPath(pagePath) {
197
+ return pagePath.replace(/\.html$/, ".md")
198
+ }
199
+
174
200
  /**
175
201
  * listPages, plus pages without a file: for listings, cards and the
176
202
  * feed, which can show a page without linking to it.