- 实现基于 Laravel 11 和 Filament 3.X 的文档管理系统 - 添加用户认证和分组管理功能 - 实现文档上传、分类和权限控制 - 集成 Word 文档自动转换为 Markdown - 集成 Meilisearch 全文搜索引擎 - 实现文档在线预览功能 - 添加安全日志和审计功能 - 完整的简体中文界面 - 包含完整的项目文档和部署指南 技术栈: - Laravel 11.x - Filament 3.X - Meilisearch 1.5+ - Pandoc 文档转换 - Redis 队列系统 - Pest PHP 测试框架
255 lines
9.0 KiB
PHP
255 lines
9.0 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use League\CommonMark\CommonMarkConverter;
|
|
use League\CommonMark\Environment\Environment;
|
|
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
|
|
use League\CommonMark\Extension\Table\TableExtension;
|
|
use League\CommonMark\Extension\Strikethrough\StrikethroughExtension;
|
|
use League\CommonMark\Extension\TaskList\TaskListExtension;
|
|
use League\CommonMark\MarkdownConverter;
|
|
|
|
class MarkdownRenderService
|
|
{
|
|
protected MarkdownConverter $converter;
|
|
protected bool $sanitize;
|
|
|
|
public function __construct()
|
|
{
|
|
// 从配置文件读取设置
|
|
$this->sanitize = config('documents.markdown.sanitize', true);
|
|
|
|
// 创建环境配置
|
|
$config = [
|
|
'html_input' => $this->sanitize ? 'strip' : 'allow', // 根据配置决定是否剥离 HTML 标签
|
|
'allow_unsafe_links' => false, // 不允许不安全的链接
|
|
'max_nesting_level' => 10, // 最大嵌套层级
|
|
];
|
|
|
|
// 创建环境并添加扩展
|
|
$environment = new Environment($config);
|
|
$environment->addExtension(new CommonMarkCoreExtension());
|
|
$environment->addExtension(new TableExtension()); // 支持表格
|
|
$environment->addExtension(new StrikethroughExtension()); // 支持删除线
|
|
$environment->addExtension(new TaskListExtension()); // 支持任务列表
|
|
|
|
// 创建转换器
|
|
$this->converter = new MarkdownConverter($environment);
|
|
}
|
|
|
|
/**
|
|
* 将 Markdown 内容渲染为 HTML
|
|
*
|
|
* @param string $markdown Markdown 内容
|
|
* @return string 渲染后的 HTML
|
|
*/
|
|
public function render(string $markdown): string
|
|
{
|
|
try {
|
|
// 转换 Markdown 为 HTML
|
|
$html = $this->converter->convert($markdown)->getContent();
|
|
|
|
// 清理和美化 HTML
|
|
$html = $this->sanitize($html);
|
|
|
|
return $html;
|
|
} catch (\Exception $e) {
|
|
// 如果渲染失败,返回错误信息
|
|
return '<div class="alert alert-danger">Markdown 渲染失败:' . htmlspecialchars($e->getMessage()) . '</div>';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 清理 HTML 内容,防止 XSS 攻击
|
|
*
|
|
* @param string $html HTML 内容
|
|
* @return string 清理后的 HTML
|
|
*/
|
|
public function sanitize(string $html): string
|
|
{
|
|
// CommonMark 已经配置了 html_input => 'strip',会自动剥离 HTML 标签
|
|
// 这里我们添加额外的样式包装
|
|
|
|
$styledHtml = '<div class="markdown-content" style="
|
|
font-family: -apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, \'Helvetica Neue\', Arial, sans-serif;
|
|
line-height: 1.6;
|
|
color: #333;
|
|
padding: 20px;
|
|
background: white;
|
|
border-radius: 8px;
|
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
|
">';
|
|
|
|
// 添加基本的 Markdown 样式
|
|
$styledHtml .= $this->getMarkdownStyles();
|
|
|
|
$styledHtml .= $html;
|
|
$styledHtml .= '</div>';
|
|
|
|
return $styledHtml;
|
|
}
|
|
|
|
/**
|
|
* 获取 Markdown 内容的 CSS 样式
|
|
*
|
|
* @return string CSS 样式
|
|
*/
|
|
protected function getMarkdownStyles(): string
|
|
{
|
|
return '<style>
|
|
.markdown-content h1 { font-size: 2em; margin-top: 0.67em; margin-bottom: 0.67em; font-weight: bold; border-bottom: 1px solid #eee; padding-bottom: 0.3em; }
|
|
.markdown-content h2 { font-size: 1.5em; margin-top: 0.83em; margin-bottom: 0.83em; font-weight: bold; border-bottom: 1px solid #eee; padding-bottom: 0.3em; }
|
|
.markdown-content h3 { font-size: 1.17em; margin-top: 1em; margin-bottom: 1em; font-weight: bold; }
|
|
.markdown-content h4 { font-size: 1em; margin-top: 1.33em; margin-bottom: 1.33em; font-weight: bold; }
|
|
.markdown-content h5 { font-size: 0.83em; margin-top: 1.67em; margin-bottom: 1.67em; font-weight: bold; }
|
|
.markdown-content h6 { font-size: 0.67em; margin-top: 2.33em; margin-bottom: 2.33em; font-weight: bold; }
|
|
.markdown-content p { margin: 1em 0; }
|
|
.markdown-content ul, .markdown-content ol { margin: 1em 0; padding-left: 2em; }
|
|
.markdown-content li { margin: 0.5em 0; }
|
|
.markdown-content code {
|
|
background-color: #f6f8fa;
|
|
padding: 2px 6px;
|
|
border-radius: 3px;
|
|
font-family: \'SFMono-Regular\', \'Consolas\', \'Liberation Mono\', \'Menlo\', \'Courier\', monospace;
|
|
font-size: 0.9em;
|
|
color: #24292e;
|
|
}
|
|
.markdown-content pre {
|
|
background-color: #f6f8fa;
|
|
padding: 16px;
|
|
border-radius: 6px;
|
|
overflow-x: auto;
|
|
margin: 1em 0;
|
|
border: 1px solid #e1e4e8;
|
|
}
|
|
.markdown-content pre code {
|
|
background-color: transparent;
|
|
padding: 0;
|
|
border: none;
|
|
}
|
|
.markdown-content blockquote {
|
|
border-left: 4px solid #dfe2e5;
|
|
padding-left: 16px;
|
|
margin: 1em 0;
|
|
color: #6a737d;
|
|
}
|
|
.markdown-content table {
|
|
border-collapse: collapse;
|
|
width: 100%;
|
|
margin: 1em 0;
|
|
display: block;
|
|
overflow-x: auto;
|
|
}
|
|
.markdown-content table th, .markdown-content table td {
|
|
border: 1px solid #dfe2e5;
|
|
padding: 8px 12px;
|
|
text-align: left;
|
|
}
|
|
.markdown-content table th {
|
|
background-color: #f6f8fa;
|
|
font-weight: bold;
|
|
}
|
|
.markdown-content table tr:nth-child(even) {
|
|
background-color: #f6f8fa;
|
|
}
|
|
.markdown-content a {
|
|
color: #0366d6;
|
|
text-decoration: none;
|
|
}
|
|
.markdown-content a:hover {
|
|
text-decoration: underline;
|
|
}
|
|
.markdown-content hr {
|
|
border: none;
|
|
border-top: 2px solid #e1e4e8;
|
|
margin: 2em 0;
|
|
}
|
|
.markdown-content img {
|
|
max-width: 100%;
|
|
height: auto;
|
|
border-radius: 6px;
|
|
}
|
|
.markdown-content del {
|
|
text-decoration: line-through;
|
|
color: #6a737d;
|
|
}
|
|
.markdown-content input[type="checkbox"] {
|
|
margin-right: 0.5em;
|
|
}
|
|
</style>';
|
|
}
|
|
|
|
/**
|
|
* 从 Markdown 内容中提取摘要
|
|
*
|
|
* @param string $markdown Markdown 内容
|
|
* @param int|null $length 摘要长度(字符数),如果为 null 则使用配置文件中的默认值
|
|
* @return string 摘要文本
|
|
*/
|
|
public function extractPreview(string $markdown, ?int $length = null): string
|
|
{
|
|
// 如果未指定长度,使用配置文件中的默认值
|
|
if ($length === null) {
|
|
$length = config('documents.markdown.preview_length', 500);
|
|
}
|
|
|
|
// 移除 Markdown 标记,获取纯文本
|
|
$text = $this->stripMarkdown($markdown);
|
|
|
|
// 移除多余的空白字符
|
|
$text = preg_replace('/\s+/', ' ', $text);
|
|
$text = trim($text);
|
|
|
|
// 截取指定长度
|
|
if (mb_strlen($text) > $length) {
|
|
$text = mb_substr($text, 0, $length) . '...';
|
|
}
|
|
|
|
return $text;
|
|
}
|
|
|
|
/**
|
|
* 移除 Markdown 标记,返回纯文本
|
|
*
|
|
* @param string $markdown Markdown 内容
|
|
* @return string 纯文本
|
|
*/
|
|
protected function stripMarkdown(string $markdown): string
|
|
{
|
|
// 移除代码块
|
|
$text = preg_replace('/```[\s\S]*?```/', '', $markdown);
|
|
$text = preg_replace('/`[^`]+`/', '', $text);
|
|
|
|
// 移除标题标记
|
|
$text = preg_replace('/^#{1,6}\s+/m', '', $text);
|
|
|
|
// 移除链接,保留文本
|
|
$text = preg_replace('/\[([^\]]+)\]\([^\)]+\)/', '$1', $text);
|
|
|
|
// 移除图片
|
|
$text = preg_replace('/!\[([^\]]*)\]\([^\)]+\)/', '', $text);
|
|
|
|
// 移除粗体和斜体标记
|
|
$text = preg_replace('/\*\*([^\*]+)\*\*/', '$1', $text);
|
|
$text = preg_replace('/\*([^\*]+)\*/', '$1', $text);
|
|
$text = preg_replace('/__([^_]+)__/', '$1', $text);
|
|
$text = preg_replace('/_([^_]+)_/', '$1', $text);
|
|
|
|
// 移除删除线
|
|
$text = preg_replace('/~~([^~]+)~~/', '$1', $text);
|
|
|
|
// 移除引用标记
|
|
$text = preg_replace('/^>\s+/m', '', $text);
|
|
|
|
// 移除列表标记
|
|
$text = preg_replace('/^[\*\-\+]\s+/m', '', $text);
|
|
$text = preg_replace('/^\d+\.\s+/m', '', $text);
|
|
|
|
// 移除水平线
|
|
$text = preg_replace('/^[\-\*_]{3,}$/m', '', $text);
|
|
|
|
return $text;
|
|
}
|
|
}
|