@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.
- package/LICENSE +94 -0
- package/dist/admin.d.mts +363 -0
- package/dist/admin.d.mts.map +1 -0
- package/dist/admin.mjs +2 -0
- package/dist/admin.mjs.map +1 -0
- package/dist/columns-DwDukx_r.mjs +2 -0
- package/dist/columns-DwDukx_r.mjs.map +1 -0
- package/dist/en-BcniJZiB.mjs +2 -0
- package/dist/en-BcniJZiB.mjs.map +1 -0
- package/dist/entity-BC8r_5xy.mjs +2 -0
- package/dist/entity-BC8r_5xy.mjs.map +1 -0
- package/dist/et-BL3ZuuFV.mjs +2 -0
- package/dist/et-BL3ZuuFV.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-_Nj03ytp.d.mts +296 -0
- package/dist/menu-item-schema-_Nj03ytp.d.mts.map +1 -0
- package/dist/menu-resolve-context-DLIUP8RY.mjs +2 -0
- package/dist/menu-resolve-context-DLIUP8RY.mjs.map +1 -0
- package/dist/messages/en.json +22 -0
- package/dist/messages/et.json +22 -0
- package/dist/messages/ru.json +22 -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-D8QNW6XE.mjs +2 -0
- package/dist/ru-D8QNW6XE.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/dist/seed.d.mts +67 -0
- package/dist/seed.d.mts.map +1 -0
- package/dist/seed.mjs +2 -0
- package/dist/seed.mjs.map +1 -0
- package/package.json +62 -0
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { _ as validateTreeDepth, 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, o as MAX_MENU_DEPTH, p as MenuTreeMeasurement, r as DEFAULT_MENU_DEPTH, s as MAX_MENU_TREE_NODES, v as MAX_MENU_ENTITY_TYPE_WHITELIST, x as normalizeMenuEntityTypes, y as MenuEntityTypesSchema } from "./menu-item-schema-_Nj03ytp.mjs";
|
|
2
|
+
import { Behavior } from "@murumets-ee/entity";
|
|
3
|
+
|
|
4
|
+
//#region src/behaviors/menu-entity-types-validation.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* Recursive-free Zod validation of `menu.entityTypes` on every create/update.
|
|
7
|
+
*
|
|
8
|
+
* Contributes NO fields, so the field-map type param is the EMPTY map
|
|
9
|
+
* `Record<never, never>` — NOT `Record<string, never>`, which would merge into
|
|
10
|
+
* the entity's field map and collapse every field to `never`.
|
|
11
|
+
*/
|
|
12
|
+
declare function menuEntityTypesValidation(): Behavior<Record<never, never>, 'menuEntityTypesValidation'>;
|
|
13
|
+
//#endregion
|
|
14
|
+
//#region src/behaviors/menu-tree-validation.d.ts
|
|
15
|
+
/** Options for {@link menuTreeValidation}. */
|
|
16
|
+
interface MenuTreeValidationOptions {
|
|
17
|
+
/**
|
|
18
|
+
* Resolve the locale codes accepted as `label` / `enabled` keys.
|
|
19
|
+
*
|
|
20
|
+
* Defaults to the `content()` plugin's configured locales, read LAZILY (per
|
|
21
|
+
* write, never at module scope) so the behavior can be constructed before
|
|
22
|
+
* the app boots. Injectable so the behavior is unit-testable without a
|
|
23
|
+
* booted content plugin.
|
|
24
|
+
*/
|
|
25
|
+
resolveAllowedLocales?: () => readonly string[];
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Recursive Zod validation of `menu.tree` on every create/update.
|
|
29
|
+
*
|
|
30
|
+
* Contributes NO fields, so the field-map type param is the EMPTY map
|
|
31
|
+
* `Record<never, never>` — NOT `Record<string, never>`, which would merge into
|
|
32
|
+
* the entity's field map and collapse every field to `never` (a break that
|
|
33
|
+
* only surfaces through the built `.d.mts`).
|
|
34
|
+
*/
|
|
35
|
+
declare function menuTreeValidation(options?: MenuTreeValidationOptions): Behavior<Record<never, never>, 'menuTreeValidation'>;
|
|
36
|
+
//#endregion
|
|
37
|
+
//#region src/entity.d.ts
|
|
38
|
+
/**
|
|
39
|
+
* The `menu` entity (plan/routing phase 08 §2, D008/D009).
|
|
40
|
+
*
|
|
41
|
+
* A menu is a normal entity whose item tree is ONE ordered nested JSONB column
|
|
42
|
+
* — the family WordPress migrated to and Statamic uses, and the same storage
|
|
43
|
+
* philosophy as our blocks layouts. Whole-tree edits save atomically in one
|
|
44
|
+
* PATCH, and `publishable()` gives draft-then-publish menu editing for free.
|
|
45
|
+
*
|
|
46
|
+
* Locale variance lives INSIDE the tree (per-item `label` / `enabled` maps),
|
|
47
|
+
* so there is deliberately no `translatable` field and no `menu_translations`
|
|
48
|
+
* table — one structure serves every locale (D009).
|
|
49
|
+
*/
|
|
50
|
+
declare const Menu: import("@murumets-ee/entity").Entity<{
|
|
51
|
+
id: import("@murumets-ee/entity").IdField;
|
|
52
|
+
} & import("@murumets-ee/entity").AuditableFields & import("@murumets-ee/entity").PublishableFields & Record<never, never> & {
|
|
53
|
+
handle: import("@murumets-ee/entity").SlugField & {
|
|
54
|
+
readonly from: "title";
|
|
55
|
+
};
|
|
56
|
+
title: import("@murumets-ee/entity").TextField & {
|
|
57
|
+
readonly required: true;
|
|
58
|
+
};
|
|
59
|
+
maxDepth: import("@murumets-ee/entity").NumberField & {
|
|
60
|
+
readonly default: 3;
|
|
61
|
+
readonly min: 1;
|
|
62
|
+
readonly max: 6;
|
|
63
|
+
readonly integer: true;
|
|
64
|
+
};
|
|
65
|
+
tree: import("@murumets-ee/entity").JsonField & {
|
|
66
|
+
readonly refScan: true;
|
|
67
|
+
readonly default: readonly [];
|
|
68
|
+
};
|
|
69
|
+
entityTypes: import("@murumets-ee/entity").JsonField & {
|
|
70
|
+
readonly default: null;
|
|
71
|
+
};
|
|
72
|
+
}, "menu", [import("@murumets-ee/entity").Behavior<import("@murumets-ee/entity").AuditableFields, string>, import("@murumets-ee/entity").Behavior<import("@murumets-ee/entity").PublishableFields, "publishable">, import("@murumets-ee/entity").Behavior<Record<never, never>, "menuTreeValidation">, import("@murumets-ee/entity").Behavior<Record<never, never>, "menuEntityTypesValidation">]>;
|
|
73
|
+
//#endregion
|
|
74
|
+
export { DEFAULT_MENU_DEPTH, LinkValueSchema, MAX_MENU_DEPTH, MAX_MENU_ENTITY_TYPE_WHITELIST, MAX_MENU_TREE_NODES, MAX_MENU_URL_LENGTH, Menu, MenuEntityTypesSchema, type MenuItem, type MenuLinkValue, type MenuSchemaOptions, type MenuTreeMeasurement, type MenuTreeValidationOptions, buildMenuItemSchema, buildMenuTreeSchema, measureMenuTree, menuAcceptsEntityType, menuEntityTypesValidation, menuTreeValidation, normalizeMenuEntityTypes, validateTreeDepth };
|
|
75
|
+
//# sourceMappingURL=index.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/behaviors/menu-entity-types-validation.ts","../src/behaviors/menu-tree-validation.ts","../src/entity.ts"],"mappings":";;;;;;;;;;;iBAuFgB,yBAAA,IAA6B,QAAQ,CACnD,MAAA;;;ACoGF;AAAA,UA/JiB,yBAAA;;;;;;;;;EASf,qBAAqB;AAAA;;AAwJL;;;;AC5KlB;;;iBD0KgB,kBAAA,CACd,OAAA,GAAU,yBAAA,GACT,QAAA,CAAS,MAAA;;;;;;;ADvGZ;;;;AACQ;;;;cEtEK,IAAA,gCAAI,MAAA"}
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{d as e,f as t,h as n,i as r,l as i,m as a,n as o,o as s,p as c,r as l,s as u,t as d,u as f}from"./menu-entity-types-BnKnKbif.mjs";import{n as p,r as m,t as h}from"./entity-BC8r_5xy.mjs";export{s as DEFAULT_MENU_DEPTH,u as LinkValueSchema,i as MAX_MENU_DEPTH,d as MAX_MENU_ENTITY_TYPE_WHITELIST,f as MAX_MENU_TREE_NODES,e as MAX_MENU_URL_LENGTH,h as Menu,o as MenuEntityTypesSchema,t as buildMenuItemSchema,c as buildMenuTreeSchema,a as measureMenuTree,l as menuAcceptsEntityType,m as menuEntityTypesValidation,p as menuTreeValidation,r as normalizeMenuEntityTypes,n as validateTreeDepth};
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{z as e}from"zod";import{sanitizeHref as t}from"@murumets-ee/blocks/links";const n=6,r=3,i=500,a=2048,o=128,s=/^[A-Za-z0-9_-]+$/,c=e.discriminatedUnion(`kind`,[e.object({kind:e.literal(`entity`),entity:e.string().min(1).max(64),id:e.string().uuid(),anchor:e.string().min(1).max(128).regex(s).optional()}),e.object({kind:e.literal(`url`),url:e.string().min(1).max(a).transform(e=>t(e)).refine(e=>e!==``,{message:`link url uses a disallowed protocol (allowed: http, https, mailto, tel, or a relative path)`})}),e.object({kind:e.literal(`email`),email:e.string().email().max(254)})]);function l(t,n){let r=new Set(n);return e.unknown().superRefine((t,n)=>{if(typeof t!=`object`||!t||Array.isArray(t))return;let i=Object.keys(t);if(i.length>r.size){n.addIssue({code:e.ZodIssueCode.custom,message:`too many locale keys — configured locales are: ${[...r].join(`, `)}`});return}for(let t of i)r.has(t)||n.addIssue({code:e.ZodIssueCode.custom,path:[t],message:`unknown locale key — configured locales are: ${[...r].join(`, `)}`})}).pipe(e.record(e.string(),t))}function u(t){let n=l(e.string().max(200),t.allowedLocales),r=l(e.boolean(),t.allowedLocales),i=e.lazy(()=>e.object({id:e.string().min(1).max(64),kind:e.enum([`link`,`header`]),link:c.optional(),label:n.nullable().default(null),enabled:r.optional(),newTab:e.boolean().optional(),children:e.array(i).default([])}).superRefine((t,n)=>{if(t.kind===`link`){t.link===void 0&&n.addIssue({code:e.ZodIssueCode.custom,path:[`link`],message:`a link item requires a link target`});return}t.link!==void 0&&n.addIssue({code:e.ZodIssueCode.custom,path:[`link`],message:`a header item must not carry a link target`}),(t.label===null||Object.keys(t.label).length===0)&&n.addIssue({code:e.ZodIssueCode.custom,path:[`label`],message:`a header item requires a label for at least one locale`})}));return i}function d(t){return e.array(u(t))}function f(e){if(!Array.isArray(e))return{depth:0,nodeCount:0};let t=0,n=0,r=e.map(e=>({node:e,depth:1}));for(;r.length>0;){let e=r.pop();if(e===void 0)break;let{node:i,depth:a}=e;if(typeof i!=`object`||!i||Array.isArray(i))continue;if(n++,a>t&&(t=a),n>500||t>6)return{depth:t,nodeCount:n};let o=i.children;if(Array.isArray(o)){let e=o;for(let t of e)r.push({node:t,depth:a+1})}}return{depth:t,nodeCount:n}}function p(e,t){return f(e).depth<=t}const m=64,h=e.string().min(1).max(64),g=e.array(h).max(64).nullable().transform(e=>{if(e===null)return null;let t=[...new Set(e)];return t.length===0?null:t});function _(e){if(!Array.isArray(e))return null;let t=e,n=[],r=new Set;for(let e of t)if(!(typeof e!=`string`||e.length===0)&&!(e.length>64)&&!r.has(e)&&(r.add(e),n.push(e),n.length===64))break;return n.length===0?null:n}function v(e,t){let n=_(e);return n===null||n.includes(t)}export{s as a,o as c,a as d,u as f,p as h,_ as i,n as l,f as m,g as n,r as o,d as p,v as r,c as s,m as t,i as u};
|
|
2
|
+
//# sourceMappingURL=menu-entity-types-BnKnKbif.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"menu-entity-types-BnKnKbif.mjs","names":["roots"],"sources":["../src/menu-item-schema.ts","../src/menu-entity-types.ts"],"sourcesContent":["/**\n * `MenuItem` — the menu tree node — and its recursive Zod validation\n * (plan/routing phase 08 §3).\n *\n * A menu's `tree` column is ordered nested JSONB (D008), so the ONLY thing\n * standing between an admin PATCH and the database is this schema: it is the\n * boundary validation required by CLAUDE.md's C5. It enforces\n *\n * - the `kind`-conditional shape (`link` items carry a `LinkValue`, `header`\n * items carry a label map and no link),\n * - locale keys drawn from the app's configured locale set,\n * - `LinkValue`s that mirror `@murumets-ee/blocks`' union exactly, with `url`\n * kinds protocol-gated through the SAME `sanitizeHref` allowlist the link\n * picker uses (no `javascript:` / `data:` / protocol-relative smuggling),\n * - hard structural bounds on tree size + nesting, checked BEFORE the\n * recursive parse so a hostile payload can't turn validation itself into a\n * stack-overflow / CPU amplification vector.\n *\n * This module is deliberately **pure**: no `@murumets-ee/core`, no content\n * plugin lookup, no DB. The allowed-locale set is passed IN, so the schema can\n * be built server-side (the `menuTreeValidation()` behavior) or client-side\n * (PR10's editor, with locales handed down from the server) from the same code.\n * Cross-field depth validation against the entity's sibling `maxDepth` column\n * lives in {@link validateTreeDepth}, which the behavior calls separately.\n */\n\nimport { sanitizeHref } from '@murumets-ee/blocks/links'\nimport { z } from 'zod'\n\n/**\n * Absolute nesting cap for any menu, independent of a menu's own `maxDepth`\n * (phase 07 §2.7 — \"depth is capped per menu (default 3, max 6)\"). Also the\n * bound that keeps the recursive parse from being a DoS vector.\n */\nexport const MAX_MENU_DEPTH = 6\n\n/** `maxDepth`'s default — a 3-level menu (phase 08 §2). */\nexport const DEFAULT_MENU_DEPTH = 3\n\n/**\n * Upper bound on the number of nodes in one menu tree. Navigation menus are\n * hand-curated: a few dozen items is a big one. The cap exists so a single\n * PATCH can't hand the validator (and `extractRefs`' ref walk, and every\n * subsequent render) an unbounded amount of work — CLAUDE.md's\n * \"no unbounded fan-out\" rule applied to a single request payload.\n */\nexport const MAX_MENU_TREE_NODES = 500\n\n/**\n * Max stored length of a `url`-kind link. Pinned to `entity`'s\n * `MAX_EXTERNAL_URL_LENGTH` (the `external_link_usage.url_raw` column width)\n * at COMPILE time via `satisfies` + a type-only import — so a longer URL is\n * rejected at the boundary instead of being silently dropped from the\n * reference graph. Same drift-proofing trick as content's\n * `DEFAULT_LOCALE_SENTINEL`; `import type` has zero runtime cost, so the pure\n * schema module keeps its (client-safe) dependency-free shape.\n */\nexport const MAX_MENU_URL_LENGTH =\n 2048 satisfies typeof import('@murumets-ee/entity/refs')['MAX_EXTERNAL_URL_LENGTH']\n\n/** Max length of an item's stable id (a UUID today; generated client-side). */\nconst MAX_ITEM_ID_LENGTH = 64\n\n/**\n * Max length of a per-locale label string. Exported for the one other writer of\n * a label — the translatable source (`./translatable-source.ts`) — which tells\n * a translator the bound up front and refuses an over-long answer by item.\n */\nexport const MAX_LABEL_LENGTH = 200\n\n/**\n * Max length of an entity-link fragment/anchor.\n *\n * Exported because this module is the SINGLE OWNER of the anchor bounds: the\n * write-side schema below, the read-side anchor walk\n * (`admin/anchors.ts`) and the editor's manual-entry fallback\n * (`@murumets-ee/menus-ui`'s anchor picker, via the client-safe `./schema`\n * subpath) all validate against exactly these two constants. Two of those three\n * used to carry their own copy; a drift between them would let the picker offer\n * — or the editor accept — a value the write boundary then rejects.\n */\nexport const MAX_ANCHOR_LENGTH = 128\n\n/**\n * Max length of an entity NAME inside an entity link (`page`, `article`, …).\n *\n * Exported for the same single-owner reason as {@link MAX_ANCHOR_LENGTH}: the\n * per-menu `entityTypes` whitelist (`./menu-entity-types.ts`) stores values of\n * exactly this kind, and a second copy of the bound could drift into a\n * whitelist entry the link schema then refuses.\n */\nexport const MAX_ENTITY_NAME_LENGTH = 64\n\n/**\n * Anchors are slugified by the block editor (phase 08 §5) and rendered into an\n * `href` as `#<anchor>`; restricting them to slug characters keeps that\n * interpolation inert.\n *\n * Exported for the same single-owner reason as {@link MAX_ANCHOR_LENGTH}.\n */\nexport const ANCHOR_PATTERN = /^[A-Za-z0-9_-]+$/\n\n/**\n * A menu item's link target — the item-type union phase 07 §2.2 and phase 08 §3\n * define: `entity` (+ optional anchor) | `url` | `email`.\n *\n * **A NARROWED SUBSET of `@murumets-ee/blocks`' `LinkValue`, deliberately.**\n * Blocks additionally carries a `media` kind; menus does NOT, because no phase\n * gives a menu a media-href seam to resolve one with (phase 11 §2 specifies\n * `href` as \"canonical path from `toolkit_paths`; external URLs verbatim;\n * header items null\" and nothing else). Accepting a `media` link would let it\n * validate, persist, and register an `entity_refs` row — arming delete\n * protection for that file — while the renderer had no way to emit it: the item\n * would silently never appear. Rejecting it at the write boundary turns that\n * dead weight into a real 400. Widening later is additive (an arm here plus a\n * `resolveMediaHref` dep on `MenuResolveDeps`) and needs no shape change.\n *\n * Every `MenuLinkValue` IS a valid blocks `LinkValue` (the subset direction, and\n * the one `resolveLinkHref` needs) — pinned at compile time by an assignment in\n * `menu-item-schema.test.ts`, so a drift in blocks' union is a build error\n * rather than a silent divergence.\n *\n * The `anchor?: string | undefined` is forced by the combination of Zod and this\n * repo's `exactOptionalPropertyTypes`: `.optional()` always widens to\n * `T | undefined`, which is NOT assignable to blocks' `anchor?: string` under\n * that flag.\n */\nexport type MenuLinkValue =\n | { kind: 'entity'; entity: string; id: string; anchor?: string | undefined }\n | { kind: 'url'; url: string }\n | { kind: 'email'; email: string }\n\n/**\n * A menu tree node (phase 08 §3 / D009).\n *\n * `label: null` means \"use the LIVE title of the linked entity, per locale\";\n * a map is an explicit per-locale override. `enabled` is per-locale visibility\n * in BOTH directions — absent/`true` shows the item, `false` hides it in that\n * locale — so one tree serves every locale (no `menu_translations` table).\n *\n * Declared as a `type`, NOT an `interface`, on purpose: only type aliases get\n * TypeScript's implicit index signature, and without it `MenuItem[]` is not\n * assignable to the `Record<string, unknown>[]` arm of `AdminClient.create/\n * update`'s json-field value type — every caller storing a typed tree would\n * need a cast.\n */\nexport type MenuItem = {\n /** Stable id — DND identity + anchor for audit diffs. */\n id: string\n /** `header` = a text-only dropdown parent with no link of its own. */\n kind: 'link' | 'header'\n /**\n * Present iff `kind === 'link'`.\n *\n * The explicit `| undefined` is required by the repo's\n * `exactOptionalPropertyTypes` — Zod's `.optional()` emits\n * `LinkValue | undefined`, and without it the schema wouldn't typecheck\n * against this interface.\n */\n link?: MenuLinkValue | undefined\n /** `null` = live entity title per locale; a map = explicit override. */\n label: Record<string, string> | null\n /** Per-locale visibility. Absent/`true` = visible; `false` = hidden. */\n enabled?: Record<string, boolean> | undefined\n newTab?: boolean | undefined\n children: MenuItem[]\n}\n\n/**\n * One menu item's target, as the editor needs to render its row\n * (`@murumets-ee/menus/admin`'s `resolveMenuItemSummaries`).\n *\n * Declared here — not in `admin/summaries.ts` — for the same reason\n * {@link BlockAnchor} is: it is a pure data shape with no server dependency,\n * so the client-safe `./schema` subpath can re-export it without pulling the\n * server-only resolver along. `import type` is erased at compile time either\n * way, but a single canonical home (this leaf module) is simpler to reason\n * about than \"which of two server-only files owns the type\".\n */\nexport interface MenuItemSummary {\n /**\n * The target row's live title, or `null` when the entity declares no\n * display-able field. Unlike the public render path — which DROPS such an\n * item rather than show a raw uuid as navigation — the admin editor still\n * reports the target as present, just unlabelled.\n */\n title: string | null\n /** `false` = the target row is gone (deleted) → a BROKEN item. */\n exists: boolean\n /** `null` when the entity isn't publishable — no draft/published distinction applies. */\n published: boolean | null\n}\n\n/**\n * One addressable block anchor on a document (`@murumets-ee/menus/admin`'s\n * `collectBlockAnchors` / the anchors endpoint). See {@link MenuItemSummary}'s\n * doc for why this pure shape lives here rather than in `admin/anchors.ts`.\n */\nexport interface BlockAnchor {\n /** The stable id rendered as the block element's `id` attribute. */\n anchor: string\n /** Display text — `anchorLabel` when usable, else the anchor value itself. */\n label: string\n}\n\n/** Options for {@link buildMenuItemSchema} / {@link buildMenuTreeSchema}. */\nexport interface MenuSchemaOptions {\n /**\n * Locale codes accepted as keys in `label` / `enabled` maps. Anything else\n * is a validation error — a typo'd locale would produce a label no renderer\n * ever reads (the same failure mode content's `validateLocale` guards).\n */\n allowedLocales: readonly string[]\n}\n\n/**\n * {@link MenuLinkValue} as Zod. Blocks models its own `LinkValue` as a plain TS\n * type + structural guards (zod-free by design), so this is the first Zod\n * encoding of it — narrowed to the three kinds a menu item may carry.\n *\n * There is deliberately NO `media` arm (see {@link MenuLinkValue}): a media\n * link has no href seam at render time, so accepting one would store a\n * permanently invisible item. `z.discriminatedUnion` rejects it with an\n * `Invalid discriminator value. Expected 'entity' | 'url' | 'email'` issue —\n * which names the allowed set without echoing the caller's own value back.\n *\n * `url` kinds run through `sanitizeHref`, the single source of truth for the\n * href protocol allowlist (`http:`/`https:`/`mailto:`/`tel:` + relative\n * paths); it returns `''` for anything it rejects, including\n * protocol-relative `//evil.com` and obfuscated `java\\nscript:`.\n */\nexport const LinkValueSchema = z.discriminatedUnion('kind', [\n z.object({\n kind: z.literal('entity'),\n entity: z.string().min(1).max(MAX_ENTITY_NAME_LENGTH),\n id: z.string().uuid(),\n anchor: z.string().min(1).max(MAX_ANCHOR_LENGTH).regex(ANCHOR_PATTERN).optional(),\n }),\n z.object({\n kind: z.literal('url'),\n // Transform-then-refine, NOT refine-alone: `sanitizeHref` trims, so\n // validating its output while storing the raw input would let a value\n // that PASSED validation differ from the value that gets STORED (leading\n // /trailing whitespace). Storing the sanitizer's own return value keeps\n // stored == validated. The arrow wrapper is required — `sanitizeHref`'s\n // second parameter is the protocol allowlist, and passing Zod's ctx into\n // it would silently replace the allowlist.\n url: z\n .string()\n .min(1)\n .max(MAX_MENU_URL_LENGTH)\n .transform((value) => sanitizeHref(value))\n .refine((value) => value !== '', {\n message:\n 'link url uses a disallowed protocol (allowed: http, https, mailto, tel, or a relative path)',\n }),\n }),\n z.object({\n kind: z.literal('email'),\n email: z.string().email().max(254),\n }),\n])\n\n/**\n * A `Record<locale, T>` whose keys must all be configured locale codes.\n *\n * The key check is PIPED INTO the record parse rather than refined on top of\n * it, so it runs against the RAW value BEFORE any per-value parsing. That\n * ordering is the whole point: with `z.record(...).superRefine(...)`, zod\n * parses all N values first, so a 1,000,000-key map of wrong-typed values\n * emits 1,000,000 `invalid_type` issues before a width check on top could ever\n * fire (verified empirically against zod 3.25). Piping caps BOTH shapes —\n * unknown keys and bad values — at a single issue.\n */\nfunction localeMap<T extends z.ZodTypeAny>(\n valueSchema: T,\n allowedLocales: readonly string[],\n): z.ZodType<Record<string, z.infer<T>>, z.ZodTypeDef, unknown> {\n const allowed = new Set(allowedLocales)\n return z\n .unknown()\n .superRefine((value, ctx) => {\n // Not a plain object — let the record parse below emit the single\n // \"expected object\" issue rather than duplicating its message here.\n if (value === null || typeof value !== 'object' || Array.isArray(value)) return\n\n const keys = Object.keys(value)\n // Width bound, checked FIRST. A legitimate map holds at most one entry\n // per configured locale, so a wider map is malformed by construction and\n // gets ONE issue for the whole map. Without this, a single node carrying\n // e.g. 1,000,000 keys sails past the tree pre-scan (which bounds node\n // COUNT and DEPTH, never map WIDTH) and turns validation itself into the\n // amplification vector it exists to prevent: a multi-hundred-MB issue\n // array plus an oversized audit-log write, from one request.\n // CLAUDE.md's \"no unbounded fan-out\" applied to a single payload.\n if (keys.length > allowed.size) {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n message: `too many locale keys — configured locales are: ${[...allowed].join(', ')}`,\n })\n return\n }\n\n // Past the width bound this loop is bounded by the CONFIGURED locale\n // count, never by the payload.\n for (const key of keys) {\n if (!allowed.has(key)) {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n path: [key],\n // Names the allowed set (server-side data), never echoes the\n // caller-supplied key into the message — see the api-handler's\n // \"refine()/custom messages must not echo user input\" contract.\n message: `unknown locale key — configured locales are: ${[...allowed].join(', ')}`,\n })\n }\n }\n })\n .pipe(z.record(z.string(), valueSchema))\n}\n\n/**\n * The recursive per-node schema. Input is `unknown` (this parses untrusted\n * JSONB), output is a fully-narrowed {@link MenuItem}. Unknown properties are\n * STRIPPED by Zod's default object behavior, so writing the parse result back\n * onto the payload also sanitizes it — nothing a caller invents gets stored.\n */\nexport function buildMenuItemSchema(\n options: MenuSchemaOptions,\n): z.ZodType<MenuItem, z.ZodTypeDef, unknown> {\n const labelMap = localeMap(z.string().max(MAX_LABEL_LENGTH), options.allowedLocales)\n const enabledMap = localeMap(z.boolean(), options.allowedLocales)\n\n const schema: z.ZodType<MenuItem, z.ZodTypeDef, unknown> = z.lazy(() =>\n z\n .object({\n id: z.string().min(1).max(MAX_ITEM_ID_LENGTH),\n kind: z.enum(['link', 'header']),\n link: LinkValueSchema.optional(),\n label: labelMap.nullable().default(null),\n enabled: enabledMap.optional(),\n newTab: z.boolean().optional(),\n children: z.array(schema).default([]),\n })\n .superRefine((node, ctx) => {\n if (node.kind === 'link') {\n if (node.link === undefined) {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n path: ['link'],\n message: 'a link item requires a link target',\n })\n }\n return\n }\n // header\n if (node.link !== undefined) {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n path: ['link'],\n message: 'a header item must not carry a link target',\n })\n }\n // A header has no entity to borrow a live title from, so its label\n // map is required (phase 08 §3) — otherwise it renders as nothing.\n if (node.label === null || Object.keys(node.label).length === 0) {\n ctx.addIssue({\n code: z.ZodIssueCode.custom,\n path: ['label'],\n message: 'a header item requires a label for at least one locale',\n })\n }\n }),\n )\n\n return schema\n}\n\n/** The whole tree — an array of {@link buildMenuItemSchema} nodes. */\nexport function buildMenuTreeSchema(\n options: MenuSchemaOptions,\n): z.ZodType<MenuItem[], z.ZodTypeDef, unknown> {\n return z.array(buildMenuItemSchema(options))\n}\n\n/** Result of {@link measureMenuTree}. */\nexport interface MenuTreeMeasurement {\n /** Deepest nesting level; `0` for an empty tree, `1` for flat items. */\n depth: number\n /** Total node count across every level. */\n nodeCount: number\n}\n\n/**\n * Measure a (possibly untrusted) tree's depth + node count **iteratively**, so\n * measuring can never blow the stack the way a recursive walk over a hostile\n * payload would. Stops early once either hard bound is exceeded and reports\n * the over-limit value — the caller turns that into a validation error.\n *\n * Accepts `unknown` on purpose: this runs BEFORE the Zod parse (bounding the\n * work that parse will do), so it can't assume a well-formed shape.\n */\nexport function measureMenuTree(tree: unknown): MenuTreeMeasurement {\n if (!Array.isArray(tree)) return { depth: 0, nodeCount: 0 }\n\n let maxDepth = 0\n let nodeCount = 0\n // `Array.isArray` narrows an `unknown` to `any[]`; re-binding through a\n // `readonly unknown[]` annotation keeps every element typed as `unknown`\n // instead of silently leaking `any` into the walk.\n const roots: readonly unknown[] = tree\n const stack: Array<{ node: unknown; depth: number }> = roots.map((node) => ({ node, depth: 1 }))\n\n while (stack.length > 0) {\n const current = stack.pop()\n if (current === undefined) break\n const { node, depth } = current\n if (node === null || typeof node !== 'object' || Array.isArray(node)) continue\n\n nodeCount++\n if (depth > maxDepth) maxDepth = depth\n // Early abort: past either bound the exact totals no longer matter, only\n // that they're over — and continuing would be the amplification we're\n // guarding against.\n if (nodeCount > MAX_MENU_TREE_NODES || maxDepth > MAX_MENU_DEPTH) {\n return { depth: maxDepth, nodeCount }\n }\n\n const children = (node as { children?: unknown }).children\n if (Array.isArray(children)) {\n const items: readonly unknown[] = children\n for (const child of items) stack.push({ node: child, depth: depth + 1 })\n }\n }\n\n return { depth: maxDepth, nodeCount }\n}\n\n/**\n * Cross-field check: does `tree` nest no deeper than the menu's own\n * `maxDepth`? Lives outside the node schema because a per-node Zod refinement\n * has no visibility of the sibling `maxDepth` COLUMN — the behavior calls this\n * after the recursive parse.\n */\nexport function validateTreeDepth(tree: unknown, maxDepth: number): boolean {\n return measureMenuTree(tree).depth <= maxDepth\n}\n","/**\n * The per-menu `entityTypes` whitelist — \"which content types may be linked\n * from this menu\" (plan/routing phase 07 §2.8, 09 §3, 10 §4).\n *\n * Drupal's per-content-type menu offering, minus the config-entity ceremony: a\n * `page` doesn't declare \"I belong in menus\", each MENU declares which types it\n * accepts. Two surfaces consume it — the menu editor's \"Add link\" picker (which\n * content types its Content tab offers) and the Meta panel's Menus section\n * (which menus an entity's edit screen offers to join).\n *\n * ## Semantics — `null` is \"everything\", and it is the ONLY unrestricted shape\n *\n * `null` (or an absent column) = the menu accepts every entity type. That is the\n * default, and it is what every menu row written before this column existed\n * reads back as, so the whitelist is backward-compatible by construction.\n *\n * An EMPTY array means the same thing, and {@link MenuEntityTypesSchema}\n * normalises it to `null` on the way in — the editor's \"nothing selected\" state\n * naturally produces `[]`, and letting both spellings persist would mean every\n * reader had to remember to check for two. Readers still tolerate a stored `[]`\n * (a hand-edited row, an older dump) via {@link menuAcceptsEntityType}.\n *\n * A menu that accepts NOTHING is deliberately unrepresentable: it could only\n * ever be a mistake, and \"empty selection\" is far more likely to mean \"I haven't\n * narrowed this yet\" than \"refuse everything\".\n *\n * ## Not a security boundary\n *\n * This is an authoring affordance. It decides what a picker OFFERS; it does not\n * decide what a caller may write — `menu:update` on the entity PATCH does, and\n * `menuTreeValidation()` re-checks the tree's shape regardless of the whitelist.\n * So the read path fails OPEN (a malformed column means \"no restriction\"): a\n * junk value must not hide every menu from the admin who needs to fix it. The\n * WRITE path fails closed, as every boundary does.\n *\n * @module\n */\n\nimport { z } from 'zod'\nimport { MAX_ENTITY_NAME_LENGTH } from './menu-item-schema.js'\n\n/**\n * Upper bound on how many entity types one menu's whitelist may name.\n *\n * A real app registers a few dozen entities, so this is a malformed-payload\n * guard rather than a product limit — it keeps a hostile PATCH from parking an\n * unbounded array in the column (and, through it, in every editor's RSC\n * payload).\n *\n * **Not** `MAX_MENU_ENTITY_TYPES` (`./get-menu.ts`, 16). That one bounds RENDER\n * fan-out — how many distinct types one menu render may issue batched lookups\n * for — and applies to the types actually present in the stored tree. This one\n * bounds the stored AUTHORING whitelist, which costs no I/O at all.\n */\nexport const MAX_MENU_ENTITY_TYPE_WHITELIST = 64\n\n/** One entity name in the whitelist — same bound `link.entity` carries. */\nconst EntityTypeName = z.string().min(1).max(MAX_ENTITY_NAME_LENGTH)\n\n/**\n * The write-boundary schema for `menu.entityTypes`.\n *\n * Strict where {@link normalizeMenuEntityTypes} is tolerant: a junk entry is a\n * 400, not a silent drop, because silently discarding half an admin's selection\n * and reporting success is worse than refusing the write. Duplicates ARE folded\n * (an idempotent selection is not an error), and `[]` becomes `null` so the\n * column only ever holds one representation of \"unrestricted\".\n */\nexport const MenuEntityTypesSchema: z.ZodType<string[] | null, z.ZodTypeDef, unknown> = z\n .array(EntityTypeName)\n // DELIBERATE ORDER — `.max()` runs on the RAW array, BEFORE the dedup in the\n // transform below, so 65 copies of `'page'` is a 400 even though it would\n // store one entry. The bound is on the PAYLOAD, not on the result: deduping\n // first would mean accepting an unbounded array to prove it was small.\n .max(MAX_MENU_ENTITY_TYPE_WHITELIST)\n .nullable()\n .transform((value) => {\n if (value === null) return null\n const deduped = [...new Set(value)]\n return deduped.length === 0 ? null : deduped\n })\n\n/**\n * Narrow a raw `entityTypes` column value (untrusted shape) to a usable\n * whitelist, or `null` for \"no restriction\".\n *\n * The read-side counterpart of {@link MenuEntityTypesSchema}, and tolerant for\n * the same reason `normalizeMenuTree` is: rows written before this column\n * existed, hand edits and restored dumps all reach readers, and a render must\n * degrade rather than throw. Anything unusable — a non-array, a non-string\n * entry, a whitelist that reduces to nothing — reads as `null`.\n */\nexport function normalizeMenuEntityTypes(value: unknown): string[] | null {\n if (!Array.isArray(value)) return null\n // `Array.isArray` narrows `unknown` to `any[]`; re-binding through a\n // `readonly unknown[]` annotation keeps every element typed as `unknown`.\n const raw: readonly unknown[] = value\n\n const out: string[] = []\n const seen = new Set<string>()\n for (const entry of raw) {\n if (typeof entry !== 'string' || entry.length === 0) continue\n if (entry.length > MAX_ENTITY_NAME_LENGTH) continue\n if (seen.has(entry)) continue\n seen.add(entry)\n out.push(entry)\n if (out.length === MAX_MENU_ENTITY_TYPE_WHITELIST) break\n }\n return out.length === 0 ? null : out\n}\n\n/**\n * Does this menu accept links to `entityType`?\n *\n * Takes the RAW column value so callers holding a `field.json()`-typed row can\n * ask directly, without narrowing first. Exact string match — entity names are\n * lowercase identifiers, and a case-insensitive or prefix match would silently\n * widen a whitelist the admin wrote precisely.\n *\n * Fails OPEN (see the module header): absent, empty and malformed all mean \"no\n * restriction\".\n */\nexport function menuAcceptsEntityType(value: unknown, entityType: string): boolean {\n const whitelist = normalizeMenuEntityTypes(value)\n return whitelist === null || whitelist.includes(entityType)\n}\n"],"mappings":"iFAkCA,MAAa,EAAiB,EAGjB,EAAqB,EASrB,EAAsB,IAWtB,EACX,KAuBW,EAAoB,IAmBpB,EAAiB,mBAmIjB,EAAkB,EAAE,mBAAmB,OAAQ,CAC1D,EAAE,OAAO,CACP,KAAM,EAAE,QAAQ,QAAQ,EACxB,OAAQ,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAA,EAA0B,EACpD,GAAI,EAAE,OAAO,CAAC,CAAC,KAAK,EACpB,OAAQ,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAA,GAAqB,CAAC,CAAC,MAAM,CAAc,CAAC,CAAC,SAAS,CAClF,CAAC,EACD,EAAE,OAAO,CACP,KAAM,EAAE,QAAQ,KAAK,EAQrB,IAAK,EACF,OAAO,CAAC,CACR,IAAI,CAAC,CAAC,CACN,IAAI,CAAmB,CAAC,CACxB,UAAW,GAAU,EAAa,CAAK,CAAC,CAAC,CACzC,OAAQ,GAAU,IAAU,GAAI,CAC/B,QACE,6FACJ,CAAC,CACL,CAAC,EACD,EAAE,OAAO,CACP,KAAM,EAAE,QAAQ,OAAO,EACvB,MAAO,EAAE,OAAO,CAAC,CAAC,MAAM,CAAC,CAAC,IAAI,GAAG,CACnC,CAAC,CACH,CAAC,EAaD,SAAS,EACP,EACA,EAC8D,CAC9D,IAAM,EAAU,IAAI,IAAI,CAAc,EACtC,OAAO,EACJ,QAAQ,CAAC,CACT,aAAa,EAAO,IAAQ,CAG3B,GAAsB,OAAO,GAAU,WAAnC,GAA+C,MAAM,QAAQ,CAAK,EAAG,OAEzE,IAAM,EAAO,OAAO,KAAK,CAAK,EAS9B,GAAI,EAAK,OAAS,EAAQ,KAAM,CAC9B,EAAI,SAAS,CACX,KAAM,EAAE,aAAa,OACrB,QAAS,kDAAkD,CAAC,GAAG,CAAO,CAAC,CAAC,KAAK,IAAI,GACnF,CAAC,EACD,MACF,CAIA,IAAK,IAAM,KAAO,EACX,EAAQ,IAAI,CAAG,GAClB,EAAI,SAAS,CACX,KAAM,EAAE,aAAa,OACrB,KAAM,CAAC,CAAG,EAIV,QAAS,gDAAgD,CAAC,GAAG,CAAO,CAAC,CAAC,KAAK,IAAI,GACjF,CAAC,CAGP,CAAC,CAAC,CACD,KAAK,EAAE,OAAO,EAAE,OAAO,EAAG,CAAW,CAAC,CAC3C,CAQA,SAAgB,EACd,EAC4C,CAC5C,IAAM,EAAW,EAAU,EAAE,OAAO,CAAC,CAAC,IAAA,GAAoB,EAAG,EAAQ,cAAc,EAC7E,EAAa,EAAU,EAAE,QAAQ,EAAG,EAAQ,cAAc,EAE1D,EAAqD,EAAE,SAC3D,EACG,OAAO,CACN,GAAI,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,EAAkB,EAC5C,KAAM,EAAE,KAAK,CAAC,OAAQ,QAAQ,CAAC,EAC/B,KAAM,EAAgB,SAAS,EAC/B,MAAO,EAAS,SAAS,CAAC,CAAC,QAAQ,IAAI,EACvC,QAAS,EAAW,SAAS,EAC7B,OAAQ,EAAE,QAAQ,CAAC,CAAC,SAAS,EAC7B,SAAU,EAAE,MAAM,CAAM,CAAC,CAAC,QAAQ,CAAC,CAAC,CACtC,CAAC,CAAC,CACD,aAAa,EAAM,IAAQ,CAC1B,GAAI,EAAK,OAAS,OAAQ,CACpB,EAAK,OAAS,IAAA,IAChB,EAAI,SAAS,CACX,KAAM,EAAE,aAAa,OACrB,KAAM,CAAC,MAAM,EACb,QAAS,oCACX,CAAC,EAEH,MACF,CAEI,EAAK,OAAS,IAAA,IAChB,EAAI,SAAS,CACX,KAAM,EAAE,aAAa,OACrB,KAAM,CAAC,MAAM,EACb,QAAS,4CACX,CAAC,GAIC,EAAK,QAAU,MAAQ,OAAO,KAAK,EAAK,KAAK,CAAC,CAAC,SAAW,IAC5D,EAAI,SAAS,CACX,KAAM,EAAE,aAAa,OACrB,KAAM,CAAC,OAAO,EACd,QAAS,wDACX,CAAC,CAEL,CAAC,CACL,EAEA,OAAO,CACT,CAGA,SAAgB,EACd,EAC8C,CAC9C,OAAO,EAAE,MAAM,EAAoB,CAAO,CAAC,CAC7C,CAmBA,SAAgB,EAAgB,EAAoC,CAClE,GAAI,CAAC,MAAM,QAAQ,CAAI,EAAG,MAAO,CAAE,MAAO,EAAG,UAAW,CAAE,EAE1D,IAAI,EAAW,EACX,EAAY,EAKV,EAAiDA,EAAM,IAAK,IAAU,CAAE,OAAM,MAAO,CAAE,EAAE,EAE/F,KAAO,EAAM,OAAS,GAAG,CACvB,IAAM,EAAU,EAAM,IAAI,EAC1B,GAAI,IAAY,IAAA,GAAW,MAC3B,GAAM,CAAE,OAAM,SAAU,EACxB,GAAqB,OAAO,GAAS,WAAjC,GAA6C,MAAM,QAAQ,CAAI,EAAG,SAOtE,GALA,IACI,EAAQ,IAAU,EAAW,GAI7B,EAAA,KAAmC,EAAA,EACrC,MAAO,CAAE,MAAO,EAAU,WAAU,EAGtC,IAAM,EAAY,EAAgC,SAClD,GAAI,MAAM,QAAQ,CAAQ,EAAG,CAC3B,IAAM,EAA4B,EAClC,IAAK,IAAM,KAAS,EAAO,EAAM,KAAK,CAAE,KAAM,EAAO,MAAO,EAAQ,CAAE,CAAC,CACzE,CACF,CAEA,MAAO,CAAE,MAAO,EAAU,WAAU,CACtC,CAQA,SAAgB,EAAkB,EAAe,EAA2B,CAC1E,OAAO,EAAgB,CAAI,CAAC,CAAC,OAAS,CACxC,CCxYA,MAAa,EAAiC,GAGxC,EAAiB,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAA,EAA0B,EAWtD,EAA2E,EACrF,MAAM,CAAc,CAAC,CAKrB,IAAA,EAAkC,CAAC,CACnC,SAAS,CAAC,CACV,UAAW,GAAU,CACpB,GAAI,IAAU,KAAM,OAAO,KAC3B,IAAM,EAAU,CAAC,GAAG,IAAI,IAAI,CAAK,CAAC,EAClC,OAAO,EAAQ,SAAW,EAAI,KAAO,CACvC,CAAC,EAYH,SAAgB,EAAyB,EAAiC,CACxE,GAAI,CAAC,MAAM,QAAQ,CAAK,EAAG,OAAO,KAGlC,IAAM,EAA0B,EAE1B,EAAgB,CAAC,EACjB,EAAO,IAAI,IACjB,IAAK,IAAM,KAAS,EACd,YAAO,GAAU,UAAY,EAAM,SAAW,IAC9C,IAAM,OAAA,KACN,GAAK,IAAI,CAAK,IAClB,EAAK,IAAI,CAAK,EACd,EAAI,KAAK,CAAK,EACV,EAAI,SAAA,IAA2C,MAErD,OAAO,EAAI,SAAW,EAAI,KAAO,CACnC,CAaA,SAAgB,EAAsB,EAAgB,EAA6B,CACjF,IAAM,EAAY,EAAyB,CAAK,EAChD,OAAO,IAAc,MAAQ,EAAU,SAAS,CAAU,CAC5D"}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
//#region src/menu-entity-types.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Upper bound on how many entity types one menu's whitelist may name.
|
|
6
|
+
*
|
|
7
|
+
* A real app registers a few dozen entities, so this is a malformed-payload
|
|
8
|
+
* guard rather than a product limit — it keeps a hostile PATCH from parking an
|
|
9
|
+
* unbounded array in the column (and, through it, in every editor's RSC
|
|
10
|
+
* payload).
|
|
11
|
+
*
|
|
12
|
+
* **Not** `MAX_MENU_ENTITY_TYPES` (`./get-menu.ts`, 16). That one bounds RENDER
|
|
13
|
+
* fan-out — how many distinct types one menu render may issue batched lookups
|
|
14
|
+
* for — and applies to the types actually present in the stored tree. This one
|
|
15
|
+
* bounds the stored AUTHORING whitelist, which costs no I/O at all.
|
|
16
|
+
*/
|
|
17
|
+
declare const MAX_MENU_ENTITY_TYPE_WHITELIST = 64;
|
|
18
|
+
/**
|
|
19
|
+
* The write-boundary schema for `menu.entityTypes`.
|
|
20
|
+
*
|
|
21
|
+
* Strict where {@link normalizeMenuEntityTypes} is tolerant: a junk entry is a
|
|
22
|
+
* 400, not a silent drop, because silently discarding half an admin's selection
|
|
23
|
+
* and reporting success is worse than refusing the write. Duplicates ARE folded
|
|
24
|
+
* (an idempotent selection is not an error), and `[]` becomes `null` so the
|
|
25
|
+
* column only ever holds one representation of "unrestricted".
|
|
26
|
+
*/
|
|
27
|
+
declare const MenuEntityTypesSchema: z.ZodType<string[] | null, z.ZodTypeDef, unknown>;
|
|
28
|
+
/**
|
|
29
|
+
* Narrow a raw `entityTypes` column value (untrusted shape) to a usable
|
|
30
|
+
* whitelist, or `null` for "no restriction".
|
|
31
|
+
*
|
|
32
|
+
* The read-side counterpart of {@link MenuEntityTypesSchema}, and tolerant for
|
|
33
|
+
* the same reason `normalizeMenuTree` is: rows written before this column
|
|
34
|
+
* existed, hand edits and restored dumps all reach readers, and a render must
|
|
35
|
+
* degrade rather than throw. Anything unusable — a non-array, a non-string
|
|
36
|
+
* entry, a whitelist that reduces to nothing — reads as `null`.
|
|
37
|
+
*/
|
|
38
|
+
declare function normalizeMenuEntityTypes(value: unknown): string[] | null;
|
|
39
|
+
/**
|
|
40
|
+
* Does this menu accept links to `entityType`?
|
|
41
|
+
*
|
|
42
|
+
* Takes the RAW column value so callers holding a `field.json()`-typed row can
|
|
43
|
+
* ask directly, without narrowing first. Exact string match — entity names are
|
|
44
|
+
* lowercase identifiers, and a case-insensitive or prefix match would silently
|
|
45
|
+
* widen a whitelist the admin wrote precisely.
|
|
46
|
+
*
|
|
47
|
+
* Fails OPEN (see the module header): absent, empty and malformed all mean "no
|
|
48
|
+
* restriction".
|
|
49
|
+
*/
|
|
50
|
+
declare function menuAcceptsEntityType(value: unknown, entityType: string): boolean;
|
|
51
|
+
//#endregion
|
|
52
|
+
//#region src/menu-item-schema.d.ts
|
|
53
|
+
/**
|
|
54
|
+
* Absolute nesting cap for any menu, independent of a menu's own `maxDepth`
|
|
55
|
+
* (phase 07 §2.7 — "depth is capped per menu (default 3, max 6)"). Also the
|
|
56
|
+
* bound that keeps the recursive parse from being a DoS vector.
|
|
57
|
+
*/
|
|
58
|
+
declare const MAX_MENU_DEPTH = 6;
|
|
59
|
+
/** `maxDepth`'s default — a 3-level menu (phase 08 §2). */
|
|
60
|
+
declare const DEFAULT_MENU_DEPTH = 3;
|
|
61
|
+
/**
|
|
62
|
+
* Upper bound on the number of nodes in one menu tree. Navigation menus are
|
|
63
|
+
* hand-curated: a few dozen items is a big one. The cap exists so a single
|
|
64
|
+
* PATCH can't hand the validator (and `extractRefs`' ref walk, and every
|
|
65
|
+
* subsequent render) an unbounded amount of work — CLAUDE.md's
|
|
66
|
+
* "no unbounded fan-out" rule applied to a single request payload.
|
|
67
|
+
*/
|
|
68
|
+
declare const MAX_MENU_TREE_NODES = 500;
|
|
69
|
+
/**
|
|
70
|
+
* Max stored length of a `url`-kind link. Pinned to `entity`'s
|
|
71
|
+
* `MAX_EXTERNAL_URL_LENGTH` (the `external_link_usage.url_raw` column width)
|
|
72
|
+
* at COMPILE time via `satisfies` + a type-only import — so a longer URL is
|
|
73
|
+
* rejected at the boundary instead of being silently dropped from the
|
|
74
|
+
* reference graph. Same drift-proofing trick as content's
|
|
75
|
+
* `DEFAULT_LOCALE_SENTINEL`; `import type` has zero runtime cost, so the pure
|
|
76
|
+
* schema module keeps its (client-safe) dependency-free shape.
|
|
77
|
+
*/
|
|
78
|
+
declare const MAX_MENU_URL_LENGTH = 2048;
|
|
79
|
+
/**
|
|
80
|
+
* Max length of an entity-link fragment/anchor.
|
|
81
|
+
*
|
|
82
|
+
* Exported because this module is the SINGLE OWNER of the anchor bounds: the
|
|
83
|
+
* write-side schema below, the read-side anchor walk
|
|
84
|
+
* (`admin/anchors.ts`) and the editor's manual-entry fallback
|
|
85
|
+
* (`@murumets-ee/menus-ui`'s anchor picker, via the client-safe `./schema`
|
|
86
|
+
* subpath) all validate against exactly these two constants. Two of those three
|
|
87
|
+
* used to carry their own copy; a drift between them would let the picker offer
|
|
88
|
+
* — or the editor accept — a value the write boundary then rejects.
|
|
89
|
+
*/
|
|
90
|
+
declare const MAX_ANCHOR_LENGTH = 128;
|
|
91
|
+
/**
|
|
92
|
+
* Anchors are slugified by the block editor (phase 08 §5) and rendered into an
|
|
93
|
+
* `href` as `#<anchor>`; restricting them to slug characters keeps that
|
|
94
|
+
* interpolation inert.
|
|
95
|
+
*
|
|
96
|
+
* Exported for the same single-owner reason as {@link MAX_ANCHOR_LENGTH}.
|
|
97
|
+
*/
|
|
98
|
+
declare const ANCHOR_PATTERN: RegExp;
|
|
99
|
+
/**
|
|
100
|
+
* A menu item's link target — the item-type union phase 07 §2.2 and phase 08 §3
|
|
101
|
+
* define: `entity` (+ optional anchor) | `url` | `email`.
|
|
102
|
+
*
|
|
103
|
+
* **A NARROWED SUBSET of `@murumets-ee/blocks`' `LinkValue`, deliberately.**
|
|
104
|
+
* Blocks additionally carries a `media` kind; menus does NOT, because no phase
|
|
105
|
+
* gives a menu a media-href seam to resolve one with (phase 11 §2 specifies
|
|
106
|
+
* `href` as "canonical path from `toolkit_paths`; external URLs verbatim;
|
|
107
|
+
* header items null" and nothing else). Accepting a `media` link would let it
|
|
108
|
+
* validate, persist, and register an `entity_refs` row — arming delete
|
|
109
|
+
* protection for that file — while the renderer had no way to emit it: the item
|
|
110
|
+
* would silently never appear. Rejecting it at the write boundary turns that
|
|
111
|
+
* dead weight into a real 400. Widening later is additive (an arm here plus a
|
|
112
|
+
* `resolveMediaHref` dep on `MenuResolveDeps`) and needs no shape change.
|
|
113
|
+
*
|
|
114
|
+
* Every `MenuLinkValue` IS a valid blocks `LinkValue` (the subset direction, and
|
|
115
|
+
* the one `resolveLinkHref` needs) — pinned at compile time by an assignment in
|
|
116
|
+
* `menu-item-schema.test.ts`, so a drift in blocks' union is a build error
|
|
117
|
+
* rather than a silent divergence.
|
|
118
|
+
*
|
|
119
|
+
* The `anchor?: string | undefined` is forced by the combination of Zod and this
|
|
120
|
+
* repo's `exactOptionalPropertyTypes`: `.optional()` always widens to
|
|
121
|
+
* `T | undefined`, which is NOT assignable to blocks' `anchor?: string` under
|
|
122
|
+
* that flag.
|
|
123
|
+
*/
|
|
124
|
+
type MenuLinkValue = {
|
|
125
|
+
kind: 'entity';
|
|
126
|
+
entity: string;
|
|
127
|
+
id: string;
|
|
128
|
+
anchor?: string | undefined;
|
|
129
|
+
} | {
|
|
130
|
+
kind: 'url';
|
|
131
|
+
url: string;
|
|
132
|
+
} | {
|
|
133
|
+
kind: 'email';
|
|
134
|
+
email: string;
|
|
135
|
+
};
|
|
136
|
+
/**
|
|
137
|
+
* A menu tree node (phase 08 §3 / D009).
|
|
138
|
+
*
|
|
139
|
+
* `label: null` means "use the LIVE title of the linked entity, per locale";
|
|
140
|
+
* a map is an explicit per-locale override. `enabled` is per-locale visibility
|
|
141
|
+
* in BOTH directions — absent/`true` shows the item, `false` hides it in that
|
|
142
|
+
* locale — so one tree serves every locale (no `menu_translations` table).
|
|
143
|
+
*
|
|
144
|
+
* Declared as a `type`, NOT an `interface`, on purpose: only type aliases get
|
|
145
|
+
* TypeScript's implicit index signature, and without it `MenuItem[]` is not
|
|
146
|
+
* assignable to the `Record<string, unknown>[]` arm of `AdminClient.create/
|
|
147
|
+
* update`'s json-field value type — every caller storing a typed tree would
|
|
148
|
+
* need a cast.
|
|
149
|
+
*/
|
|
150
|
+
type MenuItem = {
|
|
151
|
+
/** Stable id — DND identity + anchor for audit diffs. */id: string; /** `header` = a text-only dropdown parent with no link of its own. */
|
|
152
|
+
kind: 'link' | 'header';
|
|
153
|
+
/**
|
|
154
|
+
* Present iff `kind === 'link'`.
|
|
155
|
+
*
|
|
156
|
+
* The explicit `| undefined` is required by the repo's
|
|
157
|
+
* `exactOptionalPropertyTypes` — Zod's `.optional()` emits
|
|
158
|
+
* `LinkValue | undefined`, and without it the schema wouldn't typecheck
|
|
159
|
+
* against this interface.
|
|
160
|
+
*/
|
|
161
|
+
link?: MenuLinkValue | undefined; /** `null` = live entity title per locale; a map = explicit override. */
|
|
162
|
+
label: Record<string, string> | null; /** Per-locale visibility. Absent/`true` = visible; `false` = hidden. */
|
|
163
|
+
enabled?: Record<string, boolean> | undefined;
|
|
164
|
+
newTab?: boolean | undefined;
|
|
165
|
+
children: MenuItem[];
|
|
166
|
+
};
|
|
167
|
+
/**
|
|
168
|
+
* One menu item's target, as the editor needs to render its row
|
|
169
|
+
* (`@murumets-ee/menus/admin`'s `resolveMenuItemSummaries`).
|
|
170
|
+
*
|
|
171
|
+
* Declared here — not in `admin/summaries.ts` — for the same reason
|
|
172
|
+
* {@link BlockAnchor} is: it is a pure data shape with no server dependency,
|
|
173
|
+
* so the client-safe `./schema` subpath can re-export it without pulling the
|
|
174
|
+
* server-only resolver along. `import type` is erased at compile time either
|
|
175
|
+
* way, but a single canonical home (this leaf module) is simpler to reason
|
|
176
|
+
* about than "which of two server-only files owns the type".
|
|
177
|
+
*/
|
|
178
|
+
interface MenuItemSummary {
|
|
179
|
+
/**
|
|
180
|
+
* The target row's live title, or `null` when the entity declares no
|
|
181
|
+
* display-able field. Unlike the public render path — which DROPS such an
|
|
182
|
+
* item rather than show a raw uuid as navigation — the admin editor still
|
|
183
|
+
* reports the target as present, just unlabelled.
|
|
184
|
+
*/
|
|
185
|
+
title: string | null;
|
|
186
|
+
/** `false` = the target row is gone (deleted) → a BROKEN item. */
|
|
187
|
+
exists: boolean;
|
|
188
|
+
/** `null` when the entity isn't publishable — no draft/published distinction applies. */
|
|
189
|
+
published: boolean | null;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* One addressable block anchor on a document (`@murumets-ee/menus/admin`'s
|
|
193
|
+
* `collectBlockAnchors` / the anchors endpoint). See {@link MenuItemSummary}'s
|
|
194
|
+
* doc for why this pure shape lives here rather than in `admin/anchors.ts`.
|
|
195
|
+
*/
|
|
196
|
+
interface BlockAnchor {
|
|
197
|
+
/** The stable id rendered as the block element's `id` attribute. */
|
|
198
|
+
anchor: string;
|
|
199
|
+
/** Display text — `anchorLabel` when usable, else the anchor value itself. */
|
|
200
|
+
label: string;
|
|
201
|
+
}
|
|
202
|
+
/** Options for {@link buildMenuItemSchema} / {@link buildMenuTreeSchema}. */
|
|
203
|
+
interface MenuSchemaOptions {
|
|
204
|
+
/**
|
|
205
|
+
* Locale codes accepted as keys in `label` / `enabled` maps. Anything else
|
|
206
|
+
* is a validation error — a typo'd locale would produce a label no renderer
|
|
207
|
+
* ever reads (the same failure mode content's `validateLocale` guards).
|
|
208
|
+
*/
|
|
209
|
+
allowedLocales: readonly string[];
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* {@link MenuLinkValue} as Zod. Blocks models its own `LinkValue` as a plain TS
|
|
213
|
+
* type + structural guards (zod-free by design), so this is the first Zod
|
|
214
|
+
* encoding of it — narrowed to the three kinds a menu item may carry.
|
|
215
|
+
*
|
|
216
|
+
* There is deliberately NO `media` arm (see {@link MenuLinkValue}): a media
|
|
217
|
+
* link has no href seam at render time, so accepting one would store a
|
|
218
|
+
* permanently invisible item. `z.discriminatedUnion` rejects it with an
|
|
219
|
+
* `Invalid discriminator value. Expected 'entity' | 'url' | 'email'` issue —
|
|
220
|
+
* which names the allowed set without echoing the caller's own value back.
|
|
221
|
+
*
|
|
222
|
+
* `url` kinds run through `sanitizeHref`, the single source of truth for the
|
|
223
|
+
* href protocol allowlist (`http:`/`https:`/`mailto:`/`tel:` + relative
|
|
224
|
+
* paths); it returns `''` for anything it rejects, including
|
|
225
|
+
* protocol-relative `//evil.com` and obfuscated `java\nscript:`.
|
|
226
|
+
*/
|
|
227
|
+
declare const LinkValueSchema: z.ZodDiscriminatedUnion<"kind", [z.ZodObject<{
|
|
228
|
+
kind: z.ZodLiteral<"entity">;
|
|
229
|
+
entity: z.ZodString;
|
|
230
|
+
id: z.ZodString;
|
|
231
|
+
anchor: z.ZodOptional<z.ZodString>;
|
|
232
|
+
}, "strip", z.ZodTypeAny, {
|
|
233
|
+
entity: string;
|
|
234
|
+
kind: "entity";
|
|
235
|
+
id: string;
|
|
236
|
+
anchor?: string | undefined;
|
|
237
|
+
}, {
|
|
238
|
+
entity: string;
|
|
239
|
+
kind: "entity";
|
|
240
|
+
id: string;
|
|
241
|
+
anchor?: string | undefined;
|
|
242
|
+
}>, z.ZodObject<{
|
|
243
|
+
kind: z.ZodLiteral<"url">;
|
|
244
|
+
url: z.ZodEffects<z.ZodEffects<z.ZodString, string, string>, string, string>;
|
|
245
|
+
}, "strip", z.ZodTypeAny, {
|
|
246
|
+
url: string;
|
|
247
|
+
kind: "url";
|
|
248
|
+
}, {
|
|
249
|
+
url: string;
|
|
250
|
+
kind: "url";
|
|
251
|
+
}>, z.ZodObject<{
|
|
252
|
+
kind: z.ZodLiteral<"email">;
|
|
253
|
+
email: z.ZodString;
|
|
254
|
+
}, "strip", z.ZodTypeAny, {
|
|
255
|
+
email: string;
|
|
256
|
+
kind: "email";
|
|
257
|
+
}, {
|
|
258
|
+
email: string;
|
|
259
|
+
kind: "email";
|
|
260
|
+
}>]>;
|
|
261
|
+
/**
|
|
262
|
+
* The recursive per-node schema. Input is `unknown` (this parses untrusted
|
|
263
|
+
* JSONB), output is a fully-narrowed {@link MenuItem}. Unknown properties are
|
|
264
|
+
* STRIPPED by Zod's default object behavior, so writing the parse result back
|
|
265
|
+
* onto the payload also sanitizes it — nothing a caller invents gets stored.
|
|
266
|
+
*/
|
|
267
|
+
declare function buildMenuItemSchema(options: MenuSchemaOptions): z.ZodType<MenuItem, z.ZodTypeDef, unknown>;
|
|
268
|
+
/** The whole tree — an array of {@link buildMenuItemSchema} nodes. */
|
|
269
|
+
declare function buildMenuTreeSchema(options: MenuSchemaOptions): z.ZodType<MenuItem[], z.ZodTypeDef, unknown>;
|
|
270
|
+
/** Result of {@link measureMenuTree}. */
|
|
271
|
+
interface MenuTreeMeasurement {
|
|
272
|
+
/** Deepest nesting level; `0` for an empty tree, `1` for flat items. */
|
|
273
|
+
depth: number;
|
|
274
|
+
/** Total node count across every level. */
|
|
275
|
+
nodeCount: number;
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Measure a (possibly untrusted) tree's depth + node count **iteratively**, so
|
|
279
|
+
* measuring can never blow the stack the way a recursive walk over a hostile
|
|
280
|
+
* payload would. Stops early once either hard bound is exceeded and reports
|
|
281
|
+
* the over-limit value — the caller turns that into a validation error.
|
|
282
|
+
*
|
|
283
|
+
* Accepts `unknown` on purpose: this runs BEFORE the Zod parse (bounding the
|
|
284
|
+
* work that parse will do), so it can't assume a well-formed shape.
|
|
285
|
+
*/
|
|
286
|
+
declare function measureMenuTree(tree: unknown): MenuTreeMeasurement;
|
|
287
|
+
/**
|
|
288
|
+
* Cross-field check: does `tree` nest no deeper than the menu's own
|
|
289
|
+
* `maxDepth`? Lives outside the node schema because a per-node Zod refinement
|
|
290
|
+
* has no visibility of the sibling `maxDepth` COLUMN — the behavior calls this
|
|
291
|
+
* after the recursive parse.
|
|
292
|
+
*/
|
|
293
|
+
declare function validateTreeDepth(tree: unknown, maxDepth: number): boolean;
|
|
294
|
+
//#endregion
|
|
295
|
+
export { validateTreeDepth as _, MAX_ANCHOR_LENGTH as a, menuAcceptsEntityType as b, MAX_MENU_URL_LENGTH as c, MenuLinkValue as d, MenuSchemaOptions as f, measureMenuTree as g, buildMenuTreeSchema as h, LinkValueSchema as i, MenuItem as l, buildMenuItemSchema as m, BlockAnchor as n, MAX_MENU_DEPTH as o, MenuTreeMeasurement as p, DEFAULT_MENU_DEPTH as r, MAX_MENU_TREE_NODES as s, ANCHOR_PATTERN as t, MenuItemSummary as u, MAX_MENU_ENTITY_TYPE_WHITELIST as v, normalizeMenuEntityTypes as x, MenuEntityTypesSchema as y };
|
|
296
|
+
//# sourceMappingURL=menu-item-schema-_Nj03ytp.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"menu-item-schema-_Nj03ytp.d.mts","names":[],"sources":["../src/menu-entity-types.ts","../src/menu-item-schema.ts"],"mappings":";;;;;ACqC+B;AAS/B;;;;AAAgC;AAWhC;;;;AAAgC;cDHnB,8BAAA;;;;AC2BiB;AAmB9B;;;;AAAgD;cDhCnC,qBAAA,EAAuB,CAAA,CAAE,OAAO,kBAAkB,CAAA,CAAE,UAAA;;;;;;;;;;;iBAwBjD,wBAAA,CAAyB,KAAc;;ACsC7B;AAgB1B;;;;;;;;;iBDxBgB,qBAAA,CAAsB,KAAA,WAAgB,UAAkB;;;;AAAA;;;;cCxF3D,cAAA;;cAGA,kBAAA;;AAHc;AAG3B;;;;AAA+B;cASlB,mBAAA;;;;AAAmB;AAWhC;;;;AAAgC;cAAnB,mBAAA;;;AA2CmC;AA2BhD;;;;;;;;cA9Ca,iBAAA;;;;;;;;cAmBA,cAAA,EAAc,MAAqB;;;;;;;;;;AAiE5B;AAcpB;;;;;;;;AAWW;AAQX;;;;AAIO;AAIP;KA/EY,aAAA;EACN,IAAA;EAAgB,MAAA;EAAgB,EAAA;EAAY,MAAA;AAAA;EAC5C,IAAA;EAAa,GAAA;AAAA;EACb,IAAA;EAAe,KAAA;AAAA;;;;;;;;;;;;;;;KAgBT,QAAA;EAqFgB,yDAnF1B,EAAA,UAmF0B;EAjF1B,IAAA;;;;;;;;;EASA,IAAA,GAAO,aAAA;EAEP,KAAA,EAAO,MAAA;EAEP,OAAA,GAAU,MAAA;EACV,MAAA;EACA,QAAA,EAAU,QAAA;AAAA;;;;;;;;;;;;UAcK,eAAA;;;;;;;EAOf,KAAA;;EAEA,MAAA;;EAEA,SAAA;AAAA;;;;;;UAQe,WAAA;;EAEf,MAAA;;EAEA,KAAK;AAAA;;UAIU,iBAAA;;;;;;EAMf,cAAc;AAAA;;;;;;;;;;;;;;;;AAqHmB;cAlGtB,eAAA,EAAe,CAAA,CAAA,qBAAA,UAAA,CAAA,CAAA,SAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAgGZ,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"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{n as e,t}from"./columns-DwDukx_r.mjs";import{getContentConfig as n}from"@murumets-ee/content/plugin";import{createSystemReachResolver as r}from"@murumets-ee/core";import{eq as i,inArray as a}from"drizzle-orm";import{getEntityTitleField as o,isTemplateDrawn as s}from"@murumets-ee/entity";import{QueryClient as c}from"@murumets-ee/entity/query";import{createDetailTemplateProvider as l}from"@murumets-ee/content/detail-template";import{buildCanonicalPathsBatchDeps as u,buildEnumerateDeps as d,getCanonicalPaths as f}from"@murumets-ee/content/resolve";const p=r(`menu-resolver`);function m(r){let m=r.db.readOnly,h=u(r),{filterPublished:g}=d(r);return{getMenuRow:async n=>{let a=new c({entity:e,db:m,logger:r.logger.child({resolve:`menu`}),contextResolver:p}),o=(await a.findMany({where:i(t(a.getTable(),`handle`),n),limit:1}))[0];return o===void 0?null:{maxDepth:o.maxDepth,tree:o.tree}},resolveCanonicalPaths:(e,t,n)=>f(e,t,n,h),filterPublished:g,resolveTitles:async(e,i,u)=>{let d=new Map;if(i.length===0)return d;let f=r.entities.get(e);if(f===void 0)return d;let h=s(f)?l(()=>r,{defaultLocale:n().defaultLocale}):void 0,g=o(f);if(h===void 0&&g===`id`)return d;let _=new c({entity:f,db:m,logger:r.logger.child({resolve:`menu-title`,entity:e}),contextResolver:p}),v=t(_.getTable(),`id`);for(let t=0;t<i.length;t+=500){let n=i.slice(t,t+500),o=await _.findMany({where:a(v,[...n]),locale:u,limit:n.length});for(let t of o){let n=t,i=n.id,a;if(h===void 0)a=n[g];else try{a=(await h.publicRow({entity:e,row:n,locale:u})).title}catch(t){r.logger.warn({err:t,entity:e},`menus: detail-template title projection failed — the item is dropped`)}typeof i==`string`&&typeof a==`string`&&a.length>0&&d.set(i,a)}}return d},defaultLocale:n().defaultLocale,logger:r.logger.child({resolve:`menu`})}}export{m as buildMenuResolveDeps};
|
|
2
|
+
//# sourceMappingURL=menu-resolve-context-DLIUP8RY.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"menu-resolve-context-DLIUP8RY.mjs","names":[],"sources":["../src/menu-resolve-context.ts"],"sourcesContent":["/**\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":"8iBAwDA,MAAM,EAAwC,EAA0B,eAAe,EAYvF,SAAgB,EAAqB,EAAkC,CACrE,IAAM,EAAK,EAAI,GAAG,SACZ,EAAiB,EAA6B,CAAG,EACjD,CAAE,mBAAoB,EAAmB,CAAG,EAElD,MAAO,CACL,WAAY,KAAO,IAA4C,CAC7D,IAAM,EAAS,IAAI,EAAY,CAC7B,OAAQ,EACR,KACA,OAAQ,EAAI,OAAO,MAAM,CAAE,QAAS,MAAO,CAAC,EAC5C,gBAAiB,CACnB,CAAC,EAeK,GAAM,MAJO,EAAO,SAAS,CACjC,MAAO,EAAG,EAAO,EAAO,SAAS,EAAG,QAAQ,EAAG,CAAM,EACrD,MAAO,CACT,CAAC,EAAA,CACgB,GAEjB,OADI,IAAQ,IAAA,GAAkB,KACvB,CAAE,SAAU,EAAI,SAAU,KAAM,EAAI,IAAK,CAClD,EAEA,uBAAwB,EAAY,EAAK,IACvC,EAAkB,EAAY,EAAK,EAAQ,CAAc,EAE3D,kBAEA,cAAe,MAAO,EAAY,EAAK,IAAW,CAChD,IAAM,EAAS,IAAI,IACnB,GAAI,EAAI,SAAW,EAAG,OAAO,EAG7B,IAAM,EAAS,EAAI,SAAS,IAAI,CAAU,EAC1C,GAAI,IAAW,IAAA,GAAW,OAAO,EAOjC,IAAM,EAAiB,EAAgB,CAAM,EACzC,MAAmC,EAAK,CACtC,cAAe,EAAiB,CAAC,CAAC,aACpC,CAAC,EACD,IAAA,GAEE,EAAa,EAAoB,CAAM,EAI7C,GAAI,IAAmB,IAAA,IAAa,IAAe,KAAM,OAAO,EAChE,IAAM,EAAS,IAAI,EAAY,CAC7B,SACA,KACA,OAAQ,EAAI,OAAO,MAAM,CAAE,QAAS,aAAc,OAAQ,CAAW,CAAC,EACtE,gBAAiB,CACnB,CAAC,EACK,EAAW,EAAO,EAAO,SAAS,EAAG,IAAI,EAE/C,IAAK,IAAI,EAAI,EAAG,EAAI,EAAI,OAAQ,GAAK,IAAkB,CACrD,IAAM,EAAQ,EAAI,MAAM,EAAG,EAAI,GAAgB,EAIzC,EAAO,MAAM,EAAO,SAAS,CACjC,MAAO,EAAQ,EAAU,CAAC,GAAG,CAAK,CAAC,EACnC,SACA,MAAO,EAAM,MACf,CAAC,EACD,IAAK,IAAM,KAAO,EAAM,CACtB,IAAM,EAAkC,EAClC,EAAK,EAAO,GACd,EACJ,GAAI,IAAmB,IAAA,GACrB,EAAQ,EAAO,QAIf,GAAI,CACF,GAAS,MAAM,EAAe,UAAU,CAAE,OAAQ,EAAY,IAAK,EAAQ,QAAO,CAAC,EAAA,CAChF,KACL,OAAS,EAAK,CACZ,EAAI,OAAO,KACT,CAAE,MAAK,OAAQ,CAAW,EAC1B,sEACF,CACF,CAEE,OAAO,GAAO,UAAY,OAAO,GAAU,UAAY,EAAM,OAAS,GACxE,EAAO,IAAI,EAAI,CAAK,CAExB,CACF,CACA,OAAO,CACT,EAEA,cAAe,EAAiB,CAAC,CAAC,cAClC,OAAQ,EAAI,OAAO,MAAM,CAAE,QAAS,MAAO,CAAC,CAC9C,CACF"}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"PluginNav": {
|
|
3
|
+
"menu": "Menus"
|
|
4
|
+
},
|
|
5
|
+
"Menus": {
|
|
6
|
+
"entity": {
|
|
7
|
+
"menu": "Menu",
|
|
8
|
+
"menus": "Menus",
|
|
9
|
+
"handle": "Handle",
|
|
10
|
+
"title": "Title",
|
|
11
|
+
"maxDepth": "Maximum depth",
|
|
12
|
+
"tree": "Items"
|
|
13
|
+
},
|
|
14
|
+
"itemKind": {
|
|
15
|
+
"link": "Link",
|
|
16
|
+
"header": "Header"
|
|
17
|
+
},
|
|
18
|
+
"translatableSource": {
|
|
19
|
+
"menu": "Menus"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"PluginNav": {
|
|
3
|
+
"menu": "Menüüd"
|
|
4
|
+
},
|
|
5
|
+
"Menus": {
|
|
6
|
+
"entity": {
|
|
7
|
+
"menu": "Menüü",
|
|
8
|
+
"menus": "Menüüd",
|
|
9
|
+
"handle": "Tunnus",
|
|
10
|
+
"title": "Pealkiri",
|
|
11
|
+
"maxDepth": "Maksimaalne sügavus",
|
|
12
|
+
"tree": "Elemendid"
|
|
13
|
+
},
|
|
14
|
+
"itemKind": {
|
|
15
|
+
"link": "Link",
|
|
16
|
+
"header": "Pealkiri"
|
|
17
|
+
},
|
|
18
|
+
"translatableSource": {
|
|
19
|
+
"menu": "Menüüd"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"PluginNav": {
|
|
3
|
+
"menu": "Меню"
|
|
4
|
+
},
|
|
5
|
+
"Menus": {
|
|
6
|
+
"entity": {
|
|
7
|
+
"menu": "Меню",
|
|
8
|
+
"menus": "Меню",
|
|
9
|
+
"handle": "Идентификатор",
|
|
10
|
+
"title": "Название",
|
|
11
|
+
"maxDepth": "Максимальная глубина",
|
|
12
|
+
"tree": "Элементы"
|
|
13
|
+
},
|
|
14
|
+
"itemKind": {
|
|
15
|
+
"link": "Ссылка",
|
|
16
|
+
"header": "Заголовок"
|
|
17
|
+
},
|
|
18
|
+
"translatableSource": {
|
|
19
|
+
"menu": "Меню"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|