diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..776ad08 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +node_modules +.next +.git +.env +.env.local +*.db +docker +.github +.w4c +tests +README.md +coverage diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..83f1bb7 --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# --- Core ------------------------------------------------------------------- +# SQLite in development; point at Postgres in production (see README). +DATABASE_URL="file:./dev.db" +# Public origin used for absolute URLs (sitemap.xml, rss.xml, OG, checkout redirects). +NEXT_PUBLIC_SITE_URL="http://localhost:3000" + +# --- Payments (optional) ---------------------------------------------------- +# Leave empty to use the built-in mock provider (no keys needed). +STRIPE_SECRET_KEY="" +STRIPE_WEBHOOK_SECRET="" + +# --- Email (optional) ------------------------------------------------------- +# Leave empty to use the built-in log provider (no keys needed). +RESEND_API_KEY="" +EMAIL_FROM="Acme " diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..3b8ecbf --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,7 @@ +{ + "extends": "next/core-web-vitals", + "rules": { + "@next/next/no-img-element": "off", + "react/no-unescaped-entities": "off" + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..be6fc50 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + quality: + runs-on: ubuntu-latest + env: + DATABASE_URL: "file:./ci.db" + NEXT_PUBLIC_SITE_URL: "http://localhost:3000" + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run lint + - run: npm run typecheck + - run: npm run db:setup + - run: npm test + - run: npm run build + - name: e2e smoke + run: | + npm start & + for i in $(seq 1 60); do + if curl -sf http://127.0.0.1:3000/api/health >/dev/null; then break; fi + sleep 1 + done + npm run test:e2e diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ee7ee5d --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +node_modules +.next +out +dist +coverage +.env +.env.local +*.db +*.db-journal +.vercel +next-env.d.ts diff --git a/.w4c/preview.png b/.w4c/preview.png new file mode 100644 index 0000000..1ef152c Binary files /dev/null and b/.w4c/preview.png differ diff --git a/.w4c/qa.json b/.w4c/qa.json new file mode 100644 index 0000000..8a7240e --- /dev/null +++ b/.w4c/qa.json @@ -0,0 +1,43 @@ +{ + "install": "npm ci", + "setup": "npm run db:setup", + "lint": "npm run lint", + "typecheck": "npm run typecheck", + "unit": "npm run test", + "build": "npm run build", + "start": "npm run start", + "e2e": "node tests/e2e/smoke.mjs", + "port": 3000, + "health": "/api/health", + "env": { + "DATABASE_URL": "file:./dev.db", + "NEXT_PUBLIC_SITE_URL": "http://localhost:3000" + }, + "routes": [ + { "path": "/", "expect": [200] }, + { "path": "/blog", "expect": [200] }, + { "path": "/blog/launch-checklist", "expect": [200] }, + { "path": "/docs", "expect": [200] }, + { "path": "/docs/installation", "expect": [200] }, + { "path": "/tags", "expect": [200] }, + { "path": "/tags/engineering", "expect": [200] }, + { "path": "/authors/ada-lovelace", "expect": [200] }, + { "path": "/search?q=launch", "expect": [200] }, + { "path": "/rss.xml", "expect": [200] }, + { "path": "/pricing", "expect": [200] }, + { "path": "/login", "expect": [200] }, + { "path": "/register", "expect": [200] }, + { "path": "/legal/privacy", "expect": [200] }, + { "path": "/dashboard", "expect": [307, 302] }, + { "path": "/admin", "expect": [307, 302] }, + { "path": "/admin/posts", "expect": [307, 302] }, + { "path": "/admin/docs", "expect": [307, 302] }, + { "path": "/api/health", "expect": [200] }, + { "path": "/api/auth/me", "expect": [401] }, + { "path": "/api/admin/posts", "expect": [401] }, + { "path": "/api/admin/docs", "expect": [401] }, + { "path": "/sitemap.xml", "expect": [200] }, + { "path": "/robots.txt", "expect": [200] } + ], + "timeout": { "install": 900, "build": 900, "boot": 120 } +} diff --git a/.w4c/template.json b/.w4c/template.json new file mode 100644 index 0000000..76ae602 --- /dev/null +++ b/.w4c/template.json @@ -0,0 +1,29 @@ +{ + "schema": 2, + "system": "v2", + "id": "content-next", + "case": "content", + "stack": "next", + "title": "Content / Blog & Docs — Next.js", + "description": "Ready-to-run content site: markdown posts and docs, taxonomy, search, RSS/sitemap and a role-gated authoring admin.", + "tags": ["content", "blog", "docs", "cms", "markdown", "seo"], + "preview": ".w4c/preview.png", + "demoUrl": "https://content-next.demo.wiz4chat.com", + "agentId": "w4c-start2", + "defaultName": "my-blog", + "featured": true, + "env": [ + { "key": "DATABASE_URL", "required": false, "note": "SQLite by default; Postgres in production" }, + { "key": "NEXT_PUBLIC_SITE_URL", "required": false, "note": "Public origin for sitemap, RSS and OG" }, + { "key": "RESEND_API_KEY", "required": false, "note": "log provider is used when absent" } + ], + "modules": [ + { "id": "resend-email", "skill": "integration-resend-email", "default": true }, + { "id": "analytics", "skill": "integration-analytics" } + ], + "seededAccounts": [ + { "role": "admin", "email": "admin@example.com", "password": "password" }, + { "role": "editor", "email": "editor@example.com", "password": "password" }, + { "role": "user", "email": "user@example.com", "password": "password" } + ] +} diff --git a/README.md b/README.md index 0a80b67..5ffdee0 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,80 @@ -# templates2.Content.Next +# Content / Blog & Docs — Next.js (templates2) -Ready-to-run content site: markdown posts and docs, taxonomy, search, RSS/sitemap and a role-gated authoring admin. \ No newline at end of file +A **ready-to-run** content site: markdown-ish posts and docs with frontmatter-style fields +(title, slug, date, tags, author, draft), a table of contents, code highlighting, taxonomy, +server-side search, RSS/sitemap, JSON-LD and a **role-gated authoring admin** — all working on +the first run. + +> Built by forking [`Base.Next`](../Base.Next); it inherits auth, roles, Prisma, mock payments +> and email, the admin shell, SEO, i18n, CI and Docker. + +## Quickstart + +```bash +npm ci +cp .env.example .env # SQLite + mock providers by default — no keys needed +npm run db:setup # prisma db push + seed +npm run dev # http://localhost:3000 +``` + +Demo accounts (seeded), password `password`: + +| Role | Email | Can do | +|---|---|---| +| admin | `admin@example.com` | everything, incl. users & audit | +| editor | `editor@example.com` | author posts and docs | +| user | `user@example.com` | read-only | + +## What works out of the box + +- **Content** — posts and docs stored in Prisma (authored in the admin, not hard-coded), rendered by + an in-repo markdown renderer with a table of contents, fenced-code highlighting + language labels, + lists, quotes, links and inline formatting. +- **Public site** — paginated `/blog`, post pages with byline + related posts, `/docs` grouped by + section with a sidebar and prev/next, `/tags`, `/authors/[slug]` and a server-side `/search`. +- **Authoring admin** — `/admin/posts` and `/admin/docs`: create/edit/preview/publish/**draft**, + featured posts, tag multi-select, with live preview; server API under `/api/admin/posts` and + `/api/admin/docs` enforces the role and writes an audit entry. +- **Feeds/SEO** — `sitemap.xml` (posts, docs, tags, authors), `rss.xml`, per-post metadata and + JSON-LD `Article`. +- **Core** — auth + roles, mock payments/email, admin shell, `/api/health`, both themes, + responsive to 360 px, WCAG AA contrast. + +## Markdown support + +Extending the base's minimal renderer (no `mdx`/`remark`/`gray-matter` dependency): `#`–`###` +headings, paragraphs, ordered/unordered lists, blockquotes, `---`, `**bold**`, `*italic*`, +`` `inline code` ``, `[links](/path)`, and fenced code blocks with a language label and lightweight +syntax highlighting. + +## Data model + +`prisma/schema.prisma`: `Author`, `Tag`, `PostTag`, `Post`, `Doc` (plus the base `User`, +`Session`, `Subscription`, `AuditLog`). SQLite in development; point `DATABASE_URL` at Postgres +for production. + +## Scripts + +| Script | What it does | +|---|---| +| `npm run dev` | Dev server with HMR | +| `npm run build` / `npm start` | Production build / serve | +| `npm run lint` / `npm run typecheck` | ESLint / `tsc --noEmit` | +| `npm test` | Unit tests (Vitest) | +| `npm run test:e2e` | HTTP end-to-end smoke (server must be running) | +| `npm run db:setup` | `prisma db push` + seed | + +## Deployment + +```bash +docker compose -f docker/docker-compose.yml up --build +``` + +## Quality + +```bash +node ../templates2/qa.mjs Content.Next # from the repo root: node templates2/qa.mjs Content.Next +``` + +The harness isolates a clean copy, then runs install → verify → test → build → boot → routes → +e2e → screenshot. See [`../CONTRACT.md`](../CONTRACT.md) for the Definition of Done. diff --git a/app/(app)/dashboard/page.tsx b/app/(app)/dashboard/page.tsx new file mode 100644 index 0000000..d4c5d19 --- /dev/null +++ b/app/(app)/dashboard/page.tsx @@ -0,0 +1,59 @@ +import Link from 'next/link'; +import { Badge, Card } from '@/components/ui'; +import { requireUser } from '@/lib/server/auth'; +import { db } from '@/lib/server/db'; +import { getPlan } from '@/lib/plans'; +import { t } from '@/lib/i18n'; + +export const dynamic = 'force-dynamic'; + +export default async function DashboardPage() { + const user = await requireUser(); + const sub = await db.subscription.findUnique({ where: { userId: user.id } }); + const plan = getPlan(sub?.plan); + const usedCalls = await db.auditLog.count({ where: { actorId: user.id } }); + + const usage = [ + { label: 'Seats', used: 1, limit: plan.limits.seats }, + { label: 'API calls', used: usedCalls, limit: plan.limits.apiCalls }, + ]; + + return ( +
+
+
+

{t('dashboard.title')}

+

{t('dashboard.welcome', { name: user.name || user.email })}

+
+ {plan.name} +
+ + {t('pricing.choose')}}> +

+ {plan.name} — ${plan.price} / month +

+
+ + +
    + {usage.map((u) => { + const pct = Math.min(100, Math.round((u.used / u.limit) * 100)); + return ( +
  • +
    + {u.label} + + {u.used} / {u.limit} + +
    +
    +
    +
    +
  • + ); + })} +
+
+
+ ); +} diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx new file mode 100644 index 0000000..f5818a2 --- /dev/null +++ b/app/(app)/layout.tsx @@ -0,0 +1,12 @@ +import { SiteHeader } from '@/components/SiteHeader'; +import { SiteFooter } from '@/components/SiteFooter'; + +export default function AppLayout({ children }: { children: React.ReactNode }) { + return ( +
+ +
{children}
+ +
+ ); +} diff --git a/app/(auth)/layout.tsx b/app/(auth)/layout.tsx new file mode 100644 index 0000000..c36e6dc --- /dev/null +++ b/app/(auth)/layout.tsx @@ -0,0 +1,13 @@ +import Link from 'next/link'; +import { t } from '@/lib/i18n'; + +export default function AuthLayout({ children }: { children: React.ReactNode }) { + return ( +
+ + {t('brand.name')} + +
{children}
+
+ ); +} diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx new file mode 100644 index 0000000..3b76b82 --- /dev/null +++ b/app/(auth)/login/page.tsx @@ -0,0 +1,15 @@ +import { Suspense } from 'react'; +import { redirect } from 'next/navigation'; +import { AuthForm } from '@/components/AuthForm'; +import { getCurrentUser } from '@/lib/server/auth'; + +export const dynamic = 'force-dynamic'; + +export default async function LoginPage() { + if (await getCurrentUser()) redirect('/dashboard'); + return ( + Loading…

}> + +
+ ); +} diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx new file mode 100644 index 0000000..2ffbaee --- /dev/null +++ b/app/(auth)/register/page.tsx @@ -0,0 +1,15 @@ +import { Suspense } from 'react'; +import { redirect } from 'next/navigation'; +import { AuthForm } from '@/components/AuthForm'; +import { getCurrentUser } from '@/lib/server/auth'; + +export const dynamic = 'force-dynamic'; + +export default async function RegisterPage() { + if (await getCurrentUser()) redirect('/dashboard'); + return ( + Loading…

}> + +
+ ); +} diff --git a/app/(marketing)/authors/[slug]/page.tsx b/app/(marketing)/authors/[slug]/page.tsx new file mode 100644 index 0000000..c2fefde --- /dev/null +++ b/app/(marketing)/authors/[slug]/page.tsx @@ -0,0 +1,51 @@ +import type { Metadata } from 'next'; +import { notFound } from 'next/navigation'; +import { getAuthorWithPosts } from '@/lib/server/content'; +import { PostCard } from '@/components/content/PostCard'; +import { EmptyState } from '@/components/ui'; +import { t } from '@/lib/i18n'; + +export const dynamic = 'force-dynamic'; + +type Params = { params: Promise<{ slug: string }> }; + +export async function generateMetadata({ params }: Params): Promise { + const { slug } = await params; + const data = await getAuthorWithPosts(slug); + if (!data) return { title: 'Author not found' }; + return { title: `${data.author.name}`, description: data.author.bio ?? `Posts by ${data.author.name}.` }; +} + +export default async function AuthorPage({ params }: Params) { + const { slug } = await params; + const data = await getAuthorWithPosts(slug); + if (!data) notFound(); + + return ( +
+
+ +
+

{t('author.postsBy', { name: data.author.name })}

+ {data.author.bio &&

{data.author.bio}

} +
+
+ + {data.posts.length === 0 ? ( + {t('common.empty')} + ) : ( +
+ {data.posts.map((post) => ( + + ))} +
+ )} +
+ ); +} diff --git a/app/(marketing)/blog/[slug]/page.tsx b/app/(marketing)/blog/[slug]/page.tsx new file mode 100644 index 0000000..465623e --- /dev/null +++ b/app/(marketing)/blog/[slug]/page.tsx @@ -0,0 +1,99 @@ +import type { Metadata } from 'next'; +import Link from 'next/link'; +import { notFound } from 'next/navigation'; +import { getPublishedPost, listRelatedPosts } from '@/lib/server/content'; +import { Markdown } from '@/components/content/Markdown'; +import { Toc } from '@/components/content/Toc'; +import { PostCard } from '@/components/content/PostCard'; +import { extractHeadings } from '@/lib/content'; +import { formatDate } from '@/lib/utils'; +import { t } from '@/lib/i18n'; + +export const dynamic = 'force-dynamic'; + +type Params = { params: Promise<{ slug: string }> }; + +export async function generateMetadata({ params }: Params): Promise { + const { slug } = await params; + const post = await getPublishedPost(slug); + if (!post) return { title: 'Post not found' }; + return { + title: post.title, + description: post.excerpt, + openGraph: { + type: 'article', + title: post.title, + description: post.excerpt, + publishedTime: post.publishedAt?.toISOString(), + modifiedTime: post.updatedAt.toISOString(), + authors: [post.author.name], + tags: post.tags.map(({ tag }) => tag.name), + }, + }; +} + +export default async function PostPage({ params }: Params) { + const { slug } = await params; + const post = await getPublishedPost(slug); + if (!post) notFound(); + + const base = (process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000').replace(/\/$/, ''); + const headings = extractHeadings(post.body); + const related = await listRelatedPosts(post); + + const jsonLd = { + '@context': 'https://schema.org', + '@type': 'Article', + headline: post.title, + description: post.excerpt, + datePublished: post.publishedAt?.toISOString(), + dateModified: post.updatedAt.toISOString(), + author: { '@type': 'Person', name: post.author.name, url: `${base}/authors/${post.author.slug}` }, + keywords: post.tags.map(({ tag }) => tag.name).join(', '), + mainEntityOfPage: `${base}/blog/${post.slug}`, + url: `${base}/blog/${post.slug}`, + }; + + return ( +
+