From 7a61f5a85b2f7e4e67c51bb00ef2ebeffb72e4a8 Mon Sep 17 00:00:00 2001 From: Ku6epXBOCTuK Date: Mon, 12 Oct 2026 13:55:54 +0500 Subject: [PATCH] chore: add sitemap generator --- docs/plan-seo.md | 59 +++++++++++--------- web/package.json | 2 +- web/scripts/gen-sitemap.mjs | 96 +++++++++++++++++++++++++++++++++ web/src/routes/kit/+page.svelte | 4 ++ web/static/robots.txt | 6 +++ 5 files changed, 141 insertions(+), 26 deletions(-) create mode 100644 web/scripts/gen-sitemap.mjs diff --git a/docs/plan-seo.md b/docs/plan-seo.md index a747986..c020356 100644 --- a/docs/plan-seo.md +++ b/docs/plan-seo.md @@ -103,18 +103,17 @@ Чего нет (пробелы закрываются этапами ниже): -| Пробел | Где сейчас | Этап | -| -------------------------------------------------- | ----------------------------------- | ---- | -| `noindex` на страницах инструментов | стоит (`+page.svelte`) | S1 | -| `sitemap.xml` | нет | S1 | -| `robots.txt` без `Sitemap:` | allow-all (`web/static/robots.txt`) | S1 | -| `rel=canonical` | нет | S1 | -| OG/Twitter-теги, OG-картинки | нет | S1 | -| Канонический домен в абсолютных URL | нет, деплой на GitHub Pages с базой | S1 | -| Контент страницы (how-to, FAQ, related) | title, robots и отрендеренный H1 | S2 | -| JSON-LD (SoftwareApplication, FAQPage, Breadcrumb) | нет | S3 | -| `llms.txt`, хабы категорий с текстом | нет | S3 | -| GSC + доводка GA до cookieless-конфигурации | GA4 стоит, GSC не подключён | S4 | +| Пробел | Где сейчас | Этап | +| -------------------------------------------------- | -------------------------------- | ---- | +| `noindex` на страницах инструментов | стоит (`+page.svelte`) | S1 | +| `sitemap.xml` | генерируется при сборке (S1b) | — | +| `robots.txt` без `Sitemap:` | allow-all + `Sitemap:` (S1c) | — | +| `rel=canonical` | есть, `Seo.svelte` (S1d) | — | +| OG/Twitter-теги | есть; per-tool картинки — S1e | S1 | +| Контент страницы (how-to, FAQ, related) | title, robots и отрендеренный H1 | S2 | +| JSON-LD (SoftwareApplication, FAQPage, Breadcrumb) | нет | S3 | +| `llms.txt`, хабы категорий с текстом | нет | S3 | +| GSC + доводка GA до cookieless-конфигурации | GA4 стоит, GSC не подключён | S4 | Строка `noindex` — первая не потому, что её сложнее всего, а потому, что она блокирует всё остальное: пока тег стоит, остальные строки таблицы не дают @@ -211,19 +210,29 @@ (301) делает сам GitHub Pages при включённом кастомном домене. Делается одновременно с S1d (canonical), чтобы Google проиндексировал сразу с правильным доменом. -- [ ] **[S1b]** Скрипт `web/scripts/gen-sitemap.mjs`: генерирует `sitemap.xml` в - `build/` из `PAGES` реестра (`web/src/lib/registry`) + статических - страниц. Взять адреса у страниц, а не у инструментов: страниц может быть - несколько на один инструмент, у инструмента адреса нет. Вызов из - `pnpm build` после `vite build` (adapter-static пишет в `build/`, - дописываем файл там же). Origin — из env (`PUBLIC_SITE_ORIGIN`), учёт - опционального `BASE_PATH`. -- [ ] **[S1c]** `robots.txt`: `Sitemap:` + закрыть служебный `/kit`; после - cleanup — вернуть минимальный allow-all. -- [ ] **[S1d]** Компонент `Seo.svelte` (kit): canonical (абсолютный URL по - актуальному маршруту), OG-теги (title/description/type/url/image), - twitter:card. Подключить в root-layout. **Требует `PUBLIC_SITE_ORIGIN`** - для абсолютных URL в canonical и OG:url. +- [x] **[S1b]** Скрипт `web/scripts/gen-sitemap.mjs` — **сделано 2026-10-12**: + пишет `sitemap.xml` в `build/` после `vite build` (вызов добавлен в + `pnpm build`). Адреса берутся **из собранного HTML**, а не из импорта + `PAGES`: список prerender-страниц и есть фактический набор URL, а страницы + с `noindex` в HTML автоматически исключаются — sitemap не может разойтись + с тем, что реально задеплоено, и не рекламирует то, что мы просим + краулеров игнорировать. Проверено: в sitemap попадают `/`, `/list-tools`, + `/chains`, но не `/kit`, `/pipeline` и страницы инструментов (пока S1h). + Origin — `PUBLIC_SITE_ORIGIN`, фолбэк — `static/CNAME` (тот же домен, что + публикует GitHub Pages, без третьей копии литерала); опциональный + `BASE_PATH` учитывается как в `vite.config.ts`. +- [x] **[S1c]** `robots.txt` — **сделано 2026-10-12**: `Sitemap:` добавлен, + allow-all сохранён. **Служебный `/kit` закрыт не через `Disallow:`, а + через `noindex` в HTML** (как `/pipeline`): заблокированный краулером, но + не помеченный noindex URL Google помечает «проиндексирован, несмотря на + блокировку в robots.txt» и оставляет старый сниппет. Побочная выгода: тот + же признак автоматически выкидывает `/kit` из sitemap. +- [x] **[S1d]** Компонент `Seo.svelte` (kit) — **был сделан до S1b, отмечено + 2026-10-12**: canonical (абсолютный URL от `PUBLIC_SITE_ORIGIN` + + `SITE_URL`, `web/src/lib/seo.ts`), OG (title/description/type/url/image) и + `twitter:card`. Подключён на `/`, `/list-tools`, `/chains`, + `/tools/[slug]`. Картинка пока общая `og/default.png` — per-tool остаётся + за S1e. > **Порядок S1:** S1e/S1f/S1g → покупка домена → S1a+S1b+S1c+S1d → **S1h** > (снятие `noindex`) → S2-контент. Гейты: `pnpm --dir web build` зелёный; в diff --git a/web/package.json b/web/package.json index a978885..0d2ce58 100644 --- a/web/package.json +++ b/web/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "vite dev", - "build": "vite build", + "build": "vite build && node scripts/gen-sitemap.mjs", "preview": "vite preview", "prepare": "svelte-kit sync || echo ''", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", diff --git a/web/scripts/gen-sitemap.mjs b/web/scripts/gen-sitemap.mjs new file mode 100644 index 0000000..54a443d --- /dev/null +++ b/web/scripts/gen-sitemap.mjs @@ -0,0 +1,96 @@ +// Writes build/sitemap.xml right after `vite build` (adapter-static already +// emitted the HTML there). The URL set is read back from the build output +// instead of the registry: prerendered pages are exactly the pages that exist, +// and a page whose HTML carries `noindex` is skipped, so the sitemap can never +// drift from what was shipped or advertise a page we ask crawlers to ignore. +import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { join, relative, sep } from "node:path"; +import { fileURLToPath } from "node:url"; + +const BUILD_DIR = fileURLToPath(new URL("../build/", import.meta.url)); + +function siteOrigin() { + const fromEnv = (process.env.PUBLIC_SITE_ORIGIN ?? "").trim(); + if (fromEnv !== "") return fromEnv.replace(/\/+$/, ""); + + // Local builds run without CI env vars; `static/CNAME` is the domain GitHub + // Pages publishes under, so it is the same truth as the deploy-time env. + const cnamePath = fileURLToPath(new URL("../static/CNAME", import.meta.url)); + if (!existsSync(cnamePath)) { + throw new Error( + "gen-sitemap: no site origin — set PUBLIC_SITE_ORIGIN or add web/static/CNAME", + ); + } + const cname = readFileSync(cnamePath, "utf8").trim(); + if (cname === "") { + throw new Error("gen-sitemap: web/static/CNAME is empty"); + } + return `https://${cname}`; +} + +/** Mirrors the `base` normalization in vite.config.ts. */ +const rawBase = (process.env.BASE_PATH ?? "").trim(); +const base = + rawBase === "" || rawBase === "/" + ? "" + : `/${rawBase.replace(/^\/+/, "").replace(/\/+$/, "")}`; + +function* htmlFiles(dir) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) yield* htmlFiles(full); + else if (entry.name.endsWith(".html")) yield full; + } +} + +/** + * adapter-static names prerendered pages `.html` (`tools/crop-png.html`) + * because `trailingSlash` is "never"; the `index.html` form covers a future + * switch. GitHub Pages serves the flat file at the extension-less URL. + */ +function routePath(file) { + const rel = relative(BUILD_DIR, file).split(sep).join("/"); + if (rel === "404.html") return null; + if (rel === "index.html") return "/"; + if (rel.endsWith("/index.html")) + return `/${rel.slice(0, -"index.html".length)}`; + return `/${rel.slice(0, -".html".length)}`; +} + +function escapeXml(value) { + return value.replace(/&/g, "&").replace(/', + '', + ...locs.map((loc) => `\t${escapeXml(loc)}`), + "", + "", +].join("\n"); + +writeFileSync(join(BUILD_DIR, "sitemap.xml"), xml); +console.log(`sitemap.xml written: ${locs.length} urls (origin ${origin})`); diff --git a/web/src/routes/kit/+page.svelte b/web/src/routes/kit/+page.svelte index 6317b34..6b764d4 100644 --- a/web/src/routes/kit/+page.svelte +++ b/web/src/routes/kit/+page.svelte @@ -41,6 +41,10 @@ ); + + + +
KIT SHOWCASE diff --git a/web/static/robots.txt b/web/static/robots.txt index b6dd667..ba7b8d3 100644 --- a/web/static/robots.txt +++ b/web/static/robots.txt @@ -1,3 +1,9 @@ # allow crawling everything by default User-agent: * Disallow: + +# Service pages (/kit) are excluded from the index with a `noindex` meta tag, +# not with Disallow: a blocked-but-not-noindexed URL shows up in Search Console +# as an error and keeps its old indexed snippet. + +Sitemap: https://easy-image-tools.com/sitemap.xml \ No newline at end of file