Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
name: Test

on:
push:
branches: [main]
pull_request:

jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: nextjs
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
cache-dependency-path: nextjs/package-lock.json
- run: npm ci
- run: npm test
2 changes: 2 additions & 0 deletions nextjs/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules/
.next/
38 changes: 38 additions & 0 deletions nextjs/app/[year]/[month]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { notFound } from "next/navigation";
import { loadAllPostsSync } from "@/lib/content";
import { enrichPost } from "@/lib/posts";
import { getMonthArchive } from "@/lib/archives";
import { monthName } from "@/lib/dates";
import { ArchiveMonthLayout } from "@/components/ArchiveMonthLayout";

export const dynamic = "force-dynamic";

export async function generateMetadata({
params,
}: {
params: Promise<{ year: string; month: string }>;
}) {
const { year, month } = await params;
const m = parseInt(month, 10);
const label = m >= 1 && m <= 12 ? `${monthName(m)} ${year}` : `${year}/${month}`;
return { title: label };
}

export default async function MonthArchivePage({
params,
}: {
params: Promise<{ year: string; month: string }>;
}) {
const { year: yearStr, month: monthStr } = await params;
const year = parseInt(yearStr, 10);
const month = parseInt(monthStr, 10);

if (isNaN(year) || isNaN(month) || month < 1 || month > 12) notFound();

const allPosts = loadAllPostsSync().map(enrichPost);
const archive = getMonthArchive(allPosts, year, month);

if (!archive) notFound();

return <ArchiveMonthLayout archive={archive} />;
}
34 changes: 34 additions & 0 deletions nextjs/app/[year]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { notFound } from "next/navigation";
import { loadAllPostsSync } from "@/lib/content";
import { enrichPost } from "@/lib/posts";
import { getYearArchive } from "@/lib/archives";
import { ArchiveYearLayout } from "@/components/ArchiveYearLayout";

export const dynamic = "force-dynamic";

export async function generateMetadata({
params,
}: {
params: Promise<{ year: string }>;
}) {
const { year } = await params;
return { title: year };
}

export default async function YearArchivePage({
params,
}: {
params: Promise<{ year: string }>;
}) {
const { year: yearStr } = await params;
const year = parseInt(yearStr, 10);

if (isNaN(year) || year < 2000 || year > 2100) notFound();

const allPosts = loadAllPostsSync().map(enrichPost);
const archive = getYearArchive(allPosts, year);

if (!archive) notFound();

return <ArchiveYearLayout archive={archive} />;
}
15 changes: 15 additions & 0 deletions nextjs/app/about/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { Metadata } from "next";
import { loadPage, renderPageContent } from "@/lib/content";
import { ArticleLayout } from "@/components/ArticleLayout";

export const metadata: Metadata = {
title: "About",
};

export default async function AboutPage() {
const page = loadPage("about.html");
if (!page) return <article><p>Page not found.</p></article>;

const content = await renderPageContent(page.content, page.format);
return <ArticleLayout content={content} />;
}
50 changes: 50 additions & 0 deletions nextjs/app/blog/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { notFound } from "next/navigation";
import { loadAllPostsSync, renderPost } from "@/lib/content";
import { enrichPost } from "@/lib/posts";
import { BlogPostLayout } from "@/components/BlogPostLayout";

export const dynamic = "force-dynamic";

export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const allPosts = loadAllPostsSync().map(enrichPost);
const post = allPosts.find((p) => p.slug === slug);
if (!post) return {};
return {
title: post.frontmatter.title,
...(post.frontmatter.canonical && {
alternates: { canonical: post.frontmatter.canonical },
}),
};
}

export default async function BlogPostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const allPosts = loadAllPostsSync().map(enrichPost);
const index = allPosts.findIndex((p) => p.slug === slug);

if (index === -1) notFound();

const renderedPost = await renderPost(allPosts[index]);
const enrichedRendered = { ...allPosts[index], content: renderedPost.content };

// Posts sorted newest first: previous = older = index+1, next = newer = index-1
const previousPost = index < allPosts.length - 1 ? allPosts[index + 1] : null;
const nextPost = index > 0 ? allPosts[index - 1] : null;

return (
<BlogPostLayout
post={enrichedRendered}
previousPost={previousPost}
nextPost={nextPost}
/>
);
}
79 changes: 79 additions & 0 deletions nextjs/app/feed.atom/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { loadAllPostsSync, renderPost } from "@/lib/content";
import { enrichPost } from "@/lib/posts";
import { siteConfig } from "@/config/site";
import { shortlink } from "@/lib/base60";
import { generateTagId } from "@/lib/tag-id";
import { formatISO, toXmlSchema } from "@/lib/dates";

export const dynamic = "force-dynamic";

function escapeXml(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}

export async function GET() {
const allPosts = loadAllPostsSync().map(enrichPost);
const recentPosts = allPosts.slice(0, 20);

// Render all posts
const renderedPosts = await Promise.all(
recentPosts.map(async (post) => {
const rendered = await renderPost(post);
return { ...post, content: rendered.content };
})
);

const entries = renderedPosts
.map((post) => {
const id = post.frontmatter.atomid || generateTagId(post.globalDate, post.cleanUrl);
const url = post.frontmatter.canonical || `${siteConfig.url}${post.cleanUrl}`;
const shortUrl = shortlink(post.globalDate, siteConfig.shortdomain);
const isoDate = formatISO(post.globalDate);

let dateElements: string;
if (post.frontmatter.updated) {
dateElements = ` <published>${isoDate}</published>\n <updated>${formatISO(post.frontmatter.updated)}</updated>`;
} else {
dateElements = ` <updated>${isoDate}</updated>`;
}

const summary = post.excerpt
? `\n <summary type="html">${escapeXml(post.excerpt)}</summary>`
: "";

return ` <entry>
<id>${escapeXml(id)}</id>
<title>${escapeXml(post.frontmatter.title || "")}</title>
<link href="${escapeXml(url)}"/>
<link rel="shortlink" href="${escapeXml(shortUrl)}"/>
${dateElements}${summary}
<content type="html">${escapeXml(post.content)}</content>
</entry>`;
})
.join("\n");

const xml = `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>${siteConfig.title}</title>
<link href="${siteConfig.url}/feed.atom" rel="self"/>
<link href="${siteConfig.url}"/>
<updated>${toXmlSchema(new Date())}</updated>
<id>${siteConfig.url}</id>
<author>
<name>${siteConfig.author.name}</name>
<uri>${siteConfig.author.url}</uri>
</author>
${entries}
</feed>`;

return new Response(xml, {
headers: {
"Content-Type": "application/atom+xml; charset=utf-8",
},
});
}
15 changes: 15 additions & 0 deletions nextjs/app/feeds/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { Metadata } from "next";
import { loadPage, renderPageContent } from "@/lib/content";
import { ArticleLayout } from "@/components/ArticleLayout";

export const metadata: Metadata = {
title: "Feeds and Following",
};

export default async function FeedsPage() {
const page = loadPage("feeds.md");
if (!page) return <article><p>Page not found.</p></article>;

const content = await renderPageContent(page.content, page.format);
return <ArticleLayout content={content} />;
}
22 changes: 22 additions & 0 deletions nextjs/app/humans.txt/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { toXmlSchema } from "@/lib/dates";

export const dynamic = "force-dynamic";

export function GET() {
const content = `/* TEAM */
Protagonist: Ben Ward
URL: https://benward.uk/about
Twitter: @benward
From: Cambridge, United Kingdom

/* SITE */
Last update: ${toXmlSchema(new Date())}
Language: English (British)
Doctype: HTML5
IDE: Nova, Visual Studio Code, Sublime Text, iA Writer
`;

return new Response(content, {
headers: { "Content-Type": "text/plain" },
});
}
56 changes: 56 additions & 0 deletions nextjs/app/layout.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/* Root layout styles: header, footer, page canvas, site nav */

.header,
.footer,
.main > article,
.main > section {
padding: 32px;
}

.main > article,
.main > section {
background: var(--BenWard-pageColor);
box-shadow: 0 0 6px rgba(0, 0, 0, 0.2);
}

.siteTitle {
font-size: 170%;
margin: 0;
color: var(--BenWard-logoColor);
opacity: 0.25;
transition: opacity .5s ease-out;
}

.siteTitle a:link,
.siteTitle a:visited {
color: inherit;
}

.siteTitle:hover {
opacity: 0.75;
}

.footer {
color: var(--BenWard-footerTextColor);
font-size: 80%;
line-height: 21px;
}

.footer address {
display: inline;
font-style: normal;
}

.footer a:link,
.footer a:visited,
.footer a:hover,
.footer a:focus {
color: var(--BenWard-footerLinkColor);
}

.footnote {
margin-top: 100%;
color: rgba(255,255,255,0.2);
text-shadow: rgba(0, 0, 0, 0.7) -1px 0 2px;
text-align: center;
}
Loading
Loading