1<?php 2 3namespace dokuwiki\Parsing\ParserMode; 4 5use dokuwiki\Parsing\Handler; 6use dokuwiki\Parsing\Handler\GfmLists; 7use dokuwiki\Parsing\Handler\Nest; 8use dokuwiki\Parsing\ModeRegistry; 9 10/** 11 * GFM list block. 12 * 13 * Captures an entire list block atomically (one addSpecialPattern match) and 14 * walks the captured text in handle(), grouping lines into items. A pooled 15 * sub-parser is acquired from the ModeRegistry for the duration of the per-item 16 * loop so each item's body is dedented to its content column and parsed by it, 17 * and block content - paragraphs, fenced code, blockquotes, plugin blocks - 18 * works inside items uniformly without depending on column-0 anchoring of 19 * nested mode patterns. If any nested mode requests a sub-parser with the 20 * same exclusion key while ours is in use, the registry's pool hands them a 21 * different slot so their reset() does not corrupt our state. 22 * 23 * Sub-parser mode set: every active mode except CATEGORY_BASEONLY (i.e. no 24 * Header inside list items, since `<h1>`-`<h6>` inside `<li>` is never 25 * desirable and section nesting must not span into items) and gfm_listblock 26 * itself (defensive guard against lexer re-entry on pathological inputs; 27 * normal nested lists are caught by the outer pattern instead). 28 * 29 * Each item's sub-parsed calls are wrapped in a nest instruction (see 30 * Handler\Nest) before they reach the outer handler. The sub-parse has 31 * already run its own finalize - the call-writer rewriters plus the 32 * Block paragraph pass - producing a complete instruction sequence for 33 * the item. The main handler finalizes the whole document afterwards; 34 * wrapping the item in nest keeps it opaque to that second pass (Block 35 * copies a nest call verbatim instead of descending into it), so the 36 * item's block structure is preserved and replayed as-is by the renderer 37 * base class. This is the isolation Footnote has always relied on nest 38 * for, so nested block content is not re-stitched by the outer rewriters. 39 * Nest also merges adjacent cdata and drops stray eol as it buffers, 40 * sealing the item boundary. 41 * 42 * Indentation rule: depth = (indent / 2) + 1. Tabs become two spaces. 1- and 43 * 3-space indents round down. Marker characters: -, *, + (unordered) and 44 * digits followed by . or ) (ordered). Nested lists are caught by the 45 * outer pattern (each marker at any 2-space-multiple indent is its own 46 * item at the corresponding depth) and stitched back into nested HTML by 47 * the GfmLists rewriter. 48 */ 49class GfmListblock extends AbstractMode 50{ 51 /** 52 * Regex fragment matching one list marker. 53 * 54 * Either an unordered marker (`-`, `*`, `+`) or an ordered marker 55 * (1-9 digits followed by `.` or `)`). Used by the entry pattern in 56 * connectTo() and by the per-line classifier in parseItems(). 57 */ 58 protected const MARKER = '(?:[-*+]|\d{1,9}[.)])'; 59 60 /** @inheritdoc */ 61 public function getSort() 62 { 63 return 10; 64 } 65 66 /** @inheritdoc */ 67 public function preConnect() 68 { 69 $this->registry->registerBlockEolMode('gfm_listblock'); 70 } 71 72 /** 73 * Register the special pattern that captures a whole list block. 74 * 75 * The pattern starts on a marker line (any indent) and then loops over 76 * four alternatives until none matches: 77 * 78 * 1. A subsequent marker line at any indent. 79 * 2. An indented continuation line (>= 2 leading spaces with content). 80 * 3. A blank line followed by indented content (any number of 81 * intervening blank lines tolerated via the lookahead). 82 * 4. A blank line followed by a next marker (same multi-blank 83 * tolerance as alt 3). 84 * 85 * The block ends naturally when none of the alternatives match — for 86 * example a column-0 non-marker line, or two-or-more blank lines 87 * followed by non-list content. 88 * 89 * @inheritdoc 90 */ 91 public function connectTo($mode) 92 { 93 $pattern = 94 '\n[ \t]*' . self::MARKER . '(?:[ \t][^\n]*|(?=\n))' . 95 '(?:' . 96 '\n[ \t]*' . self::MARKER . '(?:[ \t][^\n]*|(?=\n))' . 97 '|' . '\n[ \t]{2,}\S[^\n]*' . 98 '|' . '\n[ \t]*(?=(?:\n[ \t]*)*\n[ \t]{2,}\S)' . 99 '|' . '\n[ \t]*(?=(?:\n[ \t]*)*\n[ \t]*' . self::MARKER . ')' . 100 ')*'; 101 $this->Lexer->addSpecialPattern($pattern, $mode, 'gfm_listblock'); 102 } 103 104 /** 105 * Convert the captured block into handler calls. 106 * 107 * Sequence: 108 * 1. parseItems() splits the captured text into per-item records. 109 * 2. Install GfmLists as a CallWriter rewriter on the main handler. 110 * 3. Emit list_open carrying the first item's marker — the rewriter's 111 * handleListOpen opens the `<ul>`/`<ol>` and the first `<li>`. 112 * 4. For each item: 113 * - If not the first, emit list_item (closes the previous `<li>` 114 * and opens a new one in the rewriter). 115 * - Sub-parse the dedented item body via the cached sub-parser. 116 * - Filter document_start/end and the outer p_open/p_close pair 117 * for tight items (single paragraph). 118 * - Wrap the filtered calls in a Nest so the main handler's 119 * Block rewriter treats them as opaque. 120 * 5. Emit list_close and finalise the GfmLists rewriter. 121 * 122 * @inheritdoc 123 */ 124 public function handle($match, $state, $pos, Handler $handler) 125 { 126 $items = $this->parseItems($match); 127 if (empty($items)) { 128 $handler->addCall('cdata', [$match], $pos); 129 return true; 130 } 131 132 $handler->setCallWriter(new GfmLists($handler->getCallWriter())); 133 $handler->addCall('list_open', [$items[0]['markerMatch']], $pos); 134 135 $registry = $this->registry; 136 $excludeCats = [ModeRegistry::CATEGORY_BASEONLY]; 137 $excludeModes = ['gfm_listblock']; 138 $subParser = $registry->acquireSubParser($excludeCats, $excludeModes); 139 $subHandler = $subParser->getHandler(); 140 141 foreach ($items as $i => $item) { 142 $itemPos = $pos + $item['offset']; 143 if ($i > 0) { 144 $handler->addCall('list_item', [$item['markerMatch']], $itemPos); 145 } 146 147 $subHandler->reset(); 148 $subParser->parse($item['body']); 149 $itemCalls = $this->filterSubCalls($subHandler->calls); 150 if (empty($itemCalls)) continue; // empty item — nothing to emit 151 152 // Wrap the item content in a Nest so the outer handler's 153 // finalize (its rewriters and Block pass) does not re-process 154 // this already-finalized sub-tree: Block copies a nest call 155 // verbatim and the renderer base class replays it as-is. 156 $outer = $handler->getCallWriter(); 157 $nest = new Nest($outer); 158 $handler->setCallWriter($nest); 159 foreach ($itemCalls as $call) { 160 // sub-handler positions are relative to the item body; offset 161 // them back into the source so section-edit anchors work. 162 $handler->addCall($call[0], $call[1], $itemPos + $call[2]); 163 } 164 $handler->setCallWriter($nest->process()); 165 } 166 167 $registry->releaseSubParser($excludeCats, $excludeModes); 168 169 $handler->addCall('list_close', [], $pos + strlen($match)); 170 $reWriter = $handler->getCallWriter(); 171 $handler->setCallWriter($reWriter->process()); 172 173 return true; 174 } 175 176 /** 177 * Walk the captured block, grouping lines into items. 178 * 179 * Each returned item describes one list_item: its marker (in the form 180 * "\n{indent}{marker}" so GfmLists::interpretSyntax can parse it), the 181 * dedented body, dedent column, and absolute offset within $match. 182 * 183 * Lines are classified as marker / continuation / blank. A marker line 184 * starts a new item; continuation and blank lines accumulate into the 185 * current item's body. Continuation lines are dedented by up to 186 * indent + marker_width + 1 leading spaces (the item's content column 187 * for single-space-after-marker cases). Blank lines are kept as empty 188 * body lines while they're in the middle of the body and stripped 189 * from the trailing edge by joinBody() so single-paragraph items 190 * parse tight. 191 * 192 * @param string $match the raw special-pattern match (starts with \n) 193 * @return array<int, array{markerMatch: string, dedent: int, body: string, offset: int}> 194 */ 195 protected function parseItems($match) 196 { 197 $stripped = ltrim($match, "\n"); 198 $offsetBase = strlen($match) - strlen($stripped); 199 $lines = explode("\n", $stripped); 200 201 $items = []; 202 $current = null; 203 $bodyLines = []; 204 $cursor = $offsetBase; 205 206 foreach ($lines as $line) { 207 $isMarker = preg_match( 208 '/^([ \t]*)(' . self::MARKER . ')(?:[ \t](.*)|$)/', 209 $line, 210 $m 211 ); 212 213 if ($isMarker) { 214 if ($current !== null) { 215 $current['body'] = $this->joinBody($bodyLines); 216 $items[] = $current; 217 } 218 $indent = str_replace("\t", " ", $m[1]); 219 $marker = $m[2]; 220 $firstLine = $m[3] ?? ''; 221 $current = [ 222 'markerMatch' => "\n" . $indent . $marker, 223 'dedent' => strlen($indent) + strlen($marker) + 1, 224 'offset' => $cursor, 225 ]; 226 $bodyLines = [$firstLine]; 227 } elseif ($current !== null) { 228 if (trim($line) === '') { 229 $bodyLines[] = ''; 230 } else { 231 $expanded = str_replace("\t", " ", $line); 232 $available = strlen($expanded) - strlen(ltrim($expanded, ' ')); 233 $strip = min($current['dedent'], $available); 234 $bodyLines[] = substr($expanded, $strip); 235 } 236 } 237 238 $cursor += strlen($line) + 1; // +1 for the \n consumed by explode 239 } 240 241 if ($current !== null) { 242 $current['body'] = $this->joinBody($bodyLines); 243 $items[] = $current; 244 } 245 246 return $items; 247 } 248 249 /** 250 * Join body lines into a string, trimming trailing blank lines. 251 * 252 * Trailing blanks would reach the sub-parser and cause Block to wrap 253 * the otherwise-single paragraph content in `p_open`/`p_close`, 254 * forcing a tight item into loose-item shape. Stripping them here 255 * preserves the tight rendering for items that look tight in source. 256 * 257 * @param string[] $lines 258 */ 259 protected function joinBody(array $lines): string 260 { 261 return rtrim(implode("\n", $lines), "\n"); 262 } 263 264 /** 265 * Filter the sub-parser's flat call list before nest-wrapping it. 266 * 267 * Drops `document_start` / `document_end` (always emitted by 268 * Handler::finalize), and strips the outer `p_open` / `p_close` pair 269 * for tight items so their content sits inline inside `<li>`. Loose 270 * items (multiple paragraphs, more than one `p_open`) keep their 271 * inner pairs untouched. The filtered calls are then wrapped in a 272 * Nest by handle() before they reach the GfmLists rewriter. 273 * 274 * @param array $calls 275 * @return array 276 */ 277 protected function filterSubCalls(array $calls) 278 { 279 if ($calls && $calls[0][0] === 'document_start') array_shift($calls); 280 if ($calls && end($calls)[0] === 'document_end') array_pop($calls); 281 282 $pCount = 0; 283 foreach ($calls as $c) { 284 if ($c[0] === 'p_open') $pCount++; 285 } 286 287 if ( 288 $pCount === 1 289 && $calls[0][0] === 'p_open' 290 && end($calls)[0] === 'p_close' 291 ) { 292 array_shift($calls); 293 array_pop($calls); 294 } 295 296 return $calls; 297 } 298} 299