fimo 0.10.1-staging.4 → 0.11.0-staging.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.
@@ -1,4 +1,19 @@
1
1
  import type { CollectionContentClient, ContentClientConfig } from './types.js';
2
+ /**
3
+ * React Query's structural sharing (`replaceEqualDeep`) rebuilds plain objects
4
+ * and arrays key-by-key from `Object.keys`, which silently drops the
5
+ * non-enumerable, symbol-keyed source tag `wrapField` puts on `json` values.
6
+ * A `json` field would then lose its `data-fimo-source` on the first refetch
7
+ * that changes it, making it unselectable in every editing surface. Every other
8
+ * wrapped type is a class instance, which structural sharing returns untouched.
9
+ *
10
+ * Turning it off costs nothing here: `wrapEntry` allocates fresh wrappers on
11
+ * every fetch, so a wrapped entry never compared equal to its predecessor and
12
+ * sharing was already returning the new object.
13
+ */
14
+ export declare const SOURCE_TRACKED_QUERY_OPTIONS: {
15
+ readonly structuralSharing: false;
16
+ };
2
17
  export declare function createContentClient<T extends {
3
18
  id: string;
4
19
  }, TInput extends object>(config: ContentClientConfig): CollectionContentClient<T, TInput>;
@@ -2,6 +2,19 @@ import { useInfiniteQuery, useMutation, useQuery, useQueryClient, useSuspenseQue
2
2
  import { parseCollectionResponse } from '../parseCollectionResponse.js';
3
3
  import { apiBase, buildReadPath, fimoHeaders, interpretSchema, readCollectionLocale, resolveClientHandle, splitWritePayload, validateReferences, withMutationLocale, wrapEntry, } from './internal.js';
4
4
  import { useQueryLocale } from './useQueryLocale.js';
5
+ /**
6
+ * React Query's structural sharing (`replaceEqualDeep`) rebuilds plain objects
7
+ * and arrays key-by-key from `Object.keys`, which silently drops the
8
+ * non-enumerable, symbol-keyed source tag `wrapField` puts on `json` values.
9
+ * A `json` field would then lose its `data-fimo-source` on the first refetch
10
+ * that changes it, making it unselectable in every editing surface. Every other
11
+ * wrapped type is a class instance, which structural sharing returns untouched.
12
+ *
13
+ * Turning it off costs nothing here: `wrapEntry` allocates fresh wrappers on
14
+ * every fetch, so a wrapped entry never compared equal to its predecessor and
15
+ * sharing was already returning the new object.
16
+ */
17
+ export const SOURCE_TRACKED_QUERY_OPTIONS = { structuralSharing: false };
5
18
  export function createContentClient(config) {
6
19
  const references = config.references ?? {};
7
20
  const info = interpretSchema(config.schema);
@@ -147,6 +160,7 @@ export function createContentClient(config) {
147
160
  return useQuery({
148
161
  queryKey: qk.byId(id, localizedOptions),
149
162
  queryFn: () => getById(id, localizedOptions),
163
+ ...SOURCE_TRACKED_QUERY_OPTIONS,
150
164
  });
151
165
  }
152
166
  function useGetBySlug(slug, options = {}) {
@@ -155,6 +169,7 @@ export function createContentClient(config) {
155
169
  return useQuery({
156
170
  queryKey: qk.bySlug(slug, localizedOptions),
157
171
  queryFn: () => getBySlug(slug, localizedOptions),
172
+ ...SOURCE_TRACKED_QUERY_OPTIONS,
158
173
  enabled: !!slug,
159
174
  });
160
175
  }
@@ -164,6 +179,7 @@ export function createContentClient(config) {
164
179
  return useQuery({
165
180
  queryKey: qk.byField(fieldName, value, localizedOptions),
166
181
  queryFn: () => getByField(fieldName, value, localizedOptions),
182
+ ...SOURCE_TRACKED_QUERY_OPTIONS,
167
183
  enabled: !!value,
168
184
  });
169
185
  }
@@ -173,6 +189,7 @@ export function createContentClient(config) {
173
189
  return useQuery({
174
190
  queryKey: qk.list(localizedParams),
175
191
  queryFn: () => list(localizedParams),
192
+ ...SOURCE_TRACKED_QUERY_OPTIONS,
176
193
  select: (page) => page.items,
177
194
  });
178
195
  }
@@ -182,6 +199,7 @@ export function createContentClient(config) {
182
199
  return useQuery({
183
200
  queryKey: qk.list(localizedParams),
184
201
  queryFn: () => list(localizedParams),
202
+ ...SOURCE_TRACKED_QUERY_OPTIONS,
185
203
  });
186
204
  }
187
205
  function useInfiniteList(params = {}) {
@@ -190,6 +208,7 @@ export function createContentClient(config) {
190
208
  const query = useInfiniteQuery({
191
209
  queryKey: qk.infiniteList(localizedParams),
192
210
  queryFn: ({ pageParam }) => list({ ...localizedParams, offset: pageParam }),
211
+ ...SOURCE_TRACKED_QUERY_OPTIONS,
193
212
  initialPageParam: 0,
194
213
  getNextPageParam: (lastPage) => lastPage.pageInfo.nextOffset ?? undefined,
195
214
  });
@@ -208,6 +227,7 @@ export function createContentClient(config) {
208
227
  return useSuspenseQuery({
209
228
  queryKey: qk.byId(id, localizedOptions),
210
229
  queryFn: () => getById(id, localizedOptions),
230
+ ...SOURCE_TRACKED_QUERY_OPTIONS,
211
231
  });
212
232
  }
213
233
  function useSuspenseGetBySlug(slug, options = {}) {
@@ -216,6 +236,7 @@ export function createContentClient(config) {
216
236
  return useSuspenseQuery({
217
237
  queryKey: qk.bySlug(slug, localizedOptions),
218
238
  queryFn: () => getBySlug(slug, localizedOptions),
239
+ ...SOURCE_TRACKED_QUERY_OPTIONS,
219
240
  });
220
241
  }
221
242
  function useSuspenseGetByField(fieldName, value, options = {}) {
@@ -224,6 +245,7 @@ export function createContentClient(config) {
224
245
  return useSuspenseQuery({
225
246
  queryKey: qk.byField(fieldName, value, localizedOptions),
226
247
  queryFn: () => getByField(fieldName, value, localizedOptions),
248
+ ...SOURCE_TRACKED_QUERY_OPTIONS,
227
249
  });
228
250
  }
229
251
  function useSuspenseGet(params = {}) {
@@ -232,6 +254,7 @@ export function createContentClient(config) {
232
254
  return useSuspenseQuery({
233
255
  queryKey: qk.list(localizedParams),
234
256
  queryFn: () => list(localizedParams),
257
+ ...SOURCE_TRACKED_QUERY_OPTIONS,
235
258
  select: (page) => page.items,
236
259
  });
237
260
  }
@@ -1,4 +1,5 @@
1
1
  import { useMutation, useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query';
2
+ import { SOURCE_TRACKED_QUERY_OPTIONS } from './createContentClient.js';
2
3
  import { apiBase, buildReadPath, fimoHeaders, interpretSchema, readSingletonLocale, referenceTargetUids, resolveClientHandle, splitWritePayload, validateReferences, withMutationLocale, wrapEntry, } from './internal.js';
3
4
  import { useQueryLocale } from './useQueryLocale.js';
4
5
  export function createSingletonClient(config) {
@@ -78,6 +79,7 @@ export function createSingletonClient(config) {
78
79
  return useQuery({
79
80
  queryKey: qk.document(localizedOptions),
80
81
  queryFn: () => get(localizedOptions),
82
+ ...SOURCE_TRACKED_QUERY_OPTIONS,
81
83
  });
82
84
  }
83
85
  function useSuspenseGet(options = {}) {
@@ -86,6 +88,7 @@ export function createSingletonClient(config) {
86
88
  return useSuspenseQuery({
87
89
  queryKey: qk.document(localizedOptions),
88
90
  queryFn: () => get(localizedOptions),
91
+ ...SOURCE_TRACKED_QUERY_OPTIONS,
89
92
  });
90
93
  }
91
94
  function useUpdate() {
@@ -1,7 +1,7 @@
1
1
  import { pascalCase } from 'change-case';
2
2
  // @ts-ignore - virtual module provided by fimoConfigPlugin in fimo/vite
3
3
  import fimoConfig from 'virtual:fimo-config';
4
- import { FimoBoolean, FimoMedia, FimoRichText, FimoString } from '../../primitives/lib/primitives.js';
4
+ import { FIMO_SOURCE, FimoBoolean, FimoMedia, FimoRichText, FimoString } from '../../primitives/lib/primitives.js';
5
5
  export function interpretSchema(schema) {
6
6
  if (!schema || typeof schema.uid !== 'string' || schema.uid.length === 0) {
7
7
  throw new Error('Content schema is missing a "uid".');
@@ -176,15 +176,59 @@ function wrapReference(value, uid, resolve) {
176
176
  }
177
177
  return target.wrapWithSource(value);
178
178
  }
179
+ /**
180
+ * Blocks values have no wrapper class of their own — the array structure stays
181
+ * plain JSON (matching the API/codegen shape) and only the scalar leaves inside
182
+ * each item are wrapped. `_key`/`_type` are structural metadata, never fields.
183
+ */
184
+ function blockItemFields(field, item) {
185
+ const schema = field.schema;
186
+ if (!schema) {
187
+ return undefined;
188
+ }
189
+ if ('oneOf' in schema) {
190
+ return schema.oneOf.find((variant) => variant.uid === item._type)?.fields;
191
+ }
192
+ return schema.fields;
193
+ }
194
+ function wrapBlockItem(field, item, source, resolve) {
195
+ if (item == null || typeof item !== 'object' || Array.isArray(item)) {
196
+ return item;
197
+ }
198
+ const record = item;
199
+ const fields = blockItemFields(field, record);
200
+ if (!fields) {
201
+ return item;
202
+ }
203
+ const wrapped = { ...record };
204
+ for (const [key, childField] of Object.entries(fields)) {
205
+ wrapped[key] = wrapField(childField, record[key], `${source}.${key}`, resolve);
206
+ }
207
+ return wrapped;
208
+ }
209
+ /**
210
+ * A JSON field's declared shape is a plain object/array — tagging it in place
211
+ * (rather than boxing it, like the scalar wrappers below) keeps that shape
212
+ * intact so consumers that read it as ordinary JSON see no difference.
213
+ */
214
+ function tagWithSource(value, source) {
215
+ Object.defineProperty(value, FIMO_SOURCE, { value: source, enumerable: false, configurable: true });
216
+ return value;
217
+ }
179
218
  function wrapField(field, value, source, resolve) {
180
219
  switch (field.type) {
181
220
  case 'string':
182
221
  case 'text':
183
- return value != null ? new FimoString(value, source) : value;
222
+ case 'date':
223
+ return value != null ? new FimoString(String(value), source) : value;
184
224
  case 'richtext':
185
225
  return value != null ? new FimoRichText(value, source) : value;
186
226
  case 'boolean':
187
227
  return value != null ? new FimoBoolean(value, source) : value;
228
+ case 'json':
229
+ return value != null && typeof value === 'object'
230
+ ? tagWithSource(Array.isArray(value) ? [...value] : { ...value }, source)
231
+ : value;
188
232
  case 'media':
189
233
  if (field.multiple) {
190
234
  return (value?.map((m, i) => new FimoMedia(m, `${source}[${i}]`)) ?? value);
@@ -207,6 +251,23 @@ function wrapField(field, value, source, resolve) {
207
251
  }
208
252
  return wrapReference(value, uid, resolve);
209
253
  }
254
+ case 'blocks':
255
+ return Array.isArray(value)
256
+ ? value.map((item, i) => wrapBlockItem(field, item, `${source}[${i}]`, resolve))
257
+ : value;
258
+ // 'number' deliberately falls through here — this is a decision, not a
259
+ // gap. React renders a boxed String directly (`<span>{new FimoString(...)}
260
+ // </span>` works, because React special-cases String objects), but it
261
+ // throws "Objects are not valid as a React child" on a boxed Number. A
262
+ // FimoNumber wrapper — whether a plain class or `extends Number` — would
263
+ // break `{post.views}`, which is exactly how a raw number field renders
264
+ // in every Fimo site today. `extends Number` would recover `.toFixed()`/
265
+ // `.toPrecision()`/`.toLocaleString()` and make `JSON.stringify` emit `7`
266
+ // instead of `{"value":7}`, but it does NOT fix JSX rendering, and
267
+ // `typeof`/`===`/`Number.isInteger` stay broken either way (boxed
268
+ // primitives never satisfy those). Revisiting this needs a `<Number>`
269
+ // component in `fimo/ui` (none exists yet — every other wrapped type has
270
+ // one) plus a migration path for sites already rendering numbers raw.
210
271
  default:
211
272
  return value;
212
273
  }
@@ -6,6 +6,19 @@ interface RecordData {
6
6
  visible: boolean;
7
7
  }
8
8
  export type GroupedDataSources = Record<string, RecordData[]>;
9
+ /**
10
+ * Parses a `data-fimo-source` attribute into its schema, identity, and field
11
+ * parts. The field itself may contain dots (e.g. a blocks leaf such as
12
+ * "body[0].title"), so only the first two segments are fixed positions.
13
+ */
14
+ export declare function parseDataSource(source: string): {
15
+ schemaName: string;
16
+ identity: {
17
+ id: string;
18
+ locale?: string;
19
+ };
20
+ fieldName: string;
21
+ } | null;
9
22
  /**
10
23
  * Finds all visible elements with a `data-fimo-source` attribute and groups them
11
24
  * by schema and ID into a structured object.
@@ -14,6 +14,24 @@ function parseRecordIdentity(token) {
14
14
  return null;
15
15
  }
16
16
  }
17
+ /**
18
+ * Parses a `data-fimo-source` attribute into its schema, identity, and field
19
+ * parts. The field itself may contain dots (e.g. a blocks leaf such as
20
+ * "body[0].title"), so only the first two segments are fixed positions.
21
+ */
22
+ export function parseDataSource(source) {
23
+ const parts = source.split('.');
24
+ if (parts.length < 3) {
25
+ return null;
26
+ }
27
+ const [schemaName, identityToken] = parts;
28
+ const fieldName = parts.slice(2).join('.');
29
+ const identity = parseRecordIdentity(identityToken);
30
+ if (!identity) {
31
+ return null;
32
+ }
33
+ return { schemaName, identity, fieldName };
34
+ }
17
35
  /**
18
36
  * Checks if an element is genuinely visible to the user within the viewport.
19
37
  * @param el The element to check.
@@ -51,37 +69,33 @@ export function retrieveVisibleDataSources() {
51
69
  if (!source) {
52
70
  return;
53
71
  }
54
- const parts = source.split('.');
55
- // Expects the format "SchemaName.id.fieldName"
56
- if (parts.length === 3) {
57
- const [schemaName, identityToken, fieldName] = parts;
58
- const identity = parseRecordIdentity(identityToken);
59
- if (!identity) {
60
- return;
61
- }
62
- const { id, locale } = identity;
63
- const recordKey = JSON.stringify([id, locale ?? null]);
64
- // Skip __translations
65
- if (schemaName === '__translations') {
66
- return;
67
- }
68
- // Ensure the schema key exists
69
- if (!tempGroupedData[schemaName]) {
70
- tempGroupedData[schemaName] = {};
71
- }
72
- // Ensure the record (by ID) exists for this schema
73
- if (!tempGroupedData[schemaName][recordKey]) {
74
- tempGroupedData[schemaName][recordKey] = {
75
- id: id,
76
- ...(locale ? { locale } : {}),
77
- data: {},
78
- visible: isElementVisible(el),
79
- };
80
- }
81
- // Add the field and its text content to the record
82
- if (el.textContent) {
83
- tempGroupedData[schemaName][recordKey].data[fieldName] = el.textContent.trim();
84
- }
72
+ const parsed = parseDataSource(source);
73
+ if (!parsed) {
74
+ return;
75
+ }
76
+ const { schemaName, identity, fieldName } = parsed;
77
+ const { id, locale } = identity;
78
+ const recordKey = JSON.stringify([id, locale ?? null]);
79
+ // Skip __translations
80
+ if (schemaName === '__translations') {
81
+ return;
82
+ }
83
+ // Ensure the schema key exists
84
+ if (!tempGroupedData[schemaName]) {
85
+ tempGroupedData[schemaName] = {};
86
+ }
87
+ // Ensure the record (by ID) exists for this schema
88
+ if (!tempGroupedData[schemaName][recordKey]) {
89
+ tempGroupedData[schemaName][recordKey] = {
90
+ id: id,
91
+ ...(locale ? { locale } : {}),
92
+ data: {},
93
+ visible: isElementVisible(el),
94
+ };
95
+ }
96
+ // Add the field and its text content to the record
97
+ if (el.textContent) {
98
+ tempGroupedData[schemaName][recordKey].data[fieldName] = el.textContent.trim();
85
99
  }
86
100
  });
87
101
  // Convert the temporary object into the final desired array structure.
@@ -0,0 +1,25 @@
1
+ import { type ComponentPropsWithoutRef, type ElementType, type ReactNode } from 'react';
2
+ type JsonProps<T extends ElementType = 'span'> = {
3
+ value: unknown;
4
+ as?: T;
5
+ children?: (value: unknown) => ReactNode;
6
+ } & Omit<ComponentPropsWithoutRef<T>, 'value' | 'as' | 'children'>;
7
+ /**
8
+ * Json component for rendering JSON field values with source tracking.
9
+ *
10
+ * Use children render function to customize how the value is displayed.
11
+ * Pass `as="pre"` (with a `white-space: pre-wrap` class) for formatted output.
12
+ *
13
+ * @example
14
+ * // Default (single-line JSON text)
15
+ * <Json value={product.metadata} />
16
+ *
17
+ * // Pretty-printed
18
+ * <Json value={product.metadata} as="pre" className="whitespace-pre-wrap" />
19
+ *
20
+ * // With render function for custom display
21
+ * <Json value={product.metadata}>{(val) => val.tags?.join(', ')}</Json>
22
+ */
23
+ export declare function Json<T extends ElementType = 'span'>({ value, as, children, ...props }: JsonProps<T>): import("react/jsx-runtime").JSX.Element | null;
24
+ export {};
25
+ //# sourceMappingURL=Json.d.ts.map
@@ -0,0 +1,29 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { getFimoSource, useFimoLiveValue } from '../lib/index.js';
3
+ /**
4
+ * Json component for rendering JSON field values with source tracking.
5
+ *
6
+ * Use children render function to customize how the value is displayed.
7
+ * Pass `as="pre"` (with a `white-space: pre-wrap` class) for formatted output.
8
+ *
9
+ * @example
10
+ * // Default (single-line JSON text)
11
+ * <Json value={product.metadata} />
12
+ *
13
+ * // Pretty-printed
14
+ * <Json value={product.metadata} as="pre" className="whitespace-pre-wrap" />
15
+ *
16
+ * // With render function for custom display
17
+ * <Json value={product.metadata}>{(val) => val.tags?.join(', ')}</Json>
18
+ */
19
+ export function Json({ value, as, children, ...props }) {
20
+ const Component = as || 'span';
21
+ const { value: liveValue, ref } = useFimoLiveValue(value);
22
+ // Defensive: return null if no value
23
+ if (value === null || value === undefined) {
24
+ return null;
25
+ }
26
+ const source = getFimoSource(value);
27
+ const content = children ? children(liveValue) : JSON.stringify(liveValue);
28
+ return (_jsx(Component, { ref: ref, ...props, "data-fimo-source": source, children: content }));
29
+ }
@@ -4,6 +4,7 @@ export { StaticImage } from './components/StaticImage.js';
4
4
  export { Video } from './components/Video.js';
5
5
  export { Date } from './components/Date.js';
6
6
  export { Boolean } from './components/Boolean.js';
7
+ export { Json } from './components/Json.js';
7
8
  export { RichText } from './components/RichText.js';
8
9
  export { useLabels, useTranslations } from './translations.js';
9
10
  export { FimoString, FimoDate, FimoMedia, FimoBoolean, FimoRichText, type FimoPolymorphicReference, type FimoReference, type FimoSource, type FimoPart, type FimoRichTextContent, type FimoRichTextNodeProps, type FimoRichTextMarkProps, type FimoRichTextComponents, FIMO_SOURCE, FIMO_PARTS, isFimoString, isFimoDate, hasFimoSource, getFimoSource, getFimoParts, fimo, useFimoLiveValue, } from './lib/index.js';
@@ -7,6 +7,7 @@ export { StaticImage } from './components/StaticImage.js';
7
7
  export { Video } from './components/Video.js';
8
8
  export { Date } from './components/Date.js';
9
9
  export { Boolean } from './components/Boolean.js';
10
+ export { Json } from './components/Json.js';
10
11
  export { RichText } from './components/RichText.js';
11
12
  // Labels
12
13
  export { useLabels, useTranslations } from './translations.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fimo",
3
- "version": "0.10.1-staging.4",
3
+ "version": "0.11.0-staging.2",
4
4
  "description": "Fimo CLI - create, deploy, and manage Fimo projects",
5
5
  "bin": {
6
6
  "fimo": "dist/cli/index.js"
@@ -68,7 +68,7 @@
68
68
  "eta": "^3.5.0",
69
69
  "oxc-parser": "^0.118.0",
70
70
  "react-error-boundary": "^6.0.0",
71
- "zod": "4.1.8"
71
+ "zod": "4.4.3"
72
72
  },
73
73
  "devDependencies": {
74
74
  "@clack/core": "^1.2.0",
@@ -82,7 +82,7 @@
82
82
  "@types/react": "^19.2.14",
83
83
  "@types/react-dom": "^19.2.3",
84
84
  "agent-install": "^0.0.5",
85
- "better-auth": "1.6.23",
85
+ "better-auth": "1.6.25",
86
86
  "cheerio": "^1.0.0",
87
87
  "commander": "^13.1.0",
88
88
  "esbuild": "^0.25.10",
@@ -94,7 +94,7 @@
94
94
  "typescript": "5.9.2",
95
95
  "vite": "^8.0.13",
96
96
  "vitest": "^4.1.6",
97
- "wrangler": "^4.83.0",
97
+ "wrangler": "4.114.0",
98
98
  "yaml": "2.8.2"
99
99
  },
100
100
  "peerDependencies": {
@@ -12,7 +12,7 @@
12
12
  },
13
13
  "dependencies": {
14
14
  "@base-ui/react": "^1.3.0",
15
- "@hookform/resolvers": "^5.2.2",
15
+ "@hookform/resolvers": "^5.5.7",
16
16
  "@react-router/node": "^8.2.0",
17
17
  "@tailwindcss/typography": "^0.5.19",
18
18
  "@tanstack/react-query": "^5.80.6",
@@ -21,25 +21,25 @@
21
21
  "cmdk": "^1.1.1",
22
22
  "date-fns": "^4.1.0",
23
23
  "embla-carousel-react": "^8.6.0",
24
- "fimo": "0.10.1-staging.4",
24
+ "fimo": "0.11.0-staging.2",
25
25
  "input-otp": "^1.4.2",
26
26
  "isbot": "^5",
27
27
  "lucide-react": "^0.577.0",
28
- "motion": "^12.36.0",
28
+ "motion": "^12.42.2",
29
29
  "next-themes": "^0.4.6",
30
- "radix-ui": "^1.4.3",
30
+ "radix-ui": "^1.6.7",
31
31
  "react": "^19.2.7",
32
32
  "react-day-picker": "^9.14.0",
33
33
  "react-dom": "^19.2.7",
34
34
  "react-error-boundary": "^6.0.0",
35
- "react-hook-form": "^7.72.1",
35
+ "react-hook-form": "^7.83.0",
36
36
  "react-resizable-panels": "^4.9.0",
37
37
  "react-router": "^8.2.0",
38
38
  "recharts": "^3.8.0",
39
39
  "sonner": "^2.0.7",
40
40
  "tailwind-merge": "^3.2.0",
41
41
  "vaul": "^1.1.2",
42
- "zod": "^4.3.6"
42
+ "zod": "^4.4.3"
43
43
  },
44
44
  "devDependencies": {
45
45
  "@react-router/dev": "^8.2.0",