@webnumseoagent/next 0.5.0 → 0.6.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/bin/cli.mjs CHANGED
@@ -131,7 +131,7 @@ async function init() {
131
131
  // Вендор-файлы адаптера — генерируемые (их не правят руками): при повторном init ОБНОВЛЯЕМ,
132
132
  // если содержимое изменилось. Иначе апгрейд оставит старый client.ts без новых экспортов
133
133
  // (напр. seoMerchantFeedXml), а свежесозданный роут импортнёт несуществующий символ → build падёт.
134
- for (const f of ["client.ts", "jsonld.tsx", "sitemap-xsl.ts", "sitemap-build.ts", "redirects.ts", "blog.tsx"]) {
134
+ for (const f of ["client.ts", "jsonld.tsx", "sitemap-xsl.ts", "sitemap-build.ts", "redirects.ts", "blog.tsx", "blog-fs.ts", "sanitize.ts"]) {
135
135
  const dest = path.join(vendor, f);
136
136
  const src = await fs.readFile(path.join(pkgRoot, f));
137
137
  const cur = await fs.readFile(dest).catch(() => null);
@@ -426,7 +426,7 @@ async function init() {
426
426
  } else {
427
427
  madeArticle = await ensureFile(
428
428
  path.join(root, appDir, `blog/[slug]/page.${jsxExt}`),
429
- `import { notFound } from "next/navigation";\nimport { seoBlogPost, SeoBlogArticle } from "${blogFor("blog/[slug]/page.tsx")}";\nexport const revalidate = 300;\n\nexport async function generateMetadata(${mdParam}) {\n const post = await seoBlogPost(params.slug);\n if (!post) return {};\n return { title: post.title, description: post.excerpt, openGraph: { images: post.cover ? [post.cover] : [] } };\n}\n\nexport default async function BlogPostPage(${mdParam}) {\n const post = await seoBlogPost(params.slug);\n if (!post) notFound();\n return <SeoBlogArticle post={post} />;\n}\n`,
429
+ `import { notFound } from "next/navigation";\nimport { seoBlogPost, seoBlogStaticParams, SeoBlogArticle } from "${blogFor("blog/[slug]/page.tsx")}";\nexport const revalidate = 300;\n\n// Пре-рендер всех статей на сборке (SSG для файлового источника; для supabase — известные статьи, новые добираются ISR).\nexport async function generateStaticParams() {\n return seoBlogStaticParams();\n}\n\nexport async function generateMetadata(${mdParam}) {\n const post = await seoBlogPost(params.slug);\n if (!post) return {};\n return { title: post.title, description: post.excerpt, openGraph: { images: post.cover ? [post.cover] : [] } };\n}\n\nexport default async function BlogPostPage(${mdParam}) {\n const post = await seoBlogPost(params.slug);\n if (!post) notFound();\n return <SeoBlogArticle post={post} />;\n}\n`,
430
430
  );
431
431
  }
432
432
  if (madeIndex || madeArticle) {
@@ -713,7 +713,7 @@ async function blogPage() {
713
713
  const localeProp = param ? ` locale={params.${param}}` : "";
714
714
  await writeOurRoute(
715
715
  path.join(root, appDir, under, `blog/[slug]/page.${jsxExt}`),
716
- `import { notFound } from "next/navigation";\nimport { seoBlogPost, SeoBlogArticle } from "${blogFor(`${under}blog/[slug]/page.tsx`)}";\nexport const revalidate = 300;\n\nexport async function generateMetadata(${artSig}) {\n const post = await seoBlogPost(params.slug${localeArg});\n if (!post) return {};\n return { title: post.title, description: post.excerpt, openGraph: { images: post.cover ? [post.cover] : [] } };\n}\n\nexport default async function BlogPostPage(${artSig}) {\n const post = await seoBlogPost(params.slug${localeArg});\n if (!post) notFound();\n return <SeoBlogArticle post={post}${localeProp} />;\n}\n`,
716
+ `import { notFound } from "next/navigation";\nimport { seoBlogPost, seoBlogStaticParams, SeoBlogArticle } from "${blogFor(`${under}blog/[slug]/page.tsx`)}";\nexport const revalidate = 300;\n\n// Пре-рендер статей на сборке (SSG для файлового источника). Слаги не зависят от локали; непокрытые\n// комбинации добираются ISR (dynamicParams по умолчанию true).\nexport async function generateStaticParams() {\n return seoBlogStaticParams();\n}\n\nexport async function generateMetadata(${artSig}) {\n const post = await seoBlogPost(params.slug${localeArg});\n if (!post) return {};\n return { title: post.title, description: post.excerpt, openGraph: { images: post.cover ? [post.cover] : [] } };\n}\n\nexport default async function BlogPostPage(${artSig}) {\n const post = await seoBlogPost(params.slug${localeArg});\n if (!post) notFound();\n return <SeoBlogArticle post={post}${localeProp} />;\n}\n`,
717
717
  );
718
718
  }
719
719
 
package/blog-fs.ts ADDED
@@ -0,0 +1,125 @@
1
+ // Файловый источник блога (Фаза 4 blogfs): читает статьи из репозитория сайта (папка content/blog,
2
+ // формат blogfs v1 — см. docs/blog-git-delivery/FORMAT.md) на СБОРКЕ, без сети и без Supabase.
3
+ // Отдаёт те же формы, что и supabase-источник (SeoBlogSummary / SeoBlogPostFull) — blog.tsx роутит.
4
+ // FAIL-SAFE: любая ошибка/битый файл → пропуск, сборка НИКОГДА не падает.
5
+ import { readdirSync, readFileSync, existsSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { sanitizeDeliveryHtml } from "./sanitize";
8
+ import type { SeoBlogSummary, SeoBlogPostFull } from "./blog";
9
+
10
+ const BLOG_DIR = process.env.SEOAGENT_BLOG_DIR ?? "content/blog";
11
+ const SUPPORTED_MAJOR = 1; // schema "seoagent.blog/v1"
12
+
13
+ function root(): string {
14
+ return join(process.cwd(), BLOG_DIR);
15
+ }
16
+
17
+ interface LocaleEntry { title?: string; excerpt?: string | null; metaTitle?: string | null; body?: string }
18
+ interface Meta {
19
+ schema?: string; slug?: string; status?: string; defaultLocale?: string; bodyFormat?: string;
20
+ coverImage?: string; publishedAt?: string | null; locales?: Record<string, LocaleEntry>; features?: string[];
21
+ }
22
+ // Возможности формата, которые НЕ меняют рендер тела (ридер v1 их спокойно поддерживает). Любую иную
23
+ // запись в features[] версия-пиннутый ридер трактует как «не умею» → пропуск статьи (forward-compat).
24
+ // source = лишь игнорируемый .tiptap.json сайдкар; local-assets = картинки лежат в public/ и уже
25
+ // указаны в теле локальным URL — ридеру ничего делать не нужно (тоже body-нейтрально).
26
+ const KNOWN_FEATURES = new Set(["source", "local-assets"]);
27
+
28
+ // Мажор схемы из строки "seoagent.blog/vN" (или число). null — не наш формат.
29
+ function schemaMajor(schema: unknown): number | null {
30
+ if (typeof schema === "number") return schema;
31
+ if (typeof schema === "string") { const m = schema.match(/\/v(\d+)$/); if (m) return parseInt(m[1], 10); }
32
+ return null;
33
+ }
34
+
35
+ function readDescriptor(): { defaultLocale?: string; locales?: string[]; schema?: string } | null {
36
+ try { return JSON.parse(readFileSync(join(root(), ".blogfs.json"), "utf8")); } catch { return null; }
37
+ }
38
+
39
+ // Есть ли файловый блог в репо (наличие .blogfs.json = git-сайт с ≥1 доставкой). Сигнал авто-детекта.
40
+ export function fsAvailable(): boolean {
41
+ return readDescriptor() !== null;
42
+ }
43
+
44
+ export function fsDefaultLocale(): string {
45
+ const d = readDescriptor()?.defaultLocale;
46
+ return typeof d === "string" ? d : ""; // битый дескриптор мог дать не-строку → не роняем split ниже
47
+ }
48
+
49
+ // Собрать meta.json всех статей (glob по папкам). Пропускает битые/незнакомые версии/черновики.
50
+ function listMetas(): { dir: string; meta: Meta }[] {
51
+ const r = root();
52
+ let names: string[];
53
+ try { names = readdirSync(r, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name); }
54
+ catch { return []; }
55
+ const out: { dir: string; meta: Meta }[] = [];
56
+ for (const name of names) {
57
+ if (name.startsWith(".") || name.startsWith("_")) continue; // служебные (assets и т.п.)
58
+ let meta: Meta;
59
+ try { meta = JSON.parse(readFileSync(join(r, name, "meta.json"), "utf8")); }
60
+ catch { continue; } // нет meta.json / битый JSON → пропуск (не роняем сборку)
61
+ // Валидный JSON может быть null/массивом/примитивом — доступ к полям тогда бросит. Пропускаем.
62
+ if (!meta || typeof meta !== "object" || Array.isArray(meta)) continue;
63
+ const major = schemaMajor(meta.schema);
64
+ if (major === null || major > SUPPORTED_MAJOR) { // незнакомая/будущая версия → пропуск с предупреждением
65
+ try { console.warn(`[seoagent blogfs] пропуск ${name}: несовместимая версия схемы (${String(meta.schema)})`); } catch { /* noop */ }
66
+ continue;
67
+ }
68
+ // Незнакомая body-возможность (mdx/frontmatter/…) в том же мажоре → ридер её не умеет → пропуск.
69
+ if (Array.isArray(meta.features) && meta.features.some((f) => !KNOWN_FEATURES.has(f))) {
70
+ try { console.warn(`[seoagent blogfs] пропуск ${name}: неподдерживаемая возможность (${meta.features.join(",")})`); } catch { /* noop */ }
71
+ continue;
72
+ }
73
+ if (meta.status !== "published" || !meta.slug || !meta.locales) continue;
74
+ out.push({ dir: name, meta });
75
+ }
76
+ return out;
77
+ }
78
+
79
+ // Запись локали: точная → без региона (en-US→en) → defaultLocale → первая доступная.
80
+ function pickLocale(meta: Meta, locale: string): LocaleEntry {
81
+ const locs = meta.locales ?? {};
82
+ const loc = typeof locale === "string" ? locale : ""; // на всякий случай — locale мог прийти не строкой
83
+ const base = loc ? loc.split("-")[0] : "";
84
+ return locs[loc] || (base && locs[base]) || (meta.defaultLocale ? locs[meta.defaultLocale] : undefined) || locs[Object.keys(locs)[0]] || {};
85
+ }
86
+
87
+ function summary(meta: Meta, locale: string): SeoBlogSummary {
88
+ const e = pickLocale(meta, locale);
89
+ return {
90
+ slug: meta.slug ?? "",
91
+ title: e.title ?? "",
92
+ excerpt: e.excerpt ?? "",
93
+ cover: meta.coverImage ?? null,
94
+ date: meta.publishedAt ?? null,
95
+ };
96
+ }
97
+
98
+ // Список статей для локали, по publishedAt убыв., до limit.
99
+ export function fsBlogList(locale: string, limit: number): SeoBlogSummary[] {
100
+ const rows = listMetas().map(({ meta }) => summary(meta, locale)).filter((p) => p.slug && p.title);
101
+ rows.sort((a, b) => (b.date ?? "").localeCompare(a.date ?? "")); // publishedAt desc (ISO — код-юнит ок)
102
+ return rows.slice(0, Math.max(0, limit));
103
+ }
104
+
105
+ // Одна статья по slug (ищем по meta.slug — папка может быть по id при pathStrategy=id).
106
+ export function fsBlogPost(slug: string, locale: string): SeoBlogPostFull | null {
107
+ const hit = listMetas().find((x) => x.meta.slug === slug);
108
+ if (!hit) return null;
109
+ const e = pickLocale(hit.meta, locale);
110
+ let bodyRaw = "";
111
+ // Имя файла тела берётся из meta.json репо — только безопасное имя (без "/" и ".."), защита от traversal.
112
+ if (e.body && /^[A-Za-z0-9._-]+$/.test(e.body)) { try { bodyRaw = readFileSync(join(root(), hit.dir, e.body), "utf8"); } catch { bodyRaw = ""; } }
113
+ const s = summary(hit.meta, locale);
114
+ return {
115
+ ...s,
116
+ // Тело уже санитайзено при доставке; чистим повторно (защита-в-глубину). bodyFormat="mdx" появится позже.
117
+ bodyHtml: sanitizeDeliveryHtml(bodyRaw),
118
+ theme: undefined, // файловый источник не несёт тему платформы (используются --sa-* дефолты)
119
+ };
120
+ }
121
+
122
+ // Есть ли вообще папка блога (для мягких проверок).
123
+ export function fsDirExists(): boolean {
124
+ try { return existsSync(root()); } catch { return false; }
125
+ }
package/blog.tsx CHANGED
@@ -15,11 +15,24 @@
15
15
  // return <SeoBlogArticle post={post} />
16
16
 
17
17
  import type { CSSProperties } from "react";
18
+ import { sanitizeDeliveryHtml } from "./sanitize";
18
19
 
19
20
  const API = process.env.SEOAGENT_API_BASE ?? "";
20
21
  const SITE = process.env.SEOAGENT_SITE_ID ?? "";
21
22
  const TOKEN = process.env.SEOAGENT_TOKEN ?? "";
22
23
  const REVALIDATE = 300;
24
+ // Источник блога: "git"/"fs" — читать файлы content/blog из репо (Фаза 4); "supabase" — REST API сайта;
25
+ // не задан → авто (файлы, если в репо есть .blogfs.json, иначе supabase). Файловый источник грузим
26
+ // динамически (node:fs только когда он реально нужен — supabase-сайты его не тянут).
27
+ const BLOG_SOURCE = (process.env.SEOAGENT_BLOG_SOURCE ?? "").toLowerCase();
28
+ async function fsActive(): Promise<typeof import("./blog-fs") | null> {
29
+ if (BLOG_SOURCE === "supabase") return null;
30
+ try {
31
+ const m = await import("./blog-fs");
32
+ if (BLOG_SOURCE === "git" || BLOG_SOURCE === "fs" || m.fsAvailable()) return m;
33
+ } catch { /* нет модуля/fs недоступен → supabase */ }
34
+ return null;
35
+ }
23
36
 
24
37
  export interface SeoBlogMapping {
25
38
  title: string; excerpt?: string; body: string;
@@ -134,17 +147,29 @@ async function fetchPost(cfg: SeoBlogConfig, slug: string, locale: string): Prom
134
147
  }
135
148
 
136
149
  // ── Публичные data-функции (для generateMetadata / generateStaticParams) ──
150
+ // Роутинг источника: файловый (репо) при наличии, иначе supabase (REST API сайта).
137
151
  export async function seoBlogList(opts?: { locale?: string; limit?: number }): Promise<SeoBlogSummary[]> {
152
+ const fs = await fsActive();
153
+ if (fs) return fs.fsBlogList(opts?.locale ?? fs.fsDefaultLocale(), opts?.limit ?? 100);
138
154
  const cfg = await fetchBlogConfig();
139
155
  if (!cfg) return [];
140
156
  return fetchPosts(cfg, opts?.locale ?? firstLocale(cfg), opts?.limit ?? 100);
141
157
  }
142
158
  export async function seoBlogPost(slug: string, opts?: { locale?: string }): Promise<SeoBlogPostFull | null> {
159
+ const fs = await fsActive();
160
+ if (fs) return fs.fsBlogPost(slug, opts?.locale ?? fs.fsDefaultLocale());
143
161
  const cfg = await fetchBlogConfig();
144
162
  if (!cfg) return null;
145
163
  return fetchPost(cfg, slug, opts?.locale ?? firstLocale(cfg));
146
164
  }
147
165
 
166
+ // Параметры для generateStaticParams роута /blog/[slug]: пре-рендер всех статей на сборке (SSG для
167
+ // файлового источника; для supabase — пре-рендер известных статей, новые добираются ISR по revalidate).
168
+ export async function seoBlogStaticParams(opts?: { locale?: string }): Promise<{ slug: string }[]> {
169
+ const list = await seoBlogList({ locale: opts?.locale, limit: 100000 });
170
+ return list.map((p) => ({ slug: p.slug }));
171
+ }
172
+
148
173
  // ── Мини-разметка (## / - / **) → HTML (для legacy-формата; текст экранируем). ──
149
174
  function esc(s: string): string {
150
175
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
@@ -169,62 +194,6 @@ function miniMarkupToHtml(src: string): string {
169
194
  return out.join("");
170
195
  }
171
196
 
172
- // ── Санитайзер HTML (защита-в-глубину перед dangerouslySetInnerHTML) — ЗЕРКАЛО доставки
173
- // (src/lib/blog/blog-delivery.ts + Edge blog-autopilot). Тело уже санитайзено при доставке, но
174
- // если у сайта не настроен RLS (провиженинг опционален) — это последний рубеж. ──
175
- const SANITIZE_ALLOWED_TAGS = new Set([
176
- "h1", "h2", "h3", "h4", "h5", "h6", "p", "br", "hr", "strong", "b", "em", "i", "u", "s",
177
- "ul", "ol", "li", "blockquote", "a", "img", "figure", "figcaption",
178
- "table", "thead", "tbody", "tfoot", "tr", "th", "td", "caption", "code", "pre", "span", "div",
179
- ]);
180
- const SANITIZE_ALLOWED_ATTRS = /^(href|src|alt|title|target|rel|width|height|loading|colspan|rowspan|scope|start|type)$/;
181
- const SANITIZE_VOID = new Set(["br", "hr", "img"]);
182
- function decodeSchemeEntities(v: string): string {
183
- return v
184
- .replace(/&#x([0-9a-fA-F]+);?/g, (_m, h) => { try { return String.fromCodePoint(parseInt(h, 16)); } catch { return ""; } })
185
- .replace(/&#(\d+);?/g, (_m, d) => { try { return String.fromCodePoint(parseInt(d, 10)); } catch { return ""; } })
186
- .replace(/&(colon|tab|newline|lf|cr|sol|amp);?/gi, (m) => {
187
- const k = m.replace(/[&;]/g, "").toLowerCase();
188
- return ({ colon: ":", sol: "/", amp: "&", tab: " ", newline: " ", lf: " ", cr: " " } as Record<string, string>)[k] ?? " ";
189
- });
190
- }
191
- function safeUrl(val: string): boolean {
192
- const v = decodeSchemeEntities(val).toLowerCase().replace(/\s/g, "");
193
- if (!/^[a-z][a-z0-9+.-]*:/.test(v)) return true;
194
- return /^(https?:|mailto:|tel:)/.test(v);
195
- }
196
- function sanitizeAttrs(tag: string, attrsRaw: string): string {
197
- const out: string[] = [];
198
- const re = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>]+))/g;
199
- let m: RegExpExecArray | null;
200
- let href = "";
201
- while ((m = re.exec(attrsRaw))) {
202
- const name = m[1].toLowerCase();
203
- const val = m[3] ?? m[4] ?? m[5] ?? "";
204
- if (name.startsWith("on") || name === "style" || name === "srcset") continue;
205
- if ((name === "href" || name === "src") && !safeUrl(val)) continue;
206
- if (!SANITIZE_ALLOWED_ATTRS.test(name)) continue;
207
- if (name === "href") href = val;
208
- out.push(`${name}="${val.replace(/"/g, "&quot;")}"`);
209
- }
210
- if (tag === "a" && !out.some((a) => a.startsWith("rel="))) {
211
- if (/^(https?:)?\/\//i.test(href.trim())) out.push('rel="noopener nofollow"');
212
- }
213
- return out.length ? " " + out.join(" ") : "";
214
- }
215
- function sanitizeDeliveryHtml(html: string): string {
216
- let s = html || "";
217
- s = s.replace(/<(script|style|iframe|object|embed|form|noscript|template|svg|math)\b[\s\S]*?<\/\1\s*>/gi, "");
218
- s = s.replace(/<!--[\s\S]*?-->/g, "");
219
- s = s.replace(/<(\/?)([a-zA-Z][a-zA-Z0-9]*)\b((?:"[^"]*"|'[^']*'|[^>])*)>/g, (_full, slash, tag, attrs) => {
220
- const t = tag.toLowerCase();
221
- if (!SANITIZE_ALLOWED_TAGS.has(t)) return "";
222
- if (slash) return `</${t}>`;
223
- return `<${t}${sanitizeAttrs(t, attrs)}${SANITIZE_VOID.has(t) ? " /" : ""}>`;
224
- });
225
- return s.trim();
226
- }
227
-
228
197
  // ── Компоненты (серверные) ──────────────────────────────────────────────────────────────────
229
198
  // Цвета/рамки/скругления — через СОБСТВЕННЫЕ переменные пакета (--sa-*) с нейтральным дефолтом.
230
199
  // НЕ ссылаемся на токены сайта напрямую: shadcn/Tailwind хранят их как сырые HSL-компоненты
@@ -286,14 +255,15 @@ const L = (map: Record<string, string>, locale: string, fb: string) => map[local
286
255
  // через ?show=, серверно — без клиентского JS, дружелюбно к SEO). Всегда рендерит H1 (даже пустой блог),
287
256
  // чтобы страница не была пустой. perPage — размер «страницы» (дефолт 20), show — сколько показать сейчас.
288
257
  export async function SeoBlogIndex(props: { locale?: string; basePath?: string; title?: string; show?: number; perPage?: number }) {
289
- const cfg = await fetchBlogConfig();
290
- const locale = props.locale ?? (cfg ? firstLocale(cfg) : "");
258
+ const fs = await fsActive();
259
+ const cfg = fs ? null : await fetchBlogConfig();
260
+ const locale = props.locale ?? (fs ? fs.fsDefaultLocale() : (cfg ? firstLocale(cfg) : ""));
291
261
  const title = props.title ?? L(BLOG_TITLE, locale, "Blog");
292
262
  const per = Math.max(1, Math.floor(props.perPage ?? 20));
293
263
  const show = Math.max(per, Math.floor(Number(props.show) || per));
294
264
  const basePath = (props.basePath ?? "/blog").replace(/\/+$/, "");
295
265
  // Тянем show+1, чтобы понять, есть ли ещё (для кнопки «Показать больше»).
296
- const posts = cfg ? await fetchPosts(cfg, locale, show + 1) : [];
266
+ const posts = fs ? fs.fsBlogList(locale, show + 1) : (cfg ? await fetchPosts(cfg, locale, show + 1) : []);
297
267
  const hasMore = posts.length > show;
298
268
  const visible = posts.slice(0, show);
299
269
  return (
@@ -0,0 +1,6 @@
1
+ import type { SeoBlogSummary, SeoBlogPostFull } from "./blog";
2
+ export declare function fsAvailable(): boolean;
3
+ export declare function fsDefaultLocale(): string;
4
+ export declare function fsBlogList(locale: string, limit: number): SeoBlogSummary[];
5
+ export declare function fsBlogPost(slug: string, locale: string): SeoBlogPostFull | null;
6
+ export declare function fsDirExists(): boolean;
@@ -0,0 +1,153 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.fsAvailable = fsAvailable;
4
+ exports.fsDefaultLocale = fsDefaultLocale;
5
+ exports.fsBlogList = fsBlogList;
6
+ exports.fsBlogPost = fsBlogPost;
7
+ exports.fsDirExists = fsDirExists;
8
+ // Файловый источник блога (Фаза 4 blogfs): читает статьи из репозитория сайта (папка content/blog,
9
+ // формат blogfs v1 — см. docs/blog-git-delivery/FORMAT.md) на СБОРКЕ, без сети и без Supabase.
10
+ // Отдаёт те же формы, что и supabase-источник (SeoBlogSummary / SeoBlogPostFull) — blog.tsx роутит.
11
+ // FAIL-SAFE: любая ошибка/битый файл → пропуск, сборка НИКОГДА не падает.
12
+ const node_fs_1 = require("node:fs");
13
+ const node_path_1 = require("node:path");
14
+ const sanitize_1 = require("./sanitize");
15
+ const BLOG_DIR = process.env.SEOAGENT_BLOG_DIR ?? "content/blog";
16
+ const SUPPORTED_MAJOR = 1; // schema "seoagent.blog/v1"
17
+ function root() {
18
+ return (0, node_path_1.join)(process.cwd(), BLOG_DIR);
19
+ }
20
+ // Возможности формата, которые НЕ меняют рендер тела (ридер v1 их спокойно поддерживает). Любую иную
21
+ // запись в features[] версия-пиннутый ридер трактует как «не умею» → пропуск статьи (forward-compat).
22
+ // source = лишь игнорируемый .tiptap.json сайдкар; local-assets = картинки лежат в public/ и уже
23
+ // указаны в теле локальным URL — ридеру ничего делать не нужно (тоже body-нейтрально).
24
+ const KNOWN_FEATURES = new Set(["source", "local-assets"]);
25
+ // Мажор схемы из строки "seoagent.blog/vN" (или число). null — не наш формат.
26
+ function schemaMajor(schema) {
27
+ if (typeof schema === "number")
28
+ return schema;
29
+ if (typeof schema === "string") {
30
+ const m = schema.match(/\/v(\d+)$/);
31
+ if (m)
32
+ return parseInt(m[1], 10);
33
+ }
34
+ return null;
35
+ }
36
+ function readDescriptor() {
37
+ try {
38
+ return JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(root(), ".blogfs.json"), "utf8"));
39
+ }
40
+ catch {
41
+ return null;
42
+ }
43
+ }
44
+ // Есть ли файловый блог в репо (наличие .blogfs.json = git-сайт с ≥1 доставкой). Сигнал авто-детекта.
45
+ function fsAvailable() {
46
+ return readDescriptor() !== null;
47
+ }
48
+ function fsDefaultLocale() {
49
+ const d = readDescriptor()?.defaultLocale;
50
+ return typeof d === "string" ? d : ""; // битый дескриптор мог дать не-строку → не роняем split ниже
51
+ }
52
+ // Собрать meta.json всех статей (glob по папкам). Пропускает битые/незнакомые версии/черновики.
53
+ function listMetas() {
54
+ const r = root();
55
+ let names;
56
+ try {
57
+ names = (0, node_fs_1.readdirSync)(r, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
58
+ }
59
+ catch {
60
+ return [];
61
+ }
62
+ const out = [];
63
+ for (const name of names) {
64
+ if (name.startsWith(".") || name.startsWith("_"))
65
+ continue; // служебные (assets и т.п.)
66
+ let meta;
67
+ try {
68
+ meta = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(r, name, "meta.json"), "utf8"));
69
+ }
70
+ catch {
71
+ continue;
72
+ } // нет meta.json / битый JSON → пропуск (не роняем сборку)
73
+ // Валидный JSON может быть null/массивом/примитивом — доступ к полям тогда бросит. Пропускаем.
74
+ if (!meta || typeof meta !== "object" || Array.isArray(meta))
75
+ continue;
76
+ const major = schemaMajor(meta.schema);
77
+ if (major === null || major > SUPPORTED_MAJOR) { // незнакомая/будущая версия → пропуск с предупреждением
78
+ try {
79
+ console.warn(`[seoagent blogfs] пропуск ${name}: несовместимая версия схемы (${String(meta.schema)})`);
80
+ }
81
+ catch { /* noop */ }
82
+ continue;
83
+ }
84
+ // Незнакомая body-возможность (mdx/frontmatter/…) в том же мажоре → ридер её не умеет → пропуск.
85
+ if (Array.isArray(meta.features) && meta.features.some((f) => !KNOWN_FEATURES.has(f))) {
86
+ try {
87
+ console.warn(`[seoagent blogfs] пропуск ${name}: неподдерживаемая возможность (${meta.features.join(",")})`);
88
+ }
89
+ catch { /* noop */ }
90
+ continue;
91
+ }
92
+ if (meta.status !== "published" || !meta.slug || !meta.locales)
93
+ continue;
94
+ out.push({ dir: name, meta });
95
+ }
96
+ return out;
97
+ }
98
+ // Запись локали: точная → без региона (en-US→en) → defaultLocale → первая доступная.
99
+ function pickLocale(meta, locale) {
100
+ const locs = meta.locales ?? {};
101
+ const loc = typeof locale === "string" ? locale : ""; // на всякий случай — locale мог прийти не строкой
102
+ const base = loc ? loc.split("-")[0] : "";
103
+ return locs[loc] || (base && locs[base]) || (meta.defaultLocale ? locs[meta.defaultLocale] : undefined) || locs[Object.keys(locs)[0]] || {};
104
+ }
105
+ function summary(meta, locale) {
106
+ const e = pickLocale(meta, locale);
107
+ return {
108
+ slug: meta.slug ?? "",
109
+ title: e.title ?? "",
110
+ excerpt: e.excerpt ?? "",
111
+ cover: meta.coverImage ?? null,
112
+ date: meta.publishedAt ?? null,
113
+ };
114
+ }
115
+ // Список статей для локали, по publishedAt убыв., до limit.
116
+ function fsBlogList(locale, limit) {
117
+ const rows = listMetas().map(({ meta }) => summary(meta, locale)).filter((p) => p.slug && p.title);
118
+ rows.sort((a, b) => (b.date ?? "").localeCompare(a.date ?? "")); // publishedAt desc (ISO — код-юнит ок)
119
+ return rows.slice(0, Math.max(0, limit));
120
+ }
121
+ // Одна статья по slug (ищем по meta.slug — папка может быть по id при pathStrategy=id).
122
+ function fsBlogPost(slug, locale) {
123
+ const hit = listMetas().find((x) => x.meta.slug === slug);
124
+ if (!hit)
125
+ return null;
126
+ const e = pickLocale(hit.meta, locale);
127
+ let bodyRaw = "";
128
+ // Имя файла тела берётся из meta.json репо — только безопасное имя (без "/" и ".."), защита от traversal.
129
+ if (e.body && /^[A-Za-z0-9._-]+$/.test(e.body)) {
130
+ try {
131
+ bodyRaw = (0, node_fs_1.readFileSync)((0, node_path_1.join)(root(), hit.dir, e.body), "utf8");
132
+ }
133
+ catch {
134
+ bodyRaw = "";
135
+ }
136
+ }
137
+ const s = summary(hit.meta, locale);
138
+ return {
139
+ ...s,
140
+ // Тело уже санитайзено при доставке; чистим повторно (защита-в-глубину). bodyFormat="mdx" появится позже.
141
+ bodyHtml: (0, sanitize_1.sanitizeDeliveryHtml)(bodyRaw),
142
+ theme: undefined, // файловый источник не несёт тему платформы (используются --sa-* дефолты)
143
+ };
144
+ }
145
+ // Есть ли вообще папка блога (для мягких проверок).
146
+ function fsDirExists() {
147
+ try {
148
+ return (0, node_fs_1.existsSync)(root());
149
+ }
150
+ catch {
151
+ return false;
152
+ }
153
+ }
package/dist/blog.d.ts CHANGED
@@ -42,6 +42,11 @@ export declare function seoBlogList(opts?: {
42
42
  export declare function seoBlogPost(slug: string, opts?: {
43
43
  locale?: string;
44
44
  }): Promise<SeoBlogPostFull | null>;
45
+ export declare function seoBlogStaticParams(opts?: {
46
+ locale?: string;
47
+ }): Promise<{
48
+ slug: string;
49
+ }[]>;
45
50
  export declare function SeoBlogIndex(props: {
46
51
  locale?: string;
47
52
  basePath?: string;
package/dist/blog.js CHANGED
@@ -1,14 +1,64 @@
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.seoBlogList = seoBlogList;
4
37
  exports.seoBlogPost = seoBlogPost;
38
+ exports.seoBlogStaticParams = seoBlogStaticParams;
5
39
  exports.SeoBlogIndex = SeoBlogIndex;
6
40
  exports.SeoBlogArticle = SeoBlogArticle;
7
41
  const jsx_runtime_1 = require("react/jsx-runtime");
42
+ const sanitize_1 = require("./sanitize");
8
43
  const API = process.env.SEOAGENT_API_BASE ?? "";
9
44
  const SITE = process.env.SEOAGENT_SITE_ID ?? "";
10
45
  const TOKEN = process.env.SEOAGENT_TOKEN ?? "";
11
46
  const REVALIDATE = 300;
47
+ // Источник блога: "git"/"fs" — читать файлы content/blog из репо (Фаза 4); "supabase" — REST API сайта;
48
+ // не задан → авто (файлы, если в репо есть .blogfs.json, иначе supabase). Файловый источник грузим
49
+ // динамически (node:fs только когда он реально нужен — supabase-сайты его не тянут).
50
+ const BLOG_SOURCE = (process.env.SEOAGENT_BLOG_SOURCE ?? "").toLowerCase();
51
+ async function fsActive() {
52
+ if (BLOG_SOURCE === "supabase")
53
+ return null;
54
+ try {
55
+ const m = await Promise.resolve().then(() => __importStar(require("./blog-fs")));
56
+ if (BLOG_SOURCE === "git" || BLOG_SOURCE === "fs" || m.fsAvailable())
57
+ return m;
58
+ }
59
+ catch { /* нет модуля/fs недоступен → supabase */ }
60
+ return null;
61
+ }
12
62
  function firstLocale(cfg) {
13
63
  return (cfg.locales ?? []).filter(Boolean)[0] ?? "";
14
64
  }
@@ -111,23 +161,36 @@ async function fetchPost(cfg, slug, locale) {
111
161
  excerpt: m.excerpt ? val(m.excerpt) : "",
112
162
  cover: m.image ? r[m.image] ?? null : null,
113
163
  date: m.publishedAt ? r[m.publishedAt] ?? null : null,
114
- bodyHtml: cfg.format === "minimarkup" ? miniMarkupToHtml(rawBody) : sanitizeDeliveryHtml(rawBody),
164
+ bodyHtml: cfg.format === "minimarkup" ? miniMarkupToHtml(rawBody) : (0, sanitize_1.sanitizeDeliveryHtml)(rawBody),
115
165
  theme: cfg.theme, // тему тащим с постом — чтобы SeoBlogArticle применил её без отдельного запроса
116
166
  };
117
167
  }
118
168
  // ── Публичные data-функции (для generateMetadata / generateStaticParams) ──
169
+ // Роутинг источника: файловый (репо) при наличии, иначе supabase (REST API сайта).
119
170
  async function seoBlogList(opts) {
171
+ const fs = await fsActive();
172
+ if (fs)
173
+ return fs.fsBlogList(opts?.locale ?? fs.fsDefaultLocale(), opts?.limit ?? 100);
120
174
  const cfg = await fetchBlogConfig();
121
175
  if (!cfg)
122
176
  return [];
123
177
  return fetchPosts(cfg, opts?.locale ?? firstLocale(cfg), opts?.limit ?? 100);
124
178
  }
125
179
  async function seoBlogPost(slug, opts) {
180
+ const fs = await fsActive();
181
+ if (fs)
182
+ return fs.fsBlogPost(slug, opts?.locale ?? fs.fsDefaultLocale());
126
183
  const cfg = await fetchBlogConfig();
127
184
  if (!cfg)
128
185
  return null;
129
186
  return fetchPost(cfg, slug, opts?.locale ?? firstLocale(cfg));
130
187
  }
188
+ // Параметры для generateStaticParams роута /blog/[slug]: пре-рендер всех статей на сборке (SSG для
189
+ // файлового источника; для supabase — пре-рендер известных статей, новые добираются ISR по revalidate).
190
+ async function seoBlogStaticParams(opts) {
191
+ const list = await seoBlogList({ locale: opts?.locale, limit: 100000 });
192
+ return list.map((p) => ({ slug: p.slug }));
193
+ }
131
194
  // ── Мини-разметка (## / - / **) → HTML (для legacy-формата; текст экранируем). ──
132
195
  function esc(s) {
133
196
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
@@ -154,79 +217,6 @@ function miniMarkupToHtml(src) {
154
217
  }
155
218
  return out.join("");
156
219
  }
157
- // ── Санитайзер HTML (защита-в-глубину перед dangerouslySetInnerHTML) — ЗЕРКАЛО доставки
158
- // (src/lib/blog/blog-delivery.ts + Edge blog-autopilot). Тело уже санитайзено при доставке, но
159
- // если у сайта не настроен RLS (провиженинг опционален) — это последний рубеж. ──
160
- const SANITIZE_ALLOWED_TAGS = new Set([
161
- "h1", "h2", "h3", "h4", "h5", "h6", "p", "br", "hr", "strong", "b", "em", "i", "u", "s",
162
- "ul", "ol", "li", "blockquote", "a", "img", "figure", "figcaption",
163
- "table", "thead", "tbody", "tfoot", "tr", "th", "td", "caption", "code", "pre", "span", "div",
164
- ]);
165
- const SANITIZE_ALLOWED_ATTRS = /^(href|src|alt|title|target|rel|width|height|loading|colspan|rowspan|scope|start|type)$/;
166
- const SANITIZE_VOID = new Set(["br", "hr", "img"]);
167
- function decodeSchemeEntities(v) {
168
- return v
169
- .replace(/&#x([0-9a-fA-F]+);?/g, (_m, h) => { try {
170
- return String.fromCodePoint(parseInt(h, 16));
171
- }
172
- catch {
173
- return "";
174
- } })
175
- .replace(/&#(\d+);?/g, (_m, d) => { try {
176
- return String.fromCodePoint(parseInt(d, 10));
177
- }
178
- catch {
179
- return "";
180
- } })
181
- .replace(/&(colon|tab|newline|lf|cr|sol|amp);?/gi, (m) => {
182
- const k = m.replace(/[&;]/g, "").toLowerCase();
183
- return { colon: ":", sol: "/", amp: "&", tab: " ", newline: " ", lf: " ", cr: " " }[k] ?? " ";
184
- });
185
- }
186
- function safeUrl(val) {
187
- const v = decodeSchemeEntities(val).toLowerCase().replace(/\s/g, "");
188
- if (!/^[a-z][a-z0-9+.-]*:/.test(v))
189
- return true;
190
- return /^(https?:|mailto:|tel:)/.test(v);
191
- }
192
- function sanitizeAttrs(tag, attrsRaw) {
193
- const out = [];
194
- const re = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>]+))/g;
195
- let m;
196
- let href = "";
197
- while ((m = re.exec(attrsRaw))) {
198
- const name = m[1].toLowerCase();
199
- const val = m[3] ?? m[4] ?? m[5] ?? "";
200
- if (name.startsWith("on") || name === "style" || name === "srcset")
201
- continue;
202
- if ((name === "href" || name === "src") && !safeUrl(val))
203
- continue;
204
- if (!SANITIZE_ALLOWED_ATTRS.test(name))
205
- continue;
206
- if (name === "href")
207
- href = val;
208
- out.push(`${name}="${val.replace(/"/g, "&quot;")}"`);
209
- }
210
- if (tag === "a" && !out.some((a) => a.startsWith("rel="))) {
211
- if (/^(https?:)?\/\//i.test(href.trim()))
212
- out.push('rel="noopener nofollow"');
213
- }
214
- return out.length ? " " + out.join(" ") : "";
215
- }
216
- function sanitizeDeliveryHtml(html) {
217
- let s = html || "";
218
- s = s.replace(/<(script|style|iframe|object|embed|form|noscript|template|svg|math)\b[\s\S]*?<\/\1\s*>/gi, "");
219
- s = s.replace(/<!--[\s\S]*?-->/g, "");
220
- s = s.replace(/<(\/?)([a-zA-Z][a-zA-Z0-9]*)\b((?:"[^"]*"|'[^']*'|[^>])*)>/g, (_full, slash, tag, attrs) => {
221
- const t = tag.toLowerCase();
222
- if (!SANITIZE_ALLOWED_TAGS.has(t))
223
- return "";
224
- if (slash)
225
- return `</${t}>`;
226
- return `<${t}${sanitizeAttrs(t, attrs)}${SANITIZE_VOID.has(t) ? " /" : ""}>`;
227
- });
228
- return s.trim();
229
- }
230
220
  // ── Компоненты (серверные) ──────────────────────────────────────────────────────────────────
231
221
  // Цвета/рамки/скругления — через СОБСТВЕННЫЕ переменные пакета (--sa-*) с нейтральным дефолтом.
232
222
  // НЕ ссылаемся на токены сайта напрямую: shadcn/Tailwind хранят их как сырые HSL-компоненты
@@ -290,14 +280,15 @@ const L = (map, locale, fb) => map[locale] || map[locale?.split("-")[0]] || fb;
290
280
  // через ?show=, серверно — без клиентского JS, дружелюбно к SEO). Всегда рендерит H1 (даже пустой блог),
291
281
  // чтобы страница не была пустой. perPage — размер «страницы» (дефолт 20), show — сколько показать сейчас.
292
282
  async function SeoBlogIndex(props) {
293
- const cfg = await fetchBlogConfig();
294
- const locale = props.locale ?? (cfg ? firstLocale(cfg) : "");
283
+ const fs = await fsActive();
284
+ const cfg = fs ? null : await fetchBlogConfig();
285
+ const locale = props.locale ?? (fs ? fs.fsDefaultLocale() : (cfg ? firstLocale(cfg) : ""));
295
286
  const title = props.title ?? L(BLOG_TITLE, locale, "Blog");
296
287
  const per = Math.max(1, Math.floor(props.perPage ?? 20));
297
288
  const show = Math.max(per, Math.floor(Number(props.show) || per));
298
289
  const basePath = (props.basePath ?? "/blog").replace(/\/+$/, "");
299
290
  // Тянем show+1, чтобы понять, есть ли ещё (для кнопки «Показать больше»).
300
- const posts = cfg ? await fetchPosts(cfg, locale, show + 1) : [];
291
+ const posts = fs ? fs.fsBlogList(locale, show + 1) : (cfg ? await fetchPosts(cfg, locale, show + 1) : []);
301
292
  const hasMore = posts.length > show;
302
293
  const visible = posts.slice(0, show);
303
294
  return ((0, jsx_runtime_1.jsxs)("div", { className: "sa-blog-wrap", style: themeStyle(cfg?.theme), children: [(0, jsx_runtime_1.jsx)("style", { dangerouslySetInnerHTML: { __html: GRID_CSS } }), (0, jsx_runtime_1.jsx)("h1", { className: "sa-blog-h1", children: title }), visible.length ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("div", { className: "sa-blog-grid", children: visible.map((p) => ((0, jsx_runtime_1.jsxs)("a", { className: "sa-blog-card", href: `${basePath}/${p.slug}`, children: [p.cover ? (0, jsx_runtime_1.jsx)("img", { src: p.cover, alt: p.title, loading: "lazy" }) : null, (0, jsx_runtime_1.jsxs)("div", { className: "sa-cb", children: [(0, jsx_runtime_1.jsx)("h2", { children: p.title }), p.excerpt ? (0, jsx_runtime_1.jsx)("p", { children: p.excerpt }) : null] })] }, p.slug))) }), hasMore ? ((0, jsx_runtime_1.jsx)("div", { className: "sa-blog-more", children: (0, jsx_runtime_1.jsx)("a", { href: `${basePath}?show=${show + per}`, rel: "nofollow", children: L(MORE_LABEL, locale, "Show more") }) })) : null] })) : ((0, jsx_runtime_1.jsx)("p", { className: "sa-blog-empty", children: L(EMPTY_LABEL, locale, "No posts yet.") }))] }));
@@ -0,0 +1 @@
1
+ export declare function sanitizeDeliveryHtml(html: string): string;
@@ -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, "&quot;")}"`);
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webnumseoagent/next",
3
- "version": "0.5.0",
3
+ "version": "0.6.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
+ }