diff --git a/packages/cli/src/__tests__/validate.test.ts b/packages/cli/src/__tests__/validate.test.ts
new file mode 100644
index 0000000..c0d2164
--- /dev/null
+++ b/packages/cli/src/__tests__/validate.test.ts
@@ -0,0 +1,513 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+/* eslint-disable @typescript-eslint/no-unsafe-assignment */
+/* eslint-disable @typescript-eslint/no-unsafe-return */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { validateCommand } from '../commands/validate.js';
+import * as core from '@md2wp/core';
+
+// Mock console methods to suppress output during tests
+vi.spyOn(console, 'log').mockImplementation(() => {});
+vi.spyOn(console, 'error').mockImplementation(() => {});
+
+// Mock process.exit to prevent actual exit and allow testing
+const mockExit = vi
+ .spyOn(process, 'exit')
+ .mockImplementation((code?: number | string | null | undefined) => {
+ throw new Error(`EXIT_${code}`);
+ });
+
+// Mock core functions
+vi.mock('@md2wp/core', async () => {
+ const actual = await vi.importActual('@md2wp/core');
+ return {
+ ...actual,
+ parseMarkdownFile: vi.fn(),
+ extractImages: vi.fn(),
+ processImagesForDryRun: vi.fn(),
+ transformToGutenberg: vi.fn(),
+ ImageCacheManager: vi.fn(),
+ };
+});
+
+describe('validateCommand', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+
+ // Setup default ImageCacheManager mock
+ vi.mocked(core.ImageCacheManager).mockImplementation(
+ () =>
+ ({
+ load: vi.fn().mockResolvedValue(undefined),
+ save: vi.fn().mockResolvedValue(undefined),
+ get: vi.fn(),
+ set: vi.fn(),
+ has: vi.fn(),
+ delete: vi.fn(),
+ clear: vi.fn(),
+ }) as any,
+ );
+ });
+
+ describe('successful validation', () => {
+ it('should pass validation for valid markdown with minimal frontmatter', async () => {
+ vi.mocked(core.parseMarkdownFile).mockResolvedValue({
+ frontmatter: {
+ title: 'Test Post',
+ },
+ content: '# Hello World',
+ images: [],
+ });
+
+ vi.mocked(core.extractImages).mockReturnValue([]);
+ vi.mocked(core.transformToGutenberg).mockReturnValue(
+ '
Hello World
',
+ );
+
+ await expect(validateCommand('test.md')).rejects.toThrow('EXIT_0');
+ expect(mockExit).toHaveBeenCalledWith(0);
+ });
+
+ it('should pass validation for markdown with full frontmatter', async () => {
+ vi.mocked(core.parseMarkdownFile).mockResolvedValue({
+ frontmatter: {
+ title: 'Complete Post',
+ status: 'publish',
+ slug: 'complete-post',
+ excerpt: 'This is an excerpt',
+ tags: ['tag1', 'tag2'],
+ categories: ['cat1', 'cat2'],
+ date: '2024-01-15T10:30:00Z',
+ wp_post_id: 456,
+ wp_url: 'https://example.com/complete-post',
+ },
+ content: 'Content',
+ images: [],
+ });
+
+ vi.mocked(core.extractImages).mockReturnValue([]);
+ vi.mocked(core.transformToGutenberg).mockReturnValue(
+ 'Content',
+ );
+
+ await expect(validateCommand('test.md')).rejects.toThrow('EXIT_0');
+ expect(mockExit).toHaveBeenCalledWith(0);
+ });
+
+ it('should pass validation with existing images', async () => {
+ vi.mocked(core.parseMarkdownFile).mockResolvedValue({
+ frontmatter: {
+ title: 'Post with Images',
+ },
+ content: '',
+ images: [],
+ });
+
+ vi.mocked(core.extractImages).mockReturnValue([
+ { path: './image.png', alt: 'Alt' },
+ ]);
+
+ vi.mocked(core.processImagesForDryRun).mockResolvedValue([
+ {
+ path: './image.png',
+ absolutePath: '/abs/path/image.png',
+ alt: 'Alt',
+ validation: {
+ exists: true,
+ absolutePath: '/abs/path/image.png',
+ size: 1024,
+ sizeFormatted: '1.0 KB',
+ errors: [],
+ warnings: [],
+ },
+ cacheHit: false,
+ },
+ ]);
+
+ vi.mocked(core.transformToGutenberg).mockReturnValue('');
+
+ await expect(validateCommand('test.md')).rejects.toThrow('EXIT_0');
+ expect(mockExit).toHaveBeenCalledWith(0);
+ });
+
+ it('should pass validation with cached images', async () => {
+ vi.mocked(core.parseMarkdownFile).mockResolvedValue({
+ frontmatter: {
+ title: 'Post with Cached Image',
+ },
+ content: '',
+ images: [],
+ });
+
+ vi.mocked(core.extractImages).mockReturnValue([
+ { path: './cached.png', alt: 'Cached' },
+ ]);
+
+ vi.mocked(core.processImagesForDryRun).mockResolvedValue([
+ {
+ path: './cached.png',
+ absolutePath: '/abs/path/cached.png',
+ alt: 'Cached',
+ validation: {
+ exists: true,
+ absolutePath: '/abs/path/cached.png',
+ size: 2048,
+ sizeFormatted: '2.0 KB',
+ errors: [],
+ warnings: [],
+ },
+ cacheHit: true,
+ cachedMediaId: 123,
+ },
+ ]);
+
+ vi.mocked(core.transformToGutenberg).mockReturnValue('');
+
+ await expect(
+ validateCommand('test.md', { verbose: true }),
+ ).rejects.toThrow('EXIT_0');
+ expect(mockExit).toHaveBeenCalledWith(0);
+ });
+ });
+
+ describe('frontmatter validation errors', () => {
+ it('should fail when title is missing', async () => {
+ vi.mocked(core.parseMarkdownFile).mockResolvedValue({
+ frontmatter: {
+ status: 'draft',
+ } as any,
+ content: 'Content',
+ images: [],
+ });
+
+ await expect(validateCommand('test.md')).rejects.toThrow('EXIT_1');
+ expect(mockExit).toHaveBeenCalledWith(1);
+ });
+
+ it('should fail when title is not a string', async () => {
+ vi.mocked(core.parseMarkdownFile).mockResolvedValue({
+ frontmatter: {
+ title: 123 as any,
+ },
+ content: 'Content',
+ images: [],
+ });
+
+ await expect(validateCommand('test.md')).rejects.toThrow('EXIT_1');
+ expect(mockExit).toHaveBeenCalledWith(1);
+ });
+
+ it('should fail when status is invalid', async () => {
+ vi.mocked(core.parseMarkdownFile).mockResolvedValue({
+ frontmatter: {
+ title: 'Test',
+ status: 'invalid' as any,
+ },
+ content: 'Content',
+ images: [],
+ });
+
+ await expect(validateCommand('test.md')).rejects.toThrow('EXIT_1');
+ expect(mockExit).toHaveBeenCalledWith(1);
+ });
+
+ it('should fail when slug is not a string', async () => {
+ vi.mocked(core.parseMarkdownFile).mockResolvedValue({
+ frontmatter: {
+ title: 'Test',
+ slug: 123 as any,
+ },
+ content: 'Content',
+ images: [],
+ });
+
+ await expect(validateCommand('test.md')).rejects.toThrow('EXIT_1');
+ expect(mockExit).toHaveBeenCalledWith(1);
+ });
+
+ it('should fail when excerpt is not a string', async () => {
+ vi.mocked(core.parseMarkdownFile).mockResolvedValue({
+ frontmatter: {
+ title: 'Test',
+ excerpt: 123 as any,
+ },
+ content: 'Content',
+ images: [],
+ });
+
+ await expect(validateCommand('test.md')).rejects.toThrow('EXIT_1');
+ expect(mockExit).toHaveBeenCalledWith(1);
+ });
+
+ it('should fail when tags is not an array', async () => {
+ vi.mocked(core.parseMarkdownFile).mockResolvedValue({
+ frontmatter: {
+ title: 'Test',
+ tags: 'tag1,tag2' as any,
+ },
+ content: 'Content',
+ images: [],
+ });
+
+ await expect(validateCommand('test.md')).rejects.toThrow('EXIT_1');
+ expect(mockExit).toHaveBeenCalledWith(1);
+ });
+
+ it('should fail when tags contains non-strings', async () => {
+ vi.mocked(core.parseMarkdownFile).mockResolvedValue({
+ frontmatter: {
+ title: 'Test',
+ tags: ['tag1', 123] as any,
+ },
+ content: 'Content',
+ images: [],
+ });
+
+ await expect(validateCommand('test.md')).rejects.toThrow('EXIT_1');
+ expect(mockExit).toHaveBeenCalledWith(1);
+ });
+
+ it('should fail when categories is not an array', async () => {
+ vi.mocked(core.parseMarkdownFile).mockResolvedValue({
+ frontmatter: {
+ title: 'Test',
+ categories: 'cat1,cat2' as any,
+ },
+ content: 'Content',
+ images: [],
+ });
+
+ await expect(validateCommand('test.md')).rejects.toThrow('EXIT_1');
+ expect(mockExit).toHaveBeenCalledWith(1);
+ });
+
+ it('should fail when categories contains non-strings', async () => {
+ vi.mocked(core.parseMarkdownFile).mockResolvedValue({
+ frontmatter: {
+ title: 'Test',
+ categories: ['cat1', 123] as any,
+ },
+ content: 'Content',
+ images: [],
+ });
+
+ await expect(validateCommand('test.md')).rejects.toThrow('EXIT_1');
+ expect(mockExit).toHaveBeenCalledWith(1);
+ });
+
+ it('should fail when date is not a string', async () => {
+ vi.mocked(core.parseMarkdownFile).mockResolvedValue({
+ frontmatter: {
+ title: 'Test',
+ date: 123 as any,
+ },
+ content: 'Content',
+ images: [],
+ });
+
+ await expect(validateCommand('test.md')).rejects.toThrow('EXIT_1');
+ expect(mockExit).toHaveBeenCalledWith(1);
+ });
+
+ it('should fail when date is invalid ISO format', async () => {
+ vi.mocked(core.parseMarkdownFile).mockResolvedValue({
+ frontmatter: {
+ title: 'Test',
+ date: 'not-a-date',
+ },
+ content: 'Content',
+ images: [],
+ });
+
+ await expect(validateCommand('test.md')).rejects.toThrow('EXIT_1');
+ expect(mockExit).toHaveBeenCalledWith(1);
+ });
+
+ it('should fail when wp_post_id is not a number', async () => {
+ vi.mocked(core.parseMarkdownFile).mockResolvedValue({
+ frontmatter: {
+ title: 'Test',
+ wp_post_id: '123' as any,
+ },
+ content: 'Content',
+ images: [],
+ });
+
+ await expect(validateCommand('test.md')).rejects.toThrow('EXIT_1');
+ expect(mockExit).toHaveBeenCalledWith(1);
+ });
+
+ it('should fail when wp_url is not a string', async () => {
+ vi.mocked(core.parseMarkdownFile).mockResolvedValue({
+ frontmatter: {
+ title: 'Test',
+ wp_url: 123 as any,
+ },
+ content: 'Content',
+ images: [],
+ });
+
+ await expect(validateCommand('test.md')).rejects.toThrow('EXIT_1');
+ expect(mockExit).toHaveBeenCalledWith(1);
+ });
+ });
+
+ describe('image validation errors', () => {
+ it('should fail when images are missing', async () => {
+ vi.mocked(core.parseMarkdownFile).mockResolvedValue({
+ frontmatter: {
+ title: 'Test',
+ },
+ content: '',
+ images: [],
+ });
+
+ vi.mocked(core.extractImages).mockReturnValue([
+ { path: './missing.png', alt: 'Missing' },
+ ]);
+
+ vi.mocked(core.processImagesForDryRun).mockResolvedValue([
+ {
+ path: './missing.png',
+ absolutePath: '/abs/path/missing.png',
+ alt: 'Missing',
+ validation: {
+ exists: false,
+ absolutePath: '/abs/path/missing.png',
+ errors: ['File not found'],
+ warnings: [],
+ },
+ cacheHit: false,
+ },
+ ]);
+
+ await expect(validateCommand('test.md')).rejects.toThrow('EXIT_1');
+ expect(mockExit).toHaveBeenCalledWith(1);
+ });
+
+ it('should fail when multiple images are missing', async () => {
+ vi.mocked(core.parseMarkdownFile).mockResolvedValue({
+ frontmatter: {
+ title: 'Test',
+ },
+ content: ' ',
+ images: [],
+ });
+
+ vi.mocked(core.extractImages).mockReturnValue([
+ { path: './missing1.png', alt: 'Missing1' },
+ { path: './missing2.png', alt: 'Missing2' },
+ ]);
+
+ vi.mocked(core.processImagesForDryRun).mockResolvedValue([
+ {
+ path: './missing1.png',
+ absolutePath: '/abs/path/missing1.png',
+ alt: 'Missing1',
+ validation: {
+ exists: false,
+ absolutePath: '/abs/path/missing1.png',
+ errors: ['File not found'],
+ warnings: [],
+ },
+ cacheHit: false,
+ },
+ {
+ path: './missing2.png',
+ absolutePath: '/abs/path/missing2.png',
+ alt: 'Missing2',
+ validation: {
+ exists: false,
+ absolutePath: '/abs/path/missing2.png',
+ errors: ['File not found'],
+ warnings: [],
+ },
+ cacheHit: false,
+ },
+ ]);
+
+ await expect(validateCommand('test.md')).rejects.toThrow('EXIT_1');
+ expect(mockExit).toHaveBeenCalledWith(1);
+ });
+ });
+
+ describe('valid date formats', () => {
+ it('should accept ISO 8601 date with time and timezone', async () => {
+ vi.mocked(core.parseMarkdownFile).mockResolvedValue({
+ frontmatter: {
+ title: 'Test',
+ date: '2024-01-15T10:30:00Z',
+ },
+ content: 'Content',
+ images: [],
+ });
+
+ vi.mocked(core.extractImages).mockReturnValue([]);
+ vi.mocked(core.transformToGutenberg).mockReturnValue(
+ '',
+ );
+
+ await expect(validateCommand('test.md')).rejects.toThrow('EXIT_0');
+ expect(mockExit).toHaveBeenCalledWith(0);
+ });
+
+ it('should accept ISO 8601 date without time', async () => {
+ vi.mocked(core.parseMarkdownFile).mockResolvedValue({
+ frontmatter: {
+ title: 'Test',
+ date: '2024-01-15',
+ },
+ content: 'Content',
+ images: [],
+ });
+
+ vi.mocked(core.extractImages).mockReturnValue([]);
+ vi.mocked(core.transformToGutenberg).mockReturnValue(
+ '',
+ );
+
+ await expect(validateCommand('test.md')).rejects.toThrow('EXIT_0');
+ expect(mockExit).toHaveBeenCalledWith(0);
+ });
+ });
+
+ describe('status field validation', () => {
+ it('should accept status "draft"', async () => {
+ vi.mocked(core.parseMarkdownFile).mockResolvedValue({
+ frontmatter: {
+ title: 'Test',
+ status: 'draft',
+ },
+ content: 'Content',
+ images: [],
+ });
+
+ vi.mocked(core.extractImages).mockReturnValue([]);
+ vi.mocked(core.transformToGutenberg).mockReturnValue(
+ '',
+ );
+
+ await expect(validateCommand('test.md')).rejects.toThrow('EXIT_0');
+ expect(mockExit).toHaveBeenCalledWith(0);
+ });
+
+ it('should accept status "publish"', async () => {
+ vi.mocked(core.parseMarkdownFile).mockResolvedValue({
+ frontmatter: {
+ title: 'Test',
+ status: 'publish',
+ },
+ content: 'Content',
+ images: [],
+ });
+
+ vi.mocked(core.extractImages).mockReturnValue([]);
+ vi.mocked(core.transformToGutenberg).mockReturnValue(
+ '',
+ );
+
+ await expect(validateCommand('test.md')).rejects.toThrow('EXIT_0');
+ expect(mockExit).toHaveBeenCalledWith(0);
+ });
+ });
+});
diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts
index c9b959e..007543e 100644
--- a/packages/cli/src/cli.ts
+++ b/packages/cli/src/cli.ts
@@ -8,6 +8,7 @@ import { Command } from 'commander';
import { version } from './index.js';
import { initCommand } from './commands/init.js';
import { publishCommand } from './commands/publish.js';
+import { validateCommand } from './commands/validate.js';
const program = new Command();
@@ -46,9 +47,15 @@ program
program
.command('validate ')
- .description('Validate frontmatter and config')
- .action((file: string) => {
- console.log('TODO: Implement validate command', { file });
+ .description('Validate markdown file before publishing')
+ .option('--verbose', 'Show detailed validation output')
+ .action(async (file: string, options: { verbose?: boolean }) => {
+ try {
+ await validateCommand(file, options);
+ } catch (error) {
+ console.error('Error:', (error as Error).message);
+ process.exit(1);
+ }
});
program
diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts
new file mode 100644
index 0000000..b2c33c6
--- /dev/null
+++ b/packages/cli/src/commands/validate.ts
@@ -0,0 +1,280 @@
+/**
+ * Validate command - Validate markdown file without publishing
+ */
+
+import { resolve } from 'path';
+import {
+ parseMarkdownFile,
+ extractImages,
+ processImagesForDryRun,
+ transformToGutenberg,
+ ImageCacheManager,
+ formatBytes,
+ type ImageMap,
+ type Frontmatter,
+} from '@md2wp/core';
+
+interface ValidateOptions {
+ verbose?: boolean;
+}
+
+/**
+ * Validate a markdown file
+ */
+export async function validateCommand(
+ file: string,
+ options: ValidateOptions = {},
+): Promise {
+ let success = false;
+
+ try {
+ console.log('š Validating markdown file...\n');
+
+ // Resolve file path
+ const absolutePath = resolve(process.cwd(), file);
+
+ // Parse markdown file
+ console.log('š Parsing markdown...');
+ const parsed = await parseMarkdownFile(absolutePath);
+ console.log('ā
Markdown parsed successfully\n');
+
+ // Validate frontmatter
+ console.log('š Validating frontmatter...');
+ validateFrontmatter(parsed.frontmatter);
+ console.log('ā
Frontmatter is valid\n');
+
+ // Show frontmatter summary
+ if (options.verbose) {
+ console.log('š Frontmatter:');
+ console.log(JSON.stringify(parsed.frontmatter, null, 2));
+ console.log('');
+ } else {
+ console.log('š Frontmatter:');
+ console.log(` Title: ${parsed.frontmatter.title}`);
+ console.log(` Status: ${parsed.frontmatter.status || 'draft'}`);
+ if (parsed.frontmatter.slug) {
+ console.log(` Slug: ${parsed.frontmatter.slug}`);
+ }
+ if (parsed.frontmatter.excerpt) {
+ console.log(
+ ` Excerpt: ${parsed.frontmatter.excerpt.substring(0, 50)}${parsed.frontmatter.excerpt.length > 50 ? '...' : ''}`,
+ );
+ }
+ if (parsed.frontmatter.tags && parsed.frontmatter.tags.length > 0) {
+ console.log(` Tags: ${parsed.frontmatter.tags.join(', ')}`);
+ }
+ if (
+ parsed.frontmatter.categories &&
+ parsed.frontmatter.categories.length > 0
+ ) {
+ console.log(
+ ` Categories: ${parsed.frontmatter.categories.join(', ')}`,
+ );
+ }
+ console.log('');
+ }
+
+ // Extract and validate images
+ const images = extractImages(parsed.content);
+
+ if (images.length > 0) {
+ console.log(`šø Validating ${images.length} image(s)...`);
+
+ // Load cache
+ const cache = new ImageCacheManager();
+ await cache.load();
+
+ // Process images for validation
+ const processed = await processImagesForDryRun(
+ images,
+ absolutePath,
+ cache,
+ );
+
+ // Check for missing images
+ const missingImages = processed.filter((img) => !img.validation.exists);
+ const existingImages = processed.filter((img) => img.validation.exists);
+ const cachedImages = processed.filter((img) => img.cacheHit);
+
+ if (missingImages.length > 0) {
+ console.error('\nā Missing images:');
+ missingImages.forEach((img) => {
+ console.error(` ⢠${img.path}`);
+ console.error(` ${img.absolutePath}`);
+ });
+ console.error('');
+ throw new Error(
+ `Validation failed: ${missingImages.length} image(s) not found`,
+ );
+ }
+
+ // Show image summary
+ console.log('ā
All images exist\n');
+
+ if (options.verbose) {
+ console.log('šø Image details:');
+ existingImages.forEach((img) => {
+ console.log(` ⢠${img.path}`);
+ console.log(` Path: ${img.absolutePath}`);
+ if (img.validation.sizeFormatted) {
+ console.log(` Size: ${img.validation.sizeFormatted}`);
+ }
+ if (img.cacheHit) {
+ console.log(
+ ` Cache: ā
Cached (Media ID: ${img.cachedMediaId})`,
+ );
+ } else {
+ console.log(' Cache: ā ļø Will be uploaded');
+ }
+ if (img.alt) {
+ console.log(` Alt: ${img.alt}`);
+ }
+ });
+ console.log('');
+ } else {
+ console.log('šø Image summary:');
+ console.log(` Total: ${images.length}`);
+ console.log(` Cached: ${cachedImages.length}`);
+ console.log(
+ ` To upload: ${existingImages.length - cachedImages.length}`,
+ );
+
+ // Calculate total size of images to upload
+ const toUploadSize = existingImages
+ .filter((img) => !img.cacheHit)
+ .reduce((sum, img) => sum + (img.validation.size || 0), 0);
+
+ if (toUploadSize > 0) {
+ console.log(` Upload size: ${formatBytes(toUploadSize)}`);
+ }
+ console.log('');
+ }
+ } else {
+ console.log('šø No images found\n');
+ }
+
+ // Generate Gutenberg preview
+ console.log('šØ Generating Gutenberg blocks...');
+ const imageMap: ImageMap = {}; // Empty map for validation
+ const gutenberg = transformToGutenberg(parsed.content, imageMap);
+ console.log('ā
Gutenberg blocks generated\n');
+
+ if (options.verbose) {
+ console.log('šØ Gutenberg preview:');
+ console.log('ā'.repeat(60));
+ console.log(gutenberg);
+ console.log('ā'.repeat(60));
+ console.log('');
+ } else {
+ const blockCount = (gutenberg.match(/