@murumets-ee/menus 0.0.0-preview-20261014110621
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +94 -0
- package/dist/admin.d.mts +363 -0
- package/dist/admin.d.mts.map +1 -0
- package/dist/admin.mjs +2 -0
- package/dist/admin.mjs.map +1 -0
- package/dist/columns-DwDukx_r.mjs +2 -0
- package/dist/columns-DwDukx_r.mjs.map +1 -0
- package/dist/en-BcniJZiB.mjs +2 -0
- package/dist/en-BcniJZiB.mjs.map +1 -0
- package/dist/entity-BC8r_5xy.mjs +2 -0
- package/dist/entity-BC8r_5xy.mjs.map +1 -0
- package/dist/et-BL3ZuuFV.mjs +2 -0
- package/dist/et-BL3ZuuFV.mjs.map +1 -0
- package/dist/i18n.d.mts +16 -0
- package/dist/i18n.d.mts.map +1 -0
- package/dist/i18n.mjs +2 -0
- package/dist/i18n.mjs.map +1 -0
- package/dist/index.d.mts +75 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +1 -0
- package/dist/menu-entity-types-BnKnKbif.mjs +2 -0
- package/dist/menu-entity-types-BnKnKbif.mjs.map +1 -0
- package/dist/menu-item-schema-_Nj03ytp.d.mts +296 -0
- package/dist/menu-item-schema-_Nj03ytp.d.mts.map +1 -0
- package/dist/menu-resolve-context-DLIUP8RY.mjs +2 -0
- package/dist/menu-resolve-context-DLIUP8RY.mjs.map +1 -0
- package/dist/messages/en.json +22 -0
- package/dist/messages/et.json +22 -0
- package/dist/messages/ru.json +22 -0
- package/dist/resolve.d.mts +205 -0
- package/dist/resolve.d.mts.map +1 -0
- package/dist/resolve.mjs +2 -0
- package/dist/resolve.mjs.map +1 -0
- package/dist/ru-D8QNW6XE.mjs +2 -0
- package/dist/ru-D8QNW6XE.mjs.map +1 -0
- package/dist/schema.d.mts +280 -0
- package/dist/schema.d.mts.map +1 -0
- package/dist/schema.mjs +2 -0
- package/dist/schema.mjs.map +1 -0
- package/dist/seed.d.mts +67 -0
- package/dist/seed.d.mts.map +1 -0
- package/dist/seed.mjs +2 -0
- package/dist/seed.mjs.map +1 -0
- package/package.json +62 -0
|
@@ -0,0 +1 @@
|
|
|
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":"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"}
|
package/dist/seed.d.mts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
//#region src/seed.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* The `main` menu seeder — creates the one menu row a site's navigation needs
|
|
4
|
+
* before anything can render it.
|
|
5
|
+
*
|
|
6
|
+
* Contributed as `menus:main` from the `menus()` plugin factory (which lives
|
|
7
|
+
* in `@murumets-ee/menus-ui`, to avoid a circular build dep) and reached
|
|
8
|
+
* through the `@murumets-ee/menus/seed` subpath. Its own build entry so the
|
|
9
|
+
* lazy import from the plugin keeps `@murumets-ee/core/clients` — which
|
|
10
|
+
* carries `import 'server-only'` — off the config-load path.
|
|
11
|
+
*
|
|
12
|
+
* ## Why the row matters more than what is in it
|
|
13
|
+
*
|
|
14
|
+
* A fresh install has ZERO `menu` rows, so `getMenu('main', …)` resolves
|
|
15
|
+
* `null`, `/v1/_menu?handle=main` answers 404, and every consumer treats that
|
|
16
|
+
* correctly as "this site has no such menu" — the admin canvas renders an
|
|
17
|
+
* empty nav, the live site renders an empty nav, and nothing anywhere is
|
|
18
|
+
* wrong. It just looks exactly like a bug. `PRIMARY_MENU_HANDLE` is `'main'`,
|
|
19
|
+
* so even a template's hand-written fallback header reads that handle and
|
|
20
|
+
* finds nothing. Creating the row is what makes the handle resolvable and the
|
|
21
|
+
* editor reachable; the items are a demo convenience on top.
|
|
22
|
+
*
|
|
23
|
+
* ## Why the items' source entity is CONFIGURED, not assumed
|
|
24
|
+
*
|
|
25
|
+
* This seeder started life in `apps/admin`, where it could
|
|
26
|
+
* `import { Page } from '@/entities'` and fill the menu with links to
|
|
27
|
+
* published pages. A package cannot: `page` is an APP entity, and nothing in
|
|
28
|
+
* `packages/*` resolves `'page'` against `app.entities` — the menus editor
|
|
29
|
+
* itself derives its entity-type picker by ITERATING the registry and
|
|
30
|
+
* filtering on `searchable()`, never by naming a type. Assuming `page` here
|
|
31
|
+
* would make this the first package in the repo to break that.
|
|
32
|
+
*
|
|
33
|
+
* So the name arrives the way `@murumets-ee/content`'s chrome provider gets
|
|
34
|
+
* its document entities: as plugin config from the app —
|
|
35
|
+
* `menus({ seedItemsFrom: 'page' })` — resolved against `app.entities` at run
|
|
36
|
+
* time and degrading to an EMPTY tree when unset or unregistered. That is the
|
|
37
|
+
* same "make the thing exist, leave it blank" shape the chrome seeder uses,
|
|
38
|
+
* and it means the seeder is never a button that does nothing: it always
|
|
39
|
+
* creates the row that was missing.
|
|
40
|
+
*
|
|
41
|
+
* ## What it assumes about its caller
|
|
42
|
+
*
|
|
43
|
+
* An admin context — `runSeederAction` and `lumi seed` both establish one,
|
|
44
|
+
* which is what makes `getApp()` and the entity clients resolve. Auth, the
|
|
45
|
+
* admin-only role check and the production gate live in those wrappers.
|
|
46
|
+
*/
|
|
47
|
+
/**
|
|
48
|
+
* Create the `main` menu if this site has none.
|
|
49
|
+
*
|
|
50
|
+
* Idempotent on two levels rather than by a flag. It FINDS first — by the
|
|
51
|
+
* HANDLE, not by "is there any menu at all", because a site may well have a
|
|
52
|
+
* `footer` menu and no `main`, and a blanket existence check would leave the
|
|
53
|
+
* one handle everything asks for missing. And it treats a unique-violation on
|
|
54
|
+
* the insert as `{ created: 0 }`, which covers the window between the find and
|
|
55
|
+
* the create when two runs overlap.
|
|
56
|
+
*
|
|
57
|
+
* @param options.seedItemsFrom entity name whose published rows become the
|
|
58
|
+
* menu items. Omitted → an empty menu. See the module docblock.
|
|
59
|
+
*/
|
|
60
|
+
declare function seedMainMenu(options?: {
|
|
61
|
+
seedItemsFrom?: string | undefined;
|
|
62
|
+
}): Promise<{
|
|
63
|
+
created: number;
|
|
64
|
+
}>;
|
|
65
|
+
//#endregion
|
|
66
|
+
export { seedMainMenu };
|
|
67
|
+
//# sourceMappingURL=seed.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
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"}
|
package/dist/seed.mjs
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{t as e}from"./entity-BC8r_5xy.mjs";import{getApp as t}from"@murumets-ee/core";import{asc as n,eq as r}from"drizzle-orm";import{createAdminClient as i}from"@murumets-ee/core/clients";function a(e){let t=new WeakSet,n=e;for(;typeof n==`object`&&n;){if(t.has(n))return!1;t.add(n);let e=n;if(typeof e.code==`string`&&e.code===`23505`)return!0;n=e.cause}return!1}const o=`main`;async function s(e){if(!e)return[];let a=t().entities.get(e);if(!a)return[];let o=i(a),s=o.getTable(),c=s.status,l=s.createdAt,u=s.id;if(!c||!l||!u)return[];let d=c,f=l,p=u;return(await o.findMany({where:r(d,`published`),orderBy:[n(f),n(p)],limit:6})).map(t=>({id:crypto.randomUUID(),kind:`link`,link:{kind:`entity`,entity:e,id:t.id},label:null,children:[]}))}async function c(t){let n=i(e),c=n.getTable().handle;if(!c)throw Error("menus:main — the `menu` table has no `handle` column. This is @murumets-ee/menus' own entity, so this indicates a broken build or a stale dist rather than a project configuration problem.");let l=c;if((await n.findMany({where:r(l,o),limit:1})).length>0)return{created:0};let u=await s(t?.seedItemsFrom);try{await n.create({handle:o,title:`Main navigation`,tree:u,status:`published`,publishedAt:new Date})}catch(e){if(!a(e))throw e;return{created:0}}return{created:1}}export{c as seedMainMenu};
|
|
2
|
+
//# sourceMappingURL=seed.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"seed.mjs","names":[],"sources":["../src/seed.ts"],"sourcesContent":["/**\n * The `main` menu seeder — creates the one menu row a site's navigation needs\n * before anything can render it.\n *\n * Contributed as `menus:main` from the `menus()` plugin factory (which lives\n * in `@murumets-ee/menus-ui`, to avoid a circular build dep) and reached\n * through the `@murumets-ee/menus/seed` subpath. Its own build entry so the\n * lazy import from the plugin keeps `@murumets-ee/core/clients` — which\n * carries `import 'server-only'` — off the config-load path.\n *\n * ## Why the row matters more than what is in it\n *\n * A fresh install has ZERO `menu` rows, so `getMenu('main', …)` resolves\n * `null`, `/v1/_menu?handle=main` answers 404, and every consumer treats that\n * correctly as \"this site has no such menu\" — the admin canvas renders an\n * empty nav, the live site renders an empty nav, and nothing anywhere is\n * wrong. It just looks exactly like a bug. `PRIMARY_MENU_HANDLE` is `'main'`,\n * so even a template's hand-written fallback header reads that handle and\n * finds nothing. Creating the row is what makes the handle resolvable and the\n * editor reachable; the items are a demo convenience on top.\n *\n * ## Why the items' source entity is CONFIGURED, not assumed\n *\n * This seeder started life in `apps/admin`, where it could\n * `import { Page } from '@/entities'` and fill the menu with links to\n * published pages. A package cannot: `page` is an APP entity, and nothing in\n * `packages/*` resolves `'page'` against `app.entities` — the menus editor\n * itself derives its entity-type picker by ITERATING the registry and\n * filtering on `searchable()`, never by naming a type. Assuming `page` here\n * would make this the first package in the repo to break that.\n *\n * So the name arrives the way `@murumets-ee/content`'s chrome provider gets\n * its document entities: as plugin config from the app —\n * `menus({ seedItemsFrom: 'page' })` — resolved against `app.entities` at run\n * time and degrading to an EMPTY tree when unset or unregistered. That is the\n * same \"make the thing exist, leave it blank\" shape the chrome seeder uses,\n * and it means the seeder is never a button that does nothing: it always\n * creates the row that was missing.\n *\n * ## What it assumes about its caller\n *\n * An admin context — `runSeederAction` and `lumi seed` both establish one,\n * which is what makes `getApp()` and the entity clients resolve. Auth, the\n * admin-only role check and the production gate live in those wrappers.\n */\n\nimport { getApp } from '@murumets-ee/core'\nimport { createAdminClient } from '@murumets-ee/core/clients'\nimport { asc, eq } from 'drizzle-orm'\n// `AdminClient.getTable()` returns `PgTableWithColumns<any>` — Drizzle cannot\n// type a dynamically-generated column schema — so a column read off it is\n// `any` until it is named. `PgColumn` is what every WHERE/ORDER BY helper in\n// this repo actually wants, and naming it is the difference between a typed\n// column reference and one that silently counts against type coverage. Same\n// idiom as `@murumets-ee/queue`'s `trash-purge.ts`.\nimport type { PgColumn } from 'drizzle-orm/pg-core'\nimport { Menu } from './entity.js'\nimport type { MenuItem } from './menu-item-schema.js'\n\n/**\n * The exact shape this seeder writes — every property REQUIRED.\n *\n * `MenuItem` cannot be used directly as the value handed to\n * `AdminClient.create`. Its optional members are declared `link?: … |\n * undefined` for `exactOptionalPropertyTypes`, so its implicit index signature\n * is `MenuLinkValue | undefined`, and `undefined` is not a member of\n * `JsonValue` (`{ [key: string]: JsonValue }`). The same code compiled in\n * `apps/admin` only because that app does not extend the root tsconfig and so\n * never had `exactOptionalPropertyTypes` applied to it (seeding plan F011) —\n * moving the file into a package is what surfaced it.\n *\n * A cast would silence it. Narrowing the constructed shape instead means the\n * value is assignable on its own terms: this seeder emits entity links and\n * nothing else, so every field it writes is one it always writes. The\n * `satisfies MenuItem[]` below is what keeps that honest — if the tree schema\n * gains a required member, this fails to compile rather than writing a row the\n * validator will reject at run time.\n */\ntype SeededMenuItem = {\n id: string\n kind: 'link'\n link: { kind: 'entity'; entity: string; id: string }\n label: null\n children: SeededMenuItem[]\n}\n\n/**\n * Postgres SQLSTATE `23505` (`unique_violation`) — surfaces from postgres-js as\n * an Error with `code === '23505'`; Drizzle re-wraps it but preserves `code` on\n * the `cause` chain, so walk both. `WeakSet` guards a self-referential cause\n * cycle.\n *\n * Same shape as `@murumets-ee/admin-ui`'s nav-layout / locale / message route\n * handlers, which is the point of copying it rather than inventing one: this is\n * the repo's established way to tell \"somebody else won the race\" from \"the\n * write is wrong\", and a second spelling of it would be a second thing to keep\n * correct.\n */\nfunction isUniqueViolation(err: unknown): boolean {\n const seen = new WeakSet<object>()\n let cursor = err\n while (typeof cursor === 'object' && cursor !== null) {\n if (seen.has(cursor)) return false\n seen.add(cursor)\n const e = cursor as { code?: unknown; cause?: unknown }\n if (typeof e.code === 'string' && e.code === '23505') return true\n cursor = e.cause\n }\n return false\n}\n\n/** The handle every consumer in this repo asks for by default. */\nconst MAIN_HANDLE = 'main'\n\n/**\n * How many entity rows become menu items.\n *\n * A seeded demo menu, not a sitemap — and `findMany` with no limit on a site\n * that has been used for a while would put every page ever written into the\n * header. Six is a plausible top-level navigation.\n */\nconst MAX_SEEDED_ITEMS = 6\n\n/**\n * Build the seeded item tree from the app's configured source entity.\n *\n * Returns `[]` — a menu with no items, which is a valid and useful menu —\n * whenever the source cannot be used, rather than throwing or skipping the\n * menu itself. Three distinct reasons land in that branch and none of them is\n * an error:\n *\n * - the app passed no `seedItemsFrom` (the default: no opinion about which\n * entity is \"pages\");\n * - it named an entity this app never registered;\n * - the named entity has no `status` column, so it is not `publishable()` and\n * the publish filter below cannot be applied. Filling a nav with rows whose\n * public visibility we cannot establish is worse than filling it with none.\n */\nasync function buildSeededItems(entityName: string | undefined): Promise<SeededMenuItem[]> {\n if (!entityName) return []\n const entity = getApp().entities.get(entityName)\n if (!entity) return []\n\n const client = createAdminClient(entity)\n // Read as an index into an unknown-valued record, THEN narrowed to\n // `PgColumn` once presence is established. Going straight to `PgColumn`\n // would assert a shape for an entity chosen by app config at run time —\n // exactly the thing that cannot be known here.\n const table = client.getTable() as Record<string, unknown>\n const status = table.status\n const createdAt = table.createdAt\n const id = table.id\n if (!status || !createdAt || !id) return []\n const statusCol = status as PgColumn\n const createdAtCol = createdAt as PgColumn\n const idCol = id as PgColumn\n\n // `orderBy` and the published filter are both load-bearing rather than tidy:\n //\n // - without an order, WHICH six rows become the navigation is whatever the\n // planner returns, so two installs seeded from the same content get\n // different menus and neither is reproducible;\n // - without the filter a DRAFT row becomes an item whose canonical path\n // does not resolve for an anonymous reader, so `getMenu` drops it (fail\n // closed) and the seeded menu silently has fewer items than it created.\n //\n // `id` breaks the tie: `createdAt` alone is not a total order, and two rows\n // created in the same millisecond would leave the planner to decide — which\n // is the non-reproducibility the ordering exists to remove.\n const rows = await client.findMany({\n where: eq(statusCol, 'published'),\n orderBy: [asc(createdAtCol), asc(idCol)],\n limit: MAX_SEEDED_ITEMS,\n })\n\n return rows.map((row) => ({\n // A stable id per item is the DND identity and the audit-diff anchor. It\n // is minted here rather than left absent because the write schema requires\n // one and the editor mints one client-side for every item it creates.\n id: crypto.randomUUID(),\n kind: 'link',\n link: { kind: 'entity', entity: entityName, id: row.id as string },\n // `null` means \"use the LIVE entity title, per locale\", so a seeded menu is\n // correct in every locale the rows are translated into and stays correct\n // when someone renames one. Hardcoded labels would be an English demo menu\n // on an Estonian site, and stale the first time a title changed.\n label: null,\n children: [],\n })) satisfies MenuItem[]\n}\n\n/**\n * Create the `main` menu if this site has none.\n *\n * Idempotent on two levels rather than by a flag. It FINDS first — by the\n * HANDLE, not by \"is there any menu at all\", because a site may well have a\n * `footer` menu and no `main`, and a blanket existence check would leave the\n * one handle everything asks for missing. And it treats a unique-violation on\n * the insert as `{ created: 0 }`, which covers the window between the find and\n * the create when two runs overlap.\n *\n * @param options.seedItemsFrom entity name whose published rows become the\n * menu items. Omitted → an empty menu. See the module docblock.\n */\nexport async function seedMainMenu(options?: {\n seedItemsFrom?: string | undefined\n}): Promise<{ created: number }> {\n const menuClient = createAdminClient(Menu)\n\n // Via `Record<string, unknown>`, not a direct `.handle` read: `getTable()` is\n // `PgTableWithColumns<any>`, so the PROPERTY ACCESS is `any` before any cast\n // can apply to it. Indexing an unknown-valued record makes the read `unknown`\n // and the narrowing explicit — which is also what keeps this package at 100%\n // type coverage instead of quietly spending a fraction of a percent.\n const menuTable = menuClient.getTable() as Record<string, unknown>\n const handle = menuTable.handle\n // Presence-checked before the cast, like the three reads in\n // `buildSeededItems` — but THROWING where those return an empty tree, and\n // the difference is the point rather than an inconsistency. Those read an\n // entity the APP chose, where \"no `status` column\" means \"this project models\n // content differently\" and degrading is correct. `Menu` is this package's\n // own entity: a missing `handle` is not a project's choice, it is this\n // package being broken, and reporting `created: 0` would file that under\n // \"nothing to do\".\n if (!handle) {\n throw new Error(\n \"menus:main — the `menu` table has no `handle` column. This is @murumets-ee/menus' own \" +\n 'entity, so this indicates a broken build or a stale dist rather than a project ' +\n 'configuration problem.',\n )\n }\n const handleCol = handle as PgColumn\n const existing = await menuClient.findMany({\n where: eq(handleCol, MAIN_HANDLE),\n limit: 1,\n })\n if (existing.length > 0) return { created: 0 }\n\n const tree = await buildSeededItems(options?.seedItemsFrom)\n\n try {\n await menuClient.create({\n handle: MAIN_HANDLE,\n title: 'Main navigation',\n tree,\n // Published, deliberately: `getMenu` reads through a publish-filtered\n // client, so a draft menu is indistinguishable from an absent one to\n // every reader, and the only symptom is navigation that never appears\n // until someone finds the row.\n status: 'published',\n // Nothing stamps this on create — `publishable()`'s auto-stamp lives in\n // `beforeUpdate` — so a row created as published would carry a null\n // publish timestamp forever.\n publishedAt: new Date(),\n })\n } catch (err) {\n // The find-then-create window. Two runs — the Seed page and `lumi seed`, or\n // two operators — can both see no `main` row and both attempt the insert.\n // `uq_menu_handle` (a partial unique index, live rows only) is what keeps\n // that from producing two menus, so the data was never at risk; what the\n // loser got was a raw SQLSTATE from the driver, which names nothing an\n // operator can act on. `{ created: 0 }` is the honest answer and the same\n // one the find above returns: the row this seeder exists to guarantee is\n // there, and this call is not what put it there.\n //\n // ONLY 23505 — every other create failure propagates. A blanket catch here\n // would report success for a validation error or a dead connection, which\n // is how a seeder that silently does nothing gets shipped.\n //\n // No equivalent is needed in `content:site-chrome`: those entities are\n // `kind: 'singleton'` and `AdminClient.create` already runs the cardinality\n // check under SERIALIZABLE with a retry, so its loser gets a clear\n // `SingletonCardinalityError` rather than an opaque code.\n if (!isUniqueViolation(err)) throw err\n return { created: 0 }\n }\n\n return { created: 1 }\n}\n"],"mappings":"6LAkGA,SAAS,EAAkB,EAAuB,CAChD,IAAM,EAAO,IAAI,QACb,EAAS,EACb,KAAO,OAAO,GAAW,UAAY,GAAiB,CACpD,GAAI,EAAK,IAAI,CAAM,EAAG,MAAO,GAC7B,EAAK,IAAI,CAAM,EACf,IAAM,EAAI,EACV,GAAI,OAAO,EAAE,MAAS,UAAY,EAAE,OAAS,QAAS,MAAO,GAC7D,EAAS,EAAE,KACb,CACA,MAAO,EACT,CAGA,MAAM,EAAc,OA0BpB,eAAe,EAAiB,EAA2D,CACzF,GAAI,CAAC,EAAY,MAAO,CAAC,EACzB,IAAM,EAAS,EAAO,CAAC,CAAC,SAAS,IAAI,CAAU,EAC/C,GAAI,CAAC,EAAQ,MAAO,CAAC,EAErB,IAAM,EAAS,EAAkB,CAAM,EAKjC,EAAQ,EAAO,SAAS,EACxB,EAAS,EAAM,OACf,EAAY,EAAM,UAClB,EAAK,EAAM,GACjB,GAAI,CAAC,GAAU,CAAC,GAAa,CAAC,EAAI,MAAO,CAAC,EAC1C,IAAM,EAAY,EACZ,EAAe,EACf,EAAQ,EAoBd,OAAO,MANY,EAAO,SAAS,CACjC,MAAO,EAAG,EAAW,WAAW,EAChC,QAAS,CAAC,EAAI,CAAY,EAAG,EAAI,CAAK,CAAC,EACvC,MAAO,CACT,CAAC,EAAA,CAEW,IAAK,IAAS,CAIxB,GAAI,OAAO,WAAW,EACtB,KAAM,OACN,KAAM,CAAE,KAAM,SAAU,OAAQ,EAAY,GAAI,EAAI,EAAa,EAKjE,MAAO,KACP,SAAU,CAAC,CACb,EAAE,CACJ,CAeA,eAAsB,EAAa,EAEF,CAC/B,IAAM,EAAa,EAAkB,CAAI,EAQnC,EADY,EAAW,SACN,CAAC,CAAC,OASzB,GAAI,CAAC,EACH,MAAU,MACR,6LAGF,EAEF,IAAM,EAAY,EAKlB,IAAI,MAJmB,EAAW,SAAS,CACzC,MAAO,EAAG,EAAW,CAAW,EAChC,MAAO,CACT,CAAC,EAAA,CACY,OAAS,EAAG,MAAO,CAAE,QAAS,CAAE,EAE7C,IAAM,EAAO,MAAM,EAAiB,GAAS,aAAa,EAE1D,GAAI,CACF,MAAM,EAAW,OAAO,CACtB,OAAQ,EACR,MAAO,kBACP,OAKA,OAAQ,YAIR,YAAa,IAAI,IACnB,CAAC,CACH,OAAS,EAAK,CAkBZ,GAAI,CAAC,EAAkB,CAAG,EAAG,MAAM,EACnC,MAAO,CAAE,QAAS,CAAE,CACtB,CAEA,MAAO,CAAE,QAAS,CAAE,CACtB"}
|
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@murumets-ee/menus",
|
|
3
|
+
"version": "0.0.0-preview-20261014110621",
|
|
4
|
+
"license": "Elastic-2.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./dist/index.d.mts",
|
|
9
|
+
"import": "./dist/index.mjs"
|
|
10
|
+
},
|
|
11
|
+
"./seed": {
|
|
12
|
+
"types": "./dist/seed.d.mts",
|
|
13
|
+
"import": "./dist/seed.mjs"
|
|
14
|
+
},
|
|
15
|
+
"./schema": {
|
|
16
|
+
"types": "./dist/schema.d.mts",
|
|
17
|
+
"import": "./dist/schema.mjs"
|
|
18
|
+
},
|
|
19
|
+
"./admin": {
|
|
20
|
+
"types": "./dist/admin.d.mts",
|
|
21
|
+
"import": "./dist/admin.mjs"
|
|
22
|
+
},
|
|
23
|
+
"./resolve": {
|
|
24
|
+
"types": "./dist/resolve.d.mts",
|
|
25
|
+
"import": "./dist/resolve.mjs"
|
|
26
|
+
},
|
|
27
|
+
"./i18n": {
|
|
28
|
+
"types": "./dist/i18n.d.mts",
|
|
29
|
+
"import": "./dist/i18n.mjs"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"dist"
|
|
34
|
+
],
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"drizzle-orm": "^0.45.2",
|
|
37
|
+
"zod": "^3.24.1",
|
|
38
|
+
"@murumets-ee/blocks": "0.0.0-preview-20261014110621",
|
|
39
|
+
"@murumets-ee/core": "0.0.0-preview-20261014110621",
|
|
40
|
+
"@murumets-ee/content": "0.0.0-preview-20261014110621",
|
|
41
|
+
"@murumets-ee/entity": "0.0.0-preview-20261014110621"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@types/node": "^20.19.42",
|
|
45
|
+
"server-only": "^0.0.1",
|
|
46
|
+
"tsdown": "^0.22.2",
|
|
47
|
+
"typescript": "^5.7.3",
|
|
48
|
+
"vitest": "^2.1.8",
|
|
49
|
+
"@murumets-ee/content-api": "0.0.0-preview-20261014110621",
|
|
50
|
+
"@murumets-ee/db": "0.0.0-preview-20261014110621"
|
|
51
|
+
},
|
|
52
|
+
"typeCoverage": {
|
|
53
|
+
"atLeast": 100
|
|
54
|
+
},
|
|
55
|
+
"scripts": {
|
|
56
|
+
"build": "tsdown && cp -r src/messages dist/messages",
|
|
57
|
+
"dev": "tsdown --watch",
|
|
58
|
+
"test": "vitest run",
|
|
59
|
+
"test:watch": "vitest",
|
|
60
|
+
"test:integration": "vitest run --config vitest.integration.config.ts"
|
|
61
|
+
}
|
|
62
|
+
}
|