@mars-stack/cli 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/template/.cursor/rules/composition-patterns.mdc +186 -0
- package/template/.cursor/rules/data-access.mdc +29 -0
- package/template/.cursor/rules/project-structure.mdc +34 -0
- package/template/.cursor/rules/security.mdc +25 -0
- package/template/.cursor/rules/testing.mdc +24 -0
- package/template/.cursor/rules/ui-conventions.mdc +29 -0
- package/template/.cursor/skills/add-api-route/SKILL.md +122 -0
- package/template/.cursor/skills/add-audit-log/SKILL.md +373 -0
- package/template/.cursor/skills/add-blog/SKILL.md +447 -0
- package/template/.cursor/skills/add-command-palette/SKILL.md +438 -0
- package/template/.cursor/skills/add-component/SKILL.md +158 -0
- package/template/.cursor/skills/add-crud-routes/SKILL.md +221 -0
- package/template/.cursor/skills/add-e2e-test/SKILL.md +227 -0
- package/template/.cursor/skills/add-error-boundary/SKILL.md +472 -0
- package/template/.cursor/skills/add-feature/SKILL.md +174 -0
- package/template/.cursor/skills/add-middleware/SKILL.md +135 -0
- package/template/.cursor/skills/add-page/SKILL.md +151 -0
- package/template/.cursor/skills/add-prisma-model/SKILL.md +148 -0
- package/template/.cursor/skills/add-protected-resource/SKILL.md +192 -0
- package/template/.cursor/skills/add-role/SKILL.md +156 -0
- package/template/.cursor/skills/add-server-action/SKILL.md +167 -0
- package/template/.cursor/skills/add-webhook/SKILL.md +192 -0
- package/template/.cursor/skills/build-complete-feature/SKILL.md +227 -0
- package/template/.cursor/skills/build-dashboard/SKILL.md +211 -0
- package/template/.cursor/skills/build-data-table/SKILL.md +283 -0
- package/template/.cursor/skills/build-form/SKILL.md +231 -0
- package/template/.cursor/skills/build-landing-page/SKILL.md +248 -0
- package/template/.cursor/skills/configure-ai/SKILL.md +617 -0
- package/template/.cursor/skills/configure-analytics/SKILL.md +413 -0
- package/template/.cursor/skills/configure-dark-mode/SKILL.md +309 -0
- package/template/.cursor/skills/configure-email/SKILL.md +170 -0
- package/template/.cursor/skills/configure-email-verification/SKILL.md +333 -0
- package/template/.cursor/skills/configure-feature-flags/SKILL.md +361 -0
- package/template/.cursor/skills/configure-i18n/SKILL.md +518 -0
- package/template/.cursor/skills/configure-jobs/SKILL.md +500 -0
- package/template/.cursor/skills/configure-magic-links/SKILL.md +385 -0
- package/template/.cursor/skills/configure-multi-tenancy/SKILL.md +611 -0
- package/template/.cursor/skills/configure-notifications/SKILL.md +569 -0
- package/template/.cursor/skills/configure-oauth/SKILL.md +217 -0
- package/template/.cursor/skills/configure-onboarding/SKILL.md +483 -0
- package/template/.cursor/skills/configure-payments/SKILL.md +243 -0
- package/template/.cursor/skills/configure-realtime/SKILL.md +733 -0
- package/template/.cursor/skills/configure-search/SKILL.md +581 -0
- package/template/.cursor/skills/configure-storage/SKILL.md +273 -0
- package/template/.cursor/skills/configure-two-factor/SKILL.md +518 -0
- package/template/.cursor/skills/create-execution-plan/SKILL.md +204 -0
- package/template/.cursor/skills/create-seed/SKILL.md +191 -0
- package/template/.cursor/skills/deploy-to-vercel/SKILL.md +300 -0
- package/template/.cursor/skills/design-tokens/SKILL.md +138 -0
- package/template/.cursor/skills/mars-capture-conversation-context/SKILL.md +119 -0
- package/template/.cursor/skills/setup-billing/SKILL.md +322 -0
- package/template/.cursor/skills/setup-project/SKILL.md +104 -0
- package/template/.cursor/skills/setup-teams/SKILL.md +682 -0
- package/template/.cursor/skills/test-api-route/SKILL.md +219 -0
- package/template/.cursor/skills/update-architecture-docs/SKILL.md +99 -0
- package/template/AGENTS.md +104 -0
- package/template/ARCHITECTURE.md +102 -0
- package/template/docs/QUALITY_SCORE.md +20 -0
- package/template/docs/design-docs/conversation-as-system-record.md +70 -0
- package/template/docs/design-docs/core-beliefs.md +43 -0
- package/template/docs/design-docs/index.md +8 -0
- package/template/docs/exec-plans/active/.gitkeep +0 -0
- package/template/docs/exec-plans/completed/.gitkeep +0 -0
- package/template/docs/exec-plans/tech-debt.md +7 -0
- package/template/docs/generated/.gitkeep +0 -0
- package/template/docs/product-specs/index.md +7 -0
- package/template/docs/references/index.md +18 -0
- package/template/e2e/api.spec.ts +20 -0
- package/template/e2e/auth.spec.ts +24 -0
- package/template/e2e/public.spec.ts +25 -0
- package/template/eslint.config.mjs +24 -0
- package/template/next-env.d.ts +6 -0
- package/template/next.config.ts +45 -0
- package/template/package.json +80 -0
- package/template/playwright.config.ts +31 -0
- package/template/postcss.config.mjs +8 -0
- package/template/prisma/generated/prisma/browser.ts +49 -0
- package/template/prisma/generated/prisma/client.ts +73 -0
- package/template/prisma/generated/prisma/commonInputTypes.ts +406 -0
- package/template/prisma/generated/prisma/enums.ts +15 -0
- package/template/prisma/generated/prisma/internal/class.ts +254 -0
- package/template/prisma/generated/prisma/internal/prismaNamespace.ts +1240 -0
- package/template/prisma/generated/prisma/internal/prismaNamespaceBrowser.ts +190 -0
- package/template/prisma/generated/prisma/models/Account.ts +1543 -0
- package/template/prisma/generated/prisma/models/File.ts +1529 -0
- package/template/prisma/generated/prisma/models/Session.ts +1415 -0
- package/template/prisma/generated/prisma/models/Subscription.ts +1455 -0
- package/template/prisma/generated/prisma/models/User.ts +2235 -0
- package/template/prisma/generated/prisma/models/VerificationToken.ts +1099 -0
- package/template/prisma/generated/prisma/models.ts +17 -0
- package/template/prisma/schema/auth.prisma +69 -0
- package/template/prisma/schema/base.prisma +8 -0
- package/template/prisma/schema/file.prisma +15 -0
- package/template/prisma/schema/subscription.prisma +17 -0
- package/template/prisma.config.ts +13 -0
- package/template/scripts/check-architecture.ts +221 -0
- package/template/scripts/check-doc-freshness.ts +242 -0
- package/template/scripts/ensure-db.mjs +291 -0
- package/template/scripts/generate-docs.ts +143 -0
- package/template/scripts/generate-env-example.ts +89 -0
- package/template/scripts/seed.ts +56 -0
- package/template/scripts/update-quality-score.ts +263 -0
- package/template/src/__tests__/architecture.test.ts +114 -0
- package/template/src/app/(auth)/forgotten-password/page.tsx +92 -0
- package/template/src/app/(auth)/layout.tsx +11 -0
- package/template/src/app/(auth)/register/page.tsx +162 -0
- package/template/src/app/(auth)/reset-password/page.tsx +109 -0
- package/template/src/app/(auth)/sign-in/page.tsx +122 -0
- package/template/src/app/(auth)/verify/[token]/page.tsx +87 -0
- package/template/src/app/(auth)/verify/page.tsx +56 -0
- package/template/src/app/(protected)/admin/page.tsx +108 -0
- package/template/src/app/(protected)/dashboard/loading.tsx +20 -0
- package/template/src/app/(protected)/dashboard/page.tsx +22 -0
- package/template/src/app/(protected)/layout.tsx +262 -0
- package/template/src/app/(protected)/settings/page.tsx +370 -0
- package/template/src/app/api/auth/forgot/route.ts +63 -0
- package/template/src/app/api/auth/login/route.ts +121 -0
- package/template/src/app/api/auth/logout/route.ts +19 -0
- package/template/src/app/api/auth/me/route.ts +30 -0
- package/template/src/app/api/auth/reset/route.ts +45 -0
- package/template/src/app/api/auth/signup/route.ts +85 -0
- package/template/src/app/api/auth/verify/route.ts +46 -0
- package/template/src/app/api/csrf/route.ts +12 -0
- package/template/src/app/api/health/route.ts +10 -0
- package/template/src/app/api/protected/admin/users/route.ts +24 -0
- package/template/src/app/api/protected/billing/checkout/route.ts +83 -0
- package/template/src/app/api/protected/billing/portal/route.ts +39 -0
- package/template/src/app/api/protected/files/[fileId]/route.ts +86 -0
- package/template/src/app/api/protected/files/upload/route.ts +64 -0
- package/template/src/app/api/protected/user/password/route.ts +63 -0
- package/template/src/app/api/protected/user/profile/route.ts +35 -0
- package/template/src/app/api/protected/user/sessions/[sessionId]/route.ts +33 -0
- package/template/src/app/api/protected/user/sessions/route.ts +22 -0
- package/template/src/app/api/readiness/route.ts +15 -0
- package/template/src/app/api/webhooks/stripe/route.ts +166 -0
- package/template/src/app/error.tsx +33 -0
- package/template/src/app/layout.tsx +29 -0
- package/template/src/app/not-found.tsx +20 -0
- package/template/src/app/page.tsx +136 -0
- package/template/src/app/privacy/page.tsx +178 -0
- package/template/src/app/providers.tsx +8 -0
- package/template/src/app/terms/page.tsx +139 -0
- package/template/src/config/app.config.ts +70 -0
- package/template/src/config/routes.ts +17 -0
- package/template/src/features/admin/index.ts +11 -0
- package/template/src/features/admin/permissions.ts +64 -0
- package/template/src/features/auth/context/AuthContext.tsx +96 -0
- package/template/src/features/auth/context/index.ts +2 -0
- package/template/src/features/auth/index.ts +3 -0
- package/template/src/features/auth/server/consent.ts +66 -0
- package/template/src/features/auth/server/session-revocation.ts +20 -0
- package/template/src/features/auth/server/sessions.ts +66 -0
- package/template/src/features/auth/server/user.ts +166 -0
- package/template/src/features/auth/types.ts +19 -0
- package/template/src/features/auth/validators.ts +29 -0
- package/template/src/features/billing/server/index.ts +66 -0
- package/template/src/features/billing/types.ts +43 -0
- package/template/src/features/uploads/server/index.ts +49 -0
- package/template/src/features/uploads/types.ts +26 -0
- package/template/src/lib/core/email/templates/base-layout.ts +122 -0
- package/template/src/lib/core/email/templates/index.ts +4 -0
- package/template/src/lib/core/email/templates/password-reset-email.ts +42 -0
- package/template/src/lib/core/email/templates/verification-email.ts +41 -0
- package/template/src/lib/core/email/templates/welcome-email.ts +40 -0
- package/template/src/lib/mars.ts +56 -0
- package/template/src/lib/prisma.ts +19 -0
- package/template/src/proxy.ts +92 -0
- package/template/src/styles/brand.css +17 -0
- package/template/src/styles/globals.css +6 -0
- package/template/tsconfig.json +59 -0
- package/template/vitest.config.ts +41 -0
- package/template/vitest.setup.ts +24 -0
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
# Skill: Add Blog
|
|
2
|
+
|
|
3
|
+
Set up an MDX-based blog with frontmatter, listing pages, RSS feed, and SEO in a MARS application.
|
|
4
|
+
|
|
5
|
+
## When to Use
|
|
6
|
+
|
|
7
|
+
Use this skill when the user asks to add a blog, create a content section, set up MDX pages, or add articles/posts to the app.
|
|
8
|
+
|
|
9
|
+
## Prerequisites
|
|
10
|
+
|
|
11
|
+
- Next.js app set up with the MARS template
|
|
12
|
+
- `appConfig.features.blog` set to `true` in `src/config/app.config.ts`
|
|
13
|
+
|
|
14
|
+
## Architecture
|
|
15
|
+
|
|
16
|
+
The blog uses MDX files stored in the repository with filesystem-based routing. Each post is an `.mdx` file with YAML frontmatter. The system provides:
|
|
17
|
+
1. **Content directory** — `content/blog/` at the project root
|
|
18
|
+
2. **Frontmatter schema** — validated with Zod
|
|
19
|
+
3. **Listing page** — paginated blog index with metadata
|
|
20
|
+
4. **Post page** — individual post rendering with table of contents
|
|
21
|
+
5. **RSS feed** — auto-generated `feed.xml`
|
|
22
|
+
6. **SEO** — JSON-LD structured data and Open Graph tags
|
|
23
|
+
|
|
24
|
+
## Step 1: Install Dependencies
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
yarn add @next/mdx @mdx-js/loader @mdx-js/react gray-matter reading-time remark-gfm rehype-pretty-code
|
|
28
|
+
yarn add -D @types/mdx
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Step 2: Directory Structure
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
content/
|
|
35
|
+
└── blog/
|
|
36
|
+
├── getting-started.mdx
|
|
37
|
+
└── building-with-mars.mdx
|
|
38
|
+
|
|
39
|
+
src/
|
|
40
|
+
├── features/blog/
|
|
41
|
+
│ ├── server/
|
|
42
|
+
│ │ └── index.ts # Post loading, parsing, RSS generation
|
|
43
|
+
│ ├── components/
|
|
44
|
+
│ │ ├── PostCard.tsx # Blog listing card
|
|
45
|
+
│ │ ├── PostHeader.tsx # Post title, date, reading time
|
|
46
|
+
│ │ ├── TableOfContents.tsx
|
|
47
|
+
│ │ └── index.ts
|
|
48
|
+
│ ├── validation/
|
|
49
|
+
│ │ └── schemas.ts # Frontmatter schema
|
|
50
|
+
│ └── types.ts
|
|
51
|
+
├── app/
|
|
52
|
+
│ └── blog/
|
|
53
|
+
│ ├── page.tsx # Blog listing
|
|
54
|
+
│ └── [slug]/
|
|
55
|
+
│ └── page.tsx # Individual post
|
|
56
|
+
│ └── feed.xml/
|
|
57
|
+
│ └── route.ts # RSS feed
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Step 3: Frontmatter Schema
|
|
61
|
+
|
|
62
|
+
```typescript
|
|
63
|
+
// src/features/blog/validation/schemas.ts
|
|
64
|
+
import { z } from 'zod';
|
|
65
|
+
|
|
66
|
+
export const frontmatterSchema = z.object({
|
|
67
|
+
title: z.string().min(1),
|
|
68
|
+
description: z.string().min(1).max(160),
|
|
69
|
+
date: z.string().refine((val) => !isNaN(Date.parse(val)), 'Invalid date'),
|
|
70
|
+
author: z.string().default('Team'),
|
|
71
|
+
tags: z.array(z.string()).default([]),
|
|
72
|
+
image: z.string().optional(),
|
|
73
|
+
published: z.boolean().default(true),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
export type Frontmatter = z.infer<typeof frontmatterSchema>;
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Step 4: Blog Types
|
|
80
|
+
|
|
81
|
+
```typescript
|
|
82
|
+
// src/features/blog/types.ts
|
|
83
|
+
import type { Frontmatter } from './validation/schemas';
|
|
84
|
+
|
|
85
|
+
export interface BlogPost {
|
|
86
|
+
slug: string;
|
|
87
|
+
frontmatter: Frontmatter;
|
|
88
|
+
content: string;
|
|
89
|
+
readingTime: string;
|
|
90
|
+
excerpt: string;
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Step 5: Server-Side Post Loading
|
|
95
|
+
|
|
96
|
+
```typescript
|
|
97
|
+
// src/features/blog/server/index.ts
|
|
98
|
+
import 'server-only';
|
|
99
|
+
|
|
100
|
+
import fs from 'fs';
|
|
101
|
+
import path from 'path';
|
|
102
|
+
import matter from 'gray-matter';
|
|
103
|
+
import readingTime from 'reading-time';
|
|
104
|
+
import { frontmatterSchema, type Frontmatter } from '../validation/schemas';
|
|
105
|
+
import type { BlogPost } from '../types';
|
|
106
|
+
|
|
107
|
+
const CONTENT_DIR = path.join(process.cwd(), 'content', 'blog');
|
|
108
|
+
|
|
109
|
+
export function getAllPosts(): BlogPost[] {
|
|
110
|
+
if (!fs.existsSync(CONTENT_DIR)) return [];
|
|
111
|
+
|
|
112
|
+
const files = fs.readdirSync(CONTENT_DIR).filter((f) => f.endsWith('.mdx'));
|
|
113
|
+
|
|
114
|
+
const posts = files
|
|
115
|
+
.map((filename) => {
|
|
116
|
+
const slug = filename.replace(/\.mdx$/, '');
|
|
117
|
+
const filePath = path.join(CONTENT_DIR, filename);
|
|
118
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
119
|
+
const { data, content } = matter(raw);
|
|
120
|
+
|
|
121
|
+
const parsed = frontmatterSchema.safeParse(data);
|
|
122
|
+
if (!parsed.success) {
|
|
123
|
+
console.warn(`Invalid frontmatter in ${filename}:`, parsed.error.message);
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (!parsed.data.published) return null;
|
|
128
|
+
|
|
129
|
+
const stats = readingTime(content);
|
|
130
|
+
const excerpt = content.replace(/^#+\s.*$/gm, '').trim().slice(0, 200) + '...';
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
slug,
|
|
134
|
+
frontmatter: parsed.data,
|
|
135
|
+
content,
|
|
136
|
+
readingTime: stats.text,
|
|
137
|
+
excerpt,
|
|
138
|
+
};
|
|
139
|
+
})
|
|
140
|
+
.filter(Boolean) as BlogPost[];
|
|
141
|
+
|
|
142
|
+
return posts.sort(
|
|
143
|
+
(a, b) => new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime(),
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function getPostBySlug(slug: string): BlogPost | null {
|
|
148
|
+
const filePath = path.join(CONTENT_DIR, `${slug}.mdx`);
|
|
149
|
+
|
|
150
|
+
if (!fs.existsSync(filePath)) return null;
|
|
151
|
+
|
|
152
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
153
|
+
const { data, content } = matter(raw);
|
|
154
|
+
|
|
155
|
+
const parsed = frontmatterSchema.safeParse(data);
|
|
156
|
+
if (!parsed.success) return null;
|
|
157
|
+
|
|
158
|
+
const stats = readingTime(content);
|
|
159
|
+
const excerpt = content.replace(/^#+\s.*$/gm, '').trim().slice(0, 200) + '...';
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
slug,
|
|
163
|
+
frontmatter: parsed.data,
|
|
164
|
+
content,
|
|
165
|
+
readingTime: stats.text,
|
|
166
|
+
excerpt,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function generateRSSFeed(posts: BlogPost[], siteUrl: string): string {
|
|
171
|
+
const items = posts
|
|
172
|
+
.map(
|
|
173
|
+
(post) => `
|
|
174
|
+
<item>
|
|
175
|
+
<title><![CDATA[${post.frontmatter.title}]]></title>
|
|
176
|
+
<link>${siteUrl}/blog/${post.slug}</link>
|
|
177
|
+
<guid isPermaLink="true">${siteUrl}/blog/${post.slug}</guid>
|
|
178
|
+
<description><![CDATA[${post.frontmatter.description}]]></description>
|
|
179
|
+
<pubDate>${new Date(post.frontmatter.date).toUTCString()}</pubDate>
|
|
180
|
+
${post.frontmatter.tags.map((tag) => `<category>${tag}</category>`).join('\n ')}
|
|
181
|
+
</item>`,
|
|
182
|
+
)
|
|
183
|
+
.join('\n');
|
|
184
|
+
|
|
185
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
186
|
+
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
|
187
|
+
<channel>
|
|
188
|
+
<title>Blog</title>
|
|
189
|
+
<link>${siteUrl}/blog</link>
|
|
190
|
+
<description>Latest posts</description>
|
|
191
|
+
<atom:link href="${siteUrl}/feed.xml" rel="self" type="application/rss+xml" />
|
|
192
|
+
${items}
|
|
193
|
+
</channel>
|
|
194
|
+
</rss>`;
|
|
195
|
+
}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
## Step 6: Blog Listing Page
|
|
199
|
+
|
|
200
|
+
```typescript
|
|
201
|
+
// src/app/blog/page.tsx
|
|
202
|
+
import { Metadata } from 'next';
|
|
203
|
+
import { getAllPosts } from '@/features/blog/server';
|
|
204
|
+
import { PostCard } from '@/features/blog/components/PostCard';
|
|
205
|
+
|
|
206
|
+
export const metadata: Metadata = {
|
|
207
|
+
title: 'Blog',
|
|
208
|
+
description: 'Latest articles and updates',
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
export default function BlogPage() {
|
|
212
|
+
const posts = getAllPosts();
|
|
213
|
+
|
|
214
|
+
return (
|
|
215
|
+
<div className="mx-auto max-w-4xl px-4 py-12">
|
|
216
|
+
<h1 className="text-3xl font-bold text-content-primary">Blog</h1>
|
|
217
|
+
<p className="mt-2 text-content-secondary">Latest articles and updates</p>
|
|
218
|
+
|
|
219
|
+
<div className="mt-8 space-y-8">
|
|
220
|
+
{posts.length === 0 && (
|
|
221
|
+
<p className="text-content-tertiary">No posts yet. Add .mdx files to content/blog/.</p>
|
|
222
|
+
)}
|
|
223
|
+
{posts.map((post) => (
|
|
224
|
+
<PostCard key={post.slug} post={post} />
|
|
225
|
+
))}
|
|
226
|
+
</div>
|
|
227
|
+
</div>
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
## Step 7: Post Card Component
|
|
233
|
+
|
|
234
|
+
```typescript
|
|
235
|
+
// src/features/blog/components/PostCard.tsx
|
|
236
|
+
import Link from 'next/link';
|
|
237
|
+
import type { BlogPost } from '../types';
|
|
238
|
+
|
|
239
|
+
export function PostCard({ post }: { post: BlogPost }) {
|
|
240
|
+
const date = new Date(post.frontmatter.date).toLocaleDateString('en-US', {
|
|
241
|
+
year: 'numeric',
|
|
242
|
+
month: 'long',
|
|
243
|
+
day: 'numeric',
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
return (
|
|
247
|
+
<article className="group">
|
|
248
|
+
<Link href={`/blog/${post.slug}`} className="block">
|
|
249
|
+
<div className="rounded-lg border border-border-primary bg-surface-primary p-6 transition-colors hover:border-interactive-primary">
|
|
250
|
+
<div className="flex items-center gap-3 text-sm text-content-tertiary">
|
|
251
|
+
<time dateTime={post.frontmatter.date}>{date}</time>
|
|
252
|
+
<span aria-hidden="true">·</span>
|
|
253
|
+
<span>{post.readingTime}</span>
|
|
254
|
+
</div>
|
|
255
|
+
<h2 className="mt-2 text-xl font-semibold text-content-primary group-hover:text-interactive-primary">
|
|
256
|
+
{post.frontmatter.title}
|
|
257
|
+
</h2>
|
|
258
|
+
<p className="mt-2 text-content-secondary">{post.frontmatter.description}</p>
|
|
259
|
+
{post.frontmatter.tags.length > 0 && (
|
|
260
|
+
<div className="mt-3 flex flex-wrap gap-2">
|
|
261
|
+
{post.frontmatter.tags.map((tag) => (
|
|
262
|
+
<span
|
|
263
|
+
key={tag}
|
|
264
|
+
className="rounded-full bg-surface-secondary px-2.5 py-0.5 text-xs font-medium text-content-secondary"
|
|
265
|
+
>
|
|
266
|
+
{tag}
|
|
267
|
+
</span>
|
|
268
|
+
))}
|
|
269
|
+
</div>
|
|
270
|
+
)}
|
|
271
|
+
</div>
|
|
272
|
+
</Link>
|
|
273
|
+
</article>
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
## Step 8: Individual Post Page
|
|
279
|
+
|
|
280
|
+
```typescript
|
|
281
|
+
// src/app/blog/[slug]/page.tsx
|
|
282
|
+
import { Metadata } from 'next';
|
|
283
|
+
import { notFound } from 'next/navigation';
|
|
284
|
+
import { getAllPosts, getPostBySlug } from '@/features/blog/server';
|
|
285
|
+
import { PostHeader } from '@/features/blog/components/PostHeader';
|
|
286
|
+
import { appConfig } from '@/config/app.config';
|
|
287
|
+
|
|
288
|
+
interface PostPageProps {
|
|
289
|
+
params: Promise<{ slug: string }>;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export async function generateStaticParams() {
|
|
293
|
+
const posts = getAllPosts();
|
|
294
|
+
return posts.map((post) => ({ slug: post.slug }));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export async function generateMetadata({ params }: PostPageProps): Promise<Metadata> {
|
|
298
|
+
const { slug } = await params;
|
|
299
|
+
const post = getPostBySlug(slug);
|
|
300
|
+
if (!post) return {};
|
|
301
|
+
|
|
302
|
+
const siteUrl = process.env.APP_URL || 'http://localhost:3000';
|
|
303
|
+
|
|
304
|
+
return {
|
|
305
|
+
title: post.frontmatter.title,
|
|
306
|
+
description: post.frontmatter.description,
|
|
307
|
+
openGraph: {
|
|
308
|
+
title: post.frontmatter.title,
|
|
309
|
+
description: post.frontmatter.description,
|
|
310
|
+
type: 'article',
|
|
311
|
+
publishedTime: post.frontmatter.date,
|
|
312
|
+
url: `${siteUrl}/blog/${slug}`,
|
|
313
|
+
images: post.frontmatter.image ? [{ url: post.frontmatter.image }] : [],
|
|
314
|
+
},
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export default async function PostPage({ params }: PostPageProps) {
|
|
319
|
+
const { slug } = await params;
|
|
320
|
+
const post = getPostBySlug(slug);
|
|
321
|
+
if (!post) notFound();
|
|
322
|
+
|
|
323
|
+
const siteUrl = process.env.APP_URL || 'http://localhost:3000';
|
|
324
|
+
|
|
325
|
+
const jsonLd = {
|
|
326
|
+
'@context': 'https://schema.org',
|
|
327
|
+
'@type': 'BlogPosting',
|
|
328
|
+
headline: post.frontmatter.title,
|
|
329
|
+
description: post.frontmatter.description,
|
|
330
|
+
datePublished: post.frontmatter.date,
|
|
331
|
+
author: { '@type': 'Person', name: post.frontmatter.author },
|
|
332
|
+
url: `${siteUrl}/blog/${slug}`,
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
return (
|
|
336
|
+
<article className="mx-auto max-w-3xl px-4 py-12">
|
|
337
|
+
<script
|
|
338
|
+
type="application/ld+json"
|
|
339
|
+
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
|
340
|
+
/>
|
|
341
|
+
<PostHeader
|
|
342
|
+
title={post.frontmatter.title}
|
|
343
|
+
date={post.frontmatter.date}
|
|
344
|
+
author={post.frontmatter.author}
|
|
345
|
+
readingTime={post.readingTime}
|
|
346
|
+
tags={post.frontmatter.tags}
|
|
347
|
+
/>
|
|
348
|
+
<div className="prose prose-neutral dark:prose-invert mt-8 max-w-none">
|
|
349
|
+
{/* Render MDX content here using your MDX pipeline */}
|
|
350
|
+
</div>
|
|
351
|
+
</article>
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
## Step 9: RSS Feed Route
|
|
357
|
+
|
|
358
|
+
```typescript
|
|
359
|
+
// src/app/feed.xml/route.ts
|
|
360
|
+
import { getAllPosts, generateRSSFeed } from '@/features/blog/server';
|
|
361
|
+
|
|
362
|
+
export async function GET() {
|
|
363
|
+
const posts = getAllPosts();
|
|
364
|
+
const siteUrl = process.env.APP_URL || 'http://localhost:3000';
|
|
365
|
+
const feed = generateRSSFeed(posts, siteUrl);
|
|
366
|
+
|
|
367
|
+
return new Response(feed, {
|
|
368
|
+
headers: {
|
|
369
|
+
'Content-Type': 'application/xml',
|
|
370
|
+
'Cache-Control': 'public, max-age=3600, s-maxage=3600',
|
|
371
|
+
},
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
## Step 10: Sample Post
|
|
377
|
+
|
|
378
|
+
```markdown
|
|
379
|
+
---
|
|
380
|
+
title: "Getting Started with Your App"
|
|
381
|
+
description: "A quick guide to setting up and customizing your new application."
|
|
382
|
+
date: "2025-01-15"
|
|
383
|
+
author: "Team"
|
|
384
|
+
tags: ["getting-started", "tutorial"]
|
|
385
|
+
published: true
|
|
386
|
+
---
|
|
387
|
+
|
|
388
|
+
# Getting Started
|
|
389
|
+
|
|
390
|
+
Welcome to your new application! This guide walks you through the basics.
|
|
391
|
+
|
|
392
|
+
## Prerequisites
|
|
393
|
+
|
|
394
|
+
Make sure you have the following installed:
|
|
395
|
+
- Node.js 18+
|
|
396
|
+
- Yarn
|
|
397
|
+
|
|
398
|
+
## Next Steps
|
|
399
|
+
|
|
400
|
+
Explore the dashboard and customize your settings.
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
## Step 11: Next.js MDX Configuration
|
|
404
|
+
|
|
405
|
+
Update `next.config.mjs`:
|
|
406
|
+
|
|
407
|
+
```typescript
|
|
408
|
+
import createMDX from '@next/mdx';
|
|
409
|
+
import remarkGfm from 'remark-gfm';
|
|
410
|
+
|
|
411
|
+
const withMDX = createMDX({
|
|
412
|
+
options: {
|
|
413
|
+
remarkPlugins: [remarkGfm],
|
|
414
|
+
rehypePlugins: [],
|
|
415
|
+
},
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
const nextConfig = {
|
|
419
|
+
pageExtensions: ['ts', 'tsx', 'md', 'mdx'],
|
|
420
|
+
// ... existing config
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
export default withMDX(nextConfig);
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
## Testing
|
|
427
|
+
|
|
428
|
+
1. Create a `.mdx` file in `content/blog/` — verify it appears on `/blog`.
|
|
429
|
+
2. Click a post — verify the individual page renders with correct metadata.
|
|
430
|
+
3. Visit `/feed.xml` — verify valid RSS with all published posts.
|
|
431
|
+
4. Add a post with `published: false` — verify it is excluded from the listing and feed.
|
|
432
|
+
5. Check Open Graph tags with a social card validator.
|
|
433
|
+
6. Verify JSON-LD structured data with Google's Rich Results Test.
|
|
434
|
+
|
|
435
|
+
## Checklist
|
|
436
|
+
|
|
437
|
+
- [ ] MDX dependencies installed (`@next/mdx`, `gray-matter`, `reading-time`, etc.)
|
|
438
|
+
- [ ] `content/blog/` directory created with at least one sample post
|
|
439
|
+
- [ ] Frontmatter schema validates all post metadata
|
|
440
|
+
- [ ] Blog listing page renders all published posts sorted by date
|
|
441
|
+
- [ ] Individual post page renders MDX content with metadata
|
|
442
|
+
- [ ] RSS feed generated at `/feed.xml`
|
|
443
|
+
- [ ] JSON-LD structured data on each post page
|
|
444
|
+
- [ ] Open Graph metadata for social sharing
|
|
445
|
+
- [ ] `generateStaticParams` enables static generation
|
|
446
|
+
- [ ] `next.config.mjs` updated for MDX support
|
|
447
|
+
- [ ] Feature flag checked (`appConfig.features.blog`)
|