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 +69 -0
- package/dist/admin/App-DgJGMG7y.js +238 -0
- package/dist/admin/App-DjrcGM8K.mjs +236 -0
- package/dist/admin/en-B4KWt_jN.js +4 -0
- package/dist/admin/en-Byx4XI2L.mjs +4 -0
- package/dist/admin/index.js +65 -0
- package/dist/admin/index.mjs +65 -0
- package/dist/admin/src/index.d.ts +13 -0
- package/dist/server/index.js +168 -0
- package/dist/server/index.mjs +168 -0
- package/dist/server/src/index.d.ts +48 -0
- package/package.json +81 -0
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,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
|
+
}
|