summaryrefslogtreecommitdiffhomepage
path: root/vhosts/blog/nuldoc-src
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2023-09-07 22:27:48 +0900
committernsfisis <nsfisis@gmail.com>2023-09-07 22:35:53 +0900
commit994e0114d76ae19768d5c303874a968cf6369fd0 (patch)
tree5fd3f8b169eea00084b24fbae820f75273864d2a /vhosts/blog/nuldoc-src
parent57f015992f678bfd7281f171fb9d71349c96a1a0 (diff)
downloadnsfisis.dev-994e0114d76ae19768d5c303874a968cf6369fd0.tar.gz
nsfisis.dev-994e0114d76ae19768d5c303874a968cf6369fd0.tar.zst
nsfisis.dev-994e0114d76ae19768d5c303874a968cf6369fd0.zip
meta: migrate to monorepo
Diffstat (limited to 'vhosts/blog/nuldoc-src')
-rw-r--r--vhosts/blog/nuldoc-src/commands/build.ts208
-rw-r--r--vhosts/blog/nuldoc-src/commands/new.ts92
-rw-r--r--vhosts/blog/nuldoc-src/commands/serve.ts38
-rw-r--r--vhosts/blog/nuldoc-src/components/global_footer.ts12
-rw-r--r--vhosts/blog/nuldoc-src/components/global_header.ts42
-rw-r--r--vhosts/blog/nuldoc-src/components/page_layout.ts78
-rw-r--r--vhosts/blog/nuldoc-src/components/post_page_entry.ts48
-rw-r--r--vhosts/blog/nuldoc-src/components/slide_page_entry.ts45
-rw-r--r--vhosts/blog/nuldoc-src/components/utils.ts30
-rw-r--r--vhosts/blog/nuldoc-src/config.ts43
-rw-r--r--vhosts/blog/nuldoc-src/docbook/document.ts108
-rw-r--r--vhosts/blog/nuldoc-src/docbook/parse.ts21
-rw-r--r--vhosts/blog/nuldoc-src/docbook/to_html.ts319
-rw-r--r--vhosts/blog/nuldoc-src/dom.ts107
-rw-r--r--vhosts/blog/nuldoc-src/errors.ts17
-rw-r--r--vhosts/blog/nuldoc-src/main.ts17
-rw-r--r--vhosts/blog/nuldoc-src/page.ts9
-rw-r--r--vhosts/blog/nuldoc-src/pages/about.ts183
-rw-r--r--vhosts/blog/nuldoc-src/pages/home.ts96
-rw-r--r--vhosts/blog/nuldoc-src/pages/not_found.ts51
-rw-r--r--vhosts/blog/nuldoc-src/pages/post.ts140
-rw-r--r--vhosts/blog/nuldoc-src/pages/post_list.ts64
-rw-r--r--vhosts/blog/nuldoc-src/pages/slide.ts140
-rw-r--r--vhosts/blog/nuldoc-src/pages/slide_list.ts65
-rw-r--r--vhosts/blog/nuldoc-src/pages/tag.ts60
-rw-r--r--vhosts/blog/nuldoc-src/pages/tag_list.ts76
-rw-r--r--vhosts/blog/nuldoc-src/pages/tagged_page.ts4
-rw-r--r--vhosts/blog/nuldoc-src/render.ts13
-rw-r--r--vhosts/blog/nuldoc-src/renderers/html.ts275
-rw-r--r--vhosts/blog/nuldoc-src/revision.ts27
-rw-r--r--vhosts/blog/nuldoc-src/slide/parse.ts19
-rw-r--r--vhosts/blog/nuldoc-src/slide/slide.ts122
-rw-r--r--vhosts/blog/nuldoc-src/types/highlight-js.d.ts10
-rw-r--r--vhosts/blog/nuldoc-src/xml.ts269
-rw-r--r--vhosts/blog/nuldoc-src/xml_test.ts17
35 files changed, 2865 insertions, 0 deletions
diff --git a/vhosts/blog/nuldoc-src/commands/build.ts b/vhosts/blog/nuldoc-src/commands/build.ts
new file mode 100644
index 00000000..da7e5cec
--- /dev/null
+++ b/vhosts/blog/nuldoc-src/commands/build.ts
@@ -0,0 +1,208 @@
+import { dirname, join, joinGlobs } from "std/path/mod.ts";
+import { ensureDir } from "std/fs/mod.ts";
+import { expandGlob } from "std/fs/expand_glob.ts";
+import { Config } from "../config.ts";
+import { parseDocBookFile } from "../docbook/parse.ts";
+import { Page } from "../page.ts";
+import { render } from "../render.ts";
+import { dateToString } from "../revision.ts";
+import { generateAboutPage } from "../pages/about.ts";
+import { generateHomePage } from "../pages/home.ts";
+import { generateNotFoundPage } from "../pages/not_found.ts";
+import {
+ generatePostPage,
+ getPostCreatedDate,
+ PostPage,
+} from "../pages/post.ts";
+import { generatePostListPage } from "../pages/post_list.ts";
+import { generateSlidePage, SlidePage } from "../pages/slide.ts";
+import { generateSlideListPage } from "../pages/slide_list.ts";
+import { generateTagPage, TagPage } from "../pages/tag.ts";
+import { TaggedPage } from "../pages/tagged_page.ts";
+import { generateTagListPage } from "../pages/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 copyStaticFiles(config);
+ await copyAssetFiles(slides, 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, "**", "*.xml"]);
+ 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 parseDocBookFile(postFile, config), config),
+ );
+ }
+ return posts;
+}
+
+async function buildPostListPage(posts: PostPage[], config: Config) {
+ const postListPage = await generatePostListPage(posts, config);
+ await writePage(postListPage, 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, "**", "*.xml"]);
+ 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), config),
+ );
+ }
+ return slides;
+}
+
+async function buildSlideListPage(slides: SlidePage[], config: Config) {
+ const slideListPage = await generateSlideListPage(slides, config);
+ await writePage(slideListPage, 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 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);
+ 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(getPostCreatedDate(a));
+ const tb = dateToString(getPostCreatedDate(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 copyAssetFiles(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 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/vhosts/blog/nuldoc-src/commands/new.ts b/vhosts/blog/nuldoc-src/commands/new.ts
new file mode 100644
index 00000000..22329972
--- /dev/null
+++ b/vhosts/blog/nuldoc-src/commands/new.ts
@@ -0,0 +1,92 @@
+import { dirname, join } from "std/path/mod.ts";
+import { ensureDir } from "std/fs/mod.ts";
+import { Config } from "../config.ts";
+
+export async function runNewCommand(config: Config) {
+ const type = Deno.args[1];
+ if (type !== "post" && type !== "slide") {
+ console.log(`Usage: nuldoc new <type>
+
+<type> must be either post or slide.`);
+ Deno.exit(1);
+ }
+
+ const now = new Date();
+ const ymd = `${now.getFullYear()}-${
+ (now.getMonth() + 1).toString().padStart(2, "0")
+ }-${now.getDate().toString().padStart(2, "0")}`;
+
+ const destFilePath = join(
+ Deno.cwd(),
+ config.locations.contentDir,
+ getDirPath(type),
+ ymd,
+ "TODO.xml",
+ );
+
+ await ensureDir(dirname(destFilePath));
+ await Deno.writeTextFile(destFilePath, getTemplate(type, ymd));
+ console.log(
+ `New file ${
+ destFilePath.replace(Deno.cwd(), "")
+ } was successfully created.`,
+ );
+}
+
+function getDirPath(type: "post" | "slide"): string {
+ return type === "post" ? "posts" : "slides";
+}
+
+function getTemplate(type: "post" | "slide", date: string): string {
+ if (type === "post") {
+ return `<?xml version="1.0" encoding="UTF-8"?>
+<article xmlns="http://docbook.org/ns/docbook" xmlns:xl="http://www.w3.org/1999/xlink" version="5.0">
+ <info>
+ <title>TODO</title>
+ <abstract>
+ TODO
+ </abstract>
+ <keywordset>
+ <keyword>TODO</keyword>
+ </keywordset>
+ <revhistory>
+ <revision>
+ <date>${date}</date>
+ <revremark>公開</revremark>
+ </revision>
+ </revhistory>
+ </info>
+ <section xml:id="TODO">
+ <title>TODO</title>
+ <para>
+ TODO
+ </para>
+ </section>
+</article>
+`;
+ } else {
+ return `<?xml version="1.0" encoding="UTF-8"?>
+<slide>
+ <info>
+ <title>TODO</title>
+ <event>
+ TODO
+ </event>
+ <talktype>
+ TODO
+ </talktype>
+ <link>TODO</link>
+ <keywordset>
+ <keyword>TODO</keyword>
+ </keywordset>
+ <revhistory>
+ <revision>
+ <date>${date}</date>
+ <revremark>登壇</revremark>
+ </revision>
+ </revhistory>
+ </info>
+</slide>
+`;
+ }
+}
diff --git a/vhosts/blog/nuldoc-src/commands/serve.ts b/vhosts/blog/nuldoc-src/commands/serve.ts
new file mode 100644
index 00000000..aa5221df
--- /dev/null
+++ b/vhosts/blog/nuldoc-src/commands/serve.ts
@@ -0,0 +1,38 @@
+import { serveDir } from "std/http/file_server.ts";
+import { Status, STATUS_TEXT } from "std/http/http_status.ts";
+import { serve } from "std/http/server.ts";
+import { join } from "std/path/mod.ts";
+import { Config } from "../config.ts";
+
+export function runServeCommand(config: Config) {
+ const rootDir = join(Deno.cwd(), config.locations.destDir);
+ serve(async (req) => {
+ const pathname = new URL(req.url).pathname;
+ if (!pathname.endsWith("css") && !pathname.endsWith("svg")) {
+ const command = new Deno.Command(
+ join(Deno.cwd(), "nuldoc"),
+ {
+ args: ["build"],
+ },
+ );
+ await command.output();
+ console.log("rebuild");
+ }
+ const res = await serveDir(req, {
+ fsRoot: rootDir,
+ showIndex: true,
+ });
+ if (res.status !== Status.NotFound) {
+ return res;
+ }
+
+ const notFoundHtml = await Deno.readTextFile(join(rootDir, "404.html"));
+ return new Response(notFoundHtml, {
+ status: Status.NotFound,
+ statusText: STATUS_TEXT[Status.NotFound],
+ headers: {
+ "content-type": "text/html",
+ },
+ });
+ });
+}
diff --git a/vhosts/blog/nuldoc-src/components/global_footer.ts b/vhosts/blog/nuldoc-src/components/global_footer.ts
new file mode 100644
index 00000000..4c9d2457
--- /dev/null
+++ b/vhosts/blog/nuldoc-src/components/global_footer.ts
@@ -0,0 +1,12 @@
+import { Config } from "../config.ts";
+import { el, Element, text } from "../dom.ts";
+
+export function globalFooter(config: Config): Element {
+ return el(
+ "footer",
+ [["class", "footer"]],
+ text(
+ `&copy; ${config.blog.siteCopyrightYear} ${config.blog.author}`,
+ ),
+ );
+}
diff --git a/vhosts/blog/nuldoc-src/components/global_header.ts b/vhosts/blog/nuldoc-src/components/global_header.ts
new file mode 100644
index 00000000..a5130612
--- /dev/null
+++ b/vhosts/blog/nuldoc-src/components/global_header.ts
@@ -0,0 +1,42 @@
+import { Config } from "../config.ts";
+import { el, Element, text } from "../dom.ts";
+
+export function globalHeader(config: Config): Element {
+ return el(
+ "header",
+ [["class", "header"]],
+ el(
+ "nav",
+ [["class", "nav"]],
+ el(
+ "ul",
+ [],
+ el(
+ "li",
+ [],
+ el("a", [["href", "/"]], text(config.blog.siteName)),
+ ),
+ el(
+ "li",
+ [],
+ el("a", [["href", "/about/"]], text("About")),
+ ),
+ el(
+ "li",
+ [],
+ el("a", [["href", "/posts/"]], text("Posts")),
+ ),
+ el(
+ "li",
+ [],
+ el("a", [["href", "/slides/"]], text("Slides")),
+ ),
+ el(
+ "li",
+ [],
+ el("a", [["href", "/tags/"]], text("Tags")),
+ ),
+ ),
+ ),
+ );
+}
diff --git a/vhosts/blog/nuldoc-src/components/page_layout.ts b/vhosts/blog/nuldoc-src/components/page_layout.ts
new file mode 100644
index 00000000..50ed45de
--- /dev/null
+++ b/vhosts/blog/nuldoc-src/components/page_layout.ts
@@ -0,0 +1,78 @@
+import { Config } from "../config.ts";
+import { el, Element, text } from "../dom.ts";
+import { stylesheetLinkElement } from "./utils.ts";
+
+type Params = {
+ metaCopyrightYear: number;
+ metaDescription: string;
+ metaKeywords: string[];
+ metaTitle: string;
+ requiresSyntaxHighlight: boolean;
+};
+
+export async function pageLayout(
+ {
+ metaCopyrightYear,
+ metaDescription,
+ metaKeywords,
+ metaTitle,
+ requiresSyntaxHighlight,
+ }: Params,
+ body: Element,
+ config: Config,
+): Promise<Element> {
+ const head = el(
+ "head",
+ [],
+ metaElement([["charset", "UTF-8"]]),
+ metaElement([["name", "viewport"], [
+ "content",
+ "width=device-width, initial-scale=1.0",
+ ]]),
+ metaElement([["name", "author"], ["content", config.blog.author]]),
+ metaElement([["name", "copyright"], [
+ "content",
+ `&copy; ${metaCopyrightYear} ${config.blog.author}`,
+ ]]),
+ metaElement([["name", "description"], [
+ "content",
+ metaDescription,
+ ]]),
+ ...(metaKeywords.length === 0 ? [] : [
+ metaElement([["name", "keywords"], [
+ "content",
+ metaKeywords.join(","),
+ ]]),
+ ]),
+ linkElement("icon", "/favicon.svg", "image/svg+xml"),
+ el("title", [], text(metaTitle)),
+ await stylesheetLinkElement("/style.css", config),
+ ...(
+ requiresSyntaxHighlight
+ ? [await stylesheetLinkElement("/hl.css", config)]
+ : []
+ ),
+ );
+ return el(
+ "html",
+ [["lang", "ja-JP"]],
+ head,
+ body,
+ );
+}
+
+function metaElement(attrs: [string, string][]): Element {
+ return el("meta", attrs);
+}
+
+function linkElement(
+ rel: string,
+ href: string,
+ type: string | null,
+): Element {
+ const attrs: [string, string][] = [["rel", rel], ["href", href]];
+ if (type !== null) {
+ attrs.push(["type", type]);
+ }
+ return el("link", attrs);
+}
diff --git a/vhosts/blog/nuldoc-src/components/post_page_entry.ts b/vhosts/blog/nuldoc-src/components/post_page_entry.ts
new file mode 100644
index 00000000..685c03a8
--- /dev/null
+++ b/vhosts/blog/nuldoc-src/components/post_page_entry.ts
@@ -0,0 +1,48 @@
+import { el, Element, text } from "../dom.ts";
+import {
+ getPostCreatedDate,
+ getPostUpdatedDate,
+ PostPage,
+} from "../pages/post.ts";
+import { dateToString } from "../revision.ts";
+
+export function postPageEntry(post: PostPage): Element {
+ return el(
+ "article",
+ [["class", "post-entry"]],
+ el(
+ "a",
+ [["href", post.href]],
+ el(
+ "header",
+ [["class", "entry-header"]],
+ el("h2", [], text(post.title)),
+ ),
+ el(
+ "section",
+ [["class", "entry-content"]],
+ el("p", [], text(post.summary)),
+ ),
+ el(
+ "footer",
+ [["class", "entry-footer"]],
+ el(
+ "time",
+ [["datetime", dateToString(getPostCreatedDate(post))]],
+ text(dateToString(getPostCreatedDate(post))),
+ ),
+ text(" 投稿"),
+ ...(post.revisions.length > 1
+ ? [
+ text("、"),
+ el("time", [[
+ "datetime",
+ dateToString(getPostUpdatedDate(post)),
+ ]], text(dateToString(getPostUpdatedDate(post)))),
+ text(" 更新"),
+ ]
+ : []),
+ ),
+ ),
+ );
+}
diff --git a/vhosts/blog/nuldoc-src/components/slide_page_entry.ts b/vhosts/blog/nuldoc-src/components/slide_page_entry.ts
new file mode 100644
index 00000000..4767ca2b
--- /dev/null
+++ b/vhosts/blog/nuldoc-src/components/slide_page_entry.ts
@@ -0,0 +1,45 @@
+import { el, Element, text } from "../dom.ts";
+import { getPostCreatedDate, getPostUpdatedDate } from "../pages/post.ts";
+import { SlidePage } from "../pages/slide.ts";
+import { dateToString } from "../revision.ts";
+
+export function slidePageEntry(slide: SlidePage): Element {
+ return el(
+ "article",
+ [["class", "post-entry"]],
+ el(
+ "a",
+ [["href", slide.href]],
+ el(
+ "header",
+ [["class", "entry-header"]],
+ el("h2", [], text(`登壇: ${slide.event} (${slide.talkType})`)),
+ ),
+ el(
+ "section",
+ [["class", "entry-content"]],
+ el("p", [], text(slide.title)),
+ ),
+ el(
+ "footer",
+ [["class", "entry-footer"]],
+ el(
+ "time",
+ [["datetime", dateToString(getPostCreatedDate(slide))]],
+ text(dateToString(getPostCreatedDate(slide))),
+ ),
+ text(" 登壇"),
+ ...(slide.revisions.length > 1
+ ? [
+ text("、"),
+ el("time", [[
+ "datetime",
+ dateToString(getPostUpdatedDate(slide)),
+ ]], text(dateToString(getPostUpdatedDate(slide)))),
+ text(" 更新"),
+ ]
+ : []),
+ ),
+ ),
+ );
+}
diff --git a/vhosts/blog/nuldoc-src/components/utils.ts b/vhosts/blog/nuldoc-src/components/utils.ts
new file mode 100644
index 00000000..f0de71f1
--- /dev/null
+++ b/vhosts/blog/nuldoc-src/components/utils.ts
@@ -0,0 +1,30 @@
+import { crypto, toHashString } from "std/crypto/mod.ts";
+import { join } from "std/path/mod.ts";
+import { Config } from "../config.ts";
+import { el, Element } from "../dom.ts";
+
+export async function stylesheetLinkElement(
+ fileName: string,
+ config: Config,
+): Promise<Element> {
+ const filePath = join(Deno.cwd(), config.locations.staticDir, fileName);
+ const hash = await calculateFileHash(filePath);
+ return el("link", [["rel", "stylesheet"], ["href", `${fileName}?h=${hash}`]]);
+}
+
+export async function staticScriptElement(
+ fileName: string,
+ attrs: [string, string][],
+ config: Config,
+): Promise<Element> {
+ const filePath = join(Deno.cwd(), config.locations.staticDir, fileName);
+ const hash = await calculateFileHash(filePath);
+ return el("script", [["src", `${fileName}?h=${hash}`], ...attrs]);
+}
+
+async function calculateFileHash(
+ filePath: string,
+): Promise<string> {
+ const content = (await Deno.readFile(filePath)).buffer;
+ return toHashString(await crypto.subtle.digest("MD5", content), "hex");
+}
diff --git a/vhosts/blog/nuldoc-src/config.ts b/vhosts/blog/nuldoc-src/config.ts
new file mode 100644
index 00000000..5e1cad46
--- /dev/null
+++ b/vhosts/blog/nuldoc-src/config.ts
@@ -0,0 +1,43 @@
+export const config = {
+ locations: {
+ contentDir: "/content",
+ destDir: "/public",
+ staticDir: "/static",
+ },
+ rendering: {
+ html: {
+ indentWidth: 2,
+ },
+ },
+ blog: {
+ author: "nsfisis",
+ siteName: "REPL: Rest-Eat-Program Loop",
+ siteCopyrightYear: 2021,
+ tagLabels: {
+ "conference": "カンファレンス",
+ "cpp": "C++",
+ "cpp17": "C++ 17",
+ "note-to-self": "備忘録",
+ "php": "PHP",
+ "phpcon": "PHP カンファレンス",
+ "phpconfuk": "PHP カンファレンス福岡",
+ "phperkaigi": "PHPerKaigi",
+ "phpstudy-tokyo": "PHP 勉強会@東京",
+ "python": "Python",
+ "python3": "Python 3",
+ "ruby": "Ruby",
+ "ruby3": "Ruby 3",
+ "rust": "Rust",
+ "vim": "Vim",
+ },
+ },
+};
+
+export type Config = typeof config;
+
+export function getTagLabel(c: Config, slug: string): string {
+ if (!(slug in c.blog.tagLabels)) {
+ throw new Error(`Unknown tag: ${slug}`);
+ }
+ return (c.blog.tagLabels as { [slug: string]: string })[slug];
+}
diff --git a/vhosts/blog/nuldoc-src/docbook/document.ts b/vhosts/blog/nuldoc-src/docbook/document.ts
new file mode 100644
index 00000000..677c8275
--- /dev/null
+++ b/vhosts/blog/nuldoc-src/docbook/document.ts
@@ -0,0 +1,108 @@
+import { join } from "std/path/mod.ts";
+import { Config } from "../config.ts";
+import { DocBookError } from "../errors.ts";
+import { Revision, stringToDate } from "../revision.ts";
+import {
+ Element,
+ findChildElements,
+ findFirstChildElement,
+ innerText,
+} from "../dom.ts";
+
+export type Document = {
+ root: Element;
+ sourceFilePath: string;
+ link: string;
+ title: string;
+ summary: string; // TODO: should it be markup text?
+ tags: string[];
+ revisions: Revision[];
+};
+
+export function createNewDocumentFromRootElement(
+ root: Element,
+ sourceFilePath: string,
+ config: Config,
+): Document {
+ const article = findFirstChildElement(root, "article");
+ if (!article) {
+ throw new DocBookError(
+ `[docbook.new] <article> element not found`,
+ );
+ }
+ const info = findFirstChildElement(article, "info");
+ if (!info) {
+ throw new DocBookError(
+ `[docbook.new] <info> element not found`,
+ );
+ }
+
+ const titleElement = findFirstChildElement(info, "title");
+ if (!titleElement) {
+ throw new DocBookError(
+ `[docbook.new] <title> element not found`,
+ );
+ }
+ const title = innerText(titleElement).trim();
+ const abstractElement = findFirstChildElement(info, "abstract");
+ if (!abstractElement) {
+ throw new DocBookError(
+ `[docbook.new] <abstract> element not found`,
+ );
+ }
+ const summary = innerText(abstractElement).trim();
+ const keywordsetElement = findFirstChildElement(info, "keywordset");
+ let tags: string[];
+ if (!keywordsetElement) {
+ tags = [];
+ } else {
+ tags = findChildElements(keywordsetElement, "keyword").map((x) =>
+ innerText(x).trim()
+ );
+ }
+ const revhistoryElement = findFirstChildElement(info, "revhistory");
+ if (!revhistoryElement) {
+ throw new DocBookError(
+ `[docbook.new] <revhistory> element not found`,
+ );
+ }
+ const revisions = findChildElements(revhistoryElement, "revision").map(
+ (x, i) => {
+ const dateElement = findFirstChildElement(x, "date");
+ if (!dateElement) {
+ throw new DocBookError(
+ `[docbook.new] <date> element not found`,
+ );
+ }
+ const revremarkElement = findFirstChildElement(x, "revremark");
+ if (!revremarkElement) {
+ throw new DocBookError(
+ `[docbook.new] <revremark> element not found`,
+ );
+ }
+ return {
+ number: i + 1,
+ date: stringToDate(innerText(dateElement).trim()),
+ remark: innerText(revremarkElement).trim(),
+ };
+ },
+ );
+ if (revisions.length === 0) {
+ throw new DocBookError(
+ `[docbook.new] <revision> element not found`,
+ );
+ }
+
+ const cwd = Deno.cwd();
+ const contentDir = join(cwd, config.locations.contentDir);
+ const link = sourceFilePath.replace(contentDir, "").replace(".xml", "/");
+ return {
+ root: root,
+ sourceFilePath: sourceFilePath,
+ link: link,
+ title: title,
+ summary: summary,
+ tags: tags,
+ revisions: revisions,
+ };
+}
diff --git a/vhosts/blog/nuldoc-src/docbook/parse.ts b/vhosts/blog/nuldoc-src/docbook/parse.ts
new file mode 100644
index 00000000..bce317e6
--- /dev/null
+++ b/vhosts/blog/nuldoc-src/docbook/parse.ts
@@ -0,0 +1,21 @@
+import { Config } from "../config.ts";
+import { parseXmlFile } from "../xml.ts";
+import { DocBookError, XmlParseError } from "../errors.ts";
+import { createNewDocumentFromRootElement, Document } from "./document.ts";
+import toHtml from "./to_html.ts";
+
+export async function parseDocBookFile(
+ filePath: string,
+ config: Config,
+): Promise<Document> {
+ try {
+ const root = await parseXmlFile(filePath);
+ const doc = createNewDocumentFromRootElement(root, filePath, config);
+ return toHtml(doc);
+ } catch (e) {
+ if (e instanceof DocBookError || e instanceof XmlParseError) {
+ e.message = `${e.message} in ${filePath}`;
+ }
+ throw e;
+ }
+}
diff --git a/vhosts/blog/nuldoc-src/docbook/to_html.ts b/vhosts/blog/nuldoc-src/docbook/to_html.ts
new file mode 100644
index 00000000..4add912c
--- /dev/null
+++ b/vhosts/blog/nuldoc-src/docbook/to_html.ts
@@ -0,0 +1,319 @@
+// @deno-types="../types/highlight-js.d.ts"
+import hljs from "npm:highlight.js";
+import { Document } from "./document.ts";
+import { DocBookError } from "../errors.ts";
+import {
+ addClass,
+ Element,
+ findFirstChildElement,
+ forEachChild,
+ forEachChildRecursively,
+ Node,
+ RawHTML,
+ removeChildElements,
+ Text,
+} from "../dom.ts";
+
+export default function toHtml(doc: Document): Document {
+ removeArticleInfo(doc);
+ removeArticleAttributes(doc);
+ removeUnnecessaryTextNode(doc);
+ transformElementNames(doc, "emphasis", "em");
+ transformElementNames(doc, "informaltable", "table");
+ transformElementNames(doc, "itemizedlist", "ul");
+ transformElementNames(doc, "link", "a");
+ transformElementNames(doc, "listitem", "li");
+ transformElementNames(doc, "literal", "code");
+ transformElementNames(doc, "orderedlist", "ol");
+ transformElementNames(doc, "para", "p");
+ transformElementNames(doc, "superscript", "sup");
+ transformAttributeNames(doc, "xml:id", "id");
+ transformAttributeNames(doc, "xl:href", "href");
+ transformSectionIdAttribute(doc);
+ setSectionTitleAnchor(doc);
+ transformSectionTitleElement(doc);
+ transformProgramListingElement(doc);
+ transformLiteralLayoutElement(doc);
+ transformNoteElement(doc);
+ setDefaultLangAttribute(doc);
+ traverseFootnotes(doc);
+ highlightPrograms(doc);
+ return doc;
+}
+
+function removeArticleInfo(doc: Document) {
+ const article = findFirstChildElement(doc.root, "article");
+ if (!article) {
+ throw new DocBookError(
+ `[docbook.tohtml] <article> element not found`,
+ );
+ }
+ removeChildElements(article, "info");
+}
+
+function removeArticleAttributes(doc: Document) {
+ const article = findFirstChildElement(doc.root, "article");
+ if (!article) {
+ throw new DocBookError(
+ `[docbook.tohtml] <article> element not found`,
+ );
+ }
+ article.attributes.delete("xmlns");
+ article.attributes.delete("xmlns:xl");
+ article.attributes.delete("version");
+}
+
+function removeUnnecessaryTextNode(doc: Document) {
+ const g = (n: Node) => {
+ 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;
+ }
+ }
+
+ forEachChild(n, g);
+ };
+ forEachChild(doc.root, g);
+}
+
+function transformElementNames(
+ doc: Document,
+ from: string,
+ to: string,
+) {
+ forEachChildRecursively(doc.root, (n) => {
+ if (n.kind === "element" && n.name === from) {
+ n.name = to;
+ }
+ });
+}
+
+function transformAttributeNames(
+ doc: Document,
+ from: string,
+ to: string,
+) {
+ forEachChildRecursively(doc.root, (n) => {
+ if (n.kind !== "element") {
+ return;
+ }
+ const value = n.attributes.get(from) as string;
+ if (value !== undefined) {
+ n.attributes.delete(from);
+ n.attributes.set(to, value);
+ }
+ });
+}
+
+function transformSectionIdAttribute(doc: Document) {
+ forEachChildRecursively(doc.root, (n) => {
+ if (n.kind !== "element" || n.name !== "section") {
+ return;
+ }
+
+ const idAttr = n.attributes.get("id");
+ n.attributes.set("id", `section--${idAttr}`);
+ });
+}
+
+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 === "title") {
+ const currentSection = sectionStack[sectionStack.length - 1];
+ if (!currentSection) {
+ throw new DocBookError(
+ "[docbook.tohtml] <title> element must be inside <section>",
+ );
+ }
+ const sectionId = currentSection.attributes.get("id");
+ const aElement: Element = {
+ kind: "element",
+ name: "a",
+ attributes: new Map(),
+ children: c.children,
+ };
+ aElement.attributes.set("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.set("--section-level", sectionLevel.toString());
+ }
+ forEachChild(c, g);
+ if (c.name === "section") {
+ sectionLevel -= 1;
+ }
+ if (c.name === "title") {
+ c.name = `h${sectionLevel}`;
+ }
+ };
+ forEachChild(doc.root, g);
+}
+
+function transformProgramListingElement(doc: Document) {
+ forEachChildRecursively(doc.root, (n) => {
+ if (n.kind !== "element" || n.name !== "programlisting") {
+ return;
+ }
+
+ n.name = "pre";
+ addClass(n, "highlight");
+ const codeElement: Element = {
+ kind: "element",
+ name: "code",
+ attributes: new Map(),
+ children: n.children,
+ };
+ n.children = [codeElement];
+ });
+}
+
+function transformLiteralLayoutElement(doc: Document) {
+ forEachChildRecursively(doc.root, (n) => {
+ if (n.kind !== "element" || n.name !== "literallayout") {
+ return;
+ }
+
+ n.name = "pre";
+ addClass(n, "highlight");
+ const codeElement: Element = {
+ kind: "element",
+ name: "code",
+ attributes: new Map(),
+ children: n.children,
+ };
+ n.children = [codeElement];
+ });
+}
+
+function transformNoteElement(doc: Document) {
+ forEachChildRecursively(doc.root, (n) => {
+ if (n.kind !== "element" || n.name !== "note") {
+ return;
+ }
+
+ const labelElement: Element = {
+ kind: "element",
+ name: "div",
+ attributes: new Map([["class", "admonition-label"]]),
+ children: [{
+ kind: "text",
+ content: "NOTE",
+ raw: false,
+ }],
+ };
+ const contentElement: Element = {
+ kind: "element",
+ name: "div",
+ attributes: new Map([["class", "admonition-content"]]),
+ children: n.children,
+ };
+ n.name = "div";
+ addClass(n, "admonition");
+ n.children = [
+ labelElement,
+ contentElement,
+ ];
+ });
+}
+
+function setDefaultLangAttribute(_doc: Document) {
+ // TODO
+ // if (!e.attributes.has("lang")) {
+ // e.attributes.set("lang", "ja-JP");
+ // }
+}
+
+function traverseFootnotes(doc: Document) {
+ forEachChildRecursively(doc.root, (n) => {
+ if (n.kind !== "element" || n.name !== "footnote") {
+ return;
+ }
+
+ // TODO
+ // <footnote>x</footnote>
+ //
+ // <sup class="footnote">[<a id="_footnoteref_1" class="footnote" href="#_footnotedef_1">1</a>]</sup>
+ //
+ // <div class="footnote" id="_footnotedef_1">
+ // <a href="#_footnoteref_1">1</a>. RAS syndrome
+ // </div>
+ n.name = "span";
+ n.children = [];
+ });
+}
+
+function highlightPrograms(doc: Document) {
+ forEachChildRecursively(doc.root, (n) => {
+ if (n.kind !== "element" || n.name !== "pre") {
+ return;
+ }
+ const preClass = n.attributes.get("class") || "";
+ if (!preClass.includes("highlight")) {
+ return;
+ }
+ const codeElement = findFirstChildElement(n, "code");
+ if (!codeElement) {
+ return;
+ }
+ const language = n.attributes.get("language");
+ if (!language) {
+ return;
+ }
+ const sourceCodeNode = codeElement.children[0] as Text | RawHTML;
+ const sourceCode = sourceCodeNode.content;
+
+ if (!hljs.getLanguage(language)) {
+ return;
+ }
+
+ const highlighted =
+ hljs.highlight(sourceCode, { language: language }).value;
+
+ sourceCodeNode.content = highlighted;
+ sourceCodeNode.raw = true;
+ codeElement.attributes.set("class", "highlight");
+ });
+}
diff --git a/vhosts/blog/nuldoc-src/dom.ts b/vhosts/blog/nuldoc-src/dom.ts
new file mode 100644
index 00000000..d8f53d76
--- /dev/null
+++ b/vhosts/blog/nuldoc-src/dom.ts
@@ -0,0 +1,107 @@
+export type Text = {
+ kind: "text";
+ content: string;
+ raw: false;
+};
+
+export type RawHTML = {
+ kind: "text";
+ content: string;
+ raw: true;
+};
+
+export type Element = {
+ kind: "element";
+ name: string;
+ attributes: Map<string, string>;
+ children: Node[];
+};
+
+export type Node = Element | Text | RawHTML;
+
+export function addClass(e: Element, klass: string) {
+ const classes = e.attributes.get("class");
+ if (classes === undefined) {
+ e.attributes.set("class", klass);
+ } else {
+ const classList = classes.split(" ");
+ classList.push(klass);
+ classList.sort();
+ e.attributes.set("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 removeChildElements(e: Element, name: string) {
+ e.children = e.children.filter((c) =>
+ c.kind !== "element" || c.name !== name
+ );
+}
+
+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 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 function text(content: string): Text {
+ return {
+ kind: "text",
+ content: content,
+ raw: false,
+ };
+}
+
+export function el(
+ name: string,
+ attrs: [string, string][],
+ ...children: Node[]
+): Element {
+ return {
+ kind: "element",
+ name: name,
+ attributes: new Map(attrs),
+ children: children,
+ };
+}
diff --git a/vhosts/blog/nuldoc-src/errors.ts b/vhosts/blog/nuldoc-src/errors.ts
new file mode 100644
index 00000000..fa535836
--- /dev/null
+++ b/vhosts/blog/nuldoc-src/errors.ts
@@ -0,0 +1,17 @@
+export class DocBookError extends Error {
+ static {
+ this.prototype.name = "DocBookError";
+ }
+}
+
+export class SlideError extends Error {
+ static {
+ this.prototype.name = "SlideError";
+ }
+}
+
+export class XmlParseError extends Error {
+ static {
+ this.prototype.name = "XmlParseError";
+ }
+}
diff --git a/vhosts/blog/nuldoc-src/main.ts b/vhosts/blog/nuldoc-src/main.ts
new file mode 100644
index 00000000..8598d80c
--- /dev/null
+++ b/vhosts/blog/nuldoc-src/main.ts
@@ -0,0 +1,17 @@
+import { runBuildCommand } from "./commands/build.ts";
+import { runNewCommand } from "./commands/new.ts";
+import { runServeCommand } from "./commands/serve.ts";
+import { config } from "./config.ts";
+
+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/vhosts/blog/nuldoc-src/page.ts b/vhosts/blog/nuldoc-src/page.ts
new file mode 100644
index 00000000..f4a6166b
--- /dev/null
+++ b/vhosts/blog/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/vhosts/blog/nuldoc-src/pages/about.ts b/vhosts/blog/nuldoc-src/pages/about.ts
new file mode 100644
index 00000000..acba113b
--- /dev/null
+++ b/vhosts/blog/nuldoc-src/pages/about.ts
@@ -0,0 +1,183 @@
+import { globalFooter } from "../components/global_footer.ts";
+import { globalHeader } from "../components/global_header.ts";
+import { pageLayout } from "../components/page_layout.ts";
+import { staticScriptElement } from "../components/utils.ts";
+import { Config } from "../config.ts";
+import { el, text } from "../dom.ts";
+import { Page } from "../page.ts";
+import { dateToString } from "../revision.ts";
+import { getPostCreatedDate } from "./post.ts";
+import { SlidePage } from "./slide.ts";
+
+export type AboutPage = Page;
+
+export async function generateAboutPage(
+ slides: SlidePage[],
+ config: Config,
+): Promise<AboutPage> {
+ const body = el(
+ "body",
+ [["class", "single"]],
+ globalHeader(config),
+ el(
+ "main",
+ [["class", "main"]],
+ el(
+ "article",
+ [["class", "post-single"]],
+ el(
+ "header",
+ [["class", "post-header"]],
+ el(
+ "h1",
+ [["class", "post-title"]],
+ text("nsfisis"),
+ ),
+ el(
+ "div",
+ [["class", "my-icon"]],
+ await staticScriptElement("/p5.min.js", [], config),
+ await staticScriptElement("/my-icon.js", [], config),
+ el("div", [["id", "p5jsMyIcon"]]),
+ el(
+ "noscript",
+ [],
+ el(
+ "img",
+ [["src", "/favicon.svg"]],
+ ),
+ ),
+ ),
+ ),
+ el(
+ "div",
+ [["class", "post-content"]],
+ el(
+ "section",
+ [],
+ el(
+ "h2",
+ [],
+ text("読み方"),
+ ),
+ el(
+ "p",
+ [],
+ text(
+ "読み方は決めていません。音にする必要があるときは本名である「いまむら」をお使いください。",
+ ),
+ ),
+ ),
+ el(
+ "section",
+ [],
+ el(
+ "h2",
+ [],
+ text("アカウント"),
+ ),
+ el(
+ "ul",
+ [],
+ el(
+ "li",
+ [],
+ el(
+ "a",
+ [["href", "https://twitter.com/nsfisis"]],
+ text("Twitter (現 𝕏): @nsfisis"),
+ ),
+ ),
+ el(
+ "li",
+ [],
+ el(
+ "a",
+ [["href", "https://github.com/nsfisis"]],
+ text("GitHub: @nsfisis"),
+ ),
+ ),
+ ),
+ ),
+ el(
+ "section",
+ [],
+ el(
+ "h2",
+ [],
+ text("仕事"),
+ ),
+ el(
+ "ul",
+ [],
+ el(
+ "li",
+ [],
+ text("2021-01~現在: "),
+ el(
+ "a",
+ [["href", "https://www.dgcircus.com/"]],
+ text("デジタルサーカス株式会社"),
+ ),
+ ),
+ ),
+ ),
+ el(
+ "section",
+ [],
+ el(
+ "h2",
+ [],
+ text("登壇"),
+ ),
+ el(
+ "ul",
+ [],
+ ...Array.from(slides).sort((a, b) => {
+ const ta = dateToString(getPostCreatedDate(a));
+ const tb = dateToString(getPostCreatedDate(b));
+ if (ta > tb) return -1;
+ if (ta < tb) return 1;
+ return 0;
+ }).map((slide) =>
+ el(
+ "li",
+ [],
+ el(
+ "a",
+ [["href", slide.href]],
+ text(
+ `${
+ dateToString(getPostCreatedDate(slide))
+ }: ${slide.event} (${slide.talkType})`,
+ ),
+ ),
+ )
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ globalFooter(config),
+ );
+
+ const html = await pageLayout(
+ {
+ metaCopyrightYear: config.blog.siteCopyrightYear,
+ metaDescription: "このサイトの著者について",
+ metaKeywords: [],
+ metaTitle: `About | ${config.blog.siteName}`,
+ requiresSyntaxHighlight: false,
+ },
+ body,
+ config,
+ );
+
+ return {
+ root: el("__root__", [], html),
+ renderer: "html",
+ destFilePath: "/about/index.html",
+ href: "/about/",
+ };
+}
diff --git a/vhosts/blog/nuldoc-src/pages/home.ts b/vhosts/blog/nuldoc-src/pages/home.ts
new file mode 100644
index 00000000..a240278a
--- /dev/null
+++ b/vhosts/blog/nuldoc-src/pages/home.ts
@@ -0,0 +1,96 @@
+import { globalFooter } from "../components/global_footer.ts";
+import { globalHeader } from "../components/global_header.ts";
+import { pageLayout } from "../components/page_layout.ts";
+import { Config } from "../config.ts";
+import { el, text } from "../dom.ts";
+import { Page } from "../page.ts";
+
+export type HomePage = Page;
+
+export async function generateHomePage(config: Config): Promise<HomePage> {
+ const body = el(
+ "body",
+ [["class", "single"]],
+ globalHeader(config),
+ el(
+ "main",
+ [["class", "main"]],
+ el(
+ "article",
+ [["class", "post-single"]],
+ el(
+ "article",
+ [["class", "post-entry"]],
+ el(
+ "a",
+ [["href", "/about/"]],
+ el(
+ "header",
+ [["class", "entry-header"]],
+ el("h2", [], text("About")),
+ ),
+ ),
+ ),
+ el(
+ "article",
+ [["class", "post-entry"]],
+ el(
+ "a",
+ [["href", "/posts/"]],
+ el(
+ "header",
+ [["class", "entry-header"]],
+ el("h2", [], text("Posts")),
+ ),
+ ),
+ ),
+ el(
+ "article",
+ [["class", "post-entry"]],
+ el(
+ "a",
+ [["href", "/slides/"]],
+ el(
+ "header",
+ [["class", "entry-header"]],
+ el("h2", [], text("Slides")),
+ ),
+ ),
+ ),
+ el(
+ "article",
+ [["class", "post-entry"]],
+ el(
+ "a",
+ [["href", "/tags/"]],
+ el(
+ "header",
+ [["class", "entry-header"]],
+ el("h2", [], text("Tags")),
+ ),
+ ),
+ ),
+ ),
+ ),
+ globalFooter(config),
+ );
+
+ const html = await pageLayout(
+ {
+ metaCopyrightYear: config.blog.siteCopyrightYear,
+ metaDescription: "nsfisis のブログサイト",
+ metaKeywords: [],
+ metaTitle: config.blog.siteName,
+ requiresSyntaxHighlight: false,
+ },
+ body,
+ config,
+ );
+
+ return {
+ root: el("__root__", [], html),
+ renderer: "html",
+ destFilePath: "/index.html",
+ href: "/",
+ };
+}
diff --git a/vhosts/blog/nuldoc-src/pages/not_found.ts b/vhosts/blog/nuldoc-src/pages/not_found.ts
new file mode 100644
index 00000000..a1b6109b
--- /dev/null
+++ b/vhosts/blog/nuldoc-src/pages/not_found.ts
@@ -0,0 +1,51 @@
+import { globalFooter } from "../components/global_footer.ts";
+import { globalHeader } from "../components/global_header.ts";
+import { pageLayout } from "../components/page_layout.ts";
+import { Config } from "../config.ts";
+import { el, text } from "../dom.ts";
+import { Page } from "../page.ts";
+
+export type NotFoundPage = Page;
+
+export async function generateNotFoundPage(
+ config: Config,
+): Promise<NotFoundPage> {
+ const body = el(
+ "body",
+ [["class", "single"]],
+ globalHeader(config),
+ el(
+ "main",
+ [["class", "main"]],
+ el(
+ "article",
+ [],
+ el(
+ "div",
+ [["class", "not-found"]],
+ text("404"),
+ ),
+ ),
+ ),
+ globalFooter(config),
+ );
+
+ const html = await pageLayout(
+ {
+ metaCopyrightYear: config.blog.siteCopyrightYear,
+ metaDescription: "リクエストされたページが見つかりません。",
+ metaKeywords: [],
+ metaTitle: `Page Not Found | ${config.blog.siteName}`,
+ requiresSyntaxHighlight: false,
+ },
+ body,
+ config,
+ );
+
+ return {
+ root: el("__root__", [], html),
+ renderer: "html",
+ destFilePath: "/404.html",
+ href: "/404.html",
+ };
+}
diff --git a/vhosts/blog/nuldoc-src/pages/post.ts b/vhosts/blog/nuldoc-src/pages/post.ts
new file mode 100644
index 00000000..24a6d5f5
--- /dev/null
+++ b/vhosts/blog/nuldoc-src/pages/post.ts
@@ -0,0 +1,140 @@
+import { join } from "std/path/mod.ts";
+import { globalFooter } from "../components/global_footer.ts";
+import { globalHeader } from "../components/global_header.ts";
+import { pageLayout } from "../components/page_layout.ts";
+import { Config, getTagLabel } from "../config.ts";
+import { el, Element, text } from "../dom.ts";
+import { Document } from "../docbook/document.ts";
+import { Page } from "../page.ts";
+import { Date, dateToString, Revision } from "../revision.ts";
+
+export interface PostPage extends Page {
+ title: string;
+ summary: string;
+ tags: string[];
+ revisions: Revision[];
+}
+
+export function getPostCreatedDate(page: { revisions: Revision[] }): Date {
+ return page.revisions[0].date;
+}
+
+export function getPostUpdatedDate(page: { revisions: Revision[] }): Date {
+ return page.revisions[page.revisions.length - 1].date;
+}
+
+export async function generatePostPage(
+ doc: Document,
+ config: Config,
+): Promise<PostPage> {
+ const body = el(
+ "body",
+ [["class", "single"]],
+ globalHeader(config),
+ el(
+ "main",
+ [["class", "main"]],
+ el(
+ "article",
+ [["class", "post-single"]],
+ el(
+ "header",
+ [["class", "post-header"]],
+ el(
+ "h1",
+ [["class", "post-title"]],
+ text(doc.title),
+ ),
+ ...(doc.tags.length === 0 ? [] : [
+ el(
+ "ul",
+ [["class", "post-tags"]],
+ ...doc.tags.map((slug) =>
+ el(
+ "li",
+ [["class", "tag"]],
+ el(
+ "a",
+ [["href", `/tags/${slug}/`]],
+ text(
+ getTagLabel(config, slug),
+ ),
+ ),
+ )
+ ),
+ ),
+ ]),
+ ),
+ el(
+ "div",
+ [["class", "post-content"]],
+ el(
+ "section",
+ [],
+ el(
+ "h2",
+ [["id", "changelog"]],
+ text("更新履歴"),
+ ),
+ el(
+ "ol",
+ [],
+ ...doc.revisions.map((rev) =>
+ el(
+ "li",
+ [["class", "revision"]],
+ el(
+ "time",
+ [["datetime", dateToString(rev.date)]],
+ text(dateToString(rev.date)),
+ ),
+ text(`: ${rev.remark}`),
+ )
+ ),
+ ),
+ ),
+ // 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>
+ ),
+ ),
+ ),
+ globalFooter(config),
+ );
+
+ const html = await pageLayout(
+ {
+ metaCopyrightYear: getPostCreatedDate(doc).year,
+ metaDescription: doc.summary,
+ metaKeywords: doc.tags.map((slug) => getTagLabel(config, slug)),
+ metaTitle: `${doc.title} | ${config.blog.siteName}`,
+ requiresSyntaxHighlight: true,
+ },
+ body,
+ config,
+ );
+
+ const cwd = Deno.cwd();
+ const contentDir = join(cwd, config.locations.contentDir);
+ const destFilePath = join(
+ doc.sourceFilePath.replace(contentDir, "").replace(".xml", ""),
+ "index.html",
+ );
+ return {
+ root: el("__root__", [], html),
+ renderer: "html",
+ destFilePath: destFilePath,
+ href: destFilePath.replace("index.html", ""),
+ title: doc.title,
+ summary: doc.summary,
+ tags: doc.tags,
+ revisions: doc.revisions,
+ };
+}
diff --git a/vhosts/blog/nuldoc-src/pages/post_list.ts b/vhosts/blog/nuldoc-src/pages/post_list.ts
new file mode 100644
index 00000000..498b3efb
--- /dev/null
+++ b/vhosts/blog/nuldoc-src/pages/post_list.ts
@@ -0,0 +1,64 @@
+import { globalFooter } from "../components/global_footer.ts";
+import { globalHeader } from "../components/global_header.ts";
+import { pageLayout } from "../components/page_layout.ts";
+import { postPageEntry } from "../components/post_page_entry.ts";
+import { Config } from "../config.ts";
+import { el, text } from "../dom.ts";
+import { Page } from "../page.ts";
+import { dateToString } from "../revision.ts";
+import { getPostCreatedDate, PostPage } from "./post.ts";
+
+export type PostListPage = Page;
+
+export async function generatePostListPage(
+ posts: PostPage[],
+ config: Config,
+): Promise<PostListPage> {
+ const pageTitle = "投稿一覧";
+
+ const body = el(
+ "body",
+ [["class", "list"]],
+ globalHeader(config),
+ el(
+ "main",
+ [["class", "main"]],
+ el(
+ "header",
+ [["class", "page-header"]],
+ el(
+ "h1",
+ [],
+ text(pageTitle),
+ ),
+ ),
+ ...Array.from(posts).sort((a, b) => {
+ const ta = dateToString(getPostCreatedDate(a));
+ const tb = dateToString(getPostCreatedDate(b));
+ if (ta > tb) return -1;
+ if (ta < tb) return 1;
+ return 0;
+ }).map((post) => postPageEntry(post)),
+ ),
+ globalFooter(config),
+ );
+
+ const html = await pageLayout(
+ {
+ metaCopyrightYear: config.blog.siteCopyrightYear,
+ metaDescription: "投稿した記事の一覧",
+ metaKeywords: [],
+ metaTitle: `${pageTitle} | ${config.blog.siteName}`,
+ requiresSyntaxHighlight: false,
+ },
+ body,
+ config,
+ );
+
+ return {
+ root: el("__root__", [], html),
+ renderer: "html",
+ destFilePath: "/posts/index.html",
+ href: "/posts/",
+ };
+}
diff --git a/vhosts/blog/nuldoc-src/pages/slide.ts b/vhosts/blog/nuldoc-src/pages/slide.ts
new file mode 100644
index 00000000..a75aeb68
--- /dev/null
+++ b/vhosts/blog/nuldoc-src/pages/slide.ts
@@ -0,0 +1,140 @@
+import { join } from "std/path/mod.ts";
+import { globalFooter } from "../components/global_footer.ts";
+import { globalHeader } from "../components/global_header.ts";
+import { pageLayout } from "../components/page_layout.ts";
+import { staticScriptElement } from "../components/utils.ts";
+import { Config, getTagLabel } from "../config.ts";
+import { el, text } from "../dom.ts";
+import { Page } from "../page.ts";
+import { dateToString, Revision } from "../revision.ts";
+import { Slide } from "../slide/slide.ts";
+import { getPostCreatedDate } from "./post.ts";
+
+export interface SlidePage extends Page {
+ title: string;
+ event: string;
+ talkType: string;
+ slideLink: string;
+ tags: string[];
+ revisions: Revision[];
+}
+
+export async function generateSlidePage(
+ slide: Slide,
+ config: Config,
+): Promise<SlidePage> {
+ const body = el(
+ "body",
+ [["class", "single"]],
+ globalHeader(config),
+ el(
+ "main",
+ [["class", "main"]],
+ el(
+ "article",
+ [["class", "post-single"]],
+ el(
+ "header",
+ [["class", "post-header"]],
+ el(
+ "h1",
+ [["class", "post-title"]],
+ text(slide.title),
+ ),
+ ...(slide.tags.length === 0 ? [] : [
+ el(
+ "ul",
+ [["class", "post-tags"]],
+ ...slide.tags.map((slug) =>
+ el(
+ "li",
+ [["class", "tag"]],
+ el(
+ "a",
+ [["href", `/tags/${slug}/`]],
+ text(
+ getTagLabel(config, slug),
+ ),
+ ),
+ )
+ ),
+ ),
+ ]),
+ ),
+ el(
+ "div",
+ [["class", "post-content"]],
+ el(
+ "section",
+ [],
+ el(
+ "h2",
+ [["id", "changelog"]],
+ text("更新履歴"),
+ ),
+ el(
+ "ol",
+ [],
+ ...slide.revisions.map((rev) =>
+ el(
+ "li",
+ [["class", "revision"]],
+ el(
+ "time",
+ [["datetime", dateToString(rev.date)]],
+ text(dateToString(rev.date)),
+ ),
+ text(`: ${rev.remark}`),
+ )
+ ),
+ ),
+ ),
+ el(
+ "canvas",
+ [["id", "slide"], ["data-slide-link", slide.slideLink]],
+ ),
+ el(
+ "div",
+ [],
+ el("button", [["id", "prev"]], text("Prev")),
+ el("button", [["id", "next"]], text("Next")),
+ ),
+ await staticScriptElement("/pdf.min.js", [], config),
+ await staticScriptElement("/slide.js", [["type", "module"]], config),
+ ),
+ ),
+ ),
+ globalFooter(config),
+ );
+
+ const html = await pageLayout(
+ {
+ metaCopyrightYear: getPostCreatedDate(slide).year,
+ metaDescription: slide.title,
+ metaKeywords: slide.tags.map((slug) => getTagLabel(config, slug)),
+ metaTitle: `${slide.event} (${slide.talkType}) | ${config.blog.siteName}`,
+ requiresSyntaxHighlight: true,
+ },
+ body,
+ config,
+ );
+
+ const cwd = Deno.cwd();
+ const contentDir = join(cwd, config.locations.contentDir);
+ const destFilePath = join(
+ slide.sourceFilePath.replace(contentDir, "").replace(".xml", ""),
+ "index.html",
+ );
+ return {
+ root: el("__root__", [], html),
+ renderer: "html",
+ destFilePath: destFilePath,
+ href: destFilePath.replace("index.html", ""),
+ title: slide.title,
+ event: slide.event,
+ talkType: slide.talkType,
+ slideLink: slide.slideLink,
+ tags: slide.tags,
+ revisions: slide.revisions,
+ };
+}
diff --git a/vhosts/blog/nuldoc-src/pages/slide_list.ts b/vhosts/blog/nuldoc-src/pages/slide_list.ts
new file mode 100644
index 00000000..5031436d
--- /dev/null
+++ b/vhosts/blog/nuldoc-src/pages/slide_list.ts
@@ -0,0 +1,65 @@
+import { globalFooter } from "../components/global_footer.ts";
+import { globalHeader } from "../components/global_header.ts";
+import { pageLayout } from "../components/page_layout.ts";
+import { slidePageEntry } from "../components/slide_page_entry.ts";
+import { Config } from "../config.ts";
+import { el, text } from "../dom.ts";
+import { Page } from "../page.ts";
+import { dateToString } from "../revision.ts";
+import { getPostCreatedDate } from "./post.ts";
+import { SlidePage } from "./slide.ts";
+
+export type SlideListPage = Page;
+
+export async function generateSlideListPage(
+ slides: SlidePage[],
+ config: Config,
+): Promise<SlideListPage> {
+ const pageTitle = "スライド一覧";
+
+ const body = el(
+ "body",
+ [["class", "list"]],
+ globalHeader(config),
+ el(
+ "main",
+ [["class", "main"]],
+ el(
+ "header",
+ [["class", "page-header"]],
+ el(
+ "h1",
+ [],
+ text(pageTitle),
+ ),
+ ),
+ ...Array.from(slides).sort((a, b) => {
+ const ta = dateToString(getPostCreatedDate(a));
+ const tb = dateToString(getPostCreatedDate(b));
+ if (ta > tb) return -1;
+ if (ta < tb) return 1;
+ return 0;
+ }).map((slide) => slidePageEntry(slide)),
+ ),
+ globalFooter(config),
+ );
+
+ const html = await pageLayout(
+ {
+ metaCopyrightYear: config.blog.siteCopyrightYear,
+ metaDescription: "登壇したイベントで使用したスライドの一覧",
+ metaKeywords: [],
+ metaTitle: `${pageTitle} | ${config.blog.siteName}`,
+ requiresSyntaxHighlight: false,
+ },
+ body,
+ config,
+ );
+
+ return {
+ root: el("__root__", [], html),
+ renderer: "html",
+ destFilePath: "/slides/index.html",
+ href: "/slides/",
+ };
+}
diff --git a/vhosts/blog/nuldoc-src/pages/tag.ts b/vhosts/blog/nuldoc-src/pages/tag.ts
new file mode 100644
index 00000000..f501cb33
--- /dev/null
+++ b/vhosts/blog/nuldoc-src/pages/tag.ts
@@ -0,0 +1,60 @@
+import { globalFooter } from "../components/global_footer.ts";
+import { globalHeader } from "../components/global_header.ts";
+import { pageLayout } from "../components/page_layout.ts";
+import { postPageEntry } from "../components/post_page_entry.ts";
+import { slidePageEntry } from "../components/slide_page_entry.ts";
+import { Config, getTagLabel } from "../config.ts";
+import { el, text } from "../dom.ts";
+import { Page } from "../page.ts";
+import { getPostCreatedDate } from "./post.ts";
+import { TaggedPage } from "./tagged_page.ts";
+
+export interface TagPage extends Page {
+ tagSlug: string;
+ tagLabel: string;
+}
+
+export async function generateTagPage(
+ tagSlug: string,
+ pages: TaggedPage[],
+ config: Config,
+): Promise<TagPage> {
+ const tagLabel = getTagLabel(config, tagSlug);
+ const pageTitle = `タグ「${tagLabel}」一覧`;
+
+ const body = el(
+ "body",
+ [["class", "list"]],
+ globalHeader(config),
+ el(
+ "main",
+ [["class", "main"]],
+ el("header", [["class", "page-header"]], el("h1", [], text(pageTitle))),
+ ...pages.map((page) =>
+ "event" in page ? slidePageEntry(page) : postPageEntry(page)
+ ),
+ ),
+ globalFooter(config),
+ );
+
+ const html = await pageLayout(
+ {
+ metaCopyrightYear: getPostCreatedDate(pages[pages.length - 1]).year,
+ metaDescription: `タグ「${tagLabel}」のついた記事またはスライドの一覧`,
+ metaKeywords: [tagLabel],
+ metaTitle: `${pageTitle} | ${config.blog.siteName}`,
+ requiresSyntaxHighlight: false,
+ },
+ body,
+ config,
+ );
+
+ return {
+ root: el("__root__", [], html),
+ renderer: "html",
+ destFilePath: `/tags/${tagSlug}/index.html`,
+ href: `/tags/${tagSlug}/`,
+ tagSlug: tagSlug,
+ tagLabel: tagLabel,
+ };
+}
diff --git a/vhosts/blog/nuldoc-src/pages/tag_list.ts b/vhosts/blog/nuldoc-src/pages/tag_list.ts
new file mode 100644
index 00000000..2b58ff32
--- /dev/null
+++ b/vhosts/blog/nuldoc-src/pages/tag_list.ts
@@ -0,0 +1,76 @@
+import { globalFooter } from "../components/global_footer.ts";
+import { globalHeader } from "../components/global_header.ts";
+import { pageLayout } from "../components/page_layout.ts";
+import { Config } from "../config.ts";
+import { el, text } from "../dom.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 pageTitle = "タグ一覧";
+
+ const body = el(
+ "body",
+ [["class", "list"]],
+ globalHeader(config),
+ el(
+ "main",
+ [["class", "main"]],
+ el(
+ "header",
+ [["class", "page-header"]],
+ el(
+ "h1",
+ [],
+ text(pageTitle),
+ ),
+ ),
+ ...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) =>
+ el(
+ "article",
+ [["class", "post-entry"]],
+ el(
+ "a",
+ [["href", tag.href]],
+ el(
+ "header",
+ [["class", "entry-header"]],
+ el("h2", [], text(tag.tagLabel)),
+ ),
+ ),
+ )
+ ),
+ ),
+ globalFooter(config),
+ );
+
+ const html = await pageLayout(
+ {
+ metaCopyrightYear: config.blog.siteCopyrightYear,
+ metaDescription: "タグの一覧",
+ metaKeywords: [],
+ metaTitle: `${pageTitle} | ${config.blog.siteName}`,
+ requiresSyntaxHighlight: false,
+ },
+ body,
+ config,
+ );
+
+ return {
+ root: el("__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
new file mode 100644
index 00000000..23de8cb4
--- /dev/null
+++ b/vhosts/blog/nuldoc-src/pages/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/render.ts b/vhosts/blog/nuldoc-src/render.ts
new file mode 100644
index 00000000..feb72a4b
--- /dev/null
+++ b/vhosts/blog/nuldoc-src/render.ts
@@ -0,0 +1,13 @@
+import { Node } from "./dom.ts";
+import { renderHtml } from "./renderers/html.ts";
+
+// export type RendererType = "html" | "xml";
+export type RendererType = "html";
+
+export function render(root: Node, renderer: RendererType): string {
+ if (renderer === "html") {
+ return renderHtml(root);
+ } else {
+ return renderHtml(root);
+ }
+}
diff --git a/vhosts/blog/nuldoc-src/renderers/html.ts b/vhosts/blog/nuldoc-src/renderers/html.ts
new file mode 100644
index 00000000..3b6c6ebc
--- /dev/null
+++ b/vhosts/blog/nuldoc-src/renderers/html.ts
@@ -0,0 +1,275 @@
+import { Element, forEachChild, Node, Text } from "../dom.ts";
+import { DocBookError } from "../errors.ts";
+
+export function renderHtml(root: Node): string {
+ return `<!DOCTYPE html>\n` + nodeToHtmlText(root, {
+ indentLevel: -1,
+ isInPre: false,
+ });
+}
+
+type Context = {
+ indentLevel: number;
+ isInPre: boolean;
+};
+
+type Dtd = { type: "block" | "inline"; auto_closing?: boolean };
+
+function getDtd(name: string): Dtd {
+ switch (name) {
+ case "__root__":
+ return { type: "block" };
+ 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", auto_closing: true };
+ case "button":
+ return { type: "block" };
+ case "canvas":
+ return { type: "block" };
+ case "code":
+ return { type: "inline" };
+ 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" };
+ case "html":
+ return { type: "block" };
+ case "img":
+ return { type: "block" };
+ case "li":
+ return { type: "block" };
+ case "link":
+ return { type: "block", auto_closing: true };
+ case "main":
+ return { type: "block" };
+ case "meta":
+ return { type: "block", auto_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 "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 "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 DocBookError(`[html.write] Unknown element name: ${name}`);
+ }
+}
+
+function isInlineNode(n: Node): boolean {
+ if (n.kind === "text") {
+ 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") {
+ if (n.raw) {
+ return n.content;
+ } else {
+ return textNodeToHtmlText(n, ctx);
+ }
+ } else {
+ return elementNodeToHtmlText(n, ctx);
+ }
+}
+
+function textNodeToHtmlText(t: Text, ctx: Context): string {
+ const s = encodeSpecialCharacters(t.content);
+ if (ctx.isInPre) return s;
+
+ // 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 elementNodeToHtmlText(e: Element, ctx: Context): string {
+ const dtd = getDtd(e.name);
+
+ let s = "";
+ if (e.name !== "__root__") {
+ 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];
+ s += `${name}="${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 (e.name !== "__root__" && !dtd.auto_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 [...e.attributes.entries()]
+ .filter((a) => !a[0].startsWith("--"))
+ .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 (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/vhosts/blog/nuldoc-src/revision.ts b/vhosts/blog/nuldoc-src/revision.ts
new file mode 100644
index 00000000..e04b7ba1
--- /dev/null
+++ b/vhosts/blog/nuldoc-src/revision.ts
@@ -0,0 +1,27 @@
+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 type Revision = {
+ number: number;
+ date: Date;
+ remark: string;
+};
diff --git a/vhosts/blog/nuldoc-src/slide/parse.ts b/vhosts/blog/nuldoc-src/slide/parse.ts
new file mode 100644
index 00000000..00ff645f
--- /dev/null
+++ b/vhosts/blog/nuldoc-src/slide/parse.ts
@@ -0,0 +1,19 @@
+import { Config } from "../config.ts";
+import { parseXmlFile } from "../xml.ts";
+import { SlideError, XmlParseError } from "../errors.ts";
+import { createNewSlideFromRootElement, Slide } from "./slide.ts";
+
+export async function parseSlideFile(
+ filePath: string,
+ config: Config,
+): Promise<Slide> {
+ try {
+ const root = await parseXmlFile(filePath);
+ return createNewSlideFromRootElement(root, filePath, config);
+ } catch (e) {
+ if (e instanceof SlideError || e instanceof XmlParseError) {
+ e.message = `${e.message} in ${filePath}`;
+ }
+ throw e;
+ }
+}
diff --git a/vhosts/blog/nuldoc-src/slide/slide.ts b/vhosts/blog/nuldoc-src/slide/slide.ts
new file mode 100644
index 00000000..a982d4f2
--- /dev/null
+++ b/vhosts/blog/nuldoc-src/slide/slide.ts
@@ -0,0 +1,122 @@
+import { Config } from "../config.ts";
+import { SlideError } from "../errors.ts";
+import { Revision, stringToDate } from "../revision.ts";
+import {
+ Element,
+ findChildElements,
+ findFirstChildElement,
+ innerText,
+} from "../dom.ts";
+
+export type Slide = {
+ sourceFilePath: string;
+ title: string;
+ event: string;
+ talkType: string;
+ slideLink: string;
+ tags: string[];
+ revisions: Revision[];
+};
+
+export function createNewSlideFromRootElement(
+ root: Element,
+ sourceFilePath: string,
+ _config: Config,
+): Slide {
+ const slide = findFirstChildElement(root, "slide");
+ if (!slide) {
+ throw new SlideError(
+ `[slide.new] <slide> element not found`,
+ );
+ }
+ const info = findFirstChildElement(slide, "info");
+ if (!info) {
+ throw new SlideError(
+ `[slide.new] <info> element not found`,
+ );
+ }
+
+ const titleElement = findFirstChildElement(info, "title");
+ if (!titleElement) {
+ throw new SlideError(
+ `[slide.new] <title> element not found`,
+ );
+ }
+ const title = innerText(titleElement).trim();
+
+ const eventElement = findFirstChildElement(info, "event");
+ if (!eventElement) {
+ throw new SlideError(
+ `[slide.new] <event> element not found`,
+ );
+ }
+ const event = innerText(eventElement).trim();
+
+ const talkTypeElement = findFirstChildElement(info, "talktype");
+ if (!talkTypeElement) {
+ throw new SlideError(
+ `[slide.new] <talktype> element not found`,
+ );
+ }
+ const talkType = innerText(talkTypeElement).trim();
+
+ const slideLinkElement = findFirstChildElement(info, "link");
+ if (!slideLinkElement) {
+ throw new SlideError(
+ `[slide.new] <link> element not found`,
+ );
+ }
+ const slideLink = innerText(slideLinkElement).trim();
+
+ const keywordsetElement = findFirstChildElement(info, "keywordset");
+ let tags: string[];
+ if (!keywordsetElement) {
+ tags = [];
+ } else {
+ tags = findChildElements(keywordsetElement, "keyword").map((x) =>
+ innerText(x).trim()
+ );
+ }
+ const revhistoryElement = findFirstChildElement(info, "revhistory");
+ if (!revhistoryElement) {
+ throw new SlideError(
+ `[slide.new] <revhistory> element not found`,
+ );
+ }
+ const revisions = findChildElements(revhistoryElement, "revision").map(
+ (x, i) => {
+ const dateElement = findFirstChildElement(x, "date");
+ if (!dateElement) {
+ throw new SlideError(
+ `[slide.new] <date> element not found`,
+ );
+ }
+ const revremarkElement = findFirstChildElement(x, "revremark");
+ if (!revremarkElement) {
+ throw new SlideError(
+ `[slide.new] <revremark> element not found`,
+ );
+ }
+ return {
+ number: i + 1,
+ date: stringToDate(innerText(dateElement).trim()),
+ remark: innerText(revremarkElement).trim(),
+ };
+ },
+ );
+ if (revisions.length === 0) {
+ throw new SlideError(
+ `[slide.new] <revision> element not found`,
+ );
+ }
+
+ return {
+ sourceFilePath: sourceFilePath,
+ title: title,
+ event: event,
+ talkType: talkType,
+ slideLink: slideLink,
+ tags: tags,
+ revisions: revisions,
+ };
+}
diff --git a/vhosts/blog/nuldoc-src/types/highlight-js.d.ts b/vhosts/blog/nuldoc-src/types/highlight-js.d.ts
new file mode 100644
index 00000000..d7bd0b50
--- /dev/null
+++ b/vhosts/blog/nuldoc-src/types/highlight-js.d.ts
@@ -0,0 +1,10 @@
+declare module "highlight.js" {
+ function getLanguage(
+ name: string,
+ ): string | undefined;
+
+ function highlight(
+ code: string,
+ options: { language: string },
+ ): { value: string };
+}
diff --git a/vhosts/blog/nuldoc-src/xml.ts b/vhosts/blog/nuldoc-src/xml.ts
new file mode 100644
index 00000000..847b5e12
--- /dev/null
+++ b/vhosts/blog/nuldoc-src/xml.ts
@@ -0,0 +1,269 @@
+import { Element, Node, Text } from "./dom.ts";
+import { XmlParseError } from "./errors.ts";
+
+export async function parseXmlFile(filePath: string): Promise<Element> {
+ return parseXmlString(await Deno.readTextFile(filePath));
+}
+
+export function parseXmlString(source: string): Element {
+ return parse({ source: source, index: 0 });
+}
+
+type Parser = {
+ source: string;
+ index: number;
+};
+
+function parse(p: Parser): Element {
+ parseXmlDeclaration(p);
+ skipWhitespaces(p);
+ const e = parseXmlElement(p);
+ const root: Element = {
+ kind: "element",
+ name: "__root__",
+ attributes: new Map(),
+ children: [e],
+ };
+ return root;
+}
+
+function parseXmlDeclaration(p: Parser) {
+ expect(p, "<?xml ");
+ skipTo(p, "?>");
+ next(p, 2);
+}
+
+function parseXmlElement(p: Parser): Element {
+ const { name, attributes, closed } = parseStartTag(p);
+ if (closed) {
+ return {
+ kind: "element",
+ name: name,
+ attributes: attributes,
+ children: [],
+ };
+ }
+ const children = parseChildNodes(p);
+ parseEndTag(p, name);
+
+ const thisElement: Element = {
+ kind: "element",
+ name: name,
+ attributes: attributes,
+ children: children,
+ };
+ return thisElement;
+}
+
+function parseChildNodes(p: Parser): Node[] {
+ const nodes = [];
+ while (true) {
+ const c = peek(p);
+ const c2 = peekN(p, 2);
+ const c3 = peekN(p, 3);
+ if (c === "<") {
+ if (c2 === "/") {
+ break;
+ } else if (c2 === "!") {
+ if (c3 === "[") {
+ // <![CDATA[
+ nodes.push(parseCdata(p));
+ } else {
+ // <!--
+ skipComment(p);
+ }
+ } else {
+ nodes.push(parseXmlElement(p));
+ }
+ } else {
+ nodes.push(parseTextNode(p));
+ }
+ }
+ return nodes;
+}
+
+function parseTextNode(p: Parser): Text {
+ const content = skipTo(p, "<");
+ return {
+ kind: "text",
+ content: replaceEntityReferences(content),
+ raw: false,
+ };
+}
+
+function parseCdata(p: Parser): Text {
+ expect(p, "<![CDATA[");
+ const content = skipTo(p, "]]>");
+ next(p, "]]>".length);
+ return {
+ kind: "text",
+ content: formatCdata(content),
+ raw: false,
+ };
+}
+
+function skipComment(p: Parser) {
+ expect(p, "<!--");
+ skipTo(p, "-->");
+ next(p, "-->".length);
+}
+
+function formatCdata(s: string): string {
+ // <![CDATA[
+ // foo
+ // bar
+ // baz
+ // ]]>
+ // => "foo\n bar\nbaz"
+ s = s.replace(/^\n(.*)\n *$/s, "$1");
+ const ls = s.split("\n");
+ const n = Math.min(
+ ...ls.filter((l) => l !== "").map((l) =>
+ l.match(/^( *)/)?.[0]?.length ?? 0
+ ),
+ );
+ let z = "";
+ for (const p of s.split("\n")) {
+ z += p.slice(n) + "\n";
+ }
+ return z.slice(0, -1);
+}
+
+function parseStartTag(
+ p: Parser,
+): { name: string; attributes: Map<string, string>; closed: boolean } {
+ expect(p, "<");
+ const name = parseIdentifier(p);
+ skipWhitespaces(p);
+ if (peek(p) === "/") {
+ expect(p, "/>");
+ return { name: name, attributes: new Map(), closed: true };
+ }
+ if (peek(p) === ">") {
+ next(p);
+ return { name: name, attributes: new Map(), closed: false };
+ }
+ const attributes = new Map();
+ while (peek(p) !== ">" && peek(p) !== "/") {
+ const { name, value } = parseAttribute(p);
+ attributes.set(name, value);
+ }
+ let closed = false;
+ if (peek(p) === "/") {
+ next(p);
+ closed = true;
+ }
+ expect(p, ">");
+ return { name: name, attributes: attributes, closed: closed };
+}
+
+function parseEndTag(p: Parser, name: string) {
+ expect(p, `</${name}>`);
+}
+
+function parseAttribute(p: Parser): { name: string; value: string } {
+ skipWhitespaces(p);
+ let name = parseIdentifier(p);
+ if (peek(p) === ":") {
+ next(p);
+ const name2 = parseIdentifier(p);
+ name += ":" + name2;
+ }
+ expect(p, "=");
+ const value = parseQuotedString(p);
+ skipWhitespaces(p);
+ return { name: name, value: replaceEntityReferences(value) };
+}
+
+function parseQuotedString(p: Parser): string {
+ expect(p, '"');
+ const content = skipTo(p, '"');
+ next(p);
+ return content;
+}
+
+function parseIdentifier(p: Parser): string {
+ let id = "";
+ while (p.index < p.source.length) {
+ const c = peek(p);
+ if (!c || !/[A-Za-z]/.test(c)) {
+ break;
+ }
+ id += c;
+ next(p);
+ }
+ return id;
+}
+
+function expect(p: Parser, expected: string) {
+ let actual = "";
+ for (let i = 0; i < expected.length; i++) {
+ actual += peek(p);
+ next(p);
+ }
+ if (actual !== expected) {
+ throw new XmlParseError(
+ `[parse.expect] expected ${expected}, but actually got ${
+ escapeForHuman(actual)
+ } (pos: ${p.index})`,
+ );
+ }
+}
+
+function skipTo(p: Parser, delimiter: string): string {
+ const indexStart = p.index;
+ let i = 0;
+ while (i < delimiter.length) {
+ if (peek(p) === delimiter[i]) {
+ i++;
+ } else {
+ i = 0;
+ }
+ next(p);
+ }
+ back(p, delimiter.length);
+ return p.source.substring(indexStart, p.index);
+}
+
+function skipWhitespaces(p: Parser) {
+ while (p.index < p.source.length) {
+ const c = peek(p);
+ if (!c || !/[ \n\t]/.test(c)) {
+ break;
+ }
+ next(p);
+ }
+}
+
+function peek(p: Parser): string | null {
+ return peekN(p, 1);
+}
+
+function peekN(p: Parser, n: number): string | null {
+ return (p.index + n - 1 < p.source.length) ? p.source[p.index + n - 1] : null;
+}
+
+function next(p: Parser, n = 1) {
+ p.index += n;
+}
+
+function back(p: Parser, n = 1) {
+ p.index -= n;
+}
+
+function replaceEntityReferences(s: string): string {
+ return s
+ .replaceAll(/&amp;/g, "&")
+ .replaceAll(/&lt;/g, "<")
+ .replaceAll(/&gt;/g, ">")
+ .replaceAll(/&apos;/g, "'")
+ .replaceAll(/&quot;/g, '"');
+}
+
+function escapeForHuman(s: string): string {
+ // support more characters?
+ return s
+ .replaceAll("\n", "\\n")
+ .replaceAll("\t", "\\t")
+ .replaceAll("\r", "\\r");
+}
diff --git a/vhosts/blog/nuldoc-src/xml_test.ts b/vhosts/blog/nuldoc-src/xml_test.ts
new file mode 100644
index 00000000..28e15970
--- /dev/null
+++ b/vhosts/blog/nuldoc-src/xml_test.ts
@@ -0,0 +1,17 @@
+import { assertEquals } from "std/testing/asserts.ts";
+import { parseXmlString } from "./xml.ts";
+
+Deno.test("Parse XML", () => {
+ assertEquals(
+ "__root__",
+ parseXmlString(
+ `<?xml version="1.0" encoding="UTF-8"?>
+<hoge>
+ <piyo>
+ <!-- comment -->
+ </piyo>
+</hoge>
+`,
+ ).name,
+ );
+});