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)