@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/CHANGELOG.md +10 -0
- package/README.md +12 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +16 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js.map +1 -1
- package/dist/nextjs.cjs +19 -6
- package/dist/nextjs.cjs.map +1 -1
- package/dist/nextjs.d.cts +16 -0
- package/dist/nextjs.d.ts +16 -0
- package/dist/nextjs.js +19 -6
- package/dist/nextjs.js.map +1 -1
- package/dist/server.cjs +19 -6
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +16 -0
- package/dist/server.d.ts +16 -0
- package/dist/server.js +19 -6
- package/dist/server.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/server-articles.test.ts +49 -0
- package/src/articlesConfig.ts +16 -0
- package/src/server-articles.ts +22 -4
package/package.json
CHANGED
|
@@ -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)
|
package/src/articlesConfig.ts
CHANGED
|
@@ -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`).
|
package/src/server-articles.ts
CHANGED
|
@@ -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:
|
|
740
|
+
return new Response(listing, { headers: listingMarkdownHeaders(config) })
|
|
741
741
|
}
|
|
742
742
|
return getArticleMarkdownResponse(slug, config, options)
|
|
743
743
|
}
|
|
744
744
|
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
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
|
}
|