@webnumseoagent/next 0.1.19 → 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 +79 -6
- package/dist/client.js +71 -6
- package/package.json +1 -1
package/client.ts
CHANGED
|
@@ -27,6 +27,52 @@ interface SeoFields {
|
|
|
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> {
|
|
@@ -68,18 +114,45 @@ function robotsFromString(s: string): Record<string, unknown> {
|
|
|
68
114
|
// robots: index/follow меняем ТОЛЬКО при явном булеве (иначе сохраняем дефолт сайта — защита
|
|
69
115
|
// от случайного деиндекса); ограничительные директивы (noarchive/nosnippet/noimageindex)
|
|
70
116
|
// добавляем поверх дефолта сайта только когда явно включены.
|
|
71
|
-
function mergeIntoMetadata(fallback: Metadata, f: SeoFields): Metadata {
|
|
117
|
+
function mergeIntoMetadata(fallback: Metadata, f: SeoFields, route: string): Metadata {
|
|
72
118
|
const m: Metadata = { ...fallback };
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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) {
|
|
76
142
|
if (m.title && typeof m.title === "object" && "template" in (m.title as object)) {
|
|
77
143
|
m.title = { ...(m.title as Record<string, unknown>), default: f.title } as Metadata["title"];
|
|
78
144
|
} else {
|
|
79
145
|
m.title = f.title;
|
|
80
146
|
}
|
|
81
147
|
}
|
|
82
|
-
|
|
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
|
+
}
|
|
83
156
|
if (f.canonical) m.alternates = { ...(m.alternates ?? {}), canonical: f.canonical };
|
|
84
157
|
if (f.robots) {
|
|
85
158
|
const r = f.robots;
|
|
@@ -141,7 +214,7 @@ export async function seoMeta(opts: {
|
|
|
141
214
|
`/config?route=${encodeURIComponent(opts.route)}&locale=${encodeURIComponent(opts.locale ?? "")}`,
|
|
142
215
|
);
|
|
143
216
|
if (!data?.fields) return fallback;
|
|
144
|
-
return mergeIntoMetadata(fallback, data.fields);
|
|
217
|
+
return mergeIntoMetadata(fallback, data.fields, opts.route ?? "");
|
|
145
218
|
}
|
|
146
219
|
|
|
147
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;
|
|
@@ -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) {
|
|
@@ -150,7 +215,7 @@ async function seoMeta(opts) {
|
|
|
150
215
|
const data = await fetchJson(`/config?route=${encodeURIComponent(opts.route)}&locale=${encodeURIComponent(opts.locale ?? "")}`);
|
|
151
216
|
if (!data?.fields)
|
|
152
217
|
return fallback;
|
|
153
|
-
return mergeIntoMetadata(fallback, data.fields);
|
|
218
|
+
return mergeIntoMetadata(fallback, data.fields, opts.route ?? "");
|
|
154
219
|
}
|
|
155
220
|
// Стилизованная карта: сырой XML с платформы (индекс/urlset со ссылкой на XSL).
|
|
156
221
|
// app/sitemap.xml/route.ts: GET() { return new Response(await seoSitemapXml("/sitemap.xml"), {headers:{"content-type":"application/xml"}}) }
|
package/package.json
CHANGED