@fullstackdatasolutions/articles 1.0.0 → 1.2.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/CHANGELOG.md +20 -0
- package/README.md +10 -8
- package/dist/index.cjs +69 -28
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +14 -2
- package/dist/index.d.ts +14 -2
- package/dist/index.js +69 -28
- package/dist/index.js.map +1 -1
- package/dist/nextjs.d.cts +8 -0
- package/dist/nextjs.d.ts +8 -0
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +8 -0
- package/dist/server.d.ts +8 -0
- package/dist/server.js.map +1 -1
- package/package.json +1 -1
- package/src/ArticleCard.tsx +5 -5
- package/src/ArticleDetailHero.tsx +54 -20
- package/src/ArticlesPage.tsx +1 -0
- package/src/Breadcrumb.tsx +4 -1
- package/src/RelatedArticlesSection.tsx +4 -1
- package/src/__tests__/ArticleDetailHero.test.tsx +57 -0
- package/src/articlesConfig.ts +8 -0
package/package.json
CHANGED
package/src/ArticleCard.tsx
CHANGED
|
@@ -32,11 +32,11 @@ export function ArticleCard({ article, config }: ArticleCardProps) {
|
|
|
32
32
|
{article.category}
|
|
33
33
|
</span>
|
|
34
34
|
</div>
|
|
35
|
-
<h3
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
>
|
|
35
|
+
<h3
|
|
36
|
+
className="text-lg font-semibold leading-none tracking-tight mb-2"
|
|
37
|
+
style={{ fontFamily: config?.theme?.headerFontFamily }}
|
|
38
|
+
>
|
|
39
|
+
<Link href={`/articles/${article.slug}`} className="hover:text-primary transition-colors">
|
|
40
40
|
{article.title}
|
|
41
41
|
</Link>
|
|
42
42
|
</h3>
|
|
@@ -3,6 +3,7 @@ import Image from 'next/image'
|
|
|
3
3
|
import Link from 'next/link'
|
|
4
4
|
import { Calendar, Clock, User } from 'lucide-react'
|
|
5
5
|
import type { Article, AuthorProfile } from './articleTypes'
|
|
6
|
+
import type { ArticlesConfig } from './articlesConfig'
|
|
6
7
|
|
|
7
8
|
type ArticleDetailHeroProps = Readonly<{
|
|
8
9
|
article: Article
|
|
@@ -10,6 +11,8 @@ type ArticleDetailHeroProps = Readonly<{
|
|
|
10
11
|
showDate?: boolean
|
|
11
12
|
showAuthor?: boolean
|
|
12
13
|
authors?: AuthorProfile[]
|
|
14
|
+
/** Optional - when `config.theme.headerFontFamily` is set, applies it to the title. */
|
|
15
|
+
config?: ArticlesConfig
|
|
13
16
|
}>
|
|
14
17
|
|
|
15
18
|
function toSlug(cat: string): string {
|
|
@@ -19,12 +22,23 @@ function toSlug(cat: string): string {
|
|
|
19
22
|
.replaceAll(/[^a-z0-9-]/g, '')
|
|
20
23
|
}
|
|
21
24
|
|
|
25
|
+
function resolveAvatarUrl(author: AuthorProfile, config?: ArticlesConfig): string | null {
|
|
26
|
+
if (!author.avatar) return null
|
|
27
|
+
if (author.avatar.startsWith('http://') || author.avatar.startsWith('https://')) {
|
|
28
|
+
return author.avatar
|
|
29
|
+
}
|
|
30
|
+
if (!config?.siteUrl) return null
|
|
31
|
+
const siteUrl = config.siteUrl.replace(/\/$/, '')
|
|
32
|
+
return `${siteUrl}/articles/authors/${author.slug}/${author.avatar.replace(/^\/+/, '')}`
|
|
33
|
+
}
|
|
34
|
+
|
|
22
35
|
export function ArticleDetailHero({
|
|
23
36
|
article,
|
|
24
37
|
categoryBasePath = '/articles/category',
|
|
25
38
|
showDate = false,
|
|
26
39
|
showAuthor = true,
|
|
27
40
|
authors = [],
|
|
41
|
+
config,
|
|
28
42
|
}: ArticleDetailHeroProps) {
|
|
29
43
|
const showConfiguredAuthors = showAuthor && authors.length > 0
|
|
30
44
|
const showLegacyAuthor = showAuthor && authors.length === 0 && article.author.trim().length > 0
|
|
@@ -98,7 +112,12 @@ export function ArticleDetailHero({
|
|
|
98
112
|
)}
|
|
99
113
|
</div>
|
|
100
114
|
)}
|
|
101
|
-
<h1
|
|
115
|
+
<h1
|
|
116
|
+
className="text-4xl md:text-5xl font-bold mb-4"
|
|
117
|
+
style={{ fontFamily: config?.theme?.headerFontFamily }}
|
|
118
|
+
>
|
|
119
|
+
{article.title}
|
|
120
|
+
</h1>
|
|
102
121
|
<div
|
|
103
122
|
style={{
|
|
104
123
|
display: 'flex',
|
|
@@ -119,25 +138,40 @@ export function ArticleDetailHero({
|
|
|
119
138
|
<Clock className="h-4 w-4" />
|
|
120
139
|
<span>{article.readTime}</span>
|
|
121
140
|
</div>
|
|
122
|
-
{showConfiguredAuthors &&
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
{
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
+
{showConfiguredAuthors &&
|
|
142
|
+
(() => {
|
|
143
|
+
const singleAvatarUrl =
|
|
144
|
+
authors.length === 1 ? resolveAvatarUrl(authors[0], config) : null
|
|
145
|
+
return (
|
|
146
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
|
147
|
+
{singleAvatarUrl ? (
|
|
148
|
+
<Image
|
|
149
|
+
src={singleAvatarUrl}
|
|
150
|
+
alt=""
|
|
151
|
+
width={20}
|
|
152
|
+
height={20}
|
|
153
|
+
className="h-5 w-5 rounded-full object-cover"
|
|
154
|
+
/>
|
|
155
|
+
) : (
|
|
156
|
+
<User className="h-4 w-4" />
|
|
157
|
+
)}
|
|
158
|
+
<span>
|
|
159
|
+
By{' '}
|
|
160
|
+
{authors.map((author, index) => (
|
|
161
|
+
<Fragment key={author.slug}>
|
|
162
|
+
{index > 0 && ', '}
|
|
163
|
+
<Link
|
|
164
|
+
href={`/articles/authors/${author.slug}`}
|
|
165
|
+
className="font-medium text-white underline-offset-4 hover:underline"
|
|
166
|
+
>
|
|
167
|
+
{author.name}
|
|
168
|
+
</Link>
|
|
169
|
+
</Fragment>
|
|
170
|
+
))}
|
|
171
|
+
</span>
|
|
172
|
+
</div>
|
|
173
|
+
)
|
|
174
|
+
})()}
|
|
141
175
|
{showLegacyAuthor && (
|
|
142
176
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
|
143
177
|
<User className="h-4 w-4" />
|
package/src/ArticlesPage.tsx
CHANGED
|
@@ -36,6 +36,7 @@ function buildThemeVars(theme?: ArticlesTheme): React.CSSProperties {
|
|
|
36
36
|
if (!theme) return {}
|
|
37
37
|
const vars: Record<string, string> = {}
|
|
38
38
|
if (theme.fontFamily) vars['--articles-font-family'] = theme.fontFamily
|
|
39
|
+
if (theme.headerFontFamily) vars['--articles-header-font-family'] = theme.headerFontFamily
|
|
39
40
|
if (theme.headerColor) vars['--articles-header-color'] = theme.headerColor
|
|
40
41
|
if (theme.textColor) vars['--articles-text-color'] = theme.textColor
|
|
41
42
|
if (theme.backgroundColor) vars['--articles-bg-color'] = theme.backgroundColor
|
package/src/Breadcrumb.tsx
CHANGED
|
@@ -4,6 +4,8 @@ import type { BreadcrumbItem } from './articleTypes'
|
|
|
4
4
|
type BreadcrumbProps = Readonly<{
|
|
5
5
|
items: BreadcrumbItem[]
|
|
6
6
|
className?: string
|
|
7
|
+
/** Max-width utility class applied to the breadcrumb list. Default: 'max-w-7xl'. */
|
|
8
|
+
containerClassName?: string
|
|
7
9
|
showSchema?: boolean
|
|
8
10
|
separator?: string
|
|
9
11
|
}>
|
|
@@ -29,6 +31,7 @@ function getDisplayItems(items: readonly BreadcrumbItem[]): BreadcrumbItem[] {
|
|
|
29
31
|
export function Breadcrumb({
|
|
30
32
|
items,
|
|
31
33
|
className = '',
|
|
34
|
+
containerClassName = 'max-w-7xl',
|
|
32
35
|
showSchema = true,
|
|
33
36
|
separator = '>',
|
|
34
37
|
}: BreadcrumbProps) {
|
|
@@ -42,7 +45,7 @@ export function Breadcrumb({
|
|
|
42
45
|
aria-label="Breadcrumb"
|
|
43
46
|
className={`border-b border-border bg-background/80 px-4 py-3 text-sm text-muted-foreground ${className}`}
|
|
44
47
|
>
|
|
45
|
-
<ol className=
|
|
48
|
+
<ol className={`mx-auto flex items-center gap-2 overflow-hidden ${containerClassName}`}>
|
|
46
49
|
{displayItems.map((item, index) => {
|
|
47
50
|
const isLast = index === displayItems.length - 1
|
|
48
51
|
const key = `${item.name}-${index}`
|
|
@@ -29,7 +29,10 @@ export function RelatedArticlesSection({
|
|
|
29
29
|
|
|
30
30
|
return (
|
|
31
31
|
<section className="mt-12 pt-8 border-t border-border" aria-label="Related articles">
|
|
32
|
-
<h2
|
|
32
|
+
<h2
|
|
33
|
+
className="text-xl font-semibold text-foreground mb-6"
|
|
34
|
+
style={{ fontFamily: config?.theme?.headerFontFamily }}
|
|
35
|
+
>
|
|
33
36
|
{heading ?? `More in ${category}`}
|
|
34
37
|
</h2>
|
|
35
38
|
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
|
@@ -193,4 +193,61 @@ describe('ArticleDetailHero', () => {
|
|
|
193
193
|
).not.toBeInTheDocument()
|
|
194
194
|
})
|
|
195
195
|
})
|
|
196
|
+
|
|
197
|
+
describe('byline author avatar', () => {
|
|
198
|
+
const authorWithAvatar: AuthorProfile = { ...configuredAuthor, avatar: 'andrew-blase.webp' }
|
|
199
|
+
|
|
200
|
+
it('renders the resolved avatar image for a single configured author with an avatar and config.siteUrl', () => {
|
|
201
|
+
const { container } = render(
|
|
202
|
+
<ArticleDetailHero
|
|
203
|
+
article={baseArticle}
|
|
204
|
+
authors={[authorWithAvatar]}
|
|
205
|
+
config={{ siteUrl: 'https://example.com', siteName: 'Test Site' }}
|
|
206
|
+
/>
|
|
207
|
+
)
|
|
208
|
+
const img = container.querySelector('img.rounded-full')
|
|
209
|
+
expect(img).toHaveAttribute(
|
|
210
|
+
'src',
|
|
211
|
+
'https://example.com/articles/authors/andrew-blase/andrew-blase.webp'
|
|
212
|
+
)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('renders an already-absolute avatar URL unchanged, without needing config', () => {
|
|
216
|
+
const authorWithAbsoluteAvatar: AuthorProfile = {
|
|
217
|
+
...configuredAuthor,
|
|
218
|
+
avatar: 'https://cdn.example.com/andrew-blase.webp',
|
|
219
|
+
}
|
|
220
|
+
const { container } = render(
|
|
221
|
+
<ArticleDetailHero article={baseArticle} authors={[authorWithAbsoluteAvatar]} />
|
|
222
|
+
)
|
|
223
|
+
const img = container.querySelector('img.rounded-full')
|
|
224
|
+
expect(img).toHaveAttribute('src', 'https://cdn.example.com/andrew-blase.webp')
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('falls back to the icon when the author has no avatar', () => {
|
|
228
|
+
const { container } = render(
|
|
229
|
+
<ArticleDetailHero article={baseArticle} authors={[configuredAuthor]} />
|
|
230
|
+
)
|
|
231
|
+
expect(container.querySelector('img.rounded-full')).not.toBeInTheDocument()
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
it('falls back to the icon when a relative avatar is set but config.siteUrl is missing', () => {
|
|
235
|
+
const { container } = render(
|
|
236
|
+
<ArticleDetailHero article={baseArticle} authors={[authorWithAvatar]} />
|
|
237
|
+
)
|
|
238
|
+
expect(container.querySelector('img.rounded-full')).not.toBeInTheDocument()
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it('falls back to the icon when multiple authors are configured, even if one has an avatar', () => {
|
|
242
|
+
const secondAuthor: AuthorProfile = { name: 'Jamie Rivera', slug: 'jamie-rivera', bio: '' }
|
|
243
|
+
const { container } = render(
|
|
244
|
+
<ArticleDetailHero
|
|
245
|
+
article={baseArticle}
|
|
246
|
+
authors={[authorWithAvatar, secondAuthor]}
|
|
247
|
+
config={{ siteUrl: 'https://example.com', siteName: 'Test Site' }}
|
|
248
|
+
/>
|
|
249
|
+
)
|
|
250
|
+
expect(container.querySelector('img.rounded-full')).not.toBeInTheDocument()
|
|
251
|
+
})
|
|
252
|
+
})
|
|
196
253
|
})
|
package/src/articlesConfig.ts
CHANGED
|
@@ -18,6 +18,14 @@ export type ArticlesSection =
|
|
|
18
18
|
export interface ArticlesTheme {
|
|
19
19
|
/** Font family for the articles section. Example: `"'Inter', sans-serif"` */
|
|
20
20
|
fontFamily?: string
|
|
21
|
+
/**
|
|
22
|
+
* Font family for headings only (article title, card titles, section
|
|
23
|
+
* headings) - falls back to `fontFamily` when omitted. Lets a site use a
|
|
24
|
+
* distinct display face for headings (e.g. a serif) while keeping a
|
|
25
|
+
* separate body font, without hardcoding either into the package.
|
|
26
|
+
* Example: `"'Cinzel', serif"`
|
|
27
|
+
*/
|
|
28
|
+
headerFontFamily?: string
|
|
21
29
|
/** Color for article card titles and section headings. Example: `'#111827'` */
|
|
22
30
|
headerColor?: string
|
|
23
31
|
/** Color for body and excerpt text. Example: `'#6b7280'` */
|