vowel 0.7.1 → 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/bin.js +18 -0
- package/package.json +2 -3
- package/plugins/atproto/body.js +32 -3
- package/plugins/atproto/publish.js +8 -2
- package/plugins/html/bundle/index.js +51 -28
- package/plugins/html/editor/frontmatter.js +44 -10
- package/plugins/html/editor/ingest.js +10 -23
- package/plugins/html/editor/main.js +5 -1
- package/plugins/html/editor/socket.js +34 -4
- package/plugins/html/image.js +77 -22
- package/plugins/html/index.js +42 -13
- package/plugins/html/partials.js +27 -9
- package/plugins/html/socialLinks.js +8 -3
- package/plugins/html/writeRules.js +48 -14
- package/plugins/images/index.js +29 -6
- package/plugins/markdown/frontmatter.js +1 -0
- package/plugins/markdown/index.js +12 -18
- package/plugins/markdown/metadata.js +62 -13
- package/plugins/markdown/mirror.js +121 -0
- package/stylesheets/DefaultStyles.css +58 -56
- package/stylesheets/TypographyStyles.css +5 -5
- package/utils.js +26 -20
|
@@ -6,6 +6,49 @@ import { displayPath } from "../../secretPaths.js"
|
|
|
6
6
|
import pathDate from "../../pathDate.js"
|
|
7
7
|
import { coerce, parseFrontmatter, toISODate } from "./frontmatter.js"
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* An HTML comment, or several in a row with nothing else between them.
|
|
11
|
+
* It stays in the page, but metadata never reads it (per Sam): a
|
|
12
|
+
* comment is not a title, a date or a description, and it doesn't take
|
|
13
|
+
* a block's place in the scan for one.
|
|
14
|
+
* @param {any} node
|
|
15
|
+
*/
|
|
16
|
+
function isComment(node) {
|
|
17
|
+
return node.type === "html" && /^\s*(?:<!--[\s\S]*?-->\s*)+$/.test(node.value)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A node's text for metadata: mdastToString with its HTML comments left
|
|
22
|
+
* out, at any depth. Where a comment sat between two words, the spaces
|
|
23
|
+
* either side of it close up to one.
|
|
24
|
+
* @param {any} node
|
|
25
|
+
* @returns {string}
|
|
26
|
+
*/
|
|
27
|
+
function metadataText(node) {
|
|
28
|
+
if (isComment(node)) return ""
|
|
29
|
+
if (!node.children) return mdastToString(node)
|
|
30
|
+
const text = node.children.map(metadataText).join("")
|
|
31
|
+
if (!node.children.some(isComment)) return text
|
|
32
|
+
return text.replace(/[ \t]{2,}/g, " ")
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* An image the page's text names, as the browser resolves it from the
|
|
37
|
+
* page: `pic.jpg` and `./pic.jpg` are the page's folder's, `../pic.jpg`
|
|
38
|
+
* its parent's, `/pic.jpg` the root's. Stored as a path from the root,
|
|
39
|
+
* the way the html write resolves the same image (`fromPage` in
|
|
40
|
+
* writeRules.js), because a listing card and `og:image` read it from
|
|
41
|
+
* pages in other folders. An external url is left as written. (A
|
|
42
|
+
* frontmatter `image:` is a setting-style path instead: `resolvePath`.)
|
|
43
|
+
* @param {string} url - as written
|
|
44
|
+
* @param {string} folder - the page's target folder, project-relative ("" at the root)
|
|
45
|
+
*/
|
|
46
|
+
export function imageFromPage(url, folder) {
|
|
47
|
+
if (/^[a-z][a-z\d+.-]*:/i.test(url) || url.startsWith("//")) return url
|
|
48
|
+
const base = new URL(folder ? `/${folder.split(path.sep).join("/")}/` : "/", "thismessage://")
|
|
49
|
+
return decodeURIComponent(new URL(url.replaceAll("#", "%23"), base).pathname)
|
|
50
|
+
}
|
|
51
|
+
|
|
9
52
|
|
|
10
53
|
/**
|
|
11
54
|
- menu_item: true | false
|
|
@@ -36,6 +79,7 @@ export const reservedProperties = [
|
|
|
36
79
|
"rss_item",
|
|
37
80
|
"sitemap_item",
|
|
38
81
|
"html_file",
|
|
82
|
+
"markdown_mirror",
|
|
39
83
|
"global_nav_item",
|
|
40
84
|
"local_nav_item",
|
|
41
85
|
"theme",
|
|
@@ -50,7 +94,7 @@ export const reservedProperties = [
|
|
|
50
94
|
* @param {object} tree
|
|
51
95
|
*/
|
|
52
96
|
export function findTitleNode(tree) {
|
|
53
|
-
const [first] = tree.children.filter(child => child.type !== "yaml")
|
|
97
|
+
const [first] = tree.children.filter(child => child.type !== "yaml" && !isComment(child))
|
|
54
98
|
if (!first) return null
|
|
55
99
|
if (first.type !== "heading" || first.depth !== 1) return null
|
|
56
100
|
return first
|
|
@@ -101,24 +145,29 @@ function markAsTime(paragraph, date) {
|
|
|
101
145
|
*
|
|
102
146
|
* @param {object} block
|
|
103
147
|
* @param {object} metadata
|
|
148
|
+
* @param {string} folder - the page's target folder, for a relative image
|
|
104
149
|
* @returns {boolean} whether the block was recognizable data
|
|
105
150
|
*/
|
|
106
|
-
function recognizeData(block, metadata) {
|
|
151
|
+
function recognizeData(block, metadata, folder) {
|
|
107
152
|
if (block.type !== "paragraph") return false
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
const [child] = block.children
|
|
153
|
+
const children = block.children.filter(child => !isComment(child))
|
|
154
|
+
const [child] = children
|
|
111
155
|
|
|
112
156
|
const imageFilled = Boolean(metadata.fm_image || metadata.inferred_image)
|
|
113
157
|
|
|
114
|
-
|
|
158
|
+
// An image, alone or with its caption after it on the same line: the
|
|
159
|
+
// html write makes the pair a <figure> and lifts it whole. The caption
|
|
160
|
+
// is not a property of the page.
|
|
161
|
+
if (child?.type === "image") {
|
|
115
162
|
if (imageFilled) return false
|
|
116
|
-
metadata.inferred_image = child.url
|
|
163
|
+
metadata.inferred_image = imageFromPage(child.url, folder)
|
|
117
164
|
metadata.inferred_alt_text = child.alt
|
|
118
165
|
return "image"
|
|
119
166
|
}
|
|
120
167
|
|
|
121
|
-
|
|
168
|
+
if (children.length !== 1) return false
|
|
169
|
+
|
|
170
|
+
const text = metadataText(block)
|
|
122
171
|
|
|
123
172
|
// A reference or a listing directive (`/blog/post`, `/blog/**`,
|
|
124
173
|
// `./drafts/*`) is an instruction to the html plugin, not prose - it
|
|
@@ -227,16 +276,16 @@ function getMetadata(tree, filePath, targetPath, report = () => {}) {
|
|
|
227
276
|
|
|
228
277
|
const titleNode = findTitleNode(tree)
|
|
229
278
|
if (titleNode) {
|
|
230
|
-
metadata.inferred_title =
|
|
279
|
+
metadata.inferred_title = metadataText(titleNode).trim() || labelFromName(path.parse(filePath).name)
|
|
231
280
|
}
|
|
232
281
|
|
|
233
282
|
// Everything up to the first block that is not recognizable data is
|
|
234
283
|
// metadata; that first block is where the content begins, and it also
|
|
235
284
|
// supplies the description.
|
|
236
|
-
const blocks = tree.children.filter(child => child.type !== "yaml" && child !== titleNode)
|
|
285
|
+
const blocks = tree.children.filter(child => child.type !== "yaml" && child !== titleNode && !isComment(child))
|
|
237
286
|
|
|
238
287
|
for (const [index, block] of blocks.entries()) {
|
|
239
|
-
const recognized = recognizeData(block, metadata)
|
|
288
|
+
const recognized = recognizeData(block, metadata, folder)
|
|
240
289
|
if (recognized === "skip") continue
|
|
241
290
|
if (recognized) {
|
|
242
291
|
markAsNatural(block, recognized)
|
|
@@ -251,13 +300,13 @@ function getMetadata(tree, filePath, targetPath, report = () => {}) {
|
|
|
251
300
|
// markdown, not a paragraph and a rule; it takes a blank line.)
|
|
252
301
|
const next = blocks[index + 1]
|
|
253
302
|
if (!metadata.fm_description && block.type === "paragraph" && next?.type === "thematicBreak") {
|
|
254
|
-
metadata.declared_description =
|
|
303
|
+
metadata.declared_description = metadataText(block).trim()
|
|
255
304
|
markAsNatural(block, "description")
|
|
256
305
|
markAsNatural(next, "end")
|
|
257
306
|
break
|
|
258
307
|
}
|
|
259
308
|
|
|
260
|
-
if (!metadata.fm_description) metadata.inferred_description =
|
|
309
|
+
if (!metadata.fm_description) metadata.inferred_description = metadataText(block).trim()
|
|
261
310
|
break
|
|
262
311
|
}
|
|
263
312
|
|
|
@@ -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 }
|
|
@@ -311,6 +311,7 @@
|
|
|
311
311
|
ul {
|
|
312
312
|
list-style: none;
|
|
313
313
|
display: flex;
|
|
314
|
+
flex-wrap: wrap;
|
|
314
315
|
gap: 2ch;
|
|
315
316
|
}
|
|
316
317
|
|
|
@@ -438,6 +439,11 @@
|
|
|
438
439
|
}
|
|
439
440
|
}
|
|
440
441
|
|
|
442
|
+
/* the page's own image, beside its title, as well as the content's */
|
|
443
|
+
img {
|
|
444
|
+
border-radius: var(--borderradius-tight);
|
|
445
|
+
}
|
|
446
|
+
|
|
441
447
|
/* content */
|
|
442
448
|
|
|
443
449
|
section#content {
|
|
@@ -454,10 +460,6 @@
|
|
|
454
460
|
}
|
|
455
461
|
}
|
|
456
462
|
|
|
457
|
-
img {
|
|
458
|
-
border-radius: var(--borderradius-tight);
|
|
459
|
-
}
|
|
460
|
-
|
|
461
463
|
mark,
|
|
462
464
|
code {
|
|
463
465
|
padding: 0.1cap 0.5ch;
|
|
@@ -612,78 +614,78 @@
|
|
|
612
614
|
display: none;
|
|
613
615
|
}
|
|
614
616
|
}
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
/* footer */
|
|
618
617
|
|
|
619
|
-
footer
|
|
618
|
+
/* footer */
|
|
620
619
|
|
|
621
|
-
|
|
622
|
-
display: flex;
|
|
623
|
-
flex-direction: column;
|
|
624
|
-
align-items: center;
|
|
625
|
-
gap: 2lh;
|
|
620
|
+
footer {
|
|
626
621
|
|
|
627
|
-
|
|
628
|
-
list-style: none;
|
|
629
|
-
padding: 0;
|
|
622
|
+
text-align: center;
|
|
630
623
|
display: flex;
|
|
631
|
-
|
|
624
|
+
flex-direction: column;
|
|
625
|
+
align-items: center;
|
|
626
|
+
gap: 2lh;
|
|
632
627
|
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
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
|
+
}
|
|
636
638
|
}
|
|
637
|
-
}
|
|
638
639
|
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
640
|
+
section {
|
|
641
|
+
display: block;
|
|
642
|
+
}
|
|
642
643
|
|
|
643
|
-
|
|
644
|
+
a {
|
|
644
645
|
|
|
645
|
-
|
|
646
|
-
|
|
646
|
+
&,
|
|
647
|
+
&:visited {
|
|
647
648
|
|
|
648
|
-
|
|
649
|
-
|
|
649
|
+
color: var(--color-link-body);
|
|
650
|
+
}
|
|
650
651
|
|
|
651
|
-
|
|
652
|
-
|
|
652
|
+
&:hover {
|
|
653
|
+
color: var(--color-link-hover-menu);
|
|
654
|
+
}
|
|
653
655
|
}
|
|
656
|
+
|
|
654
657
|
}
|
|
655
658
|
|
|
656
|
-
|
|
659
|
+
/* homepage */
|
|
660
|
+
@media (min-width: 140rch) {
|
|
661
|
+
body.page_home {
|
|
662
|
+
grid-template-columns: 1fr 15rch 70rch 15rch 1fr;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
657
665
|
|
|
658
|
-
/* homepage */
|
|
659
|
-
@media (min-width: 140rch) {
|
|
660
666
|
body.page_home {
|
|
661
|
-
grid-template-columns: 1fr 15rch 70rch 15rch 1fr;
|
|
662
|
-
}
|
|
663
|
-
}
|
|
664
667
|
|
|
665
|
-
body.page_home {
|
|
666
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;
|
|
667
676
|
|
|
668
|
-
/*
|
|
669
|
-
The homepage's slogan is the hero
|
|
670
|
-
*/
|
|
671
|
-
main>[itemprop="slogan"] {
|
|
672
|
-
font-size: 4em;
|
|
673
|
-
font-weight: bold;
|
|
674
|
-
padding-block: 0.5lh;
|
|
675
677
|
|
|
678
|
+
}
|
|
676
679
|
|
|
677
|
-
|
|
680
|
+
/*
|
|
681
|
+
Hide breadcrumbs
|
|
682
|
+
*/
|
|
683
|
+
[aria-label=Breadcrumbs],
|
|
684
|
+
[aria-label=Contents],
|
|
685
|
+
&>aside>nav,
|
|
686
|
+
&>main>h1 {
|
|
687
|
+
display: none;
|
|
688
|
+
}
|
|
678
689
|
|
|
679
|
-
/*
|
|
680
|
-
Hide breadcrumbs
|
|
681
|
-
*/
|
|
682
|
-
[aria-label=Breadcrumbs],
|
|
683
|
-
[aria-label=Contents],
|
|
684
|
-
&>aside>nav,
|
|
685
|
-
&>main>h1 {
|
|
686
|
-
display: none;
|
|
687
690
|
}
|
|
688
|
-
|
|
689
|
-
}
|
|
691
|
+
}
|
|
@@ -334,15 +334,15 @@
|
|
|
334
334
|
&>section {
|
|
335
335
|
margin-top: 1lh;
|
|
336
336
|
}
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
/* content */
|
|
340
|
-
section {
|
|
341
337
|
|
|
342
|
-
/* image caption space */
|
|
338
|
+
/* image caption space: the page's own captioned image, beside its title, as well as the content's */
|
|
343
339
|
figure {
|
|
344
340
|
gap: 1lh;
|
|
345
341
|
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/* content */
|
|
345
|
+
section {
|
|
346
346
|
|
|
347
347
|
/* list spacing */
|
|
348
348
|
ul,
|
package/utils.js
CHANGED
|
@@ -23,26 +23,6 @@ export function createImagePath({ targetFilePath, size, ext, targetDirectory, uu
|
|
|
23
23
|
})
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
/**
|
|
27
|
-
* @param {string} targetFilePath
|
|
28
|
-
* @param {string} targetDirectory
|
|
29
|
-
* @param {string} uuid
|
|
30
|
-
*/
|
|
31
|
-
export function createImagePaths(targetFilePath, targetDirectory, uuid) {
|
|
32
|
-
const defaultExt = path.extname(targetFilePath)
|
|
33
|
-
return [...imageExts, defaultExt].map(ext => {
|
|
34
|
-
return imageSizes.map(size => {
|
|
35
|
-
return createImagePath({
|
|
36
|
-
targetFilePath,
|
|
37
|
-
size,
|
|
38
|
-
ext,
|
|
39
|
-
targetDirectory,
|
|
40
|
-
uuid
|
|
41
|
-
})
|
|
42
|
-
})
|
|
43
|
-
})
|
|
44
|
-
}
|
|
45
|
-
|
|
46
26
|
/** @param {string} text */
|
|
47
27
|
export function testURL(text) {
|
|
48
28
|
if (text.match(/^https?:\/\/[\S]+$/)) {
|
|
@@ -191,6 +171,32 @@ export function isPageWithoutFile(target) {
|
|
|
191
171
|
return target.write === false && target.metadata?.html_file === false
|
|
192
172
|
}
|
|
193
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
|
+
|
|
194
200
|
/**
|
|
195
201
|
* listPages, plus pages without a file: for listings, cards and the
|
|
196
202
|
* feed, which can show a page without linking to it.
|