Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
99.09% |
109 / 110 |
|
88.89% |
8 / 9 |
CRAP | |
0.00% |
0 / 1 |
ReferenceListFormatter | |
99.09% |
109 / 110 |
|
88.89% |
8 / 9 |
34 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
formatReferences | |
90.00% |
9 / 10 |
|
0.00% |
0 / 1 |
4.02 | |||
formatRefsList | |
100.00% |
25 / 25 |
|
100.00% |
1 / 1 |
10 | |||
closeIndention | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
3 | |||
formatListItem | |
100.00% |
41 / 41 |
|
100.00% |
1 / 1 |
8 | |||
referenceText | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
4 | |||
referencesFormatEntryNumericBacklinkLabel | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
referencesFormatEntryAlternateBacklinkLabel | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
listToText | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
2 |
1 | <?php |
2 | |
3 | namespace Cite; |
4 | |
5 | use MediaWiki\Html\Html; |
6 | use Parser; |
7 | |
8 | /** |
9 | * Renderer for the actual list of references in place of the <references /> tag at the end of an |
10 | * article. |
11 | * |
12 | * @license GPL-2.0-or-later |
13 | */ |
14 | class ReferenceListFormatter { |
15 | |
16 | /** |
17 | * The backlinks, in order, to pass as $3 to |
18 | * 'cite_references_link_many_format', defined in |
19 | * 'cite_references_link_many_format_backlink_labels |
20 | * |
21 | * @var string[]|null |
22 | */ |
23 | private ?array $backlinkLabels = null; |
24 | private ErrorReporter $errorReporter; |
25 | private AnchorFormatter $anchorFormatter; |
26 | private ReferenceMessageLocalizer $messageLocalizer; |
27 | |
28 | public function __construct( |
29 | ErrorReporter $errorReporter, |
30 | AnchorFormatter $anchorFormatter, |
31 | ReferenceMessageLocalizer $messageLocalizer |
32 | ) { |
33 | $this->errorReporter = $errorReporter; |
34 | $this->anchorFormatter = $anchorFormatter; |
35 | $this->messageLocalizer = $messageLocalizer; |
36 | } |
37 | |
38 | /** |
39 | * @param Parser $parser |
40 | * @param array<string|int,ReferenceStackItem> $groupRefs |
41 | * @param bool $responsive |
42 | * @param bool $isSectionPreview |
43 | * |
44 | * @return string HTML |
45 | */ |
46 | public function formatReferences( |
47 | Parser $parser, |
48 | array $groupRefs, |
49 | bool $responsive, |
50 | bool $isSectionPreview |
51 | ): string { |
52 | if ( !$groupRefs ) { |
53 | return ''; |
54 | } |
55 | |
56 | $wikitext = $this->formatRefsList( $parser, $groupRefs, $isSectionPreview ); |
57 | $html = $parser->recursiveTagParse( $wikitext ); |
58 | |
59 | if ( $responsive ) { |
60 | $wrapClasses = [ 'mw-references-wrap' ]; |
61 | if ( count( $groupRefs ) > 10 ) { |
62 | $wrapClasses[] = 'mw-references-columns'; |
63 | } |
64 | // Use a DIV wrap because column-count on a list directly is broken in Chrome. |
65 | // See https://bugs.chromium.org/p/chromium/issues/detail?id=498730. |
66 | return Html::rawElement( 'div', [ 'class' => $wrapClasses ], $html ); |
67 | } |
68 | |
69 | return $html; |
70 | } |
71 | |
72 | /** |
73 | * @param Parser $parser |
74 | * @param array<string|int,ReferenceStackItem> $groupRefs |
75 | * @param bool $isSectionPreview |
76 | * |
77 | * @return string Wikitext |
78 | */ |
79 | private function formatRefsList( |
80 | Parser $parser, |
81 | array $groupRefs, |
82 | bool $isSectionPreview |
83 | ): string { |
84 | // After sorting the list, we can assume that references are in the same order as their |
85 | // numbering. Subreferences will come immediately after their parent. |
86 | uasort( |
87 | $groupRefs, |
88 | static function ( ReferenceStackItem $a, ReferenceStackItem $b ): int { |
89 | $cmp = ( $a->number ?? 0 ) - ( $b->number ?? 0 ); |
90 | return $cmp ?: ( $a->extendsIndex ?? 0 ) - ( $b->extendsIndex ?? 0 ); |
91 | } |
92 | ); |
93 | |
94 | // Add new lines between the list items (ref entries) to avoid confusing tidy (T15073). |
95 | // Note: This builds a string of wikitext, not html. |
96 | $parserInput = "\n"; |
97 | /** @var string|bool $indented */ |
98 | $indented = false; |
99 | foreach ( $groupRefs as $key => &$ref ) { |
100 | // Make sure the parent is not a subreference. |
101 | // FIXME: Move to a validation function. |
102 | $extends =& $ref->extends; |
103 | if ( isset( $extends ) && isset( $groupRefs[$extends]->extends ) ) { |
104 | $ref->warnings[] = [ 'cite_error_ref_nested_extends', |
105 | $extends, $groupRefs[$extends]->extends ]; |
106 | } |
107 | |
108 | if ( !$indented && isset( $extends ) ) { |
109 | // The nested <ol> must be inside the parent's <li> |
110 | if ( preg_match( '#</li>\s*$#D', $parserInput, $matches, PREG_OFFSET_CAPTURE ) ) { |
111 | $parserInput = substr( $parserInput, 0, $matches[0][1] ); |
112 | } |
113 | $parserInput .= Html::openElement( 'ol', [ 'class' => 'mw-extended-references' ] ); |
114 | $indented = $matches[0][0] ?? true; |
115 | } elseif ( $indented && !isset( $extends ) ) { |
116 | $parserInput .= $this->closeIndention( $indented ); |
117 | $indented = false; |
118 | } |
119 | $parserInput .= $this->formatListItem( $parser, $key, $ref, $isSectionPreview ) . "\n"; |
120 | } |
121 | $parserInput .= $this->closeIndention( $indented ); |
122 | return Html::rawElement( 'ol', [ 'class' => 'references' ], $parserInput ); |
123 | } |
124 | |
125 | /** |
126 | * @param string|bool $closingLi |
127 | * |
128 | * @return string |
129 | */ |
130 | private function closeIndention( $closingLi ): string { |
131 | if ( !$closingLi ) { |
132 | return ''; |
133 | } |
134 | |
135 | return Html::closeElement( 'ol' ) . ( is_string( $closingLi ) ? $closingLi : '' ); |
136 | } |
137 | |
138 | /** |
139 | * @param Parser $parser |
140 | * @param string|int $key The key of the reference |
141 | * @param ReferenceStackItem $ref |
142 | * @param bool $isSectionPreview |
143 | * |
144 | * @return string Wikitext, wrapped in a single <li> element |
145 | */ |
146 | private function formatListItem( |
147 | Parser $parser, $key, ReferenceStackItem $ref, bool $isSectionPreview |
148 | ): string { |
149 | $text = $this->referenceText( $parser, $key, $ref, $isSectionPreview ); |
150 | $extraAttributes = ''; |
151 | |
152 | if ( isset( $ref->dir ) ) { |
153 | // The following classes are generated here: |
154 | // * mw-cite-dir-ltr |
155 | // * mw-cite-dir-rtl |
156 | $extraAttributes = Html::expandAttributes( [ 'class' => 'mw-cite-dir-' . $ref->dir ] ); |
157 | } |
158 | |
159 | // Special case for an incomplete follow="…". This is valid e.g. in the Page:… namespace on |
160 | // Wikisource. Note this returns a <p>, not an <li> as expected! |
161 | if ( isset( $ref->follow ) ) { |
162 | return '<p id="' . $this->anchorFormatter->jumpLinkTarget( $ref->follow ) . '">' . $text . '</p>'; |
163 | } |
164 | |
165 | if ( $ref->count === 1 ) { |
166 | if ( !isset( $ref->name ) ) { |
167 | $id = $ref->key; |
168 | $backlinkId = $this->anchorFormatter->backLink( $ref->key ); |
169 | } else { |
170 | $id = $key . '-' . $ref->key; |
171 | // TODO: Use count without decrementing. |
172 | $backlinkId = $this->anchorFormatter->backLink( $key, $ref->key . '-' . ( $ref->count - 1 ) ); |
173 | } |
174 | return $this->messageLocalizer->msg( |
175 | 'cite_references_link_one', |
176 | $this->anchorFormatter->jumpLinkTarget( $id ), |
177 | $backlinkId, |
178 | $text, |
179 | $extraAttributes |
180 | )->plain(); |
181 | } |
182 | |
183 | // Named references with >1 occurrences |
184 | $backlinks = []; |
185 | for ( $i = 0; $i < $ref->count; $i++ ) { |
186 | $backlinks[] = $this->messageLocalizer->msg( |
187 | 'cite_references_link_many_format', |
188 | $this->anchorFormatter->backLink( $key, $ref->key . '-' . $i ), |
189 | $this->referencesFormatEntryNumericBacklinkLabel( |
190 | $ref->number . |
191 | ( isset( $ref->extendsIndex ) ? '.' . $ref->extendsIndex : '' ), |
192 | $i, |
193 | $ref->count |
194 | ), |
195 | $this->referencesFormatEntryAlternateBacklinkLabel( $parser, $i ) |
196 | )->plain(); |
197 | } |
198 | $linkTargetId = $ref->count > 0 ? |
199 | $this->anchorFormatter->jumpLinkTarget( $key . '-' . $ref->key ) : ''; |
200 | return $this->messageLocalizer->msg( |
201 | 'cite_references_link_many', |
202 | $linkTargetId, |
203 | $this->listToText( $backlinks ), |
204 | $text, |
205 | $extraAttributes |
206 | )->plain(); |
207 | } |
208 | |
209 | /** |
210 | * @param Parser $parser |
211 | * @param string|int $key |
212 | * @param ReferenceStackItem $ref |
213 | * @param bool $isSectionPreview |
214 | * |
215 | * @return string Wikitext |
216 | */ |
217 | private function referenceText( |
218 | Parser $parser, $key, ReferenceStackItem $ref, bool $isSectionPreview |
219 | ): string { |
220 | $text = $ref->text ?? null; |
221 | if ( $text === null ) { |
222 | return $this->errorReporter->plain( $parser, |
223 | $isSectionPreview |
224 | ? 'cite_warning_sectionpreview_no_text' |
225 | : 'cite_error_references_no_text', $key ); |
226 | } |
227 | |
228 | foreach ( $ref->warnings as $warning ) { |
229 | // @phan-suppress-next-line PhanParamTooFewUnpack |
230 | $text .= ' ' . $this->errorReporter->plain( $parser, ...$warning ); |
231 | // FIXME: We could use a StatusValue object to get rid of duplicates |
232 | break; |
233 | } |
234 | |
235 | return '<span class="reference-text">' . rtrim( $text, "\n" ) . "</span>\n"; |
236 | } |
237 | |
238 | /** |
239 | * Generate a numeric backlink given a base number and an |
240 | * offset, e.g. $base = 1, $offset = 2; = 1.2 |
241 | * Since bug #5525, it correctly does 1.9 -> 1.10 as well as 1.099 -> 1.100 |
242 | * |
243 | * @param int|string $base |
244 | * @param int $offset |
245 | * @param int $max Maximum value expected. |
246 | * |
247 | * @return string |
248 | */ |
249 | private function referencesFormatEntryNumericBacklinkLabel( |
250 | $base, |
251 | int $offset, |
252 | int $max |
253 | ): string { |
254 | return $this->messageLocalizer->localizeDigits( $base ) . |
255 | $this->messageLocalizer->localizeSeparators( '.' ) . |
256 | $this->messageLocalizer->localizeDigits( |
257 | str_pad( (string)$offset, strlen( (string)$max ), '0', STR_PAD_LEFT ) |
258 | ); |
259 | } |
260 | |
261 | /** |
262 | * Generate a custom format backlink given an offset, e.g. |
263 | * $offset = 2; = c if $this->mBacklinkLabels = [ 'a', |
264 | * 'b', 'c', ...]. Return an error if the offset > the # of |
265 | * array items |
266 | */ |
267 | private function referencesFormatEntryAlternateBacklinkLabel( |
268 | Parser $parser, int $offset |
269 | ): string { |
270 | $this->backlinkLabels ??= preg_split( |
271 | '/\s+/', |
272 | $this->messageLocalizer->msg( 'cite_references_link_many_format_backlink_labels' ) |
273 | ->plain() |
274 | ); |
275 | |
276 | return $this->backlinkLabels[$offset] |
277 | ?? $this->errorReporter->plain( $parser, 'cite_error_references_no_backlink_label' ); |
278 | } |
279 | |
280 | /** |
281 | * This does approximately the same thing as |
282 | * Language::listToText() but due to this being used for a |
283 | * slightly different purpose (people might not want , as the |
284 | * first separator and not 'and' as the second, and this has to |
285 | * use messages from the content language) I'm rolling my own. |
286 | * |
287 | * @param string[] $arr The array to format |
288 | * |
289 | * @return string Wikitext |
290 | */ |
291 | private function listToText( array $arr ): string { |
292 | $lastElement = array_pop( $arr ); |
293 | |
294 | if ( $arr === [] ) { |
295 | return (string)$lastElement; |
296 | } |
297 | |
298 | $sep = $this->messageLocalizer->msg( 'cite_references_link_many_sep' )->plain(); |
299 | $and = $this->messageLocalizer->msg( 'cite_references_link_many_and' )->plain(); |
300 | return implode( $sep, $arr ) . $and . $lastElement; |
301 | } |
302 | |
303 | } |