summaryrefslogtreecommitdiffhomepage
path: root/services/blog/nuldoc-src/djot/to_html.ts
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/djot/to_html.ts
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/djot/to_html.ts')
-rw-r--r--services/blog/nuldoc-src/djot/to_html.ts117
1 files changed, 116 insertions, 1 deletions
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");
+ }
+ });
+}