@murumets-ee/menus 0.110.0 → 0.111.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +0,0 @@
1
- {"version":3,"file":"columns-DwDukx_r.mjs","names":["roots","prefixIssues"],"sources":["../src/menu-item-schema.ts","../src/menu-entity-types.ts","../src/behaviors/menu-entity-types-validation.ts","../src/behaviors/menu-tree-validation.ts","../src/entity.ts","../src/internal/columns.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","/**\n * `menuEntityTypesValidation()` — boundary validation for the\n * `menu.entityTypes` JSONB column (plan/routing phase 07 §2.8, F056).\n *\n * `field.json()`'s own validation is the deliberately permissive JSON union, so\n * without this hook an admin PATCH could park any shape — including an unbounded\n * array — in the column, and every reader (editor picker, Meta panel, every RSC\n * payload that carries the row) would pay for it. CLAUDE.md's \"validate at the\n * boundary\" applied to the one column `menuTreeValidation()` doesn't cover.\n *\n * **Why a second behavior rather than a branch inside `menuTreeValidation()`.**\n * The two share nothing: the tree check is CROSS-FIELD (it reads the sibling\n * `maxDepth` column through `loadCurrent`) and CROSS-MODULE (locale keys against\n * the content plugin's registry, hence its injectable resolver option); this one\n * is self-contained and needs no context at all. Folding it in would put an\n * option-less concern behind `MenuTreeValidationOptions` and make a behavior\n * named \"tree validation\" own a non-tree column.\n *\n * Errors are `ZodError`s so the admin API maps them to **400 with structured\n * issues** rather than a 500 — same contract `menuTreeValidation()` relies on.\n * Issues are pathed at `entityTypes` so the client can point at the field.\n */\n\nimport type { Behavior, BulkUpdateInfo } from '@murumets-ee/entity'\nimport { z } from 'zod'\nimport { MenuEntityTypesSchema } from '../menu-entity-types.js'\n\n/**\n * `AdminClient.updateMany` runs NO per-row hooks, so `beforeUpdate` above never\n * fires on that path — a bulk statement could otherwise park an unbounded or\n * malformed array in the column and every reader would pay for it, which is the\n * exact hole this behavior exists to close. `assertBulkUpdate` is the sanctioned\n * cover for that gap (same mechanism `hierarchical()` uses to stop a partial\n * bulk tree write).\n *\n * This REJECTS rather than validates: `updateMany` sets one value across every\n * matched row, and \"every menu accepts exactly these types\" is not a coherent\n * intent — a whitelist is per-menu by definition. There is no legitimate bulk\n * caller to serve, so refusing is both safer and more honest than silently\n * validating a shape nobody should be writing this way.\n */\nfunction assertNoBulkEntityTypesWrite(info: BulkUpdateInfo): void {\n if (!info.keys.includes('entityTypes')) return\n throw new z.ZodError([\n {\n code: 'custom',\n path: ['entityTypes'],\n message:\n `bulk update on '${info.entityName}' cannot write 'entityTypes': updateMany runs no ` +\n 'per-row hooks, so the whitelist would bypass its schema check. A whitelist is ' +\n 'per-menu — update each row individually, or write a migration for a one-off backfill.',\n },\n ])\n}\n\n/** Prefix every issue path with `entityTypes` so the client can locate it. */\nfunction prefixIssues(error: z.ZodError): z.ZodError {\n return new z.ZodError(\n error.issues.map((issue) => ({ ...issue, path: ['entityTypes', ...issue.path] })),\n )\n}\n\n/**\n * Validate + normalize `data.entityTypes` in place, returning the payload with\n * the PARSED value (so `[]` is persisted as `null` and duplicates are folded —\n * see `../menu-entity-types.ts` for why there is exactly one unrestricted\n * shape).\n *\n * A payload without the key is a partial update that doesn't touch the column;\n * it passes through untouched rather than being defaulted, which would let a\n * `{ tree }`-only PATCH silently clear an authored whitelist.\n */\nfunction validateEntityTypes(data: Record<string, unknown>): Record<string, unknown> {\n if (!('entityTypes' in data)) return data\n\n const parsed = MenuEntityTypesSchema.safeParse(data.entityTypes)\n if (!parsed.success) throw prefixIssues(parsed.error)\n return { ...data, entityTypes: parsed.data }\n}\n\n/**\n * Recursive-free Zod validation of `menu.entityTypes` on every create/update.\n *\n * Contributes NO fields, so the field-map type param is the EMPTY map\n * `Record<never, never>` — NOT `Record<string, never>`, which would merge into\n * the entity's field map and collapse every field to `never`.\n */\nexport function menuEntityTypesValidation(): Behavior<\n Record<never, never>,\n 'menuEntityTypesValidation'\n> {\n return {\n name: 'menuEntityTypesValidation',\n hooks: {\n beforeCreate: async (data) => validateEntityTypes(data),\n beforeUpdate: async (_id, data) => validateEntityTypes(data),\n },\n assertBulkUpdate: assertNoBulkEntityTypesWrite,\n }\n}\n","/**\n * `menuTreeValidation()` — boundary validation for the `menu.tree` JSONB column\n * (plan/routing phase 08 §3, F040 pt.2).\n *\n * Why a behavior and not field-local validation: two of the four rules are\n * CROSS-FIELD or CROSS-MODULE. The tree's nesting depth is checked against the\n * sibling `maxDepth` COLUMN, and the locale keys are checked against the app's\n * configured content locales — neither is visible to `field.json()`'s\n * (deliberately permissive) JSON union. `BehaviorContext` gives a hook both:\n * the update payload and, via `loadCurrent`, the pre-update row.\n *\n * Errors are raised as `ZodError`s on purpose: the admin API handler maps\n * `ZodError` to **400 with structured issues** (a plain `Error` would surface\n * as a 500), so an editor saving a malformed tree gets a per-node message\n * pointing at the offending item instead of \"Internal server error\".\n */\n\nimport { getContentConfig } from '@murumets-ee/content/plugin'\nimport type { Behavior, BehaviorContext } from '@murumets-ee/entity'\nimport { z } from 'zod'\nimport {\n buildMenuTreeSchema,\n DEFAULT_MENU_DEPTH,\n MAX_MENU_DEPTH,\n MAX_MENU_TREE_NODES,\n measureMenuTree,\n} from '../menu-item-schema.js'\n\n/** Options for {@link menuTreeValidation}. */\nexport interface MenuTreeValidationOptions {\n /**\n * Resolve the locale codes accepted as `label` / `enabled` keys.\n *\n * Defaults to the `content()` plugin's configured locales, read LAZILY (per\n * write, never at module scope) so the behavior can be constructed before\n * the app boots. Injectable so the behavior is unit-testable without a\n * booted content plugin.\n */\n resolveAllowedLocales?: () => readonly string[]\n}\n\n/** One-issue `ZodError` at `tree`(`.path`) — the 400-mapped failure shape. */\nfunction treeError(message: string, path: Array<string | number> = []): z.ZodError {\n return new z.ZodError([{ code: z.ZodIssueCode.custom, path: ['tree', ...path], message }])\n}\n\n/** Prefix every issue path with `tree` so the client can locate the node. */\nfunction prefixIssues(error: z.ZodError): z.ZodError {\n return new z.ZodError(error.issues.map((issue) => ({ ...issue, path: ['tree', ...issue.path] })))\n}\n\n/** A `maxDepth` value from an (unvalidated, pre-field-validation) payload. */\nfunction readMaxDepth(value: unknown): number | undefined {\n if (typeof value !== 'number' || !Number.isInteger(value)) return undefined\n if (value < 1 || value > MAX_MENU_DEPTH) return undefined\n return value\n}\n\n/**\n * The effective `maxDepth` for this write.\n *\n * Hooks run BEFORE field validation, so a payload's `maxDepth` may be absent\n * or junk; junk falls through to the persisted/default value and is rejected a\n * moment later by the field's own `min`/`max`. On a partial update that\n * touches only `tree`, the persisted value comes from `loadCurrent()` (the\n * eagerly-loaded pre-update snapshot).\n */\nasync function resolveMaxDepth(\n data: Record<string, unknown>,\n ctx: BehaviorContext | undefined,\n isUpdate: boolean,\n): Promise<number> {\n const fromPayload = readMaxDepth(data.maxDepth)\n if (fromPayload !== undefined) return fromPayload\n if (!isUpdate) return DEFAULT_MENU_DEPTH\n\n if (!ctx?.loadCurrent) {\n // Fail loud rather than silently skipping the cross-field check — an\n // unvalidated depth is a correctness hole. AdminClient always wires\n // `loadCurrent` on the update codepath, so this only trips on a\n // mis-wired / direct hook invocation. (Same posture as `pathParent`.)\n throw new Error(\n 'menu: BehaviorContext.loadCurrent is unavailable — cannot validate the tree ' +\n \"against the menu's maxDepth. Writes must go through AdminClient.update.\",\n )\n }\n const current = await ctx.loadCurrent()\n // A vanished/unreadable row can't supply its own bound, so fall back to the\n // DEFAULT (3) — the same value a create would get — not the absolute cap\n // (6). Falling back to the ceiling would make an edge case the single most\n // PERMISSIVE path in the behavior; the safe fallback is the floor. The\n // absolute cap still applies separately, and the update itself fails\n // downstream when the row is genuinely gone.\n return readMaxDepth(current?.maxDepth) ?? DEFAULT_MENU_DEPTH\n}\n\n/**\n * The REVERSE cross-field check: an update that narrows `maxDepth` without\n * touching `tree`.\n *\n * `{\"maxDepth\": 1}` on a menu whose stored tree is 5 levels deep used to skip\n * validation entirely (the payload has no `tree`, so there was nothing to\n * measure) — the write succeeded and the row then PERMANENTLY violated\n * \"tree depth <= maxDepth\" until someone happened to edit the tree again.\n * Both directions of the invariant have to be enforced, so when `maxDepth`\n * moves we measure the PERSISTED tree against the new bound.\n */\nasync function validateStoredTreeAgainstNewMaxDepth(\n data: Record<string, unknown>,\n ctx: BehaviorContext | undefined,\n): Promise<void> {\n // Absent (or junk — the field's own min/max rejects that a moment later)\n // means this write doesn't move the bound, so there's nothing to re-check.\n const maxDepth = readMaxDepth(data.maxDepth)\n if (maxDepth === undefined) return\n\n if (!ctx?.loadCurrent) {\n // Same fail-loud posture as `resolveMaxDepth` — silently skipping the\n // check is precisely the hole this function closes.\n throw new Error(\n 'menu: BehaviorContext.loadCurrent is unavailable — cannot validate the stored tree ' +\n 'against the new maxDepth. Writes must go through AdminClient.update.',\n )\n }\n\n const current = await ctx.loadCurrent()\n // Measured iteratively and on a value already bounded at write time, so this\n // is cheap even for the largest legal tree.\n const { depth } = measureMenuTree(current?.tree)\n if (depth > maxDepth) {\n throw treeError(\n `the stored menu tree is nested ${depth} levels deep, deeper than this menu's ` +\n `new maxDepth (${maxDepth}) — flatten the tree first`,\n )\n }\n}\n\n/**\n * Validate + normalize `data.tree` in place. Returns the payload with `tree`\n * replaced by the PARSED value, so unknown properties a caller invented are\n * stripped before they reach the column.\n */\nasync function validateTree(\n data: Record<string, unknown>,\n ctx: BehaviorContext | undefined,\n isUpdate: boolean,\n resolveAllowedLocales: () => readonly string[],\n): Promise<Record<string, unknown>> {\n const tree = data.tree\n if (tree === undefined) {\n // No tree in the payload — but a `maxDepth`-only update still has to be\n // checked against the tree already in the column.\n if (isUpdate) await validateStoredTreeAgainstNewMaxDepth(data, ctx)\n return data\n }\n if (tree === null || !Array.isArray(tree)) {\n throw treeError('menu tree must be an array of menu items')\n }\n\n // Structural bounds FIRST — this walk is iterative, so it holds even for a\n // payload crafted to blow the recursive parse's stack.\n const { depth, nodeCount } = measureMenuTree(tree)\n if (nodeCount > MAX_MENU_TREE_NODES) {\n throw treeError(`menu tree has too many items (max ${MAX_MENU_TREE_NODES})`)\n }\n if (depth > MAX_MENU_DEPTH) {\n throw treeError(`menu tree is nested too deeply (absolute max ${MAX_MENU_DEPTH} levels)`)\n }\n\n const parsed = buildMenuTreeSchema({ allowedLocales: resolveAllowedLocales() }).safeParse(tree)\n if (!parsed.success) throw prefixIssues(parsed.error)\n\n const maxDepth = await resolveMaxDepth(data, ctx, isUpdate)\n if (depth > maxDepth) {\n throw treeError(`menu tree is nested deeper than this menu's maxDepth (${maxDepth})`)\n }\n\n return { ...data, tree: parsed.data }\n}\n\n/**\n * Recursive Zod validation of `menu.tree` on every create/update.\n *\n * Contributes NO fields, so the field-map type param is the EMPTY map\n * `Record<never, never>` — NOT `Record<string, never>`, which would merge into\n * the entity's field map and collapse every field to `never` (a break that\n * only surfaces through the built `.d.mts`).\n */\nexport function menuTreeValidation(\n options?: MenuTreeValidationOptions,\n): Behavior<Record<never, never>, 'menuTreeValidation'> {\n const resolveAllowedLocales =\n options?.resolveAllowedLocales ?? (() => getContentConfig().locales.map((l) => l.code))\n\n return {\n name: 'menuTreeValidation',\n hooks: {\n beforeCreate: async (data, ctx) => validateTree(data, ctx, false, resolveAllowedLocales),\n beforeUpdate: async (_id, data, ctx) => validateTree(data, ctx, true, resolveAllowedLocales),\n },\n }\n}\n","/**\n * The `menu` entity (plan/routing phase 08 §2, D008/D009).\n *\n * A menu is a normal entity whose item tree is ONE ordered nested JSONB column\n * — the family WordPress migrated to and Statamic uses, and the same storage\n * philosophy as our blocks layouts. Whole-tree edits save atomically in one\n * PATCH, and `publishable()` gives draft-then-publish menu editing for free.\n *\n * Locale variance lives INSIDE the tree (per-item `label` / `enabled` maps),\n * so there is deliberately no `translatable` field and no `menu_translations`\n * table — one structure serves every locale (D009).\n */\n\nimport { auditable, defineEntity, field, publishable } from '@murumets-ee/entity'\nimport { menuEntityTypesValidation } from './behaviors/menu-entity-types-validation.js'\nimport { menuTreeValidation } from './behaviors/menu-tree-validation.js'\nimport { DEFAULT_MENU_DEPTH, MAX_MENU_DEPTH } from './menu-item-schema.js'\n\nexport const Menu = defineEntity({\n name: 'menu',\n // Menus belong to one site (multisite D002); the `handle` unique is per site,\n // so every site can have its own `main` and `footer`.\n site: 'bound',\n admin: {\n label: 'Menus',\n icon: 'menu',\n },\n fields: {\n // Fetch key — 'main', 'footer' (phase 07 §2.8). `from: 'title'` is the\n // admin form's client-side auto-fill source (SlugField watches it); the\n // value itself is author-chosen and stable, so `sluggable()` (which would\n // REGENERATE the handle when the title changes) is deliberately not used.\n // `field.slug()` already sets `unique` + `indexed` — no need to repeat them.\n handle: field.slug({ from: 'title' }),\n title: field.text({ required: true }),\n // 1..6 — the renderer clamps too (phase 07 §2.7).\n maxDepth: field.number({\n default: DEFAULT_MENU_DEPTH,\n min: 1,\n max: MAX_MENU_DEPTH,\n integer: true,\n }),\n // MenuItem[] — shape validated by `menuTreeValidation()`; `refScan` makes\n // every LinkValue in the tree register in `entity_refs` /\n // `external_link_usage` on save, so a menu item pointing at a page shows\n // up in delete-protection warnings and the broken-links dashboard\n // (phase 08 §4) with no menus-side ref code at all.\n tree: field.json({ refScan: true, default: [] }),\n // Which content types may be linked FROM this menu (phase 07 §2.8) — the\n // menu editor's picker and the Meta panel's Menus section both read it.\n // `null` = accepts every type, which is what every row written before this\n // column existed reads back as, so the whitelist is backward-compatible by\n // construction. Shape validated by `menuEntityTypesValidation()`; semantics\n // and the read-side predicate live in `./menu-entity-types.ts`.\n //\n // No `refScan`: the value holds entity NAMES, not `LinkValue`s — there is\n // no row to reference, so a scan would find nothing.\n entityTypes: field.json({ default: null }),\n },\n behaviors: [auditable(), publishable(), menuTreeValidation(), menuEntityTypesValidation()],\n scope: 'global',\n // Advisory metadata (the firewall is deny-by-default + operator grants).\n // Menus are site-wide navigation: a bad edit is visible on EVERY page, and\n // the tree is a structural artifact rather than content — so writes are\n // admin-only, while `view` is public because the frontend reads menus\n // unauthenticated through the content API.\n access: {\n view: 'public',\n create: 'group.admin',\n update: 'group.admin',\n delete: 'group.admin',\n },\n})\n","/**\n * Typed column access for a dynamically-generated entity table.\n *\n * `AdminClient.getTable()` / `QueryClient.getTable()` both return\n * `PgTableWithColumns<any>` (Drizzle cannot type columns that are generated\n * from a runtime entity definition), so reading a column off one directly\n * yields `any` and silently erodes this package's type coverage. Narrowing\n * through this helper pins the ONE column being filtered on — the same idiom\n * `@murumets-ee/content`'s `buildEnumerateDeps` uses.\n *\n * Internal to `@murumets-ee/menus`; not part of any public entry.\n */\n\nimport type { PgColumn, PgTableWithColumns } from 'drizzle-orm/pg-core'\n\n/** Read one named column off a runtime-generated entity table, typed. */\nexport function column<K extends string>(\n // biome-ignore lint/suspicious/noExplicitAny: Drizzle cannot type dynamically-generated column schemas — `PgTableWithColumns<any>` is `getTable()`'s own declared return type (CLAUDE.md's sanctioned case).\n table: PgTableWithColumns<any>,\n name: K,\n): PgColumn {\n return (table as unknown as Record<K, PgColumn>)[name]\n}\n"],"mappings":"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"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"en-BcniJZiB.mjs","names":[],"sources":["../src/messages/en.json"],"sourcesContent":[""],"mappings":""}
@@ -1 +0,0 @@
1
- {"version":3,"file":"entity-BC8r_5xy.mjs","names":["prefixIssues"],"sources":["../src/behaviors/menu-entity-types-validation.ts","../src/behaviors/menu-tree-validation.ts","../src/entity.ts"],"sourcesContent":["/**\n * `menuEntityTypesValidation()` — boundary validation for the\n * `menu.entityTypes` JSONB column (plan/routing phase 07 §2.8, F056).\n *\n * `field.json()`'s own validation is the deliberately permissive JSON union, so\n * without this hook an admin PATCH could park any shape — including an unbounded\n * array — in the column, and every reader (editor picker, Meta panel, every RSC\n * payload that carries the row) would pay for it. CLAUDE.md's \"validate at the\n * boundary\" applied to the one column `menuTreeValidation()` doesn't cover.\n *\n * **Why a second behavior rather than a branch inside `menuTreeValidation()`.**\n * The two share nothing: the tree check is CROSS-FIELD (it reads the sibling\n * `maxDepth` column through `loadCurrent`) and CROSS-MODULE (locale keys against\n * the content plugin's registry, hence its injectable resolver option); this one\n * is self-contained and needs no context at all. Folding it in would put an\n * option-less concern behind `MenuTreeValidationOptions` and make a behavior\n * named \"tree validation\" own a non-tree column.\n *\n * Errors are `ZodError`s so the admin API maps them to **400 with structured\n * issues** rather than a 500 — same contract `menuTreeValidation()` relies on.\n * Issues are pathed at `entityTypes` so the client can point at the field.\n */\n\nimport type { Behavior, BulkUpdateInfo } from '@murumets-ee/entity'\nimport { z } from 'zod'\nimport { MenuEntityTypesSchema } from '../menu-entity-types.js'\n\n/**\n * `AdminClient.updateMany` runs NO per-row hooks, so `beforeUpdate` above never\n * fires on that path — a bulk statement could otherwise park an unbounded or\n * malformed array in the column and every reader would pay for it, which is the\n * exact hole this behavior exists to close. `assertBulkUpdate` is the sanctioned\n * cover for that gap (same mechanism `hierarchical()` uses to stop a partial\n * bulk tree write).\n *\n * This REJECTS rather than validates: `updateMany` sets one value across every\n * matched row, and \"every menu accepts exactly these types\" is not a coherent\n * intent — a whitelist is per-menu by definition. There is no legitimate bulk\n * caller to serve, so refusing is both safer and more honest than silently\n * validating a shape nobody should be writing this way.\n */\nfunction assertNoBulkEntityTypesWrite(info: BulkUpdateInfo): void {\n if (!info.keys.includes('entityTypes')) return\n throw new z.ZodError([\n {\n code: 'custom',\n path: ['entityTypes'],\n message:\n `bulk update on '${info.entityName}' cannot write 'entityTypes': updateMany runs no ` +\n 'per-row hooks, so the whitelist would bypass its schema check. A whitelist is ' +\n 'per-menu — update each row individually, or write a migration for a one-off backfill.',\n },\n ])\n}\n\n/** Prefix every issue path with `entityTypes` so the client can locate it. */\nfunction prefixIssues(error: z.ZodError): z.ZodError {\n return new z.ZodError(\n error.issues.map((issue) => ({ ...issue, path: ['entityTypes', ...issue.path] })),\n )\n}\n\n/**\n * Validate + normalize `data.entityTypes` in place, returning the payload with\n * the PARSED value (so `[]` is persisted as `null` and duplicates are folded —\n * see `../menu-entity-types.ts` for why there is exactly one unrestricted\n * shape).\n *\n * A payload without the key is a partial update that doesn't touch the column;\n * it passes through untouched rather than being defaulted, which would let a\n * `{ tree }`-only PATCH silently clear an authored whitelist.\n */\nfunction validateEntityTypes(data: Record<string, unknown>): Record<string, unknown> {\n if (!('entityTypes' in data)) return data\n\n const parsed = MenuEntityTypesSchema.safeParse(data.entityTypes)\n if (!parsed.success) throw prefixIssues(parsed.error)\n return { ...data, entityTypes: parsed.data }\n}\n\n/**\n * Recursive-free Zod validation of `menu.entityTypes` on every create/update.\n *\n * Contributes NO fields, so the field-map type param is the EMPTY map\n * `Record<never, never>` — NOT `Record<string, never>`, which would merge into\n * the entity's field map and collapse every field to `never`.\n */\nexport function menuEntityTypesValidation(): Behavior<\n Record<never, never>,\n 'menuEntityTypesValidation'\n> {\n return {\n name: 'menuEntityTypesValidation',\n hooks: {\n beforeCreate: async (data) => validateEntityTypes(data),\n beforeUpdate: async (_id, data) => validateEntityTypes(data),\n },\n assertBulkUpdate: assertNoBulkEntityTypesWrite,\n }\n}\n","/**\n * `menuTreeValidation()` — boundary validation for the `menu.tree` JSONB column\n * (plan/routing phase 08 §3, F040 pt.2).\n *\n * Why a behavior and not field-local validation: two of the four rules are\n * CROSS-FIELD or CROSS-MODULE. The tree's nesting depth is checked against the\n * sibling `maxDepth` COLUMN, and the locale keys are checked against the app's\n * configured content locales — neither is visible to `field.json()`'s\n * (deliberately permissive) JSON union. `BehaviorContext` gives a hook both:\n * the update payload and, via `loadCurrent`, the pre-update row.\n *\n * Errors are raised as `ZodError`s on purpose: the admin API handler maps\n * `ZodError` to **400 with structured issues** (a plain `Error` would surface\n * as a 500), so an editor saving a malformed tree gets a per-node message\n * pointing at the offending item instead of \"Internal server error\".\n */\n\nimport { getContentConfig } from '@murumets-ee/content/plugin'\nimport type { Behavior, BehaviorContext } from '@murumets-ee/entity'\nimport { z } from 'zod'\nimport {\n buildMenuTreeSchema,\n DEFAULT_MENU_DEPTH,\n MAX_MENU_DEPTH,\n MAX_MENU_TREE_NODES,\n measureMenuTree,\n} from '../menu-item-schema.js'\n\n/** Options for {@link menuTreeValidation}. */\nexport interface MenuTreeValidationOptions {\n /**\n * Resolve the locale codes accepted as `label` / `enabled` keys.\n *\n * Defaults to the `content()` plugin's configured locales, read LAZILY (per\n * write, never at module scope) so the behavior can be constructed before\n * the app boots. Injectable so the behavior is unit-testable without a\n * booted content plugin.\n */\n resolveAllowedLocales?: () => readonly string[]\n}\n\n/** One-issue `ZodError` at `tree`(`.path`) — the 400-mapped failure shape. */\nfunction treeError(message: string, path: Array<string | number> = []): z.ZodError {\n return new z.ZodError([{ code: z.ZodIssueCode.custom, path: ['tree', ...path], message }])\n}\n\n/** Prefix every issue path with `tree` so the client can locate the node. */\nfunction prefixIssues(error: z.ZodError): z.ZodError {\n return new z.ZodError(error.issues.map((issue) => ({ ...issue, path: ['tree', ...issue.path] })))\n}\n\n/** A `maxDepth` value from an (unvalidated, pre-field-validation) payload. */\nfunction readMaxDepth(value: unknown): number | undefined {\n if (typeof value !== 'number' || !Number.isInteger(value)) return undefined\n if (value < 1 || value > MAX_MENU_DEPTH) return undefined\n return value\n}\n\n/**\n * The effective `maxDepth` for this write.\n *\n * Hooks run BEFORE field validation, so a payload's `maxDepth` may be absent\n * or junk; junk falls through to the persisted/default value and is rejected a\n * moment later by the field's own `min`/`max`. On a partial update that\n * touches only `tree`, the persisted value comes from `loadCurrent()` (the\n * eagerly-loaded pre-update snapshot).\n */\nasync function resolveMaxDepth(\n data: Record<string, unknown>,\n ctx: BehaviorContext | undefined,\n isUpdate: boolean,\n): Promise<number> {\n const fromPayload = readMaxDepth(data.maxDepth)\n if (fromPayload !== undefined) return fromPayload\n if (!isUpdate) return DEFAULT_MENU_DEPTH\n\n if (!ctx?.loadCurrent) {\n // Fail loud rather than silently skipping the cross-field check — an\n // unvalidated depth is a correctness hole. AdminClient always wires\n // `loadCurrent` on the update codepath, so this only trips on a\n // mis-wired / direct hook invocation. (Same posture as `pathParent`.)\n throw new Error(\n 'menu: BehaviorContext.loadCurrent is unavailable — cannot validate the tree ' +\n \"against the menu's maxDepth. Writes must go through AdminClient.update.\",\n )\n }\n const current = await ctx.loadCurrent()\n // A vanished/unreadable row can't supply its own bound, so fall back to the\n // DEFAULT (3) — the same value a create would get — not the absolute cap\n // (6). Falling back to the ceiling would make an edge case the single most\n // PERMISSIVE path in the behavior; the safe fallback is the floor. The\n // absolute cap still applies separately, and the update itself fails\n // downstream when the row is genuinely gone.\n return readMaxDepth(current?.maxDepth) ?? DEFAULT_MENU_DEPTH\n}\n\n/**\n * The REVERSE cross-field check: an update that narrows `maxDepth` without\n * touching `tree`.\n *\n * `{\"maxDepth\": 1}` on a menu whose stored tree is 5 levels deep used to skip\n * validation entirely (the payload has no `tree`, so there was nothing to\n * measure) — the write succeeded and the row then PERMANENTLY violated\n * \"tree depth <= maxDepth\" until someone happened to edit the tree again.\n * Both directions of the invariant have to be enforced, so when `maxDepth`\n * moves we measure the PERSISTED tree against the new bound.\n */\nasync function validateStoredTreeAgainstNewMaxDepth(\n data: Record<string, unknown>,\n ctx: BehaviorContext | undefined,\n): Promise<void> {\n // Absent (or junk — the field's own min/max rejects that a moment later)\n // means this write doesn't move the bound, so there's nothing to re-check.\n const maxDepth = readMaxDepth(data.maxDepth)\n if (maxDepth === undefined) return\n\n if (!ctx?.loadCurrent) {\n // Same fail-loud posture as `resolveMaxDepth` — silently skipping the\n // check is precisely the hole this function closes.\n throw new Error(\n 'menu: BehaviorContext.loadCurrent is unavailable — cannot validate the stored tree ' +\n 'against the new maxDepth. Writes must go through AdminClient.update.',\n )\n }\n\n const current = await ctx.loadCurrent()\n // Measured iteratively and on a value already bounded at write time, so this\n // is cheap even for the largest legal tree.\n const { depth } = measureMenuTree(current?.tree)\n if (depth > maxDepth) {\n throw treeError(\n `the stored menu tree is nested ${depth} levels deep, deeper than this menu's ` +\n `new maxDepth (${maxDepth}) — flatten the tree first`,\n )\n }\n}\n\n/**\n * Validate + normalize `data.tree` in place. Returns the payload with `tree`\n * replaced by the PARSED value, so unknown properties a caller invented are\n * stripped before they reach the column.\n */\nasync function validateTree(\n data: Record<string, unknown>,\n ctx: BehaviorContext | undefined,\n isUpdate: boolean,\n resolveAllowedLocales: () => readonly string[],\n): Promise<Record<string, unknown>> {\n const tree = data.tree\n if (tree === undefined) {\n // No tree in the payload — but a `maxDepth`-only update still has to be\n // checked against the tree already in the column.\n if (isUpdate) await validateStoredTreeAgainstNewMaxDepth(data, ctx)\n return data\n }\n if (tree === null || !Array.isArray(tree)) {\n throw treeError('menu tree must be an array of menu items')\n }\n\n // Structural bounds FIRST — this walk is iterative, so it holds even for a\n // payload crafted to blow the recursive parse's stack.\n const { depth, nodeCount } = measureMenuTree(tree)\n if (nodeCount > MAX_MENU_TREE_NODES) {\n throw treeError(`menu tree has too many items (max ${MAX_MENU_TREE_NODES})`)\n }\n if (depth > MAX_MENU_DEPTH) {\n throw treeError(`menu tree is nested too deeply (absolute max ${MAX_MENU_DEPTH} levels)`)\n }\n\n const parsed = buildMenuTreeSchema({ allowedLocales: resolveAllowedLocales() }).safeParse(tree)\n if (!parsed.success) throw prefixIssues(parsed.error)\n\n const maxDepth = await resolveMaxDepth(data, ctx, isUpdate)\n if (depth > maxDepth) {\n throw treeError(`menu tree is nested deeper than this menu's maxDepth (${maxDepth})`)\n }\n\n return { ...data, tree: parsed.data }\n}\n\n/**\n * Recursive Zod validation of `menu.tree` on every create/update.\n *\n * Contributes NO fields, so the field-map type param is the EMPTY map\n * `Record<never, never>` — NOT `Record<string, never>`, which would merge into\n * the entity's field map and collapse every field to `never` (a break that\n * only surfaces through the built `.d.mts`).\n */\nexport function menuTreeValidation(\n options?: MenuTreeValidationOptions,\n): Behavior<Record<never, never>, 'menuTreeValidation'> {\n const resolveAllowedLocales =\n options?.resolveAllowedLocales ?? (() => getContentConfig().locales.map((l) => l.code))\n\n return {\n name: 'menuTreeValidation',\n hooks: {\n beforeCreate: async (data, ctx) => validateTree(data, ctx, false, resolveAllowedLocales),\n beforeUpdate: async (_id, data, ctx) => validateTree(data, ctx, true, resolveAllowedLocales),\n },\n }\n}\n","/**\n * The `menu` entity (plan/routing phase 08 §2, D008/D009).\n *\n * A menu is a normal entity whose item tree is ONE ordered nested JSONB column\n * — the family WordPress migrated to and Statamic uses, and the same storage\n * philosophy as our blocks layouts. Whole-tree edits save atomically in one\n * PATCH, and `publishable()` gives draft-then-publish menu editing for free.\n *\n * Locale variance lives INSIDE the tree (per-item `label` / `enabled` maps),\n * so there is deliberately no `translatable` field and no `menu_translations`\n * table — one structure serves every locale (D009).\n */\n\nimport { auditable, defineEntity, field, publishable } from '@murumets-ee/entity'\nimport { menuEntityTypesValidation } from './behaviors/menu-entity-types-validation.js'\nimport { menuTreeValidation } from './behaviors/menu-tree-validation.js'\nimport { DEFAULT_MENU_DEPTH, MAX_MENU_DEPTH } from './menu-item-schema.js'\n\nexport const Menu = defineEntity({\n name: 'menu',\n // Menus belong to one site (multisite D002); the `handle` unique is per site,\n // so every site can have its own `main` and `footer`.\n site: 'bound',\n admin: {\n label: 'Menus',\n icon: 'menu',\n },\n fields: {\n // Fetch key — 'main', 'footer' (phase 07 §2.8). `from: 'title'` is the\n // admin form's client-side auto-fill source (SlugField watches it); the\n // value itself is author-chosen and stable, so `sluggable()` (which would\n // REGENERATE the handle when the title changes) is deliberately not used.\n // `field.slug()` already sets `unique` + `indexed` — no need to repeat them.\n handle: field.slug({ from: 'title' }),\n title: field.text({ required: true }),\n // 1..6 — the renderer clamps too (phase 07 §2.7).\n maxDepth: field.number({\n default: DEFAULT_MENU_DEPTH,\n min: 1,\n max: MAX_MENU_DEPTH,\n integer: true,\n }),\n // MenuItem[] — shape validated by `menuTreeValidation()`; `refScan` makes\n // every LinkValue in the tree register in `entity_refs` /\n // `external_link_usage` on save, so a menu item pointing at a page shows\n // up in delete-protection warnings and the broken-links dashboard\n // (phase 08 §4) with no menus-side ref code at all.\n tree: field.json({ refScan: true, default: [] }),\n // Which content types may be linked FROM this menu (phase 07 §2.8) — the\n // menu editor's picker and the Meta panel's Menus section both read it.\n // `null` = accepts every type, which is what every row written before this\n // column existed reads back as, so the whitelist is backward-compatible by\n // construction. Shape validated by `menuEntityTypesValidation()`; semantics\n // and the read-side predicate live in `./menu-entity-types.ts`.\n //\n // No `refScan`: the value holds entity NAMES, not `LinkValue`s — there is\n // no row to reference, so a scan would find nothing.\n entityTypes: field.json({ default: null }),\n },\n behaviors: [auditable(), publishable(), menuTreeValidation(), menuEntityTypesValidation()],\n scope: 'global',\n // Advisory metadata (the firewall is deny-by-default + operator grants).\n // Menus are site-wide navigation: a bad edit is visible on EVERY page, and\n // the tree is a structural artifact rather than content — so writes are\n // admin-only, while `view` is public because the frontend reads menus\n // unauthenticated through the content API.\n access: {\n view: 'public',\n create: 'group.admin',\n update: 'group.admin',\n delete: 'group.admin',\n },\n})\n"],"mappings":"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"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"et-BL3ZuuFV.mjs","names":[],"sources":["../src/messages/et.json"],"sourcesContent":[""],"mappings":""}
@@ -1 +0,0 @@
1
- {"version":3,"file":"i18n.d.mts","names":[],"sources":["../src/i18n.ts"],"mappings":";;AAWA;;;;;;;;AAAsE;;iBAAhD,gBAAA,CAAiB,MAAA,WAAiB,OAAO,CAAC,MAAA"}
package/dist/i18n.mjs.map DELETED
@@ -1 +0,0 @@
1
- {"version":3,"file":"i18n.mjs","names":[],"sources":["../src/i18n.ts"],"sourcesContent":["/**\n * Load menus messages for a given locale.\n *\n * @example\n * ```typescript\n * import { getMenusMessages } from '@murumets-ee/menus/i18n'\n * import { mergeMessages } from '@murumets-ee/i18n'\n *\n * const messages = mergeMessages(appMessages, await getMenusMessages(locale))\n * ```\n */\nexport async function getMenusMessages(locale: string): Promise<Record<string, unknown>> {\n switch (locale) {\n case 'et':\n return (await import('./messages/et.json')).default\n case 'ru':\n return (await import('./messages/ru.json')).default\n default:\n return (await import('./messages/en.json')).default\n }\n}\n"],"mappings":"AAWA,eAAsB,EAAiB,EAAkD,CACvF,OAAQ,EAAR,CACE,IAAK,KACH,OAAQ,MAAM,OAAO,qBAAA,CAAuB,QAC9C,IAAK,KACH,OAAQ,MAAM,OAAO,qBAAA,CAAuB,QAC9C,QACE,OAAQ,MAAM,OAAO,qBAAA,CAAuB,OAChD,CACF"}
@@ -1 +0,0 @@
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"}
@@ -1 +0,0 @@
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":"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"}
@@ -1 +0,0 @@
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"}
@@ -1 +0,0 @@
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"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"resolve.d.mts","names":[],"sources":["../src/get-menu.ts","../src/menu-resolve-context.ts"],"mappings":";;;;;;AAqEA;;;;AAAkC;AAelC;;;;;;;;;;;;;;AAiB4B;AAc5B;;;;;;;;AAEyB;AAIzB;;;;AAOa;AAQb;;;;AAQM;AAQN;;;;;;;;AAC+B;AAY/B;;;;cAhGa,qBAAA;;UAeI,gBAAA;EAyGF;EAvGb,EAAA;EACA,IAAA;EAgHK;EA9GL,KAAA;EAuH0B;;;;;EAjH1B,IAAA;EACA,MAAA;EAoFE;EAlFF,QAAA;EAoFE;EAlFF,aAAA;EACA,QAAA,EAAU,gBAAgB;AAAA;;;;;;;;;;;;UAcX,YAAA;EACf,MAAA;EACA,KAAA,EAAO,gBAAgB;AAAA;AA2FG;AAAA,UAvFX,cAAA;EA+cc;EA7c7B,MAAA;EA6coF;;;;EAxcpF,WAAW;AAAA;AAwcyE;AAAA,UAhcrE,OAAA;EAqdY;;;;;EA/c3B,QAAA;EAmdQ;EAjdR,IAAI;AAAA;;;;;;UAQW,iBAAA;EACf,IAAA,CAAK,GAAA,UAAa,GAAA;AAAA;;;;ACrFpB;;;;;;UDiGiB,eAAA;ECjGqD;AAAA;;;;;;EDyGpE,UAAA,GAAa,MAAA,aAAmB,OAAA,CAAQ,OAAA;;;;;;EAMxC,qBAAA,GACE,UAAA,UACA,GAAA,qBACA,MAAA,aACG,OAAA,CAAQ,WAAA;;EAEb,eAAA,GACE,UAAA,UACA,GAAA,qBACA,MAAA,aACG,OAAA,CAAQ,WAAA;;;;;;EAMb,aAAA,GACE,UAAA,UACA,GAAA,qBACA,MAAA,aACG,OAAA,CAAQ,WAAA;;;;;;;EAOb,aAAA;;EAEA,MAAA,GAAS,iBAAA;AAAA;;;;;;;;;;;;;iBAwXK,eAAA,CAAgB,IAAA,EAAM,YAAA,EAAc,WAAA,WAAsB,YAAY;;;;;;;;;;;;;;iBAqBhE,OAAA,CACpB,MAAA,UACA,IAAA,EAAM,cAAA,EACN,IAAA,EAAM,eAAA,GACL,OAAA,CAAQ,YAAA;;;;;;;AA5ec;AAIzB;;iBCrDgB,oBAAA,CAAqB,GAAA,EAAK,UAAA,GAAa,eAAe"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"resolve.mjs","names":[],"sources":["../src/get-menu.ts","../src/internal/columns.ts","../src/menu-resolve-context.ts"],"sourcesContent":["/**\n * `getMenu()` — turn a stored menu tree into a render-ready structure for one\n * locale (plan/routing phase 11 §2), plus the pure `markActiveTrail()` helper\n * (phase 11 §3).\n *\n * **Leaf module.** Every cross-package capability (the menu row read, the\n * `toolkit_paths` forward index, the publish gate, live entity titles) arrives\n * through the injected {@link MenuResolveDeps}; nothing here imports\n * `@murumets-ee/core` or `@murumets-ee/content`. Concrete deps are built from a\n * live app in `./menu-resolve-context.ts` — the same leaf/seam split\n * `@murumets-ee/content`'s `paths/enumerate.ts` ↔ `paths/resolve-context.ts`\n * uses, and the reason the deps' function types are declared structurally here\n * rather than imported (assignability is still checked, at the seam).\n *\n * **Security.** The resolved output feeds PUBLIC page navigation, so it must\n * never surface an unpublished or deleted document:\n * - the menu itself is read through a publish-filtered client (an unpublished\n * menu resolves to `null` — nothing renders),\n * - every entity-linked item must have BOTH a canonical path in\n * `toolkit_paths` for the requested locale AND survive the batched publish\n * gate; missing either → the item (and its subtree) is dropped,\n * - `url` / `email` targets are re-gated through the same `sanitizeHref`\n * protocol allowlist the write path uses, so a value that reached the column\n * by some route other than the entity API (a direct DB edit, a restored\n * dump) still cannot become a `javascript:` href,\n * - every filtering decision fails CLOSED — anything unresolvable is dropped,\n * never rendered with a placeholder.\n *\n * **No unbounded fan-out** (CLAUDE.md). Backend work is bounded by the number\n * of distinct ENTITY TYPES in the tree, never by the number of items: the walk\n * collects ids grouped by type, then issues at most THREE batched calls per\n * group (forward index → publish gate → titles). The group count is itself hard-\n * capped ({@link MAX_MENU_ENTITY_TYPES}) and the walk carries a node budget\n * ({@link MAX_MENU_TREE_NODES}), so one render costs a fixed, small number of\n * round-trips no matter what the stored tree looks like.\n *\n * **Read-time validation is per-node, not whole-tree.** The write path already\n * runs `buildMenuTreeSchema()` (`menuTreeValidation()`), and this module\n * deliberately does NOT re-run it: that schema whitelists locale KEYS against\n * the app's configured locales, so re-parsing at read time would fail the WHOLE\n * menu — site navigation gone — the moment an operator removes a locale from\n * config after a menu was saved. The locale whitelist is a write-time authoring\n * check with no read-time security value. What DOES matter at read time (node\n * shape, the `LinkValue` protocol gate) is re-checked per node here, and a\n * malformed node is dropped on its own without taking the menu down with it.\n */\n\nimport { resolveLinkHref } from '@murumets-ee/blocks/links'\nimport {\n DEFAULT_MENU_DEPTH,\n LinkValueSchema,\n MAX_MENU_DEPTH,\n MAX_MENU_TREE_NODES,\n type MenuLinkValue,\n} from './menu-item-schema.js'\n\n// ---------------------------------------------------------------------------\n// Bounds\n// ---------------------------------------------------------------------------\n\n/**\n * Hard cap on the number of DISTINCT entity types one menu may fan out over.\n *\n * The batched pipeline costs three DB round-trips per type group, so this — not\n * the item count — is what bounds a render's backend work. A navigation menu\n * pointing at more than a handful of routable types is malformed by\n * construction; types past the cap are dropped (their items disappear) and\n * logged, never silently truncated.\n */\nexport const MAX_MENU_ENTITY_TYPES = 16\n\n/**\n * Longest `handle` accepted — rejected before any DB work. A defensive cap, not\n * a mirror of the entity's `handle` column (`varchar(255)`): a handle is a\n * hand-authored navigation key, so anything approaching this length is\n * malformed by construction.\n */\nconst MAX_HANDLE_LENGTH = 128\n\n// ---------------------------------------------------------------------------\n// Public shape (phase 11 §2)\n// ---------------------------------------------------------------------------\n\n/** One render-ready menu item (phase 11 §2 — exact shape). */\nexport interface ResolvedMenuItem {\n /** The stored item's stable id (DND identity; also a stable React key). */\n id: string\n kind: 'link' | 'header'\n /** Resolved label: the per-locale override, else the live entity title. */\n label: string\n /**\n * Locale-relative canonical path from `toolkit_paths` (+ `#anchor` when set),\n * external `url` / `email` targets verbatim (protocol-gated), `null` for\n * `header` items.\n */\n href: string | null\n newTab: boolean\n /** Exact `currentPath` match on the path part (any `#anchor` ignored). */\n isActive: boolean\n /** An ancestor BY TREE of the active item — never a path-prefix guess. */\n inActiveTrail: boolean\n children: ResolvedMenuItem[]\n}\n\n/**\n * A resolved menu.\n *\n * Phase 11 §2 names `ResolvedMenuItem` and uses `ResolvedMenu` as `getMenu`'s\n * return type without spelling its fields out; this is the minimal wrapper that\n * satisfies it — the requested `handle` (so a caller holding several resolved\n * menus can tell them apart, and so `markActiveTrail` can round-trip a menu\n * without losing its identity) plus the item forest. Deliberately carries no\n * `maxDepth` / `title`: the depth clamp is already APPLIED in `items`, and a\n * menu's admin-facing title is not a rendering concern.\n */\nexport interface ResolvedMenu {\n handle: string\n items: ResolvedMenuItem[]\n}\n\n/** Options for {@link getMenu} (phase 11 §2). */\nexport interface GetMenuOptions {\n /** The locale to resolve labels, visibility and canonical paths for. */\n locale: string\n /**\n * The page being rendered, for active-trail marking. SSG frontends usually\n * OMIT this (one build-time fetch) and call {@link markActiveTrail} per page.\n */\n currentPath?: string | undefined\n}\n\n// ---------------------------------------------------------------------------\n// Injected dependencies\n// ---------------------------------------------------------------------------\n\n/** The projected `menu` row {@link getMenu} reads. */\nexport interface MenuRow {\n /**\n * The menu's own depth cap. Absent / out of range falls back to the entity\n * default — the clamp is renderer-enforced independently of the editor\n * (phase 07 §2.7), so a bad stored value can never widen it.\n */\n maxDepth?: number | null | undefined\n /** Raw `menu.tree` JSONB. Untrusted shape — validated per node during the walk. */\n tree: unknown\n}\n\n/**\n * Structural minimum of a pino/toolkit logger. Declared here (rather than\n * imported) so this module stays import-free; `app.logger.child(...)` satisfies\n * it structurally.\n */\nexport interface MenuResolveLogger {\n warn(obj: object, msg: string): void\n}\n\n/**\n * Everything {@link getMenu} needs from the outside world.\n *\n * Function-shaped (not reader-shaped) on purpose: the concrete implementations\n * live in `./menu-resolve-context.ts`, where assignability against\n * `@murumets-ee/content`'s real `getCanonicalPaths` / `PublishFilter` is\n * type-checked at the seam — so this module needs no `@murumets-ee/content`\n * import and drift is still a build error.\n */\nexport interface MenuResolveDeps {\n /**\n * Fetch the PUBLISHED menu row by handle, or `null`.\n *\n * The publish filter is the implementation's responsibility (the app-side\n * factory reads through a publish-filtering `QueryClient`) — a draft menu\n * must never resolve, per phase 11 §2.\n */\n getMenuRow: (handle: string) => Promise<MenuRow | null>\n /**\n * Batched forward index read: which of `ids` have a canonical path in\n * `locale`? Does NOT publish-filter — {@link MenuResolveDeps.filterPublished}\n * is the required second step.\n */\n resolveCanonicalPaths: (\n entityType: string,\n ids: readonly string[],\n locale: string,\n ) => Promise<ReadonlyMap<string, string>>\n /** Batched publish gate: which of `ids` are published in `locale`? */\n filterPublished: (\n entityType: string,\n ids: readonly string[],\n locale: string,\n ) => Promise<ReadonlySet<string>>\n /**\n * Batched live-title read: `id → display title` for `locale`. Only called for\n * the ids that actually need one (no per-locale label override), and never\n * for `header` items (they have no linked entity to borrow a title from).\n */\n resolveTitles: (\n entityType: string,\n ids: readonly string[],\n locale: string,\n ) => Promise<ReadonlyMap<string, string>>\n /**\n * Fallback locale for label overrides — the app's configured content\n * `defaultLocale`. A label present only in the default locale still renders\n * (the same locale → default fallback `mergeTranslations` gives entity data),\n * instead of silently dropping a partially-translated item.\n */\n defaultLocale: string\n /** Optional — used to report DROPPED work (never silent truncation). */\n logger?: MenuResolveLogger | undefined\n}\n\n// ---------------------------------------------------------------------------\n// Locale-map reads (defensive: these run against raw JSONB)\n// ---------------------------------------------------------------------------\n\n/**\n * Read one locale's string out of a raw `label` map, falling back to\n * `defaultLocale`. Own-property reads only, so a `__proto__` / `constructor`\n * key in stored JSON can't produce a value. Empty strings count as absent — an\n * item labelled `''` renders as an invisible link.\n */\nfunction readLocaleString(map: unknown, locale: string, defaultLocale: string): string | undefined {\n if (map === null || typeof map !== 'object' || Array.isArray(map)) return undefined\n const record: Record<string, unknown> = map as Record<string, unknown>\n for (const key of locale === defaultLocale ? [locale] : [locale, defaultLocale]) {\n if (!Object.hasOwn(record, key)) continue\n const value = record[key]\n if (typeof value === 'string' && value.length > 0) return value\n }\n return undefined\n}\n\n/**\n * Read one locale's visibility flag. NO default-locale fallback on purpose:\n * `enabled` is per-locale visibility in both directions (phase 08 §3), so\n * hiding an item in `en` must not hide it in `et`.\n */\nfunction readLocaleBoolean(map: unknown, locale: string): boolean | undefined {\n if (map === null || typeof map !== 'object' || Array.isArray(map)) return undefined\n const record: Record<string, unknown> = map as Record<string, unknown>\n if (!Object.hasOwn(record, locale)) return undefined\n const value = record[locale]\n return typeof value === 'boolean' ? value : undefined\n}\n\n// ---------------------------------------------------------------------------\n// Pass 1 — structural narrowing + id collection (one walk)\n// ---------------------------------------------------------------------------\n\n/**\n * A node that survived structural validation. Data-driven filtering (publish\n * state, canonical path, label availability, empty-header pruning) happens in\n * pass 2, once the batched lookups have run.\n */\ninterface PreparedNode {\n id: string\n kind: 'link' | 'header'\n link: MenuLinkValue | undefined\n /** Raw label map — read per locale in pass 2. */\n label: unknown\n newTab: boolean\n children: PreparedNode[]\n}\n\ninterface PreparedTree {\n nodes: PreparedNode[]\n /** entityType → every linked id (needs a path + publish check). */\n idsByType: Map<string, Set<string>>\n /** entityType → the subset with no usable label override (needs a live title). */\n titleIdsByType: Map<string, Set<string>>\n /** Nodes discarded because a hard bound was hit (reported, never silent). */\n droppedForBudget: number\n}\n\nfunction isPlainObject(value: unknown): value is Record<string, unknown> {\n return value !== null && typeof value === 'object' && !Array.isArray(value)\n}\n\n/**\n * Walk the raw tree ONCE: validate each node's shape, apply the two filters\n * that need no I/O (per-locale `enabled`, the `maxDepth` clamp), and collect\n * the entity ids the batched lookups will need — grouped by entity type, so\n * pass 2 issues one call per GROUP rather than one per item.\n *\n * Bounded by {@link MAX_MENU_TREE_NODES} visited nodes and {@link MAX_MENU_DEPTH}\n * levels regardless of what is stored, so a hand-edited row can't turn a page\n * render into unbounded work.\n */\nfunction prepareTree(\n rawTree: unknown,\n locale: string,\n defaultLocale: string,\n maxDepth: number,\n): PreparedTree {\n const idsByType = new Map<string, Set<string>>()\n const titleIdsByType = new Map<string, Set<string>>()\n let budget = MAX_MENU_TREE_NODES\n let droppedForBudget = 0\n\n function collect(target: Map<string, Set<string>>, entityType: string, id: string): void {\n const bucket = target.get(entityType)\n if (bucket) bucket.add(id)\n else target.set(entityType, new Set([id]))\n }\n\n function walk(value: unknown, depth: number): PreparedNode[] {\n if (depth > maxDepth || !Array.isArray(value)) return []\n // `Array.isArray` narrows `unknown` to `any[]`; re-binding keeps elements `unknown`.\n const rawNodes: readonly unknown[] = value\n const out: PreparedNode[] = []\n\n for (const raw of rawNodes) {\n if (budget <= 0) {\n droppedForBudget++\n continue\n }\n if (!isPlainObject(raw)) continue\n\n const id = raw.id\n const kind = raw.kind\n if (typeof id !== 'string' || id.length === 0) continue\n if (kind !== 'link' && kind !== 'header') continue\n // Per-locale visibility drops the node AND its subtree — checked before\n // recursing so a hidden branch costs nothing.\n if (readLocaleBoolean(raw.enabled, locale) === false) continue\n\n budget--\n\n let link: MenuLinkValue | undefined\n if (kind === 'link') {\n // Re-validate the stored LinkValue — this is the `sanitizeHref`\n // protocol gate, the one write-time check that genuinely matters again\n // at read time (see the module header). A malformed link drops just\n // this node.\n const parsed = LinkValueSchema.safeParse(raw.link)\n if (!parsed.success) continue\n link = parsed.data\n } else if (raw.link !== undefined) {\n // A header carrying a link target is a shape violation — drop it\n // rather than render a half-defined node.\n continue\n }\n\n const children = walk(raw.children, depth + 1)\n\n if (link?.kind === 'entity') {\n collect(idsByType, link.entity, link.id)\n // Only items WITHOUT a usable override need a live title, so the title\n // read stays as small as possible — and header items, which have no\n // linked entity, can never reach this branch at all.\n if (readLocaleString(raw.label, locale, defaultLocale) === undefined) {\n collect(titleIdsByType, link.entity, link.id)\n }\n }\n\n out.push({ id, kind, link, label: raw.label, newTab: raw.newTab === true, children })\n }\n\n return out\n }\n\n const nodes = walk(rawTree, 1)\n return { nodes, idsByType, titleIdsByType, droppedForBudget }\n}\n\n// ---------------------------------------------------------------------------\n// Pass 2 — batched lookups\n// ---------------------------------------------------------------------------\n\ninterface ResolvedLookups {\n paths: Map<string, ReadonlyMap<string, string>>\n published: Map<string, ReadonlySet<string>>\n titles: Map<string, ReadonlyMap<string, string>>\n}\n\n/**\n * Run the batched lookups: for each entity-type GROUP, one forward-index read,\n * then one publish gate over the ids that had a path, then (only when some item\n * needs it) one title read. Exactly one call each, never per item, and\n * `MAX_MENU_ENTITY_TYPES` bounds the group count — so the number of round-trips\n * per render is fixed.\n *\n * Groups run CONCURRENTLY (bounded by the already-enforced `MAX_MENU_ENTITY_TYPES`\n * cap, never an unbounded `Promise.all`) — they're independent, and this sits on\n * a public SSR request path where the 3-step-per-group sequencing was otherwise\n * fully serial (up to 48 round-trips back to back for one render). Only the\n * THREE STEPS WITHIN one group stay ordered (path lookup → publish gate → title\n * read), since each depends on the previous step's output.\n *\n * How each dep handles an over-large id list is the DEP's business, and they\n * differ: `getCanonicalPaths` chunks internally, and so does the title reader in\n * `./menu-resolve-context.ts` (`TITLE_BATCH_SIZE`). `filterPublished` (content's\n * `buildEnumerateDeps`) does NOT — it issues a single `inArray` over whatever it\n * is handed. That is safe HERE because the id list is bounded tree-wide by\n * {@link MAX_MENU_TREE_NODES} (500) before it ever reaches this function, not\n * because the dep protects itself. A caller that lifts the node budget must\n * revisit that.\n */\nasync function runLookups(\n prepared: PreparedTree,\n locale: string,\n deps: MenuResolveDeps,\n handle: string,\n): Promise<ResolvedLookups> {\n const paths = new Map<string, ReadonlyMap<string, string>>()\n const published = new Map<string, ReadonlySet<string>>()\n const titles = new Map<string, ReadonlyMap<string, string>>()\n\n const groups = [...prepared.idsByType.entries()]\n if (groups.length > MAX_MENU_ENTITY_TYPES) {\n deps.logger?.warn(\n { handle, locale, entityTypes: groups.length, cap: MAX_MENU_ENTITY_TYPES },\n 'menu resolution dropped entity-type groups over the fan-out cap',\n )\n }\n\n await Promise.all(\n groups.slice(0, MAX_MENU_ENTITY_TYPES).map(async ([entityType, idSet]) => {\n const ids = [...idSet]\n // (1) Forward index — which ids have a canonical path in this locale?\n const pathMap = await deps.resolveCanonicalPaths(entityType, ids, locale)\n paths.set(entityType, pathMap)\n if (pathMap.size === 0) return\n\n // (2) Publish gate — `resolveCanonicalPaths` does NOT filter by publish\n // state (`toolkit_paths` has no status column), so this is the\n // required no-unpublished-leak step. Batched, over only the ids that\n // had a path.\n const candidates = [...pathMap.keys()]\n const publishedIds = await deps.filterPublished(entityType, candidates, locale)\n published.set(entityType, publishedIds)\n\n // (3) Live titles — only for items with no usable override, and only for\n // ids that will actually survive pass 3 (had a path AND published) —\n // fetching a title for an id that's about to be dropped is wasted work.\n const needTitles = prepared.titleIdsByType.get(entityType)\n if (needTitles === undefined) return\n const titleIds = candidates.filter((id) => needTitles.has(id) && publishedIds.has(id))\n if (titleIds.length === 0) return\n titles.set(entityType, await deps.resolveTitles(entityType, titleIds, locale))\n }),\n )\n\n return { paths, published, titles }\n}\n\n// ---------------------------------------------------------------------------\n// Pass 3 — resolve + filter, bottom-up\n// ---------------------------------------------------------------------------\n\n/**\n * Resolve prepared nodes into {@link ResolvedMenuItem}s, dropping everything\n * that can't render. Children resolve BEFORE their parent so empty-header\n * pruning sees the already-filtered child list (phase 11 §2: \"header item whose\n * surviving children are all dropped → dropped\").\n */\nfunction resolveNodes(\n nodes: readonly PreparedNode[],\n locale: string,\n lookups: ResolvedLookups,\n deps: MenuResolveDeps,\n): ResolvedMenuItem[] {\n const out: ResolvedMenuItem[] = []\n\n for (const node of nodes) {\n const children = resolveNodes(node.children, locale, lookups, deps)\n const override = readLocaleString(node.label, locale, deps.defaultLocale)\n\n if (node.kind === 'header') {\n // No empty dropdowns (phase 11 §2), and no label → nothing to click on.\n if (children.length === 0) continue\n if (override === undefined) continue\n out.push({\n id: node.id,\n kind: 'header',\n label: override,\n href: null,\n newTab: false,\n isActive: false,\n inActiveTrail: false,\n children,\n })\n continue\n }\n\n const link = node.link\n if (link === undefined) continue\n\n let href: string | null\n let label = override\n\n if (link.kind === 'entity') {\n const path = lookups.paths.get(link.entity)?.get(link.id)\n // No canonical path at all → the target was deleted, never published in\n // this locale, or its type isn't routable. Unpublished → same outcome.\n if (path === undefined) continue\n if (lookups.published.get(link.entity)?.has(link.id) !== true) continue\n href = link.anchor === undefined ? path : `${path}#${link.anchor}`\n label ??= lookups.titles.get(link.entity)?.get(link.id)\n } else {\n // `url` / `email` — the only remaining kinds, so there is no dead\n // fallback branch to carry: `MenuLinkValue` has exactly three arms. A\n // `media` link is rejected at the WRITE boundary by `LinkValueSchema`,\n // which is also what drops one that reached the column by some other\n // route (a direct edit, a restored dump) — `prepareTree`'s `safeParse`\n // never lets it get this far.\n //\n // Verbatim external targets, protocol-gated once more by blocks'\n // `resolveLinkHref` (`mailto:` composition included) — the single source\n // of truth for what may become an `<a href>`. It returns `null` for\n // anything it cannot turn into an href, and a `null` href is dropped just\n // below — so widening `MenuLinkValue` later fails CLOSED here by default\n // rather than emitting a half-resolved item.\n href = resolveLinkHref(link)\n }\n\n // An item with no href or no resolvable label renders as either a dead\n // anchor or an invisible one — drop it, matching every other filter here.\n if (href === null || href.length === 0) continue\n if (label === undefined || label.length === 0) continue\n\n out.push({\n id: node.id,\n kind: 'link',\n label,\n href,\n newTab: node.newTab,\n isActive: false,\n inActiveTrail: false,\n children,\n })\n }\n\n return out\n}\n\n// ---------------------------------------------------------------------------\n// Active trail (phase 11 §3)\n// ---------------------------------------------------------------------------\n\n/**\n * The comparable path part of an href or a current path: the `#fragment` is\n * dropped (an anchor item on the current page is still \"the current page\", per\n * phase 11 §2) and a trailing slash is normalized away, so `/about` and\n * `/about/` match. The root `/` keeps its slash.\n */\nfunction comparablePath(value: string): string {\n const hash = value.indexOf('#')\n const path = hash === -1 ? value : value.slice(0, hash)\n return path.length > 1 && path.endsWith('/') ? path.slice(0, -1) : path\n}\n\ninterface MarkResult {\n items: ResolvedMenuItem[]\n containsActive: boolean\n}\n\nfunction markNodes(nodes: readonly ResolvedMenuItem[], target: string): MarkResult {\n let containsActive = false\n const items = nodes.map((node): ResolvedMenuItem => {\n const marked = markNodes(node.children, target)\n const isActive = node.href !== null && comparablePath(node.href) === target\n // Ancestry is read off the TREE, never guessed from a path prefix — prefix\n // guessing breaks across translated bases and across sibling paths that\n // merely share a prefix (`/about` vs `/about-us`).\n const inActiveTrail = marked.containsActive\n if (isActive || inActiveTrail) containsActive = true\n return { ...node, isActive, inActiveTrail, children: marked.items }\n })\n return { items, containsActive }\n}\n\n/**\n * Mark `isActive` / `inActiveTrail` on a resolved menu for one page.\n *\n * PURE — no I/O, and the input menu is not mutated (a new tree is returned), so\n * an SSG build can fetch a menu ONCE and mark it per page without the pages\n * contaminating each other. `getMenu` calls this internally when\n * `opts.currentPath` is given, so there is exactly one implementation of the\n * ancestry walk.\n *\n * `isActive` is an EXACT match on the path part; `inActiveTrail` marks strict\n * tree ancestors of an active item (an active item is not its own ancestor).\n */\nexport function markActiveTrail(menu: ResolvedMenu, currentPath: string): ResolvedMenu {\n return { ...menu, items: markNodes(menu.items, comparablePath(currentPath)).items }\n}\n\n// ---------------------------------------------------------------------------\n// Entry point\n// ---------------------------------------------------------------------------\n\n/**\n * Resolve a stored menu into a render-ready tree for one locale (phase 11 §2).\n *\n * Returns `null` when the menu does not exist or is not published — a draft\n * menu renders nowhere public. A published menu whose every item was filtered\n * out returns an EMPTY item list, not `null`: \"the menu exists but has nothing\n * to show here\" is a different fact from \"there is no such menu\".\n *\n * Filtering order (phase 11 §2, not reorderable): menu unpublished → `null`;\n * `enabled[locale] === false` → dropped with its subtree; entity target deleted\n * or unpublished-in-locale → dropped; header with no surviving children →\n * dropped; depth clamped to the menu's own `maxDepth`.\n */\nexport async function getMenu(\n handle: string,\n opts: GetMenuOptions,\n deps: MenuResolveDeps,\n): Promise<ResolvedMenu | null> {\n if (handle.length === 0 || handle.length > MAX_HANDLE_LENGTH) return null\n\n const row = await deps.getMenuRow(handle)\n if (row === null) return null\n\n // Renderer-enforced clamp — independent of the editor's own check (phase 07\n // §2.7), and never wider than the absolute ceiling even if the column is not.\n const stored = row.maxDepth\n const maxDepth =\n typeof stored === 'number' && Number.isInteger(stored) && stored >= 1\n ? Math.min(stored, MAX_MENU_DEPTH)\n : DEFAULT_MENU_DEPTH\n\n const prepared = prepareTree(row.tree, opts.locale, deps.defaultLocale, maxDepth)\n if (prepared.droppedForBudget > 0) {\n deps.logger?.warn(\n {\n handle,\n locale: opts.locale,\n dropped: prepared.droppedForBudget,\n cap: MAX_MENU_TREE_NODES,\n },\n 'menu resolution dropped items over the node budget',\n )\n }\n\n const lookups = await runLookups(prepared, opts.locale, deps, handle)\n const menu: ResolvedMenu = {\n handle,\n items: resolveNodes(prepared.nodes, opts.locale, lookups, deps),\n }\n\n return opts.currentPath === undefined ? menu : markActiveTrail(menu, opts.currentPath)\n}\n","/**\n * Typed column access for a dynamically-generated entity table.\n *\n * `AdminClient.getTable()` / `QueryClient.getTable()` both return\n * `PgTableWithColumns<any>` (Drizzle cannot type columns that are generated\n * from a runtime entity definition), so reading a column off one directly\n * yields `any` and silently erodes this package's type coverage. Narrowing\n * through this helper pins the ONE column being filtered on — the same idiom\n * `@murumets-ee/content`'s `buildEnumerateDeps` uses.\n *\n * Internal to `@murumets-ee/menus`; not part of any public entry.\n */\n\nimport type { PgColumn, PgTableWithColumns } from 'drizzle-orm/pg-core'\n\n/** Read one named column off a runtime-generated entity table, typed. */\nexport function column<K extends string>(\n // biome-ignore lint/suspicious/noExplicitAny: Drizzle cannot type dynamically-generated column schemas — `PgTableWithColumns<any>` is `getTable()`'s own declared return type (CLAUDE.md's sanctioned case).\n table: PgTableWithColumns<any>,\n name: K,\n): PgColumn {\n return (table as unknown as Record<K, PgColumn>)[name]\n}\n","/**\n * App-binding for {@link import('./get-menu.js').getMenu} (plan/routing PR09).\n *\n * `./get-menu.ts` is a LEAF — it takes every dependency as an injected function\n * so it imports no `@murumets-ee/core` graph. THIS module is the outer seam\n * that holds those imports and builds concrete deps from a live `ToolkitApp`,\n * mirroring `@murumets-ee/content`'s `paths/resolve-context.ts` exactly. Because\n * the real `getCanonicalPaths` / `PublishFilter` are ASSIGNED to the leaf's\n * structurally-declared dep slots here, drift between the two packages is a\n * build error at this file rather than a silent divergence.\n *\n * **Reads run under an ELEVATED system context** (same `SYSTEM_CONTEXT` shape\n * and rationale as content's `resolve-context.ts` — read that file's header).\n * Rendering a menu is a SYSTEM derivation: it must work for an anonymous public\n * request, or for a build-time SSG run with no request context at all, and must\n * not fail just because the caller holds no per-entity `view` grant. Elevation\n * bypasses ONLY the permission/scope assertion — `QueryClient`'s PUBLISH filter\n * and trashed-row exclusion still apply, because the elevated context grants\n * reach WITHOUT `view_unpublished` (D017; see {@link SYSTEM_CONTEXT}), so:\n * - an unpublished / trashed MENU still resolves to `null` (nothing renders),\n * - an unpublished / trashed TARGET is still absent from the publish gate and\n * from the title read, so its item is dropped.\n * The only data elevation exposes is a published document's title + canonical\n * path — the same facts the public sitemap already enumerates.\n */\n\nimport { createDetailTemplateProvider } from '@murumets-ee/content/detail-template'\nimport { getContentConfig } from '@murumets-ee/content/plugin'\nimport {\n buildCanonicalPathsBatchDeps,\n buildEnumerateDeps,\n getCanonicalPaths,\n} from '@murumets-ee/content/resolve'\nimport type { ToolkitApp } from '@murumets-ee/core'\nimport { createSystemReachResolver } from '@murumets-ee/core'\nimport type { SecurityContext } from '@murumets-ee/entity'\nimport { getEntityTitleField, isTemplateDrawn } from '@murumets-ee/entity'\nimport { QueryClient } from '@murumets-ee/entity/query'\nimport { eq, inArray } from 'drizzle-orm'\nimport { Menu } from './entity.js'\nimport type { MenuResolveDeps, MenuRow } from './get-menu.js'\nimport { column } from './internal/columns.js'\n\n/**\n * Elevated **reach** resolver — grants reach, never visibility of unpublished\n * rows. See the file header.\n *\n * That invariant used to rest on `QueryClient` applying the publish filter\n * unconditionally; since D017 it rests on this resolver withholding\n * `view_unpublished`, which {@link createSystemReachResolver} does centrally.\n * A `checker: () => true` here would make `_menu` render items pointing at\n * draft documents.\n */\n// Named, not defaulted: `content`'s path resolver is the other elevated reader\n// and passes `'path-resolver'`. Two of them logging the default `'system'` would\n// be indistinguishable in audit output.\nconst SYSTEM_CONTEXT: () => SecurityContext = createSystemReachResolver('menu-resolver')\n\n/** Ids per batched title lookup — mirrors content's `CANONICAL_BATCH_SIZE`. */\nconst TITLE_BATCH_SIZE = 500\n\n/**\n * Build the concrete {@link MenuResolveDeps} for an app.\n *\n * Cheap to call per request (it constructs no clients eagerly — each read\n * builds its own `QueryClient`, matching `buildEnumerateDeps`'s per-call shape,\n * so this never depends on plugin boot ORDER).\n */\nexport function buildMenuResolveDeps(app: ToolkitApp): MenuResolveDeps {\n const db = app.db.readOnly\n const canonicalBatch = buildCanonicalPathsBatchDeps(app)\n const { filterPublished } = buildEnumerateDeps(app)\n\n return {\n getMenuRow: async (handle: string): Promise<MenuRow | null> => {\n const client = new QueryClient({\n entity: Menu,\n db,\n logger: app.logger.child({ resolve: 'menu' }),\n contextResolver: SYSTEM_CONTEXT,\n })\n // `publishable()` on `Menu` means this client filters to PUBLISHED rows\n // for any principal without `view_unpublished` — and `SYSTEM_CONTEXT`\n // withholds it (D017), so the filter always applies HERE. A draft menu\n // returns nothing, which `getMenu` turns into `null`. Do not swap this\n // resolver for a blanket `checker: () => true`: that grants the action and\n // silently publishes drafts.\n //\n // No `locale` is passed: the menu entity has no translations and no\n // locale-status table (locale variance lives INSIDE the tree, per D009),\n // so a locale would only cost a pointless translation merge.\n const rows = await client.findMany({\n where: eq(column(client.getTable(), 'handle'), handle),\n limit: 1,\n })\n const row = rows[0]\n if (row === undefined) return null\n return { maxDepth: row.maxDepth, tree: row.tree }\n },\n\n resolveCanonicalPaths: (entityType, ids, locale) =>\n getCanonicalPaths(entityType, ids, locale, canonicalBatch),\n\n filterPublished,\n\n resolveTitles: async (entityType, ids, locale) => {\n const titles = new Map<string, string>()\n if (ids.length === 0) return titles\n // An unregistered entity type yields NO title, so its items are dropped\n // — fail-closed, matching `buildEnumerateDeps`'s own unknown-type branch.\n const entity = app.entities.get(entityType)\n if (entity === undefined) return titles\n\n // A row DRAWN BY A STORED TEMPLATE (block-library D009, F051) is public\n // only as far as its site's detail bindings say. Its admin title column is\n // not one of those values (a legal document's is its `type` key), so it is\n // never read for a label: the item takes the row's DECLARED title — the\n // same one its own page shows — and drops if the site declares none.\n const declaredTitles = isTemplateDrawn(entity)\n ? createDetailTemplateProvider(() => app, {\n defaultLocale: getContentConfig().defaultLocale,\n })\n : undefined\n\n const titleField = getEntityTitleField(entity)\n // `getEntityTitleField` degrades to the row's own `id` when the entity has\n // no display-able field — a raw UUID is not a menu label. Fail closed and\n // let the item drop, matching this module's own stated posture.\n if (declaredTitles === undefined && titleField === 'id') return titles\n const client = new QueryClient({\n entity,\n db,\n logger: app.logger.child({ resolve: 'menu-title', entity: entityType }),\n contextResolver: SYSTEM_CONTEXT,\n })\n const idColumn = column(client.getTable(), 'id')\n\n for (let i = 0; i < ids.length; i += TITLE_BATCH_SIZE) {\n const chunk = ids.slice(i, i + TITLE_BATCH_SIZE)\n // `locale` drives `mergeTranslations`, so a translatable title comes\n // back in the requested locale (falling back to the base row's value)\n // — the \"live entity title (locale-merged)\" phase 11 §2 asks for.\n const rows = await client.findMany({\n where: inArray(idColumn, [...chunk]),\n locale,\n limit: chunk.length,\n })\n for (const row of rows) {\n const record: Record<string, unknown> = row\n const id = record.id\n let title: unknown\n if (declaredTitles === undefined) {\n title = record[titleField]\n } else {\n // Per ITEM, like everything else here: a projection that throws\n // costs that one label (the item drops), never the whole menu.\n try {\n title = (await declaredTitles.publicRow({ entity: entityType, row: record, locale }))\n .title\n } catch (err) {\n app.logger.warn(\n { err, entity: entityType },\n 'menus: detail-template title projection failed — the item is dropped',\n )\n }\n }\n if (typeof id === 'string' && typeof title === 'string' && title.length > 0) {\n titles.set(id, title)\n }\n }\n }\n return titles\n },\n\n defaultLocale: getContentConfig().defaultLocale,\n logger: app.logger.child({ resolve: 'menu' }),\n }\n}\n"],"mappings":"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"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"ru-D8QNW6XE.mjs","names":[],"sources":["../src/messages/ru.json"],"sourcesContent":[""],"mappings":""}
@@ -1 +0,0 @@
1
- {"version":3,"file":"schema.d.mts","names":[],"sources":["../src/menu-tree-flatten.ts","../src/menu-tree-normalize.ts","../src/menu-tree-ops.ts"],"mappings":"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"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"schema.mjs","names":[],"sources":["../src/menu-tree-flatten.ts","../src/menu-tree-normalize.ts","../src/menu-tree-ops.ts"],"sourcesContent":["/**\n * Tree ⇄ flat-list conversion + the indent-to-nest depth projection\n * (plan/routing phase 09 §3).\n *\n * **Why flat.** `@dnd-kit`'s sortable primitive reorders a LIST. An\n * indent-to-nest tree editor therefore renders the tree as ONE flat sortable\n * list annotated with `depth` / `parentId` / `index`, indents each row with\n * CSS, and derives a NEW depth from the drag's horizontal offset. That is\n * dnd-kit's own reference architecture for a sortable tree (`apps/stories/\n * stories/react/Sortable/Tree` in `clauderic/dnd-kit`), and the projection\n * maths below is adapted from its `utilities.ts` — the off-by-one rules for\n * \"how far right must I drag to nest one level deeper\" are subtle enough that\n * deriving them from scratch would be a bug farm.\n *\n * **What we changed vs. the reference.**\n *\n * - Nodes carry a real {@link MenuItem} payload (`kind` / `link` / `label` /\n * `enabled` / `newTab`), not just an id, so `data` is the item MINUS its\n * children — structure lives in `parentId`/`depth` only, and there is never a\n * stale `children` array riding along a flat node.\n * - Depth is capped by the MENU (`maxDepth`, 1..{@link MAX_MENU_DEPTH}), not\n * unbounded, and {@link projectDrop} reports whether that cap was the binding\n * constraint so the editor can toast (\"drops exceeding maxDepth clamp with a\n * toast\", phase 09 §3) — as opposed to the ordinary structural limit, which\n * is normal drag feedback and must NOT toast.\n * - The cap also reserves room for the DRAGGED SUBTREE's own height\n * ({@link depthCapForDrag}): dropping a 2-level branch at the deepest legal\n * row would push its children past the cap.\n *\n * Everything here is pure and DOM-free — the highest-value tests in this\n * surface live against these functions, not against mounted drag machinery.\n * That purity is also why they live in `@murumets-ee/menus` rather than in the\n * editor package that drives them: {@link flattenMenuTree} is the \"indent-\n * flattened tree\" the Meta panel's parent/position selects render too (phase 10\n * §4), and `menus-ui` can't export it to `admin-ui` without an import cycle —\n * see `./menu-tree-ops.ts`'s header for the full reasoning.\n *\n * ## Depth convention\n *\n * `depth` is **0-based** here (root rows are `0`), matching dnd-kit's reference\n * and making the CSS indent a plain `depth * indentation`. The menu's own\n * `maxDepth` column, `measureMenuTree()` and `./menu-tree-ops.ts` are\n * **1-based** (a flat tree measures `1`). {@link depthCapForDrag} is the single\n * conversion point.\n *\n * @module\n */\n\nimport { MAX_MENU_DEPTH, type MenuItem } from './menu-item-schema.js'\n\n/** A menu node's payload without its children — structure lives on the flat node. */\nexport type MenuNodeData = Omit<MenuItem, 'children'>\n\n/** One row of the flattened, DND-annotated tree. */\nexport interface FlatMenuNode {\n readonly id: string\n /** Owning node's id, or `null` for a root row. */\n readonly parentId: string | null\n /** 0-based nesting level (see the module header's depth convention). */\n readonly depth: number\n /** Position among the row's siblings. */\n readonly index: number\n /**\n * How many direct children the node has. Carried on the row (rather than\n * derived from the list) so it stays correct while a drag has temporarily\n * lifted the node's descendants out of the sortable list.\n */\n readonly childCount: number\n /** The item itself, minus `children`. */\n readonly data: MenuNodeData\n}\n\n/**\n * Flatten a nested menu tree into document order.\n *\n * Recursive rather than iterative, deliberately: the input is an\n * ALREADY-NORMALISED tree (`normalizeMenuTree` bounds it to\n * {@link MAX_MENU_DEPTH} levels and 500 nodes before it ever reaches the\n * client), so there is no hostile-payload stack-depth exposure here — unlike\n * `measureMenuTree`, which parses raw JSONB and is iterative for exactly that\n * reason.\n */\nexport function flattenMenuTree(items: readonly MenuItem[]): FlatMenuNode[] {\n const out: FlatMenuNode[] = []\n\n const walk = (nodes: readonly MenuItem[], parentId: string | null, depth: number): void => {\n nodes.forEach((node, index) => {\n const { children, ...data } = node\n out.push({ id: node.id, parentId, depth, index, childCount: children.length, data })\n walk(children, node.id, depth + 1)\n })\n }\n\n walk(items, null, 0)\n return out\n}\n\n/**\n * Rebuild a nested tree from a flat list, honouring each row's `parentId`.\n *\n * A row whose `parentId` names no row in the list is promoted to the ROOT\n * rather than dropped — silently losing a node on a whole-tree save is the one\n * failure mode this editor must never have (it is the same reason\n * `normalizeMenuTree` counts what it refuses). Cycles cannot arise from the\n * editor's own operations: {@link projectDrop} only ever assigns a parent that\n * appears EARLIER in the list.\n */\nexport function buildMenuTree(flat: readonly FlatMenuNode[]): MenuItem[] {\n const nodes = new Map<string, MenuItem>()\n for (const row of flat) nodes.set(row.id, { ...row.data, children: [] })\n\n const roots: MenuItem[] = []\n for (const row of flat) {\n const node = nodes.get(row.id)\n if (node === undefined) continue\n const parent = row.parentId === null ? undefined : nodes.get(row.parentId)\n if (parent === undefined) roots.push(node)\n else parent.children.push(node)\n }\n return roots\n}\n\n/** How many indentation steps a horizontal drag offset represents. */\nexport function getDragDepth(offsetX: number, indentation: number): number {\n if (indentation <= 0) return 0\n return Math.round(offsetX / indentation)\n}\n\n/** Number of levels a subtree occupies, counting the node itself (leaf = 1). */\nexport function subtreeHeight(item: MenuItem): number {\n let deepest = 0\n for (const child of item.children) {\n const height = subtreeHeight(child)\n if (height > deepest) deepest = height\n }\n return deepest + 1\n}\n\n/**\n * The deepest 0-based depth a dragged subtree may land at.\n *\n * Converts the menu's 1-based `maxDepth` column (also hard-capped at the\n * absolute {@link MAX_MENU_DEPTH} — the column is a plain number and a stale\n * row could hold anything) and then reserves room for the dragged subtree's own\n * height, so dropping a 2-level branch can't push its children past the cap.\n * Never negative: an already-too-deep subtree is pinned to the root rather than\n * made undraggable.\n */\nexport function depthCapForDrag(menuMaxDepth: number, draggedHeight: number): number {\n const levels = Math.min(Math.max(Math.trunc(menuMaxDepth), 1), MAX_MENU_DEPTH)\n return Math.max(levels - Math.max(draggedHeight, 1), 0)\n}\n\n/** Every transitive descendant id of `parentId` in a flat list. */\nexport function collectDescendantIds(flat: readonly FlatMenuNode[], parentId: string): Set<string> {\n const ids = new Set<string>([parentId])\n // One forward pass: the list is in document order, so a node's parent is\n // always already in `ids` by the time the node is visited.\n for (const row of flat) {\n if (row.parentId !== null && ids.has(row.parentId)) ids.add(row.id)\n }\n ids.delete(parentId)\n return ids\n}\n\n/**\n * Drop every row that has a COLLAPSED ancestor — the rendered subset of the\n * flat list.\n *\n * Collapse is UI-only state (it never touches the saved tree), but it does\n * change which rows exist as sortables. The editor therefore stashes the hidden\n * rows at drag start and re-appends them when rebuilding the tree, so a\n * collapsed branch can never be lost by a drag. Because collapsing hides ALL of\n * a node's children, the stashed rows keep their relative order within their\n * parent and there are no visible siblings to interleave with.\n */\nexport function hideCollapsedRows(\n flat: readonly FlatMenuNode[],\n collapsed: ReadonlySet<string>,\n): readonly FlatMenuNode[] {\n if (collapsed.size === 0) return flat\n const hidden = new Set<string>()\n const out: FlatMenuNode[] = []\n // Forward pass: document order guarantees a parent is classified before its\n // children are visited.\n for (const row of flat) {\n if (row.parentId !== null && (hidden.has(row.parentId) || collapsed.has(row.parentId))) {\n hidden.add(row.id)\n continue\n }\n out.push(row)\n }\n return out\n}\n\n/** Input for {@link projectDrop}. */\nexport interface ProjectDropInput {\n /** The flat list AS CURRENTLY ORDERED (the dragged row already moved). */\n readonly items: readonly FlatMenuNode[]\n /** Id of the row being projected — normally the drag source. */\n readonly activeId: string\n /** Depth the pointer is asking for (initial depth + horizontal drag steps). */\n readonly requestedDepth: number\n /** Deepest legal 0-based depth for this drag — see {@link depthCapForDrag}. */\n readonly depthCap: number\n}\n\n/** Result of {@link projectDrop}. */\nexport interface ProjectedDrop {\n /** The depth the row will actually land at. */\n readonly depth: number\n /** The parent the row will land under (`null` = root). */\n readonly parentId: string | null\n /**\n * `true` when the MENU's `maxDepth` was the binding constraint — i.e. the\n * user asked to go deeper and only the menu cap said no. The ordinary\n * structural limit (\"you can't be more than one level below the row above\")\n * does NOT set this: that is normal drag feedback, and toasting on it would\n * fire on nearly every drag.\n */\n readonly clampedByMaxDepth: boolean\n}\n\n/**\n * Project a row's new depth + parent from the depth the pointer is asking for.\n *\n * Adapted from dnd-kit's `getProjection` (see the module header). Rules:\n *\n * - the row above sets the structural ceiling — you may be at most one level\n * deeper than it, and never deeper than your own current depth + 1;\n * - the row below sets the floor — you may not be shallower than it, or it\n * would be orphaned mid-drag;\n * - the first row is always at the root (nothing to nest under);\n * - the menu's own cap is applied on top of all of the above.\n */\nexport function projectDrop({\n items,\n activeId,\n requestedDepth,\n depthCap,\n}: ProjectDropInput): ProjectedDrop {\n const activeIndex = items.findIndex((row) => row.id === activeId)\n if (activeIndex === -1) return { depth: 0, parentId: null, clampedByMaxDepth: false }\n\n const current = items[activeIndex]\n if (current === undefined) return { depth: 0, parentId: null, clampedByMaxDepth: false }\n const previous = items[activeIndex - 1]\n const next = items[activeIndex + 1]\n const minDepth = next === undefined ? 0 : next.depth\n\n // No previous row → this row IS the root, full stop — there is nothing\n // above to nest under, so depth is 0 regardless of what a deeper following\n // row's floor would otherwise ask for. A HARD override, not something that\n // flows through the general ceiling/floor/cap formula below: that formula\n // lets the floor win over the STRUCTURAL ceiling when they conflict (see\n // its own comment), and \"first row\" is exactly a structural-ceiling-of-zero\n // case — folding it into the same formula would let a deeper following row\n // pull the first item away from the root it must stay pinned to.\n if (previous === undefined) {\n return { depth: 0, parentId: null, clampedByMaxDepth: false }\n }\n\n const structuralMax = Math.min(current.depth + 1, previous.depth + 1)\n\n // Explicit precedence, three bounds applied in order — NOT a single merged\n // \"allowedMax\" clamped against the floor, which silently drops the floor\n // whenever the structural ceiling and the floor conflict (an ORDINARY\n // cross-depth-boundary drag, not just a since-reduced-`maxDepth` edge case:\n // it happens whenever the row above is shallower than the row below, which\n // a drag crosses constantly).\n //\n // 1. The structural ceiling first (never more than one level deeper than\n // your own current depth, or than the row above) — the per-gesture\n // ratchet `structuralMax` encodes.\n // 2. The floor next (never shallower than the row below, or it would be\n // orphaned) — this can push ABOVE the structural ceiling when the two\n // conflict, which is the intended resolution: prefer not orphaning a\n // row over honouring the ratchet for one frame.\n // 3. The menu's own absolute depth cap LAST, unconditionally — the one\n // bound that always wins over both of the above.\n let depth = Math.min(Math.max(Math.min(requestedDepth, structuralMax), minDepth), depthCap)\n // Hard invariant regardless of the above: never produce a depth outside\n // [0, depthCap]. The editor's own save-time `validateTreeDepth` pre-flight\n // should be unreachable, and this is what keeps it that way.\n depth = Math.min(Math.max(depth, 0), depthCap)\n\n const clampedByMaxDepth = requestedDepth > depthCap && depthCap < structuralMax\n\n return { depth, parentId: resolveParentId(items, activeIndex, depth), clampedByMaxDepth }\n}\n\n/**\n * Which node owns a row that lands at `depth`, given the rows before it.\n *\n * Verbatim in spirit from dnd-kit's `getParentId`: same depth as the row above\n * → same parent; one deeper → the row above IS the parent; shallower → walk\n * back to the nearest row at that depth and borrow its parent.\n */\nfunction resolveParentId(\n items: readonly FlatMenuNode[],\n activeIndex: number,\n depth: number,\n): string | null {\n const previous = items[activeIndex - 1]\n if (depth === 0 || previous === undefined) return null\n if (depth === previous.depth) return previous.parentId\n if (depth > previous.depth) return previous.id\n\n for (let i = activeIndex - 1; i >= 0; i--) {\n const row = items[i]\n if (row !== undefined && row.depth === depth) return row.parentId\n }\n return null\n}\n","/**\n * Read-side narrowing for a stored `menu.tree` column, plus the entity-ref\n * collection the editor page needs for `resolveMenuItemSummaries()`.\n *\n * **Why this exists.** `field.json()` types its column as `JsonValue`, so the\n * row `fetchEntityById(Menu, id)` returns hands us an untyped blob. Casting it\n * straight to `MenuItem[]` would need an `as unknown as` (CLAUDE.md: don't) and\n * would also be a lie — the column is only *usually* well-formed. Rows written\n * before `menuTreeValidation()` existed, hand-edited rows, and rows restored\n * from an older dump can all violate the shape, and the editor is exactly the\n * tool you reach for when they do. So this walks the raw value ONCE and returns\n * a properly-typed tree.\n *\n * **Deliberately tolerant, and deliberately NOT `getMenu`.** `getMenu` is the\n * public render path: it also applies the per-locale `enabled` filter, the\n * publish gate and the per-menu `maxDepth` clamp, because a visitor must not\n * see a hidden or unpublished item. The editor is the opposite case — it must\n * show *everything* that is stored, including items hidden in the current\n * locale and items nested deeper than the menu's own `maxDepth` (that is the\n * state you opened the editor to fix). The only things dropped here are nodes\n * whose shape cannot be represented as a `MenuItem` at all.\n *\n * **Bounded** (CLAUDE.md \"no unbounded fan-out\"): at most\n * {@link MAX_MENU_TREE_NODES} nodes are materialised and recursion stops at\n * {@link MAX_MENU_DEPTH}, regardless of what the column contains — so a\n * hand-edited row can't turn a page render into unbounded work or a stack\n * overflow. Whatever is refused is COUNTED and returned, never silently\n * swallowed: the caller logs it, and the editor surfaces it before a save\n * would persist the removal.\n *\n * **Why it lives in `@murumets-ee/menus`.** It was a `@murumets-ee/menus-ui`\n * internal until PR12, when the Meta panel's Menus section (`admin-ui`) needed\n * the same narrowing to turn a `menu` row's `tree` column into a `MenuItem[]`.\n * `menus-ui` depends on `admin-ui`, so exporting it from there would close an\n * `admin-ui → menus-ui → admin-ui` import cycle — same reasoning (and same\n * move) as `./menu-tree-ops.ts`.\n *\n * It pulls `LinkValueSchema` — and therefore zod — and nothing else, so it is\n * client-safe and rides the `./schema` subpath alongside the tree operations.\n *\n * @module\n */\n\nimport {\n LinkValueSchema,\n MAX_MENU_DEPTH,\n MAX_MENU_TREE_NODES,\n type MenuItem,\n type MenuLinkValue,\n} from './menu-item-schema.js'\n\n/** One entity target a menu item points at — the `resolveMenuItemSummaries` input shape. */\nexport interface MenuEntityRef {\n entity: string\n id: string\n}\n\n/** Result of {@link normalizeMenuTree}. */\nexport interface NormalizedMenuTree {\n /** The stored tree, narrowed to `MenuItem[]`. */\n items: MenuItem[]\n /**\n * How many stored nodes were REFUSED — malformed shape, or past one of the\n * hard bounds. An entire refused subtree counts as ONE (we stop at the\n * refusal point rather than descending into it to count more).\n *\n * Non-zero means a whole-tree save would persist those removals, so the\n * editor must warn before writing.\n */\n dropped: number\n}\n\nfunction isPlainObject(value: unknown): value is Record<string, unknown> {\n return value !== null && typeof value === 'object' && !Array.isArray(value)\n}\n\n/**\n * Keep only the entries whose value passes `predicate`. Locale KEYS are not\n * checked against the app's configured locale set on purpose: the set can\n * change after a tree is written, and dropping a label for a locale that was\n * just disabled would destroy authored text on the next save. An out-of-set key\n * is instead rejected loudly by `menuTreeValidation()` when the editor saves.\n */\nfunction filterMap<T>(\n value: unknown,\n predicate: (candidate: unknown) => candidate is T,\n): Record<string, T> | undefined {\n if (!isPlainObject(value)) return undefined\n const out: Record<string, T> = {}\n for (const [key, entry] of Object.entries(value)) {\n if (predicate(entry)) out[key] = entry\n }\n return out\n}\n\nconst isString = (value: unknown): value is string => typeof value === 'string'\nconst isBoolean = (value: unknown): value is boolean => typeof value === 'boolean'\n\n/**\n * Narrow a raw `menu.tree` JSONB value to `MenuItem[]`.\n *\n * @param value the raw column value (untrusted shape).\n */\nexport function normalizeMenuTree(value: unknown): NormalizedMenuTree {\n let budget = MAX_MENU_TREE_NODES\n let dropped = 0\n\n function walk(raw: unknown, depth: number): MenuItem[] {\n if (!Array.isArray(raw)) return []\n // `Array.isArray` narrows `unknown` to `any[]`; re-binding through a\n // `readonly unknown[]` annotation keeps every element typed as `unknown`.\n const rawNodes: readonly unknown[] = raw\n if (depth > MAX_MENU_DEPTH) {\n // Past the absolute cap the whole level goes, counted once per node so\n // the editor's warning is proportional to what it lost.\n dropped += rawNodes.length\n return []\n }\n\n const out: MenuItem[] = []\n for (const node of rawNodes) {\n if (!isPlainObject(node)) {\n dropped++\n continue\n }\n\n const id = node.id\n const kind = node.kind\n if (typeof id !== 'string' || id.length === 0) {\n dropped++\n continue\n }\n if (kind !== 'link' && kind !== 'header') {\n dropped++\n continue\n }\n\n let link: MenuLinkValue | undefined\n if (kind === 'link') {\n // Re-validate the stored target through the SAME schema the write path\n // uses — notably the `sanitizeHref` protocol allowlist, so a row that\n // predates (or bypassed) that gate can't hand the editor a\n // `javascript:` href to render.\n const parsed = LinkValueSchema.safeParse(node.link)\n if (!parsed.success) {\n dropped++\n continue\n }\n link = parsed.data\n } else if (node.link !== undefined) {\n // A header carrying a link target is a shape violation the type can't\n // represent — drop it rather than render a half-defined node.\n dropped++\n continue\n }\n\n if (budget <= 0) {\n dropped++\n continue\n }\n budget--\n\n // `filterMap` returns `{}` (not `undefined`) when the stored map WAS an\n // object but every entry failed the predicate (e.g. `{ en: 42 }`, a\n // non-string junk value) — `?? null`/`undefined` below wouldn't catch\n // that, since `{}` is truthy. An all-junk map must collapse the same\n // way a genuinely absent one does: for `label`, `{}` surviving into a\n // save would fail the write-side schema's \"a header needs at least one\n // locale key\" check with a server error instead of this module's own\n // documented tolerant behaviour; for `enabled`, `{}` is a harmless but\n // needless stored key (absent already means \"visible everywhere\").\n const rawLabel = filterMap(node.label, isString)\n const label =\n rawLabel !== undefined && Object.keys(rawLabel).length > 0 ? rawLabel : undefined\n const rawEnabled = filterMap(node.enabled, isBoolean)\n const enabled =\n rawEnabled !== undefined && Object.keys(rawEnabled).length > 0 ? rawEnabled : undefined\n out.push({\n id,\n kind,\n link,\n // `null` = \"use the live entity title per locale\" (the storage default).\n label: label ?? null,\n enabled,\n newTab: typeof node.newTab === 'boolean' ? node.newTab : undefined,\n children: walk(node.children, depth + 1),\n })\n }\n return out\n }\n\n return { items: walk(value, 1), dropped }\n}\n\n/**\n * Every distinct entity target in a (already-normalised, therefore bounded)\n * tree, in document order.\n *\n * Deduplicated: two menu items pointing at the same page produce ONE\n * `resolveMenuItemSummaries` ref, so the batched read never scales with item\n * count. The output feeds `resolveMenuItemSummaries(app, refs)`, whose result\n * map is keyed the same `${entity}:${id}` way.\n */\nexport function collectMenuEntityRefs(items: readonly MenuItem[]): MenuEntityRef[] {\n const seen = new Set<string>()\n const refs: MenuEntityRef[] = []\n\n function walk(nodes: readonly MenuItem[]): void {\n for (const node of nodes) {\n const link = node.link\n if (link?.kind === 'entity') {\n const key = `${link.entity}:${link.id}`\n if (!seen.has(key)) {\n seen.add(key)\n refs.push({ entity: link.entity, id: link.id })\n }\n }\n walk(node.children)\n }\n }\n\n walk(items)\n return refs\n}\n","/**\n * Pure structural operations on a `MenuItem[]` tree — the keyboard / item-menu\n * half of the editor (plan/routing phase 09 §3).\n *\n * **Why they live HERE, in `@murumets-ee/menus`.** They were written as\n * `@murumets-ee/menus-ui` internals for the tree editor (PR10), but the Meta\n * panel's Menus section (`@murumets-ee/admin-ui`, PR12) needs the same\n * insert/remove/replace primitives to toggle one entity's membership in a menu.\n * `menus-ui` depends on `admin-ui`, so exporting them from there would have made\n * `admin-ui → menus-ui → admin-ui` a real import cycle. `@murumets-ee/menus`\n * depends on neither, so it is the one home both consumers can reach — and these\n * functions are pure TS with no React, no DOM and no `@murumets-ee/core`, so the\n * client-safe `./schema` subpath re-exports them without pulling a server graph\n * along (see that module's header).\n *\n * **Why these exist separately from the drag path.** Phase 09 §3 requires a\n * full keyboard fallback (\"the Drupal show-row-weights lesson: DND-only editors\n * fail accessibility\"), and this repo has already learned that dnd-kit's own\n * `KeyboardSensor` is the wrong tool for it — `packages/blocks/src/editor/\n * interactions/dnd-provider.tsx` deliberately drops it (its Enter/Space\n * activation swallowed the editor's ⌘Enter chord) in favour of real move\n * buttons. So reordering by keyboard is REAL BUTTONS + shortcuts calling these\n * functions, not a drag protocol — which also makes the whole reorder surface\n * unit-testable with no DOM and no drag machinery.\n *\n * Every function is immutable: the input tree is never mutated, and untouched\n * subtrees are shared by reference (so React's identity checks stay cheap).\n *\n * ## Depth convention\n *\n * `maxDepth` here is the menu's own **1-based** column (a flat tree is depth 1),\n * matching `measureMenuTree` / `validateTreeDepth` in `./menu-item-schema.ts`.\n * The flat DND view in `./menu-tree-flatten.ts` uses 0-based depths — see its\n * module header.\n *\n * @module\n */\n\nimport { MAX_MENU_DEPTH, MAX_MENU_TREE_NODES, type MenuItem } from './menu-item-schema.js'\nimport { subtreeHeight } from './menu-tree-flatten.js'\n\n/**\n * Outcome of a structural operation.\n *\n * `noop` — nothing to do (already at the edge of its sibling list, at the root,\n * or the id isn't in the tree). `max-depth` — the move is legal structurally but\n * would nest deeper than the menu allows; the caller surfaces a toast, the same\n * way a drag that exceeds `maxDepth` clamps with one (phase 09 §3).\n */\nexport type TreeOpResult =\n | { readonly ok: true; readonly tree: MenuItem[] }\n | { readonly ok: false; readonly reason: 'noop' | 'max-depth' }\n\nconst NOOP: TreeOpResult = { ok: false, reason: 'noop' }\nconst TOO_DEEP: TreeOpResult = { ok: false, reason: 'max-depth' }\n\n/** The effective 1-based nesting cap: the menu's own column, hard-bounded. */\nfunction effectiveMaxDepth(menuMaxDepth: number): number {\n return Math.min(Math.max(Math.trunc(menuMaxDepth), 1), MAX_MENU_DEPTH)\n}\n\n/**\n * Move an item one slot up (`delta: -1`) or down (`delta: 1`) WITHIN its\n * sibling list, carrying its subtree. Never changes nesting — that is\n * {@link indentMenuItem} / {@link outdentMenuItem}'s job, so ⌥↑/⌥↓ and ⌥←/⌥→\n * stay orthogonal (an item at the top of its list does NOT silently escape to\n * the parent level; the user asks for that explicitly).\n */\nexport function moveMenuItem(items: readonly MenuItem[], id: string, delta: -1 | 1): TreeOpResult {\n const index = items.findIndex((item) => item.id === id)\n if (index !== -1) {\n const target = index + delta\n if (target < 0 || target >= items.length) return NOOP\n const next = items.slice()\n const moved = next.splice(index, 1)\n next.splice(target, 0, ...moved)\n return { ok: true, tree: next }\n }\n\n return recurse(items, (children) => moveMenuItem(children, id, delta))\n}\n\n/**\n * Make an item the LAST CHILD of its previous sibling (WordPress / Drupal\n * indent semantics). Refuses when it has no previous sibling, or when the moved\n * subtree's own height would push it past the menu's `maxDepth`.\n */\nexport function indentMenuItem(\n items: readonly MenuItem[],\n id: string,\n menuMaxDepth: number,\n currentLevel = 1,\n): TreeOpResult {\n const index = items.findIndex((item) => item.id === id)\n if (index !== -1) {\n if (index === 0) return NOOP\n const previous = items[index - 1]\n const moved = items[index]\n if (previous === undefined || moved === undefined) return NOOP\n\n // The moved node lands one level deeper; its own deepest descendant lands\n // `subtreeHeight - 1` below that.\n const deepestLevel = currentLevel + 1 + (subtreeHeight(moved) - 1)\n if (deepestLevel > effectiveMaxDepth(menuMaxDepth)) return TOO_DEEP\n\n const next = items.slice()\n next.splice(index, 1)\n next[index - 1] = { ...previous, children: [...previous.children, moved] }\n return { ok: true, tree: next }\n }\n\n return recurse(items, (children) => indentMenuItem(children, id, menuMaxDepth, currentLevel + 1))\n}\n\n/**\n * Move an item OUT of its parent, landing immediately after that parent in the\n * grandparent's list. A root item has nowhere to go (`noop`).\n *\n * Following siblings stay where they are — they are NOT adopted by the\n * outdented item (WordPress does adopt them; that behaviour surprises more\n * often than it helps, and an explicit indent is one keystroke away).\n */\nexport function outdentMenuItem(items: readonly MenuItem[], id: string): TreeOpResult {\n for (let i = 0; i < items.length; i++) {\n const parent = items[i]\n if (parent === undefined) continue\n\n const childIndex = parent.children.findIndex((child) => child.id === id)\n if (childIndex !== -1) {\n const moved = parent.children[childIndex]\n if (moved === undefined) continue\n const remaining = parent.children.slice()\n remaining.splice(childIndex, 1)\n const next = items.slice()\n next[i] = { ...parent, children: remaining }\n next.splice(i + 1, 0, moved)\n return { ok: true, tree: next }\n }\n\n const deeper = outdentMenuItem(parent.children, id)\n if (deeper.ok) {\n const next = items.slice()\n next[i] = { ...parent, children: deeper.tree }\n return { ok: true, tree: next }\n }\n }\n return NOOP\n}\n\n/** Depth-first lookup by id, or `null` when the tree holds no such item. */\nexport function findMenuItem(items: readonly MenuItem[], id: string): MenuItem | null {\n for (const item of items) {\n if (item.id === id) return item\n const found = findMenuItem(item.children, id)\n if (found !== null) return found\n }\n return null\n}\n\n/** One entity target — the `{ entity, id }` half of an `entity`-kind link. */\nexport interface MenuEntityTarget {\n /** Entity NAME (`page`, `article`, …), matched exactly. */\n readonly entity: string\n /** Row id (a uuid), matched exactly. */\n readonly id: string\n}\n\n/**\n * Where one matching item sits in the tree — everything a caller needs to\n * describe (or re-place) it without walking the tree a second time.\n */\nexport interface MenuItemMatch {\n /**\n * The LIVE node, subtree included — reference-identical to the one in the\n * input tree, so it can be handed straight to {@link replaceMenuItem} /\n * {@link removeMenuItem}.\n */\n readonly item: MenuItem\n /** Owning node's id, or `null` when the match is a root item. */\n readonly parentId: string | null\n /** 0-based position among its siblings. */\n readonly index: number\n /** 0-based nesting level, matching `flattenMenuTree`'s convention. */\n readonly depth: number\n}\n\n/**\n * Every item in the tree whose link points at `target` — \"is entity X in this\n * menu, and if so where\" (plan/routing phase 10 §4).\n *\n * Returns ALL matches, in document order: nothing stops an author from linking\n * the same page twice (once under a header, once at the root), and a Meta panel\n * that assumed exactly one would silently edit the wrong item — or claim the\n * entity isn't in the menu after removing only the first copy. A matched item's\n * own subtree is still searched, so a self-referential nest reports both.\n *\n * Only `entity`-kind links can match; `url` / `email` links and `header` items\n * are skipped. An `anchor` on the link does NOT affect matching — `/pricing`\n * and `/pricing#plans` are the same page in the same menu.\n *\n * Whether a match sits under a header is one lookup away:\n * `parentId !== null && findMenuItem(items, parentId)?.kind === 'header'`.\n *\n * Pure in-memory traversal of an ALREADY-LOADED tree — no I/O, so this is not a\n * fan-out surface. Recursive like the rest of this module, which is safe for the\n * same reason: menu trees are bounded to {@link MAX_MENU_TREE_NODES} nodes and\n * {@link MAX_MENU_DEPTH} levels at the write boundary (and re-bounded by\n * `normalizeMenuTree` on the read side), so there is no hostile-payload stack\n * exposure here.\n */\nexport function findMenuItemsByTarget(\n items: readonly MenuItem[],\n target: MenuEntityTarget,\n): MenuItemMatch[] {\n const out: MenuItemMatch[] = []\n\n const walk = (nodes: readonly MenuItem[], parentId: string | null, depth: number): void => {\n nodes.forEach((node, index) => {\n const link = node.link\n if (link?.kind === 'entity' && link.entity === target.entity && link.id === target.id) {\n out.push({ item: node, parentId, index, depth })\n }\n walk(node.children, node.id, depth + 1)\n })\n }\n\n walk(items, null, 0)\n return out\n}\n\n/** Remove an item and its entire subtree. Unknown ids return an equal tree. */\nexport function removeMenuItem(items: readonly MenuItem[], id: string): MenuItem[] {\n const out: MenuItem[] = []\n for (const item of items) {\n if (item.id === id) continue\n const children = removeMenuItem(item.children, id)\n out.push(children === item.children ? item : { ...item, children })\n }\n // Preserve reference identity when nothing changed anywhere in this branch,\n // so React can skip re-rendering untouched subtrees.\n return out.length === items.length && out.every((item, i) => item === items[i])\n ? (items as MenuItem[])\n : out\n}\n\n/**\n * Swap the item with `id` for `next`, in place — the inspector's write path\n * (plan/routing phase 09 §4).\n *\n * The REPLACEMENT is stored whole, children included: the inspector always\n * hands back the node it was given plus one changed field, so its `children`\n * are the live ones. Re-grafting the old children on top would silently\n * override any caller that meant to change them.\n *\n * Unknown ids return an equal tree — an edit for an item that has since been\n * removed is a no-op, never a throw.\n *\n * **`next === item` (reference-identical) is ALSO a no-op**, not just an\n * unknown id — `./item-edits.ts`'s setters already return the SAME object\n * when a field edit changes nothing (e.g. re-picking the anchor already\n * stored), specifically so the editor's `tree !== savedTree` dirty check\n * doesn't fire for it. Treating every id MATCH as \"changed\" regardless of\n * whether the value actually differs would silently defeat that guarantee\n * one layer up — the whole tree gets a fresh array, and the menu is marked\n * dirty, even though nothing was edited.\n */\nexport function replaceMenuItem(\n items: readonly MenuItem[],\n id: string,\n next: MenuItem,\n): MenuItem[] {\n let changed = false\n const out = items.map((item) => {\n if (item.id === id) {\n if (next === item) return item\n changed = true\n return next\n }\n const children = replaceMenuItem(item.children, id, next)\n if (children === item.children) return item\n changed = true\n return { ...item, children }\n })\n // Preserve reference identity when this branch holds nothing that changed,\n // so React can skip re-rendering untouched subtrees.\n return changed ? out : (items as MenuItem[])\n}\n\n/**\n * Insert `node` as the next SIBLING of `afterId`, at that anchor's level.\n * Appends to the root when `afterId` is `null` or names no item — an add\n * action must always land somewhere visible, never silently do nothing.\n */\nexport function insertMenuItem(\n items: readonly MenuItem[],\n node: MenuItem,\n afterId: string | null,\n): MenuItem[] {\n if (afterId === null) return [...items, node]\n const inserted = insertAfter(items, node, afterId)\n return inserted ?? [...items, node]\n}\n\nfunction insertAfter(\n items: readonly MenuItem[],\n node: MenuItem,\n afterId: string,\n): MenuItem[] | null {\n const index = items.findIndex((item) => item.id === afterId)\n if (index !== -1) {\n const next = items.slice()\n next.splice(index + 1, 0, node)\n return next\n }\n for (let i = 0; i < items.length; i++) {\n const item = items[i]\n if (item === undefined) continue\n const children = insertAfter(item.children, node, afterId)\n if (children !== null) {\n const next = items.slice()\n next[i] = { ...item, children }\n return next\n }\n }\n return null\n}\n\n/**\n * Apply `fn` to each item's children until one reports success, rebuilding only\n * the branch that changed.\n *\n * A `noop`/`max-depth` result keeps the scan going. That is safe because item\n * ids are unique: once the target has been found and refused, no later sibling\n * can match it, so the remaining scan simply returns the same refusal.\n */\nfunction recurse(\n items: readonly MenuItem[],\n fn: (children: readonly MenuItem[]) => TreeOpResult,\n): TreeOpResult {\n for (let i = 0; i < items.length; i++) {\n const item = items[i]\n if (item === undefined) continue\n const result = fn(item.children)\n if (result.ok) {\n const next = items.slice()\n next[i] = { ...item, children: result.tree }\n return { ok: true, tree: next }\n }\n if (result.reason === 'max-depth') return result\n }\n return NOOP\n}\n"],"mappings":"uJAkFA,SAAgB,EAAgB,EAA4C,CAC1E,IAAM,EAAsB,CAAC,EAEvB,GAAQ,EAA4B,EAAyB,IAAwB,CACzF,EAAM,SAAS,EAAM,IAAU,CAC7B,GAAM,CAAE,WAAU,GAAG,GAAS,EAC9B,EAAI,KAAK,CAAE,GAAI,EAAK,GAAI,WAAU,QAAO,QAAO,WAAY,EAAS,OAAQ,MAAK,CAAC,EACnF,EAAK,EAAU,EAAK,GAAI,EAAQ,CAAC,CACnC,CAAC,CACH,EAGA,OADA,EAAK,EAAO,KAAM,CAAC,EACZ,CACT,CAYA,SAAgB,EAAc,EAA2C,CACvE,IAAM,EAAQ,IAAI,IAClB,IAAK,IAAM,KAAO,EAAM,EAAM,IAAI,EAAI,GAAI,CAAE,GAAG,EAAI,KAAM,SAAU,CAAC,CAAE,CAAC,EAEvE,IAAM,EAAoB,CAAC,EAC3B,IAAK,IAAM,KAAO,EAAM,CACtB,IAAM,EAAO,EAAM,IAAI,EAAI,EAAE,EAC7B,GAAI,IAAS,IAAA,GAAW,SACxB,IAAM,EAAS,EAAI,WAAa,KAAO,IAAA,GAAY,EAAM,IAAI,EAAI,QAAQ,EACrE,IAAW,IAAA,GAAW,EAAM,KAAK,CAAI,EACpC,EAAO,SAAS,KAAK,CAAI,CAChC,CACA,OAAO,CACT,CAGA,SAAgB,EAAa,EAAiB,EAA6B,CAEzE,OADI,GAAe,EAAU,EACtB,KAAK,MAAM,EAAU,CAAW,CACzC,CAGA,SAAgB,EAAc,EAAwB,CACpD,IAAI,EAAU,EACd,IAAK,IAAM,KAAS,EAAK,SAAU,CACjC,IAAM,EAAS,EAAc,CAAK,EAC9B,EAAS,IAAS,EAAU,EAClC,CACA,OAAO,EAAU,CACnB,CAYA,SAAgB,EAAgB,EAAsB,EAA+B,CAEnF,OAAO,KAAK,IADG,KAAK,IAAI,KAAK,IAAI,KAAK,MAAM,CAAY,EAAG,CAAC,EAAA,CACvC,EAAI,KAAK,IAAI,EAAe,CAAC,EAAG,CAAC,CACxD,CAGA,SAAgB,EAAqB,EAA+B,EAA+B,CACjG,IAAM,EAAM,IAAI,IAAY,CAAC,CAAQ,CAAC,EAGtC,IAAK,IAAM,KAAO,EACZ,EAAI,WAAa,MAAQ,EAAI,IAAI,EAAI,QAAQ,GAAG,EAAI,IAAI,EAAI,EAAE,EAGpE,OADA,EAAI,OAAO,CAAQ,EACZ,CACT,CAaA,SAAgB,EACd,EACA,EACyB,CACzB,GAAI,EAAU,OAAS,EAAG,OAAO,EACjC,IAAM,EAAS,IAAI,IACb,EAAsB,CAAC,EAG7B,IAAK,IAAM,KAAO,EAAM,CACtB,GAAI,EAAI,WAAa,OAAS,EAAO,IAAI,EAAI,QAAQ,GAAK,EAAU,IAAI,EAAI,QAAQ,GAAI,CACtF,EAAO,IAAI,EAAI,EAAE,EACjB,QACF,CACA,EAAI,KAAK,CAAG,CACd,CACA,OAAO,CACT,CA0CA,SAAgB,EAAY,CAC1B,QACA,WACA,iBACA,YACkC,CAClC,IAAM,EAAc,EAAM,UAAW,GAAQ,EAAI,KAAO,CAAQ,EAChE,GAAI,IAAgB,GAAI,MAAO,CAAE,MAAO,EAAG,SAAU,KAAM,kBAAmB,EAAM,EAEpF,IAAM,EAAU,EAAM,GACtB,GAAI,IAAY,IAAA,GAAW,MAAO,CAAE,MAAO,EAAG,SAAU,KAAM,kBAAmB,EAAM,EACvF,IAAM,EAAW,EAAM,EAAc,GAC/B,EAAO,EAAM,EAAc,GAC3B,EAAW,IAAS,IAAA,GAAY,EAAI,EAAK,MAU/C,GAAI,IAAa,IAAA,GACf,MAAO,CAAE,MAAO,EAAG,SAAU,KAAM,kBAAmB,EAAM,EAG9D,IAAM,EAAgB,KAAK,IAAI,EAAQ,MAAQ,EAAG,EAAS,MAAQ,CAAC,EAkBhE,EAAQ,KAAK,IAAI,KAAK,IAAI,KAAK,IAAI,EAAgB,CAAa,EAAG,CAAQ,EAAG,CAAQ,EAI1F,EAAQ,KAAK,IAAI,KAAK,IAAI,EAAO,CAAC,EAAG,CAAQ,EAE7C,IAAM,EAAoB,EAAiB,GAAY,EAAW,EAElE,MAAO,CAAE,QAAO,SAAU,EAAgB,EAAO,EAAa,CAAK,EAAG,mBAAkB,CAC1F,CASA,SAAS,EACP,EACA,EACA,EACe,CACf,IAAM,EAAW,EAAM,EAAc,GACrC,GAAI,IAAU,GAAK,IAAa,IAAA,GAAW,OAAO,KAClD,GAAI,IAAU,EAAS,MAAO,OAAO,EAAS,SAC9C,GAAI,EAAQ,EAAS,MAAO,OAAO,EAAS,GAE5C,IAAK,IAAI,EAAI,EAAc,EAAG,GAAK,EAAG,IAAK,CACzC,IAAM,EAAM,EAAM,GAClB,GAAI,IAAQ,IAAA,IAAa,EAAI,QAAU,EAAO,OAAO,EAAI,QAC3D,CACA,OAAO,IACT,CCjPA,SAAS,EAAc,EAAkD,CACvE,OAAyB,OAAO,GAAU,YAAnC,GAA+C,CAAC,MAAM,QAAQ,CAAK,CAC5E,CASA,SAAS,EACP,EACA,EAC+B,CAC/B,GAAI,CAAC,EAAc,CAAK,EAAG,OAC3B,IAAM,EAAyB,CAAC,EAChC,IAAK,GAAM,CAAC,EAAK,KAAU,OAAO,QAAQ,CAAK,EACzC,EAAU,CAAK,IAAG,EAAI,GAAO,GAEnC,OAAO,CACT,CAEA,MAAM,EAAY,GAAoC,OAAO,GAAU,SACjE,EAAa,GAAqC,OAAO,GAAU,UAOzE,SAAgB,EAAkB,EAAoC,CACpE,IAAI,EAAA,IACA,EAAU,EAEd,SAAS,EAAK,EAAc,EAA2B,CACrD,GAAI,CAAC,MAAM,QAAQ,CAAG,EAAG,MAAO,CAAC,EAGjC,IAAM,EAA+B,EACrC,GAAI,EAAA,EAIF,MADA,IAAW,EAAS,OACb,CAAC,EAGV,IAAM,EAAkB,CAAC,EACzB,IAAK,IAAM,KAAQ,EAAU,CAC3B,GAAI,CAAC,EAAc,CAAI,EAAG,CACxB,IACA,QACF,CAEA,IAAM,EAAK,EAAK,GACV,EAAO,EAAK,KAClB,GAAI,OAAO,GAAO,UAAY,EAAG,SAAW,EAAG,CAC7C,IACA,QACF,CACA,GAAI,IAAS,QAAU,IAAS,SAAU,CACxC,IACA,QACF,CAEA,IAAI,EACJ,GAAI,IAAS,OAAQ,CAKnB,IAAM,EAAS,EAAgB,UAAU,EAAK,IAAI,EAClD,GAAI,CAAC,EAAO,QAAS,CACnB,IACA,QACF,CACA,EAAO,EAAO,IAChB,MAAO,GAAI,EAAK,OAAS,IAAA,GAAW,CAGlC,IACA,QACF,CAEA,GAAI,GAAU,EAAG,CACf,IACA,QACF,CACA,IAWA,IAAM,EAAW,EAAU,EAAK,MAAO,CAAQ,EACzC,EACJ,IAAa,IAAA,IAAa,OAAO,KAAK,CAAQ,CAAC,CAAC,OAAS,EAAI,EAAW,IAAA,GACpE,EAAa,EAAU,EAAK,QAAS,CAAS,EAC9C,EACJ,IAAe,IAAA,IAAa,OAAO,KAAK,CAAU,CAAC,CAAC,OAAS,EAAI,EAAa,IAAA,GAChF,EAAI,KAAK,CACP,KACA,OACA,OAEA,MAAO,GAAS,KAChB,UACA,OAAQ,OAAO,EAAK,QAAW,UAAY,EAAK,OAAS,IAAA,GACzD,SAAU,EAAK,EAAK,SAAU,EAAQ,CAAC,CACzC,CAAC,CACH,CACA,OAAO,CACT,CAEA,MAAO,CAAE,MAAO,EAAK,EAAO,CAAC,EAAG,SAAQ,CAC1C,CAWA,SAAgB,EAAsB,EAA6C,CACjF,IAAM,EAAO,IAAI,IACX,EAAwB,CAAC,EAE/B,SAAS,EAAK,EAAkC,CAC9C,IAAK,IAAM,KAAQ,EAAO,CACxB,IAAM,EAAO,EAAK,KAClB,GAAI,GAAM,OAAS,SAAU,CAC3B,IAAM,EAAM,GAAG,EAAK,OAAO,GAAG,EAAK,KAC9B,EAAK,IAAI,CAAG,IACf,EAAK,IAAI,CAAG,EACZ,EAAK,KAAK,CAAE,OAAQ,EAAK,OAAQ,GAAI,EAAK,EAAG,CAAC,EAElD,CACA,EAAK,EAAK,QAAQ,CACpB,CACF,CAGA,OADA,EAAK,CAAK,EACH,CACT,CC1KA,MAAM,EAAqB,CAAE,GAAI,GAAO,OAAQ,MAAO,EACjD,EAAyB,CAAE,GAAI,GAAO,OAAQ,WAAY,EAGhE,SAAS,EAAkB,EAA8B,CACvD,OAAO,KAAK,IAAI,KAAK,IAAI,KAAK,MAAM,CAAY,EAAG,CAAC,EAAA,CAAiB,CACvE,CASA,SAAgB,EAAa,EAA4B,EAAY,EAA6B,CAChG,IAAM,EAAQ,EAAM,UAAW,GAAS,EAAK,KAAO,CAAE,EACtD,GAAI,IAAU,GAAI,CAChB,IAAM,EAAS,EAAQ,EACvB,GAAI,EAAS,GAAK,GAAU,EAAM,OAAQ,OAAO,EACjD,IAAM,EAAO,EAAM,MAAM,EACnB,EAAQ,EAAK,OAAO,EAAO,CAAC,EAElC,OADA,EAAK,OAAO,EAAQ,EAAG,GAAG,CAAK,EACxB,CAAE,GAAI,GAAM,KAAM,CAAK,CAChC,CAEA,OAAO,EAAQ,EAAQ,GAAa,EAAa,EAAU,EAAI,CAAK,CAAC,CACvE,CAOA,SAAgB,EACd,EACA,EACA,EACA,EAAe,EACD,CACd,IAAM,EAAQ,EAAM,UAAW,GAAS,EAAK,KAAO,CAAE,EACtD,GAAI,IAAU,GAAI,CAChB,GAAI,IAAU,EAAG,OAAO,EACxB,IAAM,EAAW,EAAM,EAAQ,GACzB,EAAQ,EAAM,GACpB,GAAI,IAAa,IAAA,IAAa,IAAU,IAAA,GAAW,OAAO,EAK1D,GADqB,EAAe,GAAK,EAAc,CAAK,EAAI,GAC7C,EAAkB,CAAY,EAAG,OAAO,EAE3D,IAAM,EAAO,EAAM,MAAM,EAGzB,OAFA,EAAK,OAAO,EAAO,CAAC,EACpB,EAAK,EAAQ,GAAK,CAAE,GAAG,EAAU,SAAU,CAAC,GAAG,EAAS,SAAU,CAAK,CAAE,EAClE,CAAE,GAAI,GAAM,KAAM,CAAK,CAChC,CAEA,OAAO,EAAQ,EAAQ,GAAa,EAAe,EAAU,EAAI,EAAc,EAAe,CAAC,CAAC,CAClG,CAUA,SAAgB,EAAgB,EAA4B,EAA0B,CACpF,IAAK,IAAI,EAAI,EAAG,EAAI,EAAM,OAAQ,IAAK,CACrC,IAAM,EAAS,EAAM,GACrB,GAAI,IAAW,IAAA,GAAW,SAE1B,IAAM,EAAa,EAAO,SAAS,UAAW,GAAU,EAAM,KAAO,CAAE,EACvE,GAAI,IAAe,GAAI,CACrB,IAAM,EAAQ,EAAO,SAAS,GAC9B,GAAI,IAAU,IAAA,GAAW,SACzB,IAAM,EAAY,EAAO,SAAS,MAAM,EACxC,EAAU,OAAO,EAAY,CAAC,EAC9B,IAAM,EAAO,EAAM,MAAM,EAGzB,MAFA,GAAK,GAAK,CAAE,GAAG,EAAQ,SAAU,CAAU,EAC3C,EAAK,OAAO,EAAI,EAAG,EAAG,CAAK,EACpB,CAAE,GAAI,GAAM,KAAM,CAAK,CAChC,CAEA,IAAM,EAAS,EAAgB,EAAO,SAAU,CAAE,EAClD,GAAI,EAAO,GAAI,CACb,IAAM,EAAO,EAAM,MAAM,EAEzB,MADA,GAAK,GAAK,CAAE,GAAG,EAAQ,SAAU,EAAO,IAAK,EACtC,CAAE,GAAI,GAAM,KAAM,CAAK,CAChC,CACF,CACA,OAAO,CACT,CAGA,SAAgB,EAAa,EAA4B,EAA6B,CACpF,IAAK,IAAM,KAAQ,EAAO,CACxB,GAAI,EAAK,KAAO,EAAI,OAAO,EAC3B,IAAM,EAAQ,EAAa,EAAK,SAAU,CAAE,EAC5C,GAAI,IAAU,KAAM,OAAO,CAC7B,CACA,OAAO,IACT,CAqDA,SAAgB,EACd,EACA,EACiB,CACjB,IAAM,EAAuB,CAAC,EAExB,GAAQ,EAA4B,EAAyB,IAAwB,CACzF,EAAM,SAAS,EAAM,IAAU,CAC7B,IAAM,EAAO,EAAK,KACd,GAAM,OAAS,UAAY,EAAK,SAAW,EAAO,QAAU,EAAK,KAAO,EAAO,IACjF,EAAI,KAAK,CAAE,KAAM,EAAM,WAAU,QAAO,OAAM,CAAC,EAEjD,EAAK,EAAK,SAAU,EAAK,GAAI,EAAQ,CAAC,CACxC,CAAC,CACH,EAGA,OADA,EAAK,EAAO,KAAM,CAAC,EACZ,CACT,CAGA,SAAgB,EAAe,EAA4B,EAAwB,CACjF,IAAM,EAAkB,CAAC,EACzB,IAAK,IAAM,KAAQ,EAAO,CACxB,GAAI,EAAK,KAAO,EAAI,SACpB,IAAM,EAAW,EAAe,EAAK,SAAU,CAAE,EACjD,EAAI,KAAK,IAAa,EAAK,SAAW,EAAO,CAAE,GAAG,EAAM,UAAS,CAAC,CACpE,CAGA,OAAO,EAAI,SAAW,EAAM,QAAU,EAAI,OAAO,EAAM,IAAM,IAAS,EAAM,EAAE,EACzE,EACD,CACN,CAuBA,SAAgB,EACd,EACA,EACA,EACY,CACZ,IAAI,EAAU,GACR,EAAM,EAAM,IAAK,GAAS,CAC9B,GAAI,EAAK,KAAO,EAGd,OAFI,IAAS,EAAa,GAC1B,EAAU,GACH,GAET,IAAM,EAAW,EAAgB,EAAK,SAAU,EAAI,CAAI,EAGxD,OAFI,IAAa,EAAK,SAAiB,GACvC,EAAU,GACH,CAAE,GAAG,EAAM,UAAS,EAC7B,CAAC,EAGD,OAAO,EAAU,EAAO,CAC1B,CAOA,SAAgB,EACd,EACA,EACA,EACY,CAGZ,OAFI,IAAY,KAAa,CAAC,GAAG,EAAO,CAAI,EAC3B,EAAY,EAAO,EAAM,CAC5B,GAAK,CAAC,GAAG,EAAO,CAAI,CACpC,CAEA,SAAS,EACP,EACA,EACA,EACmB,CACnB,IAAM,EAAQ,EAAM,UAAW,GAAS,EAAK,KAAO,CAAO,EAC3D,GAAI,IAAU,GAAI,CAChB,IAAM,EAAO,EAAM,MAAM,EAEzB,OADA,EAAK,OAAO,EAAQ,EAAG,EAAG,CAAI,EACvB,CACT,CACA,IAAK,IAAI,EAAI,EAAG,EAAI,EAAM,OAAQ,IAAK,CACrC,IAAM,EAAO,EAAM,GACnB,GAAI,IAAS,IAAA,GAAW,SACxB,IAAM,EAAW,EAAY,EAAK,SAAU,EAAM,CAAO,EACzD,GAAI,IAAa,KAAM,CACrB,IAAM,EAAO,EAAM,MAAM,EAEzB,MADA,GAAK,GAAK,CAAE,GAAG,EAAM,UAAS,EACvB,CACT,CACF,CACA,OAAO,IACT,CAUA,SAAS,EACP,EACA,EACc,CACd,IAAK,IAAI,EAAI,EAAG,EAAI,EAAM,OAAQ,IAAK,CACrC,IAAM,EAAO,EAAM,GACnB,GAAI,IAAS,IAAA,GAAW,SACxB,IAAM,EAAS,EAAG,EAAK,QAAQ,EAC/B,GAAI,EAAO,GAAI,CACb,IAAM,EAAO,EAAM,MAAM,EAEzB,MADA,GAAK,GAAK,CAAE,GAAG,EAAM,SAAU,EAAO,IAAK,EACpC,CAAE,GAAI,GAAM,KAAM,CAAK,CAChC,CACA,GAAI,EAAO,SAAW,YAAa,OAAO,CAC5C,CACA,OAAO,CACT"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"seed.d.mts","names":[],"sources":["../src/seed.ts"],"mappings":";;AA4MA;;;;;;;;;AAEqB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAFC,YAAA,CAAa,OAAA;EACjC,aAAA;AAAA,IACE,OAAO;EAAG,OAAA;AAAA"}