astro-atmosphere-publishing 0.1.1 → 0.2.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/CHANGELOG.md +7 -0
- package/README.md +16 -1
- package/bin/cli.mjs +2 -2
- package/index.d.ts +6 -0
- package/lib/entries.mjs +9 -0
- package/lib/existing.mjs +80 -0
- package/lib/publish.mjs +56 -11
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.0 (8 November 2026)
|
|
4
|
+
|
|
5
|
+
- **Posts you've already announced keep their announcement.** `announcement:` in a post's frontmatter (an AT URI or a bsky.app address) names a Bluesky post that already announces it, made by hand, by a release bot or by another tool. The post is never announced again: its record points to that post (`bskyPostRef`), so comments, likes and reposts carry on in the same thread. A collection's `announcement` function maps a field of your own instead (`(post) => post.data.bskyPost`).
|
|
6
|
+
- The preview lists these as "linked to existing announcement". A post that's by another account, deleted or mistyped is reported as a problem for that post only, and everything else carries on. `announce:` and `announcement:` on the same post is an error.
|
|
7
|
+
- Naming a different post later moves the record to it, and nothing is deleted on Bluesky. The preview notes posts that share one announcement.
|
|
8
|
+
- Nothing changes for existing sites: no state file change, and no records are rewritten.
|
|
9
|
+
|
|
3
10
|
## 0.1.1 (7 November 2026)
|
|
4
11
|
|
|
5
12
|
- **@mentions, #hashtags and links in announcements work**, as in the Bluesky app: they become links, and mentioned accounts are notified. The preview lists what each announcement will link.
|
package/README.md
CHANGED
|
@@ -114,6 +114,21 @@ Set it for the whole site (`share` in the settings), a collection, or one post (
|
|
|
114
114
|
- @mentions, #hashtags and links in the wording work as in the Bluesky app: they become links, and mentioned accounts are notified. The preview lists what each announcement will link.
|
|
115
115
|
- Each post is announced once.
|
|
116
116
|
|
|
117
|
+
### Posts you've already announced
|
|
118
|
+
|
|
119
|
+
Posts announced on Bluesky before your site used this package (by hand, by a release bot or by another tool) can keep that announcement, and the comments, likes and reposts on it. Name it in the post's frontmatter, as an AT URI or a bsky.app address:
|
|
120
|
+
|
|
121
|
+
```yaml
|
|
122
|
+
announcement: "https://bsky.app/profile/example.com/post/3kbbgdr267n2b"
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
The post is never announced again. Instead its record points to that post (`bskyPostRef`), so Atmosphere Comments shows that thread under the article. The preview lists it as "linked to existing announcement".
|
|
126
|
+
|
|
127
|
+
- The announcement must be by your publishing account. A post that's by another account, deleted or mistyped is reported in the preview, and published without an announcement (or keeps the one it had), while everything else carries on.
|
|
128
|
+
- A post can't have both `announce:` and `announcement:`: one makes a new announcement, the other uses one you already made.
|
|
129
|
+
- Naming a different post later moves the record to it. Nothing is ever deleted on Bluesky.
|
|
130
|
+
- If your posts already keep the address in a field of their own, map it in the collection rather than renaming it: `announcement: (post) => post.data.bskyPost`.
|
|
131
|
+
|
|
117
132
|
## With Atmosphere Comments for Astro
|
|
118
133
|
|
|
119
134
|
Comments find each post's announcement through its `<link>` tag, so the comment box needs no `post` prop. For the Subscribe button, hand the publication over in one line:
|
|
@@ -174,7 +189,7 @@ Commit the state file even when publishing fails part-way: it's saved after ever
|
|
|
174
189
|
| `site` | required | Your site's address. |
|
|
175
190
|
| `account` | required | The publishing account's DID. |
|
|
176
191
|
| `publication.name`, `.description`, `.icon` | name required | The publication record. |
|
|
177
|
-
| `collections` | required | One or more: `files` (a glob) and `path` (a function), plus optional overrides: `id`, `title`, `description`, `share`, `textContent`, `tags`, `publishedAt`, `updatedAt`, `cover`, `draft
|
|
192
|
+
| `collections` | required | One or more: `files` (a glob) and `path` (a function), plus optional overrides: `id`, `title`, `description`, `share`, `textContent`, `tags`, `publishedAt`, `updatedAt`, `cover`, `draft`, `archived` (posts never treated as new, such as ones imported from an old site) and `announcement` (a Bluesky post that already announces the post; see "Posts you've already announced"). Each takes the post (`id`, `file`, `folder`, `data` for the frontmatter, `body`). |
|
|
178
193
|
| `share` | default `"more"` | How much of each post goes into its record (see "How much of each post is shared"). |
|
|
179
194
|
| `announceWithinDays` | default 14 | How recent a post must be to count as new. |
|
|
180
195
|
| `langs` | default `["en"]` | The announcements' languages. |
|
package/bin/cli.mjs
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// there is read too, if present.
|
|
10
10
|
import { existsSync } from "node:fs";
|
|
11
11
|
import { loadConfig } from "../lib/config.mjs";
|
|
12
|
-
import { describe, linkedFor, plan, publish } from "../lib/publish.mjs";
|
|
12
|
+
import { describe, isNotice, linkedFor, plan, publish } from "../lib/publish.mjs";
|
|
13
13
|
import { publicAgent, reader } from "../lib/account.mjs";
|
|
14
14
|
import { saveState } from "../lib/state.mjs";
|
|
15
15
|
import { syncState } from "../lib/sync.mjs";
|
|
@@ -23,7 +23,7 @@ async function main() {
|
|
|
23
23
|
if (command === "preview") {
|
|
24
24
|
const result = await plan(config);
|
|
25
25
|
const lines = describe(result, config, undefined, await linkedFor(result, publicAgent()));
|
|
26
|
-
if (!lines.some((line) => !line
|
|
26
|
+
if (!lines.some((line) => !isNotice(line))) {
|
|
27
27
|
for (const line of lines) console.log(` • ${line}`);
|
|
28
28
|
console.log("Everything is already published and up to date.");
|
|
29
29
|
return;
|
package/index.d.ts
CHANGED
|
@@ -38,6 +38,12 @@ export type CollectionSettings = {
|
|
|
38
38
|
draft?: (post: Entry) => boolean;
|
|
39
39
|
/** Posts that are never "new" (imported from an old site, say), so they're only announced when they ask. */
|
|
40
40
|
archived?: (post: Entry) => boolean;
|
|
41
|
+
/**
|
|
42
|
+
* A Bluesky post that already announces this post (an AT URI or a bsky.app
|
|
43
|
+
* address), made by hand or another tool. The post is linked to it and never
|
|
44
|
+
* announced again. Default: the frontmatter's `announcement`.
|
|
45
|
+
*/
|
|
46
|
+
announcement?: (post: Entry) => string | undefined;
|
|
41
47
|
};
|
|
42
48
|
|
|
43
49
|
export type Share = "more" | "intro" | "full" | "none";
|
package/lib/entries.mjs
CHANGED
|
@@ -43,6 +43,14 @@ export async function readEntries(config) {
|
|
|
43
43
|
const share = entry.data.share ?? collection.share ?? config.share;
|
|
44
44
|
if (!SHARE_OPTIONS.includes(share)) throw new Error(`${relative}: share must be one of ${SHARE_OPTIONS.join(", ")}.`);
|
|
45
45
|
const cover = (collection.cover ?? ((e) => e.data.heroImage ?? e.data.cover ?? e.data.image))(entry);
|
|
46
|
+
// An existing Bluesky post to link as the announcement (lib/existing.mjs).
|
|
47
|
+
const announcement = (collection.announcement ?? ((e) => e.data.announcement))(entry) || undefined;
|
|
48
|
+
if (announcement !== undefined && entry.data.announce !== undefined) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`${relative}: has both announce: and an existing announcement (${announcement}). ` +
|
|
51
|
+
"Keep announcement: to link the post you already made, or announce: to make a new one, not both.",
|
|
52
|
+
);
|
|
53
|
+
}
|
|
46
54
|
// The field order matters: it is part of each record's fingerprint.
|
|
47
55
|
posts.push({
|
|
48
56
|
id: entry.id,
|
|
@@ -58,6 +66,7 @@ export async function readEntries(config) {
|
|
|
58
66
|
coverFile: typeof cover === "string" ? path.resolve(entry.folder, cover) : null,
|
|
59
67
|
archived: Boolean(collection.archived?.(entry)),
|
|
60
68
|
announce: entry.data.announce,
|
|
69
|
+
announcement,
|
|
61
70
|
source: relative,
|
|
62
71
|
});
|
|
63
72
|
}
|
package/lib/existing.mjs
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Posts announced before the site used this package (by hand, by a release bot
|
|
2
|
+
// or by another tool) can name that Bluesky post as their announcement, with
|
|
3
|
+
// `announcement:` in their frontmatter (an AT URI or a bsky.app address). It is
|
|
4
|
+
// never announced again: the post is linked to it, so its record points to that
|
|
5
|
+
// thread (`bskyPostRef`) and comments, likes and reposts carry on there.
|
|
6
|
+
const POST = "app.bsky.feed.post";
|
|
7
|
+
const AT_URI = /^at:\/\/([^/]+)\/app\.bsky\.feed\.post\/([a-zA-Z0-9._:~-]+)$/;
|
|
8
|
+
const BSKY_URL = /^https:\/\/bsky\.app\/profile\/([^/]+)\/post\/([a-zA-Z0-9._:~-]+)\/?$/;
|
|
9
|
+
|
|
10
|
+
/** The repo (DID or handle) and record key of an AT URI or bsky.app post address, or null. */
|
|
11
|
+
export function parseAnnouncement(value) {
|
|
12
|
+
const text = String(value).trim();
|
|
13
|
+
const match = text.match(AT_URI) ?? text.match(BSKY_URL);
|
|
14
|
+
return match ? { repo: decodeURIComponent(match[1]), rkey: match[2] } : null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** True when the stored announcement is already the one the post names, so nothing needs looking up. */
|
|
18
|
+
function alreadyLinked(value, stored) {
|
|
19
|
+
if (!stored) return false;
|
|
20
|
+
const text = String(value).trim().replace(/\/+$/, "");
|
|
21
|
+
return text === stored.uri || text === stored.url;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Looks up every post's `announcement:` that isn't already linked, through
|
|
26
|
+
* Bluesky's public service. Returns, by post id, either `{ link }` (the
|
|
27
|
+
* announcement to store: `uri`, `cid`, `url`) or `{ problem }` (why that post
|
|
28
|
+
* can't be linked; the rest of the run carries on).
|
|
29
|
+
*/
|
|
30
|
+
export async function resolveExisting(posts, state, config, agent) {
|
|
31
|
+
const found = {};
|
|
32
|
+
const wanted = [];
|
|
33
|
+
for (const post of posts) {
|
|
34
|
+
if (post.announcement === undefined || post.announcement === null || post.announcement === "") continue;
|
|
35
|
+
if (alreadyLinked(post.announcement, state.announcements[post.id])) continue;
|
|
36
|
+
const parsed = parseAnnouncement(post.announcement);
|
|
37
|
+
if (!parsed) {
|
|
38
|
+
found[post.id] = { problem: `announcement: "${post.announcement}" isn't a Bluesky post's address (at://… or https://bsky.app/profile/…/post/…)` };
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
wanted.push({ post, ...parsed });
|
|
42
|
+
}
|
|
43
|
+
if (!wanted.length) return found;
|
|
44
|
+
|
|
45
|
+
// Handles in bsky.app addresses become DIDs.
|
|
46
|
+
const dids = new Map();
|
|
47
|
+
for (const { repo } of wanted) {
|
|
48
|
+
if (repo.startsWith("did:") || dids.has(repo)) continue;
|
|
49
|
+
try {
|
|
50
|
+
dids.set(repo, (await agent.resolveHandle({ handle: repo })).data.did);
|
|
51
|
+
} catch {
|
|
52
|
+
dids.set(repo, null);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const uriOf = ({ repo, rkey }) => {
|
|
56
|
+
const did = repo.startsWith("did:") ? repo : dids.get(repo);
|
|
57
|
+
return did ? `at://${did}/${POST}/${rkey}` : null;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const uris = [...new Set(wanted.map(uriOf).filter(Boolean))];
|
|
61
|
+
const fetched = new Map();
|
|
62
|
+
for (let start = 0; start < uris.length; start += 25) {
|
|
63
|
+
const { data } = await agent.app.bsky.feed.getPosts({ uris: uris.slice(start, start + 25) });
|
|
64
|
+
for (const item of data.posts) fetched.set(item.uri, item);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
for (const entry of wanted) {
|
|
68
|
+
const { post, repo } = entry;
|
|
69
|
+
const uri = uriOf(entry);
|
|
70
|
+
const item = uri && fetched.get(uri);
|
|
71
|
+
if (!uri) found[post.id] = { problem: `announcement: couldn't find the account @${repo}` };
|
|
72
|
+
else if (!item) found[post.id] = { problem: `announcement: ${post.announcement} doesn't exist (deleted, or the address is wrong)` };
|
|
73
|
+
else if (item.author.did !== config.account) {
|
|
74
|
+
found[post.id] = { problem: `announcement: ${post.announcement} is by @${item.author.handle}, not the publishing account (${config.account})` };
|
|
75
|
+
} else {
|
|
76
|
+
found[post.id] = { link: { uri: item.uri, cid: item.cid, url: `https://bsky.app/profile/${item.author.handle}/post/${entry.rkey}` } };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return found;
|
|
80
|
+
}
|
package/lib/publish.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { STANDARD_SITE, isEmpty, readState, saveState } from "./state.mjs";
|
|
|
6
6
|
import { RichText } from "@atproto/api";
|
|
7
7
|
import { publicAgent, signIn } from "./account.mjs";
|
|
8
8
|
import { syncState } from "./sync.mjs";
|
|
9
|
+
import { resolveExisting } from "./existing.mjs";
|
|
9
10
|
import {
|
|
10
11
|
DOCUMENT,
|
|
11
12
|
PUBLICATION,
|
|
@@ -60,37 +61,66 @@ export const isNew = (post, config) =>
|
|
|
60
61
|
export const announcementDate = (post, config) => (isNew(post, config) ? new Date() : post.publishedAt);
|
|
61
62
|
|
|
62
63
|
export function wantsAnnouncement(post, state, config) {
|
|
64
|
+
// A post that names an existing announcement is linked to it, never announced.
|
|
65
|
+
if (post.announcement !== undefined) return false;
|
|
63
66
|
if (post.announce === false) return false;
|
|
64
67
|
if (state.announcements[post.id]) return false;
|
|
65
68
|
if (post.announce !== undefined) return true;
|
|
66
69
|
return isNew(post, config);
|
|
67
70
|
}
|
|
68
71
|
|
|
69
|
-
/**
|
|
70
|
-
export
|
|
72
|
+
/** Lines that only inform, rather than list something to publish. */
|
|
73
|
+
export const isNotice = (line) => line.startsWith("note:") || line.startsWith("problem:");
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* What would change, without changing anything. `agent` looks up posts that
|
|
77
|
+
* name an existing announcement (Bluesky's public service will do).
|
|
78
|
+
*/
|
|
79
|
+
export async function plan(config, state = readState(config.stateFile), agent = publicAgent()) {
|
|
71
80
|
const posts = await readEntries(config);
|
|
81
|
+
const existing = await resolveExisting(posts, state, config, agent);
|
|
72
82
|
const site = state.formats[STANDARD_SITE];
|
|
73
83
|
const pubHash = await publicationHash(config);
|
|
74
84
|
const publicationChange = !site.publication ? "create" : site.publication.hash !== pubHash ? "update" : null;
|
|
75
85
|
const changes = [];
|
|
86
|
+
const problems = [];
|
|
76
87
|
for (const post of posts) {
|
|
77
|
-
const
|
|
88
|
+
const document = site.documents[post.id];
|
|
78
89
|
const announce = wantsAnnouncement(post, state, config);
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
90
|
+
// A newly named existing announcement replaces what's stored; one that
|
|
91
|
+
// can't be used leaves the stored one (if any) as it is.
|
|
92
|
+
const { link, problem } = existing[post.id] ?? {};
|
|
93
|
+
if (problem) problems.push({ post, problem });
|
|
94
|
+
const announcement = link ?? state.announcements[post.id] ?? null;
|
|
95
|
+
const hash = await documentHash(post, site.publication?.uri ?? "", announcement);
|
|
96
|
+
if (!document) changes.push({ post, action: "create", announce, link });
|
|
97
|
+
else if (announce || link || document.hash !== hash) changes.push({ post, action: "update", announce, link });
|
|
82
98
|
}
|
|
83
99
|
const known = new Set(posts.map((post) => post.id));
|
|
84
100
|
const orphans = Object.keys(site.documents).filter((id) => !known.has(id));
|
|
85
|
-
|
|
101
|
+
// Posts linked to the same announcement, which is allowed but usually a slip.
|
|
102
|
+
const byUri = new Map();
|
|
103
|
+
for (const post of posts) {
|
|
104
|
+
const uri = (existing[post.id]?.link ?? state.announcements[post.id])?.uri;
|
|
105
|
+
if (uri) byUri.set(uri, [...(byUri.get(uri) ?? []), post.id]);
|
|
106
|
+
}
|
|
107
|
+
const shared = [...byUri].filter(([, ids]) => ids.length > 1);
|
|
108
|
+
return { posts, state, pubHash, publicationChange, changes, problems, shared, orphans };
|
|
86
109
|
}
|
|
87
110
|
|
|
88
111
|
/** The plan in plain words, as the preview prints it. `linked` lists, by post id, what each announcement will link. */
|
|
89
112
|
export function describe(result, config, handle, linked = {}) {
|
|
90
113
|
const lines = [];
|
|
91
114
|
if (result.publicationChange) lines.push(`${result.publicationChange} the publication record (${config.site})`);
|
|
92
|
-
for (const { post, action, announce } of result.changes) {
|
|
115
|
+
for (const { post, action, announce, link } of result.changes) {
|
|
93
116
|
lines.push(`${action} the document for ${post.path}`);
|
|
117
|
+
if (link) {
|
|
118
|
+
const previous = result.state.announcements[post.id];
|
|
119
|
+
lines.push(
|
|
120
|
+
`linked to existing announcement: ${link.url}` +
|
|
121
|
+
(previous ? ` (was ${previous.url}; nothing is deleted on Bluesky)` : " (not announced again)"),
|
|
122
|
+
);
|
|
123
|
+
}
|
|
94
124
|
if (!announce) continue;
|
|
95
125
|
const text = announcementText(post);
|
|
96
126
|
lines.push(
|
|
@@ -107,6 +137,15 @@ export function describe(result, config, handle, linked = {}) {
|
|
|
107
137
|
" announce: false skips the announcement.",
|
|
108
138
|
);
|
|
109
139
|
}
|
|
140
|
+
for (const { post, problem } of result.problems) {
|
|
141
|
+
const kept = result.state.announcements[post.id];
|
|
142
|
+
lines.push(
|
|
143
|
+
`problem: ${post.source}: ${problem}. ` +
|
|
144
|
+
(kept ? `It keeps its current announcement (${kept.url})` : "Its record is published without an announcement") +
|
|
145
|
+
"; everything else carries on.",
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
for (const [uri, ids] of result.shared) lines.push(`note: ${ids.join(" and ")} have the same announcement (${uri}), so they share one comment thread`);
|
|
110
149
|
for (const id of result.orphans) lines.push(`note: ${id} has a record but no post (or is now a draft); it was left alone`);
|
|
111
150
|
return lines;
|
|
112
151
|
}
|
|
@@ -131,9 +170,9 @@ export async function publish(config, log = console.log) {
|
|
|
131
170
|
state = await syncState(config, agent, log);
|
|
132
171
|
await saveState(config.stateFile, state);
|
|
133
172
|
}
|
|
134
|
-
const result = await plan(config, state);
|
|
173
|
+
const result = await plan(config, state, agent);
|
|
135
174
|
const lines = describe(result, config, agent.session.handle, await linkedFor(result, agent));
|
|
136
|
-
if (!lines.some((line) => !line
|
|
175
|
+
if (!lines.some((line) => !isNotice(line))) {
|
|
137
176
|
for (const line of lines) log(` • ${line}`);
|
|
138
177
|
log("Everything is already published and up to date.");
|
|
139
178
|
return result;
|
|
@@ -160,7 +199,13 @@ export async function publish(config, log = console.log) {
|
|
|
160
199
|
const publicationUri = site.publication.uri;
|
|
161
200
|
|
|
162
201
|
// Announcing first, so the document record can point at the Bluesky thread.
|
|
163
|
-
for (const { post, announce } of result.changes) {
|
|
202
|
+
for (const { post, announce, link } of result.changes) {
|
|
203
|
+
if (link) {
|
|
204
|
+
// Stored exactly as if it had been announced here.
|
|
205
|
+
state.announcements[post.id] = link;
|
|
206
|
+
await saveState(config.stateFile, state);
|
|
207
|
+
log(` ✓ linked to existing announcement: ${link.url}`);
|
|
208
|
+
}
|
|
164
209
|
const cover = post.coverFile ? await upload(await coverImage(post.coverFile), "image/jpeg") : undefined;
|
|
165
210
|
if (announce) {
|
|
166
211
|
const rich = await richTextOf(announcementText(post), agent);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "astro-atmosphere-publishing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Publish your Astro site to the Atmosphere with Standard.site: records for every post, Bluesky announcements, and the tags that connect each page to them. Pairs with Atmosphere Comments for Astro.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|