xref: /dokuwiki/_test/tests/Parsing/ParserMode/GfmLinkTest.php (revision 75364f13219a5af44f52c564ea0a62df64c3a17f)
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 (`[![alt](img)](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 [![alt](wiki:image.png)](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 [![alt](wiki:image.png)](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 [![logo](https://example.com/logo.png)](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 [![alt](wiki:image.png)](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 [![alt](wiki:image.png)](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 [![alt](wiki:image.png?200x100&right&nolink)](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 [![](wiki:image.png)](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 [![alt](wiki:image.png "img title")](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