lthn.io/app/Core/Seo/SeoMetadata.php
Claude 41a90cbff8
feat: lthn.io API serving live chain data
Fixed: basePath self→static binding, namespace detection, event wiring,
SQLite cache, file cache driver. All Mod Boot classes converted to
$listens pattern for lifecycle event discovery.

Working endpoints:
- /v1/explorer/info — live chain height, difficulty, aliases
- /v1/explorer/stats — formatted chain statistics
- /v1/names/directory — alias directory grouped by type
- /v1/names/available/{name} — name availability check
- /v1/names/lookup/{name} — name details

Co-Authored-By: Charon <charon@lethean.io>
2026-04-03 17:17:42 +01:00

516 lines
14 KiB
PHP

<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Seo;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
/**
* SEO metadata for any model.
*
* @property int $id
* @property string $seoable_type
* @property int $seoable_id
* @property string|null $title
* @property string|null $description
* @property string|null $canonical_url
* @property array<string, mixed>|null $og_data
* @property array<string, mixed>|null $twitter_data
* @property array<string, mixed>|null $schema_markup
* @property string|null $robots
* @property string|null $focus_keyword
* @property int|null $seo_score
* @property array<string>|null $seo_issues
* @property array<string>|null $seo_suggestions
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
*/
class SeoMetadata extends Model
{
protected $table = 'seo_metadata';
protected $fillable = [
'seoable_type',
'seoable_id',
'title',
'description',
'canonical_url',
'og_data',
'twitter_data',
'schema_markup',
'robots',
'focus_keyword',
'seo_score',
'seo_issues',
'seo_suggestions',
];
protected $casts = [
'og_data' => 'array',
'twitter_data' => 'array',
'seo_issues' => 'array',
'seo_suggestions' => 'array',
'seo_score' => 'integer',
// Note: schema_markup uses lazy loading via accessor - not cast here
];
/**
* Cached parsed schema markup (lazy loaded).
*
* @var array<string, mixed>|null
*/
protected ?array $parsedSchemaMarkup = null;
/**
* Whether schema markup has been loaded.
*/
protected bool $schemaMarkupLoaded = false;
/**
* Attributes that should be deferred (not loaded eagerly).
*
* @var array<string>
*/
protected array $deferredAttributes = ['schema_markup'];
/**
* Get the schema markup with lazy loading.
*
* The schema_markup is parsed only when first accessed, improving
* performance for queries that don't need the schema data.
*
* @return array<string, mixed>|null
*/
public function getSchemaMarkupAttribute(): ?array
{
if ($this->schemaMarkupLoaded) {
return $this->parsedSchemaMarkup;
}
$this->schemaMarkupLoaded = true;
$rawValue = $this->attributes['schema_markup'] ?? null;
if ($rawValue === null) {
$this->parsedSchemaMarkup = null;
return null;
}
// If it's already an array (from direct assignment), return it
if (is_array($rawValue)) {
$this->parsedSchemaMarkup = $rawValue;
return $this->parsedSchemaMarkup;
}
// Parse JSON string
$decoded = json_decode($rawValue, true);
$this->parsedSchemaMarkup = is_array($decoded) ? $decoded : null;
return $this->parsedSchemaMarkup;
}
/**
* Set the schema markup attribute.
*
* @param array<string, mixed>|string|null $value
*/
public function setSchemaMarkupAttribute(array|string|null $value): void
{
// Reset the lazy loading cache
$this->parsedSchemaMarkup = null;
$this->schemaMarkupLoaded = false;
if ($value === null) {
$this->attributes['schema_markup'] = null;
return;
}
if (is_array($value)) {
$this->attributes['schema_markup'] = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
return;
}
// Assume it's already a JSON string
$this->attributes['schema_markup'] = $value;
}
/**
* Check if schema markup is loaded without triggering lazy load.
*/
public function isSchemaMarkupLoaded(): bool
{
return $this->schemaMarkupLoaded;
}
/**
* Check if this model has schema markup (without fully parsing it).
*/
public function hasSchemaMarkup(): bool
{
return ! empty($this->attributes['schema_markup'] ?? null);
}
/**
* Get the parent seoable model.
*/
public function seoable(): MorphTo
{
return $this->morphTo();
}
/**
* Generate JSON-LD script tag.
*
* Uses JSON_HEX_TAG to prevent XSS via </script> in content.
*/
public function getJsonLdAttribute(): string
{
if (empty($this->schema_markup)) {
return '';
}
return '<script type="application/ld+json">'.
json_encode($this->schema_markup, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG).
'</script>';
}
/**
* Generate all meta tags as HTML.
*/
public function getMetaTagsAttribute(): string
{
$tags = [];
if ($this->title) {
$tags[] = '<title>'.e($this->title).'</title>';
}
if ($this->description) {
$tags[] = '<meta name="description" content="'.e($this->description).'">';
}
if ($this->canonical_url) {
$tags[] = '<link rel="canonical" href="'.e($this->canonical_url).'">';
}
if ($this->robots) {
$tags[] = '<meta name="robots" content="'.e($this->robots).'">';
}
// Open Graph tags
if (! empty($this->og_data)) {
foreach ($this->og_data as $property => $content) {
if ($content) {
$tags[] = '<meta property="og:'.$property.'" content="'.e($content).'">';
}
}
}
// Twitter Card tags
if (! empty($this->twitter_data)) {
foreach ($this->twitter_data as $name => $content) {
if ($content) {
$tags[] = '<meta name="twitter:'.$name.'" content="'.e($content).'">';
}
}
}
return implode("\n ", $tags);
}
/**
* Get SEO score colour for UI display.
*/
public function getScoreColorAttribute(): string
{
if ($this->seo_score === null) {
return 'zinc';
}
return match (true) {
$this->seo_score >= 80 => 'green',
$this->seo_score >= 50 => 'amber',
default => 'red',
};
}
/**
* Check if there are issues to address.
*/
public function hasIssues(): bool
{
return ! empty($this->seo_issues);
}
/**
* Get the count of issues.
*/
public function getIssueCountAttribute(): int
{
return count($this->seo_issues ?? []);
}
/**
* Validate Open Graph image dimensions.
*
* @param bool $fetchRemote Whether to fetch remote images
* @return array{valid: bool, errors: array<string>, warnings: array<string>, dimensions: array{width: int|null, height: int|null}}
*/
public function validateOgImage(bool $fetchRemote = true): array
{
$validator = new Validation\OgImageValidator;
return $validator->validateOgData($this->og_data);
}
/**
* Check if the OG image meets minimum requirements.
*/
public function hasValidOgImage(): bool
{
return $this->validateOgImage()['valid'];
}
/**
* Get OG image validation warnings.
*
* @return array<string>
*/
public function getOgImageWarnings(): array
{
return $this->validateOgImage()['warnings'];
}
/**
* Validate the canonical URL format.
*
* @return array{valid: bool, errors: array<string>, warnings: array<string>}
*/
public function validateCanonicalUrl(): array
{
if (empty($this->canonical_url)) {
return [
'valid' => true,
'errors' => [],
'warnings' => ['No canonical URL specified'],
];
}
$validator = new Validation\CanonicalUrlValidator;
return $validator->validateUrl($this->canonical_url);
}
/**
* Check if this canonical URL conflicts with other records.
*
* @return array{has_conflict: bool, count: int, records: Collection}
*/
public function checkCanonicalConflict(): array
{
if (empty($this->canonical_url)) {
return [
'has_conflict' => false,
'count' => 0,
'records' => collect(),
];
}
$validator = new Validation\CanonicalUrlValidator;
$result = $validator->checkUrl($this->canonical_url);
// Exclude self from conflict check
$otherRecords = $result['records']->filter(fn ($r) => $r->id !== $this->id);
return [
'has_conflict' => $otherRecords->isNotEmpty(),
'count' => $otherRecords->count(),
'records' => $otherRecords,
];
}
/**
* Check if the canonical URL is valid and has no conflicts.
*/
public function hasValidCanonicalUrl(): bool
{
$validation = $this->validateCanonicalUrl();
$conflict = $this->checkCanonicalConflict();
return $validation['valid'] && ! $conflict['has_conflict'];
}
/**
* Record the current score for trend tracking.
*
* @param bool $force Force recording even if within minimum interval
* @return Models\SeoScoreHistory|null The created record or null if skipped
*/
public function recordScore(bool $force = false): ?Models\SeoScoreHistory
{
$trend = app(Analytics\SeoScoreTrend::class);
return $trend->recordScore($this, $force);
}
/**
* Get score history for this metadata.
*
* @param int $limit Maximum records to return
* @return Collection<int, Models\SeoScoreHistory>
*/
public function getScoreHistory(int $limit = 100): Collection
{
$trend = app(Analytics\SeoScoreTrend::class);
return $trend->getHistory($this, $limit);
}
/**
* Get daily score trend for this metadata.
*
* @param int $days Days to look back
* @return Collection<int, object>
*/
public function getDailyScoreTrend(int $days = 30): Collection
{
$trend = app(Analytics\SeoScoreTrend::class);
return $trend->getDailyTrend($this, $days);
}
/**
* Get weekly score trend for this metadata.
*
* @param int $weeks Weeks to look back
* @return Collection<int, object>
*/
public function getWeeklyScoreTrend(int $weeks = 12): Collection
{
$trend = app(Analytics\SeoScoreTrend::class);
return $trend->getWeeklyTrend($this, $weeks);
}
/**
* Check if score has improved since last recording.
*
* @return bool|null True if improved, false if declined, null if no history
*/
public function hasScoreImproved(): ?bool
{
$latest = Models\SeoScoreHistory::latestForModel(
$this->seoable_type,
$this->seoable_id
);
if ($latest === null) {
return null;
}
return $this->seo_score > $latest->score;
}
/**
* Get the score change since last recording.
*
* @return int|null Change amount or null if no history
*/
public function getScoreChange(): ?int
{
$latest = Models\SeoScoreHistory::latestForModel(
$this->seoable_type,
$this->seoable_id
);
if ($latest === null) {
return null;
}
return $this->seo_score - $latest->score;
}
/**
* Validate the structured data (schema markup).
*
* @return array{valid: bool, errors: array, warnings: array, info: array, rich_results: array, types_found: array}
*/
public function validateStructuredData(): array
{
if (! $this->hasSchemaMarkup()) {
return [
'valid' => true,
'errors' => [],
'warnings' => [['code' => 'no_schema', 'message' => 'No structured data defined', 'path' => 'schema_markup', 'fix' => 'Add schema.org structured data to improve SEO.']],
'info' => [],
'rich_results' => [],
'types_found' => [],
];
}
$tester = new Validation\StructuredDataTester;
return $tester->test($this->schema_markup);
}
/**
* Get a detailed structured data report.
*
* @return array{summary: array, types: array, rich_results: array, errors: array, warnings: array, recommendations: array, score: int}
*/
public function getStructuredDataReport(): array
{
if (! $this->hasSchemaMarkup()) {
return [
'summary' => ['valid' => true, 'error_count' => 0, 'warning_count' => 1],
'types' => [],
'rich_results' => [],
'errors' => [],
'warnings' => [[
'code' => 'no_schema',
'message' => 'No structured data defined',
'path' => 'schema_markup',
'explanation' => 'Structured data helps search engines understand your content.',
'fix' => 'Add schema.org structured data to improve SEO and enable rich results.',
]],
'recommendations' => ['Add schema.org structured data to enable rich results in search.'],
'score' => 50,
];
}
$tester = new Validation\StructuredDataTester;
return $tester->generateReport($this->schema_markup);
}
/**
* Check if this page is eligible for rich results.
*
* @return array<string> List of eligible rich result features
*/
public function getRichResultsEligibility(): array
{
if (! $this->hasSchemaMarkup()) {
return [];
}
$tester = new Validation\StructuredDataTester;
return $tester->checkRichResultsEligibility($this->schema_markup);
}
}