Skip to main content
Glama
postService.js6.34 kB
import sanitizeHtml from "sanitize-html"; import Joi from "joi"; import { createContextLogger } from "../utils/logger.js"; import { createPost as createGhostPost, getTags as getGhostTags, createTag as createGhostTag, // Import other necessary functions from ghostService later } from "./ghostService.js"; // Note the relative path /** * Helper to generate a simple meta description from HTML content. * Uses sanitize-html to safely strip HTML tags and truncates. * @param {string} htmlContent - The HTML content of the post. * @param {number} maxLength - The maximum length of the description. * @returns {string} A plain text truncated description. */ const generateSimpleMetaDescription = (htmlContent, maxLength = 500) => { if (!htmlContent) return ""; // Use sanitize-html to safely remove all HTML tags // This prevents ReDoS attacks and properly handles malformed HTML const textContent = sanitizeHtml(htmlContent, { allowedTags: [], // Remove all HTML tags allowedAttributes: {}, textFilter: function(text) { return text.replace(/\s\s+/g, ' ').trim(); } }); // Truncate and add ellipsis if needed return textContent.length > maxLength ? textContent.substring(0, maxLength - 3) + "..." : textContent; }; /** * Service layer function to handle the business logic of creating a post. * Transforms input data, handles/resolves tags, includes feature image and metadata. * @param {object} postInput - Data received from the controller. * @returns {Promise<object>} The created post object from the Ghost API. */ // Validation schema for post input const postInputSchema = Joi.object({ title: Joi.string().max(255).required(), html: Joi.string().required(), custom_excerpt: Joi.string().max(500).optional(), status: Joi.string().valid('draft', 'published', 'scheduled').optional(), published_at: Joi.string().isoDate().optional(), tags: Joi.array().items(Joi.string().max(50)).max(10).optional(), feature_image: Joi.string().uri().optional(), feature_image_alt: Joi.string().max(255).optional(), feature_image_caption: Joi.string().max(500).optional(), meta_title: Joi.string().max(70).optional(), meta_description: Joi.string().max(160).optional() }); const createPostService = async (postInput) => { const logger = createContextLogger('post-service'); // Validate input to prevent format string vulnerabilities const { error, value: validatedInput } = postInputSchema.validate(postInput); if (error) { logger.error('Post input validation failed', { error: error.details[0].message, inputKeys: Object.keys(postInput) }); throw new Error(`Invalid post input: ${error.details[0].message}`); } const { title, html, custom_excerpt, status, published_at, tags, // Expecting array of strings (tag names) here now feature_image, feature_image_alt, feature_image_caption, meta_title, meta_description, } = validatedInput; // --- Resolve Tag Names to Tag Objects (ID/Slug/Name) --- let resolvedTags = []; if (tags && Array.isArray(tags) && tags.length > 0) { logger.info('Resolving provided tag names', { tagCount: tags.length, tags }); resolvedTags = await Promise.all( tags.map(async (tagName) => { if (typeof tagName !== "string" || !tagName.trim()) { logger.warn('Skipping invalid tag name', { tagName, type: typeof tagName }); return null; // Skip invalid entries } tagName = tagName.trim(); try { // Check if tag exists by name const existingTags = await getGhostTags(tagName); if (existingTags && existingTags.length > 0) { logger.debug('Found existing tag', { tagName, tagId: existingTags[0].id }); // Use the existing tag (Ghost usually accepts name, slug, or id) return { name: tagName }; // Or { id: existingTags[0].id } or { slug: existingTags[0].slug } } else { // Tag doesn't exist, create it logger.info('Creating new tag', { tagName }); const newTag = await createGhostTag({ name: tagName }); logger.info('Created new tag successfully', { tagName, tagId: newTag.id }); // Use the new tag return { name: tagName }; // Or { id: newTag.id } } } catch (tagError) { logger.error('Error processing tag', { tagName, error: tagError.message }); return null; // Skip tags that cause errors during processing } }) ); // Filter out any nulls from skipped/errored tags resolvedTags = resolvedTags.filter((tag) => tag !== null); logger.debug('Resolved tags for API', { resolvedTagCount: resolvedTags.length, resolvedTags }); } // --- End Tag Resolution --- // --- Metadata Defaults/Generation --- const finalMetaTitle = meta_title || title; // Default meta_title to title const finalMetaDescription = meta_description || custom_excerpt || generateSimpleMetaDescription(html); // Default meta_description // Ensure description does not exceed limit even after defaulting const truncatedMetaDescription = finalMetaDescription.length > 500 ? finalMetaDescription.substring(0, 497) + "..." : finalMetaDescription; // --- End Metadata Defaults --- // Prepare data for the Ghost API, including feature image fields const postDataForApi = { title, html, custom_excerpt, status: status || "draft", // Default to draft published_at, tags: resolvedTags, feature_image, feature_image_alt, feature_image_caption, meta_title: finalMetaTitle, // Use final value meta_description: truncatedMetaDescription, // Use final, truncated value // Add metadata fields here if needed in the future }; logger.info('Creating Ghost post', { title: postDataForApi.title, status: postDataForApi.status, tagCount: postDataForApi.tags?.length || 0, hasFeatureImage: !!postDataForApi.feature_image }); // Call the lower-level ghostService function const newPost = await createGhostPost(postDataForApi); return newPost; }; export { createPostService };

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/jgardner04/Ghost-MCP-Server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server