@assistant-ui/docs-theme 0.0.0 → 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 +141 -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/package.json +91 -1
  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
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
+ });
@@ -0,0 +1,416 @@
1
+ "use client";
2
+
3
+ import {
4
+ useEffect,
5
+ useRef,
6
+ useState,
7
+ useSyncExternalStore,
8
+ type ReactNode,
9
+ } from "react";
10
+ import { createPortal } from "react-dom";
11
+ import Link from "next/link";
12
+ import { usePathname } from "next/navigation";
13
+ import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
14
+ import { useTreeContext } from "fumadocs-ui/contexts/tree";
15
+ import { useTOCItems } from "fumadocs-ui/components/toc";
16
+ import {
17
+ Check,
18
+ CircleAlert,
19
+ ChevronLeft,
20
+ ChevronRight,
21
+ Copy,
22
+ FileText,
23
+ MoreHorizontal,
24
+ Pencil,
25
+ } from "lucide-react";
26
+ import { ClaudeIcon, McpIcon, OpenAIIcon } from "./action-icons";
27
+
28
+ import { getEditUrl, getPageNeighbors } from "./navigation";
29
+ import type { createDocsLayout } from "./next";
30
+
31
+ type PageLink = getPageNeighbors.Link;
32
+
33
+ const documentSubscribers = new Set<() => void>();
34
+ let documentObserver: MutationObserver | undefined;
35
+
36
+ const subscribeToDocument = (onChange: () => void) => {
37
+ documentSubscribers.add(onChange);
38
+ if (!documentObserver) {
39
+ documentObserver = new MutationObserver(() => {
40
+ for (const subscriber of documentSubscribers) subscriber();
41
+ });
42
+ documentObserver.observe(document.body, { childList: true, subtree: true });
43
+ }
44
+ return () => {
45
+ documentSubscribers.delete(onChange);
46
+ if (documentSubscribers.size === 0) {
47
+ documentObserver?.disconnect();
48
+ documentObserver = undefined;
49
+ }
50
+ };
51
+ };
52
+
53
+ const useDocumentElement = (selector: string | null) =>
54
+ useSyncExternalStore(
55
+ subscribeToDocument,
56
+ () => (selector ? document.querySelector<HTMLElement>(selector) : null),
57
+ () => null,
58
+ );
59
+
60
+ const subscribeToOrigin = () => () => {};
61
+ const getOrigin = () => window.location.origin;
62
+ const getServerOrigin = () => "";
63
+
64
+ const PageActionLabel = ({
65
+ title,
66
+ description,
67
+ }: {
68
+ title: string;
69
+ description: string;
70
+ }) => (
71
+ <span className="aui-docs-action-label">
72
+ <span>{title}</span>
73
+ <span>{description}</span>
74
+ </span>
75
+ );
76
+
77
+ const DocsPageControls = ({
78
+ previous,
79
+ next,
80
+ title,
81
+ pathname,
82
+ markdownUrl,
83
+ siteName,
84
+ editUrl,
85
+ mcpUrl,
86
+ }: {
87
+ previous: PageLink | undefined;
88
+ next: PageLink | undefined;
89
+ title: string;
90
+ pathname: string;
91
+ markdownUrl: string;
92
+ siteName: string;
93
+ editUrl: string | undefined;
94
+ mcpUrl: string | undefined;
95
+ }) => {
96
+ const toc = useTOCItems();
97
+ // The upstream Farming Labs and Fumadocs layouts supply these portal hosts.
98
+ const headerHost = useDocumentElement("#nd-page .fd-generated-page-header");
99
+ const tocHost = useDocumentElement(toc.length > 0 ? "#nd-toc" : null);
100
+ const [message, setMessage] = useState("");
101
+ const [copying, setCopying] = useState(false);
102
+ const origin = useSyncExternalStore(
103
+ subscribeToOrigin,
104
+ getOrigin,
105
+ getServerOrigin,
106
+ );
107
+ const markdown = useRef<Promise<string> | null>(null);
108
+ const githubUrl = editUrl?.replaceAll("{path}", pathname);
109
+ const publishedUrl = `${origin}${markdownUrl}`;
110
+ const claudeUrl = `https://claude.ai/new?q=${encodeURIComponent(`Read ${publishedUrl} (the ${siteName} documentation page "${title}") so I can ask questions about it.`)}`;
111
+
112
+ const loadMarkdown = () => {
113
+ markdown.current ??= fetch(markdownUrl)
114
+ .then((response) => {
115
+ if (!response.ok) throw new Error("Could not load this page");
116
+ return response.text();
117
+ })
118
+ .catch((error: unknown) => {
119
+ markdown.current = null;
120
+ throw error;
121
+ });
122
+ return markdown.current;
123
+ };
124
+
125
+ const copy = async (kind: "page" | "mcp") => {
126
+ setCopying(true);
127
+ try {
128
+ const text =
129
+ kind === "page"
130
+ ? await loadMarkdown()
131
+ : new URL(mcpUrl ?? "", window.location.origin).href;
132
+ await navigator.clipboard.writeText(text);
133
+ setMessage(kind === "page" ? "Page copied" : "MCP server URL copied");
134
+ } catch {
135
+ setMessage("Could not copy. Please try again.");
136
+ } finally {
137
+ setCopying(false);
138
+ }
139
+ };
140
+
141
+ useEffect(() => {
142
+ if (!message) return;
143
+ const timer = window.setTimeout(() => setMessage(""), 3000);
144
+ return () => window.clearTimeout(timer);
145
+ }, [message]);
146
+
147
+ return (
148
+ <>
149
+ {headerHost &&
150
+ createPortal(
151
+ <nav
152
+ className="aui-docs-page-controls not-prose"
153
+ aria-label="Page controls"
154
+ >
155
+ {previous ? (
156
+ <Link
157
+ href={previous.url}
158
+ className="aui-docs-pager-button"
159
+ aria-label="Previous page"
160
+ title={
161
+ typeof previous.name === "string" ||
162
+ typeof previous.name === "number"
163
+ ? `Previous: ${previous.name}`
164
+ : "Previous page"
165
+ }
166
+ >
167
+ <ChevronLeft aria-hidden="true" />
168
+ </Link>
169
+ ) : (
170
+ <button
171
+ className="aui-docs-pager-button"
172
+ aria-label="Previous page"
173
+ disabled
174
+ >
175
+ <ChevronLeft aria-hidden="true" />
176
+ </button>
177
+ )}
178
+ {next ? (
179
+ <Link
180
+ href={next.url}
181
+ className="aui-docs-pager-button"
182
+ aria-label="Next page"
183
+ title={
184
+ typeof next.name === "string" || typeof next.name === "number"
185
+ ? `Next: ${next.name}`
186
+ : "Next page"
187
+ }
188
+ >
189
+ <ChevronRight aria-hidden="true" />
190
+ </Link>
191
+ ) : (
192
+ <button
193
+ className="aui-docs-pager-button"
194
+ aria-label="Next page"
195
+ disabled
196
+ >
197
+ <ChevronRight aria-hidden="true" />
198
+ </button>
199
+ )}
200
+ <DropdownMenu.Root
201
+ onOpenChange={(open) => {
202
+ if (open) void loadMarkdown().catch(() => {});
203
+ }}
204
+ >
205
+ <DropdownMenu.Trigger
206
+ className="aui-docs-pager-button"
207
+ aria-label="More page actions"
208
+ >
209
+ <MoreHorizontal aria-hidden="true" />
210
+ </DropdownMenu.Trigger>
211
+ <DropdownMenu.Portal>
212
+ <DropdownMenu.Content
213
+ className="aui-docs-actions-menu"
214
+ align="end"
215
+ sideOffset={4}
216
+ collisionPadding={16}
217
+ >
218
+ <DropdownMenu.Item
219
+ className="aui-docs-actions-menu-item"
220
+ disabled={copying}
221
+ onSelect={() => void copy("page")}
222
+ >
223
+ <Copy aria-hidden="true" />
224
+ <PageActionLabel
225
+ title={copying ? "Copying..." : "Copy page"}
226
+ description="Markdown, ready to paste into an LLM"
227
+ />
228
+ </DropdownMenu.Item>
229
+ <DropdownMenu.Item
230
+ className="aui-docs-actions-menu-item"
231
+ asChild
232
+ >
233
+ <a
234
+ href={markdownUrl}
235
+ target="_blank"
236
+ rel="noreferrer noopener"
237
+ >
238
+ <FileText aria-hidden="true" />
239
+ <PageActionLabel
240
+ title="View as Markdown"
241
+ description="This page as plain text"
242
+ />
243
+ </a>
244
+ </DropdownMenu.Item>
245
+ <DropdownMenu.Item
246
+ className="aui-docs-actions-menu-item"
247
+ asChild
248
+ >
249
+ <a
250
+ href={claudeUrl}
251
+ target="_blank"
252
+ rel="noreferrer noopener"
253
+ >
254
+ <ClaudeIcon />
255
+ <PageActionLabel
256
+ title="Open in Claude"
257
+ description="Ask Claude about this page"
258
+ />
259
+ </a>
260
+ </DropdownMenu.Item>
261
+ <DropdownMenu.Item
262
+ className="aui-docs-actions-menu-item"
263
+ asChild
264
+ >
265
+ <a
266
+ href="https://chatgpt.com/codex/"
267
+ target="_blank"
268
+ rel="noreferrer noopener"
269
+ >
270
+ <OpenAIIcon />
271
+ <PageActionLabel
272
+ title="Open in Codex"
273
+ description="Open Codex in ChatGPT"
274
+ />
275
+ </a>
276
+ </DropdownMenu.Item>
277
+ {mcpUrl && (
278
+ <>
279
+ <DropdownMenu.Separator className="aui-docs-actions-menu-separator" />
280
+ <DropdownMenu.Item
281
+ className="aui-docs-actions-menu-item"
282
+ onSelect={() => void copy("mcp")}
283
+ >
284
+ <McpIcon />
285
+ <PageActionLabel
286
+ title="Copy MCP server URL"
287
+ description="Use these docs from any MCP client"
288
+ />
289
+ </DropdownMenu.Item>
290
+ </>
291
+ )}
292
+ </DropdownMenu.Content>
293
+ </DropdownMenu.Portal>
294
+ </DropdownMenu.Root>
295
+ </nav>,
296
+ headerHost,
297
+ )}
298
+ {tocHost &&
299
+ createPortal(
300
+ <div className="aui-docs-toc-actions not-prose">
301
+ <button
302
+ type="button"
303
+ onClick={() => void copy("page")}
304
+ disabled={copying}
305
+ >
306
+ <Copy aria-hidden="true" />
307
+ {copying ? "Copying..." : "Copy page"}
308
+ </button>
309
+ <a href={markdownUrl} target="_blank" rel="noreferrer noopener">
310
+ <FileText aria-hidden="true" />
311
+ View as Markdown
312
+ </a>
313
+ {githubUrl && (
314
+ <a href={githubUrl} target="_blank" rel="noreferrer noopener">
315
+ <Pencil aria-hidden="true" />
316
+ Edit on GitHub
317
+ </a>
318
+ )}
319
+ </div>,
320
+ tocHost,
321
+ )}
322
+ <output className="aui-docs-action-status" aria-live="polite">
323
+ {message && (
324
+ <span>
325
+ {message.startsWith("Could not") ? (
326
+ <CircleAlert aria-hidden="true" />
327
+ ) : (
328
+ <Check aria-hidden="true" />
329
+ )}
330
+ {message}
331
+ </span>
332
+ )}
333
+ </output>
334
+ </>
335
+ );
336
+ };
337
+
338
+ export const DocsPageContent = ({
339
+ children,
340
+ name,
341
+ github,
342
+ editUrl,
343
+ mcpUrl,
344
+ footer,
345
+ }: {
346
+ children: ReactNode;
347
+ name: string;
348
+ github: getEditUrl.Options | undefined;
349
+ editUrl: string | undefined;
350
+ mcpUrl: string | undefined;
351
+ footer: createDocsLayout.Options["footer"];
352
+ }) => {
353
+ const pathname = usePathname().replace(/\/$/, "");
354
+ const { root } = useTreeContext();
355
+ const { previous, next, current } = getPageNeighbors(root.children, pathname);
356
+ const title = current?.name;
357
+ const pageUrl = new URL(current?.url ?? pathname, "https://docs.local");
358
+ const markdownUrl = `${pathname}.md${pageUrl.search}`;
359
+
360
+ return (
361
+ <div className="aui-docs-page-content">
362
+ <DocsPageControls
363
+ key={markdownUrl}
364
+ pathname={pathname}
365
+ markdownUrl={markdownUrl}
366
+ title={typeof title === "string" ? title : name}
367
+ siteName={name}
368
+ editUrl={editUrl ?? getEditUrl(pathname, github)}
369
+ mcpUrl={mcpUrl}
370
+ previous={previous}
371
+ next={next}
372
+ />
373
+ <div className="aui-docs-page-body">{children}</div>
374
+ {(previous || next) && (
375
+ <nav
376
+ className="aui-docs-page-navigation not-prose"
377
+ aria-label="Page navigation"
378
+ >
379
+ {previous ? (
380
+ <Link href={previous.url} rel="prev">
381
+ <ChevronLeft aria-hidden="true" />
382
+ <span>
383
+ {previous.section && (
384
+ <span className="aui-docs-page-section">
385
+ {previous.section} /{" "}
386
+ </span>
387
+ )}
388
+ {previous.name}
389
+ </span>
390
+ </Link>
391
+ ) : (
392
+ <span />
393
+ )}
394
+ {next ? (
395
+ <Link href={next.url} rel="next">
396
+ <span>
397
+ {next.section && (
398
+ <span className="aui-docs-page-section">
399
+ {next.section} /{" "}
400
+ </span>
401
+ )}
402
+ {next.name}
403
+ </span>
404
+ <ChevronRight aria-hidden="true" />
405
+ </Link>
406
+ ) : (
407
+ <span />
408
+ )}
409
+ </nav>
410
+ )}
411
+ {footer && (
412
+ <footer className="aui-docs-legal-footer not-prose">{footer}</footer>
413
+ )}
414
+ </div>
415
+ );
416
+ };
@@ -0,0 +1 @@
1
+ export { RootProvider } from "@farming-labs/theme";