Merge pull request 'hotreload' (#1) from hotreload into main

Reviewed-on: http://10.0.0.13:3002/rattatwinko/markdownblog/pulls/1
This commit is contained in:
2025-06-16 15:15:47 +00:00
12 changed files with 451 additions and 87 deletions

95
package-lock.json generated
View File

@@ -10,6 +10,7 @@
"dependencies": {
"@tailwindcss/typography": "^0.5.16",
"autoprefixer": "^10.4.17",
"chokidar": "^4.0.3",
"date-fns": "^3.3.1",
"gray-matter": "^4.0.3",
"next": "14.1.0",
@@ -2785,39 +2786,18 @@
}
},
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"license": "MIT",
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
"glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.6.0"
"readdirp": "^4.0.1"
},
"engines": {
"node": ">= 8.10.0"
"node": ">= 14.16.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/chokidar/node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/chownr": {
@@ -8102,15 +8082,16 @@
}
},
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"license": "MIT",
"dependencies": {
"picomatch": "^2.2.1"
},
"engines": {
"node": ">=8.10.0"
"node": ">= 14.18.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/reflect.getprototypeof": {
@@ -9275,6 +9256,42 @@
"node": ">=14.0.0"
}
},
"node_modules/tailwindcss/node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"license": "MIT",
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
"glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.6.0"
},
"engines": {
"node": ">= 8.10.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/tailwindcss/node_modules/chokidar/node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/tailwindcss/node_modules/postcss-load-config": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz",
@@ -9310,6 +9327,18 @@
}
}
},
"node_modules/tailwindcss/node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"license": "MIT",
"dependencies": {
"picomatch": "^2.2.1"
},
"engines": {
"node": ">=8.10.0"
}
},
"node_modules/tar": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",

View File

@@ -13,6 +13,7 @@
"dependencies": {
"@tailwindcss/typography": "^0.5.16",
"autoprefixer": "^10.4.17",
"chokidar": "^4.0.3",
"date-fns": "^3.3.1",
"gray-matter": "^4.0.3",
"next": "14.1.0",

10
posts/folder/folder.md Normal file
View File

@@ -0,0 +1,10 @@
---
title: "Testing Folders"
date: "2024-03-10"
tags: ["test", "feature"]
summary: "A test post to demonstrate folders"
---
# Folders
i hate TS

0
posts/folder/folder2.md Normal file
View File

22
posts/test-post.md Normal file
View File

@@ -0,0 +1,22 @@
---
title: "Testing Hot Reloading"
date: "2024-03-10"
tags: ["test", "feature"]
summary: "A test post to demonstrate hot reloading and date-based sorting"
---
# Testing Hot Reloading
This is a test post to demonstrate the hot reloading feature of our blog system. When you add or modify a post, the changes should appear immediately without needing to refresh the page.
## Features Being Tested
1. Hot reloading of new posts
2. File creation date sorting
3. Real-time updates
## How It Works
The system uses `chokidar` to watch the `posts/` directory for changes. When a new file is added or modified, the blog automatically updates to show the latest content.
The posts are sorted by their file creation date, so newer posts appear at the top of the list.

View File

@@ -0,0 +1,51 @@
import { NextResponse } from 'next/server';
import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';
import { remark } from 'remark';
import html from 'remark-html';
const postsDirectory = path.join(process.cwd(), 'posts');
// Function to get file creation date
function getFileCreationDate(filePath: string): Date {
const stats = fs.statSync(filePath);
return stats.birthtime;
}
async function getPostBySlug(slug: string) {
const realSlug = slug.replace(/\.md$/, '');
const fullPath = path.join(postsDirectory, `${realSlug}.md`);
const fileContents = fs.readFileSync(fullPath, 'utf8');
const { data, content } = matter(fileContents);
const createdAt = getFileCreationDate(fullPath);
const processedContent = await remark()
.use(html)
.process(content);
return {
slug: realSlug,
title: data.title,
date: data.date,
tags: data.tags || [],
summary: data.summary,
content: processedContent.toString(),
createdAt: createdAt.toISOString(),
};
}
export async function GET(
request: Request,
{ params }: { params: { slug: string[] | string } }
) {
try {
// Support catch-all route: slug can be string or string[]
const slugArr = Array.isArray(params.slug) ? params.slug : [params.slug];
const slugPath = slugArr.join('/');
const post = await getPostBySlug(slugPath);
return NextResponse.json(post);
} catch (error) {
return NextResponse.json({ error: 'Fehler beim Laden des Beitrags' }, { status: 500 });
}
}

View File

@@ -0,0 +1,60 @@
import { NextResponse } from 'next/server';
import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';
import { remark } from 'remark';
import html from 'remark-html';
const postsDirectory = path.join(process.cwd(), 'posts');
// Function to get file creation date
function getFileCreationDate(filePath: string): Date {
const stats = fs.statSync(filePath);
return stats.birthtime;
}
async function getPostByPath(filePath: string, relPath: string) {
const fileContents = fs.readFileSync(filePath, 'utf8');
const { data, content } = matter(fileContents);
const createdAt = getFileCreationDate(filePath);
const processedContent = await remark().use(html).process(content);
return {
type: 'post',
slug: relPath.replace(/\.md$/, ''),
title: data.title,
date: data.date,
tags: data.tags || [],
summary: data.summary,
content: processedContent.toString(),
createdAt: createdAt.toISOString(),
};
}
async function readPostsDir(dir: string, relDir = ''): Promise<any[]> {
const entries = fs.readdirSync(dir, { withFileTypes: true });
const folders: any[] = [];
const posts: any[] = [];
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
const relPath = relDir ? path.join(relDir, entry.name) : entry.name;
if (entry.isDirectory()) {
const children = await readPostsDir(fullPath, relPath);
folders.push({ type: 'folder', name: entry.name, path: relPath, children });
} else if (entry.isFile() && entry.name.endsWith('.md')) {
posts.push(await getPostByPath(fullPath, relPath));
}
}
// Sort posts by creation date (newest first)
posts.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
// Folders first, then posts
return [...folders, ...posts];
}
export async function GET() {
try {
const tree = await readPostsDir(postsDirectory);
return NextResponse.json(tree);
} catch (error) {
return NextResponse.json({ error: 'Fehler beim Laden der Beiträge' }, { status: 500 });
}
}

View File

@@ -5,8 +5,8 @@ import './globals.css';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: 'My Markdown Blog',
description: 'A blog built with Next.js and Markdown',
title: 'Sebastian Zinkls - Blog',
description: 'Ein Blog von Sebastian Zinkl, gebaut mit Next.js und Markdown',
};
export default function RootLayout({
@@ -15,7 +15,7 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
<html lang="en">
<html lang="de">
<body className={inter.className}>{children}</body>
</html>
);

View File

@@ -1,24 +1,126 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { getAllPosts } from '@/lib/markdown';
import { format } from 'date-fns';
export default async function Home() {
const posts = await getAllPosts();
interface Post {
type: 'post';
slug: string;
title: string;
date: string;
tags: string[];
summary: string;
content: string;
createdAt: string;
}
interface Folder {
type: 'folder';
name: string;
path: string;
children: (Folder | Post)[];
}
type Node = Folder | Post;
export default function Home() {
const [tree, setTree] = useState<Node[]>([]);
const [currentPath, setCurrentPath] = useState<string[]>([]);
useEffect(() => {
loadTree();
const interval = setInterval(loadTree, 2000);
return () => clearInterval(interval);
}, []);
const loadTree = async () => {
try {
const response = await fetch('/api/posts');
const data = await response.json();
setTree(data);
} catch (error) {
console.error('Fehler beim Laden der Beiträge:', error);
}
};
// Traverse the tree to the current path
const getCurrentNodes = (): Node[] => {
let nodes: Node[] = tree;
for (const segment of currentPath) {
const folder = nodes.find(
(n) => n.type === 'folder' && n.name === segment
) as Folder | undefined;
if (folder) {
nodes = folder.children;
} else {
break;
}
}
return nodes;
};
const nodes = getCurrentNodes();
// Breadcrumbs
const breadcrumbs = [
{ name: 'Startseite', path: [] },
...currentPath.map((name, idx) => ({
name,
path: currentPath.slice(0, idx + 1),
})),
];
return (
<main className="min-h-screen p-8 max-w-4xl mx-auto">
<h1 className="text-4xl font-bold mb-8">My Blog</h1>
<h1 className="text-4xl font-bold mb-8">Sebastian Zinkls - Blog</h1>
<nav className="mb-6 text-sm text-gray-600 flex gap-2 items-center">
{breadcrumbs.map((bc, idx) => (
<span key={bc.path.join('/') + idx}>
{idx > 0 && <span className="mx-1">/</span>}
<button
className="hover:underline"
onClick={() => setCurrentPath(bc.path)}
disabled={idx === breadcrumbs.length - 1}
>
{bc.name}
</button>
</span>
))}
</nav>
<div className="grid gap-8">
{posts.map((post) => (
{/* Folders */}
{nodes.filter((n) => n.type === 'folder').map((folder: any) => (
<div
key={folder.path}
className="border rounded-lg p-6 bg-gray-50 cursor-pointer hover:bg-gray-100 transition"
onClick={() => setCurrentPath([...currentPath, folder.name])}
>
<span className="font-semibold text-lg">📁 {folder.name}</span>
</div>
))}
{/* Posts */}
{nodes.filter((n) => n.type === 'post').map((post: any) => (
<article key={post.slug} className="border rounded-lg p-6 hover:shadow-lg transition-shadow">
<Link href={`/posts/${post.slug}`}>
<h2 className="text-2xl font-semibold mb-2">{post.title}</h2>
<div className="text-gray-600 mb-4">
{format(new Date(post.date), 'MMMM d, yyyy')}
{post.date ? (
<div>Veröffentlicht: {format(new Date(post.date), 'd. MMMM yyyy')}</div>
) : (
<div className="flex flex-col items-center">
<div className="flex">
<span className="text-2xl animate-spin mr-2"></span>
<span className="text-2xl animate-spin-reverse"></span>
</div>
<div className="text-xl font-bold mt-2">In Bearbeitung</div>
</div>
)}
<div>Erstellt: {format(new Date(post.createdAt), 'd. MMMM yyyy HH:mm')}</div>
</div>
<p className="text-gray-700 mb-4">{post.summary}</p>
<div className="flex gap-2">
{post.tags.map((tag) => (
{post.tags.map((tag: string) => (
<span
key={tag}
className="bg-gray-100 text-gray-800 px-3 py-1 rounded-full text-sm"

View File

@@ -0,0 +1,87 @@
'use client';
import { useEffect, useState } from 'react';
import { format } from 'date-fns';
import Link from 'next/link';
interface Post {
slug: string;
title: string;
date: string;
tags: string[];
summary: string;
content: string;
createdAt: string;
}
export default function PostPage({ params }: { params: { slug: string[] } }) {
const [post, setPost] = useState<Post | null>(null);
// Join the slug array to get the full path
const slugPath = Array.isArray(params.slug) ? params.slug.join('/') : params.slug;
useEffect(() => {
// Initial load
loadPost();
// Set up polling for changes
const interval = setInterval(loadPost, 2000);
// Cleanup
return () => clearInterval(interval);
}, [slugPath]);
const loadPost = async () => {
try {
const response = await fetch(`/api/posts/${encodeURIComponent(slugPath)}`);
const data = await response.json();
setPost(data);
} catch (error) {
console.error('Fehler beim Laden des Beitrags:', error);
}
};
if (!post) {
return <div>Lädt...</div>;
}
return (
<article className="min-h-screen p-8 max-w-4xl mx-auto">
<Link href="/" className="text-blue-600 hover:underline mb-8 inline-block">
Zurück zu den Beiträgen
</Link>
<h1 className="text-4xl font-bold mb-4">{post.title}</h1>
<div className="text-gray-600 mb-8">
{post.date ? (
<div>Veröffentlicht: {format(new Date(post.date), 'd. MMMM yyyy')}</div>
) : (
<div className="flex flex-col items-center">
<div className="flex">
<span className="text-2xl animate-spin mr-2"></span>
<span className="text-2xl animate-spin-reverse"></span>
</div>
<div className="text-xl font-bold mt-2">In Bearbeitung</div>
</div>
)}
<div>Erstellt: {format(new Date(post.createdAt), 'd. MMMM yyyy HH:mm')}</div>
</div>
<div className="flex gap-2 mb-8">
{post.tags.map((tag) => (
<span
key={tag}
className="bg-gray-100 text-gray-800 px-3 py-1 rounded-full text-sm"
>
{tag}
</span>
))}
</div>
<div
className="prose prose-lg max-w-none"
dangerouslySetInnerHTML={{ __html: post.content }}
/>
</article>
);
}

View File

@@ -1,43 +0,0 @@
import { getPostBySlug, getAllPosts } from '@/lib/markdown';
import { format } from 'date-fns';
import Link from 'next/link';
export async function generateStaticParams() {
const posts = await getAllPosts();
return posts.map((post) => ({
slug: post.slug,
}));
}
export default async function Post({ params }: { params: { slug: string } }) {
const post = await getPostBySlug(params.slug);
return (
<article className="min-h-screen p-8 max-w-4xl mx-auto">
<Link href="/" className="text-blue-600 hover:underline mb-8 inline-block">
Back to posts
</Link>
<h1 className="text-4xl font-bold mb-4">{post.title}</h1>
<div className="text-gray-600 mb-8">
{format(new Date(post.date), 'MMMM d, yyyy')}
</div>
<div className="flex gap-2 mb-8">
{post.tags.map((tag) => (
<span
key={tag}
className="bg-gray-100 text-gray-800 px-3 py-1 rounded-full text-sm"
>
{tag}
</span>
))}
</div>
<div
className="prose prose-lg max-w-none"
dangerouslySetInnerHTML={{ __html: post.content }}
/>
</article>
);
}

View File

@@ -3,6 +3,7 @@ import path from 'path';
import matter from 'gray-matter';
import { remark } from 'remark';
import html from 'remark-html';
import chokidar from 'chokidar';
export interface Post {
slug: string;
@@ -11,15 +12,23 @@ export interface Post {
tags: string[];
summary: string;
content: string;
createdAt: Date;
}
const postsDirectory = path.join(process.cwd(), 'posts');
// Function to get file creation date
function getFileCreationDate(filePath: string): Date {
const stats = fs.statSync(filePath);
return stats.birthtime;
}
export async function getPostBySlug(slug: string): Promise<Post> {
const realSlug = slug.replace(/\.md$/, '');
const fullPath = path.join(postsDirectory, `${realSlug}.md`);
const fileContents = fs.readFileSync(fullPath, 'utf8');
const { data, content } = matter(fileContents);
const createdAt = getFileCreationDate(fullPath);
const processedContent = await remark()
.use(html)
@@ -32,6 +41,7 @@ export async function getPostBySlug(slug: string): Promise<Post> {
tags: data.tags || [],
summary: data.summary,
content: processedContent.toString(),
createdAt,
};
}
@@ -46,10 +56,45 @@ export async function getAllPosts(): Promise<Post[]> {
})
);
return allPostsData.sort((a, b) => (a.date < b.date ? 1 : -1));
// Sort by creation date (newest first)
return allPostsData.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
}
export async function getPostsByTag(tag: string): Promise<Post[]> {
const allPosts = await getAllPosts();
return allPosts.filter((post) => post.tags.includes(tag));
}
// File watcher setup
let watcher: chokidar.FSWatcher | null = null;
let onChangeCallback: (() => void) | null = null;
export function watchPosts(callback: () => void) {
if (watcher) {
watcher.close();
}
onChangeCallback = callback;
watcher = chokidar.watch(postsDirectory, {
ignored: /(^|[\/\\])\../, // ignore dotfiles
persistent: true
});
watcher
.on('add', handleFileChange)
.on('change', handleFileChange)
.on('unlink', handleFileChange);
}
function handleFileChange() {
if (onChangeCallback) {
onChangeCallback();
}
}
export function stopWatching() {
if (watcher) {
watcher.close();
watcher = null;
}
onChangeCallback = null;
}