diff options
| author | nsfisis <nsfisis@gmail.com> | 2025-07-04 19:14:54 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2025-07-04 19:14:54 +0900 |
| commit | 80f0ab31aceec35c9fd04a6387b14349b806f713 (patch) | |
| tree | 6d9c3e47fe988f211e8598cbedd2031c09ca54cb /services/blog/nuldoc-src | |
| parent | 98db243a59fb6a409b3677f2195e96da6fd39564 (diff) | |
| download | nsfisis.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.tsx | 33 | ||||
| -rw-r--r-- | services/blog/nuldoc-src/djot/document.ts | 15 | ||||
| -rw-r--r-- | services/blog/nuldoc-src/djot/to_html.ts | 117 | ||||
| -rw-r--r-- | services/blog/nuldoc-src/pages/PostPage.tsx | 4 |
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> |
