diff --git a/src/content.config.ts b/src/content.config.ts new file mode 100644 index 0000000..fa7b20a --- /dev/null +++ b/src/content.config.ts @@ -0,0 +1,18 @@ +import { defineCollection, z } from 'astro:content'; +import { glob } from 'astro/loaders'; + +const posts = defineCollection({ + loader: glob({ + pattern: '**/*.{md,mdx}', + base: './src/content/posts', + }), + schema: z.object({ + title: z.string(), + date: z.coerce.date(), + type: z.enum(['post', 'pattern', 'lesson']).default('post'), + tags: z.array(z.string()).default([]), + summary: z.string().optional(), + }), +}); + +export const collections = { posts }; diff --git a/src/content/posts/.gitkeep b/src/content/posts/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/utils/reading-time.ts b/src/utils/reading-time.ts new file mode 100644 index 0000000..e266299 --- /dev/null +++ b/src/utils/reading-time.ts @@ -0,0 +1,4 @@ +export function getReadingTime(text: string): number { + const words = text.split(/\s+/).filter(Boolean); + return Math.max(1, Math.ceil(words.length / 200)); +} diff --git a/tests/content-config.test.mjs b/tests/content-config.test.mjs new file mode 100644 index 0000000..3d2e275 --- /dev/null +++ b/tests/content-config.test.mjs @@ -0,0 +1,95 @@ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { readFileSync, existsSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const configPath = resolve('src/content.config.ts'); +const postsDir = resolve('src/content/posts'); + +function readConfig() { + return readFileSync(configPath, 'utf-8'); +} + +describe('Content collection config', () => { + it('should exist at src/content.config.ts', () => { + const content = readConfig(); + assert.ok(content.length > 0, 'Config file should not be empty'); + }); + + it('should import defineCollection from astro:content', () => { + const content = readConfig(); + assert.match(content, /import\s+.*defineCollection.*from\s+['"]astro:content['"]/, 'should import defineCollection'); + }); + + it('should import glob from astro/loaders', () => { + const content = readConfig(); + assert.match(content, /import\s+.*glob.*from\s+['"]astro\/loaders['"]/, 'should import glob loader'); + }); + + it('should import z from astro:content', () => { + const content = readConfig(); + assert.match(content, /import\s+.*z[,\s}].*from\s+['"]astro:content['"]/, 'should import z from astro:content'); + }); + + it('should use glob loader with md/mdx pattern', () => { + const content = readConfig(); + assert.match(content, /glob\s*\(/, 'should call glob()'); + assert.match(content, /\*\*\/\*\.\{md,mdx\}/, 'should use **/*.{md,mdx} pattern'); + }); + + it('should use ./src/content/posts as base', () => { + const content = readConfig(); + assert.match(content, /\.\/src\/content\/posts/, 'should use ./src/content/posts as base'); + }); + + describe('Schema fields', () => { + it('should define title as z.string()', () => { + const content = readConfig(); + assert.match(content, /title\s*:\s*z\.string\(\)/, 'should have title: z.string()'); + }); + + it('should define date as z.coerce.date()', () => { + const content = readConfig(); + assert.match(content, /date\s*:\s*z\.coerce\.date\(\)/, 'should have date: z.coerce.date()'); + }); + + it('should define type as z.enum with post, pattern, lesson and default post', () => { + const content = readConfig(); + assert.match(content, /type\s*:\s*z\.enum\s*\(\s*\[\s*['"]post['"]/, 'should have type: z.enum starting with post'); + assert.match(content, /['"]pattern['"]/, 'should include pattern in enum'); + assert.match(content, /['"]lesson['"]/, 'should include lesson in enum'); + assert.match(content, /\.default\s*\(\s*['"]post['"]\s*\)/, 'should default to post'); + }); + + it('should define tags as z.array(z.string()).default([])', () => { + const content = readConfig(); + assert.match(content, /tags\s*:\s*z\.array\s*\(\s*z\.string\(\)\s*\)\.default\s*\(\s*\[\s*\]\s*\)/, 'should have tags: z.array(z.string()).default([])'); + }); + + it('should define summary as z.string().optional()', () => { + const content = readConfig(); + assert.match(content, /summary\s*:\s*z\.string\(\)\.optional\(\)/, 'should have summary: z.string().optional()'); + }); + }); + + it('should use z.object for schema', () => { + const content = readConfig(); + assert.match(content, /z\.object\s*\(/, 'should use z.object()'); + }); + + it('should use defineCollection', () => { + const content = readConfig(); + assert.match(content, /defineCollection\s*\(/, 'should call defineCollection()'); + }); + + it('should export collections with posts', () => { + const content = readConfig(); + assert.match(content, /export\s+const\s+collections\s*=\s*\{\s*posts\s*\}/, 'should export collections = { posts }'); + }); +}); + +describe('Content posts directory', () => { + it('should exist at src/content/posts/', () => { + assert.ok(existsSync(postsDir), 'src/content/posts/ directory should exist'); + }); +}); diff --git a/tests/reading-time.test.mjs b/tests/reading-time.test.mjs new file mode 100644 index 0000000..9091b6f --- /dev/null +++ b/tests/reading-time.test.mjs @@ -0,0 +1,53 @@ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const utilPath = resolve('src/utils/reading-time.ts'); + +function readUtil() { + return readFileSync(utilPath, 'utf-8'); +} + +describe('reading-time utility', () => { + it('should exist at src/utils/reading-time.ts', () => { + const content = readUtil(); + assert.ok(content.length > 0, 'Utility file should not be empty'); + }); + + it('should export a getReadingTime function', () => { + const content = readUtil(); + assert.match(content, /export\s+(function\s+getReadingTime|const\s+getReadingTime|{[^}]*getReadingTime)/, 'should export getReadingTime'); + }); + + it('should accept a text: string parameter', () => { + const content = readUtil(); + assert.match(content, /getReadingTime\s*\(\s*text\s*:\s*string\s*\)/, 'should accept text: string param'); + }); + + it('should return a number type', () => { + const content = readUtil(); + assert.match(content, /:\s*number/, 'should have number return type'); + }); + + it('should split on whitespace', () => { + const content = readUtil(); + assert.match(content, /split\s*\(\s*\/\\s\+\//, 'should split on whitespace regex'); + }); + + it('should divide by 200 WPM', () => { + const content = readUtil(); + assert.match(content, /200/, 'should use 200 WPM'); + }); + + it('should use Math.max(1, Math.ceil(...))', () => { + const content = readUtil(); + assert.match(content, /Math\.max\s*\(\s*1/, 'should use Math.max(1, ...)'); + assert.match(content, /Math\.ceil/, 'should use Math.ceil'); + }); + + it('should filter empty strings from split result', () => { + const content = readUtil(); + assert.match(content, /filter/, 'should filter empty strings from split'); + }); +});