Skip to content

Commit 1b56b30

Browse files
authored
Merge pull request #24 from psatomas/refactor/research-repository-abstraction
refactor(research): introduce a repository abstraction behind the public pages
2 parents 48af1af + ad9259d commit 1b56b30

8 files changed

Lines changed: 286 additions & 136 deletions

File tree

src/app/research/[slug]/page.tsx

Lines changed: 17 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,9 @@
11
import Link from "next/link";
22
import { notFound } from "next/navigation";
3-
import type { ComponentType } from "react";
43
import { Container } from "@/components/ui/container";
54
import { MonoLabel } from "@/components/ui/mono-label";
65
import { Tag } from "@/components/ui/tag";
7-
import {
8-
getAdjacentResearchArticles,
9-
getAllResearchArticles,
10-
getResearchArticleBySlug,
11-
} from "@/lib/research";
12-
import type { ResearchArticleMetadata } from "@/types";
6+
import { researchRepository } from "@/lib/research";
137

148
// Prerenders every known article at build time — same as
159
// /projects/[slug]. Deliberately NOT setting `dynamicParams = false` here:
@@ -21,7 +15,7 @@ import type { ResearchArticleMetadata } from "@/types";
2115
// /projects/[slug]/page.tsx already does successfully in production — is
2216
// the pattern actually proven to work on this stack.
2317
export async function generateStaticParams() {
24-
const articles = await getAllResearchArticles();
18+
const articles = await researchRepository.getPublishedArticles();
2519
return articles.map((article) => ({ slug: article.slug }));
2620
}
2721

@@ -40,17 +34,15 @@ export default async function ResearchArticlePage(
4034
) {
4135
const { slug } = await props.params;
4236

43-
const known = await getResearchArticleBySlug(slug);
44-
if (!known) notFound();
37+
// The page asks the repository for an article and either gets a fully
38+
// renderable one back or doesn't — it never knows or cares whether that
39+
// meant a slug lookup in an array, a file import, or (eventually) a D1
40+
// query for a row with status = 'published'.
41+
const article = await researchRepository.getPublishedArticleBySlug(slug);
42+
if (!article) notFound();
4543

46-
const { default: Article, metadata } = (await import(
47-
`@/content/research/${slug}.mdx`
48-
)) as {
49-
default: ComponentType;
50-
metadata: ResearchArticleMetadata;
51-
};
52-
53-
const { newer, older } = await getAdjacentResearchArticles(slug);
44+
const { Content } = article;
45+
const { newer, older } = await researchRepository.getAdjacentPublishedArticles(slug);
5446

5547
return (
5648
<Container as="main" className="flex flex-1 flex-col gap-10 py-16">
@@ -63,32 +55,32 @@ export default async function ResearchArticlePage(
6355

6456
<div className="flex flex-col gap-3">
6557
<MonoLabel className="text-dim">
66-
RESEARCH / {metadata.category}
58+
RESEARCH / {article.category}
6759
</MonoLabel>
6860
<div className="flex flex-wrap items-center gap-3">
6961
<MonoLabel className="text-dim">
70-
{formatArticleDate(metadata.date)}
62+
{formatArticleDate(article.publishedAt)}
7163
</MonoLabel>
7264
<span className="text-dim">·</span>
73-
<MonoLabel className="text-dim">{metadata.readingMinutes} MIN READ</MonoLabel>
65+
<MonoLabel className="text-dim">{article.readingMinutes} MIN READ</MonoLabel>
7466
</div>
7567
<h1 className="text-3xl font-semibold tracking-tight sm:text-4xl">
76-
{metadata.title}
68+
{article.title}
7769
</h1>
78-
<p className="max-w-xl text-lg text-muted">{metadata.description}</p>
70+
<p className="max-w-xl text-lg text-muted">{article.description}</p>
7971
</div>
8072

8173
<div className="flex flex-col gap-3 border-t border-border pt-8">
8274
<MonoLabel>ARTICLE</MonoLabel>
8375
<div className="max-w-xl">
84-
<Article />
76+
<Content />
8577
</div>
8678
</div>
8779

8880
<div className="flex flex-col gap-3 border-t border-border pt-8">
8981
<MonoLabel>TAGS</MonoLabel>
9082
<div className="flex flex-wrap gap-2">
91-
{metadata.tags.map((tag) => (
83+
{article.tags.map((tag) => (
9284
<Tag key={tag}>{tag}</Tag>
9385
))}
9486
</div>

src/app/research/page.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import Link from "next/link";
22
import { Container } from "@/components/ui/container";
33
import { MonoLabel } from "@/components/ui/mono-label";
4-
import { getAllResearchArticles } from "@/lib/research";
4+
import { researchRepository } from "@/lib/research";
55

66
export default async function ResearchPage() {
7-
const articles = await getAllResearchArticles();
7+
const articles = await researchRepository.getPublishedArticles();
88

99
return (
1010
<Container as="main" className="flex flex-1 flex-col gap-10 py-16">
@@ -29,7 +29,7 @@ export default async function ResearchPage() {
2929
className="flex flex-col gap-2 border-t border-border py-8 first:border-t-0 first:pt-0"
3030
>
3131
<div className="flex items-center gap-3">
32-
<MonoLabel className="text-dim">{article.date}</MonoLabel>
32+
<MonoLabel className="text-dim">{article.publishedAt}</MonoLabel>
3333
<span className="text-dim">·</span>
3434
<MonoLabel className="text-dim">{article.category}</MonoLabel>
3535
</div>

src/lib/research.ts

Lines changed: 0 additions & 67 deletions
This file was deleted.

src/lib/research/domain.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import type { ComponentType } from "react";
2+
3+
/**
4+
* Research's domain model — storage-independent on purpose. Nothing here
5+
* knows about .mdx files or D1; both are just implementations of the
6+
* repository interface in ./repository.ts. This is what lets the public
7+
* pages depend on a shape instead of a source.
8+
*/
9+
10+
/**
11+
* The known Research categories — a closed union rather than a free
12+
* string now that there's real content to base it on. Add a new one here
13+
* deliberately, in the same commit as the article that needs it, rather
14+
* than letting near-duplicate strings ("Protocol engineering" vs
15+
* "Protocol Engineering") accumulate silently. Tags stay a free string[]
16+
* — there's no fixed set of those, and no filtering feature yet that
17+
* would need one.
18+
*/
19+
export type ResearchCategory = "EVM" | "Protocol Engineering" | "Distributed Systems";
20+
21+
export type ArticleStatus = "draft" | "published";
22+
23+
/**
24+
* What the public UI actually consumes — always a published article, so
25+
* `publishedAt` is never null here (contrast ResearchArticleRecord below,
26+
* where a draft genuinely has no publish date yet). This is intentionally
27+
* the same shape the MDX-backed content already had, just with `date`
28+
* renamed to `publishedAt` to match the eventual stored record — the
29+
* public pages don't need `status` or timestamps they never display.
30+
*/
31+
export type ResearchArticleMetadata = {
32+
slug: string;
33+
title: string;
34+
description: string;
35+
category: ResearchCategory;
36+
tags: string[];
37+
readingMinutes: number;
38+
/** ISO date string, e.g. "2026-08-29" — sortable as-is. */
39+
publishedAt: string;
40+
};
41+
42+
/** Metadata plus the actual renderable body. `Content` is a component
43+
* regardless of where the article came from: the MDX adapter gets it from
44+
* a build-time file import, a future D1 adapter would get it by compiling
45+
* the stored Markdown/MDX string into a component at read time. Either
46+
* way, the page just renders `<Content />` and never knows which. */
47+
export type ResearchArticle = ResearchArticleMetadata & {
48+
Content: ComponentType;
49+
};
50+
51+
export type AdjacentArticles = {
52+
newer: ResearchArticleMetadata | null;
53+
older: ResearchArticleMetadata | null;
54+
};
55+
56+
/**
57+
* The full authoring-side record — every field a draft-to-published
58+
* article needs, independent of MDX and D1. Not consumed by the public
59+
* pages at all; exists so the eventual write operations (see
60+
* ResearchAuthoringRepository) have a settled shape to work with before
61+
* anything actually implements them. `content` is raw Markdown/MDX
62+
* source — the editable, storable form — as distinct from `Content`
63+
* above, which is that source already compiled into something renderable.
64+
*/
65+
export type ResearchArticleRecord = {
66+
id: string;
67+
slug: string;
68+
title: string;
69+
description: string;
70+
category: ResearchCategory;
71+
tags: string[];
72+
content: string;
73+
readingMinutes: number;
74+
status: ArticleStatus;
75+
/** Null until the article is published for the first time. */
76+
publishedAt: string | null;
77+
createdAt: string;
78+
updatedAt: string;
79+
};
80+
81+
/** Fields an author actually provides when writing or editing a draft —
82+
* everything on ResearchArticleRecord except what the system assigns
83+
* itself (id, status, timestamps). */
84+
export type DraftInput = {
85+
title: string;
86+
description: string;
87+
category: ResearchCategory;
88+
tags: string[];
89+
content: string;
90+
readingMinutes: number;
91+
};

src/lib/research/index.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/**
2+
* The composition root for Research's content access. This is the one
3+
* line that decides where article data actually comes from — everything
4+
* else (both public pages) imports `researchRepository` and the types
5+
* below, never a concrete adapter. Switching to D1 later means changing
6+
* this one line to `createD1ResearchRepository(...)`, not touching
7+
* /research or /research/[slug] at all.
8+
*/
9+
import { createMdxResearchRepository } from "./mdx-repository";
10+
11+
export const researchRepository = createMdxResearchRepository();
12+
13+
export type {
14+
AdjacentArticles,
15+
ArticleStatus,
16+
DraftInput,
17+
ResearchArticle,
18+
ResearchArticleMetadata,
19+
ResearchArticleRecord,
20+
ResearchCategory,
21+
} from "./domain";
22+
export type { PublicResearchRepository, ResearchAuthoringRepository } from "./repository";

0 commit comments

Comments
 (0)