1<?php 2 3namespace dokuwiki\test\Parsing\ParserMode; 4 5use dokuwiki\Parsing\ModeRegistry; 6use dokuwiki\Parsing\ParserMode\GfmLink; 7use dokuwiki\Parsing\ParserMode\Internallink; 8 9/** 10 * Tests for GFM inline links `[text](url)` dispatching to DokuWiki's 11 * internal / external / interwiki / email / windowsshare / local link 12 * handler instructions. 13 */ 14class GfmLinkTest extends ParserTestBase 15{ 16 public function setUp(): void 17 { 18 parent::setUp(); 19 global $conf; 20 $conf['syntax'] = 'md'; 21 ModeRegistry::reset(); 22 } 23 24 public function tearDown(): void 25 { 26 ModeRegistry::reset(); 27 parent::tearDown(); 28 } 29 30 function testInternalPage() 31 { 32 $this->P->addMode('gfm_link', new GfmLink()); 33 $this->P->parse('Foo [text](page) Bar'); 34 $calls = [ 35 ['document_start', []], 36 ['p_open', []], 37 ['cdata', ["\nFoo "]], 38 ['internallink', ['page', 'text']], 39 ['cdata', [' Bar']], 40 ['p_close', []], 41 ['document_end', []], 42 ]; 43 $this->assertCalls($calls, $this->H->calls); 44 } 45 46 function testInternalPageWithNamespace() 47 { 48 $this->P->addMode('gfm_link', new GfmLink()); 49 $this->P->parse('Foo [Syntax](wiki:syntax#internal) Bar'); 50 $calls = [ 51 ['document_start', []], 52 ['p_open', []], 53 ['cdata', ["\nFoo "]], 54 ['internallink', ['wiki:syntax#internal', 'Syntax']], 55 ['cdata', [' Bar']], 56 ['p_close', []], 57 ['document_end', []], 58 ]; 59 $this->assertCalls($calls, $this->H->calls); 60 } 61 62 function testExternalLink() 63 { 64 $this->P->addMode('gfm_link', new GfmLink()); 65 $this->P->parse('Foo [Google](http://google.com) Bar'); 66 $calls = [ 67 ['document_start', []], 68 ['p_open', []], 69 ['cdata', ["\nFoo "]], 70 ['externallink', ['http://google.com', 'Google']], 71 ['cdata', [' Bar']], 72 ['p_close', []], 73 ['document_end', []], 74 ]; 75 $this->assertCalls($calls, $this->H->calls); 76 } 77 78 function testInterwikiLink() 79 { 80 $this->P->addMode('gfm_link', new GfmLink()); 81 $this->P->parse('Foo [callbacks](wp>Callback) Bar'); 82 $calls = [ 83 ['document_start', []], 84 ['p_open', []], 85 ['cdata', ["\nFoo "]], 86 ['interwikilink', ['wp>Callback', 'callbacks', 'wp', 'Callback']], 87 ['cdata', [' Bar']], 88 ['p_close', []], 89 ['document_end', []], 90 ]; 91 $this->assertCalls($calls, $this->H->calls); 92 } 93 94 function testInterwikiLinkCaseNormalized() 95 { 96 $this->P->addMode('gfm_link', new GfmLink()); 97 $this->P->parse('Foo [Page](IW>somepage) Bar'); 98 $calls = [ 99 ['document_start', []], 100 ['p_open', []], 101 ['cdata', ["\nFoo "]], 102 ['interwikilink', ['IW>somepage', 'Page', 'iw', 'somepage']], 103 ['cdata', [' Bar']], 104 ['p_close', []], 105 ['document_end', []], 106 ]; 107 $this->assertCalls($calls, $this->H->calls); 108 } 109 110 function testEmailLink() 111 { 112 $this->P->addMode('gfm_link', new GfmLink()); 113 $this->P->parse('Foo [mail](user@example.com) Bar'); 114 $calls = [ 115 ['document_start', []], 116 ['p_open', []], 117 ['cdata', ["\nFoo "]], 118 ['emaillink', ['user@example.com', 'mail']], 119 ['cdata', [' Bar']], 120 ['p_close', []], 121 ['document_end', []], 122 ]; 123 $this->assertCalls($calls, $this->H->calls); 124 } 125 126 function testLocalAnchor() 127 { 128 $this->P->addMode('gfm_link', new GfmLink()); 129 $this->P->parse('Foo [section](#anchor) Bar'); 130 $calls = [ 131 ['document_start', []], 132 ['p_open', []], 133 ['cdata', ["\nFoo "]], 134 ['locallink', ['anchor', 'section']], 135 ['cdata', [' Bar']], 136 ['p_close', []], 137 ['document_end', []], 138 ]; 139 $this->assertCalls($calls, $this->H->calls); 140 } 141 142 function testWindowsShare() 143 { 144 $this->P->addMode('gfm_link', new GfmLink()); 145 $this->P->parse('Foo [share](\\\\server\\share) Bar'); 146 $calls = [ 147 ['document_start', []], 148 ['p_open', []], 149 ['cdata', ["\nFoo "]], 150 ['windowssharelink', ['\\\\server\\share', 'share']], 151 ['cdata', [' Bar']], 152 ['p_close', []], 153 ['document_end', []], 154 ]; 155 $this->assertCalls($calls, $this->H->calls); 156 } 157 158 function testTitleInDoubleQuotesIsDiscarded() 159 { 160 // GFM allows [text](url "title") but DokuWiki's link handler 161 // instructions have no title-attribute slot. The title parses 162 // cleanly but is dropped; the resulting handler call is identical 163 // to the no-title case. 164 $this->P->addMode('gfm_link', new GfmLink()); 165 $this->P->parse('Foo [Google](http://google.com "Search engine") Bar'); 166 $calls = [ 167 ['document_start', []], 168 ['p_open', []], 169 ['cdata', ["\nFoo "]], 170 ['externallink', ['http://google.com', 'Google']], 171 ['cdata', [' Bar']], 172 ['p_close', []], 173 ['document_end', []], 174 ]; 175 $this->assertCalls($calls, $this->H->calls); 176 } 177 178 function testTitleInSingleQuotesIsDiscarded() 179 { 180 $this->P->addMode('gfm_link', new GfmLink()); 181 $this->P->parse("Foo [page](target 'a title') Bar"); 182 $calls = [ 183 ['document_start', []], 184 ['p_open', []], 185 ['cdata', ["\nFoo "]], 186 ['internallink', ['target', 'page']], 187 ['cdata', [' Bar']], 188 ['p_close', []], 189 ['document_end', []], 190 ]; 191 $this->assertCalls($calls, $this->H->calls); 192 } 193 194 function testSpaceBetweenBracketsAndParensIsNotALink() 195 { 196 // GFM explicitly forbids whitespace between `]` and `(`. 197 $this->P->addMode('gfm_link', new GfmLink()); 198 $this->P->parse('[foo] (bar)'); 199 $modes = array_column($this->H->calls, 0); 200 $this->assertNotContains('internallink', $modes); 201 $this->assertNotContains('externallink', $modes); 202 } 203 204 function testDwDoubleBracketNotConsumedByGfmLink() 205 { 206 // With both gfm_link and DW internallink loaded (mixed syntax), 207 // `[[foo]]` must go to Internallink. GfmLink's `\[(?!\[)` guard 208 // refuses single-bracket matches that are actually part of `[[`. 209 $this->P->addMode('gfm_link', new GfmLink()); 210 $this->P->addMode('internallink', new Internallink()); 211 $this->P->parse('Foo [[bar]] Baz'); 212 $calls = [ 213 ['document_start', []], 214 ['p_open', []], 215 ['cdata', ["\nFoo "]], 216 ['internallink', ['bar', null]], 217 ['cdata', [' Baz']], 218 ['p_close', []], 219 ['document_end', []], 220 ]; 221 $this->assertCalls($calls, $this->H->calls); 222 } 223 224 function testMultibyteLinkText() 225 { 226 $this->P->addMode('gfm_link', new GfmLink()); 227 $this->P->parse('Foo [日本語](page) Bar'); 228 $calls = [ 229 ['document_start', []], 230 ['p_open', []], 231 ['cdata', ["\nFoo "]], 232 ['internallink', ['page', '日本語']], 233 ['cdata', [' Bar']], 234 ['p_close', []], 235 ['document_end', []], 236 ]; 237 $this->assertCalls($calls, $this->H->calls); 238 } 239 240 function testReferenceStyleLinkNotMatched() 241 { 242 // `[foo][bar]` (reference-style) requires a reference definition 243 // we do not support; each `[...]` should stay literal text. 244 $this->P->addMode('gfm_link', new GfmLink()); 245 $this->P->parse('[foo][bar]'); 246 $modes = array_column($this->H->calls, 0); 247 $this->assertNotContains('internallink', $modes); 248 $this->assertNotContains('externallink', $modes); 249 } 250 251 function testTwoLinksInOneLine() 252 { 253 $this->P->addMode('gfm_link', new GfmLink()); 254 $this->P->parse('Foo [one](a) and [two](b) Bar'); 255 $calls = [ 256 ['document_start', []], 257 ['p_open', []], 258 ['cdata', ["\nFoo "]], 259 ['internallink', ['a', 'one']], 260 ['cdata', [' and ']], 261 ['internallink', ['b', 'two']], 262 ['cdata', [' Bar']], 263 ['p_close', []], 264 ['document_end', []], 265 ]; 266 $this->assertCalls($calls, $this->H->calls); 267 } 268 269 function testFragmentInExternalUrl() 270 { 271 $this->P->addMode('gfm_link', new GfmLink()); 272 $this->P->parse('Foo [x](http://example.com#fragment) Bar'); 273 $calls = [ 274 ['document_start', []], 275 ['p_open', []], 276 ['cdata', ["\nFoo "]], 277 ['externallink', ['http://example.com#fragment', 'x']], 278 ['cdata', [' Bar']], 279 ['p_close', []], 280 ['document_end', []], 281 ]; 282 $this->assertCalls($calls, $this->H->calls); 283 } 284 285 // ----- image-as-label (`[](target)`) ----- 286 287 /** 288 * Media descriptor shape GfmLink emits for image-as-label, matching 289 * what Media::parseMedia() returns. 290 */ 291 private function mediaArray(array $overrides): array 292 { 293 return array_merge([ 294 'type' => 'internalmedia', 295 'src' => 'wiki:image.png', 296 'title' => 'alt', 297 'align' => null, 298 'width' => null, 299 'height' => null, 300 'cache' => 'cache', 301 'linking' => 'details', 302 ], $overrides); 303 } 304 305 function testImageAsLabelInternalPageLink() 306 { 307 // The canonical case: image that links to a wiki page. 308 // Markdown equivalent of DW's `[[test:link|{{wiki:image.png}}]]`. 309 $this->P->addMode('gfm_link', new GfmLink()); 310 $this->P->parse('Foo [](test:link) Bar'); 311 $calls = [ 312 ['document_start', []], 313 ['p_open', []], 314 ['cdata', ["\nFoo "]], 315 ['internallink', ['test:link', $this->mediaArray([])]], 316 ['cdata', [' Bar']], 317 ['p_close', []], 318 ['document_end', []], 319 ]; 320 $this->assertCalls($calls, $this->H->calls); 321 } 322 323 function testImageAsLabelExternalLink() 324 { 325 $this->P->addMode('gfm_link', new GfmLink()); 326 $this->P->parse('Foo [](http://example.com) Bar'); 327 $calls = [ 328 ['document_start', []], 329 ['p_open', []], 330 ['cdata', ["\nFoo "]], 331 ['externallink', ['http://example.com', $this->mediaArray([])]], 332 ['cdata', [' Bar']], 333 ['p_close', []], 334 ['document_end', []], 335 ]; 336 $this->assertCalls($calls, $this->H->calls); 337 } 338 339 function testImageAsLabelWithExternalMedia() 340 { 341 $this->P->addMode('gfm_link', new GfmLink()); 342 $this->P->parse('Foo [](test:link) Bar'); 343 $calls = [ 344 ['document_start', []], 345 ['p_open', []], 346 ['cdata', ["\nFoo "]], 347 ['internallink', ['test:link', $this->mediaArray([ 348 'type' => 'externalmedia', 349 'src' => 'https://example.com/logo.png', 350 'title' => 'logo', 351 ])]], 352 ['cdata', [' Bar']], 353 ['p_close', []], 354 ['document_end', []], 355 ]; 356 $this->assertCalls($calls, $this->H->calls); 357 } 358 359 function testImageAsLabelInterwikiLink() 360 { 361 $this->P->addMode('gfm_link', new GfmLink()); 362 $this->P->parse('Foo [](wp>Example) Bar'); 363 $calls = [ 364 ['document_start', []], 365 ['p_open', []], 366 ['cdata', ["\nFoo "]], 367 ['interwikilink', ['wp>Example', $this->mediaArray([]), 'wp', 'Example']], 368 ['cdata', [' Bar']], 369 ['p_close', []], 370 ['document_end', []], 371 ]; 372 $this->assertCalls($calls, $this->H->calls); 373 } 374 375 function testImageAsLabelEmailLink() 376 { 377 $this->P->addMode('gfm_link', new GfmLink()); 378 $this->P->parse('Foo [](user@example.com) Bar'); 379 $calls = [ 380 ['document_start', []], 381 ['p_open', []], 382 ['cdata', ["\nFoo "]], 383 ['emaillink', ['user@example.com', $this->mediaArray([])]], 384 ['cdata', [' Bar']], 385 ['p_close', []], 386 ['document_end', []], 387 ]; 388 $this->assertCalls($calls, $this->H->calls); 389 } 390 391 function testImageAsLabelMediaParameters() 392 { 393 // Full DW parameter vocabulary works in the nested image slot. 394 $this->P->addMode('gfm_link', new GfmLink()); 395 $this->P->parse('Foo [](test:link) Bar'); 396 $calls = [ 397 ['document_start', []], 398 ['p_open', []], 399 ['cdata', ["\nFoo "]], 400 ['internallink', ['test:link', $this->mediaArray([ 401 'align' => 'right', 402 'width' => '200', 403 'height' => '100', 404 'linking' => 'nolink', 405 ])]], 406 ['cdata', [' Bar']], 407 ['p_close', []], 408 ['document_end', []], 409 ]; 410 $this->assertCalls($calls, $this->H->calls); 411 } 412 413 function testImageAsLabelEmptyAlt() 414 { 415 $this->P->addMode('gfm_link', new GfmLink()); 416 $this->P->parse('Foo [](test:link) Bar'); 417 $calls = [ 418 ['document_start', []], 419 ['p_open', []], 420 ['cdata', ["\nFoo "]], 421 ['internallink', ['test:link', $this->mediaArray(['title' => null])]], 422 ['cdata', [' Bar']], 423 ['p_close', []], 424 ['document_end', []], 425 ]; 426 $this->assertCalls($calls, $this->H->calls); 427 } 428 429 function testImageAsLabelBothTitlesDiscarded() 430 { 431 // Titles on both URLs parse cleanly but are dropped — neither 432 // DW's media nor link instructions have a title-attribute slot. 433 $this->P->addMode('gfm_link', new GfmLink()); 434 $this->P->parse('Foo [](test:link "link title") Bar'); 435 $calls = [ 436 ['document_start', []], 437 ['p_open', []], 438 ['cdata', ["\nFoo "]], 439 ['internallink', ['test:link', $this->mediaArray([])]], 440 ['cdata', [' Bar']], 441 ['p_close', []], 442 ['document_end', []], 443 ]; 444 $this->assertCalls($calls, $this->H->calls); 445 } 446 447 // ----- backslash-escape interaction (GFM §6.1) ----- 448 449 function testBackslashEscapesInLabel() 450 { 451 // Plain-text label gets §6.1 unescape applied before it reaches 452 // the link handler — `\*` collapses to a literal `*`. 453 $this->P->addMode('gfm_link', new GfmLink()); 454 $this->P->parse('Foo [te\\*xt](page) Bar'); 455 $calls = [ 456 ['document_start', []], 457 ['p_open', []], 458 ['cdata', ["\nFoo "]], 459 ['internallink', ['page', 'te*xt']], 460 ['cdata', [' Bar']], 461 ['p_close', []], 462 ['document_end', []], 463 ]; 464 $this->assertCalls($calls, $this->H->calls); 465 } 466 467 function testBackslashEscapedBracketInLabel() 468 { 469 // Spec example #523: an escaped `[` inside the label is allowed 470 // and unescapes to a literal bracket. The label class accepts 471 // `\[` / `\]` so the outer match still finds its `]` close. 472 $this->P->addMode('gfm_link', new GfmLink()); 473 $this->P->parse('Foo [link \\[bar](page) Baz'); 474 $calls = [ 475 ['document_start', []], 476 ['p_open', []], 477 ['cdata', ["\nFoo "]], 478 ['internallink', ['page', 'link [bar']], 479 ['cdata', [' Baz']], 480 ['p_close', []], 481 ['document_end', []], 482 ]; 483 $this->assertCalls($calls, $this->H->calls); 484 } 485 486 function testBackslashEscapedClosingBracketInLabel() 487 { 488 // The `\]` form is symmetric with `\[`. Both must be accepted by 489 // the label class without ending the outer match early. 490 $this->P->addMode('gfm_link', new GfmLink()); 491 $this->P->parse('Foo [a\\]b](page) Bar'); 492 $calls = [ 493 ['document_start', []], 494 ['p_open', []], 495 ['cdata', ["\nFoo "]], 496 ['internallink', ['page', 'a]b']], 497 ['cdata', [' Bar']], 498 ['p_close', []], 499 ['document_end', []], 500 ]; 501 $this->assertCalls($calls, $this->H->calls); 502 } 503 504 function testBackslashEscapesInUrl() 505 { 506 // §6.1 unescape fires on the URL after classify() picks the 507 // handler — it lets users put a literal punctuation char in a 508 // URL slot that would otherwise carry markup meaning. 509 $this->P->addMode('gfm_link', new GfmLink()); 510 $this->P->parse('Foo [text](http://example.com/pa\\!ge) Bar'); 511 $calls = [ 512 ['document_start', []], 513 ['p_open', []], 514 ['cdata', ["\nFoo "]], 515 ['externallink', ['http://example.com/pa!ge', 'text']], 516 ['cdata', [' Bar']], 517 ['p_close', []], 518 ['document_end', []], 519 ]; 520 $this->assertCalls($calls, $this->H->calls); 521 } 522 523 function testWindowsShareUrlSkipsBackslashUnescape() 524 { 525 // Carve-out: a `\\host\path` URL must survive classify() and 526 // stay intact as a windowssharelink. Applying §6.1 unescape 527 // would collapse the leading `\\` to `\` and destroy the share 528 // marker, so the unescape pass is skipped for this classifier. 529 $this->P->addMode('gfm_link', new GfmLink()); 530 $this->P->parse('Foo [share](\\\\server\\share\\sub) Bar'); 531 $calls = [ 532 ['document_start', []], 533 ['p_open', []], 534 ['cdata', ["\nFoo "]], 535 ['windowssharelink', ['\\\\server\\share\\sub', 'share']], 536 ['cdata', [' Bar']], 537 ['p_close', []], 538 ['document_end', []], 539 ]; 540 $this->assertCalls($calls, $this->H->calls); 541 } 542 543 function testSortValue() 544 { 545 $this->assertSame(300, (new GfmLink())->getSort()); 546 } 547} 548