169
common/Core/Prerender/Actions/ReplacePlaceholders.php
Executable file
169
common/Core/Prerender/Actions/ReplacePlaceholders.php
Executable file
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Core\Prerender\Actions;
|
||||
|
||||
use Common\Core\Contracts\AppUrlGenerator;
|
||||
use Illuminate\Pagination\AbstractPaginator;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ReplacePlaceholders
|
||||
{
|
||||
private array $allData;
|
||||
|
||||
public function __construct(protected AppUrlGenerator $urls)
|
||||
{
|
||||
}
|
||||
|
||||
public function execute(array|string $config, array $data): array|string
|
||||
{
|
||||
$this->allData = $data;
|
||||
return $this->replace($config);
|
||||
}
|
||||
|
||||
private function replace(array|string $config): array|string
|
||||
{
|
||||
if (is_array($config)) {
|
||||
if (array_key_exists('_ifNotNull', $config)) {
|
||||
if (is_null(Arr::get($this->allData, $config['_ifNotNull']))) {
|
||||
return [];
|
||||
}
|
||||
unset($config['_ifExists']);
|
||||
}
|
||||
|
||||
if (Arr::get($config, '_type') === 'loop') {
|
||||
return $this->replaceLoop($config);
|
||||
} else {
|
||||
return array_map(function ($item) {
|
||||
return $this->replace($item);
|
||||
}, $config);
|
||||
}
|
||||
} else {
|
||||
return $this->replaceString($config, $this->allData);
|
||||
}
|
||||
}
|
||||
|
||||
private function replaceLoop(array $config): array
|
||||
{
|
||||
$dataSelector = strtolower($config['dataSelector']);
|
||||
$loopData = Arr::get($this->allData, $dataSelector);
|
||||
|
||||
// won't be able to access paginator data via dot notation
|
||||
// selector (items.data), need to extract it manually
|
||||
if ($loopData instanceof AbstractPaginator) {
|
||||
$loopData = $loopData->items();
|
||||
}
|
||||
$loopData = collect($loopData);
|
||||
|
||||
// apply filter (if provided), filter will specify which array
|
||||
// prop of loop item should match what value. For example:
|
||||
// ['key' => 'pivot.department', 'value' => 'cast' will get
|
||||
// only cast from movie credits array instead of full credits
|
||||
if ($filter = Arr::get($config, 'filter')) {
|
||||
$loopData = $loopData->filter(function ($loopItem) use ($filter) {
|
||||
return Arr::get($loopItem, $filter['key']) === $filter['value'];
|
||||
});
|
||||
}
|
||||
|
||||
if ($limit = Arr::get($config, 'limit')) {
|
||||
$loopData = $loopData->slice(0, $limit);
|
||||
}
|
||||
|
||||
// if _type is "nested" we only need to return the first item so instead
|
||||
// of nested [['name' => 'foo'], ['name' => 'bar']] only ['name' => 'foo']
|
||||
if ($returnFirstOnly = Arr::get($config, 'returnFirstOnly')) {
|
||||
$loopData->slice(0, 1);
|
||||
}
|
||||
|
||||
$generated = collect($loopData)->map(function ($loopItem) use (
|
||||
$config,
|
||||
) {
|
||||
// make sure template can access data via dot notation (TAG.NAME)
|
||||
// so instead of passing just tag, pass ['tag' => $tag]
|
||||
$model = is_array($loopItem)
|
||||
? modelTypeToNamespace($loopItem['model_type'])
|
||||
: $loopItem;
|
||||
$name = strtolower(class_basename($model));
|
||||
return $this->replaceString($config['template'], [
|
||||
$name => $loopItem,
|
||||
]);
|
||||
});
|
||||
|
||||
return $returnFirstOnly
|
||||
? $generated->first()
|
||||
: $generated->values()->toArray();
|
||||
}
|
||||
|
||||
private function replaceString(mixed $template, array $originalData): mixed
|
||||
{
|
||||
$data = [];
|
||||
foreach ($originalData as $key => $value) {
|
||||
$data[Str::lower($key)] = $value;
|
||||
}
|
||||
|
||||
return preg_replace_callback(
|
||||
'/{{([\w\.\-\?\:]+?)}}/',
|
||||
function ($matches) use ($data) {
|
||||
if (!isset($matches[1])) {
|
||||
return $matches[0];
|
||||
}
|
||||
|
||||
$placeholder = $matches[1];
|
||||
|
||||
// replace site name
|
||||
if ($placeholder === 'site_name') {
|
||||
return config('app.name');
|
||||
}
|
||||
|
||||
// replace base url
|
||||
if ($placeholder === 'url.base') {
|
||||
return url('');
|
||||
}
|
||||
|
||||
// replace by url generator url
|
||||
if (Str::startsWith($placeholder, 'url.')) {
|
||||
// "url.movie" => "movie"
|
||||
$resource = str_replace('url.', '', $placeholder);
|
||||
// "new_releases" => "newReleases"
|
||||
$method = Str::camel($resource);
|
||||
return $this->urls->$method(
|
||||
Arr::get($data, $resource) ?: $data, $data,
|
||||
);
|
||||
}
|
||||
|
||||
// replace placeholder with actual value.
|
||||
// supports dot notation: 'artist.bio.text' as well as ?:
|
||||
$replacement = $this->findUsingDotNotation($data, $placeholder);
|
||||
|
||||
// prefix relative image urls with base site url
|
||||
if ($replacement && Str::startsWith($replacement, 'storage/')) {
|
||||
$replacement = config('app.url') . "/$replacement";
|
||||
url();
|
||||
}
|
||||
|
||||
// return null if we could not replace placeholder
|
||||
if (!$replacement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Str::limit(
|
||||
strip_tags(
|
||||
$this->replaceString($replacement, $data),
|
||||
'<br>',
|
||||
),
|
||||
400,
|
||||
);
|
||||
},
|
||||
$template,
|
||||
);
|
||||
}
|
||||
|
||||
private function findUsingDotNotation(array $data, string $item)
|
||||
{
|
||||
foreach (explode('?:', $item) as $itemVariant) {
|
||||
if ($value = Arr::get($data, $itemVariant)) {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
45
common/Core/Prerender/BaseUrlGenerator.php
Executable file
45
common/Core/Prerender/BaseUrlGenerator.php
Executable file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Core\Prerender;
|
||||
|
||||
use Common\Core\Contracts\AppUrlGenerator;
|
||||
use Common\Pages\CustomPage;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class BaseUrlGenerator implements AppUrlGenerator
|
||||
{
|
||||
const SEPARATOR = '-';
|
||||
|
||||
public function customPage(array|CustomPage $page): string
|
||||
{
|
||||
if (isset($page['page'])) {
|
||||
$originalSlug = $page['page']['slug'];
|
||||
} else {
|
||||
$originalSlug = $page['slug'];
|
||||
}
|
||||
|
||||
$slug = slugify($originalSlug);
|
||||
return url("pages/$slug");
|
||||
}
|
||||
|
||||
public function home(): string
|
||||
{
|
||||
return url('');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Model|array $model
|
||||
*/
|
||||
public function generate($model): string
|
||||
{
|
||||
$method =
|
||||
$model instanceof Model ? $model::MODEL_TYPE : $model['modelType'];
|
||||
return $this->$method($model);
|
||||
}
|
||||
|
||||
public function __call(string $name, array $arguments): string
|
||||
{
|
||||
return url(Str::kebab($name));
|
||||
}
|
||||
}
|
||||
70
common/Core/Prerender/HandlesSeo.php
Executable file
70
common/Core/Prerender/HandlesSeo.php
Executable file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Core\Prerender;
|
||||
|
||||
use Arr;
|
||||
use Common\Core\AppUrl;
|
||||
use Illuminate\Support\Collection;
|
||||
use Request;
|
||||
use Str;
|
||||
|
||||
trait HandlesSeo
|
||||
{
|
||||
protected function handleSeo(
|
||||
array|Collection &$data = [],
|
||||
array $options = []
|
||||
) {
|
||||
if (Request::method() === 'GET') {
|
||||
$data['seo'] = $this->getMetaTags($data, $options) ?: null;
|
||||
}
|
||||
|
||||
if (defined('SHOULD_PRERENDER')) {
|
||||
$viewName =
|
||||
Arr::get($options, 'prerender.view') ?:
|
||||
$this->namespaceFromRouteAction();
|
||||
$viewPath = "prerender.$viewName";
|
||||
$view = null;
|
||||
|
||||
// load view from app views folder or fall back to common views otherwise
|
||||
if (view()->exists($viewPath)) {
|
||||
$view = view($viewPath);
|
||||
} else {
|
||||
$view = view("common::$viewPath");
|
||||
}
|
||||
|
||||
return response(
|
||||
$view->with([
|
||||
'meta' => $data['seo'],
|
||||
'htmlBaseUri' => app(AppUrl::class)->htmlBaseUri,
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
protected function getMetaTags($data = [], $options = []): ?MetaTags
|
||||
{
|
||||
$namespace = Arr::get(
|
||||
$options,
|
||||
'prerender.config',
|
||||
$this->namespaceFromRouteAction(),
|
||||
);
|
||||
|
||||
if ($seoConfig = config("seo.$namespace")) {
|
||||
$dataForSeo = Arr::get($options, 'prerender.dataForSeo') ?: $data;
|
||||
return new MetaTags($seoConfig, $dataForSeo, $namespace);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function namespaceFromRouteAction(): string
|
||||
{
|
||||
// 'App/Http/Controllers/ArtistController@show'
|
||||
$uses = request()->route()->action['uses'];
|
||||
|
||||
// get resource name and verb from route action
|
||||
preg_match('/\\\(\w+?)Controller@(\w+)$/', $uses, $matches);
|
||||
$resource = Str::kebab($matches[1]);
|
||||
$verb = Str::kebab($matches[2]);
|
||||
return "$resource.$verb";
|
||||
}
|
||||
}
|
||||
274
common/Core/Prerender/MetaTags.php
Executable file
274
common/Core/Prerender/MetaTags.php
Executable file
@@ -0,0 +1,274 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Core\Prerender;
|
||||
|
||||
use Arr;
|
||||
use Common\Core\Contracts\AppUrlGenerator;
|
||||
use Common\Core\Prerender\Actions\ReplacePlaceholders;
|
||||
use Common\Settings\Settings;
|
||||
use Illuminate\Contracts\Support\Arrayable;
|
||||
use Str;
|
||||
|
||||
class MetaTags implements Arrayable
|
||||
{
|
||||
/**
|
||||
* Tag types that can be edited by the user.
|
||||
*/
|
||||
const EDITABLE_TAGS = ['og:title', 'og:description', 'keywords'];
|
||||
|
||||
/**
|
||||
* Data for replacing meta tag config placeholders.
|
||||
*/
|
||||
protected array $data = [];
|
||||
|
||||
/**
|
||||
* Meta tag config before generation.
|
||||
*/
|
||||
protected array $tags = [];
|
||||
|
||||
/**
|
||||
* Final tags for appending to site head.
|
||||
*/
|
||||
protected array $generatedTags = [];
|
||||
|
||||
/**
|
||||
* Namespace for current tag config. "artist.show".
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected string $namespace;
|
||||
|
||||
public AppUrlGenerator $urls;
|
||||
|
||||
private Settings $settings;
|
||||
|
||||
public function __construct($tags, $data, $namespace)
|
||||
{
|
||||
$this->namespace = $namespace;
|
||||
$this->data = $data;
|
||||
$tags = $this->overrideTagsWithUserValues($tags);
|
||||
$this->tags = array_merge($tags, config('seo.common'));
|
||||
$this->urls = app(AppUrlGenerator::class);
|
||||
$this->settings = app(Settings::class);
|
||||
$this->generatedTags = $this->generateTags();
|
||||
}
|
||||
|
||||
public function toArray()
|
||||
{
|
||||
// remove all tags to which placeholders could not be replaced with actual content
|
||||
$tags = collect($this->getAll())->map(function ($tag) {
|
||||
// ld+json tags will contain child arrays
|
||||
$strings = array_filter($tag, function ($value) {
|
||||
return !is_array($value);
|
||||
});
|
||||
$content = implode($strings);
|
||||
$shouldRemove =
|
||||
Str::contains($content, '{{') && Str::contains($content, '}}');
|
||||
|
||||
// if could not replace title placeholder, return app name as title instead
|
||||
if ($shouldRemove && $tag['nodeName'] === 'title') {
|
||||
$tag['_text'] = config('app.name');
|
||||
$shouldRemove = false;
|
||||
}
|
||||
if ($shouldRemove && Arr::get($tag, 'property') === 'og:title') {
|
||||
$tag['content'] = config('app.name');
|
||||
$shouldRemove = false;
|
||||
}
|
||||
|
||||
return $shouldRemove ? null : $tag;
|
||||
});
|
||||
|
||||
return $tags
|
||||
->filter()
|
||||
->values()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function getTitle()
|
||||
{
|
||||
return $this->get('og:title');
|
||||
}
|
||||
|
||||
public function getDescription()
|
||||
{
|
||||
return $this->get('og:description');
|
||||
}
|
||||
|
||||
public function getImage()
|
||||
{
|
||||
return url($this->get('og:image'));
|
||||
}
|
||||
|
||||
public function getMenu(string $position): array
|
||||
{
|
||||
$default = ['items' => []];
|
||||
return Arr::first(
|
||||
settings('menus'),
|
||||
fn($menu) => in_array($position, $menu['positions']),
|
||||
$default,
|
||||
);
|
||||
}
|
||||
|
||||
public function get($value, $prop = 'property')
|
||||
{
|
||||
$tag = Arr::first(
|
||||
$this->generatedTags,
|
||||
function ($tag) use ($prop, $value) {
|
||||
return Arr::get($tag, $prop) === $value;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return Arr::get($tag, 'content');
|
||||
}
|
||||
|
||||
public function getData($selector = null, $default = null)
|
||||
{
|
||||
return Arr::get($this->data, $selector) ?? $default;
|
||||
}
|
||||
|
||||
public function getAll(): array
|
||||
{
|
||||
return $this->generatedTags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert specified tag config into a string.
|
||||
*
|
||||
* @param array $tag
|
||||
* @return string
|
||||
*/
|
||||
public function tagToString($tag)
|
||||
{
|
||||
$string = '';
|
||||
|
||||
foreach (Arr::except($tag, 'nodeName') as $key => $value) {
|
||||
$value = is_array($value) ? implode(',', $value) : $value;
|
||||
$string .= "$key=\"$value\" ";
|
||||
}
|
||||
|
||||
return trim($string);
|
||||
}
|
||||
|
||||
private function generateTags(): array
|
||||
{
|
||||
$tags = $this->tags;
|
||||
|
||||
$tags = array_map(function ($tag) {
|
||||
return $this->replacePlaceholders($tag);
|
||||
}, $tags);
|
||||
$tags = $this->removeEmptyValues($tags);
|
||||
$tags = $this->duplicateTags($tags);
|
||||
$tags = $this->escapeValues($tags);
|
||||
|
||||
$tags = array_map(function ($tag) {
|
||||
// set nodeName to <meta> tag, if not already specified
|
||||
if (!array_key_exists('nodeName', $tag)) {
|
||||
$tag['nodeName'] = 'meta';
|
||||
}
|
||||
return $tag;
|
||||
}, $tags);
|
||||
|
||||
return $tags;
|
||||
}
|
||||
|
||||
function removeEmptyValues(array &$array): array
|
||||
{
|
||||
foreach ($array as $key => &$value) {
|
||||
if (is_array($value)) {
|
||||
$value = $this->removeEmptyValues($value);
|
||||
}
|
||||
if (empty($value)) {
|
||||
unset($array[$key]);
|
||||
}
|
||||
}
|
||||
return $array;
|
||||
}
|
||||
|
||||
private function replacePlaceholders(array $tag): array
|
||||
{
|
||||
$replacer = app(ReplacePlaceholders::class);
|
||||
|
||||
// if tag does not have "content" or "_text" prop, we can continue
|
||||
if (array_key_exists('content', $tag)) {
|
||||
$tag['content'] = $replacer->execute($tag['content'], $this->data);
|
||||
} elseif (array_key_exists('_text', $tag)) {
|
||||
$tag['_text'] = $replacer->execute($tag['_text'], $this->data);
|
||||
}
|
||||
|
||||
return $tag;
|
||||
}
|
||||
|
||||
private function escapeValues(array $tags): array
|
||||
{
|
||||
// only escape meta tags that have "content" property
|
||||
foreach ($tags as $key => $tag) {
|
||||
if (array_key_exists('content', $tag)) {
|
||||
$tags[$key]['content'] = str_replace(
|
||||
'"',
|
||||
'"',
|
||||
$tag['content'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create duplicate tags from generated tags.
|
||||
* (for example: canonical link from og:url)
|
||||
*
|
||||
* @param array $tags
|
||||
* @return array
|
||||
*/
|
||||
private function duplicateTags($tags)
|
||||
{
|
||||
foreach ($tags as $tag) {
|
||||
if (!isset($tag['content'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Arr::get($tag, 'property') === 'og:url') {
|
||||
$tags[] = [
|
||||
'nodeName' => 'link',
|
||||
'rel' => 'canonical',
|
||||
'href' => $tag['content'],
|
||||
];
|
||||
}
|
||||
|
||||
if (Arr::get($tag, 'property') === 'og:title') {
|
||||
$tags[] = [
|
||||
'nodeName' => 'title',
|
||||
'_text' => ucfirst($tag['content']),
|
||||
];
|
||||
}
|
||||
|
||||
if (Arr::get($tag, 'property') === 'og:description') {
|
||||
$tags[] = [
|
||||
'name' => 'description',
|
||||
'content' => $tag['content'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $tags;
|
||||
}
|
||||
|
||||
private function overrideTagsWithUserValues(array $metaTags): array
|
||||
{
|
||||
$overrides = app(Settings::class)->all();
|
||||
foreach ($metaTags as $key => $tagConfig) {
|
||||
$property = Arr::get($tagConfig, 'property');
|
||||
$settingKey = "seo.{$this->namespace}.{$property}";
|
||||
if (
|
||||
in_array($property, self::EDITABLE_TAGS) &&
|
||||
array_key_exists($settingKey, $overrides)
|
||||
) {
|
||||
$metaTags[$key]['content'] = $overrides[$settingKey];
|
||||
}
|
||||
}
|
||||
|
||||
return $metaTags;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user