@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.
Files changed (3) hide show
  1. package/client.ts +114 -10
  2. package/dist/client.js +122 -9
  3. 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 флипается в false ТОЛЬКО если удалённо явно index:false; иначе
49
- // сохраняем дефолт сайта (защита от случайного деиндекса).
50
- function mergeIntoMetadata(fallback: Metadata, f: SeoFields): Metadata {
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
- if (f.title) {
53
- // Сохраняем title.template сайта (напр. "%s · Brand"), меняем только default
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
- if (f.description) m.description = f.description;
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
- m.robots = { index: f.robots.index !== false, follow: f.robots.follow !== false };
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 флипается в false ТОЛЬКО если удалённо явно index:false; иначе
44
- // сохраняем дефолт сайта (защита от случайного деиндекса).
45
- function mergeIntoMetadata(fallback, f) {
114
+ // robots: index/follow меняем ТОЛЬКО при явном булеве (иначе сохраняем дефолт сайта — защита
115
+ // от случайного деиндекса); ограничительные директивы (noarchive/nosnippet/noimageindex)
116
+ // добавляем поверх дефолта сайта только когда явно включены.
117
+ function mergeIntoMetadata(fallback, f, route) {
46
118
  const m = { ...fallback };
47
- if (f.title) {
48
- // Сохраняем title.template сайта (напр. "%s · Brand"), меняем только default —
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
- if (f.description)
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
- m.robots = { index: f.robots.index !== false, follow: f.robots.follow !== false };
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webnumseoagent/next",
3
- "version": "0.1.18",
3
+ "version": "0.1.20",
4
4
  "description": "Runtime SEO adapter + installer for Next.js App Router sites (SEO Agent platform)",
5
5
  "main": "dist/client.js",
6
6
  "types": "dist/client.d.ts",