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

+38
View File
@@ -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");
});
+68
View File
@@ -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 });
}
+37
View File
@@ -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`,
}
);
}
+6
View File
@@ -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;
};
+94
View File
@@ -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,
},
],
};
}
+49
View File
@@ -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 }],
},
};
}
+200
View File
@@ -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 [];
}
+52
View File
@@ -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);
});
});
+126
View File
@@ -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,
}),
});
+19
View File
@@ -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*$/, "")}…`;
});
+21
View File
@@ -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;
};
+19
View File
@@ -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");
});
});
+16
View File
@@ -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;
};
+151
View File
@@ -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();
}
+21
View File
@@ -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;
}
+36
View File
@@ -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);
});
});
+24
View File
@@ -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);