+ For agents: append .md to any page URL, or request it with
+ Accept: text/markdown, to get the page as Markdown.
+
+
+
+
+
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$/, "");