Skip to content

Commit b94cdab

Browse files
committed
feat: Add article content display with dynamic Table of Contents, code block copy functionality, and a new post page route.
1 parent b90e65c commit b94cdab

5 files changed

Lines changed: 681 additions & 161 deletions

File tree

app/posts/[title]/page.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import {
2525
RiPatreonFill,
2626
} from "react-icons/ri";
2727

28-
import { marked } from "marked";
28+
import { processMarkdown } from "@/lib/markdown";
2929

3030
interface Article {
3131
id: string;
@@ -220,6 +220,8 @@ export default async function ArticleDetails({
220220
publish: false,
221221
};
222222

223+
const processedHtml = await processMarkdown(articleData.content || "");
224+
223225
// Get latest articles (excluding current)
224226
const latestSnapshot = await getDocs(
225227
query(
@@ -324,7 +326,7 @@ export default async function ArticleDetails({
324326
</div>
325327

326328
<div className="w-full">
327-
<ArticleContent content={processedArticle.content} />
329+
<ArticleContent htmlContent={processedHtml} />
328330
</div>
329331

330332
<div className="pt-10 pb-20">

components/ArticleContent.tsx

Lines changed: 27 additions & 152 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
"use client";
22

33
import { useEffect, useMemo, useRef, useState } from "react";
4-
import { marked } from "marked";
5-
import DOMPurify from "dompurify";
64

75
interface ArticleContentProps {
8-
content: string;
6+
htmlContent: string;
97
}
108

119
type TocItem = {
@@ -15,58 +13,6 @@ type TocItem = {
1513
children: TocItem[];
1614
};
1715

18-
function getScrollParent(el: HTMLElement): HTMLElement {
19-
// Prefer the document scroller when appropriate.
20-
const docScroller =
21-
(document.scrollingElement as HTMLElement | null) ??
22-
(document.documentElement as HTMLElement | null) ??
23-
(document.body as HTMLElement | null);
24-
25-
let parent = el.parentElement;
26-
while (
27-
parent &&
28-
parent !== document.body &&
29-
parent !== document.documentElement
30-
) {
31-
const style = window.getComputedStyle(parent);
32-
const overflowY = style.overflowY;
33-
const isScrollable =
34-
(overflowY === "auto" ||
35-
overflowY === "scroll" ||
36-
overflowY === "overlay") &&
37-
parent.scrollHeight > parent.clientHeight + 1;
38-
if (isScrollable) return parent;
39-
parent = parent.parentElement;
40-
}
41-
42-
return docScroller ?? document.documentElement;
43-
}
44-
45-
function isDocumentScroller(el: HTMLElement) {
46-
const docScroller =
47-
(document.scrollingElement as HTMLElement | null) ??
48-
(document.documentElement as HTMLElement | null) ??
49-
(document.body as HTMLElement | null);
50-
return (
51-
el === docScroller ||
52-
el === document.documentElement ||
53-
el === document.body
54-
);
55-
}
56-
57-
function slugifyHeading(text: string) {
58-
return text
59-
.toString()
60-
.toLowerCase()
61-
.trim()
62-
.replace(/\s+/g, "-") // Replace spaces with -
63-
.replace(/&/g, "-and-") // Replace & with 'and'
64-
.replace(/[^\w\-]+/g, "") // Remove all non-word characters
65-
.replace(/\-\-+/g, "-") // Replace multiple - with single -
66-
.replace(/^-+/, "") // Trim - from start of text
67-
.replace(/-+$/, ""); // Trim - from end of text
68-
}
69-
7016
function buildTocTree(flatItems: Array<Omit<TocItem, "children">>): TocItem[] {
7117
const root: TocItem[] = [];
7218
const stack: TocItem[] = [];
@@ -90,8 +36,7 @@ function buildTocTree(flatItems: Array<Omit<TocItem, "children">>): TocItem[] {
9036
return root;
9137
}
9238

93-
const ArticleContent: React.FC<ArticleContentProps> = ({ content }) => {
94-
const [sanitizedContent, setSanitizedContent] = useState<string>("");
39+
const ArticleContent: React.FC<ArticleContentProps> = ({ htmlContent }) => {
9540
const containerRef = useRef<HTMLDivElement | null>(null);
9641
const [tocTree, setTocTree] = useState<TocItem[]>([]);
9742
const [collapsedIds, setCollapsedIds] = useState<Set<string>>(new Set());
@@ -127,82 +72,6 @@ const ArticleContent: React.FC<ArticleContentProps> = ({ content }) => {
12772
const checkSvg =
12873
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5"></path></svg>';
12974

130-
useEffect(() => {
131-
async function sanitizeContent() {
132-
// Use a custom renderer to add IDs to headings and wrap code blocks
133-
const renderer = new marked.Renderer();
134-
const seenIds = new Map<string, number>();
135-
136-
renderer.heading = function({ tokens, depth }) {
137-
const text = this.parser.parseInline(tokens);
138-
const title = text.toString();
139-
// Strip HTML tags for cleaner ID generation (securely)
140-
const cleanTitle = DOMPurify.sanitize(title, { ALLOWED_TAGS: [] });
141-
const baseId = slugifyHeading(cleanTitle) || "section";
142-
const nextCount = (seenIds.get(baseId) ?? 0) + 1;
143-
seenIds.set(baseId, nextCount);
144-
const id = nextCount === 1 ? baseId : `${baseId}-${nextCount}`;
145-
146-
return `<h${depth} id="${id}">${text}</h${depth}>`;
147-
};
148-
149-
renderer.code = ({ text, lang }) => {
150-
const languageClass = lang ? `language-${lang}` : "";
151-
// Encode text for the data attribute to be safe
152-
const safeCode = text.replace(/"/g, '&quot;');
153-
154-
return `
155-
<div class="relative group my-4" data-copy-wrapper="true">
156-
<button
157-
type="button"
158-
class="copy-btn absolute top-2 right-2 z-10 h-8 w-8 flex items-center justify-center rounded-md border border-white/20 bg-[#121212] text-white hover:bg-[#202020] transition-all"
159-
aria-label="Copy code to clipboard"
160-
title="Copy"
161-
data-code="${safeCode}"
162-
>
163-
${copySvg}
164-
</button>
165-
<pre><code class="${languageClass}">${text}</code></pre>
166-
</div>
167-
`;
168-
};
169-
170-
renderer.link = ({ href, title, text }) => {
171-
return `<a href="${href}" title="${title || ''}" target="_blank" rel="noopener noreferrer">${text}</a>`;
172-
};
173-
174-
marked.use({ renderer });
175-
176-
// Convert markdown to HTML
177-
const rawContent = await marked.parse(content);
178-
179-
// Configure DOMPurify to allow iframes and buttons
180-
const cleanContent = DOMPurify.sanitize(rawContent, {
181-
ADD_TAGS: ["iframe", "button"],
182-
ADD_ATTR: [
183-
"allow",
184-
"allowfullscreen",
185-
"frameborder",
186-
"height",
187-
"scrolling",
188-
"src",
189-
"width",
190-
"id",
191-
"class", // checking class
192-
"data-code", // Custom attribute for code content
193-
"aria-label",
194-
"title",
195-
"type",
196-
"target",
197-
"rel"
198-
],
199-
});
200-
201-
setSanitizedContent(cleanContent);
202-
}
203-
sanitizeContent();
204-
}, [content]);
205-
20675
useEffect(() => {
20776
if (typeof window === "undefined") return;
20877

@@ -238,15 +107,15 @@ const ArticleContent: React.FC<ArticleContentProps> = ({ content }) => {
238107

239108
const level = Number(heading.tagName.replace("H", ""));
240109
const id = heading.id;
241-
110+
242111
if (id) {
243-
headingMapRef.current.set(id, heading);
244-
flat.push({ id, title, level });
112+
headingMapRef.current.set(id, heading);
113+
flat.push({ id, title, level });
245114
}
246115
}
247116

248117
setTocTree(buildTocTree(flat));
249-
}, [sanitizedContent]);
118+
}, [htmlContent]);
250119

251120
useEffect(() => {
252121
// If the page was loaded with a hash, scroll to it after headings have ids.
@@ -261,10 +130,10 @@ const ArticleContent: React.FC<ArticleContentProps> = ({ content }) => {
261130
window.requestAnimationFrame(() => {
262131
const el = document.getElementById(id);
263132
if (el) {
264-
const headerOffset = 120;
265-
const elementPosition = el.getBoundingClientRect().top + window.scrollY;
266-
const offsetPosition = elementPosition - headerOffset;
267-
window.scrollTo({ top: offsetPosition, behavior: "smooth" });
133+
const headerOffset = 120;
134+
const elementPosition = el.getBoundingClientRect().top + window.scrollY;
135+
const offsetPosition = elementPosition - headerOffset;
136+
window.scrollTo({ top: offsetPosition, behavior: "smooth" });
268137
}
269138
});
270139
}, [tocTree]);
@@ -277,23 +146,26 @@ const ArticleContent: React.FC<ArticleContentProps> = ({ content }) => {
277146
const handleCopyClick = async (e: MouseEvent) => {
278147
const target = e.target as HTMLElement;
279148
const button = target.closest(".copy-btn") as HTMLButtonElement;
280-
149+
281150
if (!button || !root.contains(button)) return;
282151

283152
e.preventDefault();
284153
e.stopPropagation();
285154

286155
const code = button.getAttribute("data-code");
287156
// Fallback to finding the code block if attribute is empty/missing
288-
const codeText = code || button.nextElementSibling?.querySelector("code")?.textContent || "";
157+
const codeText =
158+
code ||
159+
button.nextElementSibling?.querySelector("code")?.textContent ||
160+
"";
289161

290162
if (!codeText) return;
291163

292164
try {
293165
await copyTextToClipboard(codeText);
294166
button.innerHTML = checkSvg;
295167
button.setAttribute("title", "Copied");
296-
168+
297169
setTimeout(() => {
298170
button.innerHTML = copySvg;
299171
button.setAttribute("title", "Copy");
@@ -308,7 +180,7 @@ const ArticleContent: React.FC<ArticleContentProps> = ({ content }) => {
308180
return () => {
309181
root.removeEventListener("click", handleCopyClick);
310182
};
311-
}, [sanitizedContent]);
183+
}, [htmlContent]);
312184

313185
const handleTocToggle = (id: string) => {
314186
setCollapsedIds((prev) => {
@@ -332,15 +204,14 @@ const ArticleContent: React.FC<ArticleContentProps> = ({ content }) => {
332204

333205
return (
334206
<article className="grid md:grid-cols-1 lg:grid-cols-[minmax(0,1fr)_340px] xl:grid-cols-[minmax(0,1fr)_380px] gap-0 md:gap-8 lg:gap-12 w-full max-w-[90rem] mx-auto mt-6 md:mt-24 mb-10 px-4">
335-
336-
<MobileToc
337-
items={tocTree}
338-
collapsedIds={collapsedIds}
339-
onToggle={handleTocToggle}
207+
<MobileToc
208+
items={tocTree}
209+
collapsedIds={collapsedIds}
210+
onToggle={handleTocToggle}
340211
/>
341212

342213
<div className="markdown-body w-full min-w-0" ref={containerRef}>
343-
<div dangerouslySetInnerHTML={{ __html: sanitizedContent }} />
214+
<div dangerouslySetInnerHTML={{ __html: htmlContent }} />
344215
</div>
345216

346217
{tocTree.length > 0 ? (
@@ -388,7 +259,11 @@ function MobileToc({
388259

389260
{isOpen && (
390261
<nav className="mt-4 max-h-96 overflow-auto">
391-
<TocList items={items} collapsedIds={collapsedIds} onToggle={onToggle} />
262+
<TocList
263+
items={items}
264+
collapsedIds={collapsedIds}
265+
onToggle={onToggle}
266+
/>
392267
</nav>
393268
)}
394269
</div>

lib/markdown.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { marked } from "marked";
2+
import DOMPurify from "isomorphic-dompurify";
3+
4+
function slugifyHeading(text: string) {
5+
return text
6+
.toString()
7+
.toLowerCase()
8+
.trim()
9+
.replace(/\s+/g, "-") // Replace spaces with -
10+
.replace(/&/g, "-and-") // Replace & with 'and'
11+
.replace(/[^\w\-]+/g, "") // Remove all non-word characters
12+
.replace(/\-\-+/g, "-") // Replace multiple - with single -
13+
.replace(/^-+/, "") // Trim - from start of text
14+
.replace(/-+$/, ""); // Trim - from end of text
15+
}
16+
17+
export async function processMarkdown(content: string): Promise<string> {
18+
const renderer = new marked.Renderer();
19+
const seenIds = new Map<string, number>();
20+
21+
// Icon for copy button
22+
const copySvg =
23+
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>';
24+
25+
renderer.heading = function ({ tokens, depth }) {
26+
const text = this.parser.parseInline(tokens);
27+
const title = text.toString();
28+
// Strip HTML tags for clean ID generation
29+
const cleanTitle = DOMPurify.sanitize(title, { ALLOWED_TAGS: [] });
30+
const baseId = slugifyHeading(cleanTitle) || "section";
31+
const nextCount = (seenIds.get(baseId) ?? 0) + 1;
32+
seenIds.set(baseId, nextCount);
33+
const id = nextCount === 1 ? baseId : `${baseId}-${nextCount}`;
34+
35+
return `<h${depth} id="${id}">${text}</h${depth}>`;
36+
};
37+
38+
renderer.code = ({ text, lang }) => {
39+
const languageClass = lang ? `language-${lang}` : "";
40+
// Encode text for data attribute
41+
const safeCode = text.replace(/"/g, "&quot;");
42+
43+
return `
44+
<div class="relative group my-4" data-copy-wrapper="true">
45+
<button
46+
type="button"
47+
class="copy-btn absolute top-2 right-2 z-10 h-8 w-8 flex items-center justify-center rounded-md border border-white/20 bg-[#121212] text-white hover:bg-[#202020] transition-all"
48+
aria-label="Copy code to clipboard"
49+
title="Copy"
50+
data-code="${safeCode}"
51+
>
52+
${copySvg}
53+
</button>
54+
<pre><code class="${languageClass}">${text}</code></pre>
55+
</div>
56+
`;
57+
};
58+
59+
renderer.link = ({ href, title, text }) => {
60+
return `<a href="${href}" title="${
61+
title || ""
62+
}" target="_blank" rel="noopener noreferrer">${text}</a>`;
63+
};
64+
65+
const rawContent = await marked.parse(content, { renderer });
66+
67+
const cleanContent = DOMPurify.sanitize(rawContent, {
68+
ADD_TAGS: ["iframe", "button"],
69+
ADD_ATTR: [
70+
"allow",
71+
"allowfullscreen",
72+
"frameborder",
73+
"height",
74+
"scrolling",
75+
"src",
76+
"width",
77+
"id",
78+
"class",
79+
"data-code",
80+
"aria-label",
81+
"title",
82+
"type",
83+
"target",
84+
"rel",
85+
],
86+
});
87+
88+
return cleanContent;
89+
}

0 commit comments

Comments
 (0)