vowel 0.2.4 → 0.2.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.
Files changed (73) hide show
  1. package/404.md +3 -0
  2. package/README.md +134 -26
  3. package/bin.js +209 -1
  4. package/config.js +20 -0
  5. package/docs-source/.votive.db +0 -0
  6. package/docs-source/blog/url-ui.md +1 -5
  7. package/docs-source/blog.md +5 -0
  8. package/docs-source/docs/items.md +1 -1
  9. package/docs-source/home.md +1 -1
  10. package/docs-source/output/about.html +1 -0
  11. package/docs-source/output/blog/url-ui.html +8 -0
  12. package/docs-source/output/blog.html +1 -0
  13. package/docs-source/output/default.css +1 -0
  14. package/docs-source/output/docs/deploy.html +34 -0
  15. package/docs-source/output/docs/file-structure.html +24 -0
  16. package/docs-source/output/docs/folder-settings.html +20 -0
  17. package/docs-source/output/docs/images.html +2 -0
  18. package/docs-source/output/docs/items.html +6 -0
  19. package/docs-source/output/docs/pages.html +101 -0
  20. package/docs-source/output/docs/styling.html +18 -0
  21. package/docs-source/output/docs/taxonomies.html +20 -0
  22. package/docs-source/output/docs.html +28 -0
  23. package/docs-source/output/features/cards.html +1 -0
  24. package/docs-source/output/features/editing.html +1 -0
  25. package/docs-source/output/features/emoji.html +1 -0
  26. package/docs-source/output/features/frontmatter.html +1 -0
  27. package/docs-source/output/features/lists.html +1 -0
  28. package/docs-source/output/features/navigation.html +1 -0
  29. package/docs-source/output/features/rich-previews.html +1 -0
  30. package/docs-source/output/features/robots.html +1 -0
  31. package/docs-source/output/features/rss.html +1 -0
  32. package/docs-source/output/features/sitemap.html +1 -0
  33. package/docs-source/output/features/speed.html +1 -0
  34. package/docs-source/output/features/static.html +1 -0
  35. package/docs-source/output/features/taxonomies.html +1 -0
  36. package/docs-source/output/features.html +1 -0
  37. package/docs-source/output/feed.xml +1 -0
  38. package/docs-source/output/index.html +21 -0
  39. package/docs-source/output/reset.css +1 -0
  40. package/docs-source/output/roadmap.html +87 -0
  41. package/docs-source/output/robots.txt +14 -0
  42. package/docs-source/output/sitemap.xml +16 -0
  43. package/docs-source/output/typography.css +1 -0
  44. package/docs-source/settings.md +1 -0
  45. package/getMetadata.js +1 -1
  46. package/index.js +5 -660
  47. package/package.json +16 -1
  48. package/plugins/fonts/index.js +26 -0
  49. package/plugins/icons/index.js +26 -0
  50. package/plugins/images/index.js +45 -0
  51. package/plugins/markdown/index.js +1097 -0
  52. package/plugins/robots/index.js +23 -0
  53. package/plugins/styles/index.js +69 -0
  54. package/plugins/vectors/index.js +38 -0
  55. package/plugins/xml/index.js +196 -0
  56. package/stylesheets/DefaultStyles.css +329 -263
  57. package/stylesheets/ResetStyles.css +119 -123
  58. package/stylesheets/TypographyStyles.css +259 -242
  59. package/docs-source/assets/styles.css +0 -51
  60. package/docs-source/blog/home.md +0 -5
  61. /package/docs-source/{$features → features}/cards.md +0 -0
  62. /package/docs-source/{$features → features}/editing.md +0 -0
  63. /package/docs-source/{$features → features}/emoji.md +0 -0
  64. /package/docs-source/{$features → features}/frontmatter.md +0 -0
  65. /package/docs-source/{$features → features}/lists.md +0 -0
  66. /package/docs-source/{$features → features}/navigation.md +0 -0
  67. /package/docs-source/{$features → features}/rich-previews.md +0 -0
  68. /package/docs-source/{$features → features}/robots.md +0 -0
  69. /package/docs-source/{$features → features}/rss.md +0 -0
  70. /package/docs-source/{$features → features}/sitemap.md +0 -0
  71. /package/docs-source/{$features → features}/speed.md +0 -0
  72. /package/docs-source/{$features → features}/static.md +0 -0
  73. /package/docs-source/{$features → features}/taxonomies.md +0 -0
@@ -0,0 +1,1097 @@
1
+ import extractDate from "./../../extractDate.js"
2
+ import path from "node:path"
3
+ import rehypeDocument from 'rehype-document'
4
+ import rehypePresetMinify from "rehype-preset-minify"
5
+ import rehypeStringify from "rehype-stringify"
6
+ import yaml from 'yaml'
7
+ import { fromHtml } from 'hast-util-from-html'
8
+ import { fromMarkdown } from 'mdast-util-from-markdown'
9
+ import { frontmatter } from "micromark-extension-frontmatter"
10
+ import { frontmatterFromMarkdown } from 'mdast-util-frontmatter'
11
+ import { gfmFootnote } from "micromark-extension-gfm-footnote"
12
+ import { gfmFootnoteFromMarkdown } from "mdast-util-gfm-footnote"
13
+ import { gfmStrikethrough } from 'micromark-extension-gfm-strikethrough'
14
+ import { gfmStrikethroughFromMarkdown } from 'mdast-util-gfm-strikethrough'
15
+ import { gfmTable } from 'micromark-extension-gfm-table'
16
+ import { gfmTableFromMarkdown } from 'mdast-util-gfm-table'
17
+ import { h } from 'hastscript'
18
+ import { normalizeHeadings } from 'mdast-normalize-headings'
19
+ import { readFileSync } from "fs"
20
+ import { remove } from "unist-util-remove"
21
+ import { testURL } from "./../../utils.js"
22
+ import { toHast } from 'mdast-util-to-hast'
23
+ import { toString as hastToString } from 'hast-util-to-string'
24
+ import { toString as mdastToString } from 'mdast-util-to-string'
25
+ import { unified } from "unified"
26
+ import { visit } from "unist-util-visit"
27
+ import toc from "@jsdevtools/rehype-toc"
28
+ import slug from "rehype-slug"
29
+
30
+ const VOWEL_DIR = path.normalize(path.join(import.meta.dirname, "../../"))
31
+
32
+ /** @import * as Votive from "votive" */
33
+ /** @import * as Vowel from "./../../index.js" */
34
+
35
+
36
+ const robots = `User-agent: Google-Extended
37
+ Allow: /
38
+
39
+ User-agent: Googlebot-Image
40
+ Disallow: /
41
+
42
+ User-agent: GPTBot
43
+ Disallow: /
44
+
45
+ User-agent: ChatGPT-User
46
+ Disallow: /
47
+
48
+ User-agent: CCBot
49
+ Disallow: /`
50
+
51
+
52
+ function makeHead(metadata, url) {
53
+
54
+ const treeMainHead = []
55
+
56
+ if (metadata.title) treeMainHead.push(
57
+ h("h1", metadata.title)
58
+ )
59
+
60
+ if (metadata.date) {
61
+ const date = new Date(metadata.date)
62
+ treeMainHead.push(
63
+ h("time",
64
+ {
65
+ datetime: date.toISOString(),
66
+ itemprop: "date"
67
+ },
68
+ date.toLocaleDateString("en-US", {
69
+ year: "numeric",
70
+ month: "long",
71
+ day: "numeric"
72
+ })
73
+ )
74
+ )
75
+ }
76
+
77
+ if (metadata.image) treeMainHead.push(
78
+ h("img", {
79
+ src: metadata.image,
80
+ itemprop: "image"
81
+ })
82
+ )
83
+
84
+ if (metadata.fm_description) treeMainHead.push(
85
+ h("p",
86
+ {
87
+ itemprop: "description"
88
+ },
89
+ metadata.fm_description
90
+ )
91
+ )
92
+
93
+ if (url) return h('a', { href: url }, treeMainHead)
94
+
95
+ return treeMainHead
96
+ }
97
+
98
+ function toTitleCase(string) {
99
+ if (!string) return
100
+ return string.split(" ").map(word => {
101
+ const letters = word.split("")
102
+ letters[0] = letters[0].toUpperCase()
103
+ return letters.join("")
104
+ }).join(" ")
105
+ }
106
+
107
+ function readURL(data) {
108
+ const hast = fromHtml(data)
109
+ const metadata = {}
110
+
111
+ // TODO: Remove to a separate processor
112
+ visit(hast, (node) => {
113
+ if (node.tagName === "meta") {
114
+ if (node.properties && node.properties.property) {
115
+ metadata[node.properties.property] = node.properties.content
116
+ }
117
+ } else if (node.tagName === "title") {
118
+ metadata.title = hastToString(node)
119
+ } else if (node.tagName === "link") {
120
+ if (node.properties?.rel?.includes("me")) {
121
+ metadata.me = node.properties.href
122
+ } else if (node.properties?.rel?.includes("webmention")) {
123
+ metadata.webmention = node.properties.href
124
+ } else if (node.properties?.rel?.includes("icon")) {
125
+ metadata.icon = node.properties.href
126
+ }
127
+ }
128
+ })
129
+
130
+ return metadata
131
+ }
132
+
133
+
134
+ /** @type {Votive.ReadText} */
135
+ function readMarkdown(string, filePath, destinationPath, database, config) {
136
+ const mdast = fromMarkdown(string, {
137
+ // Micromark extensions
138
+ extensions: [
139
+ frontmatter(),
140
+ gfmFootnote(),
141
+ gfmStrikethrough(),
142
+ gfmTable()
143
+ ],
144
+ mdastExtensions: [
145
+ frontmatterFromMarkdown(),
146
+ gfmFootnoteFromMarkdown(),
147
+ gfmStrikethroughFromMarkdown(),
148
+ gfmTableFromMarkdown()
149
+ ]
150
+ })
151
+
152
+ normalizeHeadings(mdast)
153
+ const pathInfo = path.parse(filePath)
154
+
155
+ const startTitle = performance.now()
156
+ const metadata = getMetadata(mdast, filePath)
157
+
158
+ metadata.inferred_label = toTitleCase(pathInfo.name)
159
+
160
+ if (destinationPath) {
161
+ const destinationInfo = path.parse(destinationPath)
162
+ const name = destinationInfo.name === "index" ? "" : destinationInfo.name
163
+ // FIXME the prettyURL should include the preceding slash
164
+ metadata.prettyURL = (new URL(`${destinationInfo.dir}/${name}`, "thismessage:/")).pathname
165
+ }
166
+ const jobs = []
167
+
168
+ selectMetadata(metadata)
169
+
170
+ if (pathInfo.base === "settings.md") {
171
+ for (const key in metadata) {
172
+ database.setting.create(
173
+ pathInfo.dir,
174
+ key,
175
+ metadata[key],
176
+ filePath
177
+ )
178
+ }
179
+ }
180
+
181
+
182
+ visit(mdast, (node, index, parent) => {
183
+ if (node.type === "text" && parent.children.length === 1 && parent.type === "paragraph") {
184
+ const validURL = testURL(node.value)
185
+ if (validURL) {
186
+
187
+ jobs.push({
188
+ data: node.value,
189
+ runner: "text",
190
+ destination: destinationPath
191
+ })
192
+ }
193
+ }
194
+ })
195
+
196
+
197
+ if (filePath === "settings.md") {
198
+ if (metadata.fm_domain) {
199
+ database.target.create({
200
+ path: "sitemap.xml",
201
+ abstract: {},
202
+ metadata: {
203
+ domain: metadata.fm_domain,
204
+ title: metadata.title
205
+ }
206
+ })
207
+
208
+ database.target.create({
209
+ path: "feed.xml",
210
+ abstract: {},
211
+ metadata: {
212
+ domain: metadata.fm_domain,
213
+ title: metadata.title
214
+ }
215
+ })
216
+ }
217
+ }
218
+
219
+
220
+ return { abstract: mdast, metadata, jobs }
221
+ }
222
+
223
+ /** @param {ReturnType<getMetadata>} metadata */
224
+ function selectMetadata(metadata) {
225
+ const date =
226
+ metadata.fm_date
227
+ || metadata.inferred_date
228
+
229
+ if (date) {
230
+ metadata.date = date
231
+ }
232
+
233
+ const title =
234
+ metadata.fm_title
235
+ || metadata.inferred_title
236
+ || metadata.inferred_label
237
+
238
+ if (title) {
239
+ metadata.title = title
240
+ }
241
+
242
+ const breadcrumb =
243
+ metadata.fm_breadcrumb
244
+ || metadata.title
245
+ || metadata.inferred_label
246
+
247
+ if (breadcrumb) {
248
+ metadata.breadcrumb = breadcrumb
249
+ }
250
+
251
+ const description =
252
+ metadata.fm_description
253
+ || metadata.tagline
254
+ || metadata.inferred_description
255
+
256
+ if (description) {
257
+ metadata.description = description
258
+ }
259
+
260
+ const image =
261
+ metadata.fm_image
262
+ || metadata.inferred_image
263
+
264
+ if (image) {
265
+ metadata.image = image
266
+ }
267
+ }
268
+
269
+ /**
270
+ * @param {string} text
271
+ */
272
+ function truncateText(text) {
273
+ // const match = text.match(/^(?<desc>((\b.+?){28})\S)\s(?<etc>.+)$/)
274
+ // if (!match) {
275
+ return text
276
+ // } else if (match.groups.etc) {
277
+ // console.log("truncate 4")
278
+ // return match.groups.desc + "..."
279
+ // } else {
280
+ // console.log("truncate 5")
281
+ // return match.groups.desc
282
+ // }
283
+ }
284
+
285
+ /** @param {object} tree */
286
+ function getMetadata(tree, filePath) {
287
+ const metadata = {}
288
+
289
+ for (let i = 0; i < tree.children.length; i++) {
290
+ const child = tree.children[i]
291
+ const text = mdastToString(child)
292
+ switch (child.type) {
293
+ case "paragraph":
294
+ if (child.children.length !== 1) {
295
+ if (!metadata.fm_description && !metadata.inferred_description) {
296
+ metadata.inferred_description = text
297
+ }
298
+ i = Infinity
299
+ break
300
+ } else if (child.children[0].type === "image") {
301
+ metadata.inferred_image = child.children[0].url
302
+ metadata.inferred_alt_text = child.children[0].alt
303
+ break
304
+ } else if (testURL(text)) {
305
+ const url = new URL(text)
306
+ if (text.match(/\.(jpeg|jpg|png)$/)) {
307
+ metadata.inferred_image = url
308
+ break
309
+ } else {
310
+ metadata.inferred_link = url
311
+ break
312
+ }
313
+ } else {
314
+ const inferred_date = extractDate(mdastToString(child))
315
+ if (inferred_date) {
316
+ metadata.inferred_date = inferred_date
317
+ } else {
318
+ if (!metadata.fm_description && !metadata.inferred_description) {
319
+ metadata.inferred_description = text
320
+ }
321
+ // tree.children.splice(0, i + 1)
322
+ i = Infinity
323
+ }
324
+ break
325
+ }
326
+ case "heading":
327
+ if (child.depth === 1) {
328
+ metadata.inferred_title = mdastToString(child) || toTitleCase(path.parse(filePath).name)
329
+ tree.children.splice(0, i + 1)
330
+ } else {
331
+ i = Infinity;
332
+ }
333
+ break
334
+ case "yaml":
335
+ const frontmatter = yaml.parse(child.value)
336
+ for (const key in frontmatter) {
337
+ metadata["fm_" + key] = frontmatter[key]
338
+ }
339
+ break
340
+ default:
341
+ i = Infinity
342
+ break
343
+ }
344
+ }
345
+
346
+ return metadata
347
+
348
+ }
349
+
350
+ /** @type {Votive.ReadAbstract} */
351
+ function readAbstract(abstract, database, config) {
352
+ const jobs = []
353
+ return { abstract, jobs }
354
+ }
355
+
356
+ /** @type {Votive.ReadFolder} */
357
+ function readFolder(folder, database, config, isRoot) {
358
+ if (folder === "") {
359
+ database.target.create({
360
+ path: "robots.txt",
361
+ abstract: {
362
+ content: robots
363
+ },
364
+ metadata: {}
365
+ })
366
+ }
367
+ const settings = database.setting.getByFolder(folder)
368
+
369
+ const folderInfo = path.parse(folder)
370
+
371
+ const indexPath = path.relative("./", path.format({
372
+ dir: path.join(folderInfo.dir, folderInfo.name),
373
+ name: "index",
374
+ ext: ".html"
375
+ }))
376
+
377
+ const aliasPath = path.format({
378
+ dir: path.join(folderInfo.dir),
379
+ name: folderInfo.name,
380
+ ext: ".html"
381
+ })
382
+
383
+
384
+ const aliasFile = database.target.get(aliasPath)
385
+ const indexFile = database.target.get(indexPath)
386
+
387
+ if (!isRoot) {
388
+ if (!aliasFile) {
389
+ const title = toTitleCase(folderInfo.name)
390
+ const prettyURL = (new URL("/" + path.normalize(
391
+ path.format({
392
+ dir: path.join(folderInfo.dir),
393
+ name: folderInfo.name
394
+ })
395
+ ), "thismessage://")).pathname
396
+
397
+ const indexPath = prettyURL + "/*"
398
+
399
+ const abstract = fromMarkdown(`# ${title}\n\n${indexPath}`)
400
+ database.target.create({
401
+ abstract,
402
+ path: aliasPath,
403
+ syntax: "html",
404
+ metadata: {
405
+ title: toTitleCase(folderInfo.name),
406
+ breadcrumb: toTitleCase(folderInfo.name),
407
+ prettyURL
408
+ }
409
+ })
410
+ }
411
+ } else {
412
+ if (!indexFile) {
413
+ const title = "Home"
414
+ const prettyURL = "/"
415
+ const indexPath = prettyURL + "/*"
416
+
417
+ const abstract = fromMarkdown(`# ${title}\n\n${indexPath}`)
418
+ database.target.create({
419
+ abstract,
420
+ path: "index.html",
421
+ syntax: "html",
422
+ metadata: {
423
+ title,
424
+ breadcrumb: title,
425
+ prettyURL: "/"
426
+ }
427
+ })
428
+ }
429
+ }
430
+
431
+ // if (!indexFile && (!isRoot && !aliasFile)) {
432
+ // database.target.create({
433
+ // metadata: {
434
+ // title: toTitleCase(folderInfo.name),
435
+ // breadcrumb: toTitleCase(folderInfo.name),
436
+ // prettyURL: "/" + path.normalize(path.format({
437
+ // dir: path.join(folderInfo.dir),
438
+ // name: folderInfo.name
439
+ // }))
440
+ // },
441
+ // path: aliasPath,
442
+ // abstract: {},
443
+ // syntax: "html"
444
+ // })
445
+ // }
446
+
447
+
448
+
449
+
450
+ if (isRoot) {
451
+ const settings = database.setting.getByFolder(config.sourceFolder)
452
+
453
+ setTheme(settings)
454
+
455
+ function setTheme(settings) {
456
+ const themes = [
457
+ "reset",
458
+ "typography",
459
+ "default"
460
+ ]
461
+
462
+
463
+ if (themes.includes(settings.theme) || !settings.theme) {
464
+ if (!settings.theme) database.setting.create("", "theme", "default")
465
+
466
+ database.setting.create(
467
+ "",
468
+ "stylesheets",
469
+ "reset.css"
470
+ )
471
+
472
+ const resetStylesPath = path.join(VOWEL_DIR, "stylesheets", "ResetStyles.css")
473
+
474
+ if (settings.theme === "reset") return
475
+
476
+ database.setting.create(
477
+ "",
478
+ "stylesheets",
479
+ "typography.css"
480
+ )
481
+
482
+ if (settings.theme === "typography") return
483
+
484
+ database.setting.create(
485
+ "",
486
+ "stylesheets",
487
+ "default.css"
488
+ )
489
+
490
+ const typeStylesPath = path.join(VOWEL_DIR, "stylesheets", "TypographyStyles.css")
491
+ const defaultStylesPath = path.join(VOWEL_DIR, "stylesheets", "DefaultStyles.css")
492
+
493
+ const resetStyles = readFileSync(resetStylesPath, "utf-8")
494
+ const typeStyles = readFileSync(typeStylesPath, "utf-8")
495
+ const defaultStyles = readFileSync(defaultStylesPath, "utf-8")
496
+
497
+
498
+ database.target.create({
499
+ path: "reset.css",
500
+ abstract: { css: resetStyles },
501
+ metadata: {},
502
+ syntax: "css"
503
+ })
504
+
505
+ database.target.create({
506
+ path: "typography.css",
507
+ abstract: { css: typeStyles },
508
+ metadata: {},
509
+ syntax: "css"
510
+ })
511
+
512
+ database.target.create({
513
+ path: "default.css",
514
+ abstract: { css: defaultStyles },
515
+ metadata: {},
516
+ syntax: "css"
517
+ })
518
+ }
519
+ }
520
+
521
+ const site_title = settings.fm_title
522
+ || settings.inferred_title
523
+ || (indexFile && indexFile.metadata.title)
524
+
525
+ if (site_title && !settings.title) {
526
+ database.setting.create("", "title", site_title)
527
+ }
528
+
529
+ const tagline = settings.fm_tagline
530
+ && settings.fm_tagline[0]
531
+ || settings.inferred_description
532
+ && settings.inferred_description[0]
533
+
534
+ if (tagline) {
535
+ database.setting.create("", "tagline", tagline)
536
+ }
537
+
538
+ const icon = settings.fm_icon
539
+ && settings.fm_icon[0]
540
+
541
+ if (icon) {
542
+ database.setting.create("", "icon", icon)
543
+ }
544
+ }
545
+
546
+ const breadcrumb = indexFile?.metadata?.breadcrumb
547
+ || aliasFile?.metadata?.breadcrumb
548
+ || toTitleCase(folder.split(path.sep).at(-2))
549
+ || toTitleCase(folder.split(path.sep).at(-1))
550
+ || "Home"
551
+
552
+
553
+
554
+ database.setting.create(folder, "breadcrumbs", breadcrumb)
555
+
556
+ return {
557
+ jobs: [],
558
+ destinations: []
559
+ }
560
+ }
561
+
562
+ /** @type {Votive.ProcessorWrite} */
563
+ function writeHTML(destination, database, config) {
564
+ const isRoot = destination.path === "index.html"
565
+
566
+ const settings = database.setting.getByFolder(destination.dir + path.sep)
567
+
568
+ const { abstract, metadata, ...rest } = destination
569
+
570
+ /** @param {string} filePath */
571
+ function listFolders(filePath) {
572
+ if (!filePath) return []
573
+
574
+ const pathInfo = path.parse(filePath)
575
+ const dir = pathInfo.dir && pathInfo.dir
576
+ return [...listFolders(
577
+ dir
578
+ ), filePath]
579
+ }
580
+
581
+ const parsedPath = path.parse("" + destination.path)
582
+
583
+ const destinationAsDir = path.relative("", path.format({
584
+ dir: parsedPath.dir,
585
+ name: parsedPath.name
586
+ }))
587
+
588
+ const ancestorFolders = listFolders(rest.dir)
589
+ ancestorFolders.unshift("")
590
+
591
+ const family = [...ancestorFolders, destinationAsDir].flatMap(folder => {
592
+ // FIXME typing
593
+ return database.target.getManyWithTrackers({
594
+ folder: Array.isArray(folder) ? path.join(...folder) : folder,
595
+ recursive: false,
596
+ dependent: destination.path,
597
+ query: {}
598
+ })
599
+ }).filter(({ path }) => path)
600
+
601
+ const treeStyleSheets = []
602
+
603
+ Object.values(settings.stylesheets).forEach(file => {
604
+ file.forEach(sheet => {
605
+ const cacheBuster = Math.random().toString(36).slice(2, 10);
606
+ treeStyleSheets.push(
607
+ h('link', {
608
+ rel: "stylesheet",
609
+ href: `/${sheet}?${cacheBuster}`
610
+ })
611
+ )
612
+ })
613
+ })
614
+
615
+ function createTitle() {
616
+ if (isRoot) {
617
+ const title = [settings?.title?.[0] || metadata?.title, settings?.fm_tagline?.[""]?.[0]]
618
+ .filter(a => a)
619
+ .join(" - ")
620
+
621
+ return title
622
+ }
623
+
624
+ // FIXME Check that this works properly
625
+ if (metadata.title && settings.title) {
626
+ const titles = [metadata.title, ...Object.values(settings.title).flatMap(a => a).reverse()]
627
+ return titles.join(" - ")
628
+ }
629
+
630
+ if (metadata.title || settings.title) {
631
+ return metadata.title || settings.title.reverse()
632
+ }
633
+
634
+ return "Website"
635
+ }
636
+
637
+
638
+ const title = createTitle()
639
+
640
+ const treeHead = h('head', [
641
+ h('meta', {
642
+ charset: "UTF-8",
643
+ }),
644
+ h("meta", {
645
+ name: "viewport",
646
+ content: "width=device-width, initial-scale=1.0"
647
+ }),
648
+ h("meta", {
649
+ "http-equiv": "X-UA-Compatible",
650
+ content: "ie-edge"
651
+ }),
652
+ h('title', title),
653
+ h('meta', {
654
+ property: "og:title",
655
+ content: title
656
+ }),
657
+ h('meta', {
658
+ property: "og:description",
659
+ content: metadata.description,
660
+ }),
661
+ h("meta", {
662
+ property: "og:url",
663
+ content: metadata.prettyURL
664
+ }),
665
+ ...treeStyleSheets,
666
+ ])
667
+
668
+
669
+ if (metadata.image) {
670
+ treeHead.children.push(h("meta", {
671
+ property: "og:image",
672
+ content: metadata.image
673
+ }))
674
+ }
675
+
676
+ /* FIXME this could be a section title */
677
+ if (settings.title) {
678
+ treeHead.children.push(h("meta", {
679
+ property: "og:site_name",
680
+ content: settings.title[0]
681
+ }))
682
+ }
683
+
684
+ /* FIXME Properly handle this image */
685
+ if (settings.icon) {
686
+ treeHead.children.push(h("link", {
687
+ href: "/" + settings.icon[0],
688
+ rel: "icon",
689
+ type: "image/png"
690
+ }))
691
+ }
692
+
693
+ function treeNavItems(navItem) {
694
+ return h('a', {
695
+ href: navItem.metadata.prettyURL,
696
+ "aria-current": metadata.prettyURL === navItem.metadata.prettyURL ? 'page' : null
697
+ }, navItem.metadata.breadcrumb)
698
+ }
699
+
700
+ function navItemFilter(nav_item) {
701
+ return !nav_item.metadata.date
702
+ && nav_item.path !== "index.html"
703
+ && nav_item.syntax === ".html"
704
+ && nav_item.path
705
+ }
706
+
707
+ function sort_items(a, b) {
708
+ if (typeof a === "number" && typeof b === "number") return a - b
709
+ if (typeof b === "number") return -1
710
+ if (typeof a === "number") return 1
711
+ if (a.metadata.breadcrumb && b.metadata.breadcrumb) return String(a.metadata.breadcrumb).localeCompare(String(b.metadata.breadcrumb))
712
+ }
713
+
714
+
715
+
716
+ function treeNavFolder(navFolder) {
717
+ const sorted = navFolder
718
+ .filter(navItemFilter)
719
+ .toSorted(sort_items)
720
+
721
+ return h('nav', sorted.map(treeNavItems))
722
+ }
723
+
724
+
725
+ const groupedNavs = Object.groupBy(family, ({ dir }) => dir)
726
+
727
+ const treeNav = Object.entries(groupedNavs)
728
+ .sort(([a], [b]) => a.length - b.length)
729
+ .map(([k, v]) => treeNavFolder(v))
730
+ .filter(folder => folder.children.length)
731
+
732
+ let treeBreadcrumbs = []
733
+
734
+ const breadcrumbs = Object.entries(settings.breadcrumbs)
735
+ .sort(([a], [b]) => a.length - b.length)
736
+ .map(([path, [label]]) => ["/" + path, label])
737
+
738
+ treeBreadcrumbs.push(
739
+ ...breadcrumbs.map(([path, label]) => {
740
+ return h('a', {
741
+ href: path
742
+ }, label)
743
+ })
744
+ )
745
+
746
+ if (!isRoot) {
747
+ treeBreadcrumbs.push(
748
+ h('a', {
749
+ href: destination.metadata.prettyURL,
750
+ 'aria-current': 'page'
751
+ }, metadata.breadcrumb)
752
+ )
753
+ }
754
+
755
+ const headerElements = []
756
+
757
+ const homeLink = []
758
+
759
+ // TODO better color handling https://antfu.me/posts/icons-in-pure-css
760
+ if (settings.fm_logo && settings.fm_logo[""]) {
761
+ headerElements.push(
762
+ h('a.logo', {
763
+ href: "/",
764
+ rel: "home"
765
+ }, h("img", {
766
+ src: "/" + settings.fm_logo[""]
767
+ }))
768
+ )
769
+ /*
770
+ if (settings.fm_logo[""][0].endsWith(".svg")) {
771
+ const [svg] = settings.fm_logo[""]
772
+ const svgTarget = database.target.getWithTrackers(svg, destination.path)
773
+ if (svgTarget.metadata.monochrome) {
774
+ // FIXME aspect ratio
775
+ headerElements.push(
776
+ h('a.logo', {
777
+ style: `background-color: currentColor; mask-image: url('${(new URL(settings.fm_logo[""], "thismessage://")).pathname}'); mask-size: 100% 100%;`,
778
+ href: "/",
779
+ rel: "home",
780
+ alt: ""
781
+ })
782
+ )
783
+ } else {
784
+ headerElements.push(
785
+ h('a.logo', {
786
+ style: `background: url(${(new URL(settings.fm_logo[""], "thismessage://")).pathname}) norepeat center; background-color: transparent; background-size: 100% 100%;`,
787
+ href: "/",
788
+ rel: "home",
789
+ alt: ""
790
+ })
791
+ )
792
+ }
793
+ }
794
+ */
795
+ }
796
+
797
+ if (settings.fm_wordmark && settings.fm_wordmark[""]) {
798
+ headerElements.push(h("a.wordmark", {
799
+ href: "/",
800
+ rel: "home"
801
+ }, h("img", {
802
+ src: "/" + settings.fm_wordmark[""]
803
+ })))
804
+ }
805
+
806
+ if (settings.title && settings.title[""]) {
807
+ headerElements.push(h('a.title', { href: "/", rel: "home" }, settings.title[""]))
808
+ }
809
+
810
+ if (settings.fm_tagline && settings.fm_tagline[""]) {
811
+ headerElements.push(h('p.tagline', settings.fm_tagline[""][0]))
812
+ }
813
+
814
+ const treeHeader = h('header', [
815
+ ...headerElements,
816
+ ...treeNav,
817
+ ])
818
+
819
+ const treeContent = toHast(abstract)
820
+
821
+ function testPaths(node, i, p) {
822
+ if (node.type !== 'element') return
823
+ if (node.tagName !== 'p') return
824
+ if (node.children.length !== 1) return
825
+ if (!node.children[0].value) return
826
+ return Boolean(node.children[0].value.match(/^\/\S*$/))
827
+ }
828
+
829
+ const slugger = unified()
830
+ .use(slug)
831
+ .use(toc, {
832
+ customizeTOC: (toc) => {
833
+ toc.properties = {
834
+ "aria-label": "Contents"
835
+ }
836
+ }
837
+ })
838
+
839
+ const treeContentSlugged = slugger.runSync(treeContent)
840
+
841
+ const treeMainHead = makeHead(metadata)
842
+
843
+ visit(treeContent, testPaths, ({ children: [child] }, i, p) => {
844
+
845
+ const recursive = child.value.endsWith("**")
846
+ const many = child.value.endsWith("*")
847
+
848
+ if (!many) {
849
+ const targetFilePathInfo = path.parse(child.value)
850
+ targetFilePathInfo.ext ||= ".html"
851
+ delete targetFilePathInfo.base
852
+ const targetFilePath = path.relative("/", path.format(targetFilePathInfo))
853
+ const target = database.target.getWithTrackers(targetFilePath, destination.path)
854
+
855
+ const article = h('article', makeHead(target.metadata, target.metadata.prettyURL))
856
+
857
+ p.children.splice(i, 1, article)
858
+ }
859
+
860
+ if (many) {
861
+ const { dir } = path.parse(path.relative("/", child.value))
862
+ const targets = database.target.getManyWithTrackers({
863
+ folder: dir,
864
+ recursive,
865
+ query: {},
866
+ dependent: destination.path
867
+ })
868
+
869
+ const list = h("section",
870
+ targets.map(target => {
871
+ return h('article', makeHead(target.metadata, target.metadata.prettyURL))
872
+ })
873
+ )
874
+
875
+ p.children.splice(i, 1, list)
876
+ }
877
+
878
+ })
879
+
880
+ remove(treeContent, (n, i, p) => p.type === "root" && n.tagName === "h1")
881
+
882
+
883
+ // function copyTreeWithoutArticles(tree) {
884
+ // if (tree.tagName !== 'article') {
885
+ // return {
886
+ // type: tree.type,
887
+ // tagName: tree.tagName,
888
+ // properties: tree.properties,
889
+ // children: tree.children?.map(copyTreeWithoutArticles)
890
+ // }
891
+ // }
892
+ // }
893
+
894
+
895
+
896
+
897
+ const treeMain = h('main',
898
+ [
899
+ h('nav', {
900
+ 'aria-label': 'Breadcrumbs'
901
+ }, treeBreadcrumbs),
902
+ treeMainHead,
903
+ treeContent
904
+ ])
905
+
906
+ /* URL handling */
907
+ visit(treeMain, (node, index, parent) => {
908
+ if (node.type === "text" && parent.tagName === 'p' && parent.children.length === 1) {
909
+ const validURL = testURL(node.value)
910
+ if (validURL) {
911
+ const metadata = database.url.get(node.value)
912
+ if (metadata) {
913
+ parent.tagName = "article"
914
+ parent.children = [
915
+ h("a", { href: node.value },
916
+ h("h2", metadata.title)
917
+ )
918
+ ]
919
+ }
920
+ }
921
+ }
922
+ })
923
+
924
+ const everything = database.target.getManyWithTrackers({
925
+ folder: "",
926
+ recursive: true,
927
+ dependent: destination.path,
928
+ }).filter(target => target.path)
929
+
930
+ const homeFile = everything.find(item => item.path === "index.html" && item.dir === "")
931
+
932
+ const globalNavItems = everything.filter(item => {
933
+ return item.dir === ""
934
+ && item.path !== "index.html"
935
+ && item.path.endsWith(".html")
936
+ && !item.metadata.date
937
+ })
938
+ .map(getChildren)
939
+
940
+ globalNavItems.unshift(homeFile)
941
+
942
+ function getChildren(item) {
943
+ const children = everything.filter(child => {
944
+ return "/" + child.dir === item.metadata.prettyURL
945
+ && child.path !== "index.html"
946
+ })
947
+
948
+ const populatedChildren = children.length > 0 && children.map(child => {
949
+ return getChildren(child)
950
+ })
951
+
952
+ const node = {
953
+ path: "/" + item.path,
954
+ metadata: item.metadata
955
+ }
956
+
957
+ if (populatedChildren) node.children = populatedChildren
958
+
959
+ return node
960
+ }
961
+
962
+ function treeNavItem(item) {
963
+ if (item.children) {
964
+ return h('li', [
965
+ h('a', { href: item.path }, item.metadata.breadcrumb),
966
+ treeNavList(item.children)
967
+ ])
968
+ }
969
+
970
+ return h('li',
971
+ h('a', { href: item.path }, item.metadata.breadcrumb)
972
+ )
973
+ }
974
+
975
+ function treeNavList(items) {
976
+ return h('ul',
977
+ items.filter(a => a).map(treeNavItem)
978
+ )
979
+ }
980
+
981
+ const treeGlobalNav = h('nav',
982
+ treeNavList(globalNavItems)
983
+ )
984
+
985
+ const treeAside = h('aside', treeGlobalNav)
986
+
987
+ const treeFooter = h('footer', [
988
+ h('section.copyright', `© ${new Date().getFullYear()}`),
989
+ h('section.shoutout', [
990
+ "Made with ",
991
+ h('a', {
992
+ href: "https://vowel.cc"
993
+ }, "Vowel"),
994
+ ])
995
+ ])
996
+
997
+ const pageClass = destination.metadata.prettyURL
998
+ .split("/")
999
+ .filter(a => a)
1000
+ .map(a => a.replace("_", ""))
1001
+ .join("_")
1002
+ || "home"
1003
+
1004
+ const treeBody = h(`body.${pageClass}`, [
1005
+ treeHeader,
1006
+ treeMain,
1007
+ treeAside,
1008
+ treeFooter
1009
+ ])
1010
+
1011
+ const tree = h(
1012
+ null,
1013
+ [
1014
+ {
1015
+ type: "doctype",
1016
+ name: 'html'
1017
+ },
1018
+ h('html',
1019
+ {
1020
+ lang: "en"
1021
+ },
1022
+ [
1023
+ treeHead,
1024
+ treeBody
1025
+ ]
1026
+ )
1027
+ ]
1028
+ )
1029
+
1030
+
1031
+ const data = unified()
1032
+ .use(rehypePresetMinify)
1033
+ .use(rehypeStringify)
1034
+ .stringify(tree)
1035
+
1036
+ return {
1037
+ data
1038
+ }
1039
+ }
1040
+
1041
+ /** @type {Votive.Router} */
1042
+ function router(args) {
1043
+ const { name, dir, inRootDir, ext } = args
1044
+ if (name.startsWith("$")) return false
1045
+ if (dir.find?.(segment => segment.startsWith("$"))) return false
1046
+
1047
+ switch (name) {
1048
+ case "settings":
1049
+ return false
1050
+ case "home":
1051
+ if (inRootDir) {
1052
+ return {
1053
+ dir,
1054
+ name: "index",
1055
+ ext: ".html"
1056
+ }
1057
+ }
1058
+ return {
1059
+ dir: dir.slice(0, -1).map(segment => segment.replaceAll(/[^\w\/]/g, "-").replaceAll(/--+/g, "-").toLowerCase()),
1060
+ name: dir.at(-1).toLowerCase(),
1061
+ ext: ".html"
1062
+ }
1063
+ default:
1064
+ return {
1065
+ dir: dir.map(segment => segment.replaceAll(/[^\w\/]/g, "-").replaceAll(/--+/g, "-").toLowerCase()),
1066
+ name: name.replaceAll(/[^\w\/]/g, "-").replaceAll(/--+/g, "-").toLowerCase(),
1067
+ ext: "html"
1068
+ }
1069
+ }
1070
+ }
1071
+
1072
+
1073
+ /** @type {Votive.VotiveProcessor} */
1074
+ const markdownReader = {
1075
+ extensions: [".md"],
1076
+ format: "text",
1077
+ readFile: readMarkdown,
1078
+ readResource: readURL,
1079
+ transformFile: readAbstract,
1080
+ readFolder: readFolder,
1081
+ }
1082
+
1083
+ const htmlWriter = {
1084
+ extensions: [".html"],
1085
+ format: "text",
1086
+ writeFile: writeHTML
1087
+ }
1088
+
1089
+
1090
+ /** @type {Votive.VotivePlugin} */
1091
+ const vowelMarkdownPlugin = {
1092
+ name: "vowel",
1093
+ processors: [markdownReader, htmlWriter],
1094
+ router
1095
+ }
1096
+
1097
+ export default vowelMarkdownPlugin