cinqcinqdev-seo 0.1.11 → 0.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/module.json +1 -1
- package/dist/module.mjs +10 -7
- package/dist/runtime/assets/admin-tw.css +1 -1
- package/dist/runtime/assets/admin.css +1 -1
- package/dist/runtime/pages/admin/editor/[id].vue +682 -310
- package/dist/runtime/pages/admin/index.vue +2 -3
- package/dist/runtime/server/api/ai/generate.post.d.ts +2 -0
- package/dist/runtime/server/api/ai/generate.post.js +102 -0
- package/dist/runtime/server/api/ai/seo-audit.post.d.ts +2 -0
- package/dist/runtime/server/api/ai/seo-audit.post.js +86 -0
- package/package.json +1 -1
|
@@ -8,8 +8,7 @@ const pageTypes = computed(() => {
|
|
|
8
8
|
const types = config?.pageTypes || [];
|
|
9
9
|
return types.map((pt) => ({
|
|
10
10
|
...pt,
|
|
11
|
-
desc: pt.desc || ""
|
|
12
|
-
svgIcon: pt.svgIcon || ""
|
|
11
|
+
desc: pt.desc || ""
|
|
13
12
|
}));
|
|
14
13
|
});
|
|
15
14
|
const defaultIcons = {
|
|
@@ -80,7 +79,7 @@ const { data: stats } = await useAsyncData("admin-dashboard-stats", async () =>
|
|
|
80
79
|
>
|
|
81
80
|
<div class="flex items-start justify-between mb-4">
|
|
82
81
|
<div class="w-9 h-9 rounded-xl bg-gray-50 group-hover:bg-[#3d35ff]/10 flex items-center justify-center transition-colors">
|
|
83
|
-
<svg class="text-gray-400 group-hover:text-[#3d35ff] transition-colors" style="width:18px;height:18px" fill="none" stroke="currentColor" viewBox="0 0 24 24" v-html="type.
|
|
82
|
+
<svg class="text-gray-400 group-hover:text-[#3d35ff] transition-colors" style="width:18px;height:18px" fill="none" stroke="currentColor" viewBox="0 0 24 24" v-html="type.svgPath || defaultIcons[type.id] || defaultIcons.blog_article"></svg>
|
|
84
83
|
</div>
|
|
85
84
|
<span class="text-[10px] font-black px-2 py-1 rounded-full bg-gray-50 text-gray-400">
|
|
86
85
|
{{ stats?.byType?.[type.id] ?? 0 }}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
const isI18nField = (val) => val !== null && typeof val === "object" && !Array.isArray(val) && ("fr" in val || "ar" in val || "en" in val);
|
|
2
|
+
export default defineEventHandler(async (event) => {
|
|
3
|
+
const body = await readBody(event);
|
|
4
|
+
const { sectionType, sectionLabel, fields, currentProps, prompt, itemFieldsConfig } = body;
|
|
5
|
+
const config = useRuntimeConfig();
|
|
6
|
+
if (!config.openrouterApiKey) {
|
|
7
|
+
throw createError({ statusCode: 500, message: "OPENROUTER_API_KEY not configured" });
|
|
8
|
+
}
|
|
9
|
+
const skeleton = {};
|
|
10
|
+
for (const f of fields) {
|
|
11
|
+
skeleton[f.name] = { fr: "...", ar: "...", en: "..." };
|
|
12
|
+
}
|
|
13
|
+
const currentItems = currentProps?.items || [];
|
|
14
|
+
const configuredItemFields = itemFieldsConfig || [];
|
|
15
|
+
let hasItems = false;
|
|
16
|
+
if (configuredItemFields.length > 0) {
|
|
17
|
+
hasItems = true;
|
|
18
|
+
const defaultCount = currentItems.length > 0 ? currentItems.length : 3;
|
|
19
|
+
skeleton.items = Array.from({ length: defaultCount }, (_, idx) => {
|
|
20
|
+
const existing = currentItems[idx] || {};
|
|
21
|
+
const obj = {};
|
|
22
|
+
for (const f of configuredItemFields) {
|
|
23
|
+
if (f.type === "stringlist") {
|
|
24
|
+
const existingFeatures = existing[f.name] || [];
|
|
25
|
+
const featureCount = existingFeatures.length > 0 ? existingFeatures.length : 3;
|
|
26
|
+
obj[f.name] = Array.from({ length: featureCount }, () => ({ fr: "...", ar: "...", en: "..." }));
|
|
27
|
+
} else if (f.i18n) {
|
|
28
|
+
obj[f.name] = { fr: "...", ar: "...", en: "..." };
|
|
29
|
+
} else {
|
|
30
|
+
obj[f.name] = existing[f.name] || "...";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return obj;
|
|
34
|
+
});
|
|
35
|
+
} else if (currentItems.length > 0) {
|
|
36
|
+
const firstItem = currentItems[0];
|
|
37
|
+
const i18nFields = Object.keys(firstItem).filter((k) => isI18nField(firstItem[k]));
|
|
38
|
+
if (i18nFields.length > 0) {
|
|
39
|
+
hasItems = true;
|
|
40
|
+
skeleton.items = currentItems.map((item) => {
|
|
41
|
+
const obj = {};
|
|
42
|
+
for (const f of i18nFields) obj[f] = { fr: "...", ar: "...", en: "..." };
|
|
43
|
+
return obj;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const contextSummary = [
|
|
48
|
+
...fields.map((f) => `${f.name}: "${currentProps?.[f.name]?.fr || ""}"`),
|
|
49
|
+
...currentItems.length > 0 ? currentItems.map((it, i) => {
|
|
50
|
+
const firstField = configuredItemFields[0]?.name || Object.keys(it)[0];
|
|
51
|
+
const val = it[firstField];
|
|
52
|
+
return `items[${i}]: "${typeof val === "object" ? val?.fr : val || ""}"`;
|
|
53
|
+
}) : []
|
|
54
|
+
].join("\n");
|
|
55
|
+
const systemPrompt = `You are a professional multilingual copywriter for a web agency CMS.
|
|
56
|
+
Always respond with a valid JSON object only \u2014 no markdown, no code blocks, no explanation.`;
|
|
57
|
+
const userPrompt = `Section: ${sectionLabel} (${sectionType})
|
|
58
|
+
User instruction: ${prompt}
|
|
59
|
+
|
|
60
|
+
Current content:
|
|
61
|
+
${contextSummary}
|
|
62
|
+
|
|
63
|
+
Fill this JSON skeleton (replace every "..." with real content):
|
|
64
|
+
${JSON.stringify(skeleton, null, 2)}
|
|
65
|
+
|
|
66
|
+
Rules:
|
|
67
|
+
- Replace ALL "..." \u2014 never leave "..." in the response
|
|
68
|
+
- Arabic must be real Arabic script (right-to-left)
|
|
69
|
+
- Titles: max 8 words
|
|
70
|
+
- Subtitles/descriptions: max 2 sentences
|
|
71
|
+
- Badges: max 3 words
|
|
72
|
+
- items array: return exactly ${skeleton.items?.length ?? 0} items
|
|
73
|
+
- i18n fields in items use {"fr":"...","ar":"...","en":"..."} format
|
|
74
|
+
- plain text fields in items use a plain string value
|
|
75
|
+
- stringlist fields are arrays of {"fr":"...","ar":"...","en":"..."} objects
|
|
76
|
+
- Tone: professional, modern, confident`;
|
|
77
|
+
const response = await $fetch("https://openrouter.ai/api/v1/chat/completions", {
|
|
78
|
+
method: "POST",
|
|
79
|
+
headers: {
|
|
80
|
+
"Authorization": `Bearer ${config.openrouterApiKey}`,
|
|
81
|
+
"Content-Type": "application/json",
|
|
82
|
+
"HTTP-Referer": "https://admin-cms.dev",
|
|
83
|
+
"X-Title": "Admin CMS"
|
|
84
|
+
},
|
|
85
|
+
body: {
|
|
86
|
+
model: "google/gemini-2.0-flash-lite-001",
|
|
87
|
+
messages: [
|
|
88
|
+
{ role: "system", content: systemPrompt },
|
|
89
|
+
{ role: "user", content: userPrompt }
|
|
90
|
+
],
|
|
91
|
+
temperature: 0.7
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
const content = response.choices?.[0]?.message?.content;
|
|
95
|
+
if (!content) throw createError({ statusCode: 500, message: "Empty AI response" });
|
|
96
|
+
const cleaned = content.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "").trim();
|
|
97
|
+
try {
|
|
98
|
+
return JSON.parse(cleaned);
|
|
99
|
+
} catch {
|
|
100
|
+
throw createError({ statusCode: 500, message: "Invalid JSON from AI: " + cleaned.slice(0, 200) });
|
|
101
|
+
}
|
|
102
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
export default defineEventHandler(async (event) => {
|
|
2
|
+
const body = await readBody(event);
|
|
3
|
+
const { pageTitle, pageType, content, seoConfig } = body;
|
|
4
|
+
const config = useRuntimeConfig();
|
|
5
|
+
if (!config.openrouterApiKey) {
|
|
6
|
+
throw createError({ statusCode: 500, message: "OPENROUTER_API_KEY not configured" });
|
|
7
|
+
}
|
|
8
|
+
const contentSummary = content.map((block) => {
|
|
9
|
+
const parts = [`[${block.type}]`];
|
|
10
|
+
const p = block.props || {};
|
|
11
|
+
const t = (v) => typeof v === "object" ? v?.fr || "" : v || "";
|
|
12
|
+
if (p.title) parts.push(`title: "${t(p.title)}"`);
|
|
13
|
+
if (p.subtitle) parts.push(`subtitle: "${t(p.subtitle)}"`);
|
|
14
|
+
if (p.description) parts.push(`description: "${t(p.description)}"`);
|
|
15
|
+
if (p.badge) parts.push(`badge: "${t(p.badge)}"`);
|
|
16
|
+
if (Array.isArray(p.items)) {
|
|
17
|
+
p.items.forEach((it, j) => {
|
|
18
|
+
const itText = t(it.title || it.question || it.name);
|
|
19
|
+
if (itText) parts.push(`item${j}: "${itText}"`);
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
return parts.join(" | ");
|
|
23
|
+
}).join("\n");
|
|
24
|
+
const currentMeta = {
|
|
25
|
+
title: typeof seoConfig?.meta_title === "object" ? seoConfig.meta_title?.fr : seoConfig?.meta_title || "",
|
|
26
|
+
description: typeof seoConfig?.meta_description === "object" ? seoConfig.meta_description?.fr : seoConfig?.meta_description || ""
|
|
27
|
+
};
|
|
28
|
+
const systemPrompt = `You are an expert SEO consultant.
|
|
29
|
+
Respond with a valid JSON object only \u2014 no markdown, no code blocks, no extra text.`;
|
|
30
|
+
const userPrompt = `Audit this web page for SEO and provide actionable recommendations.
|
|
31
|
+
|
|
32
|
+
Page title: "${pageTitle}"
|
|
33
|
+
Page type: ${pageType}
|
|
34
|
+
Current meta title: "${currentMeta.title}"
|
|
35
|
+
Current meta description: "${currentMeta.description}"
|
|
36
|
+
|
|
37
|
+
Page content:
|
|
38
|
+
${contentSummary}
|
|
39
|
+
|
|
40
|
+
Return this exact JSON structure:
|
|
41
|
+
{
|
|
42
|
+
"score": <number 0-100>,
|
|
43
|
+
"meta_title": { "fr": "...", "ar": "...", "en": "..." },
|
|
44
|
+
"meta_description": { "fr": "...", "ar": "...", "en": "..." },
|
|
45
|
+
"keywords": ["keyword1", "keyword2", "keyword3", "keyword4", "keyword5"],
|
|
46
|
+
"issues": [
|
|
47
|
+
{ "level": "error"|"warning"|"info", "message": "..." }
|
|
48
|
+
],
|
|
49
|
+
"suggestions": ["actionable tip 1", "actionable tip 2", "actionable tip 3"]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
Rules:
|
|
53
|
+
- score: honest SEO score based on content quality, meta tags, keyword usage, structure
|
|
54
|
+
- meta_title: optimized title 50-60 chars, include main keyword
|
|
55
|
+
- meta_description: compelling 150-160 chars description with CTA
|
|
56
|
+
- keywords: most relevant keywords for this page
|
|
57
|
+
- issues: identify real problems (missing meta, duplicate H1, thin content, etc.)
|
|
58
|
+
- suggestions: max 4 specific, actionable tips
|
|
59
|
+
- Arabic meta must be real Arabic script
|
|
60
|
+
- All text must reflect the actual page content`;
|
|
61
|
+
const response = await $fetch("https://openrouter.ai/api/v1/chat/completions", {
|
|
62
|
+
method: "POST",
|
|
63
|
+
headers: {
|
|
64
|
+
"Authorization": `Bearer ${config.openrouterApiKey}`,
|
|
65
|
+
"Content-Type": "application/json",
|
|
66
|
+
"HTTP-Referer": "https://admin-cms.dev",
|
|
67
|
+
"X-Title": "Admin CMS"
|
|
68
|
+
},
|
|
69
|
+
body: {
|
|
70
|
+
model: "google/gemini-2.0-flash-lite-001",
|
|
71
|
+
messages: [
|
|
72
|
+
{ role: "system", content: systemPrompt },
|
|
73
|
+
{ role: "user", content: userPrompt }
|
|
74
|
+
],
|
|
75
|
+
temperature: 0.4
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
const aiContent = response.choices?.[0]?.message?.content;
|
|
79
|
+
if (!aiContent) throw createError({ statusCode: 500, message: "Empty AI response" });
|
|
80
|
+
const cleaned = aiContent.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "").trim();
|
|
81
|
+
try {
|
|
82
|
+
return JSON.parse(cleaned);
|
|
83
|
+
} catch {
|
|
84
|
+
throw createError({ statusCode: 500, message: "Invalid JSON from AI" });
|
|
85
|
+
}
|
|
86
|
+
});
|