strapi-plugin-seo-gemini 1.0.0

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/README.md ADDED
@@ -0,0 +1,69 @@
1
+ # Strapi SEO Gemini Plugin
2
+
3
+ A premium, AI-powered Strapi plugin that seamlessly generates highly optimized SEO metadata (Title, Description, Keywords, Robots, and JSON-LD Structured Data) using Google's Gemini Flash models.
4
+
5
+ ## Features
6
+
7
+ - **Automated Metadata**: Generates Title, Description, Keywords, Meta Robots, and Structured Data (JSON-LD) from any text.
8
+ - **Strict Architecture**: Built with TypeScript, enforcing strict types (`unknown` over `any`), and adhering to the Clean Code Controller-Service pattern.
9
+ - **Seamless UI**: A highly polished, customized Strapi admin interface featuring asymmetric grid layouts, sophisticated typography, and smooth skeleton loaders.
10
+ - **Enterprise Reliability**: Implements an automatic model fallback system (`gemini-2.5-flash` -> `2.0` -> `1.5`) to handle high API demand (503) or rate limits (429) invisibly to the user.
11
+ - **Frontend Optimized**: The React UI uses `useMemo`, `useCallback`, and `useRef` to guarantee 60fps rendering, eliminating unneeded re-renders and memory leaks.
12
+
13
+ ## Requirements
14
+
15
+ - Strapi v5.x
16
+ - Node.js >= 18.x
17
+ - A Google Gemini API Key
18
+
19
+ ## Installation
20
+
21
+ As a local plugin in your Strapi project:
22
+
23
+ 1. Ensure the plugin folder is located at `src/plugins/strapi-plugin-seo-gemini` (or linked correctly via npm workspaces/docker volumes).
24
+ 2. Install dependencies within the plugin directory:
25
+ ```bash
26
+ cd src/plugins/strapi-plugin-seo-gemini
27
+ npm install
28
+ npm run build
29
+ ```
30
+
31
+ ## Configuration
32
+
33
+ For security and standard Marketplace compliance, the API key is configured via Strapi's plugin configuration system, separating secrets from the codebase.
34
+
35
+ Add the configuration in your Strapi project's `config/plugins.ts` (or `.js`):
36
+
37
+ ```typescript
38
+ export default ({ env }) => ({
39
+ // ... other plugins
40
+ 'strapi-plugin-seo-gemini': {
41
+ enabled: true,
42
+ config: {
43
+ // Securely fetch the API key from your environment variables
44
+ apiKey: env('GEMINI_API_KEY'),
45
+ },
46
+ },
47
+ });
48
+ ```
49
+
50
+ Ensure you have `GEMINI_API_KEY=your_api_key_here` set in your project's `.env` file.
51
+
52
+ ## Usage
53
+
54
+ 1. Restart your Strapi backend.
55
+ 2. In the Strapi Admin Sidebar, click on **SEO Gemini**.
56
+ 3. Paste the content of your article, page, or product into the prominent text area.
57
+ 4. Click **Generate Metadata**.
58
+ 5. Once the AI finishes generating (indicated by the skeleton loaders), use the convenient **Copy** buttons to transfer the optimized metadata directly into your Content Manager fields.
59
+
60
+ ## Development
61
+
62
+ This plugin was rigorously audited for performance and architecture.
63
+ - Frontend modifications (React): Check `admin/src/pages/HomePage.tsx`.
64
+ - Backend AI Logic: Check `server/src/services/service.ts`.
65
+
66
+ To recompile during development:
67
+ ```bash
68
+ npm run build
69
+ ```
@@ -0,0 +1,238 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
3
+ const jsxRuntime = require("react/jsx-runtime");
4
+ const admin = require("@strapi/strapi/admin");
5
+ const reactRouterDom = require("react-router-dom");
6
+ const react = require("react");
7
+ const designSystem = require("@strapi/design-system");
8
+ const styled = require("styled-components");
9
+ const _interopDefault = (e) => e && e.__esModule ? e : { default: e };
10
+ const styled__default = /* @__PURE__ */ _interopDefault(styled);
11
+ const pulse = styled.keyframes`
12
+ 0% { opacity: 1; }
13
+ 50% { opacity: 0.4; }
14
+ 100% { opacity: 1; }
15
+ `;
16
+ const SkeletonBox = styled__default.default(designSystem.Box)`
17
+ animation: ${pulse} 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite;
18
+ background-color: ${({ theme }) => theme.colors.neutral200};
19
+ border-radius: ${({ theme }) => theme.borderRadius};
20
+ height: ${(props) => props.$height || "20px"};
21
+ width: ${(props) => props.$width || "100%"};
22
+ `;
23
+ const CustomGrid = styled__default.default.div`
24
+ display: grid;
25
+ grid-template-columns: minmax(0, 1fr);
26
+ align-items: start;
27
+ gap: ${({ theme }) => theme.spaces[8]};
28
+
29
+ @media (min-width: 900px) {
30
+ grid-template-columns: minmax(0, 6fr) minmax(0, 5fr);
31
+ }
32
+ `;
33
+ const CustomGridItem = styled__default.default.div`
34
+ min-width: 0;
35
+ width: 100%;
36
+ `;
37
+ const PremiumCard = styled__default.default(designSystem.Box)`
38
+ transition: all 0.2s ease-in-out;
39
+ border: 1px solid ${({ theme }) => theme.colors.neutral200};
40
+
41
+ &:hover {
42
+ box-shadow: ${({ theme }) => theme.shadows.tableShadow};
43
+ border-color: ${({ theme }) => theme.colors.neutral300};
44
+ }
45
+ `;
46
+ const PremiumButton = styled__default.default(designSystem.Button)`
47
+ padding-left: ${({ theme }) => theme.spaces[6]};
48
+ padding-right: ${({ theme }) => theme.spaces[6]};
49
+ padding-top: ${({ theme }) => theme.spaces[3]};
50
+ padding-bottom: ${({ theme }) => theme.spaces[3]};
51
+ font-weight: 600;
52
+ letter-spacing: 0.2px;
53
+ transition: transform 0.1s ease;
54
+
55
+ &:active {
56
+ transform: scale(0.98);
57
+ }
58
+ `;
59
+ const CopyButton = styled__default.default.button`
60
+ background: none;
61
+ border: none;
62
+ color: ${({ theme }) => theme.colors.neutral500};
63
+ font-size: ${({ theme }) => theme.fontSizes[1]};
64
+ font-weight: 600;
65
+ cursor: pointer;
66
+ padding: ${({ theme }) => `${theme.spaces[1]} ${theme.spaces[2]}`};
67
+ border-radius: ${({ theme }) => theme.borderRadius};
68
+ transition: all 0.15s ease;
69
+
70
+ &:hover {
71
+ color: ${({ theme }) => theme.colors.neutral800};
72
+ background-color: ${({ theme }) => theme.colors.neutral150};
73
+ }
74
+ `;
75
+ const MetaLabel = styled__default.default(designSystem.Typography)`
76
+ letter-spacing: 1px;
77
+ text-transform: uppercase;
78
+ color: ${({ theme }) => theme.colors.neutral600};
79
+ `;
80
+ const HomePage = () => {
81
+ const [content, setContent] = react.useState("");
82
+ const [loading, setLoading] = react.useState(false);
83
+ const [result, setResult] = react.useState(null);
84
+ const [error, setError] = react.useState(null);
85
+ const [copiedState, setCopiedState] = react.useState({});
86
+ const { post } = admin.useFetchClient();
87
+ const handleGenerate = async () => {
88
+ if (!content.trim()) return;
89
+ setLoading(true);
90
+ setError(null);
91
+ setResult(null);
92
+ setCopiedState({});
93
+ try {
94
+ const { data } = await post("/strapi-plugin-seo-gemini/generate", {
95
+ content
96
+ });
97
+ setResult(data.data);
98
+ } catch (err) {
99
+ const errorObj = err;
100
+ setError(errorObj.response?.data?.error?.message || errorObj.message || "An error occurred during generation.");
101
+ } finally {
102
+ setLoading(false);
103
+ }
104
+ };
105
+ const copyTimeouts = react.useRef({});
106
+ const handleCopy = react.useCallback((text, key) => {
107
+ navigator.clipboard.writeText(text);
108
+ setCopiedState((prev) => ({ ...prev, [key]: true }));
109
+ if (copyTimeouts.current[key]) {
110
+ clearTimeout(copyTimeouts.current[key]);
111
+ }
112
+ copyTimeouts.current[key] = setTimeout(() => {
113
+ setCopiedState((prev) => ({ ...prev, [key]: false }));
114
+ }, 2e3);
115
+ }, []);
116
+ const resultsColumn = react.useMemo(() => {
117
+ return /* @__PURE__ */ jsxRuntime.jsx(CustomGridItem, { children: /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Box, { paddingLeft: 4, children: [
118
+ loading && /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Flex, { direction: "column", gap: 8, children: [
119
+ /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Box, { children: [
120
+ /* @__PURE__ */ jsxRuntime.jsx(SkeletonBox, { $width: "120px", $height: "16px", marginBottom: 3 }),
121
+ /* @__PURE__ */ jsxRuntime.jsx(SkeletonBox, { $height: "60px" })
122
+ ] }),
123
+ /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Box, { children: [
124
+ /* @__PURE__ */ jsxRuntime.jsx(SkeletonBox, { $width: "140px", $height: "16px", marginBottom: 3 }),
125
+ /* @__PURE__ */ jsxRuntime.jsx(SkeletonBox, { $height: "80px" })
126
+ ] }),
127
+ /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Box, { children: [
128
+ /* @__PURE__ */ jsxRuntime.jsx(SkeletonBox, { $width: "100px", $height: "16px", marginBottom: 3 }),
129
+ /* @__PURE__ */ jsxRuntime.jsx(SkeletonBox, { $height: "40px" })
130
+ ] }),
131
+ /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Box, { children: [
132
+ /* @__PURE__ */ jsxRuntime.jsx(SkeletonBox, { $width: "110px", $height: "16px", marginBottom: 3 }),
133
+ /* @__PURE__ */ jsxRuntime.jsx(SkeletonBox, { $height: "30px" })
134
+ ] }),
135
+ /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Box, { children: [
136
+ /* @__PURE__ */ jsxRuntime.jsx(SkeletonBox, { $width: "150px", $height: "16px", marginBottom: 3 }),
137
+ /* @__PURE__ */ jsxRuntime.jsx(SkeletonBox, { $height: "100px" })
138
+ ] })
139
+ ] }),
140
+ !loading && !result && /* @__PURE__ */ jsxRuntime.jsx(
141
+ designSystem.Flex,
142
+ {
143
+ justifyContent: "center",
144
+ alignItems: "center",
145
+ style: { height: "300px", border: "1px dashed #eaeaef", borderRadius: "8px" },
146
+ children: /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { textColor: "neutral500", children: "Metadata will appear here." })
147
+ }
148
+ ),
149
+ result && !loading && /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Flex, { direction: "column", alignItems: "stretch", gap: 6, children: [
150
+ /* @__PURE__ */ jsxRuntime.jsxs(PremiumCard, { background: "neutral0", padding: 5, hasRadius: true, children: [
151
+ /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Flex, { justifyContent: "space-between", alignItems: "center", marginBottom: 3, children: [
152
+ /* @__PURE__ */ jsxRuntime.jsx(MetaLabel, { variant: "pi", fontWeight: "bold", children: "Meta Title" }),
153
+ /* @__PURE__ */ jsxRuntime.jsx(CopyButton, { onClick: () => handleCopy(result.title, "title"), children: copiedState["title"] ? "Copied!" : "Copy" })
154
+ ] }),
155
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "epsilon", style: { lineHeight: "1.5" }, children: result.title || "No data generated" })
156
+ ] }),
157
+ /* @__PURE__ */ jsxRuntime.jsxs(PremiumCard, { background: "neutral0", padding: 5, hasRadius: true, children: [
158
+ /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Flex, { justifyContent: "space-between", alignItems: "center", marginBottom: 3, children: [
159
+ /* @__PURE__ */ jsxRuntime.jsx(MetaLabel, { variant: "pi", fontWeight: "bold", children: "Meta Description" }),
160
+ /* @__PURE__ */ jsxRuntime.jsx(CopyButton, { onClick: () => handleCopy(result.description, "description"), children: copiedState["description"] ? "Copied!" : "Copy" })
161
+ ] }),
162
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "epsilon", style: { lineHeight: "1.5" }, children: result.description || "No data generated" })
163
+ ] }),
164
+ /* @__PURE__ */ jsxRuntime.jsxs(PremiumCard, { background: "neutral0", padding: 5, hasRadius: true, children: [
165
+ /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Flex, { justifyContent: "space-between", alignItems: "center", marginBottom: 3, children: [
166
+ /* @__PURE__ */ jsxRuntime.jsx(MetaLabel, { variant: "pi", fontWeight: "bold", children: "Keywords" }),
167
+ /* @__PURE__ */ jsxRuntime.jsx(
168
+ CopyButton,
169
+ {
170
+ onClick: () => handleCopy(
171
+ Array.isArray(result.keywords) ? result.keywords.join(", ") : result.keywords,
172
+ "keywords"
173
+ ),
174
+ children: copiedState["keywords"] ? "Copied!" : "Copy"
175
+ }
176
+ )
177
+ ] }),
178
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "epsilon", style: { lineHeight: "1.5" }, children: Array.isArray(result.keywords) ? result.keywords.join(", ") : result.keywords || "No data generated" })
179
+ ] }),
180
+ result.metaRobots && /* @__PURE__ */ jsxRuntime.jsxs(PremiumCard, { background: "neutral0", padding: 5, hasRadius: true, children: [
181
+ /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Flex, { justifyContent: "space-between", alignItems: "center", marginBottom: 3, children: [
182
+ /* @__PURE__ */ jsxRuntime.jsx(MetaLabel, { variant: "pi", fontWeight: "bold", children: "Meta Robots" }),
183
+ /* @__PURE__ */ jsxRuntime.jsx(CopyButton, { onClick: () => handleCopy(result.metaRobots || "", "metaRobots"), children: copiedState["metaRobots"] ? "Copied!" : "Copy" })
184
+ ] }),
185
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "epsilon", style: { lineHeight: "1.5" }, children: result.metaRobots })
186
+ ] }),
187
+ result.structuredData && /* @__PURE__ */ jsxRuntime.jsxs(PremiumCard, { background: "neutral0", padding: 5, hasRadius: true, children: [
188
+ /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Flex, { justifyContent: "space-between", alignItems: "center", marginBottom: 3, children: [
189
+ /* @__PURE__ */ jsxRuntime.jsx(MetaLabel, { variant: "pi", fontWeight: "bold", children: "Structured Data (JSON-LD)" }),
190
+ /* @__PURE__ */ jsxRuntime.jsx(CopyButton, { onClick: () => handleCopy(JSON.stringify(result.structuredData, null, 2), "structuredData"), children: copiedState["structuredData"] ? "Copied!" : "Copy" })
191
+ ] }),
192
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Box, { background: "neutral100", padding: 3, hasRadius: true, style: { overflowX: "auto", maxHeight: "200px", fontSize: "12px" }, children: /* @__PURE__ */ jsxRuntime.jsx("pre", { style: { margin: 0, fontFamily: "monospace" }, children: JSON.stringify(result.structuredData, null, 2) }) })
193
+ ] })
194
+ ] })
195
+ ] }) });
196
+ }, [loading, result, copiedState, handleCopy]);
197
+ return /* @__PURE__ */ jsxRuntime.jsx(designSystem.Main, { children: /* @__PURE__ */ jsxRuntime.jsx(designSystem.Box, { padding: 10, background: "neutral0", minHeight: "100vh", children: /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Box, { maxWidth: "1200px", margin: "0 auto", children: [
198
+ /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Box, { marginBottom: 10, children: [
199
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "alpha", tag: "h1", fontWeight: "bold", style: { letterSpacing: "-0.5px" }, children: "SEO Metadata Generator" }),
200
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Box, { marginTop: 3, children: /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "epsilon", textColor: "neutral600", children: "Paste your content here to magically generate optimized metadata." }) })
201
+ ] }),
202
+ /* @__PURE__ */ jsxRuntime.jsxs(CustomGrid, { children: [
203
+ /* @__PURE__ */ jsxRuntime.jsx(CustomGridItem, { children: /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Flex, { direction: "column", alignItems: "stretch", gap: 6, children: [
204
+ /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Field.Root, { error: error || void 0, children: [
205
+ /* @__PURE__ */ jsxRuntime.jsx(
206
+ designSystem.Textarea,
207
+ {
208
+ placeholder: "Enter your article or page content...",
209
+ value: content,
210
+ onChange: (e) => setContent(e.target.value),
211
+ rows: 16,
212
+ style: { fontSize: "15px", lineHeight: "1.6", padding: "16px" }
213
+ }
214
+ ),
215
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Field.Error, {})
216
+ ] }),
217
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Box, { children: /* @__PURE__ */ jsxRuntime.jsx(
218
+ PremiumButton,
219
+ {
220
+ onClick: handleGenerate,
221
+ loading,
222
+ disabled: !content.trim(),
223
+ size: "L",
224
+ children: "Generate Metadata"
225
+ }
226
+ ) })
227
+ ] }) }),
228
+ resultsColumn
229
+ ] })
230
+ ] }) }) });
231
+ };
232
+ const App = () => {
233
+ return /* @__PURE__ */ jsxRuntime.jsxs(reactRouterDom.Routes, { children: [
234
+ /* @__PURE__ */ jsxRuntime.jsx(reactRouterDom.Route, { index: true, element: /* @__PURE__ */ jsxRuntime.jsx(HomePage, {}) }),
235
+ /* @__PURE__ */ jsxRuntime.jsx(reactRouterDom.Route, { path: "*", element: /* @__PURE__ */ jsxRuntime.jsx(admin.Page.Error, {}) })
236
+ ] });
237
+ };
238
+ exports.App = App;
@@ -0,0 +1,236 @@
1
+ import { jsx, jsxs } from "react/jsx-runtime";
2
+ import { useFetchClient, Page } from "@strapi/strapi/admin";
3
+ import { Routes, Route } from "react-router-dom";
4
+ import { useState, useRef, useCallback, useMemo } from "react";
5
+ import { Box, Flex, Typography, Main, Field, Textarea, Button } from "@strapi/design-system";
6
+ import styled, { keyframes } from "styled-components";
7
+ const pulse = keyframes`
8
+ 0% { opacity: 1; }
9
+ 50% { opacity: 0.4; }
10
+ 100% { opacity: 1; }
11
+ `;
12
+ const SkeletonBox = styled(Box)`
13
+ animation: ${pulse} 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite;
14
+ background-color: ${({ theme }) => theme.colors.neutral200};
15
+ border-radius: ${({ theme }) => theme.borderRadius};
16
+ height: ${(props) => props.$height || "20px"};
17
+ width: ${(props) => props.$width || "100%"};
18
+ `;
19
+ const CustomGrid = styled.div`
20
+ display: grid;
21
+ grid-template-columns: minmax(0, 1fr);
22
+ align-items: start;
23
+ gap: ${({ theme }) => theme.spaces[8]};
24
+
25
+ @media (min-width: 900px) {
26
+ grid-template-columns: minmax(0, 6fr) minmax(0, 5fr);
27
+ }
28
+ `;
29
+ const CustomGridItem = styled.div`
30
+ min-width: 0;
31
+ width: 100%;
32
+ `;
33
+ const PremiumCard = styled(Box)`
34
+ transition: all 0.2s ease-in-out;
35
+ border: 1px solid ${({ theme }) => theme.colors.neutral200};
36
+
37
+ &:hover {
38
+ box-shadow: ${({ theme }) => theme.shadows.tableShadow};
39
+ border-color: ${({ theme }) => theme.colors.neutral300};
40
+ }
41
+ `;
42
+ const PremiumButton = styled(Button)`
43
+ padding-left: ${({ theme }) => theme.spaces[6]};
44
+ padding-right: ${({ theme }) => theme.spaces[6]};
45
+ padding-top: ${({ theme }) => theme.spaces[3]};
46
+ padding-bottom: ${({ theme }) => theme.spaces[3]};
47
+ font-weight: 600;
48
+ letter-spacing: 0.2px;
49
+ transition: transform 0.1s ease;
50
+
51
+ &:active {
52
+ transform: scale(0.98);
53
+ }
54
+ `;
55
+ const CopyButton = styled.button`
56
+ background: none;
57
+ border: none;
58
+ color: ${({ theme }) => theme.colors.neutral500};
59
+ font-size: ${({ theme }) => theme.fontSizes[1]};
60
+ font-weight: 600;
61
+ cursor: pointer;
62
+ padding: ${({ theme }) => `${theme.spaces[1]} ${theme.spaces[2]}`};
63
+ border-radius: ${({ theme }) => theme.borderRadius};
64
+ transition: all 0.15s ease;
65
+
66
+ &:hover {
67
+ color: ${({ theme }) => theme.colors.neutral800};
68
+ background-color: ${({ theme }) => theme.colors.neutral150};
69
+ }
70
+ `;
71
+ const MetaLabel = styled(Typography)`
72
+ letter-spacing: 1px;
73
+ text-transform: uppercase;
74
+ color: ${({ theme }) => theme.colors.neutral600};
75
+ `;
76
+ const HomePage = () => {
77
+ const [content, setContent] = useState("");
78
+ const [loading, setLoading] = useState(false);
79
+ const [result, setResult] = useState(null);
80
+ const [error, setError] = useState(null);
81
+ const [copiedState, setCopiedState] = useState({});
82
+ const { post } = useFetchClient();
83
+ const handleGenerate = async () => {
84
+ if (!content.trim()) return;
85
+ setLoading(true);
86
+ setError(null);
87
+ setResult(null);
88
+ setCopiedState({});
89
+ try {
90
+ const { data } = await post("/strapi-plugin-seo-gemini/generate", {
91
+ content
92
+ });
93
+ setResult(data.data);
94
+ } catch (err) {
95
+ const errorObj = err;
96
+ setError(errorObj.response?.data?.error?.message || errorObj.message || "An error occurred during generation.");
97
+ } finally {
98
+ setLoading(false);
99
+ }
100
+ };
101
+ const copyTimeouts = useRef({});
102
+ const handleCopy = useCallback((text, key) => {
103
+ navigator.clipboard.writeText(text);
104
+ setCopiedState((prev) => ({ ...prev, [key]: true }));
105
+ if (copyTimeouts.current[key]) {
106
+ clearTimeout(copyTimeouts.current[key]);
107
+ }
108
+ copyTimeouts.current[key] = setTimeout(() => {
109
+ setCopiedState((prev) => ({ ...prev, [key]: false }));
110
+ }, 2e3);
111
+ }, []);
112
+ const resultsColumn = useMemo(() => {
113
+ return /* @__PURE__ */ jsx(CustomGridItem, { children: /* @__PURE__ */ jsxs(Box, { paddingLeft: 4, children: [
114
+ loading && /* @__PURE__ */ jsxs(Flex, { direction: "column", gap: 8, children: [
115
+ /* @__PURE__ */ jsxs(Box, { children: [
116
+ /* @__PURE__ */ jsx(SkeletonBox, { $width: "120px", $height: "16px", marginBottom: 3 }),
117
+ /* @__PURE__ */ jsx(SkeletonBox, { $height: "60px" })
118
+ ] }),
119
+ /* @__PURE__ */ jsxs(Box, { children: [
120
+ /* @__PURE__ */ jsx(SkeletonBox, { $width: "140px", $height: "16px", marginBottom: 3 }),
121
+ /* @__PURE__ */ jsx(SkeletonBox, { $height: "80px" })
122
+ ] }),
123
+ /* @__PURE__ */ jsxs(Box, { children: [
124
+ /* @__PURE__ */ jsx(SkeletonBox, { $width: "100px", $height: "16px", marginBottom: 3 }),
125
+ /* @__PURE__ */ jsx(SkeletonBox, { $height: "40px" })
126
+ ] }),
127
+ /* @__PURE__ */ jsxs(Box, { children: [
128
+ /* @__PURE__ */ jsx(SkeletonBox, { $width: "110px", $height: "16px", marginBottom: 3 }),
129
+ /* @__PURE__ */ jsx(SkeletonBox, { $height: "30px" })
130
+ ] }),
131
+ /* @__PURE__ */ jsxs(Box, { children: [
132
+ /* @__PURE__ */ jsx(SkeletonBox, { $width: "150px", $height: "16px", marginBottom: 3 }),
133
+ /* @__PURE__ */ jsx(SkeletonBox, { $height: "100px" })
134
+ ] })
135
+ ] }),
136
+ !loading && !result && /* @__PURE__ */ jsx(
137
+ Flex,
138
+ {
139
+ justifyContent: "center",
140
+ alignItems: "center",
141
+ style: { height: "300px", border: "1px dashed #eaeaef", borderRadius: "8px" },
142
+ children: /* @__PURE__ */ jsx(Typography, { textColor: "neutral500", children: "Metadata will appear here." })
143
+ }
144
+ ),
145
+ result && !loading && /* @__PURE__ */ jsxs(Flex, { direction: "column", alignItems: "stretch", gap: 6, children: [
146
+ /* @__PURE__ */ jsxs(PremiumCard, { background: "neutral0", padding: 5, hasRadius: true, children: [
147
+ /* @__PURE__ */ jsxs(Flex, { justifyContent: "space-between", alignItems: "center", marginBottom: 3, children: [
148
+ /* @__PURE__ */ jsx(MetaLabel, { variant: "pi", fontWeight: "bold", children: "Meta Title" }),
149
+ /* @__PURE__ */ jsx(CopyButton, { onClick: () => handleCopy(result.title, "title"), children: copiedState["title"] ? "Copied!" : "Copy" })
150
+ ] }),
151
+ /* @__PURE__ */ jsx(Typography, { variant: "epsilon", style: { lineHeight: "1.5" }, children: result.title || "No data generated" })
152
+ ] }),
153
+ /* @__PURE__ */ jsxs(PremiumCard, { background: "neutral0", padding: 5, hasRadius: true, children: [
154
+ /* @__PURE__ */ jsxs(Flex, { justifyContent: "space-between", alignItems: "center", marginBottom: 3, children: [
155
+ /* @__PURE__ */ jsx(MetaLabel, { variant: "pi", fontWeight: "bold", children: "Meta Description" }),
156
+ /* @__PURE__ */ jsx(CopyButton, { onClick: () => handleCopy(result.description, "description"), children: copiedState["description"] ? "Copied!" : "Copy" })
157
+ ] }),
158
+ /* @__PURE__ */ jsx(Typography, { variant: "epsilon", style: { lineHeight: "1.5" }, children: result.description || "No data generated" })
159
+ ] }),
160
+ /* @__PURE__ */ jsxs(PremiumCard, { background: "neutral0", padding: 5, hasRadius: true, children: [
161
+ /* @__PURE__ */ jsxs(Flex, { justifyContent: "space-between", alignItems: "center", marginBottom: 3, children: [
162
+ /* @__PURE__ */ jsx(MetaLabel, { variant: "pi", fontWeight: "bold", children: "Keywords" }),
163
+ /* @__PURE__ */ jsx(
164
+ CopyButton,
165
+ {
166
+ onClick: () => handleCopy(
167
+ Array.isArray(result.keywords) ? result.keywords.join(", ") : result.keywords,
168
+ "keywords"
169
+ ),
170
+ children: copiedState["keywords"] ? "Copied!" : "Copy"
171
+ }
172
+ )
173
+ ] }),
174
+ /* @__PURE__ */ jsx(Typography, { variant: "epsilon", style: { lineHeight: "1.5" }, children: Array.isArray(result.keywords) ? result.keywords.join(", ") : result.keywords || "No data generated" })
175
+ ] }),
176
+ result.metaRobots && /* @__PURE__ */ jsxs(PremiumCard, { background: "neutral0", padding: 5, hasRadius: true, children: [
177
+ /* @__PURE__ */ jsxs(Flex, { justifyContent: "space-between", alignItems: "center", marginBottom: 3, children: [
178
+ /* @__PURE__ */ jsx(MetaLabel, { variant: "pi", fontWeight: "bold", children: "Meta Robots" }),
179
+ /* @__PURE__ */ jsx(CopyButton, { onClick: () => handleCopy(result.metaRobots || "", "metaRobots"), children: copiedState["metaRobots"] ? "Copied!" : "Copy" })
180
+ ] }),
181
+ /* @__PURE__ */ jsx(Typography, { variant: "epsilon", style: { lineHeight: "1.5" }, children: result.metaRobots })
182
+ ] }),
183
+ result.structuredData && /* @__PURE__ */ jsxs(PremiumCard, { background: "neutral0", padding: 5, hasRadius: true, children: [
184
+ /* @__PURE__ */ jsxs(Flex, { justifyContent: "space-between", alignItems: "center", marginBottom: 3, children: [
185
+ /* @__PURE__ */ jsx(MetaLabel, { variant: "pi", fontWeight: "bold", children: "Structured Data (JSON-LD)" }),
186
+ /* @__PURE__ */ jsx(CopyButton, { onClick: () => handleCopy(JSON.stringify(result.structuredData, null, 2), "structuredData"), children: copiedState["structuredData"] ? "Copied!" : "Copy" })
187
+ ] }),
188
+ /* @__PURE__ */ jsx(Box, { background: "neutral100", padding: 3, hasRadius: true, style: { overflowX: "auto", maxHeight: "200px", fontSize: "12px" }, children: /* @__PURE__ */ jsx("pre", { style: { margin: 0, fontFamily: "monospace" }, children: JSON.stringify(result.structuredData, null, 2) }) })
189
+ ] })
190
+ ] })
191
+ ] }) });
192
+ }, [loading, result, copiedState, handleCopy]);
193
+ return /* @__PURE__ */ jsx(Main, { children: /* @__PURE__ */ jsx(Box, { padding: 10, background: "neutral0", minHeight: "100vh", children: /* @__PURE__ */ jsxs(Box, { maxWidth: "1200px", margin: "0 auto", children: [
194
+ /* @__PURE__ */ jsxs(Box, { marginBottom: 10, children: [
195
+ /* @__PURE__ */ jsx(Typography, { variant: "alpha", tag: "h1", fontWeight: "bold", style: { letterSpacing: "-0.5px" }, children: "SEO Metadata Generator" }),
196
+ /* @__PURE__ */ jsx(Box, { marginTop: 3, children: /* @__PURE__ */ jsx(Typography, { variant: "epsilon", textColor: "neutral600", children: "Paste your content here to magically generate optimized metadata." }) })
197
+ ] }),
198
+ /* @__PURE__ */ jsxs(CustomGrid, { children: [
199
+ /* @__PURE__ */ jsx(CustomGridItem, { children: /* @__PURE__ */ jsxs(Flex, { direction: "column", alignItems: "stretch", gap: 6, children: [
200
+ /* @__PURE__ */ jsxs(Field.Root, { error: error || void 0, children: [
201
+ /* @__PURE__ */ jsx(
202
+ Textarea,
203
+ {
204
+ placeholder: "Enter your article or page content...",
205
+ value: content,
206
+ onChange: (e) => setContent(e.target.value),
207
+ rows: 16,
208
+ style: { fontSize: "15px", lineHeight: "1.6", padding: "16px" }
209
+ }
210
+ ),
211
+ /* @__PURE__ */ jsx(Field.Error, {})
212
+ ] }),
213
+ /* @__PURE__ */ jsx(Box, { children: /* @__PURE__ */ jsx(
214
+ PremiumButton,
215
+ {
216
+ onClick: handleGenerate,
217
+ loading,
218
+ disabled: !content.trim(),
219
+ size: "L",
220
+ children: "Generate Metadata"
221
+ }
222
+ ) })
223
+ ] }) }),
224
+ resultsColumn
225
+ ] })
226
+ ] }) }) });
227
+ };
228
+ const App = () => {
229
+ return /* @__PURE__ */ jsxs(Routes, { children: [
230
+ /* @__PURE__ */ jsx(Route, { index: true, element: /* @__PURE__ */ jsx(HomePage, {}) }),
231
+ /* @__PURE__ */ jsx(Route, { path: "*", element: /* @__PURE__ */ jsx(Page.Error, {}) })
232
+ ] });
233
+ };
234
+ export {
235
+ App
236
+ };
@@ -0,0 +1,4 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
3
+ const en = {};
4
+ exports.default = en;
@@ -0,0 +1,4 @@
1
+ const en = {};
2
+ export {
3
+ en as default
4
+ };
@@ -0,0 +1,65 @@
1
+ "use strict";
2
+ Object.defineProperties(exports, { __esModule: { value: true }, [Symbol.toStringTag]: { value: "Module" } });
3
+ const react = require("react");
4
+ const jsxRuntime = require("react/jsx-runtime");
5
+ const icons = require("@strapi/icons");
6
+ const __variableDynamicImportRuntimeHelper = (glob, path, segs) => {
7
+ const v = glob[path];
8
+ if (v) {
9
+ return typeof v === "function" ? v() : Promise.resolve(v);
10
+ }
11
+ return new Promise((_, reject) => {
12
+ (typeof queueMicrotask === "function" ? queueMicrotask : setTimeout)(
13
+ reject.bind(
14
+ null,
15
+ new Error(
16
+ "Unknown variable dynamic import: " + path + (path.split("/").length !== segs ? ". Note that variables only represent file names one level deep." : "")
17
+ )
18
+ )
19
+ );
20
+ });
21
+ };
22
+ const PLUGIN_ID = "strapi-plugin-seo-gemini";
23
+ const Initializer = ({ setPlugin }) => {
24
+ const ref = react.useRef(setPlugin);
25
+ react.useEffect(() => {
26
+ ref.current(PLUGIN_ID);
27
+ }, []);
28
+ return null;
29
+ };
30
+ const PluginIcon = () => /* @__PURE__ */ jsxRuntime.jsx(icons.PuzzlePiece, {});
31
+ const index = {
32
+ register(app) {
33
+ app.addMenuLink({
34
+ to: `plugins/${PLUGIN_ID}`,
35
+ icon: PluginIcon,
36
+ intlLabel: {
37
+ id: `${PLUGIN_ID}.plugin.name`,
38
+ defaultMessage: PLUGIN_ID
39
+ },
40
+ Component: async () => {
41
+ const { App } = await Promise.resolve().then(() => require("./App-DgJGMG7y.js"));
42
+ return App;
43
+ }
44
+ });
45
+ app.registerPlugin({
46
+ id: PLUGIN_ID,
47
+ initializer: Initializer,
48
+ isReady: false,
49
+ name: PLUGIN_ID
50
+ });
51
+ },
52
+ async registerTrads({ locales }) {
53
+ return Promise.all(
54
+ locales.map(async (locale) => {
55
+ try {
56
+ const { default: data } = await __variableDynamicImportRuntimeHelper(/* @__PURE__ */ Object.assign({ "./translations/en.json": () => Promise.resolve().then(() => require("./en-B4KWt_jN.js")) }), `./translations/${locale}.json`, 3);
57
+ return { data, locale };
58
+ } catch {
59
+ return { data: {}, locale };
60
+ }
61
+ })
62
+ );
63
+ }
64
+ };
65
+ exports.default = index;
@@ -0,0 +1,65 @@
1
+ import { useRef, useEffect } from "react";
2
+ import { jsx } from "react/jsx-runtime";
3
+ import { PuzzlePiece } from "@strapi/icons";
4
+ const __variableDynamicImportRuntimeHelper = (glob, path, segs) => {
5
+ const v = glob[path];
6
+ if (v) {
7
+ return typeof v === "function" ? v() : Promise.resolve(v);
8
+ }
9
+ return new Promise((_, reject) => {
10
+ (typeof queueMicrotask === "function" ? queueMicrotask : setTimeout)(
11
+ reject.bind(
12
+ null,
13
+ new Error(
14
+ "Unknown variable dynamic import: " + path + (path.split("/").length !== segs ? ". Note that variables only represent file names one level deep." : "")
15
+ )
16
+ )
17
+ );
18
+ });
19
+ };
20
+ const PLUGIN_ID = "strapi-plugin-seo-gemini";
21
+ const Initializer = ({ setPlugin }) => {
22
+ const ref = useRef(setPlugin);
23
+ useEffect(() => {
24
+ ref.current(PLUGIN_ID);
25
+ }, []);
26
+ return null;
27
+ };
28
+ const PluginIcon = () => /* @__PURE__ */ jsx(PuzzlePiece, {});
29
+ const index = {
30
+ register(app) {
31
+ app.addMenuLink({
32
+ to: `plugins/${PLUGIN_ID}`,
33
+ icon: PluginIcon,
34
+ intlLabel: {
35
+ id: `${PLUGIN_ID}.plugin.name`,
36
+ defaultMessage: PLUGIN_ID
37
+ },
38
+ Component: async () => {
39
+ const { App } = await import("./App-DjrcGM8K.mjs");
40
+ return App;
41
+ }
42
+ });
43
+ app.registerPlugin({
44
+ id: PLUGIN_ID,
45
+ initializer: Initializer,
46
+ isReady: false,
47
+ name: PLUGIN_ID
48
+ });
49
+ },
50
+ async registerTrads({ locales }) {
51
+ return Promise.all(
52
+ locales.map(async (locale) => {
53
+ try {
54
+ const { default: data } = await __variableDynamicImportRuntimeHelper(/* @__PURE__ */ Object.assign({ "./translations/en.json": () => import("./en-Byx4XI2L.mjs") }), `./translations/${locale}.json`, 3);
55
+ return { data, locale };
56
+ } catch {
57
+ return { data: {}, locale };
58
+ }
59
+ })
60
+ );
61
+ }
62
+ };
63
+ export {
64
+ index as default
65
+ };
@@ -0,0 +1,13 @@
1
+ declare const _default: {
2
+ register(app: {
3
+ addMenuLink: (config: Record<string, unknown>) => void;
4
+ registerPlugin: (config: Record<string, unknown>) => void;
5
+ }): void;
6
+ registerTrads({ locales }: {
7
+ locales: string[];
8
+ }): Promise<{
9
+ data: any;
10
+ locale: string;
11
+ }[]>;
12
+ };
13
+ export default _default;
@@ -0,0 +1,168 @@
1
+ "use strict";
2
+ Object.defineProperties(exports, { __esModule: { value: true }, [Symbol.toStringTag]: { value: "Module" } });
3
+ const generativeAi = require("@google/generative-ai");
4
+ const bootstrap = ({ strapi }) => {
5
+ };
6
+ const destroy = ({ strapi }) => {
7
+ };
8
+ const register = ({ strapi }) => {
9
+ };
10
+ const config = {
11
+ default: {
12
+ apiKey: ""
13
+ },
14
+ validator(config2) {
15
+ if (typeof config2.apiKey !== "string" && config2.apiKey !== void 0) {
16
+ throw new Error("SEO Gemini: apiKey must be a string");
17
+ }
18
+ }
19
+ };
20
+ const contentTypes = {};
21
+ const controller = ({ strapi }) => ({
22
+ /**
23
+ * Generates AI-powered SEO metadata based on provided content.
24
+ *
25
+ * This controller endpoint accepts a POST request with the text content,
26
+ * validates its presence, and delegates the generation process to the
27
+ * underlying Gemini AI service.
28
+ *
29
+ * @param {object} ctx - The Koa context object containing the request and response.
30
+ * @param {object} ctx.request.body - The request body.
31
+ * @param {string} ctx.request.body.content - The text content to analyze for SEO.
32
+ * @returns {Promise<void>} Resolves when the response is sent back to the client.
33
+ * @throws {400} If the 'content' field is missing from the request body.
34
+ * @throws {500} If an internal error occurs during generation.
35
+ */
36
+ async generate(ctx) {
37
+ const { content } = ctx.request.body;
38
+ if (!content) {
39
+ return ctx.badRequest("Content is required");
40
+ }
41
+ try {
42
+ const result = await strapi.plugin("strapi-plugin-seo-gemini").service("service").generateSeo(content);
43
+ ctx.body = { data: result };
44
+ } catch (error) {
45
+ ctx.internalServerError(error.message);
46
+ }
47
+ }
48
+ });
49
+ const controllers = {
50
+ controller
51
+ };
52
+ const middlewares = {};
53
+ const policies = {};
54
+ const admin = {
55
+ type: "admin",
56
+ routes: [
57
+ {
58
+ method: "POST",
59
+ path: "/generate",
60
+ handler: "controller.generate",
61
+ config: {
62
+ policies: []
63
+ }
64
+ }
65
+ ]
66
+ };
67
+ const routes = {
68
+ admin
69
+ };
70
+ const service = ({ strapi }) => ({
71
+ /**
72
+ * Analyzes text content and generates optimized SEO metadata using Google Gemini AI.
73
+ *
74
+ * This service enforces strict JSON response formatting to ensure integration stability.
75
+ * It implements a fallback mechanism, sequentially attempting to use 'gemini-2.5-flash',
76
+ * 'gemini-2.0-flash', and 'gemini-1.5-flash' to mitigate potential 503 or 429 API errors.
77
+ * The API key must be securely configured via Strapi config (`config/plugins.ts`) or
78
+ * via the `GEMINI_API_KEY` environment variable.
79
+ *
80
+ * @param {string} content - The raw text content (e.g., an article body) to be analyzed.
81
+ * @returns {Promise<Record<string, unknown>>} A Promise that resolves to a parsed JSON object containing:
82
+ * - {string} title - Optimized SEO Title (max 60 chars).
83
+ * - {string} description - Optimized Meta Description (max 160 chars).
84
+ * - {string|string[]} keywords - Relevant SEO keywords.
85
+ * - {string} metaRobots - Indexing instructions (e.g., "index, follow").
86
+ * - {Record<string, unknown>} structuredData - A JSON-LD object for schema.org markup.
87
+ * @throws {Error} If `content` is empty.
88
+ * @throws {Error} If all configured Gemini models fail to generate a response.
89
+ */
90
+ async generateSeo(content) {
91
+ if (!content) {
92
+ throw new Error("Content is required to generate SEO");
93
+ }
94
+ const config2 = strapi.config.get("plugin::strapi-plugin-seo-gemini");
95
+ const apiKey = config2?.apiKey || process.env.GEMINI_API_KEY;
96
+ if (!apiKey) {
97
+ strapi.log.warn("SEO Gemini: GEMINI_API_KEY or plugin config is missing");
98
+ return {
99
+ title: "SEO Gemini | Key Missing",
100
+ description: "Please configure the API Key in config/plugins.ts or .env to enable AI generation."
101
+ };
102
+ }
103
+ const genAI = new generativeAi.GoogleGenerativeAI(apiKey);
104
+ const modelsToTry = ["gemini-2.5-flash", "gemini-2.0-flash", "gemini-1.5-flash"];
105
+ let lastError = null;
106
+ for (const modelName of modelsToTry) {
107
+ try {
108
+ strapi.log.info(`SEO Gemini: Generating with ${modelName}...`);
109
+ const model = genAI.getGenerativeModel({
110
+ model: modelName,
111
+ generationConfig: {
112
+ responseMimeType: "application/json"
113
+ }
114
+ });
115
+ const prompt = `
116
+ You are an expert SEO copywriter.
117
+ Analyze the following content and generate concise, highly optimized SEO metadata.
118
+
119
+ Requirements:
120
+ 1. "title": Compelling title (max 60 chars).
121
+ 2. "description": Compelling summary (max 160 chars).
122
+ 3. "keywords": Relevant keywords, comma-separated.
123
+ 4. "metaRobots": "index, follow".
124
+ 5. "structuredData": Valid JSON-LD Article/WebPage schema. Return as object.
125
+
126
+ Return ONLY valid JSON in this exact format:
127
+ {
128
+ "title": "Optimized Title",
129
+ "description": "Optimized Description",
130
+ "keywords": "k1, k2, k3",
131
+ "metaRobots": "index, follow",
132
+ "structuredData": {}
133
+ }
134
+
135
+ Content:
136
+ ${content}
137
+ `;
138
+ const result = await model.generateContent(prompt);
139
+ const parsed = JSON.parse(result.response.text());
140
+ strapi.log.info(`SEO Gemini: Success with ${modelName}`);
141
+ return parsed;
142
+ } catch (err) {
143
+ lastError = err;
144
+ const errorMessage = err instanceof Error ? err.message : String(err);
145
+ strapi.log.warn(`SEO Gemini: ${modelName} failed: ${errorMessage}`);
146
+ continue;
147
+ }
148
+ }
149
+ const finalErrorMessage = lastError instanceof Error ? lastError.message : String(lastError);
150
+ throw new Error(`All Gemini models failed. Last error: ${finalErrorMessage}`);
151
+ }
152
+ });
153
+ const services = {
154
+ service
155
+ };
156
+ const index = {
157
+ register,
158
+ bootstrap,
159
+ destroy,
160
+ config,
161
+ controllers,
162
+ routes,
163
+ services,
164
+ contentTypes,
165
+ policies,
166
+ middlewares
167
+ };
168
+ exports.default = index;
@@ -0,0 +1,168 @@
1
+ import { GoogleGenerativeAI } from "@google/generative-ai";
2
+ const bootstrap = ({ strapi }) => {
3
+ };
4
+ const destroy = ({ strapi }) => {
5
+ };
6
+ const register = ({ strapi }) => {
7
+ };
8
+ const config = {
9
+ default: {
10
+ apiKey: ""
11
+ },
12
+ validator(config2) {
13
+ if (typeof config2.apiKey !== "string" && config2.apiKey !== void 0) {
14
+ throw new Error("SEO Gemini: apiKey must be a string");
15
+ }
16
+ }
17
+ };
18
+ const contentTypes = {};
19
+ const controller = ({ strapi }) => ({
20
+ /**
21
+ * Generates AI-powered SEO metadata based on provided content.
22
+ *
23
+ * This controller endpoint accepts a POST request with the text content,
24
+ * validates its presence, and delegates the generation process to the
25
+ * underlying Gemini AI service.
26
+ *
27
+ * @param {object} ctx - The Koa context object containing the request and response.
28
+ * @param {object} ctx.request.body - The request body.
29
+ * @param {string} ctx.request.body.content - The text content to analyze for SEO.
30
+ * @returns {Promise<void>} Resolves when the response is sent back to the client.
31
+ * @throws {400} If the 'content' field is missing from the request body.
32
+ * @throws {500} If an internal error occurs during generation.
33
+ */
34
+ async generate(ctx) {
35
+ const { content } = ctx.request.body;
36
+ if (!content) {
37
+ return ctx.badRequest("Content is required");
38
+ }
39
+ try {
40
+ const result = await strapi.plugin("strapi-plugin-seo-gemini").service("service").generateSeo(content);
41
+ ctx.body = { data: result };
42
+ } catch (error) {
43
+ ctx.internalServerError(error.message);
44
+ }
45
+ }
46
+ });
47
+ const controllers = {
48
+ controller
49
+ };
50
+ const middlewares = {};
51
+ const policies = {};
52
+ const admin = {
53
+ type: "admin",
54
+ routes: [
55
+ {
56
+ method: "POST",
57
+ path: "/generate",
58
+ handler: "controller.generate",
59
+ config: {
60
+ policies: []
61
+ }
62
+ }
63
+ ]
64
+ };
65
+ const routes = {
66
+ admin
67
+ };
68
+ const service = ({ strapi }) => ({
69
+ /**
70
+ * Analyzes text content and generates optimized SEO metadata using Google Gemini AI.
71
+ *
72
+ * This service enforces strict JSON response formatting to ensure integration stability.
73
+ * It implements a fallback mechanism, sequentially attempting to use 'gemini-2.5-flash',
74
+ * 'gemini-2.0-flash', and 'gemini-1.5-flash' to mitigate potential 503 or 429 API errors.
75
+ * The API key must be securely configured via Strapi config (`config/plugins.ts`) or
76
+ * via the `GEMINI_API_KEY` environment variable.
77
+ *
78
+ * @param {string} content - The raw text content (e.g., an article body) to be analyzed.
79
+ * @returns {Promise<Record<string, unknown>>} A Promise that resolves to a parsed JSON object containing:
80
+ * - {string} title - Optimized SEO Title (max 60 chars).
81
+ * - {string} description - Optimized Meta Description (max 160 chars).
82
+ * - {string|string[]} keywords - Relevant SEO keywords.
83
+ * - {string} metaRobots - Indexing instructions (e.g., "index, follow").
84
+ * - {Record<string, unknown>} structuredData - A JSON-LD object for schema.org markup.
85
+ * @throws {Error} If `content` is empty.
86
+ * @throws {Error} If all configured Gemini models fail to generate a response.
87
+ */
88
+ async generateSeo(content) {
89
+ if (!content) {
90
+ throw new Error("Content is required to generate SEO");
91
+ }
92
+ const config2 = strapi.config.get("plugin::strapi-plugin-seo-gemini");
93
+ const apiKey = config2?.apiKey || process.env.GEMINI_API_KEY;
94
+ if (!apiKey) {
95
+ strapi.log.warn("SEO Gemini: GEMINI_API_KEY or plugin config is missing");
96
+ return {
97
+ title: "SEO Gemini | Key Missing",
98
+ description: "Please configure the API Key in config/plugins.ts or .env to enable AI generation."
99
+ };
100
+ }
101
+ const genAI = new GoogleGenerativeAI(apiKey);
102
+ const modelsToTry = ["gemini-2.5-flash", "gemini-2.0-flash", "gemini-1.5-flash"];
103
+ let lastError = null;
104
+ for (const modelName of modelsToTry) {
105
+ try {
106
+ strapi.log.info(`SEO Gemini: Generating with ${modelName}...`);
107
+ const model = genAI.getGenerativeModel({
108
+ model: modelName,
109
+ generationConfig: {
110
+ responseMimeType: "application/json"
111
+ }
112
+ });
113
+ const prompt = `
114
+ You are an expert SEO copywriter.
115
+ Analyze the following content and generate concise, highly optimized SEO metadata.
116
+
117
+ Requirements:
118
+ 1. "title": Compelling title (max 60 chars).
119
+ 2. "description": Compelling summary (max 160 chars).
120
+ 3. "keywords": Relevant keywords, comma-separated.
121
+ 4. "metaRobots": "index, follow".
122
+ 5. "structuredData": Valid JSON-LD Article/WebPage schema. Return as object.
123
+
124
+ Return ONLY valid JSON in this exact format:
125
+ {
126
+ "title": "Optimized Title",
127
+ "description": "Optimized Description",
128
+ "keywords": "k1, k2, k3",
129
+ "metaRobots": "index, follow",
130
+ "structuredData": {}
131
+ }
132
+
133
+ Content:
134
+ ${content}
135
+ `;
136
+ const result = await model.generateContent(prompt);
137
+ const parsed = JSON.parse(result.response.text());
138
+ strapi.log.info(`SEO Gemini: Success with ${modelName}`);
139
+ return parsed;
140
+ } catch (err) {
141
+ lastError = err;
142
+ const errorMessage = err instanceof Error ? err.message : String(err);
143
+ strapi.log.warn(`SEO Gemini: ${modelName} failed: ${errorMessage}`);
144
+ continue;
145
+ }
146
+ }
147
+ const finalErrorMessage = lastError instanceof Error ? lastError.message : String(lastError);
148
+ throw new Error(`All Gemini models failed. Last error: ${finalErrorMessage}`);
149
+ }
150
+ });
151
+ const services = {
152
+ service
153
+ };
154
+ const index = {
155
+ register,
156
+ bootstrap,
157
+ destroy,
158
+ config,
159
+ controllers,
160
+ routes,
161
+ services,
162
+ contentTypes,
163
+ policies,
164
+ middlewares
165
+ };
166
+ export {
167
+ index as default
168
+ };
@@ -0,0 +1,48 @@
1
+ declare const _default: {
2
+ register: ({ strapi }: {
3
+ strapi: import('@strapi/types/dist/core').Strapi;
4
+ }) => void;
5
+ bootstrap: ({ strapi }: {
6
+ strapi: import('@strapi/types/dist/core').Strapi;
7
+ }) => void;
8
+ destroy: ({ strapi }: {
9
+ strapi: import('@strapi/types/dist/core').Strapi;
10
+ }) => void;
11
+ config: {
12
+ default: {
13
+ apiKey: string;
14
+ };
15
+ validator(config: Record<string, unknown>): void;
16
+ };
17
+ controllers: {
18
+ controller: ({ strapi }: {
19
+ strapi: import('@strapi/types/dist/core').Strapi;
20
+ }) => {
21
+ generate(ctx: any): Promise<any>;
22
+ };
23
+ };
24
+ routes: {
25
+ admin: {
26
+ type: string;
27
+ routes: {
28
+ method: string;
29
+ path: string;
30
+ handler: string;
31
+ config: {
32
+ policies: any[];
33
+ };
34
+ }[];
35
+ };
36
+ };
37
+ services: {
38
+ service: ({ strapi }: {
39
+ strapi: import('@strapi/types/dist/core').Strapi;
40
+ }) => {
41
+ generateSeo(content: string): Promise<any>;
42
+ };
43
+ };
44
+ contentTypes: {};
45
+ policies: {};
46
+ middlewares: {};
47
+ };
48
+ export default _default;
package/package.json ADDED
@@ -0,0 +1,81 @@
1
+ {
2
+ "version": "1.0.0",
3
+ "keywords": ["strapi", "plugin", "seo", "gemini", "ai", "metadata"],
4
+ "type": "commonjs",
5
+ "exports": {
6
+ "./package.json": "./package.json",
7
+ "./strapi-admin": {
8
+ "types": "./dist/admin/src/index.d.ts",
9
+ "source": "./admin/src/index.ts",
10
+ "import": "./dist/admin/index.mjs",
11
+ "require": "./dist/admin/index.js",
12
+ "default": "./dist/admin/index.js"
13
+ },
14
+ "./strapi-server": {
15
+ "types": "./dist/server/src/index.d.ts",
16
+ "source": "./server/src/index.ts",
17
+ "import": "./dist/server/index.mjs",
18
+ "require": "./dist/server/index.js",
19
+ "default": "./dist/server/index.js"
20
+ }
21
+ },
22
+ "files": [
23
+ "dist"
24
+ ],
25
+ "scripts": {
26
+ "build": "strapi-plugin build",
27
+ "watch": "strapi-plugin watch",
28
+ "watch:link": "strapi-plugin watch:link",
29
+ "verify": "strapi-plugin verify",
30
+ "test:ts:front": "run -T tsc -p admin/tsconfig.json",
31
+ "test:ts:back": "run -T tsc -p server/tsconfig.json"
32
+ },
33
+ "dependencies": {
34
+ "@google/generative-ai": "^0.24.1"
35
+ },
36
+ "devDependencies": {
37
+ "@strapi/design-system": "^2.0.0-rc.30",
38
+ "@strapi/icons": "^2.0.0-rc.30",
39
+ "@strapi/sdk-plugin": "^6.0.1",
40
+ "@strapi/strapi": "^5.38.0",
41
+ "@strapi/typescript-utils": "^5.38.0",
42
+ "@types/react": "^19.2.14",
43
+ "@types/react-dom": "^19.2.3",
44
+ "prettier": "^3.8.1",
45
+ "react": "^18.3.1",
46
+ "react-dom": "^18.3.1",
47
+ "react-intl": "^6.8.9",
48
+ "react-router-dom": "^6.30.3",
49
+ "styled-components": "^6.3.11",
50
+ "typescript": "^5.9.3"
51
+ },
52
+ "peerDependencies": {
53
+ "@strapi/design-system": "^2.0.0-rc.30",
54
+ "@strapi/icons": "^2.0.0-rc.30",
55
+ "@strapi/sdk-plugin": "^6.0.1",
56
+ "@strapi/strapi": "^5.38.0",
57
+ "react": "^18.3.1",
58
+ "react-dom": "^18.3.1",
59
+ "react-intl": "^6.8.9",
60
+ "react-router-dom": "^6.30.3",
61
+ "styled-components": "^6.3.11"
62
+ },
63
+ "strapi": {
64
+ "kind": "plugin",
65
+ "name": "strapi-plugin-seo-gemini",
66
+ "displayName": "SEO Gemini",
67
+ "description": "Automate SEO metadata generation with Google Gemini 2.0 Flash"
68
+ },
69
+ "name": "strapi-plugin-seo-gemini",
70
+ "description": "Automate SEO metadata generation with Google Gemini 2.0 Flash",
71
+ "license": "MIT",
72
+ "repository": {
73
+ "type": "git",
74
+ "url": "git+https://github.com/con4ig/strapi-plugin-seo-gemini.git"
75
+ },
76
+ "bugs": {
77
+ "url": "https://github.com/con4ig/strapi-plugin-seo-gemini/issues"
78
+ },
79
+ "homepage": "https://github.com/con4ig/strapi-plugin-seo-gemini#readme",
80
+ "author": "con4ig <s.szymon11@interia.pl>"
81
+ }