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 (
-
+
)
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]);