@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
JavaScript
"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);
}