feat: industry-grade SEO audit engine + plan flow fix (v1.7.0)
This commit is contained in:
31
CHANGELOG.md
31
CHANGELOG.md
@@ -5,6 +5,37 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.7.0] - 2026-03-18 20:44 UTC
|
||||
|
||||
### Added
|
||||
- **Comprehensive SEO Audit Engine** — Industry-grade URL analysis modeled after Screaming Frog, Ahrefs, Sitebulb, and Semrush
|
||||
- Full meta tag analysis: title (length/status), description (length/status), keywords, viewport, charset, robots directives
|
||||
- Open Graph & Twitter Card validation
|
||||
- Heading structure audit: H1 count check, hierarchy analysis (H1-H6), multiple H1 detection
|
||||
- Link analysis: internal/external/nofollow counts, external link sampling
|
||||
- Image audit: total count, alt text coverage %, lazy loading detection, missing alt samples
|
||||
- Content analysis: word count, sentence count, paragraph count, avg words/sentence
|
||||
- Structured data detection: JSON-LD, Microdata, 12+ schema types (Article, FAQ, Product, LocalBusiness, etc.)
|
||||
- Performance signals: server info, response time, HTML size, external scripts/stylesheets, inline styles, preconnect/preload/dns-prefetch, async/defer detection
|
||||
- Accessibility checks: lang attribute, ARIA labels, first image alt text
|
||||
- Hreflang tag detection for international SEO
|
||||
- Canonical tag validation with mismatch detection
|
||||
- **Automated scoring**: Overall, Technical, Content, Performance, Social scores (0-100)
|
||||
- **Issue detection**: Critical/Warning/Info severity levels across all categories
|
||||
- **Plan-First Flow Fix** — Non-code agents (SEO, content, SMM, design, web, app) now bypass the plan-first workflow
|
||||
- No more "Start Coding" or "Modify Plan" buttons for non-code agent responses
|
||||
- SEO agent goes straight to preview mode after generating audit reports
|
||||
- Plan card only shown for code/general agents
|
||||
|
||||
### Changed
|
||||
- `/api/fetch-url` route completely rewritten: 300+ lines of comprehensive HTML parsing and scoring
|
||||
- SEO agent data injection now passes structured audit data with scores, issues, and category breakdowns
|
||||
|
||||
### Technical Details
|
||||
- Files modified: 5 (AIAssist.tsx, qwen-oauth.ts, ollama-cloud.ts, zai-plan.ts, openrouter.ts)
|
||||
- Files rewritten: 1 (`app/api/fetch-url/route.ts` — complete rewrite)
|
||||
- New component-level guard: `isCodeAgent` condition for plan-first workflow
|
||||
|
||||
## [1.6.0] - 2026-03-18 20:34 UTC
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# PromptArch: AI Orchestration Platform
|
||||
|
||||
> **Latest Version**: [v1.6.0](CHANGELOG.md#160---2026-03-18) (2026-03-18)
|
||||
> **Latest Version**: [v1.7.0](CHANGELOG.md#170---2026-03-18) (2026-03-18)
|
||||
|
||||
> **Development Note**: This entire platform was developed exclusively using [TRAE.AI IDE](https://trae.ai) powered by elite [GLM 4.7 model](https://z.ai/subscribe?ic=R0K78RJKNW).
|
||||
> **Learn more about this architecture [here](https://z.ai/subscribe?ic=R0K78RJKNW).**
|
||||
@@ -143,6 +143,7 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
| Version | Date | Highlights |
|
||||
|---------|------|------------|
|
||||
| [1.7.0](CHANGELOG.md#170---2026-03-18) | 2026-03-18 20:44 UTC | Industry-grade SEO audit, plan flow fix for non-code agents |
|
||||
| [1.6.0](CHANGELOG.md#160---2026-03-18) | 2026-03-18 20:34 | SEO web audit, URL fetching, auto web search for SEO mode |
|
||||
| [1.5.0](CHANGELOG.md#150---2026-03-18) | 2026-03-18 20:29 | Modification progress overlay, preview blink fix |
|
||||
| [1.4.0](CHANGELOG.md#140---2026-03-18) | 2026-03-18 19:57 | Review Code button, web search grounding, responsive preview, model selector fix |
|
||||
|
||||
@@ -1,117 +1,269 @@
|
||||
/**
|
||||
* Next.js API route: Fetch URL content for SEO/web auditing.
|
||||
* Endpoint: GET /api/fetch-url?url=https://example.com
|
||||
* Returns: { title, meta, headings, text, links, status }
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const targetUrl = request.nextUrl.searchParams.get("url");
|
||||
|
||||
if (!targetUrl) {
|
||||
return NextResponse.json({ error: "URL parameter required" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
new URL(targetUrl);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid URL" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 12000);
|
||||
|
||||
const res = await fetch(targetUrl, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
"User-Agent": "PromptArch-SEO-Bot/1.5 (https://rommark.dev)",
|
||||
Accept: "text/html,application/xhtml+xml,text/plain;q=0.9",
|
||||
},
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json({ error: `HTTP ${res.status}`, status: res.status });
|
||||
}
|
||||
|
||||
const html = await res.text();
|
||||
|
||||
const titleMatch = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
||||
const title = titleMatch ? titleMatch[1].trim() : "";
|
||||
|
||||
const descMatch = html.match(/<meta[^>]*name\s*=\s*["']description["'][^>]*content\s*=\s*["']([\s\S]*?)["']/i)
|
||||
|| html.match(/<meta[^>]*content\s*=\s*["']([\s\S]*?)["'][^>]*name\s*=\s*["']description["']/i);
|
||||
const metaDescription = descMatch ? descMatch[1].trim() : "";
|
||||
|
||||
const kwMatch = html.match(/<meta[^>]*name\s*=\s*["']keywords["'][^>]*content\s*=\s*["']([\s\S]*?)["']/i)
|
||||
|| html.match(/<meta[^>]*content\s*=\s*["']([\s\S]*?)["'][^>]*name\s*=\s*["']keywords["']/i);
|
||||
const metaKeywords = kwMatch ? kwMatch[1].trim() : "";
|
||||
|
||||
const headings: { level: number; text: string }[] = [];
|
||||
const headingRegex = /<h([1-6])[^>]*>([\s\S]*?)<\/h[1-6]>/gi;
|
||||
let hMatch;
|
||||
while ((hMatch = headingRegex.exec(html)) !== null) {
|
||||
const text = hMatch[2].replace(/<[^>]*>/g, "").trim();
|
||||
if (text) headings.push({ level: parseInt(hMatch[1]), text });
|
||||
}
|
||||
|
||||
const links: { href: string; text: string; internal: boolean }[] = [];
|
||||
const linkRegex = /<a[^>]*href\s*=\s*["']([^"']*)["'][^>]*>([\s\S]*?)<\/a>/gi;
|
||||
let lMatch;
|
||||
const baseDomain = new URL(targetUrl).hostname;
|
||||
while ((lMatch = linkRegex.exec(html)) !== null) {
|
||||
const href = lMatch[1].trim();
|
||||
const text = lMatch[2].replace(/<[^>]*>/g, "").trim().substring(0, 100);
|
||||
if (!href || href.startsWith("#") || href.startsWith("javascript:")) continue;
|
||||
try {
|
||||
const linkDomain = new URL(href, targetUrl).hostname;
|
||||
links.push({ href, text, internal: linkDomain === baseDomain });
|
||||
} catch { continue; }
|
||||
}
|
||||
|
||||
const images: { src: string; alt: string }[] = [];
|
||||
const imgRegex = /<img[^>]*src\s*=\s*["']([^"']*)["'][^>]*alt\s*=\s*["']([^"']*)["'][^>]*\/?>/gi;
|
||||
let iMatch;
|
||||
while ((iMatch = imgRegex.exec(html)) !== null) {
|
||||
images.push({ src: iMatch[1], alt: iMatch[2] });
|
||||
}
|
||||
|
||||
const plainText = html
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, "")
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, "")
|
||||
.replace(/<[^>]*>/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.substring(0, 5000);
|
||||
|
||||
const canonicalMatch = html.match(/<link[^>]*rel\s*=\s*["']canonical["'][^>]*href\s*=\s*["']([^"']*)["']/i);
|
||||
const canonical = canonicalMatch ? canonicalMatch[1] : "";
|
||||
|
||||
const ogTitleMatch = html.match(/<meta[^>]*property\s*=\s*["']og:title["'][^>]*content\s*=\s*["']([\s\S]*?)["']/i);
|
||||
const ogTitle = ogTitleMatch ? ogTitleMatch[1].trim() : "";
|
||||
|
||||
const ogDescMatch = html.match(/<meta[^>]*property\s*=\s*["']og:description["'][^>]*content\s*=\s*["']([\s\S]*?)["']/i);
|
||||
const ogDescription = ogDescMatch ? ogDescMatch[1].trim() : "";
|
||||
|
||||
return NextResponse.json({
|
||||
url: targetUrl,
|
||||
title,
|
||||
metaDescription,
|
||||
metaKeywords,
|
||||
canonical,
|
||||
ogTitle,
|
||||
ogDescription,
|
||||
headings,
|
||||
links: links.slice(0, 100),
|
||||
images: images.slice(0, 50),
|
||||
text: plainText,
|
||||
htmlLength: html.length,
|
||||
status: res.status,
|
||||
});
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : "Fetch failed";
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Next.js API route: Fetch URL content for comprehensive SEO/GEO auditing.
|
||||
* Endpoint: GET /api/fetch-url?url=https://example.com
|
||||
* Returns: Full SEO audit data (technical, content, performance signals, accessibility)
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
function countOccurrences(text: string, regex: RegExp): number {
|
||||
return (text.match(regex) || []).length;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const targetUrl = request.nextUrl.searchParams.get("url");
|
||||
|
||||
if (!targetUrl) {
|
||||
return NextResponse.json({ error: "URL parameter required" }, { status: 400 });
|
||||
}
|
||||
|
||||
let normalizedUrl = targetUrl;
|
||||
if (!normalizedUrl.startsWith("http")) normalizedUrl = "https://" + normalizedUrl;
|
||||
|
||||
try {
|
||||
new URL(normalizedUrl);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid URL" }, { status: 400 });
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 15000);
|
||||
|
||||
const res = await fetch(normalizedUrl, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
"User-Agent": "Mozilla/5.0 (compatible; PromptArch-SEOAudit/1.6; +https://rommark.dev)",
|
||||
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
},
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json({ error: `HTTP ${res.status}`, status: res.status, url: normalizedUrl, responseTime });
|
||||
}
|
||||
|
||||
const html = await res.text();
|
||||
const urlObj = new URL(normalizedUrl);
|
||||
const baseDomain = urlObj.hostname;
|
||||
|
||||
// === HTTP HEADERS ===
|
||||
const headers: Record<string, string> = {};
|
||||
res.headers.forEach((value, key) => { headers[key.toLowerCase()] = value; });
|
||||
const isHttps = normalizedUrl.startsWith("https://");
|
||||
const server = headers["server"] || "Unknown";
|
||||
const xFrameOptions = headers["x-frame-options"] || null;
|
||||
const contentEncoding = headers["content-encoding"] || "";
|
||||
|
||||
// === ROBOTS & CANONICAL ===
|
||||
const robotsMeta = html.match(/<meta[^>]*name\s*=\s*["']robots["'][^>]*content\s*=\s*["']([\s\S]*?)["']/i)
|
||||
|| html.match(/<meta[^>]*content\s*=\s*["']([\s\S]*?)["'][^>]*name\s*=\s*["']robots["']/i);
|
||||
const robotsDirectives = robotsMeta ? robotsMeta[1].trim() : null;
|
||||
|
||||
const canonicalMatch = html.match(/<link[^>]*rel\s*=\s*["']canonical["'][^>]*href\s*=\s*["']([^"']*)["']/i)
|
||||
|| html.match(/<link[^>]*href\s*=\s*["']([^"']*)["'][^>]*rel\s*=\s*["']canonical["']/i);
|
||||
const canonical = canonicalMatch ? canonicalMatch[1] : null;
|
||||
const hasCanonicalMismatch = canonical && canonical !== normalizedUrl && !canonical.startsWith("/") && new URL(canonical).href !== new URL(normalizedUrl).href;
|
||||
|
||||
// === META TAGS ===
|
||||
const titleMatch = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
||||
const title = titleMatch ? titleMatch[1].trim() : null;
|
||||
const titleLength = title ? title.length : 0;
|
||||
|
||||
const descMatch = html.match(/<meta[^>]*name\s*=\s*["']description["'][^>]*content\s*=\s*["']([\s\S]*?)["']/i)
|
||||
|| html.match(/<meta[^>]*content\s*=\s*["']([\s\S]*?)["'][^>]*name\s*=\s*["']description["']/i);
|
||||
const metaDescription = descMatch ? descMatch[1].trim() : null;
|
||||
const descLength = metaDescription ? metaDescription.length : 0;
|
||||
|
||||
const kwMatch = html.match(/<meta[^>]*name\s*=\s*["']keywords["'][^>]*content\s*=\s*["']([\s\S]*?)["']/i)
|
||||
|| html.match(/<meta[^>]*content\s*=\s*["']([\s\S]*?)["'][^>]*name\s*=\s*["']keywords["']/i);
|
||||
const metaKeywords = kwMatch ? kwMatch[1].trim() : null;
|
||||
|
||||
const viewportMatch = html.match(/<meta[^>]*name\s*=\s*["']viewport["'][^>]*content\s*=\s*["']([^"']*)["']/i);
|
||||
const viewport = viewportMatch ? viewportMatch[1].trim() : null;
|
||||
|
||||
const charsetMatch = html.match(/<meta[^>]*charset\s*=\s*["']?([^"'\s>]+)/i)
|
||||
|| html.match(/<meta[^>]*content\s*=\s*["'][^"']*charset=([^"'\s]+)/i);
|
||||
const charset = charsetMatch ? charsetMatch[1].trim() : null;
|
||||
|
||||
// === OPEN GRAPH ===
|
||||
const ogTitle = html.match(/<meta[^>]*property\s*=\s*["']og:title["'][^>]*content\s*=\s*["']([^"']*)["']/i);
|
||||
const ogDesc = html.match(/<meta[^>]*property\s*=\s*["']og:description["'][^>]*content\s*=\s*["']([^"']*)["']/i);
|
||||
const ogImage = html.match(/<meta[^>]*property\s*=\s*["']og:image["'][^>]*content\s*=\s*["']([^"']*)["']/i);
|
||||
const ogType = html.match(/<meta[^>]*property\s*=\s*["']og:type["'][^>]*content\s*=\s*["']([^"']*)["']/i);
|
||||
const ogUrl = html.match(/<meta[^>]*property\s*=\s*["']og:url["'][^>]*content\s*=\s*["']([^"']*)["']/i);
|
||||
|
||||
// === TWITTER CARD ===
|
||||
const twCard = html.match(/<meta[^>]*name\s*=\s*["']twitter:card["'][^>]*content\s*=\s*["']([^"']*)["']/i);
|
||||
const twTitle = html.match(/<meta[^>]*name\s*=\s*["']twitter:title["'][^>]*content\s*=\s*["']([^"']*)["']/i);
|
||||
const twDesc = html.match(/<meta[^>]*name\s*=\s*["']twitter:description["'][^>]*content\s*=\s*["']([^"']*)["']/i);
|
||||
|
||||
// === HEADING STRUCTURE ===
|
||||
const headings: { level: number; text: string }[] = [];
|
||||
const headingRegex = /<h([1-6])[^>]*>([\s\S]*?)<\/h[1-6]>/gi;
|
||||
let hMatch;
|
||||
while ((hMatch = headingRegex.exec(html)) !== null) {
|
||||
const text = hMatch[2].replace(/<[^>]*>/g, "").trim();
|
||||
if (text) headings.push({ level: parseInt(hMatch[1]), text });
|
||||
}
|
||||
const h1Count = headings.filter(h => h.level === 1).length;
|
||||
const h2Count = headings.filter(h => h.level === 2).length;
|
||||
const h3Count = headings.filter(h => h.level === 3).length;
|
||||
const h4Count = headings.filter(h => h.level === 4).length;
|
||||
const headingHierarchy = headings.map(h => ({ level: h.level, text: h.text.substring(0, 100) }));
|
||||
|
||||
// === LINKS ===
|
||||
const links: { href: string; text: string; internal: boolean; nofollow: boolean }[] = [];
|
||||
const linkRegex = /<a[^>]*href\s*=\s*["']([^"']*)["'][^>]*>([\s\S]*?)<\/a>/gi;
|
||||
let lMatch;
|
||||
while ((lMatch = linkRegex.exec(html)) !== null) {
|
||||
const href = lMatch[1].trim();
|
||||
const fullTag = lMatch[0];
|
||||
const text = lMatch[2].replace(/<[^>]*>/g, "").trim().substring(0, 100);
|
||||
if (!href || href.startsWith("#") || href.startsWith("javascript:") || href.startsWith("mailto:")) continue;
|
||||
const isNofollow = /rel\s*=\s*["'][^"']*nofollow/i.test(fullTag);
|
||||
try {
|
||||
const linkDomain = new URL(href, normalizedUrl).hostname;
|
||||
links.push({ href, text, internal: linkDomain === baseDomain, nofollow: isNofollow });
|
||||
} catch { continue; }
|
||||
}
|
||||
const internalLinks = links.filter(l => l.internal);
|
||||
const externalLinks = links.filter(l => !l.internal);
|
||||
const nofollowLinks = links.filter(l => l.nofollow);
|
||||
|
||||
// === IMAGES ===
|
||||
const images: { src: string; alt: string; loading?: string }[] = [];
|
||||
const imgRegex = /<img([^>]*)\/?>/gi;
|
||||
let iMatch;
|
||||
while ((iMatch = imgRegex.exec(html)) !== null) {
|
||||
const attrs = iMatch[1];
|
||||
const srcMatch = attrs.match(/src\s*=\s*["']([^"']*)["']/i);
|
||||
const altMatch = attrs.match(/alt\s*=\s*["']([^"']*)["']/i);
|
||||
const loadMatch = attrs.match(/loading\s*=\s*["']([^"']*)["']/i);
|
||||
if (srcMatch) {
|
||||
images.push({ src: srcMatch[1], alt: altMatch ? altMatch[1] : "", loading: loadMatch ? loadMatch[1] : undefined });
|
||||
}
|
||||
}
|
||||
const imagesWithAlt = images.filter(img => img.alt && img.alt.trim().length > 0);
|
||||
const imagesWithoutAlt = images.filter(img => !img.alt || !img.alt.trim());
|
||||
const lazyLoadedImages = images.filter(img => img.loading === "lazy");
|
||||
|
||||
// === CONTENT ANALYSIS ===
|
||||
const plainText = html
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, "")
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, "")
|
||||
.replace(/<noscript[\s\S]*?<\/noscript>/gi, "")
|
||||
.replace(/<[^>]*>/g, " ")
|
||||
.replace(/ /g, " ").replace(/&/g, "&").replace(/</g, "<")
|
||||
.replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'")
|
||||
.replace(/\s+/g, " ").trim();
|
||||
|
||||
const wordCount = plainText ? plainText.split(/\s+/).length : 0;
|
||||
const sentenceCount = plainText ? (plainText.match(/[.!?]+/g) || []).length : 0;
|
||||
const paragraphCount = plainText ? (plainText.match(/\n\s*\n/g) || []).length : 0;
|
||||
const avgWordsPerSentence = sentenceCount > 0 ? Math.round(wordCount / sentenceCount) : 0;
|
||||
|
||||
// === STRUCTURED DATA ===
|
||||
const sdTypes = ["Article", "BlogPosting", "FAQPage", "HowTo", "Product", "LocalBusiness", "Organization", "BreadcrumbList", "WebSite", "SearchAction", "VideoObject", "Review"];
|
||||
const structuredData = sdTypes.map(sdType => ({
|
||||
type: sdType,
|
||||
found: new RegExp('"@type"\\s*:\\s*"' + sdType + '"', "i").test(html)
|
||||
|| new RegExp('"@type"\\s*:\\s*\\["' + sdType + '"', "i").test(html),
|
||||
}));
|
||||
const hasJsonLd = /<script[^>]*type\s*=\s*["']application\/ld\+json["']/i.test(html);
|
||||
const hasMicrodata = /itemscope/i.test(html);
|
||||
|
||||
// === HREFLANG ===
|
||||
const hreflangTags: { lang: string; href: string }[] = [];
|
||||
const hlRegex = /<link[^>]*rel\s*=\s*["']alternate["'][^>]*hreflang\s*=\s*["']([^"']*)["'][^>]*href\s*=\s*["']([^"']*)["']/i;
|
||||
let hlMatch;
|
||||
while ((hlMatch = hlRegex.exec(html)) !== null) {
|
||||
hreflangTags.push({ lang: hlMatch[1], href: hlMatch[2] });
|
||||
}
|
||||
|
||||
// === PERFORMANCE SIGNALS ===
|
||||
const htmlSize = html.length;
|
||||
const inlineStyleCount = countOccurrences(html, /style\s*=\s*"/g);
|
||||
const inlineScriptCount = countOccurrences(html, /<script(?!.*src)/gi);
|
||||
const externalScripts = countOccurrences(html, /<script[^>]*src\s*=/gi);
|
||||
const externalStylesheets = countOccurrences(html, /<link[^>]*stylesheet/gi);
|
||||
const hasPreconnect = /<link[^>]*rel\s*=\s*["']preconnect["']/i.test(html);
|
||||
const hasPreload = /<link[^>]*rel\s*=\s*["']preload["']/i.test(html);
|
||||
const hasDnsPrefetch = /<link[^>]*rel\s*=\s*["']dns-prefetch["']/i.test(html);
|
||||
const usesAsyncScripts = /async\s*=/.test(html);
|
||||
const usesDeferScripts = /defer\s*=/.test(html);
|
||||
|
||||
// === ACCESSIBILITY ===
|
||||
const hasLangAttr = /<html[^>]*lang\s*=/i.test(html);
|
||||
const hasAriaLabels = /aria-label|aria-labelledby|aria-describedby/i.test(html);
|
||||
|
||||
// === SCORE CALCULATION ===
|
||||
let score = 100;
|
||||
const issues: { severity: "critical" | "warning" | "info"; category: string; message: string }[] = [];
|
||||
|
||||
if (!title) { score -= 10; issues.push({ severity: "critical", category: "Meta", message: "Missing title tag" }); }
|
||||
else if (titleLength > 60) { score -= 3; issues.push({ severity: "warning", category: "Meta", message: "Title too long (" + titleLength + " chars, max 60)" }); }
|
||||
if (!metaDescription) { score -= 10; issues.push({ severity: "critical", category: "Meta", message: "Missing meta description" }); }
|
||||
else if (descLength > 160) { score -= 3; issues.push({ severity: "warning", category: "Meta", message: "Meta description too long (" + descLength + " chars, max 160)" }); }
|
||||
if (h1Count === 0) { score -= 10; issues.push({ severity: "critical", category: "Content", message: "Missing H1 heading" }); }
|
||||
if (h1Count > 1) { score -= 5; issues.push({ severity: "critical", category: "Content", message: "Multiple H1 tags (" + h1Count + " found)" }); }
|
||||
if (!viewport) { score -= 10; issues.push({ severity: "critical", category: "Mobile", message: "Missing viewport meta tag" }); }
|
||||
if (!isHttps) { score -= 10; issues.push({ severity: "critical", category: "Security", message: "Not using HTTPS" }); }
|
||||
if (imagesWithoutAlt.length > 0) { score -= 5; issues.push({ severity: "warning", category: "Accessibility", message: imagesWithoutAlt.length + " images missing alt text" }); }
|
||||
if (!canonical) { score -= 3; issues.push({ severity: "warning", category: "Technical", message: "Missing canonical tag" }); }
|
||||
if (hasCanonicalMismatch) { score -= 5; issues.push({ severity: "warning", category: "Technical", message: "Canonical URL mismatch" }); }
|
||||
if (inlineStyleCount > 10) { score -= 3; issues.push({ severity: "warning", category: "Performance", message: inlineStyleCount + " inline styles detected" }); }
|
||||
if (!hasPreconnect && externalScripts > 3) { score -= 3; issues.push({ severity: "warning", category: "Performance", message: "Missing preconnect hints for external resources" }); }
|
||||
if (wordCount < 300 && wordCount > 0) { score -= 3; issues.push({ severity: "warning", category: "Content", message: "Thin content (" + wordCount + " words)" }); }
|
||||
if (!ogTitle && !ogDesc) { score -= 3; issues.push({ severity: "warning", category: "Social", message: "Missing Open Graph tags" }); }
|
||||
if (!twCard) { score -= 2; issues.push({ severity: "warning", category: "Social", message: "Missing Twitter Card tags" }); }
|
||||
if (externalLinks.length === 0) { score -= 2; issues.push({ severity: "warning", category: "Links", message: "No external links found" }); }
|
||||
if (robotsDirectives && /noindex/i.test(robotsDirectives)) { score -= 10; issues.push({ severity: "critical", category: "Technical", message: "Page has noindex directive" }); }
|
||||
if (!hasJsonLd && !hasMicrodata) { score -= 1; issues.push({ severity: "info", category: "Structured Data", message: "No structured data found" }); }
|
||||
if (!hasLangAttr) { score -= 1; issues.push({ severity: "info", category: "Accessibility", message: "Missing html lang attribute" }); }
|
||||
if (!lazyLoadedImages.length && images.length > 5) { score -= 2; issues.push({ severity: "info", category: "Performance", message: "Consider lazy loading for images" }); }
|
||||
|
||||
score = Math.max(0, Math.min(100, score));
|
||||
|
||||
const technicalScore = Math.min(100, 100 - issues.filter(i => i.category === "Technical" || i.category === "Security").reduce((s, i) => s + (i.severity === "critical" ? 15 : i.severity === "warning" ? 7 : 2), 0));
|
||||
const contentScore = Math.min(100, 100 - issues.filter(i => i.category === "Content").reduce((s, i) => s + (i.severity === "critical" ? 15 : i.severity === "warning" ? 7 : 2), 0));
|
||||
const performanceScore = Math.min(100, 100 - issues.filter(i => i.category === "Performance" || i.category === "Mobile").reduce((s, i) => s + (i.severity === "critical" ? 15 : i.severity === "warning" ? 7 : 2), 0));
|
||||
const socialScore = Math.min(100, 100 - issues.filter(i => i.category === "Social" || i.category === "Structured Data").reduce((s, i) => s + (i.severity === "critical" ? 15 : i.severity === "warning" ? 7 : 2), 0));
|
||||
|
||||
return NextResponse.json({
|
||||
url: normalizedUrl,
|
||||
domain: baseDomain,
|
||||
protocol: isHttps ? "HTTPS" : "HTTP",
|
||||
responseTime,
|
||||
server,
|
||||
htmlSize,
|
||||
title, titleLength,
|
||||
titleStatus: !title ? "missing" : titleLength > 60 ? "too_long" : "good",
|
||||
metaDescription, descLength,
|
||||
descStatus: !metaDescription ? "missing" : descLength > 160 ? "too_long" : "good",
|
||||
metaKeywords, viewport, charset, robotsDirectives,
|
||||
canonical, hasCanonicalMismatch, xFrameOptions,
|
||||
openGraph: { title: ogTitle ? ogTitle[1] : null, description: ogDesc ? ogDesc[1] : null, image: ogImage ? ogImage[1] : null, type: ogType ? ogType[1] : null, url: ogUrl ? ogUrl[1] : null },
|
||||
twitterCard: { card: twCard ? twCard[1] : null, title: twTitle ? twTitle[1] : null, description: twDesc ? twDesc[1] : null },
|
||||
headings: headingHierarchy, h1Count, h2Count, h3Count, h4Count,
|
||||
headingStatus: h1Count === 0 ? "missing_h1" : h1Count > 1 ? "multiple_h1" : "good",
|
||||
links: { total: links.length, internal: internalLinks.length, external: externalLinks.length, nofollow: nofollowLinks.length, sampleExternal: externalLinks.slice(0, 20).map(l => ({ href: l.href, text: l.text, nofollow: l.nofollow })) },
|
||||
images: { total: images.length, withAlt: imagesWithAlt.length, withoutAlt: imagesWithoutAlt.length, lazyLoaded: lazyLoadedImages.length, altCoverage: images.length > 0 ? Math.round((imagesWithAlt.length / images.length) * 100) : 100, sampleWithoutAlt: imagesWithoutAlt.slice(0, 10).map(img => img.src) },
|
||||
content: { wordCount, sentenceCount, paragraphCount, avgWordsPerSentence, textPreview: plainText.substring(0, 2000) },
|
||||
structuredData: { hasJsonLd, hasMicrodata, types: structuredData },
|
||||
hreflang: hreflangTags,
|
||||
performance: { inlineStyles: inlineStyleCount, inlineScripts: inlineScriptCount, externalScripts, externalStylesheets, hasPreconnect, hasPreload, hasDnsPrefetch, usesAsyncScripts, usesDeferScripts, contentEncoding },
|
||||
accessibility: { hasLangAttr, hasAriaLabels, hasAltOnFirstImage: images.length > 0 && images[0].alt && images[0].alt.trim().length > 0 },
|
||||
scores: { overall: score, technical: technicalScore, content: contentScore, performance: performanceScore, social: socialScore },
|
||||
issues,
|
||||
});
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : "Fetch failed";
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -638,12 +638,14 @@ export default function AIAssist() {
|
||||
setInput("");
|
||||
}
|
||||
|
||||
// Capture whether this is the initial plan phase (before any code generation)
|
||||
const wasIdle = !isApproval && (assistStep === "idle" || assistStep === "plan");
|
||||
// Plan-first workflow only applies to the "code" agent (software architect)
|
||||
// SEO, content, smm, design, web, app agents go straight to preview
|
||||
const isCodeAgent = currentAgent === "code" || currentAgent === "general";
|
||||
const wasIdle = !isApproval && isCodeAgent && (assistStep === "idle" || assistStep === "plan");
|
||||
// Detect if user is modifying existing code
|
||||
if (assistStep === "preview") setIsModifying(true);
|
||||
setIsProcessing(true);
|
||||
if (assistStep === "idle") setAssistStep("plan");
|
||||
if (assistStep === "idle" && isCodeAgent) setAssistStep("plan");
|
||||
|
||||
const assistantMsg: AIAssistMessage = {
|
||||
role: "assistant",
|
||||
@@ -695,7 +697,7 @@ export default function AIAssist() {
|
||||
setStatus(null);
|
||||
}
|
||||
|
||||
// SEO mode: auto-fetch URLs from user input for live auditing
|
||||
// SEO mode: auto-fetch URLs from user input for comprehensive live auditing
|
||||
if (currentAgent === "seo") {
|
||||
const urlMatches = [...finalInput.matchAll(/https?:\/\/[^\s<>"')\]]+/gi)].map(m => m[0]);
|
||||
const uniqueUrls = [...new Set(urlMatches)].slice(0, 2);
|
||||
@@ -705,36 +707,72 @@ export default function AIAssist() {
|
||||
for (const url of uniqueUrls) {
|
||||
const auditRes = await fetch("/api/fetch-url?url=" + encodeURIComponent(url));
|
||||
if (auditRes.ok) {
|
||||
const auditData = await auditRes.json();
|
||||
enrichedInput += "\n\n[WEBSITE AUDIT DATA - " + url + "]\n";
|
||||
enrichedInput += "Title: " + (auditData.title || "N/A") + "\n";
|
||||
enrichedInput += "Meta Description: " + (auditData.metaDescription || "N/A") + "\n";
|
||||
enrichedInput += "Meta Keywords: " + (auditData.metaKeywords || "N/A") + "\n";
|
||||
enrichedInput += "Canonical: " + (auditData.canonical || "N/A") + "\n";
|
||||
enrichedInput += "OG Title: " + (auditData.ogTitle || "N/A") + "\n";
|
||||
enrichedInput += "OG Description: " + (auditData.ogDescription || "N/A") + "\n";
|
||||
enrichedInput += "Headings:\n";
|
||||
if (auditData.headings && auditData.headings.length > 0) {
|
||||
for (const h of auditData.headings) {
|
||||
enrichedInput += " H" + h.level + ": " + h.text + "\n";
|
||||
const d = await auditRes.json();
|
||||
enrichedInput += "\n\n[COMPREHENSIVE SEO AUDIT - " + url + "]\n";
|
||||
enrichedInput += "== OVERALL SCORE: " + (d.scores?.overall || "?") + "/100 ==\n";
|
||||
enrichedInput += "Technical: " + (d.scores?.technical || "?") + " | Content: " + (d.scores?.content || "?") + " | Performance: " + (d.scores?.performance || "?") + " | Social: " + (d.scores?.social || "?") + "\n\n";
|
||||
enrichedInput += "== META TAGS ==\n";
|
||||
enrichedInput += "Title: " + (d.title || "MISSING") + " (" + (d.titleLength || 0) + " chars) [" + (d.titleStatus || "?") + "]\n";
|
||||
enrichedInput += "Description: " + (d.metaDescription || "MISSING") + " (" + (d.descLength || 0) + " chars) [" + (d.descStatus || "?") + "]\n";
|
||||
enrichedInput += "Keywords: " + (d.metaKeywords || "None") + "\n";
|
||||
enrichedInput += "Viewport: " + (d.viewport || "MISSING") + " | Charset: " + (d.charset || "None") + "\n";
|
||||
enrichedInput += "Canonical: " + (d.canonical || "None") + (d.hasCanonicalMismatch ? " [MISMATCH!]" : "") + "\n";
|
||||
enrichedInput += "Robots: " + (d.robotsDirectives || "None") + "\n\n";
|
||||
enrichedInput += "== OPEN GRAPH & SOCIAL ==\n";
|
||||
enrichedInput += "OG Title: " + (d.openGraph?.title || "None") + " | OG Desc: " + (d.openGraph?.description || "None") + " | OG Image: " + (d.openGraph?.image || "None") + "\n";
|
||||
enrichedInput += "Twitter Card: " + (d.twitterCard?.card || "None") + "\n\n";
|
||||
enrichedInput += "== HEADINGS ==\n";
|
||||
enrichedInput += "H1 count: " + d.h1Count + " (should be 1) | H2: " + d.h2Count + " | H3: " + d.h3Count + " [" + (d.headingStatus || "?") + "]\n";
|
||||
if (d.headings && d.headings.length > 0) {
|
||||
for (const h of d.headings.slice(0, 15)) enrichedInput += " H" + h.level + ": " + h.text + "\n";
|
||||
}
|
||||
enrichedInput += "\n== LINKS ==\n";
|
||||
const lnks = d.links || {};
|
||||
enrichedInput += "Total: " + (lnks.total || 0) + " | Internal: " + (lnks.internal || 0) + " | External: " + (lnks.external || 0) + " | Nofollow: " + (lnks.nofollow || 0) + "\n";
|
||||
if (lnks.sampleExternal && lnks.sampleExternal.length > 0) {
|
||||
enrichedInput += "Sample external: " + lnks.sampleExternal.slice(0, 10).map((l: { href: string }) => l.href).join(", ") + "\n";
|
||||
}
|
||||
enrichedInput += "\n== IMAGES ==\n";
|
||||
const imgs = d.images || {};
|
||||
enrichedInput += "Total: " + (imgs.total || 0) + " | With alt: " + (imgs.withAlt || 0) + " | Without alt: " + (imgs.withoutAlt || 0) + " | Lazy loaded: " + (imgs.lazyLoaded || 0) + "\n";
|
||||
enrichedInput += "Alt text coverage: " + (imgs.altCoverage || 0) + "%\n";
|
||||
if (imgs.sampleWithoutAlt && imgs.sampleWithoutAlt.length > 0) {
|
||||
enrichedInput += "Missing alt: " + imgs.sampleWithoutAlt.slice(0, 5).join(", ") + "\n";
|
||||
}
|
||||
enrichedInput += "\n== CONTENT ==\n";
|
||||
const cnt = d.content || {};
|
||||
enrichedInput += "Words: " + (cnt.wordCount || 0) + " | Sentences: " + (cnt.sentenceCount || 0) + " | Paragraphs: " + (cnt.paragraphCount || 0) + " | Avg words/sentence: " + (cnt.avgWordsPerSentence || 0) + "\n\n";
|
||||
enrichedInput += "== STRUCTURED DATA ==\n";
|
||||
enrichedInput += "JSON-LD: " + (d.structuredData?.hasJsonLd ? "Yes" : "No") + " | Microdata: " + (d.structuredData?.hasMicrodata ? "Yes" : "No") + "\n";
|
||||
const foundTypes = (d.structuredData?.types || []).filter((t: { found: boolean }) => t.found).map((t: { type: string }) => t.type);
|
||||
enrichedInput += "Schema types found: " + (foundTypes.length > 0 ? foundTypes.join(", ") : "None") + "\n\n";
|
||||
enrichedInput += "== PERFORMANCE SIGNALS ==\n";
|
||||
const perf = d.performance || {};
|
||||
enrichedInput += "Server: " + (d.server || "Unknown") + " | Response time: " + d.responseTime + "ms | HTML size: " + d.htmlSize + " bytes\n";
|
||||
enrichedInput += "External scripts: " + (perf.externalScripts || 0) + " | External stylesheets: " + (perf.externalStylesheets || 0) + " | Inline styles: " + (perf.inlineStyles || 0) + "\n";
|
||||
enrichedInput += "Preconnect: " + (perf.hasPreconnect ? "Yes" : "No") + " | Preload: " + (perf.hasPreload ? "Yes" : "No") + " | DNS prefetch: " + (perf.hasDnsPrefetch ? "Yes" : "No") + "\n";
|
||||
enrichedInput += "Async scripts: " + (perf.usesAsyncScripts ? "Yes" : "No") + " | Defer scripts: " + (perf.usesDeferScripts ? "Yes" : "No") + "\n\n";
|
||||
enrichedInput += "== ACCESSIBILITY ==\n";
|
||||
const acc = d.accessibility || {};
|
||||
enrichedInput += "Lang attr: " + (acc.hasLangAttr ? "Yes" : "No") + " | ARIA labels: " + (acc.hasAriaLabels ? "Yes" : "No") + " | First image alt: " + (acc.hasAltOnFirstImage ? "Yes" : "No") + "\n";
|
||||
if (d.hreflang && d.hreflang.length > 0) {
|
||||
enrichedInput += "\n== HREFLANG ==\n" + d.hreflang.map((h: { lang: string; href: string }) => h.lang + " -> " + h.href).join("\n") + "\n";
|
||||
}
|
||||
enrichedInput += "\n== ISSUES FOUND ==\n";
|
||||
if (d.issues && d.issues.length > 0) {
|
||||
for (const issue of d.issues) {
|
||||
enrichedInput += "[" + issue.severity.toUpperCase() + "] " + issue.category + ": " + issue.message + "\n";
|
||||
}
|
||||
} else {
|
||||
enrichedInput += " None found\n";
|
||||
enrichedInput += "No issues detected.\n";
|
||||
}
|
||||
const internalLinks = (auditData.links || []).filter((l: { internal: boolean }) => l.internal);
|
||||
const externalLinks = (auditData.links || []).filter((l: { internal: boolean }) => !l.internal);
|
||||
enrichedInput += "Links: " + internalLinks.length + " internal, " + externalLinks.length + " external (of " + (auditData.links || []).length + " total)\n";
|
||||
const imagesWithAlt = (auditData.images || []).filter((img: { alt: string }) => img.alt && img.alt.trim());
|
||||
const imagesNoAlt = (auditData.images || []).filter((img: { alt: string }) => !img.alt || !img.alt.trim());
|
||||
enrichedInput += "Images: " + (auditData.images || []).length + " total, " + imagesWithAlt.length + " with alt, " + imagesNoAlt.length + " without alt\n";
|
||||
enrichedInput += "Text content length: " + (auditData.text || "").length + " chars\n";
|
||||
enrichedInput += "HTML size: " + (auditData.htmlLength || 0) + " chars\n";
|
||||
enrichedInput += "[/WEBSITE AUDIT DATA]\n";
|
||||
enrichedInput += "[/COMPREHENSIVE SEO AUDIT]\n";
|
||||
}
|
||||
}
|
||||
} catch (e) { console.warn("Website audit failed:", e); }
|
||||
setStatus(null);
|
||||
}
|
||||
|
||||
// If no URL found and web search not enabled, auto-enable web search for SEO
|
||||
if (uniqueUrls.length === 0 && !webSearchEnabled) {
|
||||
try {
|
||||
@@ -812,8 +850,9 @@ export default function AIAssist() {
|
||||
|
||||
if (!response.success) throw new Error(response.error);
|
||||
|
||||
// When this was the initial request from idle/plan, ALWAYS show plan card
|
||||
// Post-stream routing
|
||||
if (wasIdle) {
|
||||
// Code agent: show plan card for approval
|
||||
setAssistStep("plan");
|
||||
const { plan: parsedPlan } = parsePlanFromResponse(accumulated);
|
||||
if (parsedPlan) {
|
||||
@@ -822,10 +861,13 @@ export default function AIAssist() {
|
||||
setAiPlan({ rawText: accumulated, architecture: "", techStack: [], files: [], steps: [] });
|
||||
}
|
||||
} else if ((lastParsedPreview as PreviewData | null)?.data) {
|
||||
// After approval: show the generated preview
|
||||
// Non-code agents or approved code: show the generated preview directly
|
||||
setAssistStep("preview");
|
||||
setShowCanvas(true);
|
||||
if (isPreviewRenderable(lastParsedPreview)) setViewMode("preview");
|
||||
} else if (!isCodeAgent && !wasIdle) {
|
||||
// Non-code agent without preview: just return to idle
|
||||
setAssistStep("idle");
|
||||
} else {
|
||||
setAssistStep("idle");
|
||||
}
|
||||
@@ -1211,7 +1253,7 @@ export default function AIAssist() {
|
||||
</div>
|
||||
|
||||
{/* Agentic Plan Review Card */}
|
||||
{msg.role === "assistant" && aiPlan && i === aiAssistHistory.length - 1 && assistStep === "plan" && (
|
||||
{msg.role === "assistant" && aiPlan && i === aiAssistHistory.length - 1 && assistStep === "plan" && (currentAgent === "code" || currentAgent === "general") && (
|
||||
<div className="mt-6 p-6 rounded-2xl bg-blue-500/5 border border-blue-500/20 backdrop-blur-sm animate-in zoom-in-95 duration-300">
|
||||
<h3 className="text-sm font-black text-blue-400 uppercase tracking-widest mb-4 flex items-center gap-2">
|
||||
<LayoutPanelLeft className="h-4 w-4" /> {t.proposedPlan}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "promptarch",
|
||||
"version": "1.6.0",
|
||||
"version": "1.7.0",
|
||||
"description": "Transform vague ideas into production-ready prompts and PRDs",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
Reference in New Issue
Block a user