@webnumseoagent/next 0.4.1 → 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/README.md +7 -0
- package/analytics.tsx +3 -3
- package/bin/cli.mjs +3 -3
- package/blog-fs.ts +125 -0
- package/blog.tsx +29 -59
- package/dist/analytics.js +3 -3
- package/dist/blog-fs.d.ts +6 -0
- package/dist/blog-fs.js +153 -0
- package/dist/blog.d.ts +5 -0
- package/dist/blog.js +68 -77
- package/dist/jsonld.d.ts +1 -1
- package/dist/jsonld.js +9 -6
- package/dist/sanitize.d.ts +1 -0
- package/dist/sanitize.js +76 -0
- package/jsonld.tsx +16 -7
- package/package.json +3 -1
- package/sanitize.ts +56 -0
package/README.md
CHANGED
|
@@ -56,6 +56,13 @@ Vercel и `git push`. **Проверь `git diff`** перед коммитом
|
|
|
56
56
|
```
|
|
57
57
|
Для страниц с сегментами (`/[slug]`) передавай реальный `route` (напр. `/projects/${slug}`).
|
|
58
58
|
|
|
59
|
+
**Аналитика (GA4 / Google Tag Manager / Яндекс.Метрика) — с версии 0.5.0 включается автоматически:**
|
|
60
|
+
`<SeoJsonLd/>` заодно рендерит счётчики, ID которых заданы на платформе (вкладка «Аналитика»).
|
|
61
|
+
Отдельно ничего монтировать не нужно — обновил пакет, передеплоил, и счётчики включаются/выключаются
|
|
62
|
+
прямо с платформы. Скрипты идемпотентны (несколько `<SeoJsonLd/>` на странице безопасны).
|
|
63
|
+
Если Schema не используешь и `<SeoJsonLd/>` не смонтирован — можно смонтировать только аналитику:
|
|
64
|
+
`import { SeoAnalytics } from "@/seoagent/analytics"` → `<SeoAnalytics />` внутри `<body>`.
|
|
65
|
+
|
|
59
66
|
4. Создай карту сайта и robots (если их ещё нет):
|
|
60
67
|
```ts
|
|
61
68
|
// app/sitemap.ts
|
package/analytics.tsx
CHANGED
|
@@ -27,7 +27,7 @@ export async function SeoAnalytics({ route, locale }: { route?: string; locale?:
|
|
|
27
27
|
<script
|
|
28
28
|
// eslint-disable-next-line react/no-danger
|
|
29
29
|
dangerouslySetInnerHTML={{
|
|
30
|
-
__html: `(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);})(window,document,'script','dataLayer','${gtm}');`,
|
|
30
|
+
__html: `(function(w,d,s,l,i){var g=w.__saGtm=w.__saGtm||{};if(g[i])return;g[i]=1;w[l]=w[l]||[];w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);})(window,document,'script','dataLayer','${gtm}');`,
|
|
31
31
|
}}
|
|
32
32
|
/>
|
|
33
33
|
<noscript>
|
|
@@ -47,7 +47,7 @@ export async function SeoAnalytics({ route, locale }: { route?: string; locale?:
|
|
|
47
47
|
<script
|
|
48
48
|
// eslint-disable-next-line react/no-danger
|
|
49
49
|
dangerouslySetInnerHTML={{
|
|
50
|
-
__html: `window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}gtag('js',new Date());gtag('config','${ga4}')
|
|
50
|
+
__html: `window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}var _g=window.__saGa4=window.__saGa4||{};if(!_g['${ga4}']){_g['${ga4}']=1;gtag('js',new Date());gtag('config','${ga4}');}`,
|
|
51
51
|
}}
|
|
52
52
|
/>
|
|
53
53
|
</>
|
|
@@ -58,7 +58,7 @@ export async function SeoAnalytics({ route, locale }: { route?: string; locale?:
|
|
|
58
58
|
<script
|
|
59
59
|
// eslint-disable-next-line react/no-danger
|
|
60
60
|
dangerouslySetInnerHTML={{
|
|
61
|
-
__html: `(function(m,e,t,r,i,k,a){m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};m[i].l=1*new Date();for(var j=0;j<document.scripts.length;j++){if(document.scripts[j].src===r){return;}}k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)})(window,document,"script","https://mc.yandex.ru/metrika/tag.js","ym");ym(${ym},"init",{clickmap:true,trackLinks:true,accurateTrackBounce:true,webvisor:true})
|
|
61
|
+
__html: `var _ym=window.__saYm=window.__saYm||{};if(!_ym[${ym}]){_ym[${ym}]=1;(function(m,e,t,r,i,k,a){m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};m[i].l=1*new Date();for(var j=0;j<document.scripts.length;j++){if(document.scripts[j].src===r){return;}}k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)})(window,document,"script","https://mc.yandex.ru/metrika/tag.js","ym");ym(${ym},"init",{clickmap:true,trackLinks:true,accurateTrackBounce:true,webvisor:true});}`,
|
|
62
62
|
}}
|
|
63
63
|
/>
|
|
64
64
|
<noscript>
|
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
@@ -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, """)}"`);
|
|
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
|
|
290
|
-
const
|
|
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 (
|
package/dist/analytics.js
CHANGED
|
@@ -25,14 +25,14 @@ async function SeoAnalytics({ route, locale } = {}) {
|
|
|
25
25
|
return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [gtm && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("script", {
|
|
26
26
|
// eslint-disable-next-line react/no-danger
|
|
27
27
|
dangerouslySetInnerHTML: {
|
|
28
|
-
__html: `(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);})(window,document,'script','dataLayer','${gtm}');`,
|
|
28
|
+
__html: `(function(w,d,s,l,i){var g=w.__saGtm=w.__saGtm||{};if(g[i])return;g[i]=1;w[l]=w[l]||[];w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);})(window,document,'script','dataLayer','${gtm}');`,
|
|
29
29
|
} }), (0, jsx_runtime_1.jsx)("noscript", { children: (0, jsx_runtime_1.jsx)("iframe", { src: `https://www.googletagmanager.com/ns.html?id=${gtm}`, height: "0", width: "0", style: { display: "none", visibility: "hidden" } }) })] })), ga4 && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("script", { async: true, src: `https://www.googletagmanager.com/gtag/js?id=${ga4}` }), (0, jsx_runtime_1.jsx)("script", {
|
|
30
30
|
// eslint-disable-next-line react/no-danger
|
|
31
31
|
dangerouslySetInnerHTML: {
|
|
32
|
-
__html: `window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}gtag('js',new Date());gtag('config','${ga4}')
|
|
32
|
+
__html: `window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}var _g=window.__saGa4=window.__saGa4||{};if(!_g['${ga4}']){_g['${ga4}']=1;gtag('js',new Date());gtag('config','${ga4}');}`,
|
|
33
33
|
} })] })), ym && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("script", {
|
|
34
34
|
// eslint-disable-next-line react/no-danger
|
|
35
35
|
dangerouslySetInnerHTML: {
|
|
36
|
-
__html: `(function(m,e,t,r,i,k,a){m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};m[i].l=1*new Date();for(var j=0;j<document.scripts.length;j++){if(document.scripts[j].src===r){return;}}k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)})(window,document,"script","https://mc.yandex.ru/metrika/tag.js","ym");ym(${ym},"init",{clickmap:true,trackLinks:true,accurateTrackBounce:true,webvisor:true})
|
|
36
|
+
__html: `var _ym=window.__saYm=window.__saYm||{};if(!_ym[${ym}]){_ym[${ym}]=1;(function(m,e,t,r,i,k,a){m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};m[i].l=1*new Date();for(var j=0;j<document.scripts.length;j++){if(document.scripts[j].src===r){return;}}k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)})(window,document,"script","https://mc.yandex.ru/metrika/tag.js","ym");ym(${ym},"init",{clickmap:true,trackLinks:true,accurateTrackBounce:true,webvisor:true});}`,
|
|
37
37
|
} }), (0, jsx_runtime_1.jsx)("noscript", { children: (0, jsx_runtime_1.jsx)("div", { children: (0, jsx_runtime_1.jsx)("img", { src: `https://mc.yandex.ru/watch/${ym}`, style: { position: "absolute", left: "-9999px" }, alt: "" }) }) })] }))] }));
|
|
38
38
|
}
|
|
@@ -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;
|
package/dist/blog-fs.js
ADDED
|
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
@@ -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, """)}"`);
|
|
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
|
|
294
|
-
const
|
|
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.") }))] }));
|
package/dist/jsonld.d.ts
CHANGED
package/dist/jsonld.js
CHANGED
|
@@ -5,7 +5,11 @@ const jsx_runtime_1 = require("react/jsx-runtime");
|
|
|
5
5
|
// Серверный компонент для инъекции JSON-LD. XSS-безопасно: экранируем '<' → <
|
|
6
6
|
// и не даём закрыть тег </script>. Смонтируй в layout и/или на страницах:
|
|
7
7
|
// <SeoJsonLd route="/" locale={locale} />
|
|
8
|
+
// Заодно рендерит счётчики аналитики (GA4/GTM/Метрика) через <SeoAnalytics/> — чтобы их можно было
|
|
9
|
+
// включать С ПЛАТФОРМЫ без правки layout сайта (аналитика монтируется вместе со Schema, которая уже стоит).
|
|
10
|
+
// Скрипты аналитики идемпотентны, поэтому несколько <SeoJsonLd/> на странице безопасны.
|
|
8
11
|
const client_1 = require("./client");
|
|
12
|
+
const analytics_1 = require("./analytics");
|
|
9
13
|
function safeJson(nodes) {
|
|
10
14
|
// Один граф Schema.org; экранируем опасные последовательности.
|
|
11
15
|
const graph = nodes.length === 1 ? nodes[0] : { "@context": "https://schema.org", "@graph": nodes };
|
|
@@ -15,10 +19,9 @@ function safeJson(nodes) {
|
|
|
15
19
|
.replace(/&/g, "\\u0026");
|
|
16
20
|
}
|
|
17
21
|
async function SeoJsonLd({ route, locale }) {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
dangerouslySetInnerHTML: { __html: safeJson(nodes) } }));
|
|
22
|
+
// Параллельно: узлы JSON-LD + элемент аналитики (сам вернёт null, если ID не заданы на платформе).
|
|
23
|
+
const [nodes, analytics] = await Promise.all([(0, client_1.seoJsonLd)(route, locale), (0, analytics_1.SeoAnalytics)({ route, locale })]);
|
|
24
|
+
return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [nodes.length > 0 && ((0, jsx_runtime_1.jsx)("script", { type: "application/ld+json",
|
|
25
|
+
// eslint-disable-next-line react/no-danger
|
|
26
|
+
dangerouslySetInnerHTML: { __html: safeJson(nodes) } })), analytics] }));
|
|
24
27
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function sanitizeDeliveryHtml(html: string): string;
|
package/dist/sanitize.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.sanitizeDeliveryHtml = sanitizeDeliveryHtml;
|
|
4
|
+
// Санитайзер HTML тела статьи (защита-в-глубину перед dangerouslySetInnerHTML) — ЗЕРКАЛО доставки
|
|
5
|
+
// (src/lib/blog/blog-delivery.ts + Edge blog-autopilot). Тело уже санитайзено при доставке, но это
|
|
6
|
+
// последний рубеж на рендере (общий для supabase- и файлового источников блога).
|
|
7
|
+
const SANITIZE_ALLOWED_TAGS = new Set([
|
|
8
|
+
"h1", "h2", "h3", "h4", "h5", "h6", "p", "br", "hr", "strong", "b", "em", "i", "u", "s",
|
|
9
|
+
"ul", "ol", "li", "blockquote", "a", "img", "figure", "figcaption",
|
|
10
|
+
"table", "thead", "tbody", "tfoot", "tr", "th", "td", "caption", "code", "pre", "span", "div",
|
|
11
|
+
]);
|
|
12
|
+
const SANITIZE_ALLOWED_ATTRS = /^(href|src|alt|title|target|rel|width|height|loading|colspan|rowspan|scope|start|type)$/;
|
|
13
|
+
const SANITIZE_VOID = new Set(["br", "hr", "img"]);
|
|
14
|
+
function decodeSchemeEntities(v) {
|
|
15
|
+
return v
|
|
16
|
+
.replace(/&#x([0-9a-fA-F]+);?/g, (_m, h) => { try {
|
|
17
|
+
return String.fromCodePoint(parseInt(h, 16));
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return "";
|
|
21
|
+
} })
|
|
22
|
+
.replace(/&#(\d+);?/g, (_m, d) => { try {
|
|
23
|
+
return String.fromCodePoint(parseInt(d, 10));
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return "";
|
|
27
|
+
} })
|
|
28
|
+
.replace(/&(colon|tab|newline|lf|cr|sol|amp);?/gi, (m) => {
|
|
29
|
+
const k = m.replace(/[&;]/g, "").toLowerCase();
|
|
30
|
+
return { colon: ":", sol: "/", amp: "&", tab: " ", newline: " ", lf: " ", cr: " " }[k] ?? " ";
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
function safeUrl(val) {
|
|
34
|
+
const v = decodeSchemeEntities(val).toLowerCase().replace(/\s/g, "");
|
|
35
|
+
if (!/^[a-z][a-z0-9+.-]*:/.test(v))
|
|
36
|
+
return true;
|
|
37
|
+
return /^(https?:|mailto:|tel:)/.test(v);
|
|
38
|
+
}
|
|
39
|
+
function sanitizeAttrs(tag, attrsRaw) {
|
|
40
|
+
const out = [];
|
|
41
|
+
const re = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>]+))/g;
|
|
42
|
+
let m;
|
|
43
|
+
let href = "";
|
|
44
|
+
while ((m = re.exec(attrsRaw))) {
|
|
45
|
+
const name = m[1].toLowerCase();
|
|
46
|
+
const val = m[3] ?? m[4] ?? m[5] ?? "";
|
|
47
|
+
if (name.startsWith("on") || name === "style" || name === "srcset")
|
|
48
|
+
continue;
|
|
49
|
+
if ((name === "href" || name === "src") && !safeUrl(val))
|
|
50
|
+
continue;
|
|
51
|
+
if (!SANITIZE_ALLOWED_ATTRS.test(name))
|
|
52
|
+
continue;
|
|
53
|
+
if (name === "href")
|
|
54
|
+
href = val;
|
|
55
|
+
out.push(`${name}="${val.replace(/"/g, """)}"`);
|
|
56
|
+
}
|
|
57
|
+
if (tag === "a" && !out.some((a) => a.startsWith("rel="))) {
|
|
58
|
+
if (/^(https?:)?\/\//i.test(href.trim()))
|
|
59
|
+
out.push('rel="noopener nofollow"');
|
|
60
|
+
}
|
|
61
|
+
return out.length ? " " + out.join(" ") : "";
|
|
62
|
+
}
|
|
63
|
+
function sanitizeDeliveryHtml(html) {
|
|
64
|
+
let s = html || "";
|
|
65
|
+
s = s.replace(/<(script|style|iframe|object|embed|form|noscript|template|svg|math)\b[\s\S]*?<\/\1\s*>/gi, "");
|
|
66
|
+
s = s.replace(/<!--[\s\S]*?-->/g, "");
|
|
67
|
+
s = s.replace(/<(\/?)([a-zA-Z][a-zA-Z0-9]*)\b((?:"[^"]*"|'[^']*'|[^>])*)>/g, (_full, slash, tag, attrs) => {
|
|
68
|
+
const t = tag.toLowerCase();
|
|
69
|
+
if (!SANITIZE_ALLOWED_TAGS.has(t))
|
|
70
|
+
return "";
|
|
71
|
+
if (slash)
|
|
72
|
+
return `</${t}>`;
|
|
73
|
+
return `<${t}${sanitizeAttrs(t, attrs)}${SANITIZE_VOID.has(t) ? " /" : ""}>`;
|
|
74
|
+
});
|
|
75
|
+
return s.trim();
|
|
76
|
+
}
|
package/jsonld.tsx
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
// Серверный компонент для инъекции JSON-LD. XSS-безопасно: экранируем '<' → <
|
|
2
2
|
// и не даём закрыть тег </script>. Смонтируй в layout и/или на страницах:
|
|
3
3
|
// <SeoJsonLd route="/" locale={locale} />
|
|
4
|
+
// Заодно рендерит счётчики аналитики (GA4/GTM/Метрика) через <SeoAnalytics/> — чтобы их можно было
|
|
5
|
+
// включать С ПЛАТФОРМЫ без правки layout сайта (аналитика монтируется вместе со Schema, которая уже стоит).
|
|
6
|
+
// Скрипты аналитики идемпотентны, поэтому несколько <SeoJsonLd/> на странице безопасны.
|
|
4
7
|
import { seoJsonLd } from "./client";
|
|
8
|
+
import { SeoAnalytics } from "./analytics";
|
|
5
9
|
|
|
6
10
|
function safeJson(nodes: unknown[]): string {
|
|
7
11
|
// Один граф Schema.org; экранируем опасные последовательности.
|
|
@@ -13,13 +17,18 @@ function safeJson(nodes: unknown[]): string {
|
|
|
13
17
|
}
|
|
14
18
|
|
|
15
19
|
export async function SeoJsonLd({ route, locale }: { route: string; locale?: string }) {
|
|
16
|
-
|
|
17
|
-
|
|
20
|
+
// Параллельно: узлы JSON-LD + элемент аналитики (сам вернёт null, если ID не заданы на платформе).
|
|
21
|
+
const [nodes, analytics] = await Promise.all([seoJsonLd(route, locale), SeoAnalytics({ route, locale })]);
|
|
18
22
|
return (
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
<>
|
|
24
|
+
{nodes.length > 0 && (
|
|
25
|
+
<script
|
|
26
|
+
type="application/ld+json"
|
|
27
|
+
// eslint-disable-next-line react/no-danger
|
|
28
|
+
dangerouslySetInnerHTML={{ __html: safeJson(nodes) }}
|
|
29
|
+
/>
|
|
30
|
+
)}
|
|
31
|
+
{analytics}
|
|
32
|
+
</>
|
|
24
33
|
);
|
|
25
34
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webnumseoagent/next",
|
|
3
|
-
"version": "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, """)}"`);
|
|
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
|
+
}
|