diff --git a/app/posts/[slug]/page.tsx b/app/posts/[slug]/page.tsx new file mode 100644 index 00000000..1fd4eb0d --- /dev/null +++ b/app/posts/[slug]/page.tsx @@ -0,0 +1,581 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import Image from 'next/image'; +import Link from 'next/link'; +import { notFound } from 'next/navigation'; +import type { ReactNode } from 'react'; +import type { Metadata } from 'next'; +import Header from '@/components/layout/header/Header'; +import { Breadcrumbs } from '@/components/shared/breadcrumbs'; +import { generateMetadata as buildMetadata } from '@/utils/generateMetadata'; +import { POSTS_DIRECTORY, POSTS_SITE_ORIGIN, formatSlugTitle, parseDateFromSlug } from '../helpers'; +const VALID_SLUG_PATTERN = /^[a-z0-9-]+$/; +const FALLBACK_IMAGE_WIDTH = 1600; +const FALLBACK_IMAGE_HEIGHT = 900; + +type PostPageProps = { + params: Promise<{ slug: string }>; +}; + +type MarkdownBlock = + | { type: 'heading'; level: 1 | 2 | 3; text: string } + | { type: 'paragraph'; text: string } + | { type: 'unordered-list'; items: string[] } + | { type: 'ordered-list'; items: string[] } + | { type: 'blockquote'; text: string } + | { type: 'image'; alt: string; src: string; title?: string } + | { type: 'code'; language: string; content: string } + | { type: 'hr' }; + +const isExternalUrl = (value: string): boolean => value.startsWith('https://') || value.startsWith('http://'); + +const resolveImageSource = (source: string, slug: string): string => { + if (source.startsWith('/')) { + return source; + } + + if (isExternalUrl(source)) { + return source; + } + + const sanitized = source.replace(/^\.?\//, ''); + return `/posts/${slug}/${sanitized}`; +}; + +const getPostMarkdown = async (slug: string): Promise => { + if (!VALID_SLUG_PATTERN.test(slug)) { + return null; + } + + const filePath = path.join(POSTS_DIRECTORY, `${slug}.md`); + + try { + return await fs.readFile(filePath, 'utf8'); + } catch (error: unknown) { + const fsError = error as NodeJS.ErrnoException; + if (fsError.code === 'ENOENT') { + return null; + } + + throw error; + } +}; + +const parseMarkdownBlocks = (markdown: string): MarkdownBlock[] => { + const lines = markdown.replaceAll('\r\n', '\n').split('\n'); + const blocks: MarkdownBlock[] = []; + let paragraphLines: string[] = []; + + const flushParagraph = () => { + if (paragraphLines.length === 0) { + return; + } + + blocks.push({ + type: 'paragraph', + text: paragraphLines.join(' '), + }); + paragraphLines = []; + }; + + let index = 0; + + while (index < lines.length) { + const rawLine = lines[index] ?? ''; + const trimmedLine = rawLine.trim(); + + if (trimmedLine.length === 0) { + flushParagraph(); + index += 1; + continue; + } + + if (trimmedLine.startsWith('```')) { + flushParagraph(); + + const language = trimmedLine.slice(3).trim(); + const codeLines: string[] = []; + index += 1; + + while (index < lines.length && !(lines[index] ?? '').trim().startsWith('```')) { + codeLines.push(lines[index] ?? ''); + index += 1; + } + + if (index < lines.length) { + index += 1; + } + + blocks.push({ + type: 'code', + language, + content: codeLines.join('\n'), + }); + continue; + } + + if (trimmedLine === '---') { + flushParagraph(); + blocks.push({ type: 'hr' }); + index += 1; + continue; + } + + const headingMatch = trimmedLine.match(/^(#{1,3})\s+(.+)$/); + if (headingMatch) { + flushParagraph(); + blocks.push({ + type: 'heading', + level: headingMatch[1].length as 1 | 2 | 3, + text: headingMatch[2].trim(), + }); + index += 1; + continue; + } + + const imageMatch = trimmedLine.match(/^!\[(.*?)\]\((.+?)(?:\s+"(.*?)")?\)$/); + if (imageMatch) { + flushParagraph(); + blocks.push({ + type: 'image', + alt: imageMatch[1], + src: imageMatch[2], + title: imageMatch[3], + }); + index += 1; + continue; + } + + const quoteMatch = trimmedLine.match(/^>\s+(.+)$/); + if (quoteMatch) { + flushParagraph(); + const quoteLines: string[] = [quoteMatch[1]]; + index += 1; + + while (index < lines.length) { + const nextLine = (lines[index] ?? '').trim(); + const nextQuoteMatch = nextLine.match(/^>\s+(.+)$/); + if (!nextQuoteMatch) { + break; + } + + quoteLines.push(nextQuoteMatch[1]); + index += 1; + } + + blocks.push({ + type: 'blockquote', + text: quoteLines.join(' '), + }); + continue; + } + + const unorderedListMatch = trimmedLine.match(/^[-*]\s+(.+)$/); + if (unorderedListMatch) { + flushParagraph(); + const items: string[] = [unorderedListMatch[1]]; + index += 1; + + while (index < lines.length) { + const nextItem = (lines[index] ?? '').trim().match(/^[-*]\s+(.+)$/); + if (!nextItem) { + break; + } + + items.push(nextItem[1]); + index += 1; + } + + blocks.push({ + type: 'unordered-list', + items, + }); + continue; + } + + const orderedListMatch = trimmedLine.match(/^\d+\.\s+(.+)$/); + if (orderedListMatch) { + flushParagraph(); + const items: string[] = [orderedListMatch[1]]; + index += 1; + + while (index < lines.length) { + const nextItem = (lines[index] ?? '').trim().match(/^\d+\.\s+(.+)$/); + if (!nextItem) { + break; + } + + items.push(nextItem[1]); + index += 1; + } + + blocks.push({ + type: 'ordered-list', + items, + }); + continue; + } + + paragraphLines.push(trimmedLine); + index += 1; + } + + flushParagraph(); + return blocks; +}; + +const parseInlineMarkdown = (text: string, keyPrefix: string): ReactNode[] => { + const nodes: ReactNode[] = []; + let remaining = text; + let keyIndex = 0; + + const patterns = [ + { type: 'link', regex: /\[([^\]]+)\]\(([^)]+)\)/ }, + { type: 'bold', regex: /\*\*([^*]+)\*\*/ }, + { type: 'code', regex: /`([^`]+)`/ }, + ] as const; + + while (remaining.length > 0) { + const candidates = patterns + .map((pattern) => { + const match = remaining.match(pattern.regex); + if (!match || match.index === undefined) { + return null; + } + + return { + type: pattern.type, + index: match.index, + match, + }; + }) + .filter((candidate) => candidate !== null); + + if (candidates.length === 0) { + nodes.push(remaining); + break; + } + + candidates.sort((first, second) => first.index - second.index); + const nextToken = candidates[0]; + + if (nextToken.index > 0) { + nodes.push(remaining.slice(0, nextToken.index)); + } + + const tokenText = nextToken.match[0]; + + if (nextToken.type === 'link') { + const [, label, href] = nextToken.match; + const linkKey = `${keyPrefix}-link-${keyIndex}`; + const linkClassName = 'underline underline-offset-2 transition-colors hover:text-primary'; + + nodes.push( + isExternalUrl(href) ? ( + + {label} + + ) : ( + + {label} + + ), + ); + } + + if (nextToken.type === 'bold') { + nodes.push( + + {nextToken.match[1]} + , + ); + } + + if (nextToken.type === 'code') { + nodes.push( + + {nextToken.match[1]} + , + ); + } + + remaining = remaining.slice(nextToken.index + tokenText.length); + keyIndex += 1; + } + + return nodes; +}; + +const getPostTitleFromBlocks = (blocks: MarkdownBlock[], slug: string): string => { + const firstHeading = blocks.find((block) => block.type === 'heading' && block.level === 1); + if (firstHeading && firstHeading.type === 'heading') { + return firstHeading.text; + } + + return formatSlugTitle(slug); +}; + +const renderMarkdownBlock = (block: MarkdownBlock, slug: string, index: number): ReactNode => { + const blockKey = `${slug}-block-${index}`; + + if (block.type === 'heading') { + if (block.level === 1) { + return ( +

+ {parseInlineMarkdown(block.text, `${blockKey}-h1`)} +

+ ); + } + + if (block.level === 2) { + return ( +

+ {parseInlineMarkdown(block.text, `${blockKey}-h2`)} +

+ ); + } + + return ( +

+ {parseInlineMarkdown(block.text, `${blockKey}-h3`)} +

+ ); + } + + if (block.type === 'paragraph') { + return ( +

+ {parseInlineMarkdown(block.text, `${blockKey}-paragraph`)} +

+ ); + } + + if (block.type === 'unordered-list') { + return ( + + ); + } + + if (block.type === 'ordered-list') { + return ( +
    + {block.items.map((item, itemIndex) => ( +
  1. + {itemIndex + 1}. + {parseInlineMarkdown(item, `${blockKey}-ol-inline-${itemIndex}`)} +
  2. + ))} +
+ ); + } + + if (block.type === 'blockquote') { + return ( +
+ {parseInlineMarkdown(block.text, `${blockKey}-quote`)} +
+ ); + } + + if (block.type === 'code') { + return ( +
+        {block.content}
+      
+ ); + } + + if (block.type === 'hr') { + return ( +
+ ); + } + + const source = resolveImageSource(block.src, slug); + const imageClassName = 'h-auto w-full rounded-md bg-hovered object-contain max-h-[520px]'; + + if (isExternalUrl(source)) { + return ( +

+ External image URLs are not enabled in posts yet. Open:{' '} + + {source} + +

+ ); + } + + return ( +
+ {block.alt + {block.title &&
{block.title}
} +
+ ); +}; + +const getPostStructure = (markdown: string, slug: string) => { + const blocks = parseMarkdownBlocks(markdown); + const title = getPostTitleFromBlocks(blocks, slug); + const hasLeadHeading = blocks[0]?.type === 'heading' && blocks[0].level === 1; + const contentBlocks = hasLeadHeading ? blocks.slice(1) : blocks; + + return { title, contentBlocks }; +}; + +export async function generateStaticParams() { + try { + const entries = await fs.readdir(POSTS_DIRECTORY, { withFileTypes: true }); + return entries + .filter((entry) => entry.isFile() && entry.name.endsWith('.md')) + .map((entry) => ({ + slug: entry.name.replace(/\.md$/, ''), + })); + } catch { + return []; + } +} + +export async function generateMetadata({ params }: PostPageProps): Promise { + const { slug } = await params; + const markdown = await getPostMarkdown(slug); + const title = markdown ? getPostStructure(markdown, slug).title : formatSlugTitle(slug); + const resolvedTitle = `${title} | Monarch`; + + const baseMetadata = buildMetadata({ + title: resolvedTitle, + description: 'Monarch product update', + images: 'themes.png', + url: POSTS_SITE_ORIGIN, + pathname: `/posts/${slug}`, + }); + + return { + ...baseMetadata, + openGraph: { + ...baseMetadata.openGraph, + title: resolvedTitle, + description: 'Monarch product update', + }, + twitter: { + card: 'summary_large_image', + title: resolvedTitle, + description: 'Monarch product update', + }, + }; +} + +export default async function PostPage({ params }: PostPageProps) { + const { slug } = await params; + const markdown = await getPostMarkdown(slug); + + if (!markdown) { + notFound(); + } + + const { title, contentBlocks } = getPostStructure(markdown, slug); + const { label: publishedDate } = parseDateFromSlug(slug); + + return ( +
+
+
+
+ +
+ +
+
+
+ + Product Update + + {publishedDate &&

{publishedDate}

} +
+

{title}

+
+ +
{contentBlocks.map((block, index) => renderMarkdownBlock(block, slug, index))}
+
+
+
+ ); +} diff --git a/app/posts/helpers.ts b/app/posts/helpers.ts new file mode 100644 index 00000000..2d299a0f --- /dev/null +++ b/app/posts/helpers.ts @@ -0,0 +1,43 @@ +import path from 'node:path'; + +const deployUrl = process.env.BOAT_DEPLOY_URL ?? process.env.VERCEL_URL; + +export const POSTS_DIRECTORY = path.join(process.cwd(), 'src/content/posts'); +export const POSTS_SITE_ORIGIN = deployUrl ? `https://${deployUrl}` : 'https://monarchlend.xyz'; + +export type PostListItem = { + slug: string; + title: string; + publishedDate: string | null; + excerpt: string; + sortTime: number; +}; + +export const formatSlugTitle = (slug: string): string => + slug + .replace(/^\d{4}-\d{2}-\d{2}-/, '') + .replace(/-/g, ' ') + .replace(/\b\w/g, (character) => character.toUpperCase()); + +export const parseDateFromSlug = (slug: string): { label: string | null; time: number } => { + const match = slug.match(/^(\d{4})-(\d{2})-(\d{2})-/); + if (!match) { + return { label: null, time: 0 }; + } + + const [_, year, month, day] = match; + const date = new Date(`${year}-${month}-${day}T00:00:00Z`); + if (Number.isNaN(date.getTime())) { + return { label: null, time: 0 }; + } + + return { + label: date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + timeZone: 'UTC', + }), + time: date.getTime(), + }; +}; diff --git a/app/posts/page.tsx b/app/posts/page.tsx new file mode 100644 index 00000000..c73f51a1 --- /dev/null +++ b/app/posts/page.tsx @@ -0,0 +1,141 @@ +import { promises as fs, type Dirent } from 'node:fs'; +import path from 'node:path'; +import Link from 'next/link'; +import Header from '@/components/layout/header/Header'; +import { Breadcrumbs } from '@/components/shared/breadcrumbs'; +import { generateMetadata } from '@/utils/generateMetadata'; +import { POSTS_DIRECTORY, POSTS_SITE_ORIGIN, formatSlugTitle, parseDateFromSlug, type PostListItem } from './helpers'; + +const getPostPreview = (markdown: string, fallbackTitle: string): { title: string; excerpt: string } => { + const lines = markdown.replaceAll('\r\n', '\n').split('\n'); + let title = fallbackTitle; + let excerpt = 'Product update and announcement.'; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + + const headingMatch = trimmed.match(/^#\s+(.+)$/); + if (headingMatch && title === fallbackTitle) { + title = headingMatch[1]; + continue; + } + + if ( + !trimmed.startsWith('#') && + !trimmed.startsWith('![') && + !trimmed.startsWith('```') && + !trimmed.startsWith('---') && + !trimmed.match(/^[-*]\s+/) && + !trimmed.match(/^\d+\.\s+/) + ) { + excerpt = trimmed.replace(/^>\s*/, ''); + break; + } + } + + return { title, excerpt }; +}; + +const getAllPosts = async (): Promise => { + let entries: Dirent[]; + try { + entries = await fs.readdir(POSTS_DIRECTORY, { withFileTypes: true }); + } catch (error: unknown) { + const fsError = error as NodeJS.ErrnoException; + if (fsError.code === 'ENOENT') { + return []; + } + + throw error; + } + + const markdownFiles = entries.filter((entry) => entry.isFile() && entry.name.endsWith('.md')); + + const posts = await Promise.all( + markdownFiles.map(async (entry) => { + const slug = entry.name.replace(/\.md$/, ''); + const fallbackTitle = formatSlugTitle(slug); + const filePath = path.join(POSTS_DIRECTORY, entry.name); + const markdown = await fs.readFile(filePath, 'utf8'); + const { title, excerpt } = getPostPreview(markdown, fallbackTitle); + const { label, time } = parseDateFromSlug(slug); + + return { + slug, + title, + excerpt, + publishedDate: label, + sortTime: time, + }; + }), + ); + + return posts.sort((first, second) => { + if (first.sortTime !== second.sortTime) { + return second.sortTime - first.sortTime; + } + + return second.slug.localeCompare(first.slug); + }); +}; + +export const metadata = generateMetadata({ + title: 'Posts | Monarch', + description: 'Monarch announcements and product updates', + images: 'themes.png', + url: POSTS_SITE_ORIGIN, + pathname: '/posts', +}); + +export default async function PostsPage() { + const posts = await getAllPosts(); + + return ( +
+
+
+
+ +
+ +
+
+

Announcements

+

Posts

+
+ +
+ {posts.length === 0 ? ( +

No posts published yet.

+ ) : ( + posts.map((post) => ( + +
+ + Product Update + + {post.publishedDate && {post.publishedDate}} +
+

{post.title}

+

{post.excerpt}

+ + )) + )} +
+
+
+
+ ); +} diff --git a/public/posts/2026-03-06-leverage/.gitkeep b/public/posts/2026-03-06-leverage/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/public/posts/2026-03-06-leverage/.gitkeep @@ -0,0 +1 @@ + diff --git a/public/posts/2026-03-06-leverage/erc4626-route.png b/public/posts/2026-03-06-leverage/erc4626-route.png new file mode 100644 index 00000000..4cb2703b Binary files /dev/null and b/public/posts/2026-03-06-leverage/erc4626-route.png differ diff --git a/public/posts/2026-03-06-leverage/leverage-overview.png b/public/posts/2026-03-06-leverage/leverage-overview.png new file mode 100644 index 00000000..5a697668 Binary files /dev/null and b/public/posts/2026-03-06-leverage/leverage-overview.png differ diff --git a/src/components/layout/notification-banner.tsx b/src/components/layout/notification-banner.tsx index 50bd2fa2..ce88cbe1 100644 --- a/src/components/layout/notification-banner.tsx +++ b/src/components/layout/notification-banner.tsx @@ -22,25 +22,37 @@ export function NotificationBanner() { const action = currentNotification.action; return ( -
- {/* Grid background overlay */} +
+ {/* Soft primary tint */} +