Skip to content
2 changes: 2 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ jobs:
- run: bun run build
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VITE_GISCUS_REPO_ID: ${{ vars.VITE_GISCUS_REPO_ID }}
VITE_GISCUS_CATEGORY_ID: ${{ vars.VITE_GISCUS_CATEGORY_ID }}
- name: Deploy to Cloudflare Workers
uses: cloudflare/wrangler-action@v4
with:
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ jobs:
- uses: oven-sh/setup-bun@v2
- run: bun install --frozen-lockfile
- run: bun run build
env:
VITE_GISCUS_REPO_ID: ${{ vars.VITE_GISCUS_REPO_ID }}
VITE_GISCUS_CATEGORY_ID: ${{ vars.VITE_GISCUS_CATEGORY_ID }}

# 배포 시작 시점에 "In progress" 상태로 댓글을 먼저 남김(갱신)
- name: Render in-progress comment
Expand Down
98 changes: 98 additions & 0 deletions src/components/Giscus.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { useEffect, useRef } from "react";
import { useTheme } from "../hooks/useTheme";
import {
GISCUS_ORIGIN,
GISCUS_SCRIPT_SRC,
buildGiscusAttributes,
giscusConfig,
} from "../constants/giscus";

interface GiscusProps {
/** 댓글을 붙일 글의 discussion 번호(= slug). */
term: string;
}

/**
* giscus 댓글 위젯.
*
* 글마다 재마운트하면(key={slug}) client.js가 다시 실행되면서 window에 걸리는
* message 리스너가 계속 쌓인다. 그래서 한 번만 마운트하고, 글·테마가 바뀌면
* iframe에 setConfig 메시지를 보내 갱신한다.
*/
export function Giscus({ term }: GiscusProps) {
const containerRef = useRef<HTMLDivElement>(null);
const { isDark } = useTheme();
const theme = isDark ? "dark" : "light";

// iframe이 늦게 떴을 때 load 시점에 다시 보낼 최신 설정.
const configRef = useRef({ theme, number: Number(term) });

useEffect(() => {
const container = containerRef.current;
if (!container) return;

if (
import.meta.env.DEV &&
(!giscusConfig.repoId || !giscusConfig.categoryId)
) {
console.warn(
"[giscus] VITE_GISCUS_REPO_ID / VITE_GISCUS_CATEGORY_ID가 비어 있어 댓글 위젯이 뜨지 않습니다.",
);
}

const sendConfig = () => {
const iframe = container.querySelector<HTMLIFrameElement>(
"iframe.giscus-frame",
);
iframe?.contentWindow?.postMessage(
{ giscus: { setConfig: configRef.current } },
GISCUS_ORIGIN,
);
};

// client.js가 iframe을 비동기로 만든다. 만들어지면 load 때 현재 설정을 다시
// 보내, 로딩 중에 테마나 글이 바뀌어도 어긋나지 않게 한다.
const observer = new MutationObserver(() => {
const iframe = container.querySelector<HTMLIFrameElement>(
"iframe.giscus-frame",
);
if (!iframe) return;
observer.disconnect();
iframe.addEventListener("load", sendConfig);
});
observer.observe(container, { childList: true });

const script = document.createElement("script");
script.src = GISCUS_SCRIPT_SRC;
script.async = true;
script.crossOrigin = "anonymous";
for (const [name, value] of Object.entries(
buildGiscusAttributes(theme, term),
)) {
script.setAttribute(name, value);
}
container.appendChild(script);

return () => {
observer.disconnect();
container.replaceChildren();
};
// 마운트 때 한 번만 실행한다. theme·term 변경은 아래 effect가 맡는다.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

// 글이나 테마가 바뀌면 위젯에 반영한다.
useEffect(() => {
const config = { theme, number: Number(term) };
configRef.current = config;
const iframe = containerRef.current?.querySelector<HTMLIFrameElement>(
"iframe.giscus-frame",
);
iframe?.contentWindow?.postMessage(
{ giscus: { setConfig: config } },
GISCUS_ORIGIN,
);
}, [theme, term]);

return <div ref={containerRef} className="giscus" />;
}
37 changes: 37 additions & 0 deletions src/constants/giscus.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { buildGiscusAttributes, giscusConfig } from "./giscus";

describe("buildGiscusAttributes", () => {
it("설정 상수를 data 속성으로 전달한다", () => {
const attrs = buildGiscusAttributes("light", "682");

expect(attrs["data-repo"]).toBe(giscusConfig.repo);
expect(attrs["data-repo-id"]).toBe(giscusConfig.repoId);
expect(attrs["data-category"]).toBe(giscusConfig.category);
expect(attrs["data-category-id"]).toBe(giscusConfig.categoryId);
});

it("글의 discussion 번호로 매핑한다", () => {
const attrs = buildGiscusAttributes("light", "682");

expect(attrs["data-mapping"]).toBe("number");
expect(attrs["data-term"]).toBe("682");
});

it("언어는 ko로 고정한다", () => {
expect(buildGiscusAttributes("light", "1")["data-lang"]).toBe("ko");
});

it("반응·입력 위치 등 고정 옵션을 설정한다", () => {
const attrs = buildGiscusAttributes("light", "1");

expect(attrs["data-reactions-enabled"]).toBe("1");
expect(attrs["data-emit-metadata"]).toBe("0");
expect(attrs["data-input-position"]).toBe("bottom");
});

it("테마 인자를 data-theme에 반영한다", () => {
expect(buildGiscusAttributes("light", "1")["data-theme"]).toBe("light");
expect(buildGiscusAttributes("dark", "1")["data-theme"]).toBe("dark");
});
});
36 changes: 36 additions & 0 deletions src/constants/giscus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// giscus 댓글 위젯 설정값.
// 블로그 글은 DaleStudy/daleui "블로그" 카테고리 Discussion에서 오고 slug가 곧
// discussion 번호라, mapping=number로 글마다 원본 토론에 그대로 연결한다.
// repo-id·category-id는 공개돼도 되는 값이라 상수로 두되 VITE_GISCUS_* 로 덮어쓸 수 있다.
export const giscusConfig = {
repo: (import.meta.env.VITE_GISCUS_REPO ??
"DaleStudy/daleui") as `${string}/${string}`,
repoId: import.meta.env.VITE_GISCUS_REPO_ID ?? "R_kgDOMovgOA",
category: import.meta.env.VITE_GISCUS_CATEGORY ?? "블로그",
categoryId: import.meta.env.VITE_GISCUS_CATEGORY_ID ?? "DIC_kwDOMovgOM4CqB1o",
} as const;

export const GISCUS_SCRIPT_SRC = "https://giscus.app/client.js";
export const GISCUS_ORIGIN = "https://giscus.app";

export type GiscusTheme = "light" | "dark";

/** client.js `<script>`에 붙일 `data-*` 속성. term은 글의 discussion 번호다. */
export function buildGiscusAttributes(
theme: GiscusTheme,
term: string,
): Record<string, string> {
return {
"data-repo": giscusConfig.repo,
"data-repo-id": giscusConfig.repoId,
"data-category": giscusConfig.category,
"data-category-id": giscusConfig.categoryId,
"data-mapping": "number",
"data-term": term,
"data-reactions-enabled": "1",
"data-emit-metadata": "0",
"data-input-position": "bottom",
"data-theme": theme,
"data-lang": "ko",
};
}
5 changes: 5 additions & 0 deletions src/routes/blog.$slug.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { Route } from "./+types/blog.$slug";
import { css } from "../../styled-system/css";
import { findBlog, listBlog } from "../content/blog/loader";
import { UserProfile } from "../components/UserProfile";
import { Giscus } from "../components/Giscus";
import DirectionalLink from "../components/DirectionalLink";
import { Navigation } from "../sections/blog/Navigation";

Expand Down Expand Up @@ -152,6 +153,10 @@ export default function BlogSlug({ loaderData }: Route.ComponentProps) {
)}
</div>
</Box>

<Box as="section" aria-label="댓글" className={css({ mt: "48" })}>
<Giscus term={slug} />
</Box>
Comment on lines +157 to +159

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

giscus component lib를 사용하시는줄 알았는데 직접 구현하셨네요 특별한 이유가 있을까요?

https://github.com/giscus/giscus-component

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

아, 예전에 만들었던 기억에 의존하다보니 있다는걸 몰랐네요 😅

</Box>
</VStack>
</>
Expand Down