vowel 0.7.0-beta.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +41 -239
- package/bin.js +73 -31
- package/cli.js +32 -0
- package/config.js +14 -0
- package/extractDate.js +2 -2
- package/index.js +6 -1
- package/package.json +9 -34
- package/pathDate.js +69 -0
- package/plugins/assets/index.js +1 -1
- package/plugins/atproto/body.js +413 -0
- package/plugins/atproto/index.js +10 -44
- package/plugins/atproto/lexicons.js +179 -0
- package/plugins/atproto/mapper.js +399 -0
- package/plugins/atproto/publish.js +195 -105
- package/plugins/atproto/record.js +57 -68
- package/plugins/deploy-cloudflare/index.js +4 -1
- package/plugins/export/index.js +131 -0
- package/plugins/html/bundle/index.js +57 -23
- package/plugins/html/editor/Editor.svelte +9 -5
- package/plugins/html/editor/create-session.js +3 -1
- package/plugins/html/editor/directives.js +12 -0
- package/plugins/html/editor/frontmatter.js +42 -11
- package/plugins/html/editor/ingest.js +19 -8
- package/plugins/html/editor/serialize.js +8 -4
- package/plugins/html/image.js +11 -5
- package/plugins/html/index.js +217 -100
- package/plugins/html/partials.js +14 -8
- package/plugins/html/socialLinks.js +5 -8
- package/plugins/html/writeRules.js +197 -37
- package/plugins/images/favicon.png +0 -0
- package/plugins/images/index.js +41 -3
- package/plugins/markdown/colorScheme.js +70 -14
- package/plugins/markdown/frontmatter.js +32 -6
- package/plugins/markdown/index.js +105 -20
- package/plugins/markdown/metadata.js +74 -22
- package/plugins/markdown/typography.js +11 -11
- package/plugins/secret-pages/index.js +75 -0
- package/plugins/styles/index.js +4 -4
- package/plugins/xml/entryContent.js +22 -11
- package/plugins/xml/index.js +19 -11
- package/stylesheets/DefaultStyles.css +198 -111
- package/stylesheets/ResetStyles.css +2 -1
- package/stylesheets/TypographyStyles.css +153 -74
- package/utils.js +74 -1
|
@@ -5,41 +5,51 @@ import path from "node:path"
|
|
|
5
5
|
import sharp from "sharp"
|
|
6
6
|
import votive from "votive"
|
|
7
7
|
import { systemDirectoryFor } from "../../systemPaths.js"
|
|
8
|
-
import {
|
|
8
|
+
import { EXPECTED } from "../markdown/frontmatter.js"
|
|
9
|
+
import { contentSection } from "../xml/entryContent.js"
|
|
10
|
+
import { PUBLICATION, siteOrigin, publicationURI, pageRecordURI } from "./record.js"
|
|
11
|
+
import { createLexiconResolver, resolvePDS } from "./lexicons.js"
|
|
12
|
+
import { mapRecord } from "./mapper.js"
|
|
13
|
+
import { markdownBody, richTextBody } from "./body.js"
|
|
9
14
|
|
|
10
15
|
/**
|
|
11
16
|
* `vowel --command atproto-publish`: make the account's records match the
|
|
12
|
-
* site.
|
|
17
|
+
* site's pages.
|
|
13
18
|
*
|
|
14
|
-
* The
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* same state.
|
|
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.
|
|
22
26
|
*
|
|
23
|
-
* **
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* is a
|
|
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.
|
|
28
36
|
*
|
|
29
37
|
* The build uses a throwaway database, so every page is read and every
|
|
30
38
|
* transform runs against current settings. A transform only re-runs when
|
|
31
39
|
* its source is re-read (tasks/1-proposed/transform-reruns-on-stale.md),
|
|
32
40
|
* so a warm database could hold records built from old settings.
|
|
33
41
|
*
|
|
34
|
-
* **The publication goes first
|
|
35
|
-
* site
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
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.
|
|
43
53
|
*
|
|
44
54
|
* The app password comes from `ATPROTO_APP_PASSWORD`, or from a file
|
|
45
55
|
* named `atproto-app-password` in the project's system directory, never
|
|
@@ -47,6 +57,9 @@ import { DOCUMENT, PUBLICATION, siteOrigin, publicationURI } from "./record.js"
|
|
|
47
57
|
* and sends nothing, and does not need the password. `{"optional": true}`
|
|
48
58
|
* makes a site with no `atproto_did` a skip rather than an error, for
|
|
49
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.
|
|
50
63
|
*/
|
|
51
64
|
|
|
52
65
|
const PASSWORD_FILE = "atproto-app-password"
|
|
@@ -143,27 +156,6 @@ async function xrpc(pds, nsid, { query, body, bytes, mimeType, token } = {}) {
|
|
|
143
156
|
throw new Error(`${nsid}: ${response.status} ${json.error ?? ""} ${json.message ?? ""}`.trim())
|
|
144
157
|
}
|
|
145
158
|
|
|
146
|
-
/**
|
|
147
|
-
* The PDS that hosts `did`, from its DID document. `did:plc` is resolved
|
|
148
|
-
* through the PLC directory, `did:web` through its own host.
|
|
149
|
-
* @param {string} did
|
|
150
|
-
*/
|
|
151
|
-
async function resolvePDS(did) {
|
|
152
|
-
const documentURL = did.startsWith("did:plc:")
|
|
153
|
-
? `https://plc.directory/${did}`
|
|
154
|
-
: did.startsWith("did:web:")
|
|
155
|
-
? `https://${decodeURIComponent(did.slice("did:web:".length))}/.well-known/did.json`
|
|
156
|
-
: undefined
|
|
157
|
-
if (!documentURL) throw new Error(`atproto: can't resolve ${did}; set atproto_pds in settings.md`)
|
|
158
|
-
|
|
159
|
-
const response = await fetch(documentURL, { signal: AbortSignal.timeout(TIMEOUT) })
|
|
160
|
-
if (!response.ok) throw new Error(`atproto: resolving ${did} failed: ${response.status}`)
|
|
161
|
-
const document = await response.json()
|
|
162
|
-
const service = (document.service ?? []).find(entry => entry.id === "#atproto_pds" || entry.id === `${did}#atproto_pds`)
|
|
163
|
-
if (!service) throw new Error(`atproto: ${did} names no PDS`)
|
|
164
|
-
return service.serviceEndpoint
|
|
165
|
-
}
|
|
166
|
-
|
|
167
159
|
/**
|
|
168
160
|
* Where the app password is kept: the project's system directory, never
|
|
169
161
|
* the project, so it is neither committed nor deployed.
|
|
@@ -203,14 +195,83 @@ async function listAll(pds, did, collection, cursor) {
|
|
|
203
195
|
const rkeyOf = (uri) => uri.slice(uri.lastIndexOf("/") + 1)
|
|
204
196
|
|
|
205
197
|
/**
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
|
|
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
|
+
* The folder whose settings a page reads: the page as a folder -
|
|
237
|
+
* `blog/post.html` → `blog/post` - which is what votive hands a write
|
|
238
|
+
* (`pageSettingsFolder` in votive's lib/utils, not exported), so publish
|
|
239
|
+
* sees each page's lexicon exactly as its write did. `index.html` is
|
|
240
|
+
* `/index` there, and here.
|
|
241
|
+
* @param {string} targetPath
|
|
242
|
+
*/
|
|
243
|
+
function pageSettingsFolder(targetPath) {
|
|
244
|
+
const segments = targetPath.split("/").filter(Boolean)
|
|
245
|
+
const folder = path.relative("", [...segments.slice(0, -1), ""].join(path.sep))
|
|
246
|
+
return folder + path.sep + path.parse(targetPath).name
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Every page under a lexicon, with its AT-URI: the author's own pages -
|
|
251
|
+
* a generated one (a tag page, a folder index, the default home or 404)
|
|
252
|
+
* is a stub, and inherits a folder's lexicon without being anything to
|
|
253
|
+
* publish.
|
|
254
|
+
* @param {any} database - a built site's
|
|
255
|
+
* @returns {{ target: any, uri: string }[]}
|
|
256
|
+
*/
|
|
257
|
+
function recordPages(database) {
|
|
258
|
+
const stubs = new Set(database.source.getStubs().map(row => row.path))
|
|
259
|
+
return database.target.getAll()
|
|
260
|
+
.filter(target => target.extension === ".html" && !stubs.has(target.source))
|
|
261
|
+
.map(target => ({ target, uri: pageRecordURI(target, database.setting.getByFolder(pageSettingsFolder(target.path))).uri }))
|
|
262
|
+
.filter(page => typeof page.uri === "string")
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* What the site should hold: every page under a lexicon - its AT-URI,
|
|
267
|
+
* its status, and what mapper.js reads - the site's own settings, and
|
|
268
|
+
* the publication's name, description and icon. The icon is read from
|
|
269
|
+
* the build's output - it is a URL path from the root (frontmatter.js
|
|
270
|
+
* resolves it), which is where the output has it. Built into a scratch
|
|
271
|
+
* folder that is removed afterwards.
|
|
211
272
|
* @param {import("votive").VotiveConfig} config
|
|
212
273
|
*/
|
|
213
|
-
async function
|
|
274
|
+
async function collectPages(config) {
|
|
214
275
|
const scratch = await mkdtemp(path.join(tmpdir(), "vowel-atproto-"))
|
|
215
276
|
try {
|
|
216
277
|
const site = await votive({
|
|
@@ -225,9 +286,21 @@ async function collectRecords(config) {
|
|
|
225
286
|
const did = root.lastNonNull("fm_atproto_did")
|
|
226
287
|
const pds = root.lastNonNull("fm_atproto_pds")
|
|
227
288
|
const domain = root.lastNonNull("fm_domain")
|
|
228
|
-
const
|
|
229
|
-
const pages =
|
|
230
|
-
|
|
289
|
+
const origin = siteOrigin(domain)
|
|
290
|
+
const pages = recordPages(site.database).map(({ target, uri }) => {
|
|
291
|
+
const [, , account, collection, rkey] = uri.split("/")
|
|
292
|
+
const pageURL = new URL(target.metadata.prettyURL, origin).href
|
|
293
|
+
const contentLexicon = site.database.setting.getByFolder(target.dir).lastNonNull("fm_atproto_content")
|
|
294
|
+
return {
|
|
295
|
+
path: target.path,
|
|
296
|
+
account,
|
|
297
|
+
collection,
|
|
298
|
+
rkey,
|
|
299
|
+
deleted: target.metadata.fm_status === "deleted",
|
|
300
|
+
context: { origin, pageURL, path: target.metadata.prettyURL, contentLexicon: typeof contentLexicon === "string" && contentLexicon ? contentLexicon : CONTENT_LEXICON },
|
|
301
|
+
page: target.metadata.fm_status === "deleted" ? undefined : pageFor(target, pageURL)
|
|
302
|
+
}
|
|
303
|
+
})
|
|
231
304
|
|
|
232
305
|
const settings = root.lastNonNull("fm_atproto_publication") ?? {}
|
|
233
306
|
const home = site.database.target.get("index.html")
|
|
@@ -235,7 +308,7 @@ async function collectRecords(config) {
|
|
|
235
308
|
const iconPath = settings.icon ?? root.lastNonNull("logo")
|
|
236
309
|
const icon = iconPath ? await readIcon(path.join(scratch, "output"), iconPath) : undefined
|
|
237
310
|
const publication = { name, description: settings.description, iconPath, icon }
|
|
238
|
-
return { did, pds, domain,
|
|
311
|
+
return { did, pds, domain, pages, publication }
|
|
239
312
|
} finally {
|
|
240
313
|
await site.close()
|
|
241
314
|
}
|
|
@@ -275,46 +348,49 @@ function planPublication(remote, rkey, value) {
|
|
|
275
348
|
}
|
|
276
349
|
|
|
277
350
|
/**
|
|
278
|
-
* The writes that make
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
351
|
+
* The writes that make the account match the site. `local` is the
|
|
352
|
+
* records the pages make, `deleted` the keys of pages whose status is
|
|
353
|
+
* `deleted`; `remote` is what the PDS holds in those collections, keyed
|
|
354
|
+
* `<collection>/<rkey>`. A record the PDS doesn't hold is created, one
|
|
355
|
+
* that differs is updated, and a deleted page's record is deleted if it
|
|
356
|
+
* is there. Nothing else in a collection is touched.
|
|
357
|
+
* @param {{ collection: string, rkey: string, value: unknown }[]} local
|
|
358
|
+
* @param {{ collection: string, rkey: string }[]} deleted
|
|
359
|
+
* @param {Map<string, unknown>} remote
|
|
282
360
|
*/
|
|
283
|
-
function planWrites(local,
|
|
284
|
-
const
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
const existing = remoteByKey.get(rkey)
|
|
290
|
-
if (!existing) return { action: "create", rkey, value: record.value }
|
|
291
|
-
if (!sites.has(existing.value?.site)) return { action: "collision", rkey, site: existing.value?.site }
|
|
292
|
-
if (sameRecord(existing.value, record.value)) return { action: "unchanged", rkey }
|
|
293
|
-
return { action: "update", rkey, value: record.value }
|
|
361
|
+
function planWrites(local, deleted, remote) {
|
|
362
|
+
const compared = local.map(({ collection, rkey, value }) => {
|
|
363
|
+
const existing = remote.get(`${collection}/${rkey}`)
|
|
364
|
+
if (existing === undefined) return { action: "create", collection, rkey, value }
|
|
365
|
+
if (sameRecord(existing, value)) return { action: "unchanged", collection, rkey }
|
|
366
|
+
return { action: "update", collection, rkey, value }
|
|
294
367
|
})
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
.
|
|
298
|
-
.map(record => ({ action: "delete", rkey: rkeyOf(record.uri) }))
|
|
299
|
-
|
|
368
|
+
const deletions = deleted
|
|
369
|
+
.filter(({ collection, rkey }) => remote.has(`${collection}/${rkey}`))
|
|
370
|
+
.map(({ collection, rkey }) => ({ action: "delete", collection, rkey }))
|
|
300
371
|
return [...compared, ...deletions]
|
|
301
372
|
}
|
|
302
373
|
|
|
303
|
-
/** @param {{ action: string, rkey: string, value?: unknown
|
|
304
|
-
function applyWrite({ action, rkey, value
|
|
374
|
+
/** @param {{ action: string, collection: string, rkey: string, value?: unknown }} write */
|
|
375
|
+
function applyWrite({ action, collection, rkey, value }) {
|
|
305
376
|
const base = { $type: `com.atproto.repo.applyWrites#${action}`, collection, rkey }
|
|
306
377
|
return action === "delete" ? base : { ...base, value }
|
|
307
378
|
}
|
|
308
379
|
|
|
380
|
+
const defaultResolver = createLexiconResolver()
|
|
381
|
+
|
|
309
382
|
/**
|
|
310
383
|
* @param {{ dryRun?: boolean, optional?: boolean } | undefined} payload
|
|
311
|
-
* @param {{ config: import("votive").VotiveConfig, notify: (message: object) => void }} context
|
|
384
|
+
* @param {{ config: import("votive").VotiveConfig & { atprotoLexicons?: ReturnType<typeof createLexiconResolver> }, notify: (message: object) => void }} context
|
|
312
385
|
*/
|
|
313
386
|
async function publishToAtproto(payload, { config, notify }) {
|
|
314
387
|
const dryRun = Boolean(payload?.dryRun)
|
|
388
|
+
// A command's config comes straight from the caller and may carry no
|
|
389
|
+
// logger; votive() adds one only to the config its hooks receive.
|
|
390
|
+
const log = config.log ?? ((level, message) => console.error(message))
|
|
315
391
|
|
|
316
392
|
notify({ status: "progress", message: "Building..." })
|
|
317
|
-
const { did, pds: configuredPDS, domain,
|
|
393
|
+
const { did, pds: configuredPDS, domain, pages, publication } = await collectPages(config)
|
|
318
394
|
|
|
319
395
|
if ((typeof did !== "string" || !did) && payload?.optional) {
|
|
320
396
|
notify({ status: "done", message: "No atproto_did in settings.md; nothing to publish to AT Protocol" })
|
|
@@ -324,34 +400,54 @@ async function publishToAtproto(payload, { config, notify }) {
|
|
|
324
400
|
const origin = siteOrigin(domain)
|
|
325
401
|
if (!origin) throw new Error("atproto: set domain in the root settings.md; every record carries the site's url")
|
|
326
402
|
|
|
327
|
-
const
|
|
328
|
-
const others = [...accounts].filter(account => account !== did)
|
|
403
|
+
const others = [...new Set(pages.map(page => page.account))].filter(account => account !== did)
|
|
329
404
|
if (others.length) throw new Error(`atproto: pages name ${others.join(", ")}; one site publishes to one account (${did})`)
|
|
330
405
|
|
|
331
|
-
|
|
406
|
+
// The publication, only for a site that publishes to standard.site.
|
|
407
|
+
const live = pages.filter(page => !page.deleted)
|
|
408
|
+
const standardSite = live.some(page => page.collection.startsWith("site.standard."))
|
|
409
|
+
if (standardSite && (typeof publication.name !== "string" || !publication.name)) {
|
|
332
410
|
throw new Error("atproto: set name in the root settings.md; the publication needs one")
|
|
333
411
|
}
|
|
412
|
+
const publicationURL = standardSite ? publicationURI(did, origin) : undefined
|
|
413
|
+
|
|
414
|
+
notify({ status: "progress", message: "Fetching lexicons..." })
|
|
415
|
+
const resolver = config.atprotoLexicons ?? defaultResolver
|
|
416
|
+
const roots = [...new Set(live.flatMap(page => [page.collection, page.context.contentLexicon]))]
|
|
417
|
+
const { lexicons, errors } = await resolver.load(roots)
|
|
418
|
+
|
|
419
|
+
const mapped = live.map(page => {
|
|
420
|
+
const context = { ...page.context, publication: publicationURL }
|
|
421
|
+
const unresolved = errors.get(page.collection)
|
|
422
|
+
const result = unresolved ? { ignored: unresolved, warnings: [] } : mapRecord(page.collection, page.page, { lexicons, context })
|
|
423
|
+
result.warnings.forEach(warning => log("warn", `${page.path}: ${warning}`))
|
|
424
|
+
if ("ignored" in result) log("warn", `${page.path}: not published to AT Protocol: ${result.ignored}`)
|
|
425
|
+
return { page, result }
|
|
426
|
+
})
|
|
427
|
+
const local = mapped
|
|
428
|
+
.filter(({ result }) => "record" in result)
|
|
429
|
+
.map(({ page, result }) => ({ collection: page.collection, rkey: page.rkey, value: result.record }))
|
|
430
|
+
const deleted = pages.filter(page => page.deleted).map(({ collection, rkey }) => ({ collection, rkey }))
|
|
334
431
|
|
|
335
|
-
const icon = publication.icon ? await iconBlob(publication.icon, publication.iconPath) : undefined
|
|
336
|
-
const publicationRecord = {
|
|
432
|
+
const icon = standardSite && publication.icon ? await iconBlob(publication.icon, publication.iconPath) : undefined
|
|
433
|
+
const publicationRecord = standardSite ? {
|
|
337
434
|
$type: PUBLICATION,
|
|
338
435
|
url: origin,
|
|
339
436
|
name: publication.name,
|
|
340
437
|
...(typeof publication.description === "string" && publication.description ? { description: publication.description } : {}),
|
|
341
438
|
...(icon ? { icon: icon.ref } : {})
|
|
342
|
-
}
|
|
343
|
-
const publicationURL = publicationURI(did, origin)
|
|
439
|
+
} : undefined
|
|
344
440
|
|
|
345
|
-
// A document is this site's when its `site` is the publication, or the
|
|
346
|
-
// origin a loose document carried before there was one.
|
|
347
|
-
const sites = new Set([origin, publicationURL, ...records.map(record => record.value.site)])
|
|
348
441
|
const pds = typeof configuredPDS === "string" && configuredPDS ? configuredPDS : await resolvePDS(did)
|
|
349
442
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
const
|
|
353
|
-
const
|
|
354
|
-
const plan = planWrites(
|
|
443
|
+
const collections = [...new Set([...local, ...deleted].map(record => record.collection))]
|
|
444
|
+
notify({ status: "progress", message: `Comparing ${local.length} records with ${pds}...` })
|
|
445
|
+
const listed = await Promise.all(collections.map(collection => listAll(pds, did, collection)))
|
|
446
|
+
const remote = new Map(listed.flatMap((records, index) => records.map(record => [`${collections[index]}/${rkeyOf(record.uri)}`, record.value])))
|
|
447
|
+
const plan = planWrites(local, deleted, remote)
|
|
448
|
+
const publicationWrite = publicationRecord
|
|
449
|
+
? planPublication(await listAll(pds, did, PUBLICATION), rkeyOf(publicationURL), publicationRecord)
|
|
450
|
+
: { action: "none" }
|
|
355
451
|
|
|
356
452
|
const count = (action) => plan.filter(write => write.action === action).length
|
|
357
453
|
const summary = {
|
|
@@ -359,20 +455,14 @@ async function publishToAtproto(payload, { config, notify }) {
|
|
|
359
455
|
updated: count("update"),
|
|
360
456
|
deleted: count("delete"),
|
|
361
457
|
unchanged: count("unchanged"),
|
|
362
|
-
|
|
458
|
+
ignored: mapped.filter(({ result }) => "ignored" in result).length,
|
|
363
459
|
publication: publicationWrite.action,
|
|
364
460
|
dryRun
|
|
365
461
|
}
|
|
366
|
-
// A command's config comes straight from the caller and may carry no
|
|
367
|
-
// logger; votive() adds one only to the config its hooks receive.
|
|
368
|
-
const log = config.log ?? ((level, message) => console.error(message))
|
|
369
|
-
for (const { rkey, site } of summary.collisions) {
|
|
370
|
-
log("error", `atproto: ${DOCUMENT}/${rkey} belongs to ${site}, not ${origin}; left alone`)
|
|
371
|
-
}
|
|
372
462
|
|
|
373
|
-
const publicationWrites = publicationWrite.action
|
|
374
|
-
const
|
|
375
|
-
const writes = [...publicationWrites, ...
|
|
463
|
+
const publicationWrites = ["create", "update"].includes(publicationWrite.action) ? [{ ...publicationWrite, collection: PUBLICATION }] : []
|
|
464
|
+
const recordWrites = plan.filter(write => ["create", "update", "delete"].includes(write.action))
|
|
465
|
+
const writes = [...publicationWrites, ...recordWrites].map(applyWrite)
|
|
376
466
|
if (dryRun || !writes.length) {
|
|
377
467
|
notify({ status: "done", message: `${dryRun ? "Would send" : "Nothing to send"}: ${JSON.stringify(summary)}` })
|
|
378
468
|
return summary
|
|
@@ -403,4 +493,4 @@ async function publishToAtproto(payload, { config, notify }) {
|
|
|
403
493
|
return summary
|
|
404
494
|
}
|
|
405
495
|
|
|
406
|
-
export { publishToAtproto, planWrites, planPublication, sameRecord, resolvePDS, blobCID, xrpc, passwordFile }
|
|
496
|
+
export { publishToAtproto, recordPages, pageSettingsFolder, planWrites, planPublication, sameRecord, resolvePDS, blobCID, xrpc, passwordFile }
|
|
@@ -1,19 +1,31 @@
|
|
|
1
|
-
import { toText } from "hast-util-to-text"
|
|
2
1
|
import { MARKER } from "../../secretPaths.js"
|
|
3
2
|
import createRkey from "./rkey.js"
|
|
3
|
+
import { validNSID } from "./lexicons.js"
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* Which pages become records, and
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* Which pages become records, and where each record lives. No network,
|
|
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.
|
|
20
|
+
*
|
|
21
|
+
* `site.standard.publication` is the one lexicon named here: standard.site
|
|
22
|
+
* defines a document's `site` as its publication's AT-URI, and vowel
|
|
23
|
+
* interprets that standard as written - the site is a publication when
|
|
24
|
+
* any of its pages publish to `site.standard.*`.
|
|
9
25
|
*/
|
|
10
26
|
|
|
11
|
-
const DOCUMENT = "site.standard.document"
|
|
12
27
|
const PUBLICATION = "site.standard.publication"
|
|
13
28
|
|
|
14
|
-
/** The lexicons vowel can build a record for. */
|
|
15
|
-
const SUPPORTED = [DOCUMENT]
|
|
16
|
-
|
|
17
29
|
/**
|
|
18
30
|
* The page's lexicon and, for a secret page, whether it may use it.
|
|
19
31
|
*
|
|
@@ -74,71 +86,48 @@ function publicationURI(did, origin) {
|
|
|
74
86
|
return `at://${did}/${PUBLICATION}/${createRkey(origin)}`
|
|
75
87
|
}
|
|
76
88
|
|
|
77
|
-
/**
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
* lexicon; a page without a date cannot be a document. `site` is the
|
|
81
|
-
* publication's AT-URI; `origin` is what the page's key is made from.
|
|
82
|
-
* @param {{ metadata: Record<string, any> }} target
|
|
83
|
-
* @param {string | undefined} origin
|
|
84
|
-
* @param {string} [site] - the publication's AT-URI
|
|
85
|
-
* @returns {{ record: Record<string, unknown> } | { missing: string[] }}
|
|
86
|
-
*/
|
|
87
|
-
function documentRecord(target, origin, site = origin) {
|
|
88
|
-
const { title, date, prettyURL, description, tags, hastAbstract } = target.metadata
|
|
89
|
-
const published = date ? new Date(date) : undefined
|
|
90
|
-
const validDate = published && !Number.isNaN(published.getTime())
|
|
91
|
-
|
|
92
|
-
const missing = [
|
|
93
|
-
...(origin ? [] : ["domain"]),
|
|
94
|
-
...(title ? [] : ["title"]),
|
|
95
|
-
...(validDate ? [] : ["date"])
|
|
96
|
-
]
|
|
97
|
-
if (missing.length) return { missing }
|
|
98
|
-
|
|
99
|
-
const textContent = hastAbstract ? toText(hastAbstract).trim() : ""
|
|
100
|
-
|
|
101
|
-
const record = {
|
|
102
|
-
$type: DOCUMENT,
|
|
103
|
-
site,
|
|
104
|
-
path: prettyURL,
|
|
105
|
-
title,
|
|
106
|
-
publishedAt: published.toISOString(),
|
|
107
|
-
...(description ? { description } : {}),
|
|
108
|
-
...(Array.isArray(tags) && tags.length ? { tags } : {}),
|
|
109
|
-
...(textContent ? { textContent } : {})
|
|
110
|
-
}
|
|
111
|
-
return { record }
|
|
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)
|
|
112
92
|
}
|
|
113
93
|
|
|
114
94
|
/**
|
|
115
|
-
* The page's AT-URI
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
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 }}
|
|
123
106
|
*/
|
|
124
|
-
function
|
|
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 {}
|
|
125
115
|
const lexicon = lexiconFor(target, settings)
|
|
126
|
-
if (!lexicon) return {
|
|
127
|
-
if (!
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
const origin = siteOrigin(domain)
|
|
132
|
-
const site = origin ? publicationURI(did, origin) : undefined
|
|
133
|
-
const built = documentRecord(target, origin, site)
|
|
134
|
-
if ("missing" in built) {
|
|
135
|
-
return { incomplete: `not published to AT Protocol: a ${lexicon} record needs ${built.missing.join(", ")}` }
|
|
136
|
-
}
|
|
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
|
+
}
|
|
137
120
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
121
|
+
/**
|
|
122
|
+
* The page's AT-URI: the account, the lexicon, and a key derived from
|
|
123
|
+
* the page's canonical URL - origin and path - and frozen (rkey.js).
|
|
124
|
+
* @param {string} did
|
|
125
|
+
* @param {string} lexicon
|
|
126
|
+
* @param {string} origin
|
|
127
|
+
* @param {string} prettyURL
|
|
128
|
+
*/
|
|
129
|
+
function recordURI(did, lexicon, origin, prettyURL) {
|
|
130
|
+
return `at://${did}/${lexicon}/${createRkey(new URL(prettyURL, origin).href)}`
|
|
142
131
|
}
|
|
143
132
|
|
|
144
|
-
export {
|
|
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}`))
|