feat: content engine
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧹 Lint (push) Successful in 1m12s
Monorepo Pipeline / 🧪 Test (push) Successful in 2m59s
Monorepo Pipeline / 🏗️ Build (push) Successful in 6m52s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
🏥 Server Maintenance / 🧹 Prune & Clean (push) Failing after 4s

This commit is contained in:
2026-02-22 02:39:27 +01:00
parent a9adb2eff7
commit 3a1a88db89
11 changed files with 942 additions and 172 deletions

View File

@@ -20,6 +20,7 @@
"dependencies": {
"@mintel/journaling": "workspace:*",
"@mintel/meme-generator": "workspace:*",
"@mintel/thumbnail-generator": "workspace:*",
"dotenv": "^17.3.1",
"openai": "^4.82.0"
},

View File

@@ -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 -> <YouTubeEmbed videoId="ID" />
- twitter -> <TwitterEmbed tweetId="ID" theme="light" />
- linkedin -> <LinkedInEmbed urn="ID" />
- 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 \`<ComparisonRow>\` oder \`<IconList>\`), 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: <!-- DIAGRAM_PLACEHOLDER: Concept Name -->
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 <br>, no <br/>, no <b>, 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.

View File

@@ -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<string> {
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<string> {
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<string[]> {
@@ -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<string> {
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 <YouTubeEmbed />, <TwitterEmbed />, or <LinkedInEmbed /> 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('<YouTubeEmbed videoId="..." />');
if (socialPosts.some((p) => p.platform === "twitter"))
allowedTags.push('<TwitterEmbed tweetId="..." />');
if (socialPosts.some((p) => p.platform === "linkedin"))
allowedTags.push('<LinkedInEmbed url="..." />');
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: <LinkedInEmbed url="https://www.linkedin.com/posts/..." />`;
}
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 <YouTubeEmbed videoId="..." />, <TwitterEmbed tweetId="..." />, or <LinkedInEmbed url="..." />):
${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 <ExternalLink> 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 \`<ComparisonRow>\` oder \`<IconList>\`), 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 \`<FAQSection>\` Component oder normales Markdown.
- SUBTLE CTAs: Webe 1-2 subtile CTAs für High-End Website Entwicklung ein (Beispiel: \`<Button href="/contact" variant="outline" size="normal">Performance-Check anfragen</Button>\` oder \`<Button href="/contact">Digitale Architektur anfragen</Button>\`). Platziere diese zwingend organisch nach Abschnitten mit hohem Mehrwert.
- Zitat-Varianten: Wenn du Organisationen oder Studien zitierst, nutze \`<ArticleQuote isCompany={true} ... />\`. Für Personen lass \`isCompany\` weg.
- Füge zwingend ein prägnantes 'TL;DR' ganz am Anfang ein.
- Füge ein sauberes '<TableOfContents />' 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 <H2>Fazit: ...</H2> 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 <Mermaid>, 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("<Mermaid>")) {
@@ -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(
/<YouTubeEmbed[^>]*videoId="([^"]+)"[^>]*\/>/gi,
(tag, id) => {
if (knownYtIds.has(id)) return tag;
console.log(
`🛑 Stripped hallucinated YouTubeEmbed with videoId="${id}"`,
);
return "";
},
);
cleaned = cleaned.replace(
/<TwitterEmbed[^>]*tweetId="([^"]+)"[^>]*\/>/gi,
(tag, id) => {
if (knownTwIds.has(id)) return tag;
console.log(
`🛑 Stripped hallucinated TwitterEmbed with tweetId="${id}"`,
);
return "";
},
);
cleaned = cleaned.replace(
/<LinkedInEmbed[^>]*(?:url|urn)="([^"]+)"[^>]*\/>/gi,
(tag, id) => {
if (knownLiIds.has(id)) return tag;
console.log(`🛑 Stripped hallucinated LinkedInEmbed with id="${id}"`);
return "";
},
);
return cleaned;
}

View File

@@ -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<Fact[]> {
@@ -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: <YouTubeEmbed videoId="..." />
const ytMatches = [
...content.matchAll(/<YouTubeEmbed[^>]*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: <TwitterEmbed tweetId="..." />
const twMatches = [
...content.matchAll(/<TwitterEmbed[^>]*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: <LinkedInEmbed url="..." /> or <LinkedInEmbed urn="..." />
const liMatches = [
...content.matchAll(/<LinkedInEmbed[^>]*(?: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<SocialPost[]> {
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<string[]> {
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 [];
}
}
}

View File

@@ -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<SerperVideoResult[]> {
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<SerperWebResult[]> {
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 [];
}
}
}

View File

@@ -1,3 +1,4 @@
export * from "./clients/data-commons";
export * from "./clients/trends";
export * from "./clients/serper";
export * from "./agent";

View File

@@ -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"
}
}

View File

@@ -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<string> {
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;
}
}

View File

@@ -0,0 +1 @@
export * from "./generator";

View File

@@ -0,0 +1,8 @@
{
"extends": "@mintel/tsconfig/base.json",
"compilerOptions": {
"outDir": "dist"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

173
pnpm-lock.yaml generated
View File

@@ -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)