@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
@@ -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
+ })