astro-atmosphere-comments 0.2.0 → 0.3.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,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0 (4 November 2026)
4
+
5
+ - **Languages:** the wording now comes from language files (`locales/*.json`, i18next JSON) in English, French, German, Spanish, Portuguese, Italian, Dutch and Japanese, picked from the component's `locale` prop, the integration's `locale` option or Astro's locale for the page, then English. Dates and plurals ("1 like", "2 likes") follow the language. The translations are drafts for native speakers to check; translation is being set up on Weblate.
6
+ - **Your own wording:** a `messages` option (and prop) changes any line, by its key in `locales/en.json`.
7
+ - The `label` and `authorLabel` props still work, and win over `messages`.
8
+
3
9
  ## 0.2.0 (4 November 2026)
4
10
 
5
11
  - The sign-in form is shown straight away under each post: a handle box and a **Sign in with Atmosphere** button, both with the Atmosphere logo. The "Respond with your Atmosphere Account" button that opened it is gone.
package/README.md CHANGED
@@ -106,6 +106,8 @@ Locally the integration uses the protocol's development mode, so no key is neede
106
106
  | `policyUri`, `tosUri` | | Your privacy policy and terms, shown on the sign-in screen. |
107
107
  | `hidden` | | Comments never to show: bsky.app post or profile addresses, AT URIs, handles or DIDs. Replies you hide in the Bluesky app, and posts with Bluesky's moderation labels (spam and so on), are left out anyway. |
108
108
  | `base` | | Where the integration's routes live. Default `/atmosphere`. |
109
+ | `locale` | | The language of the wording, such as `"fr"` or `"pt-BR"` (see Languages). |
110
+ | `messages` | | Your own wording for any line, by its key in [`locales/en.json`](locales/en.json). |
109
111
 
110
112
  ## The component
111
113
 
@@ -115,12 +117,29 @@ Locally the integration uses the protocol's development mode, so no key is neede
115
117
  | `label` | The sign-in form's heading. Default "Sign in to leave a comment, like or repost". |
116
118
  | `authorLabel` | The badge on your own comments. Default "Author". |
117
119
  | `blueskyLink` | "Rather not sign in here? Reply on Bluesky instead →" under the sign-in form, linking to the announcement (with `u-syndication`, so microformats readers learn it's the post's copy on Bluesky). Default `true`; `blueskyLink={false}` hides it. |
120
+ | `locale`, `messages` | The same as the integration's options, for this one component. |
118
121
  | `credit` | A small "Powered by Atmosphere Comments for Astro" link at the end. Default `true`; set `credit={false}` to hide it. |
119
122
 
120
123
  **Slots:** `after-box` goes between the comment box and the comments (a note about other ways to respond, say); the default slot goes after the comments (other responses, older comments).
121
124
 
122
125
  **Layout, top to bottom:** likes and reposts, the sign-in form and the "Reply on Bluesky instead" line (or, signed in, Like, Repost, Quote and the comment box), your `after-box` content, the comments, your default slot content.
123
126
 
127
+ ## Languages
128
+
129
+ The wording comes in English, French, German, Spanish, Portuguese, Italian, Dutch and Japanese. The component uses, in order: its `locale` prop, the integration's `locale` option, Astro's own locale for the page (if you use [Astro's i18n routing](https://docs.astro.build/en/guides/internationalization/)), then English. A regional code falls back to its language (`pt-BR` uses `pt`), and any line a language lacks is shown in English. Dates follow the same language.
130
+
131
+ To change any line, give its key from [`locales/en.json`](locales/en.json):
132
+
133
+ ```js
134
+ atmosphereComments({
135
+ // …
136
+ locale: "fr",
137
+ messages: { "signIn.heading": "Connecte-toi pour rejoindre la conversation" },
138
+ }),
139
+ ```
140
+
141
+ The translations other than English started as drafts and need checking by native speakers. Translation is being set up on [Weblate](https://weblate.org), the open-source translation platform; until then, corrections and new languages are very welcome as [GitHub issues or pull requests](https://github.com/saltedlolly/astro-atmosphere-comments/issues). (Bluesky's own approval page is in the reader's language already.)
142
+
124
143
  ## Styling
125
144
 
126
145
  It has a plain default look and takes your text colour. Colours come from CSS custom properties, set on `.atmo`:
@@ -162,7 +181,6 @@ The component fires (bubbling):
162
181
 
163
182
  - Needs a Node server with a lasting disk (no serverless or edge hosts yet).
164
183
  - Rate limits are kept in memory, so they apply per server process.
165
- - The wording is English only.
166
184
  - Quoting individual comments isn't supported.
167
185
 
168
186
  If you only want readers to sign in with their Atmosphere account, without comments, see [AuthProto](https://www.npmjs.com/package/@fujocoded/authproto).
@@ -33,6 +33,8 @@
33
33
  // can show it straight away.
34
34
  import logo from "../assets/union.svg?url";
35
35
  import settings from "../lib/settings";
36
+ import { messagesFor, pickLocale, translate, translateHtml } from "../lib/i18n";
37
+ import { escapeHtml } from "../lib/translate";
36
38
 
37
39
  interface Props {
38
40
  /**
@@ -49,15 +51,26 @@ interface Props {
49
51
  credit?: boolean;
50
52
  /** "Rather not sign in here? Reply on Bluesky instead →" under the sign-in form. Default true. */
51
53
  blueskyLink?: boolean;
54
+ /** The language of the wording, e.g. "fr". Overrides the integration's `locale`. */
55
+ locale?: string;
56
+ /** Your own wording for any line, by its key in locales/en.json. */
57
+ messages?: Record<string, string>;
52
58
  }
53
59
 
54
- const {
55
- post = "",
56
- label = "Sign in to leave a comment, like or repost",
57
- authorLabel = "Author",
58
- credit = true,
59
- blueskyLink = true,
60
- } = Astro.props;
60
+ const { post = "", label, authorLabel, credit = true, blueskyLink = true } = Astro.props;
61
+ // The wording: the language asked for (or Astro's i18n locale for the page),
62
+ // with the site's own lines on top. The script gets the same, as JSON.
63
+ const lang = pickLocale(Astro.props.locale, settings.locale, Astro.currentLocale);
64
+ const messages = messagesFor(lang, {
65
+ ...settings.messages,
66
+ ...Astro.props.messages,
67
+ ...(label && { "signIn.heading": label }),
68
+ ...(authorLabel && { "comment.author": authorLabel }),
69
+ });
70
+ const t = (key: string, vars?: Record<string, string | number>) => translate(messages, lang, key, vars);
71
+ const th = (key: string, html: Record<string, string>) => translateHtml(messages, lang, key, html);
72
+ const link = (href: string | undefined, text: string, attributes = "") =>
73
+ `<a${href ? ` href="${escapeHtml(href)}"` : ""}${attributes}>${escapeHtml(text)}</a>`;
61
74
  // The announcement on bsky.app, when the page is given it (otherwise the
62
75
  // script fills it in once the thread has loaded).
63
76
  const atUri = post.match(/^at:\/\/([^/]+)\/app\.bsky\.feed\.post\/([^/]+)$/);
@@ -71,13 +84,19 @@ const { base } = settings;
71
84
  const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
72
85
  ---
73
86
 
74
- <atmosphere-comments class="atmo" id="atmosphere-comments" data-post={post} data-base={base} data-author-label={authorLabel}>
87
+ <atmosphere-comments class="atmo" id="atmosphere-comments" data-post={post} data-base={base} data-locale={lang} data-messages={JSON.stringify(messages)}>
75
88
  <div class="atmo-reactions" data-reactions data-pagefind-ignore hidden></div>
76
89
 
77
90
  <div class="atmo__signed-out" data-view="signed-out" data-pagefind-ignore>
78
91
  <form class="atmo__login" id="atmo-login" method="post" action={`${base}/login`} data-login-form>
79
- <label for="atmo-handle">{label}</label>
80
- <p class="atmo__hint">Any <a href="https://atmosphereaccount.com">Atmosphere account</a> works, including <a href="https://bsky.app">Bluesky</a>: one account for a whole network of open apps.</p>
92
+ <label for="atmo-handle">{t("signIn.heading")}</label>
93
+ <p
94
+ class="atmo__hint"
95
+ set:html={th("signIn.anyAccount", {
96
+ atmosphereAccount: link("https://atmosphereaccount.com", t("signIn.atmosphereAccount")),
97
+ bluesky: link("https://bsky.app", "Bluesky"),
98
+ })}
99
+ />
81
100
  <div class="atmo__row">
82
101
  <span class="atmo__handle">
83
102
  <span class="atmo__logo" style={`--atmo-logo: url("${logo}")`} aria-hidden="true"></span>
@@ -95,54 +114,56 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
95
114
  placeholder="alice.bsky.social"
96
115
  required
97
116
  />
98
- <ul class="atmo__suggestions" id="atmo-handle-suggestions" role="listbox" aria-label="Matching accounts" data-handle-suggestions hidden></ul>
117
+ <ul class="atmo__suggestions" id="atmo-handle-suggestions" role="listbox" aria-label={t("signIn.matchingAccounts")} data-handle-suggestions hidden></ul>
99
118
  </span>
100
119
  <input type="hidden" name="returnTo" value={returnTo} />
101
120
  <button type="submit" class="atmo__button atmo__button--sign-in">
102
121
  <span class="atmo__logo" style={`--atmo-logo: url("${logo}")`} aria-hidden="true"></span>
103
- Sign in with Atmosphere
122
+ {t("signIn.button")}
104
123
  </button>
105
124
  </div>
106
- <p class="atmo__hint">
107
- You'll approve this on your account's own site (Bluesky's, for most people); your password
108
- is never shared here.
109
- No account yet? <a href="https://atmosphereaccount.com">Create a free Atmosphere account</a>.
110
- </p>
125
+ <p
126
+ class="atmo__hint"
127
+ set:html={th("signIn.privacy", { createAccount: link("https://atmosphereaccount.com", t("signIn.createAccount")) })}
128
+ />
111
129
  </form>
112
130
  {blueskyLink && (
113
- <p class="atmo__elsewhere" data-elsewhere hidden={!blueskyUrl}>
114
- Rather not sign in here?{" "}
115
- {/* u-syndication: microformats readers learn this is the post's copy on Bluesky. */}
116
- <a class="atmo__elsewhere-link u-syndication" href={blueskyUrl || undefined} data-elsewhere-link>Reply on Bluesky instead →</a>{" "}
117
- It shows up here just the same, as do likes and reposts.
118
- </p>
131
+ <p
132
+ class="atmo__elsewhere"
133
+ data-elsewhere
134
+ hidden={!blueskyUrl}
135
+ set:html={th("elsewhere.text", {
136
+ // u-syndication: microformats readers learn this is the post's copy on Bluesky.
137
+ link: link(blueskyUrl || undefined, t("elsewhere.link"), ' class="atmo__elsewhere-link u-syndication" data-elsewhere-link'),
138
+ })}
139
+ />
119
140
  )}
120
141
  </div>
121
142
 
122
143
  <div class="atmo__signed-in" data-view="signed-in" data-pagefind-ignore hidden>
123
- <div class="atmo__actions" role="group" aria-label="Respond to this post">
144
+ <div class="atmo__actions" role="group" aria-label={t("actions.label")}>
124
145
  <button type="button" class="atmo__action" data-react="like" aria-pressed="false">
125
146
  <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 20.5s-7.5-4.4-7.5-10.1A4.4 4.4 0 0 1 12 7.6a4.4 4.4 0 0 1 7.5 2.8c0 5.7-7.5 10.1-7.5 10.1Z" /></svg>
126
- <span data-label-off>Like</span><span data-label-on>Liked</span>
147
+ <span data-label-off>{t("actions.like")}</span><span data-label-on>{t("actions.liked")}</span>
127
148
  </button>
128
149
  <button type="button" class="atmo__action" data-react="repost" aria-pressed="false">
129
150
  <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M7 8h10l-3-3M17 16H7l3 3M5 13V9.5A1.5 1.5 0 0 1 6.5 8M19 11v3.5a1.5 1.5 0 0 1-1.5 1.5" /></svg>
130
- <span data-label-off>Repost</span><span data-label-on>Reposted</span>
151
+ <span data-label-off>{t("actions.repost")}</span><span data-label-on>{t("actions.reposted")}</span>
131
152
  </button>
132
153
  <button type="button" class="atmo__action" data-quote>
133
154
  <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 18h5v-5H6.5A4.5 4.5 0 0 1 11 8.5V7a6 6 0 0 0-6 6Zm9 0h5v-5h-3.5A4.5 4.5 0 0 1 20 8.5V7a6 6 0 0 0-6 6Z" /></svg>
134
- <span>Quote</span>
155
+ <span>{t("actions.quote")}</span>
135
156
  </button>
136
157
  </div>
137
158
 
138
159
  <form class="atmo__composer" data-composer>
139
160
  <p class="atmo__context" data-context hidden>
140
161
  <span data-context-text></span>
141
- <button type="button" class="atmo__link-button" data-cancel-context>Cancel</button>
162
+ <button type="button" class="atmo__link-button" data-cancel-context>{t("composer.cancel")}</button>
142
163
  </p>
143
164
  <label for="atmo-text" class="atmo__as">
144
165
  <span class="atmo__avatar atmo__avatar--me" data-me-avatar aria-hidden="true"></span>
145
- <span><span data-verb>Comment</span> as <strong data-handle></strong></span>
166
+ <span set:html={th("composer.commentAs", { verb: `<span data-verb>${escapeHtml(t("composer.comment"))}</span>`, handle: "<strong data-handle></strong>" })} />
146
167
  </label>
147
168
  <div class="atmo__field">
148
169
  <textarea
@@ -153,14 +174,14 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
153
174
  aria-autocomplete="list"
154
175
  aria-controls="atmo-suggestions"
155
176
  aria-expanded="false"></textarea>
156
- <ul class="atmo__suggestions" id="atmo-suggestions" role="listbox" aria-label="Accounts to mention" data-suggestions hidden></ul>
177
+ <ul class="atmo__suggestions" id="atmo-suggestions" role="listbox" aria-label={t("composer.mentions")} data-suggestions hidden></ul>
157
178
  </div>
158
179
  <figure class="atmo__preview" data-preview hidden>
159
- <figcaption>How your post will look</figcaption>
180
+ <figcaption>{t("composer.previewCaption")}</figcaption>
160
181
  <div class="atmo__preview-post">
161
182
  <span class="atmo__avatar" data-preview-avatar aria-hidden="true"></span>
162
183
  <div class="atmo__preview-body">
163
- <p class="atmo__preview-author"><strong data-preview-name></strong> <span data-preview-handle></span> <span>· now</span></p>
184
+ <p class="atmo__preview-author"><strong data-preview-name></strong> <span data-preview-handle></span> <span>· {t("composer.now")}</span></p>
164
185
  <p class="atmo__preview-text" data-preview-text></p>
165
186
  <div class="atmo__quoted">
166
187
  <p class="atmo__preview-author">
@@ -182,8 +203,8 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
182
203
  </figure>
183
204
  <div class="atmo__row atmo__row--end">
184
205
  <span class="atmo__count" data-count aria-live="polite"></span>
185
- <button type="button" class="atmo__link-button" data-sign-out>Sign out</button>
186
- <button type="submit" class="atmo__button" data-submit>Post comment</button>
206
+ <button type="button" class="atmo__link-button" data-sign-out>{t("composer.signOut")}</button>
207
+ <button type="submit" class="atmo__button" data-submit>{t("composer.postComment")}</button>
187
208
  </div>
188
209
  </form>
189
210
  </div>
@@ -199,29 +220,31 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
199
220
  <slot />
200
221
 
201
222
  {credit && (
202
- <p class="atmo__credit" data-credit data-pagefind-ignore>
203
- Powered by <a href={CREDIT_URL}>Atmosphere Comments for Astro</a>
204
- </p>
223
+ <p class="atmo__credit" data-credit data-pagefind-ignore set:html={th("credit", { link: link(CREDIT_URL, "Atmosphere Comments for Astro") })} />
205
224
  )}
206
225
  </atmosphere-comments>
207
226
 
208
227
  <script>
228
+ import { translate } from "../lib/translate";
229
+
209
230
  const MAX = 300;
210
231
  const graphemes = new Intl.Segmenter();
211
232
  const count = (text: string) => [...graphemes.segment(text.trim())].length;
212
233
 
213
- const MESSAGES: Record<string, string> = {
214
- "signed-in": "You're signed in. You can comment, like or repost above.",
215
- "sign-in-failed": "Signing in didn't finish. Please try again.",
216
- "account-not-found": "We couldn't find that account. Check the handle and try again.",
217
- "invalid-handle": "That doesn't look like a handle. It's usually something like alice.bsky.social.",
218
- "signed-out": "You've been signed out. Please sign in again.",
219
- "needs-permission": "To delete comments here, this site needs one more permission from your account.",
220
- "not-allowed": "That can't be done here.",
221
- "new-permissions": "This site can now also like and repost for you, so it needs your permission again. Please sign in again.",
222
- "too-long": `Posts can be up to ${MAX} characters.`,
223
- "too-many": "You're going quickly. Please wait a minute and try again.",
224
- error: "That didn't work. Please try again.",
234
+ // The error and status codes the routes and the sign-in round trip send,
235
+ // and the wording for each (in locales/*.json).
236
+ const MESSAGE_KEYS: Record<string, string> = {
237
+ "signed-in": "messages.signedIn",
238
+ "sign-in-failed": "messages.signInFailed",
239
+ "account-not-found": "messages.accountNotFound",
240
+ "invalid-handle": "messages.invalidHandle",
241
+ "signed-out": "messages.signedOut",
242
+ "needs-permission": "messages.needsPermission",
243
+ "not-allowed": "messages.notAllowed",
244
+ "new-permissions": "messages.newPermissions",
245
+ "too-long": "messages.tooLong",
246
+ "too-many": "messages.tooMany",
247
+ error: "messages.error",
225
248
  };
226
249
 
227
250
  type Posted = { uri: string; url: string; quote: boolean };
@@ -230,6 +253,11 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
230
253
  class AtmosphereComments extends HTMLElement {
231
254
  connectedCallback() {
232
255
  const base = this.dataset.base ?? "/atmosphere";
256
+ // The wording, in the page's language, from the server.
257
+ const lang = this.dataset.locale || "en";
258
+ const wording = JSON.parse(this.dataset.messages || "{}") as Record<string, string>;
259
+ const t = (key: string, vars?: Record<string, string | number>) => translate(wording, lang, key, vars);
260
+ const message = (code: string) => t(MESSAGE_KEYS[code] ?? MESSAGE_KEYS.error, { max: MAX });
233
261
  // The announcement's AT URI. A bsky.app address or the page's
234
262
  // Standard.site record is turned into one when the thread loads.
235
263
  let post = this.dataset.post?.startsWith("at://") ? this.dataset.post : "";
@@ -281,9 +309,9 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
281
309
  let thread: Thread | null = null;
282
310
  const reactionsBox = $<HTMLElement>("[data-reactions]");
283
311
  const repliesList = $<HTMLOListElement>("[data-replies]");
284
- const authorLabel = this.dataset.authorLabel || "Author";
312
+ const authorLabel = t("comment.author");
285
313
  const nameOf = (person: Pick<Person, "handle" | "displayName">) => person.displayName || `@${person.handle}`;
286
- const dates = new Intl.DateTimeFormat(document.documentElement.lang || undefined, {
314
+ const dates = new Intl.DateTimeFormat(lang, {
287
315
  day: "numeric",
288
316
  month: "long",
289
317
  year: "numeric",
@@ -312,7 +340,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
312
340
  ...groups.map(({ kind, count, people }) => {
313
341
  const group = make("div", "atmo-reactions__group");
314
342
  group.dataset.reaction = kind;
315
- group.append(make("p", "atmo-reactions__count", `${count} ${kind}${count === 1 ? "" : "s"}`));
343
+ group.append(make("p", "atmo-reactions__count", t(kind === "like" ? "reactions.likes" : "reactions.reposts", { count })));
316
344
  const faces = make("ul", "atmo-facepile");
317
345
  for (const person of people) {
318
346
  const link = Object.assign(make("a"), { href: person.url, title: nameOf(person), rel: "ugc nofollow noopener" });
@@ -345,7 +373,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
345
373
  if (reply.byAuthor) who.append(make("span", "atmo-comment__badge", authorLabel));
346
374
  const date = Object.assign(make("a", "atmo-comment__date"), { href: reply.url, rel: "ugc nofollow noopener" });
347
375
  const time = Object.assign(make("time"), { dateTime: reply.createdAt, textContent: reply.createdAt ? dates.format(new Date(reply.createdAt)) : "" });
348
- date.append(time, make("span", "", " · view on Bluesky"));
376
+ date.append(time, make("span", "", ` · ${t("comment.viewOnBluesky")}`));
349
377
  const names = make("div");
350
378
  names.append(who, date);
351
379
  const header = make("header", "atmo-comment__header");
@@ -547,13 +575,13 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
547
575
 
548
576
  const handleError = (error: string, tell: (message: string) => void = say) => {
549
577
  if (error === "signed-out" || error === "new-permissions") void show(false);
550
- tell(MESSAGES[error] ?? MESSAGES.error);
578
+ tell(message(error));
551
579
  };
552
580
 
553
581
  const postedMessage = (where: HTMLElement, data: Posted) =>
554
582
  where.replaceChildren(
555
- data.quote ? "Your quote post is on Bluesky. " : "Your comment is posted. ",
556
- Object.assign(document.createElement("a"), { href: data.url, textContent: "See it on Bluesky →" }),
583
+ `${t(data.quote ? "posted.quote" : "posted.comment")} `,
584
+ Object.assign(document.createElement("a"), { href: data.url, textContent: t("posted.see") }),
557
585
  );
558
586
 
559
587
  // Everything a comment box does: counting characters, @mention
@@ -574,7 +602,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
574
602
 
575
603
  const updateCount = () => {
576
604
  const used = count(textarea.value);
577
- counter.textContent = used > MAX - 50 ? `${MAX - used} left` : "";
605
+ counter.textContent = used > MAX - 50 ? t("composer.charactersLeft", { count: MAX - used }) : "";
578
606
  counter.classList.toggle("is-over", used > MAX);
579
607
  };
580
608
 
@@ -681,9 +709,9 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
681
709
  event.preventDefault();
682
710
  const text = textarea.value.trim();
683
711
  if (!text) return;
684
- if (count(text) > MAX) return behaviour.tell(MESSAGES["too-long"]);
712
+ if (count(text) > MAX) return behaviour.tell(message("too-long"));
685
713
  submit.disabled = true;
686
- behaviour.tell("Posting…");
714
+ behaviour.tell(t("composer.posting"));
687
715
  try {
688
716
  const response = await send("comment", { text, root: post, ...behaviour.extra() });
689
717
  const data = await response.json();
@@ -694,7 +722,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
694
722
  showPosted(data);
695
723
  this.dispatchEvent(new CustomEvent("atmosphere:commented", { detail: data, bubbles: true }));
696
724
  } catch {
697
- behaviour.tell(MESSAGES.error);
725
+ behaviour.tell(message("error"));
698
726
  } finally {
699
727
  submit.disabled = false;
700
728
  }
@@ -710,7 +738,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
710
738
  let quotedLoaded = false;
711
739
  const updatePreviewText = () => {
712
740
  const words = main.textarea.value.trim();
713
- previewText.textContent = words || "Your words appear here.";
741
+ previewText.textContent = words || t("composer.previewEmpty");
714
742
  previewText.classList.toggle("is-placeholder", !words);
715
743
  };
716
744
  const loadQuoted = async () => {
@@ -752,9 +780,9 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
752
780
  mainMode = next;
753
781
  const quoting = next === "quote";
754
782
  $<HTMLElement>("[data-context]").hidden = !quoting;
755
- $<HTMLElement>("[data-context-text]").textContent = quoting ? "Quoting this post on your own account" : "";
756
- $<HTMLElement>("[data-verb]").textContent = quoting ? "Quote" : "Comment";
757
- $<HTMLElement>("[data-submit]").textContent = quoting ? "Post quote" : "Post comment";
783
+ $<HTMLElement>("[data-context-text]").textContent = quoting ? t("composer.quoting") : "";
784
+ $<HTMLElement>("[data-verb]").textContent = t(quoting ? "composer.quote" : "composer.comment");
785
+ $<HTMLElement>("[data-submit]").textContent = t(quoting ? "composer.postQuote" : "composer.postComment");
758
786
  preview.hidden = !quoting;
759
787
  if (quoting) void loadQuoted();
760
788
  };
@@ -795,8 +823,8 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
795
823
  suggestions.id = `${id}-suggestions`;
796
824
  textarea.setAttribute("aria-controls", suggestions.id);
797
825
  form.querySelector<HTMLElement>("[data-context]")!.hidden = false;
798
- form.querySelector("[data-context-text]")!.textContent = `Replying to ${author}`;
799
- form.querySelector("[data-verb]")!.textContent = "Reply";
826
+ form.querySelector("[data-context-text]")!.textContent = t("composer.replyingTo", { name: author });
827
+ form.querySelector("[data-verb]")!.textContent = t("comment.reply");
800
828
  form.querySelector("[data-submit]")!.textContent = "Post reply";
801
829
  form.querySelector("[data-handle]")!.textContent = me.handle ? `@${me.handle}` : "";
802
830
  setAvatar(form.querySelector<HTMLElement>("[data-me-avatar]")!, me.displayName || me.handle || "?", me.avatar);
@@ -835,8 +863,8 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
835
863
  button.dataset.kind = kind;
836
864
  const seen = known[comment.dataset.atmosphereUri!];
837
865
  button.setAttribute("aria-pressed", String(!!(kind === "like" ? seen?.liked : seen?.reposted)));
838
- const author = comment.dataset.atmosphereAuthor || "this comment";
839
- button.setAttribute("aria-label", `${kind === "like" ? "Like" : "Repost"} ${author}'s comment`);
866
+ const author = comment.dataset.atmosphereAuthor || t("comment.someone");
867
+ button.setAttribute("aria-label", t(kind === "like" ? "comment.likeLabel" : "comment.repostLabel", { name: author }));
840
868
  const count = Number(comment.dataset[kind === "like" ? "atmosphereLikes" : "atmosphereReposts"] || 0);
841
869
  button.innerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true">${kind === "like" ? HEART : ARROWS}</svg><span></span>`;
842
870
  const setCount = (value: number) => {
@@ -845,7 +873,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
845
873
  };
846
874
  setCount(count);
847
875
  button.addEventListener("click", async () => {
848
- if (!isSignedIn) return signInFirst(`Sign in to ${kind} comments.`);
876
+ if (!isSignedIn) return signInFirst(t(kind === "like" ? "signInFirst.like" : "signInFirst.repost"));
849
877
  const on = button.getAttribute("aria-pressed") !== "true";
850
878
  const before = Number(button.dataset.count || 0);
851
879
  button.setAttribute("aria-pressed", String(on));
@@ -869,15 +897,15 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
869
897
  if (comment.querySelector(":scope .atmo-comment-actions")) return;
870
898
  added += 1;
871
899
  const actions = Object.assign(document.createElement("div"), { className: "atmo-comment-actions" });
872
- const author = comment.dataset.atmosphereAuthor || "this comment";
900
+ const author = comment.dataset.atmosphereAuthor || t("comment.someone");
873
901
  const reply = Object.assign(document.createElement("button"), {
874
902
  type: "button",
875
903
  className: "atmo-comment-action",
876
- textContent: "Reply",
904
+ textContent: t("comment.reply"),
877
905
  });
878
- reply.setAttribute("aria-label", `Reply to ${author}`);
906
+ reply.setAttribute("aria-label", t("comment.replyTo", { name: author }));
879
907
  reply.addEventListener("click", () => {
880
- if (!isSignedIn) return signInFirst("Sign in to reply.");
908
+ if (!isSignedIn) return signInFirst(t("signInFirst.reply"));
881
909
  openReply(comment, author);
882
910
  });
883
911
  actions.append(reply, commentButton("like", comment), commentButton("repost", comment));
@@ -892,13 +920,13 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
892
920
 
893
921
  const deleteComment = async (comment: HTMLElement, panel: HTMLElement) => {
894
922
  const uri = comment.dataset.atmosphereUri!;
895
- panel.replaceChildren(make("span", "atmo-comment-delete__text", "Deleting…"));
923
+ panel.replaceChildren(make("span", "atmo-comment-delete__text", t("comment.deleting")));
896
924
  try {
897
925
  const response = await send("delete", { post: uri });
898
926
  const data = (await response.json()) as { error?: string };
899
927
  if (response.ok) {
900
928
  comment.remove();
901
- say("Your comment was deleted.");
929
+ say(t("comment.deleted"));
902
930
  this.dispatchEvent(new CustomEvent("atmosphere:deleted", { detail: { uri }, bubbles: true }));
903
931
  return;
904
932
  }
@@ -907,7 +935,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
907
935
  handleError(data.error ?? "error");
908
936
  } catch {
909
937
  panel.remove();
910
- say(MESSAGES.error);
938
+ say(message("error"));
911
939
  }
912
940
  };
913
941
 
@@ -920,15 +948,15 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
920
948
  field("handle", me.did!);
921
949
  field("permission", "delete");
922
950
  field("returnTo", `${location.pathname}#atmo-delete=${encodeURIComponent(comment.dataset.atmosphereUri!)}`);
923
- const go = Object.assign(make("button", "atmo-comment-action atmo-comment-action--danger", "Continue to approve"), { type: "submit" });
924
- const cancel = Object.assign(make("button", "atmo-comment-action", "Cancel"), { type: "button" });
951
+ const go = Object.assign(make("button", "atmo-comment-action atmo-comment-action--danger", t("comment.continue")), { type: "submit" });
952
+ const cancel = Object.assign(make("button", "atmo-comment-action", t("comment.cancel")), { type: "button" });
925
953
  cancel.addEventListener("click", () => panel.remove());
926
954
  form.append(go, cancel);
927
955
  panel.replaceChildren(
928
956
  make(
929
957
  "span",
930
958
  "atmo-comment-delete__text",
931
- "To delete comments here, this site needs one more permission from your account. You can also delete it in any Bluesky app.",
959
+ t("comment.deletePermission"),
932
960
  ),
933
961
  form,
934
962
  );
@@ -936,22 +964,22 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
936
964
  };
937
965
 
938
966
  const deleteButton = (comment: HTMLElement) => {
939
- const button = Object.assign(make("button", "atmo-comment-action atmo-comment-action--delete", "Delete"), { type: "button" });
940
- button.setAttribute("aria-label", "Delete your comment");
967
+ const button = Object.assign(make("button", "atmo-comment-action atmo-comment-action--delete", t("comment.delete")), { type: "button" });
968
+ button.setAttribute("aria-label", t("comment.deleteLabel"));
941
969
  button.addEventListener("click", () => {
942
970
  const actions = button.parentElement!;
943
971
  if (actions.nextElementSibling?.matches(".atmo-comment-delete")) return;
944
972
  const panel = make("div", "atmo-comment-delete");
945
- const yes = Object.assign(make("button", "atmo-comment-action atmo-comment-action--danger", "Delete"), { type: "button" });
946
- const no = Object.assign(make("button", "atmo-comment-action", "Cancel"), { type: "button" });
973
+ const yes = Object.assign(make("button", "atmo-comment-action atmo-comment-action--danger", t("comment.delete")), { type: "button" });
974
+ const no = Object.assign(make("button", "atmo-comment-action", t("comment.cancel")), { type: "button" });
947
975
  yes.addEventListener("click", () => deleteComment(comment, panel));
948
976
  no.addEventListener("click", () => {
949
977
  panel.remove();
950
978
  button.focus();
951
979
  });
952
- panel.append(make("span", "atmo-comment-delete__text", "Delete this comment from Bluesky?"), yes, no);
980
+ panel.append(make("span", "atmo-comment-delete__text", t("comment.deleteConfirm")), yes, no);
953
981
  panel.setAttribute("role", "group");
954
- panel.setAttribute("aria-label", "Delete your comment");
982
+ panel.setAttribute("aria-label", t("comment.deleteLabel"));
955
983
  actions.after(panel);
956
984
  no.focus();
957
985
  });
@@ -1033,7 +1061,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
1033
1061
  const url = new URL(location.href);
1034
1062
  const result = url.searchParams.get("atmosphere");
1035
1063
  if (result) {
1036
- say(MESSAGES[result] ?? "");
1064
+ say(MESSAGE_KEYS[result] ? message(result) : "");
1037
1065
  url.searchParams.delete("atmosphere");
1038
1066
  history.replaceState(history.state, "", url);
1039
1067
  }
@@ -1072,7 +1100,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
1072
1100
  setPressed(kind, !on);
1073
1101
  return handleError((await response.json()).error);
1074
1102
  }
1075
- say(on ? (kind === "like" ? "Liked." : "Reposted.") : kind === "like" ? "Like removed." : "Repost removed.");
1103
+ say(t(on ? (kind === "like" ? "status.liked" : "status.reposted") : kind === "like" ? "status.likeRemoved" : "status.repostRemoved"));
1076
1104
  showReaction(kind, on);
1077
1105
  this.dispatchEvent(
1078
1106
  new CustomEvent("atmosphere:reacted", {
@@ -1082,7 +1110,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
1082
1110
  );
1083
1111
  } catch {
1084
1112
  setPressed(kind, !on);
1085
- say(MESSAGES.error);
1113
+ say(message("error"));
1086
1114
  }
1087
1115
  });
1088
1116
  }
@@ -1091,7 +1119,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
1091
1119
  await fetch(`${base}/logout`, { method: "POST", credentials: "same-origin" }).catch(() => undefined);
1092
1120
  void show(false);
1093
1121
  setMainMode("comment");
1094
- say("You're signed out.");
1122
+ say(t("status.signedOut"));
1095
1123
  });
1096
1124
  }
1097
1125
  }
@@ -1175,7 +1203,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
1175
1203
  font-size: 0.9em;
1176
1204
  }
1177
1205
 
1178
- .atmo__elsewhere-link {
1206
+ .atmo__elsewhere :global(.atmo__elsewhere-link) {
1179
1207
  font-weight: 700;
1180
1208
  }
1181
1209
 
@@ -1335,7 +1363,7 @@ const returnTo = `${Astro.url.pathname}#atmosphere-comments`;
1335
1363
  opacity: 0.8;
1336
1364
  }
1337
1365
 
1338
- .atmo__credit a {
1366
+ .atmo__credit :global(a) {
1339
1367
  color: inherit;
1340
1368
  }
1341
1369
 
package/index.d.ts CHANGED
@@ -21,6 +21,17 @@ export type AtmosphereCommentsOptions = {
21
21
  * handles or DIDs (on top of replies hidden on Bluesky and moderation labels).
22
22
  */
23
23
  hidden?: string[];
24
+ /**
25
+ * The language of the wording, e.g. "fr" or "pt-BR". Default: Astro's own
26
+ * i18n locale for the page, if you use Astro's i18n routing, else English.
27
+ * The component's `locale` prop overrides it.
28
+ */
29
+ locale?: string;
30
+ /**
31
+ * Your own wording for any line, by its key in locales/en.json, e.g.
32
+ * { "signIn.heading": "Sign in to join the conversation" }.
33
+ */
34
+ messages?: Record<string, string>;
24
35
  };
25
36
 
26
37
  export default function atmosphereComments(options: AtmosphereCommentsOptions): AstroIntegration;
package/index.mjs CHANGED
@@ -80,6 +80,8 @@ export default function atmosphereComments(options) {
80
80
  policyUri: options.policyUri,
81
81
  tosUri: options.tosUri,
82
82
  hidden: options.hidden ?? [],
83
+ locale: options.locale,
84
+ messages: options.messages ?? {},
83
85
  };
84
86
 
85
87
  // The client metadata lives at the address account hosts treat as the
package/lib/i18n.ts ADDED
@@ -0,0 +1,32 @@
1
+ // The component's wording in each language. The files in locales/ are
2
+ // i18next JSON (v4): flat keys, {{placeholders}}, and plural forms as
3
+ // key_one, key_other (and key_few, key_many… where a language needs them).
4
+ // English is the source; translations are made in Weblate, which commits them
5
+ // back to the repository. Any key a language lacks falls back to English.
6
+
7
+ import type { Messages } from "./translate";
8
+
9
+ export type { Messages } from "./translate";
10
+ export { translate, translateHtml } from "./translate";
11
+
12
+ const files = import.meta.glob<Messages>("../locales/*.json", { eager: true, import: "default" });
13
+ const LOCALES: Record<string, Messages> = Object.fromEntries(
14
+ Object.entries(files).map(([path, messages]) => [path.replace(/^.*\/|\.json$/g, ""), messages]),
15
+ );
16
+
17
+ /** The best language we have for `wanted` ("pt-BR" → "pt-BR", else "pt", else "en"). */
18
+ export function pickLocale(...wanted: Array<string | undefined>): string {
19
+ for (const tag of wanted) {
20
+ if (!tag) continue;
21
+ const exact = Object.keys(LOCALES).find((code) => code.toLowerCase() === tag.toLowerCase());
22
+ if (exact) return exact;
23
+ const base = tag.split("-")[0].toLowerCase();
24
+ if (LOCALES[base]) return base;
25
+ }
26
+ return "en";
27
+ }
28
+
29
+ /** English, with the language's own wording on top, then the site's overrides. */
30
+ export function messagesFor(locale: string, overrides: Messages = {}): Messages {
31
+ return { ...LOCALES.en, ...(LOCALES[locale] ?? {}), ...overrides };
32
+ }