From 22d7813b30909ad353620f0cb95ca13f42efe17a Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Mon, 24 Aug 2026 15:28:11 -0400 Subject: [PATCH 01/13] GRO-835 starts getting local metabase repo working for /latest routes, defined via .env file --- .env-dist | 1 + src/constants.ts | 4 ++++ src/content.config.ts | 20 +++++++++++++++++--- src/pages/docs/[version]/[...slug].astro | 9 +++++++-- 4 files changed, 29 insertions(+), 5 deletions(-) create mode 100644 .env-dist diff --git a/.env-dist b/.env-dist new file mode 100644 index 0000000000..87c80bfbe8 --- /dev/null +++ b/.env-dist @@ -0,0 +1 @@ +METABASE_REPO_PATH=../metabase diff --git a/src/constants.ts b/src/constants.ts index 98b3b3597b..4b82aaa35b 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,5 +1,9 @@ export const DOCS_SRC_ROOT = "_docs"; +export const DOCS_LATEST_ROOT = import.meta.env.METABASE_REPO_PATH + ? `${import.meta.env.METABASE_REPO_PATH}/docs` + : `${DOCS_SRC_ROOT}/latest`; + export const UNIFY_ENABLED_PAGES = [ "/docs/latest/embedding/modular-embedding", "/docs/latest/embedding/sdk/quickstart-with-sample-app", diff --git a/src/content.config.ts b/src/content.config.ts index 49c5563892..d044cdebaa 100644 --- a/src/content.config.ts +++ b/src/content.config.ts @@ -1,11 +1,11 @@ import { defineCollection } from "astro:content"; import { glob } from "astro/loaders"; +import { DOCS_LATEST_ROOT, DOCS_SRC_ROOT } from "./constants"; import { docsHtmlLoader } from "./lib/docs/docsHtmlLoader"; -import { DOCS_SRC_ROOT } from "./constants"; const docs = defineCollection({ loader: glob({ - pattern: ["**/*.md", "!**/embedding/sdk/api/snippets/**"], + pattern: ["**/*.md", "!**/embedding/sdk/api/snippets/**", "!latest/**"], base: DOCS_SRC_ROOT, // Preserves dots (.) in pathnames @@ -13,10 +13,24 @@ const docs = defineCollection({ }), }); +const docsLatest = defineCollection({ + loader: glob({ + pattern: [ + "**/*.md", + "!**/embedding/sdk/api/snippets/**", + "!**/_includes/**", + ], + base: DOCS_LATEST_ROOT, + + // Preserves dots (.) in pathnames + generateId: ({ entry }) => `latest/${entry.replace(/\.md$/, "")}`, + }), +}); + // Raw, standalone HTML docs (TypeDoc-generated SDK API reference pages, // per-version api.html ToC pages) that the glob() loader can't parse. const docsHtml = defineCollection({ loader: docsHtmlLoader(), }); -export const collections = { docs, docsHtml }; +export const collections = { docs, docsLatest, docsHtml }; diff --git a/src/pages/docs/[version]/[...slug].astro b/src/pages/docs/[version]/[...slug].astro index a3ce511184..7437f5428c 100644 --- a/src/pages/docs/[version]/[...slug].astro +++ b/src/pages/docs/[version]/[...slug].astro @@ -13,8 +13,9 @@ type Props = | { kind: "html"; doc: DataEntryMap["docsHtml"][number] }; export const getStaticPaths = async () => { - const [docs, docsHtml] = await Promise.all([ + const [docs, docsLatest, docsHtml] = await Promise.all([ getCollection("docs"), + getCollection("docsLatest"), getCollection("docsHtml"), ]); @@ -30,7 +31,11 @@ export const getStaticPaths = async () => { return { props: { kind, doc }, params: { version, slug } }; }; - return [...docs.map(toPath("md")), ...docsHtml.map(toPath("html"))]; + return [ + ...docs.map(toPath("md")), + ...docsLatest.map(toPath("md")), + ...docsHtml.map(toPath("html")), + ]; }; const { version, slug = "" } = Astro.params; From 62d6e45a4caf70133b6baca6babea1d34a890559 Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Mon, 24 Aug 2026 16:33:00 -0400 Subject: [PATCH 02/13] GRO-835 starts migrating raw md processing logic so it can be used for JIT renders --- astro.config.mjs | 1 + src/content.config.ts | 4 ++ src/lib/docs/buildRawDocMetadata.ts | 74 +++++++++++++++++++++++ src/lib/docs/reformatMarkdownUrls.ts | 76 ++++++++++++++++++++++++ src/pages/docs/[version]/[...slug].astro | 40 +++++++++++-- src/types/titlecase.d.ts | 3 + 6 files changed, 193 insertions(+), 5 deletions(-) create mode 100644 src/lib/docs/buildRawDocMetadata.ts create mode 100644 src/lib/docs/reformatMarkdownUrls.ts create mode 100644 src/types/titlecase.d.ts diff --git a/astro.config.mjs b/astro.config.mjs index 089fa73a51..15be85bfd2 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -23,6 +23,7 @@ export default defineConfig({ plugins: [ viteStaticCopy({ targets: [ + // FIXME: Serve static files (as latest) from METABASE_REPO_PATH if defined { src: "_docs/**/*.{jpg,png,gif,json}", dest: "docs", diff --git a/src/content.config.ts b/src/content.config.ts index d044cdebaa..8e0f44aec1 100644 --- a/src/content.config.ts +++ b/src/content.config.ts @@ -19,6 +19,10 @@ const docsLatest = defineCollection({ "**/*.md", "!**/embedding/sdk/api/snippets/**", "!**/_includes/**", + // FIXME: Should this be ignored in the "docs" collection? (Is this a regression?) + // script/docs (lib/fetch-docs.js) ignores this dir too — it has its + // own README.md, which would otherwise collide with the root one. + "!util/**", ], base: DOCS_LATEST_ROOT, diff --git a/src/lib/docs/buildRawDocMetadata.ts b/src/lib/docs/buildRawDocMetadata.ts new file mode 100644 index 0000000000..0037edf395 --- /dev/null +++ b/src/lib/docs/buildRawDocMetadata.ts @@ -0,0 +1,74 @@ +import toTitleCase from "titlecase"; + +// In local dev (METABASE_REPO_PATH set), the docsLatest collection reads +// docs/ straight out of a metabase checkout, so none of these fields have +// been baked into frontmatter yet by `script/docs` (see +// buildDocsMetadata/constructDocMetadata in ../../../lib/fetch-docs.js, +// which does the equivalent for the deployed site). This mirrors that +// defaulting logic so local /latest pages get real titles, categories, and +// breadcrumbs. Existing frontmatter always wins. +const ACRONYMS = [ + "API", + "AWS", + "DB", + "GTAP", + "JMX", + "JWT", + "LDAP", + "RDS", + "SAML", + "SQL", + "SSL", + "SSO", +]; + +const formatDocTitle = (name: string): string => + toTitleCase(name.replace(/-/g, " ")) + .split(" ") + .map((word) => { + const acronym = ACRONYMS.find((a) => a.toUpperCase() === word.toUpperCase()); + return acronym ?? word; + }) + .join(" "); + +/** + * @param docPath Path to the doc, relative to the metabase repo's `docs/` + * dir and without extension, e.g. "databases/connecting" or "README". + */ +export const buildRawDocMetadata = ( + docPath: string, + data: Record, +): Record => { + const pathArray = docPath.split("/"); + + const metadata: Record = { + version: "latest", + has_magic_breadcrumbs: true, + show_category_breadcrumb: true, + show_title_breadcrumb: true, + layout: "new-docs", + source_url: `https://github.com/metabase/metabase/blob/master/docs/${docPath}.md`, + }; + + if (pathArray.length === 1) { + metadata.show_category_breadcrumb = false; + metadata.category = "Table of Contents"; + metadata.title = formatDocTitle(pathArray[0]); + } else { + metadata.category = + pathArray[0] === "faq" + ? "FAQ" + : toTitleCase(pathArray[0].replace(/-/g, " ")); + metadata.title = formatDocTitle(pathArray[pathArray.length - 1]); + } + + if (pathArray.length > 1 && /^(index|start)$/.test(pathArray[1])) { + metadata.show_title_breadcrumb = false; + } + + if (pathArray[pathArray.length - 1] === "README") { + metadata.permalink = "/docs/latest/index.html"; + } + + return { ...metadata, ...data }; +}; diff --git a/src/lib/docs/reformatMarkdownUrls.ts b/src/lib/docs/reformatMarkdownUrls.ts new file mode 100644 index 0000000000..5190b822bd --- /dev/null +++ b/src/lib/docs/reformatMarkdownUrls.ts @@ -0,0 +1,76 @@ +// Ports reformatMarkdownUrls from ../../../lib/utils.js (used by `script/docs` +// to rewrite links once docs are copied into _docs/) so local /latest pages, +// read straight from a metabase checkout, get the same link rewriting live: +// strip .md/.html extensions from links, and shorten absolute metabase.com +// links to root-relative paths. +const MARKDOWN_LINK_REGEX = /\[(.+?)\]\((.+?)\)/gim; +const FOOTER_LINK_REGEX = /^\[(.+?)\]:\s+(.+?)\n/gim; + +const extractUrl = (match: string): string | null => { + const bodyMatch = match.match(/(?<=\[(.+?)\]\()(.+?)(?=\))/gim); + if (bodyMatch) { + return bodyMatch[0].trim(); + } + const footerMatch = match.match(/(?<=]: )(.+?)+/gim); + if (footerMatch) { + return footerMatch[0].trim(); + } + return null; +}; + +const isRelativeUrl = (url: string): boolean => + !url.includes("http://") && !url.includes("https://"); + +const isMetabaseUrl = (url: string): boolean => + url.indexOf("metabase.") === 0 || + url.indexOf("://www.metabase.") === 4 || + url.indexOf("://www.metabase.") === 5 || + url.indexOf("://metabase.") === 4 || + url.indexOf("://metabase.") === 5; + +const formatUrl = (url: string): string => + url + .replace(".md", "") + .replace(".markdown", "") + .replace(".html", "") + .replace(".htm", "") + .replace("http://metabase.com", "") + .replace("https://metabase.com", "") + .replace("http://www.metabase.com", "") + .replace("https://www.metabase.com", ""); + +const getReplacements = ( + matches: RegExpMatchArray | null, +): { match: string; updatedMatch: string }[] | null => { + if (!matches) { + return null; + } + return matches + .map((match) => { + const url = extractUrl(match); + if (!url) { + return null; + } + if (isRelativeUrl(url) || isMetabaseUrl(url)) { + return { match, updatedMatch: match.replace(url, formatUrl(url)) }; + } + return null; + }) + .filter((replacement) => replacement !== null); +}; + +export const reformatMarkdownUrls = (body: string): string => { + let formattedBody = body; + + const bodyReplacements = getReplacements(formattedBody.match(MARKDOWN_LINK_REGEX)); + bodyReplacements?.forEach(({ match, updatedMatch }) => { + formattedBody = formattedBody.replace(match, updatedMatch); + }); + + const footerReplacements = getReplacements(formattedBody.match(FOOTER_LINK_REGEX)); + footerReplacements?.forEach(({ match, updatedMatch }) => { + formattedBody = formattedBody.replace(match, updatedMatch); + }); + + return formattedBody; +}; diff --git a/src/pages/docs/[version]/[...slug].astro b/src/pages/docs/[version]/[...slug].astro index 7437f5428c..8f8eb3acf5 100644 --- a/src/pages/docs/[version]/[...slug].astro +++ b/src/pages/docs/[version]/[...slug].astro @@ -3,6 +3,8 @@ import path from "node:path"; import { pathToFileURL } from "node:url"; import NewDocsLayout from "@/layouts/NewDocsLayout.astro"; import OldDocsLayout from "@/layouts/OldDocsLayout.astro"; +import { buildRawDocMetadata } from "@/lib/docs/buildRawDocMetadata"; +import { reformatMarkdownUrls } from "@/lib/docs/reformatMarkdownUrls"; import { resolveDocUrl } from "@/lib/docs/resolveDoc"; import { getLiquidRenderer } from "@/lib/liquid/liquidRenderer"; import { getMarkdownRenderer } from "@/lib/markdown/markdownRenderer"; @@ -13,27 +15,55 @@ type Props = | { kind: "html"; doc: DataEntryMap["docsHtml"][number] }; export const getStaticPaths = async () => { + // FIXME: Maybe define IS_LOCAL_MB is constants.ts? + // getStaticPaths runs in its own isolated scope and can't see top-level + // consts from the rest of the file (only imports) — recompute locally. + const isLocalMb = !!import.meta.env.METABASE_REPO_PATH; + const [docs, docsLatest, docsHtml] = await Promise.all([ getCollection("docs"), getCollection("docsLatest"), getCollection("docsHtml"), + // FIXME: Does docsHtml need a latest counterpart? ]); const toPath = - (kind: Kind) => - (doc: { id: string; data: { permalink?: string } }) => { + (kind: Kind, isRaw?: boolean) => + (doc: { + id: string; + data: { permalink?: string; [key: string]: unknown }; + body?: string; + }) => { + // FIXME: Figure out if it makes sense to process doc.data and doc.body here or outside getStaticPaths + + // Raw docs are read straight from a metabase checkout (see + // docsLatest in content.config.ts), so they haven't been through + // `script/docs`'s frontmatter/link rewriting yet — do the local + // equivalent here so /latest renders the same as the deployed docs. + // This has to run before resolveDocUrl, since it's what computes the + // README's permalink override. + const processedDoc = isRaw + ? { + ...doc, + data: buildRawDocMetadata( + doc.id.replace(/^latest\//, ""), + doc.data, + ), + body: reformatMarkdownUrls(doc.body ?? ""), + } + : doc; const { version, slug } = resolveDocUrl({ id: doc.id, - permalink: doc.data.permalink, + permalink: processedDoc.data.permalink as string | undefined, // For prod builds, we want to output like folder/index.html, but for the dev server, the route should exclude /index includeTrailingIndex: import.meta.env.MODE !== "development", }); - return { props: { kind, doc }, params: { version, slug } }; + return { props: { kind, doc: processedDoc }, params: { version, slug } }; }; return [ ...docs.map(toPath("md")), - ...docsLatest.map(toPath("md")), + ...docsLatest.map(toPath("md", isLocalMb)), ...docsHtml.map(toPath("html")), ]; }; diff --git a/src/types/titlecase.d.ts b/src/types/titlecase.d.ts new file mode 100644 index 0000000000..e4424feae3 --- /dev/null +++ b/src/types/titlecase.d.ts @@ -0,0 +1,3 @@ +declare module "titlecase" { + export default function toTitleCase(input: string): string; +} From 33739a45971da362e332884895be82db9e417657 Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Tue, 25 Aug 2026 16:43:56 -0400 Subject: [PATCH 03/13] GRO-835 stubs out serving static assets from local mb repo --- astro.config.mjs | 49 ++++++++++++++----- src/constants.ts | 11 ++++- .../markdown/plugins/relativeImagePlugin.ts | 20 ++++++-- 3 files changed, 62 insertions(+), 18 deletions(-) diff --git a/astro.config.mjs b/astro.config.mjs index 15be85bfd2..d5bf168016 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -1,9 +1,41 @@ // @ts-check +import path from "node:path"; import { defineConfig } from "astro/config"; import { viteStaticCopy } from "vite-plugin-static-copy"; +import { DOCS_LATEST_ROOT } from "./src/constants"; import { collectRedirects } from "./src/lib/docs/collectRedirects"; import { noopMarkdownProcessor } from "./src/lib/markdown/noopMarkdownProcessor"; +// vite-plugin-static-copy resolves each matched file's dest as +// `dest + `, then +// `stripBase` walks back up that many segments before re-appending the +// remainder — so this must be the segment count of DOCS_LATEST_ROOT itself +// (relative to cwd, with any leading `../` collapsed) for the stripped +// result to land on `dest` with the original subdirectory structure intact. +const DOCS_LATEST_ROOT_DEPTH = path + .relative(process.cwd(), path.resolve(DOCS_LATEST_ROOT)) + .replace(/^(?:\.\.[/\\])+/, "") + .split(/[/\\]/).length; + +// One target pair per asset glob: committed _docs assets, plus the +// /docs/latest counterpart sourced from DOCS_LATEST_ROOT (METABASE_REPO_PATH +// when set, for local dev against a metabase checkout). +/** @param {string} globSuffix */ +function docsCopyTargets(globSuffix) { + return [ + { + src: [`_docs/**/${globSuffix}`, "!_docs/latest/**"], + dest: "docs", + rename: { stripBase: 1 }, + }, + { + src: `${DOCS_LATEST_ROOT}/**/${globSuffix}`, + dest: "docs/latest", + rename: { stripBase: DOCS_LATEST_ROOT_DEPTH }, + }, + ]; +} + // https://astro.build/config export default defineConfig({ site: "https://www.metabase.com", @@ -23,19 +55,10 @@ export default defineConfig({ plugins: [ viteStaticCopy({ targets: [ - // FIXME: Serve static files (as latest) from METABASE_REPO_PATH if defined - { - src: "_docs/**/*.{jpg,png,gif,json}", - dest: "docs", - rename: { stripBase: 1 }, // strips `_docs/` - }, - { - // TypeDoc-generated CSS/JS/icons the SDK API reference .html - // docs load via relative `assets/...` URLs. - src: "_docs/**/embedding/sdk/api/assets/*.{css,js,svg,ico}", - dest: "docs", - rename: { stripBase: 1 }, - }, + ...docsCopyTargets("*.{jpg,png,gif,json}"), + // TypeDoc-generated CSS/JS/icons the SDK API reference .html + // docs load via relative `assets/...` URLs. + ...docsCopyTargets("embedding/sdk/api/assets/*.{css,js,svg,ico}"), ], }), ], diff --git a/src/constants.ts b/src/constants.ts index 4b82aaa35b..3c1589b7bf 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,7 +1,14 @@ +import { loadEnv } from "vite"; + export const DOCS_SRC_ROOT = "_docs"; -export const DOCS_LATEST_ROOT = import.meta.env.METABASE_REPO_PATH - ? `${import.meta.env.METABASE_REPO_PATH}/docs` +// loadEnv() (rather than import.meta.env) so this resolves the same way here +// and in astro.config.mjs, which runs outside Vite's SSR pipeline and can't +// see import.meta.env values. +const env = loadEnv(process.env.NODE_ENV ?? "development", process.cwd(), ""); + +export const DOCS_LATEST_ROOT = env.METABASE_REPO_PATH + ? `${env.METABASE_REPO_PATH}/docs` : `${DOCS_SRC_ROOT}/latest`; export const UNIFY_ENABLED_PAGES = [ diff --git a/src/lib/markdown/plugins/relativeImagePlugin.ts b/src/lib/markdown/plugins/relativeImagePlugin.ts index 3a42f3ba94..2d05706ab6 100644 --- a/src/lib/markdown/plugins/relativeImagePlugin.ts +++ b/src/lib/markdown/plugins/relativeImagePlugin.ts @@ -1,11 +1,19 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; -import { DOCS_SRC_ROOT } from "@/constants"; +import { DOCS_LATEST_ROOT, DOCS_SRC_ROOT } from "@/constants"; import { defineHastPlugin } from "satteri"; // Resolves relative images from docs md files. // The images themselves are copied via viteStaticCopy in astro.config.mjs. +// When METABASE_REPO_PATH is set, DOCS_LATEST_ROOT points outside +// DOCS_SRC_ROOT entirely (at /docs), so /latest images +// must be resolved against it separately. +const DOCS_LATEST_ROOT_ABS = path.resolve(DOCS_LATEST_ROOT); + +const toSitePath = (relPath: string) => + relPath.split(path.sep).map(encodeURIComponent).join("/"); + export const relativeImagePlugin = defineHastPlugin({ name: "relative-image-resolver", element: { @@ -18,11 +26,17 @@ export const relativeImagePlugin = defineHastPlugin({ if (!ctx.fileURL) return; const absPath = fileURLToPath(new URL(decodeURI(rawSrc), ctx.fileURL)); + + const latestRelPath = path.relative(DOCS_LATEST_ROOT_ABS, absPath); + if (!latestRelPath.startsWith("..") && !path.isAbsolute(latestRelPath)) { + ctx.setProperty(node, "src", `/docs/latest/${toSitePath(latestRelPath)}`); + return; + } + const relPath = path.relative(DOCS_SRC_ROOT, absPath); if (relPath.startsWith("..") || path.isAbsolute(relPath)) return; - const newSrc = `/docs/${relPath.split(path.sep).map(encodeURIComponent).join("/")}`; - ctx.setProperty(node, "src", newSrc); + ctx.setProperty(node, "src", `/docs/${toSitePath(relPath)}`); }, }, }); From abf20ae23bcb6058d2da84031400250150ccd774 Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Tue, 25 Aug 2026 17:44:34 -0400 Subject: [PATCH 04/13] GRO-835 moves JIT doc processing out of getStaticPaths to prevent an enormous amount of computation in dev builds --- src/content.config.ts | 1 + src/lib/docs/resolveDoc.ts | 7 +--- src/pages/docs/[version]/[...slug].astro | 47 ++++++++++-------------- 3 files changed, 21 insertions(+), 34 deletions(-) diff --git a/src/content.config.ts b/src/content.config.ts index 8e0f44aec1..354dff803d 100644 --- a/src/content.config.ts +++ b/src/content.config.ts @@ -3,6 +3,7 @@ import { glob } from "astro/loaders"; import { DOCS_LATEST_ROOT, DOCS_SRC_ROOT } from "./constants"; import { docsHtmlLoader } from "./lib/docs/docsHtmlLoader"; +// FIXME: This is a big collection and it slows down dev builds. Maybe we can disable it by default for dev in .env? const docs = defineCollection({ loader: glob({ pattern: ["**/*.md", "!**/embedding/sdk/api/snippets/**", "!latest/**"], diff --git a/src/lib/docs/resolveDoc.ts b/src/lib/docs/resolveDoc.ts index 38de3749f7..a7e9dfd49a 100644 --- a/src/lib/docs/resolveDoc.ts +++ b/src/lib/docs/resolveDoc.ts @@ -4,17 +4,12 @@ // routing, sitemaps, and redirects. export const resolveDocUrl = ({ id, - permalink, includeTrailingIndex, }: { id: string; - permalink?: string; includeTrailingIndex?: boolean; }): { version: string; slug: string; url: string } => { - let resolvedId = (permalink?.replace(/^\/docs\//, "") ?? id).replace( - /\.html$/, - "", - ); + let resolvedId = id.replace(/\.html$/, "").replace(/\/README$/, "/index"); if (!includeTrailingIndex) { resolvedId = resolvedId.replace(/index$/, ""); } diff --git a/src/pages/docs/[version]/[...slug].astro b/src/pages/docs/[version]/[...slug].astro index 8f8eb3acf5..a945f222ab 100644 --- a/src/pages/docs/[version]/[...slug].astro +++ b/src/pages/docs/[version]/[...slug].astro @@ -10,9 +10,10 @@ import { getLiquidRenderer } from "@/lib/liquid/liquidRenderer"; import { getMarkdownRenderer } from "@/lib/markdown/markdownRenderer"; import { getCollection, type DataEntryMap } from "astro:content"; -type Props = +type Props = { isRaw?: boolean } & ( | { kind: "md"; doc: DataEntryMap["docs"][number] } - | { kind: "html"; doc: DataEntryMap["docsHtml"][number] }; + | { kind: "html"; doc: DataEntryMap["docsHtml"][number] } +); export const getStaticPaths = async () => { // FIXME: Maybe define IS_LOCAL_MB is constants.ts? @@ -29,36 +30,16 @@ export const getStaticPaths = async () => { const toPath = (kind: Kind, isRaw?: boolean) => - (doc: { - id: string; - data: { permalink?: string; [key: string]: unknown }; - body?: string; - }) => { - // FIXME: Figure out if it makes sense to process doc.data and doc.body here or outside getStaticPaths - - // Raw docs are read straight from a metabase checkout (see - // docsLatest in content.config.ts), so they haven't been through - // `script/docs`'s frontmatter/link rewriting yet — do the local - // equivalent here so /latest renders the same as the deployed docs. - // This has to run before resolveDocUrl, since it's what computes the - // README's permalink override. - const processedDoc = isRaw - ? { - ...doc, - data: buildRawDocMetadata( - doc.id.replace(/^latest\//, ""), - doc.data, - ), - body: reformatMarkdownUrls(doc.body ?? ""), - } - : doc; + (doc: { id: string; data: { [key: string]: unknown }; body?: string }) => { const { version, slug } = resolveDocUrl({ id: doc.id, - permalink: processedDoc.data.permalink as string | undefined, // For prod builds, we want to output like folder/index.html, but for the dev server, the route should exclude /index includeTrailingIndex: import.meta.env.MODE !== "development", }); - return { props: { kind, doc: processedDoc }, params: { version, slug } }; + return { + props: { kind, doc, isRaw }, + params: { version, slug }, + }; }; return [ @@ -69,7 +50,17 @@ export const getStaticPaths = async () => { }; const { version, slug = "" } = Astro.params; -const { kind, doc } = Astro.props; +const { kind, doc: propsDoc, isRaw } = Astro.props; +const doc = isRaw + ? { + ...propsDoc, + data: buildRawDocMetadata( + propsDoc.id.replace(/^latest\//, ""), + propsDoc.data, + ), + body: reformatMarkdownUrls(propsDoc.body ?? ""), + } + : propsDoc; const dirname = path.dirname(doc.filePath!); // Process liquid first (e.g. control flow, includes, variables, etc) From 6876f1fb728640c746013565b3dd9c80b2dff72e Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Wed, 26 Aug 2026 15:24:24 -0400 Subject: [PATCH 05/13] GRO-835 only serves /latest when METABASE_REPO_PATH is defined which significantly simplifies things and increases performance. Also updates docsHtmlLoader to handle local mb files using that same simplification method. --- astro.config.mjs | 49 ++++--------- src/constants.ts | 18 +++-- src/content.config.ts | 34 +++------- src/lib/docs/collectRedirects.ts | 15 ++-- src/lib/docs/docsHtmlLoader.ts | 68 +++++++++++-------- .../markdown/plugins/relativeImagePlugin.ts | 19 ++---- src/pages/docs/[version]/[...slug].astro | 38 +++-------- 7 files changed, 103 insertions(+), 138 deletions(-) diff --git a/astro.config.mjs b/astro.config.mjs index d5bf168016..5b26803951 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -1,41 +1,10 @@ // @ts-check -import path from "node:path"; import { defineConfig } from "astro/config"; import { viteStaticCopy } from "vite-plugin-static-copy"; -import { DOCS_LATEST_ROOT } from "./src/constants"; +import { DOCS_DEST, DOCS_SRC_ROOT } from "./src/constants"; import { collectRedirects } from "./src/lib/docs/collectRedirects"; import { noopMarkdownProcessor } from "./src/lib/markdown/noopMarkdownProcessor"; -// vite-plugin-static-copy resolves each matched file's dest as -// `dest + `, then -// `stripBase` walks back up that many segments before re-appending the -// remainder — so this must be the segment count of DOCS_LATEST_ROOT itself -// (relative to cwd, with any leading `../` collapsed) for the stripped -// result to land on `dest` with the original subdirectory structure intact. -const DOCS_LATEST_ROOT_DEPTH = path - .relative(process.cwd(), path.resolve(DOCS_LATEST_ROOT)) - .replace(/^(?:\.\.[/\\])+/, "") - .split(/[/\\]/).length; - -// One target pair per asset glob: committed _docs assets, plus the -// /docs/latest counterpart sourced from DOCS_LATEST_ROOT (METABASE_REPO_PATH -// when set, for local dev against a metabase checkout). -/** @param {string} globSuffix */ -function docsCopyTargets(globSuffix) { - return [ - { - src: [`_docs/**/${globSuffix}`, "!_docs/latest/**"], - dest: "docs", - rename: { stripBase: 1 }, - }, - { - src: `${DOCS_LATEST_ROOT}/**/${globSuffix}`, - dest: "docs/latest", - rename: { stripBase: DOCS_LATEST_ROOT_DEPTH }, - }, - ]; -} - // https://astro.build/config export default defineConfig({ site: "https://www.metabase.com", @@ -55,10 +24,18 @@ export default defineConfig({ plugins: [ viteStaticCopy({ targets: [ - ...docsCopyTargets("*.{jpg,png,gif,json}"), - // TypeDoc-generated CSS/JS/icons the SDK API reference .html - // docs load via relative `assets/...` URLs. - ...docsCopyTargets("embedding/sdk/api/assets/*.{css,js,svg,ico}"), + { + src: `${DOCS_SRC_ROOT}/**/*.{jpg,png,gif,json}`, + dest: DOCS_DEST, + rename: { stripBase: DOCS_DEST.split("/").length }, + }, + { + // TypeDoc-generated CSS/JS/icons the SDK API reference .html + // docs load via relative `assets/...` URLs. + src: `${DOCS_SRC_ROOT}/**/embedding/sdk/api/assets/*.{css,js,svg,ico}`, + dest: DOCS_DEST, + rename: { stripBase: DOCS_DEST.split("/").length }, + }, ], }), ], diff --git a/src/constants.ts b/src/constants.ts index 3c1589b7bf..ec1cbd4850 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,15 +1,23 @@ import { loadEnv } from "vite"; -export const DOCS_SRC_ROOT = "_docs"; - // loadEnv() (rather than import.meta.env) so this resolves the same way here // and in astro.config.mjs, which runs outside Vite's SSR pipeline and can't // see import.meta.env values. const env = loadEnv(process.env.NODE_ENV ?? "development", process.cwd(), ""); -export const DOCS_LATEST_ROOT = env.METABASE_REPO_PATH - ? `${env.METABASE_REPO_PATH}/docs` - : `${DOCS_SRC_ROOT}/latest`; +const METABASE_REPO_PATH = env.METABASE_REPO_PATH; + +export const DOCS_SRC_ROOT = METABASE_REPO_PATH + ? `${METABASE_REPO_PATH}/docs` + : "_docs"; + +// Only build/watch/serve/etc /docs/latest when pointing at a local metabase repo. +// When previewing those changes, there's very unlikely ever a need to preview old versions at the same time. +// And it's much simpler and faster if the source of truth is EITHER a local metabase repo OR the committed _docs files, +// not an odd mix of both. +export const DOCS_VERSION = METABASE_REPO_PATH ? "latest" : null; + +export const DOCS_DEST = DOCS_VERSION ? `docs/${DOCS_VERSION}` : "docs"; export const UNIFY_ENABLED_PAGES = [ "/docs/latest/embedding/modular-embedding", diff --git a/src/content.config.ts b/src/content.config.ts index 354dff803d..7b6e8954a9 100644 --- a/src/content.config.ts +++ b/src/content.config.ts @@ -1,41 +1,27 @@ import { defineCollection } from "astro:content"; import { glob } from "astro/loaders"; -import { DOCS_LATEST_ROOT, DOCS_SRC_ROOT } from "./constants"; +import { DOCS_SRC_ROOT, DOCS_VERSION } from "./constants"; import { docsHtmlLoader } from "./lib/docs/docsHtmlLoader"; -// FIXME: This is a big collection and it slows down dev builds. Maybe we can disable it by default for dev in .env? +const PREFIX = DOCS_VERSION ? `${DOCS_VERSION}/` : ""; + const docs = defineCollection({ loader: glob({ - pattern: ["**/*.md", "!**/embedding/sdk/api/snippets/**", "!latest/**"], + pattern: ["**/*.md", "!**/embedding/sdk/api/snippets/**", "!util/**"], base: DOCS_SRC_ROOT, // Preserves dots (.) in pathnames - generateId: ({ entry }) => entry.replace(/\.md$/, ""), - }), -}); - -const docsLatest = defineCollection({ - loader: glob({ - pattern: [ - "**/*.md", - "!**/embedding/sdk/api/snippets/**", - "!**/_includes/**", - // FIXME: Should this be ignored in the "docs" collection? (Is this a regression?) - // script/docs (lib/fetch-docs.js) ignores this dir too — it has its - // own README.md, which would otherwise collide with the root one. - "!util/**", - ], - base: DOCS_LATEST_ROOT, - - // Preserves dots (.) in pathnames - generateId: ({ entry }) => `latest/${entry.replace(/\.md$/, "")}`, + generateId: ({ entry }) => `${PREFIX}${entry.replace(/\.md$/, "")}`, }), }); // Raw, standalone HTML docs (TypeDoc-generated SDK API reference pages, // per-version api.html ToC pages) that the glob() loader can't parse. const docsHtml = defineCollection({ - loader: docsHtmlLoader(), + loader: docsHtmlLoader({ + base: DOCS_SRC_ROOT, + generateId: (entry) => `${PREFIX}${entry}`, + }), }); -export const collections = { docs, docsLatest, docsHtml }; +export const collections = { docs, docsHtml }; diff --git a/src/lib/docs/collectRedirects.ts b/src/lib/docs/collectRedirects.ts index a2a9f1385b..bc59e41d94 100644 --- a/src/lib/docs/collectRedirects.ts +++ b/src/lib/docs/collectRedirects.ts @@ -2,7 +2,7 @@ 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 { DOCS_SRC_ROOT, DOCS_VERSION } from "../../constants"; import { resolveDocUrl } from "./resolveDoc"; const EXCLUDE = ["**/embedding/sdk/api/snippets/**"]; @@ -18,7 +18,9 @@ type DocEntry = { relPath: string; url: string; redirectFrom: string[] }; // Scans every doc under DOCS_SRC_ROOT once, returning each doc's canonical // URL (for detecting collisions with real pages) alongside the subset that -// declare `redirect_from`. +// declare `redirect_from`. When METABASE_REPO_PATH is set, scans that local +// checkout's docs/ instead, treating it as the `latest` version (matching +// the `docs` and `docsHtml` collections in content.config.ts). const scanDocEntries = (): { validUrls: Set; docsWithRedirects: DocEntry[]; @@ -37,8 +39,13 @@ const scanDocEntries = (): { const absPath = path.join(base, relPath); const { data } = matter(fs.readFileSync(absPath, "utf8")); - const id = stripExtension ? relPath.replace(/\.md$/, "") : relPath; - const { url } = resolveDocUrl({ id, permalink: data.permalink }); + const strippedPath = stripExtension + ? relPath.replace(/\.md$/, "") + : relPath; + const id = DOCS_VERSION + ? `${DOCS_VERSION}/${strippedPath}` + : strippedPath; + const { url } = resolveDocUrl({ id }); validUrls.add(url); const redirectFrom: string[] | undefined = data.redirect_from; diff --git a/src/lib/docs/docsHtmlLoader.ts b/src/lib/docs/docsHtmlLoader.ts index b31fd154fe..f207f5787b 100644 --- a/src/lib/docs/docsHtmlLoader.ts +++ b/src/lib/docs/docsHtmlLoader.ts @@ -11,35 +11,47 @@ import matter from "gray-matter"; // 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(); +// +// `base` and `generateId` mirror the options of Astro's glob() loader, so +// callers can point this at a local metabase checkout (METABASE_REPO_PATH) +// the same way the `docs` collection does. +export const docsHtmlLoader = (options?: { + base?: string; + generateId?: (entry: string) => string; +}): Loader => { + const baseDirName = options?.base ?? DOCS_SRC_ROOT; + const generateId = options?.generateId ?? ((entry: string) => entry); - 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/**"], - }); + return { + name: "docs-html-loader", + load: async ({ config, store, parseData, generateDigest, logger }) => { + store.clear(); - 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), + const base = new URL(`${baseDirName}/`, config.root); + const baseDir = fileURLToPath(base); + const rootDir = fileURLToPath(config.root); + const entries: string[] = glob.sync("**/*.html", { + cwd: baseDir, + ignore: ["**/embedding/sdk/api/snippets/**"], }); - } - logger.info(`Loaded ${entries.length} html docs`); - }, -}); + 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 = generateId(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/markdown/plugins/relativeImagePlugin.ts b/src/lib/markdown/plugins/relativeImagePlugin.ts index 2d05706ab6..6ff8b47646 100644 --- a/src/lib/markdown/plugins/relativeImagePlugin.ts +++ b/src/lib/markdown/plugins/relativeImagePlugin.ts @@ -1,15 +1,13 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; -import { DOCS_LATEST_ROOT, DOCS_SRC_ROOT } from "@/constants"; +import { DOCS_DEST, DOCS_SRC_ROOT } from "@/constants"; import { defineHastPlugin } from "satteri"; // Resolves relative images from docs md files. // The images themselves are copied via viteStaticCopy in astro.config.mjs. -// When METABASE_REPO_PATH is set, DOCS_LATEST_ROOT points outside -// DOCS_SRC_ROOT entirely (at /docs), so /latest images -// must be resolved against it separately. -const DOCS_LATEST_ROOT_ABS = path.resolve(DOCS_LATEST_ROOT); +const ROOT_ABS = path.resolve(DOCS_SRC_ROOT); +const URL_PREFIX = `/${DOCS_DEST}/`; const toSitePath = (relPath: string) => relPath.split(path.sep).map(encodeURIComponent).join("/"); @@ -26,17 +24,10 @@ export const relativeImagePlugin = defineHastPlugin({ if (!ctx.fileURL) return; const absPath = fileURLToPath(new URL(decodeURI(rawSrc), ctx.fileURL)); - - const latestRelPath = path.relative(DOCS_LATEST_ROOT_ABS, absPath); - if (!latestRelPath.startsWith("..") && !path.isAbsolute(latestRelPath)) { - ctx.setProperty(node, "src", `/docs/latest/${toSitePath(latestRelPath)}`); - return; - } - - const relPath = path.relative(DOCS_SRC_ROOT, absPath); + const relPath = path.relative(ROOT_ABS, absPath); if (relPath.startsWith("..") || path.isAbsolute(relPath)) return; - ctx.setProperty(node, "src", `/docs/${toSitePath(relPath)}`); + ctx.setProperty(node, "src", `${URL_PREFIX}${toSitePath(relPath)}`); }, }, }); diff --git a/src/pages/docs/[version]/[...slug].astro b/src/pages/docs/[version]/[...slug].astro index a945f222ab..e1c9eecd21 100644 --- a/src/pages/docs/[version]/[...slug].astro +++ b/src/pages/docs/[version]/[...slug].astro @@ -10,22 +10,14 @@ import { getLiquidRenderer } from "@/lib/liquid/liquidRenderer"; import { getMarkdownRenderer } from "@/lib/markdown/markdownRenderer"; import { getCollection, type DataEntryMap } from "astro:content"; -type Props = { isRaw?: boolean } & ( +type Props = | { kind: "md"; doc: DataEntryMap["docs"][number] } - | { kind: "html"; doc: DataEntryMap["docsHtml"][number] } -); + | { kind: "html"; doc: DataEntryMap["docsHtml"][number] }; export const getStaticPaths = async () => { - // FIXME: Maybe define IS_LOCAL_MB is constants.ts? - // getStaticPaths runs in its own isolated scope and can't see top-level - // consts from the rest of the file (only imports) — recompute locally. - const isLocalMb = !!import.meta.env.METABASE_REPO_PATH; - - const [docs, docsLatest, docsHtml] = await Promise.all([ + const [docs, docsHtml] = await Promise.all([ getCollection("docs"), - getCollection("docsLatest"), getCollection("docsHtml"), - // FIXME: Does docsHtml need a latest counterpart? ]); const toPath = @@ -42,25 +34,17 @@ export const getStaticPaths = async () => { }; }; - return [ - ...docs.map(toPath("md")), - ...docsLatest.map(toPath("md", isLocalMb)), - ...docsHtml.map(toPath("html")), - ]; + return [...docs.map(toPath("md")), ...docsHtml.map(toPath("html"))]; }; const { version, slug = "" } = Astro.params; -const { kind, doc: propsDoc, isRaw } = Astro.props; -const doc = isRaw - ? { - ...propsDoc, - data: buildRawDocMetadata( - propsDoc.id.replace(/^latest\//, ""), - propsDoc.data, - ), - body: reformatMarkdownUrls(propsDoc.body ?? ""), - } - : propsDoc; +const { kind, doc: rawDoc } = Astro.props; +// FIXME: Verify already-processed docs (via script/docs) can be re-processed here w/o breaking anything +const doc = { + ...rawDoc, + data: buildRawDocMetadata(rawDoc.id.replace(/^latest\//, ""), rawDoc.data), + body: reformatMarkdownUrls(rawDoc.body ?? ""), +}; const dirname = path.dirname(doc.filePath!); // Process liquid first (e.g. control flow, includes, variables, etc) From 0748ed91fb8edeae4f240ca97e20f4dadf220e72 Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Wed, 26 Aug 2026 17:45:45 -0400 Subject: [PATCH 06/13] GRO-935 exclude `util` folders from docs collection (only one right now and it's not supposed to generate pages or be in the sitemap) --- src/content.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/content.config.ts b/src/content.config.ts index 7b6e8954a9..9407d370af 100644 --- a/src/content.config.ts +++ b/src/content.config.ts @@ -7,7 +7,7 @@ const PREFIX = DOCS_VERSION ? `${DOCS_VERSION}/` : ""; const docs = defineCollection({ loader: glob({ - pattern: ["**/*.md", "!**/embedding/sdk/api/snippets/**", "!util/**"], + pattern: ["**/*.md", "!**/embedding/sdk/api/snippets/**", "!**/util/**"], base: DOCS_SRC_ROOT, // Preserves dots (.) in pathnames From ded143925e6afc207fb69a932c8ab96899e0cb38 Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Thu, 27 Aug 2026 09:54:41 -0400 Subject: [PATCH 07/13] GRO-835 re-ports constructDocMetadata to work with simplified architecture and so the same code is used in astro and script/docs --- lib/fetch-docs.js | 97 +-------------------- script/docs | 2 +- src/lib/docs/buildRawDocMetadata.ts | 74 ---------------- src/lib/docs/constructDocMetadata.ts | 103 +++++++++++++++++++++++ src/pages/docs/[version]/[...slug].astro | 18 ++-- src/pages/docs/sitemap.xml.ts | 4 +- 6 files changed, 118 insertions(+), 180 deletions(-) delete mode 100644 src/lib/docs/buildRawDocMetadata.ts create mode 100644 src/lib/docs/constructDocMetadata.ts diff --git a/lib/fetch-docs.js b/lib/fetch-docs.js index 1ad14242aa..05add42423 100644 --- a/lib/fetch-docs.js +++ b/lib/fetch-docs.js @@ -14,10 +14,10 @@ const tar = require("tar-fs"); const gunzip = require("gunzip-maybe"); // String functions -const toTitleCase = require("titlecase"); const matter = require("gray-matter"); const { canBeProcessedByFrontmatter, squashVersion } = require("./utils.js"); +const { constructDocMetadata } = require('../src/lib/docs/constructDocMetadata.ts'); const glob = require("glob"); /** @@ -204,27 +204,7 @@ function buildDocsMetadata(docPath, fileContent, settings) { return fileContent; } - const metadata = constructDocMetadata( - docPath, - settings.majorVersion, - ); - metadata.source_url = constructSourceUrl(docPath); - - if ( - parseInt(settings.majorVersion.split(".").pop()) > 43 || - settings.version === "master" - ) { - metadata.layout = "new-docs"; - } - - if (path.basename(docPath, ".md") === "README") { - metadata.permalink = - "/" + - path - .join("docs", settings.majorVersion, "index.html") - .split(path.sep) - .join("/"); - } + const metadata = constructDocMetadata(docPath, settings.majorVersion); return frontMatterify(fileContent, metadata); } @@ -240,79 +220,6 @@ function frontMatterify(fileContent, metadata) { return frontmatterBlock + content; } -function constructSourceUrl(path) { - const baseUrl = "https://github.com/metabase/metabase/blob/master/"; - const source = path.split("/"); - source.splice(0, 1); - return baseUrl + source.join("/"); -} - -function constructDocMetadata(path, version) { - const metadata = {}; - metadata.version = version; - metadata.has_magic_breadcrumbs = true; - // We default to showing both category and title breadcrumbs, then toggle either a category and/or title breadcrumb in certain scenarios - // For documentation TOC pages, we _only_ use the title in the breadcrumb - // For _category_ TOC pages (one level below the root), we only use the category in the breadbrumb - metadata.show_category_breadcrumb = true; - metadata.show_title_breadcrumb = true; - - // Remove prefixes to docs directory, making all paths below relative - const pathArray = path.split("/"); - pathArray.splice(0, 2); - - // #breadcrumb and title/category logic - if (pathArray.length === 1) { - metadata.show_category_breadcrumb = false; - metadata.category = "Table of Contents"; - metadata.title = formatDocTitle(pathArray[0]); - } else { - if (pathArray[0] === "faq") metadata.category = "FAQ"; - else metadata.category = toTitleCase(pathArray[0]).replace(/-/g, " "); - metadata.title = formatDocTitle(pathArray[pathArray.length - 1]); - } - - // MOST categories use start.md, except for the troubleshooting guide :) - if (pathArray.length > 1 && pathArray[1].match(/^(index|start)\.md$/)) - metadata.show_title_breadcrumb = false; - - return metadata; -} - -const ACRONYMS = [ - "API", - "AWS", - "DB", - "GTAP", - "JMX", - "JWT", - "LDAP", - "RDS", - "SAML", - "SQL", - "SSL", - "SSO", -]; - -function formatDocTitle(filename) { - filename = filename.replace(".md", ""); - filename = filename.replace(/-/g, " "); - filename = toTitleCase(filename); - return filename - .split(" ") - .map((word) => { - const wordIndex = ACRONYMS.findIndex( - (acronym) => acronym.toUpperCase() === word.toUpperCase(), - ); - if (wordIndex > -1) { - return ACRONYMS[wordIndex]; - } - - return word; - }) - .join(" "); -} - // Take newly extracted 'docs' directory, name it according // to appropriate Electron version, copy it to electron.atom.io // '_docs' directory and delete temp directory diff --git a/script/docs b/script/docs index da125353c9..7590f6c0ed 100755 --- a/script/docs +++ b/script/docs @@ -1,4 +1,4 @@ -#!/usr/bin/env node +#!/usr/bin/env bun const program = require("commander"); const yaml = require("yamljs"); const fs = require("fs"); diff --git a/src/lib/docs/buildRawDocMetadata.ts b/src/lib/docs/buildRawDocMetadata.ts deleted file mode 100644 index 0037edf395..0000000000 --- a/src/lib/docs/buildRawDocMetadata.ts +++ /dev/null @@ -1,74 +0,0 @@ -import toTitleCase from "titlecase"; - -// In local dev (METABASE_REPO_PATH set), the docsLatest collection reads -// docs/ straight out of a metabase checkout, so none of these fields have -// been baked into frontmatter yet by `script/docs` (see -// buildDocsMetadata/constructDocMetadata in ../../../lib/fetch-docs.js, -// which does the equivalent for the deployed site). This mirrors that -// defaulting logic so local /latest pages get real titles, categories, and -// breadcrumbs. Existing frontmatter always wins. -const ACRONYMS = [ - "API", - "AWS", - "DB", - "GTAP", - "JMX", - "JWT", - "LDAP", - "RDS", - "SAML", - "SQL", - "SSL", - "SSO", -]; - -const formatDocTitle = (name: string): string => - toTitleCase(name.replace(/-/g, " ")) - .split(" ") - .map((word) => { - const acronym = ACRONYMS.find((a) => a.toUpperCase() === word.toUpperCase()); - return acronym ?? word; - }) - .join(" "); - -/** - * @param docPath Path to the doc, relative to the metabase repo's `docs/` - * dir and without extension, e.g. "databases/connecting" or "README". - */ -export const buildRawDocMetadata = ( - docPath: string, - data: Record, -): Record => { - const pathArray = docPath.split("/"); - - const metadata: Record = { - version: "latest", - has_magic_breadcrumbs: true, - show_category_breadcrumb: true, - show_title_breadcrumb: true, - layout: "new-docs", - source_url: `https://github.com/metabase/metabase/blob/master/docs/${docPath}.md`, - }; - - if (pathArray.length === 1) { - metadata.show_category_breadcrumb = false; - metadata.category = "Table of Contents"; - metadata.title = formatDocTitle(pathArray[0]); - } else { - metadata.category = - pathArray[0] === "faq" - ? "FAQ" - : toTitleCase(pathArray[0].replace(/-/g, " ")); - metadata.title = formatDocTitle(pathArray[pathArray.length - 1]); - } - - if (pathArray.length > 1 && /^(index|start)$/.test(pathArray[1])) { - metadata.show_title_breadcrumb = false; - } - - if (pathArray[pathArray.length - 1] === "README") { - metadata.permalink = "/docs/latest/index.html"; - } - - return { ...metadata, ...data }; -}; diff --git a/src/lib/docs/constructDocMetadata.ts b/src/lib/docs/constructDocMetadata.ts new file mode 100644 index 0000000000..bc0c679bd7 --- /dev/null +++ b/src/lib/docs/constructDocMetadata.ts @@ -0,0 +1,103 @@ +import path from "node:path"; +import toTitleCase from "titlecase"; + +const ACRONYMS = [ + "API", + "AWS", + "DB", + "GTAP", + "JMX", + "JWT", + "LDAP", + "RDS", + "SAML", + "SQL", + "SSL", + "SSO", +]; + +function formatDocTitle(filename: string) { + filename = filename.replace(".md", ""); + filename = filename.replace(/-/g, " "); + filename = toTitleCase(filename); + return filename + .split(" ") + .map((word) => { + const wordIndex = ACRONYMS.findIndex( + (acronym) => acronym.toUpperCase() === word.toUpperCase(), + ); + if (wordIndex > -1) { + return ACRONYMS[wordIndex]; + } + + return word; + }) + .join(" "); +} + +function constructSourceUrl(path: string) { + const baseUrl = "https://github.com/metabase/metabase/blob/master/"; + const source = path.split("/"); + source.splice(0, 1); + return baseUrl + source.join("/"); +} + +export type DocMetadata = { + version: string; + has_magic_breadcrumbs: true; + show_category_breadcrumb: boolean; + show_title_breadcrumb: boolean; + category: string; + title: string; + source_url: string; + layout: "docs" | "new-docs"; + permalink?: string; +}; + +// The `page` shape passed to doc layouts: metadata plus the resolved page URL. +export type DocPage = DocMetadata & { url: string }; + +export function constructDocMetadata( + docPath: string, + version: string, +): DocMetadata { + const versionNumber = parseInt(version.split(".").pop() || "", 10); + const metadata: Partial = { + version, + has_magic_breadcrumbs: true, + // We default to showing both category and title breadcrumbs, then toggle either a category and/or title breadcrumb in certain scenarios + // For documentation TOC pages, we _only_ use the title in the breadcrumb + // For _category_ TOC pages (one level below the root), we only use the category in the breadcrumb + show_category_breadcrumb: true, + show_title_breadcrumb: true, + }; + + // Remove prefixes to docs directory, making all paths below relative + const pathArray = docPath.split("/"); + pathArray.splice(0, 2); + + // #breadcrumb and title/category logic + if (pathArray.length === 1) { + metadata.show_category_breadcrumb = false; + metadata.category = "Table of Contents"; + metadata.title = formatDocTitle(pathArray[0]); + } else { + if (pathArray[0] === "faq") metadata.category = "FAQ"; + else metadata.category = toTitleCase(pathArray[0]).replace(/-/g, " "); + metadata.title = formatDocTitle(pathArray[pathArray.length - 1]); + } + + // MOST categories use start.md, except for the troubleshooting guide :) + if (pathArray.length > 1 && pathArray[1].match(/^(index|start)\.md$/)) + metadata.show_title_breadcrumb = false; + + metadata.source_url = constructSourceUrl(docPath); + metadata.layout = versionNumber > 43 ? "new-docs" : "docs"; + + if (path.basename(docPath, ".md") === "README") { + metadata.permalink = + "/" + path.join("docs", version, "index.html").split(path.sep).join("/"); + } + + return metadata as DocMetadata; +} diff --git a/src/pages/docs/[version]/[...slug].astro b/src/pages/docs/[version]/[...slug].astro index e1c9eecd21..c4e3fa84e6 100644 --- a/src/pages/docs/[version]/[...slug].astro +++ b/src/pages/docs/[version]/[...slug].astro @@ -3,10 +3,10 @@ import path from "node:path"; import { pathToFileURL } from "node:url"; import NewDocsLayout from "@/layouts/NewDocsLayout.astro"; import OldDocsLayout from "@/layouts/OldDocsLayout.astro"; -import { buildRawDocMetadata } from "@/lib/docs/buildRawDocMetadata"; +import { constructDocMetadata } from "@/lib/docs/constructDocMetadata"; import { reformatMarkdownUrls } from "@/lib/docs/reformatMarkdownUrls"; import { resolveDocUrl } from "@/lib/docs/resolveDoc"; -import { getLiquidRenderer } from "@/lib/liquid/liquidRenderer"; +import { baseCtx, getLiquidRenderer } from "@/lib/liquid/liquidRenderer"; import { getMarkdownRenderer } from "@/lib/markdown/markdownRenderer"; import { getCollection, type DataEntryMap } from "astro:content"; @@ -39,10 +39,17 @@ export const getStaticPaths = async () => { const { version, slug = "" } = Astro.params; const { kind, doc: rawDoc } = Astro.props; +const url = `/docs/${version}/${slug}`; // FIXME: Verify already-processed docs (via script/docs) can be re-processed here w/o breaking anything const doc = { ...rawDoc, - data: buildRawDocMetadata(rawDoc.id.replace(/^latest\//, ""), rawDoc.data), + data: { + ...constructDocMetadata( + url.slice(1), // remove leading slash for parity with fetchDocs + version === "latest" ? baseCtx.site.docs_version : version, + ), + ...rawDoc.data, + }, body: reformatMarkdownUrls(rawDoc.body ?? ""), }; const dirname = path.dirname(doc.filePath!); @@ -82,10 +89,7 @@ const Layout = doc.data.layout === "docs" ? OldDocsLayout : NewDocsLayout; kind === "html" ? ( ) : ( - + ) diff --git a/src/pages/docs/sitemap.xml.ts b/src/pages/docs/sitemap.xml.ts index 695305541c..7250d32c59 100644 --- a/src/pages/docs/sitemap.xml.ts +++ b/src/pages/docs/sitemap.xml.ts @@ -14,9 +14,7 @@ export const GET: APIRoute = async () => { ]); const paths = [...docs, ...docsHtml] - .map( - (doc) => resolveDocUrl({ id: doc.id, permalink: doc.data.permalink }).url, - ) + .map((doc) => resolveDocUrl({ id: doc.id }).url) .sort(); const body = ` From 31acb4bccefc2474cc03b0be8228435981dadd7d Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Thu, 27 Aug 2026 12:24:02 -0400 Subject: [PATCH 08/13] GRO-835 re-ports reformatMarkdownUrls so the same code is used in astro and script/docs --- lib/utils.js | 133 +---------------------- src/lib/docs/reformatMarkdownUrls.ts | 16 +-- src/pages/docs/[version]/[...slug].astro | 1 - 3 files changed, 10 insertions(+), 140 deletions(-) diff --git a/lib/utils.js b/lib/utils.js index 37f29e5e9f..b26ad0f649 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -1,149 +1,19 @@ -const MARKDOWN_LINK_REGEX = /\[(.+?)\]\((.+?)\)/gim; -const FOOTER_LINK_REGEX = /^\[(.+?)\]:\s+(.+?)\n/gim; const fs = require("fs"); const path = require("path"); const glob = require("glob"); const matter = require("gray-matter"); const yaml = require("yamljs"); +const { reformatMarkdownUrls } = require('../src/lib/docs/reformatMarkdownUrls'); function canBeProcessedByFrontmatter(filePath) { return path.extname(filePath) === ".md" || path.extname(filePath) === ".html" } -function reformatMarkdownUrls(filePath, body) { - let formattedBody = `${body}\n`; - - // "[text](url.md?query=something#hash=else)" => "[text](url?query=something#hash=else)" - const bodyMatchesReplacements = getReplacements( - filePath, - formattedBody.match(MARKDOWN_LINK_REGEX), - ); - if (bodyMatchesReplacements) { - bodyMatchesReplacements.forEach( - (replaceObj) => - (formattedBody = formattedBody.replace( - replaceObj.match, - replaceObj.updatedMatch, - )), - ); - } - - // "[text]: url.md?query=something#hash=else" => "[text]: url?query=something#hash=else" - const footerMatchesReplacements = getReplacements( - filePath, - formattedBody.match(FOOTER_LINK_REGEX), - ); - if (footerMatchesReplacements) { - footerMatchesReplacements.forEach( - (replaceObj) => - (formattedBody = formattedBody.replace( - replaceObj.match, - replaceObj.updatedMatch, - )), - ); - } - - return formattedBody; -} - function replaceVersionInUrls(content, { version }) { return content .replaceAll('/latest/embedding/', `/${version}/embedding/`) } -function extractUrl(match) { - // body - let url = match.match(/(?<=\[(.+?)\]\()(.+?)(?=\))/gim); - if (url) { - return url[0].trim(); - } - - // footer - url = match.match(/(?<=]: )(.+?)+/gim); - if (url) { - return url[0].trim(); - } - - return null; -} - -function isRelativeUrl(url) { - return url.indexOf("http://") === -1 && url.indexOf("https://") === -1; -} - -function isMetabaseUrl(url) { - // - metabase.com/ - // - ://www.metabase.com/ - // - ://metabase.com/ - return !!( - url.indexOf("metabase.") === 0 || - url.indexOf("://www.metabase.") === 4 || - url.indexOf("://www.metabase.") === 5 || - url.indexOf("://metabase.") === 4 || - url.indexOf("://metabase.") === 5 - ); -} - -function formatUrl(url) { - // .{md,markdown,html,htm} - url = url - // Remove extensions - .replace(".md", "") - .replace(".markdown", "") - .replace(".html", "") - .replace(".htm", "") - // Remove metabase.com - .replace("http://metabase.com", "") - .replace("https://metabase.com", "") - .replace("http://www.metabase.com", "") - .replace("https://www.metabase.com", ""); - - return url; -} - -function getReplacements(filePath, matches) { - if (matches) { - return matches - .map((match) => { - const url = extractUrl(match); - if (url) { - // Relative - if (isRelativeUrl(url)) { - return { - match, - updatedMatch: match.replace(url, formatUrl(url)), - }; - } - // Absolute + Metabase - else if (isMetabaseUrl(url)) { - // Has an extension? - const urlPaths = url.match(/(?<=.com)(.*)/gim); - if (urlPaths && urlPaths.length > 0) { - // Error if there's an extension - const [urlPath] = urlPaths; - if (urlPath.indexOf(".") > -1) { - console.error( - `Error:\n\t${filePath}\n\tMetabase url do not need extension: ${url}`, - ); - } - } - return { - match, - updatedMatch: match.replace(url, formatUrl(url)), - }; - } - } else { - console.warn(`Warning:\n\t${filePath}\n\turl not found: ${match}`); - } - - return null; - }) - .filter((match) => !!match); - } - - return null; -} - /** * Lops off the last point so that point releases will overwrite (i.e., update) the * existing docs for that major release. So docs for 40.3 will update the existing docs for 40.0 @@ -209,7 +79,6 @@ function updateRedirectsAndLinks(dir) { module.exports = { canBeProcessedByFrontmatter, - reformatMarkdownUrls, squashVersion, stringifyDataAndContent, updateRedirectsAndLinks, diff --git a/src/lib/docs/reformatMarkdownUrls.ts b/src/lib/docs/reformatMarkdownUrls.ts index 5190b822bd..7cec217ea0 100644 --- a/src/lib/docs/reformatMarkdownUrls.ts +++ b/src/lib/docs/reformatMarkdownUrls.ts @@ -1,8 +1,4 @@ -// Ports reformatMarkdownUrls from ../../../lib/utils.js (used by `script/docs` -// to rewrite links once docs are copied into _docs/) so local /latest pages, -// read straight from a metabase checkout, get the same link rewriting live: -// strip .md/.html extensions from links, and shorten absolute metabase.com -// links to root-relative paths. +// Extracted from lib/utils.js so it can be used in script/docs (for cross-repo ingestion) and [...slug].astro for JIT processing const MARKDOWN_LINK_REGEX = /\[(.+?)\]\((.+?)\)/gim; const FOOTER_LINK_REGEX = /^\[(.+?)\]:\s+(.+?)\n/gim; @@ -30,10 +26,12 @@ const isMetabaseUrl = (url: string): boolean => const formatUrl = (url: string): string => url + // Remove extensions .replace(".md", "") .replace(".markdown", "") .replace(".html", "") .replace(".htm", "") + // Remove metabase.com .replace("http://metabase.com", "") .replace("https://metabase.com", "") .replace("http://www.metabase.com", "") @@ -62,12 +60,16 @@ const getReplacements = ( export const reformatMarkdownUrls = (body: string): string => { let formattedBody = body; - const bodyReplacements = getReplacements(formattedBody.match(MARKDOWN_LINK_REGEX)); + const bodyReplacements = getReplacements( + formattedBody.match(MARKDOWN_LINK_REGEX), + ); bodyReplacements?.forEach(({ match, updatedMatch }) => { formattedBody = formattedBody.replace(match, updatedMatch); }); - const footerReplacements = getReplacements(formattedBody.match(FOOTER_LINK_REGEX)); + const footerReplacements = getReplacements( + formattedBody.match(FOOTER_LINK_REGEX), + ); footerReplacements?.forEach(({ match, updatedMatch }) => { formattedBody = formattedBody.replace(match, updatedMatch); }); diff --git a/src/pages/docs/[version]/[...slug].astro b/src/pages/docs/[version]/[...slug].astro index c4e3fa84e6..766fbe398d 100644 --- a/src/pages/docs/[version]/[...slug].astro +++ b/src/pages/docs/[version]/[...slug].astro @@ -40,7 +40,6 @@ export const getStaticPaths = async () => { const { version, slug = "" } = Astro.params; const { kind, doc: rawDoc } = Astro.props; const url = `/docs/${version}/${slug}`; -// FIXME: Verify already-processed docs (via script/docs) can be re-processed here w/o breaking anything const doc = { ...rawDoc, data: { From fecb10211b59408852a97a604c7a099975299d17 Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Thu, 27 Aug 2026 13:12:06 -0400 Subject: [PATCH 09/13] GRO-835 only processes doc in [...slug].astro if needed (local mb repo) but sets the stage to make it so all src files are raw and processed the same way --- src/constants.ts | 2 +- src/lib/docs/constructDocMetadata.ts | 6 +++++ src/pages/docs/[version]/[...slug].astro | 33 +++++++++++++++--------- 3 files changed, 28 insertions(+), 13 deletions(-) diff --git a/src/constants.ts b/src/constants.ts index ec1cbd4850..8674302ace 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -5,7 +5,7 @@ import { loadEnv } from "vite"; // see import.meta.env values. const env = loadEnv(process.env.NODE_ENV ?? "development", process.cwd(), ""); -const METABASE_REPO_PATH = env.METABASE_REPO_PATH; +export const METABASE_REPO_PATH = env.METABASE_REPO_PATH; export const DOCS_SRC_ROOT = METABASE_REPO_PATH ? `${METABASE_REPO_PATH}/docs` diff --git a/src/lib/docs/constructDocMetadata.ts b/src/lib/docs/constructDocMetadata.ts index bc0c679bd7..817875c4ae 100644 --- a/src/lib/docs/constructDocMetadata.ts +++ b/src/lib/docs/constructDocMetadata.ts @@ -52,6 +52,7 @@ export type DocMetadata = { source_url: string; layout: "docs" | "new-docs"; permalink?: string; + latest?: boolean; }; // The `page` shape passed to doc layouts: metadata plus the resolved page URL. @@ -60,6 +61,7 @@ export type DocPage = DocMetadata & { url: string }; export function constructDocMetadata( docPath: string, version: string, + isLatest = false, ): DocMetadata { const versionNumber = parseInt(version.split(".").pop() || "", 10); const metadata: Partial = { @@ -99,5 +101,9 @@ export function constructDocMetadata( "/" + path.join("docs", version, "index.html").split(path.sep).join("/"); } + if (isLatest) { + metadata.latest = true; + } + return metadata as DocMetadata; } diff --git a/src/pages/docs/[version]/[...slug].astro b/src/pages/docs/[version]/[...slug].astro index 766fbe398d..5f63f4c789 100644 --- a/src/pages/docs/[version]/[...slug].astro +++ b/src/pages/docs/[version]/[...slug].astro @@ -1,6 +1,7 @@ --- import path from "node:path"; import { pathToFileURL } from "node:url"; +import { METABASE_REPO_PATH } from "@/constants"; import NewDocsLayout from "@/layouts/NewDocsLayout.astro"; import OldDocsLayout from "@/layouts/OldDocsLayout.astro"; import { constructDocMetadata } from "@/lib/docs/constructDocMetadata"; @@ -38,19 +39,27 @@ export const getStaticPaths = async () => { }; const { version, slug = "" } = Astro.params; -const { kind, doc: rawDoc } = Astro.props; +const { kind, doc: propsDoc } = Astro.props; const url = `/docs/${version}/${slug}`; -const doc = { - ...rawDoc, - data: { - ...constructDocMetadata( - url.slice(1), // remove leading slash for parity with fetchDocs - version === "latest" ? baseCtx.site.docs_version : version, - ), - ...rawDoc.data, - }, - body: reformatMarkdownUrls(rawDoc.body ?? ""), -}; +// TODO: GRO-688 Remove processing from /script/docs so src files are raw whether reading from _docs or METABASE_REPO_PATH +// The only known gap is updateRedirectsAndLinks for non-latest versions, which is two separate fixes: +// 1. replaceVersionInUrls belongs alongside reformatMarkdownUrls +// 2. redirect_from rewriting belongs in collectRedirects +const isRaw = !!METABASE_REPO_PATH; +const doc = !isRaw + ? propsDoc + : { + ...propsDoc, + data: { + ...constructDocMetadata( + url.slice(1), // remove leading slash for parity with fetchDocs + version === "latest" ? baseCtx.site.docs_version : version, + version === "latest", + ), + ...propsDoc.data, + }, + body: reformatMarkdownUrls(propsDoc.body ?? ""), + }; const dirname = path.dirname(doc.filePath!); // Process liquid first (e.g. control flow, includes, variables, etc) From 641b5eb9e2762d5febb19c83834f57a730bc5d2d Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Thu, 27 Aug 2026 13:54:13 -0400 Subject: [PATCH 10/13] GRO-835 cleanup + typo fixes --- lib/test-utils.js | 6 ++---- lib/utils.js | 2 +- src/lib/docs/reformatMarkdownUrls.ts | 2 +- src/pages/docs/[version]/[...slug].astro | 9 +++------ 4 files changed, 7 insertions(+), 12 deletions(-) diff --git a/lib/test-utils.js b/lib/test-utils.js index 9482d32af4..21f36f72dd 100644 --- a/lib/test-utils.js +++ b/lib/test-utils.js @@ -1,4 +1,4 @@ -const {reformatMarkdownUrls} = require('./utils.js') +const { reformatMarkdownUrls } = require('../src/lib/docs/reformatMarkdownUrls') // Test URL reformatting @@ -67,10 +67,8 @@ Anchors work [in footers][footer-anchor] too. ] function testUrlReformatting () { - const filePath = '' - for (const test of tests) { - const actual = reformatMarkdownUrls(filePath, test.fixture) + const actual = reformatMarkdownUrls(test.fixture) const expectedLines = test.expected.split('\n') const actualLines = actual.split('\n') expectedLines.forEach((eL, i) => { diff --git a/lib/utils.js b/lib/utils.js index b26ad0f649..54e756fed0 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -57,7 +57,7 @@ function updateRedirectsAndLinks(dir) { const { content, data } = matter(fileContent); // Trim space buffering frontmatter; // we'll add it back later in stringifyDataAndContent - let contentUpdatedLinks = reformatMarkdownUrls(filePath, content.trim()); + let contentUpdatedLinks = reformatMarkdownUrls(content.trim()); if (dir !== "_docs/latest") { contentUpdatedLinks = replaceVersionInUrls(contentUpdatedLinks, { diff --git a/src/lib/docs/reformatMarkdownUrls.ts b/src/lib/docs/reformatMarkdownUrls.ts index 7cec217ea0..74ddb3fdec 100644 --- a/src/lib/docs/reformatMarkdownUrls.ts +++ b/src/lib/docs/reformatMarkdownUrls.ts @@ -58,7 +58,7 @@ const getReplacements = ( }; export const reformatMarkdownUrls = (body: string): string => { - let formattedBody = body; + let formattedBody = `${body}\n`; const bodyReplacements = getReplacements( formattedBody.match(MARKDOWN_LINK_REGEX), diff --git a/src/pages/docs/[version]/[...slug].astro b/src/pages/docs/[version]/[...slug].astro index 5f63f4c789..bbe550cb38 100644 --- a/src/pages/docs/[version]/[...slug].astro +++ b/src/pages/docs/[version]/[...slug].astro @@ -22,17 +22,14 @@ export const getStaticPaths = async () => { ]); const toPath = - (kind: Kind, isRaw?: boolean) => - (doc: { id: string; data: { [key: string]: unknown }; body?: string }) => { + (kind: Kind) => + (doc: { id: string; data: Record; body?: string }) => { const { version, slug } = resolveDocUrl({ id: doc.id, // For prod builds, we want to output like folder/index.html, but for the dev server, the route should exclude /index includeTrailingIndex: import.meta.env.MODE !== "development", }); - return { - props: { kind, doc, isRaw }, - params: { version, slug }, - }; + return { props: { kind, doc }, params: { version, slug } }; }; return [...docs.map(toPath("md")), ...docsHtml.map(toPath("html"))]; From cd5f25583c836f8d6f1b5acc1e0ce7bacd3added Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Thu, 27 Aug 2026 14:19:01 -0400 Subject: [PATCH 11/13] GRO-835 add readme --- README.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000000..2cf83a48b2 --- /dev/null +++ b/README.md @@ -0,0 +1,24 @@ +# Metabase docs site + +An Astro site that renders the Metabase docs. + +## Quick start + +```sh +bun i +bun dev +``` + +The dev server runs at http://localhost:4321/docs/latest/. + +## Serving docs from a local Metabase repo + +``` +cp .env-dist .env +``` + +Point `METABASE_REPO_PATH` at your local Metabase repo (defaults to `../metabase`, i.e. it assumes the repo is a sibling of this one). + +With `METABASE_REPO_PATH` set, `/docs/latest` serves and hot-reloads files from your local Metabase repo, and only `/latest` routes are available — earlier versions 404. Comment it out to serve all versions from `./_docs` instead. + +Restart the dev server after changing `.env`. From b21f85c7d46495527584b3233155d964d66e3501 Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Thu, 27 Aug 2026 15:39:01 -0400 Subject: [PATCH 12/13] GRO-835 polish --- astro.config.mjs | 14 ++++++++++++-- src/lib/docs/constructDocMetadata.ts | 1 + src/lib/docs/resolveDoc.ts | 7 +++---- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/astro.config.mjs b/astro.config.mjs index 5b26803951..4111e4d6b6 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -1,10 +1,20 @@ // @ts-check +import path from "node:path"; import { defineConfig } from "astro/config"; import { viteStaticCopy } from "vite-plugin-static-copy"; import { DOCS_DEST, DOCS_SRC_ROOT } from "./src/constants"; import { collectRedirects } from "./src/lib/docs/collectRedirects"; import { noopMarkdownProcessor } from "./src/lib/markdown/noopMarkdownProcessor"; +// The number of leading path segments to strip from each copied file's directory. +// Computes the directory relative to the project root and strips any leading `../`. +// e.g. `_docs` -> 1, `../metabase/docs` -> 2 +const docsSrcStripBase = path + .relative(process.cwd(), path.resolve(DOCS_SRC_ROOT)) + .replace(/^(?:\.\.\/)+/, "") + .split("/") + .filter(Boolean).length; + // https://astro.build/config export default defineConfig({ site: "https://www.metabase.com", @@ -27,14 +37,14 @@ export default defineConfig({ { src: `${DOCS_SRC_ROOT}/**/*.{jpg,png,gif,json}`, dest: DOCS_DEST, - rename: { stripBase: DOCS_DEST.split("/").length }, + rename: { stripBase: docsSrcStripBase }, }, { // TypeDoc-generated CSS/JS/icons the SDK API reference .html // docs load via relative `assets/...` URLs. src: `${DOCS_SRC_ROOT}/**/embedding/sdk/api/assets/*.{css,js,svg,ico}`, dest: DOCS_DEST, - rename: { stripBase: DOCS_DEST.split("/").length }, + rename: { stripBase: docsSrcStripBase }, }, ], }), diff --git a/src/lib/docs/constructDocMetadata.ts b/src/lib/docs/constructDocMetadata.ts index 817875c4ae..688afbfdd4 100644 --- a/src/lib/docs/constructDocMetadata.ts +++ b/src/lib/docs/constructDocMetadata.ts @@ -1,3 +1,4 @@ +// Extracted from lib/fetch-docs.js so it can be used in script/docs (for cross-repo ingestion) and [...slug].astro for JIT processing import path from "node:path"; import toTitleCase from "titlecase"; diff --git a/src/lib/docs/resolveDoc.ts b/src/lib/docs/resolveDoc.ts index a7e9dfd49a..fea8b5b5f1 100644 --- a/src/lib/docs/resolveDoc.ts +++ b/src/lib/docs/resolveDoc.ts @@ -1,7 +1,6 @@ -// Derives a doc's version/slug/URL from its content collection id (or -// frontmatter `permalink` override), since docs are stored as -// `/.md` but need a canonical `/docs//` URL for -// routing, sitemaps, and redirects. +// Derives a doc's version/slug/URL from its content collection id, since +// docs are stored as `/.md` but need a canonical +// `/docs//` URL for routing, sitemaps, and redirects. export const resolveDocUrl = ({ id, includeTrailingIndex, From 1063df2b07c4901d50e67aadfe4cc6bd5bf480ae Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Wed, 2 Sep 2026 13:52:37 -0400 Subject: [PATCH 13/13] GRO-835 fix docPath computation --- src/pages/docs/[version]/[...slug].astro | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/pages/docs/[version]/[...slug].astro b/src/pages/docs/[version]/[...slug].astro index bbe550cb38..beb8d2a1ca 100644 --- a/src/pages/docs/[version]/[...slug].astro +++ b/src/pages/docs/[version]/[...slug].astro @@ -1,7 +1,7 @@ --- import path from "node:path"; import { pathToFileURL } from "node:url"; -import { METABASE_REPO_PATH } from "@/constants"; +import { DOCS_SRC_ROOT, METABASE_REPO_PATH } from "@/constants"; import NewDocsLayout from "@/layouts/NewDocsLayout.astro"; import OldDocsLayout from "@/layouts/OldDocsLayout.astro"; import { constructDocMetadata } from "@/lib/docs/constructDocMetadata"; @@ -49,7 +49,9 @@ const doc = !isRaw ...propsDoc, data: { ...constructDocMetadata( - url.slice(1), // remove leading slash for parity with fetchDocs + METABASE_REPO_PATH + ? `/${path.relative(METABASE_REPO_PATH, propsDoc.filePath!)}` + : `/docs/${path.relative(`${DOCS_SRC_ROOT}/${version}`, propsDoc.filePath!)}`, version === "latest" ? baseCtx.site.docs_version : version, version === "latest", ),