first commit
Some checks failed
Build / run (push) Has been cancelled

This commit is contained in:
maher
2025-10-29 11:42:25 +01:00
commit 703f50a09d
4595 changed files with 385164 additions and 0 deletions

View 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;
}
}
}
}

View 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));
}
}

View 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";
}
}

View 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(
'"',
'&quot;',
$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;
}
}