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.
- package/LICENSE +21 -0
- package/README.md +41 -239
- package/bin.js +73 -31
- package/cli.js +32 -0
- package/config.js +14 -0
- package/extractDate.js +2 -2
- package/index.js +6 -1
- package/package.json +9 -34
- package/pathDate.js +69 -0
- package/plugins/assets/index.js +1 -1
- package/plugins/atproto/body.js +413 -0
- package/plugins/atproto/index.js +10 -44
- package/plugins/atproto/lexicons.js +179 -0
- package/plugins/atproto/mapper.js +399 -0
- package/plugins/atproto/publish.js +195 -105
- package/plugins/atproto/record.js +57 -68
- package/plugins/deploy-cloudflare/index.js +4 -1
- package/plugins/export/index.js +131 -0
- package/plugins/html/bundle/index.js +57 -23
- 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 +42 -11
- package/plugins/html/editor/ingest.js +19 -8
- package/plugins/html/editor/serialize.js +8 -4
- package/plugins/html/image.js +11 -5
- package/plugins/html/index.js +217 -100
- package/plugins/html/partials.js +14 -8
- package/plugins/html/socialLinks.js +5 -8
- package/plugins/html/writeRules.js +197 -37
- package/plugins/images/favicon.png +0 -0
- package/plugins/images/index.js +41 -3
- package/plugins/markdown/colorScheme.js +70 -14
- package/plugins/markdown/frontmatter.js +32 -6
- package/plugins/markdown/index.js +105 -20
- package/plugins/markdown/metadata.js +74 -22
- package/plugins/markdown/typography.js +11 -11
- package/plugins/secret-pages/index.js +75 -0
- package/plugins/styles/index.js +4 -4
- package/plugins/xml/entryContent.js +22 -11
- package/plugins/xml/index.js +19 -11
- package/stylesheets/DefaultStyles.css +198 -111
- package/stylesheets/ResetStyles.css +2 -1
- package/stylesheets/TypographyStyles.css +153 -74
- 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,
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
85
|
-
metadata.
|
|
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. `
|
|
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.
|
|
206
|
-
log?.("warn", `${filePath}: \`
|
|
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
|
-
//
|
|
280
|
-
// tags
|
|
281
|
-
|
|
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 *
|
|
293
|
-
*
|
|
294
|
-
*
|
|
295
|
-
*
|
|
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 =
|
|
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: `# ${
|
|
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,
|
|
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
|
-
"
|
|
39
|
-
"
|
|
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
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
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.
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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) ||
|
|
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
|
-
|
|
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.
|
|
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 `--
|
|
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 --
|
|
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: "--
|
|
33
|
-
{ key: "heading-size-decay", variable: "--
|
|
34
|
-
{ key: "h1-letter-spacing", variable: "--
|
|
35
|
-
{ key: "h6-letter-spacing", variable: "--
|
|
36
|
-
{ key: "letter-spacing-decay", variable: "--
|
|
37
|
-
{ key: "h1-weight", variable: "--
|
|
38
|
-
{ key: "h6-weight", variable: "--
|
|
39
|
-
{ key: "weight-decay", variable: "--
|
|
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(--
|
|
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
|
package/plugins/styles/index.js
CHANGED
|
@@ -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:
|
|
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
|
|
84
|
-
console.warn(`${styleText("dim", "build: ")}${styleText("yellow",
|
|
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
|
-
|
|
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
|
package/plugins/xml/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import xml from "xml"
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
];
|