@fullstackdatasolutions/articles 1.3.1 → 1.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fullstackdatasolutions/articles",
3
- "version": "1.3.1",
3
+ "version": "1.4.0",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "funding": {
@@ -1038,6 +1038,44 @@ describe('AI markdown helpers', () => {
1038
1038
  expect(response.headers.get('Link')).not.toContain('alternate')
1039
1039
  })
1040
1040
 
1041
+ it('keeps markdown twins out of the search index by default', async () => {
1042
+ setupArticleTreeMock([
1043
+ {
1044
+ slug: 'allowed',
1045
+ frontmatter: 'date: 2025-01-01\naiCrawl: true\n',
1046
+ body: '# Allowed',
1047
+ },
1048
+ ])
1049
+
1050
+ const response = await getArticleMarkdownResponse('allowed', {
1051
+ siteUrl: 'https://example.com',
1052
+ siteName: 'Example',
1053
+ })
1054
+
1055
+ expect(response.headers.get('X-Robots-Tag')).toBe('noindex')
1056
+ })
1057
+
1058
+ it('always noindexes article twins, even with listingTwinNoindex off', async () => {
1059
+ setupArticleTreeMock([
1060
+ {
1061
+ slug: 'allowed',
1062
+ frontmatter: 'date: 2025-01-01\naiCrawl: true\n',
1063
+ body: '# Allowed',
1064
+ },
1065
+ ])
1066
+
1067
+ const response = await getArticleMarkdownResponse('allowed', {
1068
+ siteUrl: 'https://example.com',
1069
+ siteName: 'Example',
1070
+ listingTwinNoindex: false,
1071
+ })
1072
+
1073
+ expect(response.headers.get('X-Robots-Tag')).toBe('noindex')
1074
+ expect(response.headers.get('Link')).toBe(
1075
+ '<https://example.com/articles/allowed>; rel="canonical"'
1076
+ )
1077
+ })
1078
+
1041
1079
  it('generates robots rules for AI crawlers and blocked articles only', async () => {
1042
1080
  setupArticleTreeMock([
1043
1081
  { slug: 'allowed', frontmatter: 'date: 2025-01-01\naiCrawl: true\n' },
@@ -1451,9 +1489,20 @@ describe('getMarkdownTwinResponse', () => {
1451
1489
  const response = await getMarkdownTwinResponse('category/campaigns', config)
1452
1490
  expect(response.status).toBe(200)
1453
1491
  expect(response.headers.get('Content-Type')).toBe('text/markdown; charset=utf-8')
1492
+ expect(response.headers.get('X-Robots-Tag')).toBe('noindex')
1454
1493
  await expect(response.text()).resolves.toContain('# campaigns')
1455
1494
  })
1456
1495
 
1496
+ it('honours listingTwinNoindex: false on listing twins', async () => {
1497
+ setupArticleTreeMock([{ slug: 'one', frontmatter: 'date: 2025-01-01\ntags: [campaigns]\n' }])
1498
+ const response = await getMarkdownTwinResponse('category/campaigns', {
1499
+ ...config,
1500
+ listingTwinNoindex: false,
1501
+ })
1502
+ expect(response.status).toBe(200)
1503
+ expect(response.headers.get('X-Robots-Tag')).toBeNull()
1504
+ })
1505
+
1457
1506
  it('404s a listing path that resolves to nothing', async () => {
1458
1507
  setupArticleTreeMock([{ slug: 'one', frontmatter: 'date: 2025-01-01\n' }])
1459
1508
  const response = await getMarkdownTwinResponse('category/missing', config)
@@ -341,6 +341,22 @@ export interface ArticlesConfig {
341
341
  * a config is available (i.e. via `getArticleMarkdownResponse`).
342
342
  */
343
343
  markdownTwinHeader?: boolean
344
+ /**
345
+ * Set to `false` to stop serving the `category/`, `authors/`, and `series/`
346
+ * listing twins with `X-Robots-Tag: noindex`. Default: `true`.
347
+ *
348
+ * Only the listing twins are configurable. Article twins are always
349
+ * `noindex`: an article twin is the same text as exactly one HTML article,
350
+ * so indexing it can only ever split that article's own signal between two
351
+ * URLs. A listing twin is a different case - it is a generated index of a
352
+ * category or author rather than a copy of one page - so a site may have a
353
+ * reason to let it rank, and this leaves that open.
354
+ *
355
+ * Either way AI crawlers can still fetch every twin; `noindex` only removes
356
+ * them from search results, and never blocks the fetch that is the point of
357
+ * publishing them.
358
+ */
359
+ listingTwinNoindex?: boolean
344
360
  /**
345
361
  * Vendor-neutral event callback (Phase 27F). Fired by components/hooks at
346
362
  * meaningful reader-journey moments (see `ArticleEvent` in `events.ts`).
@@ -737,14 +737,21 @@ export async function getMarkdownTwinResponse(
737
737
  if (listing !== undefined) {
738
738
  if (listing === null) return new Response('Not Found', { status: 404 })
739
739
  reportAiCrawl(slug, config, options?.headers)
740
- return new Response(listing, { headers: LISTING_MARKDOWN_HEADERS })
740
+ return new Response(listing, { headers: listingMarkdownHeaders(config) })
741
741
  }
742
742
  return getArticleMarkdownResponse(slug, config, options)
743
743
  }
744
744
 
745
- const LISTING_MARKDOWN_HEADERS = {
746
- 'Content-Type': 'text/markdown; charset=utf-8',
747
- 'Cache-Control': 'public, max-age=3600, s-maxage=3600',
745
+ // A listing twin is a generated index of a category/author/series rather than
746
+ // a copy of a single page, so unlike the article twin below this one is
747
+ // configurable - a site may have a reason to let it rank. Opt out with
748
+ // `listingTwinNoindex: false`.
749
+ function listingMarkdownHeaders(config: ArticlesConfig): Record<string, string> {
750
+ return {
751
+ 'Content-Type': 'text/markdown; charset=utf-8',
752
+ 'Cache-Control': 'public, max-age=3600, s-maxage=3600',
753
+ ...(config.listingTwinNoindex !== false && { 'X-Robots-Tag': 'noindex' }),
754
+ }
748
755
  }
749
756
 
750
757
  // `undefined` means "not a listing path" (fall through to the article
@@ -789,6 +796,17 @@ export async function getArticleMarkdownResponse(
789
796
  'Content-Type': 'text/markdown; charset=utf-8',
790
797
  'Cache-Control': 'public, max-age=3600, s-maxage=3600',
791
798
  Link: `<${canonicalUrl}>; rel="canonical"`,
799
+ // A twin is a second representation of a page that is already indexed,
800
+ // so it should not compete with that page in a search index - on a large
801
+ // corpus it doubles the crawlable URL count with near-duplicate content.
802
+ // noindex rather than a robots.txt disallow: AI crawlers still need to
803
+ // fetch these, and a disallow would block the fetch that is the whole
804
+ // point of publishing them. Set here rather than in the consuming app
805
+ // because the `.md` path is typically a rewrite, so a host-level header
806
+ // rule keyed on `.md` never sees the request. Not configurable: an
807
+ // article twin is the same text as exactly one HTML article, so indexing
808
+ // it can only ever split that article's own signal across two URLs.
809
+ 'X-Robots-Tag': 'noindex',
792
810
  },
793
811
  })
794
812
  }