@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,11 @@
1
+ import type { SeoBlogSummary, SeoBlogPostFull, SeoBlogTheme } from "./blog";
2
+ export declare function fsTheme(): SeoBlogTheme | undefined;
3
+ export declare function fsAvailable(): boolean;
4
+ export declare function fsDefaultLocale(): string;
5
+ export declare function fsBlogList(locale: string, limit: number): SeoBlogSummary[];
6
+ export declare function fsBlogPost(slug: string, locale: string): SeoBlogPostFull | null;
7
+ export declare function fsDirExists(): boolean;
8
+ export declare function fsBlogSitemapEntries(origin: string): {
9
+ loc: string;
10
+ lastmod?: string;
11
+ }[];
@@ -0,0 +1,199 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.fsTheme = fsTheme;
4
+ exports.fsAvailable = fsAvailable;
5
+ exports.fsDefaultLocale = fsDefaultLocale;
6
+ exports.fsBlogList = fsBlogList;
7
+ exports.fsBlogPost = fsBlogPost;
8
+ exports.fsDirExists = fsDirExists;
9
+ exports.fsBlogSitemapEntries = fsBlogSitemapEntries;
10
+ // Файловый источник блога (Фаза 4 blogfs): читает статьи из репозитория сайта (папка content/blog,
11
+ // формат blogfs v1 — см. docs/blog-git-delivery/FORMAT.md) на СБОРКЕ, без сети и без Supabase.
12
+ // Отдаёт те же формы, что и supabase-источник (SeoBlogSummary / SeoBlogPostFull) — blog.tsx роутит.
13
+ // FAIL-SAFE: любая ошибка/битый файл → пропуск, сборка НИКОГДА не падает.
14
+ const node_fs_1 = require("node:fs");
15
+ const node_path_1 = require("node:path");
16
+ const sanitize_1 = require("./sanitize");
17
+ const BLOG_DIR = process.env.SEOAGENT_BLOG_DIR ?? "content/blog";
18
+ const SUPPORTED_MAJOR = 1; // schema "seoagent.blog/v1"
19
+ function root() {
20
+ return (0, node_path_1.join)(process.cwd(), BLOG_DIR);
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
+ // Мажор схемы из строки "seoagent.blog/vN" (или число). null — не наш формат.
28
+ function schemaMajor(schema) {
29
+ if (typeof schema === "number")
30
+ return schema;
31
+ if (typeof schema === "string") {
32
+ const m = schema.match(/\/v(\d+)$/);
33
+ if (m)
34
+ return parseInt(m[1], 10);
35
+ }
36
+ return null;
37
+ }
38
+ function readDescriptor() {
39
+ try {
40
+ return JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(root(), ".blogfs.json"), "utf8"));
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ }
46
+ // Тема оформления блога из дескриптора (git-режим, Фаза 3): ридер применяет её в --sa-* БЕЗ config-API.
47
+ // Валиден только объект с строковым accent (иначе игнор → дефолтные --sa-*).
48
+ function fsTheme() {
49
+ const t = readDescriptor()?.theme;
50
+ return t && typeof t === "object" && typeof t.accent === "string" && t.accent ? t : undefined;
51
+ }
52
+ // Канонический префикс публичных URL блога. Приоритет — явный descriptor.urlPrefix (пишет платформа);
53
+ // фолбэк для старых дескрипторов без него: <2 локалей → "" (одноязычный /blog), иначе "/<defaultLocale>".
54
+ function urlPrefix() {
55
+ const d = readDescriptor();
56
+ if (!d)
57
+ return "";
58
+ if (typeof d.urlPrefix === "string")
59
+ return d.urlPrefix.replace(/\/+$/, "");
60
+ const locales = Array.isArray(d.locales) ? d.locales.map((l) => String(l).trim()).filter(Boolean) : [];
61
+ if (locales.length < 2)
62
+ return "";
63
+ const dflt = (typeof d.defaultLocale === "string" && d.defaultLocale.trim()) || locales[0];
64
+ return dflt ? `/${dflt}` : "";
65
+ }
66
+ // Есть ли файловый блог в репо (наличие .blogfs.json = git-сайт с ≥1 доставкой). Сигнал авто-детекта.
67
+ function fsAvailable() {
68
+ return readDescriptor() !== null;
69
+ }
70
+ function fsDefaultLocale() {
71
+ const d = readDescriptor()?.defaultLocale;
72
+ return typeof d === "string" ? d : ""; // битый дескриптор мог дать не-строку → не роняем split ниже
73
+ }
74
+ // Собрать meta.json всех статей (glob по папкам). Пропускает битые/незнакомые версии/черновики.
75
+ function listMetas() {
76
+ const r = root();
77
+ let names;
78
+ try {
79
+ names = (0, node_fs_1.readdirSync)(r, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
80
+ }
81
+ catch {
82
+ return [];
83
+ }
84
+ const out = [];
85
+ for (const name of names) {
86
+ if (name.startsWith(".") || name.startsWith("_"))
87
+ continue; // служебные (assets и т.п.)
88
+ let meta;
89
+ try {
90
+ meta = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(r, name, "meta.json"), "utf8"));
91
+ }
92
+ catch {
93
+ continue;
94
+ } // нет meta.json / битый JSON → пропуск (не роняем сборку)
95
+ // Валидный JSON может быть null/массивом/примитивом — доступ к полям тогда бросит. Пропускаем.
96
+ if (!meta || typeof meta !== "object" || Array.isArray(meta))
97
+ continue;
98
+ const major = schemaMajor(meta.schema);
99
+ if (major === null || major > SUPPORTED_MAJOR) { // незнакомая/будущая версия → пропуск с предупреждением
100
+ try {
101
+ console.warn(`[seoagent blogfs] пропуск ${name}: несовместимая версия схемы (${String(meta.schema)})`);
102
+ }
103
+ catch { /* noop */ }
104
+ continue;
105
+ }
106
+ // Незнакомая body-возможность (mdx/frontmatter/…) в том же мажоре → ридер её не умеет → пропуск.
107
+ if (Array.isArray(meta.features) && meta.features.some((f) => !KNOWN_FEATURES.has(f))) {
108
+ try {
109
+ console.warn(`[seoagent blogfs] пропуск ${name}: неподдерживаемая возможность (${meta.features.join(",")})`);
110
+ }
111
+ catch { /* noop */ }
112
+ continue;
113
+ }
114
+ if (meta.status !== "published" || !meta.slug || !meta.locales)
115
+ continue;
116
+ out.push({ dir: name, meta });
117
+ }
118
+ return out;
119
+ }
120
+ // Запись локали: точная → без региона (en-US→en) → defaultLocale → первая доступная.
121
+ function pickLocale(meta, locale) {
122
+ const locs = meta.locales ?? {};
123
+ const loc = typeof locale === "string" ? locale : ""; // на всякий случай — locale мог прийти не строкой
124
+ const base = loc ? loc.split("-")[0] : "";
125
+ return locs[loc] || (base && locs[base]) || (meta.defaultLocale ? locs[meta.defaultLocale] : undefined) || locs[Object.keys(locs)[0]] || {};
126
+ }
127
+ function summary(meta, locale) {
128
+ const e = pickLocale(meta, locale);
129
+ return {
130
+ slug: meta.slug ?? "",
131
+ title: e.title ?? "",
132
+ excerpt: e.excerpt ?? "",
133
+ cover: meta.coverImage ?? null,
134
+ date: meta.publishedAt ?? null,
135
+ };
136
+ }
137
+ // Список статей для локали, по publishedAt убыв., до limit.
138
+ function fsBlogList(locale, limit) {
139
+ const rows = listMetas().map(({ meta }) => summary(meta, locale)).filter((p) => p.slug && p.title);
140
+ rows.sort((a, b) => (b.date ?? "").localeCompare(a.date ?? "")); // publishedAt desc (ISO — код-юнит ок)
141
+ return rows.slice(0, Math.max(0, limit));
142
+ }
143
+ // Одна статья по slug (ищем по meta.slug — папка может быть по id при pathStrategy=id).
144
+ function fsBlogPost(slug, locale) {
145
+ const hit = listMetas().find((x) => x.meta.slug === slug);
146
+ if (!hit)
147
+ return null;
148
+ const e = pickLocale(hit.meta, locale);
149
+ let bodyRaw = "";
150
+ // Имя файла тела берётся из meta.json репо — только безопасное имя (без "/" и ".."), защита от traversal.
151
+ if (e.body && /^[A-Za-z0-9._-]+$/.test(e.body)) {
152
+ try {
153
+ bodyRaw = (0, node_fs_1.readFileSync)((0, node_path_1.join)(root(), hit.dir, e.body), "utf8");
154
+ }
155
+ catch {
156
+ bodyRaw = "";
157
+ }
158
+ }
159
+ const s = summary(hit.meta, locale);
160
+ return {
161
+ ...s,
162
+ // Тело уже санитайзено при доставке; чистим повторно (защита-в-глубину). bodyFormat="mdx" появится позже.
163
+ bodyHtml: (0, sanitize_1.sanitizeDeliveryHtml)(bodyRaw),
164
+ theme: fsTheme(), // тема из .blogfs.json (Фаза 3) — фирменные цвета блога без запроса к платформе
165
+ };
166
+ }
167
+ // Есть ли вообще папка блога (для мягких проверок).
168
+ function fsDirExists() {
169
+ try {
170
+ return (0, node_fs_1.existsSync)(root());
171
+ }
172
+ catch {
173
+ return false;
174
+ }
175
+ }
176
+ // Записи блога для sitemap (Фаза 2): страница списка + канонический URL каждой статьи, на СБОРКЕ из файлов.
177
+ // loc = origin + urlPrefix + "/blog[/<slug>]"; lastmod = meta.publishedAt (ISO), если есть. Одна каноническая
178
+ // ссылка на статью (префикс дефолтной локали) — как платформа пингует IndexNow; локали-альтернативы позже (hreflang).
179
+ // Fail-safe: любая ошибка → []. origin — https://домен (без хвостового слэша), берётся из карты сайта.
180
+ function fsBlogSitemapEntries(origin) {
181
+ try {
182
+ const base = (origin || "").replace(/\/+$/, "");
183
+ const pref = urlPrefix();
184
+ const metas = listMetas();
185
+ if (!metas.length)
186
+ return [];
187
+ const out = [{ loc: `${base}${pref}/blog` }];
188
+ for (const { meta } of metas) {
189
+ if (!meta.slug)
190
+ continue;
191
+ const lm = typeof meta.publishedAt === "string" && meta.publishedAt ? meta.publishedAt : undefined;
192
+ out.push({ loc: `${base}${pref}/blog/${meta.slug}`, lastmod: lm });
193
+ }
194
+ return out;
195
+ }
196
+ catch {
197
+ return [];
198
+ }
199
+ }
package/dist/blog.d.ts CHANGED
@@ -1,12 +1,3 @@
1
- export interface SeoBlogMapping {
2
- title: string;
3
- excerpt?: string;
4
- body: string;
5
- id?: string;
6
- image?: string;
7
- published?: string;
8
- publishedAt?: string;
9
- }
10
1
  export interface SeoBlogTheme {
11
2
  accent?: string;
12
3
  border?: string;
@@ -15,15 +6,6 @@ export interface SeoBlogTheme {
15
6
  thBg?: string;
16
7
  radius?: string;
17
8
  }
18
- export interface SeoBlogConfig {
19
- supabaseUrl: string;
20
- anonKey: string;
21
- table: string;
22
- format: "html" | "minimarkup";
23
- locales: string[];
24
- mapping: SeoBlogMapping;
25
- theme?: SeoBlogTheme;
26
- }
27
9
  export interface SeoBlogSummary {
28
10
  slug: string;
29
11
  title: string;
@@ -42,10 +24,16 @@ export declare function seoBlogList(opts?: {
42
24
  export declare function seoBlogPost(slug: string, opts?: {
43
25
  locale?: string;
44
26
  }): Promise<SeoBlogPostFull | null>;
27
+ export declare function seoBlogStaticParams(opts?: {
28
+ locale?: string;
29
+ }): Promise<{
30
+ slug: string;
31
+ }[]>;
45
32
  export declare function SeoBlogIndex(props: {
46
33
  locale?: string;
47
34
  basePath?: string;
48
35
  title?: string;
36
+ limit?: number;
49
37
  show?: number;
50
38
  perPage?: number;
51
39
  }): Promise<import("react").JSX.Element>;
package/dist/blog.js CHANGED
@@ -1,237 +1,65 @@
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");
8
- const API = process.env.SEOAGENT_API_BASE ?? "";
9
- const SITE = process.env.SEOAGENT_SITE_ID ?? "";
10
- const TOKEN = process.env.SEOAGENT_TOKEN ?? "";
11
- const REVALIDATE = 300;
12
- function firstLocale(cfg) {
13
- return (cfg.locales ?? []).filter(Boolean)[0] ?? "";
14
- }
15
- // Публичный конфиг блога с платформы (кэш ISR + тег seo-<site> — сбрасывается ревалидацией).
16
- async function fetchBlogConfig() {
17
- if (!API || !SITE || !TOKEN)
18
- return null;
19
- try {
20
- const r = await fetch(`${API}/api/seo/${SITE}/blog`, {
21
- headers: { "x-seo-agent-key": TOKEN },
22
- next: { revalidate: REVALIDATE, tags: [`seo-${SITE}`, `seo-blog-${SITE}`] },
23
- signal: AbortSignal.timeout(5000),
24
- });
25
- if (!r.ok)
26
- return null;
27
- const d = (await r.json());
28
- const b = d?.blog ?? null;
29
- if (!b?.supabaseUrl || !b?.anonKey || !b?.table || !b?.mapping?.title || !b?.mapping?.body)
30
- return null;
31
- return b;
32
- }
33
- catch {
34
- return null;
35
- }
36
- }
37
- // Запрос к REST API Supabase клиента (anon). Возвращает массив строк либо null.
38
- async function supaGet(cfg, url) {
39
- try {
40
- const r = await fetch(url, {
41
- headers: { apikey: cfg.anonKey, Authorization: `Bearer ${cfg.anonKey}` },
42
- next: { revalidate: REVALIDATE, tags: [`seo-blog-${SITE}`] },
43
- signal: AbortSignal.timeout(6000),
44
- });
45
- if (!r.ok)
46
- return null;
47
- const d = await r.json();
48
- return Array.isArray(d) ? d : null;
49
- }
50
- catch {
51
- return null;
52
- }
53
- }
54
- const S = (v) => (v == null ? "" : String(v));
55
- const base = (cfg) => cfg.supabaseUrl.replace(/\/+$/, "");
56
- async function fetchPosts(cfg, locale, limit) {
57
- const m = cfg.mapping;
58
- if (!m.id)
59
- return []; // без колонки id/slug ссылки на статьи не построить
60
- const buildUrl = (suf) => {
61
- const titleCol = m.title + suf;
62
- const excerptCol = m.excerpt ? m.excerpt + suf : "";
63
- const cols = [m.id, titleCol, excerptCol, m.image || "", m.publishedAt || ""].filter(Boolean).join(",");
64
- let url = `${base(cfg)}/rest/v1/${encodeURIComponent(cfg.table)}?select=${cols}`;
65
- if (m.published)
66
- url += `&${m.published}=eq.true`;
67
- if (m.publishedAt)
68
- url += `&order=${m.publishedAt}.desc`;
69
- url += `&limit=${limit}`;
70
- return { url, titleCol, excerptCol };
71
- };
72
- const suf = locale ? `_${locale}` : "";
73
- let q = buildUrl(suf);
74
- let rows = await supaGet(cfg, q.url);
75
- // Запрошенной локали нет среди колонок доставки (напр. языки блога не совпали с локалями сайта) →
76
- // PostgREST вернул 400 (supaGet=null). Откатываемся на базовые колонки (оригинал), а не показываем пусто.
77
- if (rows === null && suf) {
78
- q = buildUrl("");
79
- rows = await supaGet(cfg, q.url);
80
- }
81
- return (rows ?? [])
82
- .map((r) => ({
83
- slug: S(r[m.id]),
84
- title: S(r[q.titleCol]),
85
- excerpt: q.excerptCol ? S(r[q.excerptCol]) : "",
86
- cover: m.image ? r[m.image] ?? null : null,
87
- date: m.publishedAt ? r[m.publishedAt] ?? null : null,
88
- }))
89
- .filter((p) => p.slug && p.title);
90
- }
91
- async function fetchPost(cfg, slug, locale) {
92
- const m = cfg.mapping;
93
- if (!m.id)
94
- return null;
95
- const suf = locale ? `_${locale}` : "";
96
- let url = `${base(cfg)}/rest/v1/${encodeURIComponent(cfg.table)}?select=*&${m.id}=eq.${encodeURIComponent(slug)}`;
97
- if (m.published)
98
- url += `&${m.published}=eq.true`;
99
- url += `&limit=1`;
100
- const rows = await supaGet(cfg, url);
101
- const r = rows && rows[0];
102
- if (!r)
103
- return null;
104
- // Локаль-колонка пуста/отсутствует → берём базовую (оригинал). Так статья не выходит пустой, если
105
- // перевода на запрошенную локаль нет (доставка тоже кладёт оригинал для непереведённых локалей).
106
- const val = (colBase) => (suf ? S(r[colBase + suf]) : "") || S(r[colBase]);
107
- const rawBody = val(m.body);
108
- return {
109
- slug: S(r[m.id]) || slug,
110
- title: val(m.title),
111
- excerpt: m.excerpt ? val(m.excerpt) : "",
112
- cover: m.image ? r[m.image] ?? null : null,
113
- date: m.publishedAt ? r[m.publishedAt] ?? null : null,
114
- bodyHtml: cfg.format === "minimarkup" ? miniMarkupToHtml(rawBody) : sanitizeDeliveryHtml(rawBody),
115
- theme: cfg.theme, // тему тащим с постом — чтобы SeoBlogArticle применил её без отдельного запроса
116
- };
117
- }
118
- // ── Публичные data-функции (для generateMetadata / generateStaticParams) ──
42
+ // Файловый ридер (node:fs) грузим лениво — только когда блог реально рендерится (на сборке).
43
+ const blogfs = () => Promise.resolve().then(() => __importStar(require("./blog-fs")));
44
+ // ── Публичные data-функции (для generateMetadata / generateStaticParams) — читают файлы content/blog. ──
119
45
  async function seoBlogList(opts) {
120
- const cfg = await fetchBlogConfig();
121
- if (!cfg)
122
- return [];
123
- return fetchPosts(cfg, opts?.locale ?? firstLocale(cfg), opts?.limit ?? 100);
46
+ const m = await blogfs();
47
+ return m.fsBlogList(opts?.locale ?? m.fsDefaultLocale(), opts?.limit ?? 1000);
124
48
  }
125
49
  async function seoBlogPost(slug, opts) {
126
- const cfg = await fetchBlogConfig();
127
- if (!cfg)
128
- return null;
129
- return fetchPost(cfg, slug, opts?.locale ?? firstLocale(cfg));
130
- }
131
- // ── Мини-разметка (## / - / **) → HTML (для legacy-формата; текст экранируем). ──
132
- function esc(s) {
133
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
134
- }
135
- function inlineMini(s) {
136
- return esc(s).replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
137
- }
138
- function miniMarkupToHtml(src) {
139
- const out = [];
140
- for (const block of (src || "").split(/\n{2,}/)) {
141
- const b = block.trim();
142
- if (!b)
143
- continue;
144
- const lines = b.split("\n");
145
- if (lines.every((l) => l.startsWith("- "))) {
146
- out.push("<ul>" + lines.map((l) => `<li>${inlineMini(l.slice(2))}</li>`).join("") + "</ul>");
147
- }
148
- else if (b.startsWith("## ")) {
149
- out.push(`<h2>${inlineMini(b.slice(3))}</h2>`);
150
- }
151
- else {
152
- out.push(`<p>${inlineMini(b)}</p>`);
153
- }
154
- }
155
- return out.join("");
156
- }
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(" ") : "";
50
+ const m = await blogfs();
51
+ return m.fsBlogPost(slug, opts?.locale ?? m.fsDefaultLocale());
215
52
  }
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();
53
+ // Параметры для generateStaticParams роута /blog/[slug]: пре-рендер ВСЕХ статей на сборке (SSG из файлов).
54
+ async function seoBlogStaticParams(opts) {
55
+ const list = await seoBlogList({ locale: opts?.locale, limit: 100000 });
56
+ return list.map((p) => ({ slug: p.slug }));
229
57
  }
230
58
  // ── Компоненты (серверные) ──────────────────────────────────────────────────────────────────
231
59
  // Цвета/рамки/скругления — через СОБСТВЕННЫЕ переменные пакета (--sa-*) с нейтральным дефолтом.
232
60
  // НЕ ссылаемся на токены сайта напрямую: shadcn/Tailwind хранят их как сырые HSL-компоненты
233
61
  // (--primary: 222 47% 11%), в color/border это невалидно и ломает вид. Реальные цвета сайта
234
- // проставит платформа: config-API отдаст профиль, а хост-обёртка выставит --sa-* валидными цветами.
62
+ // проставит платформа: .blogfs.json несёт тему, а хост-обёртка выставит --sa-* валидными цветами.
235
63
  // Шрифт и базовый цвет текста наследуются от layout сайта (color:inherit, без font-family).
236
64
  const BODY_CSS = `.seoagent-blog-body{color:inherit}
237
65
  .seoagent-blog-body img{max-width:100%;height:auto;border-radius:var(--sa-radius,8px);margin:1rem 0}
@@ -283,24 +111,22 @@ const GRID_CSS = `.sa-blog-wrap{max-width:1120px;margin:0 auto;padding:var(--sa-
283
111
  .sa-blog-more a:hover{background:var(--sa-th-bg,#f8fafc)}
284
112
  .sa-blog-empty{color:var(--sa-muted,#64748b)}`;
285
113
  const BLOG_TITLE = { ru: "Блог", uz: "Blog", en: "Blog", ar: "المدونة", tr: "Blog" };
286
- const MORE_LABEL = { ru: "Показать больше", uz: "Ko‘proq ko‘rsatish", en: "Show more", ar: "عرض المزيد" };
287
114
  const EMPTY_LABEL = { ru: "Пока нет статей.", uz: "Hozircha maqolalar yo‘q.", en: "No posts yet.", ar: "لا توجد مقالات بعد." };
288
115
  const L = (map, locale, fb) => map[locale] || map[locale?.split("-")[0]] || fb;
289
- // Список статей блога: H1 + карточки (по 4 в ряд на десктопе) + «Показать больше» (пагинация по 20
290
- // через ?show=, серверно без клиентского JS, дружелюбно к SEO). Всегда рендерит H1 (даже пустой блог),
291
- // чтобы страница не была пустой. perPage размер «страницы» (дефолт 20), show сколько показать сейчас.
116
+ // Список статей блога: H1 + карточки (по 4 в ряд на десктопе). Всегда рендерит H1 (даже пустой блог).
117
+ // СТАТИЧЕСКИЙ (git-native): НЕ читает searchParams иначе роут стал бы динамическим, а в рантайме Vercel
118
+ // файлов content/blog в лямбде нет список был бы пуст. Читаем ВСЕ статьи на сборке; контент меняется
119
+ // git-коммитом → редеплой пересобирает страницу. limit — предохранитель (дефолт 1000).
120
+ // show/perPage приняты для обратной совместимости со старым скаффолдом (?show=), но ИГНОРИРУЮТСЯ.
292
121
  async function SeoBlogIndex(props) {
293
- const cfg = await fetchBlogConfig();
294
- const locale = props.locale ?? (cfg ? firstLocale(cfg) : "");
122
+ const m = await blogfs();
123
+ const locale = props.locale ?? m.fsDefaultLocale();
295
124
  const title = props.title ?? L(BLOG_TITLE, locale, "Blog");
296
- const per = Math.max(1, Math.floor(props.perPage ?? 20));
297
- const show = Math.max(per, Math.floor(Number(props.show) || per));
298
125
  const basePath = (props.basePath ?? "/blog").replace(/\/+$/, "");
299
- // Тянем show+1, чтобы понять, есть ли ещё (для кнопки «Показать больше»).
300
- const posts = cfg ? await fetchPosts(cfg, locale, show + 1) : [];
301
- const hasMore = posts.length > show;
302
- const visible = posts.slice(0, show);
303
- 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.") }))] }));
126
+ const limit = Math.max(1, Math.floor(props.limit ?? 1000));
127
+ const posts = m.fsBlogList(locale, limit);
128
+ const theme = m.fsTheme(); // тема из .blogfs.json
129
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "sa-blog-wrap", style: themeStyle(theme), children: [(0, jsx_runtime_1.jsx)("style", { dangerouslySetInnerHTML: { __html: GRID_CSS } }), (0, jsx_runtime_1.jsx)("h1", { className: "sa-blog-h1", children: title }), posts.length ? ((0, jsx_runtime_1.jsx)("div", { className: "sa-blog-grid", children: posts.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))) })) : ((0, jsx_runtime_1.jsx)("p", { className: "sa-blog-empty", children: L(EMPTY_LABEL, locale, "No posts yet.") }))] }));
304
130
  }
305
131
  // Одна статья. Принимает пост (из seoBlogPost) — так страница может сделать notFound()/метаданные.
306
132
  function SeoBlogArticle({ post, locale }) {
@@ -0,0 +1 @@
1
+ export declare function sanitizeDeliveryHtml(html: string): string;