vowel 0.7.1-beta.0 → 0.7.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +41 -239
- package/bin.js +91 -31
- package/cli.js +32 -0
- package/config.js +12 -0
- package/extractDate.js +1 -1
- package/package.json +8 -34
- package/pathDate.js +69 -0
- package/plugins/assets/index.js +1 -1
- package/plugins/atproto/body.js +3 -1
- package/plugins/atproto/index.js +5 -45
- package/plugins/atproto/publish.js +42 -11
- package/plugins/atproto/record.js +47 -4
- package/plugins/deploy-cloudflare/index.js +4 -1
- package/plugins/export/index.js +131 -0
- package/plugins/html/bundle/index.js +101 -44
- package/plugins/html/editor/Editor.svelte +9 -5
- package/plugins/html/editor/create-session.js +3 -1
- package/plugins/html/editor/directives.js +12 -0
- package/plugins/html/editor/frontmatter.js +77 -12
- package/plugins/html/editor/ingest.js +29 -31
- package/plugins/html/editor/main.js +5 -1
- package/plugins/html/editor/serialize.js +8 -4
- package/plugins/html/editor/socket.js +34 -4
- package/plugins/html/image.js +88 -27
- package/plugins/html/index.js +237 -109
- package/plugins/html/partials.js +41 -17
- package/plugins/html/socialLinks.js +13 -11
- package/plugins/html/writeRules.js +234 -42
- package/plugins/images/favicon.png +0 -0
- package/plugins/images/index.js +69 -8
- package/plugins/markdown/colorScheme.js +70 -14
- package/plugins/markdown/frontmatter.js +18 -8
- package/plugins/markdown/index.js +94 -25
- package/plugins/markdown/metadata.js +132 -32
- package/plugins/markdown/typography.js +11 -11
- package/plugins/styles/index.js +4 -4
- package/plugins/xml/index.js +17 -10
- package/stylesheets/DefaultStyles.css +163 -108
- package/stylesheets/TypographyStyles.css +148 -81
- package/utils.js +74 -21
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vowel",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.7.
|
|
4
|
+
"version": "0.7.3",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=26"
|
|
7
7
|
},
|
|
@@ -32,26 +32,20 @@
|
|
|
32
32
|
"@bomb.sh/tab": "^0.0.15",
|
|
33
33
|
"@clack/prompts": "^1.4.0",
|
|
34
34
|
"@jsdevtools/rehype-toc": "^3.0.2",
|
|
35
|
-
"
|
|
36
|
-
"colorhorse": "^0.3.1",
|
|
37
|
-
"cssnano": "^7.1.4",
|
|
35
|
+
"colorhorse": "^0.5.0",
|
|
38
36
|
"emojilib": "^4.0.3",
|
|
39
|
-
"encoding-sniffer": "^1.0.2",
|
|
40
37
|
"fast-xml-parser": "^5.3.6",
|
|
41
|
-
"faye-websocket": "^0.11.4",
|
|
42
38
|
"github-slugger": "^2.0.0",
|
|
43
39
|
"hast-util-from-html": "^2.0.3",
|
|
40
|
+
"hast-util-to-html": "^9.0.5",
|
|
44
41
|
"hast-util-to-string": "^3.0.1",
|
|
45
42
|
"hast-util-to-text": "^4.0.2",
|
|
46
43
|
"hastscript": "^9.0.1",
|
|
47
|
-
"highlight.js": "^11.12.0",
|
|
48
|
-
"js-yaml": "^4.1.0",
|
|
49
44
|
"lightningcss": "^1.33.0",
|
|
50
45
|
"lowlight": "^3.3.0",
|
|
51
|
-
"mdast": "^2.3.2",
|
|
52
|
-
"mdast-normalize-headings": "^4.0.0",
|
|
53
46
|
"mdast-util-from-markdown": "^2.0.2",
|
|
54
47
|
"mdast-util-frontmatter": "^2.0.1",
|
|
48
|
+
"mdast-util-gfm-footnote": "^2.1.0",
|
|
55
49
|
"mdast-util-gfm-strikethrough": "^2.0.0",
|
|
56
50
|
"mdast-util-gfm-table": "^2.0.0",
|
|
57
51
|
"mdast-util-gfm-task-list-item": "^2.0.0",
|
|
@@ -61,42 +55,21 @@
|
|
|
61
55
|
"mdast-util-to-string": "^4.0.0",
|
|
62
56
|
"micromark-extension-frontmatter": "^2.0.0",
|
|
63
57
|
"micromark-extension-gfm-footnote": "^2.1.0",
|
|
58
|
+
"micromark-extension-gfm-strikethrough": "^2.1.0",
|
|
64
59
|
"micromark-extension-gfm-table": "^2.1.1",
|
|
65
|
-
"micromark-extension-gfm-task-list-item": "^2.1.0",
|
|
66
60
|
"micromark-extension-highlight-mark": "^1.2.0",
|
|
67
|
-
"postcss": "^8.5.9",
|
|
68
|
-
"rehype": "^13.0.2",
|
|
69
|
-
"rehype-document": "^7.0.3",
|
|
70
|
-
"rehype-github-alert": "^1.0.0",
|
|
71
|
-
"rehype-parse": "^9.0.1",
|
|
72
61
|
"rehype-preset-minify": "^7.0.1",
|
|
73
62
|
"rehype-slug": "^6.0.0",
|
|
74
63
|
"rehype-stringify": "^10.0.1",
|
|
75
|
-
"remark": "^15.0.1",
|
|
76
|
-
"remark-frontmatter": "^5.0.0",
|
|
77
|
-
"remark-gfm": "^4.0.1",
|
|
78
|
-
"remark-html": "^16.0.1",
|
|
79
|
-
"remark-parse": "^11.0.0",
|
|
80
|
-
"remark-rehype": "^11.1.2",
|
|
81
64
|
"rollup-plugin-import-css": "^4.2.1",
|
|
82
65
|
"sharp": "^0.35.3",
|
|
83
66
|
"svedit": "^0.14.0",
|
|
84
67
|
"unified": "^11.0.5",
|
|
85
|
-
"unist-util-find": "^3.0.0",
|
|
86
|
-
"unist-util-is": "^6.0.0",
|
|
87
|
-
"unist-util-position": "5.0.0",
|
|
88
|
-
"unist-util-remove": "^4.0.0",
|
|
89
|
-
"unist-util-stringify-position": "^4.0.0",
|
|
90
68
|
"unist-util-visit": "^5.0.0",
|
|
91
|
-
"unist-util-visit-parents": "^6.0.0",
|
|
92
69
|
"unpic": "^4.2.2",
|
|
93
|
-
"
|
|
94
|
-
"votive": "0.5.0-beta.0",
|
|
95
|
-
"xast-util-sitemap": "^2.0.0",
|
|
96
|
-
"xast-util-to-xml": "^4.0.0",
|
|
70
|
+
"votive": "0.5",
|
|
97
71
|
"xml": "^1.0.1",
|
|
98
|
-
"yaml": "^2.8.2"
|
|
99
|
-
"yamljs": "^0.3.0"
|
|
72
|
+
"yaml": "^2.8.2"
|
|
100
73
|
},
|
|
101
74
|
"files": [
|
|
102
75
|
"bin.js",
|
|
@@ -104,6 +77,7 @@
|
|
|
104
77
|
"config.js",
|
|
105
78
|
"extractDate.js",
|
|
106
79
|
"index.js",
|
|
80
|
+
"pathDate.js",
|
|
107
81
|
"secretPaths.js",
|
|
108
82
|
"systemPaths.js",
|
|
109
83
|
"utils.js",
|
package/pathDate.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import path from "node:path"
|
|
2
|
+
import { monthIndex } from "./extractDate.js"
|
|
3
|
+
|
|
4
|
+
// Strict on purpose, and separate from extractDate's pattern: that one is
|
|
5
|
+
// unanchored and reads day-first orders, so on "2023/03/2023-03-24" it
|
|
6
|
+
// matches "2023/03/2023". A path is a guess the author never wrote as a
|
|
7
|
+
// date, so only the year-month-day order counts, each part a whole
|
|
8
|
+
// segment.
|
|
9
|
+
const YEAR = /^\d{4}$/
|
|
10
|
+
const MONTH = /^(?:\d{1,2}|jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|june?|july?|aug(?:ust)?|sep(?:t|tember)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)$/i
|
|
11
|
+
const DAY = /^\d{1,2}$/
|
|
12
|
+
const NAME_PREFIX = /^(\d{4})-(\d{1,2})-(\d{1,2})(?!\d)/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {string} year
|
|
16
|
+
* @param {string} month - a number or a month name
|
|
17
|
+
* @param {string} day
|
|
18
|
+
*/
|
|
19
|
+
function calendarDate(year, month, day) {
|
|
20
|
+
const yearNumber = Number(year)
|
|
21
|
+
const monthNumber = monthIndex(month)
|
|
22
|
+
const dayNumber = Number(day)
|
|
23
|
+
const date = new Date(Date.UTC(yearNumber, monthNumber, dayNumber))
|
|
24
|
+
|
|
25
|
+
// Date.UTC rolls 30 February into March and month 13 into next year,
|
|
26
|
+
// and maps years 0-99 to 1900-1999; any of those is not the date the
|
|
27
|
+
// path spelled.
|
|
28
|
+
const rolled =
|
|
29
|
+
date.getUTCFullYear() !== yearNumber ||
|
|
30
|
+
date.getUTCMonth() !== monthNumber ||
|
|
31
|
+
date.getUTCDate() !== dayNumber
|
|
32
|
+
|
|
33
|
+
if (rolled) return
|
|
34
|
+
return date
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {string} year
|
|
39
|
+
* @param {string} month
|
|
40
|
+
* @param {string} day
|
|
41
|
+
*/
|
|
42
|
+
function segmentsDate(year, month, day) {
|
|
43
|
+
const isDate = YEAR.test(year) && MONTH.test(month) && DAY.test(day)
|
|
44
|
+
if (!isDate) return
|
|
45
|
+
return calendarDate(year, month, day)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The date a source path spells, or undefined: a `YYYY-MM-DD` filename
|
|
50
|
+
* prefix (`blog/2024/2024-11-03-sixty.md`), else a year, month and day in
|
|
51
|
+
* neighbouring segments (`2023/04/07/hello-world.md`,
|
|
52
|
+
* `journal/2024/april/1.md`), the deepest run winning. A secret segment's
|
|
53
|
+
* salt should already be stripped (displayPath).
|
|
54
|
+
* @param {string} sourcePath - project-relative
|
|
55
|
+
*/
|
|
56
|
+
function pathDate(sourcePath) {
|
|
57
|
+
const { dir, name } = path.parse(sourcePath)
|
|
58
|
+
|
|
59
|
+
const prefix = NAME_PREFIX.exec(name)
|
|
60
|
+
const nameDate = prefix ? calendarDate(prefix[1], prefix[2], prefix[3]) : undefined
|
|
61
|
+
if (nameDate) return nameDate
|
|
62
|
+
|
|
63
|
+
const segments = [...dir.split(path.sep), name]
|
|
64
|
+
const dates = segments.slice(0, -2).map((year, index) => segmentsDate(year, segments[index + 1], segments[index + 2]))
|
|
65
|
+
return dates.findLast(Boolean)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export default pathDate
|
|
69
|
+
export { pathDate }
|
package/plugins/assets/index.js
CHANGED
|
@@ -20,7 +20,7 @@ import path from "node:path"
|
|
|
20
20
|
* to be there; adding an extension is adding it here.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
const EXTENSIONS = [".mp4", ".webm", ".csv", ".json", ".pdf"]
|
|
23
|
+
const EXTENSIONS = [".mp4", ".webm", ".csv", ".json", ".pdf", ".ico"]
|
|
24
24
|
|
|
25
25
|
/** 25 MiB: Cloudflare Pages' per-file limit. */
|
|
26
26
|
const DEFAULT_SIZE_LIMIT = 25 * 1024 * 1024
|
package/plugins/atproto/body.js
CHANGED
|
@@ -113,7 +113,9 @@ function phrase(node, base) {
|
|
|
113
113
|
}
|
|
114
114
|
if (tag === "picture") {
|
|
115
115
|
const img = (node.children ?? []).find(child => is(child, "img"))
|
|
116
|
-
|
|
116
|
+
// The <img>'s src is the largest derivative in the image's own
|
|
117
|
+
// format; the original is not published (plugins/images).
|
|
118
|
+
const src = img?.properties?.src
|
|
117
119
|
return src ? [{ type: "image", url: absolute(String(src), base), alt: String(img?.properties?.alt ?? "") }] : []
|
|
118
120
|
}
|
|
119
121
|
if (tag === "a") {
|
package/plugins/atproto/index.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import path from "node:path"
|
|
2
|
-
import {
|
|
3
|
-
import { validNSID } from "./lexicons.js"
|
|
2
|
+
import { publicationURI, siteOrigin, validDID } from "./record.js"
|
|
4
3
|
import { publishToAtproto } from "./publish.js"
|
|
5
4
|
import { saveAppPassword } from "./password.js"
|
|
6
5
|
|
|
@@ -21,8 +20,9 @@ import { saveAppPassword } from "./password.js"
|
|
|
21
20
|
* pastes the DID from their account settings. Resolving it from a handle
|
|
22
21
|
* is a later phase, through readURL like any other fetch.
|
|
23
22
|
*
|
|
24
|
-
* Phase two: a page under an `atproto_lexicon:`
|
|
25
|
-
*
|
|
23
|
+
* Phase two: a page under an `atproto_lexicon:` has an AT-URI, a function
|
|
24
|
+
* of the page and its settings with no network (record.js's
|
|
25
|
+
* `pageRecordURI`, worked out at write and at publish). Any published lexicon
|
|
26
26
|
* will do: vowel bundles none. Publishing is a command,
|
|
27
27
|
* `atproto-publish` (publish.js), which fetches each lexicon, fits the
|
|
28
28
|
* page to it (mapper.js) and sends what changed. A
|
|
@@ -38,11 +38,6 @@ import { saveAppPassword } from "./password.js"
|
|
|
38
38
|
const WELL_KNOWN = path.join(".well-known", "atproto-did")
|
|
39
39
|
const WELL_KNOWN_PUBLICATION = path.join(".well-known", "site.standard.publication")
|
|
40
40
|
|
|
41
|
-
/** A DID is `did:<method>:<id>`; that is all this checks. */
|
|
42
|
-
function validDID(value) {
|
|
43
|
-
return typeof value === "string" && /^did:[a-z]+:[A-Za-z0-9._:%-]+$/.test(value)
|
|
44
|
-
}
|
|
45
|
-
|
|
46
41
|
/**
|
|
47
42
|
* The account, from the nearest settings.md that names one. The atproto
|
|
48
43
|
* settings are flat, `atproto_did` / `atproto_lexicon` / `atproto_pds`,
|
|
@@ -54,35 +49,6 @@ function atprotoDID(settings) {
|
|
|
54
49
|
return settings.lastNonNull("fm_atproto_did")
|
|
55
50
|
}
|
|
56
51
|
|
|
57
|
-
/**
|
|
58
|
-
* The page's AT-URI, stored as metadata for the html plugin's link and
|
|
59
|
-
* the publish command. Runs once per read of the page and reaches no
|
|
60
|
-
* network, so it can't know whether the page fits its lexicon - the
|
|
61
|
-
* publish command finds that out. A lexicon name that isn't an NSID is
|
|
62
|
-
* warned about here.
|
|
63
|
-
*
|
|
64
|
-
* Not for a page with `status: hidden`, which is listed nowhere, nor for
|
|
65
|
-
* one that isn't written (`html_file: false`) - except `status: deleted`,
|
|
66
|
-
* which is kept unwritten precisely so publish can delete its record.
|
|
67
|
-
*/
|
|
68
|
-
function transformFile(target, { settings, config }) {
|
|
69
|
-
const status = target.metadata.fm_status
|
|
70
|
-
if (status === "hidden") return undefined
|
|
71
|
-
if (target.write === false && status !== "deleted") return undefined
|
|
72
|
-
const did = atprotoDID(settings)
|
|
73
|
-
if (!validDID(did)) return undefined
|
|
74
|
-
const origin = siteOrigin(settings.lastNonNull("fm_domain"))
|
|
75
|
-
if (!origin) return undefined
|
|
76
|
-
|
|
77
|
-
const lexicon = lexiconFor(target, settings)
|
|
78
|
-
if (!lexicon) return undefined
|
|
79
|
-
if (!validNSID(lexicon)) {
|
|
80
|
-
config.log("warn", `${target.path}: atproto_lexicon "${lexicon}" is not a lexicon name (an NSID, like app.bsky.feed.post)`)
|
|
81
|
-
return undefined
|
|
82
|
-
}
|
|
83
|
-
return { metadata: { atUri: recordURI(did, lexicon, origin, target.metadata.prettyURL) } }
|
|
84
|
-
}
|
|
85
|
-
|
|
86
52
|
/** @type {Votive.ProcessorStubs} */
|
|
87
53
|
function createStubs({ settings }) {
|
|
88
54
|
const did = atprotoDID(settings)
|
|
@@ -118,16 +84,10 @@ const wellKnownProcessor = {
|
|
|
118
84
|
writeFile: (target) => ({ data: target.data })
|
|
119
85
|
}
|
|
120
86
|
|
|
121
|
-
const recordProcessor = {
|
|
122
|
-
extensions: [".html"],
|
|
123
|
-
format: "text",
|
|
124
|
-
transformFile
|
|
125
|
-
}
|
|
126
|
-
|
|
127
87
|
/** @type {Votive.VotivePlugin} */
|
|
128
88
|
const vowelAtprotoPlugin = {
|
|
129
89
|
name: "vowel-atproto",
|
|
130
|
-
processors: [wellKnownProcessor
|
|
90
|
+
processors: [wellKnownProcessor],
|
|
131
91
|
commands: {
|
|
132
92
|
"atproto-publish": publishToAtproto,
|
|
133
93
|
"atproto-password": saveAppPassword
|
|
@@ -7,10 +7,12 @@ import votive from "votive"
|
|
|
7
7
|
import { systemDirectoryFor } from "../../systemPaths.js"
|
|
8
8
|
import { EXPECTED } from "../markdown/frontmatter.js"
|
|
9
9
|
import { contentSection } from "../xml/entryContent.js"
|
|
10
|
-
import { PUBLICATION, siteOrigin, publicationURI } from "./record.js"
|
|
10
|
+
import { PUBLICATION, siteOrigin, publicationURI, pageRecordURI } from "./record.js"
|
|
11
11
|
import { createLexiconResolver, resolvePDS } from "./lexicons.js"
|
|
12
12
|
import { mapRecord } from "./mapper.js"
|
|
13
13
|
import { markdownBody, richTextBody } from "./body.js"
|
|
14
|
+
import { imageURL } from "../html/image.js"
|
|
15
|
+
import { imageSizes } from "../../utils.js"
|
|
14
16
|
|
|
15
17
|
/**
|
|
16
18
|
* `vowel --command atproto-publish`: make the account's records match the
|
|
@@ -232,12 +234,44 @@ function pageFor(target, pageURL) {
|
|
|
232
234
|
}
|
|
233
235
|
}
|
|
234
236
|
|
|
237
|
+
/**
|
|
238
|
+
* The folder whose settings a page reads: the page as a folder -
|
|
239
|
+
* `blog/post.html` → `blog/post` - which is what votive hands a write
|
|
240
|
+
* (`pageSettingsFolder` in votive's lib/utils, not exported), so publish
|
|
241
|
+
* sees each page's lexicon exactly as its write did. `index.html` is
|
|
242
|
+
* `/index` there, and here.
|
|
243
|
+
* @param {string} targetPath
|
|
244
|
+
*/
|
|
245
|
+
function pageSettingsFolder(targetPath) {
|
|
246
|
+
const segments = targetPath.split("/").filter(Boolean)
|
|
247
|
+
const folder = path.relative("", [...segments.slice(0, -1), ""].join(path.sep))
|
|
248
|
+
return folder + path.sep + path.parse(targetPath).name
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Every page under a lexicon, with its AT-URI: the author's own pages -
|
|
253
|
+
* a generated one (a tag page, a folder index, the default home or 404)
|
|
254
|
+
* is a stub, and inherits a folder's lexicon without being anything to
|
|
255
|
+
* publish.
|
|
256
|
+
* @param {any} database - a built site's
|
|
257
|
+
* @returns {{ target: any, uri: string }[]}
|
|
258
|
+
*/
|
|
259
|
+
function recordPages(database) {
|
|
260
|
+
const stubs = new Set(database.source.getStubs().map(row => row.path))
|
|
261
|
+
return database.target.getAll()
|
|
262
|
+
.filter(target => target.extension === ".html" && !stubs.has(target.source))
|
|
263
|
+
.map(target => ({ target, uri: pageRecordURI(target, database.setting.getByFolder(pageSettingsFolder(target.path))).uri }))
|
|
264
|
+
.filter(page => typeof page.uri === "string")
|
|
265
|
+
}
|
|
266
|
+
|
|
235
267
|
/**
|
|
236
268
|
* What the site should hold: every page under a lexicon - its AT-URI,
|
|
237
269
|
* its status, and what mapper.js reads - the site's own settings, and
|
|
238
270
|
* the publication's name, description and icon. The icon is read from
|
|
239
271
|
* the build's output - it is a URL path from the root (frontmatter.js
|
|
240
|
-
* resolves it), which is where the output has it
|
|
272
|
+
* resolves it), which is where the output has it; a png or jpeg is read
|
|
273
|
+
* from its largest derivative, since the original is not published
|
|
274
|
+
* (plugins/images). Built into a scratch
|
|
241
275
|
* folder that is removed afterwards.
|
|
242
276
|
* @param {import("votive").VotiveConfig} config
|
|
243
277
|
*/
|
|
@@ -257,13 +291,8 @@ async function collectPages(config) {
|
|
|
257
291
|
const pds = root.lastNonNull("fm_atproto_pds")
|
|
258
292
|
const domain = root.lastNonNull("fm_domain")
|
|
259
293
|
const origin = siteOrigin(domain)
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
// folder's lexicon without being anything to publish.
|
|
263
|
-
const stubs = new Set(site.database.source.getStubs().map(row => row.path))
|
|
264
|
-
const targets = site.database.target.getAll()
|
|
265
|
-
const pages = targets.filter(target => typeof target.metadata.atUri === "string" && !stubs.has(target.source)).map(target => {
|
|
266
|
-
const [, , account, collection, rkey] = target.metadata.atUri.split("/")
|
|
294
|
+
const pages = recordPages(site.database).map(({ target, uri }) => {
|
|
295
|
+
const [, , account, collection, rkey] = uri.split("/")
|
|
267
296
|
const pageURL = new URL(target.metadata.prettyURL, origin).href
|
|
268
297
|
const contentLexicon = site.database.setting.getByFolder(target.dir).lastNonNull("fm_atproto_content")
|
|
269
298
|
return {
|
|
@@ -281,7 +310,9 @@ async function collectPages(config) {
|
|
|
281
310
|
const home = site.database.target.get("index.html")
|
|
282
311
|
const name = root.raw("fm_name")?.[0]?.at(-1) ?? (home?.source ? home.metadata.title : undefined)
|
|
283
312
|
const iconPath = settings.icon ?? root.lastNonNull("logo")
|
|
284
|
-
|
|
313
|
+
// imageURL asks an api for the target; the database answers the same.
|
|
314
|
+
const builtIcon = imageURL(iconPath, { target: (targetPath) => site.database.target.get(targetPath) }, imageSizes.at(-1))
|
|
315
|
+
const icon = builtIcon ? await readIcon(path.join(scratch, "output"), builtIcon) : undefined
|
|
285
316
|
const publication = { name, description: settings.description, iconPath, icon }
|
|
286
317
|
return { did, pds, domain, pages, publication }
|
|
287
318
|
} finally {
|
|
@@ -468,4 +499,4 @@ async function publishToAtproto(payload, { config, notify }) {
|
|
|
468
499
|
return summary
|
|
469
500
|
}
|
|
470
501
|
|
|
471
|
-
export { publishToAtproto, planWrites, planPublication, sameRecord, resolvePDS, blobCID, xrpc, passwordFile }
|
|
502
|
+
export { publishToAtproto, recordPages, pageSettingsFolder, planWrites, planPublication, sameRecord, resolvePDS, blobCID, xrpc, passwordFile }
|
|
@@ -1,11 +1,22 @@
|
|
|
1
1
|
import { MARKER } from "../../secretPaths.js"
|
|
2
2
|
import createRkey from "./rkey.js"
|
|
3
|
+
import { validNSID } from "./lexicons.js"
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Which pages become records, and where each record lives. No network,
|
|
6
|
-
* and no lexicon:
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* and no lexicon: a page's lexicon and AT-URI are a function of the page
|
|
8
|
+
* and its settings (`pageRecordURI`), worked out wherever they're needed -
|
|
9
|
+
* the html plugin's write, for standard.site's `<link>`, and
|
|
10
|
+
* atproto-publish. The record itself is made at publish (publish.js),
|
|
11
|
+
* once the lexicon has been fetched (lexicons.js) and the page fitted to
|
|
12
|
+
* it (mapper.js).
|
|
13
|
+
*
|
|
14
|
+
* At write, not in a transform: a transform runs only when its page is
|
|
15
|
+
* read, so adding `atproto_did` or a lexicon to a settings.md changed
|
|
16
|
+
* nothing for pages already built - not even after a restart, since the
|
|
17
|
+
* database persists (tasks/1-proposed/transform-reruns-on-stale.md).
|
|
18
|
+
* Settings read at write are tracked, so a change re-renders the pages
|
|
19
|
+
* it reaches.
|
|
9
20
|
*
|
|
10
21
|
* `site.standard.publication` is the one lexicon named here: standard.site
|
|
11
22
|
* defines a document's `site` as its publication's AT-URI, and vowel
|
|
@@ -75,6 +86,38 @@ function publicationURI(did, origin) {
|
|
|
75
86
|
return `at://${did}/${PUBLICATION}/${createRkey(origin)}`
|
|
76
87
|
}
|
|
77
88
|
|
|
89
|
+
/** A DID is `did:<method>:<id>`; that is all this checks. @param {unknown} value */
|
|
90
|
+
function validDID(value) {
|
|
91
|
+
return typeof value === "string" && /^did:[a-z]+:[A-Za-z0-9._:%-]+$/.test(value)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The page's AT-URI, from the page and its settings - the page-as-folder
|
|
96
|
+
* view a write gets (`blog/post.html` reads `""`, `blog`, `blog/post`).
|
|
97
|
+
* None for a page with `status: hidden`, which is listed nowhere, nor for
|
|
98
|
+
* one that isn't written (`html_file: false`) - except `status: deleted`,
|
|
99
|
+
* which is kept unwritten precisely so publish can delete its record.
|
|
100
|
+
* None without an account, a `domain` (the key is made from the page's
|
|
101
|
+
* url) or a lexicon. A lexicon name that isn't an NSID comes back as a
|
|
102
|
+
* warning; whether the page fits its lexicon is publish's question.
|
|
103
|
+
* @param {{ source?: string, write?: boolean, metadata: Record<string, any> }} target
|
|
104
|
+
* @param {any} settings
|
|
105
|
+
* @returns {{ uri?: string, warning?: string }}
|
|
106
|
+
*/
|
|
107
|
+
function pageRecordURI(target, settings) {
|
|
108
|
+
const status = target.metadata.fm_status
|
|
109
|
+
if (status === "hidden") return {}
|
|
110
|
+
if (target.write === false && status !== "deleted") return {}
|
|
111
|
+
const did = settings.lastNonNull("fm_atproto_did")
|
|
112
|
+
if (!validDID(did)) return {}
|
|
113
|
+
const origin = siteOrigin(settings.lastNonNull("fm_domain"))
|
|
114
|
+
if (!origin) return {}
|
|
115
|
+
const lexicon = lexiconFor(target, settings)
|
|
116
|
+
if (!lexicon) return {}
|
|
117
|
+
if (!validNSID(lexicon)) return { warning: `atproto_lexicon "${lexicon}" is not a lexicon name (an NSID, like app.bsky.feed.post)` }
|
|
118
|
+
return { uri: recordURI(did, lexicon, origin, target.metadata.prettyURL) }
|
|
119
|
+
}
|
|
120
|
+
|
|
78
121
|
/**
|
|
79
122
|
* The page's AT-URI: the account, the lexicon, and a key derived from
|
|
80
123
|
* the page's canonical URL - origin and path - and frozen (rkey.js).
|
|
@@ -87,4 +130,4 @@ function recordURI(did, lexicon, origin, prettyURL) {
|
|
|
87
130
|
return `at://${did}/${lexicon}/${createRkey(new URL(prettyURL, origin).href)}`
|
|
88
131
|
}
|
|
89
132
|
|
|
90
|
-
export { PUBLICATION, lexiconFor, siteOrigin, publicationURI, recordURI }
|
|
133
|
+
export { PUBLICATION, validDID, lexiconFor, siteOrigin, publicationURI, recordURI, pageRecordURI }
|
|
@@ -66,7 +66,10 @@ async function deployToCloudflarePages(payload, { config, notify }) {
|
|
|
66
66
|
proc.stdout.on("data", chunk => notify({ status: "progress", message: chunk.toString() }))
|
|
67
67
|
proc.stderr.on("data", chunk => notify({ status: "progress", message: chunk.toString() }))
|
|
68
68
|
|
|
69
|
-
proc.on("error",
|
|
69
|
+
proc.on("error", error => {
|
|
70
|
+
if (error.code !== "ENOENT") return reject(error)
|
|
71
|
+
reject(new Error("wrangler isn't installed, and deploying to Cloudflare Pages needs it. Install it with `npm install -g wrangler`, then deploy again."))
|
|
72
|
+
})
|
|
70
73
|
proc.on("exit", code => {
|
|
71
74
|
if (code === 0) resolve({ deployed: true, projectName })
|
|
72
75
|
else reject(new Error(`wrangler exited with code ${code}`))
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { cp, readdir, stat } from "node:fs/promises"
|
|
2
|
+
import { realpathSync } from "node:fs"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
import votive from "votive"
|
|
5
|
+
import { systemDirectoryFor } from "../../systemPaths.js"
|
|
6
|
+
|
|
7
|
+
/** @import * as Votive from "votive" */
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `vowel export <folder>`: build the site and copy it to a folder of
|
|
11
|
+
* your choosing - to upload anywhere, archive, or hand to someone. The
|
|
12
|
+
* same command a desktop "Export" button would run
|
|
13
|
+
* (`vowel --command export --payload '{"path": "…"}'`).
|
|
14
|
+
*
|
|
15
|
+
* It copies the output folder whole. If the output ever holds files it
|
|
16
|
+
* shouldn't, that is the output's problem to fix, not the export's.
|
|
17
|
+
*
|
|
18
|
+
* **Where it won't write**, checked before anything is built, on real
|
|
19
|
+
* paths so a symlink can't hide the answer:
|
|
20
|
+
* - inside the project: the next build would read the export as content,
|
|
21
|
+
* and each export would nest inside the next
|
|
22
|
+
* - a folder that contains the project, or is it
|
|
23
|
+
* - vowel's own system folder (the output, the database, the cache)
|
|
24
|
+
* - a folder that isn't empty: nothing an export writes should mix with,
|
|
25
|
+
* or silently replace, what's already there
|
|
26
|
+
*
|
|
27
|
+
* A destination that doesn't exist yet is created.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* `child` is `parent` or somewhere inside it.
|
|
32
|
+
* @param {string} child
|
|
33
|
+
* @param {string} parent
|
|
34
|
+
*/
|
|
35
|
+
function within(child, parent) {
|
|
36
|
+
const relative = path.relative(parent, child)
|
|
37
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A path made real as far as it exists: the deepest existing ancestor
|
|
42
|
+
* through realpath (symlinks resolved, and on a case-insensitive disk
|
|
43
|
+
* its true case), then the segments that don't exist yet.
|
|
44
|
+
* @param {string} absolute
|
|
45
|
+
* @returns {string}
|
|
46
|
+
*/
|
|
47
|
+
function realAsFarAsItExists(absolute) {
|
|
48
|
+
try {
|
|
49
|
+
return realpathSync.native(absolute)
|
|
50
|
+
} catch {
|
|
51
|
+
const parent = path.dirname(absolute)
|
|
52
|
+
if (parent === absolute) return absolute
|
|
53
|
+
return path.join(realAsFarAsItExists(parent), path.basename(absolute))
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Where an export may go, or why it may not, as a sentence. Checks the
|
|
59
|
+
* places only; whether the folder is empty is `checkEmpty`'s.
|
|
60
|
+
* @param {string} destination - as given; relative to the working directory
|
|
61
|
+
* @param {{ sourceFolder: string, targetFolder: string }} config
|
|
62
|
+
* @returns {{ path: string } | { refused: string }}
|
|
63
|
+
*/
|
|
64
|
+
function checkDestination(destination, config) {
|
|
65
|
+
if (typeof destination !== "string" || !destination.trim()) {
|
|
66
|
+
return { refused: "say where to export to, e.g. `vowel export ../my-site`" }
|
|
67
|
+
}
|
|
68
|
+
const real = realAsFarAsItExists(path.resolve(destination))
|
|
69
|
+
const source = realAsFarAsItExists(config.sourceFolder)
|
|
70
|
+
const system = realAsFarAsItExists(systemDirectoryFor(config.sourceFolder))
|
|
71
|
+
const output = realAsFarAsItExists(config.targetFolder)
|
|
72
|
+
|
|
73
|
+
if (within(real, source)) {
|
|
74
|
+
return { refused: `${destination} is inside the project (${source}); the next build would read the export as part of the site. Export somewhere outside it.` }
|
|
75
|
+
}
|
|
76
|
+
if (within(source, real)) {
|
|
77
|
+
return { refused: `${destination} contains the project (${source}). Export to a folder of its own.` }
|
|
78
|
+
}
|
|
79
|
+
if (within(real, system) || within(real, output) || within(output, real)) {
|
|
80
|
+
return { refused: `${destination} is where vowel keeps its own build (${system}). Export somewhere else.` }
|
|
81
|
+
}
|
|
82
|
+
return { path: real }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @param {string} folder - real, absolute
|
|
87
|
+
* @param {string} destination - as given, for the message
|
|
88
|
+
* @returns {Promise<string | undefined>} why it can't be used, if it can't
|
|
89
|
+
*/
|
|
90
|
+
async function checkEmpty(folder, destination) {
|
|
91
|
+
const info = await stat(folder).catch(() => undefined)
|
|
92
|
+
if (!info) return undefined
|
|
93
|
+
if (!info.isDirectory()) return `${destination} is a file, not a folder.`
|
|
94
|
+
const entries = await readdir(folder)
|
|
95
|
+
if (entries.length) return `${destination} isn't empty. Export to a new or empty folder.`
|
|
96
|
+
return undefined
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @param {{ path?: string } | undefined} payload
|
|
101
|
+
* @param {{ config: Votive.VotiveConfig, notify: (message: object) => void }} context
|
|
102
|
+
*/
|
|
103
|
+
async function exportSite(payload, { config, notify }) {
|
|
104
|
+
const checked = checkDestination(payload?.path, config)
|
|
105
|
+
if ("refused" in checked) throw new Error(`export: ${checked.refused}`)
|
|
106
|
+
const notEmpty = await checkEmpty(checked.path, payload.path)
|
|
107
|
+
if (notEmpty) throw new Error(`export: ${notEmpty}`)
|
|
108
|
+
|
|
109
|
+
notify({ status: "progress", message: "Building..." })
|
|
110
|
+
// As deploy does: everything finished, deferred work (image
|
|
111
|
+
// derivatives, link previews) included, and the database closed.
|
|
112
|
+
const site = await votive({ ...config, verbose: false })
|
|
113
|
+
await (await site.build()).deferred
|
|
114
|
+
await site.close()
|
|
115
|
+
|
|
116
|
+
notify({ status: "progress", message: `Copying to ${checked.path}...` })
|
|
117
|
+
await cp(config.targetFolder, checked.path, { recursive: true })
|
|
118
|
+
const files = await readdir(checked.path, { recursive: true, withFileTypes: true })
|
|
119
|
+
const count = files.filter(entry => entry.isFile()).length
|
|
120
|
+
notify({ status: "done", message: `Exported ${count} files to ${checked.path}` })
|
|
121
|
+
return { exported: checked.path, files: count }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** @type {Votive.VotivePlugin} */
|
|
125
|
+
const vowelExportPlugin = {
|
|
126
|
+
name: "vowel-export",
|
|
127
|
+
commands: { export: exportSite }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export default vowelExportPlugin
|
|
131
|
+
export { exportSite, checkDestination, within }
|