Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.78% covered (success)
98.78%
81 / 82
88.89% covered (warning)
88.89%
8 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
ParserCoreTagHooks
98.78% covered (success)
98.78%
81 / 82
88.89% covered (warning)
88.89%
8 / 9
23
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
 register
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 pre
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
2
 html
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
3.01
 nowiki
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 gallery
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 indicator
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 langconvert
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
7
 strictlyValidateBcp47Variant
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2/**
3 * Tag hooks provided by MediaWiki core
4 *
5 * @license GPL-2.0-or-later
6 * @file
7 * @ingroup Parser
8 */
9
10namespace MediaWiki\Parser;
11
12use MediaWiki\Config\ServiceOptions;
13use MediaWiki\Html\Html;
14use MediaWiki\Language\LanguageCode;
15use MediaWiki\Language\LanguageConverterFactory;
16use MediaWiki\Language\LanguageFactory;
17use MediaWiki\MainConfigNames;
18use UnexpectedValueException;
19use Wikimedia\StringUtils\StringUtils;
20
21/**
22 * Various tag hooks, registered in every Parser
23 * @ingroup Parser
24 * @since 1.46
25 */
26class ParserCoreTagHooks {
27
28    /**
29     * @internal For use by ServiceWiring
30     */
31    public const CONSTRUCTOR_OPTIONS = [
32        // See documentation for the corresponding config options
33        MainConfigNames::RawHtml,
34    ];
35
36    /**
37     * @internal
38     */
39    public function __construct(
40        private readonly ServiceOptions $svcOptions,
41        private readonly LanguageFactory $languageFactory,
42        private readonly LanguageConverterFactory $languageConverterFactory,
43    ) {
44        $svcOptions->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
45    }
46
47    /**
48     * @internal
49     */
50    public function register( Parser $parser ): void {
51        $rawHtml = $this->svcOptions->get( MainConfigNames::RawHtml );
52        $parser->setHook( 'pre', $this->pre( ... ) );
53        $parser->setHook( 'nowiki', $this->nowiki( ... ) );
54        $parser->setHook( 'gallery', $this->gallery( ... ) );
55        $parser->setHook( 'indicator', $this->indicator( ... ) );
56        $parser->setHook( 'langconvert', $this->langconvert( ... ) );
57        if ( $rawHtml ) {
58            $parser->setHook( 'html', $this->html( ... ) );
59        }
60    }
61
62    /**
63     * Core parser tag hook function for 'pre'.
64     * Text is treated roughly as 'nowiki' wrapped in an HTML 'pre' tag;
65     * valid HTML attributes are passed on.
66     *
67     * @param ?string $content
68     * @param array $attribs
69     * @param Parser $parser
70     * @param PPFrame $frame
71     * @return string HTML
72     * @internal
73     */
74    public function pre(
75        ?string $content, array $attribs, Parser $parser, PPFrame $frame
76    ): string {
77        $content ??= '';
78
79        if ( ( $attribs['format'] ?? '' ) === 'wikitext' ) {
80            // T348722: $frame is omitted here.  Editors should
81            // use the tag parser function if they want access
82            // to template args, for compatibility with Parsoid
83            $content = $parser->recursiveTagParse( $content );
84        } else {
85            // Backwards-compatibility hack
86            $content = StringUtils::delimiterReplace(
87                '<nowiki>', '</nowiki>', '$1', $content, 'i'
88            );
89
90            // We need to let both '"' and '&' through,
91            // for strip markers and entities respectively.
92            $content = str_replace(
93                [ '>', '<' ],
94                [ '&gt;', '&lt;' ],
95                $content
96            );
97        }
98
99        $attribs = array_map( $parser->killMarkers( ... ), $attribs );
100        $attribs = Sanitizer::validateTagAttributes( $attribs, 'pre' );
101
102        return Html::rawElement( 'pre', $attribs, $content );
103    }
104
105    /**
106     * Core parser tag hook function for 'html', used only when
107     * $wgRawHtml is enabled.
108     *
109     * This is potentially unsafe and should be used only in very careful
110     * circumstances, as the contents are emitted as raw HTML.
111     *
112     * Uses undocumented extended tag hook return values, introduced in r61913.
113     *
114     * @suppress SecurityCheck-XSS
115     * @param ?string $content
116     * @param array $attributes
117     * @param Parser $parser
118     * @return array|string Output of tag hook
119     * @internal
120     */
121    public function html( ?string $content, array $attributes, Parser $parser ) {
122        $rawHtml = $this->svcOptions->get( MainConfigNames::RawHtml );
123        if ( $rawHtml ) {
124            if ( $parser->getOptions()->getAllowUnsafeRawHtml() ) {
125                return [ $content ?? '', 'markerType' => 'nowiki' ];
126            } else {
127                // In a system message where raw html is
128                // not allowed (but it is allowed in other
129                // contexts).
130                return Html::element( 'span',
131                    [ 'class' => 'error' ],
132                    $parser->msg( 'rawhtml-notallowed' )->text()
133                );
134            }
135        } else {
136            throw new UnexpectedValueException( '<html> extension tag encountered unexpectedly' );
137        }
138    }
139
140    /**
141     * Core parser tag hook function for 'nowiki'. Text within this section
142     * gets interpreted as a string of text with HTML-compatible character
143     * references, and wiki markup within it will not be expanded.
144     *
145     * Uses undocumented extended tag hook return values, introduced in r61913.
146     *
147     * Used outside core by the Scribunto extension.
148     *
149     * Uses custom html escaping which phan-taint-check won't recognize
150     * hence we suppress the error.
151     * @suppress SecurityCheck-XSS
152     *
153     * @param ?string $content
154     * @param array $attributes
155     * @param Parser $parser
156     * @return array
157     */
158    public function nowiki( ?string $content, array $attributes, Parser $parser ): array {
159        $content = strtr( $content ?? '', [
160            // lang converter
161            '-{' => '-&#123;',
162            '}-' => '&#125;-',
163            // html tags
164            '<' => '&lt;',
165            '>' => '&gt;'
166            // Note: Both '"' and '&' are not converted.
167            // This allows strip markers and entities through.
168        ] );
169        return [ $content, 'markerType' => 'nowiki' ];
170    }
171
172    /**
173     * Core parser tag hook function for 'gallery'.
174     *
175     * Renders a thumbnail list of the given images, with optional captions.
176     * Full syntax documented on the wiki:
177     *
178     *   https://www.mediawiki.org/wiki/Help:Images#Gallery_syntax
179     *
180     * @todo break Parser::renderImageGallery out here too.
181     *
182     * @param ?string $content
183     * @param array $attributes
184     * @param Parser $parser
185     * @return string HTML
186     * @internal
187     */
188    public function gallery( ?string $content, array $attributes, Parser $parser ): string {
189        return $parser->renderImageGallery( $content ?? '', $attributes );
190    }
191
192    /**
193     * XML-style tag for page status indicators: icons (or short text snippets) usually displayed in
194     * the top-right corner of the page, outside of the main content.
195     *
196     * Used outside core by the CommunityRequests extension.
197     *
198     * @param ?string $content
199     * @param array $attributes
200     * @param Parser $parser
201     * @param PPFrame $frame
202     * @return string
203     */
204    public function indicator( ?string $content, array $attributes, Parser $parser, PPFrame $frame ): string {
205        if ( !isset( $attributes['name'] ) || trim( $attributes['name'] ) === '' ) {
206            return '<span class="error">' .
207                $parser->msg( 'invalid-indicator-name' )->parse() .
208                '</span>';
209        }
210
211        $parser->getOutput()->setIndicator(
212            trim( $parser->killMarkers( $attributes['name'] ) ),
213            Parser::stripOuterParagraph( $parser->recursiveTagParseFully( $content ?? '', $frame ) )
214        );
215
216        return '';
217    }
218
219    /**
220     * Returns content converted into the requested language variant, using LanguageConverter.
221     *
222     * @param ?string $content
223     * @param array $attributes
224     * @param Parser $parser
225     * @param PPFrame $frame
226     * @return array
227     * @internal
228     */
229    public function langconvert( ?string $content, array $attributes, Parser $parser, PPFrame $frame ): array {
230        $output = false;
231        if ( isset( $attributes['from'] ) && isset( $attributes['to'] ) ) {
232            $from = self::strictlyValidateBcp47Variant( $attributes['from'] );
233            $to = self::strictlyValidateBcp47Variant( $attributes['to'] );
234            // Verify these are both variants of the same base language!
235            if ( $from !== null && $to !== null && $from['parent']->equals( $to['parent'] ) ) {
236                $converter = $from['converter'];
237                $output = $converter->autoConvert(
238                    $parser->recursiveTagParse( $content ?? '', $frame ),
239                    $to['code']
240                );
241            }
242        }
243
244        if ( $output === false ) {
245            $output = Html::rawElement(
246                'span',
247                [ 'class' => 'error' ],
248                $parser->msg( 'invalid-langconvert-attrs' )->parse()
249            );
250        }
251        // Protect this from double-conversion.
252        return [ $output, 'markerType' => 'nowiki' ];
253    }
254
255    private function strictlyValidateBcp47Variant( string $arg ): ?array {
256        // allow whitespace around raw value
257        $arg = trim( $arg );
258        // Validate the language code against the allowed variants in the
259        // appropriate converter for the base language.
260        $lang = $this->languageFactory->getLanguage( $arg );
261        $baseLang = $this->languageFactory->getParentLanguage( $lang );
262        $converter = $this->languageConverterFactory->getLanguageConverter( $baseLang );
263        $variant = $converter->validateVariant( $lang->getCode() );
264        // Now strictly enforce the fact that the original argument was the
265        // BCP-47 code in particular (not a compatibility alias)
266        // This helps ensure that legacy compatibility aliases don't creep
267        // into new wikitext (since <langconvert> was a new extension).
268        if (
269            $variant === null ||
270            strcasecmp( LanguageCode::bcp47( $variant ), $arg ) !== 0
271        ) {
272            return null;
273        }
274        return [
275            'converter' => $converter,
276            'language' => $lang,
277            'parent' => $baseLang,
278            'code' => $variant
279        ];
280    }
281}