Initial baseline: LaraBlog core with plugin commerce surface.
Captures the current working tree after theme slots, ArticleAccess, and the payment / paid-content plugins so subsequent work has a reviewable git history.
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Blog;
|
||||
|
||||
final class AccessDecision
|
||||
{
|
||||
public const ALLOW = 'allow';
|
||||
|
||||
public const NEED_LOGIN = 'need_login';
|
||||
|
||||
public const NEED_PURCHASE = 'need_purchase';
|
||||
|
||||
public const NEED_PASSWORD = 'need_password';
|
||||
|
||||
/** @var array<string, int> */
|
||||
private const SEVERITY = [
|
||||
self::ALLOW => 0,
|
||||
self::NEED_LOGIN => 1,
|
||||
self::NEED_PURCHASE => 2,
|
||||
self::NEED_PASSWORD => 3,
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
public string $status,
|
||||
public ?string $teaserHtml = null,
|
||||
public ?string $checkoutUrl = null,
|
||||
public ?string $message = null,
|
||||
) {}
|
||||
|
||||
public static function allow(): self
|
||||
{
|
||||
return new self(self::ALLOW);
|
||||
}
|
||||
|
||||
public static function needPassword(?string $message = null): self
|
||||
{
|
||||
return new self(self::NEED_PASSWORD, message: $message);
|
||||
}
|
||||
|
||||
public static function needPurchase(?string $teaserHtml = null, ?string $checkoutUrl = null, ?string $message = null): self
|
||||
{
|
||||
return new self(self::NEED_PURCHASE, $teaserHtml, $checkoutUrl, $message);
|
||||
}
|
||||
|
||||
public function isAllow(): bool
|
||||
{
|
||||
return $this->status === self::ALLOW;
|
||||
}
|
||||
|
||||
public function severity(): int
|
||||
{
|
||||
return self::SEVERITY[$this->status] ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Only allow tightening (higher severity). Metadata from the stricter side wins when status changes;
|
||||
* otherwise fill empty meta from $other.
|
||||
*/
|
||||
public function tightenWith(self $other): self
|
||||
{
|
||||
if ($other->severity() < $this->severity()) {
|
||||
return new self(
|
||||
$this->status,
|
||||
$this->teaserHtml ?? $other->teaserHtml,
|
||||
$this->checkoutUrl ?? $other->checkoutUrl,
|
||||
$this->message ?? $other->message,
|
||||
);
|
||||
}
|
||||
|
||||
if ($other->severity() > $this->severity()) {
|
||||
return new self(
|
||||
$other->status,
|
||||
$other->teaserHtml ?? $this->teaserHtml,
|
||||
$other->checkoutUrl ?? $this->checkoutUrl,
|
||||
$other->message ?? $this->message,
|
||||
);
|
||||
}
|
||||
|
||||
return new self(
|
||||
$this->status,
|
||||
$other->teaserHtml ?? $this->teaserHtml,
|
||||
$other->checkoutUrl ?? $this->checkoutUrl,
|
||||
$other->message ?? $this->message,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{status: string, teaser_html: ?string, checkout_url: ?string, message: ?string}
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'status' => $this->status,
|
||||
'teaser_html' => $this->teaserHtml,
|
||||
'checkout_url' => $this->checkoutUrl,
|
||||
'message' => $this->message,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Blog;
|
||||
|
||||
use App\Domain\Plugin\Hook;
|
||||
use App\Models\Article;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
|
||||
class ArticleAccess
|
||||
{
|
||||
/**
|
||||
* Pure decision — no HTTP side effects.
|
||||
*
|
||||
* @param list<int>|null $unlockedArticleIds session unlock list; null = read from session
|
||||
*/
|
||||
public function resolve(Article $article, ?User $user = null, ?array $unlockedArticleIds = null): AccessDecision
|
||||
{
|
||||
if ($user !== null && $this->isPrivileged($article, $user)) {
|
||||
return AccessDecision::allow();
|
||||
}
|
||||
|
||||
if (filled($article->read_password)) {
|
||||
$unlocked = $unlockedArticleIds ?? (array) Session::get('unlocked_articles', []);
|
||||
if (! in_array($article->id, $unlocked, true)) {
|
||||
return AccessDecision::needPassword();
|
||||
}
|
||||
}
|
||||
|
||||
$decision = AccessDecision::allow();
|
||||
$context = [
|
||||
'article' => $article,
|
||||
'user' => $user,
|
||||
];
|
||||
|
||||
// Fold every listener result instead of letting the last one win, so a
|
||||
// plugin can only tighten access and a broken listener cannot re-open it.
|
||||
foreach (Hook::listeners('article.access') as $listener) {
|
||||
$result = $listener($decision, $context);
|
||||
|
||||
if ($result instanceof AccessDecision) {
|
||||
$decision = $decision->tightenWith($result);
|
||||
}
|
||||
}
|
||||
|
||||
return $decision;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML safe to expose for the given decision (full body or teaser/description).
|
||||
*/
|
||||
public function publicHtml(Article $article, AccessDecision $decision): string
|
||||
{
|
||||
if ($decision->isAllow()) {
|
||||
return $article->renderedHtml();
|
||||
}
|
||||
|
||||
if (filled($decision->teaserHtml)) {
|
||||
return $decision->teaserHtml;
|
||||
}
|
||||
|
||||
return e((string) ($article->description ?: ''));
|
||||
}
|
||||
|
||||
protected function isPrivileged(Article $article, User $user): bool
|
||||
{
|
||||
if ((int) $article->user_id === (int) $user->id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $user->hasRole('admin');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Blog;
|
||||
|
||||
use App\Models\Article;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ArticleExcerpt
|
||||
{
|
||||
public function __construct(
|
||||
protected ArticleAccess $access,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Safe list/card excerpt — never render full body for restricted articles.
|
||||
*/
|
||||
public function forList(Article $article, int $limit = 160): string
|
||||
{
|
||||
if (filled($article->description)) {
|
||||
return Str::limit((string) $article->description, $limit);
|
||||
}
|
||||
|
||||
$decision = $this->access->resolve($article, null, []);
|
||||
if (! $decision->isAllow()) {
|
||||
$html = $this->access->publicHtml($article, $decision);
|
||||
|
||||
return Str::limit(trim(html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8')), $limit);
|
||||
}
|
||||
|
||||
return Str::limit(trim(strip_tags($article->renderedHtml())), $limit);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Blog;
|
||||
|
||||
use App\Models\Attachment;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* Attachment embeds:
|
||||
* - Legacy HTML: [attach=123] or src/href="[attach=123]"
|
||||
* - Markdown-safe:  or [label](attach:123)
|
||||
*
|
||||
* DB stores references only; URLs are resolved at render/preview time.
|
||||
*/
|
||||
class AttachEmbed
|
||||
{
|
||||
public const LEGACY_PATTERN = '/\[attach=(\d+)\]/i';
|
||||
|
||||
public const MD_PROTOCOL = 'attach:';
|
||||
|
||||
public function legacyToMarkdownReference(string $content, ?int $articleId = null): string
|
||||
{
|
||||
return preg_replace_callback(self::LEGACY_PATTERN, function (array $matches) use ($articleId): string {
|
||||
$id = (int) $matches[1];
|
||||
$attachment = $this->findAttachment($id, $articleId);
|
||||
|
||||
$name = $attachment?->filename ?: "attachment-{$id}";
|
||||
$alt = pathinfo($name, PATHINFO_FILENAME) ?: $name;
|
||||
|
||||
if ($attachment && $this->isImage($attachment)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return '['.$this->escapeMdLabel($name).']('.self::MD_PROTOCOL.$id.')';
|
||||
}, $content) ?? $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Protect sablog attach tokens before HTML→Markdown conversion.
|
||||
* Tokens avoid underscores so html-to-markdown won't escape them.
|
||||
*
|
||||
* @return array{0: string, 1: array<string, array{id:int, kind:string, role:string}>}
|
||||
*/
|
||||
public function protectLegacyTokensForHtmlConversion(string $html, ?int $articleId = null): array
|
||||
{
|
||||
$map = [];
|
||||
|
||||
// Attribute values become markdown link/image destinations → restore as attach:ID only.
|
||||
$html = preg_replace_callback(
|
||||
'/\b(src|href)=([\'"])\[attach=(\d+)\]\2/i',
|
||||
function (array $matches) use (&$map, $articleId): string {
|
||||
$id = (int) $matches[3];
|
||||
$role = 'dest';
|
||||
$kind = strtolower($matches[1]) === 'src' ? 'image' : 'file';
|
||||
$token = 'LBATTACHDEST'.$id.'X';
|
||||
$map[$token] = ['id' => $id, 'kind' => $kind, 'role' => $role, 'article_id' => $articleId];
|
||||
|
||||
return $matches[1].'='.$matches[2].$token.$matches[2];
|
||||
},
|
||||
$html
|
||||
) ?? $html;
|
||||
|
||||
// Bare tokens become full markdown embeds after conversion.
|
||||
$html = preg_replace_callback(self::LEGACY_PATTERN, function (array $matches) use (&$map, $articleId): string {
|
||||
$id = (int) $matches[1];
|
||||
$attachment = $this->findAttachment($id, $articleId);
|
||||
$kind = ($attachment && $this->isImage($attachment)) ? 'image' : 'file';
|
||||
$token = 'LBATTACHBARE'.$id.'X';
|
||||
$map[$token] = ['id' => $id, 'kind' => $kind, 'role' => 'bare', 'article_id' => $articleId];
|
||||
|
||||
return $token;
|
||||
}, $html) ?? $html;
|
||||
|
||||
return [$html, $map];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array{id:int, kind:string, role:string}> $map
|
||||
*/
|
||||
public function restoreProtectedTokensToMarkdown(string $markdown, array $map): string
|
||||
{
|
||||
foreach ($map as $token => $meta) {
|
||||
$id = (int) $meta['id'];
|
||||
|
||||
if (($meta['role'] ?? '') === 'dest') {
|
||||
$markdown = str_replace($token, self::MD_PROTOCOL.$id, $markdown);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$attachment = $this->findAttachment($id, $meta['article_id'] ?? null);
|
||||
$name = $attachment?->filename ?: "attachment-{$id}";
|
||||
$alt = pathinfo($name, PATHINFO_FILENAME) ?: $name;
|
||||
|
||||
$replacement = ($meta['kind'] ?? 'file') === 'image'
|
||||
? ''
|
||||
: '['.$this->escapeMdLabel($name).']('.self::MD_PROTOCOL.$id.')';
|
||||
|
||||
$markdown = str_replace($token, $replacement, $markdown);
|
||||
}
|
||||
|
||||
return $markdown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render-time only: turn attach:123 into /attachment.php?id=123 for CommonMark.
|
||||
*/
|
||||
public function expandMarkdownAttachProtocol(string $markdown): string
|
||||
{
|
||||
return preg_replace_callback(
|
||||
'/\]\(attach:(\d+)\)/i',
|
||||
fn (array $matches): string => ']('.$this->publicEntryUrl((int) $matches[1]).')',
|
||||
$markdown
|
||||
) ?? $markdown;
|
||||
}
|
||||
|
||||
public function hydrateHtml(string $html, ?int $articleId = null): string
|
||||
{
|
||||
// 1) Attribute form: src/href="[attach=N]" (must run before bare token replace)
|
||||
$html = preg_replace_callback(
|
||||
'/\b(src|href)=([\'"])\[attach=(\d+)\]\2/i',
|
||||
function (array $matches): string {
|
||||
$attr = strtolower($matches[1]);
|
||||
$quote = $matches[2];
|
||||
$id = (int) $matches[3];
|
||||
|
||||
return $attr.'='.$quote.$this->publicEntryUrl($id).$quote;
|
||||
},
|
||||
$html
|
||||
) ?? $html;
|
||||
|
||||
// 2) Safety net for attach: protocol left in HTML attributes
|
||||
$html = preg_replace_callback(
|
||||
'/\b(href|src)=([\'"])attach:(\d+)\2/i',
|
||||
function (array $matches): string {
|
||||
$attr = strtolower($matches[1]);
|
||||
$quote = $matches[2];
|
||||
$id = (int) $matches[3];
|
||||
|
||||
return $attr.'='.$quote.$this->publicEntryUrl($id).$quote;
|
||||
},
|
||||
$html
|
||||
) ?? $html;
|
||||
|
||||
// 3) Bare legacy [attach=id] tokens
|
||||
$html = preg_replace_callback(self::LEGACY_PATTERN, function (array $matches) use ($articleId): string {
|
||||
$id = (int) $matches[1];
|
||||
$attachment = $this->findAttachment($id, $articleId);
|
||||
$url = $this->publicEntryUrl($id);
|
||||
|
||||
if ($attachment && $this->isImage($attachment)) {
|
||||
$alt = e(pathinfo($attachment->filename, PATHINFO_FILENAME) ?: $attachment->filename);
|
||||
|
||||
return '<img src="'.e($url).'" alt="'.$alt.'" />';
|
||||
}
|
||||
|
||||
$label = e($attachment?->filename ?: "attachment-{$id}");
|
||||
|
||||
return '<a href="'.e($url).'">'.$label.'</a>';
|
||||
}, $html) ?? $html;
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
public function publicEntryUrl(int $attachmentId): string
|
||||
{
|
||||
return url('/attachment.php?id='.$attachmentId);
|
||||
}
|
||||
|
||||
protected function findAttachment(int $id, ?int $articleId = null): ?Attachment
|
||||
{
|
||||
try {
|
||||
return Cache::remember("attach-embed:{$id}", 60, function () use ($id) {
|
||||
return Attachment::query()->find($id);
|
||||
});
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
protected function isImage(Attachment $attachment): bool
|
||||
{
|
||||
if (is_string($attachment->mime) && str_starts_with($attachment->mime, 'image/')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$ext = strtolower(pathinfo($attachment->filename, PATHINFO_EXTENSION));
|
||||
|
||||
return in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg'], true);
|
||||
}
|
||||
|
||||
protected function escapeMdLabel(string $label): string
|
||||
{
|
||||
return str_replace(['[', ']', '!'], ['\\[', '\\]', '\\!'], $label);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Blog;
|
||||
|
||||
final class ContentFormat
|
||||
{
|
||||
public const HTML = 'html';
|
||||
|
||||
public const MARKDOWN = 'markdown';
|
||||
|
||||
public static function all(): array
|
||||
{
|
||||
return [self::HTML, self::MARKDOWN];
|
||||
}
|
||||
|
||||
public static function isValid(string $format): bool
|
||||
{
|
||||
return in_array($format, self::all(), true);
|
||||
}
|
||||
|
||||
public static function normalize(?string $format, string $fallback = self::HTML): string
|
||||
{
|
||||
$format = strtolower(trim((string) $format));
|
||||
|
||||
return self::isValid($format) ? $format : $fallback;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Blog;
|
||||
|
||||
use DOMDocument;
|
||||
use DOMElement;
|
||||
use DOMXPath;
|
||||
use League\CommonMark\Environment\Environment;
|
||||
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
|
||||
use League\CommonMark\Extension\GithubFlavoredMarkdownExtension;
|
||||
use League\CommonMark\Extension\HeadingPermalink\HeadingPermalinkExtension;
|
||||
use League\CommonMark\MarkdownConverter;
|
||||
use League\HTMLToMarkdown\HtmlConverter;
|
||||
use Mews\Purifier\Facades\Purifier;
|
||||
|
||||
class ContentRenderer
|
||||
{
|
||||
public function __construct(
|
||||
protected AttachEmbed $attachEmbed,
|
||||
) {}
|
||||
|
||||
public function toHtml(string $content, string $format, ?int $articleId = null): string
|
||||
{
|
||||
return $this->render($content, $format, $articleId)['html'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{html: string, toc: list<array{level: int, id: string, text: string}>}
|
||||
*/
|
||||
public function render(string $content, string $format, ?int $articleId = null): array
|
||||
{
|
||||
$format = ContentFormat::normalize($format);
|
||||
|
||||
if ($format === ContentFormat::MARKDOWN) {
|
||||
$content = $this->attachEmbed->legacyToMarkdownReference($content, $articleId);
|
||||
$content = $this->attachEmbed->expandMarkdownAttachProtocol($content);
|
||||
$html = $this->markdownToHtml($content);
|
||||
} else {
|
||||
$html = $content;
|
||||
}
|
||||
|
||||
$html = $this->attachEmbed->hydrateHtml($html, $articleId);
|
||||
$html = Purifier::clean($html, 'article');
|
||||
|
||||
return $this->applyTocAnchors($html);
|
||||
}
|
||||
|
||||
public function markdownToHtml(string $markdown): string
|
||||
{
|
||||
$environment = new Environment([
|
||||
'html_input' => 'strip',
|
||||
'allow_unsafe_links' => false,
|
||||
'heading_permalink' => [
|
||||
'html_class' => 'heading-permalink',
|
||||
'id_prefix' => '',
|
||||
'fragment_prefix' => '',
|
||||
'insert' => 'none',
|
||||
'apply_id_to_heading' => true,
|
||||
'heading_class' => '',
|
||||
],
|
||||
]);
|
||||
$environment->addExtension(new CommonMarkCoreExtension);
|
||||
$environment->addExtension(new GithubFlavoredMarkdownExtension);
|
||||
$environment->addExtension(new HeadingPermalinkExtension);
|
||||
|
||||
return (string) (new MarkdownConverter($environment))->convert($markdown);
|
||||
}
|
||||
|
||||
public function htmlToMarkdown(string $html): string
|
||||
{
|
||||
$converter = new HtmlConverter([
|
||||
'strip_tags' => true,
|
||||
'hard_break' => true,
|
||||
]);
|
||||
|
||||
return trim($converter->convert($html));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert sablog HTML article body into Markdown storage form.
|
||||
* Protects [attach=id] / src="[attach=id]" with placeholders during conversion.
|
||||
*/
|
||||
public function convertImportedHtmlToMarkdown(string $html, ?int $articleId = null): string
|
||||
{
|
||||
[$protected, $map] = $this->attachEmbed->protectLegacyTokensForHtmlConversion($html, $articleId);
|
||||
$markdown = $this->htmlToMarkdown($protected);
|
||||
|
||||
return $this->attachEmbed->restoreProtectedTokensToMarkdown($markdown, $map);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{html: string, toc: list<array{level: int, id: string, text: string}>}
|
||||
*/
|
||||
protected function applyTocAnchors(string $html): array
|
||||
{
|
||||
if (trim($html) === '') {
|
||||
return ['html' => '', 'toc' => []];
|
||||
}
|
||||
|
||||
$document = new DOMDocument;
|
||||
$previous = libxml_use_internal_errors(true);
|
||||
$document->loadHTML(
|
||||
'<?xml encoding="UTF-8"><div id="larablog-toc-root">'.$html.'</div>',
|
||||
LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
|
||||
);
|
||||
libxml_clear_errors();
|
||||
libxml_use_internal_errors($previous);
|
||||
|
||||
$xpath = new DOMXPath($document);
|
||||
$nodes = $xpath->query('//*[@id="larablog-toc-root"]//*[self::h2 or self::h3 or self::h4]');
|
||||
if ($nodes === false) {
|
||||
return ['html' => $html, 'toc' => []];
|
||||
}
|
||||
|
||||
$toc = [];
|
||||
$usedIds = [];
|
||||
|
||||
foreach ($nodes as $node) {
|
||||
if (! $node instanceof DOMElement) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$text = trim(preg_replace('/\s+/u', ' ', $node->textContent ?? '') ?? '');
|
||||
if ($text === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$level = (int) substr(strtolower($node->tagName), 1);
|
||||
$id = $node->getAttribute('id');
|
||||
if ($id === '') {
|
||||
$id = $this->slugifyHeading($text);
|
||||
}
|
||||
|
||||
$base = $id;
|
||||
$i = 2;
|
||||
while (isset($usedIds[$id])) {
|
||||
$id = $base.'-'.$i;
|
||||
$i++;
|
||||
}
|
||||
$usedIds[$id] = true;
|
||||
$node->setAttribute('id', $id);
|
||||
|
||||
$toc[] = [
|
||||
'level' => $level,
|
||||
'id' => $id,
|
||||
'text' => $text,
|
||||
];
|
||||
}
|
||||
|
||||
$root = $document->getElementById('larablog-toc-root');
|
||||
$out = $html;
|
||||
if ($root instanceof DOMElement) {
|
||||
$out = '';
|
||||
foreach ($root->childNodes as $child) {
|
||||
$out .= $document->saveHTML($child);
|
||||
}
|
||||
}
|
||||
|
||||
return ['html' => $out, 'toc' => $toc];
|
||||
}
|
||||
|
||||
protected function slugifyHeading(string $text): string
|
||||
{
|
||||
$slug = strtolower(trim($text));
|
||||
$slug = preg_replace('/[^\p{L}\p{N}\-_]+/u', '-', $slug) ?? '';
|
||||
$slug = trim($slug, '-');
|
||||
|
||||
return $slug !== '' ? $slug : 'section';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\Blog;
|
||||
|
||||
use Mews\Purifier\Facades\Purifier;
|
||||
|
||||
class HtmlTeaser
|
||||
{
|
||||
/**
|
||||
* Build a teaser from already-rendered HTML.
|
||||
*
|
||||
* The result is always a plain-text excerpt and always withholds part of the
|
||||
* body: when the configured limit covers the whole article we still cut it
|
||||
* in half, so a gated article can never be served in full through a teaser.
|
||||
*/
|
||||
public function truncate(string $html, int $maxChars): string
|
||||
{
|
||||
if ($maxChars <= 0 || trim($html) === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$text = html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
$text = trim(preg_replace('/\s+/u', ' ', $text) ?? '');
|
||||
|
||||
if ($text === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$length = mb_strlen($text);
|
||||
$limit = min($maxChars, max(1, (int) floor($length / 2)));
|
||||
|
||||
$snippet = mb_substr($text, 0, $limit);
|
||||
if ($limit < $length) {
|
||||
$snippet .= '…';
|
||||
}
|
||||
|
||||
return Purifier::clean('<p>'.e($snippet).'</p>', 'article');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user