aboutsummaryrefslogtreecommitdiffhomepage
path: root/services/nuldoc/nuldoc-src/markdown
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-02-01 00:49:15 +0900
committernsfisis <nsfisis@gmail.com>2026-02-01 00:49:19 +0900
commit6dedddc545e2f1930bdc2256784eb1551bd4231d (patch)
tree75fcb5a6043dc0f2c31b098bf3cfd17a2b938599 /services/nuldoc/nuldoc-src/markdown
parentd08e3edb65b215152aa26e3518fb2f2cd7071c4b (diff)
downloadnsfisis.dev-6dedddc545e2f1930bdc2256784eb1551bd4231d.tar.gz
nsfisis.dev-6dedddc545e2f1930bdc2256784eb1551bd4231d.tar.zst
nsfisis.dev-6dedddc545e2f1930bdc2256784eb1551bd4231d.zip
feat(nuldoc): rewrite nuldoc in Ruby
Diffstat (limited to 'services/nuldoc/nuldoc-src/markdown')
-rw-r--r--services/nuldoc/nuldoc-src/markdown/document.ts75
-rw-r--r--services/nuldoc/nuldoc-src/markdown/mdast2ndoc.ts589
-rw-r--r--services/nuldoc/nuldoc-src/markdown/parse.ts47
-rw-r--r--services/nuldoc/nuldoc-src/markdown/to_html.ts496
4 files changed, 0 insertions, 1207 deletions
diff --git a/services/nuldoc/nuldoc-src/markdown/document.ts b/services/nuldoc/nuldoc-src/markdown/document.ts
deleted file mode 100644
index 1aee87b9..00000000
--- a/services/nuldoc/nuldoc-src/markdown/document.ts
+++ /dev/null
@@ -1,75 +0,0 @@
-import type { Root as MdastRoot } from "mdast";
-import { join } from "@std/path";
-import { z } from "zod/mod.ts";
-import { Config } from "../config.ts";
-import { Element } from "../dom.ts";
-import { Revision, stringToDate } from "../revision.ts";
-import { mdast2ndoc } from "./mdast2ndoc.ts";
-
-export const PostMetadataSchema = z.object({
- article: z.object({
- uuid: z.string(),
- 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(),
- isInternal: z.boolean().optional(),
- })),
- }),
-});
-
-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;
- uuid: string;
- link: string;
- title: string;
- description: string;
- tags: string[];
- revisions: Revision[];
- toc?: TocRoot;
- isTocEnabled: boolean;
-};
-
-export function createNewDocumentFromMdast(
- root: MdastRoot,
- meta: PostMetadata,
- sourceFilePath: string,
- config: Config,
-): Document {
- const cwd = Deno.cwd();
- const contentDir = join(cwd, config.locations.contentDir);
- const link = sourceFilePath.replace(contentDir, "").replace(".xml", "/");
- return {
- root: mdast2ndoc(root),
- sourceFilePath,
- uuid: meta.article.uuid,
- link: link,
- title: meta.article.title,
- description: meta.article.description,
- tags: meta.article.tags,
- revisions: meta.article.revisions.map((r, i) => ({
- number: i,
- date: stringToDate(r.date),
- remark: r.remark,
- isInternal: !!r.isInternal,
- })),
- isTocEnabled: meta.article.toc !== false,
- };
-}
diff --git a/services/nuldoc/nuldoc-src/markdown/mdast2ndoc.ts b/services/nuldoc/nuldoc-src/markdown/mdast2ndoc.ts
deleted file mode 100644
index 626f1191..00000000
--- a/services/nuldoc/nuldoc-src/markdown/mdast2ndoc.ts
+++ /dev/null
@@ -1,589 +0,0 @@
-import type {
- Blockquote,
- Code,
- Definition,
- Delete,
- Emphasis,
- FootnoteDefinition,
- FootnoteReference,
- Heading,
- Html,
- Image,
- InlineCode,
- Link,
- List,
- ListItem,
- Paragraph,
- PhrasingContent,
- Root,
- RootContent,
- Strong,
- Table,
- TableCell,
- TableRow,
- Text as MdastText,
- ThematicBreak,
-} from "mdast";
-import type {
- ContainerDirective,
- LeafDirective,
- TextDirective,
-} from "mdast-util-directive";
-import {
- a,
- article,
- div,
- elem,
- Element,
- img,
- li,
- Node,
- ol,
- p,
- rawHTML,
- section,
- span,
- text,
- ul,
-} from "../dom.ts";
-
-type DirectiveNode = ContainerDirective | LeafDirective | TextDirective;
-
-function isDirective(node: RootContent): node is DirectiveNode {
- return (
- node.type === "containerDirective" ||
- node.type === "leafDirective" ||
- node.type === "textDirective"
- );
-}
-
-// Extract section ID and attributes from heading if present
-// Supports syntax like {#id} or {#id attr="value"}
-function extractSectionId(
- node: Heading,
-): {
- id: string | null;
- attributes: Record<string, string>;
- children: Heading["children"];
-} {
- if (node.children.length === 0) {
- return { id: null, attributes: {}, children: node.children };
- }
-
- const lastChild = node.children[node.children.length - 1];
- if (lastChild && lastChild.type === "text") {
- // Match {#id ...} or {#id attr="value" ...}
- const match = lastChild.value.match(/\s*\{#([^\s}]+)([^}]*)\}\s*$/);
- if (match) {
- const id = match[1];
- const attrString = match[2].trim();
- const attributes: Record<string, string> = {};
-
- // Parse attributes like toc="false" (supports smart quotes too)
- // U+0022 = ", U+201C = ", U+201D = "
- const attrRegex =
- /(\w+)=["\u201c\u201d]([^"\u201c\u201d]*)["\u201c\u201d]/g;
- let attrMatch;
- while ((attrMatch = attrRegex.exec(attrString)) !== null) {
- attributes[attrMatch[1]] = attrMatch[2];
- }
-
- const newValue = lastChild.value.replace(/\s*\{#[^}]+\}\s*$/, "");
- if (newValue === "") {
- return { id, attributes, children: node.children.slice(0, -1) };
- } else {
- const newChildren = [...node.children];
- newChildren[newChildren.length - 1] = { ...lastChild, value: newValue };
- return { id, attributes, children: newChildren };
- }
- }
- }
-
- return { id: null, attributes: {}, children: node.children };
-}
-
-function processBlock(node: RootContent): Element | Element[] | null {
- switch (node.type) {
- case "heading":
- // Headings are handled specially in mdast2ndoc
- return null;
- case "paragraph":
- return processParagraph(node);
- case "thematicBreak":
- return processThematicBreak(node);
- case "blockquote":
- return processBlockquote(node);
- case "code":
- return processCode(node);
- case "list":
- return processList(node);
- case "table":
- return processTable(node);
- case "html":
- return processHtmlBlock(node);
- case "definition":
- return processDefinition(node);
- case "footnoteDefinition":
- return processFootnoteDefinition(node);
- default:
- if (isDirective(node)) {
- return processDirective(node);
- }
- return null;
- }
-}
-
-function processParagraph(node: Paragraph): Element {
- return p({}, ...node.children.map(processInline));
-}
-
-function processThematicBreak(_node: ThematicBreak): Element {
- return elem("hr", {});
-}
-
-function processBlockquote(node: Blockquote): Element {
- const children: Node[] = [];
- for (const child of node.children) {
- const result = processBlock(child);
- if (result) {
- if (Array.isArray(result)) {
- children.push(...result);
- } else {
- children.push(result);
- }
- }
- }
- return elem("blockquote", {}, ...children);
-}
-
-function processCode(node: Code): Element {
- const attributes: Record<string, string> = {};
-
- if (node.lang) {
- attributes.language = node.lang;
- }
-
- // Parse meta string for filename and numbered attributes
- if (node.meta) {
- const filenameMatch = node.meta.match(/filename="([^"]+)"/);
- if (filenameMatch) {
- attributes.filename = filenameMatch[1];
- }
-
- if (node.meta.includes("numbered")) {
- attributes.numbered = "true";
- }
- }
-
- return elem("codeblock", attributes, text(node.value));
-}
-
-function processList(node: List): Element {
- const attributes: Record<string, string> = {};
- attributes.__tight = node.spread === false ? "true" : "false";
-
- const isTaskList = node.children.some(
- (item) => item.checked !== null && item.checked !== undefined,
- );
-
- if (isTaskList) {
- attributes.type = "task";
- }
-
- if (node.ordered && node.start !== null && node.start !== 1) {
- attributes.start = node.start!.toString();
- }
-
- const children = node.children.map((item) =>
- processListItem(item, isTaskList)
- );
-
- return node.ordered
- ? ol(attributes, ...children)
- : ul(attributes, ...children);
-}
-
-function processListItem(node: ListItem, isTaskList: boolean): Element {
- const attributes: Record<string, string> = {};
-
- if (isTaskList) {
- attributes.checked = node.checked ? "true" : "false";
- }
-
- const children: Node[] = [];
- for (const child of node.children) {
- const result = processBlock(child);
- if (result) {
- if (Array.isArray(result)) {
- children.push(...result);
- } else {
- children.push(result);
- }
- }
- }
-
- return li(attributes, ...children);
-}
-
-function processTable(node: Table): Element {
- const tableElement = elem("table", {});
- const headerRows: Element[] = [];
- const bodyRows: Element[] = [];
-
- node.children.forEach((row, rowIndex) => {
- const rowElement = processTableRow(row, rowIndex === 0, node.align);
- if (rowIndex === 0) {
- headerRows.push(rowElement);
- } else {
- bodyRows.push(rowElement);
- }
- });
-
- if (headerRows.length > 0) {
- tableElement.children.push(elem("thead", undefined, ...headerRows));
- }
-
- if (bodyRows.length > 0) {
- tableElement.children.push(elem("tbody", undefined, ...bodyRows));
- }
-
- return tableElement;
-}
-
-function processTableRow(
- node: TableRow,
- isHeader: boolean,
- alignments: (string | null)[] | null | undefined,
-): Element {
- const cells = node.children.map((cell, index) =>
- processTableCell(cell, isHeader, alignments?.[index])
- );
- return elem("tr", {}, ...cells);
-}
-
-function processTableCell(
- node: TableCell,
- isHeader: boolean,
- alignment: string | null | undefined,
-): Element {
- const attributes: Record<string, string> = {};
- if (alignment && alignment !== "none") {
- attributes.align = alignment;
- }
-
- return elem(
- isHeader ? "th" : "td",
- attributes,
- ...node.children.map(processInline),
- );
-}
-
-function processHtmlBlock(node: Html): Element {
- return div({ class: "raw-html" }, rawHTML(node.value));
-}
-
-function processDefinition(_node: Definition): null {
- // Link definitions are handled elsewhere
- return null;
-}
-
-function processFootnoteDefinition(node: FootnoteDefinition): Element {
- const children: Node[] = [];
- for (const child of node.children) {
- const result = processBlock(child);
- if (result) {
- if (Array.isArray(result)) {
- children.push(...result);
- } else {
- children.push(result);
- }
- }
- }
- return elem("footnote", { id: node.identifier }, ...children);
-}
-
-function processDirective(node: DirectiveNode): Element | null {
- const name = node.name;
-
- if (name === "note" || name === "edit") {
- const attributes: Record<string, string> = {};
-
- // Copy directive attributes
- if (node.attributes) {
- for (const [key, value] of Object.entries(node.attributes)) {
- if (value !== undefined && value !== null) {
- attributes[key] = String(value);
- }
- }
- }
-
- const children: Node[] = [];
- if ("children" in node && node.children) {
- for (const child of node.children as RootContent[]) {
- const result = processBlock(child);
- if (result) {
- if (Array.isArray(result)) {
- children.push(...result);
- } else {
- children.push(result);
- }
- }
- }
- }
-
- return elem("note", attributes, ...children);
- }
-
- // For other directives, treat as div
- const children: Node[] = [];
- if ("children" in node && node.children) {
- for (const child of node.children as RootContent[]) {
- const result = processBlock(child);
- if (result) {
- if (Array.isArray(result)) {
- children.push(...result);
- } else {
- children.push(result);
- }
- }
- }
- }
-
- return div(
- node.attributes as Record<string, string> || {},
- ...children,
- );
-}
-
-function processInline(node: PhrasingContent): Node {
- switch (node.type) {
- case "text":
- return processText(node);
- case "emphasis":
- return processEmphasis(node);
- case "strong":
- return processStrong(node);
- case "inlineCode":
- return processInlineCode(node);
- case "link":
- return processLink(node);
- case "image":
- return processImage(node);
- case "delete":
- return processDelete(node);
- case "break":
- return elem("br");
- case "html":
- return rawHTML(node.value);
- case "footnoteReference":
- return processFootnoteReference(node);
- default:
- // Handle any unexpected node types
- if ("value" in node) {
- return text(String(node.value));
- }
- if ("children" in node && Array.isArray(node.children)) {
- return span(
- {},
- ...node.children.map((c: PhrasingContent) => processInline(c)),
- );
- }
- return text("");
- }
-}
-
-function processText(node: MdastText): Node {
- return text(node.value);
-}
-
-function processEmphasis(node: Emphasis): Element {
- return elem("em", {}, ...node.children.map(processInline));
-}
-
-function processStrong(node: Strong): Element {
- return elem("strong", {}, ...node.children.map(processInline));
-}
-
-function processInlineCode(node: InlineCode): Element {
- return elem("code", {}, text(node.value));
-}
-
-function processLink(node: Link): Element {
- const attributes: Record<string, string> = {};
- if (node.url) {
- attributes.href = node.url;
- }
- if (node.title) {
- attributes.title = node.title;
- }
- // Detect autolinks (URL equals link text)
- const isAutolink = node.children.length === 1 &&
- node.children[0].type === "text" &&
- node.children[0].value === node.url;
- if (isAutolink) {
- attributes.class = "url";
- }
- return a(attributes, ...node.children.map(processInline));
-}
-
-function processImage(node: Image): Element {
- const attributes: Record<string, string> = {};
- if (node.url) {
- attributes.src = node.url;
- }
- if (node.alt) {
- attributes.alt = node.alt;
- }
- if (node.title) {
- attributes.title = node.title;
- }
- return img(attributes);
-}
-
-function processDelete(node: Delete): Element {
- return elem("del", {}, ...node.children.map(processInline));
-}
-
-function processFootnoteReference(node: FootnoteReference): Element {
- return elem("footnoteref", { reference: node.identifier });
-}
-
-// Build hierarchical section structure from flat mdast
-// This mimics Djot's section structure where headings create nested sections
-export function mdast2ndoc(root: Root): Element {
- const footnotes: Element[] = [];
- const nonFootnoteChildren: RootContent[] = [];
-
- // Separate footnotes from other content
- for (const child of root.children) {
- if (child.type === "footnoteDefinition") {
- const footnote = processFootnoteDefinition(child);
- footnotes.push(footnote);
- } else {
- nonFootnoteChildren.push(child);
- }
- }
-
- // Build hierarchical sections
- const articleContent = buildSectionHierarchy(nonFootnoteChildren);
-
- // Add footnotes section if any exist
- if (footnotes.length > 0) {
- const footnoteSection = section(
- { class: "footnotes" },
- ...footnotes,
- );
- articleContent.push(footnoteSection);
- }
-
- return elem(
- "__root__",
- undefined,
- article(undefined, ...articleContent),
- );
-}
-
-type SectionInfo = {
- id: string | null;
- attributes: Record<string, string>;
- level: number;
- heading: Element;
- children: Node[];
-};
-
-function buildSectionHierarchy(nodes: RootContent[]): Node[] {
- // Group nodes into sections based on headings
- // Each heading starts a new section at its level
- const result: Node[] = [];
- const sectionStack: SectionInfo[] = [];
-
- for (const node of nodes) {
- if (node.type === "heading") {
- const level = node.depth;
- const { id, attributes, children } = extractSectionId(node);
-
- // Create heading element
- const headingElement = elem(
- "h",
- {},
- ...children.map(processInline),
- );
-
- // Close sections that are at same or deeper level
- while (
- sectionStack.length > 0 &&
- sectionStack[sectionStack.length - 1].level >= level
- ) {
- const closedSection = sectionStack.pop()!;
- const sectionElement = createSectionElement(closedSection);
-
- if (sectionStack.length > 0) {
- // Add to parent section
- sectionStack[sectionStack.length - 1].children.push(sectionElement);
- } else {
- // Add to result
- result.push(sectionElement);
- }
- }
-
- // Start new section
- const newSection: SectionInfo = {
- id,
- attributes,
- level,
- heading: headingElement,
- children: [],
- };
- sectionStack.push(newSection);
- } else {
- // Non-heading content
- const processed = processBlock(node);
- if (processed) {
- if (sectionStack.length > 0) {
- // Add to current section
- if (Array.isArray(processed)) {
- sectionStack[sectionStack.length - 1].children.push(...processed);
- } else {
- sectionStack[sectionStack.length - 1].children.push(processed);
- }
- } else {
- // Content before any heading
- if (Array.isArray(processed)) {
- result.push(...processed);
- } else {
- result.push(processed);
- }
- }
- }
- }
- }
-
- // Close remaining sections
- while (sectionStack.length > 0) {
- const closedSection = sectionStack.pop()!;
- const sectionElement = createSectionElement(closedSection);
-
- if (sectionStack.length > 0) {
- // Add to parent section
- sectionStack[sectionStack.length - 1].children.push(sectionElement);
- } else {
- // Add to result
- result.push(sectionElement);
- }
- }
-
- return result;
-}
-
-function createSectionElement(sectionInfo: SectionInfo): Element {
- const attributes: Record<string, string> = { ...sectionInfo.attributes };
- if (sectionInfo.id) {
- attributes.id = sectionInfo.id;
- }
-
- return section(
- attributes,
- sectionInfo.heading,
- ...sectionInfo.children,
- );
-}
diff --git a/services/nuldoc/nuldoc-src/markdown/parse.ts b/services/nuldoc/nuldoc-src/markdown/parse.ts
deleted file mode 100644
index c0875a25..00000000
--- a/services/nuldoc/nuldoc-src/markdown/parse.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import type { Root as MdastRoot } from "mdast";
-import { unified } from "unified";
-import remarkParse from "remark-parse";
-import remarkGfm from "remark-gfm";
-import remarkDirective from "remark-directive";
-import remarkSmartypants from "remark-smartypants";
-import { parse as parseToml } from "@std/toml";
-import { Config } from "../config.ts";
-import {
- createNewDocumentFromMdast,
- Document,
- PostMetadata,
- PostMetadataSchema,
-} from "./document.ts";
-import toHtml from "./to_html.ts";
-
-export async function parseMarkdownFile(
- filePath: string,
- config: Config,
-): Promise<Document> {
- try {
- const fileContent = await Deno.readTextFile(filePath);
- const [, frontmatter, ...rest] = fileContent.split(/^---$/m);
- const meta = parseMetadata(frontmatter);
- const content = rest.join("---");
-
- const processor = unified()
- .use(remarkParse)
- .use(remarkGfm)
- .use(remarkDirective)
- .use(remarkSmartypants);
-
- const root = await processor.run(processor.parse(content)) as MdastRoot;
-
- const doc = createNewDocumentFromMdast(root, meta, filePath, config);
- return await toHtml(doc);
- } catch (e) {
- if (e instanceof Error) {
- e.message = `${e.message} in ${filePath}`;
- }
- throw e;
- }
-}
-
-function parseMetadata(s: string): PostMetadata {
- return PostMetadataSchema.parse(parseToml(s));
-}
diff --git a/services/nuldoc/nuldoc-src/markdown/to_html.ts b/services/nuldoc/nuldoc-src/markdown/to_html.ts
deleted file mode 100644
index 8758b0d3..00000000
--- a/services/nuldoc/nuldoc-src/markdown/to_html.ts
+++ /dev/null
@@ -1,496 +0,0 @@
-import { BundledLanguage, bundledLanguages, codeToHtml } from "shiki";
-import { Document, TocEntry } from "./document.ts";
-import { NuldocError } from "../errors.ts";
-import {
- a,
- addClass,
- div,
- Element,
- forEachChild,
- forEachChildRecursively,
- forEachChildRecursivelyAsync,
- forEachElementOfType,
- innerText,
- Node,
- processTextNodesInElement,
- RawHTML,
- rawHTML,
- Text,
- text,
-} from "../dom.ts";
-
-export default async function toHtml(doc: Document): Promise<Document> {
- mergeConsecutiveTextNodes(doc);
- removeUnnecessaryTextNode(doc);
- transformLinkLikeToAnchorElement(doc);
- transformSectionIdAttribute(doc);
- setSectionTitleAnchor(doc);
- transformSectionTitleElement(doc);
- transformNoteElement(doc);
- addAttributesToExternalLinkElement(doc);
- traverseFootnotes(doc);
- removeUnnecessaryParagraphNode(doc);
- await transformAndHighlightCodeBlockElement(doc);
- mergeConsecutiveTextNodes(doc);
- generateTableOfContents(doc);
- removeTocAttributes(doc);
- return doc;
-}
-
-function mergeConsecutiveTextNodes(doc: Document) {
- forEachChildRecursively(doc.root, (n) => {
- if (n.kind !== "element") {
- return;
- }
-
- const newChildren: Node[] = [];
- let currentTextContent = "";
-
- for (const child of n.children) {
- if (child.kind === "text") {
- currentTextContent += child.content;
- } else {
- if (currentTextContent !== "") {
- newChildren.push(text(currentTextContent));
- currentTextContent = "";
- }
- newChildren.push(child);
- }
- }
-
- if (currentTextContent !== "") {
- newChildren.push(text(currentTextContent));
- }
-
- n.children = newChildren;
- });
-}
-
-function removeUnnecessaryTextNode(doc: Document) {
- forEachChildRecursively(doc.root, (n) => {
- if (n.kind !== "element") {
- return;
- }
-
- let changed = true;
- while (changed) {
- changed = false;
- if (n.children.length === 0) {
- break;
- }
- const firstChild = n.children[0];
- if (firstChild.kind === "text" && firstChild.content.trim() === "") {
- n.children.shift();
- changed = true;
- }
- if (n.children.length === 0) {
- break;
- }
- const lastChild = n.children[n.children.length - 1];
- if (lastChild.kind === "text" && lastChild.content.trim() === "") {
- n.children.pop();
- changed = true;
- }
- }
- });
-}
-
-function transformLinkLikeToAnchorElement(doc: Document) {
- forEachChildRecursively(doc.root, (n) => {
- if (
- n.kind !== "element" || n.name === "a" || n.name === "code" ||
- n.name === "codeblock"
- ) {
- return;
- }
-
- processTextNodesInElement(n, (content) => {
- const nodes: Node[] = [];
- let restContent = content;
- while (restContent !== "") {
- const match = /^(.*?)(https?:\/\/[^ \n]+)(.*)$/s.exec(restContent);
- if (!match) {
- nodes.push(text(restContent));
- restContent = "";
- break;
- }
- const [_, prefix, url, suffix] = match;
- nodes.push(text(prefix));
- nodes.push(a({ href: url, class: "url" }, text(url)));
- restContent = suffix;
- }
- return nodes;
- });
- });
-}
-
-function transformSectionIdAttribute(doc: Document) {
- const sectionStack: string[] = [];
- const usedIds = new Set<string>();
-
- const processNode = (n: Node) => {
- if (n.kind !== "element") {
- return;
- }
-
- if (n.name === "section") {
- const idAttr = n.attributes.id;
- if (!idAttr) {
- return;
- }
-
- let newId: string;
- if (sectionStack.length === 0) {
- newId = `section--${idAttr}`;
- } else {
- newId = `section--${sectionStack.join("--")}--${idAttr}`;
- }
-
- if (usedIds.has(newId)) {
- throw new NuldocError(
- `[nuldoc.tohtml] Duplicate section ID: ${newId}`,
- );
- }
-
- usedIds.add(newId);
- n.attributes.id = newId;
- sectionStack.push(idAttr);
-
- forEachChild(n, processNode);
-
- sectionStack.pop();
- } else {
- forEachChild(n, processNode);
- }
- };
-
- forEachChild(doc.root, processNode);
-}
-
-function setSectionTitleAnchor(doc: Document) {
- const sectionStack: Element[] = [];
- const g = (c: Node) => {
- if (c.kind !== "element") {
- return;
- }
-
- if (c.name === "section") {
- sectionStack.push(c);
- }
- forEachChild(c, g);
- if (c.name === "section") {
- sectionStack.pop();
- }
- if (c.name === "h") {
- const currentSection = sectionStack[sectionStack.length - 1];
- if (!currentSection) {
- throw new NuldocError(
- "[nuldoc.tohtml] <h> element must be inside <section>",
- );
- }
- const sectionId = currentSection.attributes.id;
- const aElement = a(undefined, ...c.children);
- aElement.attributes.href = `#${sectionId}`;
- c.children = [aElement];
- }
- };
- forEachChild(doc.root, g);
-}
-
-function transformSectionTitleElement(doc: Document) {
- let sectionLevel = 1;
- const g = (c: Node) => {
- if (c.kind !== "element") {
- return;
- }
-
- if (c.name === "section") {
- sectionLevel += 1;
- c.attributes.__sectionLevel = sectionLevel.toString();
- }
- forEachChild(c, g);
- if (c.name === "section") {
- sectionLevel -= 1;
- }
- if (c.name === "h") {
- c.name = `h${sectionLevel}`;
- }
- };
- forEachChild(doc.root, g);
-}
-
-function transformNoteElement(doc: Document) {
- forEachElementOfType(doc.root, "note", (n) => {
- const editatAttr = n.attributes?.editat;
- const operationAttr = n.attributes?.operation;
- const isEditBlock = editatAttr && operationAttr;
-
- const labelElement = div(
- { class: "admonition-label" },
- text(isEditBlock ? `${editatAttr} ${operationAttr}` : "NOTE"),
- );
- const contentElement = div(
- { class: "admonition-content" },
- ...n.children,
- );
- n.name = "div";
- addClass(n, "admonition");
- n.children = [labelElement, contentElement];
- });
-}
-
-function addAttributesToExternalLinkElement(doc: Document) {
- forEachElementOfType(doc.root, "a", (n) => {
- const href = n.attributes.href ?? "";
- if (!href.startsWith("http")) {
- return;
- }
- n.attributes.target = "_blank";
- n.attributes.rel = "noreferrer";
- });
-}
-
-function traverseFootnotes(doc: Document) {
- let footnoteCounter = 0;
- const footnoteMap = new Map<string, number>();
-
- forEachElementOfType(doc.root, "footnoteref", (n) => {
- const reference = n.attributes.reference;
- if (!reference) {
- return;
- }
-
- let footnoteNumber: number;
- if (footnoteMap.has(reference)) {
- footnoteNumber = footnoteMap.get(reference)!;
- } else {
- footnoteNumber = ++footnoteCounter;
- footnoteMap.set(reference, footnoteNumber);
- }
-
- n.name = "sup";
- delete n.attributes.reference;
- n.attributes.class = "footnote";
- n.children = [
- a(
- {
- id: `footnoteref--${reference}`,
- class: "footnote",
- href: `#footnote--${reference}`,
- },
- text(`[${footnoteNumber}]`),
- ),
- ];
- });
-
- forEachElementOfType(doc.root, "footnote", (n) => {
- const id = n.attributes.id;
- if (!id || !footnoteMap.has(id)) {
- n.name = "span";
- n.children = [];
- return;
- }
-
- const footnoteNumber = footnoteMap.get(id)!;
-
- n.name = "div";
- delete n.attributes.id;
- n.attributes.class = "footnote";
- n.attributes.id = `footnote--${id}`;
-
- n.children = [
- a(
- { href: `#footnoteref--${id}` },
- text(`${footnoteNumber}. `),
- ),
- ...n.children,
- ];
- });
-}
-
-function removeUnnecessaryParagraphNode(doc: Document) {
- forEachChildRecursively(doc.root, (n) => {
- if (n.kind !== "element" || (n.name !== "ul" && n.name !== "ol")) {
- return;
- }
-
- const isTight = n.attributes.__tight === "true";
- if (!isTight) {
- return;
- }
-
- for (const child of n.children) {
- if (child.kind !== "element" || child.name !== "li") {
- continue;
- }
- const newGrandChildren: Node[] = [];
- for (const grandChild of child.children) {
- if (grandChild.kind === "element" && grandChild.name === "p") {
- newGrandChildren.push(...grandChild.children);
- } else {
- newGrandChildren.push(grandChild);
- }
- }
- child.children = newGrandChildren;
- }
- });
-}
-
-async function transformAndHighlightCodeBlockElement(doc: Document) {
- await forEachChildRecursivelyAsync(doc.root, async (n) => {
- if (n.kind !== "element" || n.name !== "codeblock") {
- return;
- }
-
- const language = n.attributes.language || "text";
- const filename = n.attributes.filename;
- const numbered = n.attributes.numbered;
- const sourceCodeNode = n.children[0] as Text | RawHTML;
- const sourceCode = sourceCodeNode.kind === "text"
- ? sourceCodeNode.content.trimEnd()
- : sourceCodeNode.html.trimEnd();
-
- const highlighted = await codeToHtml(sourceCode, {
- lang: language in bundledLanguages ? language as BundledLanguage : "text",
- theme: "github-light",
- colorReplacements: {
- "#fff": "#f5f5f5",
- },
- });
-
- n.name = "div";
- n.attributes.class = "codeblock";
- delete n.attributes.language;
-
- if (numbered === "true") {
- delete n.attributes.numbered;
- addClass(n, "numbered");
- }
- if (filename) {
- delete n.attributes.filename;
-
- n.children = [
- div({ class: "filename" }, text(filename)),
- rawHTML(highlighted),
- ];
- } else {
- if (sourceCodeNode.kind === "text") {
- n.children[0] = rawHTML(highlighted);
- } else {
- sourceCodeNode.html = highlighted;
- }
- }
- });
-}
-
-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.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.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") {
- delete node.attributes.toc;
- }
- });
-}