@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 CHANGED
@@ -20,13 +20,63 @@ interface SeoFields {
20
20
  title?: string;
21
21
  description?: string;
22
22
  canonical?: string;
23
- robots?: { index?: boolean; follow?: boolean; noarchive?: boolean; nosnippet?: boolean; noimageindex?: boolean };
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
- if (f.title) {
74
- // Сохраняем title.template сайта (напр. "%s · Brand"), меняем только default
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
- if (f.description) m.description = f.description;
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
- if (f.title) {
82
- // Сохраняем title.template сайта (напр. "%s · Brand"), меняем только default —
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
- 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) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webnumseoagent/next",
3
- "version": "0.1.19",
3
+ "version": "0.1.21",
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",