diff options
| author | nsfisis <nsfisis@gmail.com> | 2023-09-07 22:27:48 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2023-09-07 22:35:53 +0900 |
| commit | 994e0114d76ae19768d5c303874a968cf6369fd0 (patch) | |
| tree | 5fd3f8b169eea00084b24fbae820f75273864d2a /vhosts/blog/nuldoc-src | |
| parent | 57f015992f678bfd7281f171fb9d71349c96a1a0 (diff) | |
| download | nsfisis.dev-994e0114d76ae19768d5c303874a968cf6369fd0.tar.gz nsfisis.dev-994e0114d76ae19768d5c303874a968cf6369fd0.tar.zst nsfisis.dev-994e0114d76ae19768d5c303874a968cf6369fd0.zip | |
meta: migrate to monorepo
Diffstat (limited to 'vhosts/blog/nuldoc-src')
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( + `© ${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", + `© ${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, "&") + .replaceAll(/</g, "<") + .replaceAll(/>/g, ">") + .replaceAll(/'/g, "'") + .replaceAll(/"/g, """); +} + +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(/&/g, "&") + .replaceAll(/</g, "<") + .replaceAll(/>/g, ">") + .replaceAll(/'/g, "'") + .replaceAll(/"/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, + ); +}); |
