Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
80.80% |
101 / 125 |
|
60.00% |
6 / 10 |
CRAP | |
0.00% |
0 / 1 |
Cite | |
80.80% |
101 / 125 |
|
60.00% |
6 / 10 |
51.32 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
16 / 16 |
|
100.00% |
1 / 1 |
1 | |||
ref | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
6 | |||
guardedRef | |
100.00% |
31 / 31 |
|
100.00% |
1 / 1 |
9 | |||
parseArguments | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
4 | |||
references | |
92.31% |
12 / 13 |
|
0.00% |
0 / 1 |
4.01 | |||
parseReferencesTagContent | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
30 | |||
formatReferencesErrors | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
20 | |||
formatReferences | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
2 | |||
checkRefsNoReferences | |
100.00% |
23 / 23 |
|
100.00% |
1 / 1 |
8 | |||
__clone | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 |
1 | <?php |
2 | |
3 | /** |
4 | * A parser extension that adds two tags, <ref> and <references> for adding |
5 | * citations to pages |
6 | * |
7 | * @ingroup Extensions |
8 | * |
9 | * Documentation |
10 | * @link https://www.mediawiki.org/wiki/Extension:Cite/Cite.php |
11 | * |
12 | * <cite> definition in HTML |
13 | * @link http://www.w3.org/TR/html4/struct/text.html#edef-CITE |
14 | * |
15 | * <cite> definition in XHTML 2.0 |
16 | * @link http://www.w3.org/TR/2005/WD-xhtml2-20050527/mod-text.html#edef_text_cite |
17 | * |
18 | * @bug https://phabricator.wikimedia.org/T6579 |
19 | * |
20 | * @author Ævar Arnfjörð Bjarmason <avarab@gmail.com> |
21 | * @copyright Copyright © 2005, Ævar Arnfjörð Bjarmason |
22 | * @license GPL-2.0-or-later |
23 | */ |
24 | |
25 | namespace Cite; |
26 | |
27 | use LogicException; |
28 | use MediaWiki\Html\Html; |
29 | use MediaWiki\MediaWikiServices; |
30 | use MediaWiki\Parser\Sanitizer; |
31 | use Parser; |
32 | use StatusValue; |
33 | |
34 | /** |
35 | * @license GPL-2.0-or-later |
36 | */ |
37 | class Cite { |
38 | |
39 | public const DEFAULT_GROUP = ''; |
40 | |
41 | /** |
42 | * Wikitext attribute name for Book Referencing. |
43 | */ |
44 | public const BOOK_REF_ATTRIBUTE = 'extends'; |
45 | |
46 | /** |
47 | * Page property key for the Book Referencing `extends` attribute. |
48 | */ |
49 | public const BOOK_REF_PROPERTY = 'ref-extends'; |
50 | |
51 | private bool $isSectionPreview; |
52 | private FootnoteMarkFormatter $footnoteMarkFormatter; |
53 | private ReferenceListFormatter $referenceListFormatter; |
54 | private ErrorReporter $errorReporter; |
55 | |
56 | /** |
57 | * True when a <ref> tag is being processed. |
58 | * Used to avoid infinite recursion |
59 | */ |
60 | private bool $inRefTag = false; |
61 | |
62 | /** |
63 | * @var null|string The current group name while parsing nested <ref> in <references>. Null when |
64 | * parsing <ref> outside of <references>. Warning, an empty string is a valid group name! |
65 | */ |
66 | private ?string $inReferencesGroup = null; |
67 | |
68 | /** |
69 | * Error stack used when defining refs in <references> |
70 | */ |
71 | private StatusValue $mReferencesErrors; |
72 | private ReferenceStack $referenceStack; |
73 | |
74 | public function __construct( Parser $parser ) { |
75 | $this->isSectionPreview = $parser->getOptions()->getIsSectionPreview(); |
76 | $messageLocalizer = new ReferenceMessageLocalizer( $parser->getContentLanguage() ); |
77 | $this->errorReporter = new ErrorReporter( $messageLocalizer ); |
78 | $this->mReferencesErrors = StatusValue::newGood(); |
79 | $this->referenceStack = new ReferenceStack(); |
80 | $anchorFormatter = new AnchorFormatter(); |
81 | $this->footnoteMarkFormatter = new FootnoteMarkFormatter( |
82 | $this->errorReporter, |
83 | $anchorFormatter, |
84 | $messageLocalizer |
85 | ); |
86 | $this->referenceListFormatter = new ReferenceListFormatter( |
87 | $this->errorReporter, |
88 | $anchorFormatter, |
89 | $messageLocalizer |
90 | ); |
91 | } |
92 | |
93 | /** |
94 | * Callback function for <ref> |
95 | * |
96 | * @param Parser $parser |
97 | * @param ?string $text Raw, untrimmed wikitext content of the <ref> tag, if any |
98 | * @param string[] $argv Arguments as given in <ref name=…>, already trimmed |
99 | * |
100 | * @return string|null Null in case a <ref> tag is not allowed in the current context |
101 | */ |
102 | public function ref( Parser $parser, ?string $text, array $argv ): ?string { |
103 | if ( $this->inRefTag ) { |
104 | return null; |
105 | } |
106 | |
107 | $this->inRefTag = true; |
108 | $ret = $this->guardedRef( $parser, $text, $argv ); |
109 | $this->inRefTag = false; |
110 | |
111 | return $ret; |
112 | } |
113 | |
114 | /** |
115 | * @param Parser $parser |
116 | * @param ?string $text Raw, untrimmed wikitext content of the <ref> tag, if any |
117 | * @param string[] $argv Arguments as given in <ref name=…>, already trimmed |
118 | * |
119 | * @return string HTML |
120 | */ |
121 | private function guardedRef( |
122 | Parser $parser, |
123 | ?string $text, |
124 | array $argv |
125 | ): string { |
126 | // Tag every page where Book Referencing has been used, whether or not the ref tag is valid. |
127 | // This code and the page property will be removed once the feature is stable. See T237531. |
128 | if ( array_key_exists( self::BOOK_REF_ATTRIBUTE, $argv ) ) { |
129 | $parser->getOutput()->setUnsortedPageProperty( self::BOOK_REF_PROPERTY ); |
130 | } |
131 | |
132 | $status = $this->parseArguments( |
133 | $argv, |
134 | [ 'group', 'name', self::BOOK_REF_ATTRIBUTE, 'follow', 'dir' ] |
135 | ); |
136 | $arguments = $status->getValue(); |
137 | // Use the default group, or the references group when inside one. |
138 | $arguments['group'] ??= $this->inReferencesGroup ?? self::DEFAULT_GROUP; |
139 | |
140 | $validator = new Validator( |
141 | $this->referenceStack, |
142 | $this->inReferencesGroup, |
143 | $this->isSectionPreview, |
144 | MediaWikiServices::getInstance()->getMainConfig()->get( 'CiteBookReferencing' ) |
145 | ); |
146 | // @phan-suppress-next-line PhanParamTooFewUnpack No good way to document it. |
147 | $status->merge( $validator->validateRef( $text, ...array_values( $arguments ) ) ); |
148 | |
149 | // Validation cares about the difference between null and empty, but from here on we don't |
150 | if ( $text !== null && trim( $text ) === '' ) { |
151 | $text = null; |
152 | } |
153 | |
154 | if ( $this->inReferencesGroup !== null ) { |
155 | if ( !$status->isGood() ) { |
156 | // We know we are in the middle of a <references> tag and can't display errors in place |
157 | $this->mReferencesErrors->merge( $status ); |
158 | } elseif ( $text !== null ) { |
159 | // Validation made sure we always have group and name while in <references> |
160 | $this->referenceStack->listDefinedRef( $arguments['group'], $arguments['name'], $text ); |
161 | } |
162 | return ''; |
163 | } |
164 | |
165 | if ( !$status->isGood() ) { |
166 | $this->referenceStack->pushInvalidRef(); |
167 | |
168 | // FIXME: If we ever have multiple errors, these must all be presented to the user, |
169 | // so they know what to correct. |
170 | // TODO: Make this nicer, see T238061 |
171 | return $this->errorReporter->firstError( $parser, $status ); |
172 | } |
173 | |
174 | // @phan-suppress-next-line PhanParamTooFewUnpack No good way to document it. |
175 | $ref = $this->referenceStack->pushRef( |
176 | $parser->getStripState(), $text, $argv, ...array_values( $arguments ) ); |
177 | return $ref |
178 | ? $this->footnoteMarkFormatter->linkRef( $parser, $arguments['group'], $ref ) |
179 | : ''; |
180 | } |
181 | |
182 | /** |
183 | * @param string[] $argv The argument vector |
184 | * @param string[] $allowedAttributes Allowed attribute names |
185 | * |
186 | * @return StatusValue Either an error, or has a value with the dictionary of field names and |
187 | * parsed or default values. Missing attributes will be `null`. |
188 | */ |
189 | private function parseArguments( array $argv, array $allowedAttributes ): StatusValue { |
190 | $expected = count( $allowedAttributes ); |
191 | $allValues = array_merge( array_fill_keys( $allowedAttributes, null ), $argv ); |
192 | if ( isset( $allValues['dir'] ) ) { |
193 | // @phan-suppress-next-line PhanTypeMismatchArgumentNullableInternal False positive |
194 | $allValues['dir'] = strtolower( $allValues['dir'] ); |
195 | } |
196 | |
197 | $status = StatusValue::newGood( array_slice( $allValues, 0, $expected ) ); |
198 | |
199 | if ( count( $allValues ) > $expected ) { |
200 | // A <ref> must have a name (can be null), but <references> can't have one |
201 | $status->fatal( in_array( 'name', $allowedAttributes, true ) |
202 | ? 'cite_error_ref_too_many_keys' |
203 | : 'cite_error_references_invalid_parameters' |
204 | ); |
205 | } |
206 | |
207 | return $status; |
208 | } |
209 | |
210 | /** |
211 | * Callback function for <references> |
212 | * |
213 | * @param Parser $parser |
214 | * @param ?string $text Raw, untrimmed wikitext content of the <references> tag, if any |
215 | * @param string[] $argv Arguments as given in <references …>, already trimmed |
216 | * |
217 | * @return string|null Null in case a <references> tag is not allowed in the current context |
218 | */ |
219 | public function references( Parser $parser, ?string $text, array $argv ): ?string { |
220 | if ( $this->inRefTag || $this->inReferencesGroup !== null ) { |
221 | return null; |
222 | } |
223 | |
224 | $status = $this->parseArguments( $argv, [ 'group', 'responsive' ] ); |
225 | $arguments = $status->getValue(); |
226 | |
227 | $this->inReferencesGroup = $arguments['group'] ?? self::DEFAULT_GROUP; |
228 | |
229 | $status->merge( $this->parseReferencesTagContent( $parser, $text ) ); |
230 | if ( !$status->isGood() ) { |
231 | $ret = $this->errorReporter->firstError( $parser, $status ); |
232 | } else { |
233 | $responsive = $arguments['responsive']; |
234 | $ret = $this->formatReferences( $parser, $this->inReferencesGroup, $responsive ); |
235 | // Append errors collected while {@see parseReferencesTagContent} processed <ref> tags |
236 | // in <references> |
237 | $ret .= $this->formatReferencesErrors( $parser ); |
238 | } |
239 | |
240 | $this->inReferencesGroup = null; |
241 | |
242 | return $ret; |
243 | } |
244 | |
245 | /** |
246 | * @param Parser $parser |
247 | * @param ?string $text Raw, untrimmed wikitext content of the <references> tag, if any |
248 | * |
249 | * @return StatusValue |
250 | */ |
251 | private function parseReferencesTagContent( Parser $parser, ?string $text ): StatusValue { |
252 | // Nothing to parse in an empty <references /> tag |
253 | if ( $text === null || trim( $text ) === '' ) { |
254 | return StatusValue::newGood(); |
255 | } |
256 | |
257 | if ( preg_match( '{' . preg_quote( Parser::MARKER_PREFIX ) . '-(?i:references)-}', $text ) ) { |
258 | return StatusValue::newFatal( 'cite_error_included_references' ); |
259 | } |
260 | |
261 | // Detect whether we were sent already rendered <ref>s. Mostly a side effect of using |
262 | // {{#tag:references}}. The following assumes that the parsed <ref>s sent within the |
263 | // <references> block were the most recent calls to <ref>. This assumption is true for |
264 | // all known use cases, but not strictly enforced by the parser. It is possible that |
265 | // some unusual combination of #tag, <references> and conditional parser functions could |
266 | // be created that would lead to malformed references here. |
267 | preg_match_all( '{' . preg_quote( Parser::MARKER_PREFIX ) . '-(?i:ref)-}', $text, $matches ); |
268 | $count = count( $matches[0] ); |
269 | |
270 | // Undo effects of calling <ref> while unaware of being contained in <references> |
271 | foreach ( $this->referenceStack->rollbackRefs( $count ) as $call ) { |
272 | // Rerun <ref> call with the <references> context now being known |
273 | $this->guardedRef( $parser, ...$call ); |
274 | } |
275 | |
276 | // Parse the <references> content to process any unparsed <ref> tags, but drop the resulting |
277 | // HTML |
278 | $parser->recursiveTagParse( $text ); |
279 | |
280 | return StatusValue::newGood(); |
281 | } |
282 | |
283 | private function formatReferencesErrors( Parser $parser ): string { |
284 | $html = ''; |
285 | foreach ( $this->mReferencesErrors->getErrors() as $error ) { |
286 | if ( $html ) { |
287 | $html .= "<br />\n"; |
288 | } |
289 | $html .= $this->errorReporter->halfParsed( $parser, $error['message'], ...$error['params'] ); |
290 | } |
291 | $this->mReferencesErrors = StatusValue::newGood(); |
292 | return $html ? "\n$html" : ''; |
293 | } |
294 | |
295 | /** |
296 | * @param Parser $parser |
297 | * @param string $group |
298 | * @param string|null $responsive Defaults to $wgCiteResponsiveReferences when not set |
299 | * |
300 | * @return string HTML |
301 | */ |
302 | private function formatReferences( |
303 | Parser $parser, |
304 | string $group, |
305 | string $responsive = null |
306 | ): string { |
307 | $responsiveReferences = MediaWikiServices::getInstance()->getMainConfig()->get( 'CiteResponsiveReferences' ); |
308 | |
309 | return $this->referenceListFormatter->formatReferences( |
310 | $parser, |
311 | $this->referenceStack->popGroup( $group ), |
312 | $responsive !== null ? $responsive !== '0' : $responsiveReferences, |
313 | $this->isSectionPreview |
314 | ); |
315 | } |
316 | |
317 | /** |
318 | * Called at the end of page processing to append a default references |
319 | * section, if refs were used without a main references tag. If there are references |
320 | * in a custom group, and there is no references tag for it, show an error |
321 | * message for that group. |
322 | * If we are processing a section preview, this adds the missing |
323 | * references tags and does not add the errors. |
324 | * |
325 | * @param Parser $parser |
326 | * @param bool $isSectionPreview |
327 | * |
328 | * @return string HTML |
329 | */ |
330 | public function checkRefsNoReferences( Parser $parser, bool $isSectionPreview ): string { |
331 | $s = ''; |
332 | foreach ( $this->referenceStack->getGroups() as $group ) { |
333 | if ( $group === self::DEFAULT_GROUP || $isSectionPreview ) { |
334 | $s .= $this->formatReferences( $parser, $group ); |
335 | } else { |
336 | $s .= '<br />' . $this->errorReporter->halfParsed( |
337 | $parser, |
338 | 'cite_error_group_refs_without_references', |
339 | Sanitizer::safeEncodeAttribute( $group ) |
340 | ); |
341 | } |
342 | } |
343 | if ( $isSectionPreview && $s !== '' ) { |
344 | $headerMsg = wfMessage( 'cite_section_preview_references' ); |
345 | if ( !$headerMsg->isDisabled() ) { |
346 | $s = Html::element( |
347 | 'h2', |
348 | [ 'id' => 'mw-ext-cite-cite_section_preview_references_header' ], |
349 | $headerMsg->text() |
350 | ) . $s; |
351 | } |
352 | // provide a preview of references in its own section |
353 | $s = Html::rawElement( |
354 | 'div', |
355 | [ 'class' => 'mw-ext-cite-cite_section_preview_references' ], |
356 | $s |
357 | ); |
358 | } |
359 | return $s !== '' ? "\n" . $s : ''; |
360 | } |
361 | |
362 | /** |
363 | * @see https://phabricator.wikimedia.org/T240248 |
364 | * @return never |
365 | */ |
366 | public function __clone() { |
367 | throw new LogicException( 'Create a new instance please' ); |
368 | } |
369 | |
370 | } |