diff options
| author | nsfisis <nsfisis@gmail.com> | 2022-12-23 23:27:09 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2023-03-06 01:46:04 +0900 |
| commit | 88ba6cfe220216f371f8756921059fac51a21262 (patch) | |
| tree | f272db2a0a3340f103df6618f19a101e65941b37 /nuldoc-src | |
| parent | 8f988a6e899aed678406ddfac1be4ef105439274 (diff) | |
| download | blog.nsfisis.dev-88ba6cfe220216f371f8756921059fac51a21262.tar.gz blog.nsfisis.dev-88ba6cfe220216f371f8756921059fac51a21262.tar.zst blog.nsfisis.dev-88ba6cfe220216f371f8756921059fac51a21262.zip | |
AsciiDoc to DocBook
Diffstat (limited to 'nuldoc-src')
| -rw-r--r-- | nuldoc-src/command.ts | 142 | ||||
| -rw-r--r-- | nuldoc-src/config.ts | 35 | ||||
| -rw-r--r-- | nuldoc-src/docbook/document.ts | 108 | ||||
| -rw-r--r-- | nuldoc-src/docbook/parse.ts | 21 | ||||
| -rw-r--r-- | nuldoc-src/docbook/to_html.ts | 275 | ||||
| -rw-r--r-- | nuldoc-src/dom.ts | 79 | ||||
| -rw-r--r-- | nuldoc-src/errors.ts | 2 | ||||
| -rw-r--r-- | nuldoc-src/html.ts | 254 | ||||
| -rw-r--r-- | nuldoc-src/main.ts | 6 | ||||
| -rw-r--r-- | nuldoc-src/revision.ts | 5 | ||||
| -rw-r--r-- | nuldoc-src/templates/post.ts | 162 | ||||
| -rw-r--r-- | nuldoc-src/templates/post_list.ts | 143 | ||||
| -rw-r--r-- | nuldoc-src/templates/tag.ts | 137 | ||||
| -rw-r--r-- | nuldoc-src/templates/utils.ts | 34 | ||||
| -rw-r--r-- | nuldoc-src/xml.ts | 211 |
15 files changed, 1614 insertions, 0 deletions
diff --git a/nuldoc-src/command.ts b/nuldoc-src/command.ts new file mode 100644 index 0000000..7495427 --- /dev/null +++ b/nuldoc-src/command.ts @@ -0,0 +1,142 @@ +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 { writeHtmlFile } from "./html.ts"; +import { Document } from "./docbook/document.ts"; +import convertPost from "./templates/post.ts"; +import convertPostList from "./templates/post_list.ts"; +import convertTag from "./templates/tag.ts"; + +export async function run(config: Config) { + const posts = await generatePosts(config); + await generateTags(posts, config); + await generatePostList(posts, config); +} + +async function generatePosts(config: Config) { + const sourceDir = join(Deno.cwd(), config.locations.contentDir, "posts"); + const postFiles = await collectPostFiles(sourceDir); + const posts = await parsePosts(postFiles, config); + await outputPosts(posts, 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<Document[]> { + const posts = []; + for (const postFile of postFiles) { + posts.push( + await convertPost(await parseDocBookFile(postFile, config), config), + ); + } + return posts; +} + +async function outputPosts(posts: Document[], config: Config) { + const cwd = Deno.cwd(); + const contentDir = join(cwd, config.locations.contentDir); + const destDir = join(cwd, config.locations.destDir); + for (const post of posts) { + const destFilePath = join( + post.sourceFilePath.replace(contentDir, destDir).replace(".xml", ""), + "index.html", + ); + ensureDir(dirname(destFilePath)); + await writeHtmlFile(post, destFilePath); + } +} + +async function generatePostList(posts: Document[], config: Config) { + const postList = await buildPostListDoc(posts, config); + await outputPostList(postList, config); +} + +async function buildPostListDoc( + posts: Document[], + config: Config, +): Promise<Document> { + return await convertPostList(posts, config); +} + +async function outputPostList(postList: Document, config: Config) { + const cwd = Deno.cwd(); + const destDir = join(cwd, config.locations.destDir); + const destFilePath = join(destDir, "posts", "index.html"); + ensureDir(dirname(destFilePath)); + await writeHtmlFile(postList, destFilePath); +} + +async function generateTags(posts: Document[], config: Config) { + const tagsAndPosts = collectTags(posts); + const tagDocs = await buildTagDocs(tagsAndPosts, config); + await outputTags(tagDocs, config); +} + +function collectTags(posts: Document[]): [string, Document[]][] { + const tagsAndPosts = new Map(); + for (const post of posts) { + for (const tag of post.tags) { + if (!tagsAndPosts.has(tag)) { + tagsAndPosts.set(tag, []); + } + tagsAndPosts.get(tag).push(post); + } + } + + const result: [string, Document[]][] = []; + for (const tag of Array.from(tagsAndPosts.keys()).sort()) { + result.push([ + tag, + tagsAndPosts.get(tag).sort((a: Document, b: Document) => { + const ta = a.revisions[0].date; + const tb = b.revisions[0].date; + if (ta > tb) return -1; + if (ta < tb) return 1; + return 0; + }), + ]); + } + return result; +} + +async function buildTagDocs( + tagsAndPosts: [string, Document[]][], + config: Config, +): Promise<[string, Document][]> { + const docs: [string, Document][] = []; + for (const [tag, posts] of tagsAndPosts) { + docs.push([tag, await buildTagDoc(tag, posts, config)]); + } + return docs; +} + +async function buildTagDoc( + tag: string, + posts: Document[], + config: Config, +): Promise<Document> { + return await convertTag(tag, posts, config); +} + +async function outputTags(tagDocs: [string, Document][], config: Config) { + const cwd = Deno.cwd(); + const destDir = join(cwd, config.locations.destDir); + for (const [tag, tagDoc] of tagDocs) { + const destFilePath = join(destDir, "tags", tag, "index.html"); + ensureDir(dirname(destFilePath)); + await writeHtmlFile(tagDoc, destFilePath); + } +} diff --git a/nuldoc-src/config.ts b/nuldoc-src/config.ts new file mode 100644 index 0000000..74521b3 --- /dev/null +++ b/nuldoc-src/config.ts @@ -0,0 +1,35 @@ +export const config = { + locations: { + contentDir: "/content", + destDir: "/public", + staticDir: "/static", + templateDir: "/templates", + }, + 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 カンファレンス", + "phperkaigi": "PHPerKaigi", + "python": "Python", + "python3": "Python 3", + "ruby": "Ruby", + "ruby3": "Ruby 3", + "rust": "Rust", + "vim": "Vim", + }, + }, +}; + +export type Config = typeof config; diff --git a/nuldoc-src/docbook/document.ts b/nuldoc-src/docbook/document.ts new file mode 100644 index 0000000..b779ac7 --- /dev/null +++ b/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 } 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: 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, + title: title, + summary: summary, + tags: tags, + revisions: revisions, + sourceFilePath: sourceFilePath, + link: link, + }; +} diff --git a/nuldoc-src/docbook/parse.ts b/nuldoc-src/docbook/parse.ts new file mode 100644 index 0000000..bce317e --- /dev/null +++ b/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/nuldoc-src/docbook/to_html.ts b/nuldoc-src/docbook/to_html.ts new file mode 100644 index 0000000..31c419d --- /dev/null +++ b/nuldoc-src/docbook/to_html.ts @@ -0,0 +1,275 @@ +import { Document } from "./document.ts"; +import { DocBookError } from "../errors.ts"; +import { + Element, + findFirstChildElement, + forEachChild, + forEachChildRecursively, + Node, + removeChildElements, +} 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, "simpara", "p"); + transformAttributeNames(doc, "xml:id", "id"); + transformSectionIdAttribute(doc); + setSectionTitleAnchor(doc); + transformSectionTitleElement(doc); + transformProgramListingElement(doc); + transformLiteralLayoutElement(doc); + transformNoteElement(doc); + setDefaultLangAttribute(doc); + traverseFootnotes(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"; + 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"; + const children = n.children; + const codeElement: Element = { + kind: "element", + name: "code", + attributes: new Map(), + children: 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(), + children: [{ + kind: "text", + content: "Note", + }], + }; + const contentElement: Element = { + kind: "element", + name: "div", + attributes: new Map(), + children: n.children, + }; + n.name = "div"; + 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 = []; + }); +} diff --git a/nuldoc-src/dom.ts b/nuldoc-src/dom.ts new file mode 100644 index 0000000..51ef25a --- /dev/null +++ b/nuldoc-src/dom.ts @@ -0,0 +1,79 @@ +export type Text = { + kind: "text"; + content: string; +}; + +export type Element = { + kind: "element"; + name: string; + attributes: Map<string, string>; + children: Node[]; +}; + +export type Node = Element | Text; + +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); +} diff --git a/nuldoc-src/errors.ts b/nuldoc-src/errors.ts new file mode 100644 index 0000000..4d107aa --- /dev/null +++ b/nuldoc-src/errors.ts @@ -0,0 +1,2 @@ +export class DocBookError extends Error {} +export class XmlParseError extends Error {} diff --git a/nuldoc-src/html.ts b/nuldoc-src/html.ts new file mode 100644 index 0000000..d127d29 --- /dev/null +++ b/nuldoc-src/html.ts @@ -0,0 +1,254 @@ +import { Document } from "./docbook/document.ts"; +import { Element, forEachChild, Node, Text } from "./dom.ts"; +import { DocBookError } from "./errors.ts"; + +export async function writeHtmlFile(dom: Document, filePath: string) { + await Deno.writeTextFile(filePath, toHtmlText(dom)); +} + +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 "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 "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 "ol": + return { type: "block" }; + case "p": + return { type: "block" }; + case "pre": + return { type: "block" }; + case "section": + return { type: "block" }; + case "span": + 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 { + return n.kind === "text" || getDtd(n.name).type === "inline"; +} + +function isBlockNode(n: Node): boolean { + return !isInlineNode(n); +} + +function toHtmlText(dom: Document): string { + return `<!DOCTYPE html>\n` + nodeToHtmlText(dom.root, { + indentLevel: -1, + isInPre: false, + }); +} + +function nodeToHtmlText(n: Node, ctx: Context): string { + if (n.kind === "text") { + 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 + .trim() + .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 (dtd.type === "block") { + 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 (dtd.type === "block") { + s += "\n"; + } + } + ctx.indentLevel += 1; + + let prevChild: Node | null = null; + if (e.name === "pre") { + ctx.isInPre = true; + } + forEachChild(e, (c) => { + if (dtd.type === "block") { + 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 (dtd.type === "block") { + if (needsLineBreak(prevChild)) { + s += "\n"; + } + s += indent(ctx); + } + s += `</${e.name}>`; + if (dtd.type === "block") { + 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/nuldoc-src/main.ts b/nuldoc-src/main.ts new file mode 100644 index 0000000..12a4ca9 --- /dev/null +++ b/nuldoc-src/main.ts @@ -0,0 +1,6 @@ +import { config } from "./config.ts"; +import { run } from "./command.ts"; + +if (import.meta.main) { + await run(config); +} diff --git a/nuldoc-src/revision.ts b/nuldoc-src/revision.ts new file mode 100644 index 0000000..1757694 --- /dev/null +++ b/nuldoc-src/revision.ts @@ -0,0 +1,5 @@ +export type Revision = { + number: number; + date: string; // TODO + remark: string; // TODO: should it be markup text? +}; diff --git a/nuldoc-src/templates/post.ts b/nuldoc-src/templates/post.ts new file mode 100644 index 0000000..9e1097d --- /dev/null +++ b/nuldoc-src/templates/post.ts @@ -0,0 +1,162 @@ +import { Element } from "../dom.ts"; +import { Document } from "../docbook/document.ts"; +import { Config } from "../config.ts"; +import { el, stylesheetLinkElement, text } from "./utils.ts"; + +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); +} + +export default async function convertPost( + doc: Document, + config: Config, +): Promise<Document> { + const headChildren = [ + 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", + `© ${doc.revisions[0].date.substring(0, 4)} ${config.blog.author}`, + ]]), + metaElement([["name", "description"], ["content", doc.summary]]), + ]; + if (doc.tags.length !== 0) { + headChildren.push( + metaElement([["name", "keywords"], [ + "content", + doc.tags.map((slug) => + (config.blog.tagLabels as { [key: string]: string })[slug] + ).join(","), + ]]), + ); + } + headChildren.push(linkElement("icon", "/favicon.svg", "image/svg+xml")); + headChildren.push( + el("title", [], text(`${doc.title} | ${config.blog.siteName}`)), + ); + headChildren.push(await stylesheetLinkElement("/hl.css", config)); + headChildren.push(await stylesheetLinkElement("/style.css", config)); + headChildren.push(await stylesheetLinkElement("/custom.css", config)); + const head = el("head", [], ...headChildren); + const body = el( + "body", + [["class", "single"]], + el( + "header", + [["class", "header"]], + el( + "nav", + [["class", "nav"]], + el( + "p", + [["class", "logo"]], + el("a", [["href", "/"]], text(config.blog.siteName)), + ), + ), + ), + 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( + (config.blog.tagLabels as { + [key: string]: string; + })[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", rev.date]], + text(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> + ), + ), + ), + el( + "footer", + [["class", "footer"]], + text( + `© ${config.blog.siteCopyrightYear} ${config.blog.author}`, + ), + ), + ); + const html = el( + "html", + [["lang", "ja-JP"]], + head, + body, + ); + doc.root = el("__root__", [], html); + return doc; +} diff --git a/nuldoc-src/templates/post_list.ts b/nuldoc-src/templates/post_list.ts new file mode 100644 index 0000000..bd91840 --- /dev/null +++ b/nuldoc-src/templates/post_list.ts @@ -0,0 +1,143 @@ +import { Element } from "../dom.ts"; +import { Document } from "../docbook/document.ts"; +import { Config } from "../config.ts"; +import { el, stylesheetLinkElement, text } from "./utils.ts"; + +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); +} + +export default async function convertPostList( + posts: Document[], + config: Config, +): Promise<Document> { + const doc = { + root: el("__root__", []), + sourceFilePath: "<postList>", + link: "/posts/", + title: "投稿一覧", + summary: "投稿した記事の一覧", + tags: [], + revisions: [], + }; + + 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", + `© ${config.blog.siteCopyrightYear} ${config.blog.author}`, + ]]), + metaElement([["name", "description"], ["content", doc.summary]]), + linkElement("icon", "/favicon.svg", "image/svg+xml"), + el("title", [], text(`${doc.title} | ${config.blog.siteName}`)), + await stylesheetLinkElement("/hl.css", config), + await stylesheetLinkElement("/style.css", config), + await stylesheetLinkElement("/custom.css", config), + ); + const body = el( + "body", + [["class", "list"]], + el( + "header", + [["class", "header"]], + el( + "nav", + [["class", "nav"]], + el( + "p", + [["class", "logo"]], + el("a", [["href", "/"]], text(config.blog.siteName)), + ), + ), + ), + el( + "main", + [["class", "main"]], + el( + "header", + [["class", "page-header"]], + el( + "h1", + [], + text(doc.title), + ), + ), + ...Array.from(posts).sort((a, b) => { + const ta = a.revisions[0].date; + const tb = b.revisions[0].date; + if (ta > tb) return -1; + if (ta < tb) return 1; + return 0; + }).map((post) => + el( + "article", + [["class", "post-entry"]], + el( + "a", + [["href", post.link]], + 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"]], + text("Posted on"), + el( + "time", + [["datetime", post.revisions[0].date]], + text(post.revisions[0].date), + ), + ...(post.revisions.length > 1 + ? [ + text(", updated on "), + el("time", [[ + "datetime", + post.revisions[post.revisions.length - 1].date, + ]], text(post.revisions[post.revisions.length - 1].date)), + ] + : []), + ), + ), + ) + ), + ), + el( + "footer", + [["class", "footer"]], + text( + `© ${config.blog.siteCopyrightYear} ${config.blog.author}`, + ), + ), + ); + const html = el( + "html", + [["lang", "ja-JP"]], + head, + body, + ); + + doc.root.children = [html]; + return doc; +} diff --git a/nuldoc-src/templates/tag.ts b/nuldoc-src/templates/tag.ts new file mode 100644 index 0000000..c3080e8 --- /dev/null +++ b/nuldoc-src/templates/tag.ts @@ -0,0 +1,137 @@ +import { Element } from "../dom.ts"; +import { Document } from "../docbook/document.ts"; +import { Config } from "../config.ts"; +import { el, stylesheetLinkElement, text } from "./utils.ts"; + +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); +} + +export default async function convertTag( + tag: string, + posts: Document[], + config: Config, +): Promise<Document> { + const tagLabel = (config.blog.tagLabels as { [key: string]: string })[tag]; + + const doc = { + root: el("__root__", []), + sourceFilePath: `<tag:${tag}>`, + link: `/tags/${tag}/`, + title: tagLabel, + summary: `タグ「${tagLabel}」のついた記事一覧`, + tags: [], + revisions: [], + }; + + const headChildren = [ + 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", + `© ${ + posts[posts.length - 1].revisions[0].date.substring(0, 4) + } ${config.blog.author}`, + ]]), + metaElement([["name", "description"], [ + "content", + doc.summary, + ]]), + metaElement([["name", "keywords"], ["content", tagLabel]]), + linkElement("icon", "/favicon.svg", "image/svg+xml"), + el("title", [], text(`${doc.title} | ${config.blog.siteName}`)), + await stylesheetLinkElement("/hl.css", config), + await stylesheetLinkElement("/style.css", config), + await stylesheetLinkElement("/custom.css", config), + ]; + const head = el("head", [], ...headChildren); + const body = el( + "body", + [["class", "list"]], + el( + "header", + [["class", "header"]], + el( + "nav", + [["class", "nav"]], + el( + "p", + [["class", "logo"]], + el("a", [["href", "/"]], text(config.blog.siteName)), + ), + ), + ), + el( + "main", + [["class", "main"]], + el("header", [["class", "page-header"]], el("h1", [], text(tagLabel))), + ...posts.map((post) => + el( + "article", + [["class", "post-entry"]], + el( + "a", + [["href", post.link]], + 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"]], + text("Posted on"), + el( + "time", + [["datetime", post.revisions[0].date]], + text(post.revisions[0].date), + ), + ...(post.revisions.length > 1 + ? [ + text(", updated on "), + el("time", [[ + "datetime", + post.revisions[post.revisions.length - 1].date, + ]], text(post.revisions[post.revisions.length - 1].date)), + ] + : []), + ), + ), + ) + ), + ), + el( + "footer", + [["class", "footer"]], + text( + `© ${config.blog.siteCopyrightYear} ${config.blog.author}`, + ), + ), + ); + const html = el( + "html", + [["lang", "ja-JP"]], + head, + body, + ); + + doc.root.children = [html]; + return doc; +} diff --git a/nuldoc-src/templates/utils.ts b/nuldoc-src/templates/utils.ts new file mode 100644 index 0000000..a86803d --- /dev/null +++ b/nuldoc-src/templates/utils.ts @@ -0,0 +1,34 @@ +import { join } from "std/path/mod.ts"; +import { crypto, toHashString } from "std/crypto/mod.ts"; +import { Element, Node, Text } from "../dom.ts"; +import { Config } from "../config.ts"; + +export function text(content: string): Text { + return { + kind: "text", + content: content, + }; +} + +export function el( + name: string, + attrs: [string, string][], + ...children: Node[] +): Element { + return { + kind: "element", + name: name, + attributes: new Map(attrs), + children: children, + }; +} + +export async function stylesheetLinkElement( + fileName: string, + config: Config, +): Promise<Element> { + const filePath = join(Deno.cwd(), config.locations.staticDir, fileName); + const content = (await Deno.readFile(filePath)).buffer; + const hash = toHashString(await crypto.subtle.digest("MD5", content), "hex"); + return el("link", [["rel", "stylesheet"], ["href", `${fileName}?h=${hash}`]]); +} diff --git a/nuldoc-src/xml.ts b/nuldoc-src/xml.ts new file mode 100644 index 0000000..0bfbd8d --- /dev/null +++ b/nuldoc-src/xml.ts @@ -0,0 +1,211 @@ +import { Element, Node, Text } from "./dom.ts"; +import { XmlParseError } from "./errors.ts"; + +// TODO +// Support comment? <!-- --> +// Support CDATA + +export async function parseXmlFile(filePath: string): Promise<Element> { + const source = await Deno.readTextFile(filePath); + 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 = peek2(p); + if (c === "<") { + if (c2 === "/") { + break; + } + 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), + }; +} + +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 ${actual}`, + ); + } +} + +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 (p.index < p.source.length) ? p.source[p.index] : null; +} + +function peek2(p: Parser): string | null { + return (p.index + 1 < p.source.length) ? p.source[p.index + 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, '"'); +} |
