Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .eslintrc.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"extends": ["next/core-web-vitals", "next/typescript"],
"ignorePatterns": ["src/lib/build"],
"ignorePatterns": ["src/lib/scripts"],
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
Expand Down
1,322 changes: 832 additions & 490 deletions package-lock.json

Large diffs are not rendered by default.

22 changes: 14 additions & 8 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,32 +16,38 @@
},
"lint-staged": {
"src/**/*.{ts,tsx}": [
"next lint --fix",
"next lint --fix --file",
"prettier --write"
]
},
"dependencies": {
"@next/third-parties": "^15.1.5",
"@tailwindcss/postcss": "^4.2.1",
"framer-motion": "^12.5.0",
"clsx": "^2.1.1",
"gray-matter": "^4.0.3",
"highlight.js": "^11.11.1",
"html-react-parser": "^5.2.17",
"lucide-react": "^0.563.0",
"next": "^15.5.12",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-highlight": "^0.15.0",
"react-syntax-highlighter": "^16.1.1",
"rehype-pretty-code": "^0.14.3",
"rehype-slug": "^6.0.0",
"rehype-stringify": "^10.0.1",
"remark": "^15.0.1",
"remark-gfm": "^4.0.0",
"remark-html": "^16.0.1"
"remark-gfm": "^4.0.1",
"remark-html": "^16.0.1",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.2",
"shiki": "^4.0.2",
"tailwind-merge": "^3.5.0",
"unified": "^11.0.5"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/typography": "^0.5.19",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/react-highlight": "^0.12.8",
"eslint": "^9",
"eslint-config-next": "15.1.4",
"husky": "^9.1.7",
Expand Down
80 changes: 0 additions & 80 deletions src/app/blog/[category]/[post]/_components/PostContainer.tsx

This file was deleted.

22 changes: 22 additions & 0 deletions src/app/blog/[category]/[post]/_components/PostLayout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"use client";

import PostArticle from "@/components/PostArticle";
import type { PostMetaData } from "@/types/posts.types";
import TableOfContents from "./TableOfContents";

interface HighlightedCodeProps {
contentHtml: string;
metadata: PostMetaData;
}

const PostLayout = ({ metadata, contentHtml }: HighlightedCodeProps) => {
return (
<div className="relative flex lg:mb-96">
<PostArticle metadata={metadata} contentHtml={contentHtml} />
{/* 💡 문자열만 툭 던져주면 TableOfContents가 알아서 파싱하고 그립니다. */}
<TableOfContents contentHtml={contentHtml} />
</div>
);
};

export default PostLayout;
89 changes: 89 additions & 0 deletions src/app/blog/[category]/[post]/_components/TableOfContents.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"use client";

import { useEffect, useState } from "react";

export type TocItem = {
id: string;
text: string;
level: 2 | 3;
};

interface TableOfContentsProps {
contentHtml: string;
}

const extractTocSync = (htmlString: string): TocItem[] => {
const regex = /<(h[23])\s+id="([^"]+)"[^>]*>(.*?)<\/\1>/gi;
const headings: TocItem[] = [];
let match;

while ((match = regex.exec(htmlString)) !== null) {
const textContent = match[3].replace(/<[^>]+>/g, "").trim();
headings.push({
level: match[1].toLowerCase() === "h2" ? 2 : 3,
id: match[2],
text: textContent,
});
}
return headings;
};

const TableOfContents = ({ contentHtml }: TableOfContentsProps) => {
const toc = extractTocSync(contentHtml);
const [activeId, setActiveId] = useState<string>("");

useEffect(() => {
if (toc.length === 0) return;

const elements = document.querySelectorAll("h2, h3");
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setActiveId(entry.target.id);
}
});
},
{ rootMargin: "-80px 0px -80% 0px", threshold: 0.1 }
);

elements.forEach((el) => observer.observe(el));

return () => observer.disconnect();
}, [toc]);

if (toc.length === 0) return null;

return (
<div className="sticky top-15 mt-40 hidden self-start xl:block">
<ul
className="toc max-h-[calc(100vh-120px)] list-none overflow-auto
overflow-y-auto border-l-2 border-neutral-300 px-3 py-1 text-sm
dark:border-neutral-700"
>
{toc.map((i) => (
<li
key={i.id + i.text}
className={`pr-2 text-nowrap transition-all duration-200 ${
i.level === 3 ? "mt-0.5 ml-4" : "mt-1.5"
}`}
>
<a
href={`#${i.id}`}
className={`block transition-colors ${
activeId === i.id
? "font-semibold text-blue-500 dark:text-blue-400"
: `text-[#868E96] hover:text-[#212529]
dark:hover:text-neutral-300`
}`}
>
{i.text}
</a>
</li>
))}
</ul>
</div>
);
};

export default TableOfContents;
16 changes: 13 additions & 3 deletions src/app/blog/[category]/[post]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { getPostDetail, getPostList, getPostMeta } from "@/lib/postService";
import { Metadata } from "next";
import PostContainer from "./_components/PostContainer";
import { notFound } from "next/navigation";
import PostLayout from "./_components/PostLayout";

interface PageProps {
params: Promise<{ category: string; post: string }>;
Expand Down Expand Up @@ -55,9 +56,18 @@ export const generateStaticParams = () => {

const PostPage = async ({ params }: PageProps) => {
const { category, post } = await params;
const { post: meta, contentHtml } = getPostDetail(category, post);

return <PostContainer metadata={meta.metadata} contentHtml={contentHtml} />;
try {
const { post: meta, contentHtml } = getPostDetail(category, post);

if (!meta || !contentHtml) {
notFound();
}

return <PostLayout metadata={meta.metadata} contentHtml={contentHtml} />;
} catch {
notFound();
}
};

export default PostPage;
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import HighlightCode from "@/components/HighlightCode";
import HighlightText from "@/components/HighlightText";
import PostArticle from "@/components/PostArticle";
import ArrowHeadSVG from "@/svg/ArrowHeadSVG";
import type { Post } from "@/types/posts.types";
import { useState } from "react";
Expand Down Expand Up @@ -41,9 +41,7 @@ const ArticleViewPost = ({ post, searchText }: ArticleViewPostProps) => {
border-neutral-200 bg-white dark:border-neutral-700
dark:bg-neutral-800/40"
>
<article
className="markdown-body mx-auto w-full max-w-[886px] p-5 sm:p-8"
>
<article className="markdown-body mx-auto w-full max-w-221.5 p-5 sm:p-8">
{loading ? (
<div
className="typo-14-m flex h-32 items-center justify-center
Expand All @@ -52,7 +50,7 @@ const ArticleViewPost = ({ post, searchText }: ArticleViewPostProps) => {
게시글을 불러오는 중...
</div>
) : (
<HighlightCode metadata={post.metadata} contentHtml={postContent} />
<PostArticle metadata={post.metadata} contentHtml={postContent} />
)}
</article>
<button
Expand Down Expand Up @@ -84,7 +82,7 @@ const ArticleViewPost = ({ post, searchText }: ArticleViewPostProps) => {
>
<div className="flex items-center gap-5 p-4 sm:gap-6 sm:p-5">
<div
className="hidden w-[140px] shrink-0 overflow-hidden rounded-lg border
className="hidden w-35 shrink-0 overflow-hidden rounded-lg border
border-neutral-100 sm:block dark:border-neutral-700"
>
<img
Expand All @@ -101,14 +99,12 @@ const ArticleViewPost = ({ post, searchText }: ArticleViewPostProps) => {
>
<HighlightText text={post.metadata.title} query={searchText} />
</h2>

<div
className="typo-14-body-m mb-3 line-clamp-2 text-pretty
text-neutral-600 dark:text-neutral-400"
>
<HighlightText text={post.excerpt} query={searchText} />
</div>

<div
className="typo-13-m flex items-center gap-2 text-neutral-500
dark:text-neutral-400"
Expand Down
5 changes: 5 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
@import "tailwindcss";

@import "../styles/typography.css";
@import "../styles/prose.css";
@import "../styles/codeblock.css";

@plugin "@tailwindcss/typography";

@custom-variant dark (&:where(.dark, .dark *));
4 changes: 2 additions & 2 deletions src/app/not-found.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ const NotFound = () => {
};
return (
<div
className="flex w-full flex-col items-center justify-center"
style={{ height: "calc(100svh - var(--header-size))" }}
className="flex min-h-[calc(100vh-101px)] w-full flex-col items-center
justify-center"
>
<h1 className="text-5xl font-medium">404 ERROR</h1>
<p className="mb-4 text-xl">
Expand Down
Loading
Loading