@webnumseoagent/next 0.1.10 → 0.1.11

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.
@@ -1,6 +1,32 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.seoWrapSitemap = seoWrapSitemap;
4
+ const API = process.env.SEOAGENT_API_BASE ?? "";
5
+ const SITE = process.env.SEOAGENT_SITE_ID ?? "";
6
+ const TOKEN = process.env.SEOAGENT_TOKEN ?? "";
7
+ const CFG_DEFAULT = { images: true, excludedGroups: [], excludedRoutes: [] };
8
+ async function fetchSitemapCfg() {
9
+ if (!API || !SITE || !TOKEN)
10
+ return CFG_DEFAULT; // fail-safe = прежнее поведение
11
+ try {
12
+ const r = await fetch(`${API}/api/seo/${SITE}/sitemap-config`, {
13
+ headers: { "x-seo-agent-key": TOKEN },
14
+ next: { revalidate: 300, tags: [`seo-${SITE}`] }, // тот же тег ⇒ пинг ревалидации сбрасывает
15
+ signal: AbortSignal.timeout(5000),
16
+ });
17
+ if (!r.ok)
18
+ return CFG_DEFAULT;
19
+ const j = (await r.json());
20
+ return {
21
+ images: j?.images !== false,
22
+ excludedGroups: Array.isArray(j?.excludedGroups) ? j.excludedGroups.map(String) : [],
23
+ excludedRoutes: Array.isArray(j?.excludedRoutes) ? j.excludedRoutes.map(String) : [],
24
+ };
25
+ }
26
+ catch {
27
+ return CFG_DEFAULT;
28
+ }
29
+ }
4
30
  function escapeXml(s) {
5
31
  return s
6
32
  .replace(/&/g, "&")
@@ -63,6 +89,17 @@ function groupOfUrl(loc) {
63
89
  const segs = path.split("/").filter(Boolean);
64
90
  return segs.length <= 1 ? "pages" : dec(segs[0]);
65
91
  }
92
+ // Нормализованный путь записи (декодированный, без хвостового слэша) — для сравнения
93
+ // с excludedRoutes (точное совпадение пути).
94
+ function pathOf(loc) {
95
+ try {
96
+ return dec(new URL(loc).pathname).replace(/\/+$/, "") || "/";
97
+ }
98
+ catch {
99
+ return loc;
100
+ }
101
+ }
102
+ const normPath = (p) => dec(p).replace(/\/+$/, "") || "/";
66
103
  function pi(origin) {
67
104
  return `<?xml version="1.0" encoding="UTF-8"?>\n<?xml-stylesheet type="text/xsl" href="${escapeXml(origin + "/sitemap.xsl")}"?>\n`;
68
105
  }
@@ -100,6 +137,7 @@ const EMPTY = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www
100
137
  // urlset, если раздел один); kind="child" — под-карта раздела `group`.
101
138
  // Fail-safe: если генератор упал — отдаём валидную пустую карту (не 500).
102
139
  async function seoWrapSitemap(fn, kind, group) {
140
+ const cfg = await fetchSitemapCfg();
103
141
  let entries;
104
142
  try {
105
143
  entries = normalize(await fn());
@@ -110,15 +148,33 @@ async function seoWrapSitemap(fn, kind, group) {
110
148
  const origin = originOf(entries);
111
149
  if (!origin || entries.length === 0)
112
150
  return EMPTY;
151
+ // Трансформации применяем к списку записей ОДИН раз, до ветвления по kind —
152
+ // иначе flat и «схлопывание в один раздел» их обходят.
153
+ if (!cfg.images)
154
+ for (const e of entries)
155
+ e.images = undefined; // убрать изображения
156
+ if (cfg.excludedRoutes.length) {
157
+ const ex = new Set(cfg.excludedRoutes.map(normPath));
158
+ entries = entries.filter((e) => !ex.has(pathOf(e.loc))); // исключить отдельные пути
159
+ }
160
+ const excluded = new Set(cfg.excludedGroups);
113
161
  // flat — одна карта со всеми URL (без под-карт); используется, когда под-карты недоступны.
114
162
  if (kind === "flat")
115
- return urlsetXml(origin, entries);
163
+ return urlsetXml(origin, entries.filter((e) => !excluded.has(groupOfUrl(e.loc))));
116
164
  if (kind === "child") {
117
165
  const g = (group ?? "").replace(/\.xml$/i, "");
166
+ if (excluded.has(g))
167
+ return EMPTY; // раздел выключен → пустая (валидная) карта
118
168
  return urlsetXml(origin, entries.filter((e) => groupOfUrl(e.loc) === g));
119
169
  }
120
- const groups = [...new Set(entries.map((e) => groupOfUrl(e.loc)))].sort();
121
- if (groups.length <= 1)
122
- return urlsetXml(origin, entries);
170
+ // index
171
+ const groups = [...new Set(entries.map((e) => groupOfUrl(e.loc)))].sort().filter((g) => !excluded.has(g));
172
+ if (groups.length === 0)
173
+ return EMPTY; // всё выключено → пустая карта (backstop)
174
+ if (groups.length === 1) {
175
+ // остался один раздел → отдаём его urlset (только его записи, не все!)
176
+ const only = groups[0];
177
+ return urlsetXml(origin, entries.filter((e) => groupOfUrl(e.loc) === only));
178
+ }
123
179
  return indexXml(origin, groups);
124
180
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webnumseoagent/next",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
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",
package/sitemap-build.ts CHANGED
@@ -1,8 +1,44 @@
1
1
  // «Оборачивание» существующей карты сайта: берём данные из ИХ генератора
2
2
  // (сохраняя свежесть и все URL), группируем по разделам и отдаём стилизованный
3
3
  // индекс + под-карты со ссылкой на XSL. Работает на стороне сайта (без платформы).
4
+ //
5
+ // Плюс тянем настройки карты с платформы (изображения / исключённые разделы и пути) —
6
+ // тем же тегированным fetch, что и robots (вариант B): при сохранении на платформе
7
+ // пинг ревалидации сбрасывает тег seo-<SITE>, и карта пересобирается с новыми правилами.
8
+ // Fail-safe: если платформа недоступна — ведём себя как раньше (всё включено).
4
9
  import type { MetadataRoute } from "next";
5
10
 
11
+ const API = process.env.SEOAGENT_API_BASE ?? "";
12
+ const SITE = process.env.SEOAGENT_SITE_ID ?? "";
13
+ const TOKEN = process.env.SEOAGENT_TOKEN ?? "";
14
+
15
+ interface SitemapCfg {
16
+ images: boolean;
17
+ excludedGroups: string[];
18
+ excludedRoutes: string[];
19
+ }
20
+ const CFG_DEFAULT: SitemapCfg = { images: true, excludedGroups: [], excludedRoutes: [] };
21
+
22
+ async function fetchSitemapCfg(): Promise<SitemapCfg> {
23
+ if (!API || !SITE || !TOKEN) return CFG_DEFAULT; // fail-safe = прежнее поведение
24
+ try {
25
+ const r = await fetch(`${API}/api/seo/${SITE}/sitemap-config`, {
26
+ headers: { "x-seo-agent-key": TOKEN },
27
+ next: { revalidate: 300, tags: [`seo-${SITE}`] }, // тот же тег ⇒ пинг ревалидации сбрасывает
28
+ signal: AbortSignal.timeout(5000),
29
+ });
30
+ if (!r.ok) return CFG_DEFAULT;
31
+ const j = (await r.json()) as Partial<SitemapCfg> | null;
32
+ return {
33
+ images: j?.images !== false,
34
+ excludedGroups: Array.isArray(j?.excludedGroups) ? j!.excludedGroups!.map(String) : [],
35
+ excludedRoutes: Array.isArray(j?.excludedRoutes) ? j!.excludedRoutes!.map(String) : [],
36
+ };
37
+ } catch {
38
+ return CFG_DEFAULT;
39
+ }
40
+ }
41
+
6
42
  function escapeXml(s: string): string {
7
43
  return s
8
44
  .replace(/&/g, "&amp;")
@@ -82,6 +118,17 @@ function groupOfUrl(loc: string): string {
82
118
  return segs.length <= 1 ? "pages" : dec(segs[0]);
83
119
  }
84
120
 
121
+ // Нормализованный путь записи (декодированный, без хвостового слэша) — для сравнения
122
+ // с excludedRoutes (точное совпадение пути).
123
+ function pathOf(loc: string): string {
124
+ try {
125
+ return dec(new URL(loc).pathname).replace(/\/+$/, "") || "/";
126
+ } catch {
127
+ return loc;
128
+ }
129
+ }
130
+ const normPath = (p: string): string => dec(p).replace(/\/+$/, "") || "/";
131
+
85
132
  function pi(origin: string): string {
86
133
  return `<?xml version="1.0" encoding="UTF-8"?>\n<?xml-stylesheet type="text/xsl" href="${escapeXml(origin + "/sitemap.xsl")}"?>\n`;
87
134
  }
@@ -123,6 +170,7 @@ export async function seoWrapSitemap(
123
170
  kind: "index" | "child" | "flat",
124
171
  group?: string,
125
172
  ): Promise<string> {
173
+ const cfg = await fetchSitemapCfg();
126
174
  let entries: NEntry[];
127
175
  try {
128
176
  entries = normalize(await fn());
@@ -132,15 +180,31 @@ export async function seoWrapSitemap(
132
180
  const origin = originOf(entries);
133
181
  if (!origin || entries.length === 0) return EMPTY;
134
182
 
183
+ // Трансформации применяем к списку записей ОДИН раз, до ветвления по kind —
184
+ // иначе flat и «схлопывание в один раздел» их обходят.
185
+ if (!cfg.images) for (const e of entries) e.images = undefined; // убрать изображения
186
+ if (cfg.excludedRoutes.length) {
187
+ const ex = new Set(cfg.excludedRoutes.map(normPath));
188
+ entries = entries.filter((e) => !ex.has(pathOf(e.loc))); // исключить отдельные пути
189
+ }
190
+ const excluded = new Set(cfg.excludedGroups);
191
+
135
192
  // flat — одна карта со всеми URL (без под-карт); используется, когда под-карты недоступны.
136
- if (kind === "flat") return urlsetXml(origin, entries);
193
+ if (kind === "flat") return urlsetXml(origin, entries.filter((e) => !excluded.has(groupOfUrl(e.loc))));
137
194
 
138
195
  if (kind === "child") {
139
196
  const g = (group ?? "").replace(/\.xml$/i, "");
197
+ if (excluded.has(g)) return EMPTY; // раздел выключен → пустая (валидная) карта
140
198
  return urlsetXml(origin, entries.filter((e) => groupOfUrl(e.loc) === g));
141
199
  }
142
200
 
143
- const groups = [...new Set(entries.map((e) => groupOfUrl(e.loc)))].sort();
144
- if (groups.length <= 1) return urlsetXml(origin, entries);
201
+ // index
202
+ const groups = [...new Set(entries.map((e) => groupOfUrl(e.loc)))].sort().filter((g) => !excluded.has(g));
203
+ if (groups.length === 0) return EMPTY; // всё выключено → пустая карта (backstop)
204
+ if (groups.length === 1) {
205
+ // остался один раздел → отдаём его urlset (только его записи, не все!)
206
+ const only = groups[0];
207
+ return urlsetXml(origin, entries.filter((e) => groupOfUrl(e.loc) === only));
208
+ }
145
209
  return indexXml(origin, groups);
146
210
  }