Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.91% covered (success)
90.91%
70 / 77
72.73% covered (warning)
72.73%
8 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
Jetpack_Sitemap_Buffer_XMLWriter
93.33% covered (success)
93.33%
70 / 75
72.73% covered (warning)
72.73%
8 / 11
25.19
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
1
 initialize_buffer
n/a
0 / 0
n/a
0 / 0
0
 start_root
n/a
0 / 0
n/a
0 / 0
0
 ensure_root_started
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 finalize_writer_output
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 append
84.21% covered (warning)
84.21%
16 / 19
0.00% covered (danger)
0.00%
0 / 1
8.25
 append_item
n/a
0 / 0
n/a
0 / 0
0
 array_to_xml
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 contents
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 is_full
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_empty
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 view_time
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 last_modified
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_document
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
2.01
1<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
2/**
3 * XMLWriter implementation of the sitemap buffer.
4 *
5 * @since 14.6
6 * @package automattic/jetpack
7 */
8
9if ( ! defined( 'ABSPATH' ) ) {
10    exit( 0 );
11}
12
13/**
14 * A buffer for constructing sitemap xml files using XMLWriter.
15 *
16 * @since 14.6
17 */
18abstract class Jetpack_Sitemap_Buffer_XMLWriter {
19
20    /**
21     * Largest number of items the buffer can hold.
22     *
23     * @access protected
24     * @since 14.6
25     * @var int $item_capacity The item capacity.
26     */
27    protected $item_capacity;
28
29    /**
30     * Largest number of bytes the buffer can hold.
31     *
32     * @access protected
33     * @since 14.6
34     * @var int $byte_capacity The byte capacity.
35     */
36    protected $byte_capacity;
37
38    /**
39     * Flag which detects when the buffer is full.
40     *
41     * @access protected
42     * @since 14.6
43     * @var bool $is_full_flag The flag value.
44     */
45    protected $is_full_flag;
46
47    /**
48     * Flag which detects when the buffer is empty.
49     * Set true on construction and flipped to false only after a successful append.
50     *
51     * @since 15.0
52     * @var bool
53     */
54    protected $is_empty_flag = true;
55
56    /**
57     * The most recent timestamp seen by the buffer.
58     *
59     * @access protected
60     * @since 14.6
61     * @var string $timestamp Must be in 'YYYY-MM-DD hh:mm:ss' format.
62     */
63    protected $timestamp;
64
65    /**
66     * The XMLWriter instance used to construct the XML.
67     *
68     * @access protected
69     * @since 14.6
70     * @var XMLWriter $writer
71     */
72    protected $writer;
73
74    /**
75     * Helper class to construct sitemap paths.
76     *
77     * @since 14.6
78     * @protected
79     * @var Jetpack_Sitemap_Finder
80     */
81    protected $finder;
82
83    /**
84     * The XML content chunks collected from XMLWriter.
85     *
86     * Collect chunks and join once at the end to reduce string reallocations
87     * and improve performance on large sitemaps.
88     *
89     * @access protected
90     * @since 15.0
91     * @var array $chunks
92     */
93    protected $chunks = array();
94
95    /**
96     * Tracks whether the root element has been started.
97     *
98     * @since 15.0
99     * @var bool
100     */
101    protected $root_started = false;
102
103    /**
104     * Mirror DOMDocument built on-demand for jetpack_print_sitemap compatibility.
105     *
106     * @since 15.0
107     * @var DOMDocument|null
108     */
109    protected $dom_document = null;
110
111    /**
112     * Tracks whether XMLWriter document has been finalized (closed and flushed).
113     *
114     * @since 15.0
115     * @var bool
116     */
117    protected $is_finalized = false;
118
119    /**
120     * Construct a new Jetpack_Sitemap_Buffer_XMLWriter.
121     *
122     * @since 14.6
123     *
124     * @param int    $item_limit The maximum size of the buffer in items.
125     * @param int    $byte_limit The maximum size of the buffer in bytes.
126     * @param string $time The initial datetime of the buffer. Must be in 'YYYY-MM-DD hh:mm:ss' format.
127     */
128    public function __construct( $item_limit, $byte_limit, $time ) {
129        $this->is_full_flag  = false;
130        $this->is_empty_flag = true;
131        $this->timestamp     = $time;
132        $this->finder        = new Jetpack_Sitemap_Finder();
133
134        $this->writer = new XMLWriter();
135        $this->writer->openMemory();
136        $this->writer->setIndent( true );
137        $this->writer->startDocument( '1.0', 'UTF-8' );
138
139        $this->item_capacity = max( 1, (int) $item_limit );
140        $this->byte_capacity = max( 1, (int) $byte_limit );
141
142        // Capture and account the XML declaration bytes to mirror DOM behavior.
143        $declaration          = $this->writer->outputMemory( true );
144        $this->chunks[]       = $declaration;
145        $this->byte_capacity -= strlen( $declaration );
146
147        // Allow subclasses to write comments and processing instructions only.
148        $this->initialize_buffer();
149
150        // Capture pre-root bytes (comments/PI). Do not subtract from capacity.
151        $pre_root_output = $this->writer->outputMemory( true );
152        $this->chunks[]  = $pre_root_output;
153    }
154
155    /**
156     * Initialize the buffer with any required headers or setup.
157     * This should be implemented by child classes.
158     *
159     * @access protected
160     * @since 14.6
161     */
162    abstract protected function initialize_buffer();
163
164    /**
165     * Start the root element (e.g., urlset or sitemapindex) and write its attributes.
166     * Implemented by subclasses.
167     *
168     * @since 15.0
169     * @access protected
170     * @return void
171     */
172    abstract protected function start_root();
173
174    /**
175     * Ensure the root element has been started and account its bytes once.
176     *
177     * @since 15.0
178     * @access protected
179     * @return void
180     */
181    protected function ensure_root_started() {
182        if ( $this->root_started ) {
183            return;
184        }
185        $this->start_root();
186        $root_chunk           = $this->writer->outputMemory( true );
187        $this->chunks[]       = $root_chunk;
188        $this->byte_capacity -= strlen( $root_chunk );
189        $this->root_started   = true;
190    }
191
192    /**
193     * Finalize writer output once by closing the root and document and flushing.
194     *
195     * @since 15.0
196     * @access protected
197     * @return void
198     */
199    protected function finalize_writer_output() {
200        if ( $this->is_finalized ) {
201            return;
202        }
203        $this->ensure_root_started();
204        $this->writer->endElement(); // End root element (urlset/sitemapindex)
205        $this->writer->endDocument();
206        $final_content      = $this->writer->outputMemory( true );
207        $this->chunks[]     = $final_content;
208        $this->is_finalized = true;
209    }
210
211    /**
212     * Append an item to the buffer.
213     *
214     * @since 14.6
215     *
216     * @param array $array The item to be added.
217     * @return bool True if the append succeeded, False if not.
218     */
219    public function append( $array ) {
220        if ( $array === null ) {
221            return true;
222        }
223
224        if ( $this->is_full_flag ) {
225            return false;
226        }
227
228        if ( 0 >= $this->item_capacity || 0 >= $this->byte_capacity ) {
229            $this->is_full_flag = true;
230            return false;
231        }
232
233        // Ensure root is started on first append and account its bytes.
234        $this->ensure_root_started();
235
236        // Attempt to render the item. Subclasses may decide to skip writing
237        // if the input structure is invalid for that sitemap type.
238        $this->append_item( $array );
239
240        // Capture only the bytes produced by this item.
241        $new_content = $this->writer->outputMemory( true );
242
243        // If nothing was written, treat as a no-op: keep the buffer "empty"
244        // and do not consume item/byte capacities.
245        if ( '' === $new_content ) {
246            return true;
247        }
248
249        // Persist newly written bytes and update capacities.
250        $this->chunks[]       = $new_content;
251        $this->item_capacity -= 1;
252        $this->byte_capacity -= strlen( $new_content );
253        $this->is_empty_flag  = false;
254
255        // Check both capacity limits.
256        if ( 0 >= $this->item_capacity || $this->byte_capacity <= 0 ) {
257            $this->is_full_flag = true;
258        }
259
260        return true;
261    }
262
263    /**
264     * Append a specific item to the buffer.
265     * This should be implemented by child classes.
266     *
267     * @access protected
268     * @since 14.6
269     * @param array $array The item to be added.
270     */
271    abstract protected function append_item( $array );
272
273    /**
274     * Recursively writes XML elements from an associative array.
275     *
276     * This method iterates through an array and writes XML elements using the XMLWriter instance.
277     * If a value in the array is itself an array, it calls itself recursively.
278     *
279     * @access protected
280     * @since 15.0
281     *
282     * @param array $data The array to convert to XML.
283     */
284    protected function array_to_xml( $data ) {
285        foreach ( (array) $data as $tag => $value ) {
286            if ( is_array( $value ) ) {
287                $this->writer->startElement( $tag );
288                $this->array_to_xml( $value );
289                $this->writer->endElement();
290            } else {
291                // Write raw text; XMLWriter will escape XML-reserved chars, matching DOMDocument behavior.
292                $this->writer->writeElement( $tag, (string) $value );
293            }
294        }
295    }
296
297    /**
298     * Retrieve the contents of the buffer.
299     *
300     * @since 14.6
301     * @return string The contents of the buffer.
302     */
303    public function contents() {
304        $this->finalize_writer_output();
305        if ( $this->dom_document instanceof DOMDocument ) {
306            return $this->dom_document->saveXML();
307        }
308        if ( empty( $this->chunks ) ) {
309            // If buffer is empty, return a minimal valid XML structure
310            return "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\"></urlset>";
311        }
312        return implode( '', $this->chunks );
313    }
314
315    /**
316     * Detect whether the buffer is full.
317     *
318     * @since 14.6
319     * @return bool True if the buffer is full, false otherwise.
320     */
321    public function is_full() {
322        return $this->is_full_flag;
323    }
324
325    /**
326     * Detect whether the buffer is empty.
327     *
328     * @since 14.6
329     * @return bool True if the buffer is empty, false otherwise.
330     */
331    public function is_empty() {
332        return $this->is_empty_flag;
333    }
334
335    /**
336     * Update the timestamp of the buffer.
337     *
338     * @since 14.6
339     * @param string $new_time A datetime string in 'YYYY-MM-DD hh:mm:ss' format.
340     */
341    public function view_time( $new_time ) {
342        $this->timestamp = max( $this->timestamp, $new_time );
343    }
344
345    /**
346     * Retrieve the timestamp of the buffer.
347     *
348     * @since 14.6
349     * @return string A datetime string in 'YYYY-MM-DD hh:mm:ss' format.
350     */
351    public function last_modified() {
352        return $this->timestamp;
353    }
354
355    /**
356     * Compatibility method for the old DOMDocument implementation.
357     * This is only here to satisfy the jetpack_print_sitemap filter.
358     *
359     * @since 14.6
360     * @return DOMDocument DOM representation of the current sitemap contents.
361     */
362    public function get_document() {
363        if ( $this->dom_document instanceof DOMDocument ) {
364            return $this->dom_document;
365        }
366
367        $this->finalize_writer_output();
368
369        $dom                     = new DOMDocument( '1.0', 'UTF-8' );
370        $dom->formatOutput       = true; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
371        $dom->preserveWhiteSpace = false; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
372        // Load current XML content into DOM for compatibility with filters.
373        @$dom->loadXML( implode( '', $this->chunks ) ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Avoid fatal on unexpected content
374
375        $this->dom_document = $dom;
376        return $this->dom_document;
377    }
378}