fix(seo): убрать дубли title и description
Some checks failed
deploy / deploy (push) Successful in 1m24s
security / security (push) Has been cancelled

- 6 pages (o-nas, nashi-druzya, 4 миры) получили свой description в frontmatter;
  раньше [slug].astro для типа page передавал description=undefined → fallback
  на SITE_DESCRIPTION даёт 6 одинаковых meta-description.
- [slug].astro: для постов в SEO-title подставляется год публикации (или
  полная дата, если есть второй пост с тем же title в том же году). Покрывает
  дубли «Внимание! Технические работы!» (×5) и «С 23 февраля!» (×2).
- index.astro: свой description для главной (вместо SITE_DESCRIPTION).
- category/[slug].astro: вычисляемый description с количеством публикаций и
  диапазоном лет — на случай если категории попадут в индексацию.
This commit is contained in:
Dmitry Gusev
2026-05-30 02:22:33 +03:00
parent 048cb673e0
commit b40c377761
9 changed files with 45 additions and 7 deletions

View File

@@ -5,6 +5,7 @@ legacyId: "132"
menuOrder: "4"
pubDate: "2011-05-18T00:40:47+03:00"
updatedDate: "2014-07-14T00:12:19+03:00"
description: "Игровая вселенная по «Хроникам Амбера» Роджера Желязны: Истинный мир, его Отражения и принцы крови с непростой семейной историей."
---
Тест

View File

@@ -5,6 +5,7 @@ legacyId: "130"
menuOrder: "5"
pubDate: "2011-05-18T00:39:40+03:00"
updatedDate: "2014-07-14T00:12:27+03:00"
description: "Ролевая игра по циклу «Киндрэт» Алексея Пехова, Елены Бычковой и Натальи Турчаниновой: вампирские кланы ночной Столицы и их теневая политика."
---
«Киндрэт. Кровные братья» — первая книга цикла Киндрэт известных российских писателей Алексея Пехова, Елены Бычковой и Натальи Турчаниновой, рассказывающая о жизни кланов вампиров. В цикл романов по миру ночной Столицы вошли 4 книги: «Кровные братья», «Колдун из клана смерти», «Основатель», «Новые боги».

View File

@@ -5,6 +5,7 @@ legacyId: "124"
menuOrder: "3"
pubDate: "2011-05-18T00:27:35+03:00"
updatedDate: "2014-07-14T00:12:09+03:00"
description: "Главная ролевая игра «Иных Отражений» по миру «Дозоров» Сергея Лукьяненко и Владимира Васильева: Иные, Свет и Тьма, древний Договор."
---
**Иные Отражения: Сумерки Дозоров**

View File

@@ -5,6 +5,7 @@ legacyId: "339"
menuOrder: "2"
pubDate: "2013-07-09T13:29:47+03:00"
updatedDate: "2025-06-07T02:31:08+03:00"
description: "Партнёры и друзья ролевой группы «Иные Отражения»: ООО «АйТи Решения» (hhivp.com) — IT-услуги для бизнеса в Москве и области."
---
[ООО "АйТи Решения"](https://hhivp.com) предоставляет полный спектр IT услуг на территории Москвы и Московской области, как частным лицам, так и представителям бизнеса. Мы способствуем развитию Вашего бизнеса и достижению самых смелых результатов!

View File

@@ -5,6 +5,7 @@ legacyId: "137"
menuOrder: "1"
pubDate: "2011-05-18T00:50:11+03:00"
updatedDate: "2014-07-14T00:10:31+03:00"
description: "История ролевой группы «Иные Отражения»: основана в 2006 году как «Иные Миры», в 2007-м объединилась с проектом «Отражения»."
---
**Ролевая группа "Иные Отражения"**

View File

@@ -5,6 +5,7 @@ legacyId: "378"
menuOrder: "6"
pubDate: "2013-10-06T02:59:22+03:00"
updatedDate: "2014-07-14T00:12:33+03:00"
description: "«Ренессанс» — ролевая игра по миру «Дозоров» Сергея Лукьяненко: Иные, Сумрак и древний Договор в декорациях эпохи Возрождения."
---
**Иные Отражения: Ренессанс**

View File

@@ -6,13 +6,27 @@ import { CATEGORY_COLORS } from '../consts';
export async function getStaticPaths() {
const posts = await getCollection('posts');
const pages = await getCollection('pages');
// Считаем сколько постов с одинаковым {title, year} — если >1, в SEO-title
// подставим полную дату, иначе только год.
const titleYearCount = new Map<string, number>();
for (const p of posts) {
const key = `${p.data.title}|${p.data.pubDate.getFullYear()}`;
titleYearCount.set(key, (titleYearCount.get(key) ?? 0) + 1);
}
return [
...posts.map((p) => ({ params: { slug: p.data.slug }, props: { entry: p, kind: 'post' as const } })),
...pages.map((p) => ({ params: { slug: p.data.slug }, props: { entry: p, kind: 'page' as const } })),
...posts.map((p) => {
const year = p.data.pubDate.getFullYear();
const key = `${p.data.title}|${year}`;
const sameYearDup = (titleYearCount.get(key) ?? 1) > 1;
return { params: { slug: p.data.slug }, props: { entry: p, kind: 'post' as const, sameYearDup } };
}),
...pages.map((p) => ({ params: { slug: p.data.slug }, props: { entry: p, kind: 'page' as const, sameYearDup: false } })),
];
}
const { entry, kind } = Astro.props;
const { entry, kind, sameYearDup } = Astro.props;
const { Content } = await render(entry);
// Буквица — только для постов с телом длиннее короткого порога.
const bodyLen = entry.body?.length ?? 0;
@@ -20,10 +34,18 @@ const useDropCap = kind === 'post' && bodyLen > 240;
const fmtDate = (d: Date) =>
d.toLocaleDateString('ru-RU', { year: 'numeric', month: 'long', day: 'numeric' });
// Короткая дата без "г." для SEO-title постов с одинаковым названием в одном году.
const fmtDateForTitle = (d: Date) =>
d.toLocaleDateString('ru-RU', { year: 'numeric', month: 'long', day: 'numeric' }).replace(/\sг\.?$/, '');
const seoTitle = kind === 'post'
? `${entry.data.title} (${sameYearDup ? fmtDateForTitle(entry.data.pubDate) : entry.data.pubDate.getFullYear()})`
: entry.data.title;
---
<BaseLayout
title={entry.data.title}
description={kind === 'post' ? entry.data.description : undefined}
title={seoTitle}
description={entry.data.description || undefined}
ogType={kind === 'post' ? 'article' : 'website'}
>
<article class="post">

View File

@@ -27,8 +27,18 @@ const sorted = posts.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.va
const fmtDate = (d: Date) =>
d.toLocaleDateString('ru-RU', { year: 'numeric', month: 'long', day: 'numeric' });
const yearsRange = (() => {
if (!sorted.length) return '';
const years = sorted.map((p) => p.data.pubDate.getFullYear());
const min = Math.min(...years);
const max = Math.max(...years);
return min === max ? `${min}` : `${min}${max}`;
})();
const pubWord = plural(sorted.length, ['публикация', 'публикации', 'публикаций']);
const catDescription = `${name} — ${sorted.length} ${pubWord}${yearsRange ? ` (${yearsRange})` : ''} в архиве ролевой группы «Иные Отражения».`;
---
<BaseLayout title={`Категория: ${name}`}>
<BaseLayout title={`Категория: ${name}`} description={catDescription}>
<section class="hero hero-compact" style={`--cat-color: ${catColor};`}>
<span class="hero-eyebrow" style={`color: ${catColor}; border-color: ${catColor};`}>Категория</span>
<h1 style={`background: linear-gradient(180deg, #ffffff 0%, ${catColor} 100%); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent;`}>{name}</h1>

View File

@@ -13,7 +13,7 @@ const oldestYear = posts.length ? posts[posts.length - 1].data.pubDate.getFullYe
const fmtDate = (d: Date) =>
d.toLocaleDateString('ru-RU', { year: 'numeric', month: 'long', day: 'numeric' });
---
<BaseLayout>
<BaseLayout description="Главная ролевой группы «Иные Отражения»: новости, хроника публикаций с 2009 года и игровые миры — Дозоры, Амбер, Киндрэт, Ренессанс, Над бездной, Глубина, Warhammer 40k.">
<section class="hero">
<span class="hero-eyebrow">Ролевой проект · с {SITE_FOUNDED} года</span>
<h1>Иные<br/>Отражения</h1>