vowel 0.6.4 → 0.7.1-beta.0

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 (170) hide show
  1. package/README.md +1 -1
  2. package/bin.js +47 -27
  3. package/cli.js +15 -0
  4. package/config.js +95 -26
  5. package/extractDate.js +70 -6
  6. package/index.js +19 -5
  7. package/package.json +38 -7
  8. package/plugins/assets/index.js +65 -0
  9. package/plugins/atproto/body.js +413 -0
  10. package/plugins/atproto/index.js +138 -0
  11. package/plugins/atproto/lexicons.js +179 -0
  12. package/plugins/atproto/mapper.js +399 -0
  13. package/plugins/atproto/password.js +71 -0
  14. package/plugins/atproto/publish.js +471 -0
  15. package/plugins/atproto/record.js +90 -0
  16. package/plugins/atproto/rkey.js +54 -0
  17. package/plugins/deploy-cloudflare/index.js +30 -16
  18. package/plugins/fonts/index.js +18 -21
  19. package/plugins/html/assets/bundle-BeK1Hptv.css +21 -0
  20. package/plugins/html/bundle/assets/index-BeK1Hptv.css +21 -0
  21. package/plugins/html/bundle/index.js +21145 -0
  22. package/plugins/html/editor/EditButton.svelte +152 -0
  23. package/plugins/html/editor/Editor.svelte +51 -0
  24. package/plugins/html/editor/Overlays.svelte +6 -0
  25. package/plugins/html/editor/SettingsPanel.svelte +397 -0
  26. package/plugins/html/editor/Toolbar.svelte +644 -0
  27. package/plugins/html/editor/create-session.js +250 -0
  28. package/plugins/html/editor/directives.js +128 -0
  29. package/plugins/html/editor/frontmatter.js +97 -0
  30. package/plugins/html/editor/ingest.js +339 -0
  31. package/plugins/html/editor/main.js +94 -0
  32. package/plugins/html/editor/nodes/Alert.svelte +21 -0
  33. package/plugins/html/editor/nodes/Blockquote.svelte +9 -0
  34. package/plugins/html/editor/nodes/CodeBlock.svelte +19 -0
  35. package/plugins/html/editor/nodes/Embed.svelte +19 -0
  36. package/plugins/html/editor/nodes/Emphasis.svelte +11 -0
  37. package/plugins/html/editor/nodes/Heading.svelte +15 -0
  38. package/plugins/html/editor/nodes/Highlight.svelte +11 -0
  39. package/plugins/html/editor/nodes/Image.svelte +18 -0
  40. package/plugins/html/editor/nodes/InlineCode.svelte +11 -0
  41. package/plugins/html/editor/nodes/Link.svelte +17 -0
  42. package/plugins/html/editor/nodes/List.svelte +15 -0
  43. package/plugins/html/editor/nodes/ListItem.svelte +9 -0
  44. package/plugins/html/editor/nodes/Page.svelte +21 -0
  45. package/plugins/html/editor/nodes/Paragraph.svelte +9 -0
  46. package/plugins/html/editor/nodes/Strikethrough.svelte +11 -0
  47. package/plugins/html/editor/nodes/Strong.svelte +11 -0
  48. package/plugins/html/editor/nodes/ThematicBreak.svelte +9 -0
  49. package/plugins/html/editor/nodes/index.js +18 -0
  50. package/plugins/html/editor/rollup.config.js +35 -0
  51. package/plugins/html/editor/serialize.js +157 -0
  52. package/plugins/html/editor/settings-file.js +83 -0
  53. package/plugins/html/editor/socket.js +202 -0
  54. package/plugins/html/editor/source.js +43 -0
  55. package/plugins/html/image.js +31 -5
  56. package/plugins/html/index.js +436 -458
  57. package/plugins/html/partials.js +299 -0
  58. package/plugins/html/socialLinks.js +67 -0
  59. package/plugins/html/writeRules.js +379 -0
  60. package/plugins/icons/index.js +2 -6
  61. package/plugins/images/index.js +78 -55
  62. package/plugins/markdown/brandColors.js +10 -0
  63. package/plugins/markdown/colorScheme.js +64 -0
  64. package/plugins/markdown/emoji.js +30 -0
  65. package/plugins/markdown/frontmatter.js +269 -0
  66. package/plugins/markdown/index.js +295 -335
  67. package/plugins/markdown/links.js +97 -0
  68. package/plugins/markdown/metadata.js +229 -68
  69. package/plugins/markdown/readRules.js +143 -0
  70. package/plugins/markdown/typography.js +109 -0
  71. package/plugins/markdown/visitor.js +143 -0
  72. package/plugins/publish/index.js +34 -0
  73. package/plugins/robots/agents.js +79 -0
  74. package/plugins/robots/index.js +30 -10
  75. package/plugins/robots/robots.js +48 -0
  76. package/plugins/secret-pages/index.js +75 -0
  77. package/plugins/styles/index.js +111 -18
  78. package/plugins/styles/theme.js +116 -0
  79. package/plugins/urls/index.js +49 -62
  80. package/plugins/urls/urlMetadata.js +246 -0
  81. package/plugins/vectors/index.js +5 -9
  82. package/plugins/xml/entryContent.js +114 -0
  83. package/plugins/xml/index.js +107 -44
  84. package/secretPaths.js +170 -0
  85. package/stylesheets/DefaultStyles.css +399 -435
  86. package/stylesheets/ResetStyles.css +4 -39
  87. package/stylesheets/SyntaxHighlightingStyles.css +36 -4
  88. package/stylesheets/TypographyStyles.css +398 -128
  89. package/systemPaths.js +48 -0
  90. package/utils.js +56 -5
  91. package/.github/ISSUE_TEMPLATE/bug_report.md +0 -73
  92. package/.github/ISSUE_TEMPLATE/config.yml +0 -7
  93. package/docs-source/.cache.json +0 -9
  94. package/docs-source/.obsidian/app.json +0 -3
  95. package/docs-source/.obsidian/appearance.json +0 -3
  96. package/docs-source/.obsidian/core-plugins-migration.json +0 -30
  97. package/docs-source/.obsidian/core-plugins.json +0 -20
  98. package/docs-source/.obsidian/workspace.json +0 -168
  99. package/docs-source/.stackblitzrc +0 -3
  100. package/docs-source/.votive.db +0 -0
  101. package/docs-source/about.md +0 -3
  102. package/docs-source/blog/home.md +0 -5
  103. package/docs-source/blog/url-ui.md +0 -21
  104. package/docs-source/blog.md +0 -5
  105. package/docs-source/docs/.votive.db +0 -0
  106. package/docs-source/docs/deploy.md +0 -67
  107. package/docs-source/docs/file-structure.md +0 -31
  108. package/docs-source/docs/folder-settings.md +0 -23
  109. package/docs-source/docs/home.md +0 -55
  110. package/docs-source/docs/images.md +0 -10
  111. package/docs-source/docs/items.md +0 -13
  112. package/docs-source/docs/pages.md +0 -141
  113. package/docs-source/docs/settings.md +0 -4
  114. package/docs-source/docs/styling.md +0 -34
  115. package/docs-source/docs/taxonomies.md +0 -37
  116. package/docs-source/features/cards.md +0 -7
  117. package/docs-source/features/editing.md +0 -7
  118. package/docs-source/features/emoji.md +0 -7
  119. package/docs-source/features/frontmatter.md +0 -7
  120. package/docs-source/features/lists.md +0 -7
  121. package/docs-source/features/navigation.md +0 -7
  122. package/docs-source/features/rich-previews.md +0 -7
  123. package/docs-source/features/robots.md +0 -7
  124. package/docs-source/features/rss.md +0 -7
  125. package/docs-source/features/sitemap.md +0 -7
  126. package/docs-source/features/speed.md +0 -7
  127. package/docs-source/features/static.md +0 -7
  128. package/docs-source/features/taxonomies.md +0 -7
  129. package/docs-source/home.md +0 -42
  130. package/docs-source/output/about.html +0 -1
  131. package/docs-source/output/blog/url-ui.html +0 -8
  132. package/docs-source/output/blog.html +0 -1
  133. package/docs-source/output/default.css +0 -1
  134. package/docs-source/output/docs/deploy.html +0 -34
  135. package/docs-source/output/docs/file-structure.html +0 -24
  136. package/docs-source/output/docs/folder-settings.html +0 -20
  137. package/docs-source/output/docs/images.html +0 -2
  138. package/docs-source/output/docs/items.html +0 -6
  139. package/docs-source/output/docs/pages.html +0 -101
  140. package/docs-source/output/docs/styling.html +0 -18
  141. package/docs-source/output/docs/taxonomies.html +0 -20
  142. package/docs-source/output/docs.html +0 -28
  143. package/docs-source/output/features/cards.html +0 -1
  144. package/docs-source/output/features/editing.html +0 -1
  145. package/docs-source/output/features/emoji.html +0 -1
  146. package/docs-source/output/features/frontmatter.html +0 -1
  147. package/docs-source/output/features/lists.html +0 -1
  148. package/docs-source/output/features/navigation.html +0 -1
  149. package/docs-source/output/features/rich-previews.html +0 -1
  150. package/docs-source/output/features/robots.html +0 -1
  151. package/docs-source/output/features/rss.html +0 -1
  152. package/docs-source/output/features/sitemap.html +0 -1
  153. package/docs-source/output/features/speed.html +0 -1
  154. package/docs-source/output/features/static.html +0 -1
  155. package/docs-source/output/features/taxonomies.html +0 -1
  156. package/docs-source/output/features.html +0 -1
  157. package/docs-source/output/feed.xml +0 -1
  158. package/docs-source/output/index.html +0 -21
  159. package/docs-source/output/reset.css +0 -1
  160. package/docs-source/output/roadmap.html +0 -87
  161. package/docs-source/output/robots.txt +0 -14
  162. package/docs-source/output/sitemap.xml +0 -16
  163. package/docs-source/output/typography.css +0 -1
  164. package/docs-source/roadmap.md +0 -98
  165. package/docs-source/settings.md +0 -13
  166. package/getMetadata.js +0 -41
  167. package/jsconfig.json +0 -10
  168. package/plugins/markdown/robots.js +0 -18
  169. package/regex.js +0 -36
  170. package/stylesheets/brand-colors.css +0 -119
@@ -0,0 +1,471 @@
1
+ import { createHash } from "node:crypto"
2
+ import { mkdtemp, rm, readFile } from "node:fs/promises"
3
+ import { tmpdir } from "node:os"
4
+ import path from "node:path"
5
+ import sharp from "sharp"
6
+ import votive from "votive"
7
+ import { systemDirectoryFor } from "../../systemPaths.js"
8
+ import { EXPECTED } from "../markdown/frontmatter.js"
9
+ import { contentSection } from "../xml/entryContent.js"
10
+ import { PUBLICATION, siteOrigin, publicationURI } from "./record.js"
11
+ import { createLexiconResolver, resolvePDS } from "./lexicons.js"
12
+ import { mapRecord } from "./mapper.js"
13
+ import { markdownBody, richTextBody } from "./body.js"
14
+
15
+ /**
16
+ * `vowel --command atproto-publish`: make the account's records match the
17
+ * site's pages.
18
+ *
19
+ * The command builds the site, fetches every lexicon a page names
20
+ * (lexicons.js; cached in memory), fits each page to its lexicon
21
+ * (mapper.js), lists what the PDS holds in each collection, and sends the
22
+ * difference through `applyWrites`: a record the PDS doesn't have is
23
+ * created, one that differs is updated. Nothing is remembered between
24
+ * runs, so clearing a cache cannot cause a resend, a revert publishes,
25
+ * and a coworker sees the same state.
26
+ *
27
+ * **Vowel owns only the records it creates** - the ones at keys it
28
+ * derives from the site's page URLs (rkey.js). It never deletes one on
29
+ * its own: a record whose page is gone can't be told from anyone else's,
30
+ * because its key can no longer be derived. A page with
31
+ * `status: deleted` is how a record is deleted - the page stays in the
32
+ * project, unwritten, so its key can still be derived.
33
+ *
34
+ * A page that doesn't fit its lexicon - a required property with no
35
+ * value, a lexicon that isn't published - is left out with a warning.
36
+ *
37
+ * The build uses a throwaway database, so every page is read and every
38
+ * transform runs against current settings. A transform only re-runs when
39
+ * its source is re-read (tasks/1-proposed/transform-reruns-on-stale.md),
40
+ * so a warm database could hold records built from old settings.
41
+ *
42
+ * **The publication goes first**, when any page publishes to
43
+ * `site.standard.*`: standard.site defines a document's `site` as its
44
+ * publication. One `site.standard.publication` per site, at the key
45
+ * record.js derives from the origin: `url` from `domain:`, `name` from
46
+ * the root `name:` (else the homepage's title), and `description` and
47
+ * `icon` from the root's `atproto_publication:`. The icon defaults to
48
+ * `logo:`. It is rendered to a 512px square PNG and its blob reference -
49
+ * CID, type, size - computed here, so an unchanged icon compares equal
50
+ * and is never uploaded again; a changed one is uploaded just before the
51
+ * write that names it. The publication is created and updated, never
52
+ * deleted.
53
+ *
54
+ * The app password comes from `ATPROTO_APP_PASSWORD`, or from a file
55
+ * named `atproto-app-password` in the project's system directory, never
56
+ * from the project. Payload: `{"dryRun": true}` reports what would change
57
+ * and sends nothing, and does not need the password. `{"optional": true}`
58
+ * makes a site with no `atproto_did` a skip rather than an error, for
59
+ * the `publish` command, which runs this after every deploy.
60
+ *
61
+ * `config.atprotoLexicons` replaces the lexicon resolver - for tests,
62
+ * which can't publish a DNS record.
63
+ */
64
+
65
+ const PASSWORD_FILE = "atproto-app-password"
66
+ const PAGE_SIZE = 100
67
+ const BATCH_SIZE = 100
68
+ const TIMEOUT = 30_000
69
+ const ICON_SIZE = 512
70
+ // The lexicon's maxSize for `icon`.
71
+ const ICON_MAX_BYTES = 1_000_000
72
+ const BASE32 = "abcdefghijklmnopqrstuvwxyz234567"
73
+
74
+ /**
75
+ * RFC 4648 base32, lowercase, unpadded: the multibase `b` alphabet.
76
+ * @param {Uint8Array} bytes
77
+ */
78
+ function base32(bytes) {
79
+ const bits = [...bytes].map(byte => byte.toString(2).padStart(8, "0")).join("")
80
+ const padded = bits.padEnd(Math.ceil(bits.length / 5) * 5, "0")
81
+ return padded.match(/.{5}/g).map(chunk => BASE32[parseInt(chunk, 2)]).join("")
82
+ }
83
+
84
+ /**
85
+ * The CID a PDS gives a blob: CIDv1, raw codec (0x55), sha2-256.
86
+ * @param {Uint8Array} bytes
87
+ */
88
+ function blobCID(bytes) {
89
+ const digest = createHash("sha256").update(bytes).digest()
90
+ return "b" + base32(Buffer.concat([Buffer.from([0x01, 0x55, 0x12, 0x20]), digest]))
91
+ }
92
+
93
+ /**
94
+ * The publication's icon: a 512px square PNG, the image contained in it
95
+ * on transparency, and the blob reference a PDS will give it. An SVG is
96
+ * rasterised at a density high enough to stay sharp at that size.
97
+ * @param {Buffer} source
98
+ * @param {string} iconPath - for the error message
99
+ */
100
+ async function iconBlob(source, iconPath) {
101
+ const bytes = await sharp(source, { density: 300 })
102
+ .resize(ICON_SIZE, ICON_SIZE, { fit: "contain", background: { r: 0, g: 0, b: 0, alpha: 0 } })
103
+ .png()
104
+ .toBuffer()
105
+ if (bytes.length > ICON_MAX_BYTES) {
106
+ throw new Error(`atproto: the publication icon ${iconPath} is ${bytes.length} bytes as a ${ICON_SIZE}px PNG; the limit is ${ICON_MAX_BYTES}`)
107
+ }
108
+ const ref = { $type: "blob", ref: { $link: blobCID(bytes) }, mimeType: "image/png", size: bytes.length }
109
+ return { bytes, ref }
110
+ }
111
+
112
+ /**
113
+ * A value with its object keys sorted, so two records compare by content
114
+ * and not by key order.
115
+ * @param {unknown} value
116
+ * @returns {unknown}
117
+ */
118
+ function canonical(value) {
119
+ if (Array.isArray(value)) return value.map(canonical)
120
+ if (!value || typeof value !== "object") return value
121
+ const keys = Object.keys(value).sort()
122
+ return Object.fromEntries(keys.map(key => [key, canonical(value[key])]))
123
+ }
124
+
125
+ /** @param {unknown} a @param {unknown} b */
126
+ function sameRecord(a, b) {
127
+ return JSON.stringify(canonical(a)) === JSON.stringify(canonical(b))
128
+ }
129
+
130
+ /**
131
+ * One XRPC call. An error response throws with the PDS's own error name
132
+ * and message, which is the only useful part of a failed write.
133
+ * @param {string} pds
134
+ * @param {string} nsid
135
+ * `bytes` is a raw body (uploadBlob) of type `mimeType`; `body` is JSON.
136
+ * @param {{ query?: Record<string, string>, body?: unknown, bytes?: Uint8Array, mimeType?: string, token?: string }} [options]
137
+ */
138
+ async function xrpc(pds, nsid, { query, body, bytes, mimeType, token } = {}) {
139
+ const url = new URL(`/xrpc/${nsid}`, pds)
140
+ const params = new URLSearchParams(query ?? {})
141
+ url.search = params.toString()
142
+ const payload = bytes ?? (body === undefined ? undefined : JSON.stringify(body))
143
+ const headers = {
144
+ ...(bytes ? { "content-type": mimeType } : body === undefined ? {} : { "content-type": "application/json" }),
145
+ ...(token ? { authorization: `Bearer ${token}` } : {})
146
+ }
147
+ const response = await fetch(url, {
148
+ method: payload === undefined ? "GET" : "POST",
149
+ headers,
150
+ body: payload,
151
+ signal: AbortSignal.timeout(TIMEOUT)
152
+ })
153
+ const text = await response.text()
154
+ const json = text ? JSON.parse(text) : {}
155
+ if (response.ok) return json
156
+ throw new Error(`${nsid}: ${response.status} ${json.error ?? ""} ${json.message ?? ""}`.trim())
157
+ }
158
+
159
+ /**
160
+ * Where the app password is kept: the project's system directory, never
161
+ * the project, so it is neither committed nor deployed.
162
+ * @param {string} sourceFolder
163
+ */
164
+ function passwordFile(sourceFolder) {
165
+ return path.join(systemDirectoryFor(sourceFolder), PASSWORD_FILE)
166
+ }
167
+
168
+ /** @param {string} sourceFolder */
169
+ async function appPassword(sourceFolder) {
170
+ if (process.env.ATPROTO_APP_PASSWORD) return process.env.ATPROTO_APP_PASSWORD
171
+ const file = passwordFile(sourceFolder)
172
+ const text = await readFile(file, "utf-8").catch(() => "")
173
+ if (text.trim()) return text.trim()
174
+ throw new Error(`atproto: no app password. Set ATPROTO_APP_PASSWORD, or put one in ${file}`)
175
+ }
176
+
177
+ /**
178
+ * Every record in `collection`, a page at a time.
179
+ * @param {string} pds
180
+ * @param {string} did
181
+ * @param {string} collection
182
+ * @param {string} [cursor]
183
+ * @returns {Promise<{ uri: string, value: any }[]>}
184
+ */
185
+ async function listAll(pds, did, collection, cursor) {
186
+ const query = { repo: did, collection, limit: String(PAGE_SIZE), ...(cursor ? { cursor } : {}) }
187
+ const page = await xrpc(pds, "com.atproto.repo.listRecords", { query })
188
+ const records = page.records ?? []
189
+ if (!page.cursor || !records.length) return records
190
+ const rest = await listAll(pds, did, collection, page.cursor)
191
+ return [...records, ...rest]
192
+ }
193
+
194
+ /** The last segment of an AT-URI. @param {string} uri */
195
+ const rkeyOf = (uri) => uri.slice(uri.lastIndexOf("/") + 1)
196
+
197
+ /**
198
+ * The default `$type` for a body that goes into an open union - a
199
+ * `content` any lexicon may fill, like standard.site's. MarkPub's
200
+ * markdown lexicon, fetched and interpreted like any other; a folder's
201
+ * `atproto_content:` names another.
202
+ */
203
+ const CONTENT_LEXICON = "at.markpub.markdown"
204
+
205
+ /**
206
+ * The page as mapper.js reads it: its title, description, date, image
207
+ * and tags, the frontmatter keys that are the author's own (every key
208
+ * vowel reads for itself is left out), and its body - both forms, made
209
+ * only when asked for, from the rendered page. Every value is read now:
210
+ * a target's properties are lazy database reads, and the build's
211
+ * database is closed before anything is mapped.
212
+ * @param {any} target
213
+ * @param {string} pageURL
214
+ */
215
+ function pageFor(target, pageURL) {
216
+ const { metadata } = target
217
+ const declared = Array.isArray(metadata.frontmatter_keys) ? metadata.frontmatter_keys : []
218
+ const frontmatter = Object.fromEntries(declared
219
+ .filter(key => !(key in EXPECTED) && `fm_${key}` in metadata)
220
+ .map(key => [key, metadata[`fm_${key}`]]))
221
+ const html = target.data
222
+ const section = () => contentSection(html)
223
+ return {
224
+ title: metadata.title,
225
+ description: metadata.description,
226
+ date: metadata.date,
227
+ image: metadata.image,
228
+ tags: metadata.tags,
229
+ frontmatter,
230
+ markdown: () => markdownBody(section(), pageURL),
231
+ richText: (features) => richTextBody(section(), pageURL, features)
232
+ }
233
+ }
234
+
235
+ /**
236
+ * What the site should hold: every page under a lexicon - its AT-URI,
237
+ * its status, and what mapper.js reads - the site's own settings, and
238
+ * the publication's name, description and icon. The icon is read from
239
+ * 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
241
+ * folder that is removed afterwards.
242
+ * @param {import("votive").VotiveConfig} config
243
+ */
244
+ async function collectPages(config) {
245
+ const scratch = await mkdtemp(path.join(tmpdir(), "vowel-atproto-"))
246
+ try {
247
+ const site = await votive({
248
+ ...config,
249
+ verbose: false,
250
+ databasePath: path.join(scratch, ".votive.db"),
251
+ targetFolder: path.join(scratch, "output")
252
+ })
253
+ try {
254
+ await (await site.build({ defer: false })).deferred
255
+ const root = site.database.setting.getByFolder("")
256
+ const did = root.lastNonNull("fm_atproto_did")
257
+ const pds = root.lastNonNull("fm_atproto_pds")
258
+ const domain = root.lastNonNull("fm_domain")
259
+ 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("/")
267
+ const pageURL = new URL(target.metadata.prettyURL, origin).href
268
+ const contentLexicon = site.database.setting.getByFolder(target.dir).lastNonNull("fm_atproto_content")
269
+ return {
270
+ path: target.path,
271
+ account,
272
+ collection,
273
+ rkey,
274
+ deleted: target.metadata.fm_status === "deleted",
275
+ context: { origin, pageURL, path: target.metadata.prettyURL, contentLexicon: typeof contentLexicon === "string" && contentLexicon ? contentLexicon : CONTENT_LEXICON },
276
+ page: target.metadata.fm_status === "deleted" ? undefined : pageFor(target, pageURL)
277
+ }
278
+ })
279
+
280
+ const settings = root.lastNonNull("fm_atproto_publication") ?? {}
281
+ const home = site.database.target.get("index.html")
282
+ const name = root.raw("fm_name")?.[0]?.at(-1) ?? (home?.source ? home.metadata.title : undefined)
283
+ const iconPath = settings.icon ?? root.lastNonNull("logo")
284
+ const icon = iconPath ? await readIcon(path.join(scratch, "output"), iconPath) : undefined
285
+ const publication = { name, description: settings.description, iconPath, icon }
286
+ return { did, pds, domain, pages, publication }
287
+ } finally {
288
+ await site.close()
289
+ }
290
+ } finally {
291
+ await rm(scratch, { recursive: true, force: true })
292
+ }
293
+ }
294
+
295
+ /**
296
+ * The icon's bytes from the build output. A URL is not fetched: the icon
297
+ * has to be a file in the project.
298
+ * @param {string} output
299
+ * @param {string} iconPath - a URL path from the root, e.g. "/logo.svg"
300
+ */
301
+ async function readIcon(output, iconPath) {
302
+ if (/^[a-z]+:/i.test(iconPath)) {
303
+ throw new Error(`atproto: the publication icon must be a file in the project, not ${iconPath}`)
304
+ }
305
+ const file = path.join(output, ...iconPath.split("/").filter(Boolean))
306
+ return readFile(file).catch(() => {
307
+ throw new Error(`atproto: the publication icon ${iconPath} is not in the built site`)
308
+ })
309
+ }
310
+
311
+ /**
312
+ * The publication's write: create, update or unchanged, against what the
313
+ * account holds at its key.
314
+ * @param {{ uri: string, value: any }[]} remote - the account's publications
315
+ * @param {string} rkey
316
+ * @param {Record<string, unknown>} value
317
+ */
318
+ function planPublication(remote, rkey, value) {
319
+ const existing = remote.find(record => rkeyOf(record.uri) === rkey)
320
+ if (!existing) return { action: "create", rkey, value }
321
+ if (sameRecord(existing.value, value)) return { action: "unchanged", rkey }
322
+ return { action: "update", rkey, value }
323
+ }
324
+
325
+ /**
326
+ * The writes that make the account match the site. `local` is the
327
+ * records the pages make, `deleted` the keys of pages whose status is
328
+ * `deleted`; `remote` is what the PDS holds in those collections, keyed
329
+ * `<collection>/<rkey>`. A record the PDS doesn't hold is created, one
330
+ * that differs is updated, and a deleted page's record is deleted if it
331
+ * is there. Nothing else in a collection is touched.
332
+ * @param {{ collection: string, rkey: string, value: unknown }[]} local
333
+ * @param {{ collection: string, rkey: string }[]} deleted
334
+ * @param {Map<string, unknown>} remote
335
+ */
336
+ function planWrites(local, deleted, remote) {
337
+ const compared = local.map(({ collection, rkey, value }) => {
338
+ const existing = remote.get(`${collection}/${rkey}`)
339
+ if (existing === undefined) return { action: "create", collection, rkey, value }
340
+ if (sameRecord(existing, value)) return { action: "unchanged", collection, rkey }
341
+ return { action: "update", collection, rkey, value }
342
+ })
343
+ const deletions = deleted
344
+ .filter(({ collection, rkey }) => remote.has(`${collection}/${rkey}`))
345
+ .map(({ collection, rkey }) => ({ action: "delete", collection, rkey }))
346
+ return [...compared, ...deletions]
347
+ }
348
+
349
+ /** @param {{ action: string, collection: string, rkey: string, value?: unknown }} write */
350
+ function applyWrite({ action, collection, rkey, value }) {
351
+ const base = { $type: `com.atproto.repo.applyWrites#${action}`, collection, rkey }
352
+ return action === "delete" ? base : { ...base, value }
353
+ }
354
+
355
+ const defaultResolver = createLexiconResolver()
356
+
357
+ /**
358
+ * @param {{ dryRun?: boolean, optional?: boolean } | undefined} payload
359
+ * @param {{ config: import("votive").VotiveConfig & { atprotoLexicons?: ReturnType<typeof createLexiconResolver> }, notify: (message: object) => void }} context
360
+ */
361
+ async function publishToAtproto(payload, { config, notify }) {
362
+ const dryRun = Boolean(payload?.dryRun)
363
+ // A command's config comes straight from the caller and may carry no
364
+ // logger; votive() adds one only to the config its hooks receive.
365
+ const log = config.log ?? ((level, message) => console.error(message))
366
+
367
+ notify({ status: "progress", message: "Building..." })
368
+ const { did, pds: configuredPDS, domain, pages, publication } = await collectPages(config)
369
+
370
+ if ((typeof did !== "string" || !did) && payload?.optional) {
371
+ notify({ status: "done", message: "No atproto_did in settings.md; nothing to publish to AT Protocol" })
372
+ return { skipped: true }
373
+ }
374
+ if (typeof did !== "string" || !did) throw new Error("atproto: set atproto_did in the root settings.md")
375
+ const origin = siteOrigin(domain)
376
+ if (!origin) throw new Error("atproto: set domain in the root settings.md; every record carries the site's url")
377
+
378
+ const others = [...new Set(pages.map(page => page.account))].filter(account => account !== did)
379
+ if (others.length) throw new Error(`atproto: pages name ${others.join(", ")}; one site publishes to one account (${did})`)
380
+
381
+ // The publication, only for a site that publishes to standard.site.
382
+ const live = pages.filter(page => !page.deleted)
383
+ const standardSite = live.some(page => page.collection.startsWith("site.standard."))
384
+ if (standardSite && (typeof publication.name !== "string" || !publication.name)) {
385
+ throw new Error("atproto: set name in the root settings.md; the publication needs one")
386
+ }
387
+ const publicationURL = standardSite ? publicationURI(did, origin) : undefined
388
+
389
+ notify({ status: "progress", message: "Fetching lexicons..." })
390
+ const resolver = config.atprotoLexicons ?? defaultResolver
391
+ const roots = [...new Set(live.flatMap(page => [page.collection, page.context.contentLexicon]))]
392
+ const { lexicons, errors } = await resolver.load(roots)
393
+
394
+ const mapped = live.map(page => {
395
+ const context = { ...page.context, publication: publicationURL }
396
+ const unresolved = errors.get(page.collection)
397
+ const result = unresolved ? { ignored: unresolved, warnings: [] } : mapRecord(page.collection, page.page, { lexicons, context })
398
+ result.warnings.forEach(warning => log("warn", `${page.path}: ${warning}`))
399
+ if ("ignored" in result) log("warn", `${page.path}: not published to AT Protocol: ${result.ignored}`)
400
+ return { page, result }
401
+ })
402
+ const local = mapped
403
+ .filter(({ result }) => "record" in result)
404
+ .map(({ page, result }) => ({ collection: page.collection, rkey: page.rkey, value: result.record }))
405
+ const deleted = pages.filter(page => page.deleted).map(({ collection, rkey }) => ({ collection, rkey }))
406
+
407
+ const icon = standardSite && publication.icon ? await iconBlob(publication.icon, publication.iconPath) : undefined
408
+ const publicationRecord = standardSite ? {
409
+ $type: PUBLICATION,
410
+ url: origin,
411
+ name: publication.name,
412
+ ...(typeof publication.description === "string" && publication.description ? { description: publication.description } : {}),
413
+ ...(icon ? { icon: icon.ref } : {})
414
+ } : undefined
415
+
416
+ const pds = typeof configuredPDS === "string" && configuredPDS ? configuredPDS : await resolvePDS(did)
417
+
418
+ const collections = [...new Set([...local, ...deleted].map(record => record.collection))]
419
+ notify({ status: "progress", message: `Comparing ${local.length} records with ${pds}...` })
420
+ const listed = await Promise.all(collections.map(collection => listAll(pds, did, collection)))
421
+ const remote = new Map(listed.flatMap((records, index) => records.map(record => [`${collections[index]}/${rkeyOf(record.uri)}`, record.value])))
422
+ const plan = planWrites(local, deleted, remote)
423
+ const publicationWrite = publicationRecord
424
+ ? planPublication(await listAll(pds, did, PUBLICATION), rkeyOf(publicationURL), publicationRecord)
425
+ : { action: "none" }
426
+
427
+ const count = (action) => plan.filter(write => write.action === action).length
428
+ const summary = {
429
+ created: count("create"),
430
+ updated: count("update"),
431
+ deleted: count("delete"),
432
+ unchanged: count("unchanged"),
433
+ ignored: mapped.filter(({ result }) => "ignored" in result).length,
434
+ publication: publicationWrite.action,
435
+ dryRun
436
+ }
437
+
438
+ const publicationWrites = ["create", "update"].includes(publicationWrite.action) ? [{ ...publicationWrite, collection: PUBLICATION }] : []
439
+ const recordWrites = plan.filter(write => ["create", "update", "delete"].includes(write.action))
440
+ const writes = [...publicationWrites, ...recordWrites].map(applyWrite)
441
+ if (dryRun || !writes.length) {
442
+ notify({ status: "done", message: `${dryRun ? "Would send" : "Nothing to send"}: ${JSON.stringify(summary)}` })
443
+ return summary
444
+ }
445
+
446
+ const password = await appPassword(config.sourceFolder)
447
+ const session = await xrpc(pds, "com.atproto.server.createSession", { body: { identifier: did, password } })
448
+ if (session.did !== did) throw new Error(`atproto: the app password is for ${session.did}, not ${did}`)
449
+
450
+ // The icon is uploaded only when the publication is written - which is
451
+ // when its reference changed, or the record is new.
452
+ if (icon && publicationWrites.length) {
453
+ notify({ status: "progress", message: "Uploading the publication icon..." })
454
+ const uploaded = await xrpc(pds, "com.atproto.repo.uploadBlob", { bytes: icon.bytes, mimeType: icon.ref.mimeType, token: session.accessJwt })
455
+ if (uploaded.blob?.ref?.$link !== icon.ref.ref.$link) {
456
+ throw new Error(`atproto: the PDS gave the icon ${uploaded.blob?.ref?.$link}, not the ${icon.ref.ref.$link} computed for it; nothing was written`)
457
+ }
458
+ }
459
+
460
+ const batches = Array.from({ length: Math.ceil(writes.length / BATCH_SIZE) }, (_, index) =>
461
+ writes.slice(index * BATCH_SIZE, (index + 1) * BATCH_SIZE))
462
+ for (const [index, batch] of batches.entries()) {
463
+ notify({ status: "progress", message: `Sending batch ${index + 1} of ${batches.length}...` })
464
+ await xrpc(pds, "com.atproto.repo.applyWrites", { body: { repo: did, writes: batch }, token: session.accessJwt })
465
+ }
466
+
467
+ notify({ status: "done", message: `Published: ${JSON.stringify(summary)}` })
468
+ return summary
469
+ }
470
+
471
+ export { publishToAtproto, planWrites, planPublication, sameRecord, resolvePDS, blobCID, xrpc, passwordFile }
@@ -0,0 +1,90 @@
1
+ import { MARKER } from "../../secretPaths.js"
2
+ import createRkey from "./rkey.js"
3
+
4
+ /**
5
+ * 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).
9
+ *
10
+ * `site.standard.publication` is the one lexicon named here: standard.site
11
+ * defines a document's `site` as its publication's AT-URI, and vowel
12
+ * interprets that standard as written - the site is a publication when
13
+ * any of its pages publish to `site.standard.*`.
14
+ */
15
+
16
+ const PUBLICATION = "site.standard.publication"
17
+
18
+ /**
19
+ * The page's lexicon and, for a secret page, whether it may use it.
20
+ *
21
+ * `atproto_lexicon:` cascades from settings.md, and a page's own
22
+ * frontmatter can set it too. A page under a secret segment (`##`) is published only when
23
+ * the lexicon was set *inside* its secret scope: in its own frontmatter,
24
+ * or in a settings.md at or below the secret folder. A lexicon inherited
25
+ * from above the secret would publish a hidden page to the network by
26
+ * accident.
27
+ *
28
+ * The settings chain is the page-as-folder chain of the routed path
29
+ * (`blog/post.html` reads `""`, `blog`, `blog/post`), so level `k` holds
30
+ * the first `k` segments. Routing replaces a segment with its hash but
31
+ * keeps the depth, so the secret segment at source index `s` is level
32
+ * `s + 1`, and every level from there down is inside it.
33
+ * @param {{ source?: string, metadata: Record<string, any> }} target
34
+ * @param {{ raw: (label: string) => (unknown[] | null)[] | undefined }} settings
35
+ * @returns {string | undefined}
36
+ */
37
+ function lexiconFor(target, settings) {
38
+ const own = target.metadata.fm_atproto_lexicon
39
+ if (typeof own === "string" && own) return own
40
+
41
+ const levels = settings.raw("fm_atproto_lexicon") ?? []
42
+ const deepest = levels.findLastIndex(values => Array.isArray(values) && values.length > 0)
43
+ if (deepest === -1) return undefined
44
+ const lexicon = levels[deepest].at(-1)
45
+ if (typeof lexicon !== "string" || !lexicon) return undefined
46
+
47
+ const segments = (target.source ?? "").split(/[\\/]/)
48
+ const secret = segments.findIndex(segment => segment.includes(MARKER))
49
+ if (secret !== -1 && deepest < secret + 1) return undefined
50
+ return lexicon
51
+ }
52
+
53
+ /**
54
+ * The site's origin from `domain:`, the same base the html plugin builds
55
+ * the canonical link from. No trailing slash, per the lexicon.
56
+ * @param {unknown} domain
57
+ */
58
+ function siteOrigin(domain) {
59
+ if (typeof domain !== "string" || !domain) return undefined
60
+ const base = domain.startsWith("http") ? domain : `https://${domain}`
61
+ return new URL(base).origin
62
+ }
63
+
64
+ /**
65
+ * The site's publication: one per account and origin, its key derived
66
+ * from the origin the way a document's is from its URL, so its AT-URI is
67
+ * known before it exists. A document's `site` names it, and
68
+ * `/.well-known/site.standard.publication` serves it, which is how the
69
+ * domain claims it. The record itself is built by the publish command
70
+ * (publish.js), because its icon is an uploaded image.
71
+ * @param {string} did
72
+ * @param {string} origin
73
+ */
74
+ function publicationURI(did, origin) {
75
+ return `at://${did}/${PUBLICATION}/${createRkey(origin)}`
76
+ }
77
+
78
+ /**
79
+ * The page's AT-URI: the account, the lexicon, and a key derived from
80
+ * the page's canonical URL - origin and path - and frozen (rkey.js).
81
+ * @param {string} did
82
+ * @param {string} lexicon
83
+ * @param {string} origin
84
+ * @param {string} prettyURL
85
+ */
86
+ function recordURI(did, lexicon, origin, prettyURL) {
87
+ return `at://${did}/${lexicon}/${createRkey(new URL(prettyURL, origin).href)}`
88
+ }
89
+
90
+ export { PUBLICATION, lexiconFor, siteOrigin, publicationURI, recordURI }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * A page's record key, derived from its canonical URL: the site's origin
3
+ * and the page's published path, e.g. "https://example.com/blog/post".
4
+ *
5
+ * Deterministic, so the AT-URI is known on the first build, before any
6
+ * record exists, and the same page gets the same key on every machine. No
7
+ * ledger: the URL is the identity. The domain is part of it, so two sites
8
+ * publishing to one account never share a key, and a site that changes
9
+ * domain gets new keys: the documents now live somewhere else. The old
10
+ * records are *not* deleted by the next publish, because their `site` is
11
+ * the old domain and publish only deletes records whose `site` is this
12
+ * site's. A move that changes one page's URL, on the same domain, does
13
+ * delete that page's old record.
14
+ *
15
+ * `site.standard.document` declares `key: tid`, so the key must be shaped
16
+ * like a TID: 13 characters of base32-sortable, with the top bit clear.
17
+ * It is 63 bits of FNV-1a over the URL's UTF-8 bytes, which clears the
18
+ * top bit by construction. A real TID encodes a timestamp and this one
19
+ * does not, so keys do not sort by date. Nothing in site.standard relies
20
+ * on that.
21
+ *
22
+ * **The function is frozen once anything is published.** Changing the
23
+ * hash, its input or its encoding moves every page to a new key. The
24
+ * next publish would then delete and recreate every record.
25
+ */
26
+
27
+ const ALPHABET = "234567abcdefghijklmnopqrstuvwxyz"
28
+ const OFFSET = 0xcbf29ce484222325n
29
+ const PRIME = 0x100000001b3n
30
+ const MASK_64 = (1n << 64n) - 1n
31
+ const MASK_63 = (1n << 63n) - 1n
32
+ const LENGTH = 13
33
+
34
+ /** @param {string} text */
35
+ function fnv1a63(text) {
36
+ const bytes = Buffer.from(text, "utf8")
37
+ const hash = bytes.reduce((h, byte) => ((h ^ BigInt(byte)) * PRIME) & MASK_64, OFFSET)
38
+ return hash & MASK_63
39
+ }
40
+
41
+ /**
42
+ * @param {string} canonicalURL - e.g. "https://example.com/blog/post"
43
+ * @returns {string} a TID-shaped record key
44
+ */
45
+ function createRkey(canonicalURL) {
46
+ const hash = fnv1a63(canonicalURL)
47
+ const characters = Array.from({ length: LENGTH }, (_, index) => {
48
+ const shift = BigInt(5 * (LENGTH - 1 - index))
49
+ return ALPHABET[Number((hash >> shift) & 31n)]
50
+ })
51
+ return characters.join("")
52
+ }
53
+
54
+ export default createRkey