Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
96.72% |
59 / 61 |
|
60.00% |
3 / 5 |
CRAP | |
0.00% |
0 / 1 |
HandleParsoidSectionLinks | |
96.72% |
59 / 61 |
|
60.00% |
3 / 5 |
23 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
shouldRun | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
isHtmlHeading | |
85.71% |
6 / 7 |
|
0.00% |
0 / 1 |
5.07 | |||
transformDOM | |
100.00% |
47 / 47 |
|
100.00% |
1 / 1 |
14 | |||
resolveSkin | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
2.06 |
1 | <?php |
2 | // Suppress UnusedPluginSuppression because Phan on PHP 8.1 needs more |
3 | // suppressions than PHP 7.x due to tighter types on Element::insertBefore() |
4 | // and Element::appendChild(): see comments marked PHP81 below. The |
5 | // Unused*Suppression can be removed once MW moves to >= PHP 8.1. |
6 | // @phan-file-suppress UnusedPluginSuppression,UnusedPluginFileSuppression |
7 | |
8 | namespace MediaWiki\OutputTransform\Stages; |
9 | |
10 | use MediaWiki\Config\ServiceOptions; |
11 | use MediaWiki\Context\RequestContext; |
12 | use MediaWiki\OutputTransform\ContentDOMTransformStage; |
13 | use MediaWiki\Parser\ParserOptions; |
14 | use MediaWiki\Parser\ParserOutput; |
15 | use MediaWiki\Parser\ParserOutputFlags; |
16 | use MediaWiki\Parser\Sanitizer; |
17 | use MediaWiki\Skin\Skin; |
18 | use MediaWiki\Title\TitleFactory; |
19 | use Psr\Log\LoggerInterface; |
20 | use Wikimedia\Parsoid\DOM\Document; |
21 | use Wikimedia\Parsoid\DOM\Element; |
22 | use Wikimedia\Parsoid\Utils\DOMCompat; |
23 | use Wikimedia\Parsoid\Utils\DOMDataUtils; |
24 | |
25 | /** |
26 | * Add anchors and other heading formatting, and replace the section link placeholders. |
27 | * @internal |
28 | */ |
29 | class HandleParsoidSectionLinks extends ContentDOMTransformStage { |
30 | |
31 | private TitleFactory $titleFactory; |
32 | |
33 | public function __construct( |
34 | ServiceOptions $options, LoggerInterface $logger, TitleFactory $titleFactory |
35 | ) { |
36 | parent::__construct( $options, $logger ); |
37 | $this->titleFactory = $titleFactory; |
38 | } |
39 | |
40 | public function shouldRun( ParserOutput $po, ?ParserOptions $popts, array $options = [] ): bool { |
41 | // Only run this stage if it is parsoid content |
42 | return ( $options['isParsoidContent'] ?? false ); |
43 | } |
44 | |
45 | /** |
46 | * Check if the heading has attributes that can only be added using HTML syntax. |
47 | * |
48 | * In the Parsoid default future, we might prefer checking for stx=html. |
49 | */ |
50 | private function isHtmlHeading( Element $h ): bool { |
51 | foreach ( $h->attributes as $attr ) { |
52 | // Condition matches DiscussionTool's CommentFormatter::handleHeading |
53 | if ( |
54 | !in_array( $attr->name, [ 'id', 'data-object-id', 'about', 'typeof' ], true ) && |
55 | !Sanitizer::isReservedDataAttribute( $attr->name ) |
56 | ) { |
57 | return true; |
58 | } |
59 | } |
60 | // FIXME(T100856): stx info probably shouldn't be in data-parsoid |
61 | // Id is ignored above since it's a special case, make use of metadata |
62 | // to determine if it came from wikitext |
63 | if ( DOMDataUtils::getDataParsoid( $h )->reusedId ?? false ) { |
64 | return true; |
65 | } |
66 | return false; |
67 | } |
68 | |
69 | public function transformDOM( |
70 | Document $dom, ParserOutput $po, ?ParserOptions $popts, array &$options |
71 | ): Document { |
72 | $skin = $this->resolveSkin( $options ); |
73 | $titleText = $po->getTitleText(); |
74 | // Transform: |
75 | // <section data-mw-section-id=...> |
76 | // <h2 id=...><span id=... typeof="mw:FallbackId"></span> ... </h2> |
77 | // ...section contents.. |
78 | // To: |
79 | // <section data-mw-section-id=...> |
80 | // <div class="mw-heading mw-heading2"> |
81 | // <h2 id=...><span id=... typeof="mw:FallbackId"></span> ... </h2> |
82 | // <span class="mw-editsection">...section edit link...</span> |
83 | // </div> |
84 | // That is, we're wrapping a <div> around the <h2> generated by |
85 | // Parsoid, and then (assuming section edit links are enabled) |
86 | // adding a <span> with the section edit link |
87 | // inside that <div> |
88 | // |
89 | // If COLLAPSIBLE_SECTIONS is set, then we also wrap a <div> |
90 | // around the section *contents*. |
91 | $toc = $po->getTOCData(); |
92 | $sections = ( $toc !== null ) ? $toc->getSections() : []; |
93 | // use the TOC data to extract the headings: |
94 | foreach ( $sections as $section ) { |
95 | if ( $section->anchor === '' ) { |
96 | // T375002 / T368722: The empty string isn't a valid id so |
97 | // Parsoid will have reassigned it and we'll never be able |
98 | // to select by it below. There's no sense in logging an |
99 | // error since it's a common enough occurrence at present. |
100 | continue; |
101 | } |
102 | $h = $dom->getElementById( $section->anchor ); |
103 | if ( $h === null ) { |
104 | $this->logger->error( |
105 | __METHOD__ . ': Heading missing for anchor', |
106 | $section->toLegacy() |
107 | ); |
108 | continue; |
109 | } |
110 | |
111 | if ( $this->isHtmlHeading( $h ) ) { |
112 | // This is a <h#> tag with attributes added using HTML syntax. |
113 | // Mark it with a class to make them easier to distinguish (T68637). |
114 | DOMCompat::getClassList( $h )->add( 'mw-html-heading' ); |
115 | |
116 | // Do not add the wrapper if the heading has attributes added using HTML syntax (T353489). |
117 | continue; |
118 | } |
119 | |
120 | $fromTitle = $section->fromTitle; |
121 | $div = $dom->createElement( 'div' ); |
122 | if ( |
123 | $fromTitle !== null && |
124 | ( $options['enableSectionEditLinks'] ?? true ) && |
125 | !$po->getOutputFlag( ParserOutputFlags::NO_SECTION_EDIT_LINKS ) |
126 | ) { |
127 | $editPage = $this->titleFactory->newFromTextThrow( $fromTitle ); |
128 | $html = $skin->doEditSectionLink( |
129 | $editPage, $section->index, $h->textContent, |
130 | $skin->getLanguage() |
131 | ); |
132 | DOMCompat::setInnerHTML( $div, $html ); |
133 | } |
134 | |
135 | // Reuse existing wrapper if present. |
136 | $maybeWrapper = $h->parentNode; |
137 | '@phan-var \Wikimedia\Parsoid\DOM\Element $maybeWrapper'; |
138 | if ( |
139 | DOMCompat::nodeName( $maybeWrapper ) === 'div' && |
140 | DOMCompat::getClassList( $maybeWrapper )->contains( 'mw-heading' ) |
141 | ) { |
142 | // Transfer section edit link children to existing wrapper |
143 | // All contents of the div (the section edit link) will be |
144 | // inserted immediately following the <h> tag |
145 | $ref = $h->nextSibling; |
146 | while ( $div->firstChild !== null ) { |
147 | // @phan-suppress-next-line PhanTypeMismatchArgumentNullableInternal firstChild is non-null (PHP81) |
148 | $maybeWrapper->insertBefore( $div->firstChild, $ref ); |
149 | } |
150 | $div = $maybeWrapper; // for use below |
151 | } else { |
152 | // Move <hX> to new wrapper: the div contents are currently |
153 | // the section edit link. We first replace the h with the |
154 | // div, then insert the <h> as the first child of the div |
155 | // so the section edit link is immediately following the <h>. |
156 | $div->setAttribute( |
157 | 'class', 'mw-heading mw-heading' . $section->hLevel |
158 | ); |
159 | $h->parentNode->replaceChild( $div, $h ); |
160 | // Work around bug in phan (https://github.com/phan/phan/pull/4837) |
161 | // by asserting that $div->firstChild is non-null here. Actually, |
162 | // ::insertBefore will work fine if $div->firstChild is null (if |
163 | // "doEditSectionLink" returned nothing, for instance), but |
164 | // phan incorrectly thinks the second argument must be non-null. |
165 | $divFirstChild = $div->firstChild; |
166 | '@phan-var \DOMNode $divFirstChild'; // asserting non-null (PHP81) |
167 | $div->insertBefore( $h, $divFirstChild ); |
168 | } |
169 | // Create collapsible section wrapper if requested. |
170 | if ( $po->getOutputFlag( ParserOutputFlags::COLLAPSIBLE_SECTIONS ) ) { |
171 | $contentsDiv = $dom->createElement( 'div' ); |
172 | while ( $div->nextSibling !== null ) { |
173 | // @phan-suppress-next-line PhanTypeMismatchArgumentNullableInternal |
174 | $contentsDiv->appendChild( $div->nextSibling ); |
175 | } |
176 | $div->parentNode->appendChild( $contentsDiv ); |
177 | } |
178 | } |
179 | return $dom; |
180 | } |
181 | |
182 | /** |
183 | * Extracts the skin from the $options array, with a fallback on request context skin |
184 | * @param array $options |
185 | * @return Skin |
186 | */ |
187 | private function resolveSkin( array $options ): Skin { |
188 | $skin = $options[ 'skin' ] ?? null; |
189 | if ( !$skin ) { |
190 | // T348853 passing $skin will be mandatory in the future |
191 | $skin = RequestContext::getMain()->getSkin(); |
192 | } |
193 | return $skin; |
194 | } |
195 | } |