@startsimpli/ui 0.4.30 → 0.4.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/ui",
3
- "version": "0.4.30",
3
+ "version": "0.4.31",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -14,6 +14,7 @@
14
14
  "./utils": "./src/utils/index.ts",
15
15
  "./theme": "./src/theme/index.ts",
16
16
  "./theme/contract": "./theme/contract.css",
17
+ "./blog": "./src/components/blog/index.ts",
17
18
  "./email-editor": "./src/components/email-editor/index.ts",
18
19
  "./workflows": "./src/components/workflows/index.ts",
19
20
  "./workflows/styles": "./src/components/workflows/workflows.css",
@@ -32,9 +33,13 @@
32
33
  "next": "^14.0.0 || ^15.0.0 || ^16.0.0",
33
34
  "react": "^18.0.0 || ^19.0.0",
34
35
  "react-dom": "^18.0.0 || ^19.0.0",
35
- "@startsimpli/auth": "0.4.25"
36
+ "@startsimpli/api": "0.5.31",
37
+ "@startsimpli/auth": "0.4.26"
36
38
  },
37
39
  "peerDependenciesMeta": {
40
+ "@startsimpli/api": {
41
+ "optional": true
42
+ },
38
43
  "@startsimpli/auth": {
39
44
  "optional": true
40
45
  },
@@ -67,6 +72,9 @@
67
72
  "lucide-react": "^0.408.0",
68
73
  "react-big-calendar": "^1.19.4",
69
74
  "react-day-picker": "^9.14.0",
75
+ "react-markdown": "^10.1.0",
76
+ "react-syntax-highlighter": "^16.1.0",
77
+ "remark-gfm": "^4.0.1",
70
78
  "tailwind-merge": "^2.6.1",
71
79
  "tailwindcss-animate": "^1.0.7",
72
80
  "xlsx": "^0.18.5"
@@ -84,6 +92,7 @@
84
92
  "@types/react": "^19.2.14",
85
93
  "@types/react-big-calendar": "^1.16.3",
86
94
  "@types/react-dom": "^19.2.3",
95
+ "@types/react-syntax-highlighter": "^15.5.13",
87
96
  "eslint": "^8.57.1",
88
97
  "identity-obj-proxy": "^3.0.0",
89
98
  "jest": "^30.3.0",
@@ -0,0 +1,51 @@
1
+ import Link from 'next/link'
2
+ import type { BlogPost } from '@startsimpli/api/blog'
3
+ import { cn } from '../../utils/cn'
4
+
5
+ export interface ArticleCardProps {
6
+ post: BlogPost
7
+ /** Destination href, e.g. `/blog/${post.slug}`. */
8
+ href: string
9
+ /** Pre-resolved image URL (use `getImageUrl` from the blog client). */
10
+ imageSrc?: string
11
+ /** Byline shown in the card footer (e.g. the site name). */
12
+ byline?: string
13
+ className?: string
14
+ }
15
+
16
+ /**
17
+ * Article preview card for blog index grids. Server-renderable; themed via
18
+ * shared tokens so it matches whichever app embeds it.
19
+ */
20
+ export function ArticleCard({ post, href, imageSrc, byline, className }: ArticleCardProps) {
21
+ const src = imageSrc ?? post.image
22
+ return (
23
+ <Link href={href} className={cn('group block h-full', className)}>
24
+ <article className="bg-card rounded-xl shadow-sm border border-border hover:shadow-md transition-all overflow-hidden h-full flex flex-col">
25
+ {src && (
26
+ <div className="aspect-video bg-muted overflow-hidden">
27
+ {/* eslint-disable-next-line @next/next/no-img-element */}
28
+ <img
29
+ src={src}
30
+ alt={post.title}
31
+ loading="lazy"
32
+ className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
33
+ />
34
+ </div>
35
+ )}
36
+ <div className="p-6 flex-1 flex flex-col">
37
+ <h2 className="text-xl font-heading font-semibold text-foreground mb-3 group-hover:text-primary transition-colors line-clamp-2">
38
+ {post.title}
39
+ </h2>
40
+ {post.excerpt && (
41
+ <p className="text-muted-foreground text-sm mb-4 line-clamp-3 flex-1">{post.excerpt}</p>
42
+ )}
43
+ <div className="flex items-center justify-between text-sm text-muted-foreground mt-auto">
44
+ {byline ? <span>{byline}</span> : <span />}
45
+ {post.readTime && <span>{post.readTime}</span>}
46
+ </div>
47
+ </div>
48
+ </article>
49
+ </Link>
50
+ )
51
+ }
@@ -0,0 +1,135 @@
1
+ import Link from 'next/link'
2
+ import type { ReactNode } from 'react'
3
+ import type { BlogPost, BlogPagination } from '@startsimpli/api/blog'
4
+ import { ArticleCard } from './ArticleCard'
5
+ import { cn } from '../../utils/cn'
6
+
7
+ export interface BlogIndexProps {
8
+ posts: BlogPost[]
9
+ pagination: BlogPagination
10
+ /** Current page (1-based). */
11
+ page: number
12
+ /** Route the index lives at, e.g. `/blog`. Used for card links + pagination. */
13
+ basePath?: string
14
+ /** Resolve a post's image URL (pass the client's `getImageUrl`). */
15
+ getImageUrl?: (url?: string | null) => string
16
+ /** Byline shown on each card (typically the site name). */
17
+ byline?: string
18
+ /** Hero copy. */
19
+ eyebrow?: string
20
+ title: string
21
+ subtitle?: string
22
+ /** Rendered in place of the grid when there are no posts. */
23
+ emptyState?: ReactNode
24
+ className?: string
25
+ }
26
+
27
+ /**
28
+ * Full blog index composer: hero + responsive card grid + windowed pagination.
29
+ * Apps supply data + copy; everything is themed with shared tokens. Per-app
30
+ * code reduces to fetching posts and rendering `<BlogIndex {...} />`.
31
+ */
32
+ export function BlogIndex({
33
+ posts,
34
+ pagination,
35
+ page,
36
+ basePath = '/blog',
37
+ getImageUrl,
38
+ byline,
39
+ eyebrow,
40
+ title,
41
+ subtitle,
42
+ emptyState,
43
+ className,
44
+ }: BlogIndexProps) {
45
+ const pageHref = (p: number) => (p <= 1 ? basePath : `${basePath}?page=${p}`)
46
+
47
+ return (
48
+ <div className={cn('flex flex-col', className)}>
49
+ <section className="bg-gradient-to-b from-muted to-background py-12 md:py-16">
50
+ <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
51
+ {eyebrow && (
52
+ <span className="inline-block mb-4 rounded-full bg-primary/10 text-primary border border-primary/20 px-3 py-1 text-sm font-medium">
53
+ {eyebrow}
54
+ </span>
55
+ )}
56
+ <h1 className="font-heading text-4xl sm:text-5xl font-bold text-foreground mb-4">{title}</h1>
57
+ {subtitle && <p className="text-xl text-muted-foreground max-w-2xl mx-auto">{subtitle}</p>}
58
+ </div>
59
+ </section>
60
+
61
+ <section className="py-12 bg-background">
62
+ <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
63
+ {posts.length === 0 ? (
64
+ emptyState ?? (
65
+ <div className="text-center py-12 text-muted-foreground">No articles yet — check back soon.</div>
66
+ )
67
+ ) : (
68
+ <>
69
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
70
+ {posts.map((post) => (
71
+ <ArticleCard
72
+ key={post.id}
73
+ post={post}
74
+ href={`${basePath}/${post.slug}`}
75
+ imageSrc={getImageUrl ? getImageUrl(post.image) : post.image}
76
+ byline={byline}
77
+ />
78
+ ))}
79
+ </div>
80
+
81
+ {pagination.pageCount > 1 && (
82
+ <div className="mt-12 flex justify-center items-center gap-2">
83
+ {page > 1 && (
84
+ <Link
85
+ href={pageHref(page - 1)}
86
+ className="px-4 py-2 border border-border rounded-lg text-muted-foreground hover:bg-muted transition-colors"
87
+ >
88
+ Previous
89
+ </Link>
90
+ )}
91
+ <div className="flex items-center gap-2">
92
+ {Array.from({ length: pagination.pageCount }, (_, i) => i + 1)
93
+ .filter((p) => p === 1 || p === pagination.pageCount || Math.abs(p - page) <= 1)
94
+ .map((p, idx, arr) => {
95
+ const prev = arr[idx - 1]
96
+ const showEllipsis = prev && p - prev > 1
97
+ return (
98
+ <span key={p} className="flex items-center gap-2">
99
+ {showEllipsis && <span className="px-2 text-muted-foreground">…</span>}
100
+ <Link
101
+ href={pageHref(p)}
102
+ className={cn(
103
+ 'px-4 py-2 rounded-lg transition-colors',
104
+ p === page
105
+ ? 'bg-primary text-primary-foreground'
106
+ : 'border border-border text-muted-foreground hover:bg-muted',
107
+ )}
108
+ >
109
+ {p}
110
+ </Link>
111
+ </span>
112
+ )
113
+ })}
114
+ </div>
115
+ {page < pagination.pageCount && (
116
+ <Link
117
+ href={pageHref(page + 1)}
118
+ className="px-4 py-2 border border-border rounded-lg text-muted-foreground hover:bg-muted transition-colors"
119
+ >
120
+ Next
121
+ </Link>
122
+ )}
123
+ </div>
124
+ )}
125
+
126
+ <div className="mt-8 text-center text-sm text-muted-foreground">
127
+ Showing {posts.length} of {pagination.total} articles
128
+ </div>
129
+ </>
130
+ )}
131
+ </div>
132
+ </section>
133
+ </div>
134
+ )
135
+ }
@@ -0,0 +1,117 @@
1
+ import Link from 'next/link'
2
+ import { ArrowLeft } from 'lucide-react'
3
+ import type { ReactNode } from 'react'
4
+ import type { BlogPost as BlogPostType } from '@startsimpli/api/blog'
5
+ import { MarkdownRenderer } from './MarkdownRenderer'
6
+ import { JsonLd } from './JsonLd'
7
+ import { cn } from '../../utils/cn'
8
+
9
+ export interface BlogPostProps {
10
+ post: BlogPostType
11
+ /** Pre-resolved featured-image URL (pass the client's `getImageUrl`). */
12
+ imageSrc?: string
13
+ /** Index route, e.g. `/blog`. Used for breadcrumb + back link. */
14
+ basePath?: string
15
+ /** Label for the index in the breadcrumb / back link. Default "Blog". */
16
+ indexLabel?: string
17
+ /** Byline shown in the article meta. */
18
+ byline?: string
19
+ /** Syntax-highlight theme forwarded to the markdown renderer. */
20
+ theme?: 'light' | 'dark'
21
+ /** schema.org JSON-LD object(s) rendered into the page head. */
22
+ structuredData?: object | object[]
23
+ /** Optional sidebar (TOC, share widgets, CTAs). Rendered right of the content on lg+. */
24
+ sidebar?: ReactNode
25
+ className?: string
26
+ }
27
+
28
+ /**
29
+ * Full article-detail composer: JSON-LD, breadcrumb, title, byline/date,
30
+ * featured image, themed markdown body, optional sidebar, and back link.
31
+ * Per-app code reduces to fetching the post and rendering `<BlogPost {...} />`.
32
+ */
33
+ export function BlogPost({
34
+ post,
35
+ imageSrc,
36
+ basePath = '/blog',
37
+ indexLabel = 'Blog',
38
+ byline,
39
+ theme = 'light',
40
+ structuredData,
41
+ sidebar,
42
+ className,
43
+ }: BlogPostProps) {
44
+ const featured = imageSrc ?? post.image
45
+ const publishedLabel = post.publishedAt
46
+ ? new Date(post.publishedAt).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })
47
+ : null
48
+
49
+ return (
50
+ <article className={cn('py-12', className)}>
51
+ {structuredData && <JsonLd data={structuredData} />}
52
+ <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
53
+ <nav className="mb-8 text-sm" aria-label="Breadcrumb">
54
+ <ol className="flex items-center gap-2 text-muted-foreground">
55
+ <li>
56
+ <Link href="/" className="hover:text-primary transition-colors">
57
+ Home
58
+ </Link>
59
+ </li>
60
+ <li aria-hidden>/</li>
61
+ <li>
62
+ <Link href={basePath} className="hover:text-primary transition-colors">
63
+ {indexLabel}
64
+ </Link>
65
+ </li>
66
+ <li aria-hidden>/</li>
67
+ <li className="text-foreground line-clamp-1">{post.title}</li>
68
+ </ol>
69
+ </nav>
70
+
71
+ <div className={cn('grid grid-cols-1 gap-8', sidebar ? 'lg:grid-cols-12' : '')}>
72
+ <div className={sidebar ? 'lg:col-span-8' : 'max-w-3xl mx-auto w-full'}>
73
+ <h1 className="font-heading text-4xl md:text-5xl font-bold text-foreground mb-6">{post.title}</h1>
74
+
75
+ <div className="flex items-center gap-4 mb-8 pb-8 border-b border-border text-sm text-muted-foreground">
76
+ {byline && <span className="font-medium text-foreground">{byline}</span>}
77
+ {publishedLabel && (
78
+ <time dateTime={post.publishedAt}>{publishedLabel}</time>
79
+ )}
80
+ {post.readTime && (
81
+ <>
82
+ <span aria-hidden>•</span>
83
+ <span>{post.readTime}</span>
84
+ </>
85
+ )}
86
+ </div>
87
+
88
+ {featured && (
89
+ <div className="mb-10 rounded-xl overflow-hidden">
90
+ {/* eslint-disable-next-line @next/next/no-img-element */}
91
+ <img src={featured} alt={post.title} className="w-full h-auto" />
92
+ </div>
93
+ )}
94
+
95
+ <MarkdownRenderer content={post.content} theme={theme} />
96
+
97
+ <div className="mt-12 pt-8 border-t border-border">
98
+ <Link
99
+ href={basePath}
100
+ className="inline-flex items-center gap-2 text-primary hover:opacity-80 font-medium transition-opacity"
101
+ >
102
+ <ArrowLeft className="w-5 h-5" />
103
+ Back to {indexLabel}
104
+ </Link>
105
+ </div>
106
+ </div>
107
+
108
+ {sidebar && (
109
+ <aside className="lg:col-span-4">
110
+ <div className="sticky top-8 space-y-6">{sidebar}</div>
111
+ </aside>
112
+ )}
113
+ </div>
114
+ </div>
115
+ </article>
116
+ )
117
+ }
@@ -0,0 +1,23 @@
1
+ import type { ReactElement } from 'react'
2
+
3
+ /**
4
+ * Embed schema.org JSON-LD structured data.
5
+ *
6
+ * Pair with the generators from `@startsimpli/api/blog`
7
+ * (`createStructuredData(...).article(...)` etc.). Accepts a single object or an
8
+ * array; renders one `<script type="application/ld+json">` per item.
9
+ */
10
+ export function JsonLd({ data }: { data: object | object[] }): ReactElement {
11
+ const items = Array.isArray(data) ? data : [data]
12
+ return (
13
+ <>
14
+ {items.map((item, index) => (
15
+ <script
16
+ key={index}
17
+ type="application/ld+json"
18
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(item) }}
19
+ />
20
+ ))}
21
+ </>
22
+ )
23
+ }
@@ -0,0 +1,191 @@
1
+ 'use client'
2
+
3
+ import ReactMarkdown from 'react-markdown'
4
+ import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
5
+ import { oneDark, oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism'
6
+ import remarkGfm from 'remark-gfm'
7
+ import React, { useState } from 'react'
8
+ import { Copy, Check } from 'lucide-react'
9
+ import { cn } from '../../utils/cn'
10
+
11
+ /**
12
+ * Themed markdown renderer for blog/article content.
13
+ *
14
+ * Uses shared design tokens (`text-foreground`, `border-border`, `bg-muted`,
15
+ * `text-primary`, …) so it adopts each app's theme automatically. Decoupled from
16
+ * `next-themes`: pass `theme="dark"` to switch the code-block syntax style (apps
17
+ * using next-themes can forward `useTheme().resolvedTheme`).
18
+ */
19
+ export interface MarkdownRendererProps {
20
+ content: string
21
+ /** Syntax-highlighting style for fenced code blocks. Default `"light"`. */
22
+ theme?: 'light' | 'dark'
23
+ className?: string
24
+ }
25
+
26
+ function slugify(text: string): string {
27
+ return text
28
+ .toLowerCase()
29
+ .replace(/[^a-z0-9]+/g, '-')
30
+ .replace(/(^-|-$)/g, '')
31
+ }
32
+
33
+ export function MarkdownRenderer({ content, theme = 'light', className }: MarkdownRendererProps) {
34
+ const [copiedCode, setCopiedCode] = useState<string | null>(null)
35
+
36
+ const copyToClipboard = async (code: string) => {
37
+ await navigator.clipboard.writeText(code)
38
+ setCopiedCode(code)
39
+ setTimeout(() => setCopiedCode(null), 2000)
40
+ }
41
+
42
+ return (
43
+ <div className={cn('prose prose-lg max-w-none', className)}>
44
+ <ReactMarkdown
45
+ remarkPlugins={[remarkGfm]}
46
+ components={{
47
+ // h1 in content renders as h2 — the page template owns the real <h1>.
48
+ h1: ({ children, ...props }) => (
49
+ <h2 id={slugify(String(children ?? ''))} className="text-3xl font-heading font-semibold mb-4 mt-10 first:mt-0 text-foreground scroll-mt-8" {...props}>
50
+ {children}
51
+ </h2>
52
+ ),
53
+ h2: ({ children, ...props }) => (
54
+ <h2 id={slugify(String(children ?? ''))} className="text-3xl font-heading font-semibold mb-4 mt-10 first:mt-0 text-foreground scroll-mt-8" {...props}>
55
+ {children}
56
+ </h2>
57
+ ),
58
+ h3: ({ children, ...props }) => (
59
+ <h3 id={slugify(String(children ?? ''))} className="text-2xl font-heading font-semibold mb-3 mt-8 first:mt-0 text-foreground scroll-mt-8" {...props}>
60
+ {children}
61
+ </h3>
62
+ ),
63
+ h4: ({ children, ...props }) => (
64
+ <h4 id={slugify(String(children ?? ''))} className="text-xl font-heading font-semibold mb-2 mt-6 first:mt-0 text-foreground scroll-mt-8" {...props}>
65
+ {children}
66
+ </h4>
67
+ ),
68
+ a: ({ children, ...props }) => (
69
+ <a className="text-primary underline underline-offset-2 hover:opacity-80 transition-opacity" {...props}>
70
+ {children}
71
+ </a>
72
+ ),
73
+ code: (codeProps) => {
74
+ const { className: codeClassName, children, ...props } = codeProps as {
75
+ className?: string
76
+ children?: React.ReactNode
77
+ }
78
+ const match = /language-(\w+)/.exec(codeClassName || '')
79
+ const language = match ? match[1] : ''
80
+ const codeString = String(children).replace(/\n$/, '')
81
+
82
+ if (language) {
83
+ const syntaxStyle = theme === 'dark' ? oneDark : oneLight
84
+ return (
85
+ <div className="my-6 rounded-lg overflow-hidden border border-border shadow-sm">
86
+ <div className="flex items-center justify-between bg-muted px-4 py-2 border-b border-border">
87
+ <span className="text-sm font-medium text-muted-foreground">{language}</span>
88
+ <button
89
+ onClick={() => copyToClipboard(codeString)}
90
+ className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors p-1 rounded"
91
+ title="Copy code"
92
+ aria-label={copiedCode === codeString ? 'Code copied' : 'Copy code to clipboard'}
93
+ >
94
+ {copiedCode === codeString ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
95
+ </button>
96
+ </div>
97
+ <SyntaxHighlighter
98
+ style={syntaxStyle as Record<string, React.CSSProperties>}
99
+ language={language}
100
+ PreTag="div"
101
+ customStyle={{ margin: 0, padding: '1rem', background: 'transparent', fontSize: '0.875rem', lineHeight: '1.5' }}
102
+ codeTagProps={{
103
+ style: {
104
+ background: 'transparent',
105
+ fontFamily:
106
+ "ui-monospace, SFMono-Regular, 'SF Mono', Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
107
+ },
108
+ }}
109
+ >
110
+ {codeString}
111
+ </SyntaxHighlighter>
112
+ </div>
113
+ )
114
+ }
115
+
116
+ return (
117
+ <code className="bg-muted px-2 py-1 rounded text-sm font-mono text-foreground border border-border" {...props}>
118
+ {children}
119
+ </code>
120
+ )
121
+ },
122
+ table: ({ children, ...props }) => (
123
+ <div className="overflow-x-auto my-8">
124
+ <table className="w-full border-collapse bg-card rounded-lg border border-border shadow-sm" {...props}>
125
+ {children}
126
+ </table>
127
+ </div>
128
+ ),
129
+ thead: ({ children, ...props }) => (
130
+ <thead className="bg-muted" {...props}>
131
+ {children}
132
+ </thead>
133
+ ),
134
+ th: ({ children, ...props }) => (
135
+ <th className="px-4 py-3 text-left font-semibold border-b border-border text-foreground" {...props}>
136
+ {children}
137
+ </th>
138
+ ),
139
+ td: ({ children, ...props }) => (
140
+ <td className="px-4 py-3 border-b border-border text-foreground" {...props}>
141
+ {children}
142
+ </td>
143
+ ),
144
+ tr: ({ children, ...props }) => (
145
+ <tr className="hover:bg-muted/50" {...props}>
146
+ {children}
147
+ </tr>
148
+ ),
149
+ p: ({ children, ...props }) => (
150
+ <p className="mb-6 leading-relaxed text-foreground" {...props}>
151
+ {children}
152
+ </p>
153
+ ),
154
+ strong: ({ children, ...props }) => (
155
+ <strong className="font-semibold text-foreground" {...props}>
156
+ {children}
157
+ </strong>
158
+ ),
159
+ em: ({ children, ...props }) => (
160
+ <em className="italic text-foreground" {...props}>
161
+ {children}
162
+ </em>
163
+ ),
164
+ ul: ({ children, ...props }) => (
165
+ <ul className="mb-6 space-y-2 list-disc ml-6" {...props}>
166
+ {children}
167
+ </ul>
168
+ ),
169
+ ol: ({ children, ...props }) => (
170
+ <ol className="mb-6 space-y-2 list-decimal ml-6" {...props}>
171
+ {children}
172
+ </ol>
173
+ ),
174
+ li: ({ children, ...props }) => (
175
+ <li className="text-foreground leading-relaxed" {...props}>
176
+ {children}
177
+ </li>
178
+ ),
179
+ hr: ({ ...props }) => <hr className="my-8 border-border" {...props} />,
180
+ blockquote: ({ children, ...props }) => (
181
+ <blockquote className="my-6 pl-6 border-l-4 border-primary bg-muted py-4 pr-4 rounded-r-lg" {...props}>
182
+ <div className="text-muted-foreground italic">{children}</div>
183
+ </blockquote>
184
+ ),
185
+ }}
186
+ >
187
+ {content}
188
+ </ReactMarkdown>
189
+ </div>
190
+ )
191
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * @startsimpli/ui/blog — shared blog presentation layer.
3
+ *
4
+ * Themed React components + page-level composers for marketing-site blogs.
5
+ * Pair with the headless data layer in `@startsimpli/api/blog`
6
+ * (`createBlogClient`, `createStructuredData`, `buildBlogSitemapEntries`).
7
+ */
8
+
9
+ export { JsonLd } from './JsonLd'
10
+ export { MarkdownRenderer, type MarkdownRendererProps } from './MarkdownRenderer'
11
+ export { ArticleCard, type ArticleCardProps } from './ArticleCard'
12
+ export { BlogIndex, type BlogIndexProps } from './BlogIndex'
13
+ export { BlogPost, type BlogPostProps } from './BlogPost'