fimo 0.5.0 → 0.6.0-experimental.2

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.
Files changed (31) hide show
  1. package/assets/content-templates/hooks-template.eta +83 -38
  2. package/assets/content-templates/types-template.eta +6 -2
  3. package/assets/skills/fimo/SKILL.md +1 -1
  4. package/assets/skills/fimo/references/content.md +13 -14
  5. package/assets/skills/fimo/references/locales.md +8 -0
  6. package/assets/skills/fimo/references/setup-react-router.md +2 -2
  7. package/assets/skills/fimo/references/ui.md +1 -1
  8. package/assets/skills/fimo-cli/references/content.md +9 -6
  9. package/assets/skills/fimo-cli/references/locales.md +6 -0
  10. package/dist/cli/bundle.json +1 -1
  11. package/dist/cli/index.js +112 -55
  12. package/dist/runtime/app/prefetch.js +1 -1
  13. package/dist/runtime/content/utils/retrieve-data-sources.d.ts +1 -0
  14. package/dist/runtime/content/utils/retrieve-data-sources.d.ts.map +1 -1
  15. package/dist/runtime/content/utils/retrieve-data-sources.js +27 -4
  16. package/dist/runtime/paths/get-fimo-paths.d.ts.map +1 -1
  17. package/dist/runtime/paths/get-fimo-paths.js +16 -6
  18. package/dist/runtime/paths/get-fimo-paths.test.d.ts +2 -0
  19. package/dist/runtime/paths/get-fimo-paths.test.d.ts.map +1 -0
  20. package/dist/runtime/paths/get-fimo-paths.test.js +69 -0
  21. package/dist/runtime/primitives/lib/query.d.ts +11 -9
  22. package/dist/runtime/primitives/lib/query.d.ts.map +1 -1
  23. package/dist/runtime/primitives/public-surface.test.js +3 -3
  24. package/dist/runtime/seo/buildSeoForPath.d.ts +1 -3
  25. package/dist/runtime/seo/buildSeoForPath.d.ts.map +1 -1
  26. package/dist/runtime/seo/buildSeoForPath.js +4 -1
  27. package/dist/scripts/resolve-routes.js +1 -1
  28. package/dist/scripts/sync-schemas.test.js +14 -3
  29. package/package.json +2 -2
  30. package/release.json +2 -2
  31. package/templates/react-router/package.json +10 -7
@@ -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
@@ -36,16 +36,35 @@ export type { <%= model.pascal %>, <%= model.pascal %>Fields, <%= model.pascal %
36
36
  * Wraps raw API data with Fimo types for source tracking
37
37
  */
38
38
  export function wrapWithSource(entry: Record<string, any>): <%= model.pascal %> {
39
+ <% if (!model.isSingleton) { -%>
40
+ const documentId = entry.documentId;
41
+ const locale = entry.locale;
42
+ const sourcePrefix = `<%= model.pascal %>.v2:${documentId}:${encodeURIComponent(locale)}`;
43
+ <% } else { -%>
39
44
  const id = entry.id;
40
- const sourcePrefix = `<%= model.pascal %>.${id}`;
45
+ const documentId = entry.documentId;
46
+ const locale = entry.locale ?? defaultLocale();
47
+ const usesV2Identity = typeof id !== "string";
48
+ const sourcePrefix = usesV2Identity
49
+ ? `<%= model.pascal %>.v2:${documentId}:${encodeURIComponent(locale)}`
50
+ : `<%= model.pascal %>.${id}`;
51
+ <% } -%>
41
52
  const entryFimo = entry.__fimo && typeof entry.__fimo === "object" ? entry.__fimo : {};
42
53
 
43
54
  return {
44
- id,
45
- documentId: entry.documentId,
55
+ <% if (!model.isSingleton) { -%>
56
+ documentId,
57
+ <% } else { -%>
58
+ ...(typeof id === "string" ? { id } : {}),
59
+ documentId,
60
+ <% } -%>
46
61
  slug: entry.slug != null ? new FimoString(entry.slug, `${sourcePrefix}.slug`) : entry.slug,
47
- <% if (model.localized) { -%>
48
- locale: entry.locale,
62
+ <% if (!model.isSingleton) { -%>
63
+ locale,
64
+ <% } else if (model.localized) { -%>
65
+ locale,
66
+ <% } else { -%>
67
+ ...(usesV2Identity ? { locale } : {}),
49
68
  <% } -%>
50
69
  createdAt: entry.createdAt,
51
70
  updatedAt: entry.updatedAt,
@@ -101,11 +120,11 @@ if (model.fields) {
101
120
  const wrapper = field.target === model.uid ? 'wrapWithSource' : `wrap${field.targetPascal}WithSource`;
102
121
  if (field.many) {
103
122
  -%>
104
- <%= key %>: Array.isArray(entry.<%= key %>) ? entry.<%= key %>.map((value: any) => value != null && typeof value === "object" && typeof value.id === "string" ? <%= wrapper %>(value) : value) : entry.<%= key %>,
123
+ <%= key %>: Array.isArray(entry.<%= key %>) ? entry.<%= key %>.map((value: any) => isPopulatedEntry(value) ? <%= wrapper %>(value) : value) : entry.<%= key %>,
105
124
  <%
106
125
  } else {
107
126
  -%>
108
- <%= key %>: entry.<%= key %> != null && typeof entry.<%= key %> === "object" && typeof entry.<%= key %>.id === "string" ? <%= wrapper %>(entry.<%= key %>) : entry.<%= key %>,
127
+ <%= key %>: isPopulatedEntry(entry.<%= key %>) ? <%= wrapper %>(entry.<%= key %>) : entry.<%= key %>,
109
128
  <%
110
129
  }
111
130
  break;
@@ -121,6 +140,23 @@ if (model.fields) {
121
140
  } as <%= model.pascal %>;
122
141
  }
123
142
 
143
+ interface PopulatedEntryRecord extends Record<string, unknown> {
144
+ documentId: string;
145
+ __fimo: { contentType: string };
146
+ }
147
+
148
+ function isPopulatedEntry(value: unknown): value is PopulatedEntryRecord {
149
+ if (value == null || typeof value !== "object") {
150
+ return false;
151
+ }
152
+ const entry = value as Record<string, unknown>;
153
+ const fimo = entry.__fimo;
154
+ return typeof entry.documentId === "string"
155
+ && fimo != null
156
+ && typeof fimo === "object"
157
+ && typeof (fimo as Record<string, unknown>).contentType === "string";
158
+ }
159
+
124
160
  <%
125
161
  const hasPolymorphicReferences = model.fields && Object.values(model.fields).some((field) => field.type === 'reference' && Array.isArray(field.targetRefs) && field.targetRefs.length > 0);
126
162
  if (hasPolymorphicReferences) {
@@ -132,7 +168,9 @@ if (hasPolymorphicReferences) {
132
168
  * (unpopulated) values pass through untouched.
133
169
  */
134
170
  function wrapPolymorphic(value: any, wrappers: Record<string, (entry: Record<string, any>) => any>): any {
135
- if (value == null || typeof value !== "object" || typeof value.id !== "string") return value;
171
+ if (!isPopulatedEntry(value)) {
172
+ return value;
173
+ }
136
174
  const contentType = value.__fimo && typeof value.__fimo.contentType === "string" ? value.__fimo.contentType : null;
137
175
  const wrapper = contentType ? wrappers[contentType] : undefined;
138
176
  if (!wrapper) return value;
@@ -172,8 +210,12 @@ function readLocale(
172
210
  ownerUsesLocale: boolean,
173
211
  fallbackLocale = defaultLocale()
174
212
  ): string | undefined {
213
+ <% if (!model.isSingleton) { -%>
214
+ return options.locale ?? fallbackLocale;
215
+ <% } else { -%>
175
216
  const populatesLocalizedTarget = populateFields(options.populate).some((field) => LOCALIZED_REFERENCE_FIELDS.has(field));
176
217
  return ownerUsesLocale || populatesLocalizedTarget ? options.locale ?? fallbackLocale : undefined;
218
+ <% } -%>
177
219
  }
178
220
 
179
221
  function fimoHeaders(extra?: Record<string, string>): Record<string, string> {
@@ -249,11 +291,11 @@ export type InfiniteListResult<TItem> = UseInfiniteQueryResult<
249
291
  readonly isLoadingMore: boolean;
250
292
  };
251
293
 
252
- export async function getById<TPopulate extends <%= model.pascal %>Populate | undefined = undefined>(
253
- id: string,
294
+ export async function getByDocumentId<TPopulate extends <%= model.pascal %>Populate | undefined = undefined>(
295
+ documentId: string,
254
296
  options: { locale?: string; populate?: TPopulate; localeFallbacks?: boolean } = {}
255
297
  ): Promise<<%= model.pascal %>Result<undefined, TPopulate>> {
256
- const res = await fetch(`${base}${withReadOptions(`/entries/<%= model.uid %>/${id}`, options, false)}`, { headers: fimoHeaders() });
298
+ const res = await fetch(`${base}${withReadOptions(`/v2/entries/<%= model.uid %>/${documentId}`, options)}`, { headers: fimoHeaders() });
257
299
  if (!res.ok) throw new Error(await res.text());
258
300
  const data = await res.json();
259
301
  return wrapWithSource(data.data) as <%= model.pascal %>Result<undefined, TPopulate>;
@@ -263,7 +305,7 @@ export async function getBySlug<TPopulate extends <%= model.pascal %>Populate |
263
305
  slug: string,
264
306
  options: { locale?: string; populate?: TPopulate; localeFallbacks?: boolean } = {}
265
307
  ): Promise<<%= model.pascal %>Result<undefined, TPopulate> | null> {
266
- const res = await fetch(`${base}${withReadOptions(`/entries/<%= model.uid %>/slug/${encodeURIComponent(slug)}`, options)}`, { headers: fimoHeaders() });
308
+ const res = await fetch(`${base}${withReadOptions(`/v2/entries/<%= model.uid %>/slug/${encodeURIComponent(slug)}`, options)}`, { headers: fimoHeaders() });
267
309
  if (res.status === 404) return null;
268
310
  if (!res.ok) throw new Error(await res.text());
269
311
  const data = await res.json();
@@ -275,7 +317,7 @@ export async function getByField<TPopulate extends <%= model.pascal %>Populate |
275
317
  value: string,
276
318
  options: { locale?: string; populate?: TPopulate; localeFallbacks?: boolean } = {}
277
319
  ): Promise<<%= model.pascal %>Result<undefined, TPopulate> | null> {
278
- const res = await fetch(`${base}${withReadOptions(`/entries/<%= model.uid %>/field/${encodeURIComponent(fieldName)}/${encodeURIComponent(value)}`, options)}`, { headers: fimoHeaders() });
320
+ const res = await fetch(`${base}${withReadOptions(`/v2/entries/<%= model.uid %>/field/${encodeURIComponent(fieldName)}/${encodeURIComponent(value)}`, options)}`, { headers: fimoHeaders() });
279
321
  if (res.status === 404) return null;
280
322
  if (!res.ok) throw new Error(await res.text());
281
323
  const data = await res.json();
@@ -321,7 +363,7 @@ export async function list<
321
363
  TPopulate extends <%= model.pascal %>Populate | undefined = undefined,
322
364
  >(params: <%= model.pascal %>Query<TFields, TPopulate> = {} as <%= model.pascal %>Query<TFields, TPopulate>): Promise<CollectionPage<<%= model.pascal %>Result<TFields, TPopulate>>> {
323
365
  const qs = toQueryString(params);
324
- const res = await fetch(`${base}/entries/<%= model.uid %>${qs ? "?" + qs : ""}`, { headers: fimoHeaders() });
366
+ const res = await fetch(`${base}/v2/entries/<%= model.uid %>${qs ? "?" + qs : ""}`, { headers: fimoHeaders() });
325
367
  if (!res.ok) throw new Error(await res.text());
326
368
  const data = parseCollectionResponse(await res.json());
327
369
  const items = data.data.map(wrapWithSource) as <%= model.pascal %>Result<TFields, TPopulate>[];
@@ -343,7 +385,7 @@ export async function get<
343
385
  }
344
386
 
345
387
  export async function create(payload: <%= model.pascal %>Input): Promise<<%= model.pascal %>> {
346
- const res = await fetch(`${base}/entries/<%= model.uid %>`, {
388
+ const res = await fetch(`${base}/v2/entries/<%= model.uid %>`, {
347
389
  method: "POST",
348
390
  headers: fimoHeaders({ "Content-Type": "application/json" }),
349
391
  body: JSON.stringify(splitWritePayload(payload))
@@ -353,19 +395,22 @@ export async function create(payload: <%= model.pascal %>Input): Promise<<%= mod
353
395
  return wrapWithSource(data.data);
354
396
  }
355
397
 
356
- export async function update(id: string, payload: Partial<<%= model.pascal %>Input>): Promise<<%= model.pascal %>> {
357
- const res = await fetch(`${base}/entries/<%= model.uid %>/${id}`, {
398
+ export async function update(documentId: string, payload: Partial<<%= model.pascal %>Input>): Promise<<%= model.pascal %>> {
399
+ const write = splitWritePayload(payload) as { data: Record<string, unknown>; locale?: string };
400
+ const url = withReadOptions(`/v2/entries/<%= model.uid %>/${documentId}`, { locale: write.locale });
401
+ const res = await fetch(`${base}${url}`, {
358
402
  method: "PUT",
359
403
  headers: fimoHeaders({ "Content-Type": "application/json" }),
360
- body: JSON.stringify(splitWritePayload(payload))
404
+ body: JSON.stringify({ data: write.data })
361
405
  });
362
406
  if (!res.ok) throw new Error(await res.text());
363
407
  const data = await res.json();
364
408
  return wrapWithSource(data.data);
365
409
  }
366
410
 
367
- export async function remove(id: string): Promise<void> {
368
- const res = await fetch(`${base}/entries/<%= model.uid %>/${id}`, { method: "DELETE", headers: fimoHeaders() });
411
+ export async function remove(documentId: string, options: { locale?: string } = {}): Promise<void> {
412
+ const url = withReadOptions(`/v2/entries/<%= model.uid %>/${documentId}`, options);
413
+ const res = await fetch(`${base}${url}`, { method: "DELETE", headers: fimoHeaders() });
369
414
  if (!res.ok) throw new Error(await res.text());
370
415
  }
371
416
  <% } else { -%>
@@ -399,7 +444,7 @@ export async function update(payload: Partial<<%= model.pascal %>Input>): Promis
399
444
  <% if (!model.isSingleton) { -%>
400
445
  const qk = {
401
446
  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,
447
+ byDocumentId: <TPopulate extends <%= model.pascal %>Populate | undefined = undefined>(documentId: string, options: { locale?: string; populate?: TPopulate; localeFallbacks?: boolean } = {}) => ["<%= model.pascal %>", "document", documentId, options.locale, options.populate] as const,
403
448
  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
449
  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
450
  list : (params: <%= model.pascal %>Query) => ["<%= model.pascal %>", params] as const,
@@ -409,10 +454,10 @@ const qk = {
409
454
  >(params: InfiniteListQuery<TFields, TPopulate>) => ["<%= model.pascal %>", "infinite", params] as const
410
455
  };
411
456
 
412
- export function useGetById<TPopulate extends <%= model.pascal %>Populate | undefined = undefined>(id: string, options: { locale?: string; populate?: TPopulate; localeFallbacks?: boolean } = {}) {
413
- const locale = readLocale(options, false, useQueryLocale(options.locale));
457
+ export function useGetByDocumentId<TPopulate extends <%= model.pascal %>Populate | undefined = undefined>(documentId: string, options: { locale?: string; populate?: TPopulate; localeFallbacks?: boolean } = {}): UseQueryResult<<%= model.pascal %>Result<undefined, TPopulate>, Error> {
458
+ const locale = useQueryLocale(options.locale);
414
459
  const localizedOptions = { ...options, locale };
415
- return useQuery<<%= model.pascal %>Result<undefined, TPopulate>, Error>({ queryKey: qk.byId(id, localizedOptions), queryFn: () => getById(id, localizedOptions) });
460
+ return useQuery<<%= model.pascal %>Result<undefined, TPopulate>, Error>({ queryKey: qk.byDocumentId(documentId, localizedOptions), queryFn: () => getByDocumentId(documentId, localizedOptions) });
416
461
  }
417
462
 
418
463
  export function useGetBySlug<TPopulate extends <%= model.pascal %>Populate | undefined = undefined>(slug: string, options: { locale?: string; populate?: TPopulate; localeFallbacks?: boolean } = {}) {
@@ -475,10 +520,10 @@ export function useInfiniteList<
475
520
  };
476
521
  }
477
522
 
478
- export function useSuspenseGetById<TPopulate extends <%= model.pascal %>Populate | undefined = undefined>(id: string, options: { locale?: string; populate?: TPopulate; localeFallbacks?: boolean } = {}) {
479
- const locale = readLocale(options, false, useQueryLocale(options.locale));
523
+ export function useSuspenseGetByDocumentId<TPopulate extends <%= model.pascal %>Populate | undefined = undefined>(documentId: string, options: { locale?: string; populate?: TPopulate; localeFallbacks?: boolean } = {}): UseSuspenseQueryResult<<%= model.pascal %>Result<undefined, TPopulate>, Error> {
524
+ const locale = useQueryLocale(options.locale);
480
525
  const localizedOptions = { ...options, locale };
481
- return useSuspenseQuery<<%= model.pascal %>Result<undefined, TPopulate>, Error>({ queryKey: qk.byId(id, localizedOptions), queryFn: () => getById(id, localizedOptions) });
526
+ return useSuspenseQuery<<%= model.pascal %>Result<undefined, TPopulate>, Error>({ queryKey: qk.byDocumentId(documentId, localizedOptions), queryFn: () => getByDocumentId(documentId, localizedOptions) });
482
527
  }
483
528
 
484
529
  export function useSuspenseGetBySlug<TPopulate extends <%= model.pascal %>Populate | undefined = undefined>(slug: string, options: { locale?: string; populate?: TPopulate; localeFallbacks?: boolean } = {}) {
@@ -516,22 +561,22 @@ export function useCreate() {
516
561
 
517
562
  export function useUpdate() {
518
563
  const qc = useQueryClient();
519
- return useMutation<<%= model.pascal %>, Error, { id: string } & Partial<<%= model.pascal %>Input>>({
520
- mutationFn : ({ id, ...patch }) => update(id, patch),
564
+ return useMutation<<%= model.pascal %>, Error, { documentId: string; locale?: string } & Partial<<%= model.pascal %>Input>>({
565
+ mutationFn : ({ documentId, ...patch }) => update(documentId, patch),
521
566
  onSuccess : (_data, vars) => {
522
567
  qc.invalidateQueries({ queryKey: qk.root });
523
- qc.invalidateQueries({ queryKey: qk.byId(vars.id) });
568
+ qc.invalidateQueries({ queryKey: qk.byDocumentId(vars.documentId, { locale: vars.locale ?? defaultLocale() }) });
524
569
  }
525
570
  });
526
571
  }
527
572
 
528
573
  export function useRemove() {
529
574
  const qc = useQueryClient();
530
- return useMutation<void, Error, string>({
531
- mutationFn : remove,
532
- onSuccess : (_data, id) => {
575
+ return useMutation<void, Error, { documentId: string; locale?: string }>({
576
+ mutationFn : ({ documentId, locale }) => remove(documentId, { locale }),
577
+ onSuccess : (_data, { documentId, locale }) => {
533
578
  qc.invalidateQueries({ queryKey: qk.root });
534
- qc.removeQueries({ queryKey: ["<%= model.pascal %>", id], exact: false });
579
+ qc.removeQueries({ queryKey: qk.byDocumentId(documentId, { locale: locale ?? defaultLocale() }), exact: false });
535
580
  }
536
581
  });
537
582
  }
@@ -569,7 +614,7 @@ export function useUpdate() {
569
614
  export default {
570
615
  <% if (!model.isSingleton) { -%>
571
616
  /* async helpers */
572
- getById,
617
+ getByDocumentId,
573
618
  getBySlug,
574
619
  getByField,
575
620
  list,
@@ -579,13 +624,13 @@ export default {
579
624
  remove,
580
625
 
581
626
  /* hooks */
582
- useGetById,
627
+ useGetByDocumentId,
583
628
  useGetBySlug,
584
629
  useGetByField,
585
630
  useList,
586
631
  useInfiniteList,
587
632
  useGet,
588
- useSuspenseGetById,
633
+ useSuspenseGetByDocumentId,
589
634
  useSuspenseGet,
590
635
  useSuspenseGetByField,
591
636
  useSuspenseGetBySlug,
@@ -32,11 +32,15 @@ export interface <%= model.pascal %>FimoMeta {
32
32
  }
33
33
 
34
34
  export interface <%= model.pascal %> extends WithReferences<<%= model.pascal %>References> {
35
- id: string;
35
+ <% if (model.isSingleton) { -%>
36
+ id?: string;
37
+ <% } -%>
36
38
  documentId: string;
37
39
  slug: FimoString;
38
- <% if (model.localized) { -%>
40
+ <% if (!model.isSingleton || model.localized) { -%>
39
41
  locale: string;
42
+ <% } else { -%>
43
+ locale?: string;
40
44
  <% } -%>
41
45
  createdAt: string;
42
46
  updatedAt: string;
@@ -43,7 +43,7 @@ Workflow / CLI-side concerns (pushing schemas, generating images, managing label
43
43
  ## Decision Tree (specific tasks)
44
44
 
45
45
  - **Setting up Fimo in a new project** — pick the framework:
46
- - React Router 7 framework mode → `references/setup-react-router.md`
46
+ - React Router framework mode (new projects use React Router 8) → `references/setup-react-router.md`
47
47
  - Plain Vite + React (or upgrading a legacy v3 project) → `references/setup-plain-vite.md`
48
48
  - Writing schema JSON / picking field types / Tiptap richtext → `references/content.md`
49
49
  - Writing form JSON / generated form client / form code → `references/forms.md`
@@ -191,10 +191,9 @@ Import from the public schema module, e.g. `import BlogPost from '@/schemas/Blog
191
191
 
192
192
  ```ts
193
193
  interface BlogPost {
194
- id: string;
195
194
  documentId: string;
196
195
  slug: FimoString;
197
- locale: string; // localized schemas only; omitted when i18n.enabled is false
196
+ locale: string;
198
197
  createdAt: string;
199
198
  updatedAt: string;
200
199
  __fimo: {
@@ -219,7 +218,7 @@ When a project has multiple configured locales, create and update each localized
219
218
 
220
219
  For shared content, set `i18n: { "enabled": false }` on the schema and create each entry once. Shared entries:
221
220
 
222
- - have no public `locale` property;
221
+ - expose the effective request locale in collection v2 responses;
223
222
  - are returned in every locale context without duplication;
224
223
  - ignore locale on owner reads and writes;
225
224
  - still use the active/requested locale when a populated reference targets a localized schema.
@@ -247,17 +246,17 @@ Filtering on polymorphic fields is not supported yet.
247
246
 
248
247
  ```ts
249
248
  // low-level fetch helpers
250
- getById<TPopulate extends BlogPostPopulate | undefined>(id: string, options?: { populate?: TPopulate }): Promise<BlogPostResult<undefined, TPopulate>>
249
+ getByDocumentId<TPopulate extends BlogPostPopulate | undefined>(documentId: string, options?: { locale?: string; populate?: TPopulate }): Promise<BlogPostResult<undefined, TPopulate>>
251
250
  getBySlug<TPopulate extends BlogPostPopulate | undefined>(slug: string, options?: { populate?: TPopulate }): Promise<BlogPostResult<undefined, TPopulate> | null>
252
251
  getByField<TPopulate extends BlogPostPopulate | undefined>(fieldName: string, value: string, options?: { populate?: TPopulate }): Promise<BlogPostResult<undefined, TPopulate> | null>
253
252
  list<TFields extends BlogPostFields | undefined, TPopulate extends BlogPostPopulate | undefined>(params?: BlogPostQuery<TFields, TPopulate>): Promise<CollectionPage<BlogPostResult<TFields, TPopulate>>>
254
253
  get<TFields extends BlogPostFields | undefined, TPopulate extends BlogPostPopulate | undefined>(params?: BlogPostQuery<TFields, TPopulate>): Promise<BlogPostResult<TFields, TPopulate>[]>
255
254
  create(payload: BlogPostInput): Promise<BlogPost>
256
- update(id: string, payload: Partial<BlogPostInput>): Promise<BlogPost>
257
- remove(id: string): Promise<void>
255
+ update(documentId: string, payload: Partial<BlogPostInput>): Promise<BlogPost>
256
+ remove(documentId: string, options?: { locale?: string }): Promise<void>
258
257
 
259
258
  // react-query hooks
260
- useGetById(id, { populate? }) // → { data: BlogPostResult<undefined, TPopulate> | undefined, isLoading, ... }
259
+ useGetByDocumentId(documentId, { locale?, populate? }) // → { data: BlogPostResult<undefined, TPopulate> | undefined, isLoading, ... }
261
260
  useGetBySlug(slug, { populate? }) // → { data: BlogPostResult<undefined, TPopulate> | null | undefined, ... } — ideal for /:slug detail routes
262
261
  useGetByField(fieldName, value, { populate? })
263
262
  useList<TFields, TPopulate>(params?) // → { data: CollectionPage<BlogPostResult<TFields, TPopulate>> | undefined, ... }
@@ -266,7 +265,7 @@ useGet<TFields, TPopulate>(params?) // → legacy array-shaped { data:
266
265
  useCreate() / useUpdate() / useRemove()
267
266
 
268
267
  // Suspense variants (same signatures, throw to a <Suspense> boundary instead of returning isLoading)
269
- useSuspenseGetById(id) / useSuspenseGetBySlug(slug) / useSuspenseGetByField(fieldName, value) / useSuspenseGet(params?)
268
+ useSuspenseGetByDocumentId(documentId) / useSuspenseGetBySlug(slug) / useSuspenseGetByField(fieldName, value) / useSuspenseGet(params?)
270
269
  ```
271
270
 
272
271
  Write inputs contain plain JSON values. Read models contain Fimo wrapper values for source tracking, so use `BlogPostInput` rather than sending a `BlogPost` back to `create` or `update`.
@@ -279,7 +278,7 @@ update(payload: Partial<SettingsInput>): Promise<Settings>
279
278
  useGet(options?) / useSuspenseGet(options?) / useUpdate()
280
279
  ```
281
280
 
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.
281
+ Singleton clients do not expose `create`, `remove`, `getByDocumentId`, or list queries. Localized singletons have one variant per locale; non-localized singletons have one locale-free row.
283
282
 
284
283
  Choose the collection interface from the UI behavior. Use `useList` when the UI controls a page, and read pagination state from `pageInfo`:
285
284
 
@@ -338,7 +337,7 @@ Operators: scalar equality, `$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$in`, `
338
337
  Reference field values are reference objects, but reference filters use the target `documentId` string, for example `where: { author: "750e..." }`; for to-many references that means "contains this target document".
339
338
  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
339
 
341
- Use `fields` to keep list payloads small. The API always keeps `id`, and returns only the requested fields beyond that:
340
+ Use `fields` to keep list payloads small. The v2 API always keeps `documentId` and `locale`, and returns only the requested fields beyond that:
342
341
 
343
342
  ```tsx
344
343
  const { data: summaries = [] } = BlogPost.useGet({
@@ -346,7 +345,7 @@ const { data: summaries = [] } = BlogPost.useGet({
346
345
  sort: '-publishedAt',
347
346
  limit: 12,
348
347
  });
349
- // summaries: Array<Pick<BlogPost, 'id' | 'slug' | 'title' | 'excerpt' | 'coverImage'>>
348
+ // summaries: Array<Pick<BlogPost, 'documentId' | 'locale' | 'slug' | 'title' | 'excerpt' | 'coverImage'>>
350
349
  ```
351
350
 
352
351
  Use `populate` to embed reference targets in read results:
@@ -434,7 +433,7 @@ export function BlogList() {
434
433
  return (
435
434
  <ul>
436
435
  {page.items.map((post) => (
437
- <li key={post.id}>
436
+ <li key={`${post.documentId}:${post.locale}`}>
438
437
  <Text value={post.title} as="h2" />
439
438
  <Image value={post.coverImage} width={800} height={400} fit="cover" />
440
439
  <RichText value={post.body} className="prose" />
@@ -451,9 +450,9 @@ For the `<Text>` / `<RichText>` / `<Image>` / `<Date>` components and the rules
451
450
  ## Read path (published app, no auth)
452
451
 
453
452
  ```ts
454
- const res = await fetch(`${import.meta.env.VITE_API_URL}/entries/<typeName>?fields=slug,title&limit=12`);
453
+ const res = await fetch(`${import.meta.env.VITE_API_URL}/v2/entries/<typeName>?locale=en&fields=slug,title&limit=12`);
455
454
  const { data, meta } = await res.json();
456
- // data: Entry[] — fields flattened at top level (id, slug, title, ...)
455
+ // data: Entry[] — fields flattened at top level (documentId, locale, slug, title, ...)
457
456
  // meta: { limit, offset, total, hasMore }
458
457
  ```
459
458
 
@@ -67,6 +67,14 @@ This helper exists so the Fimo UI route selector, preview navigation, labels, an
67
67
 
68
68
  Copyable examples live in `assets/react-router-locales/routes.ts` and `assets/react-router-locales/navigation.tsx`.
69
69
 
70
+ ## Existing Custom Locale Systems
71
+
72
+ Before changing a multilingual app, inspect its locale provider, route table, language switcher, redirects, and current public URLs. Preserve that behavior while migrating.
73
+
74
+ For React Router projects, prefer converging on one locale contract owned by `fimo-config.json`, `fimoRoutes()`, and `FimoLink`. Move existing providers and language-switcher UI into the framework root instead of replacing them or adding a second locale context. Keep route metadata canonical and reuse page modules across locales.
75
+
76
+ If adopting the standard helper would change established public URLs, do not silently rewrite them. Keep the custom router temporarily, pass its resolved locale to `<FimoProviders locale={locale}>`, and ask the user to choose the canonical URLs and redirects before normalizing the route table.
77
+
70
78
  ## Links
71
79
 
72
80
  Use `FimoLink` for internal links that should preserve or switch locale:
@@ -1,4 +1,4 @@
1
- # Setup Fimo on React Router 7
1
+ # Setup Fimo on React Router 8
2
2
 
3
3
  This recipe describes the canonical Fimo + React Router framework-mode setup.
4
4
  It mirrors what `fimo init` scaffolds today; you can also apply it to an
@@ -6,7 +6,7 @@ existing RR project.
6
6
 
7
7
  ## Prerequisites
8
8
 
9
- - Node 20+
9
+ - Node 22.22+
10
10
  - A Fimo project created via `fimo init` (gives you `projectId`,
11
11
  `organizationId`, and an `apiUrl`).
12
12
 
@@ -42,7 +42,7 @@ The template uses **Vite + React 19 + TypeScript + Tailwind v4 + shadcn/ui + rea
42
42
 
43
43
  ### Routing
44
44
 
45
- - This template uses React Router v7 framework mode (file-based route config in `src/routes.ts`). Use `<FimoLink>` from `fimo/react-router` for locale-aware internal links, especially in projects with more than one configured locale. Use React Router `<Link>` / `<NavLink>` only when you intentionally do not want Fimo's locale behavior. Never raw `<a href="/...">` for internal navigation because it causes full page reloads.
45
+ - This template uses React Router 8 framework mode (file-based route config in `src/routes.ts`). Use `<FimoLink>` from `fimo/react-router` for locale-aware internal links, especially in projects with more than one configured locale. Use React Router `<Link>` / `<NavLink>` only when you intentionally do not want Fimo's locale behavior. Never raw `<a href="/...">` for internal navigation because it causes full page reloads.
46
46
  - Reserve `<a>` for external links, with `target="_blank" rel="noreferrer"`.
47
47
 
48
48
  ### Loading states
@@ -20,6 +20,9 @@ Steps 1, 3, and 5 reference the project skill (`fimo`) for shape and React API.
20
20
 
21
21
  ```bash
22
22
  fimo schemas push [TypeName] # push JSON to API (create or update). Omit TypeName to push all.
23
+ # Pushing all sends every schema as ONE atomic batch, so types that
24
+ # reference each other (even cycles) can be added in a single push.
25
+ # Files not present are never deleted — use `schemas delete` for that.
23
26
  fimo schemas list # list all registered schemas
24
27
  fimo schemas get <TypeName> # get a single schema
25
28
  fimo schemas delete <TypeName> # delete a schema
@@ -40,9 +43,9 @@ fimo entries create <TypeName> --body '<json>'
40
43
  fimo entries create <TypeName> --locale es --document-id <documentId> --body '<json>'
41
44
  fimo entries list <TypeName> [--limit N] [--offset N] [--search Q] [--where JSON] [--sort F] [--fields FIELDS] [--populate FIELDS]
42
45
  fimo entries list <TypeName> --locale es
43
- fimo entries get <TypeName> <id>
44
- fimo entries update <TypeName> <id> --locale es --body '<json>'
45
- fimo entries delete <TypeName> <id>
46
+ fimo entries get <TypeName> <documentId> --locale es
47
+ fimo entries update <TypeName> <documentId> --locale es --body '<json>'
48
+ fimo entries delete <TypeName> <documentId> --locale es
46
49
  ```
47
50
 
48
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.**
@@ -63,7 +66,7 @@ Use the canonical read query flags: `--search`, `--sort`, `--where`, `--limit`,
63
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.
64
67
 
65
68
  Reference filters take the target `documentId` string even though stored values are objects (`{"author":"750e..."}`). On a to-many field, `$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; use `--sort field` / `--sort -field` on scalar fields.
66
- `--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`.
69
+ `--fields` is a comma-separated projection for list payloads. The v2 API always keeps `documentId` and `locale`; pass only the other fields your view needs, such as `slug,title,excerpt,coverImage`.
67
70
 
68
71
  `--populate` is a comma-separated list of reference fields to embed. Without it, reference fields return reference objects like `{ "documentId": "..." }`; 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).
69
72
 
@@ -92,11 +95,11 @@ Do not add `localized` or field-level `i18n` to reference fields. The reference
92
95
 
93
96
  ## Localized and shared content
94
97
 
95
- Schemas are localized by default. Omit `--locale` for default-locale entries. Pass `--locale <locale>` when creating, listing, reading, or updating a specific non-default locale.
98
+ Schemas are localized by default. Omit `--locale` for default-locale creates and lists. `entries get`, `entries update`, and `entries delete` require `--locale` because collection entry identity is always `documentId + locale`.
96
99
 
97
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.
98
101
 
99
- 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. Shared entry responses omit `locale`; a read locale is only used to resolve populated references that target localized schemas. No other `i18n` options and no field-level localization settings are supported.
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. V2 shared entry responses expose the effective request locale even though storage still has one locale-free row. No other `i18n` options and no field-level localization settings are supported.
100
103
 
101
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.
102
105
 
@@ -67,6 +67,12 @@ import { fimoRoutes } from 'fimo/react-router/routes';
67
67
 
68
68
  For custom frameworks, the project may implement its own URL scheme, but it still needs to pass the active locale into Fimo and keep canonical route metadata if it wants Fimo Studio route selection to work.
69
69
 
70
+ ## Migrate An Existing Locale System
71
+
72
+ Inspect the existing locale provider, route table, switcher, redirects, and public URLs before editing configuration. Preserve working URL behavior during the migration.
73
+
74
+ For React Router projects, prefer one contract based on `fimo-config.json`, `fimoRoutes()`, and `FimoLink`; migrate existing wrappers into the framework root instead of layering a second locale context. If that would change established URLs, keep the custom router temporarily, pass its active locale to `FimoProviders`, and ask the user to choose canonical URLs and redirects before rewriting routes.
75
+
70
76
  ## Labels And Content
71
77
 
72
78
  Labels and content are separate translated surfaces:
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "bundled": true,
3
3
  "bundler": "esbuild",
4
- "bundledAt": "2026-07-14T09:33:53.022Z",
4
+ "bundledAt": "2026-07-14T21:53:17.245Z",
5
5
  "cliVersion": "0.5.0",
6
6
  "external": [
7
7
  "oxc-parser",