@elytracms/next 0.0.92 → 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.
@@ -1,4 +1,5 @@
1
1
  import { ReactNode } from 'react';
2
+ import { ResolvedAsset } from '@elytracms/core/cms-core';
2
3
 
3
4
  /**
4
5
  * `@elytracms/next/host-image` — the host image-delivery seam for DUAL-RENDERED
@@ -37,26 +38,21 @@ import { ReactNode } from 'react';
37
38
  * second singleton instance into the server entry, disconnecting
38
39
  * `setHostImage` from `getHostImage` — pinned by `package-build.test.ts`).
39
40
  */
40
- /** The resolved-asset slice a host image renderer needs (mirrors `ResolvedAsset`). */
41
- interface HostImageAsset {
42
- url: string;
43
- width: number | null;
44
- height: number | null;
45
- alt: string | null;
46
- focalPoint: {
47
- x: number;
48
- y: number;
49
- } | null;
50
- /**
51
- * LQIP blur-placeholder data URI (EC-420) → next/image blurDataURL. Optional so a
52
- * consumer mapping a `ResolvedAsset` in stays source-compatible; when present the
53
- * delivered image blurs up while the optimized bytes load.
54
- */
55
- lqip?: string | null;
56
- }
41
+ /**
42
+ * The resolved-asset slice a host image renderer needs — DERIVED from `ResolvedAsset`
43
+ * (EC-587; it used to mirror it by hand). `lqip` (EC-420 → next/image blurDataURL) is
44
+ * optional so a caller handing a partial asset stays source-compatible; when present
45
+ * the delivered image blurs up while the optimized bytes load.
46
+ */
47
+ type HostImageAsset = Pick<ResolvedAsset, 'url' | 'width' | 'height' | 'alt' | 'focalPoint'> & Partial<Pick<ResolvedAsset, 'lqip'>>;
57
48
  interface HostImageProps {
58
49
  asset: HostImageAsset;
59
- alt: string;
50
+ /**
51
+ * The image's alt text. Omitted → the ASSET's alt (EC-587: an asset's alt has one
52
+ * home and one reader). Pass it only to override deliberately — e.g. `''` for a
53
+ * decorative image.
54
+ */
55
+ alt?: string;
60
56
  className?: string;
61
57
  loading?: 'eager' | 'lazy';
62
58
  /** Above-the-fold opt-out of lazy-loading (the hero image). */
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/host-image.ts"],"names":[],"mappings":";AAoEA,IAAI,SAAA,GAAsC,IAAA;AAGnC,SAAS,aAAa,QAAA,EAA0C;AACrE,EAAA,SAAA,GAAY,QAAA;AACd;AAGO,SAAS,YAAA,GAAyC;AACvD,EAAA,OAAO,SAAA;AACT;AAiBO,IAAM,yBAAA,GAAgF;AAAA,EAC3F,EAAE,QAAA,EAAU,OAAA,EAAS,QAAA,EAAU,gBAAA;AACjC","file":"host-image.js","sourcesContent":["import type { ReactNode } from 'react'\n\n/**\n * `@elytracms/next/host-image` — the host image-delivery seam for DUAL-RENDERED\n * project components (EC-368, from the consumer-proven EC-271 pattern).\n *\n * The `project.*` page-builder components are dual-rendered: the SAME module\n * renders in the Vite studio canvas AND the Next host (AD-2/AD-11), so it may\n * not statically import `next/image` — that would break the Vite build. And in\n * the Next host these components render as Server Components, where React\n * context is unavailable, so an optimized `<Image>` can't be threaded through a\n * provider either.\n *\n * The seam that satisfies both: a tiny module singleton the host sets once at\n * boot. The studio never sets it (it renders the components via the root\n * `elytra.config`, which never imports the Next host), so the canvas keeps a\n * plain-`<img>` fallback; the Next host injects the `ElytraImage`-backed\n * renderer so content images go through `next/image`'s optimizer:\n *\n * ```ts\n * // frontend/lib/host.ts — imported once by the catch-all route at boot\n * import { elytraHostImage } from '@elytracms/next'\n * import { setHostImage } from '@elytracms/next/host-image'\n *\n * setHostImage(elytraHostImage())\n * ```\n *\n * WHEN (and whether) to register stays consumer-owned — legacy-interop windows\n * are real: a host whose global `next/image` config is still owned by a\n * previous stack (e.g. a Sanity-era custom loader) deliberately skips\n * registration until the conflict clears, and its components keep the honest\n * plain-`<img>` fallback.\n *\n * This module is deliberately its OWN subpath entry importing nothing but React\n * types — safe in Vite, in RSC, and in `next.config.*`. The main\n * `@elytracms/next` entry never imports it at runtime (that would bundle a\n * second singleton instance into the server entry, disconnecting\n * `setHostImage` from `getHostImage` — pinned by `package-build.test.ts`).\n */\n\n/** The resolved-asset slice a host image renderer needs (mirrors `ResolvedAsset`). */\nexport interface HostImageAsset {\n url: string\n width: number | null\n height: number | null\n alt: string | null\n focalPoint: { x: number; y: number } | null\n /**\n * LQIP blur-placeholder data URI (EC-420) → next/image blurDataURL. Optional so a\n * consumer mapping a `ResolvedAsset` in stays source-compatible; when present the\n * delivered image blurs up while the optimized bytes load.\n */\n lqip?: string | null\n}\n\nexport interface HostImageProps {\n asset: HostImageAsset\n alt: string\n className?: string\n loading?: 'eager' | 'lazy'\n /** Above-the-fold opt-out of lazy-loading (the hero image). */\n priority?: boolean\n /** Responsive `sizes` hint so the optimizer serves a column-sized variant. */\n sizes?: string\n}\n\nexport type HostImageRenderer = (props: HostImageProps) => ReactNode\n\nlet hostImage: HostImageRenderer | null = null\n\n/** Host-only: register the optimized image renderer (called once at host boot). */\nexport function setHostImage(renderer: HostImageRenderer | null): void {\n hostImage = renderer\n}\n\n/** The registered host renderer, or `null` in the studio canvas (plain-`<img>` fallback). */\nexport function getHostImage(): HostImageRenderer | null {\n return hostImage\n}\n\n/**\n * The `next.config.*` `images.remotePatterns` preset for Elytra asset delivery\n * (EC-368 image defaults): Convex file-storage serve URLs, where uploaded\n * assets live in connected mode. Without it `next/image` refuses the remote\n * origin and delivery images 400 — spread it into your config:\n *\n * ```js\n * // next.config.mjs\n * import { convexAssetRemotePatterns } from '@elytracms/next/host-image'\n *\n * const nextConfig = {\n * images: { remotePatterns: [...convexAssetRemotePatterns] },\n * }\n * ```\n */\nexport const convexAssetRemotePatterns: readonly { protocol: 'https'; hostname: string }[] = [\n { protocol: 'https', hostname: '*.convex.cloud' },\n]\n"]}
1
+ {"version":3,"sources":["../src/host-image.ts"],"names":[],"mappings":";AAoEA,IAAI,SAAA,GAAsC,IAAA;AAGnC,SAAS,aAAa,QAAA,EAA0C;AACrE,EAAA,SAAA,GAAY,QAAA;AACd;AAGO,SAAS,YAAA,GAAyC;AACvD,EAAA,OAAO,SAAA;AACT;AAiBO,IAAM,yBAAA,GAAgF;AAAA,EAC3F,EAAE,QAAA,EAAU,OAAA,EAAS,QAAA,EAAU,gBAAA;AACjC","file":"host-image.js","sourcesContent":["import type { ReactNode } from 'react'\nimport type { ResolvedAsset } from '@elytracms/core/cms-core'\n\n/**\n * `@elytracms/next/host-image` — the host image-delivery seam for DUAL-RENDERED\n * project components (EC-368, from the consumer-proven EC-271 pattern).\n *\n * The `project.*` page-builder components are dual-rendered: the SAME module\n * renders in the Vite studio canvas AND the Next host (AD-2/AD-11), so it may\n * not statically import `next/image` — that would break the Vite build. And in\n * the Next host these components render as Server Components, where React\n * context is unavailable, so an optimized `<Image>` can't be threaded through a\n * provider either.\n *\n * The seam that satisfies both: a tiny module singleton the host sets once at\n * boot. The studio never sets it (it renders the components via the root\n * `elytra.config`, which never imports the Next host), so the canvas keeps a\n * plain-`<img>` fallback; the Next host injects the `ElytraImage`-backed\n * renderer so content images go through `next/image`'s optimizer:\n *\n * ```ts\n * // frontend/lib/host.ts — imported once by the catch-all route at boot\n * import { elytraHostImage } from '@elytracms/next'\n * import { setHostImage } from '@elytracms/next/host-image'\n *\n * setHostImage(elytraHostImage())\n * ```\n *\n * WHEN (and whether) to register stays consumer-owned — legacy-interop windows\n * are real: a host whose global `next/image` config is still owned by a\n * previous stack (e.g. a Sanity-era custom loader) deliberately skips\n * registration until the conflict clears, and its components keep the honest\n * plain-`<img>` fallback.\n *\n * This module is deliberately its OWN subpath entry importing nothing but React\n * types — safe in Vite, in RSC, and in `next.config.*`. The main\n * `@elytracms/next` entry never imports it at runtime (that would bundle a\n * second singleton instance into the server entry, disconnecting\n * `setHostImage` from `getHostImage` — pinned by `package-build.test.ts`).\n */\n\n/**\n * The resolved-asset slice a host image renderer needs — DERIVED from `ResolvedAsset`\n * (EC-587; it used to mirror it by hand). `lqip` (EC-420 → next/image blurDataURL) is\n * optional so a caller handing a partial asset stays source-compatible; when present\n * the delivered image blurs up while the optimized bytes load.\n */\nexport type HostImageAsset = Pick<ResolvedAsset, 'url' | 'width' | 'height' | 'alt' | 'focalPoint'> &\n Partial<Pick<ResolvedAsset, 'lqip'>>\n\nexport interface HostImageProps {\n asset: HostImageAsset\n /**\n * The image's alt text. Omitted → the ASSET's alt (EC-587: an asset's alt has one\n * home and one reader). Pass it only to override deliberately — e.g. `''` for a\n * decorative image.\n */\n alt?: string\n className?: string\n loading?: 'eager' | 'lazy'\n /** Above-the-fold opt-out of lazy-loading (the hero image). */\n priority?: boolean\n /** Responsive `sizes` hint so the optimizer serves a column-sized variant. */\n sizes?: string\n}\n\nexport type HostImageRenderer = (props: HostImageProps) => ReactNode\n\nlet hostImage: HostImageRenderer | null = null\n\n/** Host-only: register the optimized image renderer (called once at host boot). */\nexport function setHostImage(renderer: HostImageRenderer | null): void {\n hostImage = renderer\n}\n\n/** The registered host renderer, or `null` in the studio canvas (plain-`<img>` fallback). */\nexport function getHostImage(): HostImageRenderer | null {\n return hostImage\n}\n\n/**\n * The `next.config.*` `images.remotePatterns` preset for Elytra asset delivery\n * (EC-368 image defaults): Convex file-storage serve URLs, where uploaded\n * assets live in connected mode. Without it `next/image` refuses the remote\n * origin and delivery images 400 — spread it into your config:\n *\n * ```js\n * // next.config.mjs\n * import { convexAssetRemotePatterns } from '@elytracms/next/host-image'\n *\n * const nextConfig = {\n * images: { remotePatterns: [...convexAssetRemotePatterns] },\n * }\n * ```\n */\nexport const convexAssetRemotePatterns: readonly { protocol: 'https'; hostname: string }[] = [\n { protocol: 'https', hostname: '*.convex.cloud' },\n]\n"]}
package/dist/index.d.ts CHANGED
@@ -310,6 +310,12 @@ interface StaticContentData {
310
310
  * explicit unresolved-placeholder scheme (never a silent empty string).
311
311
  */
312
312
  assetUrl?: (asset: AssetRecord) => string;
313
+ /**
314
+ * EC-585 — the stored rows the host could not read (`parseDeliveredDocuments`), handed
315
+ * to every client of this source as its `sourceIssues` (and in the results they
316
+ * concern). A malformed row is not delivered, and never silently.
317
+ */
318
+ sourceIssues?: readonly CmsValidationIssue[];
313
319
  }
314
320
  /**
315
321
  * Assemble a `ContentSource` from plain prefetched data — the pure half of
package/dist/index.js CHANGED
@@ -1,9 +1,8 @@
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 { 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';
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';
7
6
  export { DRAFT_PERSPECTIVE, documentSchema, localeConfigSchema, localeUrlPath, parseLocaleConfig, redirectRecordSchema, resolveLocale, routeRecordSchema, splitLocalePath, textDirection, variantOf } from '@elytracms/core/cms-core';
8
7
  import { revalidateTag, cacheTag } from 'next/cache';
9
8
  import { draftMode } from 'next/headers';
@@ -34,33 +33,15 @@ function listingResolver(listings) {
34
33
  return Array.isArray(baked) ? baked : null;
35
34
  };
36
35
  }
37
- function listingFailureOf(node, components, listings) {
38
- const manifest = components.registry.getManifest(node.componentId);
39
- if (!manifest) return null;
40
- const listing = nodeListingOf(node, manifest, components.registry);
41
- if (!listing || listing.state === "awaiting") return null;
42
- if (listing.state === "invalid") return listing.messages;
43
- const baked = listings?.[serializeListingQuery(listing.query)];
44
- return baked && !Array.isArray(baked) ? baked.errors.map((error) => error.message) : null;
45
- }
46
- function InvalidListingNotice(props) {
47
- 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
+ };
48
41
  }
49
42
  function assetResolver(assets) {
50
43
  const byId = new Map(assets.map((asset) => [asset.id, asset]));
51
- return (id) => {
52
- const asset = byId.get(id);
53
- return asset ? {
54
- url: asset.url,
55
- width: asset.width,
56
- height: asset.height,
57
- alt: asset.alt,
58
- focalPoint: asset.focalPoint,
59
- // EC-420: carry the LQIP so a placed Image blurs up on delivery.
60
- lqip: asset.lqip,
61
- meta: asset.meta
62
- } : null;
63
- };
44
+ return (id) => byId.get(id) ?? null;
64
45
  }
65
46
  function UnresolvedPageFallback(props) {
66
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}).` });
@@ -68,41 +49,28 @@ function UnresolvedPageFallback(props) {
68
49
  function MissingDocumentFallback() {
69
50
  return /* @__PURE__ */ jsx("div", { "data-ec-fallback": "missing-document", children: `Route resolved but carried no page document to render.` });
70
51
  }
71
- function stripServerErrorBoundary(_node, rendered) {
72
- if (isValidElement(rendered) && rendered.type === RenderErrorBoundary) {
73
- return rendered.props.children ?? null;
74
- }
75
- return rendered;
76
- }
77
52
  async function CanvasRenderer(props) {
78
53
  const { result, components } = props;
79
54
  if (result.status !== "ok") {
80
55
  return /* @__PURE__ */ jsx(UnresolvedPageFallback, { status: result.status });
81
56
  }
82
- const ctx = {
83
- registry: components.registry,
84
- implementations: components.implementations,
85
- resolveAsset: assetResolver(props.assets ?? []),
86
- // EC-254: wire relation population only when the host baked the populated
87
- // targets, so a host that did not opt in keeps the raw-ref passthrough.
88
- ...props.relations ? { resolveRelation: relationResolver(props.relations) } : {},
89
- // EC-255: wire the listing resolver only when the host baked the query results,
90
- // so a host that did not opt in keeps listing props empty (the block's own state).
91
- ...props.listings ? { resolveListing: listingResolver(props.listings) } : {},
92
- // EC-291: wire the rich-text link resolver only when the host baked the link map,
93
- // so a host that did not opt in degrades a reference link to the visible
94
- // `unresolved-ref` issue (EC-290) rather than a wrong href.
95
- ...props.links ? { resolveLink: linkResolver(props.links) } : {},
96
- decorateNode: (node, rendered) => {
97
- const stripped = stripServerErrorBoundary(node, rendered);
98
- const failure = listingFailureOf(node, components, props.listings);
99
- if (!failure) return stripped;
100
- return /* @__PURE__ */ jsxs(Fragment$1, { children: [
101
- /* @__PURE__ */ jsx(InvalidListingNotice, { nodeId: node.id, messages: failure }),
102
- stripped
103
- ] }, 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) } : {}
104
72
  }
105
- };
73
+ });
106
74
  const document = result.document;
107
75
  if (document) {
108
76
  return /* @__PURE__ */ jsx(Fragment, { children: renderDocumentBodyItems(bodyItemsOf(document, props.presentation), ctx) });
@@ -181,7 +149,8 @@ function createStaticContentSource(data) {
181
149
  locales
182
150
  }),
183
151
  routes,
184
- redirects
152
+ redirects,
153
+ ...data.sourceIssues ? { sourceIssues: data.sourceIssues } : {}
185
154
  });
186
155
  return {
187
156
  projectId: data.projectId,
@@ -243,16 +212,12 @@ function seoContextOf(result, assets = []) {
243
212
  function str(value) {
244
213
  return typeof value === "string" && value.length > 0 ? value : null;
245
214
  }
246
- function looksLikeUrl(value) {
247
- return /^(https?:)?\/\//.test(value) || value.startsWith("/");
248
- }
249
215
  function resolveSeoImage(value, assets) {
250
216
  if (value && typeof value === "object") {
251
217
  const url = value.url;
252
218
  return typeof url === "string" && url.length > 0 ? url : null;
253
219
  }
254
220
  if (typeof value !== "string" || value.length === 0) return null;
255
- if (looksLikeUrl(value)) return value;
256
221
  return assets.find((asset) => asset.id === value)?.url ?? null;
257
222
  }
258
223
  function openGraphLocale(locale) {
@@ -878,7 +843,7 @@ function elytraHostImage(options = {}) {
878
843
  return ({ asset, alt, className, priority, sizes, loading }) => /* @__PURE__ */ jsx(
879
844
  ElytraImage,
880
845
  {
881
- asset: { ...asset, alt },
846
+ asset: { ...asset, alt: alt ?? asset.alt },
882
847
  ...className !== void 0 ? { className } : {},
883
848
  ...priority ? { priority: true } : {},
884
849
  ...sizes !== void 0 ? { sizes } : {},
@@ -930,14 +895,6 @@ async function runConvexQuery(fetchImpl, convexUrl, path, args) {
930
895
  }
931
896
  return body.value;
932
897
  }
933
- function parseDocuments(rows) {
934
- const documents = [];
935
- for (const row of rows) {
936
- const parsed = documentSchema.safeParse(row);
937
- if (parsed.success) documents.push(parsed.data);
938
- }
939
- return documents;
940
- }
941
898
  function parseRedirects(rows) {
942
899
  const redirects = [];
943
900
  for (const row of rows) {
@@ -948,7 +905,7 @@ function parseRedirects(rows) {
948
905
  }
949
906
  function assembleLiveSource(payload, options, perspective) {
950
907
  const locales = options.locales;
951
- const documents = parseDocuments(payload.documents);
908
+ const { documents, issues: sourceIssues } = parseDeliveredDocuments(payload.documents);
952
909
  const routes = [...options.routes];
953
910
  const documentSources = perspective === "published" ? documents.map((doc) => ({ draft: doc, published: doc, versions: [] })) : documents;
954
911
  return createStaticContentSource({
@@ -961,7 +918,8 @@ function assembleLiveSource(payload, options, perspective) {
961
918
  redirects: parseRedirects(payload.redirects ?? []),
962
919
  collections: [...options.collections],
963
920
  documents: documentSources,
964
- assets: payload.assets
921
+ assets: payload.assets,
922
+ ...sourceIssues.length > 0 ? { sourceIssues } : {}
965
923
  });
966
924
  }
967
925
  async function loadLiveContent(options, perspective) {
@@ -1023,11 +981,7 @@ function resolveGlobals(source, options = {}) {
1023
981
  const navDocs = listDocs(navigationCollection);
1024
982
  const navigation = (slot) => navDocs.find((doc) => doc.alias === slot) ?? null;
1025
983
  const assetUrl = (value) => {
1026
- if (typeof value === "string") {
1027
- const found = assetById.get(value);
1028
- if (found) return found.url;
1029
- return value.startsWith("http") || value.startsWith("/") ? value : void 0;
1030
- }
984
+ if (typeof value === "string") return assetById.get(value)?.url;
1031
985
  const url = record(value).url;
1032
986
  return typeof url === "string" && url.length > 0 ? url : void 0;
1033
987
  };