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