@murumets-ee/content 0.90.0 → 0.92.0

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.
@@ -222,6 +222,11 @@ declare function resolveBlockTree(blocks: unknown, opts: {
222
222
  claimCollection?: (() => boolean) | undefined; /** A CODED TEMPLATE's root block name — `blocks` is then ignored and the template rebuilt. */
223
223
  template?: string | undefined; /** The row a templated document's bound blocks read. Read, never written. */
224
224
  boundValues?: Readonly<Record<string, unknown>> | undefined;
225
+ /**
226
+ * `blocks` is a stored detail template drawn for `boundValues` (block-library
227
+ * D009). The row's detail is projected; its columns are NOT walked for links.
228
+ */
229
+ rowIsForeign?: boolean | undefined;
225
230
  }, deps: BlockResolverDeps): Promise<ResolvedBlockTree>;
226
231
  /**
227
232
  * Wire {@link resolveBlockTree} to the running app — the one line an app puts
@@ -257,7 +262,8 @@ declare function createBlockResolver(getApp: () => Promise<ToolkitApp> | Toolkit
257
262
  canReadEntity?: ((entityName: string) => boolean) | undefined;
258
263
  claimCollection?: (() => boolean) | undefined;
259
264
  template?: string | undefined;
260
- boundValues?: Readonly<Record<string, unknown>> | undefined;
265
+ boundValues?: Readonly<Record<string, unknown>> | undefined; /** See `resolveBlockTree`: a stored detail template drawn for a row it does not own. */
266
+ rowIsForeign?: boolean | undefined;
261
267
  }) => Promise<ResolvedBlockTree>;
262
268
  //#endregion
263
269
  export { BlockCollectionDeps, BlockMediaProjection, BlockMediaResolver, BlockResolutionBudget, BlockResolverDeps, BlockResolverLogger, BlockSpotSources, BlockTreeRef, ResolvedBlockTree, UnresolvedBlockRef, createBlockResolver, resolveBlockTree };
@@ -1 +1 @@
1
- {"version":3,"file":"block-resolver.d.mts","names":[],"sources":["../src/block-resolver.ts"],"mappings":"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"}
1
+ {"version":3,"file":"block-resolver.d.mts","names":[],"sources":["../src/block-resolver.ts"],"mappings":"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"}
@@ -1,2 +1,2 @@
1
- import{r as e}from"./resolve-context-Cvc1aNJe.mjs";import{r as t}from"./enumerate-CYHJKQz6.mjs";import{collectLinkValues as n,linkValueKey as r}from"@murumets-ee/blocks/links";import{canvasSiteFormat as i,collectBoundColumnsInTree as a,createCollectionReader as o,detailNeedsBody as s,detailPartsInTree as c,effectiveCollectionSources as l,effectiveDetailSpecs as u,hasConfiguredLiveSources as d,loadCanvasLiveValues as f,materializeServerTemplate as p,readSpotCrop as m,resolveDocumentDetail as h,resolveLinks as g,resolveTreeCollections as _,sameSpotCrop as v,serverBlockStructureLookup as y,serverBoundColumnsInTree as b,serverCollectionFieldLookup as x,serverTemplateMaterializer as S,templateNamespace as C}from"@murumets-ee/blocks/server";const w=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;function T(e){return typeof e==`object`&&!!e&&!Array.isArray(e)}function E(e){return Array.isArray(e)}const D=new Set(Object.keys({id:!0,url:!0,mimeType:!0,width:!0,height:!0,alt:!0,title:!0,variants:!0,sources:!0,avifSources:!0,focalPoint:!0,blurDataUrl:!0,crop:!0,spotSources:!0}));function O(e,t){if(!T(e))return null;let n=e.id;if(typeof n!=`string`||!w.test(n)||typeof e.url!=`string`||e.url===``)return null;let r=Object.keys(e).filter(e=>!D.has(e));return r.length>0?(t?.(r),null):!k(e.alt,`string`)||!k(e.title,`string`)||!k(e.mimeType,`string`)||!k(e.width,`number`)||!k(e.height,`number`)||!k(e.blurDataUrl,`string`)||e.variants!==void 0&&e.variants!==null&&!T(e.variants)||e.focalPoint!==void 0&&e.focalPoint!==null&&!T(e.focalPoint)||e.sources!==void 0&&e.sources!==null&&(!T(e.sources)||!Object.values(e.sources).every(e=>Array.isArray(e)))||e.avifSources!==void 0&&e.avifSources!==null&&(!T(e.avifSources)||!Object.values(e.avifSources).every(e=>Array.isArray(e)))||e.crop!==void 0&&e.crop!==null&&!T(e.crop)||e.spotSources!==void 0&&e.spotSources!==null&&!Array.isArray(e.spotSources)?null:n}function k(e,t){return e==null||typeof e===t}function A(e,t,n,r){if(n>256){r.truncated=!0;return}let i=e=>{for(let t of e)r.nearMissKeys.add(t)};if(E(e)){for(let a=0;a<e.length;a+=1){let o=O(e[a],i);o===null?A(e[a],t,n+1,r):t.push({container:e,key:a,id:o,crop:m(e[a])})}return}if(T(e))for(let[a,o]of Object.entries(e)){let s=O(o,i);s===null?A(o,t,n+1,r):t.push({container:e,key:a,id:s,crop:m(o)})}}function j(e){let t={id:e.id,url:e.url,variants:e.variants,focalPoint:e.focalPoint,sources:e.sources};return e.avifSources!==void 0&&Object.keys(e.avifSources).length>0&&(t.avifSources=e.avifSources),e.mimeType!==null&&(t.mimeType=e.mimeType),e.width!==null&&(t.width=e.width),e.height!==null&&(t.height=e.height),e.alt!==null&&(t.alt=e.alt),e.title!==null&&(t.title=e.title),e.blurDataUrl!==null&&(t.blurDataUrl=e.blurDataUrl),t}function M(e,t){if(!(t===void 0||e.spotSources===void 0))return e.spotSources.find(e=>v(e.crop,t)&&e.sources.length>0)}function N(e,t){let n=j(e),r=M(e,t),i=r?.sources[r.sources.length-1];if(r!==void 0&&i!==void 0){n.url=i.url,n.width=i.width,n.height=i.height,n.focalPoint={x:.5,y:.5},n.sources={natural:r.sources};let e=r.avifSources;e!==void 0&&e.length===r.sources.length&&e.every((e,t)=>e.width===r.sources[t]?.width&&e.height===r.sources[t]?.height)?n.avifSources={natural:e}:delete n.avifSources}return n.crop={x:t.x,y:t.y,w:t.w,h:t.h},n}function P(e,t){let n=t===null?null:e.crop===void 0?j(t):N(t,e.crop);if(Array.isArray(e.container)){typeof e.key==`number`&&(e.container[e.key]=n);return}typeof e.key==`string`&&(e.container[e.key]=n)}async function F(e,t,i){let a=e,o=[];if(t.template!==void 0){let e=t.boundValues?.id;if(typeof e==`string`&&e!==``){let n=(i.materializeTemplate??p)(t.template,C(t.entity,e));a=n.blocks,o=n.boundColumns}else a=[],i.logger?.warn({entity:t.entity,field:t.field},`content: a templated document arrived without its row id, so its template is served EMPTY — its block ids could not be namespaced to it (editor F124)`)}else if(t.boundValues!==void 0&&E(e))try{o=(i.boundColumnsOf??b)(e)}catch(e){i.logger?.warn({err:e,entity:t.entity,field:t.field},`content: could not enumerate the bound columns of a block tree, so its bound-field links render plain`)}let s=t.boundValues??{},l=o.filter(e=>Object.hasOwn(s,e)).map(e=>s[e]);if(!E(a))throw i.logger?.warn({entity:t.entity,field:t.field},`content: stored block tree is not an array — nothing to resolve, so this field is served EMPTY rather than as stored (the caller retries this throw with an exhausted budget, and a non-array fails that identically)`),TypeError(`content: block tree \`${t.entity}.${t.field}\` is not an array; nothing to resolve.`);let u=[],d={truncated:!1,nearMissKeys:new Set};A(a,u,0,d),d.truncated&&i.logger?.warn({entity:t.entity,field:t.field,cap:256},`content: block tree is nested deeper than the media walk descends — any media ref below the bound keeps the url recorded when an editor picked it, which may be signed`),d.nearMissKeys.size>0&&i.logger?.debug({entity:t.entity,field:t.field,keys:[...d.nearMissKeys].sort()},`content: a value looked like a media ref but carried keys outside the projection, so it was left alone. If these are media refs, they keep their stored urls`);let f=[...n(a,64),...n(l,64)],m=[],h=[];for(let e of f)e.kind===`media`&&(w.test(e.id)?m.push(e.id):h.push({kind:`link`,ref:r(e),reason:`unresolvable`}));let _=R([...u.map(e=>e.id),...m]),v=[];for(let e of f)e.kind===`entity`&&(w.test(e.id)?v.push(z(e.entity,e.id)):h.push({kind:`link`,ref:r(e),reason:`unresolvable`}));let y=[...h];!i.resolveMedia&&_.length>0&&i.logger?.debug({entity:t.entity,field:t.field,count:_.length},`content: no media resolver wired for block trees — every media ref resolves to null`);let x=i.resolveMedia?t.budget.claim(`block`,_.length):0,S=_.slice(0,x),T=i.resolveMedia?new Set(_.slice(x)):new Set;for(let e of _.slice(x))y.push({kind:`media`,ref:e,reason:T.has(e)?`budget`:`unresolvable`});let D=R(v),O=t.budget.claim(`block`,D.length),k=D.slice(0,O),j=new Set(D.slice(O)),M=await V(S,t.locale,i,t.entity);for(let e of u)P(e,M.get(e.id)??null);for(let e of S)M.has(e)||y.push({kind:`media`,ref:e,reason:`unresolvable`});let N=await H(k,t.locale,i,t.entity),F=await g(f,{renderContext:`request`,locale:t.locale,resolveEntityHref:({entity:e,id:t})=>N.get(z(e,t))??null,resolveMediaHref:e=>M.get(e)?.url??null}),U={};for(let[e,t]of F)U[e]=t.href;for(let e of f){if(e.kind!==`entity`&&e.kind!==`media`)continue;let t=r(e);U[t]!==null&&U[t]!==void 0||y.push({kind:`link`,ref:t,reason:L(e,j,T)})}let W=i.resolveLiveValues,G=W===void 0?0:t.budget.claim(`block`,1),[{results:K,cardMedia:q},J]=await Promise.all([I(a,t,i),G>0&&W!==void 0?(async()=>W(a,t.locale))().catch(e=>{i.logger?.warn({err:e},`blocks: resolveLiveValues rejected; this tree draws no live values`)}):Promise.resolve(void 0)]),Y;if(i.resolveDetail!==void 0&&t.boundValues!==void 0)try{let e=c(E(a)?a:[]);if(e.has(`body`)&&i.detailNeedsBody?.(t.entity,e,t.boundValues)===!0&&t.budget.claim(`block`,1)<1){let n=new Set(e);n.delete(`body`),e=n,i.logger?.warn({entity:t.entity,field:t.field},`blocks: the response budget is spent; this tree is delivered without its HTML body`)}e.size>0&&(Y=i.resolveDetail(t.entity,t.boundValues,t.locale,e))}catch(e){i.logger?.warn({err:e,entity:t.entity},`blocks: resolveDetail threw; this tree carries no detail`)}let X=new Map;for(let e of M.keys())X.set(z(`media`,e),{entity:`media`,id:e});for(let e of q)X.set(z(`media`,e),{entity:`media`,id:e});for(let e of K.values())if(e.entity!==null)for(let t of e.rows)X.set(z(e.entity,t.id),{entity:e.entity,id:t.id});for(let e of N.keys()){if(X.has(e))continue;let t=e.indexOf(` `);if(t<=0)continue;let n=e.slice(0,t),r=e.slice(t+1);r!==``&&X.set(e,{entity:n,id:r})}return{resolved:1,blocks:a,links:U,...y.length>0&&{unresolved:B(y)},...X.size>0&&{refs:[...X.values()]},...K.size>0&&{collections:Object.fromEntries(K)},...J!==void 0&&Object.keys(J.sources).length>0&&{live:{sources:J.sources,values:J.values}},...i.currency!==void 0&&{currency:i.currency},...Y!==void 0&&{detail:Y}}}async function I(e,t,n){let r=n.collections,i=t.canReadEntity,a=t.claimCollection;if(r===void 0||i===void 0||a===void 0)return{results:new Map,cardMedia:new Set};let o=n.resolveMedia,s=new Set;return{results:await _(e,r.sourceFieldOf,{sources:r.sources,getEntity:r.getEntity,clientFor:r.clientFor,canRead:i,resolveImages:o===void 0?void 0:async e=>{let n=[...await o(e,{locale:t.locale})].filter(([,e])=>e.mimeType?.startsWith(`image/`)===!0);for(let[e]of n)s.add(e);return new Map(n.map(([e,t])=>[e,{url:t.url,alt:t.alt,width:t.width,height:t.height,focalPoint:t.focalPoint,sources:t.sources,...t.avifSources!==void 0&&Object.keys(t.avifSources).length>0&&{avifSources:t.avifSources},blurDataUrl:t.blurDataUrl}]))},resolveHrefs:(e,r)=>n.resolvePublishedPaths(e,r,t.locale),locale:t.locale,renderContext:`request`,claim:()=>a()&&t.budget.claim(`block`,1)>=1,logger:n.logger}),cardMedia:s}}function L(e,t,n){return e.kind===`entity`&&t.has(z(e.entity,e.id))||e.kind===`media`&&n.has(e.id)?`budget`:`unresolvable`}function R(e){return[...new Set(e)]}function z(e,t){return[e,t].join(` `)}function B(e){let t=new Set,n=[];for(let r of e){let e=`${r.kind} ${r.ref}`;t.has(e)||(t.add(e),n.push(r))}return n}async function V(e,t,n,r){if(e.length===0||!n.resolveMedia)return new Map;try{return await n.resolveMedia(e,{locale:t,spots:!0})}catch(e){return n.logger?.warn({entity:r,err:e},"content: media resolver threw while resolving a block tree — its media refs resolve to null (if this is a ForbiddenError, the requesting role is missing `media:view`)"),new Map}}async function H(e,t,n,r){let i=new Map;if(e.length===0)return i;let a=new Map;for(let t of e){let e=t.indexOf(` `),n=t.slice(0,e),r=t.slice(e+1),i=a.get(n);i?i.push(r):a.set(n,[r])}for(let[e,o]of a)try{let r=await n.resolvePublishedPaths(e,o,t);for(let[t,n]of r)i.set(z(e,t),n)}catch(t){n.logger?.warn({entity:r,entityType:e,err:t},`content: path resolution threw for a block-tree link target — its links render as plain text`)}return i}function U(n,r){let c=null,p=n=>{if(c?.app===n)return c.deps;let p=e(n),{sources:m}=l(n),g=m.length===0?void 0:{sources:m,sourceFieldOf:x(),getEntity:e=>n.entities.get(e),clientFor:e=>o(e,n)},_=S(),v=y(),b=u(n).specs,C={...g!==void 0&&{collections:g},boundColumnsOf:e=>a(e,v),materializeTemplate:(e,t)=>_(e,t),resolveMedia:r.resolveMedia,resolvePublishedPaths:async(e,n,r)=>{let i=await p.filterPublished(e,n,r);return i.size===0?new Map:t(e,[...i],r,p)},logger:n.logger,...d()&&{resolveLiveValues:(e,t)=>f(e,{locale:t,logger:n.logger})},currency:i(r.defaultLocale).currency,...b.size>0&&{resolveDetail:(e,t,n,r)=>{let i=b.get(e);return i===void 0?void 0:h(i,t,n,r)},detailNeedsBody:(e,t,n)=>{let r=b.get(e);return r!==void 0&&s(r,t,n)}}};return c={app:n,deps:C},C};return async(e,t)=>{let i=await n();return F(e,{...t,locale:t.locale??r.defaultLocale},p(i))}}export{U as createBlockResolver,F as resolveBlockTree};
1
+ import{r as e}from"./resolve-context-Cvc1aNJe.mjs";import{r as t}from"./enumerate-CYHJKQz6.mjs";import{collectLinkValues as n,linkValueKey as r}from"@murumets-ee/blocks/links";import{canvasSiteFormat as i,collectBoundColumnsInTree as a,createCollectionReader as o,detailNeedsBody as s,detailPartsInTree as c,effectiveCollectionSources as l,effectiveDetailSpecs as u,hasConfiguredLiveSources as d,loadCanvasLiveValues as f,materializeServerTemplate as p,readSpotCrop as m,resolveDocumentDetail as h,resolveLinks as g,resolveTreeCollections as _,sameSpotCrop as v,serverBlockStructureLookup as y,serverBoundColumnsInTree as b,serverCollectionFieldLookup as x,serverTemplateMaterializer as S,templateNamespace as C}from"@murumets-ee/blocks/server";const w=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;function T(e){return typeof e==`object`&&!!e&&!Array.isArray(e)}function E(e){return Array.isArray(e)}const D=new Set(Object.keys({id:!0,url:!0,mimeType:!0,width:!0,height:!0,alt:!0,title:!0,variants:!0,sources:!0,avifSources:!0,focalPoint:!0,blurDataUrl:!0,crop:!0,spotSources:!0}));function O(e,t){if(!T(e))return null;let n=e.id;if(typeof n!=`string`||!w.test(n)||typeof e.url!=`string`||e.url===``)return null;let r=Object.keys(e).filter(e=>!D.has(e));return r.length>0?(t?.(r),null):!k(e.alt,`string`)||!k(e.title,`string`)||!k(e.mimeType,`string`)||!k(e.width,`number`)||!k(e.height,`number`)||!k(e.blurDataUrl,`string`)||e.variants!==void 0&&e.variants!==null&&!T(e.variants)||e.focalPoint!==void 0&&e.focalPoint!==null&&!T(e.focalPoint)||e.sources!==void 0&&e.sources!==null&&(!T(e.sources)||!Object.values(e.sources).every(e=>Array.isArray(e)))||e.avifSources!==void 0&&e.avifSources!==null&&(!T(e.avifSources)||!Object.values(e.avifSources).every(e=>Array.isArray(e)))||e.crop!==void 0&&e.crop!==null&&!T(e.crop)||e.spotSources!==void 0&&e.spotSources!==null&&!Array.isArray(e.spotSources)?null:n}function k(e,t){return e==null||typeof e===t}function A(e,t,n,r){if(n>256){r.truncated=!0;return}let i=e=>{for(let t of e)r.nearMissKeys.add(t)};if(E(e)){for(let a=0;a<e.length;a+=1){let o=O(e[a],i);o===null?A(e[a],t,n+1,r):t.push({container:e,key:a,id:o,crop:m(e[a])})}return}if(T(e))for(let[a,o]of Object.entries(e)){let s=O(o,i);s===null?A(o,t,n+1,r):t.push({container:e,key:a,id:s,crop:m(o)})}}function j(e){let t={id:e.id,url:e.url,variants:e.variants,focalPoint:e.focalPoint,sources:e.sources};return e.avifSources!==void 0&&Object.keys(e.avifSources).length>0&&(t.avifSources=e.avifSources),e.mimeType!==null&&(t.mimeType=e.mimeType),e.width!==null&&(t.width=e.width),e.height!==null&&(t.height=e.height),e.alt!==null&&(t.alt=e.alt),e.title!==null&&(t.title=e.title),e.blurDataUrl!==null&&(t.blurDataUrl=e.blurDataUrl),t}function M(e,t){if(!(t===void 0||e.spotSources===void 0))return e.spotSources.find(e=>v(e.crop,t)&&e.sources.length>0)}function N(e,t){let n=j(e),r=M(e,t),i=r?.sources[r.sources.length-1];if(r!==void 0&&i!==void 0){n.url=i.url,n.width=i.width,n.height=i.height,n.focalPoint={x:.5,y:.5},n.sources={natural:r.sources};let e=r.avifSources;e!==void 0&&e.length===r.sources.length&&e.every((e,t)=>e.width===r.sources[t]?.width&&e.height===r.sources[t]?.height)?n.avifSources={natural:e}:delete n.avifSources}return n.crop={x:t.x,y:t.y,w:t.w,h:t.h},n}function P(e,t){let n=t===null?null:e.crop===void 0?j(t):N(t,e.crop);if(Array.isArray(e.container)){typeof e.key==`number`&&(e.container[e.key]=n);return}typeof e.key==`string`&&(e.container[e.key]=n)}async function F(e,t,i){let a=e,o=[];if(t.template!==void 0){let e=t.boundValues?.id;if(typeof e==`string`&&e!==``){let n=(i.materializeTemplate??p)(t.template,C(t.entity,e));a=n.blocks,o=n.boundColumns}else a=[],i.logger?.warn({entity:t.entity,field:t.field},`content: a templated document arrived without its row id, so its template is served EMPTY — its block ids could not be namespaced to it (editor F124)`)}else if(t.rowIsForeign!==!0&&t.boundValues!==void 0&&E(e))try{o=(i.boundColumnsOf??b)(e)}catch(e){i.logger?.warn({err:e,entity:t.entity,field:t.field},`content: could not enumerate the bound columns of a block tree, so its bound-field links render plain`)}let s=t.boundValues??{},l=o.filter(e=>Object.hasOwn(s,e)).map(e=>s[e]);if(!E(a))throw i.logger?.warn({entity:t.entity,field:t.field},`content: stored block tree is not an array — nothing to resolve, so this field is served EMPTY rather than as stored (the caller retries this throw with an exhausted budget, and a non-array fails that identically)`),TypeError(`content: block tree \`${t.entity}.${t.field}\` is not an array; nothing to resolve.`);let u=[],d={truncated:!1,nearMissKeys:new Set};A(a,u,0,d),d.truncated&&i.logger?.warn({entity:t.entity,field:t.field,cap:256},`content: block tree is nested deeper than the media walk descends — any media ref below the bound keeps the url recorded when an editor picked it, which may be signed`),d.nearMissKeys.size>0&&i.logger?.debug({entity:t.entity,field:t.field,keys:[...d.nearMissKeys].sort()},`content: a value looked like a media ref but carried keys outside the projection, so it was left alone. If these are media refs, they keep their stored urls`);let f=[...n(a,64),...n(l,64)],m=[],h=[];for(let e of f)e.kind===`media`&&(w.test(e.id)?m.push(e.id):h.push({kind:`link`,ref:r(e),reason:`unresolvable`}));let _=R([...u.map(e=>e.id),...m]),v=[];for(let e of f)e.kind===`entity`&&(w.test(e.id)?v.push(z(e.entity,e.id)):h.push({kind:`link`,ref:r(e),reason:`unresolvable`}));let y=[...h];!i.resolveMedia&&_.length>0&&i.logger?.debug({entity:t.entity,field:t.field,count:_.length},`content: no media resolver wired for block trees — every media ref resolves to null`);let x=i.resolveMedia?t.budget.claim(`block`,_.length):0,S=_.slice(0,x),T=i.resolveMedia?new Set(_.slice(x)):new Set;for(let e of _.slice(x))y.push({kind:`media`,ref:e,reason:T.has(e)?`budget`:`unresolvable`});let D=R(v),O=t.budget.claim(`block`,D.length),k=D.slice(0,O),j=new Set(D.slice(O)),M=await V(S,t.locale,i,t.entity);for(let e of u)P(e,M.get(e.id)??null);for(let e of S)M.has(e)||y.push({kind:`media`,ref:e,reason:`unresolvable`});let N=await H(k,t.locale,i,t.entity),F=await g(f,{renderContext:`request`,locale:t.locale,resolveEntityHref:({entity:e,id:t})=>N.get(z(e,t))??null,resolveMediaHref:e=>M.get(e)?.url??null}),U={};for(let[e,t]of F)U[e]=t.href;for(let e of f){if(e.kind!==`entity`&&e.kind!==`media`)continue;let t=r(e);U[t]!==null&&U[t]!==void 0||y.push({kind:`link`,ref:t,reason:L(e,j,T)})}let W=i.resolveLiveValues,G=W===void 0?0:t.budget.claim(`block`,1),[{results:K,cardMedia:q},J]=await Promise.all([I(a,t,i),G>0&&W!==void 0?(async()=>W(a,t.locale))().catch(e=>{i.logger?.warn({err:e},`blocks: resolveLiveValues rejected; this tree draws no live values`)}):Promise.resolve(void 0)]),Y;if(i.resolveDetail!==void 0&&t.boundValues!==void 0)try{let e=c(E(a)?a:[]);if(e.has(`body`)&&i.detailNeedsBody?.(t.entity,e,t.boundValues)===!0&&t.budget.claim(`block`,1)<1){let n=new Set(e);n.delete(`body`),e=n,i.logger?.warn({entity:t.entity,field:t.field},`blocks: the response budget is spent; this tree is delivered without its HTML body`)}e.size>0&&(Y=i.resolveDetail(t.entity,t.boundValues,t.locale,e))}catch(e){i.logger?.warn({err:e,entity:t.entity},`blocks: resolveDetail threw; this tree carries no detail`)}let X=new Map;for(let e of M.keys())X.set(z(`media`,e),{entity:`media`,id:e});for(let e of q)X.set(z(`media`,e),{entity:`media`,id:e});for(let e of K.values())if(e.entity!==null)for(let t of e.rows)X.set(z(e.entity,t.id),{entity:e.entity,id:t.id});for(let e of N.keys()){if(X.has(e))continue;let t=e.indexOf(` `);if(t<=0)continue;let n=e.slice(0,t),r=e.slice(t+1);r!==``&&X.set(e,{entity:n,id:r})}return{resolved:1,blocks:a,links:U,...y.length>0&&{unresolved:B(y)},...X.size>0&&{refs:[...X.values()]},...K.size>0&&{collections:Object.fromEntries(K)},...J!==void 0&&Object.keys(J.sources).length>0&&{live:{sources:J.sources,values:J.values}},...i.currency!==void 0&&{currency:i.currency},...Y!==void 0&&{detail:Y}}}async function I(e,t,n){let r=n.collections,i=t.canReadEntity,a=t.claimCollection;if(r===void 0||i===void 0||a===void 0)return{results:new Map,cardMedia:new Set};let o=n.resolveMedia,s=new Set;return{results:await _(e,r.sourceFieldOf,{sources:r.sources,getEntity:r.getEntity,clientFor:r.clientFor,canRead:i,resolveImages:o===void 0?void 0:async e=>{let n=[...await o(e,{locale:t.locale})].filter(([,e])=>e.mimeType?.startsWith(`image/`)===!0);for(let[e]of n)s.add(e);return new Map(n.map(([e,t])=>[e,{url:t.url,alt:t.alt,width:t.width,height:t.height,focalPoint:t.focalPoint,sources:t.sources,...t.avifSources!==void 0&&Object.keys(t.avifSources).length>0&&{avifSources:t.avifSources},blurDataUrl:t.blurDataUrl}]))},resolveHrefs:(e,r)=>n.resolvePublishedPaths(e,r,t.locale),locale:t.locale,renderContext:`request`,claim:()=>a()&&t.budget.claim(`block`,1)>=1,logger:n.logger}),cardMedia:s}}function L(e,t,n){return e.kind===`entity`&&t.has(z(e.entity,e.id))||e.kind===`media`&&n.has(e.id)?`budget`:`unresolvable`}function R(e){return[...new Set(e)]}function z(e,t){return[e,t].join(` `)}function B(e){let t=new Set,n=[];for(let r of e){let e=`${r.kind} ${r.ref}`;t.has(e)||(t.add(e),n.push(r))}return n}async function V(e,t,n,r){if(e.length===0||!n.resolveMedia)return new Map;try{return await n.resolveMedia(e,{locale:t,spots:!0})}catch(e){return n.logger?.warn({entity:r,err:e},"content: media resolver threw while resolving a block tree — its media refs resolve to null (if this is a ForbiddenError, the requesting role is missing `media:view`)"),new Map}}async function H(e,t,n,r){let i=new Map;if(e.length===0)return i;let a=new Map;for(let t of e){let e=t.indexOf(` `),n=t.slice(0,e),r=t.slice(e+1),i=a.get(n);i?i.push(r):a.set(n,[r])}for(let[e,o]of a)try{let r=await n.resolvePublishedPaths(e,o,t);for(let[t,n]of r)i.set(z(e,t),n)}catch(t){n.logger?.warn({entity:r,entityType:e,err:t},`content: path resolution threw for a block-tree link target — its links render as plain text`)}return i}function U(n,r){let c=null,p=n=>{if(c?.app===n)return c.deps;let p=e(n),{sources:m}=l(n),g=m.length===0?void 0:{sources:m,sourceFieldOf:x(),getEntity:e=>n.entities.get(e),clientFor:e=>o(e,n)},_=S(),v=y(),b=u(n).specs,C={...g!==void 0&&{collections:g},boundColumnsOf:e=>a(e,v),materializeTemplate:(e,t)=>_(e,t),resolveMedia:r.resolveMedia,resolvePublishedPaths:async(e,n,r)=>{let i=await p.filterPublished(e,n,r);return i.size===0?new Map:t(e,[...i],r,p)},logger:n.logger,...d()&&{resolveLiveValues:(e,t)=>f(e,{locale:t,logger:n.logger})},currency:i(r.defaultLocale).currency,...b.size>0&&{resolveDetail:(e,t,n,r)=>{let i=b.get(e);return i===void 0?void 0:h(i,t,n,r)},detailNeedsBody:(e,t,n)=>{let r=b.get(e);return r!==void 0&&s(r,t,n)}}};return c={app:n,deps:C},C};return async(e,t)=>{let i=await n();return F(e,{...t,locale:t.locale??r.defaultLocale},p(i))}}export{U as createBlockResolver,F as resolveBlockTree};
2
2
  //# sourceMappingURL=block-resolver.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"block-resolver.mjs","names":[],"sources":["../src/block-resolver.ts"],"sourcesContent":["/**\n * The block-tree resolver for `@murumets-ee/content-api`'s `resolveBlocks` seam\n * (`frontend-delivery` D019).\n *\n * This is the half `content-api` structurally cannot do: it walks a stored\n * tree, re-resolves the media refs inside it through the public-only media\n * path, and turns the `LinkValue`s inside it into hrefs against the published\n * routing index. `content-api` is a leaf — `blocks`, `media` and this package\n * all depend back on it — so it takes this as an injected provider, exactly as\n * it takes chrome, menus and routing resolution.\n *\n * ## What it emits, and why that shape\n *\n * A `ResolvedBlockTree` envelope: `{ resolved: 1, blocks, links, unresolved? }`.\n * The reasoning is D019's and is restated where it binds the code below, but\n * the two-sentence version is: links go into a `linkValueKey`-keyed sidecar\n * because that is exactly what every renderer already consumes, and media is\n * replaced IN PLACE because a media node is not an opaque pointer — it already\n * carries a url that every render reads, and that url has to be overwritten\n * regardless.\n *\n * ## 🔴 The security property this exists for\n *\n * A `field.media()` value inside a block tree is stored as a whole `MediaRef`\n * including a `url`, denormalised when the editor picked it. That url comes\n * from `StorageClient.getUrl`, which returns a **signed** url for any file that\n * is not public — and `StorageClient.upload` defaults to private. So the stored\n * tree can carry a bearer capability, and serving it unchanged puts that\n * capability on a `public, max-age=60` response any shared CDN will hand to\n * every later anonymous visitor. That is precisely the leak `getPublicUrls` and\n * `plan/api` D021 made structurally impossible for TOP-LEVEL media fields; the\n * block-tree path routed around it because the url stopped being a url and\n * became content.\n *\n * So the stored url is **discarded, never used as a fallback**. An id that does\n * not resolve becomes `null`, because \"did not resolve\" and \"is not public\" are\n * the same branch in the shipped resolver — a fallback would preserve the leak\n * exactly in the case that matters.\n *\n * ## 🔴 The fan-out property\n *\n * `resolveLinks`' own docblock hands the COUNT bound to whoever wires it: it\n * caps peak concurrency and deliberately does not cap how many links it\n * resolves. A naive wiring would give it a seam that hits the database per\n * distinct link — one publish check plus one path read each — on an anonymous\n * request path, which is verbatim what CLAUDE.md's no-unbounded-fan-out section\n * forbids. So this module resolves everything FIRST, batched by entity type\n * (two index reads per type, regardless of link count), and hands `resolveLinks`\n * a pure map lookup. What it costs is bounded by the number of distinct entity\n * TYPES a document links to, not by the number of links.\n *\n * ## Collections — rows, through the caller's own gate (editor PR53)\n *\n * A COLLECTION block's rows are resolved here too, into the envelope's additive\n * `collections` sidecar, through `@murumets-ee/blocks/server`'s\n * `resolveTreeCollections` — the ONE resolver the canvas calls as well. Three\n * properties make that safe on an anonymous path:\n *\n * - The reader is the CALLER, not an elevated identity: rows come through a\n * `QueryClient` bound to the ambient request context, and the provider may\n * only list an entity content-api's own whitelist ∧ `view` gate admits\n * (`canReadEntity`, handed in by the handler). Without that predicate, no\n * collection resolves at all — deny-by-default, never a guess.\n * - Only the card's mapped slots cross, never the row.\n * - It is bounded: at most `MAX_COLLECTIONS_PER_DOCUMENT` per tree, serial,\n * each claiming one slot of the host's per-RESPONSE collection allowance\n * (`claimCollection` — a list response resolves many trees) and one unit of\n * the response's resolution budget. No allowance, no collections.\n *\n * ## What it deliberately does not do\n *\n * Plain `query()` fields (the fixed-entity `articleList` / `relatedPosts`\n * shells) stay unresolved: nothing renders their rows, and the collection block\n * is the dynamic listing D001 asked for.\n */\n\nimport { collectLinkValues, type LinkValue, linkValueKey } from '@murumets-ee/blocks/links'\nimport {\n type CollectionCardImage,\n type CollectionFieldLookup,\n type CollectionResult,\n type CollectionSourceSpec,\n canvasSiteFormat,\n collectBoundColumnsInTree,\n createCollectionReader,\n type DocumentDetail,\n type DocumentDetailPart,\n detailNeedsBody,\n detailPartsInTree,\n effectiveCollectionSources,\n effectiveDetailSpecs,\n type FindManyClientLike,\n hasConfiguredLiveSources,\n type LiveValues,\n loadCanvasLiveValues,\n materializeServerTemplate,\n readSpotCrop,\n resolveDocumentDetail,\n resolveLinks,\n resolveTreeCollections,\n type SpotCrop,\n sameSpotCrop,\n serverBlockStructureLookup,\n serverBoundColumnsInTree,\n serverCollectionFieldLookup,\n serverTemplateMaterializer,\n type TemplateNamespace,\n templateNamespace,\n} from '@murumets-ee/blocks/server'\nimport type { ToolkitApp } from '@murumets-ee/core'\nimport type { Entity } from '@murumets-ee/entity'\n\nimport { getCanonicalPaths } from './paths/enumerate.js'\nimport { buildEnumerateDeps } from './paths/resolve-context.js'\n\n// ---------------------------------------------------------------------------\n// Structural mirrors of `content-api`'s wire types\n// ---------------------------------------------------------------------------\n//\n// Mirrored rather than imported, for the reason every other provider in this\n// package mirrors its counterpart: `content-api` is the leaf, and the app that\n// wires the two is where TypeScript checks they agree.\n\n/** Structural mirror of `content-api`'s `MediaProjection`. */\nexport interface BlockMediaProjection {\n id: string\n url: string\n mimeType: string | null\n width: number | null\n height: number | null\n alt: string | null\n title: string | null\n variants: Record<string, string>\n sources: Record<string, Array<{ url: string; width: number; height: number }>>\n avifSources?: Record<string, Array<{ url: string; width: number; height: number }>>\n focalPoint: { x: number; y: number }\n blurDataUrl: string | null\n /**\n * The files cut for each spot crop the photograph has files for (images\n * PR16) — present only when the resolver was ASKED (`{ spots: true }`) and\n * the photograph records some. Never embedded in a tree: {@link writeSlot}\n * picks the one list a placement's own `crop` names and drops the rest.\n */\n spotSources?: BlockSpotSources[]\n}\n\n/** One spot crop's files, as the media resolver publishes them. */\nexport interface BlockSpotSources {\n crop: { x: number; y: number; w: number; h: number }\n sources: Array<{ url: string; width: number; height: number }>\n avifSources?: Array<{ url: string; width: number; height: number }>\n}\n\n/**\n * Structural mirror of `content-api`'s `ResolveMedia`.\n *\n * The SAME function an app passes as `ContentApiConfig.resolveMedia` — pass one\n * value to both, so a media ref inside a block cannot resolve through a\n * different path (or a different publish gate) than a top-level media field.\n */\nexport type BlockMediaResolver = (\n ids: readonly string[],\n opts?: {\n locale?: string | undefined\n /**\n * Also publish the files cut for spot crops (images PR16). Asked by the\n * BLOCK walk only — a top-level media field has no placement crop, so its\n * projection stays exactly what it was.\n */\n spots?: boolean | undefined\n },\n) => Promise<Map<string, BlockMediaProjection>>\n\n/** Structural mirror of `content-api`'s `UnresolvedBlockRef`. */\nexport interface UnresolvedBlockRef {\n readonly kind: 'media' | 'link'\n readonly ref: string\n readonly reason: 'unresolvable' | 'budget'\n}\n\n/** Structural mirror of `content-api`'s `BlockTreeRef`. */\nexport interface BlockTreeRef {\n readonly entity: string\n readonly id: string\n}\n\n/** Structural mirror of `content-api`'s `ResolvedBlockTree`. */\nexport interface ResolvedBlockTree {\n readonly resolved: 1\n readonly blocks: readonly unknown[]\n readonly links: Readonly<Record<string, string | null>>\n readonly unresolved?: readonly UnresolvedBlockRef[]\n readonly refs?: readonly BlockTreeRef[]\n readonly collections?: Readonly<Record<string, CollectionResult>>\n /** What this tree's live-bound Price fields resolved to (block-library F012). */\n readonly live?: LiveValues\n /** The site's currency (block-library F012) — present whenever the site configures one. */\n readonly currency?: string\n /**\n * The row this tree is drawn for, projected through its entity's detail\n * declaration (block-library PR16) — price, facts, a sanitised HTML body, a\n * viewer. Absent unless the app declares a detail for the entity and the row\n * has a value for at least one declared part.\n */\n readonly detail?: DocumentDetail\n}\n\n/**\n * What resolving COLLECTION blocks needs from the app — built once per app by\n * {@link createBlockResolver}, absent when the app declares no sources.\n */\nexport interface BlockCollectionDeps {\n readonly sources: readonly CollectionSourceSpec[]\n /** Name AND field restriction (kulli-editor F019) — `serverCollectionFieldLookup()`. */\n readonly sourceFieldOf: CollectionFieldLookup\n readonly getEntity: (name: string) => Entity | undefined\n /** A PUBLISHED-only reader bound to the ambient request context — the caller. */\n readonly clientFor: (entity: Entity) => FindManyClientLike\n}\n\n/**\n * The slice of `content-api`'s `ResolutionBudget` this provider uses —\n * narrowed to `claim`, for the reason `ChromeResolutionBudget` states: a\n * provider asks for work and is told how much it may do; it has no business\n * reading the remaining count or the drop summary.\n */\nexport interface BlockResolutionBudget {\n claim: (kind: 'block', n: number) => number\n}\n\nexport interface BlockResolverLogger {\n debug: (obj: object, msg: string) => void\n warn: (obj: object, msg: string) => void\n}\n\n/** Everything the pure resolver needs, injected so it is testable without an app. */\nexport interface BlockResolverDeps {\n /** Public-only media resolution. Omit and every media ref resolves to `null`. */\n resolveMedia?: BlockMediaResolver | undefined\n /**\n * PUBLISHED-only canonical paths for many ids of one entity type, in one\n * locale, as `entityId → path`.\n *\n * **The publish gate is this function's responsibility and the whole reason\n * it is batched rather than per-id.** An unpublished target must yield no\n * entry, or a block link leaks an unpublished document's URL — the obligation\n * `ResolveBlocks`' own docblock puts on the wiring.\n */\n resolvePublishedPaths: (\n entityType: string,\n ids: readonly string[],\n locale: string,\n ) => Promise<Map<string, string>>\n logger?: BlockResolverLogger | undefined\n /** Collection resolution. Omit and collection blocks resolve no rows. */\n collections?: BlockCollectionDeps | undefined\n /**\n * Rebuild a CODED TEMPLATE and name the columns its blocks bind (editor\n * D017, frontend-delivery F109). Defaults to the server block registry's\n * `materializeServerTemplate`; injectable so a test need not seed a registry.\n *\n * `namespace` is the document the ids belong to (editor F124) — see\n * `resolveBlockTree`, which derives it from the entity and the row's id.\n */\n materializeTemplate?:\n | ((\n template: string,\n namespace: TemplateNamespace,\n ) => {\n readonly blocks: unknown[]\n readonly boundColumns: readonly string[]\n })\n | undefined\n /**\n * The columns the blocks in an ORDINARY stored tree bind. Defaults to the\n * server registry's `serverBoundColumnsInTree`; injectable for the same reason\n * as {@link materializeTemplate}.\n */\n boundColumnsOf?: ((tree: readonly unknown[]) => readonly string[]) | undefined\n /**\n * Resolve this tree's live-bound Price values (block-library PR03, F012).\n * Omit and a live-bound Price draws nothing — same deny-by-default rule as\n * `collections`. Defaults to `loadCanvasLiveValues`, the SAME resolver the\n * canvas's own entry points call, so a price cannot bind through a\n * different path (or a different source set) than the one the author saw\n * while editing.\n */\n resolveLiveValues?: ((roots: readonly unknown[], locale: string) => Promise<LiveValues>) | undefined\n /**\n * The site's currency (ISO 4217), from `blocksPlugin({ currency })`\n * (block-library F012). Independent of `resolveLiveValues`: a plain,\n * typed-in Price needs it too, to show a currency symbol rather than a bare\n * number.\n */\n currency?: string | undefined\n /**\n * Project the row a tree is drawn for through its entity's detail\n * declaration (block-library PR16). Omit and no tree carries a `detail` —\n * deny-by-default, like `collections`. Query-free: the row is the one the\n * caller is already delivering, under the caller's own access.\n *\n * `parts` are the detail parts the TREE's blocks draw; only those are\n * projected, and a tree that draws none is never asked about.\n */\n resolveDetail?:\n | ((\n entity: string,\n row: Readonly<Record<string, unknown>>,\n locale: string,\n parts: ReadonlySet<DocumentDetailPart>,\n ) => DocumentDetail | undefined)\n | undefined\n /**\n * Would projecting `parts` for `entity` sanitise an HTML body? That is the\n * one detail part with a real CPU cost, so the resolver charges the\n * response's budget for it. Omit and no projection is charged.\n */\n detailNeedsBody?:\n | ((\n entity: string,\n parts: ReadonlySet<DocumentDetailPart>,\n row: Readonly<Record<string, unknown>>,\n ) => boolean)\n | undefined\n}\n\n// ---------------------------------------------------------------------------\n// Media refs inside a tree\n// ---------------------------------------------------------------------------\n\n/**\n * How deep the media walk descends, in JSON levels.\n *\n * A stored tree is authenticated-editor JSON, not anonymous input — but it is\n * still data crossing a process boundary, and an unbounded recursion over it is\n * a stack overflow waiting for one pathological document, which under on-demand\n * rendering is a 500 on a live page for every visitor.\n *\n * 🔴 It counts JSON levels, not BLOCK levels, and the two are not the same: one\n * block of nesting costs about three (`block → children → block`, plus one for\n * `props`). This was 64, matching `collectLinkValues`, which sounded principled\n * and was wrong — `op-commit` permitted 64 BLOCK levels then, so a media ref\n * below roughly the 21st block was never visited, its stored url crossed\n * unchanged, and nothing said so. A bound that silently stops enforcing a\n * security control is worse than no bound, because it reads as coverage.\n *\n * 256 covered those 64 block levels with room to spare, and now covers\n * `MAX_BLOCK_DEPTH` (32, the one reconciled persist-and-render cap) with twice\n * that. Left at 256 rather than retuned: the headroom costs nothing, and a\n * bound that only just fits is the shape this comment exists to warn about.\n * Exceeding it is REPORTED rather than returned in silence. `collectLinkValues`\n * keeps its own 64 — that one bounds a display concern (an unresolved link\n * renders as plain text), not a value that must not reach the wire.\n */\nconst MAX_WALK_DEPTH = 256\n\n/**\n * How deep LINK collection descends — `collectLinkValues`' own default, stated\n * here so the two bounds are visibly independent rather than accidentally\n * shared. An unresolved link renders as plain text; an unvisited media ref\n * keeps a url that may be a capability. Different stakes, different bounds.\n *\n * ⚠️ Unlike the media walk, this one truncates SILENTLY: `collectLinkValues`\n * returns what it found and reports nothing, so a link below the bound is\n * absent from the sidecar, absent from `unresolved`, and renders as plain text\n * with no signal anywhere. That is tolerable only because the failure is\n * cosmetic — and it is stated rather than left to be discovered, because the\n * media walk one constant up made exactly the opposite call for exactly the\n * opposite reason.\n */\nconst LINK_WALK_DEPTH = 64\n\n/**\n * Entity ids are UUIDs (user ids are not, but no user id is ever a media ref).\n * Requiring the shape here is what makes structural detection safe: a prop\n * object carrying BOTH a uuid `id` AND a non-empty `url` string, with every\n * optional `MediaRef` field well-typed, is a media ref by any usable\n * definition.\n */\nconst UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\n/**\n * `Array.isArray(x)` on an `unknown` narrows to `any[]`, which would put an\n * `any` into every element of a walk over untyped JSON — in a package holding a\n * type-coverage floor. Narrowing through an explicit predicate keeps elements\n * `unknown`.\n */\nfunction isUnknownArray(value: unknown): value is unknown[] {\n return Array.isArray(value)\n}\n\n/**\n * Every key a `MediaRef` or a `MediaProjection` may carry — and NOTHING ELSE\n * may be present for a value to count as one.\n *\n * 🔴 This is a WHITELIST, and the difference is not stylistic. Checking only\n * that the known keys are well-typed made the detector match any object with a\n * uuid `id` and a `url` — a project's `{ id, url, provider: 'youtube' }` prop,\n * say. That object would then be REPLACED by `null` (no media row for its id),\n * the consumer-side validator would drop the whole prop, and the block would\n * render empty with an `unresolved` entry calling it media. Silent data loss,\n * misdescribed in the one channel meant to report it.\n *\n * An unrecognised key means \"this is not a media ref\", so the failure direction\n * is leaving a value alone rather than deleting it.\n */\nconst MEDIA_REF_KEY_MAP: Record<keyof BlockMediaProjection | 'crop', true> = {\n id: true,\n url: true,\n mimeType: true,\n width: true,\n height: true,\n alt: true,\n title: true,\n variants: true,\n // A PROJECTION key, like `variants` and `focalPoint` — listed so a projection\n // is still recognised on a second pass. The STORED `MediaRef` never carries it\n // (images D007), so no stored ref changes shape.\n sources: true,\n // Projection-only too (images PR12), and absent unless AVIF is on.\n avifSources: true,\n focalPoint: true,\n // Projection-only as well (images PR11) — the stored ref never carries it.\n blurDataUrl: true,\n // A STORED key (images PR16, D002): the one crop a placement carries — author\n // data about the spot, validated at every write. Listed in the same commit\n // that let the ref carry it: an unrecognised key makes the detector walk\n // past the ref, and its stored url would cross unresolved (CLAUDE.md,\n // Images fact 2). Kept on the embedded value, so a second pass still sees it.\n crop: true,\n // Projection-only, and never embedded — `writeSlot` consumes it. Listed\n // because the map is tied to the projection's keys by type.\n spotSources: true,\n}\n\n/**\n * Derived from the map above rather than written out beside it, so ADDING a\n * field to the projection is a COMPILE error until this list learns about it.\n *\n * That tie is the whole reason the map exists. A bare literal set would have\n * made the whitelist fail-OPEN and silent: give `MediaRef` a `caption` (which\n * the image block already renders, so it is a plausible next change) and every\n * captioned ref quietly stops being recognised — not collected, not\n * re-resolved, not nulled, and not reported — so its stored url crosses\n * verbatim. That is the same \"a bound that silently stops enforcing a security\n * control reads as coverage\" this file says about {@link MAX_WALK_DEPTH}, and\n * it would have been true one field later.\n */\nconst MEDIA_REF_KEYS = new Set(Object.keys(MEDIA_REF_KEY_MAP))\n\n/**\n * The media id in `value`, or `null` when it is not a media ref.\n *\n * Structural, deliberately — the same technique `asLinkValue` uses for links,\n * and for the same reason: a resolver that needed the block REGISTRY to know\n * which props are media would need every project to register its vocabulary at\n * the content-api mount, which is a second place to forget. Detection that\n * reads the value cannot go stale.\n *\n * Optional fields accept `null` as well as `undefined` so the check is\n * idempotent: a `MediaProjection` (whose optionals are `T | null`) is still\n * recognised, rather than being silently skipped on a second pass.\n */\nfunction mediaRefId(value: unknown, onNearMiss?: (keys: readonly string[]) => void): string | null {\n if (!isRecord(value)) return null\n const id = value.id\n if (typeof id !== 'string' || !UUID_RE.test(id)) return null\n if (typeof value.url !== 'string' || value.url === '') return null\n // The two cheap discriminators are checked FIRST, so the key scan runs only\n // for the handful of values that already look like a ref rather than for\n // every object in the tree.\n const unknownKeys = Object.keys(value).filter((key) => !MEDIA_REF_KEYS.has(key))\n if (unknownKeys.length > 0) {\n // 🔴 REPORTED, not silently declined. A value with a uuid `id` and a `url`\n // that this detector walks past is either a project prop we correctly left\n // alone or a media ref we failed to recognise — and the second is a stored\n // url reaching the wire with nothing anywhere saying so. The caller cannot\n // tell the two apart, and neither can this function; what it can do is\n // refuse to be quiet about the ambiguity.\n onNearMiss?.(unknownKeys)\n return null\n }\n if (!isOptional(value.alt, 'string')) return null\n if (!isOptional(value.title, 'string')) return null\n if (!isOptional(value.mimeType, 'string')) return null\n if (!isOptional(value.width, 'number')) return null\n if (!isOptional(value.height, 'number')) return null\n // Projection-only, like `focalPoint`: a string when present, so a project\n // prop holding something else there is left alone rather than claimed.\n if (!isOptional(value.blurDataUrl, 'string')) return null\n // `variants` is the one whitelisted key the type checks above do not cover,\n // and skipping it left an over-match: `{ id: <uuid>, url: '…', variants: 'x' }`\n // passed the whitelist and was replaced by a projection or by `null`. That is\n // precisely the direction the whitelist exists to prevent.\n if (value.variants !== undefined && value.variants !== null && !isRecord(value.variants)) {\n return null\n }\n // Same reasoning for `focalPoint`, which only a PROJECTION carries (a stored\n // ref never does — images D007): recognised on a second pass, never an\n // over-match on a project prop that happens to hold a non-object there.\n if (value.focalPoint !== undefined && value.focalPoint !== null && !isRecord(value.focalPoint)) {\n return null\n }\n // And `sources`, for the same reason — projection-only, and when present an\n // object of ARRAYS (shape name → files). Checked one level deep, because a\n // bare \"is an object\" let `{ id: <uuid>, url, sources: { natural: 'x' } }`,\n // a project's own prop, be claimed as media and replaced (CodeRabbit).\n if (value.sources !== undefined && value.sources !== null) {\n if (!isRecord(value.sources)) return null\n if (!Object.values(value.sources).every((list) => Array.isArray(list))) return null\n }\n // `avifSources` has `sources`' shape and the same over-match risk.\n if (value.avifSources !== undefined && value.avifSources !== null) {\n if (!isRecord(value.avifSources)) return null\n if (!Object.values(value.avifSources).every((list) => Array.isArray(list))) return null\n }\n // A spot crop is an object. A project prop holding something else under\n // `crop` is left alone rather than claimed. An object that is not a VALID\n // crop is still a media ref — the write gate refuses those, and a stored one\n // is drawn around the focal point (`readSpotCrop` is lenient) rather than\n // being left unresolved with its stored url.\n if (value.crop !== undefined && value.crop !== null && !isRecord(value.crop)) return null\n if (value.spotSources !== undefined && value.spotSources !== null) {\n if (!Array.isArray(value.spotSources)) return null\n }\n return id\n}\n\nfunction isOptional(value: unknown, type: 'string' | 'number'): boolean {\n return value === undefined || value === null || typeof value === type\n}\n\n/** One place in the tree holding a media ref, so it can be overwritten after resolution. */\ninterface MediaSlot {\n readonly container: Record<string, unknown> | unknown[]\n readonly key: string | number\n readonly id: string\n /** The placement's own crop (images PR16), read when the slot was found. */\n readonly crop?: SpotCrop | undefined\n}\n\n/**\n * Collect every media ref in the tree, recording WHERE it sits.\n *\n * Slots rather than a returned copy because the replacement is in place, which\n * is the same shape `projectMedia` uses on entity rows for the same reason: the\n * tree came from a per-request read and copying it would double the cost of the\n * largest thing on the page.\n */\n/** What the walk noticed but could not act on. Both are ambiguity, not failure. */\ninterface WalkReport {\n /** A subtree below {@link MAX_WALK_DEPTH} was never visited. */\n truncated: boolean\n /** Keys that made a ref-shaped value fall outside the whitelist, deduped. */\n nearMissKeys: Set<string>\n}\n\nfunction collectMediaSlots(\n node: unknown,\n out: MediaSlot[],\n depth: number,\n report: WalkReport,\n): void {\n // Flagged, never silent — and flagged rather than logged, so a pathological\n // tree cannot turn the operator log into a hot loop. Below the bound a media\n // ref is not visited, so its stored url would cross unchanged.\n if (depth > MAX_WALK_DEPTH) {\n report.truncated = true\n return\n }\n const noteNearMiss = (keys: readonly string[]): void => {\n for (const key of keys) report.nearMissKeys.add(key)\n }\n if (isUnknownArray(node)) {\n for (let index = 0; index < node.length; index += 1) {\n const id = mediaRefId(node[index], noteNearMiss)\n if (id !== null)\n out.push({ container: node, key: index, id, crop: readSpotCrop(node[index]) })\n else collectMediaSlots(node[index], out, depth + 1, report)\n }\n return\n }\n if (!isRecord(node)) return\n for (const [key, value] of Object.entries(node)) {\n const id = mediaRefId(value, noteNearMiss)\n if (id !== null) out.push({ container: node, key, id, crop: readSpotCrop(value) })\n else collectMediaSlots(value, out, depth + 1, report)\n }\n}\n\n/**\n * The projection as it goes INTO a block tree: null-valued optionals OMITTED.\n *\n * 🔴 Found by rendering a real page in a browser, not by a unit test — the\n * tests all used a projection whose `width`/`height`/`alt` happened to be\n * populated. `MediaProjection` declares its optionals `T | null` because a\n * top-level media field publishes a fixed key set. `MediaRef` — the type every\n * block render is typed against, and the one the consumer-side validator\n * narrows to — declares them `T | undefined`, so a JSON `null` there is not a\n * value any render can hold. The front-end validator did exactly what it should\n * with one: dropped it, and logged \"block props were dropped as unusable\" for\n * `image.width`, `image.height` and `image.alt` on EVERY render of every page\n * carrying an image. Correct behaviour, useless signal — the log that exists to\n * report real data loss would have been drowned by it from day one.\n *\n * Absent and null mean the same thing for every one of these fields, so\n * omitting is lossless. What it buys is a value that is assignable to the type\n * the renders declare, which is the whole reason `MediaRef` carries a url at\n * all.\n */\nfunction toEmbeddedProjection(projection: BlockMediaProjection): Record<string, unknown> {\n const out: Record<string, unknown> = {\n id: projection.id,\n url: projection.url,\n variants: projection.variants,\n // Always embedded, like `variants`: the render reads it to crop a shaped\n // slot, and a projection always carries one (`{0.5, 0.5}` when the row has\n // none), so there is no null to omit.\n focalPoint: projection.focalPoint,\n // Always embedded too: an empty map is \"nothing generated yet\", which a\n // render reads as \"no srcset\" — there is no null to omit.\n sources: projection.sources,\n }\n // Only when there are some (images PR12): the tree is serialised into the\n // page, so an empty key would change every page of a site with AVIF off.\n if (projection.avifSources !== undefined && Object.keys(projection.avifSources).length > 0) {\n out.avifSources = projection.avifSources\n }\n if (projection.mimeType !== null) out.mimeType = projection.mimeType\n if (projection.width !== null) out.width = projection.width\n if (projection.height !== null) out.height = projection.height\n if (projection.alt !== null) out.alt = projection.alt\n if (projection.title !== null) out.title = projection.title\n // Omitted when there is none, like the other optionals: most photographs on\n // a site that predates images PR11 have none until the catch-up reaches them.\n if (projection.blurDataUrl !== null) out.blurDataUrl = projection.blurDataUrl\n return out\n}\n\n/**\n * The files cut for THIS placement's crop, or `undefined` when the worker has\n * not cut them yet (or the placement has no crop). Matched on the four-decimal\n * grid (`sameSpotCrop`).\n */\nfunction spotSourcesFor(\n projection: BlockMediaProjection,\n crop: SpotCrop | undefined,\n): BlockSpotSources | undefined {\n if (crop === undefined || projection.spotSources === undefined) return undefined\n return projection.spotSources.find(\n (spot) => sameSpotCrop(spot.crop, crop) && spot.sources.length > 0,\n )\n}\n\n/**\n * The embedded value for a placement with a spot crop (images PR16, D002).\n *\n * - **Its files exist** — the placement's value becomes THOSE files: `sources`\n * holds the crop's list under `natural` (the crop IS this placement's\n * picture, at the crop's own ratio), `url`/`width`/`height` are the largest\n * file's, and the focal point is the centre (the file is already the crop).\n * So a render selects from the crop's files alone — the original is never\n * appended above them, because it is no longer larger than the value's own\n * width — and CSS has nothing left to crop. `avifSources` follows only when\n * the crop's AVIF files are exactly its WebP list, re-encoded.\n * - **They do not exist yet** — the projection as any placement gets it: the\n * focal-point files, never the original (D002). The crop is kept on the\n * value either way, so a second pass sees the same placement.\n *\n * A placement with NO crop never reaches this function, so its embedded value\n * — and the markup a render makes of it — is byte-identical to before.\n */\nfunction embedCropped(projection: BlockMediaProjection, crop: SpotCrop): Record<string, unknown> {\n const out = toEmbeddedProjection(projection)\n const spot = spotSourcesFor(projection, crop)\n const largest = spot?.sources[spot.sources.length - 1]\n if (spot !== undefined && largest !== undefined) {\n out.url = largest.url\n out.width = largest.width\n out.height = largest.height\n out.focalPoint = { x: 0.5, y: 0.5 }\n out.sources = { natural: spot.sources }\n const avif = spot.avifSources\n const avifMatches =\n avif !== undefined &&\n avif.length === spot.sources.length &&\n avif.every(\n (file, i) =>\n file.width === spot.sources[i]?.width && file.height === spot.sources[i]?.height,\n )\n if (avifMatches) out.avifSources = { natural: avif }\n else delete out.avifSources\n }\n out.crop = { x: crop.x, y: crop.y, w: crop.w, h: crop.h }\n return out\n}\n\nfunction writeSlot(slot: MediaSlot, value: BlockMediaProjection | null): void {\n const written =\n value === null\n ? null\n : slot.crop === undefined\n ? toEmbeddedProjection(value)\n : embedCropped(value, slot.crop)\n if (Array.isArray(slot.container)) {\n if (typeof slot.key === 'number') slot.container[slot.key] = written\n return\n }\n if (typeof slot.key === 'string') slot.container[slot.key] = written\n}\n\n// ---------------------------------------------------------------------------\n// The resolver\n// ---------------------------------------------------------------------------\n\n/**\n * Resolve one stored block tree into a {@link ResolvedBlockTree}.\n *\n * **Throws when `blocks` is not an array**, which is how a corrupt stored value\n * is handled rather than papered over — a resolver that returned `{ resolved: 1,\n * blocks: [] }` for it would report success while deleting a page's content,\n * and the caller could not tell the two apart.\n *\n * ⚠️ **What the caller does with that throw CHANGED** (editor D010, closing\n * F056). It used to serve the stored tree unchanged, so a malformed row cost\n * its own hrefs and nothing else. It no longer does: it re-calls this function\n * with an exhausted budget and, if that fails too, serves an empty envelope. A\n * non-array `blocks` fails this guard DETERMINISTICALLY, so a corrupt column is\n * now precisely the case that reaches that third state and is blanked. That is\n * the intended outcome rather than a regression — a non-array stored value\n * cannot be rendered by any consumer, and it may still be an object carrying\n * media refs whose urls were signed at author time.\n */\nexport async function resolveBlockTree(\n blocks: unknown,\n opts: {\n locale: string\n budget: BlockResolutionBudget\n entity: string\n field: string\n /** content-api's whitelist ∧ `view` gate for THIS caller. Absent → no collection rows. */\n canReadEntity?: ((entityName: string) => boolean) | undefined\n /** One slot of the response's collection allowance. Absent → no collection rows. */\n claimCollection?: (() => boolean) | undefined\n /** A CODED TEMPLATE's root block name — `blocks` is then ignored and the template rebuilt. */\n template?: string | undefined\n /** The row a templated document's bound blocks read. Read, never written. */\n boundValues?: Readonly<Record<string, unknown>> | undefined\n },\n deps: BlockResolverDeps,\n): Promise<ResolvedBlockTree> {\n // A CODED TEMPLATE has no stored tree (editor D017): rebuild it from the\n // registry, and remember which COLUMNS its blocks bind — a bound rich-text\n // column's link marks live in the row, and they are resolved below in the\n // same batch as the tree's own (F109). Only the columns THIS template binds\n // are read, so a same-named column bound by some other block is never walked.\n //\n // An ORDINARY tree can carry bound blocks too — `documentBody` dropped into a\n // composable page renders that page's column — so when the caller hands over\n // the row, its bound columns are walked the same way. Without that the canvas\n // (which composes bound values into any tree) resolved links the site then\n // rendered as plain text.\n let tree: unknown = blocks\n let boundColumns: readonly string[] = []\n if (opts.template !== undefined) {\n // 🔴 The ids belong to THIS document (editor F124): namespaced by the entity\n // and the row's own id, exactly as the editor and the bound-field route\n // namespace them, so the delivered tree carries the ids the canvas edits.\n //\n // The id is read off the row because the row is what the caller hands over\n // — content-api always projects `id` (`query-params.ts`, \"Always include\n // id\"), so a templated field never arrives without one. If it somehow did,\n // the template is served EMPTY rather than with ids that name no document:\n // an empty body is a visible fault, a shared id space is the defect F124\n // closed.\n const documentId = opts.boundValues?.id\n if (typeof documentId === 'string' && documentId !== '') {\n const built = (deps.materializeTemplate ?? materializeServerTemplate)(\n opts.template,\n templateNamespace(opts.entity, documentId),\n )\n tree = built.blocks\n boundColumns = built.boundColumns\n } else {\n tree = []\n deps.logger?.warn(\n { entity: opts.entity, field: opts.field },\n 'content: a templated document arrived without its row id, so its template is served ' +\n 'EMPTY — its block ids could not be namespaced to it (editor F124)',\n )\n }\n } else if (opts.boundValues !== undefined && isUnknownArray(blocks)) {\n // Degrades to \"no bound columns\" rather than failing the tree: bound-column\n // links are an enhancement, and a throw here would otherwise blank the whole\n // document (the zero-budget retry would throw identically).\n try {\n boundColumns = (deps.boundColumnsOf ?? serverBoundColumnsInTree)(blocks)\n } catch (err) {\n deps.logger?.warn(\n { err, entity: opts.entity, field: opts.field },\n 'content: could not enumerate the bound columns of a block tree, so its bound-field links render plain',\n )\n }\n }\n const row = opts.boundValues ?? {}\n const boundSources = boundColumns\n .filter((column) => Object.hasOwn(row, column))\n .map((column) => row[column])\n\n if (!isUnknownArray(tree)) {\n deps.logger?.warn(\n { entity: opts.entity, field: opts.field },\n 'content: stored block tree is not an array — nothing to resolve, so this field is ' +\n 'served EMPTY rather than as stored (the caller retries this throw with an exhausted ' +\n 'budget, and a non-array fails that identically)',\n )\n throw new TypeError(\n `content: block tree \\`${opts.entity}.${opts.field}\\` is not an array; nothing to resolve.`,\n )\n }\n\n const slots: MediaSlot[] = []\n const report: WalkReport = { truncated: false, nearMissKeys: new Set() }\n collectMediaSlots(tree, slots, 0, report)\n // ONE line per call at most, whatever the tree looks like — a per-node log on\n // an anonymous request path is the amplification this file bounds everywhere\n // else. Both are hedged (\"any\", \"may\") because the walk cannot know whether\n // the thing it declined to visit was a media ref at all; what it knows is\n // that it did not look.\n if (report.truncated) {\n deps.logger?.warn(\n { entity: opts.entity, field: opts.field, cap: MAX_WALK_DEPTH },\n 'content: block tree is nested deeper than the media walk descends — any media ref below ' +\n 'the bound keeps the url recorded when an editor picked it, which may be signed',\n )\n }\n if (report.nearMissKeys.size > 0) {\n deps.logger?.debug(\n { entity: opts.entity, field: opts.field, keys: [...report.nearMissKeys].sort() },\n 'content: a value looked like a media ref but carried keys outside the projection, so it ' +\n 'was left alone. If these are media refs, they keep their stored urls',\n )\n }\n // Its OWN bound, not this module's. Link resolution is a display concern — an\n // unresolved link renders as plain text — while the media walk is a security\n // control, which is why only one of the two was raised. Passing\n // `MAX_WALK_DEPTH` here would have silently quadrupled link collection depth\n // as a side effect of a change that had nothing to do with links.\n //\n // The bound columns' links join the SAME list, so they share every step below\n // — one media batch, one published-path lookup per linked entity type, one\n // pair of claims against the response's budget. That is F109's fan-out bound:\n // links are resolved per DOCUMENT, never one request per link and never one\n // batch per column.\n const links = [\n ...collectLinkValues(tree, LINK_WALK_DEPTH),\n ...collectLinkValues(boundSources, LINK_WALK_DEPTH),\n ]\n\n // Media LINKS resolve to a media url, so they join the same batch — one\n // lookup covers a `{kind:'media'}` link and an image of the same asset.\n //\n // 🔴 FILTERED to the uuid shape, and the filter is load-bearing rather than\n // defensive. `mediaRefId` already requires a uuid, but `asLinkValue` accepts\n // ANY non-empty string as a `{kind:'media'}` id — so one legacy or\n // hand-authored link with `id: 'x'` reached `inArray(idColumn, ids)` and\n // Postgres raised `22P02 invalid input syntax for type uuid`. The batch's\n // catch then returned an EMPTY map, and every slot on the page was written\n // `null`: one malformed link deleted every image in the tree. Rejects are\n // reported as `unresolvable` rather than dropped, because a link that can\n // never resolve is exactly what that channel is for.\n const wellFormedMediaLinkIds: string[] = []\n const malformedRefs: UnresolvedBlockRef[] = []\n for (const link of links) {\n if (link.kind !== 'media') continue\n if (UUID_RE.test(link.id)) wellFormedMediaLinkIds.push(link.id)\n else malformedRefs.push({ kind: 'link', ref: linkValueKey(link), reason: 'unresolvable' })\n }\n const mediaIds = distinct([...slots.map((slot) => slot.id), ...wellFormedMediaLinkIds])\n\n const entityTargets: string[] = []\n for (const link of links) {\n if (link.kind !== 'entity') continue\n // Same reason: `filterPublished` issues one `inArray` per entity type, so a\n // single non-uuid id throws and takes every link of that type with it.\n if (UUID_RE.test(link.id)) entityTargets.push(targetKey(link.entity, link.id))\n else malformedRefs.push({ kind: 'link', ref: linkValueKey(link), reason: 'unresolvable' })\n }\n\n const unresolved: UnresolvedBlockRef[] = [...malformedRefs]\n\n // TWO claims against the ONE shared counter, media first.\n //\n // Two rather than one so each pass learns its own grant without splitting a\n // combined number, and media first because a vanished image is the more\n // visible failure — when a page is over budget, the deterministic outcome is\n // \"images render, some links go plain\" rather than whichever the scheduler\n // reached first. Both record their shortfall under the same `block` kind, so\n // the operator log is unchanged.\n //\n // Nothing is claimed for media when no resolver is wired: the budget reports\n // work the server DECLINED, and there is no work to decline. Claiming anyway\n // burned the whole ceiling on a pass that did no I/O and then reported every\n // LINK as `budget` — a drop log naming the wrong cause.\n if (!deps.resolveMedia && mediaIds.length > 0) {\n // The signal for \"this deployment cannot resolve media at all\", emitted\n // HERE because this is now the only place that can tell. `resolveMediaBatch`\n // used to carry it, and the no-claim rule above made its branch\n // unreachable — the ids never reach it. It is the likeliest cause of \"every\n // image on the site is gone\", and the `unresolvable` the envelope reports\n // deliberately does not distinguish causes, so the operator log is the only\n // place it can be said.\n deps.logger?.debug(\n { entity: opts.entity, field: opts.field, count: mediaIds.length },\n 'content: no media resolver wired for block trees — every media ref resolves to null',\n )\n }\n\n const mediaGranted = deps.resolveMedia ? opts.budget.claim('block', mediaIds.length) : 0\n const coveredMedia = mediaIds.slice(0, mediaGranted)\n // 🔴 EMPTY when no resolver is wired, and that is not the same set as\n // \"everything\". `budgetDroppedMedia` means \"a ceiling refused this\", and it\n // is read again below by `unresolvedLinkReason` — so filling it on the\n // no-resolver path reported every media LINK as `reason: 'budget'` and sent\n // an operator to raise a limit that was never involved. The reason was fixed\n // for the media entries and missed for the links; one set, read twice, is\n // what keeps them consistent.\n const budgetDroppedMedia = deps.resolveMedia\n ? new Set(mediaIds.slice(mediaGranted))\n : new Set<string>()\n for (const id of mediaIds.slice(mediaGranted)) {\n unresolved.push({\n kind: 'media',\n ref: id,\n reason: budgetDroppedMedia.has(id) ? 'budget' : 'unresolvable',\n })\n }\n\n const distinctTargets = distinct(entityTargets)\n const linkGranted = opts.budget.claim('block', distinctTargets.length)\n const coveredTargets = distinctTargets.slice(0, linkGranted)\n const budgetDroppedTargets = new Set(distinctTargets.slice(linkGranted))\n\n const media = await resolveMediaBatch(coveredMedia, opts.locale, deps, opts.entity)\n\n // Every slot is written, including the ones that did not resolve. Leaving a\n // stored ref in place for an id the resolver refused is the leak this module\n // exists to close (see the file header) — \"not public\" and \"not found\" are\n // the same branch downstream, so the ONE case where a fallback would matter\n // is exactly the case where it must not happen.\n for (const slot of slots) {\n const projection = media.get(slot.id) ?? null\n writeSlot(slot, projection)\n }\n for (const id of coveredMedia) {\n if (!media.has(id)) unresolved.push({ kind: 'media', ref: id, reason: 'unresolvable' })\n }\n\n const paths = await resolveEntityPaths(coveredTargets, opts.locale, deps, opts.entity)\n\n // `resolveLinks` still owns dedup, the `sanitizeHref` protocol gate on\n // resolved hrefs, anchor composition and the inline url/email cases. What it\n // is handed here are PURE MAP LOOKUPS, so its `runBounded` fan-out does no\n // I/O at all — the batching above already happened.\n const resolvedLinks = await resolveLinks(links, {\n renderContext: 'request',\n locale: opts.locale,\n resolveEntityHref: ({ entity, id }) => paths.get(targetKey(entity, id)) ?? null,\n resolveMediaHref: (id) => media.get(id)?.url ?? null,\n })\n\n const linkRecord: Record<string, string | null> = {}\n for (const [key, resolved] of resolvedLinks) linkRecord[key] = resolved.href\n // Only `entity`/`media` links are REPORTED as unresolved. A `url` or `email`\n // link with a null href was refused by `sanitizeHref` — an editor typed\n // `javascript:` — which is a content problem the security gate handled, not a\n // resolution this response failed to perform, and listing it would invite a\n // consumer to retry it.\n for (const link of links) {\n if (link.kind !== 'entity' && link.kind !== 'media') continue\n const key = linkValueKey(link)\n if (linkRecord[key] !== null && linkRecord[key] !== undefined) continue\n unresolved.push({\n kind: 'link',\n ref: key,\n reason: unresolvedLinkReason(link, budgetDroppedTargets, budgetDroppedMedia),\n })\n }\n\n // What this tree DEPENDS ON, for whatever cache is holding the response.\n //\n // 🔒 Built from what actually RESOLVED, not from what was asked for. `media`\n // holds only ids the public resolver returned, and `paths` only targets the\n // published-path lookup answered — so an unpublished link target contributes\n // no ref, and its id is not disclosed. That is the same gate the hrefs above\n // pass through, applied to identity; the refusal is already reported through\n // `unresolved`, which carries a `linkValueKey` the consumer already holds.\n //\n // Media BEFORE links, and both deduped by the Map: an image embedded as a\n // `field.media()` and linked as `{kind:'media'}` is one row and one tag.\n // block-library F012 — the delivered-site half of PR03's live-source\n // contract. Deny-by-default like collections: no `resolveLiveValues`\n // wired, no `live` sidecar, and a bound Price draws nothing (the same\n // thing the canvas draws for a site with no sources at all).\n //\n // 🔴 CLAIMED against the SAME shared budget every other resolution kind in\n // this function draws from, same as `collections`' own `claimCollection`.\n // Without it, a list response resolving N rows each binding a live Price\n // would call the site's own `LiveSourceSpec.resolve` — an ARBITRARY,\n // site-registered callback, explicitly documented as reaching outside this\n // process (`core/live-sources.ts`: \"never elevate… it runs inside the\n // request's own context\") — once per row with no ceiling at all: exactly\n // the amplification CLAUDE.md's no-unbounded-fan-out section forbids,\n // reachable the moment any site registers a live source. One unit, because\n // a tree either asks its source(s) once or not at all — unlike media/links,\n // there is no per-item count to charge, and a 1-unit claim is what the\n // collection pass already charges for the same \"one call, unknown size\"\n // shape.\n const resolveLive = deps.resolveLiveValues\n const liveGranted = resolveLive !== undefined ? opts.budget.claim('block', 1) : 0\n\n // Run alongside collections rather than after: neither's result feeds the\n // other, and `resolveLiveValues` can take up to several seconds against a\n // slow or down upstream (`LIVE_DOCUMENT_TIMEOUT_MS`), which otherwise\n // stacked on top of collection resolution's own latency for no reason.\n const [{ results: collections, cardMedia }, live] = await Promise.all([\n resolveCollectionsIn(tree, opts, deps),\n liveGranted > 0 && resolveLive !== undefined\n ? // `(async () => …)()` rather than calling `resolveLive` directly: its\n // type (`BlockResolverDeps['resolveLiveValues']`) only requires a\n // `Promise<LiveValues>` RETURN, not an `async` implementation, so a\n // misbehaving caller-supplied resolver could throw SYNCHRONOUSLY\n // before ever producing a promise for `.catch()` to attach to — which\n // would reject `Promise.all` (and this whole function) before the\n // `.catch()` below is ever reached. Wrapping in an async IIFE turns\n // that synchronous throw into a rejection of the IIFE's own promise,\n // so `.catch()` catches it too.\n (async () => resolveLive(tree, opts.locale))().catch((err: unknown) => {\n // The production wiring (`loadCanvasLiveValues`) never throws by its\n // own contract (\"Contained\", server/live-values.ts), but `deps` is\n // an injection seam — nothing at the TYPE level stops some other\n // caller wiring a `resolveLiveValues` that rejects or throws. Either\n // one here must not reject the whole `Promise.all`: that would\n // discard the collection pass's already-settled results and fail\n // this tree's ENTIRE resolution for a failure that was supposed to\n // cost it only the live sidecar — the same isolation\n // `resolveLiveValues` itself already gives one bad SOURCE among\n // several.\n deps.logger?.warn(\n { err },\n 'blocks: resolveLiveValues rejected; this tree draws no live values',\n )\n return undefined\n })\n : Promise.resolve(undefined),\n ])\n\n // block-library PR16 — the row's detail, for the parts THIS tree's blocks\n // draw and no others. A tree with no detail block (every row of an ordinary\n // list response) is not projected at all.\n //\n // 🔴 One part costs real CPU: an HTML body is sanitised here, up to half a\n // megabyte of it, on a public request. So a projection that needs the body\n // claims one unit of the response's budget, exactly as the live pass does —\n // a list of a hundred legal documents sanitises as many bodies as the budget\n // has units, and the rest carry their other parts without one. It reads the\n // row already in hand and runs no query.\n //\n // Contained, because `deps` is an injection seam: a throwing projection costs\n // this tree its detail, not its blocks.\n let detail: DocumentDetail | undefined\n if (deps.resolveDetail !== undefined && opts.boundValues !== undefined) {\n try {\n let parts = detailPartsInTree(isUnknownArray(tree) ? tree : [])\n if (\n parts.has('body') &&\n deps.detailNeedsBody?.(opts.entity, parts, opts.boundValues) === true &&\n opts.budget.claim('block', 1) < 1\n ) {\n const withoutBody = new Set(parts)\n withoutBody.delete('body')\n parts = withoutBody\n deps.logger?.warn(\n { entity: opts.entity, field: opts.field },\n 'blocks: the response budget is spent; this tree is delivered without its HTML body',\n )\n }\n if (parts.size > 0) {\n detail = deps.resolveDetail(opts.entity, opts.boundValues, opts.locale, parts)\n }\n } catch (err) {\n deps.logger?.warn(\n { err, entity: opts.entity },\n 'blocks: resolveDetail threw; this tree carries no detail',\n )\n }\n }\n\n const refByKey = new Map<string, BlockTreeRef>()\n for (const id of media.keys()) refByKey.set(targetKey('media', id), { entity: 'media', id })\n for (const id of cardMedia) refByKey.set(targetKey('media', id), { entity: 'media', id })\n // A listed row is a row this tree depends on, and it passed the caller's gate\n // to be listed — the same rule the links below follow.\n for (const result of collections.values()) {\n if (result.entity === null) continue\n for (const row of result.rows) {\n refByKey.set(targetKey(result.entity, row.id), { entity: result.entity, id: row.id })\n }\n }\n for (const key of paths.keys()) {\n if (refByKey.has(key)) continue\n const split = key.indexOf(' ')\n if (split <= 0) continue\n const entity = key.slice(0, split)\n const id = key.slice(split + 1)\n if (id === '') continue\n refByKey.set(key, { entity, id })\n }\n\n return {\n resolved: 1,\n blocks: tree,\n links: linkRecord,\n // OMITTED when empty, never `[]`. Its presence is itself information —\n // Contentful's rule for `errors[]`, and the property that stops \"nothing\n // failed\" from looking like \"nothing was attempted\".\n ...(unresolved.length > 0 && { unresolved: dedupeRefs(unresolved) }),\n // Same rule, same reason: a tree that references nothing says so by\n // omitting the field rather than by shipping `[]`.\n ...(refByKey.size > 0 && { refs: [...refByKey.values()] }),\n // And again: a tree with no collection block carries no sidecar.\n ...(collections.size > 0 && { collections: Object.fromEntries(collections) }),\n // Omitted when the site registers no live sources at all — see\n // `ResolvedBlockTree.live`'s own docblock for why `sources` (not just\n // `values`) is what makes this non-empty. Projected to `{sources, values}`\n // rather than spread whole: `deps.resolveLiveValues` returns the blocks\n // package's own `LiveValues`, which also carries `asked` (canvas-merge\n // bookkeeping — which keys were requested, for a reader this one-shot\n // render has none of). The wire type `ResolvedLiveValues` deliberately\n // excludes it; spreading the raw object would put it on the wire anyway,\n // since nothing else here strips it.\n ...(live !== undefined &&\n Object.keys(live.sources).length > 0 && { live: { sources: live.sources, values: live.values } }),\n // NOT gated on `live` or on this tree binding anything: a plain, typed-in\n // Price needs the currency too.\n ...(deps.currency !== undefined && { currency: deps.currency }),\n // Omitted unless the row has a value for a declared part.\n ...(detail !== undefined && { detail }),\n }\n}\n\n/**\n * Resolve every collection block in `blocks` through the shared resolver.\n *\n * DENY-BY-DEFAULT at three levels: an app with no declared sources has no\n * `deps.collections`, and a call without `canReadEntity` — a host that did not\n * say who may read what — or without `claimCollection` — a host that did not\n * say how many — resolves nothing. Either way the blocks render empty on the\n * site, which is the safe direction.\n *\n * Images go through the SAME public media resolver the tree's own media refs\n * do, and hrefs through the same published-path lookup its links do, so a card\n * cannot show an asset or a URL the rest of the page could not.\n */\nasync function resolveCollectionsIn(\n blocks: readonly unknown[],\n opts: {\n locale: string\n budget: BlockResolutionBudget\n canReadEntity?: ((entityName: string) => boolean) | undefined\n claimCollection?: (() => boolean) | undefined\n },\n deps: BlockResolverDeps,\n): Promise<{\n readonly results: ReadonlyMap<string, CollectionResult>\n readonly cardMedia: ReadonlySet<string>\n}> {\n const collections = deps.collections\n const canRead = opts.canReadEntity\n const claimCollection = opts.claimCollection\n if (collections === undefined || canRead === undefined || claimCollection === undefined) {\n return { results: new Map(), cardMedia: new Set() }\n }\n const resolveMedia = deps.resolveMedia\n // Every media id a card will draw, so the tree names it in `refs` like its\n // own media: a replaced image or an edited alt must evict the cached page\n // (CodeRabbit review, PR53).\n const cardMedia = new Set<string>()\n const results = await resolveTreeCollections(blocks, collections.sourceFieldOf, {\n sources: collections.sources,\n getEntity: collections.getEntity,\n clientFor: collections.clientFor,\n canRead,\n resolveImages:\n resolveMedia === undefined\n ? undefined\n : async (ids): Promise<ReadonlyMap<string, CollectionCardImage>> => {\n const found = await resolveMedia(ids, { locale: opts.locale })\n // A card image must BE an image. A media field's `kind: 'image'`\n // is only a picker filter (editor F116), so a video can sit in\n // one — and the card would draw it as a broken `<img>`.\n const shown = [...found].filter(\n ([, projection]) => projection.mimeType?.startsWith('image/') === true,\n )\n for (const [id] of shown) cardMedia.add(id)\n // All seven keys a card needs — beyond the four: where to crop\n // around and which generated files to offer (images PR07, F021),\n // and the preview it shows while it loads (PR11).\n // `resolveCollectionSource` normalises what this returns\n // (`cardImageOf`: a centre point, an empty `sources` and a missing\n // preview are left out), the SAME step the canvas's injected resolver goes through,\n // so a card carries the same keys on both surfaces.\n return new Map(\n shown.map(([id, projection]) => [\n id,\n {\n url: projection.url,\n alt: projection.alt,\n width: projection.width,\n height: projection.height,\n focalPoint: projection.focalPoint,\n sources: projection.sources,\n // Non-empty only, the rule the embedded projection follows\n // (`cardImageOf` would drop `{}` too; one rule, both paths).\n ...(projection.avifSources !== undefined &&\n Object.keys(projection.avifSources).length > 0 && {\n avifSources: projection.avifSources,\n }),\n blurDataUrl: projection.blurDataUrl,\n },\n ]),\n )\n },\n resolveHrefs: (entityType, ids) => deps.resolvePublishedPaths(entityType, ids, opts.locale),\n locale: opts.locale,\n renderContext: 'request',\n // A slot of the response's collection allowance, THEN one unit of its ONE\n // budget — so a page whose references and media spent the ceiling lists\n // nothing rather than exceeding it, and a list response stops at the\n // allowance however many rows it carries. Allowance first: once it is\n // spent, a refused collection must not also burn budget the rest of the\n // response's media and links need.\n claim: () => claimCollection() && opts.budget.claim('block', 1) >= 1,\n logger: deps.logger,\n })\n return { results, cardMedia }\n}\n\nfunction unresolvedLinkReason(\n link: LinkValue,\n budgetDroppedTargets: ReadonlySet<string>,\n budgetDroppedMedia: ReadonlySet<string>,\n): 'unresolvable' | 'budget' {\n if (link.kind === 'entity' && budgetDroppedTargets.has(targetKey(link.entity, link.id))) {\n return 'budget'\n }\n if (link.kind === 'media' && budgetDroppedMedia.has(link.id)) return 'budget'\n return 'unresolvable'\n}\n\nfunction distinct(values: readonly string[]): string[] {\n return [...new Set(values)]\n}\n\n/**\n * `(entityType, id)` as one map key. A space is unambiguous as a separator\n * because entity names are identifiers — `defineEntity` rejects anything else —\n * so no name can contain one.\n */\nfunction targetKey(entityType: string, id: string): string {\n return [entityType, id].join(' ')\n}\n\n/**\n * One entry per `(kind, ref)`.\n *\n * What this actually collapses is the SAME LinkValue appearing several times —\n * `collectLinkValues` does not dedupe, so a page linking twice to one target\n * would otherwise report it twice. It deliberately does NOT collapse an image\n * and a media LINK to the same asset: those are `media <id>` and\n * `link media:<id>`, two different failures a consumer may want to act on\n * differently, and the suite asserts exactly that.\n */\nfunction dedupeRefs(refs: readonly UnresolvedBlockRef[]): UnresolvedBlockRef[] {\n const seen = new Set<string>()\n const out: UnresolvedBlockRef[] = []\n for (const ref of refs) {\n const key = `${ref.kind} ${ref.ref}`\n if (seen.has(key)) continue\n seen.add(key)\n out.push(ref)\n }\n return out\n}\n\nasync function resolveMediaBatch(\n ids: readonly string[],\n locale: string,\n deps: BlockResolverDeps,\n entity: string,\n): Promise<Map<string, BlockMediaProjection>> {\n // No log for the unwired case HERE: the caller does not claim, so `ids` is\n // always empty on that path and a branch guarding on it could never fire.\n // The signal lives at the call site, which is the only place that still knows.\n if (ids.length === 0 || !deps.resolveMedia) return new Map()\n try {\n return await deps.resolveMedia(ids, { locale, spots: true })\n } catch (err) {\n deps.logger?.warn(\n { entity, err },\n 'content: media resolver threw while resolving a block tree — its media refs resolve to ' +\n 'null (if this is a ForbiddenError, the requesting role is missing `media:view`)',\n )\n return new Map()\n }\n}\n\n/**\n * {@link targetKey} → path, for the covered targets only.\n *\n * ONE call to {@link BlockResolverDeps.resolvePublishedPaths} per entity TYPE,\n * never per link. A document linking 200 times to 3 entity types costs 3 calls.\n */\nasync function resolveEntityPaths(\n targets: readonly string[],\n locale: string,\n deps: BlockResolverDeps,\n entity: string,\n): Promise<Map<string, string>> {\n const out = new Map<string, string>()\n if (targets.length === 0) return out\n\n const byType = new Map<string, string[]>()\n for (const target of targets) {\n const separator = target.indexOf(' ')\n const entityType = target.slice(0, separator)\n const id = target.slice(separator + 1)\n const bucket = byType.get(entityType)\n if (bucket) bucket.push(id)\n else byType.set(entityType, [id])\n }\n\n // SERIAL over types, matching `resolveBlockTrees`' serial-over-rows rule: the\n // set is tiny (a document links to a handful of types at most) and serial\n // keeps the read pattern predictable under load rather than bursting one\n // query per type at once.\n for (const [entityType, ids] of byType) {\n try {\n const paths = await deps.resolvePublishedPaths(entityType, ids, locale)\n for (const [id, path] of paths) out.set(targetKey(entityType, id), path)\n } catch (err) {\n // One broken entity type costs its own links, not the page's whole tree.\n deps.logger?.warn(\n { entity, entityType, err },\n 'content: path resolution threw for a block-tree link target — its links render as ' +\n 'plain text',\n )\n }\n }\n return out\n}\n\n// ---------------------------------------------------------------------------\n// App wiring\n// ---------------------------------------------------------------------------\n\n/**\n * Wire {@link resolveBlockTree} to the running app — the one line an app puts\n * into `createContentApiHandler({ resolveBlocks })`.\n *\n * Pass the SAME `resolveMedia` value the handler gets, so a media ref inside a\n * block and a top-level `media()` field resolve through one path and one gate.\n *\n * **The publish gate comes from `buildEnumerateDeps`' batched `filterPublished`,\n * which reads through an elevated REACH context** (`createSystemReachResolver`,\n * which grants `view` and withholds `view_unpublished`), so unpublished and\n * trashed targets are dropped. That is deliberately the SAME gate `GET /_resolve`\n * applies and no stricter one: the alternative — additionally gating on the\n * caller's per-entity `view` — would make the same URL a working link on one\n * endpoint and plain text on another, while the document behind the href stays\n * whitelist- and permission-gated where it is actually fetched.\n */\nexport function createBlockResolver(\n getApp: () => Promise<ToolkitApp> | ToolkitApp,\n options: {\n /**\n * The locale to resolve in when a request carried none. Pass the SAME value\n * given to `createContentApiHandler({ defaultLocale })` — the handler\n * already defaults every entity read's locale before it reaches a resolver,\n * so this only binds on a path that somehow did not, and disagreeing with\n * the handler would resolve a link in a locale the page is not rendered in.\n */\n defaultLocale: string\n resolveMedia?: BlockMediaResolver | undefined\n },\n): (\n blocks: unknown,\n opts: {\n locale?: string | undefined\n budget: BlockResolutionBudget\n entity: string\n field: string\n canReadEntity?: ((entityName: string) => boolean) | undefined\n claimCollection?: (() => boolean) | undefined\n template?: string | undefined\n boundValues?: Readonly<Record<string, unknown>> | undefined\n },\n) => Promise<ResolvedBlockTree> {\n // Built ONCE per app, not once per field per row. `buildEnumerateDeps` makes\n // a table client, and its `filterPublished` makes a fresh `QueryClient` and a\n // child logger per call — cheap individually, and paid once per `blockTree`\n // field on every row of every list page if it lives inside the closure.\n //\n // Cached against the app IDENTITY rather than assumed singleton: `getApp` is\n // a callback precisely because a host may hand back a different app (tests\n // do), and a cache that ignored that would silently read another app's\n // database.\n let cached: { app: ToolkitApp; deps: BlockResolverDeps } | null = null\n\n const depsFor = (app: ToolkitApp): BlockResolverDeps => {\n if (cached?.app === app) return cached.deps\n // `pathsClient` here is the BATCHED (`{ in }`-capable) reader, which is what\n // `getCanonicalPaths` needs — the point-lookup `buildCanonicalPathDeps`\n // reader cannot express an id batch, and reaching for it is how this ends\n // up one query per link.\n const enumerateDeps = buildEnumerateDeps(app)\n // editor PR53 — only when the app declares collection sources. The reader\n // is `createQueryClient`, which is published-only and bound to the AMBIENT\n // request context: the same caller-scoped read content-api's own list\n // endpoint performs, so a collection sees exactly the rows that caller\n // could fetch directly — nothing wider, no elevated identity.\n const { sources } = effectiveCollectionSources(app)\n const collections: BlockCollectionDeps | undefined =\n sources.length === 0\n ? undefined\n : {\n sources,\n sourceFieldOf: serverCollectionFieldLookup(),\n getEntity: (name) => app.entities.get(name),\n clientFor: (entity) => createCollectionReader(entity, app),\n }\n // A template is REGISTRY code, fixed after boot, so the registry lookups it\n // is rebuilt from are built ONCE per app rather than per row: a list\n // response of a hundred templated rows would otherwise rebuild the\n // block-definition map two hundred times on the anonymous path. The TREE is\n // still rebuilt per row, because its ids belong to that row's document\n // (editor F124) — the part that was cheap. Each call answers a fresh,\n // deep-cloned tree (see `serverTemplateMaterializer`), so the media slots\n // this resolver writes in place never reach another response or the\n // registry.\n const templates = serverTemplateMaterializer()\n // The structure lookup for ORDINARY trees' bound columns, built once per app\n // for the same reason: `serverBoundColumnsInTree` would otherwise rebuild the\n // block-definition map per row per field on the anonymous path.\n const structureLookup = serverBlockStructureLookup()\n // Validated once per app; a rejected declaration was logged at boot.\n const detailSpecs = effectiveDetailSpecs(app).specs\n const deps: BlockResolverDeps = {\n ...(collections !== undefined && { collections }),\n boundColumnsOf: (tree) => collectBoundColumnsInTree(tree, structureLookup),\n materializeTemplate: (template, namespace) => templates(template, namespace),\n resolveMedia: options.resolveMedia,\n resolvePublishedPaths: async (entityType, ids, locale) => {\n const published = await enumerateDeps.filterPublished(entityType, ids, locale)\n if (published.size === 0) return new Map()\n return getCanonicalPaths(entityType, [...published], locale, enumerateDeps)\n },\n logger: app.logger,\n // block-library F012 — the SAME resolver the canvas's own entry points\n // call (`loadCanvasLiveValues`), so a price cannot bind through a\n // different path than the one the author saw while editing. `currency`\n // does not depend on the locale passed in — `canvasSiteFormat` only\n // needs one to echo back as `.locale`, which this caller discards.\n //\n // Wired only when the site actually declared a source — mirroring\n // `collections` above. Unconditional wiring would claim one budget unit\n // per tree on every site regardless of whether it uses live values at\n // all, which on a list response competes with media/link claims for no\n // reason on the overwhelming majority of trees that carry no live-bound\n // field.\n ...(hasConfiguredLiveSources() && {\n resolveLiveValues: (roots: readonly unknown[], locale: string) =>\n loadCanvasLiveValues(roots, { locale, logger: app.logger }),\n }),\n currency: canvasSiteFormat(options.defaultLocale).currency,\n // block-library PR16 — wired only when the app declares a detail for at\n // least one entity, mirroring `collections` and `resolveLiveValues`.\n ...(detailSpecs.size > 0 && {\n resolveDetail: (\n entity: string,\n row: Readonly<Record<string, unknown>>,\n locale: string,\n parts: ReadonlySet<DocumentDetailPart>,\n ) => {\n const spec = detailSpecs.get(entity)\n return spec === undefined ? undefined : resolveDocumentDetail(spec, row, locale, parts)\n },\n detailNeedsBody: (\n entity: string,\n parts: ReadonlySet<DocumentDetailPart>,\n row: Readonly<Record<string, unknown>>,\n ) => {\n const spec = detailSpecs.get(entity)\n return spec !== undefined && detailNeedsBody(spec, parts, row)\n },\n }),\n }\n cached = { app, deps }\n return deps\n }\n\n return async (blocks, opts) => {\n const app = await getApp()\n return resolveBlockTree(\n blocks,\n { ...opts, locale: opts.locale ?? options.defaultLocale },\n depsFor(app),\n )\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"block-resolver.mjs","names":[],"sources":["../src/block-resolver.ts"],"sourcesContent":["/**\n * The block-tree resolver for `@murumets-ee/content-api`'s `resolveBlocks` seam\n * (`frontend-delivery` D019).\n *\n * This is the half `content-api` structurally cannot do: it walks a stored\n * tree, re-resolves the media refs inside it through the public-only media\n * path, and turns the `LinkValue`s inside it into hrefs against the published\n * routing index. `content-api` is a leaf — `blocks`, `media` and this package\n * all depend back on it — so it takes this as an injected provider, exactly as\n * it takes chrome, menus and routing resolution.\n *\n * ## What it emits, and why that shape\n *\n * A `ResolvedBlockTree` envelope: `{ resolved: 1, blocks, links, unresolved? }`.\n * The reasoning is D019's and is restated where it binds the code below, but\n * the two-sentence version is: links go into a `linkValueKey`-keyed sidecar\n * because that is exactly what every renderer already consumes, and media is\n * replaced IN PLACE because a media node is not an opaque pointer — it already\n * carries a url that every render reads, and that url has to be overwritten\n * regardless.\n *\n * ## 🔴 The security property this exists for\n *\n * A `field.media()` value inside a block tree is stored as a whole `MediaRef`\n * including a `url`, denormalised when the editor picked it. That url comes\n * from `StorageClient.getUrl`, which returns a **signed** url for any file that\n * is not public — and `StorageClient.upload` defaults to private. So the stored\n * tree can carry a bearer capability, and serving it unchanged puts that\n * capability on a `public, max-age=60` response any shared CDN will hand to\n * every later anonymous visitor. That is precisely the leak `getPublicUrls` and\n * `plan/api` D021 made structurally impossible for TOP-LEVEL media fields; the\n * block-tree path routed around it because the url stopped being a url and\n * became content.\n *\n * So the stored url is **discarded, never used as a fallback**. An id that does\n * not resolve becomes `null`, because \"did not resolve\" and \"is not public\" are\n * the same branch in the shipped resolver — a fallback would preserve the leak\n * exactly in the case that matters.\n *\n * ## 🔴 The fan-out property\n *\n * `resolveLinks`' own docblock hands the COUNT bound to whoever wires it: it\n * caps peak concurrency and deliberately does not cap how many links it\n * resolves. A naive wiring would give it a seam that hits the database per\n * distinct link — one publish check plus one path read each — on an anonymous\n * request path, which is verbatim what CLAUDE.md's no-unbounded-fan-out section\n * forbids. So this module resolves everything FIRST, batched by entity type\n * (two index reads per type, regardless of link count), and hands `resolveLinks`\n * a pure map lookup. What it costs is bounded by the number of distinct entity\n * TYPES a document links to, not by the number of links.\n *\n * ## Collections — rows, through the caller's own gate (editor PR53)\n *\n * A COLLECTION block's rows are resolved here too, into the envelope's additive\n * `collections` sidecar, through `@murumets-ee/blocks/server`'s\n * `resolveTreeCollections` — the ONE resolver the canvas calls as well. Three\n * properties make that safe on an anonymous path:\n *\n * - The reader is the CALLER, not an elevated identity: rows come through a\n * `QueryClient` bound to the ambient request context, and the provider may\n * only list an entity content-api's own whitelist ∧ `view` gate admits\n * (`canReadEntity`, handed in by the handler). Without that predicate, no\n * collection resolves at all — deny-by-default, never a guess.\n * - Only the card's mapped slots cross, never the row.\n * - It is bounded: at most `MAX_COLLECTIONS_PER_DOCUMENT` per tree, serial,\n * each claiming one slot of the host's per-RESPONSE collection allowance\n * (`claimCollection` — a list response resolves many trees) and one unit of\n * the response's resolution budget. No allowance, no collections.\n *\n * ## What it deliberately does not do\n *\n * Plain `query()` fields (the fixed-entity `articleList` / `relatedPosts`\n * shells) stay unresolved: nothing renders their rows, and the collection block\n * is the dynamic listing D001 asked for.\n */\n\nimport { collectLinkValues, type LinkValue, linkValueKey } from '@murumets-ee/blocks/links'\nimport {\n type CollectionCardImage,\n type CollectionFieldLookup,\n type CollectionResult,\n type CollectionSourceSpec,\n canvasSiteFormat,\n collectBoundColumnsInTree,\n createCollectionReader,\n type DocumentDetail,\n type DocumentDetailPart,\n detailNeedsBody,\n detailPartsInTree,\n effectiveCollectionSources,\n effectiveDetailSpecs,\n type FindManyClientLike,\n hasConfiguredLiveSources,\n type LiveValues,\n loadCanvasLiveValues,\n materializeServerTemplate,\n readSpotCrop,\n resolveDocumentDetail,\n resolveLinks,\n resolveTreeCollections,\n type SpotCrop,\n sameSpotCrop,\n serverBlockStructureLookup,\n serverBoundColumnsInTree,\n serverCollectionFieldLookup,\n serverTemplateMaterializer,\n type TemplateNamespace,\n templateNamespace,\n} from '@murumets-ee/blocks/server'\nimport type { ToolkitApp } from '@murumets-ee/core'\nimport type { Entity } from '@murumets-ee/entity'\n\nimport { getCanonicalPaths } from './paths/enumerate.js'\nimport { buildEnumerateDeps } from './paths/resolve-context.js'\n\n// ---------------------------------------------------------------------------\n// Structural mirrors of `content-api`'s wire types\n// ---------------------------------------------------------------------------\n//\n// Mirrored rather than imported, for the reason every other provider in this\n// package mirrors its counterpart: `content-api` is the leaf, and the app that\n// wires the two is where TypeScript checks they agree.\n\n/** Structural mirror of `content-api`'s `MediaProjection`. */\nexport interface BlockMediaProjection {\n id: string\n url: string\n mimeType: string | null\n width: number | null\n height: number | null\n alt: string | null\n title: string | null\n variants: Record<string, string>\n sources: Record<string, Array<{ url: string; width: number; height: number }>>\n avifSources?: Record<string, Array<{ url: string; width: number; height: number }>>\n focalPoint: { x: number; y: number }\n blurDataUrl: string | null\n /**\n * The files cut for each spot crop the photograph has files for (images\n * PR16) — present only when the resolver was ASKED (`{ spots: true }`) and\n * the photograph records some. Never embedded in a tree: {@link writeSlot}\n * picks the one list a placement's own `crop` names and drops the rest.\n */\n spotSources?: BlockSpotSources[]\n}\n\n/** One spot crop's files, as the media resolver publishes them. */\nexport interface BlockSpotSources {\n crop: { x: number; y: number; w: number; h: number }\n sources: Array<{ url: string; width: number; height: number }>\n avifSources?: Array<{ url: string; width: number; height: number }>\n}\n\n/**\n * Structural mirror of `content-api`'s `ResolveMedia`.\n *\n * The SAME function an app passes as `ContentApiConfig.resolveMedia` — pass one\n * value to both, so a media ref inside a block cannot resolve through a\n * different path (or a different publish gate) than a top-level media field.\n */\nexport type BlockMediaResolver = (\n ids: readonly string[],\n opts?: {\n locale?: string | undefined\n /**\n * Also publish the files cut for spot crops (images PR16). Asked by the\n * BLOCK walk only — a top-level media field has no placement crop, so its\n * projection stays exactly what it was.\n */\n spots?: boolean | undefined\n },\n) => Promise<Map<string, BlockMediaProjection>>\n\n/** Structural mirror of `content-api`'s `UnresolvedBlockRef`. */\nexport interface UnresolvedBlockRef {\n readonly kind: 'media' | 'link'\n readonly ref: string\n readonly reason: 'unresolvable' | 'budget'\n}\n\n/** Structural mirror of `content-api`'s `BlockTreeRef`. */\nexport interface BlockTreeRef {\n readonly entity: string\n readonly id: string\n}\n\n/** Structural mirror of `content-api`'s `ResolvedBlockTree`. */\nexport interface ResolvedBlockTree {\n readonly resolved: 1\n readonly blocks: readonly unknown[]\n readonly links: Readonly<Record<string, string | null>>\n readonly unresolved?: readonly UnresolvedBlockRef[]\n readonly refs?: readonly BlockTreeRef[]\n readonly collections?: Readonly<Record<string, CollectionResult>>\n /** What this tree's live-bound Price fields resolved to (block-library F012). */\n readonly live?: LiveValues\n /** The site's currency (block-library F012) — present whenever the site configures one. */\n readonly currency?: string\n /**\n * The row this tree is drawn for, projected through its entity's detail\n * declaration (block-library PR16) — price, facts, a sanitised HTML body, a\n * viewer. Absent unless the app declares a detail for the entity and the row\n * has a value for at least one declared part.\n */\n readonly detail?: DocumentDetail\n}\n\n/**\n * What resolving COLLECTION blocks needs from the app — built once per app by\n * {@link createBlockResolver}, absent when the app declares no sources.\n */\nexport interface BlockCollectionDeps {\n readonly sources: readonly CollectionSourceSpec[]\n /** Name AND field restriction (kulli-editor F019) — `serverCollectionFieldLookup()`. */\n readonly sourceFieldOf: CollectionFieldLookup\n readonly getEntity: (name: string) => Entity | undefined\n /** A PUBLISHED-only reader bound to the ambient request context — the caller. */\n readonly clientFor: (entity: Entity) => FindManyClientLike\n}\n\n/**\n * The slice of `content-api`'s `ResolutionBudget` this provider uses —\n * narrowed to `claim`, for the reason `ChromeResolutionBudget` states: a\n * provider asks for work and is told how much it may do; it has no business\n * reading the remaining count or the drop summary.\n */\nexport interface BlockResolutionBudget {\n claim: (kind: 'block', n: number) => number\n}\n\nexport interface BlockResolverLogger {\n debug: (obj: object, msg: string) => void\n warn: (obj: object, msg: string) => void\n}\n\n/** Everything the pure resolver needs, injected so it is testable without an app. */\nexport interface BlockResolverDeps {\n /** Public-only media resolution. Omit and every media ref resolves to `null`. */\n resolveMedia?: BlockMediaResolver | undefined\n /**\n * PUBLISHED-only canonical paths for many ids of one entity type, in one\n * locale, as `entityId → path`.\n *\n * **The publish gate is this function's responsibility and the whole reason\n * it is batched rather than per-id.** An unpublished target must yield no\n * entry, or a block link leaks an unpublished document's URL — the obligation\n * `ResolveBlocks`' own docblock puts on the wiring.\n */\n resolvePublishedPaths: (\n entityType: string,\n ids: readonly string[],\n locale: string,\n ) => Promise<Map<string, string>>\n logger?: BlockResolverLogger | undefined\n /** Collection resolution. Omit and collection blocks resolve no rows. */\n collections?: BlockCollectionDeps | undefined\n /**\n * Rebuild a CODED TEMPLATE and name the columns its blocks bind (editor\n * D017, frontend-delivery F109). Defaults to the server block registry's\n * `materializeServerTemplate`; injectable so a test need not seed a registry.\n *\n * `namespace` is the document the ids belong to (editor F124) — see\n * `resolveBlockTree`, which derives it from the entity and the row's id.\n */\n materializeTemplate?:\n | ((\n template: string,\n namespace: TemplateNamespace,\n ) => {\n readonly blocks: unknown[]\n readonly boundColumns: readonly string[]\n })\n | undefined\n /**\n * The columns the blocks in an ORDINARY stored tree bind. Defaults to the\n * server registry's `serverBoundColumnsInTree`; injectable for the same reason\n * as {@link materializeTemplate}.\n */\n boundColumnsOf?: ((tree: readonly unknown[]) => readonly string[]) | undefined\n /**\n * Resolve this tree's live-bound Price values (block-library PR03, F012).\n * Omit and a live-bound Price draws nothing — same deny-by-default rule as\n * `collections`. Defaults to `loadCanvasLiveValues`, the SAME resolver the\n * canvas's own entry points call, so a price cannot bind through a\n * different path (or a different source set) than the one the author saw\n * while editing.\n */\n resolveLiveValues?:\n | ((roots: readonly unknown[], locale: string) => Promise<LiveValues>)\n | undefined\n /**\n * The site's currency (ISO 4217), from `blocksPlugin({ currency })`\n * (block-library F012). Independent of `resolveLiveValues`: a plain,\n * typed-in Price needs it too, to show a currency symbol rather than a bare\n * number.\n */\n currency?: string | undefined\n /**\n * Project the row a tree is drawn for through its entity's detail\n * declaration (block-library PR16). Omit and no tree carries a `detail` —\n * deny-by-default, like `collections`. Query-free: the row is the one the\n * caller is already delivering, under the caller's own access.\n *\n * `parts` are the detail parts the TREE's blocks draw; only those are\n * projected, and a tree that draws none is never asked about.\n */\n resolveDetail?:\n | ((\n entity: string,\n row: Readonly<Record<string, unknown>>,\n locale: string,\n parts: ReadonlySet<DocumentDetailPart>,\n ) => DocumentDetail | undefined)\n | undefined\n /**\n * Would projecting `parts` for `entity` sanitise an HTML body? That is the\n * one detail part with a real CPU cost, so the resolver charges the\n * response's budget for it. Omit and no projection is charged.\n */\n detailNeedsBody?:\n | ((\n entity: string,\n parts: ReadonlySet<DocumentDetailPart>,\n row: Readonly<Record<string, unknown>>,\n ) => boolean)\n | undefined\n}\n\n// ---------------------------------------------------------------------------\n// Media refs inside a tree\n// ---------------------------------------------------------------------------\n\n/**\n * How deep the media walk descends, in JSON levels.\n *\n * A stored tree is authenticated-editor JSON, not anonymous input — but it is\n * still data crossing a process boundary, and an unbounded recursion over it is\n * a stack overflow waiting for one pathological document, which under on-demand\n * rendering is a 500 on a live page for every visitor.\n *\n * 🔴 It counts JSON levels, not BLOCK levels, and the two are not the same: one\n * block of nesting costs about three (`block → children → block`, plus one for\n * `props`). This was 64, matching `collectLinkValues`, which sounded principled\n * and was wrong — `op-commit` permitted 64 BLOCK levels then, so a media ref\n * below roughly the 21st block was never visited, its stored url crossed\n * unchanged, and nothing said so. A bound that silently stops enforcing a\n * security control is worse than no bound, because it reads as coverage.\n *\n * 256 covered those 64 block levels with room to spare, and now covers\n * `MAX_BLOCK_DEPTH` (32, the one reconciled persist-and-render cap) with twice\n * that. Left at 256 rather than retuned: the headroom costs nothing, and a\n * bound that only just fits is the shape this comment exists to warn about.\n * Exceeding it is REPORTED rather than returned in silence. `collectLinkValues`\n * keeps its own 64 — that one bounds a display concern (an unresolved link\n * renders as plain text), not a value that must not reach the wire.\n */\nconst MAX_WALK_DEPTH = 256\n\n/**\n * How deep LINK collection descends — `collectLinkValues`' own default, stated\n * here so the two bounds are visibly independent rather than accidentally\n * shared. An unresolved link renders as plain text; an unvisited media ref\n * keeps a url that may be a capability. Different stakes, different bounds.\n *\n * ⚠️ Unlike the media walk, this one truncates SILENTLY: `collectLinkValues`\n * returns what it found and reports nothing, so a link below the bound is\n * absent from the sidecar, absent from `unresolved`, and renders as plain text\n * with no signal anywhere. That is tolerable only because the failure is\n * cosmetic — and it is stated rather than left to be discovered, because the\n * media walk one constant up made exactly the opposite call for exactly the\n * opposite reason.\n */\nconst LINK_WALK_DEPTH = 64\n\n/**\n * Entity ids are UUIDs (user ids are not, but no user id is ever a media ref).\n * Requiring the shape here is what makes structural detection safe: a prop\n * object carrying BOTH a uuid `id` AND a non-empty `url` string, with every\n * optional `MediaRef` field well-typed, is a media ref by any usable\n * definition.\n */\nconst UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\n/**\n * `Array.isArray(x)` on an `unknown` narrows to `any[]`, which would put an\n * `any` into every element of a walk over untyped JSON — in a package holding a\n * type-coverage floor. Narrowing through an explicit predicate keeps elements\n * `unknown`.\n */\nfunction isUnknownArray(value: unknown): value is unknown[] {\n return Array.isArray(value)\n}\n\n/**\n * Every key a `MediaRef` or a `MediaProjection` may carry — and NOTHING ELSE\n * may be present for a value to count as one.\n *\n * 🔴 This is a WHITELIST, and the difference is not stylistic. Checking only\n * that the known keys are well-typed made the detector match any object with a\n * uuid `id` and a `url` — a project's `{ id, url, provider: 'youtube' }` prop,\n * say. That object would then be REPLACED by `null` (no media row for its id),\n * the consumer-side validator would drop the whole prop, and the block would\n * render empty with an `unresolved` entry calling it media. Silent data loss,\n * misdescribed in the one channel meant to report it.\n *\n * An unrecognised key means \"this is not a media ref\", so the failure direction\n * is leaving a value alone rather than deleting it.\n */\nconst MEDIA_REF_KEY_MAP: Record<keyof BlockMediaProjection | 'crop', true> = {\n id: true,\n url: true,\n mimeType: true,\n width: true,\n height: true,\n alt: true,\n title: true,\n variants: true,\n // A PROJECTION key, like `variants` and `focalPoint` — listed so a projection\n // is still recognised on a second pass. The STORED `MediaRef` never carries it\n // (images D007), so no stored ref changes shape.\n sources: true,\n // Projection-only too (images PR12), and absent unless AVIF is on.\n avifSources: true,\n focalPoint: true,\n // Projection-only as well (images PR11) — the stored ref never carries it.\n blurDataUrl: true,\n // A STORED key (images PR16, D002): the one crop a placement carries — author\n // data about the spot, validated at every write. Listed in the same commit\n // that let the ref carry it: an unrecognised key makes the detector walk\n // past the ref, and its stored url would cross unresolved (CLAUDE.md,\n // Images fact 2). Kept on the embedded value, so a second pass still sees it.\n crop: true,\n // Projection-only, and never embedded — `writeSlot` consumes it. Listed\n // because the map is tied to the projection's keys by type.\n spotSources: true,\n}\n\n/**\n * Derived from the map above rather than written out beside it, so ADDING a\n * field to the projection is a COMPILE error until this list learns about it.\n *\n * That tie is the whole reason the map exists. A bare literal set would have\n * made the whitelist fail-OPEN and silent: give `MediaRef` a `caption` (which\n * the image block already renders, so it is a plausible next change) and every\n * captioned ref quietly stops being recognised — not collected, not\n * re-resolved, not nulled, and not reported — so its stored url crosses\n * verbatim. That is the same \"a bound that silently stops enforcing a security\n * control reads as coverage\" this file says about {@link MAX_WALK_DEPTH}, and\n * it would have been true one field later.\n */\nconst MEDIA_REF_KEYS = new Set(Object.keys(MEDIA_REF_KEY_MAP))\n\n/**\n * The media id in `value`, or `null` when it is not a media ref.\n *\n * Structural, deliberately — the same technique `asLinkValue` uses for links,\n * and for the same reason: a resolver that needed the block REGISTRY to know\n * which props are media would need every project to register its vocabulary at\n * the content-api mount, which is a second place to forget. Detection that\n * reads the value cannot go stale.\n *\n * Optional fields accept `null` as well as `undefined` so the check is\n * idempotent: a `MediaProjection` (whose optionals are `T | null`) is still\n * recognised, rather than being silently skipped on a second pass.\n */\nfunction mediaRefId(value: unknown, onNearMiss?: (keys: readonly string[]) => void): string | null {\n if (!isRecord(value)) return null\n const id = value.id\n if (typeof id !== 'string' || !UUID_RE.test(id)) return null\n if (typeof value.url !== 'string' || value.url === '') return null\n // The two cheap discriminators are checked FIRST, so the key scan runs only\n // for the handful of values that already look like a ref rather than for\n // every object in the tree.\n const unknownKeys = Object.keys(value).filter((key) => !MEDIA_REF_KEYS.has(key))\n if (unknownKeys.length > 0) {\n // 🔴 REPORTED, not silently declined. A value with a uuid `id` and a `url`\n // that this detector walks past is either a project prop we correctly left\n // alone or a media ref we failed to recognise — and the second is a stored\n // url reaching the wire with nothing anywhere saying so. The caller cannot\n // tell the two apart, and neither can this function; what it can do is\n // refuse to be quiet about the ambiguity.\n onNearMiss?.(unknownKeys)\n return null\n }\n if (!isOptional(value.alt, 'string')) return null\n if (!isOptional(value.title, 'string')) return null\n if (!isOptional(value.mimeType, 'string')) return null\n if (!isOptional(value.width, 'number')) return null\n if (!isOptional(value.height, 'number')) return null\n // Projection-only, like `focalPoint`: a string when present, so a project\n // prop holding something else there is left alone rather than claimed.\n if (!isOptional(value.blurDataUrl, 'string')) return null\n // `variants` is the one whitelisted key the type checks above do not cover,\n // and skipping it left an over-match: `{ id: <uuid>, url: '…', variants: 'x' }`\n // passed the whitelist and was replaced by a projection or by `null`. That is\n // precisely the direction the whitelist exists to prevent.\n if (value.variants !== undefined && value.variants !== null && !isRecord(value.variants)) {\n return null\n }\n // Same reasoning for `focalPoint`, which only a PROJECTION carries (a stored\n // ref never does — images D007): recognised on a second pass, never an\n // over-match on a project prop that happens to hold a non-object there.\n if (value.focalPoint !== undefined && value.focalPoint !== null && !isRecord(value.focalPoint)) {\n return null\n }\n // And `sources`, for the same reason — projection-only, and when present an\n // object of ARRAYS (shape name → files). Checked one level deep, because a\n // bare \"is an object\" let `{ id: <uuid>, url, sources: { natural: 'x' } }`,\n // a project's own prop, be claimed as media and replaced (CodeRabbit).\n if (value.sources !== undefined && value.sources !== null) {\n if (!isRecord(value.sources)) return null\n if (!Object.values(value.sources).every((list) => Array.isArray(list))) return null\n }\n // `avifSources` has `sources`' shape and the same over-match risk.\n if (value.avifSources !== undefined && value.avifSources !== null) {\n if (!isRecord(value.avifSources)) return null\n if (!Object.values(value.avifSources).every((list) => Array.isArray(list))) return null\n }\n // A spot crop is an object. A project prop holding something else under\n // `crop` is left alone rather than claimed. An object that is not a VALID\n // crop is still a media ref — the write gate refuses those, and a stored one\n // is drawn around the focal point (`readSpotCrop` is lenient) rather than\n // being left unresolved with its stored url.\n if (value.crop !== undefined && value.crop !== null && !isRecord(value.crop)) return null\n if (value.spotSources !== undefined && value.spotSources !== null) {\n if (!Array.isArray(value.spotSources)) return null\n }\n return id\n}\n\nfunction isOptional(value: unknown, type: 'string' | 'number'): boolean {\n return value === undefined || value === null || typeof value === type\n}\n\n/** One place in the tree holding a media ref, so it can be overwritten after resolution. */\ninterface MediaSlot {\n readonly container: Record<string, unknown> | unknown[]\n readonly key: string | number\n readonly id: string\n /** The placement's own crop (images PR16), read when the slot was found. */\n readonly crop?: SpotCrop | undefined\n}\n\n/**\n * Collect every media ref in the tree, recording WHERE it sits.\n *\n * Slots rather than a returned copy because the replacement is in place, which\n * is the same shape `projectMedia` uses on entity rows for the same reason: the\n * tree came from a per-request read and copying it would double the cost of the\n * largest thing on the page.\n */\n/** What the walk noticed but could not act on. Both are ambiguity, not failure. */\ninterface WalkReport {\n /** A subtree below {@link MAX_WALK_DEPTH} was never visited. */\n truncated: boolean\n /** Keys that made a ref-shaped value fall outside the whitelist, deduped. */\n nearMissKeys: Set<string>\n}\n\nfunction collectMediaSlots(\n node: unknown,\n out: MediaSlot[],\n depth: number,\n report: WalkReport,\n): void {\n // Flagged, never silent — and flagged rather than logged, so a pathological\n // tree cannot turn the operator log into a hot loop. Below the bound a media\n // ref is not visited, so its stored url would cross unchanged.\n if (depth > MAX_WALK_DEPTH) {\n report.truncated = true\n return\n }\n const noteNearMiss = (keys: readonly string[]): void => {\n for (const key of keys) report.nearMissKeys.add(key)\n }\n if (isUnknownArray(node)) {\n for (let index = 0; index < node.length; index += 1) {\n const id = mediaRefId(node[index], noteNearMiss)\n if (id !== null)\n out.push({ container: node, key: index, id, crop: readSpotCrop(node[index]) })\n else collectMediaSlots(node[index], out, depth + 1, report)\n }\n return\n }\n if (!isRecord(node)) return\n for (const [key, value] of Object.entries(node)) {\n const id = mediaRefId(value, noteNearMiss)\n if (id !== null) out.push({ container: node, key, id, crop: readSpotCrop(value) })\n else collectMediaSlots(value, out, depth + 1, report)\n }\n}\n\n/**\n * The projection as it goes INTO a block tree: null-valued optionals OMITTED.\n *\n * 🔴 Found by rendering a real page in a browser, not by a unit test — the\n * tests all used a projection whose `width`/`height`/`alt` happened to be\n * populated. `MediaProjection` declares its optionals `T | null` because a\n * top-level media field publishes a fixed key set. `MediaRef` — the type every\n * block render is typed against, and the one the consumer-side validator\n * narrows to — declares them `T | undefined`, so a JSON `null` there is not a\n * value any render can hold. The front-end validator did exactly what it should\n * with one: dropped it, and logged \"block props were dropped as unusable\" for\n * `image.width`, `image.height` and `image.alt` on EVERY render of every page\n * carrying an image. Correct behaviour, useless signal — the log that exists to\n * report real data loss would have been drowned by it from day one.\n *\n * Absent and null mean the same thing for every one of these fields, so\n * omitting is lossless. What it buys is a value that is assignable to the type\n * the renders declare, which is the whole reason `MediaRef` carries a url at\n * all.\n */\nfunction toEmbeddedProjection(projection: BlockMediaProjection): Record<string, unknown> {\n const out: Record<string, unknown> = {\n id: projection.id,\n url: projection.url,\n variants: projection.variants,\n // Always embedded, like `variants`: the render reads it to crop a shaped\n // slot, and a projection always carries one (`{0.5, 0.5}` when the row has\n // none), so there is no null to omit.\n focalPoint: projection.focalPoint,\n // Always embedded too: an empty map is \"nothing generated yet\", which a\n // render reads as \"no srcset\" — there is no null to omit.\n sources: projection.sources,\n }\n // Only when there are some (images PR12): the tree is serialised into the\n // page, so an empty key would change every page of a site with AVIF off.\n if (projection.avifSources !== undefined && Object.keys(projection.avifSources).length > 0) {\n out.avifSources = projection.avifSources\n }\n if (projection.mimeType !== null) out.mimeType = projection.mimeType\n if (projection.width !== null) out.width = projection.width\n if (projection.height !== null) out.height = projection.height\n if (projection.alt !== null) out.alt = projection.alt\n if (projection.title !== null) out.title = projection.title\n // Omitted when there is none, like the other optionals: most photographs on\n // a site that predates images PR11 have none until the catch-up reaches them.\n if (projection.blurDataUrl !== null) out.blurDataUrl = projection.blurDataUrl\n return out\n}\n\n/**\n * The files cut for THIS placement's crop, or `undefined` when the worker has\n * not cut them yet (or the placement has no crop). Matched on the four-decimal\n * grid (`sameSpotCrop`).\n */\nfunction spotSourcesFor(\n projection: BlockMediaProjection,\n crop: SpotCrop | undefined,\n): BlockSpotSources | undefined {\n if (crop === undefined || projection.spotSources === undefined) return undefined\n return projection.spotSources.find(\n (spot) => sameSpotCrop(spot.crop, crop) && spot.sources.length > 0,\n )\n}\n\n/**\n * The embedded value for a placement with a spot crop (images PR16, D002).\n *\n * - **Its files exist** — the placement's value becomes THOSE files: `sources`\n * holds the crop's list under `natural` (the crop IS this placement's\n * picture, at the crop's own ratio), `url`/`width`/`height` are the largest\n * file's, and the focal point is the centre (the file is already the crop).\n * So a render selects from the crop's files alone — the original is never\n * appended above them, because it is no longer larger than the value's own\n * width — and CSS has nothing left to crop. `avifSources` follows only when\n * the crop's AVIF files are exactly its WebP list, re-encoded.\n * - **They do not exist yet** — the projection as any placement gets it: the\n * focal-point files, never the original (D002). The crop is kept on the\n * value either way, so a second pass sees the same placement.\n *\n * A placement with NO crop never reaches this function, so its embedded value\n * — and the markup a render makes of it — is byte-identical to before.\n */\nfunction embedCropped(projection: BlockMediaProjection, crop: SpotCrop): Record<string, unknown> {\n const out = toEmbeddedProjection(projection)\n const spot = spotSourcesFor(projection, crop)\n const largest = spot?.sources[spot.sources.length - 1]\n if (spot !== undefined && largest !== undefined) {\n out.url = largest.url\n out.width = largest.width\n out.height = largest.height\n out.focalPoint = { x: 0.5, y: 0.5 }\n out.sources = { natural: spot.sources }\n const avif = spot.avifSources\n const avifMatches =\n avif !== undefined &&\n avif.length === spot.sources.length &&\n avif.every(\n (file, i) =>\n file.width === spot.sources[i]?.width && file.height === spot.sources[i]?.height,\n )\n if (avifMatches) out.avifSources = { natural: avif }\n else delete out.avifSources\n }\n out.crop = { x: crop.x, y: crop.y, w: crop.w, h: crop.h }\n return out\n}\n\nfunction writeSlot(slot: MediaSlot, value: BlockMediaProjection | null): void {\n const written =\n value === null\n ? null\n : slot.crop === undefined\n ? toEmbeddedProjection(value)\n : embedCropped(value, slot.crop)\n if (Array.isArray(slot.container)) {\n if (typeof slot.key === 'number') slot.container[slot.key] = written\n return\n }\n if (typeof slot.key === 'string') slot.container[slot.key] = written\n}\n\n// ---------------------------------------------------------------------------\n// The resolver\n// ---------------------------------------------------------------------------\n\n/**\n * Resolve one stored block tree into a {@link ResolvedBlockTree}.\n *\n * **Throws when `blocks` is not an array**, which is how a corrupt stored value\n * is handled rather than papered over — a resolver that returned `{ resolved: 1,\n * blocks: [] }` for it would report success while deleting a page's content,\n * and the caller could not tell the two apart.\n *\n * ⚠️ **What the caller does with that throw CHANGED** (editor D010, closing\n * F056). It used to serve the stored tree unchanged, so a malformed row cost\n * its own hrefs and nothing else. It no longer does: it re-calls this function\n * with an exhausted budget and, if that fails too, serves an empty envelope. A\n * non-array `blocks` fails this guard DETERMINISTICALLY, so a corrupt column is\n * now precisely the case that reaches that third state and is blanked. That is\n * the intended outcome rather than a regression — a non-array stored value\n * cannot be rendered by any consumer, and it may still be an object carrying\n * media refs whose urls were signed at author time.\n */\nexport async function resolveBlockTree(\n blocks: unknown,\n opts: {\n locale: string\n budget: BlockResolutionBudget\n entity: string\n field: string\n /** content-api's whitelist ∧ `view` gate for THIS caller. Absent → no collection rows. */\n canReadEntity?: ((entityName: string) => boolean) | undefined\n /** One slot of the response's collection allowance. Absent → no collection rows. */\n claimCollection?: (() => boolean) | undefined\n /** A CODED TEMPLATE's root block name — `blocks` is then ignored and the template rebuilt. */\n template?: string | undefined\n /** The row a templated document's bound blocks read. Read, never written. */\n boundValues?: Readonly<Record<string, unknown>> | undefined\n /**\n * `blocks` is a stored detail template drawn for `boundValues` (block-library\n * D009). The row's detail is projected; its columns are NOT walked for links.\n */\n rowIsForeign?: boolean | undefined\n },\n deps: BlockResolverDeps,\n): Promise<ResolvedBlockTree> {\n // A CODED TEMPLATE has no stored tree (editor D017): rebuild it from the\n // registry, and remember which COLUMNS its blocks bind — a bound rich-text\n // column's link marks live in the row, and they are resolved below in the\n // same batch as the tree's own (F109). Only the columns THIS template binds\n // are read, so a same-named column bound by some other block is never walked.\n //\n // An ORDINARY tree can carry bound blocks too — `documentBody` dropped into a\n // composable page renders that page's column — so when the caller hands over\n // the row, its bound columns are walked the same way. Without that the canvas\n // (which composes bound values into any tree) resolved links the site then\n // rendered as plain text.\n let tree: unknown = blocks\n let boundColumns: readonly string[] = []\n if (opts.template !== undefined) {\n // 🔴 The ids belong to THIS document (editor F124): namespaced by the entity\n // and the row's own id, exactly as the editor and the bound-field route\n // namespace them, so the delivered tree carries the ids the canvas edits.\n //\n // The id is read off the row because the row is what the caller hands over\n // — content-api always projects `id` (`query-params.ts`, \"Always include\n // id\"), so a templated field never arrives without one. If it somehow did,\n // the template is served EMPTY rather than with ids that name no document:\n // an empty body is a visible fault, a shared id space is the defect F124\n // closed.\n const documentId = opts.boundValues?.id\n if (typeof documentId === 'string' && documentId !== '') {\n const built = (deps.materializeTemplate ?? materializeServerTemplate)(\n opts.template,\n templateNamespace(opts.entity, documentId),\n )\n tree = built.blocks\n boundColumns = built.boundColumns\n } else {\n tree = []\n deps.logger?.warn(\n { entity: opts.entity, field: opts.field },\n 'content: a templated document arrived without its row id, so its template is served ' +\n 'EMPTY — its block ids could not be namespaced to it (editor F124)',\n )\n }\n } else if (opts.rowIsForeign === true) {\n // A stored detail template (block-library D009). Its tree is authored by an\n // editor and drawn for a row the editor does not own, so a bound block in\n // it does not get to name a column of that row to read: `boundColumns`\n // stays empty and only the code-declared detail below is taken from it.\n } else if (opts.boundValues !== undefined && isUnknownArray(blocks)) {\n // Degrades to \"no bound columns\" rather than failing the tree: bound-column\n // links are an enhancement, and a throw here would otherwise blank the whole\n // document (the zero-budget retry would throw identically).\n try {\n boundColumns = (deps.boundColumnsOf ?? serverBoundColumnsInTree)(blocks)\n } catch (err) {\n deps.logger?.warn(\n { err, entity: opts.entity, field: opts.field },\n 'content: could not enumerate the bound columns of a block tree, so its bound-field links render plain',\n )\n }\n }\n const row = opts.boundValues ?? {}\n const boundSources = boundColumns\n .filter((column) => Object.hasOwn(row, column))\n .map((column) => row[column])\n\n if (!isUnknownArray(tree)) {\n deps.logger?.warn(\n { entity: opts.entity, field: opts.field },\n 'content: stored block tree is not an array — nothing to resolve, so this field is ' +\n 'served EMPTY rather than as stored (the caller retries this throw with an exhausted ' +\n 'budget, and a non-array fails that identically)',\n )\n throw new TypeError(\n `content: block tree \\`${opts.entity}.${opts.field}\\` is not an array; nothing to resolve.`,\n )\n }\n\n const slots: MediaSlot[] = []\n const report: WalkReport = { truncated: false, nearMissKeys: new Set() }\n collectMediaSlots(tree, slots, 0, report)\n // ONE line per call at most, whatever the tree looks like — a per-node log on\n // an anonymous request path is the amplification this file bounds everywhere\n // else. Both are hedged (\"any\", \"may\") because the walk cannot know whether\n // the thing it declined to visit was a media ref at all; what it knows is\n // that it did not look.\n if (report.truncated) {\n deps.logger?.warn(\n { entity: opts.entity, field: opts.field, cap: MAX_WALK_DEPTH },\n 'content: block tree is nested deeper than the media walk descends — any media ref below ' +\n 'the bound keeps the url recorded when an editor picked it, which may be signed',\n )\n }\n if (report.nearMissKeys.size > 0) {\n deps.logger?.debug(\n { entity: opts.entity, field: opts.field, keys: [...report.nearMissKeys].sort() },\n 'content: a value looked like a media ref but carried keys outside the projection, so it ' +\n 'was left alone. If these are media refs, they keep their stored urls',\n )\n }\n // Its OWN bound, not this module's. Link resolution is a display concern — an\n // unresolved link renders as plain text — while the media walk is a security\n // control, which is why only one of the two was raised. Passing\n // `MAX_WALK_DEPTH` here would have silently quadrupled link collection depth\n // as a side effect of a change that had nothing to do with links.\n //\n // The bound columns' links join the SAME list, so they share every step below\n // — one media batch, one published-path lookup per linked entity type, one\n // pair of claims against the response's budget. That is F109's fan-out bound:\n // links are resolved per DOCUMENT, never one request per link and never one\n // batch per column.\n const links = [\n ...collectLinkValues(tree, LINK_WALK_DEPTH),\n ...collectLinkValues(boundSources, LINK_WALK_DEPTH),\n ]\n\n // Media LINKS resolve to a media url, so they join the same batch — one\n // lookup covers a `{kind:'media'}` link and an image of the same asset.\n //\n // 🔴 FILTERED to the uuid shape, and the filter is load-bearing rather than\n // defensive. `mediaRefId` already requires a uuid, but `asLinkValue` accepts\n // ANY non-empty string as a `{kind:'media'}` id — so one legacy or\n // hand-authored link with `id: 'x'` reached `inArray(idColumn, ids)` and\n // Postgres raised `22P02 invalid input syntax for type uuid`. The batch's\n // catch then returned an EMPTY map, and every slot on the page was written\n // `null`: one malformed link deleted every image in the tree. Rejects are\n // reported as `unresolvable` rather than dropped, because a link that can\n // never resolve is exactly what that channel is for.\n const wellFormedMediaLinkIds: string[] = []\n const malformedRefs: UnresolvedBlockRef[] = []\n for (const link of links) {\n if (link.kind !== 'media') continue\n if (UUID_RE.test(link.id)) wellFormedMediaLinkIds.push(link.id)\n else malformedRefs.push({ kind: 'link', ref: linkValueKey(link), reason: 'unresolvable' })\n }\n const mediaIds = distinct([...slots.map((slot) => slot.id), ...wellFormedMediaLinkIds])\n\n const entityTargets: string[] = []\n for (const link of links) {\n if (link.kind !== 'entity') continue\n // Same reason: `filterPublished` issues one `inArray` per entity type, so a\n // single non-uuid id throws and takes every link of that type with it.\n if (UUID_RE.test(link.id)) entityTargets.push(targetKey(link.entity, link.id))\n else malformedRefs.push({ kind: 'link', ref: linkValueKey(link), reason: 'unresolvable' })\n }\n\n const unresolved: UnresolvedBlockRef[] = [...malformedRefs]\n\n // TWO claims against the ONE shared counter, media first.\n //\n // Two rather than one so each pass learns its own grant without splitting a\n // combined number, and media first because a vanished image is the more\n // visible failure — when a page is over budget, the deterministic outcome is\n // \"images render, some links go plain\" rather than whichever the scheduler\n // reached first. Both record their shortfall under the same `block` kind, so\n // the operator log is unchanged.\n //\n // Nothing is claimed for media when no resolver is wired: the budget reports\n // work the server DECLINED, and there is no work to decline. Claiming anyway\n // burned the whole ceiling on a pass that did no I/O and then reported every\n // LINK as `budget` — a drop log naming the wrong cause.\n if (!deps.resolveMedia && mediaIds.length > 0) {\n // The signal for \"this deployment cannot resolve media at all\", emitted\n // HERE because this is now the only place that can tell. `resolveMediaBatch`\n // used to carry it, and the no-claim rule above made its branch\n // unreachable — the ids never reach it. It is the likeliest cause of \"every\n // image on the site is gone\", and the `unresolvable` the envelope reports\n // deliberately does not distinguish causes, so the operator log is the only\n // place it can be said.\n deps.logger?.debug(\n { entity: opts.entity, field: opts.field, count: mediaIds.length },\n 'content: no media resolver wired for block trees — every media ref resolves to null',\n )\n }\n\n const mediaGranted = deps.resolveMedia ? opts.budget.claim('block', mediaIds.length) : 0\n const coveredMedia = mediaIds.slice(0, mediaGranted)\n // 🔴 EMPTY when no resolver is wired, and that is not the same set as\n // \"everything\". `budgetDroppedMedia` means \"a ceiling refused this\", and it\n // is read again below by `unresolvedLinkReason` — so filling it on the\n // no-resolver path reported every media LINK as `reason: 'budget'` and sent\n // an operator to raise a limit that was never involved. The reason was fixed\n // for the media entries and missed for the links; one set, read twice, is\n // what keeps them consistent.\n const budgetDroppedMedia = deps.resolveMedia\n ? new Set(mediaIds.slice(mediaGranted))\n : new Set<string>()\n for (const id of mediaIds.slice(mediaGranted)) {\n unresolved.push({\n kind: 'media',\n ref: id,\n reason: budgetDroppedMedia.has(id) ? 'budget' : 'unresolvable',\n })\n }\n\n const distinctTargets = distinct(entityTargets)\n const linkGranted = opts.budget.claim('block', distinctTargets.length)\n const coveredTargets = distinctTargets.slice(0, linkGranted)\n const budgetDroppedTargets = new Set(distinctTargets.slice(linkGranted))\n\n const media = await resolveMediaBatch(coveredMedia, opts.locale, deps, opts.entity)\n\n // Every slot is written, including the ones that did not resolve. Leaving a\n // stored ref in place for an id the resolver refused is the leak this module\n // exists to close (see the file header) — \"not public\" and \"not found\" are\n // the same branch downstream, so the ONE case where a fallback would matter\n // is exactly the case where it must not happen.\n for (const slot of slots) {\n const projection = media.get(slot.id) ?? null\n writeSlot(slot, projection)\n }\n for (const id of coveredMedia) {\n if (!media.has(id)) unresolved.push({ kind: 'media', ref: id, reason: 'unresolvable' })\n }\n\n const paths = await resolveEntityPaths(coveredTargets, opts.locale, deps, opts.entity)\n\n // `resolveLinks` still owns dedup, the `sanitizeHref` protocol gate on\n // resolved hrefs, anchor composition and the inline url/email cases. What it\n // is handed here are PURE MAP LOOKUPS, so its `runBounded` fan-out does no\n // I/O at all — the batching above already happened.\n const resolvedLinks = await resolveLinks(links, {\n renderContext: 'request',\n locale: opts.locale,\n resolveEntityHref: ({ entity, id }) => paths.get(targetKey(entity, id)) ?? null,\n resolveMediaHref: (id) => media.get(id)?.url ?? null,\n })\n\n const linkRecord: Record<string, string | null> = {}\n for (const [key, resolved] of resolvedLinks) linkRecord[key] = resolved.href\n // Only `entity`/`media` links are REPORTED as unresolved. A `url` or `email`\n // link with a null href was refused by `sanitizeHref` — an editor typed\n // `javascript:` — which is a content problem the security gate handled, not a\n // resolution this response failed to perform, and listing it would invite a\n // consumer to retry it.\n for (const link of links) {\n if (link.kind !== 'entity' && link.kind !== 'media') continue\n const key = linkValueKey(link)\n if (linkRecord[key] !== null && linkRecord[key] !== undefined) continue\n unresolved.push({\n kind: 'link',\n ref: key,\n reason: unresolvedLinkReason(link, budgetDroppedTargets, budgetDroppedMedia),\n })\n }\n\n // What this tree DEPENDS ON, for whatever cache is holding the response.\n //\n // 🔒 Built from what actually RESOLVED, not from what was asked for. `media`\n // holds only ids the public resolver returned, and `paths` only targets the\n // published-path lookup answered — so an unpublished link target contributes\n // no ref, and its id is not disclosed. That is the same gate the hrefs above\n // pass through, applied to identity; the refusal is already reported through\n // `unresolved`, which carries a `linkValueKey` the consumer already holds.\n //\n // Media BEFORE links, and both deduped by the Map: an image embedded as a\n // `field.media()` and linked as `{kind:'media'}` is one row and one tag.\n // block-library F012 — the delivered-site half of PR03's live-source\n // contract. Deny-by-default like collections: no `resolveLiveValues`\n // wired, no `live` sidecar, and a bound Price draws nothing (the same\n // thing the canvas draws for a site with no sources at all).\n //\n // 🔴 CLAIMED against the SAME shared budget every other resolution kind in\n // this function draws from, same as `collections`' own `claimCollection`.\n // Without it, a list response resolving N rows each binding a live Price\n // would call the site's own `LiveSourceSpec.resolve` — an ARBITRARY,\n // site-registered callback, explicitly documented as reaching outside this\n // process (`core/live-sources.ts`: \"never elevate… it runs inside the\n // request's own context\") — once per row with no ceiling at all: exactly\n // the amplification CLAUDE.md's no-unbounded-fan-out section forbids,\n // reachable the moment any site registers a live source. One unit, because\n // a tree either asks its source(s) once or not at all — unlike media/links,\n // there is no per-item count to charge, and a 1-unit claim is what the\n // collection pass already charges for the same \"one call, unknown size\"\n // shape.\n const resolveLive = deps.resolveLiveValues\n const liveGranted = resolveLive !== undefined ? opts.budget.claim('block', 1) : 0\n\n // Run alongside collections rather than after: neither's result feeds the\n // other, and `resolveLiveValues` can take up to several seconds against a\n // slow or down upstream (`LIVE_DOCUMENT_TIMEOUT_MS`), which otherwise\n // stacked on top of collection resolution's own latency for no reason.\n const [{ results: collections, cardMedia }, live] = await Promise.all([\n resolveCollectionsIn(tree, opts, deps),\n liveGranted > 0 && resolveLive !== undefined\n ? // `(async () => …)()` rather than calling `resolveLive` directly: its\n // type (`BlockResolverDeps['resolveLiveValues']`) only requires a\n // `Promise<LiveValues>` RETURN, not an `async` implementation, so a\n // misbehaving caller-supplied resolver could throw SYNCHRONOUSLY\n // before ever producing a promise for `.catch()` to attach to — which\n // would reject `Promise.all` (and this whole function) before the\n // `.catch()` below is ever reached. Wrapping in an async IIFE turns\n // that synchronous throw into a rejection of the IIFE's own promise,\n // so `.catch()` catches it too.\n (async () => resolveLive(tree, opts.locale))().catch((err: unknown) => {\n // The production wiring (`loadCanvasLiveValues`) never throws by its\n // own contract (\"Contained\", server/live-values.ts), but `deps` is\n // an injection seam — nothing at the TYPE level stops some other\n // caller wiring a `resolveLiveValues` that rejects or throws. Either\n // one here must not reject the whole `Promise.all`: that would\n // discard the collection pass's already-settled results and fail\n // this tree's ENTIRE resolution for a failure that was supposed to\n // cost it only the live sidecar — the same isolation\n // `resolveLiveValues` itself already gives one bad SOURCE among\n // several.\n deps.logger?.warn(\n { err },\n 'blocks: resolveLiveValues rejected; this tree draws no live values',\n )\n return undefined\n })\n : Promise.resolve(undefined),\n ])\n\n // block-library PR16 — the row's detail, for the parts THIS tree's blocks\n // draw and no others. A tree with no detail block (every row of an ordinary\n // list response) is not projected at all.\n //\n // 🔴 One part costs real CPU: an HTML body is sanitised here, up to half a\n // megabyte of it, on a public request. So a projection that needs the body\n // claims one unit of the response's budget, exactly as the live pass does —\n // a list of a hundred legal documents sanitises as many bodies as the budget\n // has units, and the rest carry their other parts without one. It reads the\n // row already in hand and runs no query.\n //\n // Contained, because `deps` is an injection seam: a throwing projection costs\n // this tree its detail, not its blocks.\n let detail: DocumentDetail | undefined\n if (deps.resolveDetail !== undefined && opts.boundValues !== undefined) {\n try {\n let parts = detailPartsInTree(isUnknownArray(tree) ? tree : [])\n if (\n parts.has('body') &&\n deps.detailNeedsBody?.(opts.entity, parts, opts.boundValues) === true &&\n opts.budget.claim('block', 1) < 1\n ) {\n const withoutBody = new Set(parts)\n withoutBody.delete('body')\n parts = withoutBody\n deps.logger?.warn(\n { entity: opts.entity, field: opts.field },\n 'blocks: the response budget is spent; this tree is delivered without its HTML body',\n )\n }\n if (parts.size > 0) {\n detail = deps.resolveDetail(opts.entity, opts.boundValues, opts.locale, parts)\n }\n } catch (err) {\n deps.logger?.warn(\n { err, entity: opts.entity },\n 'blocks: resolveDetail threw; this tree carries no detail',\n )\n }\n }\n\n const refByKey = new Map<string, BlockTreeRef>()\n for (const id of media.keys()) refByKey.set(targetKey('media', id), { entity: 'media', id })\n for (const id of cardMedia) refByKey.set(targetKey('media', id), { entity: 'media', id })\n // A listed row is a row this tree depends on, and it passed the caller's gate\n // to be listed — the same rule the links below follow.\n for (const result of collections.values()) {\n if (result.entity === null) continue\n for (const row of result.rows) {\n refByKey.set(targetKey(result.entity, row.id), { entity: result.entity, id: row.id })\n }\n }\n for (const key of paths.keys()) {\n if (refByKey.has(key)) continue\n const split = key.indexOf(' ')\n if (split <= 0) continue\n const entity = key.slice(0, split)\n const id = key.slice(split + 1)\n if (id === '') continue\n refByKey.set(key, { entity, id })\n }\n\n return {\n resolved: 1,\n blocks: tree,\n links: linkRecord,\n // OMITTED when empty, never `[]`. Its presence is itself information —\n // Contentful's rule for `errors[]`, and the property that stops \"nothing\n // failed\" from looking like \"nothing was attempted\".\n ...(unresolved.length > 0 && { unresolved: dedupeRefs(unresolved) }),\n // Same rule, same reason: a tree that references nothing says so by\n // omitting the field rather than by shipping `[]`.\n ...(refByKey.size > 0 && { refs: [...refByKey.values()] }),\n // And again: a tree with no collection block carries no sidecar.\n ...(collections.size > 0 && { collections: Object.fromEntries(collections) }),\n // Omitted when the site registers no live sources at all — see\n // `ResolvedBlockTree.live`'s own docblock for why `sources` (not just\n // `values`) is what makes this non-empty. Projected to `{sources, values}`\n // rather than spread whole: `deps.resolveLiveValues` returns the blocks\n // package's own `LiveValues`, which also carries `asked` (canvas-merge\n // bookkeeping — which keys were requested, for a reader this one-shot\n // render has none of). The wire type `ResolvedLiveValues` deliberately\n // excludes it; spreading the raw object would put it on the wire anyway,\n // since nothing else here strips it.\n ...(live !== undefined &&\n Object.keys(live.sources).length > 0 && {\n live: { sources: live.sources, values: live.values },\n }),\n // NOT gated on `live` or on this tree binding anything: a plain, typed-in\n // Price needs the currency too.\n ...(deps.currency !== undefined && { currency: deps.currency }),\n // Omitted unless the row has a value for a declared part.\n ...(detail !== undefined && { detail }),\n }\n}\n\n/**\n * Resolve every collection block in `blocks` through the shared resolver.\n *\n * DENY-BY-DEFAULT at three levels: an app with no declared sources has no\n * `deps.collections`, and a call without `canReadEntity` — a host that did not\n * say who may read what — or without `claimCollection` — a host that did not\n * say how many — resolves nothing. Either way the blocks render empty on the\n * site, which is the safe direction.\n *\n * Images go through the SAME public media resolver the tree's own media refs\n * do, and hrefs through the same published-path lookup its links do, so a card\n * cannot show an asset or a URL the rest of the page could not.\n */\nasync function resolveCollectionsIn(\n blocks: readonly unknown[],\n opts: {\n locale: string\n budget: BlockResolutionBudget\n canReadEntity?: ((entityName: string) => boolean) | undefined\n claimCollection?: (() => boolean) | undefined\n },\n deps: BlockResolverDeps,\n): Promise<{\n readonly results: ReadonlyMap<string, CollectionResult>\n readonly cardMedia: ReadonlySet<string>\n}> {\n const collections = deps.collections\n const canRead = opts.canReadEntity\n const claimCollection = opts.claimCollection\n if (collections === undefined || canRead === undefined || claimCollection === undefined) {\n return { results: new Map(), cardMedia: new Set() }\n }\n const resolveMedia = deps.resolveMedia\n // Every media id a card will draw, so the tree names it in `refs` like its\n // own media: a replaced image or an edited alt must evict the cached page\n // (CodeRabbit review, PR53).\n const cardMedia = new Set<string>()\n const results = await resolveTreeCollections(blocks, collections.sourceFieldOf, {\n sources: collections.sources,\n getEntity: collections.getEntity,\n clientFor: collections.clientFor,\n canRead,\n resolveImages:\n resolveMedia === undefined\n ? undefined\n : async (ids): Promise<ReadonlyMap<string, CollectionCardImage>> => {\n const found = await resolveMedia(ids, { locale: opts.locale })\n // A card image must BE an image. A media field's `kind: 'image'`\n // is only a picker filter (editor F116), so a video can sit in\n // one — and the card would draw it as a broken `<img>`.\n const shown = [...found].filter(\n ([, projection]) => projection.mimeType?.startsWith('image/') === true,\n )\n for (const [id] of shown) cardMedia.add(id)\n // All seven keys a card needs — beyond the four: where to crop\n // around and which generated files to offer (images PR07, F021),\n // and the preview it shows while it loads (PR11).\n // `resolveCollectionSource` normalises what this returns\n // (`cardImageOf`: a centre point, an empty `sources` and a missing\n // preview are left out), the SAME step the canvas's injected resolver goes through,\n // so a card carries the same keys on both surfaces.\n return new Map(\n shown.map(([id, projection]) => [\n id,\n {\n url: projection.url,\n alt: projection.alt,\n width: projection.width,\n height: projection.height,\n focalPoint: projection.focalPoint,\n sources: projection.sources,\n // Non-empty only, the rule the embedded projection follows\n // (`cardImageOf` would drop `{}` too; one rule, both paths).\n ...(projection.avifSources !== undefined &&\n Object.keys(projection.avifSources).length > 0 && {\n avifSources: projection.avifSources,\n }),\n blurDataUrl: projection.blurDataUrl,\n },\n ]),\n )\n },\n resolveHrefs: (entityType, ids) => deps.resolvePublishedPaths(entityType, ids, opts.locale),\n locale: opts.locale,\n renderContext: 'request',\n // A slot of the response's collection allowance, THEN one unit of its ONE\n // budget — so a page whose references and media spent the ceiling lists\n // nothing rather than exceeding it, and a list response stops at the\n // allowance however many rows it carries. Allowance first: once it is\n // spent, a refused collection must not also burn budget the rest of the\n // response's media and links need.\n claim: () => claimCollection() && opts.budget.claim('block', 1) >= 1,\n logger: deps.logger,\n })\n return { results, cardMedia }\n}\n\nfunction unresolvedLinkReason(\n link: LinkValue,\n budgetDroppedTargets: ReadonlySet<string>,\n budgetDroppedMedia: ReadonlySet<string>,\n): 'unresolvable' | 'budget' {\n if (link.kind === 'entity' && budgetDroppedTargets.has(targetKey(link.entity, link.id))) {\n return 'budget'\n }\n if (link.kind === 'media' && budgetDroppedMedia.has(link.id)) return 'budget'\n return 'unresolvable'\n}\n\nfunction distinct(values: readonly string[]): string[] {\n return [...new Set(values)]\n}\n\n/**\n * `(entityType, id)` as one map key. A space is unambiguous as a separator\n * because entity names are identifiers — `defineEntity` rejects anything else —\n * so no name can contain one.\n */\nfunction targetKey(entityType: string, id: string): string {\n return [entityType, id].join(' ')\n}\n\n/**\n * One entry per `(kind, ref)`.\n *\n * What this actually collapses is the SAME LinkValue appearing several times —\n * `collectLinkValues` does not dedupe, so a page linking twice to one target\n * would otherwise report it twice. It deliberately does NOT collapse an image\n * and a media LINK to the same asset: those are `media <id>` and\n * `link media:<id>`, two different failures a consumer may want to act on\n * differently, and the suite asserts exactly that.\n */\nfunction dedupeRefs(refs: readonly UnresolvedBlockRef[]): UnresolvedBlockRef[] {\n const seen = new Set<string>()\n const out: UnresolvedBlockRef[] = []\n for (const ref of refs) {\n const key = `${ref.kind} ${ref.ref}`\n if (seen.has(key)) continue\n seen.add(key)\n out.push(ref)\n }\n return out\n}\n\nasync function resolveMediaBatch(\n ids: readonly string[],\n locale: string,\n deps: BlockResolverDeps,\n entity: string,\n): Promise<Map<string, BlockMediaProjection>> {\n // No log for the unwired case HERE: the caller does not claim, so `ids` is\n // always empty on that path and a branch guarding on it could never fire.\n // The signal lives at the call site, which is the only place that still knows.\n if (ids.length === 0 || !deps.resolveMedia) return new Map()\n try {\n return await deps.resolveMedia(ids, { locale, spots: true })\n } catch (err) {\n deps.logger?.warn(\n { entity, err },\n 'content: media resolver threw while resolving a block tree — its media refs resolve to ' +\n 'null (if this is a ForbiddenError, the requesting role is missing `media:view`)',\n )\n return new Map()\n }\n}\n\n/**\n * {@link targetKey} → path, for the covered targets only.\n *\n * ONE call to {@link BlockResolverDeps.resolvePublishedPaths} per entity TYPE,\n * never per link. A document linking 200 times to 3 entity types costs 3 calls.\n */\nasync function resolveEntityPaths(\n targets: readonly string[],\n locale: string,\n deps: BlockResolverDeps,\n entity: string,\n): Promise<Map<string, string>> {\n const out = new Map<string, string>()\n if (targets.length === 0) return out\n\n const byType = new Map<string, string[]>()\n for (const target of targets) {\n const separator = target.indexOf(' ')\n const entityType = target.slice(0, separator)\n const id = target.slice(separator + 1)\n const bucket = byType.get(entityType)\n if (bucket) bucket.push(id)\n else byType.set(entityType, [id])\n }\n\n // SERIAL over types, matching `resolveBlockTrees`' serial-over-rows rule: the\n // set is tiny (a document links to a handful of types at most) and serial\n // keeps the read pattern predictable under load rather than bursting one\n // query per type at once.\n for (const [entityType, ids] of byType) {\n try {\n const paths = await deps.resolvePublishedPaths(entityType, ids, locale)\n for (const [id, path] of paths) out.set(targetKey(entityType, id), path)\n } catch (err) {\n // One broken entity type costs its own links, not the page's whole tree.\n deps.logger?.warn(\n { entity, entityType, err },\n 'content: path resolution threw for a block-tree link target — its links render as ' +\n 'plain text',\n )\n }\n }\n return out\n}\n\n// ---------------------------------------------------------------------------\n// App wiring\n// ---------------------------------------------------------------------------\n\n/**\n * Wire {@link resolveBlockTree} to the running app — the one line an app puts\n * into `createContentApiHandler({ resolveBlocks })`.\n *\n * Pass the SAME `resolveMedia` value the handler gets, so a media ref inside a\n * block and a top-level `media()` field resolve through one path and one gate.\n *\n * **The publish gate comes from `buildEnumerateDeps`' batched `filterPublished`,\n * which reads through an elevated REACH context** (`createSystemReachResolver`,\n * which grants `view` and withholds `view_unpublished`), so unpublished and\n * trashed targets are dropped. That is deliberately the SAME gate `GET /_resolve`\n * applies and no stricter one: the alternative — additionally gating on the\n * caller's per-entity `view` — would make the same URL a working link on one\n * endpoint and plain text on another, while the document behind the href stays\n * whitelist- and permission-gated where it is actually fetched.\n */\nexport function createBlockResolver(\n getApp: () => Promise<ToolkitApp> | ToolkitApp,\n options: {\n /**\n * The locale to resolve in when a request carried none. Pass the SAME value\n * given to `createContentApiHandler({ defaultLocale })` — the handler\n * already defaults every entity read's locale before it reaches a resolver,\n * so this only binds on a path that somehow did not, and disagreeing with\n * the handler would resolve a link in a locale the page is not rendered in.\n */\n defaultLocale: string\n resolveMedia?: BlockMediaResolver | undefined\n },\n): (\n blocks: unknown,\n opts: {\n locale?: string | undefined\n budget: BlockResolutionBudget\n entity: string\n field: string\n canReadEntity?: ((entityName: string) => boolean) | undefined\n claimCollection?: (() => boolean) | undefined\n template?: string | undefined\n boundValues?: Readonly<Record<string, unknown>> | undefined\n /** See `resolveBlockTree`: a stored detail template drawn for a row it does not own. */\n rowIsForeign?: boolean | undefined\n },\n) => Promise<ResolvedBlockTree> {\n // Built ONCE per app, not once per field per row. `buildEnumerateDeps` makes\n // a table client, and its `filterPublished` makes a fresh `QueryClient` and a\n // child logger per call — cheap individually, and paid once per `blockTree`\n // field on every row of every list page if it lives inside the closure.\n //\n // Cached against the app IDENTITY rather than assumed singleton: `getApp` is\n // a callback precisely because a host may hand back a different app (tests\n // do), and a cache that ignored that would silently read another app's\n // database.\n let cached: { app: ToolkitApp; deps: BlockResolverDeps } | null = null\n\n const depsFor = (app: ToolkitApp): BlockResolverDeps => {\n if (cached?.app === app) return cached.deps\n // `pathsClient` here is the BATCHED (`{ in }`-capable) reader, which is what\n // `getCanonicalPaths` needs — the point-lookup `buildCanonicalPathDeps`\n // reader cannot express an id batch, and reaching for it is how this ends\n // up one query per link.\n const enumerateDeps = buildEnumerateDeps(app)\n // editor PR53 — only when the app declares collection sources. The reader\n // is `createQueryClient`, which is published-only and bound to the AMBIENT\n // request context: the same caller-scoped read content-api's own list\n // endpoint performs, so a collection sees exactly the rows that caller\n // could fetch directly — nothing wider, no elevated identity.\n const { sources } = effectiveCollectionSources(app)\n const collections: BlockCollectionDeps | undefined =\n sources.length === 0\n ? undefined\n : {\n sources,\n sourceFieldOf: serverCollectionFieldLookup(),\n getEntity: (name) => app.entities.get(name),\n clientFor: (entity) => createCollectionReader(entity, app),\n }\n // A template is REGISTRY code, fixed after boot, so the registry lookups it\n // is rebuilt from are built ONCE per app rather than per row: a list\n // response of a hundred templated rows would otherwise rebuild the\n // block-definition map two hundred times on the anonymous path. The TREE is\n // still rebuilt per row, because its ids belong to that row's document\n // (editor F124) — the part that was cheap. Each call answers a fresh,\n // deep-cloned tree (see `serverTemplateMaterializer`), so the media slots\n // this resolver writes in place never reach another response or the\n // registry.\n const templates = serverTemplateMaterializer()\n // The structure lookup for ORDINARY trees' bound columns, built once per app\n // for the same reason: `serverBoundColumnsInTree` would otherwise rebuild the\n // block-definition map per row per field on the anonymous path.\n const structureLookup = serverBlockStructureLookup()\n // Validated once per app; a rejected declaration was logged at boot.\n const detailSpecs = effectiveDetailSpecs(app).specs\n const deps: BlockResolverDeps = {\n ...(collections !== undefined && { collections }),\n boundColumnsOf: (tree) => collectBoundColumnsInTree(tree, structureLookup),\n materializeTemplate: (template, namespace) => templates(template, namespace),\n resolveMedia: options.resolveMedia,\n resolvePublishedPaths: async (entityType, ids, locale) => {\n const published = await enumerateDeps.filterPublished(entityType, ids, locale)\n if (published.size === 0) return new Map()\n return getCanonicalPaths(entityType, [...published], locale, enumerateDeps)\n },\n logger: app.logger,\n // block-library F012 — the SAME resolver the canvas's own entry points\n // call (`loadCanvasLiveValues`), so a price cannot bind through a\n // different path than the one the author saw while editing. `currency`\n // does not depend on the locale passed in — `canvasSiteFormat` only\n // needs one to echo back as `.locale`, which this caller discards.\n //\n // Wired only when the site actually declared a source — mirroring\n // `collections` above. Unconditional wiring would claim one budget unit\n // per tree on every site regardless of whether it uses live values at\n // all, which on a list response competes with media/link claims for no\n // reason on the overwhelming majority of trees that carry no live-bound\n // field.\n ...(hasConfiguredLiveSources() && {\n resolveLiveValues: (roots: readonly unknown[], locale: string) =>\n loadCanvasLiveValues(roots, { locale, logger: app.logger }),\n }),\n currency: canvasSiteFormat(options.defaultLocale).currency,\n // block-library PR16 — wired only when the app declares a detail for at\n // least one entity, mirroring `collections` and `resolveLiveValues`.\n ...(detailSpecs.size > 0 && {\n resolveDetail: (\n entity: string,\n row: Readonly<Record<string, unknown>>,\n locale: string,\n parts: ReadonlySet<DocumentDetailPart>,\n ) => {\n const spec = detailSpecs.get(entity)\n return spec === undefined ? undefined : resolveDocumentDetail(spec, row, locale, parts)\n },\n detailNeedsBody: (\n entity: string,\n parts: ReadonlySet<DocumentDetailPart>,\n row: Readonly<Record<string, unknown>>,\n ) => {\n const spec = detailSpecs.get(entity)\n return spec !== undefined && detailNeedsBody(spec, parts, row)\n },\n }),\n }\n cached = { app, deps }\n return deps\n }\n\n return async (blocks, opts) => {\n const app = await getApp()\n return resolveBlockTree(\n blocks,\n { ...opts, locale: opts.locale ?? options.defaultLocale },\n depsFor(app),\n )\n }\n}\n"],"mappings":"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"}
package/dist/client.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import{t as e}from"./versionable-Br_G5VGR.mjs";import{n as t,r as n,t as r}from"./errors-NRNILhhm.mjs";import{t as i}from"./content-config-store-8omQF5p5.mjs";import{a,d as o,f as s,h as c,i as l,l as u,m as d,n as f,o as p,p as m,r as h,s as g,t as _,u as v}from"./versions-DLrR7hIZ.mjs";import{n as y}from"./plugin-BmxghXzV.mjs";import{t as b}from"./locale-utils-B0mFoV47.mjs";import{i as x,n as S,r as C}from"./context-D7eEItCY.mjs";import{blockTreeFieldsOf as w,buildPartitionCondition as T}from"@murumets-ee/entity";import{getApp as E,getContext as D,requireSiteFor as O}from"@murumets-ee/core";import{schemaRegistry as k}from"@murumets-ee/db";import{and as A,inArray as j}from"drizzle-orm";import"server-only";import{createAdminClient as M}from"@murumets-ee/core/clients";function N(e){return{id:e.id,entityId:e.entityId,locale:e.locale,data:e.data,createdBy:e.createdBy,createdByName:e.createdByName??null,createdAt:e.createdAt,updatedAt:e.updatedAt}}async function P(e,n,r,i=`_`,a){b(i);let s=await C(e);if(!s)throw new t(e.entity.name,`drafts`);let c=o(e.entity,r);e.admin.assertBlockTreesValid(c);let l=w(e.entity).some(({name:e})=>Object.hasOwn(c,e))?await s.findOne({entityId:n,locale:i}):null;await e.admin.assertBlockTreesStrictForCaller(c,()=>l?.data),await e.admin.assertMediaKindsValid(c,l?.data);let u=new Date,d=a?.id??``,f=a?.name??null;return N(await s.upsert({entityId:n,locale:i,data:c,createdBy:d,createdByName:f,createdAt:u,updatedAt:u},{target:[`entityId`,`locale`],set:{data:c,updatedAt:u}}))}const F=[`slug`,`slugMode`,`parent`];async function I(e,n,r,i=`_`,a){b(i);let s=await C(e);if(!s)throw new t(e.entity.name,`drafts`);let c=o(e.entity,r);if(Object.keys(c).length===0){let e=await s.findOne({entityId:n,locale:i});return e?N(e):null}e.admin.assertBlockTreesValid(c),e.admin.assertUpdateValid(c);let l=w(e.entity).some(({name:e})=>Object.hasOwn(c,e)),u=e.entity.frontend?.path!==void 0&&F.some(e=>Object.hasOwn(c,e)),d=l||u?await s.findOne({entityId:n,locale:i}):null;if(await e.admin.assertBlockTreesStrictForCaller(c,()=>d?.data),await e.admin.assertMediaKindsValid(c,d?.data),u){let t={...d?.data??{},...c};if(i===`_`)await e.admin.assertPathAvailable(n,t);else{let r=e.entity.allFields,a={},o={};for(let[e,n]of Object.entries(t))r[e]?.translatable===!0?a[e]=n:o[e]=n;let s=e=>F.some(t=>Object.hasOwn(e,t)&&Object.hasOwn(c,t));s(a)&&await e.admin.assertPathAvailable(n,a,i),s(o)&&await e.admin.assertPathAvailable(n,o)}}let f=new Date;return N(await s.upsert({entityId:n,locale:i,data:c,createdBy:a?.id??``,createdByName:a?.name??null,createdAt:f,updatedAt:f},{target:[`entityId`,`locale`],set:{updatedAt:f},mergeJsonb:[`data`]}))}async function L(e,t,n=`_`){b(n);let r=await C(e);if(!r)return null;let i=await r.findOne({entityId:t,locale:n});return i?N(i):null}async function R(e,t,n=`_`){b(n);let r=await C(e);return r?(await r.deleteMany({entityId:t,locale:n})).length>0:!1}async function z(e,n,i=`_`,a){if(b(i),!await C(e))throw new t(e.entity.name,`drafts`);let c=e.versionableOptions?await e.admin.findById(n,m(i)):null;return e.db.transaction(async l=>{let u={...e,db:l},d=await C(u);if(!d)throw new t(e.entity.name,`drafts`);let[p]=await d.deleteMany({entityId:n,locale:i});if(!p)throw new r(n,i);let{publishedAt:m,...h}=p.data??{},g=await s(u,n,o(e.entity,{...h,status:`published`}),i,l),_=B(e.entity,h);return _!==void 0&&await e.admin.updateInternal(n,{_overrides:_},{tx:l}),e.versionableOptions&&await f(u,n,g,c,i,a),g})}function B(e,t){if(!(e.behaviors??[]).some(e=>e.name===`withOverrides`))return;let n=e.allFields._overrides;if(n===void 0||n.internal!==!0)return;let r=t._overrides;if(!(typeof r!=`object`||!r||Array.isArray(r)))return r}async function V(e,t,n=`_`){b(n);let r=await C(e);return r?r.exists({entityId:t,locale:n}):!1}async function H(e,t=`_`){b(t);let n=await C(e);return n?n.count({locale:t}):0}async function U(e,t,n=`_`){b(n);let r=new Set;if(t.length===0)return r;let i=await C(e);if(!i)return r;let a=await i.findMany({where:{entityId:{in:t},locale:n}});for(let e of a)r.add(e.entityId);return r}async function W(e,t,n=`_`){b(n);let r=await L(e,t,n);if(!r)return null;let a=i().defaultLocale,o=n===`_`?{defaultLocale:a}:{locale:n,defaultLocale:a},s=await e.admin.findById(t,o);if(!s)return null;let l=e.entity.allFields,u=s,f=r.data,p={};for(let e of Object.keys(f))p[e]=u[e];let m=c(p,f,l);for(let e of m)e.type===`blockTree`&&(e.blockDiffs=d(Array.isArray(e.from)?e.from:[],Array.isArray(e.to)?e.to:[]));return{diffs:m,current:s,draft:r}}async function G(e,t){let n=(await q(e,[t])).get(t);return n?Object.values(n):[]}async function K(e,n,r,a){b(r);let o=i();if(r===o.defaultLocale){let t={status:a};a===`published`&&(t.publishedAt=new Date),await e.admin.update(n,t);return}let s=await x(e);if(!s)throw new t(e.entity.name,`localeStatus`);let c={status:a};await e.admin.updateForLocale(n,c,r,{defaultLocale:o.defaultLocale,localeStatusWriter:(e,t,n)=>y(s,e,t,n)})}async function q(e,t){let n=new Map;if(t.length===0)return n;let r=await x(e);if(!r)return n;let i=await r.findMany({where:{entityId:{in:t}}});for(let e of i){let t=e.entityId,r=e.locale,i=n.get(t);i||(i={},n.set(t,i)),i[r]={locale:r,status:e.status,publishedAt:e.publishedAt??null}}return n}function J(){let e=i(),t=D();return{locales:(t?.availableLocales??e.locales.map(e=>e.code)).map(e=>({code:e})),defaultLocale:t?.defaultLocale??e.defaultLocale}}function Y(e,t,n,r){let i={};for(let a of n){if(a.code===r)continue;let n=t?.get(a.code)??{},o=[];for(let t of e)(n[t]===void 0||n[t]===null)&&o.push(t);i[a.code]={complete:o.length===0,total:e.length,translated:e.length-o.length,missing:o}}return i}async function X(e,t){return(await Z(e,[t])).get(t)??{}}async function Z(e,t){let n=new Map;if(t.length===0)return n;let{locales:r,defaultLocale:i}=J(),a=Object.entries(e.entity.allFields).filter(([e,t])=>t.translatable).map(([e])=>e);if(a.length===0)return n;let o=k.get(`${e.entity.name}_translations`);if(!o)return n;let s=await O(e.entity),c=j(o.entityId,t),l=await e.db.select().from(o).where(s===void 0?c:A(c,T(o,{site:s},e.entity.name))),u=new Map;for(let e of l){let t=e,n=t.entityId,r=t.locale,i={};for(let e of a)i[e]=t[e];let o=u.get(n);o||(o=new Map,u.set(n,o)),o.set(r,i)}for(let e of t)n.set(e,Y(a,u.get(e),r,i));return n}async function Q(e,t){let{locales:r,defaultLocale:i}=J(),a=t?.locale??r.find(e=>e.code!==i)?.code;if(!a)return{total:0,complete:0,partial:0,missing:0};if(!r.some(e=>e.code===a))throw new n(a,r.map(e=>e.code));let o=await e.admin.findMany();if(o.length===0)return{total:0,complete:0,partial:0,missing:0};let s=await Z(e,o.map(e=>e.id)),c=0,l=0,u=0;for(let e of o){let t=s.get(e.id)?.[a];!t||t.translated===0?u++:t.complete?c++:l++}return{total:o.length,complete:c,partial:l,missing:u}}var $=class{ctx;constructor(t){let n=e(t.entity.behaviors);this.ctx=S(t,n)}async create(e){let t=await this.ctx.admin.create(e);return this.ctx.versionableOptions&&await g(this.ctx,t.id,t,null,!1),t}findById(e,t){return this.ctx.admin.findById(e,t)}findMany(e){return this.ctx.admin.findMany(e)}async update(e,t){let n=null;this.ctx.versionableOptions&&(n=await this.ctx.admin.findById(e));let r=await this.ctx.admin.update(e,t);if(this.ctx.versionableOptions&&n){let t=v(n,r),i=await g(this.ctx,e,r,t,!1);if(i!==void 0)return await u(this.ctx,e,i),await a(this.ctx,e),{...r,_version:i};await a(this.ctx,e)}return r}delete(e){return this.ctx.admin.delete(e)}saveTranslation(e,t,n){return this.ctx.admin.saveTranslation(e,t,n)}deleteTranslation(e,t){return this.ctx.admin.deleteTranslation(e,t)}getVersions(e,t){return l(this.ctx,e,t)}getVersion(e,t,n){return h(this.ctx,e,t,n)}compareVersions(e,t,n,r){return _(this.ctx,e,t,n,r)}restoreVersion(e,t,n){return p(this.ctx,e,t,n)}pruneVersions(e,t){return a(this.ctx,e,t)}createVersionSnapshot(e,t,n,r,i){return f(this.ctx,e,t,n,r,i)}getTranslationStatus(e){return X(this.ctx,e)}getTranslationStatusBatch(e){return Z(this.ctx,e)}getTranslationReport(e){return Q(this.ctx,e)}getLocaleStatus(e){return G(this.ctx,e)}setLocaleStatus(e,t,n){return K(this.ctx,e,t,n)}getLocaleStatusBatch(e){return q(this.ctx,e)}saveDraft(e,t,n,r){return P(this.ctx,e,t,n,r)}mergeDraft(e,t,n,r){return I(this.ctx,e,t,n,r)}getDraft(e,t){return L(this.ctx,e,t)}deleteDraft(e,t){return R(this.ctx,e,t)}publishDraft(e,t,n){return z(this.ctx,e,t,n)}hasDraft(e,t){return V(this.ctx,e,t)}countDrafts(e){return H(this.ctx,e)}hasDraftBatch(e,t){return U(this.ctx,e,t)}compareDraftWithCurrent(e,t){return W(this.ctx,e,t)}};function ee(e){return new $({admin:M(e),entity:e,logger:E().logger.child({content:e.name})})}export{$ as ContentClient,ee as createContentClient};
1
+ import{t as e}from"./versionable-Br_G5VGR.mjs";import{n as t,r as n,t as r}from"./errors-NRNILhhm.mjs";import{t as i}from"./content-config-store-8omQF5p5.mjs";import{a,d as o,f as s,h as c,i as l,l as u,m as d,n as f,o as p,p as m,r as h,s as g,t as _,u as v}from"./versions-CD2Tnqla.mjs";import{n as y}from"./plugin-BpkCaBrp.mjs";import{t as b}from"./locale-utils-B0mFoV47.mjs";import{i as x,n as S,r as C}from"./context-D7eEItCY.mjs";import{blockTreeFieldsOf as w,buildPartitionCondition as T}from"@murumets-ee/entity";import{getApp as E,getContext as D,requireSiteFor as O}from"@murumets-ee/core";import{schemaRegistry as k}from"@murumets-ee/db";import{and as A,inArray as j}from"drizzle-orm";import"server-only";import{createAdminClient as M}from"@murumets-ee/core/clients";function N(e){return{id:e.id,entityId:e.entityId,locale:e.locale,data:e.data,createdBy:e.createdBy,createdByName:e.createdByName??null,createdAt:e.createdAt,updatedAt:e.updatedAt}}async function P(e,n,r,i=`_`,a){b(i);let s=await C(e);if(!s)throw new t(e.entity.name,`drafts`);let c=o(e.entity,r);e.admin.assertBlockTreesValid(c);let l=w(e.entity).some(({name:e})=>Object.hasOwn(c,e))?await s.findOne({entityId:n,locale:i}):null;await e.admin.assertBlockTreesStrictForCaller(c,()=>l?.data),await e.admin.assertMediaKindsValid(c,l?.data);let u=new Date,d=a?.id??``,f=a?.name??null;return N(await s.upsert({entityId:n,locale:i,data:c,createdBy:d,createdByName:f,createdAt:u,updatedAt:u},{target:[`entityId`,`locale`],set:{data:c,updatedAt:u}}))}const F=[`slug`,`slugMode`,`parent`];async function I(e,n,r,i=`_`,a){b(i);let s=await C(e);if(!s)throw new t(e.entity.name,`drafts`);let c=o(e.entity,r);if(Object.keys(c).length===0){let e=await s.findOne({entityId:n,locale:i});return e?N(e):null}e.admin.assertBlockTreesValid(c),e.admin.assertUpdateValid(c);let l=w(e.entity).some(({name:e})=>Object.hasOwn(c,e)),u=e.entity.frontend?.path!==void 0&&F.some(e=>Object.hasOwn(c,e)),d=l||u?await s.findOne({entityId:n,locale:i}):null;if(await e.admin.assertBlockTreesStrictForCaller(c,()=>d?.data),await e.admin.assertMediaKindsValid(c,d?.data),u){let t={...d?.data??{},...c};if(i===`_`)await e.admin.assertPathAvailable(n,t);else{let r=e.entity.allFields,a={},o={};for(let[e,n]of Object.entries(t))r[e]?.translatable===!0?a[e]=n:o[e]=n;let s=e=>F.some(t=>Object.hasOwn(e,t)&&Object.hasOwn(c,t));s(a)&&await e.admin.assertPathAvailable(n,a,i),s(o)&&await e.admin.assertPathAvailable(n,o)}}let f=new Date;return N(await s.upsert({entityId:n,locale:i,data:c,createdBy:a?.id??``,createdByName:a?.name??null,createdAt:f,updatedAt:f},{target:[`entityId`,`locale`],set:{updatedAt:f},mergeJsonb:[`data`]}))}async function L(e,t,n=`_`){b(n);let r=await C(e);if(!r)return null;let i=await r.findOne({entityId:t,locale:n});return i?N(i):null}async function R(e,t,n=`_`){b(n);let r=await C(e);return r?(await r.deleteMany({entityId:t,locale:n})).length>0:!1}async function z(e,n,i=`_`,a){if(b(i),!await C(e))throw new t(e.entity.name,`drafts`);let c=e.versionableOptions?await e.admin.findById(n,m(i)):null;return e.db.transaction(async l=>{let u={...e,db:l},d=await C(u);if(!d)throw new t(e.entity.name,`drafts`);let[p]=await d.deleteMany({entityId:n,locale:i});if(!p)throw new r(n,i);let{publishedAt:m,...h}=p.data??{},g=await s(u,n,o(e.entity,{...h,status:`published`}),i,l),_=B(e.entity,h);return _!==void 0&&await e.admin.updateInternal(n,{_overrides:_},{tx:l}),e.versionableOptions&&await f(u,n,g,c,i,a),g})}function B(e,t){if(!(e.behaviors??[]).some(e=>e.name===`withOverrides`))return;let n=e.allFields._overrides;if(n===void 0||n.internal!==!0)return;let r=t._overrides;if(!(typeof r!=`object`||!r||Array.isArray(r)))return r}async function V(e,t,n=`_`){b(n);let r=await C(e);return r?r.exists({entityId:t,locale:n}):!1}async function H(e,t=`_`){b(t);let n=await C(e);return n?n.count({locale:t}):0}async function U(e,t,n=`_`){b(n);let r=new Set;if(t.length===0)return r;let i=await C(e);if(!i)return r;let a=await i.findMany({where:{entityId:{in:t},locale:n}});for(let e of a)r.add(e.entityId);return r}async function W(e,t,n=`_`){b(n);let r=await L(e,t,n);if(!r)return null;let a=i().defaultLocale,o=n===`_`?{defaultLocale:a}:{locale:n,defaultLocale:a},s=await e.admin.findById(t,o);if(!s)return null;let l=e.entity.allFields,u=s,f=r.data,p={};for(let e of Object.keys(f))p[e]=u[e];let m=c(p,f,l);for(let e of m)e.type===`blockTree`&&(e.blockDiffs=d(Array.isArray(e.from)?e.from:[],Array.isArray(e.to)?e.to:[]));return{diffs:m,current:s,draft:r}}async function G(e,t){let n=(await q(e,[t])).get(t);return n?Object.values(n):[]}async function K(e,n,r,a){b(r);let o=i();if(r===o.defaultLocale){let t={status:a};a===`published`&&(t.publishedAt=new Date),await e.admin.update(n,t);return}let s=await x(e);if(!s)throw new t(e.entity.name,`localeStatus`);let c={status:a};await e.admin.updateForLocale(n,c,r,{defaultLocale:o.defaultLocale,localeStatusWriter:(e,t,n)=>y(s,e,t,n)})}async function q(e,t){let n=new Map;if(t.length===0)return n;let r=await x(e);if(!r)return n;let i=await r.findMany({where:{entityId:{in:t}}});for(let e of i){let t=e.entityId,r=e.locale,i=n.get(t);i||(i={},n.set(t,i)),i[r]={locale:r,status:e.status,publishedAt:e.publishedAt??null}}return n}function J(){let e=i(),t=D();return{locales:(t?.availableLocales??e.locales.map(e=>e.code)).map(e=>({code:e})),defaultLocale:t?.defaultLocale??e.defaultLocale}}function Y(e,t,n,r){let i={};for(let a of n){if(a.code===r)continue;let n=t?.get(a.code)??{},o=[];for(let t of e)(n[t]===void 0||n[t]===null)&&o.push(t);i[a.code]={complete:o.length===0,total:e.length,translated:e.length-o.length,missing:o}}return i}async function X(e,t){return(await Z(e,[t])).get(t)??{}}async function Z(e,t){let n=new Map;if(t.length===0)return n;let{locales:r,defaultLocale:i}=J(),a=Object.entries(e.entity.allFields).filter(([e,t])=>t.translatable).map(([e])=>e);if(a.length===0)return n;let o=k.get(`${e.entity.name}_translations`);if(!o)return n;let s=await O(e.entity),c=j(o.entityId,t),l=await e.db.select().from(o).where(s===void 0?c:A(c,T(o,{site:s},e.entity.name))),u=new Map;for(let e of l){let t=e,n=t.entityId,r=t.locale,i={};for(let e of a)i[e]=t[e];let o=u.get(n);o||(o=new Map,u.set(n,o)),o.set(r,i)}for(let e of t)n.set(e,Y(a,u.get(e),r,i));return n}async function Q(e,t){let{locales:r,defaultLocale:i}=J(),a=t?.locale??r.find(e=>e.code!==i)?.code;if(!a)return{total:0,complete:0,partial:0,missing:0};if(!r.some(e=>e.code===a))throw new n(a,r.map(e=>e.code));let o=await e.admin.findMany();if(o.length===0)return{total:0,complete:0,partial:0,missing:0};let s=await Z(e,o.map(e=>e.id)),c=0,l=0,u=0;for(let e of o){let t=s.get(e.id)?.[a];!t||t.translated===0?u++:t.complete?c++:l++}return{total:o.length,complete:c,partial:l,missing:u}}var $=class{ctx;constructor(t){let n=e(t.entity.behaviors);this.ctx=S(t,n)}async create(e){let t=await this.ctx.admin.create(e);return this.ctx.versionableOptions&&await g(this.ctx,t.id,t,null,!1),t}findById(e,t){return this.ctx.admin.findById(e,t)}findMany(e){return this.ctx.admin.findMany(e)}async update(e,t){let n=null;this.ctx.versionableOptions&&(n=await this.ctx.admin.findById(e));let r=await this.ctx.admin.update(e,t);if(this.ctx.versionableOptions&&n){let t=v(n,r),i=await g(this.ctx,e,r,t,!1);if(i!==void 0)return await u(this.ctx,e,i),await a(this.ctx,e),{...r,_version:i};await a(this.ctx,e)}return r}delete(e){return this.ctx.admin.delete(e)}saveTranslation(e,t,n){return this.ctx.admin.saveTranslation(e,t,n)}deleteTranslation(e,t){return this.ctx.admin.deleteTranslation(e,t)}getVersions(e,t){return l(this.ctx,e,t)}getVersion(e,t,n){return h(this.ctx,e,t,n)}compareVersions(e,t,n,r){return _(this.ctx,e,t,n,r)}restoreVersion(e,t,n){return p(this.ctx,e,t,n)}pruneVersions(e,t){return a(this.ctx,e,t)}createVersionSnapshot(e,t,n,r,i){return f(this.ctx,e,t,n,r,i)}getTranslationStatus(e){return X(this.ctx,e)}getTranslationStatusBatch(e){return Z(this.ctx,e)}getTranslationReport(e){return Q(this.ctx,e)}getLocaleStatus(e){return G(this.ctx,e)}setLocaleStatus(e,t,n){return K(this.ctx,e,t,n)}getLocaleStatusBatch(e){return q(this.ctx,e)}saveDraft(e,t,n,r){return P(this.ctx,e,t,n,r)}mergeDraft(e,t,n,r){return I(this.ctx,e,t,n,r)}getDraft(e,t){return L(this.ctx,e,t)}deleteDraft(e,t){return R(this.ctx,e,t)}publishDraft(e,t,n){return z(this.ctx,e,t,n)}hasDraft(e,t){return V(this.ctx,e,t)}countDrafts(e){return H(this.ctx,e)}hasDraftBatch(e,t){return U(this.ctx,e,t)}compareDraftWithCurrent(e,t){return W(this.ctx,e,t)}};function ee(e){return new $({admin:M(e),entity:e,logger:E().logger.child({content:e.name})})}export{$ as ContentClient,ee as createContentClient};
2
2
  //# sourceMappingURL=client.mjs.map
@@ -0,0 +1,2 @@
1
+ const e=`detail_template`,t=`target`;function n(t){let n=t?.entities;return!n||typeof n.has!=`function`?!1:n.has(e)}export{t as n,n as r,e as t};
2
+ //# sourceMappingURL=detail-template-names-CD1Y9n1U.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"detail-template-names-CD1Y9n1U.mjs","names":[],"sources":["../src/detail-template-names.ts"],"sourcesContent":["/**\n * The detail-template entity's name, and the list-time predicate its seeder\n * uses — in a module that imports NOTHING, for the reason\n * `./chrome-entity-names.ts` gives: `available` runs on the seed page's\n * synchronous shell and inside `lumi seed --list`, so it cannot sit behind an\n * `await import`, and importing it from a module that reaches\n * `@murumets-ee/core/clients` would hoist that into `dist/plugin.mjs`.\n */\n\n/** The one detail-template entity. Rows differ by `target`, not by entity name. */\nexport const DETAIL_TEMPLATE_ENTITY_NAME = 'detail_template'\n\n/** The column holding the entity a template draws. Also its unique key. */\nexport const DETAIL_TEMPLATE_TARGET_FIELD = 'target'\n\n/**\n * Does this app have the detail-template entity for the seeder to create rows\n * in? `app` is `unknown` — see `hasChromeEntities`.\n */\nexport function hasDetailTemplateEntity(app: unknown): boolean {\n const entities = (app as { entities?: Map<string, unknown> } | null | undefined)?.entities\n if (!entities || typeof entities.has !== 'function') return false\n return entities.has(DETAIL_TEMPLATE_ENTITY_NAME)\n}\n"],"mappings":"AAUA,MAAa,EAA8B,kBAG9B,EAA+B,SAM5C,SAAgB,EAAwB,EAAuB,CAC7D,IAAM,EAAY,GAAgE,SAElF,MADI,CAAC,GAAY,OAAO,EAAS,KAAQ,WAAmB,GACrD,EAAS,IAAI,CAA2B,CACjD"}