@fullstackdatasolutions/articles 1.3.0 → 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.0",
3
+ "version": "1.4.0",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "funding": {
@@ -1017,6 +1017,65 @@ describe('AI markdown helpers', () => {
1017
1017
  expect(blocked.status).toBe(404)
1018
1018
  })
1019
1019
 
1020
+ it('links the markdown twin back to its HTML article, not to itself', async () => {
1021
+ setupArticleTreeMock([
1022
+ {
1023
+ slug: 'allowed',
1024
+ frontmatter: 'date: 2025-01-01\naiCrawl: true\n',
1025
+ body: '# Allowed',
1026
+ },
1027
+ ])
1028
+
1029
+ const response = await getArticleMarkdownResponse('allowed', {
1030
+ siteUrl: 'https://example.com/',
1031
+ siteName: 'Example',
1032
+ })
1033
+
1034
+ expect(response.headers.get('Link')).toBe(
1035
+ '<https://example.com/articles/allowed>; rel="canonical"'
1036
+ )
1037
+ expect(response.headers.get('Link')).not.toContain('.md')
1038
+ expect(response.headers.get('Link')).not.toContain('alternate')
1039
+ })
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
+
1020
1079
  it('generates robots rules for AI crawlers and blocked articles only', async () => {
1021
1080
  setupArticleTreeMock([
1022
1081
  { slug: 'allowed', frontmatter: 'date: 2025-01-01\naiCrawl: true\n' },
@@ -1430,9 +1489,20 @@ describe('getMarkdownTwinResponse', () => {
1430
1489
  const response = await getMarkdownTwinResponse('category/campaigns', config)
1431
1490
  expect(response.status).toBe(200)
1432
1491
  expect(response.headers.get('Content-Type')).toBe('text/markdown; charset=utf-8')
1492
+ expect(response.headers.get('X-Robots-Tag')).toBe('noindex')
1433
1493
  await expect(response.text()).resolves.toContain('# campaigns')
1434
1494
  })
1435
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
+
1436
1506
  it('404s a listing path that resolves to nothing', async () => {
1437
1507
  setupArticleTreeMock([{ slug: 'one', frontmatter: 'date: 2025-01-01\n' }])
1438
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
@@ -775,11 +782,31 @@ export async function getArticleMarkdownResponse(
775
782
  article && config.markdownTwinHeader !== false
776
783
  ? `${buildMarkdownTwinHeader(article, config, markdown)}${markdown.trimStart()}`
777
784
  : markdown
785
+ // `getArticleAiHeaders` builds a `rel="alternate"` link *to* the twin, which
786
+ // is correct on the HTML page and wrong here - on the twin's own response it
787
+ // pointed at the URL being requested, telling a client the alternate of
788
+ // `/articles/x.md` is `/articles/x.md`. `getArticleMarkdown` already returned
789
+ // null for anything not opted in, so that helper's other branch
790
+ // (`X-Robots-Tag: noai`) was unreachable from this call site anyway. Point
791
+ // back at the HTML article instead, which is the relationship a client
792
+ // fetching the twin actually needs: this is a representation of that page.
793
+ const canonicalUrl = `${config.siteUrl.replace(/\/$/, '')}/articles/${slug}`
778
794
  return new Response(body, {
779
795
  headers: {
780
796
  'Content-Type': 'text/markdown; charset=utf-8',
781
797
  'Cache-Control': 'public, max-age=3600, s-maxage=3600',
782
- ...(article ? getArticleAiHeaders(article, config) : {}),
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',
783
810
  },
784
811
  })
785
812
  }