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
2 changes: 1 addition & 1 deletion .vscode/extensions.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
"recommendations": ["oxc.oxc-vscode"]
"recommendations": ["oxc.oxc-vscode", "unifiedjs.vscode-mdx"]
}
2 changes: 1 addition & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"[markdown][yaml][json][typescript][javascript][typescriptreact][javascriptreact][css]": {
"[markdown][yaml][json][typescript][javascript][typescriptreact][javascriptreact][css][mdx]": {
"editor.defaultFormatter": "oxc.oxc-vscode"
}
}
1 change: 1 addition & 0 deletions app/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/inter";
@plugin "@tailwindcss/typography";

@custom-variant dark (&:is(.dark *));

Expand Down
28 changes: 25 additions & 3 deletions app/components/NavBar.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { SiDiscord, SiGithub } from "@icons-pack/react-simple-icons";
import { ArrowUpRight, Book, Menu, X, LayoutDashboard } from "lucide-react";
import { ArrowUpRight, Book, Menu, X, LayoutDashboard, NotebookPen } from "lucide-react";
import { useEffect, useState } from "react";
import { Link, useLocation } from "react-router";
import BrandIcon from "~/components/BrandIcon";
Expand All @@ -9,8 +9,15 @@ import { cn } from "~/lib/utils";

function NavBar() {
const [open, setOpen] = useState(false);
const [scrolled, setScrolled] = useState(false);
const location = useLocation();

useEffect(() => {
const handleScroll = () => setScrolled(window.scrollY > 10);
window.addEventListener("scroll", handleScroll, { passive: true });
Comment thread
niclimcy marked this conversation as resolved.
return () => window.removeEventListener("scroll", handleScroll);
}, []);

const openNavBar = (open: boolean) => {
if (open) {
document.body.classList.add("overflow-hidden");
Expand Down Expand Up @@ -38,8 +45,9 @@ function NavBar() {
return (
<nav
className={cn(
"sticky top-0 z-10 w-full border-b backdrop-blur-sm",
open ? "bg-background" : "bg-background/70",
"sticky top-0 z-10 w-full border-b",
scrolled && "backdrop-blur-sm",
open ? "bg-background" : scrolled ? "bg-background/70" : "bg-background/95",
)}
>
<div className="relative mx-auto flex items-center justify-between p-2 md:container">
Expand Down Expand Up @@ -103,6 +111,20 @@ function NavBar() {
</span>
</Link>

<Link
to="/blog"
className={buttonVariants({
variant: "ghost",
size: "lg",
className: "w-full justify-start md:w-auto md:justify-center",
})}
>
<span className="flex items-center gap-2">
<NotebookPen />
Blog
</span>
</Link>

<Link
to="https://github.com/wpbs-rs"
target="_blank"
Expand Down
2 changes: 1 addition & 1 deletion app/components/ui/card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ function Card({
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-6 overflow-hidden rounded-xl bg-card py-6 text-sm text-card-foreground shadow-xs ring-1 ring-foreground/10 has-[>img:first-child]:pt-0 data-[size=sm]:gap-4 data-[size=sm]:py-4 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
"group/card flex flex-col gap-6 overflow-hidden rounded-xl bg-card/50 py-6 text-sm text-card-foreground shadow-xs ring-1 ring-foreground/10 has-[>img:first-child]:pt-0 data-[size=sm]:gap-4 data-[size=sm]:py-4 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className,
)}
{...props}
Expand Down
8 changes: 8 additions & 0 deletions app/lib/format-date.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
const formatDate = new Intl.DateTimeFormat("en-GB", {
year: "numeric",
month: "short",
day: "numeric",
weekday: "long",
});

export default formatDate;
136 changes: 136 additions & 0 deletions app/lib/mdx.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import fs from "fs";
import { bundleMDX } from "mdx-bundler";
import path from "path";
import rehypePrettyCode from "rehype-pretty-code";
import remarkFrontmatter from "remark-frontmatter";
import remarkMdxFrontmatter from "remark-mdx-frontmatter";
import * as z from "zod";

Comment thread
niclimcy marked this conversation as resolved.
const frontMatterSchema = z.object({
title: z.string(),
description: z.string(),
published: z.coerce.date(),
author: z.string(),
heroImage: z.string().optional(),
heroImageAlt: z.string().optional(),
tags: z.array(z.string()).default([]),
});

export type FrontMatter = z.infer<typeof frontMatterSchema>;

function getComponentFiles(): Record<string, string> {
const componentsPath = path.join(process.cwd(), "app/components");

function readFilesRecursively(dir: string): Record<string, string> {
const entries = fs.readdirSync(dir, { withFileTypes: true });
return entries.reduce<Record<string, string>>((acc, entry) => {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
Object.assign(acc, readFilesRecursively(fullPath));
} else if (entry.isFile()) {
const relativePath = path.relative(componentsPath, fullPath);
const normalizedPath = relativePath.replaceAll("\\", "/");
const content = fs.readFileSync(fullPath, "utf8");
acc["../app/components/" + normalizedPath] = content;
}
Comment thread
niclimcy marked this conversation as resolved.
return acc;
}, {});
}
Comment thread
niclimcy marked this conversation as resolved.

try {
return readFilesRecursively(componentsPath);
} catch (e) {
console.warn("Components directory not found, continuing without components", e);
}
return {};
}

// Run once and cache
const componentFiles = getComponentFiles();

function resolvePostsPath(slug: string): string {
const postPath = path.join(process.cwd(), "posts", `${slug}.mdx`);
if (fs.existsSync(postPath)) return postPath;
throw new Error(`Post not found for slug: ${slug}`);
}

function extractSynopsis(source: string, maxLength = 500): string {
const cleaned = source
// Remove frontmatter block
.replace(/^---[\s\S]*?---/, "")
// Remove fenced code blocks (must come before inline code)
.replace(/```[\s\S]*?```/g, "")
// Remove import/export statements
.replace(/^(import|export).*$/gm, "")
// Remove JSX expressions
.replace(/\{[^}]*\}/g, "")
// Remove JSX tags
.replace(/<[^>]+>/g, "")
// Remove markdown headings, bold, italic, links, inline code
.replace(/#{1,6}\s+/g, "")
.replace(/(\*\*|__)(.*?)\1/g, "$2")
.replace(/(\*|_)(.*?)\1/g, "$2")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/`[^`]*`/g, "")
// Collapse whitespace
.replace(/\s+/g, " ")
.trim();

if (cleaned.length <= maxLength) return cleaned;

// Slice and avoid cutting mid-word
return cleaned.slice(0, maxLength).replace(/\s+\S*$/, "");
}

export async function getPostBySlug(slug: string) {
try {
const postPath = resolvePostsPath(slug);
const source = fs.readFileSync(postPath, "utf8");
const synopsis = extractSynopsis(source);
const { code, frontmatter } = await bundleMDX({
source,
files: componentFiles,
mdxOptions(options) {
options.remarkPlugins = [remarkFrontmatter, remarkMdxFrontmatter];
options.rehypePlugins = [rehypePrettyCode];
return options;
},
});
const parsed = frontMatterSchema.safeParse(frontmatter);

if (!parsed.success) {
console.error(`Invalid frontmatter in "${slug}":`, parsed.error.message);
return null;
}
return { code, frontmatter: parsed.data, synopsis };
} catch (err) {
console.error("Error processing MDX:", err);
return null;
}
}

export function getPostSlugs(): string[] {
const postsDir = path.join(process.cwd(), "posts");
try {
const files = fs.readdirSync(postsDir);
return files.filter((file) => file.endsWith(".mdx")).map((file) => file.replace(/\.mdx$/, ""));
} catch (err) {
console.error("Error reading posts directory:", err);
return [];
}
}

export async function getPosts(): Promise<
{ slug: string; frontmatter: FrontMatter; synopsis: string }[]
> {
const slugs = getPostSlugs();
const posts = await Promise.all(
slugs.map(async (slug) => {
const post = await getPostBySlug(slug);
return post ? [{ slug, frontmatter: post.frontmatter, synopsis: post.synopsis }] : [];
}),
);
return posts
.flat()
.sort((a, b) => b.frontmatter.published.getTime() - a.frontmatter.published.getTime());
}
3 changes: 2 additions & 1 deletion app/routes.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { type RouteConfig, index, route } from "@react-router/dev/routes";
import { type RouteConfig, index, route, prefix } from "@react-router/dev/routes";

export default [
index("routes/home.tsx"),
route("/docs", "routes/docs.tsx"),
route("/dashboard", "routes/dashboard.tsx"),
...prefix("blog", [index("routes/blog/home.tsx"), route(":slug", "routes/blog/$slug.tsx")]),
] satisfies RouteConfig;
89 changes: 89 additions & 0 deletions app/routes/blog/$slug.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { MDXProvider } from "@mdx-js/react";
import { Calendar, UserPen } from "lucide-react";
import { getMDXComponent } from "mdx-bundler/client";
import { useMemo } from "react";
import { useLoaderData } from "react-router";
import { Badge } from "~/components/ui/badge";
import { Separator } from "~/components/ui/separator";
import formatDate from "~/lib/format-date";
import { getPostBySlug } from "~/lib/mdx";

import type { Route } from "./+types/$slug";

export async function loader({ params }: { params: { slug: string } }) {
const post = await getPostBySlug(params.slug);
if (!post) {
throw new Response("Post not found", { status: 404 });
}
if (!post.code) {
throw new Response("Post content is missing", { status: 500 });
}

return {
code: post.code,
frontmatter: post.frontmatter,
};
}

export function meta({ loaderData }: Route.MetaArgs) {
return [
{ title: `${loaderData.frontmatter.title} - wpbs` },
{ name: "description", content: loaderData.frontmatter.description },
{ property: "og:title", content: `${loaderData.frontmatter.title} - wpbs` },
{ property: "og:description", content: loaderData.frontmatter.description },
{ property: "og:type", content: "article" },
...(loaderData.frontmatter.heroImage
? [{ property: "og:image", content: loaderData.frontmatter.heroImage }]
: []),
];
}

export default function Post() {
const { code, frontmatter } = useLoaderData<typeof loader>();
const Component = useMemo(() => getMDXComponent(code), [code]);

return (
<MDXProvider>
<main className="py-8">
<div className="container mx-auto w-full">
<article className="prose max-w-none px-6 dark:prose-invert">
<h1 className="mb-0">{frontmatter.title}</h1>
<div className="my-2 flex flex-wrap items-center gap-2 text-sm">
<div className="flex items-center gap-1">
<Calendar className="size-4" />
<time>{formatDate.format(frontmatter.published)}</time>
</div>
<div className="flex items-center gap-1">
<UserPen className="size-4" />
<span>{frontmatter.author}</span>
</div>
</div>
{frontmatter.tags.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1">
{frontmatter.tags.map((tag) => (
<Badge key={tag} variant="outline">
{tag}
</Badge>
))}
</div>
)}
<Separator />
{frontmatter.heroImage && (
<figure>
<img
src={frontmatter.heroImage}
alt={frontmatter.heroImageAlt ?? frontmatter.title}
className="max-h-100"
Comment thread
niclimcy marked this conversation as resolved.
/>
{frontmatter.heroImageAlt && (
<figcaption className="mt-2">{frontmatter.heroImageAlt}</figcaption>
)}
</figure>
)}
<Component />
</article>
</div>
</main>
</MDXProvider>
);
}
Loading