mirror of
https://gerrit.wikimedia.org/r/mediawiki/extensions/Cite
synced 2024-11-27 16:30:12 +00:00
60fbb32c1a
The new mark label renderer is a service and can be called from Parsoid. Bug: T377454 Change-Id: I6f4983c4288bf29954ad4e5fa3309f9bdf48215d
374 lines
12 KiB
PHP
374 lines
12 KiB
PHP
<?php
|
|
|
|
/**
|
|
* A parser extension that adds two tags, <ref> and <references> for adding
|
|
* citations to pages
|
|
*
|
|
* @ingroup Extensions
|
|
*
|
|
* Documentation
|
|
* @link https://www.mediawiki.org/wiki/Extension:Cite/Cite.php
|
|
*
|
|
* <cite> definition in HTML
|
|
* @link http://www.w3.org/TR/html4/struct/text.html#edef-CITE
|
|
*
|
|
* <cite> definition in XHTML 2.0
|
|
* @link http://www.w3.org/TR/2005/WD-xhtml2-20050527/mod-text.html#edef_text_cite
|
|
*
|
|
* @bug https://phabricator.wikimedia.org/T6579
|
|
*
|
|
* @author Ævar Arnfjörð Bjarmason <avarab@gmail.com>
|
|
* @copyright Copyright © 2005, Ævar Arnfjörð Bjarmason
|
|
* @license GPL-2.0-or-later
|
|
*/
|
|
|
|
namespace Cite;
|
|
|
|
use LogicException;
|
|
use MediaWiki\Config\Config;
|
|
use MediaWiki\Html\Html;
|
|
use MediaWiki\Parser\Parser;
|
|
use MediaWiki\Parser\Sanitizer;
|
|
use StatusValue;
|
|
|
|
/**
|
|
* @license GPL-2.0-or-later
|
|
*/
|
|
class Cite {
|
|
|
|
public const DEFAULT_GROUP = '';
|
|
|
|
/** Attribute name for the sub-referencing feature in <ref …> */
|
|
public const SUBREF_ATTRIBUTE = 'extends';
|
|
|
|
/**
|
|
* Message key for the (localized) tracking category for pages using the `extends` attribute.
|
|
*/
|
|
public const EXTENDS_TRACKING_CATEGORY = 'cite-tracking-category-ref-extends';
|
|
|
|
private bool $isSectionPreview;
|
|
private FootnoteMarkFormatter $footnoteMarkFormatter;
|
|
private ReferenceListFormatter $referenceListFormatter;
|
|
private ErrorReporter $errorReporter;
|
|
|
|
/**
|
|
* True when a <ref> tag is being processed.
|
|
* Used to avoid infinite recursion
|
|
*/
|
|
private bool $inRefTag = false;
|
|
|
|
/**
|
|
* @var null|string The current group name while parsing nested <ref> in <references>. Null when
|
|
* parsing <ref> outside of <references>. Warning, an empty string is a valid group name!
|
|
*/
|
|
private ?string $inReferencesGroup = null;
|
|
|
|
/**
|
|
* Error stack used when defining refs in <references>
|
|
*/
|
|
private StatusValue $mReferencesErrors;
|
|
private ReferenceStack $referenceStack;
|
|
private Config $config;
|
|
|
|
public function __construct( Parser $parser, Config $config ) {
|
|
$this->isSectionPreview = $parser->getOptions()->getIsSectionPreview();
|
|
$messageLocalizer = new ReferenceMessageLocalizer( $parser->getContentLanguage() );
|
|
$this->errorReporter = new ErrorReporter( $messageLocalizer );
|
|
$this->mReferencesErrors = StatusValue::newGood();
|
|
$this->referenceStack = new ReferenceStack();
|
|
$anchorFormatter = new AnchorFormatter();
|
|
$this->footnoteMarkFormatter = new FootnoteMarkFormatter(
|
|
$anchorFormatter,
|
|
new MarkSymbolRenderer( $messageLocalizer ),
|
|
$messageLocalizer
|
|
);
|
|
$this->referenceListFormatter = new ReferenceListFormatter(
|
|
$this->errorReporter,
|
|
$anchorFormatter,
|
|
$messageLocalizer
|
|
);
|
|
$this->config = $config;
|
|
}
|
|
|
|
/**
|
|
* Callback function for <ref>
|
|
*
|
|
* @param Parser $parser
|
|
* @param ?string $text Raw, untrimmed wikitext content of the <ref> tag, if any
|
|
* @param string[] $argv Arguments as given in <ref name=…>, already trimmed
|
|
*
|
|
* @return string|null Null in case a <ref> tag is not allowed in the current context
|
|
*/
|
|
public function ref( Parser $parser, ?string $text, array $argv ): ?string {
|
|
if ( $this->inRefTag ) {
|
|
return null;
|
|
}
|
|
|
|
$this->inRefTag = true;
|
|
$ret = $this->guardedRef( $parser, $text, $argv );
|
|
$this->inRefTag = false;
|
|
|
|
return $ret;
|
|
}
|
|
|
|
/**
|
|
* @param Parser $parser
|
|
* @param ?string $text Raw, untrimmed wikitext content of the <ref> tag, if any
|
|
* @param string[] $argv Arguments as given in <ref name=…>, already trimmed
|
|
*
|
|
* @return string HTML
|
|
*/
|
|
private function guardedRef(
|
|
Parser $parser,
|
|
?string $text,
|
|
array $argv
|
|
): string {
|
|
// Tag every page where sub-referencing has been used, whether or not the ref tag is valid.
|
|
// TODO: Remove this generic usage tracking once the feature is stable. See T237531.
|
|
if ( array_key_exists( self::SUBREF_ATTRIBUTE, $argv ) ) {
|
|
$parser->addTrackingCategory( self::EXTENDS_TRACKING_CATEGORY );
|
|
}
|
|
|
|
$status = $this->parseArguments(
|
|
$argv,
|
|
[ 'group', 'name', self::SUBREF_ATTRIBUTE, 'follow', 'dir' ]
|
|
);
|
|
$arguments = $status->getValue();
|
|
// Use the default group, or the references group when inside one.
|
|
$arguments['group'] ??= $this->inReferencesGroup ?? self::DEFAULT_GROUP;
|
|
|
|
$validator = new Validator(
|
|
$this->referenceStack,
|
|
$this->inReferencesGroup,
|
|
$this->isSectionPreview,
|
|
$this->config->get( 'CiteBookReferencing' )
|
|
);
|
|
// @phan-suppress-next-line PhanParamTooFewUnpack No good way to document it.
|
|
$status->merge( $validator->validateRef( $text, ...array_values( $arguments ) ) );
|
|
|
|
// Validation cares about the difference between null and empty, but from here on we don't
|
|
if ( $text !== null && trim( $text ) === '' ) {
|
|
$text = null;
|
|
}
|
|
|
|
if ( $this->inReferencesGroup !== null ) {
|
|
if ( !$status->isGood() ) {
|
|
// We know we are in the middle of a <references> tag and can't display errors in place
|
|
$this->mReferencesErrors->merge( $status );
|
|
} elseif ( $text !== null ) {
|
|
// Validation made sure we always have group and name while in <references>
|
|
$this->referenceStack->listDefinedRef( $arguments['group'], $arguments['name'], $text );
|
|
}
|
|
return '';
|
|
}
|
|
|
|
if ( !$status->isGood() ) {
|
|
$this->referenceStack->pushInvalidRef();
|
|
|
|
// FIXME: If we ever have multiple errors, these must all be presented to the user,
|
|
// so they know what to correct.
|
|
// TODO: Make this nicer, see T238061
|
|
return $this->errorReporter->firstError( $parser, $status );
|
|
}
|
|
|
|
// @phan-suppress-next-line PhanParamTooFewUnpack No good way to document it.
|
|
$ref = $this->referenceStack->pushRef(
|
|
$parser->getStripState(), $text, $argv, ...array_values( $arguments ) );
|
|
if ( !$ref ) {
|
|
// Rare edge-cases like follow="…" don't render a footnote marker in-place
|
|
return '';
|
|
}
|
|
|
|
return $this->footnoteMarkFormatter->linkRef( $parser, $ref );
|
|
}
|
|
|
|
/**
|
|
* @param string[] $argv The argument vector
|
|
* @param string[] $allowedAttributes Allowed attribute names
|
|
*
|
|
* @return StatusValue Either an error, or has a value with the dictionary of field names and
|
|
* parsed or default values. Missing attributes will be `null`.
|
|
*/
|
|
private function parseArguments( array $argv, array $allowedAttributes ): StatusValue {
|
|
$expected = count( $allowedAttributes );
|
|
$allValues = array_merge( array_fill_keys( $allowedAttributes, null ), $argv );
|
|
if ( isset( $allValues['dir'] ) ) {
|
|
// @phan-suppress-next-line PhanTypeMismatchArgumentNullableInternal False positive
|
|
$allValues['dir'] = strtolower( $allValues['dir'] );
|
|
}
|
|
|
|
$status = StatusValue::newGood( array_slice( $allValues, 0, $expected ) );
|
|
|
|
if ( count( $allValues ) > $expected ) {
|
|
// A <ref> must have a name (can be null), but <references> can't have one
|
|
$status->fatal( in_array( 'name', $allowedAttributes, true )
|
|
? 'cite_error_ref_too_many_keys'
|
|
: 'cite_error_references_invalid_parameters'
|
|
);
|
|
}
|
|
|
|
return $status;
|
|
}
|
|
|
|
/**
|
|
* Callback function for <references>
|
|
*
|
|
* @param Parser $parser
|
|
* @param ?string $text Raw, untrimmed wikitext content of the <references> tag, if any
|
|
* @param string[] $argv Arguments as given in <references …>, already trimmed
|
|
*
|
|
* @return string|null Null in case a <references> tag is not allowed in the current context
|
|
*/
|
|
public function references( Parser $parser, ?string $text, array $argv ): ?string {
|
|
if ( $this->inRefTag || $this->inReferencesGroup !== null ) {
|
|
return null;
|
|
}
|
|
|
|
$status = $this->parseArguments( $argv, [ 'group', 'responsive' ] );
|
|
$arguments = $status->getValue();
|
|
|
|
$this->inReferencesGroup = $arguments['group'] ?? self::DEFAULT_GROUP;
|
|
|
|
$status->merge( $this->parseReferencesTagContent( $parser, $text ) );
|
|
if ( !$status->isGood() ) {
|
|
$ret = $this->errorReporter->firstError( $parser, $status );
|
|
} else {
|
|
$responsive = $arguments['responsive'];
|
|
$ret = $this->formatReferences( $parser, $this->inReferencesGroup, $responsive );
|
|
// Append errors collected while {@see parseReferencesTagContent} processed <ref> tags
|
|
// in <references>
|
|
$ret .= $this->formatReferencesErrors( $parser );
|
|
}
|
|
|
|
$this->inReferencesGroup = null;
|
|
|
|
return $ret;
|
|
}
|
|
|
|
/**
|
|
* @param Parser $parser
|
|
* @param ?string $text Raw, untrimmed wikitext content of the <references> tag, if any
|
|
*
|
|
* @return StatusValue
|
|
*/
|
|
private function parseReferencesTagContent( Parser $parser, ?string $text ): StatusValue {
|
|
// Nothing to parse in an empty <references /> tag
|
|
if ( $text === null || trim( $text ) === '' ) {
|
|
return StatusValue::newGood();
|
|
}
|
|
|
|
if ( preg_match( '{' . preg_quote( Parser::MARKER_PREFIX ) . '-(?i:references)-}', $text ) ) {
|
|
return StatusValue::newFatal( 'cite_error_included_references' );
|
|
}
|
|
|
|
// Detect whether we were sent already rendered <ref>s. Mostly a side effect of using
|
|
// {{#tag:references}}. The following assumes that the parsed <ref>s sent within the
|
|
// <references> block were the most recent calls to <ref>. This assumption is true for
|
|
// all known use cases, but not strictly enforced by the parser. It is possible that
|
|
// some unusual combination of #tag, <references> and conditional parser functions could
|
|
// be created that would lead to malformed references here.
|
|
preg_match_all( '{' . preg_quote( Parser::MARKER_PREFIX ) . '-(?i:ref)-}', $text, $matches );
|
|
$count = count( $matches[0] );
|
|
|
|
// Undo effects of calling <ref> while unaware of being contained in <references>
|
|
foreach ( $this->referenceStack->rollbackRefs( $count ) as $call ) {
|
|
// Rerun <ref> call with the <references> context now being known
|
|
$this->guardedRef( $parser, ...$call );
|
|
}
|
|
|
|
// Parse the <references> content to process any unparsed <ref> tags, but drop the resulting
|
|
// HTML
|
|
$parser->recursiveTagParse( $text );
|
|
|
|
return StatusValue::newGood();
|
|
}
|
|
|
|
private function formatReferencesErrors( Parser $parser ): string {
|
|
$html = '';
|
|
foreach ( $this->mReferencesErrors->getErrors() as $error ) {
|
|
if ( $html ) {
|
|
$html .= "<br />\n";
|
|
}
|
|
$html .= $this->errorReporter->halfParsed( $parser, $error['message'], ...$error['params'] );
|
|
}
|
|
$this->mReferencesErrors = StatusValue::newGood();
|
|
return $html ? "\n$html" : '';
|
|
}
|
|
|
|
/**
|
|
* @param Parser $parser
|
|
* @param string $group
|
|
* @param string|null $responsive Defaults to $wgCiteResponsiveReferences when not set
|
|
*
|
|
* @return string HTML
|
|
*/
|
|
private function formatReferences(
|
|
Parser $parser,
|
|
string $group,
|
|
?string $responsive = null
|
|
): string {
|
|
$responsiveReferences = $this->config->get( 'CiteResponsiveReferences' );
|
|
|
|
return $this->referenceListFormatter->formatReferences(
|
|
$parser,
|
|
$this->referenceStack->popGroup( $group ),
|
|
$responsive !== null ? $responsive !== '0' : $responsiveReferences,
|
|
$this->isSectionPreview
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Called at the end of page processing to append a default references
|
|
* section, if refs were used without a main references tag. If there are references
|
|
* in a custom group, and there is no references tag for it, show an error
|
|
* message for that group.
|
|
* If we are processing a section preview, this adds the missing
|
|
* references tags and does not add the errors.
|
|
*
|
|
* @param Parser $parser
|
|
* @param bool $isSectionPreview
|
|
*
|
|
* @return string HTML
|
|
*/
|
|
public function checkRefsNoReferences( Parser $parser, bool $isSectionPreview ): string {
|
|
$s = '';
|
|
foreach ( $this->referenceStack->getGroups() as $group ) {
|
|
if ( $group === self::DEFAULT_GROUP || $isSectionPreview ) {
|
|
$s .= $this->formatReferences( $parser, $group );
|
|
} else {
|
|
$s .= '<br />' . $this->errorReporter->halfParsed(
|
|
$parser,
|
|
'cite_error_group_refs_without_references',
|
|
Sanitizer::safeEncodeAttribute( $group )
|
|
);
|
|
}
|
|
}
|
|
if ( $isSectionPreview && $s !== '' ) {
|
|
$headerMsg = wfMessage( 'cite_section_preview_references' );
|
|
if ( !$headerMsg->isDisabled() ) {
|
|
$s = Html::element(
|
|
'h2',
|
|
[ 'id' => 'mw-ext-cite-cite_section_preview_references_header' ],
|
|
$headerMsg->text()
|
|
) . $s;
|
|
}
|
|
// provide a preview of references in its own section
|
|
$s = Html::rawElement(
|
|
'div',
|
|
[ 'class' => 'mw-ext-cite-cite_section_preview_references' ],
|
|
$s
|
|
);
|
|
}
|
|
return $s !== '' ? "\n" . $s : '';
|
|
}
|
|
|
|
/**
|
|
* @see https://phabricator.wikimedia.org/T240248
|
|
* @return never
|
|
*/
|
|
public function __clone() {
|
|
throw new LogicException( 'Create a new instance please' );
|
|
}
|
|
|
|
}
|