Compare commits

..
Author SHA1 Message Date
ajnart 68d4956077 style: auto-fix lint and format 2026-06-04 21:26:19 +00:00
Thomas Camlong 84588c956d feat: consolidate feature 2026-06-04 23:25:31 +02:00
Thomas Camlong 73550ce998 Merge remote-tracking branch 'origin/dev' into feat/static-board-ssr 2026-06-04 23:09:38 +02:00
Thomas Camlong 71d7332505 fix: add async keyword to getInitialBoardAsync function 2026-05-25 09:36:15 +02:00
Thomas Camlong eae51a8447 perf(board): deduplicate board fetch with React cache()
Wrap getInitialBoardAsync in React cache() so the same board data is
fetched once per request instead of 3 times (layout, page, metadata).
This is the same pattern used for auth() in the codebase.
2026-05-22 12:52:15 +02:00
Thomas Camlong e9308db5dc fix(board): align edit mode with dev's removal of loading overlay
Remove useIsBoardReady and LoadingOverlay from edit mode board,
aligning with the dev branch's perf improvement that shows the board
immediately without waiting for ready state.
2026-05-22 11:59:47 +02:00
Thomas Camlong fb06b55845 Merge remote-tracking branch 'origin/dev' into feat/static-board-ssr
# Conflicts:
#	apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx
2026-05-22 11:58:58 +02:00
Thomas Camlong 108b488250 feat(widgets): add server-side prefetch for weather and RSS feed
Prefetch weather and RSS feed data during SSR so widgets render with
content on first paint instead of showing loading states.
2026-05-22 11:38:02 +02:00
Thomas Camlong 10cedbd906 fix(widgets): eliminate layout shift with fallback dimensions
Provide estimated width/height to widgets before useElementSize
measures the DOM, preventing responsive breakpoint shifts during
hydration. Replace the Loader spinner with a Skeleton for widget
loading states.
2026-05-22 11:37:51 +02:00
Thomas Camlong f53527e2e7 fix(board): make SectionContext optional for view mode
Add useOptionalSectionContext to gracefully handle rendering outside
GridStack (view mode). The item menu now uses optional chaining for
gridstack access instead of throwing when context is absent.
2026-05-22 11:37:39 +02:00
Thomas Camlong 121cff20d7 feat(board): add SSR-compatible static grid for view mode
Split board rendering into two modes: a static CSS Grid for view mode
(SSR-friendly, no JS required for layout) and a lazy-loaded GridStack
for edit mode. This eliminates widget "pop-in" on page load by rendering
the full grid layout server-side.
2026-05-22 11:37:25 +02:00
Thomas Camlong e14da6f747 feat: auto-open widget settings on add with all integrations pre-selected
When adding a widget from the "Choose item to add" modal, the widget
edit modal now opens immediately after placement, allowing the user to
configure options and integrations before the widget renders.

All available integrations for the widget are selected by default.
2026-05-22 09:58:29 +02:00
30 changed files with 1009 additions and 777 deletions

No files matched your search

-2
View File
@@ -1,2 +0,0 @@
-P ubuntu-latest=catthehacker/ubuntu:act-latest
--container-architecture linux/amd64
+1 -4
View File
@@ -19,10 +19,7 @@ body:
- type: textarea
attributes:
label: Additional information
description: |
Add any other information related to the integration.
After your integration is merged to `dev`, a bot PR updates [CODEOWNERS](https://github.com/homarr-labs/homarr/blob/dev/CODEOWNERS) so you are requested on future PRs touching your folders. Run `pnpm scripts:generate-codeowners` locally for faster updates. Add email mappings in `scripts/codeowners-overrides.json` if the generator cannot resolve your GitHub user.
description: Add any other information related to the integration.
- type: dropdown
attributes:
label: Public API available?
+1 -4
View File
@@ -19,10 +19,7 @@ body:
- type: textarea
attributes:
label: Additional information
description: |
Add any other information related to the widget.
After your widget is merged to `dev`, a bot PR updates [CODEOWNERS](https://github.com/homarr-labs/homarr/blob/dev/CODEOWNERS) so you are requested on future PRs touching your folders. Run `pnpm scripts:generate-codeowners` locally for faster updates. Add email mappings in `scripts/codeowners-overrides.json` if the generator cannot resolve your GitHub user.
description: Add any other information related to the widget.
- type: dropdown
attributes:
label: Are you willing to contribute this yourself?
-131
View File
@@ -1,131 +0,0 @@
name: Update CODEOWNERS
on:
workflow_dispatch: {}
pull_request:
paths:
- CODEOWNERS
- packages/widgets/src/**
- packages/integrations/src/**
- packages/api/src/router/widgets/**
- packages/cron-jobs/src/jobs/**
- scripts/generate-codeowners.mts
- scripts/validate-codeowners.mts
- scripts/codeowners-overrides.json
push:
paths:
- packages/widgets/src/**
- packages/integrations/src/**
- packages/api/src/router/widgets/**
- packages/cron-jobs/src/jobs/**
- scripts/generate-codeowners.mts
- scripts/validate-codeowners.mts
- scripts/codeowners-overrides.json
branches:
- dev
permissions:
contents: read
pull-requests: write
jobs:
validate:
name: Validate CODEOWNERS
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Use Node.js
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
- uses: pnpm/action-setup@v6
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Check CODEOWNERS is up to date
run: pnpm exec tsx ./scripts/generate-codeowners.mts --check
- name: Validate CODEOWNERS
env:
GITHUB_TOKEN: ${{ github.token }}
GITHUB_REPOSITORY: ${{ github.repository }}
run: pnpm exec tsx ./scripts/validate-codeowners.mts
update:
name: Open CODEOWNERS update PR
needs: validate
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
runs-on: ubuntu-latest
timeout-minutes: 10
concurrency:
group: update-codeowners
cancel-in-progress: false
steps:
- name: Obtain token
id: obtainToken
uses: actions/create-github-app-token@v3
with:
private-key: ${{ secrets.HOMARR_UPDATE_CONTRIBUTORS_PRIVATE_KEY }}
client-id: ${{ vars.HOMARR_UPDATE_CONTRIBUTORS_APP_ID }}
permission-contents: write
permission-pull-requests: write
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0
env:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
- name: Use Node.js
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
- uses: pnpm/action-setup@v6
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Generate CODEOWNERS
env:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
run: pnpm scripts:generate-codeowners
- name: Validate CODEOWNERS
env:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
GITHUB_REPOSITORY: ${{ github.repository }}
run: pnpm exec tsx ./scripts/validate-codeowners.mts
- name: Create Pull Request
id: create-pull-request
uses: peter-evans/create-pull-request@v8
with:
token: ${{ steps.obtainToken.outputs.token }}
branch: update-codeowners
base: dev
title: "chore: update widget/integration CODEOWNERS"
delete-branch: true
commit-message: "chore: update widget/integration CODEOWNERS"
body: |
This PR updates per-widget and per-integration CODEOWNERS entries from git history.
Unmapped authors can be added in `scripts/codeowners-overrides.json`.
- name: Install GitHub CLI
run: sudo apt-get install -y gh
- name: Enable auto-merge
if: steps.create-pull-request.outputs.pull-request-number != ''
env:
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
run: |
gh pr merge ${{ steps.create-pull-request.outputs.pull-request-number }} --auto --squash
-83
View File
@@ -11,86 +11,3 @@ pnpm-lock.yaml
pnpm-workspace.yaml
Dockerfile
docker-compose.yml
# BEGIN GENERATED widget/integration owners
/packages/api/src/router/widgets/anchor-notes.ts /packages/integrations/src/anchor/ /packages/widgets/src/anchor-note/ @omariqbal
/packages/api/src/router/widgets/app.ts /packages/widgets/src/app/ @manuel-rw
/packages/api/src/router/widgets/calendar.ts /packages/widgets/src/calendar/ @manuel-rw
/packages/api/src/router/widgets/coolify.ts /packages/integrations/src/coolify/ /packages/widgets/src/coolify/ @Rhiz3K
/packages/api/src/router/widgets/dns-hole.ts /packages/cron-jobs/src/jobs/integrations/dns-hole.ts /packages/widgets/src/dns-hole/controls/ @manuel-rw
/packages/api/src/router/widgets/dns-hole.ts /packages/cron-jobs/src/jobs/integrations/dns-hole.ts /packages/widgets/src/dns-hole/summary/ @manuel-rw
/packages/api/src/router/widgets/downloads.ts /packages/cron-jobs/src/jobs/integrations/downloads.ts /packages/widgets/src/downloads/ @manuel-rw
/packages/api/src/router/widgets/firewall.ts /packages/cron-jobs/src/jobs/integrations/firewall.ts /packages/widgets/src/firewall/ @Oupsman
/packages/api/src/router/widgets/health-monitoring.ts /packages/cron-jobs/src/jobs/integrations/health-monitoring.ts /packages/widgets/src/health-monitoring/ @dgshue
/packages/api/src/router/widgets/immich.ts /packages/integrations/src/immich/ /packages/widgets/src/immich/album-carousel/ @manuel-rw
/packages/api/src/router/widgets/immich.ts /packages/integrations/src/immich/ /packages/widgets/src/immich/server-stats/ @manuel-rw
/packages/api/src/router/widgets/indexer-manager.ts /packages/cron-jobs/src/jobs/integrations/indexer-manager.ts /packages/widgets/src/indexer-manager/ @manuel-rw
/packages/api/src/router/widgets/media-release.ts /packages/widgets/src/media-releases/ @manuel-rw
/packages/api/src/router/widgets/media-requests.ts /packages/cron-jobs/src/jobs/integrations/media-requests.ts /packages/widgets/src/media-requests/list/ @manuel-rw
/packages/api/src/router/widgets/media-requests.ts /packages/cron-jobs/src/jobs/integrations/media-requests.ts /packages/widgets/src/media-requests/stats/ @manuel-rw
/packages/api/src/router/widgets/media-server.ts /packages/cron-jobs/src/jobs/integrations/media-server.ts /packages/widgets/src/media-server/ @manuel-rw
/packages/api/src/router/widgets/media-transcoding.ts /packages/cron-jobs/src/jobs/integrations/media-transcoding.ts /packages/integrations/src/media-transcoding/ /packages/widgets/src/media-transcoding/ @manuel-rw
/packages/api/src/router/widgets/minecraft.ts /packages/widgets/src/minecraft/server-status/ @manuel-rw
/packages/api/src/router/widgets/network-controller.ts /packages/cron-jobs/src/jobs/integrations/network-controller.ts /packages/widgets/src/network-controller/network-status/ @pitschi
/packages/api/src/router/widgets/network-controller.ts /packages/cron-jobs/src/jobs/integrations/network-controller.ts /packages/widgets/src/network-controller/summary/ @pitschi
/packages/api/src/router/widgets/notebook.ts /packages/widgets/src/notebook/ @manuel-rw
/packages/api/src/router/widgets/notifications.ts /packages/cron-jobs/src/jobs/integrations/notifications.ts /packages/widgets/src/notifications/ @itzTheMeow
/packages/api/src/router/widgets/releases.ts /packages/widgets/src/releases/ @Aandree5
/packages/api/src/router/widgets/rssFeed.ts /packages/widgets/src/rssFeed/ @manuel-rw
/packages/api/src/router/widgets/smart-home.ts /packages/cron-jobs/src/jobs/integrations/home-assistant.ts /packages/widgets/src/smart-home/entity-state/ @manuel-rw
/packages/api/src/router/widgets/smart-home.ts /packages/cron-jobs/src/jobs/integrations/home-assistant.ts /packages/widgets/src/smart-home/execute-automation/ @manuel-rw
/packages/api/src/router/widgets/speedtest-tracker.ts /packages/integrations/src/speedtest-tracker/ /packages/widgets/src/speedtest-tracker/ @kylegrieder
/packages/api/src/router/widgets/stocks.ts /packages/widgets/src/stocks/ @jackwellerreal
/packages/api/src/router/widgets/timetable.ts /packages/cron-jobs/src/jobs/integrations/timetable.ts /packages/widgets/src/timetable/ @manuel-rw
/packages/api/src/router/widgets/tracearr.ts /packages/cron-jobs/src/jobs/integrations/tracearr.ts /packages/integrations/src/tracearr/ /packages/widgets/src/tracearr/ @StrandedTurtle
/packages/api/src/router/widgets/umami.ts /packages/cron-jobs/src/jobs/integrations/umami.ts /packages/integrations/src/umami/ /packages/widgets/src/umami/ @febrile42
/packages/api/src/router/widgets/weather.ts /packages/cron-jobs/src/jobs/weather.ts /packages/widgets/src/weather/ @manuel-rw
/packages/cron-jobs/src/jobs/docker.ts /packages/widgets/src/docker/ @ashtonbatty
/packages/cron-jobs/src/jobs/integrations/dns-hole.ts /packages/integrations/src/pi-hole/ @manuel-rw
/packages/cron-jobs/src/jobs/integrations/downloads.ts /packages/integrations/src/download-client/aria2/ @manuel-rw
/packages/cron-jobs/src/jobs/integrations/downloads.ts /packages/integrations/src/download-client/deluge/ @manuel-rw
/packages/cron-jobs/src/jobs/integrations/downloads.ts /packages/integrations/src/download-client/nzbget/ @manuel-rw
/packages/cron-jobs/src/jobs/integrations/downloads.ts /packages/integrations/src/download-client/qbittorrent/ @manuel-rw
/packages/cron-jobs/src/jobs/integrations/downloads.ts /packages/integrations/src/download-client/sabnzbd/ @manuel-rw
/packages/cron-jobs/src/jobs/integrations/downloads.ts /packages/integrations/src/download-client/slskd/ @manuel-rw
/packages/cron-jobs/src/jobs/integrations/downloads.ts /packages/integrations/src/download-client/transmission/ @manuel-rw
/packages/cron-jobs/src/jobs/integrations/home-assistant.ts /packages/integrations/src/homeassistant/ @manuel-rw
/packages/cron-jobs/src/jobs/integrations/media-organizer.ts /packages/integrations/src/media-organizer/lidarr/ @NicolasNewman
/packages/cron-jobs/src/jobs/integrations/media-organizer.ts /packages/integrations/src/media-organizer/radarr/ @manuel-rw
/packages/cron-jobs/src/jobs/integrations/media-organizer.ts /packages/integrations/src/media-organizer/readarr/ @manuel-rw
/packages/cron-jobs/src/jobs/integrations/media-organizer.ts /packages/integrations/src/media-organizer/sonarr/ @manuel-rw
/packages/integrations/src/adguard-home/ @manuel-rw
/packages/integrations/src/codeberg/ @Aandree5
/packages/integrations/src/dashdot/ @dgshue
/packages/integrations/src/docker-hub/ @Aandree5
/packages/integrations/src/emby/ @manuel-rw
/packages/integrations/src/github-container-registry/ @manuel-rw
/packages/integrations/src/github/ @manuel-rw
/packages/integrations/src/gitlab/ @Aandree5
/packages/integrations/src/glances/ @dgshue
/packages/integrations/src/ical/ @NicolasNewman
/packages/integrations/src/jellyfin/ @manuel-rw
/packages/integrations/src/jellyseerr/ @manuel-rw
/packages/integrations/src/linuxserverio/ @Aandree5
/packages/integrations/src/nextcloud/ @manuel-rw
/packages/integrations/src/npm/ @Aandree5
/packages/integrations/src/ntfy/ @manuel-rw
/packages/integrations/src/openmediavault/ @dgshue
/packages/integrations/src/opnsense/ @manuel-rw
/packages/integrations/src/overseerr/ @manuel-rw
/packages/integrations/src/plex/ @manuel-rw
/packages/integrations/src/prowlarr/ @manuel-rw
/packages/integrations/src/proxmox/ @manuel-rw
/packages/integrations/src/quay/ @Aandree5
/packages/integrations/src/search-ch/ @manuel-rw
/packages/integrations/src/seerr/ @manuel-rw
/packages/integrations/src/truenas/ @dgshue
/packages/integrations/src/unifi-controller/ @manuel-rw
/packages/integrations/src/unraid/ @dgshue
/packages/widgets/src/bookmarks/ @manuel-rw
/packages/widgets/src/clock/ @manuel-rw
/packages/widgets/src/iframe/ @manuel-rw
/packages/widgets/src/system-disks/ @manuel-rw
/packages/widgets/src/system-resources/ @kylegrieder
/packages/widgets/src/video/ @manuel-rw
# END GENERATED widget/integration owners
@@ -1,9 +1,11 @@
import { cache } from "react";
import { api } from "@homarr/api/server";
import { createBoardContentPage } from "../_creator";
const getHomeBoardAsync = cache(() => api.board.getHomeBoard());
export default createBoardContentPage<{ locale: string }>({
async getInitialBoardAsync() {
return await api.board.getHomeBoard();
},
getInitialBoardAsync: getHomeBoardAsync,
});
@@ -1,9 +1,11 @@
import { cache } from "react";
import { api } from "@homarr/api/server";
import { createBoardContentPage } from "../../_creator";
const getBoardByNameAsync = cache((name: string) => api.board.getBoardByName({ name }));
export default createBoardContentPage<{ locale: string; name: string }>({
async getInitialBoardAsync({ name }) {
return await api.board.getBoardByName({ name });
},
getInitialBoardAsync: async ({ name }) => getBoardByNameAsync(name),
});
@@ -1,33 +1,24 @@
"use client";
import { Box, Stack } from "@mantine/core";
import dynamic from "next/dynamic";
import { Box } from "@mantine/core";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { BoardCategorySection } from "~/components/board/sections/category-section";
import { BoardEmptySection } from "~/components/board/sections/empty-section";
import { StaticBoardGrid } from "~/components/board/sections/static-grid";
import { BoardBackgroundVideo } from "~/components/layout/background";
const EditModeBoard = dynamic(() => import("./_edit-board"), { ssr: false });
export const ClientBoard = () => {
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
const fullWidthSortedSections = board.sections
.filter((section) => section.kind === "empty" || section.kind === "category")
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
const [isEditMode] = useEditMode();
return (
<Box h="100%" pos="relative">
<BoardBackgroundVideo />
<Stack h="100%">
{fullWidthSortedSections.map((section) =>
section.kind === "empty" ? (
<BoardEmptySection key={`${currentLayoutId}-${section.id}`} section={section} />
) : (
<BoardCategorySection key={`${currentLayoutId}-${section.id}`} section={section} />
),
)}
</Stack>
{isEditMode ? <EditModeBoard /> : <StaticBoardGrid board={board} />}
</Box>
);
};
@@ -1,7 +1,3 @@
"use client";
import dynamic from "next/dynamic";
export const DynamicClientBoard = dynamic(() => import("./_client").then((mod) => mod.ClientBoard), {
ssr: false,
});
export { ClientBoard as DynamicClientBoard } from "./_client";
@@ -0,0 +1,31 @@
"use client";
import { Stack } from "@mantine/core";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { BoardCategorySection } from "~/components/board/sections/category-section";
import { BoardEmptySection } from "~/components/board/sections/empty-section";
const EditModeBoard = () => {
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
const fullWidthSortedSections = board.sections
.filter((section) => section.kind === "empty" || section.kind === "category")
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
return (
<Stack h="100%">
{fullWidthSortedSections.map((section) =>
section.kind === "empty" ? (
<BoardEmptySection key={`${currentLayoutId}-${section.id}`} section={section} />
) : (
<BoardCategorySection key={`${currentLayoutId}-${section.id}`} section={section} />
),
)}
</Stack>
);
};
export default EditModeBoard;
@@ -19,6 +19,10 @@ import { BoardItemMenu } from "./item-menu";
interface BoardItemContentProps {
item: SectionItem;
fallbackDimensions?: {
width: number;
height: number;
};
}
const getOverflowFromKind = (kind: SectionItem["kind"]) => {
@@ -27,9 +31,11 @@ const getOverflowFromKind = (kind: SectionItem["kind"]) => {
return undefined;
};
export const BoardItemContent = ({ item }: BoardItemContentProps) => {
export const BoardItemContent = ({ item, fallbackDimensions }: BoardItemContentProps) => {
const { ref, width, height } = useElementSize<HTMLDivElement>();
const board = useRequiredBoard();
const fallbackWidth = fallbackDimensions?.width ?? 0;
const fallbackHeight = fallbackDimensions?.height ?? 0;
return (
<>
@@ -52,7 +58,7 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
}}
p={0}
>
<InnerContent item={item} width={width} height={height} />
<InnerContent item={item} width={width || fallbackWidth} height={height || fallbackHeight} />
</Card>
{item.advancedOptions.title?.trim() && (
<Badge
@@ -12,7 +12,7 @@ import { widgetImports } from "@homarr/widgets";
import { WidgetEditModal } from "@homarr/widgets/modals";
import type { SectionItem } from "~/app/[locale]/boards/_types";
import { useSectionContext } from "../sections/section-context";
import { useOptionalSectionContext } from "../sections/section-context";
import { useItemActions } from "./item-actions";
import { ItemMoveModal } from "./item-move-modal";
@@ -41,7 +41,8 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
useItemActions();
const { data: integrationData, isPending } = clientApi.integration.all.useQuery();
const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]);
const { gridstack } = useSectionContext().refs;
const sectionContext = useOptionalSectionContext();
const gridstack = sectionContext?.refs.gridstack;
const settings = useSettings();
// Reset error boundary on next render if item has been edited
@@ -127,7 +128,7 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
<Menu.Item
leftSection={<IconLayoutKanban size={16} />}
onClick={() => {
if (!gridstack.current) return;
if (!gridstack?.current) return;
openMoveModal({ item, columnCount: gridstack.current.getColumn(), gridStack: gridstack.current });
}}
>
@@ -1,48 +1,21 @@
import { Card, Collapse, Group, Stack, Title, UnstyledButton } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconChevronDown, IconChevronUp } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
import type { CategorySection } from "~/app/[locale]/boards/_types";
import { CategoryMenu } from "./category/category-menu";
import { GridStack } from "./gridstack/gridstack";
import classes from "./item.module.css";
const localStorageKey = (sectionId: string) => `homarr-section-collapsed-${sectionId}`;
import { useCategoryCollapse } from "./use-category-collapse";
interface Props {
section: CategorySection;
}
export const BoardCategorySection = ({ section }: Props) => {
const { status } = useSession();
const { mutate } = clientApi.section.changeCollapsed.useMutation();
const board = useRequiredBoard();
const initialCollapsed =
typeof window !== "undefined"
? (localStorage.getItem(localStorageKey(section.id)) ?? String(section.collapsed)) === "true"
: section.collapsed;
const [opened, { toggle }] = useDisclosure(initialCollapsed, {
onOpen() {
if (status === "authenticated") {
mutate({ sectionId: section.id, collapsed: true });
} else if (status === "unauthenticated") {
localStorage.setItem(localStorageKey(section.id), "true");
}
},
onClose() {
if (status === "authenticated") {
mutate({ sectionId: section.id, collapsed: false });
} else if (status === "unauthenticated") {
localStorage.setItem(localStorageKey(section.id), "false");
}
},
});
const [opened, { toggle }] = useCategoryCollapse(section);
return (
<Card style={{ "--opacity": board.opacity / 100 }} radius={board.itemRadius} p={0} className={classes.itemCard}>
@@ -20,4 +20,8 @@ export const useSectionContext = () => {
return context;
};
export const useOptionalSectionContext = () => {
return useContext(SectionContext);
};
export const SectionProvider = SectionContext.Provider;
@@ -0,0 +1,93 @@
import { describe, expect, test } from "vitest";
import { BoardMockBuilder } from "../items/actions/test/mocks/board-mock";
import { DynamicSectionMockBuilder } from "../items/actions/test/mocks/dynamic-section-mock";
import { ItemMockBuilder } from "../items/actions/test/mocks/item-mock";
import { LayoutMockBuilder } from "../items/actions/test/mocks/layout-mock";
import {
getEstimatedGridWidth,
getFallbackDimensions,
getFullWidthSortedSections,
getStaticGridElements,
getStaticGridLayouts,
getStaticGridRowCount,
getStaticLayoutSelectionCss,
} from "./static-grid-layout";
describe("static grid layout", () => {
test("sorts layouts and creates breakpoint CSS in mobile-first order", () => {
const mobile = new LayoutMockBuilder({ id: "mobile", breakpoint: 0, columnCount: 4 }).build();
const tablet = new LayoutMockBuilder({ id: "tablet", breakpoint: 768, columnCount: 8 }).build();
const desktop = new LayoutMockBuilder({ id: "desktop", breakpoint: 1024, columnCount: 12 }).build();
const sortedLayouts = getStaticGridLayouts([desktop, mobile, tablet]);
const css = getStaticLayoutSelectionCss("board-1", sortedLayouts);
expect(sortedLayouts.map((layout) => layout.id)).toEqual(["mobile", "tablet", "desktop"]);
expect(css).toContain('[data-static-layout="mobile"] { display: block; }');
expect(css).toContain("@media (min-width: 768px)");
expect(css).toContain('[data-static-layout="tablet"] { display: block; }');
expect(css).toContain("@media (min-width: 1024px)");
expect(css).toContain('[data-static-layout="desktop"] { display: block; }');
});
test("selects and sorts layout-specific section elements", () => {
const rootSectionId = "root";
const mobileLayoutId = "mobile";
const desktopLayoutId = "desktop";
const mobile = new LayoutMockBuilder({ id: mobileLayoutId, breakpoint: 0, columnCount: 4 }).build();
const desktop = new LayoutMockBuilder({ id: desktopLayoutId, breakpoint: 1024, columnCount: 12 }).build();
const item = new ItemMockBuilder({ id: "item" })
.addLayout({ layoutId: mobileLayoutId, sectionId: rootSectionId, xOffset: 2, yOffset: 1, width: 1, height: 1 })
.addLayout({ layoutId: desktopLayoutId, sectionId: rootSectionId, xOffset: 0, yOffset: 1, width: 4, height: 2 })
.build();
const dynamicSection = new DynamicSectionMockBuilder({ id: "dynamic" })
.addLayout({
layoutId: mobileLayoutId,
parentSectionId: rootSectionId,
xOffset: 0,
yOffset: 0,
width: 2,
height: 2,
})
.addLayout({
layoutId: desktopLayoutId,
parentSectionId: rootSectionId,
xOffset: 4,
yOffset: 1,
width: 4,
height: 2,
})
.build();
const board = new BoardMockBuilder()
.addEmptySection({ id: rootSectionId, yOffset: 2 })
.addEmptySection({ id: "first", yOffset: 0 })
.addSection(dynamicSection)
.addItem(item)
.build();
board.layouts = [desktop, mobile];
const mobileElements = getStaticGridElements(board, rootSectionId, mobileLayoutId);
const desktopElements = getStaticGridElements(board, rootSectionId, desktopLayoutId);
const fullWidthSections = getFullWidthSortedSections(board);
expect(mobileElements.map((element) => element.id)).toEqual(["dynamic", "item"]);
expect(desktopElements.map((element) => ({ id: element.id, width: element.width }))).toEqual([
{ id: "item", width: 4 },
{ id: "dynamic", width: 4 },
]);
expect(getStaticGridRowCount(mobileElements)).toBe(2);
expect(fullWidthSections.map((section) => section.id)).toEqual(["first", rootSectionId]);
});
test("estimates grid and item dimensions per layout range", () => {
const mobile = new LayoutMockBuilder({ id: "mobile", breakpoint: 0, columnCount: 4 }).build();
const tablet = new LayoutMockBuilder({ id: "tablet", breakpoint: 768, columnCount: 8 }).build();
const desktop = new LayoutMockBuilder({ id: "desktop", breakpoint: 1024, columnCount: 12 }).build();
expect(getEstimatedGridWidth(mobile, tablet)).toBe(384);
expect(getEstimatedGridWidth(tablet, desktop)).toBe(896);
expect(getEstimatedGridWidth(desktop)).toBe(1280);
expect(getFallbackDimensions({ width: 2, height: 3 }, 400, 4)).toEqual({ width: 180, height: 280 });
});
});
@@ -0,0 +1,103 @@
import type { Board, DynamicSectionItem, EmptySection, SectionItem } from "~/app/[locale]/boards/_types";
type BoardLayout = Board["layouts"][number];
type StaticGridElement = SectionItem | DynamicSectionItem;
const STATIC_GRID_ITEM_INSET = 20;
const DEFAULT_SMALL_VIEWPORT_WIDTH = 360;
const DEFAULT_LARGE_VIEWPORT_WIDTH = 1280;
export const getStaticGridLayouts = (layouts: Board["layouts"]) =>
[...layouts].toSorted((layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint);
const escapeCssString = (value: string) => value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
export const getStaticLayoutSelectionCss = (boardId: string, layouts: Board["layouts"]) => {
const sortedLayouts = getStaticGridLayouts(layouts);
const initialLayout = sortedLayouts.at(0);
if (!initialLayout) return "";
const scope = `[data-static-board="${escapeCssString(boardId)}"]`;
const css = [
`${scope} .static-board-layout { display: none; }`,
`${scope} .static-board-layout[data-static-layout="${escapeCssString(initialLayout.id)}"] { display: block; }`,
];
for (const layout of sortedLayouts.slice(1)) {
css.push(
`@media (min-width: ${layout.breakpoint}px) { ${scope} .static-board-layout { display: none; } ${scope} .static-board-layout[data-static-layout="${escapeCssString(layout.id)}"] { display: block; } }`,
);
}
return css.join("\n");
};
export const getFullWidthSortedSections = (board: Board) =>
board.sections
.filter(
(section): section is EmptySection | Extract<Board["sections"][number], { kind: "category" }> =>
section.kind === "empty" || section.kind === "category",
)
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
export const getSectionItemsForLayout = (board: Board, sectionId: string, layoutId: string): SectionItem[] => {
return board.items
.map(({ layouts, ...item }) => {
const layout = layouts.find((candidate) => candidate.layoutId === layoutId);
if (!layout) return null;
return { ...layout, ...item, type: "item" as const };
})
.filter((item): item is SectionItem => item !== null && item.sectionId === sectionId);
};
export const getDynamicSectionsForLayout = (
board: Board,
sectionId: string,
layoutId: string,
): DynamicSectionItem[] => {
return board.sections
.filter((section) => section.kind === "dynamic")
.map(({ layouts, ...section }) => {
const layout = layouts.find((candidate) => candidate.layoutId === layoutId);
if (!layout) return null;
return { ...layout, ...section, type: "section" as const };
})
.filter((entry): entry is DynamicSectionItem => entry !== null && entry.parentSectionId === sectionId);
};
export const getStaticGridElements = (board: Board, sectionId: string, layoutId: string): StaticGridElement[] => {
return [
...getSectionItemsForLayout(board, sectionId, layoutId),
...getDynamicSectionsForLayout(board, sectionId, layoutId),
].toSorted((itemA, itemB) => {
if (itemA.yOffset === itemB.yOffset) return itemA.xOffset - itemB.xOffset;
return itemA.yOffset - itemB.yOffset;
});
};
export const getStaticGridRowCount = (items: StaticGridElement[], fixedRowCount?: number) => {
return fixedRowCount ?? Math.max(...items.map((item) => item.yOffset + item.height));
};
export const getEstimatedGridWidth = (layout: BoardLayout, nextLayout?: BoardLayout) => {
if (nextLayout) {
const upperBound = Math.max(nextLayout.breakpoint - 1, DEFAULT_SMALL_VIEWPORT_WIDTH);
return Math.max(DEFAULT_SMALL_VIEWPORT_WIDTH, Math.round((layout.breakpoint + upperBound) / 2));
}
return Math.max(layout.breakpoint, DEFAULT_LARGE_VIEWPORT_WIDTH);
};
export const getFallbackDimensions = (
item: Pick<StaticGridElement, "width" | "height">,
estimatedGridWidth: number,
columnCount: number,
) => {
const cellSize = estimatedGridWidth / columnCount;
return {
width: Math.max(0, item.width * cellSize - STATIC_GRID_ITEM_INSET),
height: Math.max(0, item.height * cellSize - STATIC_GRID_ITEM_INSET),
};
};
@@ -0,0 +1,289 @@
"use client";
import { Badge, Card, Collapse, Group, Stack, Title, UnstyledButton } from "@mantine/core";
import { IconChevronDown, IconChevronUp } from "@tabler/icons-react";
import { useRequiredBoard } from "@homarr/boards/context";
import type {
Board,
CategorySection,
DynamicSectionItem,
EmptySection,
SectionItem,
} from "~/app/[locale]/boards/_types";
import { BoardItemContent } from "../items/item-content";
import { CategoryMenu } from "./category/category-menu";
import classes from "./item.module.css";
import {
getEstimatedGridWidth,
getFallbackDimensions,
getFullWidthSortedSections,
getStaticGridElements,
getStaticGridLayouts,
getStaticGridRowCount,
getStaticLayoutSelectionCss,
} from "./static-grid-layout";
import { useCategoryCollapse } from "./use-category-collapse";
interface StaticBoardGridProps {
board: Board;
}
export const StaticBoardGrid = ({ board }: StaticBoardGridProps) => {
const boardId = `board-${board.id}`;
const layouts = getStaticGridLayouts(board.layouts);
const layoutSelectionCss = getStaticLayoutSelectionCss(boardId, layouts);
const fullWidthSortedSections = getFullWidthSortedSections(board);
if (layouts.length === 0) return null;
return (
<>
<style>{layoutSelectionCss}</style>
<div data-static-board={boardId}>
{layouts.map((layout, index) => (
<div key={layout.id} className="static-board-layout" data-static-layout={layout.id}>
<Stack h="100%">
{fullWidthSortedSections.map((section) =>
section.kind === "empty" ? (
<StaticEmptySection
key={section.id}
section={section}
board={board}
layoutId={layout.id}
columnCount={layout.columnCount}
estimatedGridWidth={getEstimatedGridWidth(layout, layouts.at(index + 1))}
/>
) : (
<StaticCategorySection
key={section.id}
section={section}
board={board}
layoutId={layout.id}
columnCount={layout.columnCount}
estimatedGridWidth={getEstimatedGridWidth(layout, layouts.at(index + 1))}
/>
),
)}
</Stack>
</div>
))}
</div>
</>
);
};
interface StaticSectionGridProps {
sectionId: string;
board: Board;
layoutId: string;
columnCount: number;
estimatedGridWidth: number;
isDynamic?: boolean;
fixedRowCount?: number;
}
const StaticSectionGrid = ({
sectionId,
board,
layoutId,
columnCount,
estimatedGridWidth,
isDynamic,
fixedRowCount,
}: StaticSectionGridProps) => {
const allItems = getStaticGridElements(board, sectionId, layoutId);
if (allItems.length === 0) return null;
const rowCount = getStaticGridRowCount(allItems, fixedRowCount);
return (
<div className="static-grid-container" style={{ height: isDynamic ? "100%" : undefined }}>
<div
className="static-grid"
style={{
display: "grid",
gridTemplateColumns: `repeat(${columnCount}, 1fr)`,
gridTemplateRows: isDynamic
? `repeat(${rowCount}, 1fr)`
: `repeat(${rowCount}, calc(100cqi / ${columnCount}))`,
height: isDynamic ? "100%" : undefined,
}}
>
{allItems.map((item) => (
<StaticGridItem
key={item.id}
item={item}
board={board}
layoutId={layoutId}
estimatedGridWidth={estimatedGridWidth}
columnCount={columnCount}
/>
))}
</div>
</div>
);
};
interface StaticGridItemProps {
item: SectionItem | DynamicSectionItem;
board: Board;
layoutId: string;
estimatedGridWidth: number;
columnCount: number;
}
const StaticGridItem = ({ item, board, layoutId, estimatedGridWidth, columnCount }: StaticGridItemProps) => {
const fallbackDimensions = getFallbackDimensions(item, estimatedGridWidth, columnCount);
return (
<div
style={{
gridColumn: `${item.xOffset + 1} / span ${item.width}`,
gridRow: `${item.yOffset + 1} / span ${item.height}`,
position: "relative",
}}
>
<div className="static-grid-item-content">
{item.type === "item" ? (
<BoardItemContent item={item} fallbackDimensions={fallbackDimensions} />
) : (
<StaticDynamicSection
section={item}
board={board}
layoutId={layoutId}
estimatedGridWidth={fallbackDimensions.width}
/>
)}
</div>
</div>
);
};
interface StaticEmptySectionProps {
section: EmptySection;
board: Board;
layoutId: string;
columnCount: number;
estimatedGridWidth: number;
}
const StaticEmptySection = ({ section, board, layoutId, columnCount, estimatedGridWidth }: StaticEmptySectionProps) => {
const items = getStaticGridElements(board, section.id, layoutId);
if (items.length === 0) return null;
return (
<StaticSectionGrid
sectionId={section.id}
board={board}
layoutId={layoutId}
columnCount={columnCount}
estimatedGridWidth={estimatedGridWidth}
/>
);
};
interface StaticCategorySectionProps {
section: CategorySection;
board: Board;
layoutId: string;
columnCount: number;
estimatedGridWidth: number;
}
const StaticCategorySection = ({
section,
board,
layoutId,
columnCount,
estimatedGridWidth,
}: StaticCategorySectionProps) => {
const boardData = useRequiredBoard();
const [opened, { toggle }] = useCategoryCollapse(section);
return (
<Card
style={{ "--opacity": boardData.opacity / 100 }}
radius={boardData.itemRadius}
p={0}
className={classes.itemCard}
>
<Stack>
<Group wrap="nowrap" gap="sm">
<UnstyledButton w="100%" p="sm" onClick={toggle}>
<Group wrap="nowrap">
{opened ? <IconChevronUp size={20} /> : <IconChevronDown size={20} />}
<Title order={3}>{section.name}</Title>
</Group>
</UnstyledButton>
<CategoryMenu category={section} />
</Group>
<Collapse expanded={opened} p="sm" pt={0}>
<StaticSectionGrid
sectionId={section.id}
board={board}
layoutId={layoutId}
columnCount={columnCount}
estimatedGridWidth={estimatedGridWidth}
/>
</Collapse>
</Stack>
</Card>
);
};
interface StaticDynamicSectionProps {
section: DynamicSectionItem;
board: Board;
layoutId: string;
estimatedGridWidth: number;
}
const StaticDynamicSection = ({ section, board, layoutId, estimatedGridWidth }: StaticDynamicSectionProps) => {
const boardData = useRequiredBoard();
const options = section.options;
return (
<Card
className={classes.itemCard}
w="100%"
h="100%"
styles={{
root: {
overflow: "visible",
"--opacity": boardData.opacity / 100,
"--border-color": options.borderColor || undefined,
},
}}
radius={boardData.itemRadius}
p={0}
>
{options.title && (
<Badge
pos="absolute"
top={-15}
left={10}
size="md"
radius={boardData.itemRadius}
color="var(--background-color)"
c="var(--mantine-color-text)"
bd="1px solid var(--border-color)"
>
{options.title}
</Badge>
)}
<StaticSectionGrid
sectionId={section.id}
board={board}
layoutId={layoutId}
columnCount={section.width}
estimatedGridWidth={estimatedGridWidth}
isDynamic
fixedRowCount={section.height}
/>
</Card>
);
};
@@ -0,0 +1,77 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import type { CategorySection } from "~/app/[locale]/boards/_types";
export const getCategoryCollapseStorageKey = (sectionId: string) => `homarr-section-collapsed-${sectionId}`;
const categoryCollapseChangeEventName = "homarr-section-collapse-change";
interface CategoryExpandedChangeEventDetail {
sectionId: string;
expanded: boolean;
}
const getInitialExpanded = (section: CategorySection) => {
if (typeof window === "undefined") return section.collapsed;
return (localStorage.getItem(getCategoryCollapseStorageKey(section.id)) ?? String(section.collapsed)) === "true";
};
export const useCategoryCollapse = (section: CategorySection) => {
const { status } = useSession();
const { mutate } = clientApi.section.changeCollapsed.useMutation();
const [opened, setOpened] = useState(() => getInitialExpanded(section));
const persistExpanded = useCallback(
(expanded: boolean) => {
if (status === "authenticated") {
mutate({ sectionId: section.id, collapsed: expanded });
} else if (status === "unauthenticated") {
localStorage.setItem(getCategoryCollapseStorageKey(section.id), String(expanded));
}
},
[mutate, section.id, status],
);
useEffect(() => {
const handleCollapseChange = (event: Event) => {
const { sectionId, expanded } = (event as CustomEvent<CategoryExpandedChangeEventDetail>).detail;
if (sectionId === section.id) {
setOpened(expanded);
}
};
window.addEventListener(categoryCollapseChangeEventName, handleCollapseChange);
return () => {
window.removeEventListener(categoryCollapseChangeEventName, handleCollapseChange);
};
}, [section.id]);
const toggle = useCallback(() => {
setOpened((current) => {
const expanded = !current;
persistExpanded(expanded);
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent<CategoryExpandedChangeEventDetail>(categoryCollapseChangeEventName, {
detail: {
sectionId: section.id,
expanded,
},
}),
);
}
return expanded;
});
}, [persistExpanded, section.id]);
return [opened, { toggle }] as const;
};
+14
View File
@@ -112,3 +112,17 @@
.grid-stack-empty-wrapper {
display: none !important;
}
.static-grid-container {
container-type: inline-size;
}
.static-grid-item-content {
position: absolute;
inset: 10px;
> .grid-stack-item-content {
width: 100%;
height: 100%;
}
}
+304
View File
@@ -0,0 +1,304 @@
import { chromium } from "@playwright/test";
import { createId } from "@paralleldrive/cuid2";
import SuperJSON from "superjson";
import { describe, expect, test } from "vitest";
import * as sqliteSchema from "../packages/db/schema/sqlite";
import { hashPasswordAsync } from "../packages/auth/security";
import { createHomarrContainer } from "./shared/create-homarr-container";
import { createSqliteDbFileAsync } from "./shared/e2e-db";
import type { SqliteDatabase } from "./shared/e2e-db";
const boardName = "responsive-board";
const credentials = {
username: "responsive-admin",
password: "Comp(exP4sswOrd",
};
const viewports = [
{ width: 320, height: 568 },
{ width: 375, height: 667 },
{ width: 768, height: 1024 },
{ width: 1024, height: 768 },
{ width: 1366, height: 768 },
{ width: 1920, height: 1080 },
];
describe("Board responsive layout", () => {
test("renders static board layouts across common viewports and loads edit mode", async () => {
const { db, localMountPath } = await createSqliteDbFileAsync();
const { categorySectionId } = await seedResponsiveBoardAsync(db);
const homarrContainer = await createHomarrContainer({
mounts: {
"/appdata": localMountPath,
},
}).start();
const browser = await chromium.launch();
const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`;
try {
for (const viewport of viewports) {
const context = await browser.newContext({ viewport });
await context.addInitScript(() => {
window.__homarrLayoutShiftScore = 0;
new PerformanceObserver((entries) => {
for (const entry of entries.getEntries()) {
const layoutShift = entry as PerformanceEntry & { value: number; hadRecentInput: boolean };
if (!layoutShift.hadRecentInput) {
window.__homarrLayoutShiftScore += layoutShift.value;
}
}
}).observe({ type: "layout-shift", buffered: true });
});
await context.addInitScript(
({ sectionId }) => {
localStorage.setItem(`homarr-section-collapsed-${sectionId}`, "true");
},
{ sectionId: categorySectionId },
);
const page = await context.newPage();
await page.goto(`${baseUrl}/boards/${boardName}`);
await page.waitForSelector(".static-grid-item-content", { state: "visible" });
await page.waitForTimeout(500);
const result = await page.evaluate(() => {
const visibleLayout = Array.from(document.querySelectorAll<HTMLElement>(".static-board-layout")).find(
(element) => getComputedStyle(element).display !== "none",
);
const grids = Array.from(visibleLayout?.querySelectorAll<HTMLElement>(".static-grid") ?? []);
const hasOverlappingItems = grids.some((grid) => {
const rects = Array.from(grid.children)
.map((element) => element.getBoundingClientRect())
.filter((rect) => rect.width > 0 && rect.height > 0);
return rects.some((rect, index) =>
rects.slice(index + 1).some((otherRect) => {
const horizontalOverlap = Math.min(rect.right, otherRect.right) - Math.max(rect.left, otherRect.left);
const verticalOverlap = Math.min(rect.bottom, otherRect.bottom) - Math.max(rect.top, otherRect.top);
return horizontalOverlap > 1 && verticalOverlap > 1;
}),
);
});
const visibleItems = Array.from(
visibleLayout?.querySelectorAll<HTMLElement>(".static-grid-item-content") ?? [],
).filter((element) => {
const rect = element.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
});
return {
hasVisibleLayout: Boolean(visibleLayout),
visibleItemCount: visibleItems.length,
hasHorizontalOverflow: document.documentElement.scrollWidth > window.innerWidth + 1,
hasOverlappingItems,
layoutShiftScore: window.__homarrLayoutShiftScore,
};
});
expect(result.hasVisibleLayout).toBe(true);
expect(result.visibleItemCount).toBe(4);
expect(result.hasHorizontalOverflow).toBe(false);
expect(result.hasOverlappingItems).toBe(false);
expect(result.layoutShiftScore).toBeLessThanOrEqual(0.01);
await context.close();
}
const context = await browser.newContext({ viewport: { width: 1366, height: 768 } });
const page = await context.newPage();
await page.goto(`${baseUrl}/auth/login`);
await page.getByLabel("Username").fill(credentials.username);
await page.getByLabel("Password").fill(credentials.password);
await page.locator("css=button[type='submit']").click();
await page.waitForURL(baseUrl);
await page.goto(`${baseUrl}/boards/${boardName}`);
await page.waitForSelector(".static-grid-item-content", { state: "visible" });
await page.keyboard.press("ControlOrMeta+E");
await page.waitForSelector(".grid-stack", { state: "visible" });
await expect.poll(() => page.locator(".grid-stack-item").count()).toBeGreaterThan(0);
await context.close();
} finally {
await browser.close();
await homarrContainer.stop();
}
}, 120_000);
});
const seedResponsiveBoardAsync = async (db: SqliteDatabase) => {
const userId = createId();
const boardId = createId();
const mobileLayoutId = createId();
const tabletLayoutId = createId();
const desktopLayoutId = createId();
const rootSectionId = createId();
const categorySectionId = createId();
const dynamicSectionId = createId();
await db.update(sqliteSchema.onboarding).set({ step: "finish" });
await db.insert(sqliteSchema.users).values({
id: userId,
name: credentials.username,
password: await hashPasswordAsync(credentials.password),
});
await db.insert(sqliteSchema.boards).values({
id: boardId,
name: boardName,
isPublic: true,
creatorId: userId,
});
await db.insert(sqliteSchema.layouts).values([
{ id: mobileLayoutId, name: "Mobile", boardId, columnCount: 4, breakpoint: 0 },
{ id: tabletLayoutId, name: "Tablet", boardId, columnCount: 8, breakpoint: 768 },
{ id: desktopLayoutId, name: "Desktop", boardId, columnCount: 12, breakpoint: 1024 },
]);
await db.insert(sqliteSchema.sections).values([
{ id: rootSectionId, boardId, kind: "empty", xOffset: 0, yOffset: 0 },
{ id: categorySectionId, boardId, kind: "category", xOffset: 0, yOffset: 1, name: "Systems" },
{
id: dynamicSectionId,
boardId,
kind: "dynamic",
options: SuperJSON.stringify({ title: "Nested", borderColor: "", customCssClasses: [] }),
},
]);
await db.insert(sqliteSchema.sectionLayouts).values([
{
sectionId: dynamicSectionId,
layoutId: mobileLayoutId,
parentSectionId: categorySectionId,
xOffset: 0,
yOffset: 0,
width: 4,
height: 2,
},
{
sectionId: dynamicSectionId,
layoutId: tabletLayoutId,
parentSectionId: categorySectionId,
xOffset: 0,
yOffset: 0,
width: 4,
height: 2,
},
{
sectionId: dynamicSectionId,
layoutId: desktopLayoutId,
parentSectionId: categorySectionId,
xOffset: 0,
yOffset: 0,
width: 6,
height: 2,
},
]);
const rootItemId = await insertClockItemAsync(db, boardId);
const categoryItemId = await insertClockItemAsync(db, boardId);
const dynamicItemId = await insertClockItemAsync(db, boardId);
await db.insert(sqliteSchema.itemLayouts).values([
{
itemId: rootItemId,
sectionId: rootSectionId,
layoutId: mobileLayoutId,
xOffset: 0,
yOffset: 0,
width: 2,
height: 1,
},
{
itemId: rootItemId,
sectionId: rootSectionId,
layoutId: tabletLayoutId,
xOffset: 0,
yOffset: 0,
width: 3,
height: 1,
},
{
itemId: rootItemId,
sectionId: rootSectionId,
layoutId: desktopLayoutId,
xOffset: 0,
yOffset: 0,
width: 4,
height: 1,
},
{
itemId: categoryItemId,
sectionId: categorySectionId,
layoutId: mobileLayoutId,
xOffset: 0,
yOffset: 2,
width: 4,
height: 1,
},
{
itemId: categoryItemId,
sectionId: categorySectionId,
layoutId: tabletLayoutId,
xOffset: 4,
yOffset: 0,
width: 4,
height: 2,
},
{
itemId: categoryItemId,
sectionId: categorySectionId,
layoutId: desktopLayoutId,
xOffset: 6,
yOffset: 0,
width: 6,
height: 2,
},
{
itemId: dynamicItemId,
sectionId: dynamicSectionId,
layoutId: mobileLayoutId,
xOffset: 0,
yOffset: 0,
width: 2,
height: 1,
},
{
itemId: dynamicItemId,
sectionId: dynamicSectionId,
layoutId: tabletLayoutId,
xOffset: 0,
yOffset: 0,
width: 2,
height: 1,
},
{
itemId: dynamicItemId,
sectionId: dynamicSectionId,
layoutId: desktopLayoutId,
xOffset: 0,
yOffset: 0,
width: 3,
height: 1,
},
]);
return {
categorySectionId,
};
};
const insertClockItemAsync = async (db: SqliteDatabase, boardId: string) => {
const itemId = createId();
await db.insert(sqliteSchema.items).values({
id: itemId,
boardId,
kind: "clock",
options: SuperJSON.stringify({ is24HourFormat: true }),
advancedOptions: SuperJSON.stringify({ title: null, customCssClasses: [], borderColor: "" }),
});
return itemId;
};
declare global {
interface Window {
__homarrLayoutShiftScore: number;
}
}
-2
View File
@@ -25,8 +25,6 @@
"release": "semantic-release",
"scripts:update-bug-report-template": "tsx ./scripts/update-bug-report-template.mts",
"scripts:update-readme-integrations": "tsx ./scripts/update-integration-list.mts",
"scripts:generate-codeowners": "tsx ./scripts/generate-codeowners.mts",
"scripts:validate-codeowners": "tsx ./scripts/validate-codeowners.mts",
"start": "concurrently \"pnpm with-env node apps/tasks/tasks.cjs\" \"pnpm with-env node apps/websocket/wssServer.cjs\" \"pnpm -F nextjs start\"",
"test": "cross-env NODE_ENV=development CI=true vitest run --exclude e2e --coverage.enabled ",
"test:e2e": "cross-env NODE_ENV=development CI=true vitest e2e",
@@ -44,6 +44,7 @@ const optionMapping: OptionMapping = {
hideIcon: (oldOptions) => oldOptions.items.some((item) => item.hideIcon),
hideHostname: (oldOptions) => oldOptions.items.some((item) => item.hideHostname),
openNewTab: (oldOptions) => oldOptions.items.some((item) => item.openNewTab),
withBorder: () => undefined,
},
calendar: {
releaseType: (oldOptions) => [oldOptions.radarrReleaseType],
+2 -6
View File
@@ -1,7 +1,7 @@
import type { ComponentType } from "react";
import type { Loader } from "next/dynamic";
import dynamic from "next/dynamic";
import { Center, Loader as UiLoader } from "@mantine/core";
import { Skeleton } from "@mantine/core";
import { objectEntries } from "@homarr/common";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
@@ -107,11 +107,7 @@ export const loadWidgetDynamic = <TKind extends WidgetKind>(kind: TKind) => {
const newlyLoadedComponent = dynamic<WidgetComponentProps<TKind>>(
widgetImports[kind].componentLoader as Loader<WidgetComponentProps<TKind>>,
{
loading: () => (
<Center w="100%" h="100%">
<UiLoader />
</Center>
),
loading: () => <Skeleton w="100%" h="100%" radius="md" animate />,
},
);
+4
View File
@@ -11,6 +11,8 @@ import prefetchForApps from "./app/prefetch";
import prefetchForBookmarks from "./bookmarks/prefetch";
import type { Prefetch, WidgetOptionsRecordOf } from "./definition";
import type { inferOptionsFromCreator } from "./options";
import prefetchRssFeedsAsync from "./rssFeed/prefetch";
import prefetchWeatherAsync from "./weather/prefetch";
const cachedGetServerSettingsAsync = cache(getServerSettingsAsync);
@@ -19,6 +21,8 @@ const prefetchCallbacks: Partial<{
}> = {
bookmarks: prefetchForBookmarks,
app: prefetchForApps,
weather: prefetchWeatherAsync,
rssFeed: prefetchRssFeedsAsync,
};
export const prefetchForKindAsync = async <TKind extends WidgetKind>(
+22
View File
@@ -0,0 +1,22 @@
import { api, trpc } from "@homarr/api/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { Prefetch } from "../definition";
const logger = createLogger({ module: "rssFeedWidgetPrefetch" });
const prefetchRssFeedsAsync: Prefetch<"rssFeed"> = async (queryClient, items) => {
for (const item of items) {
const input = {
urls: item.options.feedUrls,
maximumAmountPosts: item.options.maximumAmountPosts,
};
const data = await api.widget.rssFeed.getFeeds(input);
queryClient.setQueryData(trpc.widget.rssFeed.getFeeds.queryKey(input), data);
}
logger.info("Successfully prefetched RSS feeds", { count: items.length });
};
export default prefetchRssFeedsAsync;
+29
View File
@@ -0,0 +1,29 @@
import { api, trpc } from "@homarr/api/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { Prefetch } from "../definition";
const logger = createLogger({ module: "weatherWidgetPrefetch" });
const prefetchWeatherAsync: Prefetch<"weather"> = async (queryClient, items) => {
const locations = items.map((item) => ({
latitude: item.options.location.latitude,
longitude: item.options.location.longitude,
}));
const uniqueLocations = locations.filter(
(loc, idx, arr) =>
arr.findIndex((other) => other.latitude === loc.latitude && other.longitude === loc.longitude) === idx,
);
await Promise.all(
uniqueLocations.map(async (input) => {
const data = await api.widget.weather.atLocation(input);
queryClient.setQueryData(trpc.widget.weather.atLocation.queryKey(input), data);
}),
);
logger.info("Successfully prefetched weather data", { count: uniqueLocations.length });
};
export default prefetchWeatherAsync;
-11
View File
@@ -1,11 +0,0 @@
{
"meierschlumpf@gmail.com": "manuel-rw",
"thomas@ajnart.fr": "ajnart",
"omariqbal97@gmail.com": "omariqbal",
"kyle.grieder@gmail.com": "kylegrieder",
"oupsman@oupsman.fr": "Oupsman",
"pitschi@posteo.de": "pitschi",
"github@xela.codes": "itzTheMeow",
"febrile42@gmail.com": "febrile42",
"nanewman01@gmail.com": "NicolasNewman"
}
-309
View File
@@ -1,309 +0,0 @@
import { spawnSync } from "node:child_process";
import { existsSync, readdirSync, statSync } from "node:fs";
import { readFile, writeFile } from "node:fs/promises";
import { join, relative } from "node:path";
const REPO_ROOT = join(import.meta.dirname, "..");
const CODEOWNERS_PATH = join(REPO_ROOT, "CODEOWNERS");
const OVERRIDES_PATH = join(REPO_ROOT, "scripts/codeowners-overrides.json");
const BEGIN_MARKER = "# BEGIN GENERATED widget/integration owners";
const END_MARKER = "# END GENERATED widget/integration owners";
const WIDGET_SKIP = new Set(["_inputs", "errors", "modals", "test"]);
const INTEGRATION_SKIP = new Set(["base", "interfaces", "mock"]);
const BOT_SUBSTRINGS = ["[bot]", "renovate", "homarr-update", "dependabot", "github-actions"];
const WIDGET_ROUTER_ALIASES: Record<string, string> = {
"anchor-note": "anchor-notes",
"media-releases": "media-release",
rssFeed: "rssFeed",
stocks: "stocks",
};
const INTEGRATION_CRON_ALIASES: Record<string, string> = {
homeassistant: "home-assistant",
"download-client": "downloads",
"media-organizer": "media-organizer",
"pi-hole": "dns-hole",
"smart-home": "home-assistant",
};
const WIDGET_TO_INTEGRATION_DIR: Record<string, string> = {
"anchor-note": "anchor",
};
const NESTED_WIDGET_ROUTER_PARENT: Record<string, string> = {
"dns-hole": "dns-hole",
immich: "immich",
"media-requests": "media-requests",
"network-controller": "network-controller",
minecraft: "minecraft",
"smart-home": "smart-home",
};
type Overrides = Record<string, string>;
const isBotEmail = (email: string) => BOT_SUBSTRINGS.some((fragment) => email.toLowerCase().includes(fragment));
const gitEmailForPath = (repoPath: string) => {
const strategies = [
["log", "--grep=feat", "-i", "--format=%ae", "--reverse", "-1", "--", repoPath],
["log", "--diff-filter=A", "--format=%ae", "--reverse", "-1", "--", repoPath],
["log", "--format=%ae", "--reverse", "-1", "--", repoPath],
] as const;
for (const args of strategies) {
const result = spawnSync("git", args, { cwd: REPO_ROOT, encoding: "utf8" });
const email = (result.stdout ?? "").trim();
if (email && !isBotEmail(email)) return email;
}
return "";
};
const noreplyHandle = (email: string) => {
const match = email.match(/^\d+\+([^@]+)@users\.noreply\.github\.com$/i);
return match?.[1] ?? null;
};
const resolveHandle = (email: string, overrides: Overrides) => {
const fromNoreply = noreplyHandle(email);
if (fromNoreply) return fromNoreply;
return overrides[email] ?? null;
};
const hasLeafMarker = (dirPath: string) => {
const names = readdirSync(dirPath);
const leafPatterns = ["-integration.ts", "component.tsx", "index.ts", "index.tsx"];
return names.some((name) => leafPatterns.some((suffix) => name.endsWith(suffix) || name === suffix));
};
const discoverLeafDirs = (base: string, skip: Set<string>) => {
const absoluteBase = join(REPO_ROOT, base);
const leaves: string[] = [];
for (const name of readdirSync(absoluteBase).sort()) {
const dirPath = join(absoluteBase, name);
if (!statSync(dirPath).isDirectory() || skip.has(name)) continue;
if (hasLeafMarker(dirPath)) {
leaves.push(join(base, name));
continue;
}
const nestedLeaves = readdirSync(dirPath)
.filter((sub) => statSync(join(dirPath, sub)).isDirectory())
.filter((sub) => hasLeafMarker(join(dirPath, sub)))
.map((sub) => join(base, name, sub));
leaves.push(...(nestedLeaves.length > 0 ? nestedLeaves : [join(base, name)]));
}
return leaves;
};
const relFromBase = (fullPath: string, base: string) => relative(join(REPO_ROOT, base), join(REPO_ROOT, fullPath));
const widgetRouterKey = (widgetRel: string) => {
const direct = WIDGET_ROUTER_ALIASES[widgetRel];
if (direct) return direct;
const segment = widgetRel.includes("/") ? (widgetRel.split("/")[0] ?? widgetRel) : widgetRel;
const parentAlias = NESTED_WIDGET_ROUTER_PARENT[segment];
const segmentAlias = WIDGET_ROUTER_ALIASES[segment];
return parentAlias ?? segmentAlias ?? segment;
};
const integrationCronKey = (integrationRel: string) => {
const segment = integrationRel.includes("/") ? (integrationRel.split("/")[0] ?? integrationRel) : integrationRel;
return INTEGRATION_CRON_ALIASES[segment] ?? INTEGRATION_CRON_ALIASES[integrationRel] ?? segment;
};
const existingFile = (repoPath: string) => {
const full = join(REPO_ROOT, repoPath);
return existsSync(full) ? `/${repoPath}` : null;
};
const widgetSatellites = (widgetRel: string) => {
const routerKey = widgetRouterKey(widgetRel);
const cronKey = integrationCronKey(widgetRel);
return [
existingFile(`packages/api/src/router/widgets/${routerKey}.ts`),
existingFile(`packages/cron-jobs/src/jobs/integrations/${cronKey}.ts`),
existingFile(`packages/cron-jobs/src/jobs/${cronKey}.ts`),
].filter((path): path is string => path !== null);
};
const integrationSatellites = (integrationRel: string) => {
const cronKey = integrationCronKey(integrationRel);
return [
existingFile(`packages/cron-jobs/src/jobs/integrations/${cronKey}.ts`),
existingFile(`packages/cron-jobs/src/jobs/${cronKey}.ts`),
].filter((path): path is string => path !== null);
};
const dirCodeownersPath = (repoDir: string) => `/${repoDir}/`;
const integrationRelForWidget = (widgetRel: string) => {
const mapped = WIDGET_TO_INTEGRATION_DIR[widgetRel] ?? WIDGET_TO_INTEGRATION_DIR[widgetRel.split("/")[0] ?? ""];
if (mapped) return mapped;
const direct = join("packages/integrations/src", widgetRel);
if (existsSync(join(REPO_ROOT, direct))) return widgetRel;
const segment = widgetRel.split("/")[0] ?? widgetRel;
const segmentPath = join("packages/integrations/src", segment);
if (existsSync(join(REPO_ROOT, segmentPath))) return segment;
return null;
};
const collectPathsForWidget = (widgetRel: string) => {
const paths = new Set<string>([dirCodeownersPath(`packages/widgets/src/${widgetRel}`), ...widgetSatellites(widgetRel)]);
const integrationRel = integrationRelForWidget(widgetRel);
if (integrationRel) {
paths.add(dirCodeownersPath(`packages/integrations/src/${integrationRel}`));
for (const satellite of integrationSatellites(integrationRel)) paths.add(satellite);
}
return [...paths];
};
const collectPathsForIntegration = (integrationRel: string) => {
const paths = new Set<string>([
dirCodeownersPath(`packages/integrations/src/${integrationRel}`),
...integrationSatellites(integrationRel),
]);
const widgetPath = join(REPO_ROOT, "packages/widgets/src", integrationRel);
if (existsSync(widgetPath)) {
paths.add(dirCodeownersPath(`packages/widgets/src/${integrationRel}`));
for (const satellite of widgetSatellites(integrationRel)) paths.add(satellite);
}
return [...paths];
};
const loadOverrides = async () => JSON.parse(await readFile(OVERRIDES_PATH, "utf8")) as Overrides;
const resolveGithubUser = async (email: string, overrides: Overrides) => {
const fromOverrides = resolveHandle(email, overrides);
if (fromOverrides) return fromOverrides;
const token = process.env.GITHUB_TOKEN;
if (!token) return null;
const response = await fetch(`https://api.github.com/search/users?q=${encodeURIComponent(email)}`, {
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
});
if (!response.ok) return null;
const data = (await response.json()) as { items: { login: string }[] };
return data.items[0]?.login ?? null;
};
type OwnerEntry = { primary: string; line: string };
const buildOwnerEntries = async (overrides: Overrides) => {
const entries: OwnerEntry[] = [];
const unresolved: { path: string; email: string }[] = [];
const claimedDirs = new Set<string>();
const addUnit = async (repoDir: string, collectPaths: (rel: string) => string[]) => {
if (claimedDirs.has(repoDir)) return;
const rel =
repoDir.startsWith("packages/widgets/") ?
relFromBase(repoDir, "packages/widgets/src")
: relFromBase(repoDir, "packages/integrations/src");
const email = gitEmailForPath(`${repoDir}/`);
if (!email) {
unresolved.push({ path: repoDir, email: "" });
return;
}
const handle = await resolveGithubUser(email, overrides);
if (!handle) {
unresolved.push({ path: repoDir, email });
return;
}
const paths = collectPaths(rel).sort();
const primary = paths[0] ?? `/${repoDir}/`;
entries.push({ primary, line: `${paths.join(" ")} @${handle}` });
claimedDirs.add(repoDir);
for (const path of paths) {
if (!path.endsWith("/")) continue;
claimedDirs.add(path.slice(1, -1));
}
};
for (const widgetDir of discoverLeafDirs("packages/widgets/src", WIDGET_SKIP)) {
await addUnit(widgetDir, collectPathsForWidget);
}
for (const integrationDir of discoverLeafDirs("packages/integrations/src", INTEGRATION_SKIP)) {
await addUnit(integrationDir, collectPathsForIntegration);
}
entries.sort((a, b) => a.primary.localeCompare(b.primary));
return { entries, unresolved };
};
const formatGeneratedBlock = (entries: OwnerEntry[]) => {
const lines = entries.map((entry) => entry.line);
return [BEGIN_MARKER, ...lines, END_MARKER].join("\n");
};
const replaceGeneratedSection = (content: string, generatedBlock: string) => {
const startIndex = content.indexOf(BEGIN_MARKER);
const endIndex = content.indexOf(END_MARKER);
if (startIndex === -1 || endIndex === -1) {
return `${content.trimEnd()}\n\n${generatedBlock}\n`;
}
const before = content.slice(0, startIndex).trimEnd();
const after = content.slice(endIndex + END_MARKER.length).trimStart();
const middle = generatedBlock;
const chunks = [before, middle, after].filter((chunk) => chunk.length > 0);
return `${chunks.join("\n\n")}\n`;
};
const main = async () => {
const overrides = await loadOverrides();
const { entries, unresolved } = await buildOwnerEntries(overrides);
const generatedBlock = formatGeneratedBlock(entries);
const current = await readFile(CODEOWNERS_PATH, "utf8");
const next = replaceGeneratedSection(current, generatedBlock);
const checkOnly = process.argv.includes("--check");
if (unresolved.length > 0) {
console.warn("Unresolved ownership units (add to scripts/codeowners-overrides.json):");
for (const entry of unresolved) console.warn(` ${entry.path} (${entry.email || "no email"})`);
}
if (checkOnly) {
if (next !== current) {
console.error("CODEOWNERS is out of date. Run: pnpm scripts:generate-codeowners");
process.exit(1);
}
return;
}
await writeFile(CODEOWNERS_PATH, next, "utf8");
console.log(`Updated CODEOWNERS with ${entries.length} feature units.`);
};
await main();
-17
View File
@@ -1,17 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
echo "==> Local CODEOWNERS checks"
pnpm exec tsx ./scripts/generate-codeowners.mts --check
export GITHUB_REPOSITORY="${GITHUB_REPOSITORY:-homarr-labs/homarr}"
if [ -z "${GITHUB_TOKEN:-}" ] && [ -z "${GH_TOKEN:-}" ]; then
GITHUB_TOKEN="$(gh auth token 2>/dev/null || true)"
export GITHUB_TOKEN
fi
pnpm exec tsx ./scripts/validate-codeowners.mts
echo "==> act: validate job (requires Docker)"
act workflow_dispatch -W .github/workflows/update-codeowners.yml -j validate \
-s GITHUB_TOKEN="${GITHUB_TOKEN:-}"
-145
View File
@@ -1,145 +0,0 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
const REPO_ROOT = join(import.meta.dirname, "..");
const CODEOWNERS_PATH = join(REPO_ROOT, "CODEOWNERS");
const BEGIN_MARKER = "# BEGIN GENERATED widget/integration owners";
const END_MARKER = "# END GENERATED widget/integration owners";
const UNSUPPORTED_PATTERN_CHECKS: { label: string; pattern: RegExp }[] = [
{ label: "negation (!)", pattern: /^\s*!/ },
{ label: "character range ([ ])", pattern: /^\s*[^#]*\[[^\]]+\]/ },
];
const parseOwners = (line: string) => [...line.matchAll(/@[\w-]+(?:\/[\w-]+)?/g)].map((match) => match[0]);
const parsePatterns = (line: string) => {
const withoutOwners = line.replace(/@[\w-]+(?:\/[\w-]+)?/g, "").trim();
return withoutOwners.split(/\s+/).filter((token) => token.length > 0 && !token.startsWith("#"));
};
const validateLine = (line: string, lineNumber: number) => {
const trimmed = line.trim();
if (trimmed.length === 0 || trimmed.startsWith("#")) return [];
const errors: string[] = [];
for (const check of UNSUPPORTED_PATTERN_CHECKS) {
if (check.pattern.test(line)) {
errors.push(`line ${lineNumber}: unsupported CODEOWNERS syntax (${check.label})`);
}
}
const owners = parseOwners(line);
const patterns = parsePatterns(line);
if (patterns.length === 0 && owners.length === 0) return errors;
if (patterns.length > 0 && owners.length === 0) return errors;
if (owners.length > 0 && patterns.length === 0) {
errors.push(`line ${lineNumber}: owners without file patterns`);
}
for (const pattern of patterns) {
const allowedGlobal = pattern === "*";
const hasValidShape =
allowedGlobal || pattern.startsWith("/") || pattern.includes("*") || pattern.includes(".");
if (!hasValidShape) {
errors.push(`line ${lineNumber}: pattern "${pattern}" should start with / or contain a wildcard`);
}
}
for (const owner of owners) {
if (!owner.startsWith("@")) {
errors.push(`line ${lineNumber}: invalid owner "${owner}"`);
}
}
return errors;
};
const validateStructure = (content: string) => {
const errors: string[] = [];
const lines = content.split("\n");
if (!content.includes("* @")) {
errors.push("missing default owner rule (* @team or * @user) — last matching pattern wins per GitHub docs");
}
const starIndex = lines.findIndex((line) => /^\*(\s+|$)/.test(line.trim()));
const beginIndex = lines.findIndex((line) => line.includes(BEGIN_MARKER));
const endIndex = lines.findIndex((line) => line.includes(END_MARKER));
if (beginIndex === -1 || endIndex === -1) {
errors.push("missing generated CODEOWNERS markers");
return errors;
}
if (starIndex !== -1 && beginIndex !== -1 && starIndex > beginIndex) {
errors.push("global * rule must appear before generated feature rules so per-folder owners override maintainers");
}
if (endIndex <= beginIndex) {
errors.push("END GENERATED marker must follow BEGIN GENERATED marker");
}
for (let index = 0; index < lines.length; index += 1) {
const lineErrors = validateLine(lines[index] ?? "", index + 1);
errors.push(...lineErrors);
}
return errors;
};
const validateGithubApi = async () => {
const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN;
const repository = process.env.GITHUB_REPOSITORY ?? "homarr-labs/homarr";
if (!token) {
console.warn("Skipping GitHub API CODEOWNERS check (GITHUB_TOKEN not set).");
return;
}
const response = await fetch(`https://api.github.com/repos/${repository}/codeowners/errors`, {
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
});
if (!response.ok) {
throw new Error(`GitHub API codeowners/errors failed: ${response.status} ${response.statusText}`);
}
const payload = (await response.json()) as { errors: unknown[] };
const apiErrors = payload.errors ?? [];
if (apiErrors.length > 0) {
console.error("GitHub reported CODEOWNERS syntax errors:");
console.error(JSON.stringify(apiErrors, null, 2));
process.exit(1);
}
console.log("GitHub API: no CODEOWNERS syntax errors.");
};
const main = async () => {
const content = await readFile(CODEOWNERS_PATH, "utf8");
const errors = validateStructure(content);
if (errors.length > 0) {
console.error("CODEOWNERS validation failed:");
for (const error of errors) {
console.error(` ${error}`);
}
process.exit(1);
}
console.log("CODEOWNERS structure validation passed.");
await validateGithubApi();
};
await main();