diff --git a/package.json b/package.json index 8ac6ea1..535a16c 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "dev": "vite", "build": "tsc -b && vite build && node scripts/inline-css.js && tsx scripts/og.ts && tsx scripts/sitemap.ts && node scripts/gen-rss.mjs", "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/__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 54bf1d1..d7e47fa 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' }, { label: 'About', href: '/about' }, diff --git a/src/components/Header.tsx b/src/components/Header.tsx index c26c188..8dd214a 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -128,6 +128,12 @@ export default function Header() { > {t('header.nav.roadmap')} + + {t('header.nav.blog')} +
@@ -289,6 +295,13 @@ export default function Header() { > {t('header.nav.roadmap')} + + {t('header.nav.blog')} + ('/src/content/blog/*.mdx', { eager: true }); + +export function getAllPosts(): BlogPost[] { + return Object.entries(modules) + .map(([filepath, mod]) => { + const filename = filepath.split('/').pop() || ''; + const slug = filename.replace(/\.mdx$/, ''); + const frontmatter = mod.frontmatter || {}; + + return { + slug, + title: frontmatter.title || slug, + date: frontmatter.date || '', + author: frontmatter.author || 'Wraith Team', + excerpt: frontmatter.excerpt || '', + tags: Array.isArray(frontmatter.tags) ? frontmatter.tags : [], + Component: mod.default, + }; + }) + .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); +} + +export function getPostBySlug(slug: string): BlogPost | undefined { + const posts = getAllPosts(); + return posts.find((p) => p.slug === slug); +} + +export function getAllTags(): string[] { + const posts = getAllPosts(); + const tagSet = new Set(); + posts.forEach((post) => { + post.tags.forEach((tag) => tagSet.add(tag)); + }); + return Array.from(tagSet).sort(); +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 11f02fe..d807ab3 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1 +1,14 @@ /// + +declare module '*.mdx' { + import type { ComponentType } from 'react'; + export const frontmatter: { + title: string; + date: string; + author: string; + excerpt: string; + tags: string[]; + }; + const Component: ComponentType; + export default Component; +} diff --git a/vite.config.ts b/vite.config.ts index ecca98e..72177c8 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,9 +3,21 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import tailwindcss from '@tailwindcss/vite'; +import mdx from '@mdx-js/rollup'; +import remarkFrontmatter from 'remark-frontmatter'; +import remarkMdxFrontmatter from 'remark-mdx-frontmatter'; export default defineConfig({ - plugins: [react(), tailwindcss()], + plugins: [ + { + enforce: 'pre', + ...mdx({ + remarkPlugins: [remarkFrontmatter, remarkMdxFrontmatter], + }), + }, + react(), + tailwindcss(), + ], test: { environment: 'jsdom', setupFiles: './src/test/setup.ts',