Allow heading tags (h1-h6) in ContentSanitizer so news editor headings render

This commit is contained in:
2026-06-04 07:52:57 +02:00
parent 0b33a1b074
commit 15870ddb1f
191 changed files with 15453 additions and 1786 deletions

View File

@@ -75,6 +75,7 @@ final class NewsCoverImageService
'size_bytes' => strlen($masterEncoded),
'mobile_url' => NewsCoverImage::variantUrl($path, 'mobile'),
'desktop_url' => NewsCoverImage::variantUrl($path, 'desktop'),
'large_url' => NewsCoverImage::variantUrl($path, 'large'),
'srcset' => NewsCoverImage::srcset($path),
];
}

View File

@@ -26,6 +26,7 @@ use cPad\Plugins\News\Models\NewsArticle;
use cPad\Plugins\News\Models\NewsArticleRelation;
use cPad\Plugins\News\Models\NewsCategory;
use cPad\Plugins\News\Models\NewsTag;
use cPad\Plugins\News\Services\NewsArticleService;
final class NewsService
{
@@ -39,6 +40,7 @@ final class NewsService
public const RELATION_CHALLENGE = 'challenge';
public const RELATION_EVENT = 'event';
public const RELATION_USER = 'user';
public const RELATION_SOURCE = 'source';
public const RELATION_LABELS = [
self::RELATION_GROUP => 'Group',
@@ -49,10 +51,15 @@ final class NewsService
self::RELATION_CHALLENGE => 'Challenge',
self::RELATION_EVENT => 'Event',
self::RELATION_USER => 'Profile',
self::RELATION_SOURCE => 'Source',
];
private ?bool $artworkStatsViewsColumnExists = null;
public function __construct(private readonly NewsArticleService $articleService)
{
}
public function articleTypeOptions(): array
{
return \collect(NewsArticle::TYPE_LABELS)
@@ -224,12 +231,22 @@ final class NewsService
'og_description' => (string) ($article->og_description ?? ''),
'og_image' => (string) ($article->og_image ?? ''),
'relations' => $article->relatedEntities
->map(fn (NewsArticleRelation $relation): array => [
'entity_type' => (string) $relation->entity_type,
'entity_id' => (int) $relation->entity_id,
'context_label' => (string) ($relation->context_label ?? ''),
'preview' => $this->resolveEntityPreview((string) $relation->entity_type, (int) $relation->entity_id, $viewer),
])
->map(function (NewsArticleRelation $relation) use ($viewer): array {
$entityType = (string) $relation->entity_type;
$externalUrl = $entityType === self::RELATION_SOURCE
? (string) ($relation->external_url ?? '')
: '';
return [
'entity_type' => $entityType,
'entity_id' => $entityType === self::RELATION_SOURCE ? '' : (int) $relation->entity_id,
'external_url' => $externalUrl,
'context_label' => (string) ($relation->context_label ?? ''),
'preview' => $entityType === self::RELATION_SOURCE
? $this->resolveSourcePreview($externalUrl, (string) ($relation->context_label ?? ''))
: $this->resolveEntityPreview($entityType, (int) $relation->entity_id, $viewer),
];
})
->values()
->all(),
];
@@ -263,6 +280,8 @@ final class NewsService
'published_at' => $article->published_at ?? \now(),
])->save();
$this->articleService->createForumThread($article);
$this->invalidatePublicCache();
return $article->fresh(['author', 'category', 'tags', 'relatedEntities']);
@@ -312,6 +331,7 @@ final class NewsService
self::RELATION_CHALLENGE => $this->searchChallenges($query, $viewer),
self::RELATION_EVENT => $this->searchEvents($query, $viewer),
self::RELATION_USER => $this->searchUsers($query),
self::RELATION_SOURCE => [],
default => [],
};
}
@@ -321,7 +341,15 @@ final class NewsService
$article->loadMissing('relatedEntities');
return $article->relatedEntities
->map(fn (NewsArticleRelation $relation): ?array => $this->resolveEntityPreview((string) $relation->entity_type, (int) $relation->entity_id, $viewer, (string) ($relation->context_label ?? '')))
->map(function (NewsArticleRelation $relation) use ($viewer): ?array {
$entityType = (string) $relation->entity_type;
if ($entityType === self::RELATION_SOURCE) {
return $this->resolveSourcePreview((string) ($relation->external_url ?? ''), (string) ($relation->context_label ?? ''));
}
return $this->resolveEntityPreview($entityType, (int) $relation->entity_id, $viewer, (string) ($relation->context_label ?? ''));
})
->filter()
->values()
->all();
@@ -380,6 +408,7 @@ final class NewsService
public function invalidatePublicCache(): void
{
Cache::forever(self::PUBLIC_CACHE_VERSION_KEY, $this->publicCacheVersion() + 1);
Cache::forget('news.rss.feed');
}
public function syncRelations(NewsArticle $article, array $relations): void
@@ -387,20 +416,32 @@ final class NewsService
$normalized = \collect($relations)
->map(function (array $relation): ?array {
$entityType = trim(Str::lower((string) ($relation['entity_type'] ?? '')));
$entityId = (int) ($relation['entity_id'] ?? 0);
$externalUrl = $entityType === self::RELATION_SOURCE
? $this->normalizeExternalRelationUrl($relation['external_url'] ?? $relation['entity_id'] ?? null)
: null;
$entityId = $entityType === self::RELATION_SOURCE ? null : (int) ($relation['entity_id'] ?? 0);
if (! array_key_exists($entityType, self::RELATION_LABELS) || $entityId < 1) {
if (! array_key_exists($entityType, self::RELATION_LABELS)) {
return null;
}
if ($entityType === self::RELATION_SOURCE && $externalUrl === null) {
return null;
}
if ($entityType !== self::RELATION_SOURCE && $entityId < 1) {
return null;
}
return [
'entity_type' => $entityType,
'entity_id' => $entityId,
'external_url' => $externalUrl,
'context_label' => Str::limit(trim((string) ($relation['context_label'] ?? '')), 120, ''),
];
})
->filter()
->unique(fn (array $relation): string => $relation['entity_type'] . ':' . $relation['entity_id'])
->unique(fn (array $relation): string => $relation['entity_type'] . ':' . ($relation['entity_type'] === self::RELATION_SOURCE ? ($relation['external_url'] ?? '') : $relation['entity_id']))
->values();
$article->relatedEntities()->delete();
@@ -409,6 +450,7 @@ final class NewsService
$article->relatedEntities()->create([
'entity_type' => $relation['entity_type'],
'entity_id' => $relation['entity_id'],
'external_url' => $relation['external_url'],
'context_label' => $relation['context_label'] !== '' ? $relation['context_label'] : null,
'sort_order' => $index,
]);
@@ -808,6 +850,34 @@ final class NewsService
};
}
private function resolveSourcePreview(string $externalUrl, string $contextLabel): ?array
{
$normalizedUrl = $this->normalizeExternalRelationUrl($externalUrl);
if ($normalizedUrl === null) {
return null;
}
$host = \parse_url($normalizedUrl, PHP_URL_HOST);
$host = \is_string($host) ? preg_replace('/^www\./i', '', $host) : null;
return [
'id' => $normalizedUrl,
'entity_type' => self::RELATION_SOURCE,
'entity_label' => self::RELATION_LABELS[self::RELATION_SOURCE],
'title' => $host ?: 'External source',
'subtitle' => 'Reference link',
'description' => Str::limit($normalizedUrl, 140),
'url' => $normalizedUrl,
'image' => null,
'avatar' => null,
'context_label' => $contextLabel !== '' ? $contextLabel : 'Source link',
'meta' => array_values(array_filter([
$host,
])),
];
}
private function resolveGroupPreview(int $entityId, ?User $viewer, string $contextLabel): ?array
{
$group = Group::query()->with('owner')->find($entityId);
@@ -1017,4 +1087,23 @@ final class NewsService
'meta' => [],
];
}
private function normalizeExternalRelationUrl(mixed $value): ?string
{
$url = trim((string) ($value ?? ''));
if ($url === '') {
return null;
}
if (preg_match('/^\[[^\]]+\]\((https?:\/\/[^)]+)\)$/i', $url, $matches) === 1) {
$url = trim((string) ($matches[1] ?? ''));
}
if ($url === '') {
return null;
}
return Str::limit($url, 2048, '');
}
}