渲染Markdown中的Blade组件- Aaron Francis
你正在阅读的这个网站是一个基本的Laravel应用程序,只有几个附加包。所有的文章(包括这篇文章!)都是用Markdown编写的,然后使用CommonMark PHP库 (opens new window)和Laravel-Markdown (opens new window) Laravel shim进行渲染。
我喜欢Markdown的写作体验和简单性,但有时我需要跳出Markdown并使用一些更加花哨的东西,比如这个嵌入的推文:
Aaron Francis
@aarondfrancis
完成项目的90%最好的部分是你已经完成了一半!
这个嵌入的推文是一个blade组件 (opens new window),我可以在.blade
文件中像这样使用:
<x-tweet url='https://twitter.com/aarondfrancis/status/1705211030882684946'> The best part about being 以下是90%完成的一个项目几乎已经完成一半!
在我理想的世界里,我可以像这样将其放入.md文件中,一切都能正常工作!
这样:
一些内容在这里<x-tweet url='https://twitter.com/aarondfrancis/status/1705211030882684946'>完成一个项目的90%最好的部分是你几乎已经完成了一半!</x-tweet>这里还有一些内容
但是,现实世界并不理想,这样做行不通。这样做行不通是因为markdown处理流程不包括任何Blade渲染。但是只要付出足够的努力,一切问题都可以解决!我尝试过几种方法,每种方法都有其优缺点。最终我选择了两种同样可行的方式,选择哪种方式是个人偏好的问题。
它们是:
* 解析除代码块之外的所有内容
* 仅解析代码块
## 解析除代码块之外的所有内容
第一种方法是:
1. 从markdown文档中删除所有代码块(下面会详细介绍)
2. 将markdown渲染为HTML
3. 渲染 当Commonmark解析Markdown文档时,它将其转换成一个充满节点的[抽象语法树](https://commonmark.thephpleague.com/2.4/customization/abstract-syntax-tree/)。在将它们转换成HTML之前,我们可以遍历这些节点并进行一些修改。在我们的情况下,我们将从文档中删除所有的代码节点。
我们必须从文档中删除代码节点,因为这些节点无法安全地通过Blade解析器运行。
例如,假设您有一个代码块,其中包含以下代码:
@php(DB::table('users')->delete())
您肯定不希望这段代码被执行!
或者更糟糕的是:
@dd(file_get_contents(base_path('.env'))
这些是最糟糕的情况,但即使是相对正常的内容也可能引起问题。如果您甚至包含了一个带有Blade指令的内联块,比如`@endif`或`@include`,Blade也会出现问题。 寻找代码块
我们不再在HTML中寻找代码块,因为这可能很棘手,而是在AST阶段将代码块提取出来,这非常简单。
首先,我们将创建一个名为`BladeParsingExtension`的Commonmark扩展。我们将连接到两个事件:
* 文档解析完成:在将Markdown转换为AST后。
* 文档渲染完成:在将AST转换为HTML后。
class BladeParsingExtension implements ExtensionInterface{ public function register(EnvironmentBuilderInterface $environment): void { $environment->addEventListener( DocumentParsedEvent::class, [$this, 'onDocumentParsed'], -10 ); $environment->addEventListener( DocumentRenderedEvent::class, [$this, 'onDocumentRendered'], 10 ); } public function onDocumentParsed(DocumentParsedEvent $event) { // Walk through every node in the document foreach ($e 移除代码块
当我们找到一个代码节点时,我们会将其从AST中提取出来,留下一个占位符。占位符只是一个我们稍后查找的随机字符串。
class BladeParsingExtension implements ExtensionInterface{ protected array $rendered = []; protected Environment $environment; public function register(EnvironmentBuilderInterface $environment): void ... { $environment->addEventListener( DocumentParsedEvent::class, [$this, 'on 删除第一级标题后,将以下Markdown翻译成中文:
```php
DocumentParsed'], -10 ); $environment->addEventListener( DocumentRenderedEvent::class, [$this, 'onDocumentRendered'], 10 ); $this->environment = $environment; } public function onDocumentParsed(DocumentParsedEvent $event) { foreach ($event->getDocument()->iterator() as $node) { if (!$this->isCodeNode($node)) { continue; } // 创建一个唯一的随机ID $id = Str::uuid()->toString(); // 创建一个只包含占位符的新HTML块 $replacement = new HtmlBlock(HtmlBlock::TYPE_6_BLOCK_ELEMENT); $replacement->setLiteral("[[replace:$id]]"); // 用占位符替换代码节点 $node->replaceWith($replacement); // 创建一个与主要渲染器完全相同的渲染器 $renderer = new HtmlRenderer($this->environment) // 渲染代码节点并将其存储起来。 $this->rendere
``` d[$id] = $renderer->renderNodes([$node]); } } public function onDocumentRendered(DocumentRenderedEvent $event) ... { // @TODO } protected function isCodeNode($node) ... { return $node instanceof FencedCode || $node instanceof IndentedCode || $node instanceof Code; }} 以下是Markdown的翻译版本,同时删除了一级标题:
```php
s, [$this, 'onDocumentParsed'], -10);
$environment->addEventListener(DocumentRenderedEvent::class, [$this, 'onDocumentRendered'], 10);
$this->environment = $environment;
}
public function onDocumentParsed(DocumentParsedEvent $event) ...
{
foreach ($event->getDocument()->iterator() as $node) {
if (!$this->isCodeNode($node)) {
continue;
}
// 创建一个唯一的随机ID
$id = Str::uuid()->toString();
// 创建一个只包含占位符的新HTML块
$replacement = new HtmlBlock(HtmlBlock::TYPE_6_BLOCK_ELEMENT);
$replacement->setLiteral("[[replace:$id]]");
// 用占位符替换代码节点
$node->replaceWith($replacement);
// 创建一个与主要渲染器相同的渲染器
$renderer = new HtmlRenderer($this->environment)
// 渲染代码节点并储存起来。
请注意,此处的翻译是根据Markdown内容进行的,因此不包含Markdown标记的翻译。 $this->rendered[$id] = $renderer->renderNodes([$node]); } } public function onDocumentRendered(DocumentRenderedEvent $event) { $search = []; $replace = []; // 收集所有占位符和它们的真实内容 foreach ($this->rendered as $id => $content) { $search[] = "[[replace:$id]]"; $replace[] = $content; } // Commonmark 生成的 HTML $content = $event->getOutput()->getContent(); // 首先渲染没有代码块的输出。 $content = Blade::render($content); // 然后将代码块添加回去。 $content = Str::replace($search, $replace, $content); // 用我们新的、经过 Blade 处理的输出替换整个响应。 $event->replaceOutput( new RenderedContent($event->getOutput()->getDocument(), $content) ); } protected function isCodeNode($node) ... { return $node instanceof FencedCode || 将以下Markdown翻译成中文并删除一级标题:
$node instanceof IndentedCode || $node instanceof Code;}}
启用扩展 #
要启用扩展,我们需要将其添加到markdown.php
文件的扩展数组中。与此同时,我们将html_input
设置为allow
,以便HTML标签不会被转义。
return [ // 其他配置... 'extensions' => [ // 添加扩展 BladeParsingExtension::class, ], // 保留HTML标签 'html_input' => HtmlFilter::ALLOW,]
仅解析代码块 #
第二个选项是使用"magic"代码块。这种技术有一些好处,但我不确定它是否更好。
"magic"代码块在Markdown中的写法如下:
```blade +parse<x-tweet url='https://twitter.com/aarondfrancis/status/1705211030882684946'> The best part about being 90% done with a project is that you're almost halfway finished!</x-tweet>```
它看起来像是一个普通的代码块:三个反引号后面是语言标识符 blade
。但是在标识符后面是一个魔术注释+parse
。
这个+parse
注释将告诉我们这不是一个需要突出显示的代码块,而是一个需要运行的代码块。
这种方法的好处是,在代码块内工作时,您可以在编辑器中获得完整的语法突出显示。这真的很棒。
第二个好处是对解析和不解析的内容有精细的控制。只有您标记为+parse
的代码块将被处理。我不知道这是好事还是烦人的事。
无论如何,我们继续进行。
创建一个渲染扩展 #
与之前一样,我们首先创建一个扩展。这次我们将把它命名为CodeRendererExtension
。我们只需为代码块注册一些渲染器,而不是监听事件。
class CodeRendererExtension implements ExtensionInterface, NodeRendererInterface{ public function register(EnvironmentBuilderInterface $environment): void { $environment->addRe 我们给我们的渲染器一个相对较高的优先级(100),以便在任何语法高亮器之前运行。
在`render`函数内部,我们可以访问代码节点并做任何我们喜欢的操作。在我们的情况下,我们将检查是否存在魔术词,如果找到,我们将通过Blade运行内容。
```php
class CodeRendererExtension implements ExtensionInterface, NodeRendererInterface{ public function register(EnvironmentBuilderInterface $environment): void ... { $environment->addRenderer(FencedCode::class, $this, 100); $environment->addRenderer(IndentedCode::class, $this, 100); } public function render(Node $node, ChildNodeRendererInterface $childRenderer) { /** @var FencedCode|IndentedCode $node */ // @TODO something??? }}
``` $info = $node->getInfoWords(); // 寻找我们的魔术词 if (in_array('+parse', $info)) { // 通过 Blade 运行内容 return Blade::render($node->getLiteral()); } }}
正如您所看到的,这是一种在后端上更简单的方法,但作者体验不够灵活。
我更喜欢第一种方法,其中 Blade 解析所有内容,除了代码块。
### 启用扩展
启用扩展与之前相同。我们将其添加到 `markdown.php` 配置文件中。这次,您可以将 `html_input` 设置为任何您想要的值,因为我们不依赖于 Markdown 中的 HTML 标记。而是将它们包装在代码块中。
return [ // 其他配置... 'extensions' => [ // 添加扩展 CodeRendererExtension::class, ],]
## 安全考虑
在用户提供的内容上运行 Blade 是非常危险的,您绝对不能这样做。如果您在用户提供的内容上运行 Blade,那么您应该非常小心。 作为一个YouTube视频
你想在YouTube上看到这个视频吗?请在[Twitter](https://twitter.com/@aarondfrancis)上告诉我,我会把它加入到我的队列中!
作为一个包
在我从这篇文章的人们那里得到一些反馈后,我会在接下来的几天内将其制作成一个可安装的包。