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.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +41 -239
  3. package/bin.js +91 -31
  4. package/cli.js +32 -0
  5. package/config.js +12 -0
  6. package/extractDate.js +1 -1
  7. package/package.json +8 -34
  8. package/pathDate.js +69 -0
  9. package/plugins/assets/index.js +1 -1
  10. package/plugins/atproto/body.js +3 -1
  11. package/plugins/atproto/index.js +5 -45
  12. package/plugins/atproto/publish.js +42 -11
  13. package/plugins/atproto/record.js +47 -4
  14. package/plugins/deploy-cloudflare/index.js +4 -1
  15. package/plugins/export/index.js +131 -0
  16. package/plugins/html/bundle/index.js +101 -44
  17. package/plugins/html/editor/Editor.svelte +9 -5
  18. package/plugins/html/editor/create-session.js +3 -1
  19. package/plugins/html/editor/directives.js +12 -0
  20. package/plugins/html/editor/frontmatter.js +77 -12
  21. package/plugins/html/editor/ingest.js +29 -31
  22. package/plugins/html/editor/main.js +5 -1
  23. package/plugins/html/editor/serialize.js +8 -4
  24. package/plugins/html/editor/socket.js +34 -4
  25. package/plugins/html/image.js +88 -27
  26. package/plugins/html/index.js +237 -109
  27. package/plugins/html/partials.js +41 -17
  28. package/plugins/html/socialLinks.js +13 -11
  29. package/plugins/html/writeRules.js +234 -42
  30. package/plugins/images/favicon.png +0 -0
  31. package/plugins/images/index.js +69 -8
  32. package/plugins/markdown/colorScheme.js +70 -14
  33. package/plugins/markdown/frontmatter.js +18 -8
  34. package/plugins/markdown/index.js +94 -25
  35. package/plugins/markdown/metadata.js +132 -32
  36. package/plugins/markdown/typography.js +11 -11
  37. package/plugins/styles/index.js +4 -4
  38. package/plugins/xml/index.js +17 -10
  39. package/stylesheets/DefaultStyles.css +163 -108
  40. package/stylesheets/TypographyStyles.css +148 -81
  41. package/utils.js +74 -21
@@ -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 targets = imageSizes.flatMap(size => [...imageExts, ext].map(format => ({
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: format.replace(/^\./, "") } }
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
- return { metadata: { hash, width, height, dominant: dominantColor }, targets }
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
- * An original is copied through. A derivative is resized and converted
69
- * from its owner's bytes - which is what target.buffer() reads, because
70
- * votive stored the owner as the derivative's source.
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, { shadeCount, minLightness: 0.3, maxLightness: 0.99, minChroma: 0.2 })
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) => ` --${role}-${shadeName(index + 1)}: ${color};`)
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, so a typo in settings.md leaves the site
49
- * styled instead of stripping every color variable out from under
50
- * DefaultStyles.css.
51
- * @param {unknown} colors
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(colors, onError) {
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, as `published: false` does.
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
- tagline: "string",
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
- global_menu_item: "boolean",
74
- local_menu_item: "boolean",
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: undefined, problem: `expected one of ${STATUSES.join(", ")}, got ${JSON.stringify(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, toTitleCase, listPages } from "./../../utils.js"
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
- metadata.first_image = firstImageParagraph.children[0].url
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 - or `published: false`, its older
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 (metadata.fm_published === false || status === "draft") return
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
- if (isSecretPath(filePath) || status === "hidden") {
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.local_menu_item ??= false
103
- metadata.global_menu_item ??= false
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. `tagline:` is warned about too: it
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.fm_tagline !== undefined) {
224
- log?.("warn", `${filePath}: \`tagline:\` in a settings file does nothing - a tagline belongs to a page, e.g. home.md`)
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
- // except where a stub above already is that page: `tags.md` is the
298
- // tags folder's index.
299
- return [...named, ...folderIndexes(api).filter(stub => !taken.has(stub.path))]
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 *listPages would show* lives in it or below it.
311
- * An empty folder, an asset-only folder and a secret folder (every page
312
- * hidden) get no index, and a folder whose last page goes loses its
313
- * index on that pass - the stub is simply no longer declared.
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 = listPages(api, { folder: "", recursive: true })
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: `# ${toTitleCase(name)}\n\n${url}/*` }
449
+ return { text: `# ${labelFromName(name)}\n\n${url}/*` }
381
450
  }
382
451
 
383
452
  return null