Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/preview-url-respects-url-pattern.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;

Expand All @@ -49,28 +60,29 @@ 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
const body = await parseOptionalBody(request, contentPreviewUrlBody, {});
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) {
Expand All @@ -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,
});

Expand Down
133 changes: 133 additions & 0 deletions packages/core/tests/unit/api/preview-url-route.test.ts
Original file line number Diff line number Diff line change
@@ -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<Database>;

const call = async (collection: string, id: string, body: Record<string, unknown> = {}) => {
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<typeof previewUrl>[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 () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] The new url_pattern code path calls localizePath, which handles locale prefixes and custom path/codes mappings, but the current tests run with no i18n config so that logic is never exercised. Consider adding a test that sets an i18n config (via setI18nConfig) and verifies the preview link receives the expected locale prefix for a non-default-locale entry.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in e766a63: the test sets setI18nConfig({ defaultLocale: "en", locales: ["en", "de"], prefixDefaultLocale: false }) (plus _resetAstroI18nCacheForTests(), same pattern as the sitemap route tests), creates a de entry, and asserts the preview link comes back as /de/blog/hallo-welt?_preview=….

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 () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] The new tests cover the body-level pathPattern override and the collection url_pattern branch, but the EMDASH_PREVIEW_PATH_PATTERN env-override path in the route is not exercised. Because that branch is supposed to stay at the same precedence as a body override, consider adding a test that confirms it still wins over a configured url_pattern — even if it has to rely on build-time stubs for import.meta.env.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in e766a63: vi.stubEnv("EMDASH_PREVIEW_PATH_PATTERN", "/env/{id}") (stubs import.meta.env under vitest) with a collection that also has url_pattern: /blog/{slug} configured — the test asserts the env pattern wins and /blog/ doesn't leak through.

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);
});
});
Loading