@webnumseoagent/next 0.5.0 → 0.7.0

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.
@@ -0,0 +1,76 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.sanitizeDeliveryHtml = sanitizeDeliveryHtml;
4
+ // Санитайзер HTML тела статьи (защита-в-глубину перед dangerouslySetInnerHTML) — ЗЕРКАЛО доставки
5
+ // (src/lib/blog/blog-delivery.ts + Edge blog-autopilot). Тело уже санитайзено при доставке, но это
6
+ // последний рубеж на рендере (общий для supabase- и файлового источников блога).
7
+ const SANITIZE_ALLOWED_TAGS = new Set([
8
+ "h1", "h2", "h3", "h4", "h5", "h6", "p", "br", "hr", "strong", "b", "em", "i", "u", "s",
9
+ "ul", "ol", "li", "blockquote", "a", "img", "figure", "figcaption",
10
+ "table", "thead", "tbody", "tfoot", "tr", "th", "td", "caption", "code", "pre", "span", "div",
11
+ ]);
12
+ const SANITIZE_ALLOWED_ATTRS = /^(href|src|alt|title|target|rel|width|height|loading|colspan|rowspan|scope|start|type)$/;
13
+ const SANITIZE_VOID = new Set(["br", "hr", "img"]);
14
+ function decodeSchemeEntities(v) {
15
+ return v
16
+ .replace(/&#x([0-9a-fA-F]+);?/g, (_m, h) => { try {
17
+ return String.fromCodePoint(parseInt(h, 16));
18
+ }
19
+ catch {
20
+ return "";
21
+ } })
22
+ .replace(/&#(\d+);?/g, (_m, d) => { try {
23
+ return String.fromCodePoint(parseInt(d, 10));
24
+ }
25
+ catch {
26
+ return "";
27
+ } })
28
+ .replace(/&(colon|tab|newline|lf|cr|sol|amp);?/gi, (m) => {
29
+ const k = m.replace(/[&;]/g, "").toLowerCase();
30
+ return { colon: ":", sol: "/", amp: "&", tab: " ", newline: " ", lf: " ", cr: " " }[k] ?? " ";
31
+ });
32
+ }
33
+ function safeUrl(val) {
34
+ const v = decodeSchemeEntities(val).toLowerCase().replace(/\s/g, "");
35
+ if (!/^[a-z][a-z0-9+.-]*:/.test(v))
36
+ return true;
37
+ return /^(https?:|mailto:|tel:)/.test(v);
38
+ }
39
+ function sanitizeAttrs(tag, attrsRaw) {
40
+ const out = [];
41
+ const re = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>]+))/g;
42
+ let m;
43
+ let href = "";
44
+ while ((m = re.exec(attrsRaw))) {
45
+ const name = m[1].toLowerCase();
46
+ const val = m[3] ?? m[4] ?? m[5] ?? "";
47
+ if (name.startsWith("on") || name === "style" || name === "srcset")
48
+ continue;
49
+ if ((name === "href" || name === "src") && !safeUrl(val))
50
+ continue;
51
+ if (!SANITIZE_ALLOWED_ATTRS.test(name))
52
+ continue;
53
+ if (name === "href")
54
+ href = val;
55
+ out.push(`${name}="${val.replace(/"/g, """)}"`);
56
+ }
57
+ if (tag === "a" && !out.some((a) => a.startsWith("rel="))) {
58
+ if (/^(https?:)?\/\//i.test(href.trim()))
59
+ out.push('rel="noopener nofollow"');
60
+ }
61
+ return out.length ? " " + out.join(" ") : "";
62
+ }
63
+ function sanitizeDeliveryHtml(html) {
64
+ let s = html || "";
65
+ s = s.replace(/<(script|style|iframe|object|embed|form|noscript|template|svg|math)\b[\s\S]*?<\/\1\s*>/gi, "");
66
+ s = s.replace(/<!--[\s\S]*?-->/g, "");
67
+ s = s.replace(/<(\/?)([a-zA-Z][a-zA-Z0-9]*)\b((?:"[^"]*"|'[^']*'|[^>])*)>/g, (_full, slash, tag, attrs) => {
68
+ const t = tag.toLowerCase();
69
+ if (!SANITIZE_ALLOWED_TAGS.has(t))
70
+ return "";
71
+ if (slash)
72
+ return `</${t}>`;
73
+ return `<${t}${sanitizeAttrs(t, attrs)}${SANITIZE_VOID.has(t) ? " /" : ""}>`;
74
+ });
75
+ return s.trim();
76
+ }
@@ -1,4 +1,37 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
2
35
  Object.defineProperty(exports, "__esModule", { value: true });
3
36
  exports.seoWrapSitemap = seoWrapSitemap;
4
37
  const API = process.env.SEOAGENT_API_BASE ?? "";
@@ -115,6 +148,8 @@ function groupOfUrl(loc) {
115
148
  const segs = path.split("/").filter(Boolean);
116
149
  return segs.length <= 1 ? "pages" : dec(segs[0]);
117
150
  }
151
+ // Раздел записи: явный e.group (напр. блог) важнее эвристики по первому сегменту URL.
152
+ const groupOf = (e) => e.group ?? groupOfUrl(e.loc);
118
153
  // Нормализованный путь записи (декодированный, без хвостового слэша) — для сравнения
119
154
  // с excludedRoutes (точное совпадение пути).
120
155
  function pathOf(loc) {
@@ -174,6 +209,16 @@ async function seoWrapSitemap(fn, kind, group) {
174
209
  const origin = originOf(entries);
175
210
  if (!origin || entries.length === 0)
176
211
  return EMPTY;
212
+ // Блог живёт файлами в репо (git-native) и в генераторе карты сайта его нет — добавляем его записи
213
+ // сами, на сборке из content/blog (раздел карты "blog"). node:fs грузим динамически; нет блога → пропуск.
214
+ try {
215
+ const bf = await Promise.resolve().then(() => __importStar(require("./blog-fs")));
216
+ if (bf.fsAvailable()) {
217
+ for (const b of bf.fsBlogSitemapEntries(origin))
218
+ entries.push({ loc: b.loc, lastmod: b.lastmod, group: "blog" });
219
+ }
220
+ }
221
+ catch { /* нет модуля/файлов → без блога */ }
177
222
  // Отдаём платформе ПОЛНЫЙ список страниц (до исключений) — только на индексном/плоском
178
223
  // вызове (у него все URL). Await + троттлинг: доставка надёжна, задержка редка (карта кэш 300с).
179
224
  if (kind === "index" || kind === "flat")
@@ -190,21 +235,21 @@ async function seoWrapSitemap(fn, kind, group) {
190
235
  const excluded = new Set(cfg.excludedGroups);
191
236
  // flat — одна карта со всеми URL (без под-карт); используется, когда под-карты недоступны.
192
237
  if (kind === "flat")
193
- return urlsetXml(origin, entries.filter((e) => !excluded.has(groupOfUrl(e.loc))));
238
+ return urlsetXml(origin, entries.filter((e) => !excluded.has(groupOf(e))));
194
239
  if (kind === "child") {
195
240
  const g = (group ?? "").replace(/\.xml$/i, "");
196
241
  if (excluded.has(g))
197
242
  return EMPTY; // раздел выключен → пустая (валидная) карта
198
- return urlsetXml(origin, entries.filter((e) => groupOfUrl(e.loc) === g));
243
+ return urlsetXml(origin, entries.filter((e) => groupOf(e) === g));
199
244
  }
200
245
  // index
201
- const groups = [...new Set(entries.map((e) => groupOfUrl(e.loc)))].sort().filter((g) => !excluded.has(g));
246
+ const groups = [...new Set(entries.map((e) => groupOf(e)))].sort().filter((g) => !excluded.has(g));
202
247
  if (groups.length === 0)
203
248
  return EMPTY; // всё выключено → пустая карта (backstop)
204
249
  if (groups.length === 1) {
205
250
  // остался один раздел → отдаём его urlset (только его записи, не все!)
206
251
  const only = groups[0];
207
- return urlsetXml(origin, entries.filter((e) => groupOfUrl(e.loc) === only));
252
+ return urlsetXml(origin, entries.filter((e) => groupOf(e) === only));
208
253
  }
209
254
  return indexXml(origin, groups);
210
255
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webnumseoagent/next",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
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",
@@ -35,6 +35,8 @@
35
35
  "jsonld.tsx",
36
36
  "analytics.tsx",
37
37
  "blog.tsx",
38
+ "blog-fs.ts",
39
+ "sanitize.ts",
38
40
  "sitemap-xsl.ts",
39
41
  "sitemap-build.ts",
40
42
  "redirects.ts",
package/sanitize.ts ADDED
@@ -0,0 +1,56 @@
1
+ // Санитайзер HTML тела статьи (защита-в-глубину перед dangerouslySetInnerHTML) — ЗЕРКАЛО доставки
2
+ // (src/lib/blog/blog-delivery.ts + Edge blog-autopilot). Тело уже санитайзено при доставке, но это
3
+ // последний рубеж на рендере (общий для supabase- и файлового источников блога).
4
+ const SANITIZE_ALLOWED_TAGS = new Set([
5
+ "h1", "h2", "h3", "h4", "h5", "h6", "p", "br", "hr", "strong", "b", "em", "i", "u", "s",
6
+ "ul", "ol", "li", "blockquote", "a", "img", "figure", "figcaption",
7
+ "table", "thead", "tbody", "tfoot", "tr", "th", "td", "caption", "code", "pre", "span", "div",
8
+ ]);
9
+ const SANITIZE_ALLOWED_ATTRS = /^(href|src|alt|title|target|rel|width|height|loading|colspan|rowspan|scope|start|type)$/;
10
+ const SANITIZE_VOID = new Set(["br", "hr", "img"]);
11
+
12
+ function decodeSchemeEntities(v: string): string {
13
+ return v
14
+ .replace(/&#x([0-9a-fA-F]+);?/g, (_m, h) => { try { return String.fromCodePoint(parseInt(h, 16)); } catch { return ""; } })
15
+ .replace(/&#(\d+);?/g, (_m, d) => { try { return String.fromCodePoint(parseInt(d, 10)); } catch { return ""; } })
16
+ .replace(/&(colon|tab|newline|lf|cr|sol|amp);?/gi, (m) => {
17
+ const k = m.replace(/[&;]/g, "").toLowerCase();
18
+ return ({ colon: ":", sol: "/", amp: "&", tab: " ", newline: " ", lf: " ", cr: " " } as Record<string, string>)[k] ?? " ";
19
+ });
20
+ }
21
+ function safeUrl(val: string): boolean {
22
+ const v = decodeSchemeEntities(val).toLowerCase().replace(/\s/g, "");
23
+ if (!/^[a-z][a-z0-9+.-]*:/.test(v)) return true;
24
+ return /^(https?:|mailto:|tel:)/.test(v);
25
+ }
26
+ function sanitizeAttrs(tag: string, attrsRaw: string): string {
27
+ const out: string[] = [];
28
+ const re = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>]+))/g;
29
+ let m: RegExpExecArray | null;
30
+ let href = "";
31
+ while ((m = re.exec(attrsRaw))) {
32
+ const name = m[1].toLowerCase();
33
+ const val = m[3] ?? m[4] ?? m[5] ?? "";
34
+ if (name.startsWith("on") || name === "style" || name === "srcset") continue;
35
+ if ((name === "href" || name === "src") && !safeUrl(val)) continue;
36
+ if (!SANITIZE_ALLOWED_ATTRS.test(name)) continue;
37
+ if (name === "href") href = val;
38
+ out.push(`${name}="${val.replace(/"/g, "&quot;")}"`);
39
+ }
40
+ if (tag === "a" && !out.some((a) => a.startsWith("rel="))) {
41
+ if (/^(https?:)?\/\//i.test(href.trim())) out.push('rel="noopener nofollow"');
42
+ }
43
+ return out.length ? " " + out.join(" ") : "";
44
+ }
45
+ export function sanitizeDeliveryHtml(html: string): string {
46
+ let s = html || "";
47
+ s = s.replace(/<(script|style|iframe|object|embed|form|noscript|template|svg|math)\b[\s\S]*?<\/\1\s*>/gi, "");
48
+ s = s.replace(/<!--[\s\S]*?-->/g, "");
49
+ s = s.replace(/<(\/?)([a-zA-Z][a-zA-Z0-9]*)\b((?:"[^"]*"|'[^']*'|[^>])*)>/g, (_full, slash, tag, attrs) => {
50
+ const t = tag.toLowerCase();
51
+ if (!SANITIZE_ALLOWED_TAGS.has(t)) return "";
52
+ if (slash) return `</${t}>`;
53
+ return `<${t}${sanitizeAttrs(t, attrs)}${SANITIZE_VOID.has(t) ? " /" : ""}>`;
54
+ });
55
+ return s.trim();
56
+ }
package/sitemap-build.ts CHANGED
@@ -55,6 +55,7 @@ interface NEntry {
55
55
  priority?: number;
56
56
  images?: string[];
57
57
  title?: string; // имя записи (name/title из генератора) — для списка «SEO страниц» на платформе
58
+ group?: string; // явный раздел карты (напр. "blog") — переопределяет группировку по первому сегменту URL
58
59
  }
59
60
 
60
61
  // Авто-репорт списка страниц на платформу: у адаптера есть ВСЕ URL сайта (из генератора
@@ -144,6 +145,8 @@ function groupOfUrl(loc: string): string {
144
145
  const segs = path.split("/").filter(Boolean);
145
146
  return segs.length <= 1 ? "pages" : dec(segs[0]);
146
147
  }
148
+ // Раздел записи: явный e.group (напр. блог) важнее эвристики по первому сегменту URL.
149
+ const groupOf = (e: NEntry): string => e.group ?? groupOfUrl(e.loc);
147
150
 
148
151
  // Нормализованный путь записи (декодированный, без хвостового слэша) — для сравнения
149
152
  // с excludedRoutes (точное совпадение пути).
@@ -207,6 +210,15 @@ export async function seoWrapSitemap(
207
210
  const origin = originOf(entries);
208
211
  if (!origin || entries.length === 0) return EMPTY;
209
212
 
213
+ // Блог живёт файлами в репо (git-native) и в генераторе карты сайта его нет — добавляем его записи
214
+ // сами, на сборке из content/blog (раздел карты "blog"). node:fs грузим динамически; нет блога → пропуск.
215
+ try {
216
+ const bf = await import("./blog-fs");
217
+ if (bf.fsAvailable()) {
218
+ for (const b of bf.fsBlogSitemapEntries(origin)) entries.push({ loc: b.loc, lastmod: b.lastmod, group: "blog" });
219
+ }
220
+ } catch { /* нет модуля/файлов → без блога */ }
221
+
210
222
  // Отдаём платформе ПОЛНЫЙ список страниц (до исключений) — только на индексном/плоском
211
223
  // вызове (у него все URL). Await + троттлинг: доставка надёжна, задержка редка (карта кэш 300с).
212
224
  if (kind === "index" || kind === "flat") await reportPages(entries);
@@ -221,21 +233,21 @@ export async function seoWrapSitemap(
221
233
  const excluded = new Set(cfg.excludedGroups);
222
234
 
223
235
  // flat — одна карта со всеми URL (без под-карт); используется, когда под-карты недоступны.
224
- if (kind === "flat") return urlsetXml(origin, entries.filter((e) => !excluded.has(groupOfUrl(e.loc))));
236
+ if (kind === "flat") return urlsetXml(origin, entries.filter((e) => !excluded.has(groupOf(e))));
225
237
 
226
238
  if (kind === "child") {
227
239
  const g = (group ?? "").replace(/\.xml$/i, "");
228
240
  if (excluded.has(g)) return EMPTY; // раздел выключен → пустая (валидная) карта
229
- return urlsetXml(origin, entries.filter((e) => groupOfUrl(e.loc) === g));
241
+ return urlsetXml(origin, entries.filter((e) => groupOf(e) === g));
230
242
  }
231
243
 
232
244
  // index
233
- const groups = [...new Set(entries.map((e) => groupOfUrl(e.loc)))].sort().filter((g) => !excluded.has(g));
245
+ const groups = [...new Set(entries.map((e) => groupOf(e)))].sort().filter((g) => !excluded.has(g));
234
246
  if (groups.length === 0) return EMPTY; // всё выключено → пустая карта (backstop)
235
247
  if (groups.length === 1) {
236
248
  // остался один раздел → отдаём его urlset (только его записи, не все!)
237
249
  const only = groups[0];
238
- return urlsetXml(origin, entries.filter((e) => groupOfUrl(e.loc) === only));
250
+ return urlsetXml(origin, entries.filter((e) => groupOf(e) === only));
239
251
  }
240
252
  return indexXml(origin, groups);
241
253
  }