@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
|
@@ -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
|
+
}[];
|
package/dist/blog-fs.js
ADDED
|
@@ -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
|
-
|
|
9
|
-
const
|
|
10
|
-
|
|
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
|
|
121
|
-
|
|
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
|
|
127
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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, """)}"`);
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
// проставит платформа:
|
|
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 в ряд на десктопе)
|
|
290
|
-
//
|
|
291
|
-
//
|
|
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
|
|
294
|
-
const locale = props.locale ?? (
|
|
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
|
-
|
|
300
|
-
const posts =
|
|
301
|
-
const
|
|
302
|
-
|
|
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;
|