@webnumseoagent/next 0.1.12 → 0.1.15
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/bin/cli.mjs +28 -4
- package/bin/wrap.mjs +37 -19
- package/client.ts +6 -0
- package/dist/client.js +5 -0
- package/package.json +1 -1
package/bin/cli.mjs
CHANGED
|
@@ -262,6 +262,27 @@ async function init() {
|
|
|
262
262
|
}
|
|
263
263
|
}
|
|
264
264
|
|
|
265
|
+
// 4.6 Мониторинг 404: роут-приёмник на сайте (форвардит хит в платформу с СЕРВЕРНЫМ
|
|
266
|
+
// токеном) + not-found-репортер (клиентский маячок с путём 404). Репортер отдаём сырым
|
|
267
|
+
// исходником (client-компонент из dist пакета не работает — tsc ставит "use strict" первой).
|
|
268
|
+
{
|
|
269
|
+
const reqType = ext === "ts" ? ": Request" : "";
|
|
270
|
+
const fwd = `export async function POST(req${reqType}) {\n const origin = req.headers.get("origin"), host = req.headers.get("host");\n if (origin && host) { try { if (new URL(origin).host !== host) return new Response(null, { status: 204 }); } catch { return new Response(null, { status: 204 }); } }\n const API = process.env.SEOAGENT_API_BASE, SITE = process.env.SEOAGENT_SITE_ID, TOKEN = process.env.SEOAGENT_TOKEN;\n if (!API || !SITE || !TOKEN) return new Response(null, { status: 204 });\n let path = "";\n try { const b = await req.json(); if (typeof b?.path === "string") path = b.path; } catch {}\n if (!path) return new Response(null, { status: 204 });\n try { await fetch(\`\${API}/api/seo/\${SITE}/not-found\`, { method: "POST", headers: { "content-type": "application/json", "x-seo-agent-key": TOKEN }, body: JSON.stringify({ path }), signal: AbortSignal.timeout(5000) }); } catch {}\n return new Response(null, { status: 204 });\n}\n`;
|
|
271
|
+
await ensureFile(path.join(root, appDir, `api/seoagent-404/route.${ext}`), fwd);
|
|
272
|
+
|
|
273
|
+
const reporter = `"use client";\n\nimport Link from "next/link";\nimport { usePathname } from "next/navigation";\nimport { useEffect, useRef } from "react";\n\n// Страница 404 + репортер для «Мониторинг 404» (SEO Agent).\nexport default function NotFound() {\n const pathname = usePathname();\n const lastSent = useRef(null);\n useEffect(() => {\n const p = pathname || window.location.pathname;\n if (lastSent.current === p) return;\n lastSent.current = p;\n try {\n const body = JSON.stringify({ path: p });\n if (navigator.sendBeacon) navigator.sendBeacon("/api/seoagent-404", new Blob([body], { type: "application/json" }));\n else fetch("/api/seoagent-404", { method: "POST", headers: { "content-type": "application/json" }, body, keepalive: true }).catch(() => {});\n } catch {}\n }, [pathname]);\n return (\n <main style={{ minHeight: "60vh", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: "1rem", padding: "2rem", textAlign: "center" }}>\n <h1 style={{ fontSize: "3rem", margin: 0 }}>404</h1>\n <p style={{ color: "#64748b", margin: 0 }}>Страница не найдена.</p>\n <Link href="/" style={{ color: "#2563eb" }}>На главную</Link>\n </main>\n );\n}\n`;
|
|
274
|
+
const nfExt = ext === "ts" ? "tsx" : "js";
|
|
275
|
+
const hasNotFound = await anyExists([
|
|
276
|
+
`${appDir}/not-found.js`, `${appDir}/not-found.jsx`, `${appDir}/not-found.ts`, `${appDir}/not-found.tsx`,
|
|
277
|
+
]);
|
|
278
|
+
if (hasNotFound) {
|
|
279
|
+
log(`\n \x1b[1mnot-found уже есть\x1b[0m — добавь репортер 404 (Мониторинг 404) в свой not-found:`);
|
|
280
|
+
log(` \x1b[36m"use client"; ... useEffect(() => { navigator.sendBeacon?.("/api/seoagent-404", new Blob([JSON.stringify({ path: location.pathname })], { type: "application/json" })); }, []);\x1b[0m`);
|
|
281
|
+
} else {
|
|
282
|
+
await ensureFile(path.join(root, appDir, `not-found.${nfExt}`), reporter);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
265
286
|
// 5. .env.local
|
|
266
287
|
const envPath = path.join(root, ".env.local");
|
|
267
288
|
const cur = (await exists(envPath)) ? await fs.readFile(envPath, "utf8") : "";
|
|
@@ -298,7 +319,7 @@ async function init() {
|
|
|
298
319
|
log("\n Осталось вручную (замени `return { … }` на):");
|
|
299
320
|
for (const m of manual) {
|
|
300
321
|
log(` • ${path.relative(root, m.p)}`);
|
|
301
|
-
log(` \x1b[36mimport { seoMeta } from "${clientFor(path.relative(path.join(root, appDir), m.p))}";\n return seoMeta({ route: ${m.expr}, locale, fallback: { /* прежний объект */ } });\x1b[0m`);
|
|
322
|
+
log(` \x1b[36mimport { seoMeta } from "${clientFor(path.relative(path.join(root, appDir), m.p))}";\n return seoMeta({ route: ${paramsExpr(m.expr)}, locale, fallback: { /* прежний объект */ } });\x1b[0m`);
|
|
302
323
|
}
|
|
303
324
|
}
|
|
304
325
|
|
|
@@ -321,7 +342,7 @@ async function init() {
|
|
|
321
342
|
const rel = path.relative(path.join(root, appDir), p);
|
|
322
343
|
const { expr } = routeFromFile(rel);
|
|
323
344
|
log(`\n • \x1b[1m${path.relative(root, p)}\x1b[0m`);
|
|
324
|
-
log(` \x1b[36mreturn seoMeta({ route: ${expr}, locale, fallback: { /* твой прежний объект меты */ } });\x1b[0m`);
|
|
345
|
+
log(` \x1b[36mreturn seoMeta({ route: ${paramsExpr(expr)}, locale, fallback: { /* твой прежний объект меты */ } });\x1b[0m`);
|
|
325
346
|
}
|
|
326
347
|
}
|
|
327
348
|
|
|
@@ -361,14 +382,17 @@ function routeFromFile(relFromApp) {
|
|
|
361
382
|
if (!segs.length) return { expr: '"/"', dynamic: false };
|
|
362
383
|
let dynamic = false;
|
|
363
384
|
const parts = segs.map((seg) => {
|
|
364
|
-
const m = seg.match(/^\[(?:\.\.\.)?(
|
|
365
|
-
if (m) { dynamic = true; return "${" + m[1] + "}"; }
|
|
385
|
+
const m = seg.match(/^\[+(?:\.\.\.)?(.+?)\]+$/); // [slug], [...slug], [[...slug]]
|
|
386
|
+
if (m) { dynamic = true; return "${" + m[1].replace(/[.\[\]]/g, "") + "}"; }
|
|
366
387
|
return seg;
|
|
367
388
|
});
|
|
368
389
|
const pathStr = "/" + parts.join("/");
|
|
369
390
|
return { expr: dynamic ? "`" + pathStr + "`" : `"${pathStr}"`, dynamic };
|
|
370
391
|
}
|
|
371
392
|
|
|
393
|
+
// Ручной сниппет вставляется в generateMetadata({ params }) — сегменты идут через params.
|
|
394
|
+
const paramsExpr = (expr) => expr.replace(/\$\{(\w+)\}/g, "${params.$1}");
|
|
395
|
+
|
|
372
396
|
async function findMetadataPages(dir, acc = []) {
|
|
373
397
|
for (const e of await fs.readdir(dir, { withFileTypes: true })) {
|
|
374
398
|
const p = path.join(dir, e.name);
|
package/bin/wrap.mjs
CHANGED
|
@@ -104,27 +104,45 @@ export async function wrapFile(code, routeExpr, importSpec) {
|
|
|
104
104
|
// locale опционально (мультиязычные — есть, одноязычные — нет).
|
|
105
105
|
const hasLocale = /\blocale\b/.test(code);
|
|
106
106
|
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
107
|
+
// Строим корректные обращения к динамическим сегментам. В generateMetadata сегмент
|
|
108
|
+
// ВСЕГДА доступен через объект params (Next всегда передаёт { params }); голое имя
|
|
109
|
+
// корректно ТОЛЬКО если сегмент явно деструктурирован в сигнатуре. Опираемся на САМУ
|
|
110
|
+
// сигнатуру (а не на скан всего файла — там `id` мог встретиться где угодно) и, если
|
|
111
|
+
// нужно, нормализуем сигнатуру, чтобы params/сегменты гарантированно были в области видимости.
|
|
112
|
+
const segNames = [...routeExpr.matchAll(/\$\{(\w+)\}/g)].map((m) => m[1]);
|
|
113
|
+
let effRoute = routeExpr;
|
|
114
|
+
if (segNames.length) {
|
|
115
|
+
const mkPat = (src) => babelParser.parseExpression(src, { plugins }).params[0]; // паттерн-параметр стрелки
|
|
116
|
+
const destructured = new Set(); // сегменты, связанные как отдельные переменные в сигнатуре
|
|
117
|
+
let base = "params";
|
|
118
|
+
const p0 = fn.params && fn.params[0];
|
|
119
|
+
if (!p0) {
|
|
120
|
+
fn.params = fn.params || [];
|
|
121
|
+
fn.params[0] = mkPat("({ params }) => 0"); // нет параметра → добавляем { params }
|
|
115
122
|
} else if (p0.type === "Identifier") {
|
|
116
|
-
|
|
123
|
+
base = p0.name + ".params"; // generateMetadata(props) → props.params
|
|
124
|
+
} else if (p0.type === "ObjectPattern") {
|
|
125
|
+
const pp = p0.properties.find((x) => x.key && x.key.name === "params");
|
|
126
|
+
if (!pp) {
|
|
127
|
+
p0.properties.unshift(mkPat("({ params }) => 0").properties[0]); // { searchParams } → добавляем params
|
|
128
|
+
} else if (pp.value && pp.value.type === "Identifier") {
|
|
129
|
+
base = pp.value.name; // { params } или { params: p }
|
|
130
|
+
} else if (pp.value && pp.value.type === "ObjectPattern") {
|
|
131
|
+
// Вложенная деструктуризация { params: { id } } — сегменты как отдельные переменные;
|
|
132
|
+
// недостающие добавляем в паттерн, чтобы они были в области видимости.
|
|
133
|
+
for (const prop of pp.value.properties) if (prop.key) destructured.add(prop.key.name);
|
|
134
|
+
for (const seg of segNames) if (!destructured.has(seg)) {
|
|
135
|
+
pp.value.properties.push(mkPat(`({ ${seg} }) => 0`).properties[0]);
|
|
136
|
+
destructured.add(seg);
|
|
137
|
+
}
|
|
138
|
+
base = null; // используем голое имя для всех сегментов
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
for (const seg of segNames) {
|
|
142
|
+
if (base !== null && !destructured.has(seg)) {
|
|
143
|
+
effRoute = effRoute.replace("${" + seg + "}", "${" + base + "." + seg + "}");
|
|
144
|
+
}
|
|
117
145
|
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
// Динамические параметры маршрута: если параметр деструктурирован (есть как
|
|
121
|
-
// отдельная переменная) — используем его напрямую (${slug}); иначе — через
|
|
122
|
-
// объект params (${params.slug}). Это чинит страницы, где обращаются как params.X.
|
|
123
|
-
let effRoute = routeExpr;
|
|
124
|
-
for (const m of [...routeExpr.matchAll(/\$\{(\w+)\}/g)]) {
|
|
125
|
-
const name = m[1];
|
|
126
|
-
const standalone = new RegExp("(?<!\\.)\\b" + name + "\\b").test(code);
|
|
127
|
-
if (!standalone) effRoute = effRoute.replace("${" + name + "}", "${" + paramsBase + "." + name + "}");
|
|
128
146
|
}
|
|
129
147
|
|
|
130
148
|
const returns = collectReturns(fn.body, fn);
|
package/client.ts
CHANGED
|
@@ -24,6 +24,7 @@ interface SeoFields {
|
|
|
24
24
|
og?: { title?: string; description?: string; image?: string };
|
|
25
25
|
twitter?: { card?: string; image?: string };
|
|
26
26
|
jsonLd?: unknown[];
|
|
27
|
+
verification?: Record<string, string>; // проверочные мета-теги: metaName → content
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
async function fetchJson<T>(path: string, revalidate = 300): Promise<T | null> {
|
|
@@ -75,6 +76,11 @@ function mergeIntoMetadata(fallback: Metadata, f: SeoFields): Metadata {
|
|
|
75
76
|
...(f.twitter.image ? { images: [f.twitter.image] } : {}),
|
|
76
77
|
};
|
|
77
78
|
}
|
|
79
|
+
// Проверочные мета-теги веб-мастерских → <meta name content> через metadata.other.
|
|
80
|
+
// Мержим, не затирая существующий other сайта.
|
|
81
|
+
if (f.verification && typeof f.verification === "object") {
|
|
82
|
+
m.other = { ...(m.other ?? {}), ...f.verification };
|
|
83
|
+
}
|
|
78
84
|
return m;
|
|
79
85
|
}
|
|
80
86
|
|
package/dist/client.js
CHANGED
|
@@ -75,6 +75,11 @@ function mergeIntoMetadata(fallback, f) {
|
|
|
75
75
|
...(f.twitter.image ? { images: [f.twitter.image] } : {}),
|
|
76
76
|
};
|
|
77
77
|
}
|
|
78
|
+
// Проверочные мета-теги веб-мастерских → <meta name content> через metadata.other.
|
|
79
|
+
// Мержим, не затирая существующий other сайта.
|
|
80
|
+
if (f.verification && typeof f.verification === "object") {
|
|
81
|
+
m.other = { ...(m.other ?? {}), ...f.verification };
|
|
82
|
+
}
|
|
78
83
|
return m;
|
|
79
84
|
}
|
|
80
85
|
// В generateMetadata: const md = await seoMeta({ route, locale, fallback: compiled });
|
package/package.json
CHANGED