Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
65.71% |
115 / 175 |
|
41.18% |
7 / 17 |
CRAP | |
0.00% |
0 / 1 |
WikiSEO | |
65.71% |
115 / 175 |
|
41.18% |
7 / 17 |
171.52 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
setMetadataFromPageProps | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
2 | |||
setMetadata | |
75.00% |
9 / 12 |
|
0.00% |
0 / 1 |
6.56 | |||
getMetadataValue | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
addMetadataToPage | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
4 | |||
setMetadataGenerators | |
0.00% |
0 / 22 |
|
0.00% |
0 / 1 |
12 | |||
loadPagePropsFromDb | |
63.64% |
7 / 11 |
|
0.00% |
0 / 1 |
4.77 | |||
loadPagePropsFromOutputPage | |
90.91% |
10 / 11 |
|
0.00% |
0 / 1 |
5.02 | |||
instantiateMetadataPlugins | |
71.43% |
5 / 7 |
|
0.00% |
0 / 1 |
3.21 | |||
finalize | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
2 | |||
makeErrorHtml | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
modifyPageTitle | |
95.24% |
20 / 21 |
|
0.00% |
0 / 1 |
8 | |||
saveMetadataToProps | |
91.67% |
11 / 12 |
|
0.00% |
0 / 1 |
5.01 | |||
mergeValidParameterNames | |
0.00% |
0 / 17 |
|
0.00% |
0 / 1 |
2 | |||
fromTag | |
81.82% |
9 / 11 |
|
0.00% |
0 / 1 |
2.02 | |||
fromParserFunction | |
63.16% |
12 / 19 |
|
0.00% |
0 / 1 |
4.80 | |||
protocolizeUrl | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 |
1 | <?php |
2 | /** |
3 | * This program is free software; you can redistribute it and/or modify |
4 | * it under the terms of the GNU General Public License as published by |
5 | * the Free Software Foundation; either version 2 of the License, or |
6 | * (at your option) any later version. |
7 | * |
8 | * This program is distributed in the hope that it will be useful, |
9 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
10 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
11 | * GNU General Public License for more details. |
12 | * |
13 | * You should have received a copy of the GNU General Public License along |
14 | * with this program; if not, write to the Free Software Foundation, Inc., |
15 | * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. |
16 | * |
17 | * @file |
18 | */ |
19 | |
20 | declare( strict_types=1 ); |
21 | |
22 | namespace MediaWiki\Extension\WikiSEO; |
23 | |
24 | use ConfigException; |
25 | use ExtensionRegistry; |
26 | use MediaWiki\Extension\WikiSEO\Generator\GeneratorInterface; |
27 | use MediaWiki\Extension\WikiSEO\Generator\MetaTag; |
28 | use MediaWiki\MediaWikiServices; |
29 | use OutputPage; |
30 | use PageImages\PageImages; |
31 | use Parser; |
32 | use ParserOutput; |
33 | use PPFrame; |
34 | use ReflectionClass; |
35 | use ReflectionException; |
36 | use Title; |
37 | use WebRequest; |
38 | use Wikimedia\AtEase\AtEase; |
39 | |
40 | class WikiSEO { |
41 | private const MODE_TAG = 'tag'; |
42 | private const MODE_PARSER = 'parser'; |
43 | |
44 | /** |
45 | * @var string 'tag' or 'parser' used to determine the error message |
46 | */ |
47 | private $mode; |
48 | |
49 | /** |
50 | * prepend, append or replace the new title to the existing title |
51 | * |
52 | * @var string |
53 | */ |
54 | private $titleMode = 'replace'; |
55 | |
56 | /** |
57 | * the separator to use when using append or prepend modes |
58 | * |
59 | * @var string |
60 | */ |
61 | private $titleSeparator = ' - '; |
62 | |
63 | /** |
64 | * @var string[] Array with generator names |
65 | */ |
66 | private $generators; |
67 | |
68 | /** |
69 | * @var GeneratorInterface[] |
70 | */ |
71 | private $generatorInstances = []; |
72 | |
73 | /** |
74 | * @var string[] Possible error messages |
75 | */ |
76 | private $errors = []; |
77 | |
78 | /** |
79 | * @var array |
80 | */ |
81 | private $metadata = []; |
82 | |
83 | /** |
84 | * WikiSEO constructor. |
85 | * Loads generator names from LocalSettings |
86 | * |
87 | * @param string $mode the parser mode |
88 | */ |
89 | public function __construct( $mode = self::MODE_PARSER ) { |
90 | $this->setMetadataGenerators(); |
91 | |
92 | $this->mode = $mode; |
93 | } |
94 | |
95 | /** |
96 | * Set the metadata by loading the page props from the db or the OutputPage object |
97 | * |
98 | * @param OutputPage $outputPage |
99 | */ |
100 | public function setMetadataFromPageProps( OutputPage $outputPage ): void { |
101 | if ( $outputPage->getTitle() === null ) { |
102 | $this->errors[] = wfMessage( 'wiki-seo-missing-page-title' ); |
103 | |
104 | return; |
105 | } |
106 | |
107 | $result = |
108 | $this->loadPagePropsFromDb( $outputPage->getTitle() ) ?? |
109 | $this->loadPagePropsFromOutputPage( $outputPage ) ?? []; |
110 | |
111 | $this->setMetadata( $result ); |
112 | } |
113 | |
114 | /** |
115 | * Set an array with metadata key value pairs |
116 | * Gets validated by Validator |
117 | * |
118 | * @param array $metadataArray |
119 | * @param ParserOutput|null $out ParserOutput is used to set a extension data flag to disable auto description, |
120 | * even when the flag is active. |
121 | * The reason is, if a description was provided and does not equal 'auto' or 'textextracts' we want to use it. |
122 | * @see Validator |
123 | */ |
124 | public function setMetadata( array $metadataArray, ParserOutput $out = null ): void { |
125 | $validMetadata = []; |
126 | |
127 | // We'll set a flag to don't overwrite manual descriptions |
128 | // If the AutoDescription setting is set |
129 | if ( $out !== null ) { |
130 | if ( isset( $metadataArray['manualDescription'] ) && |
131 | !in_array( $metadataArray['manualDescription'], [ 'auto', 'textextracts' ], true ) ) { |
132 | |
133 | $out->setPageProperty( 'manualDescription', true ); |
134 | |
135 | $metadataArray['description'] = $metadataArray['manualDescription']; |
136 | unset( $metadataArray['manualDescription'] ); |
137 | } else { |
138 | $out->unsetPageProperty( 'manualDescription' ); |
139 | } |
140 | } |
141 | |
142 | foreach ( Validator::validateParams( $metadataArray ) as $k => $v ) { |
143 | if ( !empty( $v ) ) { |
144 | $validMetadata[$k] = $v; |
145 | } |
146 | } |
147 | |
148 | $this->metadata = $validMetadata; |
149 | } |
150 | |
151 | /** |
152 | * Get a value from the $metadata array, given a key (or null if |
153 | * no value exists). |
154 | * |
155 | * @param string $key |
156 | * @return string|null |
157 | */ |
158 | public function getMetadataValue( $key ) { |
159 | return $this->metadata[$key] ?? null; |
160 | } |
161 | |
162 | /** |
163 | * Add the metadata array as meta tags to the page |
164 | * |
165 | * @param OutputPage $out |
166 | */ |
167 | public function addMetadataToPage( OutputPage $out ): void { |
168 | if ( $out->isArticle() && !isset( $out->getRequest()->getQueryValues()['diff'] ) ) { |
169 | $this->modifyPageTitle( $out ); |
170 | } |
171 | |
172 | MediaWikiServices::getInstance()->getHookContainer()->run( |
173 | 'WikiSEOPreAddMetadata', |
174 | [ |
175 | &$this->metadata, |
176 | ] |
177 | ); |
178 | |
179 | foreach ( $this->generatorInstances as $generatorInstance ) { |
180 | $generatorInstance->init( $this->metadata, $out ); |
181 | $generatorInstance->addMetadata(); |
182 | } |
183 | } |
184 | |
185 | /** |
186 | * Set active metadata generators defined in $wgMetdataGenerators |
187 | * And merges all valid parameter names from the generator to the validator |
188 | */ |
189 | private function setMetadataGenerators(): void { |
190 | $defaultGenerators = [ |
191 | 'OpenGraph', |
192 | 'Twitter', |
193 | 'SchemaOrg', |
194 | ]; |
195 | |
196 | try { |
197 | $generators = MediaWikiServices::getInstance() |
198 | ->getConfigFactory() |
199 | ->makeConfig( 'WikiSEO' ) |
200 | ->get( 'MetadataGenerators' ); |
201 | |
202 | if ( empty( $generators ) ) { |
203 | $generators = $defaultGenerators; |
204 | } |
205 | } catch ( ConfigException $e ) { |
206 | wfLogWarning( |
207 | sprintf( |
208 | 'Could not get config for "$wgMetadataGenerators", using default. %s', |
209 | $e->getMessage() |
210 | ) |
211 | ); |
212 | |
213 | $generators = $defaultGenerators; |
214 | } |
215 | |
216 | $this->generators = $generators; |
217 | $this->instantiateMetadataPlugins(); |
218 | $this->mergeValidParameterNames(); |
219 | } |
220 | |
221 | /** |
222 | * Loads all page props for the given page with pp_propnames in Validator::getValidParams() |
223 | * |
224 | * @param Title $title |
225 | * @return null|array Null if empty |
226 | * @see Validator::getValidParams() |
227 | */ |
228 | private function loadPagePropsFromDb( Title $title ): ?array { |
229 | $properties = MediaWikiServices::getInstance()->getPageProps()->getProperties( |
230 | $title, |
231 | Validator::getValidParams() |
232 | ); |
233 | |
234 | $properties = array_shift( $properties ); |
235 | |
236 | if ( $properties === null || count( $properties ) === 0 ) { |
237 | return null; |
238 | } |
239 | |
240 | $result = []; |
241 | |
242 | foreach ( $properties as $key => $value ) { |
243 | $result[$key] = $value; |
244 | } |
245 | |
246 | return $result; |
247 | } |
248 | |
249 | /** |
250 | * Tries to load the page props from OutputPage with keys from Validator::getValidParams() |
251 | * |
252 | * @see Validator::getValidParams() |
253 | * |
254 | * @param OutputPage $page |
255 | * @return array|null |
256 | */ |
257 | private function loadPagePropsFromOutputPage( OutputPage $page ): ?array { |
258 | $result = []; |
259 | |
260 | foreach ( Validator::getValidParams() as $param ) { |
261 | $prop = $page->getProperty( $param ); |
262 | |
263 | if ( $prop !== null ) { |
264 | AtEase::suppressWarnings(); |
265 | $value = unserialize( $prop, [ 'allowed_classes' => false ] ); |
266 | AtEase::restoreWarnings(); |
267 | |
268 | // Value was serialized |
269 | if ( $value !== false ) { |
270 | $prop = $value; |
271 | } |
272 | |
273 | $result[$param] = $prop; |
274 | } |
275 | } |
276 | |
277 | return empty( $result ) ? null : $result; |
278 | } |
279 | |
280 | /** |
281 | * Instantiates the metadata generators from $wgMetadataGenerators |
282 | */ |
283 | private function instantiateMetadataPlugins(): void { |
284 | $this->generatorInstances[] = new MetaTag(); |
285 | |
286 | foreach ( $this->generators as $generator ) { |
287 | $classPath = "MediaWiki\\Extension\\WikiSEO\\Generator\\Plugins\\$generator"; |
288 | |
289 | try { |
290 | $class = new ReflectionClass( $classPath ); |
291 | $this->generatorInstances[] = $class->newInstance(); |
292 | } catch ( ReflectionException $e ) { |
293 | $this->errors[] = wfMessage( 'wiki-seo-invalid-generator', $generator )->parse(); |
294 | } |
295 | } |
296 | } |
297 | |
298 | /** |
299 | * Finalize everything. |
300 | * Check for errors and save to props if everything is ok. |
301 | * |
302 | * @param ParserOutput $output |
303 | * |
304 | * @return string String with errors that happened or empty |
305 | */ |
306 | public function finalize( ParserOutput $output ): string { |
307 | if ( empty( $this->metadata ) ) { |
308 | $message = sprintf( 'wiki-seo-empty-attr-%s', $this->mode ); |
309 | $this->errors[] = wfMessage( $message ); |
310 | |
311 | return $this->makeErrorHtml(); |
312 | } |
313 | |
314 | $this->saveMetadataToProps( $output ); |
315 | |
316 | return ''; |
317 | } |
318 | |
319 | /** |
320 | * @return string Concatenated error strings |
321 | */ |
322 | private function makeErrorHtml(): string { |
323 | $text = implode( '<br>', $this->errors ); |
324 | |
325 | return sprintf( '<div class="errorbox">%s</div>', $text ); |
326 | } |
327 | |
328 | /** |
329 | * Modifies the page title based on 'titleMode' |
330 | * |
331 | * @param OutputPage $out |
332 | */ |
333 | private function modifyPageTitle( OutputPage $out ): void { |
334 | if ( !array_key_exists( 'title', $this->metadata ) ) { |
335 | return; |
336 | } |
337 | |
338 | $metaTitle = $this->metadata['title']; |
339 | |
340 | if ( array_key_exists( 'title_separator', $this->metadata ) ) { |
341 | $this->titleSeparator = $this->metadata['title_separator']; |
342 | } |
343 | |
344 | if ( array_key_exists( 'title_mode', $this->metadata ) ) { |
345 | $this->titleMode = $this->metadata['title_mode']; |
346 | } |
347 | |
348 | $strippedTitle = strip_tags( $out->getPageTitle() ); |
349 | |
350 | switch ( $this->titleMode ) { |
351 | case 'append': |
352 | $pageTitle = sprintf( '%s%s%s', $strippedTitle, $this->titleSeparator, $metaTitle ); |
353 | break; |
354 | |
355 | case 'prepend': |
356 | $pageTitle = sprintf( '%s%s%s', $metaTitle, $this->titleSeparator, $strippedTitle ); |
357 | break; |
358 | |
359 | case 'replace': |
360 | default: |
361 | $pageTitle = $metaTitle; |
362 | break; |
363 | } |
364 | |
365 | $pageTitle = preg_replace( "/[\r\n]/", '', $pageTitle ); |
366 | $pageTitle = html_entity_decode( $pageTitle, ENT_QUOTES ); |
367 | |
368 | $out->setHTMLTitle( $pageTitle ); |
369 | } |
370 | |
371 | /** |
372 | * Save the metadata array json encoded to the page props table |
373 | * |
374 | * @param ParserOutput $outputPage |
375 | */ |
376 | private function saveMetadataToProps( ParserOutput $outputPage ): void { |
377 | MediaWikiServices::getInstance()->getHookContainer()->run( |
378 | 'WikiSEOPreAddPageProps', |
379 | [ |
380 | &$this->metadata, |
381 | ] |
382 | ); |
383 | |
384 | foreach ( $this->metadata as $key => $value ) { |
385 | if ( $outputPage->getPageProperty( $key ) === null ) { |
386 | $outputPage->setPageProperty( $key, $value ); |
387 | } |
388 | |
389 | if ( ExtensionRegistry::getInstance()->isLoaded( 'PageImages' ) && |
390 | $key === 'image' ) { |
391 | $outputPage->setPageProperty( PageImages::PROP_NAME_FREE, $value ); |
392 | } |
393 | } |
394 | } |
395 | |
396 | /** |
397 | * Adds valid tags from all generator instances to the Validator |
398 | * Automatically called after instantiating all active generators |
399 | */ |
400 | private function mergeValidParameterNames(): void { |
401 | Validator::$validParameterNames = array_unique( |
402 | array_merge( |
403 | Validator::$validParameterNames, |
404 | array_reduce( |
405 | array_map( |
406 | static function ( GeneratorInterface $generator ) { |
407 | return $generator->getAllowedParameterNames(); |
408 | }, |
409 | $this->generatorInstances |
410 | ), |
411 | static function ( array $carry, array $item ) { |
412 | return array_merge( $carry, $item ); |
413 | }, |
414 | [] |
415 | ) |
416 | ) |
417 | ); |
418 | } |
419 | |
420 | /** |
421 | * Parse the values input from the <seo> tag extension |
422 | * |
423 | * @param string|null $input The text content of the tag |
424 | * @param array $args The HTML attributes of the tag |
425 | * @param Parser $parser The active Parser instance |
426 | * @param PPFrame $frame |
427 | * |
428 | * @return string The HTML comments of cached attributes |
429 | */ |
430 | public static function fromTag( ?string $input, array $args, Parser $parser, PPFrame $frame ): string { |
431 | $seo = new WikiSEO( self::MODE_TAG ); |
432 | $tagParser = new TagParser(); |
433 | |
434 | $parsedInput = $tagParser->parseText( $input, $parser, $frame ); |
435 | $tags = array_merge( $parsedInput, $args ); |
436 | $tags = $tagParser->expandWikiTextTagArray( $tags, $parser, $frame ); |
437 | |
438 | if ( isset( $tags['description'] ) ) { |
439 | $tags['manualDescription'] = $tags['description']; |
440 | unset( $tags['description'] ); |
441 | } |
442 | $tags = array_merge( $seo->loadPagePropsFromDb( $frame->getTitle() ) ?? [], $tags ); |
443 | |
444 | $seo->setMetadata( $tags, $parser->getOutput() ); |
445 | |
446 | return $seo->finalize( $parser->getOutput() ); |
447 | } |
448 | |
449 | /** |
450 | * Parse the values input from the {{#seo}} parser function |
451 | * |
452 | * @param Parser $parser The active Parser instance |
453 | * @param PPFrame $frame Frame |
454 | * @param array $args Arguments |
455 | * |
456 | * @return array Parser options and the HTML comments of cached attributes |
457 | */ |
458 | public static function fromParserFunction( $parser, PPFrame $frame, array $args ): array { |
459 | $expandedArgs = []; |
460 | |
461 | foreach ( $args as $arg ) { |
462 | $expandedArgs[] = trim( $frame->expand( $arg ) ); |
463 | } |
464 | |
465 | $seo = new WikiSEO( self::MODE_PARSER ); |
466 | $tagParser = new TagParser(); |
467 | |
468 | $args = $tagParser->parseArgs( $expandedArgs, $parser, $frame ); |
469 | if ( isset( $args['description'] ) ) { |
470 | $args['manualDescription'] = $args['description']; |
471 | unset( $args['description'] ); |
472 | } |
473 | |
474 | $args = array_merge( $seo->loadPagePropsFromDb( $frame->getTitle() ) ?? [], $args ); |
475 | |
476 | $seo->setMetadata( $args, $parser->getOutput() ); |
477 | |
478 | $fin = $seo->finalize( $parser->getOutput() ); |
479 | if ( !empty( $fin ) ) { |
480 | return [ |
481 | $fin, |
482 | 'noparse' => true, |
483 | 'isHTML' => true, |
484 | ]; |
485 | } |
486 | |
487 | // See https://github.com/octfx/wiki-seo/issues/30 |
488 | return [ '<!-- WikiSEO -->' ]; |
489 | } |
490 | |
491 | /** |
492 | * Add the server protocol to the URL if it is missing |
493 | * |
494 | * @param string $url URL from getFullURL() |
495 | * @param WebRequest $request |
496 | * |
497 | * @return string |
498 | */ |
499 | public static function protocolizeUrl( string $url, WebRequest $request ): string { |
500 | if ( parse_url( $url, PHP_URL_SCHEME ) === null ) { |
501 | $url = sprintf( '%s:%s', $request->getProtocol(), $url ); |
502 | } |
503 | |
504 | return $url; |
505 | } |
506 | } |