@webnumseoagent/next 0.1.19 → 0.1.21
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/client.ts +88 -7
- package/dist/client.js +78 -6
- package/package.json +1 -1
package/client.ts
CHANGED
|
@@ -20,13 +20,63 @@ interface SeoFields {
|
|
|
20
20
|
title?: string;
|
|
21
21
|
description?: string;
|
|
22
22
|
canonical?: string;
|
|
23
|
-
robots?: {
|
|
23
|
+
robots?: {
|
|
24
|
+
index?: boolean; follow?: boolean; noarchive?: boolean; nosnippet?: boolean; noimageindex?: boolean;
|
|
25
|
+
// Расширенные директивы (RankMath «Advanced Robots»): предел сниппета, превью видео, размер превью картинок.
|
|
26
|
+
maxSnippet?: number; maxVideoPreview?: number; maxImagePreview?: "none" | "standard" | "large";
|
|
27
|
+
};
|
|
24
28
|
og?: { title?: string; description?: string; image?: string; siteName?: string; locale?: string };
|
|
25
29
|
twitter?: { card?: string; image?: string; site?: string };
|
|
26
30
|
facebook?: { appId?: string; admins?: string }; // fb:app_id / fb:admins (admins — через запятую)
|
|
27
31
|
jsonLd?: unknown[];
|
|
28
32
|
verification?: Record<string, string>; // проверочные мета-теги: metaName → content
|
|
29
33
|
analytics?: { ga4?: string; gtm?: string; metrica?: string }; // счётчики (см. analytics.tsx)
|
|
34
|
+
// Глобальные шаблоны заголовка/описания (site-wide) + данные для переменных.
|
|
35
|
+
titleTemplate?: string;
|
|
36
|
+
descTemplate?: string;
|
|
37
|
+
titleSep?: string;
|
|
38
|
+
siteName?: string;
|
|
39
|
+
baseUrl?: string;
|
|
40
|
+
orgProfile?: { name?: string };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Подстановка переменных шаблона (RankMath-стиль). Значения подставляем ЧЕРЕЗ функции — иначе `$`
|
|
44
|
+
// в значении трактовался бы как спецпаттерн String.replace. Незнакомые %...% оставляем как есть.
|
|
45
|
+
function applyTemplate(tpl: string, v: { title: string; sitename: string; sep: string; excerpt: string; orgName: string; orgUrl: string; url: string }): string {
|
|
46
|
+
const now = new Date();
|
|
47
|
+
const p2 = (n: number) => String(n).padStart(2, "0");
|
|
48
|
+
const sep = v.sep || "-";
|
|
49
|
+
let out = tpl
|
|
50
|
+
.replace(/%title%/g, () => v.title)
|
|
51
|
+
.replace(/%sitename%/g, () => v.sitename)
|
|
52
|
+
.replace(/%sep%/g, () => sep)
|
|
53
|
+
.replace(/%excerpt%/g, () => v.excerpt)
|
|
54
|
+
.replace(/%currentyear%/g, () => String(now.getFullYear()))
|
|
55
|
+
.replace(/%currentmonth%/g, () => p2(now.getMonth() + 1))
|
|
56
|
+
.replace(/%currentday%/g, () => p2(now.getDate()))
|
|
57
|
+
.replace(/%currentdate%/g, () => `${p2(now.getDate())}.${p2(now.getMonth() + 1)}.${now.getFullYear()}`)
|
|
58
|
+
.replace(/%org_name%/g, () => v.orgName)
|
|
59
|
+
.replace(/%org_url%/g, () => v.orgUrl)
|
|
60
|
+
.replace(/%url%/g, () => v.url);
|
|
61
|
+
// Убираем «висячие» разделители у пустых переменных (как RankMath): задвоенные → один, и по краям.
|
|
62
|
+
const e = sep.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
63
|
+
out = out
|
|
64
|
+
.replace(new RegExp(`(?:\\s*${e}\\s*){2,}`, "g"), ` ${sep} `)
|
|
65
|
+
.replace(new RegExp(`^(?:\\s*${e}\\s*)+`), "")
|
|
66
|
+
.replace(new RegExp(`(?:\\s*${e}\\s*)+$`), "")
|
|
67
|
+
.replace(/\s{2,}/g, " ")
|
|
68
|
+
.trim();
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// «Сырой» текст из Metadata.title (строка | {absolute|default|template}) для переменной %title%.
|
|
73
|
+
function rawText(t: unknown): string {
|
|
74
|
+
if (typeof t === "string") return t;
|
|
75
|
+
if (t && typeof t === "object") {
|
|
76
|
+
const o = t as { absolute?: string; default?: string };
|
|
77
|
+
return o.absolute ?? o.default ?? "";
|
|
78
|
+
}
|
|
79
|
+
return "";
|
|
30
80
|
}
|
|
31
81
|
|
|
32
82
|
async function fetchJson<T>(path: string, revalidate = 300): Promise<T | null> {
|
|
@@ -68,18 +118,45 @@ function robotsFromString(s: string): Record<string, unknown> {
|
|
|
68
118
|
// robots: index/follow меняем ТОЛЬКО при явном булеве (иначе сохраняем дефолт сайта — защита
|
|
69
119
|
// от случайного деиндекса); ограничительные директивы (noarchive/nosnippet/noimageindex)
|
|
70
120
|
// добавляем поверх дефолта сайта только когда явно включены.
|
|
71
|
-
function mergeIntoMetadata(fallback: Metadata, f: SeoFields): Metadata {
|
|
121
|
+
function mergeIntoMetadata(fallback: Metadata, f: SeoFields, route: string): Metadata {
|
|
72
122
|
const m: Metadata = { ...fallback };
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
123
|
+
|
|
124
|
+
// Переменные для шаблонов — из дефолтов сайта (ДО модификаций): %title% = собственный заголовок
|
|
125
|
+
// страницы, %excerpt% = собственное описание.
|
|
126
|
+
const vars = {
|
|
127
|
+
title: rawText(m.title),
|
|
128
|
+
sitename: f.siteName ?? "",
|
|
129
|
+
sep: f.titleSep ?? "-",
|
|
130
|
+
excerpt: typeof m.description === "string" ? m.description : "",
|
|
131
|
+
orgName: f.orgProfile?.name ?? "",
|
|
132
|
+
orgUrl: f.baseUrl ?? "",
|
|
133
|
+
url: f.baseUrl ? f.baseUrl.replace(/\/+$/, "") + route : route,
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// Заголовок. Есть глобальный titleTemplate → платформа полностью управляет заголовком:
|
|
137
|
+
// title.absolute обходит корневой шаблон сайта (чтобы бренд не задвоился), оверрайд страницы
|
|
138
|
+
// (f.title) тоже раскрывается по переменным. Нет шаблона → прежнее поведение (оверрайд меняет
|
|
139
|
+
// только default, сохраняя title.template сайта).
|
|
140
|
+
if (f.titleTemplate) {
|
|
141
|
+
// Оверрайд страницы — это её «часть заголовка» (поле «SEO-заголовок»), поэтому подставляем его
|
|
142
|
+
// в %title% шаблона, а не заменяем весь шаблон — иначе бренд-суффикс потерялся бы у таких страниц.
|
|
143
|
+
const out = applyTemplate(f.titleTemplate, f.title ? { ...vars, title: f.title } : vars);
|
|
144
|
+
if (out) m.title = { absolute: out };
|
|
145
|
+
} else if (f.title) {
|
|
76
146
|
if (m.title && typeof m.title === "object" && "template" in (m.title as object)) {
|
|
77
147
|
m.title = { ...(m.title as Record<string, unknown>), default: f.title } as Metadata["title"];
|
|
78
148
|
} else {
|
|
79
149
|
m.title = f.title;
|
|
80
150
|
}
|
|
81
151
|
}
|
|
82
|
-
|
|
152
|
+
|
|
153
|
+
// Описание. Глобальный descTemplate → раскрываем (оверрайд тоже как шаблон); иначе оверрайд как есть.
|
|
154
|
+
if (f.descTemplate) {
|
|
155
|
+
const out = applyTemplate(f.descTemplate, f.description ? { ...vars, excerpt: f.description } : vars);
|
|
156
|
+
if (out) m.description = out;
|
|
157
|
+
} else if (f.description) {
|
|
158
|
+
m.description = f.description;
|
|
159
|
+
}
|
|
83
160
|
if (f.canonical) m.alternates = { ...(m.alternates ?? {}), canonical: f.canonical };
|
|
84
161
|
if (f.robots) {
|
|
85
162
|
const r = f.robots;
|
|
@@ -92,6 +169,10 @@ function mergeIntoMetadata(fallback: Metadata, f: SeoFields): Metadata {
|
|
|
92
169
|
if (r.noarchive) base.noarchive = true;
|
|
93
170
|
if (r.nosnippet) base.nosnippet = true;
|
|
94
171
|
if (r.noimageindex) base.noimageindex = true;
|
|
172
|
+
// Расширенные директивы — ключи с дефисом, как их ждёт Next (эмитятся в <meta name="robots">).
|
|
173
|
+
if (typeof r.maxSnippet === "number") base["max-snippet"] = r.maxSnippet;
|
|
174
|
+
if (typeof r.maxVideoPreview === "number") base["max-video-preview"] = r.maxVideoPreview;
|
|
175
|
+
if (r.maxImagePreview) base["max-image-preview"] = r.maxImagePreview;
|
|
95
176
|
m.robots = base as Metadata["robots"];
|
|
96
177
|
}
|
|
97
178
|
if (f.og) {
|
|
@@ -141,7 +222,7 @@ export async function seoMeta(opts: {
|
|
|
141
222
|
`/config?route=${encodeURIComponent(opts.route)}&locale=${encodeURIComponent(opts.locale ?? "")}`,
|
|
142
223
|
);
|
|
143
224
|
if (!data?.fields) return fallback;
|
|
144
|
-
return mergeIntoMetadata(fallback, data.fields);
|
|
225
|
+
return mergeIntoMetadata(fallback, data.fields, opts.route ?? "");
|
|
145
226
|
}
|
|
146
227
|
|
|
147
228
|
// Стилизованная карта: сырой XML с платформы (индекс/urlset со ссылкой на XSL).
|
package/dist/client.js
CHANGED
|
@@ -22,6 +22,44 @@ exports.seoAnalytics = seoAnalytics;
|
|
|
22
22
|
const API = process.env.SEOAGENT_API_BASE ?? "";
|
|
23
23
|
const SITE = process.env.SEOAGENT_SITE_ID ?? "";
|
|
24
24
|
const TOKEN = process.env.SEOAGENT_TOKEN ?? "";
|
|
25
|
+
// Подстановка переменных шаблона (RankMath-стиль). Значения подставляем ЧЕРЕЗ функции — иначе `$`
|
|
26
|
+
// в значении трактовался бы как спецпаттерн String.replace. Незнакомые %...% оставляем как есть.
|
|
27
|
+
function applyTemplate(tpl, v) {
|
|
28
|
+
const now = new Date();
|
|
29
|
+
const p2 = (n) => String(n).padStart(2, "0");
|
|
30
|
+
const sep = v.sep || "-";
|
|
31
|
+
let out = tpl
|
|
32
|
+
.replace(/%title%/g, () => v.title)
|
|
33
|
+
.replace(/%sitename%/g, () => v.sitename)
|
|
34
|
+
.replace(/%sep%/g, () => sep)
|
|
35
|
+
.replace(/%excerpt%/g, () => v.excerpt)
|
|
36
|
+
.replace(/%currentyear%/g, () => String(now.getFullYear()))
|
|
37
|
+
.replace(/%currentmonth%/g, () => p2(now.getMonth() + 1))
|
|
38
|
+
.replace(/%currentday%/g, () => p2(now.getDate()))
|
|
39
|
+
.replace(/%currentdate%/g, () => `${p2(now.getDate())}.${p2(now.getMonth() + 1)}.${now.getFullYear()}`)
|
|
40
|
+
.replace(/%org_name%/g, () => v.orgName)
|
|
41
|
+
.replace(/%org_url%/g, () => v.orgUrl)
|
|
42
|
+
.replace(/%url%/g, () => v.url);
|
|
43
|
+
// Убираем «висячие» разделители у пустых переменных (как RankMath): задвоенные → один, и по краям.
|
|
44
|
+
const e = sep.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
45
|
+
out = out
|
|
46
|
+
.replace(new RegExp(`(?:\\s*${e}\\s*){2,}`, "g"), ` ${sep} `)
|
|
47
|
+
.replace(new RegExp(`^(?:\\s*${e}\\s*)+`), "")
|
|
48
|
+
.replace(new RegExp(`(?:\\s*${e}\\s*)+$`), "")
|
|
49
|
+
.replace(/\s{2,}/g, " ")
|
|
50
|
+
.trim();
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
// «Сырой» текст из Metadata.title (строка | {absolute|default|template}) для переменной %title%.
|
|
54
|
+
function rawText(t) {
|
|
55
|
+
if (typeof t === "string")
|
|
56
|
+
return t;
|
|
57
|
+
if (t && typeof t === "object") {
|
|
58
|
+
const o = t;
|
|
59
|
+
return o.absolute ?? o.default ?? "";
|
|
60
|
+
}
|
|
61
|
+
return "";
|
|
62
|
+
}
|
|
25
63
|
async function fetchJson(path, revalidate = 300) {
|
|
26
64
|
if (!API || !SITE || !TOKEN)
|
|
27
65
|
return null;
|
|
@@ -76,11 +114,31 @@ function robotsFromString(s) {
|
|
|
76
114
|
// robots: index/follow меняем ТОЛЬКО при явном булеве (иначе сохраняем дефолт сайта — защита
|
|
77
115
|
// от случайного деиндекса); ограничительные директивы (noarchive/nosnippet/noimageindex)
|
|
78
116
|
// добавляем поверх дефолта сайта только когда явно включены.
|
|
79
|
-
function mergeIntoMetadata(fallback, f) {
|
|
117
|
+
function mergeIntoMetadata(fallback, f, route) {
|
|
80
118
|
const m = { ...fallback };
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
119
|
+
// Переменные для шаблонов — из дефолтов сайта (ДО модификаций): %title% = собственный заголовок
|
|
120
|
+
// страницы, %excerpt% = собственное описание.
|
|
121
|
+
const vars = {
|
|
122
|
+
title: rawText(m.title),
|
|
123
|
+
sitename: f.siteName ?? "",
|
|
124
|
+
sep: f.titleSep ?? "-",
|
|
125
|
+
excerpt: typeof m.description === "string" ? m.description : "",
|
|
126
|
+
orgName: f.orgProfile?.name ?? "",
|
|
127
|
+
orgUrl: f.baseUrl ?? "",
|
|
128
|
+
url: f.baseUrl ? f.baseUrl.replace(/\/+$/, "") + route : route,
|
|
129
|
+
};
|
|
130
|
+
// Заголовок. Есть глобальный titleTemplate → платформа полностью управляет заголовком:
|
|
131
|
+
// title.absolute обходит корневой шаблон сайта (чтобы бренд не задвоился), оверрайд страницы
|
|
132
|
+
// (f.title) тоже раскрывается по переменным. Нет шаблона → прежнее поведение (оверрайд меняет
|
|
133
|
+
// только default, сохраняя title.template сайта).
|
|
134
|
+
if (f.titleTemplate) {
|
|
135
|
+
// Оверрайд страницы — это её «часть заголовка» (поле «SEO-заголовок»), поэтому подставляем его
|
|
136
|
+
// в %title% шаблона, а не заменяем весь шаблон — иначе бренд-суффикс потерялся бы у таких страниц.
|
|
137
|
+
const out = applyTemplate(f.titleTemplate, f.title ? { ...vars, title: f.title } : vars);
|
|
138
|
+
if (out)
|
|
139
|
+
m.title = { absolute: out };
|
|
140
|
+
}
|
|
141
|
+
else if (f.title) {
|
|
84
142
|
if (m.title && typeof m.title === "object" && "template" in m.title) {
|
|
85
143
|
m.title = { ...m.title, default: f.title };
|
|
86
144
|
}
|
|
@@ -88,8 +146,15 @@ function mergeIntoMetadata(fallback, f) {
|
|
|
88
146
|
m.title = f.title;
|
|
89
147
|
}
|
|
90
148
|
}
|
|
91
|
-
|
|
149
|
+
// Описание. Глобальный descTemplate → раскрываем (оверрайд тоже как шаблон); иначе оверрайд как есть.
|
|
150
|
+
if (f.descTemplate) {
|
|
151
|
+
const out = applyTemplate(f.descTemplate, f.description ? { ...vars, excerpt: f.description } : vars);
|
|
152
|
+
if (out)
|
|
153
|
+
m.description = out;
|
|
154
|
+
}
|
|
155
|
+
else if (f.description) {
|
|
92
156
|
m.description = f.description;
|
|
157
|
+
}
|
|
93
158
|
if (f.canonical)
|
|
94
159
|
m.alternates = { ...(m.alternates ?? {}), canonical: f.canonical };
|
|
95
160
|
if (f.robots) {
|
|
@@ -107,6 +172,13 @@ function mergeIntoMetadata(fallback, f) {
|
|
|
107
172
|
base.nosnippet = true;
|
|
108
173
|
if (r.noimageindex)
|
|
109
174
|
base.noimageindex = true;
|
|
175
|
+
// Расширенные директивы — ключи с дефисом, как их ждёт Next (эмитятся в <meta name="robots">).
|
|
176
|
+
if (typeof r.maxSnippet === "number")
|
|
177
|
+
base["max-snippet"] = r.maxSnippet;
|
|
178
|
+
if (typeof r.maxVideoPreview === "number")
|
|
179
|
+
base["max-video-preview"] = r.maxVideoPreview;
|
|
180
|
+
if (r.maxImagePreview)
|
|
181
|
+
base["max-image-preview"] = r.maxImagePreview;
|
|
110
182
|
m.robots = base;
|
|
111
183
|
}
|
|
112
184
|
if (f.og) {
|
|
@@ -150,7 +222,7 @@ async function seoMeta(opts) {
|
|
|
150
222
|
const data = await fetchJson(`/config?route=${encodeURIComponent(opts.route)}&locale=${encodeURIComponent(opts.locale ?? "")}`);
|
|
151
223
|
if (!data?.fields)
|
|
152
224
|
return fallback;
|
|
153
|
-
return mergeIntoMetadata(fallback, data.fields);
|
|
225
|
+
return mergeIntoMetadata(fallback, data.fields, opts.route ?? "");
|
|
154
226
|
}
|
|
155
227
|
// Стилизованная карта: сырой XML с платформы (индекс/urlset со ссылкой на XSL).
|
|
156
228
|
// app/sitemap.xml/route.ts: GET() { return new Response(await seoSitemapXml("/sitemap.xml"), {headers:{"content-type":"application/xml"}}) }
|
package/package.json
CHANGED