@assistant-ui/docs-theme 0.0.0 → 0.0.3

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 (117) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +225 -0
  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/dist/site/constants.d.ts +2 -0
  39. package/dist/site/constants.d.ts.map +1 -0
  40. package/dist/site/constants.js +5 -0
  41. package/dist/site/constants.js.map +1 -0
  42. package/dist/site/footer.d.ts +13 -0
  43. package/dist/site/footer.d.ts.map +1 -0
  44. package/dist/site/footer.js +35 -0
  45. package/dist/site/footer.js.map +1 -0
  46. package/dist/site/header.d.ts +13 -0
  47. package/dist/site/header.d.ts.map +1 -0
  48. package/dist/site/header.js +81 -0
  49. package/dist/site/header.js.map +1 -0
  50. package/dist/site/layout.d.ts +8 -0
  51. package/dist/site/layout.d.ts.map +1 -0
  52. package/dist/site/layout.js +21 -0
  53. package/dist/site/layout.js.map +1 -0
  54. package/dist/site/page.d.ts +9 -0
  55. package/dist/site/page.d.ts.map +1 -0
  56. package/dist/site/page.js +32 -0
  57. package/dist/site/page.js.map +1 -0
  58. package/dist/site/pager.d.ts +13 -0
  59. package/dist/site/pager.d.ts.map +1 -0
  60. package/dist/site/pager.js +36 -0
  61. package/dist/site/pager.js.map +1 -0
  62. package/dist/site/sidebar-navigation.d.ts +13 -0
  63. package/dist/site/sidebar-navigation.d.ts.map +1 -0
  64. package/dist/site/sidebar-navigation.js +185 -0
  65. package/dist/site/sidebar-navigation.js.map +1 -0
  66. package/dist/site/sidebar.d.ts +16 -0
  67. package/dist/site/sidebar.d.ts.map +1 -0
  68. package/dist/site/sidebar.js +46 -0
  69. package/dist/site/sidebar.js.map +1 -0
  70. package/dist/site/toc.d.ts +12 -0
  71. package/dist/site/toc.d.ts.map +1 -0
  72. package/dist/site/toc.js +92 -0
  73. package/dist/site/toc.js.map +1 -0
  74. package/dist/site/tree.d.ts +24 -0
  75. package/dist/site/tree.d.ts.map +1 -0
  76. package/dist/site/tree.js +14 -0
  77. package/dist/site/tree.js.map +1 -0
  78. package/dist/site/utils.d.ts +2 -0
  79. package/dist/site/utils.d.ts.map +1 -0
  80. package/dist/site/utils.js +5 -0
  81. package/dist/site/utils.js.map +1 -0
  82. package/dist/ui.d.ts +10 -0
  83. package/dist/ui.d.ts.map +1 -0
  84. package/dist/ui.js +9 -0
  85. package/package.json +101 -1
  86. package/src/action-icons.tsx +48 -0
  87. package/src/brand.tsx +36 -0
  88. package/src/config.ts +23 -0
  89. package/src/header.test.tsx +41 -0
  90. package/src/header.tsx +94 -0
  91. package/src/index.test.ts +37 -0
  92. package/src/index.ts +38 -0
  93. package/src/layout-hosts.test.tsx +98 -0
  94. package/src/navigation.test.ts +123 -0
  95. package/src/navigation.ts +61 -0
  96. package/src/next.test.tsx +110 -0
  97. package/src/next.tsx +101 -0
  98. package/src/page-content.test.tsx +87 -0
  99. package/src/page-content.tsx +416 -0
  100. package/src/provider.ts +1 -0
  101. package/src/site/constants.ts +1 -0
  102. package/src/site/footer.tsx +59 -0
  103. package/src/site/header.test.tsx +89 -0
  104. package/src/site/header.tsx +117 -0
  105. package/src/site/layout.tsx +33 -0
  106. package/src/site/page.tsx +38 -0
  107. package/src/site/pager.tsx +54 -0
  108. package/src/site/sidebar-navigation.test.tsx +92 -0
  109. package/src/site/sidebar-navigation.tsx +267 -0
  110. package/src/site/sidebar.tsx +77 -0
  111. package/src/site/toc.test.tsx +187 -0
  112. package/src/site/toc.tsx +118 -0
  113. package/src/site/tree.ts +36 -0
  114. package/src/site/utils.ts +2 -0
  115. package/src/ui.ts +19 -0
  116. package/styles/site.css +223 -0
  117. package/styles/theme.css +858 -0
@@ -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
+ });
package/src/next.tsx ADDED
@@ -0,0 +1,101 @@
1
+ import { cloneElement, type ReactNode } from "react";
2
+ import type { DocsLayoutProps } from "fumadocs-ui/layouts/docs";
3
+ import type { DocsConfig } from "@farming-labs/docs";
4
+ import {
5
+ createDocsLayout as createFrameworkDocsLayout,
6
+ createDocsMetadata as createFrameworkDocsMetadata,
7
+ } from "@farming-labs/theme";
8
+ import { withThemeDefaults } from "./config";
9
+ import { DocsHeader } from "./header";
10
+ import { DocsPageContent } from "./page-content";
11
+
12
+ export const createDocsMetadata = (config: DocsConfig) =>
13
+ createFrameworkDocsMetadata(config);
14
+
15
+ export const createDocsLayout = (
16
+ config: DocsConfig,
17
+ options: createDocsLayout.Options,
18
+ ) => {
19
+ const normalized = withThemeDefaults(config);
20
+ const DocsLayout = createFrameworkDocsLayout(
21
+ {
22
+ ...normalized,
23
+ // Farming Labs 0.2 uses the base preset name to label Introduction folders.
24
+ theme: { ...normalized.theme, name: "shadcn" },
25
+ },
26
+ options.locale ? { locale: options.locale } : undefined,
27
+ );
28
+ const docsUrl = config.docsPath ?? `/${config.entry ?? "docs"}`;
29
+ const localizedDocsUrl = options.locale
30
+ ? `${docsUrl}?lang=${encodeURIComponent(options.locale)}`
31
+ : docsUrl;
32
+ const Layout = ({ children }: { children: ReactNode }) => {
33
+ const layout = DocsLayout({
34
+ children: (
35
+ <DocsPageContent
36
+ name={options.name}
37
+ github={config.github}
38
+ editUrl={options.editUrl}
39
+ mcpUrl={options.mcpUrl}
40
+ footer={options.footer}
41
+ >
42
+ {children}
43
+ </DocsPageContent>
44
+ ),
45
+ });
46
+ const layoutProps = options.layoutProps;
47
+ const themedLayout = layoutProps
48
+ ? cloneElement(layout, {
49
+ ...layoutProps,
50
+ sidebar: layoutProps.sidebar
51
+ ? { ...layout.props.sidebar, ...layoutProps.sidebar }
52
+ : layout.props.sidebar,
53
+ })
54
+ : layout;
55
+ return (
56
+ <div className="aui-docs" data-docs-theme="assistant-ui">
57
+ <a className="aui-docs-skip-link" href="#nd-page">
58
+ Skip to content
59
+ </a>
60
+ <DocsHeader
61
+ brand={(config.nav?.title as ReactNode) ?? options.name}
62
+ brandSlot={options.brand}
63
+ homeUrl={
64
+ options.homeUrl ??
65
+ (config.nav?.url && config.nav.url !== docsUrl
66
+ ? config.nav.url
67
+ : localizedDocsUrl)
68
+ }
69
+ docsUrl={localizedDocsUrl}
70
+ name={options.name}
71
+ links={options.links}
72
+ themeToggle={
73
+ config.themeToggle !== false &&
74
+ (typeof config.themeToggle !== "object" ||
75
+ config.themeToggle.enabled !== false)
76
+ }
77
+ />
78
+ {themedLayout}
79
+ </div>
80
+ );
81
+ };
82
+ return Layout;
83
+ };
84
+
85
+ export namespace createDocsLayout {
86
+ export interface Options {
87
+ name: string;
88
+ /** Active locale from the site's Farming Labs i18n configuration. */
89
+ locale?: string;
90
+ /** Custom header branding, including interactive project switchers. */
91
+ brand?: ReactNode;
92
+ /** Framework layout overrides for custom page trees and sidebar slots. */
93
+ layoutProps?: Partial<Omit<DocsLayoutProps, "children">>;
94
+ homeUrl?: string;
95
+ links?: readonly { label: string; href: string; external?: boolean }[];
96
+ footer?: ReactNode;
97
+ mcpUrl?: string;
98
+ /** Override the repository edit link. `{path}` expands to the current page URL path. */
99
+ editUrl?: string;
100
+ }
101
+ }
@@ -0,0 +1,87 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { act, type ReactNode } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterEach, expect, it, vi } from "vitest";
6
+ import { DocsPageContent } from "./page-content";
7
+
8
+ const context = vi.hoisted(() => ({
9
+ tree: {
10
+ $id: "docs",
11
+ name: "Docs",
12
+ children: [
13
+ {
14
+ type: "page",
15
+ name: "Previous" as ReactNode,
16
+ url: "/guide/previous?lang=fr",
17
+ },
18
+ { type: "page", name: "Current", url: "/guide?lang=fr" },
19
+ { type: "page", name: "Next", url: "/guide/next?lang=fr" },
20
+ ],
21
+ },
22
+ }));
23
+
24
+ vi.mock("next/navigation", async (importOriginal) => ({
25
+ ...(await importOriginal<typeof import("next/navigation")>()),
26
+ usePathname: () => "/guide",
27
+ }));
28
+ vi.mock("fumadocs-ui/contexts/tree", async (importOriginal) => ({
29
+ ...(await importOriginal<typeof import("fumadocs-ui/contexts/tree")>()),
30
+ useTreeContext: () => ({ root: context.tree, full: context.tree }),
31
+ }));
32
+ vi.mock("fumadocs-ui/components/toc", async (importOriginal) => ({
33
+ ...(await importOriginal<typeof import("fumadocs-ui/components/toc")>()),
34
+ useTOCItems: () => [{ title: "Heading", url: "#heading", depth: 2 }],
35
+ }));
36
+
37
+ afterEach(() => {
38
+ document.body.replaceChildren();
39
+ vi.unstubAllGlobals();
40
+ });
41
+
42
+ it("keeps localized actions attached when the framework replaces the page header", async () => {
43
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
44
+ document.body.innerHTML =
45
+ '<article id="nd-page"><div class="fd-generated-page-header"></div></article><aside id="nd-toc"></aside>';
46
+ const container = document.createElement("div");
47
+ document.body.append(container);
48
+ const root = createRoot(container);
49
+ context.tree.children[0]!.name = <span>Previous</span>;
50
+ try {
51
+ await act(async () => {
52
+ root.render(
53
+ <DocsPageContent
54
+ name="Project"
55
+ github={undefined}
56
+ editUrl={undefined}
57
+ mcpUrl={undefined}
58
+ footer={undefined}
59
+ >
60
+ <p>Content</p>
61
+ </DocsPageContent>,
62
+ );
63
+ });
64
+ expect(
65
+ document.querySelector('a[title="Previous page"]')?.getAttribute("href"),
66
+ ).toBe("/guide/previous?lang=fr");
67
+ expect(document.querySelector("#nd-toc a")?.getAttribute("href")).toBe(
68
+ "/guide.md?lang=fr",
69
+ );
70
+ expect(document.body.textContent).not.toContain("[object Object]");
71
+ expect(document.querySelector('a[title="Next: Next"]')).not.toBeNull();
72
+
73
+ const replacement = document.createElement("div");
74
+ replacement.className = "fd-generated-page-header";
75
+ await act(async () => {
76
+ document
77
+ .querySelector(".fd-generated-page-header")!
78
+ .replaceWith(replacement);
79
+ });
80
+ expect(
81
+ replacement.querySelector('[aria-label="Page controls"]'),
82
+ ).not.toBeNull();
83
+ } finally {
84
+ await act(async () => root.unmount());
85
+ }
86
+ expect(document.querySelector('[aria-label="Page controls"]')).toBeNull();
87
+ });