'allow',
'allow_unsafe_links' => true,
'max_nesting_level' => 100,
]);
$environment->addExtension(new CommonMarkCoreExtension);
$environment->addExtension(new GithubFlavoredMarkdownExtension);
$this->converter = new MarkdownConverter($environment);
}
/**
* 将文章内容按格式渲染为 HTML。
* markdown -> HTML;html 原样返回。两种格式都会解析附件令牌。
*/
public function render(Post $post): string
{
$content = $post->content_format === 'markdown'
? $this->toHtml($post->content)
: $post->content;
$html = $this->renderShortcodes($content, $post);
// 插件钩子:付费内容过滤、AI 处理等
$html = app(\App\Blog\Support\PluginManager::class)->applyFilters('post.rendered', $html, $post);
// 兜底:任何情况下不把裸 [paid] 标签输出到页面(如会员插件被停用时)
$html = preg_replace('/\[(\/?paid)\]/i', '', $html);
// 外链中转:站外链接改写为 /go/{hash}(点击统计/拦截)
return $this->rewriteExternalLinks($html);
}
/**
* 把正文里的站外 改写为 /go 中转地址(站内/相对/锚点/mailto 不变)。
*/
private function rewriteExternalLinks(string $html): string
{
return preg_replace_callback(
'/]*?href=["\'])([^"\']+)(["\'][^>]*)>(.*?)<\/a>/is',
function (array $m): string {
$url = html_entity_decode(trim($m[2]));
$go = go_url($url);
if ($go === $url) {
return $m[0];
}
return ''.$m[4].'';
},
$html
);
}
/**
* 渲染并提取目录(TOC)。
*
* @return array{html: string, toc: array}
*/
public function renderWithToc(Post $post): array
{
$html = $this->render($post);
$toc = [];
$index = 0;
$html = preg_replace_callback(
'/]*)>(.*?)<\/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 ''.$m[3].'';
},
$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('
', $media->getUrl(), e($media->name))
: sprintf('%s', $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);
}
}