Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
52.15% covered (warning)
52.15%
158 / 303
10.53% covered (danger)
10.53%
2 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
Parsoid
52.15% covered (warning)
52.15%
158 / 303
10.53% covered (danger)
10.53%
2 / 19
558.96
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 version
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 defaultHTMLVersion
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 resolveContentVersion
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
 supportsLanguageConversion
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setupCommonOptions
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
72
 parseWikitext
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
56
 wikitext2html
96.77% covered (success)
96.77%
30 / 31
0.00% covered (danger)
0.00%
0 / 1
6
 recordParseMetrics
46.55% covered (danger)
46.55%
27 / 58
0.00% covered (danger)
0.00%
0 / 1
17.77
 wikitext2lint
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 dom2wikitext
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
6
 recordSerializationMetrics
69.23% covered (warning)
69.23%
18 / 26
0.00% covered (danger)
0.00%
0 / 1
4.47
 html2wikitext
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 pb2pb
97.30% covered (success)
97.30%
72 / 74
0.00% covered (danger)
0.00%
0 / 1
7
 substTopLevelTemplates
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 findDowngrade
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
 downgrade
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
30
 implementsLanguageConversionBcp47
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 downgrade999to2
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2declare( strict_types = 1 );
3
4namespace Wikimedia\Parsoid;
5
6use Composer\InstalledVersions;
7use Composer\Semver\Comparator;
8use Composer\Semver\Semver;
9use InvalidArgumentException;
10use LogicException;
11use Wikimedia\Bcp47Code\Bcp47Code;
12use Wikimedia\Parsoid\Config\DataAccess;
13use Wikimedia\Parsoid\Config\Env;
14use Wikimedia\Parsoid\Config\PageConfig;
15use Wikimedia\Parsoid\Config\SiteConfig;
16use Wikimedia\Parsoid\Config\StubMetadataCollector;
17use Wikimedia\Parsoid\Core\ContentMetadataCollector;
18use Wikimedia\Parsoid\Core\PageBundle;
19use Wikimedia\Parsoid\Core\ResourceLimitExceededException;
20use Wikimedia\Parsoid\Core\SelectiveUpdateData;
21use Wikimedia\Parsoid\DOM\Document;
22use Wikimedia\Parsoid\Ext\ParsoidExtensionAPI;
23use Wikimedia\Parsoid\Language\LanguageConverter;
24use Wikimedia\Parsoid\Logger\LintLogger;
25use Wikimedia\Parsoid\Utils\ComputeSelectiveStats;
26use Wikimedia\Parsoid\Utils\ContentUtils;
27use Wikimedia\Parsoid\Utils\DOMCompat;
28use Wikimedia\Parsoid\Utils\DOMDataUtils;
29use Wikimedia\Parsoid\Utils\DOMUtils;
30use Wikimedia\Parsoid\Utils\Timing;
31use Wikimedia\Parsoid\Utils\Utils;
32use Wikimedia\Parsoid\Wikitext\Wikitext;
33use Wikimedia\Parsoid\Wt2Html\DOM\Processors\AddRedLinks;
34use Wikimedia\Parsoid\Wt2Html\DOM\Processors\ConvertOffsets;
35
36class Parsoid {
37
38    /**
39     * Available HTML content versions.
40     * @see https://www.mediawiki.org/wiki/Parsoid/API#Content_Negotiation
41     * @see https://www.mediawiki.org/wiki/Specs/HTML#Versioning
42     */
43    public const AVAILABLE_VERSIONS = [ '2.8.0', '999.0.0' ];
44
45    private const DOWNGRADES = [
46        [ 'from' => '999.0.0', 'to' => '2.0.0', 'func' => 'downgrade999to2' ],
47    ];
48
49    /** @var SiteConfig */
50    private $siteConfig;
51
52    /** @var DataAccess */
53    private $dataAccess;
54
55    public function __construct(
56        SiteConfig $siteConfig, DataAccess $dataAccess
57    ) {
58        $this->siteConfig = $siteConfig;
59        $this->dataAccess = $dataAccess;
60    }
61
62    /**
63     * Returns the currently-installed version of Parsoid.
64     * @return string
65     */
66    public static function version(): string {
67        try {
68            // See https://getcomposer.org/doc/07-runtime.md#knowing-the-version-of-package-x
69            return InstalledVersions::getVersion( 'wikimedia/parsoid' ) ??
70                // From the composer runtime API docs:
71                // "It is nonetheless a good idea to make sure you
72                // handle the null return value as gracefully as
73                // possible for safety."
74                'null';
75        } catch ( \Throwable $t ) {
76            // Belt-and-suspenders protection against parts of the composer
77            // runtime API being absent in production.
78            return 'error';
79        }
80    }
81
82    /**
83     * Returns the default HTML content version
84     * @return string
85     */
86    public static function defaultHTMLVersion(): string {
87        return self::AVAILABLE_VERSIONS[0];
88    }
89
90    /**
91     * See if any content version Parsoid knows how to produce satisfies the
92     * the supplied version, when interpreted with semver caret semantics.
93     * This will allow us to make backwards compatible changes, without the need
94     * for clients to bump the version in their headers all the time.
95     *
96     * @param string $version
97     * @return string|null
98     */
99    public static function resolveContentVersion( string $version ) {
100        foreach ( self::AVAILABLE_VERSIONS as $i => $a ) {
101            if ( Semver::satisfies( $a, "^{$version}" ) &&
102                // The section wrapping in 1.6.x should have induced a major
103                // version bump, since it requires upgrading clients to
104                // handle it.  We therefore hardcode this in so that we can
105                // fail hard.
106                Comparator::greaterThanOrEqualTo( $version, '1.6.0' )
107            ) {
108                return $a;
109            }
110        }
111        return null;
112    }
113
114    /**
115     * Determine if language conversion is enabled, aka if the optional
116     * wikimedia/langconv library is installed.
117     * @return bool True if the wikimedia/langconv library is available
118     */
119    public static function supportsLanguageConversion(): bool {
120        return class_exists( '\Wikimedia\LangConv\ReplacementMachine' );
121    }
122
123    private function setupCommonOptions( array $options ): array {
124        $envOptions = [];
125        if ( isset( $options['offsetType'] ) ) {
126            $envOptions['offsetType'] = $options['offsetType'];
127        }
128        if ( isset( $options['traceFlags'] ) ) {
129            $envOptions['traceFlags'] = $options['traceFlags'];
130        }
131        if ( isset( $options['dumpFlags'] ) ) {
132            $envOptions['dumpFlags'] = $options['dumpFlags'];
133        }
134        if ( isset( $options['debugFlags'] ) ) {
135            $envOptions['debugFlags'] = $options['debugFlags'];
136        }
137        if ( !empty( $options['htmlVariantLanguage'] ) ) {
138            $envOptions['htmlVariantLanguage'] = $options['htmlVariantLanguage'];
139        }
140        if ( !empty( $options['wtVariantLanguage'] ) ) {
141            $envOptions['wtVariantLanguage'] = $options['wtVariantLanguage'];
142        }
143        if ( isset( $options['logLevels'] ) ) {
144            $envOptions['logLevels'] = $options['logLevels'];
145        }
146        return $envOptions;
147    }
148
149    /**
150     * Parsing code shared between the next two methods.
151     *
152     * @param PageConfig $pageConfig
153     * @param ContentMetadataCollector $metadata
154     * @param array $options See wikitext2html.
155     * @param ?SelectiveUpdateData $selparData See wikitext2html.
156     * @return array{0:Env,1:Document,2:?string}
157     */
158    private function parseWikitext(
159        PageConfig $pageConfig,
160        ContentMetadataCollector $metadata,
161        array $options = [],
162        ?SelectiveUpdateData $selparData = null
163    ): array {
164        $envOptions = $this->setupCommonOptions( $options );
165        if ( isset( $options['outputContentVersion'] ) ) {
166            $envOptions['outputContentVersion'] = $options['outputContentVersion'];
167        }
168        $envOptions['discardDataParsoid'] = !empty( $options['discardDataParsoid'] );
169        if ( isset( $options['wrapSections'] ) ) {
170            $envOptions['wrapSections'] = (bool)$options['wrapSections'];
171        }
172        if ( isset( $options['pageBundle'] ) ) {
173            $envOptions['pageBundle'] = (bool)$options['pageBundle'];
174        }
175        if ( isset( $options['logLinterData'] ) ) {
176            $envOptions['logLinterData'] = (bool)$options['logLinterData'];
177        }
178        if ( isset( $options['linterOverrides'] ) ) {
179            $envOptions['linterOverrides'] = $options['linterOverrides'];
180        }
181        $envOptions['skipLanguageConversionPass'] =
182            $options['skipLanguageConversionPass'] ?? false;
183
184        $env = new Env(
185            $this->siteConfig, $pageConfig, $this->dataAccess, $metadata, $envOptions
186        );
187        if ( !$env->compareWt2HtmlLimit(
188            'wikitextSize', strlen( $env->topFrame->getSrcText() )
189        ) ) {
190            throw new ResourceLimitExceededException(
191                "wt2html: wikitextSize limit exceeded"
192            );
193        }
194        $contentmodel = $options['contentmodel'] ?? null;
195        $handler = $env->getContentHandler( $contentmodel );
196        $extApi = new ParsoidExtensionAPI( $env );
197        // FIXME: Hardcoded to assume 'mode' is 'template'
198        return [ $env, $handler->toDOM( $extApi, $selparData ), $contentmodel ];
199    }
200
201    /**
202     * Parse the wikitext supplied in a `PageConfig` to HTML.
203     *
204     * @param PageConfig $pageConfig
205     * @param array $options [
206     *   'wrapSections'         => (bool) Whether `<section>` wrappers should be added.
207     *   'pageBundle'           => (bool) Sets ids on nodes and stores
208     *                                    data-* attributes in a JSON blob.
209     *   'body_only'            => (bool|null) Only return the <body> children (T181657)
210     *   'outputContentVersion' => (string|null) Version of HTML to output.
211     *                                           `null` returns the default version.
212     *   'contentmodel'         => (string|null) The content model of the input.
213     *   'discardDataParsoid'   => (bool) Drop all data-parsoid annotations.
214     *   'offsetType'           => (string) ucs2, char, byte are valid values
215     *                                      what kind of source offsets should be emitted?
216     *   'skipLanguageConversionPass'  => (bool) Skip the language variant conversion pass (defaults to false)
217     *   'htmlVariantLanguage'  => (Bcp47Code) If non-null, the language variant used for Parsoid HTML.
218     *   'wtVariantLanguage'    => (Bcp47Code) If non-null, the language variant used for wikitext.
219     *   'logLinterData'        => (bool) Should we log linter data if linting is enabled?
220     *   'linterOverrides'      => (array) Override the site linting configs.
221     *   // Debugging options, not for use in production
222     *   'traceFlags'           => (array) associative array with tracing options
223     *   'dumpFlags'            => (array) associative array with dump options
224     *   'debugFlags'           => (array) associative array with debug options
225     *   'logLevels'            => (string[]) Levels to log
226     *   // Experimental options, not considered stable
227     *   'sampleStats'          => (bool) If true, okay to perform "expensive"
228     *                             analysis to generate metrics.
229     *   'renderReason'         => (?string) Passed through from MediaWiki core
230     *                             to classify metrics; see
231     *                             ParserOptions::getRenderReason()
232     *   'previousInput'        => (?PageConfig) wikitext, revision ID, etc of
233     *                             some recent parse of this page.
234     *                             Not guaranteed to be usable for selective
235     *                             update, and could even be from a "newer"
236     *                             revision (if this is a render of an old
237     *                             revision).
238     *   'previousOutput'       => (?PageBundle) output of the prior parse of
239     *                             'previousInput'
240     * ]
241     * @param ?array &$headers
242     * @param ?ContentMetadataCollector $metadata Pass in a CMC in order to
243     *  collect and retrieve metadata about the parse.
244     * @param ?SelectiveUpdateData $selparData
245     * @return PageBundle|string
246     */
247    public function wikitext2html(
248        PageConfig $pageConfig, array $options = [], ?array &$headers = null,
249        ?ContentMetadataCollector $metadata = null, ?SelectiveUpdateData $selparData = null
250    ) {
251        if ( $metadata === null ) {
252            $metadata = new StubMetadataCollector( $this->siteConfig );
253        }
254
255        $parseTiming = Timing::start();
256        [ $env, $doc, $contentmodel ] = $this->parseWikitext( $pageConfig, $metadata, $options, $selparData );
257        $parseTimeMs = $parseTiming->end();
258
259        // FIXME: Does this belong in parseWikitext so that the other endpoint
260        // is covered as well?  It probably depends on expectations of the
261        // Rest API.  If callers of /page/lint/ assume that will update the
262        // results on the Special page.
263        if ( $env->linting() ) {
264            ( new LintLogger( $env ) )->logLintOutput();
265        }
266
267        $headers = DOMUtils::findHttpEquivHeaders( $doc );
268        $body_only = !empty( $options['body_only'] );
269        $node = $body_only ? DOMCompat::getBody( $doc ) : $doc;
270
271        if ( $env->pageBundle ) {
272            $out = ContentUtils::extractDpAndSerialize( $node, [
273                'innerXML' => $body_only,
274            ] );
275        } else {
276            $out = [
277                'html' => ContentUtils::toXML( $node, [
278                    'innerXML' => $body_only,
279                ] ),
280            ];
281        }
282
283        $this->recordParseMetrics(
284            $env, $parseTimeMs, $out, $headers, $contentmodel, $options
285        );
286
287        if ( $env->pageBundle ) {
288            return new PageBundle(
289                $out['html'],
290                $out['pb']->parsoid, $out['pb']->mw ?? null,
291                $env->getOutputContentVersion(),
292                $headers,
293                $contentmodel
294            );
295        } else {
296            return $out['html'];
297        }
298    }
299
300    /**
301     *
302     */
303    private function recordParseMetrics(
304        Env $env, float $parseTimeMs,
305        array $out, ?array $headers, string $contentmodel,
306        array $options
307    ) {
308        $metrics = $this->siteConfig->metrics();
309
310        $pageConfig = $env->getPageConfig();
311
312        // This is somewhat suspect because ParsoidHandler::tryToCreatePageConfig
313        // can set a revision id on a MutableRevisionRecord, but it might be simpler
314        // to make that go away
315        if ( $pageConfig->getRevisionId() ) {
316            $mstr = 'pageWithOldid';
317        } else {
318            $mstr = 'wt';
319        }
320
321        $timing = Timing::fakeTiming( $this->siteConfig, $parseTimeMs );
322        $timing->end( "entry.wt2html.{$mstr}.parse", 'wt2html_parse_seconds', [ 'type' => $mstr ] );
323        $version = 'default';
324
325        if ( Semver::satisfies(
326            $env->getOutputContentVersion(), '!=' . self::defaultHTMLVersion()
327        ) ) {
328            if ( $metrics ) {
329                $metrics->increment( 'entry.wt2html.parse.version.notdefault' );
330            }
331            $version = 'non-default';
332        }
333
334        $this->siteConfig->incrementCounter( 'wt2html_parse_total', [
335            'type' => $mstr,
336            'version' => $version
337        ] );
338
339        // @phan-suppress-next-line PhanDeprecatedFunction
340        $timing = Timing::fakeTiming( $this->siteConfig, strlen( $pageConfig->getPageMainContent() ) );
341        $timing->end(
342            "entry.wt2html.{$mstr}.size.input",
343            "wt2html_size_input_bytes",
344            [ "type" => $mstr ]
345        );
346
347        $outSize = strlen( $out['html'] );
348        $timing = Timing::fakeTiming( $this->siteConfig, $outSize );
349        $timing->end( "entry.wt2html.{$mstr}.size.output", "wt2html_size_output_bytes", [ "type" => $mstr ] );
350
351        if ( $parseTimeMs > 10 && $outSize > 100 ) {
352            // * Don't bother with this metric for really small parse times
353            //   p99 for initialization time is ~7ms according to grafana.
354            //   So, 10ms ensures that startup overheads don't skew the metrics
355            // * For body_only=false requests, <head> section isn't generated
356            //   and if the output is small, per-request overheads can skew
357            //   the timePerKB metrics.
358            //
359            // NOTE: This is slightly misleading since there are fixed costs
360            // for generating output like the <head> section and should be factored in,
361            // but this is good enough for now as a useful first degree of approxmation.
362            $msPerKB = $parseTimeMs * 1024 / $outSize;
363            $timing = Timing::fakeTiming( $this->siteConfig, $msPerKB );
364            $timing->end(
365                'entry.wt2html.timePerKB',
366                'wt2html_msPerKB',
367                []
368            );
369        }
370
371        // Expensive analyses: sampleStats is randomly sampled will not be
372        // true "often"
373        $doSample = $options['sampleStats'] ?? false;
374        if ( !$doSample ) {
375            return;
376        }
377
378        try {
379            // create new page bundle for this computation to ensure we
380            // don't inadvertently corrupt the main document result.
381            $newPb = new PageBundle(
382                $out['html'],
383                $out['pb']->parsoid, $out['pb']->mw ?? null,
384                $env->getOutputContentVersion(),
385                $headers,
386                $contentmodel
387            );
388            $labels = ComputeSelectiveStats::classify(
389                $env,
390                $options['previousInput'] ?? null,
391                $options['previousOutput'] ?? null,
392                $pageConfig,
393                $newPb
394            );
395            $labels['wiki'] = $this->siteConfig->iwp();
396            $labels['reason'] = $options['renderReason'] ?? 'unknown';
397
398            $this->siteConfig->incrementCounter( 'selective_update_total', $labels );
399            $this->siteConfig->incrementCounter( 'selective_update_seconds', $labels, $parseTimeMs / 1000. );
400        } catch ( \Throwable $t ) {
401            // Don't ever allow bugs in the classification code to
402            // impact the availability of content for read views/editing,
403            // just log.
404            $env->log( 'warn', 'Classification failure', $t->getTraceAsString() );
405        }
406    }
407
408    /**
409     * Lint the wikitext supplied in a `PageConfig`.
410     *
411     * @param PageConfig $pageConfig
412     * @param array $options See wikitext2html.
413     * @param ?ContentMetadataCollector $metadata Pass in a CMC in order to
414     *  collect and retrieve metadata about the parse.
415     * @return array
416     */
417    public function wikitext2lint(
418        PageConfig $pageConfig, array $options = [],
419        ?ContentMetadataCollector $metadata = null
420    ): array {
421        if ( $metadata === null ) {
422            $metadata = new StubMetadataCollector( $this->siteConfig );
423        }
424        [ $env, ] = $this->parseWikitext( $pageConfig, $metadata, $options );
425        return $env->getLints();
426    }
427
428    /**
429     * Serialize DOM to wikitext.
430     *
431     * @param PageConfig $pageConfig
432     * @param Document $doc Data attributes are expected to have been applied
433     *   already.  Loading them will happen once the environment is created.
434     * @param array $options [
435     *   'inputContentVersion' => (string) The content version of the input.
436     *     Necessary if it differs from the current default in order to
437     *     account for any serialization differences.
438     *   'offsetType'          => (string) ucs2, char, byte are valid values
439     *                                     what kind of source offsets are present in the HTML?
440     *   'contentmodel'        => (string|null) The content model of the input.
441     *   'htmlVariantLanguage' => (Bcp47Code) If non-null, the language variant used for Parsoid HTML.
442     *   'wtVariantLanguage'   => (Bcp47Code) If non-null, the language variant used for wikitext.
443     *   'traceFlags'          => (array) associative array with tracing options
444     *   'dumpFlags'           => (array) associative array with dump options
445     *   'debugFlags'          => (array) associative array with debug options
446     *   'logLevels'           => (string[]) Levels to log
447     *   'htmlSize'            => (int) Size of the HTML that generated $doc
448     * ]
449     * @param ?SelectiveUpdateData $selserData
450     * @return string
451     */
452    public function dom2wikitext(
453        PageConfig $pageConfig, Document $doc, array $options = [],
454        ?SelectiveUpdateData $selserData = null
455    ): string {
456        $envOptions = $this->setupCommonOptions( $options );
457        if ( isset( $options['inputContentVersion'] ) ) {
458            $envOptions['inputContentVersion'] = $options['inputContentVersion'];
459        }
460        $envOptions['topLevelDoc'] = $doc;
461        $metadata = new StubMetadataCollector( $this->siteConfig );
462        $env = new Env(
463            $this->siteConfig, $pageConfig, $this->dataAccess, $metadata, $envOptions
464        );
465        $env->bumpHtml2WtResourceUse( 'htmlSize', $options['htmlSize'] ?? 0 );
466        $contentmodel = $options['contentmodel'] ?? null;
467        $handler = $env->getContentHandler( $contentmodel );
468        $extApi = new ParsoidExtensionAPI( $env );
469
470        $serialTiming = Timing::start();
471        $wikitext = $handler->fromDOM( $extApi, $selserData );
472        $serialTime = $serialTiming->end();
473
474        $this->recordSerializationMetrics( $options, $serialTime, $wikitext );
475
476        return $wikitext;
477    }
478
479    /**
480     *
481     */
482    private function recordSerializationMetrics(
483        array $options, float $serialTime, string $wikitext
484    ) {
485        $siteConfig = $this->siteConfig;
486        $metrics = $siteConfig->metrics();
487
488        $htmlSize = $options['htmlSize'] ?? 0;
489        $timing = Timing::fakeTiming( $this->siteConfig, $htmlSize );
490        $timing->end( 'entry.html2wt.size.input', 'html2wt_size_input_bytes' );
491
492        if ( isset( $options['inputContentVersion'] ) ) {
493            if ( $metrics ) {
494                $metrics->increment(
495                    'entry.html2wt.original.version.' . $options['inputContentVersion']
496                );
497            }
498            $this->siteConfig->incrementCounter(
499                'html2wt_original_version',
500                [ 'input_content_version' => $options['inputContentVersion'] ]
501            );
502        }
503
504        $timing = Timing::fakeTiming( $this->siteConfig, $serialTime );
505        $timing->end( 'entry.html2wt.total', 'html2wt_total_seconds', [] );
506
507        $timing = Timing::fakeTiming( $this->siteConfig, strlen( $wikitext ) );
508        $timing->end( 'entry.html2wt.size.output', 'html2wt_size_output_bytes', [] );
509
510        if ( $htmlSize ) {  // Avoid division by zero
511            // NOTE: the name timePerInputKB is misleading, since $htmlSize is
512            //       in characters, not bytes.
513            $msPerKB = $serialTime * 1024 / $htmlSize;
514            $timing = Timing::fakeTiming( $this->siteConfig, $msPerKB );
515            $timing->end(
516                'entry.html2wt.timePerInputKB',
517                'html2wt_msPerKB',
518                []
519            );
520        }
521    }
522
523    /**
524     * Serialize HTML to wikitext.  Convenience method for dom2wikitext.
525     *
526     * @param PageConfig $pageConfig
527     * @param string $html
528     * @param array $options
529     * @param ?SelectiveUpdateData $selserData
530     * @return string
531     */
532    public function html2wikitext(
533        PageConfig $pageConfig, string $html, array $options = [],
534        ?SelectiveUpdateData $selserData = null
535    ): string {
536        $doc = DOMUtils::parseHTML( $html, true );
537        $options['htmlSize'] ??= mb_strlen( $html );
538        return $this->dom2wikitext( $pageConfig, $doc, $options, $selserData );
539    }
540
541    /**
542     * Update the supplied PageBundle based on the `$update` type.
543     *
544     *   'convertoffsets': Convert offsets between formats (byte, char, ucs2)
545     *   'redlinks': Refreshes the classes of known, missing, etc. links.
546     *   'variant': Converts the HTML based on the supplied variant.
547     *
548     * Note that these are DOM transforms, and not roundtrips through wikitext.
549     *
550     * @param PageConfig $pageConfig
551     * @param string $update 'redlinks'|'variant'
552     * @param PageBundle $pb
553     * @param array $options
554     * @return PageBundle
555     */
556    public function pb2pb(
557        PageConfig $pageConfig, string $update, PageBundle $pb,
558        array $options = []
559    ): PageBundle {
560        $envOptions = [
561            'pageBundle' => true,
562            'topLevelDoc' => DOMUtils::parseHTML( $pb->toHtml(), true ),
563        ];
564        $metadata = new StubMetadataCollector( $this->siteConfig );
565        $env = new Env(
566            $this->siteConfig, $pageConfig, $this->dataAccess, $metadata, $envOptions
567        );
568        $doc = $env->topLevelDoc;
569        DOMDataUtils::visitAndLoadDataAttribs(
570            DOMCompat::getBody( $doc ), [ 'markNew' => true ]
571        );
572
573        $dataBagPB = DOMDataUtils::getPageBundle( $doc );
574        switch ( $update ) {
575            case 'convertoffsets':
576                ContentUtils::convertOffsets(
577                    $env, $doc, $options['inputOffsetType'], $options['outputOffsetType']
578                );
579                $dataBagPB->parsoid['offsetType'] = $options['outputOffsetType'];
580                $dataBagPB->parsoid['counter'] = $pb->parsoid['counter'];
581                break;
582
583            case 'redlinks':
584                ContentUtils::convertOffsets(
585                    $env, $doc, $env->getRequestOffsetType(), 'byte'
586                );
587                ( new AddRedLinks() )->run( $env, DOMCompat::getBody( $doc ) );
588                ( new ConvertOffsets() )->run( $env, DOMCompat::getBody( $doc ), [], true );
589                break;
590
591            case 'variant':
592                ContentUtils::convertOffsets(
593                    $env, $doc, $env->getRequestOffsetType(), 'byte'
594                );
595
596                // Note that `maybeConvert` could still be a no-op, in case the
597                // __NOCONTENTCONVERT__ magic word is present, or the htmlVariant
598                // is a base language code or otherwise invalid.
599                $hasWtVariant = $options['variant']['wikitext'] ??
600                    // Deprecated name for this option:
601                    $options['variant']['source'] ?? false;
602                LanguageConverter::maybeConvert(
603                    $env, $doc,
604                    Utils::mwCodeToBcp47(
605                        $options['variant']['html'] ??
606                        // Deprecated name for this option:
607                        $options['variant']['target'],
608                        // Be strict in what we accept.
609                        true, $this->siteConfig->getLogger()
610                    ),
611                    $hasWtVariant ?
612                    Utils::mwCodeToBcp47(
613                        $options['variant']['wikitext'] ??
614                        // Deprecated name for this option:
615                        $options['variant']['source'],
616                        // Be strict in what we accept.
617                        true, $this->siteConfig->getLogger()
618                    ) : null
619                );
620
621                // NOTE: Keep this in sync with code in core's LanguageVariantConverter
622                // Update content-language and vary headers.
623                DOMUtils::addHttpEquivHeaders( $doc, [
624                    'content-language' => $env->htmlContentLanguageBcp47()->toBcp47Code(),
625                    'vary' => $env->htmlVary()
626                ] );
627
628                ( new ConvertOffsets() )->run( $env, DOMCompat::getBody( $doc ), [], true );
629                break;
630
631            default:
632                throw new LogicException( $update . 'is an unknown transformation' );
633        }
634
635        DOMDataUtils::visitAndStoreDataAttribs(
636            DOMCompat::getBody( $doc ), [
637                'discardDataParsoid' => $env->discardDataParsoid,
638                'storeInPageBundle' => $env->pageBundle,
639                'env' => $env,
640            ]
641        );
642        $body_only = !empty( $options['body_only'] );
643        $node = $body_only ? DOMCompat::getBody( $doc ) : $doc;
644        DOMDataUtils::injectPageBundle( $doc, $dataBagPB );
645        $out = ContentUtils::extractDpAndSerialize( $node, [
646            'innerXML' => $body_only,
647        ] );
648        return new PageBundle(
649            $out['html'],
650            $out['pb']->parsoid, $out['pb']->mw ?? null,
651            // Prefer the passed in version, since this was just a transformation
652            $pb->version ?? $env->getOutputContentVersion(),
653            DOMUtils::findHttpEquivHeaders( $doc ),
654            // Prefer the passed in content model
655            $pb->contentmodel ?? $pageConfig->getContentModel()
656        );
657    }
658
659    /**
660     * Perform pre-save transformations with top-level templates subst'd.
661     *
662     * @param PageConfig $pageConfig
663     * @param string $wikitext
664     * @return string
665     */
666    public function substTopLevelTemplates(
667        PageConfig $pageConfig, string $wikitext
668    ): string {
669        $metadata = new StubMetadataCollector( $this->siteConfig );
670        $env = new Env( $this->siteConfig, $pageConfig, $this->dataAccess, $metadata );
671        return Wikitext::pst( $env, $wikitext, true /* $substTLTemplates */ );
672    }
673
674    /**
675     * Check whether a given content version can be downgraded to the requested
676     * content version.
677     *
678     * @param string $from Current content version
679     * @param string $to Requested content version
680     * @return string[]|null The downgrade that will fulfill the request, as
681     *   [ 'from' => <old version>, 'to' => <new version> ], or null if it
682     *   can't be fulfilled.
683     */
684    public static function findDowngrade( string $from, string $to ): ?array {
685        foreach ( self::DOWNGRADES as [ 'from' => $dgFrom, 'to' => $dgTo ] ) {
686            if (
687                Semver::satisfies( $from, "^$dgFrom" ) &&
688                Semver::satisfies( $to, "^$dgTo" )
689            ) {
690                // FIXME: Make this a class?
691                return [ 'from' => $dgFrom, 'to' => $dgTo ];
692            }
693        }
694        return null;
695    }
696
697    /**
698     * Downgrade a document to an older content version.
699     *
700     * @param string[] $dg Value returned by findDowngrade().
701     * @param PageBundle $pageBundle
702     */
703    public static function downgrade(
704        array $dg, PageBundle $pageBundle
705    ): void {
706        foreach ( self::DOWNGRADES as [ 'from' => $dgFrom, 'to' => $dgTo, 'func' => $dgFunc ] ) {
707            if ( $dg['from'] === $dgFrom && $dg['to'] === $dgTo ) {
708                call_user_func( [ self::class, $dgFunc ], $pageBundle );
709
710                // FIXME: Maybe this resolve should just be part of the $dg
711                $pageBundle->version = self::resolveContentVersion( $dg['to'] );
712
713                // FIXME: Maybe this should be a helper to avoid the rt
714                $doc = DOMUtils::parseHTML( $pageBundle->html );
715                // Match the http-equiv meta to the content-type header
716                $meta = DOMCompat::querySelector( $doc,
717                    'meta[property="mw:htmlVersion"], meta[property="mw:html:version"]' );
718                if ( $meta ) {
719                    $meta->setAttribute( 'content', $pageBundle->version );
720                    $pageBundle->html = ContentUtils::toXML( $doc );
721                }
722
723                return;
724            }
725        }
726        throw new InvalidArgumentException(
727            "Unsupported downgrade: {$dg['from']} -> {$dg['to']}"
728        );
729    }
730
731    /**
732     * Check if language variant conversion is implemented for a language
733     *
734     * @internal FIXME: Remove once Parsoid's language variant work is completed
735     * @param PageConfig $pageConfig
736     * @param Bcp47Code $htmlVariant Variant language to check
737     * @return bool
738     */
739    public function implementsLanguageConversionBcp47( PageConfig $pageConfig, Bcp47Code $htmlVariant ): bool {
740        // Hardcode disable zh lang conversion support since Parsoid's
741        // implementation is incomplete and not performant (T346657).
742        if ( $pageConfig->getPageLanguageBcp47()->toBcp47Code() === 'zh' ) {
743            return false;
744        }
745
746        $metadata = new StubMetadataCollector( $this->siteConfig );
747        $env = new Env( $this->siteConfig, $pageConfig, $this->dataAccess, $metadata );
748        return LanguageConverter::implementsLanguageConversionBcp47( $env, $htmlVariant );
749    }
750
751    /**
752     * Downgrade the given document and pagebundle from 999.x to 2.x.
753     *
754     * @param PageBundle $pageBundle
755     */
756    private static function downgrade999to2( PageBundle $pageBundle ) {
757        // Effectively, skip applying data-parsoid.  Note that if we were to
758        // support a pb2html downgrade, we'd need to apply the full thing,
759        // but that would create complications where ids would be left behind.
760        // See the comment in around `DOMDataUtils::applyPageBundle`
761        $newPageBundle = new PageBundle(
762            $pageBundle->html,
763            [ 'ids' => [] ],
764            $pageBundle->mw
765        );
766        $pageBundle->html = $newPageBundle->toHtml();
767        // Now, modify the pagebundle to the expected form.  This is important
768        // since, at least in the serialization path, the original pb will be
769        // applied to the modified content and its presence could cause lost
770        // deletions.
771        $pageBundle->mw = [ 'ids' => [] ];
772    }
773}