Skip to content

Commit e8c22d7

Browse files
authored
feat(robots): declare Content Signals for AI crawlers (#88)
robots.txt says which crawlers may fetch the docs but says nothing about what they may do with the content once fetched. Content Signals (contentsignals.org, draft-romm-aipref-contentsignals) covers that second half, and agent-readiness scanners check for it. Declare search=yes, ai-input=yes, ai-train=yes. That restates in machine-readable form what robots.txt already grants: every AI crawler here, including the training crawlers GPTBot and Google-Extended, is allowed. Flip a value if that intent ever changes. The declaration is repeated in every User-agent group because a crawler matching a named group ignores the wildcard group entirely, so a single declaration under `*` would not reach GPTBot or ClaudeBot.
1 parent 6a89b01 commit e8c22d7

2 files changed

Lines changed: 100 additions & 0 deletions

File tree

public/robots.txt

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,52 @@
1+
# Content Signals (https://contentsignals.org) state how this site's content
2+
# may be used once it has been crawled. Steel's docs are public and written to
3+
# be read by agents, so all three signals are open:
4+
#
5+
# search: appearing in search results
6+
# ai-input: use as input for an AI answer, such as RAG or grounding
7+
# ai-train: use as training data for a model
8+
#
9+
# A crawler that matches a named group below ignores the wildcard group, so the
10+
# declaration is repeated in every group rather than stated once.
11+
112
User-agent: *
13+
Content-Signal: search=yes, ai-input=yes, ai-train=yes
214
Allow: /
315

416
User-agent: GPTBot
17+
Content-Signal: search=yes, ai-input=yes, ai-train=yes
518
Allow: /
619

720
User-agent: ChatGPT-User
21+
Content-Signal: search=yes, ai-input=yes, ai-train=yes
822
Allow: /
923

1024
User-agent: OAI-SearchBot
25+
Content-Signal: search=yes, ai-input=yes, ai-train=yes
1126
Allow: /
1227

1328
User-agent: PerplexityBot
29+
Content-Signal: search=yes, ai-input=yes, ai-train=yes
1430
Allow: /
1531

1632
User-agent: Perplexity-User
33+
Content-Signal: search=yes, ai-input=yes, ai-train=yes
1734
Allow: /
1835

1936
User-agent: ClaudeBot
37+
Content-Signal: search=yes, ai-input=yes, ai-train=yes
2038
Allow: /
2139

2240
User-agent: Claude-User
41+
Content-Signal: search=yes, ai-input=yes, ai-train=yes
2342
Allow: /
2443

2544
User-agent: Claude-SearchBot
45+
Content-Signal: search=yes, ai-input=yes, ai-train=yes
2646
Allow: /
2747

2848
User-agent: Google-Extended
49+
Content-Signal: search=yes, ai-input=yes, ai-train=yes
2950
Allow: /
3051

3152
Sitemap: https://docs.steel.dev/sitemap.xml

tests/robots-txt.test.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// ABOUTME: Tests for public/robots.txt, covering the Content Signals declaration
2+
// ABOUTME: that states how AI crawlers may use the docs, plus the crawl directives.
3+
import { describe, expect, test } from 'bun:test';
4+
import { readFileSync } from 'node:fs';
5+
import { fileURLToPath } from 'node:url';
6+
7+
const ROBOTS = readFileSync(
8+
fileURLToPath(new URL('../public/robots.txt', import.meta.url)),
9+
'utf8',
10+
);
11+
12+
/** Splits robots.txt into its User-agent groups, keyed by agent name. */
13+
function parseGroups(source: string): Map<string, string[]> {
14+
const groups = new Map<string, string[]>();
15+
let current: string[] | null = null;
16+
17+
for (const line of source.split('\n')) {
18+
const trimmed = line.trim();
19+
if (!trimmed || trimmed.startsWith('#')) continue;
20+
21+
const [rawKey, ...rawValue] = trimmed.split(':');
22+
const key = rawKey.trim().toLowerCase();
23+
const value = rawValue.join(':').trim();
24+
25+
if (key === 'user-agent') {
26+
current = [];
27+
groups.set(value, current);
28+
} else if (current) {
29+
current.push(trimmed);
30+
}
31+
}
32+
33+
return groups;
34+
}
35+
36+
const groups = parseGroups(ROBOTS);
37+
38+
describe('robots.txt content signals', () => {
39+
test('declares a Content-Signal in every User-agent group', () => {
40+
// A crawler matching a specific group ignores the wildcard group entirely,
41+
// so the declaration has to be repeated rather than stated once.
42+
expect(groups.size).toBeGreaterThan(0);
43+
44+
for (const [agent, directives] of groups) {
45+
const signal = directives.find((directive) =>
46+
directive.toLowerCase().startsWith('content-signal:'),
47+
);
48+
expect(signal, `${agent} is missing a Content-Signal directive`).toBeDefined();
49+
}
50+
});
51+
52+
test('declares all three signals with valid values', () => {
53+
for (const [agent, directives] of groups) {
54+
const signal = directives.find((directive) =>
55+
directive.toLowerCase().startsWith('content-signal:'),
56+
);
57+
const declared = new Map(
58+
(signal ?? '')
59+
.slice('content-signal:'.length)
60+
.split(',')
61+
.map((entry) => entry.trim().split('=') as [string, string]),
62+
);
63+
64+
for (const name of ['search', 'ai-input', 'ai-train']) {
65+
expect([...declared.keys()], `${agent} is missing the ${name} signal`).toContain(name);
66+
expect(['yes', 'no'], `${agent} declares an invalid ${name} value`).toContain(
67+
declared.get(name) ?? '',
68+
);
69+
}
70+
}
71+
});
72+
73+
test('keeps the crawl directives, sitemap and host', () => {
74+
expect(groups.get('*')).toContain('Allow: /');
75+
expect(groups.get('ClaudeBot')).toContain('Allow: /');
76+
expect(ROBOTS).toContain('Sitemap: https://docs.steel.dev/sitemap.xml');
77+
expect(ROBOTS).toContain('Host: docs.steel.dev');
78+
});
79+
});

0 commit comments

Comments
 (0)