aboutsummaryrefslogtreecommitdiffhomepage
path: root/services/nuldoc/nuldoc-src
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2025-11-02 17:49:34 +0900
committernsfisis <nsfisis@gmail.com>2025-11-02 17:49:34 +0900
commit57315c52be96d2a2c013f0cfb0de5429980e301a (patch)
tree5d691497772fddfe401cd970ead4e9a74b34ef38 /services/nuldoc/nuldoc-src
parentcf4091a93ed831456e8b30e2a9e1fc2650dcae02 (diff)
downloadnsfisis.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')
-rw-r--r--services/nuldoc/nuldoc-src/commands/build.ts286
-rw-r--r--services/nuldoc/nuldoc-src/commands/new.ts97
-rw-r--r--services/nuldoc/nuldoc-src/commands/serve.ts52
-rw-r--r--services/nuldoc/nuldoc-src/components/GlobalFooter.tsx9
-rw-r--r--services/nuldoc/nuldoc-src/components/GlobalHeader.tsx27
-rw-r--r--services/nuldoc/nuldoc-src/components/PageLayout.tsx63
-rw-r--r--services/nuldoc/nuldoc-src/components/Pagination.tsx104
-rw-r--r--services/nuldoc/nuldoc-src/components/PostPageEntry.tsx46
-rw-r--r--services/nuldoc/nuldoc-src/components/SlidePageEntry.tsx46
-rw-r--r--services/nuldoc/nuldoc-src/components/StaticScript.tsx18
-rw-r--r--services/nuldoc/nuldoc-src/components/StaticStylesheet.tsx11
-rw-r--r--services/nuldoc/nuldoc-src/components/TableOfContents.tsx33
-rw-r--r--services/nuldoc/nuldoc-src/components/TagList.tsx18
-rw-r--r--services/nuldoc/nuldoc-src/components/utils.ts8
-rw-r--r--services/nuldoc/nuldoc-src/config.ts41
-rw-r--r--services/nuldoc/nuldoc-src/djot/djot2ndoc.ts604
-rw-r--r--services/nuldoc/nuldoc-src/djot/document.ts75
-rw-r--r--services/nuldoc/nuldoc-src/djot/parse.ts33
-rw-r--r--services/nuldoc/nuldoc-src/djot/to_html.ts499
-rw-r--r--services/nuldoc/nuldoc-src/dom.ts154
-rw-r--r--services/nuldoc/nuldoc-src/errors.ts17
-rw-r--r--services/nuldoc/nuldoc-src/generators/about.ts23
-rw-r--r--services/nuldoc/nuldoc-src/generators/atom.ts79
-rw-r--r--services/nuldoc/nuldoc-src/generators/home.ts19
-rw-r--r--services/nuldoc/nuldoc-src/generators/not_found.ts21
-rw-r--r--services/nuldoc/nuldoc-src/generators/post.ts63
-rw-r--r--services/nuldoc/nuldoc-src/generators/post_list.ts63
-rw-r--r--services/nuldoc/nuldoc-src/generators/slide.ts53
-rw-r--r--services/nuldoc/nuldoc-src/generators/slide_list.ts23
-rw-r--r--services/nuldoc/nuldoc-src/generators/tag.ts33
-rw-r--r--services/nuldoc/nuldoc-src/generators/tag_list.ts23
-rw-r--r--services/nuldoc/nuldoc-src/generators/tagged_page.ts4
-rw-r--r--services/nuldoc/nuldoc-src/jsx/jsx-runtime.ts27
-rw-r--r--services/nuldoc/nuldoc-src/jsx/render.ts41
-rw-r--r--services/nuldoc/nuldoc-src/jsx/types.d.ts84
-rw-r--r--services/nuldoc/nuldoc-src/main.ts19
-rw-r--r--services/nuldoc/nuldoc-src/page.ts9
-rw-r--r--services/nuldoc/nuldoc-src/pages/AboutPage.tsx110
-rw-r--r--services/nuldoc/nuldoc-src/pages/AtomPage.tsx26
-rw-r--r--services/nuldoc/nuldoc-src/pages/HomePage.tsx53
-rw-r--r--services/nuldoc/nuldoc-src/pages/NotFoundPage.tsx27
-rw-r--r--services/nuldoc/nuldoc-src/pages/PostListPage.tsx56
-rw-r--r--services/nuldoc/nuldoc-src/pages/PostPage.tsx70
-rw-r--r--services/nuldoc/nuldoc-src/pages/SlideListPage.tsx44
-rw-r--r--services/nuldoc/nuldoc-src/pages/SlidePage.tsx72
-rw-r--r--services/nuldoc/nuldoc-src/pages/TagListPage.tsx57
-rw-r--r--services/nuldoc/nuldoc-src/pages/TagPage.tsx43
-rw-r--r--services/nuldoc/nuldoc-src/render.ts13
-rw-r--r--services/nuldoc/nuldoc-src/renderers/html.ts308
-rw-r--r--services/nuldoc/nuldoc-src/renderers/xml.ts128
-rw-r--r--services/nuldoc/nuldoc-src/revision.ts37
-rw-r--r--services/nuldoc/nuldoc-src/slide/parse.ts20
-rw-r--r--services/nuldoc/nuldoc-src/slide/slide.ts67
53 files changed, 3956 insertions, 0 deletions
diff --git a/services/nuldoc/nuldoc-src/commands/build.ts b/services/nuldoc/nuldoc-src/commands/build.ts
new file mode 100644
index 0000000..8c8de8f
--- /dev/null
+++ b/services/nuldoc/nuldoc-src/commands/build.ts
@@ -0,0 +1,286 @@
+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 { Page } from "../page.ts";
+import { render } from "../render.ts";
+import { dateToString } from "../revision.ts";
+import { generateAboutPage } from "../generators/about.ts";
+import { generateHomePage } from "../generators/home.ts";
+import { generateNotFoundPage } from "../generators/not_found.ts";
+import {
+ generatePostPage,
+ getPostPublishedDate,
+ PostPage,
+} from "../generators/post.ts";
+import { generatePostListPages } from "../generators/post_list.ts";
+import { generateSlidePage, SlidePage } from "../generators/slide.ts";
+import { generateSlideListPage } from "../generators/slide_list.ts";
+import { generateTagPage, TagPage } from "../generators/tag.ts";
+import { TaggedPage } from "../generators/tagged_page.ts";
+import { generateTagListPage } from "../generators/tag_list.ts";
+import { parseSlideFile } from "../slide/parse.ts";
+
+export async function runBuildCommand(config: Config) {
+ const posts = await buildPostPages(config);
+ await buildPostListPage(posts, config);
+ const slides = await buildSlidePages(config);
+ await buildSlideListPage(slides, config);
+ const tags = await buildTagPages(posts, slides, config);
+ await buildTagListPage(tags, config);
+ await buildHomePage(config);
+ await buildAboutPage(slides, config);
+ await buildNotFoundPage(config);
+ await buildFeedOfAllContents(posts, slides, config);
+ await copyStaticFiles(config);
+ await copySlidesFiles(slides, config);
+ await copyAssetFiles(config);
+}
+
+async function buildPostPages(config: Config): Promise<PostPage[]> {
+ const sourceDir = join(Deno.cwd(), config.locations.contentDir, "posts");
+ const postFiles = await collectPostFiles(sourceDir);
+ const posts = await parsePosts(postFiles, config);
+ for (const post of posts) {
+ await writePage(post, config);
+ }
+ return posts;
+}
+
+async function collectPostFiles(sourceDir: string): Promise<string[]> {
+ const filePaths = [];
+ const globPattern = joinGlobs([sourceDir, "**", "*.dj"]);
+ for await (const entry of expandGlob(globPattern)) {
+ filePaths.push(entry.path);
+ }
+ return filePaths;
+}
+
+async function parsePosts(
+ postFiles: string[],
+ config: Config,
+): Promise<PostPage[]> {
+ const posts = [];
+ for (const postFile of postFiles) {
+ posts.push(
+ await generatePostPage(await parseDjotFile(postFile, config), config),
+ );
+ }
+ return posts;
+}
+
+async function buildPostListPage(posts: PostPage[], config: Config) {
+ // Sort posts by published date (newest first)
+ const sortedPosts = [...posts].sort((a, b) => {
+ const ta = dateToString(getPostPublishedDate(a));
+ const tb = dateToString(getPostPublishedDate(b));
+ if (ta > tb) return -1;
+ if (ta < tb) return 1;
+ return 0;
+ });
+
+ const postListPages = await generatePostListPages(sortedPosts, config);
+ for (const page of postListPages) {
+ await writePage(page, config);
+ }
+
+ const postFeedPage = await generateFeedPageFromEntries(
+ "/posts/",
+ "posts",
+ `投稿一覧|${config.blog.siteName}`,
+ posts,
+ config,
+ );
+ await writePage(postFeedPage, config);
+}
+
+async function buildSlidePages(config: Config): Promise<SlidePage[]> {
+ const sourceDir = join(Deno.cwd(), config.locations.contentDir, "slides");
+ const slideFiles = await collectSlideFiles(sourceDir);
+ const slides = await parseSlides(slideFiles, config);
+ for (const slide of slides) {
+ await writePage(slide, config);
+ }
+ return slides;
+}
+
+async function collectSlideFiles(sourceDir: string): Promise<string[]> {
+ const filePaths = [];
+ const globPattern = joinGlobs([sourceDir, "**", "*.toml"]);
+ for await (const entry of expandGlob(globPattern)) {
+ filePaths.push(entry.path);
+ }
+ return filePaths;
+}
+
+async function parseSlides(
+ slideFiles: string[],
+ config: Config,
+): Promise<SlidePage[]> {
+ const slides = [];
+ for (const slideFile of slideFiles) {
+ slides.push(
+ await generateSlidePage(await parseSlideFile(slideFile), config),
+ );
+ }
+ return slides;
+}
+
+async function buildSlideListPage(slides: SlidePage[], config: Config) {
+ const slideListPage = await generateSlideListPage(slides, config);
+ await writePage(slideListPage, config);
+ const slideFeedPage = await generateFeedPageFromEntries(
+ slideListPage.href,
+ "slides",
+ `スライド一覧|${config.blog.siteName}`,
+ slides,
+ config,
+ );
+ await writePage(slideFeedPage, config);
+}
+
+async function buildHomePage(config: Config) {
+ const homePage = await generateHomePage(config);
+ await writePage(homePage, config);
+}
+
+async function buildAboutPage(slides: SlidePage[], config: Config) {
+ const aboutPage = await generateAboutPage(slides, config);
+ await writePage(aboutPage, config);
+}
+
+async function buildNotFoundPage(config: Config) {
+ const notFoundPage = await generateNotFoundPage(config);
+ await writePage(notFoundPage, config);
+}
+
+async function buildFeedOfAllContents(
+ posts: PostPage[],
+ slides: SlidePage[],
+ config: Config,
+) {
+ const feed = await generateFeedPageFromEntries(
+ "/",
+ "all",
+ config.blog.siteName,
+ [...posts, ...slides],
+ config,
+ );
+ await writePage(feed, config);
+}
+
+async function buildTagPages(
+ posts: PostPage[],
+ slides: SlidePage[],
+ config: Config,
+): Promise<TagPage[]> {
+ const tagsAndPages = collectTags([...posts, ...slides]);
+ const tags = [];
+ for (const [tag, pages] of tagsAndPages) {
+ const tagPage = await generateTagPage(tag, pages, config);
+ await writePage(tagPage, config);
+ const tagFeedPage = await generateFeedPageFromEntries(
+ tagPage.href,
+ `tag-${tag}`,
+ `タグ「${getTagLabel(config, tag)}」一覧|${config.blog.siteName}`,
+ pages,
+ config,
+ );
+ await writePage(tagFeedPage, config);
+ tags.push(tagPage);
+ }
+ return tags;
+}
+
+async function buildTagListPage(tags: TagPage[], config: Config) {
+ const tagListPage = await generateTagListPage(tags, config);
+ await writePage(tagListPage, config);
+}
+
+function collectTags(taggedPages: TaggedPage[]): [string, TaggedPage[]][] {
+ const tagsAndPages = new Map();
+ for (const page of taggedPages) {
+ for (const tag of page.tags) {
+ if (!tagsAndPages.has(tag)) {
+ tagsAndPages.set(tag, []);
+ }
+ tagsAndPages.get(tag).push(page);
+ }
+ }
+
+ const result: [string, TaggedPage[]][] = [];
+ for (const tag of Array.from(tagsAndPages.keys()).sort()) {
+ result.push([
+ tag,
+ tagsAndPages.get(tag).sort((a: TaggedPage, b: TaggedPage) => {
+ const ta = dateToString(getPostPublishedDate(a));
+ const tb = dateToString(getPostPublishedDate(b));
+ if (ta > tb) return -1;
+ if (ta < tb) return 1;
+ return 0;
+ }),
+ ]);
+ }
+ return result;
+}
+
+async function copyStaticFiles(config: Config) {
+ const globPattern = joinGlobs([Deno.cwd(), config.locations.staticDir, "*"]);
+ for await (const entry of expandGlob(globPattern)) {
+ const src = entry.path;
+ const dst = src.replace(
+ config.locations.staticDir,
+ config.locations.destDir,
+ );
+ await Deno.copyFile(src, dst);
+ }
+}
+
+async function copySlidesFiles(slides: SlidePage[], config: Config) {
+ const cwd = Deno.cwd();
+ const contentDir = join(cwd, config.locations.contentDir);
+ const destDir = join(cwd, config.locations.destDir);
+
+ for (const slide of slides) {
+ const src = join(contentDir, slide.slideLink);
+ const dst = join(destDir, slide.slideLink);
+ await ensureDir(dirname(dst));
+ await Deno.copyFile(src, dst);
+ }
+}
+
+async function copyAssetFiles(config: Config) {
+ const cwd = Deno.cwd();
+ const contentDir = join(cwd, config.locations.contentDir);
+ const destDir = join(cwd, config.locations.destDir);
+
+ const globPattern = joinGlobs([contentDir, "**", "*"]);
+ for await (const { isFile, path } of expandGlob(globPattern)) {
+ if (!isFile) continue;
+
+ // Skip .dj, .toml, .pdf files
+ if (
+ path.endsWith(".dj") ||
+ path.endsWith(".toml") ||
+ path.endsWith(".pdf")
+ ) {
+ continue;
+ }
+
+ const src = path;
+ const dst = join(destDir, relative(contentDir, path));
+ await ensureDir(dirname(dst));
+ await Deno.copyFile(src, dst);
+ }
+}
+
+async function writePage(page: Page, config: Config) {
+ const destFilePath = join(
+ Deno.cwd(),
+ config.locations.destDir,
+ page.destFilePath,
+ );
+ await ensureDir(dirname(destFilePath));
+ await Deno.writeTextFile(destFilePath, render(page.root, page.renderer));
+}
diff --git a/services/nuldoc/nuldoc-src/commands/new.ts b/services/nuldoc/nuldoc-src/commands/new.ts
new file mode 100644
index 0000000..651c59e
--- /dev/null
+++ b/services/nuldoc/nuldoc-src/commands/new.ts
@@ -0,0 +1,97 @@
+import { dirname, join } from "@std/path";
+import { ensureDir } from "@std/fs";
+import { parseArgs } from "@std/cli";
+import { Config } from "../config.ts";
+
+export async function runNewCommand(config: Config) {
+ const parsedArgs = parseArgs(Deno.args, {
+ string: ["date"],
+ });
+
+ const type = parsedArgs._[1];
+ if (type !== "post" && type !== "slide") {
+ console.log(`Usage: nuldoc new <type>
+
+<type> must be either "post" or "slide".
+
+OPTIONS:
+ --date <DATE>
+`);
+ Deno.exit(1);
+ }
+
+ const ymd = (() => {
+ if (parsedArgs.date) {
+ return parsedArgs.date;
+ }
+
+ const now = new Date();
+ const y = now.getFullYear();
+ const d = (now.getMonth() + 1).toString().padStart(2, "0");
+ const m = now.getDate().toString().padStart(2, "0");
+ return `${y}-${d}-${m}`;
+ })();
+
+ const destFilePath = join(
+ Deno.cwd(),
+ config.locations.contentDir,
+ getDirPath(type),
+ ymd,
+ getFilename(type),
+ );
+
+ await ensureDir(dirname(destFilePath));
+ await Deno.writeTextFile(destFilePath, getTemplate(type, ymd));
+ console.log(
+ `New file ${
+ destFilePath.replace(Deno.cwd(), "")
+ } was successfully created.`,
+ );
+}
+
+function getFilename(type: "post" | "slide"): string {
+ return type === "post" ? "TODO.dj" : "TODO.toml";
+}
+
+function getDirPath(type: "post" | "slide"): string {
+ return type === "post" ? "posts" : "slides";
+}
+
+function getTemplate(type: "post" | "slide", date: string): string {
+ const uuid = crypto.randomUUID();
+ if (type === "post") {
+ return `---
+[article]
+uuid = "${uuid}"
+title = "TODO"
+description = "TODO"
+tags = [
+ "TODO",
+]
+
+[[article.revisions]]
+date = "${date}"
+remark = "公開"
+---
+{#TODO}
+# TODO
+
+TODO
+`;
+ } else {
+ return `[slide]
+uuid = "${uuid}"
+title = "TODO"
+event = "TODO"
+talkType = "TODO"
+link = "TODO"
+tags = [
+ "TODO",
+]
+
+[[slide.revisions]]
+date = "${date}"
+remark = "登壇"
+`;
+ }
+}
diff --git a/services/nuldoc/nuldoc-src/commands/serve.ts b/services/nuldoc/nuldoc-src/commands/serve.ts
new file mode 100644
index 0000000..6b7d8a0
--- /dev/null
+++ b/services/nuldoc/nuldoc-src/commands/serve.ts
@@ -0,0 +1,52 @@
+import { parseArgs } from "@std/cli";
+import { serveDir, STATUS_CODE, STATUS_TEXT } from "@std/http";
+import { join } from "@std/path";
+import { Config } from "../config.ts";
+import { runBuildCommand } from "./build.ts";
+
+function isResourcePath(pathname: string): boolean {
+ const EXTENSIONS = [
+ ".css",
+ ".gif",
+ ".ico",
+ ".jpeg",
+ ".jpg",
+ ".js",
+ ".mjs",
+ ".png",
+ ".svg",
+ ];
+ return EXTENSIONS.some((ext) => pathname.endsWith(ext));
+}
+
+export function runServeCommand(config: Config) {
+ const parsedArgs = parseArgs(Deno.args, {
+ boolean: ["no-rebuild"],
+ });
+
+ const doRebuild = !parsedArgs["no-rebuild"];
+ const rootDir = join(Deno.cwd(), config.locations.destDir);
+ Deno.serve({ hostname: "127.0.0.1" }, async (req) => {
+ const pathname = new URL(req.url).pathname;
+ if (!isResourcePath(pathname) && doRebuild) {
+ await runBuildCommand(config);
+ console.log("rebuild");
+ }
+ const res = await serveDir(req, {
+ fsRoot: rootDir,
+ showIndex: true,
+ });
+ if (res.status !== STATUS_CODE.NotFound) {
+ return res;
+ }
+
+ const notFoundHtml = await Deno.readTextFile(join(rootDir, "404.html"));
+ return new Response(notFoundHtml, {
+ status: STATUS_CODE.NotFound,
+ statusText: STATUS_TEXT[STATUS_CODE.NotFound],
+ headers: {
+ "content-type": "text/html",
+ },
+ });
+ });
+}
diff --git a/services/nuldoc/nuldoc-src/components/GlobalFooter.tsx b/services/nuldoc/nuldoc-src/components/GlobalFooter.tsx
new file mode 100644
index 0000000..757bece
--- /dev/null
+++ b/services/nuldoc/nuldoc-src/components/GlobalFooter.tsx
@@ -0,0 +1,9 @@
+import { Config } from "../config.ts";
+
+export default function GlobalFooter({ config }: { config: Config }) {
+ return (
+ <footer className="footer">
+ {`&copy; ${config.blog.siteCopyrightYear} ${config.blog.author}`}
+ </footer>
+ );
+}
diff --git a/services/nuldoc/nuldoc-src/components/GlobalHeader.tsx b/services/nuldoc/nuldoc-src/components/GlobalHeader.tsx
new file mode 100644
index 0000000..c0fa7e8
--- /dev/null
+++ b/services/nuldoc/nuldoc-src/components/GlobalHeader.tsx
@@ -0,0 +1,27 @@
+import { Config } from "../config.ts";
+
+export default function GlobalHeader({ config }: { config: Config }) {
+ return (
+ <header className="header">
+ <div className="site-logo">
+ <a href="/">{config.blog.siteName}</a>
+ </div>
+ <nav className="nav">
+ <ul>
+ <li>
+ <a href="/about/">About</a>
+ </li>
+ <li>
+ <a href="/posts/">Posts</a>
+ </li>
+ <li>
+ <a href="/slides/">Slides</a>
+ </li>
+ <li>
+ <a href="/tags/">Tags</a>
+ </li>
+ </ul>
+ </nav>
+ </header>
+ );
+}
diff --git a/services/nuldoc/nuldoc-src/components/PageLayout.tsx b/services/nuldoc/nuldoc-src/components/PageLayout.tsx
new file mode 100644
index 0000000..78a5cde
--- /dev/null
+++ b/services/nuldoc/nuldoc-src/components/PageLayout.tsx
@@ -0,0 +1,63 @@
+import { Config } from "../config.ts";
+import { JSXNode } from "myjsx/jsx-runtime";
+import StaticStylesheet from "./StaticStylesheet.tsx";
+
+type Props = {
+ metaCopyrightYear: number;
+ metaDescription: string;
+ metaKeywords?: string[];
+ metaTitle: string;
+ metaAtomFeedHref?: string;
+ requiresSyntaxHighlight?: boolean;
+ config: Config;
+ children: JSXNode;
+};
+
+export default function PageLayout(
+ {
+ metaCopyrightYear,
+ metaDescription,
+ metaKeywords,
+ metaTitle,
+ metaAtomFeedHref,
+ requiresSyntaxHighlight: _,
+ config,
+ children,
+ }: Props,
+) {
+ return (
+ <html lang="ja-JP">
+ <head>
+ <meta charset="UTF-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <meta name="author" content={config.blog.author} />
+ <meta
+ name="copyright"
+ content={`&copy; ${metaCopyrightYear} ${config.blog.author}`}
+ />
+ <meta name="description" content={metaDescription} />
+ {metaKeywords && metaKeywords.length !== 0 &&
+ <meta name="keywords" content={metaKeywords.join(",")} />}
+ <meta property="og:type" content="article" />
+ <meta property="og:title" content={metaTitle} />
+ <meta property="og:description" content={metaDescription} />
+ <meta property="og:site_name" content={config.blog.siteName} />
+ <meta property="og:locale" content="ja_JP" />
+ {/* https://b.hatena.ne.jp/help/entry/nocomment */}
+ <meta name="Hatena::Bookmark" content="nocomment" />
+ {metaAtomFeedHref &&
+ (
+ <link
+ rel="alternate"
+ href={metaAtomFeedHref}
+ type="application/atom+xml"
+ />
+ )}
+ <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
+ <title>{metaTitle}</title>
+ <StaticStylesheet fileName="/style.css" config={config} />
+ </head>
+ {children}
+ </html>
+ );
+}
diff --git a/services/nuldoc/nuldoc-src/components/Pagination.tsx b/services/nuldoc/nuldoc-src/components/Pagination.tsx
new file mode 100644
index 0000000..84752c5
--- /dev/null
+++ b/services/nuldoc/nuldoc-src/components/Pagination.tsx
@@ -0,0 +1,104 @@
+type Props = {
+ currentPage: number;
+ totalPages: number;
+ basePath: string;
+};
+
+export default function Pagination(
+ { currentPage, totalPages, basePath }: Props,
+) {
+ if (totalPages <= 1) {
+ return <div></div>;
+ }
+
+ const pages = generatePageNumbers(currentPage, totalPages);
+
+ return (
+ <nav className="pagination">
+ <div className="pagination-prev">
+ {currentPage > 1
+ ? (
+ <a href={pageUrlAt(basePath, currentPage - 1)}>
+ 前へ
+ </a>
+ )
+ : null}
+ </div>
+ {pages.map((page) => {
+ if (page === "...") {
+ return (
+ <div className="pagination-elipsis">
+ …
+ </div>
+ );
+ } else if (page === currentPage) {
+ return (
+ <div className="pagination-page pagination-page-current">
+ <span>{String(page)}</span>
+ </div>
+ );
+ } else {
+ return (
+ <div className="pagination-page">
+ <a href={pageUrlAt(basePath, page)}>{String(page)}</a>
+ </div>
+ );
+ }
+ })}
+ <div className="pagination-next">
+ {currentPage < totalPages
+ ? (
+ <a href={pageUrlAt(basePath, currentPage + 1)}>
+ 次へ
+ </a>
+ )
+ : null}
+ </div>
+ </nav>
+ );
+}
+
+type PageItem = number | "...";
+
+/**
+ * Generates page numbers for pagination display.
+ *
+ * - Always show the first page
+ * - Always show the last page
+ * - Always show the current page
+ * - Always show the page before and after the current page
+ * - If there's only one page gap between displayed pages, fill it
+ * - If there are two or more pages gap between displayed pages, show ellipsis
+ */
+function generatePageNumbers(
+ currentPage: number,
+ totalPages: number,
+): PageItem[] {
+ const pages = new Set<number>();
+ pages.add(1);
+ pages.add(Math.max(1, currentPage - 1));
+ pages.add(currentPage);
+ pages.add(Math.min(totalPages, currentPage + 1));
+ pages.add(totalPages);
+
+ const sorted = Array.from(pages).sort((a, b) => a - b);
+
+ const result: PageItem[] = [];
+ for (let i = 0; i < sorted.length; i++) {
+ if (i > 0) {
+ const gap = sorted[i] - sorted[i - 1];
+ if (gap === 2) {
+ result.push(sorted[i - 1] + 1);
+ } else if (gap > 2) {
+ result.push("...");
+ }
+ }
+ result.push(sorted[i]);
+ }
+
+ return result;
+}
+
+function pageUrlAt(basePath: string, page: number): string {
+ return page === 1 ? basePath : `${basePath}${page}/`;
+}
diff --git a/services/nuldoc/nuldoc-src/components/PostPageEntry.tsx b/services/nuldoc/nuldoc-src/components/PostPageEntry.tsx
new file mode 100644
index 0000000..23ca88a
--- /dev/null
+++ b/services/nuldoc/nuldoc-src/components/PostPageEntry.tsx
@@ -0,0 +1,46 @@
+import {
+ getPostPublishedDate,
+ getPostUpdatedDate,
+ postHasAnyUpdates,
+ PostPage,
+} from "../generators/post.ts";
+import { dateToString } from "../revision.ts";
+import { Config } from "../config.ts";
+import TagList from "./TagList.tsx";
+
+type Props = { post: PostPage; config: Config };
+
+export default function PostPageEntry({ post, config }: Props) {
+ return (
+ <article className="post-entry">
+ <a href={post.href}>
+ <header className="entry-header">
+ <h2>{post.title}</h2>
+ </header>
+ <section className="entry-content">
+ <p>{post.description}</p>
+ </section>
+ <footer className="entry-footer">
+ <time datetime={dateToString(getPostPublishedDate(post))}>
+ {dateToString(getPostPublishedDate(post))}
+ </time>
+ {" 投稿"}
+ {
+ // TODO(jsx): support Fragment and merge them.
+ postHasAnyUpdates(post) && "、"
+ }
+ {postHasAnyUpdates(post) &&
+ (
+ <time datetime={dateToString(getPostUpdatedDate(post))}>
+ {dateToString(getPostUpdatedDate(post))}
+ </time>
+ )}
+ {postHasAnyUpdates(post) && " 更新"}
+ {post.tags.length !== 0 && (
+ <TagList tags={post.tags} config={config} />
+ )}
+ </footer>
+ </a>
+ </article>
+ );
+}
diff --git a/services/nuldoc/nuldoc-src/components/SlidePageEntry.tsx b/services/nuldoc/nuldoc-src/components/SlidePageEntry.tsx
new file mode 100644
index 0000000..2401765
--- /dev/null
+++ b/services/nuldoc/nuldoc-src/components/SlidePageEntry.tsx
@@ -0,0 +1,46 @@
+import {
+ getPostPublishedDate,
+ getPostUpdatedDate,
+ postHasAnyUpdates,
+} from "../generators/post.ts";
+import { SlidePage } from "../generators/slide.ts";
+import { dateToString } from "../revision.ts";
+import { Config } from "../config.ts";
+import TagList from "./TagList.tsx";
+
+type Props = { slide: SlidePage; config: Config };
+
+export default function SlidePageEntry({ slide, config }: Props) {
+ return (
+ <article className="post-entry">
+ <a href={slide.href}>
+ <header className="entry-header">
+ <h2>{slide.description}</h2>
+ </header>
+ <section className="entry-content">
+ <p>{slide.title}</p>
+ </section>
+ <footer className="entry-footer">
+ <time datetime={dateToString(getPostPublishedDate(slide))}>
+ {dateToString(getPostPublishedDate(slide))}
+ </time>
+ {" 登壇"}
+ {
+ // TODO(jsx): support Fragment and merge them.
+ postHasAnyUpdates(slide) && "、"
+ }
+ {postHasAnyUpdates(slide) &&
+ (
+ <time datetime={dateToString(getPostUpdatedDate(slide))}>
+ {dateToString(getPostUpdatedDate(slide))}
+ </time>
+ )}
+ {postHasAnyUpdates(slide) && " 更新"}
+ {slide.tags.length !== 0 && (
+ <TagList tags={slide.tags} config={config} />
+ )}
+ </footer>
+ </a>
+ </article>
+ );
+}
diff --git a/services/nuldoc/nuldoc-src/components/StaticScript.tsx b/services/nuldoc/nuldoc-src/components/StaticScript.tsx
new file mode 100644
index 0000000..0e3ab19
--- /dev/null
+++ b/services/nuldoc/nuldoc-src/components/StaticScript.tsx
@@ -0,0 +1,18 @@
+import { join } from "@std/path";
+import { Config } from "../config.ts";
+import { calculateFileHash } from "./utils.ts";
+
+export default async function StaticScript(
+ { fileName, type, defer, config }: {
+ fileName: string;
+ type?: string;
+ defer?: "true";
+ config: Config;
+ },
+) {
+ const filePath = join(Deno.cwd(), config.locations.staticDir, fileName);
+ const hash = await calculateFileHash(filePath);
+ return (
+ <script src={`${fileName}?h=${hash}`} type={type} defer={defer}></script>
+ );
+}
diff --git a/services/nuldoc/nuldoc-src/components/StaticStylesheet.tsx b/services/nuldoc/nuldoc-src/components/StaticStylesheet.tsx
new file mode 100644
index 0000000..52b695e
--- /dev/null
+++ b/services/nuldoc/nuldoc-src/components/StaticStylesheet.tsx
@@ -0,0 +1,11 @@
+import { join } from "@std/path";
+import { Config } from "../config.ts";
+import { calculateFileHash } from "./utils.ts";
+
+export default async function StaticStylesheet(
+ { fileName, config }: { fileName: string; config: Config },
+) {
+ const filePath = join(Deno.cwd(), config.locations.staticDir, fileName);
+ const hash = await calculateFileHash(filePath);
+ return <link rel="stylesheet" href={`${fileName}?h=${hash}`} />;
+}
diff --git a/services/nuldoc/nuldoc-src/components/TableOfContents.tsx b/services/nuldoc/nuldoc-src/components/TableOfContents.tsx
new file mode 100644
index 0000000..29907d0
--- /dev/null
+++ b/services/nuldoc/nuldoc-src/components/TableOfContents.tsx
@@ -0,0 +1,33 @@
+import { TocEntry, TocRoot } from "../djot/document.ts";
+
+type Props = {
+ toc: TocRoot;
+};
+
+export default function TableOfContents({ toc }: Props) {
+ return (
+ <nav className="toc">
+ <h2>目次</h2>
+ <ul>
+ {toc.entries.map((entry, index) => (
+ <TocEntryComponent key={String(index)} entry={entry} />
+ ))}
+ </ul>
+ </nav>
+ );
+}
+
+function TocEntryComponent({ entry }: { entry: TocEntry }) {
+ return (
+ <li>
+ <a href={`#${entry.id}`}>{entry.text}</a>
+ {entry.children.length > 0 && (
+ <ul>
+ {entry.children.map((child, index) => (
+ <TocEntryComponent key={String(index)} entry={child} />
+ ))}
+ </ul>
+ )}
+ </li>
+ );
+}
diff --git a/services/nuldoc/nuldoc-src/components/TagList.tsx b/services/nuldoc/nuldoc-src/components/TagList.tsx
new file mode 100644
index 0000000..86ee70b
--- /dev/null
+++ b/services/nuldoc/nuldoc-src/components/TagList.tsx
@@ -0,0 +1,18 @@
+import { Config, getTagLabel } from "../config.ts";
+
+type Props = {
+ tags: string[];
+ config: Config;
+};
+
+export default function TagList({ tags, config }: Props) {
+ return (
+ <ul className="entry-tags">
+ {tags.map((slug) => (
+ <li className="tag" key={slug}>
+ {getTagLabel(config, slug)}
+ </li>
+ ))}
+ </ul>
+ );
+}
diff --git a/services/nuldoc/nuldoc-src/components/utils.ts b/services/nuldoc/nuldoc-src/components/utils.ts
new file mode 100644
index 0000000..14059b5
--- /dev/null
+++ b/services/nuldoc/nuldoc-src/components/utils.ts
@@ -0,0 +1,8 @@
+import { Hash } from "checksum/mod.ts";
+
+export async function calculateFileHash(
+ filePath: string,
+): Promise<string> {
+ const content = await Deno.readFile(filePath);
+ return new Hash("md5").digest(content).hex();
+}
diff --git a/services/nuldoc/nuldoc-src/config.ts b/services/nuldoc/nuldoc-src/config.ts
new file mode 100644
index 0000000..adcb563
--- /dev/null
+++ b/services/nuldoc/nuldoc-src/config.ts
@@ -0,0 +1,41 @@
+import { join } from "@std/path";
+import { parse as parseToml } from "@std/toml";
+import { z } from "zod/mod.ts";
+
+const ConfigSchema = z.object({
+ locations: z.object({
+ contentDir: z.string(),
+ destDir: z.string(),
+ staticDir: z.string(),
+ }),
+ rendering: z.object({
+ html: z.object({
+ indentWidth: z.number(),
+ }),
+ }),
+ blog: z.object({
+ author: z.string(),
+ fqdn: z.string(),
+ siteName: z.string(),
+ siteCopyrightYear: z.number(),
+ postsPerPage: z.number().default(10),
+ tagLabels: z.record(z.string(), z.string()),
+ }),
+});
+
+export type Config = z.infer<typeof ConfigSchema>;
+
+export function getTagLabel(c: Config, slug: string): string {
+ if (!(slug in c.blog.tagLabels)) {
+ throw new Error(`Unknown tag: ${slug}`);
+ }
+ return c.blog.tagLabels[slug];
+}
+
+export function getDefaultConfigPath(): string {
+ return join(Deno.cwd(), "nuldoc.toml");
+}
+
+export async function loadConfig(filePath: string): Promise<Config> {
+ return ConfigSchema.parse(parseToml(await Deno.readTextFile(filePath)));
+}
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;
+ }
+ });
+}
diff --git a/services/nuldoc/nuldoc-src/dom.ts b/services/nuldoc/nuldoc-src/dom.ts
new file mode 100644
index 0000000..abe7ff8
--- /dev/null
+++ b/services/nuldoc/nuldoc-src/dom.ts
@@ -0,0 +1,154 @@
+export type Text = {
+ kind: "text";
+ content: string;
+};
+
+export type RawHTML = {
+ kind: "raw";
+ html: string;
+};
+
+export type Element = {
+ kind: "element";
+ name: string;
+ attributes: Record<string, string>;
+ children: Node[];
+};
+
+export type Node = Element | Text | RawHTML;
+
+export function text(content: string): Text {
+ return {
+ kind: "text",
+ content,
+ };
+}
+
+export function rawHTML(html: string): RawHTML {
+ return {
+ kind: "raw",
+ html,
+ };
+}
+
+export function elem(
+ name: string,
+ attributes?: Record<string, string>,
+ ...children: Node[]
+): Element {
+ return {
+ kind: "element",
+ name,
+ attributes: attributes || {},
+ children,
+ };
+}
+
+export function addClass(e: Element, klass: string) {
+ const classes = e.attributes.class;
+ if (classes === undefined) {
+ e.attributes.class = klass;
+ } else {
+ const classList = classes.split(" ");
+ classList.push(klass);
+ classList.sort();
+ e.attributes.class = classList.join(" ");
+ }
+}
+
+export function findFirstChildElement(
+ e: Element,
+ name: string,
+): Element | null {
+ for (const c of e.children) {
+ if (c.kind === "element" && c.name === name) {
+ return c;
+ }
+ }
+ return null;
+}
+
+export function findChildElements(e: Element, name: string): Element[] {
+ const cs = [];
+ for (const c of e.children) {
+ if (c.kind === "element" && c.name === name) {
+ cs.push(c);
+ }
+ }
+ return cs;
+}
+
+export function innerText(e: Element): string {
+ let t = "";
+ forEachChild(e, (c) => {
+ if (c.kind === "text") {
+ t += c.content;
+ }
+ });
+ return t;
+}
+
+export function forEachChild(e: Element, f: (n: Node) => void) {
+ for (const c of e.children) {
+ f(c);
+ }
+}
+
+export async function forEachChildAsync(
+ e: Element,
+ f: (n: Node) => Promise<void>,
+): Promise<void> {
+ for (const c of e.children) {
+ await f(c);
+ }
+}
+
+export function forEachChildRecursively(e: Element, f: (n: Node) => void) {
+ const g = (c: Node) => {
+ f(c);
+ if (c.kind === "element") {
+ forEachChild(c, g);
+ }
+ };
+ forEachChild(e, g);
+}
+
+export async function forEachChildRecursivelyAsync(
+ e: Element,
+ f: (n: Node) => Promise<void>,
+): Promise<void> {
+ const g = async (c: Node) => {
+ await f(c);
+ if (c.kind === "element") {
+ await forEachChildAsync(c, g);
+ }
+ };
+ await forEachChildAsync(e, g);
+}
+
+export function forEachElementOfType(
+ root: Element,
+ elementName: string,
+ f: (e: Element) => void,
+) {
+ forEachChildRecursively(root, (n) => {
+ if (n.kind === "element" && n.name === elementName) {
+ f(n);
+ }
+ });
+}
+
+export function processTextNodesInElement(
+ e: Element,
+ f: (text: string) => Node[],
+) {
+ const newChildren: Node[] = [];
+ for (const child of e.children) {
+ if (child.kind === "text") {
+ newChildren.push(...f(child.content));
+ } else {
+ newChildren.push(child);
+ }
+ }
+ e.children = newChildren;
+}
diff --git a/services/nuldoc/nuldoc-src/errors.ts b/services/nuldoc/nuldoc-src/errors.ts
new file mode 100644
index 0000000..1692a4c
--- /dev/null
+++ b/services/nuldoc/nuldoc-src/errors.ts
@@ -0,0 +1,17 @@
+export class NuldocError extends Error {
+ static {
+ this.prototype.name = "NuldocError";
+ }
+}
+
+export class SlideError extends Error {
+ static {
+ this.prototype.name = "SlideError";
+ }
+}
+
+export class XmlParseError extends Error {
+ static {
+ this.prototype.name = "XmlParseError";
+ }
+}
diff --git a/services/nuldoc/nuldoc-src/generators/about.ts b/services/nuldoc/nuldoc-src/generators/about.ts
new file mode 100644
index 0000000..6663a19
--- /dev/null
+++ b/services/nuldoc/nuldoc-src/generators/about.ts
@@ -0,0 +1,23 @@
+import AboutPage from "../pages/AboutPage.tsx";
+import { Config } from "../config.ts";
+import { renderToDOM } from "../jsx/render.ts";
+import { Page } from "../page.ts";
+import { SlidePage } from "./slide.ts";
+
+export type AboutPage = Page;
+
+export async function generateAboutPage(
+ slides: SlidePage[],
+ config: Config,
+): Promise<AboutPage> {
+ const html = await renderToDOM(
+ AboutPage(slides, config),
+ );
+
+ return {
+ root: html,
+ renderer: "html",
+ destFilePath: "/about/index.html",
+ href: "/about/",
+ };
+}
diff --git a/services/nuldoc/nuldoc-src/generators/atom.ts b/services/nuldoc/nuldoc-src/generators/atom.ts
new file mode 100644
index 0000000..6ad07b4
--- /dev/null
+++ b/services/nuldoc/nuldoc-src/generators/atom.ts
@@ -0,0 +1,79 @@
+import { Config } from "../config.ts";
+import { Page } from "../page.ts";
+import { PostPage } from "../generators/post.ts";
+import { SlidePage } from "../generators/slide.ts";
+import { dateToRfc3339String } from "../revision.ts";
+import AtomPage from "../pages/AtomPage.tsx";
+import { renderToDOM } from "../jsx/render.ts";
+
+export type Feed = {
+ author: string;
+ icon: string;
+ id: string;
+ linkToSelf: string;
+ linkToAlternate: string;
+ title: string;
+ updated: string;
+ entries: Entry[];
+};
+
+export type Entry = {
+ id: string;
+ linkToAlternate: string;
+ published: string;
+ summary: string;
+ title: string;
+ updated: string;
+};
+
+const BASE_NAME = "atom.xml";
+
+export async function generateFeedPageFromEntries(
+ alternateLink: string,
+ feedSlug: string,
+ feedTitle: string,
+ entries: Array<PostPage | SlidePage>,
+ config: Config,
+): Promise<Page> {
+ const entries_: Entry[] = [];
+ for (const entry of entries) {
+ entries_.push({
+ id: `urn:uuid:${entry.uuid}`,
+ linkToAlternate: `https://${config.blog.fqdn}${entry.href}`,
+ title: entry.title,
+ summary: entry.description,
+ published: dateToRfc3339String(entry.published),
+ updated: dateToRfc3339String(entry.updated),
+ });
+ }
+ // Sort by published date in ascending order.
+ entries_.sort((a, b) => {
+ if (a.published < b.published) {
+ return 1;
+ } else if (a.published > b.published) {
+ return -1;
+ }
+ return 0;
+ });
+ const feedPath = `${alternateLink}${BASE_NAME}`;
+ const feed: Feed = {
+ author: config.blog.author,
+ icon: `https://${config.blog.fqdn}/favicon.svg`,
+ id: `tag:${config.blog.fqdn},${config.blog.siteCopyrightYear}:${feedSlug}`,
+ linkToSelf: `https://${config.blog.fqdn}${feedPath}`,
+ linkToAlternate: `https://${config.blog.fqdn}${alternateLink}`,
+ title: feedTitle,
+ updated: entries_.reduce(
+ (latest, entry) => entry.updated > latest ? entry.updated : latest,
+ entries_[0].updated,
+ ),
+ entries: entries_,
+ };
+
+ return {
+ root: await renderToDOM(AtomPage({ feed: feed })),
+ renderer: "xml",
+ destFilePath: feedPath,
+ href: feedPath,
+ };
+}
diff --git a/services/nuldoc/nuldoc-src/generators/home.ts b/services/nuldoc/nuldoc-src/generators/home.ts
new file mode 100644
index 0000000..679dd39
--- /dev/null
+++ b/services/nuldoc/nuldoc-src/generators/home.ts
@@ -0,0 +1,19 @@
+import HomePage from "../pages/HomePage.tsx";
+import { renderToDOM } from "../jsx/render.ts";
+import { Config } from "../config.ts";
+import { Page } from "../page.ts";
+
+export type HomePage = Page;
+
+export async function generateHomePage(config: Config): Promise<HomePage> {
+ const html = await renderToDOM(
+ HomePage(config),
+ );
+
+ return {
+ root: html,
+ renderer: "html",
+ destFilePath: "/index.html",
+ href: "/",
+ };
+}
diff --git a/services/nuldoc/nuldoc-src/generators/not_found.ts b/services/nuldoc/nuldoc-src/generators/not_found.ts
new file mode 100644
index 0000000..f5a81c8
--- /dev/null
+++ b/services/nuldoc/nuldoc-src/generators/not_found.ts
@@ -0,0 +1,21 @@
+import NotFoundPage from "../pages/NotFoundPage.tsx";
+import { renderToDOM } from "../jsx/render.ts";
+import { Config } from "../config.ts";
+import { Page } from "../page.ts";
+
+export type NotFoundPage = Page;
+
+export async function generateNotFoundPage(
+ config: Config,
+): Promise<NotFoundPage> {
+ const html = await renderToDOM(
+ NotFoundPage(config),
+ );
+
+ return {
+ root: html,
+ renderer: "html",
+ destFilePath: "/404.html",
+ href: "/404.html",
+ };
+}
diff --git a/services/nuldoc/nuldoc-src/generators/post.ts b/services/nuldoc/nuldoc-src/generators/post.ts
new file mode 100644
index 0000000..0e2a955
--- /dev/null
+++ b/services/nuldoc/nuldoc-src/generators/post.ts
@@ -0,0 +1,63 @@
+import { join } from "@std/path";
+import { renderToDOM } from "../jsx/render.ts";
+import PostPage from "../pages/PostPage.tsx";
+import { Config } from "../config.ts";
+import { Document } from "../djot/document.ts";
+import { Page } from "../page.ts";
+import { Date, Revision } from "../revision.ts";
+
+export interface PostPage extends Page {
+ title: string;
+ description: string;
+ tags: string[];
+ revisions: Revision[];
+ published: Date;
+ updated: Date;
+ uuid: string;
+}
+
+export function getPostPublishedDate(page: { revisions: Revision[] }): Date {
+ for (const rev of page.revisions) {
+ if (!rev.isInternal) {
+ return rev.date;
+ }
+ }
+ return page.revisions[0].date;
+}
+
+export function getPostUpdatedDate(page: { revisions: Revision[] }): Date {
+ return page.revisions[page.revisions.length - 1].date;
+}
+
+export function postHasAnyUpdates(page: { revisions: Revision[] }): boolean {
+ return 2 <= page.revisions.filter((rev) => !rev.isInternal).length;
+}
+
+export async function generatePostPage(
+ doc: Document,
+ config: Config,
+): Promise<PostPage> {
+ const html = await renderToDOM(
+ PostPage(doc, config),
+ );
+
+ const cwd = Deno.cwd();
+ const contentDir = join(cwd, config.locations.contentDir);
+ const destFilePath = join(
+ doc.sourceFilePath.replace(contentDir, "").replace(".dj", ""),
+ "index.html",
+ );
+ return {
+ root: html,
+ renderer: "html",
+ destFilePath: destFilePath,
+ href: destFilePath.replace("index.html", ""),
+ title: doc.title,
+ description: doc.description,
+ tags: doc.tags,
+ revisions: doc.revisions,
+ published: getPostPublishedDate(doc),
+ updated: getPostUpdatedDate(doc),
+ uuid: doc.uuid,
+ };
+}
diff --git a/services/nuldoc/nuldoc-src/generators/post_list.ts b/services/nuldoc/nuldoc-src/generators/post_list.ts
new file mode 100644
index 0000000..b05f7ee
--- /dev/null
+++ b/services/nuldoc/nuldoc-src/generators/post_list.ts
@@ -0,0 +1,63 @@
+import { renderToDOM } from "../jsx/render.ts";
+import PostListPage from "../pages/PostListPage.tsx";
+import { Config } from "../config.ts";
+import { Page } from "../page.ts";
+import { PostPage } from "./post.ts";
+
+export type PostListPage = Page;
+
+export async function generatePostListPages(
+ posts: PostPage[],
+ config: Config,
+): Promise<PostListPage[]> {
+ const postsPerPage = config.blog.postsPerPage;
+ const totalPages = Math.ceil(posts.length / postsPerPage);
+ const pages: PostListPage[] = [];
+
+ for (let pageIndex = 0; pageIndex < totalPages; pageIndex++) {
+ const pagePosts = posts.slice(
+ pageIndex * postsPerPage,
+ (pageIndex + 1) * postsPerPage,
+ );
+
+ const page = await generatePostListPage(
+ pagePosts,
+ config,
+ pageIndex + 1,
+ totalPages,
+ );
+
+ pages.push(page);
+ }
+
+ return pages;
+}
+
+async function generatePostListPage(
+ posts: PostPage[],
+ config: Config,
+ currentPage: number,
+ totalPages: number,
+): Promise<PostListPage> {
+ const html = await renderToDOM(
+ PostListPage(
+ posts,
+ config,
+ currentPage,
+ totalPages,
+ ),
+ );
+
+ const destFilePath = currentPage === 1
+ ? "/posts/index.html"
+ : `/posts/${currentPage}/index.html`;
+
+ const href = currentPage === 1 ? "/posts/" : `/posts/${currentPage}/`;
+
+ return {
+ root: html,
+ renderer: "html",
+ destFilePath,
+ href,
+ };
+}
diff --git a/services/nuldoc/nuldoc-src/generators/slide.ts b/services/nuldoc/nuldoc-src/generators/slide.ts
new file mode 100644
index 0000000..cd28879
--- /dev/null
+++ b/services/nuldoc/nuldoc-src/generators/slide.ts
@@ -0,0 +1,53 @@
+import { join } from "@std/path";
+import { renderToDOM } from "../jsx/render.ts";
+import SlidePage from "../pages/SlidePage.tsx";
+import { Config } from "../config.ts";
+import { Page } from "../page.ts";
+import { Date, Revision } from "../revision.ts";
+import { Slide } from "../slide/slide.ts";
+import { getPostPublishedDate, getPostUpdatedDate } from "./post.ts";
+
+export interface SlidePage extends Page {
+ title: string;
+ description: string;
+ event: string;
+ talkType: string;
+ slideLink: string;
+ tags: string[];
+ revisions: Revision[];
+ published: Date;
+ updated: Date;
+ uuid: string;
+}
+
+export async function generateSlidePage(
+ slide: Slide,
+ config: Config,
+): Promise<SlidePage> {
+ const html = await renderToDOM(
+ SlidePage(slide, config),
+ );
+
+ const cwd = Deno.cwd();
+ const contentDir = join(cwd, config.locations.contentDir);
+ const destFilePath = join(
+ slide.sourceFilePath.replace(contentDir, "").replace(".toml", ""),
+ "index.html",
+ );
+ return {
+ root: html,
+ renderer: "html",
+ destFilePath: destFilePath,
+ href: destFilePath.replace("index.html", ""),
+ title: slide.title,
+ description: `登壇: ${slide.event} (${slide.talkType})`,
+ event: slide.event,
+ talkType: slide.talkType,
+ slideLink: slide.slideLink,
+ tags: slide.tags,
+ revisions: slide.revisions,
+ published: getPostPublishedDate(slide),
+ updated: getPostUpdatedDate(slide),
+ uuid: slide.uuid,
+ };
+}
diff --git a/services/nuldoc/nuldoc-src/generators/slide_list.ts b/services/nuldoc/nuldoc-src/generators/slide_list.ts
new file mode 100644
index 0000000..abebe10
--- /dev/null
+++ b/services/nuldoc/nuldoc-src/generators/slide_list.ts
@@ -0,0 +1,23 @@
+import { renderToDOM } from "../jsx/render.ts";
+import SlideListPage from "../pages/SlideListPage.tsx";
+import { Config } from "../config.ts";
+import { Page } from "../page.ts";
+import { SlidePage } from "./slide.ts";
+
+export type SlideListPage = Page;
+
+export async function generateSlideListPage(
+ slides: SlidePage[],
+ config: Config,
+): Promise<SlideListPage> {
+ const html = await renderToDOM(
+ SlideListPage(slides, config),
+ );
+
+ return {
+ root: html,
+ renderer: "html",
+ destFilePath: "/slides/index.html",
+ href: "/slides/",
+ };
+}
diff --git a/services/nuldoc/nuldoc-src/generators/tag.ts b/services/nuldoc/nuldoc-src/generators/tag.ts
new file mode 100644
index 0000000..dbd8ef9
--- /dev/null
+++ b/services/nuldoc/nuldoc-src/generators/tag.ts
@@ -0,0 +1,33 @@
+import { renderToDOM } from "../jsx/render.ts";
+import TagPage from "../pages/TagPage.tsx";
+import { Config, getTagLabel } from "../config.ts";
+import { Page } from "../page.ts";
+import { TaggedPage } from "./tagged_page.ts";
+
+export interface TagPage extends Page {
+ tagSlug: string;
+ tagLabel: string;
+ numOfPosts: number;
+ numOfSlides: number;
+}
+
+export async function generateTagPage(
+ tagSlug: string,
+ pages: TaggedPage[],
+ config: Config,
+): Promise<TagPage> {
+ const html = await renderToDOM(
+ TagPage(tagSlug, pages, config),
+ );
+
+ return {
+ root: html,
+ renderer: "html",
+ destFilePath: `/tags/${tagSlug}/index.html`,
+ href: `/tags/${tagSlug}/`,
+ tagSlug: tagSlug,
+ tagLabel: getTagLabel(config, tagSlug),
+ numOfPosts: pages.filter((p) => !("event" in p)).length,
+ numOfSlides: pages.filter((p) => "event" in p).length,
+ };
+}
diff --git a/services/nuldoc/nuldoc-src/generators/tag_list.ts b/services/nuldoc/nuldoc-src/generators/tag_list.ts
new file mode 100644
index 0000000..7baad8c
--- /dev/null
+++ b/services/nuldoc/nuldoc-src/generators/tag_list.ts
@@ -0,0 +1,23 @@
+import { renderToDOM } from "../jsx/render.ts";
+import TagListPage from "../pages/TagListPage.tsx";
+import { Config } from "../config.ts";
+import { Page } from "../page.ts";
+import { TagPage } from "./tag.ts";
+
+export type TagListPage = Page;
+
+export async function generateTagListPage(
+ tags: TagPage[],
+ config: Config,
+): Promise<TagListPage> {
+ const html = await renderToDOM(
+ TagListPage(tags, config),
+ );
+
+ return {
+ root: html,
+ renderer: "html",
+ destFilePath: "/tags/index.html",
+ href: "/tags/",
+ };
+}
diff --git a/services/nuldoc/nuldoc-src/generators/tagged_page.ts b/services/nuldoc/nuldoc-src/generators/tagged_page.ts
new file mode 100644
index 0000000..23de8cb
--- /dev/null
+++ b/services/nuldoc/nuldoc-src/generators/tagged_page.ts
@@ -0,0 +1,4 @@
+import { PostPage } from "./post.ts";
+import { SlidePage } from "./slide.ts";
+
+export type TaggedPage = PostPage | SlidePage;
diff --git a/services/nuldoc/nuldoc-src/jsx/jsx-runtime.ts b/services/nuldoc/nuldoc-src/jsx/jsx-runtime.ts
new file mode 100644
index 0000000..9571e87
--- /dev/null
+++ b/services/nuldoc/nuldoc-src/jsx/jsx-runtime.ts
@@ -0,0 +1,27 @@
+import type { Node } from "../dom.ts";
+
+export type JSXElement = {
+ tag: string | FunctionComponent;
+ props: Props;
+};
+
+export type JSXNullNode = false | null | undefined;
+export type JSXSimpleNode = JSXElement | Node | string;
+export type JSXNullableSimpleNode = JSXSimpleNode | JSXNullNode;
+export type JSXNode = JSXNullableSimpleNode | JSXNode[];
+export type RenderableJSXNode = JSXElement;
+
+type Props = { children?: JSXNode } & Record<string, unknown>;
+export type FunctionComponentResult = JSXElement | Promise<JSXElement>;
+type FunctionComponent = (props: Props) => FunctionComponentResult;
+
+export function jsx(
+ tag: string | FunctionComponent,
+ props: Props,
+): JSXElement {
+ return { tag, props };
+}
+
+export { jsx as jsxs };
+
+// TODO: support Fragment
diff --git a/services/nuldoc/nuldoc-src/jsx/render.ts b/services/nuldoc/nuldoc-src/jsx/render.ts
new file mode 100644
index 0000000..a72d9ad
--- /dev/null
+++ b/services/nuldoc/nuldoc-src/jsx/render.ts
@@ -0,0 +1,41 @@
+import type { Element, Node } from "../dom.ts";
+import { elem, text } from "../dom.ts";
+import type {
+ JSXNode,
+ JSXNullableSimpleNode,
+ JSXSimpleNode,
+ RenderableJSXNode,
+} from "myjsx/jsx-runtime";
+
+function transformNode(node: JSXNode): Promise<Node[]> {
+ const flattenNodes: JSXNullableSimpleNode[] = Array.isArray(node)
+ // @ts-ignore prevents infinite recursion
+ ? (node.flat(Infinity) as JSXNullableSimpleNode[])
+ : [node];
+ return Promise.all(
+ flattenNodes
+ .filter((c): c is JSXSimpleNode => c != null && c !== false)
+ .map((c) => {
+ if (typeof c === "string") {
+ return text(c);
+ } else if ("kind" in c) {
+ return c;
+ } else {
+ return renderToDOM(c);
+ }
+ }),
+ );
+}
+
+export async function renderToDOM(
+ element: RenderableJSXNode,
+): Promise<Element> {
+ const { tag, props } = element;
+ if (typeof tag === "string") {
+ const { children, ...attrs } = props;
+ const attrsMap = attrs as Record<string, string>;
+ return elem(tag, attrsMap, ...(await transformNode(children)));
+ } else {
+ return renderToDOM(await tag(props));
+ }
+}
diff --git a/services/nuldoc/nuldoc-src/jsx/types.d.ts b/services/nuldoc/nuldoc-src/jsx/types.d.ts
new file mode 100644
index 0000000..973b852
--- /dev/null
+++ b/services/nuldoc/nuldoc-src/jsx/types.d.ts
@@ -0,0 +1,84 @@
+import type {
+ FunctionComponentResult,
+ JSXElement,
+ JSXNode,
+} from "myjsx/jsx-runtime";
+
+export { JSXNode };
+
+interface IntrinsicElementType {
+ children?: JSXNode;
+ className?: string;
+ id?: string;
+ // My JSX runtime does not use key. It is only for linter that complains about missing key.
+ key?: string;
+}
+
+declare global {
+ namespace JSX {
+ type Element = JSXElement;
+ type ElementType =
+ | string
+ // deno-lint-ignore no-explicit-any
+ | ((props: any) => FunctionComponentResult);
+
+ // TODO: HTML 用の element と XML 用の element を分ける
+ interface IntrinsicElements {
+ // XML (Atom)
+ author: IntrinsicElementType;
+ entry: IntrinsicElementType;
+ feed: IntrinsicElementType & { xmlns: string };
+ id: IntrinsicElementType;
+ name: IntrinsicElementType;
+ published: IntrinsicElementType;
+ summary: IntrinsicElementType;
+ updated: IntrinsicElementType;
+ // HTML
+ a: IntrinsicElementType & {
+ href?: string;
+ rel?: "noreferrer";
+ target?: "_blank";
+ };
+ article: IntrinsicElementType;
+ body: IntrinsicElementType;
+ button: IntrinsicElementType & { type: string };
+ canvas: { id?: string; "data-slide-link"?: string };
+ div: IntrinsicElementType;
+ footer: IntrinsicElementType;
+ h1: IntrinsicElementType;
+ h2: IntrinsicElementType;
+ head: unknown;
+ header: IntrinsicElementType;
+ html: IntrinsicElementType & { lang?: string };
+ img: { src: string };
+ li: IntrinsicElementType;
+ link: { rel: string; href: string; type?: string };
+ main: IntrinsicElementType;
+ meta: {
+ charset?: string;
+ name?: string;
+ content?: string;
+ property?: string;
+ };
+ nav: IntrinsicElementType;
+ noscript: IntrinsicElementType;
+ ol: IntrinsicElementType;
+ p: IntrinsicElementType;
+ script: { src: string; type?: string; defer?: "true" };
+ section: IntrinsicElementType;
+ span: IntrinsicElementType;
+ time: IntrinsicElementType & { datetime?: string };
+ title: IntrinsicElementType;
+ ul: IntrinsicElementType;
+ }
+
+ interface ElementChildrenAttribute {
+ children: unknown;
+ }
+
+ type LibraryManagedAttributes<_F, P> = P & {
+ // My JSX runtime does not use key. It is only for linter that complains about missing key.
+ key?: string;
+ };
+ }
+}
diff --git a/services/nuldoc/nuldoc-src/main.ts b/services/nuldoc/nuldoc-src/main.ts
new file mode 100644
index 0000000..af6acc2
--- /dev/null
+++ b/services/nuldoc/nuldoc-src/main.ts
@@ -0,0 +1,19 @@
+import { runBuildCommand } from "./commands/build.ts";
+import { runNewCommand } from "./commands/new.ts";
+import { runServeCommand } from "./commands/serve.ts";
+import { getDefaultConfigPath, loadConfig } from "./config.ts";
+
+const config = await loadConfig(getDefaultConfigPath());
+
+if (import.meta.main) {
+ const command = Deno.args[0] ?? "build";
+ if (command === "build") {
+ await runBuildCommand(config);
+ } else if (command === "new") {
+ runNewCommand(config);
+ } else if (command === "serve") {
+ runServeCommand(config);
+ } else {
+ console.error(`Unknown command: ${command}`);
+ }
+}
diff --git a/services/nuldoc/nuldoc-src/page.ts b/services/nuldoc/nuldoc-src/page.ts
new file mode 100644
index 0000000..f4a6166
--- /dev/null
+++ b/services/nuldoc/nuldoc-src/page.ts
@@ -0,0 +1,9 @@
+import { Element } from "./dom.ts";
+import { RendererType } from "./render.ts";
+
+export interface Page {
+ root: Element;
+ renderer: RendererType;
+ destFilePath: string;
+ href: string;
+}
diff --git a/services/nuldoc/nuldoc-src/pages/AboutPage.tsx b/services/nuldoc/nuldoc-src/pages/AboutPage.tsx
new file mode 100644
index 0000000..3d6583a
--- /dev/null
+++ b/services/nuldoc/nuldoc-src/pages/AboutPage.tsx
@@ -0,0 +1,110 @@
+import GlobalFooter from "../components/GlobalFooter.tsx";
+import GlobalHeader from "../components/GlobalHeader.tsx";
+import PageLayout from "../components/PageLayout.tsx";
+import StaticScript from "../components/StaticScript.tsx";
+import { Config } from "../config.ts";
+import { dateToString } from "../revision.ts";
+import { getPostPublishedDate } from "../generators/post.ts";
+import { SlidePage } from "../generators/slide.ts";
+
+export default function AboutPage(
+ slides: SlidePage[],
+ config: Config,
+) {
+ return (
+ <PageLayout
+ metaCopyrightYear={config.blog.siteCopyrightYear}
+ metaDescription="このサイトの著者について"
+ metaTitle={`About|${config.blog.siteName}`}
+ config={config}
+ >
+ <body className="single">
+ <GlobalHeader config={config} />
+ <main className="main">
+ <article className="post-single">
+ <header className="post-header">
+ <h1 className="post-title">nsfisis</h1>
+ <div className="my-icon">
+ <div id="myIcon">
+ <img src="/favicon.svg" />
+ </div>
+ <StaticScript
+ fileName="/my-icon.js"
+ defer="true"
+ config={config}
+ />
+ </div>
+ </header>
+ <div className="post-content">
+ <section>
+ <h2>読み方</h2>
+ <p>
+ 読み方は決めていません。音にする必要があるときは本名である「いまむら」をお使いください。
+ </p>
+ </section>
+ <section>
+ <h2>アカウント</h2>
+ <ul>
+ <li>
+ <a
+ href="https://twitter.com/nsfisis"
+ target="_blank"
+ rel="noreferrer"
+ >
+ Twitter (現 𝕏): @nsfisis
+ </a>
+ </li>
+ <li>
+ <a
+ href="https://github.com/nsfisis"
+ target="_blank"
+ rel="noreferrer"
+ >
+ GitHub: @nsfisis
+ </a>
+ </li>
+ </ul>
+ </section>
+ <section>
+ <h2>仕事</h2>
+ <ul>
+ <li>
+ {"2021-01~現在: "}
+ <a
+ href="https://www.dgcircus.com/"
+ target="_blank"
+ rel="noreferrer"
+ >
+ デジタルサーカス株式会社
+ </a>
+ </li>
+ </ul>
+ </section>
+ <section>
+ <h2>登壇</h2>
+ <ul>
+ {Array.from(slides).sort((a, b) => {
+ const ta = dateToString(getPostPublishedDate(a));
+ const tb = dateToString(getPostPublishedDate(b));
+ if (ta > tb) return -1;
+ if (ta < tb) return 1;
+ return 0;
+ }).map((slide) => (
+ <li>
+ <a href={slide.href}>
+ {`${
+ dateToString(getPostPublishedDate(slide))
+ }: ${slide.event} (${slide.talkType})`}
+ </a>
+ </li>
+ ))}
+ </ul>
+ </section>
+ </div>
+ </article>
+ </main>
+ <GlobalFooter config={config} />
+ </body>
+ </PageLayout>
+ );
+}
diff --git a/services/nuldoc/nuldoc-src/pages/AtomPage.tsx b/services/nuldoc/nuldoc-src/pages/AtomPage.tsx
new file mode 100644
index 0000000..21c3bfa
--- /dev/null
+++ b/services/nuldoc/nuldoc-src/pages/AtomPage.tsx
@@ -0,0 +1,26 @@
+import { Feed } from "../generators/atom.ts";
+
+export default function AtomPage({ feed }: { feed: Feed }) {
+ return (
+ <feed xmlns="http://www.w3.org/2005/Atom">
+ <id>{feed.id}</id>
+ <title>{feed.title}</title>
+ <link rel="alternate" href={feed.linkToAlternate} />
+ <link rel="self" href={feed.linkToSelf} />
+ <author>
+ <name>{feed.author}</name>
+ </author>
+ <updated>{feed.updated}</updated>
+ {feed.entries.map((entry) => (
+ <entry>
+ <id>{entry.id}</id>
+ <link rel="alternate" href={entry.linkToAlternate} />
+ <title>{entry.title}</title>
+ <summary>{entry.summary}</summary>
+ <published>{entry.published}</published>
+ <updated>{entry.updated}</updated>
+ </entry>
+ ))}
+ </feed>
+ );
+}
diff --git a/services/nuldoc/nuldoc-src/pages/HomePage.tsx b/services/nuldoc/nuldoc-src/pages/HomePage.tsx
new file mode 100644
index 0000000..8850d03
--- /dev/null
+++ b/services/nuldoc/nuldoc-src/pages/HomePage.tsx
@@ -0,0 +1,53 @@
+import GlobalFooter from "../components/GlobalFooter.tsx";
+import GlobalHeader from "../components/GlobalHeader.tsx";
+import PageLayout from "../components/PageLayout.tsx";
+import { Config } from "../config.ts";
+
+export default function HomePage(config: Config) {
+ return (
+ <PageLayout
+ metaCopyrightYear={config.blog.siteCopyrightYear}
+ metaDescription="nsfisis のブログサイト"
+ metaTitle={config.blog.siteName}
+ metaAtomFeedHref={`https://${config.blog.fqdn}/atom.xml`}
+ config={config}
+ >
+ <body className="single">
+ <GlobalHeader config={config} />
+ <main className="main">
+ <article className="post-single">
+ <article className="post-entry">
+ <a href="/about/">
+ <header className="entry-header">
+ <h2>About</h2>
+ </header>
+ </a>
+ </article>
+ <article className="post-entry">
+ <a href="/posts/">
+ <header className="entry-header">
+ <h2>Posts</h2>
+ </header>
+ </a>
+ </article>
+ <article className="post-entry">
+ <a href="/slides/">
+ <header className="entry-header">
+ <h2>Slides</h2>
+ </header>
+ </a>
+ </article>
+ <article className="post-entry">
+ <a href="/tags/">
+ <header className="entry-header">
+ <h2>Tags</h2>
+ </header>
+ </a>
+ </article>
+ </article>
+ </main>
+ <GlobalFooter config={config} />
+ </body>
+ </PageLayout>
+ );
+}
diff --git a/services/nuldoc/nuldoc-src/pages/NotFoundPage.tsx b/services/nuldoc/nuldoc-src/pages/NotFoundPage.tsx
new file mode 100644
index 0000000..9631fef
--- /dev/null
+++ b/services/nuldoc/nuldoc-src/pages/NotFoundPage.tsx
@@ -0,0 +1,27 @@
+import GlobalFooter from "../components/GlobalFooter.tsx";
+import GlobalHeader from "../components/GlobalHeader.tsx";
+import PageLayout from "../components/PageLayout.tsx";
+import { Config } from "../config.ts";
+
+export default function NotFoundPage(
+ config: Config,
+) {
+ return (
+ <PageLayout
+ metaCopyrightYear={config.blog.siteCopyrightYear}
+ metaDescription="リクエストされたページが見つかりません"
+ metaTitle={`Page Not Found|${config.blog.siteName}`}
+ config={config}
+ >
+ <body className="single">
+ <GlobalHeader config={config} />
+ <main className="main">
+ <article>
+ <div className="not-found">404</div>
+ </article>
+ </main>
+ <GlobalFooter config={config} />
+ </body>
+ </PageLayout>
+ );
+}
diff --git a/services/nuldoc/nuldoc-src/pages/PostListPage.tsx b/services/nuldoc/nuldoc-src/pages/PostListPage.tsx
new file mode 100644
index 0000000..b825f69
--- /dev/null
+++ b/services/nuldoc/nuldoc-src/pages/PostListPage.tsx
@@ -0,0 +1,56 @@
+import GlobalFooter from "../components/GlobalFooter.tsx";
+import GlobalHeader from "../components/GlobalHeader.tsx";
+import PageLayout from "../components/PageLayout.tsx";
+import Pagination from "../components/Pagination.tsx";
+import PostPageEntry from "../components/PostPageEntry.tsx";
+import { Config } from "../config.ts";
+import { PostPage } from "../generators/post.ts";
+
+export default function PostListPage(
+ posts: PostPage[],
+ config: Config,
+ currentPage: number,
+ totalPages: number,
+) {
+ const pageTitle = "投稿一覧";
+
+ const pageInfoSuffix = ` (${currentPage}ページ目)`;
+ const metaTitle = `${pageTitle}${pageInfoSuffix}|${config.blog.siteName}`;
+ const metaDescription = `投稿した記事の一覧${pageInfoSuffix}`;
+
+ return (
+ <PageLayout
+ metaCopyrightYear={config.blog.siteCopyrightYear}
+ metaDescription={metaDescription}
+ metaTitle={metaTitle}
+ metaAtomFeedHref={`https://${config.blog.fqdn}/posts/atom.xml`}
+ config={config}
+ >
+ <body className="list">
+ <GlobalHeader config={config} />
+ <main className="main">
+ <header className="page-header">
+ <h1>{pageTitle}{pageInfoSuffix}</h1>
+ </header>
+
+ <Pagination
+ currentPage={currentPage}
+ totalPages={totalPages}
+ basePath="/posts/"
+ />
+
+ {posts.map((post) => (
+ <PostPageEntry post={post} config={config} key={post.uuid} />
+ ))}
+
+ <Pagination
+ currentPage={currentPage}
+ totalPages={totalPages}
+ basePath="/posts/"
+ />
+ </main>
+ <GlobalFooter config={config} />
+ </body>
+ </PageLayout>
+ );
+}
diff --git a/services/nuldoc/nuldoc-src/pages/PostPage.tsx b/services/nuldoc/nuldoc-src/pages/PostPage.tsx
new file mode 100644
index 0000000..e625518
--- /dev/null
+++ b/services/nuldoc/nuldoc-src/pages/PostPage.tsx
@@ -0,0 +1,70 @@
+import GlobalFooter from "../components/GlobalFooter.tsx";
+import GlobalHeader from "../components/GlobalHeader.tsx";
+import PageLayout from "../components/PageLayout.tsx";
+import TableOfContents from "../components/TableOfContents.tsx";
+import { Config, getTagLabel } from "../config.ts";
+import { Element } from "../dom.ts";
+import { Document } from "../djot/document.ts";
+import { dateToString } from "../revision.ts";
+import { getPostPublishedDate } from "../generators/post.ts";
+
+export default function PostPage(
+ doc: Document,
+ config: Config,
+) {
+ return (
+ <PageLayout
+ metaCopyrightYear={getPostPublishedDate(doc).year}
+ metaDescription={doc.description}
+ metaKeywords={doc.tags.map((slug) => getTagLabel(config, slug))}
+ metaTitle={`${doc.title}|${config.blog.siteName}`}
+ requiresSyntaxHighlight
+ config={config}
+ >
+ <body className="single">
+ <GlobalHeader config={config} />
+ <main className="main">
+ <article className="post-single">
+ <header className="post-header">
+ <h1 className="post-title">{doc.title}</h1>
+ {doc.tags.length !== 0 && (
+ <ul className="post-tags">
+ {doc.tags.map((slug) => (
+ <li className="tag">
+ <a href={`/tags/${slug}/`}>{getTagLabel(config, slug)}</a>
+ </li>
+ ))}
+ </ul>
+ )}
+ </header>
+ {doc.toc && doc.toc.entries.length > 0 && (
+ <TableOfContents toc={doc.toc} />
+ )}
+ <div className="post-content">
+ <section id="changelog">
+ <h2>
+ <a href="#changelog">更新履歴</a>
+ </h2>
+ <ol>
+ {doc.revisions.map((rev) => (
+ <li className="revision">
+ <time datetime={dateToString(rev.date)}>
+ {dateToString(rev.date)}
+ </time>
+ {`: ${rev.remark}`}
+ </li>
+ ))}
+ </ol>
+ </section>
+ {
+ // TODO: refactor
+ (doc.root.children[0] as Element).children
+ }
+ </div>
+ </article>
+ </main>
+ <GlobalFooter config={config} />
+ </body>
+ </PageLayout>
+ );
+}
diff --git a/services/nuldoc/nuldoc-src/pages/SlideListPage.tsx b/services/nuldoc/nuldoc-src/pages/SlideListPage.tsx
new file mode 100644
index 0000000..bc4b895
--- /dev/null
+++ b/services/nuldoc/nuldoc-src/pages/SlideListPage.tsx
@@ -0,0 +1,44 @@
+import GlobalFooter from "../components/GlobalFooter.tsx";
+import GlobalHeader from "../components/GlobalHeader.tsx";
+import PageLayout from "../components/PageLayout.tsx";
+import SlidePageEntry from "../components/SlidePageEntry.tsx";
+import { Config } from "../config.ts";
+import { dateToString } from "../revision.ts";
+import { getPostPublishedDate } from "../generators/post.ts";
+import { SlidePage } from "../generators/slide.ts";
+
+export default function SlideListPage(
+ slides: SlidePage[],
+ config: Config,
+) {
+ const pageTitle = "スライド一覧";
+
+ return (
+ <PageLayout
+ metaCopyrightYear={config.blog.siteCopyrightYear}
+ metaDescription="登壇したイベントで使用したスライドの一覧"
+ metaTitle={`${pageTitle}|${config.blog.siteName}`}
+ metaAtomFeedHref={`https://${config.blog.fqdn}/slides/atom.xml`}
+ config={config}
+ >
+ <body className="list">
+ <GlobalHeader config={config} />
+ <main className="main">
+ <header className="page-header">
+ <h1>{pageTitle}</h1>
+ </header>
+ {Array.from(slides).sort((a, b) => {
+ const ta = dateToString(getPostPublishedDate(a));
+ const tb = dateToString(getPostPublishedDate(b));
+ if (ta > tb) return -1;
+ if (ta < tb) return 1;
+ return 0;
+ }).map((slide) => (
+ <SlidePageEntry slide={slide} config={config} key={slide.uuid} />
+ ))}
+ </main>
+ <GlobalFooter config={config} />
+ </body>
+ </PageLayout>
+ );
+}
diff --git a/services/nuldoc/nuldoc-src/pages/SlidePage.tsx b/services/nuldoc/nuldoc-src/pages/SlidePage.tsx
new file mode 100644
index 0000000..fc11072
--- /dev/null
+++ b/services/nuldoc/nuldoc-src/pages/SlidePage.tsx
@@ -0,0 +1,72 @@
+import GlobalFooter from "../components/GlobalFooter.tsx";
+import GlobalHeader from "../components/GlobalHeader.tsx";
+import PageLayout from "../components/PageLayout.tsx";
+import StaticScript from "../components/StaticScript.tsx";
+import { Config, getTagLabel } from "../config.ts";
+import { dateToString } from "../revision.ts";
+import { Slide } from "../slide/slide.ts";
+import { getPostPublishedDate } from "../generators/post.ts";
+
+export default function SlidePage(
+ slide: Slide,
+ config: Config,
+) {
+ return (
+ <PageLayout
+ metaCopyrightYear={getPostPublishedDate(slide).year}
+ metaDescription={slide.title}
+ metaKeywords={slide.tags.map((slug) => getTagLabel(config, slug))}
+ metaTitle={`${slide.event} (${slide.talkType})|${config.blog.siteName}`}
+ requiresSyntaxHighlight
+ config={config}
+ >
+ <body className="single">
+ <GlobalHeader config={config} />
+ <main className="main">
+ <article className="post-single">
+ <header className="post-header">
+ <h1 className="post-title">{slide.title}</h1>
+ {slide.tags.length !== 0 && (
+ <ul className="post-tags">
+ {slide.tags.map((slug) => (
+ <li className="tag">
+ <a href={`/tags/${slug}/`}>{getTagLabel(config, slug)}</a>
+ </li>
+ ))}
+ </ul>
+ )}
+ </header>
+ <div className="post-content">
+ <section id="changelog">
+ <h2>
+ <a href="#changelog">更新履歴</a>
+ </h2>
+ <ol>
+ {slide.revisions.map((rev) => (
+ <li className="revision">
+ <time datetime={dateToString(rev.date)}>
+ {dateToString(rev.date)}
+ </time>
+ {`: ${rev.remark}`}
+ </li>
+ ))}
+ </ol>
+ </section>
+ <canvas id="slide" data-slide-link={slide.slideLink} />
+ <div>
+ <button id="prev" type="button">Prev</button>
+ <button id="next" type="button">Next</button>
+ </div>
+ <StaticScript
+ fileName="/slide.js"
+ type="module"
+ config={config}
+ />
+ </div>
+ </article>
+ </main>
+ <GlobalFooter config={config} />
+ </body>
+ </PageLayout>
+ );
+}
diff --git a/services/nuldoc/nuldoc-src/pages/TagListPage.tsx b/services/nuldoc/nuldoc-src/pages/TagListPage.tsx
new file mode 100644
index 0000000..cdb83ea
--- /dev/null
+++ b/services/nuldoc/nuldoc-src/pages/TagListPage.tsx
@@ -0,0 +1,57 @@
+import GlobalFooter from "../components/GlobalFooter.tsx";
+import GlobalHeader from "../components/GlobalHeader.tsx";
+import PageLayout from "../components/PageLayout.tsx";
+import { Config } from "../config.ts";
+import { TagPage } from "../generators/tag.ts";
+
+export default function TagListPage(
+ tags: TagPage[],
+ config: Config,
+) {
+ const pageTitle = "タグ一覧";
+
+ return (
+ <PageLayout
+ metaCopyrightYear={config.blog.siteCopyrightYear}
+ metaDescription="タグの一覧"
+ metaTitle={`${pageTitle}|${config.blog.siteName}`}
+ config={config}
+ >
+ <body className="list">
+ <GlobalHeader config={config} />
+ <main className="main">
+ <header className="page-header">
+ <h1>{pageTitle}</h1>
+ </header>
+ {Array.from(tags).sort((a, b) => {
+ const ta = a.tagSlug;
+ const tb = b.tagSlug;
+ if (ta < tb) return -1;
+ if (ta > tb) return 1;
+ return 0;
+ }).map((tag) => (
+ <article className="post-entry">
+ <a href={tag.href}>
+ <header className="entry-header">
+ <h2>{tag.tagLabel}</h2>
+ </header>
+ <footer className="entry-footer">
+ {(() => {
+ const posts = tag.numOfPosts === 0
+ ? ""
+ : `${tag.numOfPosts}件の記事`;
+ const slides = tag.numOfSlides === 0
+ ? ""
+ : `${tag.numOfSlides}件のスライド`;
+ return `${posts}${posts && slides ? "、" : ""}${slides}`;
+ })()}
+ </footer>
+ </a>
+ </article>
+ ))}
+ </main>
+ <GlobalFooter config={config} />
+ </body>
+ </PageLayout>
+ );
+}
diff --git a/services/nuldoc/nuldoc-src/pages/TagPage.tsx b/services/nuldoc/nuldoc-src/pages/TagPage.tsx
new file mode 100644
index 0000000..b2ffba5
--- /dev/null
+++ b/services/nuldoc/nuldoc-src/pages/TagPage.tsx
@@ -0,0 +1,43 @@
+import GlobalFooter from "../components/GlobalFooter.tsx";
+import GlobalHeader from "../components/GlobalHeader.tsx";
+import PageLayout from "../components/PageLayout.tsx";
+import PostPageEntry from "../components/PostPageEntry.tsx";
+import SlidePageEntry from "../components/SlidePageEntry.tsx";
+import { Config, getTagLabel } from "../config.ts";
+import { getPostPublishedDate } from "../generators/post.ts";
+import { TaggedPage } from "../generators/tagged_page.ts";
+
+export default function TagPage(
+ tagSlug: string,
+ pages: TaggedPage[],
+ config: Config,
+) {
+ const tagLabel = getTagLabel(config, tagSlug);
+ const pageTitle = `タグ「${tagLabel}」一覧`;
+
+ return (
+ <PageLayout
+ metaCopyrightYear={getPostPublishedDate(pages[pages.length - 1]).year}
+ metaDescription={`タグ「${tagLabel}」のついた記事またはスライドの一覧`}
+ metaKeywords={[tagLabel]}
+ metaTitle={`${pageTitle}|${config.blog.siteName}`}
+ metaAtomFeedHref={`https://${config.blog.fqdn}/tags/${tagSlug}/atom.xml`}
+ config={config}
+ >
+ <body className="list">
+ <GlobalHeader config={config} />
+ <main className="main">
+ <header className="page-header">
+ <h1>{pageTitle}</h1>
+ </header>
+ {pages.map((page) =>
+ "event" in page
+ ? <SlidePageEntry slide={page} config={config} key={page.uuid} />
+ : <PostPageEntry post={page} config={config} key={page.uuid} />
+ )}
+ </main>
+ <GlobalFooter config={config} />
+ </body>
+ </PageLayout>
+ );
+}
diff --git a/services/nuldoc/nuldoc-src/render.ts b/services/nuldoc/nuldoc-src/render.ts
new file mode 100644
index 0000000..fbad25a
--- /dev/null
+++ b/services/nuldoc/nuldoc-src/render.ts
@@ -0,0 +1,13 @@
+import { Node } from "./dom.ts";
+import { renderHtml } from "./renderers/html.ts";
+import { renderXml } from "./renderers/xml.ts";
+
+export type RendererType = "html" | "xml";
+
+export function render(root: Node, renderer: RendererType): string {
+ if (renderer === "html") {
+ return renderHtml(root);
+ } else {
+ return renderXml(root);
+ }
+}
diff --git a/services/nuldoc/nuldoc-src/renderers/html.ts b/services/nuldoc/nuldoc-src/renderers/html.ts
new file mode 100644
index 0000000..6e829f0
--- /dev/null
+++ b/services/nuldoc/nuldoc-src/renderers/html.ts
@@ -0,0 +1,308 @@
+import { Element, forEachChild, Node, Text } from "../dom.ts";
+import { NuldocError } from "../errors.ts";
+
+export function renderHtml(root: Node): string {
+ return `<!DOCTYPE html>\n` + nodeToHtmlText(root, {
+ indentLevel: 0,
+ isInPre: false,
+ });
+}
+
+type Context = {
+ indentLevel: number;
+ isInPre: boolean;
+};
+
+type Dtd = { type: "block" | "inline"; self_closing?: boolean };
+
+function getDtd(name: string): Dtd {
+ switch (name) {
+ case "a":
+ return { type: "inline" };
+ case "article":
+ return { type: "block" };
+ case "blockquote":
+ return { type: "block" };
+ case "body":
+ return { type: "block" };
+ case "br":
+ return { type: "block", self_closing: true };
+ case "button":
+ return { type: "block" };
+ case "canvas":
+ return { type: "block" };
+ case "caption":
+ return { type: "block" };
+ case "code":
+ return { type: "inline" };
+ case "del":
+ return { type: "block" };
+ case "div":
+ return { type: "block" };
+ case "em":
+ return { type: "inline" };
+ case "footer":
+ return { type: "block" };
+ case "h1":
+ return { type: "inline" };
+ case "h2":
+ return { type: "inline" };
+ case "h3":
+ return { type: "inline" };
+ case "h4":
+ return { type: "inline" };
+ case "h5":
+ return { type: "inline" };
+ case "h6":
+ return { type: "inline" };
+ case "head":
+ return { type: "block" };
+ case "header":
+ return { type: "block" };
+ case "hr":
+ return { type: "block", self_closing: true };
+ case "html":
+ return { type: "block" };
+ case "i":
+ return { type: "inline" };
+ case "li":
+ return { type: "block" };
+ case "link":
+ return { type: "block", self_closing: true };
+ case "img":
+ return { type: "inline", self_closing: true };
+ case "ins":
+ return { type: "block" };
+ case "main":
+ return { type: "block" };
+ case "mark":
+ return { type: "inline" };
+ case "meta":
+ return { type: "block", self_closing: true };
+ case "nav":
+ return { type: "block" };
+ case "noscript":
+ return { type: "block" };
+ case "ol":
+ return { type: "block" };
+ case "p":
+ return { type: "block" };
+ case "pre":
+ return { type: "block" };
+ case "script":
+ return { type: "block" };
+ case "section":
+ return { type: "block" };
+ case "span":
+ return { type: "inline" };
+ case "strong":
+ return { type: "inline" };
+ case "sub":
+ return { type: "inline" };
+ case "sup":
+ return { type: "inline" };
+ case "table":
+ return { type: "block" };
+ case "tbody":
+ return { type: "block" };
+ case "td": // TODO
+ return { type: "block" };
+ case "tfoot":
+ return { type: "block" };
+ case "th":
+ return { type: "block" };
+ case "thead":
+ return { type: "block" };
+ case "time":
+ return { type: "inline" };
+ case "title": // TODO
+ return { type: "inline" };
+ case "tr":
+ return { type: "block" };
+ case "ul":
+ return { type: "block" };
+ default:
+ throw new NuldocError(`[html.write] Unknown element name: ${name}`);
+ }
+}
+
+function isInlineNode(n: Node): boolean {
+ if (n.kind === "text" || n.kind === "raw") {
+ return true;
+ }
+ if (n.name !== "a") {
+ return getDtd(n.name).type === "inline";
+ }
+
+ // a tag: check if all children are inline elements.
+ let allInline = true;
+ forEachChild(n, (c) => allInline &&= isInlineNode(c));
+ return allInline;
+}
+
+function isBlockNode(n: Node): boolean {
+ return !isInlineNode(n);
+}
+
+function nodeToHtmlText(n: Node, ctx: Context): string {
+ if (n.kind === "text") {
+ return textNodeToHtmlText(n, ctx);
+ } else if (n.kind === "raw") {
+ return n.html;
+ } else {
+ return elementNodeToHtmlText(n, ctx);
+ }
+}
+
+function textNodeToHtmlText(t: Text, ctx: Context): string {
+ const s = encodeSpecialCharacters(t.content);
+ if (ctx.isInPre) return s;
+
+ return s.replaceAll(/\n */g, (_match, offset, subject) => {
+ const last_char = subject[offset - 1];
+ if (last_char === "。" || last_char === "、") {
+ // 日本語で改行するときはスペースを入れない
+ return "";
+ } else {
+ return " ";
+ }
+ });
+}
+
+function encodeSpecialCharacters(s: string): string {
+ return s.replaceAll(/&(?!\w+;)/g, "&amp;")
+ .replaceAll(/</g, "&lt;")
+ .replaceAll(/>/g, "&gt;")
+ .replaceAll(/'/g, "&apos;")
+ .replaceAll(/"/g, "&quot;");
+}
+
+function elementNodeToHtmlText(e: Element, ctx: Context): string {
+ const dtd = getDtd(e.name);
+
+ let s = "";
+
+ if (isBlockNode(e)) {
+ s += indent(ctx);
+ }
+ s += `<${e.name}`;
+ const attributes = getElementAttributes(e);
+ if (attributes.length > 0) {
+ s += " ";
+ for (let i = 0; i < attributes.length; i++) {
+ const [name, value] = attributes[i];
+ if (name === "defer" && value === "true") {
+ // TODO
+ s += "defer";
+ } else {
+ s += `${name === "className" ? "class" : name}="${
+ encodeSpecialCharacters(value)
+ }"`;
+ }
+ if (i !== attributes.length - 1) {
+ s += " ";
+ }
+ }
+ }
+ s += ">";
+ if (isBlockNode(e) && e.name !== "pre") {
+ s += "\n";
+ }
+
+ ctx.indentLevel += 1;
+
+ let prevChild: Node | null = null;
+ if (e.name === "pre") {
+ ctx.isInPre = true;
+ }
+ forEachChild(e, (c) => {
+ if (isBlockNode(e) && !ctx.isInPre) {
+ if (isInlineNode(c)) {
+ if (needsIndent(prevChild)) {
+ s += indent(ctx);
+ }
+ } else {
+ if (needsLineBreak(prevChild)) {
+ s += "\n";
+ }
+ }
+ }
+ s += nodeToHtmlText(c, ctx);
+ prevChild = c;
+ });
+ if (e.name === "pre") {
+ ctx.isInPre = false;
+ }
+
+ ctx.indentLevel -= 1;
+ if (!dtd.self_closing) {
+ if (e.name !== "pre") {
+ if (isBlockNode(e)) {
+ if (needsLineBreak(prevChild)) {
+ s += "\n";
+ }
+ s += indent(ctx);
+ }
+ }
+ s += `</${e.name}>`;
+ if (isBlockNode(e)) {
+ s += "\n";
+ }
+ }
+ return s;
+}
+
+function indent(ctx: Context): string {
+ return " ".repeat(ctx.indentLevel);
+}
+
+function getElementAttributes(e: Element): [string, string][] {
+ return [...Object.entries(e.attributes)]
+ .filter((a) => !a[0].startsWith("__"))
+ .filter((a) => a[1] !== undefined)
+ .sort(
+ (a, b) => {
+ // Special rules:
+ if (e.name === "meta") {
+ if (a[0] === "content" && b[0] === "name") {
+ return 1;
+ }
+ if (a[0] === "name" && b[0] === "content") {
+ return -1;
+ }
+ if (a[0] === "content" && b[0] === "property") {
+ return 1;
+ }
+ if (a[0] === "property" && b[0] === "content") {
+ return -1;
+ }
+ }
+ if (e.name === "link") {
+ if (a[0] === "href" && b[0] === "rel") {
+ return 1;
+ }
+ if (a[0] === "rel" && b[0] === "href") {
+ return -1;
+ }
+ if (a[0] === "href" && b[0] === "type") {
+ return 1;
+ }
+ if (a[0] === "type" && b[0] === "href") {
+ return -1;
+ }
+ }
+ // General rules:
+ if (a[0] > b[0]) return 1;
+ else if (a[0] < b[0]) return -1;
+ else return 0;
+ },
+ );
+}
+
+function needsIndent(prevChild: Node | null): boolean {
+ return !prevChild || isBlockNode(prevChild);
+}
+
+function needsLineBreak(prevChild: Node | null): boolean {
+ return !needsIndent(prevChild);
+}
diff --git a/services/nuldoc/nuldoc-src/renderers/xml.ts b/services/nuldoc/nuldoc-src/renderers/xml.ts
new file mode 100644
index 0000000..523567a
--- /dev/null
+++ b/services/nuldoc/nuldoc-src/renderers/xml.ts
@@ -0,0 +1,128 @@
+import { Element, forEachChild, Node, Text } from "../dom.ts";
+
+export function renderXml(root: Node): string {
+ return `<?xml version="1.0" encoding="utf-8"?>\n` + nodeToXmlText(root, {
+ indentLevel: 0,
+ });
+}
+
+type Context = {
+ indentLevel: number;
+};
+
+type Dtd = { type: "block" | "inline" };
+
+function getDtd(name: string): Dtd {
+ switch (name) {
+ case "feed":
+ case "entry":
+ case "author":
+ return { type: "block" };
+ default:
+ return { type: "inline" };
+ }
+}
+
+function isInlineNode(n: Node): boolean {
+ if (n.kind === "text" || n.kind === "raw") {
+ return true;
+ }
+ return getDtd(n.name).type === "inline";
+}
+
+function isBlockNode(n: Node): boolean {
+ return !isInlineNode(n);
+}
+
+function nodeToXmlText(n: Node, ctx: Context): string {
+ if (n.kind === "text") {
+ return textNodeToXmlText(n);
+ } else if (n.kind === "raw") {
+ return n.html;
+ } else {
+ return elementNodeToXmlText(n, ctx);
+ }
+}
+
+function textNodeToXmlText(t: Text): string {
+ const s = encodeSpecialCharacters(t.content);
+
+ // TODO: 日本語で改行するときはスペースを入れない
+ return s.replaceAll(/\n */g, " ");
+}
+
+function encodeSpecialCharacters(s: string): string {
+ return s.replaceAll(/&(?!\w+;)/g, "&amp;")
+ .replaceAll(/</g, "&lt;")
+ .replaceAll(/>/g, "&gt;")
+ .replaceAll(/'/g, "&apos;")
+ .replaceAll(/"/g, "&quot;");
+}
+
+function elementNodeToXmlText(e: Element, ctx: Context): string {
+ let s = "";
+
+ s += indent(ctx);
+ s += `<${e.name}`;
+ const attributes = getElementAttributes(e);
+ if (attributes.length > 0) {
+ s += " ";
+ for (let i = 0; i < attributes.length; i++) {
+ const [name, value] = attributes[i];
+ s += `${name}="${encodeSpecialCharacters(value)}"`;
+ if (i !== attributes.length - 1) {
+ s += " ";
+ }
+ }
+ }
+ s += ">";
+ if (isBlockNode(e)) {
+ s += "\n";
+ }
+ ctx.indentLevel += 1;
+
+ forEachChild(e, (c) => {
+ s += nodeToXmlText(c, ctx);
+ });
+
+ ctx.indentLevel -= 1;
+ if (isBlockNode(e)) {
+ s += indent(ctx);
+ }
+ s += `</${e.name}>`;
+ s += "\n";
+
+ return s;
+}
+
+function indent(ctx: Context): string {
+ return " ".repeat(ctx.indentLevel);
+}
+
+function getElementAttributes(e: Element): [string, string][] {
+ return [...Object.entries(e.attributes)]
+ .filter((a) => !a[0].startsWith("__"))
+ .sort(
+ (a, b) => {
+ // Special rules:
+ if (e.name === "link") {
+ if (a[0] === "href" && b[0] === "rel") {
+ return 1;
+ }
+ if (a[0] === "rel" && b[0] === "href") {
+ return -1;
+ }
+ if (a[0] === "href" && b[0] === "type") {
+ return 1;
+ }
+ if (a[0] === "type" && b[0] === "href") {
+ return -1;
+ }
+ }
+ // General rules:
+ if (a[0] > b[0]) return 1;
+ else if (a[0] < b[0]) return -1;
+ else return 0;
+ },
+ );
+}
diff --git a/services/nuldoc/nuldoc-src/revision.ts b/services/nuldoc/nuldoc-src/revision.ts
new file mode 100644
index 0000000..a22b6bc
--- /dev/null
+++ b/services/nuldoc/nuldoc-src/revision.ts
@@ -0,0 +1,37 @@
+export type Date = {
+ year: number;
+ month: number;
+ day: number;
+};
+
+export function stringToDate(s: string): Date {
+ const match = s.match(/(\d{4})-(\d{2})-(\d{2})/);
+ if (match === null) {
+ throw new Error();
+ }
+ const [_, y, m, d] = match;
+ return { year: parseInt(y), month: parseInt(m), day: parseInt(d) };
+}
+
+export function dateToString(date: Date): string {
+ const y = `${date.year}`.padStart(4, "0");
+ const m = `${date.month}`.padStart(2, "0");
+ const d = `${date.day}`.padStart(2, "0");
+ return `${y}-${m}-${d}`;
+}
+
+export function dateToRfc3339String(date: Date): string {
+ // 2021-01-01T12:00:00+00:00
+ // TODO: currently, time part is fixed to 00:00:00.
+ const y = `${date.year}`.padStart(4, "0");
+ const m = `${date.month}`.padStart(2, "0");
+ const d = `${date.day}`.padStart(2, "0");
+ return `${y}-${m}-${d}T00:00:00+09:00`;
+}
+
+export type Revision = {
+ number: number;
+ date: Date;
+ remark: string;
+ isInternal: boolean;
+};
diff --git a/services/nuldoc/nuldoc-src/slide/parse.ts b/services/nuldoc/nuldoc-src/slide/parse.ts
new file mode 100644
index 0000000..c5a8967
--- /dev/null
+++ b/services/nuldoc/nuldoc-src/slide/parse.ts
@@ -0,0 +1,20 @@
+import { parse as parseToml } from "@std/toml";
+import {
+ createNewSlideFromMetadata,
+ Slide,
+ SlideMetadataSchema,
+} from "./slide.ts";
+
+export async function parseSlideFile(filePath: string): Promise<Slide> {
+ try {
+ const root = SlideMetadataSchema.parse(
+ parseToml(await Deno.readTextFile(filePath)),
+ );
+ return createNewSlideFromMetadata(root, filePath);
+ } catch (e) {
+ if (e instanceof Error) {
+ e.message = `${e.message} in ${filePath}`;
+ }
+ throw e;
+ }
+}
diff --git a/services/nuldoc/nuldoc-src/slide/slide.ts b/services/nuldoc/nuldoc-src/slide/slide.ts
new file mode 100644
index 0000000..8fe99ea
--- /dev/null
+++ b/services/nuldoc/nuldoc-src/slide/slide.ts
@@ -0,0 +1,67 @@
+import { SlideError } from "../errors.ts";
+import { Revision, stringToDate } from "../revision.ts";
+import { z } from "zod/mod.ts";
+
+export const SlideMetadataSchema = z.object({
+ slide: z.object({
+ uuid: z.string(),
+ title: z.string(),
+ event: z.string(),
+ talkType: z.string(),
+ link: z.string(),
+ tags: z.array(z.string()),
+ revisions: z.array(z.object({
+ date: z.string(),
+ remark: z.string(),
+ isInternal: z.boolean().optional(),
+ })),
+ }),
+});
+
+export type SlideMetadata = z.infer<typeof SlideMetadataSchema>;
+
+export type Slide = {
+ sourceFilePath: string;
+ uuid: string;
+ title: string;
+ event: string;
+ talkType: string;
+ slideLink: string;
+ tags: string[];
+ revisions: Revision[];
+};
+
+export function createNewSlideFromMetadata(
+ { slide }: SlideMetadata,
+ sourceFilePath: string,
+): Slide {
+ const revisions = slide.revisions.map(
+ (rev, i) => {
+ const date = rev.date;
+ const remark = rev.remark;
+ const isInternal = rev.isInternal ?? false;
+ return {
+ number: i + 1,
+ date: stringToDate(date),
+ remark,
+ isInternal,
+ };
+ },
+ );
+ if (revisions.length === 0) {
+ throw new SlideError(
+ `[slide.new] 'slide.revisions' field is empty`,
+ );
+ }
+
+ return {
+ sourceFilePath: sourceFilePath,
+ uuid: slide.uuid,
+ title: slide.title,
+ event: slide.event,
+ talkType: slide.talkType,
+ slideLink: slide.link,
+ tags: slide.tags,
+ revisions: revisions,
+ };
+}