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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +41 -239
  3. package/bin.js +73 -31
  4. package/cli.js +32 -0
  5. package/config.js +14 -0
  6. package/extractDate.js +2 -2
  7. package/index.js +6 -1
  8. package/package.json +9 -34
  9. package/pathDate.js +69 -0
  10. package/plugins/assets/index.js +1 -1
  11. package/plugins/atproto/body.js +413 -0
  12. package/plugins/atproto/index.js +10 -44
  13. package/plugins/atproto/lexicons.js +179 -0
  14. package/plugins/atproto/mapper.js +399 -0
  15. package/plugins/atproto/publish.js +195 -105
  16. package/plugins/atproto/record.js +57 -68
  17. package/plugins/deploy-cloudflare/index.js +4 -1
  18. package/plugins/export/index.js +131 -0
  19. package/plugins/html/bundle/index.js +57 -23
  20. package/plugins/html/editor/Editor.svelte +9 -5
  21. package/plugins/html/editor/create-session.js +3 -1
  22. package/plugins/html/editor/directives.js +12 -0
  23. package/plugins/html/editor/frontmatter.js +42 -11
  24. package/plugins/html/editor/ingest.js +19 -8
  25. package/plugins/html/editor/serialize.js +8 -4
  26. package/plugins/html/image.js +11 -5
  27. package/plugins/html/index.js +217 -100
  28. package/plugins/html/partials.js +14 -8
  29. package/plugins/html/socialLinks.js +5 -8
  30. package/plugins/html/writeRules.js +197 -37
  31. package/plugins/images/favicon.png +0 -0
  32. package/plugins/images/index.js +41 -3
  33. package/plugins/markdown/colorScheme.js +70 -14
  34. package/plugins/markdown/frontmatter.js +32 -6
  35. package/plugins/markdown/index.js +105 -20
  36. package/plugins/markdown/metadata.js +74 -22
  37. package/plugins/markdown/typography.js +11 -11
  38. package/plugins/secret-pages/index.js +75 -0
  39. package/plugins/styles/index.js +4 -4
  40. package/plugins/xml/entryContent.js +22 -11
  41. package/plugins/xml/index.js +19 -11
  42. package/stylesheets/DefaultStyles.css +198 -111
  43. package/stylesheets/ResetStyles.css +2 -1
  44. package/stylesheets/TypographyStyles.css +153 -74
  45. 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 { DOCUMENT, PUBLICATION, siteOrigin, publicationURI } from "./record.js"
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 PDS is the ledger. The command builds the site, collects the record
15
- * each page's transform stored (`metadata.atprotoRecord`), lists what the
16
- * PDS holds, and sends the difference through `applyWrites`: a record the
17
- * site has and the PDS does not is created, one that differs is updated,
18
- * and one the PDS has for this site and the site no longer produces is
19
- * deleted. Nothing is remembered between runs, so clearing a cache
20
- * cannot cause a resend, a revert publishes, and a coworker sees the
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
- * **Only this site's records are ever deleted.** `listRecords` returns
24
- * every record in the collection, including ones another site or app
25
- * published to the same account. A record is this site's when its `site`
26
- * is this site's origin. A record at one of our keys with another `site`
27
- * is a collision; it is reported and left alone.
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.** One `site.standard.publication` per
35
- * site, at the key record.js derives from the origin: `url` from
36
- * `domain:`, `name` from the root `name:` (else the homepage's title),
37
- * and `description` and `icon` from the root's `atproto_publication:`.
38
- * The icon defaults to `logo:`. It is rendered to a 512px square PNG and
39
- * its blob reference - CID, type, size - computed here, so an unchanged
40
- * icon compares equal and is never uploaded again; a changed one is
41
- * uploaded just before the write that names it. The publication is
42
- * created and updated, never deleted.
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
- * What the site should hold: every stored record, by rkey, the site's
207
- * own settings, and the publication's name, description and icon. The
208
- * icon is read from the build's output - it is a URL path from the root
209
- * (frontmatter.js resolves it), which is where the output has it. Built
210
- * into a scratch folder that is removed afterwards.
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 collectRecords(config) {
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 targets = site.database.target.getAll()
229
- const pages = targets.filter(target => "atUri" in target.metadata && "atprotoRecord" in target.metadata)
230
- const records = pages.map(target => ({ uri: target.metadata.atUri, value: target.metadata.atprotoRecord }))
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, records, publication }
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 `remote` match `local`, plus the collisions.
279
- * @param {{ uri: string, value: any }[]} local
280
- * @param {{ uri: string, value: any }[]} remote
281
- * @param {Set<string>} sites - the `site` values that are this site's
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, remote, sites) {
284
- const remoteByKey = new Map(remote.map(record => [rkeyOf(record.uri), record]))
285
- const localKeys = new Set(local.map(record => rkeyOf(record.uri)))
286
-
287
- const compared = local.map(record => {
288
- const rkey = rkeyOf(record.uri)
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
- const deletions = remote
297
- .filter(record => sites.has(record.value?.site) && !localKeys.has(rkeyOf(record.uri)))
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, collection?: string }} write */
304
- function applyWrite({ action, rkey, value, collection = DOCUMENT }) {
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, records, publication } = await collectRecords(config)
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 accounts = new Set(records.map(record => record.uri.split("/")[2]))
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
- if (typeof publication.name !== "string" || !publication.name) {
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
- notify({ status: "progress", message: `Comparing ${records.length} records with ${pds}...` })
351
- const remote = await listAll(pds, did, DOCUMENT)
352
- const remotePublications = await listAll(pds, did, PUBLICATION)
353
- const publicationWrite = planPublication(remotePublications, rkeyOf(publicationURL), publicationRecord)
354
- const plan = planWrites(records, remote, sites)
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
- collisions: plan.filter(write => write.action === "collision").map(({ rkey, site }) => ({ rkey, site })),
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 === "unchanged" ? [] : [{ ...publicationWrite, collection: PUBLICATION }]
374
- const documentWrites = plan.filter(write => ["create", "update", "delete"].includes(write.action))
375
- const writes = [...publicationWrites, ...documentWrites].map(applyWrite)
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 what each record holds. No network:
7
- * everything here is a function of the page and its settings, so it runs
8
- * in the build, and the publish command only compares and sends.
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
- * A `site.standard.document` record for the page, or the list of what it
79
- * is missing. `title`, `publishedAt` and `site` are required by the
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 and record, or why it has none. `skip` is silent (the
116
- * page is not meant to be published). `incomplete` is a page under a
117
- * lexicon that lacks a property the lexicon requires; it is not a record,
118
- * by design, and is only mentioned under `verbose`. `warning` is a
119
- * misconfiguration: a lexicon vowel cannot build.
120
- * @param {{ path: string, source?: string, metadata: Record<string, any> }} target
121
- * @param {{ did: string, domain: unknown, settings: any }} context
122
- * @returns {{ atUri: string, record: Record<string, unknown> } | { skip: true } | { incomplete: string } | { warning: string }}
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 pageRecord(target, { did, domain, settings }) {
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 { skip: true }
127
- if (!SUPPORTED.includes(lexicon)) {
128
- return { warning: `lexicon "${lexicon}" is not one vowel can build a record for (supported: ${SUPPORTED.join(", ")})` }
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
- // The key is the page's canonical URL - origin and path - and frozen
139
- // (rkey.js), whatever `site` holds.
140
- const rkey = createRkey(new URL(String(built.record.path), origin).href)
141
- return { atUri: `at://${did}/${lexicon}/${rkey}`, record: built.record }
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 { DOCUMENT, PUBLICATION, SUPPORTED, lexiconFor, siteOrigin, publicationURI, documentRecord, pageRecord }
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}`))