diff --git a/.nvmrc b/.nvmrc index 3f33098..85aee5a 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v20.12.0 \ No newline at end of file +v20 \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b81ffc7 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,44 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.1.0-beta.1] - 2025-11-28 + +### Added + +- **Custom ID Generator** - Added `idGenerator` option to `StrapiLoaderOptions` allowing custom ID generation from item data instead of using Strapi's `documentId` (#17) +- **Multiple Collections from Same Endpoint** - Added `collectionName` option to create multiple collections from the same Strapi content type with different configurations (#18) +- **Locale Support** - Added `locale` option supporting both single locale (string) and multiple locales (array) for i18n implementations (#19) + - Single locale: Fetch content for one specific language + - Multiple locales: Fetch all specified languages with locale-prefixed IDs + - Automatic `_locale` field added to stored items +- Comprehensive test suite with 27 new tests covering all new features + +### Changed + +- `StrapiLoaderOptions` interface extended with new optional fields: `collectionName`, `idGenerator`, and `locale` +- `StrapiCollection` interface extended to support new loader options +- `generateCollection` function updated to pass new options to loader + +### Fixed + +- N/A + +### Breaking Changes + +- None - fully backward compatible with existing implementations + +## [1.0.x] - 2024-XX-XX + +### Initial Release + +- Basic Strapi loader functionality +- Automatic data loading from Strapi Content API +- Query, filtering and population capabilities +- Authorization token support +- Astro collections system integration +- TypeScript typing support + diff --git a/README.md b/README.md index 400a157..a5e583c 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,9 @@ yarn add @sensinum/astro-strapi-loader * Authorization token support * Astro collections system integration * TypeScript typing +* **πŸ†• Custom ID generation** - Generate collection IDs from custom fields (e.g., slugs) +* **πŸ†• Multiple collections** - Create multiple collections from same endpoint +* **πŸ†• i18n support** - Built-in locale support for multilingual content ## πŸ–₯️ Usage @@ -70,13 +73,16 @@ try { url: import.meta.env.STRAPI_URL, token: import.meta.env.STRAPI_TOKEN, }, [{ // leave empty [] to fetch all the collections based on default Strapi settings - name: "my-collection", + name: "homepage", query: { - populate: { - // ... - }, + populate: { seo: true }, }, - }, 'yet-another-collection']); + }, { + name: "layout", + query: { + populate: { header: true, footer: true }, + }, + }, 'simple-collection-name']); // Can also pass just strings } catch (error) { console.error(error); } @@ -86,6 +92,8 @@ export const collections = { }; ``` +> **βœ… Backward Compatible:** Existing code works without any changes! + 2. Use in your Astro component: ```jsx @@ -131,9 +139,206 @@ const myCollection = await fetchContent({ |--------|------|----------|-------------| | `url` | `string` | Yes | Strapi API URL | | `token` | `string` | No | Strapi API access token | +| `collectionName` | `string` | No | Custom collection name (for multiple collections from same endpoint) | +| `idGenerator` | `function` | No | Custom function to generate IDs from item data | +| `locale` | `string \| string[]` | No | Single locale or array of locales for i18n support | > **⚠️ Note:** The token must have **read access** to both the **Content API** and the **Content-Type Builder API** *(ONLY to the "Get Content Types" endpoint)*. +### Mixing 1.0.x and 1.1.0+ + +You can mix old (simple) and new (extended) format in a single `generateCollections` call: + +```typescript +strapiCollections = await generateCollections({ + url: import.meta.env.STRAPI_URL, + token: import.meta.env.STRAPI_TOKEN, +}, [ + // βœ… Old format - works as before + { + name: "homepage", + query: { populate: { seo: true } } + }, + { + name: "layout", + query: { populate: { header: true, footer: true } } + }, + + // βœ… 1.1.0+ - with locale support + { + name: "pages", + collectionName: "pagesEN", + locale: "en", + query: { sort: ['publishedAt:desc'] } + }, + { + name: "pages", // Same endpoint, different config! + collectionName: "pagesDE", + locale: "de", + query: { sort: ['publishedAt:desc'] } + }, + + // βœ… 1.1.0+ - with custom ID + { + name: "blog-posts", + idGenerator: (data) => data.slug as string, + query: { filters: { published: true } } + }, + + // βœ… 1.1.0+ - combining all features + { + name: "articles", + collectionName: "articlesMultilang", + locale: ["en", "de", "fr"], + idGenerator: (data) => data.slug as string + } +]); + +// Result collections: +// - homepage (old format) +// - layout (old format) +// - pagesEN (1.1.0+) +// - pagesDE (1.1.0+) +// - blog-posts (1.1.0+) +// - articlesMultilang (1.1.0+) +``` + +### Advanced Usage Examples + +#### Custom ID Generation + +Use slugs or custom fields as collection IDs instead of Strapi's `documentId`: + +**Option A:** Using `generateCollections`: + +```typescript +strapiCollections = await generateCollections({ + url: import.meta.env.STRAPI_URL, + token: import.meta.env.STRAPI_TOKEN, +}, [{ + name: "pages", + idGenerator: (data) => data.slug as string, + query: { populate: { seo: true } } +}]); + +// Now you can use: getEntry('pages', 'about-us') +``` + +**Option B:** Using `strapiLoader` directly: + +```typescript +import { strapiLoader } from '@sensinum/astro-strapi-loader'; +import { defineCollection, z } from 'astro:content'; + +const pages = defineCollection({ + loader: strapiLoader('pages', { + url: import.meta.env.STRAPI_URL, + token: import.meta.env.STRAPI_TOKEN, + idGenerator: (data) => data.slug as string + }), + schema: z.object({ + slug: z.string(), + title: z.string(), + content: z.string() + }) +}); +``` + +#### Multiple Collections from Same Endpoint + +**Option A:** Using `generateCollections` (recommended for multiple collections): + +```typescript +strapiCollections = await generateCollections({ + url: import.meta.env.STRAPI_URL, + token: import.meta.env.STRAPI_TOKEN, +}, [{ + name: "pages", + collectionName: "pagesEN", + locale: "en", + query: { sort: ['publishedAt:desc'] } +}, { + name: "pages", // Same endpoint! + collectionName: "pagesDE", + locale: "de", + query: { sort: ['publishedAt:desc'] } +}]); + +// Now you have both 'pagesEN' and 'pagesDE' collections +``` + +**Option B:** Using `strapiLoader` directly: + +```typescript +const pagesEN = defineCollection({ + loader: strapiLoader('pages', { + url: import.meta.env.STRAPI_URL, + token: import.meta.env.STRAPI_TOKEN, + collectionName: 'pagesEN', + locale: 'en' + }), + schema: pageSchema +}); + +const pagesDE = defineCollection({ + loader: strapiLoader('pages', { + url: import.meta.env.STRAPI_URL, + token: import.meta.env.STRAPI_TOKEN, + collectionName: 'pagesDE', + locale: 'de' + }), + schema: pageSchema +}); + +export const collections = { pagesEN, pagesDE }; +``` + +#### Multiple Locales in Single Collection + +Fetch all language versions in one collection: + +```typescript +const pagesMultilang = defineCollection({ + loader: strapiLoader('pages', { + url: import.meta.env.STRAPI_URL, + token: import.meta.env.STRAPI_TOKEN, + locale: ['en', 'de', 'fr'] // Array of locales + }), + schema: z.object({ + title: z.string(), + content: z.string(), + _locale: z.string() // Automatically added by loader + }) +}); + +// Access by locale-prefixed ID +const page = await getEntry('pagesMultilang', 'en:documentId'); + +// Or filter by locale +const allPages = await getCollection('pagesMultilang'); +const enPages = allPages.filter(p => p.data._locale === 'en'); +``` + +#### Combining All Features + +```typescript +const blogMultilang = defineCollection({ + loader: strapiLoader('blog-posts', { + url: import.meta.env.STRAPI_URL, + token: import.meta.env.STRAPI_TOKEN, + collectionName: 'blogAllLanguages', + locale: ['en', 'de', 'fr'], + idGenerator: (data) => data.slug as string + }, { + sort: ['publishedAt:desc'], + filters: { status: { $eq: 'published' } } + }), + schema: blogSchema +}); + +// Access: getEntry('blogAllLanguages', 'en:my-post-slug') +``` + ### Query Options | Option | Type | Description | Documentation | diff --git a/package.json b/package.json index ba33cca..b366034 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@sensinum/astro-strapi-loader", - "version": "1.0.11", + "version": "1.1.0-beta.1", "description": "Astro loader for Strapi CMS", "keywords": [ "astro", diff --git a/src/utils/__tests__/loader-extended.test.ts b/src/utils/__tests__/loader-extended.test.ts new file mode 100644 index 0000000..26ea635 --- /dev/null +++ b/src/utils/__tests__/loader-extended.test.ts @@ -0,0 +1,424 @@ +import { LoaderContext } from "astro/loaders"; +import { strapiLoader } from "../loader"; +import { fetchContent } from "../strapi"; + +// Mock fetchContent +jest.mock("../strapi", () => ({ + fetchContent: jest.fn(), +})); + +describe("strapiLoader - Extended Features", () => { + const mockContext = { + store: { + clear: jest.fn(), + set: jest.fn(), + }, + logger: { + info: jest.fn(), + }, + parseData: jest.fn(({ data }) => Promise.resolve(data)), + generateDigest: jest.fn(() => "test-digest"), + meta: {}, + }; + + const options = { + url: "http://test-strapi.com", + token: "test-token", + }; + + beforeEach(() => { + jest.clearAllMocks(); + (fetchContent as jest.Mock).mockReset(); + mockContext.store.clear.mockClear(); + mockContext.store.set.mockClear(); + mockContext.logger.info.mockClear(); + mockContext.parseData.mockClear(); + mockContext.generateDigest.mockClear(); + }); + + describe("Custom ID Generator", () => { + it("should use custom ID generator", async () => { + const mockData = [ + { documentId: "1", slug: "test-slug-1", title: "Test Title 1" }, + { documentId: "2", slug: "test-slug-2", title: "Test Title 2" }, + ]; + + (fetchContent as jest.Mock).mockResolvedValueOnce({ + data: mockData, + }); + + const idGenerator = (data: Record) => data.slug as string; + const loader = strapiLoader("test-content", { ...options, idGenerator }); + await loader.load(mockContext as unknown as LoaderContext); + + expect(mockContext.store.set).toHaveBeenCalledWith({ + id: "test-slug-1", + data: mockData[0], + digest: "test-digest", + }); + expect(mockContext.store.set).toHaveBeenCalledWith({ + id: "test-slug-2", + data: mockData[1], + digest: "test-digest", + }); + }); + + it("should use complex ID generator", async () => { + const mockData = [ + { documentId: "1", category: "blog", slug: "post-1" }, + ]; + + (fetchContent as jest.Mock).mockResolvedValueOnce({ + data: mockData, + }); + + const idGenerator = (data: Record) => + `${data.category}/${data.slug}`; + const loader = strapiLoader("test-content", { ...options, idGenerator }); + await loader.load(mockContext as unknown as LoaderContext); + + expect(mockContext.store.set).toHaveBeenCalledWith({ + id: "blog/post-1", + data: mockData[0], + digest: "test-digest", + }); + }); + + it("should work for single item with custom ID", async () => { + const mockData = { + documentId: "1", + slug: "single-page", + title: "Single Page", + }; + + (fetchContent as jest.Mock).mockResolvedValueOnce({ + data: mockData, + }); + + const idGenerator = (data: Record) => data.slug as string; + const loader = strapiLoader("test-content", { ...options, idGenerator }); + await loader.load(mockContext as unknown as LoaderContext); + + expect(mockContext.store.set).toHaveBeenCalledWith({ + id: "single-page", + data: mockData, + digest: "test-digest", + }); + }); + }); + + describe("Custom Collection Name", () => { + it("should use custom collection name", async () => { + const mockData = [ + { documentId: "1", title: "Test Title 1" }, + ]; + + (fetchContent as jest.Mock).mockResolvedValueOnce({ + data: mockData, + }); + + const loader = strapiLoader("pages", { + ...options, + collectionName: "pagesEN" + }); + + expect(loader.name).toBe("pagesEN"); + await loader.load(mockContext as unknown as LoaderContext); + + expect(mockContext.logger.info).toHaveBeenCalledWith( + "[pagesEN] Loading data from Strapi..." + ); + }); + + it("should use default name when collectionName is not provided", async () => { + const mockData = [ + { documentId: "1", title: "Test Title 1" }, + ]; + + (fetchContent as jest.Mock).mockResolvedValueOnce({ + data: mockData, + }); + + const loader = strapiLoader("pages", options); + + expect(loader.name).toBe("strapi-loader"); + }); + }); + + describe("Locale Support", () => { + it("should handle single locale", async () => { + const mockData = [ + { documentId: "en-1", title: "English Title", locale: "en" }, + { documentId: "en-2", title: "English Title 2", locale: "en" }, + ]; + + (fetchContent as jest.Mock).mockResolvedValueOnce({ + data: mockData, + }); + + const loader = strapiLoader("pagesLocale", { + ...options, + locale: "en" + }); + await loader.load(mockContext as unknown as LoaderContext); + + expect(fetchContent).toHaveBeenCalledWith({ + url: options.url, + token: options.token, + contentType: "pagesLocale", + queryParams: "locale=en", + }); + + // With locale, we store items with locale-prefixed IDs + expect(mockContext.store.set).toHaveBeenCalledTimes(2); + const setCalls = (mockContext.store.set as jest.Mock).mock.calls; + expect(setCalls[0][0]).toMatchObject({ + id: "en:en-1", + data: expect.objectContaining({ documentId: "en-1", _locale: "en" }), + digest: "test-digest", + }); + expect(setCalls[1][0]).toMatchObject({ + id: "en:en-2", + data: expect.objectContaining({ documentId: "en-2", _locale: "en" }), + digest: "test-digest", + }); + }); + + it("should handle multiple locales", async () => { + const mockDataEN = [ + { documentId: "doc1", title: "English Title", locale: "en" }, + ]; + const mockDataDE = [ + { documentId: "doc1", title: "German Title", locale: "de" }, + ]; + + (fetchContent as jest.Mock) + .mockResolvedValueOnce({ data: mockDataEN }) + .mockResolvedValueOnce({ data: mockDataDE }); + + const loader = strapiLoader("pages", { + ...options, + locale: ["en", "de"] + }); + await loader.load(mockContext as unknown as LoaderContext); + + expect(fetchContent).toHaveBeenCalledTimes(2); + expect(fetchContent).toHaveBeenCalledWith({ + url: options.url, + token: options.token, + contentType: "pages", + queryParams: "locale=en", + }); + expect(fetchContent).toHaveBeenCalledWith({ + url: options.url, + token: options.token, + contentType: "pages", + queryParams: "locale=de", + }); + + expect(mockContext.store.set).toHaveBeenCalledTimes(2); + const setCalls = (mockContext.store.set as jest.Mock).mock.calls; + expect(setCalls[0][0]).toMatchObject({ + id: "en:doc1", + data: expect.objectContaining({ documentId: "doc1", _locale: "en" }), + digest: "test-digest", + }); + expect(setCalls[1][0]).toMatchObject({ + id: "de:doc1", + data: expect.objectContaining({ documentId: "doc1", _locale: "de" }), + digest: "test-digest", + }); + }); + + it("should handle multiple locales with empty response for one", async () => { + const mockDataEN = [ + { documentId: "doc2", title: "English Title", locale: "en" }, + ]; + + (fetchContent as jest.Mock) + .mockResolvedValueOnce({ data: mockDataEN }) + .mockResolvedValueOnce({ data: [] }); + + const loader = strapiLoader("pages", { + ...options, + locale: ["en", "de"] + }); + await loader.load(mockContext as unknown as LoaderContext); + + expect(fetchContent).toHaveBeenCalledTimes(2); + expect(mockContext.store.set).toHaveBeenCalledTimes(1); + const setCalls = (mockContext.store.set as jest.Mock).mock.calls; + expect(setCalls[0][0]).toMatchObject({ + id: "en:doc2", + data: expect.objectContaining({ documentId: "doc2", _locale: "en" }), + digest: "test-digest", + }); + }); + + it("should handle multiple locales for single type", async () => { + const mockDataEN = { documentId: "home1", title: "English Title", locale: "en" }; + const mockDataDE = { documentId: "home1", title: "German Title", locale: "de" }; + + (fetchContent as jest.Mock) + .mockResolvedValueOnce({ data: mockDataEN }) + .mockResolvedValueOnce({ data: mockDataDE }); + + const loader = strapiLoader("homepage", { + ...options, + locale: ["en", "de"] + }); + await loader.load(mockContext as unknown as LoaderContext); + + expect(mockContext.store.set).toHaveBeenCalledTimes(2); + const setCalls = (mockContext.store.set as jest.Mock).mock.calls; + expect(setCalls[0][0]).toMatchObject({ + id: "en:home1", + data: expect.objectContaining({ documentId: "home1", _locale: "en" }), + digest: "test-digest", + }); + expect(setCalls[1][0]).toMatchObject({ + id: "de:home1", + data: expect.objectContaining({ documentId: "home1", _locale: "de" }), + digest: "test-digest", + }); + }); + + it("should handle situation when all locales are empty", async () => { + (fetchContent as jest.Mock) + .mockResolvedValueOnce({ data: [] }) + .mockResolvedValueOnce({ data: [] }); + + const loader = strapiLoader("emptyPages", { + ...options, + locale: ["en", "de"] + }); + await loader.load(mockContext as unknown as LoaderContext); + + expect(mockContext.store.set).not.toHaveBeenCalled(); + expect(mockContext.logger.info).toHaveBeenCalledWith( + "[emptyPages] No data found in Strapi" + ); + }); + + it("should combine locale with other query parameters", async () => { + const mockData = [ + { documentId: "pub1", title: "Published Title", locale: "en" }, + ]; + + (fetchContent as jest.Mock).mockResolvedValueOnce({ + data: mockData, + }); + + const query = { + filters: { publishedAt: { $notNull: true } } + }; + + const loader = strapiLoader("publishedPages", { + ...options, + locale: "en" + }, query); + await loader.load(mockContext as unknown as LoaderContext); + + expect(fetchContent).toHaveBeenCalledWith( + expect.objectContaining({ + queryParams: expect.stringContaining("locale=en"), + }) + ); + }); + }); + + describe("Combined Features", () => { + it("should work with custom ID, collection name and locale", async () => { + const mockDataEN = [ + { documentId: "about-en", slug: "about", title: "About Us" }, + ]; + const mockDataDE = [ + { documentId: "about-de", slug: "uber-uns", title: "Über Uns" }, + ]; + + (fetchContent as jest.Mock) + .mockResolvedValueOnce({ data: mockDataEN }) + .mockResolvedValueOnce({ data: mockDataDE }); + + const idGenerator = (data: Record) => data.slug as string; + const loader = strapiLoader("multiPages", { + ...options, + locale: ["en", "de"], + collectionName: "pagesMultilang", + idGenerator, + }); + + expect(loader.name).toBe("pagesMultilang"); + await loader.load(mockContext as unknown as LoaderContext); + + expect(mockContext.store.set).toHaveBeenCalledTimes(2); + const setCalls = (mockContext.store.set as jest.Mock).mock.calls; + expect(setCalls[0][0]).toMatchObject({ + id: "en:about", + data: expect.objectContaining({ slug: "about", _locale: "en" }), + digest: "test-digest", + }); + expect(setCalls[1][0]).toMatchObject({ + id: "de:uber-uns", + data: expect.objectContaining({ slug: "uber-uns", _locale: "de" }), + digest: "test-digest", + }); + }); + + it("should work with custom ID and single locale", async () => { + const mockData = [ + { documentId: "post-doc-1", slug: "post-1", title: "Post 1" }, + ]; + + (fetchContent as jest.Mock).mockResolvedValueOnce({ + data: mockData, + }); + + const idGenerator = (data: Record) => data.slug as string; + const loader = strapiLoader("blogPosts", { + ...options, + locale: "en", + idGenerator, + }); + await loader.load(mockContext as unknown as LoaderContext); + + expect(mockContext.store.set).toHaveBeenCalledTimes(1); + const setCalls = (mockContext.store.set as jest.Mock).mock.calls; + expect(setCalls[0][0]).toMatchObject({ + id: "en:post-1", + data: expect.objectContaining({ slug: "post-1", _locale: "en" }), + digest: "test-digest", + }); + }); + }); + + describe("Backward Compatibility", () => { + it("should work without new options (backward compatible)", async () => { + const mockData = [ + { documentId: "bc-1", title: "Test Title 1" }, + { documentId: "bc-2", title: "Test Title 2" }, + ]; + + (fetchContent as jest.Mock).mockResolvedValueOnce({ + data: mockData, + }); + + const loader = strapiLoader("backwardCompat", options); + await loader.load(mockContext as unknown as LoaderContext); + + expect(mockContext.store.set).toHaveBeenCalledTimes(2); + const setCalls = (mockContext.store.set as jest.Mock).mock.calls; + expect(setCalls[0][0]).toMatchObject({ + id: "bc-1", + data: expect.objectContaining({ documentId: "bc-1" }), + digest: "test-digest", + }); + expect(setCalls[1][0]).toMatchObject({ + id: "bc-2", + data: expect.objectContaining({ documentId: "bc-2" }), + digest: "test-digest", + }); + }); + }); +}); + diff --git a/src/utils/__tests__/strapi-extended.test.ts b/src/utils/__tests__/strapi-extended.test.ts new file mode 100644 index 0000000..b44f69d --- /dev/null +++ b/src/utils/__tests__/strapi-extended.test.ts @@ -0,0 +1,580 @@ +import { generateCollection } from "../strapi"; +import { strapiLoader } from "../loader"; +import { z } from "zod"; + +jest.mock("../loader"); +jest.mock("astro:content", () => ({ + defineCollection: jest.fn((config: any) => config), +})); + +describe("Strapi Extended Features", () => { + const mockOptions = { + url: "http://test-strapi.com", + token: "test-token", + }; + + const mockStrapiLoader = strapiLoader as jest.MockedFunction; + + beforeEach(() => { + jest.clearAllMocks(); + mockStrapiLoader.mockImplementation((contentType: string, options: any, _query?: any) => ({ + name: options.collectionName || "strapi-loader", + load: jest.fn(), + })); + }); + + describe("generateCollection with Extended Options", () => { + const testSchema = z.object({ + documentId: z.string(), + title: z.string(), + }); + + it("should create collection with custom name", () => { + const collection = generateCollection( + "pages", + testSchema, + mockOptions, + { collectionName: "pagesEN" } + ); + + expect(collection).toBeDefined(); + expect(mockStrapiLoader).toHaveBeenCalledWith( + "pages", + expect.objectContaining({ collectionName: "pagesEN" }), + {} + ); + }); + + it("should create collection with custom ID generator", () => { + const idGenerator = (data: Record) => data.slug as string; + const collection = generateCollection( + "pages", + testSchema, + mockOptions, + { idGenerator } + ); + + expect(collection).toBeDefined(); + expect(mockStrapiLoader).toHaveBeenCalledWith( + "pages", + expect.objectContaining({ idGenerator }), + {} + ); + }); + + it("should create collection with single locale", () => { + const collection = generateCollection( + "pages", + testSchema, + mockOptions, + { locale: "en" } + ); + + expect(collection).toBeDefined(); + expect(mockStrapiLoader).toHaveBeenCalledWith( + "pages", + expect.objectContaining({ locale: "en" }), + {} + ); + }); + + it("should create collection with multiple locales", () => { + const collection = generateCollection( + "pages", + testSchema, + mockOptions, + { locale: ["en", "de"] } + ); + + expect(collection).toBeDefined(); + expect(mockStrapiLoader).toHaveBeenCalledWith( + "pages", + expect.objectContaining({ locale: ["en", "de"] }), + {} + ); + }); + + it("should create collection with all options", () => { + const idGenerator = (data: Record) => data.slug as string; + const collection = generateCollection( + "pages", + testSchema, + mockOptions, + { + collectionName: "pagesMultilang", + idGenerator, + locale: ["en", "de"], + query: { filters: { published: true } }, + } + ); + + expect(collection).toBeDefined(); + expect(mockStrapiLoader).toHaveBeenCalledWith( + "pages", + expect.objectContaining({ + collectionName: "pagesMultilang", + idGenerator, + locale: ["en", "de"], + }), + { filters: { published: true } } + ); + }); + + it("should create collection without additional options (backward compatible)", () => { + const collection = generateCollection( + "pages", + testSchema, + mockOptions + ); + + expect(collection).toBeDefined(); + expect(mockStrapiLoader).toHaveBeenCalledWith( + "pages", + expect.objectContaining({ url: mockOptions.url }), + {} + ); + }); + }); + + describe("StrapiCollection Interface", () => { + it("should accept old format (backward compatibility)", () => { + // Old format still works + const collection: import("../strapi").StrapiCollection = { + name: "homepage", + query: { + populate: { seo: true }, + filters: { published: true } + }, + }; + + expect(collection.name).toBe("homepage"); + expect(collection.query).toEqual({ + populate: { seo: true }, + filters: { published: true } + }); + expect(collection.collectionName).toBeUndefined(); + expect(collection.idGenerator).toBeUndefined(); + expect(collection.locale).toBeUndefined(); + }); + + it("should accept configuration with all new fields", () => { + const idGenerator = (data: Record) => data.slug as string; + + const collection: import("../strapi").StrapiCollection = { + name: "pages", + query: { filters: { published: true } }, + collectionName: "pagesEN", + idGenerator, + locale: ["en", "de"], + }; + + expect(collection.name).toBe("pages"); + expect(collection.collectionName).toBe("pagesEN"); + expect(collection.locale).toEqual(["en", "de"]); + expect(collection.idGenerator).toBe(idGenerator); + }); + + it("should accept configuration with single locale as string", () => { + const collection: import("../strapi").StrapiCollection = { + name: "pages", + locale: "en", + }; + + expect(collection.locale).toBe("en"); + }); + + it("should accept minimal configuration (backward compatible)", () => { + const collection: import("../strapi").StrapiCollection = { + name: "pages", + }; + + expect(collection.name).toBe("pages"); + expect(collection.query).toBeUndefined(); + expect(collection.collectionName).toBeUndefined(); + expect(collection.idGenerator).toBeUndefined(); + expect(collection.locale).toBeUndefined(); + }); + }); + + describe("Multiple Collections from Same Endpoint", () => { + it("should allow creating multiple collections from same endpoint", () => { + const testSchema = z.object({ + documentId: z.string(), + title: z.string(), + }); + + const collectionEN = generateCollection( + "pages", + testSchema, + mockOptions, + { + collectionName: "pagesEN", + locale: "en", + } + ); + + const collectionDE = generateCollection( + "pages", + testSchema, + mockOptions, + { + collectionName: "pagesDE", + locale: "de", + } + ); + + expect(collectionEN).toBeDefined(); + expect(collectionDE).toBeDefined(); + expect(mockStrapiLoader).toHaveBeenCalledWith( + "pages", + expect.objectContaining({ collectionName: "pagesEN", locale: "en" }), + {} + ); + expect(mockStrapiLoader).toHaveBeenCalledWith( + "pages", + expect.objectContaining({ collectionName: "pagesDE", locale: "de" }), + {} + ); + }); + + it("should allow different ID generators for different collections", () => { + const testSchema = z.object({ + documentId: z.string(), + slug: z.string(), + title: z.string(), + }); + + const idGeneratorSlug = (data: Record) => data.slug as string; + const idGeneratorId = (data: Record) => data.documentId as string; + + const collection1 = generateCollection( + "pages", + testSchema, + mockOptions, + { + collectionName: "pagesBySlug", + idGenerator: idGeneratorSlug, + } + ); + + const collection2 = generateCollection( + "pages", + testSchema, + mockOptions, + { + collectionName: "pagesById", + idGenerator: idGeneratorId, + } + ); + + expect(collection1).toBeDefined(); + expect(collection2).toBeDefined(); + expect(mockStrapiLoader).toHaveBeenCalledWith( + "pages", + expect.objectContaining({ collectionName: "pagesBySlug", idGenerator: idGeneratorSlug }), + {} + ); + expect(mockStrapiLoader).toHaveBeenCalledWith( + "pages", + expect.objectContaining({ collectionName: "pagesById", idGenerator: idGeneratorId }), + {} + ); + }); + }); + + describe("Locale Query Parameter Handling", () => { + it("should pass locale to loader", () => { + const testSchema = z.object({ + documentId: z.string(), + title: z.string(), + }); + + const collection = generateCollection( + "pages", + testSchema, + mockOptions, + { + locale: "en", + query: { filters: { published: true } }, + } + ); + + expect(collection).toBeDefined(); + expect(mockStrapiLoader).toHaveBeenCalledWith( + "pages", + expect.objectContaining({ locale: "en" }), + { filters: { published: true } } + ); + }); + + it("should pass array of locales to loader", () => { + const testSchema = z.object({ + documentId: z.string(), + title: z.string(), + }); + + const collection = generateCollection( + "pages", + testSchema, + mockOptions, + { + locale: ["en", "de", "fr"], + } + ); + + expect(collection).toBeDefined(); + expect(mockStrapiLoader).toHaveBeenCalledWith( + "pages", + expect.objectContaining({ locale: ["en", "de", "fr"] }), + {} + ); + }); + }); + + describe("Backward Compatibility with generateCollections", () => { + it("should work with classic array of objects format", () => { + const testSchema = z.object({ + documentId: z.string(), + title: z.string(), + }); + + // Simulate old usage pattern + const homepageQuery = { populate: { seo: true } }; + const layoutQuery = { populate: { header: true, footer: true } }; + + const homepage = generateCollection( + "homepage", + testSchema, + mockOptions, + { name: "homepage", query: homepageQuery } + ); + + const layout = generateCollection( + "layout", + testSchema, + mockOptions, + { name: "layout", query: layoutQuery } + ); + + expect(homepage).toBeDefined(); + expect(layout).toBeDefined(); + + // Verify queries are passed correctly + expect(mockStrapiLoader).toHaveBeenCalledWith( + "homepage", + expect.objectContaining({ url: mockOptions.url }), + homepageQuery + ); + expect(mockStrapiLoader).toHaveBeenCalledWith( + "layout", + expect.objectContaining({ url: mockOptions.url }), + layoutQuery + ); + }); + + it("should allow mixing old and new format in single call", () => { + const testSchema = z.object({ + documentId: z.string(), + title: z.string(), + slug: z.string().optional(), + }); + + const idGenerator = (data: Record) => data.slug as string; + + // Mix old and new formats + const collections = [ + // Old format - just name and query + generateCollection( + "homepage", + testSchema, + mockOptions, + { name: "homepage", query: { populate: { seo: true } } } + ), + // New format with locale + generateCollection( + "pages", + testSchema, + mockOptions, + { + name: "pages", + collectionName: "pagesEN", + locale: "en", + query: { sort: ['publishedAt:desc'] } + } + ), + // New format with custom ID + generateCollection( + "blog-posts", + testSchema, + mockOptions, + { + name: "blog-posts", + idGenerator, + query: { filters: { published: true } } + } + ), + // New format with all features + generateCollection( + "articles", + testSchema, + mockOptions, + { + name: "articles", + collectionName: "articlesDE", + locale: ["de", "fr"], + idGenerator, + } + ), + ]; + + // All should be defined + expect(collections[0]).toBeDefined(); // homepage - old format + expect(collections[1]).toBeDefined(); // pagesEN - new with locale + expect(collections[2]).toBeDefined(); // blog-posts - new with idGenerator + expect(collections[3]).toBeDefined(); // articlesDE - new with all + + // Verify each was called correctly + expect(mockStrapiLoader).toHaveBeenCalledWith( + "homepage", + expect.objectContaining({ url: mockOptions.url }), + { populate: { seo: true } } + ); + + expect(mockStrapiLoader).toHaveBeenCalledWith( + "pages", + expect.objectContaining({ + collectionName: "pagesEN", + locale: "en" + }), + { sort: ['publishedAt:desc'] } + ); + + expect(mockStrapiLoader).toHaveBeenCalledWith( + "blog-posts", + expect.objectContaining({ idGenerator }), + { filters: { published: true } } + ); + + expect(mockStrapiLoader).toHaveBeenCalledWith( + "articles", + expect.objectContaining({ + collectionName: "articlesDE", + locale: ["de", "fr"], + idGenerator + }), + {} + ); + }); + }); + + describe("Integration Example Scenarios", () => { + it("scenario: multilingual blog with custom slugs", () => { + const testSchema = z.object({ + documentId: z.string(), + slug: z.string(), + title: z.string(), + }); + + const idGenerator = (data: Record) => data.slug as string; + + const blogEN = generateCollection( + "blog-posts", + testSchema, + mockOptions, + { + collectionName: "blogEN", + locale: "en", + idGenerator, + query: { sort: ["publishedAt:desc"] }, + } + ); + + const blogDE = generateCollection( + "blog-posts", + testSchema, + mockOptions, + { + collectionName: "blogDE", + locale: "de", + idGenerator, + query: { sort: ["publishedAt:desc"] }, + } + ); + + expect(blogEN).toBeDefined(); + expect(blogDE).toBeDefined(); + expect(mockStrapiLoader).toHaveBeenCalledWith( + "blog-posts", + expect.objectContaining({ collectionName: "blogEN", locale: "en" }), + { sort: ["publishedAt:desc"] } + ); + expect(mockStrapiLoader).toHaveBeenCalledWith( + "blog-posts", + expect.objectContaining({ collectionName: "blogDE", locale: "de" }), + { sort: ["publishedAt:desc"] } + ); + }); + + it("scenario: single collection for multiple languages", () => { + const testSchema = z.object({ + documentId: z.string(), + slug: z.string(), + title: z.string(), + }); + + const idGenerator = (data: Record) => data.slug as string; + + const pagesMultilang = generateCollection( + "pages", + testSchema, + mockOptions, + { + collectionName: "pagesAll", + locale: ["en", "de", "fr"], + idGenerator, + } + ); + + expect(pagesMultilang).toBeDefined(); + expect(mockStrapiLoader).toHaveBeenCalledWith( + "pages", + expect.objectContaining({ + collectionName: "pagesAll", + locale: ["en", "de", "fr"], + idGenerator + }), + {} + ); + }); + + it("scenario: categories with custom ID based on path", () => { + const testSchema = z.object({ + documentId: z.string(), + category: z.string(), + slug: z.string(), + title: z.string(), + }); + + const idGenerator = (data: Record) => + `${data.category}/${data.slug}`; + + const posts = generateCollection( + "posts", + testSchema, + mockOptions, + { + idGenerator, + query: { populate: "*" }, + } + ); + + expect(posts).toBeDefined(); + expect(mockStrapiLoader).toHaveBeenCalledWith( + "posts", + expect.objectContaining({ idGenerator }), + { populate: "*" } + ); + }); + }); +}); + diff --git a/src/utils/loader.ts b/src/utils/loader.ts index 8c8ead0..61ff3ad 100644 --- a/src/utils/loader.ts +++ b/src/utils/loader.ts @@ -5,6 +5,22 @@ import { fetchContent } from "./strapi"; export interface StrapiLoaderOptions { url: string; token?: string; + /** + * Custom name for the collection. Allows multiple collections from the same endpoint. + */ + collectionName?: string; + /** + * Custom function to generate ID from item data. + * Default: uses documentId field. + */ + idGenerator?: (data: Record) => string; + /** + * Locale configuration: + * - undefined: no locale parameter (default behavior) + * - string: single locale (e.g., 'en') + * - string[]: multiple locales (e.g., ['en', 'de']) - returns structure like { 'en': items, 'de': items } + */ + locale?: string | string[]; } export function strapiLoader( @@ -13,45 +29,166 @@ export function strapiLoader( query: Record = {}, ): Loader { return { - name: "strapi-loader", + name: options.collectionName || "strapi-loader", load: async (context: LoaderContext): Promise => { const { store, logger, parseData, generateDigest } = context; + const collectionName = options.collectionName || contentType; - logger.info(`[${contentType}] Loading data from Strapi...`); - const { url, token } = options; - const response = await fetchContent({ - url, - token, - contentType, - queryParams: query ? qs.stringify(query) : undefined, - }); + logger.info(`[${collectionName}] Loading data from Strapi...`); + const { url, token, idGenerator, locale } = options; + store.clear(); - if (response.data.length === 0) { - logger.info(`[${contentType}] No data found in Strapi`); - return; - } + // Determine which locales to fetch + const localesToFetch: string[] | undefined = locale + ? Array.isArray(locale) + ? locale + : [locale] + : undefined; - if (Array.isArray(response.data)) { - await Promise.all( - response.data.map(async (item: Record) => { - const data = await parseData({ - id: item.documentId as string, - data: item, - }); - const digest = generateDigest(data); - store.set({ id: data.documentId as string, data, digest }); - }), + // If no locales specified, fetch with the original query + if (!localesToFetch) { + await fetchAndStoreData( + { url, token, contentType, query }, + { store, logger, parseData, generateDigest, idGenerator, collectionName } ); } else { + // Fetch data for each locale separately + const localeDataMap: Record = {}; + + for (const loc of localesToFetch) { + const localeQuery = { ...query, locale: loc }; + const response = await fetchContent({ + url, + token, + contentType, + queryParams: qs.stringify(localeQuery), + }); + + if (response.data && response.data.length > 0) { + localeDataMap[loc] = response.data; + } else if (response.data && !Array.isArray(response.data)) { + localeDataMap[loc] = response.data; + } + } + + if (Object.keys(localeDataMap).length === 0) { + logger.info(`[${collectionName}] No data found in Strapi`); + return; + } + + // Store data with locale structure + await storeLocaleData( + localeDataMap, + { store, parseData, generateDigest, idGenerator } + ); + } + + logger.info(`[${collectionName}] Loading data from Strapi... DONE`); + }, + }; +} + +async function fetchAndStoreData( + fetchOptions: { + url: string; + token?: string; + contentType: string; + query: Record; + }, + storeOptions: { + store: LoaderContext['store']; + logger: LoaderContext['logger']; + parseData: LoaderContext['parseData']; + generateDigest: LoaderContext['generateDigest']; + idGenerator?: (data: Record) => string; + collectionName: string; + } +): Promise { + const { url, token, contentType, query } = fetchOptions; + const { store, logger, parseData, generateDigest, idGenerator, collectionName } = storeOptions; + + const response = await fetchContent({ + url, + token, + contentType, + queryParams: query ? qs.stringify(query) : undefined, + }); + + if (response.data.length === 0) { + logger.info(`[${collectionName}] No data found in Strapi`); + return; + } + + const getItemId = (item: Record): string => { + if (idGenerator) { + return idGenerator(item); + } + return item.documentId as string; + }; + + if (Array.isArray(response.data)) { + await Promise.all( + response.data.map(async (item: Record) => { + const itemId = getItemId(item); const data = await parseData({ - id: response.data.documentId as string, - data: response.data, + id: itemId, + data: item, }); const digest = generateDigest(data); - store.set({ id: data.documentId as string, data, digest }); - } - logger.info(`[${contentType}] Loading data from Strapi... DONE`); - }, + store.set({ id: itemId, data, digest }); + }), + ); + } else { + const itemId = getItemId(response.data); + const data = await parseData({ + id: itemId, + data: response.data, + }); + const digest = generateDigest(data); + store.set({ id: itemId, data, digest }); + } +} + +async function storeLocaleData( + localeDataMap: Record, + storeOptions: { + store: LoaderContext['store']; + parseData: LoaderContext['parseData']; + generateDigest: LoaderContext['generateDigest']; + idGenerator?: (data: Record) => string; + } +): Promise { + const { store, parseData, generateDigest, idGenerator } = storeOptions; + + const getItemId = (item: Record, locale: string): string => { + if (idGenerator) { + return `${locale}:${idGenerator(item)}`; + } + return `${locale}:${item.documentId as string}`; }; + + for (const [locale, data] of Object.entries(localeDataMap)) { + if (Array.isArray(data)) { + await Promise.all( + data.map(async (item: Record) => { + const itemId = getItemId(item, locale); + const parsedData = await parseData({ + id: itemId, + data: { ...item, _locale: locale }, + }); + const digest = generateDigest(parsedData); + store.set({ id: itemId, data: parsedData, digest }); + }), + ); + } else { + const itemId = getItemId(data, locale); + const parsedData = await parseData({ + id: itemId, + data: { ...data, _locale: locale }, + }); + const digest = generateDigest(parsedData); + store.set({ id: itemId, data: parsedData, digest }); + } + } } diff --git a/src/utils/strapi.ts b/src/utils/strapi.ts index 81367d2..c4204b7 100644 --- a/src/utils/strapi.ts +++ b/src/utils/strapi.ts @@ -19,7 +19,23 @@ export interface StrapiCollectionsGeneratorOptions export interface StrapiCollection { name: string; - query: Record; + query?: Record; + /** + * Custom name for the collection. Allows multiple collections from the same endpoint. + */ + collectionName?: string; + /** + * Custom function to generate ID from item data. + * Default: uses documentId field. + */ + idGenerator?: (data: Record) => string; + /** + * Locale configuration: + * - undefined: no locale parameter (default behavior) + * - string: single locale (e.g., 'en') + * - string[]: multiple locales (e.g., ['en', 'de']) - returns structure like { 'en': items, 'de': items } + */ + locale?: string | string[]; } async function strapiRequest(options: StrapiRequestOptions): Promise { @@ -95,10 +111,17 @@ export function generateCollection( contentType: string, schema: z.ZodObject, options: StrapiCollectionsGeneratorOptions, - query: Record = {}, + collectionConfig: Partial = {}, ): CollectionConfig { + const { query = {}, collectionName, idGenerator, locale } = collectionConfig; + const loaderOptions = { + ...options, + collectionName, + idGenerator, + locale, + }; return defineCollection({ - loader: strapiLoader(contentType, options, query), + loader: strapiLoader(contentType, loaderOptions, query), schema, }); } @@ -124,17 +147,21 @@ export async function generateCollections( const collections = demandedCollections.reduce( (acc, collection: string) => { const reqCollection = reqCollections.find((rc) => - typeof rc === "string" ? rc : rc.name === collection, + typeof rc === "string" ? rc === collection : rc.name === collection, ) as StrapiCollection | undefined; - const query = - typeof reqCollection === "string" ? {} : reqCollection?.query || {}; + + const collectionConfig: Partial = + typeof reqCollection === "string" ? {} : reqCollection || {}; + + const collectionKey = collectionConfig.collectionName || collection; + return { ...acc, - [collection]: generateCollection( + [collectionKey]: generateCollection( collection, schema[collection], options, - query, + collectionConfig, ), }; },