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 +1 -1
- package/plugins/atproto/body.js +29 -2
- package/plugins/html/index.js +13 -1
- package/plugins/markdown/frontmatter.js +1 -0
- package/plugins/markdown/index.js +8 -16
- package/plugins/markdown/metadata.js +1 -0
- package/plugins/markdown/mirror.js +121 -0
- package/stylesheets/DefaultStyles.css +52 -52
- package/utils.js +26 -0
package/package.json
CHANGED
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"))
|
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'
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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
|
-
|
|
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
|
@@ -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.
|