@webnumseoagent/next 0.7.3 → 0.7.5
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 +6 -0
- package/bin/cli.mjs +35 -0
- package/blog.tsx +8 -13
- package/dist/blog-grid.d.ts +11 -0
- package/dist/blog-grid.js +18 -0
- package/dist/blog.js +4 -3
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -105,6 +105,12 @@ Vercel и `git push`. **Проверь `git diff`** перед коммитом
|
|
|
105
105
|
контент = git-коммит → редеплой пересобирает страницы.
|
|
106
106
|
- Тема оформления (фирменный цвет) и канонический URL-префикс берутся из `content/blog/.blogfs.json`.
|
|
107
107
|
- Sitemap блога добавляется автоматически (раздел `sitemap/blog.xml`).
|
|
108
|
+
- Роуты карты сайта/`robots`/merchant — `force-dynamic` (не `revalidate`): статический путь Vercel
|
|
109
|
+
пре-рендерит на сборке и раздаёт из edge-кэша, из-за чего при смене данных на платформе (напр. появился
|
|
110
|
+
блог) индекс `/sitemap.xml` «застревает» и не обновляется, а `revalidateTag` пре-рендер не сбрасывает.
|
|
111
|
+
`force-dynamic` убирает пре-рендер; внутренний fetch к платформе остаётся кэшированным (300с + тег
|
|
112
|
+
`seo-<SITE>`), поэтому нагрузка почти не растёт. `init`/`blog` проставляют это идемпотентно (и на
|
|
113
|
+
авто-обновлении), так что старые сайты чинятся сами.
|
|
108
114
|
- `init`/`blog` **сами** прописывают в `next.config` `outputFileTracingIncludes` для `content/blog` —
|
|
109
115
|
без этого на Vercel файлы блога не попадают в serverless-бандл, и рантайм-чтение (ISR-ревалидация
|
|
110
116
|
страниц + route-handler карты сайта) находит пусто → блог исчезает с сайта и из sitemap. Идемпотентно;
|
package/bin/cli.mjs
CHANGED
|
@@ -143,6 +143,35 @@ async function ensureContentTracing(contentDir = "content/blog") {
|
|
|
143
143
|
ok(`${rel} (outputFileTracingIncludes → content/blog в бандле)`);
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
+
// Делает наши платформенные роуты ДИНАМИЧЕСКИМИ (force-dynamic) вместо ISR (revalidate 300).
|
|
147
|
+
// ЗАЧЕМ: /sitemap.xml (и robots/merchant) — статический путь, Vercel пре-рендерит его на сборке и
|
|
148
|
+
// раздаёт из edge-кэша. При смене данных на платформе (напр. появился блог) такой роут «застревает»:
|
|
149
|
+
// фоновая ре-валидация ненадёжна, а revalidateTag пре-рендер не сбрасывает — и карта отдаёт старое,
|
|
150
|
+
// пока сайт не пересоберут. force-dynamic убирает пре-рендер: роут исполняется на каждый запрос и всегда
|
|
151
|
+
// отражает платформу (внутренний fetch остаётся кэшированным на 300с + тег seo-<SITE>, так что нагрузка
|
|
152
|
+
// почти не растёт, а сброс по revalidateTag работает мгновенно). Идемпотентно; правит ТОЛЬКО наши роуты.
|
|
153
|
+
// Запускается и на self-update (`npx … blog --force`) — поэтому чинит все сайты сам, без разработчика.
|
|
154
|
+
async function ensureDynamicRoutes(appDir) {
|
|
155
|
+
const ext = (await exists(path.join(root, "tsconfig.json"))) ? "ts" : "js";
|
|
156
|
+
const targets = [
|
|
157
|
+
`sitemap.xml/route.${ext}`,
|
|
158
|
+
`sitemap/[name]/route.${ext}`,
|
|
159
|
+
`robots.txt/route.${ext}`,
|
|
160
|
+
`seoagent-merchant-feed.xml/route.${ext}`,
|
|
161
|
+
];
|
|
162
|
+
for (const rel of targets) {
|
|
163
|
+
const p = path.join(root, appDir, rel);
|
|
164
|
+
const src = await fs.readFile(p, "utf8").catch(() => null);
|
|
165
|
+
if (src == null) continue;
|
|
166
|
+
if (!/seoSitemapXml|seoWrapSitemap|seoRobotsTxt|seoMerchantFeedXml/.test(src)) continue; // не наш роут
|
|
167
|
+
if (/dynamic\s*=\s*["']force-dynamic["']/.test(src)) { skip(`${appDir}/${rel} — уже force-dynamic`); continue; }
|
|
168
|
+
const patched = src.replace(/export const revalidate\s*=\s*300\s*;?/, 'export const dynamic = "force-dynamic";');
|
|
169
|
+
if (patched === src) continue; // нет revalidate=300 — незнакомая форма, не трогаем
|
|
170
|
+
await fs.writeFile(p, patched);
|
|
171
|
+
ok(`${appDir}/${rel} → force-dynamic (не застревает в edge-кэше Vercel)`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
146
175
|
async function init() {
|
|
147
176
|
log("\n\x1b[1m@webnumseoagent/next — установка\x1b[0m\n");
|
|
148
177
|
// Проверяем чистоту git ДО создания файлов (чтобы весь дифф установки был обозрим).
|
|
@@ -486,6 +515,9 @@ async function init() {
|
|
|
486
515
|
await ensureContentTracing();
|
|
487
516
|
}
|
|
488
517
|
|
|
518
|
+
// Наши sitemap/robots/merchant-роуты → force-dynamic (не застревают в edge-кэше Vercel).
|
|
519
|
+
await ensureDynamicRoutes(appDir);
|
|
520
|
+
|
|
489
521
|
log("\n\x1b[1mЗатем:\x1b[0m задай эти же env-переменные в Vercel (Settings → Environment Variables) и сделай git push.\n");
|
|
490
522
|
}
|
|
491
523
|
|
|
@@ -779,6 +811,9 @@ async function blogPage() {
|
|
|
779
811
|
// Файлы блога должны попасть в serverless-бандл Vercel (ISR-ревалидация + карта сайта читают их в
|
|
780
812
|
// рантайме). Идемпотентно — запускается и на self-update (`npx … blog --force`), авто-чиня сайт.
|
|
781
813
|
await ensureContentTracing();
|
|
814
|
+
// Наши sitemap/robots/merchant-роуты → force-dynamic (иначе индекс карты застревает в кэше Vercel).
|
|
815
|
+
// Тоже гоняется на self-update — фикс сам доезжает до всех сайтов без правок разработчика.
|
|
816
|
+
await ensureDynamicRoutes(appDir);
|
|
782
817
|
|
|
783
818
|
log(`\n \x1b[32m✓\x1b[0m Готово. Проверь \x1b[36mgit diff\x1b[0m, затем commit + push. Блог: \x1b[1m${localeDir ? `/${param}/blog` : "/blog"}\x1b[0m — дизайн/шапка с сайта, список 4-в-ряд, статический рендер из файлов.\n`);
|
|
784
819
|
}
|
package/blog.tsx
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
// app/[locale]/blog/[slug]/page.tsx: const post = await seoBlogPost(params.slug); if(!post) notFound(); <SeoBlogArticle post={post} />
|
|
14
14
|
|
|
15
15
|
import type { CSSProperties } from "react";
|
|
16
|
+
import { SeoBlogGrid } from "./blog-grid";
|
|
16
17
|
|
|
17
18
|
// Файловый ридер (node:fs) грузим лениво — только когда блог реально рендерится (на сборке).
|
|
18
19
|
const blogfs = () => import("./blog-fs");
|
|
@@ -95,8 +96,8 @@ const GRID_CSS = `.sa-blog-wrap{max-width:1120px;margin:0 auto;padding:var(--sa-
|
|
|
95
96
|
.sa-blog-card h2{font-size:1.1rem;line-height:1.35;margin:0}
|
|
96
97
|
.sa-blog-card p{margin:0;color:var(--sa-muted,#64748b);font-size:.9rem;line-height:1.5;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
|
|
97
98
|
.sa-blog-more{display:flex;justify-content:center;margin-top:2rem}
|
|
98
|
-
.sa-blog-more a{display:inline-block;padding:.7rem 1.6rem;border:1px solid var(--sa-border,#e5e7eb);border-radius:var(--sa-radius,10px);text-decoration:none;color:inherit;font-weight:500}
|
|
99
|
-
.sa-blog-more a:hover{background:var(--sa-th-bg,#f8fafc)}
|
|
99
|
+
.sa-blog-more a,.sa-blog-more button{display:inline-block;padding:.7rem 1.6rem;border:1px solid var(--sa-border,#e5e7eb);border-radius:var(--sa-radius,10px);text-decoration:none;color:inherit;font:inherit;font-weight:500;background:transparent;cursor:pointer;transition:background .15s ease}
|
|
100
|
+
.sa-blog-more a:hover,.sa-blog-more button:hover{background:var(--sa-th-bg,#f8fafc)}
|
|
100
101
|
.sa-blog-empty{color:var(--sa-muted,#64748b)}`;
|
|
101
102
|
|
|
102
103
|
const BLOG_TITLE: Record<string, string> = { ru: "Блог", uz: "Blog", en: "Blog", ar: "المدونة", tr: "Blog" };
|
|
@@ -121,17 +122,11 @@ export async function SeoBlogIndex(props: { locale?: string; basePath?: string;
|
|
|
121
122
|
<style dangerouslySetInnerHTML={{ __html: GRID_CSS }} />
|
|
122
123
|
<h1 className="sa-blog-h1">{title}</h1>
|
|
123
124
|
{posts.length ? (
|
|
124
|
-
<
|
|
125
|
-
{posts.map((p) => (
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
<h2>{p.title}</h2>
|
|
130
|
-
{p.excerpt ? <p>{p.excerpt}</p> : null}
|
|
131
|
-
</div>
|
|
132
|
-
</a>
|
|
133
|
-
))}
|
|
134
|
-
</div>
|
|
125
|
+
<SeoBlogGrid
|
|
126
|
+
posts={posts.map((p) => ({ slug: p.slug, title: p.title, cover: p.cover, excerpt: p.excerpt }))}
|
|
127
|
+
basePath={basePath}
|
|
128
|
+
locale={locale}
|
|
129
|
+
/>
|
|
135
130
|
) : (
|
|
136
131
|
<p className="sa-blog-empty">{L(EMPTY_LABEL, locale, "No posts yet.")}</p>
|
|
137
132
|
)}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface SeoBlogCard {
|
|
2
|
+
slug: string;
|
|
3
|
+
title: string;
|
|
4
|
+
cover?: string | null;
|
|
5
|
+
excerpt?: string | null;
|
|
6
|
+
}
|
|
7
|
+
export declare function SeoBlogGrid({ posts, basePath, locale }: {
|
|
8
|
+
posts: SeoBlogCard[];
|
|
9
|
+
basePath: string;
|
|
10
|
+
locale: string;
|
|
11
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SeoBlogGrid = SeoBlogGrid;
|
|
4
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
5
|
+
// Клиентская сетка блога с «Показать больше» интервалами по 16 карточек (4 ряда × 4 колонки).
|
|
6
|
+
// ≤16 статей → кнопки нет; >16 → кнопка добавляет ещё до 16 карточек, и так далее.
|
|
7
|
+
// Карточки/сетка/кнопка стилизуются классами .sa-blog-* (CSS инжектит серверный SeoBlogIndex).
|
|
8
|
+
const react_1 = require("react");
|
|
9
|
+
const BATCH = 16; // 4 ряда по 4
|
|
10
|
+
const MORE_LABEL = {
|
|
11
|
+
ru: "Показать больше", uz: "Ko‘proq ko‘rsatish", en: "Show more", ar: "عرض المزيد", tr: "Daha fazla göster",
|
|
12
|
+
};
|
|
13
|
+
const label = (locale) => MORE_LABEL[locale] || MORE_LABEL[locale?.split("-")[0]] || "Show more";
|
|
14
|
+
function SeoBlogGrid({ posts, basePath, locale }) {
|
|
15
|
+
const [shown, setShown] = (0, react_1.useState)(BATCH);
|
|
16
|
+
const visible = shown >= posts.length ? posts : posts.slice(0, shown);
|
|
17
|
+
return ((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))) }), posts.length > shown ? ((0, jsx_runtime_1.jsx)("div", { className: "sa-blog-more", children: (0, jsx_runtime_1.jsx)("button", { type: "button", onClick: () => setShown((s) => s + BATCH), children: label(locale) }) })) : null] }));
|
|
18
|
+
}
|
package/dist/blog.js
CHANGED
|
@@ -39,6 +39,7 @@ exports.seoBlogStaticParams = seoBlogStaticParams;
|
|
|
39
39
|
exports.SeoBlogIndex = SeoBlogIndex;
|
|
40
40
|
exports.SeoBlogArticle = SeoBlogArticle;
|
|
41
41
|
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
42
|
+
const blog_grid_1 = require("./blog-grid");
|
|
42
43
|
// Файловый ридер (node:fs) грузим лениво — только когда блог реально рендерится (на сборке).
|
|
43
44
|
const blogfs = () => Promise.resolve().then(() => __importStar(require("./blog-fs")));
|
|
44
45
|
// ── Публичные data-функции (для generateMetadata / generateStaticParams) — читают файлы content/blog. ──
|
|
@@ -117,8 +118,8 @@ const GRID_CSS = `.sa-blog-wrap{max-width:1120px;margin:0 auto;padding:var(--sa-
|
|
|
117
118
|
.sa-blog-card h2{font-size:1.1rem;line-height:1.35;margin:0}
|
|
118
119
|
.sa-blog-card p{margin:0;color:var(--sa-muted,#64748b);font-size:.9rem;line-height:1.5;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
|
|
119
120
|
.sa-blog-more{display:flex;justify-content:center;margin-top:2rem}
|
|
120
|
-
.sa-blog-more a{display:inline-block;padding:.7rem 1.6rem;border:1px solid var(--sa-border,#e5e7eb);border-radius:var(--sa-radius,10px);text-decoration:none;color:inherit;font-weight:500}
|
|
121
|
-
.sa-blog-more a:hover{background:var(--sa-th-bg,#f8fafc)}
|
|
121
|
+
.sa-blog-more a,.sa-blog-more button{display:inline-block;padding:.7rem 1.6rem;border:1px solid var(--sa-border,#e5e7eb);border-radius:var(--sa-radius,10px);text-decoration:none;color:inherit;font:inherit;font-weight:500;background:transparent;cursor:pointer;transition:background .15s ease}
|
|
122
|
+
.sa-blog-more a:hover,.sa-blog-more button:hover{background:var(--sa-th-bg,#f8fafc)}
|
|
122
123
|
.sa-blog-empty{color:var(--sa-muted,#64748b)}`;
|
|
123
124
|
const BLOG_TITLE = { ru: "Блог", uz: "Blog", en: "Blog", ar: "المدونة", tr: "Blog" };
|
|
124
125
|
const EMPTY_LABEL = { ru: "Пока нет статей.", uz: "Hozircha maqolalar yo‘q.", en: "No posts yet.", ar: "لا توجد مقالات بعد." };
|
|
@@ -136,7 +137,7 @@ async function SeoBlogIndex(props) {
|
|
|
136
137
|
const limit = Math.max(1, Math.floor(props.limit ?? 1000));
|
|
137
138
|
const posts = m.fsBlogList(locale, limit);
|
|
138
139
|
const theme = m.fsTheme(); // тема из .blogfs.json
|
|
139
|
-
return ((0, jsx_runtime_1.jsxs)("div", { className: "sa-blog-wrap", style: themeStyle(theme), children: [(0, jsx_runtime_1.jsx)("style", { dangerouslySetInnerHTML: { __html: GRID_CSS } }), (0, jsx_runtime_1.jsx)("h1", { className: "sa-blog-h1", children: title }), posts.length ? ((0, jsx_runtime_1.jsx)(
|
|
140
|
+
return ((0, jsx_runtime_1.jsxs)("div", { className: "sa-blog-wrap", style: themeStyle(theme), children: [(0, jsx_runtime_1.jsx)("style", { dangerouslySetInnerHTML: { __html: GRID_CSS } }), (0, jsx_runtime_1.jsx)("h1", { className: "sa-blog-h1", children: title }), posts.length ? ((0, jsx_runtime_1.jsx)(blog_grid_1.SeoBlogGrid, { posts: posts.map((p) => ({ slug: p.slug, title: p.title, cover: p.cover, excerpt: p.excerpt })), basePath: basePath, locale: locale })) : ((0, jsx_runtime_1.jsx)("p", { className: "sa-blog-empty", children: L(EMPTY_LABEL, locale, "No posts yet.") }))] }));
|
|
140
141
|
}
|
|
141
142
|
// Одна статья. Принимает пост (из seoBlogPost) — так страница может сделать notFound()/метаданные.
|
|
142
143
|
function SeoBlogArticle({ post, locale }) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webnumseoagent/next",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.5",
|
|
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",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"app-router"
|
|
53
53
|
],
|
|
54
54
|
"scripts": {
|
|
55
|
-
"build": "tsc -p tsconfig.build.json",
|
|
55
|
+
"build": "tsc -p tsconfig.build.json && node scripts/postbuild.mjs",
|
|
56
56
|
"prepublishOnly": "npm run build"
|
|
57
57
|
},
|
|
58
58
|
"dependencies": {
|