@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.
- package/README.md +14 -0
- package/bin/cli.mjs +16 -15
- package/blog-fs.ts +164 -0
- package/blog.tsx +48 -243
- package/dist/blog-fs.d.ts +11 -0
- package/dist/blog-fs.js +199 -0
- package/dist/blog.d.ts +6 -18
- package/dist/blog.js +57 -231
- package/dist/sanitize.d.ts +1 -0
- package/dist/sanitize.js +76 -0
- package/dist/sitemap-build.js +49 -4
- package/package.json +3 -1
- package/sanitize.ts +56 -0
- package/sitemap-build.ts +16 -4
package/dist/sanitize.js
ADDED
|
@@ -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
|
+
}
|
package/dist/sitemap-build.js
CHANGED
|
@@ -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(
|
|
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) =>
|
|
243
|
+
return urlsetXml(origin, entries.filter((e) => groupOf(e) === g));
|
|
199
244
|
}
|
|
200
245
|
// index
|
|
201
|
-
const groups = [...new Set(entries.map((e) =>
|
|
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) =>
|
|
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.
|
|
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, """)}"`);
|
|
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(
|
|
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) =>
|
|
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) =>
|
|
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) =>
|
|
250
|
+
return urlsetXml(origin, entries.filter((e) => groupOf(e) === only));
|
|
239
251
|
}
|
|
240
252
|
return indexXml(origin, groups);
|
|
241
253
|
}
|