Add markdown rendering with react-markdown

- Installed react-markdown and remark-gfm for GitHub-flavored markdown
- Installed @tailwindcss/typography for proper prose styling
- Rewrote page.tsx to render markdown content as HTML
- Links are now clickable with proper hover states
- Headers, lists, and other markdown elements properly styled
- Fixes Task #12: Blog now renders markdown correctly
This commit is contained in:
OpenClaw Bot 2026-02-19 08:45:14 -06:00
parent 664d48abb5
commit b618772883
5 changed files with 1573 additions and 117 deletions

View File

@ -1,4 +1,16 @@
[ [
{
"id": "1771511887581",
"date": "2025-02-19",
"content": "# Daily Digest - February 19, 2025 (Retro Edition)\n\n## 🤖 iOS + AI Development\n- **[Apple Releases iOS 18.3 with Enhanced AI Features](https://developer.apple.com/news)** — On-device Siri improvements and CoreML optimizations\n- **[Swift 6 Enters Beta](https://swift.org/blog)** — Major concurrency improvements for iOS developers\n- **[Vision Pro Launches with 600+ Apps](https://apple.com/vision-pro)** — Spatial computing officially arrives\n\n## 🧑‍💻 AI Coding Assistants\n- **[Claude 3 Announced by Anthropic](https://anthropic.com/news)** — Claude 3 Opus, Sonnet, and Haiku models launched\n- **[GitHub Copilot Gets GPT-4 Turbo](https://github.blog)** — Faster, more accurate code suggestions\n- **[Cursor IDE Gains Traction](https://cursor.com)** — AI-native editor starting to challenge VS Code\n\n## 🏆 Latest Coding Models\n- **[GPT-4 Turbo with Vision Released](https://openai.com/blog)** — Multimodal capabilities for developers\n- **[Gemini 1.5 Pro Debuts](https://deepmind.google)** — 1 million token context window\n- **[Mistral Large Announced](https://mistral.ai)** — European LLM challenging GPT-4\n\n## 🦾 OpenClaw Updates\n- **[OpenClaw Beta Launched](https://github.com/openclaw/openclaw)** — Early access for AI agent framework\n- **[Terminal UI Tools Added](https://docs.openclaw.ai)** — Command-line interface improvements\n\n## 💰 Digital Entrepreneurship\n- **[Indie Hackers $100K Club Growing](https://indiehackers.com)** — More solo founders hitting six figures\n- **[AI App Revenue Surges](https://sensor-tower.com)** — AI-powered apps dominating App Store charts\n- **[No-Code Tools Evolution](https://webflow.com/blog)** — Webflow, Bubble adding AI features\n\n---\n*Retro Digest: Looking back at February 2025 | Generated by Max*",
"timestamp": 1771511887581
},
{
"id": "1771506266870",
"date": "2026-02-19",
"content": "# Daily Digest - February 19, 2026\n\n## iOS AI Development\n\n- [iOS 26.4 Beta Released - Get Ready with Latest SDKs](https://developer.apple.com/news/?id=xgkk9w83)\n- [Swift Student Challenge 2026 Submissions Now Open](https://developer.apple.com/swift-student-challenge/)\n- [Exploring LLMs with MLX on Apple Silicon Macs](https://machinelearning.apple.com/research/exploring-llms-mlx-m5)\n- [Updated App Review Guidelines - Anonymous Chat Apps](https://developer.apple.com/news/?id=d75yllv4)\n\n## AI Coding Assistants\n\n- [Cursor Composer 1.5 - Improved Reasoning with 20x RL Scaling](https://cursor.com/blog/composer-1-5)\n- [Stripe Rolls Out Cursor to 3,000 Engineers](https://cursor.com/blog/stripe)\n- [Cursor Launches Plugin Marketplace](https://cursor.com/blog/marketplace)\n- [Cursor Long-Running Agents Now in Web App](https://cursor.com/blog/long-running-agents)\n- [Box Chooses Cursor - 85% of Engineers Use Daily](https://cursor.com/blog/box)\n- [NVIDIA Commits 3x More Code with Cursor Across 30,000 Developers](https://cursor.com/blog/nvidia)\n- [Dropbox Uses Cursor to Index 550,000+ Files](https://cursor.com/blog/dropbox)\n- [Clankers with Claws - DHH on OpenClaw and Terminal UIs](https://world.hey.com/dhh/clankers-with-claws-9f86fa71)\n\n## Latest Coding Models\n\n- [Anthropic Claude Opus 4.6 Released - Industry-Leading Agentic Coding](https://www.anthropic.com/news)\n- [Anthropic Raises $30B Series G at $380B Valuation](https://www.anthropic.com/news)\n- [SWE-bench February 2026 Leaderboard Update](https://www.swebench.com/)\n- [Don't Trust the Salt: AI Summarization and LLM Guardrails](https://royapakzad.substack.com/p/multilingual-llm-evaluation-to-guardrails)\n\n## OpenClaw Updates\n\n- [OpenClaw Documentation - Full Tool Reference](https://docs.openclaw.ai/)\n- [Clankers with Claws - DHH on OpenClaw AI Agents](https://world.hey.com/dhh/clankers-with-claws-9f86fa71)\n- [Omarchy and OpenCode Coming to New York - Omacon April 10](https://world.hey.com/dhh/omacon-comes-to-new-york-e6ee93cb)\n\n## Digital Entrepreneurship / Indie Hacking\n\n- [Bootstrapping a $20k/mo AI Portfolio After VC-Backed Company Failed](https://www.indiehackers.com/post/tech/bootstrapping-a-20k-mo-ai-portfolio-after-his-vc-backed-company-failed-rQxwZBD9xWVgfHhIxvbJ)\n- [Vibe is Product Logic - Injecting Branding into Your AI](https://www.indiehackers.com/post/vibe-is-product-logic-how-to-inject-branding-into-your-ai-e9c6766a2d)\n- [Indie Hackers Truth: Distribution is the Bottleneck](https://www.indiehackers.com/product/leadsynthai)\n- [Copylio - AI Tool for SEO Ecommerce Product Descriptions](https://www.indiehackers.com/post/show-ih-copylio-an-ai-tool-to-generate-seo-optimized-ecommerce-product-descriptions-from-a-product-link-c5cd295d14)\n- [Most Founders Have a Timing Problem, Not a Product Problem](https://www.indiehackers.com/product/leadsynthai)\n\n---\n*Generated by OpenClaw - February 19, 2026*\n",
"timestamp": 1771506266870
},
{ {
"id": "1771435073243", "id": "1771435073243",
"date": "2026-02-18", "date": "2026-02-18",

1518
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -9,10 +9,13 @@
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"@tailwindcss/typography": "^0.5.19",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"next": "16.1.6", "next": "16.1.6",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3" "react-dom": "19.2.3",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",

View File

@ -1,4 +1,5 @@
@import "tailwindcss"; @import "tailwindcss";
@import "@tailwindcss/typography";
:root { :root {
--background: #ffffff; --background: #ffffff;

View File

@ -2,6 +2,8 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { format } from "date-fns"; import { format } from "date-fns";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
interface Message { interface Message {
id: string; id: string;
@ -10,65 +12,6 @@ interface Message {
timestamp: number; timestamp: number;
} }
interface DigestEntry {
category: string;
title: string;
url?: string;
summary: string;
}
interface ParsedDigest {
title: string;
entries: DigestEntry[];
raw: string;
}
function parseDigest(content: string): ParsedDigest {
const lines = content.split('\n');
const entries: DigestEntry[] = [];
let currentCategory = "";
let title = "";
for (const line of lines) {
if (line.startsWith('# ') && !title) {
title = line.replace('# ', '');
} else if (line.startsWith('## ')) {
currentCategory = line.replace('## ', '');
} else if (line.startsWith('- **')) {
// Match: - **[Title with optional link](url)**: Summary
// or: - **Title**: Summary
const match = line.match(/- \*\*\[?(.+?)\]?\*\*:\s*(.+)/);
if (match) {
let entryTitle = match[1];
let entrySummary = match[2];
let entryUrl: string | undefined;
// Check if title contains a markdown link: [Title](url)
const linkMatch = entryTitle.match(/\[(.+?)\]\((.+?)\)/);
if (linkMatch) {
entryTitle = linkMatch[1]; // Just the link text
entryUrl = linkMatch[2]; // The URL
}
entries.push({
category: currentCategory,
title: entryTitle,
summary: entrySummary,
url: entryUrl,
});
}
} else if (line.startsWith(' - ')) {
// Fallback: look for URLs on indented lines
const urlMatch = line.match(/\[.+?\]\((.+?)\)/);
if (urlMatch && entries.length > 0 && !entries[entries.length - 1].url) {
entries[entries.length - 1].url = urlMatch[1];
}
}
}
return { title, entries, raw: content };
}
export default function Home() { export default function Home() {
const [messages, setMessages] = useState<Message[]>([]); const [messages, setMessages] = useState<Message[]>([]);
const [newMessage, setNewMessage] = useState(""); const [newMessage, setNewMessage] = useState("");
@ -77,7 +20,6 @@ export default function Home() {
return today.toISOString().split("T")[0]; return today.toISOString().split("T")[0];
}); });
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [expandedId, setExpandedId] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
fetchMessages(); fetchMessages();
@ -146,7 +88,7 @@ export default function Home() {
); );
// Check if message is a digest // Check if message is a digest
const isDigest = (content: string) => content.includes("## Daily Digest"); const isDigest = (content: string) => content.includes("##");
return ( return (
<div className="min-h-screen bg-zinc-950 text-zinc-100"> <div className="min-h-screen bg-zinc-950 text-zinc-100">
@ -203,7 +145,6 @@ export default function Home() {
<div className="divide-y divide-zinc-800"> <div className="divide-y divide-zinc-800">
{groupedMessages[date].map((msg) => { {groupedMessages[date].map((msg) => {
const isDailyDigest = isDigest(msg.content); const isDailyDigest = isDigest(msg.content);
const parsed = isDailyDigest ? parseDigest(msg.content) : null;
return ( return (
<div key={msg.id} className={`p-4 group ${isDailyDigest ? 'bg-blue-900/10' : ''}`}> <div key={msg.id} className={`p-4 group ${isDailyDigest ? 'bg-blue-900/10' : ''}`}>
@ -215,59 +156,48 @@ export default function Home() {
)} )}
<div className="flex justify-between items-start gap-4"> <div className="flex justify-between items-start gap-4">
{isDailyDigest && parsed ? ( <div className="flex-1 prose prose-invert prose-zinc max-w-none">
<div className="flex-1"> <ReactMarkdown
<h3 className="text-lg font-semibold text-blue-400 mb-2"> remarkPlugins={[remarkGfm]}
{parsed.title} components={{
</h3> a: ({ node, ...props }) => (
<a
{parsed.entries.length > 0 ? ( {...props}
<div className="space-y-4"> target="_blank"
{Array.from(new Set(parsed.entries.map(e => e.category))).map(cat => ( rel="noopener noreferrer"
<div key={cat}> className="text-blue-400 hover:text-blue-300 hover:underline transition-colors"
<h4 className="text-sm font-medium text-zinc-400 mb-2">{cat}</h4> />
<ul className="space-y-2"> ),
{parsed.entries.filter(e => e.category === cat).map((entry, i) => ( h1: ({ node, ...props }) => (
<li key={i} className="text-sm text-zinc-300"> <h1 {...props} className="text-xl font-bold text-zinc-100 mb-3" />
{entry.url ? ( ),
<a h2: ({ node, ...props }) => (
href={entry.url} <h2 {...props} className="text-lg font-semibold text-zinc-200 mt-4 mb-2" />
target="_blank" ),
rel="noopener noreferrer" h3: ({ node, ...props }) => (
className="group/link inline" <h3 {...props} className="text-base font-medium text-zinc-300 mt-3 mb-2" />
> ),
<span className="font-medium text-zinc-200 group-hover/link:text-blue-400 transition-colors"> ul: ({ node, ...props }) => (
{entry.title} <ul {...props} className="list-disc list-inside space-y-1 text-zinc-300 mb-3" />
</span> ),
<span className="inline-flex items-center ml-1 text-blue-500 opacity-60 group-hover/link:opacity-100"> li: ({ node, ...props }) => (
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <li {...props} className="text-zinc-300" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /> ),
</svg> p: ({ node, ...props }) => (
</span> <p {...props} className="text-zinc-300 mb-3 leading-relaxed" />
</a> ),
) : ( hr: ({ node, ...props }) => (
<span className="font-medium text-zinc-200">{entry.title}</span> <hr {...props} className="border-zinc-700 my-4" />
)} ),
<span className="text-zinc-400">: {entry.summary}</span> }}
</li> >
))} {msg.content}
</ul> </ReactMarkdown>
</div> </div>
))}
</div>
) : (
<pre className="text-sm text-zinc-300 whitespace-pre-wrap font-sans">
{msg.content}
</pre>
)}
</div>
) : (
<p className="text-zinc-300 whitespace-pre-wrap flex-1">{msg.content}</p>
)}
<button <button
onClick={() => deleteMessage(msg.id)} onClick={() => deleteMessage(msg.id)}
className="text-zinc-600 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity text-sm" className="text-zinc-600 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity text-sm shrink-0"
title="Delete" title="Delete"
> >