Skip to content
24 changes: 24 additions & 0 deletions src/components/categories/CategoryCards.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
import ReadTime from "./ReadTime.astro";

export type PageEntry = {
url: string;
title: string;
summary: string;
readTime?: number;
};

type Props = {
pages: PageEntry[];
};

const { pages } = Astro.props;
---

{pages?.map((pageEntry) => (
<a href={pageEntry.url} class="learn__category__link">
<h4>{pageEntry.title}</h4>
<p>{pageEntry.summary}</p>
{!!pageEntry.readTime && <ReadTime minutes={pageEntry.readTime} />}
</a>
))}
46 changes: 46 additions & 0 deletions src/components/categories/CategoryHeader.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
---
interface Props {
title: string;
summary?: string;
category_image?: string;
}

const { title, summary, category_image: image } = Astro.props;
---

<header class:list={["category-header", image && "has-image"]}>
<h1>{title}</h1>
{summary && <div class="learn__summary">{summary}</div>}
{image && <img src={image} alt="" class="category-header__image no-zoom" />}
</header>

<style>
.category-header {
position: relative;
}

.category-header .category-header__image {
box-shadow: none;
display: block;
margin: 0 auto;
width: 50%;
}

@media screen and (min-width: 992px) {
.category-header.has-image {
padding-bottom: 96px;
}

.learn__summary {
width: 75%;
}

.category-header .category-header__image {
bottom: 0;
position: absolute;
right: -200px;
width: clamp(270px, 30vw, 420px);
z-index: -1;
}
}
</style>
14 changes: 14 additions & 0 deletions src/components/categories/ReadTime.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
import Clock from "@/icons/clock.svg";

interface Props {
minutes: number;
}

const { minutes } = Astro.props;
---

<p>
<Clock />
{minutes} min read
</p>
64 changes: 21 additions & 43 deletions src/components/chrome/Breadcrumb.astro
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
---
import { getCategoryUrl } from "@/lib/docs/nav";
import { baseCtx } from "@/lib/liquid/liquidRenderer";

type Props = {
Expand All @@ -15,25 +16,26 @@ type Props = {
const { page } = Astro.props;
const { site } = baseCtx;

const slugify = (str: string) =>
str
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");

const version = page.version === "latest" ? site.docs_version : page.version;
const latestOrExplicitVersion =
site.docs_version === page.version && page.url.includes("/docs/latest/")
? "latest"
: (page.version ?? "");
const categorySlug = slugify(page.category ?? "");
const categorySlugPart =
categorySlug === "troubleshooting-guide"
? "/"
: categorySlug === "api"
? "api-documentation"
: "/start";

const getFallbackCategoryUrl = (version: string, url: string) => {
// page.url is /docs/<version>/<category-slug>/...
const categorySlug = url.split("/")[3] ?? "";
const legacyMapping: Record<string, string> = {
api: "api-documentation",
"troubleshooting-guide": "troubleshooting-guide/",
};
const fallbackCategoryPath =
legacyMapping[categorySlug] ?? `${categorySlug}/start`;
return `/docs/${version}/${fallbackCategoryPath}`;
};

const categoryUrl =
getCategoryUrl(latestOrExplicitVersion, page.category ?? "") ??
getFallbackCategoryUrl(latestOrExplicitVersion, page.url);
---

<div class="bootstrap">
Expand All @@ -55,41 +57,17 @@ const categorySlugPart =
) : (
page.show_category_breadcrumb && (
<li class="breadcrumb-item">
<a
class="blue-60"
href={`/docs/${latestOrExplicitVersion}/${page.category !== "Api" ? categorySlug : ""}${categorySlugPart}`}
>
<a class="blue-60" href={categoryUrl}>
{page.category}
</a>
</li>
)
)}
</>
) : (
<>
{page.title === "Search" ? (
<li class="breadcrumb-item">
<span class="m-0 paragraph-5 neutral-40 fw-bold">
<a href="/docs/latest/">{site.latest_version}</a>
</span>
</li>
) : (
<li class="breadcrumb-item">
<span class="neutral-40">{version}</span>
</li>
)}

{page.category === "Api" && (
<li class="breadcrumb-item">
<span class="neutral-40">{page.category}</span>
</li>
)}
{page.title !== "README" && (
<li class="breadcrumb-item">
<span class="neutral-40">{page.title}</span>
</li>
)}
</>
<li class="breadcrumb-item">
<span class="neutral-40">{page.title}</span>
</li>
)}
</ol>
</nav>
Expand Down
1 change: 1 addition & 0 deletions src/components/chrome/PageNav.astro
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
});

const headers = Array.from(document.querySelectorAll("h2"));
if (!headers.length) button.remove();
const links = new Map<HTMLHeadingElement, HTMLAnchorElement>();
let activeLink: HTMLAnchorElement | null = null;

Expand Down
4 changes: 4 additions & 0 deletions src/icons/clock.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
57 changes: 57 additions & 0 deletions src/layouts/CategoryLayout.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
---
import CategoryCards, {
type PageEntry,
} from "@/components/categories/CategoryCards.astro";
import CategoryHeader from "@/components/categories/CategoryHeader.astro";
import type { DocPage } from "@/lib/docs/constructDocMetadata";
import { findNavNode, getNavForVersion, type NavNode } from "@/lib/docs/nav";
import { docIdFromUrl } from "@/lib/docs/resolveDoc";
import { getEntry } from "astro:content";
import NewDocsLayout from "./NewDocsLayout.astro";

type Props = {
page: DocPage;
dirname: string;
};

const { page, dirname } = Astro.props;

function getReadTime(body?: string, minimum = 0): number | undefined {
if (!body) return undefined;

// Match Jekyll's `number_of_words` filter, which counts whitespace-separated
// tokens in the unrendered collection content.
const words = body.trim().split(/\s+/).filter(Boolean).length;
return Math.max(minimum, Math.floor(words / 238));
}

const navNode = findNavNode(
getNavForVersion(page.latest ? "latest" : page.version).categories,
(node) => !!node.url && node.url === page.url,
);

const pages: PageEntry[] = await Promise.all(
(navNode?.pages ?? [])
// Only docs pages get cards; external links are skipped.
.filter((node): node is NavNode & { url: string } =>
Boolean(node.url?.startsWith("/docs/")),
)
.map(async (node) => {
const doc = await getEntry("docs", docIdFromUrl(node.url));
if (!doc) {
throw new Error(`Doc not found: ${node.url}`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

some nav items have links pointing to non-docs pages, mb we should filter them out or add a fallback for such items?

@bpander bpander Oct 5, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jeff-bruemmer Do you know of any cases where we'd have one of these category landing pages with a card that's an external link? Or have an opinion on what should happen if that ever comes up?

I didn't want to just guess at the behavior so I just have it failing loudly if it ever happens. We could leave that as is and cross that bridge if we come to it. I'm also happy to implement some kind of a fallback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GH failed to post Jeff's comment, but I DM'd him. We'll just show cards for links to actual docs. I updated the code to filter out external links in 96220c2.

}
return {
url: node.url,
title: doc.data.title ?? node.name,
summary: doc.data.summary,
readTime: getReadTime(doc.body, 1),
};
}),
);
---

<NewDocsLayout {page} {dirname}>
<CategoryHeader {...page} />
<CategoryCards pages={pages} />
</NewDocsLayout>
2 changes: 1 addition & 1 deletion src/lib/docs/constructDocMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export type DocMetadata = {
category: string;
title: string;
source_url: string;
layout: "docs" | "new-docs";
layout: string;
permalink?: string;
latest?: boolean;
};
Expand Down
27 changes: 27 additions & 0 deletions src/lib/docs/nav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,30 @@ export const getNavForVersion = (version: string): Nav => {
const shouldCache = import.meta.env.MODE !== "development";
return shouldCache ? (navCache[version] ??= computeNav()) : computeNav();
};

// Depth-first search for the first node matching `predicate`.
export const findNavNode = (
nodes: NavNode[] = [],
predicate: (node: NavNode) => boolean,
): NavNode | undefined => {
for (const node of nodes) {
if (predicate(node)) return node;
const match = findNavNode(node.pages, predicate);
if (match) return match;
}
};

// Finds the url of the nav section (a node with child pages) named `category`.
// Sections only live one level below the top-level categories.
export const getCategoryUrl = (
version: string,
category: string,
): string | undefined => {
const target = category.toLowerCase();
return getNavForVersion(version)
.categories.flatMap((c) => c.pages ?? [])
.find(
(node) =>
!!node.url && !!node.pages && node.name.toLowerCase() === target,
)?.url;
};
5 changes: 5 additions & 0 deletions src/lib/docs/resolveDoc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,8 @@ export const resolveDocUrl = ({
separatorIndex !== -1 ? resolvedId.slice(separatorIndex + 1) : "";
return { version, slug, url: `/docs/${version}/${slug}` };
};

// Inverse of resolveDocUrl for `.md` docs: maps `/docs/<version>/<slug>` back
// to the collection id, with index pages ending in `/index`.
export const docIdFromUrl = (url: string): string =>
url.replace(/^\/docs\//, "").replace(/\/$/, "/index");
8 changes: 7 additions & 1 deletion src/pages/docs/[version]/[...slug].astro
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import path from "node:path";
import { pathToFileURL } from "node:url";
import { DOCS_SRC_ROOT, METABASE_REPO_PATH } from "@/constants";
import CategoryLayout from "@/layouts/CategoryLayout.astro";
import NewDocsLayout from "@/layouts/NewDocsLayout.astro";
import OldDocsLayout from "@/layouts/OldDocsLayout.astro";
import { constructDocMetadata } from "@/lib/docs/constructDocMetadata";
Expand All @@ -10,6 +11,7 @@ import { rewriteDocLinks } from "@/lib/docs/rewriteDocLinks";
import { baseCtx, getLiquidRenderer } from "@/lib/liquid/liquidRenderer";
import { getMarkdownRenderer } from "@/lib/markdown/markdownRenderer";
import { getCollection, type DataEntryMap } from "astro:content";
import type { AstroComponentFactory } from "astro/runtime/server/index.js";

type Props =
| { kind: "md"; doc: DataEntryMap["docs"][number] }
Expand Down Expand Up @@ -86,7 +88,11 @@ if (kind === "html") {
renderedHtml = renderResult.code;
}

const Layout = doc.data.layout === "docs" ? OldDocsLayout : NewDocsLayout;
const layouts: Record<string, AstroComponentFactory> = {
docs: OldDocsLayout,
category: CategoryLayout,
};
const Layout = layouts[doc.data.layout] ?? NewDocsLayout;
---

{kind === "html" ? (
Expand Down
Loading