From 7e44bebfc796a1fd13cbfad525e7c9850eead654 Mon Sep 17 00:00:00 2001 From: Shai Revivo Date: Thu, 27 Aug 2026 18:08:00 +0300 Subject: [PATCH 1/4] feat(#2996): add SEO metadata to docs site Improve search indexing and social sharing for the VitePress docs site by adding a sitemap, canonical URLs, and Open Graph / Twitter Card tags. - docs/.vitepress/seo.ts: pure helpers for canonical/OG URLs and head tags, unit-tested in seo.test.ts. Docs are served under a /docs/ base, so all absolute URLs (canonical, og:url, sitemap) include that segment. The sitemap hostname carries the base with a trailing slash because VitePress resolves base-less page paths against it. - config.ts: emit sitemap.xml, global og/twitter head tags, and per-page canonical + og tags via transformHead. The 404 page is excluded from self-canonical/og:url so crawlers do not treat it as indexable. - robots.txt: advertise the sitemap location. - Add unique description frontmatter to key landing pages (getting started, agents overview and each agent, architecture, vision); other pages fall back to the site description. Verified with a full vitepress build: 190 sitemap entries, all under https://fullsend.sh/docs/, with per-page canonical and og tags present. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Shai Revivo --- cloudflare_site/public/robots.txt | 2 + docs/.vitepress/config.ts | 15 ++++ docs/.vitepress/seo.test.ts | 107 ++++++++++++++++++++++++++ docs/.vitepress/seo.ts | 97 +++++++++++++++++++++++ docs/agents/README.md | 4 + docs/agents/code.md | 4 + docs/agents/fix.md | 4 + docs/agents/prioritize.md | 4 + docs/agents/retro.md | 4 + docs/agents/review.md | 4 + docs/agents/triage.md | 4 + docs/architecture.md | 4 + docs/guides/getting-started/README.md | 1 + docs/vision.md | 4 + 14 files changed, 258 insertions(+) create mode 100644 docs/.vitepress/seo.test.ts create mode 100644 docs/.vitepress/seo.ts diff --git a/cloudflare_site/public/robots.txt b/cloudflare_site/public/robots.txt index 0d7904bf8e..2b9cff0070 100644 --- a/cloudflare_site/public/robots.txt +++ b/cloudflare_site/public/robots.txt @@ -1,3 +1,5 @@ +Sitemap: https://fullsend.sh/docs/sitemap.xml + # Training crawlers — blocked site-wide User-agent: ClaudeBot Disallow: / diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index e8ee68efac..d5986b08fc 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -2,6 +2,7 @@ import { defineConfig } from "vitepress"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { DOCS_URL_BASE, globalSeoHead, isIndexablePage, pageSeoHead } from "./seo"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const docsDir = path.resolve(__dirname, ".."); @@ -158,8 +159,22 @@ export default defineConfig({ href: "https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;700&display=swap", }, ], + // Site-wide SEO metadata (Open Graph type/site name/image + Twitter card). + ...globalSeoHead, ], + // Emit sitemap.xml for the docs. The hostname carries the /docs/ base (and a + // trailing slash) because VitePress resolves base-less page paths against it. + sitemap: { + hostname: DOCS_URL_BASE, + }, + + // Per-page canonical + Open Graph tags derived from the resolved page data. + transformHead({ page, title, description, siteConfig }) { + if (!isIndexablePage(page)) return []; + return pageSeoHead({ page, title, description, cleanUrls: siteConfig.cleanUrls }); + }, + srcExclude: ["**/agents/icons/**", "**/testing/**"], ignoreDeadLinks: true, diff --git a/docs/.vitepress/seo.test.ts b/docs/.vitepress/seo.test.ts new file mode 100644 index 0000000000..52b7e6111c --- /dev/null +++ b/docs/.vitepress/seo.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; +import { + DOCS_URL_BASE, + canonicalUrl, + globalSeoHead, + isIndexablePage, + pageOutputPath, + pageSeoHead, +} from "./seo"; + +describe("pageOutputPath", () => { + it("maps the root index to the empty (directory) path", () => { + expect(pageOutputPath("index.md")).toBe(""); + expect(pageOutputPath("README.md")).toBe(""); + }); + + it("maps a nested index/README to its directory", () => { + expect(pageOutputPath("agents/index.md")).toBe("agents/"); + expect(pageOutputPath("guides/getting-started/README.md")).toBe("guides/getting-started/"); + }); + + it("maps a content page to an .html path when cleanUrls is off", () => { + expect(pageOutputPath("agents/triage.md")).toBe("agents/triage.html"); + expect(pageOutputPath("guides/user/jira-integration.md")).toBe( + "guides/user/jira-integration.html", + ); + }); + + it("drops the extension entirely when cleanUrls is on", () => { + expect(pageOutputPath("agents/triage.md", true)).toBe("agents/triage"); + expect(pageOutputPath("index.md", true)).toBe(""); + }); +}); + +describe("canonicalUrl", () => { + it("always includes the /docs/ base", () => { + // Regression guard: the docs are served under /docs/, so a base-less + // hostname (as in the original issue template) would drop that segment. + for (const page of ["index.md", "agents/triage.md", "guides/getting-started/README.md"]) { + expect(canonicalUrl(page).startsWith(DOCS_URL_BASE)).toBe(true); + } + }); + + it("resolves the root to the docs base URL", () => { + expect(canonicalUrl("index.md")).toBe("https://fullsend.sh/docs/"); + }); + + it("resolves a directory index to a trailing-slash URL", () => { + expect(canonicalUrl("agents/index.md")).toBe("https://fullsend.sh/docs/agents/"); + }); + + it("resolves a content page to its .html URL", () => { + expect(canonicalUrl("guides/user/jira-integration.md")).toBe( + "https://fullsend.sh/docs/guides/user/jira-integration.html", + ); + }); +}); + +describe("pageSeoHead", () => { + const head = pageSeoHead({ + page: "agents/triage.md", + title: "Triage Agent | Fullsend", + description: "How the triage agent works", + }); + + it("emits a canonical link and og:url with the full docs URL", () => { + const url = "https://fullsend.sh/docs/agents/triage.html"; + expect(head).toContainEqual(["link", { rel: "canonical", href: url }]); + expect(head).toContainEqual(["meta", { property: "og:url", content: url }]); + }); + + it("emits page-specific og:title and og:description", () => { + expect(head).toContainEqual([ + "meta", + { property: "og:title", content: "Triage Agent | Fullsend" }, + ]); + expect(head).toContainEqual([ + "meta", + { property: "og:description", content: "How the triage agent works" }, + ]); + }); +}); + +describe("isIndexablePage", () => { + it("excludes the 404 page from self-canonical / OG tags", () => { + expect(isIndexablePage("404.md")).toBe(false); + }); + + it("treats content pages as indexable", () => { + expect(isIndexablePage("index.md")).toBe(true); + expect(isIndexablePage("agents/triage.md")).toBe(true); + }); +}); + +describe("globalSeoHead", () => { + it("declares site-wide Open Graph and Twitter card metadata", () => { + expect(globalSeoHead).toContainEqual(["meta", { property: "og:type", content: "website" }]); + expect(globalSeoHead).toContainEqual([ + "meta", + { property: "og:site_name", content: "Fullsend" }, + ]); + expect(globalSeoHead).toContainEqual([ + "meta", + { name: "twitter:card", content: "summary_large_image" }, + ]); + }); +}); diff --git a/docs/.vitepress/seo.ts b/docs/.vitepress/seo.ts new file mode 100644 index 0000000000..067de5642f --- /dev/null +++ b/docs/.vitepress/seo.ts @@ -0,0 +1,97 @@ +import type { HeadConfig } from "vitepress"; + +// SEO metadata helpers for the VitePress docs site. +// +// The docs are served under a base path — https://fullsend.sh/docs/ — so every +// absolute URL we emit (canonical, og:url, sitemap entries) must include that +// `/docs/` segment. VitePress 1.6.x builds sitemap entries as base-less relative +// paths and resolves them against `sitemap.hostname`, so the hostname itself must +// carry the base and a trailing slash. The same rule applies to URLs we resolve +// here, which is why DOCS_URL_BASE ends in a slash. + +/** Public origin of the site. */ +export const SITE_ORIGIN = "https://fullsend.sh"; + +/** Base path the docs are served under (matches `base` in config.ts). */ +export const DOCS_BASE = "/docs/"; + +/** + * Absolute base for canonical/OG URLs and the sitemap `hostname`. + * The trailing slash is required for correct `new URL(relative, base)` resolution. + */ +export const DOCS_URL_BASE = `${SITE_ORIGIN}${DOCS_BASE}`; + +/** + * Default social-share image (absolute URL). This is the square brand logo; a + * dedicated 1200×630 asset would render better on cards and is a good follow-up. + */ +export const OG_IMAGE = `${DOCS_URL_BASE}img/logo.png`; + +/** + * Convert a VitePress page path (the post-rewrite source path, e.g. "index.md", + * "agents/index.md", "guides/user/jira-integration.md") into the site-relative + * output path VitePress actually serves, mirroring its sitemap/link logic: + * - `index.md` / `README.md` → the containing directory ("" for the root) + * - other `*.md` → `*.html` (or the bare path when `cleanUrls` is enabled) + * + * The result is a base-less relative path, suitable for resolving against + * {@link DOCS_URL_BASE}. + */ +export function pageOutputPath(page: string, cleanUrls = false): string { + const asDir = page.replace(/(^|\/)(index|README)\.md$/, "$1"); + if (asDir !== page) return asDir; + return page.replace(/\.md$/, cleanUrls ? "" : ".html"); +} + +/** Absolute canonical URL for a page, including the `/docs/` base. */ +export function canonicalUrl(page: string, cleanUrls = false): string { + return new URL(pageOutputPath(page, cleanUrls), DOCS_URL_BASE).href; +} + +export interface PageSeoInput { + /** Post-rewrite source path of the page (VitePress `TransformContext.page`). */ + page: string; + /** Resolved page title (VitePress `TransformContext.title`). */ + title: string; + /** Resolved page description (VitePress `TransformContext.description`). */ + description: string; + cleanUrls?: boolean; +} + +/** + * Per-page SEO head tags: a canonical link plus page-specific Open Graph tags. + * Twitter cards fall back to the `og:*` values, so no `twitter:title` / + * `twitter:description` duplication is needed. + */ +export function pageSeoHead({ + page, + title, + description, + cleanUrls = false, +}: PageSeoInput): HeadConfig[] { + const url = canonicalUrl(page, cleanUrls); + return [ + ["link", { rel: "canonical", href: url }], + ["meta", { property: "og:url", content: url }], + ["meta", { property: "og:title", content: title }], + ["meta", { property: "og:description", content: description }], + ]; +} + +/** + * Whether a page should advertise a self-canonical / OG URL to crawlers. + * The 404 page is served for unknown paths and must not declare itself + * canonical, or crawlers would treat the not-found page as indexable. + */ +export function isIndexablePage(page: string): boolean { + return page !== "404.md"; +} + +/** Site-wide SEO head tags that are identical on every page. */ +export const globalSeoHead: HeadConfig[] = [ + ["meta", { property: "og:type", content: "website" }], + ["meta", { property: "og:site_name", content: "Fullsend" }], + ["meta", { property: "og:image", content: OG_IMAGE }], + ["meta", { name: "twitter:card", content: "summary_large_image" }], + ["meta", { name: "twitter:image", content: OG_IMAGE }], +]; diff --git a/docs/agents/README.md b/docs/agents/README.md index 31552810c2..068bae5630 100644 --- a/docs/agents/README.md +++ b/docs/agents/README.md @@ -1,3 +1,7 @@ +--- +description: Reference for the agents fullsend ships — triage, code, review, fix, retro, and prioritize — plus how to register custom agents via config. +--- + # Agents Reference documentation for the agents shipped by fullsend. diff --git a/docs/agents/code.md b/docs/agents/code.md index 85aadbdd93..b6768cecad 100644 --- a/docs/agents/code.md +++ b/docs/agents/code.md @@ -1,3 +1,7 @@ +--- +description: How the fullsend code agent implements triaged GitHub issues following repository conventions, runs tests and linters, and commits to a feature branch. +--- + # Code Agent ![Code agent icon](icons/coder.png) diff --git a/docs/agents/fix.md b/docs/agents/fix.md index c679fb7906..563f8f537f 100644 --- a/docs/agents/fix.md +++ b/docs/agents/fix.md @@ -1,3 +1,7 @@ +--- +description: How the fullsend fix agent reads PR review feedback, implements targeted fixes, runs tests and linters, and commits the result. +--- + # Fix Agent ![Fix agent icon](icons/coder.png) diff --git a/docs/agents/prioritize.md b/docs/agents/prioritize.md index c0dea0d858..c629d9d1ee 100644 --- a/docs/agents/prioritize.md +++ b/docs/agents/prioritize.md @@ -1,3 +1,7 @@ +--- +description: How the fullsend prioritize agent scores GitHub issues with the RICE framework and produces ranked, reasoned priority scores. +--- + # Prioritize Agent ![Prioritize agent icon](icons/prioritize.png) diff --git a/docs/agents/retro.md b/docs/agents/retro.md index 02054e2197..093e36ae5c 100644 --- a/docs/agents/retro.md +++ b/docs/agents/retro.md @@ -1,3 +1,7 @@ +--- +description: How the fullsend retro agent runs retrospectives on agent workflows and proposes improvements as structured GitHub issues. +--- + # Retro Agent ![Retro agent icon](icons/retro.png) diff --git a/docs/agents/review.md b/docs/agents/review.md index c7e9714cf5..816ec10656 100644 --- a/docs/agents/review.md +++ b/docs/agents/review.md @@ -1,3 +1,7 @@ +--- +description: How the fullsend review agent evaluates pull requests for correctness, security, intent alignment, style, and documentation currency. +--- + # Review Agent ![Review agent icon](icons/review.png) diff --git a/docs/agents/triage.md b/docs/agents/triage.md index d2eedcb448..513a564d34 100644 --- a/docs/agents/triage.md +++ b/docs/agents/triage.md @@ -1,3 +1,7 @@ +--- +description: How the fullsend triage agent inspects new GitHub issues, assesses information sufficiency, asks clarifying questions, and produces a structured triage decision. +--- + # Triage Agent ![Triage agent icon](icons/triage.png) diff --git a/docs/architecture.md b/docs/architecture.md index 3cc582c809..a7c4974e37 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,3 +1,7 @@ +--- +description: The components of the fullsend agent execution stack and how they fit together — the current architectural truth, kept in sync with accepted ADRs. +--- + # Architecture What are the components of the agent execution stack? diff --git a/docs/guides/getting-started/README.md b/docs/guides/getting-started/README.md index 7971bc2bf1..edb1a42f2a 100644 --- a/docs/guides/getting-started/README.md +++ b/docs/guides/getting-started/README.md @@ -1,5 +1,6 @@ --- sidebar_position: 1 +description: Set up fullsend step by step — get inference, choose an agent runtime, and configure GitHub to start running autonomous SDLC agents on your repos. --- # Getting Started diff --git a/docs/vision.md b/docs/vision.md index de5c39eba8..7086f21d8d 100644 --- a/docs/vision.md +++ b/docs/vision.md @@ -1,3 +1,7 @@ +--- +description: The fullsend vision — fully autonomous agentic software development across a GitHub organization, with humans setting strategic intent and guardrails. +--- + # Vision ## The goal From 1bd286a3362d0e9e46f7c1638bcd95d16ae85f41 Mon Sep 17 00:00:00 2001 From: Shai Revivo Date: Thu, 27 Aug 2026 18:21:28 +0300 Subject: [PATCH 2/4] fix(#2996): address review feedback on docs SEO - config.ts: exclude template placeholders (0000-*-template.md) from the build via srcExclude, mirroring isNonContent's template rule, so they no longer appear in sitemap.xml or carry canonical/OG metadata. Previously ADRs/0000-adr-template.html was advertised to search engines. - docs/agents/README.md: rewrite the description to point at runtime agent discovery (fullsend-ai/agents repo / config) instead of hardcoding the shipped agent names, per the project's discovery-over-inventory convention. - docs/guides/getting-started/README.md: reword the description in plain language, avoiding undefined jargon (inference, agent runtime, SDLC agents). Rebuilt: sitemap drops the template (190 -> 189 entries), all real pages retained, new descriptions render correctly; 13 unit tests pass. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Shai Revivo --- docs/.vitepress/config.ts | 5 ++++- docs/agents/README.md | 2 +- docs/guides/getting-started/README.md | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index d5986b08fc..82c4b72a12 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -175,7 +175,10 @@ export default defineConfig({ return pageSeoHead({ page, title, description, cleanUrls: siteConfig.cleanUrls }); }, - srcExclude: ["**/agents/icons/**", "**/testing/**"], + // Keep template placeholders (mirrors `isNonContent`'s `0000-*-template` + // rule) out of the build entirely, so they never reach the sitemap or get + // canonical/OG metadata and are not offered to search engines. + srcExclude: ["**/agents/icons/**", "**/testing/**", "**/0000-*-template.md"], ignoreDeadLinks: true, diff --git a/docs/agents/README.md b/docs/agents/README.md index 068bae5630..d466bc6071 100644 --- a/docs/agents/README.md +++ b/docs/agents/README.md @@ -1,5 +1,5 @@ --- -description: Reference for the agents fullsend ships — triage, code, review, fix, retro, and prioritize — plus how to register custom agents via config. +description: Reference for the agents fullsend ships and how to register custom agents — resolved at runtime from the fullsend-ai/agents repository or your org and repo config. --- # Agents diff --git a/docs/guides/getting-started/README.md b/docs/guides/getting-started/README.md index edb1a42f2a..b43fb8b543 100644 --- a/docs/guides/getting-started/README.md +++ b/docs/guides/getting-started/README.md @@ -1,6 +1,6 @@ --- sidebar_position: 1 -description: Set up fullsend step by step — get inference, choose an agent runtime, and configure GitHub to start running autonomous SDLC agents on your repos. +description: Step-by-step setup for fullsend — connect a model provider, choose where your agents run, and configure GitHub so they can work on your repositories. --- # Getting Started From 97e65ad3b1fddb6ae4e5afaae1e4f82425dd9754 Mon Sep 17 00:00:00 2001 From: Shai Revivo Date: Thu, 27 Aug 2026 19:48:22 +0300 Subject: [PATCH 3/4] fix(#2996): align SEO URLs with deployed site Signed-off-by: Shai Revivo --- docs/.vitepress/config.ts | 36 +++++++++++++----------- docs/.vitepress/seo.test.ts | 55 ++++++++++++++++++++++++++++++++++--- docs/.vitepress/seo.ts | 46 +++++++++++++++++++++++++++---- 3 files changed, 112 insertions(+), 25 deletions(-) diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 82c4b72a12..aa83aba954 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -2,25 +2,26 @@ import { defineConfig } from "vitepress"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { DOCS_URL_BASE, globalSeoHead, isIndexablePage, pageSeoHead } from "./seo"; +import { + DOCS_URL_BASE, + globalSeoHead, + isIndexablePage, + isNonContentPath, + isSitemapUrl, + pageRobotsHead, + pageSeoHead, +} from "./seo"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const docsDir = path.resolve(__dirname, ".."); -/** Non-content entry: template placeholder or repo-metadata (ALL-CAPS) name. */ -function isNonContent(entry: string): boolean { - if (/^0000-.*-template/.test(entry)) return true; - const base = entry.replace(/\.md$/, ""); - return /^[A-Z][A-Z0-9_-]*$/.test(base); -} - function getMarkdownFiles(dir: string, base: string): { text: string; link: string }[] { const fullDir = path.resolve(docsDir, dir); if (!fs.existsSync(fullDir)) return []; const items: { text: string; link: string }[] = []; for (const entry of fs.readdirSync(fullDir).sort()) { const entryPath = path.resolve(fullDir, entry); - if (entry.endsWith(".md") && entry !== "README.md" && !isNonContent(entry)) { + if (entry.endsWith(".md") && entry !== "README.md" && !isNonContentPath(entry)) { const slug = entry.replace(/\.md$/, ""); const content = fs.readFileSync(entryPath, "utf-8"); const fmTitleMatch = content.match(/^title:\s*["']?(.+?)["']?\s*$/m); @@ -29,7 +30,7 @@ function getMarkdownFiles(dir: string, base: string): { text: string; link: stri } else if ( fs.statSync(entryPath).isDirectory() && !entry.startsWith(".") && - !isNonContent(entry) + !isNonContentPath(entry) ) { const readme = path.resolve(entryPath, "README.md"); if (fs.existsSync(readme)) { @@ -136,6 +137,7 @@ export default defineConfig({ description: "Autonomous SDLC agents for your codebase", base: "/docs/", + cleanUrls: true, rewrites: { "README.md": "index.md", @@ -167,18 +169,20 @@ export default defineConfig({ // trailing slash) because VitePress resolves base-less page paths against it. sitemap: { hostname: DOCS_URL_BASE, + transformItems: (items) => items.filter((item) => isSitemapUrl(item.url)), }, // Per-page canonical + Open Graph tags derived from the resolved page data. transformHead({ page, title, description, siteConfig }) { - if (!isIndexablePage(page)) return []; - return pageSeoHead({ page, title, description, cleanUrls: siteConfig.cleanUrls }); + const robotsHead = pageRobotsHead(page); + if (!isIndexablePage(page)) return robotsHead; + return [ + ...robotsHead, + ...pageSeoHead({ page, title, description, cleanUrls: siteConfig.cleanUrls }), + ]; }, - // Keep template placeholders (mirrors `isNonContent`'s `0000-*-template` - // rule) out of the build entirely, so they never reach the sitemap or get - // canonical/OG metadata and are not offered to search engines. - srcExclude: ["**/agents/icons/**", "**/testing/**", "**/0000-*-template.md"], + srcExclude: ["**/agents/icons/**", "**/testing/**"], ignoreDeadLinks: true, diff --git a/docs/.vitepress/seo.test.ts b/docs/.vitepress/seo.test.ts index 52b7e6111c..cdc096fadf 100644 --- a/docs/.vitepress/seo.test.ts +++ b/docs/.vitepress/seo.test.ts @@ -4,7 +4,10 @@ import { canonicalUrl, globalSeoHead, isIndexablePage, + isNonContentPath, + isSitemapUrl, pageOutputPath, + pageRobotsHead, pageSeoHead, } from "./seo"; @@ -41,19 +44,25 @@ describe("canonicalUrl", () => { } }); - it("resolves the root to the docs base URL", () => { - expect(canonicalUrl("index.md")).toBe("https://fullsend.sh/docs/"); + it("resolves the redirecting root to its content destination", () => { + expect(canonicalUrl("index.md")).toBe("https://fullsend.sh/docs/guides/getting-started/"); }); it("resolves a directory index to a trailing-slash URL", () => { expect(canonicalUrl("agents/index.md")).toBe("https://fullsend.sh/docs/agents/"); }); - it("resolves a content page to its .html URL", () => { + it("resolves a content page to its .html URL when clean URLs are off", () => { expect(canonicalUrl("guides/user/jira-integration.md")).toBe( "https://fullsend.sh/docs/guides/user/jira-integration.html", ); }); + + it("resolves a content page to the URL shape served by Cloudflare", () => { + expect(canonicalUrl("guides/user/jira-integration.md", true)).toBe( + "https://fullsend.sh/docs/guides/user/jira-integration", + ); + }); }); describe("pageSeoHead", () => { @@ -61,10 +70,11 @@ describe("pageSeoHead", () => { page: "agents/triage.md", title: "Triage Agent | Fullsend", description: "How the triage agent works", + cleanUrls: true, }); it("emits a canonical link and og:url with the full docs URL", () => { - const url = "https://fullsend.sh/docs/agents/triage.html"; + const url = "https://fullsend.sh/docs/agents/triage"; expect(head).toContainEqual(["link", { rel: "canonical", href: url }]); expect(head).toContainEqual(["meta", { property: "og:url", content: url }]); }); @@ -90,6 +100,43 @@ describe("isIndexablePage", () => { expect(isIndexablePage("index.md")).toBe(true); expect(isIndexablePage("agents/triage.md")).toBe(true); }); + + it("excludes template and repo-metadata pages from canonical metadata", () => { + expect(isIndexablePage("experiments/0000-experiment-template/index.md")).toBe(false); + expect(isIndexablePage("experiments/example/RESULTS.md")).toBe(false); + expect(isIndexablePage("experiments/example/skills/check/SKILL.md")).toBe(false); + }); +}); + +describe("isNonContentPath", () => { + it("uses the same classification for source paths and clean URLs", () => { + expect(isNonContentPath("ADRs/0000-adr-template.md")).toBe(true); + expect(isNonContentPath("experiments/0000-experiment-template/")).toBe(true); + expect(isNonContentPath("experiments/example/RESULTS")).toBe(true); + expect(isNonContentPath("experiments/example/results")).toBe(false); + }); +}); + +describe("isSitemapUrl", () => { + it("excludes the redirecting root and non-content URLs", () => { + expect(isSitemapUrl("")).toBe(false); + expect(isSitemapUrl("/")).toBe(false); + expect(isSitemapUrl("experiments/0000-experiment-template/")).toBe(false); + expect(isSitemapUrl("experiments/example/SKILL")).toBe(false); + }); + + it("keeps normal experiment pages in the public sitemap", () => { + expect(isSitemapUrl("experiments/0026-eval-statistical-significance/")).toBe(true); + }); +}); + +describe("pageRobotsHead", () => { + it("marks the directly reachable 404 asset noindex", () => { + expect(pageRobotsHead("404.md")).toEqual([ + ["meta", { name: "robots", content: "noindex" }], + ]); + expect(pageRobotsHead("agents/triage.md")).toEqual([]); + }); }); describe("globalSeoHead", () => { diff --git a/docs/.vitepress/seo.ts b/docs/.vitepress/seo.ts index 067de5642f..7e93555fa6 100644 --- a/docs/.vitepress/seo.ts +++ b/docs/.vitepress/seo.ts @@ -27,6 +27,23 @@ export const DOCS_URL_BASE = `${SITE_ORIGIN}${DOCS_BASE}`; */ export const OG_IMAGE = `${DOCS_URL_BASE}img/logo.png`; +/** Canonical destinations for pages that are redirects rather than content. */ +const CANONICAL_REDIRECTS: Record = { + "index.md": "guides/getting-started/", +}; + +/** Non-content path: template placeholder or repo-metadata (ALL-CAPS) name. */ +export function isNonContentPath(pageOrUrl: string): boolean { + const pathOnly = pageOrUrl.split(/[?#]/, 1)[0]; + return pathOnly + .split("/") + .filter(Boolean) + .some((segment) => { + const base = segment.replace(/\.(?:html|md)$/, ""); + return /^0000-.*-template$/.test(base) || /^[A-Z][A-Z0-9_-]*$/.test(base); + }); +} + /** * Convert a VitePress page path (the post-rewrite source path, e.g. "index.md", * "agents/index.md", "guides/user/jira-integration.md") into the site-relative @@ -45,7 +62,8 @@ export function pageOutputPath(page: string, cleanUrls = false): string { /** Absolute canonical URL for a page, including the `/docs/` base. */ export function canonicalUrl(page: string, cleanUrls = false): string { - return new URL(pageOutputPath(page, cleanUrls), DOCS_URL_BASE).href; + const outputPath = CANONICAL_REDIRECTS[page] ?? pageOutputPath(page, cleanUrls); + return new URL(outputPath, DOCS_URL_BASE).href; } export interface PageSeoInput { @@ -79,12 +97,30 @@ export function pageSeoHead({ } /** - * Whether a page should advertise a self-canonical / OG URL to crawlers. - * The 404 page is served for unknown paths and must not declare itself - * canonical, or crawlers would treat the not-found page as indexable. + * Whether a page should advertise a canonical / OG URL to crawlers. + * VitePress emits `404.html` as a directly reachable asset, while the deployed + * Worker's SPA fallback handles unknown paths separately. The 404 asset and + * non-content files must not advertise canonical content URLs. Tag omission by + * itself is not a `noindex` signal; {@link pageRobotsHead} supplies that for 404. */ export function isIndexablePage(page: string): boolean { - return page !== "404.md"; + return page !== "404.md" && !isNonContentPath(page); +} + +/** Robots metadata for pages that must not enter search indexes. */ +export function pageRobotsHead(page: string): HeadConfig[] { + return page === "404.md" ? [["meta", { name: "robots", content: "noindex" }]] : []; +} + +/** + * Whether a generated sitemap URL represents public content. + * The root is a meta-refresh redirect, so advertise its canonical destination + * instead. Normal experiment pages remain public; only their templates and + * repo-metadata pages are filtered with the same rule used by navigation. + */ +export function isSitemapUrl(url: string): boolean { + const normalized = url.replace(/^\/+|\/+$/g, ""); + return normalized !== "" && !isNonContentPath(normalized); } /** Site-wide SEO head tags that are identical on every page. */ From 5cfc6b32ccd9dc43ab3267462fe3a4077b651d04 Mon Sep 17 00:00:00 2001 From: Shai Revivo Date: Thu, 27 Aug 2026 21:07:22 +0300 Subject: [PATCH 4/4] fix(#2996): address SEO review nits - isNonContentPath: drop the trailing $ anchor so the template regex matches suffixed names, restoring the original isNonContent semantics. - Document why cleanUrls is required (matches Cloudflare's 200-serving URL shape; canonicals must not point at redirecting .html URLs). Co-Authored-By: Claude Opus 4.8 Signed-off-by: Shai Revivo --- docs/.vitepress/config.ts | 5 +++++ docs/.vitepress/seo.test.ts | 7 +++++++ docs/.vitepress/seo.ts | 2 +- 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 5b39a9e7ff..0bbce379f3 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -137,6 +137,11 @@ export default defineConfig({ description: "Autonomous SDLC agents for your codebase", base: "/docs/", + // Required for SEO correctness, not cosmetic: Cloudflare Workers serves the + // extensionless URL with a 200 and 307-redirects the `.html` form. With + // cleanUrls the canonical/og:url/sitemap URLs match the 200-serving shape + // instead of pointing at redirecting `.html` URLs (search engines discard + // canonicals that redirect). cleanUrls: true, rewrites: { diff --git a/docs/.vitepress/seo.test.ts b/docs/.vitepress/seo.test.ts index cdc096fadf..85c7f92d5a 100644 --- a/docs/.vitepress/seo.test.ts +++ b/docs/.vitepress/seo.test.ts @@ -115,6 +115,13 @@ describe("isNonContentPath", () => { expect(isNonContentPath("experiments/example/RESULTS")).toBe(true); expect(isNonContentPath("experiments/example/results")).toBe(false); }); + + it("matches template names with suffixes, like the original isNonContent", () => { + // The predicate is unanchored (no trailing `$`) so a suffixed template such + // as `0000-foo-template-v2` classifies as non-content, matching the original + // sidebar-discovery regex rather than treating it as a content page. + expect(isNonContentPath("ADRs/0000-foo-template-v2.md")).toBe(true); + }); }); describe("isSitemapUrl", () => { diff --git a/docs/.vitepress/seo.ts b/docs/.vitepress/seo.ts index 7e93555fa6..439dc7ea94 100644 --- a/docs/.vitepress/seo.ts +++ b/docs/.vitepress/seo.ts @@ -40,7 +40,7 @@ export function isNonContentPath(pageOrUrl: string): boolean { .filter(Boolean) .some((segment) => { const base = segment.replace(/\.(?:html|md)$/, ""); - return /^0000-.*-template$/.test(base) || /^[A-Z][A-Z0-9_-]*$/.test(base); + return /^0000-.*-template/.test(base) || /^[A-Z][A-Z0-9_-]*$/.test(base); }); }