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