@elytracms/next 0.0.85 → 0.0.86

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
@@ -3,10 +3,11 @@ import { ComponentRegistry, ComponentIssue, ComponentManifest } from '@elytracms
3
3
  export { ComponentManifest, PropField, SlotSpec, defineComponent } from '@elytracms/core/component-registry';
4
4
  import { ComponentImplementations, ResolveBinding, 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';
6
7
  import { ResolvePageResult, ResolvedAsset, Perspective, ContentClient, CacheTags, ContentDocumentSource, ResolvedDocument } from '@elytracms/core/content';
7
8
  export { Perspective } from '@elytracms/core/content';
8
9
  import { LocaleConfig, Locale, RouteRecord, CollectionDef, RedirectRecord } from '@elytracms/core/cms-core';
9
- export { CmsDocument, CollectionDef, FieldDef, Locale, LocaleConfig, LocaleUrlPath, RedirectRecord, RouteRecord, documentSchema, fallbackHomeRoutes, localeConfigSchema, localeUrlPath, mergeRouteRecords, redirectRecordSchema, routeRecordSchema, splitLocalePath } from '@elytracms/core/cms-core';
10
+ export { CmsDocument, CollectionDef, FieldDef, Locale, LocaleConfig, LocaleRequest, LocaleStrategy, LocaleUrlPath, RedirectRecord, RouteRecord, documentSchema, fallbackHomeRoutes, localeConfigSchema, localeUrlPath, mergeRouteRecords, redirectRecordSchema, resolveLocale, routeRecordSchema, splitLocalePath, textDirection } from '@elytracms/core/cms-core';
10
11
  import { ComponentNode } from '@elytracms/core/project-graph';
11
12
  export { ComponentNode } from '@elytracms/core/project-graph';
12
13
  import { AssetRecord, PersistenceAdapter } from '@elytracms/core/persistence';
@@ -16,12 +17,18 @@ import { z } from 'zod';
16
17
  export { z } from 'zod';
17
18
  import { ImageLoader } from 'next/image';
18
19
  import { HostImageRenderer } from './host-image.js';
19
- import { HostComponentEntry } from '@elytracms/core/studio-core';
20
20
 
21
21
  /**
22
22
  * One host-repo component: the manifest the builder edits against plus the
23
23
  * real React implementation that renders it (vision AD-2 — project components
24
24
  * are real code in the host repo, never generated).
25
+ *
26
+ * This is the STRICT shape — the one a hand-authored component module should
27
+ * export (`export const heroComponent: HostComponent = …`), so the compiler
28
+ * checks the implementation where it is written. It is assignable to the
29
+ * config's {@link HostComponentEntry}, which is what {@link defineHostComponents}
30
+ * accepts, so both hand-built lists and the config's own list register through
31
+ * the one signature (EC-541).
25
32
  */
26
33
  interface HostComponent {
27
34
  manifest: ComponentManifest;
@@ -36,7 +43,11 @@ interface HostComponent {
36
43
  interface HostComponents {
37
44
  registry: ComponentRegistry;
38
45
  implementations: ComponentImplementations;
39
- /** Structural registration issues (duplicate ids, invalid namespaces). */
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
+ */
40
51
  issues: readonly ComponentIssue[];
41
52
  }
42
53
  interface DefineHostComponentsOptions {
@@ -53,8 +64,29 @@ interface DefineHostComponentsOptions {
53
64
  * default. A host implementation registered under a primitive id replaces the
54
65
  * default implementation (the manifest of the first registration wins, which
55
66
  * is the primitive's — by design, so prop schemas stay canonical).
56
- */
57
- declare function defineHostComponents(components?: readonly HostComponent[], options?: DefineHostComponentsOptions): HostComponents;
67
+ *
68
+ * Accepts the CONFIG's entry shape (`implementation: unknown`) rather than the
69
+ * strict {@link HostComponent}: the config declares implementations opaquely,
70
+ * and EC-539 made `createElytraHost({ ...site })` the contract, so a
71
+ * compile-time check at this seam was already gone for the recommended path.
72
+ * The check is RUNTIME instead (EC-541): every implementation is tested with
73
+ * {@link isComponentImplementation}; a value that is not a component is
74
+ * recorded as an `invalid-implementation` issue naming the component id and
75
+ * the received kind, and is NOT put into `implementations` — so the renderer
76
+ * takes its missing-component path (a visible EC-015 fallback) instead of
77
+ * crashing the server render on `createElement` ("Element type is invalid")
78
+ * or, for a string, silently rendering an HTML tag. The manifest still
79
+ * registers, so the registry's own issues (duplicate id, invalid id) for that
80
+ * entry are reported alongside.
81
+ *
82
+ * One consequence of the `unknown` parameter: an INLINE
83
+ * `implementation: (props) => …` literal no longer gets `props` contextually
84
+ * typed (it is `noImplicitAny` under strict). Annotate the parameter, or
85
+ * author the entry as a {@link HostComponent} — which is the documented shape
86
+ * anyway. (An overload pair was tried and does not restore it: TypeScript
87
+ * resolves the context-sensitive literal against the wider overload.)
88
+ */
89
+ declare function defineHostComponents(components?: readonly HostComponentEntry[], options?: DefineHostComponentsOptions): HostComponents;
58
90
 
59
91
  /**
60
92
  * Binding payloads for the embedded runtime (EC-144): one payload value per
@@ -598,6 +630,13 @@ interface CanvasCacheOptions {
598
630
  * Omit only when the emitter also emits unscoped tags (see `cache.ts`).
599
631
  */
600
632
  scope?: string;
633
+ /**
634
+ * Which content world `loadContent` reads — the delivery kit passes its
635
+ * `contentMode` (`'live' | 'fallback' | 'empty'`), so entries written in one
636
+ * world can never be served in another (EC-543, see `canvasCacheKey`). A host
637
+ * driving this helper directly with a single world may omit it.
638
+ */
639
+ mode?: string;
601
640
  }
602
641
  /**
603
642
  * What the route helper tells the content loader about the request it is
@@ -1503,13 +1542,16 @@ interface ElytraHostOptions {
1503
1542
  * / `WorkspaceProjectEntry` annotation did not. Both production consumers hit
1504
1543
  * exactly that and each carried one hand-restated `components` line (EC-539).
1505
1544
  *
1506
- * What this costs, stated plainly rather than glossed: a list passed HERE no
1507
- * longer gets a compile-time check that each `implementation` is really a
1508
- * component, and nothing yet checks it at runtime either — a garbage value
1509
- * would reach the renderer. `defineHostComponents` keeps the strict
1510
- * {@link HostComponent} signature, so a hand-built list passed there directly
1511
- * is still checked. Closing the gap properly needs a registration issue code
1512
- * in the core registry; booked as EC-541 rather than smuggled in here.
1545
+ * What the widening costs, and where it is paid (EC-541): a list passed HERE
1546
+ * gets no compile-time check that each `implementation` is really a
1547
+ * component. The check is RUNTIME instead — `defineHostComponents` takes the
1548
+ * same `HostComponentEntry` shape (no cast, one signature) and tests every
1549
+ * implementation on registration; a value that is not a component becomes an
1550
+ * `invalid-implementation` issue in `components.issues` and is withheld from
1551
+ * the implementation map, so its nodes render the visible missing-component
1552
+ * fallback rather than crashing the server render. Authoring a component
1553
+ * module as the strict {@link HostComponent} keeps the compile-time check at
1554
+ * the place the implementation is written.
1513
1555
  */
1514
1556
  components?: readonly HostComponentEntry[];
1515
1557
  /**
@@ -1533,8 +1575,10 @@ interface ElytraHostOptions {
1533
1575
  * The no-backend content fallback, used whenever `ELYTRA_CONVEX_URL` +
1534
1576
  * `ELYTRA_PROJECT_ID` are not both set. Default: a build-safe EMPTY source —
1535
1577
  * consumer CI builds green with no backend, and every URL is a clean 404
1536
- * until content exists. The starter template overrides this with its seeded
1537
- * in-memory world.
1578
+ * until content exists — in every cache state, not only a cold one, because
1579
+ * the content mode is part of the route's data-cache key (see
1580
+ * {@link ElytraContentMode}). The starter template overrides this with its
1581
+ * seeded in-memory world.
1538
1582
  */
1539
1583
  fallbackContent?: (request: CanvasContentRequest) => Promise<ContentSource>;
1540
1584
  /** Chrome-globals conventions (settings/navigation collection ids, items fields). */
@@ -1560,6 +1604,8 @@ interface HostCanvasRouteOptions {
1560
1604
  * Tagged data cache (instant publish). Default: enabled, scoped to the
1561
1605
  * kit's content scope so the studio's webhook tags match. `false` disables
1562
1606
  * caching entirely; an explicit `CanvasCacheOptions` overrides the scope.
1607
+ * The kit's {@link ElytraHost.contentMode} is always part of the entry key
1608
+ * (EC-543) — an override can replace the scope, never drop the mode.
1563
1609
  */
1564
1610
  cache?: CanvasCacheOptions | false;
1565
1611
  }
@@ -1584,6 +1630,15 @@ interface LoadGlobalsOptions {
1584
1630
  * §5 gets no signal that the thing under test was never exercised. Declared
1585
1631
  * once, read here, so a host can *say* which world it is in instead of
1586
1632
  * re-deriving it from `env` and drifting.
1633
+ *
1634
+ * **The mode is also part of the route's data-cache key** (EC-543). A cache
1635
+ * entry outlives the process that wrote it and a hit never runs the loader, so
1636
+ * until 0.0.85 the "clean 404" above held only on a COLD cache: measured, a
1637
+ * `next start` with both env vars blanked and a warm `.next/cache` from a live
1638
+ * run answered `200` with the live world's bytes on every cached URL while
1639
+ * reporting `empty` — the wrong-world failure EC-527 removed, one layer down.
1640
+ * Keyed on the mode (`canvasCacheKey`, `cache.ts`), a world change is a cold
1641
+ * miss per URL, never a stale hit, in either direction.
1587
1642
  */
1588
1643
  type ElytraContentMode = 'live' | 'fallback' | 'empty';
1589
1644
  /** The delivery host handle {@link createElytraHost} yields. */
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@ import { isBlockSequenceValueShape } from '@elytracms/core/rich-text';
6
6
  import { jsx, Fragment } from 'react/jsx-runtime';
7
7
  import { cacheTags, isSectionSourceId, parseCmsSourceQueryConfig, listQueryOfCmsConfig, isDocumentHistory, resolvedAsset, createContentClient, createContentContext } from '@elytracms/core/content';
8
8
  import { documentKey, splitLocalePath, hierarchyMount, composePath, localeUrlPath, mergeRouteRecords, fallbackHomeRoutes, presentationBodyFields, splitPath, documentSchema, redirectRecordSchema } from '@elytracms/core/cms-core';
9
- export { documentSchema, fallbackHomeRoutes, localeConfigSchema, localeUrlPath, mergeRouteRecords, redirectRecordSchema, routeRecordSchema, splitLocalePath } from '@elytracms/core/cms-core';
9
+ export { documentSchema, fallbackHomeRoutes, localeConfigSchema, localeUrlPath, mergeRouteRecords, redirectRecordSchema, resolveLocale, routeRecordSchema, splitLocalePath, textDirection } from '@elytracms/core/cms-core';
10
10
  import { revalidateTag, unstable_cache } from 'next/cache';
11
11
  import { draftMode } from 'next/headers';
12
12
  import { redirect, notFound, permanentRedirect } from 'next/navigation';
@@ -15,6 +15,34 @@ import { z } from 'zod';
15
15
  export { z } from 'zod';
16
16
  import NextImage from 'next/image';
17
17
 
18
+ // src/components.ts
19
+
20
+ // src/is-component.ts
21
+ var EXOTIC_COMPONENT_TYPES = /* @__PURE__ */ new Set([
22
+ /* @__PURE__ */ Symbol.for("react.memo"),
23
+ /* @__PURE__ */ Symbol.for("react.forward_ref"),
24
+ /* @__PURE__ */ Symbol.for("react.lazy")
25
+ ]);
26
+ function isComponentImplementation(value) {
27
+ if (typeof value === "function") return true;
28
+ if (typeof value !== "object" || value === null) return false;
29
+ const marker = value.$$typeof;
30
+ return typeof marker === "symbol" && EXOTIC_COMPONENT_TYPES.has(marker);
31
+ }
32
+ function describeImplementation(value) {
33
+ if (value === null) return "null";
34
+ if (value === void 0) return "undefined";
35
+ if (typeof value === "string") {
36
+ return `the string ${JSON.stringify(value)} (which React would render silently as an HTML tag)`;
37
+ }
38
+ if (Array.isArray(value)) return "an array";
39
+ if (typeof value === "object") {
40
+ const marker = value.$$typeof;
41
+ return typeof marker === "symbol" ? `an object tagged ${String(marker)}` : "a plain object";
42
+ }
43
+ return `a ${typeof value}`;
44
+ }
45
+
18
46
  // src/components.ts
19
47
  function defineHostComponents(components = [], options = {}) {
20
48
  const includeBase = options.includeBasePrimitives !== false;
@@ -25,10 +53,20 @@ function defineHostComponents(components = [], options = {}) {
25
53
  const implementation = getImplementation(base.implementations, manifest.id);
26
54
  if (implementation) implementations[manifest.id] = implementation;
27
55
  }
56
+ const implementationIssues = [];
28
57
  for (const component of components) {
29
- implementations[component.manifest.id] = component.implementation;
58
+ const id = component.manifest.id;
59
+ if (isComponentImplementation(component.implementation)) {
60
+ implementations[id] = component.implementation;
61
+ continue;
62
+ }
63
+ implementationIssues.push({
64
+ code: "invalid-implementation",
65
+ componentId: id,
66
+ message: `Component "${id}": implementation is not a React component \u2014 received ${describeImplementation(component.implementation)}; expected a function or class component, or a React.memo / forwardRef / lazy wrapper. The entry is registered without an implementation, so its nodes render the missing-component fallback.`
67
+ });
30
68
  }
31
- return { registry, implementations, issues: registry.issues };
69
+ return { registry, implementations, issues: [...registry.issues, ...implementationIssues] };
32
70
  }
33
71
 
34
72
  // src/payloads.ts
@@ -533,6 +571,9 @@ function nextCacheTags(tags, scope) {
533
571
  for (const assetId of tags.assets) all.add(assetCacheTag(assetId, scope));
534
572
  return [...all].sort();
535
573
  }
574
+ function canvasCacheKey({ scope, mode, url }) {
575
+ return ["elytra-route", scope ?? "", mode ?? "", url];
576
+ }
536
577
  var CacheDiscoveryBail = class extends Error {
537
578
  constructor() {
538
579
  super("elytra cache discovery bail (never user-visible)");
@@ -582,6 +623,9 @@ function resolveOgImage(value, assets) {
582
623
  if (looksLikeUrl(value)) return value;
583
624
  return assets.find((asset) => asset.id === value)?.url ?? null;
584
625
  }
626
+ function openGraphLocale(locale) {
627
+ return locale.replace(/-/g, "_");
628
+ }
585
629
  function canvasPageMetadata(result, assets = []) {
586
630
  const doc = result.document;
587
631
  const seoTitle = str(doc?.["seoTitle"]);
@@ -596,11 +640,12 @@ function canvasPageMetadata(result, assets = []) {
596
640
  if (title !== null) metadata.title = title;
597
641
  if (seoDescription) metadata.description = seoDescription;
598
642
  const ogTitle = seoOgTitle ?? title;
599
- if (ogTitle !== null || seoDescription || seoOgImage) {
643
+ if (doc) {
600
644
  metadata.openGraph = {
601
645
  ...ogTitle !== null ? { title: ogTitle } : {},
602
646
  ...seoDescription ? { description: seoDescription } : {},
603
- ...seoOgImage ? { images: [{ url: seoOgImage }] } : {}
647
+ ...seoOgImage ? { images: [{ url: seoOgImage }] } : {},
648
+ locale: openGraphLocale(result.tags.locale)
604
649
  };
605
650
  }
606
651
  if (seoNoindex) metadata.robots = { index: false };
@@ -630,7 +675,7 @@ function createCanvasRoute(options) {
630
675
  const scope = options.cache.scope;
631
676
  const entry = await loadCachedEntry(
632
677
  nextCacheWrapper,
633
- ["elytra-route", scope ?? "", url],
678
+ canvasCacheKey({ scope, mode: options.cache.mode, url }),
634
679
  [projectSweepTag(scope)],
635
680
  async () => {
636
681
  const outcome = await resolveFresh();
@@ -1287,9 +1332,9 @@ function createElytraHost(options) {
1287
1332
  // canonical manifest stays (first registration wins in defineHostComponents).
1288
1333
  ...options.image === false ? [] : [nextImagePrimitive(options.image ?? {})],
1289
1334
  // EC-539: the option is the CONFIG's entry shape (`implementation: unknown`)
1290
- // so the recommended `{ ...site }` spread compiles. Narrowing here is the
1291
- // one place that widening is paid for; see the option's doc comment and
1292
- // EC-541 for the runtime check that should back it.
1335
+ // so the recommended `{ ...site }` spread compiles; EC-541: registration
1336
+ // takes that shape too and checks each implementation at runtime (see the
1337
+ // option's doc comment) — no narrowing cast here.
1293
1338
  ...options.components ?? []
1294
1339
  ]);
1295
1340
  const emptySource = createStaticContentSource({
@@ -1336,7 +1381,9 @@ function createElytraHost(options) {
1336
1381
  components,
1337
1382
  // Tag scope = the project id the studio publishes under, so its webhook
1338
1383
  // tags match this site's cached entries.
1339
- ...cache === false ? {} : { cache: cache ?? { scope: contentScope } },
1384
+ // EC-543: the mode is in the entry key so no world ever reads another's
1385
+ // entries; an explicit override may replace the scope, not drop the mode.
1386
+ ...cache === false ? {} : { cache: { mode: contentMode, ...cache ?? { scope: contentScope } } },
1340
1387
  ...rest
1341
1388
  });
1342
1389
  };