Unified Post Header — Implementation Plan
Unified Post Header — Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Hoist ContentHeader into page.tsx so all post types share one header call site; renderers become body-only.
Architecture: page.tsx fetches shared data (postConfig, stats, authors) for all post types, renders ContentHeader once, then delegates to body-only renderers. Observable early-return is removed; blob/pageMetadata fetch is skipped for Observable only.
Tech Stack: Next.js App Router (RSC), TypeScript, tRPC
Task 1: Simplify ObservablePage to body-only
Remove ContentHeader and the internal postConfig fetch from ObservablePage. It becomes a pure iframe renderer that receives its src URL as a prop.
Files:
- Modify:
components/post-renderers/ObservablePage.tsx
Step 1: Rewrite ObservablePage
Replace the entire component with a body-only version. The src and title are now computed by the caller (page.tsx).
interface ObservableEmbedProps {
src: string;
title: string;
}
export default function ObservableEmbed({ src, title }: ObservableEmbedProps) {
return (
<iframe
src={src}
className="w-full flex-1 border-none"
title={title}
/>
);
}
Step 2: Verify TypeScript compiles
Run: npx tsc --noEmit 2>&1 | head -30
Expected: Errors in page.tsx only (since it still imports the old interface). That's fine — Task 3 fixes page.tsx.
Step 3: Commit
git add components/post-renderers/ObservablePage.tsx
git commit -m "refactor: simplify ObservablePage to body-only iframe embed"
Task 2: Remove ContentHeader from DataPackageLayout
Strip the ContentHeader call and author-resolution logic from DataPackageLayout. It keeps everything else (metadata table, data files, previews, JSON-LD, premium warning).
Files:
- Modify:
components/layouts/datapackage.tsx
Step 1: Remove ContentHeader import and rendering
Remove:
- The
ContentHeaderimport - The
Authorinterface (no longer needed here) - The
resolvedAuthorslogic - The
statsprop - The
<div className="not-prose"><ContentHeader .../></div>block
The component keeps blob, post, and children props. The post interface drops authors and the component drops the stats prop.
Updated props interface:
interface Props extends React.PropsWithChildren {
blob: Blob;
post: {
id: string;
projectName: string;
title: string | null;
description: string | null;
modifiedAt: Date | null;
publication: {
slug: string;
customDomain: string | null;
owner: {
ghUsername: string | null;
};
};
};
}
The JSX drops the <div className="not-prose"><ContentHeader .../></div> block entirely. Everything else stays.
Step 2: Verify TypeScript compiles
Run: npx tsc --noEmit 2>&1 | head -30
Expected: Errors in page.tsx only (passing extra stats prop). Task 3 fixes this.
Step 3: Commit
git add components/layouts/datapackage.tsx
git commit -m "refactor: remove ContentHeader from DataPackageLayout"
Task 3: Inline StoryLayout prose wrapper, remove story-layout.tsx
After removing ContentHeader, StoryLayout is just a <section className="prose ..."> wrapper. Inline this in page.tsx and delete the file.
Files:
- Delete:
components/story-layout.tsx
Step 1: Delete story-layout.tsx
The prose className to preserve is:
prose max-w-none font-body font-normal text-primary dark:prose-invert prose-headings:font-title prose-headings:tracking-tight prose-headings:text-primary-strong prose-a:break-words
This will be inlined in page.tsx in Task 4.
Step 2: Commit
git rm components/story-layout.tsx
git commit -m "refactor: delete StoryLayout, will inline prose wrapper in page.tsx"
Task 4: Restructure page.tsx — hoist ContentHeader, unify data fetching
This is the main task. Restructure page.tsx to:
- Fetch shared data for all post types
- Resolve authors uniformly
- Render
ContentHeaderonce - Delegate to body-only renderers
Files:
- Modify:
app/[publication]/[post]/[[...slug]]/page.tsx
Step 1: Rewrite page.tsx
Key changes from current code:
-
Remove Observable early return (line 43-45 currently). Observable goes through the same shared-data flow.
-
Fetch shared data for all types —
postConfigandstatsare fetched unconditionally.blobandpageMetadataare fetched only for non-Observable types. -
Author resolution — unified fallback chain in page.tsx:
post.authorsfrom DB (mapped to Author shape)postConfig?.authors(string-based, from config file)- DataPackage
contributors(from pageMetadata, when datapackage exists) post.publication.owner.ghUsername(last resort)
-
ContentHeader rendered once with type-aware props.
-
Body rendering — type-specific, no headers:
- Observable:
<ObservableEmbed src={...} title={...} /> - DataPackage:
<DataPackageLayout blob={blob} post={post}>{mdxContent}</DataPackageLayout> - Story:
<article><section className="prose ...">{mdxContent}</section></article>
- Observable:
Here is the full rewrite:
import { notFound } from "next/navigation";
import { PageMetadata } from "@/types";
import { parsePublicationParam } from "@/lib/parse-publication-param";
import { api } from "@/trpc/server";
import ObservableEmbed from "@/components/post-renderers/ObservablePage";
import { DataPackageLayout } from "@/components/layouts/datapackage";
import MDX from "@/components/MDX";
import EditPageButton from "@/components/edit-page-button";
import ContentHeader from "@/components/content-header";
import { RouterOutputs } from "@/server/api/root";
import { getConfig } from "@/lib/app-config";
import { getRawFilePermalinkBase } from "@/lib/url-utils";
import { resolveLink } from "@/lib/resolve-link";
const config = getConfig();
interface RouteParams {
publication: string;
post: string;
slug?: string[];
}
export default async function PostPage({ params }: { params: RouteParams }) {
const parsedPublication = parsePublicationParam(params.publication);
const postName = decodeURIComponent(params.post);
const slug = params.slug ? params.slug.join("/") : "/";
const decodedSlug = slug.replace(/%20/g, "+");
let post: RouterOutputs["post"]["get"];
try {
post = await api.post.get.query({
publication: parsedPublication,
postSlug: postName,
});
} catch {
notFound();
}
if (!post) {
notFound();
}
// ── Shared data: fetched for ALL post types ──────────────────────
const [postConfig, stats] = await Promise.all([
api.post.getConfig.query({ postId: post.id }).catch(() => null),
api.post.getStats.query({ postId: post.id }).catch(() => null),
]);
// ── Observable-only: compute iframe src ──────────────────────────
const isObservable = post.type === "OBSERVABLE";
// ── Non-observable: fetch blob + pageMetadata ────────────────────
let blob: Awaited<ReturnType<typeof api.post.getBlob.query>> | null = null;
let pageMetadata: PageMetadata | null = null;
let pageContent = "";
let postPermalinks: string[] = [];
if (!isObservable) {
const [fetchedBlob, fetchedPermalinks] = await Promise.all([
api.post.getBlob.query({ postId: post.id, slug: decodedSlug }).catch(() => null),
api.post.getPermalinks.query({ postId: post.id }).catch(() => [] as string[]),
]);
if (!fetchedBlob) notFound();
blob = fetchedBlob;
postPermalinks = fetchedPermalinks;
pageMetadata = blob.metadata as PageMetadata | null;
try {
pageContent = (await api.post.getBlobContent.query({ id: blob.id })) ?? "";
} catch {
notFound();
}
}
// ── Author resolution (unified fallback chain) ───────────────────
const dbAuthors = (post.authors ?? []).map((a) => ({
user: {
id: a.user.id,
name: a.user.name ?? a.user.username ?? "",
username: a.user.username ?? "",
image: a.user.image,
},
}));
const configAuthors =
postConfig?.authors && postConfig.authors.length > 0
? postConfig.authors.map((author) => ({
user: { id: author, username: author, name: author },
}))
: [];
const dpContributors =
pageMetadata?.datapackage?.contributors?.map((c: any) => ({
user: {
id: "",
username: c.title.toLowerCase().replace(/\s+/g, ""),
name: c.title,
image: null,
},
})) ?? [];
const ownerFallback = post.publication.owner.ghUsername
? [
{
user: {
id: "",
username: post.publication.owner.ghUsername,
name: post.publication.owner.ghUsername,
image: null,
},
},
]
: [];
const authors =
dbAuthors.length > 0
? dbAuthors
: configAuthors.length > 0
? configAuthors
: dpContributors.length > 0
? dpContributors
: ownerFallback;
// ── Header props (type-aware) ────────────────────────────────────
const isDatapackage = Boolean(pageMetadata?.datapackage);
const title = isObservable
? post.title || post.projectName
: isDatapackage
? post.title || post.projectName
: pageMetadata?.title ?? post.projectName;
const description = isObservable
? post.description
: isDatapackage
? post.description ?? ""
: pageMetadata?.description ?? post.description;
const date = isObservable
? post.modifiedAt
: isDatapackage
? post.modifiedAt
: pageMetadata?.date ?? post.modifiedAt;
const dateFormat = isObservable || isDatapackage ? "relative" as const : "human" as const;
const rawFilePermalinkBase = getRawFilePermalinkBase(post);
const resolvedImage =
!isObservable && !isDatapackage && pageMetadata?.image
? resolveLink({
link: pageMetadata.image,
filePath: blob?.path ?? "",
prefixPath: rawFilePermalinkBase,
})
: undefined;
// ── Shared UI bits ───────────────────────────────────────────────
const showEditLink = postConfig?.showEditLink ?? false;
const editUrl =
blob && !isObservable
? `https://github.com/${post.ghRepository}/edit/${post.ghBranch}/${blob.path}`
: "";
// ── Render ───────────────────────────────────────────────────────
const header = (
<ContentHeader
title={title}
description={description}
date={date}
dateFormat={dateFormat}
image={resolvedImage}
authors={authors}
postId={post.id}
postName={post.projectName}
publicationSlug={post.publication.slug}
stats={stats ?? undefined}
showLikes={true}
showViews={true}
showDownloads={isDatapackage}
showImage={!isObservable && !isDatapackage}
/>
);
if (isObservable) {
const absRawBase = getRawFilePermalinkBase(post, { absolute: true });
const filePath = decodedSlug === "/" ? "index.html" : `${decodedSlug}.html`;
const iframeSrc = `${absRawBase}/${filePath}?preview=true`;
return (
<div className="flex h-full w-full flex-col">
<div className="mx-auto max-w-7xl px-8 pt-8 sm:px-10 lg:px-12">
{header}
</div>
<ObservableEmbed src={iframeSrc} title={post.projectName} />
</div>
);
}
const mdxContent = (
<MDX
source={pageContent}
blob={blob!}
post={post}
postPermalinks={postPermalinks}
/>
);
if (isDatapackage) {
return (
<>
{header}
<DataPackageLayout blob={blob!} post={post}>
{mdxContent}
</DataPackageLayout>
{showEditLink && <EditPageButton url={editUrl} />}
</>
);
}
// Story / default
return (
<>
{header}
<article>
<section className="prose max-w-none font-body font-normal text-primary dark:prose-invert prose-headings:font-title prose-headings:tracking-tight prose-headings:text-primary-strong prose-a:break-words">
{mdxContent}
</section>
</article>
{showEditLink && <EditPageButton url={editUrl} />}
</>
);
}
Step 2: Verify TypeScript compiles
Run: npx tsc --noEmit 2>&1 | head -50
Expected: Clean compilation, no errors.
Step 3: Commit
git add app/[publication]/[post]/[[...slug]]/page.tsx
git commit -m "refactor: hoist ContentHeader into page.tsx, unify header across all post types"
Task 5: Fix ContentHeader test ID for e2e compatibility
The e2e fixtures (observable-post-page.ts, dataset-post-page.ts) look for data-testid="page-header" but ContentHeader currently uses data-testid="content-header". Align them.
Files:
- Modify:
components/content-header.tsx
Step 1: Update the test ID
In content-header.tsx, change data-testid="content-header" to data-testid="page-header".
Step 2: Commit
git add components/content-header.tsx
git commit -m "fix: align ContentHeader test ID with e2e fixtures"
Task 6: TypeScript build verification
Step 1: Full type check
Run: npx tsc --noEmit
Expected: Clean — zero errors.
Step 2: Fix any type errors found
Address any remaining issues from the refactoring.
Step 3: Commit fixes if any
Task 7: Manual smoke test / e2e
Step 1: Run e2e tests
Run: npm run test:e2e
The key tests:
e2e/renderer/observable.spec.ts— header visible, iframe renders, meta tags correcte2e/renderer/dataset-datapackage-json.spec.ts— header with title/description/likes/views, metadata table, data files, previews
Step 2: Fix any failures
If e2e tests fail, investigate and fix. The most likely issue is the container/wrapper structure changing and selectors not matching.
Step 3: Commit fixes if any
Notes
layout.tsxalready fetchespostConfigfor analytics/CSS. The page.tsx also fetches it for author resolution andshowEditLink. This is a minor duplication — Next.js RSC request deduplication should handle it (same tRPC query in the same request).- The
configimport (getConfig()) in page.tsx appears unused after this refactoring — can be removed as a cleanup. - The
StoryLayoutdeletion means any other imports of it need updating. Check withgrep -r "story-layout"before deleting.