Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
4772a19
feat(home): 콘텐츠 탐색 표면 정리
dev-wooyeon Jul 13, 2026
ea6b371
feat(typography): 콘텐츠 중심 타입 스케일 적용
dev-wooyeon Jul 13, 2026
29ba7d2
fix(content): 새 글 공개 기본값 차단
dev-wooyeon Jul 13, 2026
9416968
feat(home): 블로그 정체성과 추천 경로 추가
dev-wooyeon Jul 13, 2026
a213ae2
fix(content): 공개 글 신뢰 기준 재정비
dev-wooyeon Jul 13, 2026
34d82f0
fix(article): 실제 스크롤 기준으로 읽기 UX 수정
dev-wooyeon Jul 13, 2026
a2cbd5a
fix(site): 탐색 복구 경로와 연락처 정합성 수정
dev-wooyeon Jul 13, 2026
9379593
fix(home): 최근 30일 인기 조회 집계 적용
dev-wooyeon Jul 13, 2026
5c4bcaf
fix(seo): OG 이미지 폰트 의존성 안정화
dev-wooyeon Jul 13, 2026
7d9f42d
perf(font): TossFace 선로딩 제거
dev-wooyeon Jul 13, 2026
c483086
perf(mdx): 시각화 청크를 글 단위로 격리
dev-wooyeon Jul 13, 2026
65b5280
perf(theme): 전환 애니메이션 렌더 비용 축소
dev-wooyeon Jul 13, 2026
9d7c0a9
ci(quality): 배포 품질 gate 자동화
dev-wooyeon Jul 13, 2026
bbd569c
perf(shell): 클라이언트 글 payload 축소
dev-wooyeon Jul 13, 2026
d14b986
docs(adr): 인터페이스 관찰 정본 참조
dev-wooyeon Jul 13, 2026
1e1c5a5
style(home): 불필요한 소개 문구 제거
dev-wooyeon Jul 13, 2026
cd079ab
refactor(home): 홈을 단일 피드 구조로 단순화
dev-wooyeon Jul 13, 2026
d0772db
refactor(home): 최신순 단일 피드로 단순화
dev-wooyeon Jul 13, 2026
63281f4
fix(seo): OG 이미지 함수 용량 제한 해소
dev-wooyeon Jul 13, 2026
76bce58
fix(seo): Vercel OG 폰트 경로 보정
dev-wooyeon Jul 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .github/workflows/quality.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
name: Quality

on:
pull_request:
push:
branches:
- master

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Run quality workflow on main pushes

In this checkout the long-lived branch is main and there is no master branch, so this push.branches filter prevents the new Quality workflow from running on merges or direct pushes to the actual release branch. The workflow still runs for PRs, but the post-merge/release quality gate is skipped; point this at main or include both branch names.

Useful? React with 👍 / 👎.


concurrency:
group: quality-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
validate:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm

- name: Install dependencies
run: npm ci

- name: Install Chromium
run: npx playwright install --with-deps chromium

- name: Run repository quality gate
run: npm run test:ci

- name: Upload browser failures
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: |
playwright-report
.cache/test-results
if-no-files-found: ignore
retention-days: 7
Binary file added app/api/og/Pretendard-Bold.ttf
Binary file not shown.
26 changes: 26 additions & 0 deletions app/api/og/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// @vitest-environment node

import { describe, expect, it } from 'vitest';
import { NextRequest } from 'next/server';

const PNG_SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10];

describe('GET /api/og', () => {
it('returns a non-empty PNG from the Node.js runtime', async () => {
const { GET, runtime } = await import('./route');
const response = await GET(
new NextRequest(
'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'
)
);
const bytes = new Uint8Array(await response.arrayBuffer());

expect(runtime).toBe('nodejs');
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toBe('image/png');
expect(bytes.byteLength).toBeGreaterThan(10_000);
expect(Array.from(bytes.slice(0, PNG_SIGNATURE.length))).toEqual(
PNG_SIGNATURE
);
}, 30_000);
});
39 changes: 26 additions & 13 deletions app/api/og/route.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,35 @@
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { ImageResponse } from 'next/og';
import { NextRequest } from 'next/server';
import type { NextRequest } from 'next/server';
import { SITE_NAME } from '@/site/config/site';

export const runtime = 'edge';
export const runtime = 'nodejs';

const fontPath = join(process.cwd(), 'app', 'api', 'og', 'Pretendard-Bold.ttf');
let fontDataPromise: Promise<ArrayBuffer> | null = null;

function loadFontData(): Promise<ArrayBuffer> {
if (!fontDataPromise) {
fontDataPromise = readFile(fontPath).then(
(font) =>
font.buffer.slice(
font.byteOffset,
font.byteOffset + font.byteLength
) as ArrayBuffer
);
}

return fontDataPromise;
}

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

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

return new ImageResponse(
<div
Expand All @@ -37,7 +49,7 @@ export async function GET(req: NextRequest) {
display: 'flex',
flexDirection: 'column',
gap: '20px',
fontFamily: '"Noto Sans KR"',
fontFamily: 'Pretendard',
}}
>
{date && (
Expand Down Expand Up @@ -73,6 +85,7 @@ export async function GET(req: NextRequest) {
<div
key={tag}
style={{
display: 'flex',
backgroundColor: 'rgba(49, 130, 246, 0.1)',
color: '#3182f6',
padding: '8px 24px',
Expand Down Expand Up @@ -102,7 +115,7 @@ export async function GET(req: NextRequest) {
fontSize: '32px',
fontWeight: 700,
color: '#3182f6',
fontFamily: '"Noto Sans KR"',
fontFamily: 'Pretendard',
}}
>
{SITE_NAME}
Expand All @@ -114,7 +127,7 @@ export async function GET(req: NextRequest) {
height: 630,
fonts: [
{
name: 'Noto Sans KR',
name: 'Pretendard',
data: fontData,
style: 'normal',
weight: 700,
Expand Down
26 changes: 26 additions & 0 deletions app/layout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import fs from 'node:fs';
import path from 'node:path';
import { describe, expect, it } from 'vitest';

const layoutPath = path.resolve(process.cwd(), 'app/layout.tsx');
const layoutContent = fs.readFileSync(layoutPath, 'utf8');
const tossfacePath = path.resolve(process.cwd(), 'styles/tossface.css');
const tossfaceContent = fs.readFileSync(tossfacePath, 'utf8');

describe('root layout font loading', () => {
it('preloads the primary text font without eagerly loading Tossface', () => {
expect(layoutContent).toContain('href="/fonts/PretendardVariable.woff2"');
expect(layoutContent).not.toContain('href="/fonts/TossFaceFontWeb.otf"');
});

it('passes a public client DTO instead of full editorial metadata', () => {
expect(layoutContent).toContain('selectClientPosts(getSortedFeedData())');
expect(layoutContent).toContain('<AppProviders posts={posts}>');
});

it('keeps Tossface demand-driven for matching emoji glyphs', () => {
expect(layoutContent).toContain("import '@/styles/tossface.css';");
expect(tossfaceContent).toContain("font-family: 'Tossface Safe';");
expect(tossfaceContent).toContain('unicode-range:');
});
});
18 changes: 4 additions & 14 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import '@/styles/tossface.css';

import AppProviders from '@/site/providers/AppProviders';
import { getSortedFeedData } from '@/blog/services/post-repository';
import { selectClientPosts } from '@/site/providers/client-posts';
import { AppShell } from '@/site/shell/AppShell';
import {
SITE_AUTHOR,
Expand Down Expand Up @@ -59,12 +60,8 @@ export const viewport: Viewport = {
initialScale: 1,
};

export default function RootLayout({
children,
}: {
children: ReactNode;
}) {
const posts = getSortedFeedData();
export default function RootLayout({ children }: { children: ReactNode }) {
const posts = selectClientPosts(getSortedFeedData());

return (
<html lang="ko" suppressHydrationWarning>
Expand All @@ -76,16 +73,9 @@ export default function RootLayout({
type="font/woff2"
crossOrigin="anonymous"
/>
<link
rel="preload"
href="/fonts/TossFaceFontWeb.otf"
as="font"
type="font/otf"
crossOrigin="anonymous"
/>
</head>
<body>
<AppProviders>
<AppProviders posts={posts}>
<div id="app-root">
<AppShell posts={posts}>{children}</AppShell>
</div>
Expand Down
16 changes: 16 additions & 0 deletions app/not-found.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import NotFound from './not-found';

describe('NotFound', () => {
it('offers a Korean recovery path to the home page', () => {
render(<NotFound />);

expect(
screen.getByRole('heading', { name: '페이지를 찾을 수 없어요' })
).toBeInTheDocument();
expect(
screen.getByRole('link', { name: '홈으로 돌아가기' })
).toHaveAttribute('href', '/');
});
});
27 changes: 27 additions & 0 deletions app/not-found.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import Link from 'next/link';
import { Container } from '@/ui/layout';

export default function NotFound() {
return (
<main>
<Container size="sm" className="py-24 text-center md:py-32">
<p className="text-meta font-semibold text-[var(--color-toss-blue)]">
404
</p>
<h1 className="mt-3 text-2xl font-bold tracking-tight text-[var(--color-text-primary)] sm:text-3xl">
페이지를 찾을 수 없어요
</h1>
<p className="mx-auto mt-4 max-w-xl text-reading leading-relaxed text-[var(--color-text-secondary)]">
주소가 바뀌었거나 글이 아직 공개되지 않았습니다. 홈에서 다른 기록을
살펴보세요.
</p>
<Link
href="/"
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)]"
>
홈으로 돌아가기
</Link>
</Container>
</main>
);
}
4 changes: 2 additions & 2 deletions app/sitemap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,14 @@ describe('sitemap', () => {
expect(urls).toEqual(
expect.arrayContaining([
SITE_URL,
`${SITE_URL}/blog`,
`${SITE_URL}/engineering`,
`${SITE_URL}/life`,
`${SITE_URL}/series`,
`${SITE_URL}/resume`,
`${SITE_URL}/rss.xml`,
])
);
expect(urls).not.toContain(`${SITE_URL}/blog`);
expect(urls).not.toContain(`${SITE_URL}/series`);
});

it('uses updated date for post lastModified when available', () => {
Expand Down
2 changes: 0 additions & 2 deletions app/sitemap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,8 @@ export default function sitemap(): MetadataRoute.Sitemap {

const routePaths: SitePath[] = [
'',
'/blog',
'/engineering',
'/life',
'/series',
'/resume',
SITE_FEED_PATH,
];
Expand Down
Loading
Loading