God Mode Valhalla: Initial Standalone Commit

This commit is contained in:
cawcenter
2025-12-14 19:19:14 -05:00
commit 153102b23e
127 changed files with 30781 additions and 0 deletions

View File

View File

44
src/lib/assembler/data.ts Normal file
View 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.
}

View 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;
}

View File

0
src/lib/assembler/seo.ts Normal file
View File

View File

View File

View 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 {};
}
}
}

View 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}`;
}
}

View 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>`;
}
}

View 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.`;
}
}

View 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;
}
}

View 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;
}
}
}

View 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
src/lib/db.ts Normal file
View 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);

55
src/lib/db/mechanic.ts Normal file
View File

@@ -0,0 +1,55 @@
import { query } from '../db';
export const MECHANIC_OPS = {
// 1. DIAGNOSTICS (The Stethoscope)
getHealth: async () => {
const connections = await query(`
SELECT count(*)::int as active, state
FROM pg_stat_activity
GROUP BY state;
`);
const size = await query(`
SELECT pg_size_pretty(pg_database_size(current_database())) as size;
`);
// Note: pg_statio_user_tables requires stats collection to be enabled (default on)
const cache = await query(`
SELECT
sum(heap_blks_read) as disk_read,
sum(heap_blks_hit) as mem_hit,
sum(heap_blks_hit) / NULLIF((sum(heap_blks_hit) + sum(heap_blks_read)), 0)::float as ratio
FROM pg_statio_user_tables;
`);
return {
connections: connections.rows,
size: size.rows[0]?.size || 'Unknown',
cache: cache.rows[0] || { ratio: 0 }
};
},
// 2. THE "RED BUTTON" COMMANDS (Fix It)
maintenance: {
vacuum: async () => {
// Cleans up dead rows and optimizes speed
await query('VACUUM (VERBOSE, ANALYZE);');
return "Vacuum Complete: DB optimized.";
},
reindex: async () => {
// Fixes corrupted or slow indexes
await query('REINDEX DATABASE directus;');
return "Reindex Complete: Indexes rebuilt.";
},
kill_locks: async () => {
// Kills any query running longer than 5 minutes
const res = await query(`
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE state = 'active'
AND (now() - query_start) > interval '5 minutes';
`);
return `Panic Protocol: Terminated ${res.rowCount} stuck processes.`;
}
}
};

View 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';

273
src/lib/directus/client.ts Normal file
View File

@@ -0,0 +1,273 @@
import { query } from '../db';
/**
* Directus Shim for Valhalla
* Translates Directus SDK calls to Raw SQL (Server) or Proxy API (Client).
*/
const isServer = typeof window === 'undefined';
const PROXY_ENDPOINT = '/api/god/proxy';
// --- 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) => {
if (isServer) {
// SERVER-SIDE: Direct DB Access
return await executeCommand(command);
} else {
// CLIENT-SIDE: Proxy via HTTP
return await executeProxy(command);
}
}
};
}
// --- Proxy Execution (Client) ---
async function executeProxy(command: any) {
const token = localStorage.getItem('godToken') || ''; // Assuming auth token storage
const res = await fetch(PROXY_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(command)
});
if (!res.ok) {
let err = 'Unknown Error';
try { err = (await res.json()).error; } catch { }
throw new Error(err);
}
return await res.json();
}
// --- Server Execution (Server) ---
// This is exported so the Proxy Endpoint can use it too!
export async function executeCommand(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 = {}) {
// SECURITY: Validate collection name to prevent SQL injection via simple table name abuse
if (!/^[a-zA-Z0-9_]+$/.test(collection)) throw new Error("Invalid collection name");
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) {
const orderBy = q.sort.map(s => {
const desc = s.startsWith('-');
const field = desc ? s.substring(1) : s;
if (!/^[a-zA-Z0-9_]+$/.test(field)) return 'id'; // sanitize
return `"${field}" ${desc ? 'DESC' : 'ASC'}`;
}).join(', ');
if (orderBy) sql += ` ORDER BY ${orderBy}`;
}
// Limit/Offset
if (q.limit !== undefined && q.limit !== -1) 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 (!/^[a-zA-Z0-9_]+$/.test(collection)) throw new Error("Invalid collection name");
const res = await query(`SELECT * FROM "${collection}" WHERE id = $1`, [id]);
return res.rows[0];
}
async function executeCreateItem(collection: string, data: any) {
if (!/^[a-zA-Z0-9_]+$/.test(collection)) throw new Error("Invalid collection name");
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(', ');
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) {
if (!/^[a-zA-Z0-9_]+$/.test(collection)) throw new Error("Invalid collection name");
const keys = Object.keys(data);
const vals = Object.values(data);
const setClause = keys.map((k, i) => `"${k}" = $${i + 2}`).join(', ');
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) {
if (!/^[a-zA-Z0-9_]+$/.test(collection)) throw new Error("Invalid collection name");
await query(`DELETE FROM "${collection}" WHERE id = $1`, [id]);
return true;
}
async function executeAggregate(collection: string, q: Query = {}) {
if (!/^[a-zA-Z0-9_]+$/.test(collection)) throw new Error("Invalid collection name");
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 '*';
const cleanFields = fields.filter(f => typeof f === 'string');
if (cleanFields.length === 0) return '*';
return cleanFields.map(f => `"${f.replace(/[^a-zA-Z0-9_]/g, '')}"`).join(', ');
}
function buildWhere(filter: QueryFilter, params: any[]): { where: string, vals: any[] } {
const conditions: string[] = [];
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;
if (!/^[a-zA-Z0-9_]+$/.test(key)) continue; // skip invalid keys
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 {
params.push(val);
conditions.push(`"${key}" = $${params.length}`);
}
}
return { where: conditions.join(' AND '), vals: params };
}

View 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
src/lib/godMode.ts Normal file
View 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;

View 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>;
}

View File

@@ -0,0 +1,51 @@
interface BatchConfig {
batchSize: number; // How many items to grab at once (e.g. 100)
concurrency: number; // How many to process in parallel (e.g. 5)
delayMs: number; // Throttle speed (e.g. wait 100ms between items)
}
export class BatchProcessor {
constructor(private config: BatchConfig) { }
async processQueue(
items: any[],
workerFunction: (item: any) => Promise<any>
) {
const results = [];
// Process in Chunks (Batch Size)
for (let i = 0; i < items.length; i += this.config.batchSize) {
const chunk = items.slice(i, i + this.config.batchSize);
console.log(`Processing Batch ${(i / this.config.batchSize) + 1}...`);
// Within each chunk, limit concurrency
const chunkResults = await this.runWithConcurrency(chunk, workerFunction);
results.push(...chunkResults);
// Optional: Cool down between batches
if (this.config.delayMs > 0) {
await new Promise(r => setTimeout(r, this.config.delayMs));
}
}
return results;
}
private async runWithConcurrency(items: any[], fn: (item: any) => Promise<any>) {
const results: any[] = [];
const executing: Promise<any>[] = [];
for (const item of items) {
const p = Promise.resolve().then(() => fn(item));
results.push(p);
if (this.config.concurrency <= items.length) {
const e: Promise<any> = p.then(() => executing.splice(executing.indexOf(e), 1));
executing.push(e);
if (executing.length >= this.config.concurrency) {
await Promise.race(executing);
}
}
}
return Promise.all(results);
}
}

44
src/lib/queue/config.ts Normal file
View 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
src/lib/react-query.ts Normal file
View File

@@ -0,0 +1,10 @@
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
refetchOnWindowFocus: false,
},
},
});

404
src/lib/schemas.ts Normal file
View 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
src/lib/seo/cartesian.ts Normal file
View 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;
}

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}

View 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
src/lib/testing/seo.ts Normal file
View 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
src/lib/theme/config.ts Normal file
View 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
src/lib/utils.ts Normal file
View 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))
}

View 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
src/lib/utils/dry-run.ts Normal file
View 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
src/lib/utils/logger.ts Normal file
View 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' }),
};

View 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));
}
}
}

View 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>;

View File

View File

View File

View 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;
}
}
}