@fullstackdatasolutions/articles 0.12.0 → 1.1.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.
Files changed (69) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +559 -11
  3. package/dist/index.cjs +984 -388
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.d.cts +383 -21
  6. package/dist/index.d.ts +383 -21
  7. package/dist/index.js +968 -376
  8. package/dist/index.js.map +1 -1
  9. package/dist/nextjs.cjs +74 -6
  10. package/dist/nextjs.cjs.map +1 -1
  11. package/dist/nextjs.d.cts +149 -0
  12. package/dist/nextjs.d.ts +149 -0
  13. package/dist/nextjs.js +74 -6
  14. package/dist/nextjs.js.map +1 -1
  15. package/dist/server.cjs +665 -27
  16. package/dist/server.cjs.map +1 -1
  17. package/dist/server.d.cts +357 -3
  18. package/dist/server.d.ts +357 -3
  19. package/dist/server.js +643 -29
  20. package/dist/server.js.map +1 -1
  21. package/package.json +1 -1
  22. package/src/ArticleCard.tsx +42 -6
  23. package/src/ArticleContent.tsx +144 -5
  24. package/src/ArticleDetailHero.tsx +33 -1
  25. package/src/ArticleNavigation.tsx +32 -1
  26. package/src/ArticleSchemas.tsx +43 -39
  27. package/src/ArticleSocialShare.tsx +54 -10
  28. package/src/ArticlesPage.tsx +56 -5
  29. package/src/AuthorArticlesPage.tsx +308 -14
  30. package/src/AuthorCard.tsx +1 -1
  31. package/src/CategoryArticlesPage.tsx +34 -2
  32. package/src/LatestArticles.tsx +28 -1
  33. package/src/LatestArticlesSection.tsx +15 -1
  34. package/src/PaginationNav.tsx +78 -0
  35. package/src/RelatedArticlesSection.tsx +58 -0
  36. package/src/SeriesArticlesPage.tsx +66 -0
  37. package/src/__tests__/ArticleCard.test.tsx +63 -3
  38. package/src/__tests__/ArticleContent.test.tsx +143 -0
  39. package/src/__tests__/ArticleDetailHero.test.tsx +30 -0
  40. package/src/__tests__/ArticleNavigation.test.tsx +81 -3
  41. package/src/__tests__/ArticleSchemas.test.tsx +155 -81
  42. package/src/__tests__/ArticleSocialShare.test.tsx +54 -0
  43. package/src/__tests__/ArticlesPage.test.tsx +131 -0
  44. package/src/__tests__/AuthorArticlesPage.test.tsx +304 -3
  45. package/src/__tests__/CategoryArticlesPage.test.tsx +116 -1
  46. package/src/__tests__/LatestArticles.test.tsx +52 -0
  47. package/src/__tests__/LatestArticlesSection.test.tsx +28 -0
  48. package/src/__tests__/PaginationNav.test.tsx +73 -0
  49. package/src/__tests__/RelatedArticlesSection.test.tsx +132 -0
  50. package/src/__tests__/SeriesArticlesPage.test.tsx +121 -0
  51. package/src/__tests__/eventTracking.test.tsx +145 -0
  52. package/src/__tests__/events.test.ts +82 -0
  53. package/src/__tests__/markdown.test.ts +78 -1
  54. package/src/__tests__/pagination.test.ts +178 -0
  55. package/src/__tests__/seoUtils-authors.test.ts +37 -0
  56. package/src/__tests__/seoUtils.test.ts +246 -0
  57. package/src/__tests__/server-articles.test.ts +356 -1
  58. package/src/__tests__/validateArticles.test.ts +312 -0
  59. package/src/articleTypes.ts +109 -0
  60. package/src/articlesConfig.ts +45 -1
  61. package/src/eventTracking.tsx +97 -0
  62. package/src/events.ts +105 -0
  63. package/src/index.ts +26 -1
  64. package/src/markdown.ts +41 -0
  65. package/src/pagination.ts +93 -0
  66. package/src/seoUtils.ts +198 -11
  67. package/src/server-articles.ts +199 -6
  68. package/src/server.ts +46 -2
  69. package/src/validateArticles.ts +260 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fullstackdatasolutions/articles",
3
- "version": "0.12.0",
3
+ "version": "1.1.0",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "funding": {
@@ -3,13 +3,18 @@
3
3
  import Image from 'next/image'
4
4
  import Link from 'next/link'
5
5
  import { ArrowRight, Calendar, Clock } from 'lucide-react'
6
+ import { getInitials } from './AuthorCard'
7
+ import { emitArticleEvent } from './events'
6
8
  import type { Article } from './articleTypes'
9
+ import type { ArticlesConfig } from './articlesConfig'
7
10
 
8
11
  type ArticleCardProps = Readonly<{
9
12
  article: Article
13
+ /** Optional (Phase 27F) - when passed with `config.onEvent`, clicking the author link fires an `author_clicked` event. Omitting it changes nothing. */
14
+ config?: ArticlesConfig
10
15
  }>
11
16
 
12
- export function ArticleCard({ article }: ArticleCardProps) {
17
+ export function ArticleCard({ article, config }: ArticleCardProps) {
13
18
  return (
14
19
  <div className="rounded-lg border border-border bg-card text-card-foreground shadow-sm hover:shadow-lg transition-shadow duration-300">
15
20
  <div className="p-0">
@@ -27,15 +32,46 @@ export function ArticleCard({ article }: ArticleCardProps) {
27
32
  {article.category}
28
33
  </span>
29
34
  </div>
30
- <h3 className="text-lg font-semibold leading-none tracking-tight mb-2">
31
- <Link
32
- href={`/articles/${article.slug}`}
33
- className="hover:text-indigo-600 transition-colors"
34
- >
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">
35
40
  {article.title}
36
41
  </Link>
37
42
  </h3>
38
43
  <p className="text-muted-foreground mb-4 line-clamp-3">{article.excerpt}</p>
44
+ {article.authorSlug && (
45
+ <Link
46
+ href={`/articles/authors/${article.authorSlug}`}
47
+ className="mb-4 flex items-center gap-2 text-sm text-muted-foreground hover:text-primary transition-colors"
48
+ onClick={() =>
49
+ emitArticleEvent(config?.onEvent, {
50
+ name: 'author_clicked',
51
+ articleSlug: article.slug,
52
+ authorSlug: article.authorSlug as string,
53
+ })
54
+ }
55
+ >
56
+ {article.authorAvatar ? (
57
+ <Image
58
+ src={article.authorAvatar}
59
+ alt=""
60
+ width={24}
61
+ height={24}
62
+ className="h-6 w-6 rounded-full object-cover"
63
+ />
64
+ ) : (
65
+ <span
66
+ aria-hidden="true"
67
+ className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-muted text-[10px] font-semibold text-muted-foreground"
68
+ >
69
+ {getInitials(article.author)}
70
+ </span>
71
+ )}
72
+ <span>{article.author}</span>
73
+ </Link>
74
+ )}
39
75
  <div className="flex items-center justify-between text-sm text-muted-foreground border-t pt-4">
40
76
  <div className="flex items-center gap-3">
41
77
  {article.date && (
@@ -1,19 +1,158 @@
1
+ import type { ReactNode } from 'react'
1
2
  import { renderMdxSource } from './renderMdx'
3
+ import { markdownToHtml, getContentSlotBoundaries } from './markdown'
2
4
  import type { Article } from './articleTypes'
3
5
  import type { ArticlesConfig } from './articlesConfig'
4
6
 
7
+ /**
8
+ * Sanitized, non-PII context passed into `ArticleContent`'s slot render
9
+ * props - deliberately a narrow subset of `Article`, not the whole object
10
+ * (no raw `content`/`mdxSource`, no author email or anything author-PII).
11
+ */
12
+ export interface ArticleSlotContext {
13
+ slug: string
14
+ title: string
15
+ category: string
16
+ tags: string[]
17
+ readTime: string
18
+ wordCount?: number
19
+ authorSlug?: string
20
+ seriesSlug?: string
21
+ primaryActionId?: string
22
+ }
23
+
24
+ export type ArticleSlotContent = ReactNode | ((context: ArticleSlotContext) => ReactNode)
25
+
26
+ function buildSlotContext(article: Article): ArticleSlotContext {
27
+ return {
28
+ slug: article.slug,
29
+ title: article.title,
30
+ category: article.category,
31
+ tags: article.tags ?? [],
32
+ readTime: article.readTime,
33
+ wordCount: article.wordCount,
34
+ authorSlug: article.authorSlug,
35
+ seriesSlug: article.seriesSlug,
36
+ primaryActionId: article.primaryAction?.actionId,
37
+ }
38
+ }
39
+
40
+ function resolveSlot(slot: ArticleSlotContent | undefined, context: ArticleSlotContext): ReactNode {
41
+ if (slot === undefined) return null
42
+ return typeof slot === 'function' ? slot(context) : slot
43
+ }
44
+
5
45
  type ArticleContentProps = Readonly<{
6
46
  article: Article
7
47
  className?: string
8
48
  config?: ArticlesConfig
49
+ /** Rendered immediately before the article body - the "around the body, not inside it" counterpart to `config.mdxComponents` (which places content *inside* MDX bodies). */
50
+ afterHero?: ArticleSlotContent
51
+ /** Rendered right after the first paragraph, resolved deterministically from the parsed AST (see `getContentSlotBoundaries`). Falls back to not rendering (never a brittle string split) when the source has no detectable paragraphs, e.g. MDX using JSX-heavy syntax remark-parse can't read as plain markdown. */
52
+ afterIntro?: ArticleSlotContent
53
+ /** Rendered after roughly the middle paragraph. Same fallback behavior as `afterIntro`. */
54
+ midContent?: ArticleSlotContent
55
+ /** Rendered immediately after the article body. */
56
+ afterContent?: ArticleSlotContent
9
57
  }>
10
58
 
11
- export async function ArticleContent({ article, className, config }: ArticleContentProps) {
12
- if (article.contentType === 'mdx' && article.mdxSource) {
13
- const content = await renderMdxSource(article.mdxSource, `/articles/${article.slug}`, config)
14
- return <div className={className}>{content}</div>
59
+ async function renderSegment(
60
+ markdown: string,
61
+ contentType: Article['contentType'],
62
+ slug: string,
63
+ config?: ArticlesConfig
64
+ ): Promise<ReactNode> {
65
+ if (!markdown.trim()) return null
66
+ if (contentType === 'mdx') {
67
+ return renderMdxSource(markdown, `/articles/${slug}`, config)
68
+ }
69
+ const html = await markdownToHtml(markdown, slug, config)
70
+ return <div dangerouslySetInnerHTML={{ __html: html }} />
71
+ }
72
+
73
+ export async function ArticleContent({
74
+ article,
75
+ className,
76
+ config,
77
+ afterHero,
78
+ afterIntro,
79
+ midContent,
80
+ afterContent,
81
+ }: ArticleContentProps) {
82
+ // Legacy path (Phase 27F: zero slot props passed) reproduces the exact
83
+ // pre-27F markup - a single outer div, `dangerouslySetInnerHTML` set
84
+ // directly on it for the HTML path - rather than the slot-aware wrapper
85
+ // below, so existing consumers/tests see byte-for-byte identical output.
86
+ const hasAnySlot =
87
+ afterHero !== undefined ||
88
+ afterIntro !== undefined ||
89
+ midContent !== undefined ||
90
+ afterContent !== undefined
91
+ if (!hasAnySlot) {
92
+ if (article.contentType === 'mdx' && article.mdxSource) {
93
+ const content = await renderMdxSource(article.mdxSource, `/articles/${article.slug}`, config)
94
+ return <div className={className}>{content}</div>
95
+ }
96
+ return (
97
+ <div className={className} dangerouslySetInnerHTML={{ __html: article.htmlContent || '' }} />
98
+ )
15
99
  }
100
+
101
+ const slotContext = buildSlotContext(article)
102
+ const heroNode = resolveSlot(afterHero, slotContext)
103
+ const introNode = resolveSlot(afterIntro, slotContext)
104
+ const midNode = resolveSlot(midContent, slotContext)
105
+ const contentNode = resolveSlot(afterContent, slotContext)
106
+
107
+ const needsSplit = Boolean(introNode || midNode)
108
+ const rawSource =
109
+ article.contentType === 'mdx' ? article.mdxSource : (article.content ?? undefined)
110
+
111
+ if (needsSplit && rawSource) {
112
+ const boundaries = getContentSlotBoundaries(rawSource)
113
+ if (boundaries) {
114
+ try {
115
+ const introSegment = rawSource.slice(0, boundaries.introEnd)
116
+ const midSegment = rawSource.slice(boundaries.introEnd, boundaries.mid)
117
+ const restSegment = rawSource.slice(boundaries.mid)
118
+ const [introHtml, midHtml, restHtml] = await Promise.all([
119
+ renderSegment(introSegment, article.contentType, article.slug, config),
120
+ renderSegment(midSegment, article.contentType, article.slug, config),
121
+ renderSegment(restSegment, article.contentType, article.slug, config),
122
+ ])
123
+ return (
124
+ <div className={className}>
125
+ {heroNode}
126
+ {introHtml}
127
+ {introNode}
128
+ {midHtml}
129
+ {midNode}
130
+ {restHtml}
131
+ {contentNode}
132
+ </div>
133
+ )
134
+ } catch {
135
+ // Splitting the MDX source failed to evaluate (e.g. a JSX block
136
+ // straddled a paragraph boundary) - fall through to the
137
+ // whole-document render below rather than throwing. `afterIntro`/
138
+ // `midContent` are silently omitted for this article; `afterHero`/
139
+ // `afterContent` still render.
140
+ }
141
+ }
142
+ }
143
+
144
+ const wholeBody =
145
+ article.contentType === 'mdx' && article.mdxSource ? (
146
+ await renderMdxSource(article.mdxSource, `/articles/${article.slug}`, config)
147
+ ) : (
148
+ <div dangerouslySetInnerHTML={{ __html: article.htmlContent || '' }} />
149
+ )
150
+
16
151
  return (
17
- <div className={className} dangerouslySetInnerHTML={{ __html: article.htmlContent || '' }} />
152
+ <div className={className}>
153
+ {heroNode}
154
+ {wholeBody}
155
+ {contentNode}
156
+ </div>
18
157
  )
19
158
  }
@@ -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 {
@@ -25,6 +28,7 @@ export function ArticleDetailHero({
25
28
  showDate = false,
26
29
  showAuthor = true,
27
30
  authors = [],
31
+ config,
28
32
  }: ArticleDetailHeroProps) {
29
33
  const showConfiguredAuthors = showAuthor && authors.length > 0
30
34
  const showLegacyAuthor = showAuthor && authors.length === 0 && article.author.trim().length > 0
@@ -98,7 +102,12 @@ export function ArticleDetailHero({
98
102
  )}
99
103
  </div>
100
104
  )}
101
- <h1 className="text-4xl md:text-5xl font-bold mb-4">{article.title}</h1>
105
+ <h1
106
+ className="text-4xl md:text-5xl font-bold mb-4"
107
+ style={{ fontFamily: config?.theme?.headerFontFamily }}
108
+ >
109
+ {article.title}
110
+ </h1>
102
111
  <div
103
112
  style={{
104
113
  display: 'flex',
@@ -145,6 +154,29 @@ export function ArticleDetailHero({
145
154
  </div>
146
155
  )}
147
156
  </div>
157
+ {/*
158
+ Phase 27E: one-sentence author authority statement, sourced from
159
+ the configured author's `promise`. Scoped here (the article detail
160
+ byline) rather than `ArticleCard` - a listing grid repeats the same
161
+ author across many cards, so a second line of text per card adds
162
+ clutter without adding trust; the detail page byline is the one
163
+ high-intent placement where establishing authority right as a
164
+ reader commits to the article actually helps. Only renders for a
165
+ single configured author (ambiguous which author's promise "wins"
166
+ with more than one) and only when `promise` is set - omitted
167
+ entirely otherwise, so this never changes existing output.
168
+ */}
169
+ {showConfiguredAuthors && authors.length === 1 && authors[0].promise && (
170
+ <p
171
+ style={{
172
+ marginTop: '0.75rem',
173
+ fontSize: '0.9375rem',
174
+ color: 'rgba(255,255,255,0.85)',
175
+ }}
176
+ >
177
+ {authors[0].promise}
178
+ </p>
179
+ )}
148
180
  </div>
149
181
  </section>
150
182
  )
@@ -1,19 +1,48 @@
1
1
  import Link from 'next/link'
2
2
  import { ChevronLeft, ChevronRight } from 'lucide-react'
3
+ import { emitArticleEvent } from './events'
4
+ import type { ArticlesConfig } from './articlesConfig'
3
5
 
4
6
  interface ArticleNavigationProps {
5
7
  readonly previous: { slug: string; title: string } | null
6
8
  readonly next: { slug: string; title: string } | null
7
9
  readonly basePath: string
10
+ /** The article slug this navigation is shown on. Combined with `pathKey`, enables `path_step_advanced` events. */
11
+ readonly fromSlug?: string
12
+ /**
13
+ * Set when this navigation walks a configured `Path` (as opposed to plain
14
+ * chronological/series adjacency) - fires `path_step_advanced` on click.
15
+ * Omit for ordinary previous/next navigation; no event fires without it.
16
+ */
17
+ readonly pathKey?: string
18
+ readonly config?: ArticlesConfig
8
19
  }
9
20
 
10
21
  function truncateTitle(title: string, maxLength = 60): string {
11
22
  return title.length > maxLength ? `${title.substring(0, maxLength)}...` : title
12
23
  }
13
24
 
14
- export function ArticleNavigation({ previous, next, basePath }: ArticleNavigationProps) {
25
+ export function ArticleNavigation({
26
+ previous,
27
+ next,
28
+ basePath,
29
+ fromSlug,
30
+ pathKey,
31
+ config,
32
+ }: ArticleNavigationProps) {
15
33
  if (!previous && !next) return null
16
34
 
35
+ function emitStep(toSlug: string, direction: 'previous' | 'next') {
36
+ if (!pathKey || !fromSlug) return
37
+ emitArticleEvent(config?.onEvent, {
38
+ name: 'path_step_advanced',
39
+ pathKey,
40
+ fromSlug,
41
+ toSlug,
42
+ direction,
43
+ })
44
+ }
45
+
17
46
  return (
18
47
  <nav className="mt-12 pt-8 border-t border-border" aria-label="Article navigation">
19
48
  <div className="flex justify-between gap-4">
@@ -21,6 +50,7 @@ export function ArticleNavigation({ previous, next, basePath }: ArticleNavigatio
21
50
  {previous && (
22
51
  <Link
23
52
  href={`${basePath}/${previous.slug}`}
53
+ onClick={() => emitStep(previous.slug, 'previous')}
24
54
  className="group flex items-center gap-3 p-4 rounded-lg border border-border hover:border-primary/20 hover:bg-muted/50 transition-colors"
25
55
  >
26
56
  <ChevronLeft className="h-5 w-5 text-muted-foreground group-hover:text-primary shrink-0" />
@@ -38,6 +68,7 @@ export function ArticleNavigation({ previous, next, basePath }: ArticleNavigatio
38
68
  {next && (
39
69
  <Link
40
70
  href={`${basePath}/${next.slug}`}
71
+ onClick={() => emitStep(next.slug, 'next')}
41
72
  className="group flex items-center justify-end gap-3 p-4 rounded-lg border border-border hover:border-primary/20 hover:bg-muted/50 transition-colors"
42
73
  >
43
74
  <div className="min-w-0 flex-1 text-right">
@@ -1,59 +1,35 @@
1
+ import type { ArticlesConfig } from './articlesConfig'
1
2
  import type { Article, AuthorProfile, FaqItem, HowToStep } from './articleTypes'
2
- import { getAuthorSameAs } from './authorUtils'
3
+ import { getAuthorAvatar, getAuthorSameAs } from './authorUtils'
3
4
 
4
5
  export { BreadcrumbSchema } from './Breadcrumb'
5
6
 
6
- type ArticleSchemaProps = Readonly<{
7
- article: Article
8
- articleUrl: string
9
- siteName: string
10
- showAuthor?: boolean
11
- authors?: AuthorProfile[]
12
- }>
13
-
14
- function getPersonSchemas(article: Article, authors?: readonly AuthorProfile[]) {
7
+ function getPersonSchemas(
8
+ article: Article,
9
+ authors?: readonly AuthorProfile[],
10
+ config?: ArticlesConfig
11
+ ) {
15
12
  let resolvedAuthors: AuthorProfile[] = []
16
13
  if (authors?.length) {
17
14
  resolvedAuthors = [...authors]
18
15
  } else if (article.author) {
19
16
  resolvedAuthors = [{ name: article.author, slug: '', bio: '' }]
20
17
  }
18
+ // Phase 27E audit: same decision as AuthorArticlesPage's getPersonSchema -
19
+ // the new AuthorProfile fields (promise/servesWho/principles/credentials/
20
+ // proof) are marketing copy, audience segments, or unverifiable claims, not
21
+ // schema.org-appropriate facts, so none of them are added here. `url`/
22
+ // `image`/`sameAs` stay sourced exactly as before (author.url, resolved
23
+ // avatar, approved social links only).
21
24
  return resolvedAuthors.map((author) => ({
22
25
  '@type': 'Person',
23
26
  name: author.name,
24
27
  ...(author.url && { url: author.url }),
28
+ ...(getAuthorAvatar(author, config) && { image: getAuthorAvatar(author, config) }),
25
29
  ...(getAuthorSameAs(author).length > 0 && { sameAs: getAuthorSameAs(author) }),
26
30
  }))
27
31
  }
28
32
 
29
- export function ArticleSchema({
30
- article,
31
- articleUrl,
32
- siteName,
33
- showAuthor = true,
34
- authors,
35
- }: ArticleSchemaProps) {
36
- const personSchemas = getPersonSchemas(article, authors)
37
- const schema = {
38
- '@context': 'https://schema.org',
39
- '@type': 'Article',
40
- headline: article.title,
41
- description: article.excerpt,
42
- image: article.featuredImage,
43
- ...(article.date && { datePublished: new Date(article.date).toISOString() }),
44
- ...(showAuthor && personSchemas.length > 0 && { author: personSchemas }),
45
- publisher: { '@type': 'Organization', name: siteName },
46
- mainEntityOfPage: { '@type': 'WebPage', '@id': articleUrl },
47
- }
48
-
49
- return (
50
- <script
51
- type="application/ld+json"
52
- dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
53
- />
54
- )
55
- }
56
-
57
33
  type ArticleSEOProps = Readonly<{
58
34
  article: Article
59
35
  articleUrl: string
@@ -61,6 +37,7 @@ type ArticleSEOProps = Readonly<{
61
37
  siteLogo?: string
62
38
  showAuthor?: boolean
63
39
  authors?: AuthorProfile[]
40
+ config?: ArticlesConfig
64
41
  }>
65
42
 
66
43
  export function ArticleSEO({
@@ -70,10 +47,11 @@ export function ArticleSEO({
70
47
  siteLogo,
71
48
  showAuthor = true,
72
49
  authors,
50
+ config,
73
51
  }: ArticleSEOProps) {
74
52
  const publishedAt = article.date ? new Date(article.date).toISOString() : undefined
75
53
  const modifiedAt = article.lastmod ? new Date(article.lastmod).toISOString() : publishedAt
76
- const personSchemas = getPersonSchemas(article, authors)
54
+ const personSchemas = getPersonSchemas(article, authors, config)
77
55
 
78
56
  const structuredData = {
79
57
  '@context': 'https://schema.org',
@@ -92,6 +70,9 @@ export function ArticleSEO({
92
70
  ...(siteLogo && { logo: { '@type': 'ImageObject', url: siteLogo } }),
93
71
  },
94
72
  description: article.excerpt,
73
+ articleSection: article.category,
74
+ ...(article.tags && article.tags.length > 0 && { keywords: article.tags.join(', ') }),
75
+ ...(article.wordCount !== undefined && { wordCount: article.wordCount }),
95
76
  ...(article.series && { isPartOf: { '@type': 'Blog', name: article.series } }),
96
77
  }
97
78
 
@@ -160,11 +141,21 @@ export function FAQPageSchema({ items }: FAQPageSchemaProps) {
160
141
  )
161
142
  }
162
143
 
144
+ export type CollectionPageItem = Readonly<{
145
+ position: number
146
+ url: string
147
+ name: string
148
+ }>
149
+
163
150
  type CollectionPageSchemaProps = Readonly<{
164
151
  title: string
165
152
  description: string
166
153
  url: string
167
154
  articleCount: number
155
+ // The articles actually linked in the page's initial HTML (e.g. the first
156
+ // pageSize before "Load more"/pagination), not necessarily every article
157
+ // in the underlying collection - matches what a crawler can see.
158
+ items?: CollectionPageItem[]
168
159
  }>
169
160
 
170
161
  export function CollectionPageSchema({
@@ -172,6 +163,7 @@ export function CollectionPageSchema({
172
163
  description,
173
164
  url,
174
165
  articleCount,
166
+ items,
175
167
  }: CollectionPageSchemaProps) {
176
168
  const schema = {
177
169
  '@context': 'https://schema.org',
@@ -180,6 +172,18 @@ export function CollectionPageSchema({
180
172
  description,
181
173
  url,
182
174
  numberOfItems: articleCount,
175
+ ...(items &&
176
+ items.length > 0 && {
177
+ mainEntity: {
178
+ '@type': 'ItemList',
179
+ itemListElement: items.map((item) => ({
180
+ '@type': 'ListItem',
181
+ position: item.position,
182
+ url: item.url,
183
+ name: item.name,
184
+ })),
185
+ },
186
+ }),
183
187
  }
184
188
 
185
189
  return (
@@ -2,17 +2,35 @@
2
2
 
3
3
  import { useState } from 'react'
4
4
  import { Share2, Copy, Check, Mail, MessageCircle, Users, FileText } from 'lucide-react'
5
+ import { emitArticleEvent } from './events'
6
+ import type { ArticlesConfig } from './articlesConfig'
5
7
 
6
8
  interface ArticleSocialShareProps {
7
9
  readonly title: string
8
10
  readonly url: string
9
11
  readonly excerpt?: string
10
12
  readonly shareMessage?: string
13
+ /** Optional (Phase 27F). Enables `shared` events - requires `articleSlug` too. */
14
+ readonly config?: ArticlesConfig
15
+ /** The article slug being shared. Required to emit `shared`. */
16
+ readonly articleSlug?: string
11
17
  }
12
18
 
13
- export function ArticleSocialShare({ title, url, excerpt, shareMessage }: ArticleSocialShareProps) {
19
+ export function ArticleSocialShare({
20
+ title,
21
+ url,
22
+ excerpt,
23
+ shareMessage,
24
+ config,
25
+ articleSlug,
26
+ }: ArticleSocialShareProps) {
14
27
  const [copied, setCopied] = useState(false)
15
28
 
29
+ function trackShare(channel: string) {
30
+ if (!articleSlug) return
31
+ emitArticleEvent(config?.onEvent, { name: 'shared', articleSlug, channel })
32
+ }
33
+
16
34
  const encodedTitle = encodeURIComponent(title)
17
35
  const encodedUrl = encodeURIComponent(url)
18
36
  const encodedExcerpt = encodeURIComponent(excerpt || '')
@@ -31,13 +49,15 @@ export function ArticleSocialShare({ title, url, excerpt, shareMessage }: Articl
31
49
  try {
32
50
  await navigator.clipboard.writeText(url)
33
51
  setCopied(true)
52
+ trackShare('copy-link')
34
53
  setTimeout(() => setCopied(false), 2000)
35
54
  } catch {
36
55
  // clipboard unavailable
37
56
  }
38
57
  }
39
58
 
40
- const openShareWindow = (shareUrl: string) => {
59
+ const openShareWindow = (shareUrl: string, channel: string) => {
60
+ trackShare(channel)
41
61
  globalThis.open(shareUrl, '_blank', 'width=600,height=400,scrollbars=yes,resizable=yes')
42
62
  }
43
63
 
@@ -52,34 +72,58 @@ export function ArticleSocialShare({ title, url, excerpt, shareMessage }: Articl
52
72
  </div>
53
73
 
54
74
  <div className="flex flex-wrap gap-2">
55
- <button className={btnClass} onClick={() => openShareWindow(shareLinks.linkedin)}>
75
+ <button
76
+ type="button"
77
+ className={btnClass}
78
+ onClick={() => openShareWindow(shareLinks.linkedin, 'linkedin')}
79
+ >
56
80
  <Users className="h-4 w-4" />
57
81
  LinkedIn
58
82
  </button>
59
- <button className={btnClass} onClick={() => openShareWindow(shareLinks.facebook)}>
83
+ <button
84
+ type="button"
85
+ className={btnClass}
86
+ onClick={() => openShareWindow(shareLinks.facebook, 'facebook')}
87
+ >
60
88
  <span className="text-xs font-bold">f</span> Facebook
61
89
  </button>
62
- <button className={btnClass} onClick={() => openShareWindow(shareLinks.twitter)}>
90
+ <button
91
+ type="button"
92
+ className={btnClass}
93
+ onClick={() => openShareWindow(shareLinks.twitter, 'twitter')}
94
+ >
63
95
  <MessageCircle className="h-4 w-4" />
64
96
  Twitter
65
97
  </button>
66
- <button className={btnClass} onClick={() => openShareWindow(shareLinks.reddit)}>
98
+ <button
99
+ type="button"
100
+ className={btnClass}
101
+ onClick={() => openShareWindow(shareLinks.reddit, 'reddit')}
102
+ >
67
103
  <FileText className="h-4 w-4" />
68
104
  Reddit
69
105
  </button>
70
- <button className={btnClass} onClick={() => openShareWindow(shareLinks.whatsapp)}>
106
+ <button
107
+ type="button"
108
+ className={btnClass}
109
+ onClick={() => openShareWindow(shareLinks.whatsapp, 'whatsapp')}
110
+ >
71
111
  <MessageCircle className="h-4 w-4" />
72
112
  WhatsApp
73
113
  </button>
74
- <button className={btnClass} onClick={() => openShareWindow(shareLinks.telegram)}>
114
+ <button
115
+ type="button"
116
+ className={btnClass}
117
+ onClick={() => openShareWindow(shareLinks.telegram, 'telegram')}
118
+ >
75
119
  <MessageCircle className="h-4 w-4" />
76
120
  Telegram
77
121
  </button>
78
- <a className={btnClass} href={shareLinks.email}>
122
+ <a className={btnClass} href={shareLinks.email} onClick={() => trackShare('email')}>
79
123
  <Mail className="h-4 w-4" />
80
124
  Email
81
125
  </a>
82
- <button className={btnClass} onClick={copyToClipboard}>
126
+ <button type="button" className={btnClass} onClick={copyToClipboard}>
83
127
  {copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
84
128
  {copied ? 'Copied!' : 'Copy Link'}
85
129
  </button>