templates2.Content.Next/prisma/seed.ts
w4c-templates 3caefb8833
Some checks are pending
CI / quality (push) Waiting to run
chore(template): publish Content.Next
2026-10-11 23:29:57 +03:00

374 lines
14 KiB
TypeScript

import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcryptjs';
const db = new PrismaClient();
const fence = (lang: string, ...lines: string[]) => ['```' + lang, ...lines, '```'];
const readingMinutes = (body: string) => Math.max(1, Math.round(body.trim().split(/\s+/).filter(Boolean).length / 200));
const AUTHORS = [
{
slug: 'ada-lovelace',
name: 'Ada Lovelace',
bio: 'Engineer and writer. Ada covers the front end, performance and the launch process.',
avatarColor: '#4f46e5',
},
{
slug: 'grace-hopper',
name: 'Grace Hopper',
bio: 'Compiler enthusiast turned product writer. Grace writes about developer experience and tooling.',
avatarColor: '#0d9488',
},
];
const TAGS = ['Engineering', 'Product', 'Design'];
type SeedPost = {
slug: string;
title: string;
excerpt: string;
author: string;
tags: string[];
status: 'draft' | 'published';
featured?: boolean;
daysAgo: number;
body: string;
};
const POSTS: SeedPost[] = [
{
slug: 'launch-checklist',
title: 'The content launch checklist',
excerpt: 'Everything to verify before you point a domain at a brand-new content site.',
author: 'ada-lovelace',
tags: ['Product', 'Engineering'],
status: 'published',
featured: true,
daysAgo: 2,
body: [
'A launch is not a deploy — it is a sequence of checks that keep a bad first impression off the record.',
'',
'## Before you ship',
'',
'Confirm metadata, sitemap, robots and a working RSS feed. Verify the 404 page, the empty states and the search results page.',
'',
'- [x] titles and descriptions on every route',
'- [x] sitemap includes posts and docs',
'- [x] RSS validates',
'',
'## After you ship',
'',
'Submit the sitemap and watch the error logs for the first hour. Then publish your second post — cadence beats polish.',
'',
...fence('bash', 'curl -s https://example.com/sitemap.xml | head -n 5', 'curl -s https://example.com/rss.xml | head -n 5'),
].join('\n'),
},
{
slug: 'structured-content-with-prisma',
title: 'Structured content with Prisma',
excerpt: 'Store posts, tags and authors relationally so the site stays queryable as it grows.',
author: 'grace-hopper',
tags: ['Engineering'],
status: 'published',
daysAgo: 5,
body: [
'Hard-coded arrays get you to a demo. A relational model gets you to a product.',
'',
'## The shape',
'',
'A post belongs to an author and has many tags through a join table. That single decision unlocks author pages, tag pages and related posts.',
'',
...fence(
'prisma',
'model Post {',
' id String @id @default(cuid())',
' slug String @unique',
' title String',
' status String @default("draft")',
' authorId String',
' tags PostTag[]',
'}',
),
'',
'## Querying published content',
'',
'Filtering by `status` at the query layer means a draft can never leak onto a public list.',
'',
...fence('ts', 'const posts = await db.post.findMany({', " where: { status: 'published' },", " orderBy: { publishedAt: 'desc' },", '});'),
].join('\n'),
},
{
slug: 'a-design-token-system',
title: 'A design-token system that survives dark mode',
excerpt: 'One set of CSS variables powers both themes — contrast stays predictable.',
author: 'ada-lovelace',
tags: ['Design', 'Engineering'],
status: 'published',
daysAgo: 8,
body: [
'Tokens are the contract between design and code. Define them once and let the theme swap the values.',
'',
'## Light and dark',
'',
'Keep colour decisions in two places only: the `:root` block and the `.dark` block.',
'',
...fence('css', ':root {', ' --surface: #ffffff;', ' --text: #0f172a;', '}', '.dark {', ' --surface: #0b1120;', ' --text: #e5e7eb;', '}'),
'',
'## Contrast',
'',
'Check the muted text against each surface. AA is the floor, not the ceiling.',
].join('\n'),
},
{
slug: 'seo-for-content-sites',
title: 'SEO for content sites',
excerpt: 'Metadata, JSON-LD, a sitemap and an RSS feed — the four things search engines actually read.',
author: 'grace-hopper',
tags: ['Engineering', 'Product'],
status: 'published',
daysAgo: 12,
body: [
'Search engines reward pages that describe themselves. Start with a title, a description and canonical URLs.',
'',
'## Structured data',
'',
'Add JSON-LD for your articles. It is cheap and it shows up in results.',
'',
...fence(
'json',
'{',
' "@context": "https://schema.org",',
' "@type": "Article",',
' "headline": "SEO for content sites"',
'}',
),
'',
'## Feeds',
'',
'A sitemap and an RSS feed are the fastest way to get discovered.',
].join('\n'),
},
{
slug: 'writing-docs-people-read',
title: 'Writing docs people actually read',
excerpt: 'Short sections, runnable examples and a table of contents that earns its place.',
author: 'ada-lovelace',
tags: ['Product'],
status: 'published',
daysAgo: 16,
body: [
'Good documentation answers one question per section and ends with the next step.',
'',
'## Lead with the task',
'',
'Open with what the reader wants to do, not with architecture.',
'',
'## Show, do not tell',
'',
'Every code sample should be runnable. If it is not, say so.',
'',
...fence('bash', 'npm ci', 'cp .env.example .env', 'npm run db:setup', 'npm run dev'),
].join('\n'),
},
{
slug: 'reading-time-and-toc',
title: 'Reading time and a table of contents, from markdown',
excerpt: 'Two small utilities turn a raw markdown body into a navigable article.',
author: 'grace-hopper',
tags: ['Engineering'],
status: 'published',
daysAgo: 20,
body: [
'A table of contents is just the headings, in order, with stable anchors. Reading time is a word count divided by a comfortable pace.',
'',
'## Extracting headings',
'',
...fence('ts', 'const headings = body', ' .split("\\n")', ' .filter((line) => line.startsWith("## "));'),
'',
'## Estimating reading time',
'',
...fence('ts', 'const minutes = Math.max(1, Math.round(words / 200));'),
'',
'## Why it matters',
'',
'Both signals help a reader decide whether to commit — and they are cheap to compute server-side.',
].join('\n'),
},
{
slug: 'search-that-scales',
title: 'Search that scales past a hundred posts',
excerpt: 'Start with a server-side scan over titles and bodies; move to an index when it hurts.',
author: 'ada-lovelace',
tags: ['Engineering'],
status: 'published',
daysAgo: 24,
body: [
'A content site does not need a search cluster on day one. It needs a query that returns the right post.',
'',
'## Start simple',
'',
'Case-insensitive substring matching over title, excerpt and body covers most sites.',
'',
...fence('ts', 'const results = posts.filter((post) =>', ' post.body.toLowerCase().includes(query),', ');'),
'',
'## Grow later',
'',
'When the corpus outgrows a scan, swap the implementation behind the same results page.',
].join('\n'),
},
{
slug: 'roadmap-whats-next',
title: 'Roadmap: what is next',
excerpt: 'Draft notes on comments, newsletter capture and a richer editor.',
author: 'ada-lovelace',
tags: ['Product'],
status: 'draft',
daysAgo: 1,
body: [
'This post is a draft — it must never appear on the public blog.',
'',
'## Candidates',
'',
'- Comments with moderation',
'- Newsletter capture on posts',
'- A richer authoring editor',
].join('\n'),
},
];
type SeedDoc = { slug: string; title: string; section: string; order: number; status: 'draft' | 'published'; body: string };
const DOCS: SeedDoc[] = [
{
slug: 'installation',
title: 'Installation',
section: 'Getting started',
order: 1,
status: 'published',
body: ['Install dependencies, set up the database and start the dev server.', '', '## Steps', '', ...fence('bash', 'npm ci', 'cp .env.example .env', 'npm run db:setup', 'npm run dev'), '', '## Next', '', 'Read the project structure guide next.'].join('\n'),
},
{
slug: 'project-structure',
title: 'Project structure',
section: 'Getting started',
order: 2,
status: 'published',
body: ['Where things live.', '', '## Routes', '', 'Public pages live under `app/(marketing)`, the admin under `app/admin`.', '', '## Content', '', 'Content lives in the database, seeded from `prisma/seed.ts`.'].join('\n'),
},
{
slug: 'writing-content',
title: 'Writing content',
section: 'Guides',
order: 1,
status: 'published',
body: ['Author posts and docs from the admin.', '', '## Markdown', '', 'Use `##` and `###` headings, lists, links and fenced code blocks.', '', ...fence('md', '## A heading', '', 'Some text with `inline code`.', '', '```js', 'console.log("hi");', '```'), '', '## Publishing', '', 'Save as a draft, preview it, then publish when it is ready.'].join('\n'),
},
{
slug: 'deploying',
title: 'Deploying',
section: 'Guides',
order: 2,
status: 'published',
body: ['Build a standalone image and point `DATABASE_URL` at Postgres.', '', '## Docker', '', ...fence('bash', 'docker compose -f docker/docker-compose.yml up --build'), '', '## Environment', '', 'Set `NEXT_PUBLIC_SITE_URL` so sitemap and RSS links are absolute.'].join('\n'),
},
{
slug: 'configuration',
title: 'Configuration',
section: 'Reference',
order: 1,
status: 'published',
body: ['Every variable has a working default.', '', '## Variables', '', '| key | required | note |', '| --- | --- | --- |', '| DATABASE_URL | no | SQLite by default |', '| NEXT_PUBLIC_SITE_URL | no | public origin |', '| RESEND_API_KEY | no | log provider when absent |'].join('\n'),
},
{
slug: 'content-api',
title: 'Content API',
section: 'Reference',
order: 2,
status: 'published',
body: ['The admin API is role-gated server-side.', '', '## Endpoints', '', '- `GET /api/admin/posts` — list posts (draft and published)', '- `POST /api/admin/posts` — create', '- `PATCH /api/admin/posts/[id]` — update or publish', '- `DELETE /api/admin/posts/[id]` — delete', '', 'Same shape for `/api/admin/docs`.'].join('\n'),
},
];
async function main() {
const password = await bcrypt.hash('password', 10);
const mkUser = (email: string, name: string, role: string) =>
db.user.upsert({ where: { email }, update: { role }, create: { email, name, role, passwordHash: password } });
const admin = await mkUser('admin@example.com', 'Ada Admin', 'admin');
const editor = await mkUser('editor@example.com', 'Evan Editor', 'editor');
const user = await mkUser('user@example.com', 'Demo User', 'user');
for (const u of [admin, editor, user]) {
await db.subscription.upsert({ where: { userId: u.id }, update: {}, create: { userId: u.id, plan: 'free', status: 'active', provider: 'mock' } });
}
const authorIds = new Map<string, string>();
for (const author of AUTHORS) {
const row = await db.author.upsert({
where: { slug: author.slug },
update: { name: author.name, bio: author.bio, avatarColor: author.avatarColor },
create: author,
});
authorIds.set(author.slug, row.id);
}
for (const name of TAGS) {
const slug = name.toLowerCase();
await db.tag.upsert({ where: { slug }, update: {}, create: { name, slug } });
}
for (const post of POSTS) {
const published = post.status === 'published';
const authorId = authorIds.get(post.author);
if (!authorId) continue;
const publishedAt = new Date(Date.now() - post.daysAgo * 24 * 60 * 60 * 1000);
const data = {
title: post.title,
excerpt: post.excerpt,
body: post.body,
status: post.status,
publishedAt: published ? publishedAt : null,
readingMinutes: readingMinutes(post.body),
featured: post.featured ?? false,
authorId,
};
const row = await db.post.upsert({ where: { slug: post.slug }, update: data, create: { slug: post.slug, ...data } });
await db.postTag.deleteMany({ where: { postId: row.id } });
for (const tagName of post.tags) {
const tag = await db.tag.findUnique({ where: { slug: tagName.toLowerCase() } });
if (tag) await db.postTag.create({ data: { postId: row.id, tagId: tag.id } });
}
}
for (const doc of DOCS) {
await db.doc.upsert({
where: { slug: doc.slug },
update: { title: doc.title, section: doc.section, order: doc.order, body: doc.body, status: doc.status },
create: doc,
});
}
if ((await db.auditLog.count()) === 0) {
await db.auditLog.createMany({
data: [
{ actorId: admin.id, action: 'seed', target: 'system', meta: 'Initial demo content' },
{ actorId: admin.id, action: 'post.create', target: 'launch-checklist' },
{ actorId: editor.id, action: 'doc.create', target: 'installation' },
],
});
}
console.log(
`Seeded content: ${admin.email} (admin), ${editor.email} (editor), ${user.email} (user) — password: "password"; ` +
`${POSTS.length} posts, ${DOCS.length} docs, ${AUTHORS.length} authors, ${TAGS.length} tags`,
);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await db.$disconnect();
});