@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.
Files changed (44) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +313 -1
  3. package/dist/index.cjs +308 -79
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.d.cts +267 -16
  6. package/dist/index.d.ts +267 -16
  7. package/dist/index.js +300 -79
  8. package/dist/index.js.map +1 -1
  9. package/dist/nextjs.cjs +325 -31
  10. package/dist/nextjs.cjs.map +1 -1
  11. package/dist/nextjs.d.cts +179 -2
  12. package/dist/nextjs.d.ts +179 -2
  13. package/dist/nextjs.js +325 -31
  14. package/dist/nextjs.js.map +1 -1
  15. package/dist/server.cjs +660 -50
  16. package/dist/server.cjs.map +1 -1
  17. package/dist/server.d.cts +333 -12
  18. package/dist/server.d.ts +333 -12
  19. package/dist/server.js +645 -50
  20. package/dist/server.js.map +1 -1
  21. package/package.json +1 -1
  22. package/src/ArticleAnswer.tsx +35 -0
  23. package/src/ArticleSchemas.tsx +263 -23
  24. package/src/AuthorArticlesPage.tsx +38 -8
  25. package/src/__tests__/ArticleAnswer.test.tsx +25 -0
  26. package/src/__tests__/ArticleSchemas.test.tsx +516 -0
  27. package/src/__tests__/AuthorArticlesPage.test.tsx +76 -0
  28. package/src/__tests__/authorUtils.test.ts +50 -0
  29. package/src/__tests__/markdown.test.ts +77 -1
  30. package/src/__tests__/nextjs.test.ts +31 -15
  31. package/src/__tests__/seoUtils.test.ts +279 -0
  32. package/src/__tests__/server-articles.test.ts +434 -1
  33. package/src/__tests__/validateArticles.test.ts +167 -6
  34. package/src/articleTypes.ts +57 -0
  35. package/src/articlesConfig.ts +176 -1
  36. package/src/authorUtils.ts +19 -1
  37. package/src/errorReporting.ts +1 -0
  38. package/src/index.ts +17 -1
  39. package/src/markdown.ts +100 -1
  40. package/src/nextjs.ts +7 -4
  41. package/src/seoUtils.ts +247 -26
  42. package/src/server-articles.ts +385 -25
  43. package/src/server.ts +35 -4
  44. package/src/validateArticles.ts +157 -12
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fullstackdatasolutions/articles",
3
- "version": "1.2.3",
3
+ "version": "1.3.1",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "funding": {
@@ -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
+ }
@@ -1,6 +1,13 @@
1
- import type { ArticlesConfig } from './articlesConfig'
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 { getAuthorAvatar, getAuthorSameAs } from './authorUtils'
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
- '@type': 'Person',
26
- name: author.name,
27
- ...(author.url && { url: author.url }),
28
- ...(getAuthorAvatar(author, config) && { image: getAuthorAvatar(author, config) }),
29
- ...(getAuthorSameAs(author).length > 0 && { sameAs: getAuthorSameAs(author) }),
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
- export function ArticleSEO({
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 = true,
49
- authors,
161
+ showAuthor,
162
+ personSchemas,
50
163
  config,
51
- }: ArticleSEOProps) {
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.lastmod ? new Date(article.lastmod).toISOString() : publishedAt
54
- const personSchemas = getPersonSchemas(article, authors, config)
176
+ const modifiedAt = resolveModifiedAt(article, publishedAt, config)
177
+ const mainEntity = buildQuestionEntity(article, articleUrl, publishedAt)
55
178
 
56
- const structuredData = {
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
- ? { '@type': 'ImageObject', url: article.featuredImage }
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 && article.tags.length > 0 && { keywords: article.tags.join(', ') }),
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, getAuthorSameAs, getAuthorUrl } from './authorUtils'
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
- function getPersonSchema(author: AuthorProfile, config: ArticlesConfig, articleCount: number) {
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: author.url ?? getAuthorUrl(author),
100
+ url: authorUrl,
73
101
  ...(getAuthorAvatar(author, config) && { image: getAuthorAvatar(author, config) }),
74
102
  ...(getAuthorSameAs(author).length > 0 && { sameAs: getAuthorSameAs(author) }),
75
- knowsAbout: config.siteName,
76
- mainEntityOfPage:
77
- author.url ?? `${config.siteUrl.replace(/\/$/, '')}/articles/authors/${author.slug}`,
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(getPersonSchema(author, config, articleCount)),
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
+ })