@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.
- package/CHANGELOG.md +34 -0
- package/README.md +559 -11
- package/dist/index.cjs +984 -388
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +383 -21
- package/dist/index.d.ts +383 -21
- package/dist/index.js +968 -376
- package/dist/index.js.map +1 -1
- package/dist/nextjs.cjs +74 -6
- package/dist/nextjs.cjs.map +1 -1
- package/dist/nextjs.d.cts +149 -0
- package/dist/nextjs.d.ts +149 -0
- package/dist/nextjs.js +74 -6
- package/dist/nextjs.js.map +1 -1
- package/dist/server.cjs +665 -27
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +357 -3
- package/dist/server.d.ts +357 -3
- package/dist/server.js +643 -29
- package/dist/server.js.map +1 -1
- package/package.json +1 -1
- package/src/ArticleCard.tsx +42 -6
- package/src/ArticleContent.tsx +144 -5
- package/src/ArticleDetailHero.tsx +33 -1
- package/src/ArticleNavigation.tsx +32 -1
- package/src/ArticleSchemas.tsx +43 -39
- package/src/ArticleSocialShare.tsx +54 -10
- package/src/ArticlesPage.tsx +56 -5
- package/src/AuthorArticlesPage.tsx +308 -14
- package/src/AuthorCard.tsx +1 -1
- package/src/CategoryArticlesPage.tsx +34 -2
- package/src/LatestArticles.tsx +28 -1
- package/src/LatestArticlesSection.tsx +15 -1
- package/src/PaginationNav.tsx +78 -0
- package/src/RelatedArticlesSection.tsx +58 -0
- package/src/SeriesArticlesPage.tsx +66 -0
- package/src/__tests__/ArticleCard.test.tsx +63 -3
- package/src/__tests__/ArticleContent.test.tsx +143 -0
- package/src/__tests__/ArticleDetailHero.test.tsx +30 -0
- package/src/__tests__/ArticleNavigation.test.tsx +81 -3
- package/src/__tests__/ArticleSchemas.test.tsx +155 -81
- package/src/__tests__/ArticleSocialShare.test.tsx +54 -0
- package/src/__tests__/ArticlesPage.test.tsx +131 -0
- package/src/__tests__/AuthorArticlesPage.test.tsx +304 -3
- package/src/__tests__/CategoryArticlesPage.test.tsx +116 -1
- package/src/__tests__/LatestArticles.test.tsx +52 -0
- package/src/__tests__/LatestArticlesSection.test.tsx +28 -0
- package/src/__tests__/PaginationNav.test.tsx +73 -0
- package/src/__tests__/RelatedArticlesSection.test.tsx +132 -0
- package/src/__tests__/SeriesArticlesPage.test.tsx +121 -0
- package/src/__tests__/eventTracking.test.tsx +145 -0
- package/src/__tests__/events.test.ts +82 -0
- package/src/__tests__/markdown.test.ts +78 -1
- package/src/__tests__/pagination.test.ts +178 -0
- package/src/__tests__/seoUtils-authors.test.ts +37 -0
- package/src/__tests__/seoUtils.test.ts +246 -0
- package/src/__tests__/server-articles.test.ts +356 -1
- package/src/__tests__/validateArticles.test.ts +312 -0
- package/src/articleTypes.ts +109 -0
- package/src/articlesConfig.ts +45 -1
- package/src/eventTracking.tsx +97 -0
- package/src/events.ts +105 -0
- package/src/index.ts +26 -1
- package/src/markdown.ts +41 -0
- package/src/pagination.ts +93 -0
- package/src/seoUtils.ts +198 -11
- package/src/server-articles.ts +199 -6
- package/src/server.ts +46 -2
- package/src/validateArticles.ts +260 -0
package/package.json
CHANGED
package/src/ArticleCard.tsx
CHANGED
|
@@ -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
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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 && (
|
package/src/ArticleContent.tsx
CHANGED
|
@@ -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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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}
|
|
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
|
|
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({
|
|
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">
|
package/src/ArticleSchemas.tsx
CHANGED
|
@@ -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
|
-
|
|
7
|
-
article: Article
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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({
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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>
|