diff options
| author | nsfisis <nsfisis@gmail.com> | 2024-02-22 01:51:21 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2024-02-24 14:24:15 +0900 |
| commit | 7c81d7bf5bcb6fb9578ae4ae54684742bf9ae35d (patch) | |
| tree | 4534959896de4ba5492f8f35417fd45670296574 | |
| parent | b72e1bd7b40f1c9c3558b6ed50367a2b7fc11d62 (diff) | |
| download | nsfisis.dev-7c81d7bf5bcb6fb9578ae4ae54684742bf9ae35d.tar.gz nsfisis.dev-7c81d7bf5bcb6fb9578ae4ae54684742bf9ae35d.tar.zst nsfisis.dev-7c81d7bf5bcb6fb9578ae4ae54684742bf9ae35d.zip | |
feat(blog/nuldoc): implement generating Atom feed
| -rw-r--r-- | vhosts/blog/nuldoc-src/atom/generate.ts | 86 | ||||
| -rw-r--r-- | vhosts/blog/nuldoc-src/atom/types.ts | 19 | ||||
| -rw-r--r-- | vhosts/blog/nuldoc-src/commands/build.ts | 43 | ||||
| -rw-r--r-- | vhosts/blog/nuldoc-src/components/slide_page_entry.ts | 2 | ||||
| -rw-r--r-- | vhosts/blog/nuldoc-src/config.ts | 1 | ||||
| -rw-r--r-- | vhosts/blog/nuldoc-src/ndoc/document.ts | 3 | ||||
| -rw-r--r-- | vhosts/blog/nuldoc-src/ndoc/parse.ts | 2 | ||||
| -rw-r--r-- | vhosts/blog/nuldoc-src/pages/post.ts | 6 | ||||
| -rw-r--r-- | vhosts/blog/nuldoc-src/pages/slide.ts | 12 | ||||
| -rw-r--r-- | vhosts/blog/nuldoc-src/render.ts | 6 | ||||
| -rw-r--r-- | vhosts/blog/nuldoc-src/renderers/xml.ts | 133 | ||||
| -rw-r--r-- | vhosts/blog/nuldoc-src/revision.ts | 9 | ||||
| -rw-r--r-- | vhosts/blog/nuldoc-src/slide/parse.ts | 1 | ||||
| -rw-r--r-- | vhosts/blog/nuldoc-src/slide/slide.ts | 10 |
14 files changed, 326 insertions, 7 deletions
diff --git a/vhosts/blog/nuldoc-src/atom/generate.ts b/vhosts/blog/nuldoc-src/atom/generate.ts new file mode 100644 index 00000000..cb425aba --- /dev/null +++ b/vhosts/blog/nuldoc-src/atom/generate.ts @@ -0,0 +1,86 @@ +import { Config } from "../config.ts"; +import { el, text } from "../dom.ts"; +import { Page } from "../page.ts"; +import { Entry, Feed } from "./types.ts"; +import { PostPage } from "../pages/post.ts"; +import { SlidePage } from "../pages/slide.ts"; +import { dateToRfc3339String } from "../revision.ts"; + +const BASE_NAME = "atom.xml"; + +export function generateFeedPageFromEntries( + alternateLink: string, + feedSlug: string, + feedTitle: string, + entries: Array<PostPage | SlidePage>, + config: Config, +): 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_, + }; + + const xml = buildXmlTree(feed); + return { + root: el("__root__", [], xml), + renderer: "xml", + destFilePath: feedPath, + href: feedPath, + }; +} + +function buildXmlTree(feed: Feed) { + return el( + "feed", + [["xmlns", "http://www.w3.org/2005/Atom"]], + el("id", [], text(feed.id)), + el("title", [], text(feed.title)), + el("link", [["rel", "alternate"], ["href", feed.linkToAlternate]]), + el("link", [["rel", "self"], ["href", feed.linkToSelf]]), + el("author", [], el("name", [], text(feed.author))), + el("updated", [], text(feed.updated)), + ...feed.entries.map( + (entry) => + el( + "entry", + [], + el("id", [], text(entry.id)), + el("link", [["rel", "alternate"], ["href", entry.linkToAlternate]]), + el("title", [], text(entry.title)), + el("summary", [], text(entry.summary)), + el("published", [], text(entry.published)), + el("updated", [], text(entry.updated)), + ), + ), + ); +} diff --git a/vhosts/blog/nuldoc-src/atom/types.ts b/vhosts/blog/nuldoc-src/atom/types.ts new file mode 100644 index 00000000..66fde5ba --- /dev/null +++ b/vhosts/blog/nuldoc-src/atom/types.ts @@ -0,0 +1,19 @@ +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; +}; diff --git a/vhosts/blog/nuldoc-src/commands/build.ts b/vhosts/blog/nuldoc-src/commands/build.ts index 92230d7d..355604d8 100644 --- a/vhosts/blog/nuldoc-src/commands/build.ts +++ b/vhosts/blog/nuldoc-src/commands/build.ts @@ -1,7 +1,8 @@ import { dirname, join, joinGlobs } from "std/path/mod.ts"; import { ensureDir } from "std/fs/mod.ts"; import { expandGlob } from "std/fs/expand_glob.ts"; -import { Config } from "../config.ts"; +import { generateFeedPageFromEntries } from "../atom/generate.ts"; +import { Config, getTagLabel } from "../config.ts"; import { parseNulDocFile } from "../ndoc/parse.ts"; import { Page } from "../page.ts"; import { render } from "../render.ts"; @@ -32,6 +33,7 @@ export async function runBuildCommand(config: Config) { await buildHomePage(config); await buildAboutPage(slides, config); await buildNotFoundPage(config); + await buildFeedOfAllContents(posts, slides, config); await copyStaticFiles(config); await copyAssetFiles(slides, config); } @@ -71,6 +73,14 @@ async function parsePosts( async function buildPostListPage(posts: PostPage[], config: Config) { const postListPage = await generatePostListPage(posts, config); await writePage(postListPage, config); + const postFeedPage = generateFeedPageFromEntries( + postListPage.href, + "posts", + `投稿一覧|${config.blog.siteName}`, + posts, + config, + ); + await writePage(postFeedPage, config); } async function buildSlidePages(config: Config): Promise<SlidePage[]> { @@ -108,6 +118,14 @@ async function parseSlides( async function buildSlideListPage(slides: SlidePage[], config: Config) { const slideListPage = await generateSlideListPage(slides, config); await writePage(slideListPage, config); + const slideFeedPage = generateFeedPageFromEntries( + slideListPage.href, + "slides", + `スライド一覧|${config.blog.siteName}`, + slides, + config, + ); + await writePage(slideFeedPage, config); } async function buildHomePage(config: Config) { @@ -125,6 +143,21 @@ async function buildNotFoundPage(config: Config) { await writePage(notFoundPage, config); } +async function buildFeedOfAllContents( + posts: PostPage[], + slides: SlidePage[], + config: Config, +) { + const feed = generateFeedPageFromEntries( + "/", + "all", + config.blog.siteName, + [...posts, ...slides], + config, + ); + await writePage(feed, config); +} + async function buildTagPages( posts: PostPage[], slides: SlidePage[], @@ -135,6 +168,14 @@ async function buildTagPages( for (const [tag, pages] of tagsAndPages) { const tagPage = await generateTagPage(tag, pages, config); await writePage(tagPage, config); + const tagFeedPage = generateFeedPageFromEntries( + tagPage.href, + `tag-${tag}`, + `タグ「${getTagLabel(config, tag)}」一覧|${config.blog.siteName}`, + pages, + config, + ); + await writePage(tagFeedPage, config); tags.push(tagPage); } return tags; diff --git a/vhosts/blog/nuldoc-src/components/slide_page_entry.ts b/vhosts/blog/nuldoc-src/components/slide_page_entry.ts index 4767ca2b..6d8908b8 100644 --- a/vhosts/blog/nuldoc-src/components/slide_page_entry.ts +++ b/vhosts/blog/nuldoc-src/components/slide_page_entry.ts @@ -13,7 +13,7 @@ export function slidePageEntry(slide: SlidePage): Element { el( "header", [["class", "entry-header"]], - el("h2", [], text(`登壇: ${slide.event} (${slide.talkType})`)), + el("h2", [], text(slide.description)), ), el( "section", diff --git a/vhosts/blog/nuldoc-src/config.ts b/vhosts/blog/nuldoc-src/config.ts index cba93c06..f669f169 100644 --- a/vhosts/blog/nuldoc-src/config.ts +++ b/vhosts/blog/nuldoc-src/config.ts @@ -11,6 +11,7 @@ export const config = { }, blog: { author: "nsfisis", + fqdn: "blog.nsfisis.dev", siteName: "REPL: Rest-Eat-Program Loop", siteCopyrightYear: 2021, tagLabels: { diff --git a/vhosts/blog/nuldoc-src/ndoc/document.ts b/vhosts/blog/nuldoc-src/ndoc/document.ts index 31bae616..cbf0473c 100644 --- a/vhosts/blog/nuldoc-src/ndoc/document.ts +++ b/vhosts/blog/nuldoc-src/ndoc/document.ts @@ -7,6 +7,7 @@ import { Element, findFirstChildElement } from "../dom.ts"; export type Document = { root: Element; sourceFilePath: string; + uuid: string; link: string; title: string; description: string; // TODO: should it be markup text? @@ -18,6 +19,7 @@ export function createNewDocumentFromRootElement( root: Element, meta: { article: { + uuid: string; title: string; description: string; tags: string[]; @@ -43,6 +45,7 @@ export function createNewDocumentFromRootElement( return { root: root, sourceFilePath: sourceFilePath, + uuid: meta.article.uuid, link: link, title: meta.article.title, description: meta.article.description, diff --git a/vhosts/blog/nuldoc-src/ndoc/parse.ts b/vhosts/blog/nuldoc-src/ndoc/parse.ts index 419d2630..7c33c414 100644 --- a/vhosts/blog/nuldoc-src/ndoc/parse.ts +++ b/vhosts/blog/nuldoc-src/ndoc/parse.ts @@ -23,6 +23,7 @@ export async function parseNulDocFile( function parseMetaInfo(s: string): { article: { + uuid: string; title: string; description: string; tags: string[]; @@ -34,6 +35,7 @@ function parseMetaInfo(s: string): { } { const root = parseToml(s) as { article: { + uuid: string; title: string; description: string; tags: string[]; diff --git a/vhosts/blog/nuldoc-src/pages/post.ts b/vhosts/blog/nuldoc-src/pages/post.ts index 31a39c76..f7e53421 100644 --- a/vhosts/blog/nuldoc-src/pages/post.ts +++ b/vhosts/blog/nuldoc-src/pages/post.ts @@ -13,6 +13,9 @@ export interface PostPage extends Page { description: string; tags: string[]; revisions: Revision[]; + published: Date; + updated: Date; + uuid: string; } export function getPostCreatedDate(page: { revisions: Revision[] }): Date { @@ -136,5 +139,8 @@ export async function generatePostPage( description: doc.description, tags: doc.tags, revisions: doc.revisions, + published: getPostCreatedDate(doc), + updated: getPostUpdatedDate(doc), + uuid: doc.uuid, }; } diff --git a/vhosts/blog/nuldoc-src/pages/slide.ts b/vhosts/blog/nuldoc-src/pages/slide.ts index b84aeb38..5e4d1834 100644 --- a/vhosts/blog/nuldoc-src/pages/slide.ts +++ b/vhosts/blog/nuldoc-src/pages/slide.ts @@ -6,17 +6,21 @@ import { staticScriptElement } from "../components/utils.ts"; import { Config, getTagLabel } from "../config.ts"; import { el, text } from "../dom.ts"; import { Page } from "../page.ts"; -import { dateToString, Revision } from "../revision.ts"; +import { Date, dateToString, Revision } from "../revision.ts"; import { Slide } from "../slide/slide.ts"; -import { getPostCreatedDate } from "./post.ts"; +import { getPostCreatedDate, 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( @@ -131,10 +135,14 @@ export async function generateSlidePage( 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: getPostCreatedDate(slide), + updated: getPostUpdatedDate(slide), + uuid: slide.uuid, }; } diff --git a/vhosts/blog/nuldoc-src/render.ts b/vhosts/blog/nuldoc-src/render.ts index feb72a4b..fbad25ab 100644 --- a/vhosts/blog/nuldoc-src/render.ts +++ b/vhosts/blog/nuldoc-src/render.ts @@ -1,13 +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 type RendererType = "html"; +export type RendererType = "html" | "xml"; export function render(root: Node, renderer: RendererType): string { if (renderer === "html") { return renderHtml(root); } else { - return renderHtml(root); + return renderXml(root); } } diff --git a/vhosts/blog/nuldoc-src/renderers/xml.ts b/vhosts/blog/nuldoc-src/renderers/xml.ts new file mode 100644 index 00000000..c3293d17 --- /dev/null +++ b/vhosts/blog/nuldoc-src/renderers/xml.ts @@ -0,0 +1,133 @@ +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: -1, + }); +} + +type Context = { + indentLevel: number; +}; + +type Dtd = { type: "block" | "inline" }; + +function getDtd(name: string): Dtd { + switch (name) { + case "__root__": + 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 = ""; + if (e.name !== "__root__") { + s += indent(ctx); + s += `<${e.name}`; + const attributes = getElementAttributes(e); + if (attributes.length > 0) { + s += " "; + for (let i = 0; i < attributes.length; i++) { + const [name, value] = attributes[i]; + s += `${name}="${value}"`; + if (i !== attributes.length - 1) { + s += " "; + } + } + } + s += ">"; + if (isBlockNode(e)) { + s += "\n"; + } + } + ctx.indentLevel += 1; + + forEachChild(e, (c) => { + s += nodeToXmlText(c, ctx); + }); + + ctx.indentLevel -= 1; + if (e.name !== "__root__") { + 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/vhosts/blog/nuldoc-src/revision.ts b/vhosts/blog/nuldoc-src/revision.ts index e04b7ba1..e96d75de 100644 --- a/vhosts/blog/nuldoc-src/revision.ts +++ b/vhosts/blog/nuldoc-src/revision.ts @@ -20,6 +20,15 @@ export function dateToString(date: Date): string { 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; diff --git a/vhosts/blog/nuldoc-src/slide/parse.ts b/vhosts/blog/nuldoc-src/slide/parse.ts index 45ac6388..574dd4ba 100644 --- a/vhosts/blog/nuldoc-src/slide/parse.ts +++ b/vhosts/blog/nuldoc-src/slide/parse.ts @@ -10,6 +10,7 @@ export async function parseSlideFile( // TODO runtime assertion const root = parseToml(await Deno.readTextFile(filePath)) as { slide: { + uuid: string; title: string; event: string; talkType: string; diff --git a/vhosts/blog/nuldoc-src/slide/slide.ts b/vhosts/blog/nuldoc-src/slide/slide.ts index 5d5f30eb..388c8c88 100644 --- a/vhosts/blog/nuldoc-src/slide/slide.ts +++ b/vhosts/blog/nuldoc-src/slide/slide.ts @@ -4,6 +4,7 @@ import { Revision, stringToDate } from "../revision.ts"; export type Slide = { sourceFilePath: string; + uuid: string; title: string; event: string; talkType: string; @@ -14,6 +15,7 @@ export type Slide = { type Toml = { slide: { + uuid: string; title: string; event: string; talkType: string; @@ -38,6 +40,13 @@ export function createNewSlideFromTomlRootObject( ); } + const uuid = slide.uuid ?? null; + if (!uuid) { + throw new SlideError( + `[slide.new] 'slide.uuid' field not found`, + ); + } + const title = slide.title ?? null; if (!title) { throw new SlideError( @@ -103,6 +112,7 @@ export function createNewSlideFromTomlRootObject( return { sourceFilePath: sourceFilePath, + uuid: uuid, title: title, event: event, talkType: talkType, |
