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,
+ })) ?? []),
+ );
+ }
+ }
+}