diff --git a/.eleventy.js b/.eleventy.js index 53d820b8..e3af982a 100644 --- a/.eleventy.js +++ b/.eleventy.js @@ -1,10 +1,11 @@ const { exec } = require("child_process"); const glob = require("fast-glob"); const { DateTime } = require("luxon"); +const slugify = require('slugify'); const fs = require("fs"); const puppeteer = require('puppeteer'); -const slugify = require('slugify'); const path = require('path'); +require('dotenv').config(); // Load environment variables const pluginAddIdToHeadings = require("@orchidjs/eleventy-plugin-ids"); const pluginRss = require("@11ty/eleventy-plugin-rss"); @@ -53,13 +54,6 @@ module.exports = function (eleventyConfig) { eleventyConfig.ignores.add("src/nl/vereniging/bestuur/notulen"); } - // Custom date filter - eleventyConfig.addFilter("localizedDate", function (dateObj, locale = "en") { - return DateTime.fromJSDate(dateObj) - .setLocale(locale) - .toFormat("d LLLL yyyy"); - }); - /* Add id to heading elements */ eleventyConfig.addPlugin(pluginAddIdToHeadings); @@ -158,6 +152,47 @@ module.exports = function (eleventyConfig) { } ); + // Import listmonkUtilities + const listmonkUtils = require('./utils/listmonk.js'); + + eleventyConfig.on('afterBuild', async () => { + const postsDir = path.join(__dirname, 'src/nl/activiteiten'); // Root folder + const outputFile = path.join(__dirname, 'dist/latest-post.json'); // Output file + + // Ensure output directory exists + if (!fs.existsSync(path.dirname(outputFile))) { + fs.mkdirSync(path.dirname(outputFile), { recursive: true }); + } + + // Find all markdown files recursively + const files = await glob([`${postsDir}/**/*.md`]); // Traverse all subdirectories for .md files + + // Parse files and extract front matter date + const filteredFiles = files + .filter(file => path.basename(file) !== 'index.md') // Exclude index.md + .map(file => { + const content = fs.readFileSync(file, 'utf-8'); // Read file content + const metadata = listmonkUtils.extractFrontMatter(content); // Extract front matter + const date = metadata.date ? new Date(metadata.date) : new Date(0); // Parse date or fallback + return { file, metadata, date }; // Store file data + }) + .sort((a, b) => b.date - a.date); // Sort by date (most recent first) + + if (filteredFiles.length > 0) { + const latestPost = filteredFiles[0]; // Get the most recent post + console.log('Latest Post Metadata:', latestPost.metadata); // Debugging log + + // Write latest post metadata to JSON + fs.writeFileSync(outputFile, JSON.stringify(latestPost.metadata, null, 2)); + console.log('Latest post JSON generated:', latestPost.metadata); + + // Check and send campaign (you already have this function) + listmonkUtils.checkAndSendCampaign(latestPost.metadata); + } else { + console.log('No valid posts found.'); + } + }); + eleventyConfig.setLiquidOptions({ dynamicPartials: false, strictFilters: false, @@ -185,111 +220,65 @@ module.exports = function (eleventyConfig) { }) ); - - eleventyConfig.addFilter("readablePostDate", (dateObj) => { - return DateTime.fromJSDate(dateObj, { - zone: "Europe/Amsterdam", - }).setLocale('en').toLocaleString(DateTime.DATE_FULL); - }); - - eleventyConfig.addFilter("postDate", (dateObj) => { - return DateTime.fromJSDate(dateObj, { - zone: "Europe/Amsterdam", - }).setLocale('en').toISODate(); - }); - - eleventyConfig.addFilter('splitlines', function (input) { - const parts = input.split(' '); - const lines = parts.reduce(function (prev, current) { - - if (!prev.length) { - return [current]; - } - - let lastOne = prev[prev.length - 1]; - - if (lastOne.length + current.length > 23) { - return [...prev, current]; - } - - prev[prev.length - 1] = lastOne + ' ' + current; - - return prev; - }, []); - - return lines; - }); - - eleventyConfig.on('afterBuild', async () => { - async function convertSvgToJpeg(inputDir, outputDir) { - const browser = await puppeteer.launch(); - const page = await browser.newPage(); - - // Read all files in the input directory - const files = fs.readdirSync(inputDir); - - for (const filename of files) { - if (filename.endsWith(".svg")) { - const inputPath = path.join(inputDir, filename); - const outputPath = path.join(outputDir, filename.replace('.svg', '.jpg')); - - // Read the SVG content - const svgContent = fs.readFileSync(inputPath, 'utf8'); - - // Extract width and height from SVG (Optional: If SVG has explicit size) - const matchWidth = svgContent.match(/width="([0-9]+)"/); - const matchHeight = svgContent.match(/height="([0-9]+)"/); - - const width = matchWidth ? parseInt(matchWidth[1], 10) : 1200; // Default to 1200px - const height = matchHeight ? parseInt(matchHeight[1], 10) : 675; // Default to 630px - - // Set the viewport size to match SVG size - await page.setViewport({ width, height }); - - // Set SVG content inside an HTML wrapper - await page.setContent(` - - -
- ${svgContent} -
- - - `); - - // Take a screenshot and save as JPEG - await page.screenshot({ - path: outputPath, - type: 'jpeg', - quality: 100, - clip: { x: 0, y: 0, width, height } // Ensure clipping matches viewport - }); - - console.log(`Converted: ${filename} -> ${outputPath}`); + if (!quick) { + eleventyConfig.on('afterBuild', async () => { + async function convertSvgToJpeg(inputDir, outputDir) { + const browser = await puppeteer.launch(); + const page = await browser.newPage(); + + // Read all files in the input directory + const files = fs.readdirSync(inputDir); + + for (const filename of files) { + if (filename.endsWith(".svg")) { + const inputPath = path.join(inputDir, filename); + const outputPath = path.join(outputDir, filename.replace('.svg', '.jpg')); + + // Read the SVG content + const svgContent = fs.readFileSync(inputPath, 'utf8'); + + // Extract width and height from SVG (Optional: If SVG has explicit size) + const matchWidth = svgContent.match(/width="([0-9]+)"/); + const matchHeight = svgContent.match(/height="([0-9]+)"/); + + const width = matchWidth ? parseInt(matchWidth[1], 10) : 1200; // Default to 1200px + const height = matchHeight ? parseInt(matchHeight[1], 10) : 675; // Default to 630px + + // Set the viewport size to match SVG size + await page.setViewport({ width, height }); + + // Set SVG content inside an HTML wrapper + await page.setContent(` + + +
+ ${svgContent} +
+ + + `); + + // Take a screenshot and save as JPEG + await page.screenshot({ + path: outputPath, + type: 'jpeg', + quality: 100, + clip: { x: 0, y: 0, width, height } // Ensure clipping matches viewport + }); + + console.log(`Converted: ${filename} -> ${outputPath}`); + } } - } - - await browser.close(); - } - // Execute conversion - const inputDir = 'dist/assets/images/social-preview-images/'; - const outputDir = 'dist/assets/images/social-preview-images/'; - await convertSvgToJpeg(inputDir, outputDir); - }); - - // Allows you to debug a json object in eleventy templates data | stringify - eleventyConfig.addFilter("stringify", (data) => { - return JSON.stringify(data, null, "\t"); - }); + await browser.close(); + } - eleventyConfig.addFilter("customSlug", function (value) { - if (!value) return "fallback-title"; // Fallback for empty titles - return slugify(value, { - lower: true, // Convert to lowercase - remove: /[^\w\s-]/g // Remove all non-word characters except spaces and dashes - }).replace(/\s+/g, '-'); // Replace spaces with dashes (extra safety) + // Execute conversion + const inputDir = 'dist/assets/images/social-preview-images/'; + const outputDir = 'dist/assets/images/social-preview-images/'; + await convertSvgToJpeg(inputDir, outputDir); }); + } // https://www.11ty.dev/docs/permalinks/#remove-trailing-slashes // Dropping these normalizes the URls between sitemap.xml and canonical, which is important for indexing. diff --git a/package-lock.json b/package-lock.json index 77783091..0fd04426 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,9 @@ "hasInstallScript": true, "license": "MIT", "dependencies": { - "luxon": "^3.5.0" + "dotenv": "^16.4.7", + "luxon": "^3.5.0", + "marked": "^15.0.4" }, "devDependencies": { "@11ty/eleventy": "^2.0.1", @@ -2488,6 +2490,17 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, + "node_modules/dotenv": { + "version": "16.4.7", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", + "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/duplexer": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.1.tgz", @@ -4336,6 +4349,17 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true }, + "node_modules/marked": { + "version": "15.0.4", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.4.tgz", + "integrity": "sha512-TCHvDqmb3ZJ4PWG7VEGVgtefA5/euFmsIhxtD0XsBxI39gUSKL81mIRFdt0AiNQozUahd4ke98ZdirExd/vSEw==", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/maximatch": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/maximatch/-/maximatch-0.1.0.tgz", @@ -9260,6 +9284,11 @@ "domhandler": "^4.2.0" } }, + "dotenv": { + "version": "16.4.7", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", + "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==" + }, "duplexer": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.1.tgz", @@ -10589,6 +10618,11 @@ } } }, + "marked": { + "version": "15.0.4", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.4.tgz", + "integrity": "sha512-TCHvDqmb3ZJ4PWG7VEGVgtefA5/euFmsIhxtD0XsBxI39gUSKL81mIRFdt0AiNQozUahd4ke98ZdirExd/vSEw==" + }, "maximatch": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/maximatch/-/maximatch-0.1.0.tgz", diff --git a/package.json b/package.json index a974594c..76c6684d 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,8 @@ "defaults" ], "dependencies": { - "luxon": "^3.5.0" + "dotenv": "^16.4.7", + "luxon": "^3.5.0", + "marked": "^15.0.4" } } diff --git a/sent-campaigns.json b/sent-campaigns.json new file mode 100644 index 00000000..2d4e807d --- /dev/null +++ b/sent-campaigns.json @@ -0,0 +1,4 @@ +[ + "'Workshop: Custom Form Controls with Web Components'", + "Fronteers BE meetup bij In The Pocket in Gent" +] \ No newline at end of file diff --git a/utils/filters.js b/utils/filters.js index 131dc976..4e073e3d 100644 --- a/utils/filters.js +++ b/utils/filters.js @@ -48,4 +48,50 @@ module.exports = { }); }, -}; \ No newline at end of file + readablePostDate(dateObj) { + return DateTime.fromJSDate(dateObj, { + zone: "Europe/Amsterdam", + }).setLocale('en').toLocaleString(DateTime.DATE_FULL); + }, + + postDate(dateObj) { + return DateTime.fromJSDate(dateObj, { + zone: "Europe/Amsterdam", + }).setLocale('en').toISODate(); + }, + + splitlines(input) { + const parts = input.split(' '); + const lines = parts.reduce(function (prev, current) { + if (!prev.length) { + return [current]; + } + + let lastOne = prev[prev.length - 1]; + + if (lastOne.length + current.length > 23) { + return [...prev, current]; + } + + prev[prev.length - 1] = lastOne + ' ' + current; + return prev; + }, []); + + return lines; + }, + + stringify(data) { + return JSON.stringify(data, null, "\t"); + }, + + customSlug(value) { + if (!value) return "fallback-title"; // Fallback for empty titles + return strToSlug(value).replace(/\s+/g, '-'); // Replace spaces with dashes + }, + + localizedDate(dateObj, locale = "en") { + return DateTime.fromJSDate(dateObj) + .setLocale(locale) + .toFormat("d LLLL yyyy"); + } +}; diff --git a/utils/listmonk.js b/utils/listmonk.js new file mode 100644 index 00000000..bfae2b7a --- /dev/null +++ b/utils/listmonk.js @@ -0,0 +1,118 @@ +const fs = require('fs'); +const { marked } = require('marked'); +const { JSDOM } = require('jsdom'); + +const sentCampaignsFile = './sent-campaigns.json'; + +// Load sent campaigns +function getSentCampaigns() { + if (!fs.existsSync(sentCampaignsFile)) { + fs.writeFileSync(sentCampaignsFile, JSON.stringify([])); // Create file if it doesn't exist + } + return JSON.parse(fs.readFileSync(sentCampaignsFile)); +} + +// Save sent campaigns +function saveSentCampaigns(sentCampaigns) { + fs.writeFileSync(sentCampaignsFile, JSON.stringify(sentCampaigns, null, 2)); +} + +// Check and send campaign +async function checkAndSendCampaign(post) { + const sentCampaigns = getSentCampaigns(); + + if (sentCampaigns.includes(post.title)) { + console.log(`Campaign already sent for: ${post.title}`); + return; // Skip if already sent + } + + console.log(`Creating campaign for: ${post.title}`); + await createListmonkCampaign(post); // Send campaign + sentCampaigns.push(post.title); // Add to sent list + saveSentCampaigns(sentCampaigns); // Update the list +} + +function extractFrontMatter(content) { + const match = /---\n([\s\S]*?)\n---/.exec(content); // Extract YAML frontmatter + const body = content.replace(/---\n[\s\S]*?\n---/, '').trim(); // Extract body + + const metadata = {}; + if (match && match[1]) { + match[1].split('\n').forEach(line => { + const colonIndex = line.indexOf(':'); // Find first colon only + if (colonIndex !== -1) { + const key = line.substring(0, colonIndex).trim(); // Key before colon + const value = line.substring(colonIndex + 1).trim(); // Value after colon + metadata[key] = value; // Store in metadata object + } + }); + } + + metadata.content = body; // Add body content + return metadata; +} + +async function createListmonkCampaign(post) { + const url = process.env.LISTMONK_URL; // Replace with your Listmonk URL + const apiKey = process.env.LISTMONK_API_KEY; // Replace with your API key + const apiUser = process.env.LISTMONK_API_USER; + const baseUrl = 'https://www.fronteers.nl'; // Base URL for links + + let htmlContent = marked(post.content); + + // Step 2: Rewrite relative links to full links + const dom = new JSDOM(htmlContent); // Create a DOM instance + const document = dom.window.document; + + // Process all tags for 'href' + document.querySelectorAll('a').forEach(link => { + const href = link.getAttribute('href'); + if (href && !href.startsWith('http')) { + link.setAttribute('href', new URL(href, baseUrl).href); // Resolve relative link + } + }); + + // Process all tags for 'src' + document.querySelectorAll('img').forEach(img => { + const src = img.getAttribute('src'); + if (src && !src.startsWith('http')) { + img.setAttribute('src', new URL(src, baseUrl).href); // Resolve relative image source + } + }); + + // Convert DOM back to HTML + htmlContent = dom.serialize(); + + const payload = { + name: post.title, + subject: post.title, + content_type: 'html', + body: `

${post.title}

${htmlContent}

`, // Email content + type: 'regular', + lists: [1], // Replace with your list ID + }; + + // console.log('Campaign Payload:', JSON.stringify(payload, null, 2)); // Log the payload + + try { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `token ${apiUser}:${apiKey}`, + }, + body: JSON.stringify(payload), + }); + + const data = await response.json(); + console.log('Campaign Created:', data); + } catch (error) { + console.error('Error creating campaign:', error); + } +} + +// Export functions +module.exports = { + extractFrontMatter, + checkAndSendCampaign, +};