@inneropen/marvin-sdk 1.0.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/examples.md ADDED
@@ -0,0 +1,961 @@
1
+ # Marvin Client Usage Examples
2
+
3
+ Real-world examples for using the Marvin publishing client in an Astro site.
4
+
5
+ ## Table of Contents
6
+
7
+ 1. [Basic Setup](#basic-setup)
8
+ 2. [Markdown Rendering](#markdown-rendering)
9
+ 3. [Site Configuration](#site-configuration)
10
+ 4. [Blog/News Pages](#blognews-pages)
11
+ 5. [Project Portfolio](#project-portfolio)
12
+ 6. [Dynamic Routes](#dynamic-routes)
13
+ 7. [Collections](#collections)
14
+ 8. [Resources](#resources)
15
+ 9. [Assets & Images](#assets--images)
16
+ 10. [Metadata & SEO](#metadata--seo)
17
+
18
+ ---
19
+
20
+ ## Basic Setup
21
+
22
+ ### Create the Marvin Client Instance
23
+
24
+ ```ts
25
+ // src/lib/marvin.ts
26
+ import { createMarvinClient } from './marvin-client';
27
+
28
+ // Create client from environment variables
29
+ export const marvin = createMarvinClient();
30
+
31
+ // Or pass config explicitly
32
+ export const marvinProd = createMarvinClient({
33
+ apiUrl: 'https://marvin.example.com',
34
+ siteClientToken: import.meta.env.MARVIN_SITE_CLIENT_TOKEN,
35
+ workspaceSlug: 'mash-burn',
36
+ });
37
+ ```
38
+
39
+ ### Environment Variables
40
+
41
+ ```env
42
+ # .env
43
+ MARVIN_API_URL=https://marvin.mashburn.co
44
+ MARVIN_SITE_CLIENT_TOKEN=mbc_abc123xyz789
45
+ MARVIN_WORKSPACE_SLUG=mash-burn
46
+ ```
47
+
48
+ ---
49
+
50
+ ## Markdown Rendering
51
+
52
+ **Critical**: Entry content is returned as **raw Markdown**, not HTML. You must parse it before rendering.
53
+
54
+ ### Install Markdown Parser
55
+
56
+ ```bash
57
+ npm install marked
58
+ ```
59
+
60
+ ### Create a Helper Function
61
+
62
+ ```ts
63
+ // src/lib/markdown.ts
64
+ import { marked } from 'marked';
65
+
66
+ /**
67
+ * Parse Markdown to HTML
68
+ */
69
+ export function renderMarkdown(markdown: string): string {
70
+ return marked.parse(markdown ?? '');
71
+ }
72
+ ```
73
+
74
+ ### Use in Astro Components
75
+
76
+ ```astro
77
+ ---
78
+ import { renderMarkdown } from '@/lib/markdown';
79
+ import { marvin } from '@/lib/marvin';
80
+
81
+ const entry = await marvin.getEntry('about');
82
+ const contentHtml = renderMarkdown(entry.contentMarkdown ?? '');
83
+ ---
84
+
85
+ <article>
86
+ <h1>{entry.title}</h1>
87
+ <!-- Render parsed HTML -->
88
+ <div class="content" set:html={contentHtml} />
89
+ </article>
90
+ ```
91
+
92
+ ### Common Mistake
93
+
94
+ **❌ WRONG** - This will render raw Markdown as text:
95
+ ```astro
96
+ <div set:html={entry.contentMarkdown} />
97
+ ```
98
+
99
+ **✅ CORRECT** - Parse Markdown first:
100
+ ```astro
101
+ ---
102
+ import { marked } from 'marked';
103
+ const contentHtml = marked.parse(entry.contentMarkdown ?? '');
104
+ ---
105
+ <div set:html={contentHtml} />
106
+ ```
107
+
108
+ ---
109
+
110
+ ## Site Configuration
111
+
112
+ ### Use Site Config in Layout
113
+
114
+ ```astro
115
+ // src/layouts/BaseLayout.astro
116
+ ---
117
+ import { marvin } from '@/lib/marvin';
118
+
119
+ const site = await marvin.getSite();
120
+ const { title = 'Mash & Burn Co.' } = Astro.props;
121
+ ---
122
+
123
+ <!DOCTYPE html>
124
+ <html lang={site.locale || 'en'}>
125
+ <head>
126
+ <meta charset="UTF-8" />
127
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
128
+ <title>{title} | {site.title}</title>
129
+ <meta name="description" content={site.description} />
130
+ <link rel="icon" type="image/x-icon" href={site.favicon || '/favicon.ico'} />
131
+ <link rel="canonical" href={site.canonicalUrl} />
132
+ </head>
133
+ <body>
134
+ <header>
135
+ <a href="/">
136
+ {site.logo && <img src={site.logo} alt={site.title} />}
137
+ <h1>{site.title}</h1>
138
+ </a>
139
+ <p class="tagline">{site.tagline}</p>
140
+ </header>
141
+
142
+ <main>
143
+ <slot />
144
+ </main>
145
+
146
+ <footer>
147
+ <p>&copy; {new Date().getFullYear()} {site.title}</p>
148
+ </footer>
149
+ </body>
150
+ </html>
151
+ ```
152
+
153
+ ---
154
+
155
+ ## Blog/News Pages
156
+
157
+ ### Blog Index Page
158
+
159
+ ```astro
160
+ // src/pages/blog/index.astro
161
+ ---
162
+ import BaseLayout from '@/layouts/BaseLayout.astro';
163
+ import { marvin } from '@/lib/marvin';
164
+
165
+ // Fetch all blog posts
166
+ const posts = await marvin.getEntries({ entryType: 'blog' });
167
+
168
+ // Sort by published date, newest first
169
+ const sortedPosts = posts.sort((a, b) => {
170
+ const dateA = new Date(a.publishedAt || a.createdAt).getTime();
171
+ const dateB = new Date(b.publishedAt || b.createdAt).getTime();
172
+ return dateB - dateA;
173
+ });
174
+ ---
175
+
176
+ <BaseLayout title="Blog">
177
+ <h1>Blog</h1>
178
+
179
+ <div class="posts">
180
+ {sortedPosts.map((post) => (
181
+ <article class="post-preview">
182
+ <h2>
183
+ <a href={`/blog/${post.slug}`}>{post.title}</a>
184
+ </h2>
185
+
186
+ <time datetime={post.publishedAt}>
187
+ {new Date(post.publishedAt).toLocaleDateString('en-US', {
188
+ year: 'numeric',
189
+ month: 'long',
190
+ day: 'numeric'
191
+ })}
192
+ </time>
193
+
194
+ {post.summary && <p>{post.summary}</p>}
195
+
196
+ <a href={`/blog/${post.slug}`} class="read-more">
197
+ Read more →
198
+ </a>
199
+ </article>
200
+ ))}
201
+ </div>
202
+ </BaseLayout>
203
+ ```
204
+
205
+ ### Individual Blog Post
206
+
207
+ ```astro
208
+ // src/pages/blog/[slug].astro
209
+ ---
210
+ import { marked } from 'marked';
211
+ import BaseLayout from '@/layouts/BaseLayout.astro';
212
+ import { marvin } from '@/lib/marvin';
213
+
214
+ export async function getStaticPaths() {
215
+ const posts = await marvin.getEntries({ entryType: 'blog' });
216
+
217
+ return posts.map((post) => ({
218
+ params: { slug: post.slug },
219
+ props: { post },
220
+ }));
221
+ }
222
+
223
+ const { post } = Astro.props;
224
+
225
+ // Parse Markdown to HTML
226
+ const contentHtml = marked.parse(post.contentMarkdown ?? '');
227
+ ---
228
+
229
+ <BaseLayout title={post.title}>
230
+ <article class="blog-post">
231
+ <header>
232
+ <h1>{post.title}</h1>
233
+
234
+ <time datetime={post.publishedAt}>
235
+ {new Date(post.publishedAt).toLocaleDateString('en-US', {
236
+ year: 'numeric',
237
+ month: 'long',
238
+ day: 'numeric'
239
+ })}
240
+ </time>
241
+ </header>
242
+
243
+ {post.assets && post.assets.length > 0 && (
244
+ <img
245
+ src={post.assets[0].url}
246
+ alt={post.assets[0].altText || post.title}
247
+ class="featured-image"
248
+ />
249
+ )}
250
+
251
+ <div class="content" set:html={contentHtml} />
252
+
253
+ {post.metadata?.tags && (
254
+ <div class="tags">
255
+ {post.metadata.tags.map((tag: string) => (
256
+ <span class="tag">{tag}</span>
257
+ ))}
258
+ </div>
259
+ )}
260
+ </article>
261
+ </BaseLayout>
262
+ ```
263
+
264
+ ---
265
+
266
+ ## Project Portfolio
267
+
268
+ ### Projects Gallery
269
+
270
+ ```astro
271
+ // src/pages/projects/index.astro
272
+ ---
273
+ import BaseLayout from '@/layouts/BaseLayout.astro';
274
+ import { marvin } from '@/lib/marvin';
275
+
276
+ // Get all projects
277
+ const projects = await marvin.getEntries({ entryType: 'project' });
278
+
279
+ // Or get projects from a collection
280
+ const featuredProjects = await marvin.getCollectionEntries('featured-projects');
281
+ ---
282
+
283
+ <BaseLayout title="Projects">
284
+ <h1>Our Projects</h1>
285
+
286
+ <div class="projects-grid">
287
+ {projects.map((project) => (
288
+ <a href={`/projects/${project.slug}`} class="project-card">
289
+ {project.assets && project.assets[0] && (
290
+ <img
291
+ src={project.assets[0].url}
292
+ alt={project.assets[0].altText || project.title}
293
+ loading="lazy"
294
+ />
295
+ )}
296
+
297
+ <h2>{project.title}</h2>
298
+ <p>{project.summary}</p>
299
+
300
+ {project.metadata?.year && (
301
+ <span class="year">{project.metadata.year}</span>
302
+ )}
303
+ </a>
304
+ ))}
305
+ </div>
306
+ </BaseLayout>
307
+
308
+ <style>
309
+ .projects-grid {
310
+ display: grid;
311
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
312
+ gap: 2rem;
313
+ }
314
+
315
+ .project-card {
316
+ text-decoration: none;
317
+ border: 1px solid #ddd;
318
+ border-radius: 8px;
319
+ overflow: hidden;
320
+ transition: transform 0.2s;
321
+ }
322
+
323
+ .project-card:hover {
324
+ transform: translateY(-4px);
325
+ }
326
+ </style>
327
+ ```
328
+
329
+ ### Project Detail Page
330
+
331
+ ```astro
332
+ // src/pages/projects/[slug].astro
333
+ ---
334
+ import { marked } from 'marked';
335
+ import BaseLayout from '@/layouts/BaseLayout.astro';
336
+ import { marvin } from '@/lib/marvin';
337
+
338
+ export async function getStaticPaths() {
339
+ const projects = await marvin.getEntries({ entryType: 'project' });
340
+
341
+ return projects.map((project) => ({
342
+ params: { slug: project.slug },
343
+ props: { project },
344
+ }));
345
+ }
346
+
347
+ const { project } = Astro.props;
348
+ const metadata = project.metadata || {};
349
+
350
+ // Parse Markdown to HTML
351
+ const contentHtml = marked.parse(project.contentMarkdown ?? '');
352
+ ---
353
+
354
+ <BaseLayout title={project.title}>
355
+ <article class="project">
356
+ <header>
357
+ <h1>{project.title}</h1>
358
+
359
+ <div class="meta">
360
+ {metadata.client && <span>Client: {metadata.client}</span>}
361
+ {metadata.year && <span>Year: {metadata.year}</span>}
362
+ {metadata.category && <span>Category: {metadata.category}</span>}
363
+ </div>
364
+ </header>
365
+
366
+ {project.assets && project.assets.length > 0 && (
367
+ <div class="gallery">
368
+ {project.assets.map((asset) => (
369
+ <img
370
+ src={asset.url}
371
+ alt={asset.altText || project.title}
372
+ width={asset.width}
373
+ height={asset.height}
374
+ loading="lazy"
375
+ />
376
+ ))}
377
+ </div>
378
+ )}
379
+
380
+ <div class="content" set:html={contentHtml} />
381
+
382
+ {metadata.technologies && (
383
+ <div class="technologies">
384
+ <h3>Technologies Used</h3>
385
+ <ul>
386
+ {metadata.technologies.map((tech: string) => (
387
+ <li>{tech}</li>
388
+ ))}
389
+ </ul>
390
+ </div>
391
+ )}
392
+ </article>
393
+ </BaseLayout>
394
+ ```
395
+
396
+ ---
397
+
398
+ ## Dynamic Routes
399
+
400
+ ### Catch-All Route for Pages
401
+
402
+ ```astro
403
+ // src/pages/[...slug].astro
404
+ ---
405
+ import { marked } from 'marked';
406
+ import BaseLayout from '@/layouts/BaseLayout.astro';
407
+ import { marvin } from '@/lib/marvin';
408
+
409
+ export async function getStaticPaths() {
410
+ const pages = await marvin.getEntries({ entryType: 'page' });
411
+
412
+ return pages.map((page) => ({
413
+ params: { slug: page.slug },
414
+ props: { page },
415
+ }));
416
+ }
417
+
418
+ const { page } = Astro.props;
419
+
420
+ // Parse Markdown to HTML
421
+ const contentHtml = marked.parse(page.contentMarkdown ?? '');
422
+ ---
423
+
424
+ <BaseLayout title={page.title}>
425
+ <article class="page">
426
+ <h1>{page.title}</h1>
427
+ <div class="content" set:html={contentHtml} />
428
+ </article>
429
+ </BaseLayout>
430
+ ```
431
+
432
+ ---
433
+
434
+ ## Collections
435
+
436
+ ### Featured Items Showcase
437
+
438
+ ```astro
439
+ // src/pages/index.astro
440
+ ---
441
+ import BaseLayout from '@/layouts/BaseLayout.astro';
442
+ import { marvin } from '@/lib/marvin';
443
+
444
+ const featuredProjects = await marvin.getCollectionEntries('featured');
445
+ const latestPosts = await marvin.getEntries({
446
+ entryType: 'blog',
447
+ limit: 3
448
+ });
449
+ ---
450
+
451
+ <BaseLayout>
452
+ <section class="hero">
453
+ <h1>Welcome to Mash & Burn Co.</h1>
454
+ <p>Handcrafted with Purpose</p>
455
+ </section>
456
+
457
+ <section class="featured">
458
+ <h2>Featured Projects</h2>
459
+ <div class="grid">
460
+ {featuredProjects.map((project) => (
461
+ <a href={`/projects/${project.slug}`}>
462
+ {project.assets?.[0] && (
463
+ <img src={project.assets[0].url} alt={project.title} />
464
+ )}
465
+ <h3>{project.title}</h3>
466
+ </a>
467
+ ))}
468
+ </div>
469
+ </section>
470
+
471
+ <section class="latest-posts">
472
+ <h2>Latest from the Blog</h2>
473
+ {latestPosts.map((post) => (
474
+ <article>
475
+ <h3><a href={`/blog/${post.slug}`}>{post.title}</a></h3>
476
+ <p>{post.summary}</p>
477
+ </article>
478
+ ))}
479
+ </section>
480
+ </BaseLayout>
481
+ ```
482
+
483
+ ### Collection Landing Page
484
+
485
+ ```astro
486
+ // src/pages/collections/[slug].astro
487
+ ---
488
+ import BaseLayout from '@/layouts/BaseLayout.astro';
489
+ import { marvin } from '@/lib/marvin';
490
+
491
+ export async function getStaticPaths() {
492
+ const collections = await marvin.getCollections();
493
+
494
+ return collections.map((collection) => ({
495
+ params: { slug: collection.slug },
496
+ props: { collection },
497
+ }));
498
+ }
499
+
500
+ const { collection } = Astro.props;
501
+ const entries = await marvin.getCollectionEntries(collection.slug);
502
+ ---
503
+
504
+ <BaseLayout title={collection.name}>
505
+ <header>
506
+ {collection.icon && <span class="icon">{collection.icon}</span>}
507
+ <h1>{collection.name}</h1>
508
+ {collection.description && <p>{collection.description}</p>}
509
+ </header>
510
+
511
+ <div class="entries">
512
+ {entries.map((entry) => (
513
+ <article>
514
+ <h2><a href={`/${entry.entryType?.slug}/${entry.slug}`}>{entry.title}</a></h2>
515
+ <p>{entry.summary}</p>
516
+ </article>
517
+ ))}
518
+ </div>
519
+ </BaseLayout>
520
+ ```
521
+
522
+ ---
523
+
524
+ ## Resources
525
+
526
+ Resources are reusable structured objects that entries can reference (fabrics, tools, suppliers, etc.).
527
+
528
+ ### List Resources by Type
529
+
530
+ ```astro
531
+ // src/pages/materials/fabrics.astro
532
+ ---
533
+ import BaseLayout from '@/layouts/BaseLayout.astro';
534
+ import { marvin } from '@/lib/marvin';
535
+
536
+ const fabrics = await marvin.getResources({ resourceType: 'fabric' });
537
+ ---
538
+
539
+ <BaseLayout title="Fabrics">
540
+ <h1>Our Fabrics</h1>
541
+
542
+ <div class="resources-grid">
543
+ {fabrics.map((fabric) => (
544
+ <article class="resource-card">
545
+ <h2>
546
+ <a href={`/materials/fabrics/${fabric.slug}`}>{fabric.name}</a>
547
+ </h2>
548
+
549
+ {fabric.description && <p>{fabric.description}</p>}
550
+
551
+ {fabric.metadata?.weight && (
552
+ <span class="meta">Weight: {fabric.metadata.weight}</span>
553
+ )}
554
+
555
+ {fabric.metadata?.composition && (
556
+ <span class="meta">Composition: {fabric.metadata.composition}</span>
557
+ )}
558
+
559
+ {fabric.url && (
560
+ <a href={fabric.url} target="_blank" rel="noopener">
561
+ View supplier →
562
+ </a>
563
+ )}
564
+ </article>
565
+ ))}
566
+ </div>
567
+ </BaseLayout>
568
+ ```
569
+
570
+ ### Resource Detail Page with Related Entries
571
+
572
+ ```astro
573
+ // src/pages/materials/fabrics/[slug].astro
574
+ ---
575
+ import BaseLayout from '@/layouts/BaseLayout.astro';
576
+ import { marvin } from '@/lib/marvin';
577
+
578
+ export async function getStaticPaths() {
579
+ const fabrics = await marvin.getResources({ resourceType: 'fabric' });
580
+
581
+ return fabrics.map((fabric) => ({
582
+ params: { slug: fabric.slug },
583
+ props: { fabric },
584
+ }));
585
+ }
586
+
587
+ const { fabric } = Astro.props;
588
+
589
+ // Get entries that use this fabric
590
+ const relatedEntries = await marvin.getResourceEntries(fabric.slug);
591
+ const metadata = fabric.metadata || {};
592
+ ---
593
+
594
+ <BaseLayout title={fabric.name}>
595
+ <article class="resource-detail">
596
+ <header>
597
+ <h1>{fabric.name}</h1>
598
+ {fabric.resourceType && (
599
+ <span class="type">{fabric.resourceType}</span>
600
+ )}
601
+ </header>
602
+
603
+ {fabric.description && (
604
+ <p class="description">{fabric.description}</p>
605
+ )}
606
+
607
+ <dl class="metadata">
608
+ {fabric.externalId && (
609
+ <>
610
+ <dt>ID</dt>
611
+ <dd>{fabric.externalId}</dd>
612
+ </>
613
+ )}
614
+
615
+ {metadata.weight && (
616
+ <>
617
+ <dt>Weight</dt>
618
+ <dd>{metadata.weight}</dd>
619
+ </>
620
+ )}
621
+
622
+ {metadata.composition && (
623
+ <>
624
+ <dt>Composition</dt>
625
+ <dd>{metadata.composition}</dd>
626
+ </>
627
+ )}
628
+
629
+ {metadata.color && (
630
+ <>
631
+ <dt>Color</dt>
632
+ <dd>{metadata.color}</dd>
633
+ </>
634
+ )}
635
+
636
+ {fabric.url && (
637
+ <>
638
+ <dt>Supplier</dt>
639
+ <dd><a href={fabric.url} target="_blank" rel="noopener">{fabric.url}</a></dd>
640
+ </>
641
+ )}
642
+ </dl>
643
+
644
+ {relatedEntries.length > 0 && (
645
+ <section class="related-entries">
646
+ <h2>Used In</h2>
647
+ <div class="entries">
648
+ {relatedEntries.map((entry) => (
649
+ <article>
650
+ <h3>
651
+ <a href={`/${entry.entryType?.slug}/${entry.slug}`}>
652
+ {entry.title}
653
+ </a>
654
+ </h3>
655
+ {entry.summary && <p>{entry.summary}</p>}
656
+ </article>
657
+ ))}
658
+ </div>
659
+ </section>
660
+ )}
661
+ </article>
662
+ </BaseLayout>
663
+ ```
664
+
665
+ ### Display Resources in Entry Pages
666
+
667
+ ```astro
668
+ // src/pages/projects/[slug].astro
669
+ ---
670
+ import { marked } from 'marked';
671
+ import BaseLayout from '@/layouts/BaseLayout.astro';
672
+ import { marvin } from '@/lib/marvin';
673
+
674
+ export async function getStaticPaths() {
675
+ const projects = await marvin.getEntries({ entryType: 'project' });
676
+
677
+ return projects.map((project) => ({
678
+ params: { slug: project.slug },
679
+ props: { project },
680
+ }));
681
+ }
682
+
683
+ const { project } = Astro.props;
684
+ const contentHtml = marked.parse(project.contentMarkdown ?? '');
685
+ ---
686
+
687
+ <BaseLayout title={project.title}>
688
+ <article class="project">
689
+ <h1>{project.title}</h1>
690
+ <div class="content" set:html={contentHtml} />
691
+
692
+ {/* Display linked resources */}
693
+ {project.resources && project.resources.length > 0 && (
694
+ <section class="materials">
695
+ <h2>Materials Used</h2>
696
+ <div class="resources">
697
+ {project.resources.map((resource) => (
698
+ <a href={`/materials/${resource.resourceType}/${resource.slug}`} class="resource-tag">
699
+ {resource.name}
700
+ {resource.metadata?.weight && (
701
+ <span class="meta">({resource.metadata.weight})</span>
702
+ )}
703
+ </a>
704
+ ))}
705
+ </div>
706
+ </section>
707
+ )}
708
+ </article>
709
+ </BaseLayout>
710
+ ```
711
+
712
+ ### Resource Categories Index
713
+
714
+ ```astro
715
+ // src/pages/materials/index.astro
716
+ ---
717
+ import BaseLayout from '@/layouts/BaseLayout.astro';
718
+ import { marvin } from '@/lib/marvin';
719
+
720
+ const resources = await marvin.getResources();
721
+
722
+ // Group resources by type
723
+ const byType = resources.reduce((acc, resource) => {
724
+ const type = resource.resourceType || 'other';
725
+ if (!acc[type]) acc[type] = [];
726
+ acc[type].push(resource);
727
+ return acc;
728
+ }, {} as Record<string, typeof resources>);
729
+
730
+ const types = Object.keys(byType).sort();
731
+ ---
732
+
733
+ <BaseLayout title="Materials & Resources">
734
+ <h1>Materials & Resources</h1>
735
+
736
+ {types.map((type) => (
737
+ <section class="resource-category">
738
+ <h2>{type.charAt(0).toUpperCase() + type.slice(1)}</h2>
739
+
740
+ <ul class="resource-list">
741
+ {byType[type].map((resource) => (
742
+ <li>
743
+ <a href={`/materials/${type}/${resource.slug}`}>
744
+ {resource.name}
745
+ </a>
746
+ {resource.description && (
747
+ <span class="description">{resource.description}</span>
748
+ )}
749
+ </li>
750
+ ))}
751
+ </ul>
752
+ </section>
753
+ ))}
754
+ </BaseLayout>
755
+ ```
756
+
757
+ ---
758
+
759
+ ## Assets & Images
760
+
761
+ ### Image Gallery
762
+
763
+ ```astro
764
+ // src/pages/gallery.astro
765
+ ---
766
+ import BaseLayout from '@/layouts/BaseLayout.astro';
767
+ import { marvin } from '@/lib/marvin';
768
+
769
+ const images = await marvin.getAssets({ type: 'image' });
770
+ ---
771
+
772
+ <BaseLayout title="Gallery">
773
+ <h1>Gallery</h1>
774
+
775
+ <div class="gallery">
776
+ {images.map((image) => (
777
+ <figure>
778
+ <img
779
+ src={image.url}
780
+ alt={image.altText || image.name}
781
+ width={image.width}
782
+ height={image.height}
783
+ loading="lazy"
784
+ />
785
+ {image.description && (
786
+ <figcaption>{image.description}</figcaption>
787
+ )}
788
+ </figure>
789
+ ))}
790
+ </div>
791
+ </BaseLayout>
792
+
793
+ <style>
794
+ .gallery {
795
+ display: grid;
796
+ grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
797
+ gap: 1rem;
798
+ }
799
+
800
+ figure {
801
+ margin: 0;
802
+ }
803
+
804
+ img {
805
+ width: 100%;
806
+ height: auto;
807
+ object-fit: cover;
808
+ aspect-ratio: 1;
809
+ }
810
+ </style>
811
+ ```
812
+
813
+ ---
814
+
815
+ ## Metadata & SEO
816
+
817
+ ### SEO Component
818
+
819
+ ```astro
820
+ // src/components/SEO.astro
821
+ ---
822
+ import type { MarvinEntry } from '@/lib/marvin';
823
+
824
+ interface Props {
825
+ entry: MarvinEntry;
826
+ siteName: string;
827
+ }
828
+
829
+ const { entry, siteName } = Astro.props;
830
+ const canonicalUrl = `${import.meta.env.SITE}/${entry.slug}`;
831
+ const ogImage = entry.assets?.[0]?.url;
832
+ ---
833
+
834
+ <head>
835
+ <title>{entry.title} | {siteName}</title>
836
+ <meta name="description" content={entry.summary || entry.description} />
837
+ <link rel="canonical" href={canonicalUrl} />
838
+
839
+ <!-- Open Graph -->
840
+ <meta property="og:type" content="article" />
841
+ <meta property="og:title" content={entry.title} />
842
+ <meta property="og:description" content={entry.summary || entry.description} />
843
+ <meta property="og:url" content={canonicalUrl} />
844
+ {ogImage && <meta property="og:image" content={ogImage} />}
845
+
846
+ <!-- Twitter Card -->
847
+ <meta name="twitter:card" content={ogImage ? 'summary_large_image' : 'summary'} />
848
+ <meta name="twitter:title" content={entry.title} />
849
+ <meta name="twitter:description" content={entry.summary || entry.description} />
850
+ {ogImage && <meta name="twitter:image" content={ogImage} />}
851
+
852
+ <!-- Article Meta -->
853
+ {entry.publishedAt && (
854
+ <meta property="article:published_time" content={entry.publishedAt} />
855
+ )}
856
+ {entry.metadata?.tags && (
857
+ entry.metadata.tags.map((tag: string) => (
858
+ <meta property="article:tag" content={tag} />
859
+ ))
860
+ )}
861
+ </head>
862
+ ```
863
+
864
+ ### Usage in Page
865
+
866
+ ```astro
867
+ // src/pages/blog/[slug].astro
868
+ ---
869
+ import BaseLayout from '@/layouts/BaseLayout.astro';
870
+ import SEO from '@/components/SEO.astro';
871
+ import { marvin } from '@/lib/marvin';
872
+
873
+ export async function getStaticPaths() {
874
+ const posts = await marvin.getEntries({ entryType: 'blog' });
875
+ return posts.map((post) => ({
876
+ params: { slug: post.slug },
877
+ props: { post },
878
+ }));
879
+ }
880
+
881
+ const { post } = Astro.props;
882
+ const site = await marvin.getSite();
883
+ ---
884
+
885
+ <html>
886
+ <SEO entry={post} siteName={site.title} />
887
+ <body>
888
+ <!-- page content -->
889
+ </body>
890
+ </html>
891
+ ```
892
+
893
+ ---
894
+
895
+ ## Advanced Examples
896
+
897
+ ### Pagination
898
+
899
+ ```astro
900
+ // src/pages/blog/[...page].astro
901
+ ---
902
+ import type { GetStaticPaths } from 'astro';
903
+ import { marvin } from '@/lib/marvin';
904
+
905
+ export const getStaticPaths: GetStaticPaths = async ({ paginate }) => {
906
+ const posts = await marvin.getEntries({ entryType: 'blog' });
907
+
908
+ return paginate(posts, { pageSize: 10 });
909
+ };
910
+
911
+ const { page } = Astro.props;
912
+ ---
913
+
914
+ <div class="posts">
915
+ {page.data.map((post) => (
916
+ <article>
917
+ <h2><a href={`/blog/${post.slug}`}>{post.title}</a></h2>
918
+ </article>
919
+ ))}
920
+ </div>
921
+
922
+ <nav class="pagination">
923
+ {page.url.prev && <a href={page.url.prev}>← Previous</a>}
924
+ <span>Page {page.currentPage} of {page.lastPage}</span>
925
+ {page.url.next && <a href={page.url.next}>Next →</a>}
926
+ </nav>
927
+ ```
928
+
929
+ ### Sitemap Generation
930
+
931
+ ```ts
932
+ // src/pages/sitemap.xml.ts
933
+ import type { APIRoute } from 'astro';
934
+ import { marvin } from '@/lib/marvin';
935
+
936
+ export const GET: APIRoute = async () => {
937
+ const [pages, posts, projects] = await Promise.all([
938
+ marvin.getEntries({ entryType: 'page' }),
939
+ marvin.getEntries({ entryType: 'blog' }),
940
+ marvin.getEntries({ entryType: 'project' }),
941
+ ]);
942
+
943
+ const entries = [...pages, ...posts, ...projects];
944
+ const baseUrl = import.meta.env.SITE;
945
+
946
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
947
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
948
+ ${entries.map((entry) => `
949
+ <url>
950
+ <loc>${baseUrl}/${entry.slug}</loc>
951
+ <lastmod>${entry.updatedAt}</lastmod>
952
+ </url>`).join('')}
953
+ </urlset>`;
954
+
955
+ return new Response(xml, {
956
+ headers: {
957
+ 'Content-Type': 'application/xml',
958
+ },
959
+ });
960
+ };
961
+ ```