/** * Preview URL generation * * Creates preview URLs that include a signed token for accessing draft content. */ import { generatePreviewToken } from "./tokens.js"; const REPEATED_SLASHES = /\/{2,}/g; /** * Options for generating a preview URL */ export interface GetPreviewUrlOptions { /** Collection slug (e.g., "posts") */ collection: string; /** Content ID or slug */ id: string; /** Secret key for signing the token */ secret: string; /** How long the preview URL is valid. Default: "1h" */ expiresIn?: string | number; /** Base URL of the site. If not provided, returns a relative URL. */ baseUrl?: string; /** * Custom path pattern. Supports `{collection}`, `{id}` and `{locale}` * placeholders. Default: `"/{collection}/{id}"`. */ pathPattern?: string; /** * Locale segment substituted for the `{locale}` placeholder in `pathPattern`. * Pass an empty string to omit the locale prefix (e.g. for the default locale * when `prefixDefaultLocale` is `false`); adjacent slashes left by an empty * value are collapsed and any trailing slash is trimmed. */ locale?: string; } /** * Generate a preview URL for content * * The URL includes a `_preview` query parameter with a signed token. * * @example * ```ts * const url = await getPreviewUrl({ * collection: "posts", * id: "hello-world", * secret: process.env.PREVIEW_SECRET!, * }); * // Returns: /posts/hello-world?_preview=eyJj... * * // With base URL: * const fullUrl = await getPreviewUrl({ * collection: "posts", * id: "hello-world", * secret: process.env.PREVIEW_SECRET!, * baseUrl: "https://example.com", * }); * // Returns: https://example.com/posts/hello-world?_preview=eyJj... * * // Custom path pattern: * const customUrl = await getPreviewUrl({ * collection: "posts", * id: "hello-world", * secret: process.env.PREVIEW_SECRET!, * pathPattern: "/blog/{id}", * }); * // Returns: /blog/hello-world?_preview=eyJj... * ``` */ export async function getPreviewUrl(options: GetPreviewUrlOptions): Promise { const { collection, id, secret, expiresIn = "1h", baseUrl, pathPattern = "/{collection}/{id}", locale = "", } = options; // Generate the signed token const token = await generatePreviewToken({ contentId: `${collection}:${id}`, expiresIn, secret, }); // Build the path. `{locale}` may resolve to an empty string (default locale // without a prefix); collapse the resulting double slashes and trim a // trailing slash so the URL stays clean. let path = pathPattern .replace("{collection}", collection) .replace("{id}", id) .replace("{locale}", locale); path = path.replace(REPEATED_SLASHES, "/"); if (path.length > 1 && path.endsWith("/")) path = path.slice(0, -1); // Add token as query parameter const url = new URL(path, baseUrl || "http://placeholder"); url.searchParams.set("_preview", token); // Return relative URL if no baseUrl provided if (!baseUrl) { return `${url.pathname}${url.search}`; } return url.toString(); } /** * Build a preview URL from a token (when you already have the token) * * @example * ```ts * const url = buildPreviewUrl({ * path: "/posts/hello-world", * token: existingToken, * }); * ``` */ export function buildPreviewUrl(options: { path: string; token: string; baseUrl?: string; }): string { const { path, token, baseUrl } = options; const url = new URL(path, baseUrl || "http://placeholder"); url.searchParams.set("_preview", token); if (!baseUrl) { return `${url.pathname}${url.search}`; } return url.toString(); }