@fullstackdatasolutions/articles 1.2.2 → 1.3.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 +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 +324 -13
- 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 +324 -13
- package/dist/nextjs.js.map +1 -1
- package/dist/server.cjs +677 -51
- 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 +662 -51
- 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__/linkClassification.test.ts +55 -0
- package/src/__tests__/markdown.test.ts +77 -1
- package/src/__tests__/nextjs.test.ts +31 -15
- package/src/__tests__/renderMdx.test.tsx +162 -3
- package/src/__tests__/seoUtils.test.ts +279 -0
- package/src/__tests__/server-articles.test.ts +413 -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/linkClassification.ts +30 -0
- package/src/markdown.ts +103 -25
- package/src/nextjs.ts +7 -4
- package/src/renderMdx.tsx +43 -6
- package/src/seoUtils.ts +247 -26
- package/src/server-articles.ts +375 -24
- package/src/server.ts +35 -4
- package/src/validateArticles.ts +157 -12
|
@@ -5,7 +5,10 @@ import {
|
|
|
5
5
|
BreadcrumbSchema,
|
|
6
6
|
CollectionPageSchema,
|
|
7
7
|
FAQPageSchema,
|
|
8
|
+
OrganizationSchema,
|
|
9
|
+
WebSiteSchema,
|
|
8
10
|
} from '../ArticleSchemas'
|
|
11
|
+
import type { ArticlesConfig } from '../articlesConfig'
|
|
9
12
|
import { Breadcrumb } from '../Breadcrumb'
|
|
10
13
|
|
|
11
14
|
function parseSchemaScript(container: HTMLElement) {
|
|
@@ -230,6 +233,7 @@ describe('ArticleSEO', () => {
|
|
|
230
233
|
const article = schemas.find((s) => s['@type'] === 'Article')
|
|
231
234
|
expect(article.author[0]).toEqual({
|
|
232
235
|
'@type': 'Person',
|
|
236
|
+
'@id': 'https://example.com/articles/authors/andrew-blase#person',
|
|
233
237
|
name: 'Andrew Blase',
|
|
234
238
|
url: 'https://example.com/articles/authors/andrew-blase',
|
|
235
239
|
sameAs: ['https://github.com/blazestudios23'],
|
|
@@ -262,6 +266,7 @@ describe('ArticleSEO', () => {
|
|
|
262
266
|
const article = schemas.find((s) => s['@type'] === 'Article')
|
|
263
267
|
expect(article.author[0]).toEqual({
|
|
264
268
|
'@type': 'Person',
|
|
269
|
+
'@id': 'https://example.com/articles/authors/andrew-blase#person',
|
|
265
270
|
name: 'Andrew Blase',
|
|
266
271
|
url: 'https://example.com/articles/authors/andrew-blase',
|
|
267
272
|
})
|
|
@@ -486,3 +491,514 @@ describe('ArticleSEO', () => {
|
|
|
486
491
|
)
|
|
487
492
|
})
|
|
488
493
|
})
|
|
494
|
+
|
|
495
|
+
describe('OrganizationSchema', () => {
|
|
496
|
+
const config: ArticlesConfig = {
|
|
497
|
+
siteUrl: 'https://example.com/',
|
|
498
|
+
siteName: 'Example Site',
|
|
499
|
+
description: 'Site description.',
|
|
500
|
+
organization: {
|
|
501
|
+
logo: '/logo.png',
|
|
502
|
+
sameAs: ['https://linkedin.com/company/example'],
|
|
503
|
+
},
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
it('renders nothing when organization is unset', () => {
|
|
507
|
+
const { container } = render(
|
|
508
|
+
<OrganizationSchema config={{ siteUrl: 'https://example.com', siteName: 'Example Site' }} />
|
|
509
|
+
)
|
|
510
|
+
expect(container.querySelector('script')).toBeNull()
|
|
511
|
+
})
|
|
512
|
+
|
|
513
|
+
it('emits a stable @id, resolved logo, and falls back to siteName/siteUrl/description', () => {
|
|
514
|
+
const { container } = render(<OrganizationSchema config={config} />)
|
|
515
|
+
const schema = parseSchemaScript(container)
|
|
516
|
+
expect(schema).toEqual({
|
|
517
|
+
'@context': 'https://schema.org',
|
|
518
|
+
'@type': 'Organization',
|
|
519
|
+
'@id': 'https://example.com/#organization',
|
|
520
|
+
name: 'Example Site',
|
|
521
|
+
url: 'https://example.com',
|
|
522
|
+
logo: { '@type': 'ImageObject', url: 'https://example.com/logo.png' },
|
|
523
|
+
description: 'Site description.',
|
|
524
|
+
sameAs: ['https://linkedin.com/company/example'],
|
|
525
|
+
})
|
|
526
|
+
})
|
|
527
|
+
|
|
528
|
+
it('honors explicit type, name, url, description, and an absolute logo', () => {
|
|
529
|
+
const { container } = render(
|
|
530
|
+
<OrganizationSchema
|
|
531
|
+
config={{
|
|
532
|
+
siteUrl: 'https://example.com',
|
|
533
|
+
siteName: 'Example Site',
|
|
534
|
+
organization: {
|
|
535
|
+
type: 'Person',
|
|
536
|
+
name: 'Andrew Blase',
|
|
537
|
+
url: 'https://andrew.example',
|
|
538
|
+
description: 'Personal brand.',
|
|
539
|
+
logo: 'https://cdn.example.com/logo.png',
|
|
540
|
+
},
|
|
541
|
+
}}
|
|
542
|
+
/>
|
|
543
|
+
)
|
|
544
|
+
const schema = parseSchemaScript(container)
|
|
545
|
+
expect(schema['@type']).toBe('Person')
|
|
546
|
+
expect(schema.name).toBe('Andrew Blase')
|
|
547
|
+
expect(schema.url).toBe('https://andrew.example')
|
|
548
|
+
expect(schema.description).toBe('Personal brand.')
|
|
549
|
+
expect(schema.logo.url).toBe('https://cdn.example.com/logo.png')
|
|
550
|
+
expect(schema.sameAs).toBeUndefined()
|
|
551
|
+
})
|
|
552
|
+
})
|
|
553
|
+
|
|
554
|
+
describe('WebSiteSchema', () => {
|
|
555
|
+
const baseConfig: ArticlesConfig = {
|
|
556
|
+
siteUrl: 'https://example.com/',
|
|
557
|
+
siteName: 'Example Site',
|
|
558
|
+
description: 'Site description.',
|
|
559
|
+
organization: {},
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
it('renders nothing when organization is unset', () => {
|
|
563
|
+
const { container } = render(
|
|
564
|
+
<WebSiteSchema config={{ siteUrl: 'https://example.com', siteName: 'Example Site' }} />
|
|
565
|
+
)
|
|
566
|
+
expect(container.querySelector('script')).toBeNull()
|
|
567
|
+
})
|
|
568
|
+
|
|
569
|
+
it('references the organization @id and omits SearchAction by default', () => {
|
|
570
|
+
const { container } = render(<WebSiteSchema config={baseConfig} />)
|
|
571
|
+
const schema = parseSchemaScript(container)
|
|
572
|
+
expect(schema).toEqual({
|
|
573
|
+
'@context': 'https://schema.org',
|
|
574
|
+
'@type': 'WebSite',
|
|
575
|
+
'@id': 'https://example.com/#website',
|
|
576
|
+
name: 'Example Site',
|
|
577
|
+
url: 'https://example.com',
|
|
578
|
+
publisher: { '@id': 'https://example.com/#organization' },
|
|
579
|
+
description: 'Site description.',
|
|
580
|
+
})
|
|
581
|
+
})
|
|
582
|
+
|
|
583
|
+
it('emits SearchAction only when searchUrlTemplate is configured', () => {
|
|
584
|
+
const { container } = render(
|
|
585
|
+
<WebSiteSchema
|
|
586
|
+
config={{
|
|
587
|
+
...baseConfig,
|
|
588
|
+
organization: { searchUrlTemplate: '/search?q={search_term_string}' },
|
|
589
|
+
}}
|
|
590
|
+
/>
|
|
591
|
+
)
|
|
592
|
+
const schema = parseSchemaScript(container)
|
|
593
|
+
expect(schema.potentialAction).toEqual({
|
|
594
|
+
'@type': 'SearchAction',
|
|
595
|
+
target: {
|
|
596
|
+
'@type': 'EntryPoint',
|
|
597
|
+
urlTemplate: 'https://example.com/search?q={search_term_string}',
|
|
598
|
+
},
|
|
599
|
+
'query-input': 'required name=search_term_string',
|
|
600
|
+
})
|
|
601
|
+
})
|
|
602
|
+
})
|
|
603
|
+
|
|
604
|
+
describe('ArticleSEO publisher reference', () => {
|
|
605
|
+
const article = {
|
|
606
|
+
slug: 'test-article',
|
|
607
|
+
title: 'Test Article',
|
|
608
|
+
excerpt: 'Excerpt.',
|
|
609
|
+
date: '2025-01-01',
|
|
610
|
+
author: 'Jane Doe',
|
|
611
|
+
category: 'Campaigns',
|
|
612
|
+
categories: ['Campaigns'],
|
|
613
|
+
readTime: '3 min read',
|
|
614
|
+
featuredImage: '/image.jpg',
|
|
615
|
+
} as Article
|
|
616
|
+
|
|
617
|
+
it('references the organization @id when organization is configured', () => {
|
|
618
|
+
const { container } = render(
|
|
619
|
+
<ArticleSEO
|
|
620
|
+
article={article}
|
|
621
|
+
articleUrl="https://example.com/articles/test-article"
|
|
622
|
+
siteName="Example Site"
|
|
623
|
+
config={{
|
|
624
|
+
siteUrl: 'https://example.com',
|
|
625
|
+
siteName: 'Example Site',
|
|
626
|
+
organization: {},
|
|
627
|
+
}}
|
|
628
|
+
/>
|
|
629
|
+
)
|
|
630
|
+
const schema = parseAllSchemaScripts(container).find((s) => s['@type'] === 'Article')
|
|
631
|
+
expect(schema.publisher).toEqual({ '@id': 'https://example.com/#organization' })
|
|
632
|
+
})
|
|
633
|
+
|
|
634
|
+
it('keeps the inline Organization stub when organization is unset', () => {
|
|
635
|
+
const { container } = render(
|
|
636
|
+
<ArticleSEO
|
|
637
|
+
article={article}
|
|
638
|
+
articleUrl="https://example.com/articles/test-article"
|
|
639
|
+
siteName="Example Site"
|
|
640
|
+
siteLogo="https://example.com/logo.png"
|
|
641
|
+
/>
|
|
642
|
+
)
|
|
643
|
+
const schema = parseAllSchemaScripts(container).find((s) => s['@type'] === 'Article')
|
|
644
|
+
expect(schema.publisher).toEqual({
|
|
645
|
+
'@type': 'Organization',
|
|
646
|
+
name: 'Example Site',
|
|
647
|
+
logo: { '@type': 'ImageObject', url: 'https://example.com/logo.png' },
|
|
648
|
+
})
|
|
649
|
+
})
|
|
650
|
+
})
|
|
651
|
+
|
|
652
|
+
describe('ArticleSEO entity and access fields', () => {
|
|
653
|
+
const article = {
|
|
654
|
+
slug: 'test-article',
|
|
655
|
+
title: 'Test Article',
|
|
656
|
+
excerpt: 'Excerpt.',
|
|
657
|
+
date: '2025-01-01',
|
|
658
|
+
author: 'Jane Doe',
|
|
659
|
+
category: 'Campaigns',
|
|
660
|
+
categories: ['Campaigns'],
|
|
661
|
+
readTime: '3 min read',
|
|
662
|
+
featuredImage: '/image.jpg',
|
|
663
|
+
} as Article
|
|
664
|
+
|
|
665
|
+
function renderArticle(overrides: Partial<Article>, config?: ArticlesConfig) {
|
|
666
|
+
const { container } = render(
|
|
667
|
+
<ArticleSEO
|
|
668
|
+
article={{ ...article, ...overrides }}
|
|
669
|
+
articleUrl="https://example.com/articles/test-article"
|
|
670
|
+
siteName="Example Site"
|
|
671
|
+
config={config}
|
|
672
|
+
/>
|
|
673
|
+
)
|
|
674
|
+
return parseAllSchemaScripts(container)
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
it('defaults inLanguage to en and isAccessibleForFree to true', () => {
|
|
678
|
+
const schema = renderArticle({}).find((s) => s['@type'] === 'Article')
|
|
679
|
+
expect(schema.inLanguage).toBe('en')
|
|
680
|
+
expect(schema.isAccessibleForFree).toBe(true)
|
|
681
|
+
expect(schema.about).toBeUndefined()
|
|
682
|
+
expect(schema.citation).toBeUndefined()
|
|
683
|
+
expect(schema.speakable).toBeUndefined()
|
|
684
|
+
expect(schema.abstract).toBeUndefined()
|
|
685
|
+
})
|
|
686
|
+
|
|
687
|
+
it('honors configured language and paywalled content', () => {
|
|
688
|
+
const schema = renderArticle(
|
|
689
|
+
{},
|
|
690
|
+
{
|
|
691
|
+
siteUrl: 'https://example.com',
|
|
692
|
+
siteName: 'Example Site',
|
|
693
|
+
language: 'es',
|
|
694
|
+
isAccessibleForFree: false,
|
|
695
|
+
}
|
|
696
|
+
).find((s) => s['@type'] === 'Article')
|
|
697
|
+
expect(schema.inLanguage).toBe('es')
|
|
698
|
+
expect(schema.isAccessibleForFree).toBe(false)
|
|
699
|
+
})
|
|
700
|
+
|
|
701
|
+
it('emits abstract, about, and citation from the article', () => {
|
|
702
|
+
const schema = renderArticle({
|
|
703
|
+
answer: 'Roll a d20 and add your modifier.',
|
|
704
|
+
about: [
|
|
705
|
+
{ name: 'Pathfinder', sameAs: 'https://www.wikidata.org/wiki/Q1194077' },
|
|
706
|
+
{ name: 'Combat' },
|
|
707
|
+
],
|
|
708
|
+
citation: [{ name: 'Core Rulebook', url: 'https://example.com/crb' }, { name: 'Errata' }],
|
|
709
|
+
}).find((s) => s['@type'] === 'Article')
|
|
710
|
+
|
|
711
|
+
expect(schema.abstract).toBe('Roll a d20 and add your modifier.')
|
|
712
|
+
expect(schema.about).toEqual([
|
|
713
|
+
{ '@type': 'Thing', name: 'Pathfinder', sameAs: 'https://www.wikidata.org/wiki/Q1194077' },
|
|
714
|
+
{ '@type': 'Thing', name: 'Combat' },
|
|
715
|
+
])
|
|
716
|
+
expect(schema.citation).toEqual([
|
|
717
|
+
{ '@type': 'CreativeWork', name: 'Core Rulebook', url: 'https://example.com/crb' },
|
|
718
|
+
{ '@type': 'CreativeWork', name: 'Errata' },
|
|
719
|
+
])
|
|
720
|
+
})
|
|
721
|
+
|
|
722
|
+
it('emits speakable only when selectors are configured', () => {
|
|
723
|
+
const schema = renderArticle(
|
|
724
|
+
{},
|
|
725
|
+
{
|
|
726
|
+
siteUrl: 'https://example.com',
|
|
727
|
+
siteName: 'Example Site',
|
|
728
|
+
speakableSelectors: ['.article-answer', 'h1'],
|
|
729
|
+
}
|
|
730
|
+
).find((s) => s['@type'] === 'Article')
|
|
731
|
+
expect(schema.speakable).toEqual({
|
|
732
|
+
'@type': 'SpeakableSpecification',
|
|
733
|
+
cssSelector: ['.article-answer', 'h1'],
|
|
734
|
+
})
|
|
735
|
+
})
|
|
736
|
+
|
|
737
|
+
it('adds a mainEntity Question only for a QAPage articleType with an answer', () => {
|
|
738
|
+
expect(
|
|
739
|
+
renderArticle({ answer: 'Yes.' }).find((s) => s['@type'] === 'Article').mainEntity
|
|
740
|
+
).toBeUndefined()
|
|
741
|
+
expect(
|
|
742
|
+
renderArticle({ articleType: 'QAPage' }).find((s) => s['@type'] === 'QAPage').mainEntity
|
|
743
|
+
).toBeUndefined()
|
|
744
|
+
|
|
745
|
+
const qa = renderArticle({ articleType: 'QAPage', answer: 'Yes, always.' }).find(
|
|
746
|
+
(s) => s['@type'] === 'QAPage'
|
|
747
|
+
)
|
|
748
|
+
expect(qa.mainEntity).toEqual({
|
|
749
|
+
'@type': 'Question',
|
|
750
|
+
name: 'Test Article',
|
|
751
|
+
text: 'Excerpt.',
|
|
752
|
+
dateCreated: new Date('2025-01-01').toISOString(),
|
|
753
|
+
answerCount: 1,
|
|
754
|
+
acceptedAnswer: {
|
|
755
|
+
'@type': 'Answer',
|
|
756
|
+
text: 'Yes, always.',
|
|
757
|
+
url: 'https://example.com/articles/test-article',
|
|
758
|
+
},
|
|
759
|
+
})
|
|
760
|
+
})
|
|
761
|
+
})
|
|
762
|
+
|
|
763
|
+
describe('ArticleSEO comments and freshness', () => {
|
|
764
|
+
const article = {
|
|
765
|
+
slug: 'test-article',
|
|
766
|
+
title: 'Test Article',
|
|
767
|
+
excerpt: 'Excerpt.',
|
|
768
|
+
date: '2025-01-01',
|
|
769
|
+
author: 'Jane Doe',
|
|
770
|
+
category: 'Campaigns',
|
|
771
|
+
categories: ['Campaigns'],
|
|
772
|
+
readTime: '3 min read',
|
|
773
|
+
featuredImage: '/image.jpg',
|
|
774
|
+
} as Article
|
|
775
|
+
|
|
776
|
+
function renderArticle(overrides: Partial<Article>, extra?: Record<string, unknown>) {
|
|
777
|
+
const { container } = render(
|
|
778
|
+
<ArticleSEO
|
|
779
|
+
article={{ ...article, ...overrides }}
|
|
780
|
+
articleUrl="https://example.com/articles/test-article"
|
|
781
|
+
siteName="Example Site"
|
|
782
|
+
{...extra}
|
|
783
|
+
/>
|
|
784
|
+
)
|
|
785
|
+
return parseAllSchemaScripts(container).find((s) => s['@type'] === 'Article')
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
it('emits commentCount and comment entries for visible top-level comments', () => {
|
|
789
|
+
const schema = renderArticle(
|
|
790
|
+
{},
|
|
791
|
+
{
|
|
792
|
+
comments: [
|
|
793
|
+
{
|
|
794
|
+
id: '1',
|
|
795
|
+
articleSlug: 'test-article',
|
|
796
|
+
body: 'Great post.',
|
|
797
|
+
isDeleted: false,
|
|
798
|
+
createdAt: '2025-02-01T00:00:00.000Z',
|
|
799
|
+
authorId: 'u1',
|
|
800
|
+
authorName: 'Reader One',
|
|
801
|
+
parentId: null,
|
|
802
|
+
},
|
|
803
|
+
{
|
|
804
|
+
id: '2',
|
|
805
|
+
articleSlug: 'test-article',
|
|
806
|
+
body: 'A reply.',
|
|
807
|
+
isDeleted: false,
|
|
808
|
+
createdAt: '2025-02-02T00:00:00.000Z',
|
|
809
|
+
authorId: 'u2',
|
|
810
|
+
authorName: 'Reader Two',
|
|
811
|
+
parentId: '1',
|
|
812
|
+
},
|
|
813
|
+
{
|
|
814
|
+
id: '3',
|
|
815
|
+
articleSlug: 'test-article',
|
|
816
|
+
body: 'Removed.',
|
|
817
|
+
isDeleted: true,
|
|
818
|
+
createdAt: '2025-02-03T00:00:00.000Z',
|
|
819
|
+
authorId: 'u3',
|
|
820
|
+
authorName: 'Reader Three',
|
|
821
|
+
parentId: null,
|
|
822
|
+
},
|
|
823
|
+
],
|
|
824
|
+
}
|
|
825
|
+
)
|
|
826
|
+
|
|
827
|
+
expect(schema.commentCount).toBe(1)
|
|
828
|
+
expect(schema.comment).toEqual([
|
|
829
|
+
{
|
|
830
|
+
'@type': 'Comment',
|
|
831
|
+
text: 'Great post.',
|
|
832
|
+
dateCreated: '2025-02-01T00:00:00.000Z',
|
|
833
|
+
author: { '@type': 'Person', name: 'Reader One' },
|
|
834
|
+
},
|
|
835
|
+
])
|
|
836
|
+
})
|
|
837
|
+
|
|
838
|
+
it('omits comment fields when there are none to show', () => {
|
|
839
|
+
expect(renderArticle({}).commentCount).toBeUndefined()
|
|
840
|
+
expect(renderArticle({}, { comments: [] }).comment).toBeUndefined()
|
|
841
|
+
})
|
|
842
|
+
|
|
843
|
+
it('reuses datePublished for dateModified by default', () => {
|
|
844
|
+
expect(renderArticle({}).dateModified).toBe(new Date('2025-01-01').toISOString())
|
|
845
|
+
})
|
|
846
|
+
|
|
847
|
+
it('omits dateModified entirely when lastmodFallback is none', () => {
|
|
848
|
+
const schema = renderArticle(
|
|
849
|
+
{},
|
|
850
|
+
{
|
|
851
|
+
config: {
|
|
852
|
+
siteUrl: 'https://example.com',
|
|
853
|
+
siteName: 'Example Site',
|
|
854
|
+
lastmodFallback: 'none',
|
|
855
|
+
},
|
|
856
|
+
}
|
|
857
|
+
)
|
|
858
|
+
expect(schema.dateModified).toBeUndefined()
|
|
859
|
+
expect(schema.datePublished).toBe(new Date('2025-01-01').toISOString())
|
|
860
|
+
})
|
|
861
|
+
|
|
862
|
+
it('still uses an explicit lastmod under lastmodFallback none', () => {
|
|
863
|
+
const schema = renderArticle(
|
|
864
|
+
{ lastmod: '2026-01-01' },
|
|
865
|
+
{
|
|
866
|
+
config: {
|
|
867
|
+
siteUrl: 'https://example.com',
|
|
868
|
+
siteName: 'Example Site',
|
|
869
|
+
lastmodFallback: 'none',
|
|
870
|
+
},
|
|
871
|
+
}
|
|
872
|
+
)
|
|
873
|
+
expect(schema.dateModified).toBe(new Date('2026-01-01').toISOString())
|
|
874
|
+
})
|
|
875
|
+
})
|
|
876
|
+
|
|
877
|
+
describe('OrganizationSchema parentOrganization', () => {
|
|
878
|
+
it('links the site to its umbrella entity when configured', () => {
|
|
879
|
+
const { container } = render(
|
|
880
|
+
<OrganizationSchema
|
|
881
|
+
config={{
|
|
882
|
+
siteUrl: 'https://example.com',
|
|
883
|
+
siteName: 'Example Site',
|
|
884
|
+
organization: {
|
|
885
|
+
parentOrganization: { name: 'Example Network', url: 'https://network.example' },
|
|
886
|
+
},
|
|
887
|
+
}}
|
|
888
|
+
/>
|
|
889
|
+
)
|
|
890
|
+
expect(parseSchemaScript(container).parentOrganization).toEqual({
|
|
891
|
+
'@type': 'Organization',
|
|
892
|
+
name: 'Example Network',
|
|
893
|
+
url: 'https://network.example',
|
|
894
|
+
})
|
|
895
|
+
})
|
|
896
|
+
})
|
|
897
|
+
|
|
898
|
+
describe('ArticleSEO cross-site author identity', () => {
|
|
899
|
+
const article = {
|
|
900
|
+
slug: 'test-article',
|
|
901
|
+
title: 'Test Article',
|
|
902
|
+
excerpt: 'Excerpt.',
|
|
903
|
+
date: '2025-01-01',
|
|
904
|
+
author: 'andrew-blase',
|
|
905
|
+
category: 'Campaigns',
|
|
906
|
+
categories: ['Campaigns'],
|
|
907
|
+
readTime: '3 min read',
|
|
908
|
+
featuredImage: '/image.jpg',
|
|
909
|
+
} as Article
|
|
910
|
+
|
|
911
|
+
it('derives @id from identityUrl while url stays this site profile', () => {
|
|
912
|
+
const { container } = render(
|
|
913
|
+
<ArticleSEO
|
|
914
|
+
article={article}
|
|
915
|
+
articleUrl="https://vox.example/articles/test-article"
|
|
916
|
+
siteName="Vox"
|
|
917
|
+
authors={[
|
|
918
|
+
{
|
|
919
|
+
name: 'Andrew Blase',
|
|
920
|
+
slug: 'andrew-blase',
|
|
921
|
+
bio: '',
|
|
922
|
+
url: 'https://vox.example/articles/authors/andrew-blase',
|
|
923
|
+
identityUrl: 'https://hub.example/about',
|
|
924
|
+
sameAs: ['https://other.example/articles/authors/andrew-blase'],
|
|
925
|
+
},
|
|
926
|
+
]}
|
|
927
|
+
/>
|
|
928
|
+
)
|
|
929
|
+
const person = parseAllSchemaScripts(container).find((s) => s['@type'] === 'Article').author[0]
|
|
930
|
+
expect(person['@id']).toBe('https://hub.example/about#person')
|
|
931
|
+
expect(person.url).toBe('https://vox.example/articles/authors/andrew-blase')
|
|
932
|
+
expect(person.sameAs).toEqual(['https://other.example/articles/authors/andrew-blase'])
|
|
933
|
+
})
|
|
934
|
+
|
|
935
|
+
it('falls back to the site author page for @id when nothing is configured', () => {
|
|
936
|
+
const { container } = render(
|
|
937
|
+
<ArticleSEO
|
|
938
|
+
article={article}
|
|
939
|
+
articleUrl="https://vox.example/articles/test-article"
|
|
940
|
+
siteName="Vox"
|
|
941
|
+
config={{ siteUrl: 'https://vox.example', siteName: 'Vox' }}
|
|
942
|
+
authors={[{ name: 'Andrew Blase', slug: 'andrew-blase', bio: '' }]}
|
|
943
|
+
/>
|
|
944
|
+
)
|
|
945
|
+
const person = parseAllSchemaScripts(container).find((s) => s['@type'] === 'Article').author[0]
|
|
946
|
+
expect(person['@id']).toBe('https://vox.example/articles/authors/andrew-blase#person')
|
|
947
|
+
})
|
|
948
|
+
})
|
|
949
|
+
|
|
950
|
+
describe('ArticleSEO image absolutization', () => {
|
|
951
|
+
const article = {
|
|
952
|
+
slug: 'test-article',
|
|
953
|
+
title: 'Test Article',
|
|
954
|
+
excerpt: 'Excerpt.',
|
|
955
|
+
date: '2025-01-01',
|
|
956
|
+
author: 'Jane Doe',
|
|
957
|
+
category: 'Campaigns',
|
|
958
|
+
categories: ['Campaigns'],
|
|
959
|
+
readTime: '3 min read',
|
|
960
|
+
featuredImage: '/articles/test-article/hero.jpg',
|
|
961
|
+
} as Article
|
|
962
|
+
|
|
963
|
+
function imageOf(overrides: Partial<Article>, config?: ArticlesConfig) {
|
|
964
|
+
const { container } = render(
|
|
965
|
+
<ArticleSEO
|
|
966
|
+
article={{ ...article, ...overrides }}
|
|
967
|
+
articleUrl="https://example.com/articles/test-article"
|
|
968
|
+
siteName="Example Site"
|
|
969
|
+
config={config}
|
|
970
|
+
/>
|
|
971
|
+
)
|
|
972
|
+
return parseAllSchemaScripts(container).find((s) => s['@type'] === 'Article').image
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
it('absolutizes a site-relative image against config.siteUrl', () => {
|
|
976
|
+
expect(imageOf({}, { siteUrl: 'https://example.com/', siteName: 'Example Site' })).toEqual({
|
|
977
|
+
'@type': 'ImageObject',
|
|
978
|
+
url: 'https://example.com/articles/test-article/hero.jpg',
|
|
979
|
+
})
|
|
980
|
+
})
|
|
981
|
+
|
|
982
|
+
it('falls back to the articleUrl origin when no config is passed', () => {
|
|
983
|
+
expect(imageOf({}).url).toBe('https://example.com/articles/test-article/hero.jpg')
|
|
984
|
+
})
|
|
985
|
+
|
|
986
|
+
it('leaves an already-absolute image untouched', () => {
|
|
987
|
+
expect(imageOf({ featuredImage: 'https://cdn.example.com/hero.jpg' }).url).toBe(
|
|
988
|
+
'https://cdn.example.com/hero.jpg'
|
|
989
|
+
)
|
|
990
|
+
})
|
|
991
|
+
|
|
992
|
+
it('returns the raw value when articleUrl is unparseable and no config is given', () => {
|
|
993
|
+
const { container } = render(
|
|
994
|
+
<ArticleSEO article={article} articleUrl="not-a-url" siteName="Example Site" />
|
|
995
|
+
)
|
|
996
|
+
expect(parseAllSchemaScripts(container).find((s) => s['@type'] === 'Article').image.url).toBe(
|
|
997
|
+
'/articles/test-article/hero.jpg'
|
|
998
|
+
)
|
|
999
|
+
})
|
|
1000
|
+
|
|
1001
|
+
it('omits image entirely when the article has none', () => {
|
|
1002
|
+
expect(imageOf({ featuredImage: '' })).toBeUndefined()
|
|
1003
|
+
})
|
|
1004
|
+
})
|
|
@@ -352,6 +352,82 @@ describe('AuthorArticlesPage', () => {
|
|
|
352
352
|
expect(screen.getByText('Custom block')).toBeInTheDocument()
|
|
353
353
|
})
|
|
354
354
|
|
|
355
|
+
it("derives knowsAbout from the author's own article categories", () => {
|
|
356
|
+
const { container } = render(
|
|
357
|
+
<AuthorArticlesPage
|
|
358
|
+
author={author}
|
|
359
|
+
articles={[
|
|
360
|
+
...articles,
|
|
361
|
+
{ ...articles[0], slug: 'second', categories: ['Volunteers', 'Campaigns'] },
|
|
362
|
+
]}
|
|
363
|
+
config={config}
|
|
364
|
+
/>
|
|
365
|
+
)
|
|
366
|
+
const person = parseSchemaScript(container).find((schema) => schema['@type'] === 'Person')
|
|
367
|
+
expect(person.knowsAbout).toEqual(['Campaigns', 'Volunteers'])
|
|
368
|
+
expect(person['@id']).toBe('https://example.com/articles/authors/andrew-blase#person')
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
it('prefers an explicit AuthorProfile.knowsAbout over derived categories', () => {
|
|
372
|
+
const { container } = render(
|
|
373
|
+
<AuthorArticlesPage
|
|
374
|
+
author={{ ...author, knowsAbout: ['Civic technology'] }}
|
|
375
|
+
articles={articles}
|
|
376
|
+
config={config}
|
|
377
|
+
/>
|
|
378
|
+
)
|
|
379
|
+
const person = parseSchemaScript(container).find((schema) => schema['@type'] === 'Person')
|
|
380
|
+
expect(person.knowsAbout).toEqual(['Civic technology'])
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
it('omits knowsAbout when there are no categories to derive it from', () => {
|
|
384
|
+
const { container } = render(
|
|
385
|
+
<AuthorArticlesPage author={author} articles={[]} config={config} />
|
|
386
|
+
)
|
|
387
|
+
const person = parseSchemaScript(container).find((schema) => schema['@type'] === 'Person')
|
|
388
|
+
expect(person.knowsAbout).toBeUndefined()
|
|
389
|
+
expect(person.worksFor).toBeUndefined()
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
it('links the author to the organization @id when one is configured', () => {
|
|
393
|
+
const { container } = render(
|
|
394
|
+
<AuthorArticlesPage
|
|
395
|
+
author={author}
|
|
396
|
+
articles={articles}
|
|
397
|
+
config={{ ...config, organization: {} }}
|
|
398
|
+
/>
|
|
399
|
+
)
|
|
400
|
+
const person = parseSchemaScript(container).find((schema) => schema['@type'] === 'Person')
|
|
401
|
+
expect(person.worksFor).toEqual({ '@id': 'https://example.com/#organization' })
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
it('uses identityUrl for @id while keeping url and mainEntityOfPage on this site', () => {
|
|
405
|
+
const { container } = render(
|
|
406
|
+
<AuthorArticlesPage
|
|
407
|
+
author={{ ...author, identityUrl: 'https://hub.example/about' }}
|
|
408
|
+
articles={articles}
|
|
409
|
+
config={config}
|
|
410
|
+
/>
|
|
411
|
+
)
|
|
412
|
+
const person = parseSchemaScript(container).find((schema) => schema['@type'] === 'Person')
|
|
413
|
+
expect(person['@id']).toBe('https://hub.example/about#person')
|
|
414
|
+
expect(person.url).toBe('https://example.com/articles/authors/andrew-blase')
|
|
415
|
+
expect(person.mainEntityOfPage).toBe('https://example.com/articles/authors/andrew-blase')
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
it('keeps mainEntityOfPage on this site even when url points elsewhere', () => {
|
|
419
|
+
const { container } = render(
|
|
420
|
+
<AuthorArticlesPage
|
|
421
|
+
author={{ ...author, url: 'https://elsewhere.example/profile' }}
|
|
422
|
+
articles={articles}
|
|
423
|
+
config={config}
|
|
424
|
+
/>
|
|
425
|
+
)
|
|
426
|
+
const person = parseSchemaScript(container).find((schema) => schema['@type'] === 'Person')
|
|
427
|
+
expect(person.url).toBe('https://elsewhere.example/profile')
|
|
428
|
+
expect(person.mainEntityOfPage).toBe('https://example.com/articles/authors/andrew-blase')
|
|
429
|
+
})
|
|
430
|
+
|
|
355
431
|
it('excludes credentials, servesWho, principles, proof, and promise from the Person JSON-LD', () => {
|
|
356
432
|
const { container } = render(
|
|
357
433
|
<AuthorArticlesPage author={richAuthor} articles={articles} config={config} />
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
getAuthorIdentityUrl,
|
|
2
3
|
getAuthorAvatar,
|
|
3
4
|
getAuthorSameAs,
|
|
4
5
|
getAuthorSocialLinks,
|
|
@@ -87,3 +88,52 @@ describe('authorUtils', () => {
|
|
|
87
88
|
expect(getAuthorSocialLinks(author)).toEqual([])
|
|
88
89
|
})
|
|
89
90
|
})
|
|
91
|
+
|
|
92
|
+
describe('getAuthorSameAs cross-site profiles', () => {
|
|
93
|
+
it('merges explicit sameAs with derived social links and dedupes', () => {
|
|
94
|
+
const result = getAuthorSameAs({
|
|
95
|
+
name: 'Jane Doe',
|
|
96
|
+
slug: 'jane-doe',
|
|
97
|
+
bio: '',
|
|
98
|
+
social: { website: 'https://a.example' },
|
|
99
|
+
sameAs: ['https://b.example/authors/jane', 'https://a.example', ''],
|
|
100
|
+
})
|
|
101
|
+
expect(result).toEqual(['https://a.example', 'https://b.example/authors/jane'])
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('returns derived links only when sameAs is unset', () => {
|
|
105
|
+
expect(
|
|
106
|
+
getAuthorSameAs({
|
|
107
|
+
name: 'Jane Doe',
|
|
108
|
+
slug: 'jane-doe',
|
|
109
|
+
bio: '',
|
|
110
|
+
social: { website: 'https://a.example' },
|
|
111
|
+
})
|
|
112
|
+
).toEqual(['https://a.example'])
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
describe('getAuthorIdentityUrl', () => {
|
|
117
|
+
const config = { siteUrl: 'https://vox.example/' }
|
|
118
|
+
const base = { name: 'Jane Doe', slug: 'jane-doe', bio: '' }
|
|
119
|
+
|
|
120
|
+
it('prefers identityUrl over url and the site author page', () => {
|
|
121
|
+
expect(
|
|
122
|
+
getAuthorIdentityUrl(
|
|
123
|
+
{ ...base, identityUrl: 'https://hub.example/about', url: 'https://vox.example/x' },
|
|
124
|
+
config
|
|
125
|
+
)
|
|
126
|
+
).toBe('https://hub.example/about')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('falls back to url, then to this site author page', () => {
|
|
130
|
+
expect(getAuthorIdentityUrl({ ...base, url: 'https://vox.example/x' }, config)).toBe(
|
|
131
|
+
'https://vox.example/x'
|
|
132
|
+
)
|
|
133
|
+
expect(getAuthorIdentityUrl(base, config)).toBe('https://vox.example/articles/authors/jane-doe')
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('returns undefined with no config and nothing configured', () => {
|
|
137
|
+
expect(getAuthorIdentityUrl(base)).toBeUndefined()
|
|
138
|
+
})
|
|
139
|
+
})
|