diff --git a/data/guides.json b/data/guides.json index 128878a3..66c4bd18 100644 --- a/data/guides.json +++ b/data/guides.json @@ -1,90 +1,128 @@ [ { + "id": "guide-001", "title": "Your First Agent in 15 Minutes", "slug": "first-agent-15-minutes", "description": "Set up a practical local agent loop with clear prompts, tools, and guardrails.", "difficulty": "beginner", "category": "getting-started", "content": "# Your First Agent in 15 Minutes\n\nThis guide gets you from zero to a working agent loop.\n\n## What you'll build\n\n- A local run command\n- A tiny prompt contract\n- A repeatable test cycle\n\n## Step 1: Define success\n\nWrite one sentence that describes what success means.\n\n## Step 2: Start simple\n\nUse one model, one tool, and one output format before adding complexity.", + "tags": ["starter", "workflow", "local-dev"], + "updatedAt": "2026-02-09T19:00:00.000Z", "readCount": 12, "estimatedMinutes": 15, "author": "forAgents Team" }, { + "id": "guide-002", "title": "Prompt Contracts That Don't Drift", "slug": "prompt-contracts-dont-drift", "description": "Design durable system and task prompts that stay reliable over time.", "difficulty": "intermediate", "category": "best-practices", "content": "# Prompt Contracts That Don't Drift\n\nPrompt drift happens when instructions become vague as workflows evolve.\n\n## Core pattern\n\n1. Define role and scope\n2. Specify allowed tools\n3. Require output schema\n4. Include failure behavior\n\n## Version prompts\n\nTreat prompts as code. Track changes and test before rollout.", + "tags": ["prompts", "testing", "reliability"], + "updatedAt": "2026-02-09T19:10:00.000Z", "readCount": 18, "estimatedMinutes": 12, "author": "Kai Reflectt" }, { + "id": "guide-003", "title": "Secure Tool Use for Agent Actions", "slug": "secure-tool-use-agent-actions", "description": "Apply least-privilege tool access, validation, and audit trails for safer automation.", "difficulty": "advanced", "category": "security", "content": "# Secure Tool Use for Agent Actions\n\nSecurity is mostly about boundaries.\n\n## Guardrail checklist\n\n- Restrict tool scope per task\n- Validate user-controlled inputs\n- Log action intent and results\n- Require confirmation for destructive paths\n\n## Red-team your routes\n\nCreate adversarial prompts and ensure your policy blocks unsafe calls.", + "tags": ["security", "guardrails", "tooling"], + "updatedAt": "2026-02-09T19:20:00.000Z", "readCount": 9, "estimatedMinutes": 14, "author": "Security Guild" }, { + "id": "guide-004", "title": "Production Deployments with Confidence", "slug": "production-deployments-with-confidence", "description": "Ship agent workflows with release checklists, rollback plans, and observability.", "difficulty": "intermediate", "category": "deployment", "content": "# Production Deployments with Confidence\n\nDeployment is a process, not a button.\n\n## Release flow\n\n- Stage in a sandbox\n- Run regression scenarios\n- Deploy behind a flag\n- Monitor error and latency budgets\n\n## Rollback first\n\nWrite rollback steps before launch day.", + "tags": ["deployment", "operations", "release"], + "updatedAt": "2026-02-09T19:30:00.000Z", "readCount": 21, "estimatedMinutes": 11, "author": "Ops Team" }, { + "id": "guide-005", "title": "Latency Budgets for Multi-Agent Systems", "slug": "latency-budgets-multi-agent", "description": "Control response time with fan-out limits, timeouts, and cached context.", "difficulty": "advanced", "category": "performance", "content": "# Latency Budgets for Multi-Agent Systems\n\nSpeed is a product feature.\n\n## Where latency hides\n\n- Serial tool chains\n- Overly large context windows\n- Repeated retrieval calls\n\n## Practical fixes\n\nUse bounded fan-out, timeout budgets, and memoized retrieval results.", + "tags": ["performance", "latency", "multi-agent"], + "updatedAt": "2026-02-09T19:40:00.000Z", "readCount": 7, "estimatedMinutes": 13, "author": "Performance Desk" }, { + "id": "guide-006", "title": "Agent Architecture Fundamentals", "slug": "agent-architecture-fundamentals", "description": "Learn the building blocks of planning, memory, and execution loops.", "difficulty": "beginner", "category": "getting-started", "content": "# Agent Architecture Fundamentals\n\nEvery robust agent loop has three parts: decide, act, reflect.\n\n## Components\n\n- Planner\n- Tool executor\n- Memory store\n\n## First principle\n\nKeep each component observable so debugging is possible.", + "tags": ["architecture", "basics", "memory"], + "updatedAt": "2026-02-09T19:50:00.000Z", "readCount": 25, "estimatedMinutes": 10, "author": "forAgents Team" }, { + "id": "guide-007", "title": "Operational Playbooks for Agent Teams", "slug": "operational-playbooks-agent-teams", "description": "Create runbooks for incidents, degraded tools, and fallback behavior.", "difficulty": "intermediate", "category": "best-practices", "content": "# Operational Playbooks for Agent Teams\n\nPlaybooks reduce panic during failures.\n\n## Minimum runbook\n\n1. Detection signal\n2. Triage path\n3. User communication\n4. Recovery steps\n\n## Practice drills\n\nRun a monthly failure simulation and update docs after each drill.", + "tags": ["runbooks", "reliability", "team-ops"], + "updatedAt": "2026-02-09T20:00:00.000Z", "readCount": 11, "estimatedMinutes": 9, "author": "Reliability Crew" }, { + "id": "guide-008", "title": "Hardening Secrets in Deployment Pipelines", "slug": "hardening-secrets-deployment-pipelines", "description": "Prevent key leaks across CI/CD and runtime by rotating, scoping, and scanning secrets.", "difficulty": "advanced", "category": "security", "content": "# Hardening Secrets in Deployment Pipelines\n\nSecrets fail most often at the seams between local, CI, and production.\n\n## Best practices\n\n- Use short-lived tokens\n- Rotate on schedule and on incident\n- Scan commits and container layers\n- Separate build and runtime credentials\n\n## Final check\n\nAssume compromise and design fast recovery.", + "tags": ["secrets", "cicd", "security"], + "updatedAt": "2026-02-09T20:10:00.000Z", "readCount": 6, "estimatedMinutes": 16, "author": "Platform Security" + }, + { + "id": "guide-009", + "title": "Kit Integration Guide", + "slug": "kit-integration", + "description": "How the Reflectt agent kits work together: Memory, Autonomy, and Team.", + "difficulty": "intermediate", + "category": "best-practices", + "content": "# Kit Integration Guide\n\nHow the Reflectt agent kits work together — and how to set them up without confusion.\n\n## The kits at a glance\n\n- **Memory Kit**: persistent context and procedures\n- **Autonomy Kit**: proactive execution with queue + heartbeat\n- **Team Kit**: shared workflows and role-based coordination\n\n## Recommended setup order\n\n1. Install Memory Kit first\n2. Add Autonomy Kit after memory is stable\n3. Add Team Kit when you run multiple agents\n\n## Key distinction\n\n- `tasks/QUEUE.md` is personal\n- `process/BACKLOG.md` is shared team work\n\n## Common pitfall\n\nDon't skip memory. Without persistent memory, autonomy and team coordination degrade fast.", + "tags": ["kits", "integration", "memory", "autonomy", "team"], + "updatedAt": "2026-02-09T20:20:00.000Z", + "readCount": 4, + "estimatedMinutes": 8, + "author": "Team Reflectt" } ] diff --git a/src/app/api/guides/route.ts b/src/app/api/guides/route.ts index 202fe4b6..185351c0 100644 --- a/src/app/api/guides/route.ts +++ b/src/app/api/guides/route.ts @@ -1,24 +1,134 @@ import { NextRequest, NextResponse } from "next/server"; -import { filterGuides, readGuides, toGuideSummary } from "@/lib/guides"; +import { + createGuideId, + createGuideSlug, + filterGuides, + normalizeGuideCategory, + normalizeGuideDifficulty, + readGuides, + toGuideSummary, + type Guide, + type GuideCategory, + type GuideDifficulty, + writeGuides, +} from "@/lib/guides"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; export async function GET(request: NextRequest) { - const difficulty = request.nextUrl.searchParams.get("difficulty") ?? undefined; - const category = request.nextUrl.searchParams.get("category") ?? undefined; - const search = request.nextUrl.searchParams.get("search") ?? undefined; - - const guides = await readGuides(); - const filtered = filterGuides(guides, { difficulty, category, search }); - - return NextResponse.json( - { - guides: filtered.map(toGuideSummary), - total: filtered.length, - }, - { - headers: { - "Cache-Control": "no-store", + try { + const difficulty = request.nextUrl.searchParams.get("difficulty"); + const category = request.nextUrl.searchParams.get("category"); + const search = request.nextUrl.searchParams.get("search"); + + if (difficulty && !normalizeGuideDifficulty(difficulty)) { + return NextResponse.json( + { error: "Invalid difficulty. Use beginner, intermediate, or advanced." }, + { status: 400 } + ); + } + + if (category && !normalizeGuideCategory(category)) { + return NextResponse.json( + { error: "Invalid category. Use getting-started, best-practices, deployment, security, or performance." }, + { status: 400 } + ); + } + + const guides = await readGuides(); + const filtered = filterGuides(guides, { difficulty, category, search }); + + return NextResponse.json( + { + guides: filtered.map(toGuideSummary), + total: filtered.length, }, + { + headers: { + "Cache-Control": "no-store", + }, + } + ); + } catch (error) { + console.error("Failed to load guides", error); + return NextResponse.json({ error: "Failed to load guides" }, { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + try { + const body = (await request.json()) as Record; + + const title = typeof body.title === "string" ? body.title.trim() : ""; + const content = typeof body.content === "string" ? body.content.trim() : ""; + const description = typeof body.description === "string" ? body.description.trim() : ""; + const author = typeof body.author === "string" ? body.author.trim() : "forAgents Team"; + + const categoryRaw = typeof body.category === "string" ? body.category : ""; + const difficultyRaw = typeof body.difficulty === "string" ? body.difficulty : ""; + + const category = normalizeGuideCategory(categoryRaw) as GuideCategory | null; + const difficulty = normalizeGuideDifficulty(difficultyRaw) as GuideDifficulty | null; + + const tags = Array.isArray(body.tags) + ? body.tags.filter((tag): tag is string => typeof tag === "string" && tag.trim().length > 0) + : []; + + const readCount = typeof body.readCount === "number" && Number.isFinite(body.readCount) ? body.readCount : 0; + const estimatedMinutes = + typeof body.estimatedMinutes === "number" && Number.isFinite(body.estimatedMinutes) + ? body.estimatedMinutes + : 10; + + if (!title || !content || !description || !category || !difficulty) { + return NextResponse.json( + { + error: + "title, description, content, category (getting-started|best-practices|deployment|security|performance), and difficulty (beginner|intermediate|advanced) are required", + }, + { status: 400 } + ); + } + + const guides = await readGuides(); + + const requestedSlug = typeof body.slug === "string" ? body.slug.trim() : ""; + const baseSlug = requestedSlug || createGuideSlug(title); + + if (!baseSlug) { + return NextResponse.json({ error: "Unable to generate guide slug from title" }, { status: 400 }); + } + + let slug = baseSlug; + let counter = 2; + while (guides.some((guide) => guide.slug === slug)) { + slug = `${baseSlug}-${counter}`; + counter += 1; } - ); + + const newGuide: Guide = { + id: createGuideId(), + title, + slug, + description, + content, + category, + difficulty, + tags, + updatedAt: new Date().toISOString(), + readCount, + estimatedMinutes, + author, + }; + + const updatedGuides = [...guides, newGuide]; + await writeGuides(updatedGuides); + + return NextResponse.json({ guide: newGuide }, { status: 201 }); + } catch (error) { + console.error("Failed to create guide", error); + return NextResponse.json({ error: "Failed to create guide" }, { status: 500 }); + } } diff --git a/src/app/guides/kit-integration/page.tsx b/src/app/guides/kit-integration/page.tsx index a9fba111..f85292d1 100644 --- a/src/app/guides/kit-integration/page.tsx +++ b/src/app/guides/kit-integration/page.tsx @@ -1,4 +1,10 @@ +/* eslint-disable react/no-unescaped-entities */ import Link from "next/link"; +import { notFound } from "next/navigation"; + +import { readGuides } from "@/lib/guides"; + +import { GuideContent } from "../[slug]/guide-content"; export const metadata = { title: "Kit Integration Guide — forAgents.dev", @@ -6,367 +12,40 @@ export const metadata = { "How the Reflectt agent kits work together — Memory, Autonomy, Team — and how to set them up.", }; -function Section({ - title, - children, -}: { - title: string; - children: React.ReactNode; -}) { - return ( -
-

{title}

- {children} -
- ); +function toTitleCase(value: string) { + return value + .split("-") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); } -export default function KitIntegrationGuidePage() { - return ( -
+export default async function KitIntegrationGuidePage() { + const guides = await readGuides(); + const guide = guides.find((item) => item.slug === "kit-integration"); + + if (!guide) { + notFound(); + } - {/* Content */} -
+ return ( +
+
- + ← Back to Guides
-

Kit Integration Guide

-

- How the Reflectt agent kits work together — and how to set them up - without confusion. -

- -
- Agent-readable version:{" "} - - GET /api/guides/integration.md - +

{guide.title}

+

{guide.description}

+
+ Difficulty: {toTitleCase(guide.difficulty)} + Category: {toTitleCase(guide.category)} + Updated: {new Date(guide.updatedAt).toLocaleDateString("en-CA")}
- {/* Kits at a Glance */} -
-
- {[ - { - emoji: "🧠", - name: "Memory Kit", - purpose: "Remember what happened, what you know, how to do things", - status: "Available", - statusColor: "text-[#06D6A0]", - files: "memory/, MEMORY.md", - }, - { - emoji: "🚀", - name: "Autonomy Kit", - purpose: "Work without prompts, use heartbeats productively", - status: "Available", - statusColor: "text-[#06D6A0]", - files: "tasks/QUEUE.md, HEARTBEAT.md", - }, - { - emoji: "🤝", - name: "Team Kit", - purpose: "Coordinate multiple agents with roles and processes", - status: "Available", - statusColor: "text-[#06D6A0]", - files: "process/BACKLOG.md, process/ROLES.md", - }, - { - emoji: "🪞", - name: "Identity Kit", - purpose: "Define who the agent is (personality, values, voice)", - status: "Planned", - statusColor: "text-[#F59E0B]", - files: "SOUL.md, identity config", - }, - { - emoji: "🌉", - name: "Bridge Kit", - purpose: "Connect agents across platforms and workspaces", - status: "Planned", - statusColor: "text-[#F59E0B]", - files: "Cross-platform routing", - }, - ].map((kit) => ( -
-
- - {kit.emoji} {kit.name} - - - {kit.status} - -
-

{kit.purpose}

-

- {kit.files} -

-
- ))} -
-
- - {/* How They Work Together */} -
-
-{`┌─────────────────────────────────────────────────────┐
-│                   AGENT WORKSPACE                   │
-│                                                     │
-│  Identity Kit ─── WHO AM I? ──────► Bridge Kit      │
-│       │                            (cross-platform) │
-│       │ personality & voice              │           │
-│       ▼                                  ▼           │
-│  Memory Kit ◀──────────────┐   Discord / Slack /... │
-│  (memory/, MEMORY.md)      │                        │
-│       │                    │                        │
-│  context & procedures      │                        │
-│       │                    │                        │
-│       ▼                    │                        │
-│  Autonomy Kit ─────────────┤                        │
-│  (tasks/QUEUE.md)          │                        │
-│       │                    │                        │
-│  heartbeat triggers        │                        │
-│       │                    │                        │
-│       ▼                    │                        │
-│  Team Kit ─── completes ───┘                        │
-│  (process/BACKLOG.md)                               │
-└─────────────────────────────────────────────────────┘`}
-          
-
    -
  1. - Memory Kit provides context on wake (what - happened, what you know, how to do things) -
  2. -
  3. - Autonomy Kit uses that context to pick and execute - tasks from a personal queue -
  4. -
  5. - Team Kit coordinates multiple agents through a - shared process with roles, triage, and a team backlog -
  6. -
  7. - Memory Kit captures everything that happens back into persistent - files -
  8. -
-
- - {/* QUEUE.md vs BACKLOG.md */} -
-

This is the overlap that causes confusion. Here's the clear rule:

-
-
-

- tasks/QUEUE.md -

-

- Autonomy Kit — Personal -

-
    -
  • One agent's work list
  • -
  • Added by the agent itself + human
  • -
  • Only you pick up tasks
  • -
  • No formal triage
  • -
-
-
-

- process/BACKLOG.md -

-

- Team Kit — Shared -

-
    -
  • Shared across all agents
  • -
  • Rhythm role triages from OPPORTUNITIES
  • -
  • Any agent can pick up
  • -
  • Full lifecycle with feedback
  • -
-
-
- -

- Using both together: QUEUE.md becomes your personal - scratch list; BACKLOG.md is the team's official queue. Items can - graduate from QUEUE.md into OPPORTUNITIES.md when they're worth team - attention. -

-
- - {/* Setup Flow */} -
-

- Install in this order — each kit builds on the previous one. -

- -

- Step 1: Memory Kit (foundation) -

-

Everything else depends on persistent memory.

-
-{`git clone https://github.com/reflectt/agent-memory-kit.git skills/agent-memory-kit
-mkdir -p memory/procedures
-cp skills/agent-memory-kit/templates/ARCHITECTURE.md memory/
-cp skills/agent-memory-kit/templates/feedback.md memory/
-cp skills/agent-memory-kit/templates/procedure-template.md memory/procedures/`}
-          
- -

- Step 2: Autonomy Kit (self-direction) -

-

Now your agent can work between prompts.

-
-{`git clone https://github.com/reflectt/agent-autonomy-kit.git skills/agent-autonomy-kit
-mkdir -p tasks
-cp skills/agent-autonomy-kit/templates/QUEUE.md tasks/QUEUE.md`}
-          
- -

- Step 3: Team Kit (coordination) — only if multi-agent -

-
-{`git clone https://github.com/reflectt/agent-team-kit.git skills/agent-team-kit
-cp -r skills/agent-team-kit/templates/process ./process`}
-          
-
- - {/* File Structure */} -
-
-{`your-workspace/
-├── AGENTS.md                    # Wake routine, safety rules
-├── SOUL.md                      # Agent identity
-├── MEMORY.md                    # Semantic memory (curated)
-├── HEARTBEAT.md                 # Proactive work triggers
-│
-├── memory/                      # 🧠 Memory Kit
-│   ├── ARCHITECTURE.md
-│   ├── feedback.md
-│   ├── procedures/*.md
-│   └── YYYY-MM-DD.md            # Daily logs
-│
-├── tasks/                       # 🚀 Autonomy Kit
-│   └── QUEUE.md
-│
-├── process/                     # 🤝 Team Kit
-│   ├── INTAKE.md
-│   ├── ROLES.md
-│   ├── OPPORTUNITIES.md
-│   ├── BACKLOG.md
-│   └── STATUS.md
-│
-└── skills/                      # Kit source
-    ├── agent-memory-kit/
-    ├── agent-autonomy-kit/
-    └── agent-team-kit/`}
-          
-
- - {/* Common Pitfalls */} -
-
- {[ - { - title: "Using QUEUE.md and BACKLOG.md interchangeably", - desc: "They serve different purposes. QUEUE.md = personal. BACKLOG.md = team.", - }, - { - title: "Installing Team Kit for a single agent", - desc: "The overhead only pays off with multiple agents. Solo agents: Memory + Autonomy.", - }, - { - title: "Skipping Memory Kit", - desc: "Both Autonomy and Team Kits assume persistent memory exists. Without it, agents lose context.", - }, - { - title: "Not merging HEARTBEAT.md", - desc: "Both kits include heartbeat templates. Merge them into one file with sections for both.", - }, - { - title: "Forgetting to log HOW", - desc: 'Capture the steps, not just the outcome. "Deployed the thing" is useless.', - }, - ].map((pitfall) => ( -
-

- ❌ {pitfall.title} -

-

{pitfall.desc}

-
- ))} -
-
- - {/* Decision Tree */} -
-
-{`Are you a single agent?
-├── YES → Memory Kit + Autonomy Kit. Use QUEUE.md. Done.
-└── NO (multi-agent team)
-    └── Memory Kit + Autonomy Kit + Team Kit.
-        ├── Personal tasks → tasks/QUEUE.md
-        ├── Team tasks → process/BACKLOG.md
-        └── Raw ideas → process/OPPORTUNITIES.md`}
-          
-
- -
- -

- Built by Team Reflectt. Check each kit's README for details. -

+
- - {/* Footer */} -
); } diff --git a/src/lib/guides.ts b/src/lib/guides.ts index 450d8712..bd70f1ba 100644 --- a/src/lib/guides.ts +++ b/src/lib/guides.ts @@ -9,27 +9,114 @@ export type GuideCategory = | "security" | "performance"; -export type Guide = { +export interface Guide { + id: string; title: string; slug: string; description: string; difficulty: GuideDifficulty; category: GuideCategory; content: string; + tags: string[]; + updatedAt: string; readCount: number; estimatedMinutes: number; author: string; -}; +} export type GuideSummary = Omit; +interface GuideFilters { + difficulty?: string | null; + category?: string | null; + search?: string | null; +} + const GUIDES_PATH = path.join(process.cwd(), "data", "guides.json"); +const guideCategories: GuideCategory[] = [ + "getting-started", + "best-practices", + "deployment", + "security", + "performance", +]; + +const guideDifficulties: GuideDifficulty[] = ["beginner", "intermediate", "advanced"]; + +function isGuideCategory(value: string): value is GuideCategory { + return guideCategories.includes(value as GuideCategory); +} + +function isGuideDifficulty(value: string): value is GuideDifficulty { + return guideDifficulties.includes(value as GuideDifficulty); +} + +function slugify(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, "") + .replace(/\s+/g, "-") + .replace(/-+/g, "-"); +} + +function createFallbackId(slug: string): string { + return `guide-${slug || Date.now().toString()}`; +} + +function toGuide(candidate: unknown): Guide | null { + if (!candidate || typeof candidate !== "object") return null; + + const entry = candidate as Record; + + const title = typeof entry.title === "string" ? entry.title.trim() : ""; + const slugValue = typeof entry.slug === "string" ? entry.slug.trim() : slugify(title); + const description = typeof entry.description === "string" ? entry.description.trim() : ""; + const content = typeof entry.content === "string" ? entry.content : ""; + const difficultyValue = typeof entry.difficulty === "string" ? entry.difficulty.trim().toLowerCase() : ""; + const categoryValue = typeof entry.category === "string" ? entry.category.trim().toLowerCase() : ""; + const author = typeof entry.author === "string" ? entry.author.trim() : "forAgents Team"; + + if (!title || !slugValue || !description || !content) return null; + if (!isGuideDifficulty(difficultyValue) || !isGuideCategory(categoryValue)) return null; + + const tags = Array.isArray(entry.tags) + ? entry.tags.filter((tag): tag is string => typeof tag === "string" && tag.trim().length > 0) + : []; + + const id = typeof entry.id === "string" && entry.id.trim().length > 0 ? entry.id.trim() : createFallbackId(slugValue); + const updatedAt = + typeof entry.updatedAt === "string" && entry.updatedAt.trim().length > 0 + ? entry.updatedAt.trim() + : new Date().toISOString(); + const readCount = typeof entry.readCount === "number" && Number.isFinite(entry.readCount) ? entry.readCount : 0; + const estimatedMinutes = + typeof entry.estimatedMinutes === "number" && Number.isFinite(entry.estimatedMinutes) ? entry.estimatedMinutes : 10; + + return { + id, + title, + slug: slugValue, + description, + difficulty: difficultyValue, + category: categoryValue, + content, + tags, + updatedAt, + readCount, + estimatedMinutes, + author, + }; +} + export async function readGuides(): Promise { try { const raw = await fs.readFile(GUIDES_PATH, "utf-8"); - const parsed = JSON.parse(raw) as Guide[]; - return Array.isArray(parsed) ? parsed : []; + const parsed = JSON.parse(raw) as unknown; + + if (!Array.isArray(parsed)) return []; + return parsed.map(toGuide).filter((guide): guide is Guide => guide !== null); } catch { return []; } @@ -37,39 +124,58 @@ export async function readGuides(): Promise { export async function writeGuides(guides: Guide[]): Promise { await fs.mkdir(path.dirname(GUIDES_PATH), { recursive: true }); - await fs.writeFile(GUIDES_PATH, JSON.stringify(guides, null, 2)); + await fs.writeFile(GUIDES_PATH, `${JSON.stringify(guides, null, 2)}\n`, "utf-8"); } -export function filterGuides( - guides: Guide[], - options: { difficulty?: string; category?: string; search?: string } -): Guide[] { +export function filterGuides(guides: Guide[], options: GuideFilters): Guide[] { const difficulty = options.difficulty?.trim().toLowerCase(); const category = options.category?.trim().toLowerCase(); const search = options.search?.trim().toLowerCase(); return guides.filter((guide) => { - const matchesDifficulty = !difficulty || guide.difficulty === difficulty; - const matchesCategory = !category || guide.category === category; - const matchesSearch = - !search || - [guide.title, guide.description, guide.content, guide.author].some((value) => - value.toLowerCase().includes(search) - ); - - return matchesDifficulty && matchesCategory && matchesSearch; + if (difficulty && isGuideDifficulty(difficulty) && guide.difficulty !== difficulty) return false; + if (category && isGuideCategory(category) && guide.category !== category) return false; + + if (!search) return true; + + return [guide.title, guide.description, guide.content, guide.author, ...guide.tags] + .join(" ") + .toLowerCase() + .includes(search); }); } +export function normalizeGuideCategory(value: string): GuideCategory | null { + const normalized = value.trim().toLowerCase(); + return isGuideCategory(normalized) ? normalized : null; +} + +export function normalizeGuideDifficulty(value: string): GuideDifficulty | null { + const normalized = value.trim().toLowerCase(); + return isGuideDifficulty(normalized) ? normalized : null; +} + export function toGuideSummary(guide: Guide): GuideSummary { return { + id: guide.id, title: guide.title, slug: guide.slug, description: guide.description, difficulty: guide.difficulty, category: guide.category, + tags: guide.tags, + updatedAt: guide.updatedAt, readCount: guide.readCount, estimatedMinutes: guide.estimatedMinutes, author: guide.author, }; } + +export function createGuideId(): string { + const suffix = Math.random().toString(36).slice(2, 10); + return `guide-${Date.now()}-${suffix}`; +} + +export function createGuideSlug(title: string): string { + return slugify(title); +}