diff --git a/.github/workflows/agent-surface.yml b/.github/workflows/agent-surface.yml new file mode 100644 index 0000000..9dc11c7 --- /dev/null +++ b/.github/workflows/agent-surface.yml @@ -0,0 +1,21 @@ +name: Agent surface + +on: + pull_request: + branches: [main, uat] + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run build + - run: node scripts/check-agent-surface.mjs diff --git a/package.json b/package.json index 46148f4..8200e6d 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "main": "index.js", "scripts": { "dev": "next dev", + "prebuild": "node scripts/gen-llms.js", "build": "next build", "start": "next start", "check:pkgnames": "bash scripts/check-dead-package-names.sh", diff --git a/public/llms.txt b/public/llms.txt index 05fc232..12d4cc1 100644 --- a/public/llms.txt +++ b/public/llms.txt @@ -1,114 +1,24 @@ # Affitor Documentation -> Affitor is affiliate program software for SaaS companies — run a partner program with tracked -> click → signup → sale attribution, commission approval, and payout operations in one workflow. -> Two things to know up front: pricing is **$0 until your program earns its first $10,000 through -> affiliates, then 3.5%** of affiliate-driven revenue (no subscription, no setup fee) — and -> **AI coding agents can integrate and verify the tracking end-to-end**. -> Full docs: https://docs.affitor.com +> Documentation for Affitor affiliate programs, tracking, commissions, and payouts. -> **AI coding agents — integration entry point:** follow https://docs.affitor.com/skill.md. -> It is a self-contained runbook for wiring click + lead + sale tracking and -> verifying the integration end-to-end (synthetic chain + readiness polling). +## Start here -## Getting Started +- [Agent integration runbook](https://docs.affitor.com/skill.md): Integrate tracking and verify it end-to-end. +- [Complete documentation](https://docs.affitor.com/llms-full.txt): Every documentation page, blog post, and changelog entry, with page identifiers and source metadata. +- [Sitemap](https://docs.affitor.com/sitemap.xml): HTML pages and their Markdown twins. -- [What is Affitor?](https://docs.affitor.com/): Affitor is an affiliate growth platform for SaaS: $0/month with a 3.5% fee only after your first $10,000 in affiliate revenue, Stripe-native attribution, and an integration an AI agent can complete and verify. -- [Getting Started](https://docs.affitor.com/getting-started): Start here as an advertiser: what Affitor is, how click-to-payout tracking works, and the performance pricing — $0/month, 3.5% only after your first $10,000 in affiliate revenue. -- [How Affitor Works](https://docs.affitor.com/getting-started/how-it-works): From affiliate click to attributed revenue and payout: signup-anchored tracking via server-side API or Stripe metadata, with a verification loop you — or your AI agent — can run before launch. -- [Pricing](https://docs.affitor.com/getting-started/pricing-performance-model): Affitor pricing: $0/month, $0 setup, 3.5% of partner-generated revenue — and your first $10,000 in affiliate-driven revenue is fee-free. -- [Frequently Asked Questions](https://docs.affitor.com/faq): Common questions about Affitor's pricing, tracking, commissions, and integration options. -- [How to Use Affitor](https://docs.affitor.com/getting-started/quickstart): The end-to-end golden path — launch a program, install tracking, and run commissions through to payout. -- [Support](https://docs.affitor.com/support): Get help with Affitor, follow the fastest self-serve path, and know when to contact the team. -- [Contact Us](https://docs.affitor.com/support/contact): Get in touch with the Affitor team for support, partnerships, or press inquiries. -- [Glossary](https://docs.affitor.com/support/glossary): Common terms used across Affitor docs — server-side tracking, Stripe integration, invoice billing, and more -- [System Status](https://docs.affitor.com/support/status): Check the current public status guidance and what to do if you suspect an issue. +## Documentation sections -## For Brands +- [API Reference](https://docs.affitor.com/api-reference/overview.md): Base URL, authentication, request/response envelope, and available endpoints for the Affitor Tracking API. +- [Launch Your Program](https://docs.affitor.com/brand/quickstart.md): Set up your Affitor affiliate program in 20–30 minutes: account, program details, commissions, partner approval, and payouts — $0/month, free until your first $10,000 in affiliate revenue. +- [Frequently Asked Questions](https://docs.affitor.com/faq.md): Common questions about Affitor's pricing, tracking, commissions, and integration options. +- [Getting Started](https://docs.affitor.com/getting-started.md): Start here as an advertiser: what Affitor is, how click-to-payout tracking works, and the performance pricing — $0/month, 3.5% only after your first $10,000 in affiliate revenue. +- [What is Affitor?](https://docs.affitor.com/index.md): Affitor is an affiliate growth platform for SaaS: $0/month with a 3.5% fee only after your first $10,000 in affiliate revenue, Stripe-native attribution, and an integration an AI agent can complete and verify. +- [Become a Partner](https://docs.affitor.com/partners.md): Join a SaaS affiliate program on Affitor, get your referral link, and start earning commission. +- [Support](https://docs.affitor.com/support.md): Get help with Affitor, follow the fastest self-serve path, and know when to contact the team. -- [Launch Your Program](https://docs.affitor.com/brand/quickstart): Set up your Affitor affiliate program in 20–30 minutes: account, program details, commissions, partner approval, and payouts — $0/month, free until your first $10,000 in affiliate revenue. -- [Create your advertiser account](https://docs.affitor.com/brand/quickstart/create-account): Register as an advertiser on Affitor, verify your email, and land in your dashboard ready to set up a program. -- [Set Up Your Program](https://docs.affitor.com/brand/quickstart/setup-program): Configure your affiliate program and choose the right integration path. -- [Define Commission](https://docs.affitor.com/brand/quickstart/define-commission): Set up commission rates and structures for your affiliate program -- [Inviting Partners](https://docs.affitor.com/brand/quickstart/inviting-partners): Recruit affiliates by email or from your other programs, with an invitation written from your program's real terms -- [Partner Approval & Quality Control](https://docs.affitor.com/brand/quickstart/partner-approval-quality-control): Review applications and maintain program quality -- [View Performance](https://docs.affitor.com/brand/quickstart/view-performance): Monitor your affiliate program metrics and partner results -- [Commission Approval & Cash Flow](https://docs.affitor.com/brand/quickstart/commission-approval-cash-flow): Understand how attributed sales move into commissions, billing, and payout workflow. -- [Payouts](https://docs.affitor.com/brand/quickstart/payouts): How partner commissions move through Affitor's payout workflow. -- [CLI Quickstart](https://docs.affitor.com/brand/cli/quickstart): Set up your affiliate program in 3 commands with npx affitor. -- [CLI Command Reference](https://docs.affitor.com/brand/cli/commands): Complete reference for all npx affitor commands, flags, and options. -- [3-Step Integration Guide](https://docs.affitor.com/brand/tracking/quickstart-integration): Get Affitor tracking live with the supported click, signup, and server-side tracking flows -- [Tracking Overview](https://docs.affitor.com/brand/tracking/tracking-overview): How Affitor links clicks, signups, and sales together so every commission is backed by verifiable attribution. -- [Click Tracking](https://docs.affitor.com/brand/tracking/click-tracking): Track affiliate visits with the Affitor tracker SDK or script tag -- [Lead Tracking (Signup)](https://docs.affitor.com/brand/tracking/lead-tracking-signup): Track referred signups with the browser helper or the server-side lead API -- [Payment Tracking](https://docs.affitor.com/brand/tracking/payment-tracking-stripe): Track completed sales with server-side tracking or Stripe integration -- [Payment Flow](https://docs.affitor.com/brand/tracking/payment-flow): End-to-end flow from affiliate click to attributed revenue and payout operations, covering server-side tracking and Stripe integration paths -- [Testing Integration](https://docs.affitor.com/brand/tracking/testing-integration): Verify click, signup, and server-side tracking before going live -- [API Keys & Tokens](https://docs.affitor.com/brand/api-keys): Get the program tracking token and Management API keys you need to track conversions and configure programs programmatically. -- [Overdue Invoices & Program Pause Policy](https://docs.affitor.com/brand/billing/overdue-policy): How Affitor handles unpaid commission invoices, when programs pause, and how to resume. -- [Organize partners into groups](https://docs.affitor.com/brand/groups): Create partner groups so different partners earn different rates, hold periods, and approval rules under one program. -- [Manage program settings and your API key](https://docs.affitor.com/brand/settings): Edit your program's terms after launch and find, copy, or regenerate the per-program API key that authenticates your tracking integration. +## Blog and changelog -## For Partners - -- [Become a Partner](https://docs.affitor.com/partners): Join a SaaS affiliate program on Affitor, get your referral link, and start earning commission. -- [Create Your Partner Account](https://docs.affitor.com/partners/quickstart/create-account): Sign up as a partner, verify your email, and select the Partner role to unlock programs. -- [Find and Join Programs](https://docs.affitor.com/partners/quickstart/find-and-join-programs): Browse the Affitor Marketplace, apply to a program in one click, and get approved. -- [Get Your Referral Link](https://docs.affitor.com/partners/quickstart/get-your-referral-link): Find, copy, and share your tracked referral link — generated automatically when you join. -- [Your Partner Dashboard](https://docs.affitor.com/partners/dashboard): Where to find your programs, performance, referred customers, and earnings. -- [Getting Paid](https://docs.affitor.com/partners/payouts): How commissions turn into a payout — balances, payment methods, withdrawal, and timing. -- [Account and Payout Settings](https://docs.affitor.com/partners/account-settings): Manage your profile, security, and the payout details that decide how you get paid. -- [Partner FAQ](https://docs.affitor.com/partners/faq): Common questions about joining programs, referral links, tracking, and getting paid. -- [Promote Your Link and Earn](https://docs.affitor.com/partners/quickstart/promote-your-link): Turn your referral link into commissions — where to share it, what converts, and how to read the results in your dashboard. -- [Affitor Refer Program — Terms](https://docs.affitor.com/partners/refer-program): Official terms of the Affitor Refer Program: rewards, attribution rules, holds, clawbacks, and eligibility for referring a brand to Affitor. - -## API & Agents - -- [API Reference](https://docs.affitor.com/api-reference/overview): Base URL, authentication, request/response envelope, and available endpoints for the Affitor Tracking API. -- [SDKs](https://docs.affitor.com/api-reference/sdks): Typed client libraries for browser tracking (@affitor/sdk) and server-side conversion reporting (@affitor/sdk/server). -- [MCP Server](https://docs.affitor.com/api-reference/mcp): The @affitor/mcp Model Context Protocol server — let AI agents (Claude Desktop, Cursor) track clicks, leads, sales and refunds, generate per-stack integration plans, and self-verify attribution as tool calls. -- [Agent Integration](https://docs.affitor.com/api-reference/agent-integration): How AI coding agents (Claude Code, Cursor, Copilot, and others) auto-install Affitor tracking using generated instruction files. -- [Attribution Mechanics](https://docs.affitor.com/api-reference/attribution): How Affitor assigns credit to partners — the cookie, the click ID, the attribution windows, and what happens on re-click. -- [Track Click](https://docs.affitor.com/api-reference/track-click): Record an affiliate click event when a visitor lands via a referral link -- [Track Lead](https://docs.affitor.com/api-reference/track-lead): Record a signup or lead event that links a customer to an affiliate click. -- [Track Sale](https://docs.affitor.com/api-reference/track-sale): Record an attributed sale and create the partner commission from your backend -- [Track Refund](https://docs.affitor.com/api-reference/track-refund): Reverse a commission when a sale is refunded -- [Errors & Troubleshooting](https://docs.affitor.com/api-reference/errors): All API error codes, their causes, and how to fix common integration problems. -- [List Tracking Events](https://docs.affitor.com/api-reference/events): Retrieve paginated, merged tracking events (clicks, leads, conversions) for a program -- [Fastify](https://docs.affitor.com/api-reference/integrations/fastify): Server-side Affitor integration with @affitor/sdk/server — track signups and sales from your Fastify backend, including a raw-body Stripe webhook. -- [Next.js](https://docs.affitor.com/api-reference/integrations/nextjs): Add Affitor tracking to a Next.js (App Router) app — clicks, signups, and sales. -- [Next.js + Clerk](https://docs.affitor.com/api-reference/integrations/nextjs-clerk): Add Affitor tracking to a Next.js (App Router) app that uses Clerk for authentication. -- [Next.js + NextAuth](https://docs.affitor.com/api-reference/integrations/nextjs-nextauth): Add Affitor lead tracking to a Next.js app using NextAuth (Auth.js) — handle the click-id cookie gap correctly. -- [Next.js + Supabase Auth](https://docs.affitor.com/api-reference/integrations/nextjs-supabase): Add Affitor tracking to a Next.js app that uses Supabase Auth — clicks, signups, and sales. -- [Node / Express](https://docs.affitor.com/api-reference/integrations/node-express): Server-side Affitor integration with @affitor/sdk/server — track signups and sales from your Express backend. -- [Polar](https://docs.affitor.com/api-reference/integrations/polar): Canonical recipe for attributing Polar payments to Affitor partners — one-time, subscriptions, renewals, and refunds. No Stripe account needed. -- [Stripe](https://docs.affitor.com/api-reference/integrations/stripe): Canonical recipe for attributing Stripe payments to Affitor partners — one-time and recurring. -- [GET /tracking/performance/:programId](https://docs.affitor.com/api-reference/performance): Retrieve daily performance aggregates and period-over-period trend metrics for a program -- [Get Tracking Status](https://docs.affitor.com/api-reference/status): Returns integration step statuses and Stripe connection info for a program - -## Blog - -- [PartnerStack vs Rewardful: Which Should Your SaaS Pick in 2026?](https://affitor.com/blog/partnerstack-vs-rewardful): One costs $1,000/month, the other $49 — they aren't really the same tool. A straight comparison of the enterprise PRM versus the indie affiliate app, with every price verified against both live pages on July 5, 2026, plus Affitor, the $0/month option that starts where they both charge from day one. -- [Affiliate Software Pricing Comparison 2026 (8 Tools, Verified July 2026)](https://affitor.com/blog/affiliate-software-pricing-comparison): Real 2026 pricing for eight affiliate platforms — Affitor, Rewardful, FirstPromoter, Tolt, Post Affiliate Pro, Dub Partners, impact.com, and PartnerStack — every number checked against the vendor's live pricing page on July 5, 2026, with 12-month cost math at three revenue levels. -- [Best Affiliate Software for SaaS in 2026 (7 Tools Compared)](https://affitor.com/blog/best-affiliate-software-saas): Seven affiliate software tools for SaaS compared honestly — Affitor, Rewardful, FirstPromoter, Tolt, Dub Partners, PartnerStack, and impact.com: pricing verified July 2026, attribution trade-offs, and a straight pick for every ARR stage. -- [Best FirstPromoter Alternatives for SaaS in 2026 (5 Tools Compared)](https://affitor.com/blog/firstpromoter-alternatives): Five honest FirstPromoter alternatives for SaaS — Affitor, Rewardful, Tolt, Dub Partners, and PartnerStack: pricing verified July 2026, attribution trade-offs, and a straight answer on who should pick what. -- [Best PartnerStack Alternatives in 2026 (5 Tools Compared)](https://affitor.com/blog/partnerstack-alternatives): PartnerStack now publishes its pricing: from $1,000 per month, paid annually. Here is what that buys, who should genuinely pay it, and five self-serve alternatives with pricing verified in July 2026. -- [Best Rewardful Alternatives for SaaS in 2026 (5 Tools Compared)](https://affitor.com/blog/rewardful-alternatives): Five honest Rewardful alternatives for SaaS on Stripe — Affitor, FirstPromoter, Tolt, Dub Partners, and PartnerStack: pricing verified July 2026, attribution trade-offs, and a straight answer on who should pick what. -- [Best Tolt Alternatives for SaaS in 2026 (5 Tools Compared)](https://affitor.com/blog/tolt-alternatives): Five honest Tolt alternatives for SaaS — Affitor, Rewardful, FirstPromoter, Dub Partners, and PartnerStack: pricing verified July 2026, the 2% payout-fee nuance, attribution trade-offs, and a straight answer on who should pick what. -- [How to Create a Stripe Affiliate Program (Step-by-Step Guide 2026)](https://affitor.com/blog/stripe-affiliate-program): Stripe has no native affiliate feature. Here are the six steps from nothing to a verified Stripe affiliate program — what Stripe provides, which tools fill the gap as of July 2026, and how Affitor ($0/month until your first $10,000 in affiliate revenue) proves the integration works with one command. -- [How to Start an Affiliate Program for Your SaaS (Step-by-Step Guide 2026)](https://affitor.com/blog/how-to-start-saas-affiliate-program): Six steps take you from no program to your first partner payout: commission, software, tracking, recruiting, payout terms, and what to measure — with 2026 benchmarks and pricing verified July 2026. -- [Rewardful vs FirstPromoter: Which Is Best for SaaS in 2026?](https://affitor.com/blog/rewardful-vs-firstpromoter): Same $49 floor, different ceilings: revenue caps, API gating, and billing coverage decide this one. Every number verified against both live pricing pages on July 5, 2026 — plus Affitor, the $0/month option neither compare page mentions. -- [The agent commerce stack is missing its attribution layer](https://affitor.com/blog/agent-commerce-attribution-layer): Agentic commerce standards decide how an agent pays. Affitor decides who earned the commission, and lets the agent prove it. -- [Tolt vs Rewardful: Which Is Best for SaaS in 2026?](https://affitor.com/blog/tolt-vs-rewardful): Rewardful starts $20 cheaper and ships its API on the $49 tier; Tolt gives a third more headroom at $99 and automates global payouts. Every number verified against both live pricing pages on July 5, 2026 — plus Affitor, the $0/month option neither compare page mentions. -- [What Affitor means](https://affitor.com/blog/what-affitor-means): Affitor is affiliate + -tor: an AI-native affiliate platform for SaaS that costs $0 until your first $10,000 in affiliate revenue, then 3.5%, and verifies its own integration. The story behind the name and the bet the company is built on. - -## Changelog - -All entries live on one page: https://docs.affitor.com/changelog - -- [Read honest affiliate-software comparisons on the new Affitor blog (2026-07-06)](https://docs.affitor.com/changelog): Twelve posts comparing the tools you're actually evaluating — every price dated "as of," competitor strengths conceded up front, and .md twins plus llms.txt so your AI assistant reads the same facts you do. -- [Invite partners — with the email already written (2026-07-05)](https://docs.affitor.com/changelog): Recruit affiliates by email, CSV, or from your other programs — and send an invitation generated from your program's real terms instead of writing one from scratch. -- [Faster, less noisy docs search (2026-04-25)](https://docs.affitor.com/changelog): Search now matches page titles only — no more wading through paragraph snippets to find what you need. -- [Content Lab — write affiliate posts in one click (2026-04-20)](https://docs.affitor.com/changelog): Pick a program, get a publish-ready affiliate post in one click — free, 30 posts a day. -- [Partner profile and security settings (2026-04-10)](https://docs.affitor.com/changelog): Partners can now update their profile, avatar, and password without contacting support. -- [47 agent skills for affiliate workflows (2026-03-28)](https://docs.affitor.com/changelog): Run common affiliate workflows — researching programs, drafting content, analyzing traffic — directly from your AI assistant. +- [Blog](https://affitor.com/blog): The canonical blog home. All posts are included in the complete documentation above. +- [Changelog](https://docs.affitor.com/changelog.md): All changelog entries in Markdown. diff --git a/public/robots.txt b/public/robots.txt index 6ade2e2..57492eb 100644 --- a/public/robots.txt +++ b/public/robots.txt @@ -1,5 +1,36 @@ User-agent: * Allow: / -Sitemap: https://docs.affitor.com/sitemap-index.xml +Sitemap: https://docs.affitor.com/sitemap.xml Llms-Txt: https://docs.affitor.com/llms.txt +Llms-Full-Txt: https://docs.affitor.com/llms-full.txt + +User-agent: GPTBot +Allow: / + +User-agent: ClaudeBot +Allow: / + +User-agent: Claude-Web +Allow: / + +User-agent: PerplexityBot +Allow: / + +User-agent: Google-Extended +Allow: / + +User-agent: Bytespider +Allow: / + +User-agent: CCBot +Allow: / + +User-agent: anthropic-ai +Allow: / + +User-agent: OAI-SearchBot +Allow: / + +User-agent: ChatGPT-User +Allow: / diff --git a/scripts/check-agent-surface.mjs b/scripts/check-agent-surface.mjs new file mode 100644 index 0000000..7870e89 --- /dev/null +++ b/scripts/check-agent-surface.mjs @@ -0,0 +1,253 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { readdirSync, readFileSync } from 'node:fs'; +import { dirname, join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { loader } from 'fumadocs-core/source'; +import { load, JSON_SCHEMA } from 'js-yaml'; +import { generateLlms } from './gen-llms.js'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const APP = join(ROOT, '.next/server/app'); +const SITE = 'https://docs.affitor.com'; +const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/; +const ISO_DAY = /^\d{4}-\d{2}-\d{2}$/; +const read = (path) => readFileSync(path, 'utf8'); +const json = (path) => JSON.parse(read(path)); + +function walk(dir) { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const path = join(dir, entry.name); + return entry.isDirectory() ? walk(path) : [path]; + }).sort(); +} + +function document(file) { + const raw = read(file); + const match = raw.match(FRONTMATTER); + assert.ok(match, `Missing source frontmatter: ${relative(ROOT, file)}`); + return { + ...load(match[1], { schema: JSON_SCHEMA }), + raw, + file, + }; +} + +function markdown(data, fallbackTitle) { + const title = data.title ?? fallbackTitle; + const head = data.description ? `# ${title}\n\n> ${data.description}` : `# ${title}`; + return `${head}\n\n${data.raw.replace(FRONTMATTER, '').trim()}\n`; +} + +function inventory() { + const docsDir = join(ROOT, 'content/docs'); + // Use the installed loader's URL rules without importing the production renderer. + const files = walk(docsDir).filter((file) => /\.mdx?$/.test(file) || /\/meta\.json$/.test(file)); + const docs = loader({ + baseUrl: '/', + source: { + files: files.map((file) => ({ + type: file.endsWith('.json') ? 'meta' : 'page', + path: relative(docsDir, file), + absolutePath: file, + data: file.endsWith('.json') ? json(file) : document(file), + })), + }, + }).getPages().map((page) => { + const id = page.url.slice(1) || 'index'; + assert.equal(page.data.date, undefined, `${id}: docs must not invent a date field`); + assert.equal(page.data.updated, undefined, `${id}: docs must not invent an updated field`); + return { + id, type: 'doc', url: `${SITE}${page.url}`, + body: markdown(page.data, id), title: page.data.title, description: page.data.description, + }; + }); + const blogDir = join(ROOT, 'content/blog'); + const posts = walk(blogDir).filter((file) => /\.mdx?$/.test(file)).map((file) => { + const data = document(file); + const id = `blog/${relative(blogDir, file).replace(/\.mdx$/, '')}`; + assert.equal(typeof data.date, 'string', `${id}: missing frontmatter date`); + assert.match(data.date, ISO_DAY, `${id}: date must be YYYY-MM-DD`); + if (data.updated !== undefined) { + assert.equal(typeof data.updated, 'string', `${id}: updated must be a string`); + assert.match(data.updated, ISO_DAY, `${id}: updated must be YYYY-MM-DD`); + } + return { + id, type: 'blog', url: `https://affitor.com/${id}`, + updated: data.updated ?? data.date, + sourceDate: data.updated ?? data.date, + body: markdown(data, id), + }; + }); + const changes = walk(join(ROOT, 'content/changelog')).filter((file) => /\.mdx?$/.test(file)) + .map(document).sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); + assert.ok(changes.length, 'Changelog source is empty'); + for (const entry of changes) { + assert.equal(typeof entry.date, 'string', `${relative(ROOT, entry.file)}: missing date`); + assert.match(entry.date, ISO_DAY, `${relative(ROOT, entry.file)}: date must be YYYY-MM-DD`); + } + const sections = changes.map((entry) => + `## ${entry.title} (${entry.date})\n\n> ${entry.benefit}\n\n${entry.raw.replace(FRONTMATTER, '').trim()}`, + ); + const changelogUpdated = changes.map((entry) => entry.date).sort().at(-1); + return [...docs, ...posts, { + id: 'changelog', type: 'changelog', url: `${SITE}/changelog`, + updated: changelogUpdated, sourceDate: changelogUpdated, + body: `# Affitor Changelog\n\n${sections.join('\n\n---\n\n')}\n`, + }]; +} + +function decodeEntities(text) { + return text.replace(/&(#x[\da-f]+|#\d+|amp|lt|gt|quot|apos);/gi, (_, entity) => { + if (entity.startsWith('#x')) return String.fromCodePoint(parseInt(entity.slice(2), 16)); + if (entity.startsWith('#')) return String.fromCodePoint(Number(entity.slice(1))); + return { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" }[entity]; + }); +} + +function checkHtml(page, route) { + const html = read(join(APP, `${route === '/' ? 'index' : route.slice(1)}.html`)); + const articles = [...html.matchAll(/]*\bdata-page-id="([^"]*)"[^>]*>/g)]; + assert.equal(articles.length, 1, `${route}: expected one article with data-page-id`); + assert.equal(decodeEntities(articles[0][1]), page.id, `${route}: article id`); + const schemas = [...html.matchAll(/]*\btype="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/g)] + .map((match) => JSON.parse(match[1])).filter((schema) => schema['@type'] === 'TechArticle'); + assert.equal(schemas.length, 1, `${route}: expected one TechArticle`); + const schema = schemas[0]; + for (const [key, expected] of Object.entries({ + '@context': 'https://schema.org', '@id': page.url, identifier: page.id, + headline: page.title, description: page.description, + })) { + assert.equal(typeof schema[key], 'string', `${route}: missing TechArticle ${key}`); + assert.equal(schema[key], expected, `${route}: TechArticle ${key}`); + } + assert.equal('dateModified' in schema, false, `${route}: docs TechArticle must omit dateModified`); +} + +function main() { + const pages = inventory(); + const ids = pages.map((page) => page.id); + assert.equal(new Set(ids).size, ids.length, 'Duplicate source page ids'); + const manifest = json(join(ROOT, '.next/prerender-manifest.json')); + const twinRoutes = Object.keys(manifest.routes).filter((route) => route.startsWith('/llms.md/')); + assert.deepEqual(twinRoutes.sort(), ids.map((id) => `/llms.md/${id}`).sort(), 'Prerendered twin inventory differs from source'); + + function artifact(route, contentType) { + const record = manifest.routes[route]; + assert.ok(record, `${route}: missing prerender manifest entry`); + assert.equal(record.initialStatus ?? 200, 200, `${route}: manifest status`); + const meta = json(join(APP, `${route.slice(1)}.meta`)); + assert.equal(meta.status, 200, `${route}: artifact status`); + assert.equal(meta.headers['content-type']?.split(';')[0], contentType, `${route}: content type`); + return read(join(APP, `${route.slice(1)}.body`)); + } + + const sitemap = artifact('/sitemap.xml', 'application/xml'); + const entries = [...sitemap.matchAll(/([\s\S]*?)<\/url>/g)].map((match) => { + const loc = [...match[1].matchAll(/([\s\S]*?)<\/loc>/g)]; + const lastmod = [...match[1].matchAll(/([\s\S]*?)<\/lastmod>/g)]; + assert.equal(loc.length, 1, 'Sitemap entry must have one loc'); + assert.ok(lastmod.length <= 1, 'Sitemap entry must have at most one lastmod'); + return { + url: decodeEntities(loc[0][1]), + lastmod: lastmod.length ? lastmod[0][1] : undefined, + }; + }); + const expectedUrls = pages.flatMap((page) => [page.url, `${SITE}/${page.id}.md`]); + expectedUrls.push(`${SITE}/llms.txt`, `${SITE}/llms-full.txt`); + assert.deepEqual(entries.map((entry) => entry.url).sort(), expectedUrls.sort(), 'Sitemap URL inventory differs from source'); + const dates = new Map(entries.map((entry) => [entry.url, entry.lastmod])); + const datedPages = pages.filter((page) => page.updated); + const latest = datedPages.map((page) => page.updated).sort().at(-1); + for (const name of ['llms.txt', 'llms-full.txt']) { + assert.equal(dates.get(`${SITE}/${name}`), latest, `${name}: sitemap date`); + } + + // Reject file-mtime / build-time ISO timestamps anywhere a content date appears. + const BUILD_TIME = /^\d{4}-\d{2}-\d{2}T/; + for (const entry of entries) { + if (entry.lastmod) { + assert.match(entry.lastmod, ISO_DAY, `${entry.url}: lastmod must be a real YYYY-MM-DD date`); + assert.doesNotMatch(entry.lastmod, BUILD_TIME, `${entry.url}: lastmod must not be build/mtime ISO`); + } + } + + let corpus = artifact('/llms-full.txt', 'text/plain'); + for (const page of pages) { + const twin = artifact(`/llms.md/${page.id}`, 'text/markdown'); + const header = twin.match(FRONTMATTER); + assert.ok(header, `${page.id}: missing twin frontmatter`); + const metadata = load(header[1], { schema: JSON_SCHEMA }); + const htmlLastmod = dates.get(page.url); + const mdLastmod = dates.get(`${SITE}/${page.id}.md`); + + if (page.type === 'doc') { + assert.equal(page.updated, undefined, `${page.id}: docs inventory must omit updated`); + assert.equal('updated' in metadata, false, `${page.id}: docs twin must omit updated`); + assert.equal(htmlLastmod, undefined, `${page.id}: docs sitemap HTML must omit lastmod`); + assert.equal(mdLastmod, undefined, `${page.id}: docs sitemap .md must omit lastmod`); + assert.deepEqual(metadata, { id: page.id, type: page.type, url: page.url }, `${page.id}: twin metadata`); + } else { + assert.equal(typeof page.sourceDate, 'string', `${page.id}: missing source frontmatter date`); + assert.equal(metadata.updated, page.sourceDate, `${page.id}: twin updated must equal source frontmatter`); + assert.equal(htmlLastmod, page.sourceDate, `${page.id}: sitemap lastmod must equal source frontmatter`); + assert.equal(mdLastmod, page.sourceDate, `${page.id}: .md sitemap lastmod must equal source frontmatter`); + assert.equal(metadata.updated, htmlLastmod, `${page.id}: twin updated must equal sitemap lastmod`); + assert.deepEqual( + metadata, + { id: page.id, type: page.type, url: page.url, updated: page.sourceDate }, + `${page.id}: twin metadata`, + ); + assert.match(metadata.updated, ISO_DAY, `${page.id}: updated must be YYYY-MM-DD`); + assert.doesNotMatch(metadata.updated, BUILD_TIME, `${page.id}: updated must not be build/mtime ISO`); + } + + assert.equal(twin.slice(header[0].length), page.body, `${page.id}: twin differs from original source body`); + const position = corpus.indexOf(twin); + assert.ok(position >= 0, `${page.id}: complete twin missing from llms-full.txt`); + assert.equal(corpus.indexOf(twin, position + twin.length), -1, `${page.id}: duplicate twin in llms-full.txt`); + corpus = corpus.slice(0, position) + corpus.slice(position + twin.length); + if (page.type === 'doc') { + const path = new URL(page.url).pathname; + checkHtml(page, path); + checkHtml(page, path === '/' ? '/docs' : `/docs${path}`); + } + console.log(`PASS ${page.id}: twin, source body, sitemap dates, full corpus${page.type === 'doc' ? ', canonical and legacy HTML' : ''}`); + } + assert.match(corpus, /^\n*$/, 'llms-full.txt contains unexpected content or extra pages'); + + const llms = read(join(ROOT, 'public/llms.txt')); + assert.equal(llms, generateLlms(), 'public/llms.txt is stale; run npm run gen:llms'); + assert.ok(Buffer.byteLength(llms) < 10000, 'public/llms.txt must be under 10000 bytes'); + for (const match of llms.matchAll(/\]\(https:\/\/docs\.affitor\.com\/([^\s)]+)\.md\)/g)) { + if (match[1] === 'skill') continue; + assert.ok(ids.includes(match[1]), `llms.txt links unknown twin ${match[1]}`); + } + const robots = read(join(ROOT, 'public/robots.txt')); + const middleware = read(join(ROOT, 'src/middleware.ts')); + const botPattern = middleware.match(/const AI_BOTS\s*=\s*\/([^/]+)\/i/); + assert.ok(botPattern, 'Cannot read middleware AI_BOTS'); + for (const bot of ['*', ...botPattern[1].split('|'), 'ChatGPT-User']) { + assert.ok(robots.split(/\r?\n\s*\r?\n/).some((block) => { + const lines = block.split(/\r?\n/); + return lines.includes(`User-agent: ${bot}`) && lines.includes('Allow: /'); + }), `robots.txt missing Allow block for ${bot}`); + } + for (const line of [ + `Sitemap: ${SITE}/sitemap.xml`, `Llms-Txt: ${SITE}/llms.txt`, `Llms-Full-Txt: ${SITE}/llms-full.txt`, + ]) assert.ok(robots.split(/\r?\n/).includes(line), `robots.txt missing ${line}`); + + // Ensure the renderer no longer imports file mtime for content dates. + const agentMd = read(join(ROOT, 'src/lib/agent-md.ts')); + assert.equal(agentMd.includes('statSync'), false, 'agent-md.ts must not use statSync for content dates'); + assert.equal(agentMd.includes('mtime'), false, 'agent-md.ts must not use mtime for content dates'); + + console.log(`PASS agent surface: ${pages.length} pages, ${entries.length} sitemap URLs, ${datedPages.length} dated pages, ${Buffer.byteLength(llms)} llms.txt bytes; source parity, metadata, HTML, full corpus, freshness, and robots verified.`); +} + +try { + main(); +} catch (error) { + console.error(`FAIL agent surface: ${error.message}`); + process.exitCode = 1; +} diff --git a/scripts/gen-llms.js b/scripts/gen-llms.js index b9a1cef..730e62d 100644 --- a/scripts/gen-llms.js +++ b/scripts/gen-llms.js @@ -1,15 +1,7 @@ #!/usr/bin/env node -/** - * gen-llms.js — regenerate public/llms.txt from content frontmatter. - * - * Walks content/docs/**, content/blog/**, content/changelog/** and emits one - * benefit-first line per page: `- [Title](url): description`. Run after adding - * or renaming content so llms.txt never goes stale: - * - * npm run gen:llms - */ +/** Regenerate the compact agent entry point from documentation section indexes. */ import { readdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { join, relative } from 'node:path'; +import { join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const ROOT = join(fileURLToPath(import.meta.url), '..', '..'); @@ -17,12 +9,10 @@ const SITE = 'https://docs.affitor.com'; // Blog is proxied at affitor.com/blog (multi-zone) — that's its canonical home. const BLOG_SITE = 'https://affitor.com/blog'; -// --------------------------------------------------------------------------- -// Frontmatter (flat `key: value` lines only — all our content uses this shape) -// --------------------------------------------------------------------------- +// These section indexes use flat, single-line frontmatter values. function frontmatter(file) { const src = readFileSync(file, 'utf8'); - const m = src.match(/^---\n([\s\S]*?)\n---/); + const m = src.match(/^---\r?\n([\s\S]*?)\r?\n---/); const out = {}; if (!m) return out; for (const line of m[1].split('\n')) { @@ -38,14 +28,13 @@ function frontmatter(file) { } function walk(dir) { - return readdirSync(dir, { withFileTypes: true }).flatMap((e) => { - const p = join(dir, e.name); - if (e.isDirectory()) return walk(p); - return /\.mdx?$/.test(e.name) ? [p] : []; + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const path = join(dir, entry.name); + if (entry.isDirectory()) return walk(path); + return /\.mdx?$/.test(entry.name) ? [path] : []; }); } -// content/docs/brand/cli/quickstart.mdx -> brand/cli/quickstart ; index.mdx -> dir function docSlug(file) { return relative(join(ROOT, 'content/docs'), file) .replace(/\.mdx?$/, '') @@ -53,138 +42,44 @@ function docSlug(file) { .replace(/\/$/, ''); } -// --------------------------------------------------------------------------- -// Curation: section routing, reading-order pins, sharpened descriptions. -// Anything new is picked up automatically and sorted after pinned pages. -// --------------------------------------------------------------------------- -const SECTIONS = [ - { name: 'Getting Started', match: (s) => s === '' || /^(getting-started|faq|support)(\/|$)/.test(s) }, - { name: 'For Brands', match: (s) => /^brand(\/|$)/.test(s) }, - { name: 'For Partners', match: (s) => /^partners(\/|$)/.test(s) }, - { name: 'API & Agents', match: (s) => /^api-reference(\/|$)/.test(s) }, -]; - -// Lower = earlier. Unlisted slugs get 500 and sort alphabetically after these. -const PIN = { - '': 0, - 'getting-started': 1, - 'getting-started/how-it-works': 2, - 'getting-started/pricing-performance-model': 3, - 'faq': 4, - - 'brand/quickstart': 10, - 'brand/quickstart/create-account': 11, - 'brand/quickstart/setup-program': 12, - 'brand/quickstart/define-commission': 13, - 'brand/quickstart/inviting-partners': 14, - 'brand/quickstart/partner-approval-quality-control': 15, - 'brand/quickstart/view-performance': 16, - 'brand/quickstart/commission-approval-cash-flow': 17, - 'brand/quickstart/payouts': 18, - 'brand/cli/quickstart': 30, - 'brand/cli/commands': 31, - 'brand/tracking/quickstart-integration': 40, - 'brand/tracking/tracking-overview': 41, - 'brand/tracking/click-tracking': 42, - 'brand/tracking/lead-tracking-signup': 43, - 'brand/tracking/payment-tracking-stripe': 44, - 'brand/tracking/payment-flow': 45, - 'brand/tracking/testing-integration': 46, - - 'partners': 10, - 'partners/quickstart/create-account': 11, - 'partners/quickstart/find-and-join-programs': 12, - 'partners/quickstart/get-your-referral-link': 13, - 'partners/dashboard': 14, - 'partners/payouts': 15, - - 'api-reference/overview': 10, - 'api-reference/sdks': 11, - 'api-reference/mcp': 12, - 'api-reference/agent-integration': 13, - 'api-reference/attribution': 14, - 'api-reference/track-click': 20, - 'api-reference/track-lead': 21, - 'api-reference/track-sale': 22, - 'api-reference/track-refund': 23, -}; - -// Benefit-first rewrites for pages whose frontmatter description is still vague. -// Prefer sharpening the frontmatter itself; use this only when that isn't possible. -const OVERRIDES = { - 'brand/tracking/tracking-overview': 'How Affitor links clicks, signups, and sales together so every commission is backed by verifiable attribution.', -}; - -function line(title, url, desc) { - return `- [${title}](${url}): ${desc}`; -} - -function sortDocs(a, b) { - const wa = PIN[a.slug] ?? 500; - const wb = PIN[b.slug] ?? 500; - return wa - wb || a.slug.localeCompare(b.slug); -} +export function generateLlms() { + const files = walk(join(ROOT, 'content/docs')).sort(); + const hasApiIndex = files.some((file) => /\/api-reference\/index\.mdx?$/.test(file)); + const sections = files.filter((file) => + /\/index\.mdx?$/.test(file) || (!hasApiIndex && docSlug(file) === 'api-reference/overview'), + ).map((file) => { + const metadata = frontmatter(file); + const slug = docSlug(file) || 'index'; + return `- [${metadata.title}](${SITE}/${slug}.md)${metadata.description ? `: ${metadata.description}` : ''}`; + }); -// --------------------------------------------------------------------------- -// Collect -// --------------------------------------------------------------------------- -const docs = walk(join(ROOT, 'content/docs')).map((f) => { - const fm = frontmatter(f); - const slug = docSlug(f); - return { - slug, - title: fm.title, - desc: OVERRIDES[slug] ?? fm.description ?? '', - url: slug === '' ? `${SITE}/` : `${SITE}/${slug}`, - }; -}); + return `# Affitor Documentation -const blog = walk(join(ROOT, 'content/blog')) - .map((f) => { - const fm = frontmatter(f); - const slug = relative(join(ROOT, 'content/blog'), f).replace(/\.mdx?$/, ''); - return { title: fm.title, desc: fm.description ?? '', date: fm.date ?? '', url: `${BLOG_SITE}/${slug}` }; - }) - .sort((a, b) => b.date.localeCompare(a.date) || a.title.localeCompare(b.title)); +> Documentation for Affitor affiliate programs, tracking, commissions, and payouts. -// Changelog is a single page (no per-entry routes) — list entries, link the page. -const changelog = walk(join(ROOT, 'content/changelog')) - .map((f) => { - const fm = frontmatter(f); - return { title: fm.title, desc: fm.benefit ?? fm.description ?? '', date: fm.date ?? '' }; - }) - .sort((a, b) => b.date.localeCompare(a.date) || a.title.localeCompare(b.title)); +## Start here -// --------------------------------------------------------------------------- -// Emit -// --------------------------------------------------------------------------- -const out = []; +- [Agent integration runbook](${SITE}/skill.md): Integrate tracking and verify it end-to-end. +- [Complete documentation](${SITE}/llms-full.txt): Every documentation page, blog post, and changelog entry, with page identifiers and source metadata. +- [Sitemap](${SITE}/sitemap.xml): HTML pages and their Markdown twins. -out.push(`# Affitor Documentation +## Documentation sections -> Affitor is affiliate program software for SaaS companies — run a partner program with tracked -> click → signup → sale attribution, commission approval, and payout operations in one workflow. -> Two things to know up front: pricing is **$0 until your program earns its first $10,000 through -> affiliates, then 3.5%** of affiliate-driven revenue (no subscription, no setup fee) — and -> **AI coding agents can integrate and verify the tracking end-to-end**. -> Full docs: ${SITE} +${sections.join('\n')} -> **AI coding agents — integration entry point:** follow ${SITE}/skill.md. -> It is a self-contained runbook for wiring click + lead + sale tracking and -> verifying the integration end-to-end (synthetic chain + readiness polling).`); +## Blog and changelog -for (const section of SECTIONS) { - const pages = docs.filter((d) => section.match(d.slug)).sort(sortDocs); - out.push(`\n## ${section.name}\n`); - out.push(pages.map((p) => line(p.title, p.url, p.desc)).join('\n')); +- [Blog](${BLOG_SITE}): The canonical blog home. All posts are included in the complete documentation above. +- [Changelog](${SITE}/changelog.md): All changelog entries in Markdown. +`; } -out.push('\n## Blog\n'); -out.push(blog.map((p) => line(p.title, p.url, p.desc)).join('\n')); - -out.push('\n## Changelog\n'); -out.push(`All entries live on one page: ${SITE}/changelog\n`); -out.push(changelog.map((p) => line(`${p.title} (${p.date})`, `${SITE}/changelog`, p.desc)).join('\n')); - -writeFileSync(join(ROOT, 'public/llms.txt'), out.join('\n') + '\n'); -console.log(`public/llms.txt regenerated: ${docs.length} docs, ${blog.length} blog posts, ${changelog.length} changelog entries.`); +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const output = generateLlms(); + if (process.argv.includes('--stdout')) { + process.stdout.write(output); + } else { + writeFileSync(join(ROOT, 'public/llms.txt'), output); + console.log(`public/llms.txt regenerated: ${Buffer.byteLength(output)} bytes.`); + } +} diff --git a/src/app/[...slug]/page.tsx b/src/app/[...slug]/page.tsx index 701ef9f..2798541 100644 --- a/src/app/[...slug]/page.tsx +++ b/src/app/[...slug]/page.tsx @@ -1,4 +1,5 @@ import { source } from '@/lib/source'; +import { getDocArticle } from '@/lib/agent-md'; import { DocsPage, DocsBody, DocsDescription, DocsTitle } from 'fumadocs-ui/page'; import { EditOnGitHub } from 'fumadocs-ui/layouts/docs/page'; import { notFound } from 'next/navigation'; @@ -12,13 +13,15 @@ export default async function Page(props: { params: Promise<{ slug: string[] }> const page = source.getPage(params.slug); if (!page) notFound(); + const article = getDocArticle(page); const MDX = page.data.body; const slugPath = params.slug.join('/'); const filePath = `content/docs/${slugPath}.mdx`; const editUrl = `${GITHUB_REPO}/edit/main/${filePath}`; return ( - + +