SEO social meta (#35)
* SEO social meta * Small wording changes Co-authored-by: Julien Nahum <jhumanj@MacBook-Pro-de-Julien.local>
This commit is contained in:
parent
73cb016473
commit
567b7a44dc
|
@ -1,7 +1,7 @@
|
||||||
# OpnForm
|
# OpnForm
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://github.com/JhumanJ/OpnForm/blob/main/public/img/social-preview.png?raw=true">
|
<img src="https://github.com/JhumanJ/OpnForm/blob/main/public/img/social-preview.jpg?raw=true">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<a href="https://github.com/jhumanj/OpnForm/actions"><img src="https://github.com/jhumanj/laravel-vue-tailwind-spa/workflows/tests/badge.svg" alt="Build Status"></a>
|
<a href="https://github.com/jhumanj/OpnForm/actions"><img src="https://github.com/jhumanj/laravel-vue-tailwind-spa/workflows/tests/badge.svg" alt="Build Status"></a>
|
||||||
|
|
|
@ -2,6 +2,8 @@
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Service\SeoMetaResolver;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
class SpaController extends Controller
|
class SpaController extends Controller
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
|
@ -9,8 +11,10 @@ class SpaController extends Controller
|
||||||
*
|
*
|
||||||
* @return \Illuminate\Http\Response
|
* @return \Illuminate\Http\Response
|
||||||
*/
|
*/
|
||||||
public function __invoke()
|
public function __invoke(Request $request)
|
||||||
{
|
{
|
||||||
return view('spa');
|
return view('spa',[
|
||||||
|
'meta' => (new SeoMetaResolver($request))->getMetas(),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,178 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\Models\Forms\Form;
|
||||||
|
use App\Models\Template;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Routing\Route;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates meta per-route matching. This is useful because Google, Twitter and Facebook struggle to load meta tags
|
||||||
|
* injected dynamically by JavaScript. This class allows us to inject meta tags in the HTML head tag.
|
||||||
|
*
|
||||||
|
* Here's how to use this class
|
||||||
|
* - Add a pattern to URL_PATTERNS
|
||||||
|
* - Then choose between a static meta or a dynamic meta:
|
||||||
|
* - If the content is dynamic (ex: needs to retrieve data from the database), then add a method to this class for
|
||||||
|
* the corresponding pattern. The method should be named "getMyPatternName" (where pattern name is
|
||||||
|
* my_pattern_name) and it should return an array of meta tags.
|
||||||
|
* - If the content is static, then add meta tags to the PATTERN_STATIC_META array.
|
||||||
|
*/
|
||||||
|
class SeoMetaResolver
|
||||||
|
{
|
||||||
|
private array $patternData = [];
|
||||||
|
|
||||||
|
const URL_PATTERNS = [
|
||||||
|
'welcome' => '/',
|
||||||
|
'form_show' => '/forms/{slug}',
|
||||||
|
'login' => '/login',
|
||||||
|
'register' => '/register',
|
||||||
|
'reset_password' => '/password/reset',
|
||||||
|
'privacy_policy' => '/privacy-policy',
|
||||||
|
'terms_conditions' => '/terms-conditions',
|
||||||
|
'integrations' => '/integrations',
|
||||||
|
'templates' => '/templates',
|
||||||
|
'template_show' => '/templates/{slug}',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metas for simple route (without needing to access DB)
|
||||||
|
*/
|
||||||
|
const PATTERN_STATIC_META = [
|
||||||
|
'login' => [
|
||||||
|
'title' => 'Login',
|
||||||
|
],
|
||||||
|
'register' => [
|
||||||
|
'title' => 'Create your account',
|
||||||
|
],
|
||||||
|
'reset_password' => [
|
||||||
|
'title' => 'Reset your password',
|
||||||
|
],
|
||||||
|
'privacy_policy' => [
|
||||||
|
'title' => 'Our Privacy Policy',
|
||||||
|
],
|
||||||
|
'terms_conditions' => [
|
||||||
|
'title' => 'Our Terms & Conditions',
|
||||||
|
],
|
||||||
|
'integrations' => [
|
||||||
|
'title' => 'Our Integrations',
|
||||||
|
],
|
||||||
|
'templates' => [
|
||||||
|
'title' => 'Templates',
|
||||||
|
'description' => 'Free templates to quickly create beautiful forms for free!'
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
const META_CACHE_DURATION = 60 * 60 * 12; // 12 hours
|
||||||
|
|
||||||
|
const META_CACHE_KEY_PREFIX = 'seo_meta_';
|
||||||
|
|
||||||
|
public function __construct(private Request $request)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the right metas for a given route, caches meta for 1 hour.
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function getMetas(): array
|
||||||
|
{
|
||||||
|
$cacheKey = self::META_CACHE_KEY_PREFIX . urlencode($this->request->path());
|
||||||
|
|
||||||
|
return Cache::remember($cacheKey, now()->addSeconds(self::META_CACHE_DURATION), function () {
|
||||||
|
$pattern = $this->resolvePattern();
|
||||||
|
|
||||||
|
if ($this->hasPatternMetaGetter($pattern)) {
|
||||||
|
// Custom function for pattern
|
||||||
|
try {
|
||||||
|
return array_merge($this->getDefaultMeta(), $this->{'get' . Str::studly($pattern) . 'Meta'}());
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $this->getDefaultMeta();
|
||||||
|
}
|
||||||
|
} elseif (in_array($pattern, array_keys(self::PATTERN_STATIC_META))) {
|
||||||
|
// Simple meta for pattern
|
||||||
|
$meta = self::PATTERN_STATIC_META[$pattern];
|
||||||
|
if (isset($meta['title'])) {
|
||||||
|
$meta['title'] .= $this->titleSuffix();
|
||||||
|
}
|
||||||
|
if (isset($meta['image'])) {
|
||||||
|
$meta['image'] = asset($meta['image']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_merge($this->getDefaultMeta(), $meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->getDefaultMeta();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simulates the Laravel router to match route with Metas
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private function resolvePattern()
|
||||||
|
{
|
||||||
|
foreach (self::URL_PATTERNS as $patternName => $patternData) {
|
||||||
|
$path = rtrim($this->request->getPathInfo(), '/') ?: '/';
|
||||||
|
|
||||||
|
$route = (new Route('GET', $patternData, fn() => ''))->bind($this->request);
|
||||||
|
if (preg_match($route->getCompiled()->getRegex(), rawurldecode($path))) {
|
||||||
|
$this->patternData = $route->parameters();
|
||||||
|
|
||||||
|
return $patternName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if a get mutator exists for a pattern.
|
||||||
|
*
|
||||||
|
* @param string $key
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
private function hasPatternMetaGetter($key)
|
||||||
|
{
|
||||||
|
return method_exists($this, 'get' . Str::studly($key) . 'Meta');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function titleSuffix()
|
||||||
|
{
|
||||||
|
return ' · ' . config('app.name');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getDefaultMeta(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'title' => 'Create beautiful forms for free' . $this->titleSuffix(),
|
||||||
|
'description' => "Create beautiful forms for free. Unlimited fields, unlimited submissions. It's free and it takes less than 1 minute to create your first form.",
|
||||||
|
'image' => asset('/img/social-preview.jpg'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getFormShowMeta(): array
|
||||||
|
{
|
||||||
|
$form = Form::whereSlug($this->patternData['slug'])->firstOrFail();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'title' => $form->title . $this->titleSuffix(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getTemplateShowMeta(): array
|
||||||
|
{
|
||||||
|
$template = Template::whereSlug($this->patternData['slug'])->firstOrFail();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'title' => $template->name . $this->titleSuffix(),
|
||||||
|
'description' => Str::of($template->description)->limit(160) ,
|
||||||
|
'image' => $template->image_url
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
Binary file not shown.
After Width: | Height: | Size: 36 KiB |
Binary file not shown.
Before Width: | Height: | Size: 185 KiB |
|
@ -94,10 +94,10 @@ export default {
|
||||||
{ vmid: 'description', name: 'description', content: description },
|
{ vmid: 'description', name: 'description', content: description },
|
||||||
{ vmid: 'og:title', property: 'og:title', content: appName },
|
{ vmid: 'og:title', property: 'og:title', content: appName },
|
||||||
{ vmid: 'og:description', property: 'og:description', content: description },
|
{ vmid: 'og:description', property: 'og:description', content: description },
|
||||||
{ vmid: 'og:image', property: 'og:image', content: '/img/social-preview.png' },
|
{ vmid: 'og:image', property: 'og:image', content: this.asset('img/social-preview.jpg') },
|
||||||
{ vmid: 'twitter:title', property: 'twitter:title', content: appName },
|
{ vmid: 'twitter:title', property: 'twitter:title', content: appName },
|
||||||
{ vmid: 'twitter:description', property: 'twitter:description', content: description },
|
{ vmid: 'twitter:description', property: 'twitter:description', content: description },
|
||||||
{ vmid: 'twitter:image', property: 'twitter:image', content: '/img/social-preview.png' },
|
{ vmid: 'twitter:image', property: 'twitter:image', content: this.asset('img/social-preview.jpg') },
|
||||||
{ vmid: 'twitter:card', property: 'twitter:card', content: 'summary_large_image' }
|
{ vmid: 'twitter:card', property: 'twitter:card', content: 'summary_large_image' }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -73,11 +73,6 @@ export default {
|
||||||
next()
|
next()
|
||||||
},
|
},
|
||||||
|
|
||||||
props: {
|
|
||||||
metaTitle: {type: String, default: 'Templates'},
|
|
||||||
metaDescription: {type: String, default: 'Public templates for create form quickly!'}
|
|
||||||
},
|
|
||||||
|
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {}
|
||||||
},
|
},
|
||||||
|
@ -105,7 +100,10 @@ export default {
|
||||||
},
|
},
|
||||||
form() {
|
form() {
|
||||||
return new Form(this.template.structure)
|
return new Form(this.template.structure)
|
||||||
}
|
},
|
||||||
|
metaTitle () {
|
||||||
|
return this.template ? this.template.name : 'Template'
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -21,11 +21,23 @@
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
|
|
||||||
<title>{{ config('app.name') }}</title>
|
|
||||||
|
|
||||||
<link rel="stylesheet" href="{{ mix('dist/css/app.css') }}">
|
<link rel="stylesheet" href="{{ mix('dist/css/app.css') }}">
|
||||||
<link rel="icon" href="{{asset('/img/logo.svg')}}">
|
<link rel="icon" href="{{asset('/img/logo.svg')}}">
|
||||||
|
|
||||||
|
@if($meta)
|
||||||
|
<title>{{$meta['title']}}</title>
|
||||||
|
<meta name='description' content='{{$meta['description']}}'>
|
||||||
|
|
||||||
|
<meta name='og:title' content='{{$meta['title']}}'>
|
||||||
|
<meta name='og:description' content='{{$meta['description']}}'>
|
||||||
|
<meta name='og:image' content='{{$meta['image']}}'>
|
||||||
|
<meta name='og:site_name' content='OpenForm'>
|
||||||
|
|
||||||
|
<meta name="twitter:title" content="{{$meta['title']}}">
|
||||||
|
<meta name="twitter:description" content="{{$meta['description']}}">
|
||||||
|
<meta name="twitter:image" content="{{$meta['image']}}">
|
||||||
|
@endif
|
||||||
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|
Loading…
Reference in New Issue