@murumets-ee/menus 0.37.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/LICENSE ADDED
@@ -0,0 +1,94 @@
1
+ Elastic License 2.0 (ELv2)
2
+
3
+ URL: https://www.elastic.co/licensing/elastic-license
4
+
5
+ ## Acceptance
6
+
7
+ By using the software, you agree to all of the terms and conditions below.
8
+
9
+ ## Copyright License
10
+
11
+ The licensor grants you a non-exclusive, royalty-free, worldwide,
12
+ non-sublicensable, non-transferable license to use, copy, distribute, make
13
+ available, and prepare derivative works of the software, in each case subject
14
+ to the limitations and conditions below.
15
+
16
+ ## Limitations
17
+
18
+ You may not provide the software to third parties as a hosted or managed
19
+ service, where the service provides users with access to any substantial set
20
+ of the features or functionality of the software.
21
+
22
+ You may not move, change, disable, or circumvent the license key functionality
23
+ in the software, and you may not remove or obscure any functionality in the
24
+ software that is protected by the license key.
25
+
26
+ You may not alter, remove, or obscure any licensing, copyright, or other
27
+ notices of the licensor in the software. Any use of the licensor's trademarks
28
+ is subject to applicable law.
29
+
30
+ ## Patents
31
+
32
+ The licensor grants you a license, under any patent claims the licensor can
33
+ license, or becomes able to license, to make, have made, use, sell, offer for
34
+ sale, import and have imported the software, in each case subject to the
35
+ limitations and conditions in this license. This license does not cover any
36
+ patent claims that you cause to be infringed by modifications or additions to
37
+ the software. If you or your company make any written claim that the software
38
+ infringes or contributes to infringement of any patent, your patent license
39
+ for the software granted under these terms ends immediately. If your company
40
+ makes such a claim, your patent license ends immediately for work on behalf
41
+ of your company.
42
+
43
+ ## Notices
44
+
45
+ You must ensure that anyone who gets a copy of any part of the software from
46
+ you also gets a copy of these terms.
47
+
48
+ If you modify the software, you must include in any modified copies of the
49
+ software prominent notices stating that you have modified the software.
50
+
51
+ ## No Other Rights
52
+
53
+ These terms do not imply any licenses other than those expressly granted in
54
+ these terms.
55
+
56
+ ## Termination
57
+
58
+ If you use the software in violation of these terms, such use is not licensed,
59
+ and your licenses will automatically terminate. If the licensor provides you
60
+ with a notice of your violation, and you cease all violation of this license
61
+ no later than 30 days after you receive that notice, your licenses will be
62
+ reinstated retroactively. However, if you violate these terms after such
63
+ reinstatement, any additional violation of these terms will cause your
64
+ licenses to terminate automatically and permanently.
65
+
66
+ ## No Liability
67
+
68
+ As far as the law allows, the software comes as is, without any warranty or
69
+ condition, and the licensor will not be liable to you for any damages arising
70
+ out of these terms or the use or nature of the software, under any kind of
71
+ legal claim.
72
+
73
+ ## Definitions
74
+
75
+ The **licensor** is the entity offering these terms, and the **software** is
76
+ the software the licensor makes available under these terms, including any
77
+ portion of it.
78
+
79
+ **you** refers to the individual or entity agreeing to these terms.
80
+
81
+ **your company** is any legal entity, sole proprietorship, or other kind of
82
+ organization that you work for, plus all organizations that have control over,
83
+ are under the control of, or are under common control with that organization.
84
+ **control** means ownership of substantially all the assets of an entity, or
85
+ the power to direct the management and policies of an entity (for example, by
86
+ voting right, contract, or otherwise). Control can be direct or indirect.
87
+
88
+ **your licenses** are all the licenses granted to you for the software under
89
+ these terms.
90
+
91
+ **use** means anything you do with the software requiring one of your
92
+ licenses.
93
+
94
+ **trademark** means trademarks, service marks, and similar rights.
@@ -0,0 +1,293 @@
1
+ import { z } from "zod";
2
+ import { AdminRoute, ToolkitApp } from "@murumets-ee/core";
3
+
4
+ //#region src/menu-item-schema.d.ts
5
+ /**
6
+ * Absolute nesting cap for any menu, independent of a menu's own `maxDepth`
7
+ * (phase 07 §2.7 — "depth is capped per menu (default 3, max 6)"). Also the
8
+ * bound that keeps the recursive parse from being a DoS vector.
9
+ */
10
+ declare const MAX_MENU_DEPTH = 6;
11
+ /**
12
+ * Upper bound on the number of nodes in one menu tree. Navigation menus are
13
+ * hand-curated: a few dozen items is a big one. The cap exists so a single
14
+ * PATCH can't hand the validator (and `extractRefs`' ref walk, and every
15
+ * subsequent render) an unbounded amount of work — CLAUDE.md's
16
+ * "no unbounded fan-out" rule applied to a single request payload.
17
+ */
18
+ declare const MAX_MENU_TREE_NODES = 500;
19
+ /**
20
+ * Max length of an entity-link fragment/anchor.
21
+ *
22
+ * Exported because this module is the SINGLE OWNER of the anchor bounds: the
23
+ * write-side schema below, the read-side anchor walk
24
+ * (`admin/anchors.ts`) and the editor's manual-entry fallback
25
+ * (`@murumets-ee/menus-ui`'s anchor picker, via the client-safe `./schema`
26
+ * subpath) all validate against exactly these two constants. Two of those three
27
+ * used to carry their own copy; a drift between them would let the picker offer
28
+ * — or the editor accept — a value the write boundary then rejects.
29
+ */
30
+ declare const MAX_ANCHOR_LENGTH = 128;
31
+ /**
32
+ * Anchors are slugified by the block editor (phase 08 §5) and rendered into an
33
+ * `href` as `#<anchor>`; restricting them to slug characters keeps that
34
+ * interpolation inert.
35
+ *
36
+ * Exported for the same single-owner reason as {@link MAX_ANCHOR_LENGTH}.
37
+ */
38
+ declare const ANCHOR_PATTERN: RegExp;
39
+ /**
40
+ * A menu item's link target — the item-type union phase 07 §2.2 and phase 08 §3
41
+ * define: `entity` (+ optional anchor) | `url` | `email`.
42
+ *
43
+ * **A NARROWED SUBSET of `@murumets-ee/blocks`' `LinkValue`, deliberately.**
44
+ * Blocks additionally carries a `media` kind; menus does NOT, because no phase
45
+ * gives a menu a media-href seam to resolve one with (phase 11 §2 specifies
46
+ * `href` as "canonical path from `toolkit_paths`; external URLs verbatim;
47
+ * header items null" and nothing else). Accepting a `media` link would let it
48
+ * validate, persist, and register an `entity_refs` row — arming delete
49
+ * protection for that file — while the renderer had no way to emit it: the item
50
+ * would silently never appear. Rejecting it at the write boundary turns that
51
+ * dead weight into a real 400. Widening later is additive (an arm here plus a
52
+ * `resolveMediaHref` dep on `MenuResolveDeps`) and needs no shape change.
53
+ *
54
+ * Every `MenuLinkValue` IS a valid blocks `LinkValue` (the subset direction, and
55
+ * the one `resolveLinkHref` needs) — pinned at compile time by an assignment in
56
+ * `menu-item-schema.test.ts`, so a drift in blocks' union is a build error
57
+ * rather than a silent divergence.
58
+ *
59
+ * The `anchor?: string | undefined` is forced by the combination of Zod and this
60
+ * repo's `exactOptionalPropertyTypes`: `.optional()` always widens to
61
+ * `T | undefined`, which is NOT assignable to blocks' `anchor?: string` under
62
+ * that flag.
63
+ */
64
+ type MenuLinkValue = {
65
+ kind: 'entity';
66
+ entity: string;
67
+ id: string;
68
+ anchor?: string | undefined;
69
+ } | {
70
+ kind: 'url';
71
+ url: string;
72
+ } | {
73
+ kind: 'email';
74
+ email: string;
75
+ };
76
+ /**
77
+ * A menu tree node (phase 08 §3 / D009).
78
+ *
79
+ * `label: null` means "use the LIVE title of the linked entity, per locale";
80
+ * a map is an explicit per-locale override. `enabled` is per-locale visibility
81
+ * in BOTH directions — absent/`true` shows the item, `false` hides it in that
82
+ * locale — so one tree serves every locale (no `menu_translations` table).
83
+ *
84
+ * Declared as a `type`, NOT an `interface`, on purpose: only type aliases get
85
+ * TypeScript's implicit index signature, and without it `MenuItem[]` is not
86
+ * assignable to the `Record<string, unknown>[]` arm of `AdminClient.create/
87
+ * update`'s json-field value type — every caller storing a typed tree would
88
+ * need a cast.
89
+ */
90
+ type MenuItem = {
91
+ /** Stable id — DND identity + anchor for audit diffs. */id: string; /** `header` = a text-only dropdown parent with no link of its own. */
92
+ kind: 'link' | 'header';
93
+ /**
94
+ * Present iff `kind === 'link'`.
95
+ *
96
+ * The explicit `| undefined` is required by the repo's
97
+ * `exactOptionalPropertyTypes` — Zod's `.optional()` emits
98
+ * `LinkValue | undefined`, and without it the schema wouldn't typecheck
99
+ * against this interface.
100
+ */
101
+ link?: MenuLinkValue | undefined; /** `null` = live entity title per locale; a map = explicit override. */
102
+ label: Record<string, string> | null; /** Per-locale visibility. Absent/`true` = visible; `false` = hidden. */
103
+ enabled?: Record<string, boolean> | undefined;
104
+ newTab?: boolean | undefined;
105
+ children: MenuItem[];
106
+ };
107
+ /**
108
+ * One menu item's target, as the editor needs to render its row
109
+ * (`@murumets-ee/menus/admin`'s `resolveMenuItemSummaries`).
110
+ *
111
+ * Declared here — not in `admin/summaries.ts` — for the same reason
112
+ * {@link BlockAnchor} is: it is a pure data shape with no server dependency,
113
+ * so the client-safe `./schema` subpath can re-export it without pulling the
114
+ * server-only resolver along. `import type` is erased at compile time either
115
+ * way, but a single canonical home (this leaf module) is simpler to reason
116
+ * about than "which of two server-only files owns the type".
117
+ */
118
+ interface MenuItemSummary {
119
+ /**
120
+ * The target row's live title, or `null` when the entity declares no
121
+ * display-able field. Unlike the public render path — which DROPS such an
122
+ * item rather than show a raw uuid as navigation — the admin editor still
123
+ * reports the target as present, just unlabelled.
124
+ */
125
+ title: string | null;
126
+ /** `false` = the target row is gone (deleted) → a BROKEN item. */
127
+ exists: boolean;
128
+ /** `null` when the entity isn't publishable — no draft/published distinction applies. */
129
+ published: boolean | null;
130
+ }
131
+ /**
132
+ * One addressable block anchor on a document (`@murumets-ee/menus/admin`'s
133
+ * `collectBlockAnchors` / the anchors endpoint). See {@link MenuItemSummary}'s
134
+ * doc for why this pure shape lives here rather than in `admin/anchors.ts`.
135
+ */
136
+ interface BlockAnchor {
137
+ /** The stable id rendered as the block element's `id` attribute. */
138
+ anchor: string;
139
+ /** Display text — `anchorLabel` when usable, else the anchor value itself. */
140
+ label: string;
141
+ }
142
+ /** Options for {@link buildMenuItemSchema} / {@link buildMenuTreeSchema}. */
143
+ interface MenuSchemaOptions {
144
+ /**
145
+ * Locale codes accepted as keys in `label` / `enabled` maps. Anything else
146
+ * is a validation error — a typo'd locale would produce a label no renderer
147
+ * ever reads (the same failure mode content's `validateLocale` guards).
148
+ */
149
+ allowedLocales: readonly string[];
150
+ }
151
+ /**
152
+ * The recursive per-node schema. Input is `unknown` (this parses untrusted
153
+ * JSONB), output is a fully-narrowed {@link MenuItem}. Unknown properties are
154
+ * STRIPPED by Zod's default object behavior, so writing the parse result back
155
+ * onto the payload also sanitizes it — nothing a caller invents gets stored.
156
+ */
157
+ declare function buildMenuItemSchema(options: MenuSchemaOptions): z.ZodType<MenuItem, z.ZodTypeDef, unknown>;
158
+ /** The whole tree — an array of {@link buildMenuItemSchema} nodes. */
159
+ declare function buildMenuTreeSchema(options: MenuSchemaOptions): z.ZodType<MenuItem[], z.ZodTypeDef, unknown>;
160
+ /** Result of {@link measureMenuTree}. */
161
+ interface MenuTreeMeasurement {
162
+ /** Deepest nesting level; `0` for an empty tree, `1` for flat items. */
163
+ depth: number;
164
+ /** Total node count across every level. */
165
+ nodeCount: number;
166
+ }
167
+ /**
168
+ * Measure a (possibly untrusted) tree's depth + node count **iteratively**, so
169
+ * measuring can never blow the stack the way a recursive walk over a hostile
170
+ * payload would. Stops early once either hard bound is exceeded and reports
171
+ * the over-limit value — the caller turns that into a validation error.
172
+ *
173
+ * Accepts `unknown` on purpose: this runs BEFORE the Zod parse (bounding the
174
+ * work that parse will do), so it can't assume a well-formed shape.
175
+ */
176
+ declare function measureMenuTree(tree: unknown): MenuTreeMeasurement;
177
+ /**
178
+ * Cross-field check: does `tree` nest no deeper than the menu's own
179
+ * `maxDepth`? Lives outside the node schema because a per-node Zod refinement
180
+ * has no visibility of the sibling `maxDepth` COLUMN — the behavior calls this
181
+ * after the recursive parse.
182
+ */
183
+ declare function validateTreeDepth(tree: unknown, maxDepth: number): boolean;
184
+ //#endregion
185
+ //#region src/admin/editor-state.d.ts
186
+ /** The menu row projected to exactly what the embedded editor needs. */
187
+ interface MenuEditorStateMenu {
188
+ id: string;
189
+ handle: string;
190
+ title: string;
191
+ maxDepth: number;
192
+ status: 'draft' | 'published';
193
+ tree: MenuItem[];
194
+ /**
195
+ * Which content types this menu accepts links to (routing PR12), narrowed by
196
+ * `normalizeMenuEntityTypes`. `null` = accepts every type. Read with
197
+ * draft-then-live precedence, same as `tree`.
198
+ */
199
+ entityTypes: string[] | null;
200
+ }
201
+ /** One content type offered in the "Add link" picker's Content tab. */
202
+ interface MenuEditorStateEntityType {
203
+ name: string;
204
+ label: string;
205
+ }
206
+ /** Full response body for `GET /api/admin/menus/editor-state?handle=`. */
207
+ interface MenuEditorStateResponse {
208
+ menu: MenuEditorStateMenu;
209
+ /** Keyed `` `${entity}:${id}` `` — see `resolveMenuItemSummaries`. */
210
+ summaries: Readonly<Record<string, MenuItemSummary>>;
211
+ locales: ReadonlyArray<{
212
+ code: string;
213
+ label: string;
214
+ }>;
215
+ defaultLocale: string;
216
+ /**
217
+ * How many stored nodes the tolerant `normalizeMenuTree` could NOT read.
218
+ *
219
+ * Every menu write is whole-tree, so `> 0` means a save from this surface
220
+ * would PERMANENTLY delete those items — `<MenuTreeEditor>` refuses to save
221
+ * past it without an explicit confirm. Counted against the very read being
222
+ * handed back (not a separate projection), so the number always describes
223
+ * the tree the editor is about to write.
224
+ */
225
+ droppedItemCount: number;
226
+ entityTypes: readonly MenuEditorStateEntityType[];
227
+ canPublish: boolean;
228
+ /**
229
+ * The pending draft for this menu, when one exists.
230
+ *
231
+ * Structurally identical to `@murumets-ee/admin-ui`'s `DraftInfo` (declared
232
+ * here rather than imported — `menus` must not depend on a UI package), and
233
+ * assignability is checked at the seam where `menus-ui` passes it into
234
+ * `MenuTreeEditorProps.draft`.
235
+ *
236
+ * **Load-bearing, not cosmetic.** `useContentEditor.saveDraft` REPLACES the
237
+ * draft's `data` rather than merging, so the editor must know the existing
238
+ * payload to spread it back — otherwise saving the tree from this surface
239
+ * silently drops a `title` / `handle` / `maxDepth` edit staged in the same
240
+ * draft from the entity form. `<MenuTreeEditor>` does that spread; this
241
+ * field is what makes it possible here as well as on the standalone page.
242
+ */
243
+ draft?: {
244
+ data: Record<string, unknown>;
245
+ createdBy: string;
246
+ createdByName: string | null;
247
+ updatedAt: string;
248
+ };
249
+ }
250
+ //#endregion
251
+ //#region src/admin/routes.d.ts
252
+ /**
253
+ * Every admin route this package contributes, in the `AdminRoute[]` shape
254
+ * `createAdminApiHandler` consumes.
255
+ *
256
+ * Wired declaratively via the plugin's `server.routes` slot — see
257
+ * `@murumets-ee/menus-ui`.
258
+ *
259
+ * @example
260
+ * ```ts
261
+ * import { menusRoutes } from '@murumets-ee/menus/admin'
262
+ *
263
+ * definePlugin({ name: '…', server: { routes: menusRoutes() } })
264
+ * ```
265
+ */
266
+ declare function menusRoutes(): AdminRoute[];
267
+ //#endregion
268
+ //#region src/admin/summaries.d.ts
269
+ /**
270
+ * Batch-resolve the live title + existence + publish state of every
271
+ * `{ entity, id }` target, keyed `` `${entity}:${id}` `` (the same key shape
272
+ * `resolveRowTitles` uses).
273
+ *
274
+ * **A MISSING map entry means "not resolvable here", NOT "broken."** It happens
275
+ * when the entity type isn't registered in this app, when the caller holds no
276
+ * `view` grant on it, when that group's read failed, or (for a `team`/`user`-
277
+ * scoped entity only) when the row belongs to a different scope than this
278
+ * request's — a scope filter excludes it the same way a delete would, and
279
+ * this function cannot tell the two apart. Callers should render such items
280
+ * as indeterminate — only an explicit `exists: false` (reported ONLY for a
281
+ * `global`-scoped entity, where no scope filter can ever apply) means the
282
+ * target was really deleted.
283
+ *
284
+ * Best-effort per group: one group's failure never takes down the others (and
285
+ * therefore never takes down the editor page).
286
+ */
287
+ declare function resolveMenuItemSummaries(app: ToolkitApp, refs: ReadonlyArray<{
288
+ entity: string;
289
+ id: string;
290
+ }>): Promise<Map<string, MenuItemSummary>>;
291
+ //#endregion
292
+ export { ANCHOR_PATTERN, type BlockAnchor, MAX_ANCHOR_LENGTH, MAX_MENU_DEPTH, MAX_MENU_TREE_NODES, type MenuEditorStateEntityType, type MenuEditorStateMenu, type MenuEditorStateResponse, type MenuItem, type MenuItemSummary, type MenuSchemaOptions, buildMenuItemSchema, buildMenuTreeSchema, measureMenuTree, menusRoutes, resolveMenuItemSummaries, validateTreeDepth };
293
+ //# sourceMappingURL=admin.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"admin.d.mts","names":[],"sources":["../src/menu-item-schema.ts","../src/admin/editor-state.ts","../src/admin/routes.ts","../src/admin/summaries.ts"],"mappings":";;;;;;;;;cAkCa,cAAA;;;;AA4Fa;AAgB1B;;;cAhGa,mBAAA;;;;;;;;AAmHO;AAcpB;;;cAlGa,iBAAA;AAyHN;AAIP;;;;AAMgB;AAmHhB;AA7HO,cAtGM,cAAA,EAAc,MAAqB;;;;;;;;;;;;;;;;AAqOb;AAkDnC;;;;;;;;;KA5PY,aAAA;EACN,IAAA;EAAgB,MAAA;EAAgB,EAAA;EAAY,MAAA;AAAA;EAC5C,IAAA;EAAa,GAAA;AAAA;EACb,IAAA;EAAe,KAAA;AAAA;;AAoQV;AAYX;;;;AAAmE;AA0CnE;;;;AAAiE;;;KA1SrD,QAAA;ECrHK,yDDuHf,EAAA;EAEA,IAAA;ECxHA;;;;;;;;EDiIA,IAAA,GAAO,aAAA,cCtHI;EDwHX,KAAA,EAAO,MAAA,yBCpHiC;EDsHxC,OAAA,GAAU,MAAA;EACV,MAAA;EACA,QAAA,EAAU,QAAA;AAAA;;;;;;;;;;;;UAcK,eAAA;EC/HT;;;;;;EDsIN,KAAA;ECnIyB;EDqIzB,MAAA;ECpIA;EDsIA,SAAA;AAAA;;;;;;UAQe,WAAA;EC/Gb;EDiHF,MAAA;EChHW;EDkHX,KAAK;AAAA;;UAIU,iBAAA;EE0PD;;;;AAAyB;EFpPvC,cAAc;AAAA;;;;;;;iBAmHA,mBAAA,CACd,OAAA,EAAS,iBAAA,GACR,CAAA,CAAE,OAAA,CAAQ,QAAA,EAAU,CAAA,CAAE,UAAA;;iBAkDT,mBAAA,CACd,OAAA,EAAS,iBAAA,GACR,CAAA,CAAE,OAAA,CAAQ,QAAA,IAAY,CAAA,CAAE,UAAA;;UAKV,mBAAA;;EAEf,KAAA;;EAEA,SAAS;AAAA;;;;;;;;;;iBAYK,eAAA,CAAgB,IAAA,YAAgB,mBAAmB;;;;;;;iBA0CnD,iBAAA,CAAkB,IAAA,WAAe,QAAgB;;;;UC/ZhD,mBAAA;EACf,EAAA;EACA,MAAA;EACA,KAAA;EACA,QAAA;EACA,MAAA;EACA,IAAA,EAAM,QAAQ;ED6FsB;;;;;ECvFpC,WAAA;AAAA;ADyFwB;AAAA,UCrFT,yBAAA;EACf,IAAA;EACA,KAAK;AAAA;;UAIU,uBAAA;EACf,IAAA,EAAM,mBAAA;EDiHY;EC/GlB,SAAA,EAAW,QAAA,CAAS,MAAA,SAAe,eAAA;EACnC,OAAA,EAAS,aAAA;IAAgB,IAAA;IAAc,KAAA;EAAA;EACvC,aAAA;EDyGO;;;;;;;AAIW;AAcpB;ECjHE,gBAAA;EACA,WAAA,WAAsB,yBAAA;EACtB,UAAA;EDsHA;;;;AAIS;AAQX;;;;AAIO;AAIP;;;;AAMgB;EChId,KAAA;IACE,IAAA,EAAM,MAAA;IACN,SAAA;IACA,aAAA;IACA,SAAA;EAAA;AAAA;;;;;;;;AD6EgB;AAcpB;;;;;;;;iBEqRgB,WAAA,CAAA,GAAe,UAAU;;;;;;AFtUf;AAgB1B;;;;;;;;;;;;;;iBG1CsB,wBAAA,CACpB,GAAA,EAAK,UAAA,EACL,IAAA,EAAM,aAAA;EAAgB,MAAA;EAAgB,EAAA;AAAA,KACrC,OAAA,CAAQ,GAAA,SAAY,eAAA"}
package/dist/admin.mjs ADDED
@@ -0,0 +1,2 @@
1
+ import{sanitizeHref as e}from"@murumets-ee/blocks/links";import{z as t}from"zod";import{combineAdminRoutes as n,defineAdminRoute as r,getContext as i,getCurrentApp as a,getCurrentScope as o}from"@murumets-ee/core";import{auditable as s,defineEntity as c,field as l,getEntityTitleField as u,getSearchableConfig as d,publishable as f}from"@murumets-ee/entity";import{QueryClient as p}from"@murumets-ee/entity/query";import{eq as m,inArray as h}from"drizzle-orm";import{getContentConfig as g}from"@murumets-ee/content/plugin";const _=6,v=500,y=128,b=/^[A-Za-z0-9_-]+$/,x=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(b).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 S(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 C(e){let n=S(t.string().max(200),e.allowedLocales),r=S(t.boolean(),e.allowedLocales),i=t.lazy(()=>t.object({id:t.string().min(1).max(64),kind:t.enum([`link`,`header`]),link:x.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 w(e){return t.array(C(e))}function T(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 E(e,t){return T(e).depth<=t}const D=t.string().min(1).max(64),ee=t.array(D).max(64).nullable().transform(e=>{if(e===null)return null;let t=[...new Set(e)];return t.length===0?null:t});function te(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 O(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 k(e){return new t.ZodError(e.issues.map(e=>({...e,path:[`entityTypes`,...e.path]})))}function A(e){if(!(`entityTypes`in e))return e;let t=ee.safeParse(e.entityTypes);if(!t.success)throw k(t.error);return{...e,entityTypes:t.data}}function j(){return{name:`menuEntityTypesValidation`,hooks:{beforeCreate:async e=>A(e),beforeUpdate:async(e,t)=>A(t)},assertBulkUpdate:O}}function M(e,n=[]){return new t.ZodError([{code:t.ZodIssueCode.custom,path:[`tree`,...n],message:e}])}function ne(e){return new t.ZodError(e.issues.map(e=>({...e,path:[`tree`,...e.path]})))}function N(e){if(!(typeof e!=`number`||!Number.isInteger(e))&&!(e<1||e>6))return e}async function re(e,t,n){let r=N(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 N((await t.loadCurrent())?.maxDepth)??3}async function ie(e,t){let n=N(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}=T((await t.loadCurrent())?.tree);if(r>n)throw M(`the stored menu tree is nested ${r} levels deep, deeper than this menu's new maxDepth (${n}) — flatten the tree first`)}async function P(e,t,n,r){let i=e.tree;if(i===void 0)return n&&await ie(e,t),e;if(i===null||!Array.isArray(i))throw M(`menu tree must be an array of menu items`);let{depth:a,nodeCount:o}=T(i);if(o>500)throw M(`menu tree has too many items (max 500)`);if(a>6)throw M(`menu tree is nested too deeply (absolute max 6 levels)`);let s=w({allowedLocales:r()}).safeParse(i);if(!s.success)throw ne(s.error);let c=await re(e,t,n);if(a>c)throw M(`menu tree is nested deeper than this menu's maxDepth (${c})`);return{...e,tree:s.data}}function F(e){let t=e?.resolveAllowedLocales??(()=>g().locales.map(e=>e.code));return{name:`menuTreeValidation`,hooks:{beforeCreate:async(e,n)=>P(e,n,!1,t),beforeUpdate:async(e,n,r)=>P(n,r,!0,t)}}}const I=c({name:`menu`,admin:{label:`Menus`,icon:`menu`},fields:{handle:l.slug({from:`title`}),title:l.text({required:!0}),maxDepth:l.number({default:3,min:1,max:6,integer:!0}),tree:l.json({refScan:!0,default:[]}),entityTypes:l.json({default:null})},behaviors:[s(),f(),F(),j()],scope:`global`,access:{view:`public`,create:`group.admin`,update:`group.admin`,delete:`group.admin`}});function L(e,t){return e[t]}function R(e){return typeof e==`object`&&!!e&&!Array.isArray(e)}function z(e,t){if(!R(e))return;let n={};for(let[r,i]of Object.entries(e))t(i)&&(n[r]=i);return n}const B=e=>typeof e==`string`,V=e=>typeof e==`boolean`;function H(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(!R(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=x.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=z(e.label,B),u=l!==void 0&&Object.keys(l).length>0?l:void 0,d=z(e.enabled,V),f=d!==void 0&&Object.keys(d).length>0?d:void 0;o.push({id:a,kind:s,link:c,label:u??null,enabled:f,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 U(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 W=1e3,G=new Set([`section`,`text`]);function K(e){return typeof e==`object`&&!!e&&!Array.isArray(e)}function q(e){let t=e.type;if(typeof t!=`string`||!G.has(t))return;let n=e.props;if(!K(n))return;let r=n.anchor;if(typeof r!=`string`||r.length===0||r.length>128||!b.test(r))return;let i=n.anchorLabel;return{anchor:r,label:typeof i==`string`&&i.length>0&&i.length<=200?i:r}}function J(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,W)-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++,!K(e))continue;let t=q(e);t!==void 0&&n.push(t);let r=e.children;if(Array.isArray(r)){let e=r,t=Math.max(W-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:W,resultCap:200},`anchor collection stopped at a hard bound — the returned list is incomplete`),{anchors:n,truncated:a,visited:i}}function Y(e,t){return`${e}:${t}`}const ae={title:null,exists:!1,published:null};function oe(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 X(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}=oe(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])=>se(e,r,t,i,n))),n}async function se(e,t,n,r,i){let a=e.entities.get(n);if(a===void 0)return;let o=u(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:h(L(t.getTable(),`id`),l),limit:l.length}),u=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;u.add(r),i.set(Y(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)u.has(e)||i.set(Y(n,e),ae)}catch(e){t.warn({entity:n,count:l.length,error:String(e)},`menu item summaries could not be resolved for this entity type`)}}const ce=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;function Z(e,t=200){return new Response(JSON.stringify(e),{status:t,headers:{"Content-Type":`application/json`}})}function Q(e,t){return Z({error:e},t)}function $(){return Z([])}function le(e){for(let[t,n]of Object.entries(e.allFields))if(n.type===`blockTree`)return t;return null}const ue=r({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.`,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 Q(`query param 'entity' is required`,400);if(r.length>64)return Q(`query param 'entity' is too long`,400);if(i===null||!ce.test(i))return Q(`query param 'id' must be a uuid`,400);let s=a();if(s===void 0)return Q(`Internal error`,500);let c=s.logger.child({route:`menus/anchors`,entity:r});if(!t.checkPermission(r,`view`))return c.debug({userId:t.user.id,role:t.user.role},`anchors lookup returned empty — caller holds no view grant on the target entity`),$();let l=s.entities.get(r);if(l===void 0)return $();let u=le(l);if(u===null)return $();let d=o(),f=await new p({entity:l,db:s.db.readOnly,logger:c,contextResolver:()=>({user:{id:t.user.id,groups:[t.user.role??`viewer`]},checker:(e,n,r)=>t.checkPermission(n,r),...d!==void 0&&{scope:d}})}).findById(i,{...t.locale!==void 0&&{locale:t.locale},...t.defaultLocale!==void 0&&{defaultLocale:t.defaultLocale}});if(f===null)return $();let{anchors:m}=J(f[u],c);return Z(m)}}),de=async(e,t)=>{let n=new URL(e.url).searchParams.get(`handle`);if(n===null||n.length===0)return Q(`query param 'handle' is required`,400);if(n.length>128)return Q(`query param 'handle' is too long`,400);let r=a();if(r===void 0)return Q(`Internal error`,500);let o=r.logger.child({route:`menus/editor-state`}),s=r.getClient(I),c=(await s.findMany({where:m(L(s.getTable(),`handle`),n),limit:1}))[0];if(c===void 0)return Q(`Menu not found`,404);let l=c.id,u=typeof c.title==`string`?c.title:``,f=c.maxDepth,p=typeof f==`number`&&Number.isInteger(f)&&f>=1?Math.min(f,6):3,h=c.status===`published`?`published`:`draft`,g=i()?.availableLocales,_=t.defaultLocale??g?.[0]??`en`,v=g&&g.length>0?[...g]:[],y=(v.includes(_)?v:[_,...v]).map(e=>({code:e,label:e})),b=await fe(r,l,o),x=b?.data,{items:S,dropped:C}=H(x!==void 0&&`tree`in x?x.tree:c.tree);C>0&&o.warn({menuId:l,handle:n,dropped:C},`menu editor-state: stored tree contained items that could not be read — the embedded editor shows the rest`);let w=await X(r,U(S)),T=Object.fromEntries(w),E=[...r.entities.values()].filter(e=>d(e)!==void 0&&t.checkPermission(e.name,`view`)).map(e=>({name:e.name,label:e.admin?.labelSingular??e.admin?.label??e.name})),D=t.checkPermission(`menu`,`publish`);return Z({menu:{id:l,handle:n,title:u,maxDepth:p,status:h,tree:S,entityTypes:te(x!==void 0&&`entityTypes`in x?x.entityTypes:c.entityTypes)},summaries:T,locales:y,defaultLocale:_,droppedItemCount:C,entityTypes:E,canPublish:D,...b!==void 0&&{draft:b}})};async function fe(e,t,n){try{let{ContentClient:n}=await import(`@murumets-ee/content/client`),r=await new n({admin:e.getClient(I),entity:I}).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 pe=r({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.`,handler:de});function me(){return n([ue,pe])}export{b as ANCHOR_PATTERN,y as MAX_ANCHOR_LENGTH,_ as MAX_MENU_DEPTH,v as MAX_MENU_TREE_NODES,C as buildMenuItemSchema,w as buildMenuTreeSchema,T as measureMenuTree,me as menusRoutes,X as resolveMenuItemSummaries,E as validateTreeDepth};
2
+ //# sourceMappingURL=admin.mjs.map