Page MenuHomeWickedGov Phorge

WikitextSerializer.php
No OneTemporary

Size
55 KB
Referenced Files
None
Subscribers
None

WikitextSerializer.php

<?php
declare( strict_types = 1 );
namespace Wikimedia\Parsoid\Html2Wt;
use Closure;
use Exception;
use Wikimedia\Assert\Assert;
use Wikimedia\Parsoid\Config\Env;
use Wikimedia\Parsoid\Core\InternalException;
use Wikimedia\Parsoid\DOM\Comment;
use Wikimedia\Parsoid\DOM\Document;
use Wikimedia\Parsoid\DOM\DocumentFragment;
use Wikimedia\Parsoid\DOM\Element;
use Wikimedia\Parsoid\DOM\Node;
use Wikimedia\Parsoid\DOM\Text;
use Wikimedia\Parsoid\Html2Wt\ConstrainedText\ConstrainedText;
use Wikimedia\Parsoid\Html2Wt\DOMHandlers\DOMHandler;
use Wikimedia\Parsoid\Html2Wt\DOMHandlers\DOMHandlerFactory;
use Wikimedia\Parsoid\NodeData\ParamInfo;
use Wikimedia\Parsoid\NodeData\TemplateInfo;
use Wikimedia\Parsoid\Tokens\KV;
use Wikimedia\Parsoid\Tokens\TagTk;
use Wikimedia\Parsoid\Tokens\Token;
use Wikimedia\Parsoid\Utils\ContentUtils;
use Wikimedia\Parsoid\Utils\DiffDOMUtils;
use Wikimedia\Parsoid\Utils\DOMCompat;
use Wikimedia\Parsoid\Utils\DOMDataUtils;
use Wikimedia\Parsoid\Utils\DOMUtils;
use Wikimedia\Parsoid\Utils\PHPUtils;
use Wikimedia\Parsoid\Utils\Title;
use Wikimedia\Parsoid\Utils\TokenUtils;
use Wikimedia\Parsoid\Utils\Utils;
use Wikimedia\Parsoid\Utils\WTUtils;
use Wikimedia\Parsoid\Wikitext\Consts;
/**
* Wikitext to HTML serializer.
* Serializes a chunk of tokens or an HTML DOM to MediaWiki's wikitext flavor.
*
* This serializer is designed to eventually
* - accept arbitrary HTML and
* - serialize that to wikitext in a way that round-trips back to the same
* HTML DOM as far as possible within the limitations of wikitext.
*
* Not much effort has been invested so far on supporting
* non-Parsoid/VE-generated HTML. Some of this involves adaptively switching
* between wikitext and HTML representations based on the values of attributes
* and DOM context. A few special cases are already handled adaptively
* (multi-paragraph list item contents are serialized as HTML tags for
* example, generic A elements are serialized to HTML A tags), but in general
* support for this is mostly missing.
*
* Example issue:
* ```
* <h1><p>foo</p></h1> will serialize to =\nfoo\n= whereas the
* correct serialized output would be: =<p>foo</p>=
* ```
*
* What to do about this?
* - add a generic 'can this HTML node be serialized to wikitext in this
* context' detection method and use that to adaptively switch between
* wikitext and HTML serialization.
*
*/
class WikitextSerializer {
/** @var string[] */
private const IGNORED_ATTRIBUTES = [
'data-parsoid' => true,
'data-ve-changed' => true,
'data-parsoid-changed' => true,
'data-parsoid-diff' => true,
'data-parsoid-serialize' => true,
DOMDataUtils::DATA_OBJECT_ATTR_NAME => true,
];
/** @var string[] attribute name => value regexp */
private const PARSOID_ATTRIBUTES = [
'about' => '/^#mwt\d+$/D',
'typeof' => '/(^|\s)mw:\S+/',
];
/** @var string Regexp */
private const TRAILING_COMMENT_OR_WS_AFTER_NL_REGEXP
= '/\n(\s|' . Utils::COMMENT_REGEXP_FRAGMENT . ')*$/D';
/** @var string Regexp */
private const FORMATSTRING_REGEXP =
'/^(\n)?(\{\{ *_+)(\n? *\|\n? *_+ *= *)(_+)(\n? *\}\})(\n)?$/D';
/** @var string Regexp for testing whether nowiki added around heading-like wikitext is needed */
private const COMMENT_OR_WS_REGEXP = '/^(\s|' . Utils::COMMENT_REGEXP_FRAGMENT . ')*$/D';
/** @var string Regexp for testing whether nowiki added around heading-like wikitext is needed */
private const HEADING_NOWIKI_REGEXP = '/^(?:' . Utils::COMMENT_REGEXP_FRAGMENT . ')*'
. '<nowiki>(=+[^=]+=+)<\/nowiki>(.+)$/D';
/** @var array string[] */
private static $separatorREs = [
'pureSepRE' => '/^[ \t\r\n]*$/D',
'sepPrefixWithNlsRE' => '/^[ \t]*\n+[ \t\r\n]*/',
'sepSuffixWithNlsRE' => '/\n[ \t\r\n]*$/D',
];
/** @var WikitextEscapeHandlers */
public $wteHandlers;
/** @var Env */
public $env;
/** @var SerializerState */
private $state;
/** @var string Log type for trace() */
private $logType;
/**
* @param Env $env
* @param array $options List of options for serialization:
* - logType: (string)
* - extName: (string)
*/
public function __construct( Env $env, $options ) {
$this->env = $env;
$this->logType = $options['logType'] ?? 'trace/wts';
$this->state = new SerializerState( $this, $options );
$this->wteHandlers = new WikitextEscapeHandlers( $env, $options['extName'] ?? null );
}
/**
* Main link handler.
* @param Element $node
* Used in multiple tag handlers (<a> and <link>), and hence added as top-level method
*/
public function linkHandler( Element $node ): void {
LinkHandlerUtils::linkHandler( $this->state, $node );
}
/**
* @param Element $node
*/
public function languageVariantHandler( Node $node ): void {
LanguageVariantHandler::handleLanguageVariant( $this->state, $node );
}
/**
* Escape wikitext-like strings in '$text' so that $text renders as a plain string
* when rendered as HTML. The escaping is done based on the context in which $text
* is present (ex: start-of-line, in a link, etc.)
*
* @param SerializerState $state
* @param string $text
* @param array $opts
* - node: (Node)
* - isLastChild: (bool)
* @return string
*/
public function escapeWikitext( SerializerState $state, string $text, array $opts ): string {
return $this->wteHandlers->escapeWikitext( $state, $text, $opts );
}
public function domToWikitext(
array $opts, DocumentFragment $node
): string {
$opts['logType'] = $this->logType;
$serializer = new WikitextSerializer( $this->env, $opts );
return $serializer->serializeDOM( $node );
}
public function htmlToWikitext( array $opts, string $html ): string {
$domFragment = ContentUtils::createAndLoadDocumentFragment(
$this->env->topLevelDoc, $html, [ 'markNew' => true ]
);
return $this->domToWikitext( $opts, $domFragment );
}
public function getAttributeKey( Element $node, string $key ): string {
$tplAttrs = DOMDataUtils::getDataMw( $node )->attribs ?? [];
foreach ( $tplAttrs as $attr ) {
// If this attribute's key is generated content,
// serialize HTML back to generator wikitext.
if ( ( $attr->key['txt'] ?? null ) === $key && isset( $attr->key['html'] ) ) {
return $this->htmlToWikitext( [
'env' => $this->env,
'onSOL' => false,
], $attr->key['html'] );
}
}
return $key;
}
/**
* @param Element $node
* @param string $key Attribute name.
* @return ?string The wikitext value, or null if the attribute is not present.
*/
public function getAttributeValue( Element $node, string $key ): ?string {
$tplAttrs = DOMDataUtils::getDataMw( $node )->attribs ?? [];
foreach ( $tplAttrs as $attr ) {
// If this attribute's value is generated content,
// serialize HTML back to generator wikitext.
// PORT-FIXME: not type safe. Need documentation on attrib format.
if ( ( $attr->key === $key || ( $attr->key['txt'] ?? null ) === $key )
// Only return here if the value is generated (ie. .html),
// it may just be in .txt form.
// html:"" will serialize to "" and
// will be returned here. This is used to suppress the =".."
// string in the attribute in scenarios where the template
// generates a "k=v" string.
// Ex: <div {{1x|1=style='color:red'}}>foo</div>
&& isset( $attr->value['html'] )
) {
return $this->htmlToWikitext( [
'env' => $this->env,
'onSOL' => false,
'inAttribute' => true,
], $attr->value['html'] );
}
}
return null;
}
/**
* @param Element $node
* @param string $key
* @return array|null A tuple in {@link WTSUtils::getShadowInfo()} format,
* with an extra 'fromDataMW' flag.
*/
public function getAttributeValueAsShadowInfo( Element $node, string $key ): ?array {
$v = $this->getAttributeValue( $node, $key );
if ( $v === null ) {
return $v;
}
return [
'value' => $v,
'modified' => false,
'fromsrc' => true,
'fromDataMW' => true,
];
}
/**
* @param Element $dataMWnode
* @param Element $htmlAttrNode
* @param string $key
* @return array A tuple in {@link WTSUtils::getShadowInfo()} format,
* possibly with an extra 'fromDataMW' flag.
*/
public function serializedImageAttrVal(
Element $dataMWnode, Element $htmlAttrNode, string $key
): array {
$v = $this->getAttributeValueAsShadowInfo( $dataMWnode, $key );
return $v ?: WTSUtils::getAttributeShadowInfo( $htmlAttrNode, $key );
}
public function serializedAttrVal( Element $node, string $name ): array {
return $this->serializedImageAttrVal( $node, $node, $name );
}
/**
* Check if token needs escaping
*
* @param string $name
* @return bool
*/
public function tagNeedsEscaping( string $name ): bool {
return WTUtils::isAnnOrExtTag( $this->env, $name );
}
public function wrapAngleBracket( Token $token, string $inner ): string {
if (
$this->tagNeedsEscaping( $token->getName() ) &&
!(
// Allow for html tags that shadow extension tags found in source
// to roundtrip. They only parse as html tags if they are unclosed,
// since extension tags bail on parsing without closing tags.
//
// This only applies when wrapAngleBracket() is being called for
// start tags, but we wouldn't be here if it was autoInsertedEnd
// anyways.
isset( Consts::$Sanitizer['AllowedLiteralTags'][$token->getName()] ) &&
!empty( $token->dataParsoid->autoInsertedEnd )
)
) {
return "&lt;{$inner}&gt;";
}
return "<$inner>";
}
public function serializeHTMLTag( Element $node, bool $wrapperUnmodified ): string {
// TODO(arlolra): As of 1.3.0, html pre is considered an extension
// and wrapped in encapsulation. When that version is no longer
// accepted for serialization, we can remove this backwards
// compatibility code.
//
// 'inHTMLPre' flag has to be updated always,
// even when we are selsering in the wrapperUnmodified case.
$token = WTSUtils::mkTagTk( $node );
if ( $token->getName() === 'pre' ) {
// html-syntax pre is very similar to nowiki
$this->state->inHTMLPre = true;
}
if ( $wrapperUnmodified ) {
$dsr = DOMDataUtils::getDataParsoid( $node )->dsr;
return $this->state->getOrigSrc( $dsr->openRange() ) ?? '';
}
$da = $token->dataParsoid;
if ( !empty( $da->autoInsertedStart ) ) {
return '';
}
$close = '';
if ( ( Utils::isVoidElement( $token->getName() ) && empty( $da->noClose ) ) ||
!empty( $da->selfClose )
) {
$close = ' /';
}
$sAttribs = $this->serializeAttributes( $node, $token );
if ( strlen( $sAttribs ) > 0 ) {
$sAttribs = ' ' . $sAttribs;
}
// srcTagName cannot be '' so, it is okay to use ?? operator
$tokenName = $da->srcTagName ?? $token->getName();
$inner = "{$tokenName}{$sAttribs}{$close}";
return $this->wrapAngleBracket( $token, $inner );
}
/**
* @param Element $node
* @param bool $wrapperUnmodified
* @return string
*/
public function serializeHTMLEndTag( Element $node, $wrapperUnmodified ): string {
if ( $wrapperUnmodified ) {
$dsr = DOMDataUtils::getDataParsoid( $node )->dsr;
return $this->state->getOrigSrc( $dsr->closeRange() ) ?? '';
}
$token = WTSUtils::mkEndTagTk( $node );
if ( $token->getName() === 'pre' ) {
$this->state->inHTMLPre = false;
}
// srcTagName cannot be '' so, it is okay to use ?? operator
$tokenName = $token->dataParsoid->srcTagName ?? $token->getName();
$ret = '';
if ( empty( $token->dataParsoid->autoInsertedEnd )
&& !Utils::isVoidElement( $token->getName() )
&& empty( $token->dataParsoid->selfClose )
) {
$ret = $this->wrapAngleBracket( $token, "/{$tokenName}" );
}
return $ret;
}
public function serializeAttributes( Element $node, Token $token, bool $isWt = false ): string {
$attribs = $token->attribs;
$out = [];
foreach ( $attribs as $kv ) {
// Tokens created during html2wt don't have nested tokens for keys.
// But, they could be integers but we want strings below.
$k = (string)$kv->k;
$v = null;
$vInfo = null;
// Unconditionally ignore
// (all of the IGNORED_ATTRIBUTES should be filtered out earlier,
// but ignore them here too just to make sure.)
if ( isset( self::IGNORED_ATTRIBUTES[$k] ) || $k === 'data-mw' ) {
continue;
}
// Ignore parsoid-like ids. They may have been left behind
// by clients and shouldn't be serialized. This can also happen
// in v2/v3 API when there is no matching data-parsoid entry found
// for this id.
if ( $k === 'id' && preg_match( '/^mw[\w-]{2,}$/D', $kv->v ) ) {
if ( WTUtils::isNewElt( $node ) ) {
// Parsoid id found on element without a matching data-parsoid. Drop it!
} else {
$vInfo = $token->getAttributeShadowInfo( $k );
if ( !$vInfo['modified'] && $vInfo['fromsrc'] ) {
$out[] = $k . '=' . '"' . str_replace( '"', '&quot;', $vInfo['value'] ) . '"';
}
}
continue;
}
// Parsoid auto-generates ids for headings and they should
// be stripped out, except if this is not auto-generated id.
if ( $k === 'id' && DOMUtils::isHeading( $node ) ) {
if ( !empty( DOMDataUtils::getDataParsoid( $node )->reusedId ) ) {
$vInfo = $token->getAttributeShadowInfo( $k );
// PORT-FIXME: is this safe? value could be a token or token array
$out[] = $k . '="' . str_replace( '"', '&quot;', $vInfo['value'] ) . '"';
}
continue;
}
// Strip Parsoid-inserted class="mw-empty-elt" attributes
if ( $k === 'class'
&& isset( Consts::$Output['FlaggedEmptyElts'][DOMCompat::nodeName( $node )] )
) {
$kv->v = preg_replace( '/\bmw-empty-elt\b/', '', $kv->v, 1 );
if ( !$kv->v ) {
continue;
}
}
// Strip other Parsoid-generated values
//
// FIXME: Given that we are currently escaping about/typeof keys
// that show up in wikitext, we could unconditionally strip these
// away right now.
$parsoidValueRegExp = self::PARSOID_ATTRIBUTES[$k] ?? null;
if ( $parsoidValueRegExp && preg_match( $parsoidValueRegExp, $kv->v ) ) {
$v = preg_replace( $parsoidValueRegExp, '', $kv->v );
if ( $v ) {
$out[] = $k . '="' . $v . '"';
}
continue;
}
if ( strlen( $k ) > 0 ) {
$vInfo = $token->getAttributeShadowInfo( $k );
$v = $vInfo['value'];
// Deal with k/v's that were template-generated
$kk = $this->getAttributeKey( $node, $k );
// Pass in $k, not $kk since $kk can potentially
// be original wikitext source for 'k' rather than
// the string value of the key.
$vv = $this->getAttributeValue( $node, $k ) ?? $v;
// Remove encapsulation from protected attributes
// in pegTokenizer.pegjs:generic_newline_attribute
$kk = preg_replace( '/^data-x-/i', '', $kk, 1 );
// PORT-FIXME: is this type safe? $vv could be a ConstrainedText
if ( $vv !== null && strlen( $vv ) > 0 ) {
if ( !$vInfo['fromsrc'] && !$isWt ) {
// Escape wikitext entities
$vv = str_replace( '>', '&gt;', Utils::escapeWtEntities( $vv ) );
}
$out[] = $kk . '="' . str_replace( '"', '&quot;', $vv ) . '"';
} elseif ( preg_match( '/[{<]/', $kk ) ) {
// Templated, <*include*>, or <ext-tag> generated
$out[] = $kk;
} else {
$out[] = $kk . '=""';
}
continue;
// PORT-FIXME: is this type safe? $k->v could be a Token or Token array
} elseif ( strlen( $kv->v ) ) {
// not very likely..
$out[] = $kv->v;
}
}
// SSS FIXME: It can be reasonably argued that we can permanently delete
// dangerous and unacceptable attributes in the interest of safety/security
// and the resultant dirty diffs should be acceptable. But, this is
// something to do in the future once we have passed the initial tests
// of parsoid acceptance.
//
// 'a' data attribs -- look for attributes that were removed
// as part of sanitization and add them back
$dataParsoid = $token->dataParsoid;
if ( isset( $dataParsoid->a ) && isset( $dataParsoid->sa ) ) {
$aKeys = array_keys( $dataParsoid->a );
foreach ( $aKeys as $k ) {
// Attrib not present -- sanitized away!
if ( !KV::lookupKV( $attribs, (string)$k ) ) {
$v = $dataParsoid->sa[$k] ?? null;
// PORT-FIXME check type
if ( $v !== null && $v !== '' ) {
$out[] = $k . '="' . str_replace( '"', '&quot;', $v ) . '"';
} else {
// at least preserve the key
$out[] = $k;
}
}
}
}
// XXX: round-trip optional whitespace / line breaks etc
return implode( ' ', $out );
}
/**
* FIXME: Get rid of this function after content version 2.2.0 has expired from caches.
*
* @param Element $node
*/
public function handleLIHackIfApplicable( Element $node ): void {
$liHackSrc = DOMDataUtils::getDataParsoid( $node )->liHackSrc ?? null;
$prev = DiffDOMUtils::previousNonSepSibling( $node );
// If we are dealing with an LI hack, then we must ensure that
// we are dealing with either
//
// 1. A node with no previous sibling inside of a list.
//
// 2. A node whose previous sibling is a list element.
if ( $liHackSrc !== null
// Case 1
&& ( ( $prev === null && DOMUtils::isList( $node->parentNode ) )
// Case 2
|| ( $prev !== null && DOMUtils::isListItem( $prev ) ) )
) {
$this->state->emitChunk( $liHackSrc, $node );
}
}
private function formatStringSubst( string $format, string $value, bool $forceTrim ): string {
// PORT-FIXME: JS is more agressive and removes various unicode whitespaces
// (most notably nbsp). Does that matter?
if ( $forceTrim ) {
$value = trim( $value );
}
return preg_replace_callback( '/_+/', static function ( $m ) use ( $value ) {
if ( $value === '' ) {
return $value;
}
$hole = $m[0];
$holeLen = strlen( $hole );
$valueLen = mb_strlen( $value );
return $holeLen <= $valueLen ? $value : $value . str_repeat( ' ', $holeLen - $valueLen );
}, $format, 1 );
}
/**
* Generates a template parameter sort function that tries to preserve existing ordering
* but also to follow the order prescribed by the templatedata.
* @param array $dpArgInfo
* @param ?array $tplData
* @param array $dataMwKeys
* @return Closure
*/
private function createParamComparator(
array $dpArgInfo, ?array $tplData, array $dataMwKeys
): Closure {
// Record order of parameters in new data-mw
$newOrder = [];
foreach ( $dataMwKeys as $i => $key ) {
$newOrder[$key] = [ 'order' => $i ];
}
// Record order of parameters in templatedata (if present)
$tplDataOrder = [];
$aliasMap = [];
$keys = [];
if ( $tplData && isset( $tplData['paramOrder'] ) ) {
foreach ( $tplData['paramOrder'] as $i => $key ) {
$tplDataOrder[$key] = [ 'order' => $i ];
$aliasMap[$key] = [ 'key' => $key, 'order' => -1 ];
$keys[] = $key;
// Aliases have the same sort order as the main name.
$aliases = $tplData['params'][$key]['aliases'] ?? [];
foreach ( $aliases as $j => $alias ) {
$aliasMap[$alias] = [ 'key' => $key, 'order' => $j ];
}
}
}
// Record order of parameters in original wikitext (from data-parsoid)
$origOrder = [];
foreach ( $dpArgInfo as $i => $argInfo ) {
$origOrder[$argInfo->k] = [ 'order' => $i, 'dist' => 0 ];
}
// Canonical parameter key gets the same order as an alias parameter
// found in the original wikitext.
foreach ( $dpArgInfo as $i => $argInfo ) {
$canon = $aliasMap[$argInfo->k] ?? null;
if ( $canon !== null && !array_key_exists( $canon['key'], $origOrder ) ) {
$origOrder[$canon['key']] = $origOrder[$argInfo->k];
}
}
// Find the closest "original parameter" for each templatedata parameter,
// so that newly-added parameters are placed near the parameters which
// templatedata says they should be adjacent to.
$nearestOrder = $origOrder;
$reduceF = static function ( $acc, $val ) use ( &$origOrder, &$nearestOrder ) {
if ( isset( $origOrder[$val] ) ) {
$acc = $origOrder[$val];
}
if ( !( isset( $nearestOrder[$val] ) && $nearestOrder[$val]['dist'] < $acc['dist'] ) ) {
$nearestOrder[$val] = $acc;
}
return [ 'order' => $acc['order'], 'dist' => $acc['dist'] + 1 ];
};
// Find closest original parameter before the key.
// @phan-suppress-next-line PhanPluginUseReturnValueInternalKnown
array_reduce( $keys, $reduceF, [ 'order' => -1, 'dist' => 2 * count( $keys ) ] );
// Find closest original parameter after the key.
// @phan-suppress-next-line PhanPluginUseReturnValueInternalKnown
array_reduce( array_reverse( $keys ), $reduceF,
[ 'order' => count( $origOrder ), 'dist' => count( $keys ) ] );
// Helper function to return a large number if the given key isn't
// in the sort order map
$big = max( count( $nearestOrder ), count( $newOrder ) );
$defaultGet = static function ( $map, $key1, $key2 = null ) use ( &$big ) {
$key = ( !$key2 || isset( $map[$key1] ) ) ? $key1 : $key2;
return $map[$key]['order'] ?? $big;
};
return static function ( $a, $b ) use (
&$aliasMap, &$defaultGet, &$nearestOrder, &$tplDataOrder, &$newOrder
) {
$aCanon = $aliasMap[$a] ?? [ 'key' => $a, 'order' => -1 ];
$bCanon = $aliasMap[$b] ?? [ 'key' => $b, 'order' => -1 ];
// primary key is `nearestOrder` (nearest original parameter)
$aOrder = $defaultGet( $nearestOrder, $a, $aCanon['key'] );
$bOrder = $defaultGet( $nearestOrder, $b, $bCanon['key'] );
if ( $aOrder !== $bOrder ) {
return $aOrder - $bOrder;
}
// secondary key is templatedata order
if ( $aCanon['key'] === $bCanon['key'] ) {
return $aCanon['order'] - $bCanon['order'];
}
$aOrder = $defaultGet( $tplDataOrder, $aCanon['key'] );
$bOrder = $defaultGet( $tplDataOrder, $bCanon['key'] );
if ( $aOrder !== $bOrder ) {
return $aOrder - $bOrder;
}
// tertiary key is original input order (makes sort stable)
$aOrder = $defaultGet( $newOrder, $a );
$bOrder = $defaultGet( $newOrder, $b );
return $aOrder - $bOrder;
};
}
/**
* Serialize part of a templatelike expression.
* @param SerializerState $state
* @param string $buf
* @param Element $node
* @param TemplateInfo $part The expression fragment to serialize. See $srcParts
* in serializeFromParts() for format.
* @param ?array $tplData Templatedata, see
* https://github.com/wikimedia/mediawiki-extensions-TemplateData/blob/master/Specification.md
* @param string|TemplateInfo $prevPart Previous part. See $srcParts in serializeFromParts().
* @param string|TemplateInfo $nextPart Next part. See $srcParts in serializeFromParts().
* @return string
*/
private function serializePart(
SerializerState $state, string $buf, Element $node, TemplateInfo $part,
?array $tplData, $prevPart, $nextPart
): string {
// Parse custom format specification, if present.
$defaultBlockSpc = "{{_\n| _ = _\n}}"; // "block"
$defaultInlineSpc = '{{_|_=_}}'; // "inline"
$format = isset( $tplData['format'] ) ? strtolower( $tplData['format'] ) : null;
if ( $format === 'block' ) {
$format = $defaultBlockSpc;
} elseif ( $format === 'inline' ) {
$format = $defaultInlineSpc;
}
// Check format string for validity.
preg_match( self::FORMATSTRING_REGEXP, $format ?? '', $parsedFormat );
if ( !$parsedFormat ) {
preg_match( self::FORMATSTRING_REGEXP, $defaultInlineSpc, $parsedFormat );
$format = null; // Indicates that no valid custom format was present.
}
$formatSOL = $parsedFormat[1] ?? '';
$formatStart = $parsedFormat[2] ?? '';
$formatParamName = $parsedFormat[3] ?? '';
$formatParamValue = $parsedFormat[4] ?? '';
$formatEnd = $parsedFormat[5] ?? '';
$formatEOL = $parsedFormat[6] ?? '';
$forceTrim = ( $format !== null ) || WTUtils::isNewElt( $node );
// Shoehorn formatting of top-level templatearg wikitext into this code.
if ( $part->type === 'templatearg' ) {
$formatStart = preg_replace( '/{{/', '{{{', $formatStart, 1 );
$formatEnd = preg_replace( '/}}/', '}}}', $formatEnd, 1 );
}
// handle SOL newline requirement
if ( $formatSOL && !str_ends_with( ( $prevPart !== null ) ? $buf : ( $state->sep->src ?? '' ), "\n" ) ) {
$buf .= "\n";
}
// open the transclusion
$buf .= $this->formatStringSubst( $formatStart, $part->targetWt, $forceTrim );
// Short-circuit transclusions without params
$paramKeys = array_map( fn ( ParamInfo $pi ) => $pi->k, $part->paramInfos );
if ( !$paramKeys ) {
if ( substr( $formatEnd, 0, 1 ) === "\n" ) {
$formatEnd = substr( $formatEnd, 1 );
}
return $buf . $formatEnd;
}
// Trim whitespace from data-mw keys to deal with non-compliant
// clients. Make sure param info is accessible for the stripped key
// since later code will be using the stripped key always.
$tplKeysFromDataMw = [];
foreach ( $part->paramInfos as $pi ) {
$strippedKey = trim( $pi->k );
$tplKeysFromDataMw[$strippedKey] = $pi;
}
// Per-parameter info from data-parsoid for pre-existing parameters
$dp = DOMDataUtils::getDataParsoid( $node );
// Account for clients not setting the `i`, see T238721
$dpArgInfo = isset( $part->i ) ? ( $dp->pi[$part->i] ?? [] ) : [];
// Build a key -> arg info map
$dpArgInfoMap = [];
foreach ( $dpArgInfo as $info ) {
$dpArgInfoMap[$info->k] = $info;
}
// 1. Process all parameters and build a map of
// arg-name -> [serializeAsNamed, name, value]
//
// 2. Serialize tpl args in required order
//
// 3. Format them according to formatParamName/formatParamValue
$kvMap = [];
foreach ( $tplKeysFromDataMw as $key => $param ) {
// Storing keys in an array can turn them into ints; stringify.
$key = (string)$key;
$argInfo = $dpArgInfoMap[$key] ?? [];
// TODO: Other formats?
// Only consider the html parameter if the wikitext one
// isn't present at all. If it's present but empty,
// that's still considered a valid parameter.
if ( $param->valueWt !== null ) {
$value = $param->valueWt;
} elseif ( $param->html !== null ) {
$value = $this->htmlToWikitext( [ 'env' => $this->env ], $param->html );
} else {
$this->env->log(
'error',
"params in data-mw part is missing wt/html for $key. " .
"Serializing as empty string.",
"data-mw part: " . json_encode( $part->toJsonArray() )
);
$value = "";
}
Assert::invariant( is_string( $value ), "For param: $key, wt property should be a string '
. 'but got: $value" );
$serializeAsNamed = !empty( $argInfo->named );
// The name is usually equal to the parameter key, but
// if there's a key->wt attribute, use that.
$name = null;
if ( $param->keyWt !== null ) {
$name = $param->keyWt;
// And make it appear even if there wasn't any data-parsoid information.
$serializeAsNamed = true;
} else {
$name = $key;
}
// Use 'k' as the key, not 'name'.
//
// The normalized form of 'k' is used as the key in both
// data-parsoid and data-mw. The full non-normalized form
// is present in '$param->keyWt'
$kvMap[$key] = [ 'serializeAsNamed' => $serializeAsNamed, 'name' => $name, 'value' => $value ];
}
$argOrder = array_keys( $kvMap );
usort( $argOrder, $this->createParamComparator( $dpArgInfo, $tplData, $argOrder ) );
$argIndex = 1;
$numericIndex = 1;
$numPositionalArgs = 0;
foreach ( $dpArgInfo as $pi ) {
if ( isset( $tplKeysFromDataMw[trim( $pi->k )] ) && empty( $pi->named ) ) {
$numPositionalArgs++;
}
}
$argBuf = [];
foreach ( $argOrder as $param ) {
$kv = $kvMap[$param];
// Add nowiki escapes for the arg value, as required
$escapedValue = $this->wteHandlers->escapeTplArgWT( $kv['value'], [
'serializeAsNamed' => $kv['serializeAsNamed'] || $param !== $numericIndex,
'type' => $part->type,
'argPositionalIndex' => $numericIndex,
'numPositionalArgs' => $numPositionalArgs,
'argIndex' => $argIndex++,
'numArgs' => count( $tplKeysFromDataMw ),
] );
if ( $escapedValue['serializeAsNamed'] ) {
// WS trimming for values of named args
$argBuf[] = [ 'dpKey' => $param, 'name' => $kv['name'], 'value' => trim( $escapedValue['v'] ) ];
} else {
$numericIndex++;
// No WS trimming for positional args
$argBuf[] = [ 'dpKey' => $param, 'name' => null, 'value' => $escapedValue['v'] ];
}
}
// If no explicit format is provided, default format is:
// - 'inline' for new args
// - whatever format is available from data-parsoid for old args
// (aka, overriding formatParamName/formatParamValue)
//
// If an unedited node OR if paramFormat is unspecified,
// this strategy prevents unnecessary normalization
// of edited transclusions which don't have valid
// templatedata formatting information.
// "magic case": If the format string ends with a newline, an extra newline is added
// between the template name and the first parameter.
foreach ( $argBuf as $arg ) {
$name = $arg['name'];
$val = $arg['value'];
if ( $name === null ) {
// We are serializing a positional parameter.
// Whitespace is significant for these and
// formatting would change semantics.
$name = '';
$modFormatParamName = '|_';
$modFormatParamValue = '_';
} elseif ( $name === '' ) {
// No spacing for blank parameters ({{foo|=bar}})
// This should be an edge case and probably only for
// inline-formatted templates, but we are consciously
// forcing this default here. Can revisit if this is
// ever a problem.
$modFormatParamName = '|_=';
$modFormatParamValue = '_';
} else {
// Preserve existing spacing, esp if there was a comment
// embedded in it. Otherwise, follow TemplateData's lead.
// NOTE: In either case, we are forcibly normalizing
// non-block-formatted transclusions into block formats
// by adding missing newlines.
$spc = $dpArgInfoMap[$arg['dpKey']]->spc ?? null;
if ( $spc && ( !$format || preg_match( Utils::COMMENT_REGEXP, $spc[3] ?? '' ) ) ) {
$nl = ( substr( $formatParamName, 0, 1 ) === "\n" ) ? "\n" : '';
$modFormatParamName = $nl . '|' . $spc[0] . '_' . $spc[1] . '=' . $spc[2];
$modFormatParamValue = '_' . $spc[3];
} else {
$modFormatParamName = $formatParamName;
$modFormatParamValue = $formatParamValue;
}
}
// Don't create duplicate newlines.
$trailing = preg_match( self::TRAILING_COMMENT_OR_WS_AFTER_NL_REGEXP, $buf );
if ( $trailing && substr( $formatParamName, 0, 1 ) === "\n" ) {
$modFormatParamName = substr( $formatParamName, 1 );
}
$buf .= $this->formatStringSubst( $modFormatParamName, $name, $forceTrim );
$buf .= $this->formatStringSubst( $modFormatParamValue, $val, $forceTrim );
}
// Don't create duplicate newlines.
if ( preg_match( self::TRAILING_COMMENT_OR_WS_AFTER_NL_REGEXP, $buf )
&& substr( $formatEnd, 0, 1 ) === "\n"
) {
$buf .= substr( $formatEnd, 1 );
} else {
$buf .= $formatEnd;
}
if ( $formatEOL ) {
if ( $nextPart === null ) {
// This is the last part of the block. Add the \n only
// if the next non-comment node is not a text node
// of if the text node doesn't have a leading \n.
$next = DiffDOMUtils::nextNonDeletedSibling( $node );
while ( $next instanceof Comment ) {
$next = DiffDOMUtils::nextNonDeletedSibling( $next );
}
if ( !( $next instanceof Text ) || substr( $next->nodeValue, 0, 1 ) !== "\n" ) {
$buf .= "\n";
}
} elseif ( !is_string( $nextPart ) || substr( $nextPart, 0, 1 ) !== "\n" ) {
// If nextPart is another template, and it wants a leading nl,
// this \n we add here will count towards that because of the
// formatSOL check at the top.
$buf .= "\n";
}
}
return $buf;
}
/**
* Serialize a template from its parts.
* @param SerializerState $state
* @param Element $node
* @param list<string|TemplateInfo> $srcParts Template parts
* @return string
*/
public function serializeFromParts(
SerializerState $state, Element $node, array $srcParts
): string {
$useTplData = WTUtils::isNewElt( $node ) || DiffUtils::hasDiffMarkers( $node );
$buf = '';
foreach ( $srcParts as $i => $part ) {
if ( is_string( $part ) ) {
$buf .= $part;
continue;
}
$prevPart = $srcParts[$i - 1] ?? null;
$nextPart = $srcParts[$i + 1] ?? null;
if ( !isset( $part->targetWt ) ) {
// Maybe we should just raise a ClientError
$this->env->log( 'error', 'data-mw.parts array is malformed: ',
DOMCompat::getOuterHTML( $node ), PHPUtils::jsonEncode( $srcParts ) );
continue;
}
// Account for clients leaving off the params array, presumably when empty.
// See T291741
$part->paramInfos ??= [];
if ( $part->type === 'templatearg' ) {
$buf = $this->serializePart(
$state, $buf, $node, $part, null, $prevPart,
$nextPart
);
continue;
}
// transclusion: tpl or parser function?
// templates have $part->href
// parser functions have $part->func
// While the API supports fetching multiple template data objects in one call,
// we will fetch one at a time to benefit from cached responses.
//
// Fetch template data for the template
$tplData = null;
$apiResp = null;
if ( isset( $part->href ) && $useTplData ) {
// Not a parser function
try {
$title = Title::newFromText(
PHPUtils::stripPrefix( Utils::decodeURIComponent( $part->href ), './' ),
$this->env->getSiteConfig()
);
$tplData = $this->env->getDataAccess()->fetchTemplateData( $this->env->getPageConfig(), $title );
} catch ( Exception $err ) {
// Log the error, and use default serialization mode.
// Better to misformat a transclusion than to lose an edit.
$this->env->log( 'error/html2wt/tpldata', $err );
}
}
// If the template doesn't exist, or does but has no TemplateData, ignore it
if ( !empty( $tplData['missing'] ) || !empty( $tplData['notemplatedata'] ) ) {
$tplData = null;
}
$buf = $this->serializePart( $state, $buf, $node, $part, $tplData, $prevPart, $nextPart );
}
return $buf;
}
public function serializeExtensionStartTag( Element $node, SerializerState $state ): string {
$dataMw = DOMDataUtils::getDataMw( $node );
$extTagName = $dataMw->name;
// Serialize extension attributes in normalized form as:
// key='value'
// FIXME: with no dataParsoid, shadow info will mark it as new
$attrs = (array)( $dataMw->attrs ?? [] );
$extTok = new TagTk( $extTagName, array_map( static function ( $key ) use ( $attrs ) {
return new KV( $key, $attrs[$key] );
}, array_keys( $attrs ) ) );
$about = DOMCompat::getAttribute( $node, 'about' );
if ( $about !== null ) {
$extTok->addAttribute( 'about', $about );
}
$typeof = DOMCompat::getAttribute( $node, 'typeof' );
if ( $typeof !== null ) {
$extTok->addAttribute( 'typeof', $typeof );
}
$attrStr = $this->serializeAttributes( $node, $extTok );
$src = '<' . $extTagName;
if ( $attrStr ) {
$src .= ' ' . $attrStr;
}
return $src . ( !empty( $dataMw->body ) ? '>' : ' />' );
}
public function defaultExtensionHandler( Element $node, SerializerState $state ): string {
$dp = DOMDataUtils::getDataParsoid( $node );
$dataMw = DOMDataUtils::getDataMw( $node );
$src = $this->serializeExtensionStartTag( $node, $state );
if ( !isset( $dataMw->body ) ) {
return $src; // We self-closed this already.
} elseif ( is_string( $dataMw->body->extsrc ?? null ) ) {
$src .= $dataMw->body->extsrc;
} elseif ( isset( $dp->src ) ) {
$this->env->log(
'error/html2wt/ext',
'Extension data-mw missing for: ' . DOMCompat::getOuterHTML( $node )
);
return $dp->src;
} else {
$this->env->log(
'error/html2wt/ext',
'Extension src unavailable for: ' . DOMCompat::getOuterHTML( $node )
);
}
return $src . '</' . $dataMw->name . '>';
}
/**
* Consolidate separator handling when emitting text.
* @param string $res
* @param Node $node
*/
private function serializeText( string $res, Node $node ): void {
$state = $this->state;
// Deal with trailing separator-like text (at least 1 newline and other whitespace)
preg_match( self::$separatorREs['sepSuffixWithNlsRE'], $res, $newSepMatch );
$res = preg_replace( self::$separatorREs['sepSuffixWithNlsRE'], '', $res, 1 );
if ( !$state->inIndentPre ) {
// Strip leading newlines and other whitespace
if ( preg_match( self::$separatorREs['sepPrefixWithNlsRE'], $res, $match ) ) {
$state->appendSep( $match[0] );
$res = substr( $res, strlen( $match[0] ) );
}
}
if ( $state->needsEscaping ) {
$res = Utils::escapeWtEntities( $res );
}
$state->emitChunk( $res, $node );
// Move trailing newlines into the next separator
if ( $newSepMatch ) {
if ( !$state->sep->src ) {
$state->appendSep( $newSepMatch[0] );
} else {
/* SSS FIXME: what are we doing with the stripped NLs?? */
}
}
}
/**
* Serialize the content of a text node
* @param Node $node
* @return Node|null
*/
private function serializeTextNode( Node $node ): ?Node {
$this->state->needsEscaping = true;
$this->serializeText( $node->nodeValue, $node );
$this->state->needsEscaping = false;
return $node->nextSibling;
}
/**
* Emit non-separator wikitext that does not need to be escaped.
* @param string $res
* @param Node $node
*/
public function emitWikitext( string $res, Node $node ): void {
$this->serializeText( $res, $node );
}
/**
* DOM-based serialization
* @param Element $node
* @param DOMHandler $domHandler
* @return Node|null
*/
private function serializeNodeInternal( Element $node, DOMHandler $domHandler ) {
// To serialize a node from source, the node should satisfy these
// conditions:
//
// 1. It should not have a diff marker or be in a modified subtree
// WTS should not be in a subtree with a modification flag that
// applies to every node of a subtree (rather than an indication
// that some node in the subtree is modified).
//
// 2. It should continue to be valid in any surrounding edited context
// For some nodes, modification of surrounding context
// can change serialized output of this node
// (ex: <td>s and whether you emit | or || for them)
//
// 3. It should have valid, usable DSR
//
// 4. Either it has non-zero positive DSR width, or meets one of the
// following:
//
// 4a. It is content like <p><br/><p> or an automatically-inserted
// wikitext <references/> (HTML <ol>) (will have dsr-width 0)
// 4b. it is fostered content (will have dsr-width 0)
// 4c. it is misnested content (will have dsr-width 0)
//
// SSS FIXME: Additionally, we can guard against buggy DSR with
// some validity checks. We can test that non-sep src content
// leading wikitext markup corresponds to the node type.
//
// Ex: If node.nodeName is 'UL', then src[0] should be '*'
//
// TO BE DONE
$state = $this->state;
$wrapperUnmodified = false;
$dp = DOMDataUtils::getDataParsoid( $node );
if ( $state->selserMode
&& !$state->inInsertedContent
&& WTSUtils::origSrcValidInEditedContext( $state, $node )
&& Utils::isValidDSR( $dp->dsr ?? null )
&& ( $dp->dsr->end > $dp->dsr->start
// FIXME: <p><br/></p>
// nodes that have dsr width 0 because currently,
// we emit newlines outside the p-nodes. So, this check
// tries to handle that scenario.
|| (
$dp->dsr->end === $dp->dsr->start && (
in_array( DOMCompat::nodeName( $node ), [ 'p', 'br' ], true )
|| !empty( DOMDataUtils::getDataMw( $node )->autoGenerated )
// FIXME: This is only necessary while outputContentVersion
// 2.1.2 - 2.2.0 are still valid
|| DOMUtils::hasTypeOf( $node, 'mw:Placeholder/StrippedTag' )
)
)
|| !empty( $dp->fostered )
|| !empty( $dp->misnested )
)
) {
if ( !DiffUtils::hasDiffMarkers( $node ) ) {
// If this HTML node will disappear in wikitext because of
// zero width, then the separator constraints will carry over
// to the node's children.
//
// Since we dont recurse into 'node' in selser mode, we update the
// separator constraintInfo to apply to 'node' and its first child.
//
// We could clear constraintInfo altogether which would be
// correct (but could normalize separators and introduce dirty
// diffs unnecessarily).
$state->currNodeUnmodified = true;
if ( WTUtils::isZeroWidthWikitextElt( $node )
&& $node->hasChildNodes()
&& ( $state->sep->constraints['constraintInfo']['sepType'] ?? null ) === 'sibling'
) {
$state->sep->constraints['constraintInfo']['onSOL'] = $state->onSOL;
$state->sep->constraints['constraintInfo']['sepType'] = 'parent-child';
$state->sep->constraints['constraintInfo']['nodeA'] = $node;
$state->sep->constraints['constraintInfo']['nodeB'] = $node->firstChild;
}
$out = $state->getOrigSrc( $dp->dsr ) ?? '';
$this->trace( 'ORIG-src with DSR', static function () use ( $dp, $out ) {
return '[' . $dp->dsr->start . ',' . $dp->dsr->end . '] = '
. PHPUtils::jsonEncode( $out );
} );
// When reusing source, we should only suppress serializing
// to a single line for the cases we've allowed in normal serialization.
// <a> tags might look surprising here, but, here is the rationale.
// If some link syntax (wikilink, extlink, etc.) accepted a newline
// originally, we can safely let it through here. There is no need to have
// specific checks for wikilnks / extlinks / ... etc. The only concern is
// if the surrounding context in which this link-syntax is embedded also
// breaks the link syntax. There is no such syntax right now.
// FIXME: Note the limitation here, that if these nodes are nested
// in something as trivial as an i / b, the suppression won't happen
// and we'll dirty the text.
$suppressSLC = WTUtils::isFirstEncapsulationWrapperNode( $node )
|| DOMUtils::hasTypeOf( $node, 'mw:Nowiki' )
|| in_array( DOMCompat::nodeName( $node ), [ 'dl', 'ul', 'ol', 'a' ], true )
|| ( DOMCompat::nodeName( $node ) === 'table'
&& DOMCompat::nodeName( $node->parentNode ) === 'dd'
&& DiffDOMUtils::previousNonSepSibling( $node ) === null );
// Use selser to serialize this text! The original
// wikitext is `out`. But first allow
// `ConstrainedText.fromSelSer` to figure out the right
// type of ConstrainedText chunk(s) to use to represent
// `out`, based on the node type. Since we might actually
// have to break this wikitext into multiple chunks,
// `fromSelSer` returns an array.
if ( $suppressSLC ) {
$state->singleLineContext->disable();
}
foreach ( ConstrainedText::fromSelSer( $out, $node, $dp, $this->env ) as $ct ) {
$state->emitChunk( $ct, $ct->node );
}
if ( $suppressSLC ) {
$state->singleLineContext->pop();
}
// Skip over encapsulated content since it has already been
// serialized.
if ( WTUtils::isFirstEncapsulationWrapperNode( $node ) ) {
return WTUtils::skipOverEncapsulatedContent( $node );
} else {
return $node->nextSibling;
}
}
$wrapperUnmodified = DiffUtils::onlySubtreeChanged( $node ) &&
WTSUtils::hasValidTagWidths( $dp->dsr ?? null );
}
$state->currNodeUnmodified = false;
$currentInsertedState = $state->inInsertedContent;
$inInsertedContent = $state->selserMode && DiffUtils::hasInsertedDiffMark( $node );
if ( $inInsertedContent ) {
$state->inInsertedContent = true;
}
$next = $domHandler->handle( $node, $state, $wrapperUnmodified );
if ( $inInsertedContent ) {
$state->inInsertedContent = $currentInsertedState;
}
return $next;
}
/**
* Internal worker. Recursively serialize a DOM subtree.
* @private
* @param Node $node
* @return ?Node
*/
public function serializeNode( Node $node ): ?Node {
$nodeName = DOMCompat::nodeName( $node );
$domHandler = $method = null;
$domHandlerFactory = new DOMHandlerFactory();
$state = $this->state;
$state->currNode = $node;
if ( $state->selserMode ) {
$this->trace(
static function () use ( $node ) {
return WTSUtils::traceNodeName( $node );
},
'; prev-unmodified: ', $state->prevNodeUnmodified,
'; SOL: ', $state->onSOL );
} else {
$this->trace(
static function () use ( $node ) {
return WTSUtils::traceNodeName( $node );
},
'; SOL: ', $state->onSOL );
}
switch ( $node->nodeType ) {
case XML_ELEMENT_NODE:
'@phan-var Element $node';/** @var Element $node */
// Ignore DiffMarker metas, but clear unmodified node state
if ( DiffUtils::isDiffMarker( $node ) ) {
$state->updateModificationFlags( $node );
// `state.sep.lastSourceNode` is cleared here so that removed
// separators between otherwise unmodified nodes don't get
// restored.
$state->updateSep( $node );
return $node->nextSibling;
}
$domHandler = $domHandlerFactory->getDOMHandler( $node );
$method = [ $this, 'serializeNodeInternal' ];
break;
case XML_TEXT_NODE:
// This code assumes that the DOM is in normalized form with no
// run of text nodes.
// Accumulate whitespace from the text node into state.sep.src
$text = $node->nodeValue;
if ( !$state->inIndentPre
// PORT-FIXME: original uses this->state->serializer->separatorREs
// but that does not seem useful
&& preg_match( self::$separatorREs['pureSepRE'], $text )
) {
$state->appendSep( $text );
return $node->nextSibling;
}
if ( $state->selserMode ) {
$prev = $node->previousSibling;
if ( !$state->inInsertedContent && (
( !$prev && DOMUtils::atTheTop( $node->parentNode ) ) ||
( $prev && !DiffUtils::isDiffMarker( $prev ) )
) ) {
$state->currNodeUnmodified = true;
} else {
$state->currNodeUnmodified = false;
}
}
$domHandler = new DOMHandler( false );
$method = [ $this, 'serializeTextNode' ];
break;
case XML_COMMENT_NODE:
// Merge this into separators
$state->appendSep( WTSUtils::commentWT( $node->nodeValue ) );
return $node->nextSibling;
default:
throw new InternalException( 'Unhandled node type: ' . $node->nodeType );
}
$prev = DiffDOMUtils::previousNonSepSibling( $node ) ?: $node->parentNode;
$this->env->log( 'debug/wts', 'Before constraints for ' . $nodeName );
$state->separators->updateSeparatorConstraints(
$prev, $domHandlerFactory->getDOMHandler( $prev ),
$node, $domHandler
);
$this->env->log( 'debug/wts', 'Calling serialization handler for ' . $nodeName );
$nextNode = call_user_func( $method, $node, $domHandler );
$next = DiffDOMUtils::nextNonSepSibling( $node ) ?: $node->parentNode;
$this->env->log( 'debug/wts', 'After constraints for ' . $nodeName );
$state->separators->updateSeparatorConstraints(
$node, $domHandler,
$next, $domHandlerFactory->getDOMHandler( $next )
);
// Update modification flags
$state->updateModificationFlags( $node );
return $nextNode;
}
private function stripUnnecessaryHeadingNowikis( string $line ): string {
$state = $this->state;
if ( !$state->hasHeadingEscapes ) {
return $line;
}
$escaper = static function ( string $wt ) use ( $state ) {
$ret = $state->serializer->wteHandlers->escapedText( $state, false, $wt, false, true );
return $ret;
};
preg_match( self::HEADING_NOWIKI_REGEXP, $line, $match );
if ( $match && !preg_match( self::COMMENT_OR_WS_REGEXP, $match[2] ) ) {
// The nowikiing was spurious since the trailing = is not in EOL position
return $escaper( $match[1] ) . $match[2];
} else {
// All is good.
return $line;
}
}
private function stripUnnecessaryIndentPreNowikis(): void {
// FIXME: The solTransparentWikitextRegexp includes redirects, which really
// only belong at the SOF and should be unique. See the "New redirect" test.
$noWikiRegexp = '@^'
. PHPUtils::reStrip( $this->env->getSiteConfig()->solTransparentWikitextNoWsRegexp(), '@' )
. '((?i:<nowiki>\s+</nowiki>))([^\n]*(?:\n|$))' . '@Dm';
$pieces = preg_split( $noWikiRegexp, $this->state->out, -1, PREG_SPLIT_DELIM_CAPTURE );
$out = $pieces[0];
for ( $i = 1; $i < count( $pieces ); $i += 4 ) {
$out .= $pieces[$i];
$nowiki = $pieces[$i + 1];
$rest = $pieces[$i + 2];
// Ignore comments
preg_match_all( '/<[^!][^<>]*>/', $rest, $htmlTags );
// Not required if just sol transparent wt.
$reqd = !preg_match( $this->env->getSiteConfig()->solTransparentWikitextRegexp(), $rest );
if ( $reqd ) {
foreach ( $htmlTags[0] as $j => $rawTagName ) {
// Strip </, attributes, and > to get the tagname
$tagName = preg_replace( '/<\/?|\s.*|>/', '', $rawTagName );
if ( !isset( Consts::$HTML['HTML5Tags'][$tagName] ) ) {
// If we encounter any tag that is not a html5 tag,
// it could be an extension tag. We could do a more complex
// regexp or tokenize the string to determine if any block tags
// show up outside the extension tag. But, for now, we just
// conservatively bail and leave the nowiki as is.
$reqd = true;
break;
} elseif ( TokenUtils::isWikitextBlockTag( $tagName ) ) {
// FIXME: Extension tags shadowing html5 tags might not
// have block semantics.
// Block tags on a line suppress nowikis
$reqd = false;
}
}
}
if ( !$reqd ) {
$nowiki = preg_replace( '#^<nowiki>(\s+)</nowiki>#', '$1', $nowiki, 1 );
} else {
$solTransparentWikitextNoWsRegexpFragment = PHPUtils::reStrip(
$this->env->getSiteConfig()->solTransparentWikitextNoWsRegexp(), '/' );
$wsReplacementRE = '/^(' . $solTransparentWikitextNoWsRegexpFragment . ')\s+/';
// Replace all leading whitespace
do {
$oldRest = $rest;
$rest = preg_replace( $wsReplacementRE, '$1', $rest );
} while ( $rest !== $oldRest );
// Protect against sol-sensitive wikitext characters
$solCharsTest = '/^' . $solTransparentWikitextNoWsRegexpFragment . '[=*#:;]/';
$nowiki = preg_replace( '#^<nowiki>(\s+)</nowiki>#',
preg_match( $solCharsTest, $rest ) ? '<nowiki/>' : '', $nowiki, 1 );
}
$out = $out . $nowiki . $rest . $pieces[$i + 3];
}
$this->state->out = $out;
}
/**
* This implements a heuristic to strip two common sources of <nowiki/>s.
* When <i> and <b> tags are matched up properly,
* - any single ' char before <i> or <b> does not need <nowiki/> protection.
* - any single ' char before </i> or </b> does not need <nowiki/> protection.
* @param string $line
* @return string
*/
private function stripUnnecessaryQuoteNowikis( string $line ): string {
if ( !$this->state->hasQuoteNowikis ) {
return $line;
}
// Optimization: We are interested in <nowiki/>s before quote chars.
// So, skip this if we don't have both.
if ( !( preg_match( '#<nowiki\s*/>#', $line ) && preg_match( "/'/", $line ) ) ) {
return $line;
}
// * Split out all the [[ ]] {{ }} '' ''' ''''' <..> </...>
// parens in the regexp mean that the split segments will
// be spliced into the result array as the odd elements.
// * If we match up the tags properly and we see opening
// <i> / <b> / <i><b> tags preceded by a '<nowiki/>, we
// can remove all those nowikis.
// Ex: '<nowiki/>''foo'' bar '<nowiki/>'''baz'''
// * If we match up the tags properly and we see closing
// <i> / <b> / <i><b> tags preceded by a '<nowiki/>, we
// can remove all those nowikis.
// Ex: ''foo'<nowiki/>'' bar '''baz'<nowiki/>'''
// phpcs:ignore Generic.Files.LineLength.TooLong
$p = preg_split( "#('''''|'''|''|\[\[|\]\]|\{\{|\}\}|<\w+(?:\s+[^>]*?|\s*?)/?>|</\w+\s*>)#", $line, -1, PREG_SPLIT_DELIM_CAPTURE );
// Which nowiki do we strip out?
$nowikiIndex = -1;
// Verify that everything else is properly paired up.
$stack = [];
$quotesOnStack = 0;
$n = count( $p );
$nonHtmlTag = null;
for ( $j = 1; $j < $n; $j += 2 ) {
// For HTML tags, pull out just the tag name for clearer code below.
preg_match( '#^<(/?\w+)#', $p[$j], $matches );
$tag = mb_strtolower( $matches[1] ?? $p[$j] );
$tagLen = strlen( $tag );
$selfClose = false;
if ( str_ends_with( $p[$j], '/>' ) ) {
$tag .= '/';
$selfClose = true;
}
// Ignore non-html-tag (<nowiki> OR extension tag) blocks
if ( !$nonHtmlTag ) {
if ( isset( $this->env->getSiteConfig()->getExtensionTagNameMap()[$tag] ) ) {
$nonHtmlTag = $tag;
continue;
}
} else {
if ( $tagLen > 0 && $tag[0] === '/' && substr( $tag, 1 ) === $nonHtmlTag ) {
$nonHtmlTag = null;
}
continue;
}
if ( $tag === ']]' ) {
if ( array_pop( $stack ) !== '[[' ) {
return $line;
}
} elseif ( $tag === '}}' ) {
if ( array_pop( $stack ) !== '{{' ) {
return $line;
}
} elseif ( $tagLen > 0 && $tag[0] === '/' ) { // closing html tag
// match html/ext tags
$openTag = array_pop( $stack );
if ( $tag !== ( '/' . $openTag ) ) {
return $line;
}
} elseif ( $tag === 'nowiki/' ) {
// We only want to process:
// - trailing single quotes (bar')
// - or single quotes by themselves without a preceding '' sequence
if ( substr( $p[$j - 1], -1 ) === "'"
&& !( $p[$j - 1] === "'" && $j > 1 && substr( $p[$j - 2], -2 ) === "''" )
// Consider <b>foo<i>bar'</i>baz</b> or <b>foo'<i>bar'</i>baz</b>.
// The <nowiki/> before the <i> or </i> cannot be stripped
// if the <i> is embedded inside another quote.
&& ( $quotesOnStack === 0
// The only strippable scenario with a single quote elt on stack
// is: ''bar'<nowiki/>''
// -> ["", "''", "bar'", "<nowiki/>", "", "''"]
|| ( $quotesOnStack === 1
&& $j + 2 < $n
&& $p[$j + 1] === ''
&& $p[$j + 2][0] === "'"
&& $p[$j + 2] === PHPUtils::lastItem( $stack ) ) )
) {
$nowikiIndex = $j;
}
continue;
} elseif ( $selfClose || $tag === 'br' ) {
// Skip over self-closing tags or what should have been self-closed.
// ( While we could do this for all void tags defined in
// mediawiki.wikitext.constants.js, <br> is the most common
// culprit. )
continue;
} elseif ( $tagLen > 0 && $tag[0] === "'" && PHPUtils::lastItem( $stack ) === $tag ) {
array_pop( $stack );
$quotesOnStack--;
} else {
$stack[] = $tag;
if ( $tagLen > 0 && $tag[0] === "'" ) {
$quotesOnStack++;
}
}
}
if ( count( $stack ) ) {
return $line;
}
if ( $nowikiIndex !== -1 ) {
// We can only remove the final trailing nowiki.
//
// HTML : <i>'foo'</i>
// line : ''<nowiki/>'foo'<nowiki/>''
$p[$nowikiIndex] = '';
return implode( '', $p );
} else {
return $line;
}
}
/**
* Serialize an HTML DOM.
*
* WARNING: You probably want to use WikitextContentModelHandler::fromDOM instead.
*
* @param Document|DocumentFragment $node
* @param bool $selserMode
* @return string
*/
public function serializeDOM(
Node $node, bool $selserMode = false
): string {
Assert::parameterType(
Document::class . '|' . DocumentFragment::class,
$node, '$node' );
if ( $node instanceof Document ) {
$node = DOMCompat::getBody( $node );
}
$this->logType = $selserMode ? 'trace/selser' : 'trace/wts';
$state = $this->state;
$state->initMode( $selserMode );
$domNormalizer = new DOMNormalizer( $state );
$domNormalizer->normalize( $node );
if ( $this->env->hasDumpFlag( 'dom:post-normal' ) ) {
$options = [ 'storeDiffMark' => true ];
$this->env->writeDump( ContentUtils::dumpDOM( $node, 'DOM: post-normal', $options ) );
}
$state->kickOffSerialize( $node );
if ( $state->hasIndentPreNowikis ) {
// FIXME: Perhaps this can be done on a per-line basis
// rather than do one post-pass on the entire document.
$this->stripUnnecessaryIndentPreNowikis();
}
$splitLines = $state->selserMode
|| $state->hasQuoteNowikis
|| $state->hasSelfClosingNowikis
|| $state->hasHeadingEscapes;
if ( $splitLines ) {
$state->out = implode( "\n", array_map( function ( $line ) {
// FIXME: Perhaps this can be done on a per-line basis
// rather than do one post-pass on the entire document.
$line = $this->stripUnnecessaryQuoteNowikis( $line );
return $this->stripUnnecessaryHeadingNowikis( $line );
}, explode( "\n", $state->out ) ) );
}
if ( $state->redirectText && $state->redirectText !== 'unbuffered' ) {
$firstLine = explode( "\n", $state->out, 1 )[0];
$nl = preg_match( '/^(\s|$)/D', $firstLine ) ? '' : "\n";
$state->out = $state->redirectText . $nl . $state->out;
}
return $state->out;
}
/**
* @note Porting note: this replaces the pattern $serializer->env->log( $serializer->logType, ... )
* @param mixed ...$args
*/
public function trace( ...$args ) {
$this->env->log( $this->logType, ...$args );
}
}

File Metadata

Mime Type
text/x-php
Expires
Aug 19 2026, 21:12 (7 w, 6 d ago)
Storage Engine
local-disk
Storage Format
Raw Data
Storage Handle
93/d7/b792577eb7376ed37d49de34f05b
Default Alt Text
WikitextSerializer.php (55 KB)

Event Timeline