Support Markdown exports for getting-started docs

This commit is contained in:
ajnart committed 2026-09-11 11:20:02 +02:00
1 parent 16ba3c6977
commit 6969e3fb27
6 files changed
+185 -8

No files matched your search

+7
View File
@@ -53,6 +53,13 @@ When a user-facing change affects setup or behavior, update the matching page in
Every docs page and blog post includes copy-Markdown and view-source actions. Search is generated at build time and
runs locally in the browser; it does not depend on an external crawler. Markdown exports and search resolve
integration credentials and widget defaults from the same typed metadata used by the pages.
The getting-started overview and complete Custom JSX component catalog are also included, with property types,
binding contracts, and blocked capabilities taken from the published catalog JSON.
Published Docusaurus `/docs/category/...` addresses remain available as static compatibility pages. They forward to
the corresponding overview or developer setup guide, preserving query strings and anchors, and provide a direct
link when JavaScript is disabled. Their canonical metadata points to the destination; compatibility pages are
excluded from search and the sitemap.
## Kapa AI
@@ -0,0 +1,58 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { DocsBody, DocsPage, DocsTitle } from "fumadocs-ui/layouts/docs/page";
import { LegacyCategoryRedirect } from "@/components/docs/legacy-category-redirect";
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>
<LegacyCategoryRedirect href={`${page.url}/`} />
</DocsBody>
</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: page.url },
robots: { index: false, follow: true },
};
}
@@ -0,0 +1,11 @@
"use client";
import { useEffect } from "react";
export function LegacyCategoryRedirect({ href }: { href: string }) {
useEffect(() => {
window.location.replace(`${href}${window.location.search}${window.location.hash}`);
}, [href]);
return <a href={href}>Continue to the documentation</a>;
}
@@ -0,0 +1,76 @@
// oxlint-disable-next-line import/no-unassigned-import -- Prevent catalog data from entering a client bundle.
import "server-only";
import { asMarkdown } from "fumadocs-core/server";
import type { CustomJsxAuthoringCatalog, CustomJsxPropDescriptor } from "@homarr/custom-widgets/catalog";
import catalogData from "@site/public/custom-widgets/component-catalog-v1.json";
const catalog = catalogData as CustomJsxAuthoringCatalog;
function Props({ items }: { items: CustomJsxPropDescriptor[] }) {
asMarkdown();
return (
<ul>
{items.map((prop) => (
<li key={prop.name}>
<code>{prop.name}</code>: <code>{catalog.types[prop.typeRef] ?? "unknown"}</code>.
{prop.required && " Required."} {prop.description}
{prop.literalValues && (
<> Known values: {prop.literalValues.map((value) => JSON.stringify(value)).join(", ")}.</>
)}
</li>
))}
</ul>
);
}
export function ComponentCatalogMarkdown() {
return (
<>
<p>
Custom JSX {catalog.customWidgetVersion}, Mantine {catalog.mantineVersion}. Properties are optional unless
marked required. <a href="/custom-widgets/component-catalog-v1.json">Download the complete JSON catalog</a>.
</p>
<h2>Global properties</h2>
<Props items={catalog.globalProps} />
<h2>Blocked capabilities</h2>
<ul>
{catalog.blockedCapabilities.map((capability) => (
<li key={`${capability.kind}-${capability.name}`}>
<code>{capability.name}</code> ({capability.kind}): {capability.reason}
</li>
))}
</ul>
{catalog.components.map((component) => (
<section key={component.name}>
<h2>{component.name}</h2>
<p>
{component.package}; {component.category}; safety: {component.safety}. {component.description}
</p>
{component.deniedReason && <p>Unavailable: {component.deniedReason}</p>}
{component.bind && (
<p>
Bind a temporary {component.bind.type} input with <code>bind</code>. Initialize with{" "}
<code>{component.bind.initialProp}</code>; reset with <code>{component.bind.resetProp}</code>.
</p>
)}
{component.subcomponents.length > 0 && <p>Subcomponents: {component.subcomponents.join(", ")}.</p>}
<Props items={component.props} />
<ul>
{component.blockedProps.map((prop) => (
<li key={prop.name}>
<code>{prop.name}</code> is blocked: {prop.reason}
</li>
))}
{component.accessibilityRequirements.map((requirement) => (
<li key={requirement}>{requirement}</li>
))}
</ul>
<p>
<a href={component.documentationUrl}>Upstream documentation for {component.name}</a>
</p>
</section>
))}
</>
);
}
@@ -9,8 +9,10 @@ import { AddingWidget } from "@/components/widgets/adding";
import type { WidgetHeader } from "@/components/widgets/header";
import type { WidgetIntegrations } from "@/components/widgets/integrations";
import { WidgetInteractionGuide } from "@/components/widgets/interactions";
import { GettingStartedOverview, InstallationPaths } from "@/components/pages/getting-started/getting-started-overview";
import { getDocsHref } from "@/lib/docs-path";
import type { WidgetConfiguration } from "@/types";
import { ComponentCatalogMarkdown } from "./component-catalog-markdown";
// Fumadocs supplies the original MDX props, including typed metadata and JSX steps.
function markdown<Props>(component: (props: Props) => ReactNode) {
@@ -47,6 +49,9 @@ function configuration({ items }: WidgetConfiguration): ReactNode {
}
export const metadataMarkdownComponents = {
CustomJsxComponentReference: markdown(ComponentCatalogMarkdown),
GettingStartedOverview: markdown(GettingStartedOverview),
InstallationPaths: markdown(InstallationPaths),
AddingIntegration: markdown(AddingIntegration),
AddingWidget: markdown(AddingWidget),
WidgetInteractionGuide: markdown(WidgetInteractionGuide),
@@ -1,4 +1,6 @@
import Link from "next/link";
import { asMarkdown } from "fumadocs-core/server";
import type { ElementType } from "react";
import { IconArrowRight } from "@tabler/icons-react";
import addItem from "@site/docs/getting-started/img/manage-board-header-choose-item.png";
@@ -33,15 +35,21 @@ const editingSteps = [
];
export function GettingStartedOverview() {
const isMarkdown = asMarkdown();
let BoardLink: ElementType = Link;
if (isMarkdown) BoardLink = "a";
return (
<div className={styles.overview}>
<section className={styles.conceptCard} aria-labelledby="getting-started-overview-title">
<div className={styles.intro}>
<h2 id="getting-started-overview-title">How Homarr works</h2>
<p>Services expose data, integrations connect to them, widgets use the data, and boards arrange the widgets.</p>
<p>
Services expose data, integrations connect to them, widgets use the data, and boards arrange the widgets.
</p>
</div>
<ConceptFlow />
{!isMarkdown && <ConceptFlow />}
</section>
<section className={styles.editor} aria-labelledby="board-editor-title">
@@ -50,19 +58,18 @@ export function GettingStartedOverview() {
<h3 id="board-editor-title">Board edit mode</h3>
<p>Add items, move them, and resize them on the grid. Each viewport can have its own layout.</p>
</div>
<Link href="/docs/management/boards">
Board docs <IconArrowRight size={15} aria-hidden="true" />
</Link>
<BoardLink href="/docs/management/boards">
Board docs {!isMarkdown && <IconArrowRight size={15} aria-hidden="true" />}
</BoardLink>
</div>
<ol className={styles.editorSteps}>
{editingSteps.map((step, index) => (
<li className={styles.editorStep} key={step.title}>
<div className={styles.stepCopy}>
<span>{index + 1}</span>
{!isMarkdown && <span>{index + 1}</span>}
<div>
<strong>{step.title}</strong>
<small>{step.description}</small>
<strong>{step.title}</strong> <small>{step.description}</small>
</div>
</div>
<div className={styles.stepMedia}>
@@ -101,6 +108,19 @@ const installationPaths = [
];
export function InstallationPaths() {
const isMarkdown = asMarkdown();
if (isMarkdown) {
return (
<ul>
{installationPaths.map((path) => (
<li key={path.href}>
<a href={path.href}>{path.title}</a> ({path.eyebrow}): {path.description}
</li>
))}
</ul>
);
}
return (
<div className={styles.installPaths}>
{installationPaths.map((path) => {