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}