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