trails/apps/journal/app/lib/changelog.server.ts
Ullrich Schäfer 18e68bb176 Add public changelog with Atom feed and What's New indicator
- Changelog entries authored as markdown files in apps/journal/changelog/
  with YAML frontmatter, loaded via Vite import.meta.glob at build time
- /changelog list page with date, title, and preview
- /changelog/:slug detail page with full markdown rendering (react-markdown)
  and Open Graph meta tags for social sharing
- /changelog/feed.xml Atom feed with auto-discovery <link> tags
- "What's New" dot on nav bar using localStorage timestamp tracking
- Notification toggle: users can permanently disable/re-enable the indicator
- i18n strings for en and de
- Initial changelog entry covering the Phase 1 launch

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:49:52 +02:00

73 lines
2 KiB
TypeScript

const modules = import.meta.glob("/changelog/*.md", {
query: "?raw",
import: "default",
eager: true,
}) as Record<string, string>;
export interface ChangelogEntry {
slug: string;
title: string;
date: string;
preview: string;
content: string;
}
function parseFrontmatter(raw: string): {
attrs: Record<string, string>;
body: string;
} {
const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
if (!match) return { attrs: {}, body: raw };
const attrs: Record<string, string> = {};
for (const line of match[1]!.split("\n")) {
const idx = line.indexOf(":");
if (idx === -1) continue;
const key = line.slice(0, idx).trim();
const value = line.slice(idx + 1).trim().replace(/^["']|["']$/g, "");
attrs[key] = value;
}
return { attrs, body: match[2]! };
}
function firstParagraph(md: string): string {
const trimmed = md.trim();
const lines: string[] = [];
for (const line of trimmed.split("\n")) {
if (line.startsWith("#")) continue;
if (lines.length > 0 && line.trim() === "") break;
if (line.trim() !== "") lines.push(line);
}
return lines.join(" ").slice(0, 200);
}
function parseEntry(path: string, raw: string): ChangelogEntry {
const slug = path.replace(/^\/changelog\//, "").replace(/\.md$/, "");
const { attrs, body } = parseFrontmatter(raw);
return {
slug,
title: attrs.title ?? slug,
date: attrs.date ?? slug,
preview: firstParagraph(body),
content: body,
};
}
let cachedEntries: ChangelogEntry[] | null = null;
export function getAllEntries(): ChangelogEntry[] {
if (!cachedEntries) {
cachedEntries = Object.entries(modules)
.map(([path, raw]) => parseEntry(path, raw))
.sort((a, b) => b.date.localeCompare(a.date));
}
return cachedEntries;
}
export function getEntry(slug: string): ChangelogEntry | undefined {
return getAllEntries().find((e) => e.slug === slug);
}
export function getNewestDate(): string | null {
const entries = getAllEntries();
return entries.length > 0 ? entries[0]!.date : null;
}