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.
@@ -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
- if (block.children.length !== 1) return false
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
- if (child.type === "image") {
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
- const text = mdastToString(block)
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 = mdastToString(titleNode) || labelFromName(path.parse(filePath).name)
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 = mdastToString(block)
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 = mdastToString(block)
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
- text-align: center;
622
- display: flex;
623
- flex-direction: column;
624
- align-items: center;
625
- gap: 2lh;
620
+ footer {
626
621
 
627
- nav ul {
628
- list-style: none;
629
- padding: 0;
622
+ text-align: center;
630
623
  display: flex;
631
- gap: 1ch;
624
+ flex-direction: column;
625
+ align-items: center;
626
+ gap: 2lh;
632
627
 
633
- img {
634
- height: 1em;
635
- 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
+ }
636
638
  }
637
- }
638
639
 
639
- section {
640
- display: block;
641
- }
640
+ section {
641
+ display: block;
642
+ }
642
643
 
643
- a {
644
+ a {
644
645
 
645
- &,
646
- &:visited {
646
+ &,
647
+ &:visited {
647
648
 
648
- color: var(--color-link-body);
649
- }
649
+ color: var(--color-link-body);
650
+ }
650
651
 
651
- &:hover {
652
- color: var(--color-link-hover-menu);
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.