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 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` and `archived` (posts never treated as new, such as ones imported from an old site). Each takes the post (`id`, `file`, `folder`, `data` for the frontmatter, `body`). |
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.startsWith("note:"))) {
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
  }
@@ -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
- /** What would change, without changing anything. */
70
- export async function plan(config, state = readState(config.stateFile)) {
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 existing = site.documents[post.id];
88
+ const document = site.documents[post.id];
78
89
  const announce = wantsAnnouncement(post, state, config);
79
- const hash = await documentHash(post, site.publication?.uri ?? "", state.announcements[post.id] ?? null);
80
- if (!existing) changes.push({ post, action: "create", announce });
81
- else if (announce || existing.hash !== hash) changes.push({ post, action: "update", announce });
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
- return { posts, state, pubHash, publicationChange, changes, orphans };
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.startsWith("note:"))) {
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.1.1",
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",