import { r as SanitizedNextlyConfig, h as CollectionsHandler, gS as CacheRevalidator, gT as RevalidationIntent, gJ as Nextly, a0 as UserContext } from './_dts-chunks/auth-service.d-B0csjDLa.d.ts'; export { gU as getNextly } from './_dts-chunks/auth-service.d-B0csjDLa.d.ts'; import { Metadata, MetadataRoute } from 'next'; import { P as PreviewTokenScope } from './_dts-chunks/preview-token.d-BMsKq2in.d.ts'; import '@nextlyhq/adapter-drizzle'; import '@nextlyhq/adapter-drizzle/types'; import 'react'; import './_dts-chunks/nextly-error.d-WlStqaV9.d.ts'; import './_dts-chunks/error-codes.d-CbwkO1ux.d.ts'; import './_dts-chunks/media.d-DtIw8UQM.d.ts'; import 'zod'; import './_dts-chunks/storage.d-CEowrt6p.d.ts'; import 'drizzle-orm'; /** * Dynamic Route Handler * * Creates HTTP method handlers for Next.js API routes. * This module serves as the main orchestrator, delegating to: * - route-handler/route-parser.ts for REST route parsing * - route-handler/auth-handler.ts for auth-specific endpoints * * @example * ```typescript * // In your Next.js route handler (e.g., app/api/[[...params]]/route.ts) * import { createDynamicHandlers } from 'nextly'; * * const handlers = createDynamicHandlers(); * export const { GET, POST, PUT, PATCH, DELETE, OPTIONS } = handlers; * ``` */ declare function bumpSchemaVersion(): number; /** * Create dynamic HTTP method handlers for Next.js API routes. * * Pass your nextly config so that plugins (and their collections) are * registered automatically on first request. Without a config, services * are initialized with default settings only. * * @param options - Optional configuration * @param options.config - The nextly config object (from `defineConfig()`) * @returns Object with handlers for GET, POST, PUT, PATCH, DELETE, OPTIONS * * @example * ```typescript * import { createDynamicHandlers } from 'nextly'; * import nextlyConfig from '../../../nextly.config'; * * const handlers = createDynamicHandlers({ config: nextlyConfig }); * export const { GET, POST, PUT, PATCH, DELETE, OPTIONS } = handlers; * ``` */ declare function createDynamicHandlers(options?: { config?: SanitizedNextlyConfig; }): { GET: (req: Request, ctx: { params: Promise<{ params?: string[]; }>; }) => Promise; POST: (req: Request, ctx: { params: Promise<{ params?: string[]; }>; }) => Promise; PUT: (req: Request, ctx: { params: Promise<{ params?: string[]; }>; }) => Promise; PATCH: (req: Request, ctx: { params: Promise<{ params?: string[]; }>; }) => Promise; DELETE: (req: Request, ctx: { params: Promise<{ params?: string[]; }>; }) => Promise; OPTIONS: (req: Request) => Promise; }; /** * Get the collections service directly * Useful for server-side access to collection operations * * This function first tries to get the CollectionService from the DI container * (set up via getNextly() or registerServices()), which supports dynamic schema * registration. Falls back to the dispatcher's container if DI is not set up. * * Returns undefined if the service is not yet available (DI container not * initialized and dispatcher adapter not configured). */ declare function getCollectionsService(): unknown; /** * Get the collections handler directly * * This function returns the CollectionsHandler from the DI container, * which is the same instance used by the ServiceDispatcher. This ensures * that dynamic schemas registered here will be available to API route handlers. * * Use this when you need to register dynamic schemas for collection entries. * * Returns undefined if the handler is not yet available (DI container not * initialized and dispatcher adapter not configured). This is safe to check * before registering dynamic schemas. * * @example * ```typescript * import { getCollectionsHandler } from 'nextly'; * import * as dynamicSchemas from '@/db/schemas/dynamic'; * * const handler = getCollectionsHandler(); * if (handler) { * handler.registerDynamicSchemas(dynamicSchemas); * } * ``` */ declare function getCollectionsHandler(): CollectionsHandler | undefined; /** A Next `cacheLife`-style profile: `{ expire: 0 }` means expire immediately. */ type CacheLifeProfile = string | { expire?: number; }; /** The subset of `next/cache` this adapter uses. */ interface NextCacheModule { revalidateTag: (tag: string, profile?: CacheLifeProfile) => void; revalidatePath: (path: string, type?: "page" | "layout") => void; } /** * Maps {@link RevalidationIntent}s to `next/cache` calls. Register it as the * `cacheRevalidator` so the write path flushes tag/path invalidations through * Next's ISR cache. */ declare class NextCacheRevalidator implements CacheRevalidator { private readonly loader; /** * @param loader Resolves the `next/cache` module. Defaults to the lazy * `createRequire` loader; injectable so a test can supply a fake module * without a real Next runtime. */ constructor(loader?: () => NextCacheModule | null); flush(intents: RevalidationIntent[]): void; } /** * Register {@link NextCacheRevalidator} as the active `cacheRevalidator`, * replacing the no-op default. Safe to call any number of times. * * Installs the factory as the DI layer's default revalidator (so every service * boot — including a reboot after `clearServices()` / `shutdownServices()` in * the same process — re-seeds the adapter rather than the no-op), and also * (re)installs it on the current container so it takes effect immediately even * if the no-op default was already resolved once during boot. The write path * resolves the revalidator lazily at flush time, so a later registration is * honored. The adapter is stateless (its `next/cache` resolution is memoised at * module scope), so a fresh instance per resolve costs nothing. */ declare function registerNextCacheRevalidator(): void; /** * The tags a content read should carry so an on-write bust invalidates it. * * - `nextlyTags("posts")` → the collection tag: for a listing / index / sitemap * read, invalidated by any change in the collection. * - `nextlyTags("posts", id)` → the collection tag + the entry-id tag: for an * entry detail read, invalidated when that entry changes in any locale (tag by * the immutable id, not the slug, so a rename still invalidates). * - `nextlyTags("posts", id, locale)` → also adds the per-locale id tag. * * Tag reads by the entry's immutable id (available after the fetch resolves), * not its slug: the write busts the id tag on every change, so the read stays * invalidatable across slug and status changes. * * A detail read intentionally carries the collection tag too, so it also * refreshes when the collection changes around it (a new sibling, a reorder). * Because a match on ANY tag invalidates the entry, that broadens invalidation: * a change to one entry (which busts `nextly:{collection}`) refreshes every * detail read in the collection. That is safe — a stale read is never served, * only re-fetched more often. A high-traffic site that wants strictly per-entry * (or per-locale) granularity should tag with the entry/locale tag alone rather * than this helper's broad, safe default. */ declare function nextlyTags(collection: string, id?: string, locale?: string): string[]; /** * The tag a singleton / global read should carry, so a write to that single * invalidates every page that consumed it. */ declare function nextlySingleTags(slug: string): string[]; /** Options for {@link cachedFind}. */ interface CachedFindOptions { /** * The cache tags this read carries — use {@link nextlyTags} so a write's bust * invalidates it. A change matching any tag makes the next request re-run the * reader. */ tags: string[]; /** * The parts that make this cache entry unique, e.g. `["posts", slug]`. Beyond * the query itself, **include anything the result varies by**. * * SECURITY: if the read applies per-caller access rules (owner-only scoping, * role-based visibility, an API-key's narrowed scope), EVERY dimension those * rules read MUST be in `keyParts` — not merely who the caller is. A user id * alone survives a role change, a claim change and a narrower key scope, so * the same person can fill an entry while privileged and read it back after * being downgraded: tags bust on CONTENT changes, and a permission change is * not one. Two different * users share one cache entry when their `keyParts` match, so caching an * owner-filtered list under a stable key would serve one user's rows to * another — a cross-tenant leak, not a stale-cache annoyance. For genuinely * public content (the same for every reader) a stable key is correct and gives * the full ISR benefit. */ keyParts: string[]; /** * Optional time-based revalidation in seconds (a safety net on top of * tag-based busting). `false` (the default) means the entry only ever * revalidates on a tag bust. */ revalidate?: number | false; } /** * Run `reader` behind Next's tagged cache. On a cache hit the reader is skipped; * on a miss (or after a bust of any of `tags`) it runs and the result is cached * under `keyParts` + `tags`. * * @example * // Public entry detail — cached and busted when any post changes. Tag with the * // collection tag; a slug-routed read has no entry id until the fetch resolves. * // * // Two independent hazards, and `status` answers only one. `find()` defaults to * // `overrideAccess: true`, so a slug filter alone can return a DRAFT — * // `status: "published"` is enforced even on a trusted read and fixes that. * // It does NOT evaluate per-row ACCESS rules: a published row only its owner * // may read is still returned. A shared key is correct only for a collection * // with no read rules; otherwise use the per-user form below. * // `find()` returns `{ items, meta }`, so a detail route takes the first item. * const post = await cachedFind( * async () => * ( * await nextly.find({ * collection: "posts", * where: { slug: { equals: slug } }, * status: "published", * limit: 1, * }) * ).items[0] ?? null, * { tags: nextlyTags("posts"), keyParts: ["posts", slug] } * ); * * @example * // Per-user list — access rules evaluated AS the caller, and EVERY dimension * // those rules read in the key, not merely who the caller is. * // * // Roles are in the key because the identity outlives the permission: tags bust * // on CONTENT changes and a role change is not one, so keying on the id alone * // lets the same person fill an entry while privileged and read it back after * // being downgraded. * // * // The set is built the way `evaluateRoleBasedAccess` builds the one it decides * // with — the many-to-many `roles` UNIONED with the singular `role` — because a * // key that reads fewer dimensions than the rule cannot notice a change in the * // ones it skipped. Both fields are optional on `UserContext`, so both are * // guarded; `[...user.roles]` alone throws for a valid `{ id, role }` caller. * // Deduped and sorted last, because the key is compared as text and one role * // set spelled two ways would otherwise be two entries. * const roleKey = [...new Set([...(user.roles ?? []), user.role ?? []].flat())] * .sort() * .join(","); * * const mine = await cachedFind( * () => nextly.find({ collection: "orders", user, overrideAccess: false }), * { tags: nextlyTags("orders"), keyParts: ["orders", "list", user.id, roleKey] } * ); */ declare function cachedFind(reader: () => Promise, options: CachedFindOptions): Promise; /** * `buildMetadata` — turn a content entry's `seo` field group into a Next.js * `Metadata` object, so an app's `generateMetadata` is one call instead of a * hand-mapped block per page. * * The `next` import is TYPE-ONLY, so importing this never forces `next` onto a * consumer at runtime — the function returns a plain object typed as `Metadata`. * It reads the field group `@nextlyhq/plugin-seo` contributes (`metaTitle`, * `metaDescription`, `ogImage`, `canonical`, `noindex`) and is defensive: a * missing group or blank field falls back to the value you pass in `options`. * * @module runtime/seo/build-metadata */ /** The `seo` field group shape this bridge reads (all fields optional). */ interface SeoMetaInput { metaTitle?: string | null; metaDescription?: string | null; /** Populated upload relation (`{ url }`) or an unresolved id — read defensively. */ ogImage?: unknown; canonical?: string | null; noindex?: boolean | null; } /** An entry carrying the plugin's `seo` group (plus whatever else it has). */ interface MetadataEntry { seo?: SeoMetaInput | null; } /** Options for {@link buildMetadata}. */ interface BuildMetadataOptions { /** * Fallbacks used when the matching `seo` field is blank — e.g. the entry's * own title, excerpt, featured image, and route path. */ fallback?: { title?: string; description?: string; image?: string; canonical?: string; }; /** * Extra OpenGraph fields merged on top of the derived ones — for page-type * specifics the SEO group does not carry (e.g. `type: "article"`, * `publishedTime`, `authors`). */ openGraph?: Metadata["openGraph"]; /** Extra Twitter-card fields merged on top of the derived ones. */ twitter?: Metadata["twitter"]; /** * hreflang alternates (locale → absolute or relative URL) for a localized * page, mapped to `alternates.languages`. */ languages?: Record; } /** * Map an entry's `seo` group to a Next.js `Metadata` object. * * @example * ```ts * export async function generateMetadata({ params }) { * const { slug } = await params; * const post = await getPostBySlug(slug); * if (!post) return {}; * return buildMetadata(post, { * fallback: { title: post.title, description: post.excerpt, canonical: `/blog/${slug}` }, * openGraph: { type: "article", publishedTime: post.publishedAt ?? undefined }, * }); * } * ``` */ declare function buildMetadata(entry: MetadataEntry, options?: BuildMetadataOptions): Metadata; /** * The route that turns a preview link into a draft-reading session, and the * reader that tells the rest of the request what that session may see. * * **Why a cookie carries the token rather than a decision.** Next's draft mode * is a single boolean: `draftMode().enable()` sets `__prerender_bypass` for the * whole host, and nothing about it names a document. A preview token names * exactly one. Enabling draft mode alone would therefore turn a link meant for * one unpublished page into a key to every unpublished page on the site — the * opposite of what the token was scoped for. * * So the scope travels beside it: the token itself is stored, httpOnly, and * re-verified on every read. Storing the token rather than a decision derived * from it means expiry and revocation keep applying for the life of the * session, not just at the moment the link was clicked. * * @module runtime/preview/preview-route */ /** The cookie carrying the preview token for the rest of the session. */ declare const PREVIEW_SCOPE_COOKIE = "__nextly_preview"; interface PreviewRouteConfig { /** The signing secret; the same `NEXTLY_SECRET` sessions use. */ secret: string; /** * The site's current revocation generation. Read per request so raising it * ends existing preview sessions rather than only refusing new links. */ generation: number | (() => number | Promise); /** * Where to send the visitor once the link checks out. * * Supplied by the app because only it knows how its content is routed. The * returned value must be a site-relative path. * * Returning `null` refuses the link the same way an invalid token is * refused. A preview link outlives what it points at — the entry can be * deleted, unpublished or moved between minting and clicking — and this is * how an app says so without having to throw. */ redirectTo: (scope: PreviewTokenScope) => string | null | Promise; /** * Reads and enables Next's draft mode. Injected so the route is testable. * * Accepts a synchronous return as well: `draftMode()` is sync on Next 14 and * async from 15, and the peer range covers both, so requiring a promise would * make the natural import fail to typecheck on the older one. */ draftMode: () => { enable: () => void; } | Promise<{ enable: () => void; }>; } /** * A route handler that accepts a preview link and starts a draft session. * * Every failure answers exactly the same way: 404, no body, no cookie, no draft * mode. A token that is expired, revoked, forged or simply absent must not be * distinguishable from one naming a document that does not exist — otherwise * the endpoint becomes an oracle for which entries are in draft. */ declare function createPreviewRoute(config: PreviewRouteConfig): { GET: (request: Request) => Promise; }; interface PreviewScopeReaderConfig { secret: string; generation: number | (() => number | Promise); /** * Reads the request's cookies. Injected so the reader is testable, and * accepting a synchronous return for the same reason `draftMode` does. */ cookies: () => { get: (name: string) => { value: string; } | undefined; } | Promise<{ get: (name: string) => { value: string; } | undefined; }>; } /** * What the current request is allowed to preview, if anything. * * Re-verifies the stored token rather than trusting that the route once said * yes. A session started an hour ago is refused the moment the token expires or * the generation moves, which is what makes "revoke all preview links" mean * something for sessions already in flight. */ declare function readPreviewScope(config: PreviewScopeReaderConfig): Promise; /** * Whether this request may read the named document's draft. * * The question a read path asks. Kept as a function over the scope rather than * left to each caller to compare fields, so "a preview session exists" can * never be mistaken for "this preview session covers what is being read" — * which is the mistake that would turn one link into a key to every draft. */ declare function previewGrantsDraft(scope: PreviewTokenScope | null, requested: PreviewTokenScope): boolean; /** A resolved content entry (loose by design — shape is the app's collection). */ type ContentEntry = Record; /** * The booted-Nextly surface these helpers need: a `find` reader, plus * `findByID` for the working-draft overlay. Typed structurally (not as the * Direct API class) so BOTH the internal singleton and the public instance * returned by `await getNextly(config)` satisfy it — the public interface does * not expose the Direct API's internal handlers. */ type NextlyContentReader = Pick; /** Options for {@link resolveContent}. */ interface ResolveContentOptions { /** * A booted Nextly instance. Defaults to the runtime singleton (`getNextly()`), * which requires services to be registered — pass one explicitly (e.g. the * value from `await getNextly(config)`) from a frontend read path that boots * the config itself. */ nextly?: NextlyContentReader; /** The field holding the URL slug (default `"slug"`). */ slugField?: string; /** * Draft/Published lifecycle scope (default `"published"`). This is * lifecycle-aware AND locale-aware: for a localized collection it also * constrains the per-locale companion `_status`, so a draft translation under * a published main row is not returned. On a status-less collection (no * built-in lifecycle) it is a no-op — every row is live. */ status?: "published" | "draft" | "all"; /** * Return the pending working draft in place of the live row when one exists. * * The shipped draft model is TWO layers and a preview has to honour both. * `status` covers an entry that has never been published; this covers pending * edits on an ALREADY-published entry, which live in a sidecar row and are * invisible to any `status` scope. Widening `status` alone therefore shows a * published page's LIVE content while the edits being previewed stay hidden — * the failure this option exists to prevent. * * Because the two belong together, `status` defaults to `"all"` when this is * set on a TRUSTED read (`overrideAccess: true`), so the common case cannot * be half-configured. An explicit `status` still wins. The widening is * deliberately limited to trusted reads: the overlay is gated per row by an * update-capability probe, but widening `status` is not gated by anything, so * on an enforced read it would expose every never-published entry to whoever * asked. An enforced draft read therefore stays published-only, and sees * pending edits only through the (gated) overlay. * * Effective only on a drafts-enabled, non-localized collection with the * `status` lifecycle, and gated by an update-capability probe: a caller who * cannot edit the document still gets the published row. A read that is * neither trusted (`overrideAccess: true`) nor carrying a `user` can never * pass that probe, so a draft read must be one or the other. * * A draft read is NEVER cached — see the caching note below. * * @default false */ draft?: boolean; /** * The entry a preview grant NAMES, resolved by id instead of by slug. * * Only meaningful alongside `draft` on a trusted read. A slug is not unique: * the ordinary lookup settles duplicates by sorting on `id`, so a grant for * one entry can land on another that happens to share its slug, and the * caller then rejects the mismatch and falls back to published. The editor * sees LIVE content at a link they were given for a draft. * * Reading by the id the grant names removes that comparison rather than * hardening it. The resolved entry's own slug is then confirmed against the * requested one, which is what stops a preview session turning every path on * the site into the previewed entry: without that check a cookie for one page * would render that page at every URL for the life of the session. * * A grant that names a deleted entry, or one whose slug no longer matches the * path, falls through to the ordinary slug resolution rather than failing. A * preview link outlives what it points at. */ entryId?: string; /** Relation population depth for rendering (default `1`). */ depth?: number; /** Read a specific locale (localized collections). */ locale?: string; /** Rich-text output format for rich-text fields (default the reader's). */ richTextFormat?: "json" | "html" | "both"; /** * Extra cache tags merged with the collection's own tag. Add related * collections' tags (e.g. `nextlyTags("authors")`) when a `depth > 0` read * populates relations, so a write to one of those busts this read too. */ tags?: string[]; /** * Time-based revalidation in seconds for a CACHED (trusted) read — a safety * net on top of tag-based busting. `false` (default) means tag-only; a * non-positive value is treated as `false`. Ignored for enforced or * user-scoped reads, which are never cached. */ revalidate?: number | false; /** * A stable discriminator folded into the cache key. Supply a unique value when * distinct `nextly` readers (e.g. per-tenant or per-database) can resolve the * same collection + slug, so their cached reads never alias each other. */ cacheScope?: string; /** * Whether to bypass the collection's read-access rules. Defaults to `false`, * so a content route enforces STORED access policies: a rule-less (public) * collection still renders, but one with a stored member-only/role-based read * rule is hidden from an unauthenticated request (resolves to `null` → * `notFound()`). Pass `true` for a fully trusted read. NOTE on anonymous * scope: an anonymous read enforces stored rules that DENY outright * (public/authenticated/role-based). A row-level CONSTRAINT rule (owner-only, * or a custom rule returning a query predicate) and inline * `defineCollection({ access })` code rules require a `user` context to * evaluate, so they are not applied for an anonymous read — gate such content * behind an authenticated read (pass a `user`) rather than relying on the * anonymous default. CACHING: only a trusted (`overrideAccess: true`) read * with no `user` is F1-cached — an enforced read is never cached (its access * decision can't be invalidated on a policy change). A public site that wants * cached pages should read its public content with `overrideAccess: true`. */ overrideAccess?: boolean; /** * Which collections that trust may reach as relationships are expanded. * * A route lists the collections it SERVES; a page populating a relationship * reaches one it did not list. Without this, every populated target inherits * the route's bypass. Named collections are read trusted; the rest are read * as a visitor would read them. * * Only ever narrows, and never admits a target's drafts — see * `ContentRouteConfig.trustedCollections`. * * **A list rather than the predicate the read layer takes, because this * layer CACHES.** Two routes differing only in what they trust produce * different rows, so the bound has to be part of the cache identity — and a * function has none. Taking the data means the key and the predicate are * derived from one value that cannot disagree with itself; taking a * predicate plus a separate key would be two options a caller can get out of * step. */ trustedCollections?: readonly string[]; /** * What the CALLER authorized, before a draft decision widened it. * * A route forces `overrideAccess` on so a granted entry can be reached at * all. That forcing is justified only while the grant is answering the path, * so a read that runs after it stops answering uses this instead. Defaults to * `overrideAccess`, leaving a caller that widened nothing where it was. */ callerOverrideAccess?: boolean; /** User identity to evaluate access rules against (with `overrideAccess: false`). */ user?: UserContext; } declare function resolveContent(collection: string, slug: string, options?: ResolveContentOptions): Promise; /** Where a resolved entry was found, and in which language. */ interface ResolvedContext { /** The collection the entry was resolved from. */ collection: string; /** The joined slug path (no leading slash), e.g. `"about/team"`. */ slug: string; /** * The locale this route was configured to read in, verbatim. * * Carried explicitly rather than read back off the row: the companion overlay * copies localized values ONTO the entry without stamping which locale they * came from, so a consumer inferring it from the row would find nothing on * exactly the localized pages that need it. * * It sits on the base shape rather than on the render's, because the `draft` * decision needs it too and needs the SAME one. A decision taken against a * different locale than the read that follows authorizes one translation and * serves another. * * Absent when {@link ContentRouteConfig.locale} was not set — which a * localized site must not do, for the reason documented there. */ locale?: string; } /** * What `render` and `buildMetadata` receive: where the entry was found. * * Deliberately NOT a reader. An earlier version carried the instance this route * resolved through, so a render needing a second read did not have to obtain * one of its own. The Direct API is a TRUSTED surface — access bypassed, * lifecycle unfiltered, no locale — and a route is the opposite, so handing one * to a callback meant re-binding every attribute it carries: access, both * identity channels, lifecycle and locale, each of which failed independently. * * A caller that genuinely needs a second read passes its own instance as * `nextly` and uses it directly, where the posture is visibly theirs to choose * rather than inherited from a field that looks like a convenience. */ type RenderContext = ResolvedContext; /** * Config for {@link createContentRoute}. `TNode` is the render output (your * server component's return, e.g. `ReactNode`) — inferred from `render`, so * `nextly` needs no `react` dependency of its own. */ interface ContentRouteConfig { /** * Collections to resolve a path against, in order — the first collection with * a published entry whose slug matches the path wins. A status-less collection * (no built-in lifecycle) works automatically: the `status` scope is a no-op * there, so it can be mixed freely with lifecycle collections. */ collections: string[]; /** Render the resolved entry (your server component body). May be async. */ render: (entry: ContentEntry, context: RenderContext) => TNode | Promise; /** Optional per-entry metadata (e.g. via `buildMetadata`). */ buildMetadata?: (entry: ContentEntry, context: RenderContext) => Metadata | Promise; /** Field holding the slug (default `"slug"`). */ slugField?: string; /** * Draft/Published lifecycle scope for the resolved reads (default * `"published"`). Lifecycle- and locale-aware; a no-op on status-less * collections. */ status?: "published" | "draft" | "all"; /** * Whether this request may see pending unpublished edits at THIS path. * * Almost always a function, because route config is captured once at module * scope while whether a visitor is previewing is a per-request fact. It is * asked for every path the route resolves, and is handed the collection and * slug being resolved so the answer can be scoped to a document. * * **The argument is the point, not a convenience.** Next's draft mode is a * single boolean for the whole host — `draftMode().isEnabled` says a visitor * opened *a* valid preview link, never *which* document it was for. Answering * from that alone turns a link scoped to one unpublished page into a key to * every unpublished page in the configured collections for the life of the * session, which is precisely what the preview token's scope exists to * prevent. Compare it against what the token actually granted: * * ```ts * draft: async ({ collection, slug }) => { * const scope = await readPreviewScope(previewConfig); * if (scope === null || scope.collection !== collection) return false; * if (slug !== (await slugOf(scope.collection, scope.entryId))) return false; * return { entryId: scope.entryId }; * } * ``` * * Name the entry rather than returning a bare `true`. A slug is not unique, * so a boolean grants whichever row this route resolves the path to, which * need not be the one the token was minted for; `{ entryId }` is checked * against the document that was actually resolved. * * **Returning `true` is an authorization decision, not a display * preference.** This route always resolves anonymously, and the working-draft * overlay is gated on an update-capability probe an anonymous read can never * pass — so a request this returns `true` for is read TRUSTED, exactly as * Payload pairs `draft: isDraftMode` with `overrideAccess: isDraftMode`. Put * the authorization here, never in a query parameter the visitor controls. * * A literal `true` is accepted for a route mounted behind the app's own auth, * and means every visitor sees unpublished content at every path. It is * almost never what a public site wants. * * `draft` belongs to this factory alone. `createPublicContentRoute` refuses * it: a draft read is never cacheable, so it marks the render dynamic, while * a public route's `generateStaticParams` has told Next it is static. * * @default false */ draft?: boolean | ((context: ResolvedContext) => DraftGrant | Promise); /** * Read this locale on localized collections, and report it to `render`, * `buildMetadata` and the `draft` decision as `context.locale`. * * **State it on a localized site even when it is the default language.** The * read defaults an absent locale internally, so omitting it still serves the * right page — but `draft` is handed exactly what is written here, and a * preview token always names a resolved locale rather than a blank one. An * omitted locale therefore compares `"en"` against nothing and refuses every * preview of the default language, while the published page it falls back to * looks entirely normal. * * Nothing infers the default on your behalf, deliberately: inferring it means * reading the site's configuration at request time, and a reader is allowed * to defer booting until its first query, so that read answers differently on * the first request of a cold process than on the next one. * * Omit it only where the site configures no localization at all. */ locale?: string; /** * Relation depth for the resolved read. * * **The defaults differ by factory.** `createContentRoute` defaults to `1`, * matching `resolveContent`. `createPublicContentRoute` defaults to `0`, so a * route that populates nothing performs no expansion at all. * * Setting this on a public route enables expansion, bounded by * {@link ContentRouteConfig.trustedCollections}: a target outside that set is * read as a visitor would read it, and no target's drafts are admitted. * * A pre-rendered page is a point-in-time copy of everything it read, so a * target's policy tightening after the build does not reach it — the same is * true of the page's own content, and the remedy is the same: revalidate. * Name the related collections in `tags` so a write to one busts this page. */ depth?: number; /** * The collections this route's trust extends to, when it populates * relationships. * * **Defaults to the route's own `collections`**, which is what the guide has * always said this route means: the collections you list are the public ones. * A page that populates a relationship reaches a collection you did NOT list * — it was reached through a field — so without naming it, that target is * read the way an anonymous visitor would read it: its own access rules * apply, and only its published rows are returned. * * ```ts * // Posts are public, and each one populates an author. * createPublicContentRoute({ * collections: ["posts"], * trustedCollections: ["posts", "authors"], * depth: 1, * render: ..., * }); * ``` * * **This only ever narrows.** Listing a collection here cannot grant more * than the route already holds; omitting one means its rows are judged by * their own rules rather than skipped wholesale. * * **Trusting a collection does NOT admit its drafts.** A public route * pre-renders, so an unpublished row pulled in through a relationship is * written to a static artifact and outlives the row being unpublished. * Trusting a collection says its published content may be shown; nothing * here can widen a lifecycle. * * **`createContentRoute` uses it too, and defaults it to NOTHING.** Its * ordinary reads are enforced, where the bound decides nothing. A draft grant * turns the bypass on for one request — but that grant authorizes ONE * document and says nothing about what the document points at, including a * sibling row in the same collection. So a preview populates enforced unless * you name a target here, which is the same content an anonymous visitor * would see beside the page being previewed. */ trustedCollections?: string[]; /** A booted Nextly instance (defaults to `getNextly()`). */ nextly?: NextlyContentReader; /** * Extra cache tags attached to every resolved read, so a write to a related * collection (a populated author, category, media) can bust the page. The * primary collection is always tagged; add the related collections' tags * (e.g. `nextlyTags("authors")`) here when you render populated relations. */ tags?: string[]; /** Time-based revalidation seconds for the resolved read. */ revalidate?: number | false; /** * A stable discriminator folded into the resolved read's cache key — supply * one when distinct `nextly` readers (per-tenant/per-database) can resolve the * same collection + slug, so their cached reads never alias. */ cacheScope?: string; /** * Max published paths to pre-render per collection in `generateStaticParams` * (default `1000`). The rest render on demand via `dynamicParams`. */ staticParamsLimit?: number; } /** * What a draft decision may answer. * * `true` grants the draft at this path unconditionally. `{ entryId }` grants it * for ONE document, and the route discards the draft if the path resolved to a * different one — which matters because a slug need not be unique: the resolver * deliberately supports duplicates and settles them by sorting on `id`, so a * token issued for one entry could otherwise reach another that shares its slug. * * A preview token names an entry, so `{ entryId: scope.entryId }` is the shape * to return when one backs the decision. */ type DraftGrant = boolean | { entryId: string; }; /** The optional-catch-all route arg: `{ params: Promise<{ slug?: string[] }> }`. */ interface ContentRouteArgs { params: Promise<{ slug?: string[]; }> | { slug?: string[]; }; } /** * What a route always returns — wire these into the route file. * * Deliberately without `generateStaticParams`. A route reading access-enforced * content answers differently per visitor, so no path it serves can be * pre-rendered, and offering the function anyway is not a harmless extra: Next * classifies a route as STATIC when it exports one, and every dynamic marking * inside a static render is an error. Measured — an access-enforced route * exporting it answered 500 on every path once its collection was empty at * build time, because an empty param list left nothing to bail out and degrade * the route to dynamic. */ interface ContentRoute { generateMetadata: (args: ContentRouteArgs) => Promise; ContentPage: (args: ContentRouteArgs) => Promise; } /** * What {@link createPublicContentRoute} returns, additionally. * * Present only on this shape so a route that cannot pre-render cannot export * the function that claims it does. The check is the type system's rather than * a runtime warning nobody reads: destructuring `generateStaticParams` from an * enforced route does not compile. */ interface StaticContentRoute extends ContentRoute { generateStaticParams: () => Promise>; } /** * The instance, bound to the access policy this route resolved the entry with. * * The Direct API is a TRUSTED server surface: its documented default is * `overrideAccess: true`, because the ordinary caller is application code that * has already decided who is asking. A route is the opposite — it answers * whoever holds the URL — and it resolves its own entry with access enforced * and no user. * * Handing a render or metadata callback the raw instance therefore offers a * reader whose defaults are the inverse of the page's. A callback doing the * obvious thing — `context.reader.find({ collection: "authors" })` to name the * author of the post it is rendering — would read PAST the access rules that * governed the post itself, and publish restricted rows in a public response. * * So the defaults are restated to match the route: access enforced unless this * route resolved with it overridden, and no identity, because the route * resolves anonymously. A caller that genuinely wants the trusted surface can * still pass `overrideAccess: true` explicitly — the arguments win, since they * are spread over these defaults. What changes is which way the DEFAULT points, * and that is the direction a caller cannot see. */ declare function slugToStaticParam(value: unknown): { slug: string[]; } | null; /** * A route over ACCESS-ENFORCED content — the secure default. * * The collections' read rules decide, so the answer depends on who is asking: * no read is cacheable and no path can be pre-rendered. **It therefore returns * no `generateStaticParams`, and that is the whole point.** * * Next classifies a route as STATIC because the export exists, and every * dynamic marking inside a static render is an error. An enforced route that * also exported one answered 500 on every path whenever its collection was * empty at build time — an empty param list left nothing to bail out and * degrade the route to dynamic, so its runtime behaviour depended on whether * the database had rows in it when the build ran. Not offering the function is * what makes that unrepresentable rather than merely discouraged. * * For public content that should be cached and pre-rendered, use * {@link createPublicContentRoute}. */ declare function createContentRoute(config: ContentRouteConfig): ContentRoute; /** * A route over PUBLIC content: trusted reads, cacheable, pre-renderable. * * Access rules are not consulted — the site has stated that everything in these * collections is public — which is what makes a read cacheable and a path * pre-renderable. Returns `generateStaticParams` for the route file to export. * * **Two functions rather than one flag, and the reason is measured.** Deciding * this through an option meant the return type had to vary with a value, which * costs contextual typing: every callback in the config object * (`render`, `buildMetadata`, `metadata`) loses its parameter types the moment * the config's type depends on an inferred generic. Choosing the posture by * calling a differently-named function keeps both signatures concrete, so * inference is untouched — and the name states the decision at the call site * rather than burying it in a string three lines down. * * **Under Next 16 Cache Components, an EMPTY SITE must use * {@link createContentRoute} instead.** That mode rejects an exported * `generateStaticParams` that returns no entries, and this one returns `[]` * whenever no configured collection holds an addressable published slug — which * is every site before its first page is written. It cannot be guarded at * construction: whether a collection is empty is a fact about the database at * build time, not about the config, and reading it here would make module * evaluation depend on a query. A site that pre-renders nothing yet wants the * dynamic factory anyway; move to this one once it has content. */ declare function createPublicContentRoute(config: ContentRouteConfig): StaticContentRoute; /** * What a content route hands its `draft` hook for the path being resolved. * * DERIVED from the route's own context rather than restated, and the `locale` * member is why. Restating it compiles for as long as the two spellings agree, * and the day the route renames or drops that member this gate reads * `undefined` and stops comparing locale at all — granting a token minted for * one translation against every other. Naming the members here makes that a * compile error. The import is type-only, so nothing links `runtime/preview` to * `runtime/routing` at runtime. */ type DraftGateRequest = Pick; /** Options for {@link previewDraftGate}. */ type PreviewDraftGateConfig = PreviewScopeReaderConfig; /** * A `draft` hook that grants exactly what the request's preview token covers. * * Returns `{ entryId }` rather than `true`, and that is the load-bearing half. * A slug is not unique across collections or over time, so `true` grants * whichever row the route happens to resolve the path to — which need not be * the entry the token was minted for. Naming the entry lets the route check the * grant against the document it actually resolved, so the identity comparison * happens against the resolved row rather than against a path. * * ```ts * createBlocksPage({ * collections: ["pages"], * field: "content", * draft: previewDraftGate({ secret, generation, cookies }), * }); * ``` * * **The route's own `status` is deliberately left alone.** It widens internally * for the request a grant applies to, so configuring `status: "all"` adds * nothing and takes something away: the widened scope then also covers the * resolver's id/slug mismatch path, where a visitor holding a token for entry A * asking for a DIFFERENT unpublished slug in the same collection can be answered * with that unrelated entry. Per-entry scope is the whole point of the token, so * a configuration that defeats it must not be taught alongside the thing that * enforces it. */ declare function previewDraftGate(config: PreviewDraftGateConfig): (request: DraftGateRequest) => Promise<{ entryId: string; } | false>; /** * Reserved-path denylist — the paths a content catch-all must NOT serve, so * content can never be minted at a URL that shadows the admin panel, the API, * Next internals, or a well-known metadata file. * * @module runtime/routing/reserved-paths */ /** * Whether `path` is reserved (owned by the framework/admin/metadata) and must * not be served as content. Accepts a path with or without a leading slash; * a trailing slash is ignored. */ declare function isReservedPath(path: string): boolean; /** * `nextlySitemap` — build the default export for a Next `app/sitemap.ts` from a * caller-supplied entry provider, cached with F1 so a content write busts it. * * The provider is where you wire your data — e.g. `@nextlyhq/plugin-seo`'s * `buildSitemapUrls`, mapping each `{ loc }` to `{ url }`. Keeping the data * caller-supplied lets `nextly` stay independent of the plugin while the plugin * owns the agnostic source of truth. The `next` import is type-only. * * @module runtime/routing/sitemap */ /** A sitemap entry (a superset-compatible slice of Next's `MetadataRoute.Sitemap`). */ interface NextlySitemapEntry { url: string; lastModified?: string | Date; changeFrequency?: "always" | "hourly" | "daily" | "weekly" | "monthly" | "yearly" | "never"; priority?: number; alternates?: { languages?: Record; }; } /** Options for {@link nextlySitemap}. */ interface NextlySitemapOptions { /** Provide the sitemap entries (typically from the SEO plugin's data). */ entries: () => Promise | NextlySitemapEntry[]; /** * F1 cache tags for the read, so a content write busts the sitemap in lockstep * with the pages — use `nextlyTags(collection)` for each configured collection. */ tags?: string[]; /** * Cache key parts. Defaults to `["nextly", "sitemap", ...tags]`, which keeps * sitemaps with distinct tags apart. Multiple sitemap helpers that share the * SAME tags (e.g. partitioned routes over the same collections) MUST pass * distinct `keyParts` — the `entries` provider lives in a closure and the * route pathname is not part of Next's cache key, so there is no automatic * way to tell two same-tagged providers apart. */ keyParts?: string[]; /** Time-based revalidation seconds (safety net on top of tag busting). */ revalidate?: number | false; } /** * Create the `app/sitemap.ts` default export. * * @example * ```ts * // app/sitemap.ts * import { nextlySitemap, nextlyTags } from "nextly/runtime"; * import { buildSitemapUrls } from "@nextlyhq/plugin-seo"; * * export default nextlySitemap({ * entries: async () => { * const urls = await buildSitemapUrls(services, { collections: ["posts"], baseUrl }); * return urls.map(u => ({ url: u.loc, lastModified: u.lastModified })); * }, * tags: nextlyTags("posts"), * }); * ``` */ declare function nextlySitemap(options: NextlySitemapOptions): () => Promise; /** * `nextlyRobots` — build the default export for a Next `app/robots.ts` that * disallows the framework paths (`/admin`, `/api`) and points crawlers at the * sitemap. The `next` import is type-only. * * @module runtime/routing/robots */ /** Options for {@link nextlyRobots}. */ interface NextlyRobotsOptions { /** Absolute sitemap URL(s) to advertise (e.g. `"https://example.com/sitemap.xml"`). */ sitemap?: string | string[]; /** User-agent the rule applies to (default `"*"`). */ userAgent?: string; /** Extra disallowed paths, merged with the defaults (`/admin`, `/api`). */ disallow?: string[]; /** Allowed paths (takes precedence over a broader disallow). */ allow?: string[]; /** Preferred host for canonicalization. */ host?: string; } /** * Create the `app/robots.ts` default export. * * @example * ```ts * // app/robots.ts * import { nextlyRobots } from "nextly/runtime"; * export default nextlyRobots({ sitemap: "https://example.com/sitemap.xml" }); * ``` */ declare function nextlyRobots(options?: NextlyRobotsOptions): () => MetadataRoute.Robots; export { NextCacheRevalidator, PREVIEW_SCOPE_COOKIE, buildMetadata, bumpSchemaVersion, cachedFind, createContentRoute, createDynamicHandlers, createPreviewRoute, createPublicContentRoute, getCollectionsHandler, getCollectionsService, isReservedPath, nextlyRobots, nextlySingleTags, nextlySitemap, nextlyTags, previewDraftGate, previewGrantsDraft, readPreviewScope, registerNextCacheRevalidator, resolveContent, slugToStaticParam }; export type { BuildMetadataOptions, CachedFindOptions, ContentEntry, ContentRoute, ContentRouteArgs, ContentRouteConfig, DraftGateRequest, MetadataEntry, NextCacheModule, NextlyContentReader, NextlyRobotsOptions, NextlySitemapEntry, NextlySitemapOptions, PreviewDraftGateConfig, PreviewRouteConfig, PreviewScopeReaderConfig, RenderContext, ResolveContentOptions, ResolvedContext, SeoMetaInput, StaticContentRoute };