astro-loader-pocketbase 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,42 @@
1
+ # astro-pocketbase-loader
2
+
3
+ This package is a simple loader to load data from a PocketBase database into Astro using the [Astro Loader API](https://5-0-0-beta.docs.astro.build/en/reference/loader-reference/) introduced in Astro 5.
4
+
5
+ ## Usage
6
+
7
+ In your content configuration file, you can use the `pocketbaseLoader` function to use your PocketBase database as a data source.
8
+
9
+ ```ts
10
+ import { pocketbaseLoader } from "astro-pocketbase-loader";
11
+ import { defineCollection } from "astro:content";
12
+
13
+ const blog = defineCollection({
14
+ loader: pocketbaseLoader({
15
+ url: "https://<your-pocketbase-url>",
16
+ collectionName: "<collection-in-pocketbase>",
17
+ content: "<field-in-collection>"
18
+ })
19
+ });
20
+ ```
21
+
22
+ ### Type Generation
23
+
24
+ The loader can automatically generate types for your collection.
25
+ To do this, you need to provide the email and password of an admin of the PocketBase instance.
26
+ Under the hood, the loader will use the [PocketBase API](https://pocketbase.io/docs/api-collections/#view-collection) to fetch the schema of your collection and generate types with Zod based on that schema.
27
+
28
+ ### Private Collections
29
+
30
+ If you want to access a private collection, you also need to provide the admin credentials.
31
+ Otherwise, you need to make the collection public in the PocketBase dashboard.
32
+
33
+ ### Options
34
+
35
+ | Option | Type | Required | Description |
36
+ | ---------------- | ------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------- |
37
+ | `url` | `string` | x | The URL of your PocketBase instance. |
38
+ | `collectionName` | `string` | x | The name of the collection in your PocketBase instance. |
39
+ | `content` | `string \| Array<string>` | x | The field in the collection to use as content. This can also be an array of fields. |
40
+ | `adminEmail` | `string` | | The email of the admin of the PocketBase instance. This is used for automatic type generation and access to private collections. |
41
+ | `adminPassword` | `string` | | The password of the admin of the PocketBase instance. This is used for automatic type generation and access to private collections. |
42
+ | `forceUpdate` | `boolean` | | If set to `true`, the loader will fetch every entry instead of only the ones modified since the last build. |
package/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ import { pocketbaseLoader } from "./src/pocketbase-loader";
2
+ import type { PocketBaseLoaderOptions } from "./src/types/pocketbase-loader-options.type";
3
+
4
+ export { pocketbaseLoader };
5
+ export type { PocketBaseLoaderOptions };
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "astro-loader-pocketbase",
3
+ "description": "A content loader for Astro that uses the PocketBase API",
4
+ "homepage": "https://github.com/pawcoding/astro-loader-pocketbase",
5
+ "version": "0.1.0",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": "./index.ts"
9
+ },
10
+ "files": [
11
+ "src",
12
+ "index.ts"
13
+ ],
14
+ "author": {
15
+ "name": "Luis Wolf",
16
+ "email": "development@pawcode.de",
17
+ "url": "https://pawcode.de"
18
+ },
19
+ "keywords": [
20
+ "withastro",
21
+ "astro",
22
+ "astro-loader",
23
+ "astro-content-loader",
24
+ "pocketbase"
25
+ ],
26
+ "scripts": {},
27
+ "devDependencies": {
28
+ "astro": "^5.0.0-beta.1"
29
+ },
30
+ "peerDependencies": {
31
+ "astro": "^5.0.0"
32
+ },
33
+ "dependencies": {
34
+ "@astrojs/check": "^0.9.3",
35
+ "typescript": "^5.6.2"
36
+ },
37
+ "license": "MIT"
38
+ }
@@ -0,0 +1,92 @@
1
+ import type { LoaderContext } from "astro/loaders";
2
+ import type { PocketBaseLoaderOptions } from "./types/pocketbase-loader-options.type";
3
+
4
+ /**
5
+ * Cleanup entries that are no longer in the collection.
6
+ *
7
+ * @param options Options for the loader.
8
+ * @param context Context of the loader.
9
+ * @param adminToken Admin token to access all resources.
10
+ */
11
+ export async function cleanupEntries(
12
+ options: PocketBaseLoaderOptions,
13
+ context: LoaderContext,
14
+ adminToken: string | undefined
15
+ ): Promise<void> {
16
+ // Build the URL for the collections endpoint
17
+ const collectionUrl = new URL(
18
+ `api/collections/${options.collectionName}/records`,
19
+ options.url
20
+ ).href;
21
+
22
+ // Create the headers for the request to append the admin token (if available)
23
+ const collectionHeaders = new Headers();
24
+ if (adminToken) {
25
+ collectionHeaders.set("Authorization", adminToken);
26
+ }
27
+
28
+ // Prepare pagination variables
29
+ let page = 1;
30
+ let totalPages = 0;
31
+ const entries = new Set<string>();
32
+
33
+ // Fetch all ids of the collection
34
+ do {
35
+ // Fetch ids from the collection
36
+ const collectionRequest = await fetch(
37
+ `${collectionUrl}?page=${page}&perPage=1000&fields=id`,
38
+ {
39
+ headers: collectionHeaders
40
+ }
41
+ );
42
+
43
+ // If the request was not successful, print the error message and return
44
+ if (!collectionRequest.ok) {
45
+ // If the collection is locked, an admin token is required
46
+ if (collectionRequest.status === 403) {
47
+ context.logger.error(
48
+ `The collection ${options.collectionName} is not accessible without an admin rights. Please provide an admin email and password in the config.`
49
+ );
50
+ return;
51
+ }
52
+
53
+ const reason = await collectionRequest
54
+ .json()
55
+ .then((data) => data.message);
56
+ const errorMessage = `Fetching ids from ${options.collectionName} failed with status code ${collectionRequest.status}.\nReason: ${reason}`;
57
+ context.logger.error(errorMessage);
58
+ return;
59
+ }
60
+
61
+ // Get the data from the response
62
+ const response = await collectionRequest.json();
63
+
64
+ // Add the ids to the set
65
+ for (const item of response.items) {
66
+ entries.add(item.id);
67
+ }
68
+
69
+ // Update the page and total pages
70
+ page = response.page;
71
+ totalPages = response.totalPages;
72
+ } while (page < totalPages);
73
+
74
+ let cleanedUp = 0;
75
+
76
+ // Get all ids of the entries in the store
77
+ const storedIds = context.store.keys();
78
+ for (const id of storedIds) {
79
+ // If the id is not in the entries set, remove the entry from the store
80
+ if (!entries.has(id)) {
81
+ context.store.delete(id);
82
+ cleanedUp++;
83
+ }
84
+ }
85
+
86
+ if (cleanedUp > 0) {
87
+ // Log the number of cleaned up entries
88
+ context.logger.info(
89
+ `Cleaned up ${cleanedUp} old entries for ${context.collection}`
90
+ );
91
+ }
92
+ }
@@ -0,0 +1,99 @@
1
+ import type { ZodSchema } from "astro/zod";
2
+ import { z } from "astro/zod";
3
+ import type { PocketBaseLoaderOptions } from "./types/pocketbase-loader-options.type";
4
+ import { getAdminToken } from "./utils/get-admin-token";
5
+ import { parseSchema } from "./utils/parse-schema";
6
+
7
+ /**
8
+ * Basic schema for every PocketBase collection.
9
+ */
10
+ const BASIC_SCHEMA = {
11
+ id: z.string().length(15),
12
+ collectionId: z.string().length(15),
13
+ collectionName: z.string(),
14
+ created: z.date({ coerce: true }),
15
+ updated: z.date({ coerce: true })
16
+ };
17
+
18
+ /**
19
+ * Generate a schema for the collection based on the collection's schema in PocketBase.
20
+ * If no login credentials are provided, a {@link BASIC_SCHEMA} for every PocketBase collection is returned.
21
+ *
22
+ * @param options Options for the loader. See {@link PocketBaseLoaderOptions} for more details.
23
+ */
24
+ export async function generateSchema(
25
+ options: PocketBaseLoaderOptions
26
+ ): Promise<ZodSchema> {
27
+ // TODO: Add support for local schema files
28
+
29
+ // If no admin email and password are provided, we can't get the schema from the API
30
+ if (!options.adminEmail || !options.adminPassword) {
31
+ console.warn(
32
+ "Make sure to add an admin email and password to the config to get automatic type generation."
33
+ );
34
+
35
+ // Return the basic schema
36
+ return z.object(BASIC_SCHEMA);
37
+ }
38
+
39
+ // Get the admin token
40
+ const token = await getAdminToken(
41
+ options.url,
42
+ options.adminEmail,
43
+ options.adminPassword
44
+ );
45
+
46
+ // If the token is invalid, return the basic schema
47
+ if (!token) {
48
+ return z.object(BASIC_SCHEMA);
49
+ }
50
+
51
+ // Build URL and headers for the schema request
52
+ const schemaUrl = new URL(
53
+ `api/collections/${options.collectionName}`,
54
+ options.url
55
+ ).href;
56
+ const schemaHeaders = new Headers();
57
+ schemaHeaders.set("Authorization", token);
58
+
59
+ // Fetch the schema
60
+ const schemaRequest = await fetch(schemaUrl, {
61
+ headers: schemaHeaders
62
+ });
63
+
64
+ // If the request was not successful, return the basic schema
65
+ if (!schemaRequest.ok) {
66
+ const reason = await schemaRequest.json().then((data) => data.message);
67
+ const errorMessage = `Fetching schema from ${options.collectionName} failed with status code ${schemaRequest.status}.\nReason: ${reason}`;
68
+ console.error(errorMessage);
69
+
70
+ return z.object(BASIC_SCHEMA);
71
+ }
72
+
73
+ // Get the schema from the response
74
+ const schema = await schemaRequest.json();
75
+
76
+ // Parse the schema
77
+ const fields = parseSchema(schema.schema);
78
+
79
+ // Check if the content field is present
80
+ if (typeof options.content === "string" && !fields[options.content]) {
81
+ console.error(
82
+ `The content field "${options.content}" is not present in the schema of the collection "${options.collectionName}".`
83
+ );
84
+ } else if (Array.isArray(options.content)) {
85
+ for (const field of options.content) {
86
+ if (!fields[field]) {
87
+ console.error(
88
+ `The content field "${field}" is not present in the schema of the collection "${options.collectionName}".`
89
+ );
90
+ }
91
+ }
92
+ }
93
+
94
+ // Combine the basic schema with the parsed fields
95
+ return z.object({
96
+ ...BASIC_SCHEMA,
97
+ ...fields
98
+ });
99
+ }
@@ -0,0 +1,97 @@
1
+ import type { LoaderContext } from "astro/loaders";
2
+ import type { PocketBaseLoaderOptions } from "./types/pocketbase-loader-options.type";
3
+ import { parseEntry } from "./utils/parse-entry";
4
+
5
+ /**
6
+ * Load (modified) entries from a PocketBase collection.
7
+ *
8
+ * @param options Options for the loader.
9
+ * @param context Context of the loader.
10
+ * @param adminToken Admin token to access all resources.
11
+ * @param lastModified Date of the last fetch to only update changed entries.
12
+ */
13
+ export async function loadEntries(
14
+ options: PocketBaseLoaderOptions,
15
+ context: LoaderContext,
16
+ adminToken: string | undefined,
17
+ lastModified: string | undefined
18
+ ): Promise<void> {
19
+ // Build the URL for the collections endpoint
20
+ const collectionUrl = new URL(
21
+ `api/collections/${options.collectionName}/records`,
22
+ options.url
23
+ ).href;
24
+
25
+ // Create the headers for the request to append the admin token (if available)
26
+ const collectionHeaders = new Headers();
27
+ if (adminToken) {
28
+ collectionHeaders.set("Authorization", adminToken);
29
+ }
30
+
31
+ // Log the fetching of the entries
32
+ context.logger.info(
33
+ `Fetching${lastModified ? " modified" : ""} data for ${
34
+ context.collection
35
+ } from ${collectionUrl}${
36
+ lastModified ? ` starting at ${lastModified}` : ""
37
+ }${adminToken ? " with admin token" : ""}`
38
+ );
39
+
40
+ // Prepare pagination variables
41
+ let page = 1;
42
+ let totalPages = 0;
43
+ let entries = 0;
44
+
45
+ // Fetch all (modified) entries
46
+ do {
47
+ // Fetch entries from the collection
48
+ // If `lastModified` is set, only fetch entries that have been modified since the last fetch
49
+ const collectionRequest = await fetch(
50
+ `${collectionUrl}?page=${page}&perPage=100&sort=-updated,id${
51
+ lastModified ? `&filter=(updated>"${lastModified}")` : ""
52
+ }`,
53
+ {
54
+ headers: collectionHeaders
55
+ }
56
+ );
57
+
58
+ // If the request was not successful, print the error message and return
59
+ if (!collectionRequest.ok) {
60
+ // If the collection is locked, an admin token is required
61
+ if (collectionRequest.status === 403) {
62
+ context.logger.error(
63
+ `The collection ${options.collectionName} is not accessible without an admin rights. Please provide an admin email and password in the config.`
64
+ );
65
+ return;
66
+ }
67
+
68
+ // Get the reason for the error
69
+ const reason = await collectionRequest
70
+ .json()
71
+ .then((data) => data.message);
72
+ const errorMessage = `Fetching data from ${options.collectionName} failed with status code ${collectionRequest.status}.\nReason: ${reason}`;
73
+ context.logger.error(errorMessage);
74
+ return;
75
+ }
76
+
77
+ // Get the data from the response
78
+ const response = await collectionRequest.json();
79
+
80
+ // Parse and store the entries
81
+ for (const entry of response.items) {
82
+ await parseEntry(entry, context, options.content);
83
+ }
84
+
85
+ // Update the page and total pages
86
+ page = response.page;
87
+ totalPages = response.totalPages;
88
+ entries += response.items.length;
89
+ } while (page < totalPages);
90
+
91
+ // Log the number of fetched entries
92
+ context.logger.info(
93
+ `Fetched ${entries}${lastModified ? " changed" : ""} entries for ${
94
+ context.collection
95
+ }`
96
+ );
97
+ }
@@ -0,0 +1,55 @@
1
+ import type { Loader, LoaderContext } from "astro/loaders";
2
+ import { cleanupEntries } from "./cleanup-entries";
3
+ import { generateSchema } from "./generate-schema";
4
+ import { loadEntries } from "./load-entries";
5
+ import type { PocketBaseLoaderOptions } from "./types/pocketbase-loader-options.type";
6
+ import { getAdminToken } from "./utils/get-admin-token";
7
+
8
+ /**
9
+ * Loader for collections stored in PocketBase.
10
+ *
11
+ * @param options Options for the loader. See {@link PocketBaseLoaderOptions} for more details.
12
+ */
13
+ export function pocketbaseLoader(options: PocketBaseLoaderOptions): Loader {
14
+ return {
15
+ name: "pocketbase-loader",
16
+ load: async (context: LoaderContext): Promise<void> => {
17
+ // Get the date of the last fetch to only update changed entries.
18
+ // If `forceUpdate` is set to `true`, this will be `undefined` to fetch all entries again.
19
+ const lastModified = options.forceUpdate
20
+ ? undefined
21
+ : context.meta.get("last-modified");
22
+
23
+ // Clear the store if we want to fetch all entries again
24
+ if (options.forceUpdate) {
25
+ context.store.clear();
26
+ }
27
+
28
+ // Try to get an admin token to access all resources.
29
+ let token: string | undefined;
30
+ if (options.adminEmail && options.adminPassword) {
31
+ token = await getAdminToken(
32
+ options.url,
33
+ options.adminEmail,
34
+ options.adminPassword,
35
+ context.logger
36
+ );
37
+ }
38
+
39
+ if (context.store.keys().length > 0) {
40
+ // Cleanup entries that are no longer in the collection
41
+ await cleanupEntries(options, context, token);
42
+ }
43
+
44
+ // Load the (modified) entries
45
+ await loadEntries(options, context, token, lastModified);
46
+
47
+ // Set the last modified date to the current date
48
+ context.meta.set("last-modified", new Date().toISOString());
49
+ },
50
+ schema: async () => {
51
+ // Generate the schema for the collection according to the API
52
+ return await generateSchema(options);
53
+ }
54
+ };
55
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Options for the PocketBase loader.
3
+ */
4
+ export type PocketBaseLoaderOptions = {
5
+ /**
6
+ * URL of the PocketBase instance.
7
+ */
8
+ url: string;
9
+ /**
10
+ * Name of the collection in PocketBase.
11
+ */
12
+ collectionName: string;
13
+ /**
14
+ * Content of the collection in PocketBase.
15
+ * This must be the name of a field in the collection that contains the content.
16
+ * The content will be parsed as HTML and rendered to the page.
17
+ *
18
+ * If you want to render multiple fields as main content, you can pass an array of field names.
19
+ * The loader will concatenate the content of all fields in the order they are defined in the array.
20
+ * Each block will be contained in a `<section>` element.
21
+ */
22
+ content: string | Array<string>;
23
+ /**
24
+ * Email of an admin to get full access to the PocketBase instance.
25
+ * Together with `adminPassword` this is required to get automatic type generation and to access all resources even if they are not public.
26
+ */
27
+ adminEmail?: string;
28
+ /**
29
+ * Password of the admin to get full access to the PocketBase instance.
30
+ * Together with `adminEmail` this is required to get automatic type generation and to access all resources even if they are not public.
31
+ */
32
+ adminPassword?: string;
33
+ /**
34
+ * By default, the loader will only fetch entries that have been modified since the last fetch.
35
+ * If you want to fetch all entries, set this to `true`.
36
+ */
37
+ forceUpdate?: boolean;
38
+ };
@@ -0,0 +1,46 @@
1
+ import type { AstroIntegrationLogger } from "astro";
2
+
3
+ /**
4
+ * This function will get an admin token from the given PocketBase instance.
5
+ *
6
+ * @param url URL of the PocketBase instance
7
+ * @param email Email of the admin
8
+ * @param password Password of the admin
9
+ *
10
+ * @returns An admin token to access all resources of the PocketBase instance.
11
+ */
12
+ export async function getAdminToken(
13
+ url: string,
14
+ email: string,
15
+ password: string,
16
+ logger?: AstroIntegrationLogger
17
+ ): Promise<string | undefined> {
18
+ // Build the URL for the login endpoint
19
+ const loginUrl = new URL(`api/admins/auth-with-password`, url).href;
20
+
21
+ // Create a new FormData object to send the login data
22
+ const loginData = new FormData();
23
+ loginData.set("identity", email);
24
+ loginData.set("password", password);
25
+
26
+ // Send the login request to get a token
27
+ const loginRequest = await fetch(loginUrl, {
28
+ method: "POST",
29
+ body: loginData
30
+ });
31
+
32
+ // If the login request was not successful, print the error message and return undefined
33
+ if (!loginRequest.ok) {
34
+ const reason = await loginRequest.json().then((data) => data.message);
35
+ const errorMessage = `The given email / password for ${url} was not correct. Astro can't generate type definitions automatically and may not have access to all resources.\nReason: ${reason}`;
36
+ if (logger) {
37
+ logger.error(errorMessage);
38
+ } else {
39
+ console.error(errorMessage);
40
+ }
41
+ return undefined;
42
+ }
43
+
44
+ // Return the token
45
+ return await loginRequest.json().then((data) => data.token);
46
+ }
@@ -0,0 +1,49 @@
1
+ import type { LoaderContext } from "astro/loaders";
2
+
3
+ /**
4
+ * Parse an entry from PocketBase to match the schema and store it in the store.
5
+ *
6
+ * @param entry Entry to parse.
7
+ * @param context Context of the loader.
8
+ * @param contentFields Field(s) to use as content for the entry.
9
+ * If multiple fields are used, they will be concatenated and wrapped in `<section>` elements.
10
+ */
11
+ export async function parseEntry(
12
+ entry: Record<string, any>,
13
+ { generateDigest, parseData, store }: LoaderContext,
14
+ contentFields: string | Array<string>
15
+ ): Promise<void> {
16
+ // Parse the data to match the schema
17
+ // This will throw an error if the data does not match the schema
18
+ const data = await parseData({
19
+ id: entry.id,
20
+ data: entry
21
+ });
22
+
23
+ // Generate a digest for the entry
24
+ // We can use the updated data as an identifier if the entry has changed since PocketBase automatically updates this value on every change
25
+ const digest = generateDigest(entry.updated);
26
+
27
+ // Generate the content for the entry
28
+ let content: string;
29
+ if (typeof contentFields === "string") {
30
+ // Only one field is used as content
31
+ content = entry[contentFields];
32
+ } else {
33
+ // Multiple fields are used as content, wrap each block in a section and concatenate them
34
+ content = contentFields
35
+ .map((field) => entry[field])
36
+ .map((block) => `<section>${block}</section>`)
37
+ .join("");
38
+ }
39
+
40
+ // Store the entry
41
+ store.set({
42
+ id: entry.id,
43
+ data,
44
+ digest,
45
+ rendered: {
46
+ html: content
47
+ }
48
+ });
49
+ }
@@ -0,0 +1,81 @@
1
+ import { z } from "astro/zod";
2
+
3
+ export function parseSchema(
4
+ schema: Array<Record<string, any>>
5
+ ): Record<string, z.ZodType> {
6
+ // Prepare the schemas fields
7
+ const fields: Record<string, z.ZodType> = {};
8
+
9
+ // Parse every field in the schema
10
+ for (const field of schema) {
11
+ let fieldType;
12
+
13
+ // Determine the field type and create the corresponding Zod type
14
+ switch (field.type) {
15
+ case "number":
16
+ // Coerce the value to a number
17
+ fieldType = z.number({ coerce: true });
18
+ break;
19
+ case "bool":
20
+ // Coerce the value to a boolean
21
+ fieldType = z.boolean({ coerce: true });
22
+ break;
23
+ case "date":
24
+ // Coerce and parse the value as a date
25
+ fieldType = z.date({ coerce: true });
26
+ break;
27
+ case "select":
28
+ // Create an enum for the select values
29
+ const values = z.enum(field.options.values);
30
+
31
+ // Parse the field type based on the number of values it can have
32
+ fieldType = parseSingleOrMultipleValues(field as any, values);
33
+ break;
34
+ case "relation":
35
+ case "file":
36
+ // NOTE: Relations and files are currently not supported and are treated as strings
37
+
38
+ // Parse the field type based on the number of values it can have
39
+ fieldType = parseSingleOrMultipleValues(field as any, z.string());
40
+ break;
41
+ case "json":
42
+ // Parse the field as an object
43
+ fieldType = z.object({});
44
+ break;
45
+ default:
46
+ // Default to a string
47
+ fieldType = z.string();
48
+ break;
49
+ }
50
+
51
+ // If the field is not required, mark it as optional
52
+ if (!field.required) {
53
+ fieldType.isOptional();
54
+ }
55
+
56
+ // Add the field to the fields object
57
+ fields[field.name] = fieldType;
58
+ }
59
+
60
+ return fields;
61
+ }
62
+
63
+ /**
64
+ * Parse the field type based on the number of values it can have
65
+ *
66
+ * @param field Field to parse
67
+ * @param type Type of each value
68
+ *
69
+ * @returns The parsed field type
70
+ */
71
+ function parseSingleOrMultipleValues(
72
+ field: { options: { maxSelect: number } },
73
+ type: z.ZodType
74
+ ) {
75
+ // If the select allows multiple values, create an array of the enum
76
+ if (field.options.maxSelect === 1) {
77
+ return type;
78
+ } else {
79
+ return z.array(type);
80
+ }
81
+ }