Skip to content
Merged
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
14 changes: 10 additions & 4 deletions apps/web/src/app/[...slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Logger } from "@workspace/logger";
import {
DRAFT_MODE_ENABLED,
DRAFTS_WITHOUT_SESSION,
type DynamicFetchOptions,
getDynamicFetchOptions,
resolvePageFetchOptions,
Expand All @@ -10,6 +10,7 @@ import {
} from "@workspace/sanity/live";
import { querySlugPageData, querySlugPagePaths } from "@workspace/sanity/query";
import type { Metadata } from "next";
import { draftMode } from "next/headers";
import { notFound } from "next/navigation";
import { Suspense } from "react";

Expand Down Expand Up @@ -75,15 +76,20 @@ export async function generateMetadata({
export default async function SlugPage({
params,
}: Readonly<{ params: Promise<SlugParams> }>) {
// Dev/preview: draft-aware path so unpublished pages still render.
if (DRAFT_MODE_ENABLED) {
const { isEnabled: isDraftMode } = await draftMode();

// A Presentation session (any environment) or local dev takes the draft-aware
// path, so draft edits and unpublished pages render.
if (isDraftMode || DRAFTS_WITHOUT_SESSION) {
return (
<Suspense fallback={<HeroFallback />}>
<SlugPageInner params={params} />
</Suspense>
);
}
// Production: static published render with a real 404, no skeleton.

// Everyone else: published render off the same cached fetch as before, and a
// real 404 — not a soft one streamed inside Suspense.
const { slug } = await params;
const pageData = await getPublishedSlugPage(slug);
if (!pageData) {
Expand Down
14 changes: 10 additions & 4 deletions apps/web/src/app/blog/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Logger } from "@workspace/logger";
import {
DRAFT_MODE_ENABLED,
DRAFTS_WITHOUT_SESSION,
type DynamicFetchOptions,
getDynamicFetchOptions,
resolvePageFetchOptions,
Expand All @@ -15,6 +15,7 @@ import {
} from "@workspace/sanity-blocks/internal/rich-text";
import { SanityImage } from "@workspace/sanity-blocks/internal/sanity-image";
import type { Metadata } from "next";
import { draftMode } from "next/headers";
import { notFound } from "next/navigation";
import { Suspense } from "react";

Expand Down Expand Up @@ -82,15 +83,20 @@ export default async function BlogSlugPage({
}: Readonly<{
params: Promise<BlogParams>;
}>) {
// Dev/preview: draft-aware path so unpublished posts still render.
if (DRAFT_MODE_ENABLED) {
const { isEnabled: isDraftMode } = await draftMode();

// A Presentation session (any environment) or local dev takes the draft-aware
// path, so draft edits and unpublished posts render.
if (isDraftMode || DRAFTS_WITHOUT_SESSION) {
return (
<Suspense fallback={<BlogFallback />}>
<BlogSlugInner params={params} />
</Suspense>
);
}
// Production: static published render with a real 404, no skeleton.

// Everyone else: published render off the same cached fetch as before, and a
// real 404 — not a soft one streamed inside Suspense.
const { slug } = await params;
const data = await getPublishedBlogPage(slug);
if (!data) {
Expand Down
121 changes: 104 additions & 17 deletions apps/web/src/app/blog/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,21 +130,92 @@ async function fetchBlogIndexPageBlogsCount({
return res.data;
}

export async function generateMetadata(): Promise<Metadata> {
const { perspective } = await getDynamicFetchOptions();
type BlogPageProps = Readonly<{
searchParams: Promise<{
page?: string;
category?: string;
}>;
}>;

export async function generateMetadata({
searchParams,
}: BlogPageProps): Promise<Metadata> {
const [{ page, category }, { perspective }] = await Promise.all([
searchParams,
getDynamicFetchOptions(),
]);
await assertBlogPageInRange({
page,
category: category ?? "",
perspective,
});
const { data: result } = await sanityFetchMetadata({
query: queryBlogIndexPageData,
perspective,
});
return seoFromDocument(result, { slug: "/blog" });
}

type BlogPageProps = Readonly<{
searchParams: Promise<{
page?: string;
category?: string;
}>;
}>;
/**
* `?page=` as a 1-based page number, or `null` when the value is present but
* not a positive integer — a bogus URL that should 404 rather than quietly
* serve page 1 under a different address.
*/
function parseBlogPageParam(page: string | undefined): number | null {
if (page === undefined || page === "") {
return 1;
}
const parsed = Number(page);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
}

/**
* 404s a `?page=` past the last page. Lives in `generateMetadata` because that
* resolves before the response status is committed; the same `notFound()`
* inside the page's Suspense boundary only ever streams a soft 404, since PPR
* has already flushed the prerendered shell with a 200.
*
* `totalPages` floors at 1, so page 1 always survives: an empty blog, and an
* empty category filter, are legitimate results rather than dead URLs.
*/
async function assertBlogPageInRange({
page,
category,
perspective,
}: {
page: string | undefined;
category: string;
perspective: DynamicFetchOptions["perspective"];
}) {
const currentPage = parseBlogPageParam(page);
if (currentPage === null) {
notFound();
}
if (currentPage === 1) {
return;
}

const [totalCount] = await handleErrors(
fetchBlogIndexPageBlogsCount({
category,
excludeFeatured: !category,
perspective,
stega: false,
})
);
// A failed count is a server problem, not a missing page — let the page
// render its error state instead of masking it as a 404.
if (totalCount === null || totalCount === undefined) {
return;
}
const { totalPages } = calculateBlogPaginationMetadata(
totalCount,
currentPage
);
if (currentPage > totalPages) {
notFound();
}
}

export default function BlogIndexPage({ searchParams }: BlogPageProps) {
return (
Expand Down Expand Up @@ -187,18 +258,27 @@ async function DynamicBlogIndex({ searchParams }: BlogPageProps) {
searchParams,
getDynamicFetchOptions(),
]);
const parsedPage = Number(page);
const currentPage =
Number.isInteger(parsedPage) && parsedPage > 0 ? parsedPage : 1;
const currentPage = parseBlogPageParam(page);
if (currentPage === null) {
notFound();
}
const activeCategory = category ?? "";

// Keyed so a page/category change mounts a *new* boundary. Without it React
// keeps the previous page's posts on screen for the whole round trip — the
// URL flips to `?page=2` while the grid still shows page 1.
return (
<BlogIndexView
activeCategory={activeCategory}
currentPage={currentPage}
perspective={perspective}
stega={stega}
/>
<Suspense
fallback={<BlogIndexShell />}
key={`${activeCategory}:${currentPage}`}
>
<BlogIndexView
activeCategory={activeCategory}
currentPage={currentPage}
perspective={perspective}
stega={stega}
/>
</Suspense>
);
}

Expand Down Expand Up @@ -250,6 +330,13 @@ async function BlogIndexView({
currentPage
);

// Past the last page is a dead URL, not an empty list. `totalPages` floors at
// 1, so page 1 still renders when there is nothing to show — including an
// empty category filter, which is a legitimate result rather than a 404.
if (currentPage > paginationMetadata.totalPages) {
notFound();
}

const { start: blogStart, end: blogEnd } =
getBlogPaginationRange(currentPage);

Expand Down
63 changes: 39 additions & 24 deletions apps/web/src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import "@workspace/ui/globals.css";

import {
DRAFT_MODE_ENABLED,
DRAFTS_WITHOUT_SESSION,
type DynamicFetchOptions,
getDynamicFetchOptions,
SanityLive,
Expand Down Expand Up @@ -46,29 +46,26 @@ export default async function RootLayout({
prefetchDNS("https://cdn.sanity.io");
// In local dev, nav/footer follow drafts too (like page content), so draft
// navbar/footer/settings edits are visible without a Presentation session.
// Production stays static published (DRAFT_MODE_ENABLED is false).
const showDrafts = DRAFT_MODE_ENABLED;
// Presentation overlay (preview bar, visual editing) needs a real session.
const isDraftMode = DRAFT_MODE_ENABLED && (await draftMode()).isEnabled;
// Production stays static published.
const showDrafts = DRAFTS_WITHOUT_SESSION;
return (
<html lang="en" suppressHydrationWarning>
<body
className={`${fontSans.variable} ${fontMono.variable} font-sans antialiased`}
>
<Providers>
<ScrollToTop />
{showDrafts ? (
<Suspense fallback={<NavbarSkeleton />}>
<DynamicNavbar />
</Suspense>
) : (
<CachedNavbar perspective="published" stega={false} />
)}
<div
className="-mt-16 relative z-10 min-h-dvh bg-background pt-16"
style={{ marginBottom: "var(--footer-height)" }}
>
{children}
<div style={{ marginBottom: "var(--footer-height)" }}>
{showDrafts ? (
<Suspense fallback={<NavbarSkeleton />}>
<DynamicNavbar />
</Suspense>
) : (
<CachedNavbar perspective="published" stega={false} />
)}
<div className="-mt-16 relative z-10 min-h-dvh bg-background pt-16">
{children}
</div>
</div>
<StickyFooter>
{showDrafts ? (
Expand All @@ -79,22 +76,40 @@ export default async function RootLayout({
<CachedFooter perspective="published" stega={false} />
)}
</StickyFooter>
<SanityLive action={revalidateSyncTags} includeDrafts={isDraftMode} />
{/* Reads draftMode(), so it must stay behind Suspense — otherwise the
whole layout opts out of prerendering for every visitor. */}
<Suspense fallback={null}>
<LivePreviewLayer />
</Suspense>
<Suspense fallback={null}>
<CombinedJsonLd includeOrganization includeWebsite />
</Suspense>
{isDraftMode && (
<>
<PreviewBar />
<VisualEditing />
</>
)}
</Providers>
</body>
</html>
);
}

/**
* Live updates plus the Presentation overlay. The overlay renders wherever a
* validated draft-mode session exists — production included — which is what
* lets the deployed Studio preview the live site.
*/
async function LivePreviewLayer() {
const { isEnabled: isDraftMode } = await draftMode();
return (
<>
<SanityLive action={revalidateSyncTags} includeDrafts={isDraftMode} />
{isDraftMode && (
<>
<PreviewBar />
<VisualEditing />
</>
)}
</>
);
}

async function DynamicNavbar() {
const { perspective, stega } = await getDynamicFetchOptions();
return <CachedNavbar perspective={perspective} stega={stega} />;
Expand Down
25 changes: 15 additions & 10 deletions apps/web/src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {
DRAFT_MODE_ENABLED,
DRAFTS_WITHOUT_SESSION,
type DynamicFetchOptions,
getDynamicFetchOptions,
resolvePageFetchOptions,
Expand All @@ -8,6 +8,7 @@ import {
} from "@workspace/sanity/live";
import { queryHomePageData } from "@workspace/sanity/query";
import type { Metadata } from "next";
import { draftMode } from "next/headers";
import { Suspense } from "react";

import { PageBuilderJsonLd } from "@/components/page-builder-json-ld";
Expand All @@ -24,16 +25,20 @@ export async function generateMetadata(): Promise<Metadata> {
return seoFromDocument(homePageData, { slug: "/" });
}

export default function Page() {
// Production static-renders published; dev/preview streams drafts below.
if (!DRAFT_MODE_ENABLED) {
return <CachedHome perspective="published" stega={false} />;
export default async function Page() {
const { isEnabled: isDraftMode } = await draftMode();

// A Presentation session (any environment) or local dev streams drafts.
if (isDraftMode || DRAFTS_WITHOUT_SESSION) {
return (
<Suspense fallback={<HeroFallback />}>
<HomeContent />
</Suspense>
);
}
return (
<Suspense fallback={<HeroFallback />}>
<HomeContent />
</Suspense>
);

// Everyone else: published render off the same cache entry as before.
return <CachedHome perspective="published" stega={false} />;
}

async function HomeContent() {
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/elements/menu-link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export function MenuLink({

return (
<Link
className="group flex items-start gap-3 rounded-none p-3 focus-ring-inset hover:bg-zinc-200 dark:hover:bg-zinc-800"
className="hover-surface group flex items-start gap-3 rounded-none p-3 focus-ring-inset"
href={href}
onClick={onClick}
>
Expand Down
Loading
Loading