Handle metadata in the database for a few reasons: the method is the same between traditional and vue, the user has control over the values, we can create dynamic titles a bit more easily, and with vue the values are populated before the SPA loads so search engines can pick it up more easily

This commit is contained in:
Kevin MacMartin 2024-04-03 22:35:43 -04:00
parent 338a2517b2
commit 2f5ed84e2b
19 changed files with 204 additions and 54 deletions

View file

@ -1,7 +1,6 @@
DEFAULT_LANGUAGE=en DEFAULT_LANGUAGE=en
APP_NAME='Hypothetical' APP_NAME='Hypothetical'
APP_DESC='A website template'
APP_ENV=local APP_ENV=local
APP_KEY= APP_KEY=
APP_DEBUG=true APP_DEBUG=true

View file

@ -10,6 +10,12 @@ class Dashboard
* @return array * @return array
*/ */
public static $menu = [ public static $menu = [
[
'title' => 'Metadata',
'type' => 'edit',
'model' => 'meta'
],
[ [
'title' => 'Blog', 'title' => 'Blog',
'type' => 'edit', 'type' => 'edit',

View file

@ -6,10 +6,16 @@ use Newsletter;
use App\Models\Blog; use App\Models\Blog;
use App\Models\Contact; use App\Models\Contact;
use App\Models\Subscriptions; use App\Models\Subscriptions;
use App\Models\Meta;
use Illuminate\Http\Request; use Illuminate\Http\Request;
class ApiController extends Controller { class ApiController extends Controller {
public function getMeta($path = null)
{
return Meta::getData($path);
}
public function getBlogEntries() public function getBlogEntries()
{ {
return Blog::getBlogEntries(); return Blog::getBlogEntries();

54
app/Models/Meta.php Normal file
View file

@ -0,0 +1,54 @@
<?php
namespace App\Models;
class Meta extends DashboardModel
{
protected $table = 'meta';
public static $create = false;
public static $items_per_page = 0;
public static $dashboard_help_text = 'The path must start with a forward slash (eg: "/" or "/pagename")';
public static $dashboard_type = 'edit';
public static $dashboard_display = [ 'title', 'path' ];
public static $dashboard_columns = [
[ 'name' => 'path', 'required' => true, 'unique' => true, 'type' => 'string' ],
[ 'name' => 'title', 'required' => true, 'unique' => false, 'type' => 'string' ],
[ 'name' => 'description', 'required' => true, 'unique' => false, 'type' => 'text' ],
[ 'name' => 'keywords', 'required' => true, 'unique' => false, 'type' => 'string' ]
];
public static function getData($path)
{
if (!preg_match('/^\//', $path)) {
$path = "/$path";
}
if (preg_match('/^\/(dashboard|login|register)/', $path)) {
$page = [
'title' => 'Dashboard' . ' | ' . env('APP_NAME'),
'description' => '',
'keywords' => ''
];
} else {
$page = self::select('title', 'description', 'keywords')->where('path', "$path")->first();
if ($page == null) {
$page = [
'title' => 'Page Not Found' . ' | ' . env('APP_NAME'),
'description' => 'The requested page cannot be found',
'keywords' => ''
];
} else {
$page['title'] = $page['title'] . ' | ' . env('APP_NAME');
}
}
return $page;
}
}

View file

@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('meta', function (Blueprint $table) {
$table->id();
$table->string('path')->nullable();
$table->text('title')->nullable();
$table->text('description')->nullable();
$table->text('keywords')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('meta');
}
};

View file

@ -13,6 +13,6 @@ class DatabaseSeeder extends Seeder
*/ */
public function run(): void public function run(): void
{ {
$this->call(MetaSeeder::class);
} }
} }

View file

@ -0,0 +1,53 @@
<?php
namespace Database\Seeders;
use DB;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
use App\Models\Meta;
class MetaSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
// Delete the table
DB::table('meta')->delete();
// Page metadata
$pages = [
[
'path' => '/',
'title' => 'Home',
'description' => '',
'keywords' => ''
],
[
'path' => '/blog',
'title' => 'Blog',
'description' => '',
'keywords' => ''
],
[
'path' => '/contact',
'title' => 'Contact',
'description' => '',
'keywords' => ''
]
];
foreach ($pages as $page) {
Meta::create([
'path' => $page['path'],
'title' => $page['title'],
'description' => $page['description'],
'keywords' => $page['keywords']
]);
}
}
}

View file

@ -69,9 +69,6 @@
data() { data() {
return { return {
metaTitle: "Contact",
metaDescription: "Contact Us",
metaKeywords: "contact",
submitting: false, submitting: false,
errorCount: 0, errorCount: 0,
submitSuccess: false, submitSuccess: false,

View file

@ -10,13 +10,6 @@
export default { export default {
mixins: [ mixins: [
BasePageMixin BasePageMixin
], ]
data() {
return {
metaTitle: "Page Not Found",
metaDescription: "The requested page cannot be found"
};
}
}; };
</script> </script>

View file

@ -15,12 +15,6 @@
components: { components: {
"subscription-form": SubscriptionFormSection "subscription-form": SubscriptionFormSection
},
data() {
return {
metaKeywords: "home"
};
} }
}; };
</script> </script>

View file

@ -75,6 +75,7 @@ const store = createStore({
appLang: env.appLang, appLang: env.appLang,
appDefaultLang: env.appDefaultLang, appDefaultLang: env.appDefaultLang,
firstLoad: true, firstLoad: true,
firstPage: true,
lastPath: "", lastPath: "",
supportsWebP: null supportsWebP: null
}, },
@ -96,6 +97,10 @@ const store = createStore({
return state.firstLoad; return state.firstLoad;
}, },
getFirstPage: state => {
return state.firstPage;
},
getLastPath: state => { getLastPath: state => {
return state.lastPath; return state.lastPath;
}, },
@ -115,6 +120,10 @@ const store = createStore({
state.firstLoad = value; state.firstLoad = value;
}, },
setFirstPage(state, value) {
state.firstPage = value;
},
setLastPath(state, value) { setLastPath(state, value) {
state.lastPath = value; state.lastPath = value;
}, },

View file

@ -1,10 +1,6 @@
export default { export default {
data() { data() {
return { return {
metaTitle: "",
metaDescription: "",
metaKeywords: "",
metaTags: { metaTags: {
"title": [ "name", "title" ], "title": [ "name", "title" ],
"description": [ "name", "description" ], "description": [ "name", "description" ],
@ -21,14 +17,6 @@ export default {
}, },
computed: { computed: {
pageTitle() {
return this.metaTitle === "" ? env.appName : `${this.metaTitle} | ${env.appName}`;
},
pageDescription() {
return this.metaDescription === "" ? env.appDesc : this.metaDescription;
},
fullPath() { fullPath() {
return document.location.origin + this.$route.path; return document.location.origin + this.$route.path;
} }
@ -43,22 +31,22 @@ export default {
} }
}, },
updateMetaData() { updateMetadata(meta) {
let metaContent; let metaContent;
document.title = this.pageTitle; document.title = meta.title;
$("link[rel=canonical]").attr("href", this.fullPath); $("link[rel=canonical]").attr("href", this.fullPath);
Object.keys(this.metaTags).forEach((name) => { Object.keys(this.metaTags).forEach((name) => {
switch (this.metaTags[name][1]) { switch (this.metaTags[name][1]) {
case "title": case "title":
metaContent = this.pageTitle; metaContent = meta.title;
break; break;
case "description": case "description":
metaContent = this.pageDescription; metaContent = meta.description;
break; break;
case "keywords": case "keywords":
metaContent = this.metaKeywords; metaContent = meta.keywords;
break; break;
case "url": case "url":
metaContent = this.fullPath; metaContent = this.fullPath;
@ -69,10 +57,24 @@ export default {
this.updateMetaTag(this.metaTags[name][0], name, metaContent); this.updateMetaTag(this.metaTags[name][0], name, metaContent);
}); });
},
fetchMetadata() {
this.$http.get(`/api/meta${this.$route.path}${env.apiToken}`).then((response) => {
this.updateMetadata(response.data);
}).catch((error) => {
console.log("error fetching metadata");
this.updateMetadata({ title: appName, description: "", keywords: "" });
});
} }
}, },
created() { created() {
this.updateMetaData(); // Don't fetch metadata on the first page load as this is handled by the page render
if (this.$store.getters.getFirstPage) {
this.$store.commit("setFirstPage", false);
} else {
this.fetchMetadata();
}
} }
}; };

View file

@ -1,3 +1 @@
@extends('templates.error', [ @extends('templates.error')
'title' => 'Page Not Found'
])

View file

@ -1,8 +1,15 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="{{ Language::getSessionLanguage() }}"> <html lang="{{ Language::getSessionLanguage() }}">
@php @php
$page_title = (isset($title) ? $title . ' - ' : '') . env('APP_NAME'); // Determine whether the device is mobile
$device_mobile = !is_null(Request::header('User-Agent')) && (preg_match('/Mobi/', Request::header('User-Agent')) || preg_match('/iP(hone|ad|od);/', Request::header('User-Agent'))); $device_mobile = !is_null(Request::header('User-Agent')) && (preg_match('/Mobi/', Request::header('User-Agent')) || preg_match('/iP(hone|ad|od);/', Request::header('User-Agent')));
// If an overridden title has been set (error pages) then use that, otherwise use the Meta model to populate metadata
if (isset($title)) {
$meta = [ 'title' => $title . ' - ' . env('APP_NAME'), 'description' => '', 'keywords' => '' ];
} else {
$meta = App\Models\Meta::getData(Request::path());
}
@endphp @endphp
<head> <head>
@ -11,22 +18,23 @@
<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" />
<meta name="theme-color" content="#fcfcfc" /> <meta name="theme-color" content="#fcfcfc" />
<title>{{ $page_title }}</title> <title>{{ $meta['title'] }}</title>
<meta name="title" content="{{ $page_title }}" /> <meta name="title" content="{{ $meta['title'] }}" />
<meta name="description" content="{{ env('APP_DESC') }}" /> <meta name="description" content="{{ $meta['description'] }}" />
<meta name="dc:title" content="{{ $page_title }}" /> <meta name="keywords" content="{{ $meta['keywords'] }}" />
<meta name="dc:description" content="{{ env('APP_DESC') }}" /> <meta name="dc:title" content="{{ $meta['title'] }}" />
<meta name="dc:description" content="{{ $meta['description'] }}" />
<meta property="og:type" content="article" /> <meta property="og:type" content="article" />
<meta property="og:title" content="{{ $page_title }}" /> <meta property="og:title" content="{{ $meta['title'] }}" />
<meta property="og:description" content="{{ env('APP_DESC') }}" /> <meta property="og:description" content="{{ $meta['description'] }}" />
<meta property="og:url" content="{{ Request::url() }}" /> <meta property="og:url" content="{{ Request::url() }}" />
<meta property="og:image" content="{{ asset('/img/logo.png') }}" /> <meta property="og:image" content="{{ asset('/img/logo.png') }}" />
<meta name="twitter:card" content="summary" /> <meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="{{ $page_title }}" /> <meta name="twitter:title" content="{{ $meta['title'] }}" />
<meta name="twitter:description" content="{{ env('APP_DESC') }}" /> <meta name="twitter:description" content="{{ $meta['description'] }}" />
<meta name="twitter:image" content="{{ asset('/img/logo.png') }}" /> <meta name="twitter:image" content="{{ asset('/img/logo.png') }}" />
<link rel="shortcut icon" href="{{ URL::to('/') }}/favicon.ico?version={{ Version::get() }}" /> <link rel="shortcut icon" href="{{ URL::to('/') }}/favicon.ico?version={{ Version::get() }}" />

View file

@ -1,4 +1,4 @@
@extends('templates.base', [ 'title' => 'Dashboard' ]) @extends('templates.base')
@php @php
$current_page = preg_match('/\/settings$/', Request::url()) ? 'settings' : preg_replace([ '/https?:\/\/[^\/]*\/dashboard\/[^\/]*\//', '/\/.*/' ], [ '', '' ], Request::url()); $current_page = preg_match('/\/settings$/', Request::url()) ? 'settings' : preg_replace([ '/https?:\/\/[^\/]*\/dashboard\/[^\/]*\//', '/\/.*/' ], [ '', '' ], Request::url());

View file

@ -7,7 +7,6 @@
<script> <script>
var env = { var env = {
appName: "{!! env('APP_NAME') !!}", appName: "{!! env('APP_NAME') !!}",
appDesc: "{!! env('APP_DESC') !!}",
appLang: "{{ Language::getSessionLanguage() }}", appLang: "{{ Language::getSessionLanguage() }}",
appDefaultLang: "{{ env('DEFAULT_LANGUAGE') }}", appDefaultLang: "{{ env('DEFAULT_LANGUAGE') }}",
apiToken: "{{ Auth::check() ? '?api_token=' . Auth::user()->api_token : '' }}", apiToken: "{{ Auth::check() ? '?api_token=' . Auth::user()->api_token : '' }}",

View file

@ -10,6 +10,7 @@ use Illuminate\Support\Facades\Route;
*/ */
Route::get('/blog-entries', 'App\Http\Controllers\ApiController@getBlogEntries'); Route::get('/blog-entries', 'App\Http\Controllers\ApiController@getBlogEntries');
Route::get('/meta/{path?}', 'App\Http\Controllers\ApiController@getMeta');
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------

View file

@ -1,4 +1,4 @@
@extends('templates.public', [ 'title' => 'Blog' ]) @extends('templates.public')
@section('content') @section('content')
<div class="blog-page-component"> <div class="blog-page-component">

View file

@ -1,4 +1,4 @@
@extends('templates.public', [ 'title' => 'Contact' ]) @extends('templates.public')
@section('content') @section('content')
<div class="contact-page-component"> <div class="contact-page-component">