diff --git a/src/components/categories/CategoryCards.astro b/src/components/categories/CategoryCards.astro new file mode 100644 index 0000000000..9b4660fc55 --- /dev/null +++ b/src/components/categories/CategoryCards.astro @@ -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) => ( + +

{pageEntry.title}

+

{pageEntry.summary}

+ {!!pageEntry.readTime && } +
+))} diff --git a/src/components/categories/CategoryHeader.astro b/src/components/categories/CategoryHeader.astro new file mode 100644 index 0000000000..a2e8aec6bf --- /dev/null +++ b/src/components/categories/CategoryHeader.astro @@ -0,0 +1,46 @@ +--- +interface Props { + title: string; + summary?: string; + category_image?: string; +} + +const { title, summary, category_image: image } = Astro.props; +--- + +
+

{title}

+ {summary &&
{summary}
} + {image && } +
+ + diff --git a/src/components/categories/ReadTime.astro b/src/components/categories/ReadTime.astro new file mode 100644 index 0000000000..8a89a1a3c3 --- /dev/null +++ b/src/components/categories/ReadTime.astro @@ -0,0 +1,14 @@ +--- +import Clock from "@/icons/clock.svg"; + +interface Props { + minutes: number; +} + +const { minutes } = Astro.props; +--- + +

+ + {minutes} min read +

diff --git a/src/components/chrome/Breadcrumb.astro b/src/components/chrome/Breadcrumb.astro index bcdcf6ee8a..8c5bbc3278 100644 --- a/src/components/chrome/Breadcrumb.astro +++ b/src/components/chrome/Breadcrumb.astro @@ -1,4 +1,5 @@ --- +import { getCategoryUrl } from "@/lib/docs/nav"; import { baseCtx } from "@/lib/liquid/liquidRenderer"; type Props = { @@ -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///... + const categorySlug = url.split("/")[3] ?? ""; + const legacyMapping: Record = { + 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); ---
@@ -55,10 +57,7 @@ const categorySlugPart = ) : ( page.show_category_breadcrumb && ( @@ -66,30 +65,9 @@ const categorySlugPart = )} ) : ( - <> - {page.title === "Search" ? ( - - ) : ( - - )} - - {page.category === "Api" && ( - - )} - {page.title !== "README" && ( - - )} - + )} diff --git a/src/components/chrome/PageNav.astro b/src/components/chrome/PageNav.astro index b558219ed6..65b27503d9 100644 --- a/src/components/chrome/PageNav.astro +++ b/src/components/chrome/PageNav.astro @@ -20,6 +20,7 @@ }); const headers = Array.from(document.querySelectorAll("h2")); + if (!headers.length) button.remove(); const links = new Map(); let activeLink: HTMLAnchorElement | null = null; diff --git a/src/icons/clock.svg b/src/icons/clock.svg new file mode 100644 index 0000000000..3313889275 --- /dev/null +++ b/src/icons/clock.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/layouts/CategoryLayout.astro b/src/layouts/CategoryLayout.astro new file mode 100644 index 0000000000..5e6471b9d1 --- /dev/null +++ b/src/layouts/CategoryLayout.astro @@ -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}`); + } + return { + url: node.url, + title: doc.data.title ?? node.name, + summary: doc.data.summary, + readTime: getReadTime(doc.body, 1), + }; + }), +); +--- + + + + + diff --git a/src/lib/docs/constructDocMetadata.ts b/src/lib/docs/constructDocMetadata.ts index 688afbfdd4..6170230721 100644 --- a/src/lib/docs/constructDocMetadata.ts +++ b/src/lib/docs/constructDocMetadata.ts @@ -51,7 +51,7 @@ export type DocMetadata = { category: string; title: string; source_url: string; - layout: "docs" | "new-docs"; + layout: string; permalink?: string; latest?: boolean; }; diff --git a/src/lib/docs/nav.ts b/src/lib/docs/nav.ts index 845fbdd978..8ce188aab7 100644 --- a/src/lib/docs/nav.ts +++ b/src/lib/docs/nav.ts @@ -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; +}; diff --git a/src/lib/docs/resolveDoc.ts b/src/lib/docs/resolveDoc.ts index 7a0e5df449..d92f7517c4 100644 --- a/src/lib/docs/resolveDoc.ts +++ b/src/lib/docs/resolveDoc.ts @@ -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//` back +// to the collection id, with index pages ending in `/index`. +export const docIdFromUrl = (url: string): string => + url.replace(/^\/docs\//, "").replace(/\/$/, "/index"); diff --git a/src/pages/docs/[version]/[...slug].astro b/src/pages/docs/[version]/[...slug].astro index 7b7ba1df61..f87809bf30 100644 --- a/src/pages/docs/[version]/[...slug].astro +++ b/src/pages/docs/[version]/[...slug].astro @@ -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"; @@ -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] } @@ -86,7 +88,11 @@ if (kind === "html") { renderedHtml = renderResult.code; } -const Layout = doc.data.layout === "docs" ? OldDocsLayout : NewDocsLayout; +const layouts: Record = { + docs: OldDocsLayout, + category: CategoryLayout, +}; +const Layout = layouts[doc.data.layout] ?? NewDocsLayout; --- {kind === "html" ? (