Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
},
"dependencies": {
"@base-ui/react": "^1.6.0",
"cnfast": "^0.1.0",
"fumadocs-core": "^16.14.0",
"fumadocs-mdx": "^15.2.2",
"fumadocs-ui": "npm:@fumadocs/base-ui@^16.14.0",
Expand Down
33 changes: 33 additions & 0 deletions docs/press.config.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@ import { defineDocs } from "fumadocs-mdx/macro";
import { defineConfig } from "fumapress";
import { fumadocsMdx } from "fumapress/adapters/mdx";
import { metaSchema, pageSchema } from "fumapress/adapters/mdx/schema";
import { createDocsLayoutPage } from "fumapress/layouts/docs";
import { linkValidationPlugin } from "fumapress/plugins/link-validation";
import { sitemapPlugin } from "fumapress/plugins/sitemap";
import { takumiPlugin } from "fumapress/plugins/takumi";

import { version } from "../package.json";
import { ThemeSwitch } from "./src/components/theme-switch";
import { MARKETPLACE, REPO } from "./src/lib/links";
import { MARK_VIEW_BOX, markPath } from "./src/lib/mark";
import { url } from "./src/lib/url";
Expand Down Expand Up @@ -37,11 +40,17 @@ const OG = {
export default defineConfig({
content: docs.toFumadocsSource(),
defaultLayoutProps: {
// The sidebar's bottom strip is a bordered box holding whatever icon links
// and the theme switch it is given. With one icon link duplicating a nav
// link it was a full-width border around a lone button, so both go and the
// switch is rendered as the sidebar's own footer below.
githubUrl: "",
links: [
{ text: "GitHub", url: REPO },
{ text: "Marketplace", url: MARKETPLACE },
],
nav: { title: SITE_NAME },
themeSwitch: { enabled: false },
},
meta: {
root() {
Expand Down Expand Up @@ -84,6 +93,30 @@ export default defineConfig({
},
// Deployed to a CDN as a static site, so no route may depend on a server.
mode: "static",
renderPage: createDocsLayoutPage({
renderLayout({ next, props }) {
return next({
...props,
sidebar: {
...props.sidebar,
footer: (
<div className="flex items-center justify-between gap-2">
<a
aria-label={`Release notes for v${version}`}
className="bg-fd-secondary/50 ring-fd-foreground/10 text-fd-muted-foreground hover:text-fd-foreground focus-visible:outline-fd-ring inline-flex h-8 items-center rounded-full px-3 font-mono text-xs ring-1 focus-visible:outline-2 focus-visible:outline-offset-2"
href={`${REPO}/releases/tag/v${version}`}
rel="noreferrer"
target="_blank"
>
v{version}
</a>
<ThemeSwitch />
</div>
),
},
});
},
}),
site: {
baseUrl: url,
git: {
Expand Down
5 changes: 2 additions & 3 deletions docs/src/components/footer.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { ThemeSwitch } from "fumadocs-ui/layouts/shared/slots/theme-switch";

import { MARKETPLACE, REPO } from "../lib/links";
import { ThemeSwitch } from "./theme-switch";

interface FooterColumn {
heading: string;
Expand Down Expand Up @@ -182,7 +181,7 @@ export const SiteFooter = () => (
</div>
{/* Closing the gap opened by not using Fumapress's own nav: without
this the home page had no way to change appearance. */}
<ThemeSwitch mode="light-dark-system" />
<ThemeSwitch />
</div>
</div>
</footer>
Expand Down
147 changes: 147 additions & 0 deletions docs/src/components/theme-switch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
"use client";

import { cn } from "cnfast";
import { useTheme } from "fumadocs-ui/provider/base";
import type { ComponentProps } from "react";
import { useCallback, useId, useState, useSyncExternalStore } from "react";
import { flushSync } from "react-dom";

type ThemeSwitchProps = ComponentProps<"fieldset"> & {
mode?: "light-dark" | "light-dark-system";
};

interface Thumb {
left: number;
width: number;
}

const THEMES = [
{
icon: {
d: "M2.5 5.25C2.5 3.45 3.96 2 5.75 2h4.5c1.8 0 3.25 1.46 3.25 3.25V14h-11V5.25M5.75 3.5C4.78 3.5 4 4.28 4 5.25v7.25h8V5.25c0-.97-.78-1.75-1.75-1.75zM5 5.5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1V9H5zm3.5 6H11V10H8.5z",
rule: "evenodd",
},
label: "System theme",
value: "system",
},
{
icon: {
d: "M8.75 2v-.75h-1.5V3h1.5V2M8 10a2 2 0 1 0 0-4 2 2 0 0 0 0 4m0 1.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7m.75 1.5v1.75h-1.5V13zM13 7.25h1.75v1.5H13zm-11 0h-.75v1.5H3v-1.5H2m9-3.32.54-.53.17-.17.53-.53 1.06 1.06-.53.53-.17.17-.53.53zm-7.77 7.78-.53.53 1.06 1.06.53-.53.17-.17.53-.53L3.93 11l-.53.53zM3.93 5l-.53-.53-.17-.17-.53-.53L3.76 2.7l.53.53.17.17.53.53zm7.78 7.78.53.53 1.06-1.06-.53-.53-.17-.17-.53-.53L11 12.07l.53.53z",
rule: "evenodd",
},
label: "Light theme",
value: "light",
},
{
icon: {
d: "m6.3 3.3.7.25A4.25 4.25 0 0 0 12.45 9l.96.96-.08.2A5.75 5.75 0 1 1 6.04 2.6zM5.25 4.76a4.24 4.24 0 1 0 6 5.99H11a5.75 5.75 0 0 1-5.75-6M12.5 3.5h1.25V5H12.5v1.25H11V5H9.75V3.5H11V2.25h1.5zM7 3.55l-.7-.25-.26-.7z",
rule: "nonzero",
},
label: "Dark theme",
value: "dark",
},
] as const;

const listeners = new Set<() => void>();
const subscribe = (listener: () => void) => {
listeners.add(listener);
return () => listeners.delete(listener);
};
const onClient = () => true;
const onServer = () => false;

export const ThemeSwitch = ({
className,
mode = "light-dark-system",
...props
}: ThemeSwitchProps) => {
const { resolvedTheme, setTheme, theme } = useTheme();
const hydrated = useSyncExternalStore(subscribe, onClient, onServer);
const [thumb, setThumb] = useState<Thumb | null>(null);
const name = useId();

const measure = useCallback((cell: HTMLLabelElement | null) => {
if (!cell) {
return;
}

const place = () =>
setThumb({ left: cell.offsetLeft, width: cell.offsetWidth });

place();
const observer = new ResizeObserver(place);
observer.observe(cell);
if (cell.parentElement) {
observer.observe(cell.parentElement);
}

return () => observer.disconnect();
}, []);

const themes =
mode === "light-dark"
? THEMES.filter((option) => option.value !== "system")
: THEMES;
const selected = mode === "light-dark" ? resolvedTheme : theme;
const active = hydrated ? selected : null;

const change = (value: string) => {
if (document.startViewTransition) {
document.startViewTransition(() => flushSync(() => setTheme(value)));
} else {
setTheme(value);
}
};

return (
<fieldset
className={cn(
className,
"bg-fd-secondary/50 ring-fd-foreground/10 relative m-0 flex rounded-full border-0 p-0.5 ring-1 *:rounded-full"
)}
data-theme-toggle=""
{...props}
>
<legend className="sr-only">Theme</legend>

{thumb ? (
<div
aria-hidden="true"
className="bg-fd-background dark:bg-fd-accent ring-fd-foreground/15 pointer-events-none absolute top-0.5 bottom-0.5 rounded-full shadow-sm ring-1 transition-[left,width] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:transition-none"
style={{ left: thumb.left, width: thumb.width }}
/>
) : null}

{themes.map((option) => (
<label
className={cn(
"has-[:focus-visible]:outline-fd-ring relative inline-flex cursor-pointer items-center justify-center rounded-full px-2.5 py-1.5 leading-none transition-colors has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2",
active === option.value
? "text-fd-foreground"
: "text-fd-muted-foreground hover:text-fd-foreground"
)}
key={option.value}
ref={active === option.value ? measure : null}
>
<input
checked={active === option.value}
className="sr-only"
name={name}
onChange={() => change(option.value)}
type="radio"
value={option.value}
/>
<span className="sr-only">{option.label}</span>
<svg aria-hidden="true" className="size-4" viewBox="0 0 16 16">
<path
clipRule={option.icon.rule}
d={option.icon.d}
fill="currentColor"
fillRule={option.icon.rule}
/>
</svg>
</label>
))}
</fieldset>
);
};
Loading