astro-atmosphere-publishing 0.1.0 → 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,6 +1,18 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.0 (unreleased)
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
+
10
+ ## 0.1.1 (7 November 2026)
11
+
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.
13
+ - **`share` setting:** how much of each post goes into its record's searchable text: `"more"` (default, as before: everything above `<!--more-->`, or the whole post without a marker), `"intro"` (the first paragraph when there's no marker), `"full"` or `"none"`. Set it for the site, a collection, or a post (`share:` in its frontmatter). Nothing changes for existing sites.
14
+
15
+ ## 0.1.0 (7 November 2026)
4
16
 
5
17
  First release, built from saltedlolly.com's own publishing script.
6
18
 
package/README.md CHANGED
@@ -92,14 +92,43 @@ npx atmosphere-publishing publish # do it
92
92
 
93
93
  `publish` needs `ATPROTO_HANDLE` and `ATPROTO_APP_PASSWORD`, from the environment or a `.env` file. Then commit `src/data/atmosphere-publishing.json`, build and deploy.
94
94
 
95
+ ## How much of each post is shared
96
+
97
+ Each record carries the post's title, description, tags, dates and cover image, plus searchable text: always plain text, with no formatting, images or link addresses. Reader apps link to your site to read the post. The `share` setting decides how much text that is:
98
+
99
+ | `share` | Searchable text |
100
+ | --- | --- |
101
+ | `"more"` (default) | Everything above `<!--more-->`, or the whole post when there's no marker |
102
+ | `"intro"` | Everything above `<!--more-->`, or just the first paragraph when there's no marker |
103
+ | `"full"` | The whole post |
104
+ | `"none"` | None: the record keeps its title and description |
105
+
106
+ Set it for the whole site (`share` in the settings), a collection, or one post (`share: full` in its frontmatter).
107
+
95
108
  ## Announcements
96
109
 
97
110
  - **New posts** (published within `announceWithinDays`, default 14) are announced, dated now.
98
111
  - The announcement's text is the post's own wording, `announce: "…"` in its frontmatter, shown above the link card. **Write it for every post:** the card already shows the title and description, so say something new. Without it, the announcement is the link card alone.
99
112
  - **Older posts** are announced only when they ask (`announce: "…"`, or `announce: true` for the card alone). Their announcement is dated to the post itself, so Bluesky files it in the past and it doesn't reach followers' timelines, but it still gives the post a comment thread.
100
113
  - `announce: false` never announces a post.
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.
101
115
  - Each post is announced once.
102
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
+
103
132
  ## With Atmosphere Comments for Astro
104
133
 
105
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:
@@ -160,7 +189,8 @@ Commit the state file even when publishing fails part-way: it's saved after ever
160
189
  | `site` | required | Your site's address. |
161
190
  | `account` | required | The publishing account's DID. |
162
191
  | `publication.name`, `.description`, `.icon` | name required | The publication record. |
163
- | `collections` | required | One or more: `files` (a glob) and `path` (a function), plus optional overrides: `id`, `title`, `description`, `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`). |
193
+ | `share` | default `"more"` | How much of each post goes into its record (see "How much of each post is shared"). |
164
194
  | `announceWithinDays` | default 14 | How recent a post must be to count as new. |
165
195
  | `langs` | default `["en"]` | The announcements' languages. |
166
196
 
@@ -168,11 +198,11 @@ Commit the state file even when publishing fails part-way: it's saved after ever
168
198
 
169
199
  - Node 22.13 or later, Astro 5 or later.
170
200
  - Markdown (`.md`, `.mdx`) posts. Collections from other loaders (a CMS, say) aren't supported yet.
171
- - Records carry plain text for search, not the full formatted post.
201
+ - Records carry plain text for search (see `share`), not the full formatted post.
172
202
 
173
203
  ## Saying thanks
174
204
 
175
- It's free and open source (MIT). If it's useful, a link to [the post about it on saltedlolly.com](https://saltedlolly.com) helps other developers find it. You can also support the work through [GitHub Sponsors](https://github.com/sponsors/saltedlolly) or [Ko-fi](https://ko-fi.com/saltedlolly).
205
+ It's free and open source (MIT). If it's useful, a link to [the post about it on saltedlolly.com](https://saltedlolly.com/atmosphere-publishing-for-astro/) helps other developers find it. You can also support the work through [GitHub Sponsors](https://github.com/sponsors/saltedlolly) or [Ko-fi](https://ko-fi.com/saltedlolly).
176
206
 
177
207
  ## License
178
208
 
package/bin/cli.mjs CHANGED
@@ -9,8 +9,8 @@
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, plan, publish } from "../lib/publish.mjs";
13
- import { reader } from "../lib/account.mjs";
12
+ import { describe, isNotice, linkedFor, plan, publish } from "../lib/publish.mjs";
13
+ import { publicAgent, reader } from "../lib/account.mjs";
14
14
  import { saveState } from "../lib/state.mjs";
15
15
  import { syncState } from "../lib/sync.mjs";
16
16
 
@@ -21,8 +21,9 @@ async function main() {
21
21
  if (existsSync(".env")) process.loadEnvFile(".env");
22
22
  const config = await loadConfig();
23
23
  if (command === "preview") {
24
- const lines = describe(await plan(config), config);
25
- if (!lines.some((line) => !line.startsWith("note:"))) {
24
+ const result = await plan(config);
25
+ const lines = describe(result, config, undefined, await linkedFor(result, publicAgent()));
26
+ if (!lines.some((line) => !isNotice(line))) {
26
27
  for (const line of lines) console.log(` • ${line}`);
27
28
  console.log("Everything is already published and up to date.");
28
29
  return;
package/index.d.ts CHANGED
@@ -23,7 +23,7 @@ export type CollectionSettings = {
23
23
  title?: (post: Entry) => string;
24
24
  /** Default: the frontmatter's description, or the first paragraph. */
25
25
  description?: (post: Entry) => string;
26
- /** Plain text for search. Default: the text above <!--more-->, or the whole post. */
26
+ /** Plain text for search, replacing `share`'s choice. */
27
27
  textContent?: (post: Entry) => string;
28
28
  tags?: (post: Entry) => string[];
29
29
  /** Default: pubDate, publishDate or date. */
@@ -32,12 +32,22 @@ export type CollectionSettings = {
32
32
  updatedAt?: (post: Entry) => string | Date | undefined;
33
33
  /** The cover image file, relative to the post's folder. Default: heroImage, cover or image. */
34
34
  cover?: (post: Entry) => string | undefined;
35
+ /** How much text this collection's records share (see AtmospherePublishingConfig.share). A post's own `share:` overrides it. */
36
+ share?: Share;
35
37
  /** Default: the frontmatter's draft. Drafts are never published. */
36
38
  draft?: (post: Entry) => boolean;
37
39
  /** Posts that are never "new" (imported from an old site, say), so they're only announced when they ask. */
38
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;
39
47
  };
40
48
 
49
+ export type Share = "more" | "intro" | "full" | "none";
50
+
41
51
  export type AtmospherePublishingConfig = {
42
52
  /** Your site's address, e.g. "https://example.com". */
43
53
  site: string;
@@ -50,6 +60,14 @@ export type AtmospherePublishingConfig = {
50
60
  icon?: string;
51
61
  };
52
62
  collections: CollectionSettings[];
63
+ /**
64
+ * How much of each post goes into its record's searchable text (always plain text):
65
+ * "more" (default) everything above <!--more-->, or the whole post when there's no marker;
66
+ * "intro" everything above <!--more-->, or the first paragraph when there's no marker;
67
+ * "full" the whole post; "none" nothing (the record keeps its title and description).
68
+ * A collection's `share`, or a post's own `share:` in its frontmatter, overrides it.
69
+ */
70
+ share?: Share;
53
71
  /** Posts published within this many days are announced as new. Default 14. */
54
72
  announceWithinDays?: number;
55
73
  /** The announcements' languages. Default ["en"]. */
package/lib/account.mjs CHANGED
@@ -33,6 +33,9 @@ export async function signIn(account) {
33
33
  return agent;
34
34
  }
35
35
 
36
+ /** Bluesky's public service, for looking up handles without signing in (the preview's @mentions). */
37
+ export const publicAgent = () => new AtpAgent({ service: "https://public.api.bsky.app" });
38
+
36
39
  /** An agent for reading the account's public records, without signing in. */
37
40
  export async function reader(account) {
38
41
  return new AtpAgent({ service: await hostOf(account) });
package/lib/config.mjs CHANGED
@@ -4,6 +4,7 @@
4
4
  import { existsSync } from "node:fs";
5
5
  import path from "node:path";
6
6
  import { pathToFileURL } from "node:url";
7
+ import { SHARE_OPTIONS } from "./text.mjs";
7
8
 
8
9
  export const CONFIG_FILE = "atmosphere-publishing.config.mjs";
9
10
  export const STATE_FILE = "src/data/atmosphere-publishing.json";
@@ -27,6 +28,7 @@ export async function loadConfig(root = process.cwd()) {
27
28
  if (!DID.test(config?.account ?? "")) problems.push("`account` must be the DID of the account that publishes (did:plc:… or did:web:…)");
28
29
  if (!config?.publication?.name) problems.push("`publication.name` is required");
29
30
  if (!config?.collections?.length) problems.push("`collections` must list at least one collection");
31
+ if (config?.share !== undefined && !SHARE_OPTIONS.includes(config.share)) problems.push(`\`share\` must be one of ${SHARE_OPTIONS.join(", ")}`);
30
32
  for (const [index, collection] of (config?.collections ?? []).entries()) {
31
33
  if (!collection.files) problems.push(`collections[${index}].files must be a glob such as "src/content/blog/**/*.md"`);
32
34
  if (typeof collection.path !== "function") problems.push(`collections[${index}].path must be a function giving each post's address`);
@@ -39,6 +41,7 @@ export async function loadConfig(root = process.cwd()) {
39
41
  publication: { description: "", ...config.publication },
40
42
  collections: config.collections,
41
43
  announceWithinDays: config.announceWithinDays ?? 14,
44
+ share: config.share ?? "more",
42
45
  langs: config.langs ?? ["en"],
43
46
  stateFile: path.join(root, STATE_FILE),
44
47
  };
package/lib/entries.mjs CHANGED
@@ -4,7 +4,7 @@
4
4
  import { glob, readFile } from "node:fs/promises";
5
5
  import path from "node:path";
6
6
  import { parse as parseYaml } from "yaml";
7
- import { firstParagraph, plainParagraphs, previewMarkdown } from "./text.mjs";
7
+ import { SHARE_OPTIONS, firstParagraph, sharedText } from "./text.mjs";
8
8
 
9
9
  const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
10
10
 
@@ -39,7 +39,18 @@ export async function readEntries(config) {
39
39
  const publishedAt = dateOf((collection.publishedAt ?? ((e) => e.data.pubDate ?? e.data.publishDate ?? e.data.date))(entry));
40
40
  if (!publishedAt || Number.isNaN(publishedAt.valueOf())) throw new Error(`${relative}: no publication date (pubDate, publishDate or date).`);
41
41
  const postPath = collection.path({ ...entry, publishedAt });
42
+ // How much text the record shares: the post's own `share:`, then the collection's, then the site's.
43
+ const share = entry.data.share ?? collection.share ?? config.share;
44
+ if (!SHARE_OPTIONS.includes(share)) throw new Error(`${relative}: share must be one of ${SHARE_OPTIONS.join(", ")}.`);
42
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
+ }
43
54
  // The field order matters: it is part of each record's fingerprint.
44
55
  posts.push({
45
56
  id: entry.id,
@@ -47,7 +58,7 @@ export async function readEntries(config) {
47
58
  path: postPath,
48
59
  title: (collection.title ?? ((e) => e.data.title))(entry),
49
60
  description: (collection.description ?? ((e) => e.data.description || firstParagraph(e.body)))(entry),
50
- textContent: (collection.textContent ?? ((e) => plainParagraphs(previewMarkdown(e.body)).join("\n\n")))(entry),
61
+ textContent: (collection.textContent ?? ((e) => sharedText(e.body, share)))(entry),
51
62
  tags: (collection.tags ?? ((e) => e.data.tags ?? []))(entry),
52
63
  publishedAt,
53
64
  updatedAt: dateOf((collection.updatedAt ?? ((e) => e.data.updatedDate ?? e.data.updatedAt))(entry)),
@@ -55,6 +66,7 @@ export async function readEntries(config) {
55
66
  coverFile: typeof cover === "string" ? path.resolve(entry.folder, cover) : null,
56
67
  archived: Boolean(collection.archived?.(entry)),
57
68
  announce: entry.data.announce,
69
+ announcement,
58
70
  source: relative,
59
71
  });
60
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
@@ -3,8 +3,10 @@
3
3
  // posts (or older posts that ask), which becomes the post's comment thread.
4
4
  import { readEntries } from "./entries.mjs";
5
5
  import { STANDARD_SITE, isEmpty, readState, saveState } from "./state.mjs";
6
- import { signIn } from "./account.mjs";
6
+ import { RichText } from "@atproto/api";
7
+ import { publicAgent, signIn } from "./account.mjs";
7
8
  import { syncState } from "./sync.mjs";
9
+ import { resolveExisting } from "./existing.mjs";
8
10
  import {
9
11
  DOCUMENT,
10
12
  PUBLICATION,
@@ -20,6 +22,33 @@ const rkeyOf = (uri) => uri.split("/").pop();
20
22
  /** The post's own `announce: "…"` wording, or none: the link card alone already shows its title and introduction. */
21
23
  export const announcementText = (post) => (typeof post.announce === "string" ? post.announce : "");
22
24
 
25
+ /**
26
+ * The announcement's text with its @mentions, #hashtags and links marked, as
27
+ * the Bluesky app does, so they're links and mentioned accounts are notified.
28
+ * A mention of a handle that doesn't exist stays plain text.
29
+ */
30
+ export async function richTextOf(text, agent) {
31
+ const rich = new RichText({ text });
32
+ try {
33
+ await rich.detectFacets(agent);
34
+ } catch {
35
+ rich.detectFacetsWithoutResolution();
36
+ }
37
+ // A handle that couldn't be found leaves a mention with no account: Bluesky
38
+ // rejects those, so they stay plain text.
39
+ rich.facets = (rich.facets ?? []).filter(
40
+ (facet) => !facet.features.some((feature) => feature.$type === "app.bsky.richtext.facet#mention" && !feature.did),
41
+ );
42
+ return rich;
43
+ }
44
+
45
+ /** What the announcement's text will link, for the preview. */
46
+ export function linkedParts(rich, text) {
47
+ const bytes = new TextEncoder().encode(text);
48
+ const decode = (start, end) => new TextDecoder().decode(bytes.slice(start, end));
49
+ return (rich.facets ?? []).map((facet) => decode(facet.index.byteStart, facet.index.byteEnd));
50
+ }
51
+
23
52
  /** New posts are announced with today's date, so followers see them. */
24
53
  export const isNew = (post, config) =>
25
54
  !post.archived && (Date.now() - post.publishedAt.valueOf()) / 86_400_000 <= config.announceWithinDays;
@@ -32,37 +61,66 @@ export const isNew = (post, config) =>
32
61
  export const announcementDate = (post, config) => (isNew(post, config) ? new Date() : post.publishedAt);
33
62
 
34
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;
35
66
  if (post.announce === false) return false;
36
67
  if (state.announcements[post.id]) return false;
37
68
  if (post.announce !== undefined) return true;
38
69
  return isNew(post, config);
39
70
  }
40
71
 
41
- /** What would change, without changing anything. */
42
- 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()) {
43
80
  const posts = await readEntries(config);
81
+ const existing = await resolveExisting(posts, state, config, agent);
44
82
  const site = state.formats[STANDARD_SITE];
45
83
  const pubHash = await publicationHash(config);
46
84
  const publicationChange = !site.publication ? "create" : site.publication.hash !== pubHash ? "update" : null;
47
85
  const changes = [];
86
+ const problems = [];
48
87
  for (const post of posts) {
49
- const existing = site.documents[post.id];
88
+ const document = site.documents[post.id];
50
89
  const announce = wantsAnnouncement(post, state, config);
51
- const hash = await documentHash(post, site.publication?.uri ?? "", state.announcements[post.id] ?? null);
52
- if (!existing) changes.push({ post, action: "create", announce });
53
- 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 });
54
98
  }
55
99
  const known = new Set(posts.map((post) => post.id));
56
100
  const orphans = Object.keys(site.documents).filter((id) => !known.has(id));
57
- 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 };
58
109
  }
59
110
 
60
- /** The plan in plain words, as the preview prints it. */
61
- export function describe(result, config, handle) {
111
+ /** The plan in plain words, as the preview prints it. `linked` lists, by post id, what each announcement will link. */
112
+ export function describe(result, config, handle, linked = {}) {
62
113
  const lines = [];
63
114
  if (result.publicationChange) lines.push(`${result.publicationChange} the publication record (${config.site})`);
64
- for (const { post, action, announce } of result.changes) {
115
+ for (const { post, action, announce, link } of result.changes) {
65
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
+ }
66
124
  if (!announce) continue;
67
125
  const text = announcementText(post);
68
126
  lines.push(
@@ -74,14 +132,34 @@ export function describe(result, config, handle) {
74
132
  (text ? text.split("\n").map((line) => ` │ ${line}`).join("\n") : " │ (no announce: wording, so no text: just the link card)") +
75
133
  `\n │ [link card: ${post.title} — ${post.url}]\n` +
76
134
  " └────────────────────────────────────────\n" +
135
+ (text ? ` Linked: ${linked[post.id]?.length ? linked[post.id].join(", ") : "nothing (no @mentions, #hashtags or links found)"}\n` : "") +
77
136
  ` To change the wording, add announce: "…" to ${post.source};\n` +
78
137
  " announce: false skips the announcement.",
79
138
  );
80
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`);
81
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`);
82
150
  return lines;
83
151
  }
84
152
 
153
+ /** What each planned announcement will link, by post id (mentions are checked with `agent`). */
154
+ export async function linkedFor(result, agent) {
155
+ const linked = {};
156
+ for (const { post, announce } of result.changes) {
157
+ const text = announcementText(post);
158
+ if (announce && text) linked[post.id] = linkedParts(await richTextOf(text, agent), text);
159
+ }
160
+ return linked;
161
+ }
162
+
85
163
  /** Publishes everything the plan lists. Saves the state file after each record, so an interruption loses nothing. */
86
164
  export async function publish(config, log = console.log) {
87
165
  const agent = await signIn(config.account);
@@ -92,9 +170,9 @@ export async function publish(config, log = console.log) {
92
170
  state = await syncState(config, agent, log);
93
171
  await saveState(config.stateFile, state);
94
172
  }
95
- const result = await plan(config, state);
96
- const lines = describe(result, config, agent.session.handle);
97
- if (!lines.some((line) => !line.startsWith("note:"))) {
173
+ const result = await plan(config, state, agent);
174
+ const lines = describe(result, config, agent.session.handle, await linkedFor(result, agent));
175
+ if (!lines.some((line) => !isNotice(line))) {
98
176
  for (const line of lines) log(` • ${line}`);
99
177
  log("Everything is already published and up to date.");
100
178
  return result;
@@ -121,11 +199,19 @@ export async function publish(config, log = console.log) {
121
199
  const publicationUri = site.publication.uri;
122
200
 
123
201
  // Announcing first, so the document record can point at the Bluesky thread.
124
- 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
+ }
125
209
  const cover = post.coverFile ? await upload(await coverImage(post.coverFile), "image/jpeg") : undefined;
126
210
  if (announce) {
211
+ const rich = await richTextOf(announcementText(post), agent);
127
212
  const created = await agent.post({
128
- text: announcementText(post),
213
+ text: rich.text,
214
+ ...(rich.facets?.length && { facets: rich.facets }),
129
215
  langs: config.langs,
130
216
  createdAt: announcementDate(post, config).toISOString(),
131
217
  embed: { $type: "app.bsky.embed.external", external: { uri: post.url, title: post.title, description: post.description, thumb: cover } },
@@ -58,7 +58,7 @@ export function documentRecord(post, publicationUri, cover, announcement) {
58
58
  path: post.path,
59
59
  title: post.title,
60
60
  description: post.description,
61
- textContent: post.textContent,
61
+ ...(post.textContent && { textContent: post.textContent }),
62
62
  tags: post.tags,
63
63
  publishedAt: post.publishedAt.toISOString(),
64
64
  ...(post.updatedAt && { updatedAt: post.updatedAt.toISOString() }),
package/lib/text.mjs CHANGED
@@ -30,6 +30,28 @@ export function previewMarkdown(markdown) {
30
30
  return MORE_MARKER.test(markdown) ? markdown.split(MORE_MARKER)[0] : markdown;
31
31
  }
32
32
 
33
+ /**
34
+ * The searchable text for a post's record, by the `share` setting:
35
+ * "more" everything above <!--more-->, or the whole post when there's no marker (default)
36
+ * "intro" everything above <!--more-->, or the first paragraph when there's no marker
37
+ * "full" the whole post
38
+ * "none" nothing (the record still has the title and description)
39
+ * Always plain text: no formatting, images or link addresses.
40
+ */
41
+ export function sharedText(markdown, share = "more") {
42
+ if (share === "none") return "";
43
+ if (share === "full") return plainParagraphs(markdown.replace(MORE_MARKER, "")).join("\n\n");
44
+ if (share === "intro" && !MORE_MARKER.test(markdown)) {
45
+ const [first = ""] = plainParagraphs(
46
+ markdown.split(/\n{2,}/).filter((paragraph) => !paragraph.trim().startsWith("#")).join("\n\n"),
47
+ );
48
+ return first;
49
+ }
50
+ return plainParagraphs(previewMarkdown(markdown)).join("\n\n");
51
+ }
52
+
53
+ export const SHARE_OPTIONS = ["more", "intro", "full", "none"];
54
+
33
55
  /** The first paragraph with text (headings skipped), cut to 160 characters. */
34
56
  export function firstParagraph(markdown) {
35
57
  const text = plainParagraphs(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astro-atmosphere-publishing",
3
- "version": "0.1.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",