@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/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.