@fullstackdatasolutions/articles 1.2.3 → 1.3.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 +46 -0
- package/README.md +313 -1
- package/dist/index.cjs +308 -79
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +267 -16
- package/dist/index.d.ts +267 -16
- package/dist/index.js +300 -79
- package/dist/index.js.map +1 -1
- package/dist/nextjs.cjs +325 -31
- package/dist/nextjs.cjs.map +1 -1
- package/dist/nextjs.d.cts +179 -2
- package/dist/nextjs.d.ts +179 -2
- package/dist/nextjs.js +325 -31
- package/dist/nextjs.js.map +1 -1
- package/dist/server.cjs +660 -50
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +333 -12
- package/dist/server.d.ts +333 -12
- package/dist/server.js +645 -50
- package/dist/server.js.map +1 -1
- package/package.json +1 -1
- package/src/ArticleAnswer.tsx +35 -0
- package/src/ArticleSchemas.tsx +263 -23
- package/src/AuthorArticlesPage.tsx +38 -8
- package/src/__tests__/ArticleAnswer.test.tsx +25 -0
- package/src/__tests__/ArticleSchemas.test.tsx +516 -0
- package/src/__tests__/AuthorArticlesPage.test.tsx +76 -0
- package/src/__tests__/authorUtils.test.ts +50 -0
- package/src/__tests__/markdown.test.ts +77 -1
- package/src/__tests__/nextjs.test.ts +31 -15
- package/src/__tests__/seoUtils.test.ts +279 -0
- package/src/__tests__/server-articles.test.ts +434 -1
- package/src/__tests__/validateArticles.test.ts +167 -6
- package/src/articleTypes.ts +57 -0
- package/src/articlesConfig.ts +176 -1
- package/src/authorUtils.ts +19 -1
- package/src/errorReporting.ts +1 -0
- package/src/index.ts +17 -1
- package/src/markdown.ts +100 -1
- package/src/nextjs.ts +7 -4
- package/src/seoUtils.ts +247 -26
- package/src/server-articles.ts +385 -25
- package/src/server.ts +35 -4
- package/src/validateArticles.ts +157 -12
package/package.json
CHANGED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Article } from './articleTypes'
|
|
2
|
+
|
|
3
|
+
type ArticleAnswerProps = Readonly<{
|
|
4
|
+
article: Pick<Article, 'answer'>
|
|
5
|
+
/** Heading shown above the answer. Default: `'The short answer'`. */
|
|
6
|
+
label?: string
|
|
7
|
+
className?: string
|
|
8
|
+
}>
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Renders `article.answer` as a callout above the article body.
|
|
12
|
+
*
|
|
13
|
+
* The same text is emitted as the Article schema's `abstract` and placed at
|
|
14
|
+
* the top of the article's markdown twin, so the passage an answer engine is
|
|
15
|
+
* most likely to lift is also the one a reader sees first. Returns `null`
|
|
16
|
+
* when the article has no `answer`, so it is safe to render unconditionally.
|
|
17
|
+
*/
|
|
18
|
+
export function ArticleAnswer({
|
|
19
|
+
article,
|
|
20
|
+
label = 'The short answer',
|
|
21
|
+
className,
|
|
22
|
+
}: ArticleAnswerProps) {
|
|
23
|
+
if (!article.answer?.trim()) return null
|
|
24
|
+
return (
|
|
25
|
+
<aside
|
|
26
|
+
className={`mb-8 rounded-lg border-l-4 border-primary bg-muted/40 px-6 py-4 ${className ?? ''}`}
|
|
27
|
+
style={{ borderLeftWidth: '4px' }}
|
|
28
|
+
>
|
|
29
|
+
<p className="mb-2 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
|
30
|
+
{label}
|
|
31
|
+
</p>
|
|
32
|
+
<p className="text-base leading-relaxed">{article.answer}</p>
|
|
33
|
+
</aside>
|
|
34
|
+
)
|
|
35
|
+
}
|
package/src/ArticleSchemas.tsx
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
|
-
import
|
|
1
|
+
import {
|
|
2
|
+
getOrganizationId,
|
|
3
|
+
getPersonId,
|
|
4
|
+
getWebSiteId,
|
|
5
|
+
resolveEntityUrl,
|
|
6
|
+
type ArticlesConfig,
|
|
7
|
+
} from './articlesConfig'
|
|
2
8
|
import type { Article, AuthorProfile, FaqItem, HowToStep } from './articleTypes'
|
|
3
|
-
import {
|
|
9
|
+
import type { ArticleComment } from './commentTypes'
|
|
10
|
+
import { getAuthorAvatar, getAuthorIdentityUrl, getAuthorSameAs } from './authorUtils'
|
|
4
11
|
|
|
5
12
|
export { BreadcrumbSchema } from './Breadcrumb'
|
|
6
13
|
|
|
@@ -21,13 +28,17 @@ function getPersonSchemas(
|
|
|
21
28
|
// schema.org-appropriate facts, so none of them are added here. `url`/
|
|
22
29
|
// `image`/`sameAs` stay sourced exactly as before (author.url, resolved
|
|
23
30
|
// avatar, approved social links only).
|
|
24
|
-
return resolvedAuthors.map((author) =>
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
+
return resolvedAuthors.map((author) => {
|
|
32
|
+
const identityUrl = getAuthorIdentityUrl(author, config)
|
|
33
|
+
return {
|
|
34
|
+
'@type': 'Person',
|
|
35
|
+
...(identityUrl && { '@id': getPersonId(identityUrl) }),
|
|
36
|
+
name: author.name,
|
|
37
|
+
...(author.url && { url: author.url }),
|
|
38
|
+
...(getAuthorAvatar(author, config) && { image: getAuthorAvatar(author, config) }),
|
|
39
|
+
...(getAuthorSameAs(author).length > 0 && { sameAs: getAuthorSameAs(author) }),
|
|
40
|
+
}
|
|
41
|
+
})
|
|
31
42
|
}
|
|
32
43
|
|
|
33
44
|
type ArticleSEOProps = Readonly<{
|
|
@@ -38,43 +49,183 @@ type ArticleSEOProps = Readonly<{
|
|
|
38
49
|
showAuthor?: boolean
|
|
39
50
|
authors?: AuthorProfile[]
|
|
40
51
|
config?: ArticlesConfig
|
|
52
|
+
/**
|
|
53
|
+
* Top-level comments to emit as `commentCount`/`comment`. Real discussion
|
|
54
|
+
* is a quality signal that is otherwise invisible in structured data.
|
|
55
|
+
* Deleted comments are excluded, and only the author name, body, and
|
|
56
|
+
* timestamp are emitted - never the commenter's id.
|
|
57
|
+
*/
|
|
58
|
+
comments?: readonly ArticleComment[]
|
|
41
59
|
}>
|
|
42
60
|
|
|
43
|
-
|
|
61
|
+
// `lastmodFallback: 'none'` opts out of reusing the publish date - an article
|
|
62
|
+
// edited three times with no `lastmod` otherwise reports its original date as
|
|
63
|
+
// `dateModified` forever.
|
|
64
|
+
function resolveModifiedAt(
|
|
65
|
+
article: Article,
|
|
66
|
+
publishedAt: string | undefined,
|
|
67
|
+
config?: ArticlesConfig
|
|
68
|
+
): string | undefined {
|
|
69
|
+
if (article.lastmod) return new Date(article.lastmod).toISOString()
|
|
70
|
+
return config?.lastmodFallback === 'none' ? undefined : publishedAt
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Absolutizes an article image for structured data.
|
|
75
|
+
*
|
|
76
|
+
* `featuredImage` is stored site-relative (`/articles/<slug>/hero.jpg`), and
|
|
77
|
+
* Open Graph already absolutizes it via `resolveImageUrl` - JSON-LD did not,
|
|
78
|
+
* so every article was publishing a relative `ImageObject.url`, which
|
|
79
|
+
* consumers of structured data reject. Prefers `config.siteUrl`, falling back
|
|
80
|
+
* to the origin of `articleUrl` for callers that pass no config.
|
|
81
|
+
*
|
|
82
|
+
* No `width`/`height`: the package never measures the file, and asserting
|
|
83
|
+
* dimensions it has not read would be a guess in structured data.
|
|
84
|
+
*/
|
|
85
|
+
function resolveSchemaImageUrl(image: string, articleUrl: string, config?: ArticlesConfig): string {
|
|
86
|
+
if (/^https?:\/\//.test(image)) return image
|
|
87
|
+
if (config) return resolveEntityUrl(image, config)
|
|
88
|
+
try {
|
|
89
|
+
return `${new URL(articleUrl).origin}/${image.replace(/^\/+/, '')}`
|
|
90
|
+
} catch {
|
|
91
|
+
return image
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function buildPublisher(siteName: string, siteLogo?: string, config?: ArticlesConfig) {
|
|
96
|
+
if (config?.organization) return { '@id': getOrganizationId(config) }
|
|
97
|
+
return {
|
|
98
|
+
'@type': 'Organization',
|
|
99
|
+
name: siteName,
|
|
100
|
+
...(siteLogo && { logo: { '@type': 'ImageObject', url: siteLogo } }),
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// `articleType: 'QAPage'` already retypes the Article node, and a QAPage's
|
|
105
|
+
// required shape is a `mainEntity` Question - so the Q&A belongs on that node
|
|
106
|
+
// rather than in a second script that would duplicate one page as two
|
|
107
|
+
// competing entities.
|
|
108
|
+
function buildQuestionEntity(article: Article, articleUrl: string, publishedAt?: string) {
|
|
109
|
+
if (article.articleType !== 'QAPage' || !article.answer) return undefined
|
|
110
|
+
return {
|
|
111
|
+
'@type': 'Question',
|
|
112
|
+
name: article.title,
|
|
113
|
+
text: article.excerpt || article.title,
|
|
114
|
+
...(publishedAt && { dateCreated: publishedAt }),
|
|
115
|
+
answerCount: 1,
|
|
116
|
+
acceptedAnswer: { '@type': 'Answer', text: article.answer, url: articleUrl },
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function buildCommentFields(comments?: readonly ArticleComment[]) {
|
|
121
|
+
const visible = comments?.filter((comment) => !comment.isDeleted && !comment.parentId) ?? []
|
|
122
|
+
if (visible.length === 0) return {}
|
|
123
|
+
return {
|
|
124
|
+
commentCount: visible.length,
|
|
125
|
+
comment: visible.map((comment) => ({
|
|
126
|
+
'@type': 'Comment',
|
|
127
|
+
text: comment.body,
|
|
128
|
+
dateCreated: comment.createdAt,
|
|
129
|
+
author: { '@type': 'Person', name: comment.authorName },
|
|
130
|
+
})),
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function buildTopicFields(article: Article, config?: ArticlesConfig) {
|
|
135
|
+
const about = article.about?.map((entity) => ({
|
|
136
|
+
'@type': 'Thing',
|
|
137
|
+
name: entity.name,
|
|
138
|
+
...(entity.sameAs && { sameAs: entity.sameAs }),
|
|
139
|
+
}))
|
|
140
|
+
const citation = article.citation?.map((source) => ({
|
|
141
|
+
'@type': 'CreativeWork',
|
|
142
|
+
name: source.name,
|
|
143
|
+
...(source.url && { url: source.url }),
|
|
144
|
+
}))
|
|
145
|
+
const selectors = config?.speakableSelectors
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
...(about?.length && { about }),
|
|
149
|
+
...(citation?.length && { citation }),
|
|
150
|
+
...(selectors?.length && {
|
|
151
|
+
speakable: { '@type': 'SpeakableSpecification', cssSelector: selectors },
|
|
152
|
+
}),
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function buildArticleSchema({
|
|
44
157
|
article,
|
|
45
158
|
articleUrl,
|
|
46
159
|
siteName,
|
|
47
160
|
siteLogo,
|
|
48
|
-
showAuthor
|
|
49
|
-
|
|
161
|
+
showAuthor,
|
|
162
|
+
personSchemas,
|
|
50
163
|
config,
|
|
51
|
-
|
|
164
|
+
comments,
|
|
165
|
+
}: Readonly<{
|
|
166
|
+
article: Article
|
|
167
|
+
articleUrl: string
|
|
168
|
+
siteName: string
|
|
169
|
+
siteLogo?: string
|
|
170
|
+
showAuthor: boolean
|
|
171
|
+
personSchemas: ReturnType<typeof getPersonSchemas>
|
|
172
|
+
config?: ArticlesConfig
|
|
173
|
+
comments?: readonly ArticleComment[]
|
|
174
|
+
}>) {
|
|
52
175
|
const publishedAt = article.date ? new Date(article.date).toISOString() : undefined
|
|
53
|
-
const modifiedAt = article
|
|
54
|
-
const
|
|
176
|
+
const modifiedAt = resolveModifiedAt(article, publishedAt, config)
|
|
177
|
+
const mainEntity = buildQuestionEntity(article, articleUrl, publishedAt)
|
|
55
178
|
|
|
56
|
-
|
|
179
|
+
return {
|
|
57
180
|
'@context': 'https://schema.org',
|
|
58
181
|
'@type': article.articleType || 'Article',
|
|
59
182
|
mainEntityOfPage: { '@type': 'WebPage', '@id': articleUrl },
|
|
60
183
|
headline: article.title,
|
|
61
184
|
image: article.featuredImage
|
|
62
|
-
? {
|
|
185
|
+
? {
|
|
186
|
+
'@type': 'ImageObject',
|
|
187
|
+
url: resolveSchemaImageUrl(article.featuredImage, articleUrl, config),
|
|
188
|
+
}
|
|
63
189
|
: undefined,
|
|
64
190
|
...(publishedAt && { datePublished: publishedAt }),
|
|
65
191
|
...(modifiedAt && { dateModified: modifiedAt }),
|
|
66
192
|
...(showAuthor && personSchemas.length > 0 && { author: personSchemas }),
|
|
67
|
-
publisher:
|
|
68
|
-
'@type': 'Organization',
|
|
69
|
-
name: siteName,
|
|
70
|
-
...(siteLogo && { logo: { '@type': 'ImageObject', url: siteLogo } }),
|
|
71
|
-
},
|
|
193
|
+
publisher: buildPublisher(siteName, siteLogo, config),
|
|
72
194
|
description: article.excerpt,
|
|
195
|
+
...(article.answer && { abstract: article.answer }),
|
|
73
196
|
articleSection: article.category,
|
|
74
|
-
...(article.tags
|
|
197
|
+
...(article.tags?.length && { keywords: article.tags.join(', ') }),
|
|
75
198
|
...(article.wordCount !== undefined && { wordCount: article.wordCount }),
|
|
76
199
|
...(article.series && { isPartOf: { '@type': 'Blog', name: article.series } }),
|
|
200
|
+
inLanguage: config?.language ?? 'en',
|
|
201
|
+
isAccessibleForFree: config?.isAccessibleForFree !== false,
|
|
202
|
+
...buildTopicFields(article, config),
|
|
203
|
+
...buildCommentFields(comments),
|
|
204
|
+
...(mainEntity && { mainEntity }),
|
|
77
205
|
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function ArticleSEO({
|
|
209
|
+
article,
|
|
210
|
+
articleUrl,
|
|
211
|
+
siteName,
|
|
212
|
+
siteLogo,
|
|
213
|
+
showAuthor = true,
|
|
214
|
+
authors,
|
|
215
|
+
config,
|
|
216
|
+
comments,
|
|
217
|
+
}: ArticleSEOProps) {
|
|
218
|
+
const personSchemas = getPersonSchemas(article, authors, config)
|
|
219
|
+
const structuredData = buildArticleSchema({
|
|
220
|
+
article,
|
|
221
|
+
articleUrl,
|
|
222
|
+
siteName,
|
|
223
|
+
siteLogo,
|
|
224
|
+
showAuthor,
|
|
225
|
+
personSchemas,
|
|
226
|
+
config,
|
|
227
|
+
comments,
|
|
228
|
+
})
|
|
78
229
|
|
|
79
230
|
return (
|
|
80
231
|
<>
|
|
@@ -193,3 +344,92 @@ export function CollectionPageSchema({
|
|
|
193
344
|
/>
|
|
194
345
|
)
|
|
195
346
|
}
|
|
347
|
+
|
|
348
|
+
type OrganizationSchemaProps = Readonly<{ config: ArticlesConfig }>
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Site-level publisher entity, rendered once in the root layout.
|
|
352
|
+
*
|
|
353
|
+
* Articles reference this by `@id` rather than repeating an inline
|
|
354
|
+
* `Organization` stub, so every page on the site resolves to one entity - the
|
|
355
|
+
* thing that lets a search or answer engine attribute a whole corpus to a
|
|
356
|
+
* single publisher instead of treating each page as an orphan.
|
|
357
|
+
* Returns `null` when `config.organization` is unset.
|
|
358
|
+
*/
|
|
359
|
+
export function OrganizationSchema({ config }: OrganizationSchemaProps) {
|
|
360
|
+
const org = config.organization
|
|
361
|
+
if (!org) return null
|
|
362
|
+
|
|
363
|
+
const schema = {
|
|
364
|
+
'@context': 'https://schema.org',
|
|
365
|
+
'@type': org.type ?? 'Organization',
|
|
366
|
+
'@id': getOrganizationId(config),
|
|
367
|
+
name: org.name ?? config.siteName,
|
|
368
|
+
url: org.url ?? config.siteUrl.replace(/\/$/, ''),
|
|
369
|
+
...(org.logo && {
|
|
370
|
+
logo: { '@type': 'ImageObject', url: resolveEntityUrl(org.logo, config) },
|
|
371
|
+
}),
|
|
372
|
+
...((org.description ?? config.description) && {
|
|
373
|
+
description: org.description ?? config.description,
|
|
374
|
+
}),
|
|
375
|
+
...(org.sameAs && org.sameAs.length > 0 && { sameAs: org.sameAs }),
|
|
376
|
+
...(org.parentOrganization && {
|
|
377
|
+
parentOrganization: {
|
|
378
|
+
'@type': 'Organization',
|
|
379
|
+
name: org.parentOrganization.name,
|
|
380
|
+
url: org.parentOrganization.url,
|
|
381
|
+
},
|
|
382
|
+
}),
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return (
|
|
386
|
+
<script
|
|
387
|
+
type="application/ld+json"
|
|
388
|
+
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
|
|
389
|
+
/>
|
|
390
|
+
)
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
type WebSiteSchemaProps = Readonly<{ config: ArticlesConfig }>
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Site entity, rendered once in the root layout alongside `OrganizationSchema`.
|
|
397
|
+
* Emits a `SearchAction` only when `organization.searchUrlTemplate` is set -
|
|
398
|
+
* the library's own search is client-side with no crawlable results URL, so
|
|
399
|
+
* declaring one unconditionally would advertise an endpoint that does not exist.
|
|
400
|
+
* Returns `null` when `config.organization` is unset.
|
|
401
|
+
*/
|
|
402
|
+
export function WebSiteSchema({ config }: WebSiteSchemaProps) {
|
|
403
|
+
const org = config.organization
|
|
404
|
+
if (!org) return null
|
|
405
|
+
|
|
406
|
+
const siteUrl = config.siteUrl.replace(/\/$/, '')
|
|
407
|
+
const template = org.searchUrlTemplate
|
|
408
|
+
|
|
409
|
+
const schema = {
|
|
410
|
+
'@context': 'https://schema.org',
|
|
411
|
+
'@type': 'WebSite',
|
|
412
|
+
'@id': getWebSiteId(config),
|
|
413
|
+
name: config.siteName,
|
|
414
|
+
url: siteUrl,
|
|
415
|
+
publisher: { '@id': getOrganizationId(config) },
|
|
416
|
+
...(config.description && { description: config.description }),
|
|
417
|
+
...(template && {
|
|
418
|
+
potentialAction: {
|
|
419
|
+
'@type': 'SearchAction',
|
|
420
|
+
target: {
|
|
421
|
+
'@type': 'EntryPoint',
|
|
422
|
+
urlTemplate: resolveEntityUrl(template, config),
|
|
423
|
+
},
|
|
424
|
+
'query-input': 'required name=search_term_string',
|
|
425
|
+
},
|
|
426
|
+
}),
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
return (
|
|
430
|
+
<script
|
|
431
|
+
type="application/ld+json"
|
|
432
|
+
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
|
|
433
|
+
/>
|
|
434
|
+
)
|
|
435
|
+
}
|
|
@@ -5,10 +5,10 @@ import Link from 'next/link'
|
|
|
5
5
|
import { CollectionPageSchema } from './ArticleSchemas'
|
|
6
6
|
import { AuthorDetailHero } from './AuthorDetailHero'
|
|
7
7
|
import { LatestArticles } from './LatestArticles'
|
|
8
|
-
import { getAuthorAvatar,
|
|
8
|
+
import { getAuthorAvatar, getAuthorIdentityUrl, getAuthorSameAs } from './authorUtils'
|
|
9
9
|
import { emitArticleEvent } from './events'
|
|
10
10
|
import { CtaViewTracker } from './eventTracking'
|
|
11
|
-
import { DEFAULT_PAGE_SIZE } from './articlesConfig'
|
|
11
|
+
import { DEFAULT_PAGE_SIZE, getOrganizationId, getPersonId } from './articlesConfig'
|
|
12
12
|
import type { ArticlesConfig } from './articlesConfig'
|
|
13
13
|
import type { ListingPaginationContext } from './pagination'
|
|
14
14
|
import type { Article, AuthorProfile } from './articleTypes'
|
|
@@ -55,7 +55,24 @@ type AuthorArticlesPageProps = Readonly<{
|
|
|
55
55
|
customSection?: ReactNode
|
|
56
56
|
}>
|
|
57
57
|
|
|
58
|
-
|
|
58
|
+
/**
|
|
59
|
+
* Topics the author demonstrably writes about, taken from the categories of
|
|
60
|
+
* their own published articles - `knowsAbout` takes subject matter, and
|
|
61
|
+
* before 1.3.0 this field was filled with `config.siteName`, which is a
|
|
62
|
+
* publisher name rather than a topic and told a consumer nothing.
|
|
63
|
+
* `AuthorProfile.knowsAbout`, when set, replaces the derived list.
|
|
64
|
+
*/
|
|
65
|
+
function getKnowsAbout(author: AuthorProfile, articles: readonly Article[]): string[] {
|
|
66
|
+
if (author.knowsAbout?.length) return [...author.knowsAbout]
|
|
67
|
+
return [...new Set(articles.flatMap((article) => article.categories ?? []))].filter(Boolean)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function getPersonSchema(
|
|
71
|
+
author: AuthorProfile,
|
|
72
|
+
config: ArticlesConfig,
|
|
73
|
+
articleCount: number,
|
|
74
|
+
knowsAbout: readonly string[]
|
|
75
|
+
) {
|
|
59
76
|
// Phase 27E audit: `promise`/`servesWho`/`principles`/`credentials`/`proof`
|
|
60
77
|
// are intentionally NOT added here. `promise` and `principles` are
|
|
61
78
|
// audience-facing marketing copy, not encyclopedic facts; `servesWho` is an
|
|
@@ -64,17 +81,28 @@ function getPersonSchema(author: AuthorProfile, config: ArticlesConfig, articleC
|
|
|
64
81
|
// structured data, so `description`/`knowsAbout`/`sameAs`/`image` stay
|
|
65
82
|
// sourced exactly as they were before this phase (bio, siteName, approved
|
|
66
83
|
// social links, resolved avatar).
|
|
84
|
+
// Three distinct URLs, deliberately not collapsed into one:
|
|
85
|
+
// - `@id` is the cross-site identity (see `getAuthorIdentityUrl`); it must
|
|
86
|
+
// match what every article's `author` reference emits, and be absolute so
|
|
87
|
+
// it can resolve to the same node from any page.
|
|
88
|
+
// - `url` is the author's own canonical profile.
|
|
89
|
+
// - `mainEntityOfPage` is *this* page, always - it says where the entity is
|
|
90
|
+
// described, so it can never point at another site.
|
|
91
|
+
const pageUrl = `${config.siteUrl.replace(/\/$/, '')}/articles/authors/${author.slug}`
|
|
92
|
+
const identityUrl = getAuthorIdentityUrl(author, config) ?? pageUrl
|
|
93
|
+
const authorUrl = author.url ?? pageUrl
|
|
67
94
|
return {
|
|
68
95
|
'@context': 'https://schema.org',
|
|
69
96
|
'@type': 'Person',
|
|
97
|
+
'@id': getPersonId(identityUrl),
|
|
70
98
|
name: author.name,
|
|
71
99
|
description: author.bio,
|
|
72
|
-
url:
|
|
100
|
+
url: authorUrl,
|
|
73
101
|
...(getAuthorAvatar(author, config) && { image: getAuthorAvatar(author, config) }),
|
|
74
102
|
...(getAuthorSameAs(author).length > 0 && { sameAs: getAuthorSameAs(author) }),
|
|
75
|
-
knowsAbout:
|
|
76
|
-
|
|
77
|
-
|
|
103
|
+
...(knowsAbout.length > 0 && { knowsAbout: [...knowsAbout] }),
|
|
104
|
+
...(config.organization && { worksFor: { '@id': getOrganizationId(config) } }),
|
|
105
|
+
mainEntityOfPage: pageUrl,
|
|
78
106
|
interactionStatistic: {
|
|
79
107
|
'@type': 'InteractionCounter',
|
|
80
108
|
interactionType: 'https://schema.org/WriteAction',
|
|
@@ -299,7 +327,9 @@ export function AuthorArticlesPage({
|
|
|
299
327
|
<script
|
|
300
328
|
type="application/ld+json"
|
|
301
329
|
dangerouslySetInnerHTML={{
|
|
302
|
-
__html: JSON.stringify(
|
|
330
|
+
__html: JSON.stringify(
|
|
331
|
+
getPersonSchema(author, config, articleCount, getKnowsAbout(author, articles))
|
|
332
|
+
),
|
|
303
333
|
}}
|
|
304
334
|
/>
|
|
305
335
|
<CollectionPageSchema
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react'
|
|
2
|
+
import { ArticleAnswer } from '../ArticleAnswer'
|
|
3
|
+
|
|
4
|
+
describe('ArticleAnswer', () => {
|
|
5
|
+
it('renders the answer with a default label', () => {
|
|
6
|
+
render(<ArticleAnswer article={{ answer: 'Roll initiative, then act in order.' }} />)
|
|
7
|
+
expect(screen.getByText('The short answer')).toBeInTheDocument()
|
|
8
|
+
expect(screen.getByText('Roll initiative, then act in order.')).toBeInTheDocument()
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('accepts a custom label and className', () => {
|
|
12
|
+
const { container } = render(
|
|
13
|
+
<ArticleAnswer article={{ answer: 'Yes.' }} label="TL;DR" className="custom" />
|
|
14
|
+
)
|
|
15
|
+
expect(screen.getByText('TL;DR')).toBeInTheDocument()
|
|
16
|
+
expect(container.querySelector('aside')).toHaveClass('custom')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('renders nothing when the answer is missing or blank', () => {
|
|
20
|
+
const { container: empty } = render(<ArticleAnswer article={{}} />)
|
|
21
|
+
expect(empty.firstChild).toBeNull()
|
|
22
|
+
const { container: blank } = render(<ArticleAnswer article={{ answer: ' ' }} />)
|
|
23
|
+
expect(blank.firstChild).toBeNull()
|
|
24
|
+
})
|
|
25
|
+
})
|