Skip to content

Commit a87be45

Browse files
authored
fix(blog): heading anchor와 글 문체 정리 (#105)
* fix(blog): stabilize heading anchors and toc links * docs(blog): align ctr post titles and tone * fix(blog): MDX heading 컴포넌트 타입 보정 PR 104 이후 구조에서 cherry-pick한 heading factory가 Next build 타입 검사에서 generic JSX 오류를 냈어요. 동작은 유지하고 React createElement 기반으로 렌더링해 heading tag 타입을 명확히 했어요. * fix(blog): 긴 TOC 스크롤 영역 복원 긴 블로그 글에서 desktop TOC가 viewport 아래로 넘치면 하단 항목에 접근할 수 없는 문제가 있었어요. native hash link 동작은 유지하고 TOC 영역에 bottom constraint와 내부 overflow scroll을 복원했어요. --------- Co-authored-by: 박은우 <dev.haon@gmail.com>
1 parent e710428 commit a87be45

11 files changed

Lines changed: 302 additions & 132 deletions

File tree

blog/model/heading.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
const MARKDOWN_IMAGE_REGEX = /!\[([^\]]*)\]\([^)]*\)/g;
2+
const MARKDOWN_LINK_REGEX = /\[([^\]]+)\]\([^)]*\)/g;
3+
const INLINE_CODE_REGEX = /(`+)([^`]*?)\1/g;
4+
const HTML_TAG_REGEX = /<\/?[A-Za-z][^>]*>/g;
5+
const ESCAPED_CHARACTER_REGEX = /\\([\\`*_[\]{}()#+\-.!<>|])/g;
6+
const TRAILING_HEADING_MARKER_REGEX = /\s+#+\s*$/;
7+
8+
export function normalizeHeadingText(text: string): string {
9+
let normalizedText = text.trim();
10+
11+
if (!normalizedText) {
12+
return '';
13+
}
14+
15+
normalizedText = normalizedText
16+
.replace(MARKDOWN_IMAGE_REGEX, '$1')
17+
.replace(MARKDOWN_LINK_REGEX, '$1')
18+
.replace(INLINE_CODE_REGEX, '$2')
19+
.replace(HTML_TAG_REGEX, '')
20+
.replace(TRAILING_HEADING_MARKER_REGEX, '');
21+
22+
let previousText: string | null = null;
23+
while (normalizedText !== previousText) {
24+
previousText = normalizedText;
25+
normalizedText = normalizedText
26+
.replace(/(\*\*|__)(.*?)\1/g, '$2')
27+
.replace(/(\*|_)(.*?)\1/g, '$2')
28+
.replace(/~~(.*?)~~/g, '$1');
29+
}
30+
31+
return normalizedText
32+
.replace(ESCAPED_CHARACTER_REGEX, '$1')
33+
.replace(/\s+/g, ' ')
34+
.trim();
35+
}
36+
37+
function buildHeadingSlug(text: string): string {
38+
return normalizeHeadingText(text)
39+
.toLowerCase()
40+
.replace(
41+
/[^\w\uAC00-\uD7AF\u1100-\u11FF\u3130-\u318F\uA960-\uA97F\uD7B0-\uD7FF\s-]/g,
42+
''
43+
)
44+
.trim()
45+
.replace(/\s+/g, '-')
46+
.replace(/-+/g, '-');
47+
}
48+
49+
export function createHeadingIdGenerator() {
50+
const idCounts: Record<string, number> = {};
51+
52+
return (text: string): string | null => {
53+
const baseId = buildHeadingSlug(text);
54+
55+
if (!baseId) {
56+
return null;
57+
}
58+
59+
const count = idCounts[baseId] ?? 0;
60+
idCounts[baseId] = count + 1;
61+
62+
return count === 0 ? baseId : `${baseId}-${count}`;
63+
};
64+
}

blog/services/markdown-parser.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,38 @@ describe('parseHeadingsFromMdx', () => {
2929
});
3030
});
3131

32+
it('normalizes markdown-heavy headings and skips non-renderable cases', () => {
33+
const mdx = `
34+
### **🚀 성능 증명 (로컬 벤치마크)**
35+
### [Redis](https://redis.io) \`Pipeline\`
36+
### **🎯**
37+
\`\`\`md
38+
## code block heading
39+
\`\`\`
40+
### **🚀 성능 증명 (로컬 벤치마크)**
41+
`;
42+
43+
const headings = parseHeadingsFromMdx(mdx);
44+
45+
expect(headings).toEqual([
46+
{
47+
id: '성능-증명-로컬-벤치마크',
48+
text: '🚀 성능 증명 (로컬 벤치마크)',
49+
level: 3,
50+
},
51+
{
52+
id: 'redis-pipeline',
53+
text: 'Redis Pipeline',
54+
level: 3,
55+
},
56+
{
57+
id: '성능-증명-로컬-벤치마크-1',
58+
text: '🚀 성능 증명 (로컬 벤치마크)',
59+
level: 3,
60+
},
61+
]);
62+
});
63+
3264
it('returns an empty array for invalid content', () => {
3365
expect(parseHeadingsFromMdx('')).toEqual([]);
3466
});

blog/services/markdown-parser.ts

Lines changed: 43 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,11 @@
1+
import {
2+
createHeadingIdGenerator,
3+
normalizeHeadingText,
4+
} from '@/blog/model/heading';
15
import { getFolderSlug, type TocItem } from '@/blog/services/post-repository';
26
import fs from 'fs';
37
import path from 'path';
48

5-
// 헤딩 텍스트를 ID로 변환하는 함수
6-
function generateHeadingId(text: string): string {
7-
return text
8-
.toLowerCase()
9-
.replace(
10-
/[^\w\uAC00-\uD7AF\u1100-\u11FF\u3130-\u318F\uA960-\uA97F\uD7B0-\uD7FF\s-]/g,
11-
''
12-
) // 한글, 영문, 숫자, 공백, 하이픈 제외 제거 (특수문자/이모지 제거)
13-
.trim()
14-
.replace(/\s+/g, '-') // 공백을 하이픈으로
15-
.replace(/-+/g, '-'); // 연속된 하이픈 하나로
16-
}
17-
189
// MDX 소스에서 헤딩 파싱 (렌더링된 HTML이 아닌 원본 MDX에서)
1910
export function parseHeadingsFromMdx(mdxContent: string): TocItem[] {
2011
try {
@@ -23,29 +14,51 @@ export function parseHeadingsFromMdx(mdxContent: string): TocItem[] {
2314
return [];
2415
}
2516

26-
// Regex to match markdown headings (## Heading, ### Heading, etc.)
27-
const headingRegex = /^(#{1,6})\s+(.+)$/gm;
2817
const tocItems: TocItem[] = [];
29-
const idCounts: Record<string, number> = {};
18+
const nextHeadingId = createHeadingIdGenerator();
19+
const lines = mdxContent.split(/\r?\n/);
20+
let activeFence: { marker: '`' | '~'; length: number } | null = null;
21+
22+
for (const line of lines) {
23+
const fenceMatch = /^\s*(`{3,}|~{3,})/.exec(line);
3024

31-
let match;
32-
while ((match = headingRegex.exec(mdxContent)) !== null) {
33-
const level = match[1].length; // Number of # symbols
34-
const text = match[2].trim();
25+
if (fenceMatch) {
26+
const fenceMarker = fenceMatch[1][0] as '`' | '~';
27+
const fenceLength = fenceMatch[1].length;
28+
29+
if (!activeFence) {
30+
activeFence = {
31+
marker: fenceMarker,
32+
length: fenceLength,
33+
};
34+
} else if (
35+
activeFence.marker === fenceMarker &&
36+
fenceLength >= activeFence.length
37+
) {
38+
activeFence = null;
39+
}
40+
41+
continue;
42+
}
43+
44+
if (activeFence) {
45+
continue;
46+
}
47+
48+
const headingMatch = /^(#{1,6})\s+(.+)$/.exec(line);
49+
50+
if (!headingMatch) {
51+
continue;
52+
}
3553

36-
if (!text) continue;
54+
const level = headingMatch[1].length;
55+
const text = normalizeHeadingText(headingMatch[2]);
56+
const id = nextHeadingId(text);
3757

38-
// Generate unique ID
39-
let id = generateHeadingId(text);
40-
if (idCounts[id] !== undefined) {
41-
const count = idCounts[id];
42-
idCounts[id] = count + 1;
43-
id = `${id}-${count}`;
44-
} else {
45-
idCounts[id] = 1;
58+
if (!text || !id) {
59+
continue;
4660
}
4761

48-
// Flat structure - just push all headings
4962
tocItems.push({
5063
id,
5164
text,
Lines changed: 13 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,14 @@
11
import { fireEvent, render } from '@testing-library/react';
22
import { describe, expect, it, vi } from 'vitest';
3-
import { setupScrollToMock } from '@/tests/support/dom-mocks';
43
import { TableOfContents } from './TableOfContents';
54

65
describe('TableOfContents', () => {
7-
it('renders items and scrolls/hashes when clicked', () => {
8-
setupScrollToMock();
9-
6+
it('renders native hash links and observes headings', () => {
107
const item = { id: 'section-1', text: '첫번째 섹션', level: 2 };
118
const header = document.createElement('h2');
129
header.id = item.id;
13-
header.getBoundingClientRect = () => ({ top: 320 } as DOMRect);
1410
document.body.appendChild(header);
1511

16-
window.history.replaceState = vi.fn();
1712
const observeSpy = vi.fn();
1813
const disconnectSpy = vi.fn();
1914

@@ -29,23 +24,22 @@ describe('TableOfContents', () => {
2924
window.IntersectionObserver =
3025
MockIntersectionObserver as unknown as typeof window.IntersectionObserver;
3126

32-
const { getByRole } = render(<TableOfContents items={[item]} />);
27+
const { container, getByRole } = render(<TableOfContents items={[item]} />);
3328

34-
const button = getByRole('button', { name: '첫번째 섹션' });
35-
fireEvent.click(button);
29+
const link = getByRole('link', { name: '첫번째 섹션' });
30+
fireEvent.click(link);
3631

37-
expect(button).toHaveStyle({
32+
expect(container.querySelector('nav')).toBeNull();
33+
expect(document.body.querySelector('nav')).toHaveClass('bottom-8');
34+
expect(document.body.querySelector('nav > div')).toHaveClass(
35+
'h-full',
36+
'overflow-y-auto'
37+
);
38+
expect(document.body.querySelector('ul')).toHaveClass('m-0', 'p-0');
39+
expect(link).toHaveAttribute('href', `#${item.id}`);
40+
expect(link).toHaveStyle({
3841
fontFamily: 'var(--font-sans-emoji)',
3942
});
4043
expect(observeSpy).toHaveBeenCalledWith(header);
41-
expect(window.history.replaceState).toHaveBeenCalledWith(
42-
null,
43-
'',
44-
`#${item.id}`
45-
);
46-
expect(window.scrollTo).toHaveBeenCalledWith({
47-
top: 220,
48-
behavior: 'smooth',
49-
});
5044
});
5145
});

blog/ui/components/TableOfContents/TableOfContents.tsx

Lines changed: 19 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use client';
22

33
import { useEffect, useState } from 'react';
4+
import { createPortal } from 'react-dom';
45
import { clsx } from 'clsx';
56

67
interface TocItem {
@@ -15,8 +16,17 @@ interface TableOfContentsProps {
1516

1617
export default function TableOfContents({ items }: TableOfContentsProps) {
1718
const [activeId, setActiveId] = useState<string>('');
19+
const [portalRoot, setPortalRoot] = useState<HTMLElement | null>(null);
1820

1921
useEffect(() => {
22+
setPortalRoot(document.body);
23+
}, []);
24+
25+
useEffect(() => {
26+
if (items.length === 0) {
27+
return;
28+
}
29+
2030
const observer = new IntersectionObserver(
2131
(entries) => {
2232
entries.forEach((entry) => {
@@ -38,22 +48,9 @@ export default function TableOfContents({ items }: TableOfContentsProps) {
3848
return () => observer.disconnect();
3949
}, [items]);
4050

41-
const handleClick = (id: string) => {
42-
const element = document.getElementById(id);
43-
if (element) {
44-
const yOffset = -100;
45-
const y =
46-
element.getBoundingClientRect().top + window.pageYOffset + yOffset;
47-
window.scrollTo({ top: y, behavior: 'smooth' });
48-
49-
// Update URL hash without jumping and without adding to history
50-
window.history.replaceState(null, '', `#${id}`);
51-
}
52-
};
53-
54-
if (items.length === 0) return null;
51+
if (items.length === 0 || !portalRoot) return null;
5552

56-
return (
53+
return createPortal(
5754
<nav
5855
className="fixed right-8 top-20 bottom-8 hidden w-64 xl:block"
5956
aria-label="이 글의 목차"
@@ -62,11 +59,12 @@ export default function TableOfContents({ items }: TableOfContentsProps) {
6259
<h2 className="text-sm font-semibold text-[var(--color-grey-900)] mb-4 sticky top-0 bg-[var(--color-grey-50)] pb-2">
6360
이 글의 목차
6461
</h2>
65-
<ul className="flex flex-col gap-1">
62+
<ul className="m-0 flex list-none flex-col gap-1 p-0">
6663
{items.map((item) => (
6764
<li key={item.id}>
68-
<button
69-
onClick={() => handleClick(item.id)}
65+
<a
66+
href={`#${item.id}`}
67+
aria-current={activeId === item.id ? 'location' : undefined}
7068
className={clsx(
7169
'block w-full text-left text-sm py-1.5 px-3 rounded-[6px]',
7270
'transition-colors duration-[var(--duration-150)]',
@@ -78,12 +76,13 @@ export default function TableOfContents({ items }: TableOfContentsProps) {
7876
style={{ fontFamily: 'var(--font-sans-emoji)' }}
7977
>
8078
{item.text}
81-
</button>
79+
</a>
8280
</li>
8381
))}
8482
</ul>
8583
</div>
86-
</nav>
84+
</nav>,
85+
portalRoot
8786
);
8887
}
8988

blog/ui/mdx/components.test.tsx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { render, screen } from '@testing-library/react';
2+
import { describe, expect, it } from 'vitest';
3+
import { getMDXComponents } from './components';
4+
5+
describe('getMDXComponents', () => {
6+
it('assigns normalized heading ids from rendered text', () => {
7+
const { h3: H3, h4: H4 } = getMDXComponents({});
8+
9+
if (!H3 || !H4) {
10+
throw new Error('Expected heading components to be defined');
11+
}
12+
13+
render(
14+
<>
15+
<H3>
16+
<strong>
17+
🚀 성능 증명 <code>k6</code>
18+
</strong>
19+
</H3>
20+
<H3>🚀 성능 증명 k6</H3>
21+
<H4>하위 섹션</H4>
22+
</>
23+
);
24+
25+
const headings = screen.getAllByRole('heading', {
26+
name: '🚀 성능 증명 k6',
27+
});
28+
29+
expect(headings).toHaveLength(2);
30+
expect(headings[0]).toHaveAttribute('id', '성능-증명-k6');
31+
expect(headings[1]).toHaveAttribute('id', '성능-증명-k6-1');
32+
expect(screen.getByRole('heading', { name: '하위 섹션' })).toHaveAttribute(
33+
'id',
34+
'하위-섹션'
35+
);
36+
});
37+
});

0 commit comments

Comments
 (0)