@elytracms/next 0.0.81 → 0.0.83

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/dist/index.d.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  import { ComponentType, ReactNode } from 'react';
2
2
  import { ComponentRegistry, ComponentIssue, ComponentManifest } from '@elytracms/core/component-registry';
3
3
  export { ComponentManifest, PropField, SlotSpec, defineComponent } from '@elytracms/core/component-registry';
4
- import { ComponentImplementations, ResolveBinding, RelationTarget } from '@elytracms/core/runtime-renderer';
4
+ import { ComponentImplementations, ResolveBinding, RelationTarget, DocumentBodyItem } from '@elytracms/core/runtime-renderer';
5
5
  export { ComponentImplementations, RenderAsset } from '@elytracms/core/runtime-renderer';
6
6
  import { ResolvePageResult, ResolvedAsset, Perspective, ContentClient, CacheTags, ContentDocumentSource, ResolvedDocument } from '@elytracms/core/content';
7
7
  export { Perspective } from '@elytracms/core/content';
8
- import { LocaleConfig, Locale, RouteRecord, RedirectRecord, CollectionDef } from '@elytracms/core/cms-core';
9
- export { CmsDocument, CollectionDef, FieldDef, Locale, LocaleConfig, RedirectRecord, RouteRecord, documentSchema, fallbackHomeRoutes, localeConfigSchema, mergeRouteRecords, redirectRecordSchema, routeRecordSchema } from '@elytracms/core/cms-core';
8
+ import { LocaleConfig, Locale, RouteRecord, CollectionDef, RedirectRecord } from '@elytracms/core/cms-core';
9
+ export { CmsDocument, CollectionDef, FieldDef, Locale, LocaleConfig, LocaleUrlPath, RedirectRecord, RouteRecord, documentSchema, fallbackHomeRoutes, localeConfigSchema, localeUrlPath, mergeRouteRecords, redirectRecordSchema, routeRecordSchema, splitLocalePath } from '@elytracms/core/cms-core';
10
10
  import { ComponentNode } from '@elytracms/core/project-graph';
11
11
  export { ComponentNode } from '@elytracms/core/project-graph';
12
12
  import { AssetRecord, PersistenceAdapter } from '@elytracms/core/persistence';
@@ -16,6 +16,7 @@ import { z } from 'zod';
16
16
  export { z } from 'zod';
17
17
  import { ImageLoader } from 'next/image';
18
18
  import { HostImageRenderer } from './host-image.js';
19
+ import { HostComponentEntry } from '@elytracms/core/studio-core';
19
20
 
20
21
  /**
21
22
  * One host-repo component: the manifest the builder edits against plus the
@@ -169,6 +170,29 @@ interface CanvasRendererProps {
169
170
  * issue (EC-290), never a silent `#`.
170
171
  */
171
172
  links?: Record<string, string>;
173
+ /**
174
+ * The resolved document's collection-declared delivery presentation (EC-499,
175
+ * from the render outcome): which field(s) hold the body. Absent → the EC-458
176
+ * shape picker selects the body by VALUE, exactly as before.
177
+ */
178
+ presentation?: DeliveryPresentation;
179
+ }
180
+ /**
181
+ * A collection's `presentation` declaration (EC-499), resolved for delivery: the
182
+ * body field list normalized from `string | string[]`. Plain data — it rides the
183
+ * (serializable) render outcome and the RSC boundary like the baked
184
+ * assets/relations/links do.
185
+ *
186
+ * EC-524: this renderer applies no CHROME. It carried a `wrapper` component id
187
+ * briefly (never released); a detail wrapper is a page template, templates are
188
+ * code, and the host now supplies one from its own route file via
189
+ * `createCanvasRoute`'s `wrap` option. What stays here is content selection —
190
+ * which is exactly what makes a canvas body and a rich-text body render
191
+ * agnostically through the one entry point.
192
+ */
193
+ interface DeliveryPresentation {
194
+ /** Field names whose values form the body, in render order. */
195
+ body?: readonly string[];
172
196
  }
173
197
  /** Default payloads: the route-bound document and the dynamic route params. */
174
198
  declare function defaultBindingPayloads(result: ResolvePageResult): BindingPayloads;
@@ -178,6 +202,15 @@ declare function defaultBindingPayloads(result: ResolvePageResult): BindingPaylo
178
202
  * the `ContentSource`).
179
203
  */
180
204
  declare function CanvasRenderer(props: CanvasRendererProps): Promise<ReactNode>;
205
+ /**
206
+ * The ordered body items of a resolved document (EC-499): the values of the
207
+ * collection's declared `presentation.body` fields concatenated in declaration
208
+ * order, or — when the collection declares nothing — the EC-458 value-shape
209
+ * pick, unchanged. A declared field the document has no value for contributes
210
+ * nothing (an empty body is legitimate); a declared field holding something that
211
+ * is neither composition nor rich text contributes a visible fallback.
212
+ */
213
+ declare function bodyItemsOf(document: Record<string, unknown> | null | undefined, presentation?: DeliveryPresentation): DocumentBodyItem[];
181
214
 
182
215
  /**
183
216
  * The content seam of the embedded runtime (EC-144, vision AD-1/AD-4): the
@@ -224,6 +257,16 @@ interface ContentSource {
224
257
  * explicit routes.
225
258
  */
226
259
  readonly routes?: readonly RouteRecord[];
260
+ /**
261
+ * The project's collection definitions (EC-499, additive) — config-owned
262
+ * (AD-11), already prefetched by every source that reads a schema. The route
263
+ * helper reads the resolved document's collection from here to honour its
264
+ * declared `presentation` (which field(s) hold the delivery body, and the
265
+ * optional detail wrapper). Optional so existing custom sources stay valid;
266
+ * without it every collection is treated as undeclared and the EC-458 shape
267
+ * picker decides, exactly as before.
268
+ */
269
+ readonly collections?: readonly CollectionDef[];
227
270
  /**
228
271
  * Data-source definitions the graph's bindings reference (EC-166 delivery,
229
272
  * additive). When present, the route helper materializes per-section list
@@ -306,13 +349,36 @@ interface MaterializedSourcePayloads {
306
349
  */
307
350
  declare function materializeSourcePayloads(input: MaterializeSourcePayloadsInput): MaterializedSourcePayloads;
308
351
 
352
+ /**
353
+ * Bridge from a `PersistenceAdapter` to the synchronous `ContentLookup` the
354
+ * accessors (EC-143) read through: prefetch the project's collections,
355
+ * documents, assets, and graphs ONCE (async), then serve every accessor call
356
+ * synchronously from memory. This is how `apps/example-site` reads the seeded
357
+ * in-memory fixture project; a Convex-backed `ContentSource` (EC-145/146)
358
+ * replaces this bridge without touching the route helper.
359
+ *
360
+ * The assembly half is exposed separately as {@link createStaticContentSource}
361
+ * (EC-156): a host that already holds the prefetched data as plain values —
362
+ * e.g. a delivery-snapshot payload fetched over Convex's public HTTP query
363
+ * API — assembles the exact same `ContentSource` without faking an adapter.
364
+ */
309
365
  interface ContentSnapshotOptions {
310
366
  /** Route records served by `resolvePage` (EC-019). */
311
367
  routes?: readonly RouteRecord[];
312
368
  /** Redirect records served by `resolvePage` (EC-019/020). */
313
369
  redirects?: readonly RedirectRecord[];
314
- /** Locale configuration; defaults to English-only. */
315
- locales?: LocaleConfig;
370
+ /**
371
+ * Locale configuration — **required**, never defaulted (EC-530). The default
372
+ * locale is the storage key every localized value is written under, so a
373
+ * fabricated `en` is a data migration to undo, not a config edit: content
374
+ * authored under one locale and served under another, silently (EC-512
375
+ * measured exactly that). EC-490 made a config declaring no locales an error
376
+ * and EC-519 collapsed the two declarations into one; this was the last place
377
+ * in the chain still inventing the value, and it sits in the package that
378
+ * resolves the URLs. Hand it the project's `LocaleConfig` — the one the
379
+ * config declares.
380
+ */
381
+ locales: LocaleConfig;
316
382
  /**
317
383
  * Data-source definitions the graph's bindings reference (EC-166): section
318
384
  * sources (`cms.section.<nodeId>`) carry their stored where/sort/limit, and
@@ -332,8 +398,8 @@ interface ContentSnapshot extends ContentSource {
332
398
  */
333
399
  interface StaticContentData {
334
400
  projectId: string;
335
- /** Locale configuration; defaults to English-only. */
336
- locales?: LocaleConfig;
401
+ /** Locale configuration — **required**, never defaulted (see EC-530 above). */
402
+ locales: LocaleConfig;
337
403
  /** Route records served by `resolvePage` (EC-019). */
338
404
  routes?: readonly RouteRecord[];
339
405
  /** Redirect records served by `resolvePage` (EC-019/020). */
@@ -374,53 +440,7 @@ declare function createStaticContentSource(data: StaticContentData): ContentSnap
374
440
  * request (or memoize across requests for fixture-backed sites) — accessors
375
441
  * never reach back to the adapter.
376
442
  */
377
- declare function createContentSnapshot(adapter: PersistenceAdapter, projectId: string, options?: ContentSnapshotOptions): Promise<ContentSnapshot>;
378
-
379
- /**
380
- * The locale ⇄ URL mapping, owned in ONE place (EC-486).
381
- *
382
- * The delivery host reads a request URL and derives `{ locale, path }`; the
383
- * sitemap goes the other way, turning an enumerated `{ locale, path }` back
384
- * into the URL a crawler will request. Those are inverse functions, and while
385
- * they lived in two files they disagreed: a page whose top-level slug happens
386
- * to equal a **non-default locale code** (`/de` in a project whose locales are
387
- * `['en','de']`, default `en`) was published to `sitemap.xml` as
388
- * `https://site/de`, which the host then split as *locale `de`, path `/`* and
389
- * 404'd. Measured — see EC-486.
390
- *
391
- * Keeping both directions here makes the round-trip checkable, which is what
392
- * {@link localeUrlPath} reports: some `{ locale, path }` pairs simply have **no
393
- * URL that addresses them**, and a caller that publishes URLs must be able to
394
- * tell rather than emit a link nothing serves.
395
- */
396
- /**
397
- * Derive the request locale from a leading URL segment (`/de/about` → locale
398
- * `de`, path `/about`) and return the remaining path. No prefix (or the
399
- * default locale's own content at root) falls back to the default locale.
400
- */
401
- declare function splitLocalePath(url: string, locales: LocaleConfig): {
402
- locale: Locale;
403
- path: string;
404
- };
405
- interface LocaleUrlPath {
406
- /** The request path a client uses to reach `path` in `locale`. */
407
- urlPath: string;
408
- /**
409
- * Whether {@link splitLocalePath} recovers exactly the `{ locale, path }`
410
- * this was built from. `false` means the pair is **unaddressable**: every URL
411
- * that could denote it is read by the host as a different locale/path pair,
412
- * so the content is unreachable and the URL must not be published.
413
- */
414
- addressable: boolean;
415
- }
416
- /**
417
- * The inverse of {@link splitLocalePath}: default locale serves the root,
418
- * other locales get a `/<locale>` prefix — then the result is round-tripped
419
- * back through `splitLocalePath` so an unaddressable pair is reported rather
420
- * than silently emitted (AGENTS.md §4: never a fallback that hides a broken
421
- * state).
422
- */
423
- declare function localeUrlPath(path: string, locale: Locale, locales: LocaleConfig): LocaleUrlPath;
443
+ declare function createContentSnapshot(adapter: PersistenceAdapter, projectId: string, options: ContentSnapshotOptions): Promise<ContentSnapshot>;
424
444
 
425
445
  /**
426
446
  * The pure half of the catch-all route helper (EC-144): URL → outcome, with
@@ -429,6 +449,43 @@ declare function localeUrlPath(path: string, locale: Locale, locales: LocaleConf
429
449
  * each outcome onto `redirect()` / `permanentRedirect()` / `notFound()` /
430
450
  * `<CanvasRenderer />`.
431
451
  */
452
+ /** What the route helper tells a host callback about the request it is serving. */
453
+ interface CanvasRequestInfo {
454
+ /** The raw request path (including any locale prefix). */
455
+ url: string;
456
+ perspective: Perspective;
457
+ }
458
+ /**
459
+ * What a host's `wrap` function is told about the request it is wrapping
460
+ * (EC-524 — see `CanvasRouteOptions.wrap`).
461
+ *
462
+ * Every field is something the route already had in hand at the wrap point:
463
+ * nothing is gathered for this seam, and nothing was invented for it.
464
+ */
465
+ interface CanvasWrapContext extends CanvasRequestInfo {
466
+ /**
467
+ * The collection of the document this URL resolved to — **the branch key**.
468
+ * `undefined` when the resolution carried no document, so a host branching on
469
+ * a collection id naturally wraps nothing there (which is what the removed
470
+ * `presentation.wrapper` declaration did too: it only applied with a document).
471
+ */
472
+ collection?: string;
473
+ /**
474
+ * The resolved document. Verbatim the `document` prop the removed wrapper
475
+ * component received, so a wrapper ported to this seam needs no other source
476
+ * for its field-level chrome (the title, a byline, a date).
477
+ */
478
+ document: ResolvedDocument | null;
479
+ /**
480
+ * The whole resolution — the same value the `metadata` callback already
481
+ * receives, so this is the established host-callback argument on this route
482
+ * rather than a new surface. It carries `dynamicParams`, `route`,
483
+ * `localeFallback` and `issues` for a wrapper that needs more than the
484
+ * document (EC-499 recorded wanting exactly that and could not have it across
485
+ * a serializable declaration).
486
+ */
487
+ result: ResolvePageResult;
488
+ }
432
489
  /** What the host's payload factory sees for one request. */
433
490
  interface CanvasPayloadContext {
434
491
  /** Accessor client bound to this request's perspective + locale. */
@@ -500,6 +557,15 @@ type CanvasPageOutcome = {
500
557
  * embedding page's href.
501
558
  */
502
559
  links: Record<string, string>;
560
+ /**
561
+ * The resolved document's collection-declared delivery presentation
562
+ * (EC-499): which field(s) are the render body. Plain, serializable data —
563
+ * handed to `<CanvasRenderer />` so ONE catch-all renders every
564
+ * collection's body, whatever its shape. Absent when the collection
565
+ * declares nothing (or the source exposes no collections), and the EC-458
566
+ * value-shape picker then decides exactly as before.
567
+ */
568
+ presentation?: DeliveryPresentation;
503
569
  tags: CacheTags;
504
570
  };
505
571
  /** Build the request path from a Next catch-all `slug` param. */
@@ -525,28 +591,6 @@ interface ResolveCanvasPageOptions {
525
591
  */
526
592
  declare function resolveCanvasPage(source: ContentSource, url: string, perspective: Perspective, options?: ResolveCanvasPageOptions): Promise<CanvasPageOutcome>;
527
593
 
528
- /**
529
- * Catch-all route helper (EC-144): the factory a host app calls once in
530
- * `app/[[...slug]]/page.tsx` to wire `resolvePage` → `<CanvasRenderer />`,
531
- * with redirects answered by Next's `redirect()`/`permanentRedirect()` (per
532
- * the route's `permanent` flag), unknown URLs answered by `notFound()` (the
533
- * host's 404 renders), and the perspective chosen by Next `draftMode()` —
534
- * draft when enabled (via the token-gated preview route), published
535
- * otherwise.
536
- *
537
- * With `cache` configured (EC-146), the published-perspective resolve+render
538
- * data work runs inside `unstable_cache`, keyed by URL and tagged with the
539
- * fetch-time tag set of what the response actually contained (see
540
- * `cache.ts`). Publishing in the studio then invalidates exactly the affected
541
- * entries via the signed `/api/revalidate` webhook — and while the content
542
- * backend is unreachable, cached entries keep serving because a cache hit
543
- * never invokes `loadContent`.
544
- */
545
- interface CanvasRequestInfo {
546
- /** The raw request path (including any locale prefix). */
547
- url: string;
548
- perspective: Perspective;
549
- }
550
594
  interface CanvasCacheOptions {
551
595
  /**
552
596
  * Project scope prefix for every tag (`p:<scope>:…`) — must be the project
@@ -600,6 +644,38 @@ interface CanvasRouteOptions {
600
644
  onTags?: (tags: CacheTags, info: CanvasRequestInfo) => void;
601
645
  /** Override the generated `<head>` metadata for a resolved page. */
602
646
  metadata?: (result: ResolvePageResult, info: CanvasRequestInfo) => Metadata;
647
+ /**
648
+ * Wrap the rendered page in the host's own chrome (EC-524) — a detail
649
+ * template, a per-collection layout, a draft ribbon. Receives the rendered
650
+ * canvas as `children` and returns whatever should stand in its place:
651
+ *
652
+ * ```tsx
653
+ * elytra.canvasRoute({
654
+ * wrap: (children, ctx) =>
655
+ * ctx.collection === 'post' ? <PostDetail document={ctx.document}>{children}</PostDetail> : children,
656
+ * })
657
+ * ```
658
+ *
659
+ * **This is deliberately host code, not config** (owner ruling 2026-08-01):
660
+ * *"this sounds very much like page-templates … templates belong in code, so
661
+ * the wrapper is something the developer has to care about … the page-content
662
+ * (Canvas or Rich-Text) should render agnostically."* A collection's
663
+ * `presentation.body` still names WHICH field is the content — that is content
664
+ * selection, and it is what lets one catch-all render a canvas page and a
665
+ * rich-text post through the same call. What surrounds that content is the
666
+ * developer's implementation decision, branched here in their own file.
667
+ *
668
+ * It replaced a `presentation.wrapper` component-id STRING resolved from the
669
+ * canvas registry, which forced a delivery-only component to register as a
670
+ * pseudo-block (EC-514). A function beats that on every axis that matters: it
671
+ * cannot dangle, the compiler checks it, and it can branch on anything in
672
+ * {@link CanvasWrapContext} rather than on collection identity alone.
673
+ *
674
+ * Applied to the `render` outcome only — a redirect or a 404 never reaches it.
675
+ * Returning `children` unchanged is the no-op, and omitting `wrap` entirely is
676
+ * the default.
677
+ */
678
+ wrap?: (children: ReactNode, ctx: CanvasWrapContext) => ReactNode;
603
679
  }
604
680
  interface CanvasRouteProps {
605
681
  params: Promise<{
@@ -750,14 +826,14 @@ declare function createCanvasSitemap(options: CreateCanvasSitemapOptions): () =>
750
826
  * keeps multi-project hosts (one Next app serving several builder projects)
751
827
  * from cross-invalidating, so the prefix is **on whenever the host knows its
752
828
  * project id** — decision: include it. The emitter (the studio's webhook
753
- * dispatcher, `apps/builder/src/lib/webhooks`) always scopes with the
829
+ * dispatcher, `packages/studio/src/lib/webhooks`) always scopes with the
754
830
  * operation's `projectId`; a host that omits `cache.scope` therefore will not
755
831
  * match studio-emitted tags. Configure `cache.scope` with the same project id
756
832
  * the studio publishes under.
757
833
  *
758
834
  * The studio-side emitter cannot depend on this package (it would drag the
759
- * `next` peer dependency into the builder), so
760
- * `apps/builder/src/lib/webhooks/tags.ts` mirrors these formatters; tests on
835
+ * `next` peer dependency into the studio bundle), so
836
+ * `packages/studio/src/lib/webhooks/tags.ts` mirrors these formatters; tests on
761
837
  * both sides pin the exact literal strings to keep them in lock-step.
762
838
  */
763
839
  /** Apply the optional multi-project scope prefix to one tag. */
@@ -832,14 +908,67 @@ interface CachedEntry<T> {
832
908
  * entry and the next request repeats the cycle with fresh data.
833
909
  *
834
910
  * The loader runs **once per revalidation cycle** (per key), never once per
835
- * request. Time-based revalidation is intentionally not exposed: a
836
- * stale-while-revalidate background refresh would re-run the probe callback
837
- * (whose bail aborts the refresh), so this design is tag-driven only —
838
- * `revalidate: false` — which is exactly the AD-5 model.
911
+ * request. Time-based revalidation is intentionally not exposed, and the reason
912
+ * is a real hazard rather than a preference: a stale-while-revalidate background
913
+ * refresh re-runs the probe callback, whose bail aborts the refresh, so the
914
+ * entry is never rewritten. `revalidate: false` keeps *time* from ever putting
915
+ * an entry into that state — which is the AD-5 model.
916
+ *
917
+ * **What `revalidate: false` does NOT buy (EC-515, measured on Next 16.2.10).**
918
+ * The earlier version of this comment claimed `revalidate: false` made the SWR
919
+ * hazard unreachable. It does not: a **tag** can put an entry into the stale
920
+ * state too, and then exactly the aborted-refresh loop above happens. Whether it
921
+ * does is decided by how the invalidator stamps the tag —
922
+ * `revalidateTag(tag, 'max')` marks entries stale (365-day expiry) and triggers
923
+ * the loop; `revalidateTag(tag, { expire: 0 })` drops them outright and does
924
+ * not. `revalidate.ts` therefore sends `{ expire: 0 }`, and the reasoning lives
925
+ * at that call site. Keep the two in step: this design only survives an
926
+ * invalidation that **drops** the entry, never one that merely staleness-marks
927
+ * it.
839
928
  *
840
929
  * Concurrency: wrappers and the captured closure are per-request, so there is
841
930
  * no cross-request mutation; two racing first requests both resolve and the
842
931
  * last write wins (deterministic content ⇒ identical entries).
932
+ *
933
+ * ## ⚠️ Self-hosted hosts: only the BASELINE tags invalidate (EC-515)
934
+ *
935
+ * **Measured on a self-hosted `next start` (Next 16.2.10, default
936
+ * `FileSystemCache`, `.next/cache` on disk); NOT measured on Vercel.**
937
+ *
938
+ * Fetch-time tag discovery writes the entry with the tags the response actually
939
+ * read — but the default self-hosted cache handler decides staleness and expiry
940
+ * from the tags the **reading** wrapper names (`ctx.tags`, see
941
+ * `IncrementalCache.get` / `FileSystemCache.get` for `kind: FETCH`), not from
942
+ * the tags the entry was **stored** with. The stored tag set is only ever used
943
+ * to decide whether to re-write the entry with a superset.
944
+ *
945
+ * Every read in {@link loadCachedEntry} goes through the **probe** wrapper,
946
+ * whose tags are `baselineTags`. So on such a host:
947
+ *
948
+ * - revalidating a tag that is in `baselineTags` works (the route helper passes
949
+ * the project sweep tag, so `project` sweeps do invalidate);
950
+ * - revalidating a *discovered* `doc:` / `collection:` / `route:` / `asset:` tag
951
+ * invalidates **nothing** — silently. No error, no log line, no loader run.
952
+ *
953
+ * Measured 2×2 that isolates it (same host, same run): a probe handed the full
954
+ * discovered tag set up front DOES honour a `doc:` tag, and the same probe
955
+ * handed only the baseline does not. So the tags, their formatting and the
956
+ * webhook are all correct — the read simply never consults them.
957
+ *
958
+ * Consequence for a self-hosted deployment: invalidation granularity collapses
959
+ * to whatever is in `baselineTags`. The studio's Convex write chokepoint emits
960
+ * the project sweep on every publish, so instant publish still *works* there,
961
+ * coarsely; the browser dispatcher's targeted `doc:`/`collection:` tag set
962
+ * (`packages/studio/src/lib/webhooks/tags.ts`) does not reach a self-hosted host
963
+ * at all. Vercel-hosted deployments are unaffected in practice — both consumer
964
+ * sites were measured healthy on 2026-08-01 — which is consistent with Vercel's
965
+ * cache handler purging by the entry's stored tags, though that has **not** been
966
+ * measured here.
967
+ *
968
+ * Fixing this properly means the read must be able to name the entry's own tags,
969
+ * which no `unstable_cache` API allows without restructuring the probe/bail
970
+ * seam. That is a deliberate open decision — see EC-515, do not "fix" it here
971
+ * incidentally.
843
972
  */
844
973
  declare function loadCachedEntry<T>(cache: CacheWrapper, keyParts: string[], baselineTags: readonly string[], load: () => Promise<CachedEntry<T>>): Promise<CachedEntry<T>>;
845
974
 
@@ -1298,16 +1427,13 @@ declare function createPreviewDisableRoute(): PreviewRouteHandlers;
1298
1427
  * ```ts
1299
1428
  * // frontend/lib/elytra.ts — the ONE seam file
1300
1429
  * import { createElytraHost } from '@elytracms/next'
1301
- * import { collections, routes } from '../../cms'
1302
- * import { siteComponents } from '../../components'
1303
- *
1304
- * export const elytra = createElytraHost({
1305
- * slug: 'my-site',
1306
- * locales: { default: 'en', locales: ['en'] },
1307
- * collections,
1308
- * routes,
1309
- * components: siteComponents,
1310
- * })
1430
+ * // Read the project out of the repo's own config — `elytra.config.ts` is the
1431
+ * // single contract surface, and importing it costs a delivery bundle nothing
1432
+ * // (EC-532). Any other module layout works just as well; this is an example,
1433
+ * // not a required path.
1434
+ * import { site } from '../../elytra.config'
1435
+ *
1436
+ * export const elytra = createElytraHost({ ...site })
1311
1437
  * ```
1312
1438
  *
1313
1439
  * ```ts
@@ -1339,7 +1465,23 @@ interface ElytraHostOptions {
1339
1465
  * Used as the content scope when no live backend is configured.
1340
1466
  */
1341
1467
  slug: string;
1342
- /** Locale config — config-owned (AD-11), never read from the snapshot. */
1468
+ /**
1469
+ * Locale config — config-owned (AD-11), never read from the snapshot.
1470
+ *
1471
+ * **Import it, do not restate it** (EC-519). This is the *same*
1472
+ * {@link LocaleConfig} `elytra.config.ts` takes, so a project declares its
1473
+ * locales once and hands that one binding to both mounts. It stays a host
1474
+ * option because the engine cannot know where a repo keeps the value — but it
1475
+ * is no longer an option you have to *retype*: since EC-532 the delivery
1476
+ * bundle can import `elytra.config.ts` itself for zero engine modules
1477
+ * (measured), so `createElytraHost({ ...site })` over a project exported from
1478
+ * the config is the shortest correct answer.
1479
+ *
1480
+ * This used to be `{ default, locales }` against a config spelling the same
1481
+ * fact `locales` + `defaultLocale`, and in our own demo the two drifted — the
1482
+ * host said `['en']`, the config said `['en','de']`, and every `/de` URL 404'd
1483
+ * silently for weeks (EC-512).
1484
+ */
1343
1485
  locales: LocaleConfig;
1344
1486
  /** CMS collection schema — config-owned (AD-11). */
1345
1487
  collections: readonly CollectionDef[];
@@ -1350,8 +1492,26 @@ interface ElytraHostOptions {
1350
1492
  * SAME modules the studio composes with). Base primitives are always
1351
1493
  * included; `base.Image` is swapped for `next/image` delivery by default
1352
1494
  * (see `image`).
1495
+ *
1496
+ * Typed as the CONFIG's `HostComponentEntry` (`implementation: unknown`), not
1497
+ * the stricter {@link HostComponent}, so that `createElytraHost({ ...site })`
1498
+ * — the shape EC-532 makes the contract — compiles however the consumer typed
1499
+ * their project entry. It previously demanded `ComponentType`, which meant the
1500
+ * recommended spread worked only when the entry's implementation types
1501
+ * survived inference: `apps/demo/elytra.config.ts` gets away with it via
1502
+ * `satisfies`, and anyone using the engine's own published `HostComponentEntry`
1503
+ * / `WorkspaceProjectEntry` annotation did not. Both production consumers hit
1504
+ * exactly that and each carried one hand-restated `components` line (EC-539).
1505
+ *
1506
+ * What this costs, stated plainly rather than glossed: a list passed HERE no
1507
+ * longer gets a compile-time check that each `implementation` is really a
1508
+ * component, and nothing yet checks it at runtime either — a garbage value
1509
+ * would reach the renderer. `defineHostComponents` keeps the strict
1510
+ * {@link HostComponent} signature, so a hand-built list passed there directly
1511
+ * is still checked. Closing the gap properly needs a registration issue code
1512
+ * in the core registry; booked as EC-541 rather than smuggled in here.
1353
1513
  */
1354
- components?: readonly HostComponent[];
1514
+ components?: readonly HostComponentEntry[];
1355
1515
  /**
1356
1516
  * `base.Image` delivery options (loader/sizes/quality — the non-Vercel CDN
1357
1517
  * seam), or `false` to keep the plain-`<img>` primitive. Default: Next's own
@@ -1390,6 +1550,12 @@ interface HostCanvasRouteOptions {
1390
1550
  onTags?: (tags: CacheTags, info: CanvasRequestInfo) => void;
1391
1551
  /** Metadata override (see `CanvasRouteOptions.metadata`). */
1392
1552
  metadata?: (result: ResolvePageResult, info: CanvasRequestInfo) => Metadata;
1553
+ /**
1554
+ * Host-owned chrome around the rendered page (EC-524) — the seam a per-
1555
+ * collection detail template lives on. Branch on `ctx.collection` in your own
1556
+ * route file (see `CanvasRouteOptions.wrap`).
1557
+ */
1558
+ wrap?: (children: ReactNode, ctx: CanvasWrapContext) => ReactNode;
1393
1559
  /**
1394
1560
  * Tagged data cache (instant publish). Default: enabled, scoped to the
1395
1561
  * kit's content scope so the studio's webhook tags match. `false` disables
@@ -1401,6 +1567,25 @@ interface LoadGlobalsOptions {
1401
1567
  /** Perspective of the chrome read. Default `'published'` (live is published-only). */
1402
1568
  perspective?: Perspective;
1403
1569
  }
1570
+ /**
1571
+ * Which world {@link ElytraHost.loadContent} reads from.
1572
+ *
1573
+ * - `live` — the configured Convex deployment (published; token-gated draft).
1574
+ * - `fallback` — the host's own `fallbackContent` world (the scaffold's seeded
1575
+ * starter, the demo's in-memory fixture). A legitimate, supported mode.
1576
+ * - `empty` — no backend and no fallback: this project's structure with no
1577
+ * content, so every URL is a clean 404. Correct for a CI build; almost never
1578
+ * what a deployed site intends.
1579
+ *
1580
+ * The mode was decided from env long before EC-527 and readable by **nobody**,
1581
+ * so a site serving the fallback looked identical from the outside to one
1582
+ * reading the real deployment. Measured on the demo: `/` returned `200` while
1583
+ * all four of EC-512's published fixtures 404'd — an agent following AGENTS.md
1584
+ * §5 gets no signal that the thing under test was never exercised. Declared
1585
+ * once, read here, so a host can *say* which world it is in instead of
1586
+ * re-deriving it from `env` and drifting.
1587
+ */
1588
+ type ElytraContentMode = 'live' | 'fallback' | 'empty';
1404
1589
  /** The delivery host handle {@link createElytraHost} yields. */
1405
1590
  interface ElytraHost {
1406
1591
  /**
@@ -1408,6 +1593,13 @@ interface ElytraHost {
1408
1593
  * studio's webhook tags), else the config slug.
1409
1594
  */
1410
1595
  contentScope: string;
1596
+ /**
1597
+ * Which world {@link loadContent} reads from — see {@link ElytraContentMode}.
1598
+ * Read it to announce the mode (a boot line, a dev-only banner) rather than
1599
+ * re-deriving it from {@link env}, which is the drift this field exists to
1600
+ * prevent.
1601
+ */
1602
+ contentMode: ElytraContentMode;
1411
1603
  /** The resolved env bag (documented names, trimmed, empty = unset). */
1412
1604
  env: ElytraHostEnv;
1413
1605
  /** Materialized route records (a derive is run once, at factory time). */
@@ -1533,6 +1725,12 @@ declare function loadLiveContent(options: LiveContentOptions, perspective: Persp
1533
1725
  * document, empty composition, unknown component, or an undeclared hint all
1534
1726
  * answer `false` — the safe default is clearance.
1535
1727
  *
1728
+ * EC-499: pass the collection's resolved `presentation` (from the render
1729
+ * outcome) so this reads the SAME body the renderer will render when the
1730
+ * collection declares one — otherwise the value-shape pick could name a
1731
+ * different field than the declaration does. A body whose first item is prose
1732
+ * rather than a block answers `false`, which is right: rich text opens no hero.
1733
+ *
1536
1734
  * ```tsx
1537
1735
  * const outcome = client.resolvePage(path)
1538
1736
  * const fullBleed = firstBlockFullBleed(outcome.document, components.registry)
@@ -1541,7 +1739,7 @@ declare function loadLiveContent(options: LiveContentOptions, perspective: Persp
1541
1739
  *
1542
1740
  * The host kit wires the resolution half for you: `elytra.pageOpensFullBleed(slug)`.
1543
1741
  */
1544
- declare function firstBlockFullBleed(document: Record<string, unknown> | null | undefined, registry: ComponentRegistry): boolean;
1742
+ declare function firstBlockFullBleed(document: Record<string, unknown> | null | undefined, registry: ComponentRegistry, presentation?: DeliveryPresentation): boolean;
1545
1743
 
1546
1744
  /**
1547
1745
  * Pure token/target logic of the draft-preview routes (EC-144), kept free of
@@ -1589,4 +1787,4 @@ declare function evaluatePreviewRequest(requestUrl: string | URL, expectedToken:
1589
1787
  */
1590
1788
  declare const PACKAGE = "@elytracms/next";
1591
1789
 
1592
- export { type AssetImageLoaderOptions, type BindingPayloads, type CacheWrapper, type CacheWrapperOptions, type CachedEntry, type CanvasCacheOptions, type CanvasContentRequest, type CanvasDataSource, type CanvasPageOutcome, type CanvasPayloadContext, type CanvasPayloadsFactory, CanvasRenderer, type CanvasRendererProps, type CanvasRequestInfo, type CanvasRoute, type CanvasRouteOptions, type CanvasRouteProps, type CanvasSitemapOptions, type CanvasSitemapResult, type ContentSnapshot, type ContentSnapshotOptions, type ContentSource, type CreateCanvasSitemapOptions, type DefineHostComponentsOptions, ELYTRA_HOST_ENV_KEYS, type ElytraHost, type ElytraHostEnv, type ElytraHostImageOptions, type ElytraHostOptions, ElytraImage, type ElytraImageAsset, type ElytraImageProps, type GlobalsConventions, type HostCanvasRouteOptions, type HostComponent, type HostComponents, type LiveContentOptions, type LoadGlobalsOptions, type LocaleUrlPath, type MaterializeSourcePayloadsInput, type MaterializedSourcePayloads, type NextImagePrimitiveOptions, PACKAGE, type PreviewEvaluation, type PreviewRouteHandlers, type PreviewRouteOptions, REVALIDATE_SIGNATURE_HEADER, type ResolveCanvasPageOptions, type ResolveGlobalsOptions, type ResolvedLink, type ResolvedNavEntry, type ResolvedNavLink, type RevalidateEvaluation, type RevalidatePayload, type RevalidateRouteHandlers, type RevalidateRouteOptions, type SiteGlobals, type SitemapParamsProvider, type SitemapRouteContext, type SitemapSkippedRoute, type SourcePayloadError, type StaticContentData, assembleLiveSource, canvasPageMetadata, canvasSitemapEntries, collectBindingSourceIds, collectionCacheTag, createAssetImageLoader, createBindingResolver, createCanvasRoute, createCanvasSitemap, createContentSnapshot, createElytraHost, createPreviewDisableRoute, createPreviewRoute, createRevalidateRoute, createStaticContentSource, defaultBindingPayloads, defineHostComponents, docCacheTag, elytraHostImage, evaluatePreviewRequest, evaluateRevalidateRequest, firstBlockFullBleed, isSourcePayloadError, loadCachedEntry, loadLiveContent, localeUrlPath, materializeSourcePayloads, mergeCacheTags, nextCacheTags, nextImagePrimitive, pathFromSlug, projectSweepTag, resolveCanvasPage, resolveGlobals, resolveHostEnv, resolvePayloadToken, revalidatePayloadSchema, routeCacheTag, safeRedirectTarget, scopeCacheTag, signRevalidateBody, sourcePayloadError, splitLocalePath };
1790
+ export { type AssetImageLoaderOptions, type BindingPayloads, type CacheWrapper, type CacheWrapperOptions, type CachedEntry, type CanvasCacheOptions, type CanvasContentRequest, type CanvasDataSource, type CanvasPageOutcome, type CanvasPayloadContext, type CanvasPayloadsFactory, CanvasRenderer, type CanvasRendererProps, type CanvasRequestInfo, type CanvasRoute, type CanvasRouteOptions, type CanvasRouteProps, type CanvasSitemapOptions, type CanvasSitemapResult, type CanvasWrapContext, type ContentSnapshot, type ContentSnapshotOptions, type ContentSource, type CreateCanvasSitemapOptions, type DefineHostComponentsOptions, type DeliveryPresentation, ELYTRA_HOST_ENV_KEYS, type ElytraContentMode, type ElytraHost, type ElytraHostEnv, type ElytraHostImageOptions, type ElytraHostOptions, ElytraImage, type ElytraImageAsset, type ElytraImageProps, type GlobalsConventions, type HostCanvasRouteOptions, type HostComponent, type HostComponents, type LiveContentOptions, type LoadGlobalsOptions, type MaterializeSourcePayloadsInput, type MaterializedSourcePayloads, type NextImagePrimitiveOptions, PACKAGE, type PreviewEvaluation, type PreviewRouteHandlers, type PreviewRouteOptions, REVALIDATE_SIGNATURE_HEADER, type ResolveCanvasPageOptions, type ResolveGlobalsOptions, type ResolvedLink, type ResolvedNavEntry, type ResolvedNavLink, type RevalidateEvaluation, type RevalidatePayload, type RevalidateRouteHandlers, type RevalidateRouteOptions, type SiteGlobals, type SitemapParamsProvider, type SitemapRouteContext, type SitemapSkippedRoute, type SourcePayloadError, type StaticContentData, assembleLiveSource, bodyItemsOf, canvasPageMetadata, canvasSitemapEntries, collectBindingSourceIds, collectionCacheTag, createAssetImageLoader, createBindingResolver, createCanvasRoute, createCanvasSitemap, createContentSnapshot, createElytraHost, createPreviewDisableRoute, createPreviewRoute, createRevalidateRoute, createStaticContentSource, defaultBindingPayloads, defineHostComponents, docCacheTag, elytraHostImage, evaluatePreviewRequest, evaluateRevalidateRequest, firstBlockFullBleed, isSourcePayloadError, loadCachedEntry, loadLiveContent, materializeSourcePayloads, mergeCacheTags, nextCacheTags, nextImagePrimitive, pathFromSlug, projectSweepTag, resolveCanvasPage, resolveGlobals, resolveHostEnv, resolvePayloadToken, revalidatePayloadSchema, routeCacheTag, safeRedirectTarget, scopeCacheTag, signRevalidateBody, sourcePayloadError };