@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/CHANGELOG.md +16 -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 +23 -25
- 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 +23 -25
- package/dist/nextjs.js.map +1 -1
- package/dist/server.cjs +23 -8
- 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 +23 -8
- package/dist/server.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/server-articles.test.ts +70 -0
- package/src/articlesConfig.ts +16 -0
- package/src/server-articles.ts +32 -5
package/package.json
CHANGED
|
@@ -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)
|
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
|
|
@@ -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
|
-
|
|
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
|
}
|