From 98abcc023b99898f3a7e182e2330ea809a4c99e2 Mon Sep 17 00:00:00 2001
From: nsfisis <nsfisis@gmail.com>
Date: Sun, 12 Jan 2025 19:43:59 +0900
Subject: refactor(blog/nuldoc): convert components/*.ts to TSX

---
 .../blog/nuldoc-src/components/SlidePageEntry.tsx  | 39 ++++++++++++++++++++++
 1 file changed, 39 insertions(+)
 create mode 100644 vhosts/blog/nuldoc-src/components/SlidePageEntry.tsx

(limited to 'vhosts/blog/nuldoc-src/components/SlidePageEntry.tsx')

diff --git a/vhosts/blog/nuldoc-src/components/SlidePageEntry.tsx b/vhosts/blog/nuldoc-src/components/SlidePageEntry.tsx
new file mode 100644
index 00000000..3fa827f9
--- /dev/null
+++ b/vhosts/blog/nuldoc-src/components/SlidePageEntry.tsx
@@ -0,0 +1,39 @@
+import {
+  getPostPublishedDate,
+  getPostUpdatedDate,
+  postHasAnyUpdates,
+} from "../pages/post.tsx";
+import { SlidePage } from "../pages/slide.tsx";
+import { dateToString } from "../revision.ts";
+
+export default function SlidePageEntry({ slide }: { slide: SlidePage }) {
+  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) && " 更新"}
+        </footer>
+      </a>
+    </article>
+  );
+}
-- 
cgit v1.2.3-70-g09d2


From 16182acfcc1fad2885b9c1a96fe74d8ce56a50e0 Mon Sep 17 00:00:00 2001
From: nsfisis <nsfisis@gmail.com>
Date: Sun, 12 Jan 2025 21:59:55 +0900
Subject: refactor(blog/nuldoc): separate pages/* to pages/* and generators/*

---
 vhosts/blog/nuldoc-src/atom/generate.ts            |   4 +-
 vhosts/blog/nuldoc-src/commands/build.ts           |  20 ++--
 .../blog/nuldoc-src/components/PostPageEntry.tsx   |   2 +-
 .../blog/nuldoc-src/components/SlidePageEntry.tsx  |   4 +-
 vhosts/blog/nuldoc-src/generators/about.ts         |  23 ++++
 vhosts/blog/nuldoc-src/generators/home.ts          |  19 ++++
 vhosts/blog/nuldoc-src/generators/not_found.ts     |  21 ++++
 vhosts/blog/nuldoc-src/generators/post.ts          |  63 +++++++++++
 vhosts/blog/nuldoc-src/generators/post_list.ts     |  23 ++++
 vhosts/blog/nuldoc-src/generators/slide.ts         |  53 +++++++++
 vhosts/blog/nuldoc-src/generators/slide_list.ts    |  23 ++++
 vhosts/blog/nuldoc-src/generators/tag.ts           |  33 ++++++
 vhosts/blog/nuldoc-src/generators/tag_list.ts      |  23 ++++
 vhosts/blog/nuldoc-src/generators/tagged_page.ts   |   4 +
 vhosts/blog/nuldoc-src/pages/AboutPage.tsx         |  94 ++++++++++++++++
 vhosts/blog/nuldoc-src/pages/HomePage.tsx          |  53 +++++++++
 vhosts/blog/nuldoc-src/pages/NotFoundPage.tsx      |  27 +++++
 vhosts/blog/nuldoc-src/pages/PostListPage.tsx      |  41 +++++++
 vhosts/blog/nuldoc-src/pages/PostPage.tsx          |  74 +++++++++++++
 vhosts/blog/nuldoc-src/pages/SlideListPage.tsx     |  42 +++++++
 vhosts/blog/nuldoc-src/pages/SlidePage.tsx         |  70 ++++++++++++
 vhosts/blog/nuldoc-src/pages/TagListPage.tsx       |  57 ++++++++++
 vhosts/blog/nuldoc-src/pages/TagPage.tsx           |  43 +++++++
 vhosts/blog/nuldoc-src/pages/about.tsx             | 105 ------------------
 vhosts/blog/nuldoc-src/pages/home.tsx              |  64 -----------
 vhosts/blog/nuldoc-src/pages/not_found.tsx         |  38 -------
 vhosts/blog/nuldoc-src/pages/post.tsx              | 123 ---------------------
 vhosts/blog/nuldoc-src/pages/post_list.tsx         |  52 ---------
 vhosts/blog/nuldoc-src/pages/slide.tsx             | 109 ------------------
 vhosts/blog/nuldoc-src/pages/slide_list.tsx        |  53 ---------
 vhosts/blog/nuldoc-src/pages/tag.tsx               |  63 -----------
 vhosts/blog/nuldoc-src/pages/tag_list.tsx          |  68 ------------
 vhosts/blog/nuldoc-src/pages/tagged_page.ts        |   4 -
 33 files changed, 801 insertions(+), 694 deletions(-)
 create mode 100644 vhosts/blog/nuldoc-src/generators/about.ts
 create mode 100644 vhosts/blog/nuldoc-src/generators/home.ts
 create mode 100644 vhosts/blog/nuldoc-src/generators/not_found.ts
 create mode 100644 vhosts/blog/nuldoc-src/generators/post.ts
 create mode 100644 vhosts/blog/nuldoc-src/generators/post_list.ts
 create mode 100644 vhosts/blog/nuldoc-src/generators/slide.ts
 create mode 100644 vhosts/blog/nuldoc-src/generators/slide_list.ts
 create mode 100644 vhosts/blog/nuldoc-src/generators/tag.ts
 create mode 100644 vhosts/blog/nuldoc-src/generators/tag_list.ts
 create mode 100644 vhosts/blog/nuldoc-src/generators/tagged_page.ts
 create mode 100644 vhosts/blog/nuldoc-src/pages/AboutPage.tsx
 create mode 100644 vhosts/blog/nuldoc-src/pages/HomePage.tsx
 create mode 100644 vhosts/blog/nuldoc-src/pages/NotFoundPage.tsx
 create mode 100644 vhosts/blog/nuldoc-src/pages/PostListPage.tsx
 create mode 100644 vhosts/blog/nuldoc-src/pages/PostPage.tsx
 create mode 100644 vhosts/blog/nuldoc-src/pages/SlideListPage.tsx
 create mode 100644 vhosts/blog/nuldoc-src/pages/SlidePage.tsx
 create mode 100644 vhosts/blog/nuldoc-src/pages/TagListPage.tsx
 create mode 100644 vhosts/blog/nuldoc-src/pages/TagPage.tsx
 delete mode 100644 vhosts/blog/nuldoc-src/pages/about.tsx
 delete mode 100644 vhosts/blog/nuldoc-src/pages/home.tsx
 delete mode 100644 vhosts/blog/nuldoc-src/pages/not_found.tsx
 delete mode 100644 vhosts/blog/nuldoc-src/pages/post.tsx
 delete mode 100644 vhosts/blog/nuldoc-src/pages/post_list.tsx
 delete mode 100644 vhosts/blog/nuldoc-src/pages/slide.tsx
 delete mode 100644 vhosts/blog/nuldoc-src/pages/slide_list.tsx
 delete mode 100644 vhosts/blog/nuldoc-src/pages/tag.tsx
 delete mode 100644 vhosts/blog/nuldoc-src/pages/tag_list.tsx
 delete mode 100644 vhosts/blog/nuldoc-src/pages/tagged_page.ts

(limited to 'vhosts/blog/nuldoc-src/components/SlidePageEntry.tsx')

diff --git a/vhosts/blog/nuldoc-src/atom/generate.ts b/vhosts/blog/nuldoc-src/atom/generate.ts
index 9e30ef3e..3c76f6c5 100644
--- a/vhosts/blog/nuldoc-src/atom/generate.ts
+++ b/vhosts/blog/nuldoc-src/atom/generate.ts
@@ -2,8 +2,8 @@ import { Config } from "../config.ts";
 import { el } from "../dom.ts";
 import { Page } from "../page.ts";
 import { Entry, Feed } from "./types.ts";
-import { PostPage } from "../pages/post.tsx";
-import { SlidePage } from "../pages/slide.tsx";
+import { PostPage } from "../generators/post.ts";
+import { SlidePage } from "../generators/slide.ts";
 import { dateToRfc3339String } from "../revision.ts";
 
 const BASE_NAME = "atom.xml";
diff --git a/vhosts/blog/nuldoc-src/commands/build.ts b/vhosts/blog/nuldoc-src/commands/build.ts
index a542b4f8..a596dc2a 100644
--- a/vhosts/blog/nuldoc-src/commands/build.ts
+++ b/vhosts/blog/nuldoc-src/commands/build.ts
@@ -7,20 +7,20 @@ import { parseNulDocFile } from "../ndoc/parse.ts";
 import { Page } from "../page.ts";
 import { render } from "../render.ts";
 import { dateToString } from "../revision.ts";
-import { generateAboutPage } from "../pages/about.tsx";
-import { generateHomePage } from "../pages/home.tsx";
-import { generateNotFoundPage } from "../pages/not_found.tsx";
+import { generateAboutPage } from "../generators/about.ts";
+import { generateHomePage } from "../generators/home.ts";
+import { generateNotFoundPage } from "../generators/not_found.ts";
 import {
   generatePostPage,
   getPostPublishedDate,
   PostPage,
-} from "../pages/post.tsx";
-import { generatePostListPage } from "../pages/post_list.tsx";
-import { generateSlidePage, SlidePage } from "../pages/slide.tsx";
-import { generateSlideListPage } from "../pages/slide_list.tsx";
-import { generateTagPage, TagPage } from "../pages/tag.tsx";
-import { TaggedPage } from "../pages/tagged_page.ts";
-import { generateTagListPage } from "../pages/tag_list.tsx";
+} from "../generators/post.ts";
+import { generatePostListPage } 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) {
diff --git a/vhosts/blog/nuldoc-src/components/PostPageEntry.tsx b/vhosts/blog/nuldoc-src/components/PostPageEntry.tsx
index 52e2b5d4..2708b009 100644
--- a/vhosts/blog/nuldoc-src/components/PostPageEntry.tsx
+++ b/vhosts/blog/nuldoc-src/components/PostPageEntry.tsx
@@ -3,7 +3,7 @@ import {
   getPostUpdatedDate,
   postHasAnyUpdates,
   PostPage,
-} from "../pages/post.tsx";
+} from "../generators/post.ts";
 import { dateToString } from "../revision.ts";
 
 export default function PostPageEntry({ post }: { post: PostPage }) {
diff --git a/vhosts/blog/nuldoc-src/components/SlidePageEntry.tsx b/vhosts/blog/nuldoc-src/components/SlidePageEntry.tsx
index 3fa827f9..d2cf9a17 100644
--- a/vhosts/blog/nuldoc-src/components/SlidePageEntry.tsx
+++ b/vhosts/blog/nuldoc-src/components/SlidePageEntry.tsx
@@ -2,8 +2,8 @@ import {
   getPostPublishedDate,
   getPostUpdatedDate,
   postHasAnyUpdates,
-} from "../pages/post.tsx";
-import { SlidePage } from "../pages/slide.tsx";
+} from "../generators/post.ts";
+import { SlidePage } from "../generators/slide.ts";
 import { dateToString } from "../revision.ts";
 
 export default function SlidePageEntry({ slide }: { slide: SlidePage }) {
diff --git a/vhosts/blog/nuldoc-src/generators/about.ts b/vhosts/blog/nuldoc-src/generators/about.ts
new file mode 100644
index 00000000..6663a190
--- /dev/null
+++ b/vhosts/blog/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/vhosts/blog/nuldoc-src/generators/home.ts b/vhosts/blog/nuldoc-src/generators/home.ts
new file mode 100644
index 00000000..679dd39a
--- /dev/null
+++ b/vhosts/blog/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/vhosts/blog/nuldoc-src/generators/not_found.ts b/vhosts/blog/nuldoc-src/generators/not_found.ts
new file mode 100644
index 00000000..f5a81c86
--- /dev/null
+++ b/vhosts/blog/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/vhosts/blog/nuldoc-src/generators/post.ts b/vhosts/blog/nuldoc-src/generators/post.ts
new file mode 100644
index 00000000..f90ab193
--- /dev/null
+++ b/vhosts/blog/nuldoc-src/generators/post.ts
@@ -0,0 +1,63 @@
+import { join } from "std/path/mod.ts";
+import { renderToDOM } from "../jsx/render.ts";
+import PostPage from "../pages/PostPage.tsx";
+import { Config } from "../config.ts";
+import { Document } from "../ndoc/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(".ndoc", ""),
+    "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/vhosts/blog/nuldoc-src/generators/post_list.ts b/vhosts/blog/nuldoc-src/generators/post_list.ts
new file mode 100644
index 00000000..67a4b996
--- /dev/null
+++ b/vhosts/blog/nuldoc-src/generators/post_list.ts
@@ -0,0 +1,23 @@
+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 generatePostListPage(
+  posts: PostPage[],
+  config: Config,
+): Promise<PostListPage> {
+  const html = await renderToDOM(
+    PostListPage(posts, config),
+  );
+
+  return {
+    root: html,
+    renderer: "html",
+    destFilePath: "/posts/index.html",
+    href: "/posts/",
+  };
+}
diff --git a/vhosts/blog/nuldoc-src/generators/slide.ts b/vhosts/blog/nuldoc-src/generators/slide.ts
new file mode 100644
index 00000000..ab68d332
--- /dev/null
+++ b/vhosts/blog/nuldoc-src/generators/slide.ts
@@ -0,0 +1,53 @@
+import { join } from "std/path/mod.ts";
+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/vhosts/blog/nuldoc-src/generators/slide_list.ts b/vhosts/blog/nuldoc-src/generators/slide_list.ts
new file mode 100644
index 00000000..abebe109
--- /dev/null
+++ b/vhosts/blog/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/vhosts/blog/nuldoc-src/generators/tag.ts b/vhosts/blog/nuldoc-src/generators/tag.ts
new file mode 100644
index 00000000..dbd8ef93
--- /dev/null
+++ b/vhosts/blog/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/vhosts/blog/nuldoc-src/generators/tag_list.ts b/vhosts/blog/nuldoc-src/generators/tag_list.ts
new file mode 100644
index 00000000..7baad8cf
--- /dev/null
+++ b/vhosts/blog/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/vhosts/blog/nuldoc-src/generators/tagged_page.ts b/vhosts/blog/nuldoc-src/generators/tagged_page.ts
new file mode 100644
index 00000000..23de8cb4
--- /dev/null
+++ b/vhosts/blog/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/vhosts/blog/nuldoc-src/pages/AboutPage.tsx b/vhosts/blog/nuldoc-src/pages/AboutPage.tsx
new file mode 100644
index 00000000..0000edf5
--- /dev/null
+++ b/vhosts/blog/nuldoc-src/pages/AboutPage.tsx
@@ -0,0 +1,94 @@
+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">
+                <StaticScript fileName="/p5.min.js" config={config} />
+                <StaticScript fileName="/my-icon.js" config={config} />
+                <div id="p5jsMyIcon" />
+                <noscript>
+                  <img src="/favicon.svg" />
+                </noscript>
+              </div>
+            </header>
+            <div className="post-content">
+              <section>
+                <h2>読み方</h2>
+                <p>
+                  読み方は決めていません。音にする必要があるときは本名である「いまむら」をお使いください。
+                </p>
+              </section>
+              <section>
+                <h2>アカウント</h2>
+                <ul>
+                  <li>
+                    <a href="https://twitter.com/nsfisis">
+                      Twitter (現 𝕏): @nsfisis
+                    </a>
+                  </li>
+                  <li>
+                    <a href="https://github.com/nsfisis">GitHub: @nsfisis</a>
+                  </li>
+                </ul>
+              </section>
+              <section>
+                <h2>仕事</h2>
+                <ul>
+                  <li>
+                    {"2021-01～現在: "}
+                    <a href="https://www.dgcircus.com/">
+                      デジタルサーカス株式会社
+                    </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/vhosts/blog/nuldoc-src/pages/HomePage.tsx b/vhosts/blog/nuldoc-src/pages/HomePage.tsx
new file mode 100644
index 00000000..8850d039
--- /dev/null
+++ b/vhosts/blog/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/vhosts/blog/nuldoc-src/pages/NotFoundPage.tsx b/vhosts/blog/nuldoc-src/pages/NotFoundPage.tsx
new file mode 100644
index 00000000..9631fef2
--- /dev/null
+++ b/vhosts/blog/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/vhosts/blog/nuldoc-src/pages/PostListPage.tsx b/vhosts/blog/nuldoc-src/pages/PostListPage.tsx
new file mode 100644
index 00000000..3fcfbf1f
--- /dev/null
+++ b/vhosts/blog/nuldoc-src/pages/PostListPage.tsx
@@ -0,0 +1,41 @@
+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 { Config } from "../config.ts";
+import { dateToString } from "../revision.ts";
+import { getPostPublishedDate, PostPage } from "../generators/post.ts";
+
+export default function PostListPage(
+  posts: PostPage[],
+  config: Config,
+) {
+  const pageTitle = "投稿一覧";
+
+  return (
+    <PageLayout
+      metaCopyrightYear={config.blog.siteCopyrightYear}
+      metaDescription="投稿した記事の一覧"
+      metaTitle={`${pageTitle}｜${config.blog.siteName}`}
+      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}</h1>
+          </header>
+          {Array.from(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;
+          }).map((post) => <PostPageEntry post={post} />)}
+        </main>
+        <GlobalFooter config={config} />
+      </body>
+    </PageLayout>
+  );
+}
diff --git a/vhosts/blog/nuldoc-src/pages/PostPage.tsx b/vhosts/blog/nuldoc-src/pages/PostPage.tsx
new file mode 100644
index 00000000..e6aa83aa
--- /dev/null
+++ b/vhosts/blog/nuldoc-src/pages/PostPage.tsx
@@ -0,0 +1,74 @@
+import GlobalFooter from "../components/GlobalFooter.tsx";
+import GlobalHeader from "../components/GlobalHeader.tsx";
+import PageLayout from "../components/PageLayout.tsx";
+import { Config, getTagLabel } from "../config.ts";
+import { Element } from "../dom.ts";
+import { Document } from "../ndoc/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>
+            <div className="post-content">
+              <section>
+                <h2 id="changelog">更新履歴</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
+              }
+              {
+                // TODO: footnotes
+                // <div id="footnotes">
+                //   <% for footnote in footnotes %>
+                //     <div class="footnote" id="_footnotedef_<%= footnote.index %>">
+                //       <a href="#_footnoteref_<%= footnote.index %>"><%= footnote.index %></a>. <%= footnote.text %>
+                //     </div>
+                //   <% end %>
+                // </div>
+              }
+            </div>
+          </article>
+        </main>
+        <GlobalFooter config={config} />
+      </body>
+    </PageLayout>
+  );
+}
diff --git a/vhosts/blog/nuldoc-src/pages/SlideListPage.tsx b/vhosts/blog/nuldoc-src/pages/SlideListPage.tsx
new file mode 100644
index 00000000..44d6afff
--- /dev/null
+++ b/vhosts/blog/nuldoc-src/pages/SlideListPage.tsx
@@ -0,0 +1,42 @@
+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} />)}
+        </main>
+        <GlobalFooter config={config} />
+      </body>
+    </PageLayout>
+  );
+}
diff --git a/vhosts/blog/nuldoc-src/pages/SlidePage.tsx b/vhosts/blog/nuldoc-src/pages/SlidePage.tsx
new file mode 100644
index 00000000..6d167036
--- /dev/null
+++ b/vhosts/blog/nuldoc-src/pages/SlidePage.tsx
@@ -0,0 +1,70 @@
+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>
+                <h2 id="changelog">更新履歴</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">Prev</button>
+                <button id="next">Next</button>
+              </div>
+              <StaticScript
+                fileName="/slide.js"
+                type="module"
+                config={config}
+              />
+            </div>
+          </article>
+        </main>
+        <GlobalFooter config={config} />
+      </body>
+    </PageLayout>
+  );
+}
diff --git a/vhosts/blog/nuldoc-src/pages/TagListPage.tsx b/vhosts/blog/nuldoc-src/pages/TagListPage.tsx
new file mode 100644
index 00000000..cdb83ea5
--- /dev/null
+++ b/vhosts/blog/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/vhosts/blog/nuldoc-src/pages/TagPage.tsx b/vhosts/blog/nuldoc-src/pages/TagPage.tsx
new file mode 100644
index 00000000..568dfaa6
--- /dev/null
+++ b/vhosts/blog/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} />
+              : <PostPageEntry post={page} />
+          )}
+        </main>
+        <GlobalFooter config={config} />
+      </body>
+    </PageLayout>
+  );
+}
diff --git a/vhosts/blog/nuldoc-src/pages/about.tsx b/vhosts/blog/nuldoc-src/pages/about.tsx
deleted file mode 100644
index 91d7fee4..00000000
--- a/vhosts/blog/nuldoc-src/pages/about.tsx
+++ /dev/null
@@ -1,105 +0,0 @@
-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 { renderToDOM } from "../jsx/render.ts";
-import { Page } from "../page.ts";
-import { dateToString } from "../revision.ts";
-import { getPostPublishedDate } from "./post.tsx";
-import { SlidePage } from "./slide.tsx";
-
-export type AboutPage = Page;
-
-export async function generateAboutPage(
-  slides: SlidePage[],
-  config: Config,
-): Promise<AboutPage> {
-  const html = await renderToDOM(
-    <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">
-                <StaticScript fileName="/p5.min.js" config={config} />
-                <StaticScript fileName="/my-icon.js" config={config} />
-                <div id="p5jsMyIcon" />
-                <noscript>
-                  <img src="/favicon.svg" />
-                </noscript>
-              </div>
-            </header>
-            <div className="post-content">
-              <section>
-                <h2>読み方</h2>
-                <p>
-                  読み方は決めていません。音にする必要があるときは本名である「いまむら」をお使いください。
-                </p>
-              </section>
-              <section>
-                <h2>アカウント</h2>
-                <ul>
-                  <li>
-                    <a href="https://twitter.com/nsfisis">
-                      Twitter (現 𝕏): @nsfisis
-                    </a>
-                  </li>
-                  <li>
-                    <a href="https://github.com/nsfisis">GitHub: @nsfisis</a>
-                  </li>
-                </ul>
-              </section>
-              <section>
-                <h2>仕事</h2>
-                <ul>
-                  <li>
-                    {"2021-01～現在: "}
-                    <a href="https://www.dgcircus.com/">
-                      デジタルサーカス株式会社
-                    </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>,
-  );
-
-  return {
-    root: html,
-    renderer: "html",
-    destFilePath: "/about/index.html",
-    href: "/about/",
-  };
-}
diff --git a/vhosts/blog/nuldoc-src/pages/home.tsx b/vhosts/blog/nuldoc-src/pages/home.tsx
deleted file mode 100644
index a9a5d542..00000000
--- a/vhosts/blog/nuldoc-src/pages/home.tsx
+++ /dev/null
@@ -1,64 +0,0 @@
-import GlobalFooter from "../components/GlobalFooter.tsx";
-import { renderToDOM } from "../jsx/render.ts";
-import GlobalHeader from "../components/GlobalHeader.tsx";
-import PageLayout from "../components/PageLayout.tsx";
-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(
-    <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>,
-  );
-
-  return {
-    root: html,
-    renderer: "html",
-    destFilePath: "/index.html",
-    href: "/",
-  };
-}
diff --git a/vhosts/blog/nuldoc-src/pages/not_found.tsx b/vhosts/blog/nuldoc-src/pages/not_found.tsx
deleted file mode 100644
index 27be0738..00000000
--- a/vhosts/blog/nuldoc-src/pages/not_found.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-import GlobalFooter from "../components/GlobalFooter.tsx";
-import { renderToDOM } from "../jsx/render.ts";
-import GlobalHeader from "../components/GlobalHeader.tsx";
-import PageLayout from "../components/PageLayout.tsx";
-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(
-    <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>,
-  );
-
-  return {
-    root: html,
-    renderer: "html",
-    destFilePath: "/404.html",
-    href: "/404.html",
-  };
-}
diff --git a/vhosts/blog/nuldoc-src/pages/post.tsx b/vhosts/blog/nuldoc-src/pages/post.tsx
deleted file mode 100644
index 541c33e3..00000000
--- a/vhosts/blog/nuldoc-src/pages/post.tsx
+++ /dev/null
@@ -1,123 +0,0 @@
-import { join } from "std/path/mod.ts";
-import { renderToDOM } from "../jsx/render.ts";
-import GlobalFooter from "../components/GlobalFooter.tsx";
-import GlobalHeader from "../components/GlobalHeader.tsx";
-import PageLayout from "../components/PageLayout.tsx";
-import { Config, getTagLabel } from "../config.ts";
-import { Element } from "../dom.ts";
-import { Document } from "../ndoc/document.ts";
-import { Page } from "../page.ts";
-import { Date, dateToString, 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(
-    <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>
-            <div className="post-content">
-              <section>
-                <h2 id="changelog">更新履歴</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
-              }
-              {
-                // TODO: footnotes
-                // <div id="footnotes">
-                //   <% for footnote in footnotes %>
-                //     <div class="footnote" id="_footnotedef_<%= footnote.index %>">
-                //       <a href="#_footnoteref_<%= footnote.index %>"><%= footnote.index %></a>. <%= footnote.text %>
-                //     </div>
-                //   <% end %>
-                // </div>
-              }
-            </div>
-          </article>
-        </main>
-        <GlobalFooter config={config} />
-      </body>
-    </PageLayout>,
-  );
-
-  const cwd = Deno.cwd();
-  const contentDir = join(cwd, config.locations.contentDir);
-  const destFilePath = join(
-    doc.sourceFilePath.replace(contentDir, "").replace(".ndoc", ""),
-    "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/vhosts/blog/nuldoc-src/pages/post_list.tsx b/vhosts/blog/nuldoc-src/pages/post_list.tsx
deleted file mode 100644
index 3cf17df4..00000000
--- a/vhosts/blog/nuldoc-src/pages/post_list.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-import GlobalFooter from "../components/GlobalFooter.tsx";
-import { renderToDOM } from "../jsx/render.ts";
-import GlobalHeader from "../components/GlobalHeader.tsx";
-import PageLayout from "../components/PageLayout.tsx";
-import PostPageEntry from "../components/PostPageEntry.tsx";
-import { Config } from "../config.ts";
-import { Page } from "../page.ts";
-import { dateToString } from "../revision.ts";
-import { getPostPublishedDate, PostPage } from "./post.tsx";
-
-export type PostListPage = Page;
-
-export async function generatePostListPage(
-  posts: PostPage[],
-  config: Config,
-): Promise<PostListPage> {
-  const pageTitle = "投稿一覧";
-
-  const html = await renderToDOM(
-    <PageLayout
-      metaCopyrightYear={config.blog.siteCopyrightYear}
-      metaDescription="投稿した記事の一覧"
-      metaTitle={`${pageTitle}｜${config.blog.siteName}`}
-      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}</h1>
-          </header>
-          {Array.from(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;
-          }).map((post) => <PostPageEntry post={post} />)}
-        </main>
-        <GlobalFooter config={config} />
-      </body>
-    </PageLayout>,
-  );
-
-  return {
-    root: html,
-    renderer: "html",
-    destFilePath: "/posts/index.html",
-    href: "/posts/",
-  };
-}
diff --git a/vhosts/blog/nuldoc-src/pages/slide.tsx b/vhosts/blog/nuldoc-src/pages/slide.tsx
deleted file mode 100644
index 843f539b..00000000
--- a/vhosts/blog/nuldoc-src/pages/slide.tsx
+++ /dev/null
@@ -1,109 +0,0 @@
-import { join } from "std/path/mod.ts";
-import { renderToDOM } from "../jsx/render.ts";
-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 { Page } from "../page.ts";
-import { Date, dateToString, Revision } from "../revision.ts";
-import { Slide } from "../slide/slide.ts";
-import { getPostPublishedDate, getPostUpdatedDate } from "./post.tsx";
-
-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(
-    <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>
-                <h2 id="changelog">更新履歴</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">Prev</button>
-                <button id="next">Next</button>
-              </div>
-              <StaticScript
-                fileName="/slide.js"
-                type="module"
-                config={config}
-              />
-            </div>
-          </article>
-        </main>
-        <GlobalFooter config={config} />
-      </body>
-    </PageLayout>,
-  );
-
-  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/vhosts/blog/nuldoc-src/pages/slide_list.tsx b/vhosts/blog/nuldoc-src/pages/slide_list.tsx
deleted file mode 100644
index e42cfcea..00000000
--- a/vhosts/blog/nuldoc-src/pages/slide_list.tsx
+++ /dev/null
@@ -1,53 +0,0 @@
-import GlobalFooter from "../components/GlobalFooter.tsx";
-import { renderToDOM } from "../jsx/render.ts";
-import GlobalHeader from "../components/GlobalHeader.tsx";
-import PageLayout from "../components/PageLayout.tsx";
-import SlidePageEntry from "../components/SlidePageEntry.tsx";
-import { Config } from "../config.ts";
-import { Page } from "../page.ts";
-import { dateToString } from "../revision.ts";
-import { getPostPublishedDate } from "./post.tsx";
-import { SlidePage } from "./slide.tsx";
-
-export type SlideListPage = Page;
-
-export async function generateSlideListPage(
-  slides: SlidePage[],
-  config: Config,
-): Promise<SlideListPage> {
-  const pageTitle = "スライド一覧";
-
-  const html = await renderToDOM(
-    <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} />)}
-        </main>
-        <GlobalFooter config={config} />
-      </body>
-    </PageLayout>,
-  );
-
-  return {
-    root: html,
-    renderer: "html",
-    destFilePath: "/slides/index.html",
-    href: "/slides/",
-  };
-}
diff --git a/vhosts/blog/nuldoc-src/pages/tag.tsx b/vhosts/blog/nuldoc-src/pages/tag.tsx
deleted file mode 100644
index f7df1f13..00000000
--- a/vhosts/blog/nuldoc-src/pages/tag.tsx
+++ /dev/null
@@ -1,63 +0,0 @@
-import GlobalFooter from "../components/GlobalFooter.tsx";
-import { renderToDOM } from "../jsx/render.ts";
-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 { Page } from "../page.ts";
-import { getPostPublishedDate } from "./post.tsx";
-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 tagLabel = getTagLabel(config, tagSlug);
-  const pageTitle = `タグ「${tagLabel}」一覧`;
-
-  const html = await renderToDOM(
-    <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} />
-              : <PostPageEntry post={page} />
-          )}
-        </main>
-        <GlobalFooter config={config} />
-      </body>
-    </PageLayout>,
-  );
-
-  return {
-    root: html,
-    renderer: "html",
-    destFilePath: `/tags/${tagSlug}/index.html`,
-    href: `/tags/${tagSlug}/`,
-    tagSlug: tagSlug,
-    tagLabel: tagLabel,
-    numOfPosts: pages.filter((p) => !("event" in p)).length,
-    numOfSlides: pages.filter((p) => "event" in p).length,
-  };
-}
diff --git a/vhosts/blog/nuldoc-src/pages/tag_list.tsx b/vhosts/blog/nuldoc-src/pages/tag_list.tsx
deleted file mode 100644
index 728384f4..00000000
--- a/vhosts/blog/nuldoc-src/pages/tag_list.tsx
+++ /dev/null
@@ -1,68 +0,0 @@
-import GlobalFooter from "../components/GlobalFooter.tsx";
-import { renderToDOM } from "../jsx/render.ts";
-import GlobalHeader from "../components/GlobalHeader.tsx";
-import PageLayout from "../components/PageLayout.tsx";
-import { Config } from "../config.ts";
-import { Page } from "../page.ts";
-import { TagPage } from "./tag.tsx";
-
-export type TagListPage = Page;
-
-export async function generateTagListPage(
-  tags: TagPage[],
-  config: Config,
-): Promise<TagListPage> {
-  const pageTitle = "タグ一覧";
-
-  const html = await renderToDOM(
-    <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>,
-  );
-
-  return {
-    root: html,
-    renderer: "html",
-    destFilePath: "/tags/index.html",
-    href: "/tags/",
-  };
-}
diff --git a/vhosts/blog/nuldoc-src/pages/tagged_page.ts b/vhosts/blog/nuldoc-src/pages/tagged_page.ts
deleted file mode 100644
index 026ca91f..00000000
--- a/vhosts/blog/nuldoc-src/pages/tagged_page.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-import { PostPage } from "./post.tsx";
-import { SlidePage } from "./slide.tsx";
-
-export type TaggedPage = PostPage | SlidePage;
-- 
cgit v1.2.3-70-g09d2

