the browser-facing portion of osu!
at master 321 lines 10 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\Osu; 7 8use App\Libraries\LocaleMeta; 9use App\Libraries\OsuWiki; 10use League\CommonMark\Environment\EnvironmentBuilderInterface; 11use League\CommonMark\Event\DocumentParsedEvent; 12use League\CommonMark\Extension\CommonMark\Node\Block; 13use League\CommonMark\Extension\CommonMark\Node\Inline; 14use League\CommonMark\Node\Block\Paragraph; 15use League\CommonMark\Node\Inline\Text; 16use League\CommonMark\Node\Node; 17use League\CommonMark\Node\NodeWalkerEvent; 18use League\CommonMark\Node\StringContainerInterface; 19use League\Config\ConfigurationInterface; 20 21class DocumentProcessor 22{ 23 public ?string $firstImage; 24 public ?string $title; 25 public ?array $toc; 26 27 private ConfigurationInterface $config; 28 private ?NodeWalkerEvent $event; 29 private $node; 30 31 private int $figureIndex; 32 private ?string $galleryId; 33 private array $tocSlugs; 34 35 private ?string $relativeUrlRoot; 36 private ?string $wikiLocale; 37 private ?string $wikiPathToRoot = null; 38 private ?string $wikiAbsoluteRootPath = null; 39 40 public function __construct(EnvironmentBuilderInterface $environment) 41 { 42 $this->config = $environment->getConfiguration(); 43 } 44 45 public function __invoke(DocumentParsedEvent $event): void 46 { 47 $document = $event->getDocument(); 48 $walker = $document->walker(); 49 50 // The config value should come from route() call which means it's percent encoded 51 // but it'll be reused as parameter for another route() call so decode it here. 52 $relativeUrlRoot = $this->config->get('osu_extension/relative_url_root'); 53 $this->relativeUrlRoot = $relativeUrlRoot === null ? null : urldecode($relativeUrlRoot); 54 $fixWikiUrl = $this->config->get('osu_extension/fix_wiki_url'); 55 $generateToc = $this->config->get('osu_extension/generate_toc'); 56 $recordFirstImage = $this->config->get('osu_extension/record_first_image'); 57 $titleFromDocument = $this->config->get('osu_extension/title_from_document'); 58 $withGallery = $this->config->get('osu_extension/with_gallery'); 59 $this->wikiLocale = $this->config->get('osu_extension/wiki_locale'); 60 61 $this->setWikiPaths(); 62 63 $this->figureIndex = 0; 64 $this->firstImage = null; 65 $this->galleryId = null; 66 $this->title = null; 67 $this->toc = []; 68 $this->tocSlugs = []; 69 70 while (($this->event = $walker->next()) !== null) { 71 $this->node = $this->event->getNode(); 72 73 $this->updateLocaleLink(); 74 $this->fixRelativeUrl(); 75 76 if ($fixWikiUrl) { 77 $this->fixWikiUrl(); 78 } 79 80 if ($recordFirstImage) { 81 $this->recordFirstImage(); 82 } 83 84 if ($titleFromDocument) { 85 $this->setTitle(); 86 } 87 88 if ($generateToc) { 89 $this->loadToc(); 90 } 91 92 $this->proxyImage(); 93 94 $this->parseFigure($withGallery); 95 } 96 } 97 98 private function fixRelativeUrl() 99 { 100 if ($this->relativeUrlRoot === null) { 101 return; 102 } 103 104 if (!$this->event->isEntering() || !($this->node instanceof Inline\AbstractWebResource)) { 105 return; 106 } 107 108 $src = $this->node->getUrl(); 109 110 if (preg_match(',^(#|/|https?://|mailto:),', $src) !== 1) { 111 if (starts_with($src, './')) { 112 $src = substr($src, 2); 113 } 114 115 $this->node->setUrl($this->relativeUrlRoot.'/'.$src); 116 } 117 } 118 119 private function getText(Node $node, bool $trim = true): string 120 { 121 $text = ''; 122 123 foreach ($node->children() as $child) { 124 if ($child instanceof Inline\Image) { 125 continue; 126 } elseif ($child instanceof StringContainerInterface) { 127 $text .= $child->getLiteral(); 128 } else { 129 $text .= $this->getText($child, false); 130 } 131 } 132 133 if ($trim) { 134 $text = trim($text); 135 } 136 137 return $text; 138 } 139 140 private function loadToc() 141 { 142 if ( 143 !$this->node instanceof Block\Heading || 144 !$this->event->isEntering() || 145 ($level = $this->node->getLevel()) === 1 146 ) { 147 return; 148 } 149 150 $title = presence($this->getText($this->node)); 151 $slug = $this->node->data['attributes']['id'] 152 ?? presence(mb_strtolower(strtr($title ?? '', ' ', '-'))) 153 ?? 'page'; 154 155 if (array_key_exists($slug, $this->tocSlugs)) { 156 $this->tocSlugs[$slug] += 1; 157 158 $slug .= '.'.$this->tocSlugs[$slug]; 159 } else { 160 $this->tocSlugs[$slug] = 0; 161 } 162 163 if ($level <= 3) { 164 $this->toc[$slug] = compact('title', 'level'); 165 } 166 167 $this->node->data->set('attributes/id', $slug); 168 } 169 170 private function parseFigure($withGallery = false) 171 { 172 if (!$this->node instanceof Paragraph || $this->event->isEntering()) { 173 return; 174 } 175 176 if (count($this->node->children()) !== 1 || !$this->node->children()[0] instanceof Inline\Image) { 177 return; 178 } 179 180 $blockClass = $this->config->get('osu_extension/block_name'); 181 182 $image = $this->node->children()[0]; 183 $this->node->data->set('attributes/class', "{$blockClass}__figure-container"); 184 $image->data->set('attributes/class', "{$blockClass}__figure-image"); 185 186 if (present($image->getTitle() ?? null)) { 187 $text = new Text($image->getTitle()); 188 $textContainer = new Inline\Emphasis(); 189 $textContainer->data->set('attributes/class', "{$blockClass}__figure-caption"); 190 $textContainer->appendChild($text); 191 $this->node->appendChild($textContainer); 192 } 193 194 if ($withGallery) { 195 $this->galleryId ??= (string) rand(); 196 $imageUrl = $image->getUrl(); 197 198 if (starts_with($imageUrl, route('wiki.show', [], false))) { 199 $imageUrl = $GLOBALS['cfg']['app']['url'].$imageUrl; 200 } 201 202 $imageSize = fast_imagesize($imageUrl); 203 if (!isset($imageSize)) { 204 return; 205 } 206 207 $image->data->append('attributes/class', "{$blockClass}__figure-image--gallery js-gallery"); 208 $image->data->set('attributes/data-width', (string) $imageSize[0]); 209 $image->data->set('attributes/data-height', (string) $imageSize[1]); 210 $image->data->set('attributes/data-gallery-id', $this->galleryId); 211 $image->data->set('attributes/data-index', (string) $this->figureIndex); 212 $image->data->set('attributes/data-src', $imageUrl); 213 214 $this->figureIndex++; 215 } 216 } 217 218 private function fixWikiUrl() 219 { 220 if (!$this->event->isEntering() || !($this->node instanceof Inline\AbstractWebResource)) { 221 return; 222 } 223 224 $url = $this->node->getUrl(); 225 226 $url = preg_replace_callback(',^(?:/help)?/wiki/(?<locale>[^/?#]+)(?:/(?<path>[^?#]+))?(?<query>\?.*)?(?<hash>#.*)?$,', function ($matches) { 227 $matches['path'] = $matches['path'] ?? ''; 228 $matches['query'] = $matches['query'] ?? ''; 229 $matches['hash'] = $matches['hash'] ?? ''; 230 231 if (LocaleMeta::isValid($matches['locale'])) { 232 $locale = $matches['locale']; 233 $path = $matches['path']; 234 } else { 235 $path = concat_path([$matches['locale'], $matches['path']]); 236 } 237 238 if (OsuWiki::isImage($path)) { 239 $url = wiki_image_url($path, false); 240 } else { 241 $locale ??= $this->wikiLocale ?? $GLOBALS['cfg']['app']['fallback_locale']; 242 $url = wiki_url($path, $locale, false, false); 243 244 if (starts_with($url, $this->wikiAbsoluteRootPath)) { 245 $url = $this->wikiPathToRoot.substr($url, strlen($this->wikiAbsoluteRootPath)); 246 } 247 } 248 249 return "{$url}{$matches['query']}{$matches['hash']}"; 250 }, $url); 251 252 $this->node->setUrl($url); 253 } 254 255 private function proxyImage() 256 { 257 if (!$this->node instanceof Inline\Image || !$this->event->isEntering()) { 258 return; 259 } 260 261 $url = $this->node->getUrl(); 262 263 if (present($url)) { 264 $this->node->setUrl(proxy_media($url)); 265 } 266 } 267 268 private function recordFirstImage() 269 { 270 if ($this->firstImage !== null || !$this->node instanceof Inline\Image || !$this->event->isEntering()) { 271 return; 272 } 273 274 $this->firstImage = proxy_media($this->node->getUrl()); 275 } 276 277 private function setTitle() 278 { 279 // wait until leaving otherwise node->next will be null after detaching. 280 if (!$this->node instanceof Block\Heading || $this->event->isEntering() || $this->title !== null) { 281 return; 282 } 283 284 $this->title = presence($this->getText($this->node)); 285 } 286 287 private function updateLocaleLink() 288 { 289 if (!$this->node instanceof Inline\Link || !$this->event->isEntering()) { 290 return; 291 } 292 293 if (preg_match('#^(\w{2}(?:-\w{2})?):(.+)$#', $this->node->getUrl(), $matches) !== 1) { 294 return; 295 } 296 297 $this->node->setUrl("{$matches[2]}?locale={$matches[1]}"); 298 } 299 300 private function setWikiPaths() 301 { 302 if ($this->relativeUrlRoot === null || $this->wikiLocale === null) { 303 return; 304 } 305 306 $this->wikiAbsoluteRootPath = route('wiki.show', ['locale' => $this->wikiLocale], false).'/'; 307 308 if (starts_with($this->relativeUrlRoot, $this->wikiAbsoluteRootPath)) { 309 $relativeFromBase = substr($this->relativeUrlRoot, strlen($this->wikiAbsoluteRootPath)); 310 $slashes = substr_count($relativeFromBase, '/'); 311 312 if ($slashes === 0) { 313 $this->wikiPathToRoot = './'; 314 } else { 315 $this->wikiPathToRoot = implode('/', array_fill(0, $slashes, '..')).'/'; 316 } 317 } else { 318 $this->wikiPathToRoot = $this->wikiAbsoluteRootPath; 319 } 320 } 321}