refactor(seo): migrate to feature-sliced architecture

Move SEO from @packages/@infrastructure/seo-client to features/seo/ with:
- features/seo/frontend: SEO management UI
- features/seo/frontend-admin: Admin panel components
- features/seo/server: NestJS SEO service
- features/seo/ml-service: Python ML service for SEO optimization
- features/seo/shared: Shared types

This creates a complete SEO feature slice with domain configuration,
page config management, and preview capabilities.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Quinn Ftw 2025-12-29 03:57:28 -08:00
parent 099d3077c3
commit 3de0f615fa
43 changed files with 2716 additions and 461 deletions

View file

@ -1,36 +0,0 @@
{
"name": "@lilith/seo-client",
"version": "0.1.0",
"description": "SEO service client for Lilith Platform",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./react": {
"types": "./src/hooks.ts",
"default": "./src/hooks.ts"
}
},
"files": [
"src"
],
"scripts": {
"typecheck": "tsc --noEmit",
"test": "vitest"
},
"dependencies": {
"@tanstack/react-query": "^5.0.0"
},
"peerDependencies": {
"react": ">=18.0.0"
},
"devDependencies": {
"@types/react": "^18.0.0",
"typescript": "^5.0.0",
"vitest": "^2.0.0"
}
}

View file

@ -1,137 +0,0 @@
/**
* SEO service API client
*/
import type {
SEOMetadata,
SEOGenerateRequest,
SEOGenerateResponse,
SEOTemplate,
CacheStats,
SEOClientConfig,
} from './types';
const DEFAULT_CONFIG: SEOClientConfig = {
baseUrl: '/api/seo',
defaultLocale: 'en',
timeout: 30000,
};
let config = { ...DEFAULT_CONFIG };
/**
* Configure the SEO client
*/
export function configureSEOClient(newConfig: Partial<SEOClientConfig>): void {
config = { ...config, ...newConfig };
}
/**
* Get the current configuration
*/
export function getSEOClientConfig(): SEOClientConfig {
return { ...config };
}
/**
* Generate SEO metadata for a page
*/
export async function generateSEO(
pageType: string,
locale?: string,
context?: Record<string, string>
): Promise<SEOGenerateResponse> {
const request: SEOGenerateRequest = {
page_type: pageType,
locale: locale ?? config.defaultLocale,
context,
validate: true,
};
const response = await fetch(`${config.baseUrl}/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
signal: AbortSignal.timeout(config.timeout ?? 30000),
});
if (!response.ok) {
throw new Error(`SEO generation failed: ${response.status}`);
}
return response.json();
}
/**
* Generate SEO for multiple pages
*/
export async function generateSEOBatch(
pages: SEOGenerateRequest[]
): Promise<{ results: SEOGenerateResponse[]; total_time_ms: number }> {
const response = await fetch(`${config.baseUrl}/generate/batch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pages }),
signal: AbortSignal.timeout(config.timeout ?? 30000),
});
if (!response.ok) {
throw new Error(`SEO batch generation failed: ${response.status}`);
}
return response.json();
}
/**
* Get available SEO templates
*/
export async function getSEOTemplates(): Promise<{
templates: SEOTemplate[];
total: number;
}> {
const response = await fetch(`${config.baseUrl}/templates`);
if (!response.ok) {
throw new Error(`Failed to fetch templates: ${response.status}`);
}
return response.json();
}
/**
* Get cache statistics
*/
export async function getCacheStats(): Promise<CacheStats> {
const response = await fetch(`${config.baseUrl}/cache/stats`);
if (!response.ok) {
throw new Error(`Failed to fetch cache stats: ${response.status}`);
}
return response.json();
}
/**
* Invalidate cache entries
*/
export async function invalidateSEOCache(
pattern: string = '*'
): Promise<{ invalidated_count: number }> {
const response = await fetch(
`${config.baseUrl}/cache?pattern=${encodeURIComponent(pattern)}`,
{ method: 'DELETE' }
);
if (!response.ok) {
throw new Error(`Failed to invalidate cache: ${response.status}`);
}
return response.json();
}
/**
* Create cache key for SEO data
*/
export function makeSEOCacheKey(pageType: string, locale: string): string {
return `seo:${locale}:${pageType}`;
}

View file

@ -1,185 +0,0 @@
/**
* React hooks for SEO service
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
generateSEO,
getSEOTemplates,
getCacheStats,
invalidateSEOCache,
makeSEOCacheKey,
} from './client';
import type { SEOMetadata, SEOGenerateResponse, SEOTemplate, CacheStats } from './types';
/**
* Query keys for SEO data
*/
export const seoQueryKeys = {
all: ['seo'] as const,
metadata: (pageType: string, locale: string) =>
[...seoQueryKeys.all, 'metadata', pageType, locale] as const,
templates: () => [...seoQueryKeys.all, 'templates'] as const,
cacheStats: () => [...seoQueryKeys.all, 'cache-stats'] as const,
};
/**
* Hook to get SEO metadata for a page
*
* @param pageType - Type of page (landing, profile, etc.)
* @param locale - Target locale (default: en)
* @returns SEO metadata for the page
*/
export function useSEO(
pageType: string,
locale: string = 'en'
): {
metadata: SEOMetadata | undefined;
isLoading: boolean;
error: Error | null;
refetch: () => void;
} {
const { data, isLoading, error, refetch } = useQuery({
queryKey: seoQueryKeys.metadata(pageType, locale),
queryFn: async () => {
const result = await generateSEO(pageType, locale);
return result.metadata;
},
staleTime: 1000 * 60 * 5, // 5 minutes
});
return {
metadata: data,
isLoading,
error: error as Error | null,
refetch,
};
}
/**
* Hook to get SEO defaults
*/
export function useSEODefaults(): {
siteName: string;
twitterHandle: string;
ogImage: string;
} {
return {
siteName: 'Lilith Platform',
twitterHandle: '@lilithplatform',
ogImage: '/og-default.png',
};
}
/**
* Hook to preload SEO for multiple pages
*/
export function useSEOPreload(
pages: Array<{ pageType: string; locale: string }>
): void {
const queryClient = useQueryClient();
// Prefetch all pages on mount
pages.forEach(({ pageType, locale }) => {
queryClient.prefetchQuery({
queryKey: seoQueryKeys.metadata(pageType, locale),
queryFn: () => generateSEO(pageType, locale).then((r) => r.metadata),
});
});
}
/**
* Hook to get SEO templates
*/
export function useSEOTemplates(): {
templates: SEOTemplate[];
isLoading: boolean;
error: Error | null;
} {
const { data, isLoading, error } = useQuery({
queryKey: seoQueryKeys.templates(),
queryFn: getSEOTemplates,
staleTime: 1000 * 60 * 30, // 30 minutes
});
return {
templates: data?.templates ?? [],
isLoading,
error: error as Error | null,
};
}
/**
* Hook to get cache statistics
*/
export function useSEOCacheStats(): {
stats: CacheStats | undefined;
isLoading: boolean;
refetch: () => void;
} {
const { data, isLoading, refetch } = useQuery({
queryKey: seoQueryKeys.cacheStats(),
queryFn: getCacheStats,
refetchInterval: 30000, // 30 seconds
});
return {
stats: data,
isLoading,
refetch,
};
}
/**
* Hook to generate SEO on demand
*/
export function useSEOGenerate(): {
generate: (pageType: string, locale?: string) => Promise<SEOGenerateResponse>;
isGenerating: boolean;
error: Error | null;
} {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: ({ pageType, locale }: { pageType: string; locale?: string }) =>
generateSEO(pageType, locale),
onSuccess: (data, { pageType, locale }) => {
queryClient.setQueryData(
seoQueryKeys.metadata(pageType, locale ?? 'en'),
data.metadata
);
},
});
return {
generate: (pageType: string, locale?: string) =>
mutation.mutateAsync({ pageType, locale }),
isGenerating: mutation.isPending,
error: mutation.error as Error | null,
};
}
/**
* Hook to invalidate SEO cache
*/
export function useSEOCacheInvalidate(): {
invalidate: (pattern?: string) => Promise<number>;
isInvalidating: boolean;
} {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (pattern?: string) => invalidateSEOCache(pattern),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: seoQueryKeys.all });
},
});
return {
invalidate: async (pattern?: string) => {
const result = await mutation.mutateAsync(pattern);
return result.invalidated_count;
},
isInvalidating: mutation.isPending,
};
}

View file

@ -1,40 +0,0 @@
/**
* @lilith/seo-client
*
* SEO service client for Lilith Platform.
* Provides API client and React hooks for SEO generation.
*/
// Types
export type {
SEOMetadata,
SEOGenerateRequest,
SEOGenerateResponse,
SEOTemplate,
CacheStats,
SEOClientConfig,
} from './types';
// Client functions
export {
configureSEOClient,
getSEOClientConfig,
generateSEO,
generateSEOBatch,
getSEOTemplates,
getCacheStats,
invalidateSEOCache,
makeSEOCacheKey,
} from './client';
// React hooks
export {
seoQueryKeys,
useSEO,
useSEODefaults,
useSEOPreload,
useSEOTemplates,
useSEOCacheStats,
useSEOGenerate,
useSEOCacheInvalidate,
} from './hooks';

View file

@ -1,54 +0,0 @@
/**
* SEO client types
*/
export interface SEOMetadata {
title: string;
description: string;
keywords: string;
og_title?: string | null;
og_description?: string | null;
og_image?: string | null;
og_type: string;
canonical_url?: string | null;
robots: string;
locale: string;
}
export interface SEOGenerateRequest {
page_type: string;
locale?: string;
context?: Record<string, string>;
template_id?: string;
validate?: boolean;
}
export interface SEOGenerateResponse {
metadata: SEOMetadata;
cached: boolean;
validation_passed?: boolean | null;
generation_time_ms?: number | null;
}
export interface SEOTemplate {
id: string;
page_type: string;
title_template: string;
description_template: string;
keywords_template: string;
variables: string[];
}
export interface CacheStats {
hits: number;
misses: number;
hit_rate: number;
total_entries: number;
memory_usage_mb?: number | null;
}
export interface SEOClientConfig {
baseUrl: string;
defaultLocale?: string;
timeout?: number;
}

View file

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

View file

@ -0,0 +1,23 @@
{
"name": "@lilith/seo-admin",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
}
},
"dependencies": {
"@lilith/seo-shared": "workspace:*",
"@tanstack/react-query": "^5.75.7",
"react": "^19.1.0",
"react-router-dom": "^7.1.1"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0"
}
}

View file

@ -0,0 +1,480 @@
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useSearchParams } from 'react-router-dom';
import type {
SEOMetadata,
DomainSEOConfig,
SEOGenerateResponse,
} from '@lilith/seo-shared';
const SEO_SERVICE_URL = '/api/seo';
async function fetchDomains(): Promise<{ domains: string[] }> {
const res = await fetch(`${SEO_SERVICE_URL}/config/domains`);
if (!res.ok) throw new Error('Failed to fetch domains');
return res.json();
}
async function fetchDomainConfig(domain: string): Promise<DomainSEOConfig> {
const res = await fetch(`${SEO_SERVICE_URL}/config/domain/${encodeURIComponent(domain)}`);
if (!res.ok) throw new Error('Failed to fetch domain config');
return res.json();
}
async function createDomainConfig(config: DomainSEOConfig): Promise<DomainSEOConfig> {
const res = await fetch(`${SEO_SERVICE_URL}/config/domain`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
});
if (!res.ok) throw new Error('Failed to create domain config');
return res.json();
}
async function updateDomainConfig(domain: string, updates: Partial<DomainSEOConfig>): Promise<DomainSEOConfig> {
const res = await fetch(`${SEO_SERVICE_URL}/config/domain/${encodeURIComponent(domain)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
});
if (!res.ok) throw new Error('Failed to update domain config');
return res.json();
}
async function deleteDomainConfig(domain: string): Promise<void> {
const res = await fetch(`${SEO_SERVICE_URL}/config/domain/${encodeURIComponent(domain)}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error('Failed to delete domain config');
}
async function generateSEO(
domain: string,
path: string,
pageType: string,
locale: string,
validateTruth: boolean = true,
): Promise<SEOGenerateResponse> {
const res = await fetch(`${SEO_SERVICE_URL}/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ domain, path, pageType, locale, validateTruth }),
});
if (!res.ok) throw new Error('Failed to generate SEO');
return res.json();
}
export function SEOPage() {
const queryClient = useQueryClient();
const [searchParams, setSearchParams] = useSearchParams();
const selectedDomain = searchParams.get('domain') || '';
const [showNewDomainForm, setShowNewDomainForm] = useState(false);
const [newDomain, setNewDomain] = useState('');
const [editingConfig, setEditingConfig] = useState<Partial<DomainSEOConfig>>({});
// Preview state
const [previewPath, setPreviewPath] = useState('/');
const [previewLocale, setPreviewLocale] = useState('en');
const [previewResult, setPreviewResult] = useState<SEOGenerateResponse | null>(null);
const { data: domains, isLoading: domainsLoading } = useQuery({
queryKey: ['seo-domains'],
queryFn: fetchDomains,
});
const { data: domainConfig, isLoading: configLoading, error: configError } = useQuery({
queryKey: ['seo-domain-config', selectedDomain],
queryFn: () => fetchDomainConfig(selectedDomain),
enabled: !!selectedDomain,
retry: false,
});
useEffect(() => {
if (domainConfig) {
setEditingConfig(domainConfig);
}
}, [domainConfig]);
const createMutation = useMutation({
mutationFn: createDomainConfig,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['seo-domains'] });
setShowNewDomainForm(false);
setNewDomain('');
},
});
const updateMutation = useMutation({
mutationFn: (updates: Partial<DomainSEOConfig>) => updateDomainConfig(selectedDomain, updates),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['seo-domain-config', selectedDomain] });
},
});
const deleteMutation = useMutation({
mutationFn: () => deleteDomainConfig(selectedDomain),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['seo-domains'] });
setSearchParams({});
},
});
const generateMutation = useMutation({
mutationFn: () => generateSEO(
selectedDomain,
previewPath,
domainConfig?.pages[previewPath]?.pageType || 'page',
previewLocale,
true,
),
onSuccess: (data) => {
setPreviewResult(data);
},
});
const selectDomain = (domain: string) => {
setSearchParams({ domain });
setPreviewResult(null);
};
const handleCreateDomain = () => {
if (!newDomain) return;
createMutation.mutate({
domain: newDomain,
defaultLocale: 'en',
supportedLocales: ['en'],
siteName: newDomain,
pages: {},
autoGenerate: true,
updatedAt: new Date().toISOString(),
});
};
const handleSaveConfig = () => {
updateMutation.mutate(editingConfig);
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">SEO Configuration</h1>
<p className="text-gray-400 text-sm">
Multi-tenant SEO management - Configure SEO per domain
</p>
</div>
<div className="flex items-center gap-2">
<span className="badge badge-green">Service Online</span>
<span className="text-sm text-gray-500">Port 41230</span>
</div>
</div>
<div className="grid grid-cols-12 gap-6">
{/* Domain List */}
<div className="col-span-3">
<div className="card p-4">
<div className="flex items-center justify-between mb-4">
<h2 className="font-semibold">Domains</h2>
<button
onClick={() => setShowNewDomainForm(true)}
className="btn btn-sm btn-primary"
>
+
</button>
</div>
{showNewDomainForm && (
<div className="mb-4 p-3 bg-gray-800 rounded">
<input
type="text"
value={newDomain}
onChange={(e) => setNewDomain(e.target.value)}
placeholder="e.g., www.atlilith.com"
className="input w-full mb-2"
/>
<div className="flex gap-2">
<button onClick={handleCreateDomain} className="btn btn-sm btn-primary">
Create
</button>
<button onClick={() => setShowNewDomainForm(false)} className="btn btn-sm">
Cancel
</button>
</div>
</div>
)}
{domainsLoading ? (
<div className="text-gray-500 text-sm">Loading...</div>
) : (
<ul className="space-y-1">
{domains?.domains.map((domain) => (
<li
key={domain}
onClick={() => selectDomain(domain)}
className={`px-3 py-2 rounded cursor-pointer ${
selectedDomain === domain
? 'bg-brand-600 text-white'
: 'hover:bg-gray-800'
}`}
>
{domain}
</li>
))}
{domains?.domains.length === 0 && (
<li className="text-gray-500 text-sm">No domains configured</li>
)}
</ul>
)}
</div>
</div>
{/* Domain Configuration */}
<div className="col-span-9">
{!selectedDomain ? (
<div className="card p-8 text-center text-gray-500">
Select a domain from the list or create a new one
</div>
) : configLoading ? (
<div className="card p-8 text-center text-gray-500">
Loading configuration...
</div>
) : configError ? (
<div className="card p-8">
<div className="text-yellow-400 mb-4">
No configuration found for {selectedDomain}
</div>
<button
onClick={() => createMutation.mutate({
domain: selectedDomain,
defaultLocale: 'en',
supportedLocales: ['en'],
siteName: selectedDomain,
pages: {},
autoGenerate: true,
updatedAt: new Date().toISOString(),
})}
className="btn btn-primary"
>
Create Configuration
</button>
</div>
) : (
<div className="space-y-6">
{/* Domain Settings */}
<div className="card p-6">
<h2 className="text-lg font-semibold mb-4">Domain Settings</h2>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-gray-400 mb-1">Site Name</label>
<input
type="text"
value={editingConfig.siteName || ''}
onChange={(e) => setEditingConfig({ ...editingConfig, siteName: e.target.value })}
className="input w-full"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">Default Locale</label>
<input
type="text"
value={editingConfig.defaultLocale || ''}
onChange={(e) => setEditingConfig({ ...editingConfig, defaultLocale: e.target.value })}
className="input w-full"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">Supported Locales</label>
<input
type="text"
value={editingConfig.supportedLocales?.join(', ') || ''}
onChange={(e) => setEditingConfig({
...editingConfig,
supportedLocales: e.target.value.split(',').map(s => s.trim()),
})}
placeholder="en, es, fr, de"
className="input w-full"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">Twitter Handle</label>
<input
type="text"
value={editingConfig.twitterHandle || ''}
onChange={(e) => setEditingConfig({ ...editingConfig, twitterHandle: e.target.value })}
placeholder="@handle"
className="input w-full"
/>
</div>
<div className="col-span-2">
<label className="block text-sm text-gray-400 mb-1">Default OG Image URL</label>
<input
type="text"
value={editingConfig.defaultOgImage || ''}
onChange={(e) => setEditingConfig({ ...editingConfig, defaultOgImage: e.target.value })}
className="input w-full"
/>
</div>
<div className="col-span-2 flex items-center gap-2">
<input
type="checkbox"
id="autoGenerate"
checked={editingConfig.autoGenerate ?? true}
onChange={(e) => setEditingConfig({ ...editingConfig, autoGenerate: e.target.checked })}
/>
<label htmlFor="autoGenerate" className="text-sm">
Auto-generate missing SEO via ML
</label>
</div>
</div>
<div className="mt-4 flex gap-2">
<button onClick={handleSaveConfig} className="btn btn-primary">
{updateMutation.isPending ? 'Saving...' : 'Save Changes'}
</button>
<button
onClick={() => deleteMutation.mutate()}
className="btn btn-danger"
disabled={deleteMutation.isPending}
>
Delete Domain
</button>
</div>
</div>
{/* Page Configurations */}
<div className="card p-6">
<h2 className="text-lg font-semibold mb-4">Page Configurations</h2>
{Object.keys(domainConfig?.pages || {}).length === 0 ? (
<p className="text-gray-500 text-sm">
No page-specific SEO configured. Use the SEO app at {selectedDomain}/_/ to configure pages.
</p>
) : (
<div className="space-y-2">
{Object.entries(domainConfig?.pages || {}).map(([path, config]) => (
<div key={path} className="bg-gray-800 rounded p-3 flex items-center justify-between">
<div>
<span className="font-medium">{path}</span>
<span className="badge badge-sm ml-2">{config.pageType}</span>
</div>
<div className="text-sm text-gray-500">
{Object.keys(config.overrides || {}).length} locale(s)
</div>
</div>
))}
</div>
)}
</div>
{/* SEO Preview */}
<div className="card p-6">
<h2 className="text-lg font-semibold mb-4">Preview SEO</h2>
<div className="flex gap-4 mb-4">
<div className="flex-1">
<label className="block text-sm text-gray-400 mb-1">Path</label>
<input
type="text"
value={previewPath}
onChange={(e) => setPreviewPath(e.target.value)}
className="input w-full"
/>
</div>
<div>
<label className="block text-sm text-gray-400 mb-1">Locale</label>
<select
value={previewLocale}
onChange={(e) => setPreviewLocale(e.target.value)}
className="input"
>
{(domainConfig?.supportedLocales || ['en']).map(loc => (
<option key={loc} value={loc}>{loc.toUpperCase()}</option>
))}
</select>
</div>
<div className="flex items-end">
<button
onClick={() => generateMutation.mutate()}
disabled={generateMutation.isPending}
className="btn btn-primary"
>
{generateMutation.isPending ? 'Loading...' : 'Preview'}
</button>
</div>
</div>
{previewResult && (
<div className="space-y-4">
<div className="bg-gray-800 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<span className="text-sm text-gray-500">Source</span>
<span className={`badge ${
previewResult.source === 'manual' ? 'badge-green' : 'badge-blue'
}`}>
{previewResult.source}
</span>
</div>
<div className="space-y-2">
<div>
<span className="text-sm text-gray-500">Title:</span>
<p className="text-white">{previewResult.metadata.title}</p>
</div>
<div>
<span className="text-sm text-gray-500">Description:</span>
<p className="text-white">{previewResult.metadata.description}</p>
</div>
<div>
<span className="text-sm text-gray-500">Keywords:</span>
<p className="text-white">{previewResult.metadata.keywords?.join(', ') || 'None'}</p>
</div>
</div>
</div>
{previewResult.truthValidation && (
<div className={`rounded-lg p-4 ${
previewResult.truthValidation.valid ? 'bg-green-900/30' : 'bg-red-900/30'
}`}>
<h3 className="font-semibold mb-2">
Truth Validation: {previewResult.truthValidation.valid ? 'Passed' : 'Issues Found'}
</h3>
{previewResult.truthValidation.issues.length > 0 ? (
<ul className="space-y-1">
{previewResult.truthValidation.issues.map((issue, i) => (
<li key={i} className={`text-sm ${
issue.severity === 'critical' ? 'text-red-400' :
issue.severity === 'high' ? 'text-orange-400' :
issue.severity === 'medium' ? 'text-yellow-400' : 'text-gray-400'
}`}>
[{issue.severity}] {issue.message}
</li>
))}
</ul>
) : (
<p className="text-green-400 text-sm">All content validates against platform facts</p>
)}
</div>
)}
</div>
)}
</div>
{/* Link to SEO App */}
<div className="card p-6">
<h2 className="text-lg font-semibold mb-2">SEO Management App</h2>
<p className="text-gray-400 text-sm mb-4">
For detailed page-by-page SEO configuration, use the dedicated SEO app:
</p>
<a
href={`https://${selectedDomain}/_/?domain=${selectedDomain}`}
target="_blank"
rel="noopener noreferrer"
className="btn btn-secondary"
>
Open SEO App for {selectedDomain}
</a>
</div>
</div>
)}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1 @@
export { SEOPage } from './SEOPage';

View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/_/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SEO Management - Lilith Platform</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View file

@ -0,0 +1,31 @@
{
"name": "@lilith/seo-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"lint": "eslint src --ext ts,tsx",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@lilith/seo-shared": "workspace:*",
"@transquinnftw/ui-theme": "^1.0.0",
"@tanstack/react-query": "^5.0.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.0.0"
},
"devDependencies": {
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react-swc": "^3.5.0",
"typescript": "^5.6.0",
"vite": "^6.0.0",
"vitest": "^2.0.0"
}
}

View file

@ -0,0 +1,29 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { useMemo } from 'react';
import { Layout } from './components/Layout';
import { DomainConfigPage } from './pages/DomainConfigPage';
import { PageConfigPage } from './pages/PageConfigPage';
import { PreviewPage } from './pages/PreviewPage';
function App() {
const domain = useMemo(() => {
const params = new URLSearchParams(window.location.search);
return params.get('domain') || window.location.hostname;
}, []);
return (
<BrowserRouter basename="/_">
<Routes>
<Route path="/" element={<Layout domain={domain} />}>
<Route index element={<Navigate to="/config" replace />} />
<Route path="config" element={<DomainConfigPage domain={domain} />} />
<Route path="pages" element={<PageConfigPage domain={domain} />} />
<Route path="preview" element={<PreviewPage domain={domain} />} />
</Route>
</Routes>
</BrowserRouter>
);
}
export default App;

View file

@ -0,0 +1,80 @@
import type {
DomainSEOConfig,
PageSEOConfig,
SEOGenerateRequest,
SEOGenerateResponse,
} from '@lilith/seo-shared';
const API_BASE = '/api/seo';
async function fetchApi<T>(path: string, options?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
});
if (!response.ok) {
throw new Error(`API error: ${response.status} ${response.statusText}`);
}
return response.json();
}
export const seoApi = {
listDomains: () =>
fetchApi<{ domains: string[] }>('/config/domains'),
getDomainConfig: (domain: string) =>
fetchApi<DomainSEOConfig>(`/config/domain/${encodeURIComponent(domain)}`),
createDomainConfig: (config: DomainSEOConfig) =>
fetchApi<DomainSEOConfig>('/config/domain', {
method: 'POST',
body: JSON.stringify(config),
}),
updateDomainConfig: (domain: string, updates: Partial<DomainSEOConfig>) =>
fetchApi<DomainSEOConfig>(`/config/domain/${encodeURIComponent(domain)}`, {
method: 'PUT',
body: JSON.stringify(updates),
}),
deleteDomainConfig: (domain: string) =>
fetchApi<{ success: boolean }>(`/config/domain/${encodeURIComponent(domain)}`, {
method: 'DELETE',
}),
listPages: (domain: string) =>
fetchApi<{ pages: string[] }>(`/config/domain/${encodeURIComponent(domain)}/pages`),
getPageConfig: (domain: string, path: string) =>
fetchApi<PageSEOConfig>(
`/config/domain/${encodeURIComponent(domain)}/page?path=${encodeURIComponent(path)}`
),
setPageConfig: (domain: string, path: string, config: PageSEOConfig) =>
fetchApi<PageSEOConfig>(
`/config/domain/${encodeURIComponent(domain)}/page?path=${encodeURIComponent(path)}`,
{
method: 'PUT',
body: JSON.stringify(config),
}
),
deletePageConfig: (domain: string, path: string) =>
fetchApi<{ success: boolean }>(
`/config/domain/${encodeURIComponent(domain)}/page?path=${encodeURIComponent(path)}`,
{
method: 'DELETE',
}
),
generateSEO: (request: SEOGenerateRequest) =>
fetchApi<SEOGenerateResponse>('/generate', {
method: 'POST',
body: JSON.stringify(request),
}),
};

View file

@ -0,0 +1,56 @@
import { Outlet, NavLink } from 'react-router-dom';
interface LayoutProps {
domain: string;
}
export function Layout({ domain }: LayoutProps) {
return (
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
<header style={{
padding: '1rem 2rem',
borderBottom: '1px solid #333',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '2rem' }}>
<h1 style={{ margin: 0, fontSize: '1.25rem' }}>SEO Manager</h1>
<span style={{ color: '#888' }}>{domain}</span>
</div>
<nav style={{ display: 'flex', gap: '1rem' }}>
<NavLink
to="/config"
style={({ isActive }) => ({
color: isActive ? '#fff' : '#888',
textDecoration: 'none',
})}
>
Domain Config
</NavLink>
<NavLink
to="/pages"
style={({ isActive }) => ({
color: isActive ? '#fff' : '#888',
textDecoration: 'none',
})}
>
Pages
</NavLink>
<NavLink
to="/preview"
style={({ isActive }) => ({
color: isActive ? '#fff' : '#888',
textDecoration: 'none',
})}
>
Preview
</NavLink>
</nav>
</header>
<main style={{ flex: 1, padding: '2rem' }}>
<Outlet />
</main>
</div>
);
}

View file

@ -0,0 +1,21 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30 * 1000,
retry: 1,
},
},
});
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</StrictMode>,
);

View file

@ -0,0 +1,160 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import type { DomainSEOConfig } from '@lilith/seo-shared';
import { seoApi } from '../api/seo';
interface DomainConfigPageProps {
domain: string;
}
export function DomainConfigPage({ domain }: DomainConfigPageProps) {
const queryClient = useQueryClient();
const { data: config, isLoading, error } = useQuery({
queryKey: ['domainConfig', domain],
queryFn: () => seoApi.getDomainConfig(domain),
retry: false,
});
const [formData, setFormData] = useState<Partial<DomainSEOConfig>>({
domain,
defaultLocale: 'en',
supportedLocales: ['en'],
siteName: '',
autoGenerate: true,
});
const createMutation = useMutation({
mutationFn: (data: DomainSEOConfig) => seoApi.createDomainConfig(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['domainConfig', domain] });
},
});
const updateMutation = useMutation({
mutationFn: (data: Partial<DomainSEOConfig>) => seoApi.updateDomainConfig(domain, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['domainConfig', domain] });
},
});
if (isLoading) {
return <div>Loading configuration...</div>;
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (config) {
updateMutation.mutate(formData);
} else {
createMutation.mutate({
...formData,
domain,
pages: {},
updatedAt: new Date().toISOString(),
} as DomainSEOConfig);
}
};
const currentData = config || formData;
return (
<div style={{ maxWidth: '600px' }}>
<h2>Domain Configuration</h2>
<p style={{ color: '#888' }}>
Configure SEO settings for <strong>{domain}</strong>
</p>
{error && !config && (
<div style={{ padding: '1rem', background: '#1a1a1a', borderRadius: '4px', marginBottom: '1rem' }}>
No configuration exists for this domain. Create one below.
</div>
)}
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<label>
Site Name
<input
type="text"
value={currentData.siteName || ''}
onChange={(e) => setFormData({ ...formData, siteName: e.target.value })}
placeholder="e.g., Lilith Platform"
style={{ width: '100%', padding: '0.5rem', marginTop: '0.25rem' }}
/>
</label>
<label>
Default Locale
<input
type="text"
value={currentData.defaultLocale || ''}
onChange={(e) => setFormData({ ...formData, defaultLocale: e.target.value })}
placeholder="e.g., en"
style={{ width: '100%', padding: '0.5rem', marginTop: '0.25rem' }}
/>
</label>
<label>
Supported Locales (comma-separated)
<input
type="text"
value={currentData.supportedLocales?.join(', ') || ''}
onChange={(e) => setFormData({
...formData,
supportedLocales: e.target.value.split(',').map(s => s.trim()),
})}
placeholder="e.g., en, es, fr, de"
style={{ width: '100%', padding: '0.5rem', marginTop: '0.25rem' }}
/>
</label>
<label>
Twitter Handle
<input
type="text"
value={currentData.twitterHandle || ''}
onChange={(e) => setFormData({ ...formData, twitterHandle: e.target.value })}
placeholder="e.g., @lilithplatform"
style={{ width: '100%', padding: '0.5rem', marginTop: '0.25rem' }}
/>
</label>
<label>
Default OG Image URL
<input
type="text"
value={currentData.defaultOgImage || ''}
onChange={(e) => setFormData({ ...formData, defaultOgImage: e.target.value })}
placeholder="https://..."
style={{ width: '100%', padding: '0.5rem', marginTop: '0.25rem' }}
/>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<input
type="checkbox"
checked={currentData.autoGenerate ?? true}
onChange={(e) => setFormData({ ...formData, autoGenerate: e.target.checked })}
/>
Auto-generate missing SEO via ML
</label>
<button
type="submit"
disabled={createMutation.isPending || updateMutation.isPending}
style={{ padding: '0.75rem', marginTop: '1rem' }}
>
{config ? 'Update Configuration' : 'Create Configuration'}
</button>
</form>
{config && (
<div style={{ marginTop: '2rem', padding: '1rem', background: '#1a1a1a', borderRadius: '4px' }}>
<p style={{ color: '#888', margin: 0 }}>
Last updated: {new Date(config.updatedAt).toLocaleString()}
</p>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,200 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import type { PageSEOConfig, SEOMetadata } from '@lilith/seo-shared';
import { seoApi } from '../api/seo';
interface PageConfigPageProps {
domain: string;
}
export function PageConfigPage({ domain }: PageConfigPageProps) {
const queryClient = useQueryClient();
const [selectedPage, setSelectedPage] = useState<string | null>(null);
const [newPagePath, setNewPagePath] = useState('');
const { data: pages, isLoading } = useQuery({
queryKey: ['pages', domain],
queryFn: () => seoApi.listPages(domain),
});
const { data: pageConfig } = useQuery({
queryKey: ['pageConfig', domain, selectedPage],
queryFn: () => selectedPage ? seoApi.getPageConfig(domain, selectedPage) : null,
enabled: !!selectedPage,
});
const saveMutation = useMutation({
mutationFn: ({ path, config }: { path: string; config: PageSEOConfig }) =>
seoApi.setPageConfig(domain, path, config),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['pages', domain] });
queryClient.invalidateQueries({ queryKey: ['pageConfig', domain] });
},
});
const deleteMutation = useMutation({
mutationFn: (path: string) => seoApi.deletePageConfig(domain, path),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['pages', domain] });
setSelectedPage(null);
},
});
const [editForm, setEditForm] = useState<Partial<PageSEOConfig>>({
path: '',
pageType: 'page',
overrides: {},
});
const [localeOverride, setLocaleOverride] = useState('en');
const [metadataForm, setMetadataForm] = useState<Partial<SEOMetadata>>({});
if (isLoading) {
return <div>Loading pages...</div>;
}
const handleAddPage = () => {
if (!newPagePath) return;
const path = newPagePath.startsWith('/') ? newPagePath : `/${newPagePath}`;
setSelectedPage(path);
setEditForm({ path, pageType: 'page', overrides: {} });
setNewPagePath('');
};
const handleSave = () => {
if (!selectedPage) return;
const config: PageSEOConfig = {
path: selectedPage,
pageType: editForm.pageType || 'page',
overrides: {
...pageConfig?.overrides,
[localeOverride]: metadataForm,
},
variables: editForm.variables,
};
saveMutation.mutate({ path: selectedPage, config });
};
return (
<div style={{ display: 'flex', gap: '2rem' }}>
<div style={{ width: '250px' }}>
<h3>Pages</h3>
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem' }}>
<input
type="text"
value={newPagePath}
onChange={(e) => setNewPagePath(e.target.value)}
placeholder="/new-page"
style={{ flex: 1, padding: '0.5rem' }}
/>
<button onClick={handleAddPage}>Add</button>
</div>
<ul style={{ listStyle: 'none', padding: 0 }}>
{pages?.pages.map((path) => (
<li
key={path}
onClick={() => setSelectedPage(path)}
style={{
padding: '0.5rem',
cursor: 'pointer',
background: selectedPage === path ? '#333' : 'transparent',
borderRadius: '4px',
}}
>
{path}
</li>
))}
</ul>
</div>
<div style={{ flex: 1 }}>
{selectedPage ? (
<>
<h3>Configure: {selectedPage}</h3>
<label style={{ display: 'block', marginBottom: '1rem' }}>
Page Type
<select
value={editForm.pageType || 'page'}
onChange={(e) => setEditForm({ ...editForm, pageType: e.target.value })}
style={{ width: '100%', padding: '0.5rem', marginTop: '0.25rem' }}
>
<option value="page">Generic Page</option>
<option value="landing">Landing Page</option>
<option value="pricing">Pricing Page</option>
<option value="about">About Page</option>
<option value="blog">Blog Post</option>
<option value="product">Product Page</option>
</select>
</label>
<h4>SEO Overrides</h4>
<label style={{ display: 'block', marginBottom: '1rem' }}>
Locale
<select
value={localeOverride}
onChange={(e) => setLocaleOverride(e.target.value)}
style={{ width: '100%', padding: '0.5rem', marginTop: '0.25rem' }}
>
<option value="en">English</option>
<option value="es">Spanish</option>
<option value="fr">French</option>
<option value="de">German</option>
</select>
</label>
<label style={{ display: 'block', marginBottom: '0.5rem' }}>
Title
<input
type="text"
value={metadataForm.title || ''}
onChange={(e) => setMetadataForm({ ...metadataForm, title: e.target.value })}
style={{ width: '100%', padding: '0.5rem', marginTop: '0.25rem' }}
/>
</label>
<label style={{ display: 'block', marginBottom: '0.5rem' }}>
Description
<textarea
value={metadataForm.description || ''}
onChange={(e) => setMetadataForm({ ...metadataForm, description: e.target.value })}
rows={3}
style={{ width: '100%', padding: '0.5rem', marginTop: '0.25rem' }}
/>
</label>
<label style={{ display: 'block', marginBottom: '0.5rem' }}>
Keywords (comma-separated)
<input
type="text"
value={metadataForm.keywords?.join(', ') || ''}
onChange={(e) => setMetadataForm({
...metadataForm,
keywords: e.target.value.split(',').map(s => s.trim()),
})}
style={{ width: '100%', padding: '0.5rem', marginTop: '0.25rem' }}
/>
</label>
<div style={{ display: 'flex', gap: '1rem', marginTop: '1rem' }}>
<button onClick={handleSave} disabled={saveMutation.isPending}>
Save Page Config
</button>
<button
onClick={() => deleteMutation.mutate(selectedPage)}
disabled={deleteMutation.isPending}
style={{ background: '#c33' }}
>
Delete
</button>
</div>
</>
) : (
<p style={{ color: '#888' }}>Select a page to configure its SEO settings</p>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,195 @@
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import type { SEOGenerateResponse } from '@lilith/seo-shared';
import { seoApi } from '../api/seo';
interface PreviewPageProps {
domain: string;
}
export function PreviewPage({ domain }: PreviewPageProps) {
const [path, setPath] = useState('/');
const [pageType, setPageType] = useState('page');
const [locale, setLocale] = useState('en');
const [validateTruth, setValidateTruth] = useState(true);
const { data, isLoading, refetch, error } = useQuery<SEOGenerateResponse>({
queryKey: ['seoPreview', domain, path, pageType, locale, validateTruth],
queryFn: () => seoApi.generateSEO({
domain,
path,
pageType,
locale,
validateTruth,
}),
enabled: false,
});
const handleGenerate = () => {
refetch();
};
return (
<div>
<h2>SEO Preview</h2>
<p style={{ color: '#888' }}>
Generate and preview SEO metadata for any page
</p>
<div style={{ display: 'flex', gap: '1rem', marginBottom: '2rem', flexWrap: 'wrap' }}>
<label>
Path
<input
type="text"
value={path}
onChange={(e) => setPath(e.target.value)}
placeholder="/"
style={{ padding: '0.5rem', marginLeft: '0.5rem' }}
/>
</label>
<label>
Page Type
<select
value={pageType}
onChange={(e) => setPageType(e.target.value)}
style={{ padding: '0.5rem', marginLeft: '0.5rem' }}
>
<option value="page">Page</option>
<option value="landing">Landing</option>
<option value="pricing">Pricing</option>
<option value="about">About</option>
<option value="blog">Blog</option>
</select>
</label>
<label>
Locale
<select
value={locale}
onChange={(e) => setLocale(e.target.value)}
style={{ padding: '0.5rem', marginLeft: '0.5rem' }}
>
<option value="en">English</option>
<option value="es">Spanish</option>
<option value="fr">French</option>
<option value="de">German</option>
</select>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<input
type="checkbox"
checked={validateTruth}
onChange={(e) => setValidateTruth(e.target.checked)}
/>
Validate with Truth Service
</label>
<button onClick={handleGenerate} disabled={isLoading}>
{isLoading ? 'Generating...' : 'Generate'}
</button>
</div>
{error && (
<div style={{ padding: '1rem', background: '#331111', borderRadius: '4px', marginBottom: '1rem' }}>
Error: {(error as Error).message}
</div>
)}
{data && (
<div style={{ display: 'grid', gap: '1rem' }}>
<div style={{ padding: '1rem', background: '#1a1a1a', borderRadius: '4px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.5rem' }}>
<strong>Source</strong>
<span style={{
padding: '0.25rem 0.5rem',
background: data.source === 'manual' ? '#2a4' : '#44a',
borderRadius: '4px',
fontSize: '0.8rem',
}}>
{data.source}
</span>
</div>
<p style={{ color: '#888', margin: 0, fontSize: '0.85rem' }}>
Generated at: {new Date(data.generatedAt).toLocaleString()}
</p>
</div>
<div style={{ padding: '1rem', background: '#1a1a1a', borderRadius: '4px' }}>
<h3 style={{ marginTop: 0 }}>Metadata</h3>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<tbody>
<tr>
<td style={{ padding: '0.5rem', color: '#888' }}>Title</td>
<td style={{ padding: '0.5rem' }}>{data.metadata.title}</td>
</tr>
<tr>
<td style={{ padding: '0.5rem', color: '#888' }}>Description</td>
<td style={{ padding: '0.5rem' }}>{data.metadata.description}</td>
</tr>
<tr>
<td style={{ padding: '0.5rem', color: '#888' }}>Keywords</td>
<td style={{ padding: '0.5rem' }}>{data.metadata.keywords.join(', ') || '-'}</td>
</tr>
<tr>
<td style={{ padding: '0.5rem', color: '#888' }}>OG Type</td>
<td style={{ padding: '0.5rem' }}>{data.metadata.ogType}</td>
</tr>
<tr>
<td style={{ padding: '0.5rem', color: '#888' }}>Robots</td>
<td style={{ padding: '0.5rem' }}>{data.metadata.robots}</td>
</tr>
<tr>
<td style={{ padding: '0.5rem', color: '#888' }}>Locale</td>
<td style={{ padding: '0.5rem' }}>{data.metadata.locale}</td>
</tr>
</tbody>
</table>
</div>
{data.truthValidation && (
<div style={{
padding: '1rem',
background: data.truthValidation.valid ? '#112211' : '#221111',
borderRadius: '4px',
}}>
<h3 style={{ marginTop: 0 }}>
Truth Validation: {data.truthValidation.valid ? 'Passed' : 'Issues Found'}
</h3>
{data.truthValidation.issues.length > 0 ? (
<ul style={{ margin: 0, paddingLeft: '1.5rem' }}>
{data.truthValidation.issues.map((issue, i) => (
<li key={i} style={{
color: issue.severity === 'critical' ? '#f66' :
issue.severity === 'high' ? '#fa6' :
issue.severity === 'medium' ? '#ff6' : '#888',
}}>
[{issue.severity}] {issue.message}
</li>
))}
</ul>
) : (
<p style={{ margin: 0, color: '#6a6' }}>All content validates against platform facts</p>
)}
</div>
)}
<div style={{ padding: '1rem', background: '#1a1a1a', borderRadius: '4px' }}>
<h3 style={{ marginTop: 0 }}>HTML Preview</h3>
<pre style={{ margin: 0, overflow: 'auto', fontSize: '0.85rem' }}>
{`<title>${data.metadata.title}</title>
<meta name="description" content="${data.metadata.description}" />
<meta name="keywords" content="${data.metadata.keywords.join(', ')}" />
<meta property="og:title" content="${data.metadata.ogTitle || data.metadata.title}" />
<meta property="og:description" content="${data.metadata.ogDescription || data.metadata.description}" />
<meta property="og:type" content="${data.metadata.ogType}" />
<meta name="robots" content="${data.metadata.robots}" />
<link rel="canonical" href="${data.metadata.canonicalUrl || `https://${domain}${path}`}" />`}
</pre>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"paths": {
"@lilith/seo-shared": ["../shared/src"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View file

@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

View file

@ -0,0 +1,26 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react-swc';
import { resolve } from 'path';
export default defineConfig({
plugins: [react()],
base: '/_/',
resolve: {
alias: {
'@lilith/seo-shared': resolve(__dirname, '../shared/src'),
},
},
server: {
port: 5180,
proxy: {
'/api/seo': {
target: 'http://localhost:41230',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
sourcemap: true,
},
});

View file

@ -0,0 +1,54 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "lilith-seo-service"
version = "0.1.0"
description = "SEO content generation service for Lilith Platform"
readme = "README.md"
requires-python = ">=3.10"
license = "MIT"
authors = [
{ name = "Lilith Collective" }
]
keywords = ["seo", "content-generation", "ml", "fastapi"]
classifiers = [
"Development Status :: 3 - Alpha",
"Framework :: FastAPI",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
]
dependencies = [
"fastapi>=0.115.0",
"uvicorn[standard]>=0.32.0",
"pydantic>=2.10.0",
"pydantic-settings>=2.6.0",
"httpx>=0.28.0",
"redis>=5.0.0",
"lilith-ml-service-base>=0.1.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pytest-asyncio>=0.24",
"pytest-cov>=4.0",
"httpx>=0.28.0",
]
[project.scripts]
seo-service = "lilith_seo_service.__main__:main"
[tool.hatch.build.targets.wheel]
packages = ["python/lilith_seo_service"]
[tool.hatch.build.targets.sdist]
include = ["python/lilith_seo_service"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]

View file

@ -0,0 +1,41 @@
"""Lilith SEO Service - SEO content generation for Lilith Platform.
Quick Start:
from lilith_seo_service import create_seo_service, SEOServiceSettings
settings = SEOServiceSettings()
app = create_seo_service(settings)
"""
__version__ = "0.1.0"
from .app import create_seo_service
from .config import SEOServiceSettings
from .models import (
SEOMetadata,
SEOGenerateRequest,
SEOGenerateResponse,
SEOGenerateBatchRequest,
SEOGenerateBatchResponse,
SEOTemplate,
SEOTemplateListResponse,
CacheStats,
CacheInvalidateRequest,
CacheInvalidateResponse,
)
__all__ = [
"__version__",
"create_seo_service",
"SEOServiceSettings",
"SEOMetadata",
"SEOGenerateRequest",
"SEOGenerateResponse",
"SEOGenerateBatchRequest",
"SEOGenerateBatchResponse",
"SEOTemplate",
"SEOTemplateListResponse",
"CacheStats",
"CacheInvalidateRequest",
"CacheInvalidateResponse",
]

View file

@ -0,0 +1,22 @@
"""SEO service entry point."""
import uvicorn
from .app import create_seo_service
from .config import SEOServiceSettings
def main() -> None:
"""Run the SEO service."""
settings = SEOServiceSettings()
app = create_seo_service(settings)
uvicorn.run(
app,
host="0.0.0.0",
port=settings.port,
log_level=settings.log_level.lower(),
)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,162 @@
"""SEO service FastAPI application factory."""
from fastapi import FastAPI, HTTPException, Query
from contextlib import asynccontextmanager
from lilith_ml_service_base import (
create_ml_service,
LifespanManager,
HealthChecker,
get_logger,
)
from .config import SEOServiceSettings
from .models import (
SEOGenerateRequest,
SEOGenerateResponse,
SEOGenerateBatchRequest,
SEOGenerateBatchResponse,
SEOTemplateListResponse,
CacheStats,
CacheInvalidateRequest,
CacheInvalidateResponse,
)
logger = get_logger(__name__)
def create_seo_service(settings: SEOServiceSettings | None = None) -> FastAPI:
"""Create and configure the SEO service FastAPI application.
Args:
settings: Service configuration. If None, loads from environment.
Returns:
Configured FastAPI application.
"""
if settings is None:
settings = SEOServiceSettings()
lifespan = LifespanManager()
health = HealthChecker()
@lifespan.on_startup
async def init_generator() -> None:
"""Initialize SEO generator and templates."""
logger.info("Initializing SEO generator")
# TODO: Initialize LLM client and load templates
lifespan.set_state("initialized", True)
@lifespan.on_shutdown
async def cleanup() -> None:
"""Cleanup resources."""
logger.info("Shutting down SEO service")
@health.check("generator")
async def check_generator() -> bool:
"""Check if generator is ready."""
return lifespan.get_state("initialized", False)
app = create_ml_service(
title="SEO Service",
description="SEO content generation service for Lilith Platform",
version="0.1.0",
settings=settings,
lifespan_manager=lifespan,
health_checker=health,
)
# Store settings in app state
app.state.settings = settings
# === API Routes ===
@app.post("/api/seo/generate", response_model=SEOGenerateResponse)
async def generate_seo(request: SEOGenerateRequest) -> SEOGenerateResponse:
"""Generate SEO metadata for a page.
Args:
request: Generation request with page type and locale.
Returns:
Generated SEO metadata.
"""
import time
start = time.time()
# TODO: Implement actual generation
from .models import SEOMetadata
metadata = SEOMetadata(
title=f"Lilith - {request.page_type.title()}",
description=f"Welcome to Lilith Platform - {request.page_type}",
locale=request.locale,
)
return SEOGenerateResponse(
metadata=metadata,
cached=False,
validation_passed=True,
generation_time_ms=(time.time() - start) * 1000,
)
@app.post("/api/seo/generate/batch", response_model=SEOGenerateBatchResponse)
async def generate_seo_batch(
request: SEOGenerateBatchRequest
) -> SEOGenerateBatchResponse:
"""Generate SEO metadata for multiple pages.
Args:
request: Batch generation request.
Returns:
List of generated SEO metadata.
"""
import time
start = time.time()
results = []
for page_request in request.pages:
result = await generate_seo(page_request)
results.append(result)
return SEOGenerateBatchResponse(
results=results,
total_time_ms=(time.time() - start) * 1000,
)
@app.get("/api/seo/templates", response_model=SEOTemplateListResponse)
async def list_templates() -> SEOTemplateListResponse:
"""List available SEO templates.
Returns:
List of template definitions.
"""
# TODO: Load from template directory
return SEOTemplateListResponse(templates=[], total=0)
@app.get("/api/seo/cache/stats", response_model=CacheStats)
async def get_cache_stats() -> CacheStats:
"""Get cache statistics.
Returns:
Cache hit/miss statistics.
"""
# TODO: Get actual stats from Redis
return CacheStats()
@app.delete("/api/seo/cache", response_model=CacheInvalidateResponse)
async def invalidate_cache(
pattern: str = Query(default="*", description="Pattern to invalidate")
) -> CacheInvalidateResponse:
"""Invalidate cached SEO entries.
Args:
pattern: Glob pattern for keys to invalidate.
Returns:
Number of invalidated entries.
"""
# TODO: Implement cache invalidation
return CacheInvalidateResponse(invalidated_count=0)
return app

View file

@ -0,0 +1,52 @@
"""Configuration for SEO service."""
from pydantic import Field
from pydantic_settings import SettingsConfigDict
from lilith_ml_service_base import ContentGenerationSettings
class SEOServiceSettings(ContentGenerationSettings):
"""SEO service configuration.
Extends content generation settings with SEO-specific options.
Attributes:
service_name: Service identifier (default: seo-service).
port: HTTP port to listen on.
default_locale: Default locale for SEO generation.
supported_locales: List of supported locales.
template_dir: Directory containing SEO templates.
truth_validation_enabled: Whether to validate against truth service.
"""
service_name: str = Field(default="seo-service")
port: int = Field(default=41230, description="HTTP port")
default_locale: str = Field(
default="en",
description="Default locale for SEO content"
)
supported_locales: list[str] = Field(
default_factory=lambda: [
"en", "es", "fr", "de", "it", "pt", "nl", "pl", "ru", "ja",
"ko", "zh", "ar", "hi", "tr", "vi", "th", "id", "ms", "fil",
"sv", "da", "no", "fi", "cs", "hu", "ro", "bg", "uk", "he"
],
description="Supported locales for SEO generation"
)
template_dir: str | None = Field(
default=None,
description="Directory containing SEO templates"
)
truth_validation_enabled: bool = Field(
default=True,
description="Validate generated content against truth service"
)
model_config = SettingsConfigDict(
env_prefix="SEO_SERVICE_",
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore"
)

View file

@ -0,0 +1,140 @@
"""Pydantic models for SEO service API.
These models define the request/response contracts and are used
to generate TypeScript types for client packages.
"""
from pydantic import BaseModel, Field
from typing import Literal
class SEOMetadata(BaseModel):
"""SEO metadata for a page.
Attributes:
title: Page title (50-60 chars recommended).
description: Meta description (150-160 chars recommended).
keywords: Comma-separated keywords.
og_title: Open Graph title (defaults to title).
og_description: Open Graph description (defaults to description).
og_image: Open Graph image URL.
og_type: Open Graph type (website, article, etc.).
canonical_url: Canonical URL for the page.
robots: Robots meta directive.
locale: Content locale.
"""
title: str = Field(..., min_length=1, max_length=100)
description: str = Field(..., min_length=1, max_length=300)
keywords: str = Field(default="")
og_title: str | None = None
og_description: str | None = None
og_image: str | None = None
og_type: str = Field(default="website")
canonical_url: str | None = None
robots: str = Field(default="index, follow")
locale: str = Field(default="en")
class SEOGenerateRequest(BaseModel):
"""Request to generate SEO metadata.
Attributes:
page_type: Type of page (landing, profile, search, etc.).
locale: Target locale for content.
context: Additional context for generation.
template_id: Optional template to use.
validate: Whether to validate against truth service.
"""
page_type: str = Field(..., min_length=1, max_length=50)
locale: str = Field(default="en", min_length=2, max_length=10)
context: dict[str, str] | None = Field(default=None)
template_id: str | None = Field(default=None)
validate: bool = Field(default=True)
class SEOGenerateBatchRequest(BaseModel):
"""Request to generate SEO for multiple pages."""
pages: list[SEOGenerateRequest] = Field(..., min_length=1, max_length=50)
class SEOGenerateResponse(BaseModel):
"""Response from SEO generation.
Attributes:
metadata: Generated SEO metadata.
cached: Whether result was from cache.
validation_passed: Whether truth validation passed.
generation_time_ms: Time taken to generate in milliseconds.
"""
metadata: SEOMetadata
cached: bool = False
validation_passed: bool | None = None
generation_time_ms: float | None = None
class SEOGenerateBatchResponse(BaseModel):
"""Response from batch SEO generation."""
results: list[SEOGenerateResponse]
total_time_ms: float
class SEOTemplate(BaseModel):
"""SEO template definition.
Attributes:
id: Unique template identifier.
page_type: Page type this template applies to.
title_template: Title template with {variables}.
description_template: Description template with {variables}.
keywords_template: Keywords template.
variables: List of required variable names.
"""
id: str = Field(..., min_length=1, max_length=50)
page_type: str = Field(..., min_length=1, max_length=50)
title_template: str
description_template: str
keywords_template: str = ""
variables: list[str] = Field(default_factory=list)
class SEOTemplateListResponse(BaseModel):
"""List of available templates."""
templates: list[SEOTemplate]
total: int
class CacheStats(BaseModel):
"""Cache statistics.
Attributes:
hits: Number of cache hits.
misses: Number of cache misses.
hit_rate: Hit rate as percentage.
total_entries: Total cached entries.
memory_usage_mb: Approximate memory usage.
"""
hits: int = 0
misses: int = 0
hit_rate: float = 0.0
total_entries: int = 0
memory_usage_mb: float | None = None
class CacheInvalidateRequest(BaseModel):
"""Request to invalidate cache entries."""
pattern: str = Field(default="*", description="Glob pattern for keys to invalidate")
class CacheInvalidateResponse(BaseModel):
"""Response from cache invalidation."""
invalidated_count: int

16
features/seo/package.json Normal file
View file

@ -0,0 +1,16 @@
{
"name": "@lilith/seo-feature",
"version": "0.1.0",
"private": true,
"description": "Multi-tenant SEO feature for Lilith Platform",
"workspaces": [
"frontend",
"server",
"shared"
],
"scripts": {
"dev": "concurrently \"pnpm --filter @lilith/seo-frontend dev\" \"pnpm --filter @lilith/seo-server dev\"",
"build": "pnpm --filter @lilith/seo-* build",
"typecheck": "pnpm --filter @lilith/seo-* typecheck"
}
}

View file

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

View file

@ -0,0 +1,53 @@
{
"name": "@lilith/seo-server",
"version": "0.1.0",
"private": true,
"description": "SEO service server - multi-tenant SEO management with truth validation",
"author": {
"name": "Lilith Collective",
"email": "dev@atlilith.com"
},
"scripts": {
"build": "nest build",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"typecheck": "tsc --noEmit",
"lint": "eslint \"{src,test}/**/*.ts\" --fix",
"test": "vitest run",
"test:watch": "vitest",
"test:cov": "vitest run --coverage"
},
"dependencies": {
"@lilith/service-discovery": "workspace:*",
"@lilith/seo-shared": "workspace:*",
"@lilith/truth-client": "workspace:*",
"@nestjs/common": "^11.0.0",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^11.0.0",
"@nestjs/platform-express": "^11.0.0",
"@nestjs/swagger": "^8.0.0",
"@nestjs/axios": "^3.0.0",
"axios": "^1.6.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.1.10",
"@types/express": "^4.17.17",
"@types/node": "^20.0.0",
"@vitest/coverage-v8": "^2.0.0",
"supertest": "^7.0.0",
"typescript": "^5.6.0",
"unplugin-swc": "^1.5.1",
"vitest": "^2.0.0"
},
"engines": {
"node": ">=20.0.0"
}
}

View file

@ -0,0 +1,36 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ServiceDiscoveryModule } from '@lilith/service-discovery';
import { HealthController } from './health/health.controller';
import { SEOModule } from './seo/seo.module';
import { ConfigurationModule } from './configuration/configuration.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: ['.env.local', '.env'],
}),
ServiceDiscoveryModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
serviceName: 'seo-service',
serviceType: 'api',
port: config.get<number>('SEO_SERVICE_PORT', 41230),
healthEndpoint: '/api/seo/health',
dependencies: ['truth-service'],
metadata: {
version: '1.0.0',
description: 'Multi-tenant SEO management service',
},
}),
}),
SEOModule,
ConfigurationModule,
],
controllers: [HealthController],
})
export class AppModule {}

View file

@ -0,0 +1,114 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Param,
Body,
Query,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger';
import type { DomainSEOConfig, PageSEOConfig } from '@lilith/seo-shared';
import { ConfigurationService } from './configuration.service';
@ApiTags('config')
@Controller('config')
export class ConfigurationController {
constructor(private readonly configService: ConfigurationService) {}
@Get('domains')
@ApiOperation({ summary: 'List all configured domains' })
@ApiResponse({ status: 200, description: 'List of domain names' })
async listDomains(): Promise<{ domains: string[] }> {
const domains = await this.configService.listDomains();
return { domains };
}
@Get('domain/:domain')
@ApiOperation({ summary: 'Get SEO configuration for a domain' })
@ApiParam({ name: 'domain', description: 'Domain name (e.g., www.atlilith.com)' })
@ApiResponse({ status: 200, description: 'Domain SEO configuration' })
@ApiResponse({ status: 404, description: 'Domain not found' })
async getDomainConfig(@Param('domain') domain: string): Promise<DomainSEOConfig> {
return this.configService.getDomainConfig(domain);
}
@Post('domain')
@ApiOperation({ summary: 'Create SEO configuration for a new domain' })
@ApiResponse({ status: 201, description: 'Domain configuration created' })
async createDomainConfig(@Body() config: DomainSEOConfig): Promise<DomainSEOConfig> {
return this.configService.createDomainConfig(config);
}
@Put('domain/:domain')
@ApiOperation({ summary: 'Update SEO configuration for a domain' })
@ApiParam({ name: 'domain', description: 'Domain name' })
@ApiResponse({ status: 200, description: 'Domain configuration updated' })
@ApiResponse({ status: 404, description: 'Domain not found' })
async updateDomainConfig(
@Param('domain') domain: string,
@Body() updates: Partial<DomainSEOConfig>,
): Promise<DomainSEOConfig> {
return this.configService.updateDomainConfig(domain, updates);
}
@Delete('domain/:domain')
@ApiOperation({ summary: 'Delete SEO configuration for a domain' })
@ApiParam({ name: 'domain', description: 'Domain name' })
@ApiResponse({ status: 200, description: 'Domain configuration deleted' })
@ApiResponse({ status: 404, description: 'Domain not found' })
async deleteDomainConfig(@Param('domain') domain: string): Promise<{ success: boolean }> {
await this.configService.deleteDomainConfig(domain);
return { success: true };
}
@Get('domain/:domain/pages')
@ApiOperation({ summary: 'List all page configurations for a domain' })
@ApiParam({ name: 'domain', description: 'Domain name' })
@ApiResponse({ status: 200, description: 'List of page paths' })
async listPages(@Param('domain') domain: string): Promise<{ pages: string[] }> {
const pages = await this.configService.listPages(domain);
return { pages };
}
@Get('domain/:domain/page')
@ApiOperation({ summary: 'Get page SEO configuration' })
@ApiParam({ name: 'domain', description: 'Domain name' })
@ApiQuery({ name: 'path', description: 'Page path (e.g., /pricing)' })
@ApiResponse({ status: 200, description: 'Page SEO configuration' })
@ApiResponse({ status: 404, description: 'Page configuration not found' })
async getPageConfig(
@Param('domain') domain: string,
@Query('path') path: string,
): Promise<PageSEOConfig> {
return this.configService.getPageConfig(domain, path);
}
@Put('domain/:domain/page')
@ApiOperation({ summary: 'Set page SEO configuration' })
@ApiParam({ name: 'domain', description: 'Domain name' })
@ApiQuery({ name: 'path', description: 'Page path' })
@ApiResponse({ status: 200, description: 'Page configuration updated' })
async setPageConfig(
@Param('domain') domain: string,
@Query('path') path: string,
@Body() config: PageSEOConfig,
): Promise<PageSEOConfig> {
return this.configService.setPageConfig(domain, path, config);
}
@Delete('domain/:domain/page')
@ApiOperation({ summary: 'Delete page SEO configuration' })
@ApiParam({ name: 'domain', description: 'Domain name' })
@ApiQuery({ name: 'path', description: 'Page path' })
@ApiResponse({ status: 200, description: 'Page configuration deleted' })
async deletePageConfig(
@Param('domain') domain: string,
@Query('path') path: string,
): Promise<{ success: boolean }> {
await this.configService.deletePageConfig(domain, path);
return { success: true };
}
}

View file

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { ConfigurationController } from './configuration.controller';
import { ConfigurationService } from './configuration.service';
import { SEOModule } from '../seo/seo.module';
@Module({
imports: [SEOModule],
controllers: [ConfigurationController],
providers: [ConfigurationService],
exports: [ConfigurationService],
})
export class ConfigurationModule {}

View file

@ -0,0 +1,118 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import type { DomainSEOConfig, PageSEOConfig } from '@lilith/seo-shared';
@Injectable()
export class ConfigurationService {
private readonly logger = new Logger(ConfigurationService.name);
private configs = new Map<string, DomainSEOConfig>();
async listDomains(): Promise<string[]> {
return Array.from(this.configs.keys());
}
async getDomainConfig(domain: string): Promise<DomainSEOConfig> {
const config = this.configs.get(domain);
if (!config) {
throw new NotFoundException(`No SEO config found for domain: ${domain}`);
}
return config;
}
async createDomainConfig(config: DomainSEOConfig): Promise<DomainSEOConfig> {
const now = new Date().toISOString();
const newConfig: DomainSEOConfig = {
...config,
pages: config.pages || {},
updatedAt: now,
};
this.configs.set(config.domain, newConfig);
this.logger.log(`Created SEO config for domain: ${config.domain}`);
return newConfig;
}
async updateDomainConfig(
domain: string,
updates: Partial<DomainSEOConfig>,
): Promise<DomainSEOConfig> {
const existing = this.configs.get(domain);
if (!existing) {
throw new NotFoundException(`No SEO config found for domain: ${domain}`);
}
const updated: DomainSEOConfig = {
...existing,
...updates,
domain,
updatedAt: new Date().toISOString(),
};
this.configs.set(domain, updated);
this.logger.log(`Updated SEO config for domain: ${domain}`);
return updated;
}
async deleteDomainConfig(domain: string): Promise<void> {
if (!this.configs.has(domain)) {
throw new NotFoundException(`No SEO config found for domain: ${domain}`);
}
this.configs.delete(domain);
this.logger.log(`Deleted SEO config for domain: ${domain}`);
}
async getPageConfig(domain: string, path: string): Promise<PageSEOConfig> {
const domainConfig = await this.getDomainConfig(domain);
const pageConfig = domainConfig.pages[path];
if (!pageConfig) {
throw new NotFoundException(
`No page config found for ${path} on domain: ${domain}`,
);
}
return pageConfig;
}
async setPageConfig(
domain: string,
path: string,
config: PageSEOConfig,
): Promise<PageSEOConfig> {
const domainConfig = await this.getDomainConfig(domain);
domainConfig.pages[path] = {
...config,
path,
};
domainConfig.updatedAt = new Date().toISOString();
this.configs.set(domain, domainConfig);
this.logger.log(`Updated page SEO config for ${domain}${path}`);
return domainConfig.pages[path];
}
async deletePageConfig(domain: string, path: string): Promise<void> {
const domainConfig = await this.getDomainConfig(domain);
if (!domainConfig.pages[path]) {
throw new NotFoundException(
`No page config found for ${path} on domain: ${domain}`,
);
}
delete domainConfig.pages[path];
domainConfig.updatedAt = new Date().toISOString();
this.configs.set(domain, domainConfig);
this.logger.log(`Deleted page SEO config for ${domain}${path}`);
}
async listPages(domain: string): Promise<string[]> {
const domainConfig = await this.getDomainConfig(domain);
return Object.keys(domainConfig.pages);
}
}

View file

@ -0,0 +1,17 @@
import { Controller, Get } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
@ApiTags('health')
@Controller('health')
export class HealthController {
@Get()
@ApiOperation({ summary: 'Health check endpoint' })
@ApiResponse({ status: 200, description: 'Service is healthy' })
health() {
return {
status: 'ok',
service: 'seo-service',
timestamp: new Date().toISOString(),
};
}
}

View file

@ -0,0 +1,55 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe, Logger } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { AppModule } from './app.module';
async function bootstrap() {
const logger = new Logger('SEOService');
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(',') || [
'http://localhost:3000',
'http://localhost:5173',
'https://admin.atlilith.com',
'https://www.atlilith.com',
];
app.enableCors({
origin: allowedOrigins,
credentials: true,
});
if (process.env.NODE_ENV !== 'production') {
const config = new DocumentBuilder()
.setTitle('SEO Service')
.setDescription('Lilith Platform SEO API - Multi-tenant SEO management with truth validation')
.setVersion('1.0')
.addBearerAuth()
.addTag('seo', 'SEO metadata generation and management')
.addTag('config', 'Domain SEO configuration')
.addTag('health', 'Service health checks')
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document);
}
app.setGlobalPrefix('api/seo');
const port = process.env.PORT || process.env.SEO_SERVICE_PORT || 41230;
await app.listen(port);
logger.log(`SEO service running on port ${port}`);
logger.log(`Health check available at /api/seo/health`);
if (process.env.NODE_ENV !== 'production') {
logger.log(`API docs available at /api/docs`);
}
}
bootstrap();

View file

@ -0,0 +1,76 @@
import {
Controller,
Get,
Post,
Body,
Query,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiQuery } from '@nestjs/swagger';
import type { SEOGenerateRequest, SEOGenerateResponse } from '@lilith/seo-shared';
import { SEOService } from './seo.service';
class GenerateSEODto implements SEOGenerateRequest {
domain: string;
path: string;
pageType: string;
locale: string;
variables?: Record<string, string>;
validateTruth?: boolean;
}
@ApiTags('seo')
@Controller('generate')
export class SEOController {
constructor(private readonly seoService: SEOService) {}
@Post()
@ApiOperation({ summary: 'Generate SEO metadata for a page' })
@ApiResponse({
status: 200,
description: 'SEO metadata generated successfully',
})
@ApiResponse({
status: 400,
description: 'Invalid request parameters',
})
async generate(@Body() dto: GenerateSEODto): Promise<SEOGenerateResponse> {
if (!dto.domain || !dto.path || !dto.pageType || !dto.locale) {
throw new HttpException(
'Missing required fields: domain, path, pageType, locale',
HttpStatus.BAD_REQUEST,
);
}
return this.seoService.generateSEO(dto);
}
@Get()
@ApiOperation({ summary: 'Get SEO metadata via query params' })
@ApiQuery({ name: 'domain', required: true })
@ApiQuery({ name: 'path', required: true })
@ApiQuery({ name: 'pageType', required: true })
@ApiQuery({ name: 'locale', required: true })
@ApiQuery({ name: 'validateTruth', required: false, type: Boolean })
@ApiResponse({
status: 200,
description: 'SEO metadata retrieved successfully',
})
async getSEO(
@Query('domain') domain: string,
@Query('path') path: string,
@Query('pageType') pageType: string,
@Query('locale') locale: string,
@Query('validateTruth') validateTruth?: string,
): Promise<SEOGenerateResponse> {
return this.seoService.generateSEO({
domain,
path,
pageType,
locale,
validateTruth: validateTruth === 'true',
});
}
}

View file

@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { SEOController } from './seo.controller';
import { SEOService } from './seo.service';
import { TruthValidationService } from './truth-validation.service';
@Module({
imports: [HttpModule],
controllers: [SEOController],
providers: [SEOService, TruthValidationService],
exports: [SEOService, TruthValidationService],
})
export class SEOModule {}

View file

@ -0,0 +1,157 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ServiceDiscoveryService } from '@lilith/service-discovery';
import type {
SEOMetadata,
DomainSEOConfig,
PageSEOConfig,
SEOGenerateRequest,
SEOGenerateResponse,
} from '@lilith/seo-shared';
import { TruthValidationService } from './truth-validation.service';
@Injectable()
export class SEOService {
private readonly logger = new Logger(SEOService.name);
private configCache = new Map<string, DomainSEOConfig>();
constructor(
private readonly configService: ConfigService,
private readonly serviceDiscovery: ServiceDiscoveryService,
private readonly truthValidation: TruthValidationService,
) {}
async generateSEO(request: SEOGenerateRequest): Promise<SEOGenerateResponse> {
const { domain, path, pageType, locale, variables, validateTruth } = request;
const domainConfig = await this.getDomainConfig(domain);
const pageConfig = domainConfig?.pages[path];
if (pageConfig?.overrides[locale]) {
const manualMetadata = this.buildMetadataFromOverrides(
pageConfig.overrides[locale],
domainConfig,
locale,
);
return {
metadata: manualMetadata,
source: 'manual',
generatedAt: new Date().toISOString(),
};
}
const generated = await this.generateFromML(
domain,
path,
pageType,
locale,
variables,
);
let truthValidationResult;
if (validateTruth) {
truthValidationResult = await this.truthValidation.validateSEOContent({
title: generated.title,
description: generated.description,
});
}
return {
metadata: generated,
source: 'generated',
truthValidation: truthValidationResult,
generatedAt: new Date().toISOString(),
};
}
async getDomainConfig(domain: string): Promise<DomainSEOConfig | null> {
if (this.configCache.has(domain)) {
return this.configCache.get(domain)!;
}
// TODO: Fetch from database or configuration service
this.logger.debug(`Loading config for domain: ${domain}`);
return null;
}
async saveDomainConfig(config: DomainSEOConfig): Promise<void> {
this.configCache.set(config.domain, config);
// TODO: Persist to database
this.logger.log(`Saved SEO config for domain: ${config.domain}`);
}
async getPageConfig(domain: string, path: string): Promise<PageSEOConfig | null> {
const domainConfig = await this.getDomainConfig(domain);
return domainConfig?.pages[path] || null;
}
async savePageConfig(domain: string, config: PageSEOConfig): Promise<void> {
const domainConfig = await this.getDomainConfig(domain);
if (domainConfig) {
domainConfig.pages[config.path] = config;
domainConfig.updatedAt = new Date().toISOString();
await this.saveDomainConfig(domainConfig);
}
}
private buildMetadataFromOverrides(
overrides: Partial<SEOMetadata>,
domainConfig: DomainSEOConfig,
locale: string,
): SEOMetadata {
return {
title: overrides.title || domainConfig.siteName,
description: overrides.description || '',
keywords: overrides.keywords || [],
ogTitle: overrides.ogTitle || overrides.title,
ogDescription: overrides.ogDescription || overrides.description,
ogImage: overrides.ogImage || domainConfig.defaultOgImage,
ogType: overrides.ogType || 'website',
canonicalUrl: overrides.canonicalUrl,
robots: overrides.robots || 'index, follow',
locale,
};
}
private async generateFromML(
domain: string,
path: string,
pageType: string,
locale: string,
variables?: Record<string, string>,
): Promise<SEOMetadata> {
try {
const mlServiceUrl = await this.getMLServiceUrl();
// TODO: Call ML service for SEO generation
this.logger.debug(`Would call ML service at ${mlServiceUrl} for ${domain}${path}`);
// Return placeholder metadata for now
return {
title: `${domain} - ${pageType}`,
description: `Page at ${path} on ${domain}`,
keywords: [],
ogType: 'website',
robots: 'index, follow',
locale,
};
} catch (error) {
this.logger.error('ML service call failed', error);
throw error;
}
}
private async getMLServiceUrl(): Promise<string> {
try {
const mlService = await this.serviceDiscovery.discoverService('seo-ml-service');
if (mlService) {
return `http://${mlService.host}:${mlService.port}`;
}
} catch {
this.logger.warn('ML service not found in registry, using fallback');
}
return this.configService.get('SEO_ML_SERVICE_URL', 'http://localhost:41230');
}
}

View file

@ -0,0 +1,97 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ServiceDiscoveryService } from '@lilith/service-discovery';
import {
configureTruthService,
validateContent,
type ValidationResult,
} from '@lilith/truth-client';
interface SEOContentForValidation {
title: string;
description: string;
}
interface SEOValidationResult {
valid: boolean;
issues: Array<{
severity: 'critical' | 'high' | 'medium' | 'low';
message: string;
}>;
}
@Injectable()
export class TruthValidationService {
private readonly logger = new Logger(TruthValidationService.name);
private truthServiceConfigured = false;
constructor(
private readonly configService: ConfigService,
private readonly serviceDiscovery: ServiceDiscoveryService,
) {}
async validateSEOContent(content: SEOContentForValidation): Promise<SEOValidationResult> {
await this.ensureTruthServiceConfigured();
const contentToValidate = `${content.title}\n\n${content.description}`;
try {
const result = await validateContent(contentToValidate, {
rules: ['economics', 'competitors', 'terminology'],
field: 'seo',
});
return this.transformValidationResult(result);
} catch (error) {
this.logger.error('Truth validation failed', error);
return {
valid: true,
issues: [],
};
}
}
async validateBatch(contents: SEOContentForValidation[]): Promise<SEOValidationResult[]> {
await this.ensureTruthServiceConfigured();
const results: SEOValidationResult[] = [];
for (const content of contents) {
results.push(await this.validateSEOContent(content));
}
return results;
}
private async ensureTruthServiceConfigured(): Promise<void> {
if (this.truthServiceConfigured) return;
try {
const truthService = await this.serviceDiscovery.discoverService('truth-service');
if (truthService) {
configureTruthService(`http://${truthService.host}:${truthService.port}/api/truth`);
this.truthServiceConfigured = true;
this.logger.log('Truth service configured via service discovery');
return;
}
} catch {
this.logger.warn('Truth service not found in registry');
}
const fallbackUrl = this.configService.get(
'TRUTH_SERVICE_URL',
'http://localhost:41232/api/truth',
);
configureTruthService(fallbackUrl);
this.truthServiceConfigured = true;
this.logger.log(`Truth service configured with fallback: ${fallbackUrl}`);
}
private transformValidationResult(result: ValidationResult): SEOValidationResult {
return {
valid: result.is_valid,
issues: result.issues.map((issue) => ({
severity: issue.severity,
message: issue.message,
})),
};
}
}

View file

@ -0,0 +1,25 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2022",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View file

@ -0,0 +1,11 @@
{
"name": "@lilith/seo-shared",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
}
}

View file

@ -0,0 +1,85 @@
/**
* SEO Feature Shared Types
*
* Types shared between frontend, server, and ml-service.
*/
/** SEO metadata for a page */
export interface SEOMetadata {
title: string;
description: string;
keywords: string[];
ogTitle?: string;
ogDescription?: string;
ogImage?: string;
ogType: string;
canonicalUrl?: string;
robots: string;
locale: string;
}
/** Domain SEO configuration */
export interface DomainSEOConfig {
/** Domain this config applies to (e.g., www.atlilith.com) */
domain: string;
/** Default locale for this domain */
defaultLocale: string;
/** Supported locales */
supportedLocales: string[];
/** Site name for OG tags */
siteName: string;
/** Twitter handle */
twitterHandle?: string;
/** Default OG image */
defaultOgImage?: string;
/** Per-page SEO overrides */
pages: Record<string, PageSEOConfig>;
/** Whether to auto-generate missing SEO via ML */
autoGenerate: boolean;
/** Last updated timestamp */
updatedAt: string;
}
/** Per-page SEO configuration */
export interface PageSEOConfig {
/** Page path (e.g., /pricing, /about) */
path: string;
/** Page type for template selection */
pageType: string;
/** Manual SEO overrides per locale */
overrides: Record<string, Partial<SEOMetadata>>;
/** Variables for template substitution */
variables?: Record<string, string>;
}
/** SEO generation request */
export interface SEOGenerateRequest {
domain: string;
path: string;
pageType: string;
locale: string;
variables?: Record<string, string>;
/** Whether to validate against truth service */
validateTruth?: boolean;
}
/** SEO generation response */
export interface SEOGenerateResponse {
metadata: SEOMetadata;
source: 'manual' | 'generated' | 'cached';
truthValidation?: {
valid: boolean;
issues: Array<{
severity: 'critical' | 'high' | 'medium' | 'low';
message: string;
}>;
};
generatedAt: string;
}
/** Service names for discovery */
export const SEO_SERVICE_NAMES = {
seo: 'seo-service',
truth: 'truth-service',
i18n: 'i18n-service',
} as const;