feat: add content collection with schema and reading-time utility

- Created src/content.config.ts with Astro defineCollection using glob loader, pattern '**/*.{md,mdx}', base './src/content/posts', and Zod schema with 5 fields (title, date, type, tags, summary)
- Created src/content/posts/ directory for content storage
- Created src/utils/reading-time.ts with getReadingTime function (split whitespace, 200 WPM, Math.max(1, Math.ceil))
- All 23 new tests pass, all 61 total tests pass, npm run build succeeds

Generated with Amplifier

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
This commit is contained in:
Ken
2026-05-26 23:03:44 +00:00
parent 25fd0ea9fe
commit 2597afd472
5 changed files with 170 additions and 0 deletions
+95
View File
@@ -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');
});
});
+53
View File
@@ -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');
});
});