import type { Kysely } from 'kysely'; import type { BatchStatement } from '../db/batch.js'; import type { Database, MenuItemRow, MenuRow, MenuTargetType } from '../db/schema.js'; /** * Menus. * * A menu item references its target instead of storing a URL, so the path is resolved at render * time. That is the whole design: a moved page keeps its place in the navigation, and an * unpublished one leaves the public menu without anyone editing the menu. */ export declare class MenuError extends Error { readonly code: 'not_found' | 'duplicate_api_id' | 'invalid_target' | 'cycle' | 'wrong_menu'; name: string; constructor(message: string, code?: 'not_found' | 'duplicate_api_id' | 'invalid_target' | 'cycle' | 'wrong_menu'); } export declare function listMenus(db: Kysely): Promise; export declare function getMenu(db: Kysely, id: string): Promise; export declare function getMenuByApiId(db: Kysely, apiId: string): Promise; export interface MenuInput { api_id: string; name: string; description?: string | null; } export declare function createMenu(db: Kysely, input: MenuInput): Promise; /** `api_id` is immutable — templates ask for menus by it. */ export declare function updateMenu(db: Kysely, id: string, input: Partial>): Promise; export declare function deleteMenu(db: Kysely, id: string): Promise; export declare function listMenuItems(db: Kysely, menuId: string): Promise; export declare function getMenuItem(db: Kysely, id: string): Promise; export interface MenuItemInput { targetType: MenuTargetType; label?: string | null; contentItemId?: string | null; termId?: string | null; url?: string | null; parentId?: string | null; openInNewTab?: boolean; /** * `rel="nofollow"`. The editor's choice, unlike the new-tab pair, which `menuRel` adds whether or * not anybody asked — see `rel.ts` for why only one of the two is storable. */ noFollow?: boolean; } export declare function createMenuItem(db: Kysely, menuId: string, input: MenuItemInput): Promise; export declare function updateMenuItem(handle: { db: Kysely; batch(statements: BatchStatement[]): Promise; }, id: string, input: Partial): Promise; /** Children go with the item — a dropdown's contents have no meaning without the thing they hang off. */ export declare function deleteMenuItem(db: Kysely, id: string): Promise; /** Move an item up or down among its siblings. */ export declare function reorderMenuItems(handle: { db: Kysely; batch(statements: BatchStatement[]): Promise; }, orderedIds: string[]): Promise; export interface ResolvedMenuItem { id: string; label: string; /** Null when the target is gone or unpublished. Public rendering skips these. */ href: string | null; /** * The content item this entry references, when it references one. * * Not the same thing as `href`: the href is that row's *current* path, and this is the identity * that survives the row moving. Used for cache tagging, where the question is "which row's edit * changes this menu" and a path cannot answer it. */ contentItemId: string | null; openInNewTab: boolean; noFollow: boolean; /** * The composed `rel`, or null when the entry needs none. * * Carried **beside** the two flags rather than instead of them, and it is not redundancy. The * flags are what the admin edits and what round-trips into a write; `rel` is what goes in the * markup, and it holds one token pair no flag corresponds to — `noopener noreferrer`, which * `menuRel` adds on a new-tab link whether or not anybody asked. Leaving a consumer to assemble * it from booleans is what produced `rel="noopener"` with no `noreferrer` on the first real site * to render one: not a wrong `rel`, a nearly right one that looks deliberate. */ rel: string | null; targetType: MenuTargetType; /** * Why there is no href, for the admin. * * - `deleted` — the referenced row is gone. * - `unpublished` — it exists but is not visible to the public. * - `no_route` — a term the site publishes no page for. Not an error: most taxonomies are * internal classification, and whether a term has a public URL is the site's decision. */ brokenReason: 'deleted' | 'unpublished' | 'no_route' | null; /** * The term this entry points at, for a `term` entry whose target still exists. * * Carried alongside the href rather than being consumed by `termHref` and discarded, because a * function cannot cross an HTTP boundary. The delivery API returns *unresolved* term targets and * the consumer applies its own `termHref` on the other side — which is the only way to keep * "Taproot has no opinion about term URLs" true once the site is a separate deployment. Without * this field the delivered menu would either have to carry the CMS's guess at a URL or drop term * entries entirely. */ term: TermLinkTarget | null; children: ResolvedMenuItem[]; } /** A term, as handed to a site's `termHref` resolver. */ export interface TermLinkTarget { id: string; name: string; slug: string; taxonomyApiId: string; } export interface ResolveMenuOptions { /** * Drop entries whose target is not publicly visible. On for site rendering, off for the admin, * which needs to show a broken entry in order to let someone fix it. */ publishedOnly?: boolean; /** * Where a term's page lives on this site, or null if it has none. * * **Taproot has no opinion about term URLs**, which is why this is a callback rather than a * convention baked in here. Plenty of taxonomies exist purely to classify content — a review * status, an internal owner, an audience segment — and publishing a page per term of those * would be wrong. Others genuinely want archives. Which is which depends on the routes the site * actually serves, so it is the site's call, not the CMS's. * * Omit it and term entries resolve to no href with `brokenReason: 'no_route'`, which is the * correct default: a CMS that invented URLs its host does not serve would produce menu links * that 404. * * `termArchivePath` is one ready-made convention to pass through, if it suits. */ termHref?: (term: TermLinkTarget) => string | null; } /** * Resolve a menu into a tree of links. * * Everything is read in three queries regardless of menu size — the items, then the referenced * content, then the referenced terms — rather than resolving each entry as it is walked. A menu is * rendered on every page of the site, so an N+1 here would be an N+1 everywhere. */ export declare function resolveMenu(db: Kysely, apiId: string, options?: ResolveMenuOptions): Promise; /** * One ready-made term-URL convention: `/{taxonomy}/{term}`. * * Offered, not imposed. Nothing in Taproot calls this — a site opts in by passing it (or anything * else) as `resolveMenu`'s `termHref`, and by serving the matching route. `apps/web` does both and * is the worked example; a site wanting `/topics/{term}`, term pages for one taxonomy only, or * none at all, writes its own resolver instead. */ export declare function termArchivePath(taxonomyApiId: string, termSlug: string): string;