feat!: release v2 (#6545)

Co-authored-by: Artur <145776561+arturict@users.noreply.github.com>
Co-authored-by: arturict <arturict@users.noreply.github.com>
Co-authored-by: Thomas <thomas@ajnart.fr>
Co-authored-by: Afonso Jorge Ramos <afonsojorgeramos@gmail.com>
Co-authored-by: Danura30082 <danura30082+github@gmail.com>
Co-authored-by: Kushie <122986913+kushiemoon-dev@users.noreply.github.com>
Co-authored-by: ajnart <thomas@ajnart.fr>
Co-authored-by: ajnart <thomas@ajnart.dev>
Co-authored-by: chenjun <chenjun@foxmail.com>
Co-authored-by: Wikibear <wikibear@users.noreply.github.com>
Co-authored-by: Bergasha <bergasha@gmail.com>
Co-authored-by: leafyelin <43306196+leafyelin@users.noreply.github.com>
Co-authored-by: Lucas Araujo <lucas.lra@gmail.com>
Co-authored-by: GreenMonito <86085570+GreenMonito@users.noreply.github.com>
Co-authored-by: Nerdy-Technician <roffo@nerdy-technician.social>
Co-authored-by: Jun <34545896+jcstrong@users.noreply.github.com>
Co-authored-by: Edmond Abraham <54119952+gitedmond@users.noreply.github.com>
Co-authored-by: thrawnx <tight.bulb9384@fastmail.com>
Co-authored-by: Chris <58918656+nordada@users.noreply.github.com>
Co-authored-by: yi111 <153097222+Yi-111-a@users.noreply.github.com>
Co-authored-by: Yanis <naamaneyanis@gmail.com>
Co-authored-by: Wikibear <me@sej.de>
Co-authored-by: alessiottavo <93441886+alessiottavo@users.noreply.github.com>
This commit is contained in:
3286 files changed
+284386 -139176

No files matched your search

@@ -0,0 +1,33 @@
import { pageMetadata } from "@/lib/metadata";
import { CodeContributorList } from "@/components/pages/about/code-contributors/code-contributor-list";
import { TranslationContributorList } from "@/components/pages/about/translations-contributors/translation-constributor-list";
export const metadata = pageMetadata({
path: "/about-us",
title: "About Homarr",
description: "The volunteers and contributors behind the Homarr open-source project.",
});
export default function AboutPage() {
return (
<main className="mx-auto w-full max-w-5xl px-5 py-16 sm:px-8">
<header className="max-w-3xl border-b pb-10">
<h1 className="text-4xl font-semibold tracking-tight sm:text-5xl">About us</h1>
<p className="mt-4 text-lg leading-8 text-fd-muted-foreground">
Homarr is a community driven open source project that is being maintained by volunteers. Thanks to these
people, Homarr has been a growing project since 2021. Our team is working completely remote from many
different countries on Homarr in their leisure time for no compensation.
</p>
</header>
<section className="pt-10">
<h2 className="mb-5 text-xl font-semibold">Code contributions</h2>
<CodeContributorList />
</section>
<section className="pt-12">
<h2 className="mb-5 text-xl font-semibold">Translation contributions</h2>
<TranslationContributorList />
</section>
</main>
);
}
@@ -0,0 +1,186 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import Link from "next/link";
import { IconBrandGithub } from "@tabler/icons-react";
import { InlineTOC } from "fumadocs-ui/components/inline-toc";
import { DocsBody, MarkdownCopyButton, ViewOptionsPopover } from "fumadocs-ui/layouts/docs/page";
import homarrV2SocialImage from "@site/blog/2026/09-03-homarr-2.0/img/homarr-v2-recap.webp";
import { getBlogAuthor } from "@/lib/blog-authors";
import { pageMetadata } from "@/lib/metadata";
import { BlogToc } from "@/components/blog/blog-toc";
import { getMDXComponents } from "@/components/mdx";
import {
getPostBySegments,
getPostExcerpt,
getPostMarkdownUrl,
getPostSegments,
getPostUrl,
posts,
} from "@/lib/source";
interface PageProps {
params: Promise<{ slug: string[] }>;
}
const dateFormatter = new Intl.DateTimeFormat("en", { dateStyle: "long", timeZone: "UTC" });
function BlogArchive({ currentPath }: { currentPath: string }) {
const recentPosts = posts.entries.toSorted((left, right) => right.date.localeCompare(left.date)).slice(0, 5);
const years = [...new Set(recentPosts.map((post) => post.date.slice(0, 4)))];
return (
<aside className="sticky top-24 hidden max-h-[calc(100dvh-8rem)] self-start overflow-y-auto 2xl:block">
<h2 className="text-base font-semibold">Recent posts</h2>
<div className="mt-5 space-y-6">
{years.map((year) => (
<section key={year} aria-labelledby={`blog-year-${year}`}>
<h3 id={`blog-year-${year}`} className="text-sm font-semibold text-fd-muted-foreground">
{year}
</h3>
<ul className="mt-2 space-y-2.5 text-sm leading-5">
{recentPosts
.filter((post) => post.date.startsWith(year))
.map((post) => {
const url = getPostUrl(post);
return (
<li key={post.info.path}>
<Link
href={url}
aria-current={url === currentPath ? "page" : undefined}
className={
url === currentPath
? "font-medium text-fd-primary"
: "text-fd-muted-foreground transition-colors hover:text-fd-foreground"
}
>
{post.title}
</Link>
</li>
);
})}
</ul>
</section>
))}
</div>
</aside>
);
}
export default async function BlogPostPage({ params }: PageProps) {
const { slug } = await params;
const post = getPostBySegments(slug);
if (!post) notFound();
const MDX = post.body;
const markdownUrl = getPostMarkdownUrl(post);
const hasBodyTitle = post.toc[0]?.depth === 1;
const bodyTitleId = hasBodyTitle ? decodeURIComponent(post.toc[0].url.slice(1)) : undefined;
const toc = hasBodyTitle ? post.toc.filter((item) => item.depth !== 1) : post.toc;
const postUrl = getPostUrl(post);
const rawPost = await post.getText("raw");
const readingMinutes = Math.max(1, Math.ceil(rawPost.split(/\s+/).length / 220));
return (
<main className="mx-auto grid w-full max-w-[100rem] gap-x-8 px-5 py-10 sm:px-8 lg:py-14 xl:grid-cols-[minmax(0,60rem)_15rem] xl:justify-center 2xl:grid-cols-[15rem_minmax(0,60rem)_15rem]">
<BlogArchive currentPath={postUrl} />
<article className="min-w-0">
<header className="pb-8">
<p className="text-sm text-fd-muted-foreground">
<time dateTime={post.date}>{dateFormatter.format(new Date(post.date))}</time>
<span aria-hidden="true"> · </span>
{readingMinutes} min read
</p>
<h1
id={bodyTitleId}
className="mt-3 text-[clamp(2.25rem,4vw,3.25rem)] leading-[1.05] font-[750] tracking-[-0.035em] text-balance"
>
{post.title}
</h1>
<div className="mt-6 grid gap-5 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-end">
<ul className="flex min-w-0 flex-wrap gap-x-5 gap-y-3" aria-label="Authors">
{post.authors.map((author) => {
const profile = getBlogAuthor(author);
return (
<li key={author}>
<a
href={`https://github.com/${profile.github}`}
target="_blank"
rel="noreferrer"
className="group flex items-center gap-3"
>
<img
src={profile.image}
alt=""
width={48}
height={48}
className="size-12 rounded-full bg-fd-muted object-cover"
/>
<span>
<span className="block text-sm font-semibold group-hover:text-fd-primary">{profile.name}</span>
<span className="mt-0.5 flex items-center gap-1 text-xs text-fd-muted-foreground">
<IconBrandGithub aria-hidden size={14} />@{profile.github}
</span>
</span>
</a>
</li>
);
})}
</ul>
<div className="flex flex-wrap items-center gap-2 lg:justify-end" role="group" aria-label="Post actions">
<MarkdownCopyButton markdownUrl={markdownUrl} />
<ViewOptionsPopover
markdownUrl={markdownUrl}
githubUrl={`https://github.com/homarr-labs/homarr/blob/release/v2/apps/docs/blog/${post.info.path}`}
/>
</div>
</div>
</header>
<DocsBody className="homarr-blog-body border-t pt-8">
<InlineTOC className="xl:hidden" items={toc} />
<MDX
components={getMDXComponents({
h1: ({ id, children, ...props }) =>
bodyTitleId && id === bodyTitleId ? null : (
<h2 id={id} {...props}>
{children}
</h2>
),
})}
/>
</DocsBody>
</article>
<BlogToc items={toc} />
</main>
);
}
export function generateStaticParams() {
return posts.entries.map((post) => ({ slug: getPostSegments(post) }));
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { slug } = await params;
const post = getPostBySegments(slug);
if (!post) notFound();
const image = getPostUrl(post) === "/blog/2026/09/03/homarr-2.0" ? homarrV2SocialImage : undefined;
return pageMetadata({
title: post.title,
description: post.description ?? (await getPostExcerpt(post)).slice(0, 180),
path: getPostUrl(post),
authors: post.authors,
publishedTime: post.date,
image: image
? {
url: image.src,
width: image.width,
height: image.height,
alt: `${post.title} release overview`,
}
: undefined,
});
}
+30
View File
@@ -0,0 +1,30 @@
import type { Metadata } from "next";
import { StaticRedirect } from "@/components/static-redirect";
import { canonicalUrl } from "@/lib/metadata";
import { getPostUrl, posts } from "@/lib/source";
function latestPost() {
const post = posts.entries.toSorted((left, right) => right.date.localeCompare(left.date))[0];
if (!post) throw new Error("The blog redirect requires a published article");
return post;
}
export function generateMetadata(): Metadata {
const post = latestPost();
return {
title: post.title,
alternates: { canonical: canonicalUrl(getPostUrl(post)) },
robots: { index: false, follow: true },
};
}
export default function BlogIndexPage() {
const post = latestPost();
return (
<main className="mx-auto w-full max-w-4xl px-6 py-12">
<h1 className="text-3xl font-bold">Latest Homarr news</h1>
<StaticRedirect href={`${getPostUrl(post)}/`} label={post.title} />
</main>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { Carbon } from "@/components/carbon";
import { HomeLayout } from "fumadocs-ui/layouts/home";
import type { ReactNode } from "react";
import { baseOptions } from "@/lib/layout.shared";
import { SiteFooter } from "@/components/site-footer";
export default function Layout({ children }: { children: ReactNode }) {
return (
<HomeLayout {...baseOptions()}>
<div className="flex min-h-[calc(100dvh-var(--fd-nav-height))] flex-col">
<div className="flex-1">{children}</div>
<Carbon placement="site-footer" />
<SiteFooter />
</div>
</HomeLayout>
);
}
+52
View File
@@ -0,0 +1,52 @@
import { pageMetadata } from "@/lib/metadata";
import { SectionContainer } from "@/components/pages/home/container/section-container";
import { HomeDataflow } from "@/components/pages/home/dataflow/dataflow";
import { DocsGateway } from "@/components/pages/home/docs-gateway";
import { MotionReelShowcase } from "@/components/pages/home/motion-reel-showcase";
import HomepageFeatures from "@/components/pages/home/features/features";
import HomeHero from "@/components/pages/home/hero/hero";
import { AvailableIntegrations } from "@/components/pages/home/integrations/available-integrations";
import HomepageUserReviews from "@/components/pages/home/review-list/review-list";
import styles from "../../pages/index.module.css";
export const metadata = pageMetadata({
path: "/",
title: "A simple, powerful dashboard for your server",
description:
"Simplify the management of your server with Homarr, a sleek, modern dashboard that puts all of your apps and services at your fingertips.",
});
export default function HomePage() {
return (
<main className="overflow-hidden">
<HomeHero />
<svg
aria-hidden="true"
focusable="false"
xmlns="http://www.w3.org/2000/svg"
preserveAspectRatio="none"
viewBox="0 0 1440 320"
className={styles.heroWave}
>
<path
fill="var(--ifm-color-primary)"
d="M0,224L48,202.7C96,181,192,139,288,133.3C384,128,480,160,576,149.3C672,139,768,85,864,58.7C960,32,1056,32,1152,37.3C1248,43,1344,53,1392,58.7L1440,64L1440,0L0,0Z"
/>
</svg>
<SectionContainer>
<HomepageFeatures />
</SectionContainer>
<DocsGateway />
<HomeDataflow />
<MotionReelShowcase />
<AvailableIntegrations />
<SectionContainer>
<HomepageUserReviews />
</SectionContainer>
</main>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { SearchPage } from "@/components/search-page";
import { pageMetadata } from "@/lib/metadata";
export const metadata = {
...pageMetadata({
title: "Search documentation",
description: "Search Homarr documentation, API reference, and project news.",
path: "/search",
}),
robots: { index: false, follow: true },
};
export default function Page() {
return <SearchPage />;
}
@@ -0,0 +1,12 @@
import type { Metadata } from "next";
import { WorkshopAdminRoute } from "@/components/workshop/WorkshopRoutes";
export const metadata: Metadata = {
title: "Workshop moderation",
robots: { index: false, follow: false },
};
export default function WorkshopAdminPage() {
return <WorkshopAdminRoute configuredWorkshopUrl={process.env.WORKSHOP_API_URL ?? process.env.WORKSHOP_URL ?? ""} />;
}
@@ -0,0 +1,26 @@
import { pageMetadata } from "@/lib/metadata";
import { WorkshopListingFallback } from "@/components/workshop/WorkshopApp";
import { WorkshopErrorBoundary } from "@/components/workshop/WorkshopErrorBoundary";
import { WorkshopRoute } from "@/components/workshop/WorkshopRoutes";
export const metadata = pageMetadata({
path: "/workshop",
title: "Workshop",
description: "Community custom CSS and custom widgets for Homarr.",
});
export default function WorkshopPage() {
const configuredWorkshopUrl = process.env.WORKSHOP_API_URL ?? process.env.WORKSHOP_URL ?? "";
return (
<main className="marketplace min-h-[80vh] bg-background text-foreground">
<WorkshopErrorBoundary>
<WorkshopRoute configuredWorkshopUrl={configuredWorkshopUrl} />
</WorkshopErrorBoundary>
<noscript>
<WorkshopListingFallback />
</noscript>
</main>
);
}
@@ -0,0 +1,43 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { DocsPage, DocsTitle } from "fumadocs-ui/layouts/docs/page";
import { OpenAPIPage } from "@/components/openapi-page";
import { pageMetadata } from "@/lib/metadata";
import { apiSource } from "@/lib/openapi";
interface PageProps {
params: Promise<{ slug: string[] }>;
}
export default async function ApiPage({ params }: PageProps) {
const { slug } = await params;
const page = apiSource.getPage(slug);
if (!page) notFound();
return (
<DocsPage toc={page.data.toc} full>
<DocsTitle>{page.data.title}</DocsTitle>
<div className="homarr-api-reference">
<OpenAPIPage {...page.data.getOpenAPIPageProps()} />
</div>
</DocsPage>
);
}
export function generateStaticParams() {
return apiSource.generateParams();
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { slug } = await params;
const page = apiSource.getPage(slug);
if (!page) notFound();
const title = page.data.title ?? "API reference";
return pageMetadata({
title,
description: page.data.description ?? `Request parameters and responses for ${title} in the Homarr HTTP API.`,
path: page.url,
});
}
@@ -0,0 +1,13 @@
import { DocsLayout } from "fumadocs-ui/layouts/docs";
import type { ReactNode } from "react";
import { apiSource } from "@/lib/openapi";
import { baseOptions } from "@/lib/layout.shared";
export default function ApiReferenceLayout({ children }: { children: ReactNode }) {
return (
<DocsLayout tree={apiSource.pageTree} {...baseOptions()}>
{children}
</DocsLayout>
);
}
+39
View File
@@ -0,0 +1,39 @@
import { Carbon } from "@/components/carbon";
import { IconArrowRight } from "@tabler/icons-react";
import { pageMetadata } from "@/lib/metadata";
import Link from "next/link";
import { DocsPage } from "fumadocs-ui/layouts/docs/page";
import { apiSource } from "@/lib/openapi";
export const metadata = pageMetadata({
path: "/api-reference",
title: "API reference",
description: "Interactive reference for Homarr's HTTP API.",
});
export default function ApiReferenceIndexPage() {
const pages = apiSource.getPages();
return (
<DocsPage full breadcrumb={{ enabled: false }} footer={{ enabled: false }}>
<header className="homarr-content-header max-w-2xl">
<p className="text-sm font-medium text-fd-primary">OpenAPI</p>
<h1 className="homarr-content-title mt-2">Homarr API reference</h1>
<p className="homarr-content-lead">
Browse request parameters and response schemas, enter your Homarr instance’s full URL in Server URL, then
review examples or send a test request from your browser.
</p>
</header>
<div className="mt-10 divide-y border-y">
{pages.map((page) => (
<Link key={page.url} href={page.url} className="group flex items-center gap-3 py-4 hover:bg-fd-accent/50">
<span className="min-w-0 flex-1 truncate font-medium">{page.data.title}</span>
<IconArrowRight className="text-fd-muted-foreground" aria-hidden size={17} />
</Link>
))}
</div>
<Carbon />
</DocsPage>
);
}
+73
View File
@@ -0,0 +1,73 @@
import { getBreadcrumbItems } from "fumadocs-core/breadcrumb";
import { createSearchAPI } from "fumadocs-core/search/server";
import { structure } from "fumadocs-core/mdx-plugins/remark-structure";
import { apiSource } from "@/lib/openapi";
import { getApiMarkdown } from "@/lib/openapi-markdown";
import { getPageMarkdown, source } from "@/lib/source";
export const revalidate = false;
async function buildDocIndex(page: (typeof source)["$inferPage"]) {
const markdown = await getPageMarkdown(page);
const original = page.data.structuredData;
const headings = new Set(original.headings.map(({ id }) => id));
const resolved = structure(markdown.replace(/^(#{1,6} .+) \[#[^\]]+\]$/gm, "$1"));
const contents = new Map(original.contents.map((content) => [`${content.heading}\0${content.content}`, content]));
for (const content of resolved.contents) {
// Generated metadata can contain subheadings that are not HTML anchors.
let heading: string | undefined;
if (content.heading && headings.has(content.heading)) heading = content.heading;
contents.set(`${heading}\0${content.content}`, { ...content, heading });
}
return {
id: page.url,
title: stringOrFallback(page.data.title, page.url),
description: stringValue(page.data.description),
url: page.url,
breadcrumbs: getBreadcrumbItems(page.url, source.getPageTree(page.locale), { includeRoot: true })
.map((item) => item.name)
.filter((name): name is string => typeof name === "string"),
structuredData: {
headings: original.headings,
contents: [...contents.values()],
},
locale: page.locale,
};
}
async function buildApiIndex(page: (typeof apiSource)["$inferPage"]) {
const markdown = getApiMarkdown(page);
return {
id: page.url,
title: stringOrFallback(page.data.title, page.url),
description: stringValue(page.data.description),
url: page.url,
breadcrumbs: ["API reference"],
structuredData: {
headings: [],
contents: [{ content: markdown, heading: undefined }],
},
locale: page.locale,
};
}
export const searchAPI = createSearchAPI("advanced", {
language: "english",
indexes: async () => [
...(await Promise.all(source.getPages().map(buildDocIndex))),
...(await Promise.all(apiSource.getPages().map(buildApiIndex))),
],
});
export const GET = searchAPI.staticGET;
function stringValue(value: unknown) {
if (typeof value === "string") return value;
return undefined;
}
function stringOrFallback(value: unknown, fallback: string) {
return stringValue(value) ?? fallback;
}
+36
View File
@@ -0,0 +1,36 @@
import { Feed } from "feed";
import { getPostExcerpt, getPostUrl, posts } from "@/lib/source";
export const revalidate = false;
export async function GET() {
const siteUrl = (process.env.HOMARR_WEBSITE_URL ?? "https://homarr.dev").replace(/\/$/, "");
const feed = new Feed({
title: "Homarr blog",
description: "Release notes, migration guides, and project updates from Homarr.",
id: `${siteUrl}/blog`,
link: `${siteUrl}/blog`,
language: "en",
favicon: `${siteUrl}/img/favicon.png`,
feedLinks: { rss: `${siteUrl}/blog/rss.xml` },
copyright: `Homarr contributors ${new Date().getUTCFullYear()}`,
});
for (const post of posts.entries.toSorted((left, right) => right.date.localeCompare(left.date))) {
const link = `${siteUrl}${getPostUrl(post)}`;
feed.addItem({
title: post.title,
id: link,
link,
date: new Date(post.date),
author: post.authors.map((name) => ({ name })),
description: await getPostExcerpt(post),
category: post.tags?.map((name) => ({ name })),
});
}
return new Response(feed.rss2(), {
headers: { "Content-Type": "application/rss+xml; charset=utf-8" },
});
}
+119
View File
@@ -0,0 +1,119 @@
import { Carbon } from "@/components/carbon";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { findNeighbour } from "fumadocs-core/page-tree";
import {
DocsBody,
DocsDescription,
EditOnGitHub,
DocsPage,
DocsTitle,
MarkdownCopyButton,
ViewOptionsPopover,
} from "fumadocs-ui/layouts/docs/page";
import { createRelativeLink } from "fumadocs-ui/mdx";
import { pageMetadata } from "@/lib/metadata";
import { getPageDescription } from "@/lib/page-description";
import { getMDXComponents } from "@/components/mdx";
import { getPageMarkdownUrl, source } from "@/lib/source";
interface PageProps {
params: Promise<{ slug?: string[] }>;
}
export default async function Page({ params }: PageProps) {
const { slug } = await params;
const page = source.getPage(slug);
if (!page) notFound();
const MDX = page.data.body;
const markdownUrl = getPageMarkdownUrl(page).url;
const sourcePath = `apps/docs/docs/${page.path}`;
const hasLegacyBodyTitle = !page.data.hide_title && page.data.toc[0]?.depth === 1;
const bodyTitleId = hasLegacyBodyTitle ? decodeURIComponent(page.data.toc[0].url.slice(1)) : undefined;
const toc = hasLegacyBodyTitle ? page.data.toc.filter((item) => item.depth !== 1) : page.data.toc;
const isInstallationGuide =
page.slugs.length > 2 && page.slugs[0] === "getting-started" && page.slugs[1] === "installation";
const footerItems = isInstallationGuide
? {
...findNeighbour(source.pageTree, page.url),
next: {
name: "After the installation",
description: "Complete onboarding and configure your first board.",
url: "/docs/getting-started/after-the-installation",
},
}
: undefined;
let carbonPlacement: "banner" | "inline" = "inline";
if (page.data.full) carbonPlacement = "banner";
return (
<DocsPage
toc={toc}
full={page.data.full}
tableOfContent={{
enabled: !page.data.full,
footer: <Carbon placement="toc" />,
container: { className: "homarr-docs-toc" },
}}
footer={footerItems ? { items: footerItems } : undefined}
>
{!page.data.hide_title && (
<>
<DocsTitle id={bodyTitleId} className="homarr-docs-title">
{page.data.title}
</DocsTitle>
{page.data.description && (
<DocsDescription className="homarr-docs-description mb-0">{page.data.description}</DocsDescription>
)}
</>
)}
<div className="homarr-page-actions" role="group" aria-label="Page actions">
<MarkdownCopyButton markdownUrl={markdownUrl} />
<ViewOptionsPopover
markdownUrl={markdownUrl}
githubUrl={`https://github.com/homarr-labs/homarr/blob/release/v2/${sourcePath}`}
/>
</div>
<DocsBody>
<MDX
components={getMDXComponents({
a: createRelativeLink(source, page),
...(!page.data.hide_title && {
h1: ({ id, children, ...props }) =>
bodyTitleId && id === bodyTitleId ? null : (
<h2 id={id} {...props}>
{children}
</h2>
),
}),
})}
/>
</DocsBody>
<Carbon placement={carbonPlacement} />
<div className="mt-8 border-t pt-6">
<EditOnGitHub href={`https://github.com/homarr-labs/homarr/edit/release/v2/${sourcePath}`}>
Edit this page
</EditOnGitHub>
</div>
</DocsPage>
);
}
export function generateStaticParams() {
return source.generateParams();
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { slug } = await params;
const page = source.getPage(slug);
if (!page) notFound();
return pageMetadata({
title: page.data.title,
description: await getPageDescription(page),
path: page.url,
});
}
@@ -0,0 +1,61 @@
import { Carbon } from "@/components/carbon";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { DocsBody, DocsPage, DocsTitle } from "fumadocs-ui/layouts/docs/page";
import { StaticRedirect } from "@/components/static-redirect";
import { canonicalUrl } from "@/lib/metadata";
import { source } from "@/lib/source";
// Docusaurus generated these URLs from category labels, not directory names.
const categories: Record<string, string> = {
advanced: "advanced",
"developer-guides": "advanced/development/getting-started",
community: "community",
"getting-started": "getting-started",
installation: "getting-started/installation",
integrations: "integrations",
management: "management",
widgets: "widgets",
};
interface PageProps {
params: Promise<{ category: string }>;
}
async function getCategoryPage({ params }: PageProps) {
const { category } = await params;
const destination = Object.hasOwn(categories, category) && categories[category];
if (!destination) notFound();
const page = source.getPage(destination.split("/"));
if (!page) throw new Error(`Missing documentation destination for legacy category: ${category}`);
return page;
}
export default async function LegacyCategoryPage(props: PageProps) {
const page = await getCategoryPage(props);
return (
<DocsPage tableOfContent={{ enabled: false }}>
<DocsTitle>{page.data.title}</DocsTitle>
<DocsBody>
<p>This documentation has moved to a new address.</p>
<StaticRedirect href={`${page.url}/`} label="Continue to the documentation" />
</DocsBody>
<Carbon />
</DocsPage>
);
}
export function generateStaticParams() {
return Object.keys(categories).map((category) => ({ category }));
}
export async function generateMetadata(props: PageProps): Promise<Metadata> {
const page = await getCategoryPage(props);
return {
title: page.data.title,
alternates: { canonical: canonicalUrl(page.url) },
robots: { index: false, follow: true },
};
}
+18
View File
@@ -0,0 +1,18 @@
import { DocsLayout } from "fumadocs-ui/layouts/docs";
import type { ReactNode } from "react";
import { DocsSidebarFolder } from "@/components/docs/docs-sidebar-folder";
import { baseOptions } from "@/lib/layout.shared";
import { source } from "@/lib/source";
export default function Layout({ children }: { children: ReactNode }) {
return (
<DocsLayout
{...baseOptions()}
tree={source.getPageTree()}
sidebar={{ defaultOpenLevel: 1, components: { Folder: DocsSidebarFolder } }}
>
{children}
</DocsLayout>
);
}
+360
View File
@@ -0,0 +1,360 @@
@import "tailwindcss";
@import "fumadocs-ui/css/neutral.css";
@import "fumadocs-ui/css/preset.css";
@import "fumadocs-openapi/css/preset.css";
/* Scalar mounts its client at the end of body, outside Fumadocs' themed Test button. */
html .scalar-app {
--scalar-background-1: var(--color-fd-background);
--scalar-background-2: var(--color-fd-card);
--scalar-background-3: var(--color-fd-secondary);
--scalar-color-1: var(--color-fd-foreground);
--scalar-color-2: var(--color-fd-muted-foreground);
--scalar-color-3: var(--color-fd-muted-foreground);
--scalar-color-accent: var(--color-fd-primary);
--scalar-border-color: var(--color-fd-border);
--scalar-button-1: var(--color-fd-primary);
--scalar-button-1-hover: color-mix(in srgb, var(--color-fd-primary) 85%, var(--color-fd-background));
--scalar-button-1-color: var(--color-fd-primary-foreground);
}
html.light .scalar-app {
color-scheme: light;
}
html.dark .scalar-app {
color-scheme: dark;
}
.scalar-app .scalar-container.scalar-client--open {
z-index: 100;
background: rgb(0 0 0 / 65%);
}
.homarr-api-reference .not-prose:is(.light-mode, .dark-mode) + p {
margin-top: 1rem;
}
@source "../components/**/*.{ts,tsx}";
@source "../../docs/**/*.{md,mdx}";
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--radius-sm: calc(var(--radius) * 0.75);
--radius-md: var(--radius);
--radius-lg: calc(var(--radius) * 1.25);
--radius-xl: calc(var(--radius) * 1.5);
}
:root {
--background: oklch(0.985 0.006 25.7);
--foreground: oklch(0.22 0 0);
--card: oklch(0.997 0.002 25.7);
--card-foreground: var(--foreground);
--popover: var(--card);
--popover-foreground: var(--foreground);
--primary: #c62828;
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.96 0.012 25.7);
--secondary-foreground: var(--foreground);
--muted: oklch(0.965 0.008 25.7);
--muted-foreground: oklch(0.46 0.012 25.7);
--accent: oklch(0.955 0.025 25.7);
--accent-foreground: oklch(0.46 0.17 25.7);
--destructive: oklch(0.637 0.208 25.3);
--border: oklch(0.88 0.012 25.7);
--input: var(--border);
--ring: var(--primary);
--radius: 0.375rem;
--ifm-color-primary: #c62828;
--ifm-navbar-background-color: var(--card);
--ifm-font-family-base: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--homarr-secondary-bg: oklch(0.965 0 0);
--color-fd-background: var(--background);
--color-fd-foreground: var(--foreground);
--color-fd-card: var(--card);
--color-fd-card-foreground: var(--card-foreground);
--color-fd-popover: var(--popover);
--color-fd-popover-foreground: var(--popover-foreground);
--color-fd-primary: var(--primary);
--color-fd-primary-foreground: var(--primary-foreground);
--color-fd-secondary: var(--secondary);
--color-fd-secondary-foreground: var(--secondary-foreground);
--color-fd-muted: var(--muted);
--color-fd-muted-foreground: var(--muted-foreground);
--color-fd-accent: var(--accent);
--color-fd-accent-foreground: var(--accent-foreground);
--color-fd-border: color-mix(in oklch, var(--border) 82%, transparent);
--color-fd-ring: var(--ring);
--fd-layout-width: 96rem;
--fd-page-width: 52rem;
}
.dark {
--ifm-color-primary: #fa5252;
--background: oklch(0.185 0.012 25.7);
--foreground: oklch(0.965 0.004 25.7);
--card: oklch(0.225 0.014 25.7);
--card-foreground: var(--foreground);
--popover: var(--card);
--popover-foreground: var(--foreground);
--primary: oklch(0.665 0.222 25.7);
--primary-foreground: oklch(0.15 0.025 25.7);
--secondary: oklch(0.265 0.016 25.7);
--secondary-foreground: var(--foreground);
--muted: oklch(0.255 0.014 25.7);
--muted-foreground: oklch(0.74 0.014 25.7);
--accent: oklch(0.31 0.055 25.7);
--accent-foreground: oklch(0.88 0.09 25.7);
--border: oklch(0.34 0.018 25.7);
--input: var(--border);
--ring: var(--primary);
--ifm-navbar-background-color: var(--card);
--homarr-secondary-bg: var(--card);
}
* {
border-color: var(--border);
}
body {
background: var(--background);
color: var(--foreground);
font-family: var(--ifm-font-family-base);
letter-spacing: -0.01em;
}
:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
.homarr-brand {
display: inline-flex;
align-items: center;
gap: 0.625rem;
font-size: 1.125rem;
font-weight: 750;
letter-spacing: -0.025em;
}
.homarr-brand-logo {
width: 1.875rem;
height: auto;
}
#nd-nav {
height: 4.25rem;
}
#nd-nav > div {
background-color: color-mix(in oklch, var(--color-fd-primary) 3%, var(--color-fd-background));
border-bottom-color: color-mix(in oklch, var(--color-fd-primary) 18%, var(--color-fd-border));
}
#nd-nav > div > div > nav {
height: 4.25rem;
}
#nd-nav a[data-active="true"],
#nd-sidebar a[data-active="true"] {
color: var(--color-fd-accent-foreground);
}
#nd-page > div:first-child .text-fd-primary {
color: var(--color-fd-foreground);
}
#nd-sidebar {
background-color: color-mix(in oklch, var(--color-fd-primary) 2.5%, var(--color-fd-background));
border-color: color-mix(in oklch, var(--color-fd-primary) 14%, var(--color-fd-border));
}
#nd-sidebar [data-radix-scroll-area-viewport],
#nd-sidebar-mobile [data-radix-scroll-area-viewport] {
overflow-y: auto !important;
}
.homarr-docs-title {
max-width: 22ch;
font-size: clamp(2.15rem, 3.4vw, 2.75rem);
font-weight: 750;
line-height: 1.08;
letter-spacing: -0.035em;
}
.homarr-docs-description {
max-width: 62ch;
color: var(--color-fd-muted-foreground);
}
.homarr-content-page {
width: 100%;
margin-inline: auto;
padding: 3rem 1.25rem;
}
.homarr-content-header {
padding-bottom: 2.5rem;
border-bottom: 1px solid var(--border);
}
.homarr-content-title {
max-width: 24ch;
font-size: clamp(2.25rem, 4vw, 3rem);
font-weight: 750;
line-height: 1.08;
letter-spacing: -0.035em;
text-wrap: balance;
}
.homarr-content-lead {
max-width: 65ch;
margin-top: 1rem;
color: var(--color-fd-muted-foreground);
font-size: 1.125rem;
line-height: 1.75;
text-wrap: pretty;
}
@media (min-width: 640px) {
.homarr-content-page {
padding-inline: 2rem;
}
}
@media (min-width: 1024px) {
.homarr-content-page {
padding-block: 4rem;
}
}
.homarr-page-actions {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 1.25rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border);
}
.homarr-blog-body :where(p, li) {
font-size: 1.0625rem;
line-height: 1.8;
}
.homarr-blog-body :where(img, video) {
border-radius: 0.5rem;
}
.prose :where(h1, h2, h3) {
text-wrap: balance;
}
.prose :where(p, li) {
text-wrap: pretty;
}
.prose a[data-card="true"] {
border-color: color-mix(in oklch, var(--color-fd-primary) 12%, var(--color-fd-border));
transition:
transform 180ms cubic-bezier(0.22, 1, 0.36, 1),
border-color 180ms cubic-bezier(0.22, 1, 0.36, 1),
background-color 180ms cubic-bezier(0.22, 1, 0.36, 1);
}
.prose a[data-card="true"] > .not-prose:first-child {
color: var(--color-fd-primary);
background-color: color-mix(in oklch, var(--color-fd-primary) 9%, var(--color-fd-card));
border-color: color-mix(in oklch, var(--color-fd-primary) 22%, var(--color-fd-border));
box-shadow: none;
}
.prose a[data-card="true"]:hover {
transform: translateY(-1px);
border-color: color-mix(in oklch, var(--color-fd-primary) 38%, var(--color-fd-border));
}
@media print {
#nd-docs-layout {
--fd-sidebar-width: 0px !important;
}
#nd-sidebar,
.homarr-page-actions {
display: none !important;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}
.prose a:not([data-card="true"]),
footer a {
text-decoration-line: underline;
text-underline-offset: 0.16em;
}
.dark .prose a code {
color: var(--color-fd-primary);
}
/* Place Carbon after the TOC, or below the page content when no TOC is visible. */
.homarr-carbon {
width: 100%;
max-width: 300px;
margin: 1.5rem auto 0;
flex-shrink: 0;
font-size: 13px;
line-height: 1.5;
}
.homarr-carbon-banner {
margin-top: 1rem;
}
.homarr-carbon:has(> div:empty) {
margin-block: 0;
}
.homarr-carbon img {
max-width: 100%;
}
.homarr-carbon-toc {
display: none;
}
@media (min-width: 1280px) {
.homarr-carbon-toc {
display: block;
}
.homarr-carbon-inline {
display: none;
}
}
/* Keep the ad in the side column on pages with no headings, without an empty TOC label. */
.homarr-docs-toc:not(:has(a[href^="#"])) > :not(.homarr-carbon) {
display: none;
}
+63
View File
@@ -0,0 +1,63 @@
import type { Metadata, Viewport } from "next";
import Script from "next/script";
import type { ReactNode } from "react";
import { siteUrl } from "@/lib/metadata";
import DocsMascot from "@/components/docs-mascot";
import { Analytics } from "@/components/analytics";
import { Provider } from "@/components/provider";
import "./global.css";
const kapaWebsiteId = process.env.KAPA_WEBSITE_ID ?? "1e4656f4-abeb-4343-bbae-1d8626f52378";
export const metadata: Metadata = {
metadataBase: siteUrl,
title: {
default: "Homarr documentation",
template: "%s | Homarr documentation",
},
description: "Install, configure, operate, and extend Homarr.",
keywords: ["Homarr", "dashboard", "self-hosted", "documentation"],
icons: {
icon: "/img/favicon.png",
},
};
export const viewport: Viewport = {
colorScheme: "light dark",
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
{ media: "(prefers-color-scheme: dark)", color: "#171719" },
],
};
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body className="flex min-h-screen flex-col bg-fd-background text-fd-foreground antialiased">
<Script src="/workshop-runtime-config.js" strategy="beforeInteractive" />
<Provider>
{children}
{kapaWebsiteId && <DocsMascot />}
</Provider>
<Analytics />
{kapaWebsiteId && (
<Script
id="kapa-widget"
src="https://widget.kapa.ai/kapa-widget.bundle.js"
strategy="afterInteractive"
data-website-id={kapaWebsiteId}
data-project-name="Homarr"
data-project-color="#2B2B2B"
data-project-logo={new URL("/img/favicon.png", siteUrl).href}
data-modal-open-on-command-k="false"
data-button-hide="true"
data-color-scheme="auto"
data-color-scheme-selector="html.dark"
/>
)}
</body>
</html>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { docsLlms, getPostLLMText, posts } from "@/lib/source";
import { apiSource } from "@/lib/openapi";
import { getApiMarkdown } from "@/lib/openapi-markdown";
export const revalidate = false;
export async function GET() {
const pages = await Promise.all([
docsLlms.full(),
...posts.entries.map(getPostLLMText),
...apiSource.getPages().map(getApiMarkdown),
]);
return new Response(pages.join("\n\n"), {
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
}
@@ -0,0 +1,24 @@
import { notFound } from "next/navigation";
import { apiSource } from "@/lib/openapi";
import { getApiMarkdown, getApiMarkdownUrl } from "@/lib/openapi-markdown";
export const revalidate = false;
interface RouteContext {
params: Promise<{ slug: string[] }>;
}
export async function GET(_request: Request, { params }: RouteContext) {
const { slug } = await params;
const page = apiSource.getPage(slug.slice(0, -1));
if (!page) notFound();
return new Response(getApiMarkdown(page), {
headers: { "Content-Type": "text/markdown; charset=utf-8" },
});
}
export function generateStaticParams() {
return apiSource.getPages().map((page) => ({ slug: getApiMarkdownUrl(page).segments }));
}
@@ -0,0 +1,23 @@
import { notFound } from "next/navigation";
import { getPostBySegments, getPostLLMText, getPostSegments, posts } from "@/lib/source";
export const revalidate = false;
interface RouteContext {
params: Promise<{ slug: string[] }>;
}
export async function GET(_request: Request, { params }: RouteContext) {
const { slug } = await params;
const post = getPostBySegments(slug.slice(0, -1));
if (!post) notFound();
return new Response(await getPostLLMText(post), {
headers: { "Content-Type": "text/markdown; charset=utf-8" },
});
}
export function generateStaticParams() {
return posts.entries.map((post) => ({ slug: [...getPostSegments(post), "content.md"] }));
}
@@ -0,0 +1,25 @@
import { notFound } from "next/navigation";
import { docsLlms, getPageMarkdownUrl, source } from "@/lib/source";
export const revalidate = false;
interface RouteContext {
params: Promise<{ slug?: string[] }>;
}
export async function GET(_request: Request, { params }: RouteContext) {
const { slug } = await params;
const page = source.getPage(slug?.slice(0, -1));
if (!page) notFound();
return new Response(await docsLlms.page(page), {
headers: { "Content-Type": "text/markdown; charset=utf-8" },
});
}
export function generateStaticParams() {
return source.getPages().map((page) => ({
slug: getPageMarkdownUrl(page).segments,
}));
}
+19
View File
@@ -0,0 +1,19 @@
import { docsLlms, getPostMarkdownUrl, getPostUrl, posts } from "@/lib/source";
import { apiSource } from "@/lib/openapi";
export const revalidate = false;
export async function GET() {
const blog = posts.entries.map(
(post) => `- [${post.title}](${getPostUrl(post)}): [Markdown](${getPostMarkdownUrl(post)})`,
);
const index = await docsLlms.index();
const api = apiSource
.getPages()
.map((page) => `- [${page.data.title}](${page.url}): [Markdown](/llms.mdx${page.url}/content.md)`);
const content = `${index}\n\n## Blog\n\n${blog.join("\n")}\n\n## HTTP API\n\n${api.join("\n")}\n`;
return new Response(content, {
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
}
+12
View File
@@ -0,0 +1,12 @@
import { HomeLayout } from "fumadocs-ui/layouts/home";
import { WorkshopNotFoundRouter } from "@/components/workshop/WorkshopNotFoundRouter";
import { baseOptions } from "@/lib/layout.shared";
export default function NotFoundPage() {
return (
<HomeLayout {...baseOptions()}>
<WorkshopNotFoundRouter configuredWorkshopUrl={process.env.WORKSHOP_API_URL ?? process.env.WORKSHOP_URL ?? ""} />
</HomeLayout>
);
}
+15
View File
@@ -0,0 +1,15 @@
import type { MetadataRoute } from "next";
import { siteUrl } from "@/lib/metadata";
export const dynamic = "force-static";
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: "/",
},
sitemap: new URL("/sitemap.xml", siteUrl).href,
};
}
+36
View File
@@ -0,0 +1,36 @@
import type { MetadataRoute } from "next";
import { canonicalUrl } from "@/lib/metadata";
import { apiSource } from "@/lib/openapi";
import { getPostUrl, posts, source } from "@/lib/source";
export const dynamic = "force-static";
export default function sitemap(): MetadataRoute.Sitemap {
return [
{
url: canonicalUrl("/"),
changeFrequency: "weekly",
priority: 1,
},
...source.getPages().map((page) => ({
url: canonicalUrl(page.url),
changeFrequency: "monthly" as const,
priority: page.slugs.length <= 1 ? 0.8 : 0.7,
})),
...apiSource.getPages().map((page) => ({
url: canonicalUrl(page.url),
changeFrequency: "monthly" as const,
priority: 0.6,
})),
...posts.entries.map((post) => ({
url: canonicalUrl(getPostUrl(post)),
lastModified: new Date(post.date),
changeFrequency: "yearly" as const,
priority: 0.5,
})),
{ url: canonicalUrl("/workshop"), changeFrequency: "daily", priority: 0.7 },
{ url: canonicalUrl("/api-reference"), changeFrequency: "weekly", priority: 0.7 },
{ url: canonicalUrl("/about-us"), changeFrequency: "monthly", priority: 0.5 },
];
}