@amplifyup/sdk 0.1.63 → 0.1.64

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/README.md CHANGED
@@ -97,12 +97,27 @@ fields.hero.image
97
97
 
98
98
  fields.subheading // empty on a new page
99
99
  // → { value: null, name: 'subheading' }
100
+
101
+ fields.posts.value[0].title // a row inside a list
102
+ // → { value: 'Ship faster', name: 'title', ref: { providerId, entity, id } }
100
103
  ```
101
104
 
102
105
  `name` is the field's dot path. It is what lets `<Field>` know which field it is bound to without you telling it. `value` is the content, typed from your schema.
103
106
 
104
107
  **The shape is identical on the live site, in a draft, and in Composer.** There is no mode where you get a bare string instead of an envelope, and no mode where an empty field is `undefined` instead of `{ value: null, name }`.
105
108
 
109
+ ### Write targets
110
+
111
+ One rule explains everything about editing:
112
+
113
+ > **A field is editable if and only if it carries a write target.** The write target is set by whoever produced the data. Nothing downstream guesses.
114
+
115
+ - A **page field** (`fields.heading`) has no `ref`. Its write target is the page, and Composer already knows which page you are on.
116
+ - A **list row field** (`post.title`) carries `ref: { providerId, entity, id }` — the record it belongs to. Editing it saves to that record, not to the page.
117
+ - A row field the SDK could not trace back to a producer gets no `ref` and is marked `readOnly: true`. It renders as plain text in Composer: no outline, no click target.
118
+
119
+ You never set `ref` yourself. You never read it either. It is there so that the same `<Field>` you use for a page heading also works on a row in a list.
120
+
106
121
  ### Two ways to read a field
107
122
 
108
123
  ```tsx
@@ -130,8 +145,8 @@ export function Card({ fields }: { fields: Fields<{ title: string; link: string;
130
145
 
131
146
  | Component | Field type | Renders |
132
147
  | --- | --- | --- |
133
- | `<Field>` | `string`, `number` | text node — no wrapper element |
134
- | `<RichText>` | markdown / rich text | rendered HTML |
148
+ | `<Field>` | `string`, `number` | a `<span>` with the text |
149
+ | `<RichText>` | markdown / rich text | rendered HTML in a `<div>` (change with `as`) |
135
150
  | `<Image>` | `ImageValue` | `<img>` |
136
151
 
137
152
  All three take a `field` prop and read `name` from it. Pass any extra props (`className`, `loading`, etc.) and they go on the rendered element.
@@ -178,7 +193,7 @@ Why no `fallback` prop: a fallback turns "this field was never populated" into "
178
193
 
179
194
  ### Wrapper elements
180
195
 
181
- `<Field>` renders a text node, not an element, so you supply the tag:
196
+ `<Field>` renders an inline `<span>`, never a heading or paragraph, so you supply the semantic tag around it:
182
197
 
183
198
  ```tsx
184
199
  // ✓
@@ -189,6 +204,19 @@ Why no `fallback` prop: a fallback turns "this field was never populated" into "
189
204
  <Field as="h1" field={fields.heading} />
190
205
  ```
191
206
 
207
+ `<RichText>` is the exception: it owns a block element, and `as` picks which one.
208
+
209
+ ```tsx
210
+ // ✓ — default is <div>
211
+ <RichText field={fields.body} className="prose" />
212
+
213
+ // ✓ — pick a different block element
214
+ <RichText field={fields.body} as="article" className="prose" />
215
+
216
+ // ✗ — don't nest a block element inside a paragraph
217
+ <p><RichText field={fields.body} /></p>
218
+ ```
219
+
192
220
  If the wrapper should disappear when the field is empty, check `.value`:
193
221
 
194
222
  ```tsx
@@ -273,16 +301,18 @@ export function Announcement({ fields }: { fields: Fields<{ message: string }> }
273
301
  export function Carousel({ fields }) {
274
302
  const inComposer = useInComposer();
275
303
  return (
276
- <Collection field={fields.slides}>
277
- {(slide) => <Slide slide={slide} autoplay={!inComposer} />}
278
- </Collection>
304
+ <>
305
+ {(fields.slides.value ?? []).map((slide) => (
306
+ <Slide key={slide.id} slide={slide} autoplay={!inComposer} />
307
+ ))}
308
+ </>
279
309
  );
280
310
  }
281
311
  ```
282
312
 
283
313
  Rules of thumb:
284
314
 
285
- - The **field component is the source of truth in Composer.** If a field is visible to authors, it must be rendered through `<Field>` / `<RichText>` / `<Image>` / `<Collection>` in Composer mode, even if the live site reads `.value`.
315
+ - The **field component is the source of truth in Composer.** If a field is visible to authors, it must be rendered through `<Field>` / `<RichText>` / `<Image>` in Composer mode, even if the live site reads `.value`.
286
316
  - **Don't fork the whole component.** Branch the one element that differs, not the entire return. Two divergent trees drift and the preview stops matching production.
287
317
  - **Empty gating uses `inComposer`.** `if (!x.value) return null` is correct for live and wrong for authors — they can't click what isn't rendered. Add `&& !inComposer`.
288
318
  - **Don't use it to hide editable text on live.** If the text is visible on the live site, render it with the field component in both modes. `useInComposer` is for content the live markup genuinely can't expose as a field.
@@ -299,20 +329,13 @@ Rules of thumb:
299
329
 
300
330
  ## Lists
301
331
 
302
- There are two kinds of list field and they are handled differently. Check your schema to know which you have.
303
-
304
- | Shape | Schema | What it holds | In your component | Edited in Composer via |
305
- | --- | --- | --- | --- | --- |
306
- | **Reference list** | `array` + `entity` `of` | Linked entries — a query or a curated set of documents | Map `fields.posts.value` — plain rows | Props panel (pick, reorder) |
307
- | **Repeater** | `array` + `of: 'json'` | Inline-authored items (slides, cards, FAQs) | `<Collection>` + `<Field>` on each item | Canvas (add, remove, reorder, click to edit) |
332
+ Every object array is a list of records. Each row is a fields object plus a plain `id`. Edit `post.title` in the grid and you are editing the post. Which records are in the list, and their order, is edited in the props panel — not on the canvas.
308
333
 
309
- Scalar lists (tags, multi-select) are plain `.value` arrays edited in the props panel — same as reference lists.
310
-
311
- ### Reference lists
312
-
313
- Rows are plain objects, not envelopes. Read them directly:
334
+ Scalar lists (tags, multi-select) stay plain: `fields.tags` is `Field<string[]>`.
314
335
 
315
336
  ```tsx
337
+ import { Field, Image, RichText } from '@amplifyup/sdk/react';
338
+
316
339
  export function LatestPosts({ fields }: { fields: Fields<{ heading: string; posts: Post[] }> }) {
317
340
  const posts = fields.posts.value ?? [];
318
341
  return (
@@ -321,8 +344,10 @@ export function LatestPosts({ fields }: { fields: Fields<{ heading: string; post
321
344
  <ul>
322
345
  {posts.map((post) => (
323
346
  <li key={post.id}>
324
- <img src={post.mainImage?.url} alt={post.mainImage?.alt ?? ''} />
325
- <a href={`/blog/${post.slug}`}>{post.title}</a>
347
+ <Image field={post.mainImage} />
348
+ <a href={`/blog/${post.slug}`}>
349
+ <Field field={post.title} />
350
+ </a>
326
351
  </li>
327
352
  ))}
328
353
  </ul>
@@ -332,75 +357,60 @@ export function LatestPosts({ fields }: { fields: Fields<{ heading: string; post
332
357
  ```
333
358
 
334
359
  ```tsx
335
- // ✗ — reference rows aren't editable inline; there's nothing for Field to bind to
336
- {posts.map((post) => <Field field={post.title} />)}
360
+ // ✗ — Field expects a scalar, not the list or the row
361
+ <Field field={fields.posts} />
362
+ <Field field={post} />
337
363
 
338
- // ✗ — Collection is for repeaters
339
- <Collection field={fields.posts}>…</Collection>
364
+ // ✓ — edit the record through its fields
365
+ <Field field={post.title} />
366
+ <Image field={post.mainImage} />
340
367
  ```
341
368
 
342
- The list itself (which posts, in what order) is edited in the props panel. When the component is selected on the canvas, the component outline is the affordance — you don't need to add one.
343
-
344
- ### Repeaters
345
-
346
- Items **are** enveloped. `fields.slides.value[0].title` is `{ value, name: 'slides.0.title' }`. Render through `<Collection>`:
369
+ Search or load-more rows from `queryContent` are the same shape. Render them with the same components — one renderer for both:
347
370
 
348
371
  ```tsx
349
- import { Collection, Field, Image } from '@amplifyup/sdk/react';
350
-
351
- export function Slideshow({
352
- fields,
353
- }: {
354
- fields: Fields<{ slides: Array<{ title: string; caption: string; image: ImageValue }> }>;
355
- }) {
356
- return (
357
- <Collection field={fields.slides}>
358
- {(slide, i) => (
359
- <figure className="slide" data-index={i}>
360
- <Image field={slide.image} />
361
- <figcaption>
362
- <h2><Field field={slide.title} /></h2>
363
- <Field field={slide.caption} className="text-sm" />
364
- </figcaption>
365
- </figure>
366
- )}
367
- </Collection>
368
- );
369
- }
372
+ const posts = hits ?? fields.posts.value ?? [];
373
+ return posts.map((post) => (
374
+ <article key={post.id}>
375
+ <h2><Field field={post.title} /></h2>
376
+ </article>
377
+ ));
370
378
  ```
371
379
 
372
- On the live site `Collection` renders your children and nothing else — no wrapper element. In Composer it adds an empty-state placeholder and add / remove / reorder controls.
380
+ ### When a row is editable
373
381
 
374
- Need a wrapping `<ul>`? Put it outside:
382
+ A row is editable when the SDK knows which record it came from. That happens automatically in two cases:
375
383
 
376
- ```tsx
377
- <ul className="faq">
378
- <Collection field={fields.questions}>
379
- {(q) => (
380
- <li>
381
- <h3><Field field={q.question} /></h3>
382
- <RichText field={q.answer} />
383
- </li>
384
- )}
385
- </Collection>
386
- </ul>
387
- ```
384
+ | Rows came from | Editable in Composer? |
385
+ | --- | --- |
386
+ | A list prop on your component (curated or query connection) | Yes |
387
+ | `queryContent` using the published spec from `{field}Pagination.spec` | Yes |
388
+ | `queryContent` with a spec you assembled by hand, missing `providerId` or `entity` | No — renders as plain text |
389
+ | Rows you fetched yourself from your CMS and passed in as props | No — renders as plain text |
388
390
 
389
- ```tsx
390
- // ✗ — mapping .value skips the canvas chrome; items won't be addable in Composer
391
- {fields.slides.value.map((slide) => <Field field={slide.title} />)}
392
- ```
391
+ Read-only rows still render their text on the live site and in Composer. They just aren't clickable, and in development the SDK logs once per field:
393
392
 
394
- ### Mixing a list with a runtime query
393
+ ```
394
+ [AmplifyUp SDK] title has no write target; rendered read-only.
395
+ ```
395
396
 
396
- Search or "load more" results come from `queryContent`, which returns plain rows. Your reference list is also plain rows, so they share a renderer:
397
+ If you see that and expected an editable field, the fix is upstream — use the published spec instead of a hand-built one, or bind the list to a connection in Composer. Do not try to add `ref` yourself.
397
398
 
398
399
  ```tsx
399
- const posts = hits ?? fields.posts.value ?? [];
400
- return <PostGrid posts={posts} />;
401
- ```
400
+ // ✓ — spec comes from the list prop Composer published; rows stay editable
401
+ const hits = await queryContent({
402
+ trackingId,
403
+ route: '/insights',
404
+ spec: searchSpec(postsPagination, 'title', term),
405
+ });
402
406
 
403
- No SDK component is involved on either path.
407
+ // ✗ — hand-built spec with no connection behind it; rows render read-only
408
+ const hits = await queryContent({
409
+ trackingId,
410
+ route: '/insights',
411
+ spec: { providerId: '', entity: '', filter: [], sort: [], limit: 10, offset: 0 },
412
+ });
413
+ ```
404
414
 
405
415
  ---
406
416
 
@@ -409,19 +419,29 @@ No SDK component is involved on either path.
409
419
  A slot is a region where authors drop other components. Render it with `<Slot>`:
410
420
 
411
421
  ```tsx
412
- import { Field, Slot } from '@amplifyup/sdk/react';
422
+ import { Slot } from '@amplifyup/sdk/react';
413
423
 
414
- export function TwoColumn({ fields, slots }) {
424
+ export function TwoColumn() {
415
425
  return (
416
426
  <div className="grid grid-cols-2">
417
- <div><Slot name="left" slots={slots} /></div>
418
- <div><Slot name="right" slots={slots} /></div>
427
+ <div><Slot name="left" /></div>
428
+ <div><Slot name="right" /></div>
419
429
  </div>
420
430
  );
421
431
  }
422
432
  ```
423
433
 
424
- Slots hold components, not fields — nothing in a slot is read through `fields`.
434
+ `<Slot>` takes only `name` and `className`. It reads the slot content from `ComponentContextProvider`, so you never thread a `slots` prop through your component.
435
+
436
+ ```tsx
437
+ // ✗ — Slot has no `slots` prop
438
+ <Slot name="left" slots={slots} />
439
+
440
+ // ✓
441
+ <Slot name="left" />
442
+ ```
443
+
444
+ Slot names must match the slots declared on the component in AmplifyUp. Slots hold components, not fields — nothing in a slot is read through `fields`.
425
445
 
426
446
  ---
427
447
 
@@ -434,24 +454,47 @@ Slots hold components, not fields — nothing in a slot is read through `fields`
434
454
  | `{fields.x.value ?? 'default'}` in your code | expect the SDK to supply default copy |
435
455
  | `if (!fields.x.value) return null` to hide a component | wrap `Field` in conditional logic that hides the field in Composer |
436
456
  | `<h1><Field … /></h1>` | look for an `as` / `wrapper` prop |
437
- | `fields.posts.value.map(…)` for reference lists | `<Field>` or `<Collection>` on reference rows |
438
- | `<Collection>` for repeaters | `fields.slides.value.map(…)` for repeaters |
457
+ | `<Field field={post.title} />` in a list | `<Field field={fields.posts} />` or `<Field field={post} />` |
439
458
  | `value` + `name` together for computed output | `value` alone, or `name` alone |
440
459
  | `<Image field={fields.cover} />` | `<img src={fields.cover.value.url} />` when it should be editable |
441
- | `queryContent` rows as plain data | treat query rows as `fields` |
460
+ | `queryContent` rows through `<Field field={post.title} />` | treat query rows as raw CMS objects |
461
+ | `spec: searchSpec(postsPagination, …)` from the published list prop | hand-build a `spec` and expect rows to stay editable |
462
+ | `<Slot name="left" />` | `<Slot name="left" slots={slots} />` |
463
+ | let the producer set `ref` | set, copy, or patch `ref` yourself |
442
464
  | `useInComposer()` to expose a field the live markup can't (CSS background, attribute, hidden panel) | fork the whole component's return on `inComposer` |
443
465
  | `if (!x.value && !inComposer) return null` | `if (!x.value) return null` — authors can't click what isn't rendered |
444
466
 
445
467
  ---
446
468
 
469
+ ## Console messages
470
+
471
+ In development the SDK tells you exactly what is wrong. Production is silent.
472
+
473
+ | Message | What it means | Fix |
474
+ | --- | --- | --- |
475
+ | `Field expects a scalar field. Did you mean <Field field={post.title} />?` | You passed a list or a whole row to `Field` / `RichText` / `Image` | Pass one scalar field off the row |
476
+ | `title has no write target; rendered read-only.` | A row field the SDK can't trace to a record | Bind the list to a connection, or query with the published spec |
477
+ | `Field value={…} requires name="…" for computed values` | You passed `value` without `name` | Add `name={fields.x.name}` |
478
+ | `Field needs field={fields.yourProp} (or value + name for computed values)` | No binding at all | Pass `field=` |
479
+ | `Slot "left" used outside ComponentContextProvider` | The component wasn't wrapped when rendered | Wrap it in `renderComponent` |
480
+
481
+ ---
482
+
447
483
  ## Types
448
484
 
449
485
  ```ts
450
486
  import type { Field, Fields, ImageValue } from '@amplifyup/sdk/react';
451
487
 
452
- type Field<T> = { value: T | null; name: string };
488
+ type Field<T> = {
489
+ value: T;
490
+ name: string;
491
+ /** Write target — the record this field saves to. Page fields omit it. */
492
+ ref?: { providerId: string; entity: string; id: string };
493
+ /** Row field with no write target — display only in Composer. */
494
+ readOnly?: true;
495
+ };
453
496
 
454
- type Fields<T> = { [K in keyof T]: /* Field<T[K]>, recursing into objects and repeater arrays */ };
497
+ type Fields<T> = { [K in keyof T]: /* Field<T[K]>, recursing into objects and lists */ };
455
498
  ```
456
499
 
457
500
  Declare the schema shape once on the component and let inference do the rest:
@@ -462,14 +505,14 @@ type HeroFields = Fields<{
462
505
  heading: string;
463
506
  body: string; // markdown → use <RichText>
464
507
  image: ImageValue; // → use <Image>
465
- ctas: Array<{ label: string; href: string }>; // repeater
466
- related: Post[]; // reference list — rows are plain Post
508
+ posts: Post[]; // list — each row is Fields<Post> & { id: string }
509
+ tags: string[]; // scalar list — Field<string[]>
467
510
  }>;
468
511
 
469
512
  export function Hero({ fields }: { fields: HeroFields }) { … }
470
513
  ```
471
514
 
472
- `fields.heading.value` is `string | null`. `fields.image.value` is `ImageValue | null`. `fields.ctas.value[0].label` is `Field<string>`. `fields.related.value[0]` is `Post`.
515
+ `fields.heading.value` is `string`. `fields.image.value` is `ImageValue | null`. `fields.posts.value[0]` is `Fields<Post> & { id: string }`. `fields.posts.value[0].title` is `Field<string>` with `ref` pointing at that post.
473
516
 
474
517
  ---
475
518
 
@@ -532,7 +575,9 @@ const hits = await queryContent({
532
575
  });
533
576
  ```
534
577
 
535
- Only entities published with that route can be queried, and visitors only see published content. Results are plain rows — not `fields` envelopes, not canvas-editable. Also available from `@amplifyup/sdk/server`. See the [runtime queries guide](../../docs/sdk-site-setup.md#8-runtime-content-queries).
578
+ Only entities published with that route can be queried, and visitors only see published content. Also available from `@amplifyup/sdk/server`. See the [runtime queries guide](../../docs/sdk-site-setup.md#8-runtime-content-queries).
579
+
580
+ **Always start from `postsPagination.spec`.** `nextPageSpec` and `searchSpec` do that for you. The spec identifies the connection, and that is what makes the returned rows editable in Composer — see [when a row is editable](#when-a-row-is-editable). A spec you assemble by hand has no connection behind it, so its rows come back read-only.
536
581
 
537
582
  ## Track events
538
583
 
@@ -1,4 +1,40 @@
1
- import 'react';
2
- import 'react/jsx-runtime';
3
- export { A as AmplifyRenderer } from './AmplifyRenderer-TawbUIKZ.mjs';
4
- import './types-Db_eLrfU.mjs';
1
+ import * as react from 'react';
2
+ import { ReactNode } from 'react';
3
+ import * as react_jsx_runtime from 'react/jsx-runtime';
4
+ import { L as LayoutComponentProps, P as PageConfig } from './types-C32MgOiv.mjs';
5
+
6
+ interface AmplifyRendererProps {
7
+ /**
8
+ * Component renderer function that maps componentId to React component
9
+ * This is site-specific and must be provided by the developer
10
+ */
11
+ renderComponent: (componentId: string, props: LayoutComponentProps, slots?: Record<string, ReactNode>, context?: {
12
+ layoutNodeId: string;
13
+ }) => ReactNode;
14
+ /**
15
+ * Optional page config for server-side rendering (SSR/SSG)
16
+ * If provided, the component will use this instead of fetching client-side
17
+ */
18
+ pageConfig?: PageConfig;
19
+ /**
20
+ * Optional loading component
21
+ */
22
+ loadingComponent?: ReactNode;
23
+ /**
24
+ * Optional error component
25
+ */
26
+ errorComponent?: (error: string) => ReactNode;
27
+ /**
28
+ * Optional empty state component (when no content is configured)
29
+ */
30
+ emptyComponent?: ReactNode;
31
+ /**
32
+ * Force Composer page-slot wrapper (avoids SSR miss when window isn't available yet).
33
+ * When true, root components stay under [data-slot-source="page"] so hierarchy can climb to Page.
34
+ */
35
+ composerPreview?: boolean;
36
+ }
37
+ declare function AmplifyRendererInner({ renderComponent, pageConfig: serverPageConfig, loadingComponent, errorComponent, emptyComponent, composerPreview, }: AmplifyRendererProps): react_jsx_runtime.JSX.Element | null;
38
+ declare const AmplifyRenderer: react.MemoExoticComponent<typeof AmplifyRendererInner>;
39
+
40
+ export { AmplifyRenderer };
@@ -1,4 +1,40 @@
1
- import 'react';
2
- import 'react/jsx-runtime';
3
- export { A as AmplifyRenderer } from './AmplifyRenderer-Bwob7ICJ.js';
4
- import './types-Db_eLrfU.js';
1
+ import * as react from 'react';
2
+ import { ReactNode } from 'react';
3
+ import * as react_jsx_runtime from 'react/jsx-runtime';
4
+ import { L as LayoutComponentProps, P as PageConfig } from './types-C32MgOiv.js';
5
+
6
+ interface AmplifyRendererProps {
7
+ /**
8
+ * Component renderer function that maps componentId to React component
9
+ * This is site-specific and must be provided by the developer
10
+ */
11
+ renderComponent: (componentId: string, props: LayoutComponentProps, slots?: Record<string, ReactNode>, context?: {
12
+ layoutNodeId: string;
13
+ }) => ReactNode;
14
+ /**
15
+ * Optional page config for server-side rendering (SSR/SSG)
16
+ * If provided, the component will use this instead of fetching client-side
17
+ */
18
+ pageConfig?: PageConfig;
19
+ /**
20
+ * Optional loading component
21
+ */
22
+ loadingComponent?: ReactNode;
23
+ /**
24
+ * Optional error component
25
+ */
26
+ errorComponent?: (error: string) => ReactNode;
27
+ /**
28
+ * Optional empty state component (when no content is configured)
29
+ */
30
+ emptyComponent?: ReactNode;
31
+ /**
32
+ * Force Composer page-slot wrapper (avoids SSR miss when window isn't available yet).
33
+ * When true, root components stay under [data-slot-source="page"] so hierarchy can climb to Page.
34
+ */
35
+ composerPreview?: boolean;
36
+ }
37
+ declare function AmplifyRendererInner({ renderComponent, pageConfig: serverPageConfig, loadingComponent, errorComponent, emptyComponent, composerPreview, }: AmplifyRendererProps): react_jsx_runtime.JSX.Element | null;
38
+ declare const AmplifyRenderer: react.MemoExoticComponent<typeof AmplifyRendererInner>;
39
+
40
+ export { AmplifyRenderer };