diff options
| author | nsfisis <nsfisis@gmail.com> | 2025-11-02 17:49:34 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2025-11-02 17:49:34 +0900 |
| commit | 57315c52be96d2a2c013f0cfb0de5429980e301a (patch) | |
| tree | 5d691497772fddfe401cd970ead4e9a74b34ef38 /services/nuldoc/nuldoc-src/components | |
| parent | cf4091a93ed831456e8b30e2a9e1fc2650dcae02 (diff) | |
| download | nsfisis.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')
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"> + {`© ${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={`© ${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(); +} |
