@zoyth/simple-site-framework 1.0.2 → 1.1.0
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/dist/components/index.d.mts +74 -1
- package/dist/components/index.d.ts +74 -1
- package/dist/components/index.js +210 -2
- package/dist/components/index.js.map +1 -1
- package/dist/components/index.mjs +208 -2
- package/dist/components/index.mjs.map +1 -1
- package/dist/index.d.mts +113 -2
- package/dist/index.d.ts +113 -2
- package/dist/index.js +196 -16
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +187 -16
- package/dist/index.mjs.map +1 -1
- package/docs/BLOG.md +1005 -0
- package/docs/guides/webflow-migration.md +300 -0
- package/package.json +2 -2
package/docs/BLOG.md
ADDED
|
@@ -0,0 +1,1005 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
# Blog System Guide
|
|
4
|
+
|
|
5
|
+
Comprehensive guide for creating and managing a blog using markdown files with full SEO, RSS, filtering, and bilingual support.
|
|
6
|
+
|
|
7
|
+
## Overview
|
|
8
|
+
|
|
9
|
+
The blog system provides everything needed to run a production blog:
|
|
10
|
+
|
|
11
|
+
- ✅ Markdown-based content with rich frontmatter
|
|
12
|
+
- ✅ Pre-built components for layout, index, and cards
|
|
13
|
+
- ✅ Tag-based filtering and featured posts
|
|
14
|
+
- ✅ Related posts by shared tags
|
|
15
|
+
- ✅ RSS 2.0 feed generation
|
|
16
|
+
- ✅ Article-specific SEO metadata and JSON-LD structured data
|
|
17
|
+
- ✅ Multi-language support (EN/FR)
|
|
18
|
+
- ✅ Static generation for optimal performance
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Quick Start
|
|
23
|
+
|
|
24
|
+
### 1. Install Dependencies
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npm install next-mdx-remote
|
|
28
|
+
npm install -D @tailwindcss/typography
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### 2. Create Blog Directory
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
mkdir -p src/content/blog
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### 3. Write a Blog Post
|
|
38
|
+
|
|
39
|
+
```markdown
|
|
40
|
+
<!-- src/content/blog/hello-world.en.md -->
|
|
41
|
+
---
|
|
42
|
+
title: "Hello World"
|
|
43
|
+
excerpt: "Our very first blog post"
|
|
44
|
+
author: "Jane Doe"
|
|
45
|
+
date: "2026-02-20"
|
|
46
|
+
readTime: 3
|
|
47
|
+
tags: ["announcements"]
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
# Hello World
|
|
51
|
+
|
|
52
|
+
Welcome to our blog! This is our first post.
|
|
53
|
+
|
|
54
|
+
## What We'll Cover
|
|
55
|
+
|
|
56
|
+
We'll be writing about product updates, tips, and industry insights.
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### 4. Create Blog Pages
|
|
60
|
+
|
|
61
|
+
```typescript
|
|
62
|
+
// app/[locale]/blog/page.tsx
|
|
63
|
+
import { getAllBlogPosts } from 'simple-site-framework/lib/content';
|
|
64
|
+
import { BlogIndex } from 'simple-site-framework';
|
|
65
|
+
|
|
66
|
+
export default async function BlogPage({
|
|
67
|
+
params
|
|
68
|
+
}: {
|
|
69
|
+
params: { locale: string }
|
|
70
|
+
}) {
|
|
71
|
+
const posts = await getAllBlogPosts(params.locale);
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<BlogIndex
|
|
75
|
+
locale={params.locale}
|
|
76
|
+
posts={posts}
|
|
77
|
+
title="Blog"
|
|
78
|
+
/>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
## Content Format
|
|
86
|
+
|
|
87
|
+
### Frontmatter Fields
|
|
88
|
+
|
|
89
|
+
Every blog post markdown file must start with YAML frontmatter:
|
|
90
|
+
|
|
91
|
+
```markdown
|
|
92
|
+
---
|
|
93
|
+
title: "Your Post Title"
|
|
94
|
+
excerpt: "A short description for previews and SEO"
|
|
95
|
+
author: "Author Name"
|
|
96
|
+
date: "2026-02-20"
|
|
97
|
+
readTime: 5
|
|
98
|
+
tags: ["product", "tips"]
|
|
99
|
+
featured: true
|
|
100
|
+
image: "/blog/post-image.jpg"
|
|
101
|
+
imageAlt: "Description of the image"
|
|
102
|
+
---
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
**Required Fields:**
|
|
106
|
+
|
|
107
|
+
| Field | Type | Description |
|
|
108
|
+
|-------|------|-------------|
|
|
109
|
+
| `title` | string | Post title displayed in header and cards |
|
|
110
|
+
| `excerpt` | string | Short description for previews and meta description |
|
|
111
|
+
| `author` | string | Author name |
|
|
112
|
+
| `date` | string | Publication date in ISO format (YYYY-MM-DD) |
|
|
113
|
+
| `tags` | string[] | Array of tag strings for categorization |
|
|
114
|
+
|
|
115
|
+
**Optional Fields:**
|
|
116
|
+
|
|
117
|
+
| Field | Type | Default | Description |
|
|
118
|
+
|-------|------|---------|-------------|
|
|
119
|
+
| `readTime` | number | - | Reading time in minutes |
|
|
120
|
+
| `featured` | boolean | `false` | Mark as featured post |
|
|
121
|
+
| `image` | string | - | Featured image URL |
|
|
122
|
+
| `imageAlt` | string | title | Alt text for featured image |
|
|
123
|
+
|
|
124
|
+
Custom fields can be added and accessed via `metadata[key]`.
|
|
125
|
+
|
|
126
|
+
### File Naming Convention
|
|
127
|
+
|
|
128
|
+
**Format:** `{slug}.{locale}.md`
|
|
129
|
+
|
|
130
|
+
- **slug**: Kebab-case identifier (e.g., `getting-started`, `product-update-q1`)
|
|
131
|
+
- **locale**: Language code (e.g., `en`, `fr`, `en-US`)
|
|
132
|
+
- **extension**: `.md` for markdown, `.mdx` for MDX
|
|
133
|
+
|
|
134
|
+
**Examples:**
|
|
135
|
+
- ✅ `getting-started.en.md`
|
|
136
|
+
- ✅ `getting-started.fr.md`
|
|
137
|
+
- ✅ `product-update.en-US.mdx`
|
|
138
|
+
- ❌ `getting_started.md` (missing locale)
|
|
139
|
+
- ❌ `Getting Started.en.md` (spaces not allowed)
|
|
140
|
+
|
|
141
|
+
### Directory Structure
|
|
142
|
+
|
|
143
|
+
```
|
|
144
|
+
project/
|
|
145
|
+
├── src/
|
|
146
|
+
│ ├── content/
|
|
147
|
+
│ │ └── blog/
|
|
148
|
+
│ │ ├── getting-started.en.md
|
|
149
|
+
│ │ ├── getting-started.fr.md
|
|
150
|
+
│ │ ├── product-update.en.md
|
|
151
|
+
│ │ ├── product-update.fr.md
|
|
152
|
+
│ │ ├── tips-and-tricks.en.md
|
|
153
|
+
│ │ └── tips-and-tricks.fr.md
|
|
154
|
+
│ └── app/
|
|
155
|
+
│ └── [locale]/
|
|
156
|
+
│ └── blog/
|
|
157
|
+
│ ├── page.tsx # Blog index
|
|
158
|
+
│ ├── [slug]/
|
|
159
|
+
│ │ └── page.tsx # Individual post
|
|
160
|
+
│ └── feed.xml/
|
|
161
|
+
│ └── route.ts # RSS feed
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
## Loading Functions
|
|
167
|
+
|
|
168
|
+
### loadBlogPost()
|
|
169
|
+
|
|
170
|
+
Load and compile a single blog post markdown file.
|
|
171
|
+
|
|
172
|
+
```typescript
|
|
173
|
+
import { loadBlogPost } from 'simple-site-framework/lib/content';
|
|
174
|
+
|
|
175
|
+
const { content, metadata, slug, locale } = await loadBlogPost(
|
|
176
|
+
'getting-started', // slug
|
|
177
|
+
'en', // locale
|
|
178
|
+
'src/content/blog' // optional: custom directory
|
|
179
|
+
);
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
**Parameters:**
|
|
183
|
+
- `slug` (string): Blog post slug (filename without locale/extension)
|
|
184
|
+
- `locale` (string): Locale code
|
|
185
|
+
- `contentDir` (string, optional): Custom directory path, default: `'src/content/blog'`
|
|
186
|
+
|
|
187
|
+
**Returns:**
|
|
188
|
+
```typescript
|
|
189
|
+
{
|
|
190
|
+
content: JSX.Element, // Compiled MDX content
|
|
191
|
+
metadata: BlogPostMetadata, // Frontmatter data
|
|
192
|
+
slug: string, // Post slug
|
|
193
|
+
locale: string // Locale
|
|
194
|
+
}
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
**Throws:**
|
|
198
|
+
- Error if file not found
|
|
199
|
+
- Error if required frontmatter field missing (`title`, `excerpt`, `author`, `date`, `tags`)
|
|
200
|
+
|
|
201
|
+
---
|
|
202
|
+
|
|
203
|
+
### getBlogPostSlugs()
|
|
204
|
+
|
|
205
|
+
Get all unique blog post slugs (deduplicated across locales).
|
|
206
|
+
|
|
207
|
+
```typescript
|
|
208
|
+
import { getBlogPostSlugs } from 'simple-site-framework/lib/content';
|
|
209
|
+
|
|
210
|
+
const slugs = getBlogPostSlugs();
|
|
211
|
+
// Returns: ['getting-started', 'product-update', 'tips-and-tricks']
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
**Parameters:**
|
|
215
|
+
- `contentDir` (string, optional): Custom directory path, default: `'src/content/blog'`
|
|
216
|
+
|
|
217
|
+
**Returns:** `string[]` - Array of unique slugs. Returns empty array if directory not found.
|
|
218
|
+
|
|
219
|
+
---
|
|
220
|
+
|
|
221
|
+
### getAllBlogPosts()
|
|
222
|
+
|
|
223
|
+
Get all blog posts for a locale with metadata, sorted by date descending.
|
|
224
|
+
|
|
225
|
+
```typescript
|
|
226
|
+
import { getAllBlogPosts } from 'simple-site-framework/lib/content';
|
|
227
|
+
|
|
228
|
+
const posts = await getAllBlogPosts('en');
|
|
229
|
+
|
|
230
|
+
// Returns array of:
|
|
231
|
+
// [
|
|
232
|
+
// { slug: 'product-update', locale: 'en', metadata: {...} },
|
|
233
|
+
// { slug: 'getting-started', locale: 'en', metadata: {...} }
|
|
234
|
+
// ]
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
**Parameters:**
|
|
238
|
+
- `locale` (string): Locale code
|
|
239
|
+
- `contentDir` (string, optional): Custom directory path
|
|
240
|
+
|
|
241
|
+
**Returns:** `Omit<BlogPost, 'content'>[]` - Posts sorted by date descending. Posts that fail to load for the given locale are silently skipped.
|
|
242
|
+
|
|
243
|
+
---
|
|
244
|
+
|
|
245
|
+
### getBlogPostLocales()
|
|
246
|
+
|
|
247
|
+
Get available locales for a specific blog post.
|
|
248
|
+
|
|
249
|
+
```typescript
|
|
250
|
+
import { getBlogPostLocales } from 'simple-site-framework/lib/content';
|
|
251
|
+
|
|
252
|
+
const locales = getBlogPostLocales('getting-started');
|
|
253
|
+
// Returns: ['en', 'fr']
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
**Parameters:**
|
|
257
|
+
- `slug` (string): Blog post slug
|
|
258
|
+
- `contentDir` (string, optional): Custom directory path
|
|
259
|
+
|
|
260
|
+
**Returns:** `string[]` - Array of locale codes
|
|
261
|
+
|
|
262
|
+
---
|
|
263
|
+
|
|
264
|
+
## Filtering and Sorting
|
|
265
|
+
|
|
266
|
+
### getBlogPostsByTag()
|
|
267
|
+
|
|
268
|
+
Get all blog posts matching a specific tag, sorted by date descending.
|
|
269
|
+
|
|
270
|
+
```typescript
|
|
271
|
+
import { getBlogPostsByTag } from 'simple-site-framework/lib/content';
|
|
272
|
+
|
|
273
|
+
const posts = await getBlogPostsByTag('product', 'en');
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
**Parameters:**
|
|
277
|
+
- `tag` (string): Tag to filter by (exact match)
|
|
278
|
+
- `locale` (string): Locale code
|
|
279
|
+
- `contentDir` (string, optional): Custom directory path
|
|
280
|
+
|
|
281
|
+
**Returns:** `Omit<BlogPost, 'content'>[]`
|
|
282
|
+
|
|
283
|
+
---
|
|
284
|
+
|
|
285
|
+
### getFeaturedBlogPosts()
|
|
286
|
+
|
|
287
|
+
Get posts marked as featured, sorted by date descending.
|
|
288
|
+
|
|
289
|
+
```typescript
|
|
290
|
+
import { getFeaturedBlogPosts } from 'simple-site-framework/lib/content';
|
|
291
|
+
|
|
292
|
+
const featured = await getFeaturedBlogPosts('en');
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
**Parameters:**
|
|
296
|
+
- `locale` (string): Locale code
|
|
297
|
+
- `contentDir` (string, optional): Custom directory path
|
|
298
|
+
|
|
299
|
+
**Returns:** `Omit<BlogPost, 'content'>[]` - Posts where `metadata.featured === true`
|
|
300
|
+
|
|
301
|
+
---
|
|
302
|
+
|
|
303
|
+
### getRelatedBlogPosts()
|
|
304
|
+
|
|
305
|
+
Get posts related to a given post by shared tags. Sorted by number of shared tags (descending), then by date (descending).
|
|
306
|
+
|
|
307
|
+
```typescript
|
|
308
|
+
import { getRelatedBlogPosts } from 'simple-site-framework/lib/content';
|
|
309
|
+
|
|
310
|
+
const related = await getRelatedBlogPosts(
|
|
311
|
+
'getting-started', // source post slug
|
|
312
|
+
'en', // locale
|
|
313
|
+
3 // max results (default: 3)
|
|
314
|
+
);
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
**Parameters:**
|
|
318
|
+
- `slug` (string): Source blog post slug
|
|
319
|
+
- `locale` (string): Locale code
|
|
320
|
+
- `count` (number, optional): Maximum results, default: `3`
|
|
321
|
+
- `contentDir` (string, optional): Custom directory path
|
|
322
|
+
|
|
323
|
+
**Returns:** `Omit<BlogPost, 'content'>[]` - Only posts with at least one shared tag. Returns empty array if source post not found.
|
|
324
|
+
|
|
325
|
+
---
|
|
326
|
+
|
|
327
|
+
### getAllTags()
|
|
328
|
+
|
|
329
|
+
Get all unique tags across all blog posts with occurrence counts, sorted by count descending.
|
|
330
|
+
|
|
331
|
+
```typescript
|
|
332
|
+
import { getAllTags } from 'simple-site-framework/lib/content';
|
|
333
|
+
import type { TagCount } from 'simple-site-framework/lib/content';
|
|
334
|
+
|
|
335
|
+
const tags: TagCount[] = await getAllTags('en');
|
|
336
|
+
// Returns: [
|
|
337
|
+
// { tag: 'product', count: 5 },
|
|
338
|
+
// { tag: 'tips', count: 3 },
|
|
339
|
+
// { tag: 'announcements', count: 1 }
|
|
340
|
+
// ]
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
**TagCount type:**
|
|
344
|
+
```typescript
|
|
345
|
+
interface TagCount {
|
|
346
|
+
tag: string;
|
|
347
|
+
count: number;
|
|
348
|
+
}
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
---
|
|
352
|
+
|
|
353
|
+
## Components
|
|
354
|
+
|
|
355
|
+
### BlogLayout
|
|
356
|
+
|
|
357
|
+
Layout component for rendering individual blog post pages. Provides header with author/date/tags, featured image, prose-styled content, and optional table of contents sidebar.
|
|
358
|
+
|
|
359
|
+
```typescript
|
|
360
|
+
import { BlogLayout } from 'simple-site-framework';
|
|
361
|
+
|
|
362
|
+
<BlogLayout
|
|
363
|
+
title={metadata.title}
|
|
364
|
+
excerpt={metadata.excerpt}
|
|
365
|
+
author={metadata.author}
|
|
366
|
+
date={metadata.date}
|
|
367
|
+
readTime={metadata.readTime}
|
|
368
|
+
tags={metadata.tags}
|
|
369
|
+
image={metadata.image}
|
|
370
|
+
imageAlt={metadata.imageAlt}
|
|
371
|
+
locale={locale}
|
|
372
|
+
showToc={true}
|
|
373
|
+
>
|
|
374
|
+
{content}
|
|
375
|
+
</BlogLayout>
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
**Props:**
|
|
379
|
+
|
|
380
|
+
| Prop | Type | Default | Description |
|
|
381
|
+
|------|------|---------|-------------|
|
|
382
|
+
| `title` | string | Required | Post title |
|
|
383
|
+
| `excerpt` | string | Required | Short description |
|
|
384
|
+
| `author` | string | Required | Author name |
|
|
385
|
+
| `date` | string | Required | Publication date (ISO YYYY-MM-DD) |
|
|
386
|
+
| `readTime` | number | Required | Reading time in minutes |
|
|
387
|
+
| `tags` | string[] | Required | Post tags |
|
|
388
|
+
| `locale` | string | Required | Current locale |
|
|
389
|
+
| `children` | ReactNode | Required | Post content (from MDX) |
|
|
390
|
+
| `authorAvatar` | string | - | Author avatar URL |
|
|
391
|
+
| `image` | string | - | Featured image URL |
|
|
392
|
+
| `imageAlt` | string | title | Featured image alt text |
|
|
393
|
+
| `showToc` | boolean | `true` | Show table of contents sidebar |
|
|
394
|
+
| `backHref` | string | `"/{locale}/blog"` | Back link URL |
|
|
395
|
+
| `backLabel` | string | `"Back to blog"` / `"Retour au blog"` | Back link label |
|
|
396
|
+
| `className` | string | - | Additional CSS classes |
|
|
397
|
+
|
|
398
|
+
---
|
|
399
|
+
|
|
400
|
+
### BlogIndex
|
|
401
|
+
|
|
402
|
+
Blog listing page component with tag filtering and responsive grid. Renders posts using BlogCard with optional featured section and tag filter bar.
|
|
403
|
+
|
|
404
|
+
```typescript
|
|
405
|
+
import { BlogIndex } from 'simple-site-framework';
|
|
406
|
+
|
|
407
|
+
<BlogIndex
|
|
408
|
+
locale={locale}
|
|
409
|
+
posts={posts}
|
|
410
|
+
title="Blog"
|
|
411
|
+
description="Latest articles and updates"
|
|
412
|
+
showTagFilter={true}
|
|
413
|
+
cardVariant="default"
|
|
414
|
+
featuredFirst={true}
|
|
415
|
+
/>
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
**Props:**
|
|
419
|
+
|
|
420
|
+
| Prop | Type | Default | Description |
|
|
421
|
+
|------|------|---------|-------------|
|
|
422
|
+
| `locale` | string | Required | Current locale |
|
|
423
|
+
| `posts` | Array<{ slug, metadata }> | Required | Blog posts to display |
|
|
424
|
+
| `title` | LocalizedString \| string | - | Page title |
|
|
425
|
+
| `description` | LocalizedString \| string | - | Page description |
|
|
426
|
+
| `showTagFilter` | boolean | `true` | Show tag filter bar |
|
|
427
|
+
| `cardVariant` | `'default'` \| `'horizontal'` \| `'minimal'` | `'default'` | BlogCard display variant |
|
|
428
|
+
| `featuredFirst` | boolean | `true` | Show featured posts prominently |
|
|
429
|
+
| `className` | string | - | Additional CSS classes |
|
|
430
|
+
|
|
431
|
+
**Features:**
|
|
432
|
+
- Client-side tag filtering via clickable tag buttons
|
|
433
|
+
- Featured posts displayed in a 2-column grid above regular posts
|
|
434
|
+
- Regular posts in a 3-column responsive grid
|
|
435
|
+
- Empty state message when no posts match filter
|
|
436
|
+
- Automatic locale-aware labels (EN/FR)
|
|
437
|
+
|
|
438
|
+
---
|
|
439
|
+
|
|
440
|
+
### BlogCard
|
|
441
|
+
|
|
442
|
+
Article preview card for blog listings. Supports three display variants.
|
|
443
|
+
|
|
444
|
+
```typescript
|
|
445
|
+
import { BlogCard } from 'simple-site-framework';
|
|
446
|
+
|
|
447
|
+
<BlogCard
|
|
448
|
+
locale="en"
|
|
449
|
+
title="10 Tips for Better UX"
|
|
450
|
+
excerpt="Improve your user experience with these strategies..."
|
|
451
|
+
image="/blog/ux-tips.jpg"
|
|
452
|
+
href="/en/blog/ux-tips"
|
|
453
|
+
author="Jane Doe"
|
|
454
|
+
date="2026-02-20"
|
|
455
|
+
readTime={5}
|
|
456
|
+
tags={['UX', 'Design']}
|
|
457
|
+
variant="default"
|
|
458
|
+
/>
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
**Props:**
|
|
462
|
+
|
|
463
|
+
| Prop | Type | Default | Description |
|
|
464
|
+
|------|------|---------|-------------|
|
|
465
|
+
| `locale` | `'en'` \| `'fr'` | `'en'` | Current locale |
|
|
466
|
+
| `title` | LocalizedString \| string | Required | Article title |
|
|
467
|
+
| `excerpt` | LocalizedString \| string | - | Article excerpt |
|
|
468
|
+
| `image` | string | - | Featured image URL |
|
|
469
|
+
| `imageAlt` | string | title | Image alt text |
|
|
470
|
+
| `href` | string | Required | Article URL |
|
|
471
|
+
| `author` | string | - | Author name |
|
|
472
|
+
| `authorAvatar` | string | - | Author avatar URL |
|
|
473
|
+
| `date` | string | - | Publication date |
|
|
474
|
+
| `readTime` | number | - | Read time in minutes |
|
|
475
|
+
| `tags` | string[] | - | Tags/categories |
|
|
476
|
+
| `variant` | `'default'` \| `'horizontal'` \| `'minimal'` | `'default'` | Card display variant |
|
|
477
|
+
| `className` | string | - | Additional CSS classes |
|
|
478
|
+
|
|
479
|
+
**Variants:**
|
|
480
|
+
- **default** - Vertical card with image on top, tags overlaid on image
|
|
481
|
+
- **horizontal** - Side-by-side image and content layout
|
|
482
|
+
- **minimal** - Text-only with border-bottom, single tag badge
|
|
483
|
+
|
|
484
|
+
---
|
|
485
|
+
|
|
486
|
+
## Route Setup
|
|
487
|
+
|
|
488
|
+
### Blog Index Page
|
|
489
|
+
|
|
490
|
+
```typescript
|
|
491
|
+
// app/[locale]/blog/page.tsx
|
|
492
|
+
import { getAllBlogPosts } from 'simple-site-framework/lib/content';
|
|
493
|
+
import { BlogIndex } from 'simple-site-framework';
|
|
494
|
+
|
|
495
|
+
export default async function BlogPage({
|
|
496
|
+
params
|
|
497
|
+
}: {
|
|
498
|
+
params: { locale: string }
|
|
499
|
+
}) {
|
|
500
|
+
const posts = await getAllBlogPosts(params.locale);
|
|
501
|
+
|
|
502
|
+
return (
|
|
503
|
+
<BlogIndex
|
|
504
|
+
locale={params.locale}
|
|
505
|
+
posts={posts}
|
|
506
|
+
title={{ en: 'Blog', fr: 'Blogue' }}
|
|
507
|
+
description={{
|
|
508
|
+
en: 'Latest articles and updates',
|
|
509
|
+
fr: 'Derniers articles et mises à jour'
|
|
510
|
+
}}
|
|
511
|
+
/>
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
export async function generateStaticParams() {
|
|
516
|
+
return [
|
|
517
|
+
{ locale: 'en' },
|
|
518
|
+
{ locale: 'fr' },
|
|
519
|
+
];
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
export async function generateMetadata({ params }: { params: { locale: string } }) {
|
|
523
|
+
const isFr = params.locale === 'fr';
|
|
524
|
+
return {
|
|
525
|
+
title: isFr ? 'Blogue' : 'Blog',
|
|
526
|
+
description: isFr
|
|
527
|
+
? 'Derniers articles et mises à jour'
|
|
528
|
+
: 'Latest articles and updates',
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
### Blog Post Page
|
|
534
|
+
|
|
535
|
+
```typescript
|
|
536
|
+
// app/[locale]/blog/[slug]/page.tsx
|
|
537
|
+
import { loadBlogPost, getBlogPostSlugs, getRelatedBlogPosts } from 'simple-site-framework/lib/content';
|
|
538
|
+
import { BlogLayout, BlogCard } from 'simple-site-framework';
|
|
539
|
+
import { generateArticleMetadata } from 'simple-site-framework';
|
|
540
|
+
import { createArticle, createOrganization, serializeStructuredData } from 'simple-site-framework';
|
|
541
|
+
import { notFound } from 'next/navigation';
|
|
542
|
+
import type { Metadata } from 'next';
|
|
543
|
+
|
|
544
|
+
export default async function BlogPostPage({
|
|
545
|
+
params
|
|
546
|
+
}: {
|
|
547
|
+
params: { locale: string; slug: string }
|
|
548
|
+
}) {
|
|
549
|
+
let post;
|
|
550
|
+
try {
|
|
551
|
+
post = await loadBlogPost(params.slug, params.locale);
|
|
552
|
+
} catch {
|
|
553
|
+
notFound();
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const related = await getRelatedBlogPosts(params.slug, params.locale);
|
|
557
|
+
|
|
558
|
+
// JSON-LD structured data
|
|
559
|
+
const publisher = createOrganization({
|
|
560
|
+
name: 'Your Company',
|
|
561
|
+
url: 'https://example.com',
|
|
562
|
+
logo: 'https://example.com/logo.png',
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
const articleData = createArticle({
|
|
566
|
+
headline: post.metadata.title,
|
|
567
|
+
description: post.metadata.excerpt,
|
|
568
|
+
image: post.metadata.image,
|
|
569
|
+
author: { '@type': 'Person', name: post.metadata.author },
|
|
570
|
+
publisher,
|
|
571
|
+
datePublished: `${post.metadata.date}T00:00:00Z`,
|
|
572
|
+
mainEntityOfPage: `https://example.com/${params.locale}/blog/${params.slug}`,
|
|
573
|
+
type: 'BlogPosting',
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
return (
|
|
577
|
+
<>
|
|
578
|
+
<script
|
|
579
|
+
type="application/ld+json"
|
|
580
|
+
dangerouslySetInnerHTML={{ __html: serializeStructuredData(articleData) }}
|
|
581
|
+
/>
|
|
582
|
+
|
|
583
|
+
<BlogLayout
|
|
584
|
+
title={post.metadata.title}
|
|
585
|
+
excerpt={post.metadata.excerpt}
|
|
586
|
+
author={post.metadata.author}
|
|
587
|
+
date={post.metadata.date}
|
|
588
|
+
readTime={post.metadata.readTime}
|
|
589
|
+
tags={post.metadata.tags}
|
|
590
|
+
image={post.metadata.image}
|
|
591
|
+
imageAlt={post.metadata.imageAlt}
|
|
592
|
+
locale={params.locale}
|
|
593
|
+
>
|
|
594
|
+
{post.content}
|
|
595
|
+
</BlogLayout>
|
|
596
|
+
|
|
597
|
+
{/* Related posts */}
|
|
598
|
+
{related.length > 0 && (
|
|
599
|
+
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
|
600
|
+
<h2 className="text-2xl font-bold mb-6">
|
|
601
|
+
{params.locale === 'fr' ? 'Articles connexes' : 'Related Posts'}
|
|
602
|
+
</h2>
|
|
603
|
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
604
|
+
{related.map((r) => (
|
|
605
|
+
<BlogCard
|
|
606
|
+
key={r.slug}
|
|
607
|
+
locale={params.locale as 'en' | 'fr'}
|
|
608
|
+
title={r.metadata.title}
|
|
609
|
+
excerpt={r.metadata.excerpt}
|
|
610
|
+
image={r.metadata.image}
|
|
611
|
+
href={`/${params.locale}/blog/${r.slug}`}
|
|
612
|
+
author={r.metadata.author}
|
|
613
|
+
date={r.metadata.date}
|
|
614
|
+
readTime={r.metadata.readTime}
|
|
615
|
+
tags={r.metadata.tags}
|
|
616
|
+
/>
|
|
617
|
+
))}
|
|
618
|
+
</div>
|
|
619
|
+
</section>
|
|
620
|
+
)}
|
|
621
|
+
</>
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
export async function generateStaticParams() {
|
|
626
|
+
const slugs = getBlogPostSlugs();
|
|
627
|
+
const locales = ['en', 'fr'];
|
|
628
|
+
|
|
629
|
+
return slugs.flatMap(slug =>
|
|
630
|
+
locales.map(locale => ({ slug, locale }))
|
|
631
|
+
);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
export async function generateMetadata({
|
|
635
|
+
params
|
|
636
|
+
}: {
|
|
637
|
+
params: { locale: string; slug: string }
|
|
638
|
+
}): Promise<Metadata> {
|
|
639
|
+
try {
|
|
640
|
+
const { metadata } = await loadBlogPost(params.slug, params.locale);
|
|
641
|
+
|
|
642
|
+
return generateArticleMetadata({
|
|
643
|
+
title: metadata.title,
|
|
644
|
+
description: metadata.excerpt,
|
|
645
|
+
image: metadata.image,
|
|
646
|
+
publishedTime: `${metadata.date}T00:00:00Z`,
|
|
647
|
+
author: metadata.author,
|
|
648
|
+
tags: metadata.tags,
|
|
649
|
+
url: `https://example.com/${params.locale}/blog/${params.slug}`,
|
|
650
|
+
siteName: 'Your Company',
|
|
651
|
+
});
|
|
652
|
+
} catch {
|
|
653
|
+
return { title: 'Not Found' };
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
```
|
|
657
|
+
|
|
658
|
+
### RSS Feed Route
|
|
659
|
+
|
|
660
|
+
```typescript
|
|
661
|
+
// app/[locale]/blog/feed.xml/route.ts
|
|
662
|
+
import { generateBlogRssFeed } from 'simple-site-framework/lib/content';
|
|
663
|
+
|
|
664
|
+
export async function GET(
|
|
665
|
+
request: Request,
|
|
666
|
+
{ params }: { params: { locale: string } }
|
|
667
|
+
) {
|
|
668
|
+
const feed = await generateBlogRssFeed({
|
|
669
|
+
siteUrl: 'https://example.com',
|
|
670
|
+
siteName: 'Your Company',
|
|
671
|
+
description: {
|
|
672
|
+
en: 'Latest articles and updates',
|
|
673
|
+
fr: 'Derniers articles et mises à jour',
|
|
674
|
+
},
|
|
675
|
+
locale: params.locale,
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
return new Response(feed, {
|
|
679
|
+
headers: {
|
|
680
|
+
'Content-Type': 'application/rss+xml; charset=utf-8',
|
|
681
|
+
'Cache-Control': 'public, max-age=3600',
|
|
682
|
+
},
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
```
|
|
686
|
+
|
|
687
|
+
The RSS feed returns up to 20 most recent posts. Link to it from your layout:
|
|
688
|
+
|
|
689
|
+
```html
|
|
690
|
+
<link
|
|
691
|
+
rel="alternate"
|
|
692
|
+
type="application/rss+xml"
|
|
693
|
+
title="Blog RSS Feed"
|
|
694
|
+
href="/en/blog/feed.xml"
|
|
695
|
+
/>
|
|
696
|
+
```
|
|
697
|
+
|
|
698
|
+
---
|
|
699
|
+
|
|
700
|
+
## SEO
|
|
701
|
+
|
|
702
|
+
### Article Metadata
|
|
703
|
+
|
|
704
|
+
Use `generateArticleMetadata()` for blog post pages. It generates Open Graph `type: 'article'` with publication metadata and Twitter Card tags.
|
|
705
|
+
|
|
706
|
+
```typescript
|
|
707
|
+
import { generateArticleMetadata } from 'simple-site-framework';
|
|
708
|
+
|
|
709
|
+
export async function generateMetadata({ params }) {
|
|
710
|
+
const { metadata } = await loadBlogPost(params.slug, params.locale);
|
|
711
|
+
|
|
712
|
+
return generateArticleMetadata({
|
|
713
|
+
title: metadata.title,
|
|
714
|
+
description: metadata.excerpt,
|
|
715
|
+
image: metadata.image,
|
|
716
|
+
publishedTime: `${metadata.date}T00:00:00Z`,
|
|
717
|
+
author: metadata.author,
|
|
718
|
+
tags: metadata.tags,
|
|
719
|
+
url: `https://example.com/${params.locale}/blog/${params.slug}`,
|
|
720
|
+
siteName: 'Your Company',
|
|
721
|
+
twitterSite: 'yourcompany',
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
```
|
|
725
|
+
|
|
726
|
+
### JSON-LD Structured Data
|
|
727
|
+
|
|
728
|
+
Use `createArticle()` for rich search results (Google, Bing):
|
|
729
|
+
|
|
730
|
+
```typescript
|
|
731
|
+
import {
|
|
732
|
+
createArticle,
|
|
733
|
+
createOrganization,
|
|
734
|
+
serializeStructuredData
|
|
735
|
+
} from 'simple-site-framework';
|
|
736
|
+
|
|
737
|
+
const publisher = createOrganization({
|
|
738
|
+
name: 'Your Company',
|
|
739
|
+
url: 'https://example.com',
|
|
740
|
+
logo: 'https://example.com/logo.png',
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
const article = createArticle({
|
|
744
|
+
headline: metadata.title,
|
|
745
|
+
description: metadata.excerpt,
|
|
746
|
+
image: metadata.image,
|
|
747
|
+
author: { '@type': 'Person', name: metadata.author },
|
|
748
|
+
publisher,
|
|
749
|
+
datePublished: `${metadata.date}T00:00:00Z`,
|
|
750
|
+
mainEntityOfPage: `https://example.com/${locale}/blog/${slug}`,
|
|
751
|
+
type: 'BlogPosting',
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
// Render in page
|
|
755
|
+
<script
|
|
756
|
+
type="application/ld+json"
|
|
757
|
+
dangerouslySetInnerHTML={{ __html: serializeStructuredData(article) }}
|
|
758
|
+
/>
|
|
759
|
+
```
|
|
760
|
+
|
|
761
|
+
---
|
|
762
|
+
|
|
763
|
+
## Filtering
|
|
764
|
+
|
|
765
|
+
### Tag-Based Filtering
|
|
766
|
+
|
|
767
|
+
The `BlogIndex` component provides client-side tag filtering out of the box. For server-side tag pages:
|
|
768
|
+
|
|
769
|
+
```typescript
|
|
770
|
+
// app/[locale]/blog/tag/[tag]/page.tsx
|
|
771
|
+
import { getBlogPostsByTag, getAllTags } from 'simple-site-framework/lib/content';
|
|
772
|
+
import { BlogIndex } from 'simple-site-framework';
|
|
773
|
+
|
|
774
|
+
export default async function TagPage({
|
|
775
|
+
params
|
|
776
|
+
}: {
|
|
777
|
+
params: { locale: string; tag: string }
|
|
778
|
+
}) {
|
|
779
|
+
const posts = await getBlogPostsByTag(decodeURIComponent(params.tag), params.locale);
|
|
780
|
+
|
|
781
|
+
return (
|
|
782
|
+
<BlogIndex
|
|
783
|
+
locale={params.locale}
|
|
784
|
+
posts={posts}
|
|
785
|
+
title={`#${decodeURIComponent(params.tag)}`}
|
|
786
|
+
showTagFilter={false}
|
|
787
|
+
/>
|
|
788
|
+
);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
export async function generateStaticParams() {
|
|
792
|
+
const locales = ['en', 'fr'];
|
|
793
|
+
const params = [];
|
|
794
|
+
|
|
795
|
+
for (const locale of locales) {
|
|
796
|
+
const tags = await getAllTags(locale);
|
|
797
|
+
for (const { tag } of tags) {
|
|
798
|
+
params.push({ locale, tag });
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
return params;
|
|
803
|
+
}
|
|
804
|
+
```
|
|
805
|
+
|
|
806
|
+
### Featured Posts
|
|
807
|
+
|
|
808
|
+
Display featured posts on the homepage or in a sidebar:
|
|
809
|
+
|
|
810
|
+
```typescript
|
|
811
|
+
import { getFeaturedBlogPosts } from 'simple-site-framework/lib/content';
|
|
812
|
+
import { BlogCard } from 'simple-site-framework';
|
|
813
|
+
|
|
814
|
+
export default async function HomePage({ params }) {
|
|
815
|
+
const featured = await getFeaturedBlogPosts(params.locale);
|
|
816
|
+
|
|
817
|
+
return (
|
|
818
|
+
<section>
|
|
819
|
+
<h2>Featured Articles</h2>
|
|
820
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
821
|
+
{featured.map((post) => (
|
|
822
|
+
<BlogCard
|
|
823
|
+
key={post.slug}
|
|
824
|
+
locale={params.locale as 'en' | 'fr'}
|
|
825
|
+
title={post.metadata.title}
|
|
826
|
+
excerpt={post.metadata.excerpt}
|
|
827
|
+
image={post.metadata.image}
|
|
828
|
+
href={`/${params.locale}/blog/${post.slug}`}
|
|
829
|
+
author={post.metadata.author}
|
|
830
|
+
date={post.metadata.date}
|
|
831
|
+
readTime={post.metadata.readTime}
|
|
832
|
+
tags={post.metadata.tags}
|
|
833
|
+
/>
|
|
834
|
+
))}
|
|
835
|
+
</div>
|
|
836
|
+
</section>
|
|
837
|
+
);
|
|
838
|
+
}
|
|
839
|
+
```
|
|
840
|
+
|
|
841
|
+
### Related Posts
|
|
842
|
+
|
|
843
|
+
Show related posts at the bottom of a blog post page (see the full example in [Route Setup > Blog Post Page](#blog-post-page)).
|
|
844
|
+
|
|
845
|
+
```typescript
|
|
846
|
+
import { getRelatedBlogPosts } from 'simple-site-framework/lib/content';
|
|
847
|
+
|
|
848
|
+
// Get up to 3 related posts based on shared tags
|
|
849
|
+
const related = await getRelatedBlogPosts(slug, locale, 3);
|
|
850
|
+
```
|
|
851
|
+
|
|
852
|
+
---
|
|
853
|
+
|
|
854
|
+
## Sitemap Integration
|
|
855
|
+
|
|
856
|
+
Add blog posts to your sitemap using `createMultiLanguageEntries()`:
|
|
857
|
+
|
|
858
|
+
```typescript
|
|
859
|
+
// app/sitemap.ts
|
|
860
|
+
import { getAllBlogPosts, getBlogPostLocales } from 'simple-site-framework/lib/content';
|
|
861
|
+
import { createMultiLanguageEntries, generateSitemap } from 'simple-site-framework';
|
|
862
|
+
import type { SitemapEntry } from 'simple-site-framework';
|
|
863
|
+
|
|
864
|
+
export default async function sitemap() {
|
|
865
|
+
const baseUrl = 'https://example.com';
|
|
866
|
+
const locales = ['en', 'fr'];
|
|
867
|
+
|
|
868
|
+
// Static pages
|
|
869
|
+
const staticEntries: SitemapEntry[] = [
|
|
870
|
+
...createMultiLanguageEntries(baseUrl, '/', locales, 'en', {
|
|
871
|
+
priority: 1.0,
|
|
872
|
+
changeFrequency: 'weekly',
|
|
873
|
+
}),
|
|
874
|
+
...createMultiLanguageEntries(baseUrl, '/blog', locales, 'en', {
|
|
875
|
+
priority: 0.9,
|
|
876
|
+
changeFrequency: 'daily',
|
|
877
|
+
}),
|
|
878
|
+
];
|
|
879
|
+
|
|
880
|
+
// Blog post entries
|
|
881
|
+
const posts = await getAllBlogPosts('en');
|
|
882
|
+
const blogEntries: SitemapEntry[] = posts.flatMap((post) => {
|
|
883
|
+
const postLocales = getBlogPostLocales(post.slug);
|
|
884
|
+
return createMultiLanguageEntries(
|
|
885
|
+
baseUrl,
|
|
886
|
+
`/blog/${post.slug}`,
|
|
887
|
+
postLocales,
|
|
888
|
+
'en',
|
|
889
|
+
{
|
|
890
|
+
priority: 0.7,
|
|
891
|
+
changeFrequency: 'monthly',
|
|
892
|
+
lastModified: post.metadata.date,
|
|
893
|
+
}
|
|
894
|
+
);
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
return [...staticEntries, ...blogEntries];
|
|
898
|
+
}
|
|
899
|
+
```
|
|
900
|
+
|
|
901
|
+
---
|
|
902
|
+
|
|
903
|
+
## Bilingual Content
|
|
904
|
+
|
|
905
|
+
### Creating Translations
|
|
906
|
+
|
|
907
|
+
Create one markdown file per language with the same slug:
|
|
908
|
+
|
|
909
|
+
```
|
|
910
|
+
src/content/blog/
|
|
911
|
+
├── getting-started.en.md
|
|
912
|
+
├── getting-started.fr.md
|
|
913
|
+
├── product-update.en.md
|
|
914
|
+
└── product-update.fr.md
|
|
915
|
+
```
|
|
916
|
+
|
|
917
|
+
Each file has its own frontmatter with translated values:
|
|
918
|
+
|
|
919
|
+
```markdown
|
|
920
|
+
<!-- getting-started.en.md -->
|
|
921
|
+
---
|
|
922
|
+
title: "Getting Started with Our Platform"
|
|
923
|
+
excerpt: "Everything you need to know to get up and running"
|
|
924
|
+
author: "Jane Doe"
|
|
925
|
+
date: "2026-02-20"
|
|
926
|
+
readTime: 5
|
|
927
|
+
tags: ["getting-started", "tutorial"]
|
|
928
|
+
---
|
|
929
|
+
```
|
|
930
|
+
|
|
931
|
+
```markdown
|
|
932
|
+
<!-- getting-started.fr.md -->
|
|
933
|
+
---
|
|
934
|
+
title: "Premiers pas avec notre plateforme"
|
|
935
|
+
excerpt: "Tout ce que vous devez savoir pour commencer"
|
|
936
|
+
author: "Jane Doe"
|
|
937
|
+
date: "2026-02-20"
|
|
938
|
+
readTime: 5
|
|
939
|
+
tags: ["premiers-pas", "tutoriel"]
|
|
940
|
+
---
|
|
941
|
+
```
|
|
942
|
+
|
|
943
|
+
### Language Switcher
|
|
944
|
+
|
|
945
|
+
```typescript
|
|
946
|
+
import { getBlogPostLocales } from 'simple-site-framework/lib/content';
|
|
947
|
+
import { LanguageSelector } from 'simple-site-framework';
|
|
948
|
+
|
|
949
|
+
export default async function BlogPostPage({ params }) {
|
|
950
|
+
const availableLocales = getBlogPostLocales(params.slug);
|
|
951
|
+
|
|
952
|
+
return (
|
|
953
|
+
<>
|
|
954
|
+
<LanguageSelector
|
|
955
|
+
currentLocale={params.locale}
|
|
956
|
+
availableLocales={availableLocales}
|
|
957
|
+
/>
|
|
958
|
+
{/* Post content */}
|
|
959
|
+
</>
|
|
960
|
+
);
|
|
961
|
+
}
|
|
962
|
+
```
|
|
963
|
+
|
|
964
|
+
### Handling Missing Translations
|
|
965
|
+
|
|
966
|
+
Posts without a translation for the requested locale are silently skipped by `getAllBlogPosts()`. For individual post pages, handle the error:
|
|
967
|
+
|
|
968
|
+
```typescript
|
|
969
|
+
import { loadBlogPost } from 'simple-site-framework/lib/content';
|
|
970
|
+
import { notFound } from 'next/navigation';
|
|
971
|
+
|
|
972
|
+
export default async function BlogPostPage({ params }) {
|
|
973
|
+
try {
|
|
974
|
+
const post = await loadBlogPost(params.slug, params.locale);
|
|
975
|
+
return <BlogLayout {...post.metadata} locale={params.locale}>{post.content}</BlogLayout>;
|
|
976
|
+
} catch {
|
|
977
|
+
notFound();
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
```
|
|
981
|
+
|
|
982
|
+
### Tag Considerations
|
|
983
|
+
|
|
984
|
+
Tags are per-locale. If your English post uses `["tutorial", "product"]` and your French post uses `["tutoriel", "produit"]`, tag-based filtering works independently per locale. Keep tags consistent within each language.
|
|
985
|
+
|
|
986
|
+
---
|
|
987
|
+
|
|
988
|
+
## Examples
|
|
989
|
+
|
|
990
|
+
See complete examples in:
|
|
991
|
+
- `examples/blog/getting-started.en.md` (English blog post)
|
|
992
|
+
- `examples/blog/getting-started.fr.md` (French translation)
|
|
993
|
+
|
|
994
|
+
---
|
|
995
|
+
|
|
996
|
+
## Resources
|
|
997
|
+
|
|
998
|
+
- [Tailwind Typography Docs](https://tailwindcss.com/docs/typography-plugin)
|
|
999
|
+
- [next-mdx-remote](https://github.com/hashicorp/next-mdx-remote)
|
|
1000
|
+
- [Schema.org Article](https://schema.org/Article)
|
|
1001
|
+
- [RSS 2.0 Specification](https://www.rssboard.org/rss-specification)
|
|
1002
|
+
|
|
1003
|
+
---
|
|
1004
|
+
|
|
1005
|
+
**Questions?** Open an issue on GitHub or contact us.
|