summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2024-02-22 01:51:21 +0900
committernsfisis <nsfisis@gmail.com>2024-02-24 14:24:15 +0900
commit7c81d7bf5bcb6fb9578ae4ae54684742bf9ae35d (patch)
tree4534959896de4ba5492f8f35417fd45670296574
parentb72e1bd7b40f1c9c3558b6ed50367a2b7fc11d62 (diff)
downloadnsfisis.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.ts86
-rw-r--r--vhosts/blog/nuldoc-src/atom/types.ts19
-rw-r--r--vhosts/blog/nuldoc-src/commands/build.ts43
-rw-r--r--vhosts/blog/nuldoc-src/components/slide_page_entry.ts2
-rw-r--r--vhosts/blog/nuldoc-src/config.ts1
-rw-r--r--vhosts/blog/nuldoc-src/ndoc/document.ts3
-rw-r--r--vhosts/blog/nuldoc-src/ndoc/parse.ts2
-rw-r--r--vhosts/blog/nuldoc-src/pages/post.ts6
-rw-r--r--vhosts/blog/nuldoc-src/pages/slide.ts12
-rw-r--r--vhosts/blog/nuldoc-src/render.ts6
-rw-r--r--vhosts/blog/nuldoc-src/renderers/xml.ts133
-rw-r--r--vhosts/blog/nuldoc-src/revision.ts9
-rw-r--r--vhosts/blog/nuldoc-src/slide/parse.ts1
-rw-r--r--vhosts/blog/nuldoc-src/slide/slide.ts10
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, "&amp;")
+ .replaceAll(/</g, "&lt;")
+ .replaceAll(/>/g, "&gt;")
+ .replaceAll(/'/g, "&apos;")
+ .replaceAll(/"/g, "&quot;");
+}
+
+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,