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
1 change: 1 addition & 0 deletions apps/developer-docs/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
19 changes: 18 additions & 1 deletion apps/developer-docs/docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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",
],
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down
9 changes: 9 additions & 0 deletions apps/developer-docs/docs/.vitepress/theme/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
26 changes: 26 additions & 0 deletions apps/developer-docs/docs/not-found.md
Original file line number Diff line number Diff line change
@@ -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
---

<!-- buildEnd() in .vitepress/config turns the server-rendered not-found.html into the
404.html Vercel serves (with a 404 status) for every unknown path — VitePress leaves
its own 404.html empty. The content lives in the shared theme component so client-side
"not found" navigation shows exactly the same thing. -->

<PlaneNotFound />
1 change: 1 addition & 0 deletions apps/docs/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
```
Expand Down
18 changes: 17 additions & 1 deletion apps/docs/docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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: [
Expand Down Expand Up @@ -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: {
Expand Down
9 changes: 9 additions & 0 deletions apps/docs/docs/.vitepress/theme/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
},
});
26 changes: 26 additions & 0 deletions apps/docs/docs/not-found.md
Original file line number Diff line number Diff line change
@@ -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
---

<!-- buildEnd() in .vitepress/config turns the server-rendered not-found.html into the
404.html Vercel serves (with a 404 status) for every unknown path — VitePress leaves
its own 404.html empty. The content lives in the shared theme component so client-side
"not found" navigation shows exactly the same thing. -->

<PlaneNotFound />
2 changes: 1 addition & 1 deletion packages/theme/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
87 changes: 87 additions & 0 deletions packages/theme/src/components/NotFound.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<script setup lang="ts">
/**
* "Page not found" content, shared by both sites and rendered in two places so
* humans and agents always see the same thing:
*
* - each app's `docs/404.md` — VitePress server-renders it into the static
* 404.html that Vercel serves (with a real 404 status) for unknown paths
* - the Layout's `not-found` slot — client-side navigation to a missing page
*
* Site-specific wording comes from `PlaneThemeOptions.notFound`.
*/
import { inject } from "vue";
import { useData, withBase } from "vitepress";
import { planeOptionsKey } from "../options";

const notFound = inject(planeOptionsKey, undefined)?.notFound;
const { page } = useData();
</script>

<template>
<div class="plane-not-found vp-doc" :class="{ 'plane-not-found--standalone': page.isNotFound }">
<p class="plane-not-found__code" aria-hidden="true">404</p>
<h1>Page not found</h1>
<p>
There's no page at this address<template v-if="notFound"> on {{ notFound.siteName }}</template
>. It may have moved, or the URL may be mistyped.
</p>

<h2>Where to look next</h2>
<ul>
<li><a :href="withBase('/')">Home</a> — the start page, with the full navigation.</li>
<li>Search — press <kbd>⌘ K</kbd> (or <kbd>Ctrl K</kbd>) anywhere on the site.</li>
<li><a :href="withBase('/sitemap.xml')">Sitemap</a> — every page URL.</li>
<li>
<a :href="withBase('/llms.txt')">llms.txt</a> — an index of every page with a one-line
summary.
</li>
<li>
<a :href="withBase('/llms-full.txt')">llms-full.txt</a> — the whole site as a single
Markdown file.
</li>
<li v-if="notFound?.sibling">
Looking for {{ notFound.sibling.covers }}? That lives on
<a :href="notFound.sibling.url">{{ notFound.sibling.name }}</a> (<a
:href="`${notFound.sibling.url}/llms.txt`"
>llms.txt</a
>).
</li>
<li v-if="notFound?.help">
<a :href="notFound.help.link">{{ notFound.help.text }}</a>
</li>
</ul>

<p>
For agents: append <code>.md</code> to any page URL, or request it with
<code>Accept: text/markdown</code>, to get the page as Markdown.
</p>
</div>
</template>

<style scoped>
/* Inside `404.md` the doc layout already provides padding; the `not-found`
slot renders bare inside VPContent, so pad and centre it there. */
.plane-not-found--standalone {
margin: 0 auto;
max-width: 48rem;
padding: 48px 24px 96px;
}

@media (min-width: 768px) {
.plane-not-found--standalone {
padding: 64px 32px 128px;
}
}

.plane-not-found__code {
margin: 0;
font-size: 14px;
font-weight: 600;
letter-spacing: 0.08em;
color: var(--vp-c-text-3);
}

.plane-not-found h1 {
margin-top: 4px;
}
</style>
5 changes: 4 additions & 1 deletion packages/theme/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
Expand Down
11 changes: 8 additions & 3 deletions packages/theme/src/layout/Layout.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -41,6 +43,9 @@ const useDocLayout = computed(() => {
<slot name="doc-before" />
<CopyPageMenu />
</template>
<template #not-found>
<slot name="not-found"><NotFound /></slot>
</template>
</VPDefaultLayout>
</div>
<div v-else class="marketing-layout" :data-theme="frontmatter.theme" :data-variant="variant">
Expand Down
16 changes: 16 additions & 0 deletions packages/theme/src/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Component>;
/** Extra `enhanceApp` work, run after the shared setup. */
Expand Down
8 changes: 5 additions & 3 deletions packages/theme/src/seo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,13 @@ export function siteJsonLd(site: SiteIdentity): HeadConfig {

/**
* `<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`.
* 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$/, "");
Expand Down
Loading