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 @@