Skip to content

Commit 34faee4

Browse files
committed
Merge tag '0.9.16'
Hollo 0.9.16 # -----BEGIN PGP SIGNATURE----- # # iQEzBAABCgAdFiEEN0sVrzI3lqYqsbzjxCns1X7tbMoFAmqQaUkACgkQxCns1X7t # bMr7bggAghs+qoDqIfw4n6yPYMnFcq+kt9S9WdU0OapQ43r1bMzmIvzbs5Pyq46p # LjsbUmhXY1zeSNqwSZ7Ty2REqShyknBcgOGwS0C3E9ZxohPcyU+ILAbvCcao++3e # i8TQPWE28VrIaLJ6wOPfjzUqfb98PL1xk3vrUfJG9kLuPRyyuaSx82zmccWKv30V # BLNKA568A3CkeTMmC3d4VpW2tmaKGP2WBMhZMO4XPZVxOlj+XZoIK3tuo5c2celJ # xqN1oGQLS144HOLN90hCXapudJSevpRygadOEPf6YRGrVcFlMdi5BuKlrt9c9LQT # muOxR2K7njQJytdup1+l3MzUwqO8Zg== # =S5DB # -----END PGP SIGNATURE----- # gpg: Signature made Fri 28 Aug 2026 01:43:53 AM KST # gpg: using RSA key 374B15AF323796A62AB1BCE3C429ECD57EED6CCA # gpg: Good signature from "Hong Minhee <hong@minhee.org>" [ultimate] # gpg: aka "Hong Minhee <minhee@dahlia.kr>" [ultimate] # gpg: aka "Hong Minhee <hong.minhee@gmail.com>" [ultimate] # gpg: aka "Hong Minhee <dahlia@lunant.net>" [ultimate] # gpg: aka "Hong Minhee <hongminhee@member.fsf.org>" [ultimate] # Conflicts: # CHANGES.md # package.json # src/pages/profile/index.test.tsx
2 parents a9f7611 + 7f8bda0 commit 34faee4

7 files changed

Lines changed: 291 additions & 27 deletions

File tree

‎CHANGES.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,17 @@ To be released.
8080
[#549]: https://github.com/fedify-dev/hollo/issues/549
8181

8282

83+
Version 0.9.16
84+
--------------
85+
86+
Released on August 28, 2026.
87+
88+
- Fixed public profile pages downloading hundreds of CJK web font fragments
89+
when rendering multilingual timelines. CJK text now uses
90+
language-specific system font stacks, while Inter and JetBrains Mono remain
91+
web fonts.
92+
93+
8394
Version 0.9.15
8495
--------------
8596

‎DESIGN.md‎

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -143,17 +143,25 @@ Typography
143143

144144
### Type families
145145

146-
| Role | Family | Source |
147-
| -------- | ---------------------------------------------- | ------------------------- |
148-
| Sans | *Inter* | bunny.net (Google mirror) |
149-
| Sans CJK | *Noto Sans KR*, *Noto Sans JP*, *Noto Sans SC* | bunny.net |
150-
| Mono | *JetBrains Mono* | bunny.net |
146+
| Role | Family | Source |
147+
| -------- | ------------------------------ | ------------------------- |
148+
| Sans | *Inter* | bunny.net (Google mirror) |
149+
| Sans CJK | Language-specific system fonts | Operating system |
150+
| Mono | *JetBrains Mono* | bunny.net |
151151

152152
Fonts are loaded through UnoCSS's `presetWebFonts` with the `bunny`
153-
provider, which is a privacy-respecting mirror of Google Fonts. The CSS
154-
font stack lists Inter first, then the three Noto Sans CJK families, and
155-
falls back to the system stack so initial paint never blocks on a
156-
network request.
153+
provider, which is a privacy-respecting mirror of Google Fonts. Only Inter
154+
and JetBrains Mono are loaded from the provider. CJK web fonts are split into
155+
hundreds of Unicode-range files per family and weight, so a multilingual
156+
timeline can download most of those files. Hollo avoids that cost by using
157+
language-specific system font stacks for Korean, Japanese, Simplified Chinese,
158+
Traditional Chinese, and Hong Kong Chinese. Add a `lang` attribute to the
159+
boundary of user-authored text whenever its language is known; the font stack
160+
is selected from that explicit boundary and inherited by its contents. The
161+
bare `zh` value offered by account settings uses the Simplified Chinese stack;
162+
script-specific and region-specific tags select the matching stack instead. An
163+
explicit `Hans` script takes precedence over the Hong Kong region, while
164+
`zh-Hant-HK` uses the Hong Kong stack.
157165

158166
### Type scale
159167

‎src/components/Post.tsx‎

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ export function Post({
135135
href={authorUrl}
136136
dangerouslySetInnerHTML={{ __html: authorNameHtml }}
137137
aria-label={account.name}
138+
lang={account.owner?.language}
138139
class="hover:underline"
139140
/>
140141
</div>
@@ -147,6 +148,7 @@ export function Post({
147148
Reply to{" "}
148149
<a
149150
href={post.replyTarget.url ?? post.replyTarget.iri}
151+
lang={post.replyTarget.account.owner?.language}
150152
class="hover:text-brand-700 dark:hover:text-brand-400"
151153
>
152154
{post.replyTarget.account.name}
@@ -383,17 +385,23 @@ function PostContent({ post, featured, baseUrl }: PostContentProps) {
383385
lang={post.language ?? undefined}
384386
/>
385387
)}
386-
{post.poll != null && <Poll poll={post.poll} />}
388+
{post.poll != null && <Poll poll={post.poll} language={post.language} />}
387389
{post.media.length > 0 && (
388390
<div class="mt-3 grid gap-2 sm:grid-cols-2">
389391
{orderMedia(post.media).map((medium) => (
390392
<figure class="m-0">
391-
<Medium medium={medium} baseUrl={baseUrl} />
393+
<Medium
394+
medium={medium}
395+
language={post.language}
396+
baseUrl={baseUrl}
397+
/>
392398
{medium.description && medium.description.trim() !== "" && (
393399
<figcaption class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
394400
<details>
395401
<summary class="cursor-pointer">ALT text</summary>
396-
<p class="mt-1">{medium.description}</p>
402+
<p lang={post.language ?? undefined} class="mt-1">
403+
{medium.description}
404+
</p>
397405
</details>
398406
</figcaption>
399407
)}
@@ -534,9 +542,10 @@ function PreviewCardView({ card, baseUrl }: PreviewCardViewProps) {
534542

535543
interface PollProps {
536544
readonly poll: DbPoll & { options: PollOption[] };
545+
readonly language: string | null;
537546
}
538547

539-
function Poll({ poll }: PollProps) {
548+
function Poll({ poll, language }: PollProps) {
540549
const options = poll.options;
541550
options.sort((a, b) => (a.index < b.index ? -1 : 1));
542551
const totalVotes = options.reduce(
@@ -558,7 +567,10 @@ function Poll({ poll }: PollProps) {
558567
aria-hidden="true"
559568
/>
560569
<div class="relative flex items-center justify-between gap-3 px-3 py-2 text-sm">
561-
<span class="text-neutral-900 dark:text-neutral-100">
570+
<span
571+
lang={language ?? undefined}
572+
class="text-neutral-900 dark:text-neutral-100"
573+
>
562574
{option.title}
563575
</span>
564576
<span class="text-neutral-500 tabular-nums dark:text-neutral-400">
@@ -574,10 +586,11 @@ function Poll({ poll }: PollProps) {
574586

575587
interface MediumProps {
576588
readonly medium: DbMedium;
589+
readonly language: string | null;
577590
readonly baseUrl: URL | string;
578591
}
579592

580-
function Medium({ medium, baseUrl }: MediumProps) {
593+
function Medium({ medium, language, baseUrl }: MediumProps) {
581594
const linkUrl = proxyUrl(medium.url, baseUrl);
582595
const thumbnailUrl = medium.thumbnailCleaned
583596
? null
@@ -592,6 +605,7 @@ function Medium({ medium, baseUrl }: MediumProps) {
592605
key={medium.id}
593606
src={thumbnailUrl}
594607
alt={medium.description ?? ""}
608+
lang={language ?? undefined}
595609
width={medium.thumbnailWidth}
596610
height={medium.thumbnailHeight}
597611
class="block h-auto w-full object-cover"

‎src/components/Profile.tsx‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ export function Profile({ accountOwner, baseUrl }: ProfileProps) {
129129
href={url}
130130
dangerouslySetInnerHTML={{ __html: nameHtml }}
131131
aria-label={account.name}
132+
lang={accountOwner.language}
132133
class="hover:underline"
133134
/>
134135
</h1>
@@ -173,12 +174,16 @@ export function Profile({ accountOwner, baseUrl }: ProfileProps) {
173174
<div
174175
class={proseClass}
175176
dangerouslySetInnerHTML={{ __html: bioHtml }}
177+
lang={accountOwner.language}
176178
/>
177179
)}
178180
{fieldEntries.length > 0 && (
179181
<dl class="mt-5 grid grid-cols-1 gap-x-6 gap-y-3 sm:grid-cols-2">
180182
{fieldEntries.map(([key, value]) => (
181-
<div class="border-t border-neutral-200 pt-3 dark:border-neutral-800">
183+
<div
184+
lang={accountOwner.language}
185+
class="border-t border-neutral-200 pt-3 dark:border-neutral-800"
186+
>
182187
<dt class="text-xs font-medium uppercase tracking-wider text-neutral-500 dark:text-neutral-400">
183188
{key}
184189
</dt>

‎src/pages/profile/index.test.tsx‎

Lines changed: 180 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,20 @@
11
import { eq } from "drizzle-orm";
2+
import { parseHTML } from "linkedom";
23
import { beforeEach, describe, expect, it } from "vitest";
34

45
import { cleanDatabase } from "../../../tests/helpers";
56
import { createAccount } from "../../../tests/helpers/oauth";
67
import db from "../../db";
7-
import { accounts, featuredTags, instances, posts } from "../../schema";
8+
import {
9+
accountOwners,
10+
accounts,
11+
featuredTags,
12+
instances,
13+
media,
14+
pollOptions,
15+
polls,
16+
posts,
17+
} from "../../schema";
818
import { uuidv7 } from "../../uuid";
919
import app from "../index";
1020

@@ -78,9 +88,13 @@ describe.sequential("profile tagged page", () => {
7888
expect(html).not.toContain("Private matching tag post");
7989
});
8090

81-
it("links featured tags to the profile-specific tagged page", async () => {
82-
expect.assertions(2);
91+
it("marks profile tag labels with the owner's default language", async () => {
92+
expect.assertions(5);
8393

94+
await db
95+
.update(accountOwners)
96+
.set({ language: "ko" })
97+
.where(eq(accountOwners.id, account.id));
8498
await db.insert(featuredTags).values({
8599
id: uuidv7(),
86100
accountOwnerId: account.id,
@@ -91,7 +105,169 @@ describe.sequential("profile tagged page", () => {
91105
const response = await app.request("/@hollo");
92106

93107
expect(response.status).toBe(200);
94-
expect(await response.text()).toContain('href="/@hollo/tagged/TestTag"');
108+
109+
const { document } = parseHTML(await response.text());
110+
const featuredTag = document.querySelector(
111+
'a[href="/@hollo/tagged/TestTag"]',
112+
);
113+
114+
expect(featuredTag?.textContent?.trim()).toBe("#TestTag");
115+
expect(featuredTag?.getAttribute("lang")).toBe("ko");
116+
117+
const taggedResponse = await app.request("/@hollo/tagged/TestTag");
118+
119+
expect(taggedResponse.status).toBe(200);
120+
121+
const taggedPage = parseHTML(await taggedResponse.text()).document;
122+
123+
expect(taggedPage.querySelector("h2 span")?.getAttribute("lang")).toBe(
124+
"ko",
125+
);
126+
});
127+
128+
it("marks profile-authored text with the owner's default language", async () => {
129+
expect.assertions(4);
130+
131+
await db
132+
.update(accountOwners)
133+
.set({ language: "ko" })
134+
.where(eq(accountOwners.id, account.id));
135+
await db
136+
.update(accounts)
137+
.set({
138+
name: "테스트 사용자",
139+
bioHtml: "<p>안녕하세요.</p>",
140+
fieldHtmls: { 웹사이트: "<p>https://hollo.test/</p>" },
141+
})
142+
.where(eq(accounts.id, account.id));
143+
144+
const response = await app.request("/@hollo");
145+
146+
expect(response.status).toBe(200);
147+
148+
const { document } = parseHTML(await response.text());
149+
const profile = document.querySelector("main > header");
150+
151+
expect(profile?.querySelector("h1 a")?.getAttribute("lang")).toBe("ko");
152+
expect(profile?.querySelector(".prose")?.getAttribute("lang")).toBe("ko");
153+
expect(profile?.querySelector("dl > div")?.getAttribute("lang")).toBe("ko");
154+
});
155+
156+
it("marks poll option titles with the post language", async () => {
157+
expect.assertions(3);
158+
159+
const pollId = uuidv7();
160+
const postId = uuidv7();
161+
await db.transaction(async (tx) => {
162+
await tx.insert(polls).values({
163+
id: pollId,
164+
expires: new Date(Date.now() + 60_000),
165+
});
166+
await tx.insert(pollOptions).values({
167+
pollId,
168+
index: 0,
169+
title: "中文選項",
170+
});
171+
await tx.insert(posts).values({
172+
id: postId,
173+
iri: `https://hollo.test/@hollo/${postId}`,
174+
type: "Question",
175+
accountId: account.id,
176+
visibility: "public",
177+
content: "請選擇",
178+
contentHtml: "<p>請選擇</p>",
179+
language: "zh-Hant",
180+
pollId,
181+
published: new Date(),
182+
});
183+
});
184+
185+
const response = await app.request("/@hollo");
186+
187+
expect(response.status).toBe(200);
188+
189+
const { document } = parseHTML(await response.text());
190+
const option = document.querySelector("article ul li span[lang]");
191+
192+
expect(option?.textContent?.trim()).toBe("中文選項");
193+
expect(option?.getAttribute("lang")).toBe("zh-Hant");
194+
});
195+
196+
it("marks known account and attachment text with their languages", async () => {
197+
expect.assertions(5);
198+
199+
await db
200+
.update(accountOwners)
201+
.set({ language: "ko" })
202+
.where(eq(accountOwners.id, account.id));
203+
await db
204+
.update(accounts)
205+
.set({ name: "테스트 사용자" })
206+
.where(eq(accounts.id, account.id));
207+
208+
const replyTargetId = uuidv7();
209+
const replyTargetIri = `https://hollo.test/@hollo/${replyTargetId}`;
210+
const postId = uuidv7();
211+
await db.transaction(async (tx) => {
212+
await tx.insert(posts).values([
213+
{
214+
id: replyTargetId,
215+
iri: replyTargetIri,
216+
type: "Note",
217+
accountId: account.id,
218+
visibility: "private",
219+
content: "Reply target",
220+
contentHtml: "<p>Reply target</p>",
221+
language: "en",
222+
published: new Date(),
223+
},
224+
{
225+
id: postId,
226+
iri: `https://hollo.test/@hollo/${postId}`,
227+
type: "Note",
228+
accountId: account.id,
229+
replyTargetId,
230+
visibility: "public",
231+
content: "画像付き投稿",
232+
contentHtml: "<p>画像付き投稿</p>",
233+
language: "ja",
234+
published: new Date(),
235+
},
236+
]);
237+
await tx.insert(media).values({
238+
id: uuidv7(),
239+
postId,
240+
type: "image/png",
241+
url: "https://hollo.test/media/image.png",
242+
width: 640,
243+
height: 480,
244+
description: "画像の説明",
245+
thumbnailType: "image/png",
246+
thumbnailUrl: "https://hollo.test/media/image-thumbnail.png",
247+
thumbnailWidth: 320,
248+
thumbnailHeight: 240,
249+
});
250+
});
251+
252+
const response = await app.request("/@hollo");
253+
254+
expect(response.status).toBe(200);
255+
256+
const { document } = parseHTML(await response.text());
257+
const post = document.querySelector("article");
258+
259+
expect(post?.querySelector("header a[lang]")?.getAttribute("lang")).toBe(
260+
"ko",
261+
);
262+
expect(
263+
post?.querySelector(`a[href="${replyTargetIri}"]`)?.getAttribute("lang"),
264+
).toBe("ko");
265+
expect(post?.querySelector("figcaption p")?.getAttribute("lang")).toBe(
266+
"ja",
267+
);
268+
expect(
269+
post?.querySelector('img[alt="画像の説明"]')?.getAttribute("lang"),
270+
).toBe("ja");
95271
});
96272

97273
it("marks profiles whose account has moved", async () => {

‎src/pages/profile/index.tsx‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,12 @@ function ProfilePage({
232232
{tag != null && (
233233
<h2 class="mt-10 text-lg font-semibold text-neutral-900 dark:text-neutral-100">
234234
Posts tagged{" "}
235-
<span class="text-brand-700 dark:text-brand-400">#{tag}</span>
235+
<span
236+
lang={accountOwner.language}
237+
class="text-brand-700 dark:text-brand-400"
238+
>
239+
#{tag}
240+
</span>
236241
</h2>
237242
)}
238243
{featuredTags.length > 0 && (
@@ -243,6 +248,7 @@ function ProfilePage({
243248
{featuredTags.map((tag) => (
244249
<a
245250
href={`/@${accountOwner.handle}/tagged/${encodeURIComponent(tag.name)}`}
251+
lang={accountOwner.language}
246252
class="rounded-full border border-brand-200 bg-brand-50 px-3 py-0.5 font-medium text-brand-700 transition-colors hover:bg-brand-100 hover:border-brand-300 dark:border-brand-900 dark:bg-brand-950/40 dark:text-brand-400 dark:hover:bg-brand-900/40 dark:hover:border-brand-800"
247253
>
248254
#{tag.name}

0 commit comments

Comments
 (0)