diff --git a/packages/content-engine/package.json b/packages/content-engine/package.json
index 3207229..a216c04 100644
--- a/packages/content-engine/package.json
+++ b/packages/content-engine/package.json
@@ -20,6 +20,7 @@
"dependencies": {
"@mintel/journaling": "workspace:*",
"@mintel/meme-generator": "workspace:*",
+ "@mintel/thumbnail-generator": "workspace:*",
"dotenv": "^17.3.1",
"openai": "^4.82.0"
},
diff --git a/packages/content-engine/src/generator.ts b/packages/content-engine/src/generator.ts
index 8b87922..5101408 100644
--- a/packages/content-engine/src/generator.ts
+++ b/packages/content-engine/src/generator.ts
@@ -1,5 +1,5 @@
import OpenAI from "openai";
-import { ResearchAgent, Fact, SocialPost } from "@mintel/journaling";
+import { ResearchAgent, type Fact, type SocialPost } from "@mintel/journaling";
import { MemeGenerator, MemeSuggestion } from "@mintel/meme-generator";
import * as fs from "node:fs/promises";
import * as path from "node:path";
@@ -237,11 +237,21 @@ REGELN:
console.log(` → ${factInsertions.length} fact enrichments planned`);
}
- // ----- STEP 1.5: Social Media Search -----
- console.log("📱 Identifying real social media posts...");
- const socialPosts = await this.researchAgent.findSocialPosts(
- content.substring(0, 200),
- );
+ // ----- STEP 1.5: Social Media Extraction (no LLM — regex only) -----
+ console.log("📱 Extracting existing social media embeds...");
+ const socialPosts = this.researchAgent.extractSocialPosts(content);
+
+ // If none exist, fetch real ones via Serper API
+ if (socialPosts.length === 0) {
+ console.log(
+ " → None found. Fetching real social posts via Serper API...",
+ );
+ const newPosts = await this.researchAgent.fetchRealSocialPosts(
+ content.slice(0, 500),
+ );
+ socialPosts.push(...newPosts);
+ }
+
if (socialPosts.length > 0) {
console.log(
`📝 Planning placement for ${socialPosts.length} social media posts...`,
@@ -593,7 +603,7 @@ RULES:
- youtube ->
- twitter ->
- linkedin ->
-- Add a 1-sentence intro paragraph above the embed to contextualize it.
+- Add a 1-sentence intro paragraph above the embed to contextualize it naturally in the flow of the text (e.g. "Wie Experte XY im folgenden Video detailliert erklärt:"). This context is MANDATORY. Do not just drop the Component without text reference.
CONTEXT:
${context.slice(0, 3000)}
@@ -842,6 +852,11 @@ Tone: ${tone}.
Facts: ${factsContext}
${componentsContext}
+BLOG POST BEST PRACTICES (MANDATORY):
+- DEVIL'S ADVOCATE: Füge zwingend eine kurze kritische Sektion ein (z.B. mit \`\` oder \`\`), in der du offen die Nachteile/Kosten/Haken deiner eigenen Lösung ansprichst ("Der Haken an der Sache...").
+- FAQ GENERATOR: Am absoluten Ende des Artikels erstellst du zwingend eine Markdown-Liste mit den 3 wichtigsten Fragen (FAQ) und Antworten (jeweils 2 Sätze) für Google Rich Snippets.
+- Nutze wo passend die obigen React-Komponenten für ein hochwertiges Layout.
+
Format as Markdown. Start with # H1.
For places where a diagram would help, insert:
Return ONLY raw content.`,
@@ -891,6 +906,7 @@ RULES:
- CRITICAL: Generate ONLY ONE single connected graph. Do NOT generate multiple independent graphs or isolated subgraphs in the same Mermaid block.
- No nested subgraphs. Keep instructions short.
- Use double-quoted labels for nodes: A["Label"]
+- VERY CRITICAL: DO NOT use curly braces '{}' or brackets '[]' inside labels unless they are wrapped in double quotes (e.g. A["Text {with braces}"]).
- VERY CRITICAL: DO NOT use any HTML tags (no
, no
, no , etc).
- VERY CRITICAL: DO NOT use special characters like '&', '<', '>', or double-quotes inside the label strings. They break the mermaid parser in our environment.
- Return ONLY the raw mermaid code. No markdown blocks, no backticks.
diff --git a/packages/content-engine/src/orchestrator.ts b/packages/content-engine/src/orchestrator.ts
index a5926b3..59598df 100644
--- a/packages/content-engine/src/orchestrator.ts
+++ b/packages/content-engine/src/orchestrator.ts
@@ -1,11 +1,13 @@
import OpenAI from "openai";
-import { ResearchAgent, Fact, SocialPost } from "@mintel/journaling";
+import { ResearchAgent, type Fact, type SocialPost } from "@mintel/journaling";
+import { ThumbnailGenerator } from "@mintel/thumbnail-generator";
import { ComponentDefinition } from "./generator";
import * as fs from "node:fs/promises";
import * as path from "node:path";
export interface OrchestratorConfig {
apiKey: string;
+ replicateApiKey?: string;
model?: string;
}
@@ -14,6 +16,7 @@ export interface OptimizationTask {
projectContext: string;
availableComponents?: ComponentDefinition[];
instructions?: string;
+ internalLinks?: { title: string; slug: string }[];
}
export interface OptimizeFileOptions {
@@ -24,6 +27,7 @@ export interface OptimizeFileOptions {
export class AiBlogPostOrchestrator {
private openai: OpenAI;
private researchAgent: ResearchAgent;
+ private thumbnailGenerator?: ThumbnailGenerator;
private model: string;
constructor(config: OrchestratorConfig) {
@@ -37,6 +41,11 @@ export class AiBlogPostOrchestrator {
},
});
this.researchAgent = new ResearchAgent(config.apiKey);
+ if (config.replicateApiKey) {
+ this.thumbnailGenerator = new ThumbnailGenerator({
+ replicateApiKey: config.replicateApiKey,
+ });
+ }
}
/**
@@ -76,9 +85,15 @@ export class AiBlogPostOrchestrator {
const content = await fs.readFile(absPath, "utf8");
- const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
- const frontmatter = fmMatch ? fmMatch[0] : "";
- const body = fmMatch ? content.slice(frontmatter.length).trim() : content;
+ // Idea 4: We no longer split frontmatter and body. We pass the whole file
+ // to the LLM so it can optimize the SEO title and description.
+
+ // Idea 1: Build Internal Link Graph
+ const blogDir = path.dirname(absPath);
+ const internalLinks = await this.buildInternalLinkGraph(
+ blogDir,
+ path.basename(absPath),
+ );
console.log(`📖 Loading context from: ${options.contextDir}`);
const projectContext = await this.loadContext(options.contextDir);
@@ -89,50 +104,199 @@ export class AiBlogPostOrchestrator {
}
const optimizedContent = await this.optimizeDocument({
- content: body,
+ content: content,
projectContext,
availableComponents: options.availableComponents,
+ internalLinks: internalLinks, // pass to orchestrator
});
- const finalOutput = frontmatter
- ? `${frontmatter}\n\n${optimizedContent}`
- : optimizedContent;
+ // Idea 4b: Extract the potentially updated title to rename the file (SEO Slug)
+ const newFmMatch = optimizedContent.match(/^---\s*\n([\s\S]*?)\n---/);
+ let finalPath = absPath;
+ let finalSlug = path.basename(absPath, ".mdx");
- await fs.writeFile(`${absPath}.bak`, content); // Keep simple backup
- await fs.writeFile(absPath, finalOutput);
- console.log(`✅ Saved optimized file to: ${absPath}`);
+ if (newFmMatch && newFmMatch[1]) {
+ const titleMatch = newFmMatch[1].match(/title:\s*["']([^"']+)["']/);
+ if (titleMatch && titleMatch[1]) {
+ const newTitle = titleMatch[1];
+ // Generate SEO Slug
+ finalSlug = newTitle
+ .toLowerCase()
+ .replace(/ä/g, "ae")
+ .replace(/ö/g, "oe")
+ .replace(/ü/g, "ue")
+ .replace(/ß/g, "ss")
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "");
+
+ const newAbsPath = path.join(path.dirname(absPath), `${finalSlug}.mdx`);
+ if (newAbsPath !== absPath) {
+ console.log(
+ `🔄 SEO Title changed! Renaming file to: ${finalSlug}.mdx`,
+ );
+ // Delete old file if the title changed significantly
+ try {
+ await fs.unlink(absPath);
+ } catch (e) {
+ /* ignore */
+ }
+ finalPath = newAbsPath;
+ }
+ }
+ }
+
+ // Idea 5: Automatic Thumbnails
+ let finalContent = optimizedContent;
+
+ // Skip if thumbnail already exists in frontmatter
+ const hasExistingThumbnail = /thumbnail:\s*["'][^"']+["']/.test(
+ finalContent,
+ );
+
+ if (this.thumbnailGenerator && !hasExistingThumbnail) {
+ console.log("🎨 Phase 5: Generating visual thumbnail...");
+ try {
+ const visualPrompt = await this.generateVisualPrompt(finalContent);
+ // We assume public dir is relative to where this runs, usually monorepo root or apps/web
+ const webPublicDir = path.resolve(process.cwd(), "apps/web/public");
+ const thumbnailRelPath = `/blog/${finalSlug}.png`;
+ const thumbnailAbsPath = path.join(
+ webPublicDir,
+ "blog",
+ `${finalSlug}.png`,
+ );
+
+ await this.thumbnailGenerator.generateImage(
+ visualPrompt,
+ thumbnailAbsPath,
+ );
+
+ // Update frontmatter with thumbnail (SEO: we also want it as a hero)
+ if (finalContent.includes("thumbnail:")) {
+ finalContent = finalContent.replace(
+ /thumbnail:\s*["'].*?["']/,
+ `thumbnail: "${thumbnailRelPath}"`,
+ );
+ } else {
+ finalContent = finalContent.replace(
+ /(title:\s*["'].*?["'])/,
+ `$1\nthumbnail: "${thumbnailRelPath}"`,
+ );
+ }
+ } catch (e) {
+ console.warn("⚠️ Thumbnail generation failed, skipping:", e);
+ }
+ }
+
+ await fs.writeFile(finalPath, finalContent);
+ console.log(`✅ Saved optimized file to: ${finalPath}`);
+ }
+
+ private async generateVisualPrompt(content: string): Promise {
+ const response = await this.openai.chat.completions.create({
+ model: this.model,
+ messages: [
+ {
+ role: "system",
+ content: `You are a Visual Discovery Agent for an architectural design system.
+Review the provided blog post and create a 1-sentence abstract visual description for an image generator (like Flux).
+
+THEME: Technical blueprint / structural illustration.
+STYLE: Clean lines, geometric shapes, monochrome base with one highlighter accent color (green, pink, or yellow).
+NO TEXT. NO PEOPLE. NO REALISTIC PHOTOS.
+FOCUS: The core metaphor or technical concept of the article.
+
+Example output: "A complex network of glowing fiber optic nodes forming a recursive pyramid structure, technical blue lineart style."`,
+ },
+ { role: "user", content: content.slice(0, 5000) },
+ ],
+ max_tokens: 100,
+ });
+ return (
+ response.choices[0].message.content ||
+ "Technical architectural blueprint of a digital system"
+ );
+ }
+
+ private async buildInternalLinkGraph(
+ blogDir: string,
+ currentFile: string,
+ ): Promise<{ title: string; slug: string }[]> {
+ try {
+ const files = await fs.readdir(blogDir);
+ const mdxFiles = files.filter(
+ (f) => f.endsWith(".mdx") && f !== currentFile,
+ );
+ const graph: { title: string; slug: string }[] = [];
+
+ for (const file of mdxFiles) {
+ const fileContent = await fs.readFile(path.join(blogDir, file), "utf8");
+ const titleMatch = fileContent.match(/title:\s*["']([^"']+)["']/);
+ if (titleMatch && titleMatch[1]) {
+ graph.push({
+ title: titleMatch[1],
+ slug: `/blog/${file.replace(".mdx", "")}`,
+ });
+ }
+ }
+ return graph;
+ } catch (e) {
+ console.warn("Could not build internal link graph", e);
+ return [];
+ }
}
/**
* Executes the 3-step optimization pipeline:
* 1. Fakten recherchieren
- * 2. Social Posts recherchieren
+ * 2. Bestehende Social Posts extrahieren (kein LLM — nur Regex)
* 3. AI anweisen daraus Artikel zu erstellen
*/
async optimizeDocument(task: OptimizationTask): Promise {
console.log(`🚀 Starting AI Orchestration Pipeline (${this.model})...`);
- // 1. Fakten recherchieren
- console.log("1️⃣ Recherchiere Fakten...");
+ // 1. Fakten & Konkurrenz recherchieren
+ console.log("1️⃣ Recherchiere Fakten und analysiere Konkurrenz...");
const researchTopics = await this.identifyTopics(task.content);
const facts: Fact[] = [];
- for (const topic of researchTopics) {
- const topicFacts = await this.researchAgent.researchTopic(topic);
- facts.push(...topicFacts);
- }
+ const competitorInsights: string[] = [];
- // 2. Social Posts recherchieren
- console.log(
- "2️⃣ Recherchiere Social Media Posts (YouTube, Twitter, LinkedIn)...",
- );
- // Use the first 2000 chars to find relevant social posts
- const socialPosts = await this.researchAgent.findSocialPosts(
- task.content.substring(0, 2000),
+ // Paralellize competitor research and fact research
+ await Promise.all(
+ researchTopics.map(async (topic) => {
+ const [topicFacts, insights] = await Promise.all([
+ this.researchAgent.researchTopic(topic),
+ this.researchAgent.researchCompetitors(topic),
+ ]);
+ facts.push(...topicFacts);
+ competitorInsights.push(...insights);
+ }),
);
+ // 2. Bestehende Social Posts aus dem Content extrahieren (deterministisch, kein LLM)
+ console.log("2️⃣ Extrahiere bestehende Social Media Embeds aus Content...");
+ const socialPosts = this.researchAgent.extractSocialPosts(task.content);
+
+ // Wenn keine vorhanden sind, besorge echte von der Serper API
+ if (socialPosts.length === 0) {
+ console.log(
+ " → Keine bestehenden Posts gefunden. Suche neue über Serper API...",
+ );
+ const realPosts = await this.researchAgent.fetchRealSocialPosts(
+ task.content.slice(0, 500),
+ );
+ socialPosts.push(...realPosts);
+ }
+
// 3. AI anweisen daraus Artikel zu erstellen
console.log("3️⃣ Erstelle optimierten Artikel (Agentic Rewrite)...");
- return await this.compileArticle(task, facts, socialPosts);
+ return await this.compileArticle(
+ task,
+ facts,
+ competitorInsights,
+ socialPosts,
+ task.internalLinks || [],
+ );
}
private async identifyTopics(content: string): Promise {
@@ -170,22 +334,55 @@ Return ONLY the JSON.`,
private async compileArticle(
task: OptimizationTask,
facts: Fact[],
+ competitorInsights: string[],
socialPosts: SocialPost[],
+ internalLinks: { title: string; slug: string }[],
retryCount = 0,
): Promise {
const factsText = facts
.map((f, i) => `${i + 1}. ${f.statement} [Source: ${f.source}]`)
.join("\n");
- const socialText = socialPosts
- .map(
- (p, i) =>
- `Platform: ${p.platform}, ID: ${p.embedId} (${p.description})`,
- )
- .join("\n");
+ let socialText = `CRITICAL RULE: NO VERIFIED SOCIAL MEDIA POSTS FOUND. You MUST NOT use , , or under ANY circumstances in this article. DO NOT hallucinate IDs.`;
+
+ if (socialPosts.length > 0) {
+ const allowedTags: string[] = [];
+ if (socialPosts.some((p) => p.platform === "youtube"))
+ allowedTags.push('');
+ if (socialPosts.some((p) => p.platform === "twitter"))
+ allowedTags.push('');
+ if (socialPosts.some((p) => p.platform === "linkedin"))
+ allowedTags.push('');
+
+ socialText = `Social Media Posts to embed (use ONLY these tags, do not use others: ${allowedTags.join(", ")}):\n${socialPosts.map((p) => `Platform: ${p.platform}, ID: ${p.embedId} (${p.description})`).join("\n")}\nCRITICAL: Do not invent any IDs that are not explicitly listed in the list above.`;
+ }
const componentsText = (task.availableComponents || [])
- .map((c) => `<${c.name}>: ${c.description}\n Example: ${c.usageExample}`)
+ .filter((c) => {
+ if (
+ c.name === "YouTubeEmbed" &&
+ !socialPosts.some((p) => p.platform === "youtube")
+ )
+ return false;
+ if (
+ c.name === "TwitterEmbed" &&
+ !socialPosts.some((p) => p.platform === "twitter")
+ )
+ return false;
+ if (
+ c.name === "LinkedInEmbed" &&
+ !socialPosts.some((p) => p.platform === "linkedin")
+ )
+ return false;
+ return true;
+ })
+ .map((c) => {
+ // Ensure LinkedInEmbed usage example consistently uses 'url'
+ if (c.name === "LinkedInEmbed") {
+ return `<${c.name}>: ${c.description}\n Example: `;
+ }
+ return `<${c.name}>: ${c.description}\n Example: ${c.usageExample}`;
+ })
.join("\n\n");
const response = await this.openai.chat.completions.create({
@@ -202,30 +399,47 @@ CONTEXT & RULES:
Project Context / Tone:
${task.projectContext}
-Facts to weave in:
-${factsText || "None"}
+FACTS TO INTEGRATE:
+${factsText || "No new facts needed."}
-Social Media Posts to embed (use , , or ):
-${socialText || "None"}
+COMPETITOR BENCHMARK (TOP RANKING ARTICLES):
+Here are snippets from the top 5 ranking Google articles for this topic. Read them carefully and ensure our article covers these topics but is fundamentally BETTER, deeper, and more authoritative:
+${competitorInsights.length > 0 ? competitorInsights.join("\n") : "No competitor insights found."}
-Available MDX Components you can use contextually:
-${componentsText || "None"}
+AVAILABLE UI COMPONENTS:
+${componentsText}
+
+SOCIAL MEDIA POSTS:
+${socialText}
+
+INTERNAL LINKING GRAPH:
+Hier sind unsere existierenden Blog-Posts (Titel und URL-Slug). Finde 2-3 passende Stellen im Text, um organisch mit regulärem Markdown (\`[passender Text]([slug])\`) auf diese Posts zu verlinken. Nutze KEIN für B2B-interne Links.
+${internalLinks.length > 0 ? internalLinks.map((l) => `- "${l.title}" -> ${l.slug}`).join("\n") : "Keine internen Links verfügbar."}
Special Instructions from User:
${task.instructions || "None"}
BLOG POST BEST PRACTICES (MANDATORY):
+- DEVIL'S ADVOCATE: Füge zwingend eine kurze kritische Sektion ein (z.B. mit \`\` oder \`\`), in der du offen die Nachteile/Kosten/Haken deiner eigenen Lösung ansprichst ("Der Haken an der Sache..."). Das baut Vertrauen bei B2B Entscheidenden auf.
+- FAQ GENERATOR: Am absoluten Ende des Artikels erstellst du zwingend eine Markdown-Liste mit den 3 wichtigsten Fragen (FAQ) und Antworten (jeweils 2 Sätze) für Google Rich Snippets. Nutze dazu das \`\` Component oder normales Markdown.
+- SUBTLE CTAs: Webe 1-2 subtile CTAs für High-End Website Entwicklung ein (Beispiel: \`\` oder \`\`). Platziere diese zwingend organisch nach Abschnitten mit hohem Mehrwert.
+- Zitat-Varianten: Wenn du Organisationen oder Studien zitierst, nutze \`\`. Für Personen lass \`isCompany\` weg.
- Füge zwingend ein prägnantes 'TL;DR' ganz am Anfang ein.
- Füge ein sauberes '' ein.
- Verwende unsere Komponenten stilvoll für Visualisierungen.
- Agiere als hochprofessioneller Digital Architect und entferne alte MDX-Metadaten im Body.
-- Fazit: Schließe JEDEN Artikel ZWINGEND mit einem starken, klaren 'Fazit' ab (z.B. als Fazit: ...
gefolgt von deinen Empfehlungen).
+- Fazit: Schließe JEDEN Artikel ZWINGEND mit einem starken, klaren 'Fazit' ab.
+
+STRICT MDX OUTPUT RULES:
+1. ONLY use the exact components defined above.
+2. For Social Media Embeds, you MUST ONLY use the EXACT IDs provided in the list above. Do NOT invent IDs.
+3. If ANY verified social media posts are provided, you MUST integrate at least one naturally with a contextual sentence.
+4. Keep the original content blocks and headings as much as possible, just improve flow.
+5. FRONTMATTER SEO (Idea 4): Ich übergebe dir die KOMPLETTE Datei inklusive Markdown-Frontmatter (--- ... ---). Du MUSST das Frontmatter ebenfalls zurückgeben! Optimiere darin den \`title\` und die \`description\` maximal für B2B SEO. Lasse die anderen Keys im Frontmatter (date, tags) unangetastet.
CRITICAL GUIDELINES (NEVER BREAK THESE):
-1. ONLY return the content for the BODY of the MDX file.
-2. DO NOT INCLUDE FRONTMATTER (blocks starting and ending with ---). I ALREADY HAVE THE FRONTMATTER.
-3. DO NOT REPEAT METADATA IN THE BODY. Do not output lines like "title: ...", "description: ...", "date: ..." inside the text.
-4. DO NOT INCLUDE MARKDOWN WRAPPERS (do not wrap in \`\`\`mdx ... \`\`\`).
+1. THE OUTPUT MUST START WITH YAML FRONTMATTER AND END WITH THE MDX BODY.
+2. DO NOT INCLUDE MARKDOWN WRAPPERS (do not wrap in \`\`\`mdx ... \`\`\`).
5. Be clean. Do NOT clump all components together. Provide 3-4 paragraphs of normal text between visual items.
6. If you insert components, ensure their syntax is 100% valid JSX/MDX.
7. CRITICAL MERMAID RULE: If you use , the inner content MUST be 100% valid Mermaid.js syntax. NO HTML inside labels. NO quotes inside brackets without valid syntax.
@@ -239,7 +453,7 @@ CRITICAL GUIDELINES (NEVER BREAK THESE):
});
let rawContent = response.choices[0].message.content || task.content;
- rawContent = this.cleanResponse(rawContent);
+ rawContent = this.cleanResponse(rawContent, socialPosts);
// Validation Layer: Check Mermaid syntax
if (retryCount < 2 && rawContent.includes("")) {
@@ -266,7 +480,9 @@ CRITICAL GUIDELINES (NEVER BREAK THESE):
content: `The previous attempt failed because you generated invalid Mermaid.js syntax. Please rewrite the MDX and FIX the following Mermaid errors. \n\nErrors:\n${errorFeedback}\n\nOriginal Draft:\n${task.content}`,
},
facts,
+ competitorInsights,
socialPosts,
+ internalLinks,
retryCount + 1,
);
}
@@ -320,11 +536,7 @@ CRITICAL GUIDELINES (NEVER BREAK THESE):
}
}
- /**
- * Post-processing to ensure the AI didn't include "help" text,
- * duplicate frontmatter, or markdown wrappers.
- */
- private cleanResponse(content: string): string {
+ private cleanResponse(content: string, socialPosts: SocialPost[]): string {
let cleaned = content.trim();
// 1. Strip Markdown Wrappers (e.g. ```mdx ... ```)
@@ -334,16 +546,52 @@ CRITICAL GUIDELINES (NEVER BREAK THESE):
.replace(/\n?```\s*$/, "");
}
- // 2. Strip redundant frontmatter (the AI sometimes helpfully repeats it)
- // Look for the --- delimiters and remove the block if it exists
- const fmRegex = /^---\s*\n([\s\S]*?)\n---\s*\n?/;
- const match = cleaned.match(fmRegex);
- if (match) {
- console.log(
- "♻️ Stripping redundant frontmatter detected in AI response...",
- );
- cleaned = cleaned.replace(fmRegex, "").trim();
- }
+ // 2. We NO LONGER strip redundant frontmatter, because we requested the LLM to output it.
+ // Ensure the output actually has frontmatter, if not, something went wrong, but we just pass it along.
+
+ // 3. Strip any social embeds the AI hallucinated (IDs not in our extracted set)
+ const knownYtIds = new Set(
+ socialPosts.filter((p) => p.platform === "youtube").map((p) => p.embedId),
+ );
+ const knownTwIds = new Set(
+ socialPosts.filter((p) => p.platform === "twitter").map((p) => p.embedId),
+ );
+ const knownLiIds = new Set(
+ socialPosts
+ .filter((p) => p.platform === "linkedin")
+ .map((p) => p.embedId),
+ );
+
+ cleaned = cleaned.replace(
+ /]*videoId="([^"]+)"[^>]*\/>/gi,
+ (tag, id) => {
+ if (knownYtIds.has(id)) return tag;
+ console.log(
+ `🛑 Stripped hallucinated YouTubeEmbed with videoId="${id}"`,
+ );
+ return "";
+ },
+ );
+
+ cleaned = cleaned.replace(
+ /]*tweetId="([^"]+)"[^>]*\/>/gi,
+ (tag, id) => {
+ if (knownTwIds.has(id)) return tag;
+ console.log(
+ `🛑 Stripped hallucinated TwitterEmbed with tweetId="${id}"`,
+ );
+ return "";
+ },
+ );
+
+ cleaned = cleaned.replace(
+ /]*(?:url|urn)="([^"]+)"[^>]*\/>/gi,
+ (tag, id) => {
+ if (knownLiIds.has(id)) return tag;
+ console.log(`🛑 Stripped hallucinated LinkedInEmbed with id="${id}"`);
+ return "";
+ },
+ );
return cleaned;
}
diff --git a/packages/journaling/src/agent.ts b/packages/journaling/src/agent.ts
index faace30..d0cad26 100644
--- a/packages/journaling/src/agent.ts
+++ b/packages/journaling/src/agent.ts
@@ -1,6 +1,7 @@
import OpenAI from "openai";
import { DataCommonsClient } from "./clients/data-commons";
import { TrendsClient } from "./clients/trends";
+import { SerperClient, type SerperVideoResult } from "./clients/serper";
export interface Fact {
statement: string;
@@ -20,6 +21,7 @@ export class ResearchAgent {
private openai: OpenAI;
private dcClient: DataCommonsClient;
private trendsClient: TrendsClient;
+ private serperClient: SerperClient;
constructor(apiKey: string) {
this.openai = new OpenAI({
@@ -31,7 +33,8 @@ export class ResearchAgent {
},
});
this.dcClient = new DataCommonsClient();
- this.trendsClient = new TrendsClient();
+ this.trendsClient = new TrendsClient(apiKey);
+ this.serperClient = new SerperClient(process.env.SERPER_API_KEY);
}
async researchTopic(topic: string): Promise {
@@ -107,120 +110,151 @@ Return JSON: { "facts": [ { "statement": "...", "source": "Organization Name Onl
return result.facts || [];
}
- async findSocialPosts(
+ /**
+ * Extracts existing social media embeds from MDX content via regex.
+ * No LLM involved — purely deterministic parsing.
+ * Only returns posts that are already present in the article.
+ */
+ extractSocialPosts(content: string): SocialPost[] {
+ const posts: SocialPost[] = [];
+
+ // YouTube:
+ const ytMatches = [
+ ...content.matchAll(/]*videoId="([^"]+)"[^>]*\/>/gi),
+ ];
+ for (const match of ytMatches) {
+ if (!posts.some((p) => p.embedId === match[1])) {
+ posts.push({
+ platform: "youtube",
+ embedId: match[1],
+ description: "Existing YouTube embed",
+ });
+ }
+ }
+
+ // Twitter/X:
+ const twMatches = [
+ ...content.matchAll(/]*tweetId="([^"]+)"[^>]*\/>/gi),
+ ];
+ for (const match of twMatches) {
+ if (!posts.some((p) => p.embedId === match[1])) {
+ posts.push({
+ platform: "twitter",
+ embedId: match[1],
+ description: "Existing Twitter/X embed",
+ });
+ }
+ }
+
+ // LinkedIn: or
+ const liMatches = [
+ ...content.matchAll(/]*(?:url|urn)="([^"]+)"[^>]*\/>/gi),
+ ];
+ for (const match of liMatches) {
+ if (!posts.some((p) => p.embedId === match[1])) {
+ posts.push({
+ platform: "linkedin",
+ embedId: match[1],
+ description: "Existing LinkedIn embed",
+ });
+ }
+ }
+
+ if (posts.length > 0) {
+ console.log(
+ `📱 Extracted ${posts.length} existing social media embed(s) from content`,
+ );
+ } else {
+ console.log(`📱 No existing social media embeds found in content`);
+ }
+
+ return posts;
+ }
+
+ /**
+ * Fetches real, verified social media posts using the Serper API (Google Video Search).
+ * This completely prevents hallucinations as it relies on actual search results.
+ */
+ async fetchRealSocialPosts(
topic: string,
- retries = 2,
- previousFailures: string[] = [],
+ retries = 1,
): Promise {
console.log(
- `📱 Searching for relevant Social Media Posts: "${topic}"${retries < 2 ? ` (Retry ${2 - retries}/2)` : ""}`,
+ `🌐 [Serper] Fetching real social media posts for topic: "${topic}"...`,
);
- const failureContext =
- previousFailures.length > 0
- ? `\nCRITICAL FAILURE WARNING: The following IDs you generated previously returned 404 Not Found and were Hallucinations: ${previousFailures.join(", ")}. You MUST provide REAL, verifiable IDs. If you cannot 100% guarantee an ID exists, return an empty array instead of guessing.`
- : "";
-
- const response = await this.openai.chat.completions.create({
- model: "google/gemini-2.5-pro",
+ // Step 1: Ask the LLM to generate a highly specific YouTube search query
+ // We want tutorials, explanations, or deep dives.
+ const queryGen = await this.openai.chat.completions.create({
+ model: "google/gemini-2.5-flash",
messages: [
{
role: "system",
- content: `You are a social media researcher finding high-value, real expert posts and videos to embed in a B2B Tech Blog post about: "${topic}".
-
-Your Goal: Identify 1-3 REAL, highly relevant social media posts (YouTube, Twitter/X, LinkedIn) that provide social proof, expert opinions, or deep dives.${failureContext}
-
-Constraint: You MUST provide the exact mathematical or alphanumeric ID for the embed.
-- YouTube: The 11-character video ID (e.g. "dQw4w9WgXcQ")
-- Twitter: The numerical tweet ID (e.g. "1753464161943834945")
-- LinkedIn: The activity URN (e.g. "urn:li:activity:7153664326573674496" or just the numerical 19-digit ID)
-
-Return JSON exactly as follows:
-{
- "posts": [
- { "platform": "youtube", "embedId": "dQw4w9WgXcQ", "description": "Google Web Dev explaining Core Web Vitals" }
- ]
-}
-Return ONLY the JSON.`,
+ content: `Generate a YouTube search query to find a high-quality, professional educational video about: "${topic}".
+Prefer official tech channels or well-known developers (e.g., Google Chrome Developers, Vercel, Theo - t3.gg, Fireship, etc.).
+Return a JSON object with a single string field "query". Example: {"query": "core web vitals explanation google developers"}.
+DO NOT USE QUOTES IN THE QUERY ITSELF.`,
},
],
response_format: { type: "json_object" },
});
- if (
- !response.choices ||
- response.choices.length === 0 ||
- !response.choices[0].message
- ) {
- console.warn(`⚠️ Social post search failed for concept: "${topic}"`);
+ try {
+ let queryStr = "";
+ const parsed = JSON.parse(
+ queryGen.choices[0].message.content || '{"query": ""}',
+ );
+ queryStr = parsed.query || `${topic} tutorial explanation`;
+
+ // Step 2: Search via Serper Video Search
+ const videos = await this.serperClient.searchVideos(queryStr);
+
+ if (!videos || videos.length === 0) {
+ console.warn(`⚠️ [Serper] No videos found for query: "${queryStr}"`);
+ if (retries > 0) return this.fetchRealSocialPosts(topic, retries - 1);
+ return [];
+ }
+
+ // Filter for youtube results
+ const ytVideos = videos.filter(
+ (v) => v.link && v.link.includes("youtube.com/watch"),
+ );
+
+ if (ytVideos.length === 0) {
+ console.warn(`⚠️ [Serper] No YouTube videos in search results.`);
+ if (retries > 0) return this.fetchRealSocialPosts(topic, retries - 1);
+ return [];
+ }
+
+ // Pick the best one (usually the first result)
+ const bestVideo = ytVideos[0];
+
+ // Extract the 11-char video ID from the link (e.g., https://www.youtube.com/watch?v=dQw4w9WgXcQ)
+ const urlObj = new URL(bestVideo.link);
+ const videoId = urlObj.searchParams.get("v");
+
+ if (!videoId) {
+ console.warn(
+ `⚠️ [Serper] Could not extract video ID from: ${bestVideo.link}`,
+ );
+ return [];
+ }
+
+ console.log(
+ `✅ [Serper] Found valid YouTube Video: ${videoId} ("${bestVideo.title}")`,
+ );
+
+ return [
+ {
+ platform: "youtube",
+ embedId: videoId,
+ description: bestVideo.title || "YouTube Video",
+ },
+ ];
+ } catch (e) {
+ console.error("❌ Failed to fetch real social posts:", e);
return [];
}
-
- const result = JSON.parse(response.choices[0].message.content || "{}");
- const rawPosts: SocialPost[] = result.posts || [];
-
- // CRITICAL WORKFLOW FIX: Absolutely forbid hallucinations by verifying via oEmbed APIs
- const verifiedPosts: SocialPost[] = [];
- if (rawPosts.length > 0) {
- console.log(
- `🛡️ Verifying ${rawPosts.length} generated social ID(s) against network...`,
- );
- }
-
- const failedIdsForThisRun: string[] = [];
-
- for (const post of rawPosts) {
- let isValid = false;
- try {
- if (post.platform === "youtube") {
- const res = await fetch(
- `https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${post.embedId}`,
- );
- isValid = res.ok;
- } else if (post.platform === "twitter") {
- const res = await fetch(
- `https://publish.twitter.com/oembed?url=https://twitter.com/x/status/${post.embedId}`,
- );
- isValid = res.ok;
- } else if (post.platform === "linkedin") {
- // LinkedIn doesn't have an unauthenticated oEmbed, so we use heuristic URL/URN format validation
- if (
- post.embedId.includes("urn:li:") ||
- post.embedId.includes("linkedin.com") ||
- /^\d{19}$/.test(post.embedId)
- ) {
- isValid = true;
- }
- }
- } catch (e) {
- isValid = false;
- }
-
- if (isValid) {
- verifiedPosts.push(post);
- console.log(
- `✅ Verified real post ID: ${post.embedId} (${post.platform})`,
- );
- } else {
- failedIdsForThisRun.push(post.embedId);
- console.warn(
- `🛑 Dropped hallucinated or dead post ID: ${post.embedId} (${post.platform})`,
- );
- }
- }
-
- // AGENT SELF-HEALING: If all found posts were hallucinations and we have retries, challenge the LLM to try again
- if (verifiedPosts.length === 0 && rawPosts.length > 0 && retries > 0) {
- console.warn(
- `🔄 Self-Healing triggered: All IDs were hallucinations. Challenging agent to find real IDs...`,
- );
- return this.findSocialPosts(topic, retries - 1, [
- ...previousFailures,
- ...failedIdsForThisRun,
- ]);
- }
-
- return verifiedPosts;
}
private async planResearch(
@@ -273,4 +307,60 @@ CRITICAL: Do NOT provide more than 2 trendsKeywords. Keep it extremely focused.`
return { trendsKeywords: [], dcVariables: [] };
}
}
+
+ /**
+ * Researches the top-ranking competitors on Google for a given topic.
+ * Extracts their titles and snippets to guide the LLM to write better content.
+ */
+ async researchCompetitors(topic: string, retries = 1): Promise {
+ console.log(
+ `🔍 [Competitor Research] Fetching top ranking web pages for topic: "${topic.slice(0, 50)}..."`,
+ );
+
+ // Step 1: LLM generates the optimal Google Search query
+ const queryGen = await this.openai.chat.completions.create({
+ model: "google/gemini-2.5-flash",
+ messages: [
+ {
+ role: "system",
+ content: `Generate a Google Search query that a B2B decision maker would use to research the following topic: "${topic}".
+Focus on intent-driven keywords.
+Return a JSON object with a single string field "query". Example: {"query": "Next.js performance optimization agency"}.
+DO NOT USE QUOTES IN THE QUERY ITSELF.`,
+ },
+ ],
+ response_format: { type: "json_object" },
+ });
+
+ try {
+ const parsed = JSON.parse(
+ queryGen.choices[0].message.content || '{"query": ""}',
+ );
+ const queryStr = parsed.query || topic;
+
+ // Step 2: Search via Serper Web Search
+ const organicResults = await this.serperClient.searchWeb(queryStr, 5);
+
+ if (!organicResults || organicResults.length === 0) {
+ console.warn(
+ `⚠️ [Competitor Research] No web results found for query: "${queryStr}"`,
+ );
+ if (retries > 0) return this.researchCompetitors(topic, retries - 1);
+ return [];
+ }
+
+ // Map to structured insights string
+ const insights = organicResults.map((result, i) => {
+ return `[Rank #${i + 1}] Title: "${result.title}" | Snippet: "${result.snippet}"`;
+ });
+
+ console.log(
+ `✅ [Competitor Research] Analyzed top ${insights.length} competitor articles.`,
+ );
+ return insights;
+ } catch (e) {
+ console.error("❌ Failed to fetch competitor research:", e);
+ return [];
+ }
+ }
}
diff --git a/packages/journaling/src/clients/serper.ts b/packages/journaling/src/clients/serper.ts
new file mode 100644
index 0000000..6130a01
--- /dev/null
+++ b/packages/journaling/src/clients/serper.ts
@@ -0,0 +1,128 @@
+export interface SerperVideoResult {
+ title: string;
+ link: string;
+ snippet?: string;
+ date?: string;
+ duration?: string;
+ channel?: string;
+}
+
+export interface SerperVideoResponse {
+ searchParameters: any;
+ videos: SerperVideoResult[];
+}
+
+export interface SerperWebResult {
+ title: string;
+ link: string;
+ snippet: string;
+ date?: string;
+ sitelinks?: any[];
+ position: number;
+}
+
+export interface SerperWebResponse {
+ searchParameters: any;
+ organic: SerperWebResult[];
+}
+
+export class SerperClient {
+ private apiKey: string;
+
+ constructor(apiKey?: string) {
+ const key = apiKey || process.env.SERPER_API_KEY;
+ if (!key) {
+ console.warn("⚠️ SERPER_API_KEY is not defined. SerperClient will fail.");
+ }
+ this.apiKey = key || "";
+ }
+
+ /**
+ * Performs a video search via Serper (Google Video Search).
+ * Great for finding relevant YouTube videos.
+ */
+ async searchVideos(
+ query: string,
+ num: number = 5,
+ ): Promise {
+ if (!this.apiKey) {
+ console.error("❌ SERPER_API_KEY missing - cannot execute search.");
+ return [];
+ }
+
+ try {
+ console.log(`🔍 [Serper] Searching videos for: "${query}"`);
+ const response = await fetch("https://google.serper.dev/videos", {
+ method: "POST",
+ headers: {
+ "X-API-KEY": this.apiKey,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ q: query,
+ num: num,
+ gl: "de", // Germany for localized results
+ hl: "de", // German language
+ }),
+ });
+
+ if (!response.ok) {
+ console.error(
+ `❌ [Serper] API Error: ${response.status} ${response.statusText}`,
+ );
+ const text = await response.text();
+ console.error(text);
+ return [];
+ }
+
+ const data = (await response.json()) as SerperVideoResponse;
+ return data.videos || [];
+ } catch (e) {
+ console.error("❌ [Serper] Request failed", e);
+ return [];
+ }
+ }
+
+ /**
+ * Performs a standard web search via Serper.
+ * Crucial for B2B competitor analysis and context gathering.
+ */
+ async searchWeb(query: string, num: number = 5): Promise {
+ if (!this.apiKey) {
+ console.error("❌ SERPER_API_KEY missing - cannot execute web search.");
+ return [];
+ }
+
+ try {
+ console.log(`🔍 [Serper] Web Search for Competitor Insights: "${query}"`);
+ const response = await fetch("https://google.serper.dev/search", {
+ method: "POST",
+ headers: {
+ "X-API-KEY": this.apiKey,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ q: query,
+ num: num,
+ gl: "de", // Germany for localized results
+ hl: "de", // German language
+ }),
+ });
+
+ if (!response.ok) {
+ console.error(
+ `❌ [Serper] API Error: ${response.status} ${response.statusText}`,
+ );
+ const text = await response.text();
+ console.error(text);
+ return [];
+ }
+
+ const data = (await response.json()) as SerperWebResponse;
+ return data.organic || [];
+ } catch (e) {
+ console.error("❌ [Serper] Web Request failed", e);
+ return [];
+ }
+ }
+}
diff --git a/packages/journaling/src/index.ts b/packages/journaling/src/index.ts
index 49608f4..25d40b1 100644
--- a/packages/journaling/src/index.ts
+++ b/packages/journaling/src/index.ts
@@ -1,3 +1,4 @@
export * from "./clients/data-commons";
export * from "./clients/trends";
+export * from "./clients/serper";
export * from "./agent";
diff --git a/packages/thumbnail-generator/package.json b/packages/thumbnail-generator/package.json
new file mode 100644
index 0000000..430222a
--- /dev/null
+++ b/packages/thumbnail-generator/package.json
@@ -0,0 +1,30 @@
+{
+ "name": "@mintel/thumbnail-generator",
+ "version": "1.0.0",
+ "private": true,
+ "type": "module",
+ "main": "./dist/index.js",
+ "module": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js"
+ }
+ },
+ "scripts": {
+ "build": "tsup src/index.ts --format esm --dts --clean",
+ "dev": "tsup src/index.ts --format esm --watch --dts",
+ "lint": "eslint src"
+ },
+ "dependencies": {
+ "replicate": "^1.0.1"
+ },
+ "devDependencies": {
+ "@mintel/eslint-config": "workspace:*",
+ "@mintel/tsconfig": "workspace:*",
+ "@types/node": "^20.0.0",
+ "tsup": "^8.3.5",
+ "typescript": "^5.0.0"
+ }
+}
diff --git a/packages/thumbnail-generator/src/generator.ts b/packages/thumbnail-generator/src/generator.ts
new file mode 100644
index 0000000..981e9b9
--- /dev/null
+++ b/packages/thumbnail-generator/src/generator.ts
@@ -0,0 +1,84 @@
+import Replicate from "replicate";
+import * as fs from "node:fs/promises";
+import * as path from "node:path";
+
+export interface ThumbnailGeneratorConfig {
+ replicateApiKey: string;
+}
+
+export class ThumbnailGenerator {
+ private replicate: Replicate;
+
+ constructor(config: ThumbnailGeneratorConfig) {
+ this.replicate = new Replicate({
+ auth: config.replicateApiKey,
+ });
+ }
+
+ public async generateImage(
+ topic: string,
+ outputPath: string,
+ ): Promise {
+ const systemPrompt = `Technical blueprint / architectural illustration — clean lines, monochrome base with one highlighter accent color (yellow, pink, or green). Abstract, geometric, or diagrammatic illustrations only. 'Engineering notebook sketch' — precise, minimal, professional. No text in images. No people or realistic photos.`;
+
+ const prompt = `${systemPrompt}\n\nTopic to illustrate abstractly: ${topic}`;
+
+ console.log(`🎨 Generating thumbnail for topic: "${topic}"...`);
+
+ const output = await this.replicate.run("black-forest-labs/flux-1.1-pro", {
+ input: {
+ prompt,
+ aspect_ratio: "16:9",
+ output_format: "png",
+ output_quality: 90,
+ prompt_upsampling: false,
+ },
+ });
+
+ // Replicate returns a ReadableStream for the output of flux-1.1-pro in newer Node SDKs
+ // Or a string URL in older ones. We handle both.
+ let buffer: Buffer;
+
+ if (output instanceof ReadableStream) {
+ console.log(`⬇️ Downloading generated stream from Replicate...`);
+ const chunks: Uint8Array[] = [];
+ const reader = output.getReader();
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ if (value) chunks.push(value);
+ }
+ buffer = Buffer.concat(chunks);
+ } else if (
+ typeof output === "string" ||
+ (Array.isArray(output) && typeof output[0] === "string")
+ ) {
+ const imageUrl = Array.isArray(output) ? output[0] : output;
+ console.log(
+ `⬇️ Downloading generated image from URL: ${imageUrl.substring(0, 50)}...`,
+ );
+ const response = await fetch(imageUrl);
+ if (!response.ok) {
+ throw new Error(`Failed to download image: ${response.statusText}`);
+ }
+ const arrayBuffer = await response.arrayBuffer();
+ buffer = Buffer.from(arrayBuffer);
+ } else if (Buffer.isBuffer(output)) {
+ buffer = output;
+ } else if (typeof output === "object") {
+ console.log("Raw output object:", output);
+ throw new Error("Unexpected output format from Replicate.");
+ } else {
+ throw new Error("Unknown output format from Replicate.");
+ }
+
+ const absPath = path.isAbsolute(outputPath)
+ ? outputPath
+ : path.resolve(process.cwd(), outputPath);
+ await fs.mkdir(path.dirname(absPath), { recursive: true });
+ await fs.writeFile(absPath, buffer);
+
+ console.log(`✅ Saved thumbnail to: ${absPath}`);
+ return absPath;
+ }
+}
diff --git a/packages/thumbnail-generator/src/index.ts b/packages/thumbnail-generator/src/index.ts
new file mode 100644
index 0000000..e84bb53
--- /dev/null
+++ b/packages/thumbnail-generator/src/index.ts
@@ -0,0 +1 @@
+export * from "./generator";
diff --git a/packages/thumbnail-generator/tsconfig.json b/packages/thumbnail-generator/tsconfig.json
new file mode 100644
index 0000000..dadacb2
--- /dev/null
+++ b/packages/thumbnail-generator/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "@mintel/tsconfig/base.json",
+ "compilerOptions": {
+ "outDir": "dist"
+ },
+ "include": ["src/**/*"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 24d532d..052936e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -164,7 +164,7 @@ importers:
devDependencies:
'@directus/extensions-sdk':
specifier: 11.0.2
- version: 11.0.2(@types/node@22.19.10)(@unhead/vue@1.11.20(vue@3.5.28(typescript@5.9.3)))(knex@3.1.0)(lightningcss@1.30.2)(pinia@2.3.1(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)))(pino@10.3.1)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3)
+ version: 11.0.2(@types/node@22.19.10)(@unhead/vue@1.11.20(vue@3.4.21(typescript@5.9.3)))(knex@3.1.0)(lightningcss@1.30.2)(pinia@2.3.1(typescript@5.9.3)(vue@3.4.21(typescript@5.9.3)))(pino@10.3.1)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3)
'@mintel/mail':
specifier: workspace:*
version: link:../mail
@@ -270,6 +270,9 @@ importers:
'@mintel/meme-generator':
specifier: workspace:*
version: link:../meme-generator
+ '@mintel/thumbnail-generator':
+ specifier: workspace:*
+ version: link:../thumbnail-generator
dotenv:
specifier: ^17.3.1
version: 17.3.1
@@ -502,7 +505,7 @@ importers:
version: 5.9.3
vitest:
specifier: ^3.0.4
- version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.10)(@vitest/ui@4.0.18(vitest@4.0.18))(happy-dom@20.5.3)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)
+ version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.10)(@vitest/ui@4.0.18)(happy-dom@20.5.3)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)
packages/meme-generator:
dependencies:
@@ -683,7 +686,7 @@ importers:
version: 5.9.3
vitest:
specifier: ^2.0.0
- version: 2.1.9(@types/node@22.19.10)(@vitest/ui@4.0.18(vitest@4.0.18))(happy-dom@20.5.3)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)
+ version: 2.1.9(@types/node@22.19.10)(@vitest/ui@4.0.18)(happy-dom@20.5.3)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)
packages/pdf-library:
dependencies:
@@ -732,6 +735,28 @@ importers:
specifier: ^3.4.0
version: 3.5.28(typescript@5.9.3)
+ packages/thumbnail-generator:
+ dependencies:
+ replicate:
+ specifier: ^1.0.1
+ version: 1.4.0
+ devDependencies:
+ '@mintel/eslint-config':
+ specifier: workspace:*
+ version: link:../eslint-config
+ '@mintel/tsconfig':
+ specifier: workspace:*
+ version: link:../tsconfig
+ '@types/node':
+ specifier: ^20.0.0
+ version: 20.19.33
+ tsup:
+ specifier: ^8.3.5
+ version: 8.5.1(@swc/core@1.15.11(@swc/helpers@0.5.18))(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)
+ typescript:
+ specifier: ^5.0.0
+ version: 5.9.3
+
packages/tsconfig: {}
packages/unified-dashboard:
@@ -6903,6 +6928,10 @@ packages:
process-warning@5.0.0:
resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==}
+ process@0.11.10:
+ resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
+ engines: {node: '>= 0.6.0'}
+
progress@2.0.3:
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
engines: {node: '>=0.4.0'}
@@ -6991,6 +7020,10 @@ packages:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'}
+ readable-stream@4.7.0:
+ resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==}
+ engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+
readdirp@3.6.0:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'}
@@ -7019,6 +7052,10 @@ packages:
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
engines: {node: '>= 0.4'}
+ replicate@1.4.0:
+ resolution: {integrity: sha512-1ufKejfUVz/azy+5TnzQP7U1+MHVWZ6psnQ06az8byUUnRhT+DZ/MvewzB1NQYBVMgNKR7xPDtTwlcP5nv/5+w==}
+ engines: {git: '>=2.11.0', node: '>=18.0.0', npm: '>=7.19.0', yarn: '>=1.7.0'}
+
require-directory@2.1.1:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
@@ -8963,6 +9000,57 @@ snapshots:
'@directus/constants@11.0.3': {}
+ '@directus/extensions-sdk@11.0.2(@types/node@22.19.10)(@unhead/vue@1.11.20(vue@3.4.21(typescript@5.9.3)))(knex@3.1.0)(lightningcss@1.30.2)(pinia@2.3.1(typescript@5.9.3)(vue@3.4.21(typescript@5.9.3)))(pino@10.3.1)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3)':
+ dependencies:
+ '@directus/composables': 10.1.12(vue@3.4.21(typescript@5.9.3))
+ '@directus/constants': 11.0.3
+ '@directus/extensions': 1.0.2(@unhead/vue@1.11.20(vue@3.4.21(typescript@5.9.3)))(knex@3.1.0)(pinia@2.3.1(typescript@5.9.3)(vue@3.4.21(typescript@5.9.3)))(pino@10.3.1)(vue@3.4.21(typescript@5.9.3))
+ '@directus/themes': 0.3.6(@unhead/vue@1.11.20(vue@3.4.21(typescript@5.9.3)))(pinia@2.3.1(typescript@5.9.3)(vue@3.4.21(typescript@5.9.3)))(vue@3.4.21(typescript@5.9.3))
+ '@directus/types': 11.0.8(knex@3.1.0)(vue@3.4.21(typescript@5.9.3))
+ '@directus/utils': 11.0.7(vue@3.4.21(typescript@5.9.3))
+ '@rollup/plugin-commonjs': 25.0.7(rollup@3.29.4)
+ '@rollup/plugin-json': 6.1.0(rollup@3.29.4)
+ '@rollup/plugin-node-resolve': 15.2.3(rollup@3.29.4)
+ '@rollup/plugin-replace': 5.0.5(rollup@3.29.4)
+ '@rollup/plugin-terser': 0.4.4(rollup@3.29.4)
+ '@rollup/plugin-virtual': 3.0.2(rollup@3.29.4)
+ '@vitejs/plugin-vue': 4.6.2(vite@4.5.2(@types/node@22.19.10)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0))(vue@3.4.21(typescript@5.9.3))
+ chalk: 5.3.0
+ commander: 10.0.1
+ esbuild: 0.17.19
+ execa: 7.2.0
+ fs-extra: 11.2.0
+ inquirer: 9.2.16
+ ora: 6.3.1
+ rollup: 3.29.4
+ rollup-plugin-esbuild: 5.0.0(esbuild@0.17.19)(rollup@3.29.4)
+ rollup-plugin-styles: 4.0.0(rollup@3.29.4)
+ vite: 4.5.2(@types/node@22.19.10)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)
+ vue: 3.4.21(typescript@5.9.3)
+ transitivePeerDependencies:
+ - '@types/node'
+ - '@unhead/vue'
+ - better-sqlite3
+ - debug
+ - knex
+ - less
+ - lightningcss
+ - mysql
+ - mysql2
+ - pg
+ - pg-native
+ - pinia
+ - pino
+ - sass
+ - sqlite3
+ - stylus
+ - sugarss
+ - supports-color
+ - tedious
+ - terser
+ - typescript
+ - vue-router
+
'@directus/extensions-sdk@11.0.2(@types/node@22.19.10)(@unhead/vue@1.11.20(vue@3.5.28(typescript@5.9.3)))(knex@3.1.0)(lightningcss@1.30.2)(pinia@2.3.1(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)))(pino@10.3.1)(sass@1.97.3)(terser@5.46.0)(typescript@5.9.3)':
dependencies:
'@directus/composables': 10.1.12(vue@3.4.21(typescript@5.9.3))
@@ -9014,6 +9102,32 @@ snapshots:
- typescript
- vue-router
+ '@directus/extensions@1.0.2(@unhead/vue@1.11.20(vue@3.4.21(typescript@5.9.3)))(knex@3.1.0)(pinia@2.3.1(typescript@5.9.3)(vue@3.4.21(typescript@5.9.3)))(pino@10.3.1)(vue@3.4.21(typescript@5.9.3))':
+ dependencies:
+ '@directus/constants': 11.0.3
+ '@directus/themes': 0.3.6(@unhead/vue@1.11.20(vue@3.4.21(typescript@5.9.3)))(pinia@2.3.1(typescript@5.9.3)(vue@3.4.21(typescript@5.9.3)))(vue@3.4.21(typescript@5.9.3))
+ '@directus/types': 11.0.8(knex@3.1.0)(vue@3.4.21(typescript@5.9.3))
+ '@directus/utils': 11.0.7(vue@3.4.21(typescript@5.9.3))
+ '@types/express': 4.17.21
+ fs-extra: 11.2.0
+ lodash-es: 4.17.21
+ zod: 3.22.4
+ optionalDependencies:
+ knex: 3.1.0
+ pino: 10.3.1
+ vue: 3.4.21(typescript@5.9.3)
+ transitivePeerDependencies:
+ - '@unhead/vue'
+ - better-sqlite3
+ - mysql
+ - mysql2
+ - pg
+ - pg-native
+ - pinia
+ - sqlite3
+ - supports-color
+ - tedious
+
'@directus/extensions@1.0.2(@unhead/vue@1.11.20(vue@3.5.28(typescript@5.9.3)))(knex@3.1.0)(pinia@2.3.1(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)))(pino@10.3.1)(vue@3.4.21(typescript@5.9.3))':
dependencies:
'@directus/constants': 11.0.3
@@ -9057,6 +9171,17 @@ snapshots:
'@directus/system-data@1.0.2': {}
+ '@directus/themes@0.3.6(@unhead/vue@1.11.20(vue@3.4.21(typescript@5.9.3)))(pinia@2.3.1(typescript@5.9.3)(vue@3.4.21(typescript@5.9.3)))(vue@3.4.21(typescript@5.9.3))':
+ dependencies:
+ '@directus/utils': 11.0.7(vue@3.4.21(typescript@5.9.3))
+ '@sinclair/typebox': 0.32.15
+ '@unhead/vue': 1.11.20(vue@3.4.21(typescript@5.9.3))
+ decamelize: 6.0.0
+ flat: 6.0.1
+ lodash-es: 4.17.21
+ pinia: 2.3.1(typescript@5.9.3)(vue@3.4.21(typescript@5.9.3))
+ vue: 3.4.21(typescript@5.9.3)
+
'@directus/themes@0.3.6(@unhead/vue@1.11.20(vue@3.5.28(typescript@5.9.3)))(pinia@2.3.1(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)))(vue@3.4.21(typescript@5.9.3))':
dependencies:
'@directus/utils': 11.0.7(vue@3.4.21(typescript@5.9.3))
@@ -11157,6 +11282,14 @@ snapshots:
'@unhead/schema': 1.11.20
packrup: 0.1.2
+ '@unhead/vue@1.11.20(vue@3.4.21(typescript@5.9.3))':
+ dependencies:
+ '@unhead/schema': 1.11.20
+ '@unhead/shared': 1.11.20
+ hookable: 5.5.3
+ unhead: 1.11.20
+ vue: 3.4.21(typescript@5.9.3)
+
'@unhead/vue@1.11.20(vue@3.5.28(typescript@5.9.3))':
dependencies:
'@unhead/schema': 1.11.20
@@ -14631,6 +14764,16 @@ snapshots:
pify@4.0.1: {}
+ pinia@2.3.1(typescript@5.9.3)(vue@3.4.21(typescript@5.9.3)):
+ dependencies:
+ '@vue/devtools-api': 6.6.4
+ vue: 3.4.21(typescript@5.9.3)
+ vue-demi: 0.14.10(vue@3.4.21(typescript@5.9.3))
+ optionalDependencies:
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - '@vue/composition-api'
+
pinia@2.3.1(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)):
dependencies:
'@vue/devtools-api': 6.6.4
@@ -14962,6 +15105,9 @@ snapshots:
process-warning@5.0.0: {}
+ process@0.11.10:
+ optional: true
+
progress@2.0.3: {}
prompts@2.4.2:
@@ -15057,6 +15203,15 @@ snapshots:
string_decoder: 1.3.0
util-deprecate: 1.0.2
+ readable-stream@4.7.0:
+ dependencies:
+ abort-controller: 3.0.0
+ buffer: 6.0.3
+ events: 3.3.0
+ process: 0.11.10
+ string_decoder: 1.3.0
+ optional: true
+
readdirp@3.6.0:
dependencies:
picomatch: 2.3.1
@@ -15094,6 +15249,10 @@ snapshots:
gopd: 1.2.0
set-function-name: 2.0.2
+ replicate@1.4.0:
+ optionalDependencies:
+ readable-stream: 4.7.0
+
require-directory@2.1.1: {}
require-from-string@2.0.2: {}
@@ -16113,7 +16272,7 @@ snapshots:
tsx: 4.21.0
yaml: 2.8.2
- vitest@2.1.9(@types/node@22.19.10)(@vitest/ui@4.0.18(vitest@4.0.18))(happy-dom@20.5.3)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0):
+ vitest@2.1.9(@types/node@22.19.10)(@vitest/ui@4.0.18)(happy-dom@20.5.3)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0):
dependencies:
'@vitest/expect': 2.1.9
'@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@22.19.10)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0))
@@ -16151,7 +16310,7 @@ snapshots:
- supports-color
- terser
- vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.10)(@vitest/ui@4.0.18(vitest@4.0.18))(happy-dom@20.5.3)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0):
+ vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.10)(@vitest/ui@4.0.18)(happy-dom@20.5.3)(jsdom@27.4.0)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0):
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.4
@@ -16234,6 +16393,10 @@ snapshots:
- tsx
- yaml
+ vue-demi@0.14.10(vue@3.4.21(typescript@5.9.3)):
+ dependencies:
+ vue: 3.4.21(typescript@5.9.3)
+
vue-demi@0.14.10(vue@3.5.28(typescript@5.9.3)):
dependencies:
vue: 3.5.28(typescript@5.9.3)