Skip to content

Commit 0a51f80

Browse files
authored
feat(site): 콘텐츠 탐색과 읽기 경험 개선 (#113)
홈을 최신순 단일 피드로 단순화하고 읽기 경험, 콘텐츠 공개 정책, 성능 및 배포 안정성을 개선합니다.
2 parents 9d5a787 + 8326faa commit 0a51f80

87 files changed

Lines changed: 1630 additions & 889 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/quality.yml

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
name: Quality
2+
3+
on:
4+
pull_request:
5+
push:
6+
branches:
7+
- master
8+
9+
concurrency:
10+
group: quality-${{ github.workflow }}-${{ github.ref }}
11+
cancel-in-progress: true
12+
13+
jobs:
14+
validate:
15+
runs-on: ubuntu-latest
16+
timeout-minutes: 20
17+
steps:
18+
- name: Checkout
19+
uses: actions/checkout@v4
20+
21+
- name: Setup Node.js
22+
uses: actions/setup-node@v4
23+
with:
24+
node-version: 22
25+
cache: npm
26+
27+
- name: Install dependencies
28+
run: npm ci
29+
30+
- name: Install Chromium
31+
run: npx playwright install --with-deps chromium
32+
33+
- name: Run repository quality gate
34+
run: npm run test:ci
35+
36+
- name: Upload browser failures
37+
if: failure()
38+
uses: actions/upload-artifact@v4
39+
with:
40+
name: playwright-report
41+
path: |
42+
playwright-report
43+
.cache/test-results
44+
if-no-files-found: ignore
45+
retention-days: 7

app/api/og/Pretendard-Bold.ttf

2.6 MB
Binary file not shown.

app/api/og/route.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// @vitest-environment node
2+
3+
import { describe, expect, it } from 'vitest';
4+
import { NextRequest } from 'next/server';
5+
6+
const PNG_SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10];
7+
8+
describe('GET /api/og', () => {
9+
it('returns a non-empty PNG from the Node.js runtime', async () => {
10+
const { GET, runtime } = await import('./route');
11+
const response = await GET(
12+
new NextRequest(
13+
'https://ark-log.vercel.app/api/og?title=%ED%95%9C%EA%B8%80%20OG%20%EC%9D%B4%EB%AF%B8%EC%A7%80&tags=Next.js,Ark'
14+
)
15+
);
16+
const bytes = new Uint8Array(await response.arrayBuffer());
17+
18+
expect(runtime).toBe('nodejs');
19+
expect(response.status).toBe(200);
20+
expect(response.headers.get('content-type')).toBe('image/png');
21+
expect(bytes.byteLength).toBeGreaterThan(10_000);
22+
expect(Array.from(bytes.slice(0, PNG_SIGNATURE.length))).toEqual(
23+
PNG_SIGNATURE
24+
);
25+
}, 30_000);
26+
});

app/api/og/route.tsx

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,35 @@
1+
import { readFile } from 'node:fs/promises';
2+
import { join } from 'node:path';
13
import { ImageResponse } from 'next/og';
2-
import { NextRequest } from 'next/server';
4+
import type { NextRequest } from 'next/server';
35
import { SITE_NAME } from '@/site/config/site';
46

5-
export const runtime = 'edge';
7+
export const runtime = 'nodejs';
8+
9+
const fontPath = join(process.cwd(), 'app', 'api', 'og', 'Pretendard-Bold.ttf');
10+
let fontDataPromise: Promise<ArrayBuffer> | null = null;
11+
12+
function loadFontData(): Promise<ArrayBuffer> {
13+
if (!fontDataPromise) {
14+
fontDataPromise = readFile(fontPath).then(
15+
(font) =>
16+
font.buffer.slice(
17+
font.byteOffset,
18+
font.byteOffset + font.byteLength
19+
) as ArrayBuffer
20+
);
21+
}
22+
23+
return fontDataPromise;
24+
}
625

726
export async function GET(req: NextRequest) {
827
const { searchParams } = new URL(req.url);
928
const title = searchParams.get('title') || SITE_NAME;
1029
const date = searchParams.get('date');
1130
const tags = searchParams.get('tags')?.split(',') || [];
1231

13-
// Load Noto Sans KR for consistent rendering across environments
14-
// Using a Google Fonts raw file as a reliable source
15-
const fontData = await fetch(
16-
new URL(
17-
'https://github.com/google/fonts/raw/main/ofl/notosanskr/NotoSansKR-Bold.otf',
18-
import.meta.url
19-
)
20-
).then((res) => res.arrayBuffer());
32+
const fontData = await loadFontData();
2133

2234
return new ImageResponse(
2335
<div
@@ -37,7 +49,7 @@ export async function GET(req: NextRequest) {
3749
display: 'flex',
3850
flexDirection: 'column',
3951
gap: '20px',
40-
fontFamily: '"Noto Sans KR"',
52+
fontFamily: 'Pretendard',
4153
}}
4254
>
4355
{date && (
@@ -73,6 +85,7 @@ export async function GET(req: NextRequest) {
7385
<div
7486
key={tag}
7587
style={{
88+
display: 'flex',
7689
backgroundColor: 'rgba(49, 130, 246, 0.1)',
7790
color: '#3182f6',
7891
padding: '8px 24px',
@@ -102,7 +115,7 @@ export async function GET(req: NextRequest) {
102115
fontSize: '32px',
103116
fontWeight: 700,
104117
color: '#3182f6',
105-
fontFamily: '"Noto Sans KR"',
118+
fontFamily: 'Pretendard',
106119
}}
107120
>
108121
{SITE_NAME}
@@ -114,7 +127,7 @@ export async function GET(req: NextRequest) {
114127
height: 630,
115128
fonts: [
116129
{
117-
name: 'Noto Sans KR',
130+
name: 'Pretendard',
118131
data: fontData,
119132
style: 'normal',
120133
weight: 700,

app/layout.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
3+
import { describe, expect, it } from 'vitest';
4+
5+
const layoutPath = path.resolve(process.cwd(), 'app/layout.tsx');
6+
const layoutContent = fs.readFileSync(layoutPath, 'utf8');
7+
const tossfacePath = path.resolve(process.cwd(), 'styles/tossface.css');
8+
const tossfaceContent = fs.readFileSync(tossfacePath, 'utf8');
9+
10+
describe('root layout font loading', () => {
11+
it('preloads the primary text font without eagerly loading Tossface', () => {
12+
expect(layoutContent).toContain('href="/fonts/PretendardVariable.woff2"');
13+
expect(layoutContent).not.toContain('href="/fonts/TossFaceFontWeb.otf"');
14+
});
15+
16+
it('passes a public client DTO instead of full editorial metadata', () => {
17+
expect(layoutContent).toContain('selectClientPosts(getSortedFeedData())');
18+
expect(layoutContent).toContain('<AppProviders posts={posts}>');
19+
});
20+
21+
it('keeps Tossface demand-driven for matching emoji glyphs', () => {
22+
expect(layoutContent).toContain("import '@/styles/tossface.css';");
23+
expect(tossfaceContent).toContain("font-family: 'Tossface Safe';");
24+
expect(tossfaceContent).toContain('unicode-range:');
25+
});
26+
});

app/layout.tsx

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import '@/styles/tossface.css';
55

66
import AppProviders from '@/site/providers/AppProviders';
77
import { getSortedFeedData } from '@/blog/services/post-repository';
8+
import { selectClientPosts } from '@/site/providers/client-posts';
89
import { AppShell } from '@/site/shell/AppShell';
910
import {
1011
SITE_AUTHOR,
@@ -59,12 +60,8 @@ export const viewport: Viewport = {
5960
initialScale: 1,
6061
};
6162

62-
export default function RootLayout({
63-
children,
64-
}: {
65-
children: ReactNode;
66-
}) {
67-
const posts = getSortedFeedData();
63+
export default function RootLayout({ children }: { children: ReactNode }) {
64+
const posts = selectClientPosts(getSortedFeedData());
6865

6966
return (
7067
<html lang="ko" suppressHydrationWarning>
@@ -76,16 +73,9 @@ export default function RootLayout({
7673
type="font/woff2"
7774
crossOrigin="anonymous"
7875
/>
79-
<link
80-
rel="preload"
81-
href="/fonts/TossFaceFontWeb.otf"
82-
as="font"
83-
type="font/otf"
84-
crossOrigin="anonymous"
85-
/>
8676
</head>
8777
<body>
88-
<AppProviders>
78+
<AppProviders posts={posts}>
8979
<div id="app-root">
9080
<AppShell posts={posts}>{children}</AppShell>
9181
</div>

app/not-found.test.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { render, screen } from '@testing-library/react';
2+
import { describe, expect, it } from 'vitest';
3+
import NotFound from './not-found';
4+
5+
describe('NotFound', () => {
6+
it('offers a Korean recovery path to the home page', () => {
7+
render(<NotFound />);
8+
9+
expect(
10+
screen.getByRole('heading', { name: '페이지를 찾을 수 없어요' })
11+
).toBeInTheDocument();
12+
expect(
13+
screen.getByRole('link', { name: '홈으로 돌아가기' })
14+
).toHaveAttribute('href', '/');
15+
});
16+
});

app/not-found.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import Link from 'next/link';
2+
import { Container } from '@/ui/layout';
3+
4+
export default function NotFound() {
5+
return (
6+
<main>
7+
<Container size="sm" className="py-24 text-center md:py-32">
8+
<p className="text-meta font-semibold text-[var(--color-toss-blue)]">
9+
404
10+
</p>
11+
<h1 className="mt-3 text-2xl font-bold tracking-tight text-[var(--color-text-primary)] sm:text-3xl">
12+
페이지를 찾을 수 없어요
13+
</h1>
14+
<p className="mx-auto mt-4 max-w-xl text-reading leading-relaxed text-[var(--color-text-secondary)]">
15+
주소가 바뀌었거나 글이 아직 공개되지 않았습니다. 홈에서 다른 기록을
16+
살펴보세요.
17+
</p>
18+
<Link
19+
href="/"
20+
className="mt-8 inline-flex min-h-11 items-center justify-center rounded-[var(--radius-action)] bg-[var(--color-toss-blue)] px-5 text-sm font-semibold text-white transition-colors hover:bg-[var(--color-toss-blue-dark)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-toss-blue)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-bg-primary)]"
21+
>
22+
홈으로 돌아가기
23+
</Link>
24+
</Container>
25+
</main>
26+
);
27+
}

app/sitemap.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,14 @@ describe('sitemap', () => {
2929
expect(urls).toEqual(
3030
expect.arrayContaining([
3131
SITE_URL,
32-
`${SITE_URL}/blog`,
3332
`${SITE_URL}/engineering`,
3433
`${SITE_URL}/life`,
35-
`${SITE_URL}/series`,
3634
`${SITE_URL}/resume`,
3735
`${SITE_URL}/rss.xml`,
3836
])
3937
);
38+
expect(urls).not.toContain(`${SITE_URL}/blog`);
39+
expect(urls).not.toContain(`${SITE_URL}/series`);
4040
});
4141

4242
it('uses updated date for post lastModified when available', () => {

app/sitemap.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,8 @@ export default function sitemap(): MetadataRoute.Sitemap {
2828

2929
const routePaths: SitePath[] = [
3030
'',
31-
'/blog',
3231
'/engineering',
3332
'/life',
34-
'/series',
3533
'/resume',
3634
SITE_FEED_PATH,
3735
];

0 commit comments

Comments
 (0)