@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/CHANGELOG.md +20 -0
- package/README.md +1 -0
- package/dist/index.cjs +41 -19
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +3 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +41 -19
- package/dist/index.js.map +1 -1
- package/dist/nextjs.cjs +13 -3
- package/dist/nextjs.cjs.map +1 -1
- package/dist/nextjs.js +13 -3
- package/dist/nextjs.js.map +1 -1
- package/dist/server.cjs +13 -3
- package/dist/server.cjs.map +1 -1
- package/dist/server.js +13 -3
- package/dist/server.js.map +1 -1
- package/package.json +1 -1
- package/src/ArticleDetailHero.tsx +44 -19
- package/src/Breadcrumb.tsx +4 -1
- package/src/__tests__/ArticleDetailHero.test.tsx +57 -0
- package/src/markdown.ts +48 -3
package/package.json
CHANGED
|
@@ -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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
{
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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" />
|
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}`
|
|
@@ -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
|
|
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
|
-
|
|
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
|
}
|