From b7d65eafba36dde292980648c396d65c62e909c4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 28 Dec 2025 22:44:25 +0000 Subject: [PATCH 1/5] Initial plan From 690b87853781315fd8c77cd6829bf8b09595d67d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 28 Dec 2025 22:51:41 +0000 Subject: [PATCH 2/5] Migrate from Utterances to Disqus comment system Co-authored-by: echoja <73801151+echoja@users.noreply.github.com> --- .env.example | 4 + docs/DISQUS_MIGRATION.md | 127 ++++++++++++++++++ src/app/[locale]/article/[...slug]/layout.tsx | 4 +- src/app/article/layout.tsx | 4 +- src/app/en/article/layout.tsx | 4 +- src/app/ko/article/layout.tsx | 4 +- src/app/test/layout.tsx | 4 +- src/common/config.ts | 6 + src/modules/disqus.tsx | 77 +++++++++++ 9 files changed, 224 insertions(+), 10 deletions(-) create mode 100644 docs/DISQUS_MIGRATION.md create mode 100644 src/modules/disqus.tsx diff --git a/.env.example b/.env.example index 3f393db0..f58c9403 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,10 @@ NEXT_PUBLIC_GTM_ID=G-xxxxxx # https://github.com/utterance/utterances NEXT_PUBLIC_UTTERANCES_REPO=owner/repo +# Disqus +# https://disqus.com/ +NEXT_PUBLIC_DISQUS_SHORTNAME=your-disqus-shortname + # base url BASE_URL=http://localhost:3000 NEXT_PUBLIC_BASE_URL= \ No newline at end of file diff --git a/docs/DISQUS_MIGRATION.md b/docs/DISQUS_MIGRATION.md new file mode 100644 index 00000000..4e86c55f --- /dev/null +++ b/docs/DISQUS_MIGRATION.md @@ -0,0 +1,127 @@ +# Utterances에서 Disqus로 마이그레이션 가이드 + +## 개요 +이 문서는 블로그의 댓글 시스템을 Utterances에서 Disqus로 마이그레이션하는 과정을 설명합니다. + +## 1. Disqus 계정 및 사이트 설정 + +### 1.1 Disqus 계정 생성 +1. [Disqus](https://disqus.com/)에 접속합니다. +2. 계정이 없다면 새로 생성합니다. + +### 1.2 사이트 등록 +1. Disqus에 로그인 후 [Admin](https://disqus.com/admin/) 페이지로 이동합니다. +2. "Get Started" 또는 "Install Disqus" 를 클릭합니다. +3. "I want to install Disqus on my site"를 선택합니다. +4. 사이트 정보를 입력합니다: + - **Website Name**: 사이트 이름 (예: "Springfall Blog") + - **Disqus URL (shortname)**: 고유한 shortname 입력 (예: "springfall") + - **Category**: 블로그 카테고리 선택 +5. 플랜을 선택합니다 (무료 플랜 가능). +6. 플랫폼 선택 화면에서는 건너뛰고 수동 설정을 진행합니다. + +### 1.3 환경 변수 설정 +프로젝트의 `.env` 파일에 Disqus shortname을 추가합니다: + +\`\`\`bash +NEXT_PUBLIC_DISQUS_SHORTNAME=your-disqus-shortname +\`\`\` + +`.env.example` 파일에도 참고용으로 추가되어 있습니다. + +## 2. GitHub Issues를 Disqus로 마이그레이션 (수동 작업) + +### 2.1 기존 댓글 백업 +1. 기존 Utterances 댓글들은 GitHub Issues에 저장되어 있습니다. +2. 각 이슈를 열어서 댓글 내용을 확인합니다. +3. 필요한 경우 중요한 댓글들을 별도로 백업합니다. + +### 2.2 Disqus로 댓글 이전 +Utterances의 GitHub Issues를 Disqus로 자동 마이그레이션하는 도구는 없습니다. 다음 방법 중 하나를 선택합니다: + +#### 옵션 A: 수동 이전 (권장하지 않음) +- 중요한 댓글만 선별하여 Disqus에 수동으로 작성합니다. +- 원본 작성자와 날짜를 명시합니다. + +#### 옵션 B: GitHub Issues 유지 +- 기존 Utterances 댓글은 GitHub Issues에 그대로 남겨둡니다. +- 새로운 댓글만 Disqus에서 받습니다. +- 필요시 각 포스트에 "이전 댓글은 [GitHub Issues](링크)에서 확인하세요" 안내를 추가합니다. + +#### 옵션 C: Disqus Import API 사용 (개발자 전용) +Disqus는 [Import API](https://help.disqus.com/en/articles/1717131-importing-comments-from-wordpress)를 제공합니다. 다음 단계를 따릅니다: + +1. GitHub Issues API를 사용하여 댓글 데이터를 추출합니다. +2. Disqus의 WXR (WordPress eXtended RSS) 형식으로 변환합니다. +3. Disqus Admin 페이지에서 데이터를 임포트합니다. + +상세 가이드: +\`\`\`bash +# GitHub Issues에서 댓글 가져오기 +curl -H "Authorization: token YOUR_GITHUB_TOKEN" \\ + "https://api.github.com/repos/OWNER/REPO/issues?state=all&labels=utterances" + +# 데이터를 WXR 형식으로 변환 (별도 스크립트 필요) +# Disqus Admin > Discussions > Import에서 업로드 +\`\`\` + +## 3. 테마 설정 + +Disqus는 다크 모드를 기본적으로 지원하지 않습니다. 다음 방법으로 대응합니다: + +### 3.1 Disqus 어드민 설정 +1. [Disqus Admin > Settings > General](https://disqus.com/admin/settings/general/)로 이동합니다. +2. **Color Scheme**을 설정합니다 (Light/Dark/Auto). +3. 커스텀 CSS로 테마를 조정할 수 있습니다 (Pro 플랜 필요). + +### 3.2 코드 레벨 테마 전환 +현재 구현에서는 `useColorMode` 훅을 사용하여 테마 변경을 감지하고 Disqus를 리로드합니다. 하지만 Disqus는 동적 테마 변경을 완벽하게 지원하지 않으므로, 사용자가 페이지를 새로고침해야 할 수 있습니다. + +## 4. 테스트 + +### 4.1 로컬 테스트 +\`\`\`bash +pnpm dev +\`\`\` + +브라우저에서 블로그 포스트를 열어 Disqus 위젯이 올바르게 로드되는지 확인합니다. + +### 4.2 테마 전환 테스트 +- 라이트 모드와 다크 모드를 전환하면서 Disqus가 적절하게 표시되는지 확인합니다. +- 페이지 새로고침 시 테마가 유지되는지 확인합니다. + +### 4.3 프로덕션 테스트 +\`\`\`bash +pnpm build +pnpm start +\`\`\` + +프로덕션 빌드에서도 정상 작동하는지 확인합니다. + +## 5. 배포 + +1. 환경 변수가 배포 환경에 설정되어 있는지 확인합니다. +2. 변경사항을 커밋하고 푸시합니다. +3. Vercel 또는 다른 호스팅 플랫폼에 배포합니다. +4. 배포 후 실제 사이트에서 Disqus가 정상 작동하는지 확인합니다. + +## 6. 주의사항 + +- Disqus는 광고를 포함할 수 있습니다 (무료 플랜). +- Disqus는 사용자 개인정보를 수집합니다. 개인정보 처리방침을 업데이트해야 할 수 있습니다. +- Disqus는 외부 서비스이므로 페이지 로딩 속도에 영향을 줄 수 있습니다. +- GitHub Issues에 저장된 기존 댓글은 별도로 관리해야 합니다. + +## 7. 롤백 + +Disqus가 맞지 않는 경우, 다음 단계로 Utterances로 돌아갈 수 있습니다: + +1. `src/modules/disqus.tsx` 임포트를 `src/modules/utterances.tsx`로 되돌립니다. +2. `.env` 파일에서 `NEXT_PUBLIC_DISQUS_SHORTNAME` 대신 `NEXT_PUBLIC_UTTERANCES_REPO`를 설정합니다. +3. 변경사항을 커밋하고 재배포합니다. + +## 추가 리소스 + +- [Disqus 공식 문서](https://help.disqus.com/) +- [Disqus Admin Dashboard](https://disqus.com/admin/) +- [Disqus API 문서](https://disqus.com/api/docs/) diff --git a/src/app/[locale]/article/[...slug]/layout.tsx b/src/app/[locale]/article/[...slug]/layout.tsx index 957f63f9..eaaa0d5d 100644 --- a/src/app/[locale]/article/[...slug]/layout.tsx +++ b/src/app/[locale]/article/[...slug]/layout.tsx @@ -1,5 +1,5 @@ import ArticlePageHeader from "@modules/layout/ArticlePageHeader"; -import Utterances from "@modules/utterances"; +import Disqus from "@modules/disqus"; import ArticleLayout from "@modules/article/ArticleLayout"; import type { Metadata } from "next"; @@ -14,7 +14,7 @@ export default function Layout({ children }: { children: React.ReactNode }) { <> {children} - + ); } diff --git a/src/app/article/layout.tsx b/src/app/article/layout.tsx index 957f63f9..eaaa0d5d 100644 --- a/src/app/article/layout.tsx +++ b/src/app/article/layout.tsx @@ -1,5 +1,5 @@ import ArticlePageHeader from "@modules/layout/ArticlePageHeader"; -import Utterances from "@modules/utterances"; +import Disqus from "@modules/disqus"; import ArticleLayout from "@modules/article/ArticleLayout"; import type { Metadata } from "next"; @@ -14,7 +14,7 @@ export default function Layout({ children }: { children: React.ReactNode }) { <> {children} - + ); } diff --git a/src/app/en/article/layout.tsx b/src/app/en/article/layout.tsx index 7e6bf505..f543695b 100644 --- a/src/app/en/article/layout.tsx +++ b/src/app/en/article/layout.tsx @@ -1,5 +1,5 @@ import ArticlePageHeader from "@modules/layout/ArticlePageHeader"; -import Utterances from "@modules/utterances"; +import Disqus from "@modules/disqus"; import type { Metadata } from "next"; import ArticleLayout from "@modules/article/ArticleLayout"; @@ -14,7 +14,7 @@ export default function Layout({ children }: { children: React.ReactNode }) { <> {children} - + ); } diff --git a/src/app/ko/article/layout.tsx b/src/app/ko/article/layout.tsx index 7e6bf505..f543695b 100644 --- a/src/app/ko/article/layout.tsx +++ b/src/app/ko/article/layout.tsx @@ -1,5 +1,5 @@ import ArticlePageHeader from "@modules/layout/ArticlePageHeader"; -import Utterances from "@modules/utterances"; +import Disqus from "@modules/disqus"; import type { Metadata } from "next"; import ArticleLayout from "@modules/article/ArticleLayout"; @@ -14,7 +14,7 @@ export default function Layout({ children }: { children: React.ReactNode }) { <> {children} - + ); } diff --git a/src/app/test/layout.tsx b/src/app/test/layout.tsx index 699c35c1..52d007a8 100644 --- a/src/app/test/layout.tsx +++ b/src/app/test/layout.tsx @@ -1,12 +1,12 @@ import ArticlePageHeader from "@modules/layout/ArticlePageHeader"; -import Utterances from "@modules/utterances"; +import Disqus from "@modules/disqus"; export default function Layout({ children }: { children: React.ReactNode }) { return ( <>
{children}
- + ); } diff --git a/src/common/config.ts b/src/common/config.ts index f4749fb0..2d35b629 100644 --- a/src/common/config.ts +++ b/src/common/config.ts @@ -3,3 +3,9 @@ export const DEFAULT_UTTERANCES_REPO = "owner/repo"; export function getUtterancesRepo() { return process.env.NEXT_PUBLIC_UTTERANCES_REPO || DEFAULT_UTTERANCES_REPO; } + +export const DEFAULT_DISQUS_SHORTNAME = "your-disqus-shortname"; + +export function getDisqusShortname() { + return process.env.NEXT_PUBLIC_DISQUS_SHORTNAME || DEFAULT_DISQUS_SHORTNAME; +} diff --git a/src/modules/disqus.tsx b/src/modules/disqus.tsx new file mode 100644 index 00000000..6572a9bd --- /dev/null +++ b/src/modules/disqus.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +import { DEFAULT_DISQUS_SHORTNAME, getDisqusShortname } from "@common/config"; + +import { useColorMode } from "./color-mode/color-mode"; + +interface IDisqusConfig { + page: { + url: string; + identifier: string; + }; + language?: string; +} + +declare global { + interface Window { + DISQUS?: { + reset: (options: { reload: boolean; config: () => void }) => void; + }; + disqus_config?: (this: IDisqusConfig) => void; + } +} + +const Disqus = () => { + const { resolved } = useColorMode(); + const loaded = useRef(false); + + useEffect(() => { + const shortname = getDisqusShortname(); + if (shortname === DEFAULT_DISQUS_SHORTNAME) { + return; + } + + // Configure Disqus + window.disqus_config = function (this: IDisqusConfig) { + this.page.url = window.location.href; + this.page.identifier = window.location.pathname; + }; + + if (!loaded.current) { + // Load Disqus script for the first time + const script = document.createElement("script"); + script.src = `https://${shortname}.disqus.com/embed.js`; + script.setAttribute("data-timestamp", String(+new Date())); + script.async = true; + (document.head || document.body).appendChild(script); + loaded.current = true; + } + }, []); + + // Reset Disqus when theme changes + useEffect(() => { + if (loaded.current && window.DISQUS) { + window.DISQUS.reset({ + reload: true, + config: function (this: IDisqusConfig) { + this.page.url = window.location.href; + this.page.identifier = window.location.pathname; + // Note: Disqus doesn't natively support dynamic theme switching + // Users may need to refresh the page to see theme changes + }, + }); + } + }, [resolved]); + + return ( +
+ ); +}; + +export default Disqus; From 3dcf9c1db845902d08a46c6b536b0b570bee5b55 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 28 Dec 2025 23:06:12 +0000 Subject: [PATCH 3/5] Replace Disqus with Giscus for comment system Co-authored-by: echoja <73801151+echoja@users.noreply.github.com> --- .env.example | 9 +- docs/DISQUS_MIGRATION.md | 127 ----------- docs/GISCUS_MIGRATION.md | 197 ++++++++++++++++++ src/app/[locale]/article/[...slug]/layout.tsx | 4 +- src/app/article/layout.tsx | 4 +- src/app/en/article/layout.tsx | 4 +- src/app/ko/article/layout.tsx | 4 +- src/app/test/layout.tsx | 4 +- src/common/config.ts | 6 +- src/modules/disqus.tsx | 77 ------- src/modules/giscus.tsx | 74 +++++++ 11 files changed, 290 insertions(+), 220 deletions(-) delete mode 100644 docs/DISQUS_MIGRATION.md create mode 100644 docs/GISCUS_MIGRATION.md delete mode 100644 src/modules/disqus.tsx create mode 100644 src/modules/giscus.tsx diff --git a/.env.example b/.env.example index f58c9403..c72a4676 100644 --- a/.env.example +++ b/.env.example @@ -5,9 +5,12 @@ NEXT_PUBLIC_GTM_ID=G-xxxxxx # https://github.com/utterance/utterances NEXT_PUBLIC_UTTERANCES_REPO=owner/repo -# Disqus -# https://disqus.com/ -NEXT_PUBLIC_DISQUS_SHORTNAME=your-disqus-shortname +# Giscus - GitHub Discussions based comments +# https://giscus.app/ +NEXT_PUBLIC_GISCUS_REPO=owner/repo +NEXT_PUBLIC_GISCUS_REPO_ID= +NEXT_PUBLIC_GISCUS_CATEGORY=General +NEXT_PUBLIC_GISCUS_CATEGORY_ID= # base url BASE_URL=http://localhost:3000 diff --git a/docs/DISQUS_MIGRATION.md b/docs/DISQUS_MIGRATION.md deleted file mode 100644 index 4e86c55f..00000000 --- a/docs/DISQUS_MIGRATION.md +++ /dev/null @@ -1,127 +0,0 @@ -# Utterances에서 Disqus로 마이그레이션 가이드 - -## 개요 -이 문서는 블로그의 댓글 시스템을 Utterances에서 Disqus로 마이그레이션하는 과정을 설명합니다. - -## 1. Disqus 계정 및 사이트 설정 - -### 1.1 Disqus 계정 생성 -1. [Disqus](https://disqus.com/)에 접속합니다. -2. 계정이 없다면 새로 생성합니다. - -### 1.2 사이트 등록 -1. Disqus에 로그인 후 [Admin](https://disqus.com/admin/) 페이지로 이동합니다. -2. "Get Started" 또는 "Install Disqus" 를 클릭합니다. -3. "I want to install Disqus on my site"를 선택합니다. -4. 사이트 정보를 입력합니다: - - **Website Name**: 사이트 이름 (예: "Springfall Blog") - - **Disqus URL (shortname)**: 고유한 shortname 입력 (예: "springfall") - - **Category**: 블로그 카테고리 선택 -5. 플랜을 선택합니다 (무료 플랜 가능). -6. 플랫폼 선택 화면에서는 건너뛰고 수동 설정을 진행합니다. - -### 1.3 환경 변수 설정 -프로젝트의 `.env` 파일에 Disqus shortname을 추가합니다: - -\`\`\`bash -NEXT_PUBLIC_DISQUS_SHORTNAME=your-disqus-shortname -\`\`\` - -`.env.example` 파일에도 참고용으로 추가되어 있습니다. - -## 2. GitHub Issues를 Disqus로 마이그레이션 (수동 작업) - -### 2.1 기존 댓글 백업 -1. 기존 Utterances 댓글들은 GitHub Issues에 저장되어 있습니다. -2. 각 이슈를 열어서 댓글 내용을 확인합니다. -3. 필요한 경우 중요한 댓글들을 별도로 백업합니다. - -### 2.2 Disqus로 댓글 이전 -Utterances의 GitHub Issues를 Disqus로 자동 마이그레이션하는 도구는 없습니다. 다음 방법 중 하나를 선택합니다: - -#### 옵션 A: 수동 이전 (권장하지 않음) -- 중요한 댓글만 선별하여 Disqus에 수동으로 작성합니다. -- 원본 작성자와 날짜를 명시합니다. - -#### 옵션 B: GitHub Issues 유지 -- 기존 Utterances 댓글은 GitHub Issues에 그대로 남겨둡니다. -- 새로운 댓글만 Disqus에서 받습니다. -- 필요시 각 포스트에 "이전 댓글은 [GitHub Issues](링크)에서 확인하세요" 안내를 추가합니다. - -#### 옵션 C: Disqus Import API 사용 (개발자 전용) -Disqus는 [Import API](https://help.disqus.com/en/articles/1717131-importing-comments-from-wordpress)를 제공합니다. 다음 단계를 따릅니다: - -1. GitHub Issues API를 사용하여 댓글 데이터를 추출합니다. -2. Disqus의 WXR (WordPress eXtended RSS) 형식으로 변환합니다. -3. Disqus Admin 페이지에서 데이터를 임포트합니다. - -상세 가이드: -\`\`\`bash -# GitHub Issues에서 댓글 가져오기 -curl -H "Authorization: token YOUR_GITHUB_TOKEN" \\ - "https://api.github.com/repos/OWNER/REPO/issues?state=all&labels=utterances" - -# 데이터를 WXR 형식으로 변환 (별도 스크립트 필요) -# Disqus Admin > Discussions > Import에서 업로드 -\`\`\` - -## 3. 테마 설정 - -Disqus는 다크 모드를 기본적으로 지원하지 않습니다. 다음 방법으로 대응합니다: - -### 3.1 Disqus 어드민 설정 -1. [Disqus Admin > Settings > General](https://disqus.com/admin/settings/general/)로 이동합니다. -2. **Color Scheme**을 설정합니다 (Light/Dark/Auto). -3. 커스텀 CSS로 테마를 조정할 수 있습니다 (Pro 플랜 필요). - -### 3.2 코드 레벨 테마 전환 -현재 구현에서는 `useColorMode` 훅을 사용하여 테마 변경을 감지하고 Disqus를 리로드합니다. 하지만 Disqus는 동적 테마 변경을 완벽하게 지원하지 않으므로, 사용자가 페이지를 새로고침해야 할 수 있습니다. - -## 4. 테스트 - -### 4.1 로컬 테스트 -\`\`\`bash -pnpm dev -\`\`\` - -브라우저에서 블로그 포스트를 열어 Disqus 위젯이 올바르게 로드되는지 확인합니다. - -### 4.2 테마 전환 테스트 -- 라이트 모드와 다크 모드를 전환하면서 Disqus가 적절하게 표시되는지 확인합니다. -- 페이지 새로고침 시 테마가 유지되는지 확인합니다. - -### 4.3 프로덕션 테스트 -\`\`\`bash -pnpm build -pnpm start -\`\`\` - -프로덕션 빌드에서도 정상 작동하는지 확인합니다. - -## 5. 배포 - -1. 환경 변수가 배포 환경에 설정되어 있는지 확인합니다. -2. 변경사항을 커밋하고 푸시합니다. -3. Vercel 또는 다른 호스팅 플랫폼에 배포합니다. -4. 배포 후 실제 사이트에서 Disqus가 정상 작동하는지 확인합니다. - -## 6. 주의사항 - -- Disqus는 광고를 포함할 수 있습니다 (무료 플랜). -- Disqus는 사용자 개인정보를 수집합니다. 개인정보 처리방침을 업데이트해야 할 수 있습니다. -- Disqus는 외부 서비스이므로 페이지 로딩 속도에 영향을 줄 수 있습니다. -- GitHub Issues에 저장된 기존 댓글은 별도로 관리해야 합니다. - -## 7. 롤백 - -Disqus가 맞지 않는 경우, 다음 단계로 Utterances로 돌아갈 수 있습니다: - -1. `src/modules/disqus.tsx` 임포트를 `src/modules/utterances.tsx`로 되돌립니다. -2. `.env` 파일에서 `NEXT_PUBLIC_DISQUS_SHORTNAME` 대신 `NEXT_PUBLIC_UTTERANCES_REPO`를 설정합니다. -3. 변경사항을 커밋하고 재배포합니다. - -## 추가 리소스 - -- [Disqus 공식 문서](https://help.disqus.com/) -- [Disqus Admin Dashboard](https://disqus.com/admin/) -- [Disqus API 문서](https://disqus.com/api/docs/) diff --git a/docs/GISCUS_MIGRATION.md b/docs/GISCUS_MIGRATION.md new file mode 100644 index 00000000..650f0404 --- /dev/null +++ b/docs/GISCUS_MIGRATION.md @@ -0,0 +1,197 @@ +# Utterances에서 Giscus로 마이그레이션 가이드 + +## 개요 +이 문서는 블로그의 댓글 시스템을 Utterances에서 Giscus로 마이그레이션하는 과정을 설명합니다. + +Giscus는 Utterances와 유사하지만 GitHub Issues 대신 **GitHub Discussions**를 사용하는 댓글 시스템입니다. Utterances보다 더 나은 기능들을 제공합니다: +- GitHub Discussions 기반 (Issues 대신) +- 답글 기능 지원 +- 반응(reactions) 기능 +- 더 나은 다크모드 지원 +- 활발한 유지보수 + +## 1. GitHub Discussions 활성화 + +### 1.1 저장소에서 Discussions 활성화 +1. GitHub 저장소로 이동합니다. +2. **Settings** > **General** > **Features** 섹션으로 이동합니다. +3. **Discussions** 체크박스를 활성화합니다. +4. 저장소 상단에 **Discussions** 탭이 나타나는지 확인합니다. + +### 1.2 Discussions 카테고리 생성 (선택사항) +1. 저장소의 **Discussions** 탭으로 이동합니다. +2. 필요한 경우 댓글 전용 카테고리를 생성합니다 (예: "Comments" 또는 "블로그 댓글"). +3. 카테고리 ID는 나중에 Giscus 설정에 사용됩니다. + +## 2. Giscus 설정 + +### 2.1 Giscus App 설치 +1. [giscus.app](https://giscus.app/)에 접속합니다. +2. 페이지 하단의 "configuration" 섹션으로 스크롤합니다. +3. 저장소 이름을 입력합니다 (예: `echoja/springfall`). +4. Giscus가 저장소를 확인하고 필요한 정보를 표시합니다. + +### 2.2 필요한 정보 수집 +Giscus 설정 페이지에서 다음 정보를 확인합니다: +- **Repository**: 저장소 이름 (예: `echoja/springfall`) +- **Repository ID**: 저장소의 고유 ID +- **Category**: Discussion 카테고리 (예: "General" 또는 "Comments") +- **Category ID**: 카테고리의 고유 ID + +### 2.3 환경 변수 설정 +프로젝트의 `.env` 파일에 Giscus 설정을 추가합니다: + +\`\`\`bash +# Giscus 설정 +NEXT_PUBLIC_GISCUS_REPO=owner/repo +NEXT_PUBLIC_GISCUS_REPO_ID=your-repo-id +NEXT_PUBLIC_GISCUS_CATEGORY=General +NEXT_PUBLIC_GISCUS_CATEGORY_ID=your-category-id +\`\`\` + +`.env.example` 파일에도 참고용으로 추가되어 있습니다. + +## 3. GitHub Issues를 Discussions로 마이그레이션 + +### 3.1 기존 Utterances 댓글 확인 +1. 기존 Utterances 댓글들은 GitHub Issues에 저장되어 있습니다. +2. 저장소의 **Issues** 탭에서 `utterances` 레이블이 붙은 이슈들을 확인합니다. + +### 3.2 Issues를 Discussions로 변환 +GitHub에서는 Issues를 Discussions로 변환하는 기능을 제공합니다: + +#### GitHub UI를 통한 변환 +1. 각 Issue 페이지로 이동합니다. +2. 오른쪽 사이드바에서 "Convert to discussion" 버튼을 클릭합니다. +3. 변환할 Discussion 카테고리를 선택합니다. +4. "I understand, convert this issue" 버튼을 클릭합니다. + +이 작업은 각 이슈마다 수동으로 해야 합니다. + +#### GitHub CLI를 통한 일괄 변환 (선택사항) +GitHub CLI를 사용하여 여러 이슈를 한 번에 변환할 수 있습니다: + +\`\`\`bash +# utterances 레이블이 있는 모든 이슈 목록 가져오기 +gh issue list --label utterances --json number,title --jq '.[] | [.number, .title] | @tsv' + +# 각 이슈를 Discussion으로 변환 (수동으로 번호 입력 필요) +gh issue transfer --target-repo-id +\`\`\` + +**참고**: GitHub API를 통한 자동 변환 스크립트를 작성할 수도 있지만, 수동 확인을 권장합니다. + +### 3.3 마이그레이션 전략 + +다음 옵션 중 하나를 선택합니다: + +#### 옵션 A: 모든 댓글 변환 (권장) +- 모든 Utterances 이슈를 Discussions로 변환합니다. +- 기존 댓글들이 그대로 유지되며 URL도 자동으로 리디렉션됩니다. +- Giscus가 자동으로 기존 Discussion을 찾아 표시합니다. + +#### 옵션 B: 선택적 변환 +- 중요한 댓글이 있는 이슈만 변환합니다. +- 나머지는 Issues에 그대로 두고 새 댓글만 Giscus로 받습니다. + +#### 옵션 C: 새로 시작 +- 기존 댓글은 Issues에 보관하고 새 댓글만 Discussions에서 받습니다. +- 필요시 각 글에 "이전 댓글은 [GitHub Issues](링크)에서 확인하세요" 안내 추가. + +## 4. Giscus 동작 방식 + +Giscus는 다음과 같이 작동합니다: +1. 사용자가 블로그 글을 방문하면 Giscus가 로드됩니다. +2. Giscus는 페이지의 `pathname`을 기준으로 Discussion을 찾습니다. +3. 해당 Discussion이 없으면 첫 댓글 작성 시 자동으로 생성됩니다. +4. 기존 Utterances Issues를 Discussions로 변환한 경우, Giscus가 자동으로 연결합니다. + +## 5. 테마 설정 + +### 5.1 다크모드 지원 +Giscus는 Utterances보다 더 나은 다크모드 지원을 제공합니다: +- 현재 구현에서는 `useColorMode` 훅을 사용하여 테마를 감지합니다. +- 테마 변경 시 Giscus에 메시지를 전송하여 실시간으로 테마를 변경합니다. +- 페이지 새로고침 없이 테마가 즉시 변경됩니다. + +### 5.2 사용 가능한 테마 +Giscus는 다양한 테마를 지원합니다: +- `light` - 기본 라이트 테마 +- `dark` - 기본 다크 테마 +- `preferred_color_scheme` - 시스템 설정 따름 +- GitHub 테마들 (`github_light`, `github_dark` 등) + +현재 구현은 `light`와 `dark`를 사용합니다. + +## 6. 테스트 + +### 6.1 로컬 테스트 +\`\`\`bash +pnpm dev +\`\`\` + +브라우저에서 블로그 포스트를 열어 Giscus 위젯이 올바르게 로드되는지 확인합니다. + +### 6.2 테마 전환 테스트 +- 라이트 모드와 다크 모드를 전환하면서 Giscus가 즉시 반응하는지 확인합니다. +- Utterances와 달리 페이지 새로고침 없이 테마가 변경되어야 합니다. + +### 6.3 댓글 작성 테스트 +1. GitHub 계정으로 로그인합니다. +2. 테스트 댓글을 작성합니다. +3. 저장소의 Discussions에서 새 Discussion이 생성되었는지 확인합니다. + +### 6.4 프로덕션 테스트 +\`\`\`bash +pnpm build +pnpm start +\`\`\` + +프로덕션 빌드에서도 정상 작동하는지 확인합니다. + +## 7. 배포 + +1. 환경 변수가 배포 환경(Vercel 등)에 설정되어 있는지 확인합니다: + - `NEXT_PUBLIC_GISCUS_REPO` + - `NEXT_PUBLIC_GISCUS_REPO_ID` + - `NEXT_PUBLIC_GISCUS_CATEGORY` + - `NEXT_PUBLIC_GISCUS_CATEGORY_ID` +2. 변경사항을 커밋하고 푸시합니다. +3. 배포 플랫폼에서 자동으로 빌드 및 배포됩니다. +4. 배포 후 실제 사이트에서 Giscus가 정상 작동하는지 확인합니다. + +## 8. Utterances vs Giscus 비교 + +| 기능 | Utterances | Giscus | +|------|-----------|--------| +| 기반 | GitHub Issues | GitHub Discussions | +| 답글 | ❌ | ✅ | +| 반응(Reactions) | 제한적 | ✅ 완전 지원 | +| 다크모드 | 페이지 새로고침 필요 | 실시간 변경 | +| 유지보수 | 중단됨 | 활발함 | +| 댓글 정렬 | 제한적 | 다양한 옵션 | +| 카테고리 | ❌ | ✅ | + +## 9. 주의사항 + +- Giscus를 사용하려면 저장소가 **public**이어야 합니다. +- 댓글 작성자는 GitHub 계정이 필요합니다. +- GitHub Discussions가 활성화되어 있어야 합니다. +- 기존 Issues를 Discussions로 변환하는 작업은 되돌릴 수 없으니 주의하세요. + +## 10. 롤백 + +Giscus가 맞지 않는 경우, 다음 단계로 Utterances로 돌아갈 수 있습니다: + +1. `src/modules/giscus.tsx` 임포트를 `src/modules/utterances.tsx`로 되돌립니다. +2. `.env` 파일에서 Giscus 관련 변수를 제거하고 `NEXT_PUBLIC_UTTERANCES_REPO`를 설정합니다. +3. 변경사항을 커밋하고 재배포합니다. + +**참고**: Discussions로 변환한 댓글은 Issues로 되돌릴 수 없으므로, 마이그레이션 전 백업을 권장합니다. + +## 11. 추가 리소스 + +- [Giscus 공식 사이트](https://giscus.app/) +- [Giscus GitHub 저장소](https://github.com/giscus/giscus) +- [GitHub Discussions 문서](https://docs.github.com/en/discussions) +- [Converting an issue to a discussion](https://docs.github.com/en/discussions/managing-discussions-for-your-community/moderating-discussions#converting-an-issue-to-a-discussion) diff --git a/src/app/[locale]/article/[...slug]/layout.tsx b/src/app/[locale]/article/[...slug]/layout.tsx index eaaa0d5d..26ae43db 100644 --- a/src/app/[locale]/article/[...slug]/layout.tsx +++ b/src/app/[locale]/article/[...slug]/layout.tsx @@ -1,5 +1,5 @@ import ArticlePageHeader from "@modules/layout/ArticlePageHeader"; -import Disqus from "@modules/disqus"; +import Giscus from "@modules/giscus"; import ArticleLayout from "@modules/article/ArticleLayout"; import type { Metadata } from "next"; @@ -14,7 +14,7 @@ export default function Layout({ children }: { children: React.ReactNode }) { <> {children} - + ); } diff --git a/src/app/article/layout.tsx b/src/app/article/layout.tsx index eaaa0d5d..26ae43db 100644 --- a/src/app/article/layout.tsx +++ b/src/app/article/layout.tsx @@ -1,5 +1,5 @@ import ArticlePageHeader from "@modules/layout/ArticlePageHeader"; -import Disqus from "@modules/disqus"; +import Giscus from "@modules/giscus"; import ArticleLayout from "@modules/article/ArticleLayout"; import type { Metadata } from "next"; @@ -14,7 +14,7 @@ export default function Layout({ children }: { children: React.ReactNode }) { <> {children} - + ); } diff --git a/src/app/en/article/layout.tsx b/src/app/en/article/layout.tsx index f543695b..c2ccfeee 100644 --- a/src/app/en/article/layout.tsx +++ b/src/app/en/article/layout.tsx @@ -1,5 +1,5 @@ import ArticlePageHeader from "@modules/layout/ArticlePageHeader"; -import Disqus from "@modules/disqus"; +import Giscus from "@modules/giscus"; import type { Metadata } from "next"; import ArticleLayout from "@modules/article/ArticleLayout"; @@ -14,7 +14,7 @@ export default function Layout({ children }: { children: React.ReactNode }) { <> {children} - + ); } diff --git a/src/app/ko/article/layout.tsx b/src/app/ko/article/layout.tsx index f543695b..c2ccfeee 100644 --- a/src/app/ko/article/layout.tsx +++ b/src/app/ko/article/layout.tsx @@ -1,5 +1,5 @@ import ArticlePageHeader from "@modules/layout/ArticlePageHeader"; -import Disqus from "@modules/disqus"; +import Giscus from "@modules/giscus"; import type { Metadata } from "next"; import ArticleLayout from "@modules/article/ArticleLayout"; @@ -14,7 +14,7 @@ export default function Layout({ children }: { children: React.ReactNode }) { <> {children} - + ); } diff --git a/src/app/test/layout.tsx b/src/app/test/layout.tsx index 52d007a8..d3a9dc0e 100644 --- a/src/app/test/layout.tsx +++ b/src/app/test/layout.tsx @@ -1,12 +1,12 @@ import ArticlePageHeader from "@modules/layout/ArticlePageHeader"; -import Disqus from "@modules/disqus"; +import Giscus from "@modules/giscus"; export default function Layout({ children }: { children: React.ReactNode }) { return ( <>
{children}
- + ); } diff --git a/src/common/config.ts b/src/common/config.ts index 2d35b629..9adbc5d1 100644 --- a/src/common/config.ts +++ b/src/common/config.ts @@ -4,8 +4,8 @@ export function getUtterancesRepo() { return process.env.NEXT_PUBLIC_UTTERANCES_REPO || DEFAULT_UTTERANCES_REPO; } -export const DEFAULT_DISQUS_SHORTNAME = "your-disqus-shortname"; +export const DEFAULT_GISCUS_REPO = "owner/repo"; -export function getDisqusShortname() { - return process.env.NEXT_PUBLIC_DISQUS_SHORTNAME || DEFAULT_DISQUS_SHORTNAME; +export function getGiscusRepo() { + return process.env.NEXT_PUBLIC_GISCUS_REPO || DEFAULT_GISCUS_REPO; } diff --git a/src/modules/disqus.tsx b/src/modules/disqus.tsx deleted file mode 100644 index 6572a9bd..00000000 --- a/src/modules/disqus.tsx +++ /dev/null @@ -1,77 +0,0 @@ -"use client"; - -import { useEffect, useRef } from "react"; - -import { DEFAULT_DISQUS_SHORTNAME, getDisqusShortname } from "@common/config"; - -import { useColorMode } from "./color-mode/color-mode"; - -interface IDisqusConfig { - page: { - url: string; - identifier: string; - }; - language?: string; -} - -declare global { - interface Window { - DISQUS?: { - reset: (options: { reload: boolean; config: () => void }) => void; - }; - disqus_config?: (this: IDisqusConfig) => void; - } -} - -const Disqus = () => { - const { resolved } = useColorMode(); - const loaded = useRef(false); - - useEffect(() => { - const shortname = getDisqusShortname(); - if (shortname === DEFAULT_DISQUS_SHORTNAME) { - return; - } - - // Configure Disqus - window.disqus_config = function (this: IDisqusConfig) { - this.page.url = window.location.href; - this.page.identifier = window.location.pathname; - }; - - if (!loaded.current) { - // Load Disqus script for the first time - const script = document.createElement("script"); - script.src = `https://${shortname}.disqus.com/embed.js`; - script.setAttribute("data-timestamp", String(+new Date())); - script.async = true; - (document.head || document.body).appendChild(script); - loaded.current = true; - } - }, []); - - // Reset Disqus when theme changes - useEffect(() => { - if (loaded.current && window.DISQUS) { - window.DISQUS.reset({ - reload: true, - config: function (this: IDisqusConfig) { - this.page.url = window.location.href; - this.page.identifier = window.location.pathname; - // Note: Disqus doesn't natively support dynamic theme switching - // Users may need to refresh the page to see theme changes - }, - }); - } - }, [resolved]); - - return ( -
- ); -}; - -export default Disqus; diff --git a/src/modules/giscus.tsx b/src/modules/giscus.tsx new file mode 100644 index 00000000..ccaeab9a --- /dev/null +++ b/src/modules/giscus.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +import { DEFAULT_GISCUS_REPO, getGiscusRepo } from "@common/config"; + +import { useColorMode } from "./color-mode/color-mode"; + +const Giscus = () => { + const { resolved } = useColorMode(); + const loaded = useRef(false); + const containerRef = useRef(null); + + useEffect(() => { + const repo = getGiscusRepo(); + if (repo === DEFAULT_GISCUS_REPO) { + return; + } + if (!containerRef.current) { + return; + } + if (loaded.current) { + return; + } + + // Create and inject the giscus script + const script = document.createElement("script"); + script.src = "https://giscus.app/client.js"; + script.async = true; + script.crossOrigin = "anonymous"; + script.setAttribute("data-repo", repo); + script.setAttribute("data-repo-id", process.env.NEXT_PUBLIC_GISCUS_REPO_ID || ""); + script.setAttribute("data-category", process.env.NEXT_PUBLIC_GISCUS_CATEGORY || "General"); + script.setAttribute("data-category-id", process.env.NEXT_PUBLIC_GISCUS_CATEGORY_ID || ""); + script.setAttribute("data-mapping", "pathname"); + script.setAttribute("data-strict", "0"); + script.setAttribute("data-reactions-enabled", "1"); + script.setAttribute("data-emit-metadata", "0"); + script.setAttribute("data-input-position", "bottom"); + script.setAttribute("data-theme", resolved === "dark" ? "dark" : "light"); + script.setAttribute("data-lang", "ko"); + script.setAttribute("data-loading", "lazy"); + + containerRef.current.appendChild(script); + loaded.current = true; + }, []); + + // Update theme when color mode changes + useEffect(() => { + if (!loaded.current) { + return; + } + + const iframe = document.querySelector( + "iframe.giscus-frame", + ); + if (iframe?.contentWindow) { + iframe.contentWindow.postMessage( + { + giscus: { + setConfig: { + theme: resolved === "dark" ? "dark" : "light", + }, + }, + }, + "https://giscus.app", + ); + } + }, [resolved]); + + return
; +}; + +export default Giscus; From c36e1ec0bfb7dcff9c0424674654b3e27ce147df Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 28 Dec 2025 23:15:09 +0000 Subject: [PATCH 4/5] Remove Utterances and defensive code, simplify Giscus config Co-authored-by: echoja <73801151+echoja@users.noreply.github.com> --- .env.example | 6 +-- docs/GISCUS_MIGRATION.md | 14 +------ src/common/config.ts | 10 +---- src/modules/giscus.tsx | 11 +----- src/modules/utterances.tsx | 80 -------------------------------------- 5 files changed, 5 insertions(+), 116 deletions(-) delete mode 100644 src/modules/utterances.tsx diff --git a/.env.example b/.env.example index c72a4676..e7fcb721 100644 --- a/.env.example +++ b/.env.example @@ -1,15 +1,11 @@ # Google Tag Manager NEXT_PUBLIC_GTM_ID=G-xxxxxx -# utterances -# https://github.com/utterance/utterances -NEXT_PUBLIC_UTTERANCES_REPO=owner/repo - # Giscus - GitHub Discussions based comments # https://giscus.app/ +# Get these values from https://giscus.app/ after configuring your repository NEXT_PUBLIC_GISCUS_REPO=owner/repo NEXT_PUBLIC_GISCUS_REPO_ID= -NEXT_PUBLIC_GISCUS_CATEGORY=General NEXT_PUBLIC_GISCUS_CATEGORY_ID= # base url diff --git a/docs/GISCUS_MIGRATION.md b/docs/GISCUS_MIGRATION.md index 650f0404..7265b966 100644 --- a/docs/GISCUS_MIGRATION.md +++ b/docs/GISCUS_MIGRATION.md @@ -35,7 +35,6 @@ Giscus는 Utterances와 유사하지만 GitHub Issues 대신 **GitHub Discussion Giscus 설정 페이지에서 다음 정보를 확인합니다: - **Repository**: 저장소 이름 (예: `echoja/springfall`) - **Repository ID**: 저장소의 고유 ID -- **Category**: Discussion 카테고리 (예: "General" 또는 "Comments") - **Category ID**: 카테고리의 고유 ID ### 2.3 환경 변수 설정 @@ -45,7 +44,6 @@ Giscus 설정 페이지에서 다음 정보를 확인합니다: # Giscus 설정 NEXT_PUBLIC_GISCUS_REPO=owner/repo NEXT_PUBLIC_GISCUS_REPO_ID=your-repo-id -NEXT_PUBLIC_GISCUS_CATEGORY=General NEXT_PUBLIC_GISCUS_CATEGORY_ID=your-category-id \`\`\` @@ -172,23 +170,13 @@ pnpm start | 댓글 정렬 | 제한적 | 다양한 옵션 | | 카테고리 | ❌ | ✅ | -## 9. 주의사항 +## 10. 주의사항 - Giscus를 사용하려면 저장소가 **public**이어야 합니다. - 댓글 작성자는 GitHub 계정이 필요합니다. - GitHub Discussions가 활성화되어 있어야 합니다. - 기존 Issues를 Discussions로 변환하는 작업은 되돌릴 수 없으니 주의하세요. -## 10. 롤백 - -Giscus가 맞지 않는 경우, 다음 단계로 Utterances로 돌아갈 수 있습니다: - -1. `src/modules/giscus.tsx` 임포트를 `src/modules/utterances.tsx`로 되돌립니다. -2. `.env` 파일에서 Giscus 관련 변수를 제거하고 `NEXT_PUBLIC_UTTERANCES_REPO`를 설정합니다. -3. 변경사항을 커밋하고 재배포합니다. - -**참고**: Discussions로 변환한 댓글은 Issues로 되돌릴 수 없으므로, 마이그레이션 전 백업을 권장합니다. - ## 11. 추가 리소스 - [Giscus 공식 사이트](https://giscus.app/) diff --git a/src/common/config.ts b/src/common/config.ts index 9adbc5d1..65609923 100644 --- a/src/common/config.ts +++ b/src/common/config.ts @@ -1,11 +1,3 @@ -export const DEFAULT_UTTERANCES_REPO = "owner/repo"; - -export function getUtterancesRepo() { - return process.env.NEXT_PUBLIC_UTTERANCES_REPO || DEFAULT_UTTERANCES_REPO; -} - -export const DEFAULT_GISCUS_REPO = "owner/repo"; - export function getGiscusRepo() { - return process.env.NEXT_PUBLIC_GISCUS_REPO || DEFAULT_GISCUS_REPO; + return process.env.NEXT_PUBLIC_GISCUS_REPO; } diff --git a/src/modules/giscus.tsx b/src/modules/giscus.tsx index ccaeab9a..4808260d 100644 --- a/src/modules/giscus.tsx +++ b/src/modules/giscus.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef } from "react"; -import { DEFAULT_GISCUS_REPO, getGiscusRepo } from "@common/config"; +import { getGiscusRepo } from "@common/config"; import { useColorMode } from "./color-mode/color-mode"; @@ -13,13 +13,7 @@ const Giscus = () => { useEffect(() => { const repo = getGiscusRepo(); - if (repo === DEFAULT_GISCUS_REPO) { - return; - } - if (!containerRef.current) { - return; - } - if (loaded.current) { + if (!repo || !containerRef.current || loaded.current) { return; } @@ -30,7 +24,6 @@ const Giscus = () => { script.crossOrigin = "anonymous"; script.setAttribute("data-repo", repo); script.setAttribute("data-repo-id", process.env.NEXT_PUBLIC_GISCUS_REPO_ID || ""); - script.setAttribute("data-category", process.env.NEXT_PUBLIC_GISCUS_CATEGORY || "General"); script.setAttribute("data-category-id", process.env.NEXT_PUBLIC_GISCUS_CATEGORY_ID || ""); script.setAttribute("data-mapping", "pathname"); script.setAttribute("data-strict", "0"); diff --git a/src/modules/utterances.tsx b/src/modules/utterances.tsx deleted file mode 100644 index 7e61e6c0..00000000 --- a/src/modules/utterances.tsx +++ /dev/null @@ -1,80 +0,0 @@ -"use client"; - -import { useEffect, useRef, useState } from "react"; - -import { DEFAULT_UTTERANCES_REPO, getUtterancesRepo } from "@common/config"; - -import { useColorMode } from "./color-mode/color-mode"; - -const Utterances = () => { - const { resolved } = useColorMode(); - const [lightRef, setLightRef] = useState(null); - const [darkRef, setDarkRef] = useState(null); - const loaded = useRef(false); - - // Inject both iframes once - useEffect(() => { - const repo = getUtterancesRepo(); - if (loaded.current) { - return; - } - if (!lightRef || !darkRef) { - return; - } - if (repo === DEFAULT_UTTERANCES_REPO) { - return; - } - - const add = ( - container: HTMLDivElement, - theme: "github-light" | "github-dark", - ) => { - const script = document.createElement("script"); - script.src = "https://utteranc.es/client.js"; - script.async = true; - script.crossOrigin = "anonymous"; - script.setAttribute("repo", repo); - script.setAttribute("issue-term", "pathname"); - script.setAttribute("theme", theme); - container.appendChild(script); - return script; - }; - - const sLight = add(lightRef, "github-light"); - const sDark = add(darkRef, "github-dark"); - loaded.current = true; - - return () => { - sLight.remove(); - sDark.remove(); - [lightRef, darkRef].forEach((c) => { - if (!c) { - return; - } - while (c.firstChild) { - c.removeChild(c.firstChild); - } - }); - loaded.current = false; - }; - }, [lightRef, darkRef]); - - const isDark = resolved === "dark"; - - return ( - <> -
-
- - ); -}; - -export default Utterances; From 64ea41b80da68f9f9dbcd3a8d17261d3d2745a3e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 28 Dec 2025 23:19:25 +0000 Subject: [PATCH 5/5] Fix TypeScript error in HtmlLangUpdater: handle null locale Co-authored-by: echoja <73801151+echoja@users.noreply.github.com> --- src/modules/i18n/HtmlLangUpdater.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/modules/i18n/HtmlLangUpdater.tsx b/src/modules/i18n/HtmlLangUpdater.tsx index 9d3dc94c..eeb085e7 100644 --- a/src/modules/i18n/HtmlLangUpdater.tsx +++ b/src/modules/i18n/HtmlLangUpdater.tsx @@ -3,13 +3,14 @@ import { useEffect } from "react"; import { usePathname } from "next/navigation"; import { getLocaleFromPathname } from "./util"; +import { i18n } from "./types"; const HtmlLangUpdater = () => { const pathname = usePathname(); useEffect(() => { const locale = getLocaleFromPathname(pathname); - document.documentElement.lang = locale; + document.documentElement.lang = locale ?? i18n.defaultLocale; }, [pathname]); return null;