Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
86.54% |
45 / 52 |
|
50.00% |
2 / 4 |
CRAP | |
0.00% |
0 / 1 |
HandleParsoidSectionLinks | |
86.54% |
45 / 52 |
|
50.00% |
2 / 4 |
16.62 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
shouldRun | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
transformDOM | |
85.37% |
35 / 41 |
|
0.00% |
0 / 1 |
10.31 | |||
resolveSkin | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
2.06 |
1 | <?php |
2 | // Suppress UnusedPluginSuppression because Phan on PHP 7.4 and PHP 8.1 need different suppressions |
3 | // @phan-file-suppress UnusedPluginSuppression,UnusedPluginFileSuppression |
4 | |
5 | namespace MediaWiki\OutputTransform\Stages; |
6 | |
7 | use MediaWiki\Context\RequestContext; |
8 | use MediaWiki\OutputTransform\ContentDOMTransformStage; |
9 | use MediaWiki\Parser\ParserOutput; |
10 | use MediaWiki\Parser\ParserOutputFlags; |
11 | use MediaWiki\Title\TitleFactory; |
12 | use ParserOptions; |
13 | use Psr\Log\LoggerInterface; |
14 | use Skin; |
15 | use Wikimedia\Parsoid\DOM\Document; |
16 | use Wikimedia\Parsoid\DOM\Element; |
17 | use Wikimedia\Parsoid\Utils\DOMCompat; |
18 | |
19 | /** |
20 | * Add anchors and other heading formatting, and replace the section link placeholders. |
21 | * @internal |
22 | */ |
23 | class HandleParsoidSectionLinks extends ContentDOMTransformStage { |
24 | |
25 | private LoggerInterface $logger; |
26 | private TitleFactory $titleFactory; |
27 | |
28 | public function __construct( LoggerInterface $logger, TitleFactory $titleFactory ) { |
29 | $this->logger = $logger; |
30 | $this->titleFactory = $titleFactory; |
31 | } |
32 | |
33 | public function shouldRun( ParserOutput $po, ?ParserOptions $popts, array $options = [] ): bool { |
34 | // Only run this stage if it is parsoid content *and* section edit |
35 | // links are enabled. |
36 | return ( |
37 | ( $options['isParsoidContent'] ?? false ) && |
38 | ( $options['enableSectionEditLinks'] ?? true ) && |
39 | !$po->getOutputFlag( ParserOutputFlags::NO_SECTION_EDIT_LINKS ) |
40 | ); |
41 | } |
42 | |
43 | public function transformDOM( |
44 | Document $dom, ParserOutput $po, ?ParserOptions $popts, array &$options |
45 | ): Document { |
46 | $skin = $this->resolveSkin( $options ); |
47 | $titleText = $po->getTitleText(); |
48 | // Transform: |
49 | // <section data-mw-section-id=...> |
50 | // <h2 id=...><span id=... typeof="mw:FallbackId"></span> ... </h2> |
51 | // ...section contents.. |
52 | // To: |
53 | // <section data-mw-section-id=...> |
54 | // <div class="mw-heading mw-heading2"> |
55 | // <h2 id=...><span id=... typeof="mw:FallbackId"></span> ... </h2> |
56 | // <span class="mw-editsection">...section edit link...</span> |
57 | // </div> |
58 | // That is, we're wrapping a <div> around the <h2> generated by |
59 | // Parsoid, and then adding a <span> with the section edit link |
60 | // inside that <div> |
61 | // |
62 | // If COLLAPSIBLE_SECTIONS is set, then we also wrap a <div> |
63 | // around the section contents. |
64 | $toc = $po->getTOCData(); |
65 | $sections = ( $toc !== null ) ? $toc->getSections() : []; |
66 | // use the TOC data to extract the headings: |
67 | foreach ( $sections as $section ) { |
68 | $fromTitle = $section->fromTitle; |
69 | if ( $fromTitle === null ) { |
70 | // T353489: don't wrap bare <h> tags |
71 | continue; |
72 | } |
73 | $h = $dom->getElementById( $section->anchor ); |
74 | if ( $h === null ) { |
75 | $this->logger->error( |
76 | __METHOD__ . ': Heading missing for anchor', |
77 | $section->toLegacy() |
78 | ); |
79 | continue; |
80 | } |
81 | $div = $dom->createElement( 'div' ); |
82 | '@phan-var Element $div'; // assert Element |
83 | $editPage = $this->titleFactory->newFromTextThrow( $fromTitle ); |
84 | $html = $skin->doEditSectionLink( |
85 | $editPage, $section->index, $h->textContent, |
86 | $skin->getLanguage() |
87 | ); |
88 | DOMCompat::setInnerHTML( $div, $html ); |
89 | |
90 | // Reuse existing wrapper if present. |
91 | $maybeWrapper = $h->parentNode; |
92 | '@phan-var \Wikimedia\Parsoid\DOM\Element $maybeWrapper'; |
93 | if ( |
94 | DOMCompat::nodeName( $maybeWrapper ) === 'div' && |
95 | DOMCompat::getClassList( $maybeWrapper )->contains( 'mw-heading' ) |
96 | ) { |
97 | // Transfer section edit link children to existing wrapper |
98 | // All contents of the div (the section edit link) will be |
99 | // inserted immediately following the <h> tag |
100 | $ref = $h->nextSibling; |
101 | while ( $div->firstChild !== null ) { |
102 | // @phan-suppress-next-line PhanTypeMismatchArgumentNullableInternal |
103 | $maybeWrapper->insertBefore( $div->firstChild, $ref ); |
104 | } |
105 | $div = $maybeWrapper; // for use below |
106 | } else { |
107 | // Move <hX> to new wrapper: the div contents are currently |
108 | // the section edit link. We first replace the h with the |
109 | // div, then insert the <h> as the first child of the div |
110 | // so the section edit link is immediately following the <h>. |
111 | $div->setAttribute( |
112 | 'class', 'mw-heading mw-heading' . $section->hLevel |
113 | ); |
114 | $h->parentNode->replaceChild( $div, $h ); |
115 | // Work around bug in phan (https://github.com/phan/phan/pull/4837) |
116 | // by asserting that $div->firstChild is non-null here. Actually, |
117 | // ::insertBefore will work fine if $div->firstChild is null (if |
118 | // "doEditSectionLink" returned nothing, for instance), but |
119 | // phan incorrectly thinks the second argument must be non-null. |
120 | $divFirstChild = $div->firstChild; |
121 | '@phan-var \DOMNode $divFirstChild'; // asserting non-null |
122 | $div->insertBefore( $h, $divFirstChild ); |
123 | } |
124 | // Create collapsible section wrapper if requested. |
125 | if ( $po->getOutputFlag( ParserOutputFlags::COLLAPSIBLE_SECTIONS ) ) { |
126 | $contentsDiv = $dom->createElement( 'div' ); |
127 | while ( $div->nextSibling !== null ) { |
128 | // @phan-suppress-next-line PhanTypeMismatchArgumentNullableInternal |
129 | $contentsDiv->appendChild( $div->nextSibling ); |
130 | } |
131 | $div->parentNode->appendChild( $contentsDiv ); |
132 | } |
133 | } |
134 | return $dom; |
135 | } |
136 | |
137 | /** |
138 | * Extracts the skin from the $options array, with a fallback on request context skin |
139 | * @param array $options |
140 | * @return Skin |
141 | */ |
142 | private function resolveSkin( array $options ): Skin { |
143 | $skin = $options[ 'skin' ] ?? null; |
144 | if ( !$skin ) { |
145 | // T348853 passing $skin will be mandatory in the future |
146 | $skin = RequestContext::getMain()->getSkin(); |
147 | } |
148 | return $skin; |
149 | } |
150 | } |