diff --git a/README.md b/README.md index e215bc4..006aeb3 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,81 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# Ephemeris -## Getting Started +Source of [blog.hangerthem.com](https://blog.hangerthem.com) — my personal blog and portfolio. Posts are plain Markdown files rendered to static HTML at build time by a custom [unified](https://unifiedjs.com/) pipeline. -First, run the development server: +## Features + +- **Fully static** — every post, tag and category page is pre-rendered with Next.js (App Router, React Server Components) +- **Custom Markdown extensions** via small rehype plugins in [`lib/`](lib/): + - Callouts: `> [!TIP]`, `> [!WARNING]`, `> [!INFO]`, `> [!QUOTE]` + - Highlights: `==marked text==` + - Definition lists (a term line followed by `: definition` lines) + - Code blocks with language label, line numbers and a copy button +- **Math** with KaTeX and **diagrams** with Mermaid, rendered to SVG at build time — no client-side JS for either +- **Table of contents** generated from `h2`/`h3` headings with scroll-spy +- **SEO** — canonical URLs, Open Graph/Twitter cards, generated OG images, JSON-LD, sitemap, robots.txt and an Atom feed +- Works without JavaScript; JS-only UI (like copy buttons) is hidden when it's unavailable + +## Stack + +Next.js 16 · React 19 · TypeScript · Tailwind CSS 4 · unified (remark/rehype) · highlight.js · KaTeX · Mermaid (via Puppeteer) · Bun · oxlint/oxfmt + +## Getting started ```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev +bun install +SITE_URL=http://localhost:3000 bun dev ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +| Script | Purpose | +| ------------------ | ------------------------------------ | +| `bun dev` | Development server | +| `bun run build` | Production build (static generation) | +| `bun start` | Serve the production build | +| `bun run lint` | oxlint + formatting check | +| `bun run lint:fix` | Auto-fix lint issues and format | -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +### Environment variables -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +| Variable | Required | Description | +| ---------------- | -------- | ---------------------------------------------------------------- | +| `SITE_URL` | yes | Public origin, e.g. `https://blog.hangerthem.com` | +| `SOURCE_COMMIT` | no | Commit hash shown in the sidebar (falls back to `git rev-parse`) | +| `GIT_ORIGIN_URL` | no | Repository URL; enables the "Source Code" link and commit link | -## Learn More +## Writing posts -To learn more about Next.js, take a look at the following resources: +Posts live in [`content/posts/`](content/posts/); the file name is the URL slug. Standalone pages (About, Projects, Setup) live in [`content/pages/`](content/pages/). -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +```yaml +--- +title: 'Tessera: A Privacy-First Card Wallet' +description: 120–160 characters work best for search results. +date: 2026-09-18 12:00:00 +0200 +updated: 2026-09-18 12:30:00 +0200 # optional +category: Projects +tags: [projects, pwa, privacy] +coverImage: tessera-cover.png # optional, from public/posts/, 1200×630 recommended +coverImageAlt: Tessera logo next to two loyalty cards +math: true # optional, enables KaTeX +mermaid: true # optional, enables Mermaid diagrams +draft: true # optional, only rendered in development +--- +``` -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +Frontmatter is validated at build time, so a missing or malformed field fails the build with the file name instead of producing a broken page. -## Deploy on Vercel +Images go in [`public/posts/`](public/posts/) and are referenced by file name: `![Alt text](image.png "Optional caption")`. -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +## Project structure -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +``` +app/ Routes, metadata, feed, sitemap and OG image generation +components/ UI split into content/, layout/, post/ and ui/ +content/ Markdown posts and pages +lib/ Markdown pipeline, rehype plugins, tag/category helpers +public/posts/ Post images +``` + +## Deployment + +Deployed with [Nixpacks](https://nixpacks.com/) (see [`nixpacks.toml`](nixpacks.toml)). Mermaid diagrams are rendered with headless Chromium during the build, so the build environment needs Chromium's system libraries and network access to fetch the Mermaid script. diff --git a/app/(withRecent)/categories/[categorySlug]/page.tsx b/app/(withRecent)/categories/[categorySlug]/page.tsx index 3bbaa97..ca7842d 100644 --- a/app/(withRecent)/categories/[categorySlug]/page.tsx +++ b/app/(withRecent)/categories/[categorySlug]/page.tsx @@ -1,8 +1,9 @@ -import { metadata } from '@/app/metadata' +import { pageMetadata } from '@/app/metadata' import { getAllCategories, getCategoryBySlug, getPostsByCategory } from '@/lib/categories' -import { dateFormatter } from '@/utils/time' import type { Metadata } from 'next' -import Link from 'next/link' +import { PostList } from '@/components/post/PostList' +import { notFound } from 'next/navigation' +import { slugify } from '@/utils/slug' type CategoryPageProps = { params: Promise<{ @@ -13,25 +14,22 @@ type CategoryPageProps = { export async function generateStaticParams() { const categories = getAllCategories() return Object.keys(categories).map((category) => ({ - categorySlug: category, + categorySlug: slugify(category), })) } +export const dynamicParams = false + export async function generateMetadata({ params }: CategoryPageProps): Promise { const { categorySlug } = await params const category = getCategoryBySlug(categorySlug) + if (!category) notFound() - return { - title: `${category} | ${metadata.title!.toString()}`, - description: 'List of posts in this category', - openGraph: { - type: 'website', - title: `${category} | ${metadata.title!.toString()}`, - description: 'List of posts in this category', - url: process.env.SITE_URL + `/categories/${categorySlug}`, - siteName: metadata.title!.toString(), - }, - } + return pageMetadata({ + title: category, + description: `Posts in the “${category}” category`, + path: `/categories/${categorySlug}`, + }) } export default async function CategoryPage({ params }: CategoryPageProps) { @@ -39,41 +37,13 @@ export default async function CategoryPage({ params }: CategoryPageProps) { const posts = getPostsByCategory(categorySlug) const category = getCategoryBySlug(categorySlug) - if (!category) { - return ( -
-

Category not found

- Back to categories -
- ) - } + if (!category) notFound() return (
-
~/categories/{categorySlug}
+

~/categories/{categorySlug}

- +
) } diff --git a/app/(withRecent)/categories/page.tsx b/app/(withRecent)/categories/page.tsx index cd9d8ba..b7f28b5 100644 --- a/app/(withRecent)/categories/page.tsx +++ b/app/(withRecent)/categories/page.tsx @@ -3,28 +3,15 @@ import { getAllPostsMeta } from '@/lib/posts' import { slugify } from '@/utils/slug' import type { Metadata } from 'next' import Link from 'next/link' -import { metadata } from '@/app/metadata' -import { dateFormatter } from '@/utils/time' - -export async function generateStaticParams() { - const categories = getAllCategories() - return Object.keys(categories).map((category) => ({ - categorySlug: category, - })) -} +import { PostList } from '@/components/post/PostList' +import { pageMetadata } from '@/app/metadata' export async function generateMetadata(): Promise { - return { - title: `Categories | ${metadata.title!.toString()}`, - description: 'List of categories', - openGraph: { - type: 'website', - title: `Categories | ${metadata.title!.toString()}`, - description: 'List of categories', - url: process.env.SITE_URL + '/categories', - siteName: metadata.title!.toString(), - }, - } + return pageMetadata({ + title: 'Categories', + description: 'All post categories on the blog', + path: '/categories', + }) } export default function CategoriesPage() { @@ -34,7 +21,7 @@ export default function CategoriesPage() { return (
-
~/categories
+

~/categories

    {categoryNames.map((category) => { @@ -52,28 +39,7 @@ export default function CategoriesPage() { ({categoryPosts.length}) -
      - {categoryPosts.map((post) => ( -
    • - - - {post.title} - - {post.date && ( - - )} - -
    • - ))} -
    + ) })} diff --git a/app/(withRecent)/page.tsx b/app/(withRecent)/page.tsx index 9e90794..64b978f 100644 --- a/app/(withRecent)/page.tsx +++ b/app/(withRecent)/page.tsx @@ -1,5 +1,8 @@ import { getAllPostsMeta } from '@/lib/posts' import { Post } from '@/components/post/Post' +import type { Metadata } from 'next' + +export const metadata: Metadata = { alternates: { canonical: '/' } } export default function Homepage() { const posts = getAllPostsMeta() diff --git a/app/(withRecent)/tags/[tagSlug]/page.tsx b/app/(withRecent)/tags/[tagSlug]/page.tsx index aacdd63..22f3cec 100644 --- a/app/(withRecent)/tags/[tagSlug]/page.tsx +++ b/app/(withRecent)/tags/[tagSlug]/page.tsx @@ -1,8 +1,9 @@ -import { metadata } from '@/app/metadata' +import { pageMetadata } from '@/app/metadata' import { getAllTags, getPostsByTag, getTagBySlug } from '@/lib/tags' -import { dateFormatter } from '@/utils/time' import type { Metadata } from 'next' -import Link from 'next/link' +import { PostList } from '@/components/post/PostList' +import { notFound } from 'next/navigation' +import { slugify } from '@/utils/slug' type TagPageProps = { params: Promise<{ @@ -13,25 +14,22 @@ type TagPageProps = { export async function generateStaticParams() { const tags = getAllTags() return Object.keys(tags).map((tag) => ({ - tagSlug: tag, + tagSlug: slugify(tag), })) } +export const dynamicParams = false + export async function generateMetadata({ params }: TagPageProps): Promise { const { tagSlug } = await params const tag = getTagBySlug(tagSlug) + if (!tag) notFound() - return { - title: `${tag} | ${metadata.title!.toString()}`, - description: 'List of posts in this tag', - openGraph: { - type: 'website', - title: `${tag} | ${metadata.title!.toString()}`, - description: 'List of posts in this tag', - url: process.env.SITE_URL + `/tags/${tagSlug}`, - siteName: metadata.title!.toString(), - }, - } + return pageMetadata({ + title: tag, + description: `Posts tagged “${tag}”`, + path: `/tags/${tagSlug}`, + }) } export default async function TagPage({ params }: TagPageProps) { @@ -39,41 +37,13 @@ export default async function TagPage({ params }: TagPageProps) { const posts = getPostsByTag(tagSlug) const tag = getTagBySlug(tagSlug) - if (!tag) { - return ( -
    -

    Tag not found

    - Back to tags -
    - ) - } + if (!tag) notFound() return (
    -
    ~/tags/{tagSlug}
    +

    ~/tags/{tagSlug}

    -
      - {posts.map((post) => ( -
    • - - - {post.title} - - {post.date && ( - - )} - -
    • - ))} -
    +
    ) } diff --git a/app/(withRecent)/tags/page.tsx b/app/(withRecent)/tags/page.tsx index 207f829..4f4e231 100644 --- a/app/(withRecent)/tags/page.tsx +++ b/app/(withRecent)/tags/page.tsx @@ -3,28 +3,11 @@ import { getAllPostsMeta } from '@/lib/posts' import { slugify } from '@/utils/slug' import type { Metadata } from 'next' import Link from 'next/link' -import { metadata } from '@/app/metadata' -import { dateFormatter } from '@/utils/time' - -export async function generateStaticParams() { - const tags = getAllTags() - return Object.keys(tags).map((tag) => ({ - tagSlug: tag, - })) -} +import { PostList } from '@/components/post/PostList' +import { pageMetadata } from '@/app/metadata' export async function generateMetadata(): Promise { - return { - title: `Tags | ${metadata.title!.toString()}`, - description: 'List of tags', - openGraph: { - type: 'website', - title: `Tags | ${metadata.title!.toString()}`, - description: 'List of tags', - url: process.env.SITE_URL + '/tags', - siteName: metadata.title!.toString(), - }, - } + return pageMetadata({ title: 'Tags', description: 'All tags used on the blog', path: '/tags' }) } export default function TagsPage() { @@ -34,7 +17,7 @@ export default function TagsPage() { return (
    -
    ~/tags
    +

    ~/tags

      {tagNames.map((tag) => { @@ -52,28 +35,7 @@ export default function TagsPage() { ({tagPosts.length}) -
        - {tagPosts.map((post) => ( -
      • - - - {post.title} - - {post.date && ( - - )} - -
      • - ))} -
      + ) })} diff --git a/app/about/page.tsx b/app/about/page.tsx index afecf06..8ce158e 100644 --- a/app/about/page.tsx +++ b/app/about/page.tsx @@ -1,35 +1,7 @@ -import { metadata } from '@/app/metadata' -import { TableOfContents } from '@/components/post/TableOfContents' -import { getPostBySlug } from '@/lib/posts' -import type { Metadata } from 'next' +import { MarkdownPage, markdownPageMetadata } from '@/components/content/MarkdownPage' -export async function generateMetadata(): Promise { - const { meta } = await getPostBySlug('about', 'content/pages') - return { - title: `${meta.title} | ${metadata.title!.toString()}`, - description: meta.description, - openGraph: { - type: 'website', - title: `${meta.title} | ${metadata.title!.toString()}`, - description: meta.description, - url: process.env.SITE_URL + `/about`, - siteName: metadata.title!.toString(), - }, - } -} - -export default async function AboutPage() { - const { meta, content, tableOfContents } = await getPostBySlug('about', 'content/pages') - - return ( -
      -
      -

      {meta.title}

      -

      {meta.description}

      -
      {content}
      -
      - - -
      - ) +export const generateMetadata = () => markdownPageMetadata('about') + +export default function AboutPage() { + return } diff --git a/app/feed.xml/route.ts b/app/feed.xml/route.ts index e797839..9115efa 100644 --- a/app/feed.xml/route.ts +++ b/app/feed.xml/route.ts @@ -1,9 +1,10 @@ -import { metadata } from '@/app/metadata' +import { AUTHOR, SITE_NAME, SITE_URL } from '@/app/metadata' import { getAllPostsMeta } from '@/lib/posts' +import type { PostMeta } from '@/types/Post.type' export const dynamic = 'force-static' -const SITE_URL = process.env.SITE_URL +const lastModified = (p: PostMeta) => new Date(p.updated ?? p.date) function escapeXml(str: string) { return str @@ -18,20 +19,23 @@ export async function GET(req: Request) { const posts = getAllPostsMeta() const updated = - posts.length > 0 ? new Date(posts[0].date).toISOString() : new Date().toISOString() + posts.length > 0 + ? new Date(Math.max(...posts.map((p) => lastModified(p).getTime()))).toISOString() + : new Date().toISOString() const entries = posts .map((p) => { const url = `${SITE_URL}/posts/${p.slug}` const date = new Date(p.date).toISOString() + const modified = lastModified(p).toISOString() return ` ${escapeXml(p.title)} ${url} ${date} - ${date} - + ${modified} + ${escapeXml(p.description)} ` }) @@ -40,14 +44,15 @@ export async function GET(req: Request) { const feed = ` ${SITE_URL}/ - ${escapeXml(metadata.title!.toString())} + ${escapeXml(SITE_NAME)} ${updated} - ${escapeXml(metadata.creator!.toString())} + ${escapeXml(AUTHOR.name)} + ${AUTHOR.url} - + - © ${new Date().getFullYear()} Frank Borisjuk + © ${new Date().getFullYear()} ${escapeXml(AUTHOR.name)} ${entries} ` diff --git a/app/layout.tsx b/app/layout.tsx index e05d642..32fc18a 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,5 +1,4 @@ import { Space_Grotesk } from 'next/font/google' -import Script from 'next/script' import { Sidebar } from '@/components/layout/Sidebar' import { MobileNavbar } from '@/components/layout/MobileNavbar' import { metadata } from '@/app/metadata' @@ -18,12 +17,19 @@ export default function RootLayout({ children: React.ReactNode }>) { return ( - -