aboutsummaryrefslogtreecommitdiffhomepage
path: root/services/nuldoc/nuldoc-src
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2025-11-27 05:05:04 +0900
committernsfisis <nsfisis@gmail.com>2025-11-27 06:07:46 +0900
commitc754d24b162ecd504f3c4bdd8632045dd0398768 (patch)
tree362323710bb329ad609d379df4e4a429e4229fd2 /services/nuldoc/nuldoc-src
parentd1014de68415df8f0a5dc3389332e086119c6198 (diff)
downloadnsfisis.dev-c754d24b162ecd504f3c4bdd8632045dd0398768.tar.gz
nsfisis.dev-c754d24b162ecd504f3c4bdd8632045dd0398768.tar.zst
nsfisis.dev-c754d24b162ecd504f3c4bdd8632045dd0398768.zip
feat(nuldoc): Djot to Markdown
Diffstat (limited to 'services/nuldoc/nuldoc-src')
-rw-r--r--services/nuldoc/nuldoc-src/commands/build.ts14
-rw-r--r--services/nuldoc/nuldoc-src/components/TableOfContents.ts2
-rw-r--r--services/nuldoc/nuldoc-src/djot/djot2ndoc.ts604
-rw-r--r--services/nuldoc/nuldoc-src/generators/post.ts4
-rw-r--r--services/nuldoc/nuldoc-src/markdown/document.ts (renamed from services/nuldoc/nuldoc-src/djot/document.ts)12
-rw-r--r--services/nuldoc/nuldoc-src/markdown/mdast2ndoc.ts575
-rw-r--r--services/nuldoc/nuldoc-src/markdown/parse.ts (renamed from services/nuldoc/nuldoc-src/djot/parse.ts)24
-rw-r--r--services/nuldoc/nuldoc-src/markdown/to_html.ts (renamed from services/nuldoc/nuldoc-src/djot/to_html.ts)0
-rw-r--r--services/nuldoc/nuldoc-src/pages/PostPage.ts2
9 files changed, 611 insertions, 626 deletions
diff --git a/services/nuldoc/nuldoc-src/commands/build.ts b/services/nuldoc/nuldoc-src/commands/build.ts
index 5efff55..2b67d2b 100644
--- a/services/nuldoc/nuldoc-src/commands/build.ts
+++ b/services/nuldoc/nuldoc-src/commands/build.ts
@@ -2,7 +2,7 @@ import { dirname, join, joinGlobs, relative } from "@std/path";
import { ensureDir, expandGlob } from "@std/fs";
import { generateFeedPageFromEntries } from "../generators/atom.ts";
import { Config, getTagLabel } from "../config.ts";
-import { parseDjotFile } from "../djot/parse.ts";
+import { parseMarkdownFile } from "../markdown/parse.ts";
import { Page } from "../page.ts";
import { render } from "../render.ts";
import { dateToString } from "../revision.ts";
@@ -55,7 +55,7 @@ async function buildPostPages(config: Config): Promise<PostPage[]> {
async function collectPostFiles(sourceDir: string): Promise<string[]> {
const filePaths = [];
- const globPattern = joinGlobs([sourceDir, "**", "*.dj"]);
+ const globPattern = joinGlobs([sourceDir, "**", "*.md"]);
for await (const entry of expandGlob(globPattern)) {
filePaths.push(entry.path);
}
@@ -69,7 +69,7 @@ async function parsePosts(
const posts = [];
for (const postFile of postFiles) {
posts.push(
- await generatePostPage(await parseDjotFile(postFile, config), config),
+ await generatePostPage(await parseMarkdownFile(postFile, config), config),
);
}
return posts;
@@ -265,9 +265,9 @@ async function copyBlogAssetFiles(config: Config) {
for await (const { isFile, path } of expandGlob(globPattern)) {
if (!isFile) continue;
- // Skip .dj, .toml, .pdf files
+ // Skip .md, .toml, .pdf files
if (
- path.endsWith(".dj") ||
+ path.endsWith(".md") ||
path.endsWith(".toml") ||
path.endsWith(".pdf")
) {
@@ -290,9 +290,9 @@ async function copySlidesAssetFiles(config: Config) {
for await (const { isFile, path } of expandGlob(globPattern)) {
if (!isFile) continue;
- // Skip .dj, .toml, .pdf files
+ // Skip .md, .toml, .pdf files
if (
- path.endsWith(".dj") ||
+ path.endsWith(".md") ||
path.endsWith(".toml") ||
path.endsWith(".pdf")
) {
diff --git a/services/nuldoc/nuldoc-src/components/TableOfContents.ts b/services/nuldoc/nuldoc-src/components/TableOfContents.ts
index ac4205a..37796ff 100644
--- a/services/nuldoc/nuldoc-src/components/TableOfContents.ts
+++ b/services/nuldoc/nuldoc-src/components/TableOfContents.ts
@@ -1,4 +1,4 @@
-import { TocEntry, TocRoot } from "../djot/document.ts";
+import { TocEntry, TocRoot } from "../markdown/document.ts";
import { elem, Element } from "../dom.ts";
type Props = {
diff --git a/services/nuldoc/nuldoc-src/djot/djot2ndoc.ts b/services/nuldoc/nuldoc-src/djot/djot2ndoc.ts
deleted file mode 100644
index 627e8d6..0000000
--- a/services/nuldoc/nuldoc-src/djot/djot2ndoc.ts
+++ /dev/null
@@ -1,604 +0,0 @@
-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/generators/post.ts b/services/nuldoc/nuldoc-src/generators/post.ts
index 11a3ce8..2f466b9 100644
--- a/services/nuldoc/nuldoc-src/generators/post.ts
+++ b/services/nuldoc/nuldoc-src/generators/post.ts
@@ -1,7 +1,7 @@
import { join } from "@std/path";
import PostPage from "../pages/PostPage.ts";
import { Config } from "../config.ts";
-import { Document } from "../djot/document.ts";
+import { Document } from "../markdown/document.ts";
import { Page } from "../page.ts";
import { Date, Revision } from "../revision.ts";
@@ -41,7 +41,7 @@ export async function generatePostPage(
const cwd = Deno.cwd();
const contentDir = join(cwd, config.locations.contentDir);
const destFilePath = join(
- doc.sourceFilePath.replace(contentDir, "").replace(".dj", ""),
+ doc.sourceFilePath.replace(contentDir, "").replace(".md", ""),
"index.html",
);
return {
diff --git a/services/nuldoc/nuldoc-src/djot/document.ts b/services/nuldoc/nuldoc-src/markdown/document.ts
index 3e8cd92..1aee87b 100644
--- a/services/nuldoc/nuldoc-src/djot/document.ts
+++ b/services/nuldoc/nuldoc-src/markdown/document.ts
@@ -1,10 +1,10 @@
-import { Doc as DjotDoc } from "@djot/djot";
+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 { djot2ndoc } from "./djot2ndoc.ts";
+import { mdast2ndoc } from "./mdast2ndoc.ts";
export const PostMetadataSchema = z.object({
article: z.object({
@@ -40,15 +40,15 @@ export type Document = {
uuid: string;
link: string;
title: string;
- description: string; // TODO: should it be markup text?
+ description: string;
tags: string[];
revisions: Revision[];
toc?: TocRoot;
isTocEnabled: boolean;
};
-export function createNewDocumentFromDjotDocument(
- root: DjotDoc,
+export function createNewDocumentFromMdast(
+ root: MdastRoot,
meta: PostMetadata,
sourceFilePath: string,
config: Config,
@@ -57,7 +57,7 @@ export function createNewDocumentFromDjotDocument(
const contentDir = join(cwd, config.locations.contentDir);
const link = sourceFilePath.replace(contentDir, "").replace(".xml", "/");
return {
- root: djot2ndoc(root),
+ root: mdast2ndoc(root),
sourceFilePath,
uuid: meta.article.uuid,
link: link,
diff --git a/services/nuldoc/nuldoc-src/markdown/mdast2ndoc.ts b/services/nuldoc/nuldoc-src/markdown/mdast2ndoc.ts
new file mode 100644
index 0000000..367627c
--- /dev/null
+++ b/services/nuldoc/nuldoc-src/markdown/mdast2ndoc.ts
@@ -0,0 +1,575 @@
+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 { elem, Element, Node, rawHTML, text } 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 elem("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 elem(node.ordered ? "ol" : "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 elem("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 elem("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 elem(
+ "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 elem(
+ "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 elem("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 elem("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 = elem(
+ "section",
+ { class: "footnotes" },
+ ...footnotes,
+ );
+ articleContent.push(footnoteSection);
+ }
+
+ return elem(
+ "__root__",
+ undefined,
+ elem("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 elem(
+ "section",
+ attributes,
+ sectionInfo.heading,
+ ...sectionInfo.children,
+ );
+}
diff --git a/services/nuldoc/nuldoc-src/djot/parse.ts b/services/nuldoc/nuldoc-src/markdown/parse.ts
index c79a670..c0875a2 100644
--- a/services/nuldoc/nuldoc-src/djot/parse.ts
+++ b/services/nuldoc/nuldoc-src/markdown/parse.ts
@@ -1,15 +1,20 @@
-import { parse as parseDjot } from "@djot/djot";
+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 {
- createNewDocumentFromDjotDocument,
+ createNewDocumentFromMdast,
Document,
PostMetadata,
PostMetadataSchema,
} from "./document.ts";
import toHtml from "./to_html.ts";
-export async function parseDjotFile(
+export async function parseMarkdownFile(
filePath: string,
config: Config,
): Promise<Document> {
@@ -17,8 +22,17 @@ export async function parseDjotFile(
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);
+ 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) {
diff --git a/services/nuldoc/nuldoc-src/djot/to_html.ts b/services/nuldoc/nuldoc-src/markdown/to_html.ts
index 8219b74..8219b74 100644
--- a/services/nuldoc/nuldoc-src/djot/to_html.ts
+++ b/services/nuldoc/nuldoc-src/markdown/to_html.ts
diff --git a/services/nuldoc/nuldoc-src/pages/PostPage.ts b/services/nuldoc/nuldoc-src/pages/PostPage.ts
index 84f58c3..fe67089 100644
--- a/services/nuldoc/nuldoc-src/pages/PostPage.ts
+++ b/services/nuldoc/nuldoc-src/pages/PostPage.ts
@@ -4,7 +4,7 @@ import PageLayout from "../components/PageLayout.ts";
import TableOfContents from "../components/TableOfContents.ts";
import { Config, getTagLabel } from "../config.ts";
import { elem, Element } from "../dom.ts";
-import { Document } from "../djot/document.ts";
+import { Document } from "../markdown/document.ts";
import { dateToString } from "../revision.ts";
import { getPostPublishedDate } from "../generators/post.ts";