Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 110
0.00% covered (danger)
0.00%
0 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
ConversionTraverser
0.00% covered (danger)
0.00%
0 / 110
0.00% covered (danger)
0.00%
0 / 9
1640
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
12
 noConvertHandler
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 anyHandler
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 langContextHandler
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 textHandler
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 aHandler
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
132
 attrHandler
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
72
 lcHandler
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
72
 docFragToString
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2declare( strict_types = 1 );
3
4namespace Wikimedia\Parsoid\Language;
5
6use Wikimedia\Assert\Assert;
7use Wikimedia\Bcp47Code\Bcp47Code;
8use Wikimedia\LangConv\ReplacementMachine;
9use Wikimedia\Parsoid\Config\Env;
10use Wikimedia\Parsoid\DOM\DocumentFragment;
11use Wikimedia\Parsoid\DOM\Element;
12use Wikimedia\Parsoid\DOM\Node;
13use Wikimedia\Parsoid\DOM\Text;
14use Wikimedia\Parsoid\Utils\DOMCompat;
15use Wikimedia\Parsoid\Utils\DOMDataUtils;
16use Wikimedia\Parsoid\Utils\DOMTraverser;
17use Wikimedia\Parsoid\Utils\DOMUtils;
18use Wikimedia\Parsoid\Utils\Utils;
19
20class ConversionTraverser extends DOMTraverser {
21
22    /** @var Bcp47Code a language code */
23    private $toLang;
24
25    /** @var Bcp47Code a language code */
26    private $fromLang;
27
28    /** @var LanguageGuesser */
29    private $guesser;
30
31    /** @var ReplacementMachine (uses MW-internal codes) */
32    private $machine;
33
34    /**
35     * @param Env $env
36     * @param Bcp47Code $toLang target language for conversion
37     * @param LanguageGuesser $guesser oracle to determine "original language" for round-tripping
38     * @param ReplacementMachine $machine machine to do actual conversion
39     */
40    public function __construct(
41        Env $env, Bcp47Code $toLang, LanguageGuesser $guesser, ReplacementMachine $machine
42    ) {
43        parent::__construct();
44        $this->toLang = $toLang;
45        $this->guesser = $guesser;
46        $this->machine = $machine;
47
48        // No conversion inside <code>, <script>, <pre>, <cite>
49        // (See adhoc regexps inside LanguageConverter.php::autoConvert)
50        // XXX: <cite> ought to probably be handled more generically
51        // as extension output, not special-cased as a HTML tag.
52        foreach ( [ 'code', 'script', 'pre', 'cite' ] as $el ) {
53            $this->addHandler( $el, function ( Element $el ) {
54                return $this->noConvertHandler( $el );
55            } );
56        }
57        // Setting/saving the language context
58        $this->addHandler( null, function ( Node $node ) {
59            return $this->anyHandler( $node );
60        } );
61        $this->addHandler( 'p', function ( Element $el ) {
62            return $this->langContextHandler( $el );
63        } );
64        $this->addHandler( 'body', function ( Element $el ) {
65            return $this->langContextHandler( $el );
66        } );
67        // Converting #text, <a> nodes, and title/alt attributes
68        $this->addHandler( '#text', function ( Node $node ) {
69            return $this->textHandler( $node );
70        } );
71        $this->addHandler( 'a', function ( Element $el ) use ( $env ){
72            return $this->aHandler( $el, $env );
73        } );
74        $this->addHandler( null, function ( Node $node ) {
75            return $this->attrHandler( $node );
76        } );
77        // LanguageConverter markup
78        foreach ( [ 'meta', 'div', 'span' ] as $el ) {
79            $this->addHandler( $el, function ( Element $el ) {
80                return $this->lcHandler( $el );
81            } );
82        }
83    }
84
85    /**
86     * @param Element $el
87     * @return ?Node|bool
88     */
89    private function noConvertHandler( Element $el ) {
90        // Don't touch the inside of this node!
91        return $el->nextSibling;
92    }
93
94    /**
95     * @param Node $node
96     * @return ?Node|bool
97     */
98    private function anyHandler( Node $node ) {
99        /* Look for `lang` attributes */
100        if ( $node instanceof Element ) {
101            if ( $node->hasAttribute( 'lang' ) ) {
102                $lang = DOMCompat::getAttribute( $node, 'lang' );
103                // XXX validate lang! override fromLang?
104                // $this->>fromLang = $lang;
105            }
106        }
107        return true; // Continue with other handlers
108    }
109
110    /**
111     * @param Element $el
112     * @return ?Node|bool
113     */
114    private function langContextHandler( Element $el ) {
115        $this->fromLang = $this->guesser->guessLang( $el );
116        // T320662: use internal MW language names for now :(
117        $fromLangMw = Utils::bcp47ToMwCode( $this->fromLang );
118        $el->setAttribute( 'data-mw-variant-lang', $fromLangMw );
119        return true; // Continue with other handlers
120    }
121
122    /**
123     * @param Node $node
124     * @return ?Node|bool
125     */
126    private function textHandler( Node $node ) {
127        Assert::invariant( $this->fromLang !== null, 'Text w/o a context' );
128        $toLangMw = Utils::bcp47ToMwCode( $this->toLang );
129        $fromLangMw = Utils::bcp47ToMwCode( $this->fromLang );
130        // @phan-suppress-next-line PhanTypeMismatchArgument,PhanTypeMismatchReturn both declared as DOMNode
131        return $this->machine->replace( $node, $toLangMw, $fromLangMw );
132    }
133
134    /**
135     * @param Element $el
136     * @param Env $env
137     * @return ?Node|bool
138     */
139    private function aHandler( Element $el, Env $env ) {
140        // Is this a wikilink?  If so, extract title & convert it
141        if ( DOMUtils::hasRel( $el, 'mw:WikiLink' ) ) {
142            $href = preg_replace( '#^(\.\.?/)+#', '', DOMCompat::getAttribute( $el, 'href' ) ?? '', 1 );
143            $fromPage = Utils::decodeURI( $href );
144            $toPageFrag = $this->machine->convert(
145                $el->ownerDocument, $fromPage,
146                Utils::bcp47ToMwCode( $this->toLang ),
147                Utils::bcp47ToMwCode( $this->fromLang )
148            );
149            '@phan-var DocumentFragment $toPageFrag'; // @var DocumentFragment $toPageFrag
150            $toPage = $this->docFragToString( $toPageFrag );
151            if ( $toPage === null ) {
152                // Non-reversible transform (sigh); mark this for rt.
153                $el->setAttribute( 'data-mw-variant-orig', $fromPage );
154                $toPage = $this->docFragToString( $toPageFrag, true/* force */ );
155            }
156            if ( $el->hasAttribute( 'title' ) ) {
157                $el->setAttribute( 'title', str_replace( '_', ' ', $toPage ) );
158            }
159            $el->setAttribute( 'href', "./{$toPage}" );
160        } elseif ( DOMUtils::hasRel( $el, 'mw:WikiLink/Interwiki' ) ) {
161            // Don't convert title or children of interwiki links
162            return $el->nextSibling;
163        } elseif ( DOMUtils::hasRel( $el, 'mw:ExtLink' ) ) {
164            // WTUtils.usesURLLinkSyntax uses data-parsoid, so don't use it,
165            // but syntactic free links should also have class="external free"
166            if ( DOMUtils::hasClass( $el, 'free' ) ) {
167                // Don't convert children of syntactic "free links"
168                return $el->nextSibling;
169            }
170            // Other external link text is protected from conversion iff
171            // (a) it doesn't starts/end with -{ ... }-
172            if ( $el->firstChild &&
173                DOMUtils::hasTypeOf( $el->firstChild, 'mw:LanguageVariant' ) ) {
174                return true;
175            }
176            // (b) it looks like a URL (protocol-relative links excluded)
177            $linkText = $el->textContent; // XXX: this could be expensive
178            if ( Utils::isProtocolValid( $linkText, $env )
179                 && substr( $linkText, 0, 2 ) !== '//'
180            ) {
181                return $el->nextSibling;
182            }
183        }
184        return true;
185    }
186
187    /**
188     * @param Node $node
189     * @return ?Node|bool
190     */
191    private function attrHandler( Node $node ) {
192        // Convert `alt` and `title` attributes on elements
193        // (Called before aHandler, so the `title` might get overwritten there)
194        if ( !( $node instanceof Element ) ) {
195            return true;
196        }
197        DOMUtils::assertElt( $node );
198        foreach ( [ 'title', 'alt' ] as $attr ) {
199            $orig = DOMCompat::getAttribute( $node, $attr );
200            if ( $orig === null ) {
201                continue;
202            }
203            if ( $attr === 'title' && DOMUtils::hasRel( $node, 'mw:WikiLink' ) ) {
204                // We've already converted the title in aHandler above.
205                continue;
206            }
207            if ( str_contains( $orig, '://' ) ) {
208                continue; /* Don't convert URLs */
209            }
210            $toFrag = $this->machine->convert(
211                $node->ownerDocument, $orig,
212                Utils::bcp47ToMwCode( $this->toLang ),
213                Utils::bcp47ToMwCode( $this->fromLang )
214            );
215            '@phan-var DocumentFragment $toFrag'; // @var DocumentFragment $toFrag
216            $to = $this->docFragToString( $toFrag );
217            if ( $to === null ) {
218                // Non-reversible transform (sigh); mark for rt.
219                $node->setAttribute( "data-mw-variant-{$attr}", $orig );
220                $to = $this->docFragToString( $toFrag, true/* force */ );
221            }
222            $node->setAttribute( $attr, $to );
223        }
224        return true;
225    }
226
227    /**
228     * Handler for LanguageConverter markup
229     *
230     * @param Element $el
231     * @return ?Node|bool
232     */
233    private function lcHandler( Element $el ) {
234        if ( !DOMUtils::hasTypeOf( $el, 'mw:LanguageVariant' ) ) {
235            return true; /* not language converter markup */
236        }
237        $dmv = DOMDataUtils::getJSONAttribute( $el, 'data-mw-variant', [] );
238        if ( isset( $dmv->disabled ) ) {
239            DOMCompat::setInnerHTML( $el, $dmv->disabled->t );
240            // XXX check handling of embedded data-parsoid
241            // XXX check handling of nested constructs
242            return $el->nextSibling;
243        } elseif ( isset( $dmv->twoway ) ) {
244            // FIXME
245        } elseif ( isset( $dmv->oneway ) ) {
246            // FIXME
247        } elseif ( isset( $dmv->name ) ) {
248            // FIXME
249        } elseif ( isset( $dmv->filter ) ) {
250            // FIXME
251        } elseif ( isset( $dmv->describe ) ) {
252            // FIXME
253        }
254        return true;
255    }
256
257    private function docFragToString(
258        DocumentFragment $docFrag, bool $force = false
259    ): ?string {
260        if ( !$force ) {
261            for ( $child = $docFrag->firstChild; $child; $child = $child->nextSibling ) {
262                if ( !( $child instanceof Text ) ) {
263                    return null; /* unsafe */
264                }
265            }
266        }
267        return $docFrag->textContent;
268    }
269}