@fullstackdatasolutions/articles 1.1.0 → 1.2.1

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.1",
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
  })
package/src/markdown.ts CHANGED
@@ -362,7 +362,9 @@ export async function markdownToHtml(
362
362
  processor = processor.use(rehypeProcessImages, { articleSlug })
363
363
  }
364
364
 
365
- const result = await processor.use(rehypeStringify).process(markdown)
365
+ const result = await processor
366
+ .use(rehypeStringify)
367
+ .process(stripInlineTagsFromHeadings(markdown))
366
368
 
367
369
  return result.toString()
368
370
  } catch (error) {
@@ -377,8 +379,51 @@ export async function markdownToHtml(
377
379
  }
378
380
  }
379
381
 
382
+ /**
383
+ * Strips inline HTML/JSX tags from heading lines only, keeping their inner
384
+ * text, before the markdown reaches a plain (non-MDX) remark parse.
385
+ *
386
+ * Article headings commonly carry a reader-facing rating dot written as JSX,
387
+ * e.g. `### Rage <span style={{ color: '#3b82f6' }}>●</span>`. `renderMdxSource`
388
+ * (real MDX compilation via `@mdx-js/mdx`) parses that correctly and renders
389
+ * a real `<span>` element. But `extractToc` and `markdownToHtml` both run
390
+ * headings through plain `remark-parse` with no MDX support, and CommonMark's
391
+ * raw-inline-HTML grammar does not accept a JSX object-literal attribute
392
+ * expression like `style={{ ... }}` - remark's HTML tokenizer fails to match
393
+ * it as a tag and falls back to treating the whole thing as literal text.
394
+ * That garbled text then (a) becomes the visible TOC label, and (b) feeds
395
+ * `rehype-slug`, producing a slug built from the raw markup instead of the
396
+ * heading's real words - which does not match the id `renderMdxSource`'s
397
+ * correctly-parsed pipeline assigns to the same heading in the live page, so
398
+ * the TOC entry silently links to an id that does not exist in the DOM.
399
+ *
400
+ * Stripping tags (not their inner content) from heading lines before parsing
401
+ * keeps the extracted text and generated slug consistent with what the real
402
+ * MDX render puts in the page, for any heading-level inline markup - not
403
+ * only the rating-dot convention that surfaced the bug.
404
+ */
405
+ function stripInlineTagsFromHeadings(markdown: string): string {
406
+ return markdown.replace(/^(#{1,6}[ \t].*)$/gm, (line) =>
407
+ line.replace(/<\/?[a-zA-Z][^<>\n]*>/g, '')
408
+ )
409
+ }
410
+
411
+ /**
412
+ * Recursively extracts a node's text content, including text nested inside
413
+ * child elements (for example a markdown link's `<a>Dwarf</a>` inside a
414
+ * heading like `### [Dwarf](/link)`). A shallow, direct-children-only check
415
+ * here previously dropped link text from every heading that used a link as
416
+ * part of its heading text - a distinct bug from the JSX-in-heading garbling
417
+ * `stripInlineTagsFromHeadings` fixes, but with a worse symptom: the heading
418
+ * was silently omitted from the TOC entirely (both `id` and `text` came back
419
+ * empty, so `extractHeadingItem` returned `null`) rather than merely garbled.
420
+ */
380
421
  function nodeTextValue(c: ElementContent): string {
381
- return c.type === 'text' ? (c as { value: string }).value : ''
422
+ if (c.type === 'text') return (c as { value: string }).value
423
+ if (c.type === 'element' && 'children' in c) {
424
+ return (c as Element).children.map(nodeTextValue).join('')
425
+ }
426
+ return ''
382
427
  }
383
428
 
384
429
  function extractHeadingItem(node: Element): TocItem | null {
@@ -446,6 +491,6 @@ export async function extractToc(markdown: string): Promise<TocItem[]> {
446
491
  .use(rehypeSlug)
447
492
  .use(collectHeadings)
448
493
  .use(rehypeStringify)
449
- .process(markdown)
494
+ .process(stripInlineTagsFromHeadings(markdown))
450
495
  return headings
451
496
  }