A simple SEO inspecter Tool, to get social media card previews
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: Implement all suggested improvements

+635 -19
+36
package-lock.json
··· 49 49 "react": "^18.3.1", 50 50 "react-day-picker": "^8.10.1", 51 51 "react-dom": "^18.3.1", 52 + "react-helmet-async": "^2.0.5", 52 53 "react-hook-form": "^7.61.1", 53 54 "react-resizable-panels": "^2.1.9", 54 55 "react-router-dom": "^6.30.1", ··· 4601 4602 "node": ">=12" 4602 4603 } 4603 4604 }, 4605 + "node_modules/invariant": { 4606 + "version": "2.2.4", 4607 + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", 4608 + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", 4609 + "license": "MIT", 4610 + "dependencies": { 4611 + "loose-envify": "^1.0.0" 4612 + } 4613 + }, 4604 4614 "node_modules/is-binary-path": { 4605 4615 "version": "2.1.0", 4606 4616 "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", ··· 5867 5877 "react": "^18.3.1" 5868 5878 } 5869 5879 }, 5880 + "node_modules/react-fast-compare": { 5881 + "version": "3.2.2", 5882 + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", 5883 + "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==", 5884 + "license": "MIT" 5885 + }, 5886 + "node_modules/react-helmet-async": { 5887 + "version": "2.0.5", 5888 + "resolved": "https://registry.npmjs.org/react-helmet-async/-/react-helmet-async-2.0.5.tgz", 5889 + "integrity": "sha512-rYUYHeus+i27MvFE+Jaa4WsyBKGkL6qVgbJvSBoX8mbsWoABJXdEO0bZyi0F6i+4f0NuIb8AvqPMj3iXFHkMwg==", 5890 + "license": "Apache-2.0", 5891 + "dependencies": { 5892 + "invariant": "^2.2.4", 5893 + "react-fast-compare": "^3.2.2", 5894 + "shallowequal": "^1.1.0" 5895 + }, 5896 + "peerDependencies": { 5897 + "react": "^16.6.0 || ^17.0.0 || ^18.0.0" 5898 + } 5899 + }, 5870 5900 "node_modules/react-hook-form": { 5871 5901 "version": "7.61.1", 5872 5902 "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.61.1.tgz", ··· 6206 6236 "engines": { 6207 6237 "node": ">=10" 6208 6238 } 6239 + }, 6240 + "node_modules/shallowequal": { 6241 + "version": "1.1.0", 6242 + "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", 6243 + "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==", 6244 + "license": "MIT" 6209 6245 }, 6210 6246 "node_modules/shebang-command": { 6211 6247 "version": "2.0.0",
+1
package.json
··· 52 52 "react": "^18.3.1", 53 53 "react-day-picker": "^8.10.1", 54 54 "react-dom": "^18.3.1", 55 + "react-helmet-async": "^2.0.5", 55 56 "react-hook-form": "^7.61.1", 56 57 "react-resizable-panels": "^2.1.9", 57 58 "react-router-dom": "^6.30.1",
+15
public/sitemap.xml
··· 1 + <?xml version="1.0" encoding="UTF-8"?> 2 + <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> 3 + <url> 4 + <loc>https://yourdomain.com/</loc> 5 + <lastmod>2025-01-01</lastmod> 6 + <changefreq>weekly</changefreq> 7 + <priority>1.0</priority> 8 + </url> 9 + <url> 10 + <loc>https://yourdomain.com/docs</loc> 11 + <lastmod>2025-01-01</lastmod> 12 + <changefreq>monthly</changefreq> 13 + <priority>0.8</priority> 14 + </url> 15 + </urlset>
+94
src/components/APIPlayground.tsx
··· 1 + import { useState } from 'react'; 2 + import { Button } from '@/components/ui/button'; 3 + import { Input } from '@/components/ui/input'; 4 + import { Card } from '@/components/ui/card'; 5 + import { Loader2, Play, Copy, Check } from 'lucide-react'; 6 + import { toast } from 'sonner'; 7 + 8 + export const APIPlayground = () => { 9 + const [testUrl, setTestUrl] = useState('https://example.com'); 10 + const [loading, setLoading] = useState(false); 11 + const [response, setResponse] = useState<any>(null); 12 + const [copied, setCopied] = useState(false); 13 + 14 + const handleTest = async () => { 15 + setLoading(true); 16 + try { 17 + const res = await fetch(`${import.meta.env.VITE_SUPABASE_URL}/functions/v1/analyze-seo`, { 18 + method: 'POST', 19 + headers: { 20 + 'Content-Type': 'application/json', 21 + 'Authorization': `Bearer ${import.meta.env.VITE_SUPABASE_ANON_KEY}` 22 + }, 23 + body: JSON.stringify({ url: testUrl }) 24 + }); 25 + 26 + const data = await res.json(); 27 + setResponse(data); 28 + 29 + if (data.success) { 30 + toast.success('Analysis completed!'); 31 + } else { 32 + toast.error(data.error || 'Analysis failed'); 33 + } 34 + } catch (error) { 35 + toast.error('Failed to call API'); 36 + setResponse({ error: 'Network error' }); 37 + } finally { 38 + setLoading(false); 39 + } 40 + }; 41 + 42 + const copyResponse = () => { 43 + navigator.clipboard.writeText(JSON.stringify(response, null, 2)); 44 + setCopied(true); 45 + toast.success('Copied to clipboard!'); 46 + setTimeout(() => setCopied(false), 2000); 47 + }; 48 + 49 + return ( 50 + <div className="space-y-4"> 51 + <Card className="p-6 bg-card border-border"> 52 + <h3 className="text-lg font-semibold mb-4 text-foreground">Try it out</h3> 53 + <div className="flex gap-2"> 54 + <Input 55 + value={testUrl} 56 + onChange={(e) => setTestUrl(e.target.value)} 57 + placeholder="Enter URL to analyze" 58 + className="flex-1" 59 + /> 60 + <Button 61 + onClick={handleTest} 62 + disabled={loading} 63 + className="bg-primary text-primary-foreground hover:bg-primary/90" 64 + > 65 + {loading ? ( 66 + <Loader2 className="h-4 w-4 animate-spin" /> 67 + ) : ( 68 + <Play className="h-4 w-4" /> 69 + )} 70 + </Button> 71 + </div> 72 + </Card> 73 + 74 + {response && ( 75 + <Card className="p-6 bg-muted border-border"> 76 + <div className="flex justify-between items-center mb-4"> 77 + <h4 className="font-semibold text-foreground">Response</h4> 78 + <Button 79 + variant="ghost" 80 + size="sm" 81 + onClick={copyResponse} 82 + className="text-muted-foreground hover:text-foreground" 83 + > 84 + {copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />} 85 + </Button> 86 + </div> 87 + <pre className="text-xs overflow-auto max-h-96 bg-background p-4 rounded border border-border text-foreground"> 88 + {JSON.stringify(response, null, 2)} 89 + </pre> 90 + </Card> 91 + )} 92 + </div> 93 + ); 94 + };
+28
src/components/LoadingSkeleton.tsx
··· 1 + import { Skeleton } from '@/components/ui/skeleton'; 2 + 3 + export const LoadingSkeleton = () => { 4 + return ( 5 + <div className="space-y-6 animate-fade-in"> 6 + <div className="space-y-4"> 7 + <Skeleton className="h-8 w-48" /> 8 + <div className="grid grid-cols-1 md:grid-cols-3 gap-4"> 9 + <Skeleton className="h-24 w-full" /> 10 + <Skeleton className="h-24 w-full" /> 11 + <Skeleton className="h-24 w-full" /> 12 + </div> 13 + </div> 14 + 15 + <div className="space-y-4"> 16 + <Skeleton className="h-6 w-32" /> 17 + <div className="space-y-3"> 18 + {[...Array(6)].map((_, i) => ( 19 + <div key={i} className="flex gap-3"> 20 + <Skeleton className="h-4 w-24" /> 21 + <Skeleton className="h-4 flex-1" /> 22 + </div> 23 + ))} 24 + </div> 25 + </div> 26 + </div> 27 + ); 28 + };
+61
src/components/SEOMetaTags.tsx
··· 1 + import { Helmet } from 'react-helmet-async'; 2 + 3 + interface SEOMetaTagsProps { 4 + title: string; 5 + description: string; 6 + url?: string; 7 + image?: string; 8 + type?: string; 9 + } 10 + 11 + export const SEOMetaTags = ({ 12 + title, 13 + description, 14 + url = 'https://yourdomain.com', 15 + image = 'https://yourdomain.com/og-image.jpg', 16 + type = 'website' 17 + }: SEOMetaTagsProps) => { 18 + return ( 19 + <Helmet> 20 + {/* Basic Meta Tags */} 21 + <title>{title}</title> 22 + <meta name="description" content={description} /> 23 + <meta name="keywords" content="SEO tester, SEO analysis, meta tags checker, open graph validator, website optimization" /> 24 + 25 + {/* Open Graph */} 26 + <meta property="og:title" content={title} /> 27 + <meta property="og:description" content={description} /> 28 + <meta property="og:type" content={type} /> 29 + <meta property="og:url" content={url} /> 30 + <meta property="og:image" content={image} /> 31 + 32 + {/* Twitter Card */} 33 + <meta name="twitter:card" content="summary_large_image" /> 34 + <meta name="twitter:title" content={title} /> 35 + <meta name="twitter:description" content={description} /> 36 + <meta name="twitter:image" content={image} /> 37 + 38 + {/* Technical */} 39 + <link rel="canonical" href={url} /> 40 + <meta name="robots" content="index, follow" /> 41 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 42 + 43 + {/* Structured Data */} 44 + <script type="application/ld+json"> 45 + {JSON.stringify({ 46 + "@context": "https://schema.org", 47 + "@type": "WebApplication", 48 + "name": "SEO Tester", 49 + "description": description, 50 + "url": url, 51 + "applicationCategory": "BusinessApplication", 52 + "offers": { 53 + "@type": "Offer", 54 + "price": "0", 55 + "priceCurrency": "USD" 56 + } 57 + })} 58 + </script> 59 + </Helmet> 60 + ); 61 + };
+112 -5
src/components/SEOTester.tsx
··· 7 7 import { Separator } from '@/components/ui/separator'; 8 8 import { Progress } from '@/components/ui/progress'; 9 9 import { useToast } from '@/hooks/use-toast'; 10 - import { Search, ExternalLink, Image, FileText, Tag, Globe, AlertCircle, CheckCircle, Zap, TrendingUp, Eye, Share2, Target, Terminal, Code, Bug, Cpu, Database, Monitor, Server, Book } from 'lucide-react'; 10 + import { useLocalStorage } from '@/hooks/useLocalStorage'; 11 + import { LoadingSkeleton } from '@/components/LoadingSkeleton'; 12 + import { Search, ExternalLink, Image, FileText, Tag, Globe, AlertCircle, CheckCircle, Zap, TrendingUp, Eye, Share2, Target, Terminal, Code, Bug, Cpu, Database, Monitor, Server, Book, Copy, Check, Download, History } from 'lucide-react'; 11 13 12 14 interface SEOData { 13 15 title?: string; ··· 39 41 }; 40 42 } 41 43 44 + const EXAMPLE_URLS = [ 45 + 'https://github.com', 46 + 'https://stripe.com', 47 + 'https://vercel.com' 48 + ]; 49 + 42 50 const SEOTester = () => { 43 51 const [url, setUrl] = useState(''); 44 52 const [isLoading, setIsLoading] = useState(false); ··· 47 55 const [progress, setProgress] = useState(0); 48 56 const [seoScore, setSeoScore] = useState<SEOScore | null>(null); 49 57 const [showResults, setShowResults] = useState(false); 58 + const [urlHistory, setUrlHistory] = useLocalStorage<string[]>('seo-history', []); 59 + const [copied, setCopied] = useState(false); 60 + const [showHistory, setShowHistory] = useState(false); 50 61 const { toast } = useToast(); 51 62 52 63 const extractMetaData = (html: string, url: string): SEOData => { ··· 135 146 return; 136 147 } 137 148 149 + // Add to history 150 + if (!urlHistory.includes(url)) { 151 + setUrlHistory([url, ...urlHistory.slice(0, 9)]); // Keep last 10 152 + } 153 + 138 154 setIsLoading(true); 139 155 setError(null); 140 156 setSeoData(null); ··· 206 222 analyzeSEO(); 207 223 }; 208 224 225 + const copyToClipboard = () => { 226 + if (!seoData || !seoScore) return; 227 + navigator.clipboard.writeText(JSON.stringify({ data: seoData, score: seoScore }, null, 2)); 228 + setCopied(true); 229 + toast({ title: "Copied!", description: "Results copied to clipboard" }); 230 + setTimeout(() => setCopied(false), 2000); 231 + }; 232 + 233 + const exportAsJSON = () => { 234 + if (!seoData || !seoScore) return; 235 + const blob = new Blob([JSON.stringify({ data: seoData, score: seoScore }, null, 2)], { type: 'application/json' }); 236 + const url = URL.createObjectURL(blob); 237 + const a = document.createElement('a'); 238 + a.href = url; 239 + a.download = `seo-analysis-${new Date().toISOString().split('T')[0]}.json`; 240 + a.click(); 241 + URL.revokeObjectURL(url); 242 + toast({ title: "Exported!", description: "Analysis saved as JSON" }); 243 + }; 244 + 209 245 const getScoreColor = (score: number) => { 210 246 if (score >= 80) return 'success'; 211 247 if (score >= 60) return 'warning'; ··· 219 255 if (score >= 60) return 'C'; 220 256 if (score >= 50) return 'D'; 221 257 return 'F'; 258 + }; 259 + 260 + const getScoreBadge = (total: number) => { 261 + if (total >= 80) return <Badge className="bg-success text-success-foreground hover:bg-success/90">Excellent</Badge>; 262 + if (total >= 60) return <Badge className="bg-primary text-primary-foreground hover:bg-primary/90">Good</Badge>; 263 + if (total >= 40) return <Badge className="bg-warning text-warning-foreground hover:bg-warning/90">Fair</Badge>; 264 + return <Badge variant="destructive">Poor</Badge>; 222 265 }; 223 266 224 267 const AnimatedCounter = ({ value, duration = 1000 }: { value: number; duration?: number }) => { ··· 352 395 </Button> 353 396 </div> 354 397 398 + {/* Example URLs */} 399 + <div className="flex flex-wrap gap-2"> 400 + <span className="text-xs text-muted-foreground font-mono">Try:</span> 401 + {EXAMPLE_URLS.map((exampleUrl) => ( 402 + <Button 403 + key={exampleUrl} 404 + type="button" 405 + variant="outline" 406 + size="sm" 407 + onClick={() => setUrl(exampleUrl)} 408 + className="text-xs font-mono" 409 + > 410 + {exampleUrl} 411 + </Button> 412 + ))} 413 + {urlHistory.length > 0 && ( 414 + <Button 415 + type="button" 416 + variant="outline" 417 + size="sm" 418 + onClick={() => setShowHistory(!showHistory)} 419 + className="text-xs font-mono" 420 + > 421 + <History className="h-3 w-3 mr-1" /> 422 + History 423 + </Button> 424 + )} 425 + </div> 426 + 427 + {/* History Dropdown */} 428 + {showHistory && urlHistory.length > 0 && ( 429 + <div className="bg-muted/30 rounded border border-border p-2 space-y-1 animate-fade-in"> 430 + {urlHistory.map((histUrl, idx) => ( 431 + <button 432 + key={idx} 433 + type="button" 434 + onClick={() => { 435 + setUrl(histUrl); 436 + setShowHistory(false); 437 + }} 438 + className="w-full text-left text-xs font-mono px-2 py-1 rounded hover:bg-muted text-foreground" 439 + > 440 + {histUrl} 441 + </button> 442 + ))} 443 + </div> 444 + )} 445 + 355 446 {isLoading && ( 356 447 <div className="space-y-3 animate-fade-in bg-muted/30 p-3 rounded-md"> 357 448 <div className="flex items-center justify-between text-xs font-mono"> ··· 398 489 <Card className="border border-border bg-card"> 399 490 <CardHeader className="pb-3"> 400 491 <div className="flex items-center justify-between"> 401 - <div className="flex items-center gap-2"> 492 + <div className="flex items-center gap-2"> 402 493 <Database className="h-4 w-4 text-primary" /> 403 494 <span className="font-mono text-sm">Performance Metrics</span> 404 495 </div> 405 - <Badge variant="outline" className="font-mono text-xs"> 406 - {getScoreGrade(seoScore.total)} 407 - </Badge> 496 + <div className="flex items-center gap-2"> 497 + {getScoreBadge(seoScore.total)} 498 + <Button 499 + variant="ghost" 500 + size="sm" 501 + onClick={copyToClipboard} 502 + className="text-xs" 503 + > 504 + {copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />} 505 + </Button> 506 + <Button 507 + variant="ghost" 508 + size="sm" 509 + onClick={exportAsJSON} 510 + className="text-xs" 511 + > 512 + <Download className="h-3 w-3" /> 513 + </Button> 514 + </div> 408 515 </div> 409 516 </CardHeader> 410 517 <CardContent>
+25
src/hooks/useLocalStorage.ts
··· 1 + import { useState, useEffect } from 'react'; 2 + 3 + export function useLocalStorage<T>(key: string, initialValue: T) { 4 + const [storedValue, setStoredValue] = useState<T>(() => { 5 + try { 6 + const item = window.localStorage.getItem(key); 7 + return item ? JSON.parse(item) : initialValue; 8 + } catch (error) { 9 + console.error('Error reading from localStorage:', error); 10 + return initialValue; 11 + } 12 + }); 13 + 14 + const setValue = (value: T | ((val: T) => T)) => { 15 + try { 16 + const valueToStore = value instanceof Function ? value(storedValue) : value; 17 + setStoredValue(valueToStore); 18 + window.localStorage.setItem(key, JSON.stringify(valueToStore)); 19 + } catch (error) { 20 + console.error('Error writing to localStorage:', error); 21 + } 22 + }; 23 + 24 + return [storedValue, setValue] as const; 25 + }
+3
src/index.css
··· 40 40 --success: 142 71% 45%; 41 41 --success-foreground: 0 0% 100%; 42 42 43 + --warning: 38 92% 50%; 44 + --warning-foreground: 0 0% 12%; 45 + 43 46 /* Gradients */ 44 47 --gradient-primary: linear-gradient(135deg, hsl(var(--primary)), hsl(var(--primary-glow))); 45 48 --gradient-subtle: linear-gradient(180deg, hsl(var(--background)), hsl(210 40% 95%));
+104 -1
src/pages/Docs.tsx
··· 3 3 import { Button } from '@/components/ui/button'; 4 4 import { Separator } from '@/components/ui/separator'; 5 5 import { Badge } from '@/components/ui/badge'; 6 - import { Code, Terminal, Book, ChevronRight, Copy, Check, Home } from 'lucide-react'; 6 + import { Code, Terminal, Book, ChevronRight, Copy, Check, Home, AlertTriangle, Zap } from 'lucide-react'; 7 7 import { useToast } from '@/hooks/use-toast'; 8 8 import { Link } from 'react-router-dom'; 9 + import { APIPlayground } from '@/components/APIPlayground'; 9 10 10 11 const Docs = () => { 11 12 const [copiedSection, setCopiedSection] = useState<string | null>(null); ··· 221 222 } 222 223 }`} 223 224 </pre> 225 + </CardContent> 226 + </Card> 227 + 228 + {/* API Playground */} 229 + <Card className="mb-6 border border-border bg-card"> 230 + <CardHeader className="pb-3"> 231 + <div className="flex items-center gap-2"> 232 + <Zap className="h-4 w-4 text-primary" /> 233 + <span className="font-mono text-sm">Interactive Playground</span> 234 + </div> 235 + </CardHeader> 236 + <CardContent> 237 + <APIPlayground /> 238 + </CardContent> 239 + </Card> 240 + 241 + {/* Batch Analysis */} 242 + <Card className="mb-6 border border-border bg-card"> 243 + <CardHeader className="pb-3"> 244 + <div className="flex items-center gap-2"> 245 + <ChevronRight className="h-4 w-4 text-primary" /> 246 + <span className="font-mono text-sm">Batch Analysis</span> 247 + </div> 248 + </CardHeader> 249 + <CardContent className="space-y-4"> 250 + <p className="text-sm text-muted-foreground">Analyze multiple URLs in a single request (max 5 URLs):</p> 251 + <div> 252 + <div className="flex items-center justify-between mb-2"> 253 + <span className="text-xs font-mono text-muted-foreground">BATCH REQUEST</span> 254 + <Button 255 + size="sm" 256 + variant="ghost" 257 + onClick={() => copyToClipboard('{\n "urls": [\n "https://example.com",\n "https://another-site.com"\n ]\n}', 'batch')} 258 + > 259 + {copiedSection === 'batch' ? ( 260 + <Check className="h-4 w-4 text-green-500" /> 261 + ) : ( 262 + <Copy className="h-4 w-4" /> 263 + )} 264 + </Button> 265 + </div> 266 + <pre className="bg-muted/30 p-4 rounded font-mono text-sm border border-border/50 overflow-x-auto"> 267 + {`{ 268 + "urls": [ 269 + "https://example.com", 270 + "https://another-site.com" 271 + ] 272 + }`} 273 + </pre> 274 + </div> 275 + </CardContent> 276 + </Card> 277 + 278 + {/* Response Schema */} 279 + <Card className="mb-6 border border-border bg-card"> 280 + <CardHeader className="pb-3"> 281 + <div className="flex items-center gap-2"> 282 + <Code className="h-4 w-4 text-primary" /> 283 + <span className="font-mono text-sm">Response Schema</span> 284 + </div> 285 + </CardHeader> 286 + <CardContent className="space-y-3"> 287 + <div className="space-y-2 text-sm"> 288 + <div className="flex gap-2"><code className="text-primary">success</code><span className="text-muted-foreground">boolean - Analysis success status</span></div> 289 + <div className="flex gap-2"><code className="text-primary">cached</code><span className="text-muted-foreground">boolean - Whether result was cached</span></div> 290 + <div className="flex gap-2"><code className="text-primary">data.title</code><span className="text-muted-foreground">string - Page title tag</span></div> 291 + <div className="flex gap-2"><code className="text-primary">data.description</code><span className="text-muted-foreground">string - Meta description</span></div> 292 + <div className="flex gap-2"><code className="text-primary">data.ogImage</code><span className="text-muted-foreground">string - Open Graph image URL</span></div> 293 + <div className="flex gap-2"><code className="text-primary">score.total</code><span className="text-muted-foreground">number - Overall SEO score (0-100)</span></div> 294 + <div className="flex gap-2"><code className="text-primary">score.breakdown</code><span className="text-muted-foreground">object - Score breakdown by category</span></div> 295 + </div> 296 + </CardContent> 297 + </Card> 298 + 299 + {/* Troubleshooting */} 300 + <Card className="mb-6 border border-destructive/50 bg-destructive/5"> 301 + <CardHeader className="pb-3"> 302 + <div className="flex items-center gap-2"> 303 + <AlertTriangle className="h-4 w-4 text-destructive" /> 304 + <span className="font-mono text-sm">Troubleshooting</span> 305 + </div> 306 + </CardHeader> 307 + <CardContent className="space-y-4"> 308 + <div> 309 + <h4 className="font-semibold text-sm mb-2">Rate Limiting (429)</h4> 310 + <p className="text-sm text-muted-foreground">Maximum 10 requests per minute per IP. Wait 60 seconds before retrying.</p> 311 + </div> 312 + <Separator /> 313 + <div> 314 + <h4 className="font-semibold text-sm mb-2">Invalid URL (400)</h4> 315 + <p className="text-sm text-muted-foreground">Ensure URL starts with http:// or https:// and is properly formatted.</p> 316 + </div> 317 + <Separator /> 318 + <div> 319 + <h4 className="font-semibold text-sm mb-2">Failed to Fetch (502)</h4> 320 + <p className="text-sm text-muted-foreground">Target website may be blocking requests or experiencing issues. Try a different URL.</p> 321 + </div> 322 + <Separator /> 323 + <div> 324 + <h4 className="font-semibold text-sm mb-2">Caching</h4> 325 + <p className="text-sm text-muted-foreground">Results are cached for 1 hour. Cached responses include "cached": true field.</p> 326 + </div> 224 327 </CardContent> 225 328 </Card> 226 329
+12 -2
src/pages/Index.tsx
··· 1 1 import SEOTester from '@/components/SEOTester'; 2 + import { HelmetProvider } from 'react-helmet-async'; 3 + import { SEOMetaTags } from '@/components/SEOMetaTags'; 2 4 3 5 const Index = () => { 4 - return <SEOTester />; 6 + return ( 7 + <HelmetProvider> 8 + <SEOMetaTags 9 + title="SEO Tester - Analyze & Optimize Your Website Meta Tags" 10 + description="Free SEO analysis tool to check meta tags, Open Graph, Twitter Cards, and technical SEO. Improve your website's search engine visibility instantly." 11 + /> 12 + <SEOTester /> 13 + </HelmetProvider> 14 + ); 5 15 }; 6 16 7 - export default Index; 17 + export default Index;
+140 -11
supabase/functions/analyze-seo/index.ts
··· 111 111 }; 112 112 }; 113 113 114 + // Simple in-memory cache for demo (use Redis/Supabase for production) 115 + const cache = new Map<string, { data: any; timestamp: number }>(); 116 + const CACHE_TTL = 3600000; // 1 hour 117 + 118 + // Rate limiting (simple in-memory, use proper solution for production) 119 + const rateLimits = new Map<string, number[]>(); 120 + const RATE_LIMIT = 10; // requests per minute 121 + 122 + const checkRateLimit = (ip: string): boolean => { 123 + const now = Date.now(); 124 + const requests = rateLimits.get(ip) || []; 125 + const recentRequests = requests.filter(time => now - time < 60000); 126 + 127 + if (recentRequests.length >= RATE_LIMIT) { 128 + return false; 129 + } 130 + 131 + recentRequests.push(now); 132 + rateLimits.set(ip, recentRequests); 133 + return true; 134 + }; 135 + 114 136 serve(async (req) => { 115 137 // Handle CORS preflight requests 116 138 if (req.method === 'OPTIONS') { ··· 118 140 } 119 141 120 142 try { 121 - const { url } = await req.json(); 143 + // Rate limiting check 144 + const clientIp = req.headers.get('x-forwarded-for') || 'unknown'; 145 + if (!checkRateLimit(clientIp)) { 146 + return new Response( 147 + JSON.stringify({ 148 + error: 'Rate limit exceeded. Please try again in a minute.', 149 + retryAfter: 60 150 + }), 151 + { 152 + status: 429, 153 + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, 154 + } 155 + ); 156 + } 157 + 158 + const { url, urls } = await req.json(); 159 + 160 + // Support batch analysis 161 + if (urls && Array.isArray(urls)) { 162 + if (urls.length > 5) { 163 + return new Response( 164 + JSON.stringify({ error: 'Maximum 5 URLs allowed per batch request' }), 165 + { 166 + status: 400, 167 + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, 168 + } 169 + ); 170 + } 171 + 172 + const results = await Promise.all( 173 + urls.map(async (u: string) => { 174 + try { 175 + // Check cache 176 + const cached = cache.get(u); 177 + if (cached && Date.now() - cached.timestamp < CACHE_TTL) { 178 + return { url: u, ...cached.data, cached: true }; 179 + } 180 + 181 + const proxyUrl = `https://api.allorigins.win/get?url=${encodeURIComponent(u)}`; 182 + const response = await fetch(proxyUrl); 183 + if (!response.ok) throw new Error(`Failed to fetch: ${response.status}`); 184 + 185 + const data = await response.json(); 186 + if (!data.contents) throw new Error('No content received'); 187 + 188 + const seoData = extractMetaData(data.contents, u); 189 + const seoScore = calculateSEOScore(seoData); 190 + const result = { data: seoData, score: seoScore, success: true }; 191 + 192 + // Cache result 193 + cache.set(u, { data: result, timestamp: Date.now() }); 194 + 195 + return { url: u, ...result }; 196 + } catch (error) { 197 + return { 198 + url: u, 199 + success: false, 200 + error: error instanceof Error ? error.message : 'Analysis failed' 201 + }; 202 + } 203 + }) 204 + ); 205 + 206 + return new Response( 207 + JSON.stringify({ success: true, results }), 208 + { headers: { ...corsHeaders, 'Content-Type': 'application/json' } } 209 + ); 210 + } 122 211 212 + // Single URL analysis 123 213 if (!url) { 124 214 return new Response( 125 - JSON.stringify({ error: 'URL parameter is required' }), 215 + JSON.stringify({ 216 + error: 'URL parameter is required', 217 + hint: 'Send {"url": "https://example.com"} or {"urls": ["https://example.com"]} for batch' 218 + }), 126 219 { 127 220 status: 400, 128 221 headers: { ...corsHeaders, 'Content-Type': 'application/json' }, ··· 132 225 133 226 console.log('Analyzing SEO for URL:', url); 134 227 228 + // Check cache 229 + const cached = cache.get(url); 230 + if (cached && Date.now() - cached.timestamp < CACHE_TTL) { 231 + console.log('Returning cached result for:', url); 232 + return new Response( 233 + JSON.stringify({ ...cached.data, cached: true }), 234 + { headers: { ...corsHeaders, 'Content-Type': 'application/json' } } 235 + ); 236 + } 237 + 135 238 // Validate URL 136 239 try { 137 - new URL(url); 240 + const urlObj = new URL(url); 241 + if (!urlObj.protocol.match(/^https?:/)) { 242 + throw new Error('Only HTTP(S) URLs are supported'); 243 + } 138 244 } catch { 139 245 return new Response( 140 - JSON.stringify({ error: 'Invalid URL format' }), 246 + JSON.stringify({ 247 + error: 'Invalid URL format', 248 + hint: 'Please provide a valid URL starting with http:// or https://' 249 + }), 141 250 { 142 251 status: 400, 143 252 headers: { ...corsHeaders, 'Content-Type': 'application/json' }, ··· 162 271 // Extract and analyze SEO data 163 272 const seoData = extractMetaData(data.contents, url); 164 273 const seoScore = calculateSEOScore(seoData); 274 + 275 + const result = { 276 + success: true, 277 + data: seoData, 278 + score: seoScore, 279 + cached: false 280 + }; 281 + 282 + // Cache the result 283 + cache.set(url, { data: result, timestamp: Date.now() }); 165 284 166 285 console.log('SEO analysis completed successfully'); 167 286 168 287 return new Response( 169 - JSON.stringify({ 170 - success: true, 171 - data: seoData, 172 - score: seoScore, 173 - }), 288 + JSON.stringify(result), 174 289 { 175 290 headers: { ...corsHeaders, 'Content-Type': 'application/json' }, 176 291 } 177 292 ); 178 293 } catch (error) { 179 294 console.error('Error in analyze-seo function:', error); 295 + 296 + let errorMessage = 'Internal server error'; 297 + let statusCode = 500; 298 + 299 + if (error instanceof Error) { 300 + if (error.message.includes('fetch')) { 301 + errorMessage = 'Failed to fetch the website. The site may be blocking requests or experiencing issues.'; 302 + statusCode = 502; 303 + } else { 304 + errorMessage = error.message; 305 + } 306 + } 307 + 180 308 return new Response( 181 309 JSON.stringify({ 182 310 success: false, 183 - error: error instanceof Error ? error.message : 'Internal server error', 311 + error: errorMessage, 312 + hint: statusCode === 502 ? 'Try a different URL or check if the website is accessible' : undefined 184 313 }), 185 314 { 186 - status: 500, 315 + status: statusCode, 187 316 headers: { ...corsHeaders, 'Content-Type': 'application/json' }, 188 317 } 189 318 );
+4
tailwind.config.ts
··· 36 36 DEFAULT: "hsl(var(--success))", 37 37 foreground: "hsl(var(--success-foreground))", 38 38 }, 39 + warning: { 40 + DEFAULT: "hsl(var(--warning))", 41 + foreground: "hsl(var(--warning-foreground))", 42 + }, 39 43 muted: { 40 44 DEFAULT: "hsl(var(--muted))", 41 45 foreground: "hsl(var(--muted-foreground))",