@elytracms/next 0.0.90 → 0.0.92

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,15 +1,15 @@
1
1
  import { ComponentType, ReactNode } from 'react';
2
- import { ComponentRegistry, ComponentIssue, ComponentManifest } from '@elytracms/core/component-registry';
2
+ import { ComponentRegistry, ComponentManifest } from '@elytracms/core/component-registry';
3
3
  export { ComponentManifest, PropField, SlotSpec, defineComponent } from '@elytracms/core/component-registry';
4
4
  import { ComponentImplementations, RelationTarget, DocumentBodyItem } from '@elytracms/core/runtime-renderer';
5
5
  export { ComponentImplementations, RenderAsset } from '@elytracms/core/runtime-renderer';
6
- import { HostComponentEntry } from '@elytracms/core/studio-core';
7
- import { ResolvePageResult, ResolvedAsset, Perspective, ContentClient, CacheTags, ContentDocumentSource, ResolvedDocument } from '@elytracms/core/content';
8
- export { Perspective } from '@elytracms/core/content';
9
- import { LocaleConfig, Locale, RouteRecord, CollectionDef, RedirectRecord, LocaleConfigInput } from '@elytracms/core/cms-core';
10
- export { CmsDocument, CollectionDef, FieldDef, Locale, LocaleConfig, LocaleConfigInput, LocaleRequest, LocaleStrategy, LocaleUrlPath, RedirectRecord, RouteRecord, documentSchema, fallbackHomeRoutes, localeConfigSchema, localeUrlPath, mergeRouteRecords, parseLocaleConfig, redirectRecordSchema, resolveLocale, routeRecordSchema, splitLocalePath, textDirection } from '@elytracms/core/cms-core';
6
+ import { HostComponentEntry, ResolvedProject, WorkspaceProjectEntry } from '@elytracms/core/studio-core';
7
+ import { ContentQueryError, ResolvePageResult, ResolvedAsset, ContentClient, CacheTags, ContentDocumentSource, ResolvedDocument } from '@elytracms/core/content';
8
+ import { LocaleConfig, Perspective, Locale, RouteRecord, CollectionDef, RedirectRecord, LocaleConfigInput, CmsValidationIssue } from '@elytracms/core/cms-core';
9
+ export { CmsDocument, CollectionDef, DRAFT_PERSPECTIVE, FieldDef, Locale, LocaleConfig, LocaleConfigInput, LocaleRequest, LocaleStrategy, LocaleUrlPath, Perspective, RedirectRecord, RouteRecord, documentSchema, localeConfigSchema, localeUrlPath, parseLocaleConfig, redirectRecordSchema, resolveLocale, routeRecordSchema, splitLocalePath, textDirection, variantOf } from '@elytracms/core/cms-core';
11
10
  import { AssetRecord, PersistenceAdapter } from '@elytracms/core/persistence';
12
11
  export { AssetRecord } from '@elytracms/core/persistence';
12
+ import { Clock } from '@elytracms/core/determinism';
13
13
  import { ResolvingMetadata, Metadata, ResolvedMetadata, MetadataRoute } from 'next';
14
14
  import { RevalidationEvent } from '@elytracms/core/revalidation';
15
15
  export { REVALIDATE_SIGNATURE_HEADER, RevalidationEvent, RevalidationEventKind, revalidationEventSchema, signRevalidationBody, signedRevalidationRequest } from '@elytracms/core/revalidation';
@@ -37,48 +37,43 @@ interface HostComponent {
37
37
  /**
38
38
  * The registered component surface a host app hands to `<CanvasRenderer />`
39
39
  * and the catch-all route helper: a manifest registry plus the implementation
40
- * map, with registration problems surfaced as structured issues (never
41
- * thrown — an unknown component renders the EC-015 fallback).
40
+ * map. Registration problems — duplicate ids, invalid ids, role claims, and
41
+ * `invalid-implementation` for an entry whose `implementation` is not a React
42
+ * component — are structured issues in `registry.issues`, the ONE record every
43
+ * surface reads (EC-591; never thrown — an unknown component renders the EC-015
44
+ * fallback). The studio registers the same config list through the same core
45
+ * step, so it shows exactly these issues.
42
46
  */
43
47
  interface HostComponents {
44
48
  registry: ComponentRegistry;
45
49
  implementations: ComponentImplementations;
46
- /**
47
- * Structural registration issues: the registry's own (duplicate ids, invalid
48
- * namespaces, …) plus `invalid-implementation` for an entry whose
49
- * `implementation` is not a React component (EC-541).
50
- */
51
- issues: readonly ComponentIssue[];
52
- }
53
- interface DefineHostComponentsOptions {
54
- /**
55
- * Include the platform primitives (`base.*`) that ship with
56
- * `@elytracms/core/runtime-renderer`. Defaults to `true` — graphs authored in the
57
- * builder assume them.
58
- */
59
- includeBasePrimitives?: boolean;
60
50
  }
61
51
  /**
62
- * Component registration API for the embedded runtime (EC-144). The host repo
63
- * supplies real implementations + manifests; base primitives are included by
64
- * default. A host implementation registered under a primitive id replaces the
65
- * default implementation (the manifest of the first registration wins, which
66
- * is the primitive's — by design, so prop schemas stay canonical).
52
+ * Component registration for the embedded runtime (EC-144, EC-591): the host
53
+ * registers EXACTLY the list it is given — the config's `components` — through
54
+ * core's one registration step `registerComponents`, the same step the studio
55
+ * registry and the preview harness call. The engine adds nothing: there are no
56
+ * base primitives and no override layer (EC-591 deleted `includeBasePrimitives`).
57
+ * The components Elytra ships are an opt-in package listed in the config like any
58
+ * other (`components: [...elytraComponents, ...siteComponents]`).
59
+ *
60
+ * One precedence rule: a duplicate id is refused WHOLE (`duplicate-id`, the first
61
+ * entry kept), so a manifest never ships with another entry's implementation.
62
+ * To replace a shipped component, list yours under the id and leave the shipped
63
+ * entry out; to swap only the Image implementation for `next/image`, wrap the
64
+ * list in {@link withNextImage}.
67
65
  *
68
66
  * Accepts the CONFIG's entry shape (`implementation: unknown`) rather than the
69
67
  * strict {@link HostComponent}: the config declares implementations opaquely,
70
68
  * and EC-539 made `createElytraHost({ ...site })` the contract, so a
71
69
  * compile-time check at this seam was already gone for the recommended path.
72
- * The check is RUNTIME instead (EC-541), through core's one registration step
73
- * `registerImplementations` (EC-555 — the studio registry and the preview
74
- * harness call the same one): a value that is not a component is
70
+ * The check is RUNTIME instead (EC-541): a value that is not a component is
75
71
  * recorded as an `invalid-implementation` issue naming the component id and
76
72
  * the received kind, and is NOT put into `implementations` — so the renderer
77
73
  * takes its missing-component path (a visible EC-015 fallback) instead of
78
74
  * crashing the server render on `createElement` ("Element type is invalid")
79
75
  * or, for a string, silently rendering an HTML tag. The manifest still
80
- * registers, so the registry's own issues (duplicate id, invalid id) for that
81
- * entry are reported alongside.
76
+ * registers, so the entry shows as broken rather than vanishing.
82
77
  *
83
78
  * One consequence of the `unknown` parameter: an INLINE
84
79
  * `implementation: (props) => …` literal no longer gets `props` contextually
@@ -87,7 +82,7 @@ interface DefineHostComponentsOptions {
87
82
  * anyway. (An overload pair was tried and does not restore it: TypeScript
88
83
  * resolves the context-sensitive literal against the wider overload.)
89
84
  */
90
- declare function defineHostComponents(components?: readonly HostComponentEntry[], options?: DefineHostComponentsOptions): HostComponents;
85
+ declare function defineHostComponents(components?: readonly HostComponentEntry[]): HostComponents;
91
86
 
92
87
  /**
93
88
  * `<CanvasRenderer />` (EC-144, vision AD-1): an async React Server Component
@@ -120,9 +115,10 @@ interface CanvasRendererProps {
120
115
  * Documents each `listing` block resolved to, keyed by serialized query (EC-255,
121
116
  * from the render outcome). A ProductGrid keyed to a picked `category` reads its
122
117
  * products through this; an unknown key (or absent map) degrades to the block's
123
- * own empty state — the same degradation as a missing relation.
118
+ * own empty state — the same degradation as a missing relation. A query that
119
+ * could not run is baked as its errors (EC-581 6a) and shown above the block.
124
120
  */
125
- listings?: Record<string, RelationTarget[]>;
121
+ listings?: Record<string, BakedListing>;
126
122
  /**
127
123
  * URL paths for the documents this page's rich-text props LINK to, keyed
128
124
  * `collection:id` (EC-291, from the render outcome). A self-typed internal link
@@ -155,6 +151,14 @@ interface DeliveryPresentation {
155
151
  /** Field names whose values form the body, in render order. */
156
152
  body?: readonly string[];
157
153
  }
154
+ /**
155
+ * One listing's baked result (EC-255; EC-581 6a): the documents its query matched,
156
+ * or the structured errors `listDocuments` refused it with. Plain data — it rides
157
+ * the serializable render outcome and the RSC boundary like the other bakes.
158
+ */
159
+ type BakedListing = RelationTarget[] | {
160
+ errors: readonly ContentQueryError[];
161
+ };
158
162
  /**
159
163
  * Render a resolved page server-side. Async so hosts can compose it like any
160
164
  * other RSC; resolution itself is synchronous (the content was prefetched by
@@ -247,6 +251,15 @@ declare function mergeCacheTags(first: CacheTags, ...rest: readonly CacheTags[])
247
251
  * API — assembles the exact same `ContentSource` without faking an adapter.
248
252
  */
249
253
  interface ContentSnapshotOptions {
254
+ /**
255
+ * The project's collection definitions — **required** (EC-583). The snapshot's
256
+ * lookup resolves localized fields and a collection's declared `presentation`
257
+ * through them, and the persistence contract holds no schema any more (this used to
258
+ * read `adapter.cms.findSchema`, which in a seeded host was the engine sample's JSON
259
+ * projection unless the host re-saved its own). Hand it the config's collections —
260
+ * the same ones the studio and the seed read.
261
+ */
262
+ collections: readonly CollectionDef[];
250
263
  /** Route records served by `resolvePage` (EC-019). */
251
264
  routes?: readonly RouteRecord[];
252
265
  /** Redirect records served by `resolvePage` (EC-019/020). */
@@ -298,7 +311,6 @@ interface StaticContentData {
298
311
  */
299
312
  assetUrl?: (asset: AssetRecord) => string;
300
313
  }
301
-
302
314
  /**
303
315
  * Assemble a `ContentSource` from plain prefetched data — the pure half of
304
316
  * {@link createContentSnapshot}, shared with hosts that fetched the data
@@ -395,8 +407,24 @@ type CanvasPageOutcome = {
395
407
  * cannot ride the data cache or cross the RSC boundary. A listing depends on
396
408
  * collection MEMBERSHIP (a product joining/leaving the category), so its
397
409
  * invalidation rides the per-query `listDocuments` tags merged into `tags`.
410
+ *
411
+ * EC-581 6a: a query `listDocuments` refused (a non-filterable field, an
412
+ * operand of the wrong type, a bad limit) is baked as `{ errors }` — the
413
+ * structured `ContentQueryError`s — and `<CanvasRenderer />` shows them above
414
+ * the block (`data-ec-fallback="invalid-listing"`), never an empty grid
415
+ * indistinguishable from "no matching content".
416
+ */
417
+ listings: Record<string, BakedListing>;
418
+ /**
419
+ * EC-581 6a: whether resolving this page READ THE CLOCK — a listing on it
420
+ * binds an operand to `now` (e.g. "events whose date is still ahead"). Such a
421
+ * page's content changes as time passes, not only on publish: a cached entry
422
+ * of it is correct for the instant it was resolved and goes stale when a
423
+ * listed document crosses its `now` boundary, which no publish tag covers. A
424
+ * cache layer must bound that entry's life (the delivery cache — EC-515 —
425
+ * reads this). `false` for every page with no `now`-bound listing.
398
426
  */
399
- listings: Record<string, RelationTarget[]>;
427
+ readsNow: boolean;
400
428
  /**
401
429
  * The public URL path of every document a rich-text prop on this page LINKS
402
430
  * to (EC-291), keyed `collection:id`. Baked into the outcome (serializable) so
@@ -433,6 +461,14 @@ interface ResolveCanvasPageOptions {
433
461
  * resolution exactly, so no referenced image is ever dropped.
434
462
  */
435
463
  registry?: ComponentRegistry;
464
+ /**
465
+ * The clock a `{ now: true }` listing operand reads (EC-581 6a) — an ISO-8601
466
+ * UTC instant, read at most once per resolution so every listing on the page
467
+ * compares against the SAME instant. Defaults to the system clock; tests and
468
+ * fixtures inject a fixed one. Taken as a FUNCTION, read during resolution, so a
469
+ * `now` value is never an argument a cache keys on.
470
+ */
471
+ clock?: Clock;
436
472
  }
437
473
  /**
438
474
  * Resolve one request URL against a content source: split the locale, run
@@ -442,29 +478,165 @@ interface ResolveCanvasPageOptions {
442
478
  */
443
479
  declare function resolveCanvasPage(source: ContentSource, url: string, perspective: Perspective, options?: ResolveCanvasPageOptions): Promise<CanvasPageOutcome>;
444
480
 
445
- interface CanvasCacheOptions {
446
- /**
447
- * Project scope prefix for every tag (`p:<scope>:…`) — must be the SAME scope
448
- * the revalidate receiver derives its tags with (`createRevalidateRoute`'s
449
- * `scope`; the delivery kit hands its one `contentScope` to both, EC-579).
450
- */
451
- scope?: string;
452
- /**
453
- * Which content world `loadContent` reads — the delivery kit passes its
454
- * `contentMode` (`'live' | 'fallback' | 'empty'`), so entries written in one
455
- * world can never be served in another (EC-543, see `canvasCacheKey`). A host
456
- * driving this helper directly with a single world may omit it.
457
- */
458
- mode?: string;
481
+ /**
482
+ * Cache-tag pipeline (EC-146, vision AD-5) — the pure half.
483
+ *
484
+ * This module defines (a) the **canonical Next.js tag string format** mapping
485
+ * the accessor-level `CacheTags` (EC-143) onto `cacheTag` / `revalidateTag` keys,
486
+ * (b) the **tag derivation for a revalidation event** ({@link revalidationTags},
487
+ * EC-579 — the receiver's half of the one emitter), and (c) the **pure half of a
488
+ * cached resolution**: its key ({@link canvasCacheKey}) and the `cacheTag` calls that
489
+ * declare its tags ({@link entryTagCalls}). No Next.js imports — everything here is
490
+ * unit-testable.
491
+ *
492
+ * **Caching is the host's (EC-620).** The engine's part is exactly three things: the
493
+ * tags on everything the accessors return, the invalidation signal (the Convex
494
+ * trigger's event → the kit receiver → `revalidateTag` for every derived tag), and a
495
+ * default in the scaffold. Whether a page caches, under which scope, for how long and
496
+ * with which Next flag is the host's: the kit holds no `'use cache'` scope and no
497
+ * `cacheLife`. The host's own `'use cache'` function declares a result's tags through
498
+ * the canvas route's `applyCacheTags` (`route.tsx`), which makes the
499
+ * {@link entryTagCalls} calls with the host's one scope.
500
+ *
501
+ * This is the ONE tag module: the entries are tagged here and the invalidations
502
+ * are derived here, from the same formatters and the same scope. Before EC-579 the
503
+ * format was mirrored twice more (the studio's browser dispatcher and an inline
504
+ * literal in the Convex emitter), and each emitter brought its own scope.
505
+ *
506
+ * ## Tag format
507
+ *
508
+ * - `doc:<collection>:<id>` — one per document whose content was actually in
509
+ * the response (fetch-time tagging: includes depth-1 populated relation
510
+ * targets, and documents inside filtered/dynamic lists *after* the filter
511
+ * ran).
512
+ * - `collection:<name>:<locale>` — the coarse membership tag: every response
513
+ * that read from a collection carries it, so create/delete/newly-matching
514
+ * documents sweep cached lists without read-set tracking.
515
+ * - `route:<pattern>:<locale>` — the resolved URL path plus every hop of a
516
+ * redirect chain.
517
+ * - `asset:<id>` — one per asset whose delivery shape the page *baked*
518
+ * (page-scoped images + `seoOgImage`, EC-227); editing that asset record
519
+ * (alt/dimensions/re-upload) drops exactly the pages that baked it. Not
520
+ * locale-scoped — an asset record is locale-agnostic.
521
+ * - `project` — the whole-project sweep tag attached to **every** cached
522
+ * entry; derived for the events whose effect no narrower tag names (a redirect
523
+ * change, an asset edit — see {@link revalidationTags}).
524
+ *
525
+ * ## Project scope
526
+ *
527
+ * Every tag is prefixed `p:<projectId>:` when a scope is configured (e.g.
528
+ * `p:prj_aurora:doc:post:post-1`). The cost is a few bytes per tag and it
529
+ * keeps multi-project hosts (one Next app serving several builder projects)
530
+ * from cross-invalidating, so the prefix is **on whenever the host knows its
531
+ * project id** — decision: include it. Since EC-579 the scope has ONE owner: the
532
+ * host. The webhook carries an event, not tags, and the receiver derives the
533
+ * tags with the same scope its cache entries are written under (the delivery
534
+ * kit hands both the one `contentScope`), so emitter and host can no longer
535
+ * disagree about it.
536
+ */
537
+ /** Apply the optional multi-project scope prefix to one tag. */
538
+ declare function scopeCacheTag(tag: string, scope?: string): string;
539
+ /**
540
+ * Tag for one document identity. `docKey` is the EC-143 `documentKey` string
541
+ * (`<collection>:<id>`), exactly as found in `CacheTags.docs`.
542
+ */
543
+ declare function docCacheTag(docKey: string, scope?: string): string;
544
+ /** The coarse membership tag of one collection in one locale. */
545
+ declare function collectionCacheTag(collection: string, locale: string, scope?: string): string;
546
+ /** Tag for one resolved route path (or redirect-chain hop) in one locale. */
547
+ declare function routeCacheTag(pattern: string, locale: string, scope?: string): string;
548
+ /**
549
+ * The whole-project sweep tag. Attached to every cached page entry; derived for
550
+ * the events no narrower tag covers ({@link revalidationTags}).
551
+ */
552
+ declare function projectSweepTag(scope?: string): string;
553
+ /**
554
+ * Map an accessor-level `CacheTags` (what one request actually read) onto the
555
+ * full, deterministic (sorted, deduplicated) set of Next tag strings for the
556
+ * cache entry — including the project sweep tag.
557
+ */
558
+ declare function nextCacheTags(tags: CacheTags, scope?: string): string[];
559
+ /** The receiver's own project — what its cached entries are tagged with. */
560
+ interface RevalidationProject {
561
+ /** The tag scope of this host's cache entries (`p:<scope>:…`), if any. */
562
+ scope?: string | undefined;
563
+ /** Every locale this host serves — one `collection:` tag per locale. */
564
+ locales: readonly string[];
459
565
  }
566
+ /**
567
+ * The tags one revalidation event invalidates on this host — sorted, deduplicated,
568
+ * scoped with the receiver's OWN scope, so the emitter never needs to know it.
569
+ *
570
+ * - `publish` / `unpublish` / `delete` → `doc:<collection>:<id>` (its detail page and
571
+ * every cached list that contained it) + `collection:<collection>:<locale>` for
572
+ * EVERY locale this host serves (list membership may have changed, and a URL
573
+ * cached as not-found for a document that now exists carries it too). **No
574
+ * sweep** — a publish drops exactly the entries that read the document or its
575
+ * collection (EC-515);
576
+ * - `redirects` → the sweep (routing can change any URL; see the `redirects` trigger
577
+ * in the studio's `convex/triggers.ts` for why a targeted `route:` tag is wrong);
578
+ * - `asset` → `asset:<id>` + the sweep (a page reading the asset through a document
579
+ * asset FIELD carries no `asset:` tag — EC-566 D16, EC-589);
580
+ * - `probe` → nothing (the doctor's no-op ping).
581
+ *
582
+ * Why the precise tags are enough (EC-515 option D, kept by EC-620): a cached
583
+ * resolution declares its tags with `cacheTag` from INSIDE the cache function that
584
+ * loaded it (the host's, through `applyCacheTags`), and Next decides an entry's
585
+ * staleness from the tags it was STORED with — so a `doc:` tag drops the entries that
586
+ * read the document, on a self-hosted host as on any other. Until EC-515 the read
587
+ * named only the baseline sweep (`unstable_cache` judged the reading wrapper's tags),
588
+ * every precise tag was inert on a self-hosted host, and every publish carried the
589
+ * sweep to compensate (wave-0091). Measured both ways in EC-515's harness, and again
590
+ * with the host-owned scope in EC-620's.
591
+ */
592
+ declare function revalidationTags(event: RevalidationEvent, project: RevalidationProject): string[];
593
+ declare const canvasCacheKeyBrand: unique symbol;
594
+ /**
595
+ * What identifies one published canvas resolution — the argument the canvas route
596
+ * hands the host's cache function (`CanvasRouteOptions.published`), which therefore
597
+ * IS that entry's key: Next keys a `'use cache'` entry by (build id, function id,
598
+ * serialized arguments), so whatever identifies an entry has to be an argument, and
599
+ * nothing else may be. It encodes three facts ({@link parseCanvasCacheKey}):
600
+ *
601
+ * - `scope` — the tag scope (the project id the studio publishes under; `''` if none).
602
+ * - `mode` — which content world the loader reads (the delivery kit's `contentMode`:
603
+ * `live` / `fallback` / `empty`; `''` for a host driving the route directly with
604
+ * one world).
605
+ * - `url` — the request path, locale prefix included.
606
+ *
607
+ * **An opaque string, not an object — measured (EC-620).** Next wraps every
608
+ * `'use cache'` function in React's per-request `cache()`, which memoizes by argument
609
+ * IDENTITY. `Page` and `generateMetadata` each build the key for the same URL; as two
610
+ * equal objects they missed that memo and a cold URL ran the loader twice (page and
611
+ * metadata filling the entry concurrently), as one string they share the in-flight
612
+ * miss — one load, exactly as the kit-owned entry did. A string is also trivially
613
+ * stable for Next's own key serialization. The brand keeps a hand-built string out.
614
+ *
615
+ * The mode is in the key because an entry can outlive the world that wrote it (a
616
+ * shared or remote cache handler, a host whose env changed under a warm cache) and a
617
+ * hit never asks the loader whether it still applies. Before EC-543 the key was
618
+ * `(scope, url)` only, and since `ELYTRA_PROJECT_ID` *is* the config slug, a live host
619
+ * and an empty one keyed the same URL identically — measured on 0.0.85: a warm cache
620
+ * written in live mode kept answering `200` with the live world's bytes after both env
621
+ * vars were blanked and the host itself reported `contentMode: 'empty'`. Keying on the
622
+ * mode makes that cross-read structurally impossible rather than something to detect —
623
+ * a world change is a cold miss, never a stale hit. Since EC-620 the cache function is
624
+ * the host's, so the property holds as long as the host keys on this value — which
625
+ * it does by passing it through unchanged (the canvas route refuses a key of another
626
+ * world).
627
+ */
628
+ type CanvasCacheKey = string & {
629
+ readonly [canvasCacheKeyBrand]: true;
630
+ };
631
+
460
632
  /**
461
633
  * What the route helper tells the content loader about the request it is
462
634
  * loading for (EC-156). Loaders that serve both perspectives from one
463
635
  * prefetched source (the fixture snapshot) can ignore it; a live loader uses
464
636
  * it to fetch the draft-perspective snapshot only for draft-mode requests —
465
- * it must NOT call request APIs like `draftMode()` itself, because on the
466
- * cached published path the loader runs inside `unstable_cache`, where
467
- * dynamic request APIs are unavailable.
637
+ * it must NOT call request APIs like `draftMode()` itself, because on a cached
638
+ * published path the loader runs inside the host's `'use cache'` function, where
639
+ * request APIs are unavailable.
468
640
  */
469
641
  interface CanvasContentRequest {
470
642
  perspective: Perspective;
@@ -474,8 +646,8 @@ interface CanvasRouteOptions {
474
646
  * Async content loader — the seam EC-145/146 plug Convex into. For the
475
647
  * fixture-backed example this memoizes a `createContentSnapshot` call.
476
648
  * Invoked per request; cache/memoize inside the loader as appropriate.
477
- * With `cache` configured it only runs on a cache miss (once per
478
- * revalidation cycle) — cache hits never touch the content backend.
649
+ * Behind a host cache function ({@link CanvasRouteOptions.published}) it runs
650
+ * only on a cache miss — cache hits never touch the content backend.
479
651
  * Receives the request's perspective (see {@link CanvasContentRequest});
480
652
  * zero-arg loaders remain valid.
481
653
  */
@@ -483,17 +655,38 @@ interface CanvasRouteOptions {
483
655
  /** Host component surface from `defineHostComponents`. */
484
656
  components: HostComponents;
485
657
  /**
486
- * EC-146 instant publish: cache the published-perspective resolution in
487
- * Next's data cache (`unstable_cache`), tagged with the response's own
488
- * fetch-time tags, invalidated by the `/api/revalidate` webhook. Draft mode
489
- * always bypasses this cache entirely. Tag-driven only — no time-based
490
- * revalidation (the AD-5 model). Cached values are JSON-serialized plain data.
658
+ * The tag scope of everything this route resolves (`p:<scope>:…`) — what
659
+ * {@link CanvasRoute.applyCacheTags} scopes with and what every
660
+ * {@link CanvasCacheKey} carries. It must be the SAME scope the revalidate receiver
661
+ * derives its tags with (`createRevalidateRoute`'s `scope`); the delivery kit hands
662
+ * its one `contentScope` to both (EC-579) — a disagreement would drop nothing,
663
+ * silently.
491
664
  */
492
- cache?: CanvasCacheOptions;
665
+ scope?: string;
666
+ /**
667
+ * Which content world `loadContent` reads — the delivery kit passes its
668
+ * `contentMode` (`'live' | 'fallback' | 'empty'`). It rides in every
669
+ * {@link CanvasCacheKey}, so an entry written in one world can never be served in
670
+ * another (EC-543). A host driving this helper directly with one world may omit it.
671
+ */
672
+ mode?: string;
673
+ /**
674
+ * How a PUBLISHED request gets its outcome — the host's cache seam (EC-620). Pass
675
+ * your own `'use cache'` function: it receives the request's {@link CanvasCacheKey}
676
+ * (keep that its only argument — it is the entry's key), calls
677
+ * {@link CanvasRoute.resolve} with it, declares the result's tags with
678
+ * {@link CanvasRoute.applyCacheTags}, picks its `cacheLife`, and returns the
679
+ * outcome (module example above; the scaffold's page ships it as the default).
680
+ *
681
+ * Omit it and every published request resolves fresh — dynamic and uncached. Draft
682
+ * mode never calls it. `Page` and `generateMetadata` call it with the same key.
683
+ */
684
+ published?: (key: CanvasCacheKey) => Promise<CanvasPageOutcome>;
493
685
  /**
494
686
  * EC-146 hook: receives the merged `CacheTags` of everything this request
495
687
  * read (the `resolvePage` tags plus the baked relations/listings/links). On a
496
- * cache hit these are the stored tags of the run that wrote the entry.
688
+ * cache hit (through a host {@link CanvasRouteOptions.published} function) these
689
+ * are the stored tags of the run that wrote the entry.
497
690
  */
498
691
  onTags?: (tags: CacheTags, info: CanvasRequestInfo) => void;
499
692
  /** Override the generated `<head>` metadata for a resolved page. */
@@ -552,10 +745,131 @@ interface CanvasRoute {
552
745
  * on 0.0.88 (the Turbopack validator is lenient, which is how the gate missed it).
553
746
  */
554
747
  generateMetadata: (props: CanvasRouteProps, parent: ResolvingMetadata) => Promise<Metadata>;
748
+ /**
749
+ * Resolve one PUBLISHED request — with no cache scope of its own (EC-620). Call it
750
+ * from the `'use cache'` function you pass as {@link CanvasRouteOptions.published},
751
+ * with the key the route handed that function. A key of another scope or content
752
+ * mode (a cache function shared with a route of another world) is refused loudly:
753
+ * the entry would be keyed under one world and resolved in another (EC-543).
754
+ *
755
+ * The outcome carries everything a cache decision needs: `tags` (what the
756
+ * resolution read — hand them to {@link applyCacheTags}) and, on a `render`
757
+ * outcome, `readsNow` (a listing on the page compared against the clock, so the
758
+ * page changes as time passes — bound that entry's life).
759
+ */
760
+ resolve: (key: CanvasCacheKey) => Promise<CanvasPageOutcome>;
761
+ /**
762
+ * Declare a result's tags on the CURRENT `'use cache'` entry, scoped with this
763
+ * route's scope — exactly the strings the revalidate receiver revalidates (EC-620).
764
+ * Any accessor result works (`{ tags }`): a resolution from {@link resolve}, or a
765
+ * `listDocuments` result in a route of your own.
766
+ *
767
+ * It makes the {@link entryTagCalls} calls: the project sweep first and alone, the
768
+ * rest in chunks of at most 128. Next keeps 128 tags per `cacheTag()` call and drops
769
+ * the rest with only a warning, and the sweep sorts last — one `cacheTag(...tags)`
770
+ * call loses it on a page that read ~125 documents, and with it every redirect and
771
+ * asset invalidation (measured, EC-515/EC-620).
772
+ *
773
+ * Callable ONLY inside a `'use cache'` function: anywhere else Next's own `cacheTag`
774
+ * throws (E819 during a request or a prerender; E886 when neither `cacheComponents`
775
+ * nor `experimental.useCache` is enabled) — a loud error, never a silent no-op.
776
+ */
777
+ applyCacheTags: (result: {
778
+ tags: CacheTags;
779
+ }) => void;
780
+ /**
781
+ * `export const generateStaticParams = route.generateStaticParams` — needed in the
782
+ * catch-all page file only when the HOST enables `cacheComponents` (that flag's
783
+ * rule, not the kit's; measured on Next 16.2.10, EC-515): without it, awaiting the
784
+ * catch-all's `params` fails `next build` ("Uncached data was accessed outside of
785
+ * <Suspense>"). Wrapping the page in `<Suspense>` (or a root `loading.tsx`) instead
786
+ * also builds, but answers every 404 and every redirect with HTTP 200 — the status
787
+ * is committed before the content resolves. Declaring params keeps the page
788
+ * blocking, so `notFound()` is a real 404 and a redirect a real 308/307. A host
789
+ * without `cacheComponents` needs none of it.
790
+ *
791
+ * It names ONE param set: {@link CANVAS_VALIDATION_SLUG}, a sample that Next
792
+ * prerenders to validate the route and that resolves to not-found without
793
+ * reading content (the build never touches the backend for it). It fits both
794
+ * catch-all shapes (`[[...slug]]` and the scaffold's required `[...slug]`, which
795
+ * rejects an empty slug), and it is never empty (Next rejects an empty list).
796
+ * Every real URL renders on its first request and is cached from then on.
797
+ */
798
+ generateStaticParams: () => Promise<Array<{
799
+ slug: string[];
800
+ }>>;
555
801
  }
556
- /** Create the `{ Page, generateMetadata }` pair for `app/[[...slug]]/page.tsx`. */
802
+ /**
803
+ * The catch-all's build-time validation sample (see
804
+ * {@link CanvasRoute.generateStaticParams}). The path is reserved: the canvas
805
+ * route answers it with not-found and never resolves it against content.
806
+ */
807
+ declare const CANVAS_VALIDATION_SLUG: readonly string[];
808
+ /**
809
+ * THE edge mapping of Next's draft mode onto the delivery perspective (EC-595 binding
810
+ * point 1): draft mode reads the draft variant, everything else the published snapshot.
811
+ * The kit's other request-time read (`pageOpensFullBleed`) imports this one, and the
812
+ * package exports it for a host's own server components, so the mapping is declared
813
+ * once. A request API: never call it inside a cache scope.
814
+ */
815
+ declare function requestPerspective(): Promise<Perspective>;
816
+ /**
817
+ * Create the canvas route for `app/[[...slug]]/page.tsx` — `{ Page, generateMetadata,
818
+ * generateStaticParams }` to export, plus `resolve` / `applyCacheTags` for the host's
819
+ * own cache function ({@link CanvasRouteOptions.published}).
820
+ */
557
821
  declare function createCanvasRoute(options: CanvasRouteOptions): CanvasRoute;
558
822
 
823
+ /**
824
+ * A resolved document → Next.js `Metadata` (EC-171, reshaped by EC-187, and by EC-590 —
825
+ * EC-565 Round 3 "3g, extensibility"): **`seoMetadata(doc, ctx)`**, the kit's pure,
826
+ * exported SEO reader. The route's `generateMetadata` calls it (the default), so a
827
+ * project WRAPS it (`{ ...seoMetadata(doc, ctx), other }` for its own sub-fields),
828
+ * REPLACES it, or ignores it.
829
+ *
830
+ * What it reads — never a dictated field name:
831
+ * - the document's **SEO object**: the field under its collection's SEO ROLE
832
+ * (`seoField`, default a field named `seo`; the engine's `seoFields()` declares it).
833
+ * Its sub-field names are the contract ({@link SEO_SUBFIELDS}); a sub-field the object
834
+ * omits emits nothing, a sub-field the project added is theirs to read.
835
+ * - the document's **display title**: the ONE `resolveDocumentTitle` over its TITLE ROLE
836
+ * (`titleField`, default `title` → `name`) — the resolve result carries it
837
+ * (`result.title`), so a function `titleField` titles the page too.
838
+ *
839
+ * Mapping:
840
+ * - `title`: `seo.title` → the display title.
841
+ * - `description`: `seo.description`.
842
+ * - `openGraph`: title (`seo.ogTitle` → the effective title), description
843
+ * (`seo.ogDescription` → `seo.description`), image (`seo.image`) — and `locale` from the
844
+ * page's resolved locale when, and only when, the code carries a territory (EC-531,
845
+ * EC-610). The site-wide half of Open Graph (`type`, `siteName`, the site image) is the
846
+ * LAYOUT's; the canvas route merges this object over the layout's
847
+ * (`mergeMetadataOverParent`), so a page states only what is its own. Site-level SEO
848
+ * defaults live on the project's own settings document and are read by its layout —
849
+ * this reader needs none (EC-590 (b): nothing here ever read a settings document).
850
+ * - `robots`: `seo.noindex` → `{ index: false }`.
851
+ * - `alternates.canonical`: `seo.canonical` (EC-228), verbatim.
852
+ *
853
+ * Image resolution (EC-202/EC-266): `seo.image` is an asset field, delivered as a resolved
854
+ * asset object (`{ id, url, … }`) → its `url`; a string is a passthrough URL or a raw asset
855
+ * id resolved against the page's baked `assets`; anything unresolvable is dropped — an
856
+ * `og:image` must carry a real URL, never an internal id.
857
+ *
858
+ * `hreflang` alternates are intentionally NOT derived here (a per-page translation map is
859
+ * the sitemap's — EC-517).
860
+ */
861
+ interface SeoMetadataContext {
862
+ /** The SEO role field name — `result.seoField`. Default: the conventional `seo`. */
863
+ seoField?: string | null;
864
+ /** The display title — `result.title` (the title role, resolved). */
865
+ title?: string | null;
866
+ /** The page's resolved locale (`result.tags.locale`) — for `og:locale`. */
867
+ locale?: string;
868
+ /** The page-scoped baked assets (a raw asset-id `image` resolves against them). */
869
+ assets?: readonly ResolvedAsset[];
870
+ }
871
+ /** The {@link SeoMetadataContext} of a resolve result — the default route's one line. */
872
+ declare function seoContextOf(result: ResolvePageResult, assets?: readonly ResolvedAsset[]): SeoMetadataContext;
559
873
  /**
560
874
  * EC-531, narrowed by EC-610 (owner ruling 3, 2026-10-23): `og:locale` ONLY in the
561
875
  * shape Open Graph specifies — `language_TERRITORY` with an underscore (`de_DE`),
@@ -567,7 +881,10 @@ declare function createCanvasRoute(options: CanvasRouteOptions): CanvasRoute;
567
881
  * Brazil, Latin America), and a wrong valid value looks deliberate.
568
882
  */
569
883
  declare function openGraphLocale(locale: string): string | null;
570
- declare function canvasPageMetadata(result: ResolvePageResult, assets?: readonly ResolvedAsset[]): Metadata;
884
+ /** The document's SEO object (the field under its SEO role), or an empty record. */
885
+ declare function seoObjectOf(doc: Record<string, unknown> | null | undefined, seoField?: string | null | undefined): Record<string, unknown>;
886
+ /** The kit's SEO reader — see the module docs. Pure: data in, `Metadata` out. */
887
+ declare function seoMetadata(doc: Record<string, unknown> | null | undefined, ctx?: SeoMetadataContext): Metadata;
571
888
  /**
572
889
  * EC-610 (owner ruling 1, 2026-10-13): **the site provides the default tags, and
573
890
  * the page's tags are merged on top** — a value the page states overrides the
@@ -594,72 +911,39 @@ declare function canvasPageMetadata(result: ResolvePageResult, assets?: readonly
594
911
  declare function mergeMetadataOverParent(parent: ResolvedMetadata | null | undefined, page: Metadata): Metadata;
595
912
 
596
913
  /**
597
- * `sitemap.xml` from published routes (EC-171): walk the same route records
598
- * `resolvePage` resolves against, resolve every candidate URL in the
599
- * **published** perspective, and emit `MetadataRoute.Sitemap` entries per
600
- * locale — Next's `app/sitemap.ts` convention. Pages marked `noindex` (and
601
- * URLs that do not resolve, e.g. a page that was never published) are
602
- * omitted.
603
- *
604
- * Dynamic routes (`/blog/:slug`): a route record does not declare which
605
- * collection feeds its params, so enumeration is delegated to the host's
606
- * {@link SitemapParamsProvider} — it gets a published-perspective accessor
607
- * client and returns one param record per concrete URL (typically from
608
- * `listDocuments`). **Honest limit:** parameterized routes without a provider
609
- * (or provider entries missing a param) are skipped and reported in
610
- * `skipped`, never guessed. `lastModified` is omitted — the delivery shape
611
- * carries no per-page timestamps today.
612
- *
613
- * Hierarchy mounts (`/:path*`, EC-218): a page-tree mount self-describes its
614
- * URLs, so it is enumerated automatically from the collection's published
615
- * documents (composing each page's nested path from its parent chain) — no
616
- * params provider entry needed.
617
- *
618
- * **Enumeration is a candidate list, not the output (EC-486).** Every branch
619
- * above — static, provider-fed, and mount — only proposes paths; each one is
620
- * then put back through `resolvePage`, and a path the resolver refuses is
621
- * dropped. So the sitemap cannot claim a URL the delivery host would 404, even
622
- * though the mount branch enumerates its collection independently of route
623
- * precedence. Do not read the enumeration in isolation: the gate below it is
624
- * what makes the two agree. What a mount enumerated and the resolver then
625
- * refused is reported in {@link CanvasSitemapResult.skipped}, never dropped in
626
- * silence.
914
+ * `sitemap.xml` from published routes (EC-171, reshaped by EC-590): the URL of every
915
+ * published document of every collection a route names, per locale — Next's
916
+ * `app/sitemap.ts` convention. Documents marked `noindex` in their SEO object, and URLs
917
+ * that do not resolve, are omitted.
918
+ *
919
+ * **The router enumerates, no host provider (EC-590; EC-565 Round 3, 3e).** A route record
920
+ * names the collection it serves (`document.collection`), so the router's `urlsFor` —
921
+ * the same reverse direction the studio's live link and every rendered link read —
922
+ * yields one URL per published document: template routes (`/blog/:slug`), hierarchy
923
+ * mounts (`/:path*`, composed through the parent and slug roles) and literal-id routes
924
+ * alike. The former `SitemapParamsProvider` existed because "a route record does not
925
+ * declare which collection feeds its params" — false for every document-bound route
926
+ * (F6c RESTATED). A document-less static route (`/contact`, no `document`) is listed by
927
+ * its pattern.
928
+ *
929
+ * **Enumeration is a candidate list, not the output (EC-486).** Every candidate is put
930
+ * back through `resolvePage`, and a path the resolver refuses is dropped — so the sitemap
931
+ * cannot claim a URL the delivery host would 404. What was enumerated and then refused is
932
+ * reported in {@link CanvasSitemapResult.skipped}, never dropped in silence. `lastModified`
933
+ * is omitted — the delivery shape carries no per-page timestamps today.
627
934
  *
628
935
  * Host usage (`app/sitemap.ts`):
629
936
  *
630
937
  * ```ts
631
938
  * import { createCanvasSitemap } from '@elytracms/next'
632
- * import { loadContent } from '../lib/content'
633
- *
634
- * export default createCanvasSitemap({
635
- * loadContent,
636
- * baseUrl: 'https://example.com',
637
- * params: ({ route, client }) =>
638
- * route.id === 'r-post'
639
- * ? (client.listDocuments('post').ok ? … : []) // slug per published post
640
- * : null,
641
- * })
939
+ * import { elytra } from '../lib/elytra'
940
+ *
941
+ * export default createCanvasSitemap({ loadContent: elytra.loadContent, baseUrl: elytra.frontendUrl! })
642
942
  * ```
643
943
  */
644
- interface SitemapRouteContext {
645
- route: RouteRecord;
646
- locale: Locale;
647
- /** Published-perspective accessor client bound to `locale`. */
648
- client: ContentClient;
649
- }
650
- /**
651
- * Enumerate the param sets of one dynamic route — one record per concrete
652
- * URL (e.g. `[{ slug: 'hello-world' }, …]`). Return `null`/`undefined` to
653
- * skip the route (reported in `skipped`).
654
- */
655
- type SitemapParamsProvider = (ctx: SitemapRouteContext) => readonly Record<string, string>[] | null | undefined;
656
944
  interface CanvasSitemapOptions {
657
945
  /** Absolute site origin, e.g. `https://example.com` (no trailing slash needed). */
658
946
  baseUrl: string;
659
- /** Route records to enumerate; defaults to the source's own route table. */
660
- routes?: readonly RouteRecord[];
661
- /** Param enumeration for dynamic routes (see {@link SitemapParamsProvider}). */
662
- params?: SitemapParamsProvider;
663
947
  }
664
948
  interface SitemapSkippedRoute {
665
949
  routeId: string;
@@ -668,13 +952,12 @@ interface SitemapSkippedRoute {
668
952
  }
669
953
  interface CanvasSitemapResult {
670
954
  entries: MetadataRoute.Sitemap;
671
- /** Routes that could not be enumerated — explicit, never silent. */
955
+ /** Routes whose candidates could not be published — explicit, never silent. */
672
956
  skipped: SitemapSkippedRoute[];
673
957
  }
674
958
  /**
675
- * Enumerate the sitemap entries of one content source (published
676
- * perspective). Pure over the prefetched source — synchronous accessors,
677
- * deterministic ordering (sorted by URL).
959
+ * Enumerate the sitemap entries of one content source (published perspective). Pure over
960
+ * the prefetched source — synchronous accessors, deterministic ordering (sorted by URL).
678
961
  */
679
962
  declare function canvasSitemapEntries(source: ContentSource, options: CanvasSitemapOptions): CanvasSitemapResult;
680
963
  interface CreateCanvasSitemapOptions extends CanvasSitemapOptions {
@@ -691,220 +974,6 @@ interface CreateCanvasSitemapOptions extends CanvasSitemapOptions {
691
974
  */
692
975
  declare function createCanvasSitemap(options: CreateCanvasSitemapOptions): () => Promise<MetadataRoute.Sitemap>;
693
976
 
694
- /**
695
- * Cache-tag pipeline (EC-146, vision AD-5) — the pure half.
696
- *
697
- * This module defines (a) the **canonical Next.js tag string format** mapping
698
- * the accessor-level `CacheTags` (EC-143) onto `revalidateTag` keys, (b) the
699
- * **tag derivation for a revalidation event** ({@link revalidationTags}, EC-579 —
700
- * the receiver's half of the one emitter), and (c) the **tag-discovery caching
701
- * logic** the catch-all route helper runs inside `unstable_cache`. No Next.js
702
- * imports — everything here is unit-testable; `route.tsx` injects the real
703
- * `unstable_cache` via {@link CacheWrapper}.
704
- *
705
- * This is the ONE tag module: the entries are tagged here and the invalidations
706
- * are derived here, from the same formatters and the same scope. Before EC-579 the
707
- * format was mirrored twice more (the studio's browser dispatcher and an inline
708
- * literal in the Convex emitter), and each emitter brought its own scope.
709
- *
710
- * ## Tag format
711
- *
712
- * - `doc:<collection>:<id>` — one per document whose content was actually in
713
- * the response (fetch-time tagging: includes depth-1 populated relation
714
- * targets, and documents inside filtered/dynamic lists *after* the filter
715
- * ran).
716
- * - `collection:<name>:<locale>` — the coarse membership tag: every response
717
- * that read from a collection carries it, so create/delete/newly-matching
718
- * documents sweep cached lists without read-set tracking.
719
- * - `route:<pattern>:<locale>` — the resolved URL path plus every hop of a
720
- * redirect chain.
721
- * - `asset:<id>` — one per asset whose delivery shape the page *baked*
722
- * (page-scoped images + `seoOgImage`, EC-227); editing that asset record
723
- * (alt/dimensions/re-upload) drops exactly the pages that baked it. Not
724
- * locale-scoped — an asset record is locale-agnostic.
725
- * - `project` — the whole-project sweep tag attached to **every** cached
726
- * entry; every revalidation event that changes live output invalidates it
727
- * (see {@link revalidationTags} for why, until EC-515).
728
- *
729
- * ## Project scope
730
- *
731
- * Every tag is prefixed `p:<projectId>:` when a scope is configured (e.g.
732
- * `p:prj_aurora:doc:post:post-1`). The cost is a few bytes per tag and it
733
- * keeps multi-project hosts (one Next app serving several builder projects)
734
- * from cross-invalidating, so the prefix is **on whenever the host knows its
735
- * project id** — decision: include it. Since EC-579 the scope has ONE owner: the
736
- * host. The webhook carries an event, not tags, and the receiver derives the
737
- * tags with the same scope its cache entries are written under (the delivery
738
- * kit hands both the one `contentScope`), so emitter and host can no longer
739
- * disagree about it.
740
- */
741
- /** Apply the optional multi-project scope prefix to one tag. */
742
- declare function scopeCacheTag(tag: string, scope?: string): string;
743
- /**
744
- * Tag for one document identity. `docKey` is the EC-143 `documentKey` string
745
- * (`<collection>:<id>`), exactly as found in `CacheTags.docs`.
746
- */
747
- declare function docCacheTag(docKey: string, scope?: string): string;
748
- /** The coarse membership tag of one collection in one locale. */
749
- declare function collectionCacheTag(collection: string, locale: string, scope?: string): string;
750
- /** Tag for one resolved route path (or redirect-chain hop) in one locale. */
751
- declare function routeCacheTag(pattern: string, locale: string, scope?: string): string;
752
- /**
753
- * The whole-project sweep tag. Attached to every cached page entry; derived for
754
- * every revalidation event that changes live output ({@link revalidationTags}).
755
- */
756
- declare function projectSweepTag(scope?: string): string;
757
- /**
758
- * Map an accessor-level `CacheTags` (what one request actually read) onto the
759
- * full, deterministic (sorted, deduplicated) set of Next tag strings for the
760
- * cache entry — including the project sweep tag.
761
- */
762
- declare function nextCacheTags(tags: CacheTags, scope?: string): string[];
763
- /** The receiver's own project — what its cached entries are tagged with. */
764
- interface RevalidationProject {
765
- /** The tag scope of this host's cache entries (`p:<scope>:…`), if any. */
766
- scope?: string | undefined;
767
- /** Every locale this host serves — one `collection:` tag per locale. */
768
- locales: readonly string[];
769
- }
770
- /**
771
- * The tags one revalidation event invalidates on this host — sorted, deduplicated,
772
- * scoped with the receiver's OWN scope, so the emitter never needs to know it.
773
- *
774
- * - `publish` / `unpublish` / `delete` → `doc:<collection>:<id>` (its detail page and
775
- * every cached list that contained it) + `collection:<collection>:<locale>` for
776
- * EVERY locale this host serves (list membership may have changed) + the sweep;
777
- * - `redirects` → the sweep (routing can change any URL; see the `redirects` trigger
778
- * in the studio's `convex/triggers.ts` for why a targeted `route:` tag is wrong);
779
- * - `asset` → `asset:<id>` + the sweep (a page reading the asset through a document
780
- * asset FIELD carries no `asset:` tag — EC-566 D16, EC-589);
781
- * - `probe` → nothing (the doctor's no-op ping).
782
- *
783
- * **Why every live-output event also carries the project sweep — a deliberate,
784
- * measured deviation from the EC-579 ruling's text, which lists the sweep only for
785
- * redirects and asset removes.** A self-hosted Next host honours ONLY the baseline
786
- * tags of a cache read (measured in EC-515 on Next 16.2.10 — see
787
- * {@link loadCachedEntry}): a discovered `doc:` / `collection:` / `asset:` tag
788
- * invalidates nothing there, silently, and Vercel is unmeasured. Until EC-515's
789
- * option D makes the precise tags honoured, the precise tags alone would turn
790
- * "revalidates coarsely" into "never revalidates" on every self-hosted host — so the
791
- * sweep stays on every event, exactly as the Convex path sent it before. Dropping it
792
- * from a publish is EC-515's job, together with the read that makes it safe.
793
- */
794
- declare function revalidationTags(event: RevalidationEvent, project: RevalidationProject): string[];
795
- /**
796
- * The slice of `unstable_cache` the discovery logic needs, injectable so the
797
- * logic is testable against a faithful in-memory model (and so the test model
798
- * can mirror the real semantics this design depends on — see below).
799
- */
800
- interface CacheWrapperOptions {
801
- tags: string[];
802
- /** `false` = never expire by time (tag-driven invalidation only). */
803
- revalidate: number | false;
804
- }
805
- type CacheWrapper = <T>(cb: () => Promise<T>, keyParts: string[], options: CacheWrapperOptions) => () => Promise<T>;
806
- /** What one cached request stores: the outcome plus its own discovered tags. */
807
- interface CachedEntry<T> {
808
- value: T;
809
- /** The Next tag strings discovered at fetch time by the run that wrote this entry. */
810
- tags: string[];
811
- }
812
- /**
813
- * Fetch-time tag discovery over `unstable_cache` — the heart of EC-146.
814
- *
815
- * The problem: `unstable_cache` fixes an entry's tags when the *wrapper* is
816
- * created (`options.tags` is validated and **copied** at construction time in
817
- * Next 15 — mutating the array during the callback does not propagate), but
818
- * the correct tags are only known *after* the loader resolved the page. So a
819
- * single wrapper can never both (a) run the loader and (b) store the result
820
- * under the tags that run discovered.
821
- *
822
- * The design ("resolve once, cache with discovered tags, serve cached
823
- * thereafter") uses two wrapper constructions per request around **one**
824
- * cache entry:
825
- *
826
- * 1. **Probe pass** — a wrapper keyed by `keyParts` whose callback runs the
827
- * loader, captures the result + its discovered tags into the closure, and
828
- * then throws an internal sentinel.
829
- * - Cache **hit**: the stored entry (written by a previous request with
830
- * its full discovered tags) is returned; the callback — and therefore
831
- * the loader and any backend access — never runs. This is the outage
832
- * story: fully cached routes keep serving when the backend is down.
833
- * - Cache **miss**: the loader runs exactly once; the sentinel throw
834
- * prevents `unstable_cache` from persisting the entry under the
835
- * incomplete baseline tags (thrown callbacks cache nothing).
836
- * 2. **Write pass** (miss only) — a second wrapper is constructed *now that
837
- * the tags are known*, with `options.tags` = the discovered set, same key.
838
- * Its callback just returns the already-loaded entry, so the backend is
839
- * not hit again; `unstable_cache` persists the entry under the full
840
- * fetch-time tag set. `revalidateTag` on any of those tags drops the
841
- * entry and the next request repeats the cycle with fresh data.
842
- *
843
- * The loader runs **once per revalidation cycle** (per key), never once per
844
- * request. Time-based revalidation is intentionally not exposed, and the reason
845
- * is a real hazard rather than a preference: a stale-while-revalidate background
846
- * refresh re-runs the probe callback, whose bail aborts the refresh, so the
847
- * entry is never rewritten. `revalidate: false` keeps *time* from ever putting
848
- * an entry into that state — which is the AD-5 model.
849
- *
850
- * **What `revalidate: false` does NOT buy (EC-515, measured on Next 16.2.10).**
851
- * The earlier version of this comment claimed `revalidate: false` made the SWR
852
- * hazard unreachable. It does not: a **tag** can put an entry into the stale
853
- * state too, and then exactly the aborted-refresh loop above happens. Whether it
854
- * does is decided by how the invalidator stamps the tag —
855
- * `revalidateTag(tag, 'max')` marks entries stale (365-day expiry) and triggers
856
- * the loop; `revalidateTag(tag, { expire: 0 })` drops them outright and does
857
- * not. `revalidate.ts` therefore sends `{ expire: 0 }`, and the reasoning lives
858
- * at that call site. Keep the two in step: this design only survives an
859
- * invalidation that **drops** the entry, never one that merely staleness-marks
860
- * it.
861
- *
862
- * Concurrency: wrappers and the captured closure are per-request, so there is
863
- * no cross-request mutation; two racing first requests both resolve and the
864
- * last write wins (deterministic content ⇒ identical entries).
865
- *
866
- * ## ⚠️ Self-hosted hosts: only the BASELINE tags invalidate (EC-515)
867
- *
868
- * **Measured on a self-hosted `next start` (Next 16.2.10, default
869
- * `FileSystemCache`, `.next/cache` on disk); NOT measured on Vercel.**
870
- *
871
- * Fetch-time tag discovery writes the entry with the tags the response actually
872
- * read — but the default self-hosted cache handler decides staleness and expiry
873
- * from the tags the **reading** wrapper names (`ctx.tags`, see
874
- * `IncrementalCache.get` / `FileSystemCache.get` for `kind: FETCH`), not from
875
- * the tags the entry was **stored** with. The stored tag set is only ever used
876
- * to decide whether to re-write the entry with a superset.
877
- *
878
- * Every read in {@link loadCachedEntry} goes through the **probe** wrapper,
879
- * whose tags are `baselineTags`. So on such a host:
880
- *
881
- * - revalidating a tag that is in `baselineTags` works (the route helper passes
882
- * the project sweep tag, so `project` sweeps do invalidate);
883
- * - revalidating a *discovered* `doc:` / `collection:` / `route:` / `asset:` tag
884
- * invalidates **nothing** — silently. No error, no log line, no loader run.
885
- *
886
- * Measured 2×2 that isolates it (same host, same run): a probe handed the full
887
- * discovered tag set up front DOES honour a `doc:` tag, and the same probe
888
- * handed only the baseline does not. So the tags, their formatting and the
889
- * webhook are all correct — the read simply never consults them.
890
- *
891
- * Consequence for a self-hosted deployment: invalidation granularity collapses
892
- * to whatever is in `baselineTags`. {@link revalidationTags} therefore adds the
893
- * project sweep to every event that changes live output (EC-579), so instant
894
- * publish still *works* there, coarsely; its targeted `doc:`/`collection:`/`asset:`
895
- * tags do not reach a self-hosted host's entries at all. Vercel-hosted deployments
896
- * are unaffected in practice — both consumer
897
- * sites were measured healthy on 2026-08-01 — which is consistent with Vercel's
898
- * cache handler purging by the entry's stored tags, though that has **not** been
899
- * measured here.
900
- *
901
- * Fixing this properly means the read must be able to name the entry's own tags,
902
- * which no `unstable_cache` API allows without restructuring the probe/bail
903
- * seam. That is a deliberate open decision — see EC-515, do not "fix" it here
904
- * incidentally.
905
- */
906
- declare function loadCachedEntry<T>(cache: CacheWrapper, keyParts: string[], baselineTags: readonly string[], load: () => Promise<CachedEntry<T>>): Promise<CachedEntry<T>>;
907
-
908
977
  /**
909
978
  * Pure request logic of the revalidation webhook receiver (EC-146), kept free of
910
979
  * Next.js imports so it is unit-testable. `revalidate.ts` wires it into a route
@@ -1085,20 +1154,12 @@ interface AssetImageLoaderOptions {
1085
1154
  */
1086
1155
  declare function createAssetImageLoader(options?: AssetImageLoaderOptions): ImageLoader;
1087
1156
  /**
1088
- * The `base.Image` implementation for Next.js host apps: same
1089
- * canonical manifest, rendered optimized through {@link ElytraImage}. The
1090
- * renderer resolves an `asset`-typed `src` (EC-195) to `{ url, width, height,
1091
- * alt }` before this runs, so `normalizeImageSource` yields intrinsic dimensions
1092
- * (→ no layout shift, sized variants) whether the editor picked an uploaded asset
1093
- * or pasted a URL. `priority` opts an above-the-fold image out of lazy-loading.
1094
- */
1095
- /**
1096
- * Per-deployment image-delivery options for {@link nextImagePrimitive} (EC-271).
1157
+ * Per-deployment image-delivery options for {@link withNextImage} (EC-271).
1097
1158
  * The default is the host's own Next.js optimizer (Vercel) — zero config. A host
1098
1159
  * on a different CDN supplies its own `loader` (a plain function) to route variants
1099
1160
  * through that CDN instead, without touching component code.
1100
1161
  */
1101
- interface NextImagePrimitiveOptions {
1162
+ interface NextImageOptions {
1102
1163
  /**
1103
1164
  * Custom `next/image` loader — the override seam for non-Vercel hosting. Omit
1104
1165
  * for the Vercel default (Next's own `/_next/image` optimizer). Supply a
@@ -1117,31 +1178,35 @@ interface NextImagePrimitiveOptions {
1117
1178
  quality?: number;
1118
1179
  }
1119
1180
  /**
1120
- * Host registration that swaps the `base.Image` implementation
1121
- * for {@link ElytraImage} while keeping the primitive's canonical manifest
1122
- * (the first registration's manifest wins in `defineHostComponents` — by
1123
- * design, so prop schemas stay canonical; the duplicate-id registry issue it
1124
- * reports is the documented override signal, not an error):
1181
+ * Batteries included, opted into (EC-591): return the component list with the
1182
+ * implementation of the component that fills the `image` role swapped for
1183
+ * `next/image` delivery ({@link ElytraImage}) — the manifest is kept, so the
1184
+ * studio and the host still register the same component. Nothing is added to the
1185
+ * list and nothing is registered by the engine: no image-role component in the
1186
+ * list → the list comes back unchanged.
1187
+ *
1188
+ * Which entry fills the role is decided by core's one registration rule (the first
1189
+ * valid claimant — `ComponentRegistry.roleComponent('image')`), so the swap can
1190
+ * never land on a different entry than the one the registry resolves. Use it on
1191
+ * the DELIVERY side only (the studio's canvas runs on Vite, where `next/image`
1192
+ * does not exist) — the scaffold's host seam file does it in one line:
1125
1193
  *
1126
1194
  * ```ts
1127
- * export const hostComponents = defineHostComponents([
1128
- * nextImagePrimitive(), // Vercel default optimizer
1129
- * // ...project components
1130
- * ])
1195
+ * export const elytra = createElytraHost({ ...site, components: withNextImage(site.components) })
1131
1196
  * ```
1132
1197
  *
1133
- * Pass {@link NextImagePrimitiveOptions} to delegate image delivery to another
1134
- * CDN (EC-271) — the v1 default is Vercel, with room to grow:
1198
+ * Pass {@link NextImageOptions} to delegate image delivery to another CDN
1199
+ * (EC-271) — the v1 default is Vercel, with room to grow:
1135
1200
  *
1136
1201
  * ```ts
1137
- * nextImagePrimitive({ loader: createAssetImageLoader() }) // ?w=&q= CDN
1138
- * nextImagePrimitive({ loader: cloudflareImageLoader }) // Cloudflare Images
1202
+ * withNextImage(site.components, { loader: createAssetImageLoader() }) // ?w=&q= CDN
1203
+ * withNextImage(site.components, { loader: cloudflareImageLoader }) // Cloudflare Images
1139
1204
  * ```
1140
1205
  */
1141
- declare function nextImagePrimitive(options?: NextImagePrimitiveOptions): HostComponent;
1206
+ declare function withNextImage(components: readonly HostComponentEntry[] | undefined, options?: NextImageOptions): HostComponentEntry[];
1142
1207
  /** Per-deployment options for {@link elytraHostImage} (the EC-271 loader seam). */
1143
1208
  interface ElytraHostImageOptions {
1144
- /** Custom `next/image` loader for non-Vercel CDNs (see {@link NextImagePrimitiveOptions}). */
1209
+ /** Custom `next/image` loader for non-Vercel CDNs (see {@link NextImageOptions}). */
1145
1210
  loader?: ImageLoader;
1146
1211
  /** Default quality (1–100) when a call site doesn't set one (Next defaults to 75). */
1147
1212
  quality?: number;
@@ -1366,6 +1431,8 @@ declare function createPreviewDisableRoute(): PreviewRouteHandlers;
1366
1431
  * const route = elytra.canvasRoute()
1367
1432
  * export default route.Page
1368
1433
  * export const generateMetadata = route.generateMetadata
1434
+ * export const generateStaticParams = route.generateStaticParams // only if YOU enable cacheComponents
1435
+ * // (to cache: pass your own 'use cache' function as `published` — see `route.tsx`, EC-620)
1369
1436
  *
1370
1437
  * // app/api/revalidate/route.ts
1371
1438
  * export const { POST } = elytra.revalidateRoute()
@@ -1384,7 +1451,18 @@ declare function createPreviewDisableRoute(): PreviewRouteHandlers;
1384
1451
  * keeps ownership of project config (AD-11), host components, form endpoints,
1385
1452
  * and anything genuinely bespoke.
1386
1453
  */
1387
- interface ElytraHostOptions {
1454
+ /**
1455
+ * EC-581 (ratified 2b): the options are generic over the config's `collections`, so a
1456
+ * project declared with `defineCollection` keeps its literal types through the host and
1457
+ * `InferDocument<typeof host, 'post'>` (`@elytracms/core/cms-core`) is a delivered post.
1458
+ */
1459
+ interface ElytraHostOptions<Collections extends readonly CollectionDef[] = readonly CollectionDef[]> extends Omit<WorkspaceProjectEntry, 'slug' | 'locales' | 'collections' | 'routes' | 'components' | 'frontendUrl'> {
1460
+ /**
1461
+ * The project's display name (the entry's own `name` — `{ ...site }` carries it).
1462
+ * EC-583: the options ARE the config's project entry plus the host-only keys below,
1463
+ * and the kit resolves them through the one resolver the studio boots through.
1464
+ */
1465
+ name: string;
1388
1466
  /**
1389
1467
  * The project slug (config-as-code identity, mirrors `elytra.config.ts`).
1390
1468
  * Used as the content scope when no live backend is configured.
@@ -1415,14 +1493,17 @@ interface ElytraHostOptions {
1415
1493
  */
1416
1494
  locales: LocaleConfigInput;
1417
1495
  /** CMS collection schema — config-owned (AD-11). */
1418
- collections: readonly CollectionDef[];
1496
+ collections: Collections;
1419
1497
  /** Routes as code (AD-3/AD-11): records, or the `(collections) => records` derive. */
1420
1498
  routes: readonly RouteRecord[] | ((collections: CollectionDef[]) => RouteRecord[]);
1421
1499
  /**
1422
1500
  * The repo's page-builder components (manifest + implementation pairs — the
1423
- * SAME modules the studio composes with). Base primitives are always
1424
- * included; `base.Image` is swapped for `next/image` delivery by default
1425
- * (see `image`).
1501
+ * SAME list the studio composes with, usually `site.components` via the
1502
+ * `{ ...site }` spread). The host registers exactly this list (EC-591): the
1503
+ * engine adds no components of its own — the shipped ones are the opt-in
1504
+ * `@elytracms/components` package, listed in the config. For `next/image`
1505
+ * delivery of the image-role component, pass the list through
1506
+ * `withNextImage(site.components)` (one line, opt-in).
1426
1507
  *
1427
1508
  * Typed as the CONFIG's `HostComponentEntry` (`implementation: unknown`), not
1428
1509
  * the stricter {@link HostComponent}, so that `createElytraHost({ ...site })`
@@ -1439,7 +1520,7 @@ interface ElytraHostOptions {
1439
1520
  * component. The check is RUNTIME instead — `defineHostComponents` takes the
1440
1521
  * same `HostComponentEntry` shape (no cast, one signature) and tests every
1441
1522
  * implementation on registration; a value that is not a component becomes an
1442
- * `invalid-implementation` issue in `components.issues` and is withheld from
1523
+ * `invalid-implementation` issue in `components.registry.issues` and is withheld from
1443
1524
  * the implementation map, so its nodes render the visible missing-component
1444
1525
  * fallback rather than crashing the server render. Authoring a component
1445
1526
  * module as the strict {@link HostComponent} keeps the compile-time check at
@@ -1447,14 +1528,9 @@ interface ElytraHostOptions {
1447
1528
  */
1448
1529
  components?: readonly HostComponentEntry[];
1449
1530
  /**
1450
- * `base.Image` delivery options (loader/sizes/quality — the non-Vercel CDN
1451
- * seam), or `false` to keep the plain-`<img>` primitive. Default: Next's own
1452
- * optimizer, registered out of the box.
1453
- */
1454
- image?: NextImagePrimitiveOptions | false;
1455
- /**
1456
- * The public base URL of the delivery site (mirrors the config's
1457
- * `frontendUrl`) — exposed on the handle for sitemap/canonical use.
1531
+ * The public base URL of the delivery site (the config's project-level
1532
+ * `frontendUrl`). The handle's {@link ElytraHost.frontendUrl} is the RESOLVED one —
1533
+ * the bound environment's own URL when it declares one (EC-583).
1458
1534
  */
1459
1535
  frontendUrl?: string;
1460
1536
  /**
@@ -1491,17 +1567,18 @@ interface HostCanvasRouteOptions {
1491
1567
  */
1492
1568
  wrap?: (children: ReactNode, ctx: CanvasWrapContext) => ReactNode;
1493
1569
  /**
1494
- * Tagged data cache (instant publish). Always on and always scoped to the kit's
1495
- * {@link ElytraHost.contentScope}, with the kit's
1496
- * {@link ElytraHost.contentMode} in the entry key (EC-543). `false` disables
1497
- * caching entirely.
1570
+ * The host's cache function for published requests (EC-620 — see
1571
+ * `CanvasRouteOptions.published`): your own `'use cache'` function that calls
1572
+ * `route.resolve(key)`, `route.applyCacheTags(outcome)` and picks its `cacheLife`.
1573
+ * Omit it and the route resolves every request fresh (dynamic, uncached).
1498
1574
  *
1499
- * There is no scope override (EC-579): the kit's revalidate receiver derives its
1500
- * tags with that same `contentScope`, so one value scopes both the entries and
1501
- * their invalidation. An override here was the one way left for the two to
1502
- * disagree — and a disagreement drops nothing, silently.
1575
+ * The route it builds is always scoped to the kit's {@link ElytraHost.contentScope}
1576
+ * with the kit's {@link ElytraHost.contentMode} in every cache key (EC-543), and
1577
+ * there is no override (EC-579): the kit's revalidate receiver derives its tags with
1578
+ * that same `contentScope`, so one value scopes both the entries and their
1579
+ * invalidation — a disagreement would drop nothing, silently.
1503
1580
  */
1504
- cache?: false;
1581
+ published?: CanvasRouteOptions['published'];
1505
1582
  }
1506
1583
  interface LoadGlobalsOptions {
1507
1584
  /** Perspective of the chrome read. Default `'published'` (live is published-only). */
@@ -1525,18 +1602,24 @@ interface LoadGlobalsOptions {
1525
1602
  * once, read here, so a host can *say* which world it is in instead of
1526
1603
  * re-deriving it from `env` and drifting.
1527
1604
  *
1528
- * **The mode is also part of the route's data-cache key** (EC-543). A cache
1529
- * entry outlives the process that wrote it and a hit never runs the loader, so
1530
- * until 0.0.85 the "clean 404" above held only on a COLD cache: measured, a
1531
- * `next start` with both env vars blanked and a warm `.next/cache` from a live
1532
- * run answered `200` with the live world's bytes on every cached URL while
1533
- * reporting `empty` — the wrong-world failure EC-527 removed, one layer down.
1534
- * Keyed on the mode (`canvasCacheKey`, `cache.ts`), a world change is a cold
1535
- * miss per URL, never a stale hit, in either direction.
1605
+ * **The mode is also part of the route's cache-entry key** (EC-543; since EC-515
1606
+ * an argument of the cached function). An entry can outlive the world that wrote
1607
+ * it and a hit never runs the loader, so until 0.0.85 the "clean 404" above held
1608
+ * only on a COLD cache: measured, a `next start` with both env vars blanked and a
1609
+ * warm `.next/cache` from a live run answered `200` with the live world's bytes
1610
+ * on every cached URL while reporting `empty` — the wrong-world failure EC-527
1611
+ * removed, one layer down. Keyed on the mode (`canvasCacheKey`, `cache.ts`), a
1612
+ * world change is a cold miss per URL, never a stale hit, in either direction.
1536
1613
  */
1537
1614
  type ElytraContentMode = 'live' | 'fallback' | 'empty';
1538
1615
  /** The delivery host handle {@link createElytraHost} yields. */
1539
- interface ElytraHost {
1616
+ interface ElytraHost<Collections extends readonly CollectionDef[] = readonly CollectionDef[]> {
1617
+ /**
1618
+ * The config's collections, as passed (EC-581) — typed by their literals when the
1619
+ * project declares them with `defineCollection`, so `InferDocument<typeof host, 'post'>`
1620
+ * types a delivered post with no generation step.
1621
+ */
1622
+ readonly collections: Collections;
1540
1623
  /**
1541
1624
  * The cache-tag scope: `ELYTRA_PROJECT_ID` in live mode, else the config slug.
1542
1625
  * The ONE scope of this host (EC-579): its cache entries are written under it and
@@ -1552,22 +1635,49 @@ interface ElytraHost {
1552
1635
  contentMode: ElytraContentMode;
1553
1636
  /** The resolved env bag (documented names, trimmed, empty = unset). */
1554
1637
  env: ElytraHostEnv;
1555
- /** Materialized route records (a derive is run once, at factory time). */
1638
+ /**
1639
+ * The ONE resolved project (EC-583) — the options resolved through
1640
+ * `resolveProject`, the same rules the studio boots through: composed, defaulted,
1641
+ * validation slots normalized, routes materialized, every boot rule applied. The
1642
+ * kit reads its locales, collections, routes and frontend URL from here.
1643
+ */
1644
+ project: ResolvedProject;
1645
+ /**
1646
+ * The project's route issue set at boot (EC-590 — the ONE router's: route conflicts,
1647
+ * mount conflicts, reachability). Surfaced on the console at build and in dev; the
1648
+ * editorial redirects (data) are judged per snapshot by the content client's
1649
+ * `routerIssues` and in the studio.
1650
+ */
1651
+ routeIssues: readonly CmsValidationIssue[];
1652
+ /** Materialized route records (the resolved project's; a derive ran once, at factory time). */
1556
1653
  routes: RouteRecord[];
1557
- /** The registered component surface (base primitives + image swap + project components). */
1654
+ /** The registered component surface — exactly the `components` option's list (EC-591). */
1558
1655
  components: HostComponents;
1559
- /** The public base URL of the delivery site, when configured. */
1656
+ /**
1657
+ * The public base URL of the delivery site, when configured — the resolved project's
1658
+ * (`projectFrontendUrl`): the bound environment's own `frontendUrl`, else the
1659
+ * project-level one. The same answer the studio's live links read (EC-583).
1660
+ */
1560
1661
  frontendUrl?: string;
1561
1662
  /**
1562
1663
  * The content loader (the seam every kit surface reads through): live
1563
1664
  * delivery snapshot when env is configured — published, or the token-gated
1564
1665
  * draft for draft-mode requests — else the fallback source.
1666
+ *
1667
+ * An UNCACHED read: the kit caches nothing itself (EC-620 — caching is the host's).
1668
+ * Under `cacheComponents`, live-mode I/O in a layout or page outside `<Suspense>` fails
1669
+ * `next build` — call it inside a `<Suspense>`'d component (route handlers and
1670
+ * `sitemap.ts` just render dynamically).
1565
1671
  */
1566
1672
  loadContent(request: CanvasContentRequest): Promise<ContentSource>;
1567
1673
  /**
1568
1674
  * Load + resolve the site-chrome globals (settings singleton, navigation by
1569
1675
  * slot alias, link/asset resolution — see `host-globals.ts`). One content
1570
1676
  * load per call; published perspective by default.
1677
+ *
1678
+ * An UNCACHED read, like {@link loadContent}: under `cacheComponents`, render the
1679
+ * chrome that awaits it inside `<Suspense>` — and keep the canvas page OUTSIDE
1680
+ * every boundary, or its 404s and redirects answer HTTP 200 (measured, EC-515).
1571
1681
  */
1572
1682
  loadGlobals(options?: LoadGlobalsOptions): Promise<SiteGlobals>;
1573
1683
  /** The catch-all canvas route pair for `app/[[...slug]]/page.tsx`. */
@@ -1592,12 +1702,13 @@ interface ElytraHost {
1592
1702
  /**
1593
1703
  * Does the page for this catch-all `slug` OPEN full-bleed? Manifest-driven
1594
1704
  * (`presentation.fullBleed`, EC-368) and draft-mode aware — the host lays out
1595
- * header clearance from data instead of a hand-kept component-id set.
1705
+ * header clearance from data instead of a hand-kept component-id set. An
1706
+ * uncached read (see {@link loadContent}).
1596
1707
  */
1597
1708
  pageOpensFullBleed(slug: readonly string[] | undefined): Promise<boolean>;
1598
1709
  }
1599
1710
  /** Create the delivery host handle — see the module docs for the full wiring. */
1600
- declare function createElytraHost(options: ElytraHostOptions): ElytraHost;
1711
+ declare function createElytraHost<const Collections extends readonly CollectionDef[]>(options: ElytraHostOptions<Collections>): ElytraHost<Collections>;
1601
1712
 
1602
1713
  /**
1603
1714
  * The LIVE half of the delivery host kit (EC-368): fetch the delivery snapshot
@@ -1616,10 +1727,10 @@ declare function createElytraHost(options: ElytraHostOptions): ElytraHost;
1616
1727
  * `DRAFT_PREVIEW_TOKEN` — the same single secret that gates the kit's
1617
1728
  * preview-enable route (see `host-env.ts`).
1618
1729
  *
1619
- * Routes: config-sourced route records (AD-3/AD-11, EC-207 — the host repo's
1620
- * own `cms/`, passed via options) and the implicit per-locale home fallback are
1621
- * MERGED (EC-177 gap 2), never XOR'd — config records win per pattern+locale.
1622
- * The backend never carries route data.
1730
+ * Routes: the config's route records (AD-3/AD-11, EC-207), exactly as resolved —
1731
+ * the backend never carries route data, and the engine derives none (EC-590: the
1732
+ * implicit "`/` → the lowest-id `page`" home fallback is gone with every other
1733
+ * engine-dictated name; a project declares its `/`, the scaffold ships one).
1623
1734
  */
1624
1735
  interface LiveContentOptions {
1625
1736
  /** Convex deployment URL, e.g. `https://your-deployment-123.convex.cloud`. */
@@ -1632,7 +1743,7 @@ interface LiveContentOptions {
1632
1743
  locales: LocaleConfigInput;
1633
1744
  /** CMS collection schema — config-owned (AD-11); drives depth-1 relation population. */
1634
1745
  collections: readonly CollectionDef[];
1635
- /** Route records — config-owned (AD-3/AD-11); merged with the home fallback. */
1746
+ /** Route records — config-owned (AD-3/AD-11), the resolved project's. */
1636
1747
  routes: readonly RouteRecord[];
1637
1748
  /** `DRAFT_PREVIEW_TOKEN` — required for draft-perspective loads. */
1638
1749
  draftToken?: string;
@@ -1652,7 +1763,6 @@ interface DeliverySnapshotPayload {
1652
1763
  documents: unknown[];
1653
1764
  assets: unknown[];
1654
1765
  }
1655
-
1656
1766
  /**
1657
1767
  * Assemble a fetched delivery-snapshot payload into a `ContentSource` — the
1658
1768
  * pure half of {@link loadLiveContent}, exported so the parsing/assembly rules
@@ -1754,4 +1864,4 @@ declare function evaluatePreviewRequest(requestUrl: string | URL, expectedToken:
1754
1864
  */
1755
1865
  declare const PACKAGE = "@elytracms/next";
1756
1866
 
1757
- export { type AssetImageLoaderOptions, type CacheWrapper, type CacheWrapperOptions, type CachedEntry, type CanvasCacheOptions, type CanvasContentRequest, type CanvasPageOutcome, 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 NextImagePrimitiveOptions, PACKAGE, type PreviewEvaluation, type PreviewRouteHandlers, type PreviewRouteOptions, type ResolveCanvasPageOptions, type ResolveGlobalsOptions, type ResolvedLink, type ResolvedNavEntry, type ResolvedNavLink, type RevalidateEvaluation, type RevalidateRouteHandlers, type RevalidateRouteOptions, type RevalidationProject, type SiteGlobals, type SitemapParamsProvider, type SitemapRouteContext, type SitemapSkippedRoute, type StaticContentData, assembleLiveSource, bodyItemsOf, canvasPageMetadata, canvasSitemapEntries, collectionCacheTag, createAssetImageLoader, createCanvasRoute, createCanvasSitemap, createContentSnapshot, createElytraHost, createPreviewDisableRoute, createPreviewRoute, createRevalidateRoute, createStaticContentSource, defineHostComponents, docCacheTag, elytraHostImage, evaluatePreviewRequest, evaluateRevalidateRequest, firstBlockFullBleed, loadCachedEntry, loadLiveContent, mergeCacheTags, mergeMetadataOverParent, nextCacheTags, nextImagePrimitive, openGraphLocale, pathFromSlug, projectSweepTag, resolveCanvasPage, resolveGlobals, resolveHostEnv, revalidationTags, routeCacheTag, safeRedirectTarget, scopeCacheTag };
1867
+ export { type AssetImageLoaderOptions, type BakedListing, CANVAS_VALIDATION_SLUG, type CanvasCacheKey, type CanvasContentRequest, type CanvasPageOutcome, 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 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 NextImageOptions, PACKAGE, type PreviewEvaluation, type PreviewRouteHandlers, type PreviewRouteOptions, type ResolveCanvasPageOptions, type ResolveGlobalsOptions, type ResolvedLink, type ResolvedNavEntry, type ResolvedNavLink, type RevalidateEvaluation, type RevalidateRouteHandlers, type RevalidateRouteOptions, type RevalidationProject, type SeoMetadataContext, type SiteGlobals, type SitemapSkippedRoute, type StaticContentData, assembleLiveSource, bodyItemsOf, canvasSitemapEntries, collectionCacheTag, createAssetImageLoader, createCanvasRoute, createCanvasSitemap, createContentSnapshot, createElytraHost, createPreviewDisableRoute, createPreviewRoute, createRevalidateRoute, createStaticContentSource, defineHostComponents, docCacheTag, elytraHostImage, evaluatePreviewRequest, evaluateRevalidateRequest, firstBlockFullBleed, loadLiveContent, mergeCacheTags, mergeMetadataOverParent, nextCacheTags, openGraphLocale, pathFromSlug, projectSweepTag, requestPerspective, resolveCanvasPage, resolveGlobals, resolveHostEnv, revalidationTags, routeCacheTag, safeRedirectTarget, scopeCacheTag, seoContextOf, seoMetadata, seoObjectOf, withNextImage };