diff --git a/src/newsletter/README.md b/src/newsletter/README.md new file mode 100644 index 0000000000..7247ffa1a1 --- /dev/null +++ b/src/newsletter/README.md @@ -0,0 +1,98 @@ +# Newsletter MCP Server + +An MCP server for creating professional newsletters with markdown conversion, email templates, and preview generation. + +## Features + +### Tools + +1. **convert_markdown** - Convert Markdown to styled HTML + - Professional styling with themes (light, dark, medical, minimal) + - Syntax highlighting for code blocks + - Callouts (info, warning, tip, note) + - French typography support (guillemets, non-breaking spaces) + - Responsive images and styled tables + +2. **generate_email_template** - Generate responsive email templates + - Multiple output formats: MJML, HTML, SendGrid, Mailchimp + - Customizable branding (colors, logo) + - Responsive design for all email clients + - Header, content, CTA button, footer sections + +3. **generate_preview** - Generate newsletter previews + - Desktop and mobile viewports + - Light and dark themes + - Reading time and word count metadata + - Frame display option + +## Installation + +```bash +npm install @modelcontextprotocol/server-newsletter +``` + +## Usage with Claude Desktop + +Add to your `claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "newsletter": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-newsletter"] + } + } +} +``` + +## Tool Usage Examples + +### Convert Markdown + +``` +Use the convert_markdown tool with: +- markdown: "# My Title\n\nContent here..." +- theme: "light" (optional) +- frenchTypography: true (optional) +``` + +### Generate Email Template + +``` +Use the generate_email_template tool with: +- title: "Monthly Newsletter" +- content: "Your newsletter content..." +- cta: { text: "Read More", url: "https://example.com" } +- format: "html" (or "mjml", "sendgrid", "mailchimp") +- brandColor: "#0066cc" (optional) +``` + +### Generate Preview + +``` +Use the generate_preview tool with: +- newsletter: { title: "...", contentMarkdown: "...", takeaways: [...] } +- viewport: "both" (or "desktop", "mobile") +- theme: "light" (or "dark") +``` + +## Output Formats + +### Email Template Formats + +- **MJML**: Recommended for customization, compile to HTML with mjml CLI +- **HTML**: Ready-to-send responsive HTML email +- **SendGrid**: Dynamic template format for SendGrid API +- **Mailchimp**: Compatible format for Mailchimp campaigns + +## Themes + +- **light**: Clean, bright theme with subtle colors +- **dark**: Dark mode with light text +- **medical**: Professional medical/clinical styling +- **minimal**: Simple, distraction-free design + +## License + +MIT diff --git a/src/newsletter/email-template.ts b/src/newsletter/email-template.ts new file mode 100644 index 0000000000..49ed07f98a --- /dev/null +++ b/src/newsletter/email-template.ts @@ -0,0 +1,269 @@ +import mjml2html from "mjml"; + +export interface EmailTemplateOptions { + title: string; + intro?: string; + content: string; + takeaways?: string[]; + cta?: { text: string; url: string }; + format?: "mjml" | "html" | "sendgrid" | "mailchimp"; + brandColor?: string; + logoUrl?: string; + footerText?: string; + unsubscribeUrl?: string; +} + +interface EmailTemplateResult { + format: string; + template: string; + mjml?: string; + previewText: string; +} + +// Generate MJML template +function generateMjml(options: EmailTemplateOptions): string { + const { + title, + intro, + content, + takeaways, + cta, + brandColor = "#0066cc", + logoUrl, + footerText = "SkinArt - Médecine esthétique", + unsubscribeUrl = "#", + } = options; + + const takeawaysSection = takeaways?.length + ? ` + + + + ✨ À retenir + + +
    + ${takeaways.map((t) => `
  • ${t}
  • `).join("")} +
+
+
+
+ ` + : ""; + + const ctaSection = cta + ? ` + + + + ${cta.text} + + + + ` + : ""; + + const logoSection = logoUrl + ? ` + + + + + + ` + : ""; + + return ` + + + ${title} + ${intro || title} + + + + + + .link-nostyle { color: inherit !important; text-decoration: none !important; } + h1, h2, h3 { color: #1a5276; margin-top: 24px; margin-bottom: 12px; } + h1 { font-size: 28px; } + h2 { font-size: 22px; } + h3 { font-size: 18px; } + p { margin: 16px 0; } + ul, ol { margin: 16px 0; padding-left: 24px; } + li { margin: 8px 0; } + blockquote { + border-left: 4px solid ${brandColor}; + padding-left: 16px; + margin: 20px 0; + font-style: italic; + color: #5a6c7d; + } + + + + ${logoSection} + + + + + + ${title} + + ${intro ? `${intro}` : ""} + + + + + + + + ${content} + + + + + ${takeawaysSection} + + ${ctaSection} + + + + + + ${footerText} + + + Se désabonner + + + + + + `.trim(); +} + +// Convert to SendGrid dynamic template format +function convertToSendGrid(html: string, options: EmailTemplateOptions): string { + // SendGrid uses Handlebars-style placeholders + const sendGridTemplate = { + subject: options.title, + preheader: options.intro || options.title, + html_content: html, + plain_content: stripHtml(html), + // SendGrid dynamic data placeholders + substitution_tag: "{{}}", + categories: ["newsletter", "skinart"], + }; + + return JSON.stringify(sendGridTemplate, null, 2); +} + +// Convert to Mailchimp template format +function convertToMailchimp(html: string, options: EmailTemplateOptions): string { + // Mailchimp uses *|MERGE|* style placeholders + const mailchimpHtml = html + .replace(/\{\{first_name\}\}/g, "*|FNAME|*") + .replace(/\{\{email\}\}/g, "*|EMAIL|*") + .replace(/\{\{unsubscribe_url\}\}/g, "*|UNSUB|*"); + + // Add Mailchimp editable regions + const withEditableRegions = mailchimpHtml + .replace( + /]*)background-color="white"([^>]*)>/g, + '' + ); + + return ` + + +*|IF:FNAME|* +Bonjour *|FNAME|*, +*|END:IF|* + +${withEditableRegions} + +*|LIST:DESCRIPTION|* +*|HTML:LIST_ADDRESS_HTML|* + +Se désabonner + `.trim(); +} + +// Strip HTML for plain text version +function stripHtml(html: string): string { + return html + .replace(/]*>[\s\S]*?<\/style>/gi, "") + .replace(/]*>[\s\S]*?<\/script>/gi, "") + .replace(/<[^>]+>/g, "") + .replace(/\s+/g, " ") + .trim(); +} + +// Main function +export async function generateEmailTemplate( + options: EmailTemplateOptions +): Promise { + const format = options.format || "html"; + + // Generate MJML + const mjmlTemplate = generateMjml(options); + + // Convert MJML to HTML + const { html, errors } = mjml2html(mjmlTemplate, { + validationLevel: "soft", + }); + + if (errors.length > 0) { + console.warn("MJML warnings:", errors); + } + + // Generate preview text + const previewText = options.intro || options.title; + + switch (format) { + case "mjml": + return { + format: "mjml", + template: mjmlTemplate, + mjml: mjmlTemplate, + previewText, + }; + + case "sendgrid": + return { + format: "sendgrid", + template: convertToSendGrid(html, options), + mjml: mjmlTemplate, + previewText, + }; + + case "mailchimp": + return { + format: "mailchimp", + template: convertToMailchimp(html, options), + mjml: mjmlTemplate, + previewText, + }; + + case "html": + default: + return { + format: "html", + template: html, + mjml: mjmlTemplate, + previewText, + }; + } +} diff --git a/src/newsletter/index.ts b/src/newsletter/index.ts new file mode 100644 index 0000000000..9cae4882fa --- /dev/null +++ b/src/newsletter/index.ts @@ -0,0 +1,289 @@ +#!/usr/bin/env node +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { + CallToolRequestSchema, + ListToolsRequestSchema, + Tool, +} from "@modelcontextprotocol/sdk/types.js"; + +import { markdownToHtml, MarkdownOptions } from "./markdown-converter.js"; +import { generateEmailTemplate, EmailTemplateOptions } from "./email-template.js"; +import { generatePreview, PreviewOptions } from "./preview-generator.js"; + +// Define available tools +const tools: Tool[] = [ + { + name: "convert_markdown", + description: `Converts Markdown to professionally styled HTML. + +Features: +- Styled headings with anchors +- Code blocks with syntax highlighting +- Callouts (info, warning, tip, note) +- Styled blockquotes +- Bullet and numbered lists +- Responsive images +- Styled tables +- French typography support (guillemets, non-breaking spaces)`, + inputSchema: { + type: "object", + properties: { + markdown: { + type: "string", + description: "The Markdown content to convert", + }, + theme: { + type: "string", + enum: ["light", "dark", "medical", "minimal"], + description: "Style theme (default: light)", + }, + includeStyles: { + type: "boolean", + description: "Include inline CSS styles (default: true)", + }, + frenchTypography: { + type: "boolean", + description: "Apply French typography rules (default: false)", + }, + }, + required: ["markdown"], + }, + }, + { + name: "generate_email_template", + description: `Generates responsive email templates compatible with email clients. + +Supported formats: +- MJML (recommended for customization) +- HTML email (ready to send) +- SendGrid compatible +- Mailchimp compatible + +Template includes: +- Header with logo +- Main content +- CTA button +- Footer with unsubscribe links +- Inline styles for email compatibility`, + inputSchema: { + type: "object", + properties: { + title: { + type: "string", + description: "Newsletter title", + }, + intro: { + type: "string", + description: "Introduction/hook", + }, + content: { + type: "string", + description: "Main content (HTML or Markdown)", + }, + takeaways: { + type: "array", + items: { type: "string" }, + description: "Key takeaways", + }, + cta: { + type: "object", + properties: { + text: { type: "string" }, + url: { type: "string" }, + }, + description: "Call-to-action button", + }, + format: { + type: "string", + enum: ["mjml", "html", "sendgrid", "mailchimp"], + description: "Output format (default: html)", + }, + brandColor: { + type: "string", + description: "Brand primary color (default: #0066cc)", + }, + logoUrl: { + type: "string", + description: "Logo URL", + }, + }, + required: ["title", "content"], + }, + }, + { + name: "generate_preview", + description: `Generates a complete HTML preview of the newsletter. + +Preview options: +- Desktop (800px) +- Mobile (375px) +- Light/dark theme +- With/without frame + +Preview includes: +- Metadata (reading time, word count) +- Accurate content rendering +- Email rendering simulation`, + inputSchema: { + type: "object", + properties: { + newsletter: { + type: "object", + properties: { + title: { type: "string" }, + intro: { type: "string" }, + contentMarkdown: { type: "string" }, + contentHtml: { type: "string" }, + takeaways: { type: "array", items: { type: "string" } }, + conclusion: { type: "string" }, + cta: { type: "string" }, + }, + required: ["title"], + description: "Newsletter data", + }, + viewport: { + type: "string", + enum: ["desktop", "mobile", "both"], + description: "Screen size for preview (default: both)", + }, + theme: { + type: "string", + enum: ["light", "dark"], + description: "Preview theme (default: light)", + }, + showFrame: { + type: "boolean", + description: "Show frame around preview (default: true)", + }, + }, + required: ["newsletter"], + }, + }, +]; + +// Create server instance +const server = new Server( + { + name: "newsletter-mcp", + version: "1.0.0", + }, + { + capabilities: { + tools: {}, + }, + } +); + +// Handle list tools request +server.setRequestHandler(ListToolsRequestSchema, async () => { + return { tools }; +}); + +// Handle tool calls +server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params; + + try { + switch (name) { + case "convert_markdown": { + const themeValue = (args?.theme as string) || "light"; + const options: MarkdownOptions = { + theme: themeValue as "light" | "dark" | "medical" | "minimal", + includeStyles: args?.includeStyles !== false, + frenchTypography: args?.frenchTypography === true, + }; + const result = await markdownToHtml(args?.markdown as string, options); + return { + content: [ + { + type: "text", + text: JSON.stringify(result, null, 2), + }, + ], + }; + } + + case "generate_email_template": { + const formatValue = (args?.format as string) || "html"; + const options: EmailTemplateOptions = { + title: args?.title as string, + intro: args?.intro as string, + content: args?.content as string, + takeaways: args?.takeaways as string[], + cta: args?.cta as { text: string; url: string }, + format: formatValue as "mjml" | "html" | "sendgrid" | "mailchimp", + brandColor: (args?.brandColor as string) || "#0066cc", + logoUrl: args?.logoUrl as string, + }; + const result = await generateEmailTemplate(options); + return { + content: [ + { + type: "text", + text: JSON.stringify(result, null, 2), + }, + ], + }; + } + + case "generate_preview": { + const viewportValue = (args?.viewport as string) || "both"; + const themePreviewValue = (args?.theme as string) || "light"; + const options: PreviewOptions = { + newsletter: args?.newsletter as { + title: string; + intro?: string; + contentMarkdown?: string; + contentHtml?: string; + takeaways?: string[]; + conclusion?: string; + cta?: string; + }, + viewport: viewportValue as "desktop" | "mobile" | "both", + theme: themePreviewValue as "light" | "dark", + showFrame: args?.showFrame !== false, + }; + const result = await generatePreview(options); + return { + content: [ + { + type: "text", + text: JSON.stringify(result, null, 2), + }, + ], + }; + } + + default: + return { + content: [ + { + type: "text", + text: `Unknown tool: ${name}`, + }, + ], + isError: true, + }; + } + } catch (error) { + return { + content: [ + { + type: "text", + text: `Error: ${error instanceof Error ? error.message : String(error)}`, + }, + ], + isError: true, + }; + } +}); + +// Start the server +async function main() { + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error("Newsletter MCP Server running on stdio"); +} + +main().catch(console.error); diff --git a/src/newsletter/markdown-converter.ts b/src/newsletter/markdown-converter.ts new file mode 100644 index 0000000000..fc66d36c7d --- /dev/null +++ b/src/newsletter/markdown-converter.ts @@ -0,0 +1,427 @@ +import { marked } from "marked"; +import hljs from "highlight.js"; +import sanitizeHtml from "sanitize-html"; + +export interface MarkdownOptions { + theme?: "light" | "dark" | "medical" | "minimal"; + includeStyles?: boolean; + frenchTypography?: boolean; +} + +interface ConversionResult { + html: string; + styles: string; + wordCount: number; + readingTimeMinutes: number; + headings: Array<{ level: number; text: string; id: string }>; +} + +// Theme color palettes +const themes = { + light: { + bg: "#ffffff", + text: "#1a1a1a", + heading: "#0d0d0d", + link: "#0066cc", + code: "#f4f4f4", + codeBorder: "#e0e0e0", + blockquote: "#f8f9fa", + blockquoteBorder: "#dee2e6", + calloutInfo: "#e7f3ff", + calloutWarning: "#fff3cd", + calloutTip: "#d4edda", + calloutNote: "#f8f9fa", + }, + dark: { + bg: "#1a1a1a", + text: "#e0e0e0", + heading: "#ffffff", + link: "#66b3ff", + code: "#2d2d2d", + codeBorder: "#404040", + blockquote: "#252525", + blockquoteBorder: "#404040", + calloutInfo: "#1a3a5c", + calloutWarning: "#5c4a1a", + calloutTip: "#1a4a2a", + calloutNote: "#2a2a2a", + }, + medical: { + bg: "#ffffff", + text: "#2c3e50", + heading: "#1a5276", + link: "#2980b9", + code: "#ecf0f1", + codeBorder: "#bdc3c7", + blockquote: "#e8f6f3", + blockquoteBorder: "#1abc9c", + calloutInfo: "#d6eaf8", + calloutWarning: "#fdebd0", + calloutTip: "#d5f5e3", + calloutNote: "#f4f6f7", + }, + minimal: { + bg: "#ffffff", + text: "#333333", + heading: "#000000", + link: "#000000", + code: "#f5f5f5", + codeBorder: "#eeeeee", + blockquote: "#fafafa", + blockquoteBorder: "#dddddd", + calloutInfo: "#f0f0f0", + calloutWarning: "#f0f0f0", + calloutTip: "#f0f0f0", + calloutNote: "#f0f0f0", + }, +}; + +// Apply French typography rules +function applyFrenchTypography(text: string): string { + return text + // Guillemets français + .replace(/"([^"]+)"/g, "« $1 »") + // Espaces insécables avant ponctuation double + .replace(/ ([?!;:])/g, "\u00A0$1") + // Tirets longs pour les dialogues + .replace(/^- /gm, "— ") + // Apostrophes typographiques + .replace(/'/g, "'"); +} + +// Generate CSS styles for a theme +function generateStyles(themeName: string): string { + const t = themes[themeName as keyof typeof themes] || themes.medical; + + return ` + .newsletter-content { + font-family: 'Georgia', 'Times New Roman', serif; + font-size: 18px; + line-height: 1.7; + color: ${t.text}; + background-color: ${t.bg}; + max-width: 720px; + margin: 0 auto; + padding: 2rem; + } + + .newsletter-content h1, + .newsletter-content h2, + .newsletter-content h3, + .newsletter-content h4, + .newsletter-content h5, + .newsletter-content h6 { + font-family: 'Helvetica Neue', Arial, sans-serif; + color: ${t.heading}; + margin-top: 2em; + margin-bottom: 0.5em; + line-height: 1.3; + } + + .newsletter-content h1 { font-size: 2.2em; border-bottom: 2px solid ${t.heading}; padding-bottom: 0.3em; } + .newsletter-content h2 { font-size: 1.8em; } + .newsletter-content h3 { font-size: 1.4em; } + .newsletter-content h4 { font-size: 1.2em; } + + .newsletter-content a { + color: ${t.link}; + text-decoration: none; + border-bottom: 1px solid transparent; + transition: border-color 0.2s; + } + + .newsletter-content a:hover { + border-bottom-color: ${t.link}; + } + + .newsletter-content p { + margin: 1.2em 0; + } + + .newsletter-content blockquote { + margin: 1.5em 0; + padding: 1em 1.5em; + background-color: ${t.blockquote}; + border-left: 4px solid ${t.blockquoteBorder}; + font-style: italic; + } + + .newsletter-content blockquote p { + margin: 0; + } + + .newsletter-content pre { + background-color: ${t.code}; + border: 1px solid ${t.codeBorder}; + border-radius: 6px; + padding: 1em; + overflow-x: auto; + font-size: 0.9em; + } + + .newsletter-content code { + font-family: 'Monaco', 'Menlo', 'Consolas', monospace; + font-size: 0.9em; + background-color: ${t.code}; + padding: 0.2em 0.4em; + border-radius: 3px; + } + + .newsletter-content pre code { + background: none; + padding: 0; + } + + .newsletter-content ul, + .newsletter-content ol { + margin: 1em 0; + padding-left: 1.5em; + } + + .newsletter-content li { + margin: 0.5em 0; + } + + .newsletter-content img { + max-width: 100%; + height: auto; + border-radius: 8px; + margin: 1.5em 0; + } + + .newsletter-content table { + width: 100%; + border-collapse: collapse; + margin: 1.5em 0; + } + + .newsletter-content th, + .newsletter-content td { + border: 1px solid ${t.codeBorder}; + padding: 0.75em; + text-align: left; + } + + .newsletter-content th { + background-color: ${t.code}; + font-weight: bold; + } + + .newsletter-content hr { + border: none; + border-top: 1px solid ${t.codeBorder}; + margin: 2em 0; + } + + /* Callouts */ + .callout { + margin: 1.5em 0; + padding: 1em 1.5em; + border-radius: 8px; + border-left: 4px solid; + } + + .callout-info { + background-color: ${t.calloutInfo}; + border-left-color: #3498db; + } + + .callout-warning { + background-color: ${t.calloutWarning}; + border-left-color: #f39c12; + } + + .callout-tip { + background-color: ${t.calloutTip}; + border-left-color: #27ae60; + } + + .callout-note { + background-color: ${t.calloutNote}; + border-left-color: #95a5a6; + } + + .callout-title { + font-weight: bold; + margin-bottom: 0.5em; + font-family: 'Helvetica Neue', Arial, sans-serif; + } + + /* Takeaways box */ + .takeaways-box { + background: linear-gradient(135deg, ${t.calloutTip} 0%, ${t.calloutInfo} 100%); + border-radius: 12px; + padding: 1.5em; + margin: 2em 0; + } + + .takeaways-box h3 { + margin-top: 0; + display: flex; + align-items: center; + gap: 0.5em; + } + + .takeaways-box ul { + margin-bottom: 0; + } + + /* Heading anchors */ + .heading-anchor { + opacity: 0; + margin-left: 0.5em; + font-size: 0.8em; + text-decoration: none; + color: ${t.link}; + } + + h1:hover .heading-anchor, + h2:hover .heading-anchor, + h3:hover .heading-anchor { + opacity: 1; + } + `; +} + +// Process callouts in markdown +function processCallouts(markdown: string): string { + const calloutRegex = /:::(\w+)(?:\s+(.+?))?\n([\s\S]*?):::/g; + + return markdown.replace(calloutRegex, (_, type, title, content) => { + const calloutType = ["info", "warning", "tip", "note"].includes(type) + ? type + : "note"; + const titleText = title || type.charAt(0).toUpperCase() + type.slice(1); + + return `
+
${titleText}
+
${content.trim()}
+
`; + }); +} + +// Extract headings for TOC +function extractHeadings(html: string): Array<{ level: number; text: string; id: string }> { + const headings: Array<{ level: number; text: string; id: string }> = []; + const regex = /]*id="([^"]*)"[^>]*>([^<]*)/g; + let match; + + while ((match = regex.exec(html)) !== null) { + headings.push({ + level: parseInt(match[1]), + text: match[3], + id: match[2], + }); + } + + return headings; +} + +// Count words +function countWords(text: string): number { + return text + .replace(/<[^>]*>/g, "") + .split(/\s+/) + .filter((word) => word.length > 0).length; +} + +// Calculate reading time (average 200 words/minute) +function calculateReadingTime(wordCount: number): number { + return Math.max(1, Math.ceil(wordCount / 200)); +} + +// Main conversion function +export async function markdownToHtml( + markdown: string, + options: MarkdownOptions = {} +): Promise { + const { + theme = "medical", + includeStyles = true, + frenchTypography = true, + } = options; + + // Apply French typography if enabled + let processedMarkdown = frenchTypography + ? applyFrenchTypography(markdown) + : markdown; + + // Process callouts before markdown parsing + processedMarkdown = processCallouts(processedMarkdown); + + // Configure marked with syntax highlighting + marked.setOptions({ + gfm: true, + breaks: false, + }); + + // Custom renderer for headings with anchors + const renderer = new marked.Renderer(); + + renderer.heading = ({ text, depth }) => { + const slug = text + .toLowerCase() + .replace(/[^\w\s-]/g, "") + .replace(/\s+/g, "-"); + return `${text}#\n`; + }; + + renderer.code = ({ text, lang }) => { + const language = lang && hljs.getLanguage(lang) ? lang : "plaintext"; + const highlighted = hljs.highlight(text, { language }).value; + return `
${highlighted}
\n`; + }; + + marked.use({ renderer }); + + // Convert markdown to HTML + let html = await marked.parse(processedMarkdown); + + // Sanitize HTML + html = sanitizeHtml(html, { + allowedTags: sanitizeHtml.defaults.allowedTags.concat([ + "img", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "pre", + "code", + ]), + allowedAttributes: { + ...sanitizeHtml.defaults.allowedAttributes, + "*": ["class", "id"], + a: ["href", "target", "rel"], + img: ["src", "alt", "title", "width", "height"], + code: ["class"], + }, + }); + + // Wrap in container + html = ``; + + // Generate styles + const styles = generateStyles(theme); + + // Calculate metrics + const wordCount = countWords(html); + const readingTimeMinutes = calculateReadingTime(wordCount); + + // Extract headings + const headings = extractHeadings(html); + + // Include styles if requested + const finalHtml = includeStyles + ? `${html}` + : html; + + return { + html: finalHtml, + styles, + wordCount, + readingTimeMinutes, + headings, + }; +} diff --git a/src/newsletter/package.json b/src/newsletter/package.json new file mode 100644 index 0000000000..235adb4a3f --- /dev/null +++ b/src/newsletter/package.json @@ -0,0 +1,40 @@ +{ + "name": "@modelcontextprotocol/server-newsletter", + "version": "0.1.0", + "description": "MCP server for newsletter formatting, email templates, and previews", + "license": "MIT", + "mcpName": "io.github.modelcontextprotocol/server-newsletter", + "author": "Olivier Renoverre", + "homepage": "https://modelcontextprotocol.io", + "bugs": "https://github.com/modelcontextprotocol/servers/issues", + "repository": { + "type": "git", + "url": "https://github.com/modelcontextprotocol/servers.git" + }, + "type": "module", + "bin": { + "mcp-server-newsletter": "dist/index.js" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc && shx chmod +x dist/*.js", + "prepare": "npm run build", + "watch": "tsc --watch" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.24.0", + "marked": "^15.0.0", + "mjml": "^4.15.3", + "highlight.js": "^11.10.0", + "sanitize-html": "^2.13.0" + }, + "devDependencies": { + "@types/node": "^22", + "@types/mjml": "^4.7.4", + "@types/sanitize-html": "^2.13.0", + "shx": "^0.3.4", + "typescript": "^5.8.2" + } +} diff --git a/src/newsletter/preview-generator.ts b/src/newsletter/preview-generator.ts new file mode 100644 index 0000000000..81809a677f --- /dev/null +++ b/src/newsletter/preview-generator.ts @@ -0,0 +1,314 @@ +import { markdownToHtml } from "./markdown-converter.js"; + +export interface PreviewOptions { + newsletter: { + title: string; + intro?: string; + contentMarkdown?: string; + contentHtml?: string; + takeaways?: string[]; + conclusion?: string; + cta?: string; + }; + viewport?: "desktop" | "mobile" | "both"; + theme?: "light" | "dark"; + showFrame?: boolean; +} + +interface PreviewResult { + desktop?: string; + mobile?: string; + combined?: string; + metadata: { + title: string; + wordCount: number; + readingTimeMinutes: number; + hasImages: boolean; + hasTakeaways: boolean; + hasCta: boolean; + }; +} + +// Generate frame styles +function getFrameStyles(theme: "light" | "dark"): string { + const isDark = theme === "dark"; + + return ` + .preview-frame { + border: 1px solid ${isDark ? "#404040" : "#e0e0e0"}; + border-radius: 12px; + overflow: hidden; + box-shadow: 0 4px 12px rgba(0, 0, 0, ${isDark ? "0.3" : "0.1"}); + background: ${isDark ? "#1a1a1a" : "#ffffff"}; + } + + .preview-header { + background: ${isDark ? "#2d2d2d" : "#f5f5f5"}; + padding: 12px 16px; + display: flex; + align-items: center; + gap: 8px; + border-bottom: 1px solid ${isDark ? "#404040" : "#e0e0e0"}; + } + + .preview-dots { + display: flex; + gap: 6px; + } + + .preview-dot { + width: 12px; + height: 12px; + border-radius: 50%; + } + + .preview-dot.red { background: #ff5f57; } + .preview-dot.yellow { background: #febc2e; } + .preview-dot.green { background: #28c840; } + + .preview-title { + flex: 1; + text-align: center; + font-size: 13px; + color: ${isDark ? "#888" : "#666"}; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + } + + .preview-content { + padding: 0; + overflow-y: auto; + } + + .preview-metadata { + background: ${isDark ? "#252525" : "#f8f9fa"}; + padding: 12px 16px; + border-top: 1px solid ${isDark ? "#404040" : "#e0e0e0"}; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + font-size: 12px; + color: ${isDark ? "#888" : "#666"}; + display: flex; + gap: 16px; + } + + .preview-metadata-item { + display: flex; + align-items: center; + gap: 4px; + } + + .viewport-desktop { + width: 800px; + margin: 0 auto; + } + + .viewport-mobile { + width: 375px; + margin: 0 auto; + } + + .viewport-container { + display: flex; + gap: 24px; + justify-content: center; + padding: 24px; + background: ${isDark ? "#0d0d0d" : "#f0f0f0"}; + } + + .viewport-label { + text-align: center; + font-size: 12px; + color: ${isDark ? "#666" : "#999"}; + margin-bottom: 8px; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + text-transform: uppercase; + letter-spacing: 1px; + } + `; +} + +// Generate preview HTML for a specific viewport +function generateViewportPreview( + content: string, + viewport: "desktop" | "mobile", + theme: "light" | "dark", + showFrame: boolean, + metadata: PreviewResult["metadata"] +): string { + const width = viewport === "desktop" ? 800 : 375; + const frameClass = showFrame ? "preview-frame" : ""; + + const metadataHtml = ` + + `; + + if (showFrame) { + return ` +
+
${viewport === "desktop" ? "Desktop (800px)" : "Mobile (375px)"}
+
+
+
+
+
+
+
+
${metadata.title}
+
+
+ ${content} +
+ ${metadataHtml} +
+
+ `; + } + + return ` +
+ ${content} + ${metadataHtml} +
+ `; +} + +// Count words in text +function countWords(text: string): number { + return text + .replace(/<[^>]*>/g, "") + .split(/\s+/) + .filter((word) => word.length > 0).length; +} + +// Main function +export async function generatePreview( + options: PreviewOptions +): Promise { + const { + newsletter, + viewport = "both", + theme = "light", + showFrame = true, + } = options; + + // Convert content + let htmlContent: string; + + if (newsletter.contentHtml) { + htmlContent = newsletter.contentHtml; + } else if (newsletter.contentMarkdown) { + const converted = await markdownToHtml(newsletter.contentMarkdown, { + theme: theme === "dark" ? "dark" : "medical", + includeStyles: true, + }); + htmlContent = converted.html; + } else { + htmlContent = "

Pas de contenu

"; + } + + // Build full content with intro, takeaways, conclusion, cta + let fullContent = ` +
+

${newsletter.title}

+ ${newsletter.intro ? `

${newsletter.intro}

` : ""} + ${htmlContent} + `; + + if (newsletter.takeaways && newsletter.takeaways.length > 0) { + fullContent += ` +
+

✨ À retenir

+
    + ${newsletter.takeaways.map((t) => `
  • ${t}
  • `).join("")} +
+
+ `; + } + + if (newsletter.conclusion) { + fullContent += `

${newsletter.conclusion}

`; + } + + if (newsletter.cta) { + fullContent += ` +

+ ${newsletter.cta} +

+ `; + } + + fullContent += "
"; + + // Calculate metadata + const wordCount = countWords(fullContent); + const readingTimeMinutes = Math.max(1, Math.ceil(wordCount / 200)); + + const metadata: PreviewResult["metadata"] = { + title: newsletter.title, + wordCount, + readingTimeMinutes, + hasImages: / ` + + + + + + Preview: ${newsletter.title} + + + + ${html} + + + `; + + if (viewport === "desktop" || viewport === "both") { + result.desktop = wrapWithStyles( + generateViewportPreview(fullContent, "desktop", theme, showFrame, metadata) + ); + } + + if (viewport === "mobile" || viewport === "both") { + result.mobile = wrapWithStyles( + generateViewportPreview(fullContent, "mobile", theme, showFrame, metadata) + ); + } + + if (viewport === "both") { + result.combined = wrapWithStyles(` +
+ ${generateViewportPreview(fullContent, "desktop", theme, showFrame, metadata)} + ${generateViewportPreview(fullContent, "mobile", theme, showFrame, metadata)} +
+ `); + } + + return result; +} diff --git a/src/newsletter/tsconfig.json b/src/newsletter/tsconfig.json new file mode 100644 index 0000000000..1c0c32e23d --- /dev/null +++ b/src/newsletter/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "./dist", + "rootDir": ".", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true + }, + "include": ["./**/*.ts"], + "exclude": ["node_modules", "dist"] +}