@fullstackdatasolutions/articles 1.1.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.1.0",
3
+ "version": "1.2.0",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "funding": {
@@ -22,6 +22,16 @@ function toSlug(cat: string): string {
22
22
  .replaceAll(/[^a-z0-9-]/g, '')
23
23
  }
24
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
+
25
35
  export function ArticleDetailHero({
26
36
  article,
27
37
  categoryBasePath = '/articles/category',
@@ -128,25 +138,40 @@ export function ArticleDetailHero({
128
138
  <Clock className="h-4 w-4" />
129
139
  <span>{article.readTime}</span>
130
140
  </div>
131
- {showConfiguredAuthors && (
132
- <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
133
- <User className="h-4 w-4" />
134
- <span>
135
- By{' '}
136
- {authors.map((author, index) => (
137
- <Fragment key={author.slug}>
138
- {index > 0 && ', '}
139
- <Link
140
- href={`/articles/authors/${author.slug}`}
141
- className="font-medium text-white underline-offset-4 hover:underline"
142
- >
143
- {author.name}
144
- </Link>
145
- </Fragment>
146
- ))}
147
- </span>
148
- </div>
149
- )}
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
+ })()}
150
175
  {showLegacyAuthor && (
151
176
  <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
152
177
  <User className="h-4 w-4" />
@@ -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}`
@@ -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
  })