ваше сообщение коммита

This commit is contained in:
2025-12-24 14:46:30 +03:00
parent c02d0a38ac
commit 37d6072cf2
11 changed files with 1956 additions and 57 deletions

View File

@@ -27,7 +27,10 @@
"generate:flattened": "node scripts/generate-flattened.js",
"compile:full": "npx hardhat compile && npm run generate:abi && npm run generate:flattened",
"seed:legal": "node scripts/seed/legalTemplatesSeed.js",
"import:legal": "node scripts/import-legal-docs.js"
"import:legal": "node scripts/import-legal-docs.js",
"prerender:blog": "node scripts/pre-render-blog.js",
"prerender:blog:article": "node scripts/pre-render-blog.js --no-list",
"test:prerender": "node scripts/test-pre-render.js"
},
"bin": {},
"engines": {
@@ -66,6 +69,7 @@
"openai": "^4.102.0",
"pg": "^8.10.0",
"pg-large-object": "^2.0.0",
"puppeteer": "^24.15.0",
"semver": "^7.7.1",
"session-file-store": "^1.5.0",
"siwe": "^2.1.4",

View File

@@ -18,6 +18,7 @@ const path = require('path');
const multer = require('multer');
const vectorSearchClient = require('../services/vectorSearchClient');
const logger = require('../utils/logger');
const { preRenderBlog } = require('../scripts/pre-render-blog');
const FIELDS_TO_EXCLUDE = ['image', 'tags'];
@@ -42,7 +43,9 @@ async function ensureAdminPagesTable(fields) {
'id SERIAL PRIMARY KEY',
'author_address_encrypted TEXT NOT NULL', // Зашифрованный адрес автора
'created_at TIMESTAMP DEFAULT NOW()',
'updated_at TIMESTAMP DEFAULT NOW()'
'updated_at TIMESTAMP DEFAULT NOW()',
'show_in_blog BOOLEAN DEFAULT FALSE', // Показывать в блоге
'slug TEXT UNIQUE' // URL-friendly идентификатор для SEO
];
for (const field of fields) {
columns.push(`"${field}_encrypted" TEXT`);
@@ -63,6 +66,29 @@ async function ensureAdminPagesTable(fields) {
);
}
// Добавляем поле show_in_blog если его нет (не зашифрованное поле)
if (!existingCols.includes('show_in_blog')) {
await db.getQuery()(
`ALTER TABLE ${tableName} ADD COLUMN show_in_blog BOOLEAN DEFAULT FALSE`
);
}
// Добавляем поле slug если его нет
if (!existingCols.includes('slug')) {
await db.getQuery()(
`ALTER TABLE ${tableName} ADD COLUMN slug TEXT`
);
// Создаем уникальный индекс для slug
try {
await db.getQuery()(
`CREATE UNIQUE INDEX IF NOT EXISTS ${tableName}_slug_unique ON ${tableName}(slug) WHERE slug IS NOT NULL`
);
} catch (e) {
// Индекс может уже существовать, игнорируем ошибку
console.log('[pages] Индекс для slug уже существует или ошибка создания:', e.message);
}
}
for (const field of fields) {
const encryptedField = `${field}_encrypted`;
if (!existingCols.includes(encryptedField)) {
@@ -98,6 +124,87 @@ function stripHtml(html) {
return String(html).replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
}
/**
* Генерирует URL-friendly slug из текста
* @param {string} text - Исходный текст
* @param {number} maxLength - Максимальная длина slug (по умолчанию 100)
* @returns {string} - Сгенерированный slug
*/
function generateSlug(text, maxLength = 100) {
if (!text) return '';
return text
.toLowerCase()
.trim()
// Транслитерация кириллицы в латиницу
.replace(/[а-яё]/g, (char) => {
const map = {
'а': 'a', 'б': 'b', 'в': 'v', 'г': 'g', 'д': 'd', 'е': 'e', 'ё': 'yo',
'ж': 'zh', 'з': 'z', 'и': 'i', 'й': 'y', 'к': 'k', 'л': 'l', 'м': 'm',
'н': 'n', 'о': 'o', 'п': 'p', 'р': 'r', 'с': 's', 'т': 't', 'у': 'u',
'ф': 'f', 'х': 'h', 'ц': 'ts', 'ч': 'ch', 'ш': 'sh', 'щ': 'sch',
'ъ': '', 'ы': 'y', 'ь': '', 'э': 'e', 'ю': 'yu', 'я': 'ya'
};
return map[char] || char;
})
// Заменяем все не-латинские символы и цифры на дефисы
.replace(/[^a-z0-9]+/g, '-')
// Убираем дефисы в начале и конце
.replace(/^-+|-+$/g, '')
// Ограничиваем длину
.substring(0, maxLength)
.replace(/-+$/, ''); // Убираем дефис в конце после обрезки
}
/**
* Генерирует уникальный slug, проверяя существование в БД
* @param {string} title - Заголовок страницы
* @param {number} pageId - ID страницы (для исключения при проверке уникальности)
* @param {string} tableName - Имя таблицы
* @returns {Promise<string>} - Уникальный slug
*/
async function generateUniqueSlug(title, pageId, tableName) {
let baseSlug = generateSlug(title);
if (!baseSlug) {
// Если slug пустой, используем id
baseSlug = `page-${pageId || Date.now()}`;
}
let slug = baseSlug;
let counter = 1;
// Проверяем уникальность
while (true) {
let query = `SELECT id FROM ${tableName} WHERE slug = $1`;
const params = [slug];
// Если это редактирование, исключаем текущую страницу
if (pageId) {
query += ` AND id != $2`;
params.push(pageId);
}
const result = await db.getQuery()(query, params);
if (result.rows.length === 0) {
// Slug уникален
return slug;
}
// Slug уже существует, добавляем номер
slug = `${baseSlug}-${counter}`;
counter++;
// Защита от бесконечного цикла
if (counter > 1000) {
slug = `${baseSlug}-${Date.now()}`;
break;
}
}
return slug;
}
// Создать страницу (только для админа)
router.post('/', upload.single('file'), async (req, res) => {
console.log('[pages] POST /: Начало обработки запроса на создание страницы');
@@ -202,7 +309,8 @@ router.post('/', upload.single('file'), async (req, res) => {
? (() => { const parsed = parseInt(bodyRaw.order_index); return isNaN(parsed) ? 0 : parsed; })()
: 0,
nav_path: bodyRaw.nav_path || null,
is_index_page: bodyRaw.is_index_page === true || bodyRaw.is_index_page === 'true'
is_index_page: bodyRaw.is_index_page === true || bodyRaw.is_index_page === 'true',
show_in_blog: bodyRaw.show_in_blog === true || bodyRaw.show_in_blog === 'true' || bodyRaw.show_in_blog === true
};
console.log('[pages] POST /: Создание страницы, данные:', {
@@ -213,6 +321,14 @@ router.post('/', upload.single('file'), async (req, res) => {
format: pageData.format
});
// Генерируем slug из заголовка (если не передан вручную)
let slug = bodyRaw.slug && bodyRaw.slug.trim()
? bodyRaw.slug.trim()
: await generateUniqueSlug(pageData.title, null, tableName);
// Добавляем slug в pageData
pageData.slug = slug;
// Формируем SQL для вставки данных (включаем все поля, даже null)
// Фильтруем только undefined, null значения включаем (они допустимы в БД)
const dataEntries = Object.entries(pageData).filter(([k, v]) => {
@@ -261,6 +377,23 @@ router.post('/', upload.single('file'), async (req, res) => {
// Индексация выполняется ТОЛЬКО вручную через кнопку "Индекс" (POST /:id/reindex)
// Автоматическая индексация при создании отключена
// Запускаем pre-rendering для блога, если страница публичная и для блога
if (created.visibility === 'public' &&
created.status === 'published' &&
created.show_in_blog &&
created.slug &&
typeof created.slug === 'string' &&
created.slug.trim() !== '') {
// Запускаем асинхронно, не блокируя ответ
preRenderBlog({
renderList: true,
renderArticles: true,
specificSlug: created.slug.trim()
}).catch(err => {
console.error('[pages] Ошибка pre-rendering при создании страницы:', err);
});
}
res.json(created);
} catch (error) {
console.error('[pages] Ошибка при создании страницы:', error);
@@ -839,6 +972,26 @@ router.patch('/:id', upload.single('file'), async (req, res) => {
if (FIELDS_TO_EXCLUDE.includes(k)) continue;
if (k === 'required_permission') continue; // Уже обработано выше
// Обрабатываем show_in_blog как boolean
if (k === 'show_in_blog') {
updateData[k] = v === true || v === 'true' || v === 1 || v === '1';
continue;
}
// Обрабатываем slug
if (k === 'slug') {
if (v && String(v).trim()) {
// Если slug передан, проверяем уникальность
const uniqueSlug = await generateUniqueSlug(v, pageId, tableName);
updateData[k] = uniqueSlug;
} else if (incoming.title) {
// Если slug не передан, но есть title, генерируем из title
const uniqueSlug = await generateUniqueSlug(incoming.title, pageId, tableName);
updateData[k] = uniqueSlug;
}
continue;
}
// Нормализуем категорию: приводим к нижнему регистру для консистентности
if (k === 'category') {
updateData[k] = (v && String(v).trim()) ? String(v).trim().toLowerCase() : null;
@@ -993,6 +1146,23 @@ router.patch('/:id', upload.single('file'), async (req, res) => {
// Индексация выполняется ТОЛЬКО вручную через кнопку "Индекс" (POST /:id/reindex)
// Автоматическая индексация при обновлении отключена
// Запускаем pre-rendering для блога, если страница публичная и для блога
if (updated.visibility === 'public' &&
updated.status === 'published' &&
updated.show_in_blog &&
updated.slug &&
typeof updated.slug === 'string' &&
updated.slug.trim() !== '') {
// Запускаем асинхронно, не блокируя ответ
preRenderBlog({
renderList: true,
renderArticles: true,
specificSlug: updated.slug.trim()
}).catch(err => {
console.error('[pages] Ошибка pre-rendering при обновлении страницы:', err);
});
}
res.json(updated);
} catch (error) {
console.error('[pages] PATCH /:id: Ошибка при обновлении страницы:', error);
@@ -1210,6 +1380,216 @@ router.get('/public/all', async (req, res) => {
}
});
// Получить все страницы блога (только с show_in_blog = true)
router.get('/blog/all', async (req, res) => {
try {
const tableName = `admin_pages_simple`;
// Проверяем, есть ли таблица
const existsRes = await db.getQuery()(
`SELECT to_regclass($1) as exists`, [tableName]
);
if (!existsRes.rows[0].exists) {
return res.json([]);
}
// Поддержка фильтрации по категории и поиску
const { category, search } = req.query;
let whereClause = `WHERE visibility = 'public' AND status = 'published' AND show_in_blog = TRUE`;
const params = [];
let paramIndex = 1;
if (category) {
whereClause += ` AND category = $${paramIndex}`;
params.push(category);
paramIndex++;
}
if (search) {
whereClause += ` AND (title ILIKE $${paramIndex} OR summary ILIKE $${paramIndex})`;
params.push(`%${search}%`);
paramIndex++;
}
// Сортировка: сначала по дате создания (новые первыми), затем по order_index
const { rows } = await db.getQuery()(`
SELECT * FROM ${tableName}
${whereClause}
ORDER BY
created_at DESC,
COALESCE(order_index, 0) ASC
`, params);
console.log(`[pages] GET /blog/all: найдено ${rows.length} страниц блога`);
// Обрабатываем результаты: генерируем slug для страниц, у которых его нет
const processedRows = await Promise.all(rows.map(async (row) => {
// Если у страницы нет slug, генерируем его из title
if (!row.slug || row.slug.trim() === '') {
try {
// Получаем расшифрованный title для генерации slug
const encryptionUtils = require('../utils/encryptionUtils');
const encryptionKey = encryptionUtils.getEncryptionKey();
// Расшифровываем title (если он зашифрован)
let title = row.title;
if (row.title_encrypted) {
const titleResult = await db.getQuery()(
`SELECT decrypt_text($1, $2) as title`,
[row.title_encrypted, encryptionKey]
);
title = titleResult.rows[0]?.title || row.title || `page-${row.id}`;
}
// Генерируем slug
const newSlug = await generateUniqueSlug(title, row.id, tableName);
// Обновляем slug в БД
await db.getQuery()(
`UPDATE ${tableName} SET slug = $1 WHERE id = $2`,
[newSlug, row.id]
);
// Обновляем slug в объекте row
row.slug = newSlug;
console.log(`[pages] GET /blog/all: сгенерирован slug "${newSlug}" для страницы ${row.id}`);
} catch (error) {
console.error(`[pages] GET /blog/all: ошибка генерации slug для страницы ${row.id}:`, error);
// Если не удалось сгенерировать slug, используем id как fallback
row.slug = `page-${row.id}`;
}
}
return row;
}));
res.json(processedRows);
} catch (error) {
console.error('Ошибка получения страниц блога:', error);
res.status(500).json([]);
}
});
// Получить страницу блога по slug
router.get('/blog/:slug', async (req, res) => {
try {
const tableName = `admin_pages_simple`;
let slug = req.params.slug;
// Декодируем slug (на случай если он был закодирован)
try {
slug = decodeURIComponent(slug);
} catch (e) {
// Если декодирование не удалось, используем как есть
console.warn('[pages] Ошибка декодирования slug:', e.message);
}
// Валидация slug
if (!slug || typeof slug !== 'string' || slug.trim() === '') {
return res.status(400).json({ error: 'Невалидный slug' });
}
slug = slug.trim();
// Проверяем, есть ли таблица
const existsRes = await db.getQuery()(
`SELECT to_regclass($1) as exists`, [tableName]
);
if (!existsRes.rows[0].exists) {
return res.status(404).json({ error: 'Страница не найдена' });
}
// Получаем страницу по slug
const { rows } = await db.getQuery()(
`SELECT * FROM ${tableName}
WHERE slug = $1
AND visibility = 'public'
AND status = 'published'
AND show_in_blog = TRUE
LIMIT 1`,
[slug]
);
console.log(`[pages] GET /blog/:slug: поиск по slug "${slug}", найдено строк: ${rows.length}`);
if (rows.length === 0) {
// Пробуем найти страницу без учета регистра и пробелов
const { rows: rowsCaseInsensitive } = await db.getQuery()(
`SELECT * FROM ${tableName}
WHERE LOWER(TRIM(slug)) = LOWER(TRIM($1))
AND visibility = 'public'
AND status = 'published'
AND show_in_blog = TRUE
LIMIT 1`,
[slug]
);
if (rowsCaseInsensitive.length > 0) {
console.log(`[pages] GET /blog/:slug: найдено с учетом регистра, slug в БД: "${rowsCaseInsensitive[0].slug}"`);
return res.json(rowsCaseInsensitive[0]);
}
// Показываем все доступные slug для отладки (только в dev режиме)
if (process.env.NODE_ENV !== 'production') {
const { rows: allSlugs } = await db.getQuery()(
`SELECT id, slug, title FROM ${tableName}
WHERE visibility = 'public'
AND status = 'published'
AND show_in_blog = TRUE
LIMIT 10`
);
console.log(`[pages] GET /blog/:slug: доступные slug:`, allSlugs.map(r => ({ id: r.id, slug: r.slug })));
}
return res.status(404).json({ error: 'Страница не найдена' });
}
console.log(`[pages] GET /blog/:slug: страница найдена, id: ${rows[0].id}, slug: ${rows[0].slug}`);
// Расшифровываем зашифрованные поля
const page = rows[0];
const encryptionUtils = require('../utils/encryptionUtils');
const encryptionKey = encryptionUtils.getEncryptionKey();
// Создаем объект с расшифрованными данными
const decryptedPage = { ...page };
// Расшифровываем поля, если они зашифрованы
const fieldsToDecrypt = ['title', 'summary', 'content', 'seo', 'settings'];
for (const field of fieldsToDecrypt) {
const encryptedField = `${field}_encrypted`;
if (page[encryptedField]) {
try {
const decryptResult = await db.getQuery()(
`SELECT decrypt_text($1, $2) as ${field}`,
[page[encryptedField], encryptionKey]
);
if (decryptResult.rows[0] && decryptResult.rows[0][field] !== null) {
decryptedPage[field] = decryptResult.rows[0][field];
}
} catch (decryptError) {
console.warn(`[pages] GET /blog/:slug: ошибка расшифровки поля ${field}:`, decryptError.message);
// Если расшифровка не удалась, оставляем оригинальное значение или null
if (page[field]) {
decryptedPage[field] = page[field];
}
}
} else if (page[field]) {
// Если поле не зашифровано, используем его как есть
decryptedPage[field] = page[field];
}
}
res.json(decryptedPage);
} catch (error) {
console.error('Ошибка получения страницы блога по slug:', error);
res.status(500).json({ error: 'Ошибка получения страницы' });
}
});
// Получить иерархическую структуру всех публичных страниц
router.get('/public/structure', async (req, res) => {
try {
@@ -1562,6 +1942,7 @@ router.get('/public/robots.txt', async (req, res) => {
const robotsContent = `User-agent: *
Allow: /
Allow: /blog
Allow: /content/published
Disallow: /api/
Disallow: /ws
@@ -1569,7 +1950,7 @@ Disallow: /admin/
Disallow: /content/create
Disallow: /content/edit
Sitemap: ${baseUrl}/sitemap.xml
Sitemap: ${baseUrl}/pages/public/sitemap.xml
`;
res.setHeader('Content-Type', 'text/plain');
@@ -1593,18 +1974,6 @@ router.get('/public/sitemap.xml', async (req, res) => {
`SELECT to_regclass($1) as exists`, [tableName]
);
let pages = [];
if (existsRes.rows[0].exists) {
// Получаем все опубликованные публичные страницы
const { rows } = await db.getQuery()(`
SELECT id, title, updated_at, created_at
FROM ${tableName}
WHERE status = 'published' AND visibility = 'public'
ORDER BY created_at DESC
`);
pages = rows;
}
// Генерируем XML sitemap
let sitemap = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
@@ -1613,6 +1982,11 @@ router.get('/public/sitemap.xml', async (req, res) => {
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>${baseUrl}/blog</loc>
<changefreq>daily</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>${baseUrl}/content/published</loc>
<changefreq>daily</changefreq>
@@ -1620,8 +1994,41 @@ router.get('/public/sitemap.xml', async (req, res) => {
</url>
`;
// Добавляем страницы документов
for (const page of pages) {
if (existsRes.rows[0].exists) {
// Получаем страницы блога (с show_in_blog = true)
const { rows: blogPages } = await db.getQuery()(`
SELECT id, slug, updated_at, created_at
FROM ${tableName}
WHERE status = 'published' AND visibility = 'public' AND show_in_blog = TRUE
ORDER BY created_at DESC
`);
// Добавляем страницы блога с использованием slug
for (const page of blogPages) {
const lastmod = page.updated_at || page.created_at || new Date().toISOString();
const pageUrl = page.slug
? `${baseUrl}/blog/${page.slug}`
: `${baseUrl}/blog?page=${page.id}`;
sitemap += ` <url>
<loc>${escapeXml(pageUrl)}</loc>
<lastmod>${lastmod.split('T')[0]}</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
`;
}
// Получаем остальные публичные страницы (без show_in_blog)
const { rows: otherPages } = await db.getQuery()(`
SELECT id, updated_at, created_at
FROM ${tableName}
WHERE status = 'published' AND visibility = 'public' AND (show_in_blog IS NULL OR show_in_blog = FALSE)
ORDER BY created_at DESC
`);
// Добавляем остальные публичные страницы
for (const page of otherPages) {
const lastmod = page.updated_at || page.created_at || new Date().toISOString();
const pageUrl = `${baseUrl}/content/published?page=${page.id}`;
@@ -1632,6 +2039,7 @@ router.get('/public/sitemap.xml', async (req, res) => {
<priority>0.6</priority>
</url>
`;
}
}
sitemap += `</urlset>`;
@@ -1658,4 +2066,62 @@ function escapeXml(unsafe) {
});
}
// Endpoint для ручного запуска pre-rendering блога
router.post('/blog/prerender', async (req, res) => {
try {
// Проверка прав доступа (только админ)
if (!req.session || !req.session.authenticated || !req.session.address) {
return res.status(401).json({ error: 'Требуется аутентификация' });
}
const authService = require('../services/auth-service');
const userAccessLevel = await authService.getUserAccessLevel(req.session.address);
if (!userAccessLevel.hasAccess) {
return res.status(403).json({ error: 'Недостаточно прав' });
}
// Парсим параметры
let { renderList = true, renderArticles = true, specificSlug = null } = req.body;
// Валидация параметров
renderList = Boolean(renderList);
renderArticles = Boolean(renderArticles);
// Валидация slug
if (specificSlug && (typeof specificSlug !== 'string' || specificSlug.trim() === '')) {
return res.status(400).json({ error: 'Невалидный slug' });
}
if (specificSlug) {
specificSlug = specificSlug.trim();
}
console.log('[pages] POST /blog/prerender: Запуск pre-rendering...', {
renderList,
renderArticles,
specificSlug: specificSlug || 'all'
});
// Запускаем pre-rendering асинхронно
preRenderBlog({
renderList,
renderArticles,
specificSlug
}).then(() => {
console.log('[pages] POST /blog/prerender: Pre-rendering завершен успешно');
}).catch(err => {
console.error('[pages] POST /blog/prerender: Ошибка pre-rendering:', err);
});
// Возвращаем ответ сразу, не дожидаясь завершения
res.json({
success: true,
message: 'Pre-rendering запущен. Проверьте логи для деталей.'
});
} catch (error) {
console.error('[pages] POST /blog/prerender: Ошибка:', error);
res.status(500).json({ error: 'Ошибка запуска pre-rendering' });
}
});
module.exports = router;

View File

@@ -0,0 +1,321 @@
/**
* Copyright (c) 2024-2025 Тарабанов Александр Викторович
* All rights reserved.
*
* This software is proprietary and confidential.
* Unauthorized copying, modification, or distribution is prohibited.
*
* For licensing inquiries: info@hb3-accelerator.com
* Website: https://hb3-accelerator.com
* GitHub: https://github.com/VC-HB3-Accelerator
*/
const puppeteer = require('puppeteer');
const fs = require('fs');
const path = require('path');
const { initDbPool, getQuery } = require('../db');
// Конфигурация
// В Docker используем имя контейнера, локально - localhost
const BASE_URL = process.env.PRERENDER_BASE_URL ||
process.env.FRONTEND_URL ||
(process.env.NODE_ENV === 'production' ? 'http://dapp-frontend:5173' : 'http://localhost:5173');
const OUTPUT_DIR = process.env.PRERENDER_OUTPUT_DIR ||
(process.env.NODE_ENV === 'production'
? '/app/frontend_dist/blog'
: path.join(__dirname, '../../frontend/dist/blog'));
const TIMEOUT = 30000; // 30 секунд на загрузку страницы
/**
* Создает директорию если её нет
*/
function ensureDir(dir) {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
/**
* Ожидает загрузки контента на странице
*/
async function waitForContent(page, selector, timeout = TIMEOUT) {
try {
await page.waitForSelector(selector, { timeout });
// Дополнительная задержка для полной загрузки контента
// Используем setTimeout вместо устаревшего waitForTimeout
await new Promise(resolve => setTimeout(resolve, 2000));
} catch (error) {
console.warn(`[pre-render] Селектор ${selector} не найден, продолжаем...`);
}
}
/**
* Рендерит страницу и возвращает HTML
*/
async function renderPage(browser, url, options = {}) {
if (!browser) {
throw new Error('Браузер не инициализирован');
}
const page = await browser.newPage();
try {
// Устанавливаем viewport
await page.setViewport({ width: 1920, height: 1080 });
// Устанавливаем таймауты
page.setDefaultNavigationTimeout(TIMEOUT);
page.setDefaultTimeout(TIMEOUT);
// Переходим на страницу
console.log(`[pre-render] Загрузка: ${url}`);
await page.goto(url, {
waitUntil: 'networkidle2',
timeout: TIMEOUT
});
// Ждем загрузки контента
if (options.waitForSelector) {
await waitForContent(page, options.waitForSelector);
}
// Получаем HTML
const html = await page.content();
if (!html || html.trim().length === 0) {
throw new Error('Получен пустой HTML');
}
// Оптимизируем HTML: удаляем скрипты, которые не нужны для SEO
const optimizedHtml = optimizeHtml(html, url);
return optimizedHtml;
} catch (error) {
console.error(`[pre-render] Ошибка при рендеринге ${url}:`, error.message);
throw error;
} finally {
try {
await page.close();
} catch (closeError) {
console.warn(`[pre-render] Ошибка при закрытии страницы: ${closeError.message}`);
}
}
}
/**
* Оптимизирует HTML для статического контента
*/
function optimizeHtml(html, url) {
// Удаляем скрипты, которые выполняются только на клиенте
// Но оставляем мета-теги и структуру
let optimized = html;
// Удаляем inline скрипты, которые не нужны для SEO
optimized = optimized.replace(/<script[^>]*>(?!.*application\/ld\+json)[\s\S]*?<\/script>/gi, '');
// Оставляем JSON-LD разметку
optimized = optimized.replace(/<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi,
'<script type="application/ld+json">$1</script>');
// Удаляем WebSocket подключения
optimized = optimized.replace(/new WebSocket\([^)]+\)/gi, '');
// Удаляем console.log для уменьшения размера
optimized = optimized.replace(/console\.(log|warn|error)\([^)]*\)/gi, '');
return optimized;
}
/**
* Очищает slug для использования в имени файла
*/
function sanitizeSlug(slug) {
if (!slug) return 'page';
// Удаляем небезопасные символы для имени файла
return slug.replace(/[^a-z0-9\-_]/gi, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
}
/**
* Сохраняет HTML в файл
*/
function saveHtml(html, filePath) {
try {
ensureDir(path.dirname(filePath));
// Проверяем, что путь безопасен (защита от path traversal)
const resolvedPath = path.resolve(filePath);
const outputDirResolved = path.resolve(OUTPUT_DIR);
if (!resolvedPath.startsWith(outputDirResolved)) {
throw new Error(`Небезопасный путь: ${filePath}`);
}
fs.writeFileSync(resolvedPath, html, 'utf8');
console.log(`[pre-render] Сохранено: ${resolvedPath}`);
} catch (error) {
console.error(`[pre-render] Ошибка сохранения файла ${filePath}:`, error.message);
throw error;
}
}
/**
* Получает список статей блога из БД
*/
async function getBlogArticles() {
try {
const query = getQuery();
if (!query) {
console.error('[pre-render] БД не инициализирована');
return [];
}
const tableName = 'admin_pages_simple';
const { rows } = await query(`
SELECT id, slug, title
FROM ${tableName}
WHERE visibility = 'public'
AND status = 'published'
AND show_in_blog = TRUE
AND slug IS NOT NULL
AND slug != ''
ORDER BY created_at DESC
`);
return rows || [];
} catch (error) {
console.error('[pre-render] Ошибка получения статей из БД:', error);
return [];
}
}
/**
* Основная функция pre-rendering
*/
async function preRenderBlog(options = {}) {
const {
renderList = true,
renderArticles = true,
specificSlug = null
} = options;
console.log('[pre-render] Начало pre-rendering блога...');
console.log(`[pre-render] Base URL: ${BASE_URL}`);
console.log(`[pre-render] Output dir: ${OUTPUT_DIR}`);
// Создаем директорию для вывода
ensureDir(OUTPUT_DIR);
// Инициализируем браузер
let browser;
try {
browser = await puppeteer.launch({
headless: true,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
'--disable-software-rasterizer',
'--disable-extensions'
]
});
} catch (error) {
console.error('[pre-render] Ошибка запуска браузера:', error.message);
throw new Error(`Не удалось запустить браузер: ${error.message}`);
}
try {
// Рендерим список статей
if (renderList) {
console.log('[pre-render] Рендеринг списка статей...');
const listHtml = await renderPage(browser, `${BASE_URL}/blog`, {
waitForSelector: '.blog-articles, .empty-state, .loading-state'
});
saveHtml(listHtml, path.join(OUTPUT_DIR, 'index.html'));
}
// Получаем список статей
const articles = await getBlogArticles();
console.log(`[pre-render] Найдено статей: ${articles.length}`);
if (renderArticles && articles.length > 0) {
// Рендерим статьи
let articlesToRender;
if (specificSlug) {
// Фильтруем по slug (точное совпадение)
articlesToRender = articles.filter(a => a.slug && a.slug.trim() === specificSlug.trim());
if (articlesToRender.length === 0) {
console.warn(`[pre-render] Статья со slug "${specificSlug}" не найдена`);
}
} else {
articlesToRender = articles;
}
for (const article of articlesToRender) {
try {
// Проверяем, что slug валидный
if (!article.slug || typeof article.slug !== 'string' || article.slug.trim() === '') {
console.warn(`[pre-render] Пропущена статья с невалидным slug: ${article.id}`);
continue;
}
const sanitizedSlug = sanitizeSlug(article.slug);
console.log(`[pre-render] Рендеринг статьи: ${sanitizedSlug} (${article.title})`);
const articleHtml = await renderPage(browser, `${BASE_URL}/blog/${encodeURIComponent(article.slug)}`, {
waitForSelector: '.docs-content, .article-view'
});
// Сохраняем в файл с именем slug (используем sanitized slug для безопасности)
const filePath = path.join(OUTPUT_DIR, `${sanitizedSlug}.html`);
saveHtml(articleHtml, filePath);
} catch (error) {
console.error(`[pre-render] Ошибка при рендеринге статьи ${article.slug}:`, error.message);
// Продолжаем с другими статьями
}
}
}
console.log('[pre-render] Pre-rendering завершен успешно!');
} catch (error) {
console.error('[pre-render] Критическая ошибка:', error);
throw error;
} finally {
// Закрываем браузер, если он был открыт
if (browser) {
try {
await browser.close();
} catch (closeError) {
console.error('[pre-render] Ошибка при закрытии браузера:', closeError.message);
}
}
}
}
// Если скрипт запущен напрямую
if (require.main === module) {
(async () => {
try {
// Инициализируем БД
await initDbPool();
// Парсим аргументы командной строки
const args = process.argv.slice(2);
const options = {
renderList: !args.includes('--no-list'),
renderArticles: !args.includes('--no-articles'),
specificSlug: args.find(arg => arg.startsWith('--slug='))?.split('=')[1] || null
};
await preRenderBlog(options);
process.exit(0);
} catch (error) {
console.error('[pre-render] Фатальная ошибка:', error);
process.exit(1);
}
})();
}
module.exports = { preRenderBlog };

View File

@@ -17,6 +17,20 @@
resolved "https://registry.yarnpkg.com/@anthropic-ai/sdk/-/sdk-0.51.0.tgz#2cd022c47e0eb6f4d645d8e8ed9ee0f3c5745ea8"
integrity sha512-fAFC/uHhyzfw7rs65EPVV+scXDytGNm5BjttxHf6rP/YGvaBRKEvp2lwyuMigTwMI95neeG4bzrZigz7KCikjw==
"@babel/code-frame@^7.0.0":
version "7.27.1"
resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.27.1.tgz#200f715e66d52a23b221a9435534a91cc13ad5be"
integrity sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==
dependencies:
"@babel/helper-validator-identifier" "^7.27.1"
js-tokens "^4.0.0"
picocolors "^1.1.1"
"@babel/helper-validator-identifier@^7.27.1":
version "7.28.5"
resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz#010b6938fab7cb7df74aa2bbc06aa503b8fe5fb4"
integrity sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==
"@colors/colors@1.5.0":
version "1.5.0"
resolved "https://registry.yarnpkg.com/@colors/colors/-/colors-1.5.0.tgz#bb504579c1cae923e6576a4f5da43d25f97bdbd9"
@@ -876,6 +890,19 @@
resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570"
integrity sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==
"@puppeteer/browsers@2.11.0":
version "2.11.0"
resolved "https://registry.yarnpkg.com/@puppeteer/browsers/-/browsers-2.11.0.tgz#b2dcd7cb02dd2de5909531d00e717a04bd61de73"
integrity sha512-n6oQX6mYkG8TRPuPXmbPidkUbsSRalhmaaVAQxvH1IkQy63cwsH+kOjB3e4cpCDHg0aSvsiX9bQ4s2VB6mGWUQ==
dependencies:
debug "^4.4.3"
extract-zip "^2.0.1"
progress "^2.0.3"
proxy-agent "^6.5.0"
semver "^7.7.3"
tar-fs "^3.1.1"
yargs "^17.7.2"
"@scure/base@~1.1.0", "@scure/base@~1.1.6":
version "1.1.9"
resolved "https://registry.yarnpkg.com/@scure/base/-/base-1.1.9.tgz#e5e142fbbfe251091f9c5f1dd4c834ac04c3dbd1"
@@ -1058,6 +1085,11 @@
resolved "https://registry.yarnpkg.com/@telegraf/types/-/types-7.1.0.tgz#d8bd9b2f5070b4de46971416e890338cd89fc23d"
integrity sha512-kGevOIbpMcIlCDeorKGpwZmdH7kHbqlk/Yj6dEpJMKEQw5lk0KVQY0OLXaCswy8GqlIVLd5625OB+rAntP9xVw==
"@tootallnate/quickjs-emscripten@^0.23.0":
version "0.23.0"
resolved "https://registry.yarnpkg.com/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz#db4ecfd499a9765ab24002c3b696d02e6d32a12c"
integrity sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==
"@tsconfig/node10@^1.0.7":
version "1.0.11"
resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.11.tgz#6ee46400685f130e278128c7b38b7e031ff5b2f2"
@@ -1222,6 +1254,13 @@
resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-10.0.0.tgz#e9c07fe50da0f53dc24970cca94d619ff03f6f6d"
integrity sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==
"@types/yauzl@^2.9.1":
version "2.10.3"
resolved "https://registry.yarnpkg.com/@types/yauzl/-/yauzl-2.10.3.tgz#e9b2808b4f109504a03cda958259876f61017999"
integrity sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==
dependencies:
"@types/node" "*"
abbrev@1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8"
@@ -1296,7 +1335,7 @@ agent-base@6:
dependencies:
debug "4"
agent-base@^7.1.2:
agent-base@^7.1.0, agent-base@^7.1.2:
version "7.1.4"
resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-7.1.4.tgz#e3cd76d4c548ee895d3c3fd8dc1f6c5b9032e7a8"
integrity sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==
@@ -1535,6 +1574,13 @@ assertion-error@^1.1.0:
resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.1.0.tgz#e60b6b0e8f301bd97e5375215bda406c85118c0b"
integrity sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==
ast-types@^0.13.4:
version "0.13.4"
resolved "https://registry.yarnpkg.com/ast-types/-/ast-types-0.13.4.tgz#ee0d77b343263965ecc3fb62da16e7222b2b6782"
integrity sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==
dependencies:
tslib "^2.0.1"
astral-regex@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31"
@@ -1660,6 +1706,11 @@ base64-js@^1.3.0, base64-js@^1.3.1, base64-js@^1.5.1:
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
basic-ftp@^5.0.2:
version "5.0.5"
resolved "https://registry.yarnpkg.com/basic-ftp/-/basic-ftp-5.0.5.tgz#14a474f5fffecca1f4f406f1c26b18f800225ac0"
integrity sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==
bcrypt-pbkdf@^1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e"
@@ -1831,6 +1882,11 @@ buffer-crc32@^1.0.0:
resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-1.0.0.tgz#a10993b9055081d55304bd9feb4a072de179f405"
integrity sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==
buffer-crc32@~0.2.3:
version "0.2.13"
resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242"
integrity sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==
buffer-equal-constant-time@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819"
@@ -1997,6 +2053,14 @@ chokidar@^4.0.0:
dependencies:
readdirp "^4.0.1"
chromium-bidi@12.0.1:
version "12.0.1"
resolved "https://registry.yarnpkg.com/chromium-bidi/-/chromium-bidi-12.0.1.tgz#3edb1420ab5a52004c10c223b928622c128b4f27"
integrity sha512-fGg+6jr0xjQhzpy5N4ErZxQ4wF7KLEvhGZXD6EgvZKDhu7iOhZXnZhcDxPJDcwTcrD48NPzOCo84RP2lv3Z+Cg==
dependencies:
mitt "^3.0.1"
zod "^3.24.1"
ci-info@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-2.0.0.tgz#67a9e964be31a51e15e5010d58e6f12834002f46"
@@ -2218,6 +2282,16 @@ cors@^2.8.5:
object-assign "^4"
vary "^1"
cosmiconfig@^9.0.0:
version "9.0.0"
resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-9.0.0.tgz#34c3fc58287b915f3ae905ab6dc3de258b55ad9d"
integrity sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==
dependencies:
env-paths "^2.2.1"
import-fresh "^3.3.0"
js-yaml "^4.1.0"
parse-json "^5.2.0"
crc-32@^1.2.0:
version "1.2.2"
resolved "https://registry.yarnpkg.com/crc-32/-/crc-32-1.2.2.tgz#3cad35a934b8bf71f25ca524b6da51fb7eace2ff"
@@ -2319,6 +2393,11 @@ dashdash@^1.12.0:
dependencies:
assert-plus "^1.0.0"
data-uri-to-buffer@^6.0.2:
version "6.0.2"
resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz#8a58bb67384b261a38ef18bea1810cb01badd28b"
integrity sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==
data-view-buffer@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/data-view-buffer/-/data-view-buffer-1.0.2.tgz#211a03ba95ecaf7798a8c7198d79536211f88570"
@@ -2358,7 +2437,7 @@ debug@2.6.9:
dependencies:
ms "2.0.0"
debug@4, debug@^4, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.5:
debug@4, debug@^4, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.5, debug@^4.4.3:
version "4.4.3"
resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a"
integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==
@@ -2422,6 +2501,15 @@ define-properties@^1.2.1:
has-property-descriptors "^1.0.0"
object-keys "^1.1.1"
degenerator@^5.0.0:
version "5.0.1"
resolved "https://registry.yarnpkg.com/degenerator/-/degenerator-5.0.1.tgz#9403bf297c6dad9a1ece409b37db27954f91f2f5"
integrity sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==
dependencies:
ast-types "^0.13.4"
escodegen "^2.1.0"
esprima "^4.0.1"
delayed-stream@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
@@ -2442,6 +2530,11 @@ destroy@1.2.0:
resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015"
integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==
devtools-protocol@0.0.1534754:
version "0.0.1534754"
resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.1534754.tgz#75fb0496ff133d8d7e73d2e49600b37fcb4f46a9"
integrity sha512-26T91cV5dbOYnXdJi5qQHoTtUoNEqwkHcAyu/IKtjIAxiEqPMrDiRkDOPWVsGfNZGmlQVHQbZRSjD8sxagWVsQ==
diff@^4.0.1:
version "4.0.2"
resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d"
@@ -2598,11 +2691,18 @@ entities@^4.2.0, entities@^4.4.0:
resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48"
integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==
env-paths@^2.2.0:
env-paths@^2.2.0, env-paths@^2.2.1:
version "2.2.1"
resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-2.2.1.tgz#420399d416ce1fbe9bc0a07c62fa68d67fd0f8f2"
integrity sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==
error-ex@^1.3.1:
version "1.3.4"
resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.4.tgz#b3a8d8bb6f92eecc1629e3e27d3c8607a8a32414"
integrity sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==
dependencies:
is-arrayish "^0.2.1"
es-abstract@^1.23.5, es-abstract@^1.23.6, es-abstract@^1.23.9:
version "1.24.0"
resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.24.0.tgz#c44732d2beb0acc1ed60df840869e3106e7af328"
@@ -2738,6 +2838,17 @@ escodegen@1.8.x:
optionalDependencies:
source-map "~0.2.0"
escodegen@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-2.1.0.tgz#ba93bbb7a43986d29d6041f99f5262da773e2e17"
integrity sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==
dependencies:
esprima "^4.0.1"
estraverse "^5.2.0"
esutils "^2.0.2"
optionalDependencies:
source-map "~0.6.1"
eslint-config-prettier@^10.0.2:
version "10.1.8"
resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz#15734ce4af8c2778cc32f0b01b37b0b5cd1ecb97"
@@ -2816,7 +2927,7 @@ esprima@2.7.x, esprima@^2.7.1:
resolved "https://registry.yarnpkg.com/esprima/-/esprima-2.7.3.tgz#96e3b70d5779f6ad49cd032673d1c312767ba581"
integrity sha512-OarPfz0lFCiW4/AV2Oy1Rp9qu0iusTKqykwTspGCZtPxmF81JR4MmIebvF1F9+UOKth2ZubLQ4XGGaU+hSn99A==
esprima@^4.0.0:
esprima@^4.0.0, esprima@^4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71"
integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==
@@ -3041,6 +3152,17 @@ extend@^3.0.2, extend@~3.0.2:
resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa"
integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==
extract-zip@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/extract-zip/-/extract-zip-2.0.1.tgz#663dca56fe46df890d5f131ef4a06d22bb8ba13a"
integrity sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==
dependencies:
debug "^4.1.1"
get-stream "^5.1.0"
yauzl "^2.10.0"
optionalDependencies:
"@types/yauzl" "^2.9.1"
extsprintf@1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05"
@@ -3094,6 +3216,13 @@ fastq@^1.6.0:
dependencies:
reusify "^1.0.4"
fd-slicer@~1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/fd-slicer/-/fd-slicer-1.1.0.tgz#25c7c89cb1f9077f8891bbe61d8f390eae256f1e"
integrity sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==
dependencies:
pend "~1.2.0"
fdir@^6.5.0:
version "6.5.0"
resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350"
@@ -3366,6 +3495,13 @@ get-proto@^1.0.0, get-proto@^1.0.1:
dunder-proto "^1.0.1"
es-object-atoms "^1.0.0"
get-stream@^5.1.0:
version "5.2.0"
resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-5.2.0.tgz#4966a1795ee5ace65e706c4b7beb71257d6e22d3"
integrity sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==
dependencies:
pump "^3.0.0"
get-symbol-description@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/get-symbol-description/-/get-symbol-description-1.1.0.tgz#7bdd54e0befe8ffc9f3b4e203220d9f1e881b6ee"
@@ -3375,6 +3511,15 @@ get-symbol-description@^1.1.0:
es-errors "^1.3.0"
get-intrinsic "^1.2.6"
get-uri@^6.0.1:
version "6.0.5"
resolved "https://registry.yarnpkg.com/get-uri/-/get-uri-6.0.5.tgz#714892aa4a871db671abc5395e5e9447bc306a16"
integrity sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==
dependencies:
basic-ftp "^5.0.2"
data-uri-to-buffer "^6.0.2"
debug "^4.3.4"
getpass@^0.1.1:
version "0.1.7"
resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa"
@@ -3783,6 +3928,14 @@ http-errors@~1.7.3:
statuses ">= 1.5.0 < 2"
toidentifier "1.0.0"
http-proxy-agent@^7.0.0, http-proxy-agent@^7.0.1:
version "7.0.2"
resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz#9a8b1f246866c028509486585f62b8f2c18c270e"
integrity sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==
dependencies:
agent-base "^7.1.0"
debug "^4.3.4"
http-signature@~1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.4.0.tgz#dee5a9ba2bf49416abc544abd6d967f6a94c8c3f"
@@ -3800,7 +3953,7 @@ https-proxy-agent@^5.0.0:
agent-base "6"
debug "4"
https-proxy-agent@^7.0.1:
https-proxy-agent@^7.0.1, https-proxy-agent@^7.0.6:
version "7.0.6"
resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz#da8dfeac7da130b05c2ba4b59c9b6cd66611a6b9"
integrity sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==
@@ -3862,7 +4015,7 @@ immutable@^4.0.0-rc.12:
resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.3.7.tgz#c70145fc90d89fb02021e65c84eb0226e4e5a381"
integrity sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==
import-fresh@^3.2.1:
import-fresh@^3.2.1, import-fresh@^3.3.0:
version "3.3.1"
resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.1.tgz#9cecb56503c0ada1f2741dbbd6546e4b13b57ccf"
integrity sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==
@@ -3924,6 +4077,11 @@ io-ts@1.10.4:
dependencies:
fp-ts "^1.0.0"
ip-address@^10.0.1:
version "10.1.0"
resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-10.1.0.tgz#d8dcffb34d0e02eb241427444a6e23f5b0595aa4"
integrity sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==
ipaddr.js@1.9.1:
version "1.9.1"
resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3"
@@ -3938,6 +4096,11 @@ is-array-buffer@^3.0.4, is-array-buffer@^3.0.5:
call-bound "^1.0.3"
get-intrinsic "^1.2.6"
is-arrayish@^0.2.1:
version "0.2.1"
resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d"
integrity sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==
is-arrayish@^0.3.1:
version "0.3.4"
resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.3.4.tgz#1ee5553818511915685d33bb13d31bf854e5059d"
@@ -4204,6 +4367,11 @@ js-tiktoken@^1.0.12:
dependencies:
base64-js "^1.5.1"
js-tokens@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
js-yaml@3.x:
version "3.14.1"
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.1.tgz#dae812fdb3825fa306609a8717383c50c36a0537"
@@ -4236,6 +4404,11 @@ json-buffer@3.0.1:
resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13"
integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==
json-parse-even-better-errors@^2.3.0:
version "2.3.1"
resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d"
integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==
json-schema-traverse@^0.4.1:
version "0.4.1"
resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660"
@@ -4452,6 +4625,11 @@ libqp@2.1.1:
resolved "https://registry.yarnpkg.com/libqp/-/libqp-2.1.1.tgz#f1be767a58f966f500597997cab72cfc1e17abfa"
integrity sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==
lines-and-columns@^1.1.6:
version "1.2.4"
resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632"
integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==
linkify-it@5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-5.0.0.tgz#9ef238bfa6dc70bd8e7f9572b52d369af569b421"
@@ -4533,6 +4711,11 @@ lru-cache@^10.2.0:
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119"
integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==
lru-cache@^7.14.1:
version "7.18.3"
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.18.3.tgz#f793896e0fd0e954a59dfdd82f0773808df6aa89"
integrity sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==
lru_map@^0.3.3:
version "0.3.3"
resolved "https://registry.yarnpkg.com/lru_map/-/lru_map-0.3.3.tgz#b5c8351b9464cbd750335a79650a0ec0e56118dd"
@@ -4713,6 +4896,11 @@ minimist@^1.2.5, minimist@^1.2.6:
resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707"
integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==
mitt@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/mitt/-/mitt-3.0.1.tgz#ea36cf0cc30403601ae074c8f77b7092cdab36d1"
integrity sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==
mkdirp@0.5.x, mkdirp@^0.5.6:
version "0.5.6"
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.6.tgz#7def03d2432dcae4ba1d611445c48396062255f6"
@@ -4822,6 +5010,11 @@ neo-async@^2.6.2:
resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f"
integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==
netmask@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/netmask/-/netmask-2.0.2.tgz#8b01a07644065d536383835823bc52004ebac5e7"
integrity sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==
nice-grpc-client-middleware-retry@^3.1.11:
version "3.1.11"
resolved "https://registry.yarnpkg.com/nice-grpc-client-middleware-retry/-/nice-grpc-client-middleware-retry-3.1.11.tgz#4fc0128b891d184b6c98af3bfd6aca1b608a3fd1"
@@ -5157,6 +5350,28 @@ p-timeout@^4.1.0:
resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-4.1.0.tgz#788253c0452ab0ffecf18a62dff94ff1bd09ca0a"
integrity sha512-+/wmHtzJuWii1sXn3HCuH/FTwGhrp4tmJTxSKJbfS+vkipci6osxXM5mY0jUiRzWKMTgUT8l7HFbeSwZAynqHw==
pac-proxy-agent@^7.1.0:
version "7.2.0"
resolved "https://registry.yarnpkg.com/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz#9cfaf33ff25da36f6147a20844230ec92c06e5df"
integrity sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==
dependencies:
"@tootallnate/quickjs-emscripten" "^0.23.0"
agent-base "^7.1.2"
debug "^4.3.4"
get-uri "^6.0.1"
http-proxy-agent "^7.0.0"
https-proxy-agent "^7.0.6"
pac-resolver "^7.0.1"
socks-proxy-agent "^8.0.5"
pac-resolver@^7.0.1:
version "7.0.1"
resolved "https://registry.yarnpkg.com/pac-resolver/-/pac-resolver-7.0.1.tgz#54675558ea368b64d210fd9c92a640b5f3b8abb6"
integrity sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==
dependencies:
degenerator "^5.0.0"
netmask "^2.0.2"
package-json-from-dist@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505"
@@ -5169,6 +5384,16 @@ parent-module@^1.0.0:
dependencies:
callsites "^3.0.0"
parse-json@^5.2.0:
version "5.2.0"
resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.2.0.tgz#c76fc66dee54231c962b22bcc8a72cf2f99753cd"
integrity sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==
dependencies:
"@babel/code-frame" "^7.0.0"
error-ex "^1.3.1"
json-parse-even-better-errors "^2.3.0"
lines-and-columns "^1.1.6"
parseley@^0.12.0:
version "0.12.1"
resolved "https://registry.yarnpkg.com/parseley/-/parseley-0.12.1.tgz#4afd561d50215ebe259e3e7a853e62f600683aef"
@@ -5242,6 +5467,11 @@ peberminta@^0.9.0:
resolved "https://registry.yarnpkg.com/peberminta/-/peberminta-0.9.0.tgz#8ec9bc0eb84b7d368126e71ce9033501dca2a352"
integrity sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==
pend@~1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50"
integrity sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==
performance-now@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b"
@@ -5308,7 +5538,7 @@ pgpass@1.0.5:
dependencies:
split2 "^4.1.0"
picocolors@^1.1.0:
picocolors@^1.1.0, picocolors@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b"
integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==
@@ -5385,6 +5615,11 @@ process@^0.11.10:
resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182"
integrity sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==
progress@^2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8"
integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==
prompts@^2.4.2:
version "2.4.2"
resolved "https://registry.yarnpkg.com/prompts/-/prompts-2.4.2.tgz#7b57e73b3a48029ad10ebd44f74b01722a4cb069"
@@ -5419,6 +5654,20 @@ proxy-addr@~2.0.7:
forwarded "0.2.0"
ipaddr.js "1.9.1"
proxy-agent@^6.5.0:
version "6.5.0"
resolved "https://registry.yarnpkg.com/proxy-agent/-/proxy-agent-6.5.0.tgz#9e49acba8e4ee234aacb539f89ed9c23d02f232d"
integrity sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==
dependencies:
agent-base "^7.1.2"
debug "^4.3.4"
http-proxy-agent "^7.0.1"
https-proxy-agent "^7.0.6"
lru-cache "^7.14.1"
pac-proxy-agent "^7.1.0"
proxy-from-env "^1.1.0"
socks-proxy-agent "^8.0.5"
proxy-from-env@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2"
@@ -5462,6 +5711,31 @@ punycode@^2.1.0, punycode@^2.1.1, punycode@^2.3.1:
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
puppeteer-core@24.34.0:
version "24.34.0"
resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-24.34.0.tgz#00c7f63b4a83d4ca2ec5ea3a234588fb2ce7c994"
integrity sha512-24evawO+mUGW4mvS2a2ivwLdX3gk8zRLZr9HP+7+VT2vBQnm0oh9jJEZmUE3ePJhRkYlZ93i7OMpdcoi2qNCLg==
dependencies:
"@puppeteer/browsers" "2.11.0"
chromium-bidi "12.0.1"
debug "^4.4.3"
devtools-protocol "0.0.1534754"
typed-query-selector "^2.12.0"
webdriver-bidi-protocol "0.3.10"
ws "^8.18.3"
puppeteer@^24.15.0:
version "24.34.0"
resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-24.34.0.tgz#061f6e97ce9511863ec83cd6f17a27253c68b5e9"
integrity sha512-Sdpl/zsYOsagZ4ICoZJPGZw8d9gZmK5DcxVal11dXi/1/t2eIXHjCf5NfmhDg5XnG9Nye+yo/LqMzIxie2rHTw==
dependencies:
"@puppeteer/browsers" "2.11.0"
chromium-bidi "12.0.1"
cosmiconfig "^9.0.0"
devtools-protocol "0.0.1534754"
puppeteer-core "24.34.0"
typed-query-selector "^2.12.0"
qs@6.13.0:
version "6.13.0"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.13.0.tgz#6ca3bd58439f7e245655798997787b0d88a51906"
@@ -5816,7 +6090,7 @@ selderee@^0.11.0:
dependencies:
parseley "^0.12.0"
semver@^5.5.0, semver@^6.3.0, semver@^7.3.4, semver@^7.5.3, semver@^7.6.3, semver@^7.7.1, semver@~5.3.0:
semver@^5.5.0, semver@^6.3.0, semver@^7.3.4, semver@^7.5.3, semver@^7.6.3, semver@^7.7.1, semver@^7.7.3, semver@~5.3.0:
version "7.7.2"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.2.tgz#67d99fdcd35cec21e6f8b87a7fd515a33f982b58"
integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==
@@ -6051,6 +6325,28 @@ slice-ansi@^4.0.0:
astral-regex "^2.0.0"
is-fullwidth-code-point "^3.0.0"
smart-buffer@^4.2.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.2.0.tgz#6e1d71fa4f18c05f7d0ff216dd16a481d0e8d9ae"
integrity sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==
socks-proxy-agent@^8.0.5:
version "8.0.5"
resolved "https://registry.yarnpkg.com/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz#b9cdb4e7e998509d7659d689ce7697ac21645bee"
integrity sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==
dependencies:
agent-base "^7.1.2"
debug "^4.3.4"
socks "^2.8.3"
socks@^2.8.3:
version "2.8.7"
resolved "https://registry.yarnpkg.com/socks/-/socks-2.8.7.tgz#e2fb1d9a603add75050a2067db8c381a0b5669ea"
integrity sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==
dependencies:
ip-address "^10.0.1"
smart-buffer "^4.2.0"
solc@0.8.26:
version "0.8.26"
resolved "https://registry.yarnpkg.com/solc/-/solc-0.8.26.tgz#afc78078953f6ab3e727c338a2fefcd80dd5b01a"
@@ -6097,7 +6393,7 @@ source-map-support@^0.5.13:
buffer-from "^1.0.0"
source-map "^0.6.0"
source-map@^0.6.0, source-map@^0.6.1:
source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.1:
version "0.6.1"
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
@@ -6360,7 +6656,7 @@ table@^6.8.0:
string-width "^4.2.3"
strip-ansi "^6.0.1"
tar-fs@^3.0.0:
tar-fs@^3.0.0, tar-fs@^3.1.1:
version "3.1.1"
resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-3.1.1.tgz#4f164e59fb60f103d472360731e8c6bb4a7fe9ef"
integrity sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==
@@ -6552,6 +6848,11 @@ tslib@^1.9.3:
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
tslib@^2.0.1:
version "2.8.1"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f"
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
tsort@0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/tsort/-/tsort-0.0.1.tgz#e2280f5e817f8bf4275657fd0f9aebd44f5a2786"
@@ -6677,6 +6978,11 @@ typed-array-length@^1.0.7:
possible-typed-array-names "^1.0.0"
reflect.getprototypeof "^1.0.6"
typed-query-selector@^2.12.0:
version "2.12.0"
resolved "https://registry.yarnpkg.com/typed-query-selector/-/typed-query-selector-2.12.0.tgz#92b65dbc0a42655fccf4aeb1a08b1dddce8af5f2"
integrity sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==
typedarray-to-buffer@^3.1.5:
version "3.1.5"
resolved "https://registry.yarnpkg.com/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz#a97ee7a9ff42691b9f783ff1bc5112fe3fca9080"
@@ -6901,6 +7207,11 @@ web3-utils@^1.3.6:
randombytes "^2.1.0"
utf8 "3.0.0"
webdriver-bidi-protocol@0.3.10:
version "0.3.10"
resolved "https://registry.yarnpkg.com/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.3.10.tgz#437405564ff7e200371468f4f1eba1ff5537e119"
integrity sha512-5LAE43jAVLOhB/QqX4bwSiv0Hg1HBfMmOuwBSXHdvg4GMGu9Y0lIq7p4R/yySu6w74WmaR4GM4H9t2IwLW7hgw==
webidl-conversions@^3.0.0:
version "3.0.1"
resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871"
@@ -7084,7 +7395,7 @@ write-file-atomic@3.0.3:
signal-exit "^3.0.2"
typedarray-to-buffer "^3.1.5"
ws@8.17.1, ws@8.18.3, ws@^7.4.6, ws@^8.18.0, ws@^8.18.1:
ws@8.17.1, ws@8.18.3, ws@^7.4.6, ws@^8.18.0, ws@^8.18.1, ws@^8.18.3:
version "8.18.3"
resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.3.tgz#b56b88abffde62791c639170400c93dcb0c95472"
integrity sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==
@@ -7150,6 +7461,14 @@ yargs@^17.7.2:
y18n "^5.0.5"
yargs-parser "^21.1.1"
yauzl@^2.10.0:
version "2.10.0"
resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9"
integrity sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==
dependencies:
buffer-crc32 "~0.2.3"
fd-slicer "~1.1.0"
yn@3.1.1:
version "3.1.1"
resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50"
@@ -7174,7 +7493,7 @@ zod-to-json-schema@^3.22.3:
resolved "https://registry.yarnpkg.com/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz#5920f020c4d2647edfbb954fa036082b92c9e12d"
integrity sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==
zod@^3.22.4, zod@^3.25.32:
zod@^3.22.4, zod@^3.24.1, zod@^3.25.32:
version "3.25.76"
resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.76.tgz#26841c3f6fd22a6a2760e7ccb719179768471e34"
integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==

View File

@@ -141,6 +141,17 @@ http {
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
}
# Pre-rendered blog pages (SEO optimization)
location /blog {
root /usr/share/nginx/html;
try_files $uri $uri.html /blog/index.html /index.html;
# Заголовки для SEO
add_header Cache-Control "public, max-age=3600";
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
}
# Статические файлы
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;

View File

@@ -47,7 +47,7 @@
<router-link to="/" class="nav-link-btn" active-class="active">
<span>Чат</span>
</router-link>
<router-link to="/content/published" class="nav-link-btn" active-class="active">
<router-link to="/blog" class="nav-link-btn" active-class="active">
<span>Блог</span>
</router-link>
<router-link to="/crm" class="nav-link-btn" active-class="active">

View File

@@ -30,7 +30,7 @@
<!-- Заголовок страницы -->
<header v-if="page" class="page-header">
<div class="page-header-top">
<button class="back-btn" @click="$emit('back')" title="Вернуться к списку">
<button v-if="!hideBackButton" class="back-btn" @click="$emit('back')" title="Вернуться к списку">
<i class="fas fa-arrow-left"></i>
<span>Назад</span>
</button>
@@ -95,7 +95,7 @@
</article>
<!-- Навигация: Предыдущая/Следующая -->
<nav v-if="navigation" class="page-navigation">
<nav v-if="page && navigation" class="page-navigation">
<div class="nav-section">
<a
v-if="navigation.previous"
@@ -140,18 +140,21 @@
</nav>
<!-- Загрузка -->
<div v-else-if="isLoading" class="loading-state">
<div v-if="!page && isLoading" class="loading-state">
<div class="loading-spinner"></div>
<p>Загрузка документа...</p>
</div>
<!-- Ошибка -->
<div v-else class="error-state">
<div v-else-if="!page && !isLoading" class="error-state">
<div class="error-icon">
<i class="fas fa-exclamation-triangle"></i>
</div>
<h3>Документ не найден</h3>
<p>Запрашиваемый документ не существует или не опубликован</p>
<p v-if="route.path.startsWith('/blog')" class="error-hint">
<small>Проверьте консоль браузера для деталей ошибки. Убедитесь, что страница опубликована и отмечена для отображения в блоге.</small>
</p>
</div>
</div>
</template>
@@ -168,8 +171,12 @@ import { PERMISSIONS } from '../../composables/permissions';
const props = defineProps({
pageId: {
type: Number,
type: [Number, String],
default: null
},
hideBackButton: {
type: Boolean,
default: false
}
});
@@ -204,7 +211,24 @@ function updateMetaTags(pageData) {
const title = seoData?.title || pageData.title || 'Документ';
const description = seoData?.description || pageData.summary || '';
const keywords = seoData?.keywords || '';
const canonicalUrl = `${window.location.origin}/content/published?page=${pageData.id}`;
// Определяем canonical URL в зависимости от текущего маршрута и наличия slug
const currentPath = window.location.pathname;
let canonicalUrl;
if (currentPath.startsWith('/blog')) {
// Используем slug если есть, иначе fallback на query параметр
if (pageData.slug && typeof pageData.slug === 'string' && pageData.slug.trim() !== '') {
canonicalUrl = `${window.location.origin}/blog/${encodeURIComponent(pageData.slug)}`;
} else if (pageData.id) {
canonicalUrl = `${window.location.origin}/blog?page=${pageData.id}`;
} else {
canonicalUrl = `${window.location.origin}/blog`;
}
} else {
canonicalUrl = pageData.id
? `${window.location.origin}/content/published?page=${pageData.id}`
: `${window.location.origin}/content/published`;
}
// Обновляем title
document.title = title;
@@ -246,6 +270,56 @@ function updateMetaTags(pageData) {
// Robots meta
updateOrCreateMeta('robots', 'index, follow');
// Добавляем JSON-LD разметку для статьи
addArticleJsonLd(pageData, canonicalUrl);
}
// Добавляем JSON-LD разметку для статьи
function addArticleJsonLd(pageData, canonicalUrl) {
// Удаляем старую разметку, если есть
const oldScript = document.querySelector('script[type="application/ld+json"][data-article]');
if (oldScript) {
oldScript.remove();
}
// Парсим seo данные
let seoData = pageData.seo;
if (typeof seoData === 'string') {
try {
seoData = JSON.parse(seoData);
} catch (e) {
seoData = null;
}
}
const articleJsonLd = {
'@context': 'https://schema.org',
'@type': 'Article',
'headline': seoData?.title || pageData.title || '',
'description': seoData?.description || pageData.summary || '',
'datePublished': pageData.created_at || '',
'dateModified': pageData.updated_at || pageData.created_at || '',
'url': canonicalUrl,
'author': {
'@type': 'Organization',
'name': 'Digital Legal Entity'
},
'publisher': {
'@type': 'Organization',
'name': 'Digital Legal Entity'
}
};
if (pageData.category) {
articleJsonLd.articleSection = pageData.category;
}
const script = document.createElement('script');
script.type = 'application/ld+json';
script.setAttribute('data-article', 'true');
script.textContent = JSON.stringify(articleJsonLd);
document.head.appendChild(script);
}
// Загрузка страницы
@@ -254,27 +328,138 @@ async function loadPage() {
try {
isLoading.value = true;
page.value = await pagesService.getPublicPage(props.pageId);
// Устанавливаем мета-теги для SEO
if (page.value) {
updateMetaTags(page.value);
// Определяем, это slug или id
// Проверяем, находимся ли мы на странице блога
const isBlogRoute = route.path.startsWith('/blog');
// Если это строка и не чисто число, или мы на странице блога - считаем это slug
const isSlug = isBlogRoute || (typeof props.pageId === 'string' && !/^\d+$/.test(props.pageId));
console.log('[DocsContent] loadPage:', {
pageId: props.pageId,
isBlogRoute,
isSlug,
routePath: route.path
});
if (isSlug) {
// Загружаем по slug через новый endpoint блога
// Если pageId это число, но мы на странице блога, конвертируем в строку
const slug = typeof props.pageId === 'string' ? props.pageId : String(props.pageId);
console.log('[DocsContent] Загрузка по slug:', slug);
try {
const response = await pagesService.getBlogPageBySlug(slug);
console.log('[DocsContent] Ответ от API:', {
hasData: !!response,
type: typeof response,
keys: response ? Object.keys(response) : [],
hasTitle: response?.title,
hasContent: !!response?.content,
id: response?.id
});
if (!response) {
console.error('[DocsContent] API вернул пустой ответ');
throw new Error('Страница не найдена');
}
// Устанавливаем page.value ДО любых других операций
page.value = response;
console.log('[DocsContent] page.value установлен:', {
hasPage: !!page.value,
hasTitle: !!page.value?.title,
hasContent: !!page.value?.content,
pageValue: page.value
});
// Проверяем, что page.value действительно установлен
if (!page.value) {
console.error('[DocsContent] КРИТИЧЕСКАЯ ОШИБКА: page.value не установлен после присваивания!');
}
} catch (slugError) {
console.error('[DocsContent] Ошибка загрузки по slug:', slugError);
console.error('[DocsContent] Детали ошибки:', {
message: slugError.message,
response: slugError.response?.data,
status: slugError.response?.status
});
// Если ошибка 404, пробуем найти страницу по другому способу
if (slugError.response?.status === 404) {
console.warn('[DocsContent] Страница не найдена по slug, возможно slug не совпадает');
}
throw slugError;
}
} else {
// Загружаем по id (старый способ)
const pageId = typeof props.pageId === 'number' ? props.pageId : parseInt(props.pageId, 10);
console.log('[DocsContent] Загрузка по id:', pageId);
page.value = await pagesService.getPublicPage(pageId);
}
// Загружаем навигацию
// Устанавливаем мета-теги для SEO (оборачиваем в try-catch, чтобы ошибка не блокировала отображение)
if (page.value) {
console.log('[DocsContent] Устанавливаем мета-теги для страницы:', {
id: page.value.id,
title: page.value.title,
hasContent: !!page.value.content
});
try {
updateMetaTags(page.value);
} catch (metaError) {
console.error('[DocsContent] Ошибка установки мета-тегов (не критично):', metaError);
// Продолжаем работу, даже если мета-теги не установились
}
} else {
console.error('[DocsContent] page.value пусто после загрузки!', {
response: 'данные не были установлены',
pageId: props.pageId,
isSlug
});
}
// Загружаем навигацию (только для загрузки по ID, не по slug)
if (!isSlug && page.value && page.value.id) {
try {
navigation.value = await pagesService.getPublicPageNavigation(props.pageId);
navigation.value = await pagesService.getPublicPageNavigation(page.value.id);
breadcrumbs.value = navigation.value.breadcrumbs || [];
} catch (navError) {
console.warn('Ошибка загрузки навигации:', navError);
navigation.value = null;
breadcrumbs.value = [];
}
} else {
// Для статей блога навигация не нужна
navigation.value = null;
breadcrumbs.value = [];
}
} catch (error) {
console.error('Ошибка загрузки страницы:', error);
console.error('[DocsContent] Ошибка загрузки страницы:', error);
console.error('[DocsContent] Детали ошибки:', {
message: error.message,
response: error.response?.data,
status: error.response?.status,
pageId: props.pageId,
routePath: route.path,
stack: error.stack
});
// Если это не критическая ошибка (например, 404), все равно пытаемся установить page.value
// если данные есть в response
if (error.response?.data && error.response.status !== 404) {
console.warn('[DocsContent] Пытаемся использовать данные из error.response.data');
page.value = error.response.data;
} else {
page.value = null;
}
} finally {
isLoading.value = false;
console.log('[DocsContent] loadPage завершен:', {
hasPage: !!page.value,
isLoading: isLoading.value,
pageId: props.pageId
});
}
}
@@ -340,13 +525,18 @@ const formatContent = computed(() => {
// Конфигурация DOMPurify для разрешения медиа-контента
const sanitizeConfig = {
ADD_TAGS: ['video', 'source', 'img', 'iframe'],
ADD_TAGS: ['video', 'source', 'img', 'iframe', 'pre', 'code'],
ADD_ATTR: [
'controls', 'autoplay', 'loop', 'muted', 'poster', 'preload', 'playsinline',
'src', 'alt', 'title', 'width', 'height', 'style', 'class', 'loading',
'frameborder', 'allowfullscreen', 'allow'
],
ALLOW_DATA_ATTR: true
ALLOW_DATA_ATTR: true,
// Сохраняем пробелы в HTML
KEEP_CONTENT: true,
// Не удаляем пробелы внутри тегов
FORBID_TAGS: [],
FORBID_ATTR: []
};
// Проверяем, является ли контент HTML (содержит HTML теги)
@@ -381,9 +571,10 @@ const formatContent = computed(() => {
return match; // Оставляем заголовок
});
// Удаляем пустые строки и теги в начале
sanitizedHtml = sanitizedHtml.replace(/^\s*(<br\s*\/?>|<p>\s*<\/p>)\s*/i, '');
sanitizedHtml = sanitizedHtml.trim();
// Удаляем только пустые теги в начале, но сохраняем пробелы внутри контента
sanitizedHtml = sanitizedHtml.replace(/^(<br\s*\/?>|<p>\s*<\/p>)+/i, '');
// Обрезаем только пробелы в самом начале и конце, но не внутри
sanitizedHtml = sanitizedHtml.replace(/^\s+/, '').replace(/\s+$/, '');
return sanitizedHtml;
} else if (isHtml) {
@@ -411,7 +602,8 @@ const formatContent = computed(() => {
return match; // Оставляем заголовок
});
sanitizedHtml = sanitizedHtml.trim();
// Обрезаем только пробелы в самом начале и конце, но не внутри
sanitizedHtml = sanitizedHtml.replace(/^\s+/, '').replace(/\s+$/, '');
return sanitizedHtml;
} else {
// Для обычного текста также удаляем первую строку, если она совпадает с заголовком
@@ -545,6 +737,18 @@ function setupVideoErrorHandlers() {
});
}
// Отслеживание изменений page для диагностики
watch(() => page.value, (newPage, oldPage) => {
console.log('[DocsContent] page.value изменился:', {
wasNull: oldPage === null,
isNull: newPage === null,
hasTitle: !!newPage?.title,
hasContent: !!newPage?.content,
pageId: newPage?.id,
slug: newPage?.slug
});
}, { deep: true });
// Отслеживание изменений контента для добавления обработчиков ошибок
watch(() => page.value?.content, () => {
if (page.value?.content) {

View File

@@ -29,6 +29,16 @@ const routes = [
name: 'home',
component: HomeView,
},
{
path: '/blog',
name: 'blog',
component: () => import('../views/BlogView.vue'),
},
{
path: '/blog/:slug',
name: 'blog-article',
component: () => import('../views/BlogView.vue'),
},
{
path: '/crm',
name: 'crm',

View File

@@ -62,6 +62,27 @@ export default {
});
return res.data;
},
async getBlogPages(params = {}) {
const queryParams = new URLSearchParams();
if (params.category) queryParams.append('category', params.category);
if (params.search) queryParams.append('search', params.search);
const url = `/pages/blog/all${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
const res = await api.get(url);
return res.data;
},
async getBlogPageBySlug(slug) {
console.log('[pagesService] getBlogPageBySlug:', slug);
const res = await api.get(`/pages/blog/${encodeURIComponent(slug)}`);
console.log('[pagesService] getBlogPageBySlug response:', {
status: res.status,
hasData: !!res.data,
dataKeys: res.data ? Object.keys(res.data) : [],
id: res.data?.id,
title: res.data?.title
});
return res.data;
},
async getPublicPagesStructure() {
const res = await api.get('/pages/public/structure');
return res.data;

View File

@@ -0,0 +1,503 @@
<!--
Copyright (c) 2024-2025 Тарабанов Александр Викторович
All rights reserved.
This software is proprietary and confidential.
Unauthorized copying, modification, or distribution is prohibited.
For licensing inquiries: info@hb3-accelerator.com
Website: https://hb3-accelerator.com
GitHub: https://github.com/VC-HB3-Accelerator
-->
<template>
<BaseLayout
:is-authenticated="isAuthenticated"
:identities="identities"
:token-balances="tokenBalances"
:is-loading-tokens="isLoadingTokens"
@auth-action-completed="$emit('auth-action-completed')"
>
<div class="blog-page">
<!-- Если открыта отдельная статья, показываем только её -->
<div v-if="currentPageId || currentSlug" class="article-view">
<DocsContent :page-id="currentSlug || currentPageId" :hide-back-button="true" @back="goToIndex" />
</div>
<!-- Иначе показываем список статей -->
<template v-else>
<!-- Загрузка -->
<div v-if="isLoading" class="loading-state">
<div class="loading-spinner"></div>
<p>Загрузка статей...</p>
</div>
<!-- Пустое состояние -->
<div v-else-if="filteredPages.length === 0" class="empty-state">
<div class="empty-icon"><i class="fas fa-book-open"></i></div>
<h3>Нет статей в блоге</h3>
<p>Статьи появятся здесь после их публикации редакторами</p>
</div>
<!-- Список статей -->
<div v-else class="blog-articles">
<article
v-for="page in filteredPages"
:key="page.id"
class="blog-article"
@click="openArticle(page)"
>
<div class="article-header">
<h2 class="article-title">{{ page.title }}</h2>
<div v-if="page.category" class="article-category">
<i class="fas fa-folder"></i>
{{ formatCategoryName(page.category) }}
</div>
</div>
<div v-if="page.summary" class="article-summary">
{{ page.summary }}
</div>
<div class="article-meta">
<span class="article-date">
<i class="fas fa-calendar"></i>
{{ formatDate(page.created_at) }}
</span>
<span class="article-read-more">
Читать далее
</span>
</div>
</article>
</div>
</template>
</div>
</BaseLayout>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import BaseLayout from '../components/BaseLayout.vue';
import DocsContent from '../components/docs/DocsContent.vue';
import pagesService from '../services/pagesService';
const props = defineProps({
isAuthenticated: { type: Boolean, default: false },
identities: { type: Array, default: () => [] },
tokenBalances: { type: Object, default: () => ({}) },
isLoadingTokens: { type: Boolean, default: false },
});
const emit = defineEmits(['auth-action-completed']);
const router = useRouter();
const route = useRoute();
const pages = ref([]);
const isLoading = ref(false);
const currentSlug = computed(() => {
return route.params.slug || null;
});
const currentPageId = computed(() => {
// Если есть slug, используем его для загрузки страницы
if (currentSlug.value) {
return currentSlug.value; // Временно используем slug как идентификатор
}
// Fallback на старый способ через query параметр
const queryPage = route.query.page;
if (queryPage) {
const pageId = typeof queryPage === 'string' ? parseInt(queryPage, 10) : queryPage;
if (!isNaN(pageId)) {
return pageId;
}
}
return null;
});
const filteredPages = computed(() => {
return pages.value;
});
function formatCategoryName(name) {
if (name === 'uncategorized') return 'Без категории';
if (!name || name.length === 0) return name;
return name.charAt(0).toUpperCase() + name.slice(1).toLowerCase();
}
function formatDate(date) {
if (!date) return '';
return new Date(date).toLocaleDateString('ru-RU', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
}
function openArticle(page) {
// Проверяем, что page - это объект
if (!page || typeof page !== 'object') {
console.error('[BlogView] openArticle: невалидный объект страницы');
return;
}
// Используем slug если есть, иначе fallback на id
if (page.slug && typeof page.slug === 'string' && page.slug.trim() !== '') {
router.push({ name: 'blog-article', params: { slug: page.slug.trim() } }).catch(err => {
console.error('[BlogView] Ошибка навигации:', err);
});
} else if (page.id) {
// Fallback на старый способ через query параметр
router.push({ name: 'blog', query: { page: page.id } }).catch(err => {
console.error('[BlogView] Ошибка навигации:', err);
});
} else {
console.error('[BlogView] openArticle: у страницы нет ни slug, ни id');
}
}
function goToIndex() {
router.push({ name: 'blog' });
}
async function loadPages() {
try {
isLoading.value = true;
const loadedPages = await pagesService.getBlogPages();
if (!Array.isArray(loadedPages)) {
console.error('[BlogView] loadedPages не является массивом:', typeof loadedPages, loadedPages);
pages.value = [];
return;
}
pages.value = loadedPages;
} catch (e) {
console.error('[BlogView] Ошибка загрузки страниц:', e);
pages.value = [];
} finally {
isLoading.value = false;
}
}
// Установка мета-тегов для страницы блога
function updateBlogMetaTags() {
const title = 'Блог';
const description = 'Публикации и статьи';
const canonicalUrl = `${window.location.origin}/blog`;
// Обновляем title
document.title = title;
// Обновляем или создаем meta теги
const updateOrCreateMeta = (name, content, attribute = 'name') => {
if (!content) return;
let meta = document.querySelector(`meta[${attribute}="${name}"]`);
if (!meta) {
meta = document.createElement('meta');
meta.setAttribute(attribute, name);
document.head.appendChild(meta);
}
meta.setAttribute('content', content);
};
// Meta description
updateOrCreateMeta('description', description);
// Canonical URL
let canonical = document.querySelector('link[rel="canonical"]');
if (!canonical) {
canonical = document.createElement('link');
canonical.setAttribute('rel', 'canonical');
document.head.appendChild(canonical);
}
canonical.setAttribute('href', canonicalUrl);
// Open Graph теги для социальных сетей
updateOrCreateMeta('og:title', title, 'property');
updateOrCreateMeta('og:description', description, 'property');
updateOrCreateMeta('og:type', 'website', 'property');
updateOrCreateMeta('og:url', canonicalUrl, 'property');
// Robots meta
updateOrCreateMeta('robots', 'index, follow');
}
// Добавляем JSON-LD разметку для списка статей
function addBlogJsonLd() {
// Удаляем старую разметку, если есть
const oldScript = document.querySelector('script[type="application/ld+json"][data-blog-list]');
if (oldScript) {
oldScript.remove();
}
if (pages.value.length === 0) return;
const blogJsonLd = {
'@context': 'https://schema.org',
'@type': 'Blog',
'name': 'Блог',
'description': 'Публикации и статьи',
'url': `${window.location.origin}/blog`,
'blogPost': pages.value.slice(0, 10).map(page => {
const url = (page.slug && typeof page.slug === 'string' && page.slug.trim() !== '')
? `${window.location.origin}/blog/${encodeURIComponent(page.slug)}`
: (page.id ? `${window.location.origin}/blog?page=${page.id}` : `${window.location.origin}/blog`);
return {
'@type': 'BlogPosting',
'headline': page.title || '',
'description': page.summary || '',
'datePublished': page.created_at || '',
'url': url
};
})
};
const script = document.createElement('script');
script.type = 'application/ld+json';
script.setAttribute('data-blog-list', 'true');
script.textContent = JSON.stringify(blogJsonLd);
document.head.appendChild(script);
}
// Следим за изменением currentPageId/currentSlug и обновляем мета-теги
watch(() => currentPageId.value || currentSlug.value, (newId) => {
if (!newId) {
// Если вернулись к списку, обновляем мета-теги для списка
updateBlogMetaTags();
addBlogJsonLd();
}
});
// Обновляем JSON-LD при загрузке страниц
watch(() => pages.value, () => {
if (!currentPageId.value && !currentSlug.value) {
addBlogJsonLd();
}
}, { immediate: true });
onMounted(async () => {
await loadPages();
// Устанавливаем мета-теги только если не открыта отдельная статья
if (!currentPageId.value && !currentSlug.value) {
updateBlogMetaTags();
addBlogJsonLd();
}
});
</script>
<style scoped>
.blog-page {
max-width: 1200px;
margin: 0 auto;
padding: 40px 20px;
min-height: calc(100vh - 200px);
}
.loading-state,
.empty-state {
text-align: center;
padding: 60px 20px;
}
.loading-spinner {
border: 3px solid var(--color-light, #f3f3f3);
border-top: 3px solid var(--color-primary);
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 0 auto 20px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.empty-icon {
font-size: 3rem;
color: var(--color-grey, #6c757d);
margin-bottom: 16px;
opacity: 0.6;
}
.empty-state h3 {
color: var(--color-primary);
margin: 0 0 10px 0;
font-size: var(--font-size-xl, 18px);
font-weight: 600;
}
.empty-state p {
color: var(--color-grey, #6c757d);
margin: 0;
font-size: var(--font-size-md, 14px);
}
.blog-articles {
display: flex;
flex-direction: column;
gap: 30px;
margin-bottom: 40px;
}
.blog-article {
background: var(--color-white, #fff);
border: 1px solid var(--color-border, #e9ecef);
border-radius: 12px;
padding: 25px;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: var(--shadow-sm, 0 2px 4px rgba(0, 0, 0, 0.05));
display: flex;
flex-direction: column;
height: 100%;
}
.blog-article:hover {
box-shadow: var(--shadow-lg, 0 8px 16px rgba(0, 0, 0, 0.1));
transform: translateY(-4px);
border-color: var(--color-primary);
}
.article-header {
margin-bottom: 15px;
}
.article-title {
margin: 0 0 10px 0;
color: var(--color-primary);
font-size: 1.5rem;
font-weight: 600;
line-height: 1.3;
transition: color 0.2s ease;
}
.blog-article:hover .article-title {
color: var(--color-primary-dark, #45a049);
}
.article-category {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.85rem;
color: var(--color-grey, #6c757d);
background: var(--color-light, #f8f9fa);
padding: 4px 10px;
border-radius: 4px;
font-weight: 500;
}
.article-summary {
color: var(--color-text, #495057);
line-height: 1.6;
margin-bottom: 15px;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
flex-grow: 1;
font-size: var(--font-size-md, 14px);
}
.article-meta {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.9rem;
color: var(--color-grey, #6c757d);
padding-top: 15px;
border-top: 1px solid var(--color-border, #e9ecef);
margin-top: auto;
}
.article-date {
display: flex;
align-items: center;
gap: 6px;
}
.article-read-more {
color: var(--color-primary);
font-weight: 500;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 4px;
}
.blog-article:hover .article-read-more {
color: var(--color-primary-dark, #45a049);
transform: translateX(4px);
}
.article-view {
margin-top: 30px;
}
.back-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
margin-bottom: 20px;
background: var(--color-light, #f8f9fa);
border: 1px solid var(--color-border, #e9ecef);
border-radius: 6px;
color: var(--color-text, #495057);
text-decoration: none;
font-size: 0.95rem;
cursor: pointer;
transition: all 0.2s ease;
font-weight: 500;
}
.back-btn:hover {
background: var(--color-grey-light, #e9ecef);
border-color: var(--color-primary);
color: var(--color-primary);
}
@media (max-width: 768px) {
.blog-page {
padding: 20px 15px;
}
.blog-articles {
gap: 20px;
}
.article-title {
font-size: 1.25rem;
}
.article-summary {
-webkit-line-clamp: 2;
}
}
@media (max-width: 480px) {
.blog-page {
padding: 15px 10px;
}
.blog-article {
padding: 20px;
}
.article-meta {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
}
</style>

View File

@@ -53,6 +53,19 @@
<option value="image" disabled>Изображение (PNG/JPG) скоро</option>
</select>
</div>
<div class="form-group" v-if="form.visibility === 'public'">
<label class="checkbox-label">
<input
v-model="form.showInBlog"
type="checkbox"
class="form-checkbox"
/>
<span>Показывать в блоге</span>
</label>
<p class="form-hint">
Если отмечено, страница будет отображаться на странице блога (/blog)
</p>
</div>
<p class="form-hint">
Для HTML-постов переменные подставляются при рендере. Реквизиты заполняются на странице настроек контента.
</p>
@@ -182,7 +195,7 @@
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import { ref, computed, onMounted, watch } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import BaseLayout from '../components/BaseLayout.vue';
import RichTextEditor from '../components/editor/RichTextEditor.vue';
@@ -244,7 +257,8 @@ const form = ref({
visibility: 'public',
requiredPermission: '',
format: 'html',
category: ''
category: '',
showInBlog: false
});
// Список категорий
@@ -376,6 +390,7 @@ async function loadPageForEdit() {
form.value.requiredPermission = page.required_permission || '';
form.value.format = page.format || 'html';
form.value.category = page.category || '';
form.value.showInBlog = page.show_in_blog === true || page.show_in_blog === 'true';
}
} catch (error) {
console.error('Ошибка загрузки страницы для редактирования:', error);
@@ -395,7 +410,9 @@ async function handleSubmit() {
}
if (form.value.format === 'html') {
if (!form.value.content.trim()) {
// Проверяем, что контент не пустой (учитываем только видимый текст, без HTML тегов)
const textContent = form.value.content.replace(/<[^>]*>/g, '').trim();
if (!textContent) {
alert('Заполните контент страницы!');
return;
}
@@ -416,7 +433,9 @@ async function handleSubmit() {
const pageData = {
title: form.value.title.trim(),
summary: form.value.summary.trim(),
content: form.value.content.trim(),
// Сохраняем контент без обрезки пробелов в начале/конце, чтобы сохранить форматирование
// Удаляем только пробелы в самом начале и конце, но сохраняем пробелы внутри
content: form.value.content.replace(/^\s+/, '').replace(/\s+$/, ''),
seo: form.value.seo,
status: form.value.status,
settings: form.value.settings,
@@ -427,7 +446,8 @@ async function handleSubmit() {
format: form.value.format,
mime_type: 'text/html',
storage_type: 'embedded',
category: form.value.category || null
category: form.value.category || null,
show_in_blog: form.value.visibility === 'public' ? form.value.showInBlog : false
};
page = await pagesService.updatePage(editId.value, pageData);
} else {
@@ -449,6 +469,11 @@ async function handleSubmit() {
fd.append('required_permission', '');
}
fd.append('format', form.value.format);
if (form.value.visibility === 'public') {
fd.append('show_in_blog', form.value.showInBlog ? 'true' : 'false');
} else {
fd.append('show_in_blog', 'false');
}
if (fileBlob.value) {
fd.append('file', fileBlob.value);
}
@@ -460,7 +485,9 @@ async function handleSubmit() {
const pageData = {
title: form.value.title.trim(),
summary: form.value.summary.trim(),
content: form.value.content.trim(),
// Сохраняем контент без обрезки пробелов в начале/конце, чтобы сохранить форматирование
// Удаляем только пробелы в самом начале и конце, но сохраняем пробелы внутри
content: form.value.content.replace(/^\s+/, '').replace(/\s+$/, ''),
seo: form.value.seo,
status: form.value.status,
settings: form.value.settings,
@@ -471,7 +498,8 @@ async function handleSubmit() {
format: form.value.format,
mime_type: 'text/html',
storage_type: 'embedded',
category: form.value.category || null
category: form.value.category || null,
show_in_blog: form.value.visibility === 'public' ? form.value.showInBlog : false
};
page = await pagesService.createPage(pageData);
} else {
@@ -493,6 +521,11 @@ async function handleSubmit() {
fd.append('required_permission', '');
}
fd.append('format', form.value.format);
if (form.value.visibility === 'public') {
fd.append('show_in_blog', form.value.showInBlog ? 'true' : 'false');
} else {
fd.append('show_in_blog', 'false');
}
fd.append('file', fileBlob.value);
page = await pagesService.createPage(fd, true);
}
@@ -512,6 +545,13 @@ async function handleSubmit() {
}
}
// Следим за изменением видимости и сбрасываем showInBlog для internal страниц
watch(() => form.value.visibility, (newVisibility) => {
if (newVisibility === 'internal') {
form.value.showInBlog = false;
}
});
// Загрузка данных при монтировании
onMounted(async () => {
// Проверяем права доступа