diff options
| author | nsfisis <nsfisis@gmail.com> | 2025-11-02 17:49:34 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2025-11-02 17:49:34 +0900 |
| commit | 57315c52be96d2a2c013f0cfb0de5429980e301a (patch) | |
| tree | 5d691497772fddfe401cd970ead4e9a74b34ef38 /services/nuldoc/nuldoc-src/djot | |
| parent | cf4091a93ed831456e8b30e2a9e1fc2650dcae02 (diff) | |
| download | nsfisis.dev-57315c52be96d2a2c013f0cfb0de5429980e301a.tar.gz nsfisis.dev-57315c52be96d2a2c013f0cfb0de5429980e301a.tar.zst nsfisis.dev-57315c52be96d2a2c013f0cfb0de5429980e301a.zip | |
refactor(blog): rename directory, services/{blog => nuldoc}/
Diffstat (limited to 'services/nuldoc/nuldoc-src/djot')
| -rw-r--r-- | services/nuldoc/nuldoc-src/djot/djot2ndoc.ts | 604 | ||||
| -rw-r--r-- | services/nuldoc/nuldoc-src/djot/document.ts | 75 | ||||
| -rw-r--r-- | services/nuldoc/nuldoc-src/djot/parse.ts | 33 | ||||
| -rw-r--r-- | services/nuldoc/nuldoc-src/djot/to_html.ts | 499 |
4 files changed, 1211 insertions, 0 deletions
diff --git a/services/nuldoc/nuldoc-src/djot/djot2ndoc.ts b/services/nuldoc/nuldoc-src/djot/djot2ndoc.ts new file mode 100644 index 0000000..627e8d6 --- /dev/null +++ b/services/nuldoc/nuldoc-src/djot/djot2ndoc.ts @@ -0,0 +1,604 @@ +import { + Block as DjotBlock, + BlockQuote as DjotBlockQuote, + BulletList as DjotBulletList, + CodeBlock as DjotCodeBlock, + Definition as DjotDefinition, + DefinitionList as DjotDefinitionList, + DefinitionListItem as DjotDefinitionListItem, + Delete as DjotDelete, + DisplayMath as DjotDisplayMath, + Div as DjotDiv, + Doc as DjotDoc, + DoubleQuoted as DjotDoubleQuoted, + Email as DjotEmail, + Emph as DjotEmph, + FootnoteReference as DjotFootnoteReference, + HardBreak as DjotHardBreak, + Heading as DjotHeading, + Image as DjotImage, + Inline as DjotInline, + InlineMath as DjotInlineMath, + Insert as DjotInsert, + Link as DjotLink, + ListItem as DjotListItem, + Mark as DjotMark, + NonBreakingSpace as DjotNonBreakingSpace, + OrderedList as DjotOrderedList, + Para as DjotPara, + RawBlock as DjotRawBlock, + RawInline as DjotRawInline, + Section as DjotSection, + SingleQuoted as DjotSingleQuoted, + SmartPunctuation as DjotSmartPunctuation, + SoftBreak as DjotSoftBreak, + Span as DjotSpan, + Str as DjotStr, + Strong as DjotStrong, + Subscript as DjotSubscript, + Superscript as DjotSuperscript, + Symb as DjotSymb, + Table as DjotTable, + TaskList as DjotTaskList, + TaskListItem as DjotTaskListItem, + Term as DjotTerm, + ThematicBreak as DjotThematicBreak, + Url as DjotUrl, + Verbatim as DjotVerbatim, +} from "@djot/djot"; +import { addClass, elem, Element, Node, rawHTML, text } from "../dom.ts"; + +function processBlock(node: DjotBlock): Element { + switch (node.tag) { + case "section": + return processSection(node); + case "para": + return processPara(node); + case "heading": + return processHeading(node); + case "thematic_break": + return processThematicBreak(node); + case "block_quote": + return processBlockQuote(node); + case "code_block": + return processCodeBlock(node); + case "bullet_list": + return processBulletList(node); + case "ordered_list": + return processOrderedList(node); + case "task_list": + return processTaskList(node); + case "definition_list": + return processDefinitionList(node); + case "table": + return processTable(node); + case "div": + return processDiv(node); + case "raw_block": + return processRawBlock(node); + } +} + +function processSection(node: DjotSection): Element { + return elem( + "section", + node.attributes, + ...node.children.map(processBlock), + ); +} + +function processPara(node: DjotPara): Element { + return elem( + "p", + node.attributes, + ...node.children.map(processInline), + ); +} + +function processHeading(node: DjotHeading): Element { + return elem("h", node.attributes, ...node.children.map(processInline)); +} + +function processThematicBreak(node: DjotThematicBreak): Element { + return elem("hr", node.attributes); +} + +function processBlockQuote(node: DjotBlockQuote): Element { + return elem( + "blockquote", + node.attributes, + ...node.children.map(processBlock), + ); +} + +function processCodeBlock(node: DjotCodeBlock): Element { + const attributes = node.attributes || {}; + if (node.lang) { + attributes.language = node.lang; + } + if (node.attributes?.filename) { + attributes.filename = node.attributes.filename; + } + if (node.attributes?.numbered) { + attributes.numbered = "true"; + } + return elem("codeblock", attributes, text(node.text)); +} + +function processBulletList(node: DjotBulletList): Element { + const attributes = node.attributes || {}; + attributes.__tight = node.tight ? "true" : "false"; + return elem("ul", attributes, ...node.children.map(processListItem)); +} + +function processOrderedList(node: DjotOrderedList): Element { + const attributes = node.attributes || {}; + attributes.__tight = node.tight ? "true" : "false"; + if (node.start !== undefined && node.start !== 1) { + attributes.start = node.start.toString(); + } + return elem("ol", attributes, ...node.children.map(processListItem)); +} + +function processTaskList(node: DjotTaskList): Element { + const attributes = node.attributes || {}; + attributes.type = "task"; + attributes.__tight = node.tight ? "true" : "false"; + return elem("ul", attributes, ...node.children.map(processTaskListItem)); +} + +function processListItem(node: DjotListItem): Element { + return elem( + "li", + node.attributes, + ...node.children.map(processBlock), + ); +} + +function processTaskListItem(node: DjotTaskListItem): Element { + const attributes = node.attributes || {}; + attributes.checked = node.checkbox === "checked" ? "true" : "false"; + return elem("li", attributes, ...node.children.map(processBlock)); +} + +function processDefinitionList(node: DjotDefinitionList): Element { + return elem( + "dl", + node.attributes, + ...node.children.flatMap(processDefinitionListItem), + ); +} + +function processDefinitionListItem(node: DjotDefinitionListItem): Element[] { + return [ + processTerm(node.children[0]), + processDefinition(node.children[1]), + ]; +} + +function processTerm(node: DjotTerm): Element { + return elem( + "dt", + node.attributes, + ...node.children.map(processInline), + ); +} + +function processDefinition(node: DjotDefinition): Element { + return elem( + "dd", + node.attributes, + ...node.children.map(processBlock), + ); +} + +function processTable(node: DjotTable): Element { + // Tables in Djot have a caption as first child and then rows + // For now, we'll create a basic table structure and ignore caption + const tableElement = elem("table", node.attributes); + + // Process caption if it exists (first child) + if (node.children.length > 0 && node.children[0].tag === "caption") { + const caption = elem( + "caption", + undefined, + ...node.children[0].children.map(processInline), + ); + tableElement.children.push(caption); + } + + // Group rows into thead, tbody based on head property + const headerRows: Element[] = []; + const bodyRows: Element[] = []; + + // Start from index 1 to skip caption + for (let i = 1; i < node.children.length; i++) { + const row = node.children[i]; + if (row.tag === "row") { + const rowElement = elem( + "tr", + row.attributes, + ...row.children.map((cell) => { + const cellAttributes = cell.attributes || {}; + // Set alignment attribute if needed + if (cell.align !== "default") { + cellAttributes.align = cell.align; + } + return elem( + cell.head ? "th" : "td", + cellAttributes, + ...cell.children.map(processInline), + ); + }), + ); + + if (row.head) { + headerRows.push(rowElement); + } else { + bodyRows.push(rowElement); + } + } + } + + // Add thead and tbody if needed + 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 processInline(node: DjotInline): Node { + switch (node.tag) { + case "str": + return processStr(node); + case "soft_break": + return processSoftBreak(node); + case "hard_break": + return processHardBreak(node); + case "verbatim": + return processVerbatim(node); + case "emph": + return processEmph(node); + case "strong": + return processStrong(node); + case "link": + return processLink(node); + case "image": + return processImage(node); + case "mark": + return processMark(node); + case "superscript": + return processSuperscript(node); + case "subscript": + return processSubscript(node); + case "insert": + return processInsert(node); + case "delete": + return processDelete(node); + case "email": + return processEmail(node); + case "footnote_reference": + return processFootnoteReference(node); + case "url": + return processUrl(node); + case "span": + return processSpan(node); + case "inline_math": + return processInlineMath(node); + case "display_math": + return processDisplayMath(node); + case "non_breaking_space": + return processNonBreakingSpace(node); + case "symb": + return processSymb(node); + case "raw_inline": + return processRawInline(node); + case "double_quoted": + return processDoubleQuoted(node); + case "single_quoted": + return processSingleQuoted(node); + case "smart_punctuation": + return processSmartPunctuation(node); + } +} + +function processStr(node: DjotStr): Node { + return text(node.text); +} + +function processSoftBreak(_node: DjotSoftBreak): Node { + return text("\n"); +} + +function processHardBreak(_node: DjotHardBreak): Node { + return elem("br"); +} + +function processVerbatim(node: DjotVerbatim): Element { + return elem("code", node.attributes, text(node.text)); +} + +function processEmph(node: DjotEmph): Element { + return elem( + "em", + node.attributes, + ...node.children.map(processInline), + ); +} + +function processStrong(node: DjotStrong): Element { + return elem( + "strong", + node.attributes, + ...node.children.map(processInline), + ); +} + +function processLink(node: DjotLink): Element { + const attributes = node.attributes || {}; + if (node.destination !== undefined) { + attributes.href = node.destination; + } + return elem("a", attributes, ...node.children.map(processInline)); +} + +function processImage(node: DjotImage): Element { + const attributes = node.attributes || {}; + if (node.destination !== undefined) { + attributes.src = node.destination; + } + + // Alt text is derived from children in Djot + const alt = node.children + .map((child) => { + if (child.tag === "str") { + return child.text; + } + return ""; + }) + .join(""); + + if (alt) { + attributes.alt = alt; + } + + return elem("img", attributes); +} + +function processMark(node: DjotMark): Element { + return elem( + "mark", + node.attributes, + ...node.children.map(processInline), + ); +} + +function processSuperscript(node: DjotSuperscript): Element { + return elem( + "sup", + node.attributes, + ...node.children.map(processInline), + ); +} + +function processSubscript(node: DjotSubscript): Element { + return elem( + "sub", + node.attributes, + ...node.children.map(processInline), + ); +} + +function processInsert(node: DjotInsert): Element { + return elem( + "ins", + node.attributes, + ...node.children.map(processInline), + ); +} + +function processDelete(node: DjotDelete): Element { + return elem( + "del", + node.attributes, + ...node.children.map(processInline), + ); +} + +function processEmail(node: DjotEmail): Element { + return elem("email", node.attributes, text(node.text)); +} + +function processFootnoteReference(node: DjotFootnoteReference): Element { + return elem("footnoteref", { reference: node.text }); +} + +function processUrl(node: DjotUrl): Element { + const e = elem( + "a", + { + href: node.text, + ...node.attributes, + }, + text(node.text), + ); + addClass(e, "url"); + return e; +} + +function processSpan(node: DjotSpan): Element { + return elem( + "span", + node.attributes, + ...node.children.map(processInline), + ); +} + +function processInlineMath(node: DjotInlineMath): Element { + // For inline math, we'll wrap it in a span with a class + return elem( + "span", + { + class: "math inline", + ...node.attributes, + }, + text(node.text), + ); +} + +function processDisplayMath(node: DjotDisplayMath): Element { + // For display math, we'll wrap it in a div with a class + return elem( + "div", + { + class: "math display", + ...node.attributes, + }, + text(node.text), + ); +} + +function processNonBreakingSpace(_node: DjotNonBreakingSpace): Node { + return text("\u00A0"); // Unicode non-breaking space +} + +function processSymb(node: DjotSymb): Node { + // Map symbol aliases to their Unicode characters + const symbolMap: Record<string, string> = { + "->": "→", + "<-": "←", + "<->": "↔", + "=>": "⇒", + "<=": "⇐", + "<=>": "⇔", + "--": "–", // en dash + "---": "—", // em dash + "...": "…", // ellipsis + // Add more symbol mappings as needed + }; + + const symbolText = symbolMap[node.alias] || node.alias; + + return text(symbolText); +} + +function processRawInline(node: DjotRawInline): Node { + // If the format is HTML, return as raw HTML + if (node.format === "html" || node.format === "HTML") { + return rawHTML(node.text); + } + + // For other formats, just return as text + return text(node.text); +} + +function processDoubleQuoted(node: DjotDoubleQuoted): Node { + const children = node.children.map(processInline); + const attributes = node.attributes || {}; + + if ( + children.length === 1 && children[0].kind === "text" && + Object.keys(attributes).length === 0 + ) { + const content = children[0].content; + return text(`\u201C${content}\u201D`); + } else { + return elem("span", node.attributes, ...children); + } +} + +function processSingleQuoted(node: DjotSingleQuoted): Node { + const children = node.children.map(processInline); + const attributes = node.attributes || {}; + + if ( + children.length === 1 && children[0].kind === "text" && + Object.keys(attributes).length === 0 + ) { + const content = children[0].content; + return text(`\u2018${content}\u2019`); + } else { + return elem("span", node.attributes, ...children); + } +} + +function processSmartPunctuation(node: DjotSmartPunctuation): Node { + // Map smart punctuation types to Unicode characters + const punctuationMap: Record<string, string> = { + left_single_quote: "\u2018", // ' + right_single_quote: "\u2019", // ' + left_double_quote: "\u201C", // " + right_double_quote: "\u201D", // " + ellipses: "\u2026", // … + em_dash: "\u2014", // — + en_dash: "\u2013", // – + }; + + return text(punctuationMap[node.type] || node.text); +} + +function processDiv(node: DjotDiv): Element { + if (node.attributes?.class === "note") { + delete node.attributes.class; + return elem( + "note", + node.attributes, + ...node.children.map(processBlock), + ); + } + + if (node.attributes?.class === "edit") { + delete node.attributes.class; + return elem( + "note", + node.attributes, + ...node.children.map(processBlock), + ); + } + + return elem( + "div", + node.attributes, + ...node.children.map(processBlock), + ); +} + +function processRawBlock(node: DjotRawBlock): Element { + // If the format is HTML, wrap the HTML content in a div + if (node.format === "html" || node.format === "HTML") { + return elem("div", { class: "raw-html" }, rawHTML(node.text)); + } + + // For other formats, wrap in a pre tag + return elem("pre", { "data-format": node.format }, text(node.text)); +} + +export function djot2ndoc(doc: DjotDoc): Element { + const children: Node[] = []; + for (const child of doc.children) { + children.push(processBlock(child)); + } + + // Process footnotes if any exist + if (doc.footnotes && Object.keys(doc.footnotes).length > 0) { + const footnoteSection = elem("section", { class: "footnotes" }); + + for (const [id, footnote] of Object.entries(doc.footnotes)) { + const footnoteElement = elem( + "footnote", + { id }, + ...footnote.children.map(processBlock), + ); + footnoteSection.children.push(footnoteElement); + } + + children.push(footnoteSection); + } + + return elem("__root__", undefined, elem("article", undefined, ...children)); +} diff --git a/services/nuldoc/nuldoc-src/djot/document.ts b/services/nuldoc/nuldoc-src/djot/document.ts new file mode 100644 index 0000000..3e8cd92 --- /dev/null +++ b/services/nuldoc/nuldoc-src/djot/document.ts @@ -0,0 +1,75 @@ +import { Doc as DjotDoc } from "@djot/djot"; +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 { djot2ndoc } from "./djot2ndoc.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; // TODO: should it be markup text? + tags: string[]; + revisions: Revision[]; + toc?: TocRoot; + isTocEnabled: boolean; +}; + +export function createNewDocumentFromDjotDocument( + root: DjotDoc, + 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: djot2ndoc(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/djot/parse.ts b/services/nuldoc/nuldoc-src/djot/parse.ts new file mode 100644 index 0000000..c79a670 --- /dev/null +++ b/services/nuldoc/nuldoc-src/djot/parse.ts @@ -0,0 +1,33 @@ +import { parse as parseDjot } from "@djot/djot"; +import { parse as parseToml } from "@std/toml"; +import { Config } from "../config.ts"; +import { + createNewDocumentFromDjotDocument, + Document, + PostMetadata, + PostMetadataSchema, +} from "./document.ts"; +import toHtml from "./to_html.ts"; + +export async function parseDjotFile( + filePath: string, + config: Config, +): Promise<Document> { + try { + const fileContent = await Deno.readTextFile(filePath); + const [, frontmatter, ...rest] = fileContent.split(/^---$/m); + const meta = parseMetadata(frontmatter); + const root = parseDjot(rest.join("\n")); + const doc = createNewDocumentFromDjotDocument(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/djot/to_html.ts b/services/nuldoc/nuldoc-src/djot/to_html.ts new file mode 100644 index 0000000..8219b74 --- /dev/null +++ b/services/nuldoc/nuldoc-src/djot/to_html.ts @@ -0,0 +1,499 @@ +import { BundledLanguage, bundledLanguages, codeToHtml } from "shiki"; +import { Document, TocEntry } from "./document.ts"; +import { NuldocError } from "../errors.ts"; +import { + addClass, + elem, + 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(elem("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 = elem("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 = elem( + "div", + { class: "admonition-label" }, + text(isEditBlock ? `${editatAttr} ${operationAttr}` : "NOTE"), + ); + const contentElement = elem( + "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 = [ + elem( + "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 = [ + elem( + "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 = [ + elem("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; + } + }); +} |
