Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
87.14% covered (warning)
87.14%
271 / 311
65.00% covered (warning)
65.00%
13 / 20
CRAP
0.00% covered (danger)
0.00%
0 / 1
ZObjectContentHandler
87.14% covered (warning)
87.14%
271 / 311
65.00% covered (warning)
65.00%
13 / 20
58.98
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 canBeUsedOn
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 makeEmptyContent
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
1
 makeContent
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getContentClass
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 serializeContent
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 unserializeContent
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 getExternalRepresentation
83.33% covered (warning)
83.33%
30 / 36
0.00% covered (danger)
0.00%
0 / 1
6.17
 getSecondaryDataUpdates
83.33% covered (warning)
83.33%
15 / 18
0.00% covered (danger)
0.00%
0 / 1
3.04
 getDeletionUpdates
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 supportsDirectEditing
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getActionOverrides
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 generateHTMLOnEdit
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fillParserOutput
78.35% covered (warning)
78.35%
76 / 97
0.00% covered (danger)
0.00%
0 / 1
11.01
 createZObjectViewHeader
100.00% covered (success)
100.00%
77 / 77
100.00% covered (success)
100.00%
1 / 1
9
 createZObjectViewTitle
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
4.02
 validateSave
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 preSaveTransform
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 createDifferenceEngine
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getSlotDiffRendererWithOptions
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * WikiLambda content handler for ZObjects
4 *
5 * @file
6 * @ingroup Extensions
7 * @copyright 2020– Abstract Wikipedia team; see AUTHORS.txt
8 * @license MIT
9 */
10
11namespace MediaWiki\Extension\WikiLambda;
12
13use GuzzleHttp\Client;
14use InvalidArgumentException;
15use MediaWiki\Config\Config;
16use MediaWiki\Content\Content;
17use MediaWiki\Content\ContentHandler;
18use MediaWiki\Content\ContentSerializationException;
19use MediaWiki\Content\Renderer\ContentParseParams;
20use MediaWiki\Content\Transform\PreSaveTransformParams;
21use MediaWiki\Content\ValidationParams;
22use MediaWiki\Context\IContextSource;
23use MediaWiki\Context\RequestContext;
24use MediaWiki\Diff\TextSlotDiffRenderer;
25use MediaWiki\Extension\WikiLambda\Cache\MemcachedWrapper;
26use MediaWiki\Extension\WikiLambda\Diff\ZObjectContentDifferenceEngine;
27use MediaWiki\Extension\WikiLambda\Registry\ZErrorTypeRegistry;
28use MediaWiki\Extension\WikiLambda\Registry\ZLangRegistry;
29use MediaWiki\Extension\WikiLambda\Registry\ZTypeRegistry;
30use MediaWiki\Html\Html;
31use MediaWiki\Json\FormatJson;
32use MediaWiki\Language\Language;
33use MediaWiki\Logger\LoggerFactory;
34use MediaWiki\MainConfigNames;
35use MediaWiki\MediaWikiServices;
36use MediaWiki\Parser\ParserOutput;
37use MediaWiki\Revision\SlotRenderingProvider;
38use MediaWiki\Title\Title;
39use StatusValue;
40
41class ZObjectContentHandler extends ContentHandler {
42    use ZObjectEditingPageTrait;
43
44    /**
45     * @param string $modelId
46     * @param Config $config
47     * @param ZObjectStore $zObjectStore
48     * @param MemcachedWrapper $zObjectCache
49     */
50    public function __construct(
51        $modelId,
52        private readonly Config $config,
53        private readonly ZObjectStore $zObjectStore,
54        private readonly MemcachedWrapper $zObjectCache
55    ) {
56        if ( $modelId !== CONTENT_MODEL_ZOBJECT ) {
57            throw new InvalidArgumentException( __CLASS__ . " initialised for invalid content model" );
58        }
59
60        // Triggers use of message content-model-zobject
61        parent::__construct( CONTENT_MODEL_ZOBJECT, [ CONTENT_FORMAT_TEXT ] );
62    }
63
64    /**
65     * @param Title $title Page to check
66     * @return bool
67     */
68    public function canBeUsedOn( Title $title ) {
69        return $title->inNamespace( NS_MAIN );
70    }
71
72    /**
73     * @return ZObjectContent
74     */
75    public function makeEmptyContent() {
76        $class = $this->getContentClass();
77        return new $class(
78            '{' . "\n"
79            . '"' . ZTypeRegistry::Z_OBJECT_TYPE . '": "' . ZTypeRegistry::Z_PERSISTENTOBJECT . '",' . "\n"
80            . '"' . ZTypeRegistry::Z_PERSISTENTOBJECT_ID . '": {' . "\n"
81            . '"' . ZTypeRegistry::Z_OBJECT_TYPE . '": "' . ZTypeRegistry::Z_STRING . '",' . "\n"
82            . '"' . ZTypeRegistry::Z_STRING_VALUE . '": "' . ZTypeRegistry::Z_NULL_REFERENCE . '"},' . "\n"
83            . '"' . ZTypeRegistry::Z_PERSISTENTOBJECT_VALUE . '": "",' . "\n"
84            . '"' . ZTypeRegistry::Z_PERSISTENTOBJECT_LABEL . '": {' . "\n"
85            . '"' . ZTypeRegistry::Z_OBJECT_TYPE . '": "' . ZTypeRegistry::Z_MULTILINGUALSTRING . '",' . "\n"
86            . '"' . ZTypeRegistry::Z_MULTILINGUALSTRING_VALUE . '":'
87            . '["' . ZTypeRegistry::Z_MONOLINGUALSTRING . '"]' . "\n"
88            . '},' . "\n"
89            . '"' . ZTypeRegistry::Z_PERSISTENTOBJECT_ALIASES . '": {' . "\n"
90            . '"' . ZTypeRegistry::Z_OBJECT_TYPE . '": "' . ZTypeRegistry::Z_MULTILINGUALSTRINGSET . '",' . "\n"
91            . '"' . ZTypeRegistry::Z_MULTILINGUALSTRINGSET_VALUE . '":'
92            . '["' . ZTypeRegistry::Z_MONOLINGUALSTRINGSET . '"]' . "\n"
93            . '},' . "\n"
94            . '"' . ZTypeRegistry::Z_PERSISTENTOBJECT_DESCRIPTION . '": {' . "\n"
95            . '"' . ZTypeRegistry::Z_OBJECT_TYPE . '": "' . ZTypeRegistry::Z_MULTILINGUALSTRING . '",' . "\n"
96            . '"' . ZTypeRegistry::Z_MULTILINGUALSTRING_VALUE . '":'
97            . '["' . ZTypeRegistry::Z_MONOLINGUALSTRING . '"]' . "\n"
98            . '}' . "\n"
99            . '}'
100        );
101    }
102
103    /**
104     * @param string $data
105     * @param Title|null $title
106     * @param string|null $modelId
107     * @param string|null $format
108     * @return ZObjectContent
109     */
110    public static function makeContent( $data, ?Title $title = null, $modelId = null, $format = null ) {
111        // @phan-suppress-next-line PhanTypeMismatchReturnSuperType
112        return parent::makeContent( $data, $title, $modelId, $format );
113    }
114
115    /**
116     * @return string
117     */
118    protected function getContentClass() {
119        return ZObjectContent::class;
120    }
121
122    /**
123     * @param Content $content
124     * @param string|null $format
125     * @return string
126     */
127    public function serializeContent( Content $content, $format = null ) {
128        $this->checkFormat( $format );
129
130        if ( !( $content instanceof ZObjectContent ) ) {
131            // Throw?
132            return '';
133        }
134
135        return $content->getText();
136    }
137
138    /**
139     * @param string $text
140     * @param string|null $format
141     * @return ZObjectContent
142     * @throws ContentSerializationException if input causes an error
143     */
144    public function unserializeContent( $text, $format = null ) {
145        $this->checkFormat( $format );
146
147        $class = $this->getContentClass();
148        try {
149            return new $class( $text );
150        } catch ( ZErrorException $zerror ) {
151            // (T381115) If the passed user input isn't valid, we're expected to throw this particular MW error
152            throw new ContentSerializationException( $zerror->getZError()->getMessage() );
153        }
154    }
155
156    /**
157     * @param Title $zObjectTitle The page to fetch.
158     * @param string|null $languageCode The language in which to return results. If unset, all results are returned.
159     * @param int|null $revision The revision ID of the page to fetch. If unset, the latest is returned.
160     * @return string The external JSON form of the given title.
161     * @throws ZErrorException
162     */
163    public static function getExternalRepresentation(
164        Title $zObjectTitle, ?string $languageCode = null, ?int $revision = null
165    ): string {
166        if ( $zObjectTitle->getNamespace() !== NS_MAIN ) {
167            throw new ZErrorException(
168                ZErrorFactory::createZErrorInstance(
169                    ZErrorTypeRegistry::Z_ERROR_WRONG_NAMESPACE,
170                    [ 'title' => (string)$zObjectTitle ]
171                )
172            );
173        }
174
175        if ( $zObjectTitle->getContentModel() !== CONTENT_MODEL_ZOBJECT ) {
176            throw new ZErrorException(
177                ZErrorFactory::createZErrorInstance(
178                    ZErrorTypeRegistry::Z_ERROR_WRONG_CONTENT_TYPE,
179                    [ 'title' => (string)$zObjectTitle ]
180                )
181            );
182        }
183
184        $zObjectStore = WikiLambdaServices::getZObjectStore();
185        $zObject = $zObjectStore->fetchZObjectByTitle( $zObjectTitle, $revision );
186
187        if ( $zObject === false ) {
188            throw new ZErrorException(
189                ZErrorFactory::createZErrorInstance(
190                    ZErrorTypeRegistry::Z_ERROR_ZID_NOT_FOUND,
191                    [ 'data' => (string)$zObjectTitle ]
192                )
193            );
194        }
195
196        // Cautionary canonicalization: all objects should be fully canonicalized in the DB
197        $object = ZObjectUtils::canonicalize( $zObject->getObject() );
198
199        if ( $languageCode ) {
200            // TODO (T362246): Dependency-inject
201            $services = MediaWikiServices::getInstance();
202
203            // If language code is not valid, throws ZErrorException of Z540/Invalid language code
204            if ( !$services->getLanguageNameUtils()->isValidCode( $languageCode ) ) {
205                throw new ZErrorException(
206                    ZErrorFactory::createZErrorInstance(
207                        ZErrorTypeRegistry::Z_ERROR_INVALID_LANG_CODE,
208                        [ 'lang' => $languageCode ]
209                    )
210                );
211            }
212
213            // If language doesn't have a Zid, throws ZErrorException of Z541/Language code not found
214            $languageZid = ZLangRegistry::singleton()->getLanguageZidFromCode( $languageCode );
215
216            // Filter all Multilingual Strings and Stringsets if language is present and valid
217            $object = ZObjectUtils::filterZMultilingualStringsToLanguage( $object, [ $languageZid ] );
218        }
219
220        return FormatJson::encode( $object, true, FormatJson::UTF8_OK );
221    }
222
223    /**
224     * @inheritDoc
225     */
226    public function getSecondaryDataUpdates(
227        Title $title,
228        Content $content,
229        $role,
230        SlotRenderingProvider $slotOutput
231    ) {
232        $orchestrator = null;
233
234        if ( $this->config->get( 'WikiLambdaPersistBackendCache' ) ) {
235            $orchestratorHost = $this->config->get( 'WikiLambdaOrchestratorLocation' );
236            $client = new Client( [ "base_uri" => $orchestratorHost ] );
237            $orchestrator = new OrchestratorRequest( $client );
238        }
239
240        $ourUpdate = [];
241        if ( ( $content instanceof ZObjectContent ) ) {
242            $ourUpdate[] = new ZObjectSecondaryDataUpdate(
243                $title,
244                $content,
245                $this->zObjectStore,
246                $this->zObjectCache,
247                $orchestrator
248            );
249        }
250
251        return array_merge(
252            parent::getSecondaryDataUpdates( $title, $content, $role, $slotOutput ),
253            $ourUpdate
254        );
255    }
256
257    /**
258     * @inheritDoc
259     */
260    public function getDeletionUpdates( Title $title, $role ) {
261        return array_merge(
262            parent::getDeletionUpdates( $title, $role ),
263            [ new ZObjectSecondaryDataRemoval(
264                $title,
265                $this->zObjectStore,
266                $this->zObjectCache
267            ) ]
268        );
269    }
270
271    /**
272     * @inheritDoc
273     */
274    public function supportsDirectEditing() {
275        return false;
276    }
277
278    /**
279     * @inheritDoc
280     */
281    public function getActionOverrides() {
282        return [
283            'edit' => ZObjectEditAction::class,
284            'history' => ZObjectHistoryAction::class
285        ];
286    }
287
288    /**
289     * Do not render HTML on edit (T285987)
290     *
291     * @return bool
292     */
293    public function generateHTMLOnEdit(): bool {
294        return false;
295    }
296
297    /**
298     * Set the HTML and add the appropriate styles.
299     *
300     * @inheritDoc
301     * @param Content $content
302     * @param ContentParseParams $cpoParams
303     * @param ParserOutput &$parserOutput The output object to fill (reference).
304     */
305    protected function fillParserOutput(
306        Content $content,
307        ContentParseParams $cpoParams,
308        ParserOutput &$parserOutput
309    ) {
310        $userLang = RequestContext::getMain()->getLanguage();
311        $logger = LoggerFactory::getInstance( 'WikiLambda' );
312
313        // Ensure the stored content is a valid ZObject; this also populates $this->getZObject() for us
314        if ( !( $content instanceof ZObjectContent ) || !$content->isValid() ) {
315            $parserOutput->setContentHolderText(
316                Html::element(
317                    'div',
318                    [
319                        'class' => [ 'ext-wikilambda-view-invalidcontent', 'warning' ],
320                    ],
321                    wfMessage( 'wikilambda-invalidzobject' )->inLanguage( $userLang )->text()
322                )
323            );
324            // Exit early, as the rest of the code relies on the stored content being well-formed and valid.
325            return;
326        }
327
328        // Add the ZObject's labels in each language as a page property, for cheaper reading
329        $zLangRegistry = ZLangRegistry::singleton();
330        $labels = $content->getLabels()->getValueAsList();
331        foreach ( $labels as $langZid => $label ) {
332            if ( !$langZid ) {
333                // (T402670) Something's wrong with this label entry; skip it
334                $logger->debug(
335                    'Skipping setting page property for label in blank language ZID when displaying "{page}"',
336                    [
337                        'page' => $content->getZObject()->getZid()
338                    ]
339                );
340                continue;
341            }
342
343            try {
344                $lang = $zLangRegistry->getLanguageCodeFromZid( $langZid );
345                $parserOutput->setPageProperty( "wikilambda-label-$lang", $label );
346            } catch ( ZErrorException ) {
347                // The language code is somehow not recognised; don't set a property, but log it for review
348                $logger->warning(
349                    'Skipping setting page property for label in unknown language "{langZid}" when displaying "{page}"',
350                    [
351                        'langZid' => $langZid,
352                        'page' => $content->getZObject()->getZid()
353                    ]
354                );
355            }
356        }
357
358        // Add links to other ZObjects
359        // (T361701) Do this ahead of the early return, as LinkUpdater asks for the non-HTML version
360        foreach ( $content->getInnerZObject()->getLinkedZObjects() as $link ) {
361            if ( !$link ) {
362                // (T402670) Something's wrong with this link entry; skip it
363                $logger->debug(
364                    'Skipping setting page property for link to blank page when displaying "{page}"',
365                    [
366                        'page' => $content->getZObject()->getZid()
367                    ]
368                );
369                continue;
370            }
371
372            $title = Title::newFromText( $link, NS_MAIN );
373
374            // (T400521) Don't try to write a null Title, but log that it happened
375            if ( !$title ) {
376                $logger->warning(
377                    'Skipping setting page property for link to unknown page "{link}" when displaying "{page}"',
378                    [
379                        'link' => $link,
380                        'page' => $content->getZObject()->getZid(),
381                    ]
382                );
383            } else {
384                $parserOutput->addLink( $title, $title->getArticleID() );
385            }
386        }
387
388        // Don't do further work if the requester doesn't want the HTML version generated.
389        if ( !$cpoParams->getGenerateHtml() ) {
390            $parserOutput->setContentHolderText( '' );
391            return;
392        }
393
394        $pageIdentity = $cpoParams->getPage();
395
396        // TODO (T362245): Re-work our code to use PageReferences rather than Titles
397        $title = Title::castFromPageReference( $pageIdentity );
398        '@phan-var Title $title';
399
400        $parserOutput->addModuleStyles( [ 'ext.wikilambda.viewpage.styles' ] );
401        $parserOutput->addModules( [ 'ext.wikilambda.app' ] );
402
403        $userLangCode = $userLang->getCode();
404
405        // If the userLang isn't recognised (e.g. it's qqx, or a language we don't support yet, or it's
406        // nonsense), then fall back to English.
407        $userLangZid = $zLangRegistry->getLanguageZidFromCode( $userLangCode, true );
408        // Normalise our used language code from what the Language object says
409        $userLangCode = $zLangRegistry->getLanguageCodeFromZid( $userLangZid );
410
411        // Add the canonical page link to /view/<lang>/<zid>
412        $output = RequestContext::getMain()->getOutput();
413
414        // Set page header
415        $header = static::createZObjectViewHeader( $content, $title, $userLang );
416        $output->setPageTitle( $header );
417
418        // (T360169) Set page title meta tag
419        $metaTitle = static::createZObjectViewTitle( $content, $title, $userLang );
420        $output->setHTMLTitle( $metaTitle );
421
422        $output->addLink( [
423                'rel' => 'canonical',
424                'hreflang' => $userLangCode,
425                'href' => "/view/$userLangCode/" . $title->getDBkey(),
426            ] );
427
428        $editingData = [
429            // The following paramether may be the same now,
430            // but will surely change in the future as we remove the Zds from the UI
431            'title' => $title->getBaseText(),
432            'zId' => $title->getBaseText(),
433            'page' => $title->getPrefixedDBkey(),
434            'zlang' => $userLangCode,
435            'zlangZid' => $userLangZid,
436            'createNewPage' => false,
437            'viewmode' => true
438        ];
439
440        $parserOutput->setJsConfigVar( 'wgWikiLambda', $editingData );
441
442        $loadingMessage = wfMessage( 'wikilambda-loading' )->inLanguage( $userLang )->text();
443        $parserOutput->setContentHolderText(
444            // Placeholder div for the Vue template with Codex progress indicator.
445            Html::rawElement(
446                'div',
447                [ 'id' => 'ext-wikilambda-app' ],
448                UIUtils::createCodexProgressIndicator( $loadingMessage )
449            )
450            // Fallback message for users without JavaScript.
451            . Html::rawElement(
452                'noscript',
453                [],
454                wfMessage( 'wikilambda-nojs' )->inLanguage( $userLang )->parse()
455            )
456        );
457    }
458
459    /**
460     * Generate the special "title" shown on view pages
461     *
462     * <span lang="es" class="ext-wikilambda-viewpage-header">
463     *         <span data-title="English" class="ext-wikilambda-viewpage-header__bcp47-code">en</span>
464     *         <span class="ext-wikilambda-viewpage-header__title ext-wikilambda-viewpage-header__title--function-name">
465     *             multiply
466     *         </span>
467     *         <span class="ext-wikilambda-viewpage-header__zid">Z12345</span>
468     *         <div class="ext-wikilambda-viewpage-header__type">
469     *             <span data-title="English" class="ext-wikilambda-viewpage-header__bcp47-code">en</span>
470     *             <span class="ext-wikilambda-viewpage-header__type-label">Function</span>
471     *         </div>
472     * </span>
473     *
474     * @param ZObjectContent $content
475     * @param Title $title
476     * @param Language $userLang
477     * @return string
478     */
479    public static function createZObjectViewHeader(
480        ZObjectContent $content, Title $title, Language $userLang
481    ): string {
482        // TODO (T362246): Dependency-inject
483        $services = MediaWikiServices::getInstance();
484
485        $zobject = $content->getZObject();
486
487        if ( !$zobject || !$zobject->isValid() ) {
488            // Something's bad, let's give up.
489            return '';
490        }
491
492        // Get best-available label (and its language code) for the target object's type, given the request language.
493        [
494            'title' => $targetTypeLabel,
495            'languageCode' => $targetTypeLabelLanguage
496        ] = $content->getTypeStringAndLanguage( $userLang );
497
498        // OBJECT TYPE Language code, which is usually a BCP47 code (e.g. 'en') but sometimes tests inject it as a
499        // Language object(!)
500        $targetTypeDisplayCode = gettype( $targetTypeLabelLanguage ) === 'string'
501            ? $targetTypeLabelLanguage : $targetTypeLabelLanguage->getCode();
502        // OBJECT TYPE language label (e.g. 'Function') of the language currently being rendered
503        $targetTypeDisplayLabelLanguageName = $services->getLanguageNameUtils()->getLanguageName(
504            $targetTypeDisplayCode
505        );
506
507        // Get best-available label (and its language code) for the target object's name, given the request language.
508
509        // OBJECT NAME label (e.g. 'My function' or 'Unknown') and language code (e.g. 'en')
510        [
511            'title' => $targetLabel,
512            'languageCode' => $targetLabelLanguageCode
513        ] = $zobject->getLabels()->buildStringForLanguage( $userLang )
514            ->fallbackWithEnglish()
515            ->placeholderForTitle()
516            ->getStringAndLanguageCode();
517
518        // OBJECT NAME language label (e.g. 'English') of the language currently being rendered
519        $targetDisplayLabelLanguageName = $services->getLanguageNameUtils()->getLanguageName(
520            $targetLabelLanguageCode
521        );
522
523        $bcp47CodeClassName = 'ext-wikilambda-viewpage-header__bcp47-code';
524
525        $targetDisplayLabelWidget = '';
526        // If the object type label (e.g. 'Function') is not in the user's language, show a BCP47 code widget
527        // for the language used instead
528        if ( $targetLabelLanguageCode !== $userLang->getCode() ) {
529            $targetDisplayLabelWidget = UIUtils::wrapBCP47CodeInFakeCodexChip(
530                $targetLabelLanguageCode, $targetDisplayLabelLanguageName, $bcp47CodeClassName
531            );
532        }
533
534        $targetDisplayTypeWidget = '';
535        // If the object label (e.g. 'Echo') is not in the user's language, show a BCP47 code widget
536        // for the language used instead
537        if ( $targetTypeDisplayCode !== $userLang->getCode() ) {
538            $targetDisplayTypeWidget = UIUtils::wrapBCP47CodeInFakeCodexChip(
539                $targetTypeDisplayCode, $targetTypeDisplayLabelLanguageName, $bcp47CodeClassName
540            );
541        }
542
543        $untitledStyle = $targetLabel === wfMessage( 'wikilambda-editor-default-name' )->text() ?
544            'ext-wikilambda-viewpage-header__title--untitled' : null;
545
546        $labelSpan = Html::element(
547            'span',
548            [
549                'class' => [
550                    'ext-wikilambda-viewpage-header__title ext-wikilambda-viewpage-header__title--function-name',
551                    $untitledStyle
552                ]
553            ],
554            $targetLabel
555        );
556
557        $zidSpan = Html::element(
558            'span',
559            [
560                'class' => 'ext-wikilambda-viewpage-header__zid',
561                'role' => 'button',
562                'tabindex' => '0',
563                'aria-live' => 'polite'
564            ],
565            $title->getText()
566        );
567
568        $labelTitle =
569            // (T356731) When $targetDisplayLabelWidget is an empty string, colon-separator already
570            // adds/removes the needed/unneeded whitespace for languages. Always adding a
571            // space would unexpectedly add unneeded extra whitespace for languages including
572            // zh-hans, zh-hant, etc.
573            ( $targetDisplayLabelWidget === '' ? '' : $targetDisplayLabelWidget . ' ' )
574                . $labelSpan . ' ' . $zidSpan;
575
576        $typeSubtitle = Html::rawElement(
577            'div', [ 'class' => 'ext-wikilambda-viewpage-header__type' ],
578            $targetDisplayTypeWidget . ' ' . Html::element(
579                'span',
580                [
581                    'class' => 'ext-wikilambda-viewpage-header__type-label'
582                ],
583                $targetTypeLabel
584            )
585        );
586
587        return Html::rawElement(
588            'span',
589            [
590                // Mark the header in the correct language, regardless of the rest of the page
591                // … but mark it back into their requested language if it's actually untitled
592                'lang' => ( $untitledStyle === null ? $userLang->getCode() : $targetTypeDisplayCode ),
593                'class' => 'ext-wikilambda-viewpage-header'
594            ],
595            $labelTitle . $typeSubtitle
596        );
597    }
598
599    /**
600     * Generate the HTML "title" tag for the view page
601     *
602     * @param ZObjectContent $content
603     * @param Title $title
604     * @param Language $userLang
605     * @return string
606     */
607    public static function createZObjectViewTitle(
608        ZObjectContent $content, Title $title, Language $userLang
609    ): string {
610        $zobject = $content->getZObject();
611        if ( !$zobject || !$zobject->isValid() ) {
612            // Something's bad, let's give up.
613            return '';
614        }
615
616        $sitename = MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::Sitename );
617
618        // Get label, english fallback, or zid if no available option
619        $label = $zobject->getLabels()->buildStringForLanguage( $userLang )
620            ->fallbackWithEnglish()
621            ->getString();
622
623        // Return Label/Zid - Sitename
624        return ( $label ?: $title->getBaseText() ) . ' - ' .
625            $sitename;
626    }
627
628    /**
629     * @param Content $content
630     * @param ValidationParams $validationParams
631     * @return StatusValue
632     */
633    public function validateSave( $content, $validationParams ) {
634        if ( $content->isValid() ) {
635            return StatusValue::newGood();
636        }
637        return StatusValue::newFatal( "wikilambda-invalidzobject" );
638    }
639
640    /**
641     * @inheritDoc
642     */
643    public function preSaveTransform( Content $content, PreSaveTransformParams $pstParams ): Content {
644        '@phan-var ZObjectContent $content';
645
646        if ( !$content->isValid() ) {
647            return $content;
648        }
649
650        $json = ZObjectUtils::canonicalize( $content->getObject() );
651        $encoded = FormatJson::encode( $json, true, FormatJson::UTF8_OK );
652        $encodedCleanedWhitespace = str_replace( [ "\r\n", "\r" ], "\n", rtrim( $encoded ) );
653
654        if ( $content->getText() !== $encodedCleanedWhitespace ) {
655            $contentClass = $this->getContentClass();
656            return new $contentClass( $encodedCleanedWhitespace );
657        }
658
659        return $content;
660    }
661
662    /**
663     * @inheritDoc
664     */
665    public function createDifferenceEngine(
666        IContextSource $context,
667        $oldContentRevisionId = 0,
668        $newContentRevisionId = 0,
669        $recentChangesId = 0,
670        $refreshCache = false,
671        $unhide = false
672    ) {
673        return new ZObjectContentDifferenceEngine(
674            $context, $oldContentRevisionId, $newContentRevisionId, $recentChangesId, $refreshCache, $unhide
675        );
676    }
677
678    /**
679     * @inheritDoc
680     *
681     * Access level widened to public for use in ZObjectContentDifferenceEngine
682     */
683    public function getSlotDiffRendererWithOptions( IContextSource $context, $options = [] ) {
684        // TODO (T362246): Dependency-inject (if we haven't replaced this by then)
685        $slotDiffRenderer = MediaWikiServices::getInstance()
686            ->getContentHandlerFactory()
687            ->getContentHandler( CONTENT_MODEL_TEXT )
688            ->getSlotDiffRenderer( $context );
689        '@phan-var TextSlotDiffRenderer $slotDiffRenderer';
690        return $slotDiffRenderer;
691    }
692
693}