Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 13 additions & 7 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,11 +156,11 @@ The same option is available in `agent-docs.config.yml` as `options.urlPathPatte

### Request behavior

| Flag | Default | Description |
| -------------------------- | ------- | ------------------------------------------- |
| `--max-concurrency <n>` | `3` | Maximum concurrent HTTP requests |
| `--request-delay <ms>` | `200` | Delay between requests in milliseconds |
| `--canonical-origin <url>` | | The production domain your content links to |
| Flag | Default | Description |
| -------------------------- | ------- | ------------------------------------------------------------------------------------ |
| `--max-concurrency <n>` | `3` | Maximum concurrent HTTP requests |
| `--request-delay <ms>` | `200` | Delay between requests in milliseconds |
| `--canonical-origin <url>` | | The production base URL (origin, or origin plus a path prefix) your content links to |

AFDocs enforces delays between requests and caps concurrent connections to avoid overloading your server. Adjust these if you need gentler or faster runs:

Expand All @@ -172,11 +172,17 @@ afdocs check https://docs.example.com --request-delay 500 --max-concurrency 1
afdocs check https://docs.example.com --request-delay 50 --max-concurrency 10
```

Use `--canonical-origin` when your site's URLs in `sitemap.xml` and `llms.txt` don't match the domain you're testing, such as preview deployments or localhost.
Use `--canonical-origin` when your site's URLs in `sitemap.xml` and `llms.txt` don't match the base URL you're testing, such as preview deployments or localhost. It accepts either a bare origin or an origin plus a path prefix:

- **Origin only** — rewrites every URL on that host, regardless of path.
- **Origin plus a path prefix** — rewrites only URLs under that prefix, remapping them to the base URL you pass to `check` (the prefixes need not match).

```bash
# Test a preview deployment
# Origin only: rewrite all example.com URLs to the preview host
afdocs check https://preview-xyz-example.app/docs --canonical-origin https://example.com

# Path prefix: production docs live under /docs, but your preview serves them under /preview
afdocs check http://localhost:3000/preview --canonical-origin https://example.com/docs
```

### llms.txt selection
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/config-file.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ Override default runner options. All fields are optional:
| `requestTimeout` | `30000` | Timeout for individual HTTP requests in milliseconds |
| `preferredLocale` | auto-detect | Preferred locale for URL discovery (e.g. `en`, `fr`, `ja`) |
| `preferredVersion` | auto-detect | Preferred version for URL discovery (e.g. `v3`, `2.x`) |
| `canonicalOrigin` | | The production domain your content links to |
| `canonicalOrigin` | | The production base URL (origin, or origin plus a path prefix) your content links to |
| `llmsTxtUrl` | | Explicit llms.txt URL to use as canonical (overrides the discovery heuristic; see CLI docs) |
| `thresholds.pass` | `50000` | Page size pass threshold in characters |
| `thresholds.fail` | `100000` | Page size fail threshold in characters |
Expand Down
2 changes: 1 addition & 1 deletion docs/run-locally.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ Some checks may behave differently against a local server:

When you build your site locally, generated files like `llms.txt` and `sitemap.xml` typically contain your production domain. AFDocs sees URLs pointing to `https://docs.example.com` but you're testing `http://localhost:3000`, so origin comparisons fail and checks like `llms-txt-coverage` report 0% coverage.

Use `--canonical-origin` to tell AFDocs which production domain to rewrite:
Use `--canonical-origin` to tell AFDocs which production base URL (origin, or origin plus a path prefix) to rewrite:

```bash
npm run build
Expand Down
21 changes: 15 additions & 6 deletions src/cli/commands/check.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Command } from 'commander';
import { normalizeUrl, runChecks } from '../../runner.js';
import { normalizeCanonical, normalizeUrl, runChecks } from '../../runner.js';
import { formatText } from '../formatters/text.js';
import { formatJson } from '../formatters/json.js';
import { formatScorecard } from '../formatters/scorecard.js';
Expand Down Expand Up @@ -73,7 +73,7 @@ export function registerCheckCommand(program: Command): void {
)
.option(
'--canonical-origin <url>',
'The production domain your content links to (for preview/staging testing)',
'The production base URL (origin, or origin plus a path prefix) your content links to, rewritten to the target for preview/staging testing',
)
.option(
'--llms-txt-url <url>',
Expand Down Expand Up @@ -209,11 +209,20 @@ export function registerCheckCommand(program: Command): void {
if (rawCanonical) {
const normalized = normalizeUrl(rawCanonical);
try {
canonicalOrigin = new URL(normalized).origin;
const targetOrigin = new URL(url).origin;
if (canonicalOrigin === targetOrigin) {
const parsedCanonical = new URL(normalized);
// Normalize identically to createContext so the warning reflects the value used.
canonicalOrigin = normalizeCanonical(normalized);
// The flag has no effect when the canonical resolves to what the rewrite would
// produce anyway: for a sub-path canonical that's the full target base; for an
// origin-only canonical it's just the target origin (the path is untouched).
const parsedTarget = new URL(url);
const canonicalHasSubPath = parsedCanonical.pathname !== '/';
const noEffect = canonicalHasSubPath
? canonicalOrigin === normalizeCanonical(url)
: parsedCanonical.origin === parsedTarget.origin;
if (noEffect) {
process.stderr.write(
`Warning: --canonical-origin "${canonicalOrigin}" is the same as the target origin. The flag has no effect.\n`,
`Warning: --canonical-origin "${canonicalOrigin}" is the same as the target. The flag has no effect.\n`,
);
canonicalOrigin = undefined;
}
Expand Down
14 changes: 12 additions & 2 deletions src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ interface RateLimitedHttpClientOptions {
requestDelay: number;
requestTimeout: number;
maxConcurrency: number;
/** Canonical base URL to find in bodies (origin, or origin plus a path prefix). */
canonicalOrigin?: string;
/** Value to replace it with: the target origin, or the full target base for a path-prefix canonical. */
targetOrigin?: string;
}

Expand All @@ -24,9 +26,13 @@ function escapeRegExp(s: string): string {
export function createHttpClient(options: RateLimitedHttpClientOptions): HttpClient {
let lastRequestTime = 0;
let activeRequests = 0;
// Match the canonical base only at a URL boundary: end-of-string or one of these
// delimiters. `)` and `,` are included so URLs inside markdown links `[x](url)` and
// prose `url, next` rewrite; the rare tradeoff is a path segment like `/docs,2024`
// being treated as the `/docs` prefix.
const originPattern =
options.canonicalOrigin && options.targetOrigin
? new RegExp(escapeRegExp(options.canonicalOrigin) + '(?=[/\\s"\'\\]>]|$)', 'g')
? new RegExp(escapeRegExp(options.canonicalOrigin) + '(?=[/?#\\s"\'\\]),>]|$)', 'g')
: null;

async function waitForSlot(): Promise<void> {
Expand Down Expand Up @@ -81,7 +87,11 @@ export function createHttpClient(options: RateLimitedHttpClientOptions): HttpCli
if (/text|xml|json|markdown/.test(ct)) {
const body = await response.text();
originPattern.lastIndex = 0;
const rewritten = body.replace(originPattern, options.targetOrigin);
// Use a function replacer so `$` in the target (e.g. a preview path
// containing `$'` or `$&`) is inserted literally, not interpreted as a
// String.replace replacement pattern.
const target = options.targetOrigin;
const rewritten = body.replace(originPattern, () => target);
return {
ok: response.ok,
status: response.status,
Expand Down
34 changes: 31 additions & 3 deletions src/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,18 @@ export function normalizeUrl(url: string): string {
return url;
}

/**
* Normalize a canonical/base URL for the http.ts rewrite regex, which is literal and
* case-sensitive: lowercase the host and drop default ports (via URL.origin) while
* preserving any sub-path, then strip the trailing slash so it matches path segments.
* Must be applied identically to the canonical value and the target base it is compared
* against. Assumes `raw` is already scheme-qualified (see normalizeUrl).
*/
export function normalizeCanonical(raw: string): string {
const parsed = new URL(raw);
return `${parsed.origin}${parsed.pathname}`.replace(/\/+$/, '');
}

export function createContext(baseUrl: string, options?: Partial<RunnerOptions>): CheckContext {
if (options) {
if (options.canonicalOrigin) {
Expand All @@ -60,6 +72,18 @@ export function createContext(baseUrl: string, options?: Partial<RunnerOptions>)
const merged = { ...DEFAULT_OPTIONS, ...options };
baseUrl = normalizeUrl(baseUrl);
const url = new URL(baseUrl);
const normalizedBaseUrl = baseUrl.replace(/\/$/, '');

// Normalize the canonical value once, here, so CLI and direct createContext() callers
// behave identically. Keep merged.canonicalOrigin in sync with the value wired below.
let canonicalOrigin: string | undefined;
if (merged.canonicalOrigin) {
canonicalOrigin = normalizeCanonical(merged.canonicalOrigin);
merged.canonicalOrigin = canonicalOrigin;
}

// A sub-path canonical rewrites to the full preview base; origin-only swaps origins.
const canonicalHasSubPath = Boolean(canonicalOrigin && new URL(canonicalOrigin).pathname !== '/');

// Fail fast when the target port is on the WHATWG fetch bad port list:
// undici would refuse every request, turning one port choice into a wall
Expand All @@ -70,15 +94,19 @@ export function createContext(baseUrl: string, options?: Partial<RunnerOptions>)
}

return {
baseUrl: baseUrl.replace(/\/$/, ''),
baseUrl: normalizedBaseUrl,
origin: url.origin,
previousResults: new Map(),
http: createHttpClient({
requestDelay: merged.requestDelay,
requestTimeout: merged.requestTimeout,
maxConcurrency: merged.maxConcurrency,
canonicalOrigin: merged.canonicalOrigin,
targetOrigin: merged.canonicalOrigin ? url.origin : undefined,
canonicalOrigin,
targetOrigin: canonicalOrigin
? canonicalHasSubPath
? normalizedBaseUrl
: url.origin
: undefined,
}),
options: merged,
pageCache: new Map(),
Expand Down
7 changes: 6 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,12 @@ export interface CheckOptions {
preferredLocale?: string;
/** Preferred version for URL discovery (e.g. 'v3', '2.x', 'latest'). Overrides auto-detection from baseUrl. */
preferredVersion?: string;
/** Canonical origin to rewrite in fetched content (for preview/staging testing). */
/**
* Canonical base URL to rewrite in fetched content (for preview/staging testing).
* Accepts an origin (`https://prod.example.com`) or an origin plus a path prefix
* (`https://prod.example.com/docs`); when a path prefix is given, matching URLs are
* rewritten to the full target base.
*/
canonicalOrigin?: string;
/** Pass threshold for llms-txt-coverage (0–100). Default 95. */
coveragePassThreshold?: number;
Expand Down
110 changes: 109 additions & 1 deletion test/unit/cli/check-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -758,7 +758,115 @@ describe('check command config integration', () => {
await new Promise((r) => setTimeout(r, 100));

const stderr = stderrSpy.mock.calls.map((c) => c[0]).join('');
expect(stderr).toContain('same as the target origin');
expect(stderr).toContain('same as the target');
expect(stderr).toContain('no effect');

stdoutSpy.mockRestore();
stderrSpy.mockRestore();
});

it('warns when origin-only --canonical-origin matches target whose URL has a path', async () => {
server.use(
http.get('http://cmd-canon-path.local/docs/llms.txt', () =>
HttpResponse.text(VALID_LLMS_TXT),
),
http.get('http://cmd-canon-path.local/llms.txt', () => HttpResponse.text(VALID_LLMS_TXT)),
);

const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);

const { run } = await import('../../../src/cli/index.js');
await run([
'node',
'afdocs',
'check',
'http://cmd-canon-path.local/docs',
'--canonical-origin',
'http://cmd-canon-path.local',
'--checks',
'llms-txt-exists',
'--request-delay',
'0',
]);
await new Promise((r) => setTimeout(r, 100));

const stderr = stderrSpy.mock.calls.map((c) => c[0]).join('');
// Origin-only canonical == target origin → no effect, even though target has a path.
expect(stderr).toContain('no effect');

stdoutSpy.mockRestore();
stderrSpy.mockRestore();
});

it('does not suppress --canonical-origin when same origin but different sub-path', async () => {
server.use(
http.get('http://cmd-canon-subpath.local/preview/llms.txt', () =>
HttpResponse.text(VALID_LLMS_TXT),
),
http.get(
'http://cmd-canon-subpath.local/llms.txt',
() => new HttpResponse(null, { status: 404 }),
),
http.get(
'http://cmd-canon-subpath.local/docs/llms.txt',
() => new HttpResponse(null, { status: 404 }),
),
);

const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);

const { run } = await import('../../../src/cli/index.js');
await run([
'node',
'afdocs',
'check',
'http://cmd-canon-subpath.local/preview',
'--canonical-origin',
'http://cmd-canon-subpath.local/aws/en',
'--checks',
'llms-txt-exists',
'--request-delay',
'0',
]);
await new Promise((r) => setTimeout(r, 100));

const stdout = stdoutSpy.mock.calls.map((c) => c[0]).join('');
const stderr = stderrSpy.mock.calls.map((c) => c[0]).join('');
expect(stderr).not.toContain('no effect');
expect(stdout).toContain('llms-txt-exists');

stdoutSpy.mockRestore();
stderrSpy.mockRestore();
});

it('warns when a path-prefix --canonical-origin equals the target base', async () => {
server.use(
http.get('http://cmd-canon-eq.local/docs/llms.txt', () => HttpResponse.text(VALID_LLMS_TXT)),
http.get('http://cmd-canon-eq.local/llms.txt', () => HttpResponse.text(VALID_LLMS_TXT)),
);

const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);

const { run } = await import('../../../src/cli/index.js');
await run([
'node',
'afdocs',
'check',
'http://cmd-canon-eq.local/docs',
'--canonical-origin',
'http://cmd-canon-eq.local/docs',
'--checks',
'llms-txt-exists',
'--request-delay',
'0',
]);
await new Promise((r) => setTimeout(r, 100));

const stderr = stderrSpy.mock.calls.map((c) => c[0]).join('');
// Path-prefix canonical resolves to the same base as the target → no-op rewrite.
expect(stderr).toContain('no effect');

stdoutSpy.mockRestore();
Expand Down
65 changes: 65 additions & 0 deletions test/unit/helpers/http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,71 @@ describe('createHttpClient', () => {
expect(text).toContain('https://prod.example.com.evil.com/phishing');
});

it('does not match a longer sub-path that starts with the canonical base', async () => {
const body = [
'https://prod.example.com/docs/guide',
'https://prod.example.com/docsearch/index',
].join('\n');
globalThis.fetch = vi.fn(async () => makeTextResponse(body, { contentType: 'text/plain' }));

const client = createHttpClient({
requestDelay: 0,
requestTimeout: 5000,
maxConcurrency: 10,
canonicalOrigin: 'https://prod.example.com/docs',
targetOrigin: 'https://preview.local/preview',
});
const response = await client.fetch('http://preview.local/preview');
const text = await response.text();

expect(text).toContain('https://preview.local/preview/guide');
// /docsearch must NOT be rewritten by a /docs canonical.
expect(text).toContain('https://prod.example.com/docsearch/index');
});

it('inserts a target containing $ literally (no replacement-pattern interpretation)', async () => {
const body = 'link https://prod.example.com/docs/guide tail';
globalThis.fetch = vi.fn(async () => makeTextResponse(body, { contentType: 'text/plain' }));

const client = createHttpClient({
requestDelay: 0,
requestTimeout: 5000,
maxConcurrency: 10,
canonicalOrigin: 'https://prod.example.com/docs',
targetOrigin: "http://preview.local/a$'b$&c$`d",
});
const text = await (await client.fetch('http://preview.local/x')).text();

expect(text).toBe("link http://preview.local/a$'b$&c$`d/guide tail");
});

it('rewrites base URLs terminated by ) , ? or # (markdown links, prose)', async () => {
const body = [
'[docs](https://prod.example.com)',
'see https://prod.example.com, then',
'query https://prod.example.com?a=1',
'frag https://prod.example.com#top',
].join('\n');
globalThis.fetch = vi.fn(async () =>
makeTextResponse(body, { contentType: 'text/markdown' }),
);

const client = createHttpClient({
requestDelay: 0,
requestTimeout: 5000,
maxConcurrency: 10,
canonicalOrigin: 'https://prod.example.com',
targetOrigin: 'https://preview.local',
});
const text = await (await client.fetch('http://preview.local/x')).text();

expect(text).not.toContain('prod.example.com');
expect(text).toContain('[docs](https://preview.local)');
expect(text).toContain('see https://preview.local, then');
expect(text).toContain('query https://preview.local?a=1');
expect(text).toContain('frag https://preview.local#top');
});

it('returns the same rewritten body on multiple text() calls', async () => {
const body = 'Link: https://prod.example.com/page';
globalThis.fetch = vi.fn(async () => makeTextResponse(body, { contentType: 'text/plain' }));
Expand Down
Loading