Skip to content

Commit 974caae

Browse files
pawcodingkmishmaellujoho
authored
feat(entry): add option for custom id field (#14)
* chore(loader): add support for custom unique identifier * fix(parse-entry): use custom entry ID if available * refactor(*): update custom id field logic and documentation * build(deps): update dependencies * fix(cleanup): use PocketBase id in cleanup function * fix(schema): check for presence of custom id field * chore(release): 0.5.0-custom-id-rc.1 * fix(schema): check for valid type of custom id field * fix(parse-entry): print warning for different entries with identical id * chore(release): 0.5.0-custom-id-rc.2 * Fix typo --------- Co-authored-by: Kibet Ishmael <kmishmael@gmail.com> Co-authored-by: lujoho <104842986+lujoho@users.noreply.github.com>
1 parent 7dc5dde commit 974caae

File tree

9 files changed

+837
-583
lines changed

9 files changed

+837
-583
lines changed

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,25 @@ While the API only returns the filenames of these images and files, the loader w
6262
This doesn't mean that the files are downloaded during the build process.
6363
But you can directly use these URLs in your Astro components to display images or link to the files.
6464

65+
### Custom ids
66+
67+
By default, the loader will use the `id` field of the collection as the unique identifier.
68+
If you want to use another field as the id, e.g. a slug of the title, you can specify this field via the `id` option.
69+
70+
```ts
71+
const blog = defineCollection({
72+
loader: pocketbaseLoader({
73+
...options,
74+
id: "<field-in-collection>"
75+
})
76+
});
77+
```
78+
79+
Please note that the id should be unique for every entry in the collection.
80+
The loader will also automatically convert the value into a slug to be easily used in URLs.
81+
It's recommended to use e.g. the title of the entry to be easily searchable and readable.
82+
**Do not use e.g. rich text fields as ids.**
83+
6584
## Type generation
6685

6786
The loader can automatically generate types for your collection.
@@ -116,6 +135,7 @@ This manual schema will **always override the automatic type generation**.
116135
| `content` | `string \| Array<string>` | | The field in the collection to use as content. This can also be an array of fields. |
117136
| `adminEmail` | `string` | | The email of the admin of the PocketBase instance. This is used for automatic type generation and access to private collections. |
118137
| `adminPassword` | `string` | | The password of the admin of the PocketBase instance. This is used for automatic type generation and access to private collections. |
138+
| `id` | `string` | | The field in the collection to use as unique id. Defaults to `id`. |
119139
| `localSchema` | `string` | | The path to a local schema file. This is used for automatic type generation. |
120140
| `jsonSchemas` | `Record<string, z.ZodSchema>` | | A record of Zod schemas to use for type generation of `json` fields. |
121141
| `forceUpdate` | `boolean` | | If set to `true`, the loader will fetch every entry instead of only the ones modified since the last build. |

package-lock.json

Lines changed: 722 additions & 574 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "astro-loader-pocketbase",
3-
"version": "0.4.0",
3+
"version": "0.5.0-custom-id-rc.2",
44
"description": "A content loader for Astro that uses the PocketBase API",
55
"license": "MIT",
66
"author": "Luis Wolf <development@pawcode.de> (https://pawcode.de)",
@@ -24,7 +24,7 @@
2424
"@eslint/js": "^9.11.1",
2525
"@stylistic/eslint-plugin": "^2.8.0",
2626
"@types/node": "^22.7.4",
27-
"astro": "^5.0.0-beta.2",
27+
"astro": "^5.0.0-beta.6",
2828
"eslint": "^9.11.1",
2929
"globals": "^15.9.0",
3030
"husky": "^9.1.6",

src/cleanup-entries.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ export async function cleanupEntries(
7474
let cleanedUp = 0;
7575

7676
// Get all ids of the entries in the store
77-
const storedIds = context.store.keys();
77+
const storedIds = context.store.values().map((entry) => entry.data.id) as Array<string>;
7878
for (const id of storedIds) {
7979
// If the id is not in the entries set, remove the entry from the store
8080
if (!entries.has(id)) {

src/generate-schema.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { transformFiles } from "./utils/transform-files";
1111
* Basic schema for every PocketBase collection.
1212
*/
1313
const BASIC_SCHEMA = {
14-
id: z.string().length(15),
14+
id: z.string(),
1515
collectionId: z.string().length(15),
1616
collectionName: z.string(),
1717
created: z.coerce.date(),
@@ -29,6 +29,11 @@ const VIEW_SCHEMA = {
2929
updated: z.preprocess((val) => val || undefined, z.optional(z.coerce.date()))
3030
};
3131

32+
/**
33+
* Types of fields that can be used as an ID.
34+
*/
35+
const VALID_ID_TYPES = ["text", "number", "email", "url", "date"];
36+
3237
/**
3338
* Generate a schema for the collection based on the collection's schema in PocketBase.
3439
* By default, a basic schema is returned if no other schema is available.
@@ -65,6 +70,31 @@ export async function generateSchema(
6570
// Parse the schema
6671
const fields = parseSchema(collection, options.jsonSchemas);
6772

73+
// Check if custom id field is present
74+
if (options.id) {
75+
// Find the id field in the schema
76+
const idField = collection.schema.find(
77+
(field) => field.name === options.id
78+
);
79+
80+
// Check if the id field is present and of a valid type
81+
if (!idField) {
82+
console.error(
83+
`The id field "${options.id}" is not present in the schema of the collection "${options.collectionName}".`
84+
);
85+
} else if (!VALID_ID_TYPES.includes(idField.type)) {
86+
console.error(
87+
`The id field "${options.id}" for collection "${
88+
options.collectionName
89+
}" is of type "${
90+
idField.type
91+
}" which is not recommended. Please use one of the following types: ${VALID_ID_TYPES.join(
92+
", "
93+
)}.`
94+
);
95+
}
96+
}
97+
6898
// Check if the content field is present
6999
if (typeof options.content === "string" && !fields[options.content]) {
70100
console.error(

src/load-entries.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ export async function loadEntries(
8282

8383
// Parse and store the entries
8484
for (const entry of response.items) {
85-
await parseEntry(entry, context, options.content);
85+
await parseEntry(entry, context, options.id, options.content);
8686

8787
// Check if the entry has an `updated` column
8888
// This is used to enable the incremental fetching of entries

src/types/pocketbase-loader-options.type.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,15 @@ export interface PocketBaseLoaderOptions {
1212
* Name of the collection in PocketBase.
1313
*/
1414
collectionName: string;
15+
/**
16+
* Field that should be used as the unique identifier for the collection.
17+
* This must be the name of a field in the collection that contains unique values.
18+
* If not provided, the `id` field will be used.
19+
* The value of this field will be used in `getEntry` and `getEntries` to load the entry or entries.
20+
*
21+
* If the field is a string, it will be slugified to be used in the URL.
22+
*/
23+
id?: string;
1524
/**
1625
* Content of the collection in PocketBase.
1726
* This must be the name of a field in the collection that contains the content.

src/utils/parse-entry.ts

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,48 @@
11
import type { LoaderContext } from "astro/loaders";
22
import type { PocketBaseEntry } from "../types/pocketbase-entry.type";
3+
import { slugify } from "./slugify";
34

45
/**
56
* Parse an entry from PocketBase to match the schema and store it in the store.
67
*
78
* @param entry Entry to parse.
89
* @param context Context of the loader.
10+
* @param idField Field to use as id for the entry.
11+
* If not provided, the id of the entry will be used.
912
* @param contentFields Field(s) to use as content for the entry.
1013
* If multiple fields are used, they will be concatenated and wrapped in `<section>` elements.
1114
*/
1215
export async function parseEntry(
1316
entry: PocketBaseEntry,
14-
{ generateDigest, parseData, store }: LoaderContext,
17+
{ generateDigest, parseData, store, logger }: LoaderContext,
18+
idField?: string,
1519
contentFields?: string | Array<string>
1620
): Promise<void> {
21+
let id = entry.id;
22+
if (idField) {
23+
// Get the custom ID of the entry if it exists
24+
const customEntryId = entry[idField];
25+
26+
if (!customEntryId) {
27+
logger.warn(
28+
`The entry "${id}" does not have a value for field ${idField}. Using the default ID instead.`
29+
);
30+
} else {
31+
id = slugify(`${customEntryId}`);
32+
}
33+
}
34+
35+
const oldEntry = store.get(id);
36+
if (oldEntry && oldEntry.data.id !== entry.id) {
37+
logger.warn(
38+
`The entry "${entry.id}" seems to be a duplicate of "${oldEntry.data.id}". Please make sure to use unique IDs in the column "${idField}".`
39+
);
40+
}
41+
1742
// Parse the data to match the schema
1843
// This will throw an error if the data does not match the schema
1944
const data = await parseData({
20-
id: entry.id,
45+
id,
2146
data: entry
2247
});
2348

@@ -30,7 +55,7 @@ export async function parseEntry(
3055
if (!contentFields) {
3156
// Store the entry
3257
store.set({
33-
id: entry.id,
58+
id,
3459
data,
3560
digest
3661
});
@@ -52,7 +77,7 @@ export async function parseEntry(
5277

5378
// Store the entry
5479
store.set({
55-
id: entry.id,
80+
id,
5681
data,
5782
digest,
5883
rendered: {

src/utils/slugify.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/**
2+
* Convert a string to a slug.
3+
*
4+
* Example:
5+
* ```ts
6+
* slugify("Hello World!"); // hello-world
7+
* ```
8+
*/
9+
export function slugify(input: string): string {
10+
return input
11+
.toString()
12+
.toLowerCase()
13+
.replace(/\s+/g, "-") // Replace spaces with -
14+
.replace(/ä/g, "ae") // Replace umlauts
15+
.replace(/ö/g, "oe")
16+
.replace(/ü/g, "ue")
17+
.replace(/ß/g, "ss")
18+
.replace(/[^\w-]+/g, "") // Remove all non-word chars
19+
.replace(/--+/g, "-") // Replace multiple - with single -
20+
.replace(/^-+/, "") // Trim - from start of text
21+
.replace(/-+$/, ""); // Trim - from end of text
22+
}

0 commit comments

Comments
 (0)