seo
This commit is contained in:
@@ -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,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` сайта уже настроен на этот стенд.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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).
|
||||
@@ -1,2 +0,0 @@
|
||||
User-agent: *
|
||||
Disallow:
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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,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,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,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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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="Юридическая информация"
|
||||
|
||||
@@ -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="Защита данных"
|
||||
|
||||
@@ -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,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,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">
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user