SEO additions

This commit is contained in:
Mark Gregory
2026-10-01 11:15:55 +01:00
parent a94f294184
commit a8eff45f49
18 changed files with 493 additions and 24 deletions
+11 -1
View File
@@ -6,7 +6,17 @@
<label>Slug<input name="slug" value="{{ old('slug',$page->slug) }}" placeholder="generated-from-title" maxlength="180"></label>
<label>Excerpt<textarea name="excerpt" rows="3" maxlength="1000">{{ old('excerpt',$page->excerpt) }}</textarea></label>
<label>Body<textarea name="body" rows="18" required>{{ old('body',$page->body) }}</textarea><small>Plain text is rendered safely with paragraphs and line breaks.</small></label>
<div class="form-grid two"><label>SEO title<input name="meta_title" value="{{ old('meta_title',$page->meta_title) }}" maxlength="160"></label><label>SEO description<textarea name="meta_description" rows="3" maxlength="320">{{ old('meta_description',$page->meta_description) }}</textarea></label></div>
<section class="seo-section" style="padding:24px 0 4px;border-top:1px solid var(--line);margin-top:24px">
<div class="seo-section-head"><div><span class="eyebrow">Search appearance</span><h2>Page SEO</h2><p>Override global defaults only where this page needs specific search or social metadata.</p></div><a class="btn btn-secondary" href="{{ route('admin.seo.edit') }}">Global SEO settings</a></div>
<div class="form-grid two"><label>SEO title<input name="meta_title" value="{{ old('meta_title',$page->meta_title) }}" maxlength="160" placeholder="Defaults to the page title"></label><label>Canonical URL<input type="url" name="canonical_url" value="{{ old('canonical_url',$page->canonical_url) }}" maxlength="2048" placeholder="Leave blank for this page URL"></label></div>
<label>SEO description<textarea name="meta_description" rows="3" maxlength="320" placeholder="Defaults to the excerpt">{{ old('meta_description',$page->meta_description) }}</textarea></label>
<label>Keywords<input name="meta_keywords" value="{{ old('meta_keywords',$page->meta_keywords) }}" maxlength="500" placeholder="Optional comma-separated search topics"></label>
<div class="form-grid two"><label>Open Graph title<input name="og_title" value="{{ old('og_title',$page->og_title) }}" maxlength="160" placeholder="Defaults to SEO title"></label><label>Open Graph image URL<input type="url" name="og_image" value="{{ old('og_image',$page->og_image) }}" maxlength="2048" placeholder="Defaults to global social image"></label></div>
<label>Open Graph description<textarea name="og_description" rows="3" maxlength="320" placeholder="Defaults to SEO description">{{ old('og_description',$page->og_description) }}</textarea></label>
<div class="form-grid two"><label>Sitemap priority<input type="number" name="sitemap_priority" value="{{ old('sitemap_priority',$page->sitemap_priority ?? '0.5') }}" min="0" max="1" step="0.1" required></label><label>Change frequency<select name="sitemap_change_frequency" required>@foreach(['always','hourly','daily','weekly','monthly','yearly','never'] as $frequency)<option value="{{ $frequency }}" @selected(old('sitemap_change_frequency',$page->sitemap_change_frequency ?? 'monthly') === $frequency)>{{ ucfirst($frequency) }}</option>@endforeach</select></label></div>
<div class="form-grid two"><label class="consent-check"><input type="checkbox" name="robots_index" value="1" @checked(old('robots_index',$page->exists ? $page->robots_index : true))><span>Allow search engines to index this page</span></label><label class="consent-check"><input type="checkbox" name="robots_follow" value="1" @checked(old('robots_follow',$page->exists ? $page->robots_follow : true))><span>Allow crawlers to follow links</span></label></div>
<label class="consent-check"><input type="checkbox" name="include_in_sitemap" value="1" @checked(old('include_in_sitemap',$page->exists ? $page->include_in_sitemap : true))><span>Include this page in sitemap.xml</span></label>
</section>
<label class="consent-check"><input type="checkbox" name="is_published" value="1" @checked(old('is_published',$page->is_published))><span>Published</span></label>
<div class="support-actions"><button type="submit">Save page</button>@if($page->exists && $page->is_published)<a class="btn btn-secondary" target="_blank" href="{{ route('cms.show',$page->slug) }}">View page</a>@endif</div>
</form></div>
+1 -1
View File
@@ -2,5 +2,5 @@
@section('content')
<section class="portal-header"><div><span class="eyebrow">Admin</span><h1>Licensing administration</h1><p class="lead compact">Manage licences, activations and customer support.</p></div></section>
<div class="grid"><div class="card"><strong>{{ $licenseCount }}</strong><p>Licences</p></div><div class="card"><strong>{{ $activeCount }}</strong><p>Not revoked</p></div><div class="card"><strong>{{ $activationCount }}</strong><p>Active installations</p></div><div class="card"><strong>{{ $userCount }}</strong><p>Portal users</p></div></div>
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:18px"><a class="btn btn-primary" href="{{ route('admin.releases.index') }}">Manage releases</a><a class="btn" href="{{ route('admin.licenses') }}">Manage licences</a><a class="btn btn-secondary" href="{{ route('admin.cms.index') }}">Manage content</a><a class="btn btn-secondary" href="{{ route('admin.support.index') }}">Support inbox</a></div>
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:18px"><a class="btn btn-primary" href="{{ route('admin.releases.index') }}">Manage releases</a><a class="btn" href="{{ route('admin.licenses') }}">Manage licences</a><a class="btn btn-secondary" href="{{ route('admin.cms.index') }}">Manage content</a><a class="btn btn-secondary" href="{{ route('admin.seo.edit') }}">SEO & search</a><a class="btn btn-secondary" href="{{ route('admin.support.index') }}">Support inbox</a></div>
@endsection
+39
View File
@@ -0,0 +1,39 @@
@extends('layouts.app')
@section('content')
<section class="portal-header admin-release-head">
<div><span class="eyebrow">Search & discovery</span><h1>SEO settings</h1><p class="lead compact">Control how SonarDesk appears in search engines and when pages are shared.</p></div>
<div class="support-actions"><a class="btn btn-secondary" target="_blank" href="{{ route('sitemap') }}">View sitemap</a><a class="btn btn-secondary" target="_blank" href="{{ route('robots') }}">View robots.txt</a></div>
</section>
<form method="post" action="{{ route('admin.seo.update') }}" class="seo-admin-form">@csrf @method('PUT')
<section class="panel seo-section">
<div class="seo-section-head"><div><span class="eyebrow">Search appearance</span><h2>Site defaults</h2><p>Fallback metadata used whenever a page does not provide its own SEO values.</p></div><span class="seo-badge">Global</span></div>
<div class="form-grid two">
<label>Site name<input name="seo_site_name" value="{{ old('seo_site_name',$settings['seo_site_name'] ?: 'SonarDesk') }}" required maxlength="80"></label>
<label>Title suffix<input name="seo_title_suffix" value="{{ old('seo_title_suffix',$settings['seo_title_suffix'] ?: 'SonarDesk') }}" maxlength="80"><small>Appended to page titles where appropriate.</small></label>
</div>
<label>Default SEO title<input name="seo_default_title" value="{{ old('seo_default_title',$settings['seo_default_title'] ?: 'SonarDesk — Native SonarQube Monitoring for macOS') }}" required maxlength="160"></label>
<label>Default meta description<textarea name="seo_default_description" rows="3" maxlength="320" required>{{ old('seo_default_description',$settings['seo_default_description'] ?: 'Native SonarQube monitoring for macOS. Track projects, quality gates, bugs, vulnerabilities and code smells from one professional desktop app.') }}</textarea></label>
<label>Default keywords<input name="seo_default_keywords" value="{{ old('seo_default_keywords',$settings['seo_default_keywords']) }}" maxlength="500" placeholder="SonarQube, macOS, code quality, developer tools"></label>
<label>Canonical base URL<input type="url" name="seo_canonical_base_url" value="{{ old('seo_canonical_base_url',$settings['seo_canonical_base_url']) }}" maxlength="2048" placeholder="https://sonardesk.app"><small>Leave blank to use the application URL. Use the public production origin only.</small></label>
</section>
<section class="panel seo-section">
<div class="seo-section-head"><div><span class="eyebrow">Social sharing</span><h2>Open Graph & social</h2><p>Professional defaults for link previews across social networks and messaging applications.</p></div></div>
<label>Default social image URL<input type="url" name="seo_default_og_image" value="{{ old('seo_default_og_image',$settings['seo_default_og_image']) }}" maxlength="2048" placeholder="https://…/og-sonardesk.png"><small>Recommended: 1200 × 630 px.</small></label>
<div class="form-grid two"><label>Organization name<input name="seo_organization_name" value="{{ old('seo_organization_name',$settings['seo_organization_name'] ?: 'SonarDesk') }}" maxlength="120"></label><label>Organization logo URL<input type="url" name="seo_organization_logo" value="{{ old('seo_organization_logo',$settings['seo_organization_logo']) }}" maxlength="2048"></label></div>
<label>X / Twitter account<input name="seo_twitter_site" value="{{ old('seo_twitter_site',$settings['seo_twitter_site']) }}" maxlength="40" placeholder="@SonarDesk"></label>
</section>
<section class="panel seo-section">
<div class="seo-section-head"><div><span class="eyebrow">Google Search Console</span><h2>Domain verification</h2><p>Use a Domain property in Google Search Console. Google will provide a TXT value that you add at your DNS provider.</p></div><span class="seo-badge success">DNS TXT</span></div>
<div class="seo-dns-callout"><strong>Recommended verification method</strong><span>Add Google’s <code>google-site-verification=…</code> TXT record to the root domain. DNS verification covers all protocols and subdomains.</span></div>
<label>Optional Google verification token<input name="seo_google_site_verification" value="{{ old('seo_google_site_verification',$settings['seo_google_site_verification']) }}" maxlength="255" placeholder="Only needed if you also choose HTML-tag verification"><small>DNS TXT verification does not require this field. It is available as a secondary verification method.</small></label>
</section>
<section class="panel seo-section">
<div class="seo-section-head"><div><span class="eyebrow">Crawler control</span><h2>Indexing</h2><p>Production crawler controls. Non-production environments remain blocked automatically.</p></div></div>
<label class="consent-check"><input type="checkbox" name="seo_robots_enabled" value="1" @checked(old('seo_robots_enabled',$robotsEnabled))><span><strong>Allow production search-engine indexing</strong><small>When disabled, robots.txt blocks crawling and pages emit noindex.</small></span></label>
</section>
<div class="seo-savebar"><div><strong>Search configuration</strong><span>Changes apply to public metadata immediately.</span></div><button type="submit" class="btn btn-primary">Save SEO settings</button></div>
</form>
@endsection
+11 -1
View File
@@ -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')
<article class="legal-page"><header class="legal-head"><span class="eyebrow">SonarDesk</span><h1>{{ $page->title }}</h1>@if($page->excerpt)<p>{{ $page->excerpt }}</p>@endif</header><div class="cms-body">{!! nl2br(e($page->body)) !!}</div></article>
@endsection
+41 -19
View File
@@ -6,26 +6,45 @@
<meta name="csrf-token" content="{{ csrf_token() }}">
@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
<title>{{ $pageTitle }}</title>
<meta name="description" content="{{ $pageDescription }}">
@if($pageKeywords)<meta name="keywords" content="{{ $pageKeywords }}">@endif
<meta name="robots" content="{{ $robots }}">
<meta name="googlebot" content="{{ $robots }}">
@if($googleVerification)<meta name="google-site-verification" content="{{ $googleVerification }}">@endif
<meta name="theme-color" content="#0b1220">
<meta name="application-name" content="SonarDesk">
<meta name="apple-mobile-web-app-title" content="SonarDesk">
<meta name="application-name" content="{{ $seoSiteName }}">
<meta name="apple-mobile-web-app-title" content="{{ $seoSiteName }}">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="msapplication-TileColor" content="#2563eb">
<meta name="msapplication-config" content="{{ asset('browserconfig.xml') }}">
@if(app()->environment('production'))
<meta name="robots" content="index,follow,max-image-preview:large">
@else
<meta name="robots" content="noindex,nofollow">
@endif
<link rel="canonical" href="{{ $canonicalUrl }}">
<link rel="icon" href="{{ asset('favicon.ico') }}" sizes="any">
@@ -36,17 +55,18 @@
<link rel="manifest" href="{{ asset('site.webmanifest') }}">
<meta property="og:type" content="website">
<meta property="og:site_name" content="SonarDesk">
<meta property="og:title" content="{{ $pageTitle }}">
<meta property="og:description" content="{{ $pageDescription }}">
<meta property="og:site_name" content="{{ $seoSiteName }}">
<meta property="og:title" content="{{ $ogTitle }}">
<meta property="og:description" content="{{ $ogDescription }}">
<meta property="og:url" content="{{ $canonicalUrl }}">
<meta property="og:image" content="{{ $socialImage }}">
<meta property="og:image:width" content="512">
<meta property="og:image:height" content="512">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="{{ $pageTitle }}">
<meta name="twitter:description" content="{{ $pageDescription }}">
<meta property="og:image:alt" content="{{ $ogTitle }}">
<meta name="twitter:card" content="summary_large_image">
@if($twitterSite)<meta name="twitter:site" content="{{ str_starts_with($twitterSite, '@') ? $twitterSite : '@'.$twitterSite }}">@endif
<meta name="twitter:title" content="{{ $ogTitle }}">
<meta name="twitter:description" content="{{ $ogDescription }}">
<meta name="twitter:image" content="{{ $socialImage }}">
<script type="application/ld+json">{!! json_encode($schema, JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE) !!}</script>
<style>
:root{
@@ -176,6 +196,8 @@
@media(max-width:900px){.download-hero .download-shell{grid-template-columns:1fr}.product-visual{display:none}.download-copy h1{font-size:52px}.confidence-grid{grid-template-columns:repeat(2,1fr);gap:20px}.confidence-grid div{border:0;padding:0}.download-content,.release-editor{grid-template-columns:1fr}.sticky-card{position:static}.download-support .download-shell,.admin-release-head{align-items:flex-start;flex-direction:column}}@media(max-width:600px){.download-hero{padding:64px 0}.download-copy h1{font-size:42px}.download-actions,.release-heading{flex-direction:column}.download-primary,.download-secondary{width:100%}.confidence-grid{grid-template-columns:1fr}.release-history-list article{align-items:flex-start;gap:12px;flex-direction:column}.download-content{padding-top:40px;padding-bottom:40px}.release-notes-panel,.requirements-panel{padding:22px}.form-grid{grid-template-columns:1fr}.download-support .download-shell{align-items:stretch}}
.portal-download-banner{display:flex;align-items:center;justify-content:space-between;gap:20px;padding:18px 20px;margin-bottom:22px;background:linear-gradient(135deg,#0f172a,#172554);color:#fff;border-radius:14px;box-shadow:var(--shadow-md)}.portal-download-banner strong,.portal-download-banner small{display:block}.portal-download-banner strong{font-size:18px;margin:3px 0}.portal-download-banner small{color:#cbd5e1}.portal-download-banner .eyebrow{color:#93c5fd}@media(max-width:650px){.portal-download-banner{align-items:stretch;flex-direction:column}}
.home-download-showcase{display:grid;grid-template-columns:1.25fr .75fr;gap:36px;align-items:center;margin:0 0 64px;padding:38px;background:linear-gradient(135deg,#f8fafc,#eff6ff);border:1px solid #dbeafe;border-radius:22px}.home-download-copy h2{font-size:38px;letter-spacing:-.035em;margin:7px 0 10px}.home-download-copy p{color:#667085;font-size:16px;max-width:680px}.home-download-meta{background:#0b1220;color:#fff;border-radius:16px;padding:24px;box-shadow:0 18px 40px rgba(15,23,42,.16)}.home-download-meta>span{display:block;color:#93c5fd;text-transform:uppercase;font-size:9px;font-weight:900;letter-spacing:.12em}.home-download-meta>strong{display:block;font-size:24px;margin:7px 0 3px}.home-download-meta>small{color:#94a3b8}.home-download-meta>div{margin-top:20px;padding-top:16px;border-top:1px solid rgba(255,255,255,.1)}.home-download-meta b,.home-download-meta code{display:block}.home-download-meta b{font-size:9px;color:#64748b;letter-spacing:.1em}.home-download-meta code{font-size:10px;color:#cbd5e1;margin-top:5px}@media(max-width:760px){.home-download-showcase{grid-template-columns:1fr;padding:24px}}
.seo-admin-form{display:grid;gap:18px}.seo-section{padding:28px}.seo-section h2{margin:4px 0 4px;font-size:22px;letter-spacing:-.02em}.seo-section-head{display:flex;justify-content:space-between;align-items:flex-start;gap:20px;padding-bottom:20px;margin-bottom:22px;border-bottom:1px solid var(--line)}.seo-section-head p{margin:0;color:var(--muted);max-width:680px}.seo-badge{display:inline-flex;align-items:center;padding:6px 10px;border-radius:999px;background:var(--blue-soft);color:var(--blue-dark);font-size:11px;font-weight:850;text-transform:uppercase;letter-spacing:.08em;white-space:nowrap}.seo-badge.success{background:var(--green-soft);color:var(--green)}.seo-dns-callout{display:grid;gap:5px;margin-bottom:22px;padding:16px 18px;border:1px solid #bfdbfe;border-radius:12px;background:#eff6ff;color:#1e3a8a}.seo-dns-callout span{color:#475569}.seo-dns-callout code{font-weight:800}.seo-savebar{position:sticky;bottom:18px;z-index:20;display:flex;align-items:center;justify-content:space-between;gap:18px;padding:15px 18px;border:1px solid var(--line);border-radius:14px;background:rgba(255,255,255,.96);backdrop-filter:blur(14px);box-shadow:0 14px 38px rgba(15,23,42,.14)}.seo-savebar strong,.seo-savebar span{display:block}.seo-savebar span{font-size:12px;color:var(--muted);margin-top:2px}.seo-preview{border:1px solid var(--line);border-radius:12px;padding:18px;background:#fff}.seo-preview-label{font-size:10px;font-weight:850;text-transform:uppercase;letter-spacing:.1em;color:var(--muted);margin-bottom:10px}.seo-preview-title{color:#1a0dab;font-size:19px;line-height:1.3}.seo-preview-url{color:#188038;font-size:12px;margin:4px 0}.seo-preview-description{color:#4d5156;font-size:13px;line-height:1.45}@media(max-width:700px){.seo-section-head,.seo-savebar{align-items:stretch;flex-direction:column}.seo-savebar .btn{width:100%}}
</style>
@stack('head')
</head>
+18
View File
@@ -0,0 +1,18 @@
{!! '<?xml version="1.0" encoding="UTF-8"?>' !!}
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
@foreach($static as $item)
<url>
<loc>{{ route($item['route']) }}</loc>
<changefreq>{{ $item['frequency'] }}</changefreq>
<priority>{{ $item['priority'] }}</priority>
</url>
@endforeach
@foreach($pages as $page)
<url>
<loc>{{ route('cms.show', $page->slug) }}</loc>
<lastmod>{{ $page->updated_at->toAtomString() }}</lastmod>
<changefreq>{{ $page->sitemap_change_frequency }}</changefreq>
<priority>{{ number_format((float) $page->sitemap_priority, 1, '.', '') }}</priority>
</url>
@endforeach
</urlset>