vowel 0.7.0-beta.0 → 0.7.1

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 (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +41 -239
  3. package/bin.js +73 -31
  4. package/cli.js +32 -0
  5. package/config.js +14 -0
  6. package/extractDate.js +2 -2
  7. package/index.js +6 -1
  8. package/package.json +9 -34
  9. package/pathDate.js +69 -0
  10. package/plugins/assets/index.js +1 -1
  11. package/plugins/atproto/body.js +413 -0
  12. package/plugins/atproto/index.js +10 -44
  13. package/plugins/atproto/lexicons.js +179 -0
  14. package/plugins/atproto/mapper.js +399 -0
  15. package/plugins/atproto/publish.js +195 -105
  16. package/plugins/atproto/record.js +57 -68
  17. package/plugins/deploy-cloudflare/index.js +4 -1
  18. package/plugins/export/index.js +131 -0
  19. package/plugins/html/bundle/index.js +57 -23
  20. package/plugins/html/editor/Editor.svelte +9 -5
  21. package/plugins/html/editor/create-session.js +3 -1
  22. package/plugins/html/editor/directives.js +12 -0
  23. package/plugins/html/editor/frontmatter.js +42 -11
  24. package/plugins/html/editor/ingest.js +19 -8
  25. package/plugins/html/editor/serialize.js +8 -4
  26. package/plugins/html/image.js +11 -5
  27. package/plugins/html/index.js +217 -100
  28. package/plugins/html/partials.js +14 -8
  29. package/plugins/html/socialLinks.js +5 -8
  30. package/plugins/html/writeRules.js +197 -37
  31. package/plugins/images/favicon.png +0 -0
  32. package/plugins/images/index.js +41 -3
  33. package/plugins/markdown/colorScheme.js +70 -14
  34. package/plugins/markdown/frontmatter.js +32 -6
  35. package/plugins/markdown/index.js +105 -20
  36. package/plugins/markdown/metadata.js +74 -22
  37. package/plugins/markdown/typography.js +11 -11
  38. package/plugins/secret-pages/index.js +75 -0
  39. package/plugins/styles/index.js +4 -4
  40. package/plugins/xml/entryContent.js +22 -11
  41. package/plugins/xml/index.js +19 -11
  42. package/stylesheets/DefaultStyles.css +198 -111
  43. package/stylesheets/ResetStyles.css +2 -1
  44. package/stylesheets/TypographyStyles.css +153 -74
  45. package/utils.js +74 -1
@@ -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
19
  import getMetadata, { reservedProperties } from "./metadata.js"
20
- import { isSecretPath } from "./../../secretPaths.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"
@@ -68,7 +68,22 @@ function readFile(source, { api, config }) {
68
68
  }
69
69
 
70
70
 
71
- if (metadata.fm_published === false) return
71
+ // `status` (frontmatter.js). A draft is not a page at all.
72
+ const status = metadata.fm_status ?? "published"
73
+ if (status === "draft") return
74
+
75
+ // A deleted page is not written, and carries nothing a listing, a tag
76
+ // page, a backlink or a wikilink could find it by. It exists only so
77
+ // `atproto-publish` can delete the record it used to have: its
78
+ // lexicon, and the url its record key is derived from (rkey.js).
79
+ if (status === "deleted") {
80
+ const kept = {
81
+ fm_status: status,
82
+ prettyURL: metadata.prettyURL,
83
+ ...("fm_atproto_lexicon" in metadata ? { fm_atproto_lexicon: metadata.fm_atproto_lexicon } : {})
84
+ }
85
+ return { data: "", write: false, metadata: kept }
86
+ }
72
87
 
73
88
  // Secrecy is a property of the *path* now: a segment containing "##"
74
89
  // is hashed by the config-level router cascade, so the page is only
@@ -79,10 +94,14 @@ function readFile(source, { api, config }) {
79
94
  // This replaces `secret_key` frontmatter, which hashed one page at a
80
95
  // time, could not cover a folder, left a virtual target at the public
81
96
  // path, and had to be hidden from its own rendering.
82
- if (isSecretPath(filePath)) {
97
+ // `status: hidden` is the same promise made on purpose: written, never
98
+ // listed. So is the site's 404 page, generated or the author's own: it
99
+ // is served for a missing url, and no listing, sitemap, feed or
100
+ // AT Protocol record should name it.
101
+ if (isSecretPath(filePath) || status === "hidden" || filePath === "404.md") {
83
102
  metadata.hidden = true
84
- metadata.local_menu_item ??= false
85
- metadata.global_menu_item ??= false
103
+ metadata.local_nav_item ??= false
104
+ metadata.global_nav_item ??= false
86
105
  metadata.sitemap_item ??= false
87
106
  metadata.rss_item ??= false
88
107
  }
@@ -113,6 +132,13 @@ function readFile(source, { api, config }) {
113
132
 
114
133
 
115
134
  const hast = toHast(mdast, {
135
+ // A table in a <figure>: the figure scrolls sideways when the table
136
+ // is too wide, so the table itself can be a real table at full width
137
+ // (a table that scrolls has to be display: block, which sizes its
138
+ // columns to their content), and a caption has somewhere to go.
139
+ handlers: {
140
+ table: (state, node) => h("figure", [defaultHandlers.table(state, node)])
141
+ },
116
142
  // `state.all(node)` converts the children to hast; handing over
117
143
  // `node.children` gives the HTML compiler raw *mdast* instead. Plain
118
144
  // text survives that by coincidence - mdast and hast spell a text
@@ -191,7 +217,7 @@ function withDeclaredTypes(metadata) {
191
217
  * label inferred from its own filename - and its first paragraph became
192
218
  * the description. The site's name is `name:` (read as `fm_name`); a
193
219
  * `title:` here is an old spelling and is warned about, since a page
194
- * has a title and a site has a name. `tagline:` is warned about too: it
220
+ * has a title and a site has a name. `slogan:` is warned about too: it
195
221
  * is a page's, and the homepage's is the one a site shows on its front
196
222
  * door. `markdown` is the source, which the settings panel edits.
197
223
  * @param {Record<string, any>} metadata
@@ -202,8 +228,8 @@ function settingsContribution(metadata, filePath, log) {
202
228
  if (metadata.fm_title !== undefined && metadata.fm_name === undefined) {
203
229
  log?.("warn", `${filePath}: \`title:\` in a settings file does nothing - the site's name is \`name:\``)
204
230
  }
205
- if (metadata.fm_tagline !== undefined) {
206
- log?.("warn", `${filePath}: \`tagline:\` in a settings file does nothing - a tagline belongs to a page, e.g. home.md`)
231
+ if (metadata.fm_slogan !== undefined) {
232
+ log?.("warn", `${filePath}: \`slogan:\` in a settings file does nothing - a slogan belongs to a page, e.g. home.md`)
207
233
  }
208
234
  const declared = Object.entries(metadata)
209
235
  .filter(([key]) => key.startsWith("fm_") || reservedProperties.includes(key))
@@ -275,10 +301,12 @@ function createStubs({ api }) {
275
301
  ]
276
302
  const taken = new Set(named.map(stub => stub.path))
277
303
 
278
- // A section index per folder with a listed page (see folderIndexes) -
279
- // except where a stub above already is that page: `tags.md` is the
280
- // tags folder's index.
281
- return [...named, ...folderIndexes(api).filter(stub => !taken.has(stub.path))]
304
+ // A section index per folder with a listed page (see folderIndexes),
305
+ // and per secret folder with a page (secretFolderIndexes) - except
306
+ // where a stub above already is that page: `tags.md` is the tags
307
+ // folder's index.
308
+ const indexes = [...folderIndexes(api), ...secretFolderIndexes(api)]
309
+ return [...named, ...indexes.filter(stub => !taken.has(stub.path))]
282
310
  }
283
311
 
284
312
  /**
@@ -289,10 +317,13 @@ function createStubs({ api }) {
289
317
  * find it with no change.
290
318
  *
291
319
  * Derived from the targets, since there is no api.folders(): a folder
292
- * counts when a page *listPages would show* lives in it or below it.
293
- * An empty folder, an asset-only folder and a secret folder (every page
294
- * hidden) get no index, and a folder whose last page goes loses its
295
- * index on that pass - the stub is simply no longer declared.
320
+ * counts when a page *a listing would show* lives in it or below it -
321
+ * a page without a file (`html_file: false`) too, since the index is
322
+ * where its card is.
323
+ * An empty folder and an asset-only folder get no index, and a folder
324
+ * whose last page goes loses its index on that pass - the stub is simply
325
+ * no longer declared. A secret folder's pages are all hidden, so none
326
+ * counts here; secretFolderIndexes gives those folders theirs.
296
327
  *
297
328
  * An author's own `blog.md` beside `blog/` shadows the stub by path,
298
329
  * votive's ordinary rule; the stub stands down when `<folder>.html`
@@ -304,7 +335,7 @@ function createStubs({ api }) {
304
335
  * @param {any} api - the enumerator's untracked api
305
336
  */
306
337
  function folderIndexes(api) {
307
- const pages = listPages(api, { folder: "", recursive: true })
338
+ const pages = showPages(api, { folder: "", recursive: true })
308
339
  .filter(page => page.source && page.dir)
309
340
 
310
341
  const folders = new Set()
@@ -323,6 +354,50 @@ function folderIndexes(api) {
323
354
  })
324
355
  }
325
356
 
357
+ /**
358
+ * One index per secret folder (`members##salt/`) with a written page,
359
+ * and per folder below one. Per Sam: a secret folder should get its
360
+ * index like any other, and without one its breadcrumb had nothing to
361
+ * link to.
362
+ *
363
+ * Built in *source* terms, because a secret folder's published name is
364
+ * a hash. The stub is `members##salt.md`, which routing hashes to
365
+ * `<hash>.html` beside the folder's `<hash>/`, the way `blog.md` sits
366
+ * beside `blog/` - so the breadcrumb's `<folder>.html` lookup finds it.
367
+ * Its listing is a relative glob, the only kind that sees secret pages,
368
+ * and its title is the folder's name without the salt (expandStubs). As
369
+ * a page under a secret segment it is hidden itself.
370
+ *
371
+ * Folders *above* the secret segment get nothing from a secret page:
372
+ * their index would list none of it. A `status: hidden` page doesn't
373
+ * count. An author's own `members##salt.md` shadows the stub by path.
374
+ * @param {any} api - the enumerator's untracked api
375
+ */
376
+ function secretFolderIndexes(api) {
377
+ const pages = api.targets({ folder: "", recursive: true, query: { hidden: true } })
378
+ .filter(page => page.write !== false
379
+ && page.extension === ".html"
380
+ && typeof page.source === "string"
381
+ && isSecretPath(page.source)
382
+ && page.metadata.fm_status !== "hidden")
383
+
384
+ const folders = new Set(pages.flatMap(page => {
385
+ const segments = page.source.split(path.sep).slice(0, -1)
386
+ const first = segments.findIndex(segment => isSecretPath(segment))
387
+ if (first === -1) return []
388
+ return segments.slice(first).map((_, offset) => segments.slice(0, first + offset + 1).join(path.sep))
389
+ }))
390
+
391
+ return [...folders].sort().flatMap(folder => {
392
+ const stubPath = `${folder}.md`
393
+ // vowel's router (config.js): secret segments hashed, then lowercased.
394
+ const routed = secretRouter(stubPath).toLowerCase().replace(/\.md$/, ".html")
395
+ const indexTarget = api.target(routed.split("/").join(path.sep))
396
+ const ours = !indexTarget || indexTarget.source === stubPath
397
+ return ours ? [{ path: stubPath, params: { folder, secret: true } }] : []
398
+ })
399
+ }
400
+
326
401
  /**
327
402
  * Produces a declared stub's markdown, on demand.
328
403
  *
@@ -353,13 +428,23 @@ function expandStubs({ path: sourcePath, params }) {
353
428
  return { text: createHashtagPage(params.tag) }
354
429
  }
355
430
 
431
+ if (params?.secret) {
432
+ // A secret folder's index: titled by its name without the salt, and
433
+ // listed by a relative glob, resolved against this stub's own source
434
+ // path - the folder beside it - which is how a listing sees the
435
+ // secret pages a published-path glob never does.
436
+ const name = displayPath(params.folder).split(path.sep).at(-1)
437
+ const own = params.folder.split(path.sep).at(-1)
438
+ return { text: `# ${labelFromName(name)}\n\n./${own}/*` }
439
+ }
440
+
356
441
  if (params?.folder !== undefined) {
357
442
  // The folder's name as its title, and a listing of the folder - not
358
443
  // recursive: a nested section has an index of its own, and that
359
444
  // index is what appears here.
360
445
  const name = params.folder.split(path.sep).at(-1)
361
446
  const url = "/" + params.folder.split(path.sep).join("/")
362
- return { text: `# ${toTitleCase(name)}\n\n${url}/*` }
447
+ return { text: `# ${labelFromName(name)}\n\n${url}/*` }
363
448
  }
364
449
 
365
450
  return null
@@ -1,8 +1,9 @@
1
1
  import { toString as mdastToString } from 'mdast-util-to-string'
2
2
  import extractDate, { dateSpan } from "./../../extractDate.js"
3
- import { testURL, toTitleCase } from "./../../utils.js"
3
+ import { testURL, labelFromName } from "./../../utils.js"
4
4
  import path from "node:path"
5
5
  import { displayPath } from "../../secretPaths.js"
6
+ import pathDate from "../../pathDate.js"
6
7
  import { coerce, parseFrontmatter, toISODate } from "./frontmatter.js"
7
8
 
8
9
 
@@ -35,12 +36,11 @@ export const reservedProperties = [
35
36
  "rss_item",
36
37
  "sitemap_item",
37
38
  "html_file",
38
- "global_menu_item",
39
- "local_menu_item",
39
+ "global_nav_item",
40
+ "local_nav_item",
40
41
  "theme",
41
42
  "logo",
42
- "wordmark",
43
- "breadcrumb"
43
+ "wordmark"
44
44
  ]
45
45
 
46
46
  /**
@@ -72,10 +72,10 @@ export function normalizeHeadingLevels(tree) {
72
72
  }
73
73
 
74
74
  /**
75
- * Marks a recognized date in place so it renders as <time> without moving
76
- * out of the content. The editor reads the element's text back, so the
77
- * author's own wording survives the round trip and the markup is simply
78
- * re-inferred on the next build.
75
+ * Marks a recognized date so it renders as <time>, keeping the author's
76
+ * wording. The editor reads the element's text back, so that wording
77
+ * survives the round trip and the markup is simply re-inferred on the
78
+ * next build.
79
79
  * @param {object} paragraph
80
80
  * @param {Date} date
81
81
  */
@@ -88,10 +88,16 @@ function markAsTime(paragraph, date) {
88
88
  }
89
89
 
90
90
  /**
91
- * Recognizable data that may precede the content. Each block is recorded
92
- * where the author placed it and never relocated - only the title is
93
- * hoisted (see findTitleNode). Adding another inferred property later (an
94
- * ISBN, say) means adding a branch here, not new plumbing.
91
+ * Recognizable data that may precede the content. A block is data only
92
+ * when the property it would fill is still empty: a date line on a page
93
+ * whose frontmatter has a `date:`, or a second date line, is content,
94
+ * and the scan stops there (per Sam). Returns what the block is: "date"
95
+ * or "image" for natural metadata, which the html plugin lifts out of
96
+ * the content and renders with the page's metadata (the source is never
97
+ * changed); "skip" for a line that is neither metadata nor content (a
98
+ * listing, a bare url); false for content. Adding another inferred
99
+ * property later (an ISBN, say) means adding a branch here, not new
100
+ * plumbing.
95
101
  *
96
102
  * @param {object} block
97
103
  * @param {object} metadata
@@ -103,10 +109,13 @@ function recognizeData(block, metadata) {
103
109
 
104
110
  const [child] = block.children
105
111
 
112
+ const imageFilled = Boolean(metadata.fm_image || metadata.inferred_image)
113
+
106
114
  if (child.type === "image") {
115
+ if (imageFilled) return false
107
116
  metadata.inferred_image = child.url
108
117
  metadata.inferred_alt_text = child.alt
109
- return true
118
+ return "image"
110
119
  }
111
120
 
112
121
  const text = mdastToString(block)
@@ -116,16 +125,17 @@ function recognizeData(block, metadata) {
116
125
  // must not become the page's description, which is what a section
117
126
  // index's own listing line was doing. Same test as writeRules'
118
127
  // directive rule.
119
- if (child.type === "text" && /^(\/|\.\.?\/)\S*$/.test(text.trim())) return true
128
+ if (child.type === "text" && /^(\/|\.\.?\/)\S*$/.test(text.trim())) return "skip"
120
129
 
121
130
  if (testURL(text)) {
122
131
  const url = new URL(text)
123
132
  if (text.match(/\.(jpeg|jpg|png)$/)) {
133
+ if (imageFilled) return false
124
134
  metadata.inferred_image = url
125
135
  } else {
126
136
  metadata.inferred_link = url
127
137
  }
128
- return true
138
+ return "skip"
129
139
  }
130
140
 
131
141
  // A date is a property of the page only when the author wrote it as
@@ -144,12 +154,28 @@ function recognizeData(block, metadata) {
144
154
 
145
155
  const date = extractDate(text)
146
156
  if (!date) return false
157
+ if (metadata.fm_date || metadata.inferred_date) return false
147
158
 
148
159
  // Stored as ISO, like a frontmatter date (frontmatter.js, toISODate):
149
160
  // one format in the column, which sorts lexically.
150
161
  metadata.inferred_date = toISODate(date)
151
162
  markAsTime(block, date)
152
- return true
163
+ return "date"
164
+ }
165
+
166
+ /**
167
+ * Marks a block as natural metadata - written in the text, not the
168
+ * frontmatter - for the html plugin to lift out of the content
169
+ * (`data-metadata`, which it removes): "date", "image", "description",
170
+ * or "end" for the rule that closed the description, which renders as
171
+ * nothing. A description is marked as one where it is rendered.
172
+ * @param {object} block
173
+ * @param {"date" | "image" | "description" | "end"} kind
174
+ */
175
+ function markAsNatural(block, kind) {
176
+ const itemprop = kind === "description" ? { itemprop: "description" } : {}
177
+ const hProperties = { ...block.data?.hProperties, ...itemprop, dataMetadata: kind }
178
+ block.data = { ...block.data, hProperties }
153
179
  }
154
180
 
155
181
  /**
@@ -201,7 +227,7 @@ function getMetadata(tree, filePath, targetPath, report = () => {}) {
201
227
 
202
228
  const titleNode = findTitleNode(tree)
203
229
  if (titleNode) {
204
- metadata.inferred_title = mdastToString(titleNode) || toTitleCase(path.parse(filePath).name)
230
+ metadata.inferred_title = mdastToString(titleNode) || labelFromName(path.parse(filePath).name)
205
231
  }
206
232
 
207
233
  // Everything up to the first block that is not recognizable data is
@@ -209,9 +235,27 @@ function getMetadata(tree, filePath, targetPath, report = () => {}) {
209
235
  // supplies the description.
210
236
  const blocks = tree.children.filter(child => child.type !== "yaml" && child !== titleNode)
211
237
 
212
- for (const block of blocks) {
238
+ for (const [index, block] of blocks.entries()) {
213
239
  const recognized = recognizeData(block, metadata)
214
- if (recognized) continue
240
+ if (recognized === "skip") continue
241
+ if (recognized) {
242
+ markAsNatural(block, recognized)
243
+ continue
244
+ }
245
+
246
+ // One paragraph and then a rule: the author ended their metadata
247
+ // with the rule, so the paragraph *is* the description - stated,
248
+ // not guessed, so it outranks a slogan. With a `description:` in the
249
+ // frontmatter it is an ordinary paragraph, and the rule an ordinary
250
+ // rule. (`text` then `---` on the next line is a heading in
251
+ // markdown, not a paragraph and a rule; it takes a blank line.)
252
+ const next = blocks[index + 1]
253
+ if (!metadata.fm_description && block.type === "paragraph" && next?.type === "thematicBreak") {
254
+ metadata.declared_description = mdastToString(block)
255
+ markAsNatural(block, "description")
256
+ markAsNatural(next, "end")
257
+ break
258
+ }
215
259
 
216
260
  if (!metadata.fm_description) metadata.inferred_description = mdastToString(block)
217
261
  break
@@ -229,7 +273,14 @@ function getMetadata(tree, filePath, targetPath, report = () => {}) {
229
273
  // sees, so it is the one place the salt has to be stripped.
230
274
  const pathInfo = path.parse(displayPath(filePath))
231
275
 
232
- metadata.inferred_label = toTitleCase(pathInfo.name)
276
+ // A date the author wrote wins; failing that, the one the path spells
277
+ // (`journal/2024/april/1.md`).
278
+ if (!metadata.fm_date && !metadata.inferred_date) {
279
+ const date = pathDate(displayPath(filePath))
280
+ if (date) metadata.inferred_date = toISODate(date)
281
+ }
282
+
283
+ metadata.inferred_label = labelFromName(pathInfo.name)
233
284
 
234
285
  // What a [[wikilink]] matches, the way Obsidian matches: the filename
235
286
  // without its extension, case-insensitively, plus any `aliases:` the
@@ -283,7 +334,8 @@ function selectMetadata(metadata) {
283
334
 
284
335
  const description =
285
336
  metadata.fm_description
286
- || metadata.fm_tagline
337
+ || metadata.declared_description
338
+ || metadata.fm_slogan
287
339
  || metadata.inferred_description
288
340
 
289
341
  if (description) {
@@ -9,7 +9,7 @@
9
9
  // the document for live preview, so preview and build cannot disagree.
10
10
  //
11
11
  // No bundled fonts, and no font setting: a family is CSS - the author
12
- // sets `--font-sans-brand` (and the @font-face behind it) in their own
12
+ // sets `--fontfamily-sans-brand` (and the @font-face behind it) in their own
13
13
  // stylesheet, where the sheet's stacks put a system fallback after it.
14
14
  // Fonts by url is tasks/2-in-progress/load-fonts.md. Free of node
15
15
  // builtins.
@@ -20,7 +20,7 @@
20
20
  * @property {string} variable - the custom property in TypographyStyles.css
21
21
  * @property {string} label
22
22
  * @property {string} [hint]
23
- * @property {"x" | "ch" | ""} unit - "x" is a multiple of --font-size-root
23
+ * @property {"x" | "ch" | ""} unit - "x" is a multiple of --fontsize-root
24
24
  * @property {number} min
25
25
  * @property {number} max
26
26
  * @property {number} step
@@ -29,14 +29,14 @@
29
29
 
30
30
  /** @type {TypographySetting[]} */
31
31
  export const rampSettings = [
32
- { key: "heading-size", variable: "--font-size-delta", label: "Heading size", hint: "How much larger h1 is than body text", unit: "x", min: 0.25, max: 4, step: 0.05, default: 2 },
33
- { key: "heading-size-decay", variable: "--font-size-decay", label: "Size decay", hint: "Higher makes subheadings smaller", unit: "", min: 0.5, max: 6, step: 0.1, default: 3 },
34
- { key: "h1-letter-spacing", variable: "--letter-spacing-h1", label: "h1 letter spacing", unit: "ch", min: -0.1, max: 0.1, step: 0.005, default: -0.01 },
35
- { key: "h6-letter-spacing", variable: "--letter-spacing-h6", label: "h6 letter spacing", unit: "ch", min: -0.1, max: 0.1, step: 0.005, default: 0 },
36
- { key: "letter-spacing-decay", variable: "--letter-spacing-decay", label: "Spacing decay", unit: "", min: 0.5, max: 6, step: 0.1, default: 2 },
37
- { key: "h1-weight", variable: "--font-weight-h1", label: "h1 weight", unit: "", min: 100, max: 900, step: 10, default: 800 },
38
- { key: "h6-weight", variable: "--font-weight-h6", label: "h6 weight", unit: "", min: 100, max: 900, step: 10, default: 600 },
39
- { key: "weight-decay", variable: "--font-weight-decay", label: "Weight decay", unit: "", min: 0.5, max: 6, step: 0.1, default: 2 }
32
+ { key: "heading-size", variable: "--fontsize-delta", label: "Heading size", hint: "How much larger h1 is than body text", unit: "x", min: 0.25, max: 4, step: 0.05, default: 2 },
33
+ { key: "heading-size-decay", variable: "--fontsize-decay", label: "Size decay", hint: "Higher makes subheadings smaller", unit: "", min: 0.5, max: 6, step: 0.1, default: 3 },
34
+ { key: "h1-letter-spacing", variable: "--letterspacing-h1", label: "h1 letter spacing", unit: "ch", min: -0.1, max: 0.1, step: 0.005, default: -0.01 },
35
+ { key: "h6-letter-spacing", variable: "--letterspacing-h6", label: "h6 letter spacing", unit: "ch", min: -0.1, max: 0.1, step: 0.005, default: 0 },
36
+ { key: "letter-spacing-decay", variable: "--letterspacing-decay", label: "Spacing decay", unit: "", min: 0.5, max: 6, step: 0.1, default: 2 },
37
+ { key: "h1-weight", variable: "--fontweight-h1", label: "h1 weight", unit: "", min: 100, max: 900, step: 10, default: 800 },
38
+ { key: "h6-weight", variable: "--fontweight-h6", label: "h6 weight", unit: "", min: 100, max: 900, step: 10, default: 600 },
39
+ { key: "weight-decay", variable: "--fontweight-decay", label: "Weight decay", unit: "", min: 0.5, max: 6, step: 0.1, default: 2 }
40
40
  ]
41
41
 
42
42
  /**
@@ -57,7 +57,7 @@ function themeSettings(theme) {
57
57
  function rampValue(setting, value) {
58
58
  const clamped = Math.min(setting.max, Math.max(setting.min, value))
59
59
  const number = String(Number(clamped.toFixed(4)))
60
- if (setting.unit === "x") return `calc(${number} * var(--font-size-root))`
60
+ if (setting.unit === "x") return `calc(${number} * var(--fontsize-root))`
61
61
  return `${number}${setting.unit}`
62
62
  }
63
63
 
@@ -0,0 +1,75 @@
1
+ import { styleText } from "node:util"
2
+ import { isSecretPath } from "../../secretPaths.js"
3
+
4
+ /** @import * as Votive from "votive" */
5
+
6
+ /**
7
+ * Where your secret pages are. A page under a `##` segment is published
8
+ * at a hashed url that nothing on the site links to, so the author needs
9
+ * another way to find it: the dev server prints each one, file and url,
10
+ * when it first sees it - every one at startup, then each new one (or a
11
+ * renamed one, whose url changed) as it appears.
12
+ *
13
+ * **Only the dev server.** `init()` turns this on with
14
+ * `config.announceSecretPages`; `deploy`, `publish` and a plain build
15
+ * never do. Those run in CI too, where logs are kept and can be public,
16
+ * and a logged url is exactly what the hash exists to keep private.
17
+ *
18
+ * It is a stub enumerator that declares no stubs: the one hook that runs
19
+ * on every pass and can list pages. It lists only the hidden ones - a
20
+ * secret page is always hidden (markdown/index.js) - so a pass costs an
21
+ * indexed query, not a listing of the site.
22
+ */
23
+
24
+ /**
25
+ * What has been printed, per site: the config votive hands every pass
26
+ * is the same object for the life of a site, so a reopened project (the
27
+ * desktop app) prints its pages again.
28
+ * @type {WeakMap<object, Set<string>>}
29
+ */
30
+ const announced = new WeakMap()
31
+
32
+ /**
33
+ * @param {string | undefined} domain
34
+ * @param {string} prettyURL
35
+ */
36
+ function pageURL(domain, prettyURL) {
37
+ if (typeof domain !== "string" || !domain) return prettyURL
38
+ const base = domain.startsWith("http") ? domain : `https://${domain}`
39
+ return new URL(prettyURL, base).href
40
+ }
41
+
42
+ /** @type {Votive.ProcessorStubs} */
43
+ function createStubs({ api, settings, config }) {
44
+ if (!config.announceSecretPages || config.logging === "silent") return []
45
+
46
+ const seen = announced.get(config) ?? new Set()
47
+ announced.set(config, seen)
48
+
49
+ const hidden = api.targets({ folder: "", recursive: true, query: { hidden: true } })
50
+ const pages = hidden.filter(target => (
51
+ target.extension === ".html"
52
+ && target.write !== false
53
+ && typeof target.source === "string"
54
+ && isSecretPath(target.source)
55
+ ))
56
+ const domain = settings.lastNonNull("fm_domain")
57
+ const fresh = pages
58
+ .map(page => ({ source: page.source, url: pageURL(domain, page.metadata.prettyURL) }))
59
+ .filter(({ source, url }) => !seen.has(`${source} ${url}`))
60
+ .sort((a, b) => a.source < b.source ? -1 : a.source > b.source ? 1 : 0)
61
+
62
+ for (const { source, url } of fresh) {
63
+ seen.add(`${source} ${url}`)
64
+ console.info(`${styleText("dim", "secret:")} ${styleText("yellow", source)} ${styleText("dim", "→")} ${styleText("cyan", url)}`)
65
+ }
66
+ return []
67
+ }
68
+
69
+ /** @type {Votive.VotivePlugin} */
70
+ const vowelSecretPagesPlugin = {
71
+ name: "vowel-secret-pages",
72
+ processors: [{ createStubs }]
73
+ }
74
+
75
+ export default vowelSecretPagesPlugin
@@ -4,7 +4,7 @@ import { styleText } from "node:util"
4
4
  import { transform } from "lightningcss"
5
5
  import { hash } from "node:crypto"
6
6
  import { resolveTheme, themeStylesheets, themeKey } from "./theme.js"
7
- import { themeColorSchemeCSS } from "../markdown/colorScheme.js"
7
+ import { themeColorSchemeCSS, colorParams } from "../markdown/colorScheme.js"
8
8
  import { typographyCSS } from "../markdown/typography.js"
9
9
 
10
10
  /** @import * as Votive from "votive" */
@@ -54,7 +54,7 @@ function createStubs({ api, settings }) {
54
54
  return themeStylesheets(theme, rootTheme).map(sheet => {
55
55
  // Generated sheets carry the part of the theme they are generated
56
56
  // from, so they re-expand exactly when that part changes.
57
- if (sheet.startsWith("colors")) return { path: sheet, params: { colors: config.colors ?? null } }
57
+ if (sheet.startsWith("colors")) return { path: sheet, params: colorParams(config) }
58
58
  if (sheet.startsWith("type")) return { path: sheet, params: { theme: config } }
59
59
  return { path: sheet }
60
60
  })
@@ -80,8 +80,8 @@ function expandStubs({ path: sourcePath, params }) {
80
80
  // Reports and falls back rather than throwing: a site with unusable
81
81
  // colours should lose its palette, not its build.
82
82
  return {
83
- text: themeColorSchemeCSS(params?.colors, error => (
84
- console.warn(`${styleText("dim", "build: ")}${styleText("yellow", `ignoring theme.colors - ${error.message}`)}`)
83
+ text: themeColorSchemeCSS(params ?? {}, error => (
84
+ console.warn(`${styleText("dim", "build: ")}${styleText("yellow", error.message)}`)
85
85
  ))
86
86
  }
87
87
  }
@@ -70,17 +70,7 @@ function isContentSection(node) {
70
70
  * @returns {string} the body as HTML, or "" when there is no content
71
71
  */
72
72
  export function entryContent(html, domain) {
73
- if (!html) return ""
74
-
75
- const tree = fromHtml(html, { fragment: false })
76
-
77
- /** @type {import("hast").Element | undefined} */
78
- let content
79
- visit(tree, isContentSection, (node) => {
80
- content = node
81
- return false
82
- })
83
-
73
+ const content = contentSection(html)
84
74
  if (!content) return ""
85
75
 
86
76
  visit(content, "element", (node) => {
@@ -100,4 +90,25 @@ export function entryContent(html, domain) {
100
90
  return toHtml({ type: "root", children: content.children })
101
91
  }
102
92
 
93
+ /**
94
+ * The rendered page's `<section id=content>` - the post body - as a hast
95
+ * element, untouched, or undefined when the page has none. Shared by the
96
+ * feed and by atproto-publish, which both want the post and not the
97
+ * page around it.
98
+ * @param {string | null | undefined} html - the page's rendered output
99
+ * @returns {import("hast").Element | undefined}
100
+ */
101
+ export function contentSection(html) {
102
+ if (!html) return undefined
103
+ const tree = fromHtml(html, { fragment: false })
104
+
105
+ /** @type {import("hast").Element | undefined} */
106
+ let content
107
+ visit(tree, isContentSection, (node) => {
108
+ content = node
109
+ return false
110
+ })
111
+ return content
112
+ }
113
+
103
114
  export default entryContent
@@ -1,5 +1,5 @@
1
1
  import xml from "xml"
2
- import { listPages } from "./../../utils.js"
2
+ import { showPages, isPageWithoutFile } from "./../../utils.js"
3
3
  import { entryContent } from "./entryContent.js"
4
4
 
5
5
  /** @import * as Votive from "votive" */
@@ -78,7 +78,8 @@ const processor = {
78
78
  }
79
79
  },
80
80
  })
81
- .filter(a => a.extension === ".html" && a.path)
81
+ // A virtual page - `html_file: false`, `status: deleted` - has no url.
82
+ .filter(a => a.extension === ".html" && a.path && a.write !== false)
82
83
 
83
84
  // No {delete: true} any more: this target exists only because the
84
85
  // enumerator declared it, and it does that only when a domain is
@@ -133,7 +134,10 @@ const processor = {
133
134
  // the entries kept read it. `feed_limit` in settings.md raises or
134
135
  // lowers it.
135
136
  const limit = Number(settings.lastNonNull("fm_feed_limit")) || DEFAULT_FEED_LIMIT
136
- const pages = listPages(api, {
137
+ // A page with no file (`html_file: false`) is an entry too, with
138
+ // its body and no <link>: Atom needs an <id> and content, not a
139
+ // link, which is what makes a feed-only post possible.
140
+ const pages = showPages(api, {
137
141
  query: {
138
142
  "!": {
139
143
  rss_item: false
@@ -193,21 +197,25 @@ const processor = {
193
197
 
194
198
  feed.push(
195
199
  ...pages.map((page) => {
200
+ // The url the page has, or would have: an Atom <id> is an
201
+ // identifier, so a page without a file keeps the one it would
202
+ // have, and keeps it if it gets a file later.
196
203
  const url = (new URL(page.path, domain)).href
204
+ const link = isPageWithoutFile(page) ? [] : [{
205
+ link: {
206
+ _attr: {
207
+ rel: 'alternate',
208
+ href: url
209
+ }
210
+ }
211
+ }]
197
212
 
198
213
  /** @type {object[]} */
199
214
  const entry = [
200
215
  {
201
216
  title: page.metadata.title
202
217
  },
203
- {
204
- link: {
205
- _attr: {
206
- rel: 'alternate',
207
- href: url
208
- }
209
- }
210
- },
218
+ ...link,
211
219
  { id: url },
212
220
  { updated: page.metadata.date }
213
221
  ];