This commit is contained in:
2026-10-20 19:17:49 +04:00
parent be6115a151
commit dbc1dfdb8a
35 changed files with 662 additions and 94 deletions
+6
View File
@@ -6,6 +6,12 @@ APP_URL=http://localhost:8080
APP_PORT=8080
VITE_PORT=5173
# Indexing is enabled by default only when APP_ENV=production.
# Canonical URLs and sitemap URLs use APP_URL (set the public HTTPS domain in production).
# MOONWELL_SEO_INDEXABLE=true
GOOGLE_SITE_VERIFICATION=
YANDEX_SITE_VERIFICATION=
APP_LOCALE=ru
APP_FALLBACK_LOCALE=ru
APP_FAKER_LOCALE=ru_RU
+2
View File
@@ -2,6 +2,8 @@
Laravel 12 сайт для World of Warcraft 1.12.1 на vMaNGOS (`/home/sindo/core`). Игровые аккаунты и права хранятся в базе `realmd`, персонажи — в `characters`. Сайт хранит свои данные в отдельной базе Laravel.
Настройка индексации, подтверждение домена и отправка sitemap описаны в [инструкции SEO для Яндекса и Google](docs/seo.md).
## Подключение к локальному vMaNGOS
Локальный стенд ядра описан в `/home/sindo/core/deploy/local/README.md`. Сначала запустите его. Контейнер сайта подключается к сети `vmangos-local_default` и обращается к MariaDB как `db:3306`. Пароль пользователя `vmangos` находится в `/home/sindo/core/deploy/local/secrets/db_password`; скопируйте его в `VMANGOS_AUTH_DB_PASSWORD` и `VMANGOS_CHARACTERS_DB_PASSWORD` своего `.env`. Текущий локальный `.env` сайта уже настроен на этот стенд.
+1 -1
View File
@@ -60,7 +60,7 @@ class LandingController extends Controller
$bootstrap = [
'realm' => [
'name' => $realmName,
'mode' => sprintf('PvE · %s rate · RU', $rates['experience'] ?? 'x1'),
'mode' => sprintf('PvP · %s rate · RU', $rates['experience'] ?? 'x1'),
'realmlist' => $realmConfig['realmlist'],
'online' => $playersOnline !== null,
'players' => $playersOnline,
+2
View File
@@ -23,6 +23,8 @@ class NewsController extends Controller
->withQueryString()
->through(fn (News $item): array => $this->summary($item));
abort_if($articles->currentPage() > 1 && $articles->isEmpty(), 404);
return Inertia::render('News/Index', [
...$this->portalProps($request),
'articles' => $articles,
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers;
use App\Models\News;
use App\Services\SeoService;
use Illuminate\Http\Response;
class SeoController extends Controller
{
public function robots(SeoService $seo): Response
{
$rules = config('seo.indexable')
? "User-agent: *\nAllow: /\nDisallow: /api/\nDisallow: /launcher/\nDisallow: /appcast.xml\nDisallow: /up\n\nSitemap: ".$seo->routeUrl('seo.sitemap')."\n"
: "User-agent: *\nDisallow: /\n";
return response($rules, 200, ['Content-Type' => 'text/plain; charset=UTF-8']);
}
public function sitemap(SeoService $seo): Response
{
return response()->view('seo.sitemap', [
'seo' => $seo,
'articles' => News::query()->published()->select(['id', 'updated_at'])->orderBy('id')->cursor(),
], 200, ['Content-Type' => 'application/xml; charset=UTF-8']);
}
}
@@ -2,6 +2,7 @@
namespace App\Http\Middleware;
use App\Services\SeoService;
use Illuminate\Http\Request;
use Inertia\Middleware;
@@ -37,7 +38,7 @@ class HandleInertiaRequests extends Middleware
{
return [
...parent::share($request),
//
'seo' => fn (): array => app(SeoService::class)->forRequest($request),
];
}
}
+133
View File
@@ -0,0 +1,133 @@
<?php
namespace App\Services;
use App\Models\News;
use Illuminate\Http\Request;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Str;
class SeoService
{
public function routeUrl(string $name, mixed $parameters = []): string
{
return $this->absoluteUrl(route($name, $parameters, false));
}
public function absoluteUrl(string $path): string
{
if (Str::startsWith($path, ['https://', 'http://'])) {
return $path;
}
return rtrim(config('app.url'), '/').'/'.ltrim($path, '/');
}
/** @return array<string, mixed> */
public function forRequest(Request $request): array
{
$name = config('moonwell.realm.server_name', 'MoonWell');
$name = strcasecmp($name, 'MoonWell') === 0 ? 'MoonWell' : $name;
$version = config('moonwell.realm.client_version', '1.12.1');
$rate = config('moonwell.realm.rates.experience', 'x1');
$home = $this->routeUrl('landing.index');
$route = $request->route()?->getName();
$article = $request->route('news');
$indexable = in_array($route, ['landing.index', 'news.index', 'news.show'], true);
$title = match ($route) {
'landing.index' => "{$name} — PvP сервер WoW Vanilla {$version} {$rate}",
'news.index' => "Новости сервера WoW {$version} — {$name}",
'news.show' => $article instanceof News ? "{$article->title} — {$name}" : $name,
'legal.offer' => "Публичная оферта — {$name}",
'legal.privacy' => "Политика обработки персональных данных — {$name}",
'legal.rules' => "Правила проекта — {$name}",
'login' => "Вход в личный кабинет — {$name}",
'cabinet.index' => "Личный кабинет — {$name}",
'admin.accounts.index' => "Аккаунты и инвайты — {$name}",
'admin.balances.index' => "Слёзы Элуны — {$name}",
'admin.shop.index' => "Магазин — {$name}",
'admin.news.index' => "Новости — {$name}",
'admin.login-screen-news.index' => "Новости WoW — {$name}",
default => $name,
};
$description = match ($route) {
'landing.index' => "{$name} — русскоязычный PvP сервер World of Warcraft Vanilla {$version} с рейтами {$rate}. Классический Азерот, подземелья и рейды. Создай аккаунт и начни играть.",
'news.index' => "Новости PvP сервера WoW Vanilla {$version} {$name}: обновления, события, патчноуты и объявления проекта.",
'news.show' => $article instanceof News
? Str::limit(Str::squish(html_entity_decode(strip_tags($article->body), ENT_QUOTES | ENT_HTML5, 'UTF-8')), 160)
: '',
'legal.offer' => "Публичная оферта о заключении договора возмездного оказания цифровых услуг {$name}.",
'legal.privacy' => "Политика обработки и защиты данных пользователей {$name}.",
'legal.rules' => "Запрещённые действия, меры ответственности и порядок обжалования санкций в проекте {$name}.",
'login' => "Вход в личный кабинет игрового сервера {$name}.",
'cabinet.index' => "Персонажи, баланс и настройки игрового аккаунта {$name}.",
default => '',
};
$canonical = match ($route) {
'landing.index' => $home,
'news.index' => $this->routeUrl('news.index', Paginator::resolveCurrentPage() > 1 ? ['page' => Paginator::resolveCurrentPage()] : []),
'news.show' => $article instanceof News ? $this->routeUrl('news.show', $article) : null,
default => null,
};
if ($route === 'news.index' && Paginator::resolveCurrentPage() > 1) {
$title .= ' — страница '.Paginator::resolveCurrentPage();
}
$image = $this->absoluteUrl($article instanceof News && $article->image_url ? $article->image_url : '/landing.png');
$graph = [];
if ($indexable) {
$graph[] = [
'@type' => 'WebSite',
'@id' => $home.'#website',
'url' => $home,
'name' => $name,
'alternateName' => 'Лунный колодец',
'inLanguage' => 'ru-RU',
];
$graph[] = [
'@type' => $route === 'news.index' ? 'CollectionPage' : 'WebPage',
'@id' => $canonical.'#webpage',
'url' => $canonical,
'name' => $title,
'description' => $description,
'inLanguage' => 'ru-RU',
'isPartOf' => ['@id' => $home.'#website'],
];
if ($route === 'news.show' && $article instanceof News) {
$graph[] = array_filter([
'@type' => 'NewsArticle',
'headline' => $article->title,
'description' => $description,
'image' => $image,
'datePublished' => $article->created_at?->toIso8601String(),
'dateModified' => $article->updated_at?->toIso8601String(),
'inLanguage' => 'ru-RU',
'mainEntityOfPage' => ['@id' => $canonical.'#webpage'],
'author' => ['@type' => 'Organization', 'name' => $name, 'url' => $home],
'publisher' => ['@type' => 'Organization', 'name' => $name, 'url' => $home],
], static fn (mixed $value): bool => $value !== null);
}
}
$seo = [
'title' => $title,
'description' => $description,
'canonical' => $canonical,
'robots' => $indexable && config('seo.indexable') ? 'index, follow, max-image-preview:large' : 'noindex, follow',
'type' => $route === 'news.show' ? 'article' : 'website',
'image' => $image,
'site_name' => $name,
'published_at' => $route === 'news.show' && $article instanceof News ? $article->created_at?->toIso8601String() : null,
'modified_at' => $route === 'news.show' && $article instanceof News ? $article->updated_at?->toIso8601String() : null,
'structured_data' => $graph ? ['@context' => 'https://schema.org', '@graph' => $graph] : null,
];
// Inertia manages these same server-rendered tags on subsequent visits.
$seo['head'] = array_map('trim', preg_split('/\R(?=<)/', trim(view('seo.head', ['seo' => $seo])->render())));
return $seo;
}
}
+15 -11
View File
@@ -1,23 +1,27 @@
<?php
use App\Http\Middleware\EnsureGameMaster;
use App\Http\Middleware\EnsureGameServer;
use App\Http\Middleware\EnsureLauncherRequest;
use App\Http\Middleware\ForceJsonResponse;
use App\Http\Middleware\HandleInertiaRequests;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Middleware\SetCacheHeaders;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__ . '/../routes/web.php',
api: __DIR__ . '/../routes/api.php',
commands: __DIR__ . '/../routes/console.php',
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware) {
->withMiddleware(function (Middleware $middleware): void {
$middleware->web(append: [
HandleInertiaRequests::class,
]);
})
->withMiddleware(function (Middleware $middleware): void {
$middleware->trustProxies(at: env('TRUSTED_PROXIES', '*'));
$middleware->validateCsrfTokens(except: [
@@ -26,14 +30,14 @@ return Application::configure(basePath: dirname(__DIR__))
]);
$middleware->api(prepend: [
\Illuminate\Http\Middleware\SetCacheHeaders::class . ':no_store',
\App\Http\Middleware\ForceJsonResponse::class,
SetCacheHeaders::class.':no_store',
ForceJsonResponse::class,
]);
$middleware->alias([
'gamemaster' => \App\Http\Middleware\EnsureGameMaster::class,
'launcher' => \App\Http\Middleware\EnsureLauncherRequest::class,
'game-server' => \App\Http\Middleware\EnsureGameServer::class,
'gamemaster' => EnsureGameMaster::class,
'launcher' => EnsureLauncherRequest::class,
'game-server' => EnsureGameServer::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {
+7
View File
@@ -0,0 +1,7 @@
<?php
return [
'indexable' => (bool) env('MOONWELL_SEO_INDEXABLE', env('APP_ENV', 'production') === 'production'),
'google_verification' => env('GOOGLE_SITE_VERIFICATION'),
'yandex_verification' => env('YANDEX_SITE_VERIFICATION'),
];
+65
View File
@@ -0,0 +1,65 @@
# SEO для Яндекса и Google
Публичные страницы главной и новостей отдают метатеги и основной HTML до запуска
JavaScript. При переходах Inertia обновляет тот же набор тегов через `serverHead`.
Заголовок, описание и текст главной ориентированы на PvP сервер WoW Vanilla,
версию клиента и рейты из `config/moonwell.php`. Название проекта также берётся
из конфигурации. Позиции в поиске зависят и от содержания, ссылок и конкуренции;
SEO-настройки обеспечивают техническую готовность к индексации.
## Настройка production
```dotenv
APP_ENV=production
APP_URL=https://moon-well.online
MOONWELL_SEO_INDEXABLE=true
GOOGLE_SITE_VERIFICATION=
YANDEX_SITE_VERIFICATION=
```
`APP_URL` — единственный источник домена для canonical, sitemap и разметки.
Используйте основной HTTPS-адрес без `www`, если сайт работает именно на нём.
На уровне внешнего nginx настройте постоянный редирект HTTP и альтернативного
домена на этот адрес. Для стендов задайте `MOONWELL_SEO_INDEXABLE=false`:
robots.txt запретит обход, а HTML получит `noindex`. По умолчанию индексация
включается только при `APP_ENV=production`.
В `GOOGLE_SITE_VERIFICATION` и `YANDEX_SITE_VERIFICATION` вставляются только
значения `content` из проверочных метатегов соответствующего сервиса.
Пустые значения не создают метатеги. После изменения `.env` пересоздайте
production-контейнер приложения, чтобы загрузить переменные, и обновите кеш:
```bash
docker compose -f compose.prod.yaml up -d --no-deps --force-recreate app
docker compose -f compose.prod.yaml exec -T --user app app php artisan config:cache
```
## Подключение поисковых систем
1. Добавьте сайт в [Google Search Console](https://search.google.com/search-console)
и [Яндекс Вебмастер](https://webmaster.yandex.ru/). Для проверки метатегом
выберите ресурс по URL в Google и заполните переменные выше. Можно также
подтвердить домен через DNS средствами самих сервисов.
2. Отправьте `https://moon-well.online/sitemap.xml` в оба сервиса. Карта
автоматически содержит главную, список новостей и опубликованные статьи
с датами последнего изменения. Черновики, личный кабинет, вход, юридические
документы и административные страницы в карту не попадают.
3. Проверьте главную и одну новость инструментом проверки URL Google и
инструментами проверки страниц Яндекса, затем запросите переобход главной.
4. Проверьте robots.txt: ресурсы `/build/`, изображения и `/storage/` доступны
роботам. Закрыты API, файлы лаунчера и техническая проверка состояния.
Страницы входа, кабинета и администрации имеют `noindex`; robots.txt
не запрещает их обход, чтобы робот мог прочитать запрет индексации.
У страниц новостей `?page=2` и далее собственный canonical и номер страницы
в заголовке. Метки рекламы и другие параметры в canonical не включаются.
Несуществующие страницы пагинации и неопубликованные новости возвращают 404.
Разметка JSON-LD описывает сайт и страницы, а для новостей — `NewsArticle`
с заголовком, изображением и датами. Open Graph и Twitter Card поддерживают
превью ссылок. Проверочные коды для поисковых систем остаются на всех HTML-страницах.
Реализация учитывает рекомендации
[Google по JavaScript SEO](https://developers.google.com/search/docs/crawling-indexing/javascript/javascript-seo-basics),
[Google по sitemap](https://developers.google.com/search/docs/crawling-indexing/sitemaps/build-sitemap)
и [Яндекса по robots.txt](https://yandex.ru/support/webmaster/ru/controlling-robot/robots-txt).
-2
View File
@@ -1,2 +0,0 @@
User-agent: *
Disallow:
+1 -3
View File
@@ -1,5 +1,5 @@
<script setup>
import { Head, Link, router, useForm, usePage } from '@inertiajs/vue3';
import { Link, router, useForm, usePage } from '@inertiajs/vue3';
import { computed, reactive, ref } from 'vue';
import AdminLayout from '../../Layouts/AdminLayout.vue';
import StatCards from './Components/StatCards.vue';
@@ -70,8 +70,6 @@ function createInvites() {
</script>
<template>
<Head><title>Аккаунты и инвайты — {{ site.name }}</title></Head>
<AdminLayout :site="site" :auth="auth" :urls="urls" :flash="flash" current="accounts">
<section class="admin-hero wrap">
<div>
+1 -3
View File
@@ -1,5 +1,5 @@
<script setup>
import { Head, Link, router, useForm } from '@inertiajs/vue3';
import { Link, router, useForm } from '@inertiajs/vue3';
import { ref } from 'vue';
import AdminLayout from '../../Layouts/AdminLayout.vue';
@@ -37,8 +37,6 @@ function searchTransactions() {
</script>
<template>
<Head><title>Слёзы Элуны — {{ site.name }}</title></Head>
<AdminLayout :site="site" :auth="auth" :urls="urls" :flash="flash" current="balances">
<section class="admin-hero wrap">
<div>
@@ -1,5 +1,4 @@
<script setup>
import { Head } from '@inertiajs/vue3';
import AdminLayout from '../../Layouts/AdminLayout.vue';
import NewsForm from './Components/NewsForm.vue';
import StatCards from './Components/StatCards.vue';
@@ -47,8 +46,6 @@ const example = `|TInterface\\ICONS\\INV_Misc_QuestionMark:25:25:0:-5|t |cFF00D1
</script>
<template>
<Head><title>Новости WoW — {{ site.name }}</title></Head>
<AdminLayout :site="site" :auth="auth" :urls="urls" :flash="flash" current="login-news">
<section class="admin-hero wrap">
<div>
-3
View File
@@ -1,5 +1,4 @@
<script setup>
import { Head } from '@inertiajs/vue3';
import AdminLayout from '../../Layouts/AdminLayout.vue';
import NewsForm from './Components/NewsForm.vue';
import StatCards from './Components/StatCards.vue';
@@ -38,8 +37,6 @@ const editLabels = {
</script>
<template>
<Head><title>Новости — {{ site.name }}</title></Head>
<AdminLayout :site="site" :auth="auth" :urls="urls" :flash="flash" current="news">
<section class="admin-hero wrap">
<div>
+1 -3
View File
@@ -1,5 +1,5 @@
<script setup>
import { Head, router, useForm } from '@inertiajs/vue3';
import { router, useForm } from '@inertiajs/vue3';
import AdminLayout from '../../Layouts/AdminLayout.vue';
import CategoryForm from './Components/CategoryForm.vue';
import ProductForm from './Components/ProductForm.vue';
@@ -48,8 +48,6 @@ function updateAvailability(event) {
</script>
<template>
<Head><title>Магазин — {{ site.name }}</title></Head>
<AdminLayout :site="site" :auth="auth" :urls="urls" :flash="flash" current="shop">
<section class="admin-hero wrap">
<div>
+1 -6
View File
@@ -1,5 +1,5 @@
<script setup>
import { Head, useForm, usePage } from '@inertiajs/vue3';
import { useForm, usePage } from '@inertiajs/vue3';
import { computed } from 'vue';
import PortalLayout from '../../Layouts/PortalLayout.vue';
import { arrowSvg, moonMark } from '../Landing/Components/symbols';
@@ -28,11 +28,6 @@ function submit() {
</script>
<template>
<Head>
<title>Вход в личный кабинет — {{ site.name }}</title>
<meta name="description" content="Вход в личный кабинет игрового сервера MoonWell." />
</Head>
<PortalLayout :site="site" :auth="auth" :urls="urls" :flash="flash">
<section class="portal-auth wrap">
<div class="portal-auth__intro">
+1 -6
View File
@@ -1,5 +1,5 @@
<script setup>
import { Head, useForm, usePage } from '@inertiajs/vue3';
import { useForm, usePage } from '@inertiajs/vue3';
import { computed, ref } from 'vue';
import PortalLayout from '../../Layouts/PortalLayout.vue';
import { arrowSvg } from '../Landing/Components/symbols';
@@ -50,11 +50,6 @@ function updatePassword() {
</script>
<template>
<Head>
<title>Личный кабинет — {{ site.name }}</title>
<meta name="description" content="Персонажи, баланс и настройки игрового аккаунта MoonWell." />
</Head>
<PortalLayout :site="site" :auth="auth" :urls="urls" :flash="flash">
<section class="portal-welcome wrap">
<div>
@@ -1,3 +1,7 @@
<script setup>
defineProps({ clientVersion: { type: String, default: '1.12.1' } });
</script>
<template>
<section id="about" data-screen-label="02 About">
<div class="wrap about-grid reveal">
@@ -6,7 +10,7 @@
<div style="height: 18px"></div>
<h2 class="h-display">Эпоха за эпохой</h2>
<div style="height: 28px"></div>
<p>MoonWell приглашает в классический Азерот версии 1.12.1. Развивай героя до 60-го уровня и исследуй оригинальные зоны, подземелья и рейды.</p>
<p>MoonWell — русскоязычный PvP сервер World of Warcraft Vanilla {{ clientVersion }}. Развивай героя до 60-го уровня и исследуй классический Азерот: оригинальные зоны, подземелья и рейды.</p>
<p>Исследуй опасные земли, готовься к первым рейдам и возвращайся к приключению тогда, когда тебе удобно. Здесь не нужно подстраиваться под общий прогресс сервера.</p>
<blockquote class="about-quote">
«Исследуй классический Азерот в одиночку или собери небольшой отряд для приключений».
@@ -10,7 +10,7 @@ defineProps({
<template>
<section class="hero" data-screen-label="01 Hero">
<div class="wrap hero-inner">
<div class="hero-pretitle"><span class="dot"></span><span>MoonWell · клиент World of Warcraft {{ clientVersion }}</span></div>
<div class="hero-pretitle"><span class="dot"></span><span>MoonWell · PvP сервер WoW Vanilla {{ clientVersion }}</span></div>
<h1 class="h-display hero-title">История Азерота в твоём темпе<span class="ornament">— Лунный колодец —</span></h1>
<p class="hero-tagline">Вернись в классический Азерот — в одиночку или с небольшим отрядом.</p>
<div class="hero-meta">
@@ -22,7 +22,7 @@ onBeforeUnmount(() => window.clearInterval(timer));
<div class="wrap realm reveal">
<div class="realm-panel">
<div class="realm-status"><span class="orb"></span><span class="label">{{ isOnline ? 'Онлайн' : 'Оффлайн' }}</span></div>
<div class="realm-name">{{ realm.name || "Elune's Grace" }}<small>{{ realm.mode || 'PvE · x1 rate · RU' }}</small></div>
<div class="realm-name">{{ realm.name || "Elune's Grace" }}<small>{{ realm.mode || 'PvP · x1 rate · RU' }}</small></div>
<div class="realm-stat"><span class="k">Игроков</span><span class="v">{{ players }}</span></div>
<div class="realm-stat"><span class="k">Аптайм</span><span class="v small">{{ realm.uptime || '99.94%' }}</span></div>
<div class="realm-stat"><span class="k">Пинг</span><span class="v small">{{ isOnline ? `${ping} ms` : '-' }}</span></div>
+1 -5
View File
@@ -24,8 +24,6 @@ const props = defineProps({
flash: { type: Object, default: () => ({}) },
});
const pageTitle = 'MoonWell — Лунный колодец';
const pageDescription = 'Вернись в классический Азерот — в одиночку или с небольшим отрядом.';
let revealObserver;
onMounted(() => {
@@ -47,8 +45,6 @@ onBeforeUnmount(() => revealObserver?.disconnect());
<template>
<Head>
<title>{{ pageTitle }}</title>
<meta name="description" :content="pageDescription" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@400;500;600;700&family=Cormorant+Garamond:ital,wght@0,300;0,400;0,500;0,600;0,700;1,400&family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
@@ -58,7 +54,7 @@ onBeforeUnmount(() => revealObserver?.disconnect());
<Nav :auth="auth" :play-url="playUrl" :realm="realm" />
<Hero :play-url="playUrl" :client-version="realm.client_version" />
<Realm :realm="realm" />
<About />
<About :client-version="realm.client_version" />
<Features />
<Join :register-endpoint="registerEndpoint" :csrf-token="csrfToken" :invite-required="inviteRequired" :auth="auth" :flash="flash" :legal-urls="legalUrls" />
<News :posts="posts" :index-url="newsIndexUrl" />
-6
View File
@@ -1,5 +1,4 @@
<script setup>
import { Head } from '@inertiajs/vue3';
import LegalDocument from '../../Components/LegalDocument.vue';
import PortalLayout from '../../Layouts/PortalLayout.vue';
import {
@@ -18,11 +17,6 @@ defineProps({
</script>
<template>
<Head>
<title>Публичная оферта — {{ site.name }}</title>
<meta name="description" content="Публичная оферта о заключении договора возмездного оказания цифровых услуг MoonWell." />
</Head>
<PortalLayout :site="site" :auth="auth" :urls="urls">
<LegalDocument
eyebrow="Юридическая информация"
-6
View File
@@ -1,5 +1,4 @@
<script setup>
import { Head } from '@inertiajs/vue3';
import LegalDocument from '../../Components/LegalDocument.vue';
import PortalLayout from '../../Layouts/PortalLayout.vue';
import {
@@ -18,11 +17,6 @@ defineProps({
</script>
<template>
<Head>
<title>Политика обработки персональных данных — {{ site.name }}</title>
<meta name="description" content="Политика обработки и защиты данных пользователей MoonWell." />
</Head>
<PortalLayout :site="site" :auth="auth" :urls="urls">
<LegalDocument
eyebrow="Защита данных"
-6
View File
@@ -1,5 +1,4 @@
<script setup>
import { Head } from '@inertiajs/vue3';
import LegalDocument from '../../Components/LegalDocument.vue';
import PortalLayout from '../../Layouts/PortalLayout.vue';
import {
@@ -17,11 +16,6 @@ defineProps({
</script>
<template>
<Head>
<title>Правила проекта — {{ site.name }}</title>
<meta name="description" content="Запрещённые действия, меры ответственности и порядок обжалования санкций в проекте MoonWell." />
</Head>
<PortalLayout :site="site" :auth="auth" :urls="urls">
<LegalDocument
eyebrow="Правила MoonWell"
+1 -7
View File
@@ -1,5 +1,5 @@
<script setup>
import { Head, Link } from '@inertiajs/vue3';
import { Link } from '@inertiajs/vue3';
import { computed } from 'vue';
import PortalLayout from '../../Layouts/PortalLayout.vue';
@@ -18,12 +18,6 @@ const paginationLinks = computed(() => props.articles.links.map((link, index, li
</script>
<template>
<Head>
<title>Новости — {{ site.name }}</title>
<meta name="description" content="Новости, обновления и события игрового проекта MoonWell." />
<link rel="canonical" :href="urls.news" />
</Head>
<PortalLayout :site="site" :auth="auth" :urls="urls" :flash="flash">
<main class="wrap portal-news-index">
<header class="portal-news-index__hero">
+1 -12
View File
@@ -1,5 +1,5 @@
<script setup>
import { Head, Link } from '@inertiajs/vue3';
import { Link } from '@inertiajs/vue3';
import { computed } from 'vue';
import PortalLayout from '../../Layouts/PortalLayout.vue';
@@ -20,17 +20,6 @@ const paragraphs = computed(() => props.article.body
</script>
<template>
<Head>
<title>{{ article.title }} — {{ site.name }}</title>
<meta name="description" :content="article.excerpt" />
<link rel="canonical" :href="article.canonical_url" />
<meta property="og:type" content="article" />
<meta property="og:title" :content="article.title" />
<meta property="og:description" :content="article.excerpt" />
<meta property="og:url" :content="article.canonical_url" />
<meta v-if="article.image_url" property="og:image" :content="article.image_url" />
</Head>
<PortalLayout :site="site" :auth="auth" :urls="urls" :flash="flash">
<article class="wrap portal-news-page">
<Link class="portal-news-back" :href="article.back_url">
+1
View File
@@ -4,6 +4,7 @@ import { createInertiaApp } from '@inertiajs/vue3';
import { createApp, h } from 'vue';
createInertiaApp({
serverHead: (page) => page.props.seo?.head ?? [],
resolve: (name) => {
const pages = import.meta.glob('./Pages/**/*.vue', { eager: true });
+13 -5
View File
@@ -1,17 +1,25 @@
<html>
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="{{ asset('moonwell-icon-512.ico') }}">
@if (request()->routeIs('landing.index'))
<title>MoonWell — Лунный колодец</title>
<meta name="description" content="Вернись в классический Азерот — в одиночку или с небольшим отрядом.">
@foreach ($page['props']['seo']['head'] as $tag)
{!! $tag !!}
@endforeach
@if (config('seo.google_verification'))
<meta name="google-site-verification" content="{{ config('seo.google_verification') }}">
@endif
@if (config('seo.yandex_verification'))
<meta name="yandex-verification" content="{{ config('seo.yandex_verification') }}">
@endif
@vite('resources/js/app.js')
<x-inertia::head />
</head>
<body>
<x-inertia::app />
{{-- Public content is readable before JavaScript loads; Vue replaces it on mount. --}}
<script data-page="app" type="application/json">{!! json_encode($page, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_THROW_ON_ERROR) !!}</script>
<div id="app">@include('seo.content')</div>
@include('partials.yandex-metrika')
</body>
</html>
+99
View File
@@ -0,0 +1,99 @@
@php($props = $page['props'])
@if ($page['component'] === 'Landing/Main')
<main>
<section class="hero">
<div class="wrap hero-inner">
<div class="hero-pretitle">MoonWell · PvP сервер WoW Vanilla {{ $props['realm']['client_version'] }}</div>
<h1 class="h-display hero-title">История Азерота в твоём темпе<span class="ornament">— Лунный колодец —</span></h1>
<p class="hero-tagline">Вернись в классический Азерот — в одиночку или с небольшим отрядом.</p>
<p>{{ $props['realm']['name'] }} · {{ $props['realm']['mode'] }}</p>
<div class="hero-cta">
<a href="{{ $props['playUrl'] }}" class="btn btn-primary">Начни играть</a>
<a href="#about" class="btn btn-ghost">Узнай больше</a>
</div>
</div>
</section>
<section id="about" class="wrap">
<h2 class="h-display">Эпоха за эпохой</h2>
<p>MoonWell — русскоязычный PvP сервер World of Warcraft Vanilla {{ $props['realm']['client_version'] }}. Развивай героя до 60-го уровня и исследуй классический Азерот: оригинальные зоны, подземелья и рейды.</p>
<p>Исследуй опасные земли, готовься к первым рейдам и возвращайся к приключению тогда, когда тебе удобно. Здесь не нужно подстраиваться под общий прогресс сервера.</p>
<img src="{{ asset('landing.png') }}" alt="Концепт-арт: вход в Лунный колодец, туман и силуэт ночного эльфа" width="1086" height="1448" loading="lazy">
</section>
<section id="join" class="wrap">
<h2 class="h-display">Начни своё приключение</h2>
<p>Создай игровой аккаунт и установи клиент World of Warcraft {{ $props['realm']['client_version'] }} через лаунчер MoonWell.</p>
<a href="{{ $props['auth']['login_url'] }}">Войти в личный кабинет</a>
</section>
<section class="wrap">
<h2 class="h-display">Новости сервера</h2>
@foreach ($props['posts'] as $post)
<article>
<h3><a href="{{ $post['url'] }}">{{ $post['title'] }}</a></h3>
<p>{{ $post['excerpt'] }}</p>
</article>
@endforeach
<a href="{{ $props['newsIndexUrl'] }}">Все новости</a>
</section>
<footer class="wrap">
@foreach (['offer' => 'Публичная оферта', 'privacy' => 'Политика обработки персональных данных', 'rules' => 'Правила проекта'] as $key => $label)
@if ($props['legalUrls'][$key])
<a href="{{ $props['legalUrls'][$key] }}">{{ $label }}</a>
@endif
@endforeach
</footer>
</main>
@elseif ($page['component'] === 'News/Index')
<main class="wrap portal-news-index">
<a href="{{ $props['urls']['home'] }}">MoonWell — главная</a>
<header class="portal-news-index__hero">
<h1 class="h-display">Новости и обновления</h1>
<p class="lead">События MoonWell, патчноуты, дневники разработки и важные объявления проекта.</p>
</header>
@forelse ($props['articles']['data'] as $article)
<article>
<h2><a href="{{ $article['url'] }}">{{ $article['title'] }}</a></h2>
<p>{{ $article['date'] }}</p>
<p>{{ $article['excerpt'] }}</p>
</article>
@empty
<p>Первая новость появится здесь после публикации.</p>
@endforelse
@if ($props['articles']['last_page'] > 1)
<nav aria-label="Страницы новостей">
@foreach ($props['articles']['links'] as $link)
@if ($link['url'])
<a href="{{ $link['url'] }}">{{ $loop->first ? 'Назад' : ($loop->last ? 'Вперёд' : $link['label']) }}</a>
@endif
@endforeach
</nav>
@endif
</main>
@elseif ($page['component'] === 'News/Show')
<article class="wrap portal-news-page">
<a href="{{ $props['article']['back_url'] }}">Все новости</a>
<header class="portal-news-hero">
<h1 class="h-display">{{ $props['article']['title'] }}</h1>
<p>{{ $props['article']['date'] }}</p>
<p class="lead">{{ $props['article']['excerpt'] }}</p>
</header>
@if ($props['article']['image_url'])
<img class="portal-news-cover" src="{{ $props['article']['image_url'] }}" alt="{{ $props['article']['title'] }}">
@endif
<div class="portal-news-body">
@foreach (preg_split('/\n{2,}/', str_replace(["\r\n", "\r"], "\n", $props['article']['body'])) as $paragraph)
@if (trim($paragraph) !== '')
<p>{{ trim($paragraph) }}</p>
@endif
@endforeach
</div>
@if ($props['moreNews'])
<section>
<h2>Другие новости</h2>
@foreach ($props['moreNews'] as $article)
<h3><a href="{{ $article['url'] }}">{{ $article['title'] }}</a></h3>
<p>{{ $article['excerpt'] }}</p>
@endforeach
</section>
@endif
</article>
@endif
+29
View File
@@ -0,0 +1,29 @@
<title data-inertia="title">{{ $seo['title'] }}</title>
<meta name="description" content="{{ $seo['description'] }}" data-inertia="description">
<meta name="robots" content="{{ $seo['robots'] }}" data-inertia="robots">
@if ($seo['canonical'])
<link rel="canonical" href="{{ $seo['canonical'] }}" data-inertia="canonical">
@endif
<meta property="og:locale" content="ru_RU" data-inertia="og:locale">
<meta property="og:site_name" content="{{ $seo['site_name'] }}" data-inertia="og:site_name">
<meta property="og:type" content="{{ $seo['type'] }}" data-inertia="og:type">
<meta property="og:title" content="{{ $seo['title'] }}" data-inertia="og:title">
<meta property="og:description" content="{{ $seo['description'] }}" data-inertia="og:description">
@if ($seo['canonical'])
<meta property="og:url" content="{{ $seo['canonical'] }}" data-inertia="og:url">
@endif
<meta property="og:image" content="{{ $seo['image'] }}" data-inertia="og:image">
<meta property="og:image:alt" content="{{ $seo['site_name'] }} — сервер World of Warcraft" data-inertia="og:image:alt">
<meta name="twitter:card" content="summary_large_image" data-inertia="twitter:card">
<meta name="twitter:title" content="{{ $seo['title'] }}" data-inertia="twitter:title">
<meta name="twitter:description" content="{{ $seo['description'] }}" data-inertia="twitter:description">
<meta name="twitter:image" content="{{ $seo['image'] }}" data-inertia="twitter:image">
@if ($seo['published_at'])
<meta property="article:published_time" content="{{ $seo['published_at'] }}" data-inertia="article:published_time">
@endif
@if ($seo['modified_at'])
<meta property="article:modified_time" content="{{ $seo['modified_at'] }}" data-inertia="article:modified_time">
@endif
@if ($seo['structured_data'])
<script type="application/ld+json" data-inertia="structured-data">{!! json_encode($seo['structured_data'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_THROW_ON_ERROR) !!}</script>
@endif
+17
View File
@@ -0,0 +1,17 @@
{!! '<?xml version="1.0" encoding="UTF-8"?>' !!}
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>{{ $seo->routeUrl('landing.index') }}</loc>
</url>
<url>
<loc>{{ $seo->routeUrl('news.index') }}</loc>
</url>
@foreach ($articles as $article)
<url>
<loc>{{ $seo->routeUrl('news.show', $article) }}</loc>
@if ($article->updated_at)
<lastmod>{{ $article->updated_at->toAtomString() }}</lastmod>
@endif
</url>
@endforeach
</urlset>
+3
View File
@@ -12,10 +12,13 @@ use App\Http\Controllers\LandingController;
use App\Http\Controllers\LegalController;
use App\Http\Controllers\NewsController;
use App\Http\Controllers\PaymentController;
use App\Http\Controllers\SeoController;
use App\Http\Controllers\WowLoginNewsFeedController;
use Illuminate\Support\Facades\Route;
Route::get('/', [LandingController::class, 'index'])->name('landing.index');
Route::get('/robots.txt', [SeoController::class, 'robots'])->name('seo.robots');
Route::get('/sitemap.xml', [SeoController::class, 'sitemap'])->name('seo.sitemap');
Route::get('/news', [NewsController::class, 'index'])->name('news.index');
Route::get('/news/{news}', [NewsController::class, 'show'])
->whereNumber('news')
+3 -2
View File
@@ -12,11 +12,12 @@ class LandingPageTest extends TestCase
$response = $this->get(route('landing.index'));
$response->assertOk();
$response->assertSee('<title>MoonWell — Лунный колодец</title>', false);
$response->assertSee('<meta name="description" content="Вернись в классический Азерот — в одиночку или с небольшим отрядом.">', false);
$response->assertSee('<title data-inertia="title">MoonWell — PvP сервер WoW Vanilla 1.12.1 x1</title>', false);
$response->assertSee('MoonWell — русскоязычный PvP сервер World of Warcraft Vanilla 1.12.1 с рейтами x1.', false);
$response->assertInertia(fn (Assert $page) => $page
->component('Landing/Main', false)
->where('realm.server_name', 'MoonWell')
->where('realm.mode', 'PvP · x1 rate · RU')
->where('legalUrls.offer_published', true)
->where('legalUrls.privacy_published', true)
->where('legalUrls.rules_published', true)
+222
View File
@@ -0,0 +1,222 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\HandleInertiaRequests;
use App\Models\News;
use App\Services\VMangosAccountService;
use DOMDocument;
use DOMXPath;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
class SeoFeatureTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
config([
'app.url' => 'https://moon-well.online',
'seo.indexable' => true,
'seo.google_verification' => null,
'seo.yandex_verification' => null,
'moonwell.realm.server_name' => 'MoonWell',
'moonwell.realm.client_version' => '1.12.1',
'moonwell.realm.rates.experience' => 'x1',
]);
$this->mock(VMangosAccountService::class)
->shouldReceive('onlinePlayerCount')->andReturn(null);
}
public function test_landing_has_server_rendered_pvp_metadata_and_readable_content(): void
{
$response = $this->get('/?utm_source=google');
$response->assertOk()->assertSee('<html lang="ru">', false);
$html = $this->xpath($response->getContent());
$this->assertSame(1, $html->query('//head/title')->length);
$this->assertSame('MoonWell — PvP сервер WoW Vanilla 1.12.1 x1', $html->evaluate('string(//head/title)'));
$this->assertSame(1, $html->query('//head/meta[@name="description"]')->length);
$this->assertSame('https://moon-well.online/', $html->evaluate('string(//head/link[@rel="canonical"]/@href)'));
$this->assertSame('index, follow, max-image-preview:large', $html->evaluate('string(//head/meta[@name="robots"]/@content)'));
$this->assertSame(1, $html->query('//div[@id="app"]//h1')->length);
$this->assertStringContainsString('PvP сервер World of Warcraft Vanilla 1.12.1', $html->evaluate('string(//div[@id="app"])'));
$this->assertSame('https://moon-well.online/landing.png', $html->evaluate('string(//head/meta[@property="og:image"]/@content)'));
$schema = json_decode($html->evaluate('string(//head/script[@type="application/ld+json"])'), true, flags: JSON_THROW_ON_ERROR);
$this->assertSame('WebSite', $schema['@graph'][0]['@type']);
$response->assertInertia(fn (Assert $page) => $page
->where('seo.canonical', 'https://moon-well.online/')
->where('realm.mode', 'PvP · x1 rate · RU')
->has('seo.head')
->etc()
);
}
public function test_canonical_and_sitemap_use_the_configured_domain(): void
{
$response = $this->get('http://alternate.example/?utm_campaign=test');
$response->assertOk();
$this->assertSame('https://moon-well.online/', $this->xpath($response->getContent())->evaluate('string(//head/link[@rel="canonical"]/@href)'));
$this->get('http://alternate.example/robots.txt')
->assertOk()
->assertSee('Sitemap: https://moon-well.online/sitemap.xml', false)
->assertDontSee('alternate.example', false);
}
public function test_seo_uses_the_configured_version_and_rates(): void
{
config([
'moonwell.realm.server_name' => 'Moonwell',
'moonwell.realm.client_version' => '1.12.2',
'moonwell.realm.rates.experience' => 'x3',
]);
$response = $this->get('/');
$response->assertOk();
$this->assertSame('MoonWell — PvP сервер WoW Vanilla 1.12.2 x3', $this->xpath($response->getContent())->evaluate('string(//head/title)'));
$response->assertSee('PvP сервер World of Warcraft Vanilla 1.12.2', false);
}
public function test_news_has_full_html_content_and_safe_article_structured_data(): void
{
$article = $this->article([
'title' => "Обновление\n<script>alert(\"test\")</script>",
'body' => "Первый абзац.\n\nВторой абзац: рейды & подземелья.",
]);
$response = $this->get('/news/'.$article->id.'?utm_source=yandex');
$response->assertOk()->assertDontSee('<script>alert("test")</script>', false);
$this->assertStringContainsString(e($article->title), $response->viewData('page')['props']['seo']['head'][0]);
$html = $this->xpath($response->getContent());
$this->assertSame($article->title, $html->evaluate('string(//div[@id="app"]//h1)'));
$this->assertStringContainsString('Второй абзац: рейды & подземелья.', $html->evaluate('string(//div[@id="app"])'));
$this->assertSame('https://moon-well.online/news/'.$article->id, $html->evaluate('string(//head/link[@rel="canonical"]/@href)'));
$schema = json_decode($html->evaluate('string(//head/script[@type="application/ld+json"])'), true, flags: JSON_THROW_ON_ERROR);
$this->assertSame('NewsArticle', $schema['@graph'][2]['@type']);
$this->assertSame($article->title, $schema['@graph'][2]['headline']);
$this->assertSame($article->updated_at->toIso8601String(), $schema['@graph'][2]['dateModified']);
}
public function test_news_pagination_has_its_own_canonical_and_crawlable_links(): void
{
foreach (range(1, 10) as $number) {
$this->article(['title' => "Новость {$number}"]);
}
$response = $this->get('/news?page=2&utm_source=test');
$response->assertOk();
$html = $this->xpath($response->getContent());
$this->assertSame('https://moon-well.online/news?page=2', $html->evaluate('string(//head/link[@rel="canonical"]/@href)'));
$this->assertStringContainsString('страница 2', $html->evaluate('string(//head/title)'));
$this->assertGreaterThan(0, $html->query('//div[@id="app"]//nav//a[@href]')->length);
$first = $this->get('/news?page=1&utm_source=test')->assertOk();
$this->assertSame('https://moon-well.online/news', $this->xpath($first->getContent())->evaluate('string(//head/link[@rel="canonical"]/@href)'));
$this->get('/news?page=3')->assertNotFound();
}
public function test_sitemap_is_valid_xml_and_contains_only_published_public_pages(): void
{
$published = $this->article();
$draft = $this->article(['is_published' => false]);
$response = $this->get('/sitemap.xml');
$response->assertOk()->assertHeader('Content-Type', 'application/xml; charset=UTF-8');
$xml = simplexml_load_string($response->getContent());
$this->assertNotFalse($xml);
$xml->registerXPathNamespace('s', 'http://www.sitemaps.org/schemas/sitemap/0.9');
$locations = array_map('strval', $xml->xpath('//s:loc'));
$this->assertSame([
'https://moon-well.online/',
'https://moon-well.online/news',
'https://moon-well.online/news/'.$published->id,
], $locations);
$this->assertSame($published->updated_at->toAtomString(), (string) $xml->xpath('//s:lastmod')[0]);
$this->get('/news/'.$draft->id)->assertNotFound();
$published->update(['is_published' => false]);
$this->get('/sitemap.xml')->assertOk()->assertDontSee('/news/'.$published->id, false);
}
public function test_robots_allows_assets_and_advertises_the_sitemap(): void
{
$this->get('/robots.txt')
->assertOk()
->assertHeader('Content-Type', 'text/plain; charset=UTF-8')
->assertSee("User-agent: *\nAllow: /\n", false)
->assertSee('Sitemap: https://moon-well.online/sitemap.xml', false)
->assertDontSee('Disallow: /build', false)
->assertDontSee('Disallow: /storage', false)
->assertDontSee('Disallow: /login', false);
}
public function test_staging_disables_indexing(): void
{
config(['seo.indexable' => false]);
$this->get('/robots.txt')->assertOk()->assertContent("User-agent: *\nDisallow: /\n");
$response = $this->get('/');
$response->assertOk();
$this->assertSame('noindex, follow', $this->xpath($response->getContent())->evaluate('string(//head/meta[@name="robots"]/@content)'));
}
public function test_inertia_navigation_gets_fresh_metadata_including_private_page_noindex(): void
{
$this->withHeaders([
'X-Inertia' => 'true',
'X-Inertia-Version' => app(HandleInertiaRequests::class)->version(Request::create('/')),
])->get('/news')
->assertOk()
->assertJsonPath('props.seo.canonical', 'https://moon-well.online/news')
->assertJsonPath('props.seo.robots', 'index, follow, max-image-preview:large');
$this->get('/login')
->assertOk()
->assertJsonPath('props.seo.canonical', null)
->assertJsonPath('props.seo.robots', 'noindex, follow')
->assertJsonPath('props.seo.structured_data', null);
}
public function test_verification_tags_are_optional_and_escaped(): void
{
$response = $this->get('/login')->assertOk();
$response->assertDontSee('name="google-site-verification"', false)->assertDontSee('name="yandex-verification"', false);
config(['seo.google_verification' => 'google-token"<>', 'seo.yandex_verification' => 'yandex-token']);
$response = $this->get('/login')->assertOk();
$html = $this->xpath($response->getContent());
$this->assertSame('google-token"<>', $html->evaluate('string(//head/meta[@name="google-site-verification"]/@content)'));
$this->assertSame('yandex-token', $html->evaluate('string(//head/meta[@name="yandex-verification"]/@content)'));
$this->assertSame('noindex, follow', $html->evaluate('string(//head/meta[@name="robots"]/@content)'));
}
private function article(array $attributes = []): News
{
return News::query()->create([
'title' => 'Обновление сервера',
'body' => 'Новости классического Азерота.',
'sort_order' => 0,
'is_published' => true,
...$attributes,
]);
}
private function xpath(string $html): DOMXPath
{
$document = new DOMDocument;
@$document->loadHTML('<?xml encoding="UTF-8">'.$html);
return new DOMXPath($document);
}
}