fimo 0.4.1-staging.2 → 0.5.0-experimental.1
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/assets/content-templates/hooks-template.eta +108 -9
- package/assets/skills/fimo/SKILL.md +1 -1
- package/assets/skills/fimo/references/content.md +39 -11
- package/dist/cli/bundle.json +2 -2
- package/dist/runtime/content/parseCollectionResponse.d.ts +11 -0
- package/dist/runtime/content/parseCollectionResponse.d.ts.map +1 -0
- package/dist/runtime/content/parseCollectionResponse.js +13 -0
- package/dist/runtime/content/parseCollectionResponse.test.d.ts +2 -0
- package/dist/runtime/content/parseCollectionResponse.test.d.ts.map +1 -0
- package/dist/runtime/content/parseCollectionResponse.test.js +20 -0
- package/dist/runtime/index.d.ts +2 -0
- package/dist/runtime/index.d.ts.map +1 -1
- package/dist/runtime/index.js +1 -0
- package/dist/scripts/generated-types.test.js +40 -0
- package/dist/scripts/sync-schemas.test.js +4 -1
- package/package.json +1 -1
- package/templates/react-router/package.json +1 -1
|
@@ -9,8 +9,11 @@
|
|
|
9
9
|
*/
|
|
10
10
|
%>
|
|
11
11
|
// AUTO-GENERATED ⚠️ – do not edit by hand
|
|
12
|
-
import { useSuspenseQuery, useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
13
|
-
|
|
12
|
+
import { <% if (!model.isSingleton) { %>useInfiniteQuery, <% } %>useSuspenseQuery, useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
13
|
+
<% if (!model.isSingleton) { -%>
|
|
14
|
+
import type { InfiniteData, UseInfiniteQueryResult, UseQueryResult } from "@tanstack/react-query";
|
|
15
|
+
<% } -%>
|
|
16
|
+
import { FimoString, FimoMedia, FimoBoolean, FimoRichText, useLocale<% if (!model.isSingleton) { %>, parseCollectionResponse<% } %> } from "fimo/ui";
|
|
14
17
|
// @ts-ignore - virtual module provided by Fimo's Vite plugin
|
|
15
18
|
import fimoConfig from "virtual:fimo-config";
|
|
16
19
|
import type { <%= model.pascal %>, <%= model.pascal %>Input, <%= model.pascal %>Populate, <%= model.pascal %>Result<% if (!model.isSingleton) { %>, <%= model.pascal %>Fields, <%= model.pascal %>Query<% } %> } from "./<%= model.pascal %>.types";
|
|
@@ -215,6 +218,37 @@ function splitWritePayload(payload: Partial<<%= model.pascal %>Input>): Record<s
|
|
|
215
218
|
}
|
|
216
219
|
|
|
217
220
|
<% if (!model.isSingleton) { -%>
|
|
221
|
+
export interface CollectionPageInfo {
|
|
222
|
+
readonly limit: number;
|
|
223
|
+
readonly offset: number;
|
|
224
|
+
readonly total: number;
|
|
225
|
+
readonly hasMore: boolean;
|
|
226
|
+
readonly nextOffset: number | null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export interface CollectionPage<TItem> {
|
|
230
|
+
readonly items: TItem[];
|
|
231
|
+
readonly pageInfo: CollectionPageInfo;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export type InfiniteListQuery<
|
|
235
|
+
TFields extends <%= model.pascal %>Fields | undefined = undefined,
|
|
236
|
+
TPopulate extends <%= model.pascal %>Populate | undefined = undefined,
|
|
237
|
+
> = Omit<<%= model.pascal %>Query<TFields, TPopulate>, "offset">;
|
|
238
|
+
|
|
239
|
+
export type InfiniteListResult<TItem> = UseInfiniteQueryResult<
|
|
240
|
+
InfiniteData<CollectionPage<TItem>>,
|
|
241
|
+
Error
|
|
242
|
+
> & {
|
|
243
|
+
readonly items: TItem[];
|
|
244
|
+
readonly total: number;
|
|
245
|
+
readonly hasMore: boolean;
|
|
246
|
+
readonly loadMore: () => ReturnType<
|
|
247
|
+
UseInfiniteQueryResult<InfiniteData<CollectionPage<TItem>>, Error>["fetchNextPage"]
|
|
248
|
+
>;
|
|
249
|
+
readonly isLoadingMore: boolean;
|
|
250
|
+
};
|
|
251
|
+
|
|
218
252
|
export async function getById<TPopulate extends <%= model.pascal %>Populate | undefined = undefined>(
|
|
219
253
|
id: string,
|
|
220
254
|
options: { locale?: string; populate?: TPopulate; localeFallbacks?: boolean } = {}
|
|
@@ -282,15 +316,30 @@ function toQueryString<
|
|
|
282
316
|
return query.toString();
|
|
283
317
|
}
|
|
284
318
|
|
|
285
|
-
export async function
|
|
319
|
+
export async function list<
|
|
286
320
|
TFields extends <%= model.pascal %>Fields | undefined = undefined,
|
|
287
321
|
TPopulate extends <%= model.pascal %>Populate | undefined = undefined,
|
|
288
|
-
>(params: <%= model.pascal %>Query<TFields, TPopulate> = {} as <%= model.pascal %>Query<TFields, TPopulate>): Promise<<%= model.pascal %>Result<TFields, TPopulate
|
|
322
|
+
>(params: <%= model.pascal %>Query<TFields, TPopulate> = {} as <%= model.pascal %>Query<TFields, TPopulate>): Promise<CollectionPage<<%= model.pascal %>Result<TFields, TPopulate>>> {
|
|
289
323
|
const qs = toQueryString(params);
|
|
290
324
|
const res = await fetch(`${base}/entries/<%= model.uid %>${qs ? "?" + qs : ""}`, { headers: fimoHeaders() });
|
|
291
325
|
if (!res.ok) throw new Error(await res.text());
|
|
292
|
-
const data = await res.json();
|
|
293
|
-
|
|
326
|
+
const data = parseCollectionResponse(await res.json());
|
|
327
|
+
const items = data.data.map(wrapWithSource) as <%= model.pascal %>Result<TFields, TPopulate>[];
|
|
328
|
+
const pageInfo: CollectionPageInfo = {
|
|
329
|
+
limit: data.meta.limit,
|
|
330
|
+
offset: data.meta.offset,
|
|
331
|
+
total: data.meta.total,
|
|
332
|
+
hasMore: data.meta.hasMore,
|
|
333
|
+
nextOffset: data.meta.hasMore ? data.meta.offset + data.meta.limit : null,
|
|
334
|
+
};
|
|
335
|
+
return { items, pageInfo };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export async function get<
|
|
339
|
+
TFields extends <%= model.pascal %>Fields | undefined = undefined,
|
|
340
|
+
TPopulate extends <%= model.pascal %>Populate | undefined = undefined,
|
|
341
|
+
>(params: <%= model.pascal %>Query<TFields, TPopulate> = {} as <%= model.pascal %>Query<TFields, TPopulate>): Promise<<%= model.pascal %>Result<TFields, TPopulate>[]> {
|
|
342
|
+
return (await list(params)).items;
|
|
294
343
|
}
|
|
295
344
|
|
|
296
345
|
export async function create(payload: <%= model.pascal %>Input): Promise<<%= model.pascal %>> {
|
|
@@ -353,7 +402,11 @@ const qk = {
|
|
|
353
402
|
byId : <TPopulate extends <%= model.pascal %>Populate | undefined = undefined>(id: string, options: { locale?: string; populate?: TPopulate; localeFallbacks?: boolean } = {}) => ["<%= model.pascal %>", id, options.locale, options.populate] as const,
|
|
354
403
|
bySlug : <TPopulate extends <%= model.pascal %>Populate | undefined = undefined>(slug: string, options: { locale?: string; populate?: TPopulate; localeFallbacks?: boolean } = {}) => ["<%= model.pascal %>", "slug", slug, options.locale, options.populate] as const,
|
|
355
404
|
byField : <TPopulate extends <%= model.pascal %>Populate | undefined = undefined>(field: string, value: string, options: { locale?: string; populate?: TPopulate; localeFallbacks?: boolean } = {}) => ["<%= model.pascal %>", field, value, options.locale, options.populate] as const,
|
|
356
|
-
list : (params: <%= model.pascal %>Query) => ["<%= model.pascal %>", params] as const
|
|
405
|
+
list : (params: <%= model.pascal %>Query) => ["<%= model.pascal %>", params] as const,
|
|
406
|
+
infiniteList: <
|
|
407
|
+
TFields extends <%= model.pascal %>Fields | undefined = undefined,
|
|
408
|
+
TPopulate extends <%= model.pascal %>Populate | undefined = undefined,
|
|
409
|
+
>(params: InfiniteListQuery<TFields, TPopulate>) => ["<%= model.pascal %>", "infinite", params] as const
|
|
357
410
|
};
|
|
358
411
|
|
|
359
412
|
export function useGetById<TPopulate extends <%= model.pascal %>Populate | undefined = undefined>(id: string, options: { locale?: string; populate?: TPopulate; localeFallbacks?: boolean } = {}) {
|
|
@@ -380,7 +433,46 @@ export function useGet<
|
|
|
380
433
|
>(params: <%= model.pascal %>Query<TFields, TPopulate> = {} as <%= model.pascal %>Query<TFields, TPopulate>) {
|
|
381
434
|
const locale = readLocale(params, LOCALIZED_CONTENT, useQueryLocale(params.locale));
|
|
382
435
|
const localizedParams = { ...params, locale };
|
|
383
|
-
return useQuery<<%= model.pascal %>Result<TFields, TPopulate
|
|
436
|
+
return useQuery<CollectionPage<<%= model.pascal %>Result<TFields, TPopulate>>, Error, <%= model.pascal %>Result<TFields, TPopulate>[]>({
|
|
437
|
+
queryKey: qk.list(localizedParams),
|
|
438
|
+
queryFn: () => list(localizedParams),
|
|
439
|
+
select: (page) => page.items,
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
export function useList<
|
|
444
|
+
TFields extends <%= model.pascal %>Fields | undefined = undefined,
|
|
445
|
+
TPopulate extends <%= model.pascal %>Populate | undefined = undefined,
|
|
446
|
+
>(params: <%= model.pascal %>Query<TFields, TPopulate> = {} as <%= model.pascal %>Query<TFields, TPopulate>): UseQueryResult<CollectionPage<<%= model.pascal %>Result<TFields, TPopulate>>, Error> {
|
|
447
|
+
const locale = readLocale(params, LOCALIZED_CONTENT, useQueryLocale(params.locale));
|
|
448
|
+
const localizedParams = { ...params, locale };
|
|
449
|
+
return useQuery<CollectionPage<<%= model.pascal %>Result<TFields, TPopulate>>, Error>({
|
|
450
|
+
queryKey: qk.list(localizedParams),
|
|
451
|
+
queryFn: () => list(localizedParams),
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
export function useInfiniteList<
|
|
456
|
+
TFields extends <%= model.pascal %>Fields | undefined = undefined,
|
|
457
|
+
TPopulate extends <%= model.pascal %>Populate | undefined = undefined,
|
|
458
|
+
>(params: InfiniteListQuery<TFields, TPopulate> = {} as InfiniteListQuery<TFields, TPopulate>): InfiniteListResult<<%= model.pascal %>Result<TFields, TPopulate>> {
|
|
459
|
+
const locale = readLocale(params, LOCALIZED_CONTENT, useQueryLocale(params.locale));
|
|
460
|
+
const localizedParams = { ...params, locale };
|
|
461
|
+
const query = useInfiniteQuery({
|
|
462
|
+
queryKey: qk.infiniteList(localizedParams),
|
|
463
|
+
queryFn: ({ pageParam }) => list({ ...localizedParams, offset: pageParam }),
|
|
464
|
+
initialPageParam: 0,
|
|
465
|
+
getNextPageParam: (lastPage) => lastPage.pageInfo.nextOffset ?? undefined,
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
return {
|
|
469
|
+
...query,
|
|
470
|
+
items: query.data?.pages.flatMap((page) => page.items) ?? [],
|
|
471
|
+
total: query.data?.pages[0]?.pageInfo.total ?? 0,
|
|
472
|
+
hasMore: query.hasNextPage,
|
|
473
|
+
loadMore: () => query.fetchNextPage(),
|
|
474
|
+
isLoadingMore: query.isFetchingNextPage,
|
|
475
|
+
};
|
|
384
476
|
}
|
|
385
477
|
|
|
386
478
|
export function useSuspenseGetById<TPopulate extends <%= model.pascal %>Populate | undefined = undefined>(id: string, options: { locale?: string; populate?: TPopulate; localeFallbacks?: boolean } = {}) {
|
|
@@ -407,7 +499,11 @@ export function useSuspenseGet<
|
|
|
407
499
|
>(params: <%= model.pascal %>Query<TFields, TPopulate> = {} as <%= model.pascal %>Query<TFields, TPopulate>) {
|
|
408
500
|
const locale = readLocale(params, LOCALIZED_CONTENT, useQueryLocale(params.locale));
|
|
409
501
|
const localizedParams = { ...params, locale };
|
|
410
|
-
return useSuspenseQuery<<%= model.pascal %>Result<TFields, TPopulate
|
|
502
|
+
return useSuspenseQuery<CollectionPage<<%= model.pascal %>Result<TFields, TPopulate>>, Error, <%= model.pascal %>Result<TFields, TPopulate>[]>({
|
|
503
|
+
queryKey: qk.list(localizedParams),
|
|
504
|
+
queryFn: () => list(localizedParams),
|
|
505
|
+
select: (page) => page.items,
|
|
506
|
+
});
|
|
411
507
|
}
|
|
412
508
|
|
|
413
509
|
export function useCreate() {
|
|
@@ -476,6 +572,7 @@ export default {
|
|
|
476
572
|
getById,
|
|
477
573
|
getBySlug,
|
|
478
574
|
getByField,
|
|
575
|
+
list,
|
|
479
576
|
get,
|
|
480
577
|
create,
|
|
481
578
|
update,
|
|
@@ -485,6 +582,8 @@ export default {
|
|
|
485
582
|
useGetById,
|
|
486
583
|
useGetBySlug,
|
|
487
584
|
useGetByField,
|
|
585
|
+
useList,
|
|
586
|
+
useInfiniteList,
|
|
488
587
|
useGet,
|
|
489
588
|
useSuspenseGetById,
|
|
490
589
|
useSuspenseGet,
|
|
@@ -20,7 +20,7 @@ For CLI commands and workflows (creating a project, pushing schemas/forms, deplo
|
|
|
20
20
|
|
|
21
21
|
This is the load-bearing rule for every code change. **Fimo's whole point is that content editors edit content in the dashboard, not in code.** Anything hardcoded breaks that.
|
|
22
22
|
|
|
23
|
-
- **Content** (posts, products, pages, lists, anything structured) → declare a schema, render with
|
|
23
|
+
- **Content** (posts, products, pages, lists, anything structured) → declare a schema, render collections with generated `useList()` or `useInfiniteList()` hooks + `fimo/ui` components. Use `useGet()` only for legacy fixed array queries. **Never** write `const posts = [...]` in JSX.
|
|
24
24
|
- **Strings** (labels, CTAs, headings, descriptions, any visible text) → wrap in `t('key')` and create the default-locale value with `fimo labels set`. Use `--locale <locale>` only when intentionally writing a non-default locale. **Never** write `<h1>Welcome</h1>` directly — write `<Text value={t('home.title')} as="h1" />`.
|
|
25
25
|
- **Media** (images, videos, files) → use `<Image>` / `<Video>` from `fimo/ui` for schema media, `<StaticImage>` for hard-coded URLs. **Never** use `<img src={...}>`.
|
|
26
26
|
- **Forms** → use the generated Zod schema + `submitX` helper from `@/forms/<name>`. Never hand-roll fetch or validation.
|
|
@@ -250,6 +250,7 @@ Filtering on polymorphic fields is not supported yet.
|
|
|
250
250
|
getById<TPopulate extends BlogPostPopulate | undefined>(id: string, options?: { populate?: TPopulate }): Promise<BlogPostResult<undefined, TPopulate>>
|
|
251
251
|
getBySlug<TPopulate extends BlogPostPopulate | undefined>(slug: string, options?: { populate?: TPopulate }): Promise<BlogPostResult<undefined, TPopulate> | null>
|
|
252
252
|
getByField<TPopulate extends BlogPostPopulate | undefined>(fieldName: string, value: string, options?: { populate?: TPopulate }): Promise<BlogPostResult<undefined, TPopulate> | null>
|
|
253
|
+
list<TFields extends BlogPostFields | undefined, TPopulate extends BlogPostPopulate | undefined>(params?: BlogPostQuery<TFields, TPopulate>): Promise<CollectionPage<BlogPostResult<TFields, TPopulate>>>
|
|
253
254
|
get<TFields extends BlogPostFields | undefined, TPopulate extends BlogPostPopulate | undefined>(params?: BlogPostQuery<TFields, TPopulate>): Promise<BlogPostResult<TFields, TPopulate>[]>
|
|
254
255
|
create(payload: BlogPostInput): Promise<BlogPost>
|
|
255
256
|
update(id: string, payload: Partial<BlogPostInput>): Promise<BlogPost>
|
|
@@ -259,7 +260,9 @@ remove(id: string): Promise<void>
|
|
|
259
260
|
useGetById(id, { populate? }) // → { data: BlogPostResult<undefined, TPopulate> | undefined, isLoading, ... }
|
|
260
261
|
useGetBySlug(slug, { populate? }) // → { data: BlogPostResult<undefined, TPopulate> | null | undefined, ... } — ideal for /:slug detail routes
|
|
261
262
|
useGetByField(fieldName, value, { populate? })
|
|
262
|
-
|
|
263
|
+
useList<TFields, TPopulate>(params?) // → { data: CollectionPage<BlogPostResult<TFields, TPopulate>> | undefined, ... }
|
|
264
|
+
useInfiniteList<TFields, TPopulate>(params?) // → { items, total, hasMore, loadMore, isLoadingMore, ... }
|
|
265
|
+
useGet<TFields, TPopulate>(params?) // → legacy array-shaped { data: BlogPostResult<TFields, TPopulate>[] | undefined, ... }
|
|
263
266
|
useCreate() / useUpdate() / useRemove()
|
|
264
267
|
|
|
265
268
|
// Suspense variants (same signatures, throw to a <Suspense> boundary instead of returning isLoading)
|
|
@@ -278,12 +281,37 @@ useGet(options?) / useSuspenseGet(options?) / useUpdate()
|
|
|
278
281
|
|
|
279
282
|
Singleton clients do not expose `create`, `remove`, `getById`, or list queries. Localized singletons have one variant per locale; non-localized singletons have one locale-free row.
|
|
280
283
|
|
|
281
|
-
|
|
284
|
+
Choose the collection interface from the UI behavior. Use `useList` when the UI controls a page, and read pagination state from `pageInfo`:
|
|
282
285
|
|
|
283
286
|
```tsx
|
|
284
|
-
const { data:
|
|
285
|
-
if (isLoading || !
|
|
286
|
-
|
|
287
|
+
const { data: page, isLoading } = BlogPost.useList({ sort: '-publishedAt', limit: 12, offset: 24 });
|
|
288
|
+
if (isLoading || !page) return null;
|
|
289
|
+
|
|
290
|
+
page.items.map((post) => /* ... */);
|
|
291
|
+
page.pageInfo; // { limit, offset, total, hasMore, nextOffset }
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
Use `useInfiniteList` for load-more and infinite-scroll views. It accumulates pages and exposes one typed `items` array, so UI code does not manage offsets or flatten pages:
|
|
295
|
+
|
|
296
|
+
```tsx
|
|
297
|
+
const {
|
|
298
|
+
items: posts,
|
|
299
|
+
hasMore,
|
|
300
|
+
loadMore,
|
|
301
|
+
isLoadingMore,
|
|
302
|
+
} = BlogPost.useInfiniteList({
|
|
303
|
+
sort: '-publishedAt',
|
|
304
|
+
limit: 12,
|
|
305
|
+
});
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
`get()` and `useGet()` remain available as legacy array-shaped helpers for small, deliberately bounded subsets:
|
|
309
|
+
|
|
310
|
+
```tsx
|
|
311
|
+
const { data: featured = [] } = BlogPost.useGet({
|
|
312
|
+
where: { featured: true },
|
|
313
|
+
limit: 3,
|
|
314
|
+
});
|
|
287
315
|
```
|
|
288
316
|
|
|
289
317
|
Query params supported: `limit` (1-100, default 20), `offset` (default 0), `search`, `sort`, `where`, `fields`, and `populate`. Use these canonical names in new code.
|
|
@@ -350,7 +378,7 @@ Do not type populated hook data as `BlogPost[]`: plain `BlogPost` keeps referenc
|
|
|
350
378
|
|
|
351
379
|
Populate is depth-one. It only works for reference fields; trying to populate a scalar field returns 400. To-many reference order is preserved.
|
|
352
380
|
|
|
353
|
-
|
|
381
|
+
`list()` and `useList()` surface pagination metadata as `pageInfo`. `useInfiniteList()` consumes `nextOffset` internally and exposes `hasMore` plus `loadMore()`.
|
|
354
382
|
|
|
355
383
|
## Fimo primitives (what the field values are)
|
|
356
384
|
|
|
@@ -397,15 +425,15 @@ class FimoBoolean {
|
|
|
397
425
|
## UI code pattern
|
|
398
426
|
|
|
399
427
|
```tsx
|
|
400
|
-
import {
|
|
428
|
+
import { useList } from '@/schemas/BlogPost';
|
|
401
429
|
import { Text, RichText, Image, Date } from 'fimo/ui';
|
|
402
430
|
|
|
403
431
|
export function BlogList() {
|
|
404
|
-
const { data:
|
|
405
|
-
if (isLoading || !
|
|
432
|
+
const { data: page, isLoading } = useList({ limit: 10 });
|
|
433
|
+
if (isLoading || !page) return null;
|
|
406
434
|
return (
|
|
407
435
|
<ul>
|
|
408
|
-
{
|
|
436
|
+
{page.items.map((post) => (
|
|
409
437
|
<li key={post.id}>
|
|
410
438
|
<Text value={post.title} as="h2" />
|
|
411
439
|
<Image value={post.coverImage} width={800} height={400} fit="cover" />
|
|
@@ -429,7 +457,7 @@ const { data, meta } = await res.json();
|
|
|
429
457
|
// meta: { limit, offset, total, hasMore }
|
|
430
458
|
```
|
|
431
459
|
|
|
432
|
-
Prefer the generated
|
|
460
|
+
Prefer the generated `useList` or `useInfiniteList` hooks from `@/schemas/{Uid}` over hand-rolled fetches. They preserve projected/populated item types, cache results, and expose the pagination state regular list UIs need. Use the raw endpoint only for lower-level integrations outside the generated client.
|
|
433
461
|
|
|
434
462
|
## Forms vs. schemas
|
|
435
463
|
|
package/dist/cli/bundle.json
CHANGED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface CollectionResponse {
|
|
2
|
+
readonly data: Record<string, unknown>[];
|
|
3
|
+
readonly meta: {
|
|
4
|
+
readonly limit: number;
|
|
5
|
+
readonly offset: number;
|
|
6
|
+
readonly total: number;
|
|
7
|
+
readonly hasMore: boolean;
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
export declare function parseCollectionResponse(value: unknown): CollectionResponse;
|
|
11
|
+
//# sourceMappingURL=parseCollectionResponse.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"parseCollectionResponse.d.ts","sourceRoot":"","sources":["../../../src/runtime/content/parseCollectionResponse.ts"],"names":[],"mappings":"AAYA,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC;IACzC,QAAQ,CAAC,IAAI,EAAE;QACb,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;QACvB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;QACxB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;QACvB,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;KAC3B,CAAC;CACH;AAED,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,OAAO,GAAG,kBAAkB,CAE1E"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
const collectionResponseSchema = z.object({
|
|
3
|
+
data: z.array(z.record(z.string(), z.unknown())),
|
|
4
|
+
meta: z.object({
|
|
5
|
+
limit: z.number().int().positive(),
|
|
6
|
+
offset: z.number().int().nonnegative(),
|
|
7
|
+
total: z.number().int().nonnegative(),
|
|
8
|
+
hasMore: z.boolean(),
|
|
9
|
+
}),
|
|
10
|
+
});
|
|
11
|
+
export function parseCollectionResponse(value) {
|
|
12
|
+
return collectionResponseSchema.parse(value);
|
|
13
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"parseCollectionResponse.test.d.ts","sourceRoot":"","sources":["../../../src/runtime/content/parseCollectionResponse.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { parseCollectionResponse } from './parseCollectionResponse.js';
|
|
3
|
+
describe('parseCollectionResponse', () => {
|
|
4
|
+
it('accepts the collection envelope used by generated clients', () => {
|
|
5
|
+
const response = parseCollectionResponse({
|
|
6
|
+
data: [{ id: 'post-1', title: 'Hello' }],
|
|
7
|
+
meta: { limit: 12, offset: 24, total: 40, hasMore: true },
|
|
8
|
+
});
|
|
9
|
+
expect(response).toEqual({
|
|
10
|
+
data: [{ id: 'post-1', title: 'Hello' }],
|
|
11
|
+
meta: { limit: 12, offset: 24, total: 40, hasMore: true },
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
it('rejects invalid pagination metadata', () => {
|
|
15
|
+
expect(() => parseCollectionResponse({
|
|
16
|
+
data: [],
|
|
17
|
+
meta: { limit: 0, offset: -1, total: '40', hasMore: 'yes' },
|
|
18
|
+
})).toThrow();
|
|
19
|
+
});
|
|
20
|
+
});
|
package/dist/runtime/index.d.ts
CHANGED
|
@@ -6,6 +6,8 @@ export type { FimoLocaleContextValue } from './i18n/FimoLocaleContext.js';
|
|
|
6
6
|
export type { FimoLocaleResolution } from './i18n/locale.js';
|
|
7
7
|
export { DataSourcesProvider } from './content/DataSourcesProvider.js';
|
|
8
8
|
export { RefreshContentProvider } from './content/RefreshContentProvider.js';
|
|
9
|
+
export { parseCollectionResponse } from './content/parseCollectionResponse.js';
|
|
10
|
+
export type { CollectionResponse } from './content/parseCollectionResponse.js';
|
|
9
11
|
export { AppErrorBoundary } from './core/AppErrorBoundary.js';
|
|
10
12
|
export { ViteLifecycles } from './core/ViteLifecycles.js';
|
|
11
13
|
export { Pageview } from './t/Pageview.js';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/runtime/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,iBAAiB,EAAE,MAAM,gCAAgC,CAAC;AACnE,YAAY,EAAE,QAAQ,EAAE,YAAY,EAAE,QAAQ,EAAE,MAAM,gCAAgC,CAAC;AACvF,OAAO,EAAE,SAAS,EAAE,MAAM,6BAA6B,CAAC;AACxD,OAAO,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AACvE,YAAY,EAAE,sBAAsB,EAAE,MAAM,6BAA6B,CAAC;AAC1E,YAAY,EAAE,oBAAoB,EAAE,MAAM,kBAAkB,CAAC;AAG7D,OAAO,EAAE,mBAAmB,EAAE,MAAM,kCAAkC,CAAC;AACvE,OAAO,EAAE,sBAAsB,EAAE,MAAM,qCAAqC,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/runtime/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,iBAAiB,EAAE,MAAM,gCAAgC,CAAC;AACnE,YAAY,EAAE,QAAQ,EAAE,YAAY,EAAE,QAAQ,EAAE,MAAM,gCAAgC,CAAC;AACvF,OAAO,EAAE,SAAS,EAAE,MAAM,6BAA6B,CAAC;AACxD,OAAO,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AACvE,YAAY,EAAE,sBAAsB,EAAE,MAAM,6BAA6B,CAAC;AAC1E,YAAY,EAAE,oBAAoB,EAAE,MAAM,kBAAkB,CAAC;AAG7D,OAAO,EAAE,mBAAmB,EAAE,MAAM,kCAAkC,CAAC;AACvE,OAAO,EAAE,sBAAsB,EAAE,MAAM,qCAAqC,CAAC;AAC7E,OAAO,EAAE,uBAAuB,EAAE,MAAM,sCAAsC,CAAC;AAC/E,YAAY,EAAE,kBAAkB,EAAE,MAAM,sCAAsC,CAAC;AAG/E,OAAO,EAAE,gBAAgB,EAAE,MAAM,4BAA4B,CAAC;AAC9D,OAAO,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAG1D,OAAO,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AAG3C,OAAO,EAAE,oBAAoB,EAAE,MAAM,kCAAkC,CAAC;AACxE,OAAO,EAAE,kBAAkB,EAAE,MAAM,gCAAgC,CAAC;AAEpE,cAAc,uBAAuB,CAAC"}
|
package/dist/runtime/index.js
CHANGED
|
@@ -5,6 +5,7 @@ export { localizeFimoPath, resolveFimoLocale } from './i18n/locale.js';
|
|
|
5
5
|
// Content
|
|
6
6
|
export { DataSourcesProvider } from './content/DataSourcesProvider.js';
|
|
7
7
|
export { RefreshContentProvider } from './content/RefreshContentProvider.js';
|
|
8
|
+
export { parseCollectionResponse } from './content/parseCollectionResponse.js';
|
|
8
9
|
// Core
|
|
9
10
|
export { AppErrorBoundary } from './core/AppErrorBoundary.js';
|
|
10
11
|
export { ViteLifecycles } from './core/ViteLifecycles.js';
|
|
@@ -75,6 +75,7 @@ const SCHEMAS = {
|
|
|
75
75
|
};
|
|
76
76
|
const USAGE = `// Agent-style usage of the generated types — compile-time contract.
|
|
77
77
|
import type { PageResult, PageWhere } from "./Page";
|
|
78
|
+
import Post from "./Post";
|
|
78
79
|
import type { PostResult } from "./Post";
|
|
79
80
|
|
|
80
81
|
export function renderFeatured(page: PageResult<undefined, readonly ["featured", "hero"]>): string[] {
|
|
@@ -112,6 +113,45 @@ export function whereContract(): PageWhere {
|
|
|
112
113
|
featured: "some-document-id",
|
|
113
114
|
};
|
|
114
115
|
}
|
|
116
|
+
|
|
117
|
+
export function paginatedPostContract(): void {
|
|
118
|
+
const legacy = Post.useGet({ limit: 3 });
|
|
119
|
+
const page = Post.useList({
|
|
120
|
+
fields: ["title", "author"] as const,
|
|
121
|
+
populate: ["author"] as const,
|
|
122
|
+
limit: 12,
|
|
123
|
+
offset: 24,
|
|
124
|
+
});
|
|
125
|
+
const infinite = Post.useInfiniteList({
|
|
126
|
+
fields: ["title", "author"] as const,
|
|
127
|
+
populate: ["author"] as const,
|
|
128
|
+
limit: 12,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const legacyPosts: PostResult[] = legacy.data ?? [];
|
|
132
|
+
const pagePosts: PostResult<readonly ["title", "author"], readonly ["author"]>[] = page.data?.items ?? [];
|
|
133
|
+
const total: number = page.data?.pageInfo.total ?? 0;
|
|
134
|
+
const nextOffset: number | null = page.data?.pageInfo.nextOffset ?? null;
|
|
135
|
+
const infinitePosts: PostResult<readonly ["title", "author"], readonly ["author"]>[] = infinite.items;
|
|
136
|
+
const firstAuthorName: string = infinitePosts[0]?.author ? String(infinitePosts[0].author.name) : "";
|
|
137
|
+
const hasMore: boolean = infinite.hasMore;
|
|
138
|
+
const isLoadingMore: boolean = infinite.isLoadingMore;
|
|
139
|
+
|
|
140
|
+
void infinite.loadMore();
|
|
141
|
+
// @ts-expect-error infinite lists own offset progression
|
|
142
|
+
Post.useInfiniteList({ offset: 12 });
|
|
143
|
+
|
|
144
|
+
void [legacyPosts, pagePosts, total, nextOffset, infinitePosts, firstAuthorName, hasMore, isLoadingMore];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function paginatedPostFetchContract(): Promise<void> {
|
|
148
|
+
const page = await Post.list({ limit: 12, offset: 24 });
|
|
149
|
+
const legacyPosts: PostResult[] = await Post.get({ limit: 3 });
|
|
150
|
+
const pagePosts: PostResult[] = page.items;
|
|
151
|
+
const hasMore: boolean = page.pageInfo.hasMore;
|
|
152
|
+
|
|
153
|
+
void [legacyPosts, pagePosts, hasMore];
|
|
154
|
+
}
|
|
115
155
|
`;
|
|
116
156
|
const SHIMS = `declare module "virtual:fimo-config" {
|
|
117
157
|
const config: Record<string, unknown>;
|
|
@@ -95,7 +95,7 @@ describe('syncSchemas', () => {
|
|
|
95
95
|
expect(settings).toContain('const LOCALIZED_REFERENCE_FIELDS = new Set<string>(["homepage"]);');
|
|
96
96
|
expect(settings).not.toContain(' locale: entry.locale,');
|
|
97
97
|
expect(settings).toContain('readLocale(params, LOCALIZED_CONTENT, useQueryLocale(params.locale))');
|
|
98
|
-
expect(settings.match(/readLocale\(params, LOCALIZED_CONTENT, useQueryLocale\(params\.locale\)\)/g)?.length).toBe(
|
|
98
|
+
expect(settings.match(/readLocale\(params, LOCALIZED_CONTENT, useQueryLocale\(params\.locale\)\)/g)?.length).toBe(4);
|
|
99
99
|
expect(settings.match(/readLocale\(options, false, useQueryLocale\(options\.locale\)\)/g)?.length).toBe(2);
|
|
100
100
|
});
|
|
101
101
|
it('generates singleton document APIs without collection mutations', () => {
|
|
@@ -115,6 +115,9 @@ describe('syncSchemas', () => {
|
|
|
115
115
|
expect(settings).not.toContain('export async function create(');
|
|
116
116
|
expect(settings).not.toContain('export async function remove(');
|
|
117
117
|
expect(settings).not.toContain('export function useCreate(');
|
|
118
|
+
expect(settings).not.toContain('export function useList(');
|
|
119
|
+
expect(settings).not.toContain('export function useInfiniteList(');
|
|
120
|
+
expect(settings).not.toContain('useInfiniteQuery');
|
|
118
121
|
expect(settingsTypes).toContain('siteName: string;');
|
|
119
122
|
expect(settingsTypes).not.toContain('documentId?: string;');
|
|
120
123
|
});
|
package/package.json
CHANGED