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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 13 additions & 11 deletions apps/developer-docs/docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
/// <reference path="./env.d.ts" />
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";

Expand Down Expand Up @@ -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;
Expand Down
14 changes: 14 additions & 0 deletions apps/docs/docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -785,6 +793,12 @@ const config = defineConfig({

// Enables per-page git timestamps used for sitemap <lastmod> (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: {
Expand Down
1 change: 1 addition & 0 deletions packages/theme/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/theme/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"type": "module",
"exports": {
".": "./src/index.ts",
"./seo": "./src/seo.ts",
"./package.json": "./package.json"
},
"scripts": {
Expand Down
83 changes: 83 additions & 0 deletions packages/theme/src/seo.ts
Original file line number Diff line number Diff line change
@@ -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;
}

/**
* `<script type="application/ld+json">` head entry describing the site
* (WebSite) and its publisher (the Plane Organization). Add it to `head` so
* every page carries it; agents and search engines read it from the homepage.
*/
export function siteJsonLd(site: SiteIdentity): HeadConfig {
const data = {
"@context": "https://schema.org",
"@graph": [
planeOrganization,
{
"@type": "WebSite",
"@id": `${site.url}/#website`,
name: site.name,
url: site.url,
description: site.description,
inLanguage: "en",
publisher: { "@id": PLANE_ORGANIZATION_ID },
},
],
};
// Nothing above contains "<", but escape anyway so a future edit can never
// close the <script> element early.
return ["script", { type: "application/ld+json" }, JSON.stringify(data).replace(/</g, "\\u003c")];
}

/**
* `<link rel="canonical">` for a page, or undefined when the page's
* frontmatter already sets one. Mirrors cleanUrls: `dir/page.md` →
* `${origin}/dir/page`, `dir/index.md` → `${origin}/dir`, `index.md` →
* `${origin}/`. Call from `transformPageData`.
*/
export function canonicalLink(origin: string, pageData: PageData): HeadConfig | undefined {
const head = (pageData.frontmatter.head ?? []) as HeadConfig[];
if (head.some(([tag, attrs]) => tag === "link" && attrs?.rel === "canonical")) return undefined;
const path = pageData.relativePath.replace(/\.md$/, "").replace(/(^|\/)index$/, "");
return ["link", { rel: "canonical", href: path ? `${origin}/${path}` : `${origin}/` }];
}
Loading