Skip to content

Commit 3b12874

Browse files
authored
feat: draft mode API routes + review follow-ups (#678)
Completes Visual Editing end-to-end:\n\nDraft mode routes:\n- /api/draft-mode/enable — validates SANITY_PREVIEW_SECRET (fail-closed: 503 if missing, 401 if invalid), sets httpOnly cookie, redirects\n- /api/draft-mode/disable — clears cookie, redirects\n\nPer-request draft mode:\n- loadQuery() accepts draftMode param (from Astro.cookies)\n- isDraftMode() checks site-wide env var OR per-request cookie\n- All 12 content pages pass cookie state to loadQuery\n- BaseLayout checks cookie OR env var for VisualEditing component\n\nReview follow-ups:\n- escapeXml extracted to src/utils/xml.ts (was duplicated in both RSS files)\n- Sitemap static page lastmod uses latest content _updatedAt (not new Date())"
1 parent 74e83a5 commit 3b12874

File tree

20 files changed

+157
-50
lines changed

20 files changed

+157
-50
lines changed

apps/web/src/layouts/BaseLayout.astro

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ interface Props {
1010
const { title, description = "CodingCat.dev — Purrfect Web Tutorials" } = Astro.props;
1111
1212
const visualEditingEnabled =
13-
import.meta.env.PUBLIC_SANITY_VISUAL_EDITING_ENABLED === "true";
13+
import.meta.env.PUBLIC_SANITY_VISUAL_EDITING_ENABLED === "true" ||
14+
Astro.cookies.has("__sanity_preview");
1415
---
1516

1617
<!doctype html>

apps/web/src/pages/[slug].astro

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ if (reservedSlugs.includes(slug!)) {
1414
return Astro.redirect(`/${slug}`);
1515
}
1616
17-
const { data: page } = await loadQuery<any>({ query: pageQuery, params: { slug } });
17+
const draftMode = Astro.cookies.has("__sanity_preview");
18+
const { data: page } = await loadQuery<any>({ query: pageQuery, params: { slug }, draftMode });
1819
1920
if (!page) {
2021
return new Response(null, { status: 404 });
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/**
2+
* Disable draft mode for Sanity Visual Editing.
3+
*
4+
* Clears the preview cookie and redirects back to the page.
5+
*
6+
* Usage: /api/draft-mode/disable?slug=/post/my-post
7+
*/
8+
import type { APIRoute } from "astro";
9+
10+
export const prerender = false;
11+
12+
export const GET: APIRoute = async ({ url, cookies, redirect }) => {
13+
const slug = url.searchParams.get("slug") || "/";
14+
15+
// Delete the preview cookie
16+
cookies.delete("__sanity_preview", { path: "/" });
17+
18+
// Redirect back to the page (now showing published content)
19+
return redirect(slug, 307);
20+
};
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/**
2+
* Enable draft mode for Sanity Visual Editing.
3+
*
4+
* Called by the Studio's Presentation Tool when loading the preview iframe.
5+
* Validates SANITY_PREVIEW_SECRET, sets a __sanity_preview cookie, and
6+
* redirects to the requested page.
7+
*
8+
* Usage: /api/draft-mode/enable?secret=<value>&slug=/post/my-post
9+
*/
10+
import type { APIRoute } from "astro";
11+
import { env } from "cloudflare:workers";
12+
13+
export const prerender = false;
14+
15+
export const GET: APIRoute = async ({ url, cookies, redirect }) => {
16+
const secret = url.searchParams.get("secret");
17+
const slug = url.searchParams.get("slug") || "/";
18+
19+
// Fail-closed: require SANITY_PREVIEW_SECRET to be configured
20+
const expectedSecret = (env as Record<string, string>).SANITY_PREVIEW_SECRET;
21+
if (!expectedSecret) {
22+
return new Response("Draft mode not configured — SANITY_PREVIEW_SECRET is missing", {
23+
status: 503,
24+
});
25+
}
26+
27+
// Validate the secret
28+
if (!secret || secret !== expectedSecret) {
29+
return new Response("Invalid preview secret", { status: 401 });
30+
}
31+
32+
// Set the preview cookie — httpOnly so client JS can't tamper with it
33+
cookies.set("__sanity_preview", "1", {
34+
path: "/",
35+
httpOnly: true,
36+
sameSite: "none",
37+
secure: true,
38+
// 1 hour — long enough for an editing session
39+
maxAge: 60 * 60,
40+
});
41+
42+
// Redirect to the requested page
43+
return redirect(slug, 307);
44+
};

apps/web/src/pages/author/[slug].astro

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ import { authorQuery } from "@/lib/queries";
77
export const prerender = false;
88
99
const { slug } = Astro.params;
10-
const { data: author } = await loadQuery<any>({ query: authorQuery, params: { slug } });
10+
const draftMode = Astro.cookies.has("__sanity_preview");
11+
const { data: author } = await loadQuery<any>({ query: authorQuery, params: { slug }, draftMode });
1112
1213
if (!author) {
1314
return Astro.redirect("/404");

apps/web/src/pages/authors.astro

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ const page = Number.isNaN(rawPage) || rawPage < 1 ? 1 : Math.floor(rawPage);
1212
const perPage = 12;
1313
const offset = (page - 1) * perPage;
1414
15+
const draftMode = Astro.cookies.has("__sanity_preview");
1516
const [itemsResult, totalCount] = await Promise.all([
16-
loadQuery<any[]>({ query: authorListQuery, params: { offset, end: offset + perPage } }),
17+
loadQuery<any[]>({ query: authorListQuery, params: { offset, end: offset + perPage }, draftMode }),
1718
sanityFetch<number>(authorCountQuery),
1819
]);
1920
const items = itemsResult.data;

apps/web/src/pages/blog.astro

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@ const page = Number.isNaN(rawPage) || rawPage < 1 ? 1 : Math.floor(rawPage);
1313
const perPage = 12;
1414
const offset = (page - 1) * perPage;
1515
16+
const draftMode = Astro.cookies.has("__sanity_preview");
1617
const [postsResult, totalCount] = await Promise.all([
17-
loadQuery<any[]>({ query: postListQuery, params: { offset, end: offset + perPage } }),
18+
loadQuery<any[]>({ query: postListQuery, params: { offset, end: offset + perPage }, draftMode }),
1819
sanityFetch<number>(postCountQuery),
1920
]);
2021
const posts = postsResult.data;

apps/web/src/pages/guest/[slug].astro

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ import { guestQuery } from "@/lib/queries";
77
export const prerender = false;
88
99
const { slug } = Astro.params;
10-
const { data: guest } = await loadQuery<any>({ query: guestQuery, params: { slug } });
10+
const draftMode = Astro.cookies.has("__sanity_preview");
11+
const { data: guest } = await loadQuery<any>({ query: guestQuery, params: { slug }, draftMode });
1112
1213
if (!guest) {
1314
return Astro.redirect("/404");

apps/web/src/pages/guests.astro

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ const page = Number.isNaN(rawPage) || rawPage < 1 ? 1 : Math.floor(rawPage);
1212
const perPage = 12;
1313
const offset = (page - 1) * perPage;
1414
15+
const draftMode = Astro.cookies.has("__sanity_preview");
1516
const [itemsResult, totalCount] = await Promise.all([
16-
loadQuery<any[]>({ query: guestListQuery, params: { offset, end: offset + perPage } }),
17+
loadQuery<any[]>({ query: guestListQuery, params: { offset, end: offset + perPage }, draftMode }),
1718
sanityFetch<number>(guestCountQuery),
1819
]);
1920
const items = itemsResult.data;

apps/web/src/pages/index.astro

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import { homePageQuery } from "@/lib/queries";
66
77
export const prerender = false;
88
9-
const { data: homePage } = await loadQuery<any>({ query: homePageQuery });
9+
const draftMode = Astro.cookies.has("__sanity_preview");
10+
const { data: homePage } = await loadQuery<any>({ query: homePageQuery, draftMode });
1011
---
1112

1213
<BaseLayout title="CodingCat.dev — Purrfect Web Tutorials">

0 commit comments

Comments
 (0)