@webnumseoagent/next 0.1.6 → 0.1.8

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/bin/cli.mjs CHANGED
@@ -68,7 +68,8 @@ async function detectLocales() {
68
68
  async function init() {
69
69
  log("\n\x1b[1m@webnumseoagent/next — установка\x1b[0m\n");
70
70
  // Проверяем чистоту git ДО создания файлов (чтобы весь дифф установки был обозрим).
71
- if (args.includes("--wrap") && !isGitClean() && !args.includes("--force")) {
71
+ const gitCleanStart = isGitClean();
72
+ if (args.includes("--wrap") && !gitCleanStart && !args.includes("--force")) {
72
73
  console.error(" ✗ Рабочее дерево git не чистое. Закоммить/откатись (или добавь --force), чтобы дифф установки был обозрим.");
73
74
  process.exit(1);
74
75
  }
@@ -96,7 +97,7 @@ async function init() {
96
97
  ok("Пакет @webnumseoagent/next установлен — импорт из пакета.");
97
98
  } else {
98
99
  const vendor = path.join(root, "seoagent");
99
- for (const f of ["client.ts", "jsonld.tsx"]) {
100
+ for (const f of ["client.ts", "jsonld.tsx", "sitemap-xsl.ts", "sitemap-build.ts"]) {
100
101
  const dest = path.join(vendor, f);
101
102
  if (!(await exists(dest))) {
102
103
  await fs.mkdir(vendor, { recursive: true });
@@ -109,6 +110,10 @@ async function init() {
109
110
  importJsonld = `${imp}/jsonld`;
110
111
  }
111
112
 
113
+ // Импорт клиента/jsonld для файла на нужной глубине (в vendor-режиме путь относительный).
114
+ const clientFor = (file) => (pkgMode ? "@webnumseoagent/next" : `${importPath(appDir, file)}/client`);
115
+ const jsonldFor = (file) => (pkgMode ? "@webnumseoagent/next/jsonld" : `${importPath(appDir, file)}/jsonld`);
116
+
112
117
  // Не создаём sitemap/robots, если у сайта они УЖЕ есть (public/, отдельный роут и т.п.) —
113
118
  // чтобы не перекрыть существующую карту сайта пустой.
114
119
  const anyExists = async (paths) => {
@@ -116,18 +121,90 @@ async function init() {
116
121
  return false;
117
122
  };
118
123
 
119
- // 2. sitemap.ts
120
- const hasSitemap = await anyExists([
121
- "public/sitemap.xml", "public/sitemap_index.xml",
122
- `${appDir}/sitemap.ts`, `${appDir}/sitemap.js`, `${appDir}/sitemap.xml`,
123
- `${appDir}/sitemap.xml/route.ts`, `${appDir}/sitemap.xml/route.js`,
124
- ]);
125
- if (hasSitemap) skip("sitemap сайта уже есть не трогаем)");
126
- else await ensureFile(
127
- path.join(root, appDir, `sitemap.${ext}`),
128
- `import { seoSitemap } from "${importClient}";\nexport const revalidate = 300;\nexport default async function sitemap() {\n return seoSitemap();\n}\n`,
124
+ // 2. sitemap — стилизованный вид (XSL) + индекс с разбивкой на разделы.
125
+ // у сайта нет своей карты → ГЕНЕРИМ (данные с платформы);
126
+ // • есть стандартный app/sitemap.(ts|js) → ОБОРАЧИВАЕМ (зовём их генератор, добавляем
127
+ // стиль/разбивку свежесть и все URL сохраняются);
128
+ // • статичная/нестандартная карта → НЕ ТРОГАЕМ (безопасно).
129
+ const hdr = `{ "content-type": "application/xml; charset=utf-8", "x-seoagent-sitemap": "1" }`;
130
+ const childSig = ext === "ts" ? "_req: Request, { params }: { params: { name: string } }" : "_req, { params }";
131
+ const xslRoute = async () => ensureFile(
132
+ path.join(root, appDir, `sitemap.xsl/route.${ext}`),
133
+ `import { SITEMAP_XSL } from "${clientFor("sitemap.xsl/route.ts")}";\nexport function GET() {\n return new Response(SITEMAP_XSL, { headers: { "content-type": "text/xsl; charset=utf-8" } });\n}\n`,
129
134
  );
130
135
 
136
+ const META_EXTS = ["ts", "js", "tsx", "jsx"];
137
+ const firstExisting = async (names) => {
138
+ for (const n of names) if (await exists(path.join(root, appDir, n))) return n;
139
+ return null;
140
+ };
141
+ const ourRouteExists = await anyExists([`${appDir}/sitemap.xml/route.ts`, `${appDir}/sitemap.xml/route.js`]);
142
+ const metaFile = await firstExisting(META_EXTS.map((e) => `sitemap.${e}`));
143
+ const sourceFile = await firstExisting(META_EXTS.map((e) => `sitemap.source.${e}`));
144
+ const staticSitemap = await anyExists(["public/sitemap.xml", "public/sitemap_index.xml"]);
145
+
146
+ // Конфликт: у сайта уже есть app/sitemap/[иной-сегмент] — тогда свой [name] не создаём
147
+ // (Next не даёт два разных динамических имени на один путь) и отдаём одну плоскую карту.
148
+ let dynConflict = false;
149
+ const smDir = path.join(root, appDir, "sitemap");
150
+ if (await exists(smDir)) {
151
+ const names = await fs.readdir(smDir).catch(() => []);
152
+ dynConflict = names.some((n) => /^\[.*\]$/.test(n) && n !== "[name]");
153
+ }
154
+
155
+ // Можно ли безопасно обернуть их карту: стандартный default-export и не generateSitemaps.
156
+ const wrappable = async (file) => {
157
+ const code = await fs.readFile(file, "utf8");
158
+ if (/\bgenerateSitemaps\b/.test(code)) return false;
159
+ return /export\s+default\s+(async\s+)?function/.test(code) || /export\s+default\s+\w/.test(code);
160
+ };
161
+
162
+ // Роуты оборачивания (index + опц. child + xsl). flat=true — одна карта без под-карт.
163
+ const genWrapRoutes = async (flat) => {
164
+ await ensureFile(
165
+ path.join(root, appDir, `sitemap.xml/route.${ext}`),
166
+ `import original from "../sitemap.source";\nimport { seoWrapSitemap } from "${clientFor("sitemap.xml/route.ts")}";\nexport const revalidate = 300;\nexport async function GET() {\n return new Response(await seoWrapSitemap(original, "${flat ? "flat" : "index"}"), { headers: ${hdr} });\n}\n`,
167
+ );
168
+ if (!flat)
169
+ await ensureFile(
170
+ path.join(root, appDir, `sitemap/[name]/route.${ext}`),
171
+ `import original from "../../sitemap.source";\nimport { seoWrapSitemap } from "${clientFor("sitemap/[name]/route.ts")}";\nexport const revalidate = 300;\nexport async function GET(${childSig}) {\n return new Response(await seoWrapSitemap(original, "child", params.name), { headers: ${hdr} });\n}\n`,
172
+ );
173
+ await xslRoute();
174
+ };
175
+
176
+ if (ourRouteExists) {
177
+ skip("sitemap (наши роуты уже есть)");
178
+ } else if (sourceFile) {
179
+ // Резюме прерванной установки: их карта уже переименована — просто доделываем роуты.
180
+ await genWrapRoutes(dynConflict);
181
+ ok(`sitemap: доустановлены роуты оборачивания (${appDir}/${sourceFile} уже был)`);
182
+ } else if (metaFile && (await wrappable(path.join(root, appDir, metaFile)))) {
183
+ if (!gitCleanStart && !args.includes("--force")) {
184
+ skip("sitemap (оборачивание пропущено: git не чист — закоммить и запусти снова, или --force)");
185
+ } else {
186
+ // ОБОРАЧИВАНИЕ: их карту переименовываем в модуль, наш роут её вызывает.
187
+ const metaExt = metaFile.slice("sitemap.".length);
188
+ await fs.rename(path.join(root, appDir, metaFile), path.join(root, appDir, `sitemap.source.${metaExt}`));
189
+ ok(`${appDir}/${metaFile} → ${appDir}/sitemap.source.${metaExt} (оборачиваем существующую карту)`);
190
+ await genWrapRoutes(dynConflict);
191
+ }
192
+ } else if (metaFile || staticSitemap) {
193
+ skip(`sitemap (${metaFile ? "нестандартная" : "статичная"} карта сайта — не трогаем)`);
194
+ } else {
195
+ // ГЕНЕРАЦИЯ: своей карты нет — собираем на платформе.
196
+ await ensureFile(
197
+ path.join(root, appDir, `sitemap.xml/route.${ext}`),
198
+ `import { seoSitemapXml } from "${clientFor("sitemap.xml/route.ts")}";\nexport const revalidate = 300;\nexport async function GET() {\n return new Response(await seoSitemapXml("/sitemap.xml"), { headers: ${hdr} });\n}\n`,
199
+ );
200
+ if (!dynConflict)
201
+ await ensureFile(
202
+ path.join(root, appDir, `sitemap/[name]/route.${ext}`),
203
+ `import { seoSitemapXml } from "${clientFor("sitemap/[name]/route.ts")}";\nexport const revalidate = 300;\nexport async function GET(${childSig}) {\n return new Response(await seoSitemapXml("/sitemap/" + params.name), { headers: ${hdr} });\n}\n`,
204
+ );
205
+ await xslRoute();
206
+ }
207
+
131
208
  // 3. robots.txt (вариант B — byte-exact: отдаём сырой текст с платформы как есть).
132
209
  // Если у сайта уже есть свой robots — НЕ трогаем (его можно передать платформе позже).
133
210
  const hasRobots = await anyExists([
@@ -138,7 +215,7 @@ async function init() {
138
215
  if (hasRobots) skip("robots (у сайта уже есть — не трогаем)");
139
216
  else await ensureFile(
140
217
  path.join(root, appDir, `robots.txt/route.${ext}`),
141
- `import { seoRobotsTxt } from "${importClient}";\nexport const revalidate = 300;\nexport async function GET() {\n return new Response(await seoRobotsTxt(), { headers: { "content-type": "text/plain; charset=utf-8", "x-seoagent-robots": "1" } });\n}\n`,
218
+ `import { seoRobotsTxt } from "${clientFor("robots.txt/route.ts")}";\nexport const revalidate = 300;\nexport async function GET() {\n return new Response(await seoRobotsTxt(), { headers: { "content-type": "text/plain; charset=utf-8", "x-seoagent-robots": "1" } });\n}\n`,
142
219
  );
143
220
 
144
221
  // 4. revalidate-роут (мгновенное применение правок с платформы)
@@ -174,7 +251,7 @@ async function init() {
174
251
  const rel = path.relative(path.join(root, appDir), p);
175
252
  const { expr } = routeFromFile(rel);
176
253
  const code = await fs.readFile(p, "utf8");
177
- const r = await wrapFile(code, expr, importClient);
254
+ const r = await wrapFile(code, expr, clientFor(rel));
178
255
  if (r.ok) { await fs.writeFile(p, r.code); ok(`${path.relative(root, p)} → обёрнут (route: ${expr})`); wrapped++; }
179
256
  else { manual.push({ p, expr, reason: r.reason }); skip(`${path.relative(root, p)} → вручную (${r.reason})`); }
180
257
  }
@@ -183,7 +260,7 @@ async function init() {
183
260
  log("\n Осталось вручную (замени `return { … }` на):");
184
261
  for (const m of manual) {
185
262
  log(` • ${path.relative(root, m.p)}`);
186
- log(` \x1b[36mimport { seoMeta } from "${importClient}";\n return seoMeta({ route: ${m.expr}, locale, fallback: { /* прежний объект */ } });\x1b[0m`);
263
+ log(` \x1b[36mimport { seoMeta } from "${clientFor(path.relative(path.join(root, appDir), m.p))}";\n return seoMeta({ route: ${m.expr}, locale, fallback: { /* прежний объект */ } });\x1b[0m`);
187
264
  }
188
265
  }
189
266
 
@@ -193,7 +270,7 @@ async function init() {
193
270
  const layouts = await findLayouts(path.join(root, appDir));
194
271
  for (const lp of layouts) {
195
272
  const code = await fs.readFile(lp, "utf8");
196
- const r = await mountJsonLd(code, importJsonld);
273
+ const r = await mountJsonLd(code, jsonldFor(path.relative(path.join(root, appDir), lp)));
197
274
  if (r.ok) { await fs.writeFile(lp, r.code); ok(`${path.relative(root, lp)} → смонтирован <SeoJsonLd/>`); jsonLdMounted = true; }
198
275
  else if (r.reason === "already" || r.reason === "existing-jsonld") jsonLdMounted = true;
199
276
  }
package/client.ts CHANGED
@@ -92,6 +92,28 @@ export async function seoMeta(opts: {
92
92
  return mergeIntoMetadata(fallback, data.fields);
93
93
  }
94
94
 
95
+ // Стилизованная карта: сырой XML с платформы (индекс/urlset со ссылкой на XSL).
96
+ // app/sitemap.xml/route.ts: GET() { return new Response(await seoSitemapXml("/sitemap.xml"), {headers:{"content-type":"application/xml"}}) }
97
+ // app/sitemap/[name]/route.ts: GET(_, {params}) { return new Response(await seoSitemapXml("/sitemap/" + params.name), ...) }
98
+ export async function seoSitemapXml(path: string): Promise<string> {
99
+ const FALLBACK = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"></urlset>\n`;
100
+ if (!API || !SITE || !TOKEN) return FALLBACK;
101
+ try {
102
+ const r = await fetch(`${API}/api/seo/${SITE}${path}`, {
103
+ headers: { "x-seo-agent-key": TOKEN },
104
+ next: { revalidate: 300, tags: [`seo-${SITE}`] },
105
+ signal: AbortSignal.timeout(5000),
106
+ });
107
+ if (!r.ok) return FALLBACK;
108
+ return await r.text();
109
+ } catch {
110
+ return FALLBACK;
111
+ }
112
+ }
113
+
114
+ export { SITEMAP_XSL } from "./sitemap-xsl";
115
+ export { seoWrapSitemap } from "./sitemap-build";
116
+
95
117
  // app/sitemap.ts: export default async function sitemap() { return seoSitemap() }
96
118
  export async function seoSitemap(): Promise<MetadataRoute.Sitemap> {
97
119
  const data = await fetchJson<{ entries: { url: string; alternates: Record<string, string>; lastModified?: string }[] }>(
package/dist/client.d.ts CHANGED
@@ -4,6 +4,9 @@ export declare function seoMeta(opts: {
4
4
  locale?: string;
5
5
  fallback?: Metadata;
6
6
  }): Promise<Metadata>;
7
+ export declare function seoSitemapXml(path: string): Promise<string>;
8
+ export { SITEMAP_XSL } from "./sitemap-xsl";
9
+ export { seoWrapSitemap } from "./sitemap-build";
7
10
  export declare function seoSitemap(): Promise<MetadataRoute.Sitemap>;
8
11
  export declare function seoRobots(): Promise<MetadataRoute.Robots>;
9
12
  export declare function seoRobotsTxt(): Promise<string>;
package/dist/client.js CHANGED
@@ -11,7 +11,9 @@
11
11
  // Все хелперы FAIL-SAFE: при ошибке/таймауте возвращают переданный fallback
12
12
  // (скомпилированные дефолты сайта) — сборка/рендер НИКОГДА не падает и НЕ уходит в noindex.
13
13
  Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.seoWrapSitemap = exports.SITEMAP_XSL = void 0;
14
15
  exports.seoMeta = seoMeta;
16
+ exports.seoSitemapXml = seoSitemapXml;
15
17
  exports.seoSitemap = seoSitemap;
16
18
  exports.seoRobots = seoRobots;
17
19
  exports.seoRobotsTxt = seoRobotsTxt;
@@ -83,6 +85,31 @@ async function seoMeta(opts) {
83
85
  return fallback;
84
86
  return mergeIntoMetadata(fallback, data.fields);
85
87
  }
88
+ // Стилизованная карта: сырой XML с платформы (индекс/urlset со ссылкой на XSL).
89
+ // app/sitemap.xml/route.ts: GET() { return new Response(await seoSitemapXml("/sitemap.xml"), {headers:{"content-type":"application/xml"}}) }
90
+ // app/sitemap/[name]/route.ts: GET(_, {params}) { return new Response(await seoSitemapXml("/sitemap/" + params.name), ...) }
91
+ async function seoSitemapXml(path) {
92
+ const FALLBACK = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"></urlset>\n`;
93
+ if (!API || !SITE || !TOKEN)
94
+ return FALLBACK;
95
+ try {
96
+ const r = await fetch(`${API}/api/seo/${SITE}${path}`, {
97
+ headers: { "x-seo-agent-key": TOKEN },
98
+ next: { revalidate: 300, tags: [`seo-${SITE}`] },
99
+ signal: AbortSignal.timeout(5000),
100
+ });
101
+ if (!r.ok)
102
+ return FALLBACK;
103
+ return await r.text();
104
+ }
105
+ catch {
106
+ return FALLBACK;
107
+ }
108
+ }
109
+ var sitemap_xsl_1 = require("./sitemap-xsl");
110
+ Object.defineProperty(exports, "SITEMAP_XSL", { enumerable: true, get: function () { return sitemap_xsl_1.SITEMAP_XSL; } });
111
+ var sitemap_build_1 = require("./sitemap-build");
112
+ Object.defineProperty(exports, "seoWrapSitemap", { enumerable: true, get: function () { return sitemap_build_1.seoWrapSitemap; } });
86
113
  // app/sitemap.ts: export default async function sitemap() { return seoSitemap() }
87
114
  async function seoSitemap() {
88
115
  const data = await fetchJson("/sitemap");
@@ -0,0 +1,2 @@
1
+ import type { MetadataRoute } from "next";
2
+ export declare function seoWrapSitemap(fn: () => MetadataRoute.Sitemap | Promise<MetadataRoute.Sitemap>, kind: "index" | "child" | "flat", group?: string): Promise<string>;
@@ -0,0 +1,114 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.seoWrapSitemap = seoWrapSitemap;
4
+ function escapeXml(s) {
5
+ return s
6
+ .replace(/&/g, "&amp;")
7
+ .replace(/</g, "&lt;")
8
+ .replace(/>/g, "&gt;")
9
+ .replace(/"/g, "&quot;")
10
+ .replace(/'/g, "&apos;");
11
+ }
12
+ function normalize(entries) {
13
+ const out = [];
14
+ for (const e of entries) {
15
+ if (!e || typeof e.url !== "string")
16
+ continue;
17
+ let lastmod;
18
+ if (e.lastModified)
19
+ lastmod = e.lastModified instanceof Date ? e.lastModified.toISOString() : String(e.lastModified);
20
+ out.push({
21
+ loc: e.url,
22
+ lastmod,
23
+ changefreq: typeof e.changeFrequency === "string" ? e.changeFrequency : undefined,
24
+ priority: typeof e.priority === "number" ? e.priority : undefined,
25
+ });
26
+ }
27
+ return out;
28
+ }
29
+ function originOf(entries) {
30
+ for (const e of entries) {
31
+ try {
32
+ return new URL(e.loc).origin;
33
+ }
34
+ catch {
35
+ /* ignore */
36
+ }
37
+ }
38
+ return "";
39
+ }
40
+ const dec = (s) => {
41
+ try {
42
+ return decodeURIComponent(s);
43
+ }
44
+ catch {
45
+ return s;
46
+ }
47
+ };
48
+ // Раздел URL: корень/одноуровневые → "pages", иначе по первому сегменту пути.
49
+ // Сегмент декодируем (кириллица и пр.), чтобы имя раздела совпадало с тем, что Next
50
+ // отдаёт в params.name (он декодирует динамический сегмент).
51
+ function groupOfUrl(loc) {
52
+ let path = loc;
53
+ try {
54
+ path = new URL(loc).pathname;
55
+ }
56
+ catch {
57
+ /* keep as-is */
58
+ }
59
+ const segs = path.split("/").filter(Boolean);
60
+ return segs.length <= 1 ? "pages" : dec(segs[0]);
61
+ }
62
+ function pi(origin) {
63
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<?xml-stylesheet type="text/xsl" href="${escapeXml(origin + "/sitemap.xsl")}"?>\n`;
64
+ }
65
+ function urlsetXml(origin, entries) {
66
+ const body = entries
67
+ .map((e) => {
68
+ const parts = [` <loc>${escapeXml(e.loc)}</loc>`];
69
+ if (e.lastmod)
70
+ parts.push(` <lastmod>${escapeXml(e.lastmod)}</lastmod>`);
71
+ if (e.changefreq)
72
+ parts.push(` <changefreq>${escapeXml(e.changefreq)}</changefreq>`);
73
+ if (typeof e.priority === "number")
74
+ parts.push(` <priority>${e.priority}</priority>`);
75
+ return ` <url>\n${parts.join("\n")}\n </url>`;
76
+ })
77
+ .join("\n");
78
+ return pi(origin) + `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${body}\n</urlset>\n`;
79
+ }
80
+ function indexXml(origin, groups) {
81
+ const body = groups
82
+ // g кодируем в URL (кириллица → %..), чтобы адрес под-карты был валидным;
83
+ // Next декодирует его обратно в params.name, и фильтр по разделу совпадёт.
84
+ .map((g) => ` <sitemap>\n <loc>${escapeXml(origin + "/sitemap/" + encodeURIComponent(g) + ".xml")}</loc>\n </sitemap>`)
85
+ .join("\n");
86
+ return pi(origin) + `<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${body}\n</sitemapindex>\n`;
87
+ }
88
+ const EMPTY = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"></urlset>\n`;
89
+ // Оборачивает существующий генератор карты. kind="index" — индексный файл (или один
90
+ // urlset, если раздел один); kind="child" — под-карта раздела `group`.
91
+ // Fail-safe: если генератор упал — отдаём валидную пустую карту (не 500).
92
+ async function seoWrapSitemap(fn, kind, group) {
93
+ let entries;
94
+ try {
95
+ entries = normalize(await fn());
96
+ }
97
+ catch {
98
+ return EMPTY;
99
+ }
100
+ const origin = originOf(entries);
101
+ if (!origin || entries.length === 0)
102
+ return EMPTY;
103
+ // flat — одна карта со всеми URL (без под-карт); используется, когда под-карты недоступны.
104
+ if (kind === "flat")
105
+ return urlsetXml(origin, entries);
106
+ if (kind === "child") {
107
+ const g = (group ?? "").replace(/\.xml$/i, "");
108
+ return urlsetXml(origin, entries.filter((e) => groupOfUrl(e.loc) === g));
109
+ }
110
+ const groups = [...new Set(entries.map((e) => groupOfUrl(e.loc)))].sort();
111
+ if (groups.length <= 1)
112
+ return urlsetXml(origin, entries);
113
+ return indexXml(origin, groups);
114
+ }
@@ -0,0 +1 @@
1
+ export declare const SITEMAP_XSL = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<xsl:stylesheet version=\"1.0\"\n xmlns:xsl=\"http://www.w3.org/1999/XSL/Transform\"\n xmlns:s=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n<xsl:output method=\"html\" encoding=\"UTF-8\" indent=\"yes\"/>\n<xsl:template match=\"/\">\n<html lang=\"ru\">\n<head>\n<meta charset=\"UTF-8\"/>\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"/>\n<title>XML-\u043A\u0430\u0440\u0442\u0430 \u0441\u0430\u0439\u0442\u0430</title>\n<style>\n *{box-sizing:border-box}\n body{font-family:-apple-system,system-ui,'Segoe UI',Roboto,sans-serif;color:#1e293b;margin:0;background:#f8fafc}\n .head{background:#2563eb;color:#fff;padding:28px 40px}\n .head h1{margin:0 0 8px;font-size:26px;font-weight:600}\n .head p{margin:0;font-size:14px;opacity:.92;max-width:920px;line-height:1.55}\n .head a{color:#fff}\n .wrap{padding:24px 40px}\n .count{color:#475569;font-size:14px;margin:0 0 14px}\n table{width:100%;border-collapse:collapse;background:#fff;border:1px solid #e2e8f0;border-radius:10px;overflow:hidden}\n th{background:#eff2f7;color:#334155;text-align:left;font-size:12px;text-transform:uppercase;letter-spacing:.03em;padding:12px 16px}\n td{padding:11px 16px;font-size:14px;border-top:1px solid #f1f5f9}\n tr:hover td{background:#f8fafc}\n td a{color:#2563eb;text-decoration:none;word-break:break-all}\n td a:hover{text-decoration:underline}\n .n{width:80px;color:#64748b;text-align:right}\n .d{width:140px;color:#64748b;white-space:nowrap}\n</style>\n</head>\n<body>\n<div class=\"head\">\n <h1>XML-\u043A\u0430\u0440\u0442\u0430 \u0441\u0430\u0439\u0442\u0430</h1>\n <p>\u0421\u043F\u0438\u0441\u043E\u043A \u0441\u0442\u0440\u0430\u043D\u0438\u0446 \u0434\u043B\u044F \u043F\u043E\u0438\u0441\u043A\u043E\u0432\u044B\u0445 \u0441\u0438\u0441\u0442\u0435\u043C. \u0421\u0433\u0435\u043D\u0435\u0440\u0438\u0440\u043E\u0432\u0430\u043D\u043E \u043F\u043B\u0430\u0442\u0444\u043E\u0440\u043C\u043E\u0439 <b>SEO Agent</b>. \u041F\u043E\u0434\u0440\u043E\u0431\u043D\u0435\u0435 \u043E\u0431 <a href=\"https://www.sitemaps.org/\">XML-\u043A\u0430\u0440\u0442\u0430\u0445 \u0441\u0430\u0439\u0442\u0430</a>.</p>\n</div>\n<div class=\"wrap\">\n<xsl:choose>\n <xsl:when test=\"s:sitemapindex\">\n <p class=\"count\">\u0418\u043D\u0434\u0435\u043A\u0441\u043D\u044B\u0439 \u0444\u0430\u0439\u043B \u0441\u043E\u0434\u0435\u0440\u0436\u0438\u0442 <b><xsl:value-of select=\"count(s:sitemapindex/s:sitemap)\"/></b> \u043A\u0430\u0440\u0442(\u044B) \u0441\u0430\u0439\u0442\u0430.</p>\n <table>\n <tr><th>\u041A\u0430\u0440\u0442\u0430 \u0441\u0430\u0439\u0442\u0430</th></tr>\n <xsl:for-each select=\"s:sitemapindex/s:sitemap\">\n <tr><td><a href=\"{s:loc}\"><xsl:value-of select=\"s:loc\"/></a></td></tr>\n </xsl:for-each>\n </table>\n </xsl:when>\n <xsl:otherwise>\n <p class=\"count\">\u042D\u0442\u0430 \u043A\u0430\u0440\u0442\u0430 \u0441\u0430\u0439\u0442\u0430 \u0441\u043E\u0434\u0435\u0440\u0436\u0438\u0442 <b><xsl:value-of select=\"count(s:urlset/s:url)\"/></b> URL.</p>\n <table>\n <tr><th class=\"n\">#</th><th>URL</th><th class=\"d\">\u041F\u043E\u0441\u043B\u0435\u0434\u043D\u0435\u0435 \u0438\u0437\u043C.</th></tr>\n <xsl:for-each select=\"s:urlset/s:url\">\n <tr>\n <td class=\"n\"><xsl:value-of select=\"position()\"/></td>\n <td><a href=\"{s:loc}\"><xsl:value-of select=\"s:loc\"/></a></td>\n <td class=\"d\"><xsl:value-of select=\"substring(s:lastmod,1,10)\"/></td>\n </tr>\n </xsl:for-each>\n </table>\n </xsl:otherwise>\n</xsl:choose>\n</div>\n</body>\n</html>\n</xsl:template>\n</xsl:stylesheet>\n";
@@ -0,0 +1,71 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SITEMAP_XSL = void 0;
4
+ // XSL-таблица стилей для человекочитаемого вида XML-карты в браузере (как у RankMath).
5
+ // Отдаётся адаптером на домене сайта по /sitemap.xsl; сам XML на неё ссылается.
6
+ exports.SITEMAP_XSL = `<?xml version="1.0" encoding="UTF-8"?>
7
+ <xsl:stylesheet version="1.0"
8
+ xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
9
+ xmlns:s="http://www.sitemaps.org/schemas/sitemap/0.9">
10
+ <xsl:output method="html" encoding="UTF-8" indent="yes"/>
11
+ <xsl:template match="/">
12
+ <html lang="ru">
13
+ <head>
14
+ <meta charset="UTF-8"/>
15
+ <meta name="viewport" content="width=device-width, initial-scale=1"/>
16
+ <title>XML-карта сайта</title>
17
+ <style>
18
+ *{box-sizing:border-box}
19
+ body{font-family:-apple-system,system-ui,'Segoe UI',Roboto,sans-serif;color:#1e293b;margin:0;background:#f8fafc}
20
+ .head{background:#2563eb;color:#fff;padding:28px 40px}
21
+ .head h1{margin:0 0 8px;font-size:26px;font-weight:600}
22
+ .head p{margin:0;font-size:14px;opacity:.92;max-width:920px;line-height:1.55}
23
+ .head a{color:#fff}
24
+ .wrap{padding:24px 40px}
25
+ .count{color:#475569;font-size:14px;margin:0 0 14px}
26
+ table{width:100%;border-collapse:collapse;background:#fff;border:1px solid #e2e8f0;border-radius:10px;overflow:hidden}
27
+ th{background:#eff2f7;color:#334155;text-align:left;font-size:12px;text-transform:uppercase;letter-spacing:.03em;padding:12px 16px}
28
+ td{padding:11px 16px;font-size:14px;border-top:1px solid #f1f5f9}
29
+ tr:hover td{background:#f8fafc}
30
+ td a{color:#2563eb;text-decoration:none;word-break:break-all}
31
+ td a:hover{text-decoration:underline}
32
+ .n{width:80px;color:#64748b;text-align:right}
33
+ .d{width:140px;color:#64748b;white-space:nowrap}
34
+ </style>
35
+ </head>
36
+ <body>
37
+ <div class="head">
38
+ <h1>XML-карта сайта</h1>
39
+ <p>Список страниц для поисковых систем. Сгенерировано платформой <b>SEO Agent</b>. Подробнее об <a href="https://www.sitemaps.org/">XML-картах сайта</a>.</p>
40
+ </div>
41
+ <div class="wrap">
42
+ <xsl:choose>
43
+ <xsl:when test="s:sitemapindex">
44
+ <p class="count">Индексный файл содержит <b><xsl:value-of select="count(s:sitemapindex/s:sitemap)"/></b> карт(ы) сайта.</p>
45
+ <table>
46
+ <tr><th>Карта сайта</th></tr>
47
+ <xsl:for-each select="s:sitemapindex/s:sitemap">
48
+ <tr><td><a href="{s:loc}"><xsl:value-of select="s:loc"/></a></td></tr>
49
+ </xsl:for-each>
50
+ </table>
51
+ </xsl:when>
52
+ <xsl:otherwise>
53
+ <p class="count">Эта карта сайта содержит <b><xsl:value-of select="count(s:urlset/s:url)"/></b> URL.</p>
54
+ <table>
55
+ <tr><th class="n">#</th><th>URL</th><th class="d">Последнее изм.</th></tr>
56
+ <xsl:for-each select="s:urlset/s:url">
57
+ <tr>
58
+ <td class="n"><xsl:value-of select="position()"/></td>
59
+ <td><a href="{s:loc}"><xsl:value-of select="s:loc"/></a></td>
60
+ <td class="d"><xsl:value-of select="substring(s:lastmod,1,10)"/></td>
61
+ </tr>
62
+ </xsl:for-each>
63
+ </table>
64
+ </xsl:otherwise>
65
+ </xsl:choose>
66
+ </div>
67
+ </body>
68
+ </html>
69
+ </xsl:template>
70
+ </xsl:stylesheet>
71
+ `;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webnumseoagent/next",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
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",
@@ -21,6 +21,8 @@
21
21
  "dist",
22
22
  "client.ts",
23
23
  "jsonld.tsx",
24
+ "sitemap-xsl.ts",
25
+ "sitemap-build.ts",
24
26
  "bin",
25
27
  "README.md"
26
28
  ],
@@ -0,0 +1,127 @@
1
+ // «Оборачивание» существующей карты сайта: берём данные из ИХ генератора
2
+ // (сохраняя свежесть и все URL), группируем по разделам и отдаём стилизованный
3
+ // индекс + под-карты со ссылкой на XSL. Работает на стороне сайта (без платформы).
4
+ import type { MetadataRoute } from "next";
5
+
6
+ function escapeXml(s: string): string {
7
+ return s
8
+ .replace(/&/g, "&amp;")
9
+ .replace(/</g, "&lt;")
10
+ .replace(/>/g, "&gt;")
11
+ .replace(/"/g, "&quot;")
12
+ .replace(/'/g, "&apos;");
13
+ }
14
+
15
+ interface NEntry {
16
+ loc: string;
17
+ lastmod?: string;
18
+ changefreq?: string;
19
+ priority?: number;
20
+ }
21
+
22
+ function normalize(entries: MetadataRoute.Sitemap): NEntry[] {
23
+ const out: NEntry[] = [];
24
+ for (const e of entries) {
25
+ if (!e || typeof e.url !== "string") continue;
26
+ let lastmod: string | undefined;
27
+ if (e.lastModified) lastmod = e.lastModified instanceof Date ? e.lastModified.toISOString() : String(e.lastModified);
28
+ out.push({
29
+ loc: e.url,
30
+ lastmod,
31
+ changefreq: typeof e.changeFrequency === "string" ? e.changeFrequency : undefined,
32
+ priority: typeof e.priority === "number" ? e.priority : undefined,
33
+ });
34
+ }
35
+ return out;
36
+ }
37
+
38
+ function originOf(entries: NEntry[]): string {
39
+ for (const e of entries) {
40
+ try {
41
+ return new URL(e.loc).origin;
42
+ } catch {
43
+ /* ignore */
44
+ }
45
+ }
46
+ return "";
47
+ }
48
+
49
+ const dec = (s: string): string => {
50
+ try {
51
+ return decodeURIComponent(s);
52
+ } catch {
53
+ return s;
54
+ }
55
+ };
56
+
57
+ // Раздел URL: корень/одноуровневые → "pages", иначе по первому сегменту пути.
58
+ // Сегмент декодируем (кириллица и пр.), чтобы имя раздела совпадало с тем, что Next
59
+ // отдаёт в params.name (он декодирует динамический сегмент).
60
+ function groupOfUrl(loc: string): string {
61
+ let path = loc;
62
+ try {
63
+ path = new URL(loc).pathname;
64
+ } catch {
65
+ /* keep as-is */
66
+ }
67
+ const segs = path.split("/").filter(Boolean);
68
+ return segs.length <= 1 ? "pages" : dec(segs[0]);
69
+ }
70
+
71
+ function pi(origin: string): string {
72
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<?xml-stylesheet type="text/xsl" href="${escapeXml(origin + "/sitemap.xsl")}"?>\n`;
73
+ }
74
+
75
+ function urlsetXml(origin: string, entries: NEntry[]): string {
76
+ const body = entries
77
+ .map((e) => {
78
+ const parts = [` <loc>${escapeXml(e.loc)}</loc>`];
79
+ if (e.lastmod) parts.push(` <lastmod>${escapeXml(e.lastmod)}</lastmod>`);
80
+ if (e.changefreq) parts.push(` <changefreq>${escapeXml(e.changefreq)}</changefreq>`);
81
+ if (typeof e.priority === "number") parts.push(` <priority>${e.priority}</priority>`);
82
+ return ` <url>\n${parts.join("\n")}\n </url>`;
83
+ })
84
+ .join("\n");
85
+ return pi(origin) + `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${body}\n</urlset>\n`;
86
+ }
87
+
88
+ function indexXml(origin: string, groups: string[]): string {
89
+ const body = groups
90
+ // g кодируем в URL (кириллица → %..), чтобы адрес под-карты был валидным;
91
+ // Next декодирует его обратно в params.name, и фильтр по разделу совпадёт.
92
+ .map((g) => ` <sitemap>\n <loc>${escapeXml(origin + "/sitemap/" + encodeURIComponent(g) + ".xml")}</loc>\n </sitemap>`)
93
+ .join("\n");
94
+ return pi(origin) + `<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${body}\n</sitemapindex>\n`;
95
+ }
96
+
97
+ const EMPTY = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"></urlset>\n`;
98
+
99
+ // Оборачивает существующий генератор карты. kind="index" — индексный файл (или один
100
+ // urlset, если раздел один); kind="child" — под-карта раздела `group`.
101
+ // Fail-safe: если генератор упал — отдаём валидную пустую карту (не 500).
102
+ export async function seoWrapSitemap(
103
+ fn: () => MetadataRoute.Sitemap | Promise<MetadataRoute.Sitemap>,
104
+ kind: "index" | "child" | "flat",
105
+ group?: string,
106
+ ): Promise<string> {
107
+ let entries: NEntry[];
108
+ try {
109
+ entries = normalize(await fn());
110
+ } catch {
111
+ return EMPTY;
112
+ }
113
+ const origin = originOf(entries);
114
+ if (!origin || entries.length === 0) return EMPTY;
115
+
116
+ // flat — одна карта со всеми URL (без под-карт); используется, когда под-карты недоступны.
117
+ if (kind === "flat") return urlsetXml(origin, entries);
118
+
119
+ if (kind === "child") {
120
+ const g = (group ?? "").replace(/\.xml$/i, "");
121
+ return urlsetXml(origin, entries.filter((e) => groupOfUrl(e.loc) === g));
122
+ }
123
+
124
+ const groups = [...new Set(entries.map((e) => groupOfUrl(e.loc)))].sort();
125
+ if (groups.length <= 1) return urlsetXml(origin, entries);
126
+ return indexXml(origin, groups);
127
+ }
package/sitemap-xsl.ts ADDED
@@ -0,0 +1,68 @@
1
+ // XSL-таблица стилей для человекочитаемого вида XML-карты в браузере (как у RankMath).
2
+ // Отдаётся адаптером на домене сайта по /sitemap.xsl; сам XML на неё ссылается.
3
+ export const SITEMAP_XSL = `<?xml version="1.0" encoding="UTF-8"?>
4
+ <xsl:stylesheet version="1.0"
5
+ xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
6
+ xmlns:s="http://www.sitemaps.org/schemas/sitemap/0.9">
7
+ <xsl:output method="html" encoding="UTF-8" indent="yes"/>
8
+ <xsl:template match="/">
9
+ <html lang="ru">
10
+ <head>
11
+ <meta charset="UTF-8"/>
12
+ <meta name="viewport" content="width=device-width, initial-scale=1"/>
13
+ <title>XML-карта сайта</title>
14
+ <style>
15
+ *{box-sizing:border-box}
16
+ body{font-family:-apple-system,system-ui,'Segoe UI',Roboto,sans-serif;color:#1e293b;margin:0;background:#f8fafc}
17
+ .head{background:#2563eb;color:#fff;padding:28px 40px}
18
+ .head h1{margin:0 0 8px;font-size:26px;font-weight:600}
19
+ .head p{margin:0;font-size:14px;opacity:.92;max-width:920px;line-height:1.55}
20
+ .head a{color:#fff}
21
+ .wrap{padding:24px 40px}
22
+ .count{color:#475569;font-size:14px;margin:0 0 14px}
23
+ table{width:100%;border-collapse:collapse;background:#fff;border:1px solid #e2e8f0;border-radius:10px;overflow:hidden}
24
+ th{background:#eff2f7;color:#334155;text-align:left;font-size:12px;text-transform:uppercase;letter-spacing:.03em;padding:12px 16px}
25
+ td{padding:11px 16px;font-size:14px;border-top:1px solid #f1f5f9}
26
+ tr:hover td{background:#f8fafc}
27
+ td a{color:#2563eb;text-decoration:none;word-break:break-all}
28
+ td a:hover{text-decoration:underline}
29
+ .n{width:80px;color:#64748b;text-align:right}
30
+ .d{width:140px;color:#64748b;white-space:nowrap}
31
+ </style>
32
+ </head>
33
+ <body>
34
+ <div class="head">
35
+ <h1>XML-карта сайта</h1>
36
+ <p>Список страниц для поисковых систем. Сгенерировано платформой <b>SEO Agent</b>. Подробнее об <a href="https://www.sitemaps.org/">XML-картах сайта</a>.</p>
37
+ </div>
38
+ <div class="wrap">
39
+ <xsl:choose>
40
+ <xsl:when test="s:sitemapindex">
41
+ <p class="count">Индексный файл содержит <b><xsl:value-of select="count(s:sitemapindex/s:sitemap)"/></b> карт(ы) сайта.</p>
42
+ <table>
43
+ <tr><th>Карта сайта</th></tr>
44
+ <xsl:for-each select="s:sitemapindex/s:sitemap">
45
+ <tr><td><a href="{s:loc}"><xsl:value-of select="s:loc"/></a></td></tr>
46
+ </xsl:for-each>
47
+ </table>
48
+ </xsl:when>
49
+ <xsl:otherwise>
50
+ <p class="count">Эта карта сайта содержит <b><xsl:value-of select="count(s:urlset/s:url)"/></b> URL.</p>
51
+ <table>
52
+ <tr><th class="n">#</th><th>URL</th><th class="d">Последнее изм.</th></tr>
53
+ <xsl:for-each select="s:urlset/s:url">
54
+ <tr>
55
+ <td class="n"><xsl:value-of select="position()"/></td>
56
+ <td><a href="{s:loc}"><xsl:value-of select="s:loc"/></a></td>
57
+ <td class="d"><xsl:value-of select="substring(s:lastmod,1,10)"/></td>
58
+ </tr>
59
+ </xsl:for-each>
60
+ </table>
61
+ </xsl:otherwise>
62
+ </xsl:choose>
63
+ </div>
64
+ </body>
65
+ </html>
66
+ </xsl:template>
67
+ </xsl:stylesheet>
68
+ `;