diff --git a/apps/developer-docs/docs/.vitepress/config.mts b/apps/developer-docs/docs/.vitepress/config.mts
index 5a74acc0..030df609 100644
--- a/apps/developer-docs/docs/.vitepress/config.mts
+++ b/apps/developer-docs/docs/.vitepress/config.mts
@@ -1,9 +1,10 @@
///
-import { defineConfig, type HeadConfig, type PageData } from "vitepress";
+import { defineConfig, type PageData } from "vitepress";
import { tabsMarkdownPlugin } from "vitepress-plugin-tabs";
import { withMermaid } from "vitepress-plugin-mermaid";
import { extendConfig } from "@voidzero-dev/vitepress-theme/config";
import llmstxt from "vitepress-plugin-llms";
+import { canonicalLink, siteJsonLd } from "@plane/docs-theme/seo";
import { readFileSync, readdirSync, statSync, mkdirSync, copyFileSync } from "node:fs";
import { resolve, join, relative, dirname } from "node:path";
@@ -213,21 +214,22 @@ export default extendConfig(
"meta",
{ name: "twitter:image", content: "https://media.docs.plane.so/logo/og-docs.webp#hero" },
],
+
+ // Organization + WebSite JSON-LD (shared identity from packages/theme)
+ siteJsonLd({
+ name: "Plane Developer Docs",
+ url: "https://developers.plane.so",
+ description:
+ "Developer documentation for Plane: REST API reference, webhooks, MCP server, OAuth apps, and self-hosting guides.",
+ }),
],
transformPageData(pageData: PageData) {
const head = (pageData.frontmatter.head ??= []);
- // Inject canonical URL if not already defined in frontmatter
- const hasCanonical = (head as HeadConfig[]).some(
- ([tag, attrs]) => tag === "link" && attrs?.rel === "canonical",
- );
- if (!hasCanonical) {
- const canonicalUrl = `https://developers.plane.so/${pageData.relativePath}`
- .replace(/index\.md$/, "")
- .replace(/\.md$/, "");
- head.push(["link", { rel: "canonical", href: canonicalUrl }]);
- }
+ // Canonical URL per page (skipped when frontmatter already sets one)
+ const canonical = canonicalLink("https://developers.plane.so", pageData);
+ if (canonical) head.push(canonical);
// Inject frontmatter keywords as a meta tag (VitePress doesn't do this natively)
const keywords = pageData.frontmatter.keywords;
diff --git a/apps/docs/docs/.vitepress/config.ts b/apps/docs/docs/.vitepress/config.ts
index ebeae667..ad4ab588 100644
--- a/apps/docs/docs/.vitepress/config.ts
+++ b/apps/docs/docs/.vitepress/config.ts
@@ -6,6 +6,7 @@ import { defineConfig } from "vitepress";
import { extendConfig } from "@voidzero-dev/vitepress-theme/config";
import { tabsMarkdownPlugin } from "vitepress-plugin-tabs";
import llmstxt from "vitepress-plugin-llms";
+import { canonicalLink, siteJsonLd } from "@plane/docs-theme/seo";
function loadEnvVar(key: string): string | undefined {
// process.env takes precedence (CI/hosting platforms set vars here)
@@ -211,6 +212,13 @@ const config = defineConfig({
content: "index, follow",
},
],
+ // Organization + WebSite JSON-LD (shared identity from packages/theme)
+ siteJsonLd({
+ name: "Plane Docs",
+ url: "https://docs.plane.so",
+ description:
+ "Product documentation for Plane: workspaces, projects, work items, cycles, modules, pages, integrations, importers, automations, and Plane AI.",
+ }),
],
themeConfig: {
@@ -785,6 +793,12 @@ const config = defineConfig({
// Enables per-page git timestamps used for sitemap (and the
// "Last updated" footer). Without this, sitemap entries omit lastmod.
+ transformPageData(pageData) {
+ // Canonical URL per page (skipped when frontmatter already sets one)
+ const canonical = canonicalLink("https://docs.plane.so", pageData);
+ if (canonical) (pageData.frontmatter.head ??= []).push(canonical);
+ },
+
lastUpdated: true,
sitemap: {
diff --git a/packages/theme/README.md b/packages/theme/README.md
index c077c3f6..63605ec0 100644
--- a/packages/theme/README.md
+++ b/packages/theme/README.md
@@ -44,6 +44,7 @@ the repo root); changes hot-reload through the workspace link. `pnpm check:types
src/
index.ts createPlaneTheme(options) + client setup (appearance sync, medium-zoom, tab hashes)
options.ts PlaneThemeOptions / planeOptionsKey
+ seo.ts build-time helpers for the VitePress configs (`@plane/docs-theme/seo`): Organization/WebSite JSON-LD, canonical link
layout/ Layout.vue, doc-layout.vue (PlaneHeader + bordered content wrapper), slots/header helpers
components/ PlaneHeader, CopyPageMenu, CookieConsent, Card, CardGroup, Tags, brand icons
css/ index.css → fonts, tokens, base, layout, components, api
diff --git a/packages/theme/package.json b/packages/theme/package.json
index 583a9731..3753760b 100644
--- a/packages/theme/package.json
+++ b/packages/theme/package.json
@@ -6,6 +6,7 @@
"type": "module",
"exports": {
".": "./src/index.ts",
+ "./seo": "./src/seo.ts",
"./package.json": "./package.json"
},
"scripts": {
diff --git a/packages/theme/src/seo.ts b/packages/theme/src/seo.ts
new file mode 100644
index 00000000..11ca5bf0
--- /dev/null
+++ b/packages/theme/src/seo.ts
@@ -0,0 +1,83 @@
+/**
+ * Build-time SEO helpers shared by both sites. Imported by the VitePress
+ * configs as `@plane/docs-theme/seo`; nothing here ships to the browser.
+ */
+import type { HeadConfig, PageData } from "vitepress";
+
+const PLANE_ORGANIZATION_ID = "https://plane.so/#organization";
+
+/**
+ * Plane as a schema.org Organization. Single source of truth so both sites
+ * publish the same identity (name, logo, profiles, contact points).
+ */
+export const planeOrganization = {
+ "@type": "Organization",
+ "@id": PLANE_ORGANIZATION_ID,
+ name: "Plane",
+ url: "https://plane.so",
+ logo: "https://media.docs.plane.so/logo/new-logo-white.png",
+ sameAs: ["https://github.com/makeplane", "https://x.com/planepowers"],
+ contactPoint: [
+ {
+ "@type": "ContactPoint",
+ contactType: "customer support",
+ email: "support@plane.so",
+ url: "https://docs.plane.so/support/get-help",
+ availableLanguage: "English",
+ },
+ {
+ "@type": "ContactPoint",
+ contactType: "sales",
+ email: "sales@plane.so",
+ availableLanguage: "English",
+ },
+ ],
+};
+
+export interface SiteIdentity {
+ /** Site name, e.g. "Plane Docs". */
+ name: string;
+ /** Origin without a trailing slash, e.g. "https://docs.plane.so". */
+ url: string;
+ /** One-sentence description of what the site covers. */
+ description: string;
+}
+
+/**
+ * `