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