diff --git a/apps/developer-docs/AGENTS.md b/apps/developer-docs/AGENTS.md index a1fe2a2c..0e1f6306 100644 --- a/apps/developer-docs/AGENTS.md +++ b/apps/developer-docs/AGENTS.md @@ -26,6 +26,7 @@ pnpm --filter developer-docs check:types - **`docs/api-reference/`** — REST API endpoint docs (180+ endpoints across 30+ resource categories) - **`docs/self-hosting/`** — Deployment and configuration guides - **`docs/dev-tools/`** — Webhooks, OAuth apps, agents, MCP server docs + - **`docs/not-found.md`** — "Page not found" page; `buildEnd` turns it into the `404.html` Vercel serves (content is the shared `NotFound` theme component) - **`vercel.json`** — cleanUrls, headers, redirects (per-app Vercel project) - **`middleware.ts`** — Vercel Routing Middleware: `Accept: text/markdown` → `/path.md` with `Vary: Accept` (mirror of `apps/docs/middleware.ts`) - Shared visual identity (header, layout, tokens, `Card`/`CardGroup`/`Tags`, Copy page menu, cookie consent) diff --git a/apps/developer-docs/docs/.vitepress/config.mts b/apps/developer-docs/docs/.vitepress/config.mts index 3f2b8482..feb29691 100644 --- a/apps/developer-docs/docs/.vitepress/config.mts +++ b/apps/developer-docs/docs/.vitepress/config.mts @@ -5,7 +5,15 @@ 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 { + readFileSync, + readdirSync, + statSync, + mkdirSync, + copyFileSync, + renameSync, + rmSync, +} from "node:fs"; import { resolve, join, relative, dirname } from "node:path"; function loadEnvVar(key: string): string | undefined { @@ -102,6 +110,7 @@ export default extendConfig( // Pages hidden from search (search: false / noindex) are excluded // from the LLM files too. ignoreFiles: [ + "not-found.md", "self-hosting/methods/install-methods-commercial/docker-compose.md", "self-hosting/methods/install-methods-commercial/kubernetes.md", ], @@ -144,6 +153,12 @@ export default extendConfig( } walk(srcDir); + + // The server-rendered "page not found" page becomes the static 404.html that + // Vercel serves for unknown paths (VitePress leaves #app empty in the 404.html + // it writes). + renameSync(join(outDir, "not-found.html"), join(outDir, "404.html")); + rmSync(join(outDir, "not-found.md")); }, title: "Plane developer documentation", description: @@ -156,6 +171,8 @@ export default extendConfig( // SEO: Generate sitemap automatically sitemap: { hostname: "https://developers.plane.so", + // not-found.md only exists to become 404.html (see buildEnd). + transformItems: (items) => items.filter((item) => item.url !== "not-found"), }, // SEO: Clean URLs without .html extension diff --git a/apps/developer-docs/docs/.vitepress/theme/index.ts b/apps/developer-docs/docs/.vitepress/theme/index.ts index 41c43395..a48f1456 100644 --- a/apps/developer-docs/docs/.vitepress/theme/index.ts +++ b/apps/developer-docs/docs/.vitepress/theme/index.ts @@ -30,6 +30,15 @@ export default createPlaneTheme({ footerBg: "https://media.docs.plane.so/logo/og-docs.webp", monoIcon: "/logo/favicon-32x32.png", }, + notFound: { + siteName: "the Plane developer docs", + sibling: { + name: "docs.plane.so", + url: "https://docs.plane.so", + covers: "how to use Plane (workspaces, projects, work items, pages)", + }, + help: { text: "Get help from the Plane team", link: "https://docs.plane.so/support/get-help" }, + }, components: { ApiParam, CodePanel, ResponsePanel }, setup() { const route = useRoute(); diff --git a/apps/developer-docs/docs/not-found.md b/apps/developer-docs/docs/not-found.md new file mode 100644 index 00000000..48daef64 --- /dev/null +++ b/apps/developer-docs/docs/not-found.md @@ -0,0 +1,26 @@ +--- +title: Page not found +description: This page doesn't exist on developers.plane.so. Start from the home page, search, the sitemap, or llms.txt to find what you're looking for. +keywords: plane, page not found, 404 +# Not a real page: keep it out of search, the LLM files, and search engines, and +# don't give it a canonical URL (it is served at whatever path was not found). +search: false +canonical: false +copyPage: false +outline: false +prev: false +next: false +editLink: false +lastUpdated: false +head: + - - meta + - name: robots + content: noindex, nofollow +--- + + + + diff --git a/apps/docs/AGENTS.md b/apps/docs/AGENTS.md index 4277ba50..b50cf524 100644 --- a/apps/docs/AGENTS.md +++ b/apps/docs/AGENTS.md @@ -36,6 +36,7 @@ docs/ ai/ # Plane AI features support/ # Keyboard shortcuts, get help templates/ # Page, project, work-item templates +docs/not-found.md # "Page not found" page → becomes 404.html in buildEnd (shared NotFound component) vercel.json # cleanUrls, headers, redirects middleware.ts # Vercel Routing Middleware: Accept: text/markdown → /path.md (+ Vary: Accept) ``` diff --git a/apps/docs/docs/.vitepress/config.ts b/apps/docs/docs/.vitepress/config.ts index 7267977b..b4396abf 100644 --- a/apps/docs/docs/.vitepress/config.ts +++ b/apps/docs/docs/.vitepress/config.ts @@ -1,6 +1,14 @@ /** @format */ -import { copyFileSync, mkdirSync, readFileSync, readdirSync, statSync } from "fs"; +import { + copyFileSync, + mkdirSync, + readFileSync, + readdirSync, + renameSync, + rmSync, + statSync, +} from "fs"; import { dirname, join, relative, resolve } from "path"; import { defineConfig } from "vitepress"; import { extendConfig } from "@voidzero-dev/vitepress-theme/config"; @@ -85,6 +93,7 @@ const config = defineConfig({ // Pages hidden from search (search: false / noindex) are excluded // from the LLM files too. ignoreFiles: [ + "not-found.md", "core-concepts/issues.md", "core-concepts/projects/run-project.md", "importers/github-imp.md", @@ -115,6 +124,11 @@ const config = defineConfig({ } walk(srcDir); + + // The server-rendered "page not found" page becomes the static 404.html that Vercel + // serves for unknown paths (VitePress leaves #app empty in the 404.html it writes). + renameSync(join(outDir, "not-found.html"), join(outDir, "404.html")); + rmSync(join(outDir, "not-found.md")); }, head: [ @@ -823,6 +837,8 @@ const config = defineConfig({ sitemap: { hostname: "https://docs.plane.so", + // not-found.md only exists to become 404.html (see buildEnd). + transformItems: (items) => items.filter((item) => item.url !== "not-found"), }, markdown: { diff --git a/apps/docs/docs/.vitepress/theme/index.ts b/apps/docs/docs/.vitepress/theme/index.ts index f7bb9bcd..5365bd8d 100644 --- a/apps/docs/docs/.vitepress/theme/index.ts +++ b/apps/docs/docs/.vitepress/theme/index.ts @@ -14,4 +14,13 @@ export default createPlaneTheme({ footerBg: "https://media.docs.plane.so/logo/og-docs.webp", monoIcon: "https://media.docs.plane.so/logo/favicon-32x32.png", }, + notFound: { + siteName: "the Plane docs", + sibling: { + name: "developers.plane.so", + url: "https://developers.plane.so", + covers: "the API, webhooks, the MCP server, or self-hosting", + }, + help: { text: "Get help from the Plane team", link: "/support/get-help" }, + }, }); diff --git a/apps/docs/docs/not-found.md b/apps/docs/docs/not-found.md new file mode 100644 index 00000000..f289a22c --- /dev/null +++ b/apps/docs/docs/not-found.md @@ -0,0 +1,26 @@ +--- +title: Page not found +description: This page doesn't exist on docs.plane.so. Start from the home page, search, the sitemap, or llms.txt to find what you're looking for. +keywords: plane, page not found, 404 +# Not a real page: keep it out of search, the LLM files, and search engines, and +# don't give it a canonical URL (it is served at whatever path was not found). +search: false +canonical: false +copyPage: false +outline: false +prev: false +next: false +editLink: false +lastUpdated: false +head: + - - meta + - name: robots + content: noindex, nofollow +--- + + + + diff --git a/packages/theme/README.md b/packages/theme/README.md index 63605ec0..d22473ef 100644 --- a/packages/theme/README.md +++ b/packages/theme/README.md @@ -46,7 +46,7 @@ src/ 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 + components/ PlaneHeader, CopyPageMenu, CookieConsent, NotFound (not-found.md → 404.html + not-found slot), Card, CardGroup, Tags, brand icons css/ index.css → fonts, tokens, base, layout, components, api types/ ambient shims (*.vue, @vp-* aliases) and the VitePress config augmentation ``` diff --git a/packages/theme/src/components/NotFound.vue b/packages/theme/src/components/NotFound.vue new file mode 100644 index 00000000..8e05daa8 --- /dev/null +++ b/packages/theme/src/components/NotFound.vue @@ -0,0 +1,87 @@ + + + + + diff --git a/packages/theme/src/index.ts b/packages/theme/src/index.ts index 88a32d6d..fc39e0e6 100644 --- a/packages/theme/src/index.ts +++ b/packages/theme/src/index.ts @@ -25,11 +25,12 @@ import Card from "./components/Card.vue"; import CardGroup from "./components/CardGroup.vue"; import Tags from "./components/Tags.vue"; import CookieConsent from "./components/CookieConsent.vue"; +import NotFound from "./components/NotFound.vue"; import { planeOptionsKey, type PlaneThemeOptions } from "./options"; export type { PlaneThemeOptions } from "./options"; export { planeOptionsKey } from "./options"; -export { Card, CardGroup, Tags, CookieConsent, PlaneLayout }; +export { Card, CardGroup, Tags, CookieConsent, NotFound, PlaneLayout }; /* --------------------------------------------------------------------------- * Client-side helpers @@ -122,6 +123,8 @@ export function createPlaneTheme(options: PlaneThemeOptions): Theme { app.component("Card", Card); app.component("CardGroup", CardGroup); app.component("Tags", Tags); + // Used by each app's docs/404.md (see components/NotFound.vue). + app.component("PlaneNotFound", NotFound); for (const [name, component] of Object.entries(options.components ?? {})) { app.component(name, component); } diff --git a/packages/theme/src/layout/Layout.vue b/packages/theme/src/layout/Layout.vue index d23a5cae..594676bb 100644 --- a/packages/theme/src/layout/Layout.vue +++ b/packages/theme/src/layout/Layout.vue @@ -5,13 +5,15 @@ import VPDefaultLayout from "./default-layout"; import OSSHeader from "./header"; import TopBanner from "./top-banner"; import CopyPageMenu from "../components/CopyPageMenu.vue"; +import NotFound from "../components/NotFound.vue"; const { frontmatter, site } = useData(); const slots = useSlots(); -// `doc-before` is rendered explicitly below (it also hosts the "Copy page" control), -// so keep it out of the dynamic forwarding loop. +// `doc-before` and `not-found` are rendered explicitly below (the first also hosts the +// "Copy page" control, the second defaults to the shared NotFound content), so keep them +// out of the dynamic forwarding loop. const forwardSlotNames = computed(() => - (Object.keys(slots) as string[]).filter((name) => name !== "doc-before"), + (Object.keys(slots) as string[]).filter((name) => name !== "doc-before" && name !== "not-found"), ); const variant = computed( @@ -41,6 +43,9 @@ const useDocLayout = computed(() => { +
diff --git a/packages/theme/src/options.ts b/packages/theme/src/options.ts index e33b624a..2d3a0550 100644 --- a/packages/theme/src/options.ts +++ b/packages/theme/src/options.ts @@ -19,6 +19,22 @@ export interface PlaneThemeOptions { }; /** Render the cookie-consent banner. Default: true. */ cookieConsent?: boolean; + /** Site-specific wording for the shared "page not found" content (404.md + not-found slot). */ + notFound?: { + /** How this site is referred to in prose, e.g. "the Plane docs". */ + siteName: string; + /** The other Plane docs site, for visitors who landed on the wrong one. */ + sibling?: { + /** Display name, e.g. "developers.plane.so". */ + name: string; + /** Origin without a trailing slash. */ + url: string; + /** What it covers, completing "Looking for …?", e.g. "the API, webhooks, or self-hosting". */ + covers: string; + }; + /** Where a person can ask for help. */ + help?: { text: string; link: string }; + }; /** Extra globally-registered components (site-specific markdown components). */ components?: Record; /** Extra `enhanceApp` work, run after the shared setup. */ diff --git a/packages/theme/src/seo.ts b/packages/theme/src/seo.ts index 11ca5bf0..4f019ddc 100644 --- a/packages/theme/src/seo.ts +++ b/packages/theme/src/seo.ts @@ -71,11 +71,13 @@ export function siteJsonLd(site: SiteIdentity): HeadConfig { /** * `` 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`. + * frontmatter already sets one or opts out with `canonical: false` (e.g. the + * 404 page, which is served at arbitrary URLs). 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 { + if (pageData.frontmatter.canonical === false) return 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$/, "");