'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 处理等 return app(\App\Blog\Support\PluginManager::class)->applyFilters('post.rendered', $html, $post); } /** * 渲染并提取目录(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('%s', $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); } }