Repository navigation
feat(#2996): add SEO metadata to docs site #6683
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
rh-hemartin
merged 7 commits into
fullsend-ai:main
from
shairevivo:srevivo/2996-docs-seo-metadata
Aug 31, 2026
Merged
Changes from 1 commit
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
7e44beb
feat(#2996): add SEO metadata to docs site
shairevivo 1bd286a
fix(#2996): address review feedback on docs SEO
shairevivo 97e65ad
fix(#2996): align SEO URLs with deployed site
shairevivo ba1656f
Merge branch 'main' into srevivo/2996-docs-seo-metadata
shairevivo 5cfc6b3
fix(#2996): address SEO review nits
shairevivo 831b05f
Merge branch 'main' into srevivo/2996-docs-seo-metadata
shairevivo dfc872d
Merge branch 'main' into srevivo/2996-docs-seo-metadata
shairevivo File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" }, | ||
| ]); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
|
shairevivo marked this conversation as resolved.
Resolved
|
||
| * 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 }], | ||
| ]; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.