From c5aaef248d504f640fe53355acaca78f2c14691e Mon Sep 17 00:00:00 2001 From: kodinaka30-ship-it Date: Mon, 27 Jul 2026 17:52:12 +0100 Subject: [PATCH] feat(blog): add MDX blog engine and first post (#71) --- package.json | 3 +- scripts/rss.ts | 101 ++++++++++ scripts/sitemap.ts | 12 ++ src/App.tsx | 3 + src/__tests__/blog.test.tsx | 34 ++++ src/components/Footer.tsx | 1 + src/components/Header.tsx | 13 ++ src/content/blog/wave-7-kickoff.mdx | 67 +++++++ src/i18n/en.json | 3 +- src/i18n/es.json | 3 +- src/pages/Blog.tsx | 282 ++++++++++++++++++++++++++++ src/utils/blog.ts | 60 ++++++ src/vite-env.d.ts | 13 ++ vite.config.ts | 14 +- 14 files changed, 605 insertions(+), 4 deletions(-) create mode 100644 scripts/rss.ts create mode 100644 src/__tests__/blog.test.tsx create mode 100644 src/content/blog/wave-7-kickoff.mdx create mode 100644 src/pages/Blog.tsx create mode 100644 src/utils/blog.ts diff --git a/package.json b/package.json index 10cab71..3fbaa83 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,9 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsc -b && vite build && node scripts/inline-css.js && tsx scripts/og.ts && tsx scripts/sitemap.ts", + "build": "tsc -b && vite build && node scripts/inline-css.js && tsx scripts/og.ts && tsx scripts/sitemap.ts && tsx scripts/rss.ts", "og:generate": "tsx scripts/og.ts", + "rss:generate": "tsx scripts/rss.ts", "preview": "vite preview", "test": "vitest run", "test:a11y": "vitest run src/__tests__/a11y.test.tsx", diff --git a/scripts/rss.ts b/scripts/rss.ts new file mode 100644 index 0000000..e23f9f4 --- /dev/null +++ b/scripts/rss.ts @@ -0,0 +1,101 @@ +import { writeFileSync, readdirSync, readFileSync, existsSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const rootDir = join(__dirname, '..'); +const distDir = join(rootDir, 'dist'); +const publicDir = join(rootDir, 'public'); +const blogDir = join(rootDir, 'src', 'content', 'blog'); +const siteUrl = 'https://usewraith.xyz'; + +interface PostMetadata { + title: string; + date: string; + author: string; + excerpt: string; + slug: string; +} + +function parseFrontmatter(content: string): Partial { + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (!match) return {}; + const yaml = match[1]; + const metadata: Record = {}; + + yaml.split('\n').forEach((line) => { + const colonIdx = line.indexOf(':'); + if (colonIdx > -1) { + const key = line.slice(0, colonIdx).trim(); + let value = line.slice(colonIdx + 1).trim(); + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + metadata[key] = value; + } + }); + + return { + title: metadata.title, + date: metadata.date, + author: metadata.author, + excerpt: metadata.excerpt, + }; +} + +try { + const posts: PostMetadata[] = []; + + if (existsSync(blogDir)) { + const files = readdirSync(blogDir).filter((f) => f.endsWith('.mdx')); + for (const file of files) { + const slug = file.replace(/\.mdx$/, ''); + const content = readFileSync(join(blogDir, file), 'utf8'); + const meta = parseFrontmatter(content); + if (meta.title && meta.date) { + posts.push({ + title: meta.title, + date: meta.date, + author: meta.author || 'Wraith Team', + excerpt: meta.excerpt || '', + slug, + }); + } + } + } + + posts.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); + + const rssFeed = ` + + + Wraith Protocol Blog + ${siteUrl}/blog + Announcements, technical deep dives, and product updates from Wraith Protocol. + en-us + +${posts + .map( + (post) => ` + <![CDATA[${post.title}]]> + ${siteUrl}/blog/${post.slug} + ${siteUrl}/blog/${post.slug} + ${new Date(post.date).toUTCString()} + + `, + ) + .join('\n')} + +`; + + if (existsSync(distDir)) { + writeFileSync(join(distDir, 'feed.xml'), rssFeed, 'utf8'); + writeFileSync(join(distDir, 'rss.xml'), rssFeed, 'utf8'); + } + writeFileSync(join(publicDir, 'feed.xml'), rssFeed, 'utf8'); + writeFileSync(join(publicDir, 'rss.xml'), rssFeed, 'utf8'); + + console.log(`RSS feed generated successfully with ${posts.length} posts.`); +} catch (error) { + console.error('Failed to generate RSS feed:', error); +} diff --git a/scripts/sitemap.ts b/scripts/sitemap.ts index a9da622..0225b6e 100644 --- a/scripts/sitemap.ts +++ b/scripts/sitemap.ts @@ -33,6 +33,18 @@ try { } const routes = getRoutes(distDir); + + const blogDir = join(rootDir, 'src', 'content', 'blog'); + if (existsSync(blogDir)) { + const blogFiles = readdirSync(blogDir).filter((f) => f.endsWith('.mdx')); + if (!routes.includes('/blog')) routes.push('/blog'); + for (const f of blogFiles) { + const slug = f.replace(/\.mdx$/, ''); + const blogRoute = `/blog/${slug}`; + if (!routes.includes(blogRoute)) routes.push(blogRoute); + } + } + const sitemap = ` ${routes diff --git a/src/App.tsx b/src/App.tsx index aab59e5..253af6f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -26,6 +26,7 @@ const Stellar = lazy(() => import('./pages/Stellar')); const Roadmap = lazy(() => import('./pages/Roadmap')); const CaseStudies = lazy(() => import('./pages/CaseStudies')); const Careers = lazy(() => import('./pages/Careers')); +const Blog = lazy(() => import('./pages/Blog')); const NotFound = lazy(() => import('./pages/NotFound')); function Home() { @@ -68,6 +69,8 @@ export default function App() { } /> } /> } /> + } /> + } /> } /> } /> {/* Wrap Stellar with Layout */} diff --git a/src/__tests__/blog.test.tsx b/src/__tests__/blog.test.tsx new file mode 100644 index 0000000..0dceeea --- /dev/null +++ b/src/__tests__/blog.test.tsx @@ -0,0 +1,34 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import App from '../App'; +import { getAllPosts, getPostBySlug } from '../utils/blog'; + +describe('Blog utility & page', () => { + it('loads blog posts correctly', () => { + const posts = getAllPosts(); + expect(posts.length).toBeGreaterThan(0); + expect(posts[0].slug).toBe('wave-7-kickoff'); + }); + + it('retrieves post by slug', () => { + const post = getPostBySlug('wave-7-kickoff'); + expect(post).toBeDefined(); + expect(post?.title).toContain('Wave 7 Kick-off'); + }); + + it('renders blog index page', async () => { + window.history.replaceState({}, '', '/blog'); + render(); + + expect(await screen.findByRole('heading', { name: /wraith protocol blog/i, level: 1 })).toBeInTheDocument(); + expect(screen.getByText(/wave 7 kick-off/i)).toBeInTheDocument(); + }); + + it('renders blog single post page', async () => { + window.history.replaceState({}, '', '/blog/wave-7-kickoff'); + render(); + + expect(await screen.findByRole('heading', { name: /wave 7 kick-off/i, level: 1 })).toBeInTheDocument(); + expect(screen.getByText(/welcome to/i)).toBeInTheDocument(); + }); +}); diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index 6f2a402..8a4e442 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -123,6 +123,7 @@ export default function Footer() { { label: t('footer.resources.erc6538'), href: 'https://eips.ethereum.org/EIPS/eip-6538' }, { label: t('footer.resources.security'), href: 'https://docs.usewraith.xyz/security' }, { label: t('footer.resources.press'), href: '/press' }, + { label: 'Blog', href: '/blog' }, { label: 'Stellar Integration', href: '/stellar' }, { label: 'Careers', href: '/careers' }, ], diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 2f8f05a..ef468c2 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -144,6 +144,12 @@ export default function Header() { > {t('header.nav.roadmap')} + + {t('header.nav.blog')} +
@@ -298,6 +304,13 @@ export default function Header() { > {t('header.nav.roadmap')} + + {t('header.nav.blog')} + +