Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
22.12% covered (danger)
22.12%
25 / 113
20.00% covered (danger)
20.00%
1 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
Ref
22.12% covered (danger)
22.12%
25 / 113
20.00% covered (danger)
20.00%
1 / 5
648.09
0.00% covered (danger)
0.00%
0 / 1
 sourceToDom
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
12
 processAttributeEmbeddedHTML
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 lintHandler
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 domToWikitext
41.51% covered (danger)
41.51%
22 / 53
0.00% covered (danger)
0.00%
0 / 1
82.83
 diffHandler
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
90
1<?php
2declare( strict_types = 1 );
3// phpcs:disable MediaWiki.WhiteSpace.SpaceBeforeSingleLineComment.NewLineComment
4
5namespace Cite\Parsoid;
6
7use Closure;
8use Exception;
9use Wikimedia\Parsoid\DOM\DocumentFragment;
10use Wikimedia\Parsoid\DOM\Element;
11use Wikimedia\Parsoid\Ext\DOMDataUtils;
12use Wikimedia\Parsoid\Ext\DOMUtils;
13use Wikimedia\Parsoid\Ext\ExtensionTagHandler;
14use Wikimedia\Parsoid\Ext\ParsoidExtensionAPI;
15use Wikimedia\Parsoid\Utils\DOMCompat;
16
17/**
18 * Simple token transform version of the Ref extension tag.
19 * @license GPL-2.0-or-later
20 */
21class Ref extends ExtensionTagHandler {
22
23    /** @inheritDoc */
24    public function sourceToDom(
25        ParsoidExtensionAPI $extApi, string $txt, array $extArgs
26    ): ?DocumentFragment {
27        // Drop nested refs entirely, unless we've explicitly allowed them
28        $parentExtTag = $extApi->parentExtTag();
29        if ( $parentExtTag === 'ref' && empty( $extApi->parentExtTagOpts()['allowNestedRef'] ) ) {
30            return null;
31        }
32
33        // The one supported case for nested refs is from the {{#tag:ref}} parser
34        // function.  However, we're overly permissive here since we can't
35        // distinguish when that's nested in another template.
36        // The php preprocessor did our expansion.
37        $allowNestedRef = $extApi->inTemplate();
38
39        return $extApi->extTagToDOM(
40            $extArgs,
41            $txt,
42            [
43                // NOTE: sup's content model requires it only contain phrasing
44                // content, not flow content. However, since we are building an
45                // in-memory DOM which is simply a tree data structure, we can
46                // nest flow content in a <sup> tag.
47                'wrapperTag' => 'sup',
48                'parseOpts' => [
49                    'extTag' => 'ref',
50                    'extTagOpts' => [ 'allowNestedRef' => $allowNestedRef ],
51                    // Ref content doesn't need p-wrapping or indent-pres.
52                    // Treat this as inline-context content to get that b/c behavior.
53                    'context' => 'inline',
54                ],
55            ]
56        );
57    }
58
59    /** @inheritDoc */
60    public function processAttributeEmbeddedHTML(
61        ParsoidExtensionAPI $extApi, Element $elt, Closure $proc
62    ): void {
63        $dataMw = DOMDataUtils::getDataMw( $elt );
64        if ( isset( $dataMw->body->html ) ) {
65            $dataMw->body->html = $proc( $dataMw->body->html );
66        }
67    }
68
69    /** @inheritDoc */
70    public function lintHandler(
71        ParsoidExtensionAPI $extApi, Element $ref, callable $defaultHandler
72    ): bool {
73        $dataMw = DOMDataUtils::getDataMw( $ref );
74        if ( isset( $dataMw->body->html ) ) {
75            $fragment = $extApi->htmlToDom( $dataMw->body->html );
76            $defaultHandler( $fragment );
77        } elseif ( isset( $dataMw->body->id ) ) {
78            $refNode = DOMCompat::getElementById( $extApi->getTopLevelDoc(), $dataMw->body->id );
79            if ( $refNode ) {
80                $defaultHandler( $refNode );
81            }
82        }
83        return true;
84    }
85
86    /** @inheritDoc */
87    public function domToWikitext(
88        ParsoidExtensionAPI $extApi, Element $node, bool $wrapperUnmodified
89    ) {
90        $startTagSrc = $extApi->extStartTagToWikitext( $node );
91        $dataMw = DOMDataUtils::getDataMw( $node );
92        if ( !isset( $dataMw->body ) ) {
93            return $startTagSrc; // We self-closed this already.
94        }
95
96        $html2wtOpts = [
97            'extName' => $dataMw->name,
98            // FIXME: One-off PHP parser state leak. This needs a better solution.
99            'inPHPBlock' => true
100        ];
101
102        if ( isset( $dataMw->body->html ) ) {
103            // First look for the extension's content in data-mw.body.html
104            $src = $extApi->htmlToWikitext( $html2wtOpts, $dataMw->body->html );
105        } elseif ( isset( $dataMw->body->id ) ) {
106            // If the body isn't contained in data-mw.body.html, look if
107            // there's an element pointed to by body->id.
108            $bodyElt = DOMCompat::getElementById( $extApi->getTopLevelDoc(), $dataMw->body->id );
109
110            // So far, this is specified for Cite and relies on the "id"
111            // referring to an element in the top level dom, even though the
112            // <ref> itself may be in embedded content,
113            // https://www.mediawiki.org/wiki/Specs/HTML/Extensions/Cite#Ref_and_References
114            // FIXME: This doesn't work if the <references> section
115            // itself is in embedded content, since we aren't traversing
116            // in there.
117
118            // If we couldn't find a body element, this is a bug.
119            // Add some extra debugging for the editing client (ex: VisualEditor)
120            if ( !$bodyElt ) {
121                $extraDebug = '';
122                $firstA = DOMCompat::querySelector( $node, 'a[href]' );
123                if ( $firstA ) {
124                    $href = DOMCompat::getAttribute( $firstA, 'href' );
125                    if ( $href && str_starts_with( $href, '#' ) ) {
126                        try {
127                            $ref = DOMCompat::querySelector( $extApi->getTopLevelDoc(), $href );
128                            if ( $ref ) {
129                                $extraDebug .= ' [doc: ' . DOMCompat::getOuterHTML( $ref ) . ']';
130                            }
131                        } catch ( Exception $e ) {
132                            // We are just providing VE with debugging info.
133                            // So, ignore all exceptions / errors in this code.
134                        }
135                        if ( !$extraDebug ) {
136                            $extraDebug = ' [reference ' . $href . ' not found]';
137                        }
138                    }
139                }
140                $extApi->log(
141                    'error/' . $dataMw->name,
142                    'extension src id ' . $dataMw->body->id . ' points to non-existent element for:',
143                    DOMCompat::getOuterHTML( $node ),
144                    '. More debug info: ',
145                    $extraDebug
146                );
147                return ''; // Drop it!
148            }
149
150            $hasRefName = strlen( $dataMw->attrs->name ?? '' ) > 0;
151            $hasFollow = strlen( $dataMw->attrs->follow ?? '' ) > 0;
152
153            if ( $hasFollow ) {
154                $about = DOMCompat::getAttribute( $node, 'about' );
155                $followNode = $about !== null ? DOMCompat::querySelector(
156                    $bodyElt, "span[typeof~='mw:Cite/Follow'][about='{$about}']"
157                ) : null;
158                if ( $followNode ) {
159                    $src = $extApi->domToWikitext( $html2wtOpts, $followNode, true );
160                    $src = ltrim( $src, ' ' );
161                } else {
162                    $src = '';
163                }
164            } else {
165                if ( $hasRefName ) {
166                    // Follow content may have been added as spans, so drop it
167                    if ( DOMCompat::querySelector( $bodyElt, "span[typeof~='mw:Cite/Follow']" ) ) {
168                        $bodyElt = DOMDataUtils::cloneNode( $bodyElt, true );
169                        foreach ( $bodyElt->childNodes as $child ) {
170                            if ( DOMUtils::hasTypeOf( $child, 'mw:Cite/Follow' ) ) {
171                                // @phan-suppress-next-line PhanTypeMismatchArgumentSuperType
172                                DOMCompat::remove( $child );
173                            }
174                        }
175                    }
176                }
177
178                $src = $extApi->domToWikitext( $html2wtOpts, $bodyElt, true );
179            }
180        } else {
181            $extApi->log( 'error', 'Ref body unavailable for: ' . DOMCompat::getOuterHTML( $node ) );
182            return ''; // Drop it!
183        }
184
185        return $startTagSrc . $src . '</' . $dataMw->name . '>';
186    }
187
188    /** @inheritDoc */
189    public function diffHandler(
190        ParsoidExtensionAPI $extApi, callable $domDiff, Element $origNode,
191        Element $editedNode
192    ): bool {
193        $origDataMw = DOMDataUtils::getDataMw( $origNode );
194        $editedDataMw = DOMDataUtils::getDataMw( $editedNode );
195
196        if ( isset( $origDataMw->body->html ) && isset( $editedDataMw->body->html ) ) {
197            $origFragment = $extApi->htmlToDom(
198                $origDataMw->body->html, $origNode->ownerDocument,
199                [ 'markNew' => true ]
200            );
201            $editedFragment = $extApi->htmlToDom(
202                $editedDataMw->body->html, $editedNode->ownerDocument,
203                [ 'markNew' => true ]
204            );
205            return call_user_func( $domDiff, $origFragment, $editedFragment );
206        } elseif ( isset( $origDataMw->body->id ) && isset( $editedDataMw->body->id ) ) {
207            $origId = $origDataMw->body->id;
208            $editedId = $editedDataMw->body->id;
209
210            // So far, this is specified for Cite and relies on the "id"
211            // referring to an element in the top level dom, even though the
212            // <ref> itself may be in embedded content,
213            // https://www.mediawiki.org/wiki/Specs/HTML/Extensions/Cite#Ref_and_References
214            // FIXME: This doesn't work if the <references> section
215            // itself is in embedded content, since we aren't traversing
216            // in there.
217            $origHtml = DOMCompat::getElementById( $origNode->ownerDocument, $origId );
218            $editedHtml = DOMCompat::getElementById( $editedNode->ownerDocument, $editedId );
219
220            if ( $origHtml && $editedHtml ) {
221                return call_user_func( $domDiff, $origHtml, $editedHtml );
222            } else {
223                // Log error
224                if ( !$origHtml ) {
225                    $extApi->log(
226                        'error/domdiff/orig/ref',
227                        "extension src id {$origId} points to non-existent element for:",
228                        DOMCompat::getOuterHTML( $origNode )
229                    );
230                }
231                if ( !$editedHtml ) {
232                    $extApi->log(
233                        // use info level to avoid logspam for CX edits where translated
234                        // docs might reference nodes not copied over from orig doc.
235                        'info/domdiff/edited/ref',
236                        "extension src id {$editedId} points to non-existent element for:",
237                        DOMCompat::getOuterHTML( $editedNode )
238                    );
239                }
240            }
241        }
242
243        // FIXME: Similar to DOMDiff::subtreeDiffers, maybe $editNode should
244        // be marked as inserted to avoid losing any edits, at the cost of
245        // more normalization
246
247        return false;
248    }
249}