Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
95.00% |
57 / 60 |
|
71.43% |
5 / 7 |
CRAP | |
0.00% |
0 / 1 |
HandleSectionLinks | |
95.00% |
57 / 60 |
|
71.43% |
5 / 7 |
13 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
shouldRun | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
transformText | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
replaceHeadings | |
96.67% |
29 / 30 |
|
0.00% |
0 / 1 |
2 | |||
makeHeading | |
77.78% |
7 / 9 |
|
0.00% |
0 / 1 |
3.10 | |||
addSectionLinks | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
1 | |||
resolveSkin | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 |
1 | <?php |
2 | |
3 | namespace MediaWiki\OutputTransform\Stages; |
4 | |
5 | use MediaWiki\Context\RequestContext; |
6 | use MediaWiki\Html\Html; |
7 | use MediaWiki\Html\HtmlHelper; |
8 | use MediaWiki\OutputTransform\ContentTextTransformStage; |
9 | use MediaWiki\Parser\ParserOutput; |
10 | use MediaWiki\Parser\ParserOutputFlags; |
11 | use MediaWiki\Parser\Sanitizer; |
12 | use MediaWiki\Title\TitleFactory; |
13 | use ParserOptions; |
14 | use Skin; |
15 | |
16 | /** |
17 | * Add anchors and other heading formatting, and replace the section link placeholders. |
18 | * @internal |
19 | */ |
20 | class HandleSectionLinks extends ContentTextTransformStage { |
21 | private const EDITSECTION_REGEX = '#<mw:editsection page="(.*?)" section="(.*?)">(.*?)</mw:editsection>#s'; |
22 | private const HEADING_REGEX = |
23 | '/<H(?P<level>[1-6])(?P<attrib>(?:[^\'">]*|"([^"]*)"|\'([^\']*)\')*>)(?P<header>[\s\S]*?)<\/H[1-6] *>/i'; |
24 | |
25 | private TitleFactory $titleFactory; |
26 | |
27 | public function __construct( TitleFactory $titleFactory ) { |
28 | $this->titleFactory = $titleFactory; |
29 | } |
30 | |
31 | public function shouldRun( ParserOutput $po, ?ParserOptions $popts, array $options = [] ): bool { |
32 | $isParsoid = $options['isParsoidContent'] ?? false; |
33 | return !$isParsoid; |
34 | } |
35 | |
36 | protected function transformText( string $text, ParserOutput $po, ?ParserOptions $popts, array &$options ): string { |
37 | $text = $this->replaceHeadings( $text ); |
38 | |
39 | if ( |
40 | ( $options['enableSectionEditLinks'] ?? true ) && |
41 | !$po->getOutputFlag( ParserOutputFlags::NO_SECTION_EDIT_LINKS ) |
42 | ) { |
43 | return $this->addSectionLinks( $text, $po, $options ); |
44 | } else { |
45 | return preg_replace( self::EDITSECTION_REGEX, '', $text ); |
46 | } |
47 | } |
48 | |
49 | private function replaceHeadings( string $text ): string { |
50 | return preg_replace_callback( self::HEADING_REGEX, function ( $m ) { |
51 | // Parse attributes out of the <h#> tag. Do not actually use HtmlHelper's output, |
52 | // because EDITSECTION_REGEX is sensitive to quotes in HTML serialization. |
53 | $attrs = []; |
54 | HtmlHelper::modifyElements( |
55 | $m[0], |
56 | static fn ( $node ) => in_array( $node->name, [ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6' ] ), |
57 | static function ( $node ) use ( &$attrs ) { |
58 | $attrs = $node->attrs->getValues(); |
59 | return $node; |
60 | } |
61 | ); |
62 | |
63 | if ( !isset( $attrs['data-mw-anchor'] ) ) { |
64 | return $m[0]; |
65 | } |
66 | |
67 | $anchor = $attrs['data-mw-anchor']; |
68 | $fallbackAnchor = $attrs['data-mw-fallback-anchor'] ?? false; |
69 | unset( $attrs['data-mw-anchor'] ); |
70 | unset( $attrs['data-mw-fallback-anchor'] ); |
71 | |
72 | // Split the heading content from the section edit link placeholder |
73 | $editlink = ''; |
74 | $contents = preg_replace_callback( self::EDITSECTION_REGEX, static function ( $mm ) use ( &$editlink ) { |
75 | $editlink = $mm[0]; |
76 | return ''; |
77 | }, $m['header'] ); |
78 | |
79 | return $this->makeHeading( |
80 | (int)$m['level'], |
81 | $attrs, |
82 | $anchor, |
83 | $contents, |
84 | $editlink, |
85 | $fallbackAnchor |
86 | ); |
87 | }, $text ); |
88 | } |
89 | |
90 | /** |
91 | * @param int $level The level of the headline (1-6) |
92 | * @param array $attrs HTML attributes |
93 | * @param string $anchor The anchor to give the headline (the bit after the #) |
94 | * @param string $html HTML for the text of the header |
95 | * @param string $link HTML to add for the section edit link |
96 | * @param string|false $fallbackAnchor A second, optional anchor to give for |
97 | * backward compatibility (false to omit) |
98 | * @return string HTML headline |
99 | */ |
100 | private function makeHeading( $level, $attrs, $anchor, $html, |
101 | $link, $fallbackAnchor = false |
102 | ) { |
103 | $anchorEscaped = htmlspecialchars( $anchor, ENT_COMPAT ); |
104 | $fallback = ''; |
105 | if ( $fallbackAnchor !== false && $fallbackAnchor !== $anchor ) { |
106 | $fallbackAnchor = htmlspecialchars( $fallbackAnchor, ENT_COMPAT ); |
107 | $fallback = "<span id=\"$fallbackAnchor\"></span>"; |
108 | } |
109 | return "<h$level" . Html::expandAttributes( $attrs ) . ">" |
110 | . "$fallback<span class=\"mw-headline\" id=\"$anchorEscaped\">$html</span>" |
111 | . $link |
112 | . "</h$level>"; |
113 | } |
114 | |
115 | private function addSectionLinks( string $text, ParserOutput $po, array $options ): string { |
116 | $skin = $this->resolveSkin( $options ); |
117 | $titleText = $po->getTitleText(); |
118 | return preg_replace_callback( self::EDITSECTION_REGEX, function ( $m ) use ( $skin, $titleText ) { |
119 | $editsectionPage = $this->titleFactory->newFromTextThrow( htmlspecialchars_decode( $m[1] ) ); |
120 | $editsectionSection = htmlspecialchars_decode( $m[2] ); |
121 | $editsectionContent = Sanitizer::decodeCharReferences( $m[3] ); |
122 | return $skin->doEditSectionLink( $editsectionPage, $editsectionSection, $editsectionContent, |
123 | $skin->getLanguage() ); |
124 | }, $text ); |
125 | } |
126 | |
127 | /** |
128 | * Extracts the skin from the $options array, with a fallback on request context skin |
129 | * @param array $options |
130 | * @return Skin |
131 | */ |
132 | private function resolveSkin( array $options ): Skin { |
133 | $skin = $options[ 'skin' ] ?? null; |
134 | if ( !$skin ) { |
135 | // T348853 passing $skin will be mandatory in the future |
136 | $skin = RequestContext::getMain()->getSkin(); |
137 | } |
138 | return $skin; |
139 | } |
140 | } |