diff options
| author | nsfisis <nsfisis@gmail.com> | 2025-06-27 23:39:31 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2025-06-27 23:39:31 +0900 |
| commit | 674fe965550444db87edc7937ff6932e1a918d9d (patch) | |
| tree | e8a80dd958d3e082485286bf5785a7992b6e6b0e /services/blog/nuldoc-src | |
| parent | fe4d1d625b53796c5f20399790e5ff8c7a7e1608 (diff) | |
| download | nsfisis.dev-674fe965550444db87edc7937ff6932e1a918d9d.tar.gz nsfisis.dev-674fe965550444db87edc7937ff6932e1a918d9d.tar.zst nsfisis.dev-674fe965550444db87edc7937ff6932e1a918d9d.zip | |
feat(meta): rename vhosts/ directory to services/
Diffstat (limited to 'services/blog/nuldoc-src')
51 files changed, 3917 insertions, 0 deletions
diff --git a/services/blog/nuldoc-src/commands/build.ts b/services/blog/nuldoc-src/commands/build.ts new file mode 100644 index 00000000..3f765441 --- /dev/null +++ b/services/blog/nuldoc-src/commands/build.ts @@ -0,0 +1,260 @@ +import { dirname, join, joinGlobs } from "@std/path"; +import { ensureDir, expandGlob } from "@std/fs"; +import { generateFeedPageFromEntries } from "../generators/atom.ts"; +import { Config, getTagLabel } from "../config.ts"; +import { parseDjotFile } from "../djot/parse.ts"; +import { Page } from "../page.ts"; +import { render } from "../render.ts"; +import { dateToString } from "../revision.ts"; +import { generateAboutPage } from "../generators/about.ts"; +import { generateHomePage } from "../generators/home.ts"; +import { generateNotFoundPage } from "../generators/not_found.ts"; +import { + generatePostPage, + getPostPublishedDate, + PostPage, +} from "../generators/post.ts"; +import { generatePostListPages } from "../generators/post_list.ts"; +import { generateSlidePage, SlidePage } from "../generators/slide.ts"; +import { generateSlideListPage } from "../generators/slide_list.ts"; +import { generateTagPage, TagPage } from "../generators/tag.ts"; +import { TaggedPage } from "../generators/tagged_page.ts"; +import { generateTagListPage } from "../generators/tag_list.ts"; +import { parseSlideFile } from "../slide/parse.ts"; + +export async function runBuildCommand(config: Config) { + const posts = await buildPostPages(config); + await buildPostListPage(posts, config); + const slides = await buildSlidePages(config); + await buildSlideListPage(slides, config); + const tags = await buildTagPages(posts, slides, config); + await buildTagListPage(tags, config); + await buildHomePage(config); + await buildAboutPage(slides, config); + await buildNotFoundPage(config); + await buildFeedOfAllContents(posts, slides, config); + await copyStaticFiles(config); + await 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, "**", "*.dj"]); + for await (const entry of expandGlob(globPattern)) { + filePaths.push(entry.path); + } + return filePaths; +} + +async function parsePosts( + postFiles: string[], + config: Config, +): Promise<PostPage[]> { + const posts = []; + for (const postFile of postFiles) { + posts.push( + await generatePostPage(await parseDjotFile(postFile, config), config), + ); + } + return posts; +} + +async function buildPostListPage(posts: PostPage[], config: Config) { + // Sort posts by published date (newest first) + const sortedPosts = [...posts].sort((a, b) => { + const ta = dateToString(getPostPublishedDate(a)); + const tb = dateToString(getPostPublishedDate(b)); + if (ta > tb) return -1; + if (ta < tb) return 1; + return 0; + }); + + const postListPages = await generatePostListPages(sortedPosts, config); + for (const page of postListPages) { + await writePage(page, config); + } + + const postFeedPage = await generateFeedPageFromEntries( + "/posts/", + "posts", + `投稿一覧|${config.blog.siteName}`, + posts, + config, + ); + await writePage(postFeedPage, config); +} + +async function buildSlidePages(config: Config): Promise<SlidePage[]> { + const sourceDir = join(Deno.cwd(), config.locations.contentDir, "slides"); + const slideFiles = await collectSlideFiles(sourceDir); + const slides = await parseSlides(slideFiles, config); + for (const slide of slides) { + await writePage(slide, config); + } + return slides; +} + +async function collectSlideFiles(sourceDir: string): Promise<string[]> { + const filePaths = []; + const globPattern = joinGlobs([sourceDir, "**", "*.toml"]); + for await (const entry of expandGlob(globPattern)) { + filePaths.push(entry.path); + } + return filePaths; +} + +async function parseSlides( + slideFiles: string[], + config: Config, +): Promise<SlidePage[]> { + const slides = []; + for (const slideFile of slideFiles) { + slides.push( + await generateSlidePage(await parseSlideFile(slideFile), config), + ); + } + return slides; +} + +async function buildSlideListPage(slides: SlidePage[], config: Config) { + const slideListPage = await generateSlideListPage(slides, config); + await writePage(slideListPage, config); + const slideFeedPage = await generateFeedPageFromEntries( + slideListPage.href, + "slides", + `スライド一覧|${config.blog.siteName}`, + slides, + config, + ); + await writePage(slideFeedPage, config); +} + +async function buildHomePage(config: Config) { + const homePage = await generateHomePage(config); + await writePage(homePage, config); +} + +async function buildAboutPage(slides: SlidePage[], config: Config) { + const aboutPage = await generateAboutPage(slides, config); + await writePage(aboutPage, config); +} + +async function buildNotFoundPage(config: Config) { + const notFoundPage = await generateNotFoundPage(config); + await writePage(notFoundPage, config); +} + +async function buildFeedOfAllContents( + posts: PostPage[], + slides: SlidePage[], + config: Config, +) { + const feed = await generateFeedPageFromEntries( + "/", + "all", + config.blog.siteName, + [...posts, ...slides], + config, + ); + await writePage(feed, config); +} + +async function buildTagPages( + posts: PostPage[], + slides: SlidePage[], + config: Config, +): Promise<TagPage[]> { + const tagsAndPages = collectTags([...posts, ...slides]); + const tags = []; + for (const [tag, pages] of tagsAndPages) { + const tagPage = await generateTagPage(tag, pages, config); + await writePage(tagPage, config); + const tagFeedPage = await generateFeedPageFromEntries( + tagPage.href, + `tag-${tag}`, + `タグ「${getTagLabel(config, tag)}」一覧|${config.blog.siteName}`, + pages, + config, + ); + await writePage(tagFeedPage, config); + tags.push(tagPage); + } + return tags; +} + +async function buildTagListPage(tags: TagPage[], config: Config) { + const tagListPage = await generateTagListPage(tags, config); + await writePage(tagListPage, config); +} + +function collectTags(taggedPages: TaggedPage[]): [string, TaggedPage[]][] { + const tagsAndPages = new Map(); + for (const page of taggedPages) { + for (const tag of page.tags) { + if (!tagsAndPages.has(tag)) { + tagsAndPages.set(tag, []); + } + tagsAndPages.get(tag).push(page); + } + } + + const result: [string, TaggedPage[]][] = []; + for (const tag of Array.from(tagsAndPages.keys()).sort()) { + result.push([ + tag, + tagsAndPages.get(tag).sort((a: TaggedPage, b: TaggedPage) => { + const ta = dateToString(getPostPublishedDate(a)); + const tb = dateToString(getPostPublishedDate(b)); + if (ta > tb) return -1; + if (ta < tb) return 1; + return 0; + }), + ]); + } + return result; +} + +async function copyStaticFiles(config: Config) { + const globPattern = joinGlobs([Deno.cwd(), config.locations.staticDir, "*"]); + for await (const entry of expandGlob(globPattern)) { + const src = entry.path; + const dst = src.replace( + config.locations.staticDir, + config.locations.destDir, + ); + await Deno.copyFile(src, dst); + } +} + +async function 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/services/blog/nuldoc-src/commands/new.ts b/services/blog/nuldoc-src/commands/new.ts new file mode 100644 index 00000000..651c59e6 --- /dev/null +++ b/services/blog/nuldoc-src/commands/new.ts @@ -0,0 +1,97 @@ +import { dirname, join } from "@std/path"; +import { ensureDir } from "@std/fs"; +import { parseArgs } from "@std/cli"; +import { Config } from "../config.ts"; + +export async function runNewCommand(config: Config) { + const parsedArgs = parseArgs(Deno.args, { + string: ["date"], + }); + + const type = parsedArgs._[1]; + if (type !== "post" && type !== "slide") { + console.log(`Usage: nuldoc new <type> + +<type> must be either "post" or "slide". + +OPTIONS: + --date <DATE> +`); + Deno.exit(1); + } + + const ymd = (() => { + if (parsedArgs.date) { + return parsedArgs.date; + } + + const now = new Date(); + const y = now.getFullYear(); + const d = (now.getMonth() + 1).toString().padStart(2, "0"); + const m = now.getDate().toString().padStart(2, "0"); + return `${y}-${d}-${m}`; + })(); + + const destFilePath = join( + Deno.cwd(), + config.locations.contentDir, + getDirPath(type), + ymd, + getFilename(type), + ); + + await ensureDir(dirname(destFilePath)); + await Deno.writeTextFile(destFilePath, getTemplate(type, ymd)); + console.log( + `New file ${ + destFilePath.replace(Deno.cwd(), "") + } was successfully created.`, + ); +} + +function getFilename(type: "post" | "slide"): string { + return type === "post" ? "TODO.dj" : "TODO.toml"; +} + +function getDirPath(type: "post" | "slide"): string { + return type === "post" ? "posts" : "slides"; +} + +function getTemplate(type: "post" | "slide", date: string): string { + const uuid = crypto.randomUUID(); + if (type === "post") { + return `--- +[article] +uuid = "${uuid}" +title = "TODO" +description = "TODO" +tags = [ + "TODO", +] + +[[article.revisions]] +date = "${date}" +remark = "公開" +--- +{#TODO} +# TODO + +TODO +`; + } else { + return `[slide] +uuid = "${uuid}" +title = "TODO" +event = "TODO" +talkType = "TODO" +link = "TODO" +tags = [ + "TODO", +] + +[[slide.revisions]] +date = "${date}" +remark = "登壇" +`; + } +} diff --git a/services/blog/nuldoc-src/commands/serve.ts b/services/blog/nuldoc-src/commands/serve.ts new file mode 100644 index 00000000..e944aaf0 --- /dev/null +++ b/services/blog/nuldoc-src/commands/serve.ts @@ -0,0 +1,45 @@ +import { serveDir, STATUS_CODE, STATUS_TEXT } from "@std/http"; +import { join } from "@std/path"; +import { Config } from "../config.ts"; +import { runBuildCommand } from "./build.ts"; + +function isResourcePath(pathname: string): boolean { + const EXTENSIONS = [ + ".css", + ".gif", + ".ico", + ".jpeg", + ".jpg", + ".js", + ".png", + ".svg", + ]; + return EXTENSIONS.some((ext) => pathname.endsWith(ext)); +} + +export function runServeCommand(config: Config) { + const rootDir = join(Deno.cwd(), config.locations.destDir); + Deno.serve({ hostname: "127.0.0.1" }, async (req) => { + const pathname = new URL(req.url).pathname; + if (!isResourcePath(pathname)) { + await runBuildCommand(config); + console.log("rebuild"); + } + const res = await serveDir(req, { + fsRoot: rootDir, + showIndex: true, + }); + if (res.status !== STATUS_CODE.NotFound) { + return res; + } + + const notFoundHtml = await Deno.readTextFile(join(rootDir, "404.html")); + return new Response(notFoundHtml, { + status: STATUS_CODE.NotFound, + statusText: STATUS_TEXT[STATUS_CODE.NotFound], + headers: { + "content-type": "text/html", + }, + }); + }); +} diff --git a/services/blog/nuldoc-src/components/GlobalFooter.tsx b/services/blog/nuldoc-src/components/GlobalFooter.tsx new file mode 100644 index 00000000..757beced --- /dev/null +++ b/services/blog/nuldoc-src/components/GlobalFooter.tsx @@ -0,0 +1,9 @@ +import { Config } from "../config.ts"; + +export default function GlobalFooter({ config }: { config: Config }) { + return ( + <footer className="footer"> + {`© ${config.blog.siteCopyrightYear} ${config.blog.author}`} + </footer> + ); +} diff --git a/services/blog/nuldoc-src/components/GlobalHeader.tsx b/services/blog/nuldoc-src/components/GlobalHeader.tsx new file mode 100644 index 00000000..c0fa7e8b --- /dev/null +++ b/services/blog/nuldoc-src/components/GlobalHeader.tsx @@ -0,0 +1,27 @@ +import { Config } from "../config.ts"; + +export default function GlobalHeader({ config }: { config: Config }) { + return ( + <header className="header"> + <div className="site-logo"> + <a href="/">{config.blog.siteName}</a> + </div> + <nav className="nav"> + <ul> + <li> + <a href="/about/">About</a> + </li> + <li> + <a href="/posts/">Posts</a> + </li> + <li> + <a href="/slides/">Slides</a> + </li> + <li> + <a href="/tags/">Tags</a> + </li> + </ul> + </nav> + </header> + ); +} diff --git a/services/blog/nuldoc-src/components/PageLayout.tsx b/services/blog/nuldoc-src/components/PageLayout.tsx new file mode 100644 index 00000000..1cd0aebf --- /dev/null +++ b/services/blog/nuldoc-src/components/PageLayout.tsx @@ -0,0 +1,61 @@ +import { Config } from "../config.ts"; +import { JSXNode } from "myjsx/jsx-runtime"; +import StaticStylesheet from "./StaticStylesheet.tsx"; + +type Props = { + metaCopyrightYear: number; + metaDescription: string; + metaKeywords?: string[]; + metaTitle: string; + metaAtomFeedHref?: string; + requiresSyntaxHighlight?: boolean; + config: Config; + children: JSXNode; +}; + +export default function PageLayout( + { + metaCopyrightYear, + metaDescription, + metaKeywords, + metaTitle, + metaAtomFeedHref, + requiresSyntaxHighlight: _, + config, + children, + }: Props, +) { + return ( + <html lang="ja-JP"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <meta name="author" content={config.blog.author} /> + <meta + name="copyright" + content={`© ${metaCopyrightYear} ${config.blog.author}`} + /> + <meta name="description" content={metaDescription} /> + {metaKeywords && metaKeywords.length !== 0 && + <meta name="keywords" content={metaKeywords.join(",")} />} + <meta property="og:type" content="article" /> + <meta property="og:title" content={metaTitle} /> + <meta property="og:description" content={metaDescription} /> + <meta property="og:site_name" content={config.blog.siteName} /> + <meta property="og:locale" content="ja_JP" /> + {metaAtomFeedHref && + ( + <link + rel="alternate" + href={metaAtomFeedHref} + type="application/atom+xml" + /> + )} + <link rel="icon" href="/favicon.svg" type="image/svg+xml" /> + <title>{metaTitle}</title> + <StaticStylesheet fileName="/style.css" config={config} /> + </head> + {children} + </html> + ); +} diff --git a/services/blog/nuldoc-src/components/Pagination.tsx b/services/blog/nuldoc-src/components/Pagination.tsx new file mode 100644 index 00000000..5527c924 --- /dev/null +++ b/services/blog/nuldoc-src/components/Pagination.tsx @@ -0,0 +1,45 @@ +type Props = { + currentPage: number; + totalPages: number; + basePath: string; +}; + +export default function Pagination( + { currentPage, totalPages, basePath }: Props, +) { + if (totalPages <= 1) { + return <div></div>; + } + + const prevPage = currentPage > 1 ? currentPage - 1 : null; + const nextPage = currentPage < totalPages ? currentPage + 1 : null; + + const prevHref = prevPage === 1 ? basePath : `${basePath}${prevPage}/`; + const nextHref = `${basePath}${nextPage}/`; + + return ( + <nav className="pagination"> + <div className="pagination-prev"> + {prevPage + ? ( + <a href={prevHref}> + 前のページ + </a> + ) + : null} + </div> + <div className="pagination-info"> + {String(currentPage)} / {String(totalPages)} + </div> + <div className="pagination-next"> + {nextPage + ? ( + <a href={nextHref}> + 次のページ + </a> + ) + : null} + </div> + </nav> + ); +} diff --git a/services/blog/nuldoc-src/components/PostPageEntry.tsx b/services/blog/nuldoc-src/components/PostPageEntry.tsx new file mode 100644 index 00000000..2708b009 --- /dev/null +++ b/services/blog/nuldoc-src/components/PostPageEntry.tsx @@ -0,0 +1,39 @@ +import { + getPostPublishedDate, + getPostUpdatedDate, + postHasAnyUpdates, + PostPage, +} from "../generators/post.ts"; +import { dateToString } from "../revision.ts"; + +export default function PostPageEntry({ post }: { post: PostPage }) { + return ( + <article className="post-entry"> + <a href={post.href}> + <header className="entry-header"> + <h2>{post.title}</h2> + </header> + <section className="entry-content"> + <p>{post.description}</p> + </section> + <footer className="entry-footer"> + <time datetime={dateToString(getPostPublishedDate(post))}> + {dateToString(getPostPublishedDate(post))} + </time> + {" 投稿"} + { + // TODO(jsx): support Fragment and merge them. + postHasAnyUpdates(post) && "、" + } + {postHasAnyUpdates(post) && + ( + <time datetime={dateToString(getPostUpdatedDate(post))}> + {dateToString(getPostUpdatedDate(post))} + </time> + )} + {postHasAnyUpdates(post) && " 更新"} + </footer> + </a> + </article> + ); +} diff --git a/services/blog/nuldoc-src/components/SlidePageEntry.tsx b/services/blog/nuldoc-src/components/SlidePageEntry.tsx new file mode 100644 index 00000000..d2cf9a17 --- /dev/null +++ b/services/blog/nuldoc-src/components/SlidePageEntry.tsx @@ -0,0 +1,39 @@ +import { + getPostPublishedDate, + getPostUpdatedDate, + postHasAnyUpdates, +} from "../generators/post.ts"; +import { SlidePage } from "../generators/slide.ts"; +import { dateToString } from "../revision.ts"; + +export default function SlidePageEntry({ slide }: { slide: SlidePage }) { + return ( + <article className="post-entry"> + <a href={slide.href}> + <header className="entry-header"> + <h2>{slide.description}</h2> + </header> + <section className="entry-content"> + <p>{slide.title}</p> + </section> + <footer className="entry-footer"> + <time datetime={dateToString(getPostPublishedDate(slide))}> + {dateToString(getPostPublishedDate(slide))} + </time> + {" 登壇"} + { + // TODO(jsx): support Fragment and merge them. + postHasAnyUpdates(slide) && "、" + } + {postHasAnyUpdates(slide) && + ( + <time datetime={dateToString(getPostUpdatedDate(slide))}> + {dateToString(getPostUpdatedDate(slide))} + </time> + )} + {postHasAnyUpdates(slide) && " 更新"} + </footer> + </a> + </article> + ); +} diff --git a/services/blog/nuldoc-src/components/StaticScript.tsx b/services/blog/nuldoc-src/components/StaticScript.tsx new file mode 100644 index 00000000..0e3ab194 --- /dev/null +++ b/services/blog/nuldoc-src/components/StaticScript.tsx @@ -0,0 +1,18 @@ +import { join } from "@std/path"; +import { Config } from "../config.ts"; +import { calculateFileHash } from "./utils.ts"; + +export default async function StaticScript( + { fileName, type, defer, config }: { + fileName: string; + type?: string; + defer?: "true"; + config: Config; + }, +) { + const filePath = join(Deno.cwd(), config.locations.staticDir, fileName); + const hash = await calculateFileHash(filePath); + return ( + <script src={`${fileName}?h=${hash}`} type={type} defer={defer}></script> + ); +} diff --git a/services/blog/nuldoc-src/components/StaticStylesheet.tsx b/services/blog/nuldoc-src/components/StaticStylesheet.tsx new file mode 100644 index 00000000..52b695e5 --- /dev/null +++ b/services/blog/nuldoc-src/components/StaticStylesheet.tsx @@ -0,0 +1,11 @@ +import { join } from "@std/path"; +import { Config } from "../config.ts"; +import { calculateFileHash } from "./utils.ts"; + +export default async function StaticStylesheet( + { fileName, config }: { fileName: string; config: Config }, +) { + const filePath = join(Deno.cwd(), config.locations.staticDir, fileName); + const hash = await calculateFileHash(filePath); + return <link rel="stylesheet" href={`${fileName}?h=${hash}`} />; +} diff --git a/services/blog/nuldoc-src/components/utils.ts b/services/blog/nuldoc-src/components/utils.ts new file mode 100644 index 00000000..14059b5b --- /dev/null +++ b/services/blog/nuldoc-src/components/utils.ts @@ -0,0 +1,8 @@ +import { Hash } from "checksum/mod.ts"; + +export async function calculateFileHash( + filePath: string, +): Promise<string> { + const content = await Deno.readFile(filePath); + return new Hash("md5").digest(content).hex(); +} diff --git a/services/blog/nuldoc-src/config.ts b/services/blog/nuldoc-src/config.ts new file mode 100644 index 00000000..adcb5632 --- /dev/null +++ b/services/blog/nuldoc-src/config.ts @@ -0,0 +1,41 @@ +import { join } from "@std/path"; +import { parse as parseToml } from "@std/toml"; +import { z } from "zod/mod.ts"; + +const ConfigSchema = z.object({ + locations: z.object({ + contentDir: z.string(), + destDir: z.string(), + staticDir: z.string(), + }), + rendering: z.object({ + html: z.object({ + indentWidth: z.number(), + }), + }), + blog: z.object({ + author: z.string(), + fqdn: z.string(), + siteName: z.string(), + siteCopyrightYear: z.number(), + postsPerPage: z.number().default(10), + tagLabels: z.record(z.string(), z.string()), + }), +}); + +export type Config = z.infer<typeof ConfigSchema>; + +export function getTagLabel(c: Config, slug: string): string { + if (!(slug in c.blog.tagLabels)) { + throw new Error(`Unknown tag: ${slug}`); + } + return c.blog.tagLabels[slug]; +} + +export function getDefaultConfigPath(): string { + return join(Deno.cwd(), "nuldoc.toml"); +} + +export async function loadConfig(filePath: string): Promise<Config> { + return ConfigSchema.parse(parseToml(await Deno.readTextFile(filePath))); +} diff --git a/services/blog/nuldoc-src/djot/djot2ndoc.ts b/services/blog/nuldoc-src/djot/djot2ndoc.ts new file mode 100644 index 00000000..90b1289c --- /dev/null +++ b/services/blog/nuldoc-src/djot/djot2ndoc.ts @@ -0,0 +1,842 @@ +import { + Block as DjotBlock, + BlockQuote as DjotBlockQuote, + BulletList as DjotBulletList, + CodeBlock as DjotCodeBlock, + Definition as DjotDefinition, + DefinitionList as DjotDefinitionList, + DefinitionListItem as DjotDefinitionListItem, + Delete as DjotDelete, + DisplayMath as DjotDisplayMath, + Div as DjotDiv, + Doc as DjotDoc, + DoubleQuoted as DjotDoubleQuoted, + Email as DjotEmail, + Emph as DjotEmph, + FootnoteReference as DjotFootnoteReference, + HardBreak as DjotHardBreak, + Heading as DjotHeading, + Image as DjotImage, + Inline as DjotInline, + InlineMath as DjotInlineMath, + Insert as DjotInsert, + Link as DjotLink, + ListItem as DjotListItem, + Mark as DjotMark, + NonBreakingSpace as DjotNonBreakingSpace, + OrderedList as DjotOrderedList, + Para as DjotPara, + RawBlock as DjotRawBlock, + RawInline as DjotRawInline, + Section as DjotSection, + SingleQuoted as DjotSingleQuoted, + SmartPunctuation as DjotSmartPunctuation, + SoftBreak as DjotSoftBreak, + Span as DjotSpan, + Str as DjotStr, + Strong as DjotStrong, + Subscript as DjotSubscript, + Superscript as DjotSuperscript, + Symb as DjotSymb, + Table as DjotTable, + TaskList as DjotTaskList, + TaskListItem as DjotTaskListItem, + Term as DjotTerm, + ThematicBreak as DjotThematicBreak, + Url as DjotUrl, + Verbatim as DjotVerbatim, +} from "@djot/djot"; +import { Element, Node } from "../dom.ts"; + +function processBlock(node: DjotBlock): Element { + switch (node.tag) { + case "section": + return processSection(node); + case "para": + return processPara(node); + case "heading": + return processHeading(node); + case "thematic_break": + return processThematicBreak(node); + case "block_quote": + return processBlockQuote(node); + case "code_block": + return processCodeBlock(node); + case "bullet_list": + return processBulletList(node); + case "ordered_list": + return processOrderedList(node); + case "task_list": + return processTaskList(node); + case "definition_list": + return processDefinitionList(node); + case "table": + return processTable(node); + case "div": + return processDiv(node); + case "raw_block": + return processRawBlock(node); + } +} + +function processSection(node: DjotSection): Element { + return { + kind: "element", + name: "section", + attributes: convertAttributes(node.attributes), + children: node.children.map(processBlock), + }; +} + +function processPara(node: DjotPara): Element { + return { + kind: "element", + name: "p", + attributes: convertAttributes(node.attributes), + children: node.children.map(processInline), + }; +} + +function processHeading(node: DjotHeading): Element { + const attributes = convertAttributes(node.attributes); + return { + kind: "element", + name: "h", + attributes, + children: node.children.map(processInline), + }; +} + +function processThematicBreak(node: DjotThematicBreak): Element { + return { + kind: "element", + name: "hr", + attributes: convertAttributes(node.attributes), + children: [], + }; +} + +function processBlockQuote(node: DjotBlockQuote): Element { + return { + kind: "element", + name: "blockquote", + attributes: convertAttributes(node.attributes), + children: node.children.map(processBlock), + }; +} + +function processCodeBlock(node: DjotCodeBlock): Element { + const attributes = convertAttributes(node.attributes); + if (node.lang) { + attributes.set("language", node.lang); + } + if (node.attributes?.filename) { + attributes.set("filename", node.attributes.filename); + } + if (node.attributes?.numbered) { + attributes.set("numbered", "true"); + } + return { + kind: "element", + name: "codeblock", + attributes, + children: [ + { + kind: "text", + content: node.text, + raw: false, + }, + ], + }; +} + +function processBulletList(node: DjotBulletList): Element { + const attributes = convertAttributes(node.attributes); + attributes.set("--tight", node.tight ? "true" : "false"); + return { + kind: "element", + name: "ul", + attributes, + children: node.children.map(processListItem), + }; +} + +function processOrderedList(node: DjotOrderedList): Element { + const attributes = convertAttributes(node.attributes); + attributes.set("--tight", node.tight ? "true" : "false"); + if (node.start !== undefined && node.start !== 1) { + attributes.set("start", node.start.toString()); + } + return { + kind: "element", + name: "ol", + attributes, + children: node.children.map(processListItem), + }; +} + +function processTaskList(node: DjotTaskList): Element { + const attributes = convertAttributes(node.attributes); + attributes.set("type", "task"); + attributes.set("--tight", node.tight ? "true" : "false"); + return { + kind: "element", + name: "ul", + attributes, + children: node.children.map(processTaskListItem), + }; +} + +function processListItem(node: DjotListItem): Element { + return { + kind: "element", + name: "li", + attributes: convertAttributes(node.attributes), + children: node.children.map(processBlock), + }; +} + +function processTaskListItem(node: DjotTaskListItem): Element { + const attributes = convertAttributes(node.attributes); + attributes.set("checked", node.checkbox === "checked" ? "true" : "false"); + return { + kind: "element", + name: "li", + attributes, + children: node.children.map(processBlock), + }; +} + +function processDefinitionList(node: DjotDefinitionList): Element { + return { + kind: "element", + name: "dl", + attributes: convertAttributes(node.attributes), + children: node.children.flatMap(processDefinitionListItem), + }; +} + +function processDefinitionListItem(node: DjotDefinitionListItem): Element[] { + return [ + processTerm(node.children[0]), + processDefinition(node.children[1]), + ]; +} + +function processTerm(node: DjotTerm): Element { + return { + kind: "element", + name: "dt", + attributes: convertAttributes(node.attributes), + children: node.children.map(processInline), + }; +} + +function processDefinition(node: DjotDefinition): Element { + return { + kind: "element", + name: "dd", + attributes: convertAttributes(node.attributes), + children: node.children.map(processBlock), + }; +} + +function processTable(node: DjotTable): Element { + // Tables in Djot have a caption as first child and then rows + // For now, we'll create a basic table structure and ignore caption + const tableElement: Element = { + kind: "element", + name: "table", + attributes: convertAttributes(node.attributes), + children: [], + }; + + // Process caption if it exists (first child) + if (node.children.length > 0 && node.children[0].tag === "caption") { + const caption: Element = { + kind: "element", + name: "caption", + attributes: new Map(), + children: node.children[0].children.map(processInline), + }; + tableElement.children.push(caption); + } + + // Group rows into thead, tbody based on head property + const headerRows: Element[] = []; + const bodyRows: Element[] = []; + + // Start from index 1 to skip caption + for (let i = 1; i < node.children.length; i++) { + const row = node.children[i]; + if (row.tag === "row") { + const rowElement: Element = { + kind: "element", + name: "tr", + attributes: convertAttributes(row.attributes), + children: row.children.map((cell) => { + const cellElement: Element = { + kind: "element", + name: cell.head ? "th" : "td", + attributes: convertAttributes(cell.attributes), + children: cell.children.map(processInline), + }; + + // Set alignment attribute if needed + if (cell.align !== "default") { + cellElement.attributes.set("align", cell.align); + } + + return cellElement; + }), + }; + + if (row.head) { + headerRows.push(rowElement); + } else { + bodyRows.push(rowElement); + } + } + } + + // Add thead and tbody if needed + if (headerRows.length > 0) { + tableElement.children.push({ + kind: "element", + name: "thead", + attributes: new Map(), + children: headerRows, + }); + } + + if (bodyRows.length > 0) { + tableElement.children.push({ + kind: "element", + name: "tbody", + attributes: new Map(), + children: bodyRows, + }); + } + + return tableElement; +} + +function processInline(node: DjotInline): Node { + switch (node.tag) { + case "str": + return processStr(node); + case "soft_break": + return processSoftBreak(node); + case "hard_break": + return processHardBreak(node); + case "verbatim": + return processVerbatim(node); + case "emph": + return processEmph(node); + case "strong": + return processStrong(node); + case "link": + return processLink(node); + case "image": + return processImage(node); + case "mark": + return processMark(node); + case "superscript": + return processSuperscript(node); + case "subscript": + return processSubscript(node); + case "insert": + return processInsert(node); + case "delete": + return processDelete(node); + case "email": + return processEmail(node); + case "footnote_reference": + return processFootnoteReference(node); + case "url": + return processUrl(node); + case "span": + return processSpan(node); + case "inline_math": + return processInlineMath(node); + case "display_math": + return processDisplayMath(node); + case "non_breaking_space": + return processNonBreakingSpace(node); + case "symb": + return processSymb(node); + case "raw_inline": + return processRawInline(node); + case "double_quoted": + return processDoubleQuoted(node); + case "single_quoted": + return processSingleQuoted(node); + case "smart_punctuation": + return processSmartPunctuation(node); + } +} + +function processStr(node: DjotStr): Node { + return { + kind: "text", + content: node.text, + raw: false, + }; +} + +function processSoftBreak(_node: DjotSoftBreak): Node { + return { + kind: "text", + content: "\n", + raw: false, + }; +} + +function processHardBreak(_node: DjotHardBreak): Node { + return { + kind: "element", + name: "br", + attributes: new Map(), + children: [], + }; +} + +function processVerbatim(node: DjotVerbatim): Element { + return { + kind: "element", + name: "code", + attributes: convertAttributes(node.attributes), + children: [ + { + kind: "text", + content: node.text, + raw: false, + }, + ], + }; +} + +function processEmph(node: DjotEmph): Element { + return { + kind: "element", + name: "em", + attributes: convertAttributes(node.attributes), + children: node.children.map(processInline), + }; +} + +function processStrong(node: DjotStrong): Element { + return { + kind: "element", + name: "strong", + attributes: convertAttributes(node.attributes), + children: node.children.map(processInline), + }; +} + +function processLink(node: DjotLink): Element { + const attributes = convertAttributes(node.attributes); + if (node.destination !== undefined) { + attributes.set("href", node.destination); + } + return { + kind: "element", + name: "a", + attributes, + children: node.children.map(processInline), + }; +} + +function processImage(node: DjotImage): Element { + const attributes = convertAttributes(node.attributes); + if (node.destination !== undefined) { + attributes.set("src", node.destination); + } + + // Alt text is derived from children in Djot + const alt = node.children + .map((child) => { + if (child.tag === "str") { + return child.text; + } + return ""; + }) + .join(""); + + if (alt) { + attributes.set("alt", alt); + } + + return { + kind: "element", + name: "img", + attributes, + children: [], + }; +} + +function processMark(node: DjotMark): Element { + return { + kind: "element", + name: "mark", + attributes: convertAttributes(node.attributes), + children: node.children.map(processInline), + }; +} + +function processSuperscript(node: DjotSuperscript): Element { + return { + kind: "element", + name: "sup", + attributes: convertAttributes(node.attributes), + children: node.children.map(processInline), + }; +} + +function processSubscript(node: DjotSubscript): Element { + return { + kind: "element", + name: "sub", + attributes: convertAttributes(node.attributes), + children: node.children.map(processInline), + }; +} + +function processInsert(node: DjotInsert): Element { + return { + kind: "element", + name: "ins", + attributes: convertAttributes(node.attributes), + children: node.children.map(processInline), + }; +} + +function processDelete(node: DjotDelete): Element { + return { + kind: "element", + name: "del", + attributes: convertAttributes(node.attributes), + children: node.children.map(processInline), + }; +} + +function processEmail(node: DjotEmail): Element { + return { + kind: "element", + name: "email", + attributes: convertAttributes(node.attributes), + children: [ + { + kind: "text", + content: node.text, + raw: false, + }, + ], + }; +} + +function processFootnoteReference(node: DjotFootnoteReference): Element { + return { + kind: "element", + name: "footnoteref", + attributes: new Map([["reference", node.text]]), + children: [], + }; +} + +function processUrl(node: DjotUrl): Element { + return { + kind: "element", + name: "a", + attributes: new Map([ + ["href", node.text], + ...Object.entries(node.attributes || {}), + ]), + children: [ + { + kind: "text", + content: node.text, + raw: false, + }, + ], + }; +} + +function processSpan(node: DjotSpan): Element { + return { + kind: "element", + name: "span", + attributes: convertAttributes(node.attributes), + children: node.children.map(processInline), + }; +} + +function processInlineMath(node: DjotInlineMath): Element { + // For inline math, we'll wrap it in a span with a class + return { + kind: "element", + name: "span", + attributes: new Map([ + ["class", "math inline"], + ...Object.entries(node.attributes || {}), + ]), + children: [ + { + kind: "text", + content: node.text, + raw: false, + }, + ], + }; +} + +function processDisplayMath(node: DjotDisplayMath): Element { + // For display math, we'll wrap it in a div with a class + return { + kind: "element", + name: "div", + attributes: new Map([ + ["class", "math display"], + ...Object.entries(node.attributes || {}), + ]), + children: [ + { + kind: "text", + content: node.text, + raw: false, + }, + ], + }; +} + +function processNonBreakingSpace(_node: DjotNonBreakingSpace): Node { + return { + kind: "text", + content: "\u00A0", // Unicode non-breaking space + raw: false, + }; +} + +function processSymb(node: DjotSymb): Node { + // Map symbol aliases to their Unicode characters + const symbolMap: Record<string, string> = { + "->": "→", + "<-": "←", + "<->": "↔", + "=>": "⇒", + "<=": "⇐", + "<=>": "⇔", + "--": "–", // en dash + "---": "—", // em dash + "...": "…", // ellipsis + // Add more symbol mappings as needed + }; + + const symbolText = symbolMap[node.alias] || node.alias; + + return { + kind: "text", + content: symbolText, + raw: false, + }; +} + +function processRawInline(node: DjotRawInline): Node { + // If the format is HTML, return as raw HTML + if (node.format === "html" || node.format === "HTML") { + return { + kind: "text", + content: node.text, + raw: true, + }; + } + + // For other formats, just return as text + return { + kind: "text", + content: node.text, + raw: false, + }; +} + +function processDoubleQuoted(node: DjotDoubleQuoted): Node { + const children = node.children.map(processInline); + const attributes = convertAttributes(node.attributes); + + if ( + children.length === 1 && children[0].kind === "text" && + attributes.size === 0 + ) { + const content = children[0].content; + return { + kind: "text", + content: `\u201C${content}\u201D`, + raw: false, + }; + } else { + return { + kind: "element", + name: "span", + attributes: convertAttributes(node.attributes), + children, + }; + } +} + +function processSingleQuoted(node: DjotSingleQuoted): Node { + const children = node.children.map(processInline); + const attributes = convertAttributes(node.attributes); + + if ( + children.length === 1 && children[0].kind === "text" && + attributes.size === 0 + ) { + const content = children[0].content; + return { + kind: "text", + content: `\u2018${content}\u2019`, + raw: false, + }; + } else { + return { + kind: "element", + name: "span", + attributes: convertAttributes(node.attributes), + children, + }; + } +} + +function processSmartPunctuation(node: DjotSmartPunctuation): Node { + // Map smart punctuation types to Unicode characters + const punctuationMap: Record<string, string> = { + "left_single_quote": "\u2018", // ' + "right_single_quote": "\u2019", // ' + "left_double_quote": "\u201C", // " + "right_double_quote": "\u201D", // " + "ellipses": "\u2026", // … + "em_dash": "\u2014", // — + "en_dash": "\u2013", // – + }; + + return { + kind: "text", + content: punctuationMap[node.type] || node.text, + raw: false, + }; +} + +function processDiv(node: DjotDiv): Element { + if (node.attributes?.class === "note") { + delete node.attributes.class; + return { + kind: "element", + name: "note", + attributes: convertAttributes(node.attributes), + children: node.children.map(processBlock), + }; + } + + if (node.attributes?.class === "edit") { + delete node.attributes.class; + return { + kind: "element", + name: "note", + attributes: convertAttributes(node.attributes), + children: node.children.map(processBlock), + }; + } + + return { + kind: "element", + name: "div", + attributes: convertAttributes(node.attributes), + children: node.children.map(processBlock), + }; +} + +function processRawBlock(node: DjotRawBlock): Element { + // If the format is HTML, wrap the HTML content in a div + if (node.format === "html" || node.format === "HTML") { + return { + kind: "element", + name: "div", + attributes: new Map([["class", "raw-html"]]), + children: [ + { + kind: "text", + content: node.text, + raw: true, + }, + ], + }; + } + + // For other formats, wrap in a pre tag + return { + kind: "element", + name: "pre", + attributes: new Map([["data-format", node.format]]), + children: [ + { + kind: "text", + content: node.text, + raw: false, + }, + ], + }; +} + +// Helper function to convert Djot attributes to Nuldoc attributes +function convertAttributes( + attrs?: Record<string, string>, +): Map<string, string> { + const result = new Map<string, string>(); + if (attrs) { + for (const [key, value] of Object.entries(attrs)) { + result.set(key, value); + } + } + return result; +} + +export function djot2ndoc(doc: DjotDoc): Element { + const children: Node[] = []; + for (const child of doc.children) { + children.push(processBlock(child)); + } + + // Process footnotes if any exist + if (doc.footnotes && Object.keys(doc.footnotes).length > 0) { + const footnoteSection: Element = { + kind: "element", + name: "section", + attributes: new Map([["class", "footnotes"]]), + children: [], + }; + + for (const [id, footnote] of Object.entries(doc.footnotes)) { + const footnoteElement: Element = { + kind: "element", + name: "footnote", + attributes: new Map([["id", id]]), + children: footnote.children.map(processBlock), + }; + footnoteSection.children.push(footnoteElement); + } + + children.push(footnoteSection); + } + + return { + kind: "element", + name: "__root__", + attributes: new Map(), + children: [{ + kind: "element", + name: "article", + attributes: new Map(), + children, + }], + }; +} diff --git a/services/blog/nuldoc-src/djot/document.ts b/services/blog/nuldoc-src/djot/document.ts new file mode 100644 index 00000000..be9c08d5 --- /dev/null +++ b/services/blog/nuldoc-src/djot/document.ts @@ -0,0 +1,60 @@ +import { Doc as DjotDoc } from "@djot/djot"; +import { join } from "@std/path"; +import { z } from "zod/mod.ts"; +import { Config } from "../config.ts"; +import { Element } from "../dom.ts"; +import { Revision, stringToDate } from "../revision.ts"; +import { djot2ndoc } from "./djot2ndoc.ts"; + +export const PostMetadataSchema = z.object({ + article: z.object({ + uuid: z.string(), + title: z.string(), + description: z.string(), + tags: z.array(z.string()), + revisions: z.array(z.object({ + date: z.string(), + remark: z.string(), + isInternal: z.boolean().optional(), + })), + }), +}); + +export type PostMetadata = z.infer<typeof PostMetadataSchema>; + +export type Document = { + root: Element; + sourceFilePath: string; + uuid: string; + link: string; + title: string; + description: string; // TODO: should it be markup text? + tags: string[]; + revisions: Revision[]; +}; + +export function createNewDocumentFromDjotDocument( + root: DjotDoc, + meta: PostMetadata, + sourceFilePath: string, + config: Config, +): Document { + const cwd = Deno.cwd(); + const contentDir = join(cwd, config.locations.contentDir); + const link = sourceFilePath.replace(contentDir, "").replace(".xml", "/"); + return { + root: djot2ndoc(root), + sourceFilePath, + uuid: meta.article.uuid, + link: link, + title: meta.article.title, + description: meta.article.description, + tags: meta.article.tags, + revisions: meta.article.revisions.map((r, i) => ({ + number: i, + date: stringToDate(r.date), + remark: r.remark, + isInternal: !!r.isInternal, + })), + }; +} diff --git a/services/blog/nuldoc-src/djot/parse.ts b/services/blog/nuldoc-src/djot/parse.ts new file mode 100644 index 00000000..c79a6708 --- /dev/null +++ b/services/blog/nuldoc-src/djot/parse.ts @@ -0,0 +1,33 @@ +import { parse as parseDjot } from "@djot/djot"; +import { parse as parseToml } from "@std/toml"; +import { Config } from "../config.ts"; +import { + createNewDocumentFromDjotDocument, + Document, + PostMetadata, + PostMetadataSchema, +} from "./document.ts"; +import toHtml from "./to_html.ts"; + +export async function parseDjotFile( + filePath: string, + config: Config, +): Promise<Document> { + try { + const fileContent = await Deno.readTextFile(filePath); + const [, frontmatter, ...rest] = fileContent.split(/^---$/m); + const meta = parseMetadata(frontmatter); + const root = parseDjot(rest.join("\n")); + const doc = createNewDocumentFromDjotDocument(root, meta, filePath, config); + return await toHtml(doc); + } catch (e) { + if (e instanceof Error) { + e.message = `${e.message} in ${filePath}`; + } + throw e; + } +} + +function parseMetadata(s: string): PostMetadata { + return PostMetadataSchema.parse(parseToml(s)); +} diff --git a/services/blog/nuldoc-src/djot/to_html.ts b/services/blog/nuldoc-src/djot/to_html.ts new file mode 100644 index 00000000..5ea9b57d --- /dev/null +++ b/services/blog/nuldoc-src/djot/to_html.ts @@ -0,0 +1,449 @@ +import { BundledLanguage, bundledLanguages, codeToHtml } from "shiki"; +import { Document } from "./document.ts"; +import { NuldocError } from "../errors.ts"; +import { + addClass, + Element, + forEachChild, + forEachChildRecursively, + forEachChildRecursivelyAsync, + Node, + RawHTML, + Text, +} from "../dom.ts"; + +export default async function toHtml(doc: Document): Promise<Document> { + mergeConsecutiveTextNodes(doc); + removeUnnecessaryTextNode(doc); + transformLinkLikeToAnchorElement(doc); + transformSectionIdAttribute(doc); + setSectionTitleAnchor(doc); + transformSectionTitleElement(doc); + transformNoteElement(doc); + addAttributesToExternalLinkElement(doc); + traverseFootnotes(doc); + removeUnnecessaryParagraphNode(doc); + await transformAndHighlightCodeBlockElement(doc); + mergeConsecutiveTextNodes(doc); + return doc; +} + +function mergeConsecutiveTextNodes(doc: Document) { + forEachChildRecursively(doc.root, (n) => { + if (n.kind !== "element") { + return; + } + + const newChildren: Node[] = []; + let currentTextContent = ""; + + for (const child of n.children) { + if (child.kind === "text" && !child.raw) { + currentTextContent += child.content; + } else { + if (currentTextContent !== "") { + newChildren.push({ + kind: "text", + content: currentTextContent, + raw: false, + }); + currentTextContent = ""; + } + newChildren.push(child); + } + } + + if (currentTextContent !== "") { + newChildren.push({ + kind: "text", + content: currentTextContent, + raw: false, + }); + } + + n.children = newChildren; + }); +} + +function removeUnnecessaryTextNode(doc: Document) { + forEachChildRecursively(doc.root, (n) => { + if (n.kind !== "element") { + return; + } + + let changed = true; + while (changed) { + changed = false; + if (n.children.length === 0) { + break; + } + const firstChild = n.children[0]; + if (firstChild.kind === "text" && firstChild.content.trim() === "") { + n.children.shift(); + changed = true; + } + if (n.children.length === 0) { + break; + } + const lastChild = n.children[n.children.length - 1]; + if (lastChild.kind === "text" && lastChild.content.trim() === "") { + n.children.pop(); + changed = true; + } + } + }); +} + +function transformLinkLikeToAnchorElement(doc: Document) { + forEachChildRecursively(doc.root, (n) => { + if ( + n.kind !== "element" || n.name === "a" || n.name === "code" || + n.name === "codeblock" + ) { + return; + } + + const newChildren: Node[] = []; + for (const child of n.children) { + if (child.kind !== "text") { + newChildren.push(child); + continue; + } + let restContent = child.content; + while (restContent !== "") { + const match = /^(.*?)(https?:\/\/[^ \n]+)(.*)$/s.exec(restContent); + if (!match) { + newChildren.push({ kind: "text", content: restContent, raw: false }); + restContent = ""; + break; + } + const [_, prefix, url, suffix] = match; + newChildren.push({ kind: "text", content: prefix, raw: false }); + newChildren.push({ + kind: "element", + name: "a", + attributes: new Map([["href", url]]), + children: [{ kind: "text", content: url, raw: false }], + }); + restContent = suffix; + } + } + n.children = newChildren; + }); +} + +function transformSectionIdAttribute(doc: Document) { + const sectionStack: string[] = []; + const usedIds = new Set<string>(); + + const processNode = (n: Node) => { + if (n.kind !== "element") { + return; + } + + if (n.name === "section") { + const idAttr = n.attributes.get("id"); + if (!idAttr) { + return; + } + + let newId: string; + if (sectionStack.length === 0) { + newId = `section--${idAttr}`; + } else { + newId = `section--${sectionStack.join("--")}--${idAttr}`; + } + + if (usedIds.has(newId)) { + throw new NuldocError( + `[nuldoc.tohtml] Duplicate section ID: ${newId}`, + ); + } + + usedIds.add(newId); + n.attributes.set("id", newId); + sectionStack.push(idAttr); + + forEachChild(n, processNode); + + sectionStack.pop(); + } else { + forEachChild(n, processNode); + } + }; + + forEachChild(doc.root, processNode); +} + +function setSectionTitleAnchor(doc: Document) { + const sectionStack: Element[] = []; + const g = (c: Node) => { + if (c.kind !== "element") { + return; + } + + if (c.name === "section") { + sectionStack.push(c); + } + forEachChild(c, g); + if (c.name === "section") { + sectionStack.pop(); + } + if (c.name === "h") { + const currentSection = sectionStack[sectionStack.length - 1]; + if (!currentSection) { + throw new NuldocError( + "[nuldoc.tohtml] <h> element must be inside <section>", + ); + } + const sectionId = currentSection.attributes.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 === "h") { + c.name = `h${sectionLevel}`; + } + }; + forEachChild(doc.root, g); +} + +function transformNoteElement(doc: Document) { + forEachChildRecursively(doc.root, (n) => { + if (n.kind !== "element" || n.name !== "note") { + return; + } + + const editatAttr = n.attributes?.get("editat"); + const operationAttr = n.attributes?.get("operation"); + const isEditBlock = editatAttr && operationAttr; + + const labelElement: Element = { + kind: "element", + name: "div", + attributes: new Map([["class", "admonition-label"]]), + children: [{ + kind: "text", + content: isEditBlock ? `${editatAttr} ${operationAttr}` : "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 addAttributesToExternalLinkElement(doc: Document) { + forEachChildRecursively(doc.root, (n) => { + if (n.kind !== "element" || n.name !== "a") { + return; + } + + const href = n.attributes.get("href") ?? ""; + if (!href.startsWith("http")) { + return; + } + n.attributes + .set("target", "_blank") + .set("rel", "noreferrer"); + }); +} + +function traverseFootnotes(doc: Document) { + let footnoteCounter = 0; + const footnoteMap = new Map<string, number>(); + + forEachChildRecursively(doc.root, (n) => { + if (n.kind !== "element" || n.name !== "footnoteref") { + return; + } + + const reference = n.attributes.get("reference"); + if (!reference) { + return; + } + + let footnoteNumber: number; + if (footnoteMap.has(reference)) { + footnoteNumber = footnoteMap.get(reference)!; + } else { + footnoteNumber = ++footnoteCounter; + footnoteMap.set(reference, footnoteNumber); + } + + n.name = "sup"; + n.attributes.delete("reference"); + n.attributes.set("class", "footnote"); + n.children = [ + { + kind: "element", + name: "a", + attributes: new Map([ + ["id", `footnoteref--${reference}`], + ["class", "footnote"], + ["href", `#footnote--${reference}`], + ]), + children: [ + { + kind: "text", + content: `[${footnoteNumber}]`, + raw: false, + }, + ], + }, + ]; + }); + + forEachChildRecursively(doc.root, (n) => { + if (n.kind !== "element" || n.name !== "footnote") { + return; + } + + const id = n.attributes.get("id"); + if (!id || !footnoteMap.has(id)) { + n.name = "span"; + n.children = []; + return; + } + + const footnoteNumber = footnoteMap.get(id)!; + + n.name = "div"; + n.attributes.delete("id"); + n.attributes.set("class", "footnote"); + n.attributes.set("id", `footnote--${id}`); + + n.children = [ + { + kind: "element", + name: "a", + attributes: new Map([["href", `#footnoteref--${id}`]]), + children: [ + { + kind: "text", + content: `${footnoteNumber}. `, + raw: false, + }, + ], + }, + ...n.children, + ]; + }); +} + +function removeUnnecessaryParagraphNode(doc: Document) { + forEachChildRecursively(doc.root, (n) => { + if (n.kind !== "element" || (n.name !== "ul" && n.name !== "ol")) { + return; + } + + const isTight = n.attributes.get("--tight") === "true"; + if (!isTight) { + return; + } + + for (const child of n.children) { + if (child.kind !== "element" || child.name !== "li") { + continue; + } + const newGrandChildren: Node[] = []; + for (const grandChild of child.children) { + if (grandChild.kind === "element" && grandChild.name === "p") { + newGrandChildren.push(...grandChild.children); + } else { + newGrandChildren.push(grandChild); + } + } + child.children = newGrandChildren; + } + }); +} + +async function transformAndHighlightCodeBlockElement(doc: Document) { + await forEachChildRecursivelyAsync(doc.root, async (n) => { + if (n.kind !== "element" || n.name !== "codeblock") { + return; + } + + const language = n.attributes.get("language") || "text"; + const filename = n.attributes.get("filename"); + const numbered = n.attributes.get("numbered"); + const sourceCodeNode = n.children[0] as Text | RawHTML; + const sourceCode = sourceCodeNode.content.trimEnd(); + + const highlighted = await codeToHtml(sourceCode, { + lang: language in bundledLanguages ? language as BundledLanguage : "text", + theme: "github-light", + colorReplacements: { + "#fff": "#f5f5f5", + }, + }); + + n.name = "div"; + n.attributes.set("class", "codeblock"); + n.attributes.delete("language"); + + if (numbered === "true") { + n.attributes.delete("numbered"); + addClass(n, "numbered"); + } + if (filename) { + n.attributes.delete("filename"); + + n.children = [ + { + kind: "element", + name: "div", + attributes: new Map([["class", "filename"]]), + children: [{ + kind: "text", + content: filename, + raw: false, + }], + }, + { + kind: "text", + content: highlighted, + raw: true, + }, + ]; + } else { + sourceCodeNode.content = highlighted; + sourceCodeNode.raw = true; + } + }); +} diff --git a/services/blog/nuldoc-src/dom.ts b/services/blog/nuldoc-src/dom.ts new file mode 100644 index 00000000..ed7ffd31 --- /dev/null +++ b/services/blog/nuldoc-src/dom.ts @@ -0,0 +1,102 @@ +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 innerText(e: Element): string { + let t = ""; + forEachChild(e, (c) => { + if (c.kind === "text") { + t += c.content; + } + }); + return t; +} + +export function forEachChild(e: Element, f: (n: Node) => void) { + for (const c of e.children) { + f(c); + } +} + +export async function forEachChildAsync( + e: Element, + f: (n: Node) => Promise<void>, +): Promise<void> { + for (const c of e.children) { + await f(c); + } +} + +export function forEachChildRecursively(e: Element, f: (n: Node) => void) { + const g = (c: Node) => { + f(c); + if (c.kind === "element") { + forEachChild(c, g); + } + }; + forEachChild(e, g); +} + +export async function forEachChildRecursivelyAsync( + e: Element, + f: (n: Node) => Promise<void>, +): Promise<void> { + const g = async (c: Node) => { + await f(c); + if (c.kind === "element") { + await forEachChildAsync(c, g); + } + }; + await forEachChildAsync(e, g); +} diff --git a/services/blog/nuldoc-src/errors.ts b/services/blog/nuldoc-src/errors.ts new file mode 100644 index 00000000..1692a4c8 --- /dev/null +++ b/services/blog/nuldoc-src/errors.ts @@ -0,0 +1,17 @@ +export class NuldocError extends Error { + static { + this.prototype.name = "NuldocError"; + } +} + +export class SlideError extends Error { + static { + this.prototype.name = "SlideError"; + } +} + +export class XmlParseError extends Error { + static { + this.prototype.name = "XmlParseError"; + } +} diff --git a/services/blog/nuldoc-src/generators/about.ts b/services/blog/nuldoc-src/generators/about.ts new file mode 100644 index 00000000..6663a190 --- /dev/null +++ b/services/blog/nuldoc-src/generators/about.ts @@ -0,0 +1,23 @@ +import AboutPage from "../pages/AboutPage.tsx"; +import { Config } from "../config.ts"; +import { renderToDOM } from "../jsx/render.ts"; +import { Page } from "../page.ts"; +import { SlidePage } from "./slide.ts"; + +export type AboutPage = Page; + +export async function generateAboutPage( + slides: SlidePage[], + config: Config, +): Promise<AboutPage> { + const html = await renderToDOM( + AboutPage(slides, config), + ); + + return { + root: html, + renderer: "html", + destFilePath: "/about/index.html", + href: "/about/", + }; +} diff --git a/services/blog/nuldoc-src/generators/atom.ts b/services/blog/nuldoc-src/generators/atom.ts new file mode 100644 index 00000000..6ad07b46 --- /dev/null +++ b/services/blog/nuldoc-src/generators/atom.ts @@ -0,0 +1,79 @@ +import { Config } from "../config.ts"; +import { Page } from "../page.ts"; +import { PostPage } from "../generators/post.ts"; +import { SlidePage } from "../generators/slide.ts"; +import { dateToRfc3339String } from "../revision.ts"; +import AtomPage from "../pages/AtomPage.tsx"; +import { renderToDOM } from "../jsx/render.ts"; + +export type Feed = { + author: string; + icon: string; + id: string; + linkToSelf: string; + linkToAlternate: string; + title: string; + updated: string; + entries: Entry[]; +}; + +export type Entry = { + id: string; + linkToAlternate: string; + published: string; + summary: string; + title: string; + updated: string; +}; + +const BASE_NAME = "atom.xml"; + +export async function generateFeedPageFromEntries( + alternateLink: string, + feedSlug: string, + feedTitle: string, + entries: Array<PostPage | SlidePage>, + config: Config, +): Promise<Page> { + const entries_: Entry[] = []; + for (const entry of entries) { + entries_.push({ + id: `urn:uuid:${entry.uuid}`, + linkToAlternate: `https://${config.blog.fqdn}${entry.href}`, + title: entry.title, + summary: entry.description, + published: dateToRfc3339String(entry.published), + updated: dateToRfc3339String(entry.updated), + }); + } + // Sort by published date in ascending order. + entries_.sort((a, b) => { + if (a.published < b.published) { + return 1; + } else if (a.published > b.published) { + return -1; + } + return 0; + }); + const feedPath = `${alternateLink}${BASE_NAME}`; + const feed: Feed = { + author: config.blog.author, + icon: `https://${config.blog.fqdn}/favicon.svg`, + id: `tag:${config.blog.fqdn},${config.blog.siteCopyrightYear}:${feedSlug}`, + linkToSelf: `https://${config.blog.fqdn}${feedPath}`, + linkToAlternate: `https://${config.blog.fqdn}${alternateLink}`, + title: feedTitle, + updated: entries_.reduce( + (latest, entry) => entry.updated > latest ? entry.updated : latest, + entries_[0].updated, + ), + entries: entries_, + }; + + return { + root: await renderToDOM(AtomPage({ feed: feed })), + renderer: "xml", + destFilePath: feedPath, + href: feedPath, + }; +} diff --git a/services/blog/nuldoc-src/generators/home.ts b/services/blog/nuldoc-src/generators/home.ts new file mode 100644 index 00000000..679dd39a --- /dev/null +++ b/services/blog/nuldoc-src/generators/home.ts @@ -0,0 +1,19 @@ +import HomePage from "../pages/HomePage.tsx"; +import { renderToDOM } from "../jsx/render.ts"; +import { Config } from "../config.ts"; +import { Page } from "../page.ts"; + +export type HomePage = Page; + +export async function generateHomePage(config: Config): Promise<HomePage> { + const html = await renderToDOM( + HomePage(config), + ); + + return { + root: html, + renderer: "html", + destFilePath: "/index.html", + href: "/", + }; +} diff --git a/services/blog/nuldoc-src/generators/not_found.ts b/services/blog/nuldoc-src/generators/not_found.ts new file mode 100644 index 00000000..f5a81c86 --- /dev/null +++ b/services/blog/nuldoc-src/generators/not_found.ts @@ -0,0 +1,21 @@ +import NotFoundPage from "../pages/NotFoundPage.tsx"; +import { renderToDOM } from "../jsx/render.ts"; +import { Config } from "../config.ts"; +import { Page } from "../page.ts"; + +export type NotFoundPage = Page; + +export async function generateNotFoundPage( + config: Config, +): Promise<NotFoundPage> { + const html = await renderToDOM( + NotFoundPage(config), + ); + + return { + root: html, + renderer: "html", + destFilePath: "/404.html", + href: "/404.html", + }; +} diff --git a/services/blog/nuldoc-src/generators/post.ts b/services/blog/nuldoc-src/generators/post.ts new file mode 100644 index 00000000..0e2a9553 --- /dev/null +++ b/services/blog/nuldoc-src/generators/post.ts @@ -0,0 +1,63 @@ +import { join } from "@std/path"; +import { renderToDOM } from "../jsx/render.ts"; +import PostPage from "../pages/PostPage.tsx"; +import { Config } from "../config.ts"; +import { Document } from "../djot/document.ts"; +import { Page } from "../page.ts"; +import { Date, Revision } from "../revision.ts"; + +export interface PostPage extends Page { + title: string; + description: string; + tags: string[]; + revisions: Revision[]; + published: Date; + updated: Date; + uuid: string; +} + +export function getPostPublishedDate(page: { revisions: Revision[] }): Date { + for (const rev of page.revisions) { + if (!rev.isInternal) { + return rev.date; + } + } + return page.revisions[0].date; +} + +export function getPostUpdatedDate(page: { revisions: Revision[] }): Date { + return page.revisions[page.revisions.length - 1].date; +} + +export function postHasAnyUpdates(page: { revisions: Revision[] }): boolean { + return 2 <= page.revisions.filter((rev) => !rev.isInternal).length; +} + +export async function generatePostPage( + doc: Document, + config: Config, +): Promise<PostPage> { + const html = await renderToDOM( + PostPage(doc, config), + ); + + const cwd = Deno.cwd(); + const contentDir = join(cwd, config.locations.contentDir); + const destFilePath = join( + doc.sourceFilePath.replace(contentDir, "").replace(".dj", ""), + "index.html", + ); + return { + root: html, + renderer: "html", + destFilePath: destFilePath, + href: destFilePath.replace("index.html", ""), + title: doc.title, + description: doc.description, + tags: doc.tags, + revisions: doc.revisions, + published: getPostPublishedDate(doc), + updated: getPostUpdatedDate(doc), + uuid: doc.uuid, + }; +} diff --git a/services/blog/nuldoc-src/generators/post_list.ts b/services/blog/nuldoc-src/generators/post_list.ts new file mode 100644 index 00000000..b05f7ee6 --- /dev/null +++ b/services/blog/nuldoc-src/generators/post_list.ts @@ -0,0 +1,63 @@ +import { renderToDOM } from "../jsx/render.ts"; +import PostListPage from "../pages/PostListPage.tsx"; +import { Config } from "../config.ts"; +import { Page } from "../page.ts"; +import { PostPage } from "./post.ts"; + +export type PostListPage = Page; + +export async function generatePostListPages( + posts: PostPage[], + config: Config, +): Promise<PostListPage[]> { + const postsPerPage = config.blog.postsPerPage; + const totalPages = Math.ceil(posts.length / postsPerPage); + const pages: PostListPage[] = []; + + for (let pageIndex = 0; pageIndex < totalPages; pageIndex++) { + const pagePosts = posts.slice( + pageIndex * postsPerPage, + (pageIndex + 1) * postsPerPage, + ); + + const page = await generatePostListPage( + pagePosts, + config, + pageIndex + 1, + totalPages, + ); + + pages.push(page); + } + + return pages; +} + +async function generatePostListPage( + posts: PostPage[], + config: Config, + currentPage: number, + totalPages: number, +): Promise<PostListPage> { + const html = await renderToDOM( + PostListPage( + posts, + config, + currentPage, + totalPages, + ), + ); + + const destFilePath = currentPage === 1 + ? "/posts/index.html" + : `/posts/${currentPage}/index.html`; + + const href = currentPage === 1 ? "/posts/" : `/posts/${currentPage}/`; + + return { + root: html, + renderer: "html", + destFilePath, + href, + }; +} diff --git a/services/blog/nuldoc-src/generators/slide.ts b/services/blog/nuldoc-src/generators/slide.ts new file mode 100644 index 00000000..cd28879f --- /dev/null +++ b/services/blog/nuldoc-src/generators/slide.ts @@ -0,0 +1,53 @@ +import { join } from "@std/path"; +import { renderToDOM } from "../jsx/render.ts"; +import SlidePage from "../pages/SlidePage.tsx"; +import { Config } from "../config.ts"; +import { Page } from "../page.ts"; +import { Date, Revision } from "../revision.ts"; +import { Slide } from "../slide/slide.ts"; +import { getPostPublishedDate, getPostUpdatedDate } from "./post.ts"; + +export interface SlidePage extends Page { + title: string; + description: string; + event: string; + talkType: string; + slideLink: string; + tags: string[]; + revisions: Revision[]; + published: Date; + updated: Date; + uuid: string; +} + +export async function generateSlidePage( + slide: Slide, + config: Config, +): Promise<SlidePage> { + const html = await renderToDOM( + SlidePage(slide, config), + ); + + const cwd = Deno.cwd(); + const contentDir = join(cwd, config.locations.contentDir); + const destFilePath = join( + slide.sourceFilePath.replace(contentDir, "").replace(".toml", ""), + "index.html", + ); + return { + root: html, + renderer: "html", + destFilePath: destFilePath, + href: destFilePath.replace("index.html", ""), + title: slide.title, + description: `登壇: ${slide.event} (${slide.talkType})`, + event: slide.event, + talkType: slide.talkType, + slideLink: slide.slideLink, + tags: slide.tags, + revisions: slide.revisions, + published: getPostPublishedDate(slide), + updated: getPostUpdatedDate(slide), + uuid: slide.uuid, + }; +} diff --git a/services/blog/nuldoc-src/generators/slide_list.ts b/services/blog/nuldoc-src/generators/slide_list.ts new file mode 100644 index 00000000..abebe109 --- /dev/null +++ b/services/blog/nuldoc-src/generators/slide_list.ts @@ -0,0 +1,23 @@ +import { renderToDOM } from "../jsx/render.ts"; +import SlideListPage from "../pages/SlideListPage.tsx"; +import { Config } from "../config.ts"; +import { Page } from "../page.ts"; +import { SlidePage } from "./slide.ts"; + +export type SlideListPage = Page; + +export async function generateSlideListPage( + slides: SlidePage[], + config: Config, +): Promise<SlideListPage> { + const html = await renderToDOM( + SlideListPage(slides, config), + ); + + return { + root: html, + renderer: "html", + destFilePath: "/slides/index.html", + href: "/slides/", + }; +} diff --git a/services/blog/nuldoc-src/generators/tag.ts b/services/blog/nuldoc-src/generators/tag.ts new file mode 100644 index 00000000..dbd8ef93 --- /dev/null +++ b/services/blog/nuldoc-src/generators/tag.ts @@ -0,0 +1,33 @@ +import { renderToDOM } from "../jsx/render.ts"; +import TagPage from "../pages/TagPage.tsx"; +import { Config, getTagLabel } from "../config.ts"; +import { Page } from "../page.ts"; +import { TaggedPage } from "./tagged_page.ts"; + +export interface TagPage extends Page { + tagSlug: string; + tagLabel: string; + numOfPosts: number; + numOfSlides: number; +} + +export async function generateTagPage( + tagSlug: string, + pages: TaggedPage[], + config: Config, +): Promise<TagPage> { + const html = await renderToDOM( + TagPage(tagSlug, pages, config), + ); + + return { + root: html, + renderer: "html", + destFilePath: `/tags/${tagSlug}/index.html`, + href: `/tags/${tagSlug}/`, + tagSlug: tagSlug, + tagLabel: getTagLabel(config, tagSlug), + numOfPosts: pages.filter((p) => !("event" in p)).length, + numOfSlides: pages.filter((p) => "event" in p).length, + }; +} diff --git a/services/blog/nuldoc-src/generators/tag_list.ts b/services/blog/nuldoc-src/generators/tag_list.ts new file mode 100644 index 00000000..7baad8cf --- /dev/null +++ b/services/blog/nuldoc-src/generators/tag_list.ts @@ -0,0 +1,23 @@ +import { renderToDOM } from "../jsx/render.ts"; +import TagListPage from "../pages/TagListPage.tsx"; +import { Config } from "../config.ts"; +import { Page } from "../page.ts"; +import { TagPage } from "./tag.ts"; + +export type TagListPage = Page; + +export async function generateTagListPage( + tags: TagPage[], + config: Config, +): Promise<TagListPage> { + const html = await renderToDOM( + TagListPage(tags, config), + ); + + return { + root: html, + renderer: "html", + destFilePath: "/tags/index.html", + href: "/tags/", + }; +} diff --git a/services/blog/nuldoc-src/generators/tagged_page.ts b/services/blog/nuldoc-src/generators/tagged_page.ts new file mode 100644 index 00000000..23de8cb4 --- /dev/null +++ b/services/blog/nuldoc-src/generators/tagged_page.ts @@ -0,0 +1,4 @@ +import { PostPage } from "./post.ts"; +import { SlidePage } from "./slide.ts"; + +export type TaggedPage = PostPage | SlidePage; diff --git a/services/blog/nuldoc-src/jsx/jsx-runtime.ts b/services/blog/nuldoc-src/jsx/jsx-runtime.ts new file mode 100644 index 00000000..9571e87d --- /dev/null +++ b/services/blog/nuldoc-src/jsx/jsx-runtime.ts @@ -0,0 +1,27 @@ +import type { Node } from "../dom.ts"; + +export type JSXElement = { + tag: string | FunctionComponent; + props: Props; +}; + +export type JSXNullNode = false | null | undefined; +export type JSXSimpleNode = JSXElement | Node | string; +export type JSXNullableSimpleNode = JSXSimpleNode | JSXNullNode; +export type JSXNode = JSXNullableSimpleNode | JSXNode[]; +export type RenderableJSXNode = JSXElement; + +type Props = { children?: JSXNode } & Record<string, unknown>; +export type FunctionComponentResult = JSXElement | Promise<JSXElement>; +type FunctionComponent = (props: Props) => FunctionComponentResult; + +export function jsx( + tag: string | FunctionComponent, + props: Props, +): JSXElement { + return { tag, props }; +} + +export { jsx as jsxs }; + +// TODO: support Fragment diff --git a/services/blog/nuldoc-src/jsx/render.ts b/services/blog/nuldoc-src/jsx/render.ts new file mode 100644 index 00000000..8603f6c3 --- /dev/null +++ b/services/blog/nuldoc-src/jsx/render.ts @@ -0,0 +1,45 @@ +import type { Element, Node } from "../dom.ts"; +import type { + JSXNode, + JSXNullableSimpleNode, + JSXSimpleNode, + RenderableJSXNode, +} from "myjsx/jsx-runtime"; + +function transformNode(node: JSXNode): Promise<Node[]> { + const flattenNodes: JSXNullableSimpleNode[] = Array.isArray(node) + // @ts-ignore prevents infinite recursion + ? (node.flat(Infinity) as JSXNullableSimpleNode[]) + : [node]; + return Promise.all( + flattenNodes + .filter((c): c is JSXSimpleNode => c != null && c !== false) + .map((c) => { + if (typeof c === "string") { + return { kind: "text", content: c, raw: false }; + } else if ("kind" in c) { + return c; + } else { + return renderToDOM(c); + } + }), + ); +} + +export async function renderToDOM( + element: RenderableJSXNode, +): Promise<Element> { + const { tag, props } = element; + if (typeof tag === "string") { + const { children, ...attrs } = props; + const attrsMap = new Map(Object.entries(attrs)) as Map<string, string>; + return { + kind: "element", + name: tag, + attributes: attrsMap, + children: await transformNode(children), + }; + } else { + return renderToDOM(await tag(props)); + } +} diff --git a/services/blog/nuldoc-src/jsx/types.d.ts b/services/blog/nuldoc-src/jsx/types.d.ts new file mode 100644 index 00000000..0e5b223f --- /dev/null +++ b/services/blog/nuldoc-src/jsx/types.d.ts @@ -0,0 +1,83 @@ +import type { + FunctionComponentResult, + JSXElement, + JSXNode, +} from "myjsx/jsx-runtime"; + +export { JSXNode }; + +interface IntrinsicElementType { + children?: JSXNode; + className?: string; + id?: string; + // My JSX runtime does not use key. It is only for linter that complains about missing key. + key?: string; +} + +declare global { + namespace JSX { + type Element = JSXElement; + type ElementType = + | string + // deno-lint-ignore no-explicit-any + | ((props: any) => FunctionComponentResult); + + // TODO: HTML 用の element と XML 用の element を分ける + interface IntrinsicElements { + // XML (Atom) + author: IntrinsicElementType; + entry: IntrinsicElementType; + feed: IntrinsicElementType & { xmlns: string }; + id: IntrinsicElementType; + name: IntrinsicElementType; + published: IntrinsicElementType; + summary: IntrinsicElementType; + updated: IntrinsicElementType; + // HTML + a: IntrinsicElementType & { + href?: string; + rel?: "noreferrer"; + target?: "_blank"; + }; + article: IntrinsicElementType; + body: IntrinsicElementType; + button: IntrinsicElementType & { type: string }; + canvas: { id?: string; "data-slide-link"?: string }; + div: IntrinsicElementType; + footer: IntrinsicElementType; + h1: IntrinsicElementType; + h2: IntrinsicElementType; + head: unknown; + header: IntrinsicElementType; + html: IntrinsicElementType & { lang?: string }; + img: { src: string }; + li: IntrinsicElementType; + link: { rel: string; href: string; type?: string }; + main: IntrinsicElementType; + meta: { + charset?: string; + name?: string; + content?: string; + property?: string; + }; + nav: IntrinsicElementType; + noscript: IntrinsicElementType; + ol: IntrinsicElementType; + p: IntrinsicElementType; + script: { src: string; type?: string; defer?: "true" }; + section: IntrinsicElementType; + time: IntrinsicElementType & { datetime?: string }; + title: IntrinsicElementType; + ul: IntrinsicElementType; + } + + interface ElementChildrenAttribute { + children: unknown; + } + + type LibraryManagedAttributes<_F, P> = P & { + // My JSX runtime does not use key. It is only for linter that complains about missing key. + key?: string; + }; + } +} diff --git a/services/blog/nuldoc-src/main.ts b/services/blog/nuldoc-src/main.ts new file mode 100644 index 00000000..af6acc2e --- /dev/null +++ b/services/blog/nuldoc-src/main.ts @@ -0,0 +1,19 @@ +import { runBuildCommand } from "./commands/build.ts"; +import { runNewCommand } from "./commands/new.ts"; +import { runServeCommand } from "./commands/serve.ts"; +import { getDefaultConfigPath, loadConfig } from "./config.ts"; + +const config = await loadConfig(getDefaultConfigPath()); + +if (import.meta.main) { + const command = Deno.args[0] ?? "build"; + if (command === "build") { + await runBuildCommand(config); + } else if (command === "new") { + runNewCommand(config); + } else if (command === "serve") { + runServeCommand(config); + } else { + console.error(`Unknown command: ${command}`); + } +} diff --git a/services/blog/nuldoc-src/page.ts b/services/blog/nuldoc-src/page.ts new file mode 100644 index 00000000..f4a6166b --- /dev/null +++ b/services/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/services/blog/nuldoc-src/pages/AboutPage.tsx b/services/blog/nuldoc-src/pages/AboutPage.tsx new file mode 100644 index 00000000..3d6583a4 --- /dev/null +++ b/services/blog/nuldoc-src/pages/AboutPage.tsx @@ -0,0 +1,110 @@ +import GlobalFooter from "../components/GlobalFooter.tsx"; +import GlobalHeader from "../components/GlobalHeader.tsx"; +import PageLayout from "../components/PageLayout.tsx"; +import StaticScript from "../components/StaticScript.tsx"; +import { Config } from "../config.ts"; +import { dateToString } from "../revision.ts"; +import { getPostPublishedDate } from "../generators/post.ts"; +import { SlidePage } from "../generators/slide.ts"; + +export default function AboutPage( + slides: SlidePage[], + config: Config, +) { + return ( + <PageLayout + metaCopyrightYear={config.blog.siteCopyrightYear} + metaDescription="このサイトの著者について" + metaTitle={`About|${config.blog.siteName}`} + config={config} + > + <body className="single"> + <GlobalHeader config={config} /> + <main className="main"> + <article className="post-single"> + <header className="post-header"> + <h1 className="post-title">nsfisis</h1> + <div className="my-icon"> + <div id="myIcon"> + <img src="/favicon.svg" /> + </div> + <StaticScript + fileName="/my-icon.js" + defer="true" + config={config} + /> + </div> + </header> + <div className="post-content"> + <section> + <h2>読み方</h2> + <p> + 読み方は決めていません。音にする必要があるときは本名である「いまむら」をお使いください。 + </p> + </section> + <section> + <h2>アカウント</h2> + <ul> + <li> + <a + href="https://twitter.com/nsfisis" + target="_blank" + rel="noreferrer" + > + Twitter (現 𝕏): @nsfisis + </a> + </li> + <li> + <a + href="https://github.com/nsfisis" + target="_blank" + rel="noreferrer" + > + GitHub: @nsfisis + </a> + </li> + </ul> + </section> + <section> + <h2>仕事</h2> + <ul> + <li> + {"2021-01~現在: "} + <a + href="https://www.dgcircus.com/" + target="_blank" + rel="noreferrer" + > + デジタルサーカス株式会社 + </a> + </li> + </ul> + </section> + <section> + <h2>登壇</h2> + <ul> + {Array.from(slides).sort((a, b) => { + const ta = dateToString(getPostPublishedDate(a)); + const tb = dateToString(getPostPublishedDate(b)); + if (ta > tb) return -1; + if (ta < tb) return 1; + return 0; + }).map((slide) => ( + <li> + <a href={slide.href}> + {`${ + dateToString(getPostPublishedDate(slide)) + }: ${slide.event} (${slide.talkType})`} + </a> + </li> + ))} + </ul> + </section> + </div> + </article> + </main> + <GlobalFooter config={config} /> + </body> + </PageLayout> + ); +} diff --git a/services/blog/nuldoc-src/pages/AtomPage.tsx b/services/blog/nuldoc-src/pages/AtomPage.tsx new file mode 100644 index 00000000..21c3bfaf --- /dev/null +++ b/services/blog/nuldoc-src/pages/AtomPage.tsx @@ -0,0 +1,26 @@ +import { Feed } from "../generators/atom.ts"; + +export default function AtomPage({ feed }: { feed: Feed }) { + return ( + <feed xmlns="http://www.w3.org/2005/Atom"> + <id>{feed.id}</id> + <title>{feed.title}</title> + <link rel="alternate" href={feed.linkToAlternate} /> + <link rel="self" href={feed.linkToSelf} /> + <author> + <name>{feed.author}</name> + </author> + <updated>{feed.updated}</updated> + {feed.entries.map((entry) => ( + <entry> + <id>{entry.id}</id> + <link rel="alternate" href={entry.linkToAlternate} /> + <title>{entry.title}</title> + <summary>{entry.summary}</summary> + <published>{entry.published}</published> + <updated>{entry.updated}</updated> + </entry> + ))} + </feed> + ); +} diff --git a/services/blog/nuldoc-src/pages/HomePage.tsx b/services/blog/nuldoc-src/pages/HomePage.tsx new file mode 100644 index 00000000..8850d039 --- /dev/null +++ b/services/blog/nuldoc-src/pages/HomePage.tsx @@ -0,0 +1,53 @@ +import GlobalFooter from "../components/GlobalFooter.tsx"; +import GlobalHeader from "../components/GlobalHeader.tsx"; +import PageLayout from "../components/PageLayout.tsx"; +import { Config } from "../config.ts"; + +export default function HomePage(config: Config) { + return ( + <PageLayout + metaCopyrightYear={config.blog.siteCopyrightYear} + metaDescription="nsfisis のブログサイト" + metaTitle={config.blog.siteName} + metaAtomFeedHref={`https://${config.blog.fqdn}/atom.xml`} + config={config} + > + <body className="single"> + <GlobalHeader config={config} /> + <main className="main"> + <article className="post-single"> + <article className="post-entry"> + <a href="/about/"> + <header className="entry-header"> + <h2>About</h2> + </header> + </a> + </article> + <article className="post-entry"> + <a href="/posts/"> + <header className="entry-header"> + <h2>Posts</h2> + </header> + </a> + </article> + <article className="post-entry"> + <a href="/slides/"> + <header className="entry-header"> + <h2>Slides</h2> + </header> + </a> + </article> + <article className="post-entry"> + <a href="/tags/"> + <header className="entry-header"> + <h2>Tags</h2> + </header> + </a> + </article> + </article> + </main> + <GlobalFooter config={config} /> + </body> + </PageLayout> + ); +} diff --git a/services/blog/nuldoc-src/pages/NotFoundPage.tsx b/services/blog/nuldoc-src/pages/NotFoundPage.tsx new file mode 100644 index 00000000..9631fef2 --- /dev/null +++ b/services/blog/nuldoc-src/pages/NotFoundPage.tsx @@ -0,0 +1,27 @@ +import GlobalFooter from "../components/GlobalFooter.tsx"; +import GlobalHeader from "../components/GlobalHeader.tsx"; +import PageLayout from "../components/PageLayout.tsx"; +import { Config } from "../config.ts"; + +export default function NotFoundPage( + config: Config, +) { + return ( + <PageLayout + metaCopyrightYear={config.blog.siteCopyrightYear} + metaDescription="リクエストされたページが見つかりません" + metaTitle={`Page Not Found|${config.blog.siteName}`} + config={config} + > + <body className="single"> + <GlobalHeader config={config} /> + <main className="main"> + <article> + <div className="not-found">404</div> + </article> + </main> + <GlobalFooter config={config} /> + </body> + </PageLayout> + ); +} diff --git a/services/blog/nuldoc-src/pages/PostListPage.tsx b/services/blog/nuldoc-src/pages/PostListPage.tsx new file mode 100644 index 00000000..054955e6 --- /dev/null +++ b/services/blog/nuldoc-src/pages/PostListPage.tsx @@ -0,0 +1,54 @@ +import GlobalFooter from "../components/GlobalFooter.tsx"; +import GlobalHeader from "../components/GlobalHeader.tsx"; +import PageLayout from "../components/PageLayout.tsx"; +import Pagination from "../components/Pagination.tsx"; +import PostPageEntry from "../components/PostPageEntry.tsx"; +import { Config } from "../config.ts"; +import { PostPage } from "../generators/post.ts"; + +export default function PostListPage( + posts: PostPage[], + config: Config, + currentPage: number, + totalPages: number, +) { + const pageTitle = "投稿一覧"; + + const pageInfoSuffix = ` (${currentPage}ページ目)`; + const metaTitle = `${pageTitle}${pageInfoSuffix}|${config.blog.siteName}`; + const metaDescription = `投稿した記事の一覧${pageInfoSuffix}`; + + return ( + <PageLayout + metaCopyrightYear={config.blog.siteCopyrightYear} + metaDescription={metaDescription} + metaTitle={metaTitle} + metaAtomFeedHref={`https://${config.blog.fqdn}/posts/atom.xml`} + config={config} + > + <body className="list"> + <GlobalHeader config={config} /> + <main className="main"> + <header className="page-header"> + <h1>{pageTitle}{pageInfoSuffix}</h1> + </header> + + <Pagination + currentPage={currentPage} + totalPages={totalPages} + basePath="/posts/" + /> + + {posts.map((post) => <PostPageEntry post={post} key={post.uuid} />)} + + <Pagination + currentPage={currentPage} + totalPages={totalPages} + basePath="/posts/" + /> + </main> + <GlobalFooter config={config} /> + </body> + </PageLayout> + ); +} diff --git a/services/blog/nuldoc-src/pages/PostPage.tsx b/services/blog/nuldoc-src/pages/PostPage.tsx new file mode 100644 index 00000000..97a24048 --- /dev/null +++ b/services/blog/nuldoc-src/pages/PostPage.tsx @@ -0,0 +1,66 @@ +import GlobalFooter from "../components/GlobalFooter.tsx"; +import GlobalHeader from "../components/GlobalHeader.tsx"; +import PageLayout from "../components/PageLayout.tsx"; +import { Config, getTagLabel } from "../config.ts"; +import { Element } from "../dom.ts"; +import { Document } from "../djot/document.ts"; +import { dateToString } from "../revision.ts"; +import { getPostPublishedDate } from "../generators/post.ts"; + +export default function PostPage( + doc: Document, + config: Config, +) { + return ( + <PageLayout + metaCopyrightYear={getPostPublishedDate(doc).year} + metaDescription={doc.description} + metaKeywords={doc.tags.map((slug) => getTagLabel(config, slug))} + metaTitle={`${doc.title}|${config.blog.siteName}`} + requiresSyntaxHighlight + config={config} + > + <body className="single"> + <GlobalHeader config={config} /> + <main className="main"> + <article className="post-single"> + <header className="post-header"> + <h1 className="post-title">{doc.title}</h1> + {doc.tags.length !== 0 && ( + <ul className="post-tags"> + {doc.tags.map((slug) => ( + <li className="tag"> + <a href={`/tags/${slug}/`}>{getTagLabel(config, slug)}</a> + </li> + ))} + </ul> + )} + </header> + <div className="post-content"> + <section id="changelog"> + <h2> + <a href="#changelog">更新履歴</a> + </h2> + <ol> + {doc.revisions.map((rev) => ( + <li className="revision"> + <time datetime={dateToString(rev.date)}> + {dateToString(rev.date)} + </time> + {`: ${rev.remark}`} + </li> + ))} + </ol> + </section> + { + // TODO: refactor + (doc.root.children[0] as Element).children + } + </div> + </article> + </main> + <GlobalFooter config={config} /> + </body> + </PageLayout> + ); +} diff --git a/services/blog/nuldoc-src/pages/SlideListPage.tsx b/services/blog/nuldoc-src/pages/SlideListPage.tsx new file mode 100644 index 00000000..3d87d492 --- /dev/null +++ b/services/blog/nuldoc-src/pages/SlideListPage.tsx @@ -0,0 +1,42 @@ +import GlobalFooter from "../components/GlobalFooter.tsx"; +import GlobalHeader from "../components/GlobalHeader.tsx"; +import PageLayout from "../components/PageLayout.tsx"; +import SlidePageEntry from "../components/SlidePageEntry.tsx"; +import { Config } from "../config.ts"; +import { dateToString } from "../revision.ts"; +import { getPostPublishedDate } from "../generators/post.ts"; +import { SlidePage } from "../generators/slide.ts"; + +export default function SlideListPage( + slides: SlidePage[], + config: Config, +) { + const pageTitle = "スライド一覧"; + + return ( + <PageLayout + metaCopyrightYear={config.blog.siteCopyrightYear} + metaDescription="登壇したイベントで使用したスライドの一覧" + metaTitle={`${pageTitle}|${config.blog.siteName}`} + metaAtomFeedHref={`https://${config.blog.fqdn}/slides/atom.xml`} + config={config} + > + <body className="list"> + <GlobalHeader config={config} /> + <main className="main"> + <header className="page-header"> + <h1>{pageTitle}</h1> + </header> + {Array.from(slides).sort((a, b) => { + const ta = dateToString(getPostPublishedDate(a)); + const tb = dateToString(getPostPublishedDate(b)); + if (ta > tb) return -1; + if (ta < tb) return 1; + return 0; + }).map((slide) => <SlidePageEntry slide={slide} key={slide.uuid} />)} + </main> + <GlobalFooter config={config} /> + </body> + </PageLayout> + ); +} diff --git a/services/blog/nuldoc-src/pages/SlidePage.tsx b/services/blog/nuldoc-src/pages/SlidePage.tsx new file mode 100644 index 00000000..fc11072d --- /dev/null +++ b/services/blog/nuldoc-src/pages/SlidePage.tsx @@ -0,0 +1,72 @@ +import GlobalFooter from "../components/GlobalFooter.tsx"; +import GlobalHeader from "../components/GlobalHeader.tsx"; +import PageLayout from "../components/PageLayout.tsx"; +import StaticScript from "../components/StaticScript.tsx"; +import { Config, getTagLabel } from "../config.ts"; +import { dateToString } from "../revision.ts"; +import { Slide } from "../slide/slide.ts"; +import { getPostPublishedDate } from "../generators/post.ts"; + +export default function SlidePage( + slide: Slide, + config: Config, +) { + return ( + <PageLayout + metaCopyrightYear={getPostPublishedDate(slide).year} + metaDescription={slide.title} + metaKeywords={slide.tags.map((slug) => getTagLabel(config, slug))} + metaTitle={`${slide.event} (${slide.talkType})|${config.blog.siteName}`} + requiresSyntaxHighlight + config={config} + > + <body className="single"> + <GlobalHeader config={config} /> + <main className="main"> + <article className="post-single"> + <header className="post-header"> + <h1 className="post-title">{slide.title}</h1> + {slide.tags.length !== 0 && ( + <ul className="post-tags"> + {slide.tags.map((slug) => ( + <li className="tag"> + <a href={`/tags/${slug}/`}>{getTagLabel(config, slug)}</a> + </li> + ))} + </ul> + )} + </header> + <div className="post-content"> + <section id="changelog"> + <h2> + <a href="#changelog">更新履歴</a> + </h2> + <ol> + {slide.revisions.map((rev) => ( + <li className="revision"> + <time datetime={dateToString(rev.date)}> + {dateToString(rev.date)} + </time> + {`: ${rev.remark}`} + </li> + ))} + </ol> + </section> + <canvas id="slide" data-slide-link={slide.slideLink} /> + <div> + <button id="prev" type="button">Prev</button> + <button id="next" type="button">Next</button> + </div> + <StaticScript + fileName="/slide.js" + type="module" + config={config} + /> + </div> + </article> + </main> + <GlobalFooter config={config} /> + </body> + </PageLayout> + ); +} diff --git a/services/blog/nuldoc-src/pages/TagListPage.tsx b/services/blog/nuldoc-src/pages/TagListPage.tsx new file mode 100644 index 00000000..cdb83ea5 --- /dev/null +++ b/services/blog/nuldoc-src/pages/TagListPage.tsx @@ -0,0 +1,57 @@ +import GlobalFooter from "../components/GlobalFooter.tsx"; +import GlobalHeader from "../components/GlobalHeader.tsx"; +import PageLayout from "../components/PageLayout.tsx"; +import { Config } from "../config.ts"; +import { TagPage } from "../generators/tag.ts"; + +export default function TagListPage( + tags: TagPage[], + config: Config, +) { + const pageTitle = "タグ一覧"; + + return ( + <PageLayout + metaCopyrightYear={config.blog.siteCopyrightYear} + metaDescription="タグの一覧" + metaTitle={`${pageTitle}|${config.blog.siteName}`} + config={config} + > + <body className="list"> + <GlobalHeader config={config} /> + <main className="main"> + <header className="page-header"> + <h1>{pageTitle}</h1> + </header> + {Array.from(tags).sort((a, b) => { + const ta = a.tagSlug; + const tb = b.tagSlug; + if (ta < tb) return -1; + if (ta > tb) return 1; + return 0; + }).map((tag) => ( + <article className="post-entry"> + <a href={tag.href}> + <header className="entry-header"> + <h2>{tag.tagLabel}</h2> + </header> + <footer className="entry-footer"> + {(() => { + const posts = tag.numOfPosts === 0 + ? "" + : `${tag.numOfPosts}件の記事`; + const slides = tag.numOfSlides === 0 + ? "" + : `${tag.numOfSlides}件のスライド`; + return `${posts}${posts && slides ? "、" : ""}${slides}`; + })()} + </footer> + </a> + </article> + ))} + </main> + <GlobalFooter config={config} /> + </body> + </PageLayout> + ); +} diff --git a/services/blog/nuldoc-src/pages/TagPage.tsx b/services/blog/nuldoc-src/pages/TagPage.tsx new file mode 100644 index 00000000..02c484af --- /dev/null +++ b/services/blog/nuldoc-src/pages/TagPage.tsx @@ -0,0 +1,43 @@ +import GlobalFooter from "../components/GlobalFooter.tsx"; +import GlobalHeader from "../components/GlobalHeader.tsx"; +import PageLayout from "../components/PageLayout.tsx"; +import PostPageEntry from "../components/PostPageEntry.tsx"; +import SlidePageEntry from "../components/SlidePageEntry.tsx"; +import { Config, getTagLabel } from "../config.ts"; +import { getPostPublishedDate } from "../generators/post.ts"; +import { TaggedPage } from "../generators/tagged_page.ts"; + +export default function TagPage( + tagSlug: string, + pages: TaggedPage[], + config: Config, +) { + const tagLabel = getTagLabel(config, tagSlug); + const pageTitle = `タグ「${tagLabel}」一覧`; + + return ( + <PageLayout + metaCopyrightYear={getPostPublishedDate(pages[pages.length - 1]).year} + metaDescription={`タグ「${tagLabel}」のついた記事またはスライドの一覧`} + metaKeywords={[tagLabel]} + metaTitle={`${pageTitle}|${config.blog.siteName}`} + metaAtomFeedHref={`https://${config.blog.fqdn}/tags/${tagSlug}/atom.xml`} + config={config} + > + <body className="list"> + <GlobalHeader config={config} /> + <main className="main"> + <header className="page-header"> + <h1>{pageTitle}</h1> + </header> + {pages.map((page) => + "event" in page + ? <SlidePageEntry slide={page} key={page.uuid} /> + : <PostPageEntry post={page} key={page.uuid} /> + )} + </main> + <GlobalFooter config={config} /> + </body> + </PageLayout> + ); +} diff --git a/services/blog/nuldoc-src/render.ts b/services/blog/nuldoc-src/render.ts new file mode 100644 index 00000000..fbad25ab --- /dev/null +++ b/services/blog/nuldoc-src/render.ts @@ -0,0 +1,13 @@ +import { Node } from "./dom.ts"; +import { renderHtml } from "./renderers/html.ts"; +import { renderXml } from "./renderers/xml.ts"; + +export type RendererType = "html" | "xml"; + +export function render(root: Node, renderer: RendererType): string { + if (renderer === "html") { + return renderHtml(root); + } else { + return renderXml(root); + } +} diff --git a/services/blog/nuldoc-src/renderers/html.ts b/services/blog/nuldoc-src/renderers/html.ts new file mode 100644 index 00000000..84b3ebaa --- /dev/null +++ b/services/blog/nuldoc-src/renderers/html.ts @@ -0,0 +1,310 @@ +import { Element, forEachChild, Node, Text } from "../dom.ts"; +import { NuldocError } from "../errors.ts"; + +export function renderHtml(root: Node): string { + return `<!DOCTYPE html>\n` + nodeToHtmlText(root, { + indentLevel: 0, + isInPre: false, + }); +} + +type Context = { + indentLevel: number; + isInPre: boolean; +}; + +type Dtd = { type: "block" | "inline"; self_closing?: boolean }; + +function getDtd(name: string): Dtd { + switch (name) { + case "a": + return { type: "inline" }; + case "article": + return { type: "block" }; + case "blockquote": + return { type: "block" }; + case "body": + return { type: "block" }; + case "br": + return { type: "block", self_closing: true }; + case "button": + return { type: "block" }; + case "canvas": + return { type: "block" }; + case "caption": + return { type: "block" }; + case "code": + return { type: "inline" }; + case "del": + return { type: "block" }; + case "div": + return { type: "block" }; + case "em": + return { type: "inline" }; + case "footer": + return { type: "block" }; + case "h1": + return { type: "inline" }; + case "h2": + return { type: "inline" }; + case "h3": + return { type: "inline" }; + case "h4": + return { type: "inline" }; + case "h5": + return { type: "inline" }; + case "h6": + return { type: "inline" }; + case "head": + return { type: "block" }; + case "header": + return { type: "block" }; + case "hr": + return { type: "block", self_closing: true }; + case "html": + return { type: "block" }; + case "i": + return { type: "inline" }; + case "li": + return { type: "block" }; + case "link": + return { type: "block", self_closing: true }; + case "img": + return { type: "inline", self_closing: true }; + case "ins": + return { type: "block" }; + case "main": + return { type: "block" }; + case "mark": + return { type: "inline" }; + case "meta": + return { type: "block", self_closing: true }; + case "nav": + return { type: "block" }; + case "noscript": + return { type: "block" }; + case "ol": + return { type: "block" }; + case "p": + return { type: "block" }; + case "pre": + return { type: "block" }; + case "script": + return { type: "block" }; + case "section": + return { type: "block" }; + case "span": + return { type: "inline" }; + case "strong": + return { type: "inline" }; + case "sub": + return { type: "inline" }; + case "sup": + return { type: "inline" }; + case "table": + return { type: "block" }; + case "tbody": + return { type: "block" }; + case "td": // TODO + return { type: "block" }; + case "tfoot": + return { type: "block" }; + case "th": + return { type: "block" }; + case "thead": + return { type: "block" }; + case "time": + return { type: "inline" }; + case "title": // TODO + return { type: "inline" }; + case "tr": + return { type: "block" }; + case "ul": + return { type: "block" }; + default: + throw new NuldocError(`[html.write] Unknown element name: ${name}`); + } +} + +function isInlineNode(n: Node): boolean { + if (n.kind === "text") { + 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; + + return s.replaceAll(/\n */g, (_match, offset, subject) => { + const last_char = subject[offset - 1]; + if (last_char === "。" || last_char === "、") { + // 日本語で改行するときはスペースを入れない + return ""; + } else { + return " "; + } + }); +} + +function encodeSpecialCharacters(s: string): string { + return s.replaceAll(/&(?!\w+;)/g, "&") + .replaceAll(/</g, "<") + .replaceAll(/>/g, ">") + .replaceAll(/'/g, "'") + .replaceAll(/"/g, """); +} + +function elementNodeToHtmlText(e: Element, ctx: Context): string { + const dtd = getDtd(e.name); + + let s = ""; + + if (isBlockNode(e)) { + s += indent(ctx); + } + s += `<${e.name}`; + const attributes = getElementAttributes(e); + if (attributes.length > 0) { + s += " "; + for (let i = 0; i < attributes.length; i++) { + const [name, value] = attributes[i]; + if (name === "defer" && value === "true") { + // TODO + s += "defer"; + } else { + s += `${name === "className" ? "class" : name}="${ + encodeSpecialCharacters(value) + }"`; + } + if (i !== attributes.length - 1) { + s += " "; + } + } + } + s += ">"; + if (isBlockNode(e) && e.name !== "pre") { + s += "\n"; + } + + ctx.indentLevel += 1; + + let prevChild: Node | null = null; + if (e.name === "pre") { + ctx.isInPre = true; + } + forEachChild(e, (c) => { + if (isBlockNode(e) && !ctx.isInPre) { + if (isInlineNode(c)) { + if (needsIndent(prevChild)) { + s += indent(ctx); + } + } else { + if (needsLineBreak(prevChild)) { + s += "\n"; + } + } + } + s += nodeToHtmlText(c, ctx); + prevChild = c; + }); + if (e.name === "pre") { + ctx.isInPre = false; + } + + ctx.indentLevel -= 1; + if (!dtd.self_closing) { + if (e.name !== "pre") { + if (isBlockNode(e)) { + if (needsLineBreak(prevChild)) { + s += "\n"; + } + s += indent(ctx); + } + } + s += `</${e.name}>`; + if (isBlockNode(e)) { + s += "\n"; + } + } + return s; +} + +function indent(ctx: Context): string { + return " ".repeat(ctx.indentLevel); +} + +function getElementAttributes(e: Element): [string, string][] { + return [...e.attributes.entries()] + .filter((a) => !a[0].startsWith("--")) + .filter((a) => a[1] !== undefined) + .sort( + (a, b) => { + // Special rules: + if (e.name === "meta") { + if (a[0] === "content" && b[0] === "name") { + return 1; + } + if (a[0] === "name" && b[0] === "content") { + return -1; + } + if (a[0] === "content" && b[0] === "property") { + return 1; + } + if (a[0] === "property" && b[0] === "content") { + return -1; + } + } + if (e.name === "link") { + if (a[0] === "href" && b[0] === "rel") { + return 1; + } + if (a[0] === "rel" && b[0] === "href") { + return -1; + } + if (a[0] === "href" && b[0] === "type") { + return 1; + } + if (a[0] === "type" && b[0] === "href") { + return -1; + } + } + // General rules: + if (a[0] > b[0]) return 1; + else if (a[0] < b[0]) return -1; + else return 0; + }, + ); +} + +function needsIndent(prevChild: Node | null): boolean { + return !prevChild || isBlockNode(prevChild); +} + +function needsLineBreak(prevChild: Node | null): boolean { + return !needsIndent(prevChild); +} diff --git a/services/blog/nuldoc-src/renderers/xml.ts b/services/blog/nuldoc-src/renderers/xml.ts new file mode 100644 index 00000000..77cc1574 --- /dev/null +++ b/services/blog/nuldoc-src/renderers/xml.ts @@ -0,0 +1,130 @@ +import { Element, forEachChild, Node, Text } from "../dom.ts"; + +export function renderXml(root: Node): string { + return `<?xml version="1.0" encoding="utf-8"?>\n` + nodeToXmlText(root, { + indentLevel: 0, + }); +} + +type Context = { + indentLevel: number; +}; + +type Dtd = { type: "block" | "inline" }; + +function getDtd(name: string): Dtd { + switch (name) { + case "feed": + case "entry": + case "author": + return { type: "block" }; + default: + return { type: "inline" }; + } +} + +function isInlineNode(n: Node): boolean { + if (n.kind === "text") { + return true; + } + return getDtd(n.name).type === "inline"; +} + +function isBlockNode(n: Node): boolean { + return !isInlineNode(n); +} + +function nodeToXmlText(n: Node, ctx: Context): string { + if (n.kind === "text") { + if (n.raw) { + return n.content; + } else { + return textNodeToXmlText(n); + } + } else { + return elementNodeToXmlText(n, ctx); + } +} + +function textNodeToXmlText(t: Text): string { + const s = encodeSpecialCharacters(t.content); + + // TODO: 日本語で改行するときはスペースを入れない + return s.replaceAll(/\n */g, " "); +} + +function encodeSpecialCharacters(s: string): string { + return s.replaceAll(/&(?!\w+;)/g, "&") + .replaceAll(/</g, "<") + .replaceAll(/>/g, ">") + .replaceAll(/'/g, "'") + .replaceAll(/"/g, """); +} + +function elementNodeToXmlText(e: Element, ctx: Context): string { + let s = ""; + + s += indent(ctx); + s += `<${e.name}`; + const attributes = getElementAttributes(e); + if (attributes.length > 0) { + s += " "; + for (let i = 0; i < attributes.length; i++) { + const [name, value] = attributes[i]; + s += `${name}="${encodeSpecialCharacters(value)}"`; + if (i !== attributes.length - 1) { + s += " "; + } + } + } + s += ">"; + if (isBlockNode(e)) { + s += "\n"; + } + ctx.indentLevel += 1; + + forEachChild(e, (c) => { + s += nodeToXmlText(c, ctx); + }); + + ctx.indentLevel -= 1; + if (isBlockNode(e)) { + s += indent(ctx); + } + s += `</${e.name}>`; + s += "\n"; + + return s; +} + +function indent(ctx: Context): string { + return " ".repeat(ctx.indentLevel); +} + +function getElementAttributes(e: Element): [string, string][] { + return [...e.attributes.entries()] + .filter((a) => !a[0].startsWith("--")) + .sort( + (a, b) => { + // Special rules: + if (e.name === "link") { + if (a[0] === "href" && b[0] === "rel") { + return 1; + } + if (a[0] === "rel" && b[0] === "href") { + return -1; + } + if (a[0] === "href" && b[0] === "type") { + return 1; + } + if (a[0] === "type" && b[0] === "href") { + return -1; + } + } + // General rules: + if (a[0] > b[0]) return 1; + else if (a[0] < b[0]) return -1; + else return 0; + }, + ); +} diff --git a/services/blog/nuldoc-src/revision.ts b/services/blog/nuldoc-src/revision.ts new file mode 100644 index 00000000..a22b6bc4 --- /dev/null +++ b/services/blog/nuldoc-src/revision.ts @@ -0,0 +1,37 @@ +export type Date = { + year: number; + month: number; + day: number; +}; + +export function stringToDate(s: string): Date { + const match = s.match(/(\d{4})-(\d{2})-(\d{2})/); + if (match === null) { + throw new Error(); + } + const [_, y, m, d] = match; + return { year: parseInt(y), month: parseInt(m), day: parseInt(d) }; +} + +export function dateToString(date: Date): string { + const y = `${date.year}`.padStart(4, "0"); + const m = `${date.month}`.padStart(2, "0"); + const d = `${date.day}`.padStart(2, "0"); + return `${y}-${m}-${d}`; +} + +export function dateToRfc3339String(date: Date): string { + // 2021-01-01T12:00:00+00:00 + // TODO: currently, time part is fixed to 00:00:00. + const y = `${date.year}`.padStart(4, "0"); + const m = `${date.month}`.padStart(2, "0"); + const d = `${date.day}`.padStart(2, "0"); + return `${y}-${m}-${d}T00:00:00+09:00`; +} + +export type Revision = { + number: number; + date: Date; + remark: string; + isInternal: boolean; +}; diff --git a/services/blog/nuldoc-src/slide/parse.ts b/services/blog/nuldoc-src/slide/parse.ts new file mode 100644 index 00000000..c5a89675 --- /dev/null +++ b/services/blog/nuldoc-src/slide/parse.ts @@ -0,0 +1,20 @@ +import { parse as parseToml } from "@std/toml"; +import { + createNewSlideFromMetadata, + Slide, + SlideMetadataSchema, +} from "./slide.ts"; + +export async function parseSlideFile(filePath: string): Promise<Slide> { + try { + const root = SlideMetadataSchema.parse( + parseToml(await Deno.readTextFile(filePath)), + ); + return createNewSlideFromMetadata(root, filePath); + } catch (e) { + if (e instanceof Error) { + e.message = `${e.message} in ${filePath}`; + } + throw e; + } +} diff --git a/services/blog/nuldoc-src/slide/slide.ts b/services/blog/nuldoc-src/slide/slide.ts new file mode 100644 index 00000000..8fe99eab --- /dev/null +++ b/services/blog/nuldoc-src/slide/slide.ts @@ -0,0 +1,67 @@ +import { SlideError } from "../errors.ts"; +import { Revision, stringToDate } from "../revision.ts"; +import { z } from "zod/mod.ts"; + +export const SlideMetadataSchema = z.object({ + slide: z.object({ + uuid: z.string(), + title: z.string(), + event: z.string(), + talkType: z.string(), + link: z.string(), + tags: z.array(z.string()), + revisions: z.array(z.object({ + date: z.string(), + remark: z.string(), + isInternal: z.boolean().optional(), + })), + }), +}); + +export type SlideMetadata = z.infer<typeof SlideMetadataSchema>; + +export type Slide = { + sourceFilePath: string; + uuid: string; + title: string; + event: string; + talkType: string; + slideLink: string; + tags: string[]; + revisions: Revision[]; +}; + +export function createNewSlideFromMetadata( + { slide }: SlideMetadata, + sourceFilePath: string, +): Slide { + const revisions = slide.revisions.map( + (rev, i) => { + const date = rev.date; + const remark = rev.remark; + const isInternal = rev.isInternal ?? false; + return { + number: i + 1, + date: stringToDate(date), + remark, + isInternal, + }; + }, + ); + if (revisions.length === 0) { + throw new SlideError( + `[slide.new] 'slide.revisions' field is empty`, + ); + } + + return { + sourceFilePath: sourceFilePath, + uuid: slide.uuid, + title: slide.title, + event: slide.event, + talkType: slide.talkType, + slideLink: slide.link, + tags: slide.tags, + revisions: revisions, + }; +} |
