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,
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 },
|
||||
];
|
||||
}
|
||||
Reference in new issue
Block a user