@assistant-ui/docs-theme 0.0.0-stage → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +140 -2
  3. package/dist/action-icons.d.ts +5 -0
  4. package/dist/action-icons.d.ts.map +1 -0
  5. package/dist/action-icons.js +52 -0
  6. package/dist/action-icons.js.map +1 -0
  7. package/dist/brand.d.ts +7 -0
  8. package/dist/brand.d.ts.map +1 -0
  9. package/dist/brand.js +37 -0
  10. package/dist/brand.js.map +1 -0
  11. package/dist/config.d.ts +3 -0
  12. package/dist/config.d.ts.map +1 -0
  13. package/dist/config.js +22 -0
  14. package/dist/config.js.map +1 -0
  15. package/dist/header.d.ts +12 -0
  16. package/dist/header.d.ts.map +1 -0
  17. package/dist/header.js +73 -0
  18. package/dist/header.js.map +1 -0
  19. package/dist/index.d.ts +8 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +48 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/navigation.d.ts +23 -0
  24. package/dist/navigation.d.ts.map +1 -0
  25. package/dist/navigation.js +41 -0
  26. package/dist/navigation.js.map +1 -0
  27. package/dist/next.d.ts +29 -0
  28. package/dist/next.d.ts.map +1 -0
  29. package/dist/next.js +63 -0
  30. package/dist/next.js.map +1 -0
  31. package/dist/page-content.d.ts +12 -0
  32. package/dist/page-content.d.ts.map +1 -0
  33. package/dist/page-content.js +274 -0
  34. package/dist/page-content.js.map +1 -0
  35. package/dist/provider.d.ts +2 -0
  36. package/dist/provider.d.ts.map +1 -0
  37. package/dist/provider.js +1 -0
  38. package/package.json +88 -3
  39. package/src/action-icons.tsx +48 -0
  40. package/src/brand.tsx +36 -0
  41. package/src/config.ts +23 -0
  42. package/src/header.test.tsx +41 -0
  43. package/src/header.tsx +94 -0
  44. package/src/index.test.ts +37 -0
  45. package/src/index.ts +38 -0
  46. package/src/layout-hosts.test.tsx +98 -0
  47. package/src/navigation.test.ts +123 -0
  48. package/src/navigation.ts +61 -0
  49. package/src/next.test.tsx +110 -0
  50. package/src/next.tsx +101 -0
  51. package/src/page-content.test.tsx +87 -0
  52. package/src/page-content.tsx +416 -0
  53. package/src/provider.ts +1 -0
  54. package/styles/theme.css +858 -0
@@ -0,0 +1,41 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { renderToStaticMarkup } from "react-dom/server";
4
+ import { SearchProvider } from "fumadocs-ui/contexts/search";
5
+ import { describe, expect, it } from "vitest";
6
+ import { DocsHeader } from "./header";
7
+
8
+ describe("documentation header", () => {
9
+ it.each(["Ctrl", "⌘"])(
10
+ "displays the configured %s search shortcut",
11
+ (modifier) => {
12
+ const html = renderToStaticMarkup(
13
+ <SearchProvider
14
+ preload={false}
15
+ hotKey={[
16
+ { key: "Control", display: modifier },
17
+ { key: "k", display: "K" },
18
+ ]}
19
+ >
20
+ <DocsHeader
21
+ brand="Project"
22
+ brandSlot={null}
23
+ homeUrl="/"
24
+ docsUrl="/docs"
25
+ name="Project"
26
+ links={[]}
27
+ themeToggle={false}
28
+ />
29
+ </SearchProvider>,
30
+ );
31
+ const container = document.createElement("div");
32
+ container.innerHTML = html;
33
+ expect(container.querySelector("kbd")?.textContent).toBe(`${modifier} K`);
34
+ expect(
35
+ container
36
+ .querySelector('a[href="/docs"]')
37
+ ?.getAttribute("aria-current"),
38
+ ).toBe("true");
39
+ },
40
+ );
41
+ });
package/src/header.tsx ADDED
@@ -0,0 +1,94 @@
1
+ "use client";
2
+
3
+ import type { ReactNode } from "react";
4
+ import Link from "next/link";
5
+ import { Moon, Search, Sun } from "lucide-react";
6
+ import { useTheme } from "next-themes";
7
+ import { useSearchContext } from "fumadocs-ui/contexts/search";
8
+ import type { createDocsLayout } from "./next";
9
+
10
+ export const DocsHeader = ({
11
+ brand,
12
+ brandSlot,
13
+ homeUrl,
14
+ docsUrl,
15
+ name,
16
+ links,
17
+ themeToggle,
18
+ }: {
19
+ brand: ReactNode;
20
+ brandSlot: ReactNode;
21
+ homeUrl: string;
22
+ docsUrl: string;
23
+ name: string;
24
+ links: createDocsLayout.Options["links"];
25
+ themeToggle: boolean;
26
+ }) => {
27
+ const { resolvedTheme, setTheme } = useTheme();
28
+ const { setOpenSearch, hotKey } = useSearchContext();
29
+ return (
30
+ <header className="aui-docs-header">
31
+ {brandSlot ? (
32
+ <div className="aui-docs-header-brand">{brandSlot}</div>
33
+ ) : (
34
+ <Link
35
+ href={homeUrl}
36
+ className="aui-docs-header-brand"
37
+ aria-label={`${name} home`}
38
+ >
39
+ {brand}
40
+ </Link>
41
+ )}
42
+ <div className="aui-docs-header-controls">
43
+ <button
44
+ type="button"
45
+ className="aui-docs-header-search"
46
+ aria-label="Search documentation"
47
+ onClick={() => setOpenSearch(true)}
48
+ >
49
+ <Search aria-hidden="true" />
50
+ <span>Search...</span>
51
+ {hotKey.length > 0 && (
52
+ <kbd aria-hidden="true">
53
+ {hotKey.map((key, index) => (
54
+ <span key={index}>
55
+ {index > 0 && " "}
56
+ {key.display}
57
+ </span>
58
+ ))}
59
+ </kbd>
60
+ )}
61
+ </button>
62
+ <nav className="aui-docs-header-links" aria-label="Site navigation">
63
+ <Link href={docsUrl} aria-current="true">
64
+ Docs
65
+ </Link>
66
+ {links?.map((link) => (
67
+ <a
68
+ key={link.href}
69
+ href={link.href}
70
+ {...(link.external
71
+ ? { target: "_blank", rel: "noreferrer noopener" }
72
+ : {})}
73
+ >
74
+ {link.label}
75
+ </a>
76
+ ))}
77
+ </nav>
78
+ {themeToggle && (
79
+ <button
80
+ type="button"
81
+ className="aui-docs-theme-toggle"
82
+ aria-label="Toggle color theme"
83
+ onClick={() =>
84
+ setTheme(resolvedTheme === "dark" ? "light" : "dark")
85
+ }
86
+ >
87
+ <Sun className="aui-docs-theme-sun" aria-hidden="true" />
88
+ <Moon className="aui-docs-theme-moon" aria-hidden="true" />
89
+ </button>
90
+ )}
91
+ </div>
92
+ </header>
93
+ );
94
+ };
@@ -0,0 +1,37 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { assistantUi } from "./index";
3
+ import { withThemeDefaults } from "./config";
4
+
5
+ describe("assistant-ui theme configuration", () => {
6
+ it("provides the complete theme and a sidebar without a collapse control", () => {
7
+ const config = withThemeDefaults({ entry: "guide" });
8
+ expect(config.entry).toBe("guide");
9
+ expect(config.theme?.name).toBe("assistant-ui");
10
+ expect(config.sidebar).toMatchObject({ collapsible: false, flat: true });
11
+ expect(config.pageActions).toEqual({
12
+ copyMarkdown: false,
13
+ openDocs: false,
14
+ });
15
+ });
16
+
17
+ it("merges theme overrides without losing unrelated defaults", () => {
18
+ const theme = assistantUi({ ui: { layout: { contentWidth: 960 } } });
19
+ expect(theme.ui?.layout?.contentWidth).toBe(960);
20
+ expect(theme.ui?.layout?.sidebarWidth).toBe(260);
21
+ expect(assistantUi().ui?.layout?.contentWidth).toBe(848);
22
+ });
23
+
24
+ it("preserves consumer configuration and explicit disabled features", () => {
25
+ const config = withThemeDefaults({
26
+ entry: "guide",
27
+ sidebar: false,
28
+ lastUpdated: true,
29
+ github: "https://github.com/example/project",
30
+ nav: { title: "Example", url: "/" },
31
+ });
32
+ expect(config.sidebar).toBe(false);
33
+ expect(config.lastUpdated).toBe(true);
34
+ expect(config.github).toBe("https://github.com/example/project");
35
+ expect(config.nav?.title).toBe("Example");
36
+ });
37
+ });
package/src/index.ts ADDED
@@ -0,0 +1,38 @@
1
+ import { createTheme, type DocsTheme } from "@farming-labs/docs";
2
+ import { shadcn } from "@farming-labs/theme/shadcn";
3
+
4
+ const createAssistantTheme = createTheme({
5
+ ...shadcn({
6
+ ui: {
7
+ layout: {
8
+ contentWidth: 848,
9
+ sidebarWidth: 260,
10
+ tocWidth: 224,
11
+ header: { height: 48 },
12
+ },
13
+ typography: {
14
+ font: {
15
+ style: {
16
+ sans: "var(--font-public-sans, ui-sans-serif), system-ui, sans-serif",
17
+ mono: "var(--font-jetbrains-mono, ui-monospace), monospace",
18
+ },
19
+ h1: { size: "1.5rem", weight: 500, letterSpacing: "-0.025em" },
20
+ h2: { size: "1.25rem", weight: 500, letterSpacing: "-0.02em" },
21
+ h3: { size: "1rem", weight: 500, letterSpacing: "-0.01em" },
22
+ body: { size: "0.875rem", lineHeight: "1.7" },
23
+ },
24
+ },
25
+ codeBlock: { showCopyButton: true, showLineNumbers: false },
26
+ },
27
+ }),
28
+ name: "assistant-ui",
29
+ });
30
+
31
+ export const assistantUi = (overrides: assistantUi.Options = {}): DocsTheme =>
32
+ createAssistantTheme(overrides);
33
+
34
+ export namespace assistantUi {
35
+ export interface Options extends Partial<DocsTheme> {}
36
+ }
37
+
38
+ export { DocsBrand } from "./brand";
@@ -0,0 +1,98 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { act } from "react";
7
+ import { createRoot } from "react-dom/client";
8
+ import { renderToStaticMarkup } from "react-dom/server";
9
+ import { RootProvider } from "@farming-labs/theme";
10
+ import { afterAll, afterEach, beforeEach, expect, it, vi } from "vitest";
11
+ import { createDocsLayout } from "./next";
12
+
13
+ vi.mock("next/navigation", async (importOriginal) => ({
14
+ ...(await importOriginal<typeof import("next/navigation")>()),
15
+ usePathname: () => "/docs",
16
+ useSearchParams: () => new URLSearchParams(),
17
+ useParams: () => ({}),
18
+ useRouter: () => ({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn() }),
19
+ }));
20
+
21
+ const contentDir = mkdtempSync(join(tmpdir(), "assistant-ui-docs-hosts-"));
22
+ writeFileSync(
23
+ join(contentDir, "page.mdx"),
24
+ "---\ntitle: Welcome\ndescription: Start here\n---\n\n## Setup\n",
25
+ );
26
+ afterAll(() => rmSync(contentDir, { recursive: true }));
27
+
28
+ beforeEach(() => {
29
+ window.history.replaceState(null, "", "/docs");
30
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
31
+ vi.stubGlobal("matchMedia", () => ({
32
+ matches: false,
33
+ addListener: vi.fn(),
34
+ removeListener: vi.fn(),
35
+ addEventListener: vi.fn(),
36
+ removeEventListener: vi.fn(),
37
+ }));
38
+ const observer = vi.fn(function () {
39
+ return { observe: vi.fn(), unobserve: vi.fn(), disconnect: vi.fn() };
40
+ });
41
+ vi.stubGlobal("IntersectionObserver", observer);
42
+ vi.stubGlobal("ResizeObserver", observer);
43
+ });
44
+
45
+ it("renders the complete layout with shared framework contexts", () => {
46
+ const Layout = createDocsLayout(
47
+ { entry: "docs", contentDir, themeToggle: false },
48
+ { name: "Project" },
49
+ );
50
+ const html = renderToStaticMarkup(
51
+ <RootProvider theme={{ enabled: false }}>
52
+ <Layout>
53
+ <h2 id="setup">Setup</h2>
54
+ </Layout>
55
+ </RootProvider>,
56
+ );
57
+ expect(html).toContain('id="nd-page"');
58
+ expect(html).toContain("fd-generated-page-header");
59
+ expect(html).toContain('id="nd-toc-placeholder"');
60
+ expect(html).toContain("Welcome");
61
+ });
62
+
63
+ afterEach(() => {
64
+ document.body.replaceChildren();
65
+ vi.unstubAllGlobals();
66
+ });
67
+
68
+ it("renders the populated TOC host and attaches the theme actions", async () => {
69
+ const Layout = createDocsLayout(
70
+ { entry: "docs", contentDir, themeToggle: false },
71
+ { name: "Project" },
72
+ );
73
+ const container = document.createElement("div");
74
+ document.body.append(container);
75
+ const root = createRoot(container);
76
+ try {
77
+ await act(async () => {
78
+ root.render(
79
+ <RootProvider theme={{ enabled: false }}>
80
+ <Layout>
81
+ <h2 id="setup">Setup</h2>
82
+ </Layout>
83
+ </RootProvider>,
84
+ );
85
+ });
86
+ const toc = container.querySelector("#nd-toc");
87
+ expect(toc).not.toBeNull();
88
+ expect(toc?.querySelector('a[href="#setup"]')?.textContent).toBe("Setup");
89
+ expect(toc?.querySelector('a[href="/docs.md"]')).not.toBeNull();
90
+ expect(
91
+ container.querySelector(
92
+ '.fd-generated-page-header [aria-label="Page controls"]',
93
+ ),
94
+ ).not.toBeNull();
95
+ } finally {
96
+ await act(async () => root.unmount());
97
+ }
98
+ });
@@ -0,0 +1,123 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { getEditUrl, getPageNeighbors } from "./navigation";
3
+
4
+ const tree: Parameters<typeof getPageNeighbors>[0] = [
5
+ { type: "page", name: "Overview", url: "/guide" },
6
+ { type: "separator", name: "Learn" },
7
+ {
8
+ type: "folder",
9
+ name: "API",
10
+ index: { type: "page", name: "Introduction", url: "/guide/api" },
11
+ children: [
12
+ { type: "page", name: "Client", url: "/guide/api/client" },
13
+ {
14
+ type: "folder",
15
+ name: "Advanced",
16
+ children: [
17
+ { type: "page", name: "Streaming", url: "/guide/api/streaming" },
18
+ ],
19
+ },
20
+ ],
21
+ },
22
+ ];
23
+
24
+ describe("documentation navigation", () => {
25
+ it("follows the tree order across folder indexes and sections", () => {
26
+ const { current, previous, next } = getPageNeighbors(tree, "/guide/api");
27
+ expect(current?.name).toBe("Introduction");
28
+ expect(previous?.url).toBe("/guide");
29
+ expect(next).toEqual({
30
+ name: "Client",
31
+ url: "/guide/api/client",
32
+ section: "API",
33
+ });
34
+ });
35
+
36
+ it("keeps first and last pages at the boundaries", () => {
37
+ expect(getPageNeighbors(tree, "/guide").previous).toBeUndefined();
38
+ const last = getPageNeighbors(tree, "/guide/api/streaming");
39
+ expect(last.next).toBeUndefined();
40
+ expect(last.current?.section).toBe("Advanced");
41
+ expect(last.previous?.name).toBe("Client");
42
+ });
43
+
44
+ it("accepts trailing slashes without changing the navigation target", () => {
45
+ expect(getPageNeighbors(tree, "/guide/api/").current?.url).toBe(
46
+ "/guide/api",
47
+ );
48
+ });
49
+
50
+ it("matches localized routes while preserving the locale in navigation links", () => {
51
+ const { current, next } = getPageNeighbors(
52
+ [
53
+ { type: "page", name: "Accueil", url: "/guide?lang=fr" },
54
+ { type: "page", name: "Client", url: "/guide/client?lang=fr" },
55
+ ],
56
+ "/guide",
57
+ );
58
+ expect(current?.name).toBe("Accueil");
59
+ expect(next?.url).toBe("/guide/client?lang=fr");
60
+ });
61
+
62
+ it("does not invent neighbors for an unknown route or empty tree", () => {
63
+ for (const result of [
64
+ getPageNeighbors(tree, "/missing"),
65
+ getPageNeighbors([], "/guide"),
66
+ ]) {
67
+ expect(result).toEqual({
68
+ current: undefined,
69
+ previous: undefined,
70
+ next: undefined,
71
+ });
72
+ }
73
+ });
74
+ });
75
+
76
+ describe("repository edit links", () => {
77
+ it("uses each consumer's repository, branch, directory, and route", () => {
78
+ expect(
79
+ getEditUrl("/guide/api/", {
80
+ url: "https://github.com/example/project/",
81
+ branch: "docs",
82
+ directory: "/website/",
83
+ }),
84
+ ).toBe(
85
+ "https://github.com/example/project/edit/docs/website/app/guide/api/page.mdx",
86
+ );
87
+ });
88
+
89
+ it("supports a repository URL without extra configuration", () => {
90
+ expect(getEditUrl("/docs", "https://github.com/example/project")).toBe(
91
+ "https://github.com/example/project/edit/main/app/docs/page.mdx",
92
+ );
93
+ });
94
+
95
+ it.each(["", "/", "////"])(
96
+ "treats directory %j as the repository root",
97
+ (directory) => {
98
+ expect(
99
+ getEditUrl("/docs", {
100
+ url: "https://github.com/example/project",
101
+ directory,
102
+ }),
103
+ ).toBe("https://github.com/example/project/edit/main/app/docs/page.mdx");
104
+ },
105
+ );
106
+
107
+ it("trims long boundary slash runs while preserving interior slashes", () => {
108
+ const slashes = "/".repeat(100_000);
109
+ const directory = `website${slashes}content`;
110
+ expect(
111
+ getEditUrl("/docs", {
112
+ url: "https://github.com/example/project",
113
+ directory: `${slashes}${directory}${slashes}`,
114
+ }),
115
+ ).toBe(
116
+ `https://github.com/example/project/edit/main/${directory}/app/docs/page.mdx`,
117
+ );
118
+ });
119
+
120
+ it("omits editing when no repository is configured", () => {
121
+ expect(getEditUrl("/docs")).toBeUndefined();
122
+ });
123
+ });
@@ -0,0 +1,61 @@
1
+ import type { ReactNode } from "react";
2
+ import type { useTreeContext } from "fumadocs-ui/contexts/tree";
3
+
4
+ export const getPageNeighbors = (
5
+ nodes: ReturnType<typeof useTreeContext>["root"]["children"],
6
+ pathname: string,
7
+ ) => {
8
+ const pages: getPageNeighbors.Link[] = [];
9
+ const visit = (items: typeof nodes, section?: ReactNode) => {
10
+ for (const node of items) {
11
+ if (node.type === "page")
12
+ pages.push({ name: node.name, url: node.url, section });
13
+ if (node.type === "folder") {
14
+ if (node.index)
15
+ pages.push({ name: node.index.name, url: node.index.url });
16
+ visit(node.children, node.name);
17
+ }
18
+ }
19
+ };
20
+ visit(nodes);
21
+ const index = pages.findIndex(
22
+ (page) =>
23
+ page.url.split(/[?#]/, 1)[0]?.replace(/\/$/, "") ===
24
+ pathname.replace(/\/$/, ""),
25
+ );
26
+ return {
27
+ current: pages[index],
28
+ previous: index >= 0 ? pages[index - 1] : undefined,
29
+ next: index >= 0 ? pages[index + 1] : undefined,
30
+ };
31
+ };
32
+
33
+ export namespace getPageNeighbors {
34
+ export interface Link {
35
+ name: ReactNode;
36
+ url: string;
37
+ section?: ReactNode;
38
+ }
39
+ }
40
+
41
+ export const getEditUrl = (pathname: string, github?: getEditUrl.Options) => {
42
+ if (!github) return undefined;
43
+ const base = typeof github === "string" ? { url: github } : github;
44
+ const path = base.directory ?? "";
45
+ let start = 0;
46
+ let end = path.length;
47
+ while (path[start] === "/") start++;
48
+ while (end > start && path[end - 1] === "/") end--;
49
+ const directory = path.slice(start, end);
50
+ return `${base.url.replace(/\/$/, "")}/edit/${base.branch ?? "main"}/${directory ? `${directory}/` : ""}app${pathname.replace(/\/$/, "")}/page.mdx`;
51
+ };
52
+
53
+ export namespace getEditUrl {
54
+ export type Options =
55
+ | string
56
+ | {
57
+ url: string;
58
+ branch?: string;
59
+ directory?: string;
60
+ };
61
+ }
@@ -0,0 +1,110 @@
1
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { Children, isValidElement, type ReactNode } from "react";
5
+ import type { DocsLayoutProps } from "fumadocs-ui/layouts/docs";
6
+ import { DocsLayout as FrameworkLayout } from "@farming-labs/theme";
7
+ import { afterAll, describe, expect, it } from "vitest";
8
+ import { createDocsLayout } from "./next";
9
+ import { DocsHeader } from "./header";
10
+
11
+ const contentDir = mkdtempSync(join(tmpdir(), "assistant-ui-docs-"));
12
+ for (const [locale, title] of [
13
+ ["en", "Welcome"],
14
+ ["fr", "Bienvenue"],
15
+ ] as const) {
16
+ const directory = join(contentDir, locale);
17
+ mkdirSync(directory);
18
+ writeFileSync(join(directory, "page.mdx"), `---\ntitle: ${title}\n---\n`);
19
+ }
20
+ afterAll(() => rmSync(contentDir, { recursive: true }));
21
+
22
+ const getFrameworkLayout = (children: ReactNode) => {
23
+ const layout = Children.toArray(children).find(
24
+ (child) => isValidElement(child) && child.type === FrameworkLayout,
25
+ );
26
+ expect(isValidElement<DocsLayoutProps>(layout)).toBe(true);
27
+ if (!isValidElement<DocsLayoutProps>(layout))
28
+ throw new Error("Missing framework layout");
29
+ return layout;
30
+ };
31
+
32
+ describe("Farming Labs layout contract", () => {
33
+ it("keeps the synchronous layout root and merges consumer overrides", () => {
34
+ const banner = <div>Project language</div>;
35
+ const tree = { name: "Project", children: [] };
36
+ const Layout = createDocsLayout(
37
+ { entry: "docs", contentDir },
38
+ { name: "Project", layoutProps: { tree, sidebar: { banner } } },
39
+ );
40
+ const layout = getFrameworkLayout(
41
+ Layout({ children: <p>Content</p> }).props.children,
42
+ );
43
+ expect(layout.props.tree).toBe(tree);
44
+ expect(layout.props.sidebar).toMatchObject({ collapsible: false, banner });
45
+ expect(layout.props.children).toBeDefined();
46
+ });
47
+
48
+ it.each([undefined, "/docs"])(
49
+ "preserves the active locale in the default brand link (%s)",
50
+ (homeUrl) => {
51
+ const Layout = createDocsLayout(
52
+ {
53
+ entry: "docs",
54
+ ...(homeUrl ? { nav: { url: homeUrl } } : {}),
55
+ contentDir,
56
+ i18n: { locales: ["en", "fr"], defaultLocale: "en" },
57
+ },
58
+ { name: "Project", locale: "fr" },
59
+ );
60
+ const children = Layout({ children: null }).props.children;
61
+ const layout = getFrameworkLayout(children);
62
+ const header = Children.toArray(children).find(
63
+ (child) => isValidElement(child) && child.type === DocsHeader,
64
+ );
65
+ expect(
66
+ isValidElement<{ docsUrl: string }>(header) && header.props.docsUrl,
67
+ ).toBe("/docs?lang=fr");
68
+ expect(
69
+ isValidElement<{ homeUrl: string }>(header) && header.props.homeUrl,
70
+ ).toBe("/docs?lang=fr");
71
+ expect(layout.props.nav?.url).toBe("/docs?lang=fr");
72
+ expect(layout.props.tree.children).toContainEqual(
73
+ expect.objectContaining({ name: "Bienvenue", url: "/docs?lang=fr" }),
74
+ );
75
+ },
76
+ );
77
+
78
+ it.each([
79
+ { nav: "/", override: undefined, expected: "/" },
80
+ { nav: "/docs", override: "/", expected: "/" },
81
+ {
82
+ nav: "https://example.com",
83
+ override: undefined,
84
+ expected: "https://example.com",
85
+ },
86
+ ])(
87
+ "preserves an explicit home destination ($expected)",
88
+ ({ nav, override, expected }) => {
89
+ const Layout = createDocsLayout(
90
+ {
91
+ entry: "docs",
92
+ contentDir,
93
+ nav: { url: nav },
94
+ i18n: { locales: ["en", "fr"], defaultLocale: "en" },
95
+ },
96
+ {
97
+ name: "Project",
98
+ locale: "fr",
99
+ ...(override === undefined ? {} : { homeUrl: override }),
100
+ },
101
+ );
102
+ const header = Children.toArray(
103
+ Layout({ children: null }).props.children,
104
+ ).find((child) => isValidElement(child) && child.type === DocsHeader);
105
+ expect(
106
+ isValidElement<{ homeUrl: string }>(header) && header.props.homeUrl,
107
+ ).toBe(expected);
108
+ },
109
+ );
110
+ });