@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fullstackdatasolutions/articles",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "funding": {
@@ -32,11 +32,11 @@ export function ArticleCard({ article, config }: ArticleCardProps) {
32
32
  {article.category}
33
33
  </span>
34
34
  </div>
35
- <h3 className="text-lg font-semibold leading-none tracking-tight mb-2">
36
- <Link
37
- href={`/articles/${article.slug}`}
38
- className="hover:text-indigo-600 transition-colors"
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 className="text-4xl md:text-5xl font-bold mb-4">{article.title}</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
- <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
124
- <User className="h-4 w-4" />
125
- <span>
126
- By{' '}
127
- {authors.map((author, index) => (
128
- <Fragment key={author.slug}>
129
- {index > 0 && ', '}
130
- <Link
131
- href={`/articles/authors/${author.slug}`}
132
- className="font-medium text-white underline-offset-4 hover:underline"
133
- >
134
- {author.name}
135
- </Link>
136
- </Fragment>
137
- ))}
138
- </span>
139
- </div>
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" />
@@ -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
@@ -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="mx-auto flex max-w-7xl items-center gap-2 overflow-hidden">
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 className="text-xl font-semibold text-foreground mb-6">
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
  })
@@ -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'` */