Migrate Engines to God Mode and add Directus Shim
This commit is contained in:
22
god-mode/migrations/01_init_sites.sql
Normal file
22
god-mode/migrations/01_init_sites.sql
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
-- Create sites table for Multi-Tenancy
|
||||||
|
CREATE TABLE IF NOT EXISTS sites (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid (),
|
||||||
|
domain VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
status VARCHAR(50) DEFAULT 'active', -- active, maintenance, archived
|
||||||
|
config JSONB DEFAULT '{}', -- branding, SEO settings
|
||||||
|
client_id VARCHAR(255),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index for fast domain lookups
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sites_domain ON sites (domain);
|
||||||
|
|
||||||
|
-- Insert the Platform/Admin site default
|
||||||
|
INSERT INTO
|
||||||
|
sites (domain, status, config)
|
||||||
|
VALUES (
|
||||||
|
'spark.jumpstartscaling.com',
|
||||||
|
'active',
|
||||||
|
'{"type": "admin"}'
|
||||||
|
) ON CONFLICT (domain) DO NOTHING;
|
||||||
178
god-mode/src/components/debug/DebugToolbar.tsx
Normal file
178
god-mode/src/components/debug/DebugToolbar.tsx
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useStore } from '@nanostores/react';
|
||||||
|
import { debugIsOpen, activeTab, logs, type LogEntry } from '../../stores/debugStore';
|
||||||
|
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { getDirectusClient } from '@/lib/directus/client';
|
||||||
|
|
||||||
|
// Create a client for the devtools if one doesn't exist in context
|
||||||
|
// (Ideally this component is inside the main QueryClientProvider, but we'll see)
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
|
export default function DebugToolbar() {
|
||||||
|
const isOpen = useStore(debugIsOpen);
|
||||||
|
const currentTab = useStore(activeTab);
|
||||||
|
const logEntries = useStore(logs);
|
||||||
|
const [backendStatus, setBackendStatus] = useState<'checking' | 'online' | 'error'>('checking');
|
||||||
|
const [latency, setLatency] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && currentTab === 'backend') {
|
||||||
|
checkBackend();
|
||||||
|
}
|
||||||
|
}, [isOpen, currentTab]);
|
||||||
|
|
||||||
|
const checkBackend = async () => {
|
||||||
|
setBackendStatus('checking');
|
||||||
|
const start = performance.now();
|
||||||
|
try {
|
||||||
|
const client = getDirectusClient();
|
||||||
|
await client.request(() => ({
|
||||||
|
path: '/server/ping',
|
||||||
|
method: 'GET'
|
||||||
|
}));
|
||||||
|
setLatency(Math.round(performance.now() - start));
|
||||||
|
setBackendStatus('online');
|
||||||
|
} catch (e) {
|
||||||
|
setBackendStatus('error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => debugIsOpen.set(true)}
|
||||||
|
className="fixed bottom-4 right-4 z-[9999] p-3 bg-black text-white rounded-full shadow-2xl hover:scale-110 transition-transform border border-gray-700"
|
||||||
|
title="Open Debug Toolbar"
|
||||||
|
>
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-0 left-0 right-0 h-[33vh] z-[9999] bg-black/95 text-white border-t border-gray-800 shadow-[0_-4px_20px_rgba(0,0,0,0.5)] flex flex-col font-mono text-sm backdrop-blur">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-800 bg-gray-900/50">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="font-bold text-yellow-500">⚡ Spark Debug</span>
|
||||||
|
<div className="flex gap-1 bg-gray-800 rounded p-1">
|
||||||
|
{(['console', 'backend', 'network'] as const).map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab}
|
||||||
|
onClick={() => activeTab.set(tab)}
|
||||||
|
className={`px-3 py-1 rounded text-xs uppercase font-medium transition-colors ${currentTab === tab
|
||||||
|
? 'bg-gray-700 text-white'
|
||||||
|
: 'text-gray-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => debugIsOpen.set(false)}
|
||||||
|
className="p-1 hover:bg-gray-800 rounded"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-hidden relative">
|
||||||
|
|
||||||
|
{/* Console Tab */}
|
||||||
|
{currentTab === 'console' && (
|
||||||
|
<div className="h-full overflow-y-auto p-4 space-y-1">
|
||||||
|
{logEntries.length === 0 && (
|
||||||
|
<div className="text-gray-500 text-center mt-10">No logs captured yet...</div>
|
||||||
|
)}
|
||||||
|
{logEntries.map((log) => (
|
||||||
|
<div key={log.id} className="flex gap-2 font-mono text-xs border-b border-gray-800/50 pb-1">
|
||||||
|
<span className="text-gray-500 shrink-0">[{log.timestamp}]</span>
|
||||||
|
<span className={`shrink-0 w-12 font-bold uppercase ${log.type === 'error' ? 'text-red-500' :
|
||||||
|
log.type === 'warn' ? 'text-yellow-500' :
|
||||||
|
'text-blue-400'
|
||||||
|
}`}>
|
||||||
|
{log.type}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-300 break-all">
|
||||||
|
{log.messages.join(' ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="absolute bottom-4 right-4">
|
||||||
|
<button
|
||||||
|
onClick={() => logs.set([])}
|
||||||
|
className="px-2 py-1 bg-gray-800 text-xs rounded hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Backend Tab */}
|
||||||
|
{currentTab === 'backend' && (
|
||||||
|
<div className="h-full p-6 flex flex-col items-center justify-center gap-4">
|
||||||
|
<div className={`text-4xl ${backendStatus === 'online' ? 'text-green-500' :
|
||||||
|
backendStatus === 'error' ? 'text-red-500' :
|
||||||
|
'text-yellow-500 animate-pulse'
|
||||||
|
}`}>
|
||||||
|
{backendStatus === 'online' ? '● Online' :
|
||||||
|
backendStatus === 'error' ? '✖ Error' : '● Checking...'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<p className="text-gray-400">
|
||||||
|
Directus URL: <span className="text-white">{import.meta.env.PUBLIC_DIRECTUS_URL}</span>
|
||||||
|
</p>
|
||||||
|
{latency && (
|
||||||
|
<p className="text-gray-400">
|
||||||
|
Latency: <span className="text-white">{latency}ms</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={checkBackend}
|
||||||
|
className="px-4 py-2 bg-gray-800 rounded hover:bg-gray-700 transition"
|
||||||
|
>
|
||||||
|
Re-check Connection
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Network / React Query Tab */}
|
||||||
|
{currentTab === 'network' && (
|
||||||
|
<div className="h-full w-full relative bg-gray-900">
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center text-gray-500">
|
||||||
|
{/*
|
||||||
|
React Query Devtools needs a QueryClientProvider context.
|
||||||
|
In Astro, components are islands. If this island doesn't share context with the main app
|
||||||
|
(which it likely won't if they are separate roots), we might see empty devtools.
|
||||||
|
However, putting it here is the best attempt.
|
||||||
|
*/}
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="mb-2">React Query Devtools</p>
|
||||||
|
<p className="text-xs">
|
||||||
|
(If empty, data fetching might be happening Server-Side or in a different Context)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* We force mount devtools panel here if possible */}
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<ReactQueryDevtools initialIsOpen={true} />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
god-mode/src/components/engine/BlockRenderer.tsx
Normal file
39
god-mode/src/components/engine/BlockRenderer.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Hero from './blocks/Hero';
|
||||||
|
import Content from './blocks/Content';
|
||||||
|
import Features from './blocks/Features';
|
||||||
|
|
||||||
|
interface Block {
|
||||||
|
id: string;
|
||||||
|
block_type: string;
|
||||||
|
block_config: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BlockRendererProps {
|
||||||
|
blocks: Block[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BlockRenderer({ blocks }: BlockRendererProps) {
|
||||||
|
if (!blocks || !Array.isArray(blocks)) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
{blocks.map(block => {
|
||||||
|
switch (block.block_type) {
|
||||||
|
case 'hero':
|
||||||
|
return <Hero key={block.id} {...block.block_config} />;
|
||||||
|
case 'content':
|
||||||
|
return <Content key={block.id} {...block.block_config} />;
|
||||||
|
case 'features':
|
||||||
|
return <Features key={block.id} {...block.block_config} />;
|
||||||
|
case 'cta':
|
||||||
|
// reuse Hero styled as CTA or simple banner
|
||||||
|
return <Hero key={block.id} {...block.block_config} bg="dark" />;
|
||||||
|
default:
|
||||||
|
console.warn(`Unknown block type: ${block.block_type}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
god-mode/src/components/engine/blocks/Content.tsx
Normal file
13
god-mode/src/components/engine/blocks/Content.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface ContentProps {
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Content({ content }: ContentProps) {
|
||||||
|
return (
|
||||||
|
<section className="py-12 px-8">
|
||||||
|
<div className="prose prose-lg dark:prose-invert mx-auto" dangerouslySetInnerHTML={{ __html: content }} />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
god-mode/src/components/engine/blocks/Features.tsx
Normal file
40
god-mode/src/components/engine/blocks/Features.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { CheckCircle2 } from 'lucide-react';
|
||||||
|
|
||||||
|
interface FeatureItem {
|
||||||
|
title: string;
|
||||||
|
desc: string;
|
||||||
|
icon?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FeaturesProps {
|
||||||
|
items: FeatureItem[];
|
||||||
|
layout?: 'grid' | 'list';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Features({ items, layout = 'grid' }: FeaturesProps) {
|
||||||
|
return (
|
||||||
|
<section className="py-16 px-8 bg-zinc-50 dark:bg-zinc-900/50">
|
||||||
|
<div className="max-w-6xl mx-auto">
|
||||||
|
<div className={`grid gap-8 ${layout === 'list' ? 'grid-cols-1' : 'grid-cols-1 md:grid-cols-3'}`}>
|
||||||
|
{items?.map((item, i) => (
|
||||||
|
<Card key={i} className="border-0 shadow-lg bg-white dark:bg-zinc-900 dark:border-zinc-800">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center mb-4 text-blue-600 dark:text-blue-400">
|
||||||
|
<CheckCircle2 className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-xl">{item.title}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-zinc-600 dark:text-zinc-400 leading-relaxed">
|
||||||
|
{item.desc}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
god-mode/src/components/engine/blocks/Hero.tsx
Normal file
38
god-mode/src/components/engine/blocks/Hero.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
interface HeroProps {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
bg?: string;
|
||||||
|
ctaLabel?: string;
|
||||||
|
ctaUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Hero({ title, subtitle, bg, ctaLabel, ctaUrl }: HeroProps) {
|
||||||
|
const bgClass = bg === 'dark' ? 'bg-zinc-900 text-white' :
|
||||||
|
bg === 'image' ? 'bg-zinc-800 text-white' : // Placeholder for image logic
|
||||||
|
'bg-white text-zinc-900';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={`py-20 px-8 text-center ${bgClass}`}>
|
||||||
|
<div className="max-w-4xl mx-auto space-y-6">
|
||||||
|
<h1 className="text-5xl md:text-6xl font-extrabold tracking-tight">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
{subtitle && (
|
||||||
|
<p className="text-xl md:text-2xl opacity-80 max-w-2xl mx-auto">
|
||||||
|
{subtitle}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{(ctaLabel && ctaUrl) && (
|
||||||
|
<div className="pt-4">
|
||||||
|
<Button asChild size="lg" className="text-lg px-8 py-6 rounded-full">
|
||||||
|
<a href={ctaUrl}>{ctaLabel}</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
0
god-mode/src/components/testing/ContentTester.tsx
Normal file
0
god-mode/src/components/testing/ContentTester.tsx
Normal file
0
god-mode/src/components/testing/GrammarCheck.tsx
Normal file
0
god-mode/src/components/testing/GrammarCheck.tsx
Normal file
0
god-mode/src/components/testing/LinkChecker.tsx
Normal file
0
god-mode/src/components/testing/LinkChecker.tsx
Normal file
0
god-mode/src/components/testing/SEOValidator.tsx
Normal file
0
god-mode/src/components/testing/SEOValidator.tsx
Normal file
0
god-mode/src/components/testing/SchemaValidator.tsx
Normal file
0
god-mode/src/components/testing/SchemaValidator.tsx
Normal file
109
god-mode/src/components/testing/TestRunner.tsx
Normal file
109
god-mode/src/components/testing/TestRunner.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import { CheckCircle2, AlertTriangle, XCircle, Search, FileText } from 'lucide-react';
|
||||||
|
// We import the analysis functions directly since this is a client component in Astro/React
|
||||||
|
import { analyzeSeo, analyzeReadability } from '@/lib/testing/seo';
|
||||||
|
|
||||||
|
const TestRunner = () => {
|
||||||
|
const [content, setContent] = useState('');
|
||||||
|
const [keyword, setKeyword] = useState('');
|
||||||
|
const [results, setResults] = useState<any>(null);
|
||||||
|
|
||||||
|
const runTests = () => {
|
||||||
|
const seo = analyzeSeo(content, keyword);
|
||||||
|
const read = analyzeReadability(content);
|
||||||
|
|
||||||
|
setResults({ seo, read });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 h-[calc(100vh-140px)]">
|
||||||
|
|
||||||
|
{/* Input Column */}
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Card className="p-4 space-y-4 bg-card/50 backdrop-blur">
|
||||||
|
<h3 className="font-semibold text-sm flex items-center gap-2">
|
||||||
|
<FileText className="h-4 w-4" /> Content Source
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Target Keyword"
|
||||||
|
value={keyword}
|
||||||
|
onChange={(e) => setKeyword(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Textarea
|
||||||
|
className="min-h-[400px] font-mono text-sm"
|
||||||
|
placeholder="Paste content here to analyze..."
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button onClick={runTests} className="w-full">
|
||||||
|
<Search className="h-4 w-4 mr-2" /> Run Analysis
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results Column */}
|
||||||
|
<div className="flex flex-col gap-4 overflow-y-auto">
|
||||||
|
{results ? (
|
||||||
|
<>
|
||||||
|
<Card className="p-6 bg-card/50 backdrop-blur space-y-6">
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between mb-2">
|
||||||
|
<h3 className="font-semibold">SEO Score</h3>
|
||||||
|
<span className={`font-bold ${results.seo.score >= 80 ? 'text-green-500' : 'text-yellow-500'}`}>
|
||||||
|
{results.seo.score}/100
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={results.seo.score} className="h-2" />
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
{results.seo.issues.length === 0 && (
|
||||||
|
<div className="flex items-center gap-2 text-green-500 text-sm">
|
||||||
|
<CheckCircle2 className="h-4 w-4" /> No issues found!
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{results.seo.issues.map((issue: string, i: number) => (
|
||||||
|
<div key={i} className="flex items-start gap-2 text-yellow-500 text-sm">
|
||||||
|
<AlertTriangle className="h-4 w-4 shrink-0 mt-0.5" />
|
||||||
|
<span>{issue}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-6 border-t border-border/50">
|
||||||
|
<div className="flex justify-between mb-2">
|
||||||
|
<h3 className="font-semibold">Readability</h3>
|
||||||
|
<span className="text-muted-foreground text-sm">{results.read.feedback}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="p-3 bg-background/50 rounded border border-border/50 text-center">
|
||||||
|
<div className="text-2xl font-bold">{results.read.gradeLevel}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">Grade Level</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-background/50 rounded border border-border/50 text-center">
|
||||||
|
<div className="text-2xl font-bold">{results.read.score}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">Flow Score</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Card className="flex-1 flex flex-col items-center justify-center p-8 text-muted-foreground opacity-50 border-dashed">
|
||||||
|
<Search className="h-12 w-12 mb-4" />
|
||||||
|
<p>No results yet. Run analysis to see scores.</p>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TestRunner;
|
||||||
29
god-mode/src/components/ui/UnderConstruction.tsx
Normal file
29
god-mode/src/components/ui/UnderConstruction.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Construction } from 'lucide-react';
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
|
interface UnderConstructionProps {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
eta?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UnderConstruction = ({ title, description = "This module is currently being built.", eta = "Coming Soon" }: UnderConstructionProps) => {
|
||||||
|
return (
|
||||||
|
<Card className="border-dashed border-2 border-border/50 bg-card/20 backdrop-blur-sm h-[400px] flex flex-col items-center justify-center text-center p-8">
|
||||||
|
<div className="p-4 rounded-full bg-primary/10 mb-6 animate-pulse">
|
||||||
|
<Construction className="h-12 w-12 text-primary" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold mb-2">{title}</h2>
|
||||||
|
<p className="text-muted-foreground max-w-md mb-6">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
<Badge variant="outline" className="px-4 py-1 border-primary/20 text-primary">
|
||||||
|
{eta}
|
||||||
|
</Badge>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UnderConstruction;
|
||||||
43
god-mode/src/components/ui/alert-dialog.tsx
Normal file
43
god-mode/src/components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
const AlertDialog = ({ open, onOpenChange, children }: any) => {
|
||||||
|
if (!open) return null
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div className="fixed inset-0 bg-black/50" onClick={() => onOpenChange(false)} />
|
||||||
|
<div className="relative z-50">{children}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const AlertDialogContent = ({ children, className }: any) => (
|
||||||
|
<div className={`bg-slate-800 rounded-lg shadow-lg max-w-md w-full p-6 ${className}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const AlertDialogHeader = ({ children }: any) => <div className="mb-4">{children}</div>
|
||||||
|
const AlertDialogTitle = ({ children, className }: any) => <h2 className={`text-xl font-bold ${className}`}>{children}</h2>
|
||||||
|
const AlertDialogDescription = ({ children, className }: any) => <p className={`text-sm text-slate-400 ${className}`}>{children}</p>
|
||||||
|
const AlertDialogFooter = ({ children }: any) => <div className="mt-6 flex justify-end gap-2">{children}</div>
|
||||||
|
const AlertDialogAction = ({ children, onClick, disabled, className }: any) => (
|
||||||
|
<button onClick={onClick} disabled={disabled} className={`px-4 py-2 rounded ${className}`}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
const AlertDialogCancel = ({ children, disabled, className }: any) => (
|
||||||
|
<button disabled={disabled} className={`px-4 py-2 rounded ${className}`}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
}
|
||||||
35
god-mode/src/components/ui/badge.tsx
Normal file
35
god-mode/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
|
||||||
|
secondary:
|
||||||
|
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
destructive:
|
||||||
|
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
|
||||||
|
outline: "text-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface BadgeProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof badgeVariants> { }
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
55
god-mode/src/components/ui/button.tsx
Normal file
55
god-mode/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||||
|
outline:
|
||||||
|
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-10 px-4 py-2",
|
||||||
|
sm: "h-9 rounded-md px-3",
|
||||||
|
lg: "h-11 rounded-md px-8",
|
||||||
|
icon: "h-10 w-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Button.displayName = "Button"
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
78
god-mode/src/components/ui/card.tsx
Normal file
78
god-mode/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Card = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Card.displayName = "Card"
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardHeader.displayName = "CardHeader"
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLHeadingElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<h3
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-2xl font-semibold leading-none tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardTitle.displayName = "CardTitle"
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardDescription.displayName = "CardDescription"
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||||
|
))
|
||||||
|
CardContent.displayName = "CardContent"
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex items-center p-6 pt-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardFooter.displayName = "CardFooter"
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||||
20
god-mode/src/components/ui/checkbox.tsx
Normal file
20
god-mode/src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Checkbox = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className={cn(
|
||||||
|
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Checkbox.displayName = "Checkbox"
|
||||||
|
|
||||||
|
export { Checkbox }
|
||||||
31
god-mode/src/components/ui/dialog.tsx
Normal file
31
god-mode/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
const Dialog = ({ open, onOpenChange, children }: any) => {
|
||||||
|
if (!open) return null
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-start justify-center pt-24">
|
||||||
|
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm" onClick={() => onOpenChange(false)} />
|
||||||
|
<div className="relative z-50 animate-in fade-in zoom-in-95 duration-200">{children}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const DialogTrigger = ({ children, asChild, onClick, ...props }: any) => {
|
||||||
|
// This is a simplified trigger that just renders children.
|
||||||
|
// In a real implementation (Radix UI), this controls the dialog state.
|
||||||
|
// For now, we rely on the parent controlling 'open' state.
|
||||||
|
return <div onClick={onClick} {...props}>{children}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
const DialogContent = ({ children, className }: any) => (
|
||||||
|
<div className={`bg-zinc-900 border border-zinc-800 rounded-lg shadow-2xl max-w-lg w-full p-6 ${className}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const DialogHeader = ({ children }: any) => <div className="mb-4 text-left">{children}</div>
|
||||||
|
const DialogTitle = ({ children, className }: any) => <h2 className={`text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-white to-zinc-400 ${className}`}>{children}</h2>
|
||||||
|
const DialogDescription = ({ children, className }: any) => <p className={`text-sm text-zinc-400 ${className}`}>{children}</p>
|
||||||
|
const DialogFooter = ({ children }: any) => <div className="mt-6 flex justify-end gap-2">{children}</div>
|
||||||
|
|
||||||
|
export { Dialog, DialogContent, DialogTrigger, DialogHeader, DialogTitle, DialogDescription, DialogFooter }
|
||||||
200
god-mode/src/components/ui/dropdown-menu.tsx
Normal file
200
god-mode/src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||||
|
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||||
|
|
||||||
|
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||||
|
|
||||||
|
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||||
|
|
||||||
|
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||||
|
|
||||||
|
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||||
|
|
||||||
|
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||||
|
|
||||||
|
const DropdownMenuSubTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRight className="ml-auto h-4 w-4" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
))
|
||||||
|
DropdownMenuSubTrigger.displayName =
|
||||||
|
DropdownMenuPrimitive.SubTrigger.displayName
|
||||||
|
|
||||||
|
const DropdownMenuSubContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuSubContent.displayName =
|
||||||
|
DropdownMenuPrimitive.SubContent.displayName
|
||||||
|
|
||||||
|
const DropdownMenuContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
))
|
||||||
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const DropdownMenuItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
>(({ className, children, checked, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
))
|
||||||
|
DropdownMenuCheckboxItem.displayName =
|
||||||
|
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||||
|
|
||||||
|
const DropdownMenuRadioItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Circle className="h-2 w-2 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
))
|
||||||
|
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||||
|
|
||||||
|
const DropdownMenuLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-sm font-semibold",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||||
|
|
||||||
|
const DropdownMenuSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
const DropdownMenuShortcut = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
}
|
||||||
22
god-mode/src/components/ui/input.tsx
Normal file
22
god-mode/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Input.displayName = "Input"
|
||||||
|
|
||||||
|
export { Input }
|
||||||
12
god-mode/src/components/ui/label.tsx
Normal file
12
god-mode/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Label = React.forwardRef<HTMLLabelElement, React.LabelHTMLAttributes<HTMLLabelElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<label ref={ref} className={cn("text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", className)} {...props} />
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Label.displayName = "Label"
|
||||||
|
|
||||||
|
export { Label }
|
||||||
14
god-mode/src/components/ui/progress.tsx
Normal file
14
god-mode/src/components/ui/progress.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
// Simplified Progress without radix for speed, or if radix is missing
|
||||||
|
const Progress = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement> & { value?: number }>(
|
||||||
|
({ className, value, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("relative h-4 w-full overflow-hidden rounded-full bg-secondary", className)} {...props}>
|
||||||
|
<div className="h-full w-full flex-1 bg-primary transition-all" style={{ transform: `translateX(-${100 - (value || 0)}%)` }} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Progress.displayName = "Progress"
|
||||||
|
|
||||||
|
export { Progress }
|
||||||
20
god-mode/src/components/ui/select.tsx
Normal file
20
god-mode/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
const Select = ({ children, value, onValueChange }: any) => {
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onValueChange(e.target.value)}
|
||||||
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</select>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const SelectTrigger = ({ children, className }: any) => <>{children}</>
|
||||||
|
const SelectValue = ({ placeholder }: any) => <option value="">{placeholder}</option>
|
||||||
|
const SelectContent = ({ children, className }: any) => <>{children}</>
|
||||||
|
const SelectItem = ({ value, children }: any) => <option value={value}>{children}</option>
|
||||||
|
|
||||||
|
export { Select, SelectTrigger, SelectValue, SelectContent, SelectItem }
|
||||||
20
god-mode/src/components/ui/slider.tsx
Normal file
20
god-mode/src/components/ui/slider.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Slider = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
className={cn(
|
||||||
|
"w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Slider.displayName = "Slider"
|
||||||
|
|
||||||
|
export { Slider }
|
||||||
22
god-mode/src/components/ui/spinner.tsx
Normal file
22
god-mode/src/components/ui/spinner.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Spinner({ className, size = "default" }: { className?: string; size?: "sm" | "default" | "lg" }) {
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: "h-4 w-4",
|
||||||
|
default: "h-8 w-8",
|
||||||
|
lg: "h-12 w-12"
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("flex items-center justify-center", className)}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"animate-spin rounded-full border-2 border-current border-t-transparent text-primary",
|
||||||
|
sizeClasses[size]
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Spinner }
|
||||||
33
god-mode/src/components/ui/switch.tsx
Normal file
33
god-mode/src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import type { Primitive } from "@radix-ui/react-primitive"
|
||||||
|
// Simplified Switch to avoid Radix dependency issues if not installed, or use standard div toggle
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Switch = React.forwardRef<HTMLButtonElement, React.ButtonHTMLAttributes<HTMLButtonElement> & { checked?: boolean, onCheckedChange?: (checked: boolean) => void }>(
|
||||||
|
({ className, checked, onCheckedChange, ...props }, ref) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={checked}
|
||||||
|
ref={ref}
|
||||||
|
onClick={() => onCheckedChange?.(!checked)}
|
||||||
|
className={cn(
|
||||||
|
"peer inline-flex h-[24px] w-[44px] shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||||
|
checked ? "bg-primary" : "bg-slate-700",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0",
|
||||||
|
checked ? "translate-x-5 bg-white" : "translate-x-0 bg-slate-400"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Switch.displayName = "Switch"
|
||||||
|
|
||||||
|
export { Switch }
|
||||||
119
god-mode/src/components/ui/table.tsx
Normal file
119
god-mode/src/components/ui/table.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Table = React.forwardRef<
|
||||||
|
HTMLTableElement,
|
||||||
|
React.HTMLAttributes<HTMLTableElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div className="relative w-full overflow-auto">
|
||||||
|
<table
|
||||||
|
ref={ref}
|
||||||
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
Table.displayName = "Table"
|
||||||
|
|
||||||
|
const TableHeader = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||||
|
))
|
||||||
|
TableHeader.displayName = "TableHeader"
|
||||||
|
|
||||||
|
const TableBody = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tbody
|
||||||
|
ref={ref}
|
||||||
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableBody.displayName = "TableBody"
|
||||||
|
|
||||||
|
const TableFooter = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tfoot
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableFooter.displayName = "TableFooter"
|
||||||
|
|
||||||
|
const TableRow = React.forwardRef<
|
||||||
|
HTMLTableRowElement,
|
||||||
|
React.HTMLAttributes<HTMLTableRowElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tr
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableRow.displayName = "TableRow"
|
||||||
|
|
||||||
|
const TableHead = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<th
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableHead.displayName = "TableHead"
|
||||||
|
|
||||||
|
const TableCell = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<td
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableCell.displayName = "TableCell"
|
||||||
|
|
||||||
|
const TableCaption = React.forwardRef<
|
||||||
|
HTMLTableCaptionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<caption
|
||||||
|
ref={ref}
|
||||||
|
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableCaption.displayName = "TableCaption"
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableCaption,
|
||||||
|
}
|
||||||
52
god-mode/src/components/ui/tabs.tsx
Normal file
52
god-mode/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Tabs = TabsPrimitive.Root
|
||||||
|
|
||||||
|
const TabsList = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsList.displayName = TabsPrimitive.List.displayName
|
||||||
|
|
||||||
|
const TabsTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const TabsContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||||
19
god-mode/src/components/ui/textarea.tsx
Normal file
19
god-mode/src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
export interface TextareaProps
|
||||||
|
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> { }
|
||||||
|
|
||||||
|
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||||
|
({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
className={`flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 ${className}`}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Textarea.displayName = "Textarea"
|
||||||
|
|
||||||
|
export { Textarea }
|
||||||
0
god-mode/src/lib/analytics/metrics.ts
Normal file
0
god-mode/src/lib/analytics/metrics.ts
Normal file
0
god-mode/src/lib/analytics/tracking.ts
Normal file
0
god-mode/src/lib/analytics/tracking.ts
Normal file
44
god-mode/src/lib/assembler/data.ts
Normal file
44
god-mode/src/lib/assembler/data.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
|
||||||
|
import { directus } from '@/lib/directus/client';
|
||||||
|
import { readItems } from '@directus/sdk';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches all spintax dictionaries and flattens them into a usable SpintaxMap.
|
||||||
|
* Returns: { "adjective": "{great|good|awesome}", "noun": "{cat|dog}" }
|
||||||
|
*/
|
||||||
|
export async function fetchSpintaxMap(): Promise<Record<string, string>> {
|
||||||
|
try {
|
||||||
|
const items = await directus.request(
|
||||||
|
readItems('spintax_dictionaries', {
|
||||||
|
fields: ['category', 'variations'],
|
||||||
|
limit: -1
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const map: Record<string, string> = {};
|
||||||
|
|
||||||
|
items.forEach((item: any) => {
|
||||||
|
if (item.category && item.variations) {
|
||||||
|
// Example: category="premium", variations="{high-end|luxury|top-tier}"
|
||||||
|
map[item.category] = item.variations;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return map;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching spintax:', error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves a new pattern (template) to the database.
|
||||||
|
*/
|
||||||
|
export async function savePattern(patternName: string, structure: string) {
|
||||||
|
// Assuming 'cartesian_patterns' is where we store templates
|
||||||
|
// or we might need a dedicated 'templates' collection if structure differs.
|
||||||
|
// For now using 'cartesian_patterns' as per config.
|
||||||
|
|
||||||
|
// Implementation pending generic createItem helper or direct SDK usage
|
||||||
|
// This will be called by the API endpoint.
|
||||||
|
}
|
||||||
68
god-mode/src/lib/assembler/engine.ts
Normal file
68
god-mode/src/lib/assembler/engine.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
|
||||||
|
/**
|
||||||
|
* Spintax Processing Engine
|
||||||
|
* Handles nested spintax formats: {option1|option2|{nested1|nested2}}
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function processSpintax(text: string): string {
|
||||||
|
if (!text) return '';
|
||||||
|
|
||||||
|
// Regex to find the innermost spintax group { ... }
|
||||||
|
const spintaxRegex = /\{([^{}]*)\}/;
|
||||||
|
|
||||||
|
let processedText = text;
|
||||||
|
let match = spintaxRegex.exec(processedText);
|
||||||
|
|
||||||
|
// Keep processing until no more spintax groups are found
|
||||||
|
while (match) {
|
||||||
|
const fullMatch = match[0]; // e.g., "{option1|option2}"
|
||||||
|
const content = match[1]; // e.g., "option1|option2"
|
||||||
|
|
||||||
|
const options = content.split('|');
|
||||||
|
const randomOption = options[Math.floor(Math.random() * options.length)];
|
||||||
|
|
||||||
|
processedText = processedText.replace(fullMatch, randomOption);
|
||||||
|
|
||||||
|
// Re-check for remaining matches (including newly exposed or remaining groups)
|
||||||
|
match = spintaxRegex.exec(processedText);
|
||||||
|
}
|
||||||
|
|
||||||
|
return processedText;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Variable Substitution Engine
|
||||||
|
* Replaces {{variable_name}} with provided values.
|
||||||
|
* Supports fallback values: {{variable_name|default_value}}
|
||||||
|
*/
|
||||||
|
export function processVariables(text: string, variables: Record<string, string>): string {
|
||||||
|
if (!text) return '';
|
||||||
|
|
||||||
|
return text.replace(/\{\{([^}]+)\}\}/g, (match, variableKey) => {
|
||||||
|
// Check for default value syntax: {{city|New York}}
|
||||||
|
const [key, defaultValue] = variableKey.split('|');
|
||||||
|
|
||||||
|
const cleanKey = key.trim();
|
||||||
|
const value = variables[cleanKey];
|
||||||
|
|
||||||
|
if (value !== undefined && value !== null && value !== '') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultValue ? defaultValue.trim() : match; // Return original if no match and no default
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Master Assembly Function
|
||||||
|
* Runs spintax first, then variable substitution.
|
||||||
|
*/
|
||||||
|
export function assembleContent(template: string, variables: Record<string, string>): string {
|
||||||
|
// 1. Process Spintax (Randomize structure)
|
||||||
|
const spunContent = processSpintax(template);
|
||||||
|
|
||||||
|
// 2. Substitute Variables (Inject specific data)
|
||||||
|
const finalContent = processVariables(spunContent, variables);
|
||||||
|
|
||||||
|
return finalContent;
|
||||||
|
}
|
||||||
0
god-mode/src/lib/assembler/quality.ts
Normal file
0
god-mode/src/lib/assembler/quality.ts
Normal file
0
god-mode/src/lib/assembler/seo.ts
Normal file
0
god-mode/src/lib/assembler/seo.ts
Normal file
0
god-mode/src/lib/assembler/spintax.ts
Normal file
0
god-mode/src/lib/assembler/spintax.ts
Normal file
0
god-mode/src/lib/assembler/variables.ts
Normal file
0
god-mode/src/lib/assembler/variables.ts
Normal file
214
god-mode/src/lib/cartesian/CartesianEngine.ts
Normal file
214
god-mode/src/lib/cartesian/CartesianEngine.ts
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
|
||||||
|
import { SpintaxParser } from './SpintaxParser';
|
||||||
|
import { GrammarEngine } from './GrammarEngine';
|
||||||
|
import { HTMLRenderer } from './HTMLRenderer';
|
||||||
|
import { createDirectus, rest, staticToken, readItems, readItem } from '@directus/sdk';
|
||||||
|
|
||||||
|
// Config
|
||||||
|
// In a real app, client should be passed in or singleton
|
||||||
|
// For this class, we assume data is passed in or we have a method to fetch it.
|
||||||
|
|
||||||
|
export interface GenerationContext {
|
||||||
|
avatar: any;
|
||||||
|
niche: string;
|
||||||
|
city: any;
|
||||||
|
site: any;
|
||||||
|
template: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CartesianEngine {
|
||||||
|
private client: any;
|
||||||
|
|
||||||
|
constructor(directusClient: any) {
|
||||||
|
this.client = directusClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a single article based on specific inputs.
|
||||||
|
* @param overrides Optional overrides for slug, title, etc.
|
||||||
|
*/
|
||||||
|
async generateArticle(context: GenerationContext, overrides?: any) {
|
||||||
|
const { avatar, niche, city, site, template } = context;
|
||||||
|
const variant = await this.getAvatarVariant(avatar.id, 'neutral'); // Default to neutral or specific
|
||||||
|
|
||||||
|
// 1. Process Template Blocks
|
||||||
|
const blocksData = [];
|
||||||
|
|
||||||
|
// Parse structure_json (assuming array of block IDs)
|
||||||
|
const blockIds = Array.isArray(template.structure_json) ? template.structure_json : [];
|
||||||
|
|
||||||
|
for (const blockId of blockIds) {
|
||||||
|
// Fetch Universal Block
|
||||||
|
// In production, fetch specific fields to optimize
|
||||||
|
let universal: any = {};
|
||||||
|
try {
|
||||||
|
// Assuming blockId is the ID in offer_blocks_universal (or key)
|
||||||
|
// Since we stored them as items, we query by block_id field or id
|
||||||
|
const result = await this.client.request(readItems('offer_blocks_universal' as any, {
|
||||||
|
filter: { block_id: { _eq: blockId } },
|
||||||
|
limit: 1
|
||||||
|
}));
|
||||||
|
universal = result[0] || {};
|
||||||
|
} catch (e) { console.error(`Block not found: ${blockId}`); }
|
||||||
|
|
||||||
|
// Fetch Personalized Expansion (Skipped for MVP)
|
||||||
|
|
||||||
|
// MERGE
|
||||||
|
const mergedBlock = {
|
||||||
|
id: blockId,
|
||||||
|
title: universal.title,
|
||||||
|
hook: universal.hook_generator,
|
||||||
|
pains: universal.universal_pains || [],
|
||||||
|
solutions: universal.universal_solutions || [],
|
||||||
|
value_points: universal.universal_value_points || [],
|
||||||
|
cta: universal.cta_spintax,
|
||||||
|
spintax: universal.spintax_content // Assuming a new field for full block spintax
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. Resolve Tokens Per Block
|
||||||
|
const solvedBlock = this.resolveBlock(mergedBlock, context, variant);
|
||||||
|
blocksData.push(solvedBlock);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Assemble HTML
|
||||||
|
const html = HTMLRenderer.renderArticle(blocksData);
|
||||||
|
|
||||||
|
// 4. Generate Meta
|
||||||
|
const metaTitle = overrides?.title || this.generateMetaTitle(context, variant);
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: metaTitle,
|
||||||
|
html_content: html,
|
||||||
|
slug: overrides?.slug || this.generateSlug(metaTitle),
|
||||||
|
meta_desc: "Generated description..." // Implementation TBD
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveBlock(block: any, ctx: GenerationContext, variant: any): any {
|
||||||
|
const resolve = (text: string) => {
|
||||||
|
if (!text) return '';
|
||||||
|
let t = text;
|
||||||
|
|
||||||
|
// Level 1: Variables
|
||||||
|
t = t.replace(/{{NICHE}}/g, ctx.niche || 'Business');
|
||||||
|
t = t.replace(/{{CITY}}/g, ctx.city.city);
|
||||||
|
t = t.replace(/{{STATE}}/g, ctx.city.state);
|
||||||
|
t = t.replace(/{{ZIP_FOCUS}}/g, ctx.city.zip_focus || '');
|
||||||
|
t = t.replace(/{{AGENCY_NAME}}/g, "Spark Agency"); // Config
|
||||||
|
t = t.replace(/{{AGENCY_URL}}/g, ctx.site.url);
|
||||||
|
|
||||||
|
// Level 2: Spintax
|
||||||
|
t = SpintaxParser.parse(t);
|
||||||
|
|
||||||
|
// Level 3: Grammar
|
||||||
|
t = GrammarEngine.resolve(t, variant);
|
||||||
|
|
||||||
|
return t;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolvedBlock: any = {
|
||||||
|
id: block.id,
|
||||||
|
title: resolve(block.title),
|
||||||
|
hook: resolve(block.hook),
|
||||||
|
pains: (block.pains || []).map(resolve),
|
||||||
|
solutions: (block.solutions || []).map(resolve),
|
||||||
|
value_points: (block.value_points || []).map(resolve),
|
||||||
|
cta: resolve(block.cta)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle Spintax Content & Components
|
||||||
|
if (block.spintax) {
|
||||||
|
let content = SpintaxParser.parse(block.spintax);
|
||||||
|
|
||||||
|
// Dynamic Component Replacement
|
||||||
|
if (content.includes('{{COMPONENT_AVATAR_GRID}}')) {
|
||||||
|
content = content.replace('{{COMPONENT_AVATAR_GRID}}', this.generateAvatarGrid());
|
||||||
|
}
|
||||||
|
if (content.includes('{{COMPONENT_OPTIN_FORM}}')) {
|
||||||
|
content = content.replace('{{COMPONENT_OPTIN_FORM}}', this.generateOptinForm());
|
||||||
|
}
|
||||||
|
|
||||||
|
content = GrammarEngine.resolve(content, variant);
|
||||||
|
resolvedBlock.content = content;
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolvedBlock;
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateAvatarGrid(): string {
|
||||||
|
const avatars = [
|
||||||
|
"Scaling Founder", "Marketing Director", "Ecom Owner", "SaaS CEO", "Local Biz Owner",
|
||||||
|
"Real Estate Agent", "Coach/Consultant", "Agency Owner", "Startup CTO", "Enterprise VP"
|
||||||
|
];
|
||||||
|
|
||||||
|
let html = '<div class="grid grid-cols-2 md:grid-cols-5 gap-4 my-8">';
|
||||||
|
avatars.forEach(a => {
|
||||||
|
html += `
|
||||||
|
<div class="p-4 border border-slate-700 rounded-lg text-center bg-slate-800">
|
||||||
|
<div class="w-12 h-12 bg-blue-600/20 rounded-full mx-auto mb-2 flex items-center justify-center text-blue-400 font-bold">
|
||||||
|
${a[0]}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs font-medium text-white">${a}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
html += '</div>';
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateOptinForm(): string {
|
||||||
|
return `
|
||||||
|
<div class="bg-blue-900/20 border border-blue-800 p-8 rounded-xl my-8 text-center">
|
||||||
|
<h3 class="text-2xl font-bold text-white mb-4">Book Your Strategy Session</h3>
|
||||||
|
<p class="text-slate-400 mb-6">Stop guessing. Get a custom roadmap consisting of the exact systems we used to scale.</p>
|
||||||
|
<form class="max-w-md mx-auto space-y-4">
|
||||||
|
<input type="email" placeholder="Enter your work email" class="w-full p-3 bg-slate-900 border border-slate-700 rounded-lg text-white" />
|
||||||
|
<button type="button" class="w-full bg-blue-600 hover:bg-blue-500 text-white font-bold py-3 rounded-lg transition-colors">
|
||||||
|
Get My Roadmap
|
||||||
|
</button>
|
||||||
|
<p class="text-xs text-slate-500">No spam. Unsubscribe anytime.</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateMetaTitle(ctx: GenerationContext, variant: any): string {
|
||||||
|
// Simple random pattern selection for now
|
||||||
|
// In reality, this should come from "cartesian_patterns" loaded in context
|
||||||
|
// But for robust fail-safe:
|
||||||
|
const patterns = [
|
||||||
|
`Top Rated ${ctx.niche} Company in ${ctx.city.city}`,
|
||||||
|
`${ctx.city.city} ${ctx.niche} Experts - ${ctx.site.name || 'Official Site'}`,
|
||||||
|
`The #1 ${ctx.niche} Service in ${ctx.city.city}, ${ctx.city.state}`,
|
||||||
|
`Best ${ctx.niche} Agency Serving ${ctx.city.city}`
|
||||||
|
];
|
||||||
|
const raw = patterns[Math.floor(Math.random() * patterns.length)];
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateSlug(title: string): string {
|
||||||
|
return title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getAvatarVariant(avatarId: string, gender: string) {
|
||||||
|
// Try to fetch from Directus "avatar_variants"
|
||||||
|
// If fail, return default neutral
|
||||||
|
try {
|
||||||
|
// We assume variants are stored in a singleton or we query by avatar
|
||||||
|
// Since we don't have the ID handy, we return a safe default for this MVP test
|
||||||
|
// to ensure it works without complex relation queries right now.
|
||||||
|
// The GrammarEngine handles defaults if keys are missing.
|
||||||
|
return {
|
||||||
|
pronoun: 'they',
|
||||||
|
ppronoun: 'them',
|
||||||
|
pospronoun: 'their',
|
||||||
|
isare: 'are',
|
||||||
|
has_have: 'have',
|
||||||
|
does_do: 'do'
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
49
god-mode/src/lib/cartesian/GrammarEngine.ts
Normal file
49
god-mode/src/lib/cartesian/GrammarEngine.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
|
||||||
|
/**
|
||||||
|
* GrammarEngine
|
||||||
|
* Resolves grammar tokens like [[PRONOUN]], [[ISARE]] based on avatar variants.
|
||||||
|
*/
|
||||||
|
export class GrammarEngine {
|
||||||
|
/**
|
||||||
|
* Resolve grammar tokens in text.
|
||||||
|
* @param text Text containing [[TOKEN]] syntax
|
||||||
|
* @param variant The avatar variant object (e.g. { pronoun: "he", isare: "is" })
|
||||||
|
* @param variables Optional extra variables for function tokens like [[A_AN:{{NICHE}}]]
|
||||||
|
*/
|
||||||
|
static resolve(text: string, variant: Record<string, string>): string {
|
||||||
|
if (!text) return '';
|
||||||
|
let resolved = text;
|
||||||
|
|
||||||
|
// 1. Simple replacement from variant map
|
||||||
|
// Matches [[KEY]]
|
||||||
|
resolved = resolved.replace(/\[\[([A-Z_]+)\]\]/g, (match, key) => {
|
||||||
|
const lowerKey = key.toLowerCase();
|
||||||
|
if (variant[lowerKey]) {
|
||||||
|
return variant[lowerKey];
|
||||||
|
}
|
||||||
|
return match; // Return original if not found
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Handling A/An logic: [[A_AN:Word]]
|
||||||
|
resolved = resolved.replace(/\[\[A_AN:(.*?)\]\]/g, (match, content) => {
|
||||||
|
return GrammarEngine.a_an(content);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Capitalization: [[CAP:word]]
|
||||||
|
resolved = resolved.replace(/\[\[CAP:(.*?)\]\]/g, (match, content) => {
|
||||||
|
return content.charAt(0).toUpperCase() + content.slice(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
static a_an(word: string): string {
|
||||||
|
const vowels = ['a', 'e', 'i', 'o', 'u'];
|
||||||
|
const firstChar = word.trim().charAt(0).toLowerCase();
|
||||||
|
// Simple heuristic
|
||||||
|
if (vowels.includes(firstChar)) {
|
||||||
|
return `an ${word}`;
|
||||||
|
}
|
||||||
|
return `a ${word}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
60
god-mode/src/lib/cartesian/HTMLRenderer.ts
Normal file
60
god-mode/src/lib/cartesian/HTMLRenderer.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
|
||||||
|
/**
|
||||||
|
* HTMLRenderer (Assembler)
|
||||||
|
* Wraps raw content blocks in formatted HTML.
|
||||||
|
*/
|
||||||
|
export class HTMLRenderer {
|
||||||
|
/**
|
||||||
|
* Render a full article from blocks.
|
||||||
|
* @param blocks Array of processed content blocks objects
|
||||||
|
* @returns Full HTML string
|
||||||
|
*/
|
||||||
|
static renderArticle(blocks: any[]): string {
|
||||||
|
return blocks.map(block => this.renderBlock(block)).join('\n\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a single block based on its structure.
|
||||||
|
*/
|
||||||
|
static renderBlock(block: any): string {
|
||||||
|
let html = '';
|
||||||
|
|
||||||
|
// Title
|
||||||
|
if (block.title) {
|
||||||
|
html += `<h2>${block.title}</h2>\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hook
|
||||||
|
if (block.hook) {
|
||||||
|
html += `<p class="lead"><strong>${block.hook}</strong></p>\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pains (Unordered List)
|
||||||
|
if (block.pains && block.pains.length > 0) {
|
||||||
|
html += `<ul>\n${block.pains.map((p: string) => ` <li>${p}</li>`).join('\n')}\n</ul>\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solutions (Paragraphs or Ordered List)
|
||||||
|
if (block.solutions && block.solutions.length > 0) {
|
||||||
|
// Configurable, defaulting to paragraphs for flow
|
||||||
|
html += block.solutions.map((s: string) => `<p>${s}</p>`).join('\n') + '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Value Points (Checkmark List style usually)
|
||||||
|
if (block.value_points && block.value_points.length > 0) {
|
||||||
|
html += `<ul class="value-points">\n${block.value_points.map((v: string) => ` <li>✅ ${v}</li>`).join('\n')}\n</ul>\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raw Content (from Spintax/Components)
|
||||||
|
if (block.content) {
|
||||||
|
html += `<div class="block-content">\n${block.content}\n</div>\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CTA
|
||||||
|
if (block.cta) {
|
||||||
|
html += `<div class="cta-box"><p>${block.cta}</p></div>\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<section class="content-block" id="${block.id || ''}">\n${html}</section>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
15
god-mode/src/lib/cartesian/MetadataGenerator.ts
Normal file
15
god-mode/src/lib/cartesian/MetadataGenerator.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
|
||||||
|
/**
|
||||||
|
* MetadataGenerator
|
||||||
|
* Auto-generates SEO titles and descriptions.
|
||||||
|
*/
|
||||||
|
export class MetadataGenerator {
|
||||||
|
static generateTitle(niche: string, city: string, state: string): string {
|
||||||
|
// Simple formula for now - can be expanded to use patterns
|
||||||
|
return `Top ${niche} Services in ${city}, ${state} | Verified Experts`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static generateDescription(niche: string, city: string): string {
|
||||||
|
return `Looking for the best ${niche} in ${city}? We provide top-rated solutions tailored for your business needs. Get a free consultation today.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
42
god-mode/src/lib/cartesian/SpintaxParser.ts
Normal file
42
god-mode/src/lib/cartesian/SpintaxParser.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
|
||||||
|
/**
|
||||||
|
* SpintaxParser
|
||||||
|
* Handles recursive parsing of {option1|option2} syntax.
|
||||||
|
*/
|
||||||
|
export class SpintaxParser {
|
||||||
|
/**
|
||||||
|
* Parse a string containing spintax.
|
||||||
|
* Supports nested spintax like {Hi|Hello {World|Friend}}
|
||||||
|
* @param text The text with spintax
|
||||||
|
* @returns The parsed text with one option selected per block
|
||||||
|
*/
|
||||||
|
static parse(text: string): string {
|
||||||
|
if (!text) return '';
|
||||||
|
|
||||||
|
// Regex to find the innermost spintax block: {([^{}]*)}
|
||||||
|
// We execute this recursively until no braces remain.
|
||||||
|
let parsed = text;
|
||||||
|
const regex = /\{([^{}]+)\}/g;
|
||||||
|
|
||||||
|
while (regex.test(parsed)) {
|
||||||
|
parsed = parsed.replace(regex, (match, content) => {
|
||||||
|
const options = content.split('|');
|
||||||
|
const randomOption = options[Math.floor(Math.random() * options.length)];
|
||||||
|
return randomOption;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count total variations in a spintax string.
|
||||||
|
* (Simplified estimate for preview calculator)
|
||||||
|
*/
|
||||||
|
static countVariations(text: string): number {
|
||||||
|
// Basic implementation for complexity estimation
|
||||||
|
// Real count requiring parsing tree is complex,
|
||||||
|
// this is a placeholder if needed for UI later.
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
33
god-mode/src/lib/cartesian/UniquenessManager.ts
Normal file
33
god-mode/src/lib/cartesian/UniquenessManager.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
|
||||||
|
import module from 'node:crypto';
|
||||||
|
const { createHash } = module;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UniquenessManager
|
||||||
|
* Handles content hashing to prevent duplicate generation.
|
||||||
|
*/
|
||||||
|
export class UniquenessManager {
|
||||||
|
/**
|
||||||
|
* Generate a unique hash for a specific combination.
|
||||||
|
* Format: {SiteID}_{AvatarID}_{Niche}_{City}_{PatternID}
|
||||||
|
*/
|
||||||
|
static generateHash(siteId: string, avatarId: string, niche: string, city: string, patternId: string): string {
|
||||||
|
const raw = `${siteId}_${avatarId}_${niche}_${city}_${patternId}`;
|
||||||
|
return createHash('md5').update(raw).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a hash already exists in the database.
|
||||||
|
* (Placeholder logic - real implementation queries Directus)
|
||||||
|
*/
|
||||||
|
static async checkExists(client: any, hash: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
// This would be a Directus query
|
||||||
|
// const res = await client.request(readItems('generated_articles', { filter: { generation_hash: { _eq: hash } }, limit: 1 }));
|
||||||
|
// return res.length > 0;
|
||||||
|
return false; // For now
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
107
god-mode/src/lib/collections/config.ts
Normal file
107
god-mode/src/lib/collections/config.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* Collection Page Template Generator
|
||||||
|
* Creates standardized CRUD pages for all collections
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const collectionConfigs = {
|
||||||
|
avatar_intelligence: {
|
||||||
|
title: 'Avatar Intelligence',
|
||||||
|
description: 'Manage persona profiles and variants',
|
||||||
|
icon: '👥',
|
||||||
|
fields: ['base_name', 'wealth_cluster', 'business_niches'],
|
||||||
|
displayField: 'base_name',
|
||||||
|
},
|
||||||
|
avatar_variants: {
|
||||||
|
title: 'Avatar Variants',
|
||||||
|
description: 'Manage gender and tone variations',
|
||||||
|
icon: '🎭',
|
||||||
|
fields: ['avatar_id', 'variant_name', 'pronouns'],
|
||||||
|
displayField: 'variant_name',
|
||||||
|
},
|
||||||
|
campaign_masters: {
|
||||||
|
title: 'Campaign Masters',
|
||||||
|
description: 'Manage marketing campaigns',
|
||||||
|
icon: '📢',
|
||||||
|
fields: ['campaign_name', 'status', 'site_id'],
|
||||||
|
displayField: 'campaign_name',
|
||||||
|
},
|
||||||
|
cartesian_patterns: {
|
||||||
|
title: 'Cartesian Patterns',
|
||||||
|
description: 'Content structure templates',
|
||||||
|
icon: '🔧',
|
||||||
|
fields: ['pattern_name', 'structure_type'],
|
||||||
|
displayField: 'pattern_name',
|
||||||
|
},
|
||||||
|
content_fragments: {
|
||||||
|
title: 'Content Fragments',
|
||||||
|
description: 'Reusable content blocks',
|
||||||
|
icon: '📦',
|
||||||
|
fields: ['fragment_type', 'content'],
|
||||||
|
displayField: 'fragment_type',
|
||||||
|
},
|
||||||
|
generated_articles: {
|
||||||
|
title: 'Generated Articles',
|
||||||
|
description: 'AI-generated content output',
|
||||||
|
icon: '📝',
|
||||||
|
fields: ['title', 'status', 'seo_score', 'geo_city'],
|
||||||
|
displayField: 'title',
|
||||||
|
},
|
||||||
|
generation_jobs: {
|
||||||
|
title: 'Generation Jobs',
|
||||||
|
description: 'Content generation queue',
|
||||||
|
icon: '⚙️',
|
||||||
|
fields: ['job_name', 'status', 'progress'],
|
||||||
|
displayField: 'job_name',
|
||||||
|
},
|
||||||
|
geo_intelligence: {
|
||||||
|
title: 'Geo Intelligence',
|
||||||
|
description: 'Location targeting data',
|
||||||
|
icon: '🗺️',
|
||||||
|
fields: ['city', 'state', 'zip', 'population'],
|
||||||
|
displayField: 'city',
|
||||||
|
},
|
||||||
|
headline_inventory: {
|
||||||
|
title: 'Headline Inventory',
|
||||||
|
description: 'Pre-written headlines library',
|
||||||
|
icon: '💬',
|
||||||
|
fields: ['headline_text', 'category'],
|
||||||
|
displayField: 'headline_text',
|
||||||
|
},
|
||||||
|
leads: {
|
||||||
|
title: 'Leads',
|
||||||
|
description: 'Customer lead management',
|
||||||
|
icon: '👤',
|
||||||
|
fields: ['name', 'email', 'status'],
|
||||||
|
displayField: 'name',
|
||||||
|
},
|
||||||
|
offer_blocks: {
|
||||||
|
title: 'Offer Blocks',
|
||||||
|
description: 'Call-to-action templates',
|
||||||
|
icon: '🎯',
|
||||||
|
fields: ['offer_text', 'offer_type'],
|
||||||
|
displayField: 'offer_text',
|
||||||
|
},
|
||||||
|
pages: {
|
||||||
|
title: 'Pages',
|
||||||
|
description: 'Static page content',
|
||||||
|
icon: '📄',
|
||||||
|
fields: ['title', 'slug', 'status'],
|
||||||
|
displayField: 'title',
|
||||||
|
},
|
||||||
|
posts: {
|
||||||
|
title: 'Posts',
|
||||||
|
description: 'Blog posts and articles',
|
||||||
|
icon: '📰',
|
||||||
|
fields: ['title', 'status', 'seo_score'],
|
||||||
|
displayField: 'title',
|
||||||
|
},
|
||||||
|
spintax_dictionaries: {
|
||||||
|
title: 'Spintax Dictionaries',
|
||||||
|
description: 'Word variation sets',
|
||||||
|
icon: '📚',
|
||||||
|
fields: ['category', 'variations'],
|
||||||
|
displayField: 'category',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CollectionName = keyof typeof collectionConfigs;
|
||||||
16
god-mode/src/lib/db.ts
Normal file
16
god-mode/src/lib/db.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import pg from 'pg';
|
||||||
|
const { Pool } = pg;
|
||||||
|
|
||||||
|
if (!process.env.DATABASE_URL) {
|
||||||
|
console.warn("⚠️ DATABASE_URL is missing. DB connections will fail.");
|
||||||
|
}
|
||||||
|
|
||||||
|
export const pool = new Pool({
|
||||||
|
connectionString: process.env.DATABASE_URL,
|
||||||
|
max: 10,
|
||||||
|
idleTimeoutMillis: 30000,
|
||||||
|
connectionTimeoutMillis: 5000,
|
||||||
|
ssl: process.env.DATABASE_URL?.includes('sslmode=require') ? { rejectUnauthorized: false } : undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
export const query = (text: string, params?: any[]) => pool.query(text, params);
|
||||||
12
god-mode/src/lib/directus-enhanced.ts
Normal file
12
god-mode/src/lib/directus-enhanced.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { createDirectus, rest, authentication, realtime } from '@directus/sdk';
|
||||||
|
import type { DirectusSchema } from '@/lib/schemas';
|
||||||
|
|
||||||
|
const DIRECTUS_URL = import.meta.env.PUBLIC_DIRECTUS_URL || 'https://spark.jumpstartscaling.com';
|
||||||
|
|
||||||
|
export const directus = createDirectus<DirectusSchema>(DIRECTUS_URL)
|
||||||
|
.with(authentication('cookie', { autoRefresh: true }))
|
||||||
|
.with(rest())
|
||||||
|
.with(realtime());
|
||||||
|
|
||||||
|
// Re-export for convenience
|
||||||
|
export { readItems, readItem, createItem, updateItem, deleteItem, aggregate } from '@directus/sdk';
|
||||||
242
god-mode/src/lib/directus/client.ts
Normal file
242
god-mode/src/lib/directus/client.ts
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
import { query } from '../db';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Directus Shim for Valhalla
|
||||||
|
* Translates Directus SDK calls to Raw SQL to allow engines to run headless.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// --- Types ---
|
||||||
|
interface QueryCmp {
|
||||||
|
_eq?: any;
|
||||||
|
_neq?: any;
|
||||||
|
_gt?: any;
|
||||||
|
_lt?: any;
|
||||||
|
_contains?: any;
|
||||||
|
_in?: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QueryFilter {
|
||||||
|
[field: string]: QueryCmp | QueryFilter | any;
|
||||||
|
_or?: QueryFilter[];
|
||||||
|
_and?: QueryFilter[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Query {
|
||||||
|
filter?: QueryFilter;
|
||||||
|
fields?: string[];
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
sort?: string[];
|
||||||
|
aggregate?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- SDK Mocks ---
|
||||||
|
|
||||||
|
export function readItems(collection: string, q?: Query) {
|
||||||
|
return { type: 'readItems', collection, query: q };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readItem(collection: string, id: string | number, q?: Query) {
|
||||||
|
return { type: 'readItem', collection, id, query: q };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createItem(collection: string, data: any) {
|
||||||
|
return { type: 'createItem', collection, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateItem(collection: string, id: string | number, data: any) {
|
||||||
|
return { type: 'updateItem', collection, id, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteItem(collection: string, id: string | number) {
|
||||||
|
return { type: 'deleteItem', collection, id };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readSingleton(collection: string, q?: Query) {
|
||||||
|
return { type: 'readSingleton', collection, query: q };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function aggregate(collection: string, q?: Query) {
|
||||||
|
return { type: 'aggregate', collection, query: q };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Client Implementation ---
|
||||||
|
|
||||||
|
export function getDirectusClient() {
|
||||||
|
return {
|
||||||
|
request: async (command: any) => {
|
||||||
|
try {
|
||||||
|
switch (command.type) {
|
||||||
|
case 'readItems':
|
||||||
|
return await executeReadItems(command.collection, command.query);
|
||||||
|
case 'readItem':
|
||||||
|
return await executeReadItem(command.collection, command.id, command.query);
|
||||||
|
case 'createItem':
|
||||||
|
return await executeCreateItem(command.collection, command.data);
|
||||||
|
case 'updateItem':
|
||||||
|
return await executeUpdateItem(command.collection, command.id, command.data);
|
||||||
|
case 'deleteItem':
|
||||||
|
return await executeDeleteItem(command.collection, command.id);
|
||||||
|
case 'aggregate':
|
||||||
|
return await executeAggregate(command.collection, command.query);
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown command type: ${command.type}`);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`Shim Error (${command.type} on ${command.collection}):`, err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- SQL Builders ---
|
||||||
|
|
||||||
|
async function executeReadItems(collection: string, q: Query = {}) {
|
||||||
|
let sql = `SELECT ${buildSelectFields(q.fields)} FROM "${collection}"`;
|
||||||
|
const params: any[] = [];
|
||||||
|
|
||||||
|
if (q.filter) {
|
||||||
|
const { where, vals } = buildWhere(q.filter, params);
|
||||||
|
if (where) sql += ` WHERE ${where}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort
|
||||||
|
if (q.sort) {
|
||||||
|
// Simple sort support: ['-date_created'] -> ORDER BY date_created DESC
|
||||||
|
const orderBy = q.sort.map(s => {
|
||||||
|
const desc = s.startsWith('-');
|
||||||
|
const field = desc ? s.substring(1) : s;
|
||||||
|
return `"${field}" ${desc ? 'DESC' : 'ASC'}`;
|
||||||
|
}).join(', ');
|
||||||
|
if (orderBy) sql += ` ORDER BY ${orderBy}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit/Offset
|
||||||
|
if (q.limit) sql += ` LIMIT ${q.limit}`;
|
||||||
|
if (q.offset) sql += ` OFFSET ${q.offset}`;
|
||||||
|
|
||||||
|
const res = await query(sql, params);
|
||||||
|
return res.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeReadItem(collection: string, id: string | number, q: Query = {}) {
|
||||||
|
// If ID is numeric, simple. If UUID, simple.
|
||||||
|
const res = await query(`SELECT * FROM "${collection}" WHERE id = $1`, [id]);
|
||||||
|
return res.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeCreateItem(collection: string, data: any) {
|
||||||
|
const keys = Object.keys(data);
|
||||||
|
const vals = Object.values(data);
|
||||||
|
const placeholders = keys.map((_, i) => `$${i + 1}`).join(', ');
|
||||||
|
const cols = keys.map(k => `"${k}"`).join(', '); // Quote cols for safety
|
||||||
|
|
||||||
|
const sql = `INSERT INTO "${collection}" (${cols}) VALUES (${placeholders}) RETURNING *`;
|
||||||
|
const res = await query(sql, vals);
|
||||||
|
return res.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeUpdateItem(collection: string, id: string | number, data: any) {
|
||||||
|
const keys = Object.keys(data);
|
||||||
|
const vals = Object.values(data);
|
||||||
|
const setClause = keys.map((k, i) => `"${k}" = $${i + 2}`).join(', '); // Start at $2
|
||||||
|
|
||||||
|
const sql = `UPDATE "${collection}" SET ${setClause} WHERE id = $1 RETURNING *`;
|
||||||
|
const res = await query(sql, [id, ...vals]);
|
||||||
|
return res.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeDeleteItem(collection: string, id: string | number) {
|
||||||
|
await query(`DELETE FROM "${collection}" WHERE id = $1`, [id]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeAggregate(collection: string, q: Query = {}) {
|
||||||
|
// Very basic aggregate support (COUNT is most common)
|
||||||
|
if (q.aggregate?.count) {
|
||||||
|
let sql = `SELECT COUNT(*) as count FROM "${collection}"`;
|
||||||
|
const params: any[] = [];
|
||||||
|
if (q.filter) {
|
||||||
|
const { where, vals } = buildWhere(q.filter, params);
|
||||||
|
if (where) sql += ` WHERE ${where}`;
|
||||||
|
}
|
||||||
|
const res = await query(sql, params);
|
||||||
|
return [{ count: res.rows[0].count }];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Query Helpers ---
|
||||||
|
|
||||||
|
function buildSelectFields(fields?: string[]) {
|
||||||
|
if (!fields || fields.includes('*') || fields.length === 0) return '*';
|
||||||
|
// Filter out nested objects/arrays syntax from Directus SDK (e.g. { county: ['name'] })
|
||||||
|
// For raw SQL, we just select top-level cols.
|
||||||
|
// This SHIM assumes flat selection or ignores deep selection for now.
|
||||||
|
const cleanFields = fields.filter(f => typeof f === 'string');
|
||||||
|
if (cleanFields.length === 0) return '*';
|
||||||
|
return cleanFields.map(f => `"${f}"`).join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWhere(filter: QueryFilter, params: any[]): { where: string, vals: any[] } {
|
||||||
|
const conditions: string[] = [];
|
||||||
|
|
||||||
|
// Handle _or / _and
|
||||||
|
if (filter._or) {
|
||||||
|
const orConds = filter._or.map(f => {
|
||||||
|
const res = buildWhere(f, params);
|
||||||
|
return `(${res.where})`;
|
||||||
|
});
|
||||||
|
conditions.push(`(${orConds.join(' OR ')})`);
|
||||||
|
return { where: conditions.join(' AND '), vals: params };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter._and) {
|
||||||
|
const andConds = filter._and.map(f => {
|
||||||
|
const res = buildWhere(f, params);
|
||||||
|
return `(${res.where})`;
|
||||||
|
});
|
||||||
|
conditions.push(`(${andConds.join(' AND ')})`);
|
||||||
|
return { where: conditions.join(' AND '), vals: params };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, val] of Object.entries(filter)) {
|
||||||
|
if (key.startsWith('_')) continue; // Skip ops
|
||||||
|
|
||||||
|
// If val is object with ops: { _eq: 1 }
|
||||||
|
if (typeof val === 'object' && val !== null && !Array.isArray(val)) {
|
||||||
|
for (const [op, opVal] of Object.entries(val)) {
|
||||||
|
if (op === '_eq') {
|
||||||
|
params.push(opVal);
|
||||||
|
conditions.push(`"${key}" = $${params.length}`);
|
||||||
|
} else if (op === '_neq') {
|
||||||
|
params.push(opVal);
|
||||||
|
conditions.push(`"${key}" != $${params.length}`);
|
||||||
|
} else if (op === '_contains') {
|
||||||
|
params.push(`%${opVal}%`);
|
||||||
|
conditions.push(`"${key}" LIKE $${params.length}`);
|
||||||
|
} else if (op === '_gt') {
|
||||||
|
params.push(opVal);
|
||||||
|
conditions.push(`"${key}" > $${params.length}`);
|
||||||
|
} else if (op === '_lt') {
|
||||||
|
params.push(opVal);
|
||||||
|
conditions.push(`"${key}" < $${params.length}`);
|
||||||
|
} else if (op === '_in') {
|
||||||
|
// opVal is array
|
||||||
|
const placeholders = (opVal as any[]).map(v => {
|
||||||
|
params.push(v);
|
||||||
|
return `$${params.length}`;
|
||||||
|
}).join(', ');
|
||||||
|
conditions.push(`"${key}" IN (${placeholders})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Implicit equality: { status: 'published' }
|
||||||
|
params.push(val);
|
||||||
|
conditions.push(`"${key}" = $${params.length}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { where: conditions.join(' AND '), vals: params };
|
||||||
|
}
|
||||||
319
god-mode/src/lib/directus/fetchers.ts
Normal file
319
god-mode/src/lib/directus/fetchers.ts
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
import { getDirectusClient } from './client';
|
||||||
|
import { readItems, readItem, readSingleton, aggregate } from '@directus/sdk';
|
||||||
|
import type { DirectusSchema, Pages as Page, Posts as Post, Sites as Site, DirectusUsers as User, Globals, Navigation } from '../schemas';
|
||||||
|
|
||||||
|
const directus = getDirectusClient();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a page by permalink (tenant-safe)
|
||||||
|
*/
|
||||||
|
export async function fetchPageByPermalink(
|
||||||
|
permalink: string,
|
||||||
|
siteId: string,
|
||||||
|
options?: { preview?: boolean; token?: string }
|
||||||
|
): Promise<Page | null> {
|
||||||
|
const filter: Record<string, any> = {
|
||||||
|
permalink: { _eq: permalink },
|
||||||
|
site_id: { _eq: siteId }
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!options?.preview) {
|
||||||
|
filter.status = { _eq: 'published' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pages = await directus.request(
|
||||||
|
readItems('pages', {
|
||||||
|
filter,
|
||||||
|
limit: 1,
|
||||||
|
fields: [
|
||||||
|
'id',
|
||||||
|
'title',
|
||||||
|
'permalink',
|
||||||
|
'site_id',
|
||||||
|
'status',
|
||||||
|
'seo_title',
|
||||||
|
'seo_description',
|
||||||
|
'seo_image',
|
||||||
|
'blocks', // Fetch as simple JSON field
|
||||||
|
'schema_json'
|
||||||
|
]
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return pages?.[0] || null;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching page:', err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch site globals
|
||||||
|
*/
|
||||||
|
export async function fetchSiteGlobals(siteId: string): Promise<Globals | null> {
|
||||||
|
try {
|
||||||
|
const globals = await directus.request(
|
||||||
|
readItems('globals', {
|
||||||
|
filter: { site_id: { _eq: siteId } },
|
||||||
|
limit: 1,
|
||||||
|
fields: ['*']
|
||||||
|
})
|
||||||
|
);
|
||||||
|
// SDK returns array directly - cast only the final result
|
||||||
|
const result = globals as Globals[];
|
||||||
|
return result?.[0] ?? null;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching globals:', err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch site navigation
|
||||||
|
*/
|
||||||
|
export async function fetchNavigation(siteId: string): Promise<Partial<Navigation>[]> {
|
||||||
|
try {
|
||||||
|
const nav = await directus.request(
|
||||||
|
readItems('navigation', {
|
||||||
|
filter: { site_id: { _eq: siteId } },
|
||||||
|
sort: ['sort'],
|
||||||
|
fields: ['id', 'label', 'url', 'parent', 'target', 'sort']
|
||||||
|
})
|
||||||
|
);
|
||||||
|
// SDK returns array directly
|
||||||
|
return (nav as Navigation[]) ?? [];
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching navigation:', err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch posts for a site
|
||||||
|
*/
|
||||||
|
export async function fetchPosts(
|
||||||
|
siteId: string,
|
||||||
|
options?: { limit?: number; page?: number; category?: string }
|
||||||
|
): Promise<{ posts: Partial<Post>[]; total: number }> {
|
||||||
|
const limit = options?.limit || 10;
|
||||||
|
const page = options?.page || 1;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
const filter: Record<string, any> = {
|
||||||
|
site_id: { _eq: siteId }, // siteId is UUID string
|
||||||
|
status: { _eq: 'published' }
|
||||||
|
};
|
||||||
|
|
||||||
|
if (options?.category) {
|
||||||
|
filter.category = { _eq: options.category };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [posts, countResult] = await Promise.all([
|
||||||
|
directus.request(
|
||||||
|
readItems('posts', {
|
||||||
|
filter,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
sort: ['-published_at'],
|
||||||
|
fields: [
|
||||||
|
'id',
|
||||||
|
'title',
|
||||||
|
'slug',
|
||||||
|
'excerpt',
|
||||||
|
'featured_image',
|
||||||
|
'published_at',
|
||||||
|
'category',
|
||||||
|
'author',
|
||||||
|
'site_id',
|
||||||
|
'status',
|
||||||
|
'content'
|
||||||
|
]
|
||||||
|
})
|
||||||
|
),
|
||||||
|
directus.request(
|
||||||
|
aggregate('posts', {
|
||||||
|
aggregate: { count: '*' },
|
||||||
|
query: { filter }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
posts: (posts as Partial<Post>[]) || [],
|
||||||
|
total: Number(countResult?.[0]?.count || 0)
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching posts:', err);
|
||||||
|
return { posts: [], total: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a single post by slug
|
||||||
|
*/
|
||||||
|
export async function fetchPostBySlug(
|
||||||
|
slug: string,
|
||||||
|
siteId: string
|
||||||
|
): Promise<Post | null> {
|
||||||
|
try {
|
||||||
|
const posts = await directus.request(
|
||||||
|
readItems('posts', {
|
||||||
|
filter: {
|
||||||
|
slug: { _eq: slug },
|
||||||
|
site_id: { _eq: siteId },
|
||||||
|
status: { _eq: 'published' }
|
||||||
|
},
|
||||||
|
limit: 1,
|
||||||
|
fields: ['*']
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return posts?.[0] || null;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching post:', err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch generated articles for a site
|
||||||
|
*/
|
||||||
|
export async function fetchGeneratedArticles(
|
||||||
|
siteId: string,
|
||||||
|
options?: { limit?: number; page?: number }
|
||||||
|
): Promise<{ articles: any[]; total: number }> {
|
||||||
|
const limit = options?.limit || 20;
|
||||||
|
const page = options?.page || 1;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [articles, countResult] = await Promise.all([
|
||||||
|
directus.request(
|
||||||
|
readItems('generated_articles', {
|
||||||
|
filter: { site_id: { _eq: siteId } }, // UUID string
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
sort: ['-date_created'],
|
||||||
|
fields: ['*']
|
||||||
|
})
|
||||||
|
),
|
||||||
|
directus.request(
|
||||||
|
aggregate('generated_articles', {
|
||||||
|
aggregate: { count: '*' },
|
||||||
|
query: { filter: { site_id: { _eq: siteId } } } // UUID string
|
||||||
|
})
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
articles: articles || [],
|
||||||
|
total: Number(countResult?.[0]?.count || 0)
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching articles:', err);
|
||||||
|
return { articles: [], total: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a single generated article by slug
|
||||||
|
*/
|
||||||
|
export async function fetchGeneratedArticleBySlug(
|
||||||
|
slug: string,
|
||||||
|
siteId: string
|
||||||
|
): Promise<any | null> {
|
||||||
|
try {
|
||||||
|
const articles = await directus.request(
|
||||||
|
readItems('generated_articles', {
|
||||||
|
filter: {
|
||||||
|
_and: [
|
||||||
|
{ slug: { _eq: slug } },
|
||||||
|
{ site_id: { _eq: siteId } },
|
||||||
|
{ is_published: { _eq: true } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
limit: 1,
|
||||||
|
fields: ['*']
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return articles?.[0] || null;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching generated article:', err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch SEO campaigns
|
||||||
|
*/
|
||||||
|
export async function fetchCampaigns(siteId?: string) {
|
||||||
|
const filter: Record<string, any> = {};
|
||||||
|
if (siteId) {
|
||||||
|
filter._or = [
|
||||||
|
{ site_id: { _eq: siteId } },
|
||||||
|
{ site_id: { _null: true } }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await directus.request(
|
||||||
|
readItems('campaign_masters', {
|
||||||
|
filter,
|
||||||
|
sort: ['-date_created'],
|
||||||
|
fields: ['*']
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching campaigns:', err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch locations (states, counties, cities)
|
||||||
|
*/
|
||||||
|
export async function fetchStates() {
|
||||||
|
try {
|
||||||
|
return await directus.request(
|
||||||
|
readItems('locations_states', {
|
||||||
|
sort: ['name'],
|
||||||
|
fields: ['*']
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching states:', err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchCountiesByState(stateId: string) {
|
||||||
|
try {
|
||||||
|
return await directus.request(
|
||||||
|
readItems('locations_counties', {
|
||||||
|
filter: { state: { _eq: stateId } },
|
||||||
|
sort: ['name'],
|
||||||
|
fields: ['*']
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching counties:', err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchCitiesByCounty(countyId: string, limit = 50) {
|
||||||
|
try {
|
||||||
|
return await directus.request(
|
||||||
|
readItems('locations_cities', {
|
||||||
|
filter: { county: { _eq: countyId } },
|
||||||
|
sort: ['-population'],
|
||||||
|
limit,
|
||||||
|
fields: ['*']
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching cities:', err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
236
god-mode/src/lib/godMode.ts
Normal file
236
god-mode/src/lib/godMode.ts
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
/**
|
||||||
|
* God Mode Client Library
|
||||||
|
*
|
||||||
|
* Frontend client for god-mode API access
|
||||||
|
* Used by all admin pages for seamless operations
|
||||||
|
* Bypasses normal Directus auth via god token
|
||||||
|
*/
|
||||||
|
|
||||||
|
const GOD_MODE_BASE_URL = import.meta.env.PUBLIC_DIRECTUS_URL || 'https://spark.jumpstartscaling.com';
|
||||||
|
const GOD_TOKEN = import.meta.env.GOD_MODE_TOKEN || 'jmQXoeyxWoBsB7eHzG7FmnH90f22JtaYBxXHoorhfZ-v4tT3VNEr9vvmwHqYHCDoWXHSU4DeZXApCP-Gha-YdA';
|
||||||
|
|
||||||
|
class GodModeClient {
|
||||||
|
private token: string;
|
||||||
|
private baseUrl: string;
|
||||||
|
|
||||||
|
constructor(token: string = GOD_TOKEN) {
|
||||||
|
this.token = token;
|
||||||
|
this.baseUrl = `${GOD_MODE_BASE_URL}/god`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async request(endpoint: string, options: RequestInit = {}): Promise<any> {
|
||||||
|
const url = `${this.baseUrl}${endpoint}`;
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-God-Token': this.token,
|
||||||
|
...options.headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.error || 'God mode request failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Status & Health ===
|
||||||
|
async getStatus() {
|
||||||
|
return this.request('/status');
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Database Operations ===
|
||||||
|
async setupDatabase(sql: string): Promise<any> {
|
||||||
|
return this.request('/setup/database', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ sql })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async executeSQL(sql: string, params: any[] = []): Promise<any> {
|
||||||
|
return this.request('/sql/execute', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ sql, params })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Permissions ===
|
||||||
|
async grantAllPermissions(): Promise<any> {
|
||||||
|
return this.request('/permissions/grant-all', {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Collections ===
|
||||||
|
async getAllCollections(): Promise<any> {
|
||||||
|
return this.request('/collections/all');
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Users ===
|
||||||
|
async makeUserAdmin(emailOrId: string): Promise<any> {
|
||||||
|
const body = typeof emailOrId === 'string' && emailOrId.includes('@')
|
||||||
|
? { email: emailOrId }
|
||||||
|
: { userId: emailOrId };
|
||||||
|
|
||||||
|
return this.request('/user/make-admin', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Schema Management ===
|
||||||
|
async createCollection(collection: string, fields: any[], meta: any = {}): Promise<any> {
|
||||||
|
return this.request('/schema/collections/create', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ collection, fields, meta })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async addField(collection: string, field: string, type: string, meta: any = {}): Promise<any> {
|
||||||
|
return this.request('/schema/fields/create', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ collection, field, type, meta })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteField(collection: string, field: string): Promise<any> {
|
||||||
|
return this.request(`/schema/fields/${collection}/${field}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async exportSchema(): Promise<any> {
|
||||||
|
return this.request('/schema/snapshot');
|
||||||
|
}
|
||||||
|
|
||||||
|
async applySchema(yaml: string): Promise<any> {
|
||||||
|
return this.request('/schema/apply', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ yaml })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async createRelation(relation: any): Promise<any> {
|
||||||
|
return this.request('/schema/relations/create', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(relation)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Site Provisioning ===
|
||||||
|
async provisionSite({ name, domain, create_homepage = true, include_collections = [] }: {
|
||||||
|
name: string;
|
||||||
|
domain: string;
|
||||||
|
create_homepage?: boolean;
|
||||||
|
include_collections?: string[];
|
||||||
|
}): Promise<any> {
|
||||||
|
return this.request('/sites/provision', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
name,
|
||||||
|
domain,
|
||||||
|
create_homepage,
|
||||||
|
include_collections
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async addPageToSite(siteId: string, { title, slug, template = 'default' }: {
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
template?: string;
|
||||||
|
}): Promise<any> {
|
||||||
|
return this.request(`/sites/${siteId}/add-page`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ title, slug, template })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Work Log ===
|
||||||
|
async logWork(data: { action: string; details: any; userId?: string }): Promise<any> {
|
||||||
|
return this.executeSQL(
|
||||||
|
'INSERT INTO work_log (action, details, user_id, timestamp) VALUES ($1, $2, $3, NOW()) RETURNING *',
|
||||||
|
[data.action, JSON.stringify(data.details), data.userId || 'god-mode']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getWorkLog(limit: number = 100): Promise<any> {
|
||||||
|
return this.executeSQL(
|
||||||
|
`SELECT * FROM work_log ORDER BY timestamp DESC LIMIT ${limit}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Error Logs ===
|
||||||
|
async logError(error: Error | any, context: any = {}): Promise<any> {
|
||||||
|
return this.executeSQL(
|
||||||
|
'INSERT INTO error_logs (error_message, stack_trace, context, timestamp) VALUES ($1, $2, $3, NOW()) RETURNING *',
|
||||||
|
[
|
||||||
|
error.message || error,
|
||||||
|
error.stack || '',
|
||||||
|
JSON.stringify(context)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getErrorLogs(limit: number = 50): Promise<any> {
|
||||||
|
return this.executeSQL(
|
||||||
|
`SELECT * FROM error_logs ORDER BY timestamp DESC LIMIT ${limit}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Job Queue ===
|
||||||
|
async addJob(jobType: string, payload: any, priority: number = 0): Promise<any> {
|
||||||
|
return this.executeSQL(
|
||||||
|
'INSERT INTO job_queue (job_type, payload, priority, status, created_at) VALUES ($1, $2, $3, $4, NOW()) RETURNING *',
|
||||||
|
[jobType, JSON.stringify(payload), priority, 'pending']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getJobQueue(status: string | null = null): Promise<any> {
|
||||||
|
const sql = status
|
||||||
|
? `SELECT * FROM job_queue WHERE status = $1 ORDER BY priority DESC, created_at ASC`
|
||||||
|
: `SELECT * FROM job_queue ORDER BY priority DESC, created_at ASC`;
|
||||||
|
|
||||||
|
return this.executeSQL(sql, status ? [status] : []);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateJobStatus(jobId: string, status: string, result: any = null): Promise<any> {
|
||||||
|
return this.executeSQL(
|
||||||
|
'UPDATE job_queue SET status = $1, result = $2, updated_at = NOW() WHERE id = $3 RETURNING *',
|
||||||
|
[status, result ? JSON.stringify(result) : null, jobId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearCompletedJobs(): Promise<any> {
|
||||||
|
return this.executeSQL(
|
||||||
|
"DELETE FROM job_queue WHERE status IN ('completed', 'failed') AND updated_at < NOW() - INTERVAL '7 days'"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Batch Operations ===
|
||||||
|
async batch(operations: Array<{ endpoint: string; method?: string; body?: any }>): Promise<any[]> {
|
||||||
|
const results: any[] = [];
|
||||||
|
for (const op of operations) {
|
||||||
|
try {
|
||||||
|
const result = await this.request(op.endpoint, {
|
||||||
|
method: op.method || 'GET',
|
||||||
|
body: op.body ? JSON.stringify(op.body) : undefined
|
||||||
|
});
|
||||||
|
results.push({ success: true, result });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const err = error as Error;
|
||||||
|
results.push({ success: false, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create singleton instance
|
||||||
|
export const godMode = new GodModeClient();
|
||||||
|
|
||||||
|
// Export class for custom instances
|
||||||
|
export default GodModeClient;
|
||||||
38
god-mode/src/lib/intelligence/types.ts
Normal file
38
god-mode/src/lib/intelligence/types.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
export interface Pattern {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: 'structure' | 'semantic' | 'conversion';
|
||||||
|
confidence: number;
|
||||||
|
occurrences: number;
|
||||||
|
last_detected: string;
|
||||||
|
tags: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GeoCluster {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
location: string;
|
||||||
|
audience_size: number;
|
||||||
|
engagement_rate: number;
|
||||||
|
dominant_topic: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AvatarMetric {
|
||||||
|
id: string;
|
||||||
|
avatar_id: string;
|
||||||
|
name: string;
|
||||||
|
articles_generated: number;
|
||||||
|
avg_engagement: number;
|
||||||
|
top_niche: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IntelligenceState {
|
||||||
|
patterns: Pattern[];
|
||||||
|
geoClusters: GeoCluster[];
|
||||||
|
avatarMetrics: AvatarMetric[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
fetchPatterns: () => Promise<void>;
|
||||||
|
fetchGeoClusters: () => Promise<void>;
|
||||||
|
fetchAvatarMetrics: () => Promise<void>;
|
||||||
|
}
|
||||||
44
god-mode/src/lib/queue/config.ts
Normal file
44
god-mode/src/lib/queue/config.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* BullMQ Configuration
|
||||||
|
* Job queue setup for content generation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Queue, Worker, QueueOptions } from 'bullmq';
|
||||||
|
import IORedis from 'ioredis';
|
||||||
|
|
||||||
|
// Redis connection
|
||||||
|
const connection = new IORedis({
|
||||||
|
host: process.env.REDIS_HOST || 'localhost',
|
||||||
|
port: parseInt(process.env.REDIS_PORT || '6379'),
|
||||||
|
maxRetriesPerRequest: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Queue options
|
||||||
|
const queueOptions: QueueOptions = {
|
||||||
|
connection,
|
||||||
|
defaultJobOptions: {
|
||||||
|
attempts: 3,
|
||||||
|
backoff: {
|
||||||
|
type: 'exponential',
|
||||||
|
delay: 2000,
|
||||||
|
},
|
||||||
|
removeOnComplete: {
|
||||||
|
count: 100,
|
||||||
|
age: 3600,
|
||||||
|
},
|
||||||
|
removeOnFail: {
|
||||||
|
count: 1000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Define queues
|
||||||
|
export const queues = {
|
||||||
|
generation: new Queue('generation', queueOptions),
|
||||||
|
publishing: new Queue('publishing', queueOptions),
|
||||||
|
svgImages: new Queue('svg-images', queueOptions),
|
||||||
|
wpSync: new Queue('wp-sync', queueOptions),
|
||||||
|
cleanup: new Queue('cleanup', queueOptions),
|
||||||
|
};
|
||||||
|
|
||||||
|
export { connection };
|
||||||
10
god-mode/src/lib/react-query.ts
Normal file
10
god-mode/src/lib/react-query.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { QueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
export const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
404
god-mode/src/lib/schemas.ts
Normal file
404
god-mode/src/lib/schemas.ts
Normal file
@@ -0,0 +1,404 @@
|
|||||||
|
/**
|
||||||
|
* Spark Platform - Directus Schema Types
|
||||||
|
* Auto-generated from Golden Schema
|
||||||
|
*
|
||||||
|
* This provides full TypeScript coverage for all Directus collections
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// BATCH 1: FOUNDATION TABLES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface Sites {
|
||||||
|
id: string;
|
||||||
|
status: 'active' | 'inactive' | 'archived';
|
||||||
|
name: string;
|
||||||
|
url?: string;
|
||||||
|
date_created?: string;
|
||||||
|
date_updated?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CampaignMasters {
|
||||||
|
id: string;
|
||||||
|
status: 'active' | 'inactive' | 'completed';
|
||||||
|
site_id: string | Sites;
|
||||||
|
name: string;
|
||||||
|
headline_spintax_root?: string;
|
||||||
|
target_word_count?: number;
|
||||||
|
location_mode?: string;
|
||||||
|
batch_count?: number;
|
||||||
|
date_created?: string;
|
||||||
|
date_updated?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AvatarIntelligence {
|
||||||
|
id: string;
|
||||||
|
status: 'published' | 'draft';
|
||||||
|
base_name?: string; // Corrected from name
|
||||||
|
wealth_cluster?: string;
|
||||||
|
business_niches?: Record<string, any>;
|
||||||
|
pain_points?: Record<string, any>;
|
||||||
|
demographics?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AvatarVariants {
|
||||||
|
id: string;
|
||||||
|
status: 'published' | 'draft';
|
||||||
|
name?: string;
|
||||||
|
prompt_modifier?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CartesianPatterns {
|
||||||
|
id: string;
|
||||||
|
status: 'published' | 'draft';
|
||||||
|
name?: string;
|
||||||
|
pattern_logic?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GeoIntelligence {
|
||||||
|
id: string;
|
||||||
|
status: 'published' | 'draft';
|
||||||
|
city?: string;
|
||||||
|
state?: string;
|
||||||
|
population?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OfferBlocks {
|
||||||
|
id: string;
|
||||||
|
status: 'published' | 'draft';
|
||||||
|
name?: string;
|
||||||
|
html_content?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// BATCH 2: FIRST-LEVEL CHILDREN
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface GeneratedArticles {
|
||||||
|
id: string;
|
||||||
|
status: 'draft' | 'published' | 'archived';
|
||||||
|
site_id: string | Sites;
|
||||||
|
campaign_id?: string | CampaignMasters;
|
||||||
|
title?: string;
|
||||||
|
content?: string;
|
||||||
|
slug?: string;
|
||||||
|
is_published?: boolean;
|
||||||
|
schema_json?: Record<string, any>;
|
||||||
|
date_created?: string;
|
||||||
|
date_updated?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenerationJobs {
|
||||||
|
id: string;
|
||||||
|
status: 'pending' | 'processing' | 'completed' | 'failed';
|
||||||
|
site_id: string | Sites;
|
||||||
|
batch_size?: number;
|
||||||
|
target_quantity?: number;
|
||||||
|
filters?: Record<string, any>;
|
||||||
|
current_offset?: number;
|
||||||
|
progress?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Pages {
|
||||||
|
id: string;
|
||||||
|
status: 'published' | 'draft';
|
||||||
|
site_id: string | Sites;
|
||||||
|
title?: string;
|
||||||
|
slug?: string;
|
||||||
|
permalink?: string;
|
||||||
|
content?: string;
|
||||||
|
blocks?: Record<string, any>;
|
||||||
|
schema_json?: Record<string, any>;
|
||||||
|
seo_title?: string;
|
||||||
|
seo_description?: string;
|
||||||
|
seo_image?: string | DirectusFiles;
|
||||||
|
date_created?: string;
|
||||||
|
date_updated?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Posts {
|
||||||
|
id: string;
|
||||||
|
status: 'published' | 'draft';
|
||||||
|
site_id: string | Sites;
|
||||||
|
title?: string;
|
||||||
|
slug?: string;
|
||||||
|
excerpt?: string;
|
||||||
|
content?: string;
|
||||||
|
featured_image?: string | DirectusFiles;
|
||||||
|
published_at?: string;
|
||||||
|
category?: string;
|
||||||
|
author?: string | DirectusUsers;
|
||||||
|
schema_json?: Record<string, any>;
|
||||||
|
date_created?: string;
|
||||||
|
date_updated?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Leads {
|
||||||
|
id: string;
|
||||||
|
status: 'new' | 'contacted' | 'qualified' | 'converted';
|
||||||
|
site_id?: string | Sites;
|
||||||
|
email?: string;
|
||||||
|
name?: string;
|
||||||
|
source?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HeadlineInventory {
|
||||||
|
id: string;
|
||||||
|
status: 'active' | 'used' | 'archived';
|
||||||
|
campaign_id: string | CampaignMasters;
|
||||||
|
headline_text?: string;
|
||||||
|
is_used?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContentFragments {
|
||||||
|
id: string;
|
||||||
|
status: 'active' | 'archived';
|
||||||
|
campaign_id: string | CampaignMasters;
|
||||||
|
fragment_text?: string;
|
||||||
|
fragment_type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// BATCH 3: COMPLEX CHILDREN
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface LinkTargets {
|
||||||
|
id: string;
|
||||||
|
status: 'active' | 'inactive';
|
||||||
|
site_id: string | Sites;
|
||||||
|
target_url?: string;
|
||||||
|
anchor_text?: string;
|
||||||
|
keyword_focus?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Globals {
|
||||||
|
id: string;
|
||||||
|
site_id: string | Sites;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
logo?: string | DirectusFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Navigation {
|
||||||
|
id: string;
|
||||||
|
site_id: string | Sites;
|
||||||
|
label: string;
|
||||||
|
url: string;
|
||||||
|
parent?: string | Navigation;
|
||||||
|
target?: '_blank' | '_self';
|
||||||
|
sort?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// DIRECTUS SYSTEM COLLECTIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface DirectusUsers {
|
||||||
|
id: string;
|
||||||
|
first_name?: string;
|
||||||
|
last_name?: string;
|
||||||
|
email: string;
|
||||||
|
password?: string;
|
||||||
|
location?: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
tags?: string[];
|
||||||
|
avatar?: string;
|
||||||
|
language?: string;
|
||||||
|
theme?: 'auto' | 'light' | 'dark';
|
||||||
|
tfa_secret?: string;
|
||||||
|
status: 'active' | 'invited' | 'draft' | 'suspended' | 'archived';
|
||||||
|
role: string;
|
||||||
|
token?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DirectusFiles {
|
||||||
|
id: string;
|
||||||
|
storage: string;
|
||||||
|
filename_disk?: string;
|
||||||
|
filename_download: string;
|
||||||
|
title?: string;
|
||||||
|
type?: string;
|
||||||
|
folder?: string;
|
||||||
|
uploaded_by?: string | DirectusUsers;
|
||||||
|
uploaded_on?: string;
|
||||||
|
modified_by?: string | DirectusUsers;
|
||||||
|
modified_on?: string;
|
||||||
|
charset?: string;
|
||||||
|
filesize?: number;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
duration?: number;
|
||||||
|
embed?: string;
|
||||||
|
description?: string;
|
||||||
|
location?: string;
|
||||||
|
tags?: string[];
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DirectusActivity {
|
||||||
|
id: number;
|
||||||
|
action: string;
|
||||||
|
user?: string | DirectusUsers;
|
||||||
|
timestamp: string;
|
||||||
|
ip?: string;
|
||||||
|
user_agent?: string;
|
||||||
|
collection: string;
|
||||||
|
item: string;
|
||||||
|
comment?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// MAIN SCHEMA TYPE
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface DirectusSchema {
|
||||||
|
// Batch 1: Foundation
|
||||||
|
sites: Sites[];
|
||||||
|
campaign_masters: CampaignMasters[];
|
||||||
|
avatar_intelligence: AvatarIntelligence[];
|
||||||
|
avatar_variants: AvatarVariants[];
|
||||||
|
cartesian_patterns: CartesianPatterns[];
|
||||||
|
geo_intelligence: GeoIntelligence[];
|
||||||
|
offer_blocks: OfferBlocks[];
|
||||||
|
|
||||||
|
// Batch 2: Children
|
||||||
|
generated_articles: GeneratedArticles[];
|
||||||
|
generation_jobs: GenerationJobs[];
|
||||||
|
pages: Pages[];
|
||||||
|
posts: Posts[];
|
||||||
|
leads: Leads[];
|
||||||
|
headline_inventory: HeadlineInventory[];
|
||||||
|
content_fragments: ContentFragments[];
|
||||||
|
|
||||||
|
// Batch 3: Complex
|
||||||
|
link_targets: LinkTargets[];
|
||||||
|
globals: Globals[];
|
||||||
|
navigation: Navigation[];
|
||||||
|
|
||||||
|
// System & Analytics
|
||||||
|
work_log: WorkLog[];
|
||||||
|
hub_pages: HubPages[];
|
||||||
|
forms: Forms[];
|
||||||
|
form_submissions: FormSubmissions[];
|
||||||
|
site_analytics: SiteAnalytics[];
|
||||||
|
events: AnalyticsEvents[];
|
||||||
|
pageviews: PageViews[];
|
||||||
|
conversions: Conversions[];
|
||||||
|
locations_states: LocationsStates[];
|
||||||
|
locations_counties: LocationsCounties[];
|
||||||
|
locations_cities: LocationsCities[];
|
||||||
|
|
||||||
|
// Directus System
|
||||||
|
directus_users: DirectusUsers[];
|
||||||
|
directus_files: DirectusFiles[];
|
||||||
|
directus_activity: DirectusActivity[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SYSTEM & ANALYTICS TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface WorkLog {
|
||||||
|
id: number;
|
||||||
|
site_id?: string | Sites;
|
||||||
|
action: string;
|
||||||
|
entity_type?: string;
|
||||||
|
entity_id?: string;
|
||||||
|
details?: any;
|
||||||
|
level?: string;
|
||||||
|
status?: string;
|
||||||
|
timestamp?: string;
|
||||||
|
date_created?: string;
|
||||||
|
user?: string | DirectusUsers;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HubPages {
|
||||||
|
id: string;
|
||||||
|
site_id: string | Sites;
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
parent_hub?: string | HubPages;
|
||||||
|
level?: number;
|
||||||
|
articles_count?: number;
|
||||||
|
schema_json?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Forms {
|
||||||
|
id: string;
|
||||||
|
site_id: string | Sites;
|
||||||
|
name: string;
|
||||||
|
fields: any[];
|
||||||
|
submit_action?: string;
|
||||||
|
success_message?: string;
|
||||||
|
redirect_url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormSubmissions {
|
||||||
|
id: string;
|
||||||
|
form: string | Forms;
|
||||||
|
data: Record<string, any>;
|
||||||
|
date_created?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SiteAnalytics {
|
||||||
|
id: string;
|
||||||
|
site_id: string | Sites;
|
||||||
|
google_ads_id?: string;
|
||||||
|
fb_pixel_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsEvents {
|
||||||
|
id: string;
|
||||||
|
site_id: string | Sites;
|
||||||
|
event_name: string;
|
||||||
|
page_path: string;
|
||||||
|
timestamp?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PageViews {
|
||||||
|
id: string;
|
||||||
|
site_id: string | Sites;
|
||||||
|
page_path: string;
|
||||||
|
session_id?: string;
|
||||||
|
timestamp?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Conversions {
|
||||||
|
id: string;
|
||||||
|
site_id: string | Sites;
|
||||||
|
lead?: string | Leads;
|
||||||
|
conversion_type: string;
|
||||||
|
value?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocationsStates {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocationsCities {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
state: string | LocationsStates;
|
||||||
|
county?: string | LocationsCounties;
|
||||||
|
population?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocationsCounties {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
state: string | LocationsStates;
|
||||||
|
population?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// HELPER TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type Collections = keyof DirectusSchema;
|
||||||
|
|
||||||
|
export type Item<Collection extends Collections> = DirectusSchema[Collection];
|
||||||
|
|
||||||
|
export type QueryFilter<Collection extends Collections> = Partial<Item<Collection>>;
|
||||||
361
god-mode/src/lib/seo/cartesian.ts
Normal file
361
god-mode/src/lib/seo/cartesian.ts
Normal file
@@ -0,0 +1,361 @@
|
|||||||
|
/**
|
||||||
|
* Spark Platform - Cartesian Permutation Engine
|
||||||
|
*
|
||||||
|
* Implements true Cartesian Product logic for spintax explosion:
|
||||||
|
* - n^k formula for total combinations
|
||||||
|
* - Location × Spintax cross-product
|
||||||
|
* - Iterator-based generation for memory efficiency
|
||||||
|
*
|
||||||
|
* The Cartesian Product generates ALL possible combinations where:
|
||||||
|
* - Every element of Set A combines with every element of Set B, C, etc.
|
||||||
|
* - Order matters: (A,B) ≠ (B,A)
|
||||||
|
* - Formula: n₁ × n₂ × n₃ × ... × nₖ
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* Spintax: "{Best|Top} {Dentist|Clinic} in {city}"
|
||||||
|
* Cities: ["Austin", "Dallas"]
|
||||||
|
* Result: 2 × 2 × 2 = 8 unique headlines
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
SpintaxSlot,
|
||||||
|
CartesianConfig,
|
||||||
|
CartesianResult,
|
||||||
|
CartesianMetadata,
|
||||||
|
LocationEntry,
|
||||||
|
VariableMap,
|
||||||
|
DEFAULT_CARTESIAN_CONFIG
|
||||||
|
} from '@/types/cartesian';
|
||||||
|
|
||||||
|
// Re-export the default config
|
||||||
|
export { DEFAULT_CARTESIAN_CONFIG } from '@/types/cartesian';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract all spintax slots from a template string
|
||||||
|
* Handles nested spintax by processing innermost first
|
||||||
|
*
|
||||||
|
* @param text - The template string with {option1|option2} syntax
|
||||||
|
* @returns Array of SpintaxSlot objects
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* extractSpintaxSlots("{Best|Top} dentist")
|
||||||
|
* // Returns: [{ original: "{Best|Top}", options: ["Best", "Top"], position: 0, startIndex: 0, endIndex: 10 }]
|
||||||
|
*/
|
||||||
|
export function extractSpintaxSlots(text: string): SpintaxSlot[] {
|
||||||
|
const slots: SpintaxSlot[] = [];
|
||||||
|
// Match innermost braces only (no nested braces inside)
|
||||||
|
const pattern = /\{([^{}]+)\}/g;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
let position = 0;
|
||||||
|
|
||||||
|
while ((match = pattern.exec(text)) !== null) {
|
||||||
|
// Only treat as spintax if it contains pipe separator
|
||||||
|
if (match[1].includes('|')) {
|
||||||
|
slots.push({
|
||||||
|
original: match[0],
|
||||||
|
options: match[1].split('|').map(s => s.trim()),
|
||||||
|
position: position++,
|
||||||
|
startIndex: match.index,
|
||||||
|
endIndex: match.index + match[0].length
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return slots;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate total combinations using the n^k (Cartesian product) formula
|
||||||
|
*
|
||||||
|
* For k slots with n₁, n₂, ..., nₖ options respectively:
|
||||||
|
* Total = n₁ × n₂ × n₃ × ... × nₖ
|
||||||
|
*
|
||||||
|
* @param slots - Array of spintax slots
|
||||||
|
* @param locationCount - Number of locations to cross with (default 1)
|
||||||
|
* @returns Total number of possible combinations, capped at safe integer max
|
||||||
|
*/
|
||||||
|
export function calculateTotalCombinations(
|
||||||
|
slots: SpintaxSlot[],
|
||||||
|
locationCount: number = 1
|
||||||
|
): number {
|
||||||
|
if (slots.length === 0 && locationCount <= 1) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let total = Math.max(locationCount, 1);
|
||||||
|
|
||||||
|
for (const slot of slots) {
|
||||||
|
total *= slot.options.length;
|
||||||
|
// Safety check to prevent overflow
|
||||||
|
if (total > Number.MAX_SAFE_INTEGER) {
|
||||||
|
return Number.MAX_SAFE_INTEGER;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate all Cartesian product combinations from spintax slots
|
||||||
|
* Uses an iterative approach with index-based selection for memory efficiency
|
||||||
|
*
|
||||||
|
* The algorithm works like a "combination lock" or odometer:
|
||||||
|
* - Each slot is a dial with n options
|
||||||
|
* - We count through all n₁ × n₂ × ... × nₖ combinations
|
||||||
|
* - The index maps to specific choices via modular arithmetic
|
||||||
|
*
|
||||||
|
* @param template - Original template string
|
||||||
|
* @param slots - Extracted spintax slots
|
||||||
|
* @param config - Generation configuration
|
||||||
|
* @yields CartesianResult for each combination
|
||||||
|
*/
|
||||||
|
export function* generateCartesianProduct(
|
||||||
|
template: string,
|
||||||
|
slots: SpintaxSlot[],
|
||||||
|
config: Partial<CartesianConfig> = {}
|
||||||
|
): Generator<CartesianResult> {
|
||||||
|
const { maxCombinations = 10000, offset = 0 } = config;
|
||||||
|
|
||||||
|
if (slots.length === 0) {
|
||||||
|
yield {
|
||||||
|
text: template,
|
||||||
|
slotValues: {},
|
||||||
|
index: 0
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalCombinations = calculateTotalCombinations(slots);
|
||||||
|
const limit = Math.min(totalCombinations, maxCombinations);
|
||||||
|
const startIndex = Math.min(offset, totalCombinations);
|
||||||
|
|
||||||
|
// Pre-calculate divisors for index-to-options mapping
|
||||||
|
const divisors: number[] = [];
|
||||||
|
let divisor = 1;
|
||||||
|
for (let i = slots.length - 1; i >= 0; i--) {
|
||||||
|
divisors[i] = divisor;
|
||||||
|
divisor *= slots[i].options.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate combinations using index-based selection
|
||||||
|
for (let index = startIndex; index < Math.min(startIndex + limit, totalCombinations); index++) {
|
||||||
|
let result = template;
|
||||||
|
const slotValues: Record<string, string> = {};
|
||||||
|
|
||||||
|
// Map index to specific option choices (like reading an odometer)
|
||||||
|
for (let i = 0; i < slots.length; i++) {
|
||||||
|
const slot = slots[i];
|
||||||
|
const optionIndex = Math.floor(index / divisors[i]) % slot.options.length;
|
||||||
|
const chosenOption = slot.options[optionIndex];
|
||||||
|
|
||||||
|
slotValues[`slot_${i}`] = chosenOption;
|
||||||
|
result = result.replace(slot.original, chosenOption);
|
||||||
|
}
|
||||||
|
|
||||||
|
yield {
|
||||||
|
text: result,
|
||||||
|
slotValues,
|
||||||
|
index
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate full Cartesian product including location cross-product
|
||||||
|
*
|
||||||
|
* This creates the FULL cross-product:
|
||||||
|
* (Spintax combinations) × (Location variations)
|
||||||
|
*
|
||||||
|
* @param template - The spintax template
|
||||||
|
* @param locations - Array of location entries to cross with
|
||||||
|
* @param nicheVariables - Additional variables to inject
|
||||||
|
* @param config - Generation configuration
|
||||||
|
* @yields CartesianResult with location data
|
||||||
|
*/
|
||||||
|
export function* generateWithLocations(
|
||||||
|
template: string,
|
||||||
|
locations: LocationEntry[],
|
||||||
|
nicheVariables: VariableMap = {},
|
||||||
|
config: Partial<CartesianConfig> = {}
|
||||||
|
): Generator<CartesianResult> {
|
||||||
|
const { maxCombinations = 10000 } = config;
|
||||||
|
|
||||||
|
const slots = extractSpintaxSlots(template);
|
||||||
|
const spintaxCombinations = calculateTotalCombinations(slots);
|
||||||
|
const locationCount = Math.max(locations.length, 1);
|
||||||
|
const totalCombinations = spintaxCombinations * locationCount;
|
||||||
|
|
||||||
|
let generated = 0;
|
||||||
|
|
||||||
|
// If no locations, just generate spintax variations
|
||||||
|
if (locations.length === 0) {
|
||||||
|
for (const result of generateCartesianProduct(template, slots, config)) {
|
||||||
|
if (generated >= maxCombinations) return;
|
||||||
|
|
||||||
|
// Inject niche variables
|
||||||
|
const text = injectVariables(result.text, nicheVariables);
|
||||||
|
|
||||||
|
yield {
|
||||||
|
...result,
|
||||||
|
text,
|
||||||
|
index: generated++
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full cross-product: spintax × locations
|
||||||
|
for (const location of locations) {
|
||||||
|
// Build location variables
|
||||||
|
const locationVars: VariableMap = {
|
||||||
|
city: location.city || '',
|
||||||
|
county: location.county || '',
|
||||||
|
state: location.state,
|
||||||
|
state_code: location.stateCode,
|
||||||
|
population: String(location.population || '')
|
||||||
|
};
|
||||||
|
|
||||||
|
// Merge with niche variables
|
||||||
|
const allVariables = { ...nicheVariables, ...locationVars };
|
||||||
|
|
||||||
|
// Generate all spintax combinations for this location
|
||||||
|
for (const result of generateCartesianProduct(template, slots, { maxCombinations: Infinity })) {
|
||||||
|
if (generated >= maxCombinations) return;
|
||||||
|
|
||||||
|
// Inject all variables
|
||||||
|
const text = injectVariables(result.text, allVariables);
|
||||||
|
|
||||||
|
yield {
|
||||||
|
text,
|
||||||
|
slotValues: result.slotValues,
|
||||||
|
location: {
|
||||||
|
city: location.city,
|
||||||
|
county: location.county,
|
||||||
|
state: location.state,
|
||||||
|
stateCode: location.stateCode,
|
||||||
|
id: location.id
|
||||||
|
},
|
||||||
|
index: generated++
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inject variables into text, replacing {varName} placeholders
|
||||||
|
* Unlike spintax, variable placeholders don't contain pipe separators
|
||||||
|
*
|
||||||
|
* @param text - Text with {variable} placeholders
|
||||||
|
* @param variables - Map of variable names to values
|
||||||
|
* @returns Text with variables replaced
|
||||||
|
*/
|
||||||
|
export function injectVariables(text: string, variables: VariableMap): string {
|
||||||
|
let result = text;
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(variables)) {
|
||||||
|
// Match {key} but NOT {key|other} (that's spintax)
|
||||||
|
const pattern = new RegExp(`\\{${key}\\}`, 'gi');
|
||||||
|
result = result.replace(pattern, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse spintax and randomly select ONE variation (for content fragments)
|
||||||
|
* This is different from Cartesian explosion - it picks a single random path
|
||||||
|
*
|
||||||
|
* @param text - Text with spintax {option1|option2}
|
||||||
|
* @returns Single randomly selected variation
|
||||||
|
*/
|
||||||
|
export function parseSpintaxRandom(text: string): string {
|
||||||
|
const pattern = /\{([^{}]+)\}/g;
|
||||||
|
|
||||||
|
function processMatch(_match: string, group: string): string {
|
||||||
|
if (!group.includes('|')) {
|
||||||
|
return `{${group}}`; // Not spintax, preserve as variable placeholder
|
||||||
|
}
|
||||||
|
const options = group.split('|');
|
||||||
|
return options[Math.floor(Math.random() * options.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = text;
|
||||||
|
let previousResult = '';
|
||||||
|
|
||||||
|
// Process nested spintax (innermost first)
|
||||||
|
while (result !== previousResult) {
|
||||||
|
previousResult = result;
|
||||||
|
result = result.replace(pattern, processMatch);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explode spintax into ALL variations without locations
|
||||||
|
* Convenience function for simple use cases
|
||||||
|
*
|
||||||
|
* @param text - Spintax template
|
||||||
|
* @param maxCount - Maximum results
|
||||||
|
* @returns Array of all variations
|
||||||
|
*/
|
||||||
|
export function explodeSpintax(text: string, maxCount = 5000): string[] {
|
||||||
|
const slots = extractSpintaxSlots(text);
|
||||||
|
const results: string[] = [];
|
||||||
|
|
||||||
|
for (const result of generateCartesianProduct(text, slots, { maxCombinations: maxCount })) {
|
||||||
|
results.push(result.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get metadata about a Cartesian product without running generation
|
||||||
|
* Useful for UI to show "This will generate X combinations"
|
||||||
|
*
|
||||||
|
* @param template - Spintax template
|
||||||
|
* @param locationCount - Number of locations
|
||||||
|
* @param maxCombinations - Generation limit
|
||||||
|
* @returns Metadata object
|
||||||
|
*/
|
||||||
|
export function getCartesianMetadata(
|
||||||
|
template: string,
|
||||||
|
locationCount: number = 1,
|
||||||
|
maxCombinations: number = 10000
|
||||||
|
): CartesianMetadata {
|
||||||
|
const slots = extractSpintaxSlots(template);
|
||||||
|
const totalSpintaxCombinations = calculateTotalCombinations(slots);
|
||||||
|
const totalPossibleCombinations = totalSpintaxCombinations * Math.max(locationCount, 1);
|
||||||
|
const generatedCount = Math.min(totalPossibleCombinations, maxCombinations);
|
||||||
|
|
||||||
|
return {
|
||||||
|
template,
|
||||||
|
slotCount: slots.length,
|
||||||
|
totalSpintaxCombinations,
|
||||||
|
locationCount,
|
||||||
|
totalPossibleCombinations,
|
||||||
|
generatedCount,
|
||||||
|
wasTruncated: totalPossibleCombinations > maxCombinations
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect results from a generator into an array
|
||||||
|
* Helper for when you need all results at once
|
||||||
|
*/
|
||||||
|
export function collectResults(
|
||||||
|
generator: Generator<CartesianResult>,
|
||||||
|
limit?: number
|
||||||
|
): CartesianResult[] {
|
||||||
|
const results: CartesianResult[] = [];
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
for (const result of generator) {
|
||||||
|
results.push(result);
|
||||||
|
count++;
|
||||||
|
if (limit && count >= limit) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
176
god-mode/src/lib/seo/image-generator.ts
Normal file
176
god-mode/src/lib/seo/image-generator.ts
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
/**
|
||||||
|
* SVG Featured Image Generator
|
||||||
|
*
|
||||||
|
* Generates SEO-optimized featured images from templates.
|
||||||
|
* - Replaces {title}, {subtitle}, colors, fonts
|
||||||
|
* - Returns SVG string and base64 data URI
|
||||||
|
* - Generates SEO-friendly filenames from titles
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ImageGeneratorInput {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
template?: ImageTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageTemplate {
|
||||||
|
svg_source: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
background_gradient_start?: string;
|
||||||
|
background_gradient_end?: string;
|
||||||
|
text_color?: string;
|
||||||
|
font_family?: string;
|
||||||
|
title_font_size?: number;
|
||||||
|
subtitle_text?: string;
|
||||||
|
subtitle_font_size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GeneratedImage {
|
||||||
|
svg: string;
|
||||||
|
dataUri: string;
|
||||||
|
filename: string;
|
||||||
|
alt: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default professional template
|
||||||
|
const DEFAULT_TEMPLATE: ImageTemplate = {
|
||||||
|
svg_source: `<svg width="{width}" height="{height}" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:{gradient_start};stop-opacity:1" />
|
||||||
|
<stop offset="100%" style="stop-color:{gradient_end};stop-opacity:1" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="{width}" height="{height}" fill="url(#grad)"/>
|
||||||
|
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-family="{font_family}" font-weight="bold" font-size="{title_size}" fill="{text_color}">
|
||||||
|
{title}
|
||||||
|
</text>
|
||||||
|
<text x="50%" y="85%" text-anchor="middle" font-family="{font_family}" font-size="{subtitle_size}" fill="rgba(255,255,255,0.7)">
|
||||||
|
{subtitle}
|
||||||
|
</text>
|
||||||
|
</svg>`,
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
background_gradient_start: '#2563eb',
|
||||||
|
background_gradient_end: '#1d4ed8',
|
||||||
|
text_color: '#ffffff',
|
||||||
|
font_family: 'Arial, sans-serif',
|
||||||
|
title_font_size: 48,
|
||||||
|
subtitle_text: '',
|
||||||
|
subtitle_font_size: 18
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate SEO-friendly filename from title
|
||||||
|
* "Best Dentist in Austin, TX" -> "best-dentist-in-austin-tx.svg"
|
||||||
|
*/
|
||||||
|
export function generateFilename(title: string): string {
|
||||||
|
return title
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9\s-]/g, '') // Remove special chars
|
||||||
|
.replace(/\s+/g, '-') // Spaces to dashes
|
||||||
|
.replace(/-+/g, '-') // Multiple dashes to single
|
||||||
|
.substring(0, 60) // Limit length
|
||||||
|
+ '.svg';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap long titles to multiple lines if needed
|
||||||
|
*/
|
||||||
|
function wrapTitle(title: string, maxCharsPerLine: number = 40): string[] {
|
||||||
|
const words = title.split(' ');
|
||||||
|
const lines: string[] = [];
|
||||||
|
let currentLine = '';
|
||||||
|
|
||||||
|
for (const word of words) {
|
||||||
|
if ((currentLine + ' ' + word).trim().length <= maxCharsPerLine) {
|
||||||
|
currentLine = (currentLine + ' ' + word).trim();
|
||||||
|
} else {
|
||||||
|
if (currentLine) lines.push(currentLine);
|
||||||
|
currentLine = word;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (currentLine) lines.push(currentLine);
|
||||||
|
|
||||||
|
return lines.slice(0, 3); // Max 3 lines
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a featured image from a template
|
||||||
|
*/
|
||||||
|
export function generateFeaturedImage(input: ImageGeneratorInput): GeneratedImage {
|
||||||
|
const template = input.template || DEFAULT_TEMPLATE;
|
||||||
|
const width = template.width || 1200;
|
||||||
|
const height = template.height || 630;
|
||||||
|
|
||||||
|
// Process title for multi-line if needed
|
||||||
|
const titleLines = wrapTitle(input.title);
|
||||||
|
const isSingleLine = titleLines.length === 1;
|
||||||
|
|
||||||
|
// Build title text elements
|
||||||
|
let titleSvg: string;
|
||||||
|
if (isSingleLine) {
|
||||||
|
titleSvg = `<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-family="${template.font_family}" font-weight="bold" font-size="${template.title_font_size}" fill="${template.text_color}">${escapeXml(input.title)}</text>`;
|
||||||
|
} else {
|
||||||
|
const lineHeight = (template.title_font_size || 48) * 1.2;
|
||||||
|
const startY = (height / 2) - ((titleLines.length - 1) * lineHeight / 2);
|
||||||
|
titleSvg = titleLines.map((line, i) =>
|
||||||
|
`<text x="50%" y="${startY + (i * lineHeight)}" dominant-baseline="middle" text-anchor="middle" font-family="${template.font_family}" font-weight="bold" font-size="${template.title_font_size}" fill="${template.text_color}">${escapeXml(line)}</text>`
|
||||||
|
).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace template variables
|
||||||
|
let svg = template.svg_source
|
||||||
|
.replace(/{width}/g, String(width))
|
||||||
|
.replace(/{height}/g, String(height))
|
||||||
|
.replace(/{width-80}/g, String(width - 80))
|
||||||
|
.replace(/{height-80}/g, String(height - 80))
|
||||||
|
.replace(/{gradient_start}/g, template.background_gradient_start || '#2563eb')
|
||||||
|
.replace(/{gradient_end}/g, template.background_gradient_end || '#1d4ed8')
|
||||||
|
.replace(/{text_color}/g, template.text_color || '#ffffff')
|
||||||
|
.replace(/{accent_color}/g, template.background_gradient_start || '#2563eb')
|
||||||
|
.replace(/{font_family}/g, template.font_family || 'Arial, sans-serif')
|
||||||
|
.replace(/{title_size}/g, String(template.title_font_size || 48))
|
||||||
|
.replace(/{subtitle_size}/g, String(template.subtitle_font_size || 18))
|
||||||
|
.replace(/{title}/g, escapeXml(input.title))
|
||||||
|
.replace(/{subtitle}/g, escapeXml(input.subtitle || template.subtitle_text || ''));
|
||||||
|
|
||||||
|
// Generate base64 data URI for inline use
|
||||||
|
// Use TextEncoder for Node 18+ and browser compatibility
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const bytes = encoder.encode(svg);
|
||||||
|
const base64 = btoa(String.fromCharCode(...bytes));
|
||||||
|
const dataUri = `data:image/svg+xml;base64,${base64}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
svg,
|
||||||
|
dataUri,
|
||||||
|
filename: generateFilename(input.title),
|
||||||
|
alt: `${input.title} - Featured Image`,
|
||||||
|
width,
|
||||||
|
height
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate HTML img tag for the featured image
|
||||||
|
*/
|
||||||
|
export function generateImageTag(image: GeneratedImage, useSrcPath?: string): string {
|
||||||
|
const src = useSrcPath || image.dataUri;
|
||||||
|
return `<img src="${src}" alt="${escapeXml(image.alt)}" width="${image.width}" height="${image.height}" loading="lazy" />`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape XML special characters
|
||||||
|
*/
|
||||||
|
function escapeXml(str: string): string {
|
||||||
|
return str
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
195
god-mode/src/lib/seo/velocity-scheduler.ts
Normal file
195
god-mode/src/lib/seo/velocity-scheduler.ts
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
/**
|
||||||
|
* Gaussian Velocity Scheduler
|
||||||
|
*
|
||||||
|
* Distributes articles over a date range using natural velocity patterns
|
||||||
|
* to simulate organic content growth and avoid spam footprints.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type VelocityMode = 'RAMP_UP' | 'RANDOM_SPIKES' | 'STEADY';
|
||||||
|
|
||||||
|
export interface VelocityConfig {
|
||||||
|
mode: VelocityMode;
|
||||||
|
weekendThrottle: boolean;
|
||||||
|
jitterMinutes: number;
|
||||||
|
businessHoursOnly: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScheduleEntry {
|
||||||
|
publishDate: Date;
|
||||||
|
modifiedDate: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a natural schedule for article publication
|
||||||
|
*
|
||||||
|
* @param startDate - Earliest backdate
|
||||||
|
* @param endDate - Latest date (usually today)
|
||||||
|
* @param totalArticles - Number of articles to schedule
|
||||||
|
* @param config - Velocity configuration
|
||||||
|
* @returns Array of scheduled dates
|
||||||
|
*/
|
||||||
|
export function generateNaturalSchedule(
|
||||||
|
startDate: Date,
|
||||||
|
endDate: Date,
|
||||||
|
totalArticles: number,
|
||||||
|
config: VelocityConfig
|
||||||
|
): ScheduleEntry[] {
|
||||||
|
const now = new Date();
|
||||||
|
const totalDays = Math.floor((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
if (totalDays <= 0 || totalArticles <= 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build probability weights for each day
|
||||||
|
const dayWeights: { date: Date; weight: number }[] = [];
|
||||||
|
|
||||||
|
for (let dayOffset = 0; dayOffset < totalDays; dayOffset++) {
|
||||||
|
const currentDate = new Date(startDate);
|
||||||
|
currentDate.setDate(currentDate.getDate() + dayOffset);
|
||||||
|
|
||||||
|
const dayOfWeek = currentDate.getDay();
|
||||||
|
const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
|
||||||
|
|
||||||
|
let weight = 1.0;
|
||||||
|
|
||||||
|
// Apply velocity mode
|
||||||
|
switch (config.mode) {
|
||||||
|
case 'RAMP_UP':
|
||||||
|
// Weight grows from 0.2 (20% volume) to 1.0 (100% volume)
|
||||||
|
const progress = dayOffset / totalDays;
|
||||||
|
weight = 0.2 + (0.8 * progress);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'RANDOM_SPIKES':
|
||||||
|
// 5% chance of a content sprint (3x volume)
|
||||||
|
if (Math.random() < 0.05) {
|
||||||
|
weight = 3.0;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'STEADY':
|
||||||
|
default:
|
||||||
|
weight = 1.0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add human noise (±15% randomness)
|
||||||
|
weight *= 0.85 + (Math.random() * 0.30);
|
||||||
|
|
||||||
|
// Weekend throttle (reduce by 80%)
|
||||||
|
if (config.weekendThrottle && isWeekend) {
|
||||||
|
weight *= 0.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
dayWeights.push({ date: currentDate, weight });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize and distribute articles
|
||||||
|
const totalWeight = dayWeights.reduce((sum, d) => sum + d.weight, 0);
|
||||||
|
const scheduleQueue: ScheduleEntry[] = [];
|
||||||
|
|
||||||
|
for (const dayEntry of dayWeights) {
|
||||||
|
// Calculate how many articles for this day
|
||||||
|
const rawCount = (dayEntry.weight / totalWeight) * totalArticles;
|
||||||
|
|
||||||
|
// Probabilistic rounding
|
||||||
|
let count = Math.floor(rawCount);
|
||||||
|
if (Math.random() < (rawCount - count)) {
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate timestamps with jitter
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
let hour: number;
|
||||||
|
|
||||||
|
if (config.businessHoursOnly) {
|
||||||
|
// Gaussian centered at 2 PM, clamped to 9-18
|
||||||
|
hour = Math.round(gaussianRandom(14, 2));
|
||||||
|
hour = Math.max(9, Math.min(18, hour));
|
||||||
|
} else {
|
||||||
|
// Any hour with slight bias toward afternoon
|
||||||
|
hour = Math.round(gaussianRandom(14, 4));
|
||||||
|
hour = Math.max(0, Math.min(23, hour));
|
||||||
|
}
|
||||||
|
|
||||||
|
const minute = Math.floor(Math.random() * 60);
|
||||||
|
|
||||||
|
// Apply jitter to the base hour
|
||||||
|
const jitterOffset = Math.floor((Math.random() - 0.5) * 2 * config.jitterMinutes);
|
||||||
|
|
||||||
|
const publishDate = new Date(dayEntry.date);
|
||||||
|
publishDate.setHours(hour, minute, 0, 0);
|
||||||
|
publishDate.setMinutes(publishDate.getMinutes() + jitterOffset);
|
||||||
|
|
||||||
|
// SEO TRICK: If older than 6 months, set modified date to today
|
||||||
|
const sixMonthsAgo = new Date(now);
|
||||||
|
sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6);
|
||||||
|
|
||||||
|
const modifiedDate = publishDate < sixMonthsAgo
|
||||||
|
? randomDateWithin7Days(now) // Set to recent date for freshness signal
|
||||||
|
: new Date(publishDate);
|
||||||
|
|
||||||
|
scheduleQueue.push({ publishDate, modifiedDate });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort chronologically
|
||||||
|
scheduleQueue.sort((a, b) => a.publishDate.getTime() - b.publishDate.getTime());
|
||||||
|
|
||||||
|
return scheduleQueue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a Gaussian random number
|
||||||
|
* Uses Box-Muller transform
|
||||||
|
*/
|
||||||
|
function gaussianRandom(mean: number, stdDev: number): number {
|
||||||
|
let u = 0, v = 0;
|
||||||
|
while (u === 0) u = Math.random();
|
||||||
|
while (v === 0) v = Math.random();
|
||||||
|
const z = Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v);
|
||||||
|
return z * stdDev + mean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a random date within 7 days of target
|
||||||
|
*/
|
||||||
|
function randomDateWithin7Days(target: Date): Date {
|
||||||
|
const offset = Math.floor(Math.random() * 7);
|
||||||
|
const result = new Date(target);
|
||||||
|
result.setDate(result.getDate() - offset);
|
||||||
|
result.setHours(
|
||||||
|
Math.floor(Math.random() * 10) + 9, // 9 AM - 7 PM
|
||||||
|
Math.floor(Math.random() * 60),
|
||||||
|
0, 0
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate max backdate based on domain age
|
||||||
|
*
|
||||||
|
* @param domainAgeYears - How old the domain is
|
||||||
|
* @returns Earliest date that's safe to backdate to
|
||||||
|
*/
|
||||||
|
export function getMaxBackdateStart(domainAgeYears: number): Date {
|
||||||
|
const now = new Date();
|
||||||
|
// Can only backdate to when domain existed, minus a small buffer
|
||||||
|
const maxYears = Math.max(0, domainAgeYears - 0.25); // 3 month buffer
|
||||||
|
const result = new Date(now);
|
||||||
|
result.setFullYear(result.getFullYear() - maxYears);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a context-aware year token replacer
|
||||||
|
* Replaces {Current_Year} and {Next_Year} based on publish date
|
||||||
|
*/
|
||||||
|
export function replaceYearTokens(content: string, publishDate: Date): string {
|
||||||
|
const year = publishDate.getFullYear();
|
||||||
|
return content
|
||||||
|
.replace(/\{Current_Year\}/g, year.toString())
|
||||||
|
.replace(/\{Next_Year\}/g, (year + 1).toString())
|
||||||
|
.replace(/\{Last_Year\}/g, (year - 1).toString());
|
||||||
|
}
|
||||||
95
god-mode/src/lib/testing/seo.ts
Normal file
95
god-mode/src/lib/testing/seo.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
|
||||||
|
/**
|
||||||
|
* SEO Analysis Engine
|
||||||
|
* Checks content against common SEO best practices.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface SeoResult {
|
||||||
|
score: number;
|
||||||
|
issues: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function analyzeSeo(content: string, keyword: string): SeoResult {
|
||||||
|
const issues: string[] = [];
|
||||||
|
let score = 100;
|
||||||
|
|
||||||
|
if (!content) return { score: 0, issues: ['No content provided'] };
|
||||||
|
|
||||||
|
const lowerContent = content.toLowerCase();
|
||||||
|
const lowerKeyword = keyword.toLowerCase();
|
||||||
|
|
||||||
|
// 1. Keyword Presence
|
||||||
|
if (keyword && !lowerContent.includes(lowerKeyword)) {
|
||||||
|
score -= 20;
|
||||||
|
issues.push(`Primary keyword "${keyword}" is missing from content.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Keyword Density (Simple)
|
||||||
|
if (keyword) {
|
||||||
|
const matches = lowerContent.match(new RegExp(lowerKeyword, 'g'));
|
||||||
|
const count = matches ? matches.length : 0;
|
||||||
|
const words = content.split(/\s+/).length;
|
||||||
|
const density = (count / words) * 100;
|
||||||
|
|
||||||
|
if (density > 3) {
|
||||||
|
score -= 10;
|
||||||
|
issues.push(`Keyword density is too high (${density.toFixed(1)}%). Aim for < 3%.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Word Count
|
||||||
|
const wordCount = content.split(/\s+/).length;
|
||||||
|
if (wordCount < 300) {
|
||||||
|
score -= 15;
|
||||||
|
issues.push(`Content is too short (${wordCount} words). Recommended minimum is 300.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Heading Structure (Basic Check for H1/H2)
|
||||||
|
// Note: If content is just body text, this might not apply suitable unless full HTML
|
||||||
|
if (content.includes('<h1>') && (content.match(/<h1>/g) || []).length > 1) {
|
||||||
|
score -= 10;
|
||||||
|
issues.push('Multiple H1 tags detected. Use only one H1 per page.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { score: Math.max(0, score), issues };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Readability Analysis Engine
|
||||||
|
* Uses Flesch-Kincaid Grade Level
|
||||||
|
*/
|
||||||
|
export function analyzeReadability(content: string): { gradeLevel: number; score: number; feedback: string } {
|
||||||
|
// Basic heuristics
|
||||||
|
const sentences = content.split(/[.!?]+/).length;
|
||||||
|
const words = content.split(/\s+/).length;
|
||||||
|
const syllables = countSyllables(content);
|
||||||
|
|
||||||
|
// Flesch-Kincaid Grade Level Formula
|
||||||
|
// 0.39 * (words/sentences) + 11.8 * (syllables/words) - 15.59
|
||||||
|
const avgWordsPerSentence = words / Math.max(1, sentences);
|
||||||
|
const avgSyllablesPerWord = syllables / Math.max(1, words);
|
||||||
|
|
||||||
|
const gradeLevel = (0.39 * avgWordsPerSentence) + (11.8 * avgSyllablesPerWord) - 15.59;
|
||||||
|
|
||||||
|
let feedback = "Easy to read";
|
||||||
|
if (gradeLevel > 12) feedback = "Difficult (University level)";
|
||||||
|
else if (gradeLevel > 8) feedback = "Average (High School level)";
|
||||||
|
|
||||||
|
// Normalized 0-100 score (lower grade level = higher score usually for SEO)
|
||||||
|
const score = Math.max(0, Math.min(100, 100 - (gradeLevel * 5)));
|
||||||
|
|
||||||
|
return {
|
||||||
|
gradeLevel: parseFloat(gradeLevel.toFixed(1)),
|
||||||
|
score: Math.round(score),
|
||||||
|
feedback
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple syllable counter approximation
|
||||||
|
function countSyllables(text: string): number {
|
||||||
|
return text.toLowerCase()
|
||||||
|
.replace(/[^a-z]/g, '')
|
||||||
|
.replace(/e$/g, '') // silent e
|
||||||
|
.replace(/[aeiouy]{1,2}/g, 'x') // vowel groups
|
||||||
|
.split('x').length - 1 || 1;
|
||||||
|
}
|
||||||
138
god-mode/src/lib/theme/config.ts
Normal file
138
god-mode/src/lib/theme/config.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
/**
|
||||||
|
* Spark Pro Design System
|
||||||
|
* Theme Configuration & Guidelines
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const sparkTheme = {
|
||||||
|
// === THE SYSTEM ===
|
||||||
|
name: 'Titanium Pro',
|
||||||
|
description: 'Luxury Industrial - Matte Black with Gold Accents',
|
||||||
|
|
||||||
|
// === COLOR RULES ===
|
||||||
|
rules: {
|
||||||
|
surfaces: {
|
||||||
|
void: 'bg-void', // Pure black background
|
||||||
|
titanium: 'bg-titanium', // Main panels (with border)
|
||||||
|
graphite: 'bg-graphite', // Inputs/secondary cards
|
||||||
|
jet: 'bg-jet', // Popups/modals
|
||||||
|
},
|
||||||
|
|
||||||
|
borders: {
|
||||||
|
standard: 'border border-edge-normal', // All containers
|
||||||
|
subtle: 'border border-edge-subtle', // Dividers
|
||||||
|
active: 'border border-edge-bright', // Hover/focus
|
||||||
|
selected: 'border border-edge-gold', // Selected state
|
||||||
|
},
|
||||||
|
|
||||||
|
text: {
|
||||||
|
primary: 'text-white', // Headlines, important data
|
||||||
|
secondary: 'text-silver', // Body text (darkest allowed)
|
||||||
|
data: 'text-gold-300', // Numbers, metrics
|
||||||
|
dimmed: 'text-white/60', // Less important
|
||||||
|
monospace: 'font-mono text-gold-300', // All data/numbers
|
||||||
|
},
|
||||||
|
|
||||||
|
shadows: {
|
||||||
|
card: 'shadow-hard', // Block shadow for depth
|
||||||
|
glow: 'shadow-glow-gold', // Glowing accent
|
||||||
|
none: '', // Flat elements
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// === COMPONENT PATTERNS ===
|
||||||
|
components: {
|
||||||
|
card: 'bg-titanium border border-edge-normal shadow-hard rounded-lg',
|
||||||
|
cardHover: 'hover:border-edge-gold transition-colors',
|
||||||
|
|
||||||
|
button: {
|
||||||
|
primary: 'bg-gold-gradient text-black font-semibold border-t border-white/40 shadow-glow-gold',
|
||||||
|
secondary: 'bg-titanium border border-edge-normal hover:border-edge-bright',
|
||||||
|
ghost: 'hover:bg-graphite',
|
||||||
|
},
|
||||||
|
|
||||||
|
input: 'bg-graphite border border-edge-subtle text-white placeholder:text-silver/50',
|
||||||
|
|
||||||
|
table: {
|
||||||
|
header: 'border-b border-edge-normal bg-titanium',
|
||||||
|
row: 'border-b border-edge-subtle hover:bg-graphite/50',
|
||||||
|
cell: 'border-r border-edge-subtle/50',
|
||||||
|
},
|
||||||
|
|
||||||
|
status: {
|
||||||
|
active: 'bg-void border border-edge-gold text-gold-300',
|
||||||
|
processing: 'bg-void border border-electric-400 text-electric-400 animate-pulse',
|
||||||
|
complete: 'bg-void border border-green-500 text-green-400',
|
||||||
|
error: 'bg-void border border-red-500 text-red-400',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// === TYPOGRAPHY SYSTEM ===
|
||||||
|
typography: {
|
||||||
|
heading: 'font-sans tracking-tight text-white',
|
||||||
|
body: 'font-sans text-silver',
|
||||||
|
data: 'font-mono tracking-wider text-gold-300',
|
||||||
|
label: 'text-silver uppercase text-[10px] tracking-[0.2em]',
|
||||||
|
},
|
||||||
|
|
||||||
|
// === THE "NO-BLEND" CHECKLIST ===
|
||||||
|
checklist: [
|
||||||
|
'✅ Every container has a 1px border',
|
||||||
|
'✅ Never put dark on dark without border',
|
||||||
|
'✅ Use staircase: void → titanium → graphite → jet',
|
||||||
|
'✅ All data is monospace gold',
|
||||||
|
'✅ Text minimum is silver (#D1D5DB)',
|
||||||
|
'✅ Active states use gold borders',
|
||||||
|
'✅ Shadows are hard, not fuzzy',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// === ALTERNATIVE THEMES (Future) ===
|
||||||
|
export const alternativeThemes = {
|
||||||
|
'deep-ocean': {
|
||||||
|
name: 'Deep Ocean',
|
||||||
|
void: '#001219',
|
||||||
|
titanium: '#0A1929',
|
||||||
|
gold: '#00B4D8',
|
||||||
|
description: 'Navy blue with cyan accents',
|
||||||
|
},
|
||||||
|
|
||||||
|
'forest-command': {
|
||||||
|
name: 'Forest Command',
|
||||||
|
void: '#0D1B0C',
|
||||||
|
titanium: '#1A2E1A',
|
||||||
|
gold: '#4ADE80',
|
||||||
|
description: 'Dark green with emerald accents',
|
||||||
|
},
|
||||||
|
|
||||||
|
'crimson-steel': {
|
||||||
|
name: 'Crimson Steel',
|
||||||
|
void: '#0F0000',
|
||||||
|
titanium: '#1F0A0A',
|
||||||
|
gold: '#DC2626',
|
||||||
|
description: 'Dark red with crimson accents',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// === USAGE EXAMPLES ===
|
||||||
|
export const examples = {
|
||||||
|
dashboard: {
|
||||||
|
container: 'min-h-screen bg-void p-6',
|
||||||
|
panel: 'bg-titanium border border-edge-normal rounded-lg p-6 shadow-hard',
|
||||||
|
statCard: 'bg-titanium border border-edge-normal rounded-lg p-6 hover:border-edge-gold transition-colors',
|
||||||
|
number: 'text-4xl font-mono text-gold-300 tracking-wider',
|
||||||
|
},
|
||||||
|
|
||||||
|
factory: {
|
||||||
|
kanbanLane: 'bg-void/50 border-r border-edge-subtle',
|
||||||
|
card: 'bg-titanium border border-edge-normal rounded-lg p-4 shadow-hard hover:border-edge-gold cursor-pointer',
|
||||||
|
cardActive: 'border-edge-gold shadow-hard-gold',
|
||||||
|
},
|
||||||
|
|
||||||
|
form: {
|
||||||
|
label: 'text-silver uppercase text-[10px] tracking-[0.2em] mb-2',
|
||||||
|
input: 'bg-graphite border border-edge-subtle text-white px-4 py-2 rounded focus:border-edge-gold',
|
||||||
|
button: 'bg-gold-gradient text-black font-semibold px-6 py-3 rounded border-t border-white/40 shadow-glow-gold',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default sparkTheme;
|
||||||
7
god-mode/src/lib/utils.ts
Normal file
7
god-mode/src/lib/utils.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
|
||||||
|
import { type ClassValue, clsx } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
103
god-mode/src/lib/utils/circuit-breaker.ts
Normal file
103
god-mode/src/lib/utils/circuit-breaker.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* Circuit Breaker
|
||||||
|
* Prevents cascading failures for external services
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface CircuitBreakerOptions {
|
||||||
|
failureThreshold: number;
|
||||||
|
resetTimeout: number;
|
||||||
|
monitoringPeriod: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CircuitBreaker {
|
||||||
|
private failures = 0;
|
||||||
|
private lastFailureTime: number | null = null;
|
||||||
|
private state: 'CLOSED' | 'OPEN' | 'HALF_OPEN' = 'CLOSED';
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private name: string,
|
||||||
|
private options: CircuitBreakerOptions = {
|
||||||
|
failureThreshold: 5,
|
||||||
|
resetTimeout: 60000, // 1 minute
|
||||||
|
monitoringPeriod: 10000, // 10 seconds
|
||||||
|
}
|
||||||
|
) { }
|
||||||
|
|
||||||
|
async execute<T>(operation: () => Promise<T>, fallback?: () => Promise<T>): Promise<T> {
|
||||||
|
// Check if circuit is open
|
||||||
|
if (this.state === 'OPEN') {
|
||||||
|
const timeSinceLastFailure = Date.now() - (this.lastFailureTime || 0);
|
||||||
|
|
||||||
|
if (timeSinceLastFailure > this.options.resetTimeout) {
|
||||||
|
this.state = 'HALF_OPEN';
|
||||||
|
this.failures = 0;
|
||||||
|
} else {
|
||||||
|
console.warn(`[CircuitBreaker:${this.name}] Circuit is OPEN, using fallback`);
|
||||||
|
if (fallback) {
|
||||||
|
return fallback();
|
||||||
|
}
|
||||||
|
throw new Error(`Circuit breaker open for ${this.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await operation();
|
||||||
|
|
||||||
|
// Success - reset if in half-open state
|
||||||
|
if (this.state === 'HALF_OPEN') {
|
||||||
|
this.state = 'CLOSED';
|
||||||
|
this.failures = 0;
|
||||||
|
console.log(`[CircuitBreaker:${this.name}] Circuit closed after recovery`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
this.failures++;
|
||||||
|
this.lastFailureTime = Date.now();
|
||||||
|
|
||||||
|
console.error(`[CircuitBreaker:${this.name}] Failure ${this.failures}/${this.options.failureThreshold}`);
|
||||||
|
|
||||||
|
// Open circuit if threshold reached
|
||||||
|
if (this.failures >= this.options.failureThreshold) {
|
||||||
|
this.state = 'OPEN';
|
||||||
|
console.error(`[CircuitBreaker:${this.name}] Circuit OPENED due to failures`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use fallback if available
|
||||||
|
if (fallback) {
|
||||||
|
return fallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatus() {
|
||||||
|
return {
|
||||||
|
state: this.state,
|
||||||
|
failures: this.failures,
|
||||||
|
lastFailureTime: this.lastFailureTime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this.state = 'CLOSED';
|
||||||
|
this.failures = 0;
|
||||||
|
this.lastFailureTime = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-configured circuit breakers
|
||||||
|
export const breakers = {
|
||||||
|
wordpress: new CircuitBreaker('WordPress', {
|
||||||
|
failureThreshold: 3,
|
||||||
|
resetTimeout: 30000,
|
||||||
|
monitoringPeriod: 5000,
|
||||||
|
}),
|
||||||
|
|
||||||
|
directus: new CircuitBreaker('Directus', {
|
||||||
|
failureThreshold: 5,
|
||||||
|
resetTimeout: 60000,
|
||||||
|
monitoringPeriod: 10000,
|
||||||
|
}),
|
||||||
|
};
|
||||||
64
god-mode/src/lib/utils/dry-run.ts
Normal file
64
god-mode/src/lib/utils/dry-run.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* Dry Run Mode
|
||||||
|
* Preview generation without saving to database
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Article } from '@/lib/validation/schemas';
|
||||||
|
|
||||||
|
export interface DryRunResult {
|
||||||
|
preview: Article;
|
||||||
|
blocks_used: string[];
|
||||||
|
variables_injected: Record<string, string>;
|
||||||
|
spintax_resolved: boolean;
|
||||||
|
estimated_seo_score: number;
|
||||||
|
warnings: string[];
|
||||||
|
processing_time_ms: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dryRunGeneration(
|
||||||
|
patternId: string,
|
||||||
|
avatarId: string,
|
||||||
|
geoCity: string,
|
||||||
|
geoState: string,
|
||||||
|
keyword: string
|
||||||
|
): Promise<DryRunResult> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
const warnings: string[] = [];
|
||||||
|
|
||||||
|
// Simulate generation process without saving
|
||||||
|
const preview: Article = {
|
||||||
|
id: 'dry-run-preview',
|
||||||
|
collection_id: 'dry-run',
|
||||||
|
status: 'review',
|
||||||
|
title: `Preview: ${keyword} in ${geoCity}, ${geoState}`,
|
||||||
|
slug: 'dry-run-preview',
|
||||||
|
content_html: '<p>This is a dry-run preview. No data was saved.</p>',
|
||||||
|
geo_city: geoCity,
|
||||||
|
geo_state: geoState,
|
||||||
|
seo_score: 75,
|
||||||
|
is_published: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Track what would be used
|
||||||
|
const blocks_used = [
|
||||||
|
'intro-block-123',
|
||||||
|
'problem-block-456',
|
||||||
|
'solution-block-789',
|
||||||
|
];
|
||||||
|
|
||||||
|
const variables_injected = {
|
||||||
|
city: geoCity,
|
||||||
|
state: geoState,
|
||||||
|
keyword,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
preview,
|
||||||
|
blocks_used,
|
||||||
|
variables_injected,
|
||||||
|
spintax_resolved: true,
|
||||||
|
estimated_seo_score: 75,
|
||||||
|
warnings,
|
||||||
|
processing_time_ms: Date.now() - startTime,
|
||||||
|
};
|
||||||
|
}
|
||||||
56
god-mode/src/lib/utils/logger.ts
Normal file
56
god-mode/src/lib/utils/logger.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* Work Log Helper
|
||||||
|
* Centralized logging to work_log collection
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getDirectusClient } from '@/lib/directus/client';
|
||||||
|
import { createItem } from '@directus/sdk';
|
||||||
|
|
||||||
|
export type LogLevel = 'info' | 'success' | 'warning' | 'error';
|
||||||
|
export type LogAction = 'create' | 'update' | 'delete' | 'generate' | 'publish' | 'sync' | 'test';
|
||||||
|
|
||||||
|
interface LogEntry {
|
||||||
|
action: LogAction;
|
||||||
|
message: string;
|
||||||
|
entity_type?: string;
|
||||||
|
entity_id?: string | number;
|
||||||
|
details?: string;
|
||||||
|
level?: LogLevel;
|
||||||
|
site?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logWork(entry: LogEntry) {
|
||||||
|
try {
|
||||||
|
const client = getDirectusClient();
|
||||||
|
|
||||||
|
await client.request(
|
||||||
|
createItem('work_log', {
|
||||||
|
action: entry.action,
|
||||||
|
message: entry.message,
|
||||||
|
entity_type: entry.entity_type,
|
||||||
|
entity_id: entry.entity_id?.toString(),
|
||||||
|
details: entry.details,
|
||||||
|
level: entry.level || 'info',
|
||||||
|
site: entry.site,
|
||||||
|
status: 'completed',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to log work:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience methods
|
||||||
|
export const logger = {
|
||||||
|
info: (message: string, details?: Partial<LogEntry>) =>
|
||||||
|
logWork({ ...details, message, action: details?.action || 'update', level: 'info' }),
|
||||||
|
|
||||||
|
success: (message: string, details?: Partial<LogEntry>) =>
|
||||||
|
logWork({ ...details, message, action: details?.action || 'create', level: 'success' }),
|
||||||
|
|
||||||
|
warning: (message: string, details?: Partial<LogEntry>) =>
|
||||||
|
logWork({ ...details, message, action: details?.action || 'update', level: 'warning' }),
|
||||||
|
|
||||||
|
error: (message: string, details?: Partial<LogEntry>) =>
|
||||||
|
logWork({ ...details, message, action: details?.action || 'update', level: 'error' }),
|
||||||
|
};
|
||||||
71
god-mode/src/lib/utils/transactions.ts
Normal file
71
god-mode/src/lib/utils/transactions.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* Database Transaction Wrapper
|
||||||
|
* Ensures atomic operations with PostgreSQL
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getDirectusClient } from '@/lib/directus/client';
|
||||||
|
import { logger } from '@/lib/utils/logger';
|
||||||
|
|
||||||
|
export async function withTransaction<T>(
|
||||||
|
operation: () => Promise<T>,
|
||||||
|
options?: {
|
||||||
|
onError?: (error: Error) => void;
|
||||||
|
logContext?: string;
|
||||||
|
}
|
||||||
|
): Promise<T> {
|
||||||
|
try {
|
||||||
|
// Execute operation
|
||||||
|
const result = await operation();
|
||||||
|
|
||||||
|
if (options?.logContext) {
|
||||||
|
await logger.success(`Transaction completed: ${options.logContext}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
// Log error
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
|
||||||
|
if (options?.logContext) {
|
||||||
|
await logger.error(`Transaction failed: ${options.logContext}`, {
|
||||||
|
details: errorMessage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call error handler if provided
|
||||||
|
if (options?.onError && error instanceof Error) {
|
||||||
|
options.onError(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch operation wrapper with rate limiting
|
||||||
|
export async function batchOperation<T>(
|
||||||
|
items: T[],
|
||||||
|
operation: (item: T) => Promise<void>,
|
||||||
|
options?: {
|
||||||
|
batchSize?: number;
|
||||||
|
delayMs?: number;
|
||||||
|
onProgress?: (completed: number, total: number) => void;
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
const batchSize = options?.batchSize || 50;
|
||||||
|
const delayMs = options?.delayMs || 100;
|
||||||
|
|
||||||
|
for (let i = 0; i < items.length; i += batchSize) {
|
||||||
|
const batch = items.slice(i, i + batchSize);
|
||||||
|
|
||||||
|
await Promise.all(batch.map(item => operation(item)));
|
||||||
|
|
||||||
|
if (options?.onProgress) {
|
||||||
|
options.onProgress(Math.min(i + batchSize, items.length), items.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delay between batches
|
||||||
|
if (i + batchSize < items.length && delayMs) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
134
god-mode/src/lib/validation/schemas.ts
Normal file
134
god-mode/src/lib/validation/schemas.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* Zod Validation Schemas
|
||||||
|
* Type-safe validation for all collections
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// Site schema
|
||||||
|
export const siteSchema = z.object({
|
||||||
|
id: z.string().uuid().optional(),
|
||||||
|
name: z.string().min(1, 'Site name required'),
|
||||||
|
domain: z.string().min(1, 'Domain required'),
|
||||||
|
domain_aliases: z.array(z.string()).optional(),
|
||||||
|
settings: z.record(z.any()).optional(),
|
||||||
|
status: z.enum(['active', 'inactive']),
|
||||||
|
date_created: z.string().optional(),
|
||||||
|
date_updated: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Collection schema
|
||||||
|
export const collectionSchema = z.object({
|
||||||
|
id: z.string().uuid().optional(),
|
||||||
|
name: z.string().min(1, 'Collection name required'),
|
||||||
|
status: z.enum(['queued', 'processing', 'complete', 'failed']),
|
||||||
|
site_id: z.string().uuid('Invalid site ID'),
|
||||||
|
avatar_id: z.string().uuid('Invalid avatar ID'),
|
||||||
|
pattern_id: z.string().uuid('Invalid pattern ID'),
|
||||||
|
geo_cluster_id: z.string().uuid('Invalid geo cluster ID').optional(),
|
||||||
|
target_keyword: z.string().min(1, 'Keyword required'),
|
||||||
|
batch_size: z.number().min(1).max(1000),
|
||||||
|
logs: z.any().optional(),
|
||||||
|
date_created: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generated article schema
|
||||||
|
export const articleSchema = z.object({
|
||||||
|
id: z.string().uuid().optional(),
|
||||||
|
collection_id: z.string().uuid('Invalid collection ID'),
|
||||||
|
status: z.enum(['queued', 'generating', 'review', 'approved', 'published', 'failed']),
|
||||||
|
title: z.string().min(1, 'Title required'),
|
||||||
|
slug: z.string().min(1, 'Slug required'),
|
||||||
|
content_html: z.string().optional(),
|
||||||
|
content_raw: z.string().optional(),
|
||||||
|
assembly_map: z.object({
|
||||||
|
pattern_id: z.string(),
|
||||||
|
block_ids: z.array(z.string()),
|
||||||
|
variables: z.record(z.string()),
|
||||||
|
}).optional(),
|
||||||
|
seo_score: z.number().min(0).max(100).optional(),
|
||||||
|
geo_city: z.string().optional(),
|
||||||
|
geo_state: z.string().optional(),
|
||||||
|
featured_image_url: z.string().url().optional(),
|
||||||
|
meta_desc: z.string().max(160).optional(),
|
||||||
|
schema_json: z.any().optional(),
|
||||||
|
logs: z.any().optional(),
|
||||||
|
wordpress_post_id: z.number().optional(),
|
||||||
|
is_published: z.boolean().optional(),
|
||||||
|
date_created: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Content block schema
|
||||||
|
export const contentBlockSchema = z.object({
|
||||||
|
id: z.string().uuid().optional(),
|
||||||
|
category: z.enum(['intro', 'body', 'cta', 'problem', 'solution', 'benefits']),
|
||||||
|
avatar_id: z.string().uuid('Invalid avatar ID'),
|
||||||
|
content: z.string().min(1, 'Content required'),
|
||||||
|
tags: z.array(z.string()).optional(),
|
||||||
|
usage_count: z.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pattern schema
|
||||||
|
export const patternSchema = z.object({
|
||||||
|
id: z.string().uuid().optional(),
|
||||||
|
name: z.string().min(1, 'Pattern name required'),
|
||||||
|
structure_json: z.any(),
|
||||||
|
execution_order: z.array(z.string()),
|
||||||
|
preview_template: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Avatar schema
|
||||||
|
export const avatarSchema = z.object({
|
||||||
|
id: z.string().uuid().optional(),
|
||||||
|
base_name: z.string().min(1, 'Avatar name required'),
|
||||||
|
business_niches: z.array(z.string()),
|
||||||
|
wealth_cluster: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Geo cluster schema
|
||||||
|
export const geoClusterSchema = z.object({
|
||||||
|
id: z.string().uuid().optional(),
|
||||||
|
cluster_name: z.string().min(1, 'Cluster name required'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Spintax validation
|
||||||
|
export const validateSpintax = (text: string): { valid: boolean; errors: string[] } => {
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
// Check for unbalanced braces
|
||||||
|
let braceCount = 0;
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
if (text[i] === '{') braceCount++;
|
||||||
|
if (text[i] === '}') braceCount--;
|
||||||
|
if (braceCount < 0) {
|
||||||
|
errors.push(`Unbalanced closing brace at position ${i}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (braceCount > 0) {
|
||||||
|
errors.push('Unclosed opening braces');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for empty options
|
||||||
|
if (/{[^}]*\|\|[^}]*}/.test(text)) {
|
||||||
|
errors.push('Empty spintax options found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for orphaned pipes
|
||||||
|
if (/\|(?![^{]*})/.test(text)) {
|
||||||
|
errors.push('Pipe character outside spintax block');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: errors.length === 0,
|
||||||
|
errors,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Site = z.infer<typeof siteSchema>;
|
||||||
|
export type Collection = z.infer<typeof collectionSchema>;
|
||||||
|
export type Article = z.infer<typeof articleSchema>;
|
||||||
|
export type ContentBlock = z.infer<typeof contentBlockSchema>;
|
||||||
|
export type Pattern = z.infer<typeof patternSchema>;
|
||||||
|
export type Avatar = z.infer<typeof avatarSchema>;
|
||||||
|
export type GeoCluster = z.infer<typeof geoClusterSchema>;
|
||||||
0
god-mode/src/lib/variables/context.ts
Normal file
0
god-mode/src/lib/variables/context.ts
Normal file
0
god-mode/src/lib/variables/interpolation.ts
Normal file
0
god-mode/src/lib/variables/interpolation.ts
Normal file
0
god-mode/src/lib/variables/templates.ts
Normal file
0
god-mode/src/lib/variables/templates.ts
Normal file
138
god-mode/src/lib/wordpress/WordPressClient.ts
Normal file
138
god-mode/src/lib/wordpress/WordPressClient.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
|
||||||
|
export interface WPPost {
|
||||||
|
id: number;
|
||||||
|
date: string;
|
||||||
|
slug: string;
|
||||||
|
status: string;
|
||||||
|
type: string;
|
||||||
|
link: string;
|
||||||
|
title: { rendered: string };
|
||||||
|
content: { rendered: string };
|
||||||
|
excerpt: { rendered: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WordPressClient {
|
||||||
|
private baseUrl: string;
|
||||||
|
private authHeader: string | null = null;
|
||||||
|
|
||||||
|
constructor(domain: string, appPassword?: string) {
|
||||||
|
// Normalize domain
|
||||||
|
this.baseUrl = domain.replace(/\/$/, '');
|
||||||
|
if (!this.baseUrl.startsWith('http')) {
|
||||||
|
this.baseUrl = `https://${this.baseUrl}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (appPassword) {
|
||||||
|
// Assumes username is 'admin' or handled in the pass string if formatted 'user:pass'
|
||||||
|
// Usually Application Passwords are just the pwd, requiring a user.
|
||||||
|
// For now, let's assume the user passes "username:app_password" string or implemented later.
|
||||||
|
// We'll stick to public GET for now which doesn't need auth for reading content usually.
|
||||||
|
// If auth is needed:
|
||||||
|
// this.authHeader = `Basic ${btoa(appPassword)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async testConnection(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${this.baseUrl}/wp-json/`);
|
||||||
|
return res.ok;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("WP Connection Failed", e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPages(limit = 100): Promise<WPPost[]> {
|
||||||
|
const url = `${this.baseUrl}/wp-json/wp/v2/pages?per_page=${limit}`;
|
||||||
|
return this.fetchCollection(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPosts(limit = 100, page = 1): Promise<WPPost[]> {
|
||||||
|
const url = `${this.baseUrl}/wp-json/wp/v2/posts?per_page=${limit}&page=${page}`;
|
||||||
|
return this.fetchCollection(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPost(postId: number): Promise<WPPost | null> {
|
||||||
|
try {
|
||||||
|
const url = `${this.baseUrl}/wp-json/wp/v2/posts/${postId}`;
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return await res.json();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Fetch Post Error", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllPosts(): Promise<WPPost[]> {
|
||||||
|
let allPosts: WPPost[] = [];
|
||||||
|
let page = 1;
|
||||||
|
let totalPages = 1;
|
||||||
|
|
||||||
|
// First fetch to get total pages
|
||||||
|
const url = `${this.baseUrl}/wp-json/wp/v2/posts?per_page=100&page=${page}`;
|
||||||
|
try {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) throw new Error(`WP API Error: ${res.status}`);
|
||||||
|
|
||||||
|
const totalPagesHeader = res.headers.get('X-WP-TotalPages');
|
||||||
|
if (totalPagesHeader) {
|
||||||
|
totalPages = parseInt(totalPagesHeader, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
allPosts = [...allPosts, ...data];
|
||||||
|
|
||||||
|
// Loop remaining pages
|
||||||
|
// Process in parallel chunks if too many, but for now sequential is safer to avoid rate limits
|
||||||
|
// or perform simple Promise.all for batches.
|
||||||
|
// Let's do batches of 5 to speed it up.
|
||||||
|
|
||||||
|
const remainingPages = [];
|
||||||
|
for (let p = 2; p <= totalPages; p++) {
|
||||||
|
remainingPages.push(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch fetch
|
||||||
|
const batchSize = 5;
|
||||||
|
for (let i = 0; i < remainingPages.length; i += batchSize) {
|
||||||
|
const batch = remainingPages.slice(i, i + batchSize);
|
||||||
|
const promises = batch.map(p =>
|
||||||
|
fetch(`${this.baseUrl}/wp-json/wp/v2/posts?per_page=100&page=${p}`)
|
||||||
|
.then(r => r.json())
|
||||||
|
);
|
||||||
|
const results = await Promise.all(promises);
|
||||||
|
results.forEach(posts => {
|
||||||
|
allPosts = [...allPosts, ...posts];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Fetch Error", e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
return allPosts;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCategories(): Promise<any[]> {
|
||||||
|
// Fetch all categories
|
||||||
|
return this.fetchCollection(`${this.baseUrl}/wp-json/wp/v2/categories?per_page=100`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTags(): Promise<any[]> {
|
||||||
|
// Fetch all tags
|
||||||
|
return this.fetchCollection(`${this.baseUrl}/wp-json/wp/v2/tags?per_page=100`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchCollection(url: string): Promise<any[]> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) throw new Error(`WP API Error: ${res.status}`);
|
||||||
|
return await res.json();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Fetch Error", e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,50 +1,51 @@
|
|||||||
/**
|
/**
|
||||||
* 🔱 GOD MODE BACKDOOR - Direct PostgreSQL Access
|
* 🔱 GOD MODE BACKDOOR - Direct PostgreSQL Access
|
||||||
* Standalone Version (Valhalla)
|
*
|
||||||
|
* This endpoint bypasses Directus entirely and connects directly to PostgreSQL.
|
||||||
|
* Works even when Directus is crashed/frozen.
|
||||||
|
*
|
||||||
|
* Endpoints:
|
||||||
|
* GET /api/god/health - Full system health check
|
||||||
|
* GET /api/god/services - Quick service status (all 4 containers)
|
||||||
|
* GET /api/god/db-status - Database connection test
|
||||||
|
* POST /api/god/sql - Execute raw SQL (dangerous!)
|
||||||
|
* GET /api/god/tables - List all tables
|
||||||
|
* GET /api/god/logs - Recent work_log entries
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from 'astro';
|
||||||
import pg from 'pg'; // Default import for some environments
|
import { pool } from '@/lib/db';
|
||||||
const { Pool } = pg;
|
|
||||||
import Redis from 'ioredis';
|
import Redis from 'ioredis';
|
||||||
|
|
||||||
// Direct PostgreSQL connection (Strict Connection String)
|
// Direct PostgreSQL connection (Strict Connection String)
|
||||||
// God Mode requires Superuser access (postgres) to effectively diagnose and fix the DB.
|
// God Mode requires Superuser access (postgres) to effectively diagnose and fix the DB.
|
||||||
if (!process.env.DATABASE_URL) {
|
// Pool is now shared from @/lib/dbr: process.env.DB_USER || 'postgres',
|
||||||
console.error("❌ FATAL: DATABASE_URL environment variable is missing!");
|
// password: process.env.DB_PASSWORD || 'Idk@2026lolhappyha232',
|
||||||
}
|
// max: 3,
|
||||||
|
// idleTimeoutMillis: 30000,
|
||||||
|
// connectionTimeoutMillis: 5000,
|
||||||
|
// });
|
||||||
|
|
||||||
const pool = new Pool({
|
// Directus URL
|
||||||
connectionString: process.env.DATABASE_URL,
|
|
||||||
max: 3,
|
|
||||||
idleTimeoutMillis: 30000,
|
|
||||||
connectionTimeoutMillis: 5000,
|
|
||||||
ssl: process.env.DATABASE_URL?.includes('sslmode=require') ? { rejectUnauthorized: false } : undefined
|
|
||||||
});
|
|
||||||
|
|
||||||
// Redis connection
|
|
||||||
const REDIS_URL = process.env.REDIS_URL || 'redis://redis:6379';
|
|
||||||
|
|
||||||
// Service URLs
|
|
||||||
const DIRECTUS_URL = process.env.PUBLIC_DIRECTUS_URL || 'http://directus:8055';
|
const DIRECTUS_URL = process.env.PUBLIC_DIRECTUS_URL || 'http://directus:8055';
|
||||||
|
|
||||||
// Token validation
|
// God Mode Token validation
|
||||||
function validateGodToken(request: Request): boolean {
|
function validateGodToken(request: Request): boolean {
|
||||||
const token = request.headers.get('X-God-Token') ||
|
const token = request.headers.get('X-God-Token') ||
|
||||||
request.headers.get('Authorization')?.replace('Bearer ', '') ||
|
request.headers.get('Authorization')?.replace('Bearer ', '') ||
|
||||||
new URL(request.url).searchParams.get('token');
|
new URL(request.url).searchParams.get('token');
|
||||||
|
|
||||||
const godToken = process.env.GOD_MODE_TOKEN;
|
const godToken = process.env.GOD_MODE_TOKEN || import.meta.env.GOD_MODE_TOKEN;
|
||||||
|
|
||||||
if (!godToken) {
|
if (!godToken) {
|
||||||
// In standalone mode, we force a token for security
|
console.warn('⚠️ GOD_MODE_TOKEN not set - backdoor is open!');
|
||||||
console.warn('⚠️ GOD_MODE_TOKEN not set in env!');
|
return true; // Allow access if no token configured (dev mode)
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return token === godToken;
|
return token === godToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// JSON response helper
|
||||||
function json(data: object, status = 200) {
|
function json(data: object, status = 200) {
|
||||||
return new Response(JSON.stringify(data, null, 2), {
|
return new Response(JSON.stringify(data, null, 2), {
|
||||||
status,
|
status,
|
||||||
@@ -52,13 +53,8 @@ function json(data: object, status = 200) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET Handler
|
// GET /api/god/health - Full system health
|
||||||
export const GET: APIRoute = async ({ request, url }) => {
|
export const GET: APIRoute = async ({ request, url }) => {
|
||||||
// Quick health check (no auth needed for load balancers)
|
|
||||||
if (url.pathname.endsWith('/healthz')) {
|
|
||||||
return new Response('OK', { status: 200 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!validateGodToken(request)) {
|
if (!validateGodToken(request)) {
|
||||||
return json({ error: 'Unauthorized - Invalid God Mode Token' }, 401);
|
return json({ error: 'Unauthorized - Invalid God Mode Token' }, 401);
|
||||||
}
|
}
|
||||||
@@ -67,13 +63,28 @@ export const GET: APIRoute = async ({ request, url }) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'health': return await getHealth();
|
case 'health':
|
||||||
case 'services': return await getServices();
|
return await getHealth();
|
||||||
case 'db-status': return await getDbStatus();
|
case 'services':
|
||||||
case 'tables': return await getTables();
|
return await getServices();
|
||||||
case 'logs': return await getLogs();
|
case 'db-status':
|
||||||
default: return json({
|
return await getDbStatus();
|
||||||
message: '🔱 Valhalla Active',
|
case 'tables':
|
||||||
|
return await getTables();
|
||||||
|
case 'logs':
|
||||||
|
return await getLogs();
|
||||||
|
default:
|
||||||
|
return json({
|
||||||
|
message: '🔱 God Mode Backdoor Active',
|
||||||
|
frontend: 'RUNNING ✅',
|
||||||
|
endpoints: {
|
||||||
|
'GET /api/god/health': 'Full system health check',
|
||||||
|
'GET /api/god/services': 'Quick status of all 4 containers',
|
||||||
|
'GET /api/god/db-status': 'Database connection test',
|
||||||
|
'GET /api/god/tables': 'List all tables',
|
||||||
|
'GET /api/god/logs': 'Recent work_log entries',
|
||||||
|
'POST /api/god/sql': 'Execute raw SQL (body: { query: "..." })',
|
||||||
|
},
|
||||||
timestamp: new Date().toISOString()
|
timestamp: new Date().toISOString()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -82,110 +93,333 @@ export const GET: APIRoute = async ({ request, url }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// POST Handler
|
// POST /api/god/sql - Execute raw SQL
|
||||||
export const POST: APIRoute = async ({ request, url }) => {
|
export const POST: APIRoute = async ({ request, url }) => {
|
||||||
if (!validateGodToken(request)) {
|
if (!validateGodToken(request)) {
|
||||||
return json({ error: 'Unauthorized' }, 401);
|
return json({ error: 'Unauthorized - Invalid God Mode Token' }, 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
const action = url.pathname.split('/').pop();
|
const action = url.pathname.split('/').pop();
|
||||||
|
|
||||||
if (action === 'sql') {
|
if (action !== 'sql') {
|
||||||
|
return json({ error: 'POST only supported for /api/god/sql' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
if (!body.query) return json({ error: 'Missing query' }, 400);
|
const { query } = body;
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
return json({ error: 'Missing query in request body' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pool.query(query);
|
||||||
|
|
||||||
const result = await pool.query(body.query);
|
|
||||||
return json({
|
return json({
|
||||||
success: true,
|
success: true,
|
||||||
|
command: result.command,
|
||||||
rowCount: result.rowCount,
|
rowCount: result.rowCount,
|
||||||
rows: result.rows,
|
rows: result.rows,
|
||||||
fields: result.fields?.map(f => f.name)
|
fields: result.fields?.map(f => f.name)
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
return json({ error: error.message }, 500);
|
return json({ error: error.message, code: error.code }, 500);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return json({ error: 'Method not allowed' }, 405);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Helpers ---
|
// Quick service status check
|
||||||
|
|
||||||
async function getServices() {
|
async function getServices() {
|
||||||
const services: any = { timestamp: new Date().toISOString() };
|
const services: Record<string, any> = {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
frontend: { status: '✅ RUNNING', note: 'You are seeing this response' }
|
||||||
|
};
|
||||||
|
|
||||||
// DB Check
|
// Check PostgreSQL
|
||||||
try {
|
try {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
await pool.query('SELECT 1');
|
await pool.query('SELECT 1');
|
||||||
services.postgresql = { status: '✅ RUNNING', latency: Date.now() - start };
|
services.postgresql = {
|
||||||
} catch (e: any) {
|
status: '✅ RUNNING',
|
||||||
services.postgresql = { status: '❌ DOWN', error: e.message };
|
latency_ms: Date.now() - start
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
services.postgresql = {
|
||||||
|
status: '❌ DOWN',
|
||||||
|
error: error.message
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redis Check
|
// Check Redis
|
||||||
try {
|
try {
|
||||||
const redis = new Redis(REDIS_URL, { maxRetriesPerRequest: 1, connectTimeout: 2000 });
|
const redis = new Redis({
|
||||||
|
host: process.env.REDIS_HOST || 'redis',
|
||||||
|
port: 6379,
|
||||||
|
connectTimeout: 3000,
|
||||||
|
maxRetriesPerRequest: 1
|
||||||
|
});
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
await redis.ping();
|
await redis.ping();
|
||||||
services.redis = { status: '✅ RUNNING', latency: Date.now() - start };
|
services.redis = {
|
||||||
|
status: '✅ RUNNING',
|
||||||
|
latency_ms: Date.now() - start
|
||||||
|
};
|
||||||
redis.disconnect();
|
redis.disconnect();
|
||||||
} catch (e: any) {
|
} catch (error: any) {
|
||||||
services.redis = { status: '❌ DOWN', error: e.message };
|
services.redis = {
|
||||||
|
status: '❌ DOWN',
|
||||||
|
error: error.message
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Directus Check
|
// Check Directus
|
||||||
try {
|
try {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
const res = await fetch(`${DIRECTUS_URL}/server/health`, { signal: AbortSignal.timeout(5000) });
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||||
|
|
||||||
|
const response = await fetch(`${DIRECTUS_URL}/server/health`, {
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
clearTimeout(timeout);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
services.directus = {
|
services.directus = {
|
||||||
status: res.ok ? '✅ RUNNING' : `⚠️ HTTP ${res.status}`,
|
status: '✅ RUNNING',
|
||||||
latency: Date.now() - start
|
latency_ms: Date.now() - start,
|
||||||
|
health: data.status
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
services.directus = {
|
||||||
|
status: '⚠️ UNHEALTHY',
|
||||||
|
http_status: response.status
|
||||||
};
|
};
|
||||||
} catch (e: any) {
|
|
||||||
services.directus = { status: '❌ DOWN', error: e.message };
|
|
||||||
}
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
services.directus = {
|
||||||
|
status: '❌ DOWN',
|
||||||
|
error: error.name === 'AbortError' ? 'Timeout (5s)' : error.message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
const allUp = services.postgresql.status.includes('✅') &&
|
||||||
|
services.redis.status.includes('✅') &&
|
||||||
|
services.directus.status.includes('✅');
|
||||||
|
|
||||||
|
services.summary = allUp ? '✅ ALL SERVICES HEALTHY' : '⚠️ SOME SERVICES DOWN';
|
||||||
|
|
||||||
return json(services);
|
return json(services);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Health check implementation
|
||||||
async function getHealth() {
|
async function getHealth() {
|
||||||
// Full system health (similar to getServices but more detail)
|
const start = Date.now();
|
||||||
return getServices();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getDbStatus() {
|
const checks: Record<string, any> = {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
uptime_seconds: Math.round(process.uptime()),
|
||||||
|
memory: {
|
||||||
|
rss_mb: Math.round(process.memoryUsage().rss / 1024 / 1024),
|
||||||
|
heap_used_mb: Math.round(process.memoryUsage().heapUsed / 1024 / 1024),
|
||||||
|
heap_total_mb: Math.round(process.memoryUsage().heapTotal / 1024 / 1024),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// PostgreSQL check
|
||||||
try {
|
try {
|
||||||
const res = await pool.query(`
|
const dbStart = Date.now();
|
||||||
SELECT current_database() as db, version(),
|
const result = await pool.query('SELECT NOW() as time, current_database() as db, current_user as user');
|
||||||
(SELECT count(*) FROM pg_stat_activity) as connections
|
checks.postgresql = {
|
||||||
`);
|
status: '✅ healthy',
|
||||||
return json({ status: 'connected', ...res.rows[0] });
|
latency_ms: Date.now() - dbStart,
|
||||||
} catch (e: any) {
|
...result.rows[0]
|
||||||
return json({ status: 'error', error: e.message });
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
checks.postgresql = {
|
||||||
|
status: '❌ unhealthy',
|
||||||
|
error: error.message
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
async function getTables() {
|
// Connection pool status
|
||||||
|
checks.pg_pool = {
|
||||||
|
total: pool.totalCount,
|
||||||
|
idle: pool.idleCount,
|
||||||
|
waiting: pool.waitingCount
|
||||||
|
};
|
||||||
|
|
||||||
|
// Redis check
|
||||||
try {
|
try {
|
||||||
const res = await pool.query(`
|
const redis = new Redis({
|
||||||
|
host: process.env.REDIS_HOST || 'redis',
|
||||||
|
port: 6379,
|
||||||
|
connectTimeout: 3000,
|
||||||
|
maxRetriesPerRequest: 1
|
||||||
|
});
|
||||||
|
const redisStart = Date.now();
|
||||||
|
const info = await redis.info('server');
|
||||||
|
checks.redis = {
|
||||||
|
status: '✅ healthy',
|
||||||
|
latency_ms: Date.now() - redisStart,
|
||||||
|
version: info.match(/redis_version:([^\r\n]+)/)?.[1]
|
||||||
|
};
|
||||||
|
redis.disconnect();
|
||||||
|
} catch (error: any) {
|
||||||
|
checks.redis = {
|
||||||
|
status: '❌ unhealthy',
|
||||||
|
error: error.message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Directus check
|
||||||
|
try {
|
||||||
|
const directusStart = Date.now();
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||||
|
|
||||||
|
const response = await fetch(`${DIRECTUS_URL}/server/health`, {
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
clearTimeout(timeout);
|
||||||
|
|
||||||
|
checks.directus = {
|
||||||
|
status: response.ok ? '✅ healthy' : '⚠️ unhealthy',
|
||||||
|
latency_ms: Date.now() - directusStart,
|
||||||
|
http_status: response.status
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
checks.directus = {
|
||||||
|
status: '❌ unreachable',
|
||||||
|
error: error.name === 'AbortError' ? 'Timeout (5s)' : error.message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Directus tables check
|
||||||
|
try {
|
||||||
|
const tables = await pool.query(`
|
||||||
SELECT table_name
|
SELECT table_name
|
||||||
FROM information_schema.tables
|
FROM information_schema.tables
|
||||||
WHERE table_schema = 'public'
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name LIKE 'directus_%'
|
||||||
ORDER BY table_name
|
ORDER BY table_name
|
||||||
`);
|
`);
|
||||||
return json({ total: res.rows.length, tables: res.rows });
|
checks.directus_tables = tables.rows.length;
|
||||||
} catch (e: any) {
|
} catch (error: any) {
|
||||||
return json({ error: e.message });
|
checks.directus_tables = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom tables check
|
||||||
|
try {
|
||||||
|
const tables = await pool.query(`
|
||||||
|
SELECT table_name
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name NOT LIKE 'directus_%'
|
||||||
|
ORDER BY table_name
|
||||||
|
`);
|
||||||
|
checks.custom_tables = {
|
||||||
|
count: tables.rows.length,
|
||||||
|
tables: tables.rows.map(r => r.table_name)
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
checks.custom_tables = { count: 0, error: error.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
checks.total_latency_ms = Date.now() - start;
|
||||||
|
|
||||||
|
return json(checks);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Database status
|
||||||
|
async function getDbStatus() {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
pg_database_size(current_database()) as db_size_bytes,
|
||||||
|
(SELECT count(*) FROM pg_stat_activity) as active_connections,
|
||||||
|
(SELECT count(*) FROM pg_stat_activity WHERE state = 'active') as running_queries,
|
||||||
|
(SELECT max(query_start) FROM pg_stat_activity WHERE state = 'active') as oldest_query_start,
|
||||||
|
current_database() as database,
|
||||||
|
version() as version
|
||||||
|
`);
|
||||||
|
|
||||||
|
return json({
|
||||||
|
status: 'connected',
|
||||||
|
...result.rows[0],
|
||||||
|
db_size_mb: Math.round(result.rows[0].db_size_bytes / 1024 / 1024)
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
return json({ status: 'error', error: error.message }, 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getLogs() {
|
// List all tables
|
||||||
|
async function getTables() {
|
||||||
try {
|
try {
|
||||||
const res = await pool.query('SELECT * FROM work_log ORDER BY timestamp DESC LIMIT 50');
|
const result = await pool.query(`
|
||||||
return json({ logs: res.rows });
|
SELECT
|
||||||
} catch (e: any) {
|
table_name,
|
||||||
return json({ error: e.message });
|
(SELECT count(*) FROM information_schema.columns c WHERE c.table_name = t.table_name) as column_count
|
||||||
|
FROM information_schema.tables t
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
ORDER BY table_name
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Get row counts for each table
|
||||||
|
const tables = [];
|
||||||
|
for (const row of result.rows) {
|
||||||
|
try {
|
||||||
|
const countResult = await pool.query(`SELECT count(*) as count FROM "${row.table_name}"`);
|
||||||
|
tables.push({
|
||||||
|
name: row.table_name,
|
||||||
|
columns: row.column_count,
|
||||||
|
rows: parseInt(countResult.rows[0].count)
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
tables.push({
|
||||||
|
name: row.table_name,
|
||||||
|
columns: row.column_count,
|
||||||
|
rows: 'error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({
|
||||||
|
total: tables.length,
|
||||||
|
tables
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
return json({ error: error.message }, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get recent logs
|
||||||
|
async function getLogs() {
|
||||||
|
try {
|
||||||
|
// Check if work_log table exists
|
||||||
|
const exists = await pool.query(`
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public' AND table_name = 'work_log'
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (!exists.rows[0].exists) {
|
||||||
|
return json({ message: 'work_log table does not exist', logs: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pool.query(`
|
||||||
|
SELECT * FROM work_log
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT 50
|
||||||
|
`);
|
||||||
|
|
||||||
|
return json({
|
||||||
|
count: result.rows.length,
|
||||||
|
logs: result.rows
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
return json({ error: error.message }, 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
88
god-mode/src/pages/api/seo/approve-batch.ts
Normal file
88
god-mode/src/pages/api/seo/approve-batch.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
// @ts-ignore - Astro types available at build time
|
||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { getDirectusClient, readItem, updateItem, createItem } from '@/lib/directus/client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approve Batch API
|
||||||
|
*
|
||||||
|
* Approves test batch and unlocks full production run.
|
||||||
|
*
|
||||||
|
* POST /api/seo/approve-batch
|
||||||
|
*/
|
||||||
|
export const POST: APIRoute = async ({ request }: { request: Request }) => {
|
||||||
|
try {
|
||||||
|
const data = await request.json();
|
||||||
|
const { queue_id, approved = true } = data;
|
||||||
|
|
||||||
|
if (!queue_id) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'queue_id is required' }),
|
||||||
|
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const directus = getDirectusClient();
|
||||||
|
|
||||||
|
// Get queue
|
||||||
|
const queue = await directus.request(readItem('production_queue', queue_id)) as any;
|
||||||
|
|
||||||
|
if (!queue) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Queue not found' }),
|
||||||
|
{ status: 404, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (queue.status !== 'test_batch') {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Queue is not in test_batch status' }),
|
||||||
|
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newStatus = approved ? 'approved' : 'pending';
|
||||||
|
|
||||||
|
// Update queue
|
||||||
|
await directus.request(
|
||||||
|
updateItem('production_queue', queue_id, {
|
||||||
|
status: newStatus
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update campaign
|
||||||
|
await directus.request(
|
||||||
|
updateItem('campaign_masters', queue.campaign, {
|
||||||
|
test_batch_status: approved ? 'approved' : 'rejected'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Log work
|
||||||
|
await directus.request(
|
||||||
|
createItem('work_log', {
|
||||||
|
site: queue.site,
|
||||||
|
action: approved ? 'approved' : 'rejected',
|
||||||
|
entity_type: 'production_queue',
|
||||||
|
entity_id: queue_id,
|
||||||
|
details: { approved }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
queue_id,
|
||||||
|
status: newStatus,
|
||||||
|
next_step: approved
|
||||||
|
? 'Queue approved. Call /api/seo/process-queue to start full generation.'
|
||||||
|
: 'Queue rejected. Modify campaign and resubmit test batch.'
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error approving batch:', error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Failed to approve batch' }),
|
||||||
|
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
43
god-mode/src/pages/api/seo/articles.ts
Normal file
43
god-mode/src/pages/api/seo/articles.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { getDirectusClient, readItems } from '@/lib/directus/client';
|
||||||
|
|
||||||
|
export const GET: APIRoute = async ({ locals }) => {
|
||||||
|
try {
|
||||||
|
const directus = getDirectusClient();
|
||||||
|
const siteId = locals.siteId;
|
||||||
|
|
||||||
|
const filter: Record<string, any> = {};
|
||||||
|
if (siteId) {
|
||||||
|
filter.site = { _eq: siteId };
|
||||||
|
}
|
||||||
|
|
||||||
|
const articles = await directus.request(
|
||||||
|
readItems('generated_articles', {
|
||||||
|
filter,
|
||||||
|
sort: ['-date_created'],
|
||||||
|
limit: 100,
|
||||||
|
fields: [
|
||||||
|
'id',
|
||||||
|
'headline',
|
||||||
|
'meta_title',
|
||||||
|
'word_count',
|
||||||
|
'is_published',
|
||||||
|
'location_city',
|
||||||
|
'location_state',
|
||||||
|
'date_created'
|
||||||
|
]
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ articles }),
|
||||||
|
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching articles:', error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ articles: [], error: 'Failed to fetch articles' }),
|
||||||
|
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
224
god-mode/src/pages/api/seo/assemble-article.ts
Normal file
224
god-mode/src/pages/api/seo/assemble-article.ts
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
// @ts-ignore - Astro types available at build time
|
||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { getDirectusClient, readItems, createItem, updateItem } from '@/lib/directus/client';
|
||||||
|
import { replaceYearTokens } from '@/lib/seo/velocity-scheduler';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assemble Article API
|
||||||
|
*
|
||||||
|
* Builds a full article from content modules based on campaign recipe.
|
||||||
|
* Uses lowest usage_count modules to ensure variety.
|
||||||
|
*
|
||||||
|
* POST /api/seo/assemble-article
|
||||||
|
*/
|
||||||
|
export const POST: APIRoute = async ({ request }: { request: Request }) => {
|
||||||
|
try {
|
||||||
|
const data = await request.json();
|
||||||
|
const {
|
||||||
|
campaign_id,
|
||||||
|
location, // { city, state, county }
|
||||||
|
publish_date,
|
||||||
|
modified_date
|
||||||
|
} = data;
|
||||||
|
|
||||||
|
if (!campaign_id || !location) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'campaign_id and location required' }),
|
||||||
|
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const directus = getDirectusClient();
|
||||||
|
|
||||||
|
// Get campaign with recipe
|
||||||
|
const campaigns = await directus.request(readItems('campaign_masters', {
|
||||||
|
filter: { id: { _eq: campaign_id } },
|
||||||
|
limit: 1
|
||||||
|
})) as any[];
|
||||||
|
|
||||||
|
const campaign = campaigns[0];
|
||||||
|
if (!campaign) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Campaign not found' }),
|
||||||
|
{ status: 404, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipe = campaign.content_recipe || ['intro', 'benefits', 'howto', 'conclusion'];
|
||||||
|
const pubDate = publish_date ? new Date(publish_date) : new Date();
|
||||||
|
const modDate = modified_date ? new Date(modified_date) : new Date();
|
||||||
|
|
||||||
|
// Build context for token replacement
|
||||||
|
const context = {
|
||||||
|
city: location.city || '',
|
||||||
|
state: location.state || '',
|
||||||
|
county: location.county || '',
|
||||||
|
state_code: getStateCode(location.state) || '',
|
||||||
|
year: pubDate.getFullYear()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch and assemble modules
|
||||||
|
const assembledParts: string[] = [];
|
||||||
|
const modulesUsed: string[] = [];
|
||||||
|
|
||||||
|
for (const moduleType of recipe) {
|
||||||
|
// Get modules of this type, prefer lowest usage_count
|
||||||
|
const modules = await directus.request(readItems('content_modules', {
|
||||||
|
filter: {
|
||||||
|
site: { _eq: campaign.site },
|
||||||
|
module_type: { _eq: moduleType },
|
||||||
|
is_active: { _eq: true }
|
||||||
|
},
|
||||||
|
sort: ['usage_count', 'id'], // Lowest usage first
|
||||||
|
limit: 1
|
||||||
|
})) as any[];
|
||||||
|
|
||||||
|
if (modules.length > 0) {
|
||||||
|
const module = modules[0];
|
||||||
|
|
||||||
|
// Process spintax
|
||||||
|
let content = module.content_spintax || '';
|
||||||
|
|
||||||
|
// Replace location tokens
|
||||||
|
content = content
|
||||||
|
.replace(/\{City\}/gi, context.city)
|
||||||
|
.replace(/\{State\}/gi, context.state)
|
||||||
|
.replace(/\{County\}/gi, context.county)
|
||||||
|
.replace(/\{State_Code\}/gi, context.state_code)
|
||||||
|
.replace(/\{Location_City\}/gi, context.city)
|
||||||
|
.replace(/\{Location_State\}/gi, context.state);
|
||||||
|
|
||||||
|
// Replace year tokens
|
||||||
|
content = replaceYearTokens(content, pubDate);
|
||||||
|
|
||||||
|
// Process spintax syntax
|
||||||
|
content = processSpintax(content);
|
||||||
|
|
||||||
|
assembledParts.push(content);
|
||||||
|
modulesUsed.push(module.id);
|
||||||
|
|
||||||
|
// Increment usage count
|
||||||
|
await directus.request(
|
||||||
|
updateItem('content_modules', module.id, {
|
||||||
|
usage_count: (module.usage_count || 0) + 1
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullContent = assembledParts.join('\n\n');
|
||||||
|
|
||||||
|
// Generate headline from intro
|
||||||
|
const headline = generateHeadline(campaign.spintax_title, context, pubDate) ||
|
||||||
|
`${context.city} ${campaign.name || 'Guide'}`;
|
||||||
|
|
||||||
|
// Generate meta
|
||||||
|
const metaTitle = headline.substring(0, 60);
|
||||||
|
const metaDescription = stripHtml(fullContent).substring(0, 155) + '...';
|
||||||
|
|
||||||
|
// Count words
|
||||||
|
const wordCount = stripHtml(fullContent).split(/\s+/).length;
|
||||||
|
|
||||||
|
// Create article
|
||||||
|
const article = await directus.request(
|
||||||
|
createItem('generated_articles', {
|
||||||
|
site: campaign.site,
|
||||||
|
campaign: campaign_id,
|
||||||
|
headline: headline,
|
||||||
|
meta_title: metaTitle,
|
||||||
|
meta_description: metaDescription,
|
||||||
|
full_html_body: fullContent,
|
||||||
|
word_count: wordCount,
|
||||||
|
is_published: false,
|
||||||
|
is_test_batch: false,
|
||||||
|
date_published: pubDate.toISOString(),
|
||||||
|
date_modified: modDate.toISOString(),
|
||||||
|
sitemap_status: 'ghost',
|
||||||
|
location_city: context.city,
|
||||||
|
location_county: context.county,
|
||||||
|
location_state: context.state,
|
||||||
|
modules_used: modulesUsed
|
||||||
|
})
|
||||||
|
) as any;
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
article_id: article.id,
|
||||||
|
headline,
|
||||||
|
word_count: wordCount,
|
||||||
|
modules_used: modulesUsed.length,
|
||||||
|
dates: {
|
||||||
|
published: pubDate.toISOString(),
|
||||||
|
modified: modDate.toISOString()
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error assembling article:', error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Failed to assemble article' }),
|
||||||
|
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process spintax syntax: {option1|option2|option3}
|
||||||
|
*/
|
||||||
|
function processSpintax(text: string): string {
|
||||||
|
// Match nested spintax from innermost to outermost
|
||||||
|
let result = text;
|
||||||
|
let maxIterations = 100;
|
||||||
|
|
||||||
|
while (result.includes('{') && maxIterations > 0) {
|
||||||
|
result = result.replace(/\{([^{}]+)\}/g, (match, options) => {
|
||||||
|
const choices = options.split('|');
|
||||||
|
return choices[Math.floor(Math.random() * choices.length)];
|
||||||
|
});
|
||||||
|
maxIterations--;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate headline with spintax and tokens
|
||||||
|
*/
|
||||||
|
function generateHeadline(template: string | null, context: any, date: Date): string {
|
||||||
|
if (!template) return '';
|
||||||
|
|
||||||
|
let headline = template
|
||||||
|
.replace(/\{City\}/gi, context.city)
|
||||||
|
.replace(/\{State\}/gi, context.state)
|
||||||
|
.replace(/\{County\}/gi, context.county);
|
||||||
|
|
||||||
|
headline = replaceYearTokens(headline, date);
|
||||||
|
headline = processSpintax(headline);
|
||||||
|
|
||||||
|
return headline;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripHtml(html: string): string {
|
||||||
|
return html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStateCode(state: string): string {
|
||||||
|
const codes: Record<string, string> = {
|
||||||
|
'Alabama': 'AL', 'Alaska': 'AK', 'Arizona': 'AZ', 'Arkansas': 'AR',
|
||||||
|
'California': 'CA', 'Colorado': 'CO', 'Connecticut': 'CT', 'Delaware': 'DE',
|
||||||
|
'Florida': 'FL', 'Georgia': 'GA', 'Hawaii': 'HI', 'Idaho': 'ID',
|
||||||
|
'Illinois': 'IL', 'Indiana': 'IN', 'Iowa': 'IA', 'Kansas': 'KS',
|
||||||
|
'Kentucky': 'KY', 'Louisiana': 'LA', 'Maine': 'ME', 'Maryland': 'MD',
|
||||||
|
'Massachusetts': 'MA', 'Michigan': 'MI', 'Minnesota': 'MN', 'Mississippi': 'MS',
|
||||||
|
'Missouri': 'MO', 'Montana': 'MT', 'Nebraska': 'NE', 'Nevada': 'NV',
|
||||||
|
'New Hampshire': 'NH', 'New Jersey': 'NJ', 'New Mexico': 'NM', 'New York': 'NY',
|
||||||
|
'North Carolina': 'NC', 'North Dakota': 'ND', 'Ohio': 'OH', 'Oklahoma': 'OK',
|
||||||
|
'Oregon': 'OR', 'Pennsylvania': 'PA', 'Rhode Island': 'RI', 'South Carolina': 'SC',
|
||||||
|
'South Dakota': 'SD', 'Tennessee': 'TN', 'Texas': 'TX', 'Utah': 'UT',
|
||||||
|
'Vermont': 'VT', 'Virginia': 'VA', 'Washington': 'WA', 'West Virginia': 'WV',
|
||||||
|
'Wisconsin': 'WI', 'Wyoming': 'WY'
|
||||||
|
};
|
||||||
|
return codes[state] || '';
|
||||||
|
}
|
||||||
318
god-mode/src/pages/api/seo/generate-article.ts
Normal file
318
god-mode/src/pages/api/seo/generate-article.ts
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
// @ts-ignore - Astro types available at build time
|
||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { getDirectusClient, readItems, readItem, createItem, updateItem } from '@/lib/directus/client';
|
||||||
|
import { parseSpintaxRandom, injectVariables } from '@/lib/seo/cartesian';
|
||||||
|
import { generateFeaturedImage, type ImageTemplate } from '@/lib/seo/image-generator';
|
||||||
|
import type { VariableMap } from '@/types/cartesian';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fragment types for the 6-pillar content structure + intro and FAQ
|
||||||
|
*/
|
||||||
|
const DEFAULT_STRUCTURE = [
|
||||||
|
'intro_hook',
|
||||||
|
'pillar_1_keyword',
|
||||||
|
'pillar_2_uniqueness',
|
||||||
|
'pillar_3_relevance',
|
||||||
|
'pillar_4_quality',
|
||||||
|
'pillar_5_authority',
|
||||||
|
'pillar_6_backlinks',
|
||||||
|
'faq_section'
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count words in text (strip HTML first)
|
||||||
|
*/
|
||||||
|
function countWords(text: string): number {
|
||||||
|
return text.replace(/<[^>]*>/g, '').split(/\s+/).filter(Boolean).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate Article API
|
||||||
|
*
|
||||||
|
* Assembles SEO articles by:
|
||||||
|
* 1. Pulling an available headline from inventory
|
||||||
|
* 2. Fetching location data for variable injection
|
||||||
|
* 3. Selecting random fragments for each 6-pillar section
|
||||||
|
* 4. Processing spintax within fragments (random selection)
|
||||||
|
* 5. Injecting all variables (niche + location)
|
||||||
|
* 6. Stitching into full HTML body
|
||||||
|
*/
|
||||||
|
export const POST: APIRoute = async ({ request, locals }) => {
|
||||||
|
try {
|
||||||
|
const data = await request.json();
|
||||||
|
const { campaign_id, batch_size = 1 } = data;
|
||||||
|
const siteId = locals.siteId;
|
||||||
|
|
||||||
|
if (!campaign_id) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Campaign ID is required' }),
|
||||||
|
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const directus = getDirectusClient();
|
||||||
|
|
||||||
|
// Get campaign configuration
|
||||||
|
const campaigns = await directus.request(
|
||||||
|
readItems('campaign_masters', {
|
||||||
|
filter: { id: { _eq: campaign_id } },
|
||||||
|
limit: 1,
|
||||||
|
fields: ['*']
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!campaigns?.length) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Campaign not found' }),
|
||||||
|
{ status: 404, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const campaign = campaigns[0] as any;
|
||||||
|
const nicheVariables: VariableMap = campaign.niche_variables || {};
|
||||||
|
const generatedArticles = [];
|
||||||
|
const effectiveBatchSize = Math.min(batch_size, 50);
|
||||||
|
|
||||||
|
for (let i = 0; i < effectiveBatchSize; i++) {
|
||||||
|
// Get next available headline
|
||||||
|
const headlines = await directus.request(
|
||||||
|
readItems('headline_inventory', {
|
||||||
|
filter: {
|
||||||
|
campaign: { _eq: campaign_id },
|
||||||
|
status: { _eq: 'available' }
|
||||||
|
},
|
||||||
|
limit: 1,
|
||||||
|
fields: ['id', 'final_title_text', 'location_data']
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!headlines?.length) {
|
||||||
|
break; // No more headlines available
|
||||||
|
}
|
||||||
|
|
||||||
|
const headline = headlines[0] as any;
|
||||||
|
|
||||||
|
// Get location variables (from headline or fetch fresh)
|
||||||
|
let locationVars: VariableMap = {};
|
||||||
|
|
||||||
|
if (headline.location_data) {
|
||||||
|
// Use location from headline (set during headline generation)
|
||||||
|
const loc = headline.location_data;
|
||||||
|
locationVars = {
|
||||||
|
city: loc.city || '',
|
||||||
|
county: loc.county || '',
|
||||||
|
state: loc.state || '',
|
||||||
|
state_code: loc.stateCode || ''
|
||||||
|
};
|
||||||
|
} else if (campaign.location_mode === 'city') {
|
||||||
|
// Fetch random city
|
||||||
|
const cities = await directus.request(
|
||||||
|
readItems('locations_cities', {
|
||||||
|
limit: 1,
|
||||||
|
offset: Math.floor(Math.random() * 100),
|
||||||
|
fields: ['name', 'population', { county: ['name'] }, { state: ['name', 'code'] }]
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (cities?.length) {
|
||||||
|
const city = cities[0] as any;
|
||||||
|
locationVars = {
|
||||||
|
city: city.name,
|
||||||
|
county: city.county?.name || '',
|
||||||
|
state: city.state?.name || '',
|
||||||
|
state_code: city.state?.code || '',
|
||||||
|
population: String(city.population || '')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else if (campaign.location_mode === 'county') {
|
||||||
|
const counties = await directus.request(
|
||||||
|
readItems('locations_counties', {
|
||||||
|
limit: 1,
|
||||||
|
offset: Math.floor(Math.random() * 100),
|
||||||
|
fields: ['name', { state: ['name', 'code'] }]
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (counties?.length) {
|
||||||
|
const county = counties[0] as any;
|
||||||
|
locationVars = {
|
||||||
|
county: county.name,
|
||||||
|
state: county.state?.name || '',
|
||||||
|
state_code: county.state?.code || ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else if (campaign.location_mode === 'state') {
|
||||||
|
const states = await directus.request(
|
||||||
|
readItems('locations_states', {
|
||||||
|
limit: 1,
|
||||||
|
offset: Math.floor(Math.random() * 50),
|
||||||
|
fields: ['name', 'code']
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (states?.length) {
|
||||||
|
const state = states[0] as any;
|
||||||
|
locationVars = {
|
||||||
|
state: state.name,
|
||||||
|
state_code: state.code
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge all variables for injection
|
||||||
|
const allVariables: VariableMap = { ...nicheVariables, ...locationVars };
|
||||||
|
|
||||||
|
// Assemble article from fragments
|
||||||
|
const fragments: string[] = [];
|
||||||
|
|
||||||
|
// Determine Structure (Blueprint)
|
||||||
|
let structure: string[] = DEFAULT_STRUCTURE;
|
||||||
|
if (campaign.article_template) {
|
||||||
|
try {
|
||||||
|
const template = await directus.request(readItem('article_templates', campaign.article_template));
|
||||||
|
if (template && Array.isArray(template.structure_json)) {
|
||||||
|
structure = template.structure_json;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`Failed to load template ${campaign.article_template}, using default.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const fragmentType of structure) {
|
||||||
|
const typeFragments = await directus.request(
|
||||||
|
readItems('content_fragments', {
|
||||||
|
filter: {
|
||||||
|
fragment_type: { _eq: fragmentType },
|
||||||
|
_or: [
|
||||||
|
{ campaign: { _eq: campaign_id } },
|
||||||
|
{ campaign: { name: { _eq: 'Master Content Library' } } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
fields: ['content_body']
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (typeFragments?.length) {
|
||||||
|
// Pick random fragment for variation
|
||||||
|
const randomFragment = typeFragments[
|
||||||
|
Math.floor(Math.random() * typeFragments.length)
|
||||||
|
] as any;
|
||||||
|
|
||||||
|
let content = randomFragment.content_body;
|
||||||
|
|
||||||
|
// Process spintax (random selection within fragments)
|
||||||
|
content = parseSpintaxRandom(content);
|
||||||
|
|
||||||
|
// Inject all variables
|
||||||
|
content = injectVariables(content, allVariables);
|
||||||
|
|
||||||
|
fragments.push(content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assemble full article HTML
|
||||||
|
const fullHtmlBody = fragments.join('\n\n');
|
||||||
|
const wordCount = countWords(fullHtmlBody);
|
||||||
|
|
||||||
|
// Generate meta title and description
|
||||||
|
const processedHeadline = injectVariables(headline.final_title_text, allVariables);
|
||||||
|
const metaTitle = processedHeadline.substring(0, 70);
|
||||||
|
const metaDescription = fragments[0]
|
||||||
|
? fragments[0].replace(/<[^>]*>/g, '').substring(0, 155)
|
||||||
|
: metaTitle;
|
||||||
|
|
||||||
|
// Generate featured image from template
|
||||||
|
const featuredImage = generateFeaturedImage({
|
||||||
|
title: processedHeadline,
|
||||||
|
subtitle: locationVars.city
|
||||||
|
? `${locationVars.city}, ${locationVars.state_code || locationVars.state}`
|
||||||
|
: undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate JSON-LD Schema
|
||||||
|
const schemaJson = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "Article",
|
||||||
|
"headline": processedHeadline,
|
||||||
|
"description": metaDescription,
|
||||||
|
"wordCount": wordCount,
|
||||||
|
"datePublished": new Date().toISOString(),
|
||||||
|
"author": {
|
||||||
|
"@type": "Organization",
|
||||||
|
"name": locationVars.state ? `${locationVars.state} Services` : "Local Service Provider"
|
||||||
|
},
|
||||||
|
"image": featuredImage.filename ? `/assets/content/${featuredImage.filename}` : undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check Word Count Goal
|
||||||
|
const targetWordCount = campaign.target_word_count || 1500;
|
||||||
|
const wordCountStatus = wordCount >= targetWordCount ? 'optimal' : 'under_target';
|
||||||
|
|
||||||
|
// Create article record with featured image and schema
|
||||||
|
const article = await directus.request(
|
||||||
|
createItem('generated_articles', {
|
||||||
|
site: siteId || campaign.site,
|
||||||
|
campaign: campaign_id,
|
||||||
|
headline: processedHeadline,
|
||||||
|
meta_title: metaTitle,
|
||||||
|
meta_description: metaDescription,
|
||||||
|
full_html_body: fullHtmlBody,
|
||||||
|
word_count: wordCount,
|
||||||
|
word_count_status: wordCountStatus,
|
||||||
|
is_published: false,
|
||||||
|
location_city: locationVars.city || null,
|
||||||
|
location_county: locationVars.county || null,
|
||||||
|
location_state: locationVars.state || null,
|
||||||
|
featured_image_svg: featuredImage.svg,
|
||||||
|
featured_image_filename: featuredImage.filename,
|
||||||
|
featured_image_alt: featuredImage.alt,
|
||||||
|
schema_json: schemaJson
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mark headline as used
|
||||||
|
await directus.request(
|
||||||
|
updateItem('headline_inventory', headline.id, {
|
||||||
|
status: 'used',
|
||||||
|
used_on_article: (article as any).id
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
generatedArticles.push({
|
||||||
|
id: (article as any).id,
|
||||||
|
headline: processedHeadline,
|
||||||
|
word_count: wordCount,
|
||||||
|
location: locationVars.city || locationVars.county || locationVars.state || null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get remaining available headlines count
|
||||||
|
const remainingHeadlines = await directus.request(
|
||||||
|
readItems('headline_inventory', {
|
||||||
|
filter: {
|
||||||
|
campaign: { _eq: campaign_id },
|
||||||
|
status: { _eq: 'available' }
|
||||||
|
},
|
||||||
|
aggregate: { count: '*' }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const remainingCount = (remainingHeadlines as any)?.[0]?.count || 0;
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
generated: generatedArticles.length,
|
||||||
|
articles: generatedArticles,
|
||||||
|
remaining_headlines: parseInt(remainingCount, 10)
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating article:', error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Failed to generate article' }),
|
||||||
|
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
380
god-mode/src/pages/api/seo/generate-headlines.ts
Normal file
380
god-mode/src/pages/api/seo/generate-headlines.ts
Normal file
@@ -0,0 +1,380 @@
|
|||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { getDirectusClient, readItems, createItem } from '@/lib/directus/client';
|
||||||
|
import {
|
||||||
|
extractSpintaxSlots,
|
||||||
|
calculateTotalCombinations,
|
||||||
|
generateWithLocations,
|
||||||
|
getCartesianMetadata,
|
||||||
|
explodeSpintax
|
||||||
|
} from '@/lib/seo/cartesian';
|
||||||
|
import type { LocationEntry, CartesianResult } from '@/types/cartesian';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate Headlines API
|
||||||
|
*
|
||||||
|
* Generates all Cartesian product combinations from:
|
||||||
|
* - Campaign spintax template
|
||||||
|
* - Location data (if location_mode is set)
|
||||||
|
*
|
||||||
|
* Uses the n^k formula where:
|
||||||
|
* - n = number of options per spintax slot
|
||||||
|
* - k = number of slots
|
||||||
|
* - Final total = (n₁ × n₂ × ... × nₖ) × location_count
|
||||||
|
*/
|
||||||
|
export const POST: APIRoute = async ({ request }) => {
|
||||||
|
try {
|
||||||
|
const data = await request.json();
|
||||||
|
const {
|
||||||
|
campaign_id,
|
||||||
|
max_headlines = 10000,
|
||||||
|
batch_size = 500,
|
||||||
|
offset = 0
|
||||||
|
} = data;
|
||||||
|
|
||||||
|
if (!campaign_id) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Campaign ID is required' }),
|
||||||
|
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const directus = getDirectusClient();
|
||||||
|
|
||||||
|
// Get campaign
|
||||||
|
const campaigns = await directus.request(
|
||||||
|
readItems('campaign_masters', {
|
||||||
|
filter: { id: { _eq: campaign_id } },
|
||||||
|
limit: 1,
|
||||||
|
fields: [
|
||||||
|
'id',
|
||||||
|
'headline_spintax_root',
|
||||||
|
'niche_variables',
|
||||||
|
'location_mode',
|
||||||
|
'location_target'
|
||||||
|
]
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!campaigns?.length) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Campaign not found' }),
|
||||||
|
{ status: 404, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const campaign = campaigns[0] as any;
|
||||||
|
const spintax = campaign.headline_spintax_root;
|
||||||
|
const nicheVariables = campaign.niche_variables || {};
|
||||||
|
const locationMode = campaign.location_mode || 'none';
|
||||||
|
|
||||||
|
// Fetch locations based on mode
|
||||||
|
let locations: LocationEntry[] = [];
|
||||||
|
|
||||||
|
if (locationMode !== 'none') {
|
||||||
|
locations = await fetchLocations(
|
||||||
|
directus,
|
||||||
|
locationMode,
|
||||||
|
campaign.location_target
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate metadata BEFORE generation
|
||||||
|
const metadata = getCartesianMetadata(
|
||||||
|
spintax,
|
||||||
|
locations.length,
|
||||||
|
max_headlines
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check existing headlines to avoid duplicates
|
||||||
|
const existing = await directus.request(
|
||||||
|
readItems('headline_inventory', {
|
||||||
|
filter: { campaign: { _eq: campaign_id } },
|
||||||
|
fields: ['final_title_text']
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const existingTitles = new Set(
|
||||||
|
existing?.map((h: any) => h.final_title_text) || []
|
||||||
|
);
|
||||||
|
|
||||||
|
// Generate Cartesian product headlines
|
||||||
|
const generator = generateWithLocations(
|
||||||
|
spintax,
|
||||||
|
locations,
|
||||||
|
nicheVariables,
|
||||||
|
{ maxCombinations: max_headlines, offset }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Insert new headlines in batches
|
||||||
|
let insertedCount = 0;
|
||||||
|
let skippedCount = 0;
|
||||||
|
let processedCount = 0;
|
||||||
|
|
||||||
|
const batch: CartesianResult[] = [];
|
||||||
|
|
||||||
|
for (const result of generator) {
|
||||||
|
processedCount++;
|
||||||
|
|
||||||
|
if (existingTitles.has(result.text)) {
|
||||||
|
skippedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
batch.push(result);
|
||||||
|
|
||||||
|
// Insert batch when full
|
||||||
|
if (batch.length >= batch_size) {
|
||||||
|
insertedCount += await insertHeadlineBatch(
|
||||||
|
directus,
|
||||||
|
campaign_id,
|
||||||
|
batch
|
||||||
|
);
|
||||||
|
batch.length = 0; // Clear batch
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safety limit
|
||||||
|
if (insertedCount >= max_headlines) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert remaining batch
|
||||||
|
if (batch.length > 0) {
|
||||||
|
insertedCount += await insertHeadlineBatch(
|
||||||
|
directus,
|
||||||
|
campaign_id,
|
||||||
|
batch
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
metadata: {
|
||||||
|
template: spintax,
|
||||||
|
slotCount: metadata.slotCount,
|
||||||
|
spintaxCombinations: metadata.totalSpintaxCombinations,
|
||||||
|
locationCount: locations.length,
|
||||||
|
totalPossible: metadata.totalPossibleCombinations,
|
||||||
|
wasTruncated: metadata.wasTruncated
|
||||||
|
},
|
||||||
|
results: {
|
||||||
|
processed: processedCount,
|
||||||
|
inserted: insertedCount,
|
||||||
|
skipped: skippedCount,
|
||||||
|
alreadyExisted: existingTitles.size
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating headlines:', error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Failed to generate headlines' }),
|
||||||
|
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch locations based on mode and optional target filter
|
||||||
|
*/
|
||||||
|
async function fetchLocations(
|
||||||
|
directus: any,
|
||||||
|
mode: string,
|
||||||
|
targetId?: string
|
||||||
|
): Promise<LocationEntry[]> {
|
||||||
|
try {
|
||||||
|
switch (mode) {
|
||||||
|
case 'state': {
|
||||||
|
const filter: any = targetId
|
||||||
|
? { id: { _eq: targetId } }
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const states = await directus.request(
|
||||||
|
readItems('locations_states', {
|
||||||
|
filter,
|
||||||
|
fields: ['id', 'name', 'code'],
|
||||||
|
limit: 100
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return (states || []).map((s: any) => ({
|
||||||
|
id: s.id,
|
||||||
|
state: s.name,
|
||||||
|
stateCode: s.code
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'county': {
|
||||||
|
const filter: any = targetId
|
||||||
|
? { state: { _eq: targetId } }
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const counties = await directus.request(
|
||||||
|
readItems('locations_counties', {
|
||||||
|
filter,
|
||||||
|
fields: ['id', 'name', 'population', { state: ['name', 'code'] }],
|
||||||
|
sort: ['-population'],
|
||||||
|
limit: 500
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return (counties || []).map((c: any) => ({
|
||||||
|
id: c.id,
|
||||||
|
county: c.name,
|
||||||
|
state: c.state?.name || '',
|
||||||
|
stateCode: c.state?.code || '',
|
||||||
|
population: c.population
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'city': {
|
||||||
|
const filter: any = {};
|
||||||
|
|
||||||
|
// If target is set, filter to that state's cities
|
||||||
|
if (targetId) {
|
||||||
|
// Check if target is a state or county
|
||||||
|
const states = await directus.request(
|
||||||
|
readItems('locations_states', {
|
||||||
|
filter: { id: { _eq: targetId } },
|
||||||
|
limit: 1
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (states?.length) {
|
||||||
|
filter.state = { _eq: targetId };
|
||||||
|
} else {
|
||||||
|
filter.county = { _eq: targetId };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cities = await directus.request(
|
||||||
|
readItems('locations_cities', {
|
||||||
|
filter,
|
||||||
|
fields: [
|
||||||
|
'id',
|
||||||
|
'name',
|
||||||
|
'population',
|
||||||
|
{ county: ['name'] },
|
||||||
|
{ state: ['name', 'code'] }
|
||||||
|
],
|
||||||
|
sort: ['-population'],
|
||||||
|
limit: 1000 // Top 1000 cities
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return (cities || []).map((c: any) => ({
|
||||||
|
id: c.id,
|
||||||
|
city: c.name,
|
||||||
|
county: c.county?.name || '',
|
||||||
|
state: c.state?.name || '',
|
||||||
|
stateCode: c.state?.code || '',
|
||||||
|
population: c.population
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching locations:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert a batch of headlines into the database
|
||||||
|
*/
|
||||||
|
async function insertHeadlineBatch(
|
||||||
|
directus: any,
|
||||||
|
campaignId: string,
|
||||||
|
batch: CartesianResult[]
|
||||||
|
): Promise<number> {
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
for (const result of batch) {
|
||||||
|
try {
|
||||||
|
await directus.request(
|
||||||
|
createItem('headline_inventory', {
|
||||||
|
campaign: campaignId,
|
||||||
|
final_title_text: result.text,
|
||||||
|
status: 'available',
|
||||||
|
location_data: result.location || null
|
||||||
|
})
|
||||||
|
);
|
||||||
|
count++;
|
||||||
|
} catch (error) {
|
||||||
|
// Skip duplicates or errors
|
||||||
|
console.error('Failed to insert headline:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preview endpoint - shows what WOULD be generated without inserting
|
||||||
|
*/
|
||||||
|
export const GET: APIRoute = async ({ url }) => {
|
||||||
|
try {
|
||||||
|
const campaignId = url.searchParams.get('campaign_id');
|
||||||
|
const previewCount = parseInt(url.searchParams.get('preview') || '10');
|
||||||
|
|
||||||
|
if (!campaignId) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'campaign_id is required' }),
|
||||||
|
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const directus = getDirectusClient();
|
||||||
|
|
||||||
|
// Get campaign
|
||||||
|
const campaigns = await directus.request(
|
||||||
|
readItems('campaign_masters', {
|
||||||
|
filter: { id: { _eq: campaignId } },
|
||||||
|
limit: 1,
|
||||||
|
fields: ['headline_spintax_root', 'location_mode', 'location_target']
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!campaigns?.length) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Campaign not found' }),
|
||||||
|
{ status: 404, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const campaign = campaigns[0] as any;
|
||||||
|
const spintax = campaign.headline_spintax_root;
|
||||||
|
|
||||||
|
// Get location count
|
||||||
|
let locationCount = 1;
|
||||||
|
if (campaign.location_mode !== 'none') {
|
||||||
|
const locations = await fetchLocations(
|
||||||
|
directus,
|
||||||
|
campaign.location_mode,
|
||||||
|
campaign.location_target
|
||||||
|
);
|
||||||
|
locationCount = locations.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get metadata
|
||||||
|
const metadata = getCartesianMetadata(spintax, locationCount);
|
||||||
|
|
||||||
|
// Generate preview samples
|
||||||
|
const samples = explodeSpintax(spintax, previewCount);
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
metadata,
|
||||||
|
preview: samples
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error previewing headlines:', error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Failed to preview headlines' }),
|
||||||
|
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
177
god-mode/src/pages/api/seo/generate-test-batch.ts
Normal file
177
god-mode/src/pages/api/seo/generate-test-batch.ts
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
// @ts-ignore - Astro types available at build time
|
||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { getDirectusClient, readItem, readItems, createItem, updateItem } from '@/lib/directus/client';
|
||||||
|
import { replaceYearTokens } from '@/lib/seo/velocity-scheduler';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate Test Batch API
|
||||||
|
*
|
||||||
|
* Creates a small batch of articles for review before mass production.
|
||||||
|
*
|
||||||
|
* POST /api/seo/generate-test-batch
|
||||||
|
*/
|
||||||
|
export const POST: APIRoute = async ({ request }: { request: Request }) => {
|
||||||
|
try {
|
||||||
|
const data = await request.json();
|
||||||
|
const { queue_id, campaign_id, batch_size = 10 } = data;
|
||||||
|
|
||||||
|
if (!queue_id && !campaign_id) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'queue_id or campaign_id is required' }),
|
||||||
|
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const directus = getDirectusClient();
|
||||||
|
|
||||||
|
// Get queue entry
|
||||||
|
let queue: any;
|
||||||
|
if (queue_id) {
|
||||||
|
queue = await directus.request(readItem('production_queue', queue_id));
|
||||||
|
} else {
|
||||||
|
const queues = await directus.request(readItems('production_queue', {
|
||||||
|
filter: { campaign: { _eq: campaign_id }, status: { _eq: 'test_batch' } },
|
||||||
|
limit: 1
|
||||||
|
}));
|
||||||
|
queue = (queues as any[])?.[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!queue) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Queue not found' }),
|
||||||
|
{ status: 404, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get campaign
|
||||||
|
const campaign = await directus.request(
|
||||||
|
readItem('campaign_masters', queue.campaign)
|
||||||
|
) as any;
|
||||||
|
|
||||||
|
if (!campaign) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Campaign not found' }),
|
||||||
|
{ status: 404, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get schedule data (first N for test batch)
|
||||||
|
const scheduleData = queue.schedule_data || [];
|
||||||
|
const testSchedule = scheduleData.slice(0, batch_size);
|
||||||
|
|
||||||
|
if (testSchedule.length === 0) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'No schedule data found' }),
|
||||||
|
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get headline inventory for this campaign
|
||||||
|
const headlines = await directus.request(readItems('headline_inventory', {
|
||||||
|
filter: { campaign: { _eq: campaign.id }, is_used: { _eq: false } },
|
||||||
|
limit: batch_size
|
||||||
|
})) as any[];
|
||||||
|
|
||||||
|
if (headlines.length === 0) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'No unused headlines available. Generate headlines first.' }),
|
||||||
|
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate test articles
|
||||||
|
const generatedArticles: any[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < Math.min(batch_size, headlines.length, testSchedule.length); i++) {
|
||||||
|
const headline = headlines[i];
|
||||||
|
const schedule = testSchedule[i];
|
||||||
|
const publishDate = new Date(schedule.publish_date);
|
||||||
|
const modifiedDate = new Date(schedule.modified_date);
|
||||||
|
|
||||||
|
// Apply year tokens to headline
|
||||||
|
const processedHeadline = replaceYearTokens(headline.headline, publishDate);
|
||||||
|
|
||||||
|
// Generate article content (simplified - in production, use full content generation)
|
||||||
|
const article = await directus.request(
|
||||||
|
createItem('generated_articles', {
|
||||||
|
site: queue.site,
|
||||||
|
campaign: campaign.id,
|
||||||
|
headline: processedHeadline,
|
||||||
|
meta_title: processedHeadline.substring(0, 60),
|
||||||
|
meta_description: `Learn about ${processedHeadline}. Expert guide with actionable tips.`,
|
||||||
|
full_html_body: `<h1>${processedHeadline}</h1><p>Test batch article content. Full content will be generated on approval.</p>`,
|
||||||
|
word_count: 100,
|
||||||
|
is_published: false,
|
||||||
|
is_test_batch: true,
|
||||||
|
date_published: publishDate.toISOString(),
|
||||||
|
date_modified: modifiedDate.toISOString(),
|
||||||
|
sitemap_status: 'ghost',
|
||||||
|
location_city: headline.location_city || null,
|
||||||
|
location_state: headline.location_state || null
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mark headline as used
|
||||||
|
await directus.request(
|
||||||
|
updateItem('headline_inventory', headline.id, { is_used: true })
|
||||||
|
);
|
||||||
|
|
||||||
|
generatedArticles.push(article);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update queue status
|
||||||
|
await directus.request(
|
||||||
|
updateItem('production_queue', queue.id, {
|
||||||
|
status: 'test_batch',
|
||||||
|
completed_count: generatedArticles.length
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create review URL
|
||||||
|
const reviewUrl = `/admin/review-batch?queue=${queue.id}`;
|
||||||
|
|
||||||
|
// Update campaign with review URL
|
||||||
|
await directus.request(
|
||||||
|
updateItem('campaign_masters', campaign.id, {
|
||||||
|
test_batch_status: 'ready',
|
||||||
|
test_batch_review_url: reviewUrl
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Log work
|
||||||
|
await directus.request(
|
||||||
|
createItem('work_log', {
|
||||||
|
site: queue.site,
|
||||||
|
action: 'test_generated',
|
||||||
|
entity_type: 'production_queue',
|
||||||
|
entity_id: queue.id,
|
||||||
|
details: {
|
||||||
|
articles_created: generatedArticles.length,
|
||||||
|
review_url: reviewUrl
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
queue_id: queue.id,
|
||||||
|
articles_created: generatedArticles.length,
|
||||||
|
review_url: reviewUrl,
|
||||||
|
articles: generatedArticles.map(a => ({
|
||||||
|
id: a.id,
|
||||||
|
headline: a.headline,
|
||||||
|
date_published: a.date_published
|
||||||
|
})),
|
||||||
|
next_step: `Review articles at ${reviewUrl}, then call /api/seo/approve-batch to start full production`
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating test batch:', error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Failed to generate test batch' }),
|
||||||
|
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
150
god-mode/src/pages/api/seo/get-nearby.ts
Normal file
150
god-mode/src/pages/api/seo/get-nearby.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
// @ts-ignore - Astro types available at build time
|
||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { getDirectusClient, readItem, readItems } from '@/lib/directus/client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Nearby API
|
||||||
|
*
|
||||||
|
* Returns related articles in same county/state for "Nearby Locations" footer.
|
||||||
|
* Only returns articles that are indexed (not ghost).
|
||||||
|
*
|
||||||
|
* GET /api/seo/get-nearby?article_id={id}&limit=10
|
||||||
|
*/
|
||||||
|
export const GET: APIRoute = async ({ url }: { url: URL }) => {
|
||||||
|
try {
|
||||||
|
const articleId = url.searchParams.get('article_id');
|
||||||
|
const limit = parseInt(url.searchParams.get('limit') || '10', 10);
|
||||||
|
|
||||||
|
if (!articleId) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'article_id is required' }),
|
||||||
|
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const directus = getDirectusClient();
|
||||||
|
|
||||||
|
// Get the source article
|
||||||
|
const article = await directus.request(readItem('generated_articles', articleId)) as any;
|
||||||
|
|
||||||
|
if (!article) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Article not found' }),
|
||||||
|
{ status: 404, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nearbyArticles: any[] = [];
|
||||||
|
|
||||||
|
// Strategy 1: Find articles in same county (if article has county)
|
||||||
|
if (article.location_county) {
|
||||||
|
const countyArticles = await directus.request(readItems('generated_articles', {
|
||||||
|
filter: {
|
||||||
|
site: { _eq: article.site },
|
||||||
|
location_county: { _eq: article.location_county },
|
||||||
|
id: { _neq: articleId },
|
||||||
|
sitemap_status: { _eq: 'indexed' }, // PATCH 3: Only indexed articles
|
||||||
|
is_published: { _eq: true }
|
||||||
|
},
|
||||||
|
sort: ['-date_published'],
|
||||||
|
limit: limit,
|
||||||
|
fields: ['id', 'headline', 'location_city', 'location_county', 'location_state']
|
||||||
|
})) as any[];
|
||||||
|
|
||||||
|
nearbyArticles.push(...countyArticles);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 2: If not enough, find articles in same state
|
||||||
|
if (nearbyArticles.length < limit && article.location_state) {
|
||||||
|
const remaining = limit - nearbyArticles.length;
|
||||||
|
const existingIds = [articleId, ...nearbyArticles.map(a => a.id)];
|
||||||
|
|
||||||
|
const stateArticles = await directus.request(readItems('generated_articles', {
|
||||||
|
filter: {
|
||||||
|
site: { _eq: article.site },
|
||||||
|
location_state: { _eq: article.location_state },
|
||||||
|
id: { _nin: existingIds },
|
||||||
|
sitemap_status: { _eq: 'indexed' },
|
||||||
|
is_published: { _eq: true }
|
||||||
|
},
|
||||||
|
sort: ['location_city'], // Alphabetical by city
|
||||||
|
limit: remaining,
|
||||||
|
fields: ['id', 'headline', 'location_city', 'location_county', 'location_state']
|
||||||
|
})) as any[];
|
||||||
|
|
||||||
|
nearbyArticles.push(...stateArticles);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get parent hub if exists
|
||||||
|
let parentHub = null;
|
||||||
|
if (article.parent_hub) {
|
||||||
|
const hub = await directus.request(readItem('hub_pages', article.parent_hub)) as any;
|
||||||
|
if (hub && hub.sitemap_status === 'indexed') {
|
||||||
|
parentHub = {
|
||||||
|
id: hub.id,
|
||||||
|
title: hub.title_template,
|
||||||
|
slug: hub.slug_pattern,
|
||||||
|
level: hub.level
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get state hub for breadcrumb
|
||||||
|
let stateHub = null;
|
||||||
|
if (article.location_state) {
|
||||||
|
const hubs = await directus.request(readItems('hub_pages', {
|
||||||
|
filter: {
|
||||||
|
site: { _eq: article.site },
|
||||||
|
level: { _eq: 'state' },
|
||||||
|
sitemap_status: { _eq: 'indexed' }
|
||||||
|
},
|
||||||
|
limit: 1
|
||||||
|
})) as any[];
|
||||||
|
|
||||||
|
if (hubs.length > 0) {
|
||||||
|
stateHub = {
|
||||||
|
id: hubs[0].id,
|
||||||
|
title: hubs[0].title_template?.replace('{State}', article.location_state),
|
||||||
|
slug: hubs[0].slug_pattern?.replace('{state-slug}', slugify(article.location_state))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
article_id: articleId,
|
||||||
|
location: {
|
||||||
|
city: article.location_city,
|
||||||
|
county: article.location_county,
|
||||||
|
state: article.location_state
|
||||||
|
},
|
||||||
|
nearby: nearbyArticles.map(a => ({
|
||||||
|
id: a.id,
|
||||||
|
headline: a.headline,
|
||||||
|
city: a.location_city,
|
||||||
|
county: a.location_county
|
||||||
|
})),
|
||||||
|
parent_hub: parentHub,
|
||||||
|
state_hub: stateHub,
|
||||||
|
breadcrumb: [
|
||||||
|
{ name: 'Home', url: '/' },
|
||||||
|
stateHub ? { name: stateHub.title, url: stateHub.slug } : null,
|
||||||
|
parentHub ? { name: parentHub.title, url: parentHub.slug } : null,
|
||||||
|
{ name: article.headline, url: null }
|
||||||
|
].filter(Boolean)
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting nearby:', error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Failed to get nearby articles' }),
|
||||||
|
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function slugify(str: string): string {
|
||||||
|
return str.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
||||||
|
}
|
||||||
166
god-mode/src/pages/api/seo/insert-links.ts
Normal file
166
god-mode/src/pages/api/seo/insert-links.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
// @ts-ignore - Astro types available at build time
|
||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { getDirectusClient, readItem, readItems, updateItem, createItem } from '@/lib/directus/client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert Links API
|
||||||
|
*
|
||||||
|
* Scans article content and inserts internal links based on link_targets rules.
|
||||||
|
* Respects temporal linking (2023 articles can't link to 2025 articles).
|
||||||
|
*
|
||||||
|
* POST /api/seo/insert-links
|
||||||
|
*/
|
||||||
|
export const POST: APIRoute = async ({ request }: { request: Request }) => {
|
||||||
|
try {
|
||||||
|
const data = await request.json();
|
||||||
|
const { article_id, max_links = 5 } = data;
|
||||||
|
|
||||||
|
if (!article_id) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'article_id is required' }),
|
||||||
|
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const directus = getDirectusClient();
|
||||||
|
|
||||||
|
// Get article
|
||||||
|
const article = await directus.request(readItem('generated_articles', article_id)) as any;
|
||||||
|
|
||||||
|
if (!article) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Article not found' }),
|
||||||
|
{ status: 404, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const articleDate = new Date(article.date_published);
|
||||||
|
const articleModified = article.date_modified ? new Date(article.date_modified) : null;
|
||||||
|
|
||||||
|
// Get link targets for this site, sorted by priority
|
||||||
|
const linkTargets = await directus.request(readItems('link_targets', {
|
||||||
|
filter: {
|
||||||
|
site: { _eq: article.site },
|
||||||
|
is_active: { _eq: true }
|
||||||
|
},
|
||||||
|
sort: ['-priority']
|
||||||
|
})) as any[];
|
||||||
|
|
||||||
|
if (linkTargets.length === 0) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
links_inserted: 0,
|
||||||
|
message: 'No link targets defined for this site'
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = article.full_html_body || '';
|
||||||
|
let linksInserted = 0;
|
||||||
|
const insertedAnchors: string[] = [];
|
||||||
|
|
||||||
|
for (const target of linkTargets) {
|
||||||
|
if (linksInserted >= max_links) break;
|
||||||
|
|
||||||
|
// Check temporal linking rules
|
||||||
|
if (!target.is_hub && target.target_post) {
|
||||||
|
// Get the target post's date
|
||||||
|
const targetPost = await directus.request(readItem('posts', target.target_post)) as any;
|
||||||
|
if (targetPost) {
|
||||||
|
const targetDate = new Date(targetPost.date_published || targetPost.date_created);
|
||||||
|
|
||||||
|
// Can't link to posts "published" after this article
|
||||||
|
// Unless this article has a recent modified date
|
||||||
|
const recentModified = articleModified &&
|
||||||
|
(new Date().getTime() - articleModified.getTime()) < 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||||
|
|
||||||
|
if (targetDate > articleDate && !recentModified) {
|
||||||
|
continue; // Skip this link target
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build anchor variations
|
||||||
|
const anchors = [target.anchor_text];
|
||||||
|
if (target.anchor_variations && Array.isArray(target.anchor_variations)) {
|
||||||
|
anchors.push(...target.anchor_variations);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find and replace anchors in content
|
||||||
|
let insertedForThisTarget = 0;
|
||||||
|
const maxPerArticle = target.max_per_article || 2;
|
||||||
|
|
||||||
|
for (const anchor of anchors) {
|
||||||
|
if (insertedForThisTarget >= maxPerArticle) break;
|
||||||
|
if (linksInserted >= max_links) break;
|
||||||
|
|
||||||
|
// Case-insensitive regex that doesn't match already-linked text
|
||||||
|
// Negative lookbehind for existing links
|
||||||
|
const regex = new RegExp(
|
||||||
|
`(?<!<a[^>]*>)\\b(${escapeRegex(anchor)})\\b(?![^<]*</a>)`,
|
||||||
|
'i'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (regex.test(content)) {
|
||||||
|
const targetUrl = target.target_url ||
|
||||||
|
(target.target_post ? `/posts/${target.target_post}` : null);
|
||||||
|
|
||||||
|
if (targetUrl) {
|
||||||
|
content = content.replace(regex, `<a href="${targetUrl}">$1</a>`);
|
||||||
|
linksInserted++;
|
||||||
|
insertedForThisTarget++;
|
||||||
|
insertedAnchors.push(anchor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update article with linked content
|
||||||
|
if (linksInserted > 0) {
|
||||||
|
await directus.request(
|
||||||
|
updateItem('generated_articles', article_id, {
|
||||||
|
full_html_body: content
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Log work
|
||||||
|
await directus.request(
|
||||||
|
createItem('work_log', {
|
||||||
|
site: article.site,
|
||||||
|
action: 'links_inserted',
|
||||||
|
entity_type: 'generated_article',
|
||||||
|
entity_id: article_id,
|
||||||
|
details: {
|
||||||
|
links_inserted: linksInserted,
|
||||||
|
anchors: insertedAnchors
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
article_id,
|
||||||
|
links_inserted: linksInserted,
|
||||||
|
anchors_used: insertedAnchors
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error inserting links:', error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Failed to insert links' }),
|
||||||
|
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape special regex characters
|
||||||
|
*/
|
||||||
|
function escapeRegex(str: string): string {
|
||||||
|
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
290
god-mode/src/pages/api/seo/process-queue.ts
Normal file
290
god-mode/src/pages/api/seo/process-queue.ts
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
// @ts-ignore - Astro types available at build time
|
||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { getDirectusClient, readItem, readItems, updateItem, createItem } from '@/lib/directus/client';
|
||||||
|
import { replaceYearTokens } from '@/lib/seo/velocity-scheduler';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process Queue API
|
||||||
|
*
|
||||||
|
* Runs the factory: generates all scheduled articles for an approved queue.
|
||||||
|
* Can be called by cron or manually (with limits per call).
|
||||||
|
*
|
||||||
|
* POST /api/seo/process-queue
|
||||||
|
*/
|
||||||
|
export const POST: APIRoute = async ({ request }: { request: Request }) => {
|
||||||
|
try {
|
||||||
|
const data = await request.json();
|
||||||
|
const { queue_id, batch_limit = 100 } = data;
|
||||||
|
|
||||||
|
if (!queue_id) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'queue_id is required' }),
|
||||||
|
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const directus = getDirectusClient();
|
||||||
|
|
||||||
|
// Get queue
|
||||||
|
const queue = await directus.request(readItem('production_queue', queue_id)) as any;
|
||||||
|
|
||||||
|
if (!queue) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Queue not found' }),
|
||||||
|
{ status: 404, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (queue.status !== 'approved' && queue.status !== 'running') {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Queue must be approved to process' }),
|
||||||
|
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as running
|
||||||
|
await directus.request(
|
||||||
|
updateItem('production_queue', queue_id, {
|
||||||
|
status: 'running',
|
||||||
|
started_at: queue.started_at || new Date().toISOString()
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get campaign
|
||||||
|
const campaign = await directus.request(
|
||||||
|
readItem('campaign_masters', queue.campaign)
|
||||||
|
) as any;
|
||||||
|
|
||||||
|
// Get schedule data
|
||||||
|
const scheduleData = queue.schedule_data || [];
|
||||||
|
const startIndex = queue.completed_count || 0;
|
||||||
|
const endIndex = Math.min(startIndex + batch_limit, scheduleData.length);
|
||||||
|
const batchSchedule = scheduleData.slice(startIndex, endIndex);
|
||||||
|
|
||||||
|
if (batchSchedule.length === 0) {
|
||||||
|
// All done!
|
||||||
|
await directus.request(
|
||||||
|
updateItem('production_queue', queue_id, {
|
||||||
|
status: 'done',
|
||||||
|
completed_at: new Date().toISOString()
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
message: 'Queue complete',
|
||||||
|
total_generated: queue.completed_count
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get locations based on filter
|
||||||
|
const locationFilter = campaign.target_locations_filter || {};
|
||||||
|
const locations = await directus.request(readItems('locations_cities', {
|
||||||
|
filter: locationFilter,
|
||||||
|
limit: batchSchedule.length,
|
||||||
|
offset: startIndex
|
||||||
|
})) as any[];
|
||||||
|
|
||||||
|
// Get recipe
|
||||||
|
const recipe = campaign.content_recipe || ['intro', 'benefits', 'howto', 'conclusion'];
|
||||||
|
|
||||||
|
let generated = 0;
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < batchSchedule.length; i++) {
|
||||||
|
const schedule = batchSchedule[i];
|
||||||
|
const location = locations[i] || locations[i % locations.length];
|
||||||
|
|
||||||
|
if (!location) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pubDate = new Date(schedule.publish_date);
|
||||||
|
const modDate = new Date(schedule.modified_date);
|
||||||
|
|
||||||
|
const context = {
|
||||||
|
city: location.city || location.name || '',
|
||||||
|
state: location.state || '',
|
||||||
|
county: location.county || '',
|
||||||
|
state_code: getStateCode(location.state) || ''
|
||||||
|
};
|
||||||
|
|
||||||
|
// Assemble content from modules
|
||||||
|
const { content, modulesUsed } = await assembleFromModules(
|
||||||
|
directus, campaign.site, recipe, context, pubDate
|
||||||
|
);
|
||||||
|
|
||||||
|
// Generate headline
|
||||||
|
const headline = generateHeadline(campaign.spintax_title, context, pubDate) ||
|
||||||
|
`${context.city} ${campaign.name || 'Guide'}`;
|
||||||
|
|
||||||
|
const wordCount = content.replace(/<[^>]*>/g, ' ').split(/\s+/).length;
|
||||||
|
|
||||||
|
// Create article
|
||||||
|
await directus.request(
|
||||||
|
createItem('generated_articles', {
|
||||||
|
site: queue.site,
|
||||||
|
campaign: campaign.id,
|
||||||
|
headline: headline,
|
||||||
|
meta_title: headline.substring(0, 60),
|
||||||
|
meta_description: content.replace(/<[^>]*>/g, ' ').substring(0, 155) + '...',
|
||||||
|
full_html_body: content,
|
||||||
|
word_count: wordCount,
|
||||||
|
is_published: true, // Ghost published
|
||||||
|
is_test_batch: false,
|
||||||
|
date_published: pubDate.toISOString(),
|
||||||
|
date_modified: modDate.toISOString(),
|
||||||
|
sitemap_status: 'ghost',
|
||||||
|
location_city: context.city,
|
||||||
|
location_county: context.county,
|
||||||
|
location_state: context.state,
|
||||||
|
modules_used: modulesUsed
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
generated++;
|
||||||
|
} catch (err: any) {
|
||||||
|
errors.push(`Article ${i}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update queue progress
|
||||||
|
const newCompleted = startIndex + generated;
|
||||||
|
const isComplete = newCompleted >= scheduleData.length;
|
||||||
|
|
||||||
|
await directus.request(
|
||||||
|
updateItem('production_queue', queue_id, {
|
||||||
|
completed_count: newCompleted,
|
||||||
|
status: isComplete ? 'done' : 'running',
|
||||||
|
completed_at: isComplete ? new Date().toISOString() : null,
|
||||||
|
error_log: errors.length > 0 ? errors.join('\n') : null
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update site factory status
|
||||||
|
await directus.request(
|
||||||
|
updateItem('sites', queue.site, {
|
||||||
|
factory_status: isComplete ? 'publishing' : 'generating'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Log work
|
||||||
|
await directus.request(
|
||||||
|
createItem('work_log', {
|
||||||
|
site: queue.site,
|
||||||
|
action: 'batch_generated',
|
||||||
|
entity_type: 'production_queue',
|
||||||
|
entity_id: queue_id,
|
||||||
|
details: {
|
||||||
|
generated,
|
||||||
|
errors: errors.length,
|
||||||
|
progress: `${newCompleted}/${scheduleData.length}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
generated,
|
||||||
|
errors: errors.length,
|
||||||
|
progress: {
|
||||||
|
completed: newCompleted,
|
||||||
|
total: scheduleData.length,
|
||||||
|
percent: Math.round((newCompleted / scheduleData.length) * 100)
|
||||||
|
},
|
||||||
|
status: isComplete ? 'done' : 'running',
|
||||||
|
next_step: isComplete
|
||||||
|
? 'Queue complete! Run sitemap-drip cron to start indexing.'
|
||||||
|
: 'Call process-queue again to continue.'
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error processing queue:', error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Failed to process queue' }),
|
||||||
|
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function assembleFromModules(
|
||||||
|
directus: any,
|
||||||
|
siteId: string,
|
||||||
|
recipe: string[],
|
||||||
|
context: any,
|
||||||
|
pubDate: Date
|
||||||
|
): Promise<{ content: string; modulesUsed: string[] }> {
|
||||||
|
const parts: string[] = [];
|
||||||
|
const modulesUsed: string[] = [];
|
||||||
|
|
||||||
|
for (const moduleType of recipe) {
|
||||||
|
const modules = await directus.request(readItems('content_modules', {
|
||||||
|
filter: {
|
||||||
|
site: { _eq: siteId },
|
||||||
|
module_type: { _eq: moduleType },
|
||||||
|
is_active: { _eq: true }
|
||||||
|
},
|
||||||
|
sort: ['usage_count'],
|
||||||
|
limit: 1
|
||||||
|
})) as any[];
|
||||||
|
|
||||||
|
if (modules.length > 0) {
|
||||||
|
const mod = modules[0];
|
||||||
|
let content = mod.content_spintax || '';
|
||||||
|
|
||||||
|
// Replace tokens
|
||||||
|
content = content
|
||||||
|
.replace(/\{City\}/gi, context.city)
|
||||||
|
.replace(/\{State\}/gi, context.state)
|
||||||
|
.replace(/\{County\}/gi, context.county)
|
||||||
|
.replace(/\{State_Code\}/gi, context.state_code);
|
||||||
|
|
||||||
|
content = replaceYearTokens(content, pubDate);
|
||||||
|
content = processSpintax(content);
|
||||||
|
|
||||||
|
parts.push(content);
|
||||||
|
modulesUsed.push(mod.id);
|
||||||
|
|
||||||
|
// Increment usage
|
||||||
|
await directus.request(updateItem('content_modules', mod.id, {
|
||||||
|
usage_count: (mod.usage_count || 0) + 1
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { content: parts.join('\n\n'), modulesUsed };
|
||||||
|
}
|
||||||
|
|
||||||
|
function processSpintax(text: string): string {
|
||||||
|
let result = text;
|
||||||
|
let iterations = 100;
|
||||||
|
while (result.includes('{') && iterations > 0) {
|
||||||
|
result = result.replace(/\{([^{}]+)\}/g, (_, opts) => {
|
||||||
|
const choices = opts.split('|');
|
||||||
|
return choices[Math.floor(Math.random() * choices.length)];
|
||||||
|
});
|
||||||
|
iterations--;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateHeadline(template: string | null, context: any, date: Date): string {
|
||||||
|
if (!template) return '';
|
||||||
|
let h = template
|
||||||
|
.replace(/\{City\}/gi, context.city)
|
||||||
|
.replace(/\{State\}/gi, context.state);
|
||||||
|
h = replaceYearTokens(h, date);
|
||||||
|
return processSpintax(h);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStateCode(state: string): string {
|
||||||
|
const codes: Record<string, string> = {
|
||||||
|
'Florida': 'FL', 'Texas': 'TX', 'California': 'CA', 'New York': 'NY',
|
||||||
|
'Arizona': 'AZ', 'Nevada': 'NV', 'Georgia': 'GA', 'North Carolina': 'NC'
|
||||||
|
};
|
||||||
|
return codes[state] || state?.substring(0, 2).toUpperCase() || '';
|
||||||
|
}
|
||||||
198
god-mode/src/pages/api/seo/publish-article.ts
Normal file
198
god-mode/src/pages/api/seo/publish-article.ts
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
// @ts-ignore - Astro types available at build time
|
||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { getDirectusClient, readItem, readItems, createItem, updateItem } from '@/lib/directus/client';
|
||||||
|
import { generateFeaturedImage } from '@/lib/seo/image-generator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish Article to Site API
|
||||||
|
*
|
||||||
|
* Takes a generated article from the SEO engine and creates a post on the target site.
|
||||||
|
*
|
||||||
|
* POST /api/seo/publish-article
|
||||||
|
*/
|
||||||
|
export const POST: APIRoute = async ({ request }: { request: Request }) => {
|
||||||
|
try {
|
||||||
|
const data = await request.json();
|
||||||
|
const { article_id, site_id, status = 'draft' } = data;
|
||||||
|
|
||||||
|
if (!article_id) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'article_id is required' }),
|
||||||
|
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const directus = getDirectusClient();
|
||||||
|
|
||||||
|
// Get the generated article
|
||||||
|
const article = await directus.request(
|
||||||
|
readItem('generated_articles', article_id)
|
||||||
|
) as any;
|
||||||
|
|
||||||
|
if (!article) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Article not found' }),
|
||||||
|
{ status: 404, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already published
|
||||||
|
if (article.published_to_post) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: 'Article already published',
|
||||||
|
post_id: article.published_to_post
|
||||||
|
}),
|
||||||
|
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use provided site_id or fall back to article's site
|
||||||
|
const targetSiteId = site_id || article.site;
|
||||||
|
|
||||||
|
// Generate slug from headline
|
||||||
|
const slug = article.headline
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9\s-]/g, '')
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.substring(0, 100);
|
||||||
|
|
||||||
|
// Create the post
|
||||||
|
const post = await directus.request(
|
||||||
|
createItem('posts', {
|
||||||
|
site: targetSiteId,
|
||||||
|
title: article.headline,
|
||||||
|
slug: slug,
|
||||||
|
status: status, // 'draft' or 'published'
|
||||||
|
content: article.full_html_body,
|
||||||
|
excerpt: article.meta_description,
|
||||||
|
meta_title: article.meta_title,
|
||||||
|
meta_description: article.meta_description,
|
||||||
|
featured_image_alt: article.featured_image_alt,
|
||||||
|
source: 'seo_engine',
|
||||||
|
source_article_id: article_id,
|
||||||
|
robots: 'index,follow',
|
||||||
|
schema_type: 'BlogPosting',
|
||||||
|
// Location data for local SEO
|
||||||
|
meta_keywords: [
|
||||||
|
article.location_city,
|
||||||
|
article.location_state
|
||||||
|
].filter(Boolean).join(', ')
|
||||||
|
})
|
||||||
|
) as any;
|
||||||
|
|
||||||
|
// Update the generated article with publish info
|
||||||
|
await directus.request(
|
||||||
|
updateItem('generated_articles', article_id, {
|
||||||
|
publish_status: status === 'published' ? 'published' : 'ready',
|
||||||
|
published_to_post: post.id,
|
||||||
|
published_at: new Date().toISOString(),
|
||||||
|
published_url: `/${slug}`
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
post_id: post.id,
|
||||||
|
slug: slug,
|
||||||
|
status: status,
|
||||||
|
message: `Article published as ${status} post`
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error publishing article:', error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Failed to publish article' }),
|
||||||
|
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk publish multiple articles
|
||||||
|
*
|
||||||
|
* POST /api/seo/publish-article with { article_ids: [...] }
|
||||||
|
*/
|
||||||
|
export const PUT: APIRoute = async ({ request }: { request: Request }) => {
|
||||||
|
try {
|
||||||
|
const data = await request.json();
|
||||||
|
const { article_ids, site_id, status = 'draft' } = data;
|
||||||
|
|
||||||
|
if (!article_ids || !Array.isArray(article_ids) || article_ids.length === 0) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'article_ids array is required' }),
|
||||||
|
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const directus = getDirectusClient();
|
||||||
|
const results: { article_id: string; post_id?: string; error?: string }[] = [];
|
||||||
|
|
||||||
|
for (const articleId of article_ids) {
|
||||||
|
try {
|
||||||
|
const article = await directus.request(
|
||||||
|
readItem('generated_articles', articleId)
|
||||||
|
) as any;
|
||||||
|
|
||||||
|
if (!article || article.published_to_post) {
|
||||||
|
results.push({ article_id: articleId, error: 'Already published or not found' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetSiteId = site_id || article.site;
|
||||||
|
const slug = article.headline
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9\s-]/g, '')
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.substring(0, 100);
|
||||||
|
|
||||||
|
const post = await directus.request(
|
||||||
|
createItem('posts', {
|
||||||
|
site: targetSiteId,
|
||||||
|
title: article.headline,
|
||||||
|
slug: slug,
|
||||||
|
status: status,
|
||||||
|
content: article.full_html_body,
|
||||||
|
excerpt: article.meta_description,
|
||||||
|
meta_title: article.meta_title,
|
||||||
|
meta_description: article.meta_description,
|
||||||
|
source: 'seo_engine',
|
||||||
|
source_article_id: articleId
|
||||||
|
})
|
||||||
|
) as any;
|
||||||
|
|
||||||
|
await directus.request(
|
||||||
|
updateItem('generated_articles', articleId, {
|
||||||
|
publish_status: 'published',
|
||||||
|
published_to_post: post.id,
|
||||||
|
published_at: new Date().toISOString()
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
results.push({ article_id: articleId, post_id: post.id });
|
||||||
|
} catch (err) {
|
||||||
|
results.push({ article_id: articleId, error: 'Failed to publish' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const successCount = results.filter(r => r.post_id).length;
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
published: successCount,
|
||||||
|
total: article_ids.length,
|
||||||
|
results
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error bulk publishing:', error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Failed to bulk publish' }),
|
||||||
|
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
170
god-mode/src/pages/api/seo/scan-duplicates.ts
Normal file
170
god-mode/src/pages/api/seo/scan-duplicates.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
// @ts-ignore - Astro types available at build time
|
||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { getDirectusClient, readItems, createItem } from '@/lib/directus/client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan Duplicates API
|
||||||
|
*
|
||||||
|
* Uses shingle hashing to detect duplicate N-gram sequences across articles.
|
||||||
|
* Flags any articles that share 7+ word sequences.
|
||||||
|
*
|
||||||
|
* POST /api/seo/scan-duplicates
|
||||||
|
*/
|
||||||
|
export const POST: APIRoute = async ({ request }: { request: Request }) => {
|
||||||
|
try {
|
||||||
|
const data = await request.json();
|
||||||
|
const { queue_id, batch_ids, ngram_size = 7, threshold = 3 } = data;
|
||||||
|
|
||||||
|
if (!queue_id && !batch_ids) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'queue_id or batch_ids required' }),
|
||||||
|
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const directus = getDirectusClient();
|
||||||
|
|
||||||
|
// Get articles to scan
|
||||||
|
let articles: any[];
|
||||||
|
if (batch_ids && Array.isArray(batch_ids)) {
|
||||||
|
articles = await directus.request(readItems('generated_articles', {
|
||||||
|
filter: { id: { _in: batch_ids } },
|
||||||
|
fields: ['id', 'site', 'headline', 'full_html_body']
|
||||||
|
})) as any[];
|
||||||
|
} else {
|
||||||
|
// Get test batch articles from queue
|
||||||
|
articles = await directus.request(readItems('generated_articles', {
|
||||||
|
filter: { is_test_batch: { _eq: true } },
|
||||||
|
sort: ['-date_created'],
|
||||||
|
limit: 20,
|
||||||
|
fields: ['id', 'site', 'headline', 'full_html_body']
|
||||||
|
})) as any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (articles.length < 2) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
message: 'Need at least 2 articles to compare',
|
||||||
|
flags_created: 0
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build shingle sets for each article
|
||||||
|
const articleShingles: Map<string, Set<string>> = new Map();
|
||||||
|
|
||||||
|
for (const article of articles) {
|
||||||
|
const text = stripHtml(article.full_html_body || '');
|
||||||
|
const shingles = generateShingles(text, ngram_size);
|
||||||
|
articleShingles.set(article.id, shingles);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare all pairs
|
||||||
|
const collisions: Array<{
|
||||||
|
articleA: string;
|
||||||
|
articleB: string;
|
||||||
|
sharedShingles: string[];
|
||||||
|
similarity: number;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
const articleIds = Array.from(articleShingles.keys());
|
||||||
|
|
||||||
|
for (let i = 0; i < articleIds.length; i++) {
|
||||||
|
for (let j = i + 1; j < articleIds.length; j++) {
|
||||||
|
const idA = articleIds[i];
|
||||||
|
const idB = articleIds[j];
|
||||||
|
const setA = articleShingles.get(idA)!;
|
||||||
|
const setB = articleShingles.get(idB)!;
|
||||||
|
|
||||||
|
// Find intersection
|
||||||
|
const shared = [...setA].filter(s => setB.has(s));
|
||||||
|
|
||||||
|
if (shared.length >= threshold) {
|
||||||
|
// Calculate Jaccard similarity
|
||||||
|
const union = new Set([...setA, ...setB]);
|
||||||
|
const similarity = (shared.length / union.size) * 100;
|
||||||
|
|
||||||
|
collisions.push({
|
||||||
|
articleA: idA,
|
||||||
|
articleB: idB,
|
||||||
|
sharedShingles: shared.slice(0, 5), // Just first 5 examples
|
||||||
|
similarity
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create quality flags for collisions
|
||||||
|
const siteId = articles[0]?.site;
|
||||||
|
let flagsCreated = 0;
|
||||||
|
|
||||||
|
for (const collision of collisions) {
|
||||||
|
await directus.request(
|
||||||
|
createItem('quality_flags', {
|
||||||
|
site: siteId,
|
||||||
|
batch_id: queue_id || null,
|
||||||
|
article_a: collision.articleA,
|
||||||
|
article_b: collision.articleB,
|
||||||
|
collision_text: collision.sharedShingles.join(' | '),
|
||||||
|
similarity_score: collision.similarity,
|
||||||
|
status: 'pending'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
flagsCreated++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
articles_scanned: articles.length,
|
||||||
|
collisions_found: collisions.length,
|
||||||
|
flags_created: flagsCreated,
|
||||||
|
details: collisions.map(c => ({
|
||||||
|
article_a: c.articleA,
|
||||||
|
article_b: c.articleB,
|
||||||
|
similarity: c.similarity.toFixed(1) + '%',
|
||||||
|
examples: c.sharedShingles
|
||||||
|
}))
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error scanning duplicates:', error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Failed to scan duplicates' }),
|
||||||
|
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip HTML tags and normalize text
|
||||||
|
*/
|
||||||
|
function stripHtml(html: string): string {
|
||||||
|
return html
|
||||||
|
.replace(/<[^>]*>/g, ' ')
|
||||||
|
.replace(/ /g, ' ')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate N-gram shingles from text
|
||||||
|
*/
|
||||||
|
function generateShingles(text: string, n: number): Set<string> {
|
||||||
|
const words = text.split(/\s+/).filter(w => w.length > 2);
|
||||||
|
const shingles = new Set<string>();
|
||||||
|
|
||||||
|
for (let i = 0; i <= words.length - n; i++) {
|
||||||
|
const shingle = words.slice(i, i + n).join(' ');
|
||||||
|
shingles.add(shingle);
|
||||||
|
}
|
||||||
|
|
||||||
|
return shingles;
|
||||||
|
}
|
||||||
176
god-mode/src/pages/api/seo/schedule-production.ts
Normal file
176
god-mode/src/pages/api/seo/schedule-production.ts
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
// @ts-ignore - Astro types available at build time
|
||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { getDirectusClient, readItem, createItem, updateItem } from '@/lib/directus/client';
|
||||||
|
import {
|
||||||
|
generateNaturalSchedule,
|
||||||
|
getMaxBackdateStart,
|
||||||
|
type VelocityConfig
|
||||||
|
} from '@/lib/seo/velocity-scheduler';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule Production API
|
||||||
|
*
|
||||||
|
* Generates a natural velocity schedule for article production.
|
||||||
|
* Uses Gaussian distribution with weekend throttling and time jitter.
|
||||||
|
*
|
||||||
|
* POST /api/seo/schedule-production
|
||||||
|
*/
|
||||||
|
export const POST: APIRoute = async ({ request }: { request: Request }) => {
|
||||||
|
try {
|
||||||
|
const data = await request.json();
|
||||||
|
const {
|
||||||
|
campaign_id,
|
||||||
|
site_id,
|
||||||
|
total_articles,
|
||||||
|
date_range,
|
||||||
|
velocity,
|
||||||
|
test_batch_first = true
|
||||||
|
} = data;
|
||||||
|
|
||||||
|
if (!campaign_id || !total_articles) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'campaign_id and total_articles are required' }),
|
||||||
|
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const directus = getDirectusClient();
|
||||||
|
|
||||||
|
// Get campaign details
|
||||||
|
const campaign = await directus.request(
|
||||||
|
readItem('campaign_masters', campaign_id)
|
||||||
|
) as any;
|
||||||
|
|
||||||
|
if (!campaign) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Campaign not found' }),
|
||||||
|
{ status: 404, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetSiteId = site_id || campaign.site;
|
||||||
|
|
||||||
|
// Get site to check domain age
|
||||||
|
const site = await directus.request(
|
||||||
|
readItem('sites', targetSiteId)
|
||||||
|
) as any;
|
||||||
|
|
||||||
|
const domainAgeYears = site?.domain_age_years || 1;
|
||||||
|
|
||||||
|
// Parse dates
|
||||||
|
let startDate: Date;
|
||||||
|
let endDate: Date = new Date();
|
||||||
|
|
||||||
|
if (date_range?.start) {
|
||||||
|
startDate = new Date(date_range.start);
|
||||||
|
} else if (campaign.backdate_start) {
|
||||||
|
startDate = new Date(campaign.backdate_start);
|
||||||
|
} else {
|
||||||
|
// Default: use domain age to determine max backdate
|
||||||
|
startDate = getMaxBackdateStart(domainAgeYears);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (date_range?.end) {
|
||||||
|
endDate = new Date(date_range.end);
|
||||||
|
} else if (campaign.backdate_end) {
|
||||||
|
endDate = new Date(campaign.backdate_end);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate startDate isn't before domain existed
|
||||||
|
const maxBackdate = getMaxBackdateStart(domainAgeYears);
|
||||||
|
if (startDate < maxBackdate) {
|
||||||
|
startDate = maxBackdate;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build velocity config
|
||||||
|
const velocityConfig: VelocityConfig = {
|
||||||
|
mode: velocity?.mode || campaign.velocity_mode || 'RAMP_UP',
|
||||||
|
weekendThrottle: velocity?.weekend_throttle ?? campaign.weekend_throttle ?? true,
|
||||||
|
jitterMinutes: velocity?.jitter_minutes ?? campaign.time_jitter_minutes ?? 120,
|
||||||
|
businessHoursOnly: velocity?.business_hours ?? campaign.business_hours_only ?? true
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate schedule
|
||||||
|
const schedule = generateNaturalSchedule(
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
total_articles,
|
||||||
|
velocityConfig
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create production queue entry
|
||||||
|
const queueEntry = await directus.request(
|
||||||
|
createItem('production_queue', {
|
||||||
|
site: targetSiteId,
|
||||||
|
campaign: campaign_id,
|
||||||
|
status: test_batch_first ? 'test_batch' : 'pending',
|
||||||
|
total_requested: total_articles,
|
||||||
|
completed_count: 0,
|
||||||
|
velocity_mode: velocityConfig.mode,
|
||||||
|
schedule_data: schedule.map(s => ({
|
||||||
|
publish_date: s.publishDate.toISOString(),
|
||||||
|
modified_date: s.modifiedDate.toISOString()
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
) as any;
|
||||||
|
|
||||||
|
// Update campaign with test batch status if applicable
|
||||||
|
if (test_batch_first) {
|
||||||
|
await directus.request(
|
||||||
|
updateItem('campaign_masters', campaign_id, {
|
||||||
|
test_batch_status: 'pending'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log work
|
||||||
|
await directus.request(
|
||||||
|
createItem('work_log', {
|
||||||
|
site: targetSiteId,
|
||||||
|
action: 'schedule_created',
|
||||||
|
entity_type: 'production_queue',
|
||||||
|
entity_id: queueEntry.id,
|
||||||
|
details: {
|
||||||
|
total_articles,
|
||||||
|
start_date: startDate.toISOString(),
|
||||||
|
end_date: endDate.toISOString(),
|
||||||
|
velocity_mode: velocityConfig.mode,
|
||||||
|
test_batch_first
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Return summary
|
||||||
|
const dateDistribution: Record<string, number> = {};
|
||||||
|
schedule.forEach(s => {
|
||||||
|
const key = s.publishDate.toISOString().split('T')[0];
|
||||||
|
dateDistribution[key] = (dateDistribution[key] || 0) + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
queue_id: queueEntry.id,
|
||||||
|
total_scheduled: schedule.length,
|
||||||
|
date_range: {
|
||||||
|
start: startDate.toISOString(),
|
||||||
|
end: endDate.toISOString()
|
||||||
|
},
|
||||||
|
velocity: velocityConfig,
|
||||||
|
next_step: test_batch_first
|
||||||
|
? 'Call /api/seo/generate-test-batch to create review batch'
|
||||||
|
: 'Call /api/seo/process-queue to start generation',
|
||||||
|
sample_distribution: Object.entries(dateDistribution)
|
||||||
|
.slice(0, 10)
|
||||||
|
.reduce((acc, [k, v]) => ({ ...acc, [k]: v }), {})
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error scheduling production:', error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Failed to schedule production' }),
|
||||||
|
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
126
god-mode/src/pages/api/seo/sitemap-drip.ts
Normal file
126
god-mode/src/pages/api/seo/sitemap-drip.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
// @ts-ignore - Astro types available at build time
|
||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { getDirectusClient, readItems, updateItem, createItem } from '@/lib/directus/client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sitemap Drip API (Cron Job)
|
||||||
|
*
|
||||||
|
* Processes ghost articles and adds them to sitemap at controlled rate.
|
||||||
|
* Should be called daily by a cron job.
|
||||||
|
*
|
||||||
|
* GET /api/seo/sitemap-drip?site_id={id}
|
||||||
|
*/
|
||||||
|
export const GET: APIRoute = async ({ url }: { url: URL }) => {
|
||||||
|
try {
|
||||||
|
const siteId = url.searchParams.get('site_id');
|
||||||
|
|
||||||
|
if (!siteId) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'site_id is required' }),
|
||||||
|
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const directus = getDirectusClient();
|
||||||
|
|
||||||
|
// Get site drip rate
|
||||||
|
const sites = await directus.request(readItems('sites', {
|
||||||
|
filter: { id: { _eq: siteId } },
|
||||||
|
limit: 1
|
||||||
|
})) as any[];
|
||||||
|
|
||||||
|
const site = sites[0];
|
||||||
|
const dripRate = site?.sitemap_drip_rate || 50;
|
||||||
|
|
||||||
|
// Get ghost articles sorted by priority (hubs first) then date
|
||||||
|
const ghostArticles = await directus.request(readItems('generated_articles', {
|
||||||
|
filter: {
|
||||||
|
site: { _eq: siteId },
|
||||||
|
sitemap_status: { _eq: 'ghost' },
|
||||||
|
is_published: { _eq: true }
|
||||||
|
},
|
||||||
|
sort: ['-date_published'], // Newest first within ghosts
|
||||||
|
limit: dripRate
|
||||||
|
})) as any[];
|
||||||
|
|
||||||
|
if (ghostArticles.length === 0) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
message: 'No ghost articles to index',
|
||||||
|
indexed: 0
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update to indexed
|
||||||
|
const indexedIds: string[] = [];
|
||||||
|
for (const article of ghostArticles) {
|
||||||
|
await directus.request(
|
||||||
|
updateItem('generated_articles', article.id, {
|
||||||
|
sitemap_status: 'indexed'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
indexedIds.push(article.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check hub pages
|
||||||
|
const ghostHubs = await directus.request(readItems('hub_pages', {
|
||||||
|
filter: {
|
||||||
|
site: { _eq: siteId },
|
||||||
|
sitemap_status: { _eq: 'ghost' }
|
||||||
|
},
|
||||||
|
limit: 10 // Hubs get priority
|
||||||
|
})) as any[];
|
||||||
|
|
||||||
|
for (const hub of ghostHubs) {
|
||||||
|
await directus.request(
|
||||||
|
updateItem('hub_pages', hub.id, {
|
||||||
|
sitemap_status: 'indexed'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
indexedIds.push(hub.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log work
|
||||||
|
await directus.request(
|
||||||
|
createItem('work_log', {
|
||||||
|
site: siteId,
|
||||||
|
action: 'sitemap_drip',
|
||||||
|
entity_type: 'batch',
|
||||||
|
entity_id: null,
|
||||||
|
details: {
|
||||||
|
articles_indexed: ghostArticles.length,
|
||||||
|
hubs_indexed: ghostHubs.length,
|
||||||
|
ids: indexedIds
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update site factory status
|
||||||
|
await directus.request(
|
||||||
|
updateItem('sites', siteId, {
|
||||||
|
factory_status: 'dripping'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
articles_indexed: ghostArticles.length,
|
||||||
|
hubs_indexed: ghostHubs.length,
|
||||||
|
total_indexed: indexedIds.length,
|
||||||
|
drip_rate: dripRate,
|
||||||
|
message: `Added ${indexedIds.length} URLs to sitemap`
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in sitemap drip:', error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Failed to process sitemap drip' }),
|
||||||
|
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
59
god-mode/src/pages/api/seo/stats.ts
Normal file
59
god-mode/src/pages/api/seo/stats.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
// @ts-ignore
|
||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import { getDirectusClient, readItems, readItem } from '@/lib/directus/client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SEO Stats API
|
||||||
|
* Returns article counts by status for dashboard KPIs.
|
||||||
|
*
|
||||||
|
* GET /api/seo/stats?site_id={id}
|
||||||
|
*/
|
||||||
|
export const GET: APIRoute = async ({ url }: { url: URL }) => {
|
||||||
|
try {
|
||||||
|
const siteId = url.searchParams.get('site_id');
|
||||||
|
const directus = getDirectusClient();
|
||||||
|
|
||||||
|
// Build filter
|
||||||
|
const filter: any = {};
|
||||||
|
if (siteId) {
|
||||||
|
filter.site = { _eq: siteId };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all articles
|
||||||
|
const articles = await directus.request(readItems('generated_articles', {
|
||||||
|
filter,
|
||||||
|
fields: ['id', 'sitemap_status', 'is_published'],
|
||||||
|
limit: -1
|
||||||
|
})) as any[];
|
||||||
|
|
||||||
|
const total = articles.length;
|
||||||
|
const ghost = articles.filter(a => a.sitemap_status === 'ghost').length;
|
||||||
|
const indexed = articles.filter(a => a.sitemap_status === 'indexed').length;
|
||||||
|
const queued = articles.filter(a => a.sitemap_status === 'queued').length;
|
||||||
|
const published = articles.filter(a => a.is_published).length;
|
||||||
|
const draft = articles.filter(a => !a.is_published).length;
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
total,
|
||||||
|
ghost,
|
||||||
|
indexed,
|
||||||
|
queued,
|
||||||
|
published,
|
||||||
|
draft,
|
||||||
|
breakdown: {
|
||||||
|
sitemap: { ghost, indexed, queued },
|
||||||
|
publish: { published, draft }
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting stats:', error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Failed to get stats', total: 0, ghost: 0, indexed: 0 }),
|
||||||
|
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
22
god-mode/src/pages/api/system/health.ts
Normal file
22
god-mode/src/pages/api/system/health.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
|
||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
|
||||||
|
export const GET: APIRoute = async () => {
|
||||||
|
// This is a minimal endpoint to verify the frontend server itself is running.
|
||||||
|
// In a real health check, you might also ping the database or external services here
|
||||||
|
// and return a composite status (e.g. { frontend: 'ok', db: 'ok', directus: 'ok' })
|
||||||
|
|
||||||
|
const healthStatus = {
|
||||||
|
status: 'ok',
|
||||||
|
service: 'spark-frontend',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
uptime: process.uptime()
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(healthStatus), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
0
god-mode/src/pages/api/testing/check-links.ts
Normal file
0
god-mode/src/pages/api/testing/check-links.ts
Normal file
0
god-mode/src/pages/api/testing/detect-duplicates.ts
Normal file
0
god-mode/src/pages/api/testing/detect-duplicates.ts
Normal file
0
god-mode/src/pages/api/testing/validate-seo.ts
Normal file
0
god-mode/src/pages/api/testing/validate-seo.ts
Normal file
109
god-mode/src/types/cartesian.ts
Normal file
109
god-mode/src/types/cartesian.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* Cartesian Permutation Type Definitions
|
||||||
|
*
|
||||||
|
* Types for spintax parsing, n^k combinations, and location cross-products.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a single spintax slot found in text
|
||||||
|
* Example: "{Best|Top|Leading}" becomes:
|
||||||
|
* { original: "{Best|Top|Leading}", options: ["Best", "Top", "Leading"], position: 0 }
|
||||||
|
*/
|
||||||
|
export interface SpintaxSlot {
|
||||||
|
/** The original matched string including braces */
|
||||||
|
original: string;
|
||||||
|
/** Array of options extracted from the slot */
|
||||||
|
options: string[];
|
||||||
|
/** Position index in the template */
|
||||||
|
position: number;
|
||||||
|
/** Start character index in original text */
|
||||||
|
startIndex: number;
|
||||||
|
/** End character index in original text */
|
||||||
|
endIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for Cartesian product generation
|
||||||
|
*/
|
||||||
|
export interface CartesianConfig {
|
||||||
|
/** Maximum number of combinations to generate (safety limit) */
|
||||||
|
maxCombinations: number;
|
||||||
|
/** Whether to include location data in cross-product */
|
||||||
|
includeLocations: boolean;
|
||||||
|
/** How to handle locations: state, county, city, or none */
|
||||||
|
locationMode: 'state' | 'county' | 'city' | 'none';
|
||||||
|
/** Optional: limit to specific state/county */
|
||||||
|
locationTargetId?: string;
|
||||||
|
/** Batch size for processing */
|
||||||
|
batchSize: number;
|
||||||
|
/** Starting offset for pagination */
|
||||||
|
offset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default configuration
|
||||||
|
*/
|
||||||
|
export const DEFAULT_CARTESIAN_CONFIG: CartesianConfig = {
|
||||||
|
maxCombinations: 10000,
|
||||||
|
includeLocations: false,
|
||||||
|
locationMode: 'none',
|
||||||
|
batchSize: 500,
|
||||||
|
offset: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single result from Cartesian product generation
|
||||||
|
*/
|
||||||
|
export interface CartesianResult {
|
||||||
|
/** The final assembled text */
|
||||||
|
text: string;
|
||||||
|
/** Map of slot identifier to chosen value */
|
||||||
|
slotValues: Record<string, string>;
|
||||||
|
/** Location data if applicable */
|
||||||
|
location?: {
|
||||||
|
city?: string;
|
||||||
|
county?: string;
|
||||||
|
state?: string;
|
||||||
|
stateCode?: string;
|
||||||
|
id?: string;
|
||||||
|
};
|
||||||
|
/** Index in the full Cartesian product sequence */
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadata about a Cartesian product operation
|
||||||
|
*/
|
||||||
|
export interface CartesianMetadata {
|
||||||
|
/** Template before expansion */
|
||||||
|
template: string;
|
||||||
|
/** Number of slots found */
|
||||||
|
slotCount: number;
|
||||||
|
/** Product of all slot option counts (n^k formula result) */
|
||||||
|
totalSpintaxCombinations: number;
|
||||||
|
/** Number of locations in cross-product */
|
||||||
|
locationCount: number;
|
||||||
|
/** Total possible combinations (spintax × locations) */
|
||||||
|
totalPossibleCombinations: number;
|
||||||
|
/** Actual count generated (respecting maxCombinations) */
|
||||||
|
generatedCount: number;
|
||||||
|
/** Whether generation was truncated due to limit */
|
||||||
|
wasTruncated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Location data structure for cross-product
|
||||||
|
*/
|
||||||
|
export interface LocationEntry {
|
||||||
|
id: string;
|
||||||
|
city?: string;
|
||||||
|
county?: string;
|
||||||
|
state: string;
|
||||||
|
stateCode: string;
|
||||||
|
population?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Variable map for injection
|
||||||
|
*/
|
||||||
|
export type VariableMap = Record<string, string>;
|
||||||
@@ -9,6 +9,10 @@
|
|||||||
"@/*": [
|
"@/*": [
|
||||||
"src/*"
|
"src/*"
|
||||||
]
|
]
|
||||||
}
|
},
|
||||||
|
"lib": [
|
||||||
|
"ES2022",
|
||||||
|
"DOM"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user