astro-loader-pocketbase
Version:
A content loader for Astro that uses the PocketBase API
1 lines • 81.6 kB
Source Map (JSON)
{"version":3,"file":"index.mjs","names":["packageJson.version"],"sources":["../src/types/errors.ts","../src/types/pocketbase-entry.type.ts","../src/types/pocketbase-api-response.type.ts","../src/utils/combine-fields-for-request.ts","../src/utils/format-fields.ts","../src/loader/fetch-collection.ts","../src/loader/parse-live-entry.ts","../src/loader/live-collection-loader.ts","../src/loader/fetch-entry.ts","../src/loader/live-entry-loader.ts","../package.json","../src/utils/should-refresh.ts","../src/loader/cleanup-entries.ts","../src/utils/is-realtime-data.ts","../src/utils/slugify.ts","../src/loader/parse-entry.ts","../src/loader/handle-realtime-updates.ts","../src/loader/load-entries.ts","../src/loader/loader.ts","../src/utils/extract-field-names.ts","../src/types/pocketbase-schema.type.ts","../src/schema/get-remote-schema.ts","../src/schema/parse-schema.ts","../src/schema/read-local-schema.ts","../src/schema/transform-files.ts","../src/schema/generate-schema.ts","../src/schema/generate-type.ts","../src/utils/get-superuser-token.ts","../src/utils/create-token-promise.ts","../src/pocketbase-loader.ts"],"sourcesContent":["import { LiveCollectionError } from \"astro/content/runtime\";\n\n/**\n * Error thrown when there is an authentication issue with PocketBase.\n */\nexport class PocketBaseAuthenticationError extends LiveCollectionError {\n constructor(collection: string, message: string) {\n super(collection, message);\n this.name = \"PocketBaseAuthenticationError\";\n }\n\n static is(error: unknown): error is PocketBaseAuthenticationError {\n // This is similar to the original implementation in Astro itself.\n return (\n // oxlint-disable-next-line no-unsafe-type-assertion\n !!error && (error as Error).name === \"PocketBaseAuthenticationError\"\n );\n }\n}\n","import { z } from \"astro/zod\";\n\n/**\n * Schema for a PocketBase entry.\n */\nexport const pocketBaseEntry = z.looseObject({\n /**\n * ID of the entry.\n */\n id: z.string(),\n /**\n * ID of the collection the entry belongs to.\n */\n collectionId: z.string(),\n /**\n * Name of the collection the entry belongs to.\n */\n collectionName: z.string()\n});\n\n/**\n * Type for a PocketBase entry.\n */\nexport type PocketBaseEntry = z.infer<typeof pocketBaseEntry>;\n","import { z } from \"astro/zod\";\nimport { pocketBaseEntry } from \"./pocketbase-entry.type\";\n\n/**\n * The schema for a PocketBase error response.\n */\nexport const pocketBaseErrorResponse = z.object({\n /**\n * The error message returned by PocketBase.\n */\n message: z.string()\n});\n\n/**\n * The schema for a PocketBase list response.\n */\nexport const pocketBaseListResponse = z.object({\n /**\n * Current page number.\n */\n page: z.number(),\n /**\n * Total number of pages available.\n */\n totalPages: z.number(),\n /**\n * Array of items in the current page.\n */\n items: z.array(pocketBaseEntry)\n});\n\n/**\n * The schema for a PocketBase login response.\n */\nexport const pocketBaseLoginResponse = z.object({\n /**\n * The authentication token returned by PocketBase.\n */\n token: z.string()\n});\n","import type {\n PocketBaseLoaderBaseOptions,\n PocketBaseLoaderOptions\n} from \"../types/pocketbase-loader-options.type\";\n\n/**\n * Combine basic, special, and user-specified fields for PocketBase API requests.\n * This utility ensures that required system fields are always included in API requests.\n *\n * @param userFields Array of fields specified by the user, or undefined for all fields\n * @param options PocketBase loader options containing custom field configurations\n * @returns Combined array of fields to include in the API request, or undefined for all fields\n */\nexport function combineFieldsForRequest(\n userFields: Array<string> | undefined,\n options: Pick<PocketBaseLoaderBaseOptions, \"updatedField\" | \"contentFields\"> &\n Pick<PocketBaseLoaderOptions, \"idField\">\n): Array<string> | undefined {\n // If no fields specified, return undefined to get all fields\n if (!userFields) {\n return undefined;\n }\n\n // Basic fields that are always required by the loader\n const basicFields = [\"id\", \"collectionId\", \"collectionName\"];\n\n // Special fields that are configured in options\n const specialFields: Array<string> = [];\n\n // Add custom id field if specified\n if (options.idField && options.idField !== \"id\") {\n specialFields.push(options.idField);\n }\n\n // Add updated field if specified\n if (options.updatedField) {\n specialFields.push(options.updatedField);\n }\n\n // Add content fields if specified\n if (options.contentFields) {\n if (Array.isArray(options.contentFields)) {\n specialFields.push(...options.contentFields);\n } else {\n specialFields.push(options.contentFields);\n }\n }\n\n // Combine all field sets, removing duplicates\n const allFields = [\n ...new Set([...basicFields, ...specialFields, ...userFields])\n ];\n\n return allFields;\n}\n","import type { PocketBaseLoaderBaseOptions } from \"../types/pocketbase-loader-options.type\";\n\n/**\n * Format fields option into an array and validate for expand usage.\n * Handles wildcard \"*\" and preserves excerpt field modifiers.\n *\n * @param fields The fields option (string or array)\n * @returns Formatted fields array, or undefined if no fields specified or \"*\" wildcard is used\n */\nexport function formatFields(\n fields: PocketBaseLoaderBaseOptions[\"fields\"]\n): Array<string> | undefined {\n if (!fields || fields.length === 0) {\n return undefined;\n }\n\n let fieldList: Array<string>;\n if (Array.isArray(fields)) {\n fieldList = fields.map((f) => f.trim());\n } else {\n // Split carefully, respecting parentheses in excerpt syntax\n fieldList = splitFieldsString(fields).map((f) => f.trim());\n }\n\n // Warn if expand is used since it's not currently supported\n const hasExpand = fieldList.some((field) => field.includes(\"expand\"));\n if (hasExpand) {\n console.warn(\n 'The \"expand\" parameter is not currently supported by astro-loader-pocketbase and will be filtered out.'\n );\n fieldList = fieldList.filter((field) => !field.includes(\"expand\"));\n }\n\n // Check for \"*\" wildcard - if found anywhere, include all fields\n const hasWildcard = fieldList.some((field) => field === \"*\");\n if (hasWildcard) {\n return undefined;\n }\n\n return fieldList;\n}\n\n/**\n * Splits the fields string at `,` but respects the `:excerpt(number, boolean)` option\n */\nfunction splitFieldsString(fieldsString: string): Array<string> {\n // First, split by comma\n const initialSplit = fieldsString.split(\",\");\n\n const fields: Array<string> = [];\n for (let i = 0; i < initialSplit.length; i++) {\n const part = initialSplit.at(i);\n if (!part) {\n continue;\n }\n\n if (part.includes(\"(\") && !part.includes(\")\")) {\n fields.push(`${part},${initialSplit[++i]}`);\n continue;\n }\n\n fields.push(part);\n }\n\n return fields;\n}\n","import {\n LiveCollectionError,\n LiveEntryNotFoundError\n} from \"astro/content/runtime\";\nimport { PocketBaseAuthenticationError } from \"../types/errors\";\nimport {\n pocketBaseErrorResponse,\n pocketBaseListResponse\n} from \"../types/pocketbase-api-response.type\";\nimport type { PocketBaseEntry } from \"../types/pocketbase-entry.type\";\nimport type { PocketBaseLiveLoaderCollectionFilter } from \"../types/pocketbase-live-loader-filter.type\";\nimport type { PocketBaseLoaderBaseOptions } from \"../types/pocketbase-loader-options.type\";\nimport { combineFieldsForRequest } from \"../utils/combine-fields-for-request\";\nimport { formatFields } from \"../utils/format-fields\";\n\n/**\n * Provides utilities to fetch entries from a PocketBase collection, supporting filtering and pagination.\n */\nexport type CollectionFilter = {\n /**\n * Date string to only fetch entries that have been modified since this date.\n * If not provided, all entries will be fetched.\n */\n lastModified?: string;\n} & PocketBaseLiveLoaderCollectionFilter;\n\n/**\n * Fetches entries from a PocketBase collection, optionally filtering by modification date and supporting pagination.\n */\nexport async function fetchCollection<TEntry extends PocketBaseEntry>(\n options: PocketBaseLoaderBaseOptions,\n chunkLoaded: (entries: Array<TEntry>) => Promise<void>,\n token: string | undefined,\n collectionFilter: CollectionFilter | undefined\n): Promise<void> {\n // Build the URL for the collections endpoint\n const collectionUrl = new URL(\n `api/collections/${options.collectionName}/records`,\n options.url\n );\n\n // Create the headers for the request to append the token (if available)\n const collectionHeaders = new Headers();\n if (token) {\n collectionHeaders.set(\"Authorization\", token);\n }\n\n // Cache fields computation outside the loop\n const fieldsArray = formatFields(options.fields);\n const combinedFields = combineFieldsForRequest(fieldsArray, options);\n\n // Prepare pagination variables\n let page = 0;\n let totalPages = 0;\n\n // Fetch all (modified) entries\n do {\n const searchParams = buildSearchParams(options, combinedFields, {\n ...collectionFilter,\n page: collectionFilter?.page ?? ++page,\n perPage: collectionFilter?.perPage ?? 100\n });\n\n // Apply search parameters to URL\n collectionUrl.search = searchParams.toString();\n\n // Fetch entries from the collection\n const collectionRequest = await fetch(collectionUrl.href, {\n headers: collectionHeaders\n });\n\n // If the request was not successful, print the error message and return\n if (!collectionRequest.ok) {\n // If the collection is locked, an superuser token is required\n if (collectionRequest.status === 403) {\n if (\n options.superuserCredentials &&\n \"impersonateToken\" in options.superuserCredentials\n ) {\n throw new PocketBaseAuthenticationError(\n options.collectionName,\n \"The given impersonate token is not valid.\"\n );\n } else {\n throw new PocketBaseAuthenticationError(\n options.collectionName,\n \"The collection is not accessible without superuser rights. Please provide superuser credentials in the config.\"\n );\n }\n }\n\n if (collectionRequest.status === 404) {\n throw new LiveEntryNotFoundError(options.collectionName, {\n ...collectionFilter\n });\n }\n\n // Get the reason for the error\n const errorResponse = pocketBaseErrorResponse.parse(\n await collectionRequest.json()\n );\n throw new LiveCollectionError(\n options.collectionName,\n errorResponse.message\n );\n }\n\n // Get the data from the response\n const response = pocketBaseListResponse.parse(\n await collectionRequest.json()\n );\n\n // Return current chunk\n // oxlint-disable-next-line no-unsafe-type-assertion\n await chunkLoaded(response.items as Array<TEntry>);\n\n // Update the page and total pages\n page = response.page;\n totalPages = response.totalPages;\n } while (!collectionFilter?.perPage && page < totalPages);\n}\n\n/**\n * Build search parameters for the PocketBase collection request.\n */\nfunction buildSearchParams(\n loaderOptions: PocketBaseLoaderBaseOptions,\n combinedFields: Array<string> | undefined,\n collectionFilter: CollectionFilter\n): URLSearchParams {\n const searchParams = new URLSearchParams();\n\n if (collectionFilter.page) {\n searchParams.set(\"page\", `${collectionFilter.page}`);\n }\n\n if (collectionFilter.perPage) {\n searchParams.set(\"perPage\", `${collectionFilter.perPage}`);\n }\n\n const filters = [];\n\n // If `lastModified` is set, only fetch entries that have been modified since the last fetch\n // Sort by the updated field and id\n if (collectionFilter.lastModified && loaderOptions.updatedField) {\n filters.push(\n `(${loaderOptions.updatedField}>\"${collectionFilter.lastModified}\")`\n );\n searchParams.set(\"sort\", `-${loaderOptions.updatedField},id`);\n }\n\n // Add filter from the loader options\n if (loaderOptions.filter) {\n filters.push(`(${loaderOptions.filter})`);\n }\n\n // Add additional filter from the collection filter\n if (collectionFilter.filter) {\n filters.push(`(${collectionFilter.filter})`);\n }\n\n // Add filters to search parameters\n if (filters.length > 0) {\n searchParams.set(\"filter\", filters.join(\"&&\"));\n }\n\n if (collectionFilter.sort) {\n searchParams.set(\"sort\", collectionFilter.sort);\n }\n\n // Add fields parameter if specified\n if (combinedFields) {\n searchParams.set(\"fields\", combinedFields.join(\",\"));\n }\n\n return searchParams;\n}\n","import type { LiveDataEntry } from \"astro\";\nimport { LiveCollectionValidationError } from \"astro/content/runtime\";\nimport { z } from \"astro/zod\";\nimport type { PocketBaseEntry } from \"../types/pocketbase-entry.type\";\nimport type { PocketBaseLiveLoaderOptions } from \"../types/pocketbase-loader-options.type\";\n\n/**\n * Converts a PocketBase entry into a LiveDataEntry for Astro, extracting content and cache metadata.\n */\nexport function parseLiveEntry<TEntry extends PocketBaseEntry>(\n entry: TEntry,\n options: PocketBaseLiveLoaderOptions\n): LiveDataEntry<TEntry> {\n // Build a cache tag\n const tag = `${options.collectionName}-${entry.id}`;\n\n let lastModified: Date | undefined = undefined;\n // If an updated field is provided and the entry has a valid date value,\n // use it as the last modified date cache hint\n if (options.updatedField && entry[options.updatedField]) {\n const value = `${entry[options.updatedField]}`;\n const date = z.coerce.date().safeParse(value);\n if (!date.success) {\n throw new LiveCollectionValidationError(\n options.collectionName,\n entry.id,\n date.error\n );\n }\n lastModified = date.data;\n }\n\n if (!options.contentFields) {\n return {\n id: entry.id,\n data: entry,\n cacheHint: {\n tags: [tag],\n lastModified\n }\n };\n }\n\n let content: string;\n if (typeof options.contentFields === \"string\") {\n // If a single content field is provided, use it directly\n content = `${entry[options.contentFields]}`;\n } else {\n // If multiple content fields are provided, concatenate them with `<section>` tags\n content = options.contentFields\n .map((field) => `<section id=\"${field}\">${entry[field]}</section>`)\n .join(\"\");\n }\n\n return {\n id: entry.id,\n data: entry,\n rendered: {\n html: content\n },\n cacheHint: {\n tags: [tag],\n lastModified\n }\n };\n}\n","import type { LiveDataCollection, LiveDataEntry } from \"astro\";\nimport { LiveCollectionError } from \"astro/content/runtime\";\nimport type { PocketBaseEntry } from \"../types/pocketbase-entry.type\";\nimport type { PocketBaseLiveLoaderCollectionFilter } from \"../types/pocketbase-live-loader-filter.type\";\nimport type { PocketBaseLiveLoaderOptions } from \"../types/pocketbase-loader-options.type\";\nimport { fetchCollection } from \"./fetch-collection\";\nimport { parseLiveEntry } from \"./parse-live-entry\";\n\n/**\n * Loads and parses a PocketBase collection for live data, returning entries or an error.\n */\nexport async function liveCollectionLoader<TEntry extends PocketBaseEntry>(\n collectionFilter: PocketBaseLiveLoaderCollectionFilter | undefined,\n options: PocketBaseLiveLoaderOptions,\n token: string | undefined\n): Promise<LiveDataCollection<TEntry> | { error: LiveCollectionError }> {\n const entries: Array<LiveDataEntry<TEntry>> = [];\n\n try {\n await fetchCollection<TEntry>(\n options,\n async (chunk) => {\n entries.push(...chunk.map((entry) => parseLiveEntry(entry, options)));\n },\n token,\n collectionFilter\n );\n } catch (error) {\n if (error instanceof LiveCollectionError) {\n return { error };\n }\n\n if (error instanceof Error) {\n return {\n error: new LiveCollectionError(\n options.collectionName,\n error.message,\n error\n )\n };\n }\n\n return {\n error: new LiveCollectionError(options.collectionName, String(error))\n };\n }\n\n return {\n entries\n };\n}\n","import {\n LiveCollectionError,\n LiveEntryNotFoundError\n} from \"astro/content/runtime\";\nimport { PocketBaseAuthenticationError } from \"../types/errors\";\nimport { pocketBaseErrorResponse } from \"../types/pocketbase-api-response.type\";\nimport type { PocketBaseEntry } from \"../types/pocketbase-entry.type\";\nimport { pocketBaseEntry } from \"../types/pocketbase-entry.type\";\nimport type { PocketBaseLiveLoaderOptions } from \"../types/pocketbase-loader-options.type\";\nimport { combineFieldsForRequest } from \"../utils/combine-fields-for-request\";\nimport { formatFields } from \"../utils/format-fields\";\n\n/**\n * Retrieves a specific entry from a PocketBase collection using its ID and loader options.\n */\nexport async function fetchEntry<TEntry extends PocketBaseEntry>(\n id: string,\n options: PocketBaseLiveLoaderOptions,\n token: string | undefined\n): Promise<TEntry> {\n // Build the URL for the entry endpoint\n const entryUrl = new URL(\n `api/collections/${options.collectionName}/records/${id}`,\n options.url\n );\n\n // Add fields parameter if specified\n const fieldsArray = formatFields(options.fields);\n const combinedFields = combineFieldsForRequest(fieldsArray, options);\n if (combinedFields) {\n entryUrl.searchParams.set(\"fields\", combinedFields.join(\",\"));\n }\n\n // Create the headers for the request to append the token (if available)\n const entryHeaders = new Headers();\n if (token) {\n entryHeaders.set(\"Authorization\", token);\n }\n\n // Fetch the entry from the collection\n const entryRequest = await fetch(entryUrl.href, {\n headers: entryHeaders\n });\n\n // If the request was not successful, return an error\n if (!entryRequest.ok) {\n // If the entry is locked, a superuser token is required\n if (entryRequest.status === 403) {\n if (\n options.superuserCredentials &&\n \"impersonateToken\" in options.superuserCredentials\n ) {\n throw new PocketBaseAuthenticationError(\n options.collectionName,\n \"The given impersonate token is not valid.\"\n );\n } else {\n throw new PocketBaseAuthenticationError(\n options.collectionName,\n \"The entry is not accessible without superuser rights. Please provide superuser credentials in the config.\"\n );\n }\n }\n\n if (entryRequest.status === 404) {\n throw new LiveEntryNotFoundError(options.collectionName, id);\n }\n\n // Get the reason for the error\n const errorResponse = pocketBaseErrorResponse.parse(\n await entryRequest.json()\n );\n throw new LiveCollectionError(\n options.collectionName,\n errorResponse.message\n );\n }\n\n // Get the data from the response\n const response = pocketBaseEntry.parse(await entryRequest.json());\n // oxlint-disable-next-line no-unsafe-type-assertion\n return response as TEntry;\n}\n","import type { LiveDataEntry } from \"astro\";\nimport { LiveCollectionError } from \"astro/content/runtime\";\nimport type { PocketBaseEntry } from \"../types/pocketbase-entry.type\";\nimport type { PocketBaseLiveLoaderOptions } from \"../types/pocketbase-loader-options.type\";\nimport { fetchEntry } from \"./fetch-entry\";\nimport { parseLiveEntry } from \"./parse-live-entry\";\n\n/**\n * Loads and parses a single PocketBase entry for live data, returning the entry or an error.\n */\nexport async function liveEntryLoader<TEntry extends PocketBaseEntry>(\n id: string,\n options: PocketBaseLiveLoaderOptions,\n token: string | undefined\n): Promise<LiveDataEntry<TEntry> | { error: LiveCollectionError }> {\n try {\n const entry = await fetchEntry<TEntry>(id, options, token);\n return parseLiveEntry(entry, options);\n } catch (error) {\n if (error instanceof LiveCollectionError) {\n return { error };\n }\n\n if (error instanceof Error) {\n return {\n error: new LiveCollectionError(\n options.collectionName,\n error.message,\n error\n )\n };\n }\n\n return {\n error: new LiveCollectionError(options.collectionName, String(error))\n };\n }\n}\n","","import type { LoaderContext } from \"astro/loaders\";\n\n/**\n * Checks if the collection should be refreshed.\n */\nexport function shouldRefresh(\n context: LoaderContext[\"refreshContextData\"],\n collectionName: string\n): \"refresh\" | \"skip\" | \"force\" {\n // Check if the refresh was triggered by the `astro-integration-pocketbase`\n // and the correct metadata is provided.\n if (context?.source !== \"astro-integration-pocketbase\") {\n return \"refresh\";\n }\n\n // Check if all collections should be refreshed.\n if (context.force) {\n return \"force\";\n }\n\n if (!context.collection) {\n return \"refresh\";\n }\n\n // Check if the collection name matches the current collection.\n if (typeof context.collection === \"string\") {\n return context.collection === collectionName ? \"refresh\" : \"skip\";\n }\n\n // Check if the collection is included in the list of collections.\n if (Array.isArray(context.collection)) {\n return context.collection.includes(collectionName) ? \"refresh\" : \"skip\";\n }\n\n // Should not happen but return true to be safe.\n return \"refresh\";\n}\n","import type { LoaderContext } from \"astro/loaders\";\nimport { z } from \"astro/zod\";\nimport {\n pocketBaseErrorResponse,\n pocketBaseListResponse\n} from \"../types/pocketbase-api-response.type\";\nimport type { PocketBaseEntry } from \"../types/pocketbase-entry.type\";\nimport { pocketBaseEntry } from \"../types/pocketbase-entry.type\";\nimport type { PocketBaseLoaderBaseOptions } from \"../types/pocketbase-loader-options.type\";\n\n/**\n * Cleanup entries that are no longer in the collection.\n *\n * @param options Options for the loader.\n * @param context Context of the loader.\n * @param superuserToken Superuser token to access all resources.\n */\nexport async function cleanupEntries(\n options: PocketBaseLoaderBaseOptions,\n context: LoaderContext,\n superuserToken: string | undefined\n): Promise<void> {\n // Build the URL for the collections endpoint\n const collectionUrl = new URL(\n `api/collections/${options.collectionName}/records`,\n options.url\n ).href;\n\n // Create the headers for the request to append the superuser token (if available)\n const collectionHeaders = new Headers();\n if (superuserToken) {\n collectionHeaders.set(\"Authorization\", superuserToken);\n }\n\n // Prepare pagination variables\n let page = 0;\n let totalPages = 0;\n const entries = new Set<string>();\n\n // Fetch all ids of the collection\n do {\n // Build search parameters\n const searchParams = new URLSearchParams({\n page: `${++page}`,\n perPage: \"1000\",\n fields: \"id\"\n });\n\n if (options.filter) {\n // If a filter is set, add it to the search parameters\n searchParams.set(\"filter\", `(${options.filter})`);\n }\n\n // Fetch ids from the collection\n const collectionRequest = await fetch(\n `${collectionUrl}?${searchParams.toString()}`,\n {\n headers: collectionHeaders\n }\n );\n\n // If the request was not successful, print the error message and return\n if (!collectionRequest.ok) {\n // If the collection is locked, an superuser token is required\n if (collectionRequest.status === 403) {\n if (\n options.superuserCredentials &&\n \"impersonateToken\" in options.superuserCredentials\n ) {\n context.logger.error(\"The given impersonate token is not valid.\");\n } else {\n context.logger.error(\n \"The collection is not accessible without superuser rights. Please provide superuser credentials in the config.\"\n );\n }\n } else {\n const errorResponse = pocketBaseErrorResponse.parse(\n await collectionRequest.json()\n );\n const errorMessage = `Fetching ids failed with status code ${collectionRequest.status}.\\nReason: ${errorResponse.message}`;\n context.logger.error(errorMessage);\n }\n\n // Remove all entries from the store\n context.logger.info(`Removing all entries from the store.`);\n context.store.clear();\n return;\n }\n\n // Get the data from the response\n const response = cleanUpEntriesResponse.parse(\n await collectionRequest.json()\n );\n\n // Add the ids to the set\n for (const item of response.items) {\n entries.add(item.id);\n }\n\n // Update the page and total pages\n page = response.page;\n totalPages = response.totalPages;\n } while (page < totalPages);\n\n let cleanedUp = 0;\n\n // Create a mapping from PocketBase IDs to store keys for proper cleanup\n const storedIds = new Map<string, string>(\n context.store\n .values()\n // oxlint-disable-next-line no-unsafe-type-assertion\n .map((entry) => [(entry.data as PocketBaseEntry).id, entry.id])\n );\n\n // Check which PocketBase IDs are missing from the server response\n for (const [pocketbaseId, storeKey] of storedIds.entries()) {\n // If the PocketBase ID is not in the entries set, remove the entry from the store\n if (!entries.has(pocketbaseId)) {\n context.store.delete(storeKey);\n cleanedUp++;\n }\n }\n\n if (cleanedUp > 0) {\n // Log the number of cleaned up entries\n context.logger.info(`Cleaned up ${cleanedUp} old entries.`);\n }\n}\n\n/**\n * The response schema for cleaning up entries.\n */\nconst cleanUpEntriesResponse = pocketBaseListResponse\n .omit({ items: true })\n .extend({\n items: z.array(pocketBaseEntry.pick({ id: true }))\n });\n","import { z } from \"astro/zod\";\n\n/**\n * Schema for realtime data received from PocketBase.\n */\nconst realtimeDataSchema = z.object({\n action: z.union([\n z.literal(\"create\"),\n z.literal(\"update\"),\n z.literal(\"delete\")\n ]),\n record: z.object({\n id: z.string(),\n collectionName: z.string(),\n collectionId: z.string()\n })\n});\n\n/**\n * Type for realtime data received from PocketBase.\n */\nexport type RealtimeData = z.infer<typeof realtimeDataSchema>;\n\n/**\n * Checks if the given data is realtime data received from PocketBase.\n */\nexport function isRealtimeData(data: unknown): data is RealtimeData {\n try {\n realtimeDataSchema.parse(data);\n return true;\n } catch {\n return false;\n }\n}\n","/**\n * Convert a string to a slug.\n *\n * Example:\n * ```ts\n * slugify(\"Hello World!\"); // hello-world\n * ```\n */\nexport function slugify(input: string): string {\n return input\n .toLowerCase()\n .replaceAll(/\\s+/g, \"-\") // Replace spaces with -\n .replaceAll(\"ä\", \"ae\") // Replace umlauts\n .replaceAll(\"ö\", \"oe\")\n .replaceAll(\"ü\", \"ue\")\n .replaceAll(\"ß\", \"ss\")\n .replaceAll(/[^\\w-]+/g, \"\") // Remove all non-word chars\n .replaceAll(/--+/g, \"-\") // Replace multiple - with single -\n .replace(/^-+/, \"\") // Trim - from start of text\n .replace(/-+$/, \"\"); // Trim - from end of text\n}\n","import type { LoaderContext } from \"astro/loaders\";\nimport type { PocketBaseEntry } from \"../types/pocketbase-entry.type\";\nimport type { PocketBaseLoaderOptions } from \"../types/pocketbase-loader-options.type\";\nimport { slugify } from \"../utils/slugify\";\n\n/**\n * Parse an entry from PocketBase to match the schema and store it in the store.\n *\n * @param entry Entry to parse.\n * @param context Context of the loader.\n * @param idField Field to use as id for the entry.\n * If not provided, the id of the entry will be used.\n * @param contentFields Field(s) to use as content for the entry.\n * If multiple fields are used, they will be concatenated and wrapped in `<section>` elements.\n */\nexport async function parseEntry(\n entry: PocketBaseEntry,\n { generateDigest, parseData, store, logger }: LoaderContext,\n { idField, contentFields, updatedField }: PocketBaseLoaderOptions\n): Promise<void> {\n let id = entry.id;\n if (idField) {\n // Get the custom ID of the entry if it exists\n const customEntryId = entry[idField];\n\n if (!customEntryId) {\n logger.warn(\n `The entry \"${id}\" does not have a value for field ${idField}. Using the default ID instead.`\n );\n } else {\n id = slugify(`${customEntryId}`);\n }\n }\n\n const oldEntry = store.get(id);\n if (oldEntry && oldEntry.data.id !== entry.id) {\n logger.warn(\n `The entry \"${entry.id}\" seems to be a duplicate of \"${oldEntry.data.id}\". Please make sure to use unique IDs in the column \"${idField}\".`\n );\n }\n\n // Parse the data to match the schema\n // This will throw an error if the data does not match the schema\n const data = await parseData({\n id,\n data: entry\n });\n\n // Get the updated date of the entry\n let updated: string | undefined;\n if (updatedField) {\n updated = `${entry[updatedField]}`;\n }\n\n // Generate a digest for the entry\n // If no updated date is available, the digest will be generated from the whole entry\n const digest = generateDigest(updated ?? entry);\n\n if (!contentFields) {\n // Store the entry\n store.set({\n id,\n data,\n digest\n });\n return;\n }\n\n // Generate the content for the entry\n let content: string;\n if (typeof contentFields === \"string\") {\n // Only one field is used as content\n content = `${entry[contentFields]}`;\n } else {\n // Multiple fields are used as content, wrap each block in a section and concatenate them\n content = contentFields\n .map((field) => `<section id=\"${field}\">${entry[field]}</section>`)\n .join(\"\");\n }\n\n // Store the entry\n store.set({\n id,\n data,\n digest,\n rendered: {\n html: content\n }\n });\n}\n","import type { LoaderContext } from \"astro/loaders\";\nimport type { PocketBaseLoaderOptions } from \"../types/pocketbase-loader-options.type\";\nimport { isRealtimeData } from \"../utils/is-realtime-data\";\nimport { parseEntry } from \"./parse-entry\";\n\n/**\n * Handles realtime updates for the loader without making any new network requests.\n *\n * Returns `true` if the data was handled and no further action is needed.\n */\nexport async function handleRealtimeUpdates(\n context: LoaderContext,\n options: PocketBaseLoaderOptions\n): Promise<boolean> {\n // Check if a custom filter is set\n if (options.filter) {\n // Updating an entry directly via realtime updates is not supported when using a custom filter.\n // This is because the filter can only be applied via the get request and is not considered in the realtime updates.\n // Updating the entry directly would bypass the filter and could lead to inconsistent data.\n return false;\n }\n\n // Check if data was provided via the refresh context\n if (!context.refreshContextData?.data) {\n return false;\n }\n\n // Check if the data is PocketBase realtime data\n const data = context.refreshContextData.data;\n if (!isRealtimeData(data)) {\n return false;\n }\n\n // Check if the collection name matches the current collection\n if (data.record.collectionName !== options.collectionName) {\n return false;\n }\n\n // Handle deleted entry\n if (data.action === \"delete\") {\n context.logger.info(\"Removing deleted entry\");\n context.store.delete(data.record.id);\n return true;\n }\n\n // Handle updated or new entry\n if (data.action === \"update\") {\n context.logger.info(\"Updating outdated entry\");\n } else {\n context.logger.info(\"Creating new entry\");\n }\n\n // Parse the entry and store\n await parseEntry(data.record, context, options);\n return true;\n}\n","import type { LoaderContext } from \"astro/loaders\";\nimport type { PocketBaseLoaderOptions } from \"../types/pocketbase-loader-options.type\";\nimport { fetchCollection } from \"./fetch-collection\";\nimport { parseEntry } from \"./parse-entry\";\n\n/**\n * Load (modified) entries from a PocketBase collection.\n *\n * @param options Options for the loader.\n * @param context Context of the loader.\n * @param superuserToken Superuser token to access all resources.\n * @param lastModified Date of the last fetch to only update changed entries.\n */\nexport async function loadEntries(\n options: PocketBaseLoaderOptions,\n context: LoaderContext,\n superuserToken: string | undefined,\n lastModified: string | undefined\n): Promise<void> {\n // Log the fetching of the entries\n context.logger.info(\n `Fetching${lastModified ? \" modified\" : \"\"} data${\n lastModified ? ` starting at ${lastModified}` : \"\"\n }${superuserToken ? \" as superuser\" : \"\"}`\n );\n\n let numEntries = 0;\n await fetchCollection(\n options,\n async (entries) => {\n // Parse and store the entries\n for (const entry of entries) {\n await parseEntry(entry, context, options);\n }\n\n numEntries += entries.length;\n },\n superuserToken,\n {\n lastModified\n }\n );\n\n // Log the number of fetched entries\n if (lastModified) {\n context.logger.info(\n `Updated ${numEntries}/${context.store.keys().length} entries.`\n );\n } else {\n context.logger.info(`Fetched ${numEntries} entries.`);\n }\n}\n","import type { LoaderContext } from \"astro/loaders\";\nimport packageJson from \"../../package.json\";\nimport type { PocketBaseLoaderOptions } from \"../types/pocketbase-loader-options.type\";\nimport { shouldRefresh } from \"../utils/should-refresh\";\nimport { cleanupEntries } from \"./cleanup-entries\";\nimport { handleRealtimeUpdates } from \"./handle-realtime-updates\";\nimport { loadEntries } from \"./load-entries\";\n\n/**\n * Load entries from a PocketBase collection.\n */\nexport async function loader(\n context: LoaderContext,\n options: PocketBaseLoaderOptions,\n token: string | undefined\n): Promise<void> {\n context.logger.label = `pocketbase-loader:${options.collectionName}`;\n\n // Check if the collection should be refreshed.\n const refresh = shouldRefresh(\n context.refreshContextData,\n options.collectionName\n );\n if (refresh === \"skip\") {\n return;\n }\n\n // Handle realtime updates\n const handled = await handleRealtimeUpdates(context, options);\n if (handled) {\n return;\n }\n\n // Get the date of the last fetch to only update changed entries.\n let lastModified = context.meta.get(\"last-modified\");\n\n // Force a full update if the refresh is forced\n if (refresh === \"force\") {\n lastModified = undefined;\n context.store.clear();\n }\n\n // Check if the version has changed to force an update\n const lastVersion = context.meta.get(\"version\");\n if (lastVersion !== packageJson.version) {\n if (lastVersion) {\n context.logger.info(\n `PocketBase loader was updated from ${lastVersion} to ${packageJson.version}. All entries will be loaded again.`\n );\n }\n\n // Disable incremental builds and clear the store\n lastModified = undefined;\n context.store.clear();\n }\n\n // Disable incremental builds if no updated field is provided\n if (!options.updatedField) {\n context.logger.info(\n `No \"updatedField\" was provided. Incremental builds are disabled.`\n );\n lastModified = undefined;\n }\n\n if (context.store.keys().length > 0) {\n // Cleanup entries that are no longer in the collection\n await cleanupEntries(options, context, token);\n }\n\n // Load the (modified) entries\n await loadEntries(options, context, token, lastModified);\n\n // Set the last modified date to the current date\n context.meta.set(\"last-modified\", new Date().toISOString().replace(\"T\", \" \"));\n\n context.meta.set(\"version\", packageJson.version);\n}\n","/**\n * Extract field names from fields that may contain modifiers like :excerpt().\n *\n * @param fields Array of field specifications that may contain modifiers\n * @returns Array of clean field names suitable for schema parsing\n */\nexport function extractFieldNames(\n fields: Array<string> | undefined\n): Array<string> | undefined {\n if (!fields) {\n return undefined;\n }\n\n return fields.map((field) => field.split(\":\").at(0) ?? field);\n}\n","import { z } from \"astro/zod\";\n\n/**\n * Entry for a collections schema in PocketBase.\n */\nexport const pocketBaseSchemaEntry = z.object({\n /**\n * Flag to indicate if the field is hidden.\n * Hidden fields are not returned in the API response.\n */\n hidden: z.optional(z.boolean()),\n /**\n * Unique identifier for the field.\n */\n id: z.string(),\n /**\n * Name of the field.\n */\n name: z.string(),\n /**\n * Help text for the field.\n * This is only present if the field has help text defined.\n */\n help: z.optional(z.string()),\n /**\n * Type of the field.\n */\n type: z.enum([\n \"text\",\n \"editor\",\n \"number\",\n \"bool\",\n \"email\",\n \"url\",\n \"date\",\n \"autodate\",\n \"select\",\n \"file\",\n \"relation\",\n \"json\",\n \"geoPoint\",\n \"password\"\n ]),\n /**\n * Whether the field is required.\n */\n required: z.optional(z.boolean()),\n /**\n * Values for a select field.\n * This is only present if the field type is \"select\".\n */\n values: z.optional(z.array(z.string())),\n /**\n * Maximum number of values for a select field.\n * This is only present on \"select\", \"relation\", and \"file\" fields.\n */\n maxSelect: z.optional(z.number()),\n /**\n * Whether the field is filled when the entry is created.\n * This is only present on \"autodate\" fields.\n */\n onCreate: z.optional(z.boolean()),\n /**\n * Whether the field is updated when the entry is updated.\n * This is only present on \"autodate\" fields.\n */\n onUpdate: z.optional(z.boolean())\n});\n\n/**\n * Entry for a collections schema in PocketBase.\n */\nexport type PocketBaseSchemaEntry = z.infer<typeof pocketBaseSchemaEntry>;\n\n/**\n * Schema for a PocketBase collection.\n */\nexport const pocketBaseCollection = z.object({\n /**\n * Name of the collection.\n */\n name: z.string(),\n /**\n * Type of the collection.\n */\n type: z.enum([\"base\", \"view\", \"auth\"]),\n /**\n * Schema of the collection.\n */\n fields: z.array(pocketBaseSchemaEntry)\n});\n\n/**\n * Type for a PocketBase collection.\n */\nexport type PocketBaseCollection = z.infer<typeof pocketBaseCollection>;\n\n/**\n * Schema for an entire PocketBase database.\n */\nexport const pocketBaseDatabase = z.array(pocketBaseCollection);\n","import { pocketBaseErrorResponse } from \"../types/pocketbase-api-response.type\";\nimport type { PocketBaseLoaderOptions } from \"../types/pocketbase-loader-options.type\";\nimport type { PocketBaseCollection } from \"../types/pocketbase-schema.type\";\nimport { pocketBaseCollection } from \"../types/pocketbase-schema.type\";\n\n/**\n * Fetches the schema for the specified collection from the PocketBase instance.\n *\n * @param options Options for the loader. See {@link PocketBaseLoaderOptions} for more details.\n * @param token The superuser token to authenticate the request.\n */\nexport async function getRemoteSchema(\n options: Pick<PocketBaseLoaderOptions, \"collectionName\" | \"url\">,\n token: string\n): Promise<PocketBaseCollection | undefined> {\n // Build URL and headers for the schema request\n const schemaUrl = new URL(\n `api/collections/${options.collectionName}`,\n options.url\n ).href;\n const schemaHeaders = new Headers();\n schemaHeaders.set(\"Authorization\", token);\n\n // Fetch the schema\n const schemaRequest = await fetch(schemaUrl, {\n headers: schemaHeaders\n });\n\n // If the request was not successful, try another method\n if (!schemaRequest.ok) {\n const errorResponse = pocketBaseErrorResponse.parse(\n await schemaRequest.json()\n );\n const errorMessage = `Fetching schema from ${options.collectionName} failed with status code ${schemaRequest.status}.\\nReason: ${errorResponse.message}`;\n console.error(errorMessage);\n\n return undefined;\n }\n\n // Get the schema from the response\n const response = pocketBaseCollection.parse(await schemaRequest.json());\n return response;\n}\n","import { z } from \"astro/zod\";\nimport type {\n PocketBaseCollection,\n PocketBaseSchemaEntry\n} from \"../types/pocketbase-schema.type\";\n\nexport interface ParseSchemaOptions {\n hasSuperuserRights: boolean;\n fieldsToInclude?: Array<string>;\n experimentalLiveTypesOnly?: boolean;\n}\n\n/**\n * Converts PocketBase collection fields into Zod types, handling field types, required status, and custom schemas.\n */\nexport function parseSchema(\n collection: PocketBaseCollection,\n customSchemas: Record<string, z.ZodType> | undefined,\n options: ParseSchemaOptions\n): Record<string, z.ZodType> {\n // Prepare the schemas fields\n const fields: Record<string, z.ZodType> = {};\n\n // Parse every field in the schema\n for (const field of collection.fields) {\n // If fieldsToInclude is specified, only include fields that are in the list\n if (\n options.fieldsToInclude &&\n !options.fieldsToInclude.includes(field.name)\n ) {\n continue;\n }\n\n // Skip hidden fields if the user does not have superuser rights\n if (field.hidden && !options.hasSuperuserRights) {\n if (options.fieldsToInclude) {\n console.warn(\n `\"${field.name}\" is requested but hidden. Provide superuser credentials to include this field.`\n );\n }\n\n continue;\n }\n\n let fieldType: z.ZodType;\n\n // Determine the field type and create the corresponding Zod type\n switch (field.type) {\n case \"number\":\n fieldType = z.number();\n break;\n case \"bool\":\n fieldType = z.boolean();\n break;\n case \"date\":\n case \"autodate\":\n if (options.experimentalLiveTypesOnly) {\n // If experimental live types only mode is enabled, treat dates as strings\n fieldType = z.string();\n break;\n }\n // Coerce and parse the value as a date\n fieldType = z.coerce.date();\n break;\n case \"geoPoint\":\n fieldType = z.object({\n lon: z.number(),\n lat: z.number()\n });\n break;\n case \"select\": {\n if (!field.values) {\n throw new Error(\n `Field ${field.name} is of type \"select\" but has no values defined.`\n );\n }\n\n // Create an enum for the select values\n // oxlint-disable-next-line no-unsafe-type-assertion\n const values = z.enum(field.values as [string, ...Array<string>]);\n\n // Parse the field type based on the number of values it can have\n fieldType = parseSingleOrMultipleValues(field, values);\n break;\n }\n case \"relation\":\n case \"file\":\n // NOTE: Relations are currently not supported and are treated as strings\n // NOTE: Files are later transformed to URLs\n\n // Parse the field type based on the number of values it can have\n fieldType = parseSingleOrMultipleValues(field, z.string());\n break;\n case \"json\":\n // Use the user defined custom schema for the field or fallback to unknown\n fieldType = customSchemas?.[field.name] ?? z.unknown();\n break;\n default:\n // Default to a string\n fieldType = z.string();\n break;\n }\n\n const isRequired =\n // Check if the field is required\n field.required ||\n // `onCreate autodate` fields are always set\n (field.type === \"autodate\" && field.onCreate) ||\n // number and bool fields are always set\n field.type === \"number\" ||\n field.type === \"bool\";\n\n // If the field is not required, mark it as optional\n if (!isRequired) {\n fieldType = z.preprocess(\n (val) => val || undefined,\n z.optional(fieldType)\n );\n }\n\n // Add the field to the fields object\n fields[field.name] = fieldType.meta({\n id: field.id,\n title: field.name,\n description: getFieldDescription(field)\n });\n }\n\n return fields;\n}\n\n/**\n * Parse the field type based on the number of values it can have\n *\n * @param field Field to parse\n * @param type Type of each value\n *\n * @returns The parsed field type\n */\nfunction parseSingleOrMultipleValues(\n field: PocketBaseSchemaEntry,\n type: z.ZodType\n): z.ZodType {\n // If the select allows multiple values, create an array of the enum\n if (field.maxSelect === undefined || field.maxSelect === 1) {\n return type;\n }\n\n return z.array(type);\n}\n\n/**\n * Get the description for a field based on its help text and type.\n */\nfunction getFieldDescription(field: PocketBaseSchemaEntry): string | undefined {\n switch (true) {\n case !!field.help:\n return field.help;\n case field.type === \"autodate\" && field.onUpdate:\n return \"Date when the entry was last updated. This field is automatically updated by PocketBase whenever the entry is updated.\";\n case field.type === \"autodate\" && field.onCreate:\n return \"Date when the entry was created. This field is automatically set by PocketBase when the entry is created.\";\n case field.name === \"id\":\n return \"The unique identifier for the entry.\";\n case field.hidden:\n return \"This field is hidden and may require superuser credentials to access.\";\n default:\n return undefined;\n }\n}\n","import fs from \"fs/promises\";\nimport path from \"path\";\nimport type { PocketBaseCollection } from \"../types/pocketbase-schema.type\";\nimport { pocketBaseDatabase } from \"../types/pocketbase-schema.type\";\n\n/**\n * Reads the local PocketBase schema file and returns the schema for the specified collection.\n *\n * @param localSchemaPath Path to the local schema file.\n * @param collectionName Name of the collection to get the schema for.\n */\nexport async function readLocalSchema(\n localSchemaPath: string,\n collectionName: string\n): Promise<PocketBaseCollection | undefined> {\n const realPath = path.join(process.cwd(), localSchemaPath);\n\n try {\n // Read the schema file\n const schemaFile = await fs.readFile(realPath, \"utf-8\");\n const fileContent = pocketBaseDatabase.safeParse(JSON.parse(schemaFile));\n\n // Check if the database file is valid\n if (!fileContent.success) {\n throw new Error(\"Invalid schema file\");\n }\n\n // Find and return the schema for the collection\n const schema = fileContent.data.find(\n (collection) => collection.name === collectionName\n );\n\n if (!schema) {\n throw new Error(\n `Collection \"${collectionName}\" not found in schema file`\n );\n }\n\n return schema;\n } catch (error) {\n console.error(\n `Failed to read local schema from ${localSchemaPath}. No types will be generated.\\nReason: ${error}`\n );\n return undefined;\n }\n}\n","import { z } from \"astro/zod\";\nimport type { PocketBaseEntry } from \"../types/pocketbase-entry.type\";\nimport type { PocketBaseSchemaEntry } from \"../types/pocketbase-schema.type\";\n\n/**\n * Transforms file names in a PocketBase entry to file URLs.\n *\n * @param baseUrl URL of the PocketBase instance.\n * @param collection Collection of the entry.\n * @param entry Entry to transform.\n */\nexport function transformFiles(\n baseUrl: string,\n fileFields: Array<PocketBaseSchemaEntry>,\n entry: PocketBaseEntry\n): PocketBaseEntry {\n // Transform all file names to file URLs\n for (const field of fileFields) {\n const fieldName = field.name;\n\n if (field.maxSelect === 1) {\n const fileName = z.optional(z.string()).parse(entry[fieldName]);\n // Check if a file name is present\n if (!fileName) {\n continue;\n }\n\n // Transform the file name to a file URL\n entry[fieldName] = transformFileUrl(\n baseUrl,\n entry.collectionName,\n entry.id,\n fileName\n );\n } else {\n const fileNames = z.optional(z.array(z.string())).parse(entry[fieldName]);\n // Check if file names are present\n if (!fileNames) {\n continue;\n }\n\n // Transform all file names to file URLs\n entry[fieldName] = fileNames.map((file) =>\n transformFileUrl(baseUrl, entry.collectionName, entry.id, file)\n );\n }\n }\n\n return entry;\n}\n\n/**\n * Transforms a file name to a PocketBase file URL.\n *\n * @param base Base URL of the PocketBase instance.\n * @param collectionName Name of the collection.\n * @param entryId ID of the entry.\n * @param file Name of the file.\n */\nexport function transformFileUrl(\n base: string,\n collectionName: string,\n entryId: string,\n file: string\n): string {\n return new URL(`api/files/${collectionName}/${entryId}/${file}`, base).href;\n}\n","import { z } from \"astro/zod\";\nimport type { PocketBaseLoaderOptions } from \"../types/pocketbase-loader-optio