diff --git a/.changeset/preview-url-respects-url-pattern.md b/.changeset/preview-url-respects-url-pattern.md new file mode 100644 index 0000000000..9616f94078 --- /dev/null +++ b/.changeset/preview-url-respects-url-pattern.md @@ -0,0 +1,5 @@ +--- +"emdash": patch +--- + +Fixes preview links 404ing on sites with a custom collection `url_pattern`. The content Preview button now resolves the collection's `url_pattern` (the same route the sitemap and "View published" links use) instead of the hard-coded `/{collection}/{id}`, falling back to `/{collection}/{id}` only when no pattern is configured. An explicit `pathPattern` or `EMDASH_PREVIEW_PATH_PATTERN` still takes precedence. diff --git a/packages/core/src/astro/routes/api/content/[collection]/[id]/preview-url.ts b/packages/core/src/astro/routes/api/content/[collection]/[id]/preview-url.ts index 71e43dad31..a2df8964ae 100644 --- a/packages/core/src/astro/routes/api/content/[collection]/[id]/preview-url.ts +++ b/packages/core/src/astro/routes/api/content/[collection]/[id]/preview-url.ts @@ -6,9 +6,18 @@ * Request body: * { * expiresIn?: string | number; // Default: "1h" - * pathPattern?: string; // Default: "/{collection}/{id}" (or EMDASH_PREVIEW_PATH_PATTERN) + * pathPattern?: string; // Overrides the resolved default (see below) * } * + * Path resolution precedence (highest first): + * 1. `pathPattern` in the request body (per-call override) + * 2. `EMDASH_PREVIEW_PATH_PATTERN` env (project-wide override) + * 3. the collection's configured `url_pattern` — so preview links match the + * same routes the sitemap and "View published" links already use (incl. + * custom permalinks like `/blog/{slug}`), resolved via the shared + * `interpolateUrlPattern` + `localizePath` helpers. + * 4. the generic `/{collection}/{id}` fallback. + * * Response: * { * url: string; // The preview URL with token @@ -23,9 +32,11 @@ import { apiError, apiSuccess, handleError, unwrapResult } from "#api/error.js"; import { parseOptionalBody, isParseError } from "#api/parse.js"; import { contentPreviewUrlBody } from "#api/schemas.js"; import { resolveSecretsCached } from "#config/secrets.js"; -import { getPreviewUrl } from "#preview/index.js"; +import { buildPreviewUrl, generatePreviewToken, getPreviewUrl } from "#preview/index.js"; +import { getCollectionInfoWithDb } from "#schema/query.js"; import { getI18nConfig } from "../../../../../../i18n/config.js"; +import { interpolateUrlPattern, localizePath } from "../../../../../../i18n/resolve.js"; export const prerender = false; @@ -49,12 +60,14 @@ export const POST: APIRoute = async ({ params, request, locals }) => { const { previewSecret } = await resolveSecretsCached(emdash.db); // Verify the content exists. The fetched item also yields the entry's - // locale, used below to resolve the `{locale}` placeholder. + // locale and slug, used below to resolve the public path. let entryLocale: string | null = null; + let entrySlug: string | null = null; if (emdash?.handleContentGet) { const result = await emdash.handleContentGet(collection, id); if (!result.success) return unwrapResult(result); entryLocale = result.data?.item?.locale ?? null; + entrySlug = result.data?.item?.slug ?? null; } // Parse request body @@ -62,15 +75,14 @@ export const POST: APIRoute = async ({ params, request, locals }) => { if (isParseError(body)) return body; const expiresIn = body.expiresIn || "1h"; - // Allow a project-wide default `pathPattern` so the admin's "View on site" - // link can match the site's actual route shape without each call having - // to override the default `/{collection}/{id}`. - const defaultPathPattern = import.meta.env.EMDASH_PREVIEW_PATH_PATTERN || "/{collection}/{id}"; - const pathPattern = body.pathPattern || defaultPathPattern; - - // Resolve the locale segment substituted for `{locale}`: empty when the - // entry is in the default locale and `prefixDefaultLocale` is `false`, - // the entry's own locale otherwise. + // A project-wide default `pathPattern` (body or env) always wins so callers + // can force a specific shape. When neither is set we resolve the + // collection's own `url_pattern` below. + const explicitPattern = body.pathPattern || import.meta.env.EMDASH_PREVIEW_PATH_PATTERN || null; + + // Resolve the locale segment substituted for the `{locale}` placeholder in + // an explicit pattern: empty when the entry is in the default locale and + // `prefixDefaultLocale` is `false`, the entry's own locale otherwise. const i18n = getI18nConfig(); let localeSegment = ""; if (entryLocale && i18n) { @@ -85,12 +97,40 @@ export const POST: APIRoute = async ({ params, request, locals }) => { const expiresAt = Math.floor(Date.now() / 1000) + expiresInSeconds; try { + // No explicit override: reuse the collection's `url_pattern` so the + // preview link points at the same route the sitemap and "View + // published" links already use (custom permalinks like `/blog/{slug}`). + // Without this the generic `/{collection}/{id}` fallback 404s on any + // site whose content isn't served at that path. + if (!explicitPattern) { + const collectionInfo = await getCollectionInfoWithDb(emdash.db, collection); + if (collectionInfo?.urlPattern) { + const path = interpolateUrlPattern({ + pattern: collectionInfo.urlPattern, + collection, + slug: entrySlug || id, + id, + }); + // `localizePath` returns null when the entry's locale isn't in the + // configured i18n list; fall back to the un-prefixed path so we + // still hand back a usable preview link rather than failing. + const localized = await localizePath(path, entryLocale ?? ""); + const token = await generatePreviewToken({ + contentId: `${collection}:${id}`, + expiresIn, + secret: previewSecret, + }); + const url = buildPreviewUrl({ path: localized ?? path, token }); + return apiSuccess({ url, expiresAt }); + } + } + const url = await getPreviewUrl({ collection, id, secret: previewSecret, expiresIn, - pathPattern, + pathPattern: explicitPattern || "/{collection}/{id}", locale: localeSegment, }); diff --git a/packages/core/tests/unit/api/preview-url-route.test.ts b/packages/core/tests/unit/api/preview-url-route.test.ts new file mode 100644 index 0000000000..f89e6276aa --- /dev/null +++ b/packages/core/tests/unit/api/preview-url-route.test.ts @@ -0,0 +1,133 @@ +import { Role } from "@emdash-cms/auth"; +import type { Kysely } from "kysely"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +import { handleContentCreate, handleContentGet } from "../../../src/api/index.js"; +import { POST as previewUrl } from "../../../src/astro/routes/api/content/[collection]/[id]/preview-url.js"; +import type { Database } from "../../../src/database/types.js"; +import { setI18nConfig } from "../../../src/i18n/config.js"; +import { _resetAstroI18nCacheForTests } from "../../../src/i18n/resolve.js"; +import { SchemaRegistry } from "../../../src/schema/registry.js"; +import { setupTestDatabaseWithCollections, teardownTestDatabase } from "../../utils/test-db.js"; + +/** + * Regression: the preview-url endpoint used a hard-coded `/{collection}/{id}` + * default, ignoring the collection's configured `url_pattern`. On any site + * whose content is served at a custom permalink (e.g. `/blog/{slug}`) the + * admin "Preview" button produced a link that 404'd. The sitemap and + * "View published" links already resolve the same `url_pattern`; the preview + * link must too. See discussion #1525 / PR #1526. + */ +describe("preview-url route — respects collection url_pattern", () => { + let db: Kysely; + + const call = async (collection: string, id: string, body: Record = {}) => { + const request = new Request( + `http://localhost/_emdash/api/content/${collection}/${id}/preview-url`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ); + const response = await previewUrl({ + params: { collection, id }, + request, + locals: { + emdash: { + db, + handleContentGet: (c: string, i: string) => handleContentGet(db, c, i), + }, + user: { id: "u1", role: Role.ADMIN }, + }, + } as unknown as Parameters[0]); + return response; + }; + + beforeEach(async () => { + db = await setupTestDatabaseWithCollections(); + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + setI18nConfig(null); + _resetAstroI18nCacheForTests(); + await teardownTestDatabase(db); + }); + + it("resolves the configured url_pattern into the preview link", async () => { + await new SchemaRegistry(db).updateCollection("post", { urlPattern: "/blog/{slug}" }); + const created = await handleContentCreate(db, "post", { + data: { title: "Hello World" }, + }); + const id = created.data!.item.id; + + const response = await call("post", id); + expect(response.status).toBe(200); + const { url } = (await response.json()).data as { url: string }; + + expect(url.startsWith("/blog/hello-world?_preview=")).toBe(true); + // The generic collection/id fallback must NOT leak through. + expect(url.startsWith("/post/")).toBe(false); + }); + + it("falls back to /{collection}/{id} when no url_pattern is configured", async () => { + const created = await handleContentCreate(db, "post", { + data: { title: "No Pattern" }, + }); + const id = created.data!.item.id; + + const response = await call("post", id); + expect(response.status).toBe(200); + const { url } = (await response.json()).data as { url: string }; + + expect(url.startsWith(`/post/${id}?_preview=`)).toBe(true); + }); + + it("lets an explicit pathPattern override the url_pattern", async () => { + await new SchemaRegistry(db).updateCollection("post", { urlPattern: "/blog/{slug}" }); + const created = await handleContentCreate(db, "post", { + data: { title: "Override Me" }, + }); + const id = created.data!.item.id; + + const response = await call("post", id, { pathPattern: "/custom/{id}" }); + expect(response.status).toBe(200); + const { url } = (await response.json()).data as { url: string }; + + expect(url.startsWith(`/custom/${id}?_preview=`)).toBe(true); + }); + + it("lets the EMDASH_PREVIEW_PATH_PATTERN env override win over the url_pattern", async () => { + vi.stubEnv("EMDASH_PREVIEW_PATH_PATTERN", "/env/{id}"); + await new SchemaRegistry(db).updateCollection("post", { urlPattern: "/blog/{slug}" }); + const created = await handleContentCreate(db, "post", { + data: { title: "Env Wins" }, + }); + const id = created.data!.item.id; + + const response = await call("post", id); + expect(response.status).toBe(200); + const { url } = (await response.json()).data as { url: string }; + + expect(url.startsWith(`/env/${id}?_preview=`)).toBe(true); + expect(url.startsWith("/blog/")).toBe(false); + }); + + it("prefixes the locale segment for a non-default-locale entry", async () => { + setI18nConfig({ defaultLocale: "en", locales: ["en", "de"], prefixDefaultLocale: false }); + _resetAstroI18nCacheForTests(); + await new SchemaRegistry(db).updateCollection("post", { urlPattern: "/blog/{slug}" }); + const created = await handleContentCreate(db, "post", { + data: { title: "Hallo Welt" }, + locale: "de", + }); + const id = created.data!.item.id; + + const response = await call("post", id); + expect(response.status).toBe(200); + const { url } = (await response.json()).data as { url: string }; + + expect(url.startsWith("/de/blog/hallo-welt?_preview=")).toBe(true); + }); +});