fimo 0.6.0 → 0.7.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 +73 -27
- package/assets/content-templates/types-template.eta +4 -1
- package/assets/skills/fimo/references/content.md +46 -42
- package/assets/skills/fimo-cli/references/content.md +14 -14
- package/dist/cli/bundle.json +1 -1
- package/dist/cli/index.js +250 -29
- package/dist/runtime/content/utils/retrieve-data-sources.d.ts +1 -0
- package/dist/runtime/content/utils/retrieve-data-sources.d.ts.map +1 -1
- package/dist/runtime/content/utils/retrieve-data-sources.js +27 -4
- package/dist/runtime/primitives/lib/primitives.d.ts +2 -2
- package/dist/runtime/primitives/lib/primitives.d.ts.map +1 -1
- package/dist/runtime/primitives/lib/query.d.ts +5 -3
- package/dist/runtime/primitives/lib/query.d.ts.map +1 -1
- package/dist/runtime/primitives/public-surface.test.js +9 -5
- package/dist/scripts/generated-types.test.js +1 -1
- package/dist/scripts/sync-schemas.test.js +24 -4
- package/dist/scripts/validate-schemas.test.js +2 -2
- package/package.json +1 -1
- package/release.json +2 -2
- package/templates/react-router/package.json +1 -1
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// AUTO-GENERATED ⚠️ – do not edit by hand
|
|
12
12
|
import { <% if (!model.isSingleton) { %>useInfiniteQuery, <% } %>useSuspenseQuery, useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
13
13
|
<% if (!model.isSingleton) { -%>
|
|
14
|
-
import type { InfiniteData, UseInfiniteQueryResult, UseQueryResult } from "@tanstack/react-query";
|
|
14
|
+
import type { InfiniteData, UseInfiniteQueryResult, UseQueryResult, UseSuspenseQueryResult } from "@tanstack/react-query";
|
|
15
15
|
<% } -%>
|
|
16
16
|
import { FimoString, FimoMedia, FimoBoolean, FimoRichText, useLocale<% if (!model.isSingleton) { %>, parseCollectionResponse<% } %> } from "fimo/ui";
|
|
17
17
|
// @ts-ignore - virtual module provided by Fimo's Vite plugin
|
|
@@ -37,15 +37,21 @@ export type { <%= model.pascal %>, <%= model.pascal %>Fields, <%= model.pascal %
|
|
|
37
37
|
*/
|
|
38
38
|
export function wrapWithSource(entry: Record<string, any>): <%= model.pascal %> {
|
|
39
39
|
const id = entry.id;
|
|
40
|
-
const
|
|
40
|
+
const entryId = entry.entryId;
|
|
41
|
+
const documentId = entry.documentId ?? id;
|
|
42
|
+
const locale = entry.locale ?? defaultLocale();
|
|
43
|
+
const sourcePrefix = `<%= model.pascal %>.id:${id}:${encodeURIComponent(locale)}`;
|
|
41
44
|
const entryFimo = entry.__fimo && typeof entry.__fimo === "object" ? entry.__fimo : {};
|
|
42
45
|
|
|
43
46
|
return {
|
|
44
47
|
id,
|
|
45
|
-
|
|
48
|
+
entryId,
|
|
49
|
+
documentId,
|
|
46
50
|
slug: entry.slug != null ? new FimoString(entry.slug, `${sourcePrefix}.slug`) : entry.slug,
|
|
47
|
-
<% if (model.localized) { -%>
|
|
48
|
-
locale
|
|
51
|
+
<% if (!model.isSingleton || model.localized) { -%>
|
|
52
|
+
locale,
|
|
53
|
+
<% } else { -%>
|
|
54
|
+
...(entry.locale ? { locale } : {}),
|
|
49
55
|
<% } -%>
|
|
50
56
|
createdAt: entry.createdAt,
|
|
51
57
|
updatedAt: entry.updatedAt,
|
|
@@ -101,11 +107,11 @@ if (model.fields) {
|
|
|
101
107
|
const wrapper = field.target === model.uid ? 'wrapWithSource' : `wrap${field.targetPascal}WithSource`;
|
|
102
108
|
if (field.many) {
|
|
103
109
|
-%>
|
|
104
|
-
<%= key %>: Array.isArray(entry.<%= key %>) ? entry.<%= key %>.map((value: any) => value
|
|
110
|
+
<%= key %>: Array.isArray(entry.<%= key %>) ? entry.<%= key %>.map((value: any) => isPopulatedEntry(value) ? <%= wrapper %>(value) : value) : entry.<%= key %>,
|
|
105
111
|
<%
|
|
106
112
|
} else {
|
|
107
113
|
-%>
|
|
108
|
-
<%= key %>: entry.<%= key %>
|
|
114
|
+
<%= key %>: isPopulatedEntry(entry.<%= key %>) ? <%= wrapper %>(entry.<%= key %>) : entry.<%= key %>,
|
|
109
115
|
<%
|
|
110
116
|
}
|
|
111
117
|
break;
|
|
@@ -121,6 +127,23 @@ if (model.fields) {
|
|
|
121
127
|
} as <%= model.pascal %>;
|
|
122
128
|
}
|
|
123
129
|
|
|
130
|
+
interface PopulatedEntryRecord extends Record<string, unknown> {
|
|
131
|
+
id: string;
|
|
132
|
+
__fimo: { contentType: string };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function isPopulatedEntry(value: unknown): value is PopulatedEntryRecord {
|
|
136
|
+
if (value == null || typeof value !== "object") {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
const entry = value as Record<string, unknown>;
|
|
140
|
+
const fimo = entry.__fimo;
|
|
141
|
+
return typeof entry.id === "string"
|
|
142
|
+
&& fimo != null
|
|
143
|
+
&& typeof fimo === "object"
|
|
144
|
+
&& typeof (fimo as Record<string, unknown>).contentType === "string";
|
|
145
|
+
}
|
|
146
|
+
|
|
124
147
|
<%
|
|
125
148
|
const hasPolymorphicReferences = model.fields && Object.values(model.fields).some((field) => field.type === 'reference' && Array.isArray(field.targetRefs) && field.targetRefs.length > 0);
|
|
126
149
|
if (hasPolymorphicReferences) {
|
|
@@ -132,7 +155,9 @@ if (hasPolymorphicReferences) {
|
|
|
132
155
|
* (unpopulated) values pass through untouched.
|
|
133
156
|
*/
|
|
134
157
|
function wrapPolymorphic(value: any, wrappers: Record<string, (entry: Record<string, any>) => any>): any {
|
|
135
|
-
if (value
|
|
158
|
+
if (!isPopulatedEntry(value)) {
|
|
159
|
+
return value;
|
|
160
|
+
}
|
|
136
161
|
const contentType = value.__fimo && typeof value.__fimo.contentType === "string" ? value.__fimo.contentType : null;
|
|
137
162
|
const wrapper = contentType ? wrappers[contentType] : undefined;
|
|
138
163
|
if (!wrapper) return value;
|
|
@@ -159,6 +184,11 @@ function useQueryLocale(locale?: string): string {
|
|
|
159
184
|
return locale ?? activeLocale ?? defaultLocale();
|
|
160
185
|
}
|
|
161
186
|
|
|
187
|
+
function withMutationLocale<T extends object>(payload: T, activeLocale: string): T & { locale: string } {
|
|
188
|
+
const locale = (payload as { locale?: string }).locale;
|
|
189
|
+
return { ...payload, locale: locale ?? activeLocale };
|
|
190
|
+
}
|
|
191
|
+
|
|
162
192
|
const LOCALIZED_CONTENT = <%= model.localized ? 'true' : 'false' %>;
|
|
163
193
|
const LOCALIZED_REFERENCE_FIELDS = new Set<string>(<%= JSON.stringify(model.localizedReferenceFields) %>);
|
|
164
194
|
|
|
@@ -172,8 +202,12 @@ function readLocale(
|
|
|
172
202
|
ownerUsesLocale: boolean,
|
|
173
203
|
fallbackLocale = defaultLocale()
|
|
174
204
|
): string | undefined {
|
|
205
|
+
<% if (!model.isSingleton) { -%>
|
|
206
|
+
return options.locale ?? fallbackLocale;
|
|
207
|
+
<% } else { -%>
|
|
175
208
|
const populatesLocalizedTarget = populateFields(options.populate).some((field) => LOCALIZED_REFERENCE_FIELDS.has(field));
|
|
176
209
|
return ownerUsesLocale || populatesLocalizedTarget ? options.locale ?? fallbackLocale : undefined;
|
|
210
|
+
<% } -%>
|
|
177
211
|
}
|
|
178
212
|
|
|
179
213
|
function fimoHeaders(extra?: Record<string, string>): Record<string, string> {
|
|
@@ -202,6 +236,7 @@ function withReadOptions<TPopulate extends <%= model.pascal %>Populate | undefin
|
|
|
202
236
|
function splitWritePayload(payload: Partial<<%= model.pascal %>Input>): Record<string, unknown> {
|
|
203
237
|
const {
|
|
204
238
|
id,
|
|
239
|
+
entryId,
|
|
205
240
|
documentId,
|
|
206
241
|
locale,
|
|
207
242
|
createdAt,
|
|
@@ -253,7 +288,7 @@ export async function getById<TPopulate extends <%= model.pascal %>Populate | un
|
|
|
253
288
|
id: string,
|
|
254
289
|
options: { locale?: string; populate?: TPopulate; localeFallbacks?: boolean } = {}
|
|
255
290
|
): Promise<<%= model.pascal %>Result<undefined, TPopulate>> {
|
|
256
|
-
const res = await fetch(`${base}${withReadOptions(`/entries/<%= model.uid %>/${id}`, options
|
|
291
|
+
const res = await fetch(`${base}${withReadOptions(`/entries/<%= model.uid %>/${id}`, options)}`, { headers: fimoHeaders() });
|
|
257
292
|
if (!res.ok) throw new Error(await res.text());
|
|
258
293
|
const data = await res.json();
|
|
259
294
|
return wrapWithSource(data.data) as <%= model.pascal %>Result<undefined, TPopulate>;
|
|
@@ -354,18 +389,21 @@ export async function create(payload: <%= model.pascal %>Input): Promise<<%= mod
|
|
|
354
389
|
}
|
|
355
390
|
|
|
356
391
|
export async function update(id: string, payload: Partial<<%= model.pascal %>Input>): Promise<<%= model.pascal %>> {
|
|
357
|
-
const
|
|
392
|
+
const write = splitWritePayload(payload) as { data: Record<string, unknown>; locale?: string };
|
|
393
|
+
const url = withReadOptions(`/entries/<%= model.uid %>/${id}`, { locale: write.locale });
|
|
394
|
+
const res = await fetch(`${base}${url}`, {
|
|
358
395
|
method: "PUT",
|
|
359
396
|
headers: fimoHeaders({ "Content-Type": "application/json" }),
|
|
360
|
-
body: JSON.stringify(
|
|
397
|
+
body: JSON.stringify({ data: write.data })
|
|
361
398
|
});
|
|
362
399
|
if (!res.ok) throw new Error(await res.text());
|
|
363
400
|
const data = await res.json();
|
|
364
401
|
return wrapWithSource(data.data);
|
|
365
402
|
}
|
|
366
403
|
|
|
367
|
-
export async function remove(id: string): Promise<void> {
|
|
368
|
-
const
|
|
404
|
+
export async function remove(id: string, options: { locale?: string } = {}): Promise<void> {
|
|
405
|
+
const url = withReadOptions(`/entries/<%= model.uid %>/${id}`, options);
|
|
406
|
+
const res = await fetch(`${base}${url}`, { method: "DELETE", headers: fimoHeaders() });
|
|
369
407
|
if (!res.ok) throw new Error(await res.text());
|
|
370
408
|
}
|
|
371
409
|
<% } else { -%>
|
|
@@ -399,7 +437,7 @@ export async function update(payload: Partial<<%= model.pascal %>Input>): Promis
|
|
|
399
437
|
<% if (!model.isSingleton) { -%>
|
|
400
438
|
const qk = {
|
|
401
439
|
root : ["<%= model.pascal %>"] as const,
|
|
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,
|
|
440
|
+
byId : <TPopulate extends <%= model.pascal %>Populate | undefined = undefined>(id: string, options: { locale?: string; populate?: TPopulate; localeFallbacks?: boolean } = {}) => ["<%= model.pascal %>", "id", id, options.locale, options.populate] as const,
|
|
403
441
|
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,
|
|
404
442
|
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,
|
|
405
443
|
list : (params: <%= model.pascal %>Query) => ["<%= model.pascal %>", params] as const,
|
|
@@ -409,8 +447,8 @@ const qk = {
|
|
|
409
447
|
>(params: InfiniteListQuery<TFields, TPopulate>) => ["<%= model.pascal %>", "infinite", params] as const
|
|
410
448
|
};
|
|
411
449
|
|
|
412
|
-
export function useGetById<TPopulate extends <%= model.pascal %>Populate | undefined = undefined>(id: string, options: { locale?: string; populate?: TPopulate; localeFallbacks?: boolean } = {}) {
|
|
413
|
-
const locale =
|
|
450
|
+
export function useGetById<TPopulate extends <%= model.pascal %>Populate | undefined = undefined>(id: string, options: { locale?: string; populate?: TPopulate; localeFallbacks?: boolean } = {}): UseQueryResult<<%= model.pascal %>Result<undefined, TPopulate>, Error> {
|
|
451
|
+
const locale = useQueryLocale(options.locale);
|
|
414
452
|
const localizedOptions = { ...options, locale };
|
|
415
453
|
return useQuery<<%= model.pascal %>Result<undefined, TPopulate>, Error>({ queryKey: qk.byId(id, localizedOptions), queryFn: () => getById(id, localizedOptions) });
|
|
416
454
|
}
|
|
@@ -475,8 +513,8 @@ export function useInfiniteList<
|
|
|
475
513
|
};
|
|
476
514
|
}
|
|
477
515
|
|
|
478
|
-
export function useSuspenseGetById<TPopulate extends <%= model.pascal %>Populate | undefined = undefined>(id: string, options: { locale?: string; populate?: TPopulate; localeFallbacks?: boolean } = {}) {
|
|
479
|
-
const locale =
|
|
516
|
+
export function useSuspenseGetById<TPopulate extends <%= model.pascal %>Populate | undefined = undefined>(id: string, options: { locale?: string; populate?: TPopulate; localeFallbacks?: boolean } = {}): UseSuspenseQueryResult<<%= model.pascal %>Result<undefined, TPopulate>, Error> {
|
|
517
|
+
const locale = useQueryLocale(options.locale);
|
|
480
518
|
const localizedOptions = { ...options, locale };
|
|
481
519
|
return useSuspenseQuery<<%= model.pascal %>Result<undefined, TPopulate>, Error>({ queryKey: qk.byId(id, localizedOptions), queryFn: () => getById(id, localizedOptions) });
|
|
482
520
|
}
|
|
@@ -508,30 +546,37 @@ export function useSuspenseGet<
|
|
|
508
546
|
|
|
509
547
|
export function useCreate() {
|
|
510
548
|
const qc = useQueryClient();
|
|
549
|
+
const activeLocale = useQueryLocale();
|
|
511
550
|
return useMutation<<%= model.pascal %>, Error, <%= model.pascal %>Input>({
|
|
512
|
-
mutationFn : create,
|
|
551
|
+
mutationFn : (payload) => create(withMutationLocale(payload, activeLocale)),
|
|
513
552
|
onSuccess : () => qc.invalidateQueries({ queryKey: qk.root })
|
|
514
553
|
});
|
|
515
554
|
}
|
|
516
555
|
|
|
517
556
|
export function useUpdate() {
|
|
518
557
|
const qc = useQueryClient();
|
|
519
|
-
|
|
520
|
-
|
|
558
|
+
const activeLocale = useQueryLocale();
|
|
559
|
+
return useMutation<<%= model.pascal %>, Error, { id: string; locale?: string } & Partial<<%= model.pascal %>Input>>({
|
|
560
|
+
mutationFn : ({ id, ...patch }) => update(id, withMutationLocale(patch, activeLocale)),
|
|
521
561
|
onSuccess : (_data, vars) => {
|
|
522
562
|
qc.invalidateQueries({ queryKey: qk.root });
|
|
523
|
-
qc.invalidateQueries({ queryKey: qk.byId(vars.id) });
|
|
563
|
+
qc.invalidateQueries({ queryKey: qk.byId(vars.id, { locale: vars.locale ?? activeLocale }) });
|
|
524
564
|
}
|
|
525
565
|
});
|
|
526
566
|
}
|
|
527
567
|
|
|
528
568
|
export function useRemove() {
|
|
529
569
|
const qc = useQueryClient();
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
570
|
+
const activeLocale = useQueryLocale();
|
|
571
|
+
return useMutation<void, Error, string | { id: string; locale?: string }>({
|
|
572
|
+
mutationFn : (value) => {
|
|
573
|
+
const { id, locale } = typeof value === "string" ? { id: value, locale: activeLocale } : { ...value, locale: value.locale ?? activeLocale };
|
|
574
|
+
return remove(id, { locale });
|
|
575
|
+
},
|
|
576
|
+
onSuccess : (_data, value) => {
|
|
577
|
+
const { id, locale } = typeof value === "string" ? { id: value, locale: activeLocale } : { ...value, locale: value.locale ?? activeLocale };
|
|
533
578
|
qc.invalidateQueries({ queryKey: qk.root });
|
|
534
|
-
qc.removeQueries({ queryKey:
|
|
579
|
+
qc.removeQueries({ queryKey: qk.byId(id, { locale }), exact: false });
|
|
535
580
|
}
|
|
536
581
|
});
|
|
537
582
|
}
|
|
@@ -555,8 +600,9 @@ export function useSuspenseGet<TPopulate extends <%= model.pascal %>Populate | u
|
|
|
555
600
|
|
|
556
601
|
export function useUpdate() {
|
|
557
602
|
const qc = useQueryClient();
|
|
603
|
+
const activeLocale = useQueryLocale();
|
|
558
604
|
return useMutation<<%= model.pascal %>, Error, Partial<<%= model.pascal %>Input>>({
|
|
559
|
-
mutationFn: update,
|
|
605
|
+
mutationFn: (payload) => update(withMutationLocale(payload, activeLocale)),
|
|
560
606
|
onSuccess: () => qc.invalidateQueries({ queryKey: qk.root })
|
|
561
607
|
});
|
|
562
608
|
}
|
|
@@ -33,10 +33,13 @@ export interface <%= model.pascal %>FimoMeta {
|
|
|
33
33
|
|
|
34
34
|
export interface <%= model.pascal %> extends WithReferences<<%= model.pascal %>References> {
|
|
35
35
|
id: string;
|
|
36
|
+
entryId: string;
|
|
36
37
|
documentId: string;
|
|
37
38
|
slug: FimoString;
|
|
38
|
-
<% if (model.localized) { -%>
|
|
39
|
+
<% if (!model.isSingleton || model.localized) { -%>
|
|
39
40
|
locale: string;
|
|
41
|
+
<% } else { -%>
|
|
42
|
+
locale?: string;
|
|
40
43
|
<% } -%>
|
|
41
44
|
createdAt: string;
|
|
42
45
|
updatedAt: string;
|
|
@@ -34,9 +34,9 @@ Every entry already carries this metadata automatically. Declaring any of them a
|
|
|
34
34
|
schema field is rejected by `fimo schemas push` with a 400 (e.g. `fields.slug: "slug" is
|
|
35
35
|
reserved entry metadata and cannot be used as a content field.`):
|
|
36
36
|
|
|
37
|
-
`id`, `documentId`, `slug`, `locale`, `createdAt`, `updatedAt`, `__fimo`.
|
|
37
|
+
`id`, `entryId`, `documentId`, `slug`, `locale`, `createdAt`, `updatedAt`, `__fimo`.
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
Collection entries expose the effective `locale`. It remains reserved for both localized and shared schemas.
|
|
40
40
|
|
|
41
41
|
In particular **do not add a `slug` field** — every entry gets a `slug` for free. You still
|
|
42
42
|
pass `slug` inside `data` when _creating an entry_ (see "Entry body shape" below); it just
|
|
@@ -45,16 +45,16 @@ something else (e.g. `handle`, `permalink`).
|
|
|
45
45
|
|
|
46
46
|
## Field types
|
|
47
47
|
|
|
48
|
-
| Type | `data` JSON shape
|
|
49
|
-
| ----------- |
|
|
50
|
-
| `string` | `"..."`
|
|
51
|
-
| `richtext` | Tiptap JSONContent (see below)
|
|
52
|
-
| `number` | `42`
|
|
53
|
-
| `boolean` | `true` / `false`
|
|
54
|
-
| `date` | ISO date string, e.g. `"2026-06-03"`
|
|
55
|
-
| `media` | `{ "source": "local"\|"external", "url": "https://...", "alt": "..." }`
|
|
56
|
-
| `json` | any JSON
|
|
57
|
-
| `reference` | `{ "
|
|
48
|
+
| Type | `data` JSON shape | Use for |
|
|
49
|
+
| ----------- | ----------------------------------------------------------------------------- | --------------------------------- |
|
|
50
|
+
| `string` | `"..."` | Titles, short text |
|
|
51
|
+
| `richtext` | Tiptap JSONContent (see below) | Blog body, descriptions, articles |
|
|
52
|
+
| `number` | `42` | Counts, quantities |
|
|
53
|
+
| `boolean` | `true` / `false` | Flags |
|
|
54
|
+
| `date` | ISO date string, e.g. `"2026-06-03"` | Publish dates, timestamps |
|
|
55
|
+
| `media` | `{ "source": "local"\|"external", "url": "https://...", "alt": "..." }` | Images, video |
|
|
56
|
+
| `json` | any JSON | Structured metadata |
|
|
57
|
+
| `reference` | `{ "id": "..." }`, `null`, or an array of reference objects when `many: true` | References to another schema |
|
|
58
58
|
|
|
59
59
|
A `date` field currently generates as `any` in the TS client, so for typed access (and the `<Date>` component) prefer a `string` field holding an ISO date — which is what the `publishDate` examples below do.
|
|
60
60
|
|
|
@@ -79,7 +79,7 @@ Rules:
|
|
|
79
79
|
- `target` is required and must be the PascalCase schema `uid` of the referenced content type.
|
|
80
80
|
- Omit `many` for a to-one reference. Use `"many": true` for a to-many reference.
|
|
81
81
|
- Self-references are allowed, e.g. a `Category` schema can have `parent: { "type": "reference", "target": "Category" }`.
|
|
82
|
-
- Entry values
|
|
82
|
+
- Entry values use the target's stable `id`, never the variant `entryId`: `{ "id": "..." }`.
|
|
83
83
|
- Do not include `contentType` for normal fixed-target references; the schema's `target` already defines it.
|
|
84
84
|
- Do not add `localized` or field-level `i18n` to a reference. The reference value belongs to the entry variant that stores it.
|
|
85
85
|
- Use `null` for an empty to-one reference and `[]` for an empty to-many reference.
|
|
@@ -92,22 +92,22 @@ Entry payload example:
|
|
|
92
92
|
"data": {
|
|
93
93
|
"slug": "reference-example",
|
|
94
94
|
"title": "Reference example",
|
|
95
|
-
"author": { "
|
|
96
|
-
"contributors": [{ "
|
|
95
|
+
"author": { "id": "750e8400-e29b-41d4-a716-446655440000" },
|
|
96
|
+
"contributors": [{ "id": "750e8400-e29b-41d4-a716-446655440000" }]
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
99
|
```
|
|
100
100
|
|
|
101
101
|
## Schema transitions
|
|
102
102
|
|
|
103
|
-
After editing schema JSON, run `fimo schemas push {Uid}` and then `fimo validate`. Push regenerates the schema client and applies the schema change to
|
|
103
|
+
After editing schema JSON, run `fimo schemas push {Uid}` and then `fimo validate`. Push regenerates the schema client and applies the schema change to existing content automatically:
|
|
104
104
|
|
|
105
105
|
- Adding fields and changing labels preserve entries.
|
|
106
106
|
- Removing a field removes only that field's values.
|
|
107
107
|
- A one-to-many reference change wraps each existing reference in an array.
|
|
108
108
|
- A many-to-one change, reference target change, scalar type change, or scalar/reference change clears only the changed field.
|
|
109
109
|
- Collection-to-singleton removes that type's entries and cleans references to those documents.
|
|
110
|
-
- Singleton-to-collection preserves the existing
|
|
110
|
+
- Singleton-to-collection preserves the existing entry.
|
|
111
111
|
|
|
112
112
|
There are no reset or data-loss flags. Do not invent one or promise that incompatible field values survive; the schema change itself selects the behavior above.
|
|
113
113
|
|
|
@@ -192,9 +192,10 @@ Import from the public schema module, e.g. `import BlogPost from '@/schemas/Blog
|
|
|
192
192
|
```ts
|
|
193
193
|
interface BlogPost {
|
|
194
194
|
id: string;
|
|
195
|
+
entryId: string;
|
|
195
196
|
documentId: string;
|
|
196
197
|
slug: FimoString;
|
|
197
|
-
locale: string;
|
|
198
|
+
locale: string;
|
|
198
199
|
createdAt: string;
|
|
199
200
|
updatedAt: string;
|
|
200
201
|
__fimo: {
|
|
@@ -213,13 +214,14 @@ Content fields live at the top level. Fimo-owned metadata that is not part of th
|
|
|
213
214
|
|
|
214
215
|
## Localized and shared content
|
|
215
216
|
|
|
216
|
-
Localized content
|
|
217
|
+
Localized content has one variant per locale. Generated hooks read the active Fimo locale automatically.
|
|
217
218
|
|
|
218
219
|
When a project has multiple configured locales, create and update each localized content variant explicitly. Use the default locale first, then create non-default variants with the same `documentId` and the target `locale` through the content tools or `fimo entries` commands.
|
|
219
220
|
|
|
220
221
|
For shared content, set `i18n: { "enabled": false }` on the schema and create each entry once. Shared entries:
|
|
221
222
|
|
|
222
|
-
-
|
|
223
|
+
- send and expose the effective active/default locale in generated collection queries and models;
|
|
224
|
+
- omit `locale` from raw API responses because there is no localized variant;
|
|
223
225
|
- are returned in every locale context without duplication;
|
|
224
226
|
- ignore locale on owner reads and writes;
|
|
225
227
|
- still use the active/requested locale when a populated reference targets a localized schema.
|
|
@@ -228,7 +230,7 @@ Do not duplicate shared entries across locales or add field-level localization s
|
|
|
228
230
|
|
|
229
231
|
A populated reference whose target has no variant in the requested locale resolves to null. Reads accept an opt-in `localeFallbacks: true` that resolves those to the best available variant instead (project default first), marked by its `locale` field — never implement fallbacks by hand; public reads stay strict unless the option is passed.
|
|
230
232
|
|
|
231
|
-
**Polymorphic references:** a field may declare `targets: ["Post", "Video"]` instead of a single `target` (never both) when one slot genuinely holds different types — mixed feeds, hero blocks, curated collections. Prefer a fixed `target` whenever every value is the same type: fixed fields keep `where` filtering and simpler generated types. Polymorphic values
|
|
233
|
+
**Polymorphic references:** a field may declare `targets: ["Post", "Video"]` instead of a single `target` (never both) when one slot genuinely holds different types — mixed feeds, hero blocks, curated collections. Prefer a fixed `target` whenever every value is the same type: fixed fields keep `where` filtering and simpler generated types. Polymorphic values use `{ contentType, id }`; populated values in generated hooks carry a top-level `$type` that narrows the union:
|
|
232
234
|
|
|
233
235
|
```ts
|
|
234
236
|
for (const item of page.featured) {
|
|
@@ -245,19 +247,21 @@ Filtering on polymorphic fields is not supported yet.
|
|
|
245
247
|
|
|
246
248
|
**Generated API:**
|
|
247
249
|
|
|
250
|
+
Collection responses use `id` as the stable identity shared by all locale variants. `locale` selects the variant, and generated hooks use the active Fimo locale when it is omitted. `entryId` identifies the exact variant returned by Fimo and is only for diagnostics or migrations. `documentId` remains a compatibility alias for `id`; reference inputs use `{ id: entry.id }`. Filters and sorts follow the same rule: `where.id` and `sort: "id"` use stable identity, while `entryId` addresses an exact variant.
|
|
251
|
+
|
|
248
252
|
```ts
|
|
249
253
|
// low-level fetch helpers
|
|
250
|
-
getById<TPopulate extends BlogPostPopulate | undefined>(id: string, options?: { populate?: TPopulate }): Promise<BlogPostResult<undefined, TPopulate>>
|
|
254
|
+
getById<TPopulate extends BlogPostPopulate | undefined>(id: string, options?: { locale?: string; populate?: TPopulate }): Promise<BlogPostResult<undefined, TPopulate>>
|
|
251
255
|
getBySlug<TPopulate extends BlogPostPopulate | undefined>(slug: string, options?: { populate?: TPopulate }): Promise<BlogPostResult<undefined, TPopulate> | null>
|
|
252
256
|
getByField<TPopulate extends BlogPostPopulate | undefined>(fieldName: string, value: string, options?: { populate?: TPopulate }): Promise<BlogPostResult<undefined, TPopulate> | null>
|
|
253
257
|
list<TFields extends BlogPostFields | undefined, TPopulate extends BlogPostPopulate | undefined>(params?: BlogPostQuery<TFields, TPopulate>): Promise<CollectionPage<BlogPostResult<TFields, TPopulate>>>
|
|
254
258
|
get<TFields extends BlogPostFields | undefined, TPopulate extends BlogPostPopulate | undefined>(params?: BlogPostQuery<TFields, TPopulate>): Promise<BlogPostResult<TFields, TPopulate>[]>
|
|
255
259
|
create(payload: BlogPostInput): Promise<BlogPost>
|
|
256
260
|
update(id: string, payload: Partial<BlogPostInput>): Promise<BlogPost>
|
|
257
|
-
remove(id: string): Promise<void>
|
|
261
|
+
remove(id: string, options?: { locale?: string }): Promise<void>
|
|
258
262
|
|
|
259
263
|
// react-query hooks
|
|
260
|
-
useGetById(id, { populate? })
|
|
264
|
+
useGetById(id, { locale?, populate? }) // → { data: BlogPostResult<undefined, TPopulate> | undefined, isLoading, ... }
|
|
261
265
|
useGetBySlug(slug, { populate? }) // → { data: BlogPostResult<undefined, TPopulate> | null | undefined, ... } — ideal for /:slug detail routes
|
|
262
266
|
useGetByField(fieldName, value, { populate? })
|
|
263
267
|
useList<TFields, TPopulate>(params?) // → { data: CollectionPage<BlogPostResult<TFields, TPopulate>> | undefined, ... }
|
|
@@ -279,7 +283,7 @@ update(payload: Partial<SettingsInput>): Promise<Settings>
|
|
|
279
283
|
useGet(options?) / useSuspenseGet(options?) / useUpdate()
|
|
280
284
|
```
|
|
281
285
|
|
|
282
|
-
Singleton clients do not expose `create`, `remove`, `getById`, or list queries. Localized singletons have one variant per locale;
|
|
286
|
+
Singleton clients do not expose `create`, `remove`, `getById`, or list queries. Localized singletons have one variant per locale; shared singletons have one entry across locales.
|
|
283
287
|
|
|
284
288
|
Choose the collection interface from the UI behavior. Use `useList` when the UI controls a page, and read pagination state from `pageInfo`:
|
|
285
289
|
|
|
@@ -335,10 +339,10 @@ BlogPost.useGet({
|
|
|
335
339
|
```
|
|
336
340
|
|
|
337
341
|
Operators: scalar equality, `$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$in`, `$nin`, `$contains`, `$startsWith`, `$null`.
|
|
338
|
-
Reference field values are reference objects, but reference filters use the target `
|
|
342
|
+
Reference field values are reference objects, but reference filters use the target `id` string, for example `where: { author: author.id }`; for to-many references that means "contains this target".
|
|
339
343
|
For to-many references, `$eq` and `$contains` mean membership, `$in` means any listed document is present, `$ne` and `$nin` mean none are present, and `$null: true` means empty or missing. Reference fields cannot be sorted.
|
|
340
344
|
|
|
341
|
-
Use `fields` to keep list payloads small. The API always keeps `id`, and returns only the requested fields beyond that:
|
|
345
|
+
Use `fields` to keep list payloads small. The API always keeps `id`, `entryId`, `documentId`, and `locale`, and returns only the requested fields beyond that:
|
|
342
346
|
|
|
343
347
|
```tsx
|
|
344
348
|
const { data: summaries = [] } = BlogPost.useGet({
|
|
@@ -346,7 +350,7 @@ const { data: summaries = [] } = BlogPost.useGet({
|
|
|
346
350
|
sort: '-publishedAt',
|
|
347
351
|
limit: 12,
|
|
348
352
|
});
|
|
349
|
-
// summaries: Array<Pick<BlogPost, 'id' | 'slug' | 'title' | 'excerpt' | 'coverImage'>>
|
|
353
|
+
// summaries: Array<Pick<BlogPost, 'id' | 'entryId' | 'documentId' | 'locale' | 'slug' | 'title' | 'excerpt' | 'coverImage'>>
|
|
350
354
|
```
|
|
351
355
|
|
|
352
356
|
Use `populate` to embed reference targets in read results:
|
|
@@ -356,7 +360,7 @@ const { data: posts = [] } = BlogPost.useGet({
|
|
|
356
360
|
fields: ['slug', 'title', 'author'] as const,
|
|
357
361
|
populate: ['author'] as const,
|
|
358
362
|
});
|
|
359
|
-
// Without populate: post.author is {
|
|
363
|
+
// Without populate: post.author is { id: "..." }.
|
|
360
364
|
// With populate: post.author is the target Author entry object, or null.
|
|
361
365
|
```
|
|
362
366
|
|
|
@@ -384,16 +388,16 @@ Populate is depth-one. It only works for reference fields; trying to populate a
|
|
|
384
388
|
|
|
385
389
|
The entry objects a hook returns are **not** plain JS values. The generated client wraps each _trackable_ field in a `fimo/ui` primitive that carries source metadata for inline editing. Which fields wrap, and which stay plain:
|
|
386
390
|
|
|
387
|
-
| Schema field type | Field value you get | Notes
|
|
388
|
-
| ----------------- | ----------------------------------- |
|
|
389
|
-
| `string` | `FimoString` | `extends String` — **not** a primitive `string`
|
|
390
|
-
| `richtext` | `FimoRichText` | Tiptap JSONContent + source metadata
|
|
391
|
-
| `boolean` | `FimoBoolean` | class; `.valueOf()` → `boolean`
|
|
392
|
-
| `media` | `FimoMedia` / `FimoMedia[]` | shape + rendering in `references/assets.md`
|
|
393
|
-
| `number` | `number` | plain — not wrapped
|
|
394
|
-
| `json` | `Record<string, any>` | plain — not wrapped
|
|
395
|
-
| `date` | `any` (raw ISO date string) | not wrapped (`FimoDate` is not emitted — see below)
|
|
396
|
-
| `reference` | `FimoReference` / `FimoReference[]` | reference object(s) with target `
|
|
391
|
+
| Schema field type | Field value you get | Notes |
|
|
392
|
+
| ----------------- | ----------------------------------- | ------------------------------------------------------------------------------------- |
|
|
393
|
+
| `string` | `FimoString` | `extends String` — **not** a primitive `string` |
|
|
394
|
+
| `richtext` | `FimoRichText` | Tiptap JSONContent + source metadata |
|
|
395
|
+
| `boolean` | `FimoBoolean` | class; `.valueOf()` → `boolean` |
|
|
396
|
+
| `media` | `FimoMedia` / `FimoMedia[]` | shape + rendering in `references/assets.md` |
|
|
397
|
+
| `number` | `number` | plain — not wrapped |
|
|
398
|
+
| `json` | `Record<string, any>` | plain — not wrapped |
|
|
399
|
+
| `date` | `any` (raw ISO date string) | not wrapped (`FimoDate` is not emitted — see below) |
|
|
400
|
+
| `reference` | `FimoReference` / `FimoReference[]` | reference object(s) with target `id`; use `populate` when embedded targets are needed |
|
|
397
401
|
|
|
398
402
|
Rules of thumb:
|
|
399
403
|
|
|
@@ -434,7 +438,7 @@ export function BlogList() {
|
|
|
434
438
|
return (
|
|
435
439
|
<ul>
|
|
436
440
|
{page.items.map((post) => (
|
|
437
|
-
<li key={post.id}>
|
|
441
|
+
<li key={`${post.id}:${post.locale}`}>
|
|
438
442
|
<Text value={post.title} as="h2" />
|
|
439
443
|
<Image value={post.coverImage} width={800} height={400} fit="cover" />
|
|
440
444
|
<RichText value={post.body} className="prose" />
|
|
@@ -451,9 +455,9 @@ For the `<Text>` / `<RichText>` / `<Image>` / `<Date>` components and the rules
|
|
|
451
455
|
## Read path (published app, no auth)
|
|
452
456
|
|
|
453
457
|
```ts
|
|
454
|
-
const res = await fetch(`${import.meta.env.VITE_API_URL}/entries/<typeName>?fields=slug,title&limit=12`);
|
|
458
|
+
const res = await fetch(`${import.meta.env.VITE_API_URL}/entries/<typeName>?locale=en&fields=slug,title&limit=12`);
|
|
455
459
|
const { data, meta } = await res.json();
|
|
456
|
-
// data: Entry[] — fields flattened at top level (id, slug, title, ...)
|
|
460
|
+
// data: Entry[] — fields flattened at top level (id, entryId, documentId, locale, slug, title, ...)
|
|
457
461
|
// meta: { limit, offset, total, hasMore }
|
|
458
462
|
```
|
|
459
463
|
|
|
@@ -32,7 +32,7 @@ The command is `fimo schemas` (plural). There is no singular `fimo schema` alias
|
|
|
32
32
|
|
|
33
33
|
All commands accept `-C, --cwd <dir>` to point at a different project directory.
|
|
34
34
|
|
|
35
|
-
Schema transitions are automatic. Adding fields and changing labels preserve content. Removing a field removes only that field's
|
|
35
|
+
Schema transitions are automatic. Adding fields and changing labels preserve content. Removing a field removes only that field's values. One-to-many reference changes wrap existing references in arrays. Field type changes, scalar/reference changes, reference target changes, and many-to-one changes clear only the changed field. Collection-to-singleton removes that type's entries and cleans inbound references; singleton-to-collection preserves its entry. There are no reset or data-loss flags: changing the schema is the migration request. Run `fimo validate` after pushing to verify the regenerated client and whole project.
|
|
36
36
|
|
|
37
37
|
## Entries CLI commands
|
|
38
38
|
|
|
@@ -43,9 +43,9 @@ fimo entries create <TypeName> --body '<json>'
|
|
|
43
43
|
fimo entries create <TypeName> --locale es --document-id <documentId> --body '<json>'
|
|
44
44
|
fimo entries list <TypeName> [--limit N] [--offset N] [--search Q] [--where JSON] [--sort F] [--fields FIELDS] [--populate FIELDS]
|
|
45
45
|
fimo entries list <TypeName> --locale es
|
|
46
|
-
fimo entries get <TypeName> <id>
|
|
47
|
-
fimo entries update <TypeName> <id> --locale es --body '<json>'
|
|
48
|
-
fimo entries delete <TypeName> <id>
|
|
46
|
+
fimo entries get <TypeName> <id> [--locale es]
|
|
47
|
+
fimo entries update <TypeName> <id> [--locale es] --body '<json>'
|
|
48
|
+
fimo entries delete <TypeName> <id> [--locale es]
|
|
49
49
|
```
|
|
50
50
|
|
|
51
51
|
`--body` is a **required** option on `create` and `update`. Its JSON shape (where `slug` goes, how `media` fields look, Tiptap richtext format) lives in `fimo/references/content.md`. **Always read that first before constructing an entry payload — slug must be inside `data`, media has a specific shape, richtext must be Tiptap JSONContent.**
|
|
@@ -65,12 +65,12 @@ fimo entries list BlogPost --fields slug,title,author --populate author
|
|
|
65
65
|
Use the canonical read query flags: `--search`, `--sort`, `--where`, `--limit`, `--offset`, `--fields`, and `--populate`.
|
|
66
66
|
`--where` is a JSON object keyed by schema field name. Simple values mean equality and stay typed inside the JSON (`{"featured":true}`, `{"category":"react"}`, `{"rank":{"$gte":3}}`); operator objects support `$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$in`, `$nin`, `$contains`, `$startsWith`, and `$null`, subject to the field type. Public Fimo-owned metadata lives under `__fimo`; currently `__fimo.contentType` is filterable.
|
|
67
67
|
|
|
68
|
-
Reference filters take the target `
|
|
69
|
-
`--fields` is a comma-separated projection for list payloads. The API always keeps `id`; pass only the fields your view needs, such as `slug,title,excerpt,coverImage`.
|
|
68
|
+
Reference filters take the target `id` string (`{"author":"750e..."}`). On a to-many field, `$eq` and `$contains` mean membership, `$in` means any listed target is present, `$ne` and `$nin` mean none are present, and `$null: true` means empty or missing. Reference fields cannot be sorted; use `--sort field` / `--sort -field` on scalar fields.
|
|
69
|
+
`--fields` is a comma-separated projection for list payloads. The API always keeps `id`, `entryId`, `documentId`, and `locale`; pass only the other fields your view needs, such as `slug,title,excerpt,coverImage`.
|
|
70
70
|
|
|
71
|
-
`--populate` is a comma-separated list of reference fields to embed. Without it, reference fields return reference objects like `{ "
|
|
71
|
+
`--populate` is a comma-separated list of reference fields to embed. Without it, reference fields return reference objects like `{ "id": "..." }`; with `--populate author,contributors`, those fields return the target entry objects for the requested locale. Only reference fields can be populated (scalar fields 400), and populate never selects which target fields come back. A populated target missing in the requested locale resolves to null unless you pass `localeFallbacks=true`, which falls back to the best available locale variant, marked by its `locale` field. Limits: 200 references per to-many write, 500 distinct populated documents per field per request (400 past either).
|
|
72
72
|
|
|
73
|
-
Polymorphic references declare `targets: ["Post", "Product"]` instead of `target` (never both) and their values carry both parts: `{ "contentType": "Post", "
|
|
73
|
+
Polymorphic references declare `targets: ["Post", "Product"]` instead of `target` (never both) and their values carry both parts: `{ "contentType": "Post", "id": "..." }`. Populate returns mixed full entries, and generated hooks expose `$type` on each populated value to branch on. `--where` on polymorphic fields is not supported yet and fails with a clear error.
|
|
74
74
|
|
|
75
75
|
In generated React code, the equivalent is `populate: ['author', 'contributors'] as const` in the schema hook/query params. Let hooks infer the result when possible; if a named TypeScript shape is needed, use the generated `BlogPostResult<TFields, TPopulate>` type documented in `fimo/references/content.md`. Writes use the separate generated `BlogPostInput` type.
|
|
76
76
|
|
|
@@ -85,28 +85,28 @@ For reference schema JSON and entry value shape, load `fimo/references/content.m
|
|
|
85
85
|
}
|
|
86
86
|
```
|
|
87
87
|
|
|
88
|
-
Entry bodies
|
|
88
|
+
Entry bodies use reference objects with target `id` values:
|
|
89
89
|
|
|
90
90
|
```bash
|
|
91
|
-
fimo entries create BlogPost --body '{"data":{"slug":"hello","title":"Hello","author":{"
|
|
91
|
+
fimo entries create BlogPost --body '{"data":{"slug":"hello","title":"Hello","author":{"id":"750e8400-e29b-41d4-a716-446655440000"},"contributors":[]}}'
|
|
92
92
|
```
|
|
93
93
|
|
|
94
|
-
Do not add `localized` or field-level `i18n` to reference fields. The reference value belongs to the entry variant
|
|
94
|
+
Do not add `localized` or field-level `i18n` to reference fields. The reference value belongs to the entry variant containing it.
|
|
95
95
|
|
|
96
96
|
## Localized and shared content
|
|
97
97
|
|
|
98
|
-
Schemas are localized by default. Omit `--locale`
|
|
98
|
+
Schemas are localized by default. Omit `--locale` to use the project default. Pass it explicitly when reading, updating, or deleting a non-default variant: public identity is stable `id` plus `locale`.
|
|
99
99
|
|
|
100
100
|
When creating a localized variant of an existing entry, pass the source entry's `documentId` with `--document-id`. Keep projects single-locale unless the user asks for multilingual or localized output; when multiple locales are configured, create and update every locale explicitly.
|
|
101
101
|
|
|
102
|
-
For content shared across locales, define `"i18n": { "enabled": false }` at the schema root and create each entry once. Do not pass `--document-id` to manufacture locale variants.
|
|
102
|
+
For content shared across locales, define `"i18n": { "enabled": false }` at the schema root and create each entry once. Do not pass `--document-id` to manufacture locale variants. Raw API and CLI responses omit `locale`; generated collection models add the effective active/default locale without creating per-locale variants. No other `i18n` options and no field-level localization settings are supported.
|
|
103
103
|
|
|
104
104
|
Use `"isSingleton": true` for a type with exactly one logical document. Generated singleton clients expose `get`/`update` and their React Query hooks, not collection create/remove APIs.
|
|
105
105
|
|
|
106
106
|
## "Add a blog" recipe
|
|
107
107
|
|
|
108
108
|
1. Load `fimo/references/content.md` for the JSON shape and field types.
|
|
109
|
-
2. Write `src/schemas/BlogPost.json` with fields `title`, `body` (richtext), `coverImage` (media), etc. Do NOT declare a `slug` field — `slug` (and `id`, `documentId`, `locale`, `createdAt`, `updatedAt`, `__fimo`) is reserved entry metadata and will be rejected; every entry gets a `slug` for free.
|
|
109
|
+
2. Write `src/schemas/BlogPost.json` with fields `title`, `body` (richtext), `coverImage` (media), etc. Do NOT declare a `slug` field — `slug` (and `id`, `entryId`, `documentId`, `locale`, `createdAt`, `updatedAt`, `__fimo`) is reserved entry metadata and will be rejected; every entry gets a `slug` for free.
|
|
110
110
|
3. Run `fimo schemas push BlogPost`.
|
|
111
111
|
4. Generate cover images: `fimo generate pruna/p-image --prompt "..."` (per-model flags come from `fimo models show <model>`; see `media.md`).
|
|
112
112
|
5. Seed entries: `fimo entries create BlogPost --body '{"data": {"slug": "first-post", "title": "...", "coverImage": {...}}}'`.
|