Skip to content

blog: TS6 글 마감 + 렌더링·타입·통계 개선 모음#130

Merged
Han5991 merged 27 commits into
mainfrom
blog/ts6-post-cta
Jun 28, 2026
Merged

blog: TS6 글 마감 + 렌더링·타입·통계 개선 모음#130
Han5991 merged 27 commits into
mainfrom
blog/ts6-post-cta

Conversation

@Han5991

@Han5991 Han5991 commented Jun 28, 2026

Copy link
Copy Markdown
Owner

요약

TS6 "업그레이드인 줄 알았는데" 글을 마감하며 곁따라 발견·수정한 렌더링/타입/통계 개선을 모았습니다.

📝 글 콘텐츠

  • TS6 글: 참여 유도(CTA) 추가, 파일명 정리(따옴표·공백 제거), 디렉터리 트리를 <file-tree>로, 맺음말 정리
  • Panda CSS / bundler 글의 트리도 <file-tree>로 전환

⚠️ merge 시 TS6 글이 발행됩니다 (published: true).

🎨 렌더링 수정

  • ssgoi: SsgoiTransition deprecation 대응 → data-ssgoi-transition을 재사용 컴포넌트 PageBoundary로 추출 (home/posts/about/privacy/PostClient 5곳)
  • CodeBlock: 언어 태그 없는 멀티라인 코드블록이 인라인으로 폴백해 줄마다 박스가 쪼개지던 것 → 블록으로 렌더. trim 판별로 raw inline <code><p> 안에서 <div>가 되는 hydration 오류 방지
  • FileTree: 루트 커넥터를 제거해 표준 트리 모양으로

📊 about 통계 실시간 연동

  • 블로그 글 수: getAllPostSummaries().length로 빌드타임 산출
  • PR 승인 수: 프로필 레포(Han5991/Han5991)의 config/summary.json(머지 PR 총수)을 CI가 NEXT_PUBLIC_PR_COUNT로 주입, 로컬/실패 시 폴백
    • 프로필 레포에 summary.json 출력 추가는 그 레포 main에 별도 반영 완료

🔧 타입 / 도구

  • next.js: vitest 4.1에서 jest-dom 매처 타입 보강 복구 — vitest가 Assertion@vitest/expect로 옮겨 declare module 'vitest' 보강이 안 붙던 것을 @vitest/expect 직접 augment + 동일 버전 deps 추가로 해결
  • chore: package.json engines 제거, useViewCount 정리, vitest/plugin-react 버전 bump

✅ 검증

  • pnpm check-types 전체 통과 (5/5)
  • next.js 테스트 2/2 통과
  • 코드블록·트리 렌더링은 dev 서버가 내보낸 HTML(curl)로 검증 (로컬 Chrome이 localhost 접속 불가로 시각 확인은 보류)

🤖 Generated with Claude Code

Han5991 and others added 13 commits June 28, 2026 12:30
@ssgoi/react 6.4에서 SsgoiTransition이 deprecated됨. 이 컴포넌트는 내부적으로 <div data-ssgoi-transition={id} className={...}>를 렌더할 뿐이라, 페이지 경계 div에 data-ssgoi-transition을 직접 부여하는 방식으로 치환. DOM 동일 → 동작 보존.

적용: home/posts/about/privacy 페이지 + PostClient. check-types 통과(5/5).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
5곳에 흩어진 <div data-ssgoi-transition={id}> 경계 마크업을 재사용 컴포넌트 PageBoundary로 통합. ssgoi 결합을 한 파일로 격리해 향후 전환 동작 조정 시 한 곳만 고치면 되게 함. 순수 마크업이라 use client 불필요 → server page에서도 그대로 사용.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
글 말미에 'tsconfig 점검' 액션 + 댓글 유도 단락 추가. 파일명의 따옴표·공백을 제거해 정리(slug는 frontmatter 기반이라 URL 영향 없음).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
블로그 글 수는 getAllPostSummaries().length로 빌드타임 산출. PR 승인 수는 Han5991/Han5991 프로필 레포의 config/summary.json(.mergedPRs)을 CI가 NEXT_PUBLIC_PR_COUNT로 주입하고, 로컬·실패 시 '58' 폴백. 컨퍼런스는 수동 상수.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ComponentPropsWithRef<'div'>를 extend해 표준 div 속성/ref를 받도록 타입 확장.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
vitest 4.1이 expect()의 Assertion을 @vitest/expect 모듈로 옮기고 vitest는 re-export만 하도록 바뀌면서, @testing-library/jest-dom(6.9.1)의 declare module 'vitest' 보강이 매처를 못 붙여 tsc가 깨졌다(런타임은 정상). 실제 인터페이스가 사는 @vitest/expect의 Assertion을 직접 보강하는 vitest.d.ts 추가 + pnpm isolation으로 보강 대상이 앱에서 resolve되도록 @vitest/expect를 vitest와 동일한 4.1.4로 직접 의존성에 추가.

주의: @vitest/expect는 next.js의 vitest와 버전을 맞춰 올려야 보강이 유지된다.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
언어 태그 없는 ``` 블록이라 CodeBlock이 인라인 코드로 폴백 → <pre> 안에서 줄마다 배경 박스가 쪼개져 보였다. <file-tree> 전용 렌더러로 전환. 더불어 FileTree가 루트(depth 0)에 커넥터를 붙이지 않도록 개선해 표준 트리 모양(루트 bare)으로 그린다.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
언어 없는 ``` 블록이 인라인 <code>로 폴백해, 여러 줄이면 <pre> 안에서 줄마다 배경 박스가 쪼개졌다. 내부에 줄바꿈이 있으면 블록으로 렌더하도록 수정. trim 후 판별해 raw HTML 인라인 <code>가 앞뒤 줄바꿈을 끼어도(예: 본문 줄넘김) <p> 안에 <div>가 들어가는 hydration 오류를 막는다.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
jest-dom 매처 보강 제네릭을 any 대신 E=unknown(기대값 인자 위치), R=T(반환)로 명시해 no-explicit-any를 정공법으로 통과. 빈 인터페이스 확장은 module augmentation에 구조적으로 필수라 no-empty-object-type만 인라인 비활성화.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 28, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
ant-blog Ready Ready Preview, Comment Jun 28, 2026 7:36am
fe-lab-next-js Ready Ready Preview, Comment Jun 28, 2026 7:36am

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

이번 PR은 TS6 관련 글을 마무리하며 블로그 전반의 렌더링 안정성, 타입 시스템 보강, 그리고 통계 데이터의 자동화 연동을 포함한 종합적인 개선을 담고 있습니다. 주요 변경 사항은 사용자 경험을 위한 UI 컴포넌트 표준화와 빌드 도구 환경의 최적화에 초점을 맞추고 있습니다.

Highlights

  • 글 마감 및 콘텐츠 개선: TS6 관련 글을 발행 상태로 전환하고, 여러 블로그 포스트의 파일 트리 구조를 <file-tree> 컴포넌트로 표준화했습니다.
  • 렌더링 및 UI 개선: 코드블록 렌더링 로직을 개선하여 인라인/블록 구분 오류를 방지하고, 페이지 전환을 위한 PageBoundary 컴포넌트를 도입했습니다.
  • 통계 실시간 연동: About 페이지의 통계 정보를 빌드 타임 데이터와 CI 환경 변수(NEXT_PUBLIC_PR_COUNT)를 통해 동적으로 표시하도록 변경했습니다.
  • 타입 및 도구 설정: Vitest 4.1 버전업에 따른 jest-dom 매처 타입 보강 문제를 해결하고, package.json의 불필요한 engines 설정을 제거했습니다.
Ignored Files
  • Ignored by pattern: .github/workflows/** (1)
    • .github/workflows/deploy-blog.yml
  • Ignored by pattern: pnpm-lock.yaml (1)
    • pnpm-lock.yaml
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

이번 풀 리퀘스트는 블로그 포스트의 마크다운 파일 구조 개선, SsgoiTransition을 대체하는 PageBoundary 컴포넌트 도입, 코드 블록 렌더링 및 파일 트리 렌더링 로직 개선, 그리고 Vitest 4.1+ 대응을 위한 타입 보강 작업을 포함하고 있습니다. 리뷰어는 PageBoundary 컴포넌트의 id 프로프가 표준 HTML id 속성과 충돌할 수 있으므로 transitionId 등으로 변경할 것과, 모노레포 내 의존성 일관성을 위해 apps/next.js 패키지의 vitest 버전을 업그레이드할 것을 제안했습니다.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +3 to +13
interface PageBoundaryProps extends ComponentPropsWithRef<'div'> {
/**
* `data-ssgoi-transition` 값 — 라우트 전환(hero/fade)을 매칭하는 키.
* 보통 해당 페이지 경로("/posts" 등)를 쓴다.
*/
id: string;
}

export const PageBoundary = ({ id, ...rest }: PageBoundaryProps) => (
<div data-ssgoi-transition={id} {...rest} />
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

현재 PageBoundary 컴포넌트는 id 프로프를 받아 data-ssgoi-transition 속성으로 매핑하고 있습니다. 하지만 id는 HTML의 표준 속성(HTML standard attribute)이므로, 이 컴포넌트를 사용하는 곳에서 실제 HTML id 속성을 div 엘리먼트에 전달하고자 할 때 해당 값이 무시되거나 의도치 않게 전환 키로만 사용되는 문제가 발생할 수 있습니다.

표준 HTML 속성과의 충돌을 방지하고 컴포넌트의 예측 가능성을 높이기 위해, 프로프 이름을 transitionId 또는 routeId와 같이 명확하게 변경하는 것을 권장합니다.

Suggested change
interface PageBoundaryProps extends ComponentPropsWithRef<'div'> {
/**
* `data-ssgoi-transition` 라우트 전환(hero/fade) 매칭하는 .
* 보통 해당 페이지 경로("/posts" ) 쓴다.
*/
id: string;
}
export const PageBoundary = ({ id, ...rest }: PageBoundaryProps) => (
<div data-ssgoi-transition={id} {...rest} />
);
interface PageBoundaryProps extends ComponentPropsWithRef<'div'> {
/**
* `data-ssgoi-transition` 라우트 전환(hero/fade) 매칭하는 .
* 보통 해당 페이지 경로("/posts" ) 쓴다.
*/
transitionId: string;
}
export const PageBoundary = ({ transitionId, ...rest }: PageBoundaryProps) => (
<div data-ssgoi-transition={transitionId} {...rest} />
);

Comment thread apps/next.js/package.json
Comment on lines 35 to 37
"@vitejs/plugin-react": "^5.0.4",
"@vitest/expect": "4.1.4",
"eslint": "^9",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

현재 apps/next.js/package.json에서 vitest 버전은 여전히 ^4.0.0-beta.15로 유지되고 있는 반면, @vitest/expect4.1.4 버전으로 추가되었습니다.

모노레포 내의 의존성 일관성을 유지하고 잠재적인 타입 충돌이나 런타임 문제를 방지하기 위해, vitest 패키지 버전도 @blog/web 패키지와 동일하게 ^4.1.9 (또는 최소 4.1.4 이상)로 업그레이드하는 것을 권장합니다. vitest 버전을 올리면 @vitest/expect와의 버전 불일치로 인한 fragile한 상태를 방지할 수 있습니다.

References
  1. 모노레포 환경에서는 패키지 간 의존성 버전의 일관성을 유지하는 것이 중요합니다. (link)

@claude

claude Bot commented Jun 28, 2026

Copy link
Copy Markdown

Code Review

This PR finalizes the TS6 migration blog post (renaming + publishing it), refactors the SsgoiTransition deprecation into a shared PageBoundary wrapper across 5 pages, fixes inline-vs-block code rendering in CodeBlock, live-wires the about page stats via getAllPostSummaries() and a CI-injected env var, and fixes the vitest 4.1 type augmentation gap for @testing-library/jest-dom. The changes are well-scoped and the CI step includes solid input sanitization.


Findings

1. PageBoundary.tsx:12 — prop spread overrides the transition key (correctness)

// current — rest can clobber id
<div data-ssgoi-transition={id} {...rest} />

// fix — explicit attribute wins
<div {...rest} data-ssgoi-transition={id} />

ComponentPropsWithRef<'div'> accepts arbitrary data-* HTML attributes, so a caller can pass data-ssgoi-transition through the spread even though it isn't named in the interface. As written, the spread comes last and silently wins. Moving data-ssgoi-transition={id} after {...rest} makes id authoritative.


2. .github/workflows/deploy-blog.yml:15-19 — silent fetch failure bakes stale fallback with no build signal (observability)

json=$(curl -fsSL https://raw.githubusercontent.com/... || true)
count=$(printf '%s' "$json" | jq -r '.mergedPRs // empty' 2>/dev/null || true)
[[ "$count" =~ ^[1-9][0-9]*$ ]] || count=''
echo "count=$count" >> "$GITHUB_OUTPUT"

When curl fails (network blip, repo gone), count becomes '' and NEXT_PUBLIC_PR_COUNT is baked into the static bundle as an empty string. The || '58' fallback in about/page.tsx silently shows the old hardcoded value in production — the build succeeds with no visible warning. A simple echo to stderr on the fallback path would make stale deployments observable:

[[ "$count" =~ ^[1-9][0-9]*$ ]] || { echo "⚠️ PR count fetch failed, using fallback" >&2; count=''; }

3. apps/next.js/vitest.d.ts:9-11 — second comment paragraph explains WHAT, not WHY (CLAUDE.md)

// TestingLibraryMatchers<E, R>: E = 매처가 받는 기대값(인자 위치라 unknown이면 어떤
// 값이든 허용), R = 매처 반환 타입. Assertion<T>는 받은 값 T를 R로 돌려 체이닝을 유지한다.

CLAUDE.md: "Don't explain WHAT the code does … Only add [a comment] when the WHY is non-obvious." The type-parameter explanation above restates what the type signature already says. The first paragraph (vitest 4.1 moved Assertion to @vitest/expect so the old declare module 'vitest' augmentation stopped working) is the non-obvious WHY and should stay. The second paragraph can be removed.


4. apps/blog/web/src/components/post/markdown/FileTree.tsx:55slice(1) fix is one layer above the right place (altitude)

// current: build array with depth-0 included, then discard it
const branches = ancestorContinues
  .slice(1)       // skip the root column we just built
  .map(c => ...)
  .join('');

The ancestorContinues array is populated by a loop for (let d = 0; d < line.depth; d++). Starting that loop at d = 1 would never push the root entry in the first place, making the slice(1) and its explanatory comment unnecessary. The current fix is correct but fragile — if someone adjusts the loop bounds without noticing the slice, the column alignment breaks silently.


5. apps/blog/web/src/components/post/CodeBlock.tsx:74content.trim().includes('\\n') false-negative on whitespace-only fenced blocks (low severity)

const isBlock = Boolean(match) || content.trim().includes('\n');

A fenced code block containing only blank lines (e.g. ``` / newlines / ```) would fail the trim() check and render as inline <code>. The comment explains the trim() rationale (avoiding the <div> inside <p> hydration error from raw HTML <code> with surrounding whitespace) — that rationale is valid. The edge case is extremely unlikely in real content, but a more precise guard would be children.includes('\n') on the raw children (before trimming), combined with a separate check for the hydration-error case. Flagged as a known tradeoff rather than a must-fix.


🤖 Generated with Claude Code

Han5991 and others added 2 commits June 28, 2026 14:38
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Jun 28, 2026

Copy link
Copy Markdown

Code Review

This PR wraps up the TS6 blog post with a CTA section, migrates five pages from the deprecated SsgoiTransition wrapper to a new PageBoundary component, fixes a CodeBlock rendering regression for unlabeled fenced code blocks, corrects the FileTree root-connector display, wires the about-page stats to live data, and unblocks @testing-library/jest-dom types in vitest 4.1.

All correctness-sensitive changes verified cleanly:

  • FileTree.tsx slice(1) is correct for all depth levels (root column correctly omitted)
  • CodeBlock.tsx content.trim().includes('\n') is safe — no existing post has a raw <code> with internal (non-leading/trailing) newlines
  • Catch-all [...slug] posts all carry explicit flat slug: frontmatter, so the /posts/${post.slug} ssgoi ID never contains a second path segment

Three items worth addressing before merge:


apps/blog/web/src/components/PageBoundary.tsx:1 — component placed as a flat file instead of a directory

The repo CLAUDE.md specifies:

Components follow this pattern: components/ComponentName/ComponentName.tsx, ComponentName.test.tsx, index.ts

PageBoundary.tsx is placed directly at src/components/PageBoundary.tsx rather than src/components/PageBoundary/PageBoundary.tsx + index.ts. Every other component in the blog app follows the directory pattern. Minor now but costs a structural refactor if a test or sub-component is added later.


apps/next.js/vitest.d.ts:1–19 — prose comment block longer than the code it documents

CLAUDE.md:

Default to writing no comments. Only add one when the WHY is non-obvious.

The what (augmenting @vitest/expect instead of vitest) is already clear from the declare module '@vitest/expect' declaration. The 10-line explanation of vitest internals belongs in the commit message or PR description, not in a declaration file that will outlive the context. If anything, one sentence about why the augment target changed would be enough — the current block will read as confusing background noise once the Vitest version moves on.


apps/blog/web/src/app/about/page.tsx:203–206 — comment narrates what the code already says

// 빌드 타임 통계. 블로그 글 수는 실제 발행 수, PR 수는 CI가 주입(NEXT_PUBLIC_PR_COUNT),
// 로컬 빌드·CI 조회 실패 시 폴백 상수가 처리. 컨퍼런스는 수동 관리.
const blogPostCount = getAllPostSummaries().length;
const mergedPrCount = process.env.NEXT_PUBLIC_PR_COUNT || '58';

The variable names and values already communicate what the comment restates. The CI-injection detail is the only non-obvious part, but that is explained by the workflow step name and the NEXT_PUBLIC_PR_COUNT env name. Trimming this to a single-line note on the fallback value (if anything) would keep it within the "non-obvious WHY" threshold.


Overall the change is solid — the PageBoundary abstraction is clean, the CodeBlock fix is well-reasoned, and the CI PR-count injection is nicely guarded with the regex validation and || '58' fallback.

🤖 Generated with Claude Code

vitest를 4.1.9로 정렬(블로그와 동일)하니 next.js 4.1.4 vs 블로그 4.1.9 이중 버전 충돌이 사라져, 공식 @testing-library/jest-dom/vitest 보강만으로 매처 타입이 정상 머지된다. 비표준 vitest.d.ts(@vitest/expect 직접 보강)와 @vitest/expect 직접 의존을 제거. 진짜 원인은 vitest의 re-export 구조가 아니라 모노레포 내 vitest 이중 버전이었다.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Jun 28, 2026

Copy link
Copy Markdown

PR Review — blog: TS6 글 마감 + 렌더링·타입·통계 개선

전반적으로 잘 정리된 PR입니다. SsgoiTransitionPageBoundary 추상화는 깔끔하고, CodeBlockisBlock 로직(+ p 핸들러 backstop)은 의도한 hydration 오류를 올바르게 방지합니다. FileTree 루트 커넥터 제거도 표준 트리 렌더링에 맞는 수정입니다.


curl에 타임아웃 없음

파일: .github/workflows/deploy-blog.yml (Fetch merged PR count 스텝)

curl -fsSL--max-time / --connect-timeout 플래그가 없습니다. raw.githubusercontent.com이 느리거나 멈추면 이 스텝이 GitHub Actions 기본 job 타임아웃(6시간)까지 블로킹됩니다.

권장 수정:

-json=$(curl -fsSL https://raw.githubusercontent.com/Han5991/Han5991/main/config/summary.json || true)
+json=$(curl -fsSL --max-time 10 --connect-timeout 5 https://raw.githubusercontent.com/Han5991/Han5991/main/config/summary.json || true)

PR 수 폴백 로직이 CI와 컴포넌트에 분리됨

파일: .github/workflows/deploy-blog.yml:77 + apps/blog/web/src/app/about/page.tsx:206

count 검증 실패 시 NEXT_PUBLIC_PR_COUNT가 빈 문자열로 전달되고, 컴포넌트에서 || '58'로 폴백합니다. 현재는 정상 동작하지만, 폴백 기본값이 두 곳에 분산되어 나중에 변경 시 한쪽만 업데이트될 위험이 있습니다.

CI 한 곳으로 통합하는 것을 권장합니다:

NEXT_PUBLIC_PR_COUNT: ${{ steps.pr_count.outputs.count || '58' }}

그러면 컴포넌트에서 || '58' 폴백을 제거할 수 있습니다.


PageBoundary JSDoc이 CLAUDE.md 규칙 위반

파일: apps/blog/web/src/components/PageBoundary.tsx:4-8

CLAUDE.md: "Don't write multi-paragraph docstrings or multi-line comment blocks — one short line max."

현재 JSDoc 4줄 중 첫 두 문장은 prop 이름과 타입에서 이미 자명한 내용입니다. 비자명한 이유(HTML id 충돌 방지)만 남기면 됩니다:

/** 페이지 전환 매칭 키 — HTML id 충돌을 피해 별도 이름 사용 */
transitionId: string;

참고: cron 스케줄 오프셋 (pre-existing)

이 PR에서 수정된 부분은 아니지만 연관성이 있어 언급합니다. CLAUDE.md와 코드 주석에는 "KST 09:00 (UTC 00:00) cron 자동 빌드"라고 되어 있지만, deploy-blog.yml의 cron 0 9 * * *은 UTC 09:00 = KST 18:00입니다. 예약 발행 글이 KST 09:00이 아닌 18:00에 공개됩니다. KST 09:00 의도라면 0 0 * * *으로 변경이 필요합니다.


전반적으로 코드 품질이 좋습니다. curl 타임아웃이 가장 실질적인 액션 아이템입니다.

Generated with Claude Code

@Han5991

Han5991 commented Jun 28, 2026

Copy link
Copy Markdown
Owner Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

이번 풀 리퀘스트는 블로그 포스트의 파일 트리 형식을 커스텀 태그로 전환하고, SsgoiTransition 컴포넌트를 새로운 PageBoundary 컴포넌트로 대체하여 트랜지션 처리를 개선했습니다. 또한, 소개 페이지의 통계 수치를 동적으로 계산하도록 수정하고, 의존성 패키지(Vite 플러그인, Vitest 등) 버전을 업데이트했습니다. 리뷰어는 CodeBlock 컴포넌트에서 언어 지정이 없는 싱글 라인 fenced 코드 블록이 인라인 코드로 오인되어 스타일이 깨지는 버그를 지적하며, 원본 텍스트의 줄바꿈 여부를 판별 조건에 추가하는 구체적인 해결 방안을 제시했습니다.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +70 to +74
// 언어가 있거나, 언어 없이도 "내부에" 줄바꿈이 있으면(= fenced 코드블록) 블록으로 렌더.
// trim 후 판별하는 이유: raw HTML 인라인 <code>가 앞뒤로 줄바꿈을 끼고 작성될 수 있는데
// (예: 본문에서 줄을 넘긴 `<code>\ntypes</code>`), 이를 블록으로 오인하면 <p> 안에
// <div>가 들어가 hydration 오류가 난다. 진짜 인라인 코드는 내부 줄바꿈이 없다.
const isBlock = Boolean(match) || content.trim().includes('\n');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

현재 구현된 isBlock 판별 로직(content.trim().includes('\n'))은 raw HTML 인라인 <code> 태그로 인한 hydration 오류를 방지하기 위해 trim()을 수행하고 있습니다.

하지만 이로 인해 언어 지정이 없는 싱글 라인 fenced 코드 블록(예: ```\nnpm install\n```)의 경우, trim() 이후 내부 줄바꿈(\n)이 사라져 isBlockfalse로 판별됩니다. 결과적으로 블록이 아닌 인라인 <code>로 렌더링되어 코드 블록 스타일(윈도우 크롬, 복사 버튼 등)이 깨지는 부작용이 발생합니다.

해결 방안:
fenced 코드 블록은 항상 줄바꿈(\n)으로 끝나지만, raw HTML 인라인 <code>와 달리 줄바꿈으로 시작하지 않는다는 특징이 있습니다. 따라서 원본 children 문자열이 \n으로 끝나되 \n으로 시작하지 않는 조건을 추가하면, 싱글 라인 fenced 코드 블록도 올바르게 블록으로 판별할 수 있습니다.

  const rawContent = String(children);
  // 언어가 있거나, 언어 없이도 "내부에" 줄바꿈이 있으면(= fenced 코드블록) 블록으로 렌더.
  // 단, single-line fenced 코드블록(예: 'npm install')의 경우 trim()을 하면 내부 줄바꿈이 없어 인라인으로 오인됩니다.
  // 따라서 원본 텍스트가 줄바꿈으로 끝나되 줄바꿈으로 시작하지 않는 경우(fenced 블록의 특징)도 블록으로 판별합니다.
  const isBlock =
    Boolean(match) ||
    content.trim().includes('\n') ||
    (rawContent.endsWith('\n') && !rawContent.startsWith('\n'));

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

이번 PR은 블로그 포스트 내 폴더 구조 표현을 컴포넌트로 표준화하고, 기존 SsgoiTransition을 대체하는 PageBoundary 컴포넌트를 도입했습니다. 또한 소개 페이지의 통계 데이터를 동적으로 렌더링하도록 개선하고, 의존성 버전을 업데이트했습니다. 리뷰에서는 CodeBlock 컴포넌트에서 children이 문자열이 아닐 때 발생할 수 있는 비정상적인 문자열 변환 문제를 방지하기 위해 안전한 타입 가드를 적용하도록 제안하는 피드백이 제공되었습니다.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +68 to +69
const rawContent = String(children);
const content = rawContent.replace(/\n$/, '');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

children이 문자열이 아닌 경우(예: undefined, null 또는 React 엘리먼트 객체 등) String(children)을 호출하면 "undefined", "null", "[object Object]"와 같은 의도치 않은 문자열이 생성됩니다.

특히 children이 React 엘리먼트인 경우, rawContent"[object Object]"가 되어 인라인 <code> 태그 내부에 실제 엘리먼트 대신 "[object Object]" 텍스트가 렌더링될 수 있습니다.

안전한 타입 가드를 적용하여 children이 문자열일 때만 값을 사용하고, 그렇지 않은 경우 빈 문자열('')로 처리하면 이러한 문제를 방지할 수 있습니다. 이 경우에도 인라인 렌더링 시에는 실제 {children} 엘리먼트가 그대로 전달되므로 안전하게 렌더링됩니다.

Suggested change
const rawContent = String(children);
const content = rawContent.replace(/\n$/, '');
const rawContent = typeof children === 'string' ? children : '';
const content = rawContent.replace(/\n$/, '');

gemini 리뷰 반영. children이 React 엘리먼트 등 비문자열이면 String(children)이 '[object Object]'가 되어 인라인 코드에 잘못된 텍스트가 들어갈 수 있다. typeof 가드로 문자열일 때만 rawContent를 채우고, 그 외엔 ''→인라인 분기에서 실제 {children} 엘리먼트를 그대로 렌더한다.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Jun 28, 2026

Copy link
Copy Markdown

Code Review — blog: TS6 글 마감 + 렌더링·타입·통계 개선 모음

이 PR은 TS6 블로그 글 마감과 함께 ssgoi deprecation 대응 (SsgoiTransitionPageBoundary), CodeBlock의 언어 없는 코드블록 블록 렌더링 수정, about 페이지 통계 실시간 연동, vitest/plugin-react 버전 업 등 다수의 개선을 한 번에 묶었습니다. 전반적으로 품질이 높고 의도가 명확하게 전달됩니다. 아래 두 가지 버그를 수정하고 나머지는 필요에 따라 검토해 주세요.


🔴 버그 1 — cron 표현식과 주석이 불일치 (스케줄드 글이 9시간 늦게 공개됨)

파일: .github/workflows/deploy-blog.yml (스케줄 라인)

cron 표현식이 '0 9 * * *' (UTC 09:00 = KST 18:00)인데, 바로 위 주석은 "KST 09:00 (UTC 00:00)"이라고 적혀 있습니다. status: 'scheduled' 글은 아침에 공개될 것을 의도하는데 실제로는 저녁 6시에 빌드가 돌아갑니다.

수정:

- cron: '0 0 * * *'   # 매일 KST 09:00 (UTC 00:00)

🟡 버그 2 — CodeBlock isBlockisBlockMarkdownChild 감지 조건 불일치 → 잠재적 hydration 오류

파일: apps/blog/web/src/components/post/CodeBlock.tsx (isBlock 조건부)

새로운 isBlock 로직은 language-* 클래스 없이도 내부에 줄바꿈이 있는 <code><div> 블록으로 렌더링합니다. 그런데 PostClient.tsxp() 렌더러가 <p>→<div> 전환 여부를 판단하는 isBlockMarkdownChild() 함수는 여전히 language-* 클래스 유무만 봅니다:

// markdownBlocks.ts
return typeof className === 'string' && /\blanguage-/.test(className);

MDX 포스트 본문에 다음처럼 언어 태그 없이 줄바꿈을 포함하는 raw HTML <code>가 있으면:

<code>TypeA
TypeB</code>

CodeBlock<div>(블록)를 내보내지만, p() 렌더러는 isBlockMarkdownChildfalse를 반환하므로 <p> 래퍼를 유지합니다. 결과: <p><div>…</div></p> — invalid HTML, React hydration 오류.

isBlock의 세 번째 조건(rawContent.endsWith('\n') && !/^\r?\n/.test(rawContent))이 단일 라인 fenced 블록을 잡기 위한 것이고, 두 번째 조건(content.trim().includes('\n'))이 문제의 원인입니다. 이 조건이 언어 없는 멀티라인 raw <code>도 잡을 수 있습니다.

수정 방향 AisBlockMarkdownChild도 동일한 조건을 공유하도록 확장:

// 언어 클래스 OR 멀티라인(이하 CodeBlock.isBlock 로직과 동기화)
return (typeof className === 'string' && /\blanguage-/.test(className))
  || someSharedIsBlockCheck(children);

수정 방향 BisBlock 두 번째 조건을 Boolean(match) 가드 뒤에만 적용해 raw inline <code>는 항상 인라인으로 두기 (현재 주석의 의도에 더 가깝습니다):

const isBlock =
  Boolean(match) ||
  (Boolean(match) && content.trim().includes('\n')) || // language 있을 때만 내부 줄바꿈 허용
  (rawContent.endsWith('\n') && !/^\r?\n/.test(rawContent));

실제로 포스트 본문에 이 패턴이 없다면 현재도 문제없이 동작하지만, 구조적 불일치는 남아 있습니다.


🔵 개선 제안 — CI 폴백이 무성하게(silently) 동작

파일: .github/workflows/deploy-blog.yml (Fetch merged PR count step)

summary.json의 키 이름이 변경되거나 curl이 실패하면 매일 빌드가 58이라는 하드코딩된 폴백을 조용히 구워넣습니다. 로그에 경고를 추가하면 나중에 눈치채기 쉬워집니다:

[[ "$count" =~ ^[1-9][0-9]*$ ]] || { count=''; echo "::warning::PR count fetch failed — using fallback"; }

✅ 잘 된 부분

  • PageBoundary 추출로 ssgoi deprecation을 깔끔하게 처리하고 5곳에 일관되게 적용
  • ancestorContinues.slice(1) 로직은 depth === 0 조기 반환과 정확히 맞아 안전함
  • NEXT_PUBLIC_PR_COUNT 빈 문자열 폴백('' || '58')은 정상 동작
  • vitest 4.0.0-beta.15 → 4.1.9 정식 릴리즈 업그레이드 타이밍 좋음

🤖 Generated with Claude Code

@Han5991

Han5991 commented Jun 28, 2026

Copy link
Copy Markdown
Owner Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

이번 풀 리퀘스트는 블로그 포스트의 디렉토리 구조 시각화를 위해 <file-tree> 컴포넌트를 적용하고, 기존 SsgoiTransition을 대체하는 PageBoundary 컴포넌트를 도입하여 각 페이지에 반영했습니다. 또한, CodeBlock 컴포넌트에서 하이드레이션 오류를 방지하도록 블록/인라인 렌더링 로직을 개선하였으며, 소개 페이지의 통계 데이터를 동적으로 계산하도록 수정했습니다. 그 외에 vitest 등 일부 의존성 버전을 업데이트하고 포스트 내용을 수정했습니다. 이번 변경 사항에 대해 제공할 피드백은 없습니다.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

@claude

claude Bot commented Jun 28, 2026

Copy link
Copy Markdown

PR Review — blog: TS6 글 마감 + 렌더링·타입·통계 개선 모음

이 PR은 ssgoi deprecation 대응(SsgoiTransitionPageBoundary), about 페이지 통계 실시간 연동, 언어 없는 fenced 코드블록 렌더 수정, FileTree 루트 커넥터 제거를 함께 묶은 것입니다. 변경 방향은 명확하고, 대부분의 개선은 올바르게 구현되어 있습니다. 다만 아래 몇 가지는 배포 전 확인이 필요합니다.


🔴 버그: cron 표현식이 의도와 9시간 다릅니다

파일: .github/workflows/deploy-blog.yml:15

# 매일 KST 09:00 (UTC 00:00) 자동 빌드 — 예약 발행(scheduled) 글 공개용
schedule:
  - cron: '0 9 * * *'   # ← 이건 UTC 09:00 = KST 18:00

'0 9 * * *'은 UTC 09:00에 실행됩니다. 주석이 명시한 UTC 00:00(= KST 09:00)이 되려면 '0 0 * * *' 이어야 합니다.

영향: scheduledDate: '2026-07-xx T09:00:00+09:00'로 예약한 글이 실제로는 KST 18:00에 공개됩니다. 예약 발행 기능의 핵심 invariant가 깨집니다.

PR이 직접 이 줄을 수정하진 않았지만 파일을 건드렸고, 주석이 명시적 의도를 담고 있어 여기서 수정하는 것이 적절합니다.


🟠 렌더링 버그: content.trim().includes('\n') 판별이 인라인 코드를 잘못 승격시킵니다

파일: apps/blog/web/src/components/post/CodeBlock.tsx:77

const isBlock =
  Boolean(match) ||
  content.trim().includes('\n') ||   // ← 위험
  rawContent.endsWith('\n');

rawContent.endsWith('\n')은 react-markdown이 fenced 블록에 항상 붙이는 trailing \n으로 fenced 블록을 판별하는 올바른 신호입니다. 그런데 content.trim().includes('\n') 조건은 rehypeRaw가 파싱한 raw HTML 인라인 코드(<code>type A =\nstring</code> 등)에서도 true가 됩니다. 이 경우 isBlock=true<div> 반환 → <p> 안에 <div> → 브라우저가 <p>를 조기 닫음 → React hydration 오류.

코드 주석이 \n으로 끝나지 않아 인라인으로 남고라고 설명하는 케이스 중, 내부에 \n이 있는 경우(예: <code>A\nB</code>)는 이 조건에 잡힙니다. trailing \n 체크 단독으로도 단일 줄 fenced 블록을 정확히 감지할 수 있으므로, 이 조건은 제거해도 충분합니다.

// 제안
const isBlock = Boolean(match) || rawContent.endsWith('\n');

🟡 콘솔 경고: language || 'text'가 Prism에 등록되지 않은 언어를 전달합니다

파일: apps/blog/web/src/components/post/CodeBlock.tsx:139

<SyntaxHighlighter
  language={language || 'text'}   // 'text'는 Prism 언어가 아님
  ...

언어 태그 없는 fenced 블록(```만 쓴 경우)에서 language=undefined이고 language || 'text' = 'text'가 됩니다. react-syntax-highlighter의 Prism 빌드는 'text'를 모르고 SyntaxError: The language "text" has no grammar.를 콘솔에 남깁니다. 'plaintext' 또는 undefined 전달이 더 안전합니다.

language={language ?? 'plaintext'}

🟡 CI 파이프라인: 외부 URL이 미고정 브랜치 HEAD를 참조합니다

파일: .github/workflows/deploy-blog.yml:61

json=$(curl -fsSL https://raw.githubusercontent.com/Han5991/Han5991/main/config/summary.json || true)

정수 regex(^[1-9][0-9]*$)가 GITHUB_OUTPUT 인젝션을 막고, mergedPrCount는 JSX 텍스트로만 렌더되어 XSS 위험은 없습니다. 다만 main HEAD를 핀 없이 가져오므로 해당 레포에 문제가 생기면 표시값이 조작될 수 있습니다. 중요도는 낮지만, 가능하다면 특정 커밋 SHA를 경로에 포함하거나 별도 검증 로직을 두는 것이 좋습니다.


🔵 컨벤션: PageBoundary 테스트 파일이 없습니다

파일: apps/blog/web/src/components/PageBoundary/

CLAUDE.md의 컴포넌트 구조 규칙:

components/ComponentName/ComponentName.tsx, ComponentName.test.tsx, index.ts

현재 PageBoundary.test.tsx가 없습니다. 컴포넌트 자체는 얇은 wrapper이지만, data-ssgoi-transition 어트리뷰트 전달과 className/ref 포워딩을 검증하는 테스트가 적합합니다.


✅ 잘 된 점

  • 모든 5개 call site (about, home, posts, privacy, PostClient)가 빠짐없이 SsgoiTransitionPageBoundary로 교체됨
  • CI의 PR 수 fetch가 || true로 실패를 흡수하고 regex로 정수만 통과시키는 방어적 설계
  • getAllPostSummaries()를 Server Component 빌드 타임에 한 번만 호출하는 올바른 SSG 패턴
  • FileTree 루트 커넥터 제거 — .slice(1) 인덱싱이 ancestorContinues 배열 의미와 일치
  • useViewCount의 중간 변수(cookieStr) 제거 — 의도를 흐리지 않는 명확한 정리

@claude

claude Bot commented Jun 28, 2026

Copy link
Copy Markdown

코드 리뷰

TS6 포스트 마감과 함께 ssgoi 추상화(PageBoundary), CodeBlock 블록/인라인 판별 개선, about 페이지 실시간 통계 연동을 묶은 PR입니다. 전반적인 방향은 맞지만, CodeBlock 변경에서 두 가지 실제 버그가 확인됐습니다.


🔴 Bug 1 — 비-문자열 children이 빈 코드블록으로 렌더됨

파일: apps/blog/web/src/components/post/CodeBlock.tsx:68

// 변경 후
const rawContent = typeof children === 'string' ? children : '';

rehypeRaw가 파이프라인에 있을 때, 마크다운 내 raw HTML <code> 태그에 자식 엘리먼트가 포함되면(<code><span>foo</span></code> 등) react-markdown이 childrenReactElement 배열로 전달합니다. 이 경우 typeof children !== 'string'이므로 rawContent = ''이 되고, SyntaxHighlighter는 빈 문자열을 받아 코드블록 전체가 공백으로 렌더됩니다.

기존 코드의 String(children)은 배열을 쉼표 결합 문자열로 강제 변환해 내용을 보존했습니다.

수정 제안:

const rawContent = String(children ?? '');
const content = rawContent.replace(/\n$/, '');

🔴 Bug 2 — 언어 태그 없는 fenced 코드블록에서 hydration 오류

파일: apps/blog/web/src/components/post/CodeBlock.tsx:75 + apps/blog/web/src/app/posts/[...slug]/markdownBlocks.ts:25

두 곳에서 "블록 코드 여부"를 판별하는 로직이 서로 다른 기준을 사용합니다:

위치 판별 기준 언어 태그 없는 fenced 블록 결과
markdownBlocks.ts:isBlockMarkdownChild language-* className 존재 false<p>로 감쌈
CodeBlock.tsx:isBlock rawContent.endsWith('\n') true<div> 렌더

언어 태그 없는 ``` 블록은 react-markdown이 className 없이 전달하므로 isBlockMarkdownChild는 인라인으로 판단해 <p>로 감쌉니다. 그런데 CodeBlock 내부에서는 trailing \n으로 블록을 감지해 <div>를 렌더합니다 — <p> 안에 <div> 가 생겨 React hydration 오류가 발생합니다.

이는 정확히 이번 변경이 방지하려는 버그와 동일한 패턴입니다.

수정 방향: isBlockMarkdownChild에도 trailing \n 체크를 추가하거나, CodeBlock에서 isBlock 여부를 props로 노출해 두 판단 로직이 동일한 기준을 공유하도록 통일이 필요합니다.


🟡 Info — rawContent.endsWith('\n') 의존성

파일: apps/blog/web/src/components/post/CodeBlock.tsx:75

trailing \n 방식은 mdast-util-to-hast가 내부적으로 fenced code block value에 \n을 붙이는 구현 세부사항에 의존합니다. 이건 공개 API 계약이 아니라 내부 동작이므로 라이브러리 업데이트 시 조용히 깨질 수 있습니다.

더 견고한 방법은 react-markdown이 이미 props로 전달하는 hast node를 활용해 부모 노드가 pre인지 확인하는 것입니다.


✅ 나머지는 깔끔합니다

  • PageBoundary 추상화: 5군데 일관되게 적용, 옳은 방향입니다
  • FileTree.tsx slice(1) + depth-0 guard: 로직 검증 완료, 의도대로 동작합니다
  • CI PR count 주입: 검증 + fallback 패턴이 잘 설계됐습니다
  • useViewCount 정리: 순수 cosmetic 변경, 문제없습니다
  • vitest 4.1.9, @vitejs/plugin-react 6.x 업그레이드: 적절합니다

🤖 Generated with Claude Code

🔴 deploy-blog.yml cron '0 9'→'0 0': UTC 09:00(=KST 18:00)으로 돼 있어 예약 발행이 9시간 늦었다. 주석·CLAUDE.md가 명시한 KST 09:00(UTC 00:00)에 맞춤.

PageBoundary: data-ssgoi-transition을 {...rest} 뒤로 옮겨 호출부가 spread로 덮어쓰지 못하게(명시 속성 우선). FileTree: ancestorContinues 루프를 d=1부터 시작해 slice(1) 제거(등가). deploy-blog: PR count fetch 실패 시 ::warning::으로 stale 폴백 가시화. about: WHAT 나열 주석을 비자명한 부분만 남기고 축약.

decline: Prism 'text' 경고(react-syntax-highlighter가 미등록 언어를 graceful 처리—probe로 재현 안 됨), summary.json URL 핀(의도된 live-fetch+regex 검증), PageBoundary 테스트(trivial wrapper—보류).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bug1: typeof 가드(': '')가 중첩 엘리먼트 children(raw HTML <code><span>x</span></code> 등)의 내용을 빈 문자열로 날려 languaged 블록이 공백 렌더될 수 있었다. codeText()로 재귀 추출해 String()의 '[object Object]'도 ''의 내용 손실도 막는다.

Bug2: isBlockMarkdownChild(언어 className 기준)와 CodeBlock.isBlock(endsWith('\n') 기준)이 언어 없는 블록에서 어긋나 <p> 안 <div> hydration 위험이 있었다. isBlockMarkdownChild를 CodeBlock과 동일 기준으로 통일.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Jun 28, 2026

Copy link
Copy Markdown

PR Review

This PR ships the TS6 blog post, migrates 5 pages from SsgoiTransition to a new PageBoundary abstraction, fixes CodeBlock block/inline detection, fixes the FileTree root connector, and wires up live stats on the about page. The dependency bumps (vitest 4.1.9, @vitejs/plugin-react 6.x) and cron schedule correction are clean.


🔴 Bug: markdownBlocks.ts and CodeBlock disagree on bare fenced code blocks — hydration error

apps/blog/web/src/app/posts/[...slug]/markdownBlocks.ts:25

// 인라인/fenced code는 같은 핸들러라 language-* className으로만 구분된다.
return typeof className === 'string' && /\blanguage-/.test(className);

apps/blog/web/src/components/post/CodeBlock.tsx:75

const isBlock = Boolean(match) || rawContent.endsWith('\n');

The p handler in PostClient.tsx:267–274 relies on isBlockMarkdownChild to decide whether to render <p> or <div>. For a bare fenced code block (``` with no language tag), isBlockMarkdownChild returns false (no language-* className), so it wraps the child in <p>. But CodeBlock now renders it as a <div> because rawContent.endsWith('\n') is true. The result is <p><div>…</div></p> — invalid HTML that the browser fixes by breaking the <p> early, causing a React hydration mismatch.

Every bare fenced code block in any post hits this path. The fix is to update isBlockMarkdownChild to use the same heuristic:

// code element: fenced blocks have language-* class OR trailing \n on content (no-language fenced)
if (child.type === 'code') {
  return (typeof className === 'string' && /\blanguage-/.test(className)) ||
    !className; // bare fenced: react-markdown omits className
}

Or more simply — react-markdown gives className = undefined for bare fenced code (as opposed to '' for inline), so !className alone is sufficient to identify it.


🟡 conferenceCount is a hardcoded string that can silently diverge from the presentations array

apps/blog/web/src/app/about/page.tsx:66

const conferenceCount = '2';

The presentations array just below (line 436–448) also has exactly 2 entries, but the two are not linked. When a third conference entry is added, the stat card will show "2 컨퍼런스" while three are listed. The other stats (blogPostCount, mergedPrCount) are already derived dynamically — keeping conferenceCount in sync manually is easy to forget.

Suggestion: derive it from the array:

const conferences = [
  { event: 'FEConf 2025', ... },
  { event: 'TeoConf', ... },
];
const conferenceCount = String(conferences.length);
// ...
{conferences.map(item => <Link ...>)}

🟢 Positive notes

  • FileTree loop fix (d=0d=1) is correct. Traced through multiple tree shapes (depth 1–3, multi-root, sparse sibling sets) — all produce well-formed connectors. The old d=0 was adding a spurious ancestor column for depth-1 nodes.
  • Cron schedule fix (0 9 * * *0 0 * * *) correctly targets UTC 00:00 = KST 09:00 as documented.
  • PageBoundary correctly uses data-ssgoi-transition — confirmed this is the DOM attribute @ssgoi/react reads. The ComponentPropsWithRef<'div'> + spread pattern is correct for React 19 (ref as regular prop).
  • CI PR count injection is well-guarded: the ^[1-9][0-9]*$ regex ensures only a valid positive integer (or empty string) reaches $GITHUB_OUTPUT, preventing any injection.
  • useViewCount simplification and SsgoiTransitionPageBoundary migration are clean refactors.

The hydration bug (bare fenced code in markdownBlocks.ts) is the one to fix before merge — it affects any post that uses ``` without a language tag.

@claude

claude Bot commented Jun 28, 2026

Copy link
Copy Markdown

Code Review

이 PR은 TS6 포스트 마감과 함께 ssgoi deprecation 대응(SsgoiTransitionPageBoundary), CodeBlock 렌더링 개선, About 페이지 통계 실시간화, FileTree 루트 커넥터 제거, vitest/plugin-react 버전 업그레이드를 묶은 작업입니다. 전반적으로 구조가 명확하고 타입 검사도 통과했으나, CI 워크플로우에 작은 안전장치 누락 하나와 확인이 필요한 항목 두 가지가 있습니다.


1. ⚠️ [pre-existing] Cron 스케줄이 KST 18:00에 실행됨 (의도: KST 09:00)

파일: .github/workflows/deploy-blog.yml (cron 라인)

- cron: '0 9 * * *'
# 주석: 매일 KST 09:00 (UTC 00:00) cron 자동 빌드

0 9 * * *UTC 09:00 = KST 18:00입니다. KST 09:00이 되려면 0 0 * * * (UTC 00:00)이어야 합니다. 이 PR이 도입한 버그는 아니지만, 예약 발행 글의 공개 타이밍이 9시간 늦어지는 문제라 함께 수정하면 좋을 것 같습니다.

# Before (wrong):
- cron: '0 9 * * *'   # UTC 09:00 = KST 18:00

# After (correct):
- cron: '0 0 * * *'   # UTC 00:00 = KST 09:00

2. 🔧 curl에 --max-time 없음 — CI 행(hang) 가능성

파일: .github/workflows/deploy-blog.yml, 새로 추가된 "Fetch merged PR count" 스텝

json=$(curl -fsSL https://raw.githubusercontent.com/Han5991/Han5991/main/config/summary.json || true)

|| true는 curl의 비정상 종료 코드를 잡지만, TCP 연결 후 서버가 응답을 멈추는 stall 상황은 잡지 못합니다. timeout 없이 행이 걸리면 GitHub Actions의 기본 job timeout(6시간)까지 블로킹될 수 있습니다.

json=$(curl -fsSL --max-time 10 https://raw.githubusercontent.com/Han5991/Han5991/main/config/summary.json || true)

raw.githubusercontent.com은 신뢰성 높은 CDN이라 실제 발생 확률은 낮지만, 10초 타임아웃 한 줄로 방어할 수 있어서 추가를 권장합니다.


3. 💡 CodeBlock endsWith('\n') 휴리스틱 — 라이브러리 구현 세부사항 의존

파일: apps/blog/web/src/components/post/CodeBlock.tsx:75

const isBlock = Boolean(match) || rawContent.endsWith('\n');

주석에도 설명되어 있듯, react-markdown이 fenced 코드블록 텍스트를 \n으로 끝나게 전달한다는 라이브러리 내부 동작에 의존합니다. react-markdown이 trailing whitespace 처리 방식을 바꾸면 언어 태그 없는 fenced 블록이 silently inline <code>로 폴백해 <p> 안에 <div>가 들어가는 hydration 오류가 생깁니다.

현재 코드베이스에 이 경로를 커버하는 테스트가 없어, 추후 react-markdown 버전 업 시 무음 회귀가 생길 수 있습니다. 언어 태그 없는 fenced 블록에 대한 스냅샷 테스트 한 케이스를 추가해두면 안전합니다. (블로커는 아닙니다)


✅ 확인된 올바른 구현

  • FileTree slice(1): ancestorContinues[0]은 루트 컬럼 해당 — 루트가 커넥터 없이 early-return하므로 자식 노드 렌더링에서 스킵하는 것이 의도적으로 정확합니다. LGTM.
  • PageBoundary vs SsgoiTransition: PR 설명대로 deprecation 대응이고, <Ssgoi> 컨텍스트 프로바이더는 PageTransition.tsx에 그대로 유지되어 있습니다. next.config.ts에 ssgoi 빌드 플러그인이 없었던 것도 기존과 동일합니다. 등가 치환으로 판단됩니다.
  • process.env.NEXT_PUBLIC_PR_COUNT || '58' 폴백: SSG 빌드 타임 주입이므로 undefined || '58''' || '58' 모두 올바르게 처리됩니다.
  • vitest 4.1.9 / @vitejs/plugin-react 6 bump: 안정 릴리즈로의 전환, 문제 없습니다.

🤖 Reviewed with Claude Code

@claude

claude Bot commented Jun 28, 2026

Copy link
Copy Markdown

PR Review — blog: TS6 글 마감 + 렌더링·타입·통계 개선 모음

이 PR은 TS6 글 마감 및 세 가지 주요 개선을 포함합니다: SsgoiTransition deprecation 대응 (PageBoundary 추출), CodeBlock/FileTree 렌더링 버그 수정, About 통계 실시간 연동. 변경 방향은 올바르며 cron 시각 수정(0 90 0, UTC 00:00 = KST 09:00)도 정확합니다.


🔴 버그 (잠재적 hydration 오류)

apps/blog/web/src/app/posts/[...slug]/markdownBlocks.ts:33isBlockMarkdownChildCodeBlock의 블록 판별 로직이 불일치

isBlockMarkdownChild는 fenced code 판별에 typeof children === 'string' && children.endsWith('\n')을 쓰는데, CodeBlockcodeText(children).endsWith('\n')을 씁니다. codeText()는 배열·React 엘리먼트까지 재귀 추출하지만, isBlockMarkdownChildtypeof 가드는 배열/엘리먼트에서 false를 반환합니다.

트리거 조건: 마크다운 소스에 raw HTML <code> (language 클래스 없이)가 있고, rehypeRaw가 이를 string이 아닌 배열·엘리먼트 자식으로 파싱하는 경우. 예:

<code><em>foo</em>
</code>
  • isBlockMarkdownChild: typeof [<em>, '\n'] === 'string'false<p> wrapper 유지
  • CodeBlock.isBlock: codeText([<em>, '\n']) = "foo\n"endsWith('\n')true<div> 렌더

결과: <p><div>…</div></p> — 이 PR이 막으려는 바로 그 hydration 오류가 다른 경로로 재발합니다.

수정 방법: codeText를 공유 유틸로 추출해 isBlockMarkdownChild에서도 동일하게 사용:

// markdownBlocks.ts line 33
// 기존:
return typeof children === 'string' && children.endsWith('\n');
// 수정:
return codeText(children as ReactNode).endsWith('\n');

두 함수가 같은 추출 로직을 공유하면 divergence가 없어집니다.


🟡 보안 (잠재적)

apps/blog/web/src/app/about/page.tsx (및 다른 페이지들) — JSON.stringify</script>를 이스케이프하지 않음

dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}

JSON.stringify</script> 시퀀스를 이스케이프하지 않습니다. 현재 jsonLd는 전부 하드코딩된 상수라 실제 exploit은 없지만, 구조적으로 안전하지 않습니다. 향후 사이트 URL이나 작성자 이름 등 필드가 외부 데이터 소스와 연결되면 스크립트 태그 탈출이 가능합니다.

수정 방법:

JSON.stringify(jsonLd).replace(/</g, '\\u003c')

이는 Next.js 내부에서도 사용하는 표준 패턴입니다. about/page.tsx뿐 아니라 page.tsx, posts/page.tsx 등 동일 패턴을 쓰는 곳 모두 적용을 권장합니다.


🟡 유지보수성

블록 판별 로직의 중복 — 향후 divergence 위험

markdownBlocks.tsCodeBlock.tsx는 "이 코드는 블록인가?"를 각자 다르게 구현합니다. 주석도 "동일하게 판별해야" 한다고 경고하지만, 두 함수는 아직 공유 유틸을 쓰지 않습니다. 위의 버그 수정 겸, isBlockCode(children: ReactNode, className?: string): boolean 같은 공유 함수를 markdownUtils.ts에 두고 양쪽에서 import하면 미래의 divergence를 구조적으로 방지할 수 있습니다.

덤으로: markdownBlocks.ts/\blanguage-/.test(className) (word boundary 있음)를 쓰고 CodeBlock.tsx/language-(\w+)/.exec(className) (word boundary 없음)를 씁니다. xlanguage-js 같은 클래스명에서 두 함수가 다르게 동작합니다. 실용적으로 발생할 가능성은 낮지만 공유 유틸로 묶으면 이 불일치도 함께 해소됩니다.


✅ 잘된 부분

  • codeText() 헬퍼: String(children)[object Object] 문제를 정확히 짚고 재귀적으로 해결했습니다.
  • FileTree depth-0 처리: 루트 항목이 connector 없이 렌더되는 표준 트리 모양 적용, loop를 d=1부터 시작해 고아 문자를 없앤 것은 올바른 수정입니다.
  • CI PR count 주입: 정규식 ^[1-9][0-9]*$ 검증 + fallback 패턴이 안전하게 설계되었습니다. empty string이 falsy로 || '58' fallback을 정확히 트리거합니다.
  • PageBoundary 컴포넌트: ssgoi 라이브러리 내부 data-attribute API를 감추는 적절한 추상화입니다.

요약: 블록 판별 로직 불일치(hydration 버그 잠재)와 JSON-LD 이스케이프(보안 잠재)를 수정하면 merge 준비가 됩니다.

🤖 Generated with Claude Code

Han5991 and others added 2 commits June 28, 2026 15:54
codeText·isBlockCode를 markdownCode.ts로 분리하고 CodeBlock이 import해
사용하도록 변경. 블록/인라인 판별 기준을 isBlockCode 하나로 단일화해
<p> 안 <div> hydration 오류 방지 의도를 한곳에서 관리한다.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…이스케이프

🔴 hydration: isBlockMarkdownChild를 isBlockCode로 위임해 CodeBlock과 블록 판별
기준을 단일화. raw HTML <code>의 비문자열(배열/엘리먼트) children을 codeText로
추출하고 language 정규식(/\blanguage-/ vs /language-(\w+)/) divergence도 함께
해소 → <p> 안 <div> hydration mismatch 재발 차단.

🟡 security: safeJsonLd() 헬퍼 추가, JSON-LD 6개 주입부에서 <를 <로
이스케이프해 값에 </script>가 들어갈 때 태그 조기 종료/XSS 방지(about/home/
posts 목록/포스트 상세).

회귀 테스트: markdownCode.test.ts(codeText·isBlockCode, 비문자열 children),
jsonLd.test.ts(이스케이프·의미 보존), markdownBlocks.test.ts(code 비문자열 children).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Jun 28, 2026

Copy link
Copy Markdown

Code Review

이 PR은 TS6 블로그 글 마감을 중심으로 ssgoi deprecation 대응(PageBoundary 추출), 언어 태그 없는 코드블록 렌더링 수정, JSON-LD XSS 하드닝, about 페이지 통계 실시간 연동 등 여러 개선을 묶어 처리합니다. 전반적으로 구조가 명확하고 테스트도 충실하게 추가되었습니다. 아래 두 가지는 실제로 조치할 만한 항목입니다.


🔴 .github/workflows/deploy-blog.yml:66 — 유효성 검증 정규식이 0을 거부

[[ "$count" =~ ^[1-9][0-9]*$ ]] || { ... count=''; }

^[1-9][0-9]*$첫 자리가 1 이상이어야 통과합니다. summary.jsonmergedPRs: 0이 들어오면 (jq'0'을 출력해도) 검증에 실패해 count''로 초기화되고, NEXT_PUBLIC_PR_COUNT가 빈 문자열로 주입되어 about 페이지는 '58' 폴백을 표시합니다. 현재 PR 수가 0일 일은 없지만, 의도한 "숫자 여부" 검증으로는 부정확합니다.

수정: ^[1-9][0-9]*$^[0-9]+$


🟡 markdownCode.tscodeTextFileTree.tsxextractText 중복

새로 추가된 codeText(exported)와 FileTree.tsx의 private extractText는 동일한 재귀적 ReactNode → string 추출 로직입니다. 그런데 한 가지 동작이 다릅니다:

함수 number 노드 처리
extractText String(n) — 숫자를 문자열로 변환
codeText return '' — 조용히 빈 문자열

codeText에 number 처리를 추가한 뒤 FileTree.tsx가 이를 재사용하면 중복과 divergence를 모두 제거할 수 있습니다.

export function codeText(node: ReactNode): string {
  if (typeof node === 'string') return node;
+ if (typeof node === 'number') return String(node);
  if (Array.isArray(node)) return node.map(codeText).join('');
  if (isValidElement<{ children?: ReactNode }>(node))
    return codeText(node.props.children);
  return '';
}

✅ 잘 된 부분

  • safeJsonLd: < 치환은 Next.js 내부와 동일한 패턴이고, 라운드트립 테스트까지 추가한 점이 좋습니다.
  • isBlockCode 단일화: CodeBlockisBlockMarkdownChild가 같은 함수를 공유해 hydration mismatch 근본 원인을 제거한 접근이 올바릅니다.
  • cron 0 9 → 0 0 수정: 기존 0 9 * * *은 UTC 09:00(= KST 18:00)으로 주석의 "KST 09:00"과 실제로 어긋나 있었는데 이번에 바로잡혔습니다.
  • PageBoundary: ComponentPropsWithRef<'div'> 사용은 React 19에서 plain function component에서도 ref prop이 정상 동작하므로 문제없습니다.
  • FileTree 루트 커넥터 제거: renderTreeLine에서 depth=0 분기 추가 + 루프 d=1 시작은 정확합니다.

🤖 Generated with Claude Code

🔴 deploy-blog.yml: PR count 검증 정규식 ^[1-9][0-9]*$ → ^[0-9]+$.
기존 패턴은 mergedPRs가 0이면 유효한 숫자인데도 검증 실패 → count='' →
about 페이지가 '58' 폴백을 표시했다. "숫자 여부" 검증 의도에 맞게 0도 통과시킨다.

🟡 codeText ↔ FileTree.extractText 중복 제거: codeText에 number 처리(String)를
추가하고, FileTree의 private extractText를 제거해 codeText를 재사용한다. 두 함수의
유일한 divergence(number를 String vs '')도 함께 해소.

테스트: markdownCode.test.ts의 number 단언을 ''→String으로 갱신.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Jun 28, 2026

Copy link
Copy Markdown

PR Review

This PR wraps up the TS6 upgrade post and bundles several rendering/type/statistics improvements: the SsgoiTransition deprecation is handled by extracting a PageBoundary wrapper, CodeBlock gets a unified block/inline detection function (isBlockCode) that fixes hydration mismatches, safeJsonLd adds structural XSS hardening for JSON-LD script tags, and the About page stats become dynamic (post count computed at build time, PR count injected via CI). The changes are generally well-structured and clearly tested.


Findings

1. .github/workflows/deploy-blog.ymlcurl has no timeout; can block the build indefinitely

json=$(curl -fsSL https://raw.githubusercontent.com/Han5991/Han5991/main/config/summary.json || true)

-fsSL sets silent/redirect/fail-on-error, but none of these flags set a connect or transfer timeout. || true only masks a non-zero exit after curl terminates — it cannot interrupt a hung socket. If raw.githubusercontent.com is slow or unresponsive, this step will block until GitHub Actions hits its default 6-hour job timeout, stalling every CI build.

Fix:

json=$(curl --connect-timeout 10 --max-time 30 -fsSL \
  https://raw.githubusercontent.com/Han5991/Han5991/main/config/summary.json || true)

2. PageBoundary + missing ssgoi build plugin (PLAUSIBLE) — transitions may silently break

PageBoundary correctly writes data-ssgoi-transition and PageTransition.tsx wraps <main> in <Ssgoi config={config}>. However, @ssgoi/react@6.4.0 ships an unplugin dependency (visible in the lockfile). If the library requires a build-time transform (e.g. withSsgoi(nextConfig)) to process data-ssgoi-transition — rather than the <Ssgoi> runtime reading it via querySelectorAll — transitions will silently do nothing since next.config.ts does not register a ssgoi plugin.

Suggested check: confirm from @ssgoi/react v6 docs or source that data-ssgoi-transition is a runtime DOM attribute (no build plugin needed). If a plugin is required, add it to next.config.ts.


3. src/components/post/CodeBlock.tsxcodeText(children) is called twice per render

For inline code and language-untagged fenced blocks, codeText(children) is called explicitly on line ~69 to produce rawContent, and then again inside isBlockCode(children, className) on line ~74 (the endsWith('\n') branch falls through to codeText). The cost is low today but the duplication is structural.

Fix: pass the pre-computed text into isBlockCode:

const rawContent = codeText(children);
const content = rawContent.replace(/\n$/, '');
const isBlock = isBlockCode(rawContent, className);  // or: expose isBlockCodeFromText(text, className)

4. Comment length — CLAUDE.md: "one short line max" (multiple files)

The project CLAUDE.md says: "Don't add error handling… Only add one [comment] when the WHY is non-obvious… never write multi-paragraph docstrings or multi-line comment blocks — one short line max."

Several new docblocks exceed this:

  • markdownCode.ts lines 3–8: 5-line JSDoc on codeText — the rehypeRaw edge case is a valid WHY but the callers list and traversal description are WHAT.
  • markdownCode.ts lines 19–28: 8-line JSDoc on isBlockCode — the shared-function hydration invariant is the WHY; the bottom paragraph restates implementation details visible from the code.
  • lib/jsonLd.ts lines 1–12: 11-line JSDoc on a one-liner — the </script> risk is a valid WHY; the semantic-preservation proof and "currently constants" disclaimer are WHAT.
  • PageBoundary.tsx lines 4–8: 3-line JSDoc on a prop — only the id collision note is a WHY; the usage hint is WHAT.

These don't affect runtime but they accumulate comment debt.


Items reviewed and found correct (no action needed)

  • safeJsonLd escaping only <: Correct. '\\u003c' in a JS string literal produces the literal 6-char sequence < in the output. The HTML parser needs a raw < byte to trigger a close-tag scan; < blocks that while remaining valid JSON. This is the same pattern Next.js uses internally. ✓
  • FileTree loop change d=0d=1: The old loop was a bug — it pushed a spurious ancestor column for depth-0, adding three leading spaces to every depth-1+ node. Starting at d=1 is correct; grandchild renders as │ └─ grandchild as expected. ✓
  • NEXT_PUBLIC_PR_COUNT='' on fetch failure: The || '58' fallback handles empty string correctly (falsy). ✓
  • isBlockCode trailing-\n heuristic: react-markdown's contract (fenced blocks always end with \n) is stable enough for this use case, and the test file explicitly covers the regression case. ✓

🤖 Generated with Claude Code

@Han5991 Han5991 merged commit a74d44c into main Jun 28, 2026
6 checks passed
@Han5991 Han5991 deleted the blog/ts6-post-cta branch June 28, 2026 07:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant