@murumets-ee/menus 0.109.0 → 0.111.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.
package/dist/admin.d.mts CHANGED
@@ -360,4 +360,3 @@ declare function resolveMenuItemSummaries(app: ToolkitApp, refs: ReadonlyArray<{
360
360
  }>): Promise<Map<string, MenuItemSummary>>;
361
361
  //#endregion
362
362
  export { ANCHOR_PATTERN, type BlockAnchor, MAX_ANCHOR_LENGTH, MAX_MENU_DEPTH, MAX_MENU_SOURCE_PAGE, MAX_MENU_TREE_NODES, MAX_RESOLVED_HANDLES, type MenuEditorStateEntityType, type MenuEditorStateMenu, type MenuEditorStateResponse, type MenuItem, type MenuItemSummary, type MenuSchemaOptions, type ResolvedMenusResponse, applyMenuLabelTranslations, buildMenuItemSchema, buildMenuTreeSchema, collectMenuLabelTexts, measureMenuTree, menuTranslatableSource, menuTreeRevision, menusContributions, menusRoutes, resolveMenuItemSummaries, validateTreeDepth };
363
- //# sourceMappingURL=admin.d.mts.map
package/dist/admin.mjs CHANGED
@@ -1,2 +1 @@
1
1
  import{a as e,c as t,d as n,f as r,i,l as a,n as o,o as s,r as c,s as l,t as u,u as d}from"./columns-DwDukx_r.mjs";import{resolveLinkHref as f}from"@murumets-ee/blocks/links";import{z as p}from"zod";import{createHash as m}from"node:crypto";import{getContentConfig as h}from"@murumets-ee/content/plugin";import{combineAdminRoutes as g,defineAdminRoute as _,getContext as v,getCurrentApp as y,getCurrentScope as b,siteSecurityFields as x}from"@murumets-ee/core";import{asc as S,eq as ee,gt as C,inArray as w}from"drizzle-orm";import{VIEW_UNPUBLISHED_ACTION as T,getBlockTreeFieldName as E,getEntityTitleField as D,getSearchableConfig as te}from"@murumets-ee/entity";import{QueryClient as O}from"@murumets-ee/entity/query";const ne=200,k=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;function A(e){return typeof e==`object`&&!!e&&!Array.isArray(e)}function j(e){return typeof e==`string`&&e.trim()!==``}function M(e){let t=[],n=Array.isArray(e)?[...e].reverse():[];for(;n.length>0;){let e=n.pop();if(A(e)){if(t.push(e),t.length>500)throw Error(`menu tree has more than 500 items`);if(Array.isArray(e.children))for(let t=e.children.length-1;t>=0;t--)n.push(e.children[t])}}return t}function N(e){return m(`sha256`).update(JSON.stringify(Array.isArray(e)?e:[])).digest(`hex`)}function P(e,t){let{sourceLocale:n,targetLocale:r,mode:i}=t;if(n===r)return[];let a=[],o=new Set;for(let t of M(e)){let{id:e,label:s}=t;if(!j(e)||o.has(e)||!A(s))continue;let c=Object.hasOwn(s,n)?s[n]:void 0;if(!j(c))continue;let l=Object.hasOwn(s,r)?s[r]:void 0;i===`missing`&&j(l)||(o.add(e),a.push({key:e,text:c,maxLength:200}))}return a}function F(e,t,n){let r=Array.isArray(e)?structuredClone(e):[],i=0;for(let e of M(r)){let{id:r,label:a}=e;if(typeof r!=`string`||!Object.hasOwn(n,r)||!A(a))continue;let o=n[r];if(typeof o!=`string`)continue;let s=o.trim();if(s!==``){if(s.length>200)throw Error(`menu item ${r}: the translated label is longer than 200 characters`);e.label={...a,[t]:s},i++}}return{tree:r,applied:i}}function I(e){if(e===null||typeof e==`string`||typeof e==`boolean`)return e;if(typeof e==`number`)return Number.isFinite(e)?e:null;if(Array.isArray(e))return e.map(I);if(A(e)){let t={};for(let[n,r]of Object.entries(e))r!==void 0&&(t[n]=I(r));return t}return null}async function re(e){let{ContentClient:t}=await import(`@murumets-ee/content/client`);return new t({admin:e.getClient(o),entity:o})}async function L(e,t){if(!k.test(t))return null;let n=await e.getClient(o).findById(t);if(!n)return null;let r=await re(e),i=await r.getDraft(t,`_`),a=A(i?.data)?i.data:void 0;return{tree:a!==void 0&&`tree`in a?a.tree:n.tree,draftData:a,content:r}}async function ie(e,t){if(t.after!==void 0&&!k.test(t.after))throw Error("menu translatable source: `after` is not a menu id");let n=Number.isFinite(t.limit)?Math.min(Math.max(Math.trunc(t.limit),1),200):200,r=e.getClient(o),i=u(r.getTable(),`id`);return(await r.findMany({...t.after!==void 0&&{where:C(i,t.after)},orderBy:S(i),limit:n})).map(e=>{let t=e;return{id:t.id,label:typeof t.title==`string`?t.title:``}})}async function ae(e,t){let n=await L(e,t.id);return n===null?null:{revision:N(n.tree),texts:P(n.tree,t)}}function oe(){let e=v(),t=e?.user?.groups[0];if(t===void 0||e?.checker?.(t,o.name,`update`)!==!0)throw Error(`menu translatable source: the caller may not update menus`)}async function se(e,t,n){oe();let r=await L(e,t.id);if(r===null)return`not_found`;if(N(r.tree)!==t.revision)return`changed`;if(!n.includes(t.targetLocale))throw Error(`menu ${t.id}: the target locale is not one of the site's content languages (${n.join(`, `)})`);let{tree:i,applied:a}=F(r.tree,t.targetLocale,t.texts);if(a===0)return`written`;let s=d({allowedLocales:n}).safeParse(i);if(!s.success){let e=s.error.issues.slice(0,5).map(e=>`${e.path.join(`.`)}: ${e.message}`).join(`; `);throw Error(`menu ${t.id}: the translated tree is not valid — ${e}`)}let{LockService:c}=await import(`@murumets-ee/content/lock`),l=await new c({app:e}).getLock(o.name,t.id,`_`);if(l&&l.lockedBy!==t.user.id)throw Error(`menu ${t.id}: is being edited by ${l.lockedByName??l.lockedBy} — try again when they have finished`);return await r.content.saveDraft(t.id,{...r.draftData,tree:I(s.data)},`_`,t.user),`written`}function R(){let e=y();if(e===void 0)throw Error(`menu translatable source: called outside a request context`);return e}function ce(){let e=v()?.availableLocales;return e!==void 0&&e.length>0?e:h().locales.map(e=>e.code)}const z={kind:`menu`,label:`Menus`,labelKey:`Menus.translatableSource.menu`,permission:{resource:`menu`,action:`update`},draft:!0,list:e=>ie(R(),e),read:e=>ae(R(),e),write:e=>se(R(),e,ce())},le={"content.translatableSources":[z]};function B(e,t,n){if(typeof e!=`object`||!e||Array.isArray(e))return;let r=e;for(let e of t===n?[t]:[t,n]){if(!Object.hasOwn(r,e))continue;let t=r[e];if(typeof t==`string`&&t.length>0)return t}}function ue(e,t){if(typeof e!=`object`||!e||Array.isArray(e))return;let n=e;if(!Object.hasOwn(n,t))return;let r=n[t];return typeof r==`boolean`?r:void 0}function de(e){return typeof e==`object`&&!!e&&!Array.isArray(e)}function fe(t,n,r,i){let a=new Map,o=new Map,s=500,c=0;function l(e,t,n){let r=e.get(t);r?r.add(n):e.set(t,new Set([n]))}function u(t,d){if(d>i||!Array.isArray(t))return[];let f=t,p=[];for(let t of f){if(s<=0){c++;continue}if(!de(t))continue;let i=t.id,f=t.kind;if(typeof i!=`string`||i.length===0||f!==`link`&&f!==`header`||ue(t.enabled,n)===!1)continue;s--;let m;if(f===`link`){let n=e.safeParse(t.link);if(!n.success)continue;m=n.data}else if(t.link!==void 0)continue;let h=u(t.children,d+1);m?.kind===`entity`&&(l(a,m.entity,m.id),B(t.label,n,r)===void 0&&l(o,m.entity,m.id)),p.push({id:i,kind:f,link:m,label:t.label,newTab:t.newTab===!0,children:h})}return p}return{nodes:u(t,1),idsByType:a,titleIdsByType:o,droppedForBudget:c}}async function pe(e,t,n,r){let i=new Map,a=new Map,o=new Map,s=[...e.idsByType.entries()];return s.length>16&&n.logger?.warn({handle:r,locale:t,entityTypes:s.length,cap:16},`menu resolution dropped entity-type groups over the fan-out cap`),await Promise.all(s.slice(0,16).map(async([r,s])=>{let c=[...s],l=await n.resolveCanonicalPaths(r,c,t);if(i.set(r,l),l.size===0)return;let u=[...l.keys()],d=await n.filterPublished(r,u,t);a.set(r,d);let f=e.titleIdsByType.get(r);if(f===void 0)return;let p=u.filter(e=>f.has(e)&&d.has(e));p.length!==0&&o.set(r,await n.resolveTitles(r,p,t))})),{paths:i,published:a,titles:o}}function V(e,t,n,r){let i=[];for(let a of e){let e=V(a.children,t,n,r),o=B(a.label,t,r.defaultLocale);if(a.kind===`header`){if(e.length===0||o===void 0)continue;i.push({id:a.id,kind:`header`,label:o,href:null,newTab:!1,isActive:!1,inActiveTrail:!1,children:e});continue}let s=a.link;if(s===void 0)continue;let c,l=o;if(s.kind===`entity`){let e=n.paths.get(s.entity)?.get(s.id);if(e===void 0||n.published.get(s.entity)?.has(s.id)!==!0)continue;c=s.anchor===void 0?e:`${e}#${s.anchor}`,l??=n.titles.get(s.entity)?.get(s.id)}else c=f(s);c===null||c.length===0||l===void 0||l.length===0||i.push({id:a.id,kind:`link`,label:l,href:c,newTab:a.newTab,isActive:!1,inActiveTrail:!1,children:e})}return i}function H(e){let t=e.indexOf(`#`),n=t===-1?e:e.slice(0,t);return n.length>1&&n.endsWith(`/`)?n.slice(0,-1):n}function U(e,t){let n=!1;return{items:e.map(e=>{let r=U(e.children,t),i=e.href!==null&&H(e.href)===t,a=r.containsActive;return(i||a)&&(n=!0),{...e,isActive:i,inActiveTrail:a,children:r.items}}),containsActive:n}}function me(e,t){return{...e,items:U(e.items,H(t)).items}}async function he(e,t,n){if(e.length===0||e.length>128)return null;let r=await n.getMenuRow(e);if(r===null)return null;let i=r.maxDepth,a=typeof i==`number`&&Number.isInteger(i)&&i>=1?Math.min(i,6):3,o=fe(r.tree,t.locale,n.defaultLocale,a);o.droppedForBudget>0&&n.logger?.warn({handle:e,locale:t.locale,dropped:o.droppedForBudget,cap:500},`menu resolution dropped items over the node budget`);let s=await pe(o,t.locale,n,e),c={handle:e,items:V(o.nodes,t.locale,s,n)};return t.currentPath===void 0?c:me(c,t.currentPath)}function W(e){return typeof e==`object`&&!!e&&!Array.isArray(e)}function G(e,t){if(!W(e))return;let n={};for(let[r,i]of Object.entries(e))t(i)&&(n[r]=i);return n}const ge=e=>typeof e==`string`,_e=e=>typeof e==`boolean`;function ve(t){let n=500,r=0;function i(t,a){if(!Array.isArray(t))return[];let o=t;if(a>6)return r+=o.length,[];let s=[];for(let t of o){if(!W(t)){r++;continue}let o=t.id,c=t.kind;if(typeof o!=`string`||o.length===0){r++;continue}if(c!==`link`&&c!==`header`){r++;continue}let l;if(c===`link`){let n=e.safeParse(t.link);if(!n.success){r++;continue}l=n.data}else if(t.link!==void 0){r++;continue}if(n<=0){r++;continue}n--;let u=G(t.label,ge),d=u!==void 0&&Object.keys(u).length>0?u:void 0,f=G(t.enabled,_e),p=f!==void 0&&Object.keys(f).length>0?f:void 0;s.push({id:o,kind:c,link:l,label:d??null,enabled:p,newTab:typeof t.newTab==`boolean`?t.newTab:void 0,children:i(t.children,a+1)})}return s}return{items:i(t,1),dropped:r}}function ye(e){let t=new Set,n=[];function r(e){for(let i of e){let e=i.link;if(e?.kind===`entity`){let r=`${e.entity}:${e.id}`;t.has(r)||(t.add(r),n.push({entity:e.entity,id:e.id}))}r(i.children)}}return r(e),n}const K=1e3;function q(e){return typeof e==`object`&&!!e&&!Array.isArray(e)}function be(e){let t=e.props;if(!q(t))return;let n=t.anchor;if(typeof n!=`string`||n.length===0||n.length>128||!i.test(n))return;let r=t.anchorLabel;return{anchor:n,label:typeof r==`string`&&r.length>0&&r.length<=200?r:n}}function xe(e,t){let n=[];if(!Array.isArray(e))return{anchors:n,truncated:!1,visited:0};let r=e,i=0,a=!1;r.length>1e3&&(a=!0);let o=[];for(let e=Math.min(r.length,K)-1;e>=0;e--)o.push(r[e]);for(;o.length>0;){if(i>=1e3||n.length>=200){a=!0;break}let e=o.pop();if(i++,!q(e))continue;let t=be(e);t!==void 0&&n.push(t);let r=e.children;if(Array.isArray(r)){let e=r,t=Math.max(K-i,0);e.length>t&&(a=!0);for(let n=Math.min(e.length,t)-1;n>=0;n--)o.push(e[n])}}return a&&t?.warn({visited:i,collected:n.length,nodeBudget:K,resultCap:200},`anchor collection stopped at a hard bound — the returned list is incomplete`),{anchors:n,truncated:a,visited:i}}function J(e,t){return`${e}:${t}`}const Se={title:null,exists:!1,published:null};function Ce(e){let t=new Map,n=0,r=0;for(let i of e){if(typeof i.entity!=`string`||typeof i.id!=`string`||i.entity.length===0||i.id.length===0)continue;let e=t.get(i.entity);if(!e?.has(i.id)){if(n>=500){r++;continue}n++,e?e.add(i.id):t.set(i.entity,new Set([i.id]))}}let i=Math.max(0,t.size-16);return{groups:t,droppedRefs:r,droppedGroups:i}}async function Y(e,t){let n=new Map;if(t.length===0)return n;let r=e.logger.child({resolve:`menu-item-summaries`}),{groups:i,droppedRefs:a,droppedGroups:o}=Ce(t);return a>0&&r.warn({dropped:a,cap:500},`menu item summaries dropped targets over the ref budget — some rows will show no status`),o>0&&r.warn({dropped:o,cap:16},`menu item summaries dropped entity-type groups over the fan-out cap`),await Promise.all([...i.entries()].slice(0,16).map(([t,i])=>we(e,r,t,i,n))),n}async function we(e,t,n,r,i){let a=e.entities.get(n);if(a===void 0)return;let o=D(a),s=o!==`id`,c=a.behaviors?.some(e=>e.name===`publishable`)??!1,l=[...r];try{let t=e.getClient(a),r=await t.findMany({where:w(u(t.getTable(),`id`),l),limit:l.length}),d=new Set;for(let e of r){let t=e,r=t.id;if(typeof r!=`string`)continue;let a=s?t[o]:void 0;d.add(r),i.set(J(n,r),{title:typeof a==`string`&&a.length>0?a:null,exists:!0,published:c?t.status===`published`:null})}if((a.scope??`global`)===`global`)for(let e of l)d.has(e)||i.set(J(n,e),Se)}catch(e){t.warn({entity:n,count:l.length,error:String(e)},`menu item summaries could not be resolved for this entity type`)}}const Te=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;function X(e,t=200){return new Response(JSON.stringify(e),{status:t,headers:{"Content-Type":`application/json`}})}function Z(e,t){return X({error:e},t)}function Q(){return X([])}function Ee(e){return E(e)??null}const De=_({prefix:`menus`,path:`anchors`,method:`GET`,permission:`menu:view`,defaultRoles:[`admin`],description:`List a published document's addressable block anchors for the menu editor's anchor picker.`,tokens:`accept`,handler:async(e,t)=>{let n=new URL(e.url).searchParams,r=n.get(`entity`),i=n.get(`id`);if(r===null||r.length===0)return Z(`query param 'entity' is required`,400);if(r.length>64)return Z(`query param 'entity' is too long`,400);if(i===null||!Te.test(i))return Z(`query param 'id' must be a uuid`,400);let a=y();if(a===void 0)return Z(`Internal error`,500);let o=a.logger.child({route:`menus/anchors`,entity:r});if(!t.checkPermission(r,`view`))return o.debug({userId:t.user.id,role:t.user.role},`anchors lookup returned empty — caller holds no view grant on the target entity`),Q();let s=a.entities.get(r);if(s===void 0)return Q();let c=Ee(s);if(c===null)return Q();let l=b(),u=await new O({entity:s,db:a.db.readOnly,logger:o,contextResolver:()=>({user:{id:t.user.id,groups:[t.user.role??`viewer`]},checker:(e,n,r)=>r!==T&&t.checkPermission(n,r),...l!==void 0&&{scope:l},...x(void 0,a)})}).findById(i,{...t.locale!==void 0&&{locale:t.locale},...t.defaultLocale!==void 0&&{defaultLocale:t.defaultLocale}});if(u===null)return Q();let{anchors:d}=xe(u[c],o);return X(d)}}),Oe=async(e,t)=>{let n=new URL(e.url).searchParams.get(`handle`);if(n===null||n.length===0)return Z(`query param 'handle' is required`,400);if(n.length>128)return Z(`query param 'handle' is too long`,400);let r=y();if(r===void 0)return Z(`Internal error`,500);let i=r.logger.child({route:`menus/editor-state`}),a=r.getClient(o),s=(await a.findMany({where:ee(u(a.getTable(),`handle`),n),limit:1}))[0];if(s===void 0)return Z(`Menu not found`,404);let l=s.id,d=typeof s.title==`string`?s.title:``,f=s.maxDepth,p=typeof f==`number`&&Number.isInteger(f)&&f>=1?Math.min(f,6):3,m=s.status===`published`?`published`:`draft`,h=v()?.availableLocales,g=t.defaultLocale??h?.[0]??`en`,_=h&&h.length>0?[...h]:[],b=(_.includes(g)?_:[g,..._]).map(e=>({code:e,label:e})),x=await ke(r,l,i),S=x?.data,{items:C,dropped:w}=ve(S!==void 0&&`tree`in S?S.tree:s.tree);w>0&&i.warn({menuId:l,handle:n,dropped:w},`menu editor-state: stored tree contained items that could not be read — the embedded editor shows the rest`);let T=await Y(r,ye(C)),E=Object.fromEntries(T),D=[...r.entities.values()].filter(e=>te(e)!==void 0&&t.checkPermission(e.name,`view`)).map(e=>({name:e.name,label:e.admin?.labelSingular??e.admin?.label??e.name})),O=t.checkPermission(`menu`,`publish`);return X({menu:{id:l,handle:n,title:d,maxDepth:p,status:m,tree:C,entityTypes:c(S!==void 0&&`entityTypes`in S?S.entityTypes:s.entityTypes)},summaries:E,locales:b,defaultLocale:g,droppedItemCount:w,entityTypes:D,canPublish:O,...x!==void 0&&{draft:x}})};async function ke(e,t,n){try{let{ContentClient:n}=await import(`@murumets-ee/content/client`),r=await new n({admin:e.getClient(o),entity:o}).getDraft(t,`_`);return r?{data:r.data,createdBy:r.createdBy,createdByName:r.createdByName,updatedAt:r.updatedAt instanceof Date?r.updatedAt.toISOString():String(r.updatedAt)}:void 0}catch(e){n.warn({err:e,menuId:t},`menu editor-state: draft state unavailable — continuing without`);return}}const Ae=_({prefix:`menus`,path:`editor-state`,method:`GET`,permission:`menu:view`,defaultRoles:[`admin`],description:`Read a menu's full tree + editor metadata by handle, for the blocks editor's in-place Tier 3 menu surface.`,tokens:`accept`,handler:Oe}),$=8,je=p.object({handles:p.array(p.string().min(1).max(128)).min(1).max(8)}),Me=_({prefix:`menus`,path:`resolved`,method:`GET`,permission:`blocks:read-editor-state`,defaultRoles:[`admin`,`editor`],description:`Read the published, resolved items of the menus an editing canvas names, by handle.`,tokens:`accept`,handler:async(e,t)=>{let n=je.safeParse({handles:[...new Set(new URL(e.url).searchParams.getAll(`handle`))]});if(!n.success)return Z(`query param 'handle' must be given 1 to 8 times, each 1 to 128 characters`,400);let{handles:r}=n.data,i=y();if(i===void 0)return Z(`Internal error`,500);let a=i.logger.child({route:`menus/resolved`}),{buildMenuResolveDeps:o}=await import(`./menu-resolve-context-DLIUP8RY.mjs`),s=o(i),c=t.locale??t.defaultLocale??s.defaultLocale,l=await Promise.all(r.map(async e=>{try{let t=await he(e,{locale:c},s);return[e,t===null?null:t.items]}catch(t){a.warn({err:t,handle:e,locale:c},`menu failed to resolve for the canvas; it is left out of the answer`);return}}));return X({menus:Object.fromEntries(l.filter(e=>e!==void 0))})}});function Ne(){return g([De,Ae,Me])}export{i as ANCHOR_PATTERN,s as MAX_ANCHOR_LENGTH,l as MAX_MENU_DEPTH,ne as MAX_MENU_SOURCE_PAGE,t as MAX_MENU_TREE_NODES,$ as MAX_RESOLVED_HANDLES,F as applyMenuLabelTranslations,a as buildMenuItemSchema,d as buildMenuTreeSchema,P as collectMenuLabelTexts,n as measureMenuTree,z as menuTranslatableSource,N as menuTreeRevision,le as menusContributions,Ne as menusRoutes,Y as resolveMenuItemSummaries,r as validateTreeDepth};
2
- //# sourceMappingURL=admin.mjs.map
@@ -1,2 +1 @@
1
1
  import{sanitizeHref as e}from"@murumets-ee/blocks/links";import{z as t}from"zod";import{getContentConfig as n}from"@murumets-ee/content/plugin";import{auditable as r,defineEntity as i,field as a,publishable as o}from"@murumets-ee/entity";const s=6,c=500,l=128,u=/^[A-Za-z0-9_-]+$/,d=t.discriminatedUnion(`kind`,[t.object({kind:t.literal(`entity`),entity:t.string().min(1).max(64),id:t.string().uuid(),anchor:t.string().min(1).max(128).regex(u).optional()}),t.object({kind:t.literal(`url`),url:t.string().min(1).max(2048).transform(t=>e(t)).refine(e=>e!==``,{message:`link url uses a disallowed protocol (allowed: http, https, mailto, tel, or a relative path)`})}),t.object({kind:t.literal(`email`),email:t.string().email().max(254)})]);function f(e,n){let r=new Set(n);return t.unknown().superRefine((e,n)=>{if(typeof e!=`object`||!e||Array.isArray(e))return;let i=Object.keys(e);if(i.length>r.size){n.addIssue({code:t.ZodIssueCode.custom,message:`too many locale keys — configured locales are: ${[...r].join(`, `)}`});return}for(let e of i)r.has(e)||n.addIssue({code:t.ZodIssueCode.custom,path:[e],message:`unknown locale key — configured locales are: ${[...r].join(`, `)}`})}).pipe(t.record(t.string(),e))}function p(e){let n=f(t.string().max(200),e.allowedLocales),r=f(t.boolean(),e.allowedLocales),i=t.lazy(()=>t.object({id:t.string().min(1).max(64),kind:t.enum([`link`,`header`]),link:d.optional(),label:n.nullable().default(null),enabled:r.optional(),newTab:t.boolean().optional(),children:t.array(i).default([])}).superRefine((e,n)=>{if(e.kind===`link`){e.link===void 0&&n.addIssue({code:t.ZodIssueCode.custom,path:[`link`],message:`a link item requires a link target`});return}e.link!==void 0&&n.addIssue({code:t.ZodIssueCode.custom,path:[`link`],message:`a header item must not carry a link target`}),(e.label===null||Object.keys(e.label).length===0)&&n.addIssue({code:t.ZodIssueCode.custom,path:[`label`],message:`a header item requires a label for at least one locale`})}));return i}function m(e){return t.array(p(e))}function h(e){if(!Array.isArray(e))return{depth:0,nodeCount:0};let t=0,n=0,r=e.map(e=>({node:e,depth:1}));for(;r.length>0;){let e=r.pop();if(e===void 0)break;let{node:i,depth:a}=e;if(typeof i!=`object`||!i||Array.isArray(i))continue;if(n++,a>t&&(t=a),n>500||t>6)return{depth:t,nodeCount:n};let o=i.children;if(Array.isArray(o)){let e=o;for(let t of e)r.push({node:t,depth:a+1})}}return{depth:t,nodeCount:n}}function g(e,t){return h(e).depth<=t}const _=t.string().min(1).max(64),v=t.array(_).max(64).nullable().transform(e=>{if(e===null)return null;let t=[...new Set(e)];return t.length===0?null:t});function y(e){if(!Array.isArray(e))return null;let t=e,n=[],r=new Set;for(let e of t)if(!(typeof e!=`string`||e.length===0)&&!(e.length>64)&&!r.has(e)&&(r.add(e),n.push(e),n.length===64))break;return n.length===0?null:n}function b(e){if(e.keys.includes(`entityTypes`))throw new t.ZodError([{code:`custom`,path:[`entityTypes`],message:`bulk update on '${e.entityName}' cannot write 'entityTypes': updateMany runs no per-row hooks, so the whitelist would bypass its schema check. A whitelist is per-menu — update each row individually, or write a migration for a one-off backfill.`}])}function x(e){return new t.ZodError(e.issues.map(e=>({...e,path:[`entityTypes`,...e.path]})))}function S(e){if(!(`entityTypes`in e))return e;let t=v.safeParse(e.entityTypes);if(!t.success)throw x(t.error);return{...e,entityTypes:t.data}}function C(){return{name:`menuEntityTypesValidation`,hooks:{beforeCreate:async e=>S(e),beforeUpdate:async(e,t)=>S(t)},assertBulkUpdate:b}}function w(e,n=[]){return new t.ZodError([{code:t.ZodIssueCode.custom,path:[`tree`,...n],message:e}])}function T(e){return new t.ZodError(e.issues.map(e=>({...e,path:[`tree`,...e.path]})))}function E(e){if(!(typeof e!=`number`||!Number.isInteger(e))&&!(e<1||e>6))return e}async function D(e,t,n){let r=E(e.maxDepth);if(r!==void 0)return r;if(!n)return 3;if(!t?.loadCurrent)throw Error(`menu: BehaviorContext.loadCurrent is unavailable — cannot validate the tree against the menu's maxDepth. Writes must go through AdminClient.update.`);return E((await t.loadCurrent())?.maxDepth)??3}async function O(e,t){let n=E(e.maxDepth);if(n===void 0)return;if(!t?.loadCurrent)throw Error(`menu: BehaviorContext.loadCurrent is unavailable — cannot validate the stored tree against the new maxDepth. Writes must go through AdminClient.update.`);let{depth:r}=h((await t.loadCurrent())?.tree);if(r>n)throw w(`the stored menu tree is nested ${r} levels deep, deeper than this menu's new maxDepth (${n}) — flatten the tree first`)}async function k(e,t,n,r){let i=e.tree;if(i===void 0)return n&&await O(e,t),e;if(i===null||!Array.isArray(i))throw w(`menu tree must be an array of menu items`);let{depth:a,nodeCount:o}=h(i);if(o>500)throw w(`menu tree has too many items (max 500)`);if(a>6)throw w(`menu tree is nested too deeply (absolute max 6 levels)`);let s=m({allowedLocales:r()}).safeParse(i);if(!s.success)throw T(s.error);let c=await D(e,t,n);if(a>c)throw w(`menu tree is nested deeper than this menu's maxDepth (${c})`);return{...e,tree:s.data}}function A(e){let t=e?.resolveAllowedLocales??(()=>n().locales.map(e=>e.code));return{name:`menuTreeValidation`,hooks:{beforeCreate:async(e,n)=>k(e,n,!1,t),beforeUpdate:async(e,n,r)=>k(n,r,!0,t)}}}const j=i({name:`menu`,site:`bound`,admin:{label:`Menus`,icon:`menu`},fields:{handle:a.slug({from:`title`}),title:a.text({required:!0}),maxDepth:a.number({default:3,min:1,max:6,integer:!0}),tree:a.json({refScan:!0,default:[]}),entityTypes:a.json({default:null})},behaviors:[r(),o(),A(),C()],scope:`global`,access:{view:`public`,create:`group.admin`,update:`group.admin`,delete:`group.admin`}});function M(e,t){return e[t]}export{d as a,c,h as d,g as f,u as i,p as l,j as n,l as o,y as r,s,M as t,m as u};
2
- //# sourceMappingURL=columns-DwDukx_r.mjs.map
@@ -1,2 +1 @@
1
1
  var e={PluginNav:{menu:`Menus`},Menus:{entity:{menu:`Menu`,menus:`Menus`,handle:`Handle`,title:`Title`,maxDepth:`Maximum depth`,tree:`Items`},itemKind:{link:`Link`,header:`Header`},translatableSource:{menu:`Menus`}}};export{e as default};
2
- //# sourceMappingURL=en-BcniJZiB.mjs.map
@@ -1,2 +1 @@
1
1
  import{m as e,n as t,p as n}from"./menu-entity-types-BnKnKbif.mjs";import{z as r}from"zod";import{getContentConfig as i}from"@murumets-ee/content/plugin";import{auditable as a,defineEntity as o,field as s,publishable as c}from"@murumets-ee/entity";function l(e){if(e.keys.includes(`entityTypes`))throw new r.ZodError([{code:`custom`,path:[`entityTypes`],message:`bulk update on '${e.entityName}' cannot write 'entityTypes': updateMany runs no per-row hooks, so the whitelist would bypass its schema check. A whitelist is per-menu — update each row individually, or write a migration for a one-off backfill.`}])}function u(e){return new r.ZodError(e.issues.map(e=>({...e,path:[`entityTypes`,...e.path]})))}function d(e){if(!(`entityTypes`in e))return e;let n=t.safeParse(e.entityTypes);if(!n.success)throw u(n.error);return{...e,entityTypes:n.data}}function f(){return{name:`menuEntityTypesValidation`,hooks:{beforeCreate:async e=>d(e),beforeUpdate:async(e,t)=>d(t)},assertBulkUpdate:l}}function p(e,t=[]){return new r.ZodError([{code:r.ZodIssueCode.custom,path:[`tree`,...t],message:e}])}function m(e){return new r.ZodError(e.issues.map(e=>({...e,path:[`tree`,...e.path]})))}function h(e){if(!(typeof e!=`number`||!Number.isInteger(e))&&!(e<1||e>6))return e}async function g(e,t,n){let r=h(e.maxDepth);if(r!==void 0)return r;if(!n)return 3;if(!t?.loadCurrent)throw Error(`menu: BehaviorContext.loadCurrent is unavailable — cannot validate the tree against the menu's maxDepth. Writes must go through AdminClient.update.`);return h((await t.loadCurrent())?.maxDepth)??3}async function _(t,n){let r=h(t.maxDepth);if(r===void 0)return;if(!n?.loadCurrent)throw Error(`menu: BehaviorContext.loadCurrent is unavailable — cannot validate the stored tree against the new maxDepth. Writes must go through AdminClient.update.`);let{depth:i}=e((await n.loadCurrent())?.tree);if(i>r)throw p(`the stored menu tree is nested ${i} levels deep, deeper than this menu's new maxDepth (${r}) — flatten the tree first`)}async function v(t,r,i,a){let o=t.tree;if(o===void 0)return i&&await _(t,r),t;if(o===null||!Array.isArray(o))throw p(`menu tree must be an array of menu items`);let{depth:s,nodeCount:c}=e(o);if(c>500)throw p(`menu tree has too many items (max 500)`);if(s>6)throw p(`menu tree is nested too deeply (absolute max 6 levels)`);let l=n({allowedLocales:a()}).safeParse(o);if(!l.success)throw m(l.error);let u=await g(t,r,i);if(s>u)throw p(`menu tree is nested deeper than this menu's maxDepth (${u})`);return{...t,tree:l.data}}function y(e){let t=e?.resolveAllowedLocales??(()=>i().locales.map(e=>e.code));return{name:`menuTreeValidation`,hooks:{beforeCreate:async(e,n)=>v(e,n,!1,t),beforeUpdate:async(e,n,r)=>v(n,r,!0,t)}}}const b=o({name:`menu`,site:`bound`,admin:{label:`Menus`,icon:`menu`},fields:{handle:s.slug({from:`title`}),title:s.text({required:!0}),maxDepth:s.number({default:3,min:1,max:6,integer:!0}),tree:s.json({refScan:!0,default:[]}),entityTypes:s.json({default:null})},behaviors:[a(),c(),y(),f()],scope:`global`,access:{view:`public`,create:`group.admin`,update:`group.admin`,delete:`group.admin`}});export{y as n,f as r,b as t};
2
- //# sourceMappingURL=entity-BC8r_5xy.mjs.map
@@ -1,2 +1 @@
1
1
  var e={PluginNav:{menu:`Menüüd`},Menus:{entity:{menu:`Menüü`,menus:`Menüüd`,handle:`Tunnus`,title:`Pealkiri`,maxDepth:`Maksimaalne sügavus`,tree:`Elemendid`},itemKind:{link:`Link`,header:`Pealkiri`},translatableSource:{menu:`Menüüd`}}};export{e as default};
2
- //# sourceMappingURL=et-BL3ZuuFV.mjs.map
package/dist/i18n.d.mts CHANGED
@@ -13,4 +13,3 @@
13
13
  declare function getMenusMessages(locale: string): Promise<Record<string, unknown>>;
14
14
  //#endregion
15
15
  export { getMenusMessages };
16
- //# sourceMappingURL=i18n.d.mts.map
package/dist/i18n.mjs CHANGED
@@ -1,2 +1 @@
1
1
  async function e(e){switch(e){case`et`:return(await import(`./et-BL3ZuuFV.mjs`)).default;case`ru`:return(await import(`./ru-D8QNW6XE.mjs`)).default;default:return(await import(`./en-BcniJZiB.mjs`)).default}}export{e as getMenusMessages};
2
- //# sourceMappingURL=i18n.mjs.map
package/dist/index.d.mts CHANGED
@@ -72,4 +72,3 @@ declare const Menu: import("@murumets-ee/entity").Entity<{
72
72
  }, "menu", [import("@murumets-ee/entity").Behavior<import("@murumets-ee/entity").AuditableFields, string>, import("@murumets-ee/entity").Behavior<import("@murumets-ee/entity").PublishableFields, "publishable">, import("@murumets-ee/entity").Behavior<Record<never, never>, "menuTreeValidation">, import("@murumets-ee/entity").Behavior<Record<never, never>, "menuEntityTypesValidation">]>;
73
73
  //#endregion
74
74
  export { DEFAULT_MENU_DEPTH, LinkValueSchema, MAX_MENU_DEPTH, MAX_MENU_ENTITY_TYPE_WHITELIST, MAX_MENU_TREE_NODES, MAX_MENU_URL_LENGTH, Menu, MenuEntityTypesSchema, type MenuItem, type MenuLinkValue, type MenuSchemaOptions, type MenuTreeMeasurement, type MenuTreeValidationOptions, buildMenuItemSchema, buildMenuTreeSchema, measureMenuTree, menuAcceptsEntityType, menuEntityTypesValidation, menuTreeValidation, normalizeMenuEntityTypes, validateTreeDepth };
75
- //# sourceMappingURL=index.d.mts.map
@@ -1,2 +1 @@
1
1
  import{z as e}from"zod";import{sanitizeHref as t}from"@murumets-ee/blocks/links";const n=6,r=3,i=500,a=2048,o=128,s=/^[A-Za-z0-9_-]+$/,c=e.discriminatedUnion(`kind`,[e.object({kind:e.literal(`entity`),entity:e.string().min(1).max(64),id:e.string().uuid(),anchor:e.string().min(1).max(128).regex(s).optional()}),e.object({kind:e.literal(`url`),url:e.string().min(1).max(a).transform(e=>t(e)).refine(e=>e!==``,{message:`link url uses a disallowed protocol (allowed: http, https, mailto, tel, or a relative path)`})}),e.object({kind:e.literal(`email`),email:e.string().email().max(254)})]);function l(t,n){let r=new Set(n);return e.unknown().superRefine((t,n)=>{if(typeof t!=`object`||!t||Array.isArray(t))return;let i=Object.keys(t);if(i.length>r.size){n.addIssue({code:e.ZodIssueCode.custom,message:`too many locale keys — configured locales are: ${[...r].join(`, `)}`});return}for(let t of i)r.has(t)||n.addIssue({code:e.ZodIssueCode.custom,path:[t],message:`unknown locale key — configured locales are: ${[...r].join(`, `)}`})}).pipe(e.record(e.string(),t))}function u(t){let n=l(e.string().max(200),t.allowedLocales),r=l(e.boolean(),t.allowedLocales),i=e.lazy(()=>e.object({id:e.string().min(1).max(64),kind:e.enum([`link`,`header`]),link:c.optional(),label:n.nullable().default(null),enabled:r.optional(),newTab:e.boolean().optional(),children:e.array(i).default([])}).superRefine((t,n)=>{if(t.kind===`link`){t.link===void 0&&n.addIssue({code:e.ZodIssueCode.custom,path:[`link`],message:`a link item requires a link target`});return}t.link!==void 0&&n.addIssue({code:e.ZodIssueCode.custom,path:[`link`],message:`a header item must not carry a link target`}),(t.label===null||Object.keys(t.label).length===0)&&n.addIssue({code:e.ZodIssueCode.custom,path:[`label`],message:`a header item requires a label for at least one locale`})}));return i}function d(t){return e.array(u(t))}function f(e){if(!Array.isArray(e))return{depth:0,nodeCount:0};let t=0,n=0,r=e.map(e=>({node:e,depth:1}));for(;r.length>0;){let e=r.pop();if(e===void 0)break;let{node:i,depth:a}=e;if(typeof i!=`object`||!i||Array.isArray(i))continue;if(n++,a>t&&(t=a),n>500||t>6)return{depth:t,nodeCount:n};let o=i.children;if(Array.isArray(o)){let e=o;for(let t of e)r.push({node:t,depth:a+1})}}return{depth:t,nodeCount:n}}function p(e,t){return f(e).depth<=t}const m=64,h=e.string().min(1).max(64),g=e.array(h).max(64).nullable().transform(e=>{if(e===null)return null;let t=[...new Set(e)];return t.length===0?null:t});function _(e){if(!Array.isArray(e))return null;let t=e,n=[],r=new Set;for(let e of t)if(!(typeof e!=`string`||e.length===0)&&!(e.length>64)&&!r.has(e)&&(r.add(e),n.push(e),n.length===64))break;return n.length===0?null:n}function v(e,t){let n=_(e);return n===null||n.includes(t)}export{s as a,o as c,a as d,u as f,p as h,_ as i,n as l,f as m,g as n,r as o,d as p,v as r,c as s,m as t,i as u};
2
- //# sourceMappingURL=menu-entity-types-BnKnKbif.mjs.map
@@ -293,4 +293,3 @@ declare function measureMenuTree(tree: unknown): MenuTreeMeasurement;
293
293
  declare function validateTreeDepth(tree: unknown, maxDepth: number): boolean;
294
294
  //#endregion
295
295
  export { validateTreeDepth as _, MAX_ANCHOR_LENGTH as a, menuAcceptsEntityType as b, MAX_MENU_URL_LENGTH as c, MenuLinkValue as d, MenuSchemaOptions as f, measureMenuTree as g, buildMenuTreeSchema as h, LinkValueSchema as i, MenuItem as l, buildMenuItemSchema as m, BlockAnchor as n, MAX_MENU_DEPTH as o, MenuTreeMeasurement as p, DEFAULT_MENU_DEPTH as r, MAX_MENU_TREE_NODES as s, ANCHOR_PATTERN as t, MenuItemSummary as u, MAX_MENU_ENTITY_TYPE_WHITELIST as v, normalizeMenuEntityTypes as x, MenuEntityTypesSchema as y };
296
- //# sourceMappingURL=menu-item-schema-_Nj03ytp.d.mts.map
@@ -1,2 +1 @@
1
1
  import{n as e,t}from"./columns-DwDukx_r.mjs";import{getContentConfig as n}from"@murumets-ee/content/plugin";import{createSystemReachResolver as r}from"@murumets-ee/core";import{eq as i,inArray as a}from"drizzle-orm";import{getEntityTitleField as o,isTemplateDrawn as s}from"@murumets-ee/entity";import{QueryClient as c}from"@murumets-ee/entity/query";import{createDetailTemplateProvider as l}from"@murumets-ee/content/detail-template";import{buildCanonicalPathsBatchDeps as u,buildEnumerateDeps as d,getCanonicalPaths as f}from"@murumets-ee/content/resolve";const p=r(`menu-resolver`);function m(r){let m=r.db.readOnly,h=u(r),{filterPublished:g}=d(r);return{getMenuRow:async n=>{let a=new c({entity:e,db:m,logger:r.logger.child({resolve:`menu`}),contextResolver:p}),o=(await a.findMany({where:i(t(a.getTable(),`handle`),n),limit:1}))[0];return o===void 0?null:{maxDepth:o.maxDepth,tree:o.tree}},resolveCanonicalPaths:(e,t,n)=>f(e,t,n,h),filterPublished:g,resolveTitles:async(e,i,u)=>{let d=new Map;if(i.length===0)return d;let f=r.entities.get(e);if(f===void 0)return d;let h=s(f)?l(()=>r,{defaultLocale:n().defaultLocale}):void 0,g=o(f);if(h===void 0&&g===`id`)return d;let _=new c({entity:f,db:m,logger:r.logger.child({resolve:`menu-title`,entity:e}),contextResolver:p}),v=t(_.getTable(),`id`);for(let t=0;t<i.length;t+=500){let n=i.slice(t,t+500),o=await _.findMany({where:a(v,[...n]),locale:u,limit:n.length});for(let t of o){let n=t,i=n.id,a;if(h===void 0)a=n[g];else try{a=(await h.publicRow({entity:e,row:n,locale:u})).title}catch(t){r.logger.warn({err:t,entity:e},`menus: detail-template title projection failed — the item is dropped`)}typeof i==`string`&&typeof a==`string`&&a.length>0&&d.set(i,a)}}return d},defaultLocale:n().defaultLocale,logger:r.logger.child({resolve:`menu`})}}export{m as buildMenuResolveDeps};
2
- //# sourceMappingURL=menu-resolve-context-DLIUP8RY.mjs.map
@@ -202,4 +202,3 @@ declare function getMenu(handle: string, opts: GetMenuOptions, deps: MenuResolve
202
202
  declare function buildMenuResolveDeps(app: ToolkitApp): MenuResolveDeps;
203
203
  //#endregion
204
204
  export { type GetMenuOptions, MAX_MENU_ENTITY_TYPES, type MenuResolveDeps, type MenuResolveLogger, type MenuRow, type ResolvedMenu, type ResolvedMenuItem, buildMenuResolveDeps, getMenu, markActiveTrail };
205
- //# sourceMappingURL=resolve.d.mts.map
package/dist/resolve.mjs CHANGED
@@ -1,2 +1 @@
1
1
  import{s as e}from"./menu-entity-types-BnKnKbif.mjs";import{t}from"./entity-BC8r_5xy.mjs";import{resolveLinkHref as n}from"@murumets-ee/blocks/links";import{getContentConfig as r}from"@murumets-ee/content/plugin";import{getEntityTitleField as i,isTemplateDrawn as a}from"@murumets-ee/entity";import{createDetailTemplateProvider as o}from"@murumets-ee/content/detail-template";import{buildCanonicalPathsBatchDeps as s,buildEnumerateDeps as c,getCanonicalPaths as l}from"@murumets-ee/content/resolve";import{createSystemReachResolver as u}from"@murumets-ee/core";import{QueryClient as d}from"@murumets-ee/entity/query";import{eq as f,inArray as p}from"drizzle-orm";const m=16;function h(e,t,n){if(typeof e!=`object`||!e||Array.isArray(e))return;let r=e;for(let e of t===n?[t]:[t,n]){if(!Object.hasOwn(r,e))continue;let t=r[e];if(typeof t==`string`&&t.length>0)return t}}function g(e,t){if(typeof e!=`object`||!e||Array.isArray(e))return;let n=e;if(!Object.hasOwn(n,t))return;let r=n[t];return typeof r==`boolean`?r:void 0}function _(e){return typeof e==`object`&&!!e&&!Array.isArray(e)}function v(t,n,r,i){let a=new Map,o=new Map,s=500,c=0;function l(e,t,n){let r=e.get(t);r?r.add(n):e.set(t,new Set([n]))}function u(t,d){if(d>i||!Array.isArray(t))return[];let f=t,p=[];for(let t of f){if(s<=0){c++;continue}if(!_(t))continue;let i=t.id,f=t.kind;if(typeof i!=`string`||i.length===0||f!==`link`&&f!==`header`||g(t.enabled,n)===!1)continue;s--;let m;if(f===`link`){let n=e.safeParse(t.link);if(!n.success)continue;m=n.data}else if(t.link!==void 0)continue;let v=u(t.children,d+1);m?.kind===`entity`&&(l(a,m.entity,m.id),h(t.label,n,r)===void 0&&l(o,m.entity,m.id)),p.push({id:i,kind:f,link:m,label:t.label,newTab:t.newTab===!0,children:v})}return p}return{nodes:u(t,1),idsByType:a,titleIdsByType:o,droppedForBudget:c}}async function y(e,t,n,r){let i=new Map,a=new Map,o=new Map,s=[...e.idsByType.entries()];return s.length>16&&n.logger?.warn({handle:r,locale:t,entityTypes:s.length,cap:16},`menu resolution dropped entity-type groups over the fan-out cap`),await Promise.all(s.slice(0,16).map(async([r,s])=>{let c=[...s],l=await n.resolveCanonicalPaths(r,c,t);if(i.set(r,l),l.size===0)return;let u=[...l.keys()],d=await n.filterPublished(r,u,t);a.set(r,d);let f=e.titleIdsByType.get(r);if(f===void 0)return;let p=u.filter(e=>f.has(e)&&d.has(e));p.length!==0&&o.set(r,await n.resolveTitles(r,p,t))})),{paths:i,published:a,titles:o}}function b(e,t,r,i){let a=[];for(let o of e){let e=b(o.children,t,r,i),s=h(o.label,t,i.defaultLocale);if(o.kind===`header`){if(e.length===0||s===void 0)continue;a.push({id:o.id,kind:`header`,label:s,href:null,newTab:!1,isActive:!1,inActiveTrail:!1,children:e});continue}let c=o.link;if(c===void 0)continue;let l,u=s;if(c.kind===`entity`){let e=r.paths.get(c.entity)?.get(c.id);if(e===void 0||r.published.get(c.entity)?.has(c.id)!==!0)continue;l=c.anchor===void 0?e:`${e}#${c.anchor}`,u??=r.titles.get(c.entity)?.get(c.id)}else l=n(c);l===null||l.length===0||u===void 0||u.length===0||a.push({id:o.id,kind:`link`,label:u,href:l,newTab:o.newTab,isActive:!1,inActiveTrail:!1,children:e})}return a}function x(e){let t=e.indexOf(`#`),n=t===-1?e:e.slice(0,t);return n.length>1&&n.endsWith(`/`)?n.slice(0,-1):n}function S(e,t){let n=!1;return{items:e.map(e=>{let r=S(e.children,t),i=e.href!==null&&x(e.href)===t,a=r.containsActive;return(i||a)&&(n=!0),{...e,isActive:i,inActiveTrail:a,children:r.items}}),containsActive:n}}function C(e,t){return{...e,items:S(e.items,x(t)).items}}async function w(e,t,n){if(e.length===0||e.length>128)return null;let r=await n.getMenuRow(e);if(r===null)return null;let i=r.maxDepth,a=typeof i==`number`&&Number.isInteger(i)&&i>=1?Math.min(i,6):3,o=v(r.tree,t.locale,n.defaultLocale,a);o.droppedForBudget>0&&n.logger?.warn({handle:e,locale:t.locale,dropped:o.droppedForBudget,cap:500},`menu resolution dropped items over the node budget`);let s=await y(o,t.locale,n,e),c={handle:e,items:b(o.nodes,t.locale,s,n)};return t.currentPath===void 0?c:C(c,t.currentPath)}function T(e,t){return e[t]}const E=u(`menu-resolver`);function D(e){let n=e.db.readOnly,u=s(e),{filterPublished:m}=c(e);return{getMenuRow:async r=>{let i=new d({entity:t,db:n,logger:e.logger.child({resolve:`menu`}),contextResolver:E}),a=(await i.findMany({where:f(T(i.getTable(),`handle`),r),limit:1}))[0];return a===void 0?null:{maxDepth:a.maxDepth,tree:a.tree}},resolveCanonicalPaths:(e,t,n)=>l(e,t,n,u),filterPublished:m,resolveTitles:async(t,s,c)=>{let l=new Map;if(s.length===0)return l;let u=e.entities.get(t);if(u===void 0)return l;let f=a(u)?o(()=>e,{defaultLocale:r().defaultLocale}):void 0,m=i(u);if(f===void 0&&m===`id`)return l;let h=new d({entity:u,db:n,logger:e.logger.child({resolve:`menu-title`,entity:t}),contextResolver:E}),g=T(h.getTable(),`id`);for(let n=0;n<s.length;n+=500){let r=s.slice(n,n+500),i=await h.findMany({where:p(g,[...r]),locale:c,limit:r.length});for(let n of i){let r=n,i=r.id,a;if(f===void 0)a=r[m];else try{a=(await f.publicRow({entity:t,row:r,locale:c})).title}catch(n){e.logger.warn({err:n,entity:t},`menus: detail-template title projection failed — the item is dropped`)}typeof i==`string`&&typeof a==`string`&&a.length>0&&l.set(i,a)}}return l},defaultLocale:r().defaultLocale,logger:e.logger.child({resolve:`menu`})}}export{m as MAX_MENU_ENTITY_TYPES,D as buildMenuResolveDeps,w as getMenu,C as markActiveTrail};
2
- //# sourceMappingURL=resolve.mjs.map
@@ -1,2 +1 @@
1
1
  var e={PluginNav:{menu:`Меню`},Menus:{entity:{menu:`Меню`,menus:`Меню`,handle:`Идентификатор`,title:`Название`,maxDepth:`Максимальная глубина`,tree:`Элементы`},itemKind:{link:`Ссылка`,header:`Заголовок`},translatableSource:{menu:`Меню`}}};export{e as default};
2
- //# sourceMappingURL=ru-D8QNW6XE.mjs.map
package/dist/schema.d.mts CHANGED
@@ -277,4 +277,3 @@ declare function replaceMenuItem(items: readonly MenuItem[], id: string, next: M
277
277
  declare function insertMenuItem(items: readonly MenuItem[], node: MenuItem, afterId: string | null): MenuItem[];
278
278
  //#endregion
279
279
  export { ANCHOR_PATTERN, type BlockAnchor, DEFAULT_MENU_DEPTH, type FlatMenuNode, LinkValueSchema, MAX_ANCHOR_LENGTH, MAX_MENU_DEPTH, MAX_MENU_ENTITY_TYPE_WHITELIST, MAX_MENU_TREE_NODES, MAX_MENU_URL_LENGTH, type MenuEntityRef, type MenuEntityTarget, MenuEntityTypesSchema, type MenuItem, type MenuItemMatch, type MenuItemSummary, type MenuLinkValue, type MenuNodeData, type MenuSchemaOptions, type MenuTreeMeasurement, type NormalizedMenuTree, type ProjectDropInput, type ProjectedDrop, type TreeOpResult, buildMenuItemSchema, buildMenuTree, buildMenuTreeSchema, collectDescendantIds, collectMenuEntityRefs, depthCapForDrag, findMenuItem, findMenuItemsByTarget, flattenMenuTree, getDragDepth, hideCollapsedRows, indentMenuItem, insertMenuItem, measureMenuTree, menuAcceptsEntityType, moveMenuItem, normalizeMenuEntityTypes, normalizeMenuTree, outdentMenuItem, projectDrop, removeMenuItem, replaceMenuItem, subtreeHeight, validateTreeDepth };
280
- //# sourceMappingURL=schema.d.mts.map
package/dist/schema.mjs CHANGED
@@ -1,2 +1 @@
1
1
  import{a as e,c as t,d as n,f as r,h as i,i as a,l as o,m as s,n as c,o as l,p as u,r as d,s as f,t as p,u as m}from"./menu-entity-types-BnKnKbif.mjs";function h(e){let t=[],n=(e,r,i)=>{e.forEach((e,a)=>{let{children:o,...s}=e;t.push({id:e.id,parentId:r,depth:i,index:a,childCount:o.length,data:s}),n(o,e.id,i+1)})};return n(e,null,0),t}function g(e){let t=new Map;for(let n of e)t.set(n.id,{...n.data,children:[]});let n=[];for(let r of e){let e=t.get(r.id);if(e===void 0)continue;let i=r.parentId===null?void 0:t.get(r.parentId);i===void 0?n.push(e):i.children.push(e)}return n}function _(e,t){return t<=0?0:Math.round(e/t)}function v(e){let t=0;for(let n of e.children){let e=v(n);e>t&&(t=e)}return t+1}function y(e,t){return Math.max(Math.min(Math.max(Math.trunc(e),1),6)-Math.max(t,1),0)}function b(e,t){let n=new Set([t]);for(let t of e)t.parentId!==null&&n.has(t.parentId)&&n.add(t.id);return n.delete(t),n}function x(e,t){if(t.size===0)return e;let n=new Set,r=[];for(let i of e){if(i.parentId!==null&&(n.has(i.parentId)||t.has(i.parentId))){n.add(i.id);continue}r.push(i)}return r}function S({items:e,activeId:t,requestedDepth:n,depthCap:r}){let i=e.findIndex(e=>e.id===t);if(i===-1)return{depth:0,parentId:null,clampedByMaxDepth:!1};let a=e[i];if(a===void 0)return{depth:0,parentId:null,clampedByMaxDepth:!1};let o=e[i-1],s=e[i+1],c=s===void 0?0:s.depth;if(o===void 0)return{depth:0,parentId:null,clampedByMaxDepth:!1};let l=Math.min(a.depth+1,o.depth+1),u=Math.min(Math.max(Math.min(n,l),c),r);u=Math.min(Math.max(u,0),r);let d=n>r&&r<l;return{depth:u,parentId:C(e,i,u),clampedByMaxDepth:d}}function C(e,t,n){let r=e[t-1];if(n===0||r===void 0)return null;if(n===r.depth)return r.parentId;if(n>r.depth)return r.id;for(let r=t-1;r>=0;r--){let t=e[r];if(t!==void 0&&t.depth===n)return t.parentId}return null}function w(e){return typeof e==`object`&&!!e&&!Array.isArray(e)}function T(e,t){if(!w(e))return;let n={};for(let[r,i]of Object.entries(e))t(i)&&(n[r]=i);return n}const E=e=>typeof e==`string`,D=e=>typeof e==`boolean`;function O(e){let t=500,n=0;function r(e,i){if(!Array.isArray(e))return[];let a=e;if(i>6)return n+=a.length,[];let o=[];for(let e of a){if(!w(e)){n++;continue}let a=e.id,s=e.kind;if(typeof a!=`string`||a.length===0){n++;continue}if(s!==`link`&&s!==`header`){n++;continue}let c;if(s===`link`){let t=f.safeParse(e.link);if(!t.success){n++;continue}c=t.data}else if(e.link!==void 0){n++;continue}if(t<=0){n++;continue}t--;let l=T(e.label,E),u=l!==void 0&&Object.keys(l).length>0?l:void 0,d=T(e.enabled,D),p=d!==void 0&&Object.keys(d).length>0?d:void 0;o.push({id:a,kind:s,link:c,label:u??null,enabled:p,newTab:typeof e.newTab==`boolean`?e.newTab:void 0,children:r(e.children,i+1)})}return o}return{items:r(e,1),dropped:n}}function k(e){let t=new Set,n=[];function r(e){for(let i of e){let e=i.link;if(e?.kind===`entity`){let r=`${e.entity}:${e.id}`;t.has(r)||(t.add(r),n.push({entity:e.entity,id:e.id}))}r(i.children)}}return r(e),n}const A={ok:!1,reason:`noop`},j={ok:!1,reason:`max-depth`};function M(e){return Math.min(Math.max(Math.trunc(e),1),6)}function N(e,t,n){let r=e.findIndex(e=>e.id===t);if(r!==-1){let t=r+n;if(t<0||t>=e.length)return A;let i=e.slice(),a=i.splice(r,1);return i.splice(t,0,...a),{ok:!0,tree:i}}return H(e,e=>N(e,t,n))}function P(e,t,n,r=1){let i=e.findIndex(e=>e.id===t);if(i!==-1){if(i===0)return A;let t=e[i-1],a=e[i];if(t===void 0||a===void 0)return A;if(r+1+(v(a)-1)>M(n))return j;let o=e.slice();return o.splice(i,1),o[i-1]={...t,children:[...t.children,a]},{ok:!0,tree:o}}return H(e,e=>P(e,t,n,r+1))}function F(e,t){for(let n=0;n<e.length;n++){let r=e[n];if(r===void 0)continue;let i=r.children.findIndex(e=>e.id===t);if(i!==-1){let t=r.children[i];if(t===void 0)continue;let a=r.children.slice();a.splice(i,1);let o=e.slice();return o[n]={...r,children:a},o.splice(n+1,0,t),{ok:!0,tree:o}}let a=F(r.children,t);if(a.ok){let t=e.slice();return t[n]={...r,children:a.tree},{ok:!0,tree:t}}}return A}function I(e,t){for(let n of e){if(n.id===t)return n;let e=I(n.children,t);if(e!==null)return e}return null}function L(e,t){let n=[],r=(e,i,a)=>{e.forEach((e,o)=>{let s=e.link;s?.kind===`entity`&&s.entity===t.entity&&s.id===t.id&&n.push({item:e,parentId:i,index:o,depth:a}),r(e.children,e.id,a+1)})};return r(e,null,0),n}function R(e,t){let n=[];for(let r of e){if(r.id===t)continue;let e=R(r.children,t);n.push(e===r.children?r:{...r,children:e})}return n.length===e.length&&n.every((t,n)=>t===e[n])?e:n}function z(e,t,n){let r=!1,i=e.map(e=>{if(e.id===t)return n===e?e:(r=!0,n);let i=z(e.children,t,n);return i===e.children?e:(r=!0,{...e,children:i})});return r?i:e}function B(e,t,n){return n===null?[...e,t]:V(e,t,n)??[...e,t]}function V(e,t,n){let r=e.findIndex(e=>e.id===n);if(r!==-1){let n=e.slice();return n.splice(r+1,0,t),n}for(let r=0;r<e.length;r++){let i=e[r];if(i===void 0)continue;let a=V(i.children,t,n);if(a!==null){let t=e.slice();return t[r]={...i,children:a},t}}return null}function H(e,t){for(let n=0;n<e.length;n++){let r=e[n];if(r===void 0)continue;let i=t(r.children);if(i.ok){let t=e.slice();return t[n]={...r,children:i.tree},{ok:!0,tree:t}}if(i.reason===`max-depth`)return i}return A}export{e as ANCHOR_PATTERN,l as DEFAULT_MENU_DEPTH,f as LinkValueSchema,t as MAX_ANCHOR_LENGTH,o as MAX_MENU_DEPTH,p as MAX_MENU_ENTITY_TYPE_WHITELIST,m as MAX_MENU_TREE_NODES,n as MAX_MENU_URL_LENGTH,c as MenuEntityTypesSchema,r as buildMenuItemSchema,g as buildMenuTree,u as buildMenuTreeSchema,b as collectDescendantIds,k as collectMenuEntityRefs,y as depthCapForDrag,I as findMenuItem,L as findMenuItemsByTarget,h as flattenMenuTree,_ as getDragDepth,x as hideCollapsedRows,P as indentMenuItem,B as insertMenuItem,s as measureMenuTree,d as menuAcceptsEntityType,N as moveMenuItem,a as normalizeMenuEntityTypes,O as normalizeMenuTree,F as outdentMenuItem,S as projectDrop,R as removeMenuItem,z as replaceMenuItem,v as subtreeHeight,i as validateTreeDepth};
2
- //# sourceMappingURL=schema.mjs.map
package/dist/seed.d.mts CHANGED
@@ -64,4 +64,3 @@ declare function seedMainMenu(options?: {
64
64
  }>;
65
65
  //#endregion
66
66
  export { seedMainMenu };
67
- //# sourceMappingURL=seed.d.mts.map
package/dist/seed.mjs CHANGED
@@ -1,2 +1 @@
1
1
  import{t as e}from"./entity-BC8r_5xy.mjs";import{getApp as t}from"@murumets-ee/core";import{asc as n,eq as r}from"drizzle-orm";import{createAdminClient as i}from"@murumets-ee/core/clients";function a(e){let t=new WeakSet,n=e;for(;typeof n==`object`&&n;){if(t.has(n))return!1;t.add(n);let e=n;if(typeof e.code==`string`&&e.code===`23505`)return!0;n=e.cause}return!1}const o=`main`;async function s(e){if(!e)return[];let a=t().entities.get(e);if(!a)return[];let o=i(a),s=o.getTable(),c=s.status,l=s.createdAt,u=s.id;if(!c||!l||!u)return[];let d=c,f=l,p=u;return(await o.findMany({where:r(d,`published`),orderBy:[n(f),n(p)],limit:6})).map(t=>({id:crypto.randomUUID(),kind:`link`,link:{kind:`entity`,entity:e,id:t.id},label:null,children:[]}))}async function c(t){let n=i(e),c=n.getTable().handle;if(!c)throw Error("menus:main — the `menu` table has no `handle` column. This is @murumets-ee/menus' own entity, so this indicates a broken build or a stale dist rather than a project configuration problem.");let l=c;if((await n.findMany({where:r(l,o),limit:1})).length>0)return{created:0};let u=await s(t?.seedItemsFrom);try{await n.create({handle:o,title:`Main navigation`,tree:u,status:`published`,publishedAt:new Date})}catch(e){if(!a(e))throw e;return{created:0}}return{created:1}}export{c as seedMainMenu};
2
- //# sourceMappingURL=seed.mjs.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@murumets-ee/menus",
3
- "version": "0.109.0",
3
+ "version": "0.111.0",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "exports": {
@@ -35,10 +35,10 @@
35
35
  "dependencies": {
36
36
  "drizzle-orm": "^0.45.2",
37
37
  "zod": "^3.24.1",
38
- "@murumets-ee/blocks": "0.109.0",
39
- "@murumets-ee/content": "0.109.0",
40
- "@murumets-ee/core": "0.109.0",
41
- "@murumets-ee/entity": "0.109.0"
38
+ "@murumets-ee/blocks": "0.111.0",
39
+ "@murumets-ee/content": "0.111.0",
40
+ "@murumets-ee/core": "0.111.0",
41
+ "@murumets-ee/entity": "0.111.0"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@types/node": "^20.19.42",
@@ -46,8 +46,8 @@
46
46
  "tsdown": "^0.22.2",
47
47
  "typescript": "^5.7.3",
48
48
  "vitest": "^2.1.8",
49
- "@murumets-ee/content-api": "0.109.0",
50
- "@murumets-ee/db": "0.109.0"
49
+ "@murumets-ee/content-api": "0.111.0",
50
+ "@murumets-ee/db": "0.111.0"
51
51
  },
52
52
  "typeCoverage": {
53
53
  "atLeast": 100
@@ -1 +0,0 @@
1
- {"version":3,"file":"admin.d.mts","names":[],"sources":["../src/menu-item-schema.ts","../src/translatable-source.ts","../src/admin/editor-state.ts","../src/admin/routes.ts","../src/admin/summaries.ts"],"mappings":"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"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"admin.mjs","names":["MENU_SNAPSHOT_LOCALE","UUID_RE","MAX_HANDLE_LENGTH","isPlainObject","isPlainObject","record"],"sources":["../src/translatable-source.ts","../src/get-menu.ts","../src/menu-tree-normalize.ts","../src/admin/anchors.ts","../src/admin/summaries.ts","../src/admin/routes.ts"],"sourcesContent":["/**\n * Menu labels as a translatable source (agent-access D019).\n *\n * A menu is not a translatable entity: its per-locale labels live INSIDE the\n * one `tree` JSON every locale shares (D009), so a whole-site translation's\n * walk over translatable entity fields never reaches them. This module declares\n * them to `@murumets-ee/content`'s `'content.translatableSources'` slot instead,\n * which is how `@murumets-ee/translation` covers menus without depending on\n * this package.\n *\n * ## What it reads and writes\n *\n * The WORKING tree — the pending draft's `tree` when the menu has a draft that\n * stages one, else the live row's. That is the tree the menu editor opens, so a\n * translation lands on what an editor would see next. `write` saves into the\n * DRAFT (key `'_'`, like every menu draft) through the same `ContentClient.saveDraft`\n * the editor's \"Save draft\" reaches; it never publishes and never touches the\n * live row. Somebody presses Publish.\n *\n * ## Authority\n *\n * Every read and write goes through `app.getClient(Menu)` and a `ContentClient`\n * built on it — the request's own client, with the caller's checker. Nothing\n * here elevates.\n *\n * ## Why the walk takes `unknown`\n *\n * It reads and rewrites the STORED tree as it is, not the editor's tolerant\n * narrowing of it (`normalizeMenuTree`), which drops nodes it cannot read: a\n * write built from the narrowed tree would delete those nodes for good. An\n * unreadable node is carried through untouched, and the strict schema then\n * refuses the save if the tree as a whole is not storable.\n *\n * ⚠️ `ContentClient` and `LockService` are imported LAZILY, inside the methods.\n * Both are `server-only`-tagged and this module is on the `lumi.config.ts` load\n * path (the plugin's `contributes`), which the CLI evaluates through jiti —\n * see `loadPendingDraft` in `./admin/routes.ts` for the incident.\n */\n\nimport { createHash } from 'node:crypto'\nimport { getContentConfig } from '@murumets-ee/content/plugin'\nimport type {\n TranslatableSource,\n TranslatableSourceItem,\n TranslatableSourceRead,\n TranslatableSourceText,\n} from '@murumets-ee/content/translation'\nimport { getContext, getCurrentApp, type ToolkitApp } from '@murumets-ee/core'\nimport type { JsonValue } from '@murumets-ee/entity'\nimport { asc, gt } from 'drizzle-orm'\nimport { Menu } from './entity.js'\nimport { column } from './internal/columns.js'\nimport { buildMenuTreeSchema, MAX_LABEL_LENGTH, MAX_MENU_TREE_NODES } from './menu-item-schema.js'\n\n/** The key every menu draft and lock is stored under — see `MENU_SNAPSHOT_LOCALE` in `./admin/routes.ts`. */\nconst MENU_SNAPSHOT_LOCALE = '_'\n\n/** The largest page `list` returns, whatever the caller asks for. */\nexport const MAX_MENU_SOURCE_PAGE = 200\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\ntype RawNode = Record<string, unknown>\n\nfunction isRecord(value: unknown): value is RawNode {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\nfunction nonEmptyString(value: unknown): value is string {\n return typeof value === 'string' && value.trim() !== ''\n}\n\n/**\n * Every object node of a stored tree, parents before children, in order.\n *\n * Iterative and counted: a draft's `tree` is not held to the live row's bounds\n * until it is published, so the walk bounds itself rather than trusting them.\n */\nfunction menuNodes(tree: unknown): RawNode[] {\n const nodes: RawNode[] = []\n const pending: unknown[] = Array.isArray(tree) ? [...tree].reverse() : []\n while (pending.length > 0) {\n const node = pending.pop()\n if (!isRecord(node)) continue\n nodes.push(node)\n if (nodes.length > MAX_MENU_TREE_NODES) {\n throw new Error(`menu tree has more than ${MAX_MENU_TREE_NODES} items`)\n }\n if (Array.isArray(node.children)) {\n for (let i = node.children.length - 1; i >= 0; i--) pending.push(node.children[i])\n }\n }\n return nodes\n}\n\n/**\n * What identifies the tree a set of texts was read from: sha256 of its JSON.\n *\n * Any change to the tree — a label, an order, a new item — is a new revision,\n * so `write` can refuse to lay translations over a tree that moved under them.\n */\nexport function menuTreeRevision(tree: unknown): string {\n return createHash('sha256')\n .update(JSON.stringify(Array.isArray(tree) ? tree : []))\n .digest('hex')\n}\n\n/**\n * The labels of a stored tree that want a translation.\n *\n * An item contributes when its `label` is a MAP with a non-empty string for\n * `sourceLocale` and — in `'missing'` mode — no non-empty string yet for\n * `targetLocale`. `label: null` means \"use the linked entity's live title\",\n * which is that entity's text, not the menu's, and is never offered.\n */\nexport function collectMenuLabelTexts(\n tree: unknown,\n input: { sourceLocale: string; targetLocale: string; mode: 'missing' | 'all' },\n): TranslatableSourceText[] {\n const { sourceLocale, targetLocale, mode } = input\n // Translating a locale into itself would overwrite the source with its echo.\n if (sourceLocale === targetLocale) return []\n\n const texts: TranslatableSourceText[] = []\n const seen = new Set<string>()\n for (const node of menuNodes(tree)) {\n const { id, label } = node\n if (!nonEmptyString(id) || seen.has(id)) continue\n if (!isRecord(label)) continue\n const source = Object.hasOwn(label, sourceLocale) ? label[sourceLocale] : undefined\n if (!nonEmptyString(source)) continue\n const target = Object.hasOwn(label, targetLocale) ? label[targetLocale] : undefined\n if (mode === 'missing' && nonEmptyString(target)) continue\n seen.add(id)\n texts.push({ key: id, text: source, maxLength: MAX_LABEL_LENGTH })\n }\n return texts\n}\n\n/**\n * A copy of `tree` with `label[targetLocale]` set on each item named in\n * `texts`. The input is not modified.\n *\n * Only an item whose label is already a map takes a translation: a `null`\n * label stays `null` (it follows the linked entity's title in every locale),\n * and an id in `texts` that matches no such item is ignored. A text is trimmed;\n * an empty one is skipped; one longer than {@link MAX_LABEL_LENGTH} throws,\n * naming the item.\n */\nexport function applyMenuLabelTranslations(\n tree: unknown,\n targetLocale: string,\n texts: Readonly<Record<string, string>>,\n): { tree: unknown[]; applied: number } {\n const next: unknown[] = Array.isArray(tree) ? structuredClone(tree) : []\n let applied = 0\n for (const node of menuNodes(next)) {\n const { id, label } = node\n if (typeof id !== 'string' || !Object.hasOwn(texts, id)) continue\n if (!isRecord(label)) continue\n const raw = texts[id]\n if (typeof raw !== 'string') continue\n const text = raw.trim()\n if (text === '') continue\n if (text.length > MAX_LABEL_LENGTH) {\n throw new Error(\n `menu item ${id}: the translated label is longer than ${MAX_LABEL_LENGTH} characters`,\n )\n }\n // A computed key in a literal is always an OWN property — no assignment\n // through a locale string can reach the prototype.\n node.label = { ...label, [targetLocale]: text }\n applied++\n }\n return { tree: next, applied }\n}\n\n/**\n * A parsed tree as the value `field.json()` stores.\n *\n * `MenuItem`'s optional members are `T | undefined` (Zod's `.optional()`), and\n * `JsonValue` has no `undefined` arm, so a `MenuItem[]` is not assignable to\n * the column even though it serialises fine (`tests/stored-tree.ts` records\n * why). This builds the JSON value instead of casting to it: an `undefined`\n * member is left out, which is exactly what `JSON.stringify` would do.\n */\nfunction toJsonValue(value: unknown): JsonValue {\n if (value === null || typeof value === 'string' || typeof value === 'boolean') return value\n if (typeof value === 'number') return Number.isFinite(value) ? value : null\n if (Array.isArray(value)) return value.map(toJsonValue)\n if (isRecord(value)) {\n const out: { [key: string]: JsonValue } = {}\n for (const [key, member] of Object.entries(value)) {\n if (member !== undefined) out[key] = toJsonValue(member)\n }\n return out\n }\n return null\n}\n\n// ---------------------------------------------------------------------------\n// The database half\n// ---------------------------------------------------------------------------\n\n/** The slice of `ContentClient` this module uses — lets the loader below be typed without a static import. */\nasync function menuContentClient(app: ToolkitApp) {\n const { ContentClient } = await import('@murumets-ee/content/client')\n return new ContentClient({ admin: app.getClient(Menu), entity: Menu })\n}\n\ninterface WorkingMenu {\n /** The draft's `tree` when a draft stages one, else the live row's. */\n tree: unknown\n /** The pending draft's whole payload, or `undefined` with no draft. */\n draftData: Record<string, unknown> | undefined\n content: Awaited<ReturnType<typeof menuContentClient>>\n}\n\nasync function loadWorkingMenu(app: ToolkitApp, id: string): Promise<WorkingMenu | null> {\n if (!UUID_RE.test(id)) return null\n const row = (await app.getClient(Menu).findById(id)) as Record<string, unknown> | null\n if (!row) return null\n const content = await menuContentClient(app)\n const draft = await content.getDraft(id, MENU_SNAPSHOT_LOCALE)\n const draftData = isRecord(draft?.data) ? draft.data : undefined\n // Draft-then-live, the same precedence `menus/editor-state` applies.\n const tree = draftData !== undefined && 'tree' in draftData ? draftData.tree : row.tree\n return { tree, draftData, content }\n}\n\n/** One keyset page of menus, ordered by id. */\nexport async function listMenuSourceItems(\n app: ToolkitApp,\n input: { after?: string; limit: number },\n): Promise<TranslatableSourceItem[]> {\n if (input.after !== undefined && !UUID_RE.test(input.after)) {\n throw new Error('menu translatable source: `after` is not a menu id')\n }\n const limit = Number.isFinite(input.limit)\n ? Math.min(Math.max(Math.trunc(input.limit), 1), MAX_MENU_SOURCE_PAGE)\n : MAX_MENU_SOURCE_PAGE\n const client = app.getClient(Menu)\n const idColumn = column(client.getTable(), 'id')\n const rows = await client.findMany({\n ...(input.after !== undefined && { where: gt(idColumn, input.after) }),\n orderBy: asc(idColumn),\n limit,\n })\n return rows.map((row) => {\n const record = row as Record<string, unknown>\n return {\n id: record.id as string,\n label: typeof record.title === 'string' ? record.title : '',\n }\n })\n}\n\nexport async function readMenuSource(\n app: ToolkitApp,\n input: { id: string; sourceLocale: string; targetLocale: string; mode: 'missing' | 'all' },\n): Promise<TranslatableSourceRead | null> {\n const working = await loadWorkingMenu(app, input.id)\n if (working === null) return null\n return {\n revision: menuTreeRevision(working.tree),\n texts: collectMenuLabelTexts(working.tree, input),\n }\n}\n\n/**\n * Refuse unless the REQUEST'S own checker grants `menu:update`.\n *\n * The draft store is an infrastructure table: `ContentClient.saveDraft` asks no\n * permission of its own, and the generic draft route supplies the gate\n * (`requirePermission(…, 'update')`) before calling it. This write reaches the\n * same method without that route, so it carries the same gate — the engine's\n * check of {@link menuTranslatableSource}'s `permission` is the first, this is\n * the one that holds if a caller forgets it. Deny by default: no user, no role\n * or no checker in the context is a refusal, not a pass.\n */\nfunction assertMayUpdateMenus(): void {\n const context = getContext()\n const role = context?.user?.groups[0]\n if (role === undefined || context?.checker?.(role, Menu.name, 'update') !== true) {\n throw new Error('menu translatable source: the caller may not update menus')\n }\n}\n\n/**\n * Lay translated labels into the menu's DRAFT.\n *\n * `allowedLocales` is the site's content languages — the same set the menu's\n * own write path holds a label map's keys to.\n */\nexport async function writeMenuSource(\n app: ToolkitApp,\n input: {\n id: string\n targetLocale: string\n revision: string\n texts: Readonly<Record<string, string>>\n user: { id: string; name?: string }\n },\n allowedLocales: readonly string[],\n): Promise<'written' | 'changed' | 'not_found'> {\n assertMayUpdateMenus()\n const working = await loadWorkingMenu(app, input.id)\n if (working === null) return 'not_found'\n // The texts were read from another tree: an editor saved in between. Laying\n // them over this one could put a translation on a label that has since been\n // reworded, so nothing is written and the job reads again.\n if (menuTreeRevision(working.tree) !== input.revision) return 'changed'\n\n if (!allowedLocales.includes(input.targetLocale)) {\n throw new Error(\n `menu ${input.id}: the target locale is not one of the site's content languages (${allowedLocales.join(', ')})`,\n )\n }\n\n const { tree, applied } = applyMenuLabelTranslations(\n working.tree,\n input.targetLocale,\n input.texts,\n )\n // Nothing to lay down — do not open a draft that changes nothing.\n if (applied === 0) return 'written'\n\n // A draft write runs no entity hook, so `menuTreeValidation` does not see\n // it; the same schema is asked here. Storing the PARSE RESULT is what the\n // live write path does too (unknown keys stripped, urls sanitised).\n const parsed = buildMenuTreeSchema({ allowedLocales }).safeParse(tree)\n if (!parsed.success) {\n const issues = parsed.error.issues\n .slice(0, 5)\n .map((issue) => `${issue.path.join('.')}: ${issue.message}`)\n .join('; ')\n throw new Error(`menu ${input.id}: the translated tree is not valid — ${issues}`)\n }\n\n // \"Save draft\" REPLACES every staged change, so it is refused while another\n // person holds the menu's edit lock — the guard the draft route applies\n // (`assertNoLockHeldByOther`). Their editor autosaves its own tree each\n // pause and would silently drop these labels anyway.\n const { LockService } = await import('@murumets-ee/content/lock')\n const lock = await new LockService({ app }).getLock(Menu.name, input.id, MENU_SNAPSHOT_LOCALE)\n if (lock && lock.lockedBy !== input.user.id) {\n throw new Error(\n `menu ${input.id}: is being edited by ${lock.lockedByName ?? lock.lockedBy} — try again when they have finished`,\n )\n }\n\n // The same call the menu editor's \"Save draft\" reaches (`POST /draft` →\n // `contentClient.saveDraft`). It replaces the draft's data, so whatever the\n // draft already stages — a `title`, a `maxDepth`, `entityTypes` — is spread\n // back, exactly as `<MenuTreeEditor>` does.\n await working.content.saveDraft(\n input.id,\n { ...working.draftData, tree: toJsonValue(parsed.data) },\n MENU_SNAPSHOT_LOCALE,\n input.user,\n )\n return 'written'\n}\n\nfunction requireApp(): ToolkitApp {\n const app = getCurrentApp()\n if (app === undefined) {\n throw new Error('menu translatable source: called outside a request context')\n }\n return app\n}\n\n/** The request's enabled content languages, else the configured list — as `menuTreeValidation` resolves it. */\nfunction resolveAllowedLocales(): readonly string[] {\n const fromRequest = getContext()?.availableLocales\n if (fromRequest !== undefined && fromRequest.length > 0) return fromRequest\n return getContentConfig().locales.map((locale) => locale.code)\n}\n\n/**\n * Menu labels, for a whole-site translation. Contributed on the menus plugin's\n * `contributes['content.translatableSources']` via {@link menusContributions}.\n */\nexport const menuTranslatableSource: TranslatableSource = {\n kind: 'menu',\n label: 'Menus',\n labelKey: 'Menus.translatableSource.menu',\n permission: { resource: 'menu', action: 'update' },\n draft: true,\n list: (input) => listMenuSourceItems(requireApp(), input),\n read: (input) => readMenuSource(requireApp(), input),\n write: (input) => writeMenuSource(requireApp(), input, resolveAllowedLocales()),\n}\n\n/**\n * Everything `@murumets-ee/menus` contributes to other plugins' slots, as the\n * value of the menus plugin's `contributes` field.\n *\n * Declared HERE rather than inline in the factory because the factory lives in\n * `@murumets-ee/menus-ui/plugin` (this package stays react-free) while what is\n * contributed is server behaviour this package owns.\n */\nexport const menusContributions = {\n 'content.translatableSources': [menuTranslatableSource],\n} satisfies { 'content.translatableSources': readonly TranslatableSource[] }\n","/**\n * `getMenu()` — turn a stored menu tree into a render-ready structure for one\n * locale (plan/routing phase 11 §2), plus the pure `markActiveTrail()` helper\n * (phase 11 §3).\n *\n * **Leaf module.** Every cross-package capability (the menu row read, the\n * `toolkit_paths` forward index, the publish gate, live entity titles) arrives\n * through the injected {@link MenuResolveDeps}; nothing here imports\n * `@murumets-ee/core` or `@murumets-ee/content`. Concrete deps are built from a\n * live app in `./menu-resolve-context.ts` — the same leaf/seam split\n * `@murumets-ee/content`'s `paths/enumerate.ts` ↔ `paths/resolve-context.ts`\n * uses, and the reason the deps' function types are declared structurally here\n * rather than imported (assignability is still checked, at the seam).\n *\n * **Security.** The resolved output feeds PUBLIC page navigation, so it must\n * never surface an unpublished or deleted document:\n * - the menu itself is read through a publish-filtered client (an unpublished\n * menu resolves to `null` — nothing renders),\n * - every entity-linked item must have BOTH a canonical path in\n * `toolkit_paths` for the requested locale AND survive the batched publish\n * gate; missing either → the item (and its subtree) is dropped,\n * - `url` / `email` targets are re-gated through the same `sanitizeHref`\n * protocol allowlist the write path uses, so a value that reached the column\n * by some route other than the entity API (a direct DB edit, a restored\n * dump) still cannot become a `javascript:` href,\n * - every filtering decision fails CLOSED — anything unresolvable is dropped,\n * never rendered with a placeholder.\n *\n * **No unbounded fan-out** (CLAUDE.md). Backend work is bounded by the number\n * of distinct ENTITY TYPES in the tree, never by the number of items: the walk\n * collects ids grouped by type, then issues at most THREE batched calls per\n * group (forward index → publish gate → titles). The group count is itself hard-\n * capped ({@link MAX_MENU_ENTITY_TYPES}) and the walk carries a node budget\n * ({@link MAX_MENU_TREE_NODES}), so one render costs a fixed, small number of\n * round-trips no matter what the stored tree looks like.\n *\n * **Read-time validation is per-node, not whole-tree.** The write path already\n * runs `buildMenuTreeSchema()` (`menuTreeValidation()`), and this module\n * deliberately does NOT re-run it: that schema whitelists locale KEYS against\n * the app's configured locales, so re-parsing at read time would fail the WHOLE\n * menu — site navigation gone — the moment an operator removes a locale from\n * config after a menu was saved. The locale whitelist is a write-time authoring\n * check with no read-time security value. What DOES matter at read time (node\n * shape, the `LinkValue` protocol gate) is re-checked per node here, and a\n * malformed node is dropped on its own without taking the menu down with it.\n */\n\nimport { resolveLinkHref } from '@murumets-ee/blocks/links'\nimport {\n DEFAULT_MENU_DEPTH,\n LinkValueSchema,\n MAX_MENU_DEPTH,\n MAX_MENU_TREE_NODES,\n type MenuLinkValue,\n} from './menu-item-schema.js'\n\n// ---------------------------------------------------------------------------\n// Bounds\n// ---------------------------------------------------------------------------\n\n/**\n * Hard cap on the number of DISTINCT entity types one menu may fan out over.\n *\n * The batched pipeline costs three DB round-trips per type group, so this — not\n * the item count — is what bounds a render's backend work. A navigation menu\n * pointing at more than a handful of routable types is malformed by\n * construction; types past the cap are dropped (their items disappear) and\n * logged, never silently truncated.\n */\nexport const MAX_MENU_ENTITY_TYPES = 16\n\n/**\n * Longest `handle` accepted — rejected before any DB work. A defensive cap, not\n * a mirror of the entity's `handle` column (`varchar(255)`): a handle is a\n * hand-authored navigation key, so anything approaching this length is\n * malformed by construction.\n */\nconst MAX_HANDLE_LENGTH = 128\n\n// ---------------------------------------------------------------------------\n// Public shape (phase 11 §2)\n// ---------------------------------------------------------------------------\n\n/** One render-ready menu item (phase 11 §2 — exact shape). */\nexport interface ResolvedMenuItem {\n /** The stored item's stable id (DND identity; also a stable React key). */\n id: string\n kind: 'link' | 'header'\n /** Resolved label: the per-locale override, else the live entity title. */\n label: string\n /**\n * Locale-relative canonical path from `toolkit_paths` (+ `#anchor` when set),\n * external `url` / `email` targets verbatim (protocol-gated), `null` for\n * `header` items.\n */\n href: string | null\n newTab: boolean\n /** Exact `currentPath` match on the path part (any `#anchor` ignored). */\n isActive: boolean\n /** An ancestor BY TREE of the active item — never a path-prefix guess. */\n inActiveTrail: boolean\n children: ResolvedMenuItem[]\n}\n\n/**\n * A resolved menu.\n *\n * Phase 11 §2 names `ResolvedMenuItem` and uses `ResolvedMenu` as `getMenu`'s\n * return type without spelling its fields out; this is the minimal wrapper that\n * satisfies it — the requested `handle` (so a caller holding several resolved\n * menus can tell them apart, and so `markActiveTrail` can round-trip a menu\n * without losing its identity) plus the item forest. Deliberately carries no\n * `maxDepth` / `title`: the depth clamp is already APPLIED in `items`, and a\n * menu's admin-facing title is not a rendering concern.\n */\nexport interface ResolvedMenu {\n handle: string\n items: ResolvedMenuItem[]\n}\n\n/** Options for {@link getMenu} (phase 11 §2). */\nexport interface GetMenuOptions {\n /** The locale to resolve labels, visibility and canonical paths for. */\n locale: string\n /**\n * The page being rendered, for active-trail marking. SSG frontends usually\n * OMIT this (one build-time fetch) and call {@link markActiveTrail} per page.\n */\n currentPath?: string | undefined\n}\n\n// ---------------------------------------------------------------------------\n// Injected dependencies\n// ---------------------------------------------------------------------------\n\n/** The projected `menu` row {@link getMenu} reads. */\nexport interface MenuRow {\n /**\n * The menu's own depth cap. Absent / out of range falls back to the entity\n * default — the clamp is renderer-enforced independently of the editor\n * (phase 07 §2.7), so a bad stored value can never widen it.\n */\n maxDepth?: number | null | undefined\n /** Raw `menu.tree` JSONB. Untrusted shape — validated per node during the walk. */\n tree: unknown\n}\n\n/**\n * Structural minimum of a pino/toolkit logger. Declared here (rather than\n * imported) so this module stays import-free; `app.logger.child(...)` satisfies\n * it structurally.\n */\nexport interface MenuResolveLogger {\n warn(obj: object, msg: string): void\n}\n\n/**\n * Everything {@link getMenu} needs from the outside world.\n *\n * Function-shaped (not reader-shaped) on purpose: the concrete implementations\n * live in `./menu-resolve-context.ts`, where assignability against\n * `@murumets-ee/content`'s real `getCanonicalPaths` / `PublishFilter` is\n * type-checked at the seam — so this module needs no `@murumets-ee/content`\n * import and drift is still a build error.\n */\nexport interface MenuResolveDeps {\n /**\n * Fetch the PUBLISHED menu row by handle, or `null`.\n *\n * The publish filter is the implementation's responsibility (the app-side\n * factory reads through a publish-filtering `QueryClient`) — a draft menu\n * must never resolve, per phase 11 §2.\n */\n getMenuRow: (handle: string) => Promise<MenuRow | null>\n /**\n * Batched forward index read: which of `ids` have a canonical path in\n * `locale`? Does NOT publish-filter — {@link MenuResolveDeps.filterPublished}\n * is the required second step.\n */\n resolveCanonicalPaths: (\n entityType: string,\n ids: readonly string[],\n locale: string,\n ) => Promise<ReadonlyMap<string, string>>\n /** Batched publish gate: which of `ids` are published in `locale`? */\n filterPublished: (\n entityType: string,\n ids: readonly string[],\n locale: string,\n ) => Promise<ReadonlySet<string>>\n /**\n * Batched live-title read: `id → display title` for `locale`. Only called for\n * the ids that actually need one (no per-locale label override), and never\n * for `header` items (they have no linked entity to borrow a title from).\n */\n resolveTitles: (\n entityType: string,\n ids: readonly string[],\n locale: string,\n ) => Promise<ReadonlyMap<string, string>>\n /**\n * Fallback locale for label overrides — the app's configured content\n * `defaultLocale`. A label present only in the default locale still renders\n * (the same locale → default fallback `mergeTranslations` gives entity data),\n * instead of silently dropping a partially-translated item.\n */\n defaultLocale: string\n /** Optional — used to report DROPPED work (never silent truncation). */\n logger?: MenuResolveLogger | undefined\n}\n\n// ---------------------------------------------------------------------------\n// Locale-map reads (defensive: these run against raw JSONB)\n// ---------------------------------------------------------------------------\n\n/**\n * Read one locale's string out of a raw `label` map, falling back to\n * `defaultLocale`. Own-property reads only, so a `__proto__` / `constructor`\n * key in stored JSON can't produce a value. Empty strings count as absent — an\n * item labelled `''` renders as an invisible link.\n */\nfunction readLocaleString(map: unknown, locale: string, defaultLocale: string): string | undefined {\n if (map === null || typeof map !== 'object' || Array.isArray(map)) return undefined\n const record: Record<string, unknown> = map as Record<string, unknown>\n for (const key of locale === defaultLocale ? [locale] : [locale, defaultLocale]) {\n if (!Object.hasOwn(record, key)) continue\n const value = record[key]\n if (typeof value === 'string' && value.length > 0) return value\n }\n return undefined\n}\n\n/**\n * Read one locale's visibility flag. NO default-locale fallback on purpose:\n * `enabled` is per-locale visibility in both directions (phase 08 §3), so\n * hiding an item in `en` must not hide it in `et`.\n */\nfunction readLocaleBoolean(map: unknown, locale: string): boolean | undefined {\n if (map === null || typeof map !== 'object' || Array.isArray(map)) return undefined\n const record: Record<string, unknown> = map as Record<string, unknown>\n if (!Object.hasOwn(record, locale)) return undefined\n const value = record[locale]\n return typeof value === 'boolean' ? value : undefined\n}\n\n// ---------------------------------------------------------------------------\n// Pass 1 — structural narrowing + id collection (one walk)\n// ---------------------------------------------------------------------------\n\n/**\n * A node that survived structural validation. Data-driven filtering (publish\n * state, canonical path, label availability, empty-header pruning) happens in\n * pass 2, once the batched lookups have run.\n */\ninterface PreparedNode {\n id: string\n kind: 'link' | 'header'\n link: MenuLinkValue | undefined\n /** Raw label map — read per locale in pass 2. */\n label: unknown\n newTab: boolean\n children: PreparedNode[]\n}\n\ninterface PreparedTree {\n nodes: PreparedNode[]\n /** entityType → every linked id (needs a path + publish check). */\n idsByType: Map<string, Set<string>>\n /** entityType → the subset with no usable label override (needs a live title). */\n titleIdsByType: Map<string, Set<string>>\n /** Nodes discarded because a hard bound was hit (reported, never silent). */\n droppedForBudget: number\n}\n\nfunction isPlainObject(value: unknown): value is Record<string, unknown> {\n return value !== null && typeof value === 'object' && !Array.isArray(value)\n}\n\n/**\n * Walk the raw tree ONCE: validate each node's shape, apply the two filters\n * that need no I/O (per-locale `enabled`, the `maxDepth` clamp), and collect\n * the entity ids the batched lookups will need — grouped by entity type, so\n * pass 2 issues one call per GROUP rather than one per item.\n *\n * Bounded by {@link MAX_MENU_TREE_NODES} visited nodes and {@link MAX_MENU_DEPTH}\n * levels regardless of what is stored, so a hand-edited row can't turn a page\n * render into unbounded work.\n */\nfunction prepareTree(\n rawTree: unknown,\n locale: string,\n defaultLocale: string,\n maxDepth: number,\n): PreparedTree {\n const idsByType = new Map<string, Set<string>>()\n const titleIdsByType = new Map<string, Set<string>>()\n let budget = MAX_MENU_TREE_NODES\n let droppedForBudget = 0\n\n function collect(target: Map<string, Set<string>>, entityType: string, id: string): void {\n const bucket = target.get(entityType)\n if (bucket) bucket.add(id)\n else target.set(entityType, new Set([id]))\n }\n\n function walk(value: unknown, depth: number): PreparedNode[] {\n if (depth > maxDepth || !Array.isArray(value)) return []\n // `Array.isArray` narrows `unknown` to `any[]`; re-binding keeps elements `unknown`.\n const rawNodes: readonly unknown[] = value\n const out: PreparedNode[] = []\n\n for (const raw of rawNodes) {\n if (budget <= 0) {\n droppedForBudget++\n continue\n }\n if (!isPlainObject(raw)) continue\n\n const id = raw.id\n const kind = raw.kind\n if (typeof id !== 'string' || id.length === 0) continue\n if (kind !== 'link' && kind !== 'header') continue\n // Per-locale visibility drops the node AND its subtree — checked before\n // recursing so a hidden branch costs nothing.\n if (readLocaleBoolean(raw.enabled, locale) === false) continue\n\n budget--\n\n let link: MenuLinkValue | undefined\n if (kind === 'link') {\n // Re-validate the stored LinkValue — this is the `sanitizeHref`\n // protocol gate, the one write-time check that genuinely matters again\n // at read time (see the module header). A malformed link drops just\n // this node.\n const parsed = LinkValueSchema.safeParse(raw.link)\n if (!parsed.success) continue\n link = parsed.data\n } else if (raw.link !== undefined) {\n // A header carrying a link target is a shape violation — drop it\n // rather than render a half-defined node.\n continue\n }\n\n const children = walk(raw.children, depth + 1)\n\n if (link?.kind === 'entity') {\n collect(idsByType, link.entity, link.id)\n // Only items WITHOUT a usable override need a live title, so the title\n // read stays as small as possible — and header items, which have no\n // linked entity, can never reach this branch at all.\n if (readLocaleString(raw.label, locale, defaultLocale) === undefined) {\n collect(titleIdsByType, link.entity, link.id)\n }\n }\n\n out.push({ id, kind, link, label: raw.label, newTab: raw.newTab === true, children })\n }\n\n return out\n }\n\n const nodes = walk(rawTree, 1)\n return { nodes, idsByType, titleIdsByType, droppedForBudget }\n}\n\n// ---------------------------------------------------------------------------\n// Pass 2 — batched lookups\n// ---------------------------------------------------------------------------\n\ninterface ResolvedLookups {\n paths: Map<string, ReadonlyMap<string, string>>\n published: Map<string, ReadonlySet<string>>\n titles: Map<string, ReadonlyMap<string, string>>\n}\n\n/**\n * Run the batched lookups: for each entity-type GROUP, one forward-index read,\n * then one publish gate over the ids that had a path, then (only when some item\n * needs it) one title read. Exactly one call each, never per item, and\n * `MAX_MENU_ENTITY_TYPES` bounds the group count — so the number of round-trips\n * per render is fixed.\n *\n * Groups run CONCURRENTLY (bounded by the already-enforced `MAX_MENU_ENTITY_TYPES`\n * cap, never an unbounded `Promise.all`) — they're independent, and this sits on\n * a public SSR request path where the 3-step-per-group sequencing was otherwise\n * fully serial (up to 48 round-trips back to back for one render). Only the\n * THREE STEPS WITHIN one group stay ordered (path lookup → publish gate → title\n * read), since each depends on the previous step's output.\n *\n * How each dep handles an over-large id list is the DEP's business, and they\n * differ: `getCanonicalPaths` chunks internally, and so does the title reader in\n * `./menu-resolve-context.ts` (`TITLE_BATCH_SIZE`). `filterPublished` (content's\n * `buildEnumerateDeps`) does NOT — it issues a single `inArray` over whatever it\n * is handed. That is safe HERE because the id list is bounded tree-wide by\n * {@link MAX_MENU_TREE_NODES} (500) before it ever reaches this function, not\n * because the dep protects itself. A caller that lifts the node budget must\n * revisit that.\n */\nasync function runLookups(\n prepared: PreparedTree,\n locale: string,\n deps: MenuResolveDeps,\n handle: string,\n): Promise<ResolvedLookups> {\n const paths = new Map<string, ReadonlyMap<string, string>>()\n const published = new Map<string, ReadonlySet<string>>()\n const titles = new Map<string, ReadonlyMap<string, string>>()\n\n const groups = [...prepared.idsByType.entries()]\n if (groups.length > MAX_MENU_ENTITY_TYPES) {\n deps.logger?.warn(\n { handle, locale, entityTypes: groups.length, cap: MAX_MENU_ENTITY_TYPES },\n 'menu resolution dropped entity-type groups over the fan-out cap',\n )\n }\n\n await Promise.all(\n groups.slice(0, MAX_MENU_ENTITY_TYPES).map(async ([entityType, idSet]) => {\n const ids = [...idSet]\n // (1) Forward index — which ids have a canonical path in this locale?\n const pathMap = await deps.resolveCanonicalPaths(entityType, ids, locale)\n paths.set(entityType, pathMap)\n if (pathMap.size === 0) return\n\n // (2) Publish gate — `resolveCanonicalPaths` does NOT filter by publish\n // state (`toolkit_paths` has no status column), so this is the\n // required no-unpublished-leak step. Batched, over only the ids that\n // had a path.\n const candidates = [...pathMap.keys()]\n const publishedIds = await deps.filterPublished(entityType, candidates, locale)\n published.set(entityType, publishedIds)\n\n // (3) Live titles — only for items with no usable override, and only for\n // ids that will actually survive pass 3 (had a path AND published) —\n // fetching a title for an id that's about to be dropped is wasted work.\n const needTitles = prepared.titleIdsByType.get(entityType)\n if (needTitles === undefined) return\n const titleIds = candidates.filter((id) => needTitles.has(id) && publishedIds.has(id))\n if (titleIds.length === 0) return\n titles.set(entityType, await deps.resolveTitles(entityType, titleIds, locale))\n }),\n )\n\n return { paths, published, titles }\n}\n\n// ---------------------------------------------------------------------------\n// Pass 3 — resolve + filter, bottom-up\n// ---------------------------------------------------------------------------\n\n/**\n * Resolve prepared nodes into {@link ResolvedMenuItem}s, dropping everything\n * that can't render. Children resolve BEFORE their parent so empty-header\n * pruning sees the already-filtered child list (phase 11 §2: \"header item whose\n * surviving children are all dropped → dropped\").\n */\nfunction resolveNodes(\n nodes: readonly PreparedNode[],\n locale: string,\n lookups: ResolvedLookups,\n deps: MenuResolveDeps,\n): ResolvedMenuItem[] {\n const out: ResolvedMenuItem[] = []\n\n for (const node of nodes) {\n const children = resolveNodes(node.children, locale, lookups, deps)\n const override = readLocaleString(node.label, locale, deps.defaultLocale)\n\n if (node.kind === 'header') {\n // No empty dropdowns (phase 11 §2), and no label → nothing to click on.\n if (children.length === 0) continue\n if (override === undefined) continue\n out.push({\n id: node.id,\n kind: 'header',\n label: override,\n href: null,\n newTab: false,\n isActive: false,\n inActiveTrail: false,\n children,\n })\n continue\n }\n\n const link = node.link\n if (link === undefined) continue\n\n let href: string | null\n let label = override\n\n if (link.kind === 'entity') {\n const path = lookups.paths.get(link.entity)?.get(link.id)\n // No canonical path at all → the target was deleted, never published in\n // this locale, or its type isn't routable. Unpublished → same outcome.\n if (path === undefined) continue\n if (lookups.published.get(link.entity)?.has(link.id) !== true) continue\n href = link.anchor === undefined ? path : `${path}#${link.anchor}`\n label ??= lookups.titles.get(link.entity)?.get(link.id)\n } else {\n // `url` / `email` — the only remaining kinds, so there is no dead\n // fallback branch to carry: `MenuLinkValue` has exactly three arms. A\n // `media` link is rejected at the WRITE boundary by `LinkValueSchema`,\n // which is also what drops one that reached the column by some other\n // route (a direct edit, a restored dump) — `prepareTree`'s `safeParse`\n // never lets it get this far.\n //\n // Verbatim external targets, protocol-gated once more by blocks'\n // `resolveLinkHref` (`mailto:` composition included) — the single source\n // of truth for what may become an `<a href>`. It returns `null` for\n // anything it cannot turn into an href, and a `null` href is dropped just\n // below — so widening `MenuLinkValue` later fails CLOSED here by default\n // rather than emitting a half-resolved item.\n href = resolveLinkHref(link)\n }\n\n // An item with no href or no resolvable label renders as either a dead\n // anchor or an invisible one — drop it, matching every other filter here.\n if (href === null || href.length === 0) continue\n if (label === undefined || label.length === 0) continue\n\n out.push({\n id: node.id,\n kind: 'link',\n label,\n href,\n newTab: node.newTab,\n isActive: false,\n inActiveTrail: false,\n children,\n })\n }\n\n return out\n}\n\n// ---------------------------------------------------------------------------\n// Active trail (phase 11 §3)\n// ---------------------------------------------------------------------------\n\n/**\n * The comparable path part of an href or a current path: the `#fragment` is\n * dropped (an anchor item on the current page is still \"the current page\", per\n * phase 11 §2) and a trailing slash is normalized away, so `/about` and\n * `/about/` match. The root `/` keeps its slash.\n */\nfunction comparablePath(value: string): string {\n const hash = value.indexOf('#')\n const path = hash === -1 ? value : value.slice(0, hash)\n return path.length > 1 && path.endsWith('/') ? path.slice(0, -1) : path\n}\n\ninterface MarkResult {\n items: ResolvedMenuItem[]\n containsActive: boolean\n}\n\nfunction markNodes(nodes: readonly ResolvedMenuItem[], target: string): MarkResult {\n let containsActive = false\n const items = nodes.map((node): ResolvedMenuItem => {\n const marked = markNodes(node.children, target)\n const isActive = node.href !== null && comparablePath(node.href) === target\n // Ancestry is read off the TREE, never guessed from a path prefix — prefix\n // guessing breaks across translated bases and across sibling paths that\n // merely share a prefix (`/about` vs `/about-us`).\n const inActiveTrail = marked.containsActive\n if (isActive || inActiveTrail) containsActive = true\n return { ...node, isActive, inActiveTrail, children: marked.items }\n })\n return { items, containsActive }\n}\n\n/**\n * Mark `isActive` / `inActiveTrail` on a resolved menu for one page.\n *\n * PURE — no I/O, and the input menu is not mutated (a new tree is returned), so\n * an SSG build can fetch a menu ONCE and mark it per page without the pages\n * contaminating each other. `getMenu` calls this internally when\n * `opts.currentPath` is given, so there is exactly one implementation of the\n * ancestry walk.\n *\n * `isActive` is an EXACT match on the path part; `inActiveTrail` marks strict\n * tree ancestors of an active item (an active item is not its own ancestor).\n */\nexport function markActiveTrail(menu: ResolvedMenu, currentPath: string): ResolvedMenu {\n return { ...menu, items: markNodes(menu.items, comparablePath(currentPath)).items }\n}\n\n// ---------------------------------------------------------------------------\n// Entry point\n// ---------------------------------------------------------------------------\n\n/**\n * Resolve a stored menu into a render-ready tree for one locale (phase 11 §2).\n *\n * Returns `null` when the menu does not exist or is not published — a draft\n * menu renders nowhere public. A published menu whose every item was filtered\n * out returns an EMPTY item list, not `null`: \"the menu exists but has nothing\n * to show here\" is a different fact from \"there is no such menu\".\n *\n * Filtering order (phase 11 §2, not reorderable): menu unpublished → `null`;\n * `enabled[locale] === false` → dropped with its subtree; entity target deleted\n * or unpublished-in-locale → dropped; header with no surviving children →\n * dropped; depth clamped to the menu's own `maxDepth`.\n */\nexport async function getMenu(\n handle: string,\n opts: GetMenuOptions,\n deps: MenuResolveDeps,\n): Promise<ResolvedMenu | null> {\n if (handle.length === 0 || handle.length > MAX_HANDLE_LENGTH) return null\n\n const row = await deps.getMenuRow(handle)\n if (row === null) return null\n\n // Renderer-enforced clamp — independent of the editor's own check (phase 07\n // §2.7), and never wider than the absolute ceiling even if the column is not.\n const stored = row.maxDepth\n const maxDepth =\n typeof stored === 'number' && Number.isInteger(stored) && stored >= 1\n ? Math.min(stored, MAX_MENU_DEPTH)\n : DEFAULT_MENU_DEPTH\n\n const prepared = prepareTree(row.tree, opts.locale, deps.defaultLocale, maxDepth)\n if (prepared.droppedForBudget > 0) {\n deps.logger?.warn(\n {\n handle,\n locale: opts.locale,\n dropped: prepared.droppedForBudget,\n cap: MAX_MENU_TREE_NODES,\n },\n 'menu resolution dropped items over the node budget',\n )\n }\n\n const lookups = await runLookups(prepared, opts.locale, deps, handle)\n const menu: ResolvedMenu = {\n handle,\n items: resolveNodes(prepared.nodes, opts.locale, lookups, deps),\n }\n\n return opts.currentPath === undefined ? menu : markActiveTrail(menu, opts.currentPath)\n}\n","/**\n * Read-side narrowing for a stored `menu.tree` column, plus the entity-ref\n * collection the editor page needs for `resolveMenuItemSummaries()`.\n *\n * **Why this exists.** `field.json()` types its column as `JsonValue`, so the\n * row `fetchEntityById(Menu, id)` returns hands us an untyped blob. Casting it\n * straight to `MenuItem[]` would need an `as unknown as` (CLAUDE.md: don't) and\n * would also be a lie — the column is only *usually* well-formed. Rows written\n * before `menuTreeValidation()` existed, hand-edited rows, and rows restored\n * from an older dump can all violate the shape, and the editor is exactly the\n * tool you reach for when they do. So this walks the raw value ONCE and returns\n * a properly-typed tree.\n *\n * **Deliberately tolerant, and deliberately NOT `getMenu`.** `getMenu` is the\n * public render path: it also applies the per-locale `enabled` filter, the\n * publish gate and the per-menu `maxDepth` clamp, because a visitor must not\n * see a hidden or unpublished item. The editor is the opposite case — it must\n * show *everything* that is stored, including items hidden in the current\n * locale and items nested deeper than the menu's own `maxDepth` (that is the\n * state you opened the editor to fix). The only things dropped here are nodes\n * whose shape cannot be represented as a `MenuItem` at all.\n *\n * **Bounded** (CLAUDE.md \"no unbounded fan-out\"): at most\n * {@link MAX_MENU_TREE_NODES} nodes are materialised and recursion stops at\n * {@link MAX_MENU_DEPTH}, regardless of what the column contains — so a\n * hand-edited row can't turn a page render into unbounded work or a stack\n * overflow. Whatever is refused is COUNTED and returned, never silently\n * swallowed: the caller logs it, and the editor surfaces it before a save\n * would persist the removal.\n *\n * **Why it lives in `@murumets-ee/menus`.** It was a `@murumets-ee/menus-ui`\n * internal until PR12, when the Meta panel's Menus section (`admin-ui`) needed\n * the same narrowing to turn a `menu` row's `tree` column into a `MenuItem[]`.\n * `menus-ui` depends on `admin-ui`, so exporting it from there would close an\n * `admin-ui → menus-ui → admin-ui` import cycle — same reasoning (and same\n * move) as `./menu-tree-ops.ts`.\n *\n * It pulls `LinkValueSchema` — and therefore zod — and nothing else, so it is\n * client-safe and rides the `./schema` subpath alongside the tree operations.\n *\n * @module\n */\n\nimport {\n LinkValueSchema,\n MAX_MENU_DEPTH,\n MAX_MENU_TREE_NODES,\n type MenuItem,\n type MenuLinkValue,\n} from './menu-item-schema.js'\n\n/** One entity target a menu item points at — the `resolveMenuItemSummaries` input shape. */\nexport interface MenuEntityRef {\n entity: string\n id: string\n}\n\n/** Result of {@link normalizeMenuTree}. */\nexport interface NormalizedMenuTree {\n /** The stored tree, narrowed to `MenuItem[]`. */\n items: MenuItem[]\n /**\n * How many stored nodes were REFUSED — malformed shape, or past one of the\n * hard bounds. An entire refused subtree counts as ONE (we stop at the\n * refusal point rather than descending into it to count more).\n *\n * Non-zero means a whole-tree save would persist those removals, so the\n * editor must warn before writing.\n */\n dropped: number\n}\n\nfunction isPlainObject(value: unknown): value is Record<string, unknown> {\n return value !== null && typeof value === 'object' && !Array.isArray(value)\n}\n\n/**\n * Keep only the entries whose value passes `predicate`. Locale KEYS are not\n * checked against the app's configured locale set on purpose: the set can\n * change after a tree is written, and dropping a label for a locale that was\n * just disabled would destroy authored text on the next save. An out-of-set key\n * is instead rejected loudly by `menuTreeValidation()` when the editor saves.\n */\nfunction filterMap<T>(\n value: unknown,\n predicate: (candidate: unknown) => candidate is T,\n): Record<string, T> | undefined {\n if (!isPlainObject(value)) return undefined\n const out: Record<string, T> = {}\n for (const [key, entry] of Object.entries(value)) {\n if (predicate(entry)) out[key] = entry\n }\n return out\n}\n\nconst isString = (value: unknown): value is string => typeof value === 'string'\nconst isBoolean = (value: unknown): value is boolean => typeof value === 'boolean'\n\n/**\n * Narrow a raw `menu.tree` JSONB value to `MenuItem[]`.\n *\n * @param value the raw column value (untrusted shape).\n */\nexport function normalizeMenuTree(value: unknown): NormalizedMenuTree {\n let budget = MAX_MENU_TREE_NODES\n let dropped = 0\n\n function walk(raw: unknown, depth: number): MenuItem[] {\n if (!Array.isArray(raw)) return []\n // `Array.isArray` narrows `unknown` to `any[]`; re-binding through a\n // `readonly unknown[]` annotation keeps every element typed as `unknown`.\n const rawNodes: readonly unknown[] = raw\n if (depth > MAX_MENU_DEPTH) {\n // Past the absolute cap the whole level goes, counted once per node so\n // the editor's warning is proportional to what it lost.\n dropped += rawNodes.length\n return []\n }\n\n const out: MenuItem[] = []\n for (const node of rawNodes) {\n if (!isPlainObject(node)) {\n dropped++\n continue\n }\n\n const id = node.id\n const kind = node.kind\n if (typeof id !== 'string' || id.length === 0) {\n dropped++\n continue\n }\n if (kind !== 'link' && kind !== 'header') {\n dropped++\n continue\n }\n\n let link: MenuLinkValue | undefined\n if (kind === 'link') {\n // Re-validate the stored target through the SAME schema the write path\n // uses — notably the `sanitizeHref` protocol allowlist, so a row that\n // predates (or bypassed) that gate can't hand the editor a\n // `javascript:` href to render.\n const parsed = LinkValueSchema.safeParse(node.link)\n if (!parsed.success) {\n dropped++\n continue\n }\n link = parsed.data\n } else if (node.link !== undefined) {\n // A header carrying a link target is a shape violation the type can't\n // represent — drop it rather than render a half-defined node.\n dropped++\n continue\n }\n\n if (budget <= 0) {\n dropped++\n continue\n }\n budget--\n\n // `filterMap` returns `{}` (not `undefined`) when the stored map WAS an\n // object but every entry failed the predicate (e.g. `{ en: 42 }`, a\n // non-string junk value) — `?? null`/`undefined` below wouldn't catch\n // that, since `{}` is truthy. An all-junk map must collapse the same\n // way a genuinely absent one does: for `label`, `{}` surviving into a\n // save would fail the write-side schema's \"a header needs at least one\n // locale key\" check with a server error instead of this module's own\n // documented tolerant behaviour; for `enabled`, `{}` is a harmless but\n // needless stored key (absent already means \"visible everywhere\").\n const rawLabel = filterMap(node.label, isString)\n const label =\n rawLabel !== undefined && Object.keys(rawLabel).length > 0 ? rawLabel : undefined\n const rawEnabled = filterMap(node.enabled, isBoolean)\n const enabled =\n rawEnabled !== undefined && Object.keys(rawEnabled).length > 0 ? rawEnabled : undefined\n out.push({\n id,\n kind,\n link,\n // `null` = \"use the live entity title per locale\" (the storage default).\n label: label ?? null,\n enabled,\n newTab: typeof node.newTab === 'boolean' ? node.newTab : undefined,\n children: walk(node.children, depth + 1),\n })\n }\n return out\n }\n\n return { items: walk(value, 1), dropped }\n}\n\n/**\n * Every distinct entity target in a (already-normalised, therefore bounded)\n * tree, in document order.\n *\n * Deduplicated: two menu items pointing at the same page produce ONE\n * `resolveMenuItemSummaries` ref, so the batched read never scales with item\n * count. The output feeds `resolveMenuItemSummaries(app, refs)`, whose result\n * map is keyed the same `${entity}:${id}` way.\n */\nexport function collectMenuEntityRefs(items: readonly MenuItem[]): MenuEntityRef[] {\n const seen = new Set<string>()\n const refs: MenuEntityRef[] = []\n\n function walk(nodes: readonly MenuItem[]): void {\n for (const node of nodes) {\n const link = node.link\n if (link?.kind === 'entity') {\n const key = `${link.entity}:${link.id}`\n if (!seen.has(key)) {\n seen.add(key)\n refs.push({ entity: link.entity, id: link.id })\n }\n }\n walk(node.children)\n }\n }\n\n walk(items)\n return refs\n}\n","/**\n * Block-anchor collection — the pure half of the anchors endpoint\n * (plan/routing phase 08 §5 / 09 §7, SD027).\n *\n * **Leaf module.** No `@murumets-ee/core`, no DB, no `@murumets-ee/blocks`\n * import: it walks a raw `field.blockTree()` JSONB value structurally, the same\n * way `get-menu.ts` walks a raw menu tree. That keeps the walk unit-testable\n * without a database and keeps this package's dependency direction unchanged\n * (blocks is UPSTREAM of menus, so nothing here reaches back into it).\n *\n * The anchor charset + length bound come from `../menu-item-schema.ts`, this\n * package's single owner of them — an intra-package import of a deliberately\n * pure module, NOT a new dependency direction. They used to be mirrored here;\n * a drift between the READ side (what the picker offers) and the WRITE side\n * (what `LinkValueSchema` accepts) would let the endpoint list anchors the menu\n * schema then rejects. Routing PR10 Task 3b converged them.\n *\n * **Bounded by construction** (CLAUDE.md \"no unbounded fan-out\"). The value\n * being walked is admin-authored, not a public payload — but it is still\n * untrusted-SHAPE JSONB (a hand-edited row, a restored dump, a future editor\n * bug), and this runs on an interactive picker's request path. So the walk is\n * ITERATIVE (never recursive — a deep tree must not blow the stack), carries a\n * hard visited-node budget, and hard-caps the returned list. Both bounds report\n * truncation to the caller so it can log; nothing is ever silently dropped.\n */\n\nimport { ANCHOR_PATTERN, type BlockAnchor, MAX_ANCHOR_LENGTH } from '../menu-item-schema.js'\n\n/**\n * Max length of a picker label. Mirrors `menu-item-schema.ts`'s\n * `MAX_LABEL_LENGTH`. `anchorLabel` is a free-text block field with no\n * declared `maxLength`, so a pathological value falls back to the anchor\n * itself rather than being truncated into something the editor never wrote.\n */\nexport const MAX_ANCHOR_LABEL_LENGTH = 200\n\n/**\n * Visited-node budget for one layout walk.\n *\n * Deliberately larger than `MAX_MENU_TREE_NODES` (500): a page layout is a\n * richer document than a hand-curated navigation menu. Past this the walk\n * stops and reports `truncated` — the bound that keeps a corrupted or hostile\n * layout from turning an interactive picker request into unbounded work.\n */\nexport const MAX_LAYOUT_NODES = 1000\n\n/**\n * Max anchors returned in one response. A picker listing more than this is not\n * a usable UI, and the cap bounds the response body independently of the node\n * budget.\n */\nexport const MAX_ANCHOR_RESULTS = 200\n\n/**\n * 🔴 THERE IS NO BLOCK-TYPE LIST HERE ANY MORE, and its removal is the fix\n * rather than a simplification.\n *\n * This was `new Set(['section', 'text'])`. The `editor` plan's PR05 merges\n * `section` into `container` (D002), and this list is the one place in the repo\n * where that rename fails SILENTLY: `readAnchor` would return `undefined` for\n * every renamed block, so every section anchor on every site would vanish from\n * the menu link picker with no error, no log line and no failing test. F027\n * audited five hardcoded block-name lists and found the frightening one (rent's\n * contract renderer, which THROWS) is safe — it names `richtext`/`text`/\n * `divider`/`spacer` and the rename never reaches it — while this one, which\n * looks harmless, is the dangerous one precisely because it is quiet.\n *\n * \"Import the constant\" is not available: this module deliberately imports\n * nothing (`blocks` is UPSTREAM of `menus`, so the dependency runs the other\n * way), and adding `'container'` beside `'section'` would leave the same trap\n * armed for the next rename.\n *\n * So the type gate is GONE and the data answers the question instead: a block\n * is anchorable if it carries a slug-shaped `anchor` prop. That is what the\n * list was a proxy for — `anchorFields` is spread into exactly the blocks that\n * are addressable, so the field's PRESENCE is the declaration and the name was\n * only ever a second, driftable copy of it.\n *\n * The widening is bounded on every axis that matters, and by guards that\n * already existed rather than by new ones: {@link MAX_LAYOUT_NODES} caps the\n * walk, {@link MAX_ANCHOR_RESULTS} caps the response, `ANCHOR_PATTERN` +\n * {@link MAX_ANCHOR_LENGTH} reject anything that is not a slug, and duplicates\n * are dropped. A block type that happens to carry an unrelated `anchor` prop\n * would now be offered — no such block exists in any vocabulary in this repo,\n * and being offered an extra jump target is a far smaller failure than a\n * picker that silently lists nothing.\n */\n\n/** {@link collectBlockAnchors}' result, including what it had to drop. */\nexport interface CollectAnchorsResult {\n anchors: BlockAnchor[]\n /** `true` when the node budget or the result cap cut the walk short. */\n truncated: boolean\n /** Nodes actually visited — for the truncation log line. */\n visited: number\n}\n\n/** Structural minimum of a pino/toolkit logger (declared, not imported). */\ninterface AnchorWalkLogger {\n warn(obj: object, msg: string): void\n}\n\nfunction isPlainObject(value: unknown): value is Record<string, unknown> {\n return value !== null && typeof value === 'object' && !Array.isArray(value)\n}\n\n/**\n * Read a usable anchor off a raw block node's `props`, or `undefined`.\n *\n * Returns `undefined` for every failure mode (missing / non-string / empty /\n * over-long / non-slug value) — the picker simply doesn't offer it.\n */\nfunction readAnchor(node: Record<string, unknown>): BlockAnchor | undefined {\n const props = node.props\n if (!isPlainObject(props)) return undefined\n\n const anchor = props.anchor\n if (typeof anchor !== 'string') return undefined\n if (anchor.length === 0 || anchor.length > MAX_ANCHOR_LENGTH) return undefined\n if (!ANCHOR_PATTERN.test(anchor)) return undefined\n\n const rawLabel = props.anchorLabel\n const label =\n typeof rawLabel === 'string' &&\n rawLabel.length > 0 &&\n rawLabel.length <= MAX_ANCHOR_LABEL_LENGTH\n ? rawLabel\n : anchor\n\n return { anchor, label }\n}\n\n/**\n * Walk a raw `field.blockTree()` value and collect every addressable anchor, in\n * document order.\n *\n * Duplicate anchors are RETURNED AS-IS — cross-block uniqueness is deliberately\n * not enforced in v1 (plan/routing F044 point 6); the endpoint lists whatever\n * exists so a colliding anchor is visible to the editor rather than silently\n * collapsed.\n *\n * @param tree Raw JSONB value. Any non-array input yields an empty result.\n * @param logger Optional — receives one line per bound that fired.\n */\nexport function collectBlockAnchors(\n tree: unknown,\n logger?: AnchorWalkLogger,\n): CollectAnchorsResult {\n const anchors: BlockAnchor[] = []\n if (!Array.isArray(tree)) return { anchors, truncated: false, visited: 0 }\n\n // `Array.isArray` narrows `unknown` to `any[]`; re-binding through a\n // `readonly unknown[]` annotation keeps every element typed as `unknown`\n // instead of leaking `any` into the walk.\n const roots: readonly unknown[] = tree\n\n let visited = 0\n let truncated = false\n\n // Explicit stack, NOT recursion — a 50k-deep layout must not blow the\n // stack. Pushed in reverse so `pop()` yields document order. Bounded the\n // SAME way the children-push path below is: cap how many root entries get\n // copied onto the stack, rather than `[...roots].reverse()`ing the WHOLE\n // array unconditionally before the budget check ever runs — a\n // pathologically large root-level array (a restored dump, a hand-edited\n // row) would otherwise force a full O(N) copy+reverse on the request\n // thread regardless of `MAX_LAYOUT_NODES`, defeating the \"bounded by\n // construction\" guarantee this module's header documents.\n if (roots.length > MAX_LAYOUT_NODES) truncated = true\n const stack: unknown[] = []\n for (let i = Math.min(roots.length, MAX_LAYOUT_NODES) - 1; i >= 0; i--) stack.push(roots[i])\n\n while (stack.length > 0) {\n if (visited >= MAX_LAYOUT_NODES || anchors.length >= MAX_ANCHOR_RESULTS) {\n truncated = true\n break\n }\n const node = stack.pop()\n // Count every stack pop against the budget, not just plain-object nodes —\n // a non-object entry still costs a loop iteration, and the budget exists\n // to bound WORK, not \"real\" nodes. Counting only objects let a `children`\n // array full of non-object junk (or a malformed dump) iterate past the cap\n // entirely, defeating the bound this comment block claims exists.\n visited++\n if (!isPlainObject(node)) continue\n\n const found = readAnchor(node)\n if (found !== undefined) anchors.push(found)\n\n const children = node.children\n if (Array.isArray(children)) {\n const items: readonly unknown[] = children\n // Push at most as many children as remain in the budget — pushing a\n // node's ENTIRE children array regardless of size (the old behaviour)\n // let one node's oversized array blow past the budget in a single step,\n // before the loop ever got to re-check it. Any children left unpushed\n // are exactly as truncated as a node the budget cut off outright.\n const room = Math.max(MAX_LAYOUT_NODES - visited, 0)\n if (items.length > room) truncated = true\n for (let i = Math.min(items.length, room) - 1; i >= 0; i--) stack.push(items[i])\n }\n }\n\n if (truncated) {\n logger?.warn(\n {\n visited,\n collected: anchors.length,\n nodeBudget: MAX_LAYOUT_NODES,\n resultCap: MAX_ANCHOR_RESULTS,\n },\n 'anchor collection stopped at a hard bound — the returned list is incomplete',\n )\n }\n\n return { anchors, truncated, visited }\n}\n","/**\n * `resolveMenuItemSummaries()` — live title + broken/unpublished status for\n * every entity target a stored menu tree points at (plan/routing phase 09 §3:\n * item rows show \"target summary … broken/unpublished badge\").\n *\n * **Deliberately NOT `getMenu`'s machinery.** `getMenu` resolves menus for\n * ANONYMOUS PUBLIC requests, so it elevates to a system REACH context — one\n * that withholds `view_unpublished`, which is what makes its reads\n * publish-filtered (D017 made that filter conditional on the permission; it is\n * no longer unconditional for any principal). The admin editor is the opposite\n * case:\n *\n * - it MUST surface a DRAFT target's title (an editor may be building\n * navigation for content that isn't published yet), which rules out\n * `QueryClient` — that client filters to `status = 'published'` for any\n * principal without `view_unpublished` (D017), so every draft-linked item\n * would look broken for exactly the roles that most need the editor to\n * work. `AdminClient` has no publish filter at all, which is the right\n * tool here;\n * - it MUST respect the REAL signed-in admin's own grants and scope — no\n * elevation. If that admin cannot view an entity type, the editor correctly\n * shows no title for it. Least privilege, not convenience.\n *\n * So this goes through `app.getClient(entity)` — the framework-managed,\n * request-bound `AdminClient` — batched by entity type exactly like\n * `@murumets-ee/admin-ui`'s `resolveRowTitles`, but with richer per-row output.\n *\n * **No unbounded fan-out** (CLAUDE.md). Backend work is bounded by the number\n * of distinct ENTITY TYPES, never by item count: one `findMany` per type group.\n * The ref count is capped at {@link MAX_MENU_TREE_NODES} (the same bound the\n * stored tree the caller walked already carries) and the group count at\n * {@link MAX_MENU_ENTITY_TYPES}. Anything dropped is logged — never silently\n * truncated.\n *\n * Called ONCE, server-side, by the menu editor page (RSC → props; no\n * `useEffect` fetching, per CLAUDE.md). Items added client-side via the link\n * picker already carry their title from the picker, so they need no round-trip.\n */\n\nimport type { ToolkitApp } from '@murumets-ee/core'\nimport type { Entity } from '@murumets-ee/entity'\nimport { getEntityTitleField } from '@murumets-ee/entity'\nimport { inArray } from 'drizzle-orm'\nimport { MAX_MENU_ENTITY_TYPES } from '../get-menu.js'\nimport { column } from '../internal/columns.js'\nimport { MAX_MENU_TREE_NODES, type MenuItemSummary } from '../menu-item-schema.js'\n\n/** Key shape of {@link resolveMenuItemSummaries}' result map. */\nfunction summaryKey(entity: string, id: string): string {\n return `${entity}:${id}`\n}\n\nconst BROKEN: MenuItemSummary = { title: null, exists: false, published: null }\n\n/**\n * Group refs by entity type, deduping ids and stopping at the hard ref bound.\n *\n * Returns the bounded groups plus what was dropped, so the caller logs once\n * rather than per drop.\n */\nfunction groupRefs(refs: ReadonlyArray<{ entity: string; id: string }>): {\n groups: Map<string, Set<string>>\n droppedRefs: number\n droppedGroups: number\n} {\n const groups = new Map<string, Set<string>>()\n let distinct = 0\n let droppedRefs = 0\n\n for (const ref of refs) {\n if (typeof ref.entity !== 'string' || typeof ref.id !== 'string') continue\n if (ref.entity.length === 0 || ref.id.length === 0) continue\n\n const bucket = groups.get(ref.entity)\n if (bucket?.has(ref.id)) continue // already counted — duplicates are free\n if (distinct >= MAX_MENU_TREE_NODES) {\n droppedRefs++\n continue\n }\n distinct++\n if (bucket) bucket.add(ref.id)\n else groups.set(ref.entity, new Set([ref.id]))\n }\n\n const droppedGroups = Math.max(0, groups.size - MAX_MENU_ENTITY_TYPES)\n return { groups, droppedRefs, droppedGroups }\n}\n\n/**\n * Batch-resolve the live title + existence + publish state of every\n * `{ entity, id }` target, keyed `` `${entity}:${id}` `` (the same key shape\n * `resolveRowTitles` uses).\n *\n * **A MISSING map entry means \"not resolvable here\", NOT \"broken.\"** It happens\n * when the entity type isn't registered in this app, when the caller holds no\n * `view` grant on it, when that group's read failed, or (for a `team`/`user`-\n * scoped entity only) when the row belongs to a different scope than this\n * request's — a scope filter excludes it the same way a delete would, and\n * this function cannot tell the two apart. Callers should render such items\n * as indeterminate — only an explicit `exists: false` (reported ONLY for a\n * `global`-scoped entity, where no scope filter can ever apply) means the\n * target was really deleted.\n *\n * Best-effort per group: one group's failure never takes down the others (and\n * therefore never takes down the editor page).\n */\nexport async function resolveMenuItemSummaries(\n app: ToolkitApp,\n refs: ReadonlyArray<{ entity: string; id: string }>,\n): Promise<Map<string, MenuItemSummary>> {\n const summaries = new Map<string, MenuItemSummary>()\n if (refs.length === 0) return summaries\n\n const logger = app.logger.child({ resolve: 'menu-item-summaries' })\n const { groups, droppedRefs, droppedGroups } = groupRefs(refs)\n\n if (droppedRefs > 0) {\n logger.warn(\n { dropped: droppedRefs, cap: MAX_MENU_TREE_NODES },\n 'menu item summaries dropped targets over the ref budget — some rows will show no status',\n )\n }\n if (droppedGroups > 0) {\n logger.warn(\n { dropped: droppedGroups, cap: MAX_MENU_ENTITY_TYPES },\n 'menu item summaries dropped entity-type groups over the fan-out cap',\n )\n }\n\n // Groups are independent and their count is already hard-capped, so this is a\n // BOUNDED concurrent fan-out (≤ MAX_MENU_ENTITY_TYPES), not an unbounded\n // `Promise.all` over caller-controlled input.\n await Promise.all(\n [...groups.entries()]\n .slice(0, MAX_MENU_ENTITY_TYPES)\n .map(([entityName, idSet]) => resolveGroup(app, logger, entityName, idSet, summaries)),\n )\n\n return summaries\n}\n\nasync function resolveGroup(\n app: ToolkitApp,\n logger: ToolkitApp['logger'],\n entityName: string,\n idSet: ReadonlySet<string>,\n out: Map<string, MenuItemSummary>,\n): Promise<void> {\n // Unregistered type — leave NO entry (see the \"missing entry\" contract):\n // claiming `exists: false` would flag every item as broken just because a\n // plugin isn't loaded in this process.\n const entity: Entity | undefined = app.entities.get(entityName)\n if (entity === undefined) return\n\n const titleField = getEntityTitleField(entity)\n // `getEntityTitleField` degrades to `'id'` when nothing is display-able. The\n // row still EXISTS; it just has no title to show.\n const hasTitle = titleField !== 'id'\n const isPublishable = entity.behaviors?.some((b) => b.name === 'publishable') ?? false\n const ids = [...idSet]\n\n try {\n const client = app.getClient(entity)\n // `AdminClient`, so rows come back regardless of publish state — the whole\n // point (see the module header). Bounded by `ids.length`, itself bounded by\n // `MAX_MENU_TREE_NODES`.\n const rows = await client.findMany({\n where: inArray(column(client.getTable(), 'id'), ids),\n limit: ids.length,\n })\n\n const seen = new Set<string>()\n for (const row of rows) {\n const record: Record<string, unknown> = row\n const id = record.id\n if (typeof id !== 'string') continue\n const rawTitle = hasTitle ? record[titleField] : undefined\n seen.add(id)\n out.set(summaryKey(entityName, id), {\n title: typeof rawTitle === 'string' && rawTitle.length > 0 ? rawTitle : null,\n exists: true,\n published: isPublishable ? record.status === 'published' : null,\n })\n }\n\n // An id asked for and not returned is USUALLY gone — but for a `team`/\n // `user`-scoped entity it can also mean \"belongs to a different scope than\n // this request's\", which `findMany` filters out silently (same\n // `strictScope: false` fail-open every admin read path shares — see\n // `packages/core/src/client-factories.ts`). Claiming `exists: false` there\n // would tell an admin to delete a perfectly live row just because it's\n // outside their current scope — the exact false alarm the \"missing entry\n // is indeterminate\" contract exists to prevent. Only a `global`-scoped\n // entity (no scope filter can ever apply) gets the real broken signal;\n // anything else is left unresolved (indeterminate) instead.\n const canTrustAbsence = (entity.scope ?? 'global') === 'global'\n if (canTrustAbsence) {\n for (const id of ids) {\n if (!seen.has(id)) out.set(summaryKey(entityName, id), BROKEN)\n }\n }\n } catch (error) {\n // Best-effort, per group: a denied entity, a pre-migration table or a\n // transient read failure must degrade THIS group's badges, not blank the\n // whole editor page. Logged (never a silent swallow) and left with no\n // entries, which callers read as indeterminate rather than broken.\n logger.warn(\n { entity: entityName, count: ids.length, error: String(error) },\n 'menu item summaries could not be resolved for this entity type',\n )\n }\n}\n","/**\n * Admin API routes for `@murumets-ee/menus`.\n *\n * **URL surface served by this module:**\n *\n * - GET /api/admin/menus/anchors?entity=&id= — addressable block anchors on\n * one published document, for the menu editor's anchor picker\n * (plan/routing phase 08 §5 / 09 §4 + §7).\n * - GET /api/admin/menus/editor-state?handle= — a menu's full tree + editor\n * metadata (summaries, locales, entityTypes, canPublish) by handle. Feeds\n * `@murumets-ee/menus-ui`'s Tier 3 in-place menu editor surface\n * (`MenuBlock.customEditorSurface`, `plan/blocks/08-adaptive-ui.md`\n * §\"Tier 3\") the same props shape the standalone `/admin/menu/[id]` page\n * builds server-side — see `./editor-state.ts`'s module header for the\n * v1 scope trade-off (no draft/lock parity).\n * - GET /api/admin/menus/resolved?handle=a&handle=b — the PUBLISHED, resolved\n * items of up to {@link MAX_RESOLVED_HANDLES} menus, for an editing canvas\n * whose blocks came to name a menu after the page loaded\n * (davatoidud-toolkit F040). See `handleResolved`.\n *\n * Menu CRUD itself is plain entity CRUD (the `menu` entity is served by\n * admin-ui's generic entity family), so it deliberately has no route here —\n * the embedded editor's Save/Publish buttons PATCH straight through that\n * existing entity-CRUD route, same as the standalone page.\n *\n * ## Security posture\n *\n * The anchors endpoint reads ANOTHER entity's content, so it is gated TWICE:\n *\n * 1. The declarative `menu:view` gate the framework wrapper enforces before\n * the handler runs — \"may this caller work with menus at all?\".\n * 2. An in-handler `ctx.checkPermission(<target entity>, 'view')` — \"may this\n * caller see THAT entity's content?\". The static gate can't express this:\n * the target entity is a request parameter (the exact \"multi-entity routes\n * ... per-entity permission checks\" case `AdminRouteHandler`'s contract\n * documents), and without it any menu editor could read the layout of an\n * entity type they hold no grant on.\n *\n * **Every negative outcome INSIDE the handler answers `200 []`, never 403/404**\n * — no permission on the target entity, unknown entity, entity with no block\n * tree, missing / unpublished / deleted row. One uniform response means the\n * endpoint discloses nothing about which entity types exist, which are\n * block-backed, or which ids are real. (Gate 1 above still answers a real\n * `403` for a caller who lacks `menu:view` itself — that gate protects the\n * endpoint as a whole, not a specific target, so there is no oracle risk in\n * it answering truthfully.) A malformed REQUEST (missing/short/over-long\n * `entity`, non-uuid `id`) still 400s — that is a fact about the caller's own\n * request, not about our data.\n *\n * Reads go through a `QueryClient` bound to the REAL caller's identity and\n * scope — deliberately NOT the elevated `SYSTEM_CONTEXT` that\n * `menu-resolve-context.ts` uses for public rendering. `QueryClient` excludes\n * trashed rows and publish-filters, which is exactly phase 08 §5's \"walking\n * its published layout\": a draft page exposes no anchors, and the picker's\n * manual-string entry is the documented fallback for that case (07 §2.6).\n *\n * Since D017 (plan/api PR04) that publish filter is CONDITIONAL on the\n * caller's `view_unpublished` grant, so this route withholds that one action\n * from the checker it hands the client. The reason is a PRODUCT contract, not\n * a permission one: an anchor picker exists to build navigation that WORKS,\n * and an anchor that only exists in an unpublished layout is a link that 404s\n * (or jumps nowhere) for every visitor. \"Published layout\" is the definition\n * of the endpoint, so it must not vary by who is asking. Nothing is concealed\n * from a privileged caller here — an editor who may read the draft can read it\n * through the normal entity API; they just don't get to pick anchors out of it.\n */\n\nimport type { ResolvedMenuNavItem } from '@murumets-ee/blocks'\nimport {\n type AdminRoute,\n type AdminRouteHandler,\n combineAdminRoutes,\n defineAdminRoute,\n getContext,\n getCurrentApp,\n getCurrentScope,\n siteSecurityFields,\n} from '@murumets-ee/core'\nimport type { Entity, SecurityContext } from '@murumets-ee/entity'\nimport {\n getBlockTreeFieldName,\n getSearchableConfig,\n VIEW_UNPUBLISHED_ACTION,\n} from '@murumets-ee/entity'\nimport { QueryClient } from '@murumets-ee/entity/query'\nimport { eq } from 'drizzle-orm'\nimport { z } from 'zod'\nimport { Menu } from '../entity.js'\nimport { getMenu } from '../get-menu.js'\nimport { column } from '../internal/columns.js'\nimport { normalizeMenuEntityTypes } from '../menu-entity-types.js'\nimport { DEFAULT_MENU_DEPTH, MAX_MENU_DEPTH } from '../menu-item-schema.js'\nimport { collectMenuEntityRefs, normalizeMenuTree } from '../menu-tree-normalize.js'\nimport { collectBlockAnchors } from './anchors.js'\nimport type { MenuEditorStateResponse } from './editor-state.js'\nimport { resolveMenuItemSummaries } from './summaries.js'\n\n// ---------------------------------------------------------------------------\n// Request validation\n// ---------------------------------------------------------------------------\n\n/** Canonical UUID shape for the `id` query param. */\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\n/**\n * Longest `entity` param accepted, rejected before any lookup. Mirrors\n * `menu-item-schema.ts`'s `MAX_ENTITY_NAME_LENGTH` — the same bound the write\n * path already applies to an entity name inside a stored link.\n */\nconst MAX_ENTITY_NAME_LENGTH = 64\n\n/**\n * Role stand-in when the authenticated user carries none.\n *\n * `SecurityContext.user.groups` MUST be non-empty: `resolveAuthContext` reads\n * `groups[0]` and throws `ForbiddenError` on an empty array BEFORE it ever\n * consults the checker. The VALUE is inert here — the checker below ignores\n * its `role` argument and delegates to `ctx.checkPermission`, which the API\n * handler already bound to the caller's real role — so the least-privileged\n * built-in role is the honest placeholder.\n */\nconst FALLBACK_ROLE = 'viewer'\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\nfunction jsonResponse(data: unknown, status = 200): Response {\n return new Response(JSON.stringify(data), {\n status,\n headers: { 'Content-Type': 'application/json' },\n })\n}\n\nfunction jsonError(message: string, status: number): Response {\n return jsonResponse({ error: message }, status)\n}\n\n/** The one uniform \"nothing to offer\" answer — see the module header. */\nfunction noAnchors(): Response {\n return jsonResponse([])\n}\n\n/**\n * Name of the entity's `field.blockTree()` slot, or `null` when it has none.\n *\n * This WAS a deliberately local linear scan, justified on the grounds that\n * neither `@murumets-ee/blocks`' `resolveBlockTreeField` nor admin-ui's\n * `getBlocksFieldName` was reachable from this package — blocks' lives behind\n * its server entry, and admin-ui is a UI package `menus` must not depend on.\n * The reasoning was right and the conclusion was one step short: the answer\n * belongs in the LEAF all three already depend on. It now lives in\n * `@murumets-ee/entity` (`block-tree-fields.ts`), so this is a `null`-shaped\n * adaptor rather than a sixth copy of the loop.\n */\nfunction findBlockTreeField(entity: Entity): string | null {\n return getBlockTreeFieldName(entity) ?? null\n}\n\n// ---------------------------------------------------------------------------\n// GET /api/admin/menus/anchors\n// ---------------------------------------------------------------------------\n\nconst handleAnchors: AdminRouteHandler = async (req, ctx) => {\n const params = new URL(req.url).searchParams\n const entityName = params.get('entity')\n const id = params.get('id')\n\n // Boundary validation. These 400s describe the CALLER'S request, so they\n // disclose nothing about our data.\n if (entityName === null || entityName.length === 0) {\n return jsonError(\"query param 'entity' is required\", 400)\n }\n if (entityName.length > MAX_ENTITY_NAME_LENGTH) {\n return jsonError(\"query param 'entity' is too long\", 400)\n }\n if (id === null || !UUID_RE.test(id)) {\n return jsonError(\"query param 'id' must be a uuid\", 400)\n }\n\n const app = getCurrentApp()\n if (app === undefined) {\n // Handlers always run inside `runWithContextAsync`; no app means the\n // request never got a context, which is an internal wiring fault.\n return jsonError('Internal error', 500)\n }\n const logger = app.logger.child({ route: 'menus/anchors', entity: entityName })\n\n // Gate 2 — the per-target-entity check (see the module header). Answers with\n // the same empty list as every other negative outcome.\n if (!ctx.checkPermission(entityName, 'view')) {\n logger.debug(\n { userId: ctx.user.id, role: ctx.user.role },\n 'anchors lookup returned empty — caller holds no view grant on the target entity',\n )\n return noAnchors()\n }\n\n const entity = app.entities.get(entityName)\n if (entity === undefined) return noAnchors()\n\n const blockTreeField = findBlockTreeField(entity)\n // Not a block-tree-backed content type, so nothing on it is addressable.\n if (blockTreeField === null) return noAnchors()\n\n // Bound to the REAL caller — same identity, role and data scope the rest of\n // this request runs under. `scope` matters: without it a `team`/`user`-scoped\n // entity would skip its isolation filter on this read path.\n const scope = getCurrentScope()\n const client = new QueryClient({\n entity,\n db: app.db.readOnly,\n logger,\n contextResolver: (): SecurityContext => ({\n user: { id: ctx.user.id, groups: [ctx.user.role ?? FALLBACK_ROLE] },\n // Delegates to the caller's real grants for everything EXCEPT\n // `view_unpublished` (D017), which this endpoint pins closed regardless\n // of role — see the file header for why \"published layout\" is part of\n // the endpoint's definition rather than a visibility decision.\n checker: (_role, resource, action) =>\n action !== VIEW_UNPUBLISHED_ACTION && ctx.checkPermission(resource, action),\n ...(scope !== undefined && { scope }),\n ...siteSecurityFields(undefined, app),\n }),\n })\n\n // `locale` drives BOTH the per-locale publish filter and the translation\n // merge, so a translatable block tree resolves to the layout the admin is\n // actually editing. The client publish-filters (see the checker above), so a\n // draft / trashed / deleted row simply isn't found.\n const row = await client.findById(id, {\n ...(ctx.locale !== undefined && { locale: ctx.locale }),\n ...(ctx.defaultLocale !== undefined && { defaultLocale: ctx.defaultLocale }),\n })\n if (row === null) return noAnchors()\n\n const record: Record<string, unknown> = row\n const { anchors } = collectBlockAnchors(record[blockTreeField], logger)\n return jsonResponse(anchors)\n}\n\nconst anchorsRoute = defineAdminRoute({\n prefix: 'menus',\n path: 'anchors',\n method: 'GET',\n // Building a menu is the umbrella capability; the per-target-entity `view`\n // check inside the handler is the least-privilege half.\n permission: 'menu:view',\n // Mirrors the `menu` entity's own admin-only write posture (`entity.ts`):\n // only admins build menus today. `registerPermission` UNIONS default roles\n // across registrations, so this can only add, never narrow.\n defaultRoles: ['admin'],\n description:\n \"List a published document's addressable block anchors for the menu editor's anchor picker.\",\n tokens: 'accept',\n handler: handleAnchors,\n})\n\n// ---------------------------------------------------------------------------\n// GET /api/admin/menus/editor-state — the Tier 3 block-editor surface's read\n// ---------------------------------------------------------------------------\n\n/** Longest `handle` accepted — mirrors `get-menu.ts`'s own local bound. */\nconst MAX_HANDLE_LENGTH = 128\n\nconst handleEditorState: AdminRouteHandler = async (req, ctx) => {\n const handle = new URL(req.url).searchParams.get('handle')\n if (handle === null || handle.length === 0) {\n return jsonError(\"query param 'handle' is required\", 400)\n }\n if (handle.length > MAX_HANDLE_LENGTH) {\n return jsonError(\"query param 'handle' is too long\", 400)\n }\n\n const app = getCurrentApp()\n if (app === undefined) return jsonError('Internal error', 500)\n const logger = app.logger.child({ route: 'menus/editor-state' })\n\n // Bound to the REAL caller (same `app.getClient` framework accessor\n // `resolveMenuItemSummaries` uses below) — no elevation. A caller who\n // cannot view menus never reaches this handler at all (the route's static\n // `permission: 'menu:view'` gate below), so this is the ordinary\n // request-scoped client, not `createSystemAdminClient`.\n const client = app.getClient(Menu)\n const rows = await client.findMany({\n where: eq(column(client.getTable(), 'handle'), handle),\n limit: 1,\n })\n const row = rows[0] as Record<string, unknown> | undefined\n if (row === undefined) return jsonError('Menu not found', 404)\n\n const id = row.id as string\n const title = typeof row.title === 'string' ? row.title : ''\n const storedMaxDepth = row.maxDepth\n const maxDepth =\n typeof storedMaxDepth === 'number' && Number.isInteger(storedMaxDepth) && storedMaxDepth >= 1\n ? Math.min(storedMaxDepth, MAX_MENU_DEPTH)\n : DEFAULT_MENU_DEPTH\n const status: 'draft' | 'published' = row.status === 'published' ? 'published' : 'draft'\n\n // Codes only (no display labels) — see `RequestContext.availableLocales`.\n // Falling back to the code itself as the label is the same idiom\n // `generic-block-editor.tsx` uses when no richer locale registry is wired.\n const localeCodes = getContext()?.availableLocales\n const defaultLocale = ctx.defaultLocale ?? localeCodes?.[0] ?? 'en'\n // `MenuTreeEditorProps.defaultLocale` is contractually \"always present in\n // `locales`\", but the two come from DIFFERENT sources — `defaultLocale` from\n // `ctx.defaultLocale`, the list from the request's enabled `availableLocales`\n // — so they can disagree (a default locale that was later disabled, or a\n // context that carries one but not the other). When they do, prepend it:\n // otherwise the editor's preview switcher omits the very locale the tree is\n // authored in, and \"preview as default\" becomes unreachable.\n const enabled = localeCodes && localeCodes.length > 0 ? [...localeCodes] : []\n const codes = enabled.includes(defaultLocale) ? enabled : [defaultLocale, ...enabled]\n const locales = codes.map((code) => ({ code, label: code }))\n\n const draft = await loadPendingDraft(app, id, logger)\n const draftData = draft?.data\n\n // Seed from the pending DRAFT when there is one, else the live row — the\n // same precedence `MenuEditorPage` applies, and the same `EntityForm`\n // applies generally. Without it the editor would show the LIVE tree under a\n // banner saying \"you are editing a pending draft\", and a Save would silently\n // republish the pre-draft structure.\n const treeSource = draftData !== undefined && 'tree' in draftData ? draftData.tree : row.tree\n\n // The SAME tolerant per-node narrowing the standalone `/admin/menu/[id]`\n // page uses (`../menu-tree-normalize.ts` — one implementation, deliberately).\n //\n // NOT a strict whole-tree `buildMenuTreeSchema().safeParse`: that whitelists\n // locale KEYS against the app's currently-ENABLED locales, so disabling a\n // locale after items were authored for it failed the entire parse and\n // collapsed the tree to `[]` — a subsequent save from this surface would\n // then persist that wipe, while the standalone page (already tolerant) kept\n // every valid node from the same row. Two editing paths to the same data\n // must not disagree on malformed input.\n //\n // Whatever IS unreadable is counted, not swallowed: `droppedItemCount`\n // rides the response, and `<MenuTreeEditor>` refuses to save past it\n // without an explicit confirm.\n const { items: tree, dropped: droppedItemCount } = normalizeMenuTree(treeSource)\n if (droppedItemCount > 0) {\n logger.warn(\n { menuId: id, handle, dropped: droppedItemCount },\n 'menu editor-state: stored tree contained items that could not be read — the embedded editor shows the rest',\n )\n }\n\n const summaryMap = await resolveMenuItemSummaries(app, collectMenuEntityRefs(tree))\n const summaries = Object.fromEntries(summaryMap)\n\n // Same derivation `MenuEditorPage.resolveLinkEntityTypes` uses, minus the\n // admin-ui label helper (this package must not depend on `admin-ui` — see\n // the module header on `../plugin.ts`'s sibling `menus-ui` for why the\n // dependency runs the other way). `labelSingular` is the closest built-in\n // equivalent to a type-picker's \"one item of this type\" label.\n const entityTypes = [...app.entities.values()]\n .filter(\n (entity) =>\n getSearchableConfig(entity) !== undefined && ctx.checkPermission(entity.name, 'view'),\n )\n .map((entity) => ({\n name: entity.name,\n label: entity.admin?.labelSingular ?? entity.admin?.label ?? entity.name,\n }))\n\n const canPublish = ctx.checkPermission('menu', 'publish')\n\n // Same draft-then-live precedence as the tree above (routing PR12 added the\n // per-menu whitelist): a pending draft that narrowed which content types the\n // menu accepts must be what the editor shows, or a Save would silently\n // republish the pre-draft selection.\n const acceptedEntityTypes = normalizeMenuEntityTypes(\n draftData !== undefined && 'entityTypes' in draftData ? draftData.entityTypes : row.entityTypes,\n )\n\n const body: MenuEditorStateResponse = {\n menu: { id, handle, title, maxDepth, status, tree, entityTypes: acceptedEntityTypes },\n summaries,\n locales,\n defaultLocale,\n droppedItemCount,\n entityTypes,\n canPublish,\n ...(draft !== undefined && { draft }),\n }\n return jsonResponse(body)\n}\n\n/**\n * A menu's draft + lock are keyed by the DEFAULT snapshot key, never the\n * admin's current content locale: one tree serves every locale (D009), so a\n * per-locale draft would let two locales hold divergent copies of the same\n * structure. `'_'` is the api-handler's own default-snapshot key.\n * `MenuEditorPage` uses the same constant for the same reason.\n */\nconst MENU_SNAPSHOT_LOCALE = '_'\n\n/**\n * The pending draft for this menu, or `undefined`.\n *\n * Read through the REQUEST'S own `AdminClient` (`app.getClient`), so the\n * entity firewall applies exactly as it does everywhere else — no elevation.\n *\n * Failure degrades to \"no draft\" rather than failing the request, matching\n * `MenuEditorPage.loadMenuEditState`: a partially-down draft subsystem should\n * not take the editing surface down with it, and every write is re-authorized\n * server-side regardless of what this returns.\n *\n * ⚠️ **`ContentClient` is imported LAZILY, and must stay that way.**\n * `@murumets-ee/content/client` is `server-only`-tagged, and this module is on\n * the `lumi.config.ts` load path: `menus-ui`'s `menus()` plugin factory\n * statically imports `menusRoutes` from this entry, and the CLI / worker load\n * that config through jiti, which does NOT apply the `react-server` export\n * condition — so a MODULE-SCOPE import of it throws the instant `lumi migrate`\n * (or any other command) loads the config, taking every CLI command down with\n * it. A static import here did exactly that; `pnpm verify:plugin-loads` is the\n * gate that catches it (`packages/menus/dist/admin.mjs` failed to load).\n *\n * The dynamic import defers the module to CALL time, which only ever happens\n * inside a real admin-API request — i.e. inside RSC, where `server-only`\n * resolves to its empty stub. Exactly the same lazy-thunk technique, and the\n * same reasoning, as the page factories in `menus-ui`'s `plugin.ts`.\n */\nasync function loadPendingDraft(\n app: NonNullable<ReturnType<typeof getCurrentApp>>,\n menuId: string,\n logger: { warn(obj: object, msg: string): void },\n): Promise<MenuEditorStateResponse['draft']> {\n try {\n const { ContentClient } = await import('@murumets-ee/content/client')\n const contentClient = new ContentClient({ admin: app.getClient(Menu), entity: Menu })\n const entry = await contentClient.getDraft(menuId, MENU_SNAPSHOT_LOCALE)\n if (!entry) return undefined\n return {\n data: entry.data,\n createdBy: entry.createdBy,\n createdByName: entry.createdByName,\n // The response is JSON, so normalise `Date | string` to a string here\n // rather than letting `JSON.stringify` do it implicitly — the declared\n // response type then matches what actually crosses the wire.\n updatedAt:\n entry.updatedAt instanceof Date ? entry.updatedAt.toISOString() : String(entry.updatedAt),\n }\n } catch (err) {\n logger.warn({ err, menuId }, 'menu editor-state: draft state unavailable — continuing without')\n return undefined\n }\n}\n\nconst editorStateRoute = defineAdminRoute({\n prefix: 'menus',\n path: 'editor-state',\n method: 'GET',\n // Same gate the standalone `/admin/menu/[id]` page uses (`menus-ui/plugin.ts`\n // — `requires: 'menu:view'`). Mutations still go through the entity's own\n // `menu:update` / `menu:publish` gates on the generic entity CRUD routes —\n // this endpoint is read-only.\n permission: 'menu:view',\n defaultRoles: ['admin'],\n description:\n \"Read a menu's full tree + editor metadata by handle, for the blocks editor's in-place Tier 3 menu surface.\",\n tokens: 'accept',\n handler: handleEditorState,\n})\n\n// ---------------------------------------------------------------------------\n// GET /api/admin/menus/resolved — menus a canvas came to name after it loaded\n// ---------------------------------------------------------------------------\n\n/**\n * Most distinct handles one request resolves — the amplification bound.\n *\n * The number is `MAX_RENDERED_MENU_HANDLES` in `@murumets-ee/blocks/library`:\n * what one canvas resolves when it opens, so what one canvas can come to need.\n * Restated rather than imported because that entry is the whole block library\n * and this module is on the `lumi.config.ts` load path; `routes.test.ts` holds\n * the two equal.\n */\nexport const MAX_RESOLVED_HANDLES = 8\n\n/**\n * The query, validated before any lookup. Repeated `handle` params rather than\n * one comma-joined value: a handle is stored text, and splitting it on a\n * character it may contain would ask for menus nobody named.\n */\nconst resolvedQuerySchema = z.object({\n handles: z.array(z.string().min(1).max(MAX_HANDLE_LENGTH)).min(1).max(MAX_RESOLVED_HANDLES),\n})\n\n/** `GET /api/admin/menus/resolved` — `null` for a handle with no published menu. */\nexport interface ResolvedMenusResponse {\n // The blocks package's own item type, not this package's `ResolvedMenuItem`:\n // the canvas reads these into `ResolvedMenusContext`, and naming the local\n // type here would declare it a second time in this entry's `.d.mts`\n // (`verify:dts`).\n menus: Record<string, ResolvedMenuNavItem[] | null>\n}\n\n/**\n * The published items of the menus an editing canvas names.\n *\n * A canvas resolves its menus once, on the server, when the page opens\n * (`resolveCanvasMenus` in admin-ui's `canvas-chrome.ts`). A `menu` block added\n * afterwards, a handle changed, or a page reached by navigating inside the\n * canvas can name a menu that load never saw; this is where the canvas asks.\n *\n * It answers with exactly what that load would have: the SAME resolver\n * (`getMenu` over `buildMenuResolveDeps`), so published-only, confined to the\n * request's site, and reach-only — never a draft menu, never an unpublished\n * target. That is what a visitor to the site is served, which is why the gate\n * is the canvas's own read permission rather than `menu:view`: see the route.\n *\n * Every handle that RESOLVED is in the answer. `null` means \"no published menu\n * at that handle\", which the canvas records and does not ask for again. A\n * handle whose lookup FAILED is logged and left out, so a passing fault is not\n * remembered as a missing menu and the canvas may ask again. An empty array is\n * a published menu with no items, which a render treats differently from no\n * menu.\n *\n * Bounded: at most {@link MAX_RESOLVED_HANDLES} distinct handles, each one\n * `getMenu` call. More than that, or an over-long handle, is a 400.\n */\nconst handleResolved: AdminRouteHandler = async (req, ctx) => {\n const parsed = resolvedQuerySchema.safeParse({\n handles: [...new Set(new URL(req.url).searchParams.getAll('handle'))],\n })\n if (!parsed.success) {\n return jsonError(\n `query param 'handle' must be given 1 to ${MAX_RESOLVED_HANDLES} times, each 1 to ${MAX_HANDLE_LENGTH} characters`,\n 400,\n )\n }\n const { handles } = parsed.data\n\n const app = getCurrentApp()\n if (app === undefined) return jsonError('Internal error', 500)\n const logger = app.logger.child({ route: 'menus/resolved' })\n\n // Loaded on use, like `loadPendingDraft`'s import below and for its reason:\n // the resolver's app binding reaches `@murumets-ee/content`'s resolve graph,\n // which has no place on the config-load path this module is on.\n const { buildMenuResolveDeps } = await import('../menu-resolve-context.js')\n const deps = buildMenuResolveDeps(app)\n // The locale the api-handler validated off `?locale=`; a request without one\n // is answered in the site's default, as the canvas itself would be drawn.\n const locale = ctx.locale ?? ctx.defaultLocale ?? deps.defaultLocale\n\n const resolved = await Promise.all(\n handles.map(async (handle): Promise<[string, ResolvedMenuNavItem[] | null] | undefined> => {\n try {\n // No `currentPath`, as on the canvas's own load: an editing surface is\n // not a visit to a URL.\n const menu = await getMenu(handle, { locale }, deps)\n return [handle, menu === null ? null : menu.items]\n } catch (err) {\n logger.warn(\n { err, handle, locale },\n 'menu failed to resolve for the canvas; it is left out of the answer',\n )\n return undefined\n }\n }),\n )\n const body: ResolvedMenusResponse = {\n menus: Object.fromEntries(\n resolved.filter(\n (entry): entry is [string, ResolvedMenuNavItem[] | null] => entry !== undefined,\n ),\n ),\n }\n return jsonResponse(body)\n}\n\nconst resolvedRoute = defineAdminRoute({\n prefix: 'menus',\n path: 'resolved',\n method: 'GET',\n // The canvas's own read permission — the one its in-canvas navigation read\n // (`GET /api/admin/blocks/editor-state`) is gated on — and NOT `menu:view`.\n // What this serves is the published menu a visitor already gets from the\n // content API, and the page's server render resolves it for anyone who can\n // open the canvas, whatever their grant on `menu`. Gating on `menu:view`\n // would have a role that edits pages but not menus see a menu on load and a\n // placeholder for the one it just added.\n permission: 'blocks:read-editor-state',\n // Catalogue metadata only (it seeds nothing): the roles that permission is\n // declared for by `@murumets-ee/blocks`.\n defaultRoles: ['admin', 'editor'],\n description:\n 'Read the published, resolved items of the menus an editing canvas names, by handle.',\n // An agent's delegated browser session opens the canvas too (agent-access\n // D020). A read of published navigation; it changes nothing.\n tokens: 'accept',\n handler: handleResolved,\n})\n\n/**\n * Every admin route this package contributes, in the `AdminRoute[]` shape\n * `createAdminApiHandler` consumes.\n *\n * Wired declaratively via the plugin's `server.routes` slot — see\n * `@murumets-ee/menus-ui`.\n *\n * @example\n * ```ts\n * import { menusRoutes } from '@murumets-ee/menus/admin'\n *\n * definePlugin({ name: '…', server: { routes: menusRoutes() } })\n * ```\n */\nexport function menusRoutes(): AdminRoute[] {\n return combineAdminRoutes([anchorsRoute, editorStateRoute, resolvedRoute])\n}\n"],"mappings":"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"}