Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 96
0.00% covered (danger)
0.00%
0 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
SectionMetadata
0.00% covered (danger)
0.00%
0 / 96
0.00% covered (danger)
0.00%
0 / 10
812
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 setExtensionData
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 appendExtensionData
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getExtensionData
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 fromLegacy
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 toLegacy
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
6
 toJsonArray
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
132
 newFromJsonArray
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
2
 prettyPrint
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
20
 __clone
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2declare( strict_types = 1 );
3
4namespace Wikimedia\Parsoid\Core;
5
6use Wikimedia\JsonCodec\JsonCodecable;
7use Wikimedia\JsonCodec\JsonCodecableTrait;
8use Wikimedia\Parsoid\Utils\CompatJsonCodec;
9use Wikimedia\Parsoid\Utils\Utils;
10
11/**
12 * Section metadata for generating TOC.
13 *
14 * This is not the complete data for the article section, just the
15 * information needed to generate the table of contents.
16 *
17 * For now, this schema matches whatever is generated by Parser.php.
18 * Parsoid will attempt to match this output for now.
19 *
20 * Parser.php::finalizeHeadings() is the authoritative source for how
21 * some of these properties are computed right now, especially for the
22 * $line, $anchor, and $linkAnchor properties below.
23 *
24 * Linker.php::tocLine() and ::makeHeadline() demonstrate how these
25 * properties are used to create headings and table of contents lines.
26 */
27class SectionMetadata implements JsonCodecable {
28    use JsonCodecableTrait;
29
30    /**
31     * The heading tag level: a 1 here means an `<H1>` tag was used, a
32     * 2 means an `<H2>` tag was used, etc.
33     */
34    public int $hLevel;
35
36    /**
37     * This is a one-indexed TOC level and the nesting level.
38     * If a page has a H2-H4-H6 then those `hLevel`s 2,4,6
39     * correspond to `tocLevel`s 1,2,3.
40     */
41    public int $tocLevel;
42
43    /**
44     * HTML heading of the section. Only a narrow set of HTML tags are allowed here.
45     *
46     * This starts with the parsed headline seen in wikitext and
47     * - replaces links with link text
48     * - processes extension strip markers
49     * - removes style, script tags
50     * - strips all HTML tags except the following tags (from Parser.php)
51     *   . `<sup>` and `<sub>` (T10393)
52     *   . `<i>` (T28375)
53     *   . `<b>` (r105284)
54     *   . `<bdi>` (T74884)
55     *   . `<span dir="rtl">` and `<span dir="ltr">` (T37167)
56     *   . `<s>` and `<strike>` (T35715)
57     *   . `<q>` (T251672)
58     *   We strip any attribute from accepted tags, except `dir="rtl|ltr"`
59     *   from `<span>` to allow setting directionality in toc items.
60     *
61     * @note Parsoid creates this in the default/mixed variant and
62     * converts into the proper user language variant during postprocessing.
63     */
64    public string $line;
65
66    /**
67     * TOC number string (`3.1.3`, `4.5.2`, etc.)
68     *
69     * @note In the legacy parser, this is localized from creation, while in
70     * Parsoid this is initially a sequence of dot-separated ascii digits and
71     * is localized into the parser target language during postprocessing.
72     */
73    public string $number;
74
75    /**
76     * Section id (integer, assigned in depth first traversal order)
77     * Template-generated sections get a "T-" prefix.
78     */
79    public string $index;
80
81    /**
82     * The title of the page that generated this heading.
83     * For template-generated sections, this will be the template title.
84     * This string is in "prefixed DB key" format, which means spaces
85     * will be replaced by underscores in the title.
86     */
87    public ?string $fromTitle;
88
89    /**
90     * Codepoint offset where the section shows up in wikitext; this is null
91     * if this section comes from a template, if it comes from a literal
92     * HTML `<h_>` tag in the wikitext, or otherwise doesn't correspond to a
93     * "preprocessor section".
94     * @note This is measured in codepoints, not bytes; you should use
95     * appropriate multi-byte aware string functions, *not* `substr()`.
96     * Similarly, in JavaScript, be careful not to confuse JavaScript
97     * UCS-2 "characters" with codepoints.
98     */
99    public ?int $codepointOffset;
100
101    /**
102     * Anchor attribute.
103     *
104     * This property is the "true" value of the ID attribute, and should be
105     * used when looking up a heading or setting an attribute, for example
106     * using `Document.getElementById()` or `Element.setAttribute('id',...)`.
107     *
108     * This value is *not* HTML-entity escaped; if you are writing HTML
109     * as a literal string, you should still entity-escape ampersands and
110     * single/double quotes as appropriate.
111     *
112     * This value is *not* URL-escaped either; instead use the `linkAnchor`
113     * property if you are constructing a URL to target this section.
114     *
115     * The anchor attribute is based on the $line property, but does extra
116     * processing to turn it into a valid attribute:
117     * - strip all HTML tags,
118     * - normalizes section name
119     * - normalizes section name whitespace
120     * - decodes char references
121     * - makes it a valid HTML id attribute value
122     *   (HTML5 / HTML4 based on `$wgFragmentMode` property)
123     * - dedupes (case-insensitively) identical anchors by adding "_$n" suffixes
124     */
125    public string $anchor;
126
127    /**
128     * Anchor URL fragment.
129     *
130     * This is very similar to the $anchor property, but is appropriately
131     * URL-escaped to make it appropriate to use in constructing a URL
132     * fragment link.  You should almost always prepend a `#` symbol
133     * to `linkAnchor` if you are using it correctly.  You are still
134     * responsible for HTML-escaping the resulting URL if you are emitting
135     * this as an HTML attribute.
136     */
137    public string $linkAnchor;
138
139    /**
140     * Arbitrary data attached to this section by extensions.  This
141     * data will be stored and cached in the ParserOutput object along
142     * with the rest of the section data, and made available to external
143     * clients via the action API.
144     *
145     * This method is provided to overcome the unsafe practice of attaching
146     * extra information to a section by directly assigning member variables.
147     *
148     * See ParserOutput::setExtensionData() for more information on typical
149     * use.
150     */
151    private array $extensionData;
152
153    /**
154     * @param int $tocLevel One-indexed TOC level and the nesting level
155     * @param int $hLevel The heading tag level
156     * @param string $line Stripped headline text
157     * @param string $number TOC number string (3.1.3, 4.5.2, etc)
158     * @param string $index Section id
159     * @param ?string $fromTitle The title of the page or template that
160     *   generated this heading, or null.
161     * @param ?int $codepointOffset Codepoint offset (# of characters) where the
162     *   section shows up in wikitext, or null if this doesn't correspond to
163     *   a "preprocesor section".  (Be careful if using JavaScript, as
164     *   JavaScript "characters" are UCS-2 encoded and don't correspond
165     *   directly to code points.)
166     * @param string $anchor "True" value of the ID attribute
167     * @param string $linkAnchor URL-escaped value of the anchor, for use in
168     *   constructing a URL fragment link
169     * @param ?array $extensionData Extension data passed in as an associative array
170     */
171    public function __construct(
172        // This is a great candidate for named arguments in PHP 8.0+
173        int $tocLevel = 0,
174        int $hLevel = -1,
175        string $line = '',
176        string $number = '',
177        string $index = '',
178        ?string $fromTitle = null,
179        ?int $codepointOffset = null,
180        string $anchor = '',
181        string $linkAnchor = '',
182        ?array $extensionData = null
183    ) {
184        $this->tocLevel = $tocLevel;
185        $this->line = $line;
186        $this->hLevel = $hLevel;
187        $this->number = $number;
188        $this->index = $index;
189        $this->fromTitle = $fromTitle;
190        $this->codepointOffset = $codepointOffset;
191        $this->anchor = $anchor;
192        $this->linkAnchor = $linkAnchor;
193        $this->extensionData = $extensionData ?? [];
194    }
195
196    /**
197     * Attaches arbitrary data to this SectionMetadata object. This
198     * can be used to store some information about this section in the
199     * ParserOutput object for later use during page output. The data
200     * will be cached along with the ParserOutput object.
201     *
202     * This method is provided to overcome the unsafe practice of
203     * attaching extra information to a section by directly assigning
204     * member variables.
205     *
206     * See ParserOutput::setExtensionData() in core for further information
207     * about typical usage in hooks.
208     *
209     * Setting conflicting values for the same key is not allowed.
210     * If you call ::setExtensionData() multiple times with the same key
211     * on a SectionMetadata, is is expected that the value will be identical
212     * each time.  If you want to collect multiple pieces of data under a
213     * single key, use ::appendExtensionData().
214     *
215     * @note Only scalars (numbers, strings, or arrays) or
216     * `JsonCodecable` objects are supported for `$value`. Attempts to set
217     * other types as extension data values will break ParserCache for the
218     * page.  Object values should support the built-in PHP `clone`
219     * operator.
220     *
221     * @param string $key The key for accessing the data. Extensions
222     *   should take care to avoid conflicts in naming keys. It is
223     *   suggested to use the extension's name as a prefix.  Using
224     *   the prefix `mw:` is reserved for core.
225     *
226     * @param mixed $value The value to set.
227     *   Setting a value to null is equivalent to removing the value.
228     */
229    public function setExtensionData( string $key, $value ): void {
230        if (
231            array_key_exists( $key, $this->extensionData ) &&
232            $this->extensionData[$key] !== $value
233        ) {
234            throw new \InvalidArgumentException( "Conflicting data for $key" );
235        }
236        if ( $value === null ) {
237            unset( $this->extensionData[$key] );
238        } else {
239            $this->extensionData[$key] = $value;
240        }
241    }
242
243    /**
244     * Appends arbitrary data to this SectionMetadata. This can be used
245     * to store some information about the section in the ParserOutput object for later
246     * use during page output.
247     *
248     * See ::setExtensionData() for more details on rationale and use.
249     *
250     * @param string $key The key for accessing the data. Extensions should take care to avoid
251     *   conflicts in naming keys. It is suggested to use the extension's name as a prefix.
252     *
253     * @param int|string $value The value to append to the list.
254     * @return never This method is not yet implemented.
255     */
256    public function appendExtensionData( string $key, $value ): void {
257        // This implementation would mirror that of
258        // ParserOutput::appendExtensionData, but let's defer implementing
259        // this until we're sure we need it.  In particular, we might need
260        // to figure out how a merge on section data is expected to work
261        // before we can determine the right semantics for this.
262        throw new \InvalidArgumentException( "Not yet implemented" );
263    }
264
265    /**
266     * Gets extension data previously attached to this SectionMetadata.
267     *
268     * @param string $key The key to look up
269     * @return mixed|null The value(s) previously set for the given key using
270     *   ::setExtensionData() or ::appendExtensionData(), or null if no
271     *  value was set for this key.
272     */
273    public function getExtensionData( $key ) {
274        $value = $this->extensionData[$key] ?? null;
275        return $value;
276    }
277
278    /**
279     * Create a new SectionMetadata object from an array in the legacy
280     * format returned by the action API.
281     *
282     * This is useful for backward-compatibility, but is expected to
283     * be replaced by conversion to/from JSON in the future.
284     *
285     * @param array $data Associative array with section metadata
286     * @return SectionMetadata
287     */
288    public static function fromLegacy( array $data ): SectionMetadata {
289        return new SectionMetadata(
290            $data['toclevel'] ?? 0,
291            (int)( $data['level'] ?? -1 ),
292            $data['line'] ?? '',
293            $data['number'] ?? '',
294            $data['index'] ?? '',
295            ( $data['fromtitle'] ?? false ) ?: null,
296            $data['byteoffset'] ?? null, // T319141: actually "codepoint offset"
297            $data['anchor'] ?? '',
298            $data['linkAnchor'] ?? $data['anchor'] ?? '',
299            $data['extensionData'] ?? null
300        );
301    }
302
303    /**
304     * Return as associative array, in the format returned by the
305     * action API (including the order of fields and the value types).
306     *
307     * This is helpful as b/c support while we transition to objects.
308     *
309     * @phpcs:ignore Generic.Files.LineLength.TooLong
310     * @return array{toclevel: int, level: numeric-string, line: string, number: string, index: string, fromtitle: false|string, byteoffset: ?int, anchor: string, linkAnchor: string, extensionData?: array}
311     */
312    public function toLegacy(): array {
313        $ret = [
314            'toclevel' => $this->tocLevel,
315            // cast $level to string in order to keep b/c for the parse api
316            'level' => (string)$this->hLevel,
317            'line' => $this->line,
318            'number' => $this->number,
319            'index' => $this->index,
320            'fromtitle' => $this->fromTitle ?? false,
321             // T319141: legacy 'byteoffset' is actually "codepoint offset"
322            'byteoffset' => $this->codepointOffset,
323            'anchor' => $this->anchor,
324            'linkAnchor' => $this->linkAnchor,
325        ];
326        // Micro-opt: Output 'extensionData' conditionally to avoid bloat
327        if ( $this->extensionData ) {
328            $ret['extensionData'] = $this->extensionData;
329        }
330        return $ret;
331    }
332
333    // JsonCodecable interface
334
335    /** @inheritDoc */
336    public function toJsonArray(): array {
337        $ret = [];
338        if ( $this->tocLevel !== 0 ) {
339            $ret['tocLevel'] = $this->tocLevel;
340        }
341        if ( $this->hLevel !== -1 ) {
342            $ret['hLevel'] = $this->hLevel;
343        }
344        if ( $this->line !== '' ) {
345            $ret['line'] = $this->line;
346        }
347        if ( $this->number !== '' ) {
348            $ret['number'] = $this->number;
349        }
350        if ( $this->index !== '' ) {
351            $ret['index'] = $this->index;
352        }
353        if ( $this->fromTitle !== null ) {
354            $ret['fromTitle'] = $this->fromTitle;
355        }
356        if ( $this->codepointOffset !== null ) {
357            $ret['codepointOffset'] = $this->codepointOffset;
358        }
359        if ( $this->anchor !== '' ) {
360            $ret['anchor'] = $this->anchor;
361        }
362        if ( $this->linkAnchor !== $this->anchor ) {
363            $ret['linkAnchor'] = $this->linkAnchor;
364        }
365        if ( $this->extensionData ) {
366            $ret['extensionData'] = $this->extensionData;
367        }
368        return $ret;
369    }
370
371    /** @inheritDoc */
372    public static function newFromJsonArray( array $json ) {
373        return new SectionMetadata(
374            $json['tocLevel'] ?? 0,
375            $json['hLevel'] ?? -1,
376            $json['line'] ?? '',
377            $json['number'] ?? '',
378            $json['index'] ?? '',
379            $json['fromTitle'] ?? null,
380            $json['codepointOffset'] ?? null,
381            $json['anchor'] ?? '',
382            $json['linkAnchor'] ?? $json['anchor'] ?? '',
383            $json['extensionData'] ?? null
384        );
385    }
386
387    // Pretty-printing
388
389    /**
390     * For use in parser tests and wherever else humans might appreciate
391     * some formatting in the JSON encoded output. For now, nothing special.
392     * @param int $indent Additional indentation to apply (defaults to zero)
393     * @return string
394     */
395    public function prettyPrint( int $indent = 0 ): string {
396        # Basic info
397        $buf = str_repeat( ' ', $indent + $this->tocLevel ) . "h{$this->hLevel}";
398        $buf .= " index:{$this->index} toclevel:$this->tocLevel number:{$this->number}";
399
400        # Optional information
401        $title = $this->fromTitle ?? "NULL";
402        $offset = $this->codepointOffset ?? "NULL";
403        $buf .= " title:{$title} off:{$offset}";
404
405        # Anchors & link text
406        if ( $this->anchor === $this->linkAnchor ) {
407            $buf .= " anchor/linkAnchor:{$this->anchor}";
408        } else {
409            $buf .= " anchor:{$this->anchor} linkAnchor:{$this->linkAnchor}";
410        }
411        $line = $this->line;
412        if ( str_contains( $line, "\n" ) ) {
413            // Handle cases where $line has "funny" characters
414            $line = json_encode( $line );
415        }
416        $buf .= " line:{$line}";
417
418        # Extension data
419        if ( $this->extensionData ) {
420            $codec = new CompatJsonCodec();
421            $buf .= " ext:" . json_encode( $codec->toJsonArray( $this->extensionData ) );
422        }
423
424        return $buf;
425    }
426
427    public function __clone() {
428        $this->extensionData = Utils::cloneArray( $this->extensionData );
429    }
430}