@elytracms/next 0.0.91 → 0.0.93

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.js CHANGED
@@ -1,11 +1,10 @@
1
- import { registerComponents, renderDocumentBodyItems, documentBodyItems, serializeListingQuery, documentBodyRichTextValues, documentBodyCompositionNodes, richTextEmbedsInValue, collectReferencedAssetIds, collectRichTextRefsInValue, collectReferencedRelations, collectReferencedListings, collectReferencedRichTextLinks, RenderErrorBoundary, nodeListingOf, listingRequestOf } from '@elytracms/core/runtime-renderer';
2
- import { Fragment as Fragment$1, isValidElement } from 'react';
1
+ import { registerComponents, createRenderContext, renderDocumentBodyItems, documentBodyItems, serializeListingQuery, documentBodyRichTextValues, documentBodyCompositionNodes, richTextEmbedsInValue, collectReferencedAssetIds, collectRichTextRefsInValue, collectReferencedRelations, collectReferencedListings, collectReferencedRichTextLinks, listingRequestOf } from '@elytracms/core/runtime-renderer';
3
2
  import { isBlockSequenceValueShape } from '@elytracms/core/rich-text';
4
- import { jsx, Fragment, jsxs } from 'react/jsx-runtime';
5
- import { cacheTags, isDocumentHistory, resolvedAsset, createContentClient, createContentContext } from '@elytracms/core/content';
6
- import { parseLocaleConfig, documentKey, splitLocalePath, hierarchyMount, composePath, localeUrlPath, mergeRouteRecords, fallbackHomeRoutes, presentationBodyFields, splitPath, documentSchema, redirectRecordSchema } from '@elytracms/core/cms-core';
7
- export { documentSchema, fallbackHomeRoutes, localeConfigSchema, localeUrlPath, mergeRouteRecords, parseLocaleConfig, redirectRecordSchema, resolveLocale, routeRecordSchema, splitLocalePath, textDirection } from '@elytracms/core/cms-core';
8
- import { revalidateTag, cacheLife, cacheTag } from 'next/cache';
3
+ import { jsx, Fragment } from 'react/jsx-runtime';
4
+ import { cacheTags, isDocumentHistory, resolvedAsset, parseDeliveredDocuments, createContentClient, createContentContext } from '@elytracms/core/content';
5
+ import { DEFAULT_SEO_FIELD, parseLocaleConfig, documentKey, liveRedirectRecords, SEO_SUBFIELDS, splitLocalePath, DRAFT_PERSPECTIVE, splitPath, localeUrlPath, variantOf, DRAFT_VARIANT, createRouter, presentationBodyFields, redirectRecordSchema } from '@elytracms/core/cms-core';
6
+ export { DRAFT_PERSPECTIVE, documentSchema, localeConfigSchema, localeUrlPath, parseLocaleConfig, redirectRecordSchema, resolveLocale, routeRecordSchema, splitLocalePath, textDirection, variantOf } from '@elytracms/core/cms-core';
7
+ import { revalidateTag, cacheTag } from 'next/cache';
9
8
  import { draftMode } from 'next/headers';
10
9
  import { redirect, notFound, permanentRedirect } from 'next/navigation';
11
10
  import { COMPOSITE_KEY_SEPARATOR } from '@elytracms/core/project-graph';
@@ -13,6 +12,7 @@ import { verifyRevalidationSignature, readRevalidationBody, REVALIDATE_SIGNATURE
13
12
  export { REVALIDATE_SIGNATURE_HEADER, revalidationEventSchema, signRevalidationBody, signedRevalidationRequest } from '@elytracms/core/revalidation';
14
13
  import NextImage from 'next/image';
15
14
  import { normalizeImageSource } from '@elytracms/components';
15
+ import { projectFrontendUrl, resolveProject } from '@elytracms/core/studio-core';
16
16
  export { defineComponent } from '@elytracms/core/component-registry';
17
17
  export { z } from 'zod';
18
18
 
@@ -33,33 +33,15 @@ function listingResolver(listings) {
33
33
  return Array.isArray(baked) ? baked : null;
34
34
  };
35
35
  }
36
- function listingFailureOf(node, components, listings) {
37
- const manifest = components.registry.getManifest(node.componentId);
38
- if (!manifest) return null;
39
- const listing = nodeListingOf(node, manifest, components.registry);
40
- if (!listing || listing.state === "awaiting") return null;
41
- if (listing.state === "invalid") return listing.messages;
42
- const baked = listings?.[serializeListingQuery(listing.query)];
43
- return baked && !Array.isArray(baked) ? baked.errors.map((error) => error.message) : null;
44
- }
45
- function InvalidListingNotice(props) {
46
- return /* @__PURE__ */ jsx("div", { "data-ec-fallback": "invalid-listing", "data-ec-node-id": props.nodeId, role: "note", children: `This listing can't run: ${props.messages.join(" ")}` });
36
+ function bakedListingFailure(listings) {
37
+ return (query) => {
38
+ const baked = listings[serializeListingQuery(query)];
39
+ return baked && !Array.isArray(baked) ? baked.errors.map((error) => error.message) : null;
40
+ };
47
41
  }
48
42
  function assetResolver(assets) {
49
43
  const byId = new Map(assets.map((asset) => [asset.id, asset]));
50
- return (id) => {
51
- const asset = byId.get(id);
52
- return asset ? {
53
- url: asset.url,
54
- width: asset.width,
55
- height: asset.height,
56
- alt: asset.alt,
57
- focalPoint: asset.focalPoint,
58
- // EC-420: carry the LQIP so a placed Image blurs up on delivery.
59
- lqip: asset.lqip,
60
- meta: asset.meta
61
- } : null;
62
- };
44
+ return (id) => byId.get(id) ?? null;
63
45
  }
64
46
  function UnresolvedPageFallback(props) {
65
47
  return /* @__PURE__ */ jsx("div", { "data-ec-fallback": "unresolved-page", "data-ec-status": props.status, children: `This URL did not resolve to a page (status: ${props.status}).` });
@@ -67,41 +49,28 @@ function UnresolvedPageFallback(props) {
67
49
  function MissingDocumentFallback() {
68
50
  return /* @__PURE__ */ jsx("div", { "data-ec-fallback": "missing-document", children: `Route resolved but carried no page document to render.` });
69
51
  }
70
- function stripServerErrorBoundary(_node, rendered) {
71
- if (isValidElement(rendered) && rendered.type === RenderErrorBoundary) {
72
- return rendered.props.children ?? null;
73
- }
74
- return rendered;
75
- }
76
52
  async function CanvasRenderer(props) {
77
53
  const { result, components } = props;
78
54
  if (result.status !== "ok") {
79
55
  return /* @__PURE__ */ jsx(UnresolvedPageFallback, { status: result.status });
80
56
  }
81
- const ctx = {
82
- registry: components.registry,
83
- implementations: components.implementations,
84
- resolveAsset: assetResolver(props.assets ?? []),
85
- // EC-254: wire relation population only when the host baked the populated
86
- // targets, so a host that did not opt in keeps the raw-ref passthrough.
87
- ...props.relations ? { resolveRelation: relationResolver(props.relations) } : {},
88
- // EC-255: wire the listing resolver only when the host baked the query results,
89
- // so a host that did not opt in keeps listing props empty (the block's own state).
90
- ...props.listings ? { resolveListing: listingResolver(props.listings) } : {},
91
- // EC-291: wire the rich-text link resolver only when the host baked the link map,
92
- // so a host that did not opt in degrades a reference link to the visible
93
- // `unresolved-ref` issue (EC-290) rather than a wrong href.
94
- ...props.links ? { resolveLink: linkResolver(props.links) } : {},
95
- decorateNode: (node, rendered) => {
96
- const stripped = stripServerErrorBoundary(node, rendered);
97
- const failure = listingFailureOf(node, components, props.listings);
98
- if (!failure) return stripped;
99
- return /* @__PURE__ */ jsxs(Fragment$1, { children: [
100
- /* @__PURE__ */ jsx(InvalidListingNotice, { nodeId: node.id, messages: failure }),
101
- stripped
102
- ] }, node.id);
57
+ const ctx = createRenderContext({
58
+ components,
59
+ mode: "delivery",
60
+ source: {
61
+ resolveAsset: assetResolver(props.assets ?? []),
62
+ // EC-254: wire relation population only when the host baked the populated
63
+ // targets, so a host that did not opt in keeps the raw-ref passthrough.
64
+ ...props.relations ? { resolveRelation: relationResolver(props.relations) } : {},
65
+ // EC-255: wire the listing resolver only when the host baked the query results,
66
+ // so a host that did not opt in keeps listing props empty (the block's own state).
67
+ ...props.listings ? { resolveListing: listingResolver(props.listings), listingFailure: bakedListingFailure(props.listings) } : {},
68
+ // EC-291: wire the rich-text link resolver only when the host baked the link map,
69
+ // so a host that did not opt in degrades a reference link to the visible
70
+ // `unresolved-ref` issue (EC-290) rather than a wrong href.
71
+ ...props.links ? { resolveLink: linkResolver(props.links) } : {}
103
72
  }
104
- };
73
+ });
105
74
  const document = result.document;
106
75
  if (document) {
107
76
  return /* @__PURE__ */ jsx(Fragment, { children: renderDocumentBodyItems(bodyItemsOf(document, props.presentation), ctx) });
@@ -180,7 +149,8 @@ function createStaticContentSource(data) {
180
149
  locales
181
150
  }),
182
151
  routes,
183
- redirects
152
+ redirects,
153
+ ...data.sourceIssues ? { sourceIssues: data.sourceIssues } : {}
184
154
  });
185
155
  return {
186
156
  projectId: data.projectId,
@@ -200,17 +170,11 @@ function createStaticContentSource(data) {
200
170
  };
201
171
  }
202
172
  async function createContentSnapshot(adapter, projectId, options) {
203
- const schema = await adapter.cms.findSchema(projectId);
204
173
  const documentRecords = await adapter.cms.listDocuments(projectId);
205
174
  const versionRecords = await adapter.cms.listAllDocumentVersions(projectId);
206
175
  const assetRecords = await adapter.assets.listAssets(projectId);
207
176
  const redirectRecords = await adapter.redirects.listRedirects(projectId);
208
- const redirects = redirectRecords.filter((record2) => record2.enabled).map((record2) => ({
209
- id: record2.id,
210
- from: record2.from,
211
- to: record2.to,
212
- permanent: record2.permanent
213
- }));
177
+ const redirects = liveRedirectRecords(redirectRecords);
214
178
  const versionsByKey = /* @__PURE__ */ new Map();
215
179
  for (const record2 of versionRecords) {
216
180
  const key = `${record2.collection}:${record2.docId}`;
@@ -231,12 +195,85 @@ async function createContentSnapshot(adapter, projectId, options) {
231
195
  locales: options.locales,
232
196
  ...options.routes ? { routes: options.routes } : {},
233
197
  redirects: options.redirects ? [...options.redirects] : redirects,
234
- ...schema ? { collections: schema.collections } : {},
198
+ collections: options.collections,
235
199
  documents,
236
200
  assets: assetRecords,
237
201
  assetUrl: (asset) => adapter.blobs.url(asset.storageKey)
238
202
  });
239
203
  }
204
+ function seoContextOf(result, assets = []) {
205
+ return {
206
+ seoField: result.seoField ?? null,
207
+ title: result.title ?? null,
208
+ locale: result.tags.locale,
209
+ assets
210
+ };
211
+ }
212
+ function str(value) {
213
+ return typeof value === "string" && value.length > 0 ? value : null;
214
+ }
215
+ function resolveSeoImage(value, assets) {
216
+ if (value && typeof value === "object") {
217
+ const url = value.url;
218
+ return typeof url === "string" && url.length > 0 ? url : null;
219
+ }
220
+ if (typeof value !== "string" || value.length === 0) return null;
221
+ return assets.find((asset) => asset.id === value)?.url ?? null;
222
+ }
223
+ function openGraphLocale(locale) {
224
+ return locale.includes("-") ? locale.replace(/-/g, "_") : null;
225
+ }
226
+ function seoObjectOf(doc, seoField = DEFAULT_SEO_FIELD) {
227
+ if (!doc || seoField === null) return {};
228
+ const value = doc[seoField ?? DEFAULT_SEO_FIELD];
229
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
230
+ }
231
+ function seoMetadata(doc, ctx = {}) {
232
+ const seo = seoObjectOf(doc, ctx.seoField);
233
+ const assets = ctx.assets ?? [];
234
+ const seoTitle = str(seo[SEO_SUBFIELDS.title]);
235
+ const description = str(seo[SEO_SUBFIELDS.description]);
236
+ const ogTitle = str(seo[SEO_SUBFIELDS.ogTitle]);
237
+ const ogDescription = str(seo[SEO_SUBFIELDS.ogDescription]);
238
+ const image = resolveSeoImage(seo[SEO_SUBFIELDS.image], assets);
239
+ const noindex = seo[SEO_SUBFIELDS.noindex] === true;
240
+ const canonical = str(seo[SEO_SUBFIELDS.canonical]);
241
+ const docTitle = ctx.title !== void 0 ? str(ctx.title) : str(doc?.["title"]) ?? str(doc?.["name"]);
242
+ const title = seoTitle ?? docTitle;
243
+ const metadata = {};
244
+ if (title !== null) metadata.title = title;
245
+ if (description) metadata.description = description;
246
+ if (doc) {
247
+ const effectiveOgTitle = ogTitle ?? title;
248
+ const effectiveOgDescription = ogDescription ?? description;
249
+ const ogLocale = ctx.locale !== void 0 ? openGraphLocale(ctx.locale) : null;
250
+ metadata.openGraph = {
251
+ ...effectiveOgTitle !== null ? { title: effectiveOgTitle } : {},
252
+ ...effectiveOgDescription ? { description: effectiveOgDescription } : {},
253
+ ...image ? { images: [{ url: image }] } : {},
254
+ ...ogLocale !== null ? { locale: ogLocale } : {}
255
+ };
256
+ }
257
+ if (noindex) metadata.robots = { index: false };
258
+ if (canonical) metadata.alternates = { canonical };
259
+ return metadata;
260
+ }
261
+ function mergeMetadataOverParent(parent, page) {
262
+ if (!parent) return page;
263
+ const merged = { ...page };
264
+ for (const [key, pageValue] of Object.entries(page)) {
265
+ const parentValue = parent[key];
266
+ if (isPlainObject(pageValue) && isPlainObject(parentValue)) {
267
+ merged[key] = { ...parentValue, ...pageValue };
268
+ }
269
+ }
270
+ return merged;
271
+ }
272
+ function isPlainObject(value) {
273
+ return typeof value === "object" && value !== null && !Array.isArray(value);
274
+ }
275
+
276
+ // src/route-core.ts
240
277
  function canvasWrapContext(result, info) {
241
278
  const collection = result.document?.collection;
242
279
  return {
@@ -260,7 +297,7 @@ function scopeAssetsToPage(allAssets, registry, result, body, resolveRelation) {
260
297
  for (const value of body.richTexts) {
261
298
  collectRichTextRefsInValue(value, { onAsset: (id) => ids.add(id) });
262
299
  }
263
- const ogImage = result.document?.["seoOgImage"];
300
+ const ogImage = seoObjectOf(result.document, result.seoField)[SEO_SUBFIELDS.image];
264
301
  const ogImageId = typeof ogImage === "string" ? ogImage : ogImage && typeof ogImage === "object" && typeof ogImage.id === "string" ? ogImage.id : null;
265
302
  if (ogImageId) ids.add(ogImageId);
266
303
  return ids.size === 0 ? [] : allAssets.filter((asset) => ids.has(asset.id));
@@ -457,14 +494,20 @@ function revalidationTags(event, project) {
457
494
  return [...tags].sort();
458
495
  }
459
496
  function canvasCacheKey({ scope, mode, url }) {
460
- return [scope ?? "", mode ?? "", url];
497
+ return JSON.stringify([scope ?? "", mode ?? "", url]);
461
498
  }
462
- var CANVAS_CACHE_LIFE = { revalidate: Infinity, expire: Infinity };
463
- var CANVAS_DEV_CACHE_LIFE = { revalidate: 1, expire: 300 };
464
- var CANVAS_CLOCK_CACHE_LIFE = { revalidate: 60, expire: Infinity };
465
- function canvasEntryLife(nodeEnv, readsClock = false) {
466
- if (nodeEnv === "development") return CANVAS_DEV_CACHE_LIFE;
467
- return readsClock ? CANVAS_CLOCK_CACHE_LIFE : CANVAS_CACHE_LIFE;
499
+ function parseCanvasCacheKey(key) {
500
+ let parsed;
501
+ try {
502
+ parsed = JSON.parse(key);
503
+ } catch {
504
+ return void 0;
505
+ }
506
+ if (!Array.isArray(parsed) || parsed.length !== 3 || !parsed.every((part) => typeof part === "string")) {
507
+ return void 0;
508
+ }
509
+ const [scope, mode, url] = parsed;
510
+ return { scope, mode, url };
468
511
  }
469
512
  var NEXT_CACHE_TAG_CALL_LIMIT = 128;
470
513
  function entryTagCalls(tags, scope) {
@@ -476,100 +519,21 @@ function entryTagCalls(tags, scope) {
476
519
  }
477
520
  return calls;
478
521
  }
479
- var canvasResolvers = /* @__PURE__ */ new Map();
480
- function resolverKey(scope, mode) {
481
- return JSON.stringify([scope, mode]);
482
- }
483
- function registerCanvasResolver(scope, mode, resolve) {
484
- canvasResolvers.set(resolverKey(scope, mode), resolve);
485
- }
486
- function canvasResolverFor(scope, mode) {
487
- const resolve = canvasResolvers.get(resolverKey(scope, mode));
488
- if (!resolve) {
489
- throw new Error(
490
- `Elytra delivery cache: no canvas route registered for scope "${scope}" and content mode "${mode}" in this server process \u2014 the cached route function has no loader to run. This is a wiring defect: create the route with createCanvasRoute / elytra.canvasRoute() before its Page renders.`
491
- );
492
- }
493
- return resolve;
494
- }
495
-
496
- // src/metadata.ts
497
- function str(value) {
498
- return typeof value === "string" && value.length > 0 ? value : null;
499
- }
500
- function looksLikeUrl(value) {
501
- return /^(https?:)?\/\//.test(value) || value.startsWith("/");
502
- }
503
- function resolveOgImage(value, assets) {
504
- if (value && typeof value === "object") {
505
- const url = value.url;
506
- return typeof url === "string" && url.length > 0 ? url : null;
507
- }
508
- if (typeof value !== "string" || value.length === 0) return null;
509
- if (looksLikeUrl(value)) return value;
510
- return assets.find((asset) => asset.id === value)?.url ?? null;
511
- }
512
- function openGraphLocale(locale) {
513
- return locale.includes("-") ? locale.replace(/-/g, "_") : null;
514
- }
515
- function canvasPageMetadata(result, assets = []) {
516
- const doc = result.document;
517
- const seoTitle = str(doc?.["seoTitle"]);
518
- const seoDescription = str(doc?.["seoDescription"]);
519
- const seoOgTitle = str(doc?.["seoOgTitle"]);
520
- const seoOgImage = resolveOgImage(doc?.["seoOgImage"], assets);
521
- const seoNoindex = doc?.["seoNoindex"] === true;
522
- const seoCanonical = str(doc?.["seoCanonical"]);
523
- const docTitle = str(doc?.["name"]) ?? str(doc?.["title"]);
524
- const title = seoTitle ?? docTitle;
525
- const metadata = {};
526
- if (title !== null) metadata.title = title;
527
- if (seoDescription) metadata.description = seoDescription;
528
- const ogTitle = seoOgTitle ?? title;
529
- if (doc) {
530
- const ogLocale = openGraphLocale(result.tags.locale);
531
- metadata.openGraph = {
532
- ...ogTitle !== null ? { title: ogTitle } : {},
533
- ...seoDescription ? { description: seoDescription } : {},
534
- ...seoOgImage ? { images: [{ url: seoOgImage }] } : {},
535
- ...ogLocale !== null ? { locale: ogLocale } : {}
536
- };
537
- }
538
- if (seoNoindex) metadata.robots = { index: false };
539
- if (seoCanonical) metadata.alternates = { canonical: seoCanonical };
540
- return metadata;
541
- }
542
- function mergeMetadataOverParent(parent, page) {
543
- if (!parent) return page;
544
- const merged = { ...page };
545
- for (const [key, pageValue] of Object.entries(page)) {
546
- const parentValue = parent[key];
547
- if (isPlainObject(pageValue) && isPlainObject(parentValue)) {
548
- merged[key] = { ...parentValue, ...pageValue };
549
- }
550
- }
551
- return merged;
552
- }
553
- function isPlainObject(value) {
554
- return typeof value === "object" && value !== null && !Array.isArray(value);
555
- }
556
522
  var CANVAS_VALIDATION_SLUG = ["_elytra"];
557
523
  var CANVAS_VALIDATION_PATH = pathFromSlug([...CANVAS_VALIDATION_SLUG]);
558
524
  function defaultMetadata(result, assets) {
559
- return canvasPageMetadata(result, assets);
525
+ return seoMetadata(result.document, seoContextOf(result, assets));
560
526
  }
561
527
  async function requestPerspective() {
562
528
  const { isEnabled } = await draftMode();
563
- return isEnabled ? "draft" : "published";
564
- }
565
- async function cachedCanvasOutcome(scope, mode, url) {
566
- "use cache";
567
- const outcome = await canvasResolverFor(scope, mode)(url);
568
- cacheLife(canvasEntryLife(process.env.NODE_ENV, outcome.kind === "render" && outcome.readsNow));
569
- for (const tags of entryTagCalls(outcome.tags, scope)) cacheTag(...tags);
570
- return outcome;
529
+ return isEnabled ? DRAFT_PERSPECTIVE : "published";
571
530
  }
572
531
  function createCanvasRoute(options) {
532
+ if (Object.hasOwn(options, "published") && options.published === void 0) {
533
+ throw new Error(
534
+ "Elytra canvas route: `published` was passed but is undefined. If it names your cache function (the one carrying the cache directive), declare that function ABOVE the canvasRoute() call \u2014 Next compiles a cached function into a variable assigned where it is written, so it is not hoisted like a plain function. To render without a cache, omit `published` entirely."
535
+ );
536
+ }
573
537
  const resolveFresh = async (url, perspective) => {
574
538
  const source = await options.loadContent({ perspective });
575
539
  return resolveCanvasPage(source, url, perspective, {
@@ -577,15 +541,29 @@ function createCanvasRoute(options) {
577
541
  registry: options.components.registry
578
542
  });
579
543
  };
580
- const cache = options.cache;
581
- const entryKey = (url) => canvasCacheKey({ scope: cache?.scope, mode: cache?.mode, url });
582
- if (cache) {
583
- const [scope, mode] = entryKey("");
584
- registerCanvasResolver(scope, mode, (url) => resolveFresh(url, "published"));
585
- }
544
+ const scope = options.scope ?? "";
545
+ const mode = options.mode ?? "";
546
+ const keyFor = (url) => canvasCacheKey({ scope, mode, url });
547
+ const resolve = async (key) => {
548
+ const parts = parseCanvasCacheKey(key);
549
+ if (!parts) {
550
+ throw new Error(
551
+ `Elytra canvas route: resolve() was handed ${JSON.stringify(key)}, which is not a key this route produced. Pass your cache function's argument through unchanged \u2014 it is the entry's key.`
552
+ );
553
+ }
554
+ if (parts.scope !== scope || parts.mode !== mode) {
555
+ throw new Error(
556
+ `Elytra canvas route: resolve() was handed a cache key of scope "${parts.scope}" and content mode "${parts.mode}", but this route resolves in scope "${scope}" and mode "${mode}". The entry would be keyed under one world and filled from another (EC-543). Pass each route's \`published\` function the key that route hands it, and call that same route's resolve().`
557
+ );
558
+ }
559
+ return resolveFresh(parts.url, "published");
560
+ };
561
+ const applyCacheTags = (result) => {
562
+ for (const tags of entryTagCalls(result.tags, scope)) cacheTag(...tags);
563
+ };
586
564
  const loadOutcome = async (url, perspective) => {
587
- if (!cache || perspective !== "published") return resolveFresh(url, perspective);
588
- return cachedCanvasOutcome(...entryKey(url));
565
+ if (perspective !== "published" || !options.published) return resolveFresh(url, perspective);
566
+ return options.published(keyFor(url));
589
567
  };
590
568
  const Page = async (props) => {
591
569
  const { slug } = await props.params;
@@ -622,7 +600,7 @@ function createCanvasRoute(options) {
622
600
  const url = pathFromSlug(slug);
623
601
  if (url === CANVAS_VALIDATION_PATH) return {};
624
602
  const perspective = await requestPerspective();
625
- if (cache && perspective === "published") {
603
+ if (options.published && perspective === "published") {
626
604
  const outcome2 = await loadOutcome(url, perspective);
627
605
  if (outcome2.kind !== "render") return {};
628
606
  return options.metadata ? options.metadata(outcome2.result, { url, perspective }) : defaultMetadata(outcome2.result, outcome2.assets);
@@ -639,29 +617,13 @@ function createCanvasRoute(options) {
639
617
  const generateStaticParams = async () => [
640
618
  { slug: [...CANVAS_VALIDATION_SLUG] }
641
619
  ];
642
- return { Page, generateMetadata, generateStaticParams };
643
- }
644
- function paramNamesOf(pattern) {
645
- return splitPath(pattern).filter((segment) => segment.startsWith(":")).map((segment) => segment.slice(1));
646
- }
647
- function fillPattern(pattern, params) {
648
- const segments = [];
649
- for (const segment of splitPath(pattern)) {
650
- if (!segment.startsWith(":")) {
651
- segments.push(segment);
652
- continue;
653
- }
654
- const value = params[segment.slice(1)];
655
- if (value === void 0 || value.length === 0) return null;
656
- segments.push(encodeURIComponent(value));
657
- }
658
- return "/" + segments.join("/");
620
+ return { Page, generateMetadata, resolve, applyCacheTags, generateStaticParams };
659
621
  }
660
622
  function originOf(baseUrl) {
661
623
  return baseUrl.replace(/\/+$/, "");
662
624
  }
663
625
  function canvasSitemapEntries(source, options) {
664
- const routes = options.routes ?? source.routes ?? [];
626
+ const routes = source.routes ?? [];
665
627
  const locales = source.locales;
666
628
  const skipped = [];
667
629
  const skip = (route, reason) => {
@@ -678,83 +640,79 @@ function canvasSitemapEntries(source, options) {
678
640
  }
679
641
  return client;
680
642
  };
681
- const byUrl = /* @__PURE__ */ new Map();
682
- const byGroup = /* @__PURE__ */ new Map();
643
+ const candidates = [];
644
+ const collectionLocales = /* @__PURE__ */ new Map();
683
645
  for (const route of routes) {
684
- const paramNames = paramNamesOf(route.pattern);
685
- const mount = hierarchyMount(route);
686
- const routeLocales = route.locale ? [route.locale] : locales.locales;
687
- for (const locale of routeLocales) {
688
- const client = clientFor(locale);
689
- let paths;
690
- if (mount) {
691
- const listed = client.listDocuments(mount.collection);
692
- if (!listed.ok) {
693
- skip(route, `hierarchy collection "${mount.collection}" could not be listed`);
694
- continue;
695
- }
696
- const rows = listed.documents.map((doc) => ({
697
- collection: mount.collection,
698
- id: doc.id,
699
- values: { slug: doc["slug"], parent: doc["parent"] }
700
- }));
701
- const byId = new Map(rows.map((row) => [row.id, row]));
702
- paths = [];
703
- for (const row of rows) {
704
- if (typeof row.values["slug"] !== "string" || row.values["slug"] === "") continue;
705
- const composed = composePath(row, (id) => byId.get(id));
706
- if (!composed.ok) continue;
707
- paths.push("/" + [...mount.prefix, ...composed.segments].join("/"));
708
- }
709
- } else if (paramNames.length === 0) {
710
- paths = [route.pattern];
711
- } else {
712
- const paramSets = options.params?.({ route, locale, client });
713
- if (paramSets == null) {
714
- skip(route, "dynamic route not enumerated (no params provider entry)");
715
- continue;
716
- }
717
- paths = [];
718
- for (const paramSet of paramSets) {
719
- const path = fillPattern(route.pattern, paramSet);
720
- if (path === null) {
721
- skip(route, `params entry missing one of :${paramNames.join(", :")}`);
722
- continue;
723
- }
724
- paths.push(path);
725
- }
726
- }
727
- let unresolved = 0;
728
- for (const path of paths) {
729
- const result = client.resolvePage(path, locale);
730
- if (result.status !== "ok") {
731
- if (result.status !== "redirect") unresolved += 1;
732
- continue;
733
- }
734
- if (result.document?.["seoNoindex"] === true) continue;
735
- const { urlPath, addressable } = localeUrlPath(path, locale, locales);
736
- if (!addressable) {
737
- skip(
738
- route,
739
- `"${path}" (locale "${locale}") has no addressable URL \u2014 its first segment is read as a locale prefix`
740
- );
741
- continue;
742
- }
743
- const absolute = originOf(options.baseUrl) + urlPath;
744
- const group = result.document ? `${route.id}${COMPOSITE_KEY_SEPARATOR}doc${COMPOSITE_KEY_SEPARATOR}${result.document.collection}${COMPOSITE_KEY_SEPARATOR}${result.document.id}` : `${route.id}${COMPOSITE_KEY_SEPARATOR}path${COMPOSITE_KEY_SEPARATOR}${path}`;
745
- if (!byUrl.has(absolute)) byUrl.set(absolute, { group, locale });
746
- const perLocale = byGroup.get(group) ?? /* @__PURE__ */ new Map();
747
- if (!perLocale.has(locale)) perLocale.set(locale, absolute);
748
- byGroup.set(group, perLocale);
749
- }
750
- if (mount && paths.length > 0 && unresolved === paths.length) {
751
- skip(
752
- route,
753
- `hierarchy mount over "${mount.collection}" composed ${paths.length} path(s) in locale "${locale}", none of which the resolver serves`
754
- );
646
+ if (!route.document) continue;
647
+ const set = collectionLocales.get(route.document.collection) ?? /* @__PURE__ */ new Set();
648
+ for (const locale of route.locale ? [route.locale] : locales.locales) set.add(locale);
649
+ collectionLocales.set(route.document.collection, set);
650
+ }
651
+ for (const [collection, routeLocales] of collectionLocales) {
652
+ for (const locale of locales.locales) {
653
+ if (!routeLocales.has(locale)) continue;
654
+ for (const entry of clientFor(locale).urlsFor(collection)) {
655
+ candidates.push({
656
+ route: entry.route,
657
+ locale,
658
+ routePath: entry.routePath,
659
+ path: entry.path,
660
+ addressable: entry.addressable,
661
+ // EC-517: group by the DOCUMENT — the locale-invariant fact — scoped by route.
662
+ group: `${entry.route.id}${COMPOSITE_KEY_SEPARATOR}doc${COMPOSITE_KEY_SEPARATOR}${collection}${COMPOSITE_KEY_SEPARATOR}${entry.id}`
663
+ });
755
664
  }
756
665
  }
757
666
  }
667
+ for (const route of routes) {
668
+ if (route.document || splitPath(route.pattern).some((segment) => segment.startsWith(":"))) continue;
669
+ for (const locale of route.locale ? [route.locale] : locales.locales) {
670
+ const { urlPath, addressable } = localeUrlPath(route.pattern, locale, locales);
671
+ candidates.push({
672
+ route,
673
+ locale,
674
+ routePath: route.pattern,
675
+ path: urlPath,
676
+ addressable,
677
+ group: `${route.id}${COMPOSITE_KEY_SEPARATOR}path${COMPOSITE_KEY_SEPARATOR}${route.pattern}`
678
+ });
679
+ }
680
+ }
681
+ const byUrl = /* @__PURE__ */ new Map();
682
+ const byGroup = /* @__PURE__ */ new Map();
683
+ const tally = /* @__PURE__ */ new Map();
684
+ for (const candidate of candidates) {
685
+ const key = `${candidate.route.id}${COMPOSITE_KEY_SEPARATOR}${candidate.locale}`;
686
+ const count = tally.get(key) ?? { route: candidate.route, locale: candidate.locale, total: 0, unresolved: 0 };
687
+ count.total += 1;
688
+ tally.set(key, count);
689
+ const result = clientFor(candidate.locale).resolvePage(candidate.routePath, candidate.locale);
690
+ if (result.status !== "ok") {
691
+ if (result.status !== "redirect") count.unresolved += 1;
692
+ continue;
693
+ }
694
+ if (seoObjectOf(result.document, result.seoField)[SEO_SUBFIELDS.noindex] === true) continue;
695
+ if (!candidate.addressable) {
696
+ skip(
697
+ candidate.route,
698
+ `"${candidate.routePath}" (locale "${candidate.locale}") has no addressable URL \u2014 its first segment is read as a locale prefix`
699
+ );
700
+ continue;
701
+ }
702
+ const absolute = originOf(options.baseUrl) + candidate.path;
703
+ if (!byUrl.has(absolute)) byUrl.set(absolute, { group: candidate.group, locale: candidate.locale });
704
+ const perLocale = byGroup.get(candidate.group) ?? /* @__PURE__ */ new Map();
705
+ if (!perLocale.has(candidate.locale)) perLocale.set(candidate.locale, absolute);
706
+ byGroup.set(candidate.group, perLocale);
707
+ }
708
+ for (const { route, locale, total, unresolved } of tally.values()) {
709
+ if (total > 0 && unresolved === total && route.document && splitPath(route.pattern).at(-1)?.endsWith("*")) {
710
+ skip(
711
+ route,
712
+ `hierarchy mount over "${route.document.collection}" composed ${total} path(s) in locale "${locale}", none of which the resolver serves`
713
+ );
714
+ }
715
+ }
758
716
  const entries = [];
759
717
  for (const [url, { group }] of byUrl) {
760
718
  const perLocale = byGroup.get(group);
@@ -885,7 +843,7 @@ function elytraHostImage(options = {}) {
885
843
  return ({ asset, alt, className, priority, sizes, loading }) => /* @__PURE__ */ jsx(
886
844
  ElytraImage,
887
845
  {
888
- asset: { ...asset, alt },
846
+ asset: { ...asset, alt: alt ?? asset.alt },
889
847
  ...className !== void 0 ? { className } : {},
890
848
  ...priority ? { priority: true } : {},
891
849
  ...sizes !== void 0 ? { sizes } : {},
@@ -937,14 +895,6 @@ async function runConvexQuery(fetchImpl, convexUrl, path, args) {
937
895
  }
938
896
  return body.value;
939
897
  }
940
- function parseDocuments(rows) {
941
- const documents = [];
942
- for (const row of rows) {
943
- const parsed = documentSchema.safeParse(row);
944
- if (parsed.success) documents.push(parsed.data);
945
- }
946
- return documents;
947
- }
948
898
  function parseRedirects(rows) {
949
899
  const redirects = [];
950
900
  for (const row of rows) {
@@ -955,8 +905,8 @@ function parseRedirects(rows) {
955
905
  }
956
906
  function assembleLiveSource(payload, options, perspective) {
957
907
  const locales = options.locales;
958
- const documents = parseDocuments(payload.documents);
959
- const routes = mergeRouteRecords([...options.routes], fallbackHomeRoutes(documents, locales));
908
+ const { documents, issues: sourceIssues } = parseDeliveredDocuments(payload.documents);
909
+ const routes = [...options.routes];
960
910
  const documentSources = perspective === "published" ? documents.map((doc) => ({ draft: doc, published: doc, versions: [] })) : documents;
961
911
  return createStaticContentSource({
962
912
  projectId: payload.projectId,
@@ -968,14 +918,21 @@ function assembleLiveSource(payload, options, perspective) {
968
918
  redirects: parseRedirects(payload.redirects ?? []),
969
919
  collections: [...options.collections],
970
920
  documents: documentSources,
971
- assets: payload.assets
921
+ assets: payload.assets,
922
+ ...sourceIssues.length > 0 ? { sourceIssues } : {}
972
923
  });
973
924
  }
974
925
  async function loadLiveContent(options, perspective) {
975
926
  const fetchImpl = options.fetch ?? globalThis.fetch;
976
927
  const args = { projectId: options.projectId };
977
928
  let payload;
978
- if (perspective === "draft") {
929
+ const variant = variantOf(perspective);
930
+ if (variant !== void 0) {
931
+ if (variant !== DRAFT_VARIANT) {
932
+ throw new Error(
933
+ `Elytra live content: no delivery snapshot serves the "${variant}" variant \u2014 the deployment serves the published snapshot and the "${DRAFT_VARIANT}" variant's only.`
934
+ );
935
+ }
979
936
  if (!options.draftToken) {
980
937
  throw new Error(
981
938
  "Draft preview requested but DRAFT_PREVIEW_TOKEN is not configured on the host."
@@ -1024,11 +981,7 @@ function resolveGlobals(source, options = {}) {
1024
981
  const navDocs = listDocs(navigationCollection);
1025
982
  const navigation = (slot) => navDocs.find((doc) => doc.alias === slot) ?? null;
1026
983
  const assetUrl = (value) => {
1027
- if (typeof value === "string") {
1028
- const found = assetById.get(value);
1029
- if (found) return found.url;
1030
- return value.startsWith("http") || value.startsWith("/") ? value : void 0;
1031
- }
984
+ if (typeof value === "string") return assetById.get(value)?.url;
1032
985
  const url = record(value).url;
1033
986
  return typeof url === "string" && url.length > 0 ? url : void 0;
1034
987
  };
@@ -1059,8 +1012,6 @@ function resolveGlobals(source, options = {}) {
1059
1012
  if (typeof shaped.path === "string" && shaped.path.startsWith("/")) {
1060
1013
  return { href: shaped.path, openInNewTab };
1061
1014
  }
1062
- const slug = str2(shaped.slug);
1063
- if (slug) return { href: `/${slug}`, openInNewTab };
1064
1015
  return null;
1065
1016
  }
1066
1017
  return null;
@@ -1187,12 +1138,35 @@ function createPreviewDisableRoute() {
1187
1138
  }
1188
1139
 
1189
1140
  // src/host-kit.ts
1190
- function materializeRoutes(routes, collections) {
1191
- return typeof routes === "function" ? routes([...collections]) : [...routes];
1141
+ function resolveHostProject(entry) {
1142
+ const result = resolveProject(entry);
1143
+ if (result.ok) return result.project;
1144
+ const lines = result.issues.filter((issue) => issue.severity === "error").map((issue) => {
1145
+ const at = issue.path.length > 0 ? ` at ${issue.path.join(".")}` : "";
1146
+ return ` \u2022 ${issue.code}${at}: ${issue.message}`;
1147
+ });
1148
+ throw new Error(
1149
+ `Elytra delivery host: the project config "${String(entry["slug"])}" is invalid \u2014 the studio refuses it too:
1150
+ ${lines.join("\n")}`
1151
+ );
1152
+ }
1153
+ function surfaceRouteIssues(slug, issues) {
1154
+ if (issues.length === 0) return;
1155
+ const building = process.env["NEXT_PHASE"] === "phase-production-build";
1156
+ if (!building && process.env["NODE_ENV"] === "production") return;
1157
+ const lines = issues.map((issue) => ` \u2022 ${issue.severity} ${issue.code}: ${issue.message}`);
1158
+ const report = `[elytra] project "${slug}": ${issues.length} route issue(s) \u2014
1159
+ ${lines.join("\n")}`;
1160
+ if (issues.some((issue) => issue.severity === "error")) console.error(report);
1161
+ else console.warn(report);
1192
1162
  }
1193
1163
  function createElytraHost(options) {
1194
- const env = resolveHostEnv(options.env ?? process.env);
1195
- const routes = materializeRoutes(options.routes, options.collections);
1164
+ const { env: envOption, fallbackContent, globals, fetch: fetchOption, ...entry } = options;
1165
+ const project = resolveHostProject(entry);
1166
+ const env = resolveHostEnv(envOption ?? process.env);
1167
+ const routes = project.routes;
1168
+ const routeIssues = createRouter(project).issues;
1169
+ surfaceRouteIssues(project.slug, routeIssues);
1196
1170
  const live = env.convexUrl !== void 0 && env.projectId !== void 0;
1197
1171
  if (!live && (env.convexUrl !== void 0 || env.projectId !== void 0)) {
1198
1172
  const [present, missing] = env.convexUrl !== void 0 ? [ELYTRA_HOST_ENV_KEYS.convexUrl, ELYTRA_HOST_ENV_KEYS.projectId] : [ELYTRA_HOST_ENV_KEYS.projectId, ELYTRA_HOST_ENV_KEYS.convexUrl];
@@ -1200,16 +1174,16 @@ function createElytraHost(options) {
1200
1174
  `Elytra delivery host: ${present} is set but ${missing} is not \u2014 live content needs both. Left as it is, this site would serve fallback content and look healthy. Set ${missing} to go live, or unset ${present} to run the no-backend fallback on purpose.`
1201
1175
  );
1202
1176
  }
1203
- const contentMode = live ? "live" : options.fallbackContent ? "fallback" : "empty";
1204
- const contentScope = live ? env.projectId : options.slug;
1205
- const components = defineHostComponents(options.components ?? []);
1177
+ const contentMode = live ? "live" : fallbackContent ? "fallback" : "empty";
1178
+ const contentScope = live ? env.projectId : project.slug;
1179
+ const components = defineHostComponents(project.components ?? []);
1206
1180
  const emptySource = createStaticContentSource({
1207
1181
  projectId: contentScope,
1208
- locales: options.locales,
1182
+ locales: project.locales,
1209
1183
  routes,
1210
1184
  // Redirects are editorial DATA (EC-303) — the no-backend build has none.
1211
1185
  redirects: [],
1212
- collections: [...options.collections],
1186
+ collections: project.collections,
1213
1187
  documents: [],
1214
1188
  assets: []
1215
1189
  });
@@ -1219,16 +1193,16 @@ function createElytraHost(options) {
1219
1193
  {
1220
1194
  convexUrl: env.convexUrl,
1221
1195
  projectId: env.projectId,
1222
- locales: options.locales,
1223
- collections: options.collections,
1196
+ locales: project.locales,
1197
+ collections: project.collections,
1224
1198
  routes,
1225
1199
  ...env.draftToken ? { draftToken: env.draftToken } : {},
1226
- ...options.fetch ? { fetch: options.fetch } : {}
1200
+ ...fetchOption ? { fetch: fetchOption } : {}
1227
1201
  },
1228
1202
  request.perspective
1229
1203
  );
1230
1204
  }
1231
- if (options.fallbackContent) return options.fallbackContent(request);
1205
+ if (fallbackContent) return fallbackContent(request);
1232
1206
  return Promise.resolve(emptySource);
1233
1207
  };
1234
1208
  const loadGlobals = async (globalsOptions = {}) => {
@@ -1236,21 +1210,22 @@ function createElytraHost(options) {
1236
1210
  const source = await loadContent({ perspective });
1237
1211
  return resolveGlobals(source, {
1238
1212
  perspective,
1239
- ...options.globals ? { conventions: options.globals } : {}
1240
- });
1241
- };
1242
- const canvasRoute = (routeOptions = {}) => {
1243
- const { cache, ...rest } = routeOptions;
1244
- return createCanvasRoute({
1245
- loadContent,
1246
- components,
1247
- // Tag scope = `contentScope`, the SAME value `revalidateRoute` derives its
1248
- // invalidation tags with (EC-579) — one scope, so they cannot disagree.
1249
- // EC-543: the mode is in the entry key so no world ever reads another's entries.
1250
- ...cache === false ? {} : { cache: { mode: contentMode, scope: contentScope } },
1251
- ...rest
1213
+ ...globals ? { conventions: globals } : {}
1252
1214
  });
1253
1215
  };
1216
+ const canvasRoute = (routeOptions = {}) => createCanvasRoute({
1217
+ ...routeOptions,
1218
+ loadContent,
1219
+ components,
1220
+ // EC-620: the route caches nothing itself; these reach the host's own cache
1221
+ // function. Tag scope = `contentScope`, the SAME value `revalidateRoute` derives
1222
+ // its invalidation tags with (EC-579) — so `route.applyCacheTags` writes exactly
1223
+ // the strings the receiver revalidates. EC-543: the mode rides in every cache key
1224
+ // the route hands out, so no world ever reads another's entries. Spread last:
1225
+ // neither can be overridden.
1226
+ scope: contentScope,
1227
+ mode: contentMode
1228
+ });
1254
1229
  const revalidateRoute = (overrides = {}) => createRevalidateRoute({
1255
1230
  // An unset secret keeps the endpoint CLOSED (401) — the kit never ships a
1256
1231
  // default secret (an auth-shaped no-op is worse than an explicit gap).
@@ -1259,27 +1234,29 @@ function createElytraHost(options) {
1259
1234
  // under (`canvasRoute` above) and every locale it serves. The emitter sends only
1260
1235
  // the event, so this is the one place the scope and locales are read for it.
1261
1236
  scope: contentScope,
1262
- locales: options.locales.locales
1237
+ locales: project.locales.locales
1263
1238
  });
1264
1239
  const previewRoute = () => createPreviewRoute({ token: env.draftToken });
1265
1240
  const previewDisableRoute = () => createPreviewDisableRoute();
1266
1241
  const pageOpensFullBleed = async (slug) => {
1267
- const { isEnabled } = await draftMode();
1268
- const perspective = isEnabled ? "draft" : "published";
1242
+ const perspective = await requestPerspective();
1269
1243
  const source = await loadContent({ perspective });
1270
1244
  const { locale, path } = splitLocalePath(pathFromSlug(slug), source.locales);
1271
1245
  const client = source.client({ perspective, locale });
1272
1246
  const resolved = client.resolvePage(path);
1273
1247
  return firstBlockFullBleed(resolved.document, components.registry);
1274
1248
  };
1249
+ const frontendUrl = projectFrontendUrl(project);
1275
1250
  return {
1251
+ routeIssues,
1276
1252
  collections: options.collections,
1277
1253
  contentScope,
1278
1254
  contentMode,
1279
1255
  env,
1256
+ project,
1280
1257
  routes,
1281
1258
  components,
1282
- ...options.frontendUrl !== void 0 ? { frontendUrl: options.frontendUrl } : {},
1259
+ ...frontendUrl !== void 0 ? { frontendUrl } : {},
1283
1260
  loadContent,
1284
1261
  loadGlobals,
1285
1262
  canvasRoute,
@@ -1291,6 +1268,6 @@ function createElytraHost(options) {
1291
1268
  }
1292
1269
  var PACKAGE = "@elytracms/next";
1293
1270
 
1294
- export { CANVAS_VALIDATION_SLUG, CanvasRenderer, ELYTRA_HOST_ENV_KEYS, ElytraImage, PACKAGE, assembleLiveSource, bodyItemsOf, canvasPageMetadata, canvasSitemapEntries, collectionCacheTag, createAssetImageLoader, createCanvasRoute, createCanvasSitemap, createContentSnapshot, createElytraHost, createPreviewDisableRoute, createPreviewRoute, createRevalidateRoute, createStaticContentSource, defineHostComponents, docCacheTag, elytraHostImage, evaluatePreviewRequest, evaluateRevalidateRequest, firstBlockFullBleed, loadLiveContent, mergeCacheTags, mergeMetadataOverParent, nextCacheTags, openGraphLocale, pathFromSlug, projectSweepTag, resolveCanvasPage, resolveGlobals, resolveHostEnv, revalidationTags, routeCacheTag, safeRedirectTarget, scopeCacheTag, withNextImage };
1271
+ export { CANVAS_VALIDATION_SLUG, CanvasRenderer, ELYTRA_HOST_ENV_KEYS, ElytraImage, PACKAGE, 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 };
1295
1272
  //# sourceMappingURL=index.js.map
1296
1273
  //# sourceMappingURL=index.js.map