diff --git a/__tests__/lib/directus.test.ts b/__tests__/lib/directus.test.ts
new file mode 100644
index 0000000..7ee128a
--- /dev/null
+++ b/__tests__/lib/directus.test.ts
@@ -0,0 +1,75 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+
+const mockRequest = vi.fn()
+const mockWith = vi.fn()
+
+vi.mock('@directus/sdk', () => ({
+ createDirectus: vi.fn(() => ({ with: mockWith })),
+ rest: vi.fn(() => 'rest-plugin'),
+ staticToken: vi.fn(() => 'token-plugin'),
+ readItems: vi.fn((collection: string) => ({ _op: 'readItems', collection })),
+ readSingleton: vi.fn((collection: string) => ({ _op: 'readSingleton', collection })),
+ readItem: vi.fn((collection: string, id: string) => ({ _op: 'readItem', collection, id })),
+}))
+
+mockWith.mockReturnValue({ with: mockWith, request: mockRequest })
+
+beforeEach(() => {
+ vi.resetModules()
+ mockRequest.mockReset()
+ process.env.DIRECTUS_URL = 'https://cms.achmad.dev'
+ process.env.DIRECTUS_TOKEN = 'test-token'
+})
+
+describe('getAllCategories', () => {
+ it('returns array of categories from Directus', async () => {
+ mockRequest.mockResolvedValue([
+ { id: '1', name: 'Anime', slug: 'anime', description: null },
+ { id: '2', name: 'VTubers', slug: 'vtubers', description: null },
+ ])
+ const { getAllCategories } = await import('@/lib/directus')
+ const result = await getAllCategories()
+ expect(result).toHaveLength(2)
+ expect(result[0].slug).toBe('anime')
+ })
+})
+
+describe('getArticleBySlug', () => {
+ it('returns null when no article matches', async () => {
+ mockRequest.mockResolvedValue([])
+ const { getArticleBySlug } = await import('@/lib/directus')
+ const result = await getArticleBySlug('nonexistent')
+ expect(result).toBeNull()
+ })
+
+ it('returns the first matching article', async () => {
+ mockRequest.mockResolvedValue([
+ {
+ id: '1', title: 'Frieren S2', slug: 'frieren-s2', status: 'published',
+ content: '
Body
', excerpt: 'Short', featured_image: null,
+ published_at: '2026-05-28T00:00:00Z', is_featured: false,
+ seo_title: null, seo_description: null,
+ category: { id: '1', name: 'Anime', slug: 'anime', description: null },
+ tags: [],
+ },
+ ])
+ const { getArticleBySlug } = await import('@/lib/directus')
+ const result = await getArticleBySlug('frieren-s2')
+ expect(result?.title).toBe('Frieren S2')
+ })
+})
+
+describe('getAssetUrl', () => {
+ it('constructs a Directus asset URL from a file UUID', async () => {
+ const { getAssetUrl } = await import('@/lib/directus')
+ const url = getAssetUrl('abc-123-uuid')
+ expect(url).toBe('https://cms.achmad.dev/assets/abc-123-uuid')
+ })
+
+ it('appends width and quality query params', async () => {
+ const { getAssetUrl } = await import('@/lib/directus')
+ const url = getAssetUrl('abc-123-uuid', { width: 800, quality: 80 })
+ expect(url).toContain('width=800')
+ expect(url).toContain('quality=80')
+ })
+})
diff --git a/lib/directus.ts b/lib/directus.ts
new file mode 100644
index 0000000..0e70e3d
--- /dev/null
+++ b/lib/directus.ts
@@ -0,0 +1,147 @@
+import {
+ createDirectus,
+ rest,
+ staticToken,
+ readItems,
+ readSingleton,
+ readItem,
+} from '@directus/sdk'
+import type { Article, Category, SiteSettings } from './types'
+
+const directus = createDirectus(process.env.DIRECTUS_URL!)
+ .with(staticToken(process.env.DIRECTUS_TOKEN!))
+ .with(rest())
+
+export async function getAllCategories(): Promise {
+ return directus.request(
+ readItems('categories', {
+ fields: ['id', 'name', 'slug', 'description'],
+ sort: ['name'],
+ })
+ ) as Promise
+}
+
+export async function getCategoryBySlug(slug: string): Promise {
+ const results = await directus.request(
+ readItems('categories', {
+ fields: ['id', 'name', 'slug', 'description'],
+ filter: { slug: { _eq: slug } },
+ limit: 1,
+ })
+ ) as Category[]
+ return results[0] ?? null
+}
+
+export async function getArticles(options: {
+ limit?: number
+ offset?: number
+ categorySlug?: string
+} = {}): Promise {
+ const { limit = 12, offset = 0, categorySlug } = options
+ const filter: Record = { status: { _eq: 'published' } }
+ if (categorySlug) {
+ filter['category'] = { slug: { _eq: categorySlug } }
+ }
+ return directus.request(
+ readItems('articles', {
+ fields: [
+ 'id', 'title', 'slug', 'status', 'excerpt', 'featured_image',
+ 'published_at', 'is_featured',
+ 'category.id', 'category.name', 'category.slug',
+ ],
+ filter,
+ sort: ['-published_at'],
+ limit,
+ offset,
+ })
+ ) as Promise
+}
+
+export async function getArticleBySlug(slug: string): Promise {
+ const results = await directus.request(
+ readItems('articles', {
+ fields: [
+ 'id', 'title', 'slug', 'status', 'content', 'excerpt',
+ 'featured_image', 'published_at', 'is_featured',
+ 'seo_title', 'seo_description',
+ 'category.id', 'category.name', 'category.slug',
+ 'tags.tags_id.id', 'tags.tags_id.name', 'tags.tags_id.slug',
+ ],
+ filter: { slug: { _eq: slug }, status: { _eq: 'published' } },
+ limit: 1,
+ })
+ ) as Article[]
+ return results[0] ?? null
+}
+
+export async function getArticlePathById(id: string): Promise<{
+ slug: string
+ category: { slug: string }
+} | null> {
+ try {
+ return await directus.request(
+ readItem('articles', id, {
+ fields: ['slug', 'category.slug'],
+ })
+ ) as { slug: string; category: { slug: string } }
+ } catch {
+ return null
+ }
+}
+
+export async function getRelatedArticles(
+ categorySlug: string,
+ excludeSlug: string,
+): Promise {
+ return directus.request(
+ readItems('articles', {
+ fields: [
+ 'id', 'title', 'slug', 'excerpt', 'featured_image',
+ 'published_at', 'category.id', 'category.name', 'category.slug',
+ ],
+ filter: {
+ status: { _eq: 'published' },
+ category: { slug: { _eq: categorySlug } },
+ slug: { _neq: excludeSlug },
+ },
+ sort: ['-published_at'],
+ limit: 4,
+ })
+ ) as Promise
+}
+
+export async function getSiteSettings(): Promise {
+ return directus.request(
+ readSingleton('site_settings', {
+ fields: [
+ 'id', 'site_name',
+ 'hero_article.id', 'hero_article.title', 'hero_article.slug',
+ 'hero_article.excerpt', 'hero_article.featured_image',
+ 'hero_article.category.slug', 'hero_article.category.name',
+ 'nav_categories.id', 'nav_categories.name', 'nav_categories.slug',
+ ],
+ })
+ ) as Promise
+}
+
+export async function searchArticles(query: string): Promise {
+ return directus.request(
+ readItems('articles', {
+ fields: ['id', 'title', 'slug', 'category.slug', 'category.name'],
+ search: query,
+ filter: { status: { _eq: 'published' } },
+ limit: 8,
+ })
+ ) as Promise
+}
+
+export function getAssetUrl(
+ fileId: string,
+ params?: { width?: number; height?: number; quality?: number },
+): string {
+ const url = new URL(`/assets/${fileId}`, process.env.DIRECTUS_URL!)
+ if (params?.width) url.searchParams.set('width', String(params.width))
+ if (params?.height) url.searchParams.set('height', String(params.height))
+ if (params?.quality) url.searchParams.set('quality', String(params.quality))
+ return url.toString()
+}