vowel 0.7.1-beta.0 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +41 -239
  3. package/bin.js +91 -31
  4. package/cli.js +32 -0
  5. package/config.js +12 -0
  6. package/extractDate.js +1 -1
  7. package/package.json +8 -34
  8. package/pathDate.js +69 -0
  9. package/plugins/assets/index.js +1 -1
  10. package/plugins/atproto/body.js +3 -1
  11. package/plugins/atproto/index.js +5 -45
  12. package/plugins/atproto/publish.js +42 -11
  13. package/plugins/atproto/record.js +47 -4
  14. package/plugins/deploy-cloudflare/index.js +4 -1
  15. package/plugins/export/index.js +131 -0
  16. package/plugins/html/bundle/index.js +101 -44
  17. package/plugins/html/editor/Editor.svelte +9 -5
  18. package/plugins/html/editor/create-session.js +3 -1
  19. package/plugins/html/editor/directives.js +12 -0
  20. package/plugins/html/editor/frontmatter.js +77 -12
  21. package/plugins/html/editor/ingest.js +29 -31
  22. package/plugins/html/editor/main.js +5 -1
  23. package/plugins/html/editor/serialize.js +8 -4
  24. package/plugins/html/editor/socket.js +34 -4
  25. package/plugins/html/image.js +88 -27
  26. package/plugins/html/index.js +237 -109
  27. package/plugins/html/partials.js +41 -17
  28. package/plugins/html/socialLinks.js +13 -11
  29. package/plugins/html/writeRules.js +234 -42
  30. package/plugins/images/favicon.png +0 -0
  31. package/plugins/images/index.js +69 -8
  32. package/plugins/markdown/colorScheme.js +70 -14
  33. package/plugins/markdown/frontmatter.js +18 -8
  34. package/plugins/markdown/index.js +94 -25
  35. package/plugins/markdown/metadata.js +132 -32
  36. package/plugins/markdown/typography.js +11 -11
  37. package/plugins/styles/index.js +4 -4
  38. package/plugins/xml/index.js +17 -10
  39. package/stylesheets/DefaultStyles.css +163 -108
  40. package/stylesheets/TypographyStyles.css +148 -81
  41. package/utils.js +74 -21
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "vowel",
3
3
  "type": "module",
4
- "version": "0.7.1-beta.0",
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
- "autoprefixer": "^10.4.27",
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
- "url-metadata": "^5.4.1",
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 }
@@ -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
@@ -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
- const src = node.properties?.dataOriginal ?? img?.properties?.src
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") {
@@ -1,6 +1,5 @@
1
1
  import path from "node:path"
2
- import { lexiconFor, publicationURI, recordURI, siteOrigin } from "./record.js"
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:` gets an AT-URI, computed
25
- * by `transformFile` with no network (record.js). Any published lexicon
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, recordProcessor],
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. Built into a scratch
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
- // Only the author's own pages: a generated one - a tag page, a
261
- // folder index, the default home or 404 - is a stub, and inherits a
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
- const icon = iconPath ? await readIcon(path.join(scratch, "output"), iconPath) : undefined
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: the build decides a page's lexicon and its AT-URI; the
7
- * record itself is made at publish (publish.js), once the lexicon has
8
- * been fetched (lexicons.js) and the page fitted to it (mapper.js).
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", reject) // e.g. wrangler isn't installed
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 }