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 ContentHeader import
  • The Author interface (no longer needed here)
  • The resolvedAuthors logic
  • The stats prop
  • 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:

  1. Fetch shared data for all post types
  2. Resolve authors uniformly
  3. Render ContentHeader once
  4. Delegate to body-only renderers

Files:

  • Modify: app/[publication]/[post]/[[...slug]]/page.tsx

Step 1: Rewrite page.tsx

Key changes from current code:

  1. Remove Observable early return (line 43-45 currently). Observable goes through the same shared-data flow.

  2. Fetch shared data for all typespostConfig and stats are fetched unconditionally. blob and pageMetadata are fetched only for non-Observable types.

  3. Author resolution — unified fallback chain in page.tsx:

    • post.authors from 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)
  4. ContentHeader rendered once with type-aware props.

  5. 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>

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 correct
  • e2e/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.tsx already fetches postConfig for analytics/CSS. The page.tsx also fetches it for author resolution and showEditLink. This is a minor duplication — Next.js RSC request deduplication should handle it (same tRPC query in the same request).
  • The config import (getConfig()) in page.tsx appears unused after this refactoring — can be removed as a cleanup.
  • The StoryLayout deletion means any other imports of it need updating. Check with grep -r "story-layout" before deleting.