astro-loader-pocketbase
Version:
A content loader for Astro that uses the PocketBase API
196 lines (175 loc) • 6.3 kB
text/typescript
import type { ZodSchema } from "astro/zod";
import { z } from "astro/zod";
import type { PocketBaseLoaderOptions } from "../types/pocketbase-loader-options.type";
import type { PocketBaseCollection } from "../types/pocketbase-schema.type";
import { combineFieldsForRequest } from "../utils/combine-fields-for-request";
import { extractFieldNames } from "../utils/extract-field-names";
import { formatFields } from "../utils/format-fields";
import { getRemoteSchema } from "./get-remote-schema";
import { parseSchema } from "./parse-schema";
import { readLocalSchema } from "./read-local-schema";
import { transformFiles } from "./transform-files";
/**
* Basic schema for every PocketBase collection.
*/
const BASIC_SCHEMA = {
id: z.string(),
collectionId: z.string(),
collectionName: z.string()
};
/**
* Types of fields that can be used as an ID.
*/
const VALID_ID_TYPES = ["text", "number", "email", "url", "date"];
/**
* Generate a schema for the collection based on the collection's schema in PocketBase.
* By default, a basic schema is returned if no other schema is available.
* If superuser credentials are provided, the schema is fetched from the PocketBase API.
* If a path to a local schema file is provided, the schema is read from the file.
*
* @param options Options for the loader. See {@link PocketBaseLoaderOptions} for more details.
* @param token The superuser token to authenticate the request.
*/
export async function generateSchema(
options: PocketBaseLoaderOptions,
token: string | undefined
): Promise<ZodSchema> {
let collection: PocketBaseCollection | undefined;
if (token) {
// Try to get the schema directly from the PocketBase instance
collection = await getRemoteSchema(options, token);
}
const hasSuperuserRights = !!collection || !!options.superuserCredentials;
// If the schema is not available, try to read it from a local schema file
if (!collection && options.localSchema) {
collection = await readLocalSchema(
options.localSchema,
options.collectionName
);
}
// If the schema is still not available, return the basic schema
if (!collection) {
console.error(
`No schema available for "${options.collectionName}". Only basic types are available. Please check your configuration and provide a valid schema file or superuser credentials.`
);
// Return the basic schema since every collection has at least these fields
return z.object(BASIC_SCHEMA);
}
// Get fields to include from options
const formattedFields = formatFields(options.fields);
const fieldNames = extractFieldNames(formattedFields);
const fieldsToInclude = combineFieldsForRequest(fieldNames, options);
// Parse the schema with optional field filtering
const fields = parseSchema(collection, options.jsonSchemas, {
hasSuperuserRights,
improveTypes: options.improveTypes,
fieldsToInclude,
experimentalLiveTypesOnly: options.experimental?.liveTypesOnly
});
// Do some sanity checks on the provided options
checkCustomIdField(collection, options);
checkContentField(fields, options);
checkUpdatedField(fields, collection, options);
// Combine the basic schema with the parsed fields
const schema = z.object({
...BASIC_SCHEMA,
...fields
});
// Get all file fields
const fileFields = collection.fields
.filter((field) => field.type === "file")
// Only show hidden fields if the user has superuser rights
.filter((field) => !field.hidden || hasSuperuserRights);
if (fileFields.length === 0) {
return schema;
}
// Transform file names to file urls
return schema.transform((entry) =>
transformFiles(options.url, fileFields, entry)
);
}
/**
* Check if the custom id field is present
*/
function checkCustomIdField(
collection: PocketBaseCollection,
options: PocketBaseLoaderOptions
): void {
if (!options.idField) {
return;
}
// Find the id field in the schema
const idField = collection.fields.find(
(field) => field.name === options.idField
);
// Check if the id field is present and of a valid type
if (!idField) {
console.error(
`The id field "${options.idField}" is not present in the schema of the collection "${options.collectionName}".`
);
} else if (!VALID_ID_TYPES.includes(idField.type)) {
console.error(
`The id field "${options.idField}" for collection "${
options.collectionName
}" is of type "${
idField.type
}" which is not recommended. Please use one of the following types: ${VALID_ID_TYPES.join(
", "
)}.`
);
}
}
/**
* Check if the content field(s) are present
*/
function checkContentField(
fields: Record<string, z.ZodType>,
options: PocketBaseLoaderOptions
): void {
if (
typeof options.contentFields === "string" &&
!fields[options.contentFields]
) {
console.error(
`The content field "${options.contentFields}" is not present in the schema of the collection "${options.collectionName}".`
);
} else if (Array.isArray(options.contentFields)) {
for (const field of options.contentFields) {
if (!fields[field]) {
console.error(
`The content field "${field}" is not present in the schema of the collection "${options.collectionName}".`
);
}
}
}
}
/**
* Check if the updated field is present
*/
function checkUpdatedField(
fields: Record<string, z.ZodType>,
collection: PocketBaseCollection,
options: PocketBaseLoaderOptions
): void {
if (!options.updatedField) {
return;
}
if (!fields[options.updatedField]) {
console.error(
`The field "${options.updatedField}" is not present in the schema of the collection "${options.collectionName}".\nThis will lead to errors when trying to fetch only updated entries.`
);
} else {
const updatedField = collection.fields.find(
(field) => field.name === options.updatedField
);
if (
!updatedField ||
updatedField.type !== "autodate" ||
!updatedField.onUpdate
) {
console.warn(
`The field "${options.updatedField}" is not of type "autodate" with the value "Update" or "Create/Update".\nMake sure that the field is automatically updated when the entry is updated!`
);
}
}
}