UNPKG

@bernierllc/content-type-blog-post

Version:

Blog post content type with rich TipTap editor, SEO metadata, database storage, and web publishing

565 lines (496 loc) 16.5 kB
/* Copyright (c) 2025 Bernier LLC This file is licensed to the client under a limited-use license. The client may use and modify this code *only within the scope of the project it was delivered for*. Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC. */ import { z } from 'zod'; import { BlogPostMetadata, BlogPostMetadataSchema, BlogPostResult, BlogPostStatus, TipTapEditorConfig, DatabaseStorageConfig, WebPublishingConfig, BlogPostContentTypeConfig, } from './types'; import { calculateWordCount, calculateReadingTime } from './calculations'; import { validateSEOCompleteness } from './seo'; /** * Blog post content type with rich TipTap editor, SEO metadata, database storage, and web publishing */ export class BlogPostContentType { // eslint-disable-next-line @typescript-eslint/no-explicit-any private dbClient: any; // PostgreSQL/Supabase client private tableName: string = 'blog_posts'; public config: Required<BlogPostContentTypeConfig>; constructor(config?: { // eslint-disable-next-line @typescript-eslint/no-explicit-any dbClient?: any; tableName?: string; baseUrl?: string; }) { // Store blog-specific configuration this.dbClient = config?.dbClient; this.tableName = config?.tableName || 'blog_posts'; this.config = { id: 'blog-post', name: 'Blog Post', editor: { type: 'tiptap-wysiwyg', extensions: [ 'starter-kit', 'link', 'image', 'code-block-lowlight', 'table', 'table-row', 'table-cell', 'table-header', ], placeholder: 'Write your blog post content...', autofocus: true, } as TipTapEditorConfig, metadata: BlogPostMetadataSchema as z.ZodType<BlogPostMetadata>, storage: { type: 'database', table: this.tableName, } as DatabaseStorageConfig, publishing: { type: 'web', urlPattern: '/blog/{slug}', baseUrl: config?.baseUrl || '', } as WebPublishingConfig, }; } /** * Initialize database schema */ async initializeDatabase(): Promise<BlogPostResult<void>> { if (!this.dbClient) { return { success: false, error: 'Database client not provided' }; } try { // Create blog_posts table if it doesn't exist await this.dbClient.query(` CREATE TABLE IF NOT EXISTS ${this.tableName} ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), title VARCHAR(200) NOT NULL, slug VARCHAR(200) NOT NULL UNIQUE, content TEXT NOT NULL, excerpt VARCHAR(300), -- SEO metadata meta_title VARCHAR(60), meta_description VARCHAR(160), keywords TEXT[], og_image TEXT, canonical_url TEXT, -- Author information author_name VARCHAR(100) NOT NULL, author_email VARCHAR(100), author_avatar TEXT, author_bio TEXT, -- Taxonomy tags TEXT[] DEFAULT ARRAY[]::TEXT[], categories TEXT[] DEFAULT ARRAY[]::TEXT[], -- Publishing workflow status VARCHAR(20) NOT NULL DEFAULT 'draft', published_at TIMESTAMPTZ, scheduled_for TIMESTAMPTZ, -- Additional metadata featured_image TEXT, reading_time INTEGER, word_count INTEGER DEFAULT 0, -- Timestamps created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -- Create indexes CREATE INDEX IF NOT EXISTS idx_blog_posts_slug ON ${this.tableName}(slug); CREATE INDEX IF NOT EXISTS idx_blog_posts_status ON ${this.tableName}(status); CREATE INDEX IF NOT EXISTS idx_blog_posts_published_at ON ${this.tableName}(published_at); CREATE INDEX IF NOT EXISTS idx_blog_posts_tags ON ${this.tableName} USING GIN(tags); CREATE INDEX IF NOT EXISTS idx_blog_posts_categories ON ${this.tableName} USING GIN(categories); `); return { success: true }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Database initialization failed', }; } } /** * Create new blog post */ async create(data: BlogPostMetadata): Promise<BlogPostResult<string>> { if (!this.dbClient) { return { success: false, error: 'Database client not configured' }; } try { // Validate metadata const validated = BlogPostMetadataSchema.parse(data); // Calculate word count and reading time if not provided const wordCount = validated.wordCount || calculateWordCount(validated.content); const readingTime = validated.readingTime || calculateReadingTime(wordCount); // Ensure slug is unique const uniqueSlug = await this.ensureUniqueSlug(validated.slug); // Insert into database const result = await this.dbClient.query( ` INSERT INTO ${this.tableName} ( title, slug, content, excerpt, meta_title, meta_description, keywords, og_image, canonical_url, author_name, author_email, author_avatar, author_bio, tags, categories, status, published_at, scheduled_for, featured_image, reading_time, word_count ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21 ) RETURNING id `, [ validated.title, uniqueSlug, validated.content, validated.excerpt, validated.seo.metaTitle, validated.seo.metaDescription, validated.seo.keywords, validated.seo.ogImage, validated.seo.canonicalUrl, validated.author.name, validated.author.email, validated.author.avatar, validated.author.bio, validated.tags, validated.categories, validated.status, validated.publishedAt, validated.scheduledFor, validated.featuredImage, readingTime, wordCount, ] ); const postId = result.rows[0].id; return { success: true, data: postId }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Blog post creation failed', }; } } /** * Read blog post by ID */ async read(id: string): Promise<BlogPostResult<BlogPostMetadata>> { if (!this.dbClient) { return { success: false, error: 'Database client not configured' }; } try { const result = await this.dbClient.query(`SELECT * FROM ${this.tableName} WHERE id = $1`, [ id, ]); if (result.rows.length === 0) { return { success: false, error: 'Blog post not found' }; } const row = result.rows[0]; const metadata: BlogPostMetadata = this.rowToMetadata(row); return { success: true, data: metadata }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Blog post read failed', }; } } /** * Read blog post by slug */ async readBySlug(slug: string): Promise<BlogPostResult<BlogPostMetadata>> { if (!this.dbClient) { return { success: false, error: 'Database client not configured' }; } try { const result = await this.dbClient.query(`SELECT * FROM ${this.tableName} WHERE slug = $1`, [ slug, ]); if (result.rows.length === 0) { return { success: false, error: 'Blog post not found' }; } const row = result.rows[0]; const metadata: BlogPostMetadata = this.rowToMetadata(row); return { success: true, data: metadata }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Blog post read failed', }; } } /** * Update blog post */ async update(id: string, data: Partial<BlogPostMetadata>): Promise<BlogPostResult<void>> { if (!this.dbClient) { return { success: false, error: 'Database client not configured' }; } try { // Read current post const readResult = await this.read(id); if (!readResult.success || !readResult.data) { return { success: false, error: 'Blog post not found' }; } // Merge updates const updated: BlogPostMetadata = { ...readResult.data, ...data, updatedAt: new Date(), }; // Recalculate word count and reading time if content changed if (data.content) { updated.wordCount = calculateWordCount(data.content); updated.readingTime = calculateReadingTime(updated.wordCount); } // Validate const validated = BlogPostMetadataSchema.parse(updated); // Update database await this.dbClient.query( ` UPDATE ${this.tableName} SET title = $1, slug = $2, content = $3, excerpt = $4, meta_title = $5, meta_description = $6, keywords = $7, og_image = $8, canonical_url = $9, author_name = $10, author_email = $11, author_avatar = $12, author_bio = $13, tags = $14, categories = $15, status = $16, published_at = $17, scheduled_for = $18, featured_image = $19, reading_time = $20, word_count = $21, updated_at = NOW() WHERE id = $22 `, [ validated.title, validated.slug, validated.content, validated.excerpt, validated.seo.metaTitle, validated.seo.metaDescription, validated.seo.keywords, validated.seo.ogImage, validated.seo.canonicalUrl, validated.author.name, validated.author.email, validated.author.avatar, validated.author.bio, validated.tags, validated.categories, validated.status, validated.publishedAt, validated.scheduledFor, validated.featuredImage, validated.readingTime, validated.wordCount, id, ] ); return { success: true }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Blog post update failed', }; } } /** * Publish blog post (change status to published and set publishedAt) */ async publish(id: string): Promise<BlogPostResult<string>> { if (!this.dbClient) { return { success: false, error: 'Database client not configured' }; } try { // Validate post is ready for publishing const readResult = await this.read(id); if (!readResult.success || !readResult.data) { return { success: false, error: 'Blog post not found' }; } const post = readResult.data; // Validate SEO fields are complete const seoValidation = validateSEOCompleteness(post); if (!seoValidation.success) { return { success: false, error: seoValidation.error }; } // Update status to published await this.dbClient.query( ` UPDATE ${this.tableName} SET status = 'published', published_at = NOW(), updated_at = NOW() WHERE id = $1 `, [id] ); // Generate URL const url = this.generateUrl(post.slug); return { success: true, data: url }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Publishing failed', }; } } /** * Delete blog post */ async delete(id: string): Promise<BlogPostResult<void>> { if (!this.dbClient) { return { success: false, error: 'Database client not configured' }; } try { await this.dbClient.query(`DELETE FROM ${this.tableName} WHERE id = $1`, [id]); return { success: true }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Blog post deletion failed', }; } } /** * List blog posts with filtering */ async list(filters?: { status?: BlogPostStatus; tags?: string[]; categories?: string[]; limit?: number; offset?: number; }): Promise<BlogPostResult<BlogPostMetadata[]>> { if (!this.dbClient) { return { success: false, error: 'Database client not configured' }; } try { let query = `SELECT * FROM ${this.tableName} WHERE 1=1`; // eslint-disable-next-line @typescript-eslint/no-explicit-any const params: any[] = []; let paramIndex = 1; if (filters?.status) { query += ` AND status = $${paramIndex}`; params.push(filters.status); paramIndex++; } if (filters?.tags && filters.tags.length > 0) { query += ` AND tags && $${paramIndex}`; params.push(filters.tags); paramIndex++; } if (filters?.categories && filters.categories.length > 0) { query += ` AND categories && $${paramIndex}`; params.push(filters.categories); paramIndex++; } query += ` ORDER BY created_at DESC`; if (filters?.limit) { query += ` LIMIT $${paramIndex}`; params.push(filters.limit); paramIndex++; } if (filters?.offset) { query += ` OFFSET $${paramIndex}`; params.push(filters.offset); paramIndex++; } const result = await this.dbClient.query(query, params); // eslint-disable-next-line @typescript-eslint/no-explicit-any const posts = result.rows.map((row: any) => this.rowToMetadata(row)); return { success: true, data: posts }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Blog post listing failed', }; } } // Private helper methods private async ensureUniqueSlug(slug: string): Promise<string> { if (!this.dbClient) { return slug; } const result = await this.dbClient.query( `SELECT slug FROM ${this.tableName} WHERE slug LIKE $1`, [`${slug}%`] ); if (result.rows.length === 0) { return slug; } // Append number to make unique let counter = 1; let uniqueSlug = `${slug}-${counter}`; // eslint-disable-next-line @typescript-eslint/no-explicit-any while (result.rows.some((row: any) => row.slug === uniqueSlug)) { counter++; uniqueSlug = `${slug}-${counter}`; } return uniqueSlug; } private generateUrl(slug: string): string { const publishingConfig = this.config.publishing as WebPublishingConfig; const baseUrl = publishingConfig.baseUrl || ''; const urlPattern = publishingConfig.urlPattern || '/blog/{slug}'; const url = urlPattern.replace('{slug}', slug); return `${baseUrl}${url}`; } // eslint-disable-next-line @typescript-eslint/no-explicit-any private rowToMetadata(row: any): BlogPostMetadata { return { content: row.content, createdAt: new Date(row.created_at), updatedAt: new Date(row.updated_at), title: row.title, slug: row.slug, excerpt: row.excerpt, seo: { metaTitle: row.meta_title, metaDescription: row.meta_description, keywords: row.keywords || [], ogImage: row.og_image, ogType: 'article', canonicalUrl: row.canonical_url, }, author: { name: row.author_name, email: row.author_email, avatar: row.author_avatar, bio: row.author_bio, }, tags: row.tags || [], categories: row.categories || [], status: row.status, publishedAt: row.published_at ? new Date(row.published_at) : undefined, scheduledFor: row.scheduled_for ? new Date(row.scheduled_for) : undefined, featuredImage: row.featured_image, readingTime: row.reading_time, wordCount: row.word_count, }; } } /** * Factory function to create blog post content type */ export function createBlogPostContentType(config?: { // eslint-disable-next-line @typescript-eslint/no-explicit-any dbClient?: any; tableName?: string; baseUrl?: string; }): BlogPostContentType { return new BlogPostContentType(config); }