UNPKG

@bernierllc/content-type-blog-post

Version:

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

477 lines (468 loc) 17.3 kB
"use strict"; /* 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. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.BlogPostContentType = void 0; exports.createBlogPostContentType = createBlogPostContentType; const types_1 = require("./types"); const calculations_1 = require("./calculations"); const seo_1 = require("./seo"); /** * Blog post content type with rich TipTap editor, SEO metadata, database storage, and web publishing */ class BlogPostContentType { constructor(config) { this.tableName = 'blog_posts'; // 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, }, metadata: types_1.BlogPostMetadataSchema, storage: { type: 'database', table: this.tableName, }, publishing: { type: 'web', urlPattern: '/blog/{slug}', baseUrl: config?.baseUrl || '', }, }; } /** * Initialize database schema */ async initializeDatabase() { 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) { if (!this.dbClient) { return { success: false, error: 'Database client not configured' }; } try { // Validate metadata const validated = types_1.BlogPostMetadataSchema.parse(data); // Calculate word count and reading time if not provided const wordCount = validated.wordCount || (0, calculations_1.calculateWordCount)(validated.content); const readingTime = validated.readingTime || (0, calculations_1.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) { 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 = 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) { 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 = 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, data) { 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 = { ...readResult.data, ...data, updatedAt: new Date(), }; // Recalculate word count and reading time if content changed if (data.content) { updated.wordCount = (0, calculations_1.calculateWordCount)(data.content); updated.readingTime = (0, calculations_1.calculateReadingTime)(updated.wordCount); } // Validate const validated = types_1.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) { 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 = (0, seo_1.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) { 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) { 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 = []; 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) => 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 async ensureUniqueSlug(slug) { 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) => row.slug === uniqueSlug)) { counter++; uniqueSlug = `${slug}-${counter}`; } return uniqueSlug; } generateUrl(slug) { const publishingConfig = this.config.publishing; 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 rowToMetadata(row) { 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, }; } } exports.BlogPostContentType = BlogPostContentType; /** * Factory function to create blog post content type */ function createBlogPostContentType(config) { return new BlogPostContentType(config); }