import type { Kysely } from 'kysely'; import type { ContentStatus, ContentTypeKind, Database, FieldRow } from '../db/schema.js'; import type { StorageAdapter } from '../storage/types.js'; import { type VisibilityCondition } from '../validation/visibility.js'; import { type DeliveryQueryResult } from './itemQueries.js'; import type { ItemSort } from './itemSort.js'; import { type ContentItem } from './items.js'; import { type ResolvedSnippet } from './snippets.js'; import { type SeoSiteDefaults } from './seo.js'; import { type SocialProfile } from './siteSettings.js'; /** * The delivery layer: everything a page needs, in one answer. * * This exists because a consumer reading over HTTP cannot afford the shape the embedded demo site * used. `apps/web/src/pages/[...path].astro` makes twelve-plus separate queries to render one page — * the item, its type, its children, one lookup *per ancestor* for breadcrumbs, the blocks, an * `og:image` row, and the redirect fallback — which is fine against a local database and * indefensible as twelve HTTP round trips. * * It lives in core rather than in the route for the reason `resolveSeo` does: the studio's own * preview and the delivery API must resolve identically, and a rule implemented twice is one that * will disagree. `visibleToPublic` stays the single visibility predicate — nothing here reimplements * "what may a visitor see". * * **References are returned as lookup maps, not inlined into `data`.** Replacing a media id with an * object would read more nicely in a template and would be wrong three ways: `data` would stop * matching the field types the CMS validates against (and therefore the generated types), an image * used twice would be serialised twice, and the payload could no longer be handed back to a write. * The maps deduplicate, and the consumer looks up by the id it already has. */ export interface DeliveryMedia { id: string; /** Absolute. A relative URL is useless to a consumer on another origin. */ url: string; alt: string | null; title: string | null; mimeType: string; width: number | null; height: number | null; /** Normalised focal point and crop, so a consumer can resolve its own aspect ratios. */ hotspot: { x: number; y: number; } | null; crop: { top: number; right: number; bottom: number; left: number; } | null; } /** A content item referenced through a relation field, or sitting in a breadcrumb. */ export interface DeliveryItemRef { id: string; title: string; path: string; status: ContentStatus; /** * The item's own field values — present **only** for items matched by a `query` field. * * A breadcrumb or a relation target is a name and a URL, and that is all any consumer has ever * needed of one. A query result is a card: a thumbnail, a date, a location. Carrying the data on * the same ref rather than in a second map means an item that is both — a related page that also * matches a listing — is one entry rather than two that could disagree. * * `block` and `query` fields are stripped; media, relation and term ids inside it resolve through * the payload's own maps exactly as the host item's do, one level deep. */ data?: Record; } export interface DeliveryTermRef { id: string; name: string; slug: string; taxonomyApiId: string; } export interface DeliveryField { apiId: string; label: string; type: FieldRow['type']; required: boolean; helpText: string | null; position: number; config: Record; /** * The condition under which the editor shows this field, or null. * * Sent because a hidden field's **value is still stored and still delivered** — dropping it would * make a content-type edit silently wipe content — so a consumer that wants to honour the * editor's intent needs the rule as well as the data. Most will not: the usual template reads the * controlling boolean itself, which is what `show_website_banner` is for. Taproot ships no * templates and does not get to decide. */ visibleWhen: VisibilityCondition | null; } export interface DeliveryItem { id: string; title: string; slug: string; path: string; status: ContentStatus; publishedAt: string | null; updatedAt: string; contentType: { apiId: string; name: string; namePlural: string; kind: ContentTypeKind; }; fields: DeliveryField[]; /** Field values keyed by `api_id`, with blocks already dereferenced. */ data: Record; /** * Absent when the content type has search-and-social settings switched off. * * **Absent rather than empty**, which is the rule the reference maps already follow: `{}` reads as * "asked, and this item has none", where a missing key says the question does not apply to this * type at all. A consumer rendering a title tag for a directory entry nobody will ever share is * rendering a fallback somebody never chose, and it should be able to tell the difference. * * A type with SEO on always has this, so `item.seo?.title` is only a guard where a site actually * turns it off — and `resolveSeo`'s fallback chain means the key is never present-but-useless. */ seo?: { title: string; description: string | null; ogImageId: string | null; noIndex: boolean; }; } export type DeliveryResult = { kind: 'item'; item: DeliveryItem; /** Ancestors, outermost first. Excludes the item itself. */ breadcrumbs: DeliveryItemRef[]; /** Visible children, for "in this section" navigation. */ children: DeliveryItemRef[]; media: Record; references: Record; terms: Record; /** * Answers to the page's `query` fields, keyed by `queryKey(containerId, fieldApiId)`. * * A fourth top-level map rather than results written into `data[apiId]`, because that slot * holds the saved *rule* and has to keep the stored shape — the payload stays usable for a * write, and the generated types keep describing what is actually sent. Overwriting it with * an answer would break both, and worse than the rich-text exception does, since a rule * replaced by its results does not round-trip at all. * * The key is composite because a query field can sit inside a block, and the same block type * placed twice on one page is two placements with two answers. `containerId` is the item's id * at the top level and the block instance's id inside a block — both of which a consumer * already holds when it comes to render one. */ queries: Record; /** * The reusable text snippets this page's content refers to, keyed by `api_id`. * * A fifth top-level map, and — unlike `queries` — the values have **already been substituted * into `data`**. That is the rich-text exception rather than a new one: a `{{ tuition }}` left * in place ships braces to a visitor the moment a site forgets a helper, and delivery is * read-only so nothing round-trips it back. The same reasoning, and the same trade, as * `taproot:item:` markers. * * The map is here anyway because prose gets `display` and some consumers need `value`: a chart * block plots `snippets.tuition.value` as a real number, where the substituted text would hand * it "$4,500" to parse back. Block components live in git and are written by a developer, * which is the escape hatch the `embed` field already documents. * * Only snippets the page actually refers to travel, collected on the same walk as everything * else. */ snippets: Record; /** * Everything this page's content depends on, as cache tags. * * In the payload rather than only in a response header because **two** caches need it: the * studio tags its own cached JSON, and a consumer tags the HTML it renders from that JSON. * The site cannot derive this list — it would have to know that a breadcrumb came from an * ancestor row, that a listing depends on a type rather than on the items it matched, and * that a block was filled in from the library. The side that resolved the page knows all * three, so it says so. * * Purely a caching hint: a consumer that ignores it renders exactly the same page and simply * relies on the shared TTL to expire, which is what every site did before this existed. */ cacheTags: string[]; } | { kind: 'redirect'; to: string; status: number; } | { kind: 'not_found'; }; export interface DeliveryOptions { /** Absolute origin for media URLs. */ origin: string; storage: StorageAdapter; /** * Include content a visitor cannot see. * * Off by default, and the default is the security property: a delivery route that forgot to pass * this serves published content, not drafts. */ includeUnpublished?: boolean; /** * The last link of the SEO fallback chain, read once by the caller rather than here. * * **Passed in rather than queried**, because this runs on every page view and the settings row * changes about once a year: a route can read it once and reuse it, and `npm run query-count` * measures exactly this function. Omitted, the chain simply ends one link earlier — which is what * a deployment that has never opened the site settings screen has anyway. * * It lives in the chain rather than in the consumer's template for the reason the whole of * `resolveSeo` does: a preview and the published page resolving fallbacks separately is two * implementations that will disagree, and the one nobody checks is the preview. */ site?: SeoSiteDefaults | null; } /** * Resolve a request path to everything needed to render it. * * Resolution order matches the embedded route exactly, and the order matters: an item wins over a * redirect, because a redirect exists to say content moved *away* from a path and a live page now * occupying it should be served. Term archives are the consumer's business — Taproot has no opinion * about which taxonomies deserve public pages — so this returns `not_found` for one and the site * decides what to do next. */ export declare function resolveDelivery(db: Kysely, path: string, options: DeliveryOptions): Promise; /** * The payload for an item already in hand. * * Split from `resolveDelivery` so a preview by id — and, in 3.75b, a release's staged version — can * produce exactly the same shape without going through a path lookup. One builder, so a preview * cannot drift from the page it is previewing. */ export declare function buildItemPayload(db: Kysely, item: ContentItem, options: DeliveryOptions): Promise>; /** * One item in a delivered listing. * * A **superset of `DeliveryItemRef`**, deliberately: a card component written against a query * field's results renders one of these unchanged, which is the whole point of not inventing a second * shape. The three extra keys are what a listing has always sent and an index page uses — `slug` for * a site building its own URLs, and the two timestamps for "posted on" lines and date sorting a * consumer does itself. */ export interface DeliveryListItem extends DeliveryItemRef { slug: string; publishedAt: string | null; updatedAt: string; } export interface DeliveryList { items: DeliveryListItem[]; /** Matching rows in total, which is what a pager needs and `items.length` is not. */ total: number; /** * The lookup maps, present **only** when `includeData` was asked for. * * Absent rather than empty when it was not, because there is nothing to look anything up in: a * summary carries no ids. An empty object would read as "asked, and this site has no media", * which is a different fact. */ media?: Record; references?: Record; terms?: Record; } export interface DeliverItemsOptions extends DeliveryOptions { contentTypeId?: string; /** Already expanded to whole branches by the caller, as `ItemFilters.termIds` requires. */ termIds?: string[]; search?: string; sort?: ItemSort; limit?: number; offset?: number; /** Narrow to types whose items have pages — see `ItemFilters.contentTypeHasItemPages`. */ contentTypeHasItemPages?: boolean; /** Direct children of one item. `null` for the top level. See `ItemFilters.parentId`. */ parentId?: string | null; /** * Everything below a path — one whole branch of the site tree. * * The branch rather than one level, which is what a section-scoped listing needs: a large branch * can cross this endpoint's 200-row cap on its own while any one part of it is comfortably inside * it. See `ItemFilters.pathPrefix` for why it is a range and not a `like`. */ pathPrefix?: string; /** * Send each item's own field values, and the maps their ids resolve through. * * Off by default, and the default is the one that matters: a menu picker asking for two hundred * candidates by title must not start paying for two hundred page bodies. Opt in when rendering * cards — a directory needs the photo, the position and the department, and the alternative is N * calls to `resolve`. */ includeData?: boolean; } /** * A filtered listing, optionally carrying enough to render a card grid. * * Lives in core rather than in the route for the reason `resolveSeo` does: the shape a listing sends * has to be the shape a query field's results already send, and two implementations of "what a * listed item is" would drift on the first field type either of them forgot. * * **The data path costs three extra queries at most, not three per item.** Every listed item's * media, relations and terms are collected across the whole page and loaded in one query each — * which is the same batching `resolveDelivery` does, and the reason a listing of fifty is not fifty * round trips. The content types are loaded once per *distinct* type on the page, so a listing * narrowed to one type — which is what a directory is — loads exactly one. */ export declare function deliverItems(db: Kysely, options: DeliverItemsOptions): Promise; export interface DeliveryTypeSchema { /** * The row id, which is what a `relation` or `query` field's `config` names its target by. * * Sent so a consumer reading the model can follow that reference. Without it `config` hands over a * uuid with nothing on this side of the wire to match it against — the caller has to open the * admin and read the name off a screen, which is the manual step a schema exists to remove. */ id: string; apiId: string; name: string; namePlural: string; kind: ContentTypeKind; urlPrefix: string | null; /** * Whether this type's items are served at their own URLs. * * False for a collection whose item pages are turned off — a staff directory — and for every kind * that never had them. A consumer rendering a listing reads it to decide whether a card's title is * a link: the CMS is the authority on that, and a site restating it is the two-implementations * problem in miniature. `resolve` answers `not_found` at those items' paths, so a link built * anyway is a 404 rather than a silent field dump. */ hasItemPages: boolean; fields: DeliveryField[]; } /** * A taxonomy, as the schema lists it. * * Its terms are deliberately not here: a vocabulary can hold hundreds, the schema is read to learn * the *model*, and `GET /delivery/taxonomy/{apiIdOrId}/terms` answers the other question — with * counts, and narrowed to the type a facet sits beside. */ export interface DeliveryTaxonomySummary { id: string; apiId: string; name: string; namePlural: string; hierarchical: boolean; } export interface DeliverySchema { contentTypes: DeliveryTypeSchema[]; blockTypes: DeliveryTypeSchema[]; /** * Every taxonomy, which is what makes a `taxonomy` field's `config.taxonomyId` resolvable. * * The alternative was resolving it *into* each field's config as a `taxonomyApiId`, and it was * rejected on cost: `toDeliveryField` also builds the `fields` array on every `resolve`, so the * lookup would land on the hot path of every page view to serve a question only a schema reader * asks. Listing them once here costs one query on an endpoint that is `no-store` and read at build * time — and answers "what taxonomies exist" as well, which nothing else did. */ taxonomies: DeliveryTaxonomySummary[]; } /** * The whole content model, for generating a consumer's types. * * SCOPE calls type generation "the point of the split rather than a nicety". Today's client is typed * over table rows — `data: Record` — which tells a consumer nothing about *their* * content. A site with an `event` type wants `Event`, with the fields it declared. * * Block types are included because a block field's values need types too, and a block type is a * content type whose instances are never addressed. They are asked for explicitly rather than by * flipping `listContentTypes`' default, which is load-bearing everywhere else. * * Fields come back in one query for every type rather than one query per type — the same shape * `blockTypeRegistry` uses, and the reason it exists. */ export declare function deliverySchema(db: Kysely): Promise; /** * A menu entry with its target described rather than turned into a URL. * * This is the answer to the question SCOPE flagged to decide rather than discover: `resolveMenu` * takes a `termHref` **callback**, and a function cannot cross an HTTP boundary. * * The alternative was to revisit "Taproot has no opinion about term URLs" and let the CMS hold a * setting for which taxonomies get public pages. That would have been the wrong trade. Which * taxonomies deserve URLs depends on the routes a site actually serves — a review status or an * internal owner classifies content without wanting a page each — so it is the consumer's judgement, * and moving it server-side would make Taproot assert something it cannot know. Returning the term * unresolved keeps the decision exactly where it was, on the other side of the wire. */ export type DeliveryMenuTarget = { type: 'item'; path: string; } | { type: 'term'; id: string; name: string; slug: string; taxonomyApiId: string; } | { type: 'url'; url: string; }; export interface DeliveryMenuItem { id: string; label: string; openInNewTab: boolean; noFollow: boolean; /** * The composed `rel`, or null. Render it; do not rebuild it from the two flags. * * It carries `noopener noreferrer` on a new-tab entry, which neither flag names and which the * visitor's safety depends on — the same division `` draws when it owns `sandbox` * and `referrerpolicy` rather than trusting a caller to remember them. The flags stay in the * payload because they are what the entry actually *says*, and a site that wants to style an * external link differently should not have to parse a token list to find out. */ rel: string | null; target: DeliveryMenuTarget; children: DeliveryMenuItem[]; } export declare function deliverMenu(db: Kysely, apiId: string): Promise<{ items: DeliveryMenuItem[]; cacheTags: string[]; }>; /** * A term as a facet control needs it: what `resolve` puts in its `terms` map, plus its place in the * tree and — when asked for — how much content is under it. * * `parentId` rather than nested children, because the flat form is what both renderings need. A * `