the browser-facing portion of osu!
at master 400 lines 13 kB view raw
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}