vowel 0.6.4 → 0.7.0-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.
- package/README.md +1 -1
- package/bin.js +47 -27
- package/cli.js +15 -0
- package/config.js +93 -26
- package/extractDate.js +70 -6
- package/index.js +14 -5
- package/package.json +39 -8
- package/plugins/assets/index.js +65 -0
- package/plugins/atproto/index.js +132 -0
- package/plugins/atproto/password.js +71 -0
- package/plugins/atproto/publish.js +406 -0
- package/plugins/atproto/record.js +144 -0
- package/plugins/atproto/rkey.js +54 -0
- package/plugins/deploy-cloudflare/index.js +30 -16
- package/plugins/fonts/index.js +18 -21
- package/plugins/html/assets/bundle-BeK1Hptv.css +21 -0
- package/plugins/html/bundle/assets/index-BeK1Hptv.css +21 -0
- package/plugins/html/bundle/index.js +21145 -0
- package/plugins/html/editor/EditButton.svelte +152 -0
- package/plugins/html/editor/Editor.svelte +51 -0
- package/plugins/html/editor/Overlays.svelte +6 -0
- package/plugins/html/editor/SettingsPanel.svelte +397 -0
- package/plugins/html/editor/Toolbar.svelte +644 -0
- package/plugins/html/editor/create-session.js +250 -0
- package/plugins/html/editor/directives.js +128 -0
- package/plugins/html/editor/frontmatter.js +97 -0
- package/plugins/html/editor/ingest.js +339 -0
- package/plugins/html/editor/main.js +94 -0
- package/plugins/html/editor/nodes/Alert.svelte +21 -0
- package/plugins/html/editor/nodes/Blockquote.svelte +9 -0
- package/plugins/html/editor/nodes/CodeBlock.svelte +19 -0
- package/plugins/html/editor/nodes/Embed.svelte +19 -0
- package/plugins/html/editor/nodes/Emphasis.svelte +11 -0
- package/plugins/html/editor/nodes/Heading.svelte +15 -0
- package/plugins/html/editor/nodes/Highlight.svelte +11 -0
- package/plugins/html/editor/nodes/Image.svelte +18 -0
- package/plugins/html/editor/nodes/InlineCode.svelte +11 -0
- package/plugins/html/editor/nodes/Link.svelte +17 -0
- package/plugins/html/editor/nodes/List.svelte +15 -0
- package/plugins/html/editor/nodes/ListItem.svelte +9 -0
- package/plugins/html/editor/nodes/Page.svelte +21 -0
- package/plugins/html/editor/nodes/Paragraph.svelte +9 -0
- package/plugins/html/editor/nodes/Strikethrough.svelte +11 -0
- package/plugins/html/editor/nodes/Strong.svelte +11 -0
- package/plugins/html/editor/nodes/ThematicBreak.svelte +9 -0
- package/plugins/html/editor/nodes/index.js +18 -0
- package/plugins/html/editor/rollup.config.js +35 -0
- package/plugins/html/editor/serialize.js +157 -0
- package/plugins/html/editor/settings-file.js +83 -0
- package/plugins/html/editor/socket.js +202 -0
- package/plugins/html/editor/source.js +43 -0
- package/plugins/html/image.js +31 -5
- package/plugins/html/index.js +430 -458
- package/plugins/html/partials.js +299 -0
- package/plugins/html/socialLinks.js +67 -0
- package/plugins/html/writeRules.js +377 -0
- package/plugins/icons/index.js +2 -6
- package/plugins/images/index.js +78 -55
- package/plugins/markdown/brandColors.js +10 -0
- package/plugins/markdown/colorScheme.js +64 -0
- package/plugins/markdown/emoji.js +30 -0
- package/plugins/markdown/frontmatter.js +253 -0
- package/plugins/markdown/index.js +276 -334
- package/plugins/markdown/links.js +97 -0
- package/plugins/markdown/metadata.js +229 -68
- package/plugins/markdown/readRules.js +143 -0
- package/plugins/markdown/typography.js +109 -0
- package/plugins/markdown/visitor.js +143 -0
- package/plugins/publish/index.js +34 -0
- package/plugins/robots/agents.js +79 -0
- package/plugins/robots/index.js +30 -10
- package/plugins/robots/robots.js +48 -0
- package/plugins/styles/index.js +111 -18
- package/plugins/styles/theme.js +116 -0
- package/plugins/urls/index.js +49 -62
- package/plugins/urls/urlMetadata.js +246 -0
- package/plugins/vectors/index.js +5 -9
- package/plugins/xml/entryContent.js +103 -0
- package/plugins/xml/index.js +105 -43
- package/secretPaths.js +170 -0
- package/stylesheets/DefaultStyles.css +367 -437
- package/stylesheets/ResetStyles.css +2 -38
- package/stylesheets/SyntaxHighlightingStyles.css +36 -4
- package/stylesheets/TypographyStyles.css +386 -128
- package/systemPaths.js +48 -0
- package/utils.js +56 -5
- package/.github/ISSUE_TEMPLATE/bug_report.md +0 -73
- package/.github/ISSUE_TEMPLATE/config.yml +0 -7
- package/docs-source/.cache.json +0 -9
- package/docs-source/.obsidian/app.json +0 -3
- package/docs-source/.obsidian/appearance.json +0 -3
- package/docs-source/.obsidian/core-plugins-migration.json +0 -30
- package/docs-source/.obsidian/core-plugins.json +0 -20
- package/docs-source/.obsidian/workspace.json +0 -168
- package/docs-source/.stackblitzrc +0 -3
- package/docs-source/.votive.db +0 -0
- package/docs-source/about.md +0 -3
- package/docs-source/blog/home.md +0 -5
- package/docs-source/blog/url-ui.md +0 -21
- package/docs-source/blog.md +0 -5
- package/docs-source/docs/.votive.db +0 -0
- package/docs-source/docs/deploy.md +0 -67
- package/docs-source/docs/file-structure.md +0 -31
- package/docs-source/docs/folder-settings.md +0 -23
- package/docs-source/docs/home.md +0 -55
- package/docs-source/docs/images.md +0 -10
- package/docs-source/docs/items.md +0 -13
- package/docs-source/docs/pages.md +0 -141
- package/docs-source/docs/settings.md +0 -4
- package/docs-source/docs/styling.md +0 -34
- package/docs-source/docs/taxonomies.md +0 -37
- package/docs-source/features/cards.md +0 -7
- package/docs-source/features/editing.md +0 -7
- package/docs-source/features/emoji.md +0 -7
- package/docs-source/features/frontmatter.md +0 -7
- package/docs-source/features/lists.md +0 -7
- package/docs-source/features/navigation.md +0 -7
- package/docs-source/features/rich-previews.md +0 -7
- package/docs-source/features/robots.md +0 -7
- package/docs-source/features/rss.md +0 -7
- package/docs-source/features/sitemap.md +0 -7
- package/docs-source/features/speed.md +0 -7
- package/docs-source/features/static.md +0 -7
- package/docs-source/features/taxonomies.md +0 -7
- package/docs-source/home.md +0 -42
- package/docs-source/output/about.html +0 -1
- package/docs-source/output/blog/url-ui.html +0 -8
- package/docs-source/output/blog.html +0 -1
- package/docs-source/output/default.css +0 -1
- package/docs-source/output/docs/deploy.html +0 -34
- package/docs-source/output/docs/file-structure.html +0 -24
- package/docs-source/output/docs/folder-settings.html +0 -20
- package/docs-source/output/docs/images.html +0 -2
- package/docs-source/output/docs/items.html +0 -6
- package/docs-source/output/docs/pages.html +0 -101
- package/docs-source/output/docs/styling.html +0 -18
- package/docs-source/output/docs/taxonomies.html +0 -20
- package/docs-source/output/docs.html +0 -28
- package/docs-source/output/features/cards.html +0 -1
- package/docs-source/output/features/editing.html +0 -1
- package/docs-source/output/features/emoji.html +0 -1
- package/docs-source/output/features/frontmatter.html +0 -1
- package/docs-source/output/features/lists.html +0 -1
- package/docs-source/output/features/navigation.html +0 -1
- package/docs-source/output/features/rich-previews.html +0 -1
- package/docs-source/output/features/robots.html +0 -1
- package/docs-source/output/features/rss.html +0 -1
- package/docs-source/output/features/sitemap.html +0 -1
- package/docs-source/output/features/speed.html +0 -1
- package/docs-source/output/features/static.html +0 -1
- package/docs-source/output/features/taxonomies.html +0 -1
- package/docs-source/output/features.html +0 -1
- package/docs-source/output/feed.xml +0 -1
- package/docs-source/output/index.html +0 -21
- package/docs-source/output/reset.css +0 -1
- package/docs-source/output/roadmap.html +0 -87
- package/docs-source/output/robots.txt +0 -14
- package/docs-source/output/sitemap.xml +0 -16
- package/docs-source/output/typography.css +0 -1
- package/docs-source/roadmap.md +0 -98
- package/docs-source/settings.md +0 -13
- package/getMetadata.js +0 -41
- package/jsconfig.json +0 -10
- package/plugins/markdown/robots.js +0 -18
- package/regex.js +0 -36
- package/stylesheets/brand-colors.css +0 -119
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import path from "node:path"
|
|
2
|
+
import { pageRecord, publicationURI, siteOrigin } from "./record.js"
|
|
3
|
+
import { publishToAtproto } from "./publish.js"
|
|
4
|
+
import { saveAppPassword } from "./password.js"
|
|
5
|
+
|
|
6
|
+
/** @import * as Votive from "votive" */
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* AT Protocol, phase one: the site's domain becomes the account's handle.
|
|
10
|
+
*
|
|
11
|
+
* An AT Protocol account is a DID (`did:plc:…`), and a domain becomes its
|
|
12
|
+
* handle when the domain proves it points at that DID - one of the two
|
|
13
|
+
* proofs is serving the DID as plain text at `/.well-known/atproto-did`.
|
|
14
|
+
* That is a file, so it is a stub: declared when settings.md says
|
|
15
|
+
*
|
|
16
|
+
* atproto_did: did:plc:abc123
|
|
17
|
+
*
|
|
18
|
+
* and gone, file and all, when it stops saying so. Deploying the site is
|
|
19
|
+
* then the verification. Nothing here talks to the network; the author
|
|
20
|
+
* pastes the DID from their account settings. Resolving it from a handle
|
|
21
|
+
* is a later phase, through readURL like any other fetch.
|
|
22
|
+
*
|
|
23
|
+
* Phase two: a page under an `atproto_lexicon:` gets an AT-URI and a record,
|
|
24
|
+
* computed by `transformFile` with no network (record.js), and a
|
|
25
|
+
* `<link rel="site.standard.document">` from the html plugin. Publishing
|
|
26
|
+
* is a command, `atproto-publish` (publish.js), which makes the account's
|
|
27
|
+
* records match the site. See tasks/2-in-progress/atproto-integration.md.
|
|
28
|
+
*
|
|
29
|
+
* The publication: with a `domain` too, the site is a
|
|
30
|
+
* `site.standard.publication`, every document's `site` names it, and
|
|
31
|
+
* `/.well-known/site.standard.publication` serves its AT-URI - the
|
|
32
|
+
* domain's claim to it. The record is the publish command's.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const WELL_KNOWN = path.join(".well-known", "atproto-did")
|
|
36
|
+
const WELL_KNOWN_PUBLICATION = path.join(".well-known", "site.standard.publication")
|
|
37
|
+
|
|
38
|
+
/** A DID is `did:<method>:<id>`; that is all this checks. */
|
|
39
|
+
function validDID(value) {
|
|
40
|
+
return typeof value === "string" && /^did:[a-z]+:[A-Za-z0-9._:%-]+$/.test(value)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The account, from the nearest settings.md that names one. The atproto
|
|
45
|
+
* settings are flat, `atproto_did` / `atproto_lexicon` / `atproto_pds`,
|
|
46
|
+
* so each cascades on its own: a folder that sets only a lexicon keeps
|
|
47
|
+
* the root's account. Not reserved, so each arrives as `fm_atproto_*`.
|
|
48
|
+
* @param {{ lastNonNull: (label: string) => unknown }} settings
|
|
49
|
+
*/
|
|
50
|
+
function atprotoDID(settings) {
|
|
51
|
+
return settings.lastNonNull("fm_atproto_did")
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The page's AT-URI and record, stored as metadata for the html plugin's
|
|
56
|
+
* link and the publish command. Runs once per read of the page; nothing
|
|
57
|
+
* here reaches the network. A page missing a property its lexicon
|
|
58
|
+
* requires is not a record (mentioned under `verbose`); an unsupported
|
|
59
|
+
* lexicon is warned about.
|
|
60
|
+
*/
|
|
61
|
+
function transformFile(target, { settings, config }) {
|
|
62
|
+
if (target.write === false) return undefined
|
|
63
|
+
const did = atprotoDID(settings)
|
|
64
|
+
if (!validDID(did)) return undefined
|
|
65
|
+
|
|
66
|
+
const domain = settings.lastNonNull("fm_domain")
|
|
67
|
+
const result = pageRecord(target, { did, domain, settings })
|
|
68
|
+
if ("skip" in result) return undefined
|
|
69
|
+
if ("incomplete" in result) {
|
|
70
|
+
config.log("info", `${target.path}: ${result.incomplete}`)
|
|
71
|
+
return undefined
|
|
72
|
+
}
|
|
73
|
+
if ("warning" in result) {
|
|
74
|
+
config.log("warn", `${target.path}: ${result.warning}`)
|
|
75
|
+
return undefined
|
|
76
|
+
}
|
|
77
|
+
return { metadata: { atUri: result.atUri, atprotoRecord: result.record } }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** @type {Votive.ProcessorStubs} */
|
|
81
|
+
function createStubs({ settings }) {
|
|
82
|
+
const did = atprotoDID(settings)
|
|
83
|
+
if (!did) return []
|
|
84
|
+
if (!validDID(did)) {
|
|
85
|
+
throw new Error(`settings.md: atproto_did must look like did:plc:… or did:web:…, not ${JSON.stringify(did)}`)
|
|
86
|
+
}
|
|
87
|
+
const origin = siteOrigin(settings.lastNonNull("fm_domain"))
|
|
88
|
+
const publication = origin ? [{ path: WELL_KNOWN_PUBLICATION, params: { text: publicationURI(did, origin) } }] : []
|
|
89
|
+
return [{ path: WELL_KNOWN, params: { text: did } }, ...publication]
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** @type {Votive.ProcessorExpand} */
|
|
93
|
+
function expandStubs({ params }) {
|
|
94
|
+
return { text: params.text }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Claims files with **no extension**, which is what a well-known file
|
|
99
|
+
* is, and `.publication`, which `site.standard.publication` looks like
|
|
100
|
+
* one to. The scan never hands this processor a real file - dot-folders
|
|
101
|
+
* are skipped - so only the stubs arrive; a file elsewhere in the project
|
|
102
|
+
* with either (a LICENSE) is read here and routed nowhere.
|
|
103
|
+
* @type {Votive.VotiveProcessor}
|
|
104
|
+
*/
|
|
105
|
+
const wellKnownProcessor = {
|
|
106
|
+
extensions: ["", ".publication"],
|
|
107
|
+
format: "text",
|
|
108
|
+
router: ({ dir, name, ext }) => dir.includes(".well-known") ? { dir, name, ext } : false,
|
|
109
|
+
createStubs,
|
|
110
|
+
expandStubs,
|
|
111
|
+
readFile: (source) => ({ data: source.text, metadata: {} }),
|
|
112
|
+
writeFile: (target) => ({ data: target.data })
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const recordProcessor = {
|
|
116
|
+
extensions: [".html"],
|
|
117
|
+
format: "text",
|
|
118
|
+
transformFile
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** @type {Votive.VotivePlugin} */
|
|
122
|
+
const vowelAtprotoPlugin = {
|
|
123
|
+
name: "vowel-atproto",
|
|
124
|
+
processors: [wellKnownProcessor, recordProcessor],
|
|
125
|
+
commands: {
|
|
126
|
+
"atproto-publish": publishToAtproto,
|
|
127
|
+
"atproto-password": saveAppPassword
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export default vowelAtprotoPlugin
|
|
132
|
+
export { validDID, atprotoDID }
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import { password as promptPassword, isCancel } from "@clack/prompts"
|
|
4
|
+
import votive from "votive"
|
|
5
|
+
import { passwordFile, resolvePDS, xrpc } from "./publish.js"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `vowel --command atproto-password`: saves the account's app password
|
|
9
|
+
* where `atproto-publish` looks for it (publish.js's `passwordFile`).
|
|
10
|
+
*
|
|
11
|
+
* Asked for with a masked prompt, so it never lands in shell history. With
|
|
12
|
+
* no terminal to ask in - a Publish button, CI - it comes from the
|
|
13
|
+
* payload, `{"password": "…"}`.
|
|
14
|
+
*
|
|
15
|
+
* With an `atproto_did` in the root settings.md, the password is checked
|
|
16
|
+
* by logging in, and a password for another account, or none, is refused
|
|
17
|
+
* before anything is written. Without one it is saved unchecked, and
|
|
18
|
+
* said so. The file is readable by its owner only.
|
|
19
|
+
*
|
|
20
|
+
* @param {{ password?: string } | undefined} payload
|
|
21
|
+
* @param {{ config: import("votive").VotiveConfig, notify: (message: object) => void }} context
|
|
22
|
+
*/
|
|
23
|
+
async function saveAppPassword(payload, { config, notify }) {
|
|
24
|
+
const given = typeof payload?.password === "string" ? payload.password.trim() : ""
|
|
25
|
+
const password = given || await askForPassword()
|
|
26
|
+
|
|
27
|
+
const { did, pds: configuredPDS } = await account(config)
|
|
28
|
+
const verified = typeof did === "string" && did !== ""
|
|
29
|
+
if (verified) {
|
|
30
|
+
const pds = typeof configuredPDS === "string" && configuredPDS ? configuredPDS : await resolvePDS(did)
|
|
31
|
+
notify({ status: "progress", message: `Checking the password with ${pds}...` })
|
|
32
|
+
const session = await xrpc(pds, "com.atproto.server.createSession", { body: { identifier: did, password } })
|
|
33
|
+
if (session.did !== did) throw new Error(`atproto: that app password is for ${session.did}, not ${did}; nothing saved`)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const file = passwordFile(config.sourceFolder)
|
|
37
|
+
await mkdir(path.dirname(file), { recursive: true })
|
|
38
|
+
await writeFile(file, password, { mode: 0o600 })
|
|
39
|
+
|
|
40
|
+
const checked = verified ? `checked against ${did}` : "not checked: no atproto_did in settings.md"
|
|
41
|
+
notify({ status: "done", message: `Saved the app password to ${file} (${checked})` })
|
|
42
|
+
return { saved: file, verified }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The masked prompt, or an error saying what to pass instead. */
|
|
46
|
+
async function askForPassword() {
|
|
47
|
+
if (!process.stdin.isTTY) {
|
|
48
|
+
throw new Error("atproto: no terminal to ask in; pass the app password as {\"password\": \"…\"}")
|
|
49
|
+
}
|
|
50
|
+
const answer = await promptPassword({ message: "App password (from your Bluesky settings)" })
|
|
51
|
+
if (isCancel(answer) || !answer.trim()) throw new Error("atproto: no app password given; nothing saved")
|
|
52
|
+
return answer.trim()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The root's `atproto_did` and `atproto_pds`, from a build of the site -
|
|
57
|
+
* settings are the database's to resolve.
|
|
58
|
+
* @param {import("votive").VotiveConfig} config
|
|
59
|
+
*/
|
|
60
|
+
async function account(config) {
|
|
61
|
+
const site = await votive({ ...config, verbose: false })
|
|
62
|
+
try {
|
|
63
|
+
await site.build({ defer: false })
|
|
64
|
+
const root = site.database.setting.getByFolder("")
|
|
65
|
+
return { did: root.lastNonNull("fm_atproto_did"), pds: root.lastNonNull("fm_atproto_pds") }
|
|
66
|
+
} finally {
|
|
67
|
+
await site.close()
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export { saveAppPassword }
|
|
@@ -0,0 +1,406 @@
|
|
|
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 { DOCUMENT, PUBLICATION, siteOrigin, publicationURI } from "./record.js"
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `vowel --command atproto-publish`: make the account's records match the
|
|
12
|
+
* site.
|
|
13
|
+
*
|
|
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.
|
|
22
|
+
*
|
|
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.
|
|
28
|
+
*
|
|
29
|
+
* The build uses a throwaway database, so every page is read and every
|
|
30
|
+
* transform runs against current settings. A transform only re-runs when
|
|
31
|
+
* its source is re-read (tasks/1-proposed/transform-reruns-on-stale.md),
|
|
32
|
+
* so a warm database could hold records built from old settings.
|
|
33
|
+
*
|
|
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.
|
|
43
|
+
*
|
|
44
|
+
* The app password comes from `ATPROTO_APP_PASSWORD`, or from a file
|
|
45
|
+
* named `atproto-app-password` in the project's system directory, never
|
|
46
|
+
* from the project. Payload: `{"dryRun": true}` reports what would change
|
|
47
|
+
* and sends nothing, and does not need the password. `{"optional": true}`
|
|
48
|
+
* makes a site with no `atproto_did` a skip rather than an error, for
|
|
49
|
+
* the `publish` command, which runs this after every deploy.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
const PASSWORD_FILE = "atproto-app-password"
|
|
53
|
+
const PAGE_SIZE = 100
|
|
54
|
+
const BATCH_SIZE = 100
|
|
55
|
+
const TIMEOUT = 30_000
|
|
56
|
+
const ICON_SIZE = 512
|
|
57
|
+
// The lexicon's maxSize for `icon`.
|
|
58
|
+
const ICON_MAX_BYTES = 1_000_000
|
|
59
|
+
const BASE32 = "abcdefghijklmnopqrstuvwxyz234567"
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* RFC 4648 base32, lowercase, unpadded: the multibase `b` alphabet.
|
|
63
|
+
* @param {Uint8Array} bytes
|
|
64
|
+
*/
|
|
65
|
+
function base32(bytes) {
|
|
66
|
+
const bits = [...bytes].map(byte => byte.toString(2).padStart(8, "0")).join("")
|
|
67
|
+
const padded = bits.padEnd(Math.ceil(bits.length / 5) * 5, "0")
|
|
68
|
+
return padded.match(/.{5}/g).map(chunk => BASE32[parseInt(chunk, 2)]).join("")
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The CID a PDS gives a blob: CIDv1, raw codec (0x55), sha2-256.
|
|
73
|
+
* @param {Uint8Array} bytes
|
|
74
|
+
*/
|
|
75
|
+
function blobCID(bytes) {
|
|
76
|
+
const digest = createHash("sha256").update(bytes).digest()
|
|
77
|
+
return "b" + base32(Buffer.concat([Buffer.from([0x01, 0x55, 0x12, 0x20]), digest]))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The publication's icon: a 512px square PNG, the image contained in it
|
|
82
|
+
* on transparency, and the blob reference a PDS will give it. An SVG is
|
|
83
|
+
* rasterised at a density high enough to stay sharp at that size.
|
|
84
|
+
* @param {Buffer} source
|
|
85
|
+
* @param {string} iconPath - for the error message
|
|
86
|
+
*/
|
|
87
|
+
async function iconBlob(source, iconPath) {
|
|
88
|
+
const bytes = await sharp(source, { density: 300 })
|
|
89
|
+
.resize(ICON_SIZE, ICON_SIZE, { fit: "contain", background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
|
90
|
+
.png()
|
|
91
|
+
.toBuffer()
|
|
92
|
+
if (bytes.length > ICON_MAX_BYTES) {
|
|
93
|
+
throw new Error(`atproto: the publication icon ${iconPath} is ${bytes.length} bytes as a ${ICON_SIZE}px PNG; the limit is ${ICON_MAX_BYTES}`)
|
|
94
|
+
}
|
|
95
|
+
const ref = { $type: "blob", ref: { $link: blobCID(bytes) }, mimeType: "image/png", size: bytes.length }
|
|
96
|
+
return { bytes, ref }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* A value with its object keys sorted, so two records compare by content
|
|
101
|
+
* and not by key order.
|
|
102
|
+
* @param {unknown} value
|
|
103
|
+
* @returns {unknown}
|
|
104
|
+
*/
|
|
105
|
+
function canonical(value) {
|
|
106
|
+
if (Array.isArray(value)) return value.map(canonical)
|
|
107
|
+
if (!value || typeof value !== "object") return value
|
|
108
|
+
const keys = Object.keys(value).sort()
|
|
109
|
+
return Object.fromEntries(keys.map(key => [key, canonical(value[key])]))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** @param {unknown} a @param {unknown} b */
|
|
113
|
+
function sameRecord(a, b) {
|
|
114
|
+
return JSON.stringify(canonical(a)) === JSON.stringify(canonical(b))
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* One XRPC call. An error response throws with the PDS's own error name
|
|
119
|
+
* and message, which is the only useful part of a failed write.
|
|
120
|
+
* @param {string} pds
|
|
121
|
+
* @param {string} nsid
|
|
122
|
+
* `bytes` is a raw body (uploadBlob) of type `mimeType`; `body` is JSON.
|
|
123
|
+
* @param {{ query?: Record<string, string>, body?: unknown, bytes?: Uint8Array, mimeType?: string, token?: string }} [options]
|
|
124
|
+
*/
|
|
125
|
+
async function xrpc(pds, nsid, { query, body, bytes, mimeType, token } = {}) {
|
|
126
|
+
const url = new URL(`/xrpc/${nsid}`, pds)
|
|
127
|
+
const params = new URLSearchParams(query ?? {})
|
|
128
|
+
url.search = params.toString()
|
|
129
|
+
const payload = bytes ?? (body === undefined ? undefined : JSON.stringify(body))
|
|
130
|
+
const headers = {
|
|
131
|
+
...(bytes ? { "content-type": mimeType } : body === undefined ? {} : { "content-type": "application/json" }),
|
|
132
|
+
...(token ? { authorization: `Bearer ${token}` } : {})
|
|
133
|
+
}
|
|
134
|
+
const response = await fetch(url, {
|
|
135
|
+
method: payload === undefined ? "GET" : "POST",
|
|
136
|
+
headers,
|
|
137
|
+
body: payload,
|
|
138
|
+
signal: AbortSignal.timeout(TIMEOUT)
|
|
139
|
+
})
|
|
140
|
+
const text = await response.text()
|
|
141
|
+
const json = text ? JSON.parse(text) : {}
|
|
142
|
+
if (response.ok) return json
|
|
143
|
+
throw new Error(`${nsid}: ${response.status} ${json.error ?? ""} ${json.message ?? ""}`.trim())
|
|
144
|
+
}
|
|
145
|
+
|
|
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
|
+
/**
|
|
168
|
+
* Where the app password is kept: the project's system directory, never
|
|
169
|
+
* the project, so it is neither committed nor deployed.
|
|
170
|
+
* @param {string} sourceFolder
|
|
171
|
+
*/
|
|
172
|
+
function passwordFile(sourceFolder) {
|
|
173
|
+
return path.join(systemDirectoryFor(sourceFolder), PASSWORD_FILE)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** @param {string} sourceFolder */
|
|
177
|
+
async function appPassword(sourceFolder) {
|
|
178
|
+
if (process.env.ATPROTO_APP_PASSWORD) return process.env.ATPROTO_APP_PASSWORD
|
|
179
|
+
const file = passwordFile(sourceFolder)
|
|
180
|
+
const text = await readFile(file, "utf-8").catch(() => "")
|
|
181
|
+
if (text.trim()) return text.trim()
|
|
182
|
+
throw new Error(`atproto: no app password. Set ATPROTO_APP_PASSWORD, or put one in ${file}`)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Every record in `collection`, a page at a time.
|
|
187
|
+
* @param {string} pds
|
|
188
|
+
* @param {string} did
|
|
189
|
+
* @param {string} collection
|
|
190
|
+
* @param {string} [cursor]
|
|
191
|
+
* @returns {Promise<{ uri: string, value: any }[]>}
|
|
192
|
+
*/
|
|
193
|
+
async function listAll(pds, did, collection, cursor) {
|
|
194
|
+
const query = { repo: did, collection, limit: String(PAGE_SIZE), ...(cursor ? { cursor } : {}) }
|
|
195
|
+
const page = await xrpc(pds, "com.atproto.repo.listRecords", { query })
|
|
196
|
+
const records = page.records ?? []
|
|
197
|
+
if (!page.cursor || !records.length) return records
|
|
198
|
+
const rest = await listAll(pds, did, collection, page.cursor)
|
|
199
|
+
return [...records, ...rest]
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** The last segment of an AT-URI. @param {string} uri */
|
|
203
|
+
const rkeyOf = (uri) => uri.slice(uri.lastIndexOf("/") + 1)
|
|
204
|
+
|
|
205
|
+
/**
|
|
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.
|
|
211
|
+
* @param {import("votive").VotiveConfig} config
|
|
212
|
+
*/
|
|
213
|
+
async function collectRecords(config) {
|
|
214
|
+
const scratch = await mkdtemp(path.join(tmpdir(), "vowel-atproto-"))
|
|
215
|
+
try {
|
|
216
|
+
const site = await votive({
|
|
217
|
+
...config,
|
|
218
|
+
verbose: false,
|
|
219
|
+
databasePath: path.join(scratch, ".votive.db"),
|
|
220
|
+
targetFolder: path.join(scratch, "output")
|
|
221
|
+
})
|
|
222
|
+
try {
|
|
223
|
+
await (await site.build({ defer: false })).deferred
|
|
224
|
+
const root = site.database.setting.getByFolder("")
|
|
225
|
+
const did = root.lastNonNull("fm_atproto_did")
|
|
226
|
+
const pds = root.lastNonNull("fm_atproto_pds")
|
|
227
|
+
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 }))
|
|
231
|
+
|
|
232
|
+
const settings = root.lastNonNull("fm_atproto_publication") ?? {}
|
|
233
|
+
const home = site.database.target.get("index.html")
|
|
234
|
+
const name = root.raw("fm_name")?.[0]?.at(-1) ?? (home?.source ? home.metadata.title : undefined)
|
|
235
|
+
const iconPath = settings.icon ?? root.lastNonNull("logo")
|
|
236
|
+
const icon = iconPath ? await readIcon(path.join(scratch, "output"), iconPath) : undefined
|
|
237
|
+
const publication = { name, description: settings.description, iconPath, icon }
|
|
238
|
+
return { did, pds, domain, records, publication }
|
|
239
|
+
} finally {
|
|
240
|
+
await site.close()
|
|
241
|
+
}
|
|
242
|
+
} finally {
|
|
243
|
+
await rm(scratch, { recursive: true, force: true })
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* The icon's bytes from the build output. A URL is not fetched: the icon
|
|
249
|
+
* has to be a file in the project.
|
|
250
|
+
* @param {string} output
|
|
251
|
+
* @param {string} iconPath - a URL path from the root, e.g. "/logo.svg"
|
|
252
|
+
*/
|
|
253
|
+
async function readIcon(output, iconPath) {
|
|
254
|
+
if (/^[a-z]+:/i.test(iconPath)) {
|
|
255
|
+
throw new Error(`atproto: the publication icon must be a file in the project, not ${iconPath}`)
|
|
256
|
+
}
|
|
257
|
+
const file = path.join(output, ...iconPath.split("/").filter(Boolean))
|
|
258
|
+
return readFile(file).catch(() => {
|
|
259
|
+
throw new Error(`atproto: the publication icon ${iconPath} is not in the built site`)
|
|
260
|
+
})
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* The publication's write: create, update or unchanged, against what the
|
|
265
|
+
* account holds at its key.
|
|
266
|
+
* @param {{ uri: string, value: any }[]} remote - the account's publications
|
|
267
|
+
* @param {string} rkey
|
|
268
|
+
* @param {Record<string, unknown>} value
|
|
269
|
+
*/
|
|
270
|
+
function planPublication(remote, rkey, value) {
|
|
271
|
+
const existing = remote.find(record => rkeyOf(record.uri) === rkey)
|
|
272
|
+
if (!existing) return { action: "create", rkey, value }
|
|
273
|
+
if (sameRecord(existing.value, value)) return { action: "unchanged", rkey }
|
|
274
|
+
return { action: "update", rkey, value }
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
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
|
|
282
|
+
*/
|
|
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 }
|
|
294
|
+
})
|
|
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
|
+
|
|
300
|
+
return [...compared, ...deletions]
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** @param {{ action: string, rkey: string, value?: unknown, collection?: string }} write */
|
|
304
|
+
function applyWrite({ action, rkey, value, collection = DOCUMENT }) {
|
|
305
|
+
const base = { $type: `com.atproto.repo.applyWrites#${action}`, collection, rkey }
|
|
306
|
+
return action === "delete" ? base : { ...base, value }
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* @param {{ dryRun?: boolean, optional?: boolean } | undefined} payload
|
|
311
|
+
* @param {{ config: import("votive").VotiveConfig, notify: (message: object) => void }} context
|
|
312
|
+
*/
|
|
313
|
+
async function publishToAtproto(payload, { config, notify }) {
|
|
314
|
+
const dryRun = Boolean(payload?.dryRun)
|
|
315
|
+
|
|
316
|
+
notify({ status: "progress", message: "Building..." })
|
|
317
|
+
const { did, pds: configuredPDS, domain, records, publication } = await collectRecords(config)
|
|
318
|
+
|
|
319
|
+
if ((typeof did !== "string" || !did) && payload?.optional) {
|
|
320
|
+
notify({ status: "done", message: "No atproto_did in settings.md; nothing to publish to AT Protocol" })
|
|
321
|
+
return { skipped: true }
|
|
322
|
+
}
|
|
323
|
+
if (typeof did !== "string" || !did) throw new Error("atproto: set atproto_did in the root settings.md")
|
|
324
|
+
const origin = siteOrigin(domain)
|
|
325
|
+
if (!origin) throw new Error("atproto: set domain in the root settings.md; every record carries the site's url")
|
|
326
|
+
|
|
327
|
+
const accounts = new Set(records.map(record => record.uri.split("/")[2]))
|
|
328
|
+
const others = [...accounts].filter(account => account !== did)
|
|
329
|
+
if (others.length) throw new Error(`atproto: pages name ${others.join(", ")}; one site publishes to one account (${did})`)
|
|
330
|
+
|
|
331
|
+
if (typeof publication.name !== "string" || !publication.name) {
|
|
332
|
+
throw new Error("atproto: set name in the root settings.md; the publication needs one")
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const icon = publication.icon ? await iconBlob(publication.icon, publication.iconPath) : undefined
|
|
336
|
+
const publicationRecord = {
|
|
337
|
+
$type: PUBLICATION,
|
|
338
|
+
url: origin,
|
|
339
|
+
name: publication.name,
|
|
340
|
+
...(typeof publication.description === "string" && publication.description ? { description: publication.description } : {}),
|
|
341
|
+
...(icon ? { icon: icon.ref } : {})
|
|
342
|
+
}
|
|
343
|
+
const publicationURL = publicationURI(did, origin)
|
|
344
|
+
|
|
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
|
+
const pds = typeof configuredPDS === "string" && configuredPDS ? configuredPDS : await resolvePDS(did)
|
|
349
|
+
|
|
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)
|
|
355
|
+
|
|
356
|
+
const count = (action) => plan.filter(write => write.action === action).length
|
|
357
|
+
const summary = {
|
|
358
|
+
created: count("create"),
|
|
359
|
+
updated: count("update"),
|
|
360
|
+
deleted: count("delete"),
|
|
361
|
+
unchanged: count("unchanged"),
|
|
362
|
+
collisions: plan.filter(write => write.action === "collision").map(({ rkey, site }) => ({ rkey, site })),
|
|
363
|
+
publication: publicationWrite.action,
|
|
364
|
+
dryRun
|
|
365
|
+
}
|
|
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
|
+
|
|
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)
|
|
376
|
+
if (dryRun || !writes.length) {
|
|
377
|
+
notify({ status: "done", message: `${dryRun ? "Would send" : "Nothing to send"}: ${JSON.stringify(summary)}` })
|
|
378
|
+
return summary
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const password = await appPassword(config.sourceFolder)
|
|
382
|
+
const session = await xrpc(pds, "com.atproto.server.createSession", { body: { identifier: did, password } })
|
|
383
|
+
if (session.did !== did) throw new Error(`atproto: the app password is for ${session.did}, not ${did}`)
|
|
384
|
+
|
|
385
|
+
// The icon is uploaded only when the publication is written - which is
|
|
386
|
+
// when its reference changed, or the record is new.
|
|
387
|
+
if (icon && publicationWrites.length) {
|
|
388
|
+
notify({ status: "progress", message: "Uploading the publication icon..." })
|
|
389
|
+
const uploaded = await xrpc(pds, "com.atproto.repo.uploadBlob", { bytes: icon.bytes, mimeType: icon.ref.mimeType, token: session.accessJwt })
|
|
390
|
+
if (uploaded.blob?.ref?.$link !== icon.ref.ref.$link) {
|
|
391
|
+
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`)
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const batches = Array.from({ length: Math.ceil(writes.length / BATCH_SIZE) }, (_, index) =>
|
|
396
|
+
writes.slice(index * BATCH_SIZE, (index + 1) * BATCH_SIZE))
|
|
397
|
+
for (const [index, batch] of batches.entries()) {
|
|
398
|
+
notify({ status: "progress", message: `Sending batch ${index + 1} of ${batches.length}...` })
|
|
399
|
+
await xrpc(pds, "com.atproto.repo.applyWrites", { body: { repo: did, writes: batch }, token: session.accessJwt })
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
notify({ status: "done", message: `Published: ${JSON.stringify(summary)}` })
|
|
403
|
+
return summary
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export { publishToAtproto, planWrites, planPublication, sameRecord, resolvePDS, blobCID, xrpc, passwordFile }
|