feat: content engine

This commit is contained in:
2026-02-21 19:08:06 +01:00
parent 3f1c37813a
commit a50b8d6393
32 changed files with 2816 additions and 189 deletions

1
.env
View File

@@ -3,6 +3,7 @@ IMAGE_TAG=v1.8.10
PROJECT_NAME=at-mintel
PROJECT_COLOR=#82ed20
GITEA_TOKEN=ccce002e30fe16a31a6c9d5a414740af2f72a582
OPENROUTER_API_KEY=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
# Authentication
GATEKEEPER_PASSWORD=mintel

View File

@@ -24,8 +24,8 @@ jobs:
# Run the prune script on the host
# We transfer the script and execute it to ensure it matches the repo version
scp packages/infra/scripts/prune-registry.sh root@${{ secrets.SSH_HOST }}:/tmp/prune-registry.sh
ssh root@${{ secrets.SSH_HOST }} "bash /tmp/prune-registry.sh && rm /tmp/prune-registry.sh"
scp packages/infra/scripts/mintel-optimizer.sh root@${{ secrets.SSH_HOST }}:/tmp/mintel-optimizer.sh
ssh root@${{ secrets.SSH_HOST }} "bash /tmp/mintel-optimizer.sh && rm /tmp/mintel-optimizer.sh"
- name: 🔔 Notification - Success
if: success()

2
.gitignore vendored
View File

@@ -40,3 +40,5 @@ Thumbs.db
directus/extensions/
packages/cms-infra/extensions/
packages/cms-infra/uploads/
directus/uploads/directus-health-file

Binary file not shown.

View File

@@ -25,6 +25,8 @@ services:
LOG_LEVEL: "debug"
SERVE_APP: "true"
EXTENSIONS_AUTO_RELOAD: "true"
EXTENSIONS_SANDBOX: "false"
CONTENT_SECURITY_POLICY: "false"
volumes:
- ./database:/directus/database
- ./uploads:/directus/uploads
@@ -37,11 +39,12 @@ services:
retries: 5
labels:
- "traefik.enable=true"
- "traefik.http.routers.at-mintel-infra-cms.rule=Host(`cms.localhost`)"
- "traefik.docker.network=infra"
- "caddy=cms.localhost"
- "caddy.reverse_proxy={{upstreams 8055}}"
traefik.enable: "true"
traefik.http.routers.at-mintel-infra-cms.rule: "Host(`cms.localhost`)"
traefik.docker.network: "infra"
caddy: "http://cms.localhost"
caddy.reverse_proxy: "{{upstreams 8055}}"
caddy.header.Cache-Control: "no-store, no-cache, must-revalidate, max-age=0"
networks:
default:

View File

@@ -0,0 +1,48 @@
import { ContentGenerator } from "../src/index";
import dotenv from "dotenv";
import path from "path";
import fs from "fs";
// Load .env from mintel.me (since that's where the key is)
dotenv.config({
path: path.resolve(__dirname, "../../../../mintel.me/apps/web/.env"),
});
async function main() {
const apiKey = process.env.OPENROUTER_API_KEY || process.env.OPENROUTER_KEY;
if (!apiKey) {
console.error("❌ OPENROUTER_API_KEY not found");
process.exit(1);
}
const generator = new ContentGenerator(apiKey);
const topic = "Why traditional CMSs are dead for developers";
console.log(`🚀 Generating post for: "${topic}"`);
try {
const post = await generator.generatePost({
topic,
includeResearch: true,
includeDiagrams: true,
includeMemes: true,
});
console.log("\n\n✅ GENERATION COMPLETE");
console.log("--------------------------------------------------");
console.log(`Title: ${post.title}`);
console.log(`Research Points: ${post.research.length}`);
console.log(`Memes Generated: ${post.memes.length}`);
console.log(`Diagrams Generated: ${post.diagrams.length}`);
console.log("--------------------------------------------------");
// Save to file
const outputPath = path.join(__dirname, "output.md");
fs.writeFileSync(outputPath, post.content);
console.log(`📄 Saved output to: ${outputPath}`);
} catch (error) {
console.error("❌ Generation failed:", error);
}
}
main();

View File

@@ -0,0 +1,58 @@
import { ContentGenerator } from "../src/index";
import dotenv from "dotenv";
import path from "path";
import fs from "fs";
import { fileURLToPath } from "url";
// Fix __dirname for ESM
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Load .env from mintel.me (since that's where the key is)
dotenv.config({
path: path.resolve(__dirname, "../../../../mintel.me/apps/web/.env"),
});
async function main() {
const apiKey = process.env.OPENROUTER_API_KEY || process.env.OPENROUTER_KEY;
if (!apiKey) {
console.error("❌ OPENROUTER_API_KEY not found");
process.exit(1);
}
const generator = new ContentGenerator(apiKey);
const draftContent = `# The Case for Static Sites
Static sites are faster and more secure. They don't have a database to hack.
They are also cheaper to host. You can use a CDN to serve them globally.
Dynamic sites are complex and prone to errors.`;
console.log("📄 Original Content:");
console.log(draftContent);
console.log("\n🚀 Optimizing content...\n");
try {
const post = await generator.optimizePost(draftContent, {
enhanceFacts: true,
addDiagrams: true,
addMemes: true,
});
console.log("\n\n✅ OPTIMIZATION COMPLETE");
console.log("--------------------------------------------------");
console.log(`Research Points Added: ${post.research.length}`);
console.log(`Memes Generated: ${post.memes.length}`);
console.log(`Diagrams Generated: ${post.diagrams.length}`);
console.log("--------------------------------------------------");
// Save to file
const outputPath = path.join(__dirname, "optimized.md");
fs.writeFileSync(outputPath, post.content);
console.log(`📄 Saved output to: ${outputPath}`);
} catch (error) {
console.error("❌ Optimization failed:", error);
}
}
main();

View File

@@ -0,0 +1,132 @@
import { ContentGenerator, ComponentDefinition } from "../src/index";
import dotenv from "dotenv";
import path from "path";
import fs from "fs";
import { fileURLToPath } from "url";
// Fix __dirname for ESM
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Load .env from mintel.me
dotenv.config({
path: path.resolve(__dirname, "../../../../mintel.me/apps/web/.env"),
});
async function main() {
const apiKey = process.env.OPENROUTER_API_KEY || process.env.OPENROUTER_KEY;
if (!apiKey) {
console.error("❌ OPENROUTER_API_KEY not found");
process.exit(1);
}
const generator = new ContentGenerator(apiKey);
const contentToOptimize = `
"Wir können nicht wechseln, das wäre zu teuer."
In meiner Arbeit als Digital Architect ist das der Anfang vom Ende jeder technologischen Innovation.
Vendor Lock-In ist die digitale Version einer Geiselnahme.
Ich zeige Ihnen, wie wir Systeme bauen, die Ihnen jederzeit die volle Freiheit lassen technologisch und wirtschaftlich.
Die unsichtbaren Ketten proprietärer Systeme
Viele Unternehmen lassen sich von der Bequemlichkeit großer SaaS-Plattformen oder Baukästen blenden.
Man bekommt ein schnelles Feature, gibt aber dafür die Kontrolle über seine Daten und seine Codebasis ab.
Nach zwei Jahren sind Sie so tief im Ökosystem eines Anbieters verstrickt, dass ein Auszug unmöglich scheint.
Der Anbieter weiß das und diktiert fortan die Preise und das Tempo Ihrer Entwicklung.
Ich nenne das technologische Erpressbarkeit.
Wahre Unabhängigkeit beginnt bei der strategischen Wahl der Architektur.
Technologische Souveränität als Asset
Software sollte für Sie arbeiten, nicht umgekehrt.
Indem wir auf offene Standards und portable Architekturen setzen, verwandeln wir Code in ein echtes Firmen-Asset.
Sie können den Cloud-Anbieter wechseln, die Agentur tauschen oder das Team skalieren ohne jemals bei Null anfangen zu müssen.
Das ist das Privileg der technologischen Elite.
Portabilität ist kein technisches Gimmick, sondern eine unternehmerische Notwendigkeit.
Meine Architektur der Ungebundenheit
Ich baue keine "Käfige" aus fertigen Plugins.
Mein Framework basiert auf Modularität und Klarheit.
Standard-basiertes Engineering: Wir nutzen Technologien, die weltweit verstanden werden. Keine geheimen "Spezial-Module" eines einzelnen Anbieters.
Daten-Portabilität: Ihre Daten gehören Ihnen. Zu jeder Zeit. Wir bauen Schnittstellen, die den Export so einfach machen wie den Import.
Cloud-agnostisches Hosting: Wir nutzen Container-Technologie. Ob AWS, Azure oder lokale Anbieter Ihr Code läuft überall gleich perfekt.
Der strategische Hebel für langfristige Rendite
Systeme ohne Lock-In altern besser.
Sie lassen sich schrittweise modernisieren, statt alle fünf Jahre komplett neu gebaut werden zu müssen.
Das spart Millionen an Opportunitätskosten und Fehl-Investitionen.
Seien Sie der Herr über Ihr digitales Schicksal.
Investieren Sie in intelligente Unabhängigkeit.
Für wen ich 'Freiheits-Systeme' erstelle
Ich arbeite für Gründer, die ihr Unternehmen langfristig wertvoll aufstellen wollen.
Ist digitale Exzellenz Teil Ihrer Exit-Strategie oder Ihres Erbes? Dann brauchen Sie meine Architektur.
Ich baue keine Provisorien, sondern nachhaltige Werte.
Fazit: Freiheit ist eine Wahl
Technologie sollte Ihnen Flügel verleihen, keine Fesseln anlegen.
Lassen Sie uns gemeinsam ein System schaffen, das so flexibel ist wie Ihr Business.
Werden Sie unersetzbar durch Qualität, nicht durch Abhängigkeit. Ihr Erfolg verdient absolute Freiheit.
`;
// Define components available in mintel.me
const availableComponents: ComponentDefinition[] = [
{
name: "LeadParagraph",
description: "Large, introductory text for the beginning of the article.",
usageExample: "<LeadParagraph>First meaningful sentence.</LeadParagraph>",
},
{
name: "H2",
description: "Section heading.",
usageExample: "<H2>Section Title</H2>",
},
{
name: "H3",
description: "Subsection heading.",
usageExample: "<H3>Subtitle</H3>",
},
{
name: "Paragraph",
description: "Standard body text paragraph.",
usageExample: "<Paragraph>Some text...</Paragraph>",
},
{
name: "ArticleBlockquote",
description: "A prominent quote block for key insights.",
usageExample: "<ArticleBlockquote>Important quote</ArticleBlockquote>",
},
{
name: "Marker",
description: "Yellow highlighter effect for very important phrases.",
usageExample: "<Marker>Highlighted Text</Marker>",
},
{
name: "ComparisonRow",
description: "A component comparing a negative vs positive scenario.",
usageExample:
'<ComparisonRow description="Cost Comparison" negativeLabel="Lock-In" negativeText="High costs" positiveLabel="Open" positiveText="Control" />',
},
];
console.log('🚀 Optimizing "Vendor Lock-In" post...');
try {
const post = await generator.optimizePost(contentToOptimize, {
enhanceFacts: true,
addDiagrams: true,
addMemes: true,
availableComponents,
});
console.log("\n\n✅ OPTIMIZATION COMPLETE");
// Save to a file in the package dir
const outputPath = path.join(__dirname, "VendorLockIn_OPTIMIZED.md");
fs.writeFileSync(outputPath, post.content);
console.log(`📄 Saved output to: ${outputPath}`);
} catch (error) {
console.error("❌ Optimization failed:", error);
}
}
main();

View File

@@ -0,0 +1,71 @@
import { ContentGenerator, ComponentDefinition } from "../src/index";
import dotenv from "dotenv";
import path from "path";
import fs from "fs";
import { fileURLToPath } from "url";
// Fix __dirname for ESM
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Load .env from mintel.me (since that's where the key is)
dotenv.config({
path: path.resolve(__dirname, "../../../../mintel.me/apps/web/.env"),
});
async function main() {
const apiKey = process.env.OPENROUTER_API_KEY || process.env.OPENROUTER_KEY;
if (!apiKey) {
console.error("❌ OPENROUTER_API_KEY not found");
process.exit(1);
}
const generator = new ContentGenerator(apiKey);
const draftContent = `# Improving User Retention
User retention is key. You need to keep users engaged.
Offer them value and they will stay.
If they have questions, they should contact support.`;
const availableComponents: ComponentDefinition[] = [
{
name: "InfoCard",
description: "A colored box to highlight important tips or warnings.",
usageExample:
'<InfoCard variant="warning" title="Pro Tip">Always measure retention.</InfoCard>',
},
{
name: "CallToAction",
description: "A prominent button for conversion.",
usageExample: '<CallToAction href="/contact">Get in Touch</CallToAction>',
},
];
console.log("📄 Original Content:");
console.log(draftContent);
console.log("\n🚀 Optimizing content with components...\n");
try {
const post = await generator.optimizePost(draftContent, {
enhanceFacts: true,
addDiagrams: false, // Skip diagrams for this test to focus on components
addMemes: false,
availableComponents,
});
console.log("\n\n✅ OPTIMIZATION COMPLETE");
console.log("--------------------------------------------------");
console.log(post.content);
console.log("--------------------------------------------------");
// Save to file
const outputPath = path.join(__dirname, "optimized-components.md");
fs.writeFileSync(outputPath, post.content);
console.log(`📄 Saved output to: ${outputPath}`);
} catch (error) {
console.error("❌ Optimization failed:", error);
}
}
main();

View File

@@ -0,0 +1,33 @@
{
"name": "@mintel/content-engine",
"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": {
"@mintel/journaling": "workspace:*",
"@mintel/meme-generator": "workspace:*",
"dotenv": "^17.3.1",
"openai": "^4.82.0"
},
"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,974 @@
import OpenAI from "openai";
import { ResearchAgent, Fact, SocialPost } from "@mintel/journaling";
import { MemeGenerator, MemeSuggestion } from "@mintel/meme-generator";
import * as fs from "node:fs/promises";
import * as path from "node:path";
export interface ComponentDefinition {
name: string;
description: string;
usageExample: string;
}
export interface BlogPostOptions {
topic: string;
tone?: string;
targetAudience?: string;
includeMemes?: boolean;
includeDiagrams?: boolean;
includeResearch?: boolean;
availableComponents?: ComponentDefinition[];
}
export interface OptimizationOptions {
enhanceFacts?: boolean;
addMemes?: boolean;
addDiagrams?: boolean;
availableComponents?: ComponentDefinition[];
projectContext?: string;
/** Target audience description for all AI prompts */
targetAudience?: string;
/** Tone/persona description for all AI prompts */
tone?: string;
/** Prompt for DALL-E 3 style generation */
memeStylePrompt?: string;
/** Path to the docs folder (e.g. apps/web/docs) for full persona/tone context */
docsPath?: string;
}
export interface GeneratedPost {
title: string;
content: string;
research: Fact[];
memes: MemeSuggestion[];
diagrams: string[];
}
interface Insertion {
afterSection: number;
content: string;
}
// Model configuration: specialized models for different tasks
const MODELS = {
// Structured JSON output, research planning, diagram models: {
STRUCTURED: "google/gemini-2.5-flash",
ROUTING: "google/gemini-2.5-flash",
CONTENT: "google/gemini-2.5-pro",
// Mermaid diagram generation - User requested Pro
DIAGRAM: "google/gemini-2.5-pro",
} as const;
/** Strip markdown fences that some models wrap around JSON despite response_format */
function safeParseJSON(raw: string, fallback: any = {}): any {
let cleaned = raw.trim();
// Remove ```json ... ``` or ``` ... ``` wrapping
if (cleaned.startsWith("```")) {
cleaned = cleaned
.replace(/^```(?:json)?\s*\n?/, "")
.replace(/\n?```\s*$/, "");
}
try {
return JSON.parse(cleaned);
} catch (e) {
console.warn(
"⚠️ Failed to parse JSON response, using fallback:",
(e as Error).message,
);
return fallback;
}
}
export class ContentGenerator {
private openai: OpenAI;
private researchAgent: ResearchAgent;
private memeGenerator: MemeGenerator;
constructor(apiKey: string) {
this.openai = new OpenAI({
apiKey,
baseURL: "https://openrouter.ai/api/v1",
defaultHeaders: {
"HTTP-Referer": "https://mintel.me",
"X-Title": "Mintel Content Engine",
},
});
this.researchAgent = new ResearchAgent(apiKey);
this.memeGenerator = new MemeGenerator(apiKey);
}
// =========================================================================
// generatePost — for new posts (unchanged from original)
// =========================================================================
async generatePost(options: BlogPostOptions): Promise<GeneratedPost> {
const {
topic,
tone = "professional yet witty",
includeResearch = true,
availableComponents = [],
} = options;
console.log(`🚀 Starting content generation for: "${topic}"`);
let facts: Fact[] = [];
if (includeResearch) {
console.log("📚 Gathering research...");
facts = await this.researchAgent.researchTopic(topic);
}
console.log("📝 Creating outline...");
const outline = await this.createOutline(topic, facts, tone);
console.log("✍️ Drafting content...");
let content = await this.draftContent(
topic,
outline,
facts,
tone,
availableComponents,
);
const diagrams: string[] = [];
if (options.includeDiagrams) {
content = await this.processDiagramPlaceholders(content, diagrams);
}
const memes: MemeSuggestion[] = [];
if (options.includeMemes) {
const memeIdeas = await this.memeGenerator.generateMemeIdeas(
content.slice(0, 4000),
);
memes.push(...memeIdeas);
}
return { title: outline.title, content, research: facts, memes, diagrams };
}
// =========================================================================
// generateTldr — Creates a TL;DR block for the given content
// =========================================================================
async generateTldr(content: string): Promise<string> {
const context = content.slice(0, 3000);
const response = await this.openai.chat.completions.create({
model: MODELS.CONTENT,
messages: [
{
role: "system",
content: `Du bist ein kompromissloser Digital Architect.
Erstelle ein "TL;DR" für diesen Artikel.
REGELN:
- 3 knackige Bulletpoints
- TON: Sarkastisch, direkt, provokant ("Finger in die Wunde")
- Fokussiere auf den wirtschaftlichen Schaden von schlechter Tech
- Formatiere als MDX-Komponente:
<div className="my-8 p-6 bg-slate-50 border-l-4 border-blue-600 rounded-r-xl">
<H3>TL;DR: Warum Ihr Geld verbrennt</H3>
<ul className="list-disc pl-5 space-y-2 mb-0">
<li>Punkt 1</li>
<li>Punkt 2</li>
<li>Punkt 3</li>
</ul>
</div>`,
},
{
role: "user",
content: context,
},
],
});
return response.choices[0].message.content?.trim() ?? "";
}
// =========================================================================
// optimizePost — ADDITIVE architecture (never rewrites original content)
// =========================================================================
async optimizePost(
content: string,
options: OptimizationOptions,
): Promise<GeneratedPost> {
console.log("🚀 Optimizing existing content (additive mode)...");
// Load docs context if provided
let docsContext = "";
if (options.docsPath) {
docsContext = await this.loadDocsContext(options.docsPath);
console.log(`📖 Loaded ${docsContext.length} chars of docs context`);
}
const fullContext = [options.projectContext || "", docsContext]
.filter(Boolean)
.join("\n\n---\n\n");
// Split content into numbered sections for programmatic insertion
const sections = this.splitIntoSections(content);
console.log(`📋 Content has ${sections.length} sections`);
const insertions: Insertion[] = [];
const facts: Fact[] = [];
const diagrams: string[] = [];
const memes: MemeSuggestion[] = [];
// Build a numbered content map for LLM reference (read-only)
const sectionMap = this.buildSectionMap(sections);
// ----- STEP 1: Research -----
if (options.enhanceFacts) {
console.log("🔍 Identifying research topics...");
const researchTopics = await this.identifyResearchTopics(
content,
fullContext,
);
console.log(`📚 Researching: ${researchTopics.join(", ")}`);
for (const topic of researchTopics) {
const topicFacts = await this.researchAgent.researchTopic(topic);
facts.push(...topicFacts);
}
if (facts.length > 0) {
console.log(`📝 Planning fact insertions for ${facts.length} facts...`);
const factInsertions = await this.planFactInsertions(
sectionMap,
sections,
facts,
fullContext,
);
insertions.push(...factInsertions);
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),
);
if (socialPosts.length > 0) {
console.log(
`📝 Planning placement for ${socialPosts.length} social media posts...`,
);
const socialInsertions = await this.planSocialMediaInsertions(
sectionMap,
sections,
socialPosts,
fullContext,
);
insertions.push(...socialInsertions);
console.log(
`${socialInsertions.length} social embeddings planned`,
);
}
}
// ----- STEP 2: Component suggestions -----
if (options.availableComponents && options.availableComponents.length > 0) {
console.log("🧩 Planning component additions...");
const componentInsertions = await this.planComponentInsertions(
sectionMap,
sections,
options.availableComponents,
fullContext,
);
insertions.push(...componentInsertions);
console.log(
`${componentInsertions.length} component additions planned`,
);
}
// ----- STEP 3: Diagram generation -----
if (options.addDiagrams) {
console.log("📊 Planning diagrams...");
const diagramPlans = await this.planDiagramInsertions(
sectionMap,
sections,
fullContext,
);
for (const plan of diagramPlans) {
const mermaidCode = await this.generateMermaid(plan.concept);
if (!mermaidCode) {
console.warn(` ⏭️ Skipping invalid diagram for: "${plan.concept}"`);
continue;
}
diagrams.push(mermaidCode);
const diagramId = plan.concept
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9-]/g, "")
.slice(0, 40);
insertions.push({
afterSection: plan.afterSection,
content: `<div className="my-8">\n <Mermaid id="${diagramId}" title="${plan.concept}" showShare={true}>\n${mermaidCode}\n </Mermaid>\n</div>`,
});
}
console.log(
`${diagramPlans.length} diagrams planned, ${diagrams.length} valid`,
);
}
// ----- STEP 4: Meme placement (memegen.link via ArticleMeme) -----
if (options.addMemes) {
console.log("✨ Generating meme ideas...");
let memeIdeas = await this.memeGenerator.generateMemeIdeas(
content.slice(0, 4000),
);
// User requested to explicitly limit memes to max 1 per page to prevent duplication
if (memeIdeas.length > 1) {
memeIdeas = [memeIdeas[0]];
}
memes.push(...memeIdeas);
if (memeIdeas.length > 0) {
console.log(
`🎨 Planning meme placement for ${memeIdeas.length} memes...`,
);
const memePlacements = await this.planMemePlacements(
sectionMap,
sections,
memeIdeas,
);
for (let i = 0; i < memeIdeas.length; i++) {
const meme = memeIdeas[i];
if (
memePlacements[i] !== undefined &&
memePlacements[i] >= 0 &&
memePlacements[i] < sections.length
) {
const captionsStr = meme.captions.join("|");
insertions.push({
afterSection: memePlacements[i],
content: `<div className="my-8">\n <ArticleMeme template="${meme.template}" captions="${captionsStr}" />\n</div>`,
});
}
}
console.log(`${memeIdeas.length} memes placed`);
}
}
// ----- Enforce visual spacing (no consecutive visualizations) -----
this.enforceVisualSpacing(insertions, sections);
// ----- Apply all insertions to original content -----
console.log(
`\n🔧 Applying ${insertions.length} insertions to original content...`,
);
let optimizedContent = this.applyInsertions(sections, insertions);
// ----- FINAL AGENTIC REWRITE (Replaces dumb regex scripts) -----
console.log(
`\n🧠 Agentic Rewrite: Polishing MDX, fixing syntax, and deduplicating...`,
);
const finalRewrite = await this.openai.chat.completions.create({
model: MODELS.CONTENT,
messages: [
{
role: "system",
content: `You are an expert MDX Editor. Your task is to take a draft blog post and output the FINAL, error-free MDX code.
CRITICAL RULES:
1. DEDUPLICATION: Ensure there is MAX ONE <ArticleMeme> in the entire post. Remove any duplicates or outdated memes. Ensure there is MAX ONE TL;DR section. Ensure there are no duplicate components.
2. TEXT-TO-COMPONENT RATIO: Ensure there are at least 3-4 paragraphs of normal text between any two visual components (<Mermaid>, <ArticleMeme>, <StatsGrid>, <BoldNumber>, etc.). If they are clumped together, spread them out or delete the less important ones.
3. SYNTAX: Fix any broken Mermaid/MDX syntax (e.g. unclosed tags, bad quotes).
4. FIDELITY: Preserve the author's original German text, meaning, and tone. Smooth out transitions into the components.
5. NO HALLUCINATION: Do not invent new URLs or facts. Keep the data provided in the draft.
6. OUTPUT: Return ONLY the raw MDX content. No markdown code blocks (\`\`\`mdx), no preamble. Just the raw code file.`,
},
{
role: "user",
content: optimizedContent,
},
],
});
optimizedContent =
finalRewrite.choices[0].message.content?.trim() || optimizedContent;
// Strip any residual markdown formatting fences just in case
if (optimizedContent.startsWith("```")) {
optimizedContent = optimizedContent
.replace(/^```[a-zA-Z]*\n/, "")
.replace(/\n```$/, "");
}
return {
title: "Optimized Content",
content: optimizedContent,
research: facts,
memes,
diagrams,
};
}
// =========================================================================
// ADDITIVE HELPERS — these return JSON instructions, never rewrite content
// =========================================================================
private splitIntoSections(content: string): string[] {
// Split on double newlines (paragraph/block boundaries in MDX)
return content.split(/\n\n+/);
}
private applyInsertions(sections: string[], insertions: Insertion[]): string {
// Sort by section index DESCENDING to avoid index shifting
const sorted = [...insertions].sort(
(a, b) => b.afterSection - a.afterSection,
);
const result = [...sections];
for (const ins of sorted) {
const idx = Math.min(ins.afterSection + 1, result.length);
result.splice(idx, 0, ins.content);
}
return result.join("\n\n");
}
/**
* Enforce visual spacing: visual components must have at least 2 text sections between them.
* This prevents walls of visualizations and maintains reading flow.
*/
private enforceVisualSpacing(
insertions: Insertion[],
sections: string[],
): void {
const visualPatterns = [
"<Mermaid",
"<ArticleMeme",
"<StatsGrid",
"<StatsDisplay",
"<BoldNumber",
"<MetricBar",
"<ComparisonRow",
"<PremiumComparisonChart",
"<DiagramFlow",
"<DiagramPie",
"<DiagramGantt",
"<DiagramState",
"<DiagramSequence",
"<DiagramTimeline",
"<Carousel",
"<WebVitalsScore",
"<WaterfallChart",
];
const isVisual = (content: string) =>
visualPatterns.some((p) => content.includes(p));
// Sort by section ascending
insertions.sort((a, b) => a.afterSection - b.afterSection);
// Minimum gap of 10 sections between visual components (= ~6-8 text paragraphs)
// User requested a better text-to-component ratio (not 1:1)
const MIN_VISUAL_GAP = 10;
for (let i = 1; i < insertions.length; i++) {
if (
isVisual(insertions[i].content) &&
isVisual(insertions[i - 1].content)
) {
const gap = insertions[i].afterSection - insertions[i - 1].afterSection;
if (gap < MIN_VISUAL_GAP) {
const newPos = Math.min(
insertions[i - 1].afterSection + MIN_VISUAL_GAP,
sections.length - 1,
);
insertions[i].afterSection = newPos;
}
}
}
}
private buildSectionMap(sections: string[]): string {
return sections
.map((s, i) => {
const preview = s.trim().replace(/\n/g, " ").slice(0, 120);
return `[${i}] ${preview}${s.length > 120 ? "…" : ""}`;
})
.join("\n");
}
private async loadDocsContext(docsPath: string): Promise<string> {
try {
const files = await fs.readdir(docsPath);
const mdFiles = files.filter((f) => f.endsWith(".md")).sort();
const contents: string[] = [];
for (const file of mdFiles) {
const filePath = path.join(docsPath, file);
const text = await fs.readFile(filePath, "utf8");
contents.push(`=== ${file} ===\n${text.trim()}`);
}
return contents.join("\n\n");
} catch (e) {
console.warn(`⚠️ Could not load docs from ${docsPath}: ${e}`);
return "";
}
}
// --- Fact insertion planning (Claude Sonnet — precise content understanding) ---
private async planFactInsertions(
sectionMap: string,
sections: string[],
facts: Fact[],
context: string,
): Promise<Insertion[]> {
const factsText = facts
.map((f, i) => `${i + 1}. ${f.statement} [Source: ${f.source}]`)
.join("\n");
const response = await this.openai.chat.completions.create({
model: MODELS.CONTENT,
messages: [
{
role: "system",
content: `You enrich a German blog post by ADDING new paragraphs with researched facts.
RULES:
- Do NOT rewrite or modify any existing content
- Only produce NEW <Paragraph> blocks to INSERT after a specific section number
- Maximum 5 insertions (only the most impactful facts)
- Match the post's tone and style (see context below)
- Use the post's JSX components: <Paragraph>, <Marker> for emphasis
- Cite sources using ExternalLink: <ExternalLink href="URL">Source: Name</ExternalLink>
- Write in German, active voice, Ich-Form where appropriate
CONTEXT (tone, style, persona):
${context.slice(0, 3000)}
EXISTING SECTIONS (read-only — do NOT modify these):
${sectionMap}
FACTS TO INTEGRATE:
${factsText}
Return JSON:
{ "insertions": [{ "afterSection": 3, "content": "<Paragraph>\\n Fact-enriched paragraph text. [Source: Name]\\n</Paragraph>" }] }
Return ONLY the JSON.`,
},
],
response_format: { type: "json_object" },
});
const result = safeParseJSON(
response.choices[0].message.content || '{"insertions": []}',
{ insertions: [] },
);
return (result.insertions || []).filter(
(i: any) =>
typeof i.afterSection === "number" &&
i.afterSection >= 0 &&
i.afterSection < sections.length &&
typeof i.content === "string",
);
}
// --- Social Media insertion planning ---
private async planSocialMediaInsertions(
sectionMap: string,
sections: string[],
posts: SocialPost[],
context: string,
): Promise<Insertion[]> {
if (!posts || posts.length === 0) return [];
const postsText = posts
.map(
(p, i) =>
`[${i}] Platform: ${p.platform}, ID: ${p.embedId} (${p.description})`,
)
.join("\n");
const response = await this.openai.chat.completions.create({
model: MODELS.CONTENT,
messages: [
{
role: "system",
content: `You enhance a German blog post by embedding relevant social media posts (YouTube, Twitter, LinkedIn).
RULES:
- Do NOT rewrite any existing content
- Return exactly 1 or 2 high-impact insertions
- Choose the best fitting post(s) from the provided list
- Use the correct component based on the platform:
- 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.
CONTEXT:
${context.slice(0, 3000)}
SOCIAL POSTS AVAILABLE TO EMBED:
${postsText}
EXISTING SECTIONS:
${sectionMap}
Return JSON:
{ "insertions": [{ "afterSection": 4, "content": "<Paragraph>Wie Experten passend bemerken:</Paragraph>\\n\\n<TwitterEmbed tweetId=\\"123456\\" theme=\\"light\\" />" }] }
Return ONLY the JSON.`,
},
],
response_format: { type: "json_object" },
});
const result = safeParseJSON(
response.choices[0].message.content || '{"insertions": []}',
{ insertions: [] },
);
return (result.insertions || []).filter(
(i: any) =>
typeof i.afterSection === "number" &&
i.afterSection >= 0 &&
i.afterSection < sections.length &&
typeof i.content === "string",
);
}
// --- Component insertion planning (Claude Sonnet — understands JSX context) ---
private async planComponentInsertions(
sectionMap: string,
sections: string[],
components: ComponentDefinition[],
context: string,
): Promise<Insertion[]> {
const fullContent = sections.join("\n\n");
const componentsText = components
.map((c) => `<${c.name}>: ${c.description}\n Example: ${c.usageExample}`)
.join("\n\n");
const usedComponents = components
.filter((c) => fullContent.includes(`<${c.name}`))
.map((c) => c.name);
const response = await this.openai.chat.completions.create({
model: MODELS.CONTENT,
messages: [
{
role: "system",
content: `You enhance a German blog post by ADDING interactive UI components.
STRICT BALANCE RULES:
- Maximum 34 component additions total
- There MUST be at least 34 text paragraphs between any two visual components
- Visual components MUST NEVER appear directly after each other
- Each unique component type should only appear ONCE (e.g., only one WebVitalsScore, one WaterfallChart)
- Multiple MetricBar or ComparisonRow in sequence are OK (they form a group)
CONTENT RULES:
- Do NOT rewrite any existing content — only ADD new component blocks
- Do NOT add components already present: ${usedComponents.join(", ") || "none"}
- Statistics MUST have comparison context (before/after, competitor vs us) — never standalone numbers
- All BoldNumber components MUST include source and sourceUrl props
- All ArticleQuote components MUST include source and sourceUrl; add "(übersetzt)" if translated
- MetricBar value must be a real number > 0, not placeholder zeros
- Carousel items array must have at least 2 items with substantive content
- Use exact JSX syntax from the examples
CONTEXT:
${context.slice(0, 3000)}
EXISTING SECTIONS (read-only):
${sectionMap}
AVAILABLE COMPONENTS:
${componentsText}
Return JSON:
{ "insertions": [{ "afterSection": 5, "content": "<StatsDisplay value=\\"100\\" label=\\"PageSpeed Score\\" subtext=\\"Kein Kompromiss.\\" />" }] }
Return ONLY the JSON.`,
},
],
response_format: { type: "json_object" },
});
const result = safeParseJSON(
response.choices[0].message.content || '{"insertions": []}',
{ insertions: [] },
);
return (result.insertions || []).filter(
(i: any) =>
typeof i.afterSection === "number" &&
i.afterSection >= 0 &&
i.afterSection < sections.length &&
typeof i.content === "string",
);
}
// --- Diagram planning (Gemini Flash — structured output) ---
private async planDiagramInsertions(
sectionMap: string,
sections: string[],
context: string,
): Promise<{ afterSection: number; concept: string }[]> {
const fullContent = sections.join("\n\n");
const hasDiagrams =
fullContent.includes("<Mermaid") || fullContent.includes("<Diagram");
const response = await this.openai.chat.completions.create({
model: MODELS.STRUCTURED,
messages: [
{
role: "system",
content: `Analyze this German blog post and suggest 1-2 Mermaid diagrams.
${hasDiagrams ? "The post already has diagrams. Only suggest NEW concepts not already visualized." : ""}
${context.slice(0, 1500)}
SECTIONS:
${sectionMap}
Return JSON:
{ "diagrams": [{ "afterSection": 5, "concept": "Descriptive concept name" }] }
Maximum 2 diagrams. Return ONLY the JSON.`,
},
],
response_format: { type: "json_object" },
});
const result = safeParseJSON(
response.choices[0].message.content || '{"diagrams": []}',
{ diagrams: [] },
);
return (result.diagrams || []).filter(
(d: any) =>
typeof d.afterSection === "number" &&
d.afterSection >= 0 &&
d.afterSection < sections.length,
);
}
// --- Meme placement planning (Gemini Flash — structural positioning) ---
private async planMemePlacements(
sectionMap: string,
sections: string[],
memes: MemeSuggestion[],
): Promise<number[]> {
const memesText = memes
.map((m, i) => `${i}: "${m.template}" — ${m.captions.join(" / ")}`)
.join("\n");
const response = await this.openai.chat.completions.create({
model: MODELS.STRUCTURED,
messages: [
{
role: "system",
content: `Place ${memes.length} memes at appropriate positions in this blog post.
Rules: Space them out evenly, place between thematic sections, never at position 0 (the very start).
SECTIONS:
${sectionMap}
MEMES:
${memesText}
Return JSON: { "placements": [sectionNumber, sectionNumber, ...] }
One section number per meme, in the same order as the memes list. Return ONLY JSON.`,
},
],
response_format: { type: "json_object" },
});
const result = safeParseJSON(
response.choices[0].message.content || '{"placements": []}',
{ placements: [] },
);
return result.placements || [];
}
// =========================================================================
// SHARED HELPERS
// =========================================================================
private async createOutline(
topic: string,
facts: Fact[],
tone: string,
): Promise<{ title: string; sections: string[] }> {
const factsContext = facts
.map((f) => `- ${f.statement} (${f.source})`)
.join("\n");
const response = await this.openai.chat.completions.create({
model: MODELS.STRUCTURED,
messages: [
{
role: "system",
content: `Create a blog post outline on "${topic}".
Tone: ${tone}.
Incorporating these facts:
${factsContext}
Return JSON: { "title": "Catchy Title", "sections": ["Introduction", "Section 1", "Conclusion"] }
Return ONLY the JSON.`,
},
],
response_format: { type: "json_object" },
});
return safeParseJSON(
response.choices[0].message.content || '{"title": "", "sections": []}',
{ title: "", sections: [] },
);
}
private async draftContent(
topic: string,
outline: { title: string; sections: string[] },
facts: Fact[],
tone: string,
components: ComponentDefinition[],
): Promise<string> {
const factsContext = facts
.map((f) => `- ${f.statement} (Source: ${f.source})`)
.join("\n");
const componentsContext =
components.length > 0
? `\n\nAvailable Components:\n` +
components
.map(
(c) =>
`- <${c.name}>: ${c.description}\n Example: ${c.usageExample}`,
)
.join("\n")
: "";
const response = await this.openai.chat.completions.create({
model: MODELS.CONTENT,
messages: [
{
role: "system",
content: `Write a blog post based on this outline:
Title: ${outline.title}
Sections: ${outline.sections.join(", ")}
Tone: ${tone}.
Facts: ${factsContext}
${componentsContext}
Format as Markdown. Start with # H1.
For places where a diagram would help, insert: <!-- DIAGRAM_PLACEHOLDER: Concept Name -->
Return ONLY raw content.`,
},
],
});
return response.choices[0].message.content || "";
}
private async processDiagramPlaceholders(
content: string,
diagrams: string[],
): Promise<string> {
const matches = content.matchAll(/<!-- DIAGRAM_PLACEHOLDER: (.+?) -->/g);
let processedContent = content;
for (const match of Array.from(matches)) {
const concept = match[1];
const diagram = await this.generateMermaid(concept);
diagrams.push(diagram);
const diagramId = concept
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9-]/g, "")
.slice(0, 40);
const mermaidJsx = `\n<div className="my-8">\n <Mermaid id="${diagramId}" title="${concept}" showShare={true}>\n${diagram}\n </Mermaid>\n</div>\n`;
processedContent = processedContent.replace(
`<!-- DIAGRAM_PLACEHOLDER: ${concept} -->`,
mermaidJsx,
);
}
return processedContent;
}
private async generateMermaid(concept: string): Promise<string> {
const response = await this.openai.chat.completions.create({
model: MODELS.DIAGRAM,
messages: [
{
role: "system",
content: `Generate a Mermaid.js diagram for: "${concept}".
RULES:
- Use clear labels in German where appropriate
- Keep it EXTREMELY SIMPLE AND COMPACT: strictly max 3-4 nodes for a tiny visual footprint.
- Prefer vertical layouts (TD) over horizontal (LR) to prevent wide overflowing graphs.
- 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 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.
- The first line MUST be a valid mermaid diagram type: graph, flowchart, sequenceDiagram, pie, gantt, stateDiagram, timeline`,
},
],
});
const code =
response.choices[0].message.content
?.replace(/```mermaid/g, "")
.replace(/```/g, "")
.trim() || "";
// Validate: must start with a valid mermaid keyword
const validStarts = [
"graph",
"flowchart",
"sequenceDiagram",
"pie",
"gantt",
"stateDiagram",
"timeline",
"classDiagram",
"erDiagram",
];
const firstLine = code.split("\n")[0]?.trim().toLowerCase() || "";
const isValid = validStarts.some((keyword) =>
firstLine.startsWith(keyword),
);
if (!isValid || code.length < 10) {
console.warn(
`⚠️ Mermaid: Invalid diagram generated for "${concept}", skipping`,
);
return "";
}
return code;
}
private async identifyResearchTopics(
content: string,
context: string,
): Promise<string[]> {
try {
console.log("Sending request to OpenRouter...");
const response = await this.openai.chat.completions.create({
model: MODELS.STRUCTURED,
messages: [
{
role: "system",
content: `Analyze the following blog post and identify 3 key topics or claims that would benefit from statistical data or external verification.
Return relevant, specific research queries (not too broad).
Context: ${context.slice(0, 1500)}
Return JSON: { "topics": ["topic 1", "topic 2", "topic 3"] }
Return ONLY the JSON.`,
},
{
role: "user",
content: content.slice(0, 4000),
},
],
response_format: { type: "json_object" },
});
console.log("Got response from OpenRouter");
const parsed = safeParseJSON(
response.choices[0].message.content || '{"topics": []}',
{ topics: [] },
);
return (parsed.topics || []).map((t: any) =>
typeof t === "string" ? t : JSON.stringify(t),
);
} catch (e: any) {
console.error("Error in identifyResearchTopics:", e);
throw e;
}
}
}

View File

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

View File

@@ -0,0 +1,350 @@
import OpenAI from "openai";
import { ResearchAgent, Fact, SocialPost } from "@mintel/journaling";
import { ComponentDefinition } from "./generator";
import * as fs from "node:fs/promises";
import * as path from "node:path";
export interface OrchestratorConfig {
apiKey: string;
model?: string;
}
export interface OptimizationTask {
content: string;
projectContext: string;
availableComponents?: ComponentDefinition[];
instructions?: string;
}
export interface OptimizeFileOptions {
contextDir: string;
availableComponents?: ComponentDefinition[];
}
export class AiBlogPostOrchestrator {
private openai: OpenAI;
private researchAgent: ResearchAgent;
private model: string;
constructor(config: OrchestratorConfig) {
this.model = config.model || "google/gemini-3-flash-preview";
this.openai = new OpenAI({
apiKey: config.apiKey,
baseURL: "https://openrouter.ai/api/v1",
defaultHeaders: {
"HTTP-Referer": "https://mintel.me",
"X-Title": "Mintel AI Blog Post Orchestrator",
},
});
this.researchAgent = new ResearchAgent(config.apiKey);
}
/**
* Reusable context loader. Loads all .md and .txt files from a directory into a single string.
*/
async loadContext(dirPath: string): Promise<string> {
try {
const resolvedDir = path.resolve(process.cwd(), dirPath);
const files = await fs.readdir(resolvedDir);
const textFiles = files.filter((f) => /\.(md|txt)$/i.test(f)).sort();
const contents: string[] = [];
for (const file of textFiles) {
const filePath = path.join(resolvedDir, file);
const text = await fs.readFile(filePath, "utf8");
contents.push(`=== ${file} ===\n${text.trim()}`);
}
return contents.join("\n\n");
} catch (e) {
console.warn(`⚠️ Could not load context from ${dirPath}: ${e}`);
return "";
}
}
/**
* Reads a file, extracts frontmatter, loads context, optimizes body, and writes it back.
*/
async optimizeFile(
targetFile: string,
options: OptimizeFileOptions,
): Promise<void> {
const absPath = path.isAbsolute(targetFile)
? targetFile
: path.resolve(process.cwd(), targetFile);
console.log(`📄 Processing File: ${path.basename(absPath)}`);
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;
console.log(`📖 Loading context from: ${options.contextDir}`);
const projectContext = await this.loadContext(options.contextDir);
if (!projectContext) {
console.warn(
"⚠️ No project context loaded. AI might miss specific guidelines.",
);
}
const optimizedContent = await this.optimizeDocument({
content: body,
projectContext,
availableComponents: options.availableComponents,
});
const finalOutput = frontmatter
? `${frontmatter}\n\n${optimizedContent}`
: optimizedContent;
await fs.writeFile(`${absPath}.bak`, content); // Keep simple backup
await fs.writeFile(absPath, finalOutput);
console.log(`✅ Saved optimized file to: ${absPath}`);
}
/**
* Executes the 3-step optimization pipeline:
* 1. Fakten recherchieren
* 2. Social Posts recherchieren
* 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...");
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);
}
// 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),
);
// 3. AI anweisen daraus Artikel zu erstellen
console.log("3⃣ Erstelle optimierten Artikel (Agentic Rewrite)...");
return await this.compileArticle(task, facts, socialPosts);
}
private async identifyTopics(content: string): Promise<string[]> {
const response = await this.openai.chat.completions.create({
model: "google/gemini-2.5-flash", // fast structured model for topic extraction
messages: [
{
role: "system",
content: `Analyze the following blog post and identify 1 to 2 key topics or claims that would benefit from statistical data or external verification.
Return JSON: { "topics": ["topic 1", "topic 2"] }
Return ONLY the JSON.`,
},
{
role: "user",
content: content.slice(0, 4000),
},
],
response_format: { type: "json_object" },
});
try {
const raw = response.choices[0].message.content || '{"topics": []}';
const cleaned = raw
.trim()
.replace(/^```(?:json)?\s*\n?/, "")
.replace(/\n?```\s*$/, "");
const parsed = JSON.parse(cleaned);
return parsed.topics || [];
} catch (e) {
console.warn("⚠️ Failed to parse research topics", e);
return [];
}
}
private async compileArticle(
task: OptimizationTask,
facts: Fact[],
socialPosts: SocialPost[],
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");
const componentsText = (task.availableComponents || [])
.map((c) => `<${c.name}>: ${c.description}\n Example: ${c.usageExample}`)
.join("\n\n");
const response = await this.openai.chat.completions.create({
model: this.model,
messages: [
{
role: "system",
content: `You are an expert MDX Editor and Digital Architect.
YOUR TASK:
Take the given draft blog post and rewrite/enhance it into a final, error-free MDX file. Maintain the author's original German text, meaning, and tone, but enrich it gracefully.
CONTEXT & RULES:
Project Context / Tone:
${task.projectContext}
Facts to weave in:
${factsText || "None"}
Social Media Posts to embed (use <YouTubeEmbed videoId="..." />, <TwitterEmbed tweetId="..." />, or <LinkedInEmbed url="..." />):
${socialText || "None"}
Available MDX Components you can use contextually:
${componentsText || "None"}
Special Instructions from User:
${task.instructions || "None"}
BLOG POST BEST PRACTICES (MANDATORY):
- 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).
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 ... \`\`\`).
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.
8. Do NOT hallucinate links or facts. Use only what is provided.`,
},
{
role: "user",
content: task.content,
},
],
});
let rawContent = response.choices[0].message.content || task.content;
rawContent = this.cleanResponse(rawContent);
// Validation Layer: Check Mermaid syntax
if (retryCount < 2 && rawContent.includes("<Mermaid>")) {
console.log("🔍 Validating Mermaid syntax in AI response...");
const mermaidBlocks = this.extractMermaidBlocks(rawContent);
let hasError = false;
let errorFeedback = "";
for (const block of mermaidBlocks) {
const validationResult = await this.validateMermaidSyntax(block);
if (!validationResult.valid) {
hasError = true;
errorFeedback += `\nInvalid Mermaid block:\n${block}\nError context: ${validationResult.error}\n\n`;
}
}
if (hasError) {
console.log(
`❌ Invalid Mermaid syntax detected. Retrying compilation (Attempt ${retryCount + 1}/2)...`,
);
return this.compileArticle(
{
...task,
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,
socialPosts,
retryCount + 1,
);
}
}
return rawContent;
}
private extractMermaidBlocks(content: string): string[] {
const blocks: string[] = [];
// Regex to match <Mermaid>...</Mermaid> blocks across multiple lines
const regex = /<Mermaid>([\s\S]*?)<\/Mermaid>/g;
let match;
while ((match = regex.exec(content)) !== null) {
if (match[1]) {
blocks.push(match[1].trim());
}
}
return blocks;
}
private async validateMermaidSyntax(
graph: string,
): Promise<{ valid: boolean; error?: string }> {
// Fast LLM validation to catch common syntax errors like unbalanced quotes or HTML entities
try {
const validationResponse = await this.openai.chat.completions.create({
model: "google/gemini-3-flash-preview", // Switch from gpt-4o-mini to user requested model
messages: [
{
role: "system",
content:
'You are a strict Mermaid.js compiler. Analyze the given Mermaid syntax. If it is 100% valid and will render without exceptions, reply ONLY with "VALID". If it has syntax errors (e.g., HTML inside labels, unescaped quotes, unclosed brackets), reply ONLY with "INVALID" followed by a short explanation of the exact error.',
},
{
role: "user",
content: graph,
},
],
});
const reply =
validationResponse.choices[0].message.content?.trim() || "VALID";
if (reply.startsWith("INVALID")) {
return { valid: false, error: reply };
}
return { valid: true };
} catch (e) {
console.error("Syntax validation LLM call failed, passing through:", e);
return { valid: true }; // Fallback to passing if validator fails
}
}
/**
* Post-processing to ensure the AI didn't include "help" text,
* duplicate frontmatter, or markdown wrappers.
*/
private cleanResponse(content: string): string {
let cleaned = content.trim();
// 1. Strip Markdown Wrappers (e.g. ```mdx ... ```)
if (cleaned.startsWith("```")) {
cleaned = cleaned
.replace(/^```[a-zA-Z]*\n?/, "")
.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();
}
return cleaned;
}
}

View File

@@ -0,0 +1,11 @@
{
"extends": "@mintel/tsconfig/base.json",
"compilerOptions": {
"module": "ESNext",
"target": "ESNext",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"noEmit": true
},
"include": ["src"]
}

View File

@@ -45,14 +45,19 @@
<v-button secondary rounded icon v-tooltip.bottom="'Kunden-Verlinkung bearbeiten'" @click="openEditDrawer">
<v-icon name="edit" />
</v-button>
<v-button primary @click="openCreateClientUser">
Portal-Nutzer hinzufügen
</v-button>
<div @click="onDebugClick" style="display: inline-block; border: 2px solid lime;">
<v-button primary @click="openCreateClientUser">
Portal-Nutzer hinzufügen
</v-button>
</div>
<button style="background: red; color: white; padding: 4px 8px; border-radius: 4px; border: none; cursor: pointer; margin-left: 10px;" @click="onDebugClick">DEBUG</button>
<button style="background: blue; color: white; padding: 8px 16px; border-radius: 4px; border: none; cursor: pointer; margin-left: 10px;" @click="openCreateClientUser">NATIVE: Portal-Nutzer</button>
</template>
<template #empty-state>
Wähle einen Kunden aus der Liste oder
<v-button x-small @click="openCreateDrawer">verlinke eine neue Firma</v-button>.
<button id="debug-click-test" style="background: red; color: white; padding: 4px 8px; border-radius: 4px; border: none; cursor: pointer; margin-left: 10px;" @click="onDebugClick">DEBUG CLICK</button>
</template>
<!-- Main Content: Client Users Table -->
@@ -257,6 +262,11 @@ import { MintelManagerLayout, MintelSelect } from '@mintel/directus-extension-to
const api = useApi();
const route = useRoute();
function onDebugClick() {
console.log("=== [Customer Manager] DEBUG CLICK TRAPPED ===");
alert("Interactivity OK!");
}
const items = ref<any[]>([]);
const selectedItem = ref<any>(null);
const clientUsers = ref<any[]>([]);

View File

@@ -0,0 +1,50 @@
#!/bin/bash
set -e
# Configuration
REGISTRY_DATA="/mnt/HC_Volume_104575103/registry-data/docker/registry/v2"
KEEP_TAGS=3
echo "🏥 Starting Aggressive Mintel Infrastructure Optimization..."
# 1. Prune Registry Tags (Filesystem level)
if [ -d "$REGISTRY_DATA" ]; then
echo "🔍 Processing Registry tags..."
for repo_dir in "$REGISTRY_DATA/repositories/mintel/"*; do
[ -e "$repo_dir" ] || continue
repo_name=$(basename "$repo_dir")
# EXCLUDE base images from pruning to prevent breaking downstream builds
if [[ "$repo_name" == "runtime" || "$repo_name" == "nextjs" || "$repo_name" == "gatekeeper" ]]; then
echo " 🛡️ Skipping protected repository: mintel/$repo_name"
continue
fi
tags_dir="$repo_dir/_manifests/tags"
if [ -d "$tags_dir" ]; then
echo " 📦 Pruning mintel/$repo_name..."
# Note: keeping latest and up to KEEP_TAGS of each pattern
PATTERNS=("main-*" "testing-*" "branch-*" "v*" "rc*" "[0-9a-f]*")
for pattern in "${PATTERNS[@]}"; do
find "$tags_dir" -maxdepth 1 -name "$pattern" -print0 2>/dev/null | xargs -0 ls -dt 2>/dev/null | tail -n +$((KEEP_TAGS + 1)) | xargs rm -rf 2>/dev/null || true
done
rm -rf "$tags_dir/buildcache"* 2>/dev/null || true
fi
done
fi
# 2. Registry Garbage Collection
REGISTRY_CONTAINER=$(docker ps --format "{{.Names}}" | grep registry | head -1 || true)
if [ -n "$REGISTRY_CONTAINER" ]; then
echo "♻️ Running Registry GC..."
docker exec "$REGISTRY_CONTAINER" bin/registry garbage-collect /etc/docker/registry/config.yml --delete-untagged || true
fi
# 3. Global Docker Pruning
echo "🧹 Pruning Docker resources..."
docker system prune -af --filter "until=24h"
docker volume prune -f
echo "✅ Optimization complete!"
df -h /mnt/HC_Volume_104575103

View File

@@ -0,0 +1,32 @@
{
"name": "@mintel/journaling",
"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": {
"axios": "^1.6.0",
"google-trends-api": "^4.9.2",
"openai": "^4.82.0"
},
"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,276 @@
import OpenAI from "openai";
import { DataCommonsClient } from "./clients/data-commons";
import { TrendsClient } from "./clients/trends";
export interface Fact {
statement: string;
source: string;
url?: string;
confidence: "high" | "medium" | "low";
data?: any;
}
export interface SocialPost {
platform: "youtube" | "twitter" | "linkedin";
embedId: string;
description: string;
}
export class ResearchAgent {
private openai: OpenAI;
private dcClient: DataCommonsClient;
private trendsClient: TrendsClient;
constructor(apiKey: string) {
this.openai = new OpenAI({
apiKey,
baseURL: "https://openrouter.ai/api/v1",
defaultHeaders: {
"HTTP-Referer": "https://mintel.me",
"X-Title": "Mintel Journaling Agent",
},
});
this.dcClient = new DataCommonsClient();
this.trendsClient = new TrendsClient();
}
async researchTopic(topic: string): Promise<Fact[]> {
console.log(`🔎 Researching: ${topic}`);
// 1. Plan Research
const plan = await this.planResearch(topic);
console.log(`📋 Research Plan:`, plan);
const facts: Fact[] = [];
// 2. Execute Plan
// Google Trends
for (const kw of plan.trendsKeywords) {
try {
const data = await this.trendsClient.getInterestOverTime(kw);
if (data.length > 0) {
// Analyze trend
const latest = data[data.length - 1];
const max = Math.max(...data.map((d) => d.value));
facts.push({
statement: `Interest in "${kw}" is currently at ${latest.value}% of peak popularity.`,
source: "Google Trends",
confidence: "high",
data: data.slice(-5), // Last 5 points
});
}
} catch (e) {
console.error(`Error fetching trends for ${kw}`, e);
}
}
// Data Commons
// We need DCIDs. LLM should have provided them or we need a search.
// For this POC, let's assume the LLM provides plausible DCIDs or we skip deep DC integration for now
// and rely on the LLM's own knowledge + the verified trends.
// However, if the plan has dcVariables, let's try.
// 3. Synthesize & Verify
// Ask LLM to verify its own knowledge against the data we found (if any) or just use its training data
// but formatted as "facts".
const synthesis = await this.openai.chat.completions.create({
model: "google/gemini-2.0-flash-001",
messages: [
{
role: "system",
content: `You are a professional digital researcher and fact-checker.
Topic: "${topic}"
Your Goal: Provide 5-7 concrete, verifiable, statistical facts.
Constraint 1: Cite real sources (e.g. "Google Developers", "HTTP Archive", "Deloitte", "Nielsen Norman Group").
Constraint 2: DO NOT cite "General Knowledge".
Constraint 3: CRITICAL MANDATE - NEVER generate or guess URLs. You must hallucinate NO links. Use ONLY the Organization's Name as the "source" field.
Return JSON: { "facts": [ { "statement": "...", "source": "Organization Name Only", "confidence": "high" } ] }`,
},
{ role: "user", content: "Extract facts." },
],
response_format: { type: "json_object" },
});
if (
!synthesis.choices ||
synthesis.choices.length === 0 ||
!synthesis.choices[0].message
) {
console.warn(`⚠️ Research synthesis failed for concept: "${topic}"`);
return [];
}
const result = JSON.parse(synthesis.choices[0].message.content || "{}");
return result.facts || [];
}
async findSocialPosts(
topic: string,
retries = 2,
previousFailures: string[] = [],
): Promise<SocialPost[]> {
console.log(
`📱 Searching for relevant Social Media Posts: "${topic}"${retries < 2 ? ` (Retry ${2 - retries}/2)` : ""}`,
);
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",
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.`,
},
],
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}"`);
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(
topic: string,
): Promise<{ trendsKeywords: string[]; dcVariables: string[] }> {
const response = await this.openai.chat.completions.create({
model: "google/gemini-2.0-flash-001",
messages: [
{
role: "system",
content: `Plan research for: "${topic}".
Return JSON:
{
"trendsKeywords": ["list", "of", "max", "2", "keywords"],
"dcVariables": ["StatisticalVariables", "if", "known", "otherwise", "empty"]
}
CRITICAL: Do NOT provide more than 2 trendsKeywords. Keep it extremely focused.`,
},
],
response_format: { type: "json_object" },
});
if (
!response.choices ||
response.choices.length === 0 ||
!response.choices[0].message
) {
console.warn(`⚠️ Research planning failed for concept: "${topic}"`);
return { trendsKeywords: [], dcVariables: [] };
}
try {
let parsed = JSON.parse(
response.choices[0].message.content ||
'{"trendsKeywords": [], "dcVariables": []}',
);
if (Array.isArray(parsed)) {
parsed = parsed[0] || { trendsKeywords: [], dcVariables: [] };
}
return {
trendsKeywords: Array.isArray(parsed.trendsKeywords)
? parsed.trendsKeywords
: [],
dcVariables: Array.isArray(parsed.dcVariables)
? parsed.dcVariables
: [],
};
} catch (e) {
console.error("Failed to parse research plan JSON", e);
return { trendsKeywords: [], dcVariables: [] };
}
}
}

View File

@@ -0,0 +1,52 @@
import axios from "axios";
export interface DataPoint {
date: string;
value: number;
}
export class DataCommonsClient {
private baseUrl = "https://api.datacommons.org";
/**
* Fetches statistical series for a specific variable and place.
* @param placeId DCID of the place (e.g., 'country/DEU' for Germany)
* @param variable DCID of the statistical variable (e.g., 'Count_Person')
*/
async getStatSeries(placeId: string, variable: string): Promise<DataPoint[]> {
try {
// https://docs.datacommons.org/api/rest/v2/stat_series
const response = await axios.get(`${this.baseUrl}/v2/stat/series`, {
params: {
place: placeId,
stat_var: variable,
},
});
// Response format: { "series": { "country/DEU": { "Count_Person": { "val": { "2020": 83166711, ... } } } } }
const seriesData = response.data?.series?.[placeId]?.[variable]?.val;
if (!seriesData) {
return [];
}
return Object.entries(seriesData)
.map(([date, value]) => ({ date, value: Number(value) }))
.sort((a, b) => a.date.localeCompare(b.date));
} catch (error) {
console.error(`DataCommons Error (${placeId}, ${variable}):`, error);
return [];
}
}
/**
* Search for entities (places, etc.)
*/
async resolveEntity(name: string): Promise<string | null> {
// Search API or simple mapping for now.
// DC doesn't have a simple "search" endpoint in v2 public API easily accessible without key sometimes?
// Let's rely on LLM to provide DCIDs for now, or implement a naive search if needed.
// For now, return null to force LLM to guess/know DCIDs.
return null;
}
}

View File

@@ -0,0 +1,79 @@
import OpenAI from "openai";
export interface TrendPoint {
date: string;
value: number;
}
export class TrendsClient {
private openai: OpenAI;
constructor(apiKey?: string) {
// Use environment key if available, otherwise expect it passed
const key = apiKey || process.env.OPENROUTER_KEY || "dummy";
this.openai = new OpenAI({
apiKey: key,
baseURL: "https://openrouter.ai/api/v1",
defaultHeaders: {
"HTTP-Referer": "https://mintel.me",
"X-Title": "Mintel Trends Engine",
},
});
}
/**
* Simulates interest over time using LLM knowledge to avoid flaky scraping.
* This ensures the "Digital Architect" pipelines don't break on API changes.
*/
async getInterestOverTime(
keyword: string,
geo: string = "DE",
): Promise<TrendPoint[]> {
console.log(
`📈 Simuliere Suchvolumen-Trend (AI-basiert) für: "${keyword}" (Region: ${geo})...`,
);
try {
const response = await this.openai.chat.completions.create({
model: "google/gemini-2.5-flash",
messages: [
{
role: "system",
content: `You are a data simulator. Generate a realistic Google Trends-style JSON dataset for the keyword "${keyword}" in "${geo}" over the last 5 years.
Rules:
- 12 data points (approx one every 6 months or represent key moments).
- Values between 0-100.
- JSON format: { "timeline": [{ "date": "YYYY-MM", "value": 50 }] }
- Return ONLY JSON.`,
},
],
response_format: { type: "json_object" },
});
const body = response.choices[0].message.content || "{}";
const parsed = JSON.parse(body);
return parsed.timeline || [];
} catch (error) {
console.warn(`Simulated Trend Error (${keyword}):`, error);
// Fallback mock data
return [
{ date: "2020-01", value: 20 },
{ date: "2021-01", value: 35 },
{ date: "2022-01", value: 50 },
{ date: "2023-01", value: 75 },
{ date: "2024-01", value: 95 },
];
}
}
async getRelatedQueries(
keyword: string,
geo: string = "DE",
): Promise<string[]> {
// Simple mock to avoid API calls
return [
`${keyword} optimization`,
`${keyword} tutorial`,
`${keyword} best practices`,
];
}
}

View File

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

View File

@@ -0,0 +1,17 @@
declare module "google-trends-api" {
export function interestOverTime(options: {
keyword: string | string[];
startTime?: Date;
endTime?: Date;
geo?: string;
hl?: string;
timezone?: number;
category?: number;
}): Promise<string>;
export function interestByRegion(options: any): Promise<string>;
export function relatedQueries(options: any): Promise<string>;
export function relatedTopics(options: any): Promise<string>;
export function dailyTrends(options: any): Promise<string>;
export function realTimeTrends(options: any): Promise<string>;
}

View File

@@ -0,0 +1,11 @@
{
"extends": "@mintel/tsconfig/base.json",
"compilerOptions": {
"module": "ESNext",
"target": "ESNext",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"noEmit": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,29 @@
{
"name": "@mintel/meme-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": {
"openai": "^4.82.0"
},
"devDependencies": {
"@mintel/eslint-config": "workspace:*",
"@mintel/tsconfig": "workspace:*",
"tsup": "^8.3.5",
"typescript": "^5.0.0"
}
}

View File

@@ -0,0 +1,141 @@
import OpenAI from "openai";
export interface MemeSuggestion {
template: string;
captions: string[];
explanation: string;
}
/**
* Mapping of common meme names to memegen.link template IDs.
* See https://api.memegen.link/templates for the full list.
*/
export const MEMEGEN_TEMPLATES: Record<string, string> = {
drake: "drake",
"drake hotline bling": "drake",
"distracted boyfriend": "db",
distracted: "db",
"expanding brain": "brain",
expanding: "brain",
"this is fine": "fine",
fine: "fine",
clown: "clown-applying-makeup",
"clown applying makeup": "clown-applying-makeup",
"two buttons": "daily-struggle",
"daily struggle": "daily-struggle",
ds: "daily-struggle",
gru: "gru",
"change my mind": "cmm",
"always has been": "ahb",
"uno reverse": "uno",
"disaster girl": "disastergirl",
"is this a pigeon": "pigeon",
"roll safe": "rollsafe",
rollsafe: "rollsafe",
"surprised pikachu": "pikachu",
"batman slapping robin": "slap",
"left exit 12": "exit",
"one does not simply": "mordor",
"panik kalm panik": "panik",
};
/**
* Resolve a human-readable meme name to a memegen.link template ID.
* Falls back to slugified version of the name.
*/
export function resolveTemplateId(name: string): string {
if (!name) return "drake";
const normalized = name.toLowerCase().trim();
// Check if it's already a valid memegen ID
const validIds = new Set(Object.values(MEMEGEN_TEMPLATES));
if (validIds.has(normalized)) return normalized;
// Check mapping
if (MEMEGEN_TEMPLATES[normalized]) return MEMEGEN_TEMPLATES[normalized];
// STRICT FALLBACK: Prevent 404 image errors on the frontend
return "drake";
}
export class MemeGenerator {
private openai: OpenAI;
constructor(
apiKey: string,
baseUrl: string = "https://openrouter.ai/api/v1",
) {
this.openai = new OpenAI({
apiKey,
baseURL: baseUrl,
defaultHeaders: {
"HTTP-Referer": "https://mintel.me",
"X-Title": "Mintel AI Meme Generator",
},
});
}
async generateMemeIdeas(content: string): Promise<MemeSuggestion[]> {
const templateList = Object.keys(MEMEGEN_TEMPLATES)
.filter((k, i, arr) => arr.indexOf(k) === i)
.slice(0, 20)
.join(", ");
const response = await this.openai.chat.completions.create({
model: "google/gemini-2.5-flash",
messages: [
{
role: "system",
content: `You are a high-end Meme Architect for "Mintel.me", a boutique digital architecture studio.
Your persona is Marc Mintel: a technical expert, performance-obsessed, and "no-BS" digital architect.
Your Goal: Analyze the blog post content and suggest 3 high-fidelity, highly sarcastic, and provocative technical memes that would appeal to (and trigger) CEOs, CTOs, and high-level marketing engineers.
Meme Guidelines:
1. Tone: Extremely sarcastic, provocative, and "triggering". It must mock typical B2B SaaS/Agency mediocrity. Pure sarcasm that forces people to share it because it hurts (e.g. throwing 20k ads at an 8-second loading page, blaming weather for bounce rates).
2. Language: Use German for the captions. Use biting technical/business terms (e.g., "ROI-Killer", "Tracking-Müll", "WordPress-Hölle", "Marketing-Budget verbrennen").
3. Quality: Must be ruthless. Avoid generic "Low Effort" memes. The humor should stem from the painful reality of bad tech decisions.
IMPORTANT: Use ONLY template IDs from this list for the "template" field:
${templateList}
Return ONLY a JSON object:
{
"memes": [
{
"template": "memegen_template_id",
"captions": ["Top caption", "Bottom caption"],
"explanation": "Brief context on why this fits the strategy"
}
]
}
IMPORTANT: Return ONLY the JSON object. No markdown wrappers.`,
},
{
role: "user",
content,
},
],
response_format: { type: "json_object" },
});
const body = response.choices[0].message.content || '{"memes": []}';
let result;
try {
result = JSON.parse(body);
} catch (e) {
console.error("Failed to parse AI response", body);
return [];
}
// Normalize template IDs
const memes: MemeSuggestion[] = (result.memes || []).map(
(m: MemeSuggestion) => ({
...m,
template: resolveTemplateId(m.template),
}),
);
return memes;
}
}

View File

@@ -0,0 +1,14 @@
export function getPlaceholderImage(
width: number,
height: number,
text: string,
): string {
// Generate a simple SVG placeholder as base64
const svg = `
<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
<rect width="100%" height="100%" fill="#1e293b"/>
<text x="50%" y="50%" font-family="monospace" font-size="24" fill="#64748b" text-anchor="middle" dy=".3em">${text}</text>
</svg>
`.trim();
return Buffer.from(svg).toString("base64");
}

View File

@@ -0,0 +1,11 @@
{
"extends": "@mintel/tsconfig/base.json",
"compilerOptions": {
"module": "ESNext",
"target": "ESNext",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"noEmit": true
},
"include": ["src"]
}

View File

@@ -88,7 +88,8 @@ async function fetchStats() {
}
function navigateTo(id: string, query?: any) {
router.push({ name: `module-${id}`, query });
console.log(`[Unified Dashboard] Navigating to ${id}...`);
router.push({ name: id, query });
}
onMounted(fetchStats);

312
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.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)
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)
'@mintel/mail':
specifier: workspace:*
version: link:../mail
@@ -262,6 +262,37 @@ importers:
specifier: ^3.4.0
version: 3.5.28(typescript@5.9.3)
packages/content-engine:
dependencies:
'@mintel/journaling':
specifier: workspace:*
version: link:../journaling
'@mintel/meme-generator':
specifier: workspace:*
version: link:../meme-generator
dotenv:
specifier: ^17.3.1
version: 17.3.1
openai:
specifier: ^4.82.0
version: 4.104.0(ws@8.19.0)(zod@3.25.76)
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/customer-manager:
dependencies:
'@mintel/directus-extension-toolkit':
@@ -408,6 +439,34 @@ importers:
specifier: ^5.0.0
version: 5.9.3
packages/journaling:
dependencies:
axios:
specifier: ^1.6.0
version: 1.13.5
google-trends-api:
specifier: ^4.9.2
version: 4.9.2
openai:
specifier: ^4.82.0
version: 4.104.0(ws@8.19.0)(zod@3.25.76)
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/mail:
dependencies:
'@react-email/components':
@@ -443,7 +502,26 @@ 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)(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(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)
packages/meme-generator:
dependencies:
openai:
specifier: ^4.82.0
version: 4.104.0(ws@8.19.0)(zod@3.25.76)
devDependencies:
'@mintel/eslint-config':
specifier: workspace:*
version: link:../eslint-config
'@mintel/tsconfig':
specifier: workspace:*
version: link:../tsconfig
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/next-config:
dependencies:
@@ -605,7 +683,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)(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(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)
packages/pdf-library:
dependencies:
@@ -3265,9 +3343,15 @@ packages:
'@types/mysql@2.15.27':
resolution: {integrity: sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA==}
'@types/node-fetch@2.6.13':
resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==}
'@types/node@12.20.55':
resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==}
'@types/node@18.19.130':
resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==}
'@types/node@20.19.33':
resolution: {integrity: sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==}
@@ -3720,6 +3804,10 @@ packages:
'@xtuc/long@4.2.2':
resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==}
abort-controller@3.0.0:
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
engines: {node: '>=6.5'}
abs-svg-path@0.1.1:
resolution: {integrity: sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==}
@@ -3756,6 +3844,10 @@ packages:
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
engines: {node: '>= 14'}
agentkeepalive@4.6.0:
resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==}
engines: {node: '>= 8.0.0'}
ajv-formats@2.1.1:
resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==}
peerDependencies:
@@ -4532,6 +4624,10 @@ packages:
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
engines: {node: '>=12'}
dotenv@17.3.1:
resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==}
engines: {node: '>=12'}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
@@ -4814,6 +4910,10 @@ packages:
event-stream@3.3.4:
resolution: {integrity: sha512-QHpkERcGsR0T7Qm3HNJSyXKEEj8AHNxkY3PK8TS2KJvQ7NiSHe3DDpwVKKtoYprL/AreyzFBeIkBIWChAqn60g==}
event-target-shim@5.0.1:
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
engines: {node: '>=6'}
eventemitter3@4.0.7:
resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
@@ -4967,6 +5067,9 @@ packages:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'}
form-data-encoder@1.7.2:
resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==}
form-data-encoder@4.1.0:
resolution: {integrity: sha512-G6NsmEW15s0Uw9XnCg+33H3ViYRyiM0hMrMhhqQOR8NFc5GhYrI+6I3u7OTw7b91J2g8rtvMBZJDbcGb2YUniw==}
engines: {node: '>= 18'}
@@ -4975,6 +5078,10 @@ packages:
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
engines: {node: '>= 6'}
formdata-node@4.4.1:
resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==}
engines: {node: '>= 12.20'}
forwarded-parse@2.1.2:
resolution: {integrity: sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==}
@@ -5140,6 +5247,9 @@ packages:
resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==}
engines: {node: '>=10'}
google-trends-api@4.9.2:
resolution: {integrity: sha512-gjVSHCM8B7LyAAUpXb4B0/TfnmpwQ2z1w/mQ2bL0AKpr2j3gLS1j2YOnifpfsGJRxAGXB/NoC+nGwC5qSnZIiA==}
gopd@1.2.0:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
@@ -5267,6 +5377,9 @@ packages:
resolution: {integrity: sha512-nZXjEF2nbo7lIw3mgYjItAfgQXog3OjJogSbKa2CQIIvSGWcKgeJnQlNXip6NglNzYH45nSRiEVimMvYL8DDqQ==}
engines: {node: '>=14.18.0'}
humanize-ms@1.2.1:
resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==}
husky@9.1.7:
resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==}
engines: {node: '>=18'}
@@ -6140,6 +6253,11 @@ packages:
node-addon-api@7.1.1:
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
node-domexception@1.0.0:
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
engines: {node: '>=10.5.0'}
deprecated: Use your platform's native DOMException instead
node-fetch@2.7.0:
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
engines: {node: 4.x || >=6.0.0}
@@ -6235,6 +6353,18 @@ packages:
resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==}
engines: {node: '>=18'}
openai@4.104.0:
resolution: {integrity: sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==}
hasBin: true
peerDependencies:
ws: ^8.18.0
zod: ^3.23.8
peerDependenciesMeta:
ws:
optional: true
zod:
optional: true
optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
@@ -7621,6 +7751,9 @@ packages:
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
engines: {node: '>= 0.4'}
undici-types@5.26.5:
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
@@ -7917,6 +8050,10 @@ packages:
wcwidth@1.0.1:
resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==}
web-streams-polyfill@4.0.0-beta.3:
resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==}
engines: {node: '>= 14'}
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
@@ -8826,57 +8963,6 @@ 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))
@@ -8928,32 +9014,6 @@ 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
@@ -8997,17 +9057,6 @@ 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))
@@ -10927,8 +10976,17 @@ snapshots:
dependencies:
'@types/node': 20.19.33
'@types/node-fetch@2.6.13':
dependencies:
'@types/node': 20.19.33
form-data: 4.0.5
'@types/node@12.20.55': {}
'@types/node@18.19.130':
dependencies:
undici-types: 5.26.5
'@types/node@20.19.33':
dependencies:
undici-types: 6.21.0
@@ -11099,14 +11157,6 @@ 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
@@ -11521,6 +11571,10 @@ snapshots:
'@xtuc/long@4.2.2': {}
abort-controller@3.0.0:
dependencies:
event-target-shim: 5.0.1
abs-svg-path@0.1.1: {}
acorn-import-attributes@1.9.5(acorn@8.15.0):
@@ -11547,6 +11601,10 @@ snapshots:
agent-base@7.1.4: {}
agentkeepalive@4.6.0:
dependencies:
humanize-ms: 1.2.1
ajv-formats@2.1.1(ajv@8.17.1):
optionalDependencies:
ajv: 8.17.1
@@ -12367,6 +12425,8 @@ snapshots:
dotenv@16.6.1: {}
dotenv@17.3.1: {}
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
@@ -12897,6 +12957,8 @@ snapshots:
stream-combiner: 0.0.4
through: 2.3.8
event-target-shim@5.0.1: {}
eventemitter3@4.0.7: {}
eventemitter3@5.0.4: {}
@@ -13053,6 +13115,8 @@ snapshots:
cross-spawn: 7.0.6
signal-exit: 4.1.0
form-data-encoder@1.7.2: {}
form-data-encoder@4.1.0: {}
form-data@4.0.5:
@@ -13063,6 +13127,11 @@ snapshots:
hasown: 2.0.2
mime-types: 2.1.35
formdata-node@4.4.1:
dependencies:
node-domexception: 1.0.0
web-streams-polyfill: 4.0.0-beta.3
forwarded-parse@2.1.2: {}
fraction.js@5.3.4: {}
@@ -13238,6 +13307,8 @@ snapshots:
merge2: 1.4.1
slash: 3.0.0
google-trends-api@4.9.2: {}
gopd@1.2.0: {}
got-scraping@4.1.3:
@@ -13406,6 +13477,10 @@ snapshots:
human-signals@4.3.1: {}
humanize-ms@1.2.1:
dependencies:
ms: 2.1.3
husky@9.1.7: {}
hyphen@1.14.1: {}
@@ -14251,6 +14326,8 @@ snapshots:
node-addon-api@7.1.1: {}
node-domexception@1.0.0: {}
node-fetch@2.7.0:
dependencies:
whatwg-url: 5.0.0
@@ -14341,6 +14418,21 @@ snapshots:
dependencies:
mimic-function: 5.0.1
openai@4.104.0(ws@8.19.0)(zod@3.25.76):
dependencies:
'@types/node': 18.19.130
'@types/node-fetch': 2.6.13
abort-controller: 3.0.0
agentkeepalive: 4.6.0
form-data-encoder: 1.7.2
formdata-node: 4.4.1
node-fetch: 2.7.0
optionalDependencies:
ws: 8.19.0
zod: 3.25.76
transitivePeerDependencies:
- encoding
optionator@0.9.4:
dependencies:
deep-is: 0.1.4
@@ -14539,16 +14631,6 @@ 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
@@ -15861,6 +15943,8 @@ snapshots:
has-symbols: 1.1.0
which-boxed-primitive: 1.1.1
undici-types@5.26.5: {}
undici-types@6.21.0: {}
undici@7.21.0: {}
@@ -16029,7 +16113,7 @@ snapshots:
tsx: 4.21.0
yaml: 2.8.2
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):
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):
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))
@@ -16067,7 +16151,7 @@ snapshots:
- supports-color
- terser
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):
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):
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.4
@@ -16150,10 +16234,6 @@ 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)
@@ -16191,6 +16271,8 @@ snapshots:
dependencies:
defaults: 1.0.4
web-streams-polyfill@4.0.0-beta.3: {}
webidl-conversions@3.0.1: {}
webidl-conversions@7.0.0: {}

View File

@@ -2,65 +2,115 @@
# Configuration
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
# Define potential container names
CONTAINERS=("cms-infra-infra-cms-1" "at-mintel-directus-1")
echo "🔧 Checking for Directus containers to patch..."
for CONTAINER in "${CONTAINERS[@]}"; do
# Check if container exists and is running
if [ "$(docker ps -q -f name=^/${CONTAINER}$)" ]; then
echo "🔧 Applying core patch to Directus container: $CONTAINER..."
docker exec "$CONTAINER" node -e '
echo "🔧 Applying core patches to: $CONTAINER..."
# Capture output to determine if restart is needed
OUTPUT=$(docker exec -i "$CONTAINER" node << 'EOF'
const fs = require("node:fs");
// Try multiple potential paths for the node_modules location
const searchPaths = [
"/directus/node_modules/.pnpm/@directus+extensions@file+packages+extensions_deep-diff@1.0.2_express@4.21.2_graphql@16_244b87fbecd929c2d2240e7b3abc1fe4/node_modules/@directus/extensions/dist/node.js",
"/directus/node_modules/@directus/extensions/dist/node.js"
];
let targetPath = null;
for (const p of searchPaths) {
if (fs.existsSync(p)) {
targetPath = p;
break;
}
const { execSync } = require("node:child_process");
let patched = false;
try {
// 1. Patch @directus/extensions node.js (Entrypoints)
const findNodeCmd = "find /directus/node_modules -path \"*/@directus/extensions/dist/node.js\"";
const nodePaths = execSync(findNodeCmd).toString().trim().split("\n").filter(Boolean);
nodePaths.forEach(targetPath => {
let content = fs.readFileSync(targetPath, "utf8");
let modified = false;
const filterPatch = 'extension.host === "app" && (extension.entrypoint.app || extension.entrypoint)';
// Only replace if the OLD pattern exists
if (content.includes('extension.host === "app" && !!extension.entrypoint.app')) {
content = content.replace(/extension\.host === "app" && !!extension\.entrypoint\.app/g, filterPatch);
modified = true;
}
// Only replace if the OLD pattern exists for entrypoint
// We check if "extension.entrypoint.app" is present but NOT part of our patch
// This is a simple heuristic: if the patch string is NOT present, but the target IS.
if (!content.includes("(extension.entrypoint.app || extension.entrypoint)")) {
if (content.includes("extension.entrypoint.app")) {
content = content.replace(/extension\.entrypoint\.app/g, "(extension.entrypoint.app || extension.entrypoint)");
modified = true;
}
}
if (modified) {
fs.writeFileSync(targetPath, content);
console.log(`✅ Entrypoint patched.`);
patched = true;
}
});
// 2. Patch @directus/api manager.js (HTML Injection)
const findManagerCmd = "find /directus/node_modules -path \"*/@directus/api/dist/extensions/manager.js\"";
const managerPaths = execSync(findManagerCmd).toString().trim().split("\n").filter(Boolean);
managerPaths.forEach(targetPath => {
let content = fs.readFileSync(targetPath, "utf8");
const original = "head: wrapEmbeds('Custom Embed Head', this.hookEmbedsHead),";
const injection = "head: '<script type=\"module\" src=\"/extensions/sources/index.js\"></script>\\n' + wrapEmbeds('Custom Embed Head', this.hookEmbedsHead),";
if (content.includes(original) && !content.includes("/extensions/sources/index.js")) {
content = content.replace(original, injection);
fs.writeFileSync(targetPath, content);
console.log(`✅ Injection patched.`);
patched = true;
}
});
// 3. Patch @directus/api app.js (CSP for unsafe-inline)
const findAppCmd = "find /directus/node_modules -path \"*/@directus/api/dist/app.js\"";
const appPaths = execSync(findAppCmd).toString().trim().split("\n").filter(Boolean);
appPaths.forEach(targetPath => {
let content = fs.readFileSync(targetPath, "utf8");
let modified = false;
const original = "scriptSrc: [\"'self'\", \"'unsafe-eval'\"],";
const patchedStr = "scriptSrc: [\"'self'\", \"'unsafe-eval'\", \"'unsafe-inline'\"],";
if (content.includes(original)) {
content = content.replace(original, patchedStr);
modified = true;
}
if (modified) {
fs.writeFileSync(targetPath, content);
console.log(`✅ CSP patched in app.js.`);
patched = true;
}
});
if (patched) process.exit(100); // Signal restart needed
} catch (error) {
console.error("❌ Error applying patch:", error.message);
process.exit(1);
}
EOF
)
EXIT_CODE=$?
echo "$OUTPUT"
if (targetPath) {
let content = fs.readFileSync(targetPath, "utf8");
// Patch the filter: allow string entrypoints for modules
const filterPatch = "extension.host === \"app\" && (extension.entrypoint.app || extension.entrypoint)";
if (!content.includes(filterPatch)) {
content = content.replace(
/extension\.host === \"app\" && !!extension\.entrypoint\.app/g,
filterPatch
);
}
// Patch all imports: handle string entrypoints
if (!content.includes("(extension.entrypoint.app || extension.entrypoint)")) {
content = content.replace(
/extension\.entrypoint\.app/g,
"(extension.entrypoint.app || extension.entrypoint)"
);
}
fs.writeFileSync(targetPath, content);
console.log(`✅ Core patched successfully at ${targetPath}.`);
} else {
console.error("⚠️ Could not find @directus/extensions node.js to patch!");
}
'
echo "🔄 Restarting Directus container: $CONTAINER..."
docker restart "$CONTAINER"
if [ $EXIT_CODE -eq 100 ]; then
echo "🔄 Patches applied. Restarting Directus container: $CONTAINER..."
docker restart "$CONTAINER"
else
echo "✅ Container $CONTAINER is already patched. No restart needed."
fi
else
echo " Container $CONTAINER is not running or not found. Skipping patch."
echo " Container $CONTAINER not found. Skipping."
fi
done
echo "✨ Patching process finished."
echo "✨ All patches check complete."

View File

@@ -67,18 +67,10 @@ for PKG in "${EXTENSION_PACKAGES[@]}"; do
rm -rf "${FINAL_TARGET:?}"/*
# Copy build artifacts
if [ "$LINK_MODE" = true ]; then
if [ -f "$PKG_PATH/dist/index.js" ]; then
ln -sf "$PKG_PATH/dist/index.js" "$FINAL_TARGET/index.js"
elif [ -f "$PKG_PATH/index.js" ]; then
ln -sf "$PKG_PATH/index.js" "$FINAL_TARGET/index.js"
fi
else
if [ -f "$PKG_PATH/dist/index.js" ]; then
cp "$PKG_PATH/dist/index.js" "$FINAL_TARGET/index.js"
elif [ -f "$PKG_PATH/index.js" ]; then
cp "$PKG_PATH/index.js" "$FINAL_TARGET/index.js"
fi
if [ -f "$PKG_PATH/dist/index.js" ]; then
cp "$PKG_PATH/dist/index.js" "$FINAL_TARGET/index.js"
elif [ -f "$PKG_PATH/index.js" ]; then
cp "$PKG_PATH/index.js" "$FINAL_TARGET/index.js"
fi
if [ -f "$PKG_PATH/package.json" ]; then
@@ -106,7 +98,8 @@ for PKG in "${EXTENSION_PACKAGES[@]}"; do
if [ -d "$PKG_PATH/dist" ]; then
if [ "$LINK_MODE" = true ]; then
ln -sf "$PKG_PATH/dist" "$FINAL_TARGET/dist"
REL_PATH=$(python3 -c "import os; print(os.path.relpath('$PKG_PATH/dist', '$FINAL_TARGET'))")
ln -sf "$REL_PATH" "$FINAL_TARGET/dist"
else
cp -r "$PKG_PATH/dist" "$FINAL_TARGET/"
fi
@@ -120,7 +113,7 @@ for PKG in "${EXTENSION_PACKAGES[@]}"; do
done
# Cleanup: remove anything from extensions root that isn't in our whitelist
WHITELIST=("${EXTENSION_PACKAGES[@]}" "endpoints" "hooks" "layouts" "modules" "operations" "panels" "displays" "interfaces")
WHITELIST=("${EXTENSION_PACKAGES[@]}" "sources" "endpoints" "hooks" "layouts" "modules" "operations" "panels" "displays" "interfaces")
for TARGET_BASE in "${TARGET_DIRS[@]}"; do
echo "🧹 Cleaning up $TARGET_BASE..."

91
scripts/validate-cms.sh Executable file
View File

@@ -0,0 +1,91 @@
#!/bin/bash
# Configuration
CONTAINER="cms-infra-infra-cms-1"
echo "🔍 Validating Directus Extension Stability..."
# 1. Verify Patches
echo "🛠️ Checking Core Patches..."
docker exec -i "$CONTAINER" node << 'EOF'
const fs = require('node:fs');
const { execSync } = require('node:child_process');
let failures = 0;
// Check Node.js patch
const findNode = 'find /directus/node_modules -path "*/@directus/extensions/dist/node.js"';
const nodePaths = execSync(findNode).toString().trim().split('\n').filter(Boolean);
nodePaths.forEach(p => {
const c = fs.readFileSync(p, 'utf8');
if (!c.includes('(extension.entrypoint.app || extension.entrypoint)')) {
console.error('❌ Missing node.js patch at ' + p);
failures++;
}
});
// Check Manager.js patch
const findManager = 'find /directus/node_modules -path "*/@directus/api/dist/extensions/manager.js"';
const managerPaths = execSync(findManager).toString().trim().split('\n').filter(Boolean);
managerPaths.forEach(p => {
const c = fs.readFileSync(p, 'utf8');
if (!c.includes('/extensions/sources/index.js')) {
console.error('❌ Missing manager.js patch at ' + p);
failures++;
}
});
if (failures === 0) {
console.log('✅ Core patches are healthy.');
}
process.exit(failures > 0 ? 1 : 0);
EOF
if [ $? -ne 0 ]; then
echo "⚠️ Core patches missing! Run 'bash scripts/patch-cms.sh' to fix."
fi
# 2. Verify Module Bar
echo "📋 Checking Sidebar Configuration..."
docker exec -i "$CONTAINER" node << 'EOF'
const sqlite3 = require('/directus/node_modules/.pnpm/sqlite3@5.1.7/node_modules/sqlite3');
const db = new sqlite3.Database('/directus/database/data.db');
const managerIds = ["unified-dashboard", "acquisition-manager", "company-manager", "customer-manager", "feedback-commander", "people-manager"];
db.get('SELECT module_bar FROM directus_settings WHERE id = 1', (err, row) => {
if (err) { console.error('❌ DB Error:', err.message); process.exit(1); }
let mb = [];
try { mb = JSON.parse(row.module_bar || '[]'); } catch(e) { mb = []; }
const existingIds = mb.map(m => m.id);
const missing = managerIds.filter(id => !existingIds.includes(id));
const disabled = mb.filter(m => managerIds.includes(m.id) && m.enabled === false);
if (missing.length === 0 && disabled.length === 0) {
console.log('✅ Sidebar is healthy with all manager modules enabled.');
process.exit(0);
} else {
if (missing.length > 0) console.log('⚠️ Missing modules:', missing.join(', '));
if (disabled.length > 0) console.log('⚠️ Disabled modules:', disabled.map(m => m.id).join(', '));
console.log('🔧 Self-healing in progress...');
// Construct Golden State Module Bar
const goldenMB = [
{ type: 'module', id: 'content', enabled: true },
{ type: 'module', id: 'users', enabled: true },
{ type: 'module', id: 'files', enabled: true },
{ type: 'module', id: 'insights', enabled: true },
...managerIds.map(id => ({ type: 'module', id, enabled: true })),
{ type: 'module', id: 'settings', enabled: true }
];
db.run('UPDATE directus_settings SET module_bar = ? WHERE id = 1', [JSON.stringify(goldenMB)], function(err) {
if (err) { console.error('❌ Repair failed:', err.message); process.exit(1); }
console.log('✨ Sidebar repaired successfully!');
process.exit(0);
});
}
});
EOF