From 84869f44b0274ada6b602bd2c2062be416cca56d Mon Sep 17 00:00:00 2001 From: Evrhet Milam Date: Sun, 15 Mar 2026 10:25:23 -0700 Subject: [PATCH] Add SEO blog section, sitemap, robots.txt, and Slack article bot - /learn route with server-rendered articles from marketing_articles table - Sitemap and robots.txt for Google crawling - Slack bot: @mention to generate articles via OpenAI, threaded feedback loop, "publish it" to go live - Split supabase types into individual files per domain - Migration 016: marketing_articles + marketing_article_threads tables with RLS - Full test coverage for DB layer and Slack signature verification Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 1 + src/__tests__/helpers/seed.ts | 6 + src/__tests__/lib/db/articles.test.ts | 286 +++++++++++++++++++++++ src/__tests__/lib/slack/verify.test.ts | 93 ++++++++ src/app/api/webhooks/slack/route.ts | 253 ++++++++++++++++++++ src/app/components/marketing-footer.tsx | 28 +++ src/app/components/marketing-nav.tsx | 40 ++++ src/app/learn/[slug]/page.tsx | 132 +++++++++++ src/app/learn/format-date.ts | 7 + src/app/learn/layout.tsx | 12 + src/app/learn/not-found.tsx | 18 ++ src/app/learn/page.tsx | 68 ++++++ src/app/page.tsx | 48 +--- src/app/robots.ts | 22 ++ src/app/sitemap.ts | 33 +++ src/lib/db/articles.ts | 166 +++++++++++++ src/lib/openai/article-writer.ts | 197 ++++++++++++++++ src/lib/slack/client.ts | 43 ++++ src/lib/slack/orchestrate-article.ts | 156 +++++++++++++ src/lib/slack/verify.ts | 30 +++ src/lib/supabase/types.ts | 119 ---------- src/lib/supabase/types/article-thread.ts | 10 + src/lib/supabase/types/article.ts | 17 ++ src/lib/supabase/types/facebook.ts | 12 + src/lib/supabase/types/index.ts | 10 + src/lib/supabase/types/instagram.ts | 12 + src/lib/supabase/types/message.ts | 15 ++ src/lib/supabase/types/organization.ts | 23 ++ src/lib/supabase/types/post-stats.ts | 11 + src/lib/supabase/types/post.ts | 18 ++ src/lib/supabase/types/profile.ts | 13 ++ src/lib/supabase/types/registration.ts | 8 + src/middleware.ts | 11 +- supabase/migrations/016_add_articles.sql | 61 +++++ 34 files changed, 1815 insertions(+), 164 deletions(-) create mode 100644 src/__tests__/lib/db/articles.test.ts create mode 100644 src/__tests__/lib/slack/verify.test.ts create mode 100644 src/app/api/webhooks/slack/route.ts create mode 100644 src/app/components/marketing-footer.tsx create mode 100644 src/app/components/marketing-nav.tsx create mode 100644 src/app/learn/[slug]/page.tsx create mode 100644 src/app/learn/format-date.ts create mode 100644 src/app/learn/layout.tsx create mode 100644 src/app/learn/not-found.tsx create mode 100644 src/app/learn/page.tsx create mode 100644 src/app/robots.ts create mode 100644 src/app/sitemap.ts create mode 100644 src/lib/db/articles.ts create mode 100644 src/lib/openai/article-writer.ts create mode 100644 src/lib/slack/client.ts create mode 100644 src/lib/slack/orchestrate-article.ts create mode 100644 src/lib/slack/verify.ts delete mode 100644 src/lib/supabase/types.ts create mode 100644 src/lib/supabase/types/article-thread.ts create mode 100644 src/lib/supabase/types/article.ts create mode 100644 src/lib/supabase/types/facebook.ts create mode 100644 src/lib/supabase/types/index.ts create mode 100644 src/lib/supabase/types/instagram.ts create mode 100644 src/lib/supabase/types/message.ts create mode 100644 src/lib/supabase/types/organization.ts create mode 100644 src/lib/supabase/types/post-stats.ts create mode 100644 src/lib/supabase/types/post.ts create mode 100644 src/lib/supabase/types/profile.ts create mode 100644 src/lib/supabase/types/registration.ts create mode 100644 supabase/migrations/016_add_articles.sql diff --git a/CLAUDE.md b/CLAUDE.md index 3be50df..3c03165 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,6 +68,7 @@ Required in `.env.local`: - `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN`, `TWILIO_PHONE_NUMBER` - `NEXT_PUBLIC_BASE_URL` - `CRON_SECRET` (Vercel Cron authentication) +- `SLACK_BOT_TOKEN`, `SLACK_SIGNING_SECRET` (Slack bot for article generation) ## Logging diff --git a/src/__tests__/helpers/seed.ts b/src/__tests__/helpers/seed.ts index 0fe4a1d..332b492 100644 --- a/src/__tests__/helpers/seed.ts +++ b/src/__tests__/helpers/seed.ts @@ -175,6 +175,12 @@ export async function cleanAll() { const { error: orgErr } = await db.from("organizations").delete().neq("id", NIL); if (orgErr) console.error("cleanAll organizations:", orgErr.message); + const { error: threadErr } = await db.from("marketing_article_threads").delete().neq("id", NIL); + if (threadErr) console.error("cleanAll marketing_article_threads:", threadErr.message); + + const { error: articleErr } = await db.from("marketing_articles").delete().neq("id", NIL); + if (articleErr) console.error("cleanAll marketing_articles:", articleErr.message); + const { error: profErr } = await db.from("profiles").delete().neq("id", NIL); if (profErr) console.error("cleanAll profiles:", profErr.message); diff --git a/src/__tests__/lib/db/articles.test.ts b/src/__tests__/lib/db/articles.test.ts new file mode 100644 index 0000000..0651a5f --- /dev/null +++ b/src/__tests__/lib/db/articles.test.ts @@ -0,0 +1,286 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { createDbClient } from "@/lib/db/client"; +import { + getPublishedArticles, + getArticleBySlug, + getAllPublishedSlugs, + insertArticle, + updateArticle, + insertArticleThread, + getThreadBySlack, + updateThreadResponseId, +} from "@/lib/db/articles"; + +const db = createDbClient(); +const NIL = "00000000-0000-0000-0000-000000000000"; + +async function seedArticle(overrides: Record = {}) { + const { data, error } = await db + .from("marketing_articles") + .insert({ + slug: `test-article-${crypto.randomUUID().slice(0, 8)}`, + title: "Test Article", + description: "A test article description", + content: "# Hello\n\nThis is test content.", + author: "Post Imp Team", + tags: ["test"], + published: true, + published_at: new Date().toISOString(), + ...overrides, + }) + .select("id, slug") + .single(); + + if (error) throw new Error(`seedArticle: ${error.message}`); + return data!; +} + +async function cleanAll() { + await db.from("marketing_article_threads").delete().neq("id", NIL); + await db.from("marketing_articles").delete().neq("id", NIL); +} + +describe("marketing_articles", () => { + afterEach(async () => { + await cleanAll(); + }); + + describe("getPublishedArticles", () => { + it("returns published articles ordered by date", async () => { + await seedArticle({ + slug: "older-post", + title: "Older Post", + published_at: "2026-01-01T00:00:00Z", + }); + await seedArticle({ + slug: "newer-post", + title: "Newer Post", + published_at: "2026-03-01T00:00:00Z", + }); + + const articles = await getPublishedArticles(db); + + expect(articles).toHaveLength(2); + expect(articles[0].title).toBe("Newer Post"); + expect(articles[1].title).toBe("Older Post"); + }); + + it("excludes unpublished articles", async () => { + await seedArticle({ slug: "published", published: true }); + await seedArticle({ slug: "draft", published: false }); + + const articles = await getPublishedArticles(db); + + expect(articles).toHaveLength(1); + expect(articles[0].slug).toBe("published"); + }); + + it("does not include content field", async () => { + await seedArticle(); + + const articles = await getPublishedArticles(db); + + expect(articles).toHaveLength(1); + expect(articles[0]).not.toHaveProperty("content"); + }); + }); + + describe("getArticleBySlug", () => { + it("returns a published article with content", async () => { + const { slug } = await seedArticle({ content: "# Full content here" }); + + const article = await getArticleBySlug(db, slug); + + expect(article).not.toBeNull(); + expect(article!.slug).toBe(slug); + expect(article!.content).toBe("# Full content here"); + }); + + it("returns null for unpublished articles", async () => { + const { slug } = await seedArticle({ published: false }); + + const article = await getArticleBySlug(db, slug); + + expect(article).toBeNull(); + }); + + it("returns null for non-existent slugs", async () => { + const article = await getArticleBySlug(db, "does-not-exist"); + + expect(article).toBeNull(); + }); + + it("includes SEO fields", async () => { + const { slug } = await seedArticle({ + og_title: "Custom OG Title", + og_description: "Custom OG Description", + og_image_url: "https://example.com/image.jpg", + canonical_url: "https://example.com/canonical", + }); + + const article = await getArticleBySlug(db, slug); + + expect(article!.og_title).toBe("Custom OG Title"); + expect(article!.og_description).toBe("Custom OG Description"); + expect(article!.og_image_url).toBe("https://example.com/image.jpg"); + expect(article!.canonical_url).toBe("https://example.com/canonical"); + }); + }); + + describe("getAllPublishedSlugs", () => { + it("returns slugs and published_at for published articles", async () => { + await seedArticle({ slug: "slug-one", published_at: "2026-02-01T00:00:00Z" }); + await seedArticle({ slug: "slug-two", published_at: "2026-03-01T00:00:00Z" }); + await seedArticle({ slug: "draft-slug", published: false }); + + const slugs = await getAllPublishedSlugs(db); + + expect(slugs).toHaveLength(2); + expect(slugs[0].slug).toBe("slug-two"); + expect(slugs[1].slug).toBe("slug-one"); + expect(slugs[0].published_at).toBeTruthy(); + }); + }); + + describe("insertArticle", () => { + it("creates an article and returns it with all fields", async () => { + const article = await insertArticle(db, { + slug: "new-article", + title: "New Article", + description: "A new article", + content: "# Content", + tags: ["social", "tips"], + og_title: "OG Title", + og_description: "OG Desc", + }); + + expect(article.id).toBeTruthy(); + expect(article.slug).toBe("new-article"); + expect(article.title).toBe("New Article"); + expect(article.tags).toEqual(["social", "tips"]); + expect(article.published).toBe(false); + expect(article.author).toBe("Post Imp Team"); + }); + + it("throws on duplicate slug", async () => { + await seedArticle({ slug: "duplicate" }); + + await expect( + insertArticle(db, { + slug: "duplicate", + title: "Another", + description: "Desc", + content: "Content", + }), + ).rejects.toThrow(); + }); + }); + + describe("updateArticle", () => { + it("updates specified fields", async () => { + const { id, slug } = await seedArticle({ title: "Original" }); + + await updateArticle(db, id, { title: "Updated Title", description: "Updated desc" }); + + const article = await getArticleBySlug(db, slug); + expect(article!.title).toBe("Updated Title"); + expect(article!.description).toBe("Updated desc"); + }); + + it("can publish an article", async () => { + const { id, slug } = await seedArticle({ published: false }); + + expect(await getArticleBySlug(db, slug)).toBeNull(); + + await updateArticle(db, id, { + published: true, + published_at: new Date().toISOString(), + }); + + const article = await getArticleBySlug(db, slug); + expect(article).not.toBeNull(); + expect(article!.published).toBe(true); + }); + }); +}); + +describe("marketing_article_threads", () => { + afterEach(async () => { + await cleanAll(); + }); + + it("insertArticleThread creates a thread and getThreadBySlack retrieves it", async () => { + const article = await insertArticle(db, { + slug: "thread-test", + title: "Thread Test", + description: "Desc", + content: "Content", + }); + + const thread = await insertArticleThread(db, { + article_id: article.id, + slack_channel_id: "C123", + slack_thread_ts: "1234567890.123456", + openai_response_id: "resp_abc", + created_by_slack_user: "U456", + }); + + expect(thread.id).toBeTruthy(); + expect(thread.article_id).toBe(article.id); + expect(thread.slack_channel_id).toBe("C123"); + + const found = await getThreadBySlack(db, "C123", "1234567890.123456"); + expect(found).not.toBeNull(); + expect(found!.id).toBe(thread.id); + expect(found!.article.title).toBe("Thread Test"); + }); + + it("getThreadBySlack returns null for unknown thread", async () => { + const found = await getThreadBySlack(db, "C999", "0000000000.000000"); + expect(found).toBeNull(); + }); + + it("updateThreadResponseId updates the openai_response_id", async () => { + const article = await insertArticle(db, { + slug: "update-resp-test", + title: "Update Resp", + description: "Desc", + content: "Content", + }); + + const thread = await insertArticleThread(db, { + article_id: article.id, + slack_channel_id: "C789", + slack_thread_ts: "9999999999.999999", + openai_response_id: "resp_old", + }); + + await updateThreadResponseId(db, thread.id, "resp_new"); + + const found = await getThreadBySlack(db, "C789", "9999999999.999999"); + expect(found!.openai_response_id).toBe("resp_new"); + }); + + it("enforces unique constraint on channel + thread_ts", async () => { + const article = await insertArticle(db, { + slug: "unique-test", + title: "Unique", + description: "Desc", + content: "Content", + }); + + await insertArticleThread(db, { + article_id: article.id, + slack_channel_id: "C111", + slack_thread_ts: "1111111111.111111", + }); + + await expect( + insertArticleThread(db, { + article_id: article.id, + slack_channel_id: "C111", + slack_thread_ts: "1111111111.111111", + }), + ).rejects.toThrow(); + }); +}); diff --git a/src/__tests__/lib/slack/verify.test.ts b/src/__tests__/lib/slack/verify.test.ts new file mode 100644 index 0000000..c56f1b1 --- /dev/null +++ b/src/__tests__/lib/slack/verify.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { createHmac } from "crypto"; +import { verifySlackRequest } from "@/lib/slack/verify"; + +const SECRET = "test-signing-secret"; + +function makeSignature(secret: string, timestamp: number, body: string): string { + const sigBase = `v0:${timestamp}:${body}`; + const hmac = createHmac("sha256", secret).update(sigBase).digest("hex"); + return `v0=${hmac}`; +} + +function nowSeconds(): number { + return Math.floor(Date.now() / 1000); +} + +describe("verifySlackRequest", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("accepts a valid signature with current timestamp", () => { + const ts = nowSeconds(); + const body = '{"type":"event_callback"}'; + const sig = makeSignature(SECRET, ts, body); + + expect(verifySlackRequest(SECRET, { timestamp: String(ts), signature: sig }, body)).toBe(true); + }); + + it("rejects an invalid signature", () => { + const ts = nowSeconds(); + const body = '{"type":"event_callback"}'; + + expect(verifySlackRequest(SECRET, { timestamp: String(ts), signature: "v0=bad" }, body)).toBe( + false, + ); + }); + + it("rejects a valid signature with wrong secret", () => { + const ts = nowSeconds(); + const body = '{"type":"event_callback"}'; + const sig = makeSignature("wrong-secret", ts, body); + + expect(verifySlackRequest(SECRET, { timestamp: String(ts), signature: sig }, body)).toBe(false); + }); + + it("rejects timestamps older than 5 minutes", () => { + const ts = nowSeconds() - 301; + const body = '{"type":"event_callback"}'; + const sig = makeSignature(SECRET, ts, body); + + expect(verifySlackRequest(SECRET, { timestamp: String(ts), signature: sig }, body)).toBe(false); + }); + + it("rejects timestamps more than 60 seconds in the future", () => { + const ts = nowSeconds() + 61; + const body = '{"type":"event_callback"}'; + const sig = makeSignature(SECRET, ts, body); + + expect(verifySlackRequest(SECRET, { timestamp: String(ts), signature: sig }, body)).toBe(false); + }); + + it("accepts a timestamp slightly in the future (within 60s)", () => { + const ts = nowSeconds() + 30; + const body = '{"type":"event_callback"}'; + const sig = makeSignature(SECRET, ts, body); + + expect(verifySlackRequest(SECRET, { timestamp: String(ts), signature: sig }, body)).toBe(true); + }); + + it("returns false for empty signature (length mismatch)", () => { + const ts = nowSeconds(); + const body = "test"; + + expect(verifySlackRequest(SECRET, { timestamp: String(ts), signature: "" }, body)).toBe(false); + }); + + it("returns false for non-numeric timestamp", () => { + const body = "test"; + + expect( + verifySlackRequest(SECRET, { timestamp: "not-a-number", signature: "v0=abc" }, body), + ).toBe(false); + }); + + it("works with empty body", () => { + const ts = nowSeconds(); + const body = ""; + const sig = makeSignature(SECRET, ts, body); + + expect(verifySlackRequest(SECRET, { timestamp: String(ts), signature: sig }, body)).toBe(true); + }); +}); diff --git a/src/app/api/webhooks/slack/route.ts b/src/app/api/webhooks/slack/route.ts new file mode 100644 index 0000000..088b093 --- /dev/null +++ b/src/app/api/webhooks/slack/route.ts @@ -0,0 +1,253 @@ +import { after } from "next/server"; +import { NextResponse, type NextRequest } from "next/server"; +import { verifySlackRequest } from "@/lib/slack/verify"; +import { postSlackMessage } from "@/lib/slack/client"; +import { orchestrateArticle } from "@/lib/slack/orchestrate-article"; +import { createDbClient } from "@/lib/db/client"; +import { insertArticle, insertArticleThread, getThreadBySlack } from "@/lib/db/articles"; +import { sendArticleMessage, sendArticleToolResults } from "@/lib/openai/article-writer"; +import { log, timed, serializeError } from "@/lib/logger"; + +// Track recently processed event IDs to prevent duplicate handling from Slack retries +const recentEventIds = new Set(); +const MAX_EVENT_CACHE = 1000; + +function isDuplicateEvent(eventId: string): boolean { + if (recentEventIds.has(eventId)) return true; + recentEventIds.add(eventId); + if (recentEventIds.size > MAX_EVENT_CACHE) { + const first = recentEventIds.values().next().value; + if (first) recentEventIds.delete(first); + } + return false; +} + +export async function POST(request: NextRequest) { + const rawBody = await request.text(); + const body = JSON.parse(rawBody); + + // Slack URL verification challenge + if (body.type === "url_verification") { + return NextResponse.json({ challenge: body.challenge }); + } + + // Verify request signature + const signingSecret = process.env.SLACK_SIGNING_SECRET; + if (!signingSecret) { + log.warn({ + operation: "api.webhooks.slack", + message: "SLACK_SIGNING_SECRET not configured — rejecting request", + }); + return NextResponse.json({ error: "Not configured" }, { status: 500 }); + } + + const timestamp = request.headers.get("x-slack-request-timestamp") || ""; + const signature = request.headers.get("x-slack-signature") || ""; + + if (!verifySlackRequest(signingSecret, { timestamp, signature }, rawBody)) { + log.warn({ operation: "api.webhooks.slack", message: "Invalid Slack signature" }); + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + if (body.type !== "event_callback") { + return NextResponse.json({ ok: true }); + } + + // Deduplicate retried events + if (body.event_id && isDuplicateEvent(body.event_id)) { + return NextResponse.json({ ok: true }); + } + + const event = body.event; + if (!event) return NextResponse.json({ ok: true }); + + if (event.type === "app_mention") { + after(async () => { + try { + await handleNewArticle(event); + } catch (err) { + log.error({ + operation: "api.webhooks.slack", + message: "Background handleNewArticle failed", + error: serializeError(err), + }); + } + }); + } else if (event.type === "message" && event.thread_ts && !event.bot_id && !event.subtype) { + after(async () => { + try { + await handleThreadReply(event); + } catch (err) { + log.error({ + operation: "api.webhooks.slack", + message: "Background handleThreadReply failed", + error: serializeError(err), + }); + } + }); + } + + return NextResponse.json({ ok: true }); +} + +async function handleNewArticle(event: { + text: string; + channel: string; + user: string; + ts: string; +}) { + const elapsed = timed(); + const idea = event.text.replace(/<@[A-Z0-9]+>/g, "").trim(); + + if (!idea) { + await postSlackMessage( + event.channel, + "I need an article idea! Mention me with a topic, like: `@Post Imp 10 tips for writing better Instagram captions`", + event.ts, + ); + return; + } + + await postSlackMessage( + event.channel, + `Writing an article about: _${idea}_\nThis will take a minute...`, + event.ts, + ); + + try { + // Send to AI — it should call update_article tool with initial draft + let result = await sendArticleMessage({ + text: `Write a blog article based on this idea:\n\n${idea}`, + }); + + // Extract article fields from the first update_article tool call. + // This initial loop only captures fields and responds to tool calls — + // it intentionally differs from orchestrateArticle because the article + // row doesn't exist in the DB yet. + let articleFields: Record | null = null; + const MAX_ROUNDS = 5; + + for (let round = 0; round < MAX_ROUNDS && result.toolCalls.length > 0; round++) { + const toolOutputs: Array<{ callId: string; output: string }> = []; + + for (const toolCall of result.toolCalls) { + if (toolCall.name === "update_article") { + articleFields = toolCall.args; + toolOutputs.push({ callId: toolCall.callId, output: "Article saved as draft." }); + } else if (toolCall.name === "publish_article") { + toolOutputs.push({ + callId: toolCall.callId, + output: "Article saved as draft. The team will review before publishing.", + }); + } else { + toolOutputs.push({ + callId: toolCall.callId, + output: `Unknown tool: ${toolCall.name}`, + }); + } + } + + result = await sendArticleToolResults({ + previousResponseId: result.responseId, + toolOutputs, + }); + } + + if (!articleFields) { + await postSlackMessage( + event.channel, + "Something went wrong — AI didn't generate article content. Try again.", + event.ts, + ); + return; + } + + // Save article and thread + const db = createDbClient(); + const saved = await insertArticle(db, { + slug: articleFields.slug as string, + title: articleFields.title as string, + description: articleFields.description as string, + content: articleFields.content as string, + tags: articleFields.tags as string[], + og_title: (articleFields.og_title as string) || null, + og_description: (articleFields.og_description as string) || null, + author: "Post Imp Team", + published: false, + }); + + await insertArticleThread(db, { + article_id: saved.id, + slack_channel_id: event.channel, + slack_thread_ts: event.ts, + openai_response_id: result.responseId, + created_by_slack_user: event.user, + }); + + const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "https://postimp.com"; + + await postSlackMessage( + event.channel, + [ + `*Article drafted!*`, + `*Title:* ${saved.title}`, + `*Slug:* \`${saved.slug}\``, + `*Description:* ${saved.description}`, + `*Tags:* ${saved.tags.join(", ")}`, + ``, + `Reply in this thread to give feedback and I'll revise. Say *publish it* when you're happy.`, + `Once published it will appear at: ${baseUrl}/learn/${saved.slug}`, + ].join("\n"), + event.ts, + ); + + if (result.textResponse) { + await postSlackMessage(event.channel, result.textResponse, event.ts); + } + + log.info({ + operation: "api.webhooks.slack.newArticle", + message: "Article generated and saved as draft", + durationMs: elapsed(), + slug: saved.slug, + channel: event.channel, + }); + } catch (err) { + log.error({ + operation: "api.webhooks.slack.newArticle", + message: "Failed to generate article", + error: serializeError(err), + }); + + await postSlackMessage( + event.channel, + "Something went wrong generating that article. Please try again.", + event.ts, + ); + } +} + +async function handleThreadReply(event: { + text: string; + channel: string; + user: string; + ts: string; + thread_ts: string; +}) { + const db = createDbClient(); + + const thread = await getThreadBySlack(db, event.channel, event.thread_ts); + if (!thread) return; + + const feedback = event.text.replace(/<@[A-Z0-9]+>/g, "").trim(); + if (!feedback) return; + + await orchestrateArticle({ + articleId: thread.article_id, + threadId: thread.id, + text: feedback, + previousResponseId: thread.openai_response_id, + channel: event.channel, + threadTs: event.thread_ts, + }); +} diff --git a/src/app/components/marketing-footer.tsx b/src/app/components/marketing-footer.tsx new file mode 100644 index 0000000..413373c --- /dev/null +++ b/src/app/components/marketing-footer.tsx @@ -0,0 +1,28 @@ +import Link from "next/link"; + +export default function MarketingFooter() { + return ( +
+
+

© {new Date().getFullYear()} Post Imp

+
+ + Learn + + + Privacy + + + Terms + + + Support + +
+
+
+ ); +} diff --git a/src/app/components/marketing-nav.tsx b/src/app/components/marketing-nav.tsx new file mode 100644 index 0000000..83b26f8 --- /dev/null +++ b/src/app/components/marketing-nav.tsx @@ -0,0 +1,40 @@ +import Link from "next/link"; +import ImpLoader from "@/app/components/imp-loader"; + +export default function MarketingNav() { + return ( + + ); +} diff --git a/src/app/learn/[slug]/page.tsx b/src/app/learn/[slug]/page.tsx new file mode 100644 index 0000000..f99263f --- /dev/null +++ b/src/app/learn/[slug]/page.tsx @@ -0,0 +1,132 @@ +import { cache } from "react"; +import { notFound } from "next/navigation"; +import Link from "next/link"; +import type { Metadata } from "next"; +import Markdown from "react-markdown"; +import { createDbClient } from "@/lib/db/client"; +import { getArticleBySlug } from "@/lib/db/articles"; +import { formatArticleDate } from "@/app/learn/format-date"; + +export const revalidate = 300; + +type Props = { + params: Promise<{ slug: string }>; +}; + +const getCachedArticle = cache(async (slug: string) => { + const db = createDbClient(); + return getArticleBySlug(db, slug); +}); + +export async function generateMetadata({ params }: Props): Promise { + const { slug } = await params; + const article = await getCachedArticle(slug); + + if (!article) { + return { title: "Article Not Found - Post Imp" }; + } + + const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "https://postimp.com"; + const ogTitle = article.og_title || article.title; + const ogDescription = article.og_description || article.description; + + return { + title: `${article.title} - Post Imp`, + description: article.description, + authors: [{ name: article.author }], + openGraph: { + title: ogTitle, + description: ogDescription, + type: "article", + publishedTime: article.published_at || undefined, + authors: [article.author], + tags: article.tags, + ...(article.og_image_url && { + images: [{ url: article.og_image_url }], + }), + }, + twitter: { + card: article.og_image_url ? "summary_large_image" : "summary", + title: ogTitle, + description: ogDescription, + ...(article.og_image_url && { images: [article.og_image_url] }), + }, + ...(article.canonical_url && { + alternates: { canonical: article.canonical_url }, + }), + ...(!article.canonical_url && { + alternates: { canonical: `${baseUrl}/learn/${article.slug}` }, + }), + }; +} + +export default async function ArticlePage({ params }: Props) { + const { slug } = await params; + const article = await getCachedArticle(slug); + + if (!article) { + notFound(); + } + + const jsonLd = { + "@context": "https://schema.org", + "@type": "BlogPosting", + headline: article.title, + description: article.description, + datePublished: article.published_at, + dateModified: article.updated_at, + author: { + "@type": "Organization", + name: article.author, + }, + publisher: { + "@type": "Organization", + name: "Post Imp", + }, + ...(article.og_image_url && { image: article.og_image_url }), + }; + + return ( +
+