v4: the emails' words live in the catalogs alone and their look is read from the site's stylesheets — every email message was written as i18n._({ id, message }), so its English sat in the template and again in en.po, and api/mail/styles.ts held a copy of each theme's palette, fonts and radii that a test chased. The templates now name each message by its id only (i18n._("email.reset.title", values)) and its English is written in locales/en.po, its French in fr.po; the four labels the settings tab and the unsubscribe page share with the emails read the same ids through @lingui/react's Trans instead of restating the English. i18n:extract runs without --overwrite, which would write the id where the English is, and i18n:compile runs --strict, so a language lacking a message fails the build; it caught email.digest.hl.mentionIn, never translated into French. The new api/mail/theme-tokens.ts reads src/tokens.css and src/themes/<style>.css and resolves a token for one scheme (var(), light-dark(), color-mix(), translucent colors laid over the card), and styles.ts names tokens for every color, GeoCities' rainbow, Melange's spice and shadow, Kanagawa's syntax hues, the font stack and the corner radii, keeping only what is the email's own (type scale, title, frame, divider, avatar rounding). The copies had drifted: GeoCities' stack had lost Marker Felt and its light emails drew the dark rainbow, which now also recolors its dividers on the "auto" scheme. The pills take the site's badge color, now the --color-badge-bg token that the private badge reads too. The Dockerfile copies src/tokens.css and src/themes/ into the runtime image, which reads them at load. The catalogs were re-extracted, having been stale against the code. 240 backend tests (new: the token reader, the dark rainbow; the palette-copy test is gone) and 661 frontend tests pass, and tsc, deno check and doc:lint are clean; sendDueDigests' empty-digest test fails now and then, as it did before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-10-05 18:21:45 +00:00
parent 7960d74c55
commit fd0d81fe66
23 changed files with 1419 additions and 1402 deletions

View File

@@ -121,41 +121,17 @@ const ACTIVITY_ORDER: ActivityKind[] = [
function countLabel(kind: ActivityKind, n: number): string {
switch (kind) {
case "upvotes":
return i18n._({
id: "email.digest.count.upvotes",
message: "{n, plural, one {upvote} other {upvotes}}",
values: { n },
});
return i18n._("email.digest.count.upvotes", { n });
case "followers":
return i18n._({
id: "email.digest.count.followers",
message: "{n, plural, one {new follower} other {new followers}}",
values: { n },
});
return i18n._("email.digest.count.followers", { n });
case "comments":
return i18n._({
id: "email.digest.count.comments",
message: "{n, plural, one {comment} other {comments}}",
values: { n },
});
return i18n._("email.digest.count.comments", { n });
case "mentions":
return i18n._({
id: "email.digest.count.mentions",
message: "{n, plural, one {mention} other {mentions}}",
values: { n },
});
return i18n._("email.digest.count.mentions", { n });
case "likes":
return i18n._({
id: "email.digest.count.likes",
message: "{n, plural, one {comment like} other {comment likes}}",
values: { n },
});
return i18n._("email.digest.count.likes", { n });
case "playlistFollows":
return i18n._({
id: "email.digest.count.playlistFollows",
message: "{n, plural, one {playlist follow} other {playlist follows}}",
values: { n },
});
return i18n._("email.digest.count.playlistFollows", { n });
}
}
@@ -171,85 +147,34 @@ function highlightSentence(
const values = { who, target };
switch (kind) {
case "mentionComment":
return i18n._({
id: "email.digest.hl.mentionComment",
message: "{who} mentioned you in a comment on {target}",
values,
});
return i18n._("email.digest.hl.mentionComment", values);
case "mentionDump":
case "mentionChat":
return i18n._({
id: "email.digest.hl.mentionIn",
message: "{who} mentioned you in {target}",
values,
});
return i18n._("email.digest.hl.mentionIn", values);
case "mentionPlaylist":
return i18n._({
id: "email.digest.hl.mentionPlaylist",
message: "{who} mentioned you in the playlist {target}",
values,
});
return i18n._("email.digest.hl.mentionPlaylist", values);
case "comment":
return i18n._({
id: "email.digest.hl.comment",
message: "{who} commented on your dump {target}",
values,
});
return i18n._("email.digest.hl.comment", values);
case "playlistFollow":
return i18n._({
id: "email.digest.hl.playlistFollow",
message: "{who} followed your playlist {target}",
values,
});
return i18n._("email.digest.hl.playlistFollow", values);
case "userFollow":
return i18n._({
id: "email.digest.hl.userFollow",
message: "{who} started following you",
values,
});
return i18n._("email.digest.hl.userFollow", values);
case "commentLike":
return i18n._({
id: "email.digest.hl.commentLike",
message: "{who} liked your comment on {target}",
values,
});
return i18n._("email.digest.hl.commentLike", values);
}
}
const byLine = (username: string) =>
i18n._({
id: "email.digest.by",
message: "by @{username}",
values: { username },
});
const byLine = (username: string) => i18n._("email.digest.by", { username });
const addedToLine = (playlist: string) =>
i18n._({
id: "email.digest.addedTo",
message: "added to {playlist}",
values: { playlist },
});
const uploadLabel = () =>
i18n._({ id: "email.digest.upload", message: "upload" });
i18n._("email.digest.addedTo", { playlist });
const uploadLabel = () => i18n._("email.digest.upload");
const invitedLine = (inviter: string, reader: string) =>
inviter === reader
? i18n._({ id: "email.digest.invitedByYou", message: "invited by you" })
: i18n._({
id: "email.digest.invitedBy",
message: "invited by @{username}",
values: { username: inviter },
});
const andMoreLine = (n: number) =>
i18n._({
id: "email.digest.andMore",
message: "and {n} more",
values: { n },
});
? i18n._("email.digest.invitedByYou")
: i18n._("email.digest.invitedBy", { username: inviter });
const andMoreLine = (n: number) => i18n._("email.digest.andMore", { n });
const playlistMeta = (owner: string, n: number) =>
i18n._({
id: "email.digest.playlistMeta",
message: "by @{username} · {n, plural, one {# dump} other {# dumps}}",
values: { username: owner, n },
});
i18n._("email.digest.playlistMeta", { username: owner, n });
/**
* Returns the digest `content` describes, written as Markdown and drawn in
@@ -272,7 +197,7 @@ export function renderDigestEmail(
new Intl.DateTimeFormat(EMAIL_LOCALE, { weekday: "short" }).format(d);
const figure = (n: number, label: string) =>
`**${nf.format(n)}** ${escMd(label)}`;
const yours = i18n._({ id: "email.digest.yours", message: "yours" });
const yours = i18n._("email.digest.yours");
/** A dump as a list item: its thumbnail, title, pill and detail. */
const dumpItem = (d: DigestDump, bullet: string, image = true) =>
`${bullet} ${image && d.thumbnail ? `![](${d.thumbnail}) ` : ""}${
@@ -290,32 +215,16 @@ export function renderDigestEmail(
const md: string[] = [
`*${
escMd(
`${
monthly
? i18n._({
id: "digest.monthly",
message: "Monthly recap",
})
: i18n._({
id: "digest.weekly",
message: "Weekly digest",
})
} · ${range(true)}`,
`${monthly ? i18n._("digest.monthly") : i18n._("digest.weekly")} · ${
range(true)
}`,
)
}*`,
`# ${
escMd(
monthly
? i18n._({
id: "email.digest.monthly.title",
message: "The month on {site}",
values: { site: OG_SITE_NAME },
})
: i18n._({
id: "email.digest.weekly.title",
message: "Your week, {username}",
values: { username: content.reader },
}),
? i18n._("email.digest.monthly.title", { site: OG_SITE_NAME })
: i18n._("email.digest.weekly.title", { username: content.reader }),
)
}`,
];
@@ -323,7 +232,7 @@ export function renderDigestEmail(
const { activity, network, community } = content;
if (activity) {
md.push(
`## ${escMd(i18n._({ id: "email.digest.forYou", message: "For you" }))}`,
`## ${escMd(i18n._("email.digest.forYou"))}`,
ACTIVITY_ORDER.filter((k) => (activity.counts[k] ?? 0) > 0)
.map((k) =>
figure(activity.counts[k]!, countLabel(k, activity.counts[k]!))
@@ -332,7 +241,7 @@ export function renderDigestEmail(
activity.highlights.map((h) => {
const target = h.kind === "mentionChat"
? mdLink(
i18n._({ id: "email.digest.theChat", message: "the chat" }),
i18n._("email.digest.theChat"),
content.links.chat,
)
: h.target
@@ -345,12 +254,7 @@ export function renderDigestEmail(
}).join("\n"),
`${
mdLink(
i18n._({
id: "email.digest.seeAll",
message:
"{n, plural, one {See the notification} other {See all # notifications}}",
values: { n: activity.total },
}),
i18n._("email.digest.seeAll", { n: activity.total }),
content.links.notifications,
)
} →`,
@@ -359,22 +263,13 @@ export function renderDigestEmail(
if (network) {
md.push(
`## ${
escMd(i18n._({
id: "email.digest.network",
message: "From people you follow",
}))
}`,
`## ${escMd(i18n._("email.digest.network"))}`,
network.dumps.map((d) => dumpItem(d, "-")).join("\n"),
);
if (network.more > 0) {
md.push(`${
mdLink(
i18n._({
id: "email.digest.more",
message: "{n} more on your Followed feed",
values: { n: network.more },
}),
i18n._("email.digest.more", { n: network.more }),
content.links.followed,
)
} →`);
@@ -387,8 +282,8 @@ export function renderDigestEmail(
`## ${
escMd(
monthly
? i18n._({ id: "email.digest.topMonth", message: "Top this month" })
: i18n._({ id: "email.digest.topWeek", message: "Top this week" }),
? i18n._("email.digest.topMonth")
: i18n._("email.digest.topWeek"),
)
}`,
);
@@ -404,30 +299,20 @@ export function renderDigestEmail(
if (community) {
const { dumps, upvotes, comments } = community.totals;
md.push(
`## ${
escMd(i18n._({ id: "email.digest.community", message: "Community" }))
}`,
`## ${escMd(i18n._("email.digest.community"))}`,
);
if (monthly) {
md.push(
[
figure(
dumps,
i18n._({
id: "email.digest.stat.dumps",
message: "{n, plural, one {dump} other {dumps}}",
values: { n: dumps },
}),
i18n._("email.digest.stat.dumps", { n: dumps }),
),
figure(upvotes, countLabel("upvotes", upvotes)),
figure(comments, countLabel("comments", comments)),
figure(
community.chatMessages,
i18n._({
id: "email.digest.stat.chat",
message: "{n, plural, one {chat message} other {chat messages}}",
values: { n: community.chatMessages },
}),
i18n._("email.digest.stat.chat", { n: community.chatMessages }),
),
].join(" · "),
);
@@ -436,7 +321,7 @@ export function renderDigestEmail(
md.push(
`### ${
escMd(
i18n._({ id: "email.digest.newMembers", message: "New members" }),
i18n._("email.digest.newMembers"),
)
}`,
[
@@ -455,12 +340,7 @@ export function renderDigestEmail(
}
if (community.playlists.length > 0) {
md.push(
`### ${
escMd(i18n._({
id: "email.digest.newPlaylists",
message: "New public playlists",
}))
}`,
`### ${escMd(i18n._("email.digest.newPlaylists"))}`,
[
...community.playlists.map((p) =>
`- ${mdLink(p.title, p.url)} · ${
@@ -475,33 +355,23 @@ export function renderDigestEmail(
}
if (!monthly && community.chatMessages > 0) {
md.push(
`### ${
escMd(i18n._({ id: "email.digest.inChat", message: "In the chat" }))
}`,
`### ${escMd(i18n._("email.digest.inChat"))}`,
`${
escMd(i18n._({
id: "email.digest.chatWeek",
message:
"{n, plural, one {# message this week} other {# messages this week}}",
values: { n: community.chatMessages },
}))
escMd(i18n._("email.digest.chatWeek", { n: community.chatMessages }))
} · ${
mdLink(
i18n._({ id: "email.digest.openChat", message: "Open the chat" }),
i18n._("email.digest.openChat"),
content.links.chat,
)
}`,
);
}
if (!monthly && dumps + upvotes + comments > 0) {
md.push(`*${
escMd(i18n._({
id: "email.digest.totals",
message:
"{dumps, plural, one {# dump} other {# dumps}}, {upvotes, plural, one {# upvote} other {# upvotes}} and {comments, plural, one {# comment} other {# comments}}.",
values: { dumps, upvotes, comments },
}))
}*`);
md.push(
`*${
escMd(i18n._("email.digest.totals", { dumps, upvotes, comments }))
}*`,
);
}
}
@@ -509,33 +379,17 @@ export function renderDigestEmail(
"---",
escMd(
monthly
? i18n._({
id: "email.digest.monthly.reason",
message:
"You're getting the monthly recap because it's on in your settings.",
})
: i18n._({
id: "email.digest.weekly.reason",
message:
"You're getting the weekly digest because it's on in your settings.",
}),
? i18n._("email.digest.monthly.reason")
: i18n._("email.digest.weekly.reason"),
),
`${
mdLink(
monthly
? i18n._({
id: "digest.monthly.off",
message: "Turn off monthly recaps",
})
: i18n._({
id: "digest.weekly.off",
message: "Turn off weekly digests",
}),
monthly ? i18n._("digest.monthly.off") : i18n._("digest.weekly.off"),
content.links.unsubscribe,
)
} · ${
mdLink(
i18n._({ id: "email.settings", message: "Settings" }),
i18n._("email.settings"),
content.links.settings,
)
}`,
@@ -543,15 +397,13 @@ export function renderDigestEmail(
return new EmailKit(look).render({
subject: monthly
? i18n._({
id: "email.digest.monthly.subject",
message: "The month on {site}, {range}",
values: { site: OG_SITE_NAME, range: range(false) },
? i18n._("email.digest.monthly.subject", {
site: OG_SITE_NAME,
range: range(false),
})
: i18n._({
id: "email.digest.weekly.subject",
message: "Your week on {site}, {range}",
values: { site: OG_SITE_NAME, range: range(false) },
: i18n._("email.digest.weekly.subject", {
site: OG_SITE_NAME,
range: range(false),
}),
preheader: preheader(content),
markdown: md.join("\n\n"),
@@ -572,12 +424,7 @@ function preheader(content: DigestContent): string {
const n = (content.network?.dumps.length ?? 0) +
(content.network?.more ?? 0);
if (n > 0) {
parts.push(i18n._({
id: "email.digest.pre.network",
message:
"{n, plural, one {# new dump from people you follow} other {# new dumps from people you follow}}",
values: { n },
}));
parts.push(i18n._("email.digest.pre.network", { n }));
}
} else {
const members = (content.community?.members.length ?? 0) +
@@ -585,35 +432,19 @@ function preheader(content: DigestContent): string {
const playlists = (content.community?.playlists.length ?? 0) +
(content.community?.playlistsMore ?? 0);
if (content.top.length > 0) {
parts.push(i18n._({
id: "email.digest.pre.topMonth",
message:
"{n, plural, one {the most upvoted dump of the month} other {the # most upvoted dumps of the month}}",
values: { n: content.top.length },
}));
parts.push(
i18n._("email.digest.pre.topMonth", { n: content.top.length }),
);
}
if (members > 0) {
parts.push(i18n._({
id: "email.digest.pre.members",
message: "{n, plural, one {# new member} other {# new members}}",
values: { n: members },
}));
parts.push(i18n._("email.digest.pre.members", { n: members }));
}
if (playlists > 0) {
parts.push(i18n._({
id: "email.digest.pre.playlists",
message: "{n, plural, one {# new playlist} other {# new playlists}}",
values: { n: playlists },
}));
parts.push(i18n._("email.digest.pre.playlists", { n: playlists }));
}
}
if (parts.length === 0 && content.top.length > 0) {
parts.push(i18n._({
id: "email.digest.pre.topWeek",
message:
"{n, plural, one {the most upvoted dump of the week} other {the # most upvoted dumps of the week}}",
values: { n: content.top.length },
}));
parts.push(i18n._("email.digest.pre.topWeek", { n: content.top.length }));
}
if (parts.length === 0) return "";
const line = new Intl.ListFormat(EMAIL_LOCALE, { type: "conjunction" })