@webnumseoagent/next 0.1.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 +93 -0
- package/bin/cli.mjs +222 -0
- package/bin/wrap.mjs +87 -0
- package/client.ts +121 -0
- package/dist/client.d.ts +9 -0
- package/dist/client.js +100 -0
- package/dist/jsonld.d.ts +4 -0
- package/dist/jsonld.js +21 -0
- package/jsonld.tsx +25 -0
- package/package.json +58 -0
package/README.md
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# @webnumseoagent/next
|
|
2
|
+
|
|
3
|
+
Рантайм-адаптер SEO для **любого** сайта на Next.js (App Router). Тянет SEO-конфиг
|
|
4
|
+
(метаданные, hreflang, sitemap, robots, JSON-LD) из платформы SEO Agent по
|
|
5
|
+
`site_id + токен`. Правки в платформе применяются на сайте без правок кода (через ISR).
|
|
6
|
+
|
|
7
|
+
## Быстрая установка (одна команда)
|
|
8
|
+
|
|
9
|
+
В корне репозитория сайта (рабочее дерево git должно быть чистым):
|
|
10
|
+
```
|
|
11
|
+
node bin/cli.mjs init --wrap --site <SITE_ID> --token <TOKEN> --api https://seonum.uz
|
|
12
|
+
# (после публикации в npm: npx @webnumseoagent/next init --wrap …)
|
|
13
|
+
```
|
|
14
|
+
С флагом **`--wrap`** установщик делает всё автоматически:
|
|
15
|
+
- обнаруживает `app/` и локали (next-intl), копирует адаптер в `seoagent/`;
|
|
16
|
+
- создаёт `app/sitemap.ts`, `app/robots.ts`, роут ревалидации `app/api/seoagent-revalidate/`;
|
|
17
|
+
- прописывает `.env.local`;
|
|
18
|
+
- **сам оборачивает `generateMetadata` во всех страницах** в `seoMeta({ route, locale, fallback })`
|
|
19
|
+
(маршрут вычисляется из пути файла, включая `[slug]`; гард-возвраты не трогаются).
|
|
20
|
+
|
|
21
|
+
Останется: смонтировать одну строку `<SeoJsonLd/>` в layout, задать те же env-переменные в
|
|
22
|
+
Vercel и `git push`. **Проверь `git diff`** перед коммитом (для этого и нужно чистое дерево).
|
|
23
|
+
|
|
24
|
+
Без `--wrap` установщик не меняет код, а печатает готовые строки для ручной вставки.
|
|
25
|
+
|
|
26
|
+
После этого **всё SEO редактируется в платформе без кода** — правки применяются через ISR.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## Ручная установка (если нужно вручную)
|
|
31
|
+
|
|
32
|
+
1. Скопируй папку `seoagent-next/` в проект сайта (напр. в `src/seoagent/`), либо
|
|
33
|
+
поставь как пакет.
|
|
34
|
+
|
|
35
|
+
2. Добавь env-переменные (Vercel → Settings → Environment Variables):
|
|
36
|
+
```
|
|
37
|
+
SEOAGENT_API_BASE=https://seonum.uz
|
|
38
|
+
SEOAGENT_SITE_ID=<id сайта из платформы>
|
|
39
|
+
SEOAGENT_TOKEN=<config_token из настроек сайта>
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
3. В `app/[locale]/layout.tsx` наложи удалённый конфиг поверх своих дефолтов и
|
|
43
|
+
смонтируй JSON-LD:
|
|
44
|
+
```tsx
|
|
45
|
+
import { seoMeta } from "@/seoagent/client";
|
|
46
|
+
import { SeoJsonLd } from "@/seoagent/jsonld";
|
|
47
|
+
|
|
48
|
+
export async function generateMetadata({ params }): Promise<Metadata> {
|
|
49
|
+
const { locale } = await params;
|
|
50
|
+
const compiled = { /* твои текущие метаданные — как fallback */ };
|
|
51
|
+
return seoMeta({ route: "/", locale, fallback: compiled });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// в JSX layout, внутри <body>:
|
|
55
|
+
// <SeoJsonLd route="/" locale={locale} />
|
|
56
|
+
```
|
|
57
|
+
Для страниц с сегментами (`/[slug]`) передавай реальный `route` (напр. `/projects/${slug}`).
|
|
58
|
+
|
|
59
|
+
4. Создай карту сайта и robots (если их ещё нет):
|
|
60
|
+
```ts
|
|
61
|
+
// app/sitemap.ts
|
|
62
|
+
import { seoSitemap } from "@/seoagent/client";
|
|
63
|
+
export default async function sitemap() { return seoSitemap(); }
|
|
64
|
+
```
|
|
65
|
+
```ts
|
|
66
|
+
// app/robots.ts
|
|
67
|
+
import { seoRobots } from "@/seoagent/client";
|
|
68
|
+
export default async function robots() { return seoRobots(); }
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
5. Чтобы правки применялись «за секунды» (а не по TTL), маршруты должны быть на **ISR**.
|
|
72
|
+
Для полностью статичных страниц добавь ревалидацию, напр.:
|
|
73
|
+
```ts
|
|
74
|
+
export const revalidate = 300; // сек
|
|
75
|
+
```
|
|
76
|
+
*(Опционально)* добавь роут ревалидации, который платформа дёрнет после сохранения:
|
|
77
|
+
```ts
|
|
78
|
+
// app/api/seo-revalidate/route.ts
|
|
79
|
+
import { revalidateTag } from "next/cache";
|
|
80
|
+
export async function POST(req: Request) {
|
|
81
|
+
if (req.headers.get("x-revalidate-secret") !== process.env.SEOAGENT_REVALIDATE_SECRET) {
|
|
82
|
+
return new Response("no", { status: 401 });
|
|
83
|
+
}
|
|
84
|
+
revalidateTag(`seo-${process.env.SEOAGENT_SITE_ID}`);
|
|
85
|
+
return Response.json({ ok: true });
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Гарантии
|
|
90
|
+
- **Fail-safe:** при недоступности платформы адаптер возвращает твои дефолты и
|
|
91
|
+
разрешающий robots — сайт не падает и не уходит в `noindex`.
|
|
92
|
+
- **Защита от деиндекса:** `index:false` применяется только если он явно задан в конфиге.
|
|
93
|
+
- **Generic:** ничего не завязано на конкретный сайт — только env + маршрут/локаль.
|
package/bin/cli.mjs
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @webnumseoagent/next init — установщик SEO для Next.js App Router.
|
|
3
|
+
// v0 (без AST): создаёт sitemap.ts / robots.ts / revalidate-роут, копирует адаптер,
|
|
4
|
+
// прописывает .env.local и ПЕЧАТАЕТ сниппет для generateMetadata (единственная ручная правка).
|
|
5
|
+
// Идемпотентно: существующие файлы не перезаписываются.
|
|
6
|
+
|
|
7
|
+
import { promises as fs } from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { execSync } from "node:child_process";
|
|
11
|
+
import { wrapFile } from "./wrap.mjs";
|
|
12
|
+
|
|
13
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const pkgRoot = path.resolve(__dirname, "..");
|
|
15
|
+
const root = process.cwd();
|
|
16
|
+
|
|
17
|
+
const args = process.argv.slice(2);
|
|
18
|
+
const cmd = args[0];
|
|
19
|
+
const opt = (name) => {
|
|
20
|
+
const i = args.indexOf(`--${name}`);
|
|
21
|
+
return i >= 0 ? args[i + 1] : undefined;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const log = (m) => console.log(m);
|
|
25
|
+
const ok = (m) => console.log(` \x1b[32m✓\x1b[0m ${m}`);
|
|
26
|
+
const skip = (m) => console.log(` \x1b[90m•\x1b[0m ${m} (уже есть — пропуск)`);
|
|
27
|
+
|
|
28
|
+
async function exists(p) {
|
|
29
|
+
try { await fs.access(p); return true; } catch { return false; }
|
|
30
|
+
}
|
|
31
|
+
async function ensureFile(p, content) {
|
|
32
|
+
if (await exists(p)) { skip(path.relative(root, p)); return false; }
|
|
33
|
+
await fs.mkdir(path.dirname(p), { recursive: true });
|
|
34
|
+
await fs.writeFile(p, content);
|
|
35
|
+
ok(path.relative(root, p));
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function findAppDir() {
|
|
40
|
+
for (const c of ["app", "src/app"]) {
|
|
41
|
+
if (await exists(path.join(root, c))) return c;
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Относительный import из файла в appDir к вендор-папке seoagent/ в корне.
|
|
47
|
+
function importPath(appDir, file) {
|
|
48
|
+
const from = path.join(root, appDir, path.dirname(file));
|
|
49
|
+
const to = path.join(root, "seoagent");
|
|
50
|
+
let rel = path.relative(from, to).split(path.sep).join("/");
|
|
51
|
+
if (!rel.startsWith(".")) rel = "./" + rel;
|
|
52
|
+
return rel;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function detectLocales() {
|
|
56
|
+
// Пытаемся прочитать next-intl routing (locales: [...]).
|
|
57
|
+
for (const c of ["lib/i18n/routing.ts", "src/lib/i18n/routing.ts", "i18n/routing.ts", "src/i18n/routing.ts"]) {
|
|
58
|
+
const p = path.join(root, c);
|
|
59
|
+
if (await exists(p)) {
|
|
60
|
+
const txt = await fs.readFile(p, "utf8");
|
|
61
|
+
const m = txt.match(/locales\s*:\s*\[([^\]]+)\]/);
|
|
62
|
+
if (m) return m[1].split(",").map((s) => s.trim().replace(/['"]/g, "")).filter(Boolean);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function init() {
|
|
69
|
+
log("\n\x1b[1m@webnumseoagent/next — установка\x1b[0m\n");
|
|
70
|
+
// Проверяем чистоту git ДО создания файлов (чтобы весь дифф установки был обозрим).
|
|
71
|
+
if (args.includes("--wrap") && !isGitClean() && !args.includes("--force")) {
|
|
72
|
+
console.error(" ✗ Рабочее дерево git не чистое. Закоммить/откатись (или добавь --force), чтобы дифф установки был обозрим.");
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
const appDir = await findAppDir();
|
|
76
|
+
if (!appDir) {
|
|
77
|
+
console.error(" ✗ Не найдена папка app/ или src/app/. Запусти из корня Next.js-проекта.");
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
ok(`Найден app-каталог: ${appDir}/`);
|
|
81
|
+
|
|
82
|
+
const locales = await detectLocales();
|
|
83
|
+
if (locales.length) ok(`Обнаружены локали (next-intl): ${locales.join(", ")}`);
|
|
84
|
+
|
|
85
|
+
// Режим: пакет установлен через npm → импортируем "@webnumseoagent/next";
|
|
86
|
+
// иначе — вендорим адаптер в seoagent/ и импортируем относительным путём.
|
|
87
|
+
const pkgMode = await exists(path.join(root, "node_modules/@webnumseoagent/next/package.json"));
|
|
88
|
+
let importClient, importJsonld;
|
|
89
|
+
if (pkgMode) {
|
|
90
|
+
importClient = "@webnumseoagent/next";
|
|
91
|
+
importJsonld = "@webnumseoagent/next/jsonld";
|
|
92
|
+
ok("Пакет @webnumseoagent/next установлен — импорт из пакета.");
|
|
93
|
+
} else {
|
|
94
|
+
const vendor = path.join(root, "seoagent");
|
|
95
|
+
for (const f of ["client.ts", "jsonld.tsx"]) {
|
|
96
|
+
const dest = path.join(vendor, f);
|
|
97
|
+
if (!(await exists(dest))) {
|
|
98
|
+
await fs.mkdir(vendor, { recursive: true });
|
|
99
|
+
await fs.copyFile(path.join(pkgRoot, f), dest);
|
|
100
|
+
ok(`seoagent/${f}`);
|
|
101
|
+
} else skip(`seoagent/${f}`);
|
|
102
|
+
}
|
|
103
|
+
const imp = importPath(appDir, "sitemap.ts");
|
|
104
|
+
importClient = `${imp}/client`;
|
|
105
|
+
importJsonld = `${imp}/jsonld`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 2. sitemap.ts
|
|
109
|
+
await ensureFile(
|
|
110
|
+
path.join(root, appDir, "sitemap.ts"),
|
|
111
|
+
`import { seoSitemap } from "${importClient}";\nexport const revalidate = 300;\nexport default async function sitemap() {\n return seoSitemap();\n}\n`,
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
// 3. robots.ts
|
|
115
|
+
await ensureFile(
|
|
116
|
+
path.join(root, appDir, "robots.ts"),
|
|
117
|
+
`import { seoRobots } from "${importClient}";\nexport const revalidate = 300;\nexport default async function robots() {\n return seoRobots();\n}\n`,
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
// 4. revalidate-роут (мгновенное применение правок с платформы)
|
|
121
|
+
await ensureFile(
|
|
122
|
+
path.join(root, appDir, "api/seoagent-revalidate/route.ts"),
|
|
123
|
+
`import { revalidateTag } from "next/cache";\n\nexport async function POST(req: Request) {\n if (req.headers.get("x-revalidate-secret") !== process.env.SEOAGENT_REVALIDATE_SECRET) {\n return new Response("unauthorized", { status: 401 });\n }\n revalidateTag(\`seo-\${process.env.SEOAGENT_SITE_ID}\`);\n return Response.json({ ok: true });\n}\n`,
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
// 5. .env.local
|
|
127
|
+
const envPath = path.join(root, ".env.local");
|
|
128
|
+
const cur = (await exists(envPath)) ? await fs.readFile(envPath, "utf8") : "";
|
|
129
|
+
const need = [
|
|
130
|
+
["SEOAGENT_API_BASE", opt("api") ?? "https://seo-agent-blond-beta.vercel.app"],
|
|
131
|
+
["SEOAGENT_SITE_ID", opt("site") ?? ""],
|
|
132
|
+
["SEOAGENT_TOKEN", opt("token") ?? ""],
|
|
133
|
+
["SEOAGENT_REVALIDATE_SECRET", opt("revalidate-secret") ?? ""],
|
|
134
|
+
].filter(([k]) => !cur.includes(k + "="));
|
|
135
|
+
if (need.length) {
|
|
136
|
+
await fs.appendFile(envPath, "\n# SEO Agent\n" + need.map(([k, v]) => `${k}=${v}`).join("\n") + "\n");
|
|
137
|
+
ok(`.env.local (+${need.length} перем.)`);
|
|
138
|
+
} else skip(".env.local");
|
|
139
|
+
|
|
140
|
+
// 6. generateMetadata: авто-обёртка (--wrap) или готовые сниппеты
|
|
141
|
+
const pages = await findMetadataPages(path.join(root, appDir));
|
|
142
|
+
const wrapMode = args.includes("--wrap");
|
|
143
|
+
|
|
144
|
+
if (wrapMode) {
|
|
145
|
+
log("\n\x1b[1mАвто-обёртка generateMetadata:\x1b[0m");
|
|
146
|
+
let wrapped = 0;
|
|
147
|
+
const manual = [];
|
|
148
|
+
for (const p of pages) {
|
|
149
|
+
const rel = path.relative(path.join(root, appDir), p);
|
|
150
|
+
const { expr } = routeFromFile(rel);
|
|
151
|
+
const code = await fs.readFile(p, "utf8");
|
|
152
|
+
const r = await wrapFile(code, expr, importClient);
|
|
153
|
+
if (r.ok) { await fs.writeFile(p, r.code); ok(`${path.relative(root, p)} → обёрнут (route: ${expr})`); wrapped++; }
|
|
154
|
+
else { manual.push({ p, expr, reason: r.reason }); skip(`${path.relative(root, p)} → вручную (${r.reason})`); }
|
|
155
|
+
}
|
|
156
|
+
log(`\n Обёрнуто автоматически: ${wrapped} из ${pages.length}. \x1b[1mПроверь \`git diff\` перед коммитом.\x1b[0m`);
|
|
157
|
+
if (manual.length) {
|
|
158
|
+
log("\n Осталось вручную (замени `return { … }` на):");
|
|
159
|
+
for (const m of manual) {
|
|
160
|
+
log(` • ${path.relative(root, m.p)}`);
|
|
161
|
+
log(` \x1b[36mimport { seoMeta } from "${importClient}";\n return seoMeta({ route: ${m.expr}, locale, fallback: { /* прежний объект */ } });\x1b[0m`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
} else {
|
|
165
|
+
log(`\n \x1b[1mimport { seoMeta } from "${importClient}";\x1b[0m — добавь этот импорт в файлы ниже.`);
|
|
166
|
+
log("\n\x1b[1mОсталось вручную: в каждой странице замени `return { …меты }` на строку из блока (route уже посчитан):\x1b[0m");
|
|
167
|
+
log(" (или запусти повторно с флагом \x1b[1m--wrap\x1b[0m — я оберну сам)");
|
|
168
|
+
for (const p of pages) {
|
|
169
|
+
const rel = path.relative(path.join(root, appDir), p);
|
|
170
|
+
const { expr } = routeFromFile(rel);
|
|
171
|
+
log(`\n • \x1b[1m${path.relative(root, p)}\x1b[0m`);
|
|
172
|
+
log(` \x1b[36mreturn seoMeta({ route: ${expr}, locale, fallback: { /* твой прежний объект меты */ } });\x1b[0m`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
log(`\n И смонтируй JSON-LD в layout (внутри <body>):\n \x1b[36mimport { SeoJsonLd } from "${importJsonld}";\n <SeoJsonLd route="/" locale={locale} />\x1b[0m`);
|
|
177
|
+
log("\n\x1b[1mЗатем:\x1b[0m задай эти же env-переменные в Vercel (Settings → Environment Variables) и сделай git push.\n");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function isGitClean() {
|
|
181
|
+
try {
|
|
182
|
+
return execSync("git status --porcelain", { cwd: root, stdio: ["ignore", "pipe", "ignore"] }).toString().trim() === "";
|
|
183
|
+
} catch {
|
|
184
|
+
return true; // не git-репо — не блокируем
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Вычисляет маршрут для файла страницы из его пути: static → "строка",
|
|
189
|
+
// с динамическим сегментом [slug] → template `/path/${slug}`.
|
|
190
|
+
function routeFromFile(relFromApp) {
|
|
191
|
+
let s = relFromApp.replace(/\\/g, "/").replace(/\/?(page|layout)\.(tsx?|jsx?)$/, "");
|
|
192
|
+
const segs = s.split("/").filter((x) => x && x !== "[locale]");
|
|
193
|
+
if (!segs.length) return { expr: '"/"', dynamic: false };
|
|
194
|
+
let dynamic = false;
|
|
195
|
+
const parts = segs.map((seg) => {
|
|
196
|
+
const m = seg.match(/^\[(?:\.\.\.)?(.+)\]$/);
|
|
197
|
+
if (m) { dynamic = true; return "${" + m[1] + "}"; }
|
|
198
|
+
return seg;
|
|
199
|
+
});
|
|
200
|
+
const pathStr = "/" + parts.join("/");
|
|
201
|
+
return { expr: dynamic ? "`" + pathStr + "`" : `"${pathStr}"`, dynamic };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function findMetadataPages(dir, acc = []) {
|
|
205
|
+
for (const e of await fs.readdir(dir, { withFileTypes: true })) {
|
|
206
|
+
const p = path.join(dir, e.name);
|
|
207
|
+
if (e.isDirectory()) {
|
|
208
|
+
if (e.name === "node_modules" || e.name === "api") continue;
|
|
209
|
+
await findMetadataPages(p, acc);
|
|
210
|
+
} else if (/\.(tsx|ts)$/.test(e.name) && /page\.|layout\./.test(e.name)) {
|
|
211
|
+
const txt = await fs.readFile(p, "utf8");
|
|
212
|
+
if (/generateMetadata/.test(txt)) acc.push(p);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return acc;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (cmd === "init") {
|
|
219
|
+
init().catch((e) => { console.error(e); process.exit(1); });
|
|
220
|
+
} else {
|
|
221
|
+
log("Использование: npx @webnumseoagent/next init [--site <id>] [--token <t>] [--api <url>] [--revalidate-secret <s>]");
|
|
222
|
+
}
|
package/bin/wrap.mjs
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// AST-обёртка generateMetadata → seoMeta(): оборачивает ПОСЛЕДНИЙ return с непустым
|
|
2
|
+
// объектом (гарды вроде `if(!x) return {}` не трогаются). recast сохраняет форматирование.
|
|
3
|
+
// Возвращает {ok, code} или {ok:false, reason}. При отсутствии зависимостей — reason:"deps".
|
|
4
|
+
|
|
5
|
+
function collectReturns(fnBody, fn) {
|
|
6
|
+
const out = [];
|
|
7
|
+
(function walk(node) {
|
|
8
|
+
if (!node || typeof node !== "object") return;
|
|
9
|
+
if (Array.isArray(node)) return node.forEach(walk);
|
|
10
|
+
const t = node.type || "";
|
|
11
|
+
// не спускаемся во вложенные функции (их return'ы — не наши)
|
|
12
|
+
if (node !== fn && /^(FunctionDeclaration|FunctionExpression|ArrowFunctionExpression)$/.test(t)) return;
|
|
13
|
+
if (t === "ReturnStatement" && node.argument && node.argument.type === "ObjectExpression" && node.argument.properties.length > 0) {
|
|
14
|
+
out.push(node);
|
|
15
|
+
}
|
|
16
|
+
for (const k in node) {
|
|
17
|
+
if (k === "loc" || k === "start" || k === "end" || k === "range" || k === "tokens" || k === "comments" || k === "leadingComments" || k === "trailingComments") continue;
|
|
18
|
+
const v = node[k];
|
|
19
|
+
if (v && typeof v === "object") walk(v);
|
|
20
|
+
}
|
|
21
|
+
})(fnBody);
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function wrapFile(code, routeExpr, importSpec) {
|
|
26
|
+
let recast, babelParser;
|
|
27
|
+
try {
|
|
28
|
+
recast = await import("recast");
|
|
29
|
+
babelParser = await import("@babel/parser");
|
|
30
|
+
} catch {
|
|
31
|
+
return { ok: false, reason: "deps" };
|
|
32
|
+
}
|
|
33
|
+
const plugins = ["typescript", "jsx"];
|
|
34
|
+
const parser = { parse: (src) => babelParser.parse(src, { sourceType: "module", tokens: true, plugins }) };
|
|
35
|
+
|
|
36
|
+
let ast;
|
|
37
|
+
try { ast = recast.parse(code, { parser }); } catch { return { ok: false, reason: "parse" }; }
|
|
38
|
+
const body = ast.program.body;
|
|
39
|
+
|
|
40
|
+
// Находим generateMetadata (function declaration или const = async () => ...)
|
|
41
|
+
let fn = null;
|
|
42
|
+
for (const n of body) {
|
|
43
|
+
const d = n.type === "ExportNamedDeclaration" ? n.declaration : n;
|
|
44
|
+
if (!d) continue;
|
|
45
|
+
if (d.type === "FunctionDeclaration" && d.id && d.id.name === "generateMetadata") fn = d;
|
|
46
|
+
if (d.type === "VariableDeclaration") {
|
|
47
|
+
for (const decl of d.declarations) {
|
|
48
|
+
if (decl.id && decl.id.name === "generateMetadata" && decl.init && /Function|Arrow/.test(decl.init.type)) fn = decl.init;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (!fn || !fn.body) return { ok: false, reason: "no-generateMetadata" };
|
|
53
|
+
if (!/\blocale\b/.test(code)) return { ok: false, reason: "no-locale-binding" };
|
|
54
|
+
|
|
55
|
+
// Динамические параметры маршрута (напр. slug) должны быть в scope.
|
|
56
|
+
for (const m of routeExpr.matchAll(/\$\{(\w+)\}/g)) {
|
|
57
|
+
if (!new RegExp("\\b" + m[1] + "\\b").test(code)) return { ok: false, reason: "no-param:" + m[1] };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const returns = collectReturns(fn.body, fn);
|
|
61
|
+
if (!returns.length) return { ok: false, reason: "no-object-return" };
|
|
62
|
+
const target = returns[returns.length - 1];
|
|
63
|
+
|
|
64
|
+
// Строим seoMeta({ route, locale, fallback: <оригинальный объект> })
|
|
65
|
+
let call;
|
|
66
|
+
try {
|
|
67
|
+
call = babelParser.parseExpression(`seoMeta({ route: ${routeExpr}, locale, fallback: null })`, { plugins });
|
|
68
|
+
} catch { return { ok: false, reason: "route-expr" }; }
|
|
69
|
+
const fbProp = call.arguments[0].properties.find((p) => p.key && p.key.name === "fallback");
|
|
70
|
+
fbProp.value = target.argument; // подставляем прежний объект как fallback
|
|
71
|
+
target.argument = call;
|
|
72
|
+
|
|
73
|
+
// Импорт seoMeta
|
|
74
|
+
const hasImport = body.some(
|
|
75
|
+
(n) => n.type === "ImportDeclaration" && n.source.value === importSpec && n.specifiers.some((s) => s.imported && s.imported.name === "seoMeta"),
|
|
76
|
+
);
|
|
77
|
+
if (!hasImport) {
|
|
78
|
+
const imp = babelParser.parse(`import { seoMeta } from "${importSpec}";`, { sourceType: "module", plugins }).program.body[0];
|
|
79
|
+
body.unshift(imp);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let out;
|
|
83
|
+
try { out = recast.print(ast).code; } catch { return { ok: false, reason: "print" }; }
|
|
84
|
+
// Санити: результат должен парситься.
|
|
85
|
+
try { babelParser.parse(out, { sourceType: "module", plugins }); } catch { return { ok: false, reason: "invalid-output" }; }
|
|
86
|
+
return { ok: true, code: out };
|
|
87
|
+
}
|
package/client.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// @seoagent/next — рантайм-адаптер SEO для Next.js (App Router).
|
|
2
|
+
// Generic: работает для ЛЮБОГО сайта — всё определяется env-переменными.
|
|
3
|
+
// Копируется в сайт-клиент (или ставится как пакет). Ничего не хардкодит.
|
|
4
|
+
//
|
|
5
|
+
// ENV на стороне сайта:
|
|
6
|
+
// SEOAGENT_API_BASE — база платформы, напр. https://seonum.uz
|
|
7
|
+
// SEOAGENT_SITE_ID — id сайта в платформе
|
|
8
|
+
// SEOAGENT_TOKEN — токен чтения config-API (config_token)
|
|
9
|
+
//
|
|
10
|
+
// Все хелперы FAIL-SAFE: при ошибке/таймауте возвращают переданный fallback
|
|
11
|
+
// (скомпилированные дефолты сайта) — сборка/рендер НИКОГДА не падает и НЕ уходит в noindex.
|
|
12
|
+
|
|
13
|
+
import type { Metadata, MetadataRoute } from "next";
|
|
14
|
+
|
|
15
|
+
const API = process.env.SEOAGENT_API_BASE ?? "";
|
|
16
|
+
const SITE = process.env.SEOAGENT_SITE_ID ?? "";
|
|
17
|
+
const TOKEN = process.env.SEOAGENT_TOKEN ?? "";
|
|
18
|
+
|
|
19
|
+
interface SeoFields {
|
|
20
|
+
title?: string;
|
|
21
|
+
description?: string;
|
|
22
|
+
canonical?: string;
|
|
23
|
+
robots?: { index?: boolean; follow?: boolean };
|
|
24
|
+
og?: { title?: string; description?: string; image?: string };
|
|
25
|
+
twitter?: { card?: string; image?: string };
|
|
26
|
+
jsonLd?: unknown[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function fetchJson<T>(path: string, revalidate = 300): Promise<T | null> {
|
|
30
|
+
if (!API || !SITE || !TOKEN) return null;
|
|
31
|
+
try {
|
|
32
|
+
const r = await fetch(`${API}/api/seo/${SITE}${path}`, {
|
|
33
|
+
headers: { "x-seo-agent-key": TOKEN },
|
|
34
|
+
next: { revalidate, tags: [`seo-${SITE}`] },
|
|
35
|
+
signal: AbortSignal.timeout(5000),
|
|
36
|
+
});
|
|
37
|
+
if (!r.ok) return null;
|
|
38
|
+
return (await r.json()) as T;
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Накладывает удалённый конфиг ПОВЕРХ скомпилированных дефолтов сайта.
|
|
45
|
+
// robots: index флипается в false ТОЛЬКО если удалённо явно index:false; иначе
|
|
46
|
+
// сохраняем дефолт сайта (защита от случайного деиндекса).
|
|
47
|
+
function mergeIntoMetadata(fallback: Metadata, f: SeoFields): Metadata {
|
|
48
|
+
const m: Metadata = { ...fallback };
|
|
49
|
+
if (f.title) {
|
|
50
|
+
// Сохраняем title.template сайта (напр. "%s · Brand"), меняем только default —
|
|
51
|
+
// иначе строка затрёт шаблон и все дочерние страницы потеряют бренд-суффикс.
|
|
52
|
+
if (m.title && typeof m.title === "object" && "template" in (m.title as object)) {
|
|
53
|
+
m.title = { ...(m.title as Record<string, unknown>), default: f.title } as Metadata["title"];
|
|
54
|
+
} else {
|
|
55
|
+
m.title = f.title;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (f.description) m.description = f.description;
|
|
59
|
+
if (f.canonical) m.alternates = { ...(m.alternates ?? {}), canonical: f.canonical };
|
|
60
|
+
if (f.robots) {
|
|
61
|
+
m.robots = { index: f.robots.index !== false, follow: f.robots.follow !== false };
|
|
62
|
+
}
|
|
63
|
+
if (f.og) {
|
|
64
|
+
m.openGraph = {
|
|
65
|
+
...(m.openGraph ?? {}),
|
|
66
|
+
...(f.og.title ? { title: f.og.title } : {}),
|
|
67
|
+
...(f.og.description ? { description: f.og.description } : {}),
|
|
68
|
+
...(f.og.image ? { images: [f.og.image] } : {}),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
if (f.twitter) {
|
|
72
|
+
m.twitter = {
|
|
73
|
+
...(m.twitter ?? {}),
|
|
74
|
+
...(f.twitter.card ? { card: f.twitter.card as "summary" | "summary_large_image" } : {}),
|
|
75
|
+
...(f.twitter.image ? { images: [f.twitter.image] } : {}),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
return m;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// В generateMetadata: const md = await seoMeta({ route, locale, fallback: compiled });
|
|
82
|
+
export async function seoMeta(opts: {
|
|
83
|
+
route: string;
|
|
84
|
+
locale?: string;
|
|
85
|
+
fallback?: Metadata;
|
|
86
|
+
}): Promise<Metadata> {
|
|
87
|
+
const fallback = opts.fallback ?? {};
|
|
88
|
+
const data = await fetchJson<{ fields: SeoFields }>(
|
|
89
|
+
`/config?route=${encodeURIComponent(opts.route)}&locale=${encodeURIComponent(opts.locale ?? "")}`,
|
|
90
|
+
);
|
|
91
|
+
if (!data?.fields) return fallback;
|
|
92
|
+
return mergeIntoMetadata(fallback, data.fields);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// app/sitemap.ts: export default async function sitemap() { return seoSitemap() }
|
|
96
|
+
export async function seoSitemap(): Promise<MetadataRoute.Sitemap> {
|
|
97
|
+
const data = await fetchJson<{ entries: { url: string; alternates: Record<string, string>; lastModified?: string }[] }>(
|
|
98
|
+
"/sitemap",
|
|
99
|
+
);
|
|
100
|
+
if (!data?.entries) return [];
|
|
101
|
+
return data.entries.map((e) => ({
|
|
102
|
+
url: e.url,
|
|
103
|
+
lastModified: e.lastModified,
|
|
104
|
+
alternates: e.alternates ? { languages: e.alternates } : undefined,
|
|
105
|
+
}));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// app/robots.ts: export default async function robots() { return seoRobots() }
|
|
109
|
+
export async function seoRobots(): Promise<MetadataRoute.Robots> {
|
|
110
|
+
const data = await fetchJson<MetadataRoute.Robots>("/robots");
|
|
111
|
+
// Fail-safe: при недоступности отдаём разрешающий robots (никогда не Disallow: /).
|
|
112
|
+
return data ?? { rules: [{ userAgent: "*", allow: "/" }] };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Для JSON-LD берём массив узлов конфига данного маршрута/локали (см. jsonld.tsx).
|
|
116
|
+
export async function seoJsonLd(route: string, locale?: string): Promise<unknown[]> {
|
|
117
|
+
const data = await fetchJson<{ fields: SeoFields }>(
|
|
118
|
+
`/config?route=${encodeURIComponent(route)}&locale=${encodeURIComponent(locale ?? "")}`,
|
|
119
|
+
);
|
|
120
|
+
return data?.fields?.jsonLd ?? [];
|
|
121
|
+
}
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Metadata, MetadataRoute } from "next";
|
|
2
|
+
export declare function seoMeta(opts: {
|
|
3
|
+
route: string;
|
|
4
|
+
locale?: string;
|
|
5
|
+
fallback?: Metadata;
|
|
6
|
+
}): Promise<Metadata>;
|
|
7
|
+
export declare function seoSitemap(): Promise<MetadataRoute.Sitemap>;
|
|
8
|
+
export declare function seoRobots(): Promise<MetadataRoute.Robots>;
|
|
9
|
+
export declare function seoJsonLd(route: string, locale?: string): Promise<unknown[]>;
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// @seoagent/next — рантайм-адаптер SEO для Next.js (App Router).
|
|
2
|
+
// Generic: работает для ЛЮБОГО сайта — всё определяется env-переменными.
|
|
3
|
+
// Копируется в сайт-клиент (или ставится как пакет). Ничего не хардкодит.
|
|
4
|
+
//
|
|
5
|
+
// ENV на стороне сайта:
|
|
6
|
+
// SEOAGENT_API_BASE — база платформы, напр. https://seonum.uz
|
|
7
|
+
// SEOAGENT_SITE_ID — id сайта в платформе
|
|
8
|
+
// SEOAGENT_TOKEN — токен чтения config-API (config_token)
|
|
9
|
+
//
|
|
10
|
+
// Все хелперы FAIL-SAFE: при ошибке/таймауте возвращают переданный fallback
|
|
11
|
+
// (скомпилированные дефолты сайта) — сборка/рендер НИКОГДА не падает и НЕ уходит в noindex.
|
|
12
|
+
const API = process.env.SEOAGENT_API_BASE ?? "";
|
|
13
|
+
const SITE = process.env.SEOAGENT_SITE_ID ?? "";
|
|
14
|
+
const TOKEN = process.env.SEOAGENT_TOKEN ?? "";
|
|
15
|
+
async function fetchJson(path, revalidate = 300) {
|
|
16
|
+
if (!API || !SITE || !TOKEN)
|
|
17
|
+
return null;
|
|
18
|
+
try {
|
|
19
|
+
const r = await fetch(`${API}/api/seo/${SITE}${path}`, {
|
|
20
|
+
headers: { "x-seo-agent-key": TOKEN },
|
|
21
|
+
next: { revalidate, tags: [`seo-${SITE}`] },
|
|
22
|
+
signal: AbortSignal.timeout(5000),
|
|
23
|
+
});
|
|
24
|
+
if (!r.ok)
|
|
25
|
+
return null;
|
|
26
|
+
return (await r.json());
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
// Накладывает удалённый конфиг ПОВЕРХ скомпилированных дефолтов сайта.
|
|
33
|
+
// robots: index флипается в false ТОЛЬКО если удалённо явно index:false; иначе
|
|
34
|
+
// сохраняем дефолт сайта (защита от случайного деиндекса).
|
|
35
|
+
function mergeIntoMetadata(fallback, f) {
|
|
36
|
+
const m = { ...fallback };
|
|
37
|
+
if (f.title) {
|
|
38
|
+
// Сохраняем title.template сайта (напр. "%s · Brand"), меняем только default —
|
|
39
|
+
// иначе строка затрёт шаблон и все дочерние страницы потеряют бренд-суффикс.
|
|
40
|
+
if (m.title && typeof m.title === "object" && "template" in m.title) {
|
|
41
|
+
m.title = { ...m.title, default: f.title };
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
m.title = f.title;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (f.description)
|
|
48
|
+
m.description = f.description;
|
|
49
|
+
if (f.canonical)
|
|
50
|
+
m.alternates = { ...(m.alternates ?? {}), canonical: f.canonical };
|
|
51
|
+
if (f.robots) {
|
|
52
|
+
m.robots = { index: f.robots.index !== false, follow: f.robots.follow !== false };
|
|
53
|
+
}
|
|
54
|
+
if (f.og) {
|
|
55
|
+
m.openGraph = {
|
|
56
|
+
...(m.openGraph ?? {}),
|
|
57
|
+
...(f.og.title ? { title: f.og.title } : {}),
|
|
58
|
+
...(f.og.description ? { description: f.og.description } : {}),
|
|
59
|
+
...(f.og.image ? { images: [f.og.image] } : {}),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
if (f.twitter) {
|
|
63
|
+
m.twitter = {
|
|
64
|
+
...(m.twitter ?? {}),
|
|
65
|
+
...(f.twitter.card ? { card: f.twitter.card } : {}),
|
|
66
|
+
...(f.twitter.image ? { images: [f.twitter.image] } : {}),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
return m;
|
|
70
|
+
}
|
|
71
|
+
// В generateMetadata: const md = await seoMeta({ route, locale, fallback: compiled });
|
|
72
|
+
export async function seoMeta(opts) {
|
|
73
|
+
const fallback = opts.fallback ?? {};
|
|
74
|
+
const data = await fetchJson(`/config?route=${encodeURIComponent(opts.route)}&locale=${encodeURIComponent(opts.locale ?? "")}`);
|
|
75
|
+
if (!data?.fields)
|
|
76
|
+
return fallback;
|
|
77
|
+
return mergeIntoMetadata(fallback, data.fields);
|
|
78
|
+
}
|
|
79
|
+
// app/sitemap.ts: export default async function sitemap() { return seoSitemap() }
|
|
80
|
+
export async function seoSitemap() {
|
|
81
|
+
const data = await fetchJson("/sitemap");
|
|
82
|
+
if (!data?.entries)
|
|
83
|
+
return [];
|
|
84
|
+
return data.entries.map((e) => ({
|
|
85
|
+
url: e.url,
|
|
86
|
+
lastModified: e.lastModified,
|
|
87
|
+
alternates: e.alternates ? { languages: e.alternates } : undefined,
|
|
88
|
+
}));
|
|
89
|
+
}
|
|
90
|
+
// app/robots.ts: export default async function robots() { return seoRobots() }
|
|
91
|
+
export async function seoRobots() {
|
|
92
|
+
const data = await fetchJson("/robots");
|
|
93
|
+
// Fail-safe: при недоступности отдаём разрешающий robots (никогда не Disallow: /).
|
|
94
|
+
return data ?? { rules: [{ userAgent: "*", allow: "/" }] };
|
|
95
|
+
}
|
|
96
|
+
// Для JSON-LD берём массив узлов конфига данного маршрута/локали (см. jsonld.tsx).
|
|
97
|
+
export async function seoJsonLd(route, locale) {
|
|
98
|
+
const data = await fetchJson(`/config?route=${encodeURIComponent(route)}&locale=${encodeURIComponent(locale ?? "")}`);
|
|
99
|
+
return data?.fields?.jsonLd ?? [];
|
|
100
|
+
}
|
package/dist/jsonld.d.ts
ADDED
package/dist/jsonld.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
// Серверный компонент для инъекции JSON-LD. XSS-безопасно: экранируем '<' → <
|
|
3
|
+
// и не даём закрыть тег </script>. Смонтируй в layout и/или на страницах:
|
|
4
|
+
// <SeoJsonLd route="/" locale={locale} />
|
|
5
|
+
import { seoJsonLd } from "./client";
|
|
6
|
+
function safeJson(nodes) {
|
|
7
|
+
// Один граф Schema.org; экранируем опасные последовательности.
|
|
8
|
+
const graph = nodes.length === 1 ? nodes[0] : { "@context": "https://schema.org", "@graph": nodes };
|
|
9
|
+
return JSON.stringify(graph)
|
|
10
|
+
.replace(/</g, "\\u003c")
|
|
11
|
+
.replace(/>/g, "\\u003e")
|
|
12
|
+
.replace(/&/g, "\\u0026");
|
|
13
|
+
}
|
|
14
|
+
export async function SeoJsonLd({ route, locale }) {
|
|
15
|
+
const nodes = await seoJsonLd(route, locale);
|
|
16
|
+
if (!nodes.length)
|
|
17
|
+
return null;
|
|
18
|
+
return (_jsx("script", { type: "application/ld+json",
|
|
19
|
+
// eslint-disable-next-line react/no-danger
|
|
20
|
+
dangerouslySetInnerHTML: { __html: safeJson(nodes) } }));
|
|
21
|
+
}
|
package/jsonld.tsx
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Серверный компонент для инъекции JSON-LD. XSS-безопасно: экранируем '<' → <
|
|
2
|
+
// и не даём закрыть тег </script>. Смонтируй в layout и/или на страницах:
|
|
3
|
+
// <SeoJsonLd route="/" locale={locale} />
|
|
4
|
+
import { seoJsonLd } from "./client";
|
|
5
|
+
|
|
6
|
+
function safeJson(nodes: unknown[]): string {
|
|
7
|
+
// Один граф Schema.org; экранируем опасные последовательности.
|
|
8
|
+
const graph = nodes.length === 1 ? nodes[0] : { "@context": "https://schema.org", "@graph": nodes };
|
|
9
|
+
return JSON.stringify(graph)
|
|
10
|
+
.replace(/</g, "\\u003c")
|
|
11
|
+
.replace(/>/g, "\\u003e")
|
|
12
|
+
.replace(/&/g, "\\u0026");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function SeoJsonLd({ route, locale }: { route: string; locale?: string }) {
|
|
16
|
+
const nodes = await seoJsonLd(route, locale);
|
|
17
|
+
if (!nodes.length) return null;
|
|
18
|
+
return (
|
|
19
|
+
<script
|
|
20
|
+
type="application/ld+json"
|
|
21
|
+
// eslint-disable-next-line react/no-danger
|
|
22
|
+
dangerouslySetInnerHTML={{ __html: safeJson(nodes) }}
|
|
23
|
+
/>
|
|
24
|
+
);
|
|
25
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@webnumseoagent/next",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Runtime SEO adapter + installer for Next.js App Router sites (SEO Agent platform)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/client.js",
|
|
7
|
+
"types": "dist/client.d.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"seoagent-next": "bin/cli.mjs"
|
|
10
|
+
},
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/client.d.ts",
|
|
14
|
+
"default": "./dist/client.js"
|
|
15
|
+
},
|
|
16
|
+
"./jsonld": {
|
|
17
|
+
"types": "./dist/jsonld.d.ts",
|
|
18
|
+
"default": "./dist/jsonld.js"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist",
|
|
23
|
+
"client.ts",
|
|
24
|
+
"jsonld.tsx",
|
|
25
|
+
"bin",
|
|
26
|
+
"README.md"
|
|
27
|
+
],
|
|
28
|
+
"keywords": [
|
|
29
|
+
"nextjs",
|
|
30
|
+
"seo",
|
|
31
|
+
"sitemap",
|
|
32
|
+
"robots",
|
|
33
|
+
"json-ld",
|
|
34
|
+
"app-router"
|
|
35
|
+
],
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsc -p tsconfig.build.json",
|
|
38
|
+
"prepublishOnly": "npm run build"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"recast": "^0.23.9",
|
|
42
|
+
"@babel/parser": "^7.25.0"
|
|
43
|
+
},
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"next": ">=13.4",
|
|
46
|
+
"react": ">=18"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"typescript": "^5.5.0",
|
|
50
|
+
"next": "^14.2.0",
|
|
51
|
+
"react": "^18.3.0",
|
|
52
|
+
"@types/react": "^18.3.0"
|
|
53
|
+
},
|
|
54
|
+
"publishConfig": {
|
|
55
|
+
"access": "public"
|
|
56
|
+
},
|
|
57
|
+
"license": "MIT"
|
|
58
|
+
}
|