From 064924316bd07c70159bc234727f993b97ab74fb Mon Sep 17 00:00:00 2001 From: Mauricio Siu <47042324+Siumauricio@users.noreply.github.com> Date: Sun, 2 Mar 2025 19:18:43 -0600 Subject: [PATCH] feat: add table of contents and improve blog post page layout --- .../[slug]/components/TableOfContents.tsx | 76 ++++++ .../website/app/[locale]/blog/[slug]/page.tsx | 219 +++++++++++------- apps/website/package.json | 15 +- pnpm-lock.yaml | 168 +++----------- 4 files changed, 252 insertions(+), 226 deletions(-) create mode 100644 apps/website/app/[locale]/blog/[slug]/components/TableOfContents.tsx diff --git a/apps/website/app/[locale]/blog/[slug]/components/TableOfContents.tsx b/apps/website/app/[locale]/blog/[slug]/components/TableOfContents.tsx new file mode 100644 index 0000000..69631db --- /dev/null +++ b/apps/website/app/[locale]/blog/[slug]/components/TableOfContents.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { useEffect, useState } from "react"; +interface Heading { + id: string; + text: string; + level: number; +} + +export function TableOfContents() { + const [headings, setHeadings] = useState([]); + const [activeId, setActiveId] = useState(); + + useEffect(() => { + const elements = Array.from(document.querySelectorAll("h1, h2, h3")) + .filter((element) => element.id) + .map((element) => ({ + id: element.id, + text: element.textContent || "", + level: Number(element.tagName.charAt(1)), + })); + + setHeadings(elements); + + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (entry.isIntersecting) { + setActiveId(entry.target.id); + } + } + }, + { rootMargin: "-100px 0px -66%" }, + ); + + for (const { id } of elements) { + const element = document.getElementById(id); + if (element) { + observer.observe(element); + } + } + + return () => observer.disconnect(); + }, []); + + return ( + + ); +} diff --git a/apps/website/app/[locale]/blog/[slug]/page.tsx b/apps/website/app/[locale]/blog/[slug]/page.tsx index f804943..5f36113 100644 --- a/apps/website/app/[locale]/blog/[slug]/page.tsx +++ b/apps/website/app/[locale]/blog/[slug]/page.tsx @@ -12,11 +12,14 @@ import ReactMarkdown from "react-markdown"; import type { Components } from "react-markdown"; import rehypeRaw from "rehype-raw"; import remarkGfm from "remark-gfm"; +import remarkToc from "remark-toc"; import { codeToHtml } from "shiki"; import type { BundledLanguage } from "shiki/bundle/web"; +import slugify from "slugify"; import TurndownService from "turndown"; // @ts-ignore import * as turndownPluginGfm from "turndown-plugin-gfm"; +import { TableOfContents } from "./components/TableOfContents"; import { ZoomableImage } from "./components/ZoomableImage"; type Props = { @@ -158,7 +161,7 @@ export default async function BlogPostPage({ params }: Props) { const gfm = turndownPluginGfm.gfm; const tables = turndownPluginGfm.tables; const strikethrough = turndownPluginGfm.strikethrough; - turndownService.use([tables, strikethrough, gfm]); + turndownService.use([tables, strikethrough, gfm, remarkToc]); const markdown = turndownService.turndown(post.html); @@ -169,18 +172,45 @@ export default async function BlogPostPage({ params }: Props) { }); const components: Partial = { - h1: ({ node, ...props }) => ( -

- ), - h2: ({ node, ...props }) => ( -

- ), - h3: ({ node, ...props }) => ( -

- ), + h1: ({ node, ...props }) => { + const id = slugify(props.children?.toString() || "", { + lower: true, + strict: true, + }); + return ( +

+ ); + }, + h2: ({ node, ...props }) => { + const id = slugify(props.children?.toString() || "", { + lower: true, + strict: true, + }); + return ( +

+ ); + }, + h3: ({ node, ...props }) => { + const id = slugify(props.children?.toString() || "", { + lower: true, + strict: true, + }); + return ( +

+ ); + }, p: ({ node, children, ...props }) => { return (

+

-
-
-

- {post.title} -

-
- {post.primary_author?.profile_image && ( -
- {post.primary_author.twitter ? ( - +
+
+
+

+ {post.title} +

+
+ {post.primary_author?.profile_image && ( +
+ {post.primary_author.twitter ? ( + + {post.primary_author.name} + + ) : ( {post.primary_author.name} - - ) : ( - {post.primary_author.name} - )} + )} +
+ )} +
+

+ {post.primary_author?.twitter ? ( + + {post.primary_author.name || "Unknown Author"} + + ) : ( + post.primary_author?.name || "Unknown Author" + )} +

+

+ {formattedDate} • {post.reading_time} min read +

+
+
+ {post.feature_image && ( +
+
)} -
-

- {post.primary_author?.twitter ? ( - - {post.primary_author.name || "Unknown Author"} - - ) : ( - post.primary_author?.name || "Unknown Author" - )} -

-

- {formattedDate} • {post.reading_time} min read -

-
+
+ +
+ + {markdown} +
- {post.feature_image && ( -
- + + {post.tags && post.tags.length > 0 && ( +
+

{t("tags")}

+
+ {post.tags.map((tag) => ( + + {tag.name} + + ))} +
)} -
- -
- - {markdown} -
- {post.tags && post.tags.length > 0 && ( -
-

{t("tags")}

-
- {post.tags.map((tag) => ( - - {tag.name} - - ))} -
+
+
+
- )} +
{relatedPosts.length > 0 && ( diff --git a/apps/website/package.json b/apps/website/package.json index fcb3829..6b8a8c4 100644 --- a/apps/website/package.json +++ b/apps/website/package.json @@ -37,20 +37,21 @@ "react": "18.2.0", "react-dom": "18.2.0", "react-ga4": "^2.1.0", + "react-markdown": "^10.0.0", "react-photo-view": "^1.2.7", + "rehype-raw": "^7.0.0", + "remark-gfm": "^4.0.1", + "remark-toc": "^9.0.0", + "satori": "^0.12.1", + "sharp": "^0.33.5", "shiki": "1.22.2", + "slugify": "^1.6.6", "tailwind-merge": "^2.2.2", "tailwindcss": "^3.4.1", "tailwindcss-animate": "^1.0.7", "turndown": "^7.2.0", "turndown-plugin-gfm": "^1.0.2", - "typescript": "5.1.6", - "react-markdown": "^10.0.0", - "rehype-raw": "^7.0.0", - "remark-gfm": "^4.0.1", - "@resvg/resvg-js": "^2.6.2", - "satori": "^0.12.1", - "sharp": "^0.33.5" + "typescript": "5.1.6" }, "devDependencies": { "@babel/core": "^7.26.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d0cd9c4..34e6ce8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -121,9 +121,6 @@ importers: '@radix-ui/react-tooltip': specifier: ^1.1.3 version: 1.1.3(@types/react-dom@18.3.0)(@types/react@18.3.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@resvg/resvg-js': - specifier: ^2.6.2 - version: 2.6.2 '@tabler/icons-react': specifier: 3.21.0 version: 3.21.0(react@18.2.0) @@ -181,6 +178,9 @@ importers: remark-gfm: specifier: ^4.0.1 version: 4.0.1 + remark-toc: + specifier: ^9.0.0 + version: 9.0.0 satori: specifier: ^0.12.1 version: 0.12.1 @@ -190,6 +190,9 @@ importers: shiki: specifier: 1.22.2 version: 1.22.2 + slugify: + specifier: ^1.6.6 + version: 1.6.6 tailwind-merge: specifier: ^2.2.2 version: 2.4.0 @@ -1620,82 +1623,6 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 - '@resvg/resvg-js-android-arm-eabi@2.6.2': - resolution: {integrity: sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==} - engines: {node: '>= 10'} - cpu: [arm] - os: [android] - - '@resvg/resvg-js-android-arm64@2.6.2': - resolution: {integrity: sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [android] - - '@resvg/resvg-js-darwin-arm64@2.6.2': - resolution: {integrity: sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [darwin] - - '@resvg/resvg-js-darwin-x64@2.6.2': - resolution: {integrity: sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==} - engines: {node: '>= 10'} - cpu: [x64] - os: [darwin] - - '@resvg/resvg-js-linux-arm-gnueabihf@2.6.2': - resolution: {integrity: sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==} - engines: {node: '>= 10'} - cpu: [arm] - os: [linux] - - '@resvg/resvg-js-linux-arm64-gnu@2.6.2': - resolution: {integrity: sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - - '@resvg/resvg-js-linux-arm64-musl@2.6.2': - resolution: {integrity: sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - - '@resvg/resvg-js-linux-x64-gnu@2.6.2': - resolution: {integrity: sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - - '@resvg/resvg-js-linux-x64-musl@2.6.2': - resolution: {integrity: sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - - '@resvg/resvg-js-win32-arm64-msvc@2.6.2': - resolution: {integrity: sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [win32] - - '@resvg/resvg-js-win32-ia32-msvc@2.6.2': - resolution: {integrity: sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==} - engines: {node: '>= 10'} - cpu: [ia32] - os: [win32] - - '@resvg/resvg-js-win32-x64-msvc@2.6.2': - resolution: {integrity: sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ==} - engines: {node: '>= 10'} - cpu: [x64] - os: [win32] - - '@resvg/resvg-js@2.6.2': - resolution: {integrity: sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==} - engines: {node: '>= 10'} - '@shikijs/core@1.22.2': resolution: {integrity: sha512-bvIQcd8BEeR1yFvOYv6HDiyta2FFVePbzeowf5pPS1avczrPK+cjmaxxh0nx5QzbON7+Sv0sQfQVciO7bN72sg==} @@ -1804,6 +1731,9 @@ packages: '@types/turndown@5.0.5': resolution: {integrity: sha512-TL2IgGgc7B5j78rIccBtlYAnkuv8nUQqhQc+DSYV5j9Be9XOcm/SKOVRuA47xAVI3680Tk9B1d8flK2GWT2+4w==} + '@types/ungap__structured-clone@1.2.0': + resolution: {integrity: sha512-ZoaihZNLeZSxESbk9PUAPZOlSpcKx81I1+4emtULDVmBLkYutTcMlCj2K9VNlf9EWODxdO6gkAqEaLorXwZQVA==} + '@types/unist@2.0.10': resolution: {integrity: sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA==} @@ -2856,6 +2786,9 @@ packages: mdast-util-to-string@4.0.0: resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + mdast-util-toc@7.1.0: + resolution: {integrity: sha512-2TVKotOQzqdY7THOdn2gGzS9d1Sdd66bvxUyw3aNpWfcPXCLYSJCCgfPy30sEtuzkDraJgqF35dzgmz6xlvH/w==} + meow@12.1.1: resolution: {integrity: sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==} engines: {node: '>=16.10'} @@ -3448,6 +3381,9 @@ packages: remark-stringify@11.0.0: resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + remark-toc@9.0.0: + resolution: {integrity: sha512-KJ9txbo33GjDAV1baHFze7ij4G8c7SGYoY8Kzsm2gzFpbhL/bSoVpMMzGa3vrNDSWASNd/3ppAqL7cP2zD6JIA==} + remark@15.0.1: resolution: {integrity: sha512-Eht5w30ruCXgFmxVUSlNWQ9iiimq07URKeFS3hNc8cUWy1llX4KDWfyEDZRycMc+znsN9Ux5/tJ/BFdgdOwA3A==} @@ -3541,6 +3477,10 @@ packages: resolution: {integrity: sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==} engines: {node: '>=18'} + slugify@1.6.6: + resolution: {integrity: sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==} + engines: {node: '>=8.0.0'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -5401,57 +5341,6 @@ snapshots: dependencies: react: 18.2.0 - '@resvg/resvg-js-android-arm-eabi@2.6.2': - optional: true - - '@resvg/resvg-js-android-arm64@2.6.2': - optional: true - - '@resvg/resvg-js-darwin-arm64@2.6.2': - optional: true - - '@resvg/resvg-js-darwin-x64@2.6.2': - optional: true - - '@resvg/resvg-js-linux-arm-gnueabihf@2.6.2': - optional: true - - '@resvg/resvg-js-linux-arm64-gnu@2.6.2': - optional: true - - '@resvg/resvg-js-linux-arm64-musl@2.6.2': - optional: true - - '@resvg/resvg-js-linux-x64-gnu@2.6.2': - optional: true - - '@resvg/resvg-js-linux-x64-musl@2.6.2': - optional: true - - '@resvg/resvg-js-win32-arm64-msvc@2.6.2': - optional: true - - '@resvg/resvg-js-win32-ia32-msvc@2.6.2': - optional: true - - '@resvg/resvg-js-win32-x64-msvc@2.6.2': - optional: true - - '@resvg/resvg-js@2.6.2': - optionalDependencies: - '@resvg/resvg-js-android-arm-eabi': 2.6.2 - '@resvg/resvg-js-android-arm64': 2.6.2 - '@resvg/resvg-js-darwin-arm64': 2.6.2 - '@resvg/resvg-js-darwin-x64': 2.6.2 - '@resvg/resvg-js-linux-arm-gnueabihf': 2.6.2 - '@resvg/resvg-js-linux-arm64-gnu': 2.6.2 - '@resvg/resvg-js-linux-arm64-musl': 2.6.2 - '@resvg/resvg-js-linux-x64-gnu': 2.6.2 - '@resvg/resvg-js-linux-x64-musl': 2.6.2 - '@resvg/resvg-js-win32-arm64-msvc': 2.6.2 - '@resvg/resvg-js-win32-ia32-msvc': 2.6.2 - '@resvg/resvg-js-win32-x64-msvc': 2.6.2 - '@shikijs/core@1.22.2': dependencies: '@shikijs/engine-javascript': 1.22.2 @@ -5587,6 +5476,8 @@ snapshots: '@types/turndown@5.0.5': {} + '@types/ungap__structured-clone@1.2.0': {} + '@types/unist@2.0.10': {} '@types/unist@3.0.2': {} @@ -6854,6 +6745,16 @@ snapshots: dependencies: '@types/mdast': 4.0.4 + mdast-util-toc@7.1.0: + dependencies: + '@types/mdast': 4.0.4 + '@types/ungap__structured-clone': 1.2.0 + '@ungap/structured-clone': 1.2.0 + github-slugger: 2.0.0 + mdast-util-to-string: 4.0.0 + unist-util-is: 6.0.0 + unist-util-visit: 5.0.0 + meow@12.1.1: {} merge-stream@2.0.0: {} @@ -7614,6 +7515,11 @@ snapshots: mdast-util-to-markdown: 2.1.0 unified: 11.0.5 + remark-toc@9.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-toc: 7.1.0 + remark@15.0.1: dependencies: '@types/mdast': 4.0.4 @@ -7740,6 +7646,8 @@ snapshots: ansi-styles: 6.2.1 is-fullwidth-code-point: 5.0.0 + slugify@1.6.6: {} + source-map-js@1.2.1: {} source-map@0.7.4: {}