@webnumseoagent/next 0.1.7 → 0.1.10
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 +80 -20
- package/client.ts +1 -0
- package/dist/client.d.ts +1 -0
- package/dist/client.js +3 -1
- package/dist/sitemap-build.d.ts +2 -0
- package/dist/sitemap-build.js +124 -0
- package/dist/sitemap-xsl.d.ts +1 -1
- package/dist/sitemap-xsl.js +11 -2
- package/package.json +2 -1
- package/sitemap-build.ts +146 -0
- package/sitemap-xsl.ts +11 -2
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
|
-
|
|
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", "sitemap-xsl.ts"]) {
|
|
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 });
|
|
@@ -120,29 +121,88 @@ async function init() {
|
|
|
120
121
|
return false;
|
|
121
122
|
};
|
|
122
123
|
|
|
123
|
-
// 2. sitemap
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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`,
|
|
134
|
+
);
|
|
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) => {
|
|
134
164
|
await ensureFile(
|
|
135
165
|
path.join(root, appDir, `sitemap.xml/route.${ext}`),
|
|
136
|
-
`import {
|
|
137
|
-
);
|
|
138
|
-
await ensureFile(
|
|
139
|
-
path.join(root, appDir, `sitemap/[name]/route.${ext}`),
|
|
140
|
-
`import { seoSitemapXml } from "${clientFor("sitemap/[name]/route.ts")}";\nexport const revalidate = 300;\nexport async function GET(${tt ? "_req: Request, { params }: { params: { name: string } }" : "_req, { params }"}) {\n return new Response(await seoSitemapXml("/sitemap/" + params.name), { headers: { "content-type": "application/xml; charset=utf-8", "x-seoagent-sitemap": "1" } });\n}\n`,
|
|
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`,
|
|
141
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
|
+
// ГЕНЕРАЦИЯ: своей карты нет — собираем на платформе.
|
|
142
196
|
await ensureFile(
|
|
143
|
-
path.join(root, appDir, `sitemap.
|
|
144
|
-
`import {
|
|
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`,
|
|
145
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();
|
|
146
206
|
}
|
|
147
207
|
|
|
148
208
|
// 3. robots.txt (вариант B — byte-exact: отдаём сырой текст с платформы как есть).
|
package/client.ts
CHANGED
|
@@ -112,6 +112,7 @@ export async function seoSitemapXml(path: string): Promise<string> {
|
|
|
112
112
|
}
|
|
113
113
|
|
|
114
114
|
export { SITEMAP_XSL } from "./sitemap-xsl";
|
|
115
|
+
export { seoWrapSitemap } from "./sitemap-build";
|
|
115
116
|
|
|
116
117
|
// app/sitemap.ts: export default async function sitemap() { return seoSitemap() }
|
|
117
118
|
export async function seoSitemap(): Promise<MetadataRoute.Sitemap> {
|
package/dist/client.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ export declare function seoMeta(opts: {
|
|
|
6
6
|
}): Promise<Metadata>;
|
|
7
7
|
export declare function seoSitemapXml(path: string): Promise<string>;
|
|
8
8
|
export { SITEMAP_XSL } from "./sitemap-xsl";
|
|
9
|
+
export { seoWrapSitemap } from "./sitemap-build";
|
|
9
10
|
export declare function seoSitemap(): Promise<MetadataRoute.Sitemap>;
|
|
10
11
|
export declare function seoRobots(): Promise<MetadataRoute.Robots>;
|
|
11
12
|
export declare function seoRobotsTxt(): Promise<string>;
|
package/dist/client.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// Все хелперы FAIL-SAFE: при ошибке/таймауте возвращают переданный fallback
|
|
12
12
|
// (скомпилированные дефолты сайта) — сборка/рендер НИКОГДА не падает и НЕ уходит в noindex.
|
|
13
13
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
-
exports.SITEMAP_XSL = void 0;
|
|
14
|
+
exports.seoWrapSitemap = exports.SITEMAP_XSL = void 0;
|
|
15
15
|
exports.seoMeta = seoMeta;
|
|
16
16
|
exports.seoSitemapXml = seoSitemapXml;
|
|
17
17
|
exports.seoSitemap = seoSitemap;
|
|
@@ -108,6 +108,8 @@ async function seoSitemapXml(path) {
|
|
|
108
108
|
}
|
|
109
109
|
var sitemap_xsl_1 = require("./sitemap-xsl");
|
|
110
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; } });
|
|
111
113
|
// app/sitemap.ts: export default async function sitemap() { return seoSitemap() }
|
|
112
114
|
async function seoSitemap() {
|
|
113
115
|
const data = await fetchJson("/sitemap");
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.seoWrapSitemap = seoWrapSitemap;
|
|
4
|
+
function escapeXml(s) {
|
|
5
|
+
return s
|
|
6
|
+
.replace(/&/g, "&")
|
|
7
|
+
.replace(/</g, "<")
|
|
8
|
+
.replace(/>/g, ">")
|
|
9
|
+
.replace(/"/g, """)
|
|
10
|
+
.replace(/'/g, "'");
|
|
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
|
+
const images = Array.isArray(e.images)
|
|
21
|
+
? e.images.filter((i) => typeof i === "string" && !!i)
|
|
22
|
+
: [];
|
|
23
|
+
out.push({
|
|
24
|
+
loc: e.url,
|
|
25
|
+
lastmod,
|
|
26
|
+
changefreq: typeof e.changeFrequency === "string" ? e.changeFrequency : undefined,
|
|
27
|
+
priority: typeof e.priority === "number" ? e.priority : undefined,
|
|
28
|
+
images: images.length ? images : undefined,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
function originOf(entries) {
|
|
34
|
+
for (const e of entries) {
|
|
35
|
+
try {
|
|
36
|
+
return new URL(e.loc).origin;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
/* ignore */
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return "";
|
|
43
|
+
}
|
|
44
|
+
const dec = (s) => {
|
|
45
|
+
try {
|
|
46
|
+
return decodeURIComponent(s);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return s;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
// Раздел URL: корень/одноуровневые → "pages", иначе по первому сегменту пути.
|
|
53
|
+
// Сегмент декодируем (кириллица и пр.), чтобы имя раздела совпадало с тем, что Next
|
|
54
|
+
// отдаёт в params.name (он декодирует динамический сегмент).
|
|
55
|
+
function groupOfUrl(loc) {
|
|
56
|
+
let path = loc;
|
|
57
|
+
try {
|
|
58
|
+
path = new URL(loc).pathname;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
/* keep as-is */
|
|
62
|
+
}
|
|
63
|
+
const segs = path.split("/").filter(Boolean);
|
|
64
|
+
return segs.length <= 1 ? "pages" : dec(segs[0]);
|
|
65
|
+
}
|
|
66
|
+
function pi(origin) {
|
|
67
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<?xml-stylesheet type="text/xsl" href="${escapeXml(origin + "/sitemap.xsl")}"?>\n`;
|
|
68
|
+
}
|
|
69
|
+
function urlsetXml(origin, entries) {
|
|
70
|
+
const hasImages = entries.some((e) => e.images && e.images.length);
|
|
71
|
+
const body = entries
|
|
72
|
+
.map((e) => {
|
|
73
|
+
const parts = [` <loc>${escapeXml(e.loc)}</loc>`];
|
|
74
|
+
if (e.lastmod)
|
|
75
|
+
parts.push(` <lastmod>${escapeXml(e.lastmod)}</lastmod>`);
|
|
76
|
+
if (e.changefreq)
|
|
77
|
+
parts.push(` <changefreq>${escapeXml(e.changefreq)}</changefreq>`);
|
|
78
|
+
if (typeof e.priority === "number")
|
|
79
|
+
parts.push(` <priority>${e.priority}</priority>`);
|
|
80
|
+
for (const img of e.images ?? [])
|
|
81
|
+
parts.push(` <image:image><image:loc>${escapeXml(img)}</image:loc></image:image>`);
|
|
82
|
+
return ` <url>\n${parts.join("\n")}\n </url>`;
|
|
83
|
+
})
|
|
84
|
+
.join("\n");
|
|
85
|
+
const ns = hasImages
|
|
86
|
+
? `xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"`
|
|
87
|
+
: `xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"`;
|
|
88
|
+
return pi(origin) + `<urlset ${ns}>\n${body}\n</urlset>\n`;
|
|
89
|
+
}
|
|
90
|
+
function indexXml(origin, groups) {
|
|
91
|
+
const body = groups
|
|
92
|
+
// g кодируем в URL (кириллица → %..), чтобы адрес под-карты был валидным;
|
|
93
|
+
// Next декодирует его обратно в params.name, и фильтр по разделу совпадёт.
|
|
94
|
+
.map((g) => ` <sitemap>\n <loc>${escapeXml(origin + "/sitemap/" + encodeURIComponent(g) + ".xml")}</loc>\n </sitemap>`)
|
|
95
|
+
.join("\n");
|
|
96
|
+
return pi(origin) + `<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${body}\n</sitemapindex>\n`;
|
|
97
|
+
}
|
|
98
|
+
const EMPTY = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"></urlset>\n`;
|
|
99
|
+
// Оборачивает существующий генератор карты. kind="index" — индексный файл (или один
|
|
100
|
+
// urlset, если раздел один); kind="child" — под-карта раздела `group`.
|
|
101
|
+
// Fail-safe: если генератор упал — отдаём валидную пустую карту (не 500).
|
|
102
|
+
async function seoWrapSitemap(fn, kind, group) {
|
|
103
|
+
let entries;
|
|
104
|
+
try {
|
|
105
|
+
entries = normalize(await fn());
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return EMPTY;
|
|
109
|
+
}
|
|
110
|
+
const origin = originOf(entries);
|
|
111
|
+
if (!origin || entries.length === 0)
|
|
112
|
+
return EMPTY;
|
|
113
|
+
// flat — одна карта со всеми URL (без под-карт); используется, когда под-карты недоступны.
|
|
114
|
+
if (kind === "flat")
|
|
115
|
+
return urlsetXml(origin, entries);
|
|
116
|
+
if (kind === "child") {
|
|
117
|
+
const g = (group ?? "").replace(/\.xml$/i, "");
|
|
118
|
+
return urlsetXml(origin, entries.filter((e) => groupOfUrl(e.loc) === g));
|
|
119
|
+
}
|
|
120
|
+
const groups = [...new Set(entries.map((e) => groupOfUrl(e.loc)))].sort();
|
|
121
|
+
if (groups.length <= 1)
|
|
122
|
+
return urlsetXml(origin, entries);
|
|
123
|
+
return indexXml(origin, groups);
|
|
124
|
+
}
|
package/dist/sitemap-xsl.d.ts
CHANGED
|
@@ -1 +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</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></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 </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";
|
|
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 xmlns:image=\"http://www.google.com/schemas/sitemap-image/1.1\">\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 .back{margin:0 0 12px}\n .back a{color:#2563eb;text-decoration:none;font-size:14px;font-weight:500}\n .back a:hover{text-decoration:underline}\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 .i{width:120px;color:#64748b;text-align:center}\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=\"back\"><a href=\"/sitemap.xml\">\u2190 \u0418\u043D\u0434\u0435\u043A\u0441 \u043A\u0430\u0440\u0442\u044B \u0441\u0430\u0439\u0442\u0430</a></p>\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=\"i\">\u0418\u0437\u043E\u0431\u0440\u0430\u0436\u0435\u043D\u0438\u044F</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=\"i\"><xsl:value-of select=\"count(image:image)\"/></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";
|
package/dist/sitemap-xsl.js
CHANGED
|
@@ -6,7 +6,8 @@ exports.SITEMAP_XSL = void 0;
|
|
|
6
6
|
exports.SITEMAP_XSL = `<?xml version="1.0" encoding="UTF-8"?>
|
|
7
7
|
<xsl:stylesheet version="1.0"
|
|
8
8
|
xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
|
|
9
|
-
xmlns:s="http://www.sitemaps.org/schemas/sitemap/0.9"
|
|
9
|
+
xmlns:s="http://www.sitemaps.org/schemas/sitemap/0.9"
|
|
10
|
+
xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">
|
|
10
11
|
<xsl:output method="html" encoding="UTF-8" indent="yes"/>
|
|
11
12
|
<xsl:template match="/">
|
|
12
13
|
<html lang="ru">
|
|
@@ -23,6 +24,9 @@ exports.SITEMAP_XSL = `<?xml version="1.0" encoding="UTF-8"?>
|
|
|
23
24
|
.head a{color:#fff}
|
|
24
25
|
.wrap{padding:24px 40px}
|
|
25
26
|
.count{color:#475569;font-size:14px;margin:0 0 14px}
|
|
27
|
+
.back{margin:0 0 12px}
|
|
28
|
+
.back a{color:#2563eb;text-decoration:none;font-size:14px;font-weight:500}
|
|
29
|
+
.back a:hover{text-decoration:underline}
|
|
26
30
|
table{width:100%;border-collapse:collapse;background:#fff;border:1px solid #e2e8f0;border-radius:10px;overflow:hidden}
|
|
27
31
|
th{background:#eff2f7;color:#334155;text-align:left;font-size:12px;text-transform:uppercase;letter-spacing:.03em;padding:12px 16px}
|
|
28
32
|
td{padding:11px 16px;font-size:14px;border-top:1px solid #f1f5f9}
|
|
@@ -30,6 +34,8 @@ exports.SITEMAP_XSL = `<?xml version="1.0" encoding="UTF-8"?>
|
|
|
30
34
|
td a{color:#2563eb;text-decoration:none;word-break:break-all}
|
|
31
35
|
td a:hover{text-decoration:underline}
|
|
32
36
|
.n{width:80px;color:#64748b;text-align:right}
|
|
37
|
+
.i{width:120px;color:#64748b;text-align:center}
|
|
38
|
+
.d{width:140px;color:#64748b;white-space:nowrap}
|
|
33
39
|
</style>
|
|
34
40
|
</head>
|
|
35
41
|
<body>
|
|
@@ -49,13 +55,16 @@ exports.SITEMAP_XSL = `<?xml version="1.0" encoding="UTF-8"?>
|
|
|
49
55
|
</table>
|
|
50
56
|
</xsl:when>
|
|
51
57
|
<xsl:otherwise>
|
|
58
|
+
<p class="back"><a href="/sitemap.xml">← Индекс карты сайта</a></p>
|
|
52
59
|
<p class="count">Эта карта сайта содержит <b><xsl:value-of select="count(s:urlset/s:url)"/></b> URL.</p>
|
|
53
60
|
<table>
|
|
54
|
-
<tr><th class="n">#</th><th>URL</th></tr>
|
|
61
|
+
<tr><th class="n">#</th><th>URL</th><th class="i">Изображения</th><th class="d">Последнее изм.</th></tr>
|
|
55
62
|
<xsl:for-each select="s:urlset/s:url">
|
|
56
63
|
<tr>
|
|
57
64
|
<td class="n"><xsl:value-of select="position()"/></td>
|
|
58
65
|
<td><a href="{s:loc}"><xsl:value-of select="s:loc"/></a></td>
|
|
66
|
+
<td class="i"><xsl:value-of select="count(image:image)"/></td>
|
|
67
|
+
<td class="d"><xsl:value-of select="substring(s:lastmod,1,10)"/></td>
|
|
59
68
|
</tr>
|
|
60
69
|
</xsl:for-each>
|
|
61
70
|
</table>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webnumseoagent/next",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
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",
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"client.ts",
|
|
23
23
|
"jsonld.tsx",
|
|
24
24
|
"sitemap-xsl.ts",
|
|
25
|
+
"sitemap-build.ts",
|
|
25
26
|
"bin",
|
|
26
27
|
"README.md"
|
|
27
28
|
],
|
package/sitemap-build.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// «Оборачивание» существующей карты сайта: берём данные из ИХ генератора
|
|
2
|
+
// (сохраняя свежесть и все URL), группируем по разделам и отдаём стилизованный
|
|
3
|
+
// индекс + под-карты со ссылкой на XSL. Работает на стороне сайта (без платформы).
|
|
4
|
+
import type { MetadataRoute } from "next";
|
|
5
|
+
|
|
6
|
+
function escapeXml(s: string): string {
|
|
7
|
+
return s
|
|
8
|
+
.replace(/&/g, "&")
|
|
9
|
+
.replace(/</g, "<")
|
|
10
|
+
.replace(/>/g, ">")
|
|
11
|
+
.replace(/"/g, """)
|
|
12
|
+
.replace(/'/g, "'");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface NEntry {
|
|
16
|
+
loc: string;
|
|
17
|
+
lastmod?: string;
|
|
18
|
+
changefreq?: string;
|
|
19
|
+
priority?: number;
|
|
20
|
+
images?: string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Запись карты может нести доп. поля (напр. images) сверх типа Next — читаем через каст.
|
|
24
|
+
type RawEntry = {
|
|
25
|
+
url?: unknown;
|
|
26
|
+
lastModified?: string | Date;
|
|
27
|
+
changeFrequency?: string;
|
|
28
|
+
priority?: number;
|
|
29
|
+
images?: unknown;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function normalize(entries: MetadataRoute.Sitemap): NEntry[] {
|
|
33
|
+
const out: NEntry[] = [];
|
|
34
|
+
for (const e of entries as RawEntry[]) {
|
|
35
|
+
if (!e || typeof e.url !== "string") continue;
|
|
36
|
+
let lastmod: string | undefined;
|
|
37
|
+
if (e.lastModified) lastmod = e.lastModified instanceof Date ? e.lastModified.toISOString() : String(e.lastModified);
|
|
38
|
+
const images = Array.isArray(e.images)
|
|
39
|
+
? e.images.filter((i: unknown): i is string => typeof i === "string" && !!i)
|
|
40
|
+
: [];
|
|
41
|
+
out.push({
|
|
42
|
+
loc: e.url,
|
|
43
|
+
lastmod,
|
|
44
|
+
changefreq: typeof e.changeFrequency === "string" ? e.changeFrequency : undefined,
|
|
45
|
+
priority: typeof e.priority === "number" ? e.priority : undefined,
|
|
46
|
+
images: images.length ? images : undefined,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function originOf(entries: NEntry[]): string {
|
|
53
|
+
for (const e of entries) {
|
|
54
|
+
try {
|
|
55
|
+
return new URL(e.loc).origin;
|
|
56
|
+
} catch {
|
|
57
|
+
/* ignore */
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return "";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const dec = (s: string): string => {
|
|
64
|
+
try {
|
|
65
|
+
return decodeURIComponent(s);
|
|
66
|
+
} catch {
|
|
67
|
+
return s;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// Раздел URL: корень/одноуровневые → "pages", иначе по первому сегменту пути.
|
|
72
|
+
// Сегмент декодируем (кириллица и пр.), чтобы имя раздела совпадало с тем, что Next
|
|
73
|
+
// отдаёт в params.name (он декодирует динамический сегмент).
|
|
74
|
+
function groupOfUrl(loc: string): string {
|
|
75
|
+
let path = loc;
|
|
76
|
+
try {
|
|
77
|
+
path = new URL(loc).pathname;
|
|
78
|
+
} catch {
|
|
79
|
+
/* keep as-is */
|
|
80
|
+
}
|
|
81
|
+
const segs = path.split("/").filter(Boolean);
|
|
82
|
+
return segs.length <= 1 ? "pages" : dec(segs[0]);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function pi(origin: string): string {
|
|
86
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<?xml-stylesheet type="text/xsl" href="${escapeXml(origin + "/sitemap.xsl")}"?>\n`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function urlsetXml(origin: string, entries: NEntry[]): string {
|
|
90
|
+
const hasImages = entries.some((e) => e.images && e.images.length);
|
|
91
|
+
const body = entries
|
|
92
|
+
.map((e) => {
|
|
93
|
+
const parts = [` <loc>${escapeXml(e.loc)}</loc>`];
|
|
94
|
+
if (e.lastmod) parts.push(` <lastmod>${escapeXml(e.lastmod)}</lastmod>`);
|
|
95
|
+
if (e.changefreq) parts.push(` <changefreq>${escapeXml(e.changefreq)}</changefreq>`);
|
|
96
|
+
if (typeof e.priority === "number") parts.push(` <priority>${e.priority}</priority>`);
|
|
97
|
+
for (const img of e.images ?? []) parts.push(` <image:image><image:loc>${escapeXml(img)}</image:loc></image:image>`);
|
|
98
|
+
return ` <url>\n${parts.join("\n")}\n </url>`;
|
|
99
|
+
})
|
|
100
|
+
.join("\n");
|
|
101
|
+
const ns = hasImages
|
|
102
|
+
? `xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"`
|
|
103
|
+
: `xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"`;
|
|
104
|
+
return pi(origin) + `<urlset ${ns}>\n${body}\n</urlset>\n`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function indexXml(origin: string, groups: string[]): string {
|
|
108
|
+
const body = groups
|
|
109
|
+
// g кодируем в URL (кириллица → %..), чтобы адрес под-карты был валидным;
|
|
110
|
+
// Next декодирует его обратно в params.name, и фильтр по разделу совпадёт.
|
|
111
|
+
.map((g) => ` <sitemap>\n <loc>${escapeXml(origin + "/sitemap/" + encodeURIComponent(g) + ".xml")}</loc>\n </sitemap>`)
|
|
112
|
+
.join("\n");
|
|
113
|
+
return pi(origin) + `<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${body}\n</sitemapindex>\n`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const EMPTY = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"></urlset>\n`;
|
|
117
|
+
|
|
118
|
+
// Оборачивает существующий генератор карты. kind="index" — индексный файл (или один
|
|
119
|
+
// urlset, если раздел один); kind="child" — под-карта раздела `group`.
|
|
120
|
+
// Fail-safe: если генератор упал — отдаём валидную пустую карту (не 500).
|
|
121
|
+
export async function seoWrapSitemap(
|
|
122
|
+
fn: () => MetadataRoute.Sitemap | Promise<MetadataRoute.Sitemap>,
|
|
123
|
+
kind: "index" | "child" | "flat",
|
|
124
|
+
group?: string,
|
|
125
|
+
): Promise<string> {
|
|
126
|
+
let entries: NEntry[];
|
|
127
|
+
try {
|
|
128
|
+
entries = normalize(await fn());
|
|
129
|
+
} catch {
|
|
130
|
+
return EMPTY;
|
|
131
|
+
}
|
|
132
|
+
const origin = originOf(entries);
|
|
133
|
+
if (!origin || entries.length === 0) return EMPTY;
|
|
134
|
+
|
|
135
|
+
// flat — одна карта со всеми URL (без под-карт); используется, когда под-карты недоступны.
|
|
136
|
+
if (kind === "flat") return urlsetXml(origin, entries);
|
|
137
|
+
|
|
138
|
+
if (kind === "child") {
|
|
139
|
+
const g = (group ?? "").replace(/\.xml$/i, "");
|
|
140
|
+
return urlsetXml(origin, entries.filter((e) => groupOfUrl(e.loc) === g));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const groups = [...new Set(entries.map((e) => groupOfUrl(e.loc)))].sort();
|
|
144
|
+
if (groups.length <= 1) return urlsetXml(origin, entries);
|
|
145
|
+
return indexXml(origin, groups);
|
|
146
|
+
}
|
package/sitemap-xsl.ts
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
export const SITEMAP_XSL = `<?xml version="1.0" encoding="UTF-8"?>
|
|
4
4
|
<xsl:stylesheet version="1.0"
|
|
5
5
|
xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
|
|
6
|
-
xmlns:s="http://www.sitemaps.org/schemas/sitemap/0.9"
|
|
6
|
+
xmlns:s="http://www.sitemaps.org/schemas/sitemap/0.9"
|
|
7
|
+
xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">
|
|
7
8
|
<xsl:output method="html" encoding="UTF-8" indent="yes"/>
|
|
8
9
|
<xsl:template match="/">
|
|
9
10
|
<html lang="ru">
|
|
@@ -20,6 +21,9 @@ export const SITEMAP_XSL = `<?xml version="1.0" encoding="UTF-8"?>
|
|
|
20
21
|
.head a{color:#fff}
|
|
21
22
|
.wrap{padding:24px 40px}
|
|
22
23
|
.count{color:#475569;font-size:14px;margin:0 0 14px}
|
|
24
|
+
.back{margin:0 0 12px}
|
|
25
|
+
.back a{color:#2563eb;text-decoration:none;font-size:14px;font-weight:500}
|
|
26
|
+
.back a:hover{text-decoration:underline}
|
|
23
27
|
table{width:100%;border-collapse:collapse;background:#fff;border:1px solid #e2e8f0;border-radius:10px;overflow:hidden}
|
|
24
28
|
th{background:#eff2f7;color:#334155;text-align:left;font-size:12px;text-transform:uppercase;letter-spacing:.03em;padding:12px 16px}
|
|
25
29
|
td{padding:11px 16px;font-size:14px;border-top:1px solid #f1f5f9}
|
|
@@ -27,6 +31,8 @@ export const SITEMAP_XSL = `<?xml version="1.0" encoding="UTF-8"?>
|
|
|
27
31
|
td a{color:#2563eb;text-decoration:none;word-break:break-all}
|
|
28
32
|
td a:hover{text-decoration:underline}
|
|
29
33
|
.n{width:80px;color:#64748b;text-align:right}
|
|
34
|
+
.i{width:120px;color:#64748b;text-align:center}
|
|
35
|
+
.d{width:140px;color:#64748b;white-space:nowrap}
|
|
30
36
|
</style>
|
|
31
37
|
</head>
|
|
32
38
|
<body>
|
|
@@ -46,13 +52,16 @@ export const SITEMAP_XSL = `<?xml version="1.0" encoding="UTF-8"?>
|
|
|
46
52
|
</table>
|
|
47
53
|
</xsl:when>
|
|
48
54
|
<xsl:otherwise>
|
|
55
|
+
<p class="back"><a href="/sitemap.xml">← Индекс карты сайта</a></p>
|
|
49
56
|
<p class="count">Эта карта сайта содержит <b><xsl:value-of select="count(s:urlset/s:url)"/></b> URL.</p>
|
|
50
57
|
<table>
|
|
51
|
-
<tr><th class="n">#</th><th>URL</th></tr>
|
|
58
|
+
<tr><th class="n">#</th><th>URL</th><th class="i">Изображения</th><th class="d">Последнее изм.</th></tr>
|
|
52
59
|
<xsl:for-each select="s:urlset/s:url">
|
|
53
60
|
<tr>
|
|
54
61
|
<td class="n"><xsl:value-of select="position()"/></td>
|
|
55
62
|
<td><a href="{s:loc}"><xsl:value-of select="s:loc"/></a></td>
|
|
63
|
+
<td class="i"><xsl:value-of select="count(image:image)"/></td>
|
|
64
|
+
<td class="d"><xsl:value-of select="substring(s:lastmod,1,10)"/></td>
|
|
56
65
|
</tr>
|
|
57
66
|
</xsl:for-each>
|
|
58
67
|
</table>
|