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,38 @@
|
||||
import posthog from "posthog-js";
|
||||
import { expect, test, vi } from "vitest";
|
||||
|
||||
import { initAnalytics, track } from "./analytics";
|
||||
|
||||
vi.mock("posthog-js", () => ({ default: { init: vi.fn(), capture: vi.fn() } }));
|
||||
|
||||
test("attributes automatic and custom events without leaking URL queries", () => {
|
||||
initAnalytics();
|
||||
const beforeSend = vi.mocked(posthog.init).mock.calls[0]?.[1]?.before_send;
|
||||
if (typeof beforeSend !== "function") throw new Error("Missing analytics event hook");
|
||||
|
||||
const pageview = beforeSend({
|
||||
uuid: "pageview",
|
||||
event: "$pageview",
|
||||
properties: {
|
||||
$current_url: "https://homarr.dev/docs/?token=secret#section",
|
||||
$set: { $initial_current_url: "https://homarr.dev/?token=secret" },
|
||||
},
|
||||
});
|
||||
expect(pageview?.properties).toMatchObject({
|
||||
site: "homarr-docs",
|
||||
source_path: window.location.pathname,
|
||||
$current_url: "https://homarr.dev/docs/",
|
||||
$set: { $initial_current_url: "https://homarr.dev/" },
|
||||
});
|
||||
|
||||
track("Workshop Item Downloaded", { item_id: "widget", method: "clipboard" });
|
||||
expect(posthog.capture).toHaveBeenCalledWith("Workshop Item Downloaded", {
|
||||
item_id: "widget",
|
||||
method: "clipboard",
|
||||
source_path: window.location.pathname,
|
||||
});
|
||||
expect(
|
||||
beforeSend({ uuid: "custom", event: "Workshop Item Downloaded", properties: { source_path: "/workshop/widget" } })
|
||||
?.properties.source_path,
|
||||
).toBe("/workshop/widget");
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import posthog from "posthog-js";
|
||||
|
||||
let initialized = false;
|
||||
|
||||
export function initAnalytics() {
|
||||
if (initialized || typeof window === "undefined") return;
|
||||
initialized = true;
|
||||
|
||||
posthog.init("phc_pWxeD1hbl4ip02JYReX1Crjkt5DhB3dduigirHMCtFE", {
|
||||
api_host: "https://hog.homarr.dev",
|
||||
ui_host: "https://eu.posthog.com",
|
||||
defaults: "2026-01-30",
|
||||
capture_pageview: "history_change",
|
||||
capture_pageleave: false,
|
||||
// Keep Workshop forms and API-key inputs out of analytics.
|
||||
autocapture: false,
|
||||
capture_dead_clicks: false,
|
||||
capture_heatmaps: false,
|
||||
capture_exceptions: false,
|
||||
disable_surveys: true,
|
||||
disable_session_recording: true,
|
||||
advanced_disable_feature_flags: true,
|
||||
person_profiles: "identified_only",
|
||||
before_send(event) {
|
||||
if (!event) return event;
|
||||
event.properties.site = "homarr-docs";
|
||||
event.properties.source_path ??= window.location.pathname;
|
||||
event.properties.verification =
|
||||
window.location.hostname === "localhost" ||
|
||||
window.location.hostname === "127.0.0.1" ||
|
||||
new URLSearchParams(window.location.search).has("analytics_test");
|
||||
for (const properties of [
|
||||
event.properties,
|
||||
event.properties.$set,
|
||||
event.properties.$set_once,
|
||||
"$set" in event && event.$set,
|
||||
"$set_once" in event && event.$set_once,
|
||||
]) {
|
||||
if (!properties || typeof properties !== "object") continue;
|
||||
for (const key of [
|
||||
"$current_url",
|
||||
"$referrer",
|
||||
"$initial_current_url",
|
||||
"$initial_referrer",
|
||||
"$session_entry_url",
|
||||
"$session_entry_referrer",
|
||||
]) {
|
||||
const value = properties[key];
|
||||
if (typeof value !== "string" || !value) continue;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
properties[key] = `${url.origin}${url.pathname}`;
|
||||
} catch {
|
||||
delete properties[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
return event;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function track(event: string, properties: Record<string, unknown> = {}) {
|
||||
if (typeof window === "undefined" || !initialized) return;
|
||||
posthog.capture(event, { ...properties, source_path: window.location.pathname });
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
const authors: Record<string, { name: string; github: string; image: string }> = {
|
||||
"manuel-rw": {
|
||||
name: "Manuel",
|
||||
github: "manuel-rw",
|
||||
image: "https://avatars.githubusercontent.com/u/30572287?v=4",
|
||||
},
|
||||
ajnart: {
|
||||
name: "Ajnart",
|
||||
github: "ajnart",
|
||||
image: "https://avatars.githubusercontent.com/u/49837342?v=4",
|
||||
},
|
||||
meierschlumpf: {
|
||||
name: "Meierschlumpf",
|
||||
github: "Meierschlumpf",
|
||||
image: "https://avatars.githubusercontent.com/u/63781622?v=4",
|
||||
},
|
||||
tagashi: {
|
||||
name: "Tagashi",
|
||||
github: "tagaishi",
|
||||
image: "https://avatars.githubusercontent.com/u/26098587?v=4",
|
||||
},
|
||||
walkx: {
|
||||
name: "Walkx",
|
||||
github: "walkxcode",
|
||||
image: "https://avatars.githubusercontent.com/u/71191962?v=4",
|
||||
},
|
||||
};
|
||||
|
||||
export function getBlogAuthor(author: string) {
|
||||
return (
|
||||
authors[author] ?? {
|
||||
name: author,
|
||||
github: author,
|
||||
image: `https://github.com/${author}.png?size=96`,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
const legacyDocsPath = /^(?:\.\.\/)+(integrations|management|widgets)\/(.+)$/;
|
||||
|
||||
export const getDocsHref = (path: string) => {
|
||||
const match = legacyDocsPath.exec(path);
|
||||
return match ? `/docs/${match[1]}/${match[2]}` : path;
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
import { IconBrandDiscord, IconBrandGithub, IconBrandReddit, IconBuildingStore, IconHeart } from "@tabler/icons-react";
|
||||
import Image from "next/image";
|
||||
import type { BaseLayoutProps } from "fumadocs-ui/layouts/shared";
|
||||
|
||||
export function baseOptions(): BaseLayoutProps {
|
||||
return {
|
||||
nav: {
|
||||
title: (
|
||||
<span className="homarr-brand">
|
||||
<Image className="homarr-brand-logo" src="/img/logo.svg" alt="" width={30} height={20} aria-hidden />
|
||||
<span className="homarr-brand-name">Homarr</span>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
links: [
|
||||
{
|
||||
text: "Documentation",
|
||||
url: "/docs",
|
||||
active: "nested-url",
|
||||
},
|
||||
{
|
||||
text: "Workshop",
|
||||
url: "/workshop",
|
||||
active: "nested-url",
|
||||
},
|
||||
{
|
||||
text: "API reference",
|
||||
url: "/api-reference",
|
||||
active: "url",
|
||||
},
|
||||
{
|
||||
text: "Blog",
|
||||
url: "/blog",
|
||||
active: "nested-url",
|
||||
secondary: true,
|
||||
},
|
||||
{
|
||||
text: "Community",
|
||||
url: "/docs/community",
|
||||
active: "nested-url",
|
||||
secondary: true,
|
||||
},
|
||||
{
|
||||
text: "About us",
|
||||
url: "/about-us",
|
||||
active: "url",
|
||||
secondary: true,
|
||||
},
|
||||
{
|
||||
type: "icon",
|
||||
text: "Donate",
|
||||
label: "Donate to Homarr",
|
||||
icon: <IconHeart />,
|
||||
url: "https://opencollective.com/homarr",
|
||||
external: true,
|
||||
on: "menu",
|
||||
},
|
||||
{
|
||||
type: "icon",
|
||||
text: "Workshop",
|
||||
label: "Open Community Workshop",
|
||||
icon: <IconBuildingStore />,
|
||||
url: "/workshop",
|
||||
on: "menu",
|
||||
},
|
||||
{
|
||||
type: "icon",
|
||||
text: "Discord",
|
||||
label: "Join Homarr on Discord",
|
||||
icon: <IconBrandDiscord />,
|
||||
url: "https://discord.com/invite/aCsmEV5RgA",
|
||||
external: true,
|
||||
on: "menu",
|
||||
},
|
||||
{
|
||||
type: "icon",
|
||||
text: "Reddit",
|
||||
label: "Visit Homarr on Reddit",
|
||||
icon: <IconBrandReddit />,
|
||||
url: "https://www.reddit.com/r/homarr/",
|
||||
external: true,
|
||||
on: "menu",
|
||||
},
|
||||
{
|
||||
type: "icon",
|
||||
text: "GitHub",
|
||||
label: "Open Homarr on GitHub",
|
||||
icon: <IconBrandGithub />,
|
||||
url: "https://github.com/homarr-labs/homarr",
|
||||
external: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const siteUrl = new URL(process.env.HOMARR_WEBSITE_URL ?? "https://homarr.dev");
|
||||
|
||||
export function canonicalUrl(pathname: string) {
|
||||
return new URL(`${pathname.replace(/\/$/, "")}/`, siteUrl).href;
|
||||
}
|
||||
|
||||
export function pageMetadata({
|
||||
title,
|
||||
description,
|
||||
path,
|
||||
publishedTime,
|
||||
authors,
|
||||
image: socialImage,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
path: string;
|
||||
publishedTime?: string;
|
||||
authors?: string[];
|
||||
image?: { url: string; width: number; height: number; alt: string };
|
||||
}): Metadata {
|
||||
const socialTitle = title === "Homarr documentation" ? title : `${title} | Homarr documentation`;
|
||||
const image = socialImage
|
||||
? { ...socialImage, url: new URL(socialImage.url, siteUrl).href }
|
||||
: { url: new URL("/img/logo.png", siteUrl).href, width: 484, height: 329, alt: "Homarr" };
|
||||
return {
|
||||
title: { absolute: socialTitle },
|
||||
description,
|
||||
alternates: { canonical: canonicalUrl(path) },
|
||||
authors: authors?.map((name) => ({ name })),
|
||||
openGraph: {
|
||||
title: socialTitle,
|
||||
description,
|
||||
url: canonicalUrl(path),
|
||||
siteName: "Homarr",
|
||||
locale: "en_US",
|
||||
images: [image],
|
||||
...(publishedTime ? { type: "article", publishedTime, authors } : { type: "website" }),
|
||||
},
|
||||
twitter: {
|
||||
card: socialImage ? "summary_large_image" : "summary",
|
||||
title: socialTitle,
|
||||
description,
|
||||
images: [{ url: image.url, alt: image.alt }],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import type { apiSource } from "@/lib/openapi";
|
||||
|
||||
type ApiPage = ReturnType<typeof apiSource.getPages>[number];
|
||||
type UnknownRecord = Record<string, unknown>;
|
||||
|
||||
function record(value: unknown): UnknownRecord | undefined {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
|
||||
return value as UnknownRecord;
|
||||
}
|
||||
|
||||
function resolveReferences(value: unknown, document: UnknownRecord, seen = new Set<string>()): unknown {
|
||||
if (Array.isArray(value)) return value.map((item) => resolveReferences(item, document, seen));
|
||||
const current = record(value);
|
||||
if (!current) return value;
|
||||
|
||||
const referenceValue = current.$ref;
|
||||
if (typeof referenceValue !== "string" || !referenceValue.startsWith("#/")) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(current).map(([key, item]) => [key, resolveReferences(item, document, seen)]),
|
||||
);
|
||||
}
|
||||
const reference = referenceValue;
|
||||
if (seen.has(reference)) return { $ref: reference };
|
||||
|
||||
let target: unknown = document;
|
||||
for (const segment of reference.slice(2).split("/")) {
|
||||
const parent = record(target);
|
||||
target = parent?.[segment.replaceAll("~1", "/").replaceAll("~0", "~")];
|
||||
}
|
||||
if (target === undefined) return current;
|
||||
|
||||
const nextSeen = new Set(seen).add(reference);
|
||||
const resolved = record(resolveReferences(target, document, nextSeen));
|
||||
if (!resolved) return target;
|
||||
const siblings = Object.fromEntries(
|
||||
Object.entries(current)
|
||||
.filter(([key]) => key !== "$ref")
|
||||
.map(([key, item]) => [key, resolveReferences(item, document, nextSeen)]),
|
||||
);
|
||||
return { ...resolved, ...siblings };
|
||||
}
|
||||
|
||||
function formatSchema(value: unknown): string {
|
||||
if (value === undefined) return "";
|
||||
return `\n\`\`\`json\n${JSON.stringify(value, null, 2)}\n\`\`\``;
|
||||
}
|
||||
|
||||
function renderParameters(parameters: unknown, document: UnknownRecord): string {
|
||||
if (!Array.isArray(parameters) || parameters.length === 0) return "";
|
||||
|
||||
const rows = parameters.flatMap((value) => {
|
||||
const parameter = record(resolveReferences(value, document));
|
||||
if (!parameter) return [];
|
||||
const schema = record(parameter.schema);
|
||||
let type = "object";
|
||||
if (typeof schema?.type === "string") type = schema.type;
|
||||
let required = "";
|
||||
if (parameter.required === true) required = " (required)";
|
||||
let description = "";
|
||||
if (typeof parameter.description === "string") description = ` — ${parameter.description}`;
|
||||
let location = "parameter";
|
||||
if (typeof parameter.in === "string") location = parameter.in;
|
||||
let name = "unknown";
|
||||
if (typeof parameter.name === "string") name = parameter.name;
|
||||
return [
|
||||
`- \`${name}\` (${location}, ${type}${required})${description}${formatSchema(resolveReferences(parameter.schema, document))}`,
|
||||
];
|
||||
});
|
||||
|
||||
if (rows.length === 0) return "";
|
||||
return `\n### Parameters\n\n${rows.join("\n")}`;
|
||||
}
|
||||
|
||||
function renderRequestBody(requestBody: unknown, document: UnknownRecord): string {
|
||||
const body = record(resolveReferences(requestBody, document));
|
||||
const content = record(body?.content);
|
||||
if (!content) return "";
|
||||
|
||||
const parts = Object.entries(content).flatMap(([mediaType, mediaValue]) => {
|
||||
const media = record(mediaValue);
|
||||
if (!media) return [];
|
||||
const schema = resolveReferences(media.schema, document);
|
||||
const example = media.example;
|
||||
const examples = record(media.examples);
|
||||
let exampleText = formatSchema(example);
|
||||
if (example === undefined && examples) exampleText = formatSchema(examples);
|
||||
return [`#### ${mediaType}${formatSchema(schema)}${exampleText}`];
|
||||
});
|
||||
if (parts.length === 0) return "";
|
||||
|
||||
let description = "";
|
||||
if (typeof body?.description === "string") description = `${body.description}\n\n`;
|
||||
let required = "";
|
||||
if (body?.required === true) required = " Required.";
|
||||
return `\n### Request body\n\n${description}${required}\n${parts.join("\n\n")}`;
|
||||
}
|
||||
|
||||
function renderResponses(responses: unknown, document: UnknownRecord): string {
|
||||
const entries = record(resolveReferences(responses, document));
|
||||
if (!entries) return "";
|
||||
|
||||
const parts = Object.entries(entries).flatMap(([status, value]) => {
|
||||
const response = record(value);
|
||||
if (!response) return [];
|
||||
let description = "";
|
||||
if (typeof response.description === "string") description = response.description;
|
||||
const content = record(response.content);
|
||||
let schemas: string[] = [];
|
||||
if (content) {
|
||||
schemas = Object.entries(content).flatMap(([mediaType, mediaValue]) => {
|
||||
const media = record(mediaValue);
|
||||
if (!media) return [];
|
||||
return [
|
||||
`\n${mediaType}${formatSchema(resolveReferences(media.schema, document))}${formatSchema(media.example)}`,
|
||||
];
|
||||
});
|
||||
}
|
||||
let heading = `#### ${status}`;
|
||||
if (description) heading += ` — ${description}`;
|
||||
return [`${heading}${schemas.join("\n")}`];
|
||||
});
|
||||
if (parts.length === 0) return "";
|
||||
return `\n### Responses\n\n${parts.join("\n\n")}`;
|
||||
}
|
||||
|
||||
/** Render one generated OpenAPI page as portable Markdown for search and LLM exports. */
|
||||
export function getApiMarkdown(page: ApiPage): string {
|
||||
const props = page.data.getOpenAPIPageProps();
|
||||
const schema = record(page.data.getSchema().bundled);
|
||||
const paths = record(schema?.paths);
|
||||
const operations = props.operations ?? [];
|
||||
let pageDescription = "";
|
||||
if (page.data.description) pageDescription = `${page.data.description}\n\n`;
|
||||
const rendered = operations.flatMap(({ path, method }) => {
|
||||
const pathItem = record(paths?.[path]);
|
||||
const operation = record(resolveReferences(pathItem?.[method], schema ?? {}));
|
||||
if (!operation) return [];
|
||||
|
||||
let operationId: string | undefined;
|
||||
if (typeof operation.operationId === "string") operationId = operation.operationId;
|
||||
let title = operationId ?? `${method.toUpperCase()} ${path}`;
|
||||
if (typeof operation.summary === "string") title = operation.summary;
|
||||
let description = "";
|
||||
if (typeof operation.description === "string") description = `${operation.description}\n\n`;
|
||||
const parameters = [...arrayValue(pathItem?.parameters), ...arrayValue(operation.parameters)];
|
||||
const security = operation.security ?? schema?.security;
|
||||
const components = record(schema?.components);
|
||||
const schemes = record(components?.securitySchemes);
|
||||
let authenticationOptional = false;
|
||||
let authLines: string[] = [];
|
||||
if (Array.isArray(security)) {
|
||||
authLines = security.flatMap((requirement) => {
|
||||
const names = record(requirement);
|
||||
if (!names) return [];
|
||||
if (Object.keys(names).length === 0) authenticationOptional = true;
|
||||
return Object.keys(names).flatMap((name) => {
|
||||
const scheme = record(resolveReferences(schemes?.[name], schema ?? {}));
|
||||
if (!scheme) return [];
|
||||
let location = "authorization";
|
||||
if (typeof scheme.in === "string") location = scheme.in;
|
||||
let keyName = name;
|
||||
if (typeof scheme.name === "string") keyName = scheme.name;
|
||||
let schemeDescription = "";
|
||||
if (typeof scheme.description === "string") schemeDescription = ` — ${scheme.description}`;
|
||||
return [`- ${keyName} (${location})${schemeDescription}`];
|
||||
});
|
||||
});
|
||||
}
|
||||
let auth = "";
|
||||
if (authLines.length > 0) {
|
||||
let heading = "### Authentication";
|
||||
if (authenticationOptional) heading += " (optional)";
|
||||
auth = `\n${heading}\n\n${authLines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
let heading = `## ${title}\n\n\`${method.toUpperCase()} ${path}\``;
|
||||
if (operationId) heading += `\n\nOperation ID: \`${operationId}\``;
|
||||
|
||||
return [
|
||||
`${heading}\n\n${description}${auth}${renderParameters(parameters, schema ?? {})}${renderRequestBody(operation.requestBody, schema ?? {})}${renderResponses(operation.responses, schema ?? {})}`,
|
||||
];
|
||||
});
|
||||
|
||||
const serverGuidance =
|
||||
"Set Server URL to the full URL of your Homarr instance, including its scheme and reverse-proxy path. Open Test, then select Send to run a request from your browser; authentication values are not persisted.\n\n";
|
||||
return `# ${page.data.title}\n\n${pageDescription}${serverGuidance}${rendered.join("\n\n")}`.trim();
|
||||
}
|
||||
|
||||
export function getApiMarkdownUrl(page: ApiPage) {
|
||||
const segments = [...page.url.replace(/^\/api-reference\/?/, "").split("/"), "content.md"];
|
||||
return {
|
||||
segments,
|
||||
url: `/llms.mdx${page.url}/content.md`,
|
||||
};
|
||||
}
|
||||
|
||||
function arrayValue(value: unknown) {
|
||||
if (Array.isArray(value)) return value;
|
||||
return [];
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { normalizeRecursiveJsonSchemas } from "./openapi";
|
||||
|
||||
function recursiveJsonSchema(name: string): { anyOf: Record<string, unknown>[] } {
|
||||
const self = `#/components/schemas/${name}`;
|
||||
return {
|
||||
anyOf: [
|
||||
{ type: "string" },
|
||||
{ type: "number" },
|
||||
{ type: "boolean" },
|
||||
{ type: "null" },
|
||||
{ type: "array", items: { $ref: self } },
|
||||
{ type: "object", propertyNames: { type: "string" }, additionalProperties: { $ref: self } },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("normalizeRecursiveJsonSchemas", () => {
|
||||
it("replaces only the unconstrained recursive JSON shape without mutating the generated document", () => {
|
||||
const recursive = recursiveJsonSchema("JsonValue");
|
||||
const constrained = recursiveJsonSchema("ConstrainedJsonValue");
|
||||
constrained.anyOf[4].maxItems = 4;
|
||||
const document = {
|
||||
openapi: "3.1.0",
|
||||
components: { schemas: { JsonValue: recursive, ConstrainedJsonValue: constrained } },
|
||||
};
|
||||
|
||||
const normalized = normalizeRecursiveJsonSchemas(document as never);
|
||||
|
||||
expect(normalized).not.toBe(document);
|
||||
expect(normalized.components?.schemas?.JsonValue).toEqual({
|
||||
anyOf: [
|
||||
{ type: "string" },
|
||||
{ type: "number" },
|
||||
{ type: "boolean" },
|
||||
{ type: "null" },
|
||||
{ type: "array" },
|
||||
{ type: "object" },
|
||||
],
|
||||
});
|
||||
expect(normalized.components?.schemas?.ConstrainedJsonValue).toBe(constrained);
|
||||
expect(document.components.schemas.JsonValue).toBe(recursive);
|
||||
expect(recursive).toEqual(recursiveJsonSchema("JsonValue"));
|
||||
});
|
||||
|
||||
it("leaves documents without the exact recursive shape unchanged", () => {
|
||||
const document = { openapi: "3.1.0", components: { schemas: { JsonValue: { type: "object" } } } };
|
||||
|
||||
expect(normalizeRecursiveJsonSchemas(document as never)).toBe(document);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { loader } from "fumadocs-core/source";
|
||||
import { createOpenAPI } from "fumadocs-openapi/server";
|
||||
import type { Document } from "fumadocs-openapi";
|
||||
|
||||
import schema from "../../public/api/open-api-schema.json";
|
||||
|
||||
type RecordValue = Record<string, unknown>;
|
||||
|
||||
function asRecord(value: unknown): RecordValue | undefined {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
|
||||
return value as RecordValue;
|
||||
}
|
||||
|
||||
function hasExactKeys(value: RecordValue, keys: string[]) {
|
||||
const actual = Object.keys(value).toSorted();
|
||||
const expected = keys.toSorted();
|
||||
if (actual.length !== expected.length) return false;
|
||||
return actual.every((key, index) => key === expected[index]);
|
||||
}
|
||||
|
||||
function isUnconstrainedRecursiveJsonSchema(name: string, value: unknown) {
|
||||
const root = asRecord(value);
|
||||
if (!root || !hasExactKeys(root, ["anyOf"]) || !Array.isArray(root.anyOf)) return false;
|
||||
if (root.anyOf.length !== 6) return false;
|
||||
|
||||
const simpleTypes = new Set<string>();
|
||||
let hasArray = false;
|
||||
let hasObject = false;
|
||||
const self = `#/components/schemas/${name.replaceAll("~", "~0").replaceAll("/", "~1")}`;
|
||||
|
||||
for (const member of root.anyOf) {
|
||||
const variant = asRecord(member);
|
||||
if (!variant) return false;
|
||||
if (typeof variant.type === "string" && hasExactKeys(variant, ["type"])) {
|
||||
simpleTypes.add(variant.type);
|
||||
continue;
|
||||
}
|
||||
if (variant.type === "array" && hasExactKeys(variant, ["type", "items"])) {
|
||||
const items = asRecord(variant.items);
|
||||
if (!items || !hasExactKeys(items, ["$ref"]) || items.$ref !== self) return false;
|
||||
hasArray = true;
|
||||
continue;
|
||||
}
|
||||
if (variant.type === "object" && hasExactKeys(variant, ["type", "propertyNames", "additionalProperties"])) {
|
||||
const propertyNames = asRecord(variant.propertyNames);
|
||||
const additionalProperties = asRecord(variant.additionalProperties);
|
||||
if (!propertyNames || !hasExactKeys(propertyNames, ["type"]) || propertyNames.type !== "string") return false;
|
||||
if (!additionalProperties || !hasExactKeys(additionalProperties, ["$ref"]) || additionalProperties.$ref !== self)
|
||||
return false;
|
||||
hasObject = true;
|
||||
continue;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
simpleTypes.size === 4 &&
|
||||
simpleTypes.has("string") &&
|
||||
simpleTypes.has("number") &&
|
||||
simpleTypes.has("boolean") &&
|
||||
simpleTypes.has("null") &&
|
||||
hasArray &&
|
||||
hasObject
|
||||
);
|
||||
}
|
||||
|
||||
/** Replace only the generator's exact unconstrained recursive JSON schema with its equivalent finite union for Scalar. */
|
||||
export function normalizeRecursiveJsonSchemas(document: Document): Document {
|
||||
const components = asRecord(document.components);
|
||||
const schemas = asRecord(components?.schemas);
|
||||
if (!components || !schemas) return document;
|
||||
|
||||
const normalizedSchemas = { ...schemas };
|
||||
let changed = false;
|
||||
for (const [name, value] of Object.entries(schemas)) {
|
||||
if (!isUnconstrainedRecursiveJsonSchema(name, value)) continue;
|
||||
normalizedSchemas[name] = {
|
||||
anyOf: [
|
||||
{ type: "string" },
|
||||
{ type: "number" },
|
||||
{ type: "boolean" },
|
||||
{ type: "null" },
|
||||
{ type: "array" },
|
||||
{ type: "object" },
|
||||
],
|
||||
};
|
||||
changed = true;
|
||||
}
|
||||
if (!changed) return document;
|
||||
return {
|
||||
...document,
|
||||
components: {
|
||||
...components,
|
||||
schemas: normalizedSchemas,
|
||||
},
|
||||
} as unknown as Document;
|
||||
}
|
||||
|
||||
export const openapi = createOpenAPI({
|
||||
input: {
|
||||
homarr: () => ({
|
||||
...normalizeRecursiveJsonSchemas(schema as unknown as Document),
|
||||
servers: [
|
||||
{
|
||||
url: "{instanceUrl}",
|
||||
description: "Enter the full URL of your Homarr instance, including its scheme and reverse-proxy path.",
|
||||
variables: {
|
||||
instanceUrl: {
|
||||
default: "https://homarr.example.com",
|
||||
description: "The full URL where your Homarr instance is reachable, including any reverse-proxy path.",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
export const apiSource = loader({
|
||||
baseUrl: "/api-reference",
|
||||
source: await openapi.staticSource({
|
||||
per: "operation",
|
||||
groupBy: "tag",
|
||||
meta: true,
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { structure } from "fumadocs-core/mdx-plugins/remark-structure";
|
||||
import { cache } from "react";
|
||||
|
||||
import { getPageMarkdown, type source } from "./source";
|
||||
|
||||
/** Reuse the rendered content so directory and search-engine summaries stay aligned. */
|
||||
export const getPageDescription = cache(async (page: (typeof source)["$inferPage"]) => {
|
||||
if (page.data.description) return page.data.description;
|
||||
|
||||
const markdown = await getPageMarkdown(page);
|
||||
const paragraph = structure(markdown).contents.find(
|
||||
({ content }) => content.trim().length > 40 && !content.startsWith("Categories:"),
|
||||
)?.content;
|
||||
if (!paragraph) return `Set up and use ${page.data.title} in Homarr.`;
|
||||
|
||||
const text = paragraph.replace(/\s+/g, " ").trim();
|
||||
if (text.length <= 180) return text;
|
||||
return `${text.slice(0, 177).replace(/\s+\S*$/, "")}…`;
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { WorkshopBackend, type TypedWorkshopPocketBase } from "@homarr/workshop/backend";
|
||||
import type { WorkshopSubmissionSummary } from "@homarr/workshop/schema";
|
||||
|
||||
export type { WorkshopComment, WorkshopVote } from "@homarr/workshop/schema";
|
||||
|
||||
const backends = new Map<string, WorkshopBackend>();
|
||||
|
||||
export const getWorkshopBackend = (url: string): WorkshopBackend => {
|
||||
const normalizedUrl = url.replace(/\/$/u, "");
|
||||
const existing = backends.get(normalizedUrl);
|
||||
if (existing) return existing;
|
||||
const backend = new WorkshopBackend(normalizedUrl);
|
||||
backends.set(normalizedUrl, backend);
|
||||
return backend;
|
||||
};
|
||||
|
||||
export const getPocketBase = (url: string): TypedWorkshopPocketBase => getWorkshopBackend(url).pocketBase;
|
||||
|
||||
export type WorkshopSubmission = WorkshopSubmissionSummary & {
|
||||
content: string;
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { getRuntimeWorkshopApiUrl } from "./runtime-config";
|
||||
|
||||
describe("Workshop runtime configuration", () => {
|
||||
afterEach(() => {
|
||||
delete window.homarrRuntimeConfig;
|
||||
});
|
||||
|
||||
test("prefers the URL supplied when the container starts", () => {
|
||||
window.homarrRuntimeConfig = { workshopApiUrl: "https://api.example.invalid" };
|
||||
|
||||
expect(getRuntimeWorkshopApiUrl("https://homarr.dev")).toBe("https://api.example.invalid");
|
||||
});
|
||||
|
||||
test("keeps the build-time URL as a fallback", () => {
|
||||
expect(getRuntimeWorkshopApiUrl("https://homarr.dev")).toBe("https://homarr.dev");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
interface HomarrRuntimeConfig {
|
||||
homarrWebsiteUrl?: string;
|
||||
workshopApiUrl?: string;
|
||||
workshopWebUrl?: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
homarrRuntimeConfig?: Readonly<HomarrRuntimeConfig>;
|
||||
}
|
||||
}
|
||||
|
||||
export const getRuntimeWorkshopApiUrl = (configuredUrl: string): string => {
|
||||
if (typeof window === "undefined") return configuredUrl;
|
||||
return window.homarrRuntimeConfig?.workshopApiUrl || configuredUrl || window.location.origin;
|
||||
};
|
||||
@@ -0,0 +1,151 @@
|
||||
import { llms, loader } from "fumadocs-core/source";
|
||||
import { metaSchema, pageSchema } from "fumadocs-core/source/schema";
|
||||
import { defineCollections, defineDocs } from "fumadocs-mdx/macro";
|
||||
import { z } from "zod";
|
||||
import { metadataMarkdownComponents } from "@/components/mdx/metadata-markdown";
|
||||
|
||||
const titleFromSource = (source: string, path: string) => {
|
||||
const heading = /^#\s+(.+)$/m
|
||||
.exec(source)?.[1]
|
||||
?.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
|
||||
.replace(/[*_`]/g, "")
|
||||
.trim();
|
||||
if (heading) return heading;
|
||||
|
||||
const name =
|
||||
path
|
||||
.split("/")
|
||||
.at(-1)
|
||||
?.replace(/\.(md|mdx)$/, "")
|
||||
.replace(/^index$/, "Overview") ?? "Overview";
|
||||
return name.replace(/[-_]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||
};
|
||||
|
||||
const docs = defineDocs({
|
||||
dir: "docs",
|
||||
docs: {
|
||||
schema: ({ source, path }) =>
|
||||
pageSchema
|
||||
.extend({
|
||||
title: z.string().optional(),
|
||||
hide_title: z.boolean().optional(),
|
||||
sidebar_label: z.string().optional(),
|
||||
sidebar_position: z.number().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
})
|
||||
.transform((data) => ({ ...data, title: data.title ?? titleFromSource(source, path) })),
|
||||
postprocess: {
|
||||
extractLinkReferences: true,
|
||||
includeProcessedMarkdown: { output: "function" },
|
||||
},
|
||||
},
|
||||
meta: {
|
||||
schema: metaSchema,
|
||||
},
|
||||
});
|
||||
|
||||
export const posts = defineCollections({
|
||||
type: "doc",
|
||||
dir: "blog",
|
||||
files: ["**/index.mdx"],
|
||||
schema: pageSchema.extend({
|
||||
date: z.string().date(),
|
||||
authors: z.array(z.string()),
|
||||
tags: z.array(z.string()).optional(),
|
||||
slug: z.string().optional(),
|
||||
}),
|
||||
postprocess: {
|
||||
extractLinkReferences: true,
|
||||
includeProcessedMarkdown: true,
|
||||
},
|
||||
});
|
||||
|
||||
export const docsRoute = "/docs";
|
||||
export const docsContentRoute = "/llms.mdx/docs";
|
||||
|
||||
export const source = loader({
|
||||
baseUrl: docsRoute,
|
||||
source: docs.toFumadocsSource(),
|
||||
pageTree: {
|
||||
transformers: [
|
||||
{
|
||||
folder(node) {
|
||||
return node.children.length === 0 ? { ...node, collapsible: false } : node;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
export function getPageMarkdownUrl(page: (typeof source)["$inferPage"]) {
|
||||
const segments = [...page.slugs, "content.md"];
|
||||
|
||||
return {
|
||||
segments,
|
||||
url: `/${[...docsContentRoute.split("/"), ...segments].filter(Boolean).join("/")}`,
|
||||
};
|
||||
}
|
||||
|
||||
export const docsLlms = llms(source, {
|
||||
renderDescription(node, { lang }) {
|
||||
if (node.type === "page") {
|
||||
const page = source.getNodePage(node, lang);
|
||||
if (page) {
|
||||
return [page.data.description, `[Markdown](${getPageMarkdownUrl(page).url})`].filter(Boolean).join(" ");
|
||||
}
|
||||
}
|
||||
if (typeof node.description === "string") return node.description;
|
||||
return "";
|
||||
},
|
||||
async renderPage(page) {
|
||||
const processed = await getPageMarkdown(page);
|
||||
const body = processed.replace(/^\s*#\s+[^\n]+\n+/, "");
|
||||
|
||||
return `# ${page.data.title} (${page.url})\n\n${body}`;
|
||||
},
|
||||
});
|
||||
|
||||
export function getPageMarkdown(page: (typeof source)["$inferPage"]) {
|
||||
return page.data.getText("processed", { components: metadataMarkdownComponents });
|
||||
}
|
||||
|
||||
export type BlogPost = (typeof posts.entries)[number];
|
||||
|
||||
export function getPostSegments(post: BlogPost) {
|
||||
if (post.slug) return [post.slug];
|
||||
|
||||
const [year, datedFolder = ""] = post.info.path.split("/");
|
||||
const match = /^(\d{2})-(\d{2})-(.+)$/.exec(datedFolder);
|
||||
return match ? [year, match[1], match[2], match[3]] : [year, datedFolder].filter(Boolean);
|
||||
}
|
||||
|
||||
export function getPostUrl(post: BlogPost) {
|
||||
return `/blog/${getPostSegments(post).join("/")}`;
|
||||
}
|
||||
|
||||
export function getPostBySegments(segments: string[]) {
|
||||
return posts.entries.find((post) => getPostSegments(post).join("/") === segments.join("/"));
|
||||
}
|
||||
|
||||
export function getPostMarkdownUrl(post: BlogPost) {
|
||||
return `/llms.mdx/blog/${[...getPostSegments(post), "content.md"].join("/")}`;
|
||||
}
|
||||
|
||||
export async function getPostLLMText(post: BlogPost) {
|
||||
const processed = await post.getText("processed");
|
||||
return `# ${post.title} (${getPostUrl(post)})\n\n${processed}`;
|
||||
}
|
||||
|
||||
export async function getPostExcerpt(post: BlogPost) {
|
||||
const raw = await post.getText("raw");
|
||||
return raw
|
||||
.replace(/^---[\s\S]*?---\s*/, "")
|
||||
.split("{/* Historical excerpt boundary. */}", 1)[0]
|
||||
.replace(/^import\s.+$/gm, "")
|
||||
.replace(/^#\s+.+$/m, "")
|
||||
.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
|
||||
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
|
||||
.replace(/[*_`>#]/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { ClassValue } from "clsx";
|
||||
import { clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function errorMessage(caught: unknown, fallback: string) {
|
||||
return caught instanceof Error ? caught.message : fallback;
|
||||
}
|
||||
|
||||
export function oauthErrorMessage(caught: unknown) {
|
||||
const message = errorMessage(caught, "GitHub sign-in failed");
|
||||
if (/popup.*block|blocked.*popup/iu.test(message))
|
||||
return "GitHub sign-in popup was blocked. Allow popups for this site and try again.";
|
||||
if (/cancel|closed.*popup|popup.*closed/iu.test(message)) return "GitHub sign-in was cancelled before it completed.";
|
||||
if (/provider|oauth|redirect|callback|client.?id/iu.test(message))
|
||||
return "GitHub sign-in is not configured correctly on the Workshop server.";
|
||||
return message;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { CUSTOM_WIDGET_STARTER } from "@homarr/custom-widgets/core";
|
||||
|
||||
import { validateSubmissionContent } from "./workshop-schema";
|
||||
|
||||
const validWidget = CUSTOM_WIDGET_STARTER;
|
||||
|
||||
describe("validateSubmissionContent", () => {
|
||||
it("accepts a valid homarr-custom-widget-v2 JSON", () => {
|
||||
expect(validateSubmissionContent("customWidget", JSON.stringify(validWidget)).success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an old/unknown widget schema version", () => {
|
||||
const result = validateSubmissionContent(
|
||||
"customWidget",
|
||||
JSON.stringify({ ...validWidget, $schema: "homarr-custom-widget-v1" }),
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects widgets without a schema version", () => {
|
||||
const { $schema: _schema, ...widgetWithoutSchema } = validWidget;
|
||||
|
||||
expect(validateSubmissionContent("customWidget", JSON.stringify(widgetWithoutSchema)).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects malformed widget JSON", () => {
|
||||
expect(validateSubmissionContent("customWidget", "{ not json").success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts non-empty CSS and rejects empty CSS", () => {
|
||||
expect(validateSubmissionContent("customCss", ".card { color: red; }").success).toBe(true);
|
||||
expect(validateSubmissionContent("customCss", " ").success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { CUSTOM_WIDGET_SCHEMA, type HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
import {
|
||||
MAX_WORKSHOP_CSS_LENGTH,
|
||||
type WorkshopSubmissionType,
|
||||
validateWorkshopContent,
|
||||
WORKSHOP_CSS_SCHEMA,
|
||||
} from "@homarr/workshop/schema";
|
||||
|
||||
export type SubmissionType = WorkshopSubmissionType;
|
||||
export const WIDGET_SCHEMA_VERSION = CUSTOM_WIDGET_SCHEMA;
|
||||
export const CSS_SCHEMA_VERSION = WORKSHOP_CSS_SCHEMA;
|
||||
export const MAX_CSS_LENGTH = MAX_WORKSHOP_CSS_LENGTH;
|
||||
|
||||
export type WorkshopValidationResult =
|
||||
| { success: true; data: HomarrCustomWidgetV2 | string }
|
||||
| { success: false; error: string };
|
||||
|
||||
export const schemaVersionByType = {
|
||||
customWidget: WIDGET_SCHEMA_VERSION,
|
||||
customCss: CSS_SCHEMA_VERSION,
|
||||
} satisfies Record<SubmissionType, string>;
|
||||
|
||||
export const validateSubmissionContent = (type: SubmissionType, raw: string): WorkshopValidationResult =>
|
||||
validateWorkshopContent(type, raw);
|
||||
Reference in new issue
Block a user