@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 +11 -2
- package/src/components/blog/ArticleCard.tsx +51 -0
- package/src/components/blog/BlogIndex.tsx +135 -0
- package/src/components/blog/BlogPost.tsx +117 -0
- package/src/components/blog/JsonLd.tsx +23 -0
- package/src/components/blog/MarkdownRenderer.tsx +191 -0
- package/src/components/blog/index.ts +13 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/ui",
|
|
3
|
-
"version": "0.4.
|
|
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/
|
|
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'
|