aboutsummaryrefslogtreecommitdiffhomepage
path: root/services/nuldoc/nuldoc-src/components
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2025-11-02 17:49:34 +0900
committernsfisis <nsfisis@gmail.com>2025-11-02 17:49:34 +0900
commit57315c52be96d2a2c013f0cfb0de5429980e301a (patch)
tree5d691497772fddfe401cd970ead4e9a74b34ef38 /services/nuldoc/nuldoc-src/components
parentcf4091a93ed831456e8b30e2a9e1fc2650dcae02 (diff)
downloadnsfisis.dev-57315c52be96d2a2c013f0cfb0de5429980e301a.tar.gz
nsfisis.dev-57315c52be96d2a2c013f0cfb0de5429980e301a.tar.zst
nsfisis.dev-57315c52be96d2a2c013f0cfb0de5429980e301a.zip
refactor(blog): rename directory, services/{blog => nuldoc}/
Diffstat (limited to 'services/nuldoc/nuldoc-src/components')
-rw-r--r--services/nuldoc/nuldoc-src/components/GlobalFooter.tsx9
-rw-r--r--services/nuldoc/nuldoc-src/components/GlobalHeader.tsx27
-rw-r--r--services/nuldoc/nuldoc-src/components/PageLayout.tsx63
-rw-r--r--services/nuldoc/nuldoc-src/components/Pagination.tsx104
-rw-r--r--services/nuldoc/nuldoc-src/components/PostPageEntry.tsx46
-rw-r--r--services/nuldoc/nuldoc-src/components/SlidePageEntry.tsx46
-rw-r--r--services/nuldoc/nuldoc-src/components/StaticScript.tsx18
-rw-r--r--services/nuldoc/nuldoc-src/components/StaticStylesheet.tsx11
-rw-r--r--services/nuldoc/nuldoc-src/components/TableOfContents.tsx33
-rw-r--r--services/nuldoc/nuldoc-src/components/TagList.tsx18
-rw-r--r--services/nuldoc/nuldoc-src/components/utils.ts8
11 files changed, 383 insertions, 0 deletions
diff --git a/services/nuldoc/nuldoc-src/components/GlobalFooter.tsx b/services/nuldoc/nuldoc-src/components/GlobalFooter.tsx
new file mode 100644
index 0000000..757bece
--- /dev/null
+++ b/services/nuldoc/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">
+ {`&copy; ${config.blog.siteCopyrightYear} ${config.blog.author}`}
+ </footer>
+ );
+}
diff --git a/services/nuldoc/nuldoc-src/components/GlobalHeader.tsx b/services/nuldoc/nuldoc-src/components/GlobalHeader.tsx
new file mode 100644
index 0000000..c0fa7e8
--- /dev/null
+++ b/services/nuldoc/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/nuldoc/nuldoc-src/components/PageLayout.tsx b/services/nuldoc/nuldoc-src/components/PageLayout.tsx
new file mode 100644
index 0000000..78a5cde
--- /dev/null
+++ b/services/nuldoc/nuldoc-src/components/PageLayout.tsx
@@ -0,0 +1,63 @@
+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={`&copy; ${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" />
+ {/* https://b.hatena.ne.jp/help/entry/nocomment */}
+ <meta name="Hatena::Bookmark" content="nocomment" />
+ {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/nuldoc/nuldoc-src/components/Pagination.tsx b/services/nuldoc/nuldoc-src/components/Pagination.tsx
new file mode 100644
index 0000000..84752c5
--- /dev/null
+++ b/services/nuldoc/nuldoc-src/components/Pagination.tsx
@@ -0,0 +1,104 @@
+type Props = {
+ currentPage: number;
+ totalPages: number;
+ basePath: string;
+};
+
+export default function Pagination(
+ { currentPage, totalPages, basePath }: Props,
+) {
+ if (totalPages <= 1) {
+ return <div></div>;
+ }
+
+ const pages = generatePageNumbers(currentPage, totalPages);
+
+ return (
+ <nav className="pagination">
+ <div className="pagination-prev">
+ {currentPage > 1
+ ? (
+ <a href={pageUrlAt(basePath, currentPage - 1)}>
+ 前へ
+ </a>
+ )
+ : null}
+ </div>
+ {pages.map((page) => {
+ if (page === "...") {
+ return (
+ <div className="pagination-elipsis">
+ …
+ </div>
+ );
+ } else if (page === currentPage) {
+ return (
+ <div className="pagination-page pagination-page-current">
+ <span>{String(page)}</span>
+ </div>
+ );
+ } else {
+ return (
+ <div className="pagination-page">
+ <a href={pageUrlAt(basePath, page)}>{String(page)}</a>
+ </div>
+ );
+ }
+ })}
+ <div className="pagination-next">
+ {currentPage < totalPages
+ ? (
+ <a href={pageUrlAt(basePath, currentPage + 1)}>
+ 次へ
+ </a>
+ )
+ : null}
+ </div>
+ </nav>
+ );
+}
+
+type PageItem = number | "...";
+
+/**
+ * Generates page numbers for pagination display.
+ *
+ * - Always show the first page
+ * - Always show the last page
+ * - Always show the current page
+ * - Always show the page before and after the current page
+ * - If there's only one page gap between displayed pages, fill it
+ * - If there are two or more pages gap between displayed pages, show ellipsis
+ */
+function generatePageNumbers(
+ currentPage: number,
+ totalPages: number,
+): PageItem[] {
+ const pages = new Set<number>();
+ pages.add(1);
+ pages.add(Math.max(1, currentPage - 1));
+ pages.add(currentPage);
+ pages.add(Math.min(totalPages, currentPage + 1));
+ pages.add(totalPages);
+
+ const sorted = Array.from(pages).sort((a, b) => a - b);
+
+ const result: PageItem[] = [];
+ for (let i = 0; i < sorted.length; i++) {
+ if (i > 0) {
+ const gap = sorted[i] - sorted[i - 1];
+ if (gap === 2) {
+ result.push(sorted[i - 1] + 1);
+ } else if (gap > 2) {
+ result.push("...");
+ }
+ }
+ result.push(sorted[i]);
+ }
+
+ return result;
+}
+
+function pageUrlAt(basePath: string, page: number): string {
+ return page === 1 ? basePath : `${basePath}${page}/`;
+}
diff --git a/services/nuldoc/nuldoc-src/components/PostPageEntry.tsx b/services/nuldoc/nuldoc-src/components/PostPageEntry.tsx
new file mode 100644
index 0000000..23ca88a
--- /dev/null
+++ b/services/nuldoc/nuldoc-src/components/PostPageEntry.tsx
@@ -0,0 +1,46 @@
+import {
+ getPostPublishedDate,
+ getPostUpdatedDate,
+ postHasAnyUpdates,
+ PostPage,
+} from "../generators/post.ts";
+import { dateToString } from "../revision.ts";
+import { Config } from "../config.ts";
+import TagList from "./TagList.tsx";
+
+type Props = { post: PostPage; config: Config };
+
+export default function PostPageEntry({ post, config }: Props) {
+ 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) && " 更新"}
+ {post.tags.length !== 0 && (
+ <TagList tags={post.tags} config={config} />
+ )}
+ </footer>
+ </a>
+ </article>
+ );
+}
diff --git a/services/nuldoc/nuldoc-src/components/SlidePageEntry.tsx b/services/nuldoc/nuldoc-src/components/SlidePageEntry.tsx
new file mode 100644
index 0000000..2401765
--- /dev/null
+++ b/services/nuldoc/nuldoc-src/components/SlidePageEntry.tsx
@@ -0,0 +1,46 @@
+import {
+ getPostPublishedDate,
+ getPostUpdatedDate,
+ postHasAnyUpdates,
+} from "../generators/post.ts";
+import { SlidePage } from "../generators/slide.ts";
+import { dateToString } from "../revision.ts";
+import { Config } from "../config.ts";
+import TagList from "./TagList.tsx";
+
+type Props = { slide: SlidePage; config: Config };
+
+export default function SlidePageEntry({ slide, config }: Props) {
+ 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) && " 更新"}
+ {slide.tags.length !== 0 && (
+ <TagList tags={slide.tags} config={config} />
+ )}
+ </footer>
+ </a>
+ </article>
+ );
+}
diff --git a/services/nuldoc/nuldoc-src/components/StaticScript.tsx b/services/nuldoc/nuldoc-src/components/StaticScript.tsx
new file mode 100644
index 0000000..0e3ab19
--- /dev/null
+++ b/services/nuldoc/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/nuldoc/nuldoc-src/components/StaticStylesheet.tsx b/services/nuldoc/nuldoc-src/components/StaticStylesheet.tsx
new file mode 100644
index 0000000..52b695e
--- /dev/null
+++ b/services/nuldoc/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/nuldoc/nuldoc-src/components/TableOfContents.tsx b/services/nuldoc/nuldoc-src/components/TableOfContents.tsx
new file mode 100644
index 0000000..29907d0
--- /dev/null
+++ b/services/nuldoc/nuldoc-src/components/TableOfContents.tsx
@@ -0,0 +1,33 @@
+import { TocEntry, TocRoot } from "../djot/document.ts";
+
+type Props = {
+ toc: TocRoot;
+};
+
+export default function TableOfContents({ toc }: Props) {
+ return (
+ <nav className="toc">
+ <h2>目次</h2>
+ <ul>
+ {toc.entries.map((entry, index) => (
+ <TocEntryComponent key={String(index)} entry={entry} />
+ ))}
+ </ul>
+ </nav>
+ );
+}
+
+function TocEntryComponent({ entry }: { entry: TocEntry }) {
+ return (
+ <li>
+ <a href={`#${entry.id}`}>{entry.text}</a>
+ {entry.children.length > 0 && (
+ <ul>
+ {entry.children.map((child, index) => (
+ <TocEntryComponent key={String(index)} entry={child} />
+ ))}
+ </ul>
+ )}
+ </li>
+ );
+}
diff --git a/services/nuldoc/nuldoc-src/components/TagList.tsx b/services/nuldoc/nuldoc-src/components/TagList.tsx
new file mode 100644
index 0000000..86ee70b
--- /dev/null
+++ b/services/nuldoc/nuldoc-src/components/TagList.tsx
@@ -0,0 +1,18 @@
+import { Config, getTagLabel } from "../config.ts";
+
+type Props = {
+ tags: string[];
+ config: Config;
+};
+
+export default function TagList({ tags, config }: Props) {
+ return (
+ <ul className="entry-tags">
+ {tags.map((slug) => (
+ <li className="tag" key={slug}>
+ {getTagLabel(config, slug)}
+ </li>
+ ))}
+ </ul>
+ );
+}
diff --git a/services/nuldoc/nuldoc-src/components/utils.ts b/services/nuldoc/nuldoc-src/components/utils.ts
new file mode 100644
index 0000000..14059b5
--- /dev/null
+++ b/services/nuldoc/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();
+}