From f051ae0ca9abe52dfff526d817597131f17a6d14 Mon Sep 17 00:00:00 2001 From: Maksym Yezhov Date: Tue, 16 Dec 2025 14:32:36 -0800 Subject: [PATCH] feat: optional yaml libraries --- .../GitHubLibrary/ManageLibrariesDialog.tsx | 39 +++- .../AddYamlLibraryDialogContent.tsx | 181 +++++++++++++++ .../components/TokenStatusButton.tsx | 51 ++++- src/components/shared/GitHubLibrary/types.ts | 21 ++ .../GitHubLibrary/utils/ensureYamlLibrary.ts | 41 ++++ .../shared/GitHubLibrary/utils/libraryId.ts | 4 + .../ComponentLibraryProvider.tsx | 19 +- .../libraries/types.ts | 8 + .../libraries/utils.ts | 41 ++++ .../libraries/yamlFileLibrary.test.ts | 216 ++++++++++++++++++ .../libraries/yamlFileLibrary.ts | 162 +++++++++++++ 11 files changed, 773 insertions(+), 10 deletions(-) create mode 100644 src/components/shared/GitHubLibrary/components/AddYamlLibraryDialogContent.tsx create mode 100644 src/components/shared/GitHubLibrary/utils/ensureYamlLibrary.ts create mode 100644 src/providers/ComponentLibraryProvider/libraries/yamlFileLibrary.test.ts create mode 100644 src/providers/ComponentLibraryProvider/libraries/yamlFileLibrary.ts diff --git a/src/components/shared/GitHubLibrary/ManageLibrariesDialog.tsx b/src/components/shared/GitHubLibrary/ManageLibrariesDialog.tsx index 99e52994e..44c49e3c3 100644 --- a/src/components/shared/GitHubLibrary/ManageLibrariesDialog.tsx +++ b/src/components/shared/GitHubLibrary/ManageLibrariesDialog.tsx @@ -15,16 +15,19 @@ import { Separator } from "@/components/ui/separator"; import type { StoredLibrary } from "@/providers/ComponentLibraryProvider/libraries/storage"; import { AddGitHubLibraryDialogContent } from "./components/AddGitHubLibraryDialogContent"; +import { AddYamlLibraryDialogContent } from "./components/AddYamlLibraryDialogContent"; import { LibraryList } from "./components/LibraryList"; import { UpdateGitHubLibrary } from "./components/UpdateGitHubLibrary"; export function ManageLibrariesDialog({ defaultMode = "manage", }: { - defaultMode?: "add" | "manage" | "update"; + defaultMode?: "add" | "addYaml" | "manage" | "update"; }) { const [open, setOpen] = useState(false); - const [mode, setMode] = useState<"add" | "manage" | "update">(defaultMode); + const [mode, setMode] = useState<"add" | "addYaml" | "manage" | "update">( + defaultMode, + ); const [libraryToUpdate, setLibraryToUpdate] = useState< StoredLibrary | undefined >(); @@ -75,6 +78,29 @@ export function ManageLibrariesDialog({ )} + {mode === "addYaml" && ( + <> + + + + + Add Library + + + + + setMode("manage")} + /> + + )} + {mode === "manage" && ( <> @@ -93,6 +119,15 @@ export function ManageLibrariesDialog({ Link Library from GitHub + diff --git a/src/components/shared/GitHubLibrary/components/AddYamlLibraryDialogContent.tsx b/src/components/shared/GitHubLibrary/components/AddYamlLibraryDialogContent.tsx new file mode 100644 index 000000000..5dccc16d8 --- /dev/null +++ b/src/components/shared/GitHubLibrary/components/AddYamlLibraryDialogContent.tsx @@ -0,0 +1,181 @@ +import { useMutation } from "@tanstack/react-query"; +import { useReducer, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { BlockStack, InlineStack } from "@/components/ui/layout"; +import { Spinner } from "@/components/ui/spinner"; +import { Paragraph, Text } from "@/components/ui/typography"; +import useToastNotification from "@/hooks/useToastNotification"; +import { cn } from "@/lib/utils"; +import { fetchWithCache } from "@/providers/ComponentLibraryProvider/libraries/utils"; +import { isValidComponentLibrary } from "@/types/componentLibrary"; +import { loadObjectFromYamlData } from "@/utils/cache"; + +import { ensureYamlLibrary } from "../utils/ensureYamlLibrary"; +import { validatePAT } from "../utils/validatePAT"; +import { InputField } from "./InputField"; + +export const AddYamlLibraryDialogContent = ({ + onOkClick, +}: { + onOkClick: () => void; +}) => { + return ( + + + + ); +}; + +interface YamlLibraryProcessState { + yamlUrl: string; + accessToken: string; + hasErrors: boolean; +} + +type YamlLibraryFormAction = { + type: "UPDATE"; + payload: Partial>; +}; + +function addGithubLibraryReducer( + state: YamlLibraryProcessState, + action: YamlLibraryFormAction, +): YamlLibraryProcessState { + switch (action.type) { + case "UPDATE": { + const newState = { ...state, ...action.payload }; + return { + ...newState, + hasErrors: !isStateValid(newState), + }; + } + default: + return state; + } +} + +function isStateValid(state: YamlLibraryProcessState): boolean { + return ( + !validateYamlUrl(state.yamlUrl)?.length && + (!state.accessToken || !validatePAT(state.accessToken)?.length) + ); +} + +function validateYamlUrl(url: string): string[] | null { + if (!url) return ["YAML URL is required"]; + // todo: add more validation for YAML URL + return url.startsWith("http") + ? null + : ["Invalid YAML URL. Must start with http"]; +} + +function ConfigureImport({ onSuccess }: { onSuccess: () => void }) { + const notify = useToastNotification(); + + const initialState: YamlLibraryProcessState = { + yamlUrl: "", + accessToken: "", + hasErrors: true, + }; + + const [state, dispatch] = useReducer(addGithubLibraryReducer, initialState); + const [processError, setProcessError] = useState(null); + + const { mutate: importYamlLibrary, isPending } = useMutation({ + mutationFn: async (state: YamlLibraryProcessState) => { + const response = await fetchWithCache(state.yamlUrl); + + if (!response.ok) { + throw new Error(`Failed to fetch ${state.yamlUrl}: ${response.status}`); + } + + const data = await response.arrayBuffer(); + + const yamlData = loadObjectFromYamlData(data); + + if (!isValidComponentLibrary(yamlData)) { + throw new Error(`Invalid component library: ${state.yamlUrl}`); + } + + const name = + (yamlData.annotations?.name as string) ?? + state.yamlUrl.split("/").pop()?.split(".")[0] ?? + "YAML Library"; + + await ensureYamlLibrary({ + name, + yamlUrl: state.yamlUrl, + accessToken: state.accessToken, + }); + + return yamlData; + }, + onSuccess: () => { + notify(`Successfully fetched library`, "success"); + onSuccess(); + }, + onError: (error) => { + notify(`Error importing YAML library: ${error.message}`, "error"); + setProcessError(error.message); + }, + }); + + const handleSubmit = async () => { + if (state.hasErrors) { + notify("Please fill in all fields", "error"); + return; + } + + setProcessError(null); + + await importYamlLibrary(state); + }; + + return ( + + + You can use a Personal Access Token to access private repositories. + Connect a YAML file to import components from your library. + + {processError && ( + + {processError} + + )} + { + dispatch({ type: "UPDATE", payload: { yamlUrl: value ?? "" } }); + }} + /> + { + dispatch({ type: "UPDATE", payload: { accessToken: value ?? "" } }); + }} + /> + + + + + + ); +} diff --git a/src/components/shared/GitHubLibrary/components/TokenStatusButton.tsx b/src/components/shared/GitHubLibrary/components/TokenStatusButton.tsx index ff2c4863c..068bcc801 100644 --- a/src/components/shared/GitHubLibrary/components/TokenStatusButton.tsx +++ b/src/components/shared/GitHubLibrary/components/TokenStatusButton.tsx @@ -4,10 +4,14 @@ import TooltipButton from "@/components/shared/Buttons/TooltipButton"; import { HOURS } from "@/components/shared/ComponentEditor/constants"; import { withSuspenseWrapper } from "@/components/shared/SuspenseWrapper"; import { Icon } from "@/components/ui/icon"; +import { Spinner } from "@/components/ui/spinner"; import { cn } from "@/lib/utils"; import type { StoredLibrary } from "@/providers/ComponentLibraryProvider/libraries/storage"; -import { isGitHubLibraryConfiguration } from "../types"; +import { + isGitHubLibraryConfiguration, + isYamlLibraryConfiguration, +} from "../types"; import { checkPATStatus } from "../utils/checkPATStatus"; export const TokenStatusButton = withSuspenseWrapper( @@ -21,17 +25,39 @@ export const TokenStatusButton = withSuspenseWrapper( const { data: tokenStatus } = useSuspenseQuery({ queryKey: ["github-token-status", library.id], queryFn: async () => { - if (!isGitHubLibraryConfiguration(library.configuration)) { - throw new Error("Invalid library configuration"); + if (isGitHubLibraryConfiguration(library.configuration)) { + return checkPATStatus( + library.configuration.repo_name, + library.configuration.access_token, + ); } - return checkPATStatus( - library.configuration.repo_name, - library.configuration.access_token, - ); + if (isYamlLibraryConfiguration(library.configuration)) { + // todo: check PAT status for YAML library + return true; + } + + throw new Error("Invalid library configuration"); }, staleTime: 1 * HOURS, }); + + if ( + isYamlLibraryConfiguration(library.configuration) && + !library.configuration.access_token + ) { + return ( + + + + ); + } + return ( ); }, + () => , + ({ error }) => ( + + + + ), ); diff --git a/src/components/shared/GitHubLibrary/types.ts b/src/components/shared/GitHubLibrary/types.ts index 9142f4062..a1eb23af6 100644 --- a/src/components/shared/GitHubLibrary/types.ts +++ b/src/components/shared/GitHubLibrary/types.ts @@ -19,3 +19,24 @@ export function isGitHubLibraryConfiguration( "auto_update" in configuration ); } + +interface YamlLibraryConfiguration { + created_at: string; + last_updated_at: string; + yaml_url: string; + access_token: string | undefined; + auto_update: boolean; +} + +export function isYamlLibraryConfiguration( + configuration: any, +): configuration is YamlLibraryConfiguration { + return ( + typeof configuration === "object" && + configuration !== null && + "created_at" in configuration && + "last_updated_at" in configuration && + "yaml_url" in configuration && + "auto_update" in configuration + ); +} diff --git a/src/components/shared/GitHubLibrary/utils/ensureYamlLibrary.ts b/src/components/shared/GitHubLibrary/utils/ensureYamlLibrary.ts new file mode 100644 index 000000000..e998677cf --- /dev/null +++ b/src/components/shared/GitHubLibrary/utils/ensureYamlLibrary.ts @@ -0,0 +1,41 @@ +import { LibraryDB } from "@/providers/ComponentLibraryProvider/libraries/storage"; + +import { getYamlLibraryId } from "./libraryId"; + +interface CreateYamlLibraryOptions { + name: string; + yamlUrl: string; + accessToken: string; +} + +export async function ensureYamlLibrary({ + name, + yamlUrl, + accessToken, +}: CreateYamlLibraryOptions) { + const id = getYamlLibraryId(yamlUrl); + + const existingLibrary = await LibraryDB.component_libraries.get(id); + + if (existingLibrary) { + return existingLibrary; + } + + await LibraryDB.component_libraries.put({ + id, + name, + icon: "FolderGit", + type: "yaml", + knownDigests: [], + configuration: { + created_at: new Date().toISOString(), + last_updated_at: new Date().toISOString(), + yaml_url: yamlUrl, + access_token: accessToken, + auto_update: true, + }, + components: [], + }); + + return await LibraryDB.component_libraries.get(id); +} diff --git a/src/components/shared/GitHubLibrary/utils/libraryId.ts b/src/components/shared/GitHubLibrary/utils/libraryId.ts index ad0a1854f..9a1f5fadf 100644 --- a/src/components/shared/GitHubLibrary/utils/libraryId.ts +++ b/src/components/shared/GitHubLibrary/utils/libraryId.ts @@ -6,3 +6,7 @@ export function getGitHubLibraryId(repoName: string) { return `github__${repoName.toLowerCase().replace(/ /g, "_")}`; } + +export function getYamlLibraryId(yamlUrl: string) { + return `yaml__${yamlUrl.toLowerCase().replace(/ /g, "_")}`; +} diff --git a/src/providers/ComponentLibraryProvider/ComponentLibraryProvider.tsx b/src/providers/ComponentLibraryProvider/ComponentLibraryProvider.tsx index b3fe94be3..d17972d93 100644 --- a/src/providers/ComponentLibraryProvider/ComponentLibraryProvider.tsx +++ b/src/providers/ComponentLibraryProvider/ComponentLibraryProvider.tsx @@ -10,7 +10,10 @@ import { import ComponentDuplicateDialog from "@/components/shared/Dialogs/ComponentDuplicateDialog"; import { GitHubFlatComponentLibrary } from "@/components/shared/GitHubLibrary/githubFlatComponentLibrary"; -import { isGitHubLibraryConfiguration } from "@/components/shared/GitHubLibrary/types"; +import { + isGitHubLibraryConfiguration, + isYamlLibraryConfiguration, +} from "@/components/shared/GitHubLibrary/types"; import { fetchAndStoreComponentLibrary, hydrateComponentReference, @@ -63,6 +66,7 @@ import { import { PublishedComponentsLibrary } from "./libraries/publishedComponentsLibrary"; import { LibraryDB, type StoredLibrary } from "./libraries/storage"; import type { Library } from "./libraries/types"; +import { YamlFileLibrary } from "./libraries/yamlFileLibrary"; type AvailableComponentLibraries = "published_components" | string; @@ -111,6 +115,19 @@ registerLibraryFactory("github", (library) => { return new GitHubFlatComponentLibrary(library.configuration.repo_name); }); +/** + * Register the GitHub library factory. This allows to have multiple instances of the same library type. + */ +registerLibraryFactory("yaml", (library) => { + if (!isYamlLibraryConfiguration(library.configuration)) { + throw new Error( + `YAML library configuration is not valid for "${library.id}"`, + ); + } + + return new YamlFileLibrary(library.name, library.configuration.yaml_url); +}); + function useComponentLibraryRegistry() { const queryClient = useQueryClient(); const [existingComponentLibraries, setExistingComponentLibraries] = useState< diff --git a/src/providers/ComponentLibraryProvider/libraries/types.ts b/src/providers/ComponentLibraryProvider/libraries/types.ts index b3ac38ddf..270c7e898 100644 --- a/src/providers/ComponentLibraryProvider/libraries/types.ts +++ b/src/providers/ComponentLibraryProvider/libraries/types.ts @@ -46,3 +46,11 @@ export class DuplicateComponentError extends Error { ); } } + +export class ReadOnlyLibraryError extends Error { + name = "ReadOnlyLibraryError"; + + constructor(message: string) { + super(message); + } +} diff --git a/src/providers/ComponentLibraryProvider/libraries/utils.ts b/src/providers/ComponentLibraryProvider/libraries/utils.ts index 1578cfeed..a83c933a1 100644 --- a/src/providers/ComponentLibraryProvider/libraries/utils.ts +++ b/src/providers/ComponentLibraryProvider/libraries/utils.ts @@ -1,3 +1,4 @@ +import { HOURS } from "@/components/shared/ComponentEditor/constants"; import type { ComponentReference } from "@/utils/componentSpec"; import type { Library } from "./types"; @@ -22,3 +23,43 @@ export function dispatchLibraryChangeEvent( }), ); } + +const CACHE_NAME = "component-library"; + +/** + * Fetch a resource from the network, and cache the response. + * @param url - The URL to fetch. + * @returns The response from the network. + */ +export async function fetchWithCache(url: string): Promise { + const cache = await caches.open(CACHE_NAME); + const cacheResponse = await cache.match(url); + if (cacheResponse) { + if (!isResponseValid(cacheResponse)) { + await cache.delete(url); + } else { + return cacheResponse; + } + } + + const response = await fetch(url); + if (response.ok) { + await cache.put(url, response.clone()); + } + + return response; +} + +function isResponseValid( + response: Response, + maxAgeMs: number = 1 * HOURS, +): boolean { + const dateHeader = response.headers.get("Date"); + + if (dateHeader) { + const cachedAt = new Date(dateHeader).getTime(); + return Date.now() - cachedAt <= maxAgeMs; + } + + return false; +} diff --git a/src/providers/ComponentLibraryProvider/libraries/yamlFileLibrary.test.ts b/src/providers/ComponentLibraryProvider/libraries/yamlFileLibrary.test.ts new file mode 100644 index 000000000..f51d2bcc0 --- /dev/null +++ b/src/providers/ComponentLibraryProvider/libraries/yamlFileLibrary.test.ts @@ -0,0 +1,216 @@ +import yaml from "js-yaml"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { hydrateComponentReference } from "@/services/componentService"; +import type { ComponentReference } from "@/utils/componentSpec"; + +import { InvalidComponentReferenceError } from "./types"; +import { fetchWithCache } from "./utils"; +import { YamlFileLibrary } from "./yamlFileLibrary"; + +// Mock fetchWithCache - required because it uses browser Cache API +vi.mock("./utils", () => ({ + fetchWithCache: vi.fn(), +})); + +// Mock hydrateComponentReference - required because it accesses localforage +vi.mock("@/services/componentService", () => ({ + hydrateComponentReference: vi.fn(), +})); + +const mockFetchWithCache = vi.mocked(fetchWithCache); +const mockHydrateComponentReference = vi.mocked(hydrateComponentReference); + +function createComponentReference( + overrides?: Partial, +): ComponentReference { + return { + name: "test-component", + digest: "sha256:abc123", + url: "https://example.com/component.yaml", + ...overrides, + }; +} + +function createValidLibraryYaml(components: ComponentReference[] = []) { + const folder = { + name: "Test Folder", + components: components.map((c) => ({ + name: c.name, + digest: c.digest, + url: c.url, + })), + }; + + return yaml.dump({ folders: [folder] }); +} + +function mockFetchResponse( + yamlContent: string, + options: { ok?: boolean; status?: number } = {}, +) { + const { ok = true, status = 200 } = options; + + mockFetchWithCache.mockResolvedValue({ + ok, + status, + arrayBuffer: async () => new TextEncoder().encode(yamlContent).buffer, + } as Response); +} + +describe("YamlFileLibrary", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("hasComponent", () => { + it("should return true when component exists by digest", async () => { + // Arrange + const component = createComponentReference({ digest: "sha256:exists" }); + mockFetchResponse(createValidLibraryYaml([component])); + mockHydrateComponentReference.mockResolvedValue({ + name: component.name!, + digest: component.digest!, + url: component.url!, + spec: { + name: component.name!, + implementation: { graph: { tasks: {} } }, + }, + text: "name: test", + }); + const library = new YamlFileLibrary("Test", "/test.yaml"); + + // Act + const result = await library.hasComponent(component); + + // Assert + expect(result).toBe(true); + }); + + it("should return false when component does not exist", async () => { + // Arrange + const existingComponent = createComponentReference({ + digest: "sha256:exists", + }); + const nonExistentComponent = createComponentReference({ + digest: "sha256:not-found", + }); + mockFetchResponse(createValidLibraryYaml([existingComponent])); + mockHydrateComponentReference.mockResolvedValue({ + name: existingComponent.name!, + digest: existingComponent.digest!, + url: existingComponent.url!, + spec: { + name: existingComponent.name!, + implementation: { graph: { tasks: {} } }, + }, + text: "name: test", + }); + const library = new YamlFileLibrary("Test", "/test.yaml"); + + // Act + const result = await library.hasComponent(nonExistentComponent); + + // Assert + expect(result).toBe(false); + }); + + it("should throw InvalidComponentReferenceError for component without digest", async () => { + // Arrange + mockFetchResponse(createValidLibraryYaml()); + const library = new YamlFileLibrary("Test", "/test.yaml"); + const componentWithoutDigest = createComponentReference({ + digest: undefined, + }); + + // Act & Assert + await expect( + library.hasComponent(componentWithoutDigest), + ).rejects.toThrow(InvalidComponentReferenceError); + }); + }); + + describe("getComponents", () => { + it("should return folder structure without filter", async () => { + // Arrange + const component = createComponentReference(); + mockFetchResponse(createValidLibraryYaml([component])); + mockHydrateComponentReference.mockResolvedValue({ + name: component.name!, + digest: component.digest!, + url: component.url!, + spec: { + name: component.name!, + implementation: { graph: { tasks: {} } }, + }, + text: "name: test", + }); + const library = new YamlFileLibrary("Test Library", "/test.yaml"); + + // Act + const result = await library.getComponents(); + + // Assert + expect(result.name).toBe("Test Library"); + expect(result.folders).toHaveLength(1); + expect(result.folders![0].name).toBe("Test Folder"); + expect(result.folders![0].components).toHaveLength(1); + }); + + it("should return flat component list when filter is provided", async () => { + // Arrange + const component = createComponentReference(); + mockFetchResponse(createValidLibraryYaml([component])); + mockHydrateComponentReference.mockResolvedValue({ + name: component.name!, + digest: component.digest!, + url: component.url!, + spec: { + name: component.name!, + implementation: { graph: { tasks: {} } }, + }, + text: "name: test", + }); + const library = new YamlFileLibrary("Test Library", "/test.yaml"); + + // Act + const result = await library.getComponents({ + searchTerm: "test", + filters: ["name"], + }); + + // Assert + expect(result.name).toBe("Test Library"); + expect(result.components).toHaveLength(1); + expect(result.components![0].digest).toBe(component.digest); + }); + }); + + describe("library loading", () => { + it("should throw error when fetch fails", async () => { + // Arrange + mockFetchResponse("", { ok: false, status: 404 }); + const library = new YamlFileLibrary("Test", "/not-found.yaml"); + + // Act & Assert + await expect(library.getComponents()).rejects.toThrow( + "Failed to fetch /not-found.yaml: 404", + ); + }); + + it("should throw error for invalid YAML structure", async () => { + // Arrange + mockFetchResponse("name: missing-folders-property"); + const library = new YamlFileLibrary("Test", "/invalid.yaml"); + + // Act & Assert + await expect(library.getComponents()).rejects.toThrow( + "Invalid component library: /invalid.yaml", + ); + }); + }); +}); diff --git a/src/providers/ComponentLibraryProvider/libraries/yamlFileLibrary.ts b/src/providers/ComponentLibraryProvider/libraries/yamlFileLibrary.ts new file mode 100644 index 000000000..db722f9b2 --- /dev/null +++ b/src/providers/ComponentLibraryProvider/libraries/yamlFileLibrary.ts @@ -0,0 +1,162 @@ +import { hydrateComponentReference } from "@/services/componentService"; +import { + type ComponentFolder, + isValidComponentLibrary, +} from "@/types/componentLibrary"; +import { loadObjectFromYamlData } from "@/utils/cache"; +import { + type ComponentReference, + isDiscoverableComponentReference, + isDisplayableComponentReference, + isHydratedComponentReference, + isLoadableComponentReference, +} from "@/utils/componentSpec"; + +import { isValidFilterRequest, type LibraryFilterRequest } from "../types"; +import { + InvalidComponentReferenceError, + type Library, + ReadOnlyLibraryError, +} from "./types"; +import { fetchWithCache } from "./utils"; + +class YamlFileLibraryError extends Error { + name = "YamlFileLibraryError"; + + constructor(message: string) { + super(message); + } +} + +export class YamlFileLibrary implements Library { + #components: Map = new Map(); + #rootFolder: ComponentFolder; + #loading: Promise; + + constructor( + name: string, + private readonly yamlFilePath: string, + ) { + this.#rootFolder = { + name, + folders: [], + components: [], + }; + + this.#loading = this.#loadLibrary(); + } + + async hasComponent(component: ComponentReference): Promise { + await this.#loading; + + if (!isDiscoverableComponentReference(component)) { + throw new InvalidComponentReferenceError(component); + } + + return this.#components.has(component.digest); + } + + async addComponent(): Promise { + throw new ReadOnlyLibraryError("This library is read-only"); + } + + async removeComponent(): Promise { + throw new ReadOnlyLibraryError("This library is read-only"); + } + + async getComponents(filter?: LibraryFilterRequest): Promise { + await this.#loading; + + if (isValidFilterRequest(filter)) { + return { + name: this.#rootFolder.name, + // TODO: implement contentful filter + components: Array.from(this.#components.values()), + }; + } + + return { + name: this.#rootFolder.name, + components: this.#rootFolder.components ?? [], + folders: this.#rootFolder.folders ?? [], + }; + } + + async #loadLibrary() { + const response = await fetchWithCache(this.yamlFilePath); + + if (!response.ok) { + throw new YamlFileLibraryError( + `Failed to fetch ${this.yamlFilePath}: ${response.status}`, + ); + } + + const data = await response.arrayBuffer(); + + const yamlData = loadObjectFromYamlData(data); + + if (!isValidComponentLibrary(yamlData)) { + throw new YamlFileLibraryError( + `Invalid component library: ${this.yamlFilePath}`, + ); + } + + // visit all components and folders in the library + const queue = [ + ...yamlData.folders.map((folder) => ({ + folder, + parent: this.#rootFolder, + })), + ]; + + // BFS to process folders and components + while (queue.length > 0) { + const queuedFolder = queue.shift(); + if (!queuedFolder) { + continue; + } + + const { folder, parent } = queuedFolder; + + const currentFolder: ComponentFolder = { + name: folder.name, + components: [], + folders: [], + }; + + for (const componentRef of folder.components ?? []) { + const hydratedComponentRef = + !isHydratedComponentReference(componentRef) && + isDisplayableComponentReference(componentRef) && + isLoadableComponentReference(componentRef) + ? componentRef + : // we dont need fully hydrated component reference, just digest, url and name + await hydrateComponentReference(componentRef); + + if (!hydratedComponentRef) { + // todo: track component failure + console.warn( + `Failed to hydrate component: ${componentRef.url ?? componentRef.digest}`, + componentRef, + ); + continue; + } + + this.#components.set(hydratedComponentRef.digest, hydratedComponentRef); + currentFolder.components = [ + ...(currentFolder.components ?? []), + hydratedComponentRef, + ]; + } + + parent.folders = [...(parent.folders ?? []), currentFolder]; + + queue.push( + ...(folder.folders?.map((subfolder) => ({ + folder: subfolder, + parent: currentFolder, + })) ?? []), + ); + } + } +}