@murumets-ee/menus 0.0.0-preview-20261014110621

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.
Files changed (44) hide show
  1. package/LICENSE +94 -0
  2. package/dist/admin.d.mts +363 -0
  3. package/dist/admin.d.mts.map +1 -0
  4. package/dist/admin.mjs +2 -0
  5. package/dist/admin.mjs.map +1 -0
  6. package/dist/columns-DwDukx_r.mjs +2 -0
  7. package/dist/columns-DwDukx_r.mjs.map +1 -0
  8. package/dist/en-BcniJZiB.mjs +2 -0
  9. package/dist/en-BcniJZiB.mjs.map +1 -0
  10. package/dist/entity-BC8r_5xy.mjs +2 -0
  11. package/dist/entity-BC8r_5xy.mjs.map +1 -0
  12. package/dist/et-BL3ZuuFV.mjs +2 -0
  13. package/dist/et-BL3ZuuFV.mjs.map +1 -0
  14. package/dist/i18n.d.mts +16 -0
  15. package/dist/i18n.d.mts.map +1 -0
  16. package/dist/i18n.mjs +2 -0
  17. package/dist/i18n.mjs.map +1 -0
  18. package/dist/index.d.mts +75 -0
  19. package/dist/index.d.mts.map +1 -0
  20. package/dist/index.mjs +1 -0
  21. package/dist/menu-entity-types-BnKnKbif.mjs +2 -0
  22. package/dist/menu-entity-types-BnKnKbif.mjs.map +1 -0
  23. package/dist/menu-item-schema-_Nj03ytp.d.mts +296 -0
  24. package/dist/menu-item-schema-_Nj03ytp.d.mts.map +1 -0
  25. package/dist/menu-resolve-context-DLIUP8RY.mjs +2 -0
  26. package/dist/menu-resolve-context-DLIUP8RY.mjs.map +1 -0
  27. package/dist/messages/en.json +22 -0
  28. package/dist/messages/et.json +22 -0
  29. package/dist/messages/ru.json +22 -0
  30. package/dist/resolve.d.mts +205 -0
  31. package/dist/resolve.d.mts.map +1 -0
  32. package/dist/resolve.mjs +2 -0
  33. package/dist/resolve.mjs.map +1 -0
  34. package/dist/ru-D8QNW6XE.mjs +2 -0
  35. package/dist/ru-D8QNW6XE.mjs.map +1 -0
  36. package/dist/schema.d.mts +280 -0
  37. package/dist/schema.d.mts.map +1 -0
  38. package/dist/schema.mjs +2 -0
  39. package/dist/schema.mjs.map +1 -0
  40. package/dist/seed.d.mts +67 -0
  41. package/dist/seed.d.mts.map +1 -0
  42. package/dist/seed.mjs +2 -0
  43. package/dist/seed.mjs.map +1 -0
  44. package/package.json +62 -0
@@ -0,0 +1,205 @@
1
+ import { ToolkitApp } from "@murumets-ee/core";
2
+
3
+ //#region src/get-menu.d.ts
4
+ /**
5
+ * `getMenu()` — turn a stored menu tree into a render-ready structure for one
6
+ * locale (plan/routing phase 11 §2), plus the pure `markActiveTrail()` helper
7
+ * (phase 11 §3).
8
+ *
9
+ * **Leaf module.** Every cross-package capability (the menu row read, the
10
+ * `toolkit_paths` forward index, the publish gate, live entity titles) arrives
11
+ * through the injected {@link MenuResolveDeps}; nothing here imports
12
+ * `@murumets-ee/core` or `@murumets-ee/content`. Concrete deps are built from a
13
+ * live app in `./menu-resolve-context.ts` — the same leaf/seam split
14
+ * `@murumets-ee/content`'s `paths/enumerate.ts` ↔ `paths/resolve-context.ts`
15
+ * uses, and the reason the deps' function types are declared structurally here
16
+ * rather than imported (assignability is still checked, at the seam).
17
+ *
18
+ * **Security.** The resolved output feeds PUBLIC page navigation, so it must
19
+ * never surface an unpublished or deleted document:
20
+ * - the menu itself is read through a publish-filtered client (an unpublished
21
+ * menu resolves to `null` — nothing renders),
22
+ * - every entity-linked item must have BOTH a canonical path in
23
+ * `toolkit_paths` for the requested locale AND survive the batched publish
24
+ * gate; missing either → the item (and its subtree) is dropped,
25
+ * - `url` / `email` targets are re-gated through the same `sanitizeHref`
26
+ * protocol allowlist the write path uses, so a value that reached the column
27
+ * by some route other than the entity API (a direct DB edit, a restored
28
+ * dump) still cannot become a `javascript:` href,
29
+ * - every filtering decision fails CLOSED — anything unresolvable is dropped,
30
+ * never rendered with a placeholder.
31
+ *
32
+ * **No unbounded fan-out** (CLAUDE.md). Backend work is bounded by the number
33
+ * of distinct ENTITY TYPES in the tree, never by the number of items: the walk
34
+ * collects ids grouped by type, then issues at most THREE batched calls per
35
+ * group (forward index → publish gate → titles). The group count is itself hard-
36
+ * capped ({@link MAX_MENU_ENTITY_TYPES}) and the walk carries a node budget
37
+ * ({@link MAX_MENU_TREE_NODES}), so one render costs a fixed, small number of
38
+ * round-trips no matter what the stored tree looks like.
39
+ *
40
+ * **Read-time validation is per-node, not whole-tree.** The write path already
41
+ * runs `buildMenuTreeSchema()` (`menuTreeValidation()`), and this module
42
+ * deliberately does NOT re-run it: that schema whitelists locale KEYS against
43
+ * the app's configured locales, so re-parsing at read time would fail the WHOLE
44
+ * menu — site navigation gone — the moment an operator removes a locale from
45
+ * config after a menu was saved. The locale whitelist is a write-time authoring
46
+ * check with no read-time security value. What DOES matter at read time (node
47
+ * shape, the `LinkValue` protocol gate) is re-checked per node here, and a
48
+ * malformed node is dropped on its own without taking the menu down with it.
49
+ */
50
+ /**
51
+ * Hard cap on the number of DISTINCT entity types one menu may fan out over.
52
+ *
53
+ * The batched pipeline costs three DB round-trips per type group, so this — not
54
+ * the item count — is what bounds a render's backend work. A navigation menu
55
+ * pointing at more than a handful of routable types is malformed by
56
+ * construction; types past the cap are dropped (their items disappear) and
57
+ * logged, never silently truncated.
58
+ */
59
+ declare const MAX_MENU_ENTITY_TYPES = 16;
60
+ /** One render-ready menu item (phase 11 §2 — exact shape). */
61
+ interface ResolvedMenuItem {
62
+ /** The stored item's stable id (DND identity; also a stable React key). */
63
+ id: string;
64
+ kind: 'link' | 'header';
65
+ /** Resolved label: the per-locale override, else the live entity title. */
66
+ label: string;
67
+ /**
68
+ * Locale-relative canonical path from `toolkit_paths` (+ `#anchor` when set),
69
+ * external `url` / `email` targets verbatim (protocol-gated), `null` for
70
+ * `header` items.
71
+ */
72
+ href: string | null;
73
+ newTab: boolean;
74
+ /** Exact `currentPath` match on the path part (any `#anchor` ignored). */
75
+ isActive: boolean;
76
+ /** An ancestor BY TREE of the active item — never a path-prefix guess. */
77
+ inActiveTrail: boolean;
78
+ children: ResolvedMenuItem[];
79
+ }
80
+ /**
81
+ * A resolved menu.
82
+ *
83
+ * Phase 11 §2 names `ResolvedMenuItem` and uses `ResolvedMenu` as `getMenu`'s
84
+ * return type without spelling its fields out; this is the minimal wrapper that
85
+ * satisfies it — the requested `handle` (so a caller holding several resolved
86
+ * menus can tell them apart, and so `markActiveTrail` can round-trip a menu
87
+ * without losing its identity) plus the item forest. Deliberately carries no
88
+ * `maxDepth` / `title`: the depth clamp is already APPLIED in `items`, and a
89
+ * menu's admin-facing title is not a rendering concern.
90
+ */
91
+ interface ResolvedMenu {
92
+ handle: string;
93
+ items: ResolvedMenuItem[];
94
+ }
95
+ /** Options for {@link getMenu} (phase 11 §2). */
96
+ interface GetMenuOptions {
97
+ /** The locale to resolve labels, visibility and canonical paths for. */
98
+ locale: string;
99
+ /**
100
+ * The page being rendered, for active-trail marking. SSG frontends usually
101
+ * OMIT this (one build-time fetch) and call {@link markActiveTrail} per page.
102
+ */
103
+ currentPath?: string | undefined;
104
+ }
105
+ /** The projected `menu` row {@link getMenu} reads. */
106
+ interface MenuRow {
107
+ /**
108
+ * The menu's own depth cap. Absent / out of range falls back to the entity
109
+ * default — the clamp is renderer-enforced independently of the editor
110
+ * (phase 07 §2.7), so a bad stored value can never widen it.
111
+ */
112
+ maxDepth?: number | null | undefined;
113
+ /** Raw `menu.tree` JSONB. Untrusted shape — validated per node during the walk. */
114
+ tree: unknown;
115
+ }
116
+ /**
117
+ * Structural minimum of a pino/toolkit logger. Declared here (rather than
118
+ * imported) so this module stays import-free; `app.logger.child(...)` satisfies
119
+ * it structurally.
120
+ */
121
+ interface MenuResolveLogger {
122
+ warn(obj: object, msg: string): void;
123
+ }
124
+ /**
125
+ * Everything {@link getMenu} needs from the outside world.
126
+ *
127
+ * Function-shaped (not reader-shaped) on purpose: the concrete implementations
128
+ * live in `./menu-resolve-context.ts`, where assignability against
129
+ * `@murumets-ee/content`'s real `getCanonicalPaths` / `PublishFilter` is
130
+ * type-checked at the seam — so this module needs no `@murumets-ee/content`
131
+ * import and drift is still a build error.
132
+ */
133
+ interface MenuResolveDeps {
134
+ /**
135
+ * Fetch the PUBLISHED menu row by handle, or `null`.
136
+ *
137
+ * The publish filter is the implementation's responsibility (the app-side
138
+ * factory reads through a publish-filtering `QueryClient`) — a draft menu
139
+ * must never resolve, per phase 11 §2.
140
+ */
141
+ getMenuRow: (handle: string) => Promise<MenuRow | null>;
142
+ /**
143
+ * Batched forward index read: which of `ids` have a canonical path in
144
+ * `locale`? Does NOT publish-filter — {@link MenuResolveDeps.filterPublished}
145
+ * is the required second step.
146
+ */
147
+ resolveCanonicalPaths: (entityType: string, ids: readonly string[], locale: string) => Promise<ReadonlyMap<string, string>>;
148
+ /** Batched publish gate: which of `ids` are published in `locale`? */
149
+ filterPublished: (entityType: string, ids: readonly string[], locale: string) => Promise<ReadonlySet<string>>;
150
+ /**
151
+ * Batched live-title read: `id → display title` for `locale`. Only called for
152
+ * the ids that actually need one (no per-locale label override), and never
153
+ * for `header` items (they have no linked entity to borrow a title from).
154
+ */
155
+ resolveTitles: (entityType: string, ids: readonly string[], locale: string) => Promise<ReadonlyMap<string, string>>;
156
+ /**
157
+ * Fallback locale for label overrides — the app's configured content
158
+ * `defaultLocale`. A label present only in the default locale still renders
159
+ * (the same locale → default fallback `mergeTranslations` gives entity data),
160
+ * instead of silently dropping a partially-translated item.
161
+ */
162
+ defaultLocale: string;
163
+ /** Optional — used to report DROPPED work (never silent truncation). */
164
+ logger?: MenuResolveLogger | undefined;
165
+ }
166
+ /**
167
+ * Mark `isActive` / `inActiveTrail` on a resolved menu for one page.
168
+ *
169
+ * PURE — no I/O, and the input menu is not mutated (a new tree is returned), so
170
+ * an SSG build can fetch a menu ONCE and mark it per page without the pages
171
+ * contaminating each other. `getMenu` calls this internally when
172
+ * `opts.currentPath` is given, so there is exactly one implementation of the
173
+ * ancestry walk.
174
+ *
175
+ * `isActive` is an EXACT match on the path part; `inActiveTrail` marks strict
176
+ * tree ancestors of an active item (an active item is not its own ancestor).
177
+ */
178
+ declare function markActiveTrail(menu: ResolvedMenu, currentPath: string): ResolvedMenu;
179
+ /**
180
+ * Resolve a stored menu into a render-ready tree for one locale (phase 11 §2).
181
+ *
182
+ * Returns `null` when the menu does not exist or is not published — a draft
183
+ * menu renders nowhere public. A published menu whose every item was filtered
184
+ * out returns an EMPTY item list, not `null`: "the menu exists but has nothing
185
+ * to show here" is a different fact from "there is no such menu".
186
+ *
187
+ * Filtering order (phase 11 §2, not reorderable): menu unpublished → `null`;
188
+ * `enabled[locale] === false` → dropped with its subtree; entity target deleted
189
+ * or unpublished-in-locale → dropped; header with no surviving children →
190
+ * dropped; depth clamped to the menu's own `maxDepth`.
191
+ */
192
+ declare function getMenu(handle: string, opts: GetMenuOptions, deps: MenuResolveDeps): Promise<ResolvedMenu | null>;
193
+ //#endregion
194
+ //#region src/menu-resolve-context.d.ts
195
+ /**
196
+ * Build the concrete {@link MenuResolveDeps} for an app.
197
+ *
198
+ * Cheap to call per request (it constructs no clients eagerly — each read
199
+ * builds its own `QueryClient`, matching `buildEnumerateDeps`'s per-call shape,
200
+ * so this never depends on plugin boot ORDER).
201
+ */
202
+ declare function buildMenuResolveDeps(app: ToolkitApp): MenuResolveDeps;
203
+ //#endregion
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
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resolve.d.mts","names":[],"sources":["../src/get-menu.ts","../src/menu-resolve-context.ts"],"mappings":";;;;;;AAqEA;;;;AAAkC;AAelC;;;;;;;;;;;;;;AAiB4B;AAc5B;;;;;;;;AAEyB;AAIzB;;;;AAOa;AAQb;;;;AAQM;AAQN;;;;;;;;AAC+B;AAY/B;;;;cAhGa,qBAAA;;UAeI,gBAAA;EAyGF;EAvGb,EAAA;EACA,IAAA;EAgHK;EA9GL,KAAA;EAuH0B;;;;;EAjH1B,IAAA;EACA,MAAA;EAoFE;EAlFF,QAAA;EAoFE;EAlFF,aAAA;EACA,QAAA,EAAU,gBAAgB;AAAA;;;;;;;;;;;;UAcX,YAAA;EACf,MAAA;EACA,KAAA,EAAO,gBAAgB;AAAA;AA2FG;AAAA,UAvFX,cAAA;EA+cc;EA7c7B,MAAA;EA6coF;;;;EAxcpF,WAAW;AAAA;AAwcyE;AAAA,UAhcrE,OAAA;EAqdY;;;;;EA/c3B,QAAA;EAmdQ;EAjdR,IAAI;AAAA;;;;;;UAQW,iBAAA;EACf,IAAA,CAAK,GAAA,UAAa,GAAA;AAAA;;;;ACrFpB;;;;;;UDiGiB,eAAA;ECjGqD;AAAA;;;;;;EDyGpE,UAAA,GAAa,MAAA,aAAmB,OAAA,CAAQ,OAAA;;;;;;EAMxC,qBAAA,GACE,UAAA,UACA,GAAA,qBACA,MAAA,aACG,OAAA,CAAQ,WAAA;;EAEb,eAAA,GACE,UAAA,UACA,GAAA,qBACA,MAAA,aACG,OAAA,CAAQ,WAAA;;;;;;EAMb,aAAA,GACE,UAAA,UACA,GAAA,qBACA,MAAA,aACG,OAAA,CAAQ,WAAA;;;;;;;EAOb,aAAA;;EAEA,MAAA,GAAS,iBAAA;AAAA;;;;;;;;;;;;;iBAwXK,eAAA,CAAgB,IAAA,EAAM,YAAA,EAAc,WAAA,WAAsB,YAAY;;;;;;;;;;;;;;iBAqBhE,OAAA,CACpB,MAAA,UACA,IAAA,EAAM,cAAA,EACN,IAAA,EAAM,eAAA,GACL,OAAA,CAAQ,YAAA;;;;;;;AA5ec;AAIzB;;iBCrDgB,oBAAA,CAAqB,GAAA,EAAK,UAAA,GAAa,eAAe"}
@@ -0,0 +1,2 @@
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
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resolve.mjs","names":[],"sources":["../src/get-menu.ts","../src/internal/columns.ts","../src/menu-resolve-context.ts"],"sourcesContent":["/**\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 * Typed column access for a dynamically-generated entity table.\n *\n * `AdminClient.getTable()` / `QueryClient.getTable()` both return\n * `PgTableWithColumns<any>` (Drizzle cannot type columns that are generated\n * from a runtime entity definition), so reading a column off one directly\n * yields `any` and silently erodes this package's type coverage. Narrowing\n * through this helper pins the ONE column being filtered on — the same idiom\n * `@murumets-ee/content`'s `buildEnumerateDeps` uses.\n *\n * Internal to `@murumets-ee/menus`; not part of any public entry.\n */\n\nimport type { PgColumn, PgTableWithColumns } from 'drizzle-orm/pg-core'\n\n/** Read one named column off a runtime-generated entity table, typed. */\nexport function column<K extends string>(\n // biome-ignore lint/suspicious/noExplicitAny: Drizzle cannot type dynamically-generated column schemas — `PgTableWithColumns<any>` is `getTable()`'s own declared return type (CLAUDE.md's sanctioned case).\n table: PgTableWithColumns<any>,\n name: K,\n): PgColumn {\n return (table as unknown as Record<K, PgColumn>)[name]\n}\n","/**\n * App-binding for {@link import('./get-menu.js').getMenu} (plan/routing PR09).\n *\n * `./get-menu.ts` is a LEAF — it takes every dependency as an injected function\n * so it imports no `@murumets-ee/core` graph. THIS module is the outer seam\n * that holds those imports and builds concrete deps from a live `ToolkitApp`,\n * mirroring `@murumets-ee/content`'s `paths/resolve-context.ts` exactly. Because\n * the real `getCanonicalPaths` / `PublishFilter` are ASSIGNED to the leaf's\n * structurally-declared dep slots here, drift between the two packages is a\n * build error at this file rather than a silent divergence.\n *\n * **Reads run under an ELEVATED system context** (same `SYSTEM_CONTEXT` shape\n * and rationale as content's `resolve-context.ts` — read that file's header).\n * Rendering a menu is a SYSTEM derivation: it must work for an anonymous public\n * request, or for a build-time SSG run with no request context at all, and must\n * not fail just because the caller holds no per-entity `view` grant. Elevation\n * bypasses ONLY the permission/scope assertion — `QueryClient`'s PUBLISH filter\n * and trashed-row exclusion still apply, because the elevated context grants\n * reach WITHOUT `view_unpublished` (D017; see {@link SYSTEM_CONTEXT}), so:\n * - an unpublished / trashed MENU still resolves to `null` (nothing renders),\n * - an unpublished / trashed TARGET is still absent from the publish gate and\n * from the title read, so its item is dropped.\n * The only data elevation exposes is a published document's title + canonical\n * path — the same facts the public sitemap already enumerates.\n */\n\nimport { createDetailTemplateProvider } from '@murumets-ee/content/detail-template'\nimport { getContentConfig } from '@murumets-ee/content/plugin'\nimport {\n buildCanonicalPathsBatchDeps,\n buildEnumerateDeps,\n getCanonicalPaths,\n} from '@murumets-ee/content/resolve'\nimport type { ToolkitApp } from '@murumets-ee/core'\nimport { createSystemReachResolver } from '@murumets-ee/core'\nimport type { SecurityContext } from '@murumets-ee/entity'\nimport { getEntityTitleField, isTemplateDrawn } from '@murumets-ee/entity'\nimport { QueryClient } from '@murumets-ee/entity/query'\nimport { eq, inArray } from 'drizzle-orm'\nimport { Menu } from './entity.js'\nimport type { MenuResolveDeps, MenuRow } from './get-menu.js'\nimport { column } from './internal/columns.js'\n\n/**\n * Elevated **reach** resolver — grants reach, never visibility of unpublished\n * rows. See the file header.\n *\n * That invariant used to rest on `QueryClient` applying the publish filter\n * unconditionally; since D017 it rests on this resolver withholding\n * `view_unpublished`, which {@link createSystemReachResolver} does centrally.\n * A `checker: () => true` here would make `_menu` render items pointing at\n * draft documents.\n */\n// Named, not defaulted: `content`'s path resolver is the other elevated reader\n// and passes `'path-resolver'`. Two of them logging the default `'system'` would\n// be indistinguishable in audit output.\nconst SYSTEM_CONTEXT: () => SecurityContext = createSystemReachResolver('menu-resolver')\n\n/** Ids per batched title lookup — mirrors content's `CANONICAL_BATCH_SIZE`. */\nconst TITLE_BATCH_SIZE = 500\n\n/**\n * Build the concrete {@link MenuResolveDeps} for an app.\n *\n * Cheap to call per request (it constructs no clients eagerly — each read\n * builds its own `QueryClient`, matching `buildEnumerateDeps`'s per-call shape,\n * so this never depends on plugin boot ORDER).\n */\nexport function buildMenuResolveDeps(app: ToolkitApp): MenuResolveDeps {\n const db = app.db.readOnly\n const canonicalBatch = buildCanonicalPathsBatchDeps(app)\n const { filterPublished } = buildEnumerateDeps(app)\n\n return {\n getMenuRow: async (handle: string): Promise<MenuRow | null> => {\n const client = new QueryClient({\n entity: Menu,\n db,\n logger: app.logger.child({ resolve: 'menu' }),\n contextResolver: SYSTEM_CONTEXT,\n })\n // `publishable()` on `Menu` means this client filters to PUBLISHED rows\n // for any principal without `view_unpublished` — and `SYSTEM_CONTEXT`\n // withholds it (D017), so the filter always applies HERE. A draft menu\n // returns nothing, which `getMenu` turns into `null`. Do not swap this\n // resolver for a blanket `checker: () => true`: that grants the action and\n // silently publishes drafts.\n //\n // No `locale` is passed: the menu entity has no translations and no\n // locale-status table (locale variance lives INSIDE the tree, per D009),\n // so a locale would only cost a pointless translation merge.\n const rows = await client.findMany({\n where: eq(column(client.getTable(), 'handle'), handle),\n limit: 1,\n })\n const row = rows[0]\n if (row === undefined) return null\n return { maxDepth: row.maxDepth, tree: row.tree }\n },\n\n resolveCanonicalPaths: (entityType, ids, locale) =>\n getCanonicalPaths(entityType, ids, locale, canonicalBatch),\n\n filterPublished,\n\n resolveTitles: async (entityType, ids, locale) => {\n const titles = new Map<string, string>()\n if (ids.length === 0) return titles\n // An unregistered entity type yields NO title, so its items are dropped\n // — fail-closed, matching `buildEnumerateDeps`'s own unknown-type branch.\n const entity = app.entities.get(entityType)\n if (entity === undefined) return titles\n\n // A row DRAWN BY A STORED TEMPLATE (block-library D009, F051) is public\n // only as far as its site's detail bindings say. Its admin title column is\n // not one of those values (a legal document's is its `type` key), so it is\n // never read for a label: the item takes the row's DECLARED title — the\n // same one its own page shows — and drops if the site declares none.\n const declaredTitles = isTemplateDrawn(entity)\n ? createDetailTemplateProvider(() => app, {\n defaultLocale: getContentConfig().defaultLocale,\n })\n : undefined\n\n const titleField = getEntityTitleField(entity)\n // `getEntityTitleField` degrades to the row's own `id` when the entity has\n // no display-able field — a raw UUID is not a menu label. Fail closed and\n // let the item drop, matching this module's own stated posture.\n if (declaredTitles === undefined && titleField === 'id') return titles\n const client = new QueryClient({\n entity,\n db,\n logger: app.logger.child({ resolve: 'menu-title', entity: entityType }),\n contextResolver: SYSTEM_CONTEXT,\n })\n const idColumn = column(client.getTable(), 'id')\n\n for (let i = 0; i < ids.length; i += TITLE_BATCH_SIZE) {\n const chunk = ids.slice(i, i + TITLE_BATCH_SIZE)\n // `locale` drives `mergeTranslations`, so a translatable title comes\n // back in the requested locale (falling back to the base row's value)\n // — the \"live entity title (locale-merged)\" phase 11 §2 asks for.\n const rows = await client.findMany({\n where: inArray(idColumn, [...chunk]),\n locale,\n limit: chunk.length,\n })\n for (const row of rows) {\n const record: Record<string, unknown> = row\n const id = record.id\n let title: unknown\n if (declaredTitles === undefined) {\n title = record[titleField]\n } else {\n // Per ITEM, like everything else here: a projection that throws\n // costs that one label (the item drops), never the whole menu.\n try {\n title = (await declaredTitles.publicRow({ entity: entityType, row: record, locale }))\n .title\n } catch (err) {\n app.logger.warn(\n { err, entity: entityType },\n 'menus: detail-template title projection failed — the item is dropped',\n )\n }\n }\n if (typeof id === 'string' && typeof title === 'string' && title.length > 0) {\n titles.set(id, title)\n }\n }\n }\n return titles\n },\n\n defaultLocale: getContentConfig().defaultLocale,\n logger: app.logger.child({ resolve: 'menu' }),\n }\n}\n"],"mappings":"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"}
@@ -0,0 +1,2 @@
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
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ru-D8QNW6XE.mjs","names":[],"sources":["../src/messages/ru.json"],"sourcesContent":[""],"mappings":""}
@@ -0,0 +1,280 @@
1
+ import { _ as validateTreeDepth, a as MAX_ANCHOR_LENGTH, b as menuAcceptsEntityType, c as MAX_MENU_URL_LENGTH, d as MenuLinkValue, f as MenuSchemaOptions, g as measureMenuTree, h as buildMenuTreeSchema, i as LinkValueSchema, l as MenuItem, m as buildMenuItemSchema, n as BlockAnchor, o as MAX_MENU_DEPTH, p as MenuTreeMeasurement, r as DEFAULT_MENU_DEPTH, s as MAX_MENU_TREE_NODES, t as ANCHOR_PATTERN, u as MenuItemSummary, v as MAX_MENU_ENTITY_TYPE_WHITELIST, x as normalizeMenuEntityTypes, y as MenuEntityTypesSchema } from "./menu-item-schema-_Nj03ytp.mjs";
2
+
3
+ //#region src/menu-tree-flatten.d.ts
4
+ /** A menu node's payload without its children — structure lives on the flat node. */
5
+ type MenuNodeData = Omit<MenuItem, 'children'>;
6
+ /** One row of the flattened, DND-annotated tree. */
7
+ interface FlatMenuNode {
8
+ readonly id: string;
9
+ /** Owning node's id, or `null` for a root row. */
10
+ readonly parentId: string | null;
11
+ /** 0-based nesting level (see the module header's depth convention). */
12
+ readonly depth: number;
13
+ /** Position among the row's siblings. */
14
+ readonly index: number;
15
+ /**
16
+ * How many direct children the node has. Carried on the row (rather than
17
+ * derived from the list) so it stays correct while a drag has temporarily
18
+ * lifted the node's descendants out of the sortable list.
19
+ */
20
+ readonly childCount: number;
21
+ /** The item itself, minus `children`. */
22
+ readonly data: MenuNodeData;
23
+ }
24
+ /**
25
+ * Flatten a nested menu tree into document order.
26
+ *
27
+ * Recursive rather than iterative, deliberately: the input is an
28
+ * ALREADY-NORMALISED tree (`normalizeMenuTree` bounds it to
29
+ * {@link MAX_MENU_DEPTH} levels and 500 nodes before it ever reaches the
30
+ * client), so there is no hostile-payload stack-depth exposure here — unlike
31
+ * `measureMenuTree`, which parses raw JSONB and is iterative for exactly that
32
+ * reason.
33
+ */
34
+ declare function flattenMenuTree(items: readonly MenuItem[]): FlatMenuNode[];
35
+ /**
36
+ * Rebuild a nested tree from a flat list, honouring each row's `parentId`.
37
+ *
38
+ * A row whose `parentId` names no row in the list is promoted to the ROOT
39
+ * rather than dropped — silently losing a node on a whole-tree save is the one
40
+ * failure mode this editor must never have (it is the same reason
41
+ * `normalizeMenuTree` counts what it refuses). Cycles cannot arise from the
42
+ * editor's own operations: {@link projectDrop} only ever assigns a parent that
43
+ * appears EARLIER in the list.
44
+ */
45
+ declare function buildMenuTree(flat: readonly FlatMenuNode[]): MenuItem[];
46
+ /** How many indentation steps a horizontal drag offset represents. */
47
+ declare function getDragDepth(offsetX: number, indentation: number): number;
48
+ /** Number of levels a subtree occupies, counting the node itself (leaf = 1). */
49
+ declare function subtreeHeight(item: MenuItem): number;
50
+ /**
51
+ * The deepest 0-based depth a dragged subtree may land at.
52
+ *
53
+ * Converts the menu's 1-based `maxDepth` column (also hard-capped at the
54
+ * absolute {@link MAX_MENU_DEPTH} — the column is a plain number and a stale
55
+ * row could hold anything) and then reserves room for the dragged subtree's own
56
+ * height, so dropping a 2-level branch can't push its children past the cap.
57
+ * Never negative: an already-too-deep subtree is pinned to the root rather than
58
+ * made undraggable.
59
+ */
60
+ declare function depthCapForDrag(menuMaxDepth: number, draggedHeight: number): number;
61
+ /** Every transitive descendant id of `parentId` in a flat list. */
62
+ declare function collectDescendantIds(flat: readonly FlatMenuNode[], parentId: string): Set<string>;
63
+ /**
64
+ * Drop every row that has a COLLAPSED ancestor — the rendered subset of the
65
+ * flat list.
66
+ *
67
+ * Collapse is UI-only state (it never touches the saved tree), but it does
68
+ * change which rows exist as sortables. The editor therefore stashes the hidden
69
+ * rows at drag start and re-appends them when rebuilding the tree, so a
70
+ * collapsed branch can never be lost by a drag. Because collapsing hides ALL of
71
+ * a node's children, the stashed rows keep their relative order within their
72
+ * parent and there are no visible siblings to interleave with.
73
+ */
74
+ declare function hideCollapsedRows(flat: readonly FlatMenuNode[], collapsed: ReadonlySet<string>): readonly FlatMenuNode[];
75
+ /** Input for {@link projectDrop}. */
76
+ interface ProjectDropInput {
77
+ /** The flat list AS CURRENTLY ORDERED (the dragged row already moved). */
78
+ readonly items: readonly FlatMenuNode[];
79
+ /** Id of the row being projected — normally the drag source. */
80
+ readonly activeId: string;
81
+ /** Depth the pointer is asking for (initial depth + horizontal drag steps). */
82
+ readonly requestedDepth: number;
83
+ /** Deepest legal 0-based depth for this drag — see {@link depthCapForDrag}. */
84
+ readonly depthCap: number;
85
+ }
86
+ /** Result of {@link projectDrop}. */
87
+ interface ProjectedDrop {
88
+ /** The depth the row will actually land at. */
89
+ readonly depth: number;
90
+ /** The parent the row will land under (`null` = root). */
91
+ readonly parentId: string | null;
92
+ /**
93
+ * `true` when the MENU's `maxDepth` was the binding constraint — i.e. the
94
+ * user asked to go deeper and only the menu cap said no. The ordinary
95
+ * structural limit ("you can't be more than one level below the row above")
96
+ * does NOT set this: that is normal drag feedback, and toasting on it would
97
+ * fire on nearly every drag.
98
+ */
99
+ readonly clampedByMaxDepth: boolean;
100
+ }
101
+ /**
102
+ * Project a row's new depth + parent from the depth the pointer is asking for.
103
+ *
104
+ * Adapted from dnd-kit's `getProjection` (see the module header). Rules:
105
+ *
106
+ * - the row above sets the structural ceiling — you may be at most one level
107
+ * deeper than it, and never deeper than your own current depth + 1;
108
+ * - the row below sets the floor — you may not be shallower than it, or it
109
+ * would be orphaned mid-drag;
110
+ * - the first row is always at the root (nothing to nest under);
111
+ * - the menu's own cap is applied on top of all of the above.
112
+ */
113
+ declare function projectDrop({
114
+ items,
115
+ activeId,
116
+ requestedDepth,
117
+ depthCap
118
+ }: ProjectDropInput): ProjectedDrop;
119
+ //#endregion
120
+ //#region src/menu-tree-normalize.d.ts
121
+ /** One entity target a menu item points at — the `resolveMenuItemSummaries` input shape. */
122
+ interface MenuEntityRef {
123
+ entity: string;
124
+ id: string;
125
+ }
126
+ /** Result of {@link normalizeMenuTree}. */
127
+ interface NormalizedMenuTree {
128
+ /** The stored tree, narrowed to `MenuItem[]`. */
129
+ items: MenuItem[];
130
+ /**
131
+ * How many stored nodes were REFUSED — malformed shape, or past one of the
132
+ * hard bounds. An entire refused subtree counts as ONE (we stop at the
133
+ * refusal point rather than descending into it to count more).
134
+ *
135
+ * Non-zero means a whole-tree save would persist those removals, so the
136
+ * editor must warn before writing.
137
+ */
138
+ dropped: number;
139
+ }
140
+ /**
141
+ * Narrow a raw `menu.tree` JSONB value to `MenuItem[]`.
142
+ *
143
+ * @param value the raw column value (untrusted shape).
144
+ */
145
+ declare function normalizeMenuTree(value: unknown): NormalizedMenuTree;
146
+ /**
147
+ * Every distinct entity target in a (already-normalised, therefore bounded)
148
+ * tree, in document order.
149
+ *
150
+ * Deduplicated: two menu items pointing at the same page produce ONE
151
+ * `resolveMenuItemSummaries` ref, so the batched read never scales with item
152
+ * count. The output feeds `resolveMenuItemSummaries(app, refs)`, whose result
153
+ * map is keyed the same `${entity}:${id}` way.
154
+ */
155
+ declare function collectMenuEntityRefs(items: readonly MenuItem[]): MenuEntityRef[];
156
+ //#endregion
157
+ //#region src/menu-tree-ops.d.ts
158
+ /**
159
+ * Outcome of a structural operation.
160
+ *
161
+ * `noop` — nothing to do (already at the edge of its sibling list, at the root,
162
+ * or the id isn't in the tree). `max-depth` — the move is legal structurally but
163
+ * would nest deeper than the menu allows; the caller surfaces a toast, the same
164
+ * way a drag that exceeds `maxDepth` clamps with one (phase 09 §3).
165
+ */
166
+ type TreeOpResult = {
167
+ readonly ok: true;
168
+ readonly tree: MenuItem[];
169
+ } | {
170
+ readonly ok: false;
171
+ readonly reason: 'noop' | 'max-depth';
172
+ };
173
+ /**
174
+ * Move an item one slot up (`delta: -1`) or down (`delta: 1`) WITHIN its
175
+ * sibling list, carrying its subtree. Never changes nesting — that is
176
+ * {@link indentMenuItem} / {@link outdentMenuItem}'s job, so ⌥↑/⌥↓ and ⌥←/⌥→
177
+ * stay orthogonal (an item at the top of its list does NOT silently escape to
178
+ * the parent level; the user asks for that explicitly).
179
+ */
180
+ declare function moveMenuItem(items: readonly MenuItem[], id: string, delta: -1 | 1): TreeOpResult;
181
+ /**
182
+ * Make an item the LAST CHILD of its previous sibling (WordPress / Drupal
183
+ * indent semantics). Refuses when it has no previous sibling, or when the moved
184
+ * subtree's own height would push it past the menu's `maxDepth`.
185
+ */
186
+ declare function indentMenuItem(items: readonly MenuItem[], id: string, menuMaxDepth: number, currentLevel?: number): TreeOpResult;
187
+ /**
188
+ * Move an item OUT of its parent, landing immediately after that parent in the
189
+ * grandparent's list. A root item has nowhere to go (`noop`).
190
+ *
191
+ * Following siblings stay where they are — they are NOT adopted by the
192
+ * outdented item (WordPress does adopt them; that behaviour surprises more
193
+ * often than it helps, and an explicit indent is one keystroke away).
194
+ */
195
+ declare function outdentMenuItem(items: readonly MenuItem[], id: string): TreeOpResult;
196
+ /** Depth-first lookup by id, or `null` when the tree holds no such item. */
197
+ declare function findMenuItem(items: readonly MenuItem[], id: string): MenuItem | null;
198
+ /** One entity target — the `{ entity, id }` half of an `entity`-kind link. */
199
+ interface MenuEntityTarget {
200
+ /** Entity NAME (`page`, `article`, …), matched exactly. */
201
+ readonly entity: string;
202
+ /** Row id (a uuid), matched exactly. */
203
+ readonly id: string;
204
+ }
205
+ /**
206
+ * Where one matching item sits in the tree — everything a caller needs to
207
+ * describe (or re-place) it without walking the tree a second time.
208
+ */
209
+ interface MenuItemMatch {
210
+ /**
211
+ * The LIVE node, subtree included — reference-identical to the one in the
212
+ * input tree, so it can be handed straight to {@link replaceMenuItem} /
213
+ * {@link removeMenuItem}.
214
+ */
215
+ readonly item: MenuItem;
216
+ /** Owning node's id, or `null` when the match is a root item. */
217
+ readonly parentId: string | null;
218
+ /** 0-based position among its siblings. */
219
+ readonly index: number;
220
+ /** 0-based nesting level, matching `flattenMenuTree`'s convention. */
221
+ readonly depth: number;
222
+ }
223
+ /**
224
+ * Every item in the tree whose link points at `target` — "is entity X in this
225
+ * menu, and if so where" (plan/routing phase 10 §4).
226
+ *
227
+ * Returns ALL matches, in document order: nothing stops an author from linking
228
+ * the same page twice (once under a header, once at the root), and a Meta panel
229
+ * that assumed exactly one would silently edit the wrong item — or claim the
230
+ * entity isn't in the menu after removing only the first copy. A matched item's
231
+ * own subtree is still searched, so a self-referential nest reports both.
232
+ *
233
+ * Only `entity`-kind links can match; `url` / `email` links and `header` items
234
+ * are skipped. An `anchor` on the link does NOT affect matching — `/pricing`
235
+ * and `/pricing#plans` are the same page in the same menu.
236
+ *
237
+ * Whether a match sits under a header is one lookup away:
238
+ * `parentId !== null && findMenuItem(items, parentId)?.kind === 'header'`.
239
+ *
240
+ * Pure in-memory traversal of an ALREADY-LOADED tree — no I/O, so this is not a
241
+ * fan-out surface. Recursive like the rest of this module, which is safe for the
242
+ * same reason: menu trees are bounded to {@link MAX_MENU_TREE_NODES} nodes and
243
+ * {@link MAX_MENU_DEPTH} levels at the write boundary (and re-bounded by
244
+ * `normalizeMenuTree` on the read side), so there is no hostile-payload stack
245
+ * exposure here.
246
+ */
247
+ declare function findMenuItemsByTarget(items: readonly MenuItem[], target: MenuEntityTarget): MenuItemMatch[];
248
+ /** Remove an item and its entire subtree. Unknown ids return an equal tree. */
249
+ declare function removeMenuItem(items: readonly MenuItem[], id: string): MenuItem[];
250
+ /**
251
+ * Swap the item with `id` for `next`, in place — the inspector's write path
252
+ * (plan/routing phase 09 §4).
253
+ *
254
+ * The REPLACEMENT is stored whole, children included: the inspector always
255
+ * hands back the node it was given plus one changed field, so its `children`
256
+ * are the live ones. Re-grafting the old children on top would silently
257
+ * override any caller that meant to change them.
258
+ *
259
+ * Unknown ids return an equal tree — an edit for an item that has since been
260
+ * removed is a no-op, never a throw.
261
+ *
262
+ * **`next === item` (reference-identical) is ALSO a no-op**, not just an
263
+ * unknown id — `./item-edits.ts`'s setters already return the SAME object
264
+ * when a field edit changes nothing (e.g. re-picking the anchor already
265
+ * stored), specifically so the editor's `tree !== savedTree` dirty check
266
+ * doesn't fire for it. Treating every id MATCH as "changed" regardless of
267
+ * whether the value actually differs would silently defeat that guarantee
268
+ * one layer up — the whole tree gets a fresh array, and the menu is marked
269
+ * dirty, even though nothing was edited.
270
+ */
271
+ declare function replaceMenuItem(items: readonly MenuItem[], id: string, next: MenuItem): MenuItem[];
272
+ /**
273
+ * Insert `node` as the next SIBLING of `afterId`, at that anchor's level.
274
+ * Appends to the root when `afterId` is `null` or names no item — an add
275
+ * action must always land somewhere visible, never silently do nothing.
276
+ */
277
+ declare function insertMenuItem(items: readonly MenuItem[], node: MenuItem, afterId: string | null): MenuItem[];
278
+ //#endregion
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
@@ -0,0 +1 @@
1
+ {"version":3,"file":"schema.d.mts","names":[],"sources":["../src/menu-tree-flatten.ts","../src/menu-tree-normalize.ts","../src/menu-tree-ops.ts"],"mappings":"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"}
@@ -0,0 +1,2 @@
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