diff --git a/.gitignore b/.gitignore
index 7be55e2..5fa2e97 100755
--- a/.gitignore
+++ b/.gitignore
@@ -25,3 +25,4 @@ _ide_helper.php
Homestead.json
Homestead.yaml
Thumbs.db
+/.scannerwork/
diff --git a/.scannerwork/.sonar_lock b/.scannerwork/.sonar_lock
deleted file mode 100644
index e69de29..0000000
diff --git a/.scannerwork/report-task.txt b/.scannerwork/report-task.txt
deleted file mode 100644
index e327be9..0000000
--- a/.scannerwork/report-task.txt
+++ /dev/null
@@ -1,6 +0,0 @@
-projectKey=sonar_desk
-serverUrl=https://sonarcube.absdev.net
-serverVersion=26.9.0.129388
-dashboardUrl=https://sonarcube.absdev.net/dashboard?id=sonar_desk
-ceTaskId=ed0bb599-656b-42f0-a0ec-c838a1ab98b2
-ceTaskUrl=https://sonarcube.absdev.net/api/ce/task?id=ed0bb599-656b-42f0-a0ec-c838a1ab98b2
diff --git a/AGENTS.md b/AGENTS.md
deleted file mode 100755
index b80472f..0000000
--- a/AGENTS.md
+++ /dev/null
@@ -1,47 +0,0 @@
-
-# Laravel Application
-
-This repository contains a Laravel application. Complete the following setup before working on the user's request.
-
-## Prerequisites
-
-Verify that PHP and Composer are available:
-
-```sh
-php -v
-composer -V
-```
-
-If either command is unavailable, detect the user's operating system and install the prerequisites with the appropriate command:
-
-macOS:
-
-```sh
-/bin/bash -c "$(curl -fsSL https://php.new/install/mac/8.5)"
-```
-
-Windows PowerShell:
-
-```powershell
-Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; iex ((New-Object System.Net.WebClient).DownloadString('https://php.new/install/windows/8.5'))
-```
-
-Linux:
-
-```sh
-/bin/bash -c "$(curl -fsSL https://php.new/install/linux/8.5)"
-```
-
-After installation, ask the user to restart their terminal. If the agent needs the restarted shell to continue, ask the user to reopen their terminal and rerun their original prompt.
-
-## Agent Setup
-
-Install Laravel Boost from the application root before making application changes:
-
-```sh
-composer require laravel/boost --dev
-php artisan boost:install
-```
-
-Boost replaces these bootstrap instructions with guidelines tailored to the application. After installation, read `AGENTS.md` again and continue with the user's original request using the generated guidelines.
-
diff --git a/CLAUDE.md b/CLAUDE.md
deleted file mode 100755
index b80472f..0000000
--- a/CLAUDE.md
+++ /dev/null
@@ -1,47 +0,0 @@
-
-# Laravel Application
-
-This repository contains a Laravel application. Complete the following setup before working on the user's request.
-
-## Prerequisites
-
-Verify that PHP and Composer are available:
-
-```sh
-php -v
-composer -V
-```
-
-If either command is unavailable, detect the user's operating system and install the prerequisites with the appropriate command:
-
-macOS:
-
-```sh
-/bin/bash -c "$(curl -fsSL https://php.new/install/mac/8.5)"
-```
-
-Windows PowerShell:
-
-```powershell
-Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; iex ((New-Object System.Net.WebClient).DownloadString('https://php.new/install/windows/8.5'))
-```
-
-Linux:
-
-```sh
-/bin/bash -c "$(curl -fsSL https://php.new/install/linux/8.5)"
-```
-
-After installation, ask the user to restart their terminal. If the agent needs the restarted shell to continue, ask the user to reopen their terminal and rerun their original prompt.
-
-## Agent Setup
-
-Install Laravel Boost from the application root before making application changes:
-
-```sh
-composer require laravel/boost --dev
-php artisan boost:install
-```
-
-Boost replaces these bootstrap instructions with guidelines tailored to the application. After installation, read `AGENTS.md` again and continue with the user's original request using the generated guidelines.
-
diff --git a/Http/Controllers/Admin/CmsPageController.php b/Http/Controllers/Admin/CmsPageController.php
new file mode 100644
index 0000000..e12651d
--- /dev/null
+++ b/Http/Controllers/Admin/CmsPageController.php
@@ -0,0 +1,45 @@
+ CmsPage::latest()->paginate(25)]); }
+ public function create() { return view('admin.cms.form', ['page' => new CmsPage()]); }
+ public function store(Request $request) { $page = CmsPage::create($this->validated($request)); return redirect()->route('admin.cms.edit', $page)->with('status', 'Page created.'); }
+ public function edit(CmsPage $page) { return view('admin.cms.form', compact('page')); }
+ public function update(Request $request, CmsPage $page) { $page->update($this->validated($request, $page)); return back()->with('status', 'Page updated.'); }
+ public function destroy(CmsPage $page) { $page->delete(); return redirect()->route('admin.cms.index')->with('status', 'Page deleted.'); }
+
+ private function validated(Request $request, ?CmsPage $page = null): array
+ {
+ $request->merge(['slug' => Str::slug($request->input('slug') ?: $request->input('title', ''))]);
+ $data = $request->validate([
+ 'title' => ['required','string','max:160'],
+ 'slug' => ['required','string','max:180', Rule::unique('cms_pages','slug')->ignore($page?->id)],
+ 'excerpt' => ['nullable','string','max:1000'],
+ 'body' => ['required','string'],
+ 'meta_title' => ['nullable','string','max:160'],
+ 'meta_description' => ['nullable','string','max:320'],
+ 'meta_keywords' => ['nullable','string','max:500'],
+ 'canonical_url' => ['nullable','url:http,https','max:2048'],
+ 'og_title' => ['nullable','string','max:160'],
+ 'og_description' => ['nullable','string','max:320'],
+ 'og_image' => ['nullable','url:http,https','max:2048'],
+ 'sitemap_priority' => ['required','numeric','min:0','max:1'],
+ 'sitemap_change_frequency' => ['required', Rule::in(['always','hourly','daily','weekly','monthly','yearly','never'])],
+ ]);
+ $data['robots_index'] = $request->boolean('robots_index');
+ $data['robots_follow'] = $request->boolean('robots_follow');
+ $data['include_in_sitemap'] = $request->boolean('include_in_sitemap');
+ $data['is_published'] = $request->boolean('is_published');
+ $data['published_at'] = $data['is_published'] ? ($page?->published_at ?? now()) : null;
+ return $data;
+ }
+}
diff --git a/Http/Controllers/Admin/SeoSettingsController.php b/Http/Controllers/Admin/SeoSettingsController.php
new file mode 100644
index 0000000..a7f920d
--- /dev/null
+++ b/Http/Controllers/Admin/SeoSettingsController.php
@@ -0,0 +1,50 @@
+ collect(self::KEYS)->mapWithKeys(fn (string $key) => [$key => SiteSetting::valueFor($key, '')]),
+ 'robotsEnabled' => SiteSetting::booleanFor('seo_robots_enabled', true),
+ ]);
+ }
+
+ public function update(Request $request): RedirectResponse
+ {
+ $data = $request->validate([
+ 'seo_site_name' => ['required', 'string', 'max:80'],
+ 'seo_title_suffix' => ['nullable', 'string', 'max:80'],
+ 'seo_default_title' => ['required', 'string', 'max:160'],
+ 'seo_default_description' => ['required', 'string', 'max:320'],
+ 'seo_default_keywords' => ['nullable', 'string', 'max:500'],
+ 'seo_canonical_base_url' => ['nullable', 'url:http,https', 'max:2048'],
+ 'seo_default_og_image' => ['nullable', 'url:http,https', 'max:2048'],
+ 'seo_twitter_site' => ['nullable', 'string', 'max:40', 'regex:/^@?[A-Za-z0-9_]{1,15}$/'],
+ 'seo_organization_name' => ['nullable', 'string', 'max:120'],
+ 'seo_organization_logo' => ['nullable', 'url:http,https', 'max:2048'],
+ 'seo_google_site_verification' => ['nullable', 'string', 'max:255'],
+ ]);
+
+ foreach (self::KEYS as $key) {
+ SiteSetting::put($key, isset($data[$key]) ? trim((string) $data[$key]) : null);
+ }
+ SiteSetting::put('seo_robots_enabled', $request->boolean('seo_robots_enabled') ? '1' : '0');
+
+ return back()->with('status', 'SEO settings updated.');
+ }
+}
diff --git a/Http/Controllers/SeoController.php b/Http/Controllers/SeoController.php
new file mode 100644
index 0000000..59603f3
--- /dev/null
+++ b/Http/Controllers/SeoController.php
@@ -0,0 +1,53 @@
+ 'home', 'priority' => '1.0', 'frequency' => 'weekly'],
+ ['route' => 'features', 'priority' => '0.9', 'frequency' => 'monthly'],
+ ['route' => 'buy', 'priority' => '0.9', 'frequency' => 'monthly'],
+ ['route' => 'release', 'priority' => '0.8', 'frequency' => 'weekly'],
+ ['route' => 'support', 'priority' => '0.6', 'frequency' => 'monthly'],
+ ['route' => 'privacy', 'priority' => '0.3', 'frequency' => 'yearly'],
+ ['route' => 'terms', 'priority' => '0.3', 'frequency' => 'yearly'],
+ ['route' => 'refund', 'priority' => '0.3', 'frequency' => 'yearly'],
+ ['route' => 'cookies', 'priority' => '0.3', 'frequency' => 'yearly'],
+ ])->filter(fn (array $item) => Route::has($item['route']))->values();
+
+ $pages = CmsPage::query()
+ ->where('is_published', true)
+ ->where('include_in_sitemap', true)
+ ->where('robots_index', true)
+ ->where(fn ($query) => $query->whereNull('published_at')->orWhere('published_at', '<=', now()))
+ ->orderBy('slug')
+ ->get();
+
+ return response()
+ ->view('seo.sitemap', compact('static', 'pages'))
+ ->header('Content-Type', 'application/xml; charset=UTF-8')
+ ->header('Cache-Control', 'public, max-age=3600');
+ }
+
+ public function robots(): Response
+ {
+ $indexable = app()->environment('production') && SiteSetting::booleanFor('seo_robots_enabled', true);
+
+ $body = $indexable
+ ? "User-agent: *\nAllow: /\nDisallow: /admin/\nDisallow: /portal/\nDisallow: /login\nDisallow: /register\n\nSitemap: ".route('sitemap')."\n"
+ : "User-agent: *\nDisallow: /\n";
+
+ return response($body, 200, [
+ 'Content-Type' => 'text/plain; charset=UTF-8',
+ 'Cache-Control' => 'public, max-age=3600',
+ ]);
+ }
+}
diff --git a/Models/CmsPage.php b/Models/CmsPage.php
new file mode 100644
index 0000000..3209346
--- /dev/null
+++ b/Models/CmsPage.php
@@ -0,0 +1,22 @@
+ 'boolean', 'published_at' => 'datetime', 'robots_index' => 'boolean',
+ 'robots_follow' => 'boolean', 'include_in_sitemap' => 'boolean', 'sitemap_priority' => 'decimal:1',
+ ];
+ }
+}
diff --git a/Models/SiteSetting.php b/Models/SiteSetting.php
new file mode 100644
index 0000000..872204e
--- /dev/null
+++ b/Models/SiteSetting.php
@@ -0,0 +1,32 @@
+ static::query()->find($key)?->value ?? $default);
+ }
+
+ public static function booleanFor(string $key, bool $default = false): bool
+ {
+ $value = static::valueFor($key);
+ return $value === null ? $default : filter_var($value, FILTER_VALIDATE_BOOLEAN);
+ }
+
+ public static function put(string $key, ?string $value): void
+ {
+ static::query()->updateOrCreate(['key' => $key], ['value' => $value]);
+ Cache::forget('sonardesk.setting.'.$key);
+ }
+}
diff --git a/app/Http/Controllers/Admin/CmsPageController.php b/app/Http/Controllers/Admin/CmsPageController.php
index b0d53ab..e12651d 100644
--- a/app/Http/Controllers/Admin/CmsPageController.php
+++ b/app/Http/Controllers/Admin/CmsPageController.php
@@ -27,7 +27,17 @@ final class CmsPageController extends Controller
'body' => ['required','string'],
'meta_title' => ['nullable','string','max:160'],
'meta_description' => ['nullable','string','max:320'],
+ 'meta_keywords' => ['nullable','string','max:500'],
+ 'canonical_url' => ['nullable','url:http,https','max:2048'],
+ 'og_title' => ['nullable','string','max:160'],
+ 'og_description' => ['nullable','string','max:320'],
+ 'og_image' => ['nullable','url:http,https','max:2048'],
+ 'sitemap_priority' => ['required','numeric','min:0','max:1'],
+ 'sitemap_change_frequency' => ['required', Rule::in(['always','hourly','daily','weekly','monthly','yearly','never'])],
]);
+ $data['robots_index'] = $request->boolean('robots_index');
+ $data['robots_follow'] = $request->boolean('robots_follow');
+ $data['include_in_sitemap'] = $request->boolean('include_in_sitemap');
$data['is_published'] = $request->boolean('is_published');
$data['published_at'] = $data['is_published'] ? ($page?->published_at ?? now()) : null;
return $data;
diff --git a/app/Http/Controllers/Admin/SeoSettingsController.php b/app/Http/Controllers/Admin/SeoSettingsController.php
new file mode 100644
index 0000000..a7f920d
--- /dev/null
+++ b/app/Http/Controllers/Admin/SeoSettingsController.php
@@ -0,0 +1,50 @@
+ collect(self::KEYS)->mapWithKeys(fn (string $key) => [$key => SiteSetting::valueFor($key, '')]),
+ 'robotsEnabled' => SiteSetting::booleanFor('seo_robots_enabled', true),
+ ]);
+ }
+
+ public function update(Request $request): RedirectResponse
+ {
+ $data = $request->validate([
+ 'seo_site_name' => ['required', 'string', 'max:80'],
+ 'seo_title_suffix' => ['nullable', 'string', 'max:80'],
+ 'seo_default_title' => ['required', 'string', 'max:160'],
+ 'seo_default_description' => ['required', 'string', 'max:320'],
+ 'seo_default_keywords' => ['nullable', 'string', 'max:500'],
+ 'seo_canonical_base_url' => ['nullable', 'url:http,https', 'max:2048'],
+ 'seo_default_og_image' => ['nullable', 'url:http,https', 'max:2048'],
+ 'seo_twitter_site' => ['nullable', 'string', 'max:40', 'regex:/^@?[A-Za-z0-9_]{1,15}$/'],
+ 'seo_organization_name' => ['nullable', 'string', 'max:120'],
+ 'seo_organization_logo' => ['nullable', 'url:http,https', 'max:2048'],
+ 'seo_google_site_verification' => ['nullable', 'string', 'max:255'],
+ ]);
+
+ foreach (self::KEYS as $key) {
+ SiteSetting::put($key, isset($data[$key]) ? trim((string) $data[$key]) : null);
+ }
+ SiteSetting::put('seo_robots_enabled', $request->boolean('seo_robots_enabled') ? '1' : '0');
+
+ return back()->with('status', 'SEO settings updated.');
+ }
+}
diff --git a/app/Http/Controllers/SeoController.php b/app/Http/Controllers/SeoController.php
new file mode 100644
index 0000000..59603f3
--- /dev/null
+++ b/app/Http/Controllers/SeoController.php
@@ -0,0 +1,53 @@
+ 'home', 'priority' => '1.0', 'frequency' => 'weekly'],
+ ['route' => 'features', 'priority' => '0.9', 'frequency' => 'monthly'],
+ ['route' => 'buy', 'priority' => '0.9', 'frequency' => 'monthly'],
+ ['route' => 'release', 'priority' => '0.8', 'frequency' => 'weekly'],
+ ['route' => 'support', 'priority' => '0.6', 'frequency' => 'monthly'],
+ ['route' => 'privacy', 'priority' => '0.3', 'frequency' => 'yearly'],
+ ['route' => 'terms', 'priority' => '0.3', 'frequency' => 'yearly'],
+ ['route' => 'refund', 'priority' => '0.3', 'frequency' => 'yearly'],
+ ['route' => 'cookies', 'priority' => '0.3', 'frequency' => 'yearly'],
+ ])->filter(fn (array $item) => Route::has($item['route']))->values();
+
+ $pages = CmsPage::query()
+ ->where('is_published', true)
+ ->where('include_in_sitemap', true)
+ ->where('robots_index', true)
+ ->where(fn ($query) => $query->whereNull('published_at')->orWhere('published_at', '<=', now()))
+ ->orderBy('slug')
+ ->get();
+
+ return response()
+ ->view('seo.sitemap', compact('static', 'pages'))
+ ->header('Content-Type', 'application/xml; charset=UTF-8')
+ ->header('Cache-Control', 'public, max-age=3600');
+ }
+
+ public function robots(): Response
+ {
+ $indexable = app()->environment('production') && SiteSetting::booleanFor('seo_robots_enabled', true);
+
+ $body = $indexable
+ ? "User-agent: *\nAllow: /\nDisallow: /admin/\nDisallow: /portal/\nDisallow: /login\nDisallow: /register\n\nSitemap: ".route('sitemap')."\n"
+ : "User-agent: *\nDisallow: /\n";
+
+ return response($body, 200, [
+ 'Content-Type' => 'text/plain; charset=UTF-8',
+ 'Cache-Control' => 'public, max-age=3600',
+ ]);
+ }
+}
diff --git a/app/Models/CmsPage.php b/app/Models/CmsPage.php
index 180a18e..3209346 100644
--- a/app/Models/CmsPage.php
+++ b/app/Models/CmsPage.php
@@ -6,10 +6,17 @@ use Illuminate\Database\Eloquent\Model;
final class CmsPage extends Model
{
- protected $fillable = ['title', 'slug', 'excerpt', 'body', 'meta_title', 'meta_description', 'is_published', 'published_at'];
+ protected $fillable = [
+ 'title', 'slug', 'excerpt', 'body', 'meta_title', 'meta_description', 'meta_keywords',
+ 'canonical_url', 'og_title', 'og_description', 'og_image', 'robots_index', 'robots_follow',
+ 'include_in_sitemap', 'sitemap_priority', 'sitemap_change_frequency', 'is_published', 'published_at',
+ ];
protected function casts(): array
{
- return ['is_published' => 'boolean', 'published_at' => 'datetime'];
+ return [
+ 'is_published' => 'boolean', 'published_at' => 'datetime', 'robots_index' => 'boolean',
+ 'robots_follow' => 'boolean', 'include_in_sitemap' => 'boolean', 'sitemap_priority' => 'decimal:1',
+ ];
}
}
diff --git a/app/Models/SiteSetting.php b/app/Models/SiteSetting.php
index 83af974..872204e 100644
--- a/app/Models/SiteSetting.php
+++ b/app/Models/SiteSetting.php
@@ -18,6 +18,12 @@ final class SiteSetting extends Model
return Cache::remember('sonardesk.setting.'.$key, 300, fn () => static::query()->find($key)?->value ?? $default);
}
+ public static function booleanFor(string $key, bool $default = false): bool
+ {
+ $value = static::valueFor($key);
+ return $value === null ? $default : filter_var($value, FILTER_VALIDATE_BOOLEAN);
+ }
+
public static function put(string $key, ?string $value): void
{
static::query()->updateOrCreate(['key' => $key], ['value' => $value]);
diff --git a/database/migrations/2026_10_01_100000_add_professional_seo_to_cms_pages.php b/database/migrations/2026_10_01_100000_add_professional_seo_to_cms_pages.php
new file mode 100644
index 0000000..5633841
--- /dev/null
+++ b/database/migrations/2026_10_01_100000_add_professional_seo_to_cms_pages.php
@@ -0,0 +1,35 @@
+string('meta_keywords', 500)->nullable()->after('meta_description');
+ $table->string('canonical_url', 2048)->nullable()->after('meta_keywords');
+ $table->string('og_title')->nullable()->after('canonical_url');
+ $table->string('og_description', 320)->nullable()->after('og_title');
+ $table->string('og_image', 2048)->nullable()->after('og_description');
+ $table->boolean('robots_index')->default(true)->after('og_image');
+ $table->boolean('robots_follow')->default(true)->after('robots_index');
+ $table->boolean('include_in_sitemap')->default(true)->after('robots_follow')->index();
+ $table->decimal('sitemap_priority', 2, 1)->default(0.5)->after('include_in_sitemap');
+ $table->string('sitemap_change_frequency', 20)->default('monthly')->after('sitemap_priority');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('cms_pages', function (Blueprint $table): void {
+ $table->dropIndex(['include_in_sitemap']);
+ $table->dropColumn([
+ 'meta_keywords', 'canonical_url', 'og_title', 'og_description', 'og_image',
+ 'robots_index', 'robots_follow', 'include_in_sitemap', 'sitemap_priority',
+ 'sitemap_change_frequency',
+ ]);
+ });
+ }
+};
diff --git a/.env.develop.example b/provision/.env.develop.example
similarity index 100%
rename from .env.develop.example
rename to provision/.env.develop.example
diff --git a/.env.paddle b/provision/.env.paddle
similarity index 100%
rename from .env.paddle
rename to provision/.env.paddle
diff --git a/.env.production.example b/provision/.env.production.example
similarity index 100%
rename from .env.production.example
rename to provision/.env.production.example
diff --git a/.env.stage.example b/provision/.env.stage.example
similarity index 100%
rename from .env.stage.example
rename to provision/.env.stage.example
diff --git a/resources/views/admin/cms/form.blade.php b/resources/views/admin/cms/form.blade.php
index 2ba7d36..9df446c 100644
--- a/resources/views/admin/cms/form.blade.php
+++ b/resources/views/admin/cms/form.blade.php
@@ -6,7 +6,17 @@
-
+
@if($page->exists && $page->is_published)
View page@endif
diff --git a/resources/views/admin/index.blade.php b/resources/views/admin/index.blade.php
index f4198c6..e4d91e4 100755
--- a/resources/views/admin/index.blade.php
+++ b/resources/views/admin/index.blade.php
@@ -2,5 +2,5 @@
@section('content')
{{ $licenseCount }}Licences
{{ $activeCount }}Not revoked
{{ $activationCount }}Active installations
{{ $userCount }}Portal users
-
+
@endsection
diff --git a/resources/views/admin/seo/edit.blade.php b/resources/views/admin/seo/edit.blade.php
new file mode 100644
index 0000000..67fcb35
--- /dev/null
+++ b/resources/views/admin/seo/edit.blade.php
@@ -0,0 +1,39 @@
+@extends('layouts.app')
+@section('content')
+
+
+@endsection
diff --git a/resources/views/cms/show.blade.php b/resources/views/cms/show.blade.php
index 909e3cf..35da777 100644
--- a/resources/views/cms/show.blade.php
+++ b/resources/views/cms/show.blade.php
@@ -1,4 +1,14 @@
-@extends('layouts.app', ['title' => $page->meta_title ?: $page->title.' — SonarDesk', 'description' => $page->meta_description ?: $page->excerpt])
+@extends('layouts.app', [
+ 'title' => $page->meta_title ?: $page->title.' — '.\App\Models\SiteSetting::valueFor('seo_site_name', 'SonarDesk'),
+ 'description' => $page->meta_description ?: $page->excerpt,
+ 'keywords' => $page->meta_keywords,
+ 'canonical' => $page->canonical_url ?: null,
+ 'ogTitle' => $page->og_title ?: null,
+ 'ogDescription' => $page->og_description ?: null,
+ 'ogImage' => $page->og_image ?: null,
+ 'robotsIndex' => $page->robots_index,
+ 'robotsFollow' => $page->robots_follow,
+])
@section('content')
SonarDesk{{ $page->title }}
@if($page->excerpt){{ $page->excerpt }}
@endif{!! nl2br(e($page->body)) !!}
@endsection
diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php
index 4dbe278..7bd879d 100755
--- a/resources/views/layouts/app.blade.php
+++ b/resources/views/layouts/app.blade.php
@@ -6,26 +6,45 @@
@php
- $pageTitle = $title ?? 'SonarDesk';
- $pageDescription = $description ?? 'Native SonarQube monitoring for macOS. Track projects, quality gates, bugs, vulnerabilities and code smells from one professional desktop app.';
- $canonicalUrl = url()->current();
- $socialImage = url('/icons/og-sonardesk.png');
+ $seoSiteName = \App\Models\SiteSetting::valueFor('seo_site_name', 'SonarDesk');
+ $seoDefaultTitle = \App\Models\SiteSetting::valueFor('seo_default_title', 'SonarDesk — Native SonarQube Monitoring for macOS');
+ $seoDefaultDescription = \App\Models\SiteSetting::valueFor('seo_default_description', 'Native SonarQube monitoring for macOS. Track projects, quality gates, bugs, vulnerabilities and code smells from one professional desktop app.');
+ $pageTitle = $title ?? $seoDefaultTitle;
+ $pageDescription = $description ?? $seoDefaultDescription;
+ $pageKeywords = $keywords ?? \App\Models\SiteSetting::valueFor('seo_default_keywords');
+ $canonicalBase = rtrim(\App\Models\SiteSetting::valueFor('seo_canonical_base_url', config('app.url')), '/');
+ $canonicalUrl = $canonical ?? ($canonicalBase . request()->getPathInfo());
+ $socialImage = $ogImage ?? \App\Models\SiteSetting::valueFor('seo_default_og_image', url('/icons/og-sonardesk.png'));
+ $ogTitle = $ogTitle ?? $pageTitle;
+ $ogDescription = $ogDescription ?? $pageDescription;
+ $robotsIndex = $robotsIndex ?? true;
+ $robotsFollow = $robotsFollow ?? true;
+ $productionIndexing = app()->environment('production') && \App\Models\SiteSetting::booleanFor('seo_robots_enabled', true);
+ $robots = $productionIndexing ? (($robotsIndex ? 'index' : 'noindex').','.($robotsFollow ? 'follow' : 'nofollow').',max-image-preview:large,max-snippet:-1,max-video-preview:-1') : 'noindex,nofollow';
+ $twitterSite = \App\Models\SiteSetting::valueFor('seo_twitter_site');
+ $googleVerification = \App\Models\SiteSetting::valueFor('seo_google_site_verification');
+ $organizationName = \App\Models\SiteSetting::valueFor('seo_organization_name', $seoSiteName);
+ $organizationLogo = \App\Models\SiteSetting::valueFor('seo_organization_logo', url('/icons/sonardesk-icon-512.png'));
+ $schema = [
+ '@context' => 'https://schema.org', '@type' => 'SoftwareApplication', 'name' => $seoSiteName,
+ 'applicationCategory' => 'DeveloperApplication', 'operatingSystem' => 'macOS', 'url' => route('home'),
+ 'description' => $seoDefaultDescription, 'publisher' => ['@type' => 'Organization', 'name' => $organizationName, 'logo' => ['@type' => 'ImageObject', 'url' => $organizationLogo]],
+ ];
@endphp
{{ $pageTitle }}
+ @if($pageKeywords)@endif
+
+
+ @if($googleVerification)@endif
-
-
+
+
- @if(app()->environment('production'))
-
- @else
-
- @endif
@@ -36,17 +55,18 @@
-
-
-
+
+
+
-
-
-
-
-
+
+
+ @if($twitterSite)@endif
+
+
+
@stack('head')
diff --git a/resources/views/seo/sitemap.blade.php b/resources/views/seo/sitemap.blade.php
new file mode 100644
index 0000000..517eb1c
--- /dev/null
+++ b/resources/views/seo/sitemap.blade.php
@@ -0,0 +1,18 @@
+{!! '' !!}
+
+@foreach($static as $item)
+
+ {{ route($item['route']) }}
+ {{ $item['frequency'] }}
+ {{ $item['priority'] }}
+
+@endforeach
+@foreach($pages as $page)
+
+ {{ route('cms.show', $page->slug) }}
+ {{ $page->updated_at->toAtomString() }}
+ {{ $page->sitemap_change_frequency }}
+ {{ number_format((float) $page->sitemap_priority, 1, '.', '') }}
+
+@endforeach
+
diff --git a/routes/web.php b/routes/web.php
index 0438c31..cc02748 100755
--- a/routes/web.php
+++ b/routes/web.php
@@ -4,6 +4,8 @@ use App\Http\Controllers\Admin\AdminController;
use App\Http\Controllers\Admin\SupportTicketController;
use App\Http\Controllers\Admin\ReleaseController as AdminReleaseController;
use App\Http\Controllers\Admin\CmsPageController as AdminCmsPageController;
+use App\Http\Controllers\Admin\SeoSettingsController;
+use App\Http\Controllers\SeoController;
use App\Http\Controllers\CmsPageController;
use App\Http\Controllers\Auth\AuthenticatedSessionController;
use App\Http\Controllers\Auth\EmailVerificationController;
@@ -16,6 +18,9 @@ use App\Http\Controllers\Support\SupportController;
use App\Http\Middleware\AdminOnly;
use Illuminate\Support\Facades\Route;
+Route::get('/sitemap.xml', [SeoController::class, 'sitemap'])->name('sitemap');
+Route::get('/robots.txt', [SeoController::class, 'robots'])->name('robots');
+
Route::get('/', fn () => view('portal.welcome', ['latestRelease' => \App\Models\SonarDeskRelease::published()->where('channel', 'stable')->latest('published_at')->first()]))->name('home');
Route::get('/buy', CheckoutController::class)->name('buy');
Route::view('/features', 'features')->name('features');
@@ -59,6 +64,8 @@ Route::middleware(['auth', 'verified', AdminOnly::class])->prefix('admin')->name
Route::get('/licences/{license}', [AdminController::class, 'show'])->name('license');
Route::post('/licences/{license}/revoke', [AdminController::class, 'revoke'])->name('revoke');
Route::post('/licences/{license}/restore', [AdminController::class, 'restore'])->name('restore');
+ Route::get('/seo', [SeoSettingsController::class, 'edit'])->name('seo.edit');
+ Route::put('/seo', [SeoSettingsController::class, 'update'])->name('seo.update');
Route::get('/cms', [AdminCmsPageController::class, 'index'])->name('cms.index');
Route::get('/cms/create', [AdminCmsPageController::class, 'create'])->name('cms.create');
Route::post('/cms', [AdminCmsPageController::class, 'store'])->name('cms.store');
diff --git a/scripts/scan.sh b/scripts/scan.sh
new file mode 100644
index 0000000..e708714
--- /dev/null
+++ b/scripts/scan.sh
@@ -0,0 +1,5 @@
+sonar-scanner \
+ -Dsonar.projectKey=SonarDesk \
+ -Dsonar.sources=. \
+ -Dsonar.host.url=https://sonarcube.absdev.net \
+ -Dsonar.token=sqp_b215d121d857a0014bdad688bc76f31986a882c5
diff --git a/sonar_desk b/sonar_desk
deleted file mode 100644
index e69de29..0000000