diff --git a/app/about-us/page.tsx b/app/about-us/page.tsx index 50666992..438e0161 100644 --- a/app/about-us/page.tsx +++ b/app/about-us/page.tsx @@ -1,7 +1,7 @@ import fs from "fs/promises" import path from "path" import matter from "gray-matter" -import { MarkdownRenderer } from "@/components/markdown-renderer" +import { ContentWithToc } from "@/components/content-with-toc" import { PageHero } from "@/components/page-hero" const FILE_PATH = path.join(process.cwd(), "contents", "pages", "about-us.md") @@ -19,7 +19,7 @@ export default async function AboutUsPage() {
- +
diff --git a/app/achievements/page.tsx b/app/achievements/page.tsx index b784f90e..0c39a902 100644 --- a/app/achievements/page.tsx +++ b/app/achievements/page.tsx @@ -1,7 +1,7 @@ import fs from "fs/promises" import path from "path" import matter from "gray-matter" -import { MarkdownRenderer } from "@/components/markdown-renderer" +import { ContentWithToc } from "@/components/content-with-toc" import { PageHero } from "@/components/page-hero" const FILE_PATH = path.join(process.cwd(), "contents", "pages", "achievements.md") @@ -18,8 +18,8 @@ export default async function AchievementsPage() {
-
- +
+
diff --git a/app/codeofconduct/page.tsx b/app/codeofconduct/page.tsx index 01edd312..6cc4fa98 100644 --- a/app/codeofconduct/page.tsx +++ b/app/codeofconduct/page.tsx @@ -1,7 +1,7 @@ import fs from "fs/promises" import path from "path" import matter from "gray-matter" -import { MarkdownRenderer } from "@/components/markdown-renderer" +import { ContentWithToc } from "@/components/content-with-toc" import { PageHero } from "@/components/page-hero" const FILE_PATH = path.join(process.cwd(), "contents", "pages", "codeofconduct.md") @@ -22,7 +22,7 @@ export default async function CodeOfConductPage() {
- +
diff --git a/app/contact/page.tsx b/app/contact/page.tsx index 2a8bbea8..edcbd418 100644 --- a/app/contact/page.tsx +++ b/app/contact/page.tsx @@ -1,7 +1,7 @@ import fs from "fs/promises" import path from "path" import matter from "gray-matter" -import { MarkdownRenderer } from "@/components/markdown-renderer" +import { ContentWithToc } from "@/components/content-with-toc" import { PageHero } from "@/components/page-hero" const FILE_PATH = path.join( @@ -27,7 +27,7 @@ export default async function ContactPage() {
- +
diff --git a/app/contribute-website/page.tsx b/app/contribute-website/page.tsx index 58c33591..8d81d4fb 100644 --- a/app/contribute-website/page.tsx +++ b/app/contribute-website/page.tsx @@ -1,7 +1,7 @@ import fs from "fs/promises" import path from "path" import matter from "gray-matter" -import { MarkdownRenderer } from "@/components/markdown-renderer" +import { ContentWithToc } from "@/components/content-with-toc" import { PageHero } from "@/components/page-hero" const FILE_PATH = path.join(process.cwd(), "contents", "pages", "contribute-website.md") @@ -18,7 +18,7 @@ export default async function ContributeWebsitePage() {
- +
diff --git a/app/contribute/page.tsx b/app/contribute/page.tsx index 5a33e76a..ee2467a9 100644 --- a/app/contribute/page.tsx +++ b/app/contribute/page.tsx @@ -1,7 +1,7 @@ import fs from "fs/promises" import path from "path" import matter from "gray-matter" -import { MarkdownRenderer } from "@/components/markdown-renderer" +import { ContentWithToc } from "@/components/content-with-toc" import { PageHero } from "@/components/page-hero" const FILE_PATH = path.join( @@ -27,7 +27,7 @@ export default async function ContributePage() {
- +
diff --git a/app/current/page.tsx b/app/current/page.tsx index df15ce1f..9e09c9f0 100644 --- a/app/current/page.tsx +++ b/app/current/page.tsx @@ -1,7 +1,7 @@ import fs from "fs/promises" import path from "path" import matter from "gray-matter" -import { MarkdownRenderer } from "@/components/markdown-renderer" +import { ContentWithToc } from "@/components/content-with-toc" import { PageHero } from "@/components/page-hero" const FILE_PATH = path.join(process.cwd(), "contents", "pages", "current.md") @@ -18,8 +18,8 @@ export default async function CurrentPage() {
-
- +
+
diff --git a/app/databaseintro/page.tsx b/app/databaseintro/page.tsx index 304ac8fe..6d8ece4e 100644 --- a/app/databaseintro/page.tsx +++ b/app/databaseintro/page.tsx @@ -1,7 +1,7 @@ import fs from "fs/promises" import path from "path" import matter from "gray-matter" -import { MarkdownRenderer } from "@/components/markdown-renderer" +import { ContentWithToc } from "@/components/content-with-toc" import { PageHero } from "@/components/page-hero" const FILE_PATH = path.join(process.cwd(), "contents", "pages", "databaseintro.md") @@ -18,7 +18,7 @@ export default async function DatabaseIntroPage() {
- +
diff --git a/app/documentation/[doc]/page.tsx b/app/documentation/[doc]/page.tsx index 8c7871ea..d5772911 100644 --- a/app/documentation/[doc]/page.tsx +++ b/app/documentation/[doc]/page.tsx @@ -1,7 +1,7 @@ import fs from "fs/promises" import path from "path" import matter from "gray-matter" -import { MarkdownRenderer } from "@/components/markdown-renderer" +import { ContentWithToc } from "@/components/content-with-toc" export const dynamic = "force-static" export const dynamicParams = false @@ -35,14 +35,12 @@ export default async function DocumentationDetail({ return (
-
+

{title}

-
- -
+
) diff --git a/app/documentation/page.tsx b/app/documentation/page.tsx index 5079c268..92378150 100644 --- a/app/documentation/page.tsx +++ b/app/documentation/page.tsx @@ -2,7 +2,7 @@ import fs from "fs/promises" import path from "path" import matter from "gray-matter" import Link from "next/link" -import { MarkdownRenderer } from "@/components/markdown-renderer" +import { ContentWithToc } from "@/components/content-with-toc" const PAGE_MD = path.join(process.cwd(), "contents", "pages", "documentation.md") const DOCS_DIR = path.join(process.cwd(), "contents", "documentation") @@ -41,7 +41,7 @@ export default async function DocumentationPage() { {hasContent && (
- +
)} diff --git a/app/donations/page.tsx b/app/donations/page.tsx index 6cdddefe..855c223b 100644 --- a/app/donations/page.tsx +++ b/app/donations/page.tsx @@ -1,7 +1,7 @@ import fs from "fs/promises" import path from "path" import matter from "gray-matter" -import { MarkdownRenderer } from "@/components/markdown-renderer" +import { ContentWithToc } from "@/components/content-with-toc" import { PageHero } from "@/components/page-hero" const FILE_PATH = path.join( @@ -13,7 +13,7 @@ const FILE_PATH = path.join( export default async function DonationsPage() { const raw = await fs.readFile(FILE_PATH, "utf8") - const { data } = matter(raw) + const { data, content } = matter(raw) const title = typeof data.title === "string" ? data.title : "Donations" @@ -24,7 +24,7 @@ export default async function DonationsPage() {
- +
diff --git a/app/driverless/page.tsx b/app/driverless/page.tsx index e671c324..f6574f4a 100644 --- a/app/driverless/page.tsx +++ b/app/driverless/page.tsx @@ -2,7 +2,7 @@ import fs from "fs/promises" import path from "path" import matter from "gray-matter" import Image from "next/image" -import { MarkdownRenderer } from "@/components/markdown-renderer" +import { ContentWithToc } from "@/components/content-with-toc" import { getAssetPath } from "@/lib/utils"; const FILE_PATH = path.join( @@ -65,7 +65,7 @@ export default async function DriverlessPage() {
- +
diff --git a/app/gsod2020/[slug]/page.tsx b/app/gsod2020/[slug]/page.tsx index f7360c10..d02203d1 100644 --- a/app/gsod2020/[slug]/page.tsx +++ b/app/gsod2020/[slug]/page.tsx @@ -4,7 +4,7 @@ import matter from "gray-matter" import Link from "next/link" import { notFound } from "next/navigation" import { ArrowLeft } from "lucide-react" -import { MarkdownRenderer } from "@/components/markdown-renderer" +import { ContentWithToc } from "@/components/content-with-toc" import { GsodHero } from "@/components/gsod-hero" const IDEAS_DIR = path.join(process.cwd(), "contents", "gsod", "gsod2020") @@ -18,7 +18,7 @@ async function getIdeaBySlug(slug: string) { const title = typeof data.title === "string" ? data.title : slug.replace(/-/g, " ") - return { title, content } + return { title, content, data } } catch { return null } @@ -66,7 +66,7 @@ export default async function GSoD2020IdeaPage({
- +
diff --git a/app/gsod2020/page.tsx b/app/gsod2020/page.tsx index 3ceeeba7..6ffb5814 100644 --- a/app/gsod2020/page.tsx +++ b/app/gsod2020/page.tsx @@ -3,7 +3,7 @@ import path from "path" import matter from "gray-matter" import Link from "next/link" import { ArrowRight, Lightbulb } from "lucide-react" -import { MarkdownRenderer } from "@/components/markdown-renderer" +import { ContentWithToc } from "@/components/content-with-toc" import { GsodHero } from "@/components/gsod-hero" const MAIN_FILE = path.join(process.cwd(), "contents", "pages", "gsod2020.md") @@ -58,7 +58,7 @@ export default async function GSoD2020Page() {
- +
diff --git a/app/history/page.tsx b/app/history/page.tsx index d75c000a..810ed1b3 100644 --- a/app/history/page.tsx +++ b/app/history/page.tsx @@ -1,7 +1,7 @@ import fs from "fs/promises" import path from "path" import matter from "gray-matter" -import { MarkdownRenderer } from "@/components/markdown-renderer" +import { ContentWithToc } from "@/components/content-with-toc" import { PageHero } from "@/components/page-hero" const FILE_PATH = path.join(process.cwd(), "contents", "pages", "history.md") @@ -18,8 +18,8 @@ export default async function HistoryPage() {
-
- +
+
diff --git a/app/lfmp/page.tsx b/app/lfmp/page.tsx index 204b565c..a12e7fbb 100644 --- a/app/lfmp/page.tsx +++ b/app/lfmp/page.tsx @@ -1,7 +1,7 @@ import fs from "fs/promises" import path from "path" import matter from "gray-matter" -import { MarkdownRenderer } from "@/components/markdown-renderer" +import { ContentWithToc } from "@/components/content-with-toc" import { PageHero } from "@/components/page-hero" const FILE_PATH = path.join(process.cwd(), "contents", "pages", "lfmp.md") @@ -18,7 +18,7 @@ export default async function LfmpPage() {
- +
diff --git a/app/lfmp2020/page.tsx b/app/lfmp2020/page.tsx index 9a365058..ac467158 100644 --- a/app/lfmp2020/page.tsx +++ b/app/lfmp2020/page.tsx @@ -1,7 +1,7 @@ import fs from "fs/promises" import path from "path" import matter from "gray-matter" -import { MarkdownRenderer } from "@/components/markdown-renderer" +import { ContentWithToc } from "@/components/content-with-toc" import { PageHero } from "@/components/page-hero" const FILE_PATH = path.join(process.cwd(), "contents", "pages", "lfmp2020.md") @@ -18,7 +18,7 @@ export default async function Lfmp2020Page() {
- +
diff --git a/app/opportunity-open-source/page.tsx b/app/opportunity-open-source/page.tsx index 8b1b14b3..68e8bbcc 100644 --- a/app/opportunity-open-source/page.tsx +++ b/app/opportunity-open-source/page.tsx @@ -2,7 +2,7 @@ import fs from "fs/promises" import path from "path" import matter from "gray-matter" import type { Metadata } from "next" -import { MarkdownRenderer } from "@/components/markdown-renderer" +import { ContentWithToc } from "@/components/content-with-toc" import { PageHero } from "@/components/page-hero" import { getSiteUrl } from "@/lib/site" @@ -29,7 +29,7 @@ export const metadata: Metadata = { export default async function OpportunityOpenSourcePage() { const raw = await fs.readFile(FILE_PATH, "utf8") - const { data } = matter(raw) + const { data, content } = matter(raw) const title = typeof data.title === "string" ? data.title : "Opportunity Open Source" @@ -46,7 +46,7 @@ export default async function OpportunityOpenSourcePage() {
- +
diff --git a/app/pappl-system-utilities/page.tsx b/app/pappl-system-utilities/page.tsx index 8c51ed84..02c5ce20 100644 --- a/app/pappl-system-utilities/page.tsx +++ b/app/pappl-system-utilities/page.tsx @@ -1,7 +1,7 @@ import fs from "fs/promises" import path from "path" import matter from "gray-matter" -import { MarkdownRenderer } from "@/components/markdown-renderer" +import { ContentWithToc } from "@/components/content-with-toc" import { PageHero } from "@/components/page-hero" const FILE_PATH = path.join( @@ -23,7 +23,7 @@ export default async function PapplSystemUtilitiesPage() {
- +
diff --git a/app/projects/[project]/page.tsx b/app/projects/[project]/page.tsx index f63291ba..b66c2e90 100644 --- a/app/projects/[project]/page.tsx +++ b/app/projects/[project]/page.tsx @@ -1,7 +1,7 @@ import fs from "fs/promises" import path from "path" import matter from "gray-matter" -import { MarkdownRenderer } from "@/components/markdown-renderer" +import { ContentWithToc } from "@/components/content-with-toc" export const dynamic = "force-static" export const dynamicParams = false @@ -74,7 +74,7 @@ export default async function ProjectDetail({
- +
diff --git a/app/projects/page.tsx b/app/projects/page.tsx index 5a50f0f5..7d5058fb 100644 --- a/app/projects/page.tsx +++ b/app/projects/page.tsx @@ -2,7 +2,7 @@ import fs from "fs/promises" import path from "path" import matter from "gray-matter" import Link from "next/link" -import { MarkdownRenderer } from "@/components/markdown-renderer" +import { ContentWithToc } from "@/components/content-with-toc" const PAGE_MD = path.join(process.cwd(), "contents", "pages", "projects.md") const PROJECTS_DIR = path.join(process.cwd(), "contents", "projects") @@ -42,7 +42,7 @@ export default async function ProjectsPage() {
- +

Project List

diff --git a/app/sponsors/page.tsx b/app/sponsors/page.tsx index a8f210a4..6f681020 100644 --- a/app/sponsors/page.tsx +++ b/app/sponsors/page.tsx @@ -2,7 +2,7 @@ import fs from "fs/promises" import path from "path" import matter from "gray-matter" import type { Metadata } from "next" -import { MarkdownRenderer } from "@/components/markdown-renderer" +import { ContentWithToc } from "@/components/content-with-toc" import { PageHero } from "@/components/page-hero" import { getSiteUrl } from "@/lib/site" @@ -24,7 +24,7 @@ export const metadata: Metadata = { export default async function SponsorsPage() { const raw = await fs.readFile(FILE_PATH, "utf8") - const { data } = matter(raw) + const { data, content } = matter(raw) const title = typeof data.title === "string" ? data.title : "Sponsors & Supporters" @@ -34,7 +34,7 @@ export default async function SponsorsPage() {
- +
diff --git a/app/upcoming-technologies/[slug]/page.tsx b/app/upcoming-technologies/[slug]/page.tsx index 5f742866..9710f8e0 100644 --- a/app/upcoming-technologies/[slug]/page.tsx +++ b/app/upcoming-technologies/[slug]/page.tsx @@ -2,7 +2,7 @@ import fs from "fs/promises" import path from "path" import matter from "gray-matter" import type { Metadata } from "next" -import { MarkdownRenderer } from "@/components/markdown-renderer" +import { ContentWithToc } from "@/components/content-with-toc" import { getSiteUrl } from "@/lib/site" export const dynamic = "force-static" @@ -75,7 +75,7 @@ export default async function UpcomingTechnologyDetail({

{title}

- +
diff --git a/app/wsl-printer-app-compile/page.tsx b/app/wsl-printer-app-compile/page.tsx index e2229094..41b37ba4 100644 --- a/app/wsl-printer-app-compile/page.tsx +++ b/app/wsl-printer-app-compile/page.tsx @@ -1,7 +1,7 @@ import fs from "fs/promises" import path from "path" import matter from "gray-matter" -import { MarkdownRenderer } from "@/components/markdown-renderer" +import { ContentWithToc } from "@/components/content-with-toc" import AuthorCard from "@/components/AuthorCard" import { PageHero } from "@/components/page-hero" @@ -14,7 +14,7 @@ const FILE_PATH = path.join( export default async function WSLPrinterAppCompilePage() { const raw = await fs.readFile(FILE_PATH, "utf8") - const { data } = matter(raw) + const { data, content } = matter(raw) const title = typeof data.title === "string" ? data.title : "Reviving an older printer with Ubuntu WSL and Printer Applications" @@ -37,7 +37,7 @@ export default async function WSLPrinterAppCompilePage() { )}
- +
diff --git a/app/wsl-printer-app/page.tsx b/app/wsl-printer-app/page.tsx index cf8ceb0b..183ff0a9 100644 --- a/app/wsl-printer-app/page.tsx +++ b/app/wsl-printer-app/page.tsx @@ -1,7 +1,7 @@ import fs from "fs/promises" import path from "path" import matter from "gray-matter" -import { MarkdownRenderer } from "@/components/markdown-renderer" +import { ContentWithToc } from "@/components/content-with-toc" import AuthorCard from "@/components/AuthorCard" import { PageHero } from "@/components/page-hero" @@ -14,7 +14,7 @@ const FILE_PATH = path.join( export default async function WSLPrinterAppPage() { const raw = await fs.readFile(FILE_PATH, "utf8") - const { data } = matter(raw) + const { data, content } = matter(raw) const title = typeof data.title === "string" ? data.title : "Reviving an older printer with Ubuntu WSL and Printer Application Snaps" @@ -38,7 +38,7 @@ export default async function WSLPrinterAppPage() { )}
- +
diff --git a/components/content-with-toc.tsx b/components/content-with-toc.tsx new file mode 100644 index 00000000..73ce8722 --- /dev/null +++ b/components/content-with-toc.tsx @@ -0,0 +1,38 @@ +import { MarkdownRenderer } from "./markdown-renderer" +import { TableOfContents } from "./table-of-contents" + +export function shouldShowToc(data: Record): boolean { + return data.toc === true || String(data.toc) === "true" +} + +interface ContentWithTocProps { + content: string + data: Record + showMeta?: boolean + noCard?: boolean +} + +export function ContentWithToc({ + content, + data, + showMeta = false, + noCard = true, +}: ContentWithTocProps) { + if (!shouldShowToc(data)) { + return + } + + return ( +
+
+ +
+
+ +
+ +
+ ) +} diff --git a/components/table-of-contents.tsx b/components/table-of-contents.tsx index f39e11b4..0b64834c 100644 --- a/components/table-of-contents.tsx +++ b/components/table-of-contents.tsx @@ -2,7 +2,6 @@ import React, { useMemo, MouseEvent } from "react"; import { toString } from "mdast-util-to-string"; -import { visit } from "unist-util-visit"; import GithubSlugger from "github-slugger"; import { unified } from "unified"; import remarkParse from "remark-parse"; @@ -19,23 +18,126 @@ interface TableOfContentsProps { isSticky?: boolean; } +interface MdastNode extends Node { + value?: string; + depth?: number; + children?: MdastNode[]; +} + +// Matches either a complete ... pair or a lone opening/closing tag +const HEADING_PAIR_OR_TAG_REGEX = + /]*)>([\s\S]*?)<\/h\1>|<(\/?)h([1-6])\b([^>]*)>/gi; +const ID_ATTR_REGEX = /\bid\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i; +const TAG_REGEX = /<[^>]+>/g; + +function stripTags(html: string): string { + return html + .replace(TAG_REGEX, "") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'|'/g, "'") + .replace(/\s+/g, " ") + .trim(); +} + +function extractId(attrs: string): string | undefined { + const match = ID_ATTR_REGEX.exec(attrs); + if (!match) return undefined; + const id = (match[1] ?? match[2] ?? match[3] ?? "").trim(); + return id === "" ? undefined : id; +} + export function TableOfContents({ content, isSticky = false }: TableOfContentsProps) { const toc = useMemo(() => { const slugger = new GithubSlugger(); const tocEntries: TocEntry[] = []; - const tree = unified().use(remarkParse).parse(content); - - visit(tree, "heading", (node: Node) => { - const textContent = toString(node); - const heading = node as unknown as { depth?: number }; - - tocEntries.push({ - value: textContent, - url: slugger.slug(textContent), - depth: heading.depth ?? 1, - }); - }); + const tree = unified().use(remarkParse).parse(content) as MdastNode; + + // Raw HTML headings (e.g.

) are mdast "html" nodes, + // not "heading" nodes, and inline ones inside lists/paragraphs are split + // into separate open-tag / text / close-tag siblings. Track a dangling + // opening tag so both forms are captured in document order. + let openHeading: { depth: number; id?: string; parts: string[] } | null = + null; + + const flushOpenHeading = () => { + if (!openHeading) return; + const text = stripTags(openHeading.parts.join(" ")); + if (text !== "") { + tocEntries.push({ + value: text, + url: openHeading.id ?? slugger.slug(text), + depth: openHeading.depth, + }); + } + openHeading = null; + }; + + const handleHtml = (html: string) => { + HEADING_PAIR_OR_TAG_REGEX.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = HEADING_PAIR_OR_TAG_REGEX.exec(html)) !== null) { + if (match[1]) { + // complete pair + flushOpenHeading(); + const text = stripTags(match[3]); + if (text === "") continue; + tocEntries.push({ + value: text, + url: extractId(match[2]) ?? slugger.slug(text), + depth: Number(match[1]), + }); + } else { + const isClosingTag = match[4] === "/"; + const depth = Number(match[5]); + if (isClosingTag) { + if (openHeading && openHeading.depth === depth) { + flushOpenHeading(); + } + } else { + flushOpenHeading(); + openHeading = { depth, id: extractId(match[6]), parts: [] }; + } + } + } + }; + + const walk = (node: MdastNode) => { + if (node.type === "heading") { + flushOpenHeading(); + const text = toString(node); + tocEntries.push({ + value: text, + url: slugger.slug(text), + depth: node.depth ?? 1, + }); + return; + } + + if (node.type === "html") { + handleHtml(node.value ?? ""); + return; + } + + if (!node.children) return; + + for (const child of node.children) { + if (openHeading && child.type !== "html" && child.type !== "heading") { + // text node between a split ... + const text = toString(child).trim(); + if (text !== "") openHeading.parts.push(text); + continue; + } + walk(child); + } + + flushOpenHeading(); + }; + + walk(tree); return tocEntries; }, [content]);