vowel 0.7.1-beta.0 → 0.7.3
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/LICENSE +21 -0
- package/README.md +41 -239
- package/bin.js +91 -31
- package/cli.js +32 -0
- package/config.js +12 -0
- package/extractDate.js +1 -1
- package/package.json +8 -34
- package/pathDate.js +69 -0
- package/plugins/assets/index.js +1 -1
- package/plugins/atproto/body.js +3 -1
- package/plugins/atproto/index.js +5 -45
- package/plugins/atproto/publish.js +42 -11
- package/plugins/atproto/record.js +47 -4
- package/plugins/deploy-cloudflare/index.js +4 -1
- package/plugins/export/index.js +131 -0
- package/plugins/html/bundle/index.js +101 -44
- package/plugins/html/editor/Editor.svelte +9 -5
- package/plugins/html/editor/create-session.js +3 -1
- package/plugins/html/editor/directives.js +12 -0
- package/plugins/html/editor/frontmatter.js +77 -12
- package/plugins/html/editor/ingest.js +29 -31
- package/plugins/html/editor/main.js +5 -1
- package/plugins/html/editor/serialize.js +8 -4
- package/plugins/html/editor/socket.js +34 -4
- package/plugins/html/image.js +88 -27
- package/plugins/html/index.js +237 -109
- package/plugins/html/partials.js +41 -17
- package/plugins/html/socialLinks.js +13 -11
- package/plugins/html/writeRules.js +234 -42
- package/plugins/images/favicon.png +0 -0
- package/plugins/images/index.js +69 -8
- package/plugins/markdown/colorScheme.js +70 -14
- package/plugins/markdown/frontmatter.js +18 -8
- package/plugins/markdown/index.js +94 -25
- package/plugins/markdown/metadata.js +132 -32
- package/plugins/markdown/typography.js +11 -11
- package/plugins/styles/index.js +4 -4
- package/plugins/xml/index.js +17 -10
- package/stylesheets/DefaultStyles.css +163 -108
- package/stylesheets/TypographyStyles.css +148 -81
- package/utils.js +74 -21
package/plugins/images/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { hash as digest } from "node:crypto"
|
|
2
|
+
import { readFileSync } from "node:fs"
|
|
2
3
|
import path from "node:path"
|
|
3
4
|
import sharp from 'sharp'
|
|
4
5
|
import { createImagePath, imageSizes, imageExts } from "./../../utils.js"
|
|
@@ -37,6 +38,18 @@ function hex(channel) {
|
|
|
37
38
|
return Math.round(channel).toString(16).padStart(2, "0")
|
|
38
39
|
}
|
|
39
40
|
|
|
41
|
+
/**
|
|
42
|
+
* A blurry stand-in for the image while it loads, inlined in the page:
|
|
43
|
+
* 16 px on its long side, low-quality WebP, a data URI of roughly
|
|
44
|
+
* 150-300 bytes. The browser smooths it as it scales up. Done here
|
|
45
|
+
* rather than with lqip-modern, which wraps these same sharp calls.
|
|
46
|
+
* @param {import("sharp").Sharp} image
|
|
47
|
+
*/
|
|
48
|
+
async function placeholderURI(image) {
|
|
49
|
+
const bytes = await image.clone().resize(16, 16, { fit: "inside" }).webp({ quality: 20 }).toBuffer()
|
|
50
|
+
return `data:image/webp;base64,${bytes.toString("base64")}`
|
|
51
|
+
}
|
|
52
|
+
|
|
40
53
|
/**
|
|
41
54
|
* Runs deferred, with the bytes (buffer format), so this is where an
|
|
42
55
|
* image is looked at. Dimensions go on <img width height> so the browser
|
|
@@ -51,26 +64,55 @@ function hex(channel) {
|
|
|
51
64
|
async function readImagePath(source) {
|
|
52
65
|
const buffer = source.buffer()
|
|
53
66
|
const image = sharp(buffer)
|
|
54
|
-
const [{ width, height }, { dominant }] = await Promise.all([image.metadata(), image.stats()])
|
|
67
|
+
const [{ width, height, hasAlpha }, { dominant }] = await Promise.all([image.metadata(), image.stats()])
|
|
55
68
|
const dominantColor = dominant ? `#${hex(dominant.r)}${hex(dominant.g)}${hex(dominant.b)}` : undefined
|
|
69
|
+
// Not behind an image with transparency: the page has no script to take
|
|
70
|
+
// it away once the image loads, so it would show through.
|
|
71
|
+
const placeholder = hasAlpha ? undefined : await placeholderURI(image)
|
|
56
72
|
const hash = digest("sha1", buffer).slice(0, 8)
|
|
57
73
|
|
|
58
74
|
const ext = path.extname(source.target)
|
|
59
|
-
const
|
|
75
|
+
const formats = [...imageExts, ext.replace(/^\./, "")]
|
|
76
|
+
const targets = formats.flatMap(format => imageSizes.map(size => ({
|
|
60
77
|
path: createImagePath({ targetFilePath: source.target, size, ext: format, targetDirectory: "", uuid: hash }),
|
|
61
|
-
metadata: { derivative: { size, format
|
|
78
|
+
metadata: { derivative: { size, format } }
|
|
62
79
|
})))
|
|
80
|
+
const metadata = { hash, width, height, dominant: dominantColor, placeholder }
|
|
81
|
+
|
|
82
|
+
if (!isOptimized(source)) return { metadata, targets }
|
|
83
|
+
|
|
84
|
+
// Grouped by format in <picture> order (avif, webp, its own), each
|
|
85
|
+
// smallest first. This list is the one place a derivative's path is
|
|
86
|
+
// decided: plugins/html/image.js links what it names.
|
|
87
|
+
const derivatives = targets.map(({ path, metadata }) => ({ path, ...metadata.derivative }))
|
|
88
|
+
return { metadata: { ...metadata, derivatives }, targets, write: false }
|
|
89
|
+
}
|
|
63
90
|
|
|
64
|
-
|
|
91
|
+
/**
|
|
92
|
+
* A png or jpeg is never published at full size: its derivatives are
|
|
93
|
+
* linked everywhere instead, and the original is a virtual target. The
|
|
94
|
+
* root favicon.png is exempt, because it is linked as it is, as the
|
|
95
|
+
* site's icon. The other formats are copied through as before; their
|
|
96
|
+
* derivatives are made but nothing links them yet.
|
|
97
|
+
* @param {{ path: string, target: string }} source
|
|
98
|
+
*/
|
|
99
|
+
function isOptimized(source) {
|
|
100
|
+
if (source.path === "favicon.png") return false
|
|
101
|
+
return /^\.(png|jpe?g)$/.test(path.extname(source.target))
|
|
65
102
|
}
|
|
66
103
|
|
|
67
104
|
/**
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
105
|
+
* A derivative is resized and converted from its owner's bytes - which
|
|
106
|
+
* is what target.buffer() reads, because votive stored the owner as the
|
|
107
|
+
* derivative's source. An original with derivatives is virtual and
|
|
108
|
+
* writes nothing; any other original is copied through.
|
|
71
109
|
* @type {Votive.ProcessorWrite}
|
|
72
110
|
*/
|
|
73
111
|
async function writeImage(target) {
|
|
112
|
+
// The placeholder favicon has no file to read; its bytes are its data.
|
|
113
|
+
if (target.metadata?.favicon) return { data: Buffer.from(target.data, "base64") }
|
|
114
|
+
if (target.metadata?.derivatives) return
|
|
115
|
+
|
|
74
116
|
const bytes = target.buffer()
|
|
75
117
|
const recipe = target.metadata?.derivative
|
|
76
118
|
if (!recipe) return { data: bytes }
|
|
@@ -89,10 +131,29 @@ const imagesProcessor = {
|
|
|
89
131
|
writeFile: writeImage
|
|
90
132
|
}
|
|
91
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Every site has Vowel's mark as a placeholder favicon,
|
|
136
|
+
* `favicon-placeholder.png`, published always and linked only when the
|
|
137
|
+
* site has no `icon` and no `favicon.png` of its own (the html plugin).
|
|
138
|
+
* A stub of its own source type (`.favicon`, which nothing else uses)
|
|
139
|
+
* read as base64 text, so it is read with the pages rather than with
|
|
140
|
+
* the images in the background. A write is matched on the target's
|
|
141
|
+
* extension, so writeImage writes it, from that text.
|
|
142
|
+
* @type {Votive.VotiveProcessor}
|
|
143
|
+
*/
|
|
144
|
+
const faviconProcessor = {
|
|
145
|
+
extensions: [".favicon"],
|
|
146
|
+
format: "text",
|
|
147
|
+
router: ({ name, dir }) => ({ name, dir, ext: ".png" }),
|
|
148
|
+
createStubs: () => [{ path: "favicon-placeholder.favicon" }],
|
|
149
|
+
expandStubs: () => ({ text: readFileSync(path.join(import.meta.dirname, "favicon.png")).toString("base64") }),
|
|
150
|
+
readFile: (source) => ({ data: source.text, metadata: { favicon: true } })
|
|
151
|
+
}
|
|
152
|
+
|
|
92
153
|
/** @type {Votive.VotivePlugin} */
|
|
93
154
|
const vowelImagesPlugin = {
|
|
94
155
|
name: "vowel-images",
|
|
95
|
-
processors: [imagesProcessor]
|
|
156
|
+
processors: [imagesProcessor, faviconProcessor]
|
|
96
157
|
}
|
|
97
158
|
|
|
98
159
|
export default vowelImagesPlugin
|
|
@@ -4,16 +4,70 @@ import { fallbackColors } from "./brandColors.js"
|
|
|
4
4
|
/**
|
|
5
5
|
* Ten shades per role, named `01` (darkest) to `10` (lightest) in
|
|
6
6
|
* colorhorse's own darkest-first order. DefaultStyles.css reads them
|
|
7
|
-
* that way round: `light-dark(var(--primary-10), var(--primary-01))`
|
|
7
|
+
* that way round: `light-dark(var(--color-primary-10), var(--color-primary-01))`
|
|
8
8
|
* is the body background. tests/colorScheme.js pins the order.
|
|
9
9
|
*/
|
|
10
10
|
const shadeCount = 10
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* The `theme` keys that tune the scheme, each one colorhorse option, all
|
|
14
|
+
* numbers from 0 to 1. The defaults are vowel's, set before these were
|
|
15
|
+
* settings; `contrast` 0 is colorhorse's, evenly spread shades.
|
|
16
|
+
*/
|
|
17
|
+
const schemeSettings = [
|
|
18
|
+
{ key: "darkest-shade", option: "minLightness", default: 0.3 },
|
|
19
|
+
{ key: "lightest-shade", option: "maxLightness", default: 0.99 },
|
|
20
|
+
{ key: "min-saturation", option: "minChroma", default: 0.2 },
|
|
21
|
+
{ key: "contrast", option: "contrast", default: 0 }
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
const defaultOptions = Object.fromEntries(schemeSettings.map(setting => [setting.option, setting.default]))
|
|
25
|
+
|
|
12
26
|
/** @param {number} index */
|
|
13
27
|
function shadeName(index) {
|
|
14
28
|
return String(index).padStart(2, "0")
|
|
15
29
|
}
|
|
16
30
|
|
|
31
|
+
/** @param {unknown} value */
|
|
32
|
+
function isFraction(value) {
|
|
33
|
+
return typeof value === "number" && value >= 0 && value <= 1
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The part of a theme the colors sheet is generated from, as stub
|
|
38
|
+
* params: the sheet re-expands when one of these changes, and only then.
|
|
39
|
+
* @param {Record<string, unknown>} theme - a resolved theme's config
|
|
40
|
+
*/
|
|
41
|
+
function colorParams(theme) {
|
|
42
|
+
const settings = schemeSettings.map(setting => [setting.key, theme[setting.key] ?? null])
|
|
43
|
+
return { colors: theme.colors ?? null, ...Object.fromEntries(settings) }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* colorhorse's options from a theme's settings. A value that is not a
|
|
48
|
+
* number from 0 to 1 is reported and left at its default, rather than
|
|
49
|
+
* costing the site its colors: colorhorse throws for `contrast` alone,
|
|
50
|
+
* and turns the rest into ramps nobody asked for.
|
|
51
|
+
* @param {Record<string, unknown>} theme
|
|
52
|
+
* @param {(error: Error) => void} onError
|
|
53
|
+
*/
|
|
54
|
+
function schemeOptions(theme, onError) {
|
|
55
|
+
const entries = schemeSettings.map(setting => {
|
|
56
|
+
const value = theme[setting.key]
|
|
57
|
+
if (value === undefined || value === null) return [setting.option, setting.default]
|
|
58
|
+
if (isFraction(value)) return [setting.option, value]
|
|
59
|
+
onError(new Error(`ignoring theme.${setting.key} - expected a number from 0 to 1, got ${JSON.stringify(value)}`))
|
|
60
|
+
return [setting.option, setting.default]
|
|
61
|
+
})
|
|
62
|
+
const options = Object.fromEntries(entries)
|
|
63
|
+
|
|
64
|
+
// Reversed, colorhorse still answers, with a ramp whose "darkest"
|
|
65
|
+
// shade is the lighter one.
|
|
66
|
+
if (options.minLightness < options.maxLightness) return options
|
|
67
|
+
onError(new Error(`ignoring theme.darkest-shade and theme.lightest-shade - the darkest shade (${options.minLightness}) must be below the lightest (${options.maxLightness})`))
|
|
68
|
+
return { ...options, minLightness: defaultOptions.minLightness, maxLightness: defaultOptions.maxLightness }
|
|
69
|
+
}
|
|
70
|
+
|
|
17
71
|
/**
|
|
18
72
|
* Builds the `:root` block for a theme's `colors` seed pair, falling
|
|
19
73
|
* back to Vowel's brand colors when a site hasn't configured any.
|
|
@@ -23,20 +77,21 @@ function shadeName(index) {
|
|
|
23
77
|
* messages are specific ("colorOne has a chroma of zero...") and are
|
|
24
78
|
* left to surface as they are.
|
|
25
79
|
* @param {unknown} colors - a theme setting's `colors`
|
|
80
|
+
* @param {object} [options] - colorhorse's, already checked (schemeOptions)
|
|
26
81
|
* @returns {string}
|
|
27
82
|
*/
|
|
28
|
-
function colorSchemeCSS(colors) {
|
|
29
|
-
if (colors === undefined || colors === null) return colorSchemeCSS(fallbackColors)
|
|
83
|
+
function colorSchemeCSS(colors, options = defaultOptions) {
|
|
84
|
+
if (colors === undefined || colors === null) return colorSchemeCSS(fallbackColors, options)
|
|
30
85
|
|
|
31
86
|
if (!Array.isArray(colors) || colors.length !== 2) {
|
|
32
87
|
throw new Error(`theme.colors must be an array of two "#rrggbb" colors, got ${JSON.stringify(colors)}`)
|
|
33
88
|
}
|
|
34
89
|
|
|
35
90
|
const [colorOne, colorTwo] = colors
|
|
36
|
-
const scheme = getScheme(colorOne, colorTwo, {
|
|
91
|
+
const scheme = getScheme(colorOne, colorTwo, { ...options, shadeCount })
|
|
37
92
|
|
|
38
93
|
const declarations = Object.entries(scheme).flatMap(([role, ramp]) => (
|
|
39
|
-
ramp.map((color, index) => `
|
|
94
|
+
ramp.map((color, index) => ` --color-${role}-${shadeName(index + 1)}: ${color};`)
|
|
40
95
|
))
|
|
41
96
|
|
|
42
97
|
return `:root {\n${declarations.join("\n")}\n}\n`
|
|
@@ -45,20 +100,21 @@ function colorSchemeCSS(colors) {
|
|
|
45
100
|
/**
|
|
46
101
|
* The build-facing entry point: always returns a usable `:root` block.
|
|
47
102
|
* Invalid configuration is reported through `onError` and then falls
|
|
48
|
-
* back to the brand colors,
|
|
49
|
-
*
|
|
50
|
-
* DefaultStyles.css.
|
|
51
|
-
* @param {unknown}
|
|
103
|
+
* back - unusable colors to the brand colors, a bad setting to its
|
|
104
|
+
* default - so a typo in settings.md leaves the site styled instead of
|
|
105
|
+
* stripping every color variable out from under DefaultStyles.css.
|
|
106
|
+
* @param {Record<string, unknown>} theme - colorParams' shape
|
|
52
107
|
* @param {(error: Error) => void} onError
|
|
53
108
|
* @returns {string}
|
|
54
109
|
*/
|
|
55
|
-
function themeColorSchemeCSS(
|
|
110
|
+
function themeColorSchemeCSS(theme, onError) {
|
|
111
|
+
const options = schemeOptions(theme, onError)
|
|
56
112
|
try {
|
|
57
|
-
return colorSchemeCSS(colors)
|
|
113
|
+
return colorSchemeCSS(theme.colors, options)
|
|
58
114
|
} catch (error) {
|
|
59
|
-
onError(error)
|
|
60
|
-
return colorSchemeCSS(fallbackColors)
|
|
115
|
+
onError(new Error(`ignoring theme.colors - ${error.message}`))
|
|
116
|
+
return colorSchemeCSS(fallbackColors, options)
|
|
61
117
|
}
|
|
62
118
|
}
|
|
63
119
|
|
|
64
|
-
export { colorSchemeCSS, themeColorSchemeCSS, fallbackColors }
|
|
120
|
+
export { colorSchemeCSS, themeColorSchemeCSS, colorParams, fallbackColors }
|
|
@@ -33,13 +33,13 @@ import extractDate from "../../extractDate.js"
|
|
|
33
33
|
*/
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
|
-
* @typedef {"string" | "date" | "path" | "boolean" | "number" | "strings" | "theme" | "links" | "publication" | "object" | "status"} Shape
|
|
36
|
+
* @typedef {"string" | "date" | "path" | "paths" | "boolean" | "number" | "strings" | "theme" | "links" | "publication" | "object" | "status"} Shape
|
|
37
37
|
*/
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
40
|
* A page's visibility. `published` is the default. `hidden` writes the
|
|
41
41
|
* page but lists it nowhere - no menu, listing, sitemap, feed or record.
|
|
42
|
-
* `draft` skips the page entirely
|
|
42
|
+
* `draft` skips the page entirely.
|
|
43
43
|
* `deleted` skips it too, and tells `atproto-publish` to delete its
|
|
44
44
|
* record.
|
|
45
45
|
*/
|
|
@@ -54,7 +54,7 @@ const EXPECTED = {
|
|
|
54
54
|
title: "string",
|
|
55
55
|
breadcrumb: "string",
|
|
56
56
|
description: "string",
|
|
57
|
-
|
|
57
|
+
slogan: "string",
|
|
58
58
|
name: "string",
|
|
59
59
|
author: "string",
|
|
60
60
|
domain: "string",
|
|
@@ -63,15 +63,16 @@ const EXPECTED = {
|
|
|
63
63
|
logo: "path",
|
|
64
64
|
wordmark: "path",
|
|
65
65
|
icon: "path",
|
|
66
|
+
fallback_image: "path",
|
|
67
|
+
styles: "paths",
|
|
66
68
|
tags: "strings",
|
|
67
69
|
aliases: "strings",
|
|
68
|
-
published: "boolean",
|
|
69
70
|
status: "status",
|
|
70
71
|
html_file: "boolean",
|
|
71
72
|
rss_item: "boolean",
|
|
72
73
|
sitemap_item: "boolean",
|
|
73
|
-
|
|
74
|
-
|
|
74
|
+
global_nav_item: "boolean",
|
|
75
|
+
local_nav_item: "boolean",
|
|
75
76
|
feed_limit: "number",
|
|
76
77
|
theme: "theme",
|
|
77
78
|
social_links: "links",
|
|
@@ -143,6 +144,12 @@ function coerceOne(shape, value, folder) {
|
|
|
143
144
|
if (inner) return { value: resolvePath(inner, folder), problem: `expected a path, got ${Array.isArray(value) ? "a list" : "an object"}; using "${inner}"` }
|
|
144
145
|
return { value: undefined, problem: `expected a path, got ${JSON.stringify(value)}` }
|
|
145
146
|
}
|
|
147
|
+
if (shape === "paths") {
|
|
148
|
+
const values = Array.isArray(value) ? value : [value]
|
|
149
|
+
const kept = values.filter(item => typeof item === "string" && item.trim()).map(item => resolvePath(item, folder))
|
|
150
|
+
if (!kept.length) return { value: undefined, problem: `expected a path or a list of paths, got ${JSON.stringify(value)}` }
|
|
151
|
+
return kept.length === values.length ? { value: kept } : { value: kept, problem: "expected a list of paths; dropped what was not" }
|
|
152
|
+
}
|
|
146
153
|
if (shape === "boolean") {
|
|
147
154
|
if (typeof value === "boolean") return { value }
|
|
148
155
|
const word = String(value).trim().toLowerCase()
|
|
@@ -191,9 +198,12 @@ function coerceOne(shape, value, folder) {
|
|
|
191
198
|
return wrong.length ? { value: kept, problem: `${wrong.join(", ")}; dropped` } : { value: kept }
|
|
192
199
|
}
|
|
193
200
|
if (shape === "status") {
|
|
201
|
+
// One of vowel's four controls the page. Anything else is the
|
|
202
|
+
// author's own `status` - a digital garden's "evergreen" or
|
|
203
|
+
// "growing" - kept as written, the page published as usual.
|
|
194
204
|
const word = typeof value === "string" ? value.trim().toLowerCase() : undefined
|
|
195
205
|
if (STATUSES.includes(word)) return { value: word }
|
|
196
|
-
return { value
|
|
206
|
+
return { value }
|
|
197
207
|
}
|
|
198
208
|
if (shape === "object") {
|
|
199
209
|
if (isPlainObject(value)) return { value }
|
|
@@ -266,4 +276,4 @@ function asContent(tree, node) {
|
|
|
266
276
|
return undefined
|
|
267
277
|
}
|
|
268
278
|
|
|
269
|
-
export { EXPECTED, coerce, parseFrontmatter, resolvePath, toISODate }
|
|
279
|
+
export { EXPECTED, STATUSES, coerce, parseFrontmatter, resolvePath, toISODate }
|
|
@@ -13,11 +13,11 @@ import { gfmTable } from 'micromark-extension-gfm-table'
|
|
|
13
13
|
import { gfmTableFromMarkdown } from 'mdast-util-gfm-table'
|
|
14
14
|
import { normalizeHeadingLevels } from './metadata.js'
|
|
15
15
|
import { readFileSync } from "fs"
|
|
16
|
-
import { createHashtagPage,
|
|
17
|
-
import { toHast } from 'mdast-util-to-hast'
|
|
16
|
+
import { createHashtagPage, labelFromName, showPages } from "./../../utils.js"
|
|
17
|
+
import { toHast, defaultHandlers } from 'mdast-util-to-hast'
|
|
18
18
|
import { toString as hastToString } from 'hast-util-to-string'
|
|
19
|
-
import getMetadata, { reservedProperties } from "./metadata.js"
|
|
20
|
-
import { isSecretPath } from "./../../secretPaths.js"
|
|
19
|
+
import getMetadata, { imageFromPage, reservedProperties } from "./metadata.js"
|
|
20
|
+
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"
|
|
@@ -63,15 +63,16 @@ function readFile(source, { api, config }) {
|
|
|
63
63
|
if (!metadata.image) {
|
|
64
64
|
const firstImageParagraph = mdast.children.find(child => child.children && child.children[0].type === "image")
|
|
65
65
|
if (firstImageParagraph) {
|
|
66
|
-
|
|
66
|
+
// A card shows it from another folder's page, so from the root.
|
|
67
|
+
const folder = path.dirname(targetPath ?? filePath).replace(/^\.$/, "")
|
|
68
|
+
metadata.first_image = imageFromPage(firstImageParagraph.children[0].url, folder)
|
|
67
69
|
}
|
|
68
70
|
}
|
|
69
71
|
|
|
70
72
|
|
|
71
|
-
// `status` (frontmatter.js). A draft
|
|
72
|
-
// spelling - is not a page at all.
|
|
73
|
+
// `status` (frontmatter.js). A draft is not a page at all.
|
|
73
74
|
const status = metadata.fm_status ?? "published"
|
|
74
|
-
if (
|
|
75
|
+
if (status === "draft") return
|
|
75
76
|
|
|
76
77
|
// A deleted page is not written, and carries nothing a listing, a tag
|
|
77
78
|
// page, a backlink or a wikilink could find it by. It exists only so
|
|
@@ -96,11 +97,13 @@ function readFile(source, { api, config }) {
|
|
|
96
97
|
// time, could not cover a folder, left a virtual target at the public
|
|
97
98
|
// path, and had to be hidden from its own rendering.
|
|
98
99
|
// `status: hidden` is the same promise made on purpose: written, never
|
|
99
|
-
// listed.
|
|
100
|
-
|
|
100
|
+
// listed. So is the site's 404 page, generated or the author's own: it
|
|
101
|
+
// is served for a missing url, and no listing, sitemap, feed or
|
|
102
|
+
// AT Protocol record should name it.
|
|
103
|
+
if (isSecretPath(filePath) || status === "hidden" || filePath === "404.md") {
|
|
101
104
|
metadata.hidden = true
|
|
102
|
-
metadata.
|
|
103
|
-
metadata.
|
|
105
|
+
metadata.local_nav_item ??= false
|
|
106
|
+
metadata.global_nav_item ??= false
|
|
104
107
|
metadata.sitemap_item ??= false
|
|
105
108
|
metadata.rss_item ??= false
|
|
106
109
|
}
|
|
@@ -131,6 +134,13 @@ function readFile(source, { api, config }) {
|
|
|
131
134
|
|
|
132
135
|
|
|
133
136
|
const hast = toHast(mdast, {
|
|
137
|
+
// A table in a <figure>: the figure scrolls sideways when the table
|
|
138
|
+
// is too wide, so the table itself can be a real table at full width
|
|
139
|
+
// (a table that scrolls has to be display: block, which sizes its
|
|
140
|
+
// columns to their content), and a caption has somewhere to go.
|
|
141
|
+
handlers: {
|
|
142
|
+
table: (state, node) => h("figure", [defaultHandlers.table(state, node)])
|
|
143
|
+
},
|
|
134
144
|
// `state.all(node)` converts the children to hast; handing over
|
|
135
145
|
// `node.children` gives the HTML compiler raw *mdast* instead. Plain
|
|
136
146
|
// text survives that by coincidence - mdast and hast spell a text
|
|
@@ -209,7 +219,7 @@ function withDeclaredTypes(metadata) {
|
|
|
209
219
|
* label inferred from its own filename - and its first paragraph became
|
|
210
220
|
* the description. The site's name is `name:` (read as `fm_name`); a
|
|
211
221
|
* `title:` here is an old spelling and is warned about, since a page
|
|
212
|
-
* has a title and a site has a name. `
|
|
222
|
+
* has a title and a site has a name. `slogan:` is warned about too: it
|
|
213
223
|
* is a page's, and the homepage's is the one a site shows on its front
|
|
214
224
|
* door. `markdown` is the source, which the settings panel edits.
|
|
215
225
|
* @param {Record<string, any>} metadata
|
|
@@ -220,8 +230,8 @@ function settingsContribution(metadata, filePath, log) {
|
|
|
220
230
|
if (metadata.fm_title !== undefined && metadata.fm_name === undefined) {
|
|
221
231
|
log?.("warn", `${filePath}: \`title:\` in a settings file does nothing - the site's name is \`name:\``)
|
|
222
232
|
}
|
|
223
|
-
if (metadata.
|
|
224
|
-
log?.("warn", `${filePath}: \`
|
|
233
|
+
if (metadata.fm_slogan !== undefined) {
|
|
234
|
+
log?.("warn", `${filePath}: \`slogan:\` in a settings file does nothing - a slogan belongs to a page, e.g. home.md`)
|
|
225
235
|
}
|
|
226
236
|
const declared = Object.entries(metadata)
|
|
227
237
|
.filter(([key]) => key.startsWith("fm_") || reservedProperties.includes(key))
|
|
@@ -293,10 +303,12 @@ function createStubs({ api }) {
|
|
|
293
303
|
]
|
|
294
304
|
const taken = new Set(named.map(stub => stub.path))
|
|
295
305
|
|
|
296
|
-
// A section index per folder with a listed page (see folderIndexes)
|
|
297
|
-
//
|
|
298
|
-
// tags
|
|
299
|
-
|
|
306
|
+
// A section index per folder with a listed page (see folderIndexes),
|
|
307
|
+
// and per secret folder with a page (secretFolderIndexes) - except
|
|
308
|
+
// where a stub above already is that page: `tags.md` is the tags
|
|
309
|
+
// folder's index.
|
|
310
|
+
const indexes = [...folderIndexes(api), ...secretFolderIndexes(api)]
|
|
311
|
+
return [...named, ...indexes.filter(stub => !taken.has(stub.path))]
|
|
300
312
|
}
|
|
301
313
|
|
|
302
314
|
/**
|
|
@@ -307,10 +319,13 @@ function createStubs({ api }) {
|
|
|
307
319
|
* find it with no change.
|
|
308
320
|
*
|
|
309
321
|
* Derived from the targets, since there is no api.folders(): a folder
|
|
310
|
-
* counts when a page *
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
*
|
|
322
|
+
* counts when a page *a listing would show* lives in it or below it -
|
|
323
|
+
* a page without a file (`html_file: false`) too, since the index is
|
|
324
|
+
* where its card is.
|
|
325
|
+
* An empty folder and an asset-only folder get no index, and a folder
|
|
326
|
+
* whose last page goes loses its index on that pass - the stub is simply
|
|
327
|
+
* no longer declared. A secret folder's pages are all hidden, so none
|
|
328
|
+
* counts here; secretFolderIndexes gives those folders theirs.
|
|
314
329
|
*
|
|
315
330
|
* An author's own `blog.md` beside `blog/` shadows the stub by path,
|
|
316
331
|
* votive's ordinary rule; the stub stands down when `<folder>.html`
|
|
@@ -322,7 +337,7 @@ function createStubs({ api }) {
|
|
|
322
337
|
* @param {any} api - the enumerator's untracked api
|
|
323
338
|
*/
|
|
324
339
|
function folderIndexes(api) {
|
|
325
|
-
const pages =
|
|
340
|
+
const pages = showPages(api, { folder: "", recursive: true })
|
|
326
341
|
.filter(page => page.source && page.dir)
|
|
327
342
|
|
|
328
343
|
const folders = new Set()
|
|
@@ -341,6 +356,50 @@ function folderIndexes(api) {
|
|
|
341
356
|
})
|
|
342
357
|
}
|
|
343
358
|
|
|
359
|
+
/**
|
|
360
|
+
* One index per secret folder (`members##salt/`) with a written page,
|
|
361
|
+
* and per folder below one. Per Sam: a secret folder should get its
|
|
362
|
+
* index like any other, and without one its breadcrumb had nothing to
|
|
363
|
+
* link to.
|
|
364
|
+
*
|
|
365
|
+
* Built in *source* terms, because a secret folder's published name is
|
|
366
|
+
* a hash. The stub is `members##salt.md`, which routing hashes to
|
|
367
|
+
* `<hash>.html` beside the folder's `<hash>/`, the way `blog.md` sits
|
|
368
|
+
* beside `blog/` - so the breadcrumb's `<folder>.html` lookup finds it.
|
|
369
|
+
* Its listing is a relative glob, the only kind that sees secret pages,
|
|
370
|
+
* and its title is the folder's name without the salt (expandStubs). As
|
|
371
|
+
* a page under a secret segment it is hidden itself.
|
|
372
|
+
*
|
|
373
|
+
* Folders *above* the secret segment get nothing from a secret page:
|
|
374
|
+
* their index would list none of it. A `status: hidden` page doesn't
|
|
375
|
+
* count. An author's own `members##salt.md` shadows the stub by path.
|
|
376
|
+
* @param {any} api - the enumerator's untracked api
|
|
377
|
+
*/
|
|
378
|
+
function secretFolderIndexes(api) {
|
|
379
|
+
const pages = api.targets({ folder: "", recursive: true, query: { hidden: true } })
|
|
380
|
+
.filter(page => page.write !== false
|
|
381
|
+
&& page.extension === ".html"
|
|
382
|
+
&& typeof page.source === "string"
|
|
383
|
+
&& isSecretPath(page.source)
|
|
384
|
+
&& page.metadata.fm_status !== "hidden")
|
|
385
|
+
|
|
386
|
+
const folders = new Set(pages.flatMap(page => {
|
|
387
|
+
const segments = page.source.split(path.sep).slice(0, -1)
|
|
388
|
+
const first = segments.findIndex(segment => isSecretPath(segment))
|
|
389
|
+
if (first === -1) return []
|
|
390
|
+
return segments.slice(first).map((_, offset) => segments.slice(0, first + offset + 1).join(path.sep))
|
|
391
|
+
}))
|
|
392
|
+
|
|
393
|
+
return [...folders].sort().flatMap(folder => {
|
|
394
|
+
const stubPath = `${folder}.md`
|
|
395
|
+
// vowel's router (config.js): secret segments hashed, then lowercased.
|
|
396
|
+
const routed = secretRouter(stubPath).toLowerCase().replace(/\.md$/, ".html")
|
|
397
|
+
const indexTarget = api.target(routed.split("/").join(path.sep))
|
|
398
|
+
const ours = !indexTarget || indexTarget.source === stubPath
|
|
399
|
+
return ours ? [{ path: stubPath, params: { folder, secret: true } }] : []
|
|
400
|
+
})
|
|
401
|
+
}
|
|
402
|
+
|
|
344
403
|
/**
|
|
345
404
|
* Produces a declared stub's markdown, on demand.
|
|
346
405
|
*
|
|
@@ -371,13 +430,23 @@ function expandStubs({ path: sourcePath, params }) {
|
|
|
371
430
|
return { text: createHashtagPage(params.tag) }
|
|
372
431
|
}
|
|
373
432
|
|
|
433
|
+
if (params?.secret) {
|
|
434
|
+
// A secret folder's index: titled by its name without the salt, and
|
|
435
|
+
// listed by a relative glob, resolved against this stub's own source
|
|
436
|
+
// path - the folder beside it - which is how a listing sees the
|
|
437
|
+
// secret pages a published-path glob never does.
|
|
438
|
+
const name = displayPath(params.folder).split(path.sep).at(-1)
|
|
439
|
+
const own = params.folder.split(path.sep).at(-1)
|
|
440
|
+
return { text: `# ${labelFromName(name)}\n\n./${own}/*` }
|
|
441
|
+
}
|
|
442
|
+
|
|
374
443
|
if (params?.folder !== undefined) {
|
|
375
444
|
// The folder's name as its title, and a listing of the folder - not
|
|
376
445
|
// recursive: a nested section has an index of its own, and that
|
|
377
446
|
// index is what appears here.
|
|
378
447
|
const name = params.folder.split(path.sep).at(-1)
|
|
379
448
|
const url = "/" + params.folder.split(path.sep).join("/")
|
|
380
|
-
return { text: `# ${
|
|
449
|
+
return { text: `# ${labelFromName(name)}\n\n${url}/*` }
|
|
381
450
|
}
|
|
382
451
|
|
|
383
452
|
return null
|