diff --git a/packages/astro-theme/src/components/DocsContent.astro b/packages/astro-theme/src/components/DocsContent.astro
index 64f58124..1e2fe8df 100644
--- a/packages/astro-theme/src/components/DocsContent.astro
+++ b/packages/astro-theme/src/components/DocsContent.astro
@@ -1,5 +1,6 @@
---
import DocsPage from "./DocsPage.astro";
+import { toDocsMarkdownUrl } from "@farming-labs/docs";
const { data, config = null } = Astro.props;
@@ -71,6 +72,10 @@ const openDocsProviders = (() => {
})();
const pathname = Astro.url.pathname;
+const pageUrl = typeof data.url === "string" ? data.url : pathname;
+const markdownAlternateHref = config?.staticExport
+ ? null
+ : toDocsMarkdownUrl(pageUrl, { locale: data.locale });
const githubFileUrl = config?.github && data.editOnGithub ? data.editOnGithub : null;
const pageActionsPosition = (() => {
@@ -141,6 +146,7 @@ const htmlWithoutFirstH1 = (data.html || "").replace(/
]*>[\s\S]*?<\/h1>\s*
{data.title}{titleSuffix}
{data.description && }
+ {markdownAlternateHref && }
Promise<{
tree: ReturnType;
flatPages: PageNode[];
+ url: string;
title: string;
description?: string;
html: string;
@@ -710,6 +711,7 @@ export function createDocsServer(config: Record = {}): DocsServer {
return {
tree,
flatPages,
+ url: currentUrl,
title: (data.title as string) ?? fallbackTitle,
description: data.description as string | undefined,
html,
diff --git a/packages/docs/src/agent.test.ts b/packages/docs/src/agent.test.ts
index 55e22a2a..5e56b6f6 100644
--- a/packages/docs/src/agent.test.ts
+++ b/packages/docs/src/agent.test.ts
@@ -12,6 +12,7 @@ import {
resolveDocsLlmsTxtFormat,
resolveDocsSkillFormat,
resolveDocsMarkdownRequest,
+ toDocsMarkdownUrl,
} from "./agent.js";
describe("agent route helpers", () => {
@@ -123,6 +124,18 @@ describe("agent route helpers", () => {
expect(hijackRoute).toBeNull();
});
+ it("builds per-page markdown alternate URLs", () => {
+ expect(toDocsMarkdownUrl("/docs")).toBe("/docs.md");
+ expect(toDocsMarkdownUrl("/docs/install")).toBe("/docs/install.md");
+ expect(toDocsMarkdownUrl("/docs/install.md")).toBe("/docs/install.md");
+ expect(toDocsMarkdownUrl("/docs/install?ref=sidebar", { locale: "fr" })).toBe(
+ "/docs/install.md?ref=sidebar&lang=fr",
+ );
+ expect(toDocsMarkdownUrl("/docs/install?lang=es", { locale: "fr" })).toBe(
+ "/docs/install.md?lang=es",
+ );
+ });
+
it("renders agent-specific markdown documents", () => {
const human = resolveDocsAgentMdxContent("Visible\n\n\nHidden\n", "human");
const agent = resolveDocsAgentMdxContent("Visible\n\n\nHidden\n", "agent");
diff --git a/packages/docs/src/agent.ts b/packages/docs/src/agent.ts
index a77f1520..5799d050 100644
--- a/packages/docs/src/agent.ts
+++ b/packages/docs/src/agent.ts
@@ -90,6 +90,17 @@ export function normalizeDocsUrlPath(value: string): string {
return normalized.replace(/\/+$/, "");
}
+export function toDocsMarkdownUrl(url: string, options: { locale?: string } = {}): string {
+ const [withoutHash, hash = ""] = url.split("#", 2);
+ const [pathname, query = ""] = withoutHash.split("?", 2);
+ const normalizedPath = normalizeDocsUrlPath(pathname || "/");
+ const markdownPath = normalizedPath.endsWith(".md") ? normalizedPath : `${normalizedPath}.md`;
+ const params = new URLSearchParams(query);
+ if (options.locale && !params.has("lang")) params.set("lang", options.locale);
+ const search = params.toString();
+ return `${markdownPath}${search ? `?${search}` : ""}${hash ? `#${hash}` : ""}`;
+}
+
export function isDocsAgentDiscoveryRequest(url: URL): boolean {
const pathname = normalizeDocsUrlPath(url.pathname);
if (pathname === DEFAULT_DOCS_API_ROUTE && url.searchParams.get("agent")?.trim() === "spec") {
diff --git a/packages/docs/src/cli/templates.ts b/packages/docs/src/cli/templates.ts
index bcc98fbd..77752481 100644
--- a/packages/docs/src/cli/templates.ts
+++ b/packages/docs/src/cli/templates.ts
@@ -1085,6 +1085,7 @@ export function tanstackDocsIndexRouteTemplate(opts: TanstackRouteTemplateOption
const entryUrl = `/${opts.entry.replace(/^\/+|\/+$/g, "")}`;
return `\
import { createFileRoute } from "@tanstack/react-router";
+import { toDocsMarkdownUrl } from "@farming-labs/docs";
import { TanstackDocsPage } from "@farming-labs/tanstack-start/react";
import { loadDocPage } from "${tanstackDocsFunctionsImport(opts)}";
import docsConfig from "${tanstackDocsConfigImport(opts.filePath)}";
@@ -1092,6 +1093,9 @@ import docsConfig from "${tanstackDocsConfigImport(opts.filePath)}";
export const Route = createFileRoute("${entryUrl}/")({
loader: () => loadDocPage({ data: { pathname: "${entryUrl}" } }),
head: ({ loaderData }) => ({
+ links: loaderData && !docsConfig.staticExport
+ ? [{ rel: "alternate", type: "text/markdown", href: toDocsMarkdownUrl(loaderData.url, { locale: loaderData.locale }) }]
+ : [],
meta: [
{ title: loaderData ? \`\${loaderData.title} – ${opts.projectName}\` : "${opts.projectName}" },
...(loaderData?.description
@@ -1116,7 +1120,7 @@ export function tanstackDocsCatchAllRouteTemplate(opts: TanstackRouteTemplateOpt
: relativeImport(opts.filePath, "src/lib/docs.server.ts");
return `\
import { createFileRoute, notFound } from "@tanstack/react-router";
-import { isDocsPublicGetRequest } from "@farming-labs/docs";
+import { isDocsPublicGetRequest, toDocsMarkdownUrl } from "@farming-labs/docs";
import { TanstackDocsPage } from "@farming-labs/tanstack-start/react";
import { loadDocPage } from "${tanstackDocsFunctionsImport(opts)}";
import { docsServer } from "${serverImport}";
@@ -1150,6 +1154,9 @@ export const Route = createFileRoute("${entryUrl}/$")({
}
},
head: ({ loaderData }) => ({
+ links: loaderData && !docsConfig.staticExport
+ ? [{ rel: "alternate", type: "text/markdown", href: toDocsMarkdownUrl(loaderData.url, { locale: loaderData.locale }) }]
+ : [],
meta: [
{ title: loaderData ? \`\${loaderData.title} – ${opts.projectName}\` : "${opts.projectName}" },
...(loaderData?.description
diff --git a/packages/docs/src/index.ts b/packages/docs/src/index.ts
index 0571506f..ff72c68e 100644
--- a/packages/docs/src/index.ts
+++ b/packages/docs/src/index.ts
@@ -78,6 +78,7 @@ export {
resolveDocsLlmsTxtFormat,
resolveDocsSkillFormat,
resolveDocsMarkdownRequest,
+ toDocsMarkdownUrl,
} from "./agent.js";
export {
DEFAULT_SITEMAP_MANIFEST_PATH,
diff --git a/packages/fumadocs/src/docs-layout.test.ts b/packages/fumadocs/src/docs-layout.test.ts
index bdb75009..22ff3ab9 100644
--- a/packages/fumadocs/src/docs-layout.test.ts
+++ b/packages/fumadocs/src/docs-layout.test.ts
@@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
-import { createDocsLayout } from "./docs-layout.js";
+import { createDocsLayout, createPageMetadata } from "./docs-layout.js";
function findDocsPageClientProps(node: unknown): Record | null {
if (!node || typeof node !== "object") return null;
@@ -57,6 +57,20 @@ function findDocsLayoutTree(node: unknown): Record | null {
return children ? findDocsLayoutTree(children) : null;
}
+describe("createPageMetadata", () => {
+ it("preserves the active locale in markdown alternate links", () => {
+ const metadata = createPageMetadata(
+ { entry: "docs" },
+ { title: "Installation", description: "Install the framework" },
+ undefined,
+ "/docs/installation",
+ { locale: "fr" },
+ ) as { alternates?: { types?: Record } };
+
+ expect(metadata.alternates?.types?.["text/markdown"]).toBe("/docs/installation.md?lang=fr");
+ });
+});
+
describe("createDocsLayout pageActions", () => {
let tmpDir: string;
let originalCwd: string;
diff --git a/packages/fumadocs/src/docs-layout.tsx b/packages/fumadocs/src/docs-layout.tsx
index 5823bede..fa32d14a 100644
--- a/packages/fumadocs/src/docs-layout.tsx
+++ b/packages/fumadocs/src/docs-layout.tsx
@@ -12,6 +12,7 @@ import {
resolveDocsAgentMdxContent,
resolveDocsAnalyticsConfig,
resolvePageSidebarFolderIndexBehavior,
+ toDocsMarkdownUrl,
} from "@farming-labs/docs";
import type {
DocsConfig,
@@ -605,7 +606,7 @@ export function createDocsMetadata(config: DocsConfig) {
* ```ts
* export function generateMetadata({ params }) {
* const page = getPage(params.slug);
- * return createPageMetadata(docsConfig, page.data);
+ * return createPageMetadata(docsConfig, page.data, undefined, page.url, { locale });
* }
* ```
*/
@@ -613,12 +614,22 @@ export function createPageMetadata(
config: DocsConfig,
page: Pick,
baseUrl?: string,
+ url?: string,
+ options: { locale?: string } = {},
) {
const result: Record = {
title: page.title,
...(page.description ? { description: page.description } : {}),
};
+ if (url && !(config as { staticExport?: boolean }).staticExport) {
+ result.alternates = {
+ types: {
+ "text/markdown": toDocsMarkdownUrl(url, { locale: options.locale }),
+ },
+ };
+ }
+
if (config.og?.enabled !== false) {
const openGraph = buildPageOpenGraph(page, config.og, baseUrl);
if (openGraph) result.openGraph = openGraph;
diff --git a/packages/next/package.json b/packages/next/package.json
index 634b41ea..56c223b5 100644
--- a/packages/next/package.json
+++ b/packages/next/package.json
@@ -73,6 +73,11 @@
"types": "./dist/mdx-plugins/remark-og.d.mts",
"import": "./dist/mdx-plugins/remark-og.mjs",
"default": "./dist/mdx-plugins/remark-og.mjs"
+ },
+ "./mdx-plugins/remark-markdown-alternate": {
+ "types": "./dist/mdx-plugins/remark-markdown-alternate.d.mts",
+ "import": "./dist/mdx-plugins/remark-markdown-alternate.mjs",
+ "default": "./dist/mdx-plugins/remark-markdown-alternate.mjs"
}
},
"scripts": {
diff --git a/packages/next/src/changelog.tsx b/packages/next/src/changelog.tsx
index d7ac4282..e1af0d15 100644
--- a/packages/next/src/changelog.tsx
+++ b/packages/next/src/changelog.tsx
@@ -557,10 +557,15 @@ export function createNextChangelogStaticParams(entries: GeneratedChangelogEntry
export function createNextChangelogIndexMetadata(config: DocsConfig): Metadata {
const changelog = resolveChangelogConfig(config.changelog);
- return createPageMetadata(config, {
- title: changelog.title,
- description: changelog.description,
- }) as Metadata;
+ return createPageMetadata(
+ config,
+ {
+ title: changelog.title,
+ description: changelog.description,
+ },
+ undefined,
+ getListingUrl(config),
+ ) as Metadata;
}
export function createNextChangelogEntryMetadata(
@@ -576,15 +581,25 @@ export function createNextChangelogEntryMetadata(
if (!entry) {
const changelog = resolveChangelogConfig(config.changelog);
- return createPageMetadata(config, {
- title: changelog.title,
- description: changelog.description,
- }) as Metadata;
+ return createPageMetadata(
+ config,
+ {
+ title: changelog.title,
+ description: changelog.description,
+ },
+ undefined,
+ getListingUrl(config),
+ ) as Metadata;
}
- return createPageMetadata(config, {
- title: entry.title,
- description: entry.description,
- }) as Metadata;
+ return createPageMetadata(
+ config,
+ {
+ title: entry.title,
+ description: entry.description,
+ },
+ undefined,
+ `${getListingUrl(config)}/${entry.slug}`,
+ ) as Metadata;
};
}
diff --git a/packages/next/src/config.ts b/packages/next/src/config.ts
index 9a87bec7..aca4605e 100644
--- a/packages/next/src/config.ts
+++ b/packages/next/src/config.ts
@@ -301,6 +301,8 @@ function createDocsWorkspaceAliases(): Record {
"@farming-labs/next/mdx-plugins/remark-heading":
"./packages/next/src/mdx-plugins/remark-heading.ts",
"@farming-labs/next/mdx-plugins/remark-og": "./packages/next/src/mdx-plugins/remark-og.ts",
+ "@farming-labs/next/mdx-plugins/remark-markdown-alternate":
+ "./packages/next/src/mdx-plugins/remark-markdown-alternate.ts",
"@farming-labs/theme": "./packages/fumadocs/src/index.ts",
"@farming-labs/theme/api": "./packages/fumadocs/src/docs-api.ts",
"@farming-labs/theme/client-hooks": "./packages/fumadocs/src/docs-client-hooks.tsx",
@@ -1628,6 +1630,10 @@ export function withDocs(nextConfig: NextConfig = {}): NextConfig {
if (ogEndpoint) {
remarkPlugins.push(["@farming-labs/next/mdx-plugins/remark-og", { endpoint: ogEndpoint }]);
}
+ remarkPlugins.push([
+ "@farming-labs/next/mdx-plugins/remark-markdown-alternate",
+ { entry, appDir, contentDir: docsContentDir, enabled: !isStaticExport },
+ ]);
remarkPlugins.push(
["remark-mdx-frontmatter", { name: "metadata" }],
"@farming-labs/next/mdx-plugins/remark-heading",
@@ -1721,6 +1727,14 @@ export function withDocs(nextConfig: NextConfig = {}): NextConfig {
"client-callbacks.mjs",
),
"@farming-labs/next/layout": join(workspaceRoot, "packages", "next", "dist", "layout.mjs"),
+ "@farming-labs/next/mdx-plugins/remark-markdown-alternate": join(
+ workspaceRoot,
+ "packages",
+ "next",
+ "dist",
+ "mdx-plugins",
+ "remark-markdown-alternate.mjs",
+ ),
"@farming-labs/theme$": join(workspaceRoot, "packages", "fumadocs", "dist", "index.mjs"),
"@farming-labs/theme/api": join(
workspaceRoot,
diff --git a/packages/next/src/mdx-plugins/remark-markdown-alternate.test.ts b/packages/next/src/mdx-plugins/remark-markdown-alternate.test.ts
new file mode 100644
index 00000000..6f8a98ed
--- /dev/null
+++ b/packages/next/src/mdx-plugins/remark-markdown-alternate.test.ts
@@ -0,0 +1,54 @@
+import { describe, expect, it } from "vitest";
+import remarkMarkdownAlternate from "./remark-markdown-alternate.js";
+
+describe("remarkMarkdownAlternate", () => {
+ it("adds a text/markdown alternate URL for docs page routes", () => {
+ const tree = {
+ children: [{ type: "yaml", value: 'title: "Install"' }],
+ };
+ const transform = remarkMarkdownAlternate({ entry: "docs", contentDir: "app/docs" });
+
+ transform(tree, { path: "/repo/app/docs/install/page.mdx" });
+
+ expect(tree.children[0]?.value).toContain("alternates:");
+ expect(tree.children[0]?.value).toContain('text/markdown: "/docs/install.md"');
+ });
+
+ it("adds root docs markdown alternate URLs", () => {
+ const tree = {
+ children: [{ type: "yaml", value: 'title: "Docs"' }],
+ };
+ const transform = remarkMarkdownAlternate({ entry: "docs", contentDir: "app/docs" });
+
+ transform(tree, { path: "/repo/app/docs/page.mdx" });
+
+ expect(tree.children[0]?.value).toContain('text/markdown: "/docs.md"');
+ });
+
+ it("handles relative source paths", () => {
+ const tree = {
+ children: [{ type: "yaml", value: 'title: "Quickstart"' }],
+ };
+ const transform = remarkMarkdownAlternate({ entry: "docs", contentDir: "app/docs" });
+
+ transform(tree, { path: "app/docs/quickstart/page.md" });
+
+ expect(tree.children[0]?.value).toContain('text/markdown: "/docs/quickstart.md"');
+ });
+
+ it("does not overwrite custom alternates", () => {
+ const tree = {
+ children: [
+ {
+ type: "yaml",
+ value: 'title: "Install"\nalternates:\n canonical: "/custom"',
+ },
+ ],
+ };
+ const transform = remarkMarkdownAlternate({ entry: "docs", contentDir: "app/docs" });
+
+ transform(tree, { path: "/repo/app/docs/install/page.mdx" });
+
+ expect(tree.children[0]?.value).not.toContain("text/markdown");
+ });
+});
diff --git a/packages/next/src/mdx-plugins/remark-markdown-alternate.ts b/packages/next/src/mdx-plugins/remark-markdown-alternate.ts
new file mode 100644
index 00000000..5fbe3e82
--- /dev/null
+++ b/packages/next/src/mdx-plugins/remark-markdown-alternate.ts
@@ -0,0 +1,104 @@
+import { toDocsMarkdownUrl } from "@farming-labs/docs";
+
+interface RemarkMarkdownAlternateOptions {
+ entry?: string;
+ appDir?: string;
+ contentDir?: string;
+ enabled?: boolean;
+}
+
+interface MarkdownNode {
+ type: string;
+ value?: string;
+}
+
+interface VFileLike {
+ path?: string;
+ history?: string[];
+}
+
+function normalizePath(value: string): string {
+ return value.replace(/\\/g, "/").replace(/\/+/g, "/");
+}
+
+function normalizeSegment(value: string | undefined, fallback: string): string {
+ return (value ?? fallback).replace(/^\/+|\/+$/g, "") || fallback;
+}
+
+function escapeYamlString(value: string): string {
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
+}
+
+function routeFromSourcePath(
+ filePath: string,
+ options: RemarkMarkdownAlternateOptions,
+): string | null {
+ const normalized = `/${normalizePath(filePath).replace(/^\/+/, "")}`;
+ if (!/\/page\.mdx?$/.test(normalized)) return null;
+
+ const entry = normalizeSegment(options.entry, "docs");
+ const candidates = [
+ options.contentDir,
+ options.appDir ? `${options.appDir}/${entry}` : undefined,
+ `src/app/${entry}`,
+ `app/${entry}`,
+ ]
+ .filter(
+ (candidate): candidate is string => typeof candidate === "string" && candidate.length > 0,
+ )
+ .map((candidate) => normalizePath(candidate).replace(/^\/+|\/+$/g, ""));
+
+ for (const candidate of candidates) {
+ const marker = `/${candidate}/`;
+ const markerIndex = normalized.lastIndexOf(marker);
+ if (markerIndex === -1) continue;
+
+ const relativePath = normalized.slice(markerIndex + marker.length);
+ if (!relativePath.endsWith("page.mdx") && !relativePath.endsWith("page.md")) continue;
+
+ const slug = relativePath.replace(/\/?page\.mdx?$/, "").replace(/^\/+|\/+$/g, "");
+ return slug ? `/${entry}/${slug}` : `/${entry}`;
+ }
+
+ return null;
+}
+
+function getFilePath(file?: VFileLike): string | undefined {
+ return file?.path ?? file?.history?.[0];
+}
+
+function hasAlternates(yaml: string): boolean {
+ return /^\s*alternates\s*:/m.test(yaml);
+}
+
+function alternateYaml(url: string): string {
+ return [
+ "alternates:",
+ " types:",
+ ` text/markdown: "${escapeYamlString(toDocsMarkdownUrl(url))}"`,
+ ].join("\n");
+}
+
+export default function remarkMarkdownAlternate(options: RemarkMarkdownAlternateOptions = {}) {
+ return (tree: { children: MarkdownNode[] }, file?: VFileLike) => {
+ if (options.enabled === false) return;
+
+ const filePath = getFilePath(file);
+ if (!filePath) return;
+
+ const route = routeFromSourcePath(filePath, options);
+ if (!route) return;
+
+ const yamlNode = tree.children.find((node) => node.type === "yaml");
+ if (yamlNode?.value) {
+ if (hasAlternates(yamlNode.value)) return;
+ yamlNode.value += `\n${alternateYaml(route)}`;
+ return;
+ }
+
+ tree.children.unshift({
+ type: "yaml",
+ value: alternateYaml(route),
+ });
+ };
+}
diff --git a/packages/next/tsdown.config.ts b/packages/next/tsdown.config.ts
index 30f681fe..c9c53ee8 100644
--- a/packages/next/tsdown.config.ts
+++ b/packages/next/tsdown.config.ts
@@ -14,6 +14,7 @@ export default defineConfig({
"src/mdx-plugins/rehype-toc.ts",
"src/mdx-plugins/rehype-code.ts",
"src/mdx-plugins/remark-og.ts",
+ "src/mdx-plugins/remark-markdown-alternate.ts",
],
format: "esm",
dts: true,
diff --git a/packages/nuxt-theme/src/components/DocsContent.vue b/packages/nuxt-theme/src/components/DocsContent.vue
index a4948db6..f4ec6b4b 100644
--- a/packages/nuxt-theme/src/components/DocsContent.vue
+++ b/packages/nuxt-theme/src/components/DocsContent.vue
@@ -2,6 +2,7 @@
import { computed, ref, onMounted, onUnmounted, watch } from "vue";
import { useRoute } from "vue-router";
import { useHead } from "#app";
+import { toDocsMarkdownUrl } from "@farming-labs/docs";
import DocsPage from "./DocsPage.vue";
const DEFAULT_OPEN_PROVIDERS = [
@@ -12,6 +13,7 @@ const DEFAULT_OPEN_PROVIDERS = [
const props = defineProps<{
data: {
title: string;
+ url?: string;
description?: string;
html: string;
rawMarkdown?: string;
@@ -88,6 +90,12 @@ const llmsTxtEnabled = computed(() => {
});
const entry = computed(() => (props.data.entry as string) ?? (props.config?.entry as string) ?? "docs");
+const pageUrl = computed(() =>
+ props.data.url ?? `/${entry.value}${props.data.slug ? `/${props.data.slug}` : ""}`,
+);
+const markdownAlternateHref = computed(() =>
+ props.config?.staticExport ? null : toDocsMarkdownUrl(pageUrl.value, { locale: props.data.locale }),
+);
const copyMarkdownEnabled = computed(() => {
const pa = props.config?.pageActions as Record | undefined;
@@ -227,6 +235,10 @@ useHead({
title: () => `${props.data.title}${titleSuffix.value}`,
meta: () =>
metaDescription.value ? [{ name: "description", content: metaDescription.value }] : [],
+ link: () =>
+ markdownAlternateHref.value
+ ? [{ rel: "alternate", type: "text/markdown", href: markdownAlternateHref.value }]
+ : [],
});
function handleCopyPage() {
diff --git a/packages/nuxt/src/server.ts b/packages/nuxt/src/server.ts
index ab39c4d4..474057cf 100644
--- a/packages/nuxt/src/server.ts
+++ b/packages/nuxt/src/server.ts
@@ -142,6 +142,7 @@ export interface DocsServer {
load: (pathname: string) => Promise<{
tree: ReturnType;
flatPages: PageNode[];
+ url: string;
title: string;
description?: string;
html: string;
@@ -700,6 +701,7 @@ export function createDocsServer(config: Record = {}): DocsServer {
return {
tree,
flatPages,
+ url: currentUrl,
title: (data.title as string) ?? fallbackTitle,
description: data.description as string | undefined,
html,
diff --git a/packages/svelte-theme/src/components/DocsContent.svelte b/packages/svelte-theme/src/components/DocsContent.svelte
index e2ba0dc9..3005595a 100644
--- a/packages/svelte-theme/src/components/DocsContent.svelte
+++ b/packages/svelte-theme/src/components/DocsContent.svelte
@@ -1,6 +1,7 @@