Files
laralog/app/Blog/Services/PostContentRenderer.php
T

156 lines
5.0 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Blog\Services;
use App\Models\Post;
use Illuminate\Support\Facades\Cache;
use League\CommonMark\Environment\Environment;
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
use League\CommonMark\Extension\GithubFlavoredMarkdownExtension;
use League\CommonMark\MarkdownConverter;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
class PostContentRenderer
{
private MarkdownConverter $converter;
public function __construct()
{
$environment = new Environment([
'html_input' => 'allow',
'allow_unsafe_links' => true,
'max_nesting_level' => 100,
]);
$environment->addExtension(new CommonMarkCoreExtension);
$environment->addExtension(new GithubFlavoredMarkdownExtension);
$this->converter = new MarkdownConverter($environment);
}
/**
* 将文章内容按格式渲染为 HTML。
* markdown -> HTMLhtml 原样返回。两种格式都会解析附件令牌。
*/
public function render(Post $post): string
{
$content = $post->content_format === 'markdown'
? $this->toHtml($post->content)
: $post->content;
$html = $this->renderShortcodes($content, $post);
// 插件钩子:付费内容过滤、AI 处理等
return app(\App\Blog\Support\PluginManager::class)->applyFilters('post.rendered', $html, $post);
}
/**
* 渲染并提取目录(TOC)。
*
* @return array{html: string, toc: array<int, array{id: string, text: string, level: int}>}
*/
public function renderWithToc(Post $post): array
{
$html = $this->render($post);
$toc = [];
$index = 0;
$html = preg_replace_callback(
'/<h([2-6])([^>]*)>(.*?)<\/h\1>/is',
function (array $m) use (&$toc, &$index): string {
$level = (int) $m[1];
$attrs = $m[2];
$text = trim(strip_tags($m[3]));
$id = 'toc-'.(++$index);
$toc[] = ['id' => $id, 'text' => $text, 'level' => $level];
// 已存在 id 则保留原 id,否则注入锚点
if (preg_match('/id="([^"]+)"/', $attrs, $idMatch)) {
$toc[array_key_last($toc)]['id'] = $idMatch[1];
return $m[0];
}
return '<h'.$level.$attrs.' id="'.$id.'">'.$m[3].'</h'.$level.'>';
},
$html
);
return ['html' => $html, 'toc' => $toc];
}
public function toHtml(string $markdown): string
{
return $this->converter->convert($markdown)->getContent();
}
/**
* 解析附件引用。
*
* 兼容两种写法:
* - 老 sablog HTML 内容: [attach=xx] / [img=xx]
* - markdown 转换导入: {{attach:xx}} / {{img:xx}}[] 是 markdown 保留字符,转换时改为令牌)
*
* xx 是 sablog attachmentid;找不到时回退到按出现顺序的第 N 个附件。
*/
public function renderShortcodes(string $html, Post $post): string
{
$pattern = '/\[(attach|img)=(\d+)\]|\{\{(attach|img):(\d+)\}\}/';
if (! preg_match_all($pattern, $html, $matches, PREG_SET_ORDER)) {
return $html;
}
// 短代码引用的是 sablog 全局附件 id,可能属于其他文章,需要全局查
$byLegacyId = Cache::remember('attach.legacy_ids', now()->addHour(), function () {
return Media::query()
->where('collection_name', 'attachments')
->get()
->keyBy(fn ($m) => (int) $m->getCustomProperty('legacy_attachmentid'));
});
// 当前文章媒体用于索引回退
$ordered = $post->getMedia('attachments')->values();
foreach ($matches as $match) {
$full = $match[0];
$type = $match[1] !== '' ? $match[1] : $match[3];
$id = (int) ($match[2] !== '' ? $match[2] : $match[4]);
$media = $byLegacyId->get($id);
if (! $media) {
// 兼容按索引引用的旧写法:第 $id 个附件(从 1 开始)
$media = $ordered->get($id - 1);
}
if (! $media) {
// 找不到媒体时移除残留短代码,避免把 [attach=999] 当正文显示
$html = str_replace($full, '', $html);
continue;
}
$replacement = $type === 'img'
? sprintf('<img src="%s" alt="%s" loading="lazy" />', $media->getUrl(), e($media->name))
: sprintf('<a href="%s">%s</a>', $media->getUrl(), e($media->file_name));
$html = str_replace($full, $replacement, $html);
}
return $html;
}
/**
* 把老 sablog HTML 里的 [attach=xx]/[img=xx] 换成 markdown 安全令牌,
* 供 HTML -> markdown 转换前调用。
*/
public function toMarkdownSafeTokens(string $html): string
{
return preg_replace('/\[(attach|img)=(\d+)\]/', '{{$1:$2}}', $html);
}
}