Skip to content
Closed
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
64 changes: 64 additions & 0 deletions blog/model/heading.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
const MARKDOWN_IMAGE_REGEX = /!\[([^\]]*)\]\([^)]*\)/g;
const MARKDOWN_LINK_REGEX = /\[([^\]]+)\]\([^)]*\)/g;
const INLINE_CODE_REGEX = /(`+)([^`]*?)\1/g;
const HTML_TAG_REGEX = /<\/?[A-Za-z][^>]*>/g;
const ESCAPED_CHARACTER_REGEX = /\\([\\`*_[\]{}()#+\-.!<>|])/g;
const TRAILING_HEADING_MARKER_REGEX = /\s+#+\s*$/;

export function normalizeHeadingText(text: string): string {
let normalizedText = text.trim();

if (!normalizedText) {
return '';
}

normalizedText = normalizedText
.replace(MARKDOWN_IMAGE_REGEX, '$1')
.replace(MARKDOWN_LINK_REGEX, '$1')
.replace(INLINE_CODE_REGEX, '$2')
.replace(HTML_TAG_REGEX, '')
.replace(TRAILING_HEADING_MARKER_REGEX, '');

let previousText: string | null = null;
while (normalizedText !== previousText) {
previousText = normalizedText;
normalizedText = normalizedText
.replace(/(\*\*|__)(.*?)\1/g, '$2')
.replace(/(\*|_)(.*?)\1/g, '$2')
.replace(/~~(.*?)~~/g, '$1');
}

return normalizedText
.replace(ESCAPED_CHARACTER_REGEX, '$1')
.replace(/\s+/g, ' ')
.trim();
}

function buildHeadingSlug(text: string): string {
return normalizeHeadingText(text)
.toLowerCase()
.replace(
/[^\w\uAC00-\uD7AF\u1100-\u11FF\u3130-\u318F\uA960-\uA97F\uD7B0-\uD7FF\s-]/g,
''
)
.trim()
.replace(/\s+/g, '-')
.replace(/-+/g, '-');
}

export function createHeadingIdGenerator() {
const idCounts: Record<string, number> = {};

return (text: string): string | null => {
const baseId = buildHeadingSlug(text);

if (!baseId) {
return null;
}

const count = idCounts[baseId] ?? 0;
idCounts[baseId] = count + 1;

return count === 0 ? baseId : `${baseId}-${count}`;
};
}
32 changes: 32 additions & 0 deletions blog/services/markdown-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,38 @@ describe('parseHeadingsFromMdx', () => {
});
});

it('normalizes markdown-heavy headings and skips non-renderable cases', () => {
const mdx = `
### **🚀 성능 증명 (로컬 벤치마크)**
### [Redis](https://redis.io) \`Pipeline\`
### **🎯**
\`\`\`md
## code block heading
\`\`\`
### **🚀 성능 증명 (로컬 벤치마크)**
`;

const headings = parseHeadingsFromMdx(mdx);

expect(headings).toEqual([
{
id: '성능-증명-로컬-벤치마크',
text: '🚀 성능 증명 (로컬 벤치마크)',
level: 3,
},
{
id: 'redis-pipeline',
text: 'Redis Pipeline',
level: 3,
},
{
id: '성능-증명-로컬-벤치마크-1',
text: '🚀 성능 증명 (로컬 벤치마크)',
level: 3,
},
]);
});

it('returns an empty array for invalid content', () => {
expect(parseHeadingsFromMdx('')).toEqual([]);
});
Expand Down
73 changes: 43 additions & 30 deletions blog/services/markdown-parser.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,11 @@
import {
createHeadingIdGenerator,
normalizeHeadingText,
} from '@/blog/model/heading';
import { getFolderSlug, type TocItem } from '@/blog/services/post-repository';
import fs from 'fs';
import path from 'path';

// 헤딩 텍스트를 ID로 변환하는 함수
function generateHeadingId(text: string): string {
return text
.toLowerCase()
.replace(
/[^\w\uAC00-\uD7AF\u1100-\u11FF\u3130-\u318F\uA960-\uA97F\uD7B0-\uD7FF\s-]/g,
''
) // 한글, 영문, 숫자, 공백, 하이픈 제외 제거 (특수문자/이모지 제거)
.trim()
.replace(/\s+/g, '-') // 공백을 하이픈으로
.replace(/-+/g, '-'); // 연속된 하이픈 하나로
}

// MDX 소스에서 헤딩 파싱 (렌더링된 HTML이 아닌 원본 MDX에서)
export function parseHeadingsFromMdx(mdxContent: string): TocItem[] {
try {
Expand All @@ -23,29 +14,51 @@ export function parseHeadingsFromMdx(mdxContent: string): TocItem[] {
return [];
}

// Regex to match markdown headings (## Heading, ### Heading, etc.)
const headingRegex = /^(#{1,6})\s+(.+)$/gm;
const tocItems: TocItem[] = [];
const idCounts: Record<string, number> = {};
const nextHeadingId = createHeadingIdGenerator();
const lines = mdxContent.split(/\r?\n/);
let activeFence: { marker: '`' | '~'; length: number } | null = null;

for (const line of lines) {
const fenceMatch = /^\s*(`{3,}|~{3,})/.exec(line);

let match;
while ((match = headingRegex.exec(mdxContent)) !== null) {
const level = match[1].length; // Number of # symbols
const text = match[2].trim();
if (fenceMatch) {
const fenceMarker = fenceMatch[1][0] as '`' | '~';
const fenceLength = fenceMatch[1].length;

if (!activeFence) {
activeFence = {
marker: fenceMarker,
length: fenceLength,
};
} else if (
activeFence.marker === fenceMarker &&
fenceLength >= activeFence.length
) {
activeFence = null;
}

continue;
}

if (activeFence) {
continue;
}

const headingMatch = /^(#{1,6})\s+(.+)$/.exec(line);

if (!headingMatch) {
continue;
}

if (!text) continue;
const level = headingMatch[1].length;
const text = normalizeHeadingText(headingMatch[2]);
const id = nextHeadingId(text);

// Generate unique ID
let id = generateHeadingId(text);
if (idCounts[id] !== undefined) {
const count = idCounts[id];
idCounts[id] = count + 1;
id = `${id}-${count}`;
} else {
idCounts[id] = 1;
if (!text || !id) {
continue;
}

// Flat structure - just push all headings
tocItems.push({
id,
text,
Expand Down
29 changes: 10 additions & 19 deletions blog/ui/components/TableOfContents.test.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,14 @@
import { fireEvent, render } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { setupScrollToMock } from '@/shared/testing/dom-mocks';
import { TableOfContents } from './TableOfContents';

describe('TableOfContents', () => {
it('renders items and scrolls/hashes when clicked', () => {
setupScrollToMock();

it('renders native hash links and observes headings', () => {
const item = { id: 'section-1', text: '첫번째 섹션', level: 2 };
const header = document.createElement('h2');
header.id = item.id;
header.getBoundingClientRect = () => ({ top: 320 } as DOMRect);
document.body.appendChild(header);

window.history.replaceState = vi.fn();
const observeSpy = vi.fn();
const disconnectSpy = vi.fn();

Expand All @@ -29,23 +24,19 @@ describe('TableOfContents', () => {
window.IntersectionObserver =
MockIntersectionObserver as unknown as typeof window.IntersectionObserver;

const { getByRole } = render(<TableOfContents items={[item]} />);
const { container, getByRole } = render(<TableOfContents items={[item]} />);

const button = getByRole('button', { name: '첫번째 섹션' });
fireEvent.click(button);
const link = getByRole('link', { name: '첫번째 섹션' });
fireEvent.click(link);

expect(button).toHaveStyle({
expect(container.querySelector('nav')).toBeNull();
expect(document.body.querySelector('nav')).not.toHaveClass('bottom-8');
expect(document.body.querySelector('nav > div')).not.toHaveClass('h-full');
expect(document.body.querySelector('ul')).toHaveClass('m-0', 'p-0');
expect(link).toHaveAttribute('href', `#${item.id}`);
expect(link).toHaveStyle({
fontFamily: 'var(--font-sans-emoji)',
});
expect(observeSpy).toHaveBeenCalledWith(header);
expect(window.history.replaceState).toHaveBeenCalledWith(
null,
'',
`#${item.id}`
);
expect(window.scrollTo).toHaveBeenCalledWith({
top: 220,
behavior: 'smooth',
});
});
});
43 changes: 21 additions & 22 deletions blog/ui/components/TableOfContents/TableOfContents.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { clsx } from 'clsx';

interface TocItem {
Expand All @@ -15,8 +16,17 @@ interface TableOfContentsProps {

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

useEffect(() => {
setPortalRoot(document.body);
}, []);

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

const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
Expand All @@ -38,35 +48,23 @@ export default function TableOfContents({ items }: TableOfContentsProps) {
return () => observer.disconnect();
}, [items]);

const handleClick = (id: string) => {
const element = document.getElementById(id);
if (element) {
const yOffset = -100;
const y =
element.getBoundingClientRect().top + window.pageYOffset + yOffset;
window.scrollTo({ top: y, behavior: 'smooth' });

// Update URL hash without jumping and without adding to history
window.history.replaceState(null, '', `#${id}`);
}
};

if (items.length === 0) return null;
if (items.length === 0 || !portalRoot) return null;

return (
return createPortal(
<nav
className="fixed right-8 top-20 bottom-8 hidden w-64 xl:block"
className="fixed right-8 top-20 hidden w-64 xl:block"
aria-label="이 글의 목차"
>
<div className="h-full overflow-y-auto rounded-[var(--radius-md)] bg-[var(--color-grey-50)] p-4">
<div className="rounded-[var(--radius-md)] bg-[var(--color-grey-50)] p-4">
<h2 className="text-sm font-semibold text-[var(--color-grey-900)] mb-4 sticky top-0 bg-[var(--color-grey-50)] pb-2">
이 글의 목차
</h2>
<ul className="flex flex-col gap-1">
<ul className="m-0 flex list-none flex-col gap-1 p-0">
{items.map((item) => (
<li key={item.id}>
<button
onClick={() => handleClick(item.id)}
<a
href={`#${item.id}`}
aria-current={activeId === item.id ? 'location' : undefined}
className={clsx(
'block w-full text-left text-sm py-1.5 px-3 rounded-[6px]',
'transition-colors duration-[var(--duration-150)]',
Expand All @@ -78,12 +76,13 @@ export default function TableOfContents({ items }: TableOfContentsProps) {
style={{ fontFamily: 'var(--font-sans-emoji)' }}
>
{item.text}
</button>
</a>
</li>
))}
</ul>
</div>
</nav>
</nav>,
portalRoot
);
}

Expand Down
37 changes: 37 additions & 0 deletions blog/ui/mdx/components.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { getMDXComponents } from './components';

describe('getMDXComponents', () => {
it('assigns normalized heading ids from rendered text', () => {
const { h3: H3, h4: H4 } = getMDXComponents({});

if (!H3 || !H4) {
throw new Error('Expected heading components to be defined');
}

render(
<>
<H3>
<strong>
🚀 성능 증명 <code>k6</code>
</strong>
</H3>
<H3>🚀 성능 증명 k6</H3>
<H4>하위 섹션</H4>
</>
);

const headings = screen.getAllByRole('heading', {
name: '🚀 성능 증명 k6',
});

expect(headings).toHaveLength(2);
expect(headings[0]).toHaveAttribute('id', '성능-증명-k6');
expect(headings[1]).toHaveAttribute('id', '성능-증명-k6-1');
expect(screen.getByRole('heading', { name: '하위 섹션' })).toHaveAttribute(
'id',
'하위-섹션'
);
});
});
Loading
Loading