@elytracms/next 0.0.91 → 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.js CHANGED
@@ -3,9 +3,9 @@ import { Fragment as Fragment$1, isValidElement } from 'react';
3
3
  import { isBlockSequenceValueShape } from '@elytracms/core/rich-text';
4
4
  import { jsx, Fragment, jsxs } from 'react/jsx-runtime';
5
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';
6
+ import { DEFAULT_SEO_FIELD, parseLocaleConfig, documentKey, liveRedirectRecords, SEO_SUBFIELDS, splitLocalePath, DRAFT_PERSPECTIVE, splitPath, localeUrlPath, variantOf, DRAFT_VARIANT, createRouter, presentationBodyFields, documentSchema, redirectRecordSchema } from '@elytracms/core/cms-core';
7
+ export { DRAFT_PERSPECTIVE, documentSchema, localeConfigSchema, localeUrlPath, parseLocaleConfig, redirectRecordSchema, resolveLocale, routeRecordSchema, splitLocalePath, textDirection, variantOf } from '@elytracms/core/cms-core';
8
+ import { revalidateTag, cacheTag } from 'next/cache';
9
9
  import { draftMode } from 'next/headers';
10
10
  import { redirect, notFound, permanentRedirect } from 'next/navigation';
11
11
  import { COMPOSITE_KEY_SEPARATOR } from '@elytracms/core/project-graph';
@@ -13,6 +13,7 @@ import { verifyRevalidationSignature, readRevalidationBody, REVALIDATE_SIGNATURE
13
13
  export { REVALIDATE_SIGNATURE_HEADER, revalidationEventSchema, signRevalidationBody, signedRevalidationRequest } from '@elytracms/core/revalidation';
14
14
  import NextImage from 'next/image';
15
15
  import { normalizeImageSource } from '@elytracms/components';
16
+ import { projectFrontendUrl, resolveProject } from '@elytracms/core/studio-core';
16
17
  export { defineComponent } from '@elytracms/core/component-registry';
17
18
  export { z } from 'zod';
18
19
 
@@ -200,17 +201,11 @@ function createStaticContentSource(data) {
200
201
  };
201
202
  }
202
203
  async function createContentSnapshot(adapter, projectId, options) {
203
- const schema = await adapter.cms.findSchema(projectId);
204
204
  const documentRecords = await adapter.cms.listDocuments(projectId);
205
205
  const versionRecords = await adapter.cms.listAllDocumentVersions(projectId);
206
206
  const assetRecords = await adapter.assets.listAssets(projectId);
207
207
  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
- }));
208
+ const redirects = liveRedirectRecords(redirectRecords);
214
209
  const versionsByKey = /* @__PURE__ */ new Map();
215
210
  for (const record2 of versionRecords) {
216
211
  const key = `${record2.collection}:${record2.docId}`;
@@ -231,12 +226,89 @@ async function createContentSnapshot(adapter, projectId, options) {
231
226
  locales: options.locales,
232
227
  ...options.routes ? { routes: options.routes } : {},
233
228
  redirects: options.redirects ? [...options.redirects] : redirects,
234
- ...schema ? { collections: schema.collections } : {},
229
+ collections: options.collections,
235
230
  documents,
236
231
  assets: assetRecords,
237
232
  assetUrl: (asset) => adapter.blobs.url(asset.storageKey)
238
233
  });
239
234
  }
235
+ function seoContextOf(result, assets = []) {
236
+ return {
237
+ seoField: result.seoField ?? null,
238
+ title: result.title ?? null,
239
+ locale: result.tags.locale,
240
+ assets
241
+ };
242
+ }
243
+ function str(value) {
244
+ return typeof value === "string" && value.length > 0 ? value : null;
245
+ }
246
+ function looksLikeUrl(value) {
247
+ return /^(https?:)?\/\//.test(value) || value.startsWith("/");
248
+ }
249
+ function resolveSeoImage(value, assets) {
250
+ if (value && typeof value === "object") {
251
+ const url = value.url;
252
+ return typeof url === "string" && url.length > 0 ? url : null;
253
+ }
254
+ if (typeof value !== "string" || value.length === 0) return null;
255
+ if (looksLikeUrl(value)) return value;
256
+ return assets.find((asset) => asset.id === value)?.url ?? null;
257
+ }
258
+ function openGraphLocale(locale) {
259
+ return locale.includes("-") ? locale.replace(/-/g, "_") : null;
260
+ }
261
+ function seoObjectOf(doc, seoField = DEFAULT_SEO_FIELD) {
262
+ if (!doc || seoField === null) return {};
263
+ const value = doc[seoField ?? DEFAULT_SEO_FIELD];
264
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
265
+ }
266
+ function seoMetadata(doc, ctx = {}) {
267
+ const seo = seoObjectOf(doc, ctx.seoField);
268
+ const assets = ctx.assets ?? [];
269
+ const seoTitle = str(seo[SEO_SUBFIELDS.title]);
270
+ const description = str(seo[SEO_SUBFIELDS.description]);
271
+ const ogTitle = str(seo[SEO_SUBFIELDS.ogTitle]);
272
+ const ogDescription = str(seo[SEO_SUBFIELDS.ogDescription]);
273
+ const image = resolveSeoImage(seo[SEO_SUBFIELDS.image], assets);
274
+ const noindex = seo[SEO_SUBFIELDS.noindex] === true;
275
+ const canonical = str(seo[SEO_SUBFIELDS.canonical]);
276
+ const docTitle = ctx.title !== void 0 ? str(ctx.title) : str(doc?.["title"]) ?? str(doc?.["name"]);
277
+ const title = seoTitle ?? docTitle;
278
+ const metadata = {};
279
+ if (title !== null) metadata.title = title;
280
+ if (description) metadata.description = description;
281
+ if (doc) {
282
+ const effectiveOgTitle = ogTitle ?? title;
283
+ const effectiveOgDescription = ogDescription ?? description;
284
+ const ogLocale = ctx.locale !== void 0 ? openGraphLocale(ctx.locale) : null;
285
+ metadata.openGraph = {
286
+ ...effectiveOgTitle !== null ? { title: effectiveOgTitle } : {},
287
+ ...effectiveOgDescription ? { description: effectiveOgDescription } : {},
288
+ ...image ? { images: [{ url: image }] } : {},
289
+ ...ogLocale !== null ? { locale: ogLocale } : {}
290
+ };
291
+ }
292
+ if (noindex) metadata.robots = { index: false };
293
+ if (canonical) metadata.alternates = { canonical };
294
+ return metadata;
295
+ }
296
+ function mergeMetadataOverParent(parent, page) {
297
+ if (!parent) return page;
298
+ const merged = { ...page };
299
+ for (const [key, pageValue] of Object.entries(page)) {
300
+ const parentValue = parent[key];
301
+ if (isPlainObject(pageValue) && isPlainObject(parentValue)) {
302
+ merged[key] = { ...parentValue, ...pageValue };
303
+ }
304
+ }
305
+ return merged;
306
+ }
307
+ function isPlainObject(value) {
308
+ return typeof value === "object" && value !== null && !Array.isArray(value);
309
+ }
310
+
311
+ // src/route-core.ts
240
312
  function canvasWrapContext(result, info) {
241
313
  const collection = result.document?.collection;
242
314
  return {
@@ -260,7 +332,7 @@ function scopeAssetsToPage(allAssets, registry, result, body, resolveRelation) {
260
332
  for (const value of body.richTexts) {
261
333
  collectRichTextRefsInValue(value, { onAsset: (id) => ids.add(id) });
262
334
  }
263
- const ogImage = result.document?.["seoOgImage"];
335
+ const ogImage = seoObjectOf(result.document, result.seoField)[SEO_SUBFIELDS.image];
264
336
  const ogImageId = typeof ogImage === "string" ? ogImage : ogImage && typeof ogImage === "object" && typeof ogImage.id === "string" ? ogImage.id : null;
265
337
  if (ogImageId) ids.add(ogImageId);
266
338
  return ids.size === 0 ? [] : allAssets.filter((asset) => ids.has(asset.id));
@@ -457,14 +529,20 @@ function revalidationTags(event, project) {
457
529
  return [...tags].sort();
458
530
  }
459
531
  function canvasCacheKey({ scope, mode, url }) {
460
- return [scope ?? "", mode ?? "", url];
532
+ return JSON.stringify([scope ?? "", mode ?? "", url]);
461
533
  }
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;
534
+ function parseCanvasCacheKey(key) {
535
+ let parsed;
536
+ try {
537
+ parsed = JSON.parse(key);
538
+ } catch {
539
+ return void 0;
540
+ }
541
+ if (!Array.isArray(parsed) || parsed.length !== 3 || !parsed.every((part) => typeof part === "string")) {
542
+ return void 0;
543
+ }
544
+ const [scope, mode, url] = parsed;
545
+ return { scope, mode, url };
468
546
  }
469
547
  var NEXT_CACHE_TAG_CALL_LIMIT = 128;
470
548
  function entryTagCalls(tags, scope) {
@@ -476,100 +554,21 @@ function entryTagCalls(tags, scope) {
476
554
  }
477
555
  return calls;
478
556
  }
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
557
  var CANVAS_VALIDATION_SLUG = ["_elytra"];
557
558
  var CANVAS_VALIDATION_PATH = pathFromSlug([...CANVAS_VALIDATION_SLUG]);
558
559
  function defaultMetadata(result, assets) {
559
- return canvasPageMetadata(result, assets);
560
+ return seoMetadata(result.document, seoContextOf(result, assets));
560
561
  }
561
562
  async function requestPerspective() {
562
563
  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;
564
+ return isEnabled ? DRAFT_PERSPECTIVE : "published";
571
565
  }
572
566
  function createCanvasRoute(options) {
567
+ if (Object.hasOwn(options, "published") && options.published === void 0) {
568
+ throw new Error(
569
+ "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."
570
+ );
571
+ }
573
572
  const resolveFresh = async (url, perspective) => {
574
573
  const source = await options.loadContent({ perspective });
575
574
  return resolveCanvasPage(source, url, perspective, {
@@ -577,15 +576,29 @@ function createCanvasRoute(options) {
577
576
  registry: options.components.registry
578
577
  });
579
578
  };
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
- }
579
+ const scope = options.scope ?? "";
580
+ const mode = options.mode ?? "";
581
+ const keyFor = (url) => canvasCacheKey({ scope, mode, url });
582
+ const resolve = async (key) => {
583
+ const parts = parseCanvasCacheKey(key);
584
+ if (!parts) {
585
+ throw new Error(
586
+ `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.`
587
+ );
588
+ }
589
+ if (parts.scope !== scope || parts.mode !== mode) {
590
+ throw new Error(
591
+ `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().`
592
+ );
593
+ }
594
+ return resolveFresh(parts.url, "published");
595
+ };
596
+ const applyCacheTags = (result) => {
597
+ for (const tags of entryTagCalls(result.tags, scope)) cacheTag(...tags);
598
+ };
586
599
  const loadOutcome = async (url, perspective) => {
587
- if (!cache || perspective !== "published") return resolveFresh(url, perspective);
588
- return cachedCanvasOutcome(...entryKey(url));
600
+ if (perspective !== "published" || !options.published) return resolveFresh(url, perspective);
601
+ return options.published(keyFor(url));
589
602
  };
590
603
  const Page = async (props) => {
591
604
  const { slug } = await props.params;
@@ -622,7 +635,7 @@ function createCanvasRoute(options) {
622
635
  const url = pathFromSlug(slug);
623
636
  if (url === CANVAS_VALIDATION_PATH) return {};
624
637
  const perspective = await requestPerspective();
625
- if (cache && perspective === "published") {
638
+ if (options.published && perspective === "published") {
626
639
  const outcome2 = await loadOutcome(url, perspective);
627
640
  if (outcome2.kind !== "render") return {};
628
641
  return options.metadata ? options.metadata(outcome2.result, { url, perspective }) : defaultMetadata(outcome2.result, outcome2.assets);
@@ -639,29 +652,13 @@ function createCanvasRoute(options) {
639
652
  const generateStaticParams = async () => [
640
653
  { slug: [...CANVAS_VALIDATION_SLUG] }
641
654
  ];
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("/");
655
+ return { Page, generateMetadata, resolve, applyCacheTags, generateStaticParams };
659
656
  }
660
657
  function originOf(baseUrl) {
661
658
  return baseUrl.replace(/\/+$/, "");
662
659
  }
663
660
  function canvasSitemapEntries(source, options) {
664
- const routes = options.routes ?? source.routes ?? [];
661
+ const routes = source.routes ?? [];
665
662
  const locales = source.locales;
666
663
  const skipped = [];
667
664
  const skip = (route, reason) => {
@@ -678,83 +675,79 @@ function canvasSitemapEntries(source, options) {
678
675
  }
679
676
  return client;
680
677
  };
681
- const byUrl = /* @__PURE__ */ new Map();
682
- const byGroup = /* @__PURE__ */ new Map();
678
+ const candidates = [];
679
+ const collectionLocales = /* @__PURE__ */ new Map();
683
680
  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
- );
681
+ if (!route.document) continue;
682
+ const set = collectionLocales.get(route.document.collection) ?? /* @__PURE__ */ new Set();
683
+ for (const locale of route.locale ? [route.locale] : locales.locales) set.add(locale);
684
+ collectionLocales.set(route.document.collection, set);
685
+ }
686
+ for (const [collection, routeLocales] of collectionLocales) {
687
+ for (const locale of locales.locales) {
688
+ if (!routeLocales.has(locale)) continue;
689
+ for (const entry of clientFor(locale).urlsFor(collection)) {
690
+ candidates.push({
691
+ route: entry.route,
692
+ locale,
693
+ routePath: entry.routePath,
694
+ path: entry.path,
695
+ addressable: entry.addressable,
696
+ // EC-517: group by the DOCUMENT — the locale-invariant fact — scoped by route.
697
+ group: `${entry.route.id}${COMPOSITE_KEY_SEPARATOR}doc${COMPOSITE_KEY_SEPARATOR}${collection}${COMPOSITE_KEY_SEPARATOR}${entry.id}`
698
+ });
755
699
  }
756
700
  }
757
701
  }
702
+ for (const route of routes) {
703
+ if (route.document || splitPath(route.pattern).some((segment) => segment.startsWith(":"))) continue;
704
+ for (const locale of route.locale ? [route.locale] : locales.locales) {
705
+ const { urlPath, addressable } = localeUrlPath(route.pattern, locale, locales);
706
+ candidates.push({
707
+ route,
708
+ locale,
709
+ routePath: route.pattern,
710
+ path: urlPath,
711
+ addressable,
712
+ group: `${route.id}${COMPOSITE_KEY_SEPARATOR}path${COMPOSITE_KEY_SEPARATOR}${route.pattern}`
713
+ });
714
+ }
715
+ }
716
+ const byUrl = /* @__PURE__ */ new Map();
717
+ const byGroup = /* @__PURE__ */ new Map();
718
+ const tally = /* @__PURE__ */ new Map();
719
+ for (const candidate of candidates) {
720
+ const key = `${candidate.route.id}${COMPOSITE_KEY_SEPARATOR}${candidate.locale}`;
721
+ const count = tally.get(key) ?? { route: candidate.route, locale: candidate.locale, total: 0, unresolved: 0 };
722
+ count.total += 1;
723
+ tally.set(key, count);
724
+ const result = clientFor(candidate.locale).resolvePage(candidate.routePath, candidate.locale);
725
+ if (result.status !== "ok") {
726
+ if (result.status !== "redirect") count.unresolved += 1;
727
+ continue;
728
+ }
729
+ if (seoObjectOf(result.document, result.seoField)[SEO_SUBFIELDS.noindex] === true) continue;
730
+ if (!candidate.addressable) {
731
+ skip(
732
+ candidate.route,
733
+ `"${candidate.routePath}" (locale "${candidate.locale}") has no addressable URL \u2014 its first segment is read as a locale prefix`
734
+ );
735
+ continue;
736
+ }
737
+ const absolute = originOf(options.baseUrl) + candidate.path;
738
+ if (!byUrl.has(absolute)) byUrl.set(absolute, { group: candidate.group, locale: candidate.locale });
739
+ const perLocale = byGroup.get(candidate.group) ?? /* @__PURE__ */ new Map();
740
+ if (!perLocale.has(candidate.locale)) perLocale.set(candidate.locale, absolute);
741
+ byGroup.set(candidate.group, perLocale);
742
+ }
743
+ for (const { route, locale, total, unresolved } of tally.values()) {
744
+ if (total > 0 && unresolved === total && route.document && splitPath(route.pattern).at(-1)?.endsWith("*")) {
745
+ skip(
746
+ route,
747
+ `hierarchy mount over "${route.document.collection}" composed ${total} path(s) in locale "${locale}", none of which the resolver serves`
748
+ );
749
+ }
750
+ }
758
751
  const entries = [];
759
752
  for (const [url, { group }] of byUrl) {
760
753
  const perLocale = byGroup.get(group);
@@ -956,7 +949,7 @@ function parseRedirects(rows) {
956
949
  function assembleLiveSource(payload, options, perspective) {
957
950
  const locales = options.locales;
958
951
  const documents = parseDocuments(payload.documents);
959
- const routes = mergeRouteRecords([...options.routes], fallbackHomeRoutes(documents, locales));
952
+ const routes = [...options.routes];
960
953
  const documentSources = perspective === "published" ? documents.map((doc) => ({ draft: doc, published: doc, versions: [] })) : documents;
961
954
  return createStaticContentSource({
962
955
  projectId: payload.projectId,
@@ -975,7 +968,13 @@ async function loadLiveContent(options, perspective) {
975
968
  const fetchImpl = options.fetch ?? globalThis.fetch;
976
969
  const args = { projectId: options.projectId };
977
970
  let payload;
978
- if (perspective === "draft") {
971
+ const variant = variantOf(perspective);
972
+ if (variant !== void 0) {
973
+ if (variant !== DRAFT_VARIANT) {
974
+ throw new Error(
975
+ `Elytra live content: no delivery snapshot serves the "${variant}" variant \u2014 the deployment serves the published snapshot and the "${DRAFT_VARIANT}" variant's only.`
976
+ );
977
+ }
979
978
  if (!options.draftToken) {
980
979
  throw new Error(
981
980
  "Draft preview requested but DRAFT_PREVIEW_TOKEN is not configured on the host."
@@ -1059,8 +1058,6 @@ function resolveGlobals(source, options = {}) {
1059
1058
  if (typeof shaped.path === "string" && shaped.path.startsWith("/")) {
1060
1059
  return { href: shaped.path, openInNewTab };
1061
1060
  }
1062
- const slug = str2(shaped.slug);
1063
- if (slug) return { href: `/${slug}`, openInNewTab };
1064
1061
  return null;
1065
1062
  }
1066
1063
  return null;
@@ -1187,12 +1184,35 @@ function createPreviewDisableRoute() {
1187
1184
  }
1188
1185
 
1189
1186
  // src/host-kit.ts
1190
- function materializeRoutes(routes, collections) {
1191
- return typeof routes === "function" ? routes([...collections]) : [...routes];
1187
+ function resolveHostProject(entry) {
1188
+ const result = resolveProject(entry);
1189
+ if (result.ok) return result.project;
1190
+ const lines = result.issues.filter((issue) => issue.severity === "error").map((issue) => {
1191
+ const at = issue.path.length > 0 ? ` at ${issue.path.join(".")}` : "";
1192
+ return ` \u2022 ${issue.code}${at}: ${issue.message}`;
1193
+ });
1194
+ throw new Error(
1195
+ `Elytra delivery host: the project config "${String(entry["slug"])}" is invalid \u2014 the studio refuses it too:
1196
+ ${lines.join("\n")}`
1197
+ );
1198
+ }
1199
+ function surfaceRouteIssues(slug, issues) {
1200
+ if (issues.length === 0) return;
1201
+ const building = process.env["NEXT_PHASE"] === "phase-production-build";
1202
+ if (!building && process.env["NODE_ENV"] === "production") return;
1203
+ const lines = issues.map((issue) => ` \u2022 ${issue.severity} ${issue.code}: ${issue.message}`);
1204
+ const report = `[elytra] project "${slug}": ${issues.length} route issue(s) \u2014
1205
+ ${lines.join("\n")}`;
1206
+ if (issues.some((issue) => issue.severity === "error")) console.error(report);
1207
+ else console.warn(report);
1192
1208
  }
1193
1209
  function createElytraHost(options) {
1194
- const env = resolveHostEnv(options.env ?? process.env);
1195
- const routes = materializeRoutes(options.routes, options.collections);
1210
+ const { env: envOption, fallbackContent, globals, fetch: fetchOption, ...entry } = options;
1211
+ const project = resolveHostProject(entry);
1212
+ const env = resolveHostEnv(envOption ?? process.env);
1213
+ const routes = project.routes;
1214
+ const routeIssues = createRouter(project).issues;
1215
+ surfaceRouteIssues(project.slug, routeIssues);
1196
1216
  const live = env.convexUrl !== void 0 && env.projectId !== void 0;
1197
1217
  if (!live && (env.convexUrl !== void 0 || env.projectId !== void 0)) {
1198
1218
  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 +1220,16 @@ function createElytraHost(options) {
1200
1220
  `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
1221
  );
1202
1222
  }
1203
- const contentMode = live ? "live" : options.fallbackContent ? "fallback" : "empty";
1204
- const contentScope = live ? env.projectId : options.slug;
1205
- const components = defineHostComponents(options.components ?? []);
1223
+ const contentMode = live ? "live" : fallbackContent ? "fallback" : "empty";
1224
+ const contentScope = live ? env.projectId : project.slug;
1225
+ const components = defineHostComponents(project.components ?? []);
1206
1226
  const emptySource = createStaticContentSource({
1207
1227
  projectId: contentScope,
1208
- locales: options.locales,
1228
+ locales: project.locales,
1209
1229
  routes,
1210
1230
  // Redirects are editorial DATA (EC-303) — the no-backend build has none.
1211
1231
  redirects: [],
1212
- collections: [...options.collections],
1232
+ collections: project.collections,
1213
1233
  documents: [],
1214
1234
  assets: []
1215
1235
  });
@@ -1219,16 +1239,16 @@ function createElytraHost(options) {
1219
1239
  {
1220
1240
  convexUrl: env.convexUrl,
1221
1241
  projectId: env.projectId,
1222
- locales: options.locales,
1223
- collections: options.collections,
1242
+ locales: project.locales,
1243
+ collections: project.collections,
1224
1244
  routes,
1225
1245
  ...env.draftToken ? { draftToken: env.draftToken } : {},
1226
- ...options.fetch ? { fetch: options.fetch } : {}
1246
+ ...fetchOption ? { fetch: fetchOption } : {}
1227
1247
  },
1228
1248
  request.perspective
1229
1249
  );
1230
1250
  }
1231
- if (options.fallbackContent) return options.fallbackContent(request);
1251
+ if (fallbackContent) return fallbackContent(request);
1232
1252
  return Promise.resolve(emptySource);
1233
1253
  };
1234
1254
  const loadGlobals = async (globalsOptions = {}) => {
@@ -1236,21 +1256,22 @@ function createElytraHost(options) {
1236
1256
  const source = await loadContent({ perspective });
1237
1257
  return resolveGlobals(source, {
1238
1258
  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
1259
+ ...globals ? { conventions: globals } : {}
1252
1260
  });
1253
1261
  };
1262
+ const canvasRoute = (routeOptions = {}) => createCanvasRoute({
1263
+ ...routeOptions,
1264
+ loadContent,
1265
+ components,
1266
+ // EC-620: the route caches nothing itself; these reach the host's own cache
1267
+ // function. Tag scope = `contentScope`, the SAME value `revalidateRoute` derives
1268
+ // its invalidation tags with (EC-579) — so `route.applyCacheTags` writes exactly
1269
+ // the strings the receiver revalidates. EC-543: the mode rides in every cache key
1270
+ // the route hands out, so no world ever reads another's entries. Spread last:
1271
+ // neither can be overridden.
1272
+ scope: contentScope,
1273
+ mode: contentMode
1274
+ });
1254
1275
  const revalidateRoute = (overrides = {}) => createRevalidateRoute({
1255
1276
  // An unset secret keeps the endpoint CLOSED (401) — the kit never ships a
1256
1277
  // default secret (an auth-shaped no-op is worse than an explicit gap).
@@ -1259,27 +1280,29 @@ function createElytraHost(options) {
1259
1280
  // under (`canvasRoute` above) and every locale it serves. The emitter sends only
1260
1281
  // the event, so this is the one place the scope and locales are read for it.
1261
1282
  scope: contentScope,
1262
- locales: options.locales.locales
1283
+ locales: project.locales.locales
1263
1284
  });
1264
1285
  const previewRoute = () => createPreviewRoute({ token: env.draftToken });
1265
1286
  const previewDisableRoute = () => createPreviewDisableRoute();
1266
1287
  const pageOpensFullBleed = async (slug) => {
1267
- const { isEnabled } = await draftMode();
1268
- const perspective = isEnabled ? "draft" : "published";
1288
+ const perspective = await requestPerspective();
1269
1289
  const source = await loadContent({ perspective });
1270
1290
  const { locale, path } = splitLocalePath(pathFromSlug(slug), source.locales);
1271
1291
  const client = source.client({ perspective, locale });
1272
1292
  const resolved = client.resolvePage(path);
1273
1293
  return firstBlockFullBleed(resolved.document, components.registry);
1274
1294
  };
1295
+ const frontendUrl = projectFrontendUrl(project);
1275
1296
  return {
1297
+ routeIssues,
1276
1298
  collections: options.collections,
1277
1299
  contentScope,
1278
1300
  contentMode,
1279
1301
  env,
1302
+ project,
1280
1303
  routes,
1281
1304
  components,
1282
- ...options.frontendUrl !== void 0 ? { frontendUrl: options.frontendUrl } : {},
1305
+ ...frontendUrl !== void 0 ? { frontendUrl } : {},
1283
1306
  loadContent,
1284
1307
  loadGlobals,
1285
1308
  canvasRoute,
@@ -1291,6 +1314,6 @@ function createElytraHost(options) {
1291
1314
  }
1292
1315
  var PACKAGE = "@elytracms/next";
1293
1316
 
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 };
1317
+ 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
1318
  //# sourceMappingURL=index.js.map
1296
1319
  //# sourceMappingURL=index.js.map