+ {
+ page.version && page.version !== currentDocsVersion && (
+
+ )
+ }
+
+
+
+
diff --git a/src/layouts/OldDocsLayout.astro b/src/layouts/OldDocsLayout.astro
new file mode 100644
index 0000000000..f8531f9043
--- /dev/null
+++ b/src/layouts/OldDocsLayout.astro
@@ -0,0 +1,99 @@
+---
+import LiquidInclude from "@/components/LiquidInclude.astro";
+import { getVersionSupport } from "@/lib/docs/versionSupport";
+import { getLiquidRenderer } from "@/lib/liquid/liquidRenderer";
+import DefaultLayout from "./DefaultLayout.astro";
+
+type Props = {
+ page: {
+ category: string;
+ version: string;
+ url: string;
+ title?: string;
+ source_url?: string;
+ };
+ dirname: string;
+};
+
+const { page, dirname } = Astro.props;
+
+const lq = getLiquidRenderer({ page, dirname });
+
+const currentDocsVersion = await lq.render("{{ site.docs_version }}");
+const latestOrExplicitVersion =
+ page.version === currentDocsVersion && page.url.includes("/docs/latest/")
+ ? "latest"
+ : page.version;
+
+const support = getVersionSupport(page.version);
+
+const showChrome = page.category !== "ignore";
+---
+
+
+
+
+
+
+
+ {
+ showChrome && (
+
+ )
+ }
+
+
+
diff --git a/src/lib/docs/collectRedirects.ts b/src/lib/docs/collectRedirects.ts
new file mode 100644
index 0000000000..18b2e56344
--- /dev/null
+++ b/src/lib/docs/collectRedirects.ts
@@ -0,0 +1,53 @@
+import fs from "node:fs";
+import path from "node:path";
+import glob from "glob";
+import matter from "gray-matter";
+import { DOCS_SRC_ROOT } from "../../constants";
+import { resolveDocUrl } from "./resolveDoc";
+
+const EXCLUDE = ["**/embedding/sdk/api/snippets/**"];
+
+type ScanTarget = { extension: "md" | "html"; stripExtension: boolean };
+
+const SCAN_TARGETS: ScanTarget[] = [
+ { extension: "md", stripExtension: true },
+ { extension: "html", stripExtension: false },
+];
+
+// Builds a flat { [oldPath]: canonicalUrl } map from every doc's
+// `redirect_from` frontmatter, for use as Astro's `redirects` config.
+export const collectRedirects = (): Record
=> {
+ const redirects: Record = {};
+ const claimedBy = new Map();
+
+ for (const { extension, stripExtension } of SCAN_TARGETS) {
+ const base = path.resolve(DOCS_SRC_ROOT);
+ const entries: string[] = glob.sync(`**/*.${extension}`, {
+ cwd: base,
+ ignore: EXCLUDE,
+ });
+
+ for (const relPath of entries) {
+ const absPath = path.join(base, relPath);
+ const { data } = matter(fs.readFileSync(absPath, "utf8"));
+ const redirectFrom: string[] | undefined = data.redirect_from;
+ if (!redirectFrom?.length) continue;
+
+ const id = stripExtension ? relPath.replace(/\.md$/, "") : relPath;
+ const { url } = resolveDocUrl({ id, permalink: data.permalink });
+
+ for (const source of redirectFrom) {
+ const existingOwner = claimedBy.get(source);
+ if (existingOwner && redirects[source] !== url) {
+ console.warn(
+ `[collectRedirects] "${source}" is claimed by both ${existingOwner} (-> ${redirects[source]}) and ${relPath} (-> ${url}); keeping the latest.`,
+ );
+ }
+ claimedBy.set(source, relPath);
+ redirects[source] = url;
+ }
+ }
+ }
+
+ return redirects;
+};
diff --git a/src/lib/docs/docsHtmlLoader.ts b/src/lib/docs/docsHtmlLoader.ts
new file mode 100644
index 0000000000..b31fd154fe
--- /dev/null
+++ b/src/lib/docs/docsHtmlLoader.ts
@@ -0,0 +1,45 @@
+import { promises as fs } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { DOCS_SRC_ROOT } from "@/constants";
+import type { Loader } from "astro/loaders";
+import glob from "glob";
+import matter from "gray-matter";
+
+// Astro's built-in glob() loader only parses frontmatter and body content for
+// *markdown* files, but our docs root also contains HTML files (TypeDoc-generated
+// SDK API reference pages and per-version api.html pages) that use the same
+// frontmatter-plus-Liquid conventions as the Markdown docs. This custom loader
+// lets us process both file types consistently.
+export const docsHtmlLoader = (): Loader => ({
+ name: "docs-html-loader",
+ load: async ({ config, store, parseData, generateDigest, logger }) => {
+ store.clear();
+
+ const base = new URL(`${DOCS_SRC_ROOT}/`, config.root);
+ const baseDir = fileURLToPath(base);
+ const rootDir = fileURLToPath(config.root);
+ const entries: string[] = glob.sync("**/*.html", {
+ cwd: baseDir,
+ ignore: ["**/embedding/sdk/api/snippets/**"],
+ });
+
+ for (const entry of entries) {
+ const absPath = path.join(baseDir, entry);
+ const contents = await fs.readFile(absPath, "utf-8");
+ const { data, content: body } = matter(contents);
+ const id = entry;
+
+ const parsedData = await parseData({ id, data, filePath: absPath });
+ store.set({
+ id,
+ data: parsedData,
+ body,
+ filePath: path.relative(rootDir, absPath).split(path.sep).join("/"),
+ digest: generateDigest(contents),
+ });
+ }
+
+ logger.info(`Loaded ${entries.length} html docs`);
+ },
+});
diff --git a/src/lib/docs/llmsTxt.ts b/src/lib/docs/llmsTxt.ts
new file mode 100644
index 0000000000..36fedd7fdc
--- /dev/null
+++ b/src/lib/docs/llmsTxt.ts
@@ -0,0 +1,349 @@
+// Astro port of `_plugins/jekyll_generate_llms_files_plugin.rb`, which ran as
+// a Jekyll `post_write` hook. Docs are no longer rendered by Jekyll so this
+// logic now lives here and is consumed by `src/pages/docs/[version]/llms.txt.ts`
+// and `src/pages/docs/[version]/llms-[section]-full.txt.ts`.
+//
+// See: https://llmstxt.org for the spec.
+
+import fs from "node:fs";
+import path from "node:path";
+import { getCollection, type DataEntryMap } from "astro:content";
+import YAML from "yamljs";
+
+export type Doc = DataEntryMap["docs"][number];
+
+const REPO = "metabase/metabase";
+
+// Sections to generate llms-{section}-full.txt for.
+// These huge files are used by AI tools like Cursor for RAG chunking and indexing.
+// Add more sections to let AI agents understand Metabase better.
+//
+// NOTE: adding a section here also requires adding a matching literal page
+// file, e.g. `src/pages/docs/[version]/llms-{section}-full.txt.ts` re-using
+// `generateFullContent`/`getFullSections` below (mirroring the two existing
+// ones), since Astro needs a concrete route to build.
+
+// TODO: "agent-api" is not a folder so nothing gets output for it. This was an issue in the jekyll hook and left as-is in the astro migration.
+export const LLMS_FULL_SECTIONS = ["embedding", "agent-api"] as const;
+export type LlmsFullSection = (typeof LLMS_FULL_SECTIONS)[number];
+
+// Paths to include in llms.txt generation.
+//
+// We focus on content relevant to coding with Metabase:
+// 1. Embedding integration guides (modular embedding & SDK)
+// 2. Embedding related setup and config (auth, SSO)
+//
+// Use prefix matching - a path matches if it starts with any of these.
+// For specific files, include the full path. For directories, include trailing slash.
+const INCLUDED_PATHS = [
+ // All embedding docs (SDK, modular embedding, integration guides)
+ "embedding/",
+
+ // Auth/SSO configuration for embedding
+ "people-and-groups/api-keys.md",
+ "people-and-groups/authenticating-with-jwt.md",
+ "people-and-groups/authenticating-with-saml.md",
+ "people-and-groups/saml-auth0.md",
+ "people-and-groups/saml-azure.md",
+ "people-and-groups/saml-google.md",
+ "people-and-groups/saml-keycloak.md",
+ "people-and-groups/saml-okta.md",
+ "people-and-groups/google-sign-in.md",
+ "people-and-groups/ldap.md",
+
+ // Configuration reference
+ "configuring-metabase/environment-variables.md",
+ "configuring-metabase/config-file.md",
+
+ // Agent API reference
+ "agent-api/",
+];
+
+// Paths to exclude from llms.txt generation (applied after allowlist)
+const EXCLUDED_PATHS = ["embedding/sdk/api/snippets"];
+
+const releaseBranch: string | undefined = YAML.parse(
+ fs.readFileSync(path.join(process.cwd(), "_config.yml"), "utf8"),
+).release_branch;
+
+// Path relative to the doc's version root, with the `.md` extension
+// restored (e.g. "embedding/authentication.md"), matching the old plugin's
+// `doc.relative_path.sub(%r{^_docs/[^/]+/}, '')`.
+const versionRelativePath = (doc: Doc): string =>
+ `${doc.id.slice(doc.id.indexOf("/") + 1)}.md`;
+
+// Groups the `docs` content collection by version, keyed like the old
+// `_docs/VERSION/...` directory structure (e.g. "latest", "v0.58"), sorted
+// for consistent ordering across all generated files. Skips README.md files
+// and docs directly under `_docs/` (e.g. `_docs/index.md`), matching the old
+// plugin's `docs_by_version` grouping.
+export const getDocsByVersion = async (): Promise