summaryrefslogtreecommitdiffhomepage
path: root/services/blog/nuldoc-src
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2025-07-04 19:14:54 +0900
committernsfisis <nsfisis@gmail.com>2025-07-04 19:14:54 +0900
commit80f0ab31aceec35c9fd04a6387b14349b806f713 (patch)
tree6d9c3e47fe988f211e8598cbedd2031c09ca54cb /services/blog/nuldoc-src
parent98db243a59fb6a409b3677f2195e96da6fd39564 (diff)
downloadnsfisis.dev-80f0ab31aceec35c9fd04a6387b14349b806f713.tar.gz
nsfisis.dev-80f0ab31aceec35c9fd04a6387b14349b806f713.tar.zst
nsfisis.dev-80f0ab31aceec35c9fd04a6387b14349b806f713.zip
feat(blog/nuldoc): implement TOC
Diffstat (limited to 'services/blog/nuldoc-src')
-rw-r--r--services/blog/nuldoc-src/components/TableOfContents.tsx33
-rw-r--r--services/blog/nuldoc-src/djot/document.ts15
-rw-r--r--services/blog/nuldoc-src/djot/to_html.ts117
-rw-r--r--services/blog/nuldoc-src/pages/PostPage.tsx4
4 files changed, 168 insertions, 1 deletions
diff --git a/services/blog/nuldoc-src/components/TableOfContents.tsx b/services/blog/nuldoc-src/components/TableOfContents.tsx
new file mode 100644
index 00000000..29907d08
--- /dev/null
+++ b/services/blog/nuldoc-src/components/TableOfContents.tsx
@@ -0,0 +1,33 @@
+import { TocEntry, TocRoot } from "../djot/document.ts";
+
+type Props = {
+ toc: TocRoot;
+};
+
+export default function TableOfContents({ toc }: Props) {
+ return (
+ <nav className="toc">
+ <h2>目次</h2>
+ <ul>
+ {toc.entries.map((entry, index) => (
+ <TocEntryComponent key={String(index)} entry={entry} />
+ ))}
+ </ul>
+ </nav>
+ );
+}
+
+function TocEntryComponent({ entry }: { entry: TocEntry }) {
+ return (
+ <li>
+ <a href={`#${entry.id}`}>{entry.text}</a>
+ {entry.children.length > 0 && (
+ <ul>
+ {entry.children.map((child, index) => (
+ <TocEntryComponent key={String(index)} entry={child} />
+ ))}
+ </ul>
+ )}
+ </li>
+ );
+}
diff --git a/services/blog/nuldoc-src/djot/document.ts b/services/blog/nuldoc-src/djot/document.ts
index be9c08d5..3e8cd92c 100644
--- a/services/blog/nuldoc-src/djot/document.ts
+++ b/services/blog/nuldoc-src/djot/document.ts
@@ -12,6 +12,7 @@ export const PostMetadataSchema = z.object({
title: z.string(),
description: z.string(),
tags: z.array(z.string()),
+ toc: z.boolean().optional(),
revisions: z.array(z.object({
date: z.string(),
remark: z.string(),
@@ -22,6 +23,17 @@ export const PostMetadataSchema = z.object({
export type PostMetadata = z.infer<typeof PostMetadataSchema>;
+export type TocEntry = {
+ id: string;
+ text: string;
+ level: number;
+ children: TocEntry[];
+};
+
+export type TocRoot = {
+ entries: TocEntry[];
+};
+
export type Document = {
root: Element;
sourceFilePath: string;
@@ -31,6 +43,8 @@ export type Document = {
description: string; // TODO: should it be markup text?
tags: string[];
revisions: Revision[];
+ toc?: TocRoot;
+ isTocEnabled: boolean;
};
export function createNewDocumentFromDjotDocument(
@@ -56,5 +70,6 @@ export function createNewDocumentFromDjotDocument(
remark: r.remark,
isInternal: !!r.isInternal,
})),
+ isTocEnabled: meta.article.toc !== false,
};
}
diff --git a/services/blog/nuldoc-src/djot/to_html.ts b/services/blog/nuldoc-src/djot/to_html.ts
index 5ea9b57d..5d461ad9 100644
--- a/services/blog/nuldoc-src/djot/to_html.ts
+++ b/services/blog/nuldoc-src/djot/to_html.ts
@@ -1,5 +1,5 @@
import { BundledLanguage, bundledLanguages, codeToHtml } from "shiki";
-import { Document } from "./document.ts";
+import { Document, TocEntry } from "./document.ts";
import { NuldocError } from "../errors.ts";
import {
addClass,
@@ -7,6 +7,7 @@ import {
forEachChild,
forEachChildRecursively,
forEachChildRecursivelyAsync,
+ innerText,
Node,
RawHTML,
Text,
@@ -25,6 +26,8 @@ export default async function toHtml(doc: Document): Promise<Document> {
removeUnnecessaryParagraphNode(doc);
await transformAndHighlightCodeBlockElement(doc);
mergeConsecutiveTextNodes(doc);
+ generateTableOfContents(doc);
+ removeTocAttributes(doc);
return doc;
}
@@ -447,3 +450,115 @@ async function transformAndHighlightCodeBlockElement(doc: Document) {
}
});
}
+
+function generateTableOfContents(doc: Document) {
+ if (!doc.isTocEnabled) {
+ return;
+ }
+ const tocEntries: TocEntry[] = [];
+ const stack: TocEntry[] = [];
+ const excludedLevels: number[] = []; // Track levels to exclude
+
+ const processNode = (node: Node) => {
+ if (node.kind !== "element") {
+ return;
+ }
+
+ const match = node.name.match(/^h(\d+)$/);
+ if (match) {
+ const level = parseInt(match[1]);
+
+ let parentSection: Element | null = null;
+ const findParentSection = (n: Node, target: Node): Element | null => {
+ if (n.kind !== "element") return null;
+
+ for (const child of n.children) {
+ if (child === target && n.name === "section") {
+ return n;
+ }
+ const result = findParentSection(child, target);
+ if (result) return result;
+ }
+ return null;
+ };
+
+ parentSection = findParentSection(doc.root, node);
+ if (!parentSection) return;
+
+ // Check if this section has toc=false attribute
+ const tocAttribute = parentSection.attributes.get("toc");
+ if (tocAttribute === "false") {
+ // Add this level to excluded levels and remove deeper levels
+ excludedLevels.length = 0;
+ excludedLevels.push(level);
+ return;
+ }
+
+ // Check if this header should be excluded based on parent exclusion
+ const shouldExclude = excludedLevels.some((excludedLevel) =>
+ level > excludedLevel
+ );
+ if (shouldExclude) {
+ return;
+ }
+
+ // Clean up excluded levels that are now at same or deeper level
+ while (
+ excludedLevels.length > 0 &&
+ excludedLevels[excludedLevels.length - 1] >= level
+ ) {
+ excludedLevels.pop();
+ }
+
+ const sectionId = parentSection.attributes.get("id");
+ if (!sectionId) return;
+
+ let headingText = "";
+ for (const child of node.children) {
+ if (child.kind === "element" && child.name === "a") {
+ headingText = innerText(child);
+ }
+ }
+
+ const entry: TocEntry = {
+ id: sectionId,
+ text: headingText,
+ level: level,
+ children: [],
+ };
+
+ while (stack.length > 0 && stack[stack.length - 1].level >= level) {
+ stack.pop();
+ }
+
+ if (stack.length === 0) {
+ tocEntries.push(entry);
+ } else {
+ stack[stack.length - 1].children.push(entry);
+ }
+
+ stack.push(entry);
+ }
+
+ forEachChild(node, processNode);
+ };
+
+ forEachChild(doc.root, processNode);
+
+ // Don't generate TOC if there's only one top-level section with no children
+ if (tocEntries.length === 1 && tocEntries[0].children.length === 0) {
+ return;
+ }
+
+ doc.toc = {
+ entries: tocEntries,
+ };
+}
+
+function removeTocAttributes(doc: Document) {
+ forEachChildRecursively(doc.root, (node) => {
+ if (node.kind === "element" && node.name === "section") {
+ node.attributes.delete("toc");
+ }
+ });
+}
diff --git a/services/blog/nuldoc-src/pages/PostPage.tsx b/services/blog/nuldoc-src/pages/PostPage.tsx
index 97a24048..e625518c 100644
--- a/services/blog/nuldoc-src/pages/PostPage.tsx
+++ b/services/blog/nuldoc-src/pages/PostPage.tsx
@@ -1,6 +1,7 @@
import GlobalFooter from "../components/GlobalFooter.tsx";
import GlobalHeader from "../components/GlobalHeader.tsx";
import PageLayout from "../components/PageLayout.tsx";
+import TableOfContents from "../components/TableOfContents.tsx";
import { Config, getTagLabel } from "../config.ts";
import { Element } from "../dom.ts";
import { Document } from "../djot/document.ts";
@@ -36,6 +37,9 @@ export default function PostPage(
</ul>
)}
</header>
+ {doc.toc && doc.toc.entries.length > 0 && (
+ <TableOfContents toc={doc.toc} />
+ )}
<div className="post-content">
<section id="changelog">
<h2>