@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 +94 -0
- package/dist/admin.d.mts +293 -0
- package/dist/admin.d.mts.map +1 -0
- package/dist/admin.mjs +2 -0
- package/dist/admin.mjs.map +1 -0
- package/dist/en-Cr9XUf6B.mjs +2 -0
- package/dist/en-Cr9XUf6B.mjs.map +1 -0
- package/dist/entity-BxLeqoBC.mjs +2 -0
- package/dist/entity-BxLeqoBC.mjs.map +1 -0
- package/dist/et-C_k3aK3T.mjs +2 -0
- package/dist/et-C_k3aK3T.mjs.map +1 -0
- package/dist/i18n.d.mts +16 -0
- package/dist/i18n.d.mts.map +1 -0
- package/dist/i18n.mjs +2 -0
- package/dist/i18n.mjs.map +1 -0
- package/dist/index.d.mts +75 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +1 -0
- package/dist/menu-entity-types-BnKnKbif.mjs +2 -0
- package/dist/menu-entity-types-BnKnKbif.mjs.map +1 -0
- package/dist/menu-item-schema-xXWZKw7t.d.mts +296 -0
- package/dist/menu-item-schema-xXWZKw7t.d.mts.map +1 -0
- package/dist/messages/en.json +19 -0
- package/dist/messages/et.json +19 -0
- package/dist/messages/ru.json +19 -0
- package/dist/resolve.d.mts +205 -0
- package/dist/resolve.d.mts.map +1 -0
- package/dist/resolve.mjs +2 -0
- package/dist/resolve.mjs.map +1 -0
- package/dist/ru-CIJipJXJ.mjs +2 -0
- package/dist/ru-CIJipJXJ.mjs.map +1 -0
- package/dist/schema.d.mts +280 -0
- package/dist/schema.d.mts.map +1 -0
- package/dist/schema.mjs +2 -0
- package/dist/schema.mjs.map +1 -0
- package/package.json +57 -0
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.
|
package/dist/admin.d.mts
ADDED
|
@@ -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
|