1<?php
2
3// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
4// See the LICENCE file in the repository root for full licence text.
5
6namespace App\Libraries\Markdown;
7
8use App\Libraries\Markdown\CustomContainerInline\Extension as CustomContainerInlineExtension;
9use App\Traits\Memoizes;
10use League\CommonMark\Environment\Environment;
11use League\CommonMark\Extension\Autolink\AutolinkExtension;
12use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
13use League\CommonMark\Extension\CommonMark\Node\Block\Heading;
14use League\CommonMark\Extension\CommonMark\Node\Block\ListBlock;
15use League\CommonMark\Extension\CommonMark\Node\Block\ListItem;
16use League\CommonMark\Extension\CommonMark\Node\Inline\Image;
17use League\CommonMark\Extension\CommonMark\Node\Inline\Link;
18use League\CommonMark\Extension\DefaultAttributes\DefaultAttributesExtension;
19use League\CommonMark\Extension\Footnote\FootnoteExtension;
20use League\CommonMark\Extension\Strikethrough\StrikethroughExtension;
21use League\CommonMark\Extension\Table\Table;
22use League\CommonMark\Extension\Table\TableCell;
23use League\CommonMark\Extension\Table\TableExtension;
24use League\CommonMark\MarkdownConverter;
25use League\CommonMark\Node\Block\Paragraph;
26use Symfony\Component\Yaml\Exception\ParseException as YamlParseException;
27use Symfony\Component\Yaml\Yaml;
28
29class OsuMarkdown
30{
31 use Memoizes;
32
33 const VERSION = 14;
34
35 const DEFAULT_COMMONMARK_CONFIG = [
36 'allow_unsafe_links' => false,
37 'html_input' => 'strip',
38 'max_nesting_level' => 20,
39 'renderer' => ['soft_break' => '<br />'],
40 ];
41
42 const DEFAULT_OSU_EXTENSION_CONFIG = [
43 'block_name' => 'osu-md',
44 'custom_container_inline' => false,
45 'fix_wiki_url' => false,
46 'generate_toc' => false,
47 'record_first_image' => false,
48 'relative_url_root' => null,
49 'style_block_allowed_classes' => null,
50 'title_from_document' => false,
51 'wiki_locale' => null,
52 'with_gallery' => false,
53 ];
54
55 // this config is only used in this class
56 const DEFAULT_OSU_MARKDOWN_CONFIG = [
57 'block_modifiers' => [],
58 'enable_autolink' => false,
59 'enable_footnote' => false,
60 'parse_yaml_header' => true,
61 ];
62
63 const PRESETS = [
64 'changelog_entry' => [
65 'commonmark' => [
66 'html_input' => 'allow',
67 ],
68 'osu_extension' => [
69 'block_name' => 'changelog-md',
70 ],
71 ],
72 'comment' => [
73 'osu_markdown' => [
74 'block_modifiers' => ['comment'],
75 'enable_autolink' => true,
76 ],
77 ],
78 'contest' => [
79 'commonmark' => [
80 'html_input' => 'allow',
81 ],
82 ],
83 'default' => [],
84 'group' => [
85 'osu_markdown' => [
86 'block_modifiers' => ['group'],
87 ],
88 ],
89 'news' => [
90 'commonmark' => [
91 'html_input' => 'allow',
92 ],
93 'osu_extension' => [
94 'attributes_allowed' => ['flag', 'id'],
95 'custom_container_inline' => true,
96 'fix_wiki_url' => true,
97 'generate_toc' => true,
98 'record_first_image' => true,
99 ],
100 'osu_markdown' => [
101 'block_modifiers' => ['news'],
102 ],
103 ],
104 'store' => [
105 'commonmark' => [
106 'html_input' => 'allow',
107 ],
108 'osu_markdown' => [
109 'block_modifiers' => ['store'],
110 ],
111 ],
112 'store-product' => [
113 'osu_markdown' => [
114 'block_modifiers' => ['store-product'],
115 ],
116 ],
117 'store-product-small' => [
118 'osu_markdown' => [
119 'block_modifiers' => ['store-product', 'store-product-small'],
120 ],
121 ],
122 'wiki' => [
123 'osu_extension' => [
124 'attributes_allowed' => ['flag', 'id'],
125 'custom_container_inline' => true,
126 'fix_wiki_url' => true,
127 'generate_toc' => true,
128 'style_block_allowed_classes' => ['infobox'],
129 'title_from_document' => true,
130 'with_gallery' => true,
131 ],
132 'osu_markdown' => [
133 'block_modifiers' => ['wiki'],
134 'enable_footnote' => true,
135 ],
136 ],
137 ];
138
139 private array $commonmarkConfig;
140 private array $osuExtensionConfig;
141 private array $osuMarkdownConfig;
142
143 private $document = '';
144 private $firstImage;
145 private $header;
146 private $toc;
147
148 private $htmlConverterAndExtension;
149 private $indexableConverter;
150
151 public static function parseYamlHeader($input)
152 {
153 $hasMatch = preg_match('#^(?:---\n(?<header>.+?)\n(?:---|\.\.\.)\n)(?<document>.+)$#s', $input, $matches);
154
155 if ($hasMatch === 1) {
156 try {
157 $header = Yaml::parse($matches['header']);
158 } catch (YamlParseException $_e) {
159 // ignores error
160 }
161
162 if (!is_array($header ?? null)) {
163 $header = null;
164 }
165
166 $document = $matches['document'];
167 }
168
169 return [
170 'document' => $document ?? $input,
171 'header' => $header ?? [],
172 ];
173 }
174
175 public function __construct(
176 $preset,
177 $commonmarkConfig = [],
178 $osuExtensionConfig = [],
179 $osuMarkdownConfig = [],
180 ) {
181 $presetConfig = static::PRESETS[$preset];
182
183 $this->commonmarkConfig = array_merge(
184 static::DEFAULT_COMMONMARK_CONFIG,
185 $presetConfig['commonmark'] ?? [],
186 $commonmarkConfig,
187 );
188
189 $this->osuExtensionConfig = array_merge(
190 static::DEFAULT_OSU_EXTENSION_CONFIG,
191 $presetConfig['osu_extension'] ?? [],
192 $osuExtensionConfig,
193 );
194
195 $this->osuMarkdownConfig = array_merge(
196 static::DEFAULT_OSU_MARKDOWN_CONFIG,
197 $presetConfig['osu_markdown'] ?? [],
198 $osuMarkdownConfig,
199 );
200 }
201
202 public function html(): string
203 {
204 return $this->memoize(__FUNCTION__, function () {
205 [$converter, $osuExtension] = $this->getHtmlConverterAndExtension();
206
207 $blockClass = class_with_modifiers(
208 $this->osuExtensionConfig['block_name'],
209 $this->osuMarkdownConfig['block_modifiers'],
210 );
211 $converted = $converter->convert($this->document)->getContent();
212 $processor = $osuExtension->processor;
213
214 if ($this->osuExtensionConfig['title_from_document']) {
215 $this->header['title'] = $processor->title;
216 }
217
218 $this->firstImage = $processor->firstImage;
219 $this->toc = $processor->toc;
220
221 return "<div class='{$blockClass}'>{$converted}</div>";
222 });
223 }
224
225 public function load($rawInput)
226 {
227 $this->resetMemoized();
228
229 $rawInput = strip_utf8_bom($rawInput);
230
231 if ($this->osuMarkdownConfig['parse_yaml_header']) {
232 $parsed = static::parseYamlHeader($rawInput);
233 $this->document = $parsed['document'];
234 $this->header = $parsed['header'];
235 } else {
236 $this->document = $rawInput;
237 $this->header = [];
238 }
239
240 $this->document = $this->document ?? '';
241
242 return $this;
243 }
244
245 public function toArray()
246 {
247 $html = $this->html();
248
249 return [
250 'firstImage' => $this->firstImage,
251 'header' => $this->header,
252 'output' => $html,
253 'toc' => $this->toc,
254 ];
255 }
256
257 public function toIndexable(): string
258 {
259 return $this->memoize(__FUNCTION__, function () {
260 return $this->getIndexableConverter()->convert($this->document)->getContent();
261 });
262 }
263
264 private function getHtmlConverterAndExtension(): array
265 {
266 if ($this->htmlConverterAndExtension === null) {
267 $extraConfig = [
268 'osu_extension' => $this->osuExtensionConfig,
269 'default_attributes' => $this->createDefaultAttributesConfig(),
270 ];
271
272 if ($this->osuMarkdownConfig['enable_footnote']) {
273 $extraConfig['footnote'] = $this->createFootnoteConfig();
274 }
275
276 $environment = $this->createEnvironment($extraConfig);
277 $environment->addExtension(new DefaultAttributesExtension());
278
279 $osuExtension = new Osu\Extension();
280 $environment->addExtension($osuExtension);
281
282 $this->htmlConverterAndExtension = [
283 new MarkdownConverter($environment),
284 $osuExtension,
285 ];
286 }
287
288 return $this->htmlConverterAndExtension;
289 }
290
291 private function getIndexableConverter(): MarkdownConverter
292 {
293 if ($this->indexableConverter === null) {
294 $environment = $this->createEnvironment(['osu_extension' => $this->osuExtensionConfig]);
295 $environment->addExtension(new Indexing\Extension());
296
297 $this->indexableConverter = new MarkdownConverter($environment);
298 }
299
300 return $this->indexableConverter;
301 }
302
303 private function createEnvironment(array $extraConfig = []): Environment
304 {
305 $config = array_merge($this->commonmarkConfig, $extraConfig);
306
307 $environment = new Environment($config);
308 $environment->addExtension(new CommonMarkCoreExtension());
309 $environment->addExtension(new TableExtension());
310 $environment->addExtension(new StrikethroughExtension());
311
312 if ($this->osuExtensionConfig['custom_container_inline']) {
313 $environment->addExtension(new CustomContainerInlineExtension());
314 }
315
316 if ($this->osuExtensionConfig['style_block_allowed_classes'] !== null) {
317 $environment->addExtension(new StyleBlock\Extension());
318 }
319
320 if ($this->osuMarkdownConfig['enable_footnote']) {
321 $environment->addExtension(new FootnoteExtension());
322 }
323
324 if ($this->osuMarkdownConfig['enable_autolink']) {
325 $environment->addExtension(new AutolinkExtension());
326 }
327
328 return $environment;
329 }
330
331 private function createDefaultAttributesConfig(): array
332 {
333 $blockClass = $this->osuExtensionConfig['block_name'];
334
335 return [
336 Heading::class => [
337 'class' => static fn (Heading $node) => class_with_modifiers(
338 "{$blockClass}__header",
339 [$node->getLevel()],
340 ),
341 ],
342 Image::class => [
343 'class' => "{$blockClass}__image",
344 ],
345 Link::class => [
346 'class' => "{$blockClass}__link",
347 ],
348 ListBlock::class => [
349 'class' => static fn (ListBlock $node) => class_with_modifiers(
350 "{$blockClass}__list",
351 ['ordered' => $node->getListData()->type === ListBlock::TYPE_ORDERED]
352 ),
353 'style' => static function (ListBlock $node) {
354 if ($node->getListData()->type === ListBlock::TYPE_ORDERED) {
355 $start = ($node->getListData()->start ?? 1) - 1;
356 return "--list-start: {$start}";
357 }
358 return null;
359 },
360 ],
361 ListItem::class => [
362 'class' => "{$blockClass}__list-item",
363 ],
364 Paragraph::class => [
365 'class' => "{$blockClass}__paragraph",
366 ],
367 StyleBlock\Element::class => [
368 'class' => static fn (StyleBlock\Element $node) => "{$blockClass}__{$node->getClassName()}",
369 ],
370 Table::class => [
371 'class' => "{$blockClass}__table",
372 ],
373 TableCell::class => [
374 'class' => static fn (TableCell $node) => class_with_modifiers(
375 "{$blockClass}__table-data",
376 [
377 $node->getAlign() => $node->getAlign() !== null,
378 'header' => $node->getType() === TableCell::TYPE_HEADER,
379 ]
380 ),
381 ],
382 ];
383 }
384
385 private function createFootnoteConfig()
386 {
387 $blockClass = $this->osuExtensionConfig['block_name'];
388
389 return [
390 'backref_class' => "{$blockClass}__link",
391 'backref_symbol' => '↑',
392 'container_add_hr' => false,
393 'container_class' => "{$blockClass}__footnote-container",
394 'footnote_class' => "{$blockClass}__list-item {$blockClass}__list-item--footnote",
395 'footnote_id_prefix' => 'fn-',
396 'ref_class' => "{$blockClass}__link {$blockClass}__link--footnote-ref js-reference-link",
397 'ref_id_prefix' => 'fnref-',
398 ];
399 }
400}