Skip to content

Commit 603837b

Browse files
committed
Feat: 링크에엥커 추가
1 parent ed37b3c commit 603837b

File tree

7 files changed

+160
-30
lines changed

7 files changed

+160
-30
lines changed

components/markdown-renderer.tsx

Lines changed: 147 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
"use client";
2-
import React, { useEffect, useState } from "react";
2+
import React, { useEffect, useState, useRef } from "react";
33
import ReactMarkdown from "react-markdown";
44
import Link from "next/link";
55
import Image from "next/image";
@@ -43,6 +43,22 @@ export default function MarkdownRenderer({
4343
}: MarkdownRendererProps) {
4444
const [linkMap, setLinkMap] = useState<LinkMap>({});
4545
const [isMapLoaded, setIsMapLoaded] = useState(false);
46+
47+
// 헤딩 ID 중복 추적을 위한 Map
48+
const headingIdMap = useRef<Map<string, number>>(new Map());
49+
50+
// 중복되지 않는 ID 생성 함수
51+
const generateUniqueHeadingId = (text: string, level?: number): string => {
52+
const baseId = generateHeadingId(text, level);
53+
54+
// 현재 ID가 사용된 횟수 확인
55+
const count = headingIdMap.current.get(baseId) || 0;
56+
headingIdMap.current.set(baseId, count + 1);
57+
58+
// 첫 번째 사용이면 그대로, 두 번째부터는 숫자 추가
59+
return count === 0 ? baseId : `${baseId}-${count + 1}`;
60+
};
61+
4662
useEffect(() => {
4763
fetch("/link-map.json")
4864
.then((res) => res.json())
@@ -60,18 +76,63 @@ export default function MarkdownRenderer({
6076
// 이스케이프된 링크 패턴을 정상 링크로 변환
6177
let processed = text.replace(/\\(\[|\]|\(|\))/g, "$1");
6278

63-
// 위키링크 처리 - 정규식 개선
79+
// 위키링크 처리 - 정규식 개선 (앵커 지원 추가)
6480
processed = processed.replace(
65-
/\[\[([^|\]]+)(?:\|([^\]]+))?\]\]/g,
66-
(_, path, label) => {
81+
/\[\[([^\]]+)\]\]/g,
82+
(match, content) => {
83+
// | 분리자가 있는지 확인
84+
const pipeIndex = content.lastIndexOf('|');
85+
let pathWithAnchor, label;
86+
87+
if (pipeIndex !== -1) {
88+
// | 이후가 label
89+
pathWithAnchor = content.substring(0, pipeIndex);
90+
label = content.substring(pipeIndex + 1);
91+
} else {
92+
pathWithAnchor = content;
93+
label = null;
94+
}
95+
96+
// # 앵커 분리
97+
const hashIndex = pathWithAnchor.indexOf('#');
98+
let path, anchor;
99+
100+
if (hashIndex !== -1) {
101+
path = pathWithAnchor.substring(0, hashIndex);
102+
anchor = pathWithAnchor.substring(hashIndex);
103+
} else {
104+
path = pathWithAnchor;
105+
anchor = null;
106+
}
107+
67108
// 경로 정규화
68109
const cleanPath = path.replace(/\.md$/, "");
69-
// 표시할 이름이 없으면 경로의 마지막 부분을 사용
70-
const displayName = label || cleanPath.split("/").pop() || cleanPath;
110+
// 표시할 이름 결정
111+
let displayName;
112+
if (label) {
113+
// 사용자가 지정한 라벨이 있으면 사용
114+
displayName = label;
115+
} else {
116+
// 라벨이 없으면 파일명 사용
117+
const fileName = cleanPath.split("/").pop() || cleanPath;
118+
// 앵커가 있으면 파일명#앵커 형식으로 표시
119+
displayName = anchor ? `${fileName}${anchor}` : fileName;
120+
}
71121
// URI 인코딩 적용
72122
const encodedPath = encodeURIComponent(cleanPath);
123+
124+
// 앵커 처리: #제목 형식을 헤딩 ID 형식으로 변환
125+
let fullPath = encodedPath;
126+
if (anchor) {
127+
// #을 제거하고 텍스트 추출
128+
const anchorText = anchor.substring(1);
129+
// 헤딩 ID 형식으로 변환 (헤딩 레벨 없이 생성)
130+
const headingId = generateHeadingId(anchorText);
131+
fullPath = `${encodedPath}#${headingId}`;
132+
}
133+
73134
// 인코딩된 경로로 마크다운 링크 생성
74-
return `[${displayName}](${encodedPath})`;
135+
return `[${displayName}](${fullPath})`;
75136
},
76137
);
77138
return processed;
@@ -83,20 +144,65 @@ export default function MarkdownRenderer({
83144
const calloutRegex = /(>\s\[!.*?\].*?(?:\n>.*?)*)(?:\n\n|$)/gs;
84145

85146
return content.replace(calloutRegex, (calloutBlock) => {
86-
// 전체 콜아웃 블록 내에서 모든 위키링크를 한 번에 처리
147+
// 전체 콜아웃 블록 내에서 모든 위키링크를 한 번에 처리 (앵커 지원 추가)
87148
return calloutBlock.replace(
88-
/(- )?\[\[([^|\]]+)(?:\|([^\]]+))?\]\]/g,
89-
(match, bulletPoint, path, label) => {
149+
/(- )?\[\[([^\]]+)\]\]/g,
150+
(match, bulletPoint, content) => {
151+
// | 분리자가 있는지 확인
152+
const pipeIndex = content.lastIndexOf('|');
153+
let pathWithAnchor, label;
154+
155+
if (pipeIndex !== -1) {
156+
// | 이후가 label
157+
pathWithAnchor = content.substring(0, pipeIndex);
158+
label = content.substring(pipeIndex + 1);
159+
} else {
160+
pathWithAnchor = content;
161+
label = null;
162+
}
163+
164+
// # 앵커 분리
165+
const hashIndex = pathWithAnchor.indexOf('#');
166+
let path, anchor;
167+
168+
if (hashIndex !== -1) {
169+
path = pathWithAnchor.substring(0, hashIndex);
170+
anchor = pathWithAnchor.substring(hashIndex);
171+
} else {
172+
path = pathWithAnchor;
173+
anchor = null;
174+
}
175+
90176
// 이스케이프 문자 제거 및 경로 정규화
91177
const cleanPath = path.replace(/\\|\\.md$/, "");
92-
// 표시할 이름이 없으면 경로의 마지막 부분을 사용
93-
const displayName = label || cleanPath.split("/").pop() || cleanPath;
178+
// 표시할 이름 결정
179+
let displayName;
180+
if (label) {
181+
// 사용자가 지정한 라벨이 있으면 사용
182+
displayName = label;
183+
} else {
184+
// 라벨이 없으면 파일명 사용
185+
const fileName = cleanPath.split("/").pop() || cleanPath;
186+
// 앵커가 있으면 파일명#앵커 형식으로 표시
187+
displayName = anchor ? `${fileName}${anchor}` : fileName;
188+
}
94189
// URI 인코딩 적용
95190
const encodedPath = encodeURIComponent(cleanPath);
191+
192+
// 앵커 처리: #제목 형식을 헤딩 ID 형식으로 변환
193+
let fullPath = encodedPath;
194+
if (anchor) {
195+
// #을 제거하고 텍스트 추출
196+
const anchorText = anchor.substring(1);
197+
// 헤딩 ID 형식으로 변환 (헤딩 레벨 없이 생성)
198+
const headingId = generateHeadingId(anchorText);
199+
fullPath = `${encodedPath}#${headingId}`;
200+
}
201+
96202
// 불릿 포인트가 있으면 유지
97203
const prefix = bulletPoint || "";
98204
// 인코딩된 경로로 마크다운 링크 생성
99-
return `${prefix}[${displayName}](${encodedPath})`;
205+
return `${prefix}[${displayName}](${fullPath})`;
100206
},
101207
);
102208
});
@@ -159,7 +265,7 @@ export default function MarkdownRenderer({
159265
),
160266
h2: ({ children }: { children?: React.ReactNode }) => {
161267
const headingText = children?.toString() || "heading";
162-
const id = generateHeadingId(headingText, 2);
268+
const id = generateUniqueHeadingId(headingText);
163269

164270
// 현재 URL에서 # 이후의 앵커 부분 제외하고 기본 URL만 가져오기
165271
const getBaseUrl = () => {
@@ -188,7 +294,7 @@ export default function MarkdownRenderer({
188294
},
189295
h3: ({ children }: { children?: React.ReactNode }) => {
190296
const headingText = children?.toString() || "heading";
191-
const id = generateHeadingId(headingText, 3);
297+
const id = generateUniqueHeadingId(headingText);
192298

193299
// 현재 URL에서 # 이후의 앵커 부분 제외하고 기본 URL만 가져오기
194300
const getBaseUrl = () => {
@@ -217,7 +323,7 @@ export default function MarkdownRenderer({
217323
},
218324
h4: ({ children }: { children?: React.ReactNode }) => {
219325
const headingText = children?.toString() || "heading";
220-
const id = generateHeadingId(headingText, 4);
326+
const id = generateUniqueHeadingId(headingText);
221327

222328
// 현재 URL에서 # 이후의 앵커 부분 제외하고 기본 URL만 가져오기
223329
const getBaseUrl = () => {
@@ -405,23 +511,46 @@ export default function MarkdownRenderer({
405511
</Link>
406512
);
407513
}
514+
515+
// 동일 페이지 내의 앵커 링크 처리
516+
if (href.startsWith('#')) {
517+
return (
518+
<a
519+
href={href}
520+
className="relative text-primary font-medium transition-all duration-200
521+
hover:text-primary/80 after:absolute after:left-0 after:right-0 after:bottom-0
522+
after:h-[1px] after:bg-primary after:origin-bottom-right after:scale-x-0
523+
hover:after:origin-bottom-left hover:after:scale-x-100 after:transition-transform after:duration-300"
524+
>
525+
{children}
526+
</a>
527+
);
528+
}
408529

409530
// 내부 링크 처리
410531
if (!isMapLoaded)
411532
return <span className="text-muted-foreground">{children}</span>;
412533

534+
// 앵커 분리 처리
535+
const [hrefWithoutAnchor, anchor] = href.split('#');
536+
413537
// 디코딩 및 정규화
414-
const decodedHref = decodeURIComponent(href);
538+
const decodedHref = decodeURIComponent(hrefWithoutAnchor);
415539
const normalizedHref = decodedHref.replace(/\.md$/, "");
416540
const targetFileName = normalizedHref.split("/").pop();
417541

418542
// 링크맵에서 검색
419543
for (const [key, value] of Object.entries(linkMap)) {
420544
const srcFileName = key.replace(/\.md$/, "").split("/").pop();
421545
if (srcFileName === targetFileName) {
546+
// 앵커가 있으면 추가 (trailingSlash 설정 고려)
547+
const basePath = `/posts/${value}`;
548+
// 앵커가 있을 때는 trailing slash 제거 (Next.js trailingSlash: true 때문)
549+
const cleanBasePath = anchor ? basePath.replace(/\/$/, '') : basePath;
550+
const fullHref = anchor ? `${cleanBasePath}#${anchor}` : basePath;
422551
return (
423552
<Link
424-
href={`/posts/${value}`}
553+
href={fullHref}
425554
className="relative text-primary font-medium transition-all duration-200
426555
hover:text-primary/80 after:absolute after:left-0 after:right-0 after:bottom-0
427556
after:h-[1px] after:bg-primary after:origin-bottom-right after:scale-x-0

content/posts/자원/JavaScript/Javascript.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ tags:
44
- web
55
- javascript
66
createdAt: 2025-04-12 09:09:33
7-
modifiedAt: 2025-06-05 15:40:53
7+
modifiedAt: 2025-07-03 15:23:30
88
publish: 자원/JavaScript
99
related: ""
1010
series: ""
@@ -29,6 +29,6 @@ series: ""
2929
- [[콜 스택(Call Stack)이란 무엇인가]]
3030
- [[클로저(Closure)란 무엇인가]]
3131
- [[`this`키워드]]
32-
- [[프로토타입(Prototype)과 프로토타입 ]]
32+
- [[프로토타입(Prototype)과 프로토타입 상속]]
3333
- [[Javascript는 어떻게 비동기 처리가 가능한 것인가]]
3434
- [[Javascript에서의 비동기 함수]]

content/posts/프로젝트/당근마켓 클론코딩/당근마켓 클론코딩으로 Validator 사용해보기.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ tags:
55
- validator
66
- zod
77
createdAt: 2025-07-03 13:12:42
8-
modifiedAt: 2025-07-03 13:39:55
8+
modifiedAt: 2025-07-03 19:54:53
99
publish: 프로젝트/당근마켓 클론코딩
1010
related:
1111
- 당근마켓 클론코딩

lib/utils.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,14 @@ export function isGifFile(url: string): boolean {
4444
}
4545

4646
// 헤딩 ID 생성을 위한 공통 함수
47-
export function generateHeadingId(text: string, level: number): string {
48-
const prefix = `h${level}-`;
47+
export function generateHeadingId(text: string, level?: number): string {
4948
const slug = text
5049
.toLowerCase()
5150
.replace(/\s+/g, '-')
5251
.replace(/[^\w\--]/g, ''); // 한글 지원
53-
return `${prefix}${slug}`;
52+
53+
// level이 제공되면 prefix 추가, 아니면 slug만 반환
54+
return level ? `h${level}-${slug}` : slug;
5455
}
5556

5657
export function buildFolderStructure(posts: Post[]): FolderStructure[] {

public/meta-data.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
"tags": ["project", "carrot-market", "validator", "zod"],
1919
"series": "",
2020
"createdAt": "2025-07-03 13:12:42",
21-
"modifiedAt": "2025-07-03 13:39:55",
21+
"modifiedAt": "2025-07-03 19:54:53",
2222
"publish": "프로젝트/당근마켓 클론코딩"
2323
},
2424
{
@@ -282,7 +282,7 @@
282282
"tags": ["resource", "web", "javascript"],
283283
"series": "",
284284
"createdAt": "2025-04-12 09:09:33",
285-
"modifiedAt": "2025-06-05 15:40:53",
285+
"modifiedAt": "2025-07-03 15:23:30",
286286
"publish": "자원/JavaScript"
287287
},
288288
{
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
{
2-
"content": "\n# Javascript\n\n![1745108580-javascript.png](_assets/attachments/1745108580-javascript.png)\n\n웹사이트에서 [[HTML]]과 [[CSS]]가 각각 내용의 구조를 정하고 그 내용이 어떤 모습으로 표현되어야 하는지 브라우져에게 설명하는 언어라면 Javascript 는 웹사이트와 사용자가 상호작용을 할 수 있도록 하는 언어이다.\n\n## 관련 노트\n\n- [[변수의 선언,초기화,할당의 차이점은 무엇인가]]\n- [[var,let,const의 주요 차이점은 무엇인가]]\n- [[호이스팅(Hoisting)이란 무엇인가]]\n- [[데이터 타입과 불변성]]\n- [[null,undefined,undeclared 의 차이점은]]\n- [[`==` 과 `===` 의 차이]]\n- [[스코프(Scope)란 무엇인가]]\n- [[실행컨텍스트(Execution Context)]]\n- [[콜 스택(Call Stack)이란 무엇인가]]\n- [[클로저(Closure)란 무엇인가]]\n- [[`this`키워드]]\n- [[프로토타입(Prototype)과 프로토타입 ]]\n- [[Javascript는 어떻게 비동기 처리가 가능한 것인가]]\n- [[Javascript에서의 비동기 함수]]\n",
3-
"plainContent": "웹사이트에서 HTML]]과 [[CSS]]가 각각 내용의 구조를 정하고 그 내용이 어떤 모습으로 표현되어야 하는지 브라우져에게 설명하는 언어라면 Javascript 는 웹사이트와 사용자가 상호작용을 할 수 있도록 하는 언어이다.\n[[변수의 선언,초기화,할당의 차이점은 무엇인가]]\n[[var,let,const의 주요 차이점은 무엇인가]]\n[[호이스팅(Hoisting)이란 무엇인가]]\n[[데이터 타입과 불변성]]\n[[null,undefined,undeclared 의 차이점은]]\n[[== 과 === 의 차이]]\n[[스코프(Scope)란 무엇인가]]\n[[실행컨텍스트(Execution Context)]]\n[[콜 스택(Call Stack)이란 무엇인가]]\n[[클로저(Closure)란 무엇인가]]\n[[this키워드]]\n[[프로토타입(Prototype)과 프로토타입 ]]\n[[Javascript는 어떻게 비동기 처리가 가능한 것인가]]\n[[Javascript에서의 비동기 함수"
2+
"content": "\n# Javascript\n\n![1745108580-javascript.png](_assets/attachments/1745108580-javascript.png)\n\n웹사이트에서 [[HTML]]과 [[CSS]]가 각각 내용의 구조를 정하고 그 내용이 어떤 모습으로 표현되어야 하는지 브라우져에게 설명하는 언어라면 Javascript 는 웹사이트와 사용자가 상호작용을 할 수 있도록 하는 언어이다.\n\n## 관련 노트\n\n- [[변수의 선언,초기화,할당의 차이점은 무엇인가]]\n- [[var,let,const의 주요 차이점은 무엇인가]]\n- [[호이스팅(Hoisting)이란 무엇인가]]\n- [[데이터 타입과 불변성]]\n- [[null,undefined,undeclared 의 차이점은]]\n- [[`==` 과 `===` 의 차이]]\n- [[스코프(Scope)란 무엇인가]]\n- [[실행컨텍스트(Execution Context)]]\n- [[콜 스택(Call Stack)이란 무엇인가]]\n- [[클로저(Closure)란 무엇인가]]\n- [[`this`키워드]]\n- [[프로토타입(Prototype)과 프로토타입 상속]]\n- [[Javascript는 어떻게 비동기 처리가 가능한 것인가]]\n- [[Javascript에서의 비동기 함수]]\n",
3+
"plainContent": "웹사이트에서 HTML]]과 [[CSS]]가 각각 내용의 구조를 정하고 그 내용이 어떤 모습으로 표현되어야 하는지 브라우져에게 설명하는 언어라면 Javascript 는 웹사이트와 사용자가 상호작용을 할 수 있도록 하는 언어이다.\n[[변수의 선언,초기화,할당의 차이점은 무엇인가]]\n[[var,let,const의 주요 차이점은 무엇인가]]\n[[호이스팅(Hoisting)이란 무엇인가]]\n[[데이터 타입과 불변성]]\n[[null,undefined,undeclared 의 차이점은]]\n[[== 과 === 의 차이]]\n[[스코프(Scope)란 무엇인가]]\n[[실행컨텍스트(Execution Context)]]\n[[콜 스택(Call Stack)이란 무엇인가]]\n[[클로저(Closure)란 무엇인가]]\n[[this키워드]]\n[[프로토타입(Prototype)과 프로토타입 상속]]\n[[Javascript는 어떻게 비동기 처리가 가능한 것인가]]\n[[Javascript에서의 비동기 함수"
44
}

public/sitemap.xml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@
22
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
33
<url>
44
<loc>https://lazy-dino.github.io/</loc>
5-
<lastmod>2025-07-01</lastmod>
5+
<lastmod>2025-07-03</lastmod>
66
<changefreq>daily</changefreq>
77
<priority>1.0</priority>
88
</url>
99
<url>
1010
<loc>https://lazy-dino.github.io/projects</loc>
11-
<lastmod>2025-07-01</lastmod>
11+
<lastmod>2025-07-03</lastmod>
1212
<changefreq>weekly</changefreq>
1313
<priority>0.9</priority>
1414
</url>

0 commit comments

Comments
 (0)