Migrate blog from hardcoded TSX posts to MDX with auto-population

- Add gray-matter + next-mdx-remote for MDX rendering
- Create content/blog/ with .mdx files and frontmatter
- Add dynamic [slug] route and blog utility (src/lib/blog.ts)
- Blog index auto-populates from MDX files, sorted newest first
- Upgrade React to 19 for next-mdx-remote v6 compatibility
- Convert existing posts: website-relaunch, smirk-wallet, bauhaus-echo, crypto-checkout
This commit is contained in:
jwinterm
2026-03-10 10:46:41 -04:00
parent 3001d4bbe1
commit 2b9b1bf429
12 changed files with 1505 additions and 375 deletions
+56
View File
@@ -0,0 +1,56 @@
import { Header } from "@/components/layout/header";
import { MobileNav } from "@/components/layout/mobile-nav";
import Link from "next/link";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { MDXRemote } from "next-mdx-remote/rsc";
import { getAllSlugs, getPost } from "@/lib/blog";
type Props = { params: Promise<{ slug: string }> };
export async function generateStaticParams() {
return getAllSlugs().map((slug) => ({ slug }));
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = getPost(slug);
if (!post) return {};
return {
title: `${post.title} | Such Software Blog`,
description: post.excerpt,
alternates: { canonical: `/blog/${slug}` },
};
}
export default async function BlogPost({ params }: Props) {
const { slug } = await params;
const post = getPost(slug);
if (!post) notFound();
return (
<main className="relative min-h-screen flex flex-col items-center bg-background text-foreground">
<Header />
<article id="main-content" className="z-10 w-full max-w-4xl mx-auto py-20 px-4 pb-24 md:pb-20">
<Link href="/blog" className="text-sm text-muted-foreground hover:text-emerald-600 dark:hover:text-emerald-400 mb-8 inline-flex items-center gap-2 transition-colors">
← Back to Blog
</Link>
<div className="glass-card mt-6">
<header className="mb-8 pb-6 border-b border-border">
<time className="text-sm text-muted-foreground">{post.date}</time>
<h1 className="text-3xl md:text-4xl font-bold mt-2">{post.title}</h1>
{post.excerpt && (
<p className="text-muted-foreground mt-3">{post.excerpt}</p>
)}
</header>
<div className="prose dark:prose-invert max-w-none">
<MDXRemote source={post.content} />
</div>
</div>
</article>
<MobileNav />
</main>
);
}
@@ -1,97 +0,0 @@
import { Header } from "@/components/layout/header";
import { MobileNav } from "@/components/layout/mobile-nav";
import Link from "next/link";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Bauhaus Echo is Live | Such Software Blog",
description: "Our first mobile game is available on App Store, Google Play, itch.io, and the web. A visual memory puzzle inspired by Bauhaus design.",
alternates: { canonical: "/blog/bauhaus-echo-launch" },
};
export default function BauhausEchoLaunchPost() {
return (
<main className="relative min-h-screen flex flex-col items-center bg-background text-foreground">
<Header />
<article id="main-content" className="z-10 w-full max-w-4xl mx-auto py-20 px-4 pb-24 md:pb-20">
<Link href="/blog" className="text-sm text-muted-foreground hover:text-emerald-600 dark:hover:text-emerald-400 mb-8 inline-flex items-center gap-2 transition-colors">
← Back to Blog
</Link>
<div className="glass-card mt-6">
<header className="mb-8 pb-6 border-b border-border">
<time className="text-sm text-muted-foreground">March 4, 2026</time>
<h1 className="text-3xl md:text-4xl font-bold mt-2">Bauhaus Echo is Live</h1>
<p className="text-muted-foreground mt-3">A visual memory puzzle inspired by Bauhaus design — now on every platform</p>
</header>
<div className="prose dark:prose-invert max-w-none">
<p>
Bauhaus Echo is now available on the{" "}
<a href="https://apps.apple.com/us/app/bauhaus-echo/id6759010657" target="_blank" rel="noopener noreferrer">App Store</a>,{" "}
<a href="https://play.google.com/store/apps/details?id=com.suchsoftware.bauhausecho" target="_blank" rel="noopener noreferrer">Google Play</a>,{" "}
<a href="https://suchsoftware.itch.io/bauhaus-echo" target="_blank" rel="noopener noreferrer">itch.io</a>, and{" "}
<a href="https://bauhaus.such.software" target="_blank" rel="noopener noreferrer">the web</a>.
It is a visual memory game where you memorize tile arrangements, watch them shuffle, and
reconstruct the original pattern.
</p>
<h2>The Concept</h2>
<p>
The game shows you a grid of colorful geometric tiles inspired by Bauhaus design. You get a
few seconds to memorize their positions. Then the tiles spin, shrink, and scatter to the
bottom of the screen. Your job is to drag them back to where they belong.
</p>
<h2>Four Game Modes</h2>
<ul>
<li><strong>Classic</strong> — The core experience. Memorize and reconstruct.</li>
<li><strong>Speed</strong> — Tiles appear one at a time. Place each before the next drops.</li>
<li><strong>Endless</strong> — Speed mode that loops forever. See how long you last.</li>
<li><strong>Memory</strong> — Traditional card-matching pairs.</li>
</ul>
<p>
Each mode supports 2×2 through 5×5 grids and three difficulty levels. Global leaderboards
let you compete against other players across all platforms.
</p>
<h2>500 Bauhaus Tiles</h2>
<p>
The tile collection features 500 hand-curated geometric designs drawn from the Bauhaus
aesthetic — bold shapes, primary colors, clean lines. Each tile has beveled 3D edges and
the game board is styled like a wood-and-felt craft table.
</p>
<h2>Tech Stack</h2>
<p>
Built with{" "}
<a href="https://libgdx.com/" target="_blank" rel="noopener noreferrer">libGDX</a>,
a cross-platform Java game framework. One codebase runs on Android, iOS (via RoboVM),
desktop (LWJGL3), and web (via GWT). The tile assets are packed into a TextureAtlas
for efficient GPU rendering.
</p>
<h2>Get It</h2>
<ul>
<li>
<strong>Mobile (free with ads)</strong> —{" "}
<a href="https://apps.apple.com/us/app/bauhaus-echo/id6759010657" target="_blank" rel="noopener noreferrer">App Store</a>{" | "}
<a href="https://play.google.com/store/apps/details?id=com.suchsoftware.bauhausecho" target="_blank" rel="noopener noreferrer">Google Play</a>
</li>
<li>
<strong>Desktop ($2, no ads)</strong> —{" "}
<a href="https://suchsoftware.itch.io/bauhaus-echo" target="_blank" rel="noopener noreferrer">itch.io</a>
</li>
<li>
<strong>Web (free)</strong> —{" "}
<a href="https://bauhaus.such.software" target="_blank" rel="noopener noreferrer">bauhaus.such.software</a>
</li>
</ul>
</div>
</div>
</article>
<MobileNav />
</main>
);
}
+5 -29
View File
@@ -3,6 +3,7 @@ import { MobileNav } from "@/components/layout/mobile-nav";
import { Card } from "@repo/ui/components/card";
import Link from "next/link";
import type { Metadata } from "next";
import { getAllPosts } from "@/lib/blog";
export const metadata: Metadata = {
title: "Blog | Such Software",
@@ -10,34 +11,9 @@ export const metadata: Metadata = {
alternates: { canonical: "/blog" },
};
// For now, hardcoded posts. Can migrate to MDX/CMS later.
const posts = [
{
slug: "smirk-wallet-release",
title: "Smirk Wallet v0.1.6",
date: "2026-02-06",
excerpt: "Non-custodial browser extension for social tipping with BTC, LTC, XMR, WOW, and GRIN. Now on GitHub, Chrome Web Store pending.",
draft: false,
},
{
slug: "website-relaunch-2026",
title: "Website Relaunch",
date: "2026-02-06",
excerpt: "We rebuilt such.software from the ground up. Here is what we used and why.",
draft: false,
},
{
slug: "bauhaus-echo-launch",
title: "Bauhaus Echo is Live",
date: "2026-03-04",
excerpt: "Our first mobile game is available on App Store, Google Play, itch.io, and the web. A visual memory puzzle inspired by Bauhaus design.",
draft: false,
},
];
const publishedPosts = posts.filter((p) => !p.draft);
export default function BlogPage() {
const posts = getAllPosts();
return (
<main className="relative min-h-screen flex flex-col items-center bg-background text-foreground">
<Header />
@@ -63,11 +39,11 @@ export default function BlogPage() {
/>
</div>
{publishedPosts.length === 0 ? (
{posts.length === 0 ? (
<p className="text-muted-foreground">No posts yet. Check back soon.</p>
) : (
<div className="space-y-6">
{publishedPosts.map((post) => (
{posts.map((post) => (
<Link key={post.slug} href={`/blog/${post.slug}`}>
<Card className="glass-card hover:border-primary/30 transition-colors cursor-pointer">
<time className="text-sm text-muted-foreground">{post.date}</time>
@@ -1,117 +0,0 @@
import { Header } from "@/components/layout/header";
import { MobileNav } from "@/components/layout/mobile-nav";
import Link from "next/link";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Smirk Wallet v0.1.6 | Such Software Blog",
description: "Announcing Smirk Wallet, a non-custodial browser extension for social tipping with BTC, LTC, XMR, WOW, and GRIN.",
alternates: { canonical: "/blog/smirk-wallet-release" },
};
export default function SmirkWalletReleasePost() {
return (
<main className="relative min-h-screen flex flex-col items-center bg-background text-foreground">
<Header />
<article id="main-content" className="z-10 w-full max-w-4xl mx-auto py-20 px-4 pb-24 md:pb-20">
<Link href="/blog" className="text-sm text-muted-foreground hover:text-emerald-600 dark:hover:text-emerald-400 mb-8 inline-flex items-center gap-2 transition-colors">
← Back to Blog
</Link>
<div className="glass-card mt-6">
<header className="mb-8 pb-6 border-b border-border">
<time className="text-sm text-muted-foreground">February 6, 2026</time>
<h1 className="text-3xl md:text-4xl font-bold mt-2">Smirk Wallet v0.1.6</h1>
<p className="text-muted-foreground mt-3">Non-custodial social tipping for Telegram and Discord</p>
</header>
<div className="prose dark:prose-invert max-w-none">
<p>
Smirk Wallet is a non-custodial browser extension that lets you tip people cryptocurrency
by their Telegram or Discord username. We have shipped six releases and the extension is
available now on GitHub.
</p>
<h2>What It Does</h2>
<p>
One 12-word seed phrase gives you addresses for five currencies: Bitcoin, Litecoin, Monero,
Wownero, and Grin. Link your Telegram or Discord account on smirk.cash, and anyone running
Smirk can send you crypto by username.
</p>
<p>
Tips are encrypted with the recipient's public key. Only they can claim. If a tip goes
unclaimed, the sender can claw it back. No funds get lost.
</p>
<h2>Non-Custodial by Design</h2>
<p>
Your private spend keys are generated locally and never leave your browser. They are
encrypted with your password. We store only public keys and view keys (for Monero/Wownero
balance scanning). We cannot move your funds.
</p>
<h2>What We Built</h2>
<ul>
<li>Browser extension (TypeScript, Preact, Vite)</li>
<li>Backend API (Rust, Axum, PostgreSQL)</li>
<li>Light wallet servers for XMR and WOW</li>
<li>Grin wallet with client-side WASM</li>
<li>Telegram and Discord bots for tip notifications</li>
<li>User dashboard at smirk.cash</li>
</ul>
<h2>Chrome Web Store Status</h2>
<p>
Our initial submission was rejected for requesting an unused permission (activeTab).
We had included it for a planned feature that was not yet implemented. The fix is already
in v0.1.6. We have removed the permission and resubmitted.
</p>
<p>
In the meantime, you can install manually from{" "}
<a href="https://github.com/Such-Software/smirk-extension/releases" target="_blank" rel="noopener noreferrer">
GitHub Releases
</a>
. Firefox support is also available.
</p>
<h2>Open Source</h2>
<p>
The extension and WASM libraries are open source on GitHub:
</p>
<ul>
<li>
<a href="https://github.com/Such-Software/smirk-extension" target="_blank" rel="noopener noreferrer">
smirk-extension
</a>
</li>
<li>
<a href="https://github.com/Such-Software/monero-oxide" target="_blank" rel="noopener noreferrer">
monero-oxide
</a>{" "}
(Monero WASM)
</li>
</ul>
<h2>Get Started</h2>
<p>
Visit{" "}
<a href="https://smirk.cash" target="_blank" rel="noopener noreferrer">
smirk.cash
</a>{" "}
to learn more, or join the community on{" "}
<a href="https://t.me/smirkwallet" target="_blank" rel="noopener noreferrer">
Telegram
</a>{" "}
or{" "}
<a href="https://discord.gg/7EnsaWTm6C" target="_blank" rel="noopener noreferrer">
Discord
</a>
.
</p>
</div>
</div>
</article>
<MobileNav />
</main>
);
}
@@ -1,79 +0,0 @@
import { Header } from "@/components/layout/header";
import { MobileNav } from "@/components/layout/mobile-nav";
import Link from "next/link";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Website Relaunch | Such Software Blog",
description: "We rebuilt such.software from the ground up. Here is what we used and why.",
alternates: { canonical: "/blog/website-relaunch-2026" },
};
export default function WebsiteRelaunchPost() {
return (
<main className="relative min-h-screen flex flex-col items-center bg-background text-foreground">
<Header />
<article id="main-content" className="z-10 w-full max-w-4xl mx-auto py-20 px-4 pb-24 md:pb-20">
<Link href="/blog" className="text-sm text-muted-foreground hover:text-emerald-600 dark:hover:text-emerald-400 mb-8 inline-flex items-center gap-2 transition-colors">
← Back to Blog
</Link>
<div className="glass-card mt-6">
<header className="mb-8 pb-6 border-b border-border">
<time className="text-sm text-muted-foreground">February 6, 2026</time>
<h1 className="text-3xl md:text-4xl font-bold mt-2">Website Relaunch</h1>
<p className="text-muted-foreground mt-3">New design, new infrastructure, same focus on quality</p>
</header>
<div className="prose dark:prose-invert max-w-none">
<p>
We rebuilt such.software. New design, new infrastructure, same focus on performance and accessibility.
</p>
<h2>Stack</h2>
<ul>
<li><strong>Framework:</strong> Next.js 15 (App Router)</li>
<li><strong>Language:</strong> TypeScript</li>
<li><strong>Styling:</strong> Tailwind CSS with CSS variables for theming</li>
<li><strong>Animation:</strong> Framer Motion</li>
<li><strong>Monorepo:</strong> Turborepo + pnpm workspaces</li>
<li><strong>Testing:</strong> Playwright with axe-core for accessibility</li>
<li><strong>Bot Protection:</strong> Cloudflare Turnstile</li>
<li><strong>Email:</strong> Resend</li>
</ul>
<h2>Procedural Graphics</h2>
<p>
The site features two custom procedural animations: NebulaField and DataGrid. Both are
SVG-based, theme-aware, and respect prefers-reduced-motion. NebulaField renders animated
data streams flowing toward a central icon using quadratic Bezier curves on the homepage.
DataGrid draws a pulsing grid with traveling data packets on the Services page.
</p>
<h2>Accessibility</h2>
<p>
We run 42 automated accessibility tests across Chromium, Firefox, and WebKit on every build.
Tests cover WCAG 2.2 AA compliance, heading hierarchy, color contrast, keyboard navigation,
and form accessibility. Lighthouse scores are 99-100 across all pages.
</p>
<h2>Performance</h2>
<p>
The NebulaField component is lazy loaded with next/dynamic and requestIdleCallback. Canvas
rendering is throttled. Fonts are loaded via next/font for zero layout shift. Images use
next/image for automatic optimization.
</p>
<h2>Structure</h2>
<p>
The site now has Products, Services, Blog, and About sections. Products showcases our
standalone offerings: Webshops, Custom Websites, Smirk Wallet, and Bauhaus Echo. Services
covers our partnership model and consulting work. Mobile users get a bottom navigation bar.
</p>
</div>
</div>
</article>
<MobileNav />
</main>
);
}
+64
View File
@@ -0,0 +1,64 @@
import fs from "fs";
import path from "path";
import matter from "gray-matter";
const CONTENT_DIR = path.join(process.cwd(), "content", "blog");
export type PostMeta = {
slug: string;
title: string;
date: string;
excerpt: string;
draft: boolean;
};
export type Post = PostMeta & {
content: string;
};
export function getAllPosts(): PostMeta[] {
if (!fs.existsSync(CONTENT_DIR)) return [];
return fs
.readdirSync(CONTENT_DIR)
.filter((f) => f.endsWith(".mdx"))
.map((filename) => {
const slug = filename.replace(/\.mdx$/, "");
const raw = fs.readFileSync(path.join(CONTENT_DIR, filename), "utf-8");
const { data } = matter(raw);
return {
slug,
title: data.title ?? slug,
date: data.date ?? "",
excerpt: data.excerpt ?? "",
draft: data.draft === true,
};
})
.filter((p) => !p.draft)
.sort((a, b) => (a.date > b.date ? -1 : 1));
}
export function getPost(slug: string): Post | null {
const filePath = path.join(CONTENT_DIR, `${slug}.mdx`);
if (!fs.existsSync(filePath)) return null;
const raw = fs.readFileSync(filePath, "utf-8");
const { data, content } = matter(raw);
return {
slug,
title: data.title ?? slug,
date: data.date ?? "",
excerpt: data.excerpt ?? "",
draft: data.draft === true,
content,
};
}
export function getAllSlugs(): string[] {
if (!fs.existsSync(CONTENT_DIR)) return [];
return fs
.readdirSync(CONTENT_DIR)
.filter((f) => f.endsWith(".mdx"))
.map((f) => f.replace(/\.mdx$/, ""));
}