diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..df59db5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,89 @@ +node_modules +public/build +build +dist +out +coverage +.history +.react-router + +# Other Coverage tools +*.lcov + +# macOS +.DS_* + +# Cache Directories and files +.cache +.yarn* +.env* +!.env.example +.swp* +.turbo +.npm +.stylelintcache +*.tsbuildinfo +.node_repl_history + +# Lock files from other package managers +package-lock.json +yarn.lock + +# General tempory files and directories +t?mp +.t?mp +*.t?mp + +# Docusaurus cache and generated files +.docusaurus + +# Output of 'npm pack' +*.tgz +*.tar +*.tar.gz +*.tar.bz2 +*.tbz +*.zip + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* +vite.config.ts.* + +# Playwright various test reports +test-results +playwright-report +blob-report + + +# Editors +.idea/workspace.xml +.idea/usage.statistics.xml +.idea/shelf + +# Dont commit sqlite database files +*.db +*.sqlite +*.sqlite3 +*.db-journal + + +# Content collections output files +.content-collections + +# Output base directory of the documentation +generated-docs/ diff --git a/.env.example b/.env.example index d6b7df4..02cb40a 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,4 @@ GITHUB_OWNER="github-owner" # Your username or organization name (Optional. For edit/report an issue for the documentation page) GITHUB_REPO="github-repo" # Repository name (Optional. For edit/report an issue for the documentation page) APP_ROOT_PATH="/path/to/your/app" # Optional. Default is `process.cwd()` +GITHUB_REPO_URL="github-repo-url" # Optional. If you want to have GitHub icon link in the header or footer diff --git a/.github/workflows/validate.yml b/.github/workflows/ci.yml similarity index 70% rename from .github/workflows/validate.yml rename to .github/workflows/ci.yml index c1598c4..b979290 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -name: ๐Ÿš€ Validation Pipeline +name: ๐Ÿš€ Validation & Deploy Pipeline concurrency: group: ${{ github.repository }}-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true @@ -67,3 +67,24 @@ jobs: # Only works if you set `reportOnFailure: true` in your vite config as specified above if: always() uses: davelosert/vitest-coverage-report-action@v2 + + + deploy: + needs: [lint, typecheck, check-unused, vitest] + name: ๐Ÿš€ Deploy PR Preview + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: forge-42/fly-deploy@v1.0.0-rc.2 + id: deploy + env: + FLY_ORG: ${{ vars.FLY_ORG }} + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} + FLY_REGION: ${{ vars.FLY_REGION }} + with: + app_name: ${{github.event.repository.name}}-${{ github.event.number }} + env_vars: | + APP_ENV=staging + GITHUB_OWNER=${{github.repository_owner}} + GITHUB_REPO=${{github.event.repository.name}} + GITHUB_REPO_URL=https://github.com/${{ github.repository }} diff --git a/.github/workflows/pr-close.yml b/.github/workflows/pr-close.yml new file mode 100644 index 0000000..1f8f72a --- /dev/null +++ b/.github/workflows/pr-close.yml @@ -0,0 +1,27 @@ +name: ๐Ÿงน PR Close + +concurrency: + group: ${{ github.repository }}-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + pull_request: + branches: [main] + types: closed + +jobs: + + destroy-pr-preview: + name: ๐Ÿงน Destroy PR Preview + runs-on: ubuntu-latest + environment: + name: pr-preview + steps: + - uses: actions/checkout@v4 + - uses: forge-42/fly-destroy@v1.0.0-rc.2 + id: destroy + env: + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} + FLY_ORG: ${{ vars.FLY_ORG }} + with: + app_name: ${{ env.FLY_ORG }}-${{ github.event.number }} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0534ba1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,51 @@ + +# syntax = docker/dockerfile:1.4 + +# Base dependencies stage +ARG NODE_VERSION=22.17.0 +FROM node:${NODE_VERSION}-slim AS base + +LABEL fly_launch_runtime="Node.js" + +# Node.js app lives here +WORKDIR /app + +# Set production environment +ENV NODE_ENV="production" + +# Install pnpm +ARG PNPM_VERSION=10.13.0 +RUN npm install -g pnpm@$PNPM_VERSION + + +# Throw-away build stage to reduce size of final image +FROM base AS build + +# Install packages needed to build node modules +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y build-essential node-gyp pkg-config python-is-python3 git + +# Install node modules +COPY .npmrc package.json pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile --prod=false + +# Copy application code +COPY . . + +# Build application +RUN pnpm run generate:docs +RUN pnpm run build + +# Remove development dependencies +RUN pnpm prune --prod + + +# Final stage for app image +FROM base + +# Copy built application +COPY --from=build /app /app + +# Start the server by default, this can be overwritten at runtime +EXPOSE 3000 +CMD [ "pnpm", "run", "start" ] diff --git a/README.md b/README.md index 28283d7..602a5a6 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ This folder contains all the resources used by the documentation site, such as S `content/` -This folder contains sections and subsections with .mdx files that hold your documentation content. Below is the recommended structure to follow. +This folder contains .md and .mdx files that hold your documentation content. Below is the recommended structure to follow. An example of a valid content/ folder structure for organizing your package documentation: @@ -46,12 +46,15 @@ content/ โ”œโ”€โ”€ 01-buttons.mdx โ””โ”€โ”€ 02-modals.mdx ``` -- Top-level .mdx files (like 01-changelog.mdx) are allowed. -- Sections (like 04-getting-started, 05-core-features) are subfolders inside the content/ folder. -- Subsections (like 03-data-management, 04-ui-components) are nested folders within sections. -- Each section or subsection should have an index.md file for its sidebar title. +- Top-level .mdx files (like 01-changelog.mdx) are allowed, but we recommend placing them in order before the sections, as shown in the example. -### Example of the valid `02-introduction.mdx` file: +- Sections (like 04-getting-started, 05-core-features) are subfolders inside the `content` folder. + +- Subsections (like 03-data-management, 04-ui-components) are nested folders within sections. Filenames inside them should start with `01-*.mdx`. + +- Each section or subsection should include an `index.md` file, which defines its sidebar title. + +### Example of the valid `**/*.mdx` file: ``` --- title: "Introduction to Forge42 Base Stack" @@ -75,7 +78,7 @@ cd my-app npm install ``` -### Example of the valid `04-getting-started/index.md` file: +### Example of the valid `**/*.md` file: ``` --- title: Getting Started diff --git a/app/components/command-k/components/search-input.tsx b/app/components/command-k/components/search-input.tsx index 7ccf51f..da71c9f 100644 --- a/app/components/command-k/components/search-input.tsx +++ b/app/components/command-k/components/search-input.tsx @@ -13,7 +13,7 @@ export function SearchInput({ value, onChange, placeholder, ref }: SearchInputPr return (
diff --git a/app/components/command-k/components/trigger-button.tsx b/app/components/command-k/components/trigger-button.tsx index 3c243e2..e95d791 100644 --- a/app/components/command-k/components/trigger-button.tsx +++ b/app/components/command-k/components/trigger-button.tsx @@ -12,7 +12,7 @@ export const TriggerButton = ({ type="button" onClick={onOpen} className={cn( - "group flex items-center gap-2 rounded-lg border px-3 py-2 text-sm shadow-sm transition-all duration-200", + "group flex items-center gap-2 rounded-lg border px-2 py-1.5 text-sm shadow-sm transition-all duration-200 xl:px-3 xl:py-2", "border-[var(--color-trigger-border)] bg-[var(--color-trigger-bg)] text-[var(--color-trigger-text)]", "hover:border-[var(--color-trigger-hover-border)] hover:bg-[var(--color-trigger-hover-bg)] hover:shadow-md", "focus:border-[var(--color-trigger-focus-border)] focus:outline-none focus:ring-2 focus:ring-[var(--color-trigger-focus-ring)]" @@ -22,8 +22,8 @@ export const TriggerButton = ({ name="Search" className={cn("size-4 transition-colors", "group-hover:text-[var(--color-trigger-hover-text)]")} /> - {placeholder} -
+ {placeholder} +
page._meta.fileName !== "_index.mdx" && page.slug !== "_index") + .filter((page) => page.slug !== "_index") .flatMap((page) => { const pageSlug = getPageSlug(page) const pageUrl = pageSlug.startsWith("/") ? pageSlug : `/${pageSlug}` const sections = extractHeadingSections(page.rawMdx) - return sections.map((section) => { const heading = section.heading === "_intro" ? page.title : section.heading diff --git a/app/components/command-k/hooks/use-debounce.ts b/app/components/command-k/hooks/use-debounce.ts new file mode 100644 index 0000000..1a01372 --- /dev/null +++ b/app/components/command-k/hooks/use-debounce.ts @@ -0,0 +1,12 @@ +import { useEffect, useState } from "react" + +export function useDebounce(value: T, delay = 250) { + const [debouncedValue, setDebouncedValue] = useState(value) + + useEffect(() => { + const id = setTimeout(() => setDebouncedValue(value), delay) + return () => clearTimeout(id) + }, [value, delay]) + + return debouncedValue +} diff --git a/app/components/command-k/hooks/use-search.ts b/app/components/command-k/hooks/use-search.ts index cd06c74..607b86e 100644 --- a/app/components/command-k/hooks/use-search.ts +++ b/app/components/command-k/hooks/use-search.ts @@ -1,9 +1,10 @@ -import { useState } from "react" +import { useEffect, useRef, useState } from "react" import { useFetcher } from "react-router" import z from "zod" import type { Version } from "~/utils/version-resolvers" import { versions } from "~/utils/versions" import type { SearchResult } from "../search-types" +import { useDebounce } from "./use-debounce" export const commandKSearchParamsSchema = z.object({ query: z.string(), @@ -23,31 +24,36 @@ function createCommandKSearchParams(params: Record) { return { params: new URLSearchParams(result.data) } } +const debounceMs = 250 +const minChars = 1 + export function useSearch({ version }: { version: Version }) { const fetcher = useFetcher<{ results: SearchResult[] }>() const [query, setQuery] = useState("") - //we will show results as soon as we have a non-empty query - //this does not debounce or wait for fetcher.state === "idle". + const debouncedQuery = useDebounce(query, debounceMs) + const lastLoadedRef = useRef(null) + const results = query.trim() ? (fetcher.data?.results ?? []) : [] function search(q: string) { - const trimmed = q.trim() + setQuery(q) + } - if (!trimmed) { - setQuery("") + useEffect(() => { + const trimmed = debouncedQuery.trim() + if (!trimmed || trimmed.length < minChars) { + lastLoadedRef.current = null return } - setQuery(trimmed) + if (lastLoadedRef.current === trimmed) return + lastLoadedRef.current = trimmed + const { params } = createCommandKSearchParams({ query: trimmed, version }) - if (!params) { - // biome-ignore lint/suspicious/noConsole: keep for debugging - console.error("Failed to create search parameters.") - return - } + if (!params) return fetcher.load(`/search?${params.toString()}`) - } + }, [debouncedQuery, version, fetcher]) return { results, diff --git a/app/components/icon-link.tsx b/app/components/icon-link.tsx new file mode 100644 index 0000000..280ee9e --- /dev/null +++ b/app/components/icon-link.tsx @@ -0,0 +1,28 @@ +import type { ComponentProps } from "react" +import { Icon } from "~/ui/icon/icon" +import type { IconName } from "~/ui/icon/icons/types" +import { cn } from "~/utils/css" + +interface IconLinkProps extends ComponentProps<"a"> { + name: IconName +} + +export const IconLink = ({ name, className, ...props }: IconLinkProps) => { + const { href } = props + const isExternal = typeof href === "string" && /^https?:\/\//i.test(href) + return ( + + + + ) +} diff --git a/app/components/logo.tsx b/app/components/logo.tsx index 7a6644a..78cf6c2 100644 --- a/app/components/logo.tsx +++ b/app/components/logo.tsx @@ -2,7 +2,7 @@ import type { ReactNode } from "react" export const Logo = ({ children }: { children: ReactNode }) => { return ( -
+
{children}
) diff --git a/app/components/page-mdx-article.tsx b/app/components/page-mdx-article.tsx index 89f8de2..7419032 100644 --- a/app/components/page-mdx-article.tsx +++ b/app/components/page-mdx-article.tsx @@ -1,18 +1,18 @@ -import type { Page } from "~/routes/documentation-page" +import type { Page } from "content-collections" import { Title } from "~/ui/title" import { MDXWrapper } from "./mdx-wrapper" export default function PageMdxArticle({ page }: { page: Page }) { return ( -
+
{page.title} {page.description && ( - + <p className="my-6 font-normal text-[var(--color-text-muted)] text-base sm:text-lg md:text-xl"> {page.description} - +

)}
diff --git a/app/components/sidebar/build-breadcrumbs.ts b/app/components/sidebar/build-breadcrumbs.ts new file mode 100644 index 0000000..8e23ede --- /dev/null +++ b/app/components/sidebar/build-breadcrumbs.ts @@ -0,0 +1,41 @@ +import type { Page } from "content-collections" +import type { SidebarSection } from "~/utils/create-sidebar-tree" +import { buildDocPathFromSlug } from "~/utils/path-builders" + +export const buildBreadcrumbs = ( + items: SidebarSection[], + pathname: string, + documentationPages: Pick[] = [] +) => { + // for standalone pages: /:filename + for (const page of documentationPages) { + const docPath = buildDocPathFromSlug(page.slug) + if (docPath === pathname) { + return [page.title] + } + } + + // for sectioned pages: /:section/:subsection?/:filename + let trail: string[] = [] + + const walk = (section: SidebarSection, acc: string[]): boolean => { + for (const doc of section.documentationPages) { + const docPath = buildDocPathFromSlug(doc.slug) + if (docPath === pathname) { + trail = [...acc, section.title, doc.title] + return true + } + } + + for (const sub of section.subsections) { + if (walk(sub, [...acc, section.title])) return true + } + return false + } + + for (const root of items) { + if (walk(root, [])) break + } + + return trail +} diff --git a/app/components/sidebar/build-breadcrumbs.tsx b/app/components/sidebar/build-breadcrumbs.tsx deleted file mode 100644 index 5efc4c0..0000000 --- a/app/components/sidebar/build-breadcrumbs.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { href } from "react-router" -import { splitSlugAndAppendVersion } from "~/utils/split-slug-and-append-version" -import type { SidebarSection } from "./sidebar" - -// builds a breadcrumb trail from sidebar sections based on the current pathname -export const buildBreadcrumb = (items: SidebarSection[], pathname: string) => { - let trail: string[] = [] - - const walk = (section: SidebarSection, acc: string[]) => { - for (const doc of section.documentationPages) { - const docPath = href("/:version/:section/:subsection?/:filename", splitSlugAndAppendVersion(doc.slug)) - if (docPath === pathname) { - trail = [...acc, section.title, doc.title] - return true - } - } - - for (const sub of section.subsections) { - if (walk(sub, [...acc, section.title])) return true - } - return false - } - - for (const root of items) { - if (walk(root, [])) break - } - - return trail -} diff --git a/app/components/sidebar/desktop-sidebar.tsx b/app/components/sidebar/desktop-sidebar.tsx index 49bd847..eade5e9 100644 --- a/app/components/sidebar/desktop-sidebar.tsx +++ b/app/components/sidebar/desktop-sidebar.tsx @@ -1,14 +1,14 @@ +import type { SidebarTree } from "~/utils/create-sidebar-tree" import { cn } from "~/utils/css" -import type { SidebarSection } from "./sidebar" import { SidebarContent } from "./sidebar-content" -export const DesktopSidebarPanel = ({ items, className }: { items: SidebarSection[]; className: string }) => ( +export const DesktopSidebarPanel = ({ sidebarTree, className }: { sidebarTree: SidebarTree; className: string }) => (
- +
) diff --git a/app/components/sidebar/mobile-sidebar.tsx b/app/components/sidebar/mobile-sidebar.tsx index f0c06f7..f08ea8b 100644 --- a/app/components/sidebar/mobile-sidebar.tsx +++ b/app/components/sidebar/mobile-sidebar.tsx @@ -1,26 +1,35 @@ +import { useParams } from "react-router" +import { useDocumentationLayoutLoaderData } from "~/hooks/use-documentation-layout-loader-data" import { BreadcrumbItem, Breadcrumbs } from "~/ui/breadcrumbs" +import { IconButton } from "~/ui/icon-button" import { Icon } from "~/ui/icon/icon" +import type { SidebarTree } from "~/utils/create-sidebar-tree" import { cn } from "~/utils/css" +import { buildBreadcrumbs } from "./build-breadcrumbs" import { useMobileSidebar } from "./mobile-sidebar-context" -import type { SidebarSection } from "./sidebar" import { SidebarContent } from "./sidebar-content" const MobileSidebarMenuButton = () => { const { open } = useMobileSidebar() return ( - + className="text-[var(--color-text-normal)] transition-colors duration-200 hover:text-[var(--color-text-hover)]" + aria-label="Navigation menu" + /> ) } -export const MobileSidebarHeader = ({ breadcrumbs }: { breadcrumbs: string[] }) => { +export const MobileSidebarHeader = () => { + const params = useParams() + const { + sidebarTree: { sections, documentationPages }, + } = useDocumentationLayoutLoaderData() + const { section, subsection, filename } = params + const currentPath = `/${[section, subsection, filename].filter(Boolean).join("/")}` + const breadcrumbs = buildBreadcrumbs(sections, currentPath, documentationPages) return (
@@ -64,10 +73,10 @@ const MobileSidebarCloseButton = () => { } export const MobileSidebarPanel = ({ - items, + sidebarTree, className, }: { - items: SidebarSection[] + sidebarTree: SidebarTree className: string }) => { const { close, isOpen } = useMobileSidebar() @@ -78,10 +87,9 @@ export const MobileSidebarPanel = ({ isOpen ? "translate-x-0" : "-translate-x-full", className )} - aria-modal="true" aria-label="Navigation menu" > - +
) diff --git a/app/components/sidebar/sidebar-content.tsx b/app/components/sidebar/sidebar-content.tsx index 3b45c4f..6372021 100644 --- a/app/components/sidebar/sidebar-content.tsx +++ b/app/components/sidebar/sidebar-content.tsx @@ -1,19 +1,42 @@ import { useMobileView } from "~/hooks/use-mobile-view" import { Accordion } from "~/ui/accordion" -import type { SidebarSection } from "./sidebar" -import { SectionItem } from "./sidebar-section" +import type { SidebarTree } from "~/utils/create-sidebar-tree" +import { buildStandaloneTo } from "~/utils/path-builders" +import { useCurrentVersion } from "~/utils/version-resolvers" +import { DocumentationNavLink, SectionItem } from "./sidebar-items" -export const SidebarContent = ({ items, onClose }: { items: SidebarSection[]; onClose?: () => void }) => { +export const SidebarContent = ({ + sidebarTree, + onClose, +}: { + sidebarTree: SidebarTree + onClose?: () => void +}) => { const { isMobile } = useMobileView() - + const handle = isMobile ? onClose : undefined + const { sections, documentationPages } = sidebarTree + const version = useCurrentVersion() return ( diff --git a/app/components/sidebar/sidebar-section.tsx b/app/components/sidebar/sidebar-items.tsx similarity index 56% rename from app/components/sidebar/sidebar-section.tsx rename to app/components/sidebar/sidebar-items.tsx index c5fb493..392c328 100644 --- a/app/components/sidebar/sidebar-section.tsx +++ b/app/components/sidebar/sidebar-items.tsx @@ -1,6 +1,7 @@ -import { NavLink, href } from "react-router" +import { NavLink } from "react-router" import { AccordionItem } from "~/ui/accordion" -import { splitSlugAndAppendVersion } from "~/utils/split-slug-and-append-version" +import { buildSectionedTo } from "~/utils/path-builders" +import { useCurrentVersion } from "~/utils/version-resolvers" import type { SidebarSection } from "./sidebar" const getIndentClass = (depth: number) => { @@ -8,58 +9,64 @@ const getIndentClass = (depth: number) => { return indentMap[depth as keyof typeof indentMap] || "ml-8" } -interface SectionItemProps { - item: SidebarSection +type DocumentationNavLinkProps = { + title: string + to: string depth?: number - onItemClick?: () => void -} - -interface SectionItemLinkProps { - documentPage: { slug: string; title: string } - depth: number - onItemClick?: () => void + onClick?: () => void } -const SectionTitle = ({ title }: { title: string }) => { - return ( -

- {title} -

- ) -} - -const SectionItemLink = ({ documentPage, depth, onItemClick }: SectionItemLinkProps) => { +export function DocumentationNavLink({ title, to, depth = 0, onClick }: DocumentationNavLinkProps) { const indentClass = getIndentClass(depth) return ( - `block rounded-md px-3 py-2 text-xs sm:text-sm md:text-base ${indentClass} - ${isPending ? "text-[var(--color-text-hover)]" : ""} - ${ - isActive - ? "bg-[var(--color-background-active)] font-medium text-[var(--color-text-active)]" - : "text-[var(--color-text-normal)] hover:text-[var(--color-text-hover)]" - } - ` + `block rounded-md px-3 py-2 text-sm md:text-base ${indentClass} + ${isPending ? "text-[var(--color-text-hover)]" : ""} + ${ + isActive + ? "bg-[var(--color-background-active)] font-medium text-[var(--color-text-active)]" + : "text-[var(--color-text-normal)] hover:text-[var(--color-text-hover)]" + }` } > - {documentPage.title} + {title} ) } +interface SectionItemProps { + item: SidebarSection + depth?: number + onItemClick?: () => void +} + +const SectionTitle = ({ title }: { title: string }) => { + return ( +

+ {title} +

+ ) +} + export const SectionItem = ({ item, depth = 0, onItemClick }: SectionItemProps) => { const isTopLevel = depth === 0 - + const version = useCurrentVersion() const content = (
{item.documentationPages.length > 0 && (
{item.documentationPages.map((doc) => ( - + ))}
)} @@ -78,8 +85,8 @@ export const SectionItem = ({ item, depth = 0, onItemClick }: SectionItemProps) return ( diff --git a/app/components/sidebar/sidebar.tsx b/app/components/sidebar/sidebar.tsx index 42a7a43..b5a2bf8 100644 --- a/app/components/sidebar/sidebar.tsx +++ b/app/components/sidebar/sidebar.tsx @@ -1,6 +1,5 @@ -import { useLocation } from "react-router" +import type { SidebarTree } from "~/utils/create-sidebar-tree" import { cn } from "~/utils/css" -import { buildBreadcrumb } from "./build-breadcrumbs" import { DesktopSidebarPanel } from "./desktop-sidebar" import { MobileSidebarHeader, MobileSidebarOverlay, MobileSidebarPanel } from "./mobile-sidebar" import { MobileSidebarProvider } from "./mobile-sidebar-context" @@ -9,29 +8,23 @@ export type SidebarSection = { title: string slug: string subsections: SidebarSection[] - documentationPages: { - title: string - slug: string - }[] + documentationPages: { title: string; slug: string }[] } + interface SidebarProps { - items: SidebarSection[] + sidebarTree: SidebarTree className?: string } -export const Sidebar = ({ items, className = "" }: SidebarProps) => { - const location = useLocation() - const breadcrumbs = buildBreadcrumb(items, location.pathname) - +export const Sidebar = ({ sidebarTree, className = "" }: SidebarProps) => { return ( <> - - +
- + - +
diff --git a/app/components/sidebar/tests/build-breadcrumbs.test.ts b/app/components/sidebar/tests/build-breadcrumbs.test.ts new file mode 100644 index 0000000..6920ede --- /dev/null +++ b/app/components/sidebar/tests/build-breadcrumbs.test.ts @@ -0,0 +1,105 @@ +import type { Page } from "content-collections" +import type { SidebarSection } from "~/utils/create-sidebar-tree" +import { buildBreadcrumbs } from "../build-breadcrumbs" + +const makePage = (slug: string, title: string, section = slug.split("/")[0] ?? ""): Page => ({ + content: "", + slug, + section, + rawMdx: "", + title, + summary: "", + description: "", + _meta: { + filePath: "", + fileName: "", + directory: "", + path: "", + extension: ".mdx", + }, +}) + +const makeSection = (overrides: Partial = {}): SidebarSection => ({ + title: "", + slug: "", + documentationPages: [], + subsections: [], + ...overrides, +}) + +const makeStandalone = (slug: string, title: string): Pick => ({ + slug, + title, +}) + +describe("buildBreadcrumbs", () => { + it("returns [] when pathname doesn't match any doc", () => { + const items: SidebarSection[] = [ + makeSection({ + title: "Getting Started", + slug: "getting-started", + documentationPages: [makePage("getting-started/intro", "Intro")], + }), + ] + + expect(buildBreadcrumbs(items, "/getting-started/unknown")).toEqual([]) + }) + + it("returns [section, doc] for a top-level doc within a section", () => { + const items: SidebarSection[] = [ + makeSection({ + title: "Getting Started", + slug: "getting-started", + documentationPages: [makePage("getting-started/intro", "Intro")], + }), + ] + + expect(buildBreadcrumbs(items, "/getting-started/intro")).toEqual(["Getting Started", "Intro"]) + }) + + it("returns full trail for a nested doc (root โ†’ sub โ†’ doc)", () => { + const items: SidebarSection[] = [ + makeSection({ + title: "Configuration", + slug: "configuration", + subsections: [ + makeSection({ + title: "Advanced", + slug: "configuration/advanced", + documentationPages: [makePage("configuration/advanced/tuning", "Tuning")], + }), + ], + documentationPages: [makePage("configuration/setup", "Setup")], + }), + ] + + expect(buildBreadcrumbs(items, "/configuration/advanced/tuning")).toEqual(["Configuration", "Advanced", "Tuning"]) + }) + + it("returns [] for an empty sidebar", () => { + const items: SidebarSection[] = [] + expect(buildBreadcrumbs(items, "/any-path")).toEqual([]) + }) + + it("returns [doc] for a standalone top-level doc", () => { + const items: SidebarSection[] = [] + const standalone = [makeStandalone("changelog", "Changelog")] + + expect(buildBreadcrumbs(items, "/changelog", standalone)).toEqual(["Changelog"]) + }) + + it("matches sectioned path when pathname is sectioned, and standalone when pathname is standalone", () => { + const items: SidebarSection[] = [ + makeSection({ + title: "Guides", + slug: "guides", + documentationPages: [makePage("guides/quickstart", "Quickstart")], + }), + ] + const standalone = [makeStandalone("quickstart", "Quickstart")] + + expect(buildBreadcrumbs(items, "/guides/quickstart", standalone)).toEqual(["Guides", "Quickstart"]) + + expect(buildBreadcrumbs(items, "/quickstart", standalone)).toEqual(["Quickstart"]) + }) +}) diff --git a/app/components/sidebar/tests/build-breadcrumbs.test.tsx b/app/components/sidebar/tests/build-breadcrumbs.test.tsx deleted file mode 100644 index 723b4e6..0000000 --- a/app/components/sidebar/tests/build-breadcrumbs.test.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { describe, expect, it, vi } from "vitest" -import type { SidebarSection } from "../sidebar" - -vi.mock("~/utils/split-slug-and-append-version", () => ({ - splitSlugAndAppendVersion: (slug: string) => { - const parts = slug.split("/").filter(Boolean) - const version = "v1.0.0" - - if (parts.length === 2) { - const [section, filename] = parts - return { version, section, filename } - } - if (parts.length === 3) { - const [section, subsection, filename] = parts - return { version, section, subsection, filename } - } - - throw new Error(`Bad slug in test: ${slug}`) - }, -})) - -import { buildBreadcrumb } from "../build-breadcrumbs" - -type Doc = { slug: string; title: string } -const makeDoc = (slug: string, title: string): Doc => ({ slug, title }) - -type MinimalSection = Pick -const makeSection = (overrides: Partial = {}) => ({ - title: "", - slug: "", - documentationPages: [], - subsections: [], - ...overrides, -}) - -describe("buildBreadcrumb (versioned paths via splitSlugAndAppendVersion)", () => { - it("returns [] when pathname doesn't match any doc", () => { - const items = [ - makeSection({ - title: "Getting Started", - slug: "getting-started", - documentationPages: [makeDoc("getting-started/intro", "Intro")], - }), - ] - expect(buildBreadcrumb(items, "/v1.0.0/getting-started/unknown")).toEqual([]) - }) - - it("returns [section, doc] for a top-level doc", () => { - const items = [ - makeSection({ - title: "Getting Started", - slug: "getting-started", - documentationPages: [makeDoc("getting-started/intro", "Intro")], - }), - ] - expect(buildBreadcrumb(items, "/v1.0.0/getting-started/intro")).toEqual(["Getting Started", "Intro"]) - }) - - it("returns full trail for a nested doc (root โ†’ sub โ†’ doc)", () => { - const items = [ - makeSection({ - title: "Configuration", - slug: "configuration", - subsections: [ - makeSection({ - title: "Advanced", - slug: "configuration/advanced", - documentationPages: [makeDoc("configuration/advanced/tuning", "Tuning")], - }), - ], - documentationPages: [makeDoc("configuration/setup", "Setup")], - }), - ] - expect(buildBreadcrumb(items, "/v1.0.0/configuration/advanced/tuning")).toEqual([ - "Configuration", - "Advanced", - "Tuning", - ]) - }) -}) diff --git a/app/components/theme-toggle.tsx b/app/components/theme-toggle.tsx index 32e4a45..0b0ea47 100644 --- a/app/components/theme-toggle.tsx +++ b/app/components/theme-toggle.tsx @@ -22,12 +22,10 @@ export function ThemeToggle() { const isDarkTheme = theme === "dark" return ( -
- -
+ ) } diff --git a/app/components/versions-dropdown.tsx b/app/components/versions-dropdown.tsx index bf64dae..a6b0d1b 100644 --- a/app/components/versions-dropdown.tsx +++ b/app/components/versions-dropdown.tsx @@ -1,12 +1,12 @@ import { useState } from "react" import { useNavigate } from "react-router" import { Icon } from "~/ui/icon/icon" -import { getCurrentVersion, homepageUrlWithVersion, isKnownVersion } from "~/utils/version-resolvers" +import { homepageUrlWithVersion, isKnownVersion, useCurrentVersion } from "~/utils/version-resolvers" import { versions } from "~/utils/versions" export function VersionDropdown() { const navigate = useNavigate() - const { version: currentVersion } = getCurrentVersion() + const currentVersion = useCurrentVersion() const [selectedVersion, setSelectedVersion] = useState(currentVersion) function onChange(e: React.ChangeEvent) { @@ -29,7 +29,7 @@ export function VersionDropdown() {