diff --git a/lib/src/url.dart b/lib/src/url.dart index 9bb6607..4de7bea 100644 --- a/lib/src/url.dart +++ b/lib/src/url.dart @@ -1,13 +1,13 @@ import 'package:linkify/linkify.dart'; final _urlRegex = RegExp( - r'^(.*?)((?:https?:\/\/|www\.)[^\s/$.?#].[^\s]*)', + r'^(.*?)((?:https?:\/\/|www\.)[^\s<>\x22\x27\)\]\}]*[^\s<>\x22\x27\)\]\}\.,;:!?])(?=$|[\s<>\x22\x27\)\]\}\.,;:!?])', caseSensitive: false, dotAll: true, ); final _looseUrlRegex = RegExp( - r'''^(.*?)((https?:\/\/)?(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,4}\b([-a-zA-Z0-9@:%_\+.~#?&//="'`]*))''', + r'^(.*?)((?:https?:\/\/)?(?:localhost(?::\d{2,5})(?:[\/?#][^\s<>\x22\x27]*[^\s<>\x22\x27\)\]\}\.,;:!?])?|(?:[\w.%+-]+(?::[\w.%+-]+)?@)?(?:\[(?:[0-9a-f:.]+)\]|(?:\d{1,3}\.){3}\d{1,3}|(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+(?:[a-z]{2,63}|xn--[a-z0-9-]{2,59}))(?::\d{2,5})?(?:[\/?#][^\s<>\x22\x27]*[^\s<>\x22\x27\)\]\}\.\,;:!?])?))(?=$|[\s<>\x22\x27\)\]\}\.,;:!?])', caseSensitive: false, dotAll: true, ); diff --git a/test/linkify_test.dart b/test/linkify_test.dart index b9c2ccb..845e2cc 100644 --- a/test/linkify_test.dart +++ b/test/linkify_test.dart @@ -193,7 +193,233 @@ void main() { ); }); - test('Parses ending period', () { + test('Does not parse invalid URLs with consecutive periods', () { + expectListEqual( + linkify('awdaw....aw', options: LinkifyOptions(looseUrl: true)), + [TextElement('awdaw....aw')], + ); + + expectListEqual( + linkify('awdaw...wad...wadw', options: LinkifyOptions(looseUrl: true)), + [TextElement('awdaw...wad...wadw')], + ); + + expectListEqual( + linkify('test..example.com', options: LinkifyOptions(looseUrl: true)), + [TextElement('test..'), UrlElement('http://example.com', 'example.com')], + ); + + expectListEqual( + linkify( + '....and i am a sentence', + options: LinkifyOptions(looseUrl: true), + ), + [TextElement('....and i am a sentence')], + ); + }); + + test('Parses subdomains correctly', () { + expectListEqual( + linkify('https://subdomain.example.com'), + [UrlElement('https://subdomain.example.com', 'subdomain.example.com')], + ); + + expectListEqual( + linkify('https://api.subdomain.example.com'), + [ + UrlElement( + 'https://api.subdomain.example.com', + 'api.subdomain.example.com', + ) + ], + ); + + expectListEqual( + linkify('subdomain.example.com', options: LinkifyOptions(looseUrl: true)), + [UrlElement('http://subdomain.example.com', 'subdomain.example.com')], + ); + + expectListEqual( + linkify( + 'Check out api.subdomain.example.com for more info', + options: LinkifyOptions(looseUrl: true), + ), + [ + TextElement('Check out '), + UrlElement( + 'http://api.subdomain.example.com', + 'api.subdomain.example.com', + ), + TextElement(' for more info'), + ], + ); + }); + + test('Parses localhost URLs', () { + expectListEqual( + linkify('http://localhost'), + [UrlElement('http://localhost', 'localhost')], + ); + + expectListEqual( + linkify('http://localhost:3000'), + [UrlElement('http://localhost:3000', 'localhost:3000')], + ); + + expectListEqual( + linkify('http://localhost:8080/api/test'), + [UrlElement('http://localhost:8080/api/test', 'localhost:8080/api/test')], + ); + + expectListEqual( + linkify('localhost', options: LinkifyOptions(looseUrl: true)), + [TextElement('localhost')], + ); + + expectListEqual( + linkify( + 'Check out localhost for testing', + options: LinkifyOptions(looseUrl: true), + ), + [TextElement('Check out localhost for testing')], + ); + + expectListEqual( + linkify('localhost:3000', options: LinkifyOptions(looseUrl: true)), + [UrlElement('http://localhost:3000', 'localhost:3000')], + ); + }); + + test('Parses URLs with ports', () { + expectListEqual( + linkify('https://example.com:8080'), + [UrlElement('https://example.com:8080', 'example.com:8080')], + ); + + expectListEqual( + linkify('https://api.example.com:3000/path'), + [ + UrlElement( + 'https://api.example.com:3000/path', + 'api.example.com:3000/path', + ) + ], + ); + + expectListEqual( + linkify('example.com:8080', options: LinkifyOptions(looseUrl: true)), + [UrlElement('http://example.com:8080', 'example.com:8080')], + ); + }); + + test('Parses IP address URLs', () { + expectListEqual( + linkify('http://192.168.1.1'), + [UrlElement('http://192.168.1.1', '192.168.1.1')], + ); + + expectListEqual( + linkify('http://192.168.1.1:8080'), + [UrlElement('http://192.168.1.1:8080', '192.168.1.1:8080')], + ); + + expectListEqual( + linkify('https://10.0.0.1:3000/api'), + [UrlElement('https://10.0.0.1:3000/api', '10.0.0.1:3000/api')], + ); + + expectListEqual( + linkify('192.168.1.1:8080', options: LinkifyOptions(looseUrl: true)), + [UrlElement('http://192.168.1.1:8080', '192.168.1.1:8080')], + ); + + expectListEqual( + linkify( + 'Check out 192.168.1.1:8080 for the dashboard', + options: LinkifyOptions(looseUrl: true), + ), + [ + TextElement('Check out '), + UrlElement('http://192.168.1.1:8080', '192.168.1.1:8080'), + TextElement(' for the dashboard'), + ], + ); + }); + + test('Parses punycode domains', () { + // xn--n3h.com is ☃.com (snowman emoji domain) + expectListEqual( + linkify('https://xn--n3h.com'), + [UrlElement('https://xn--n3h.com', 'xn--n3h.com')], + ); + + // xn--bcher-kva.com is bücher.com (books in German) + expectListEqual( + linkify('https://xn--bcher-kva.com'), + [UrlElement('https://xn--bcher-kva.com', 'xn--bcher-kva.com')], + ); + + expectListEqual( + linkify('xn--n3h.com', options: LinkifyOptions(looseUrl: true)), + [UrlElement('http://xn--n3h.com', 'xn--n3h.com')], + ); + + expectListEqual( + linkify('Visit xn--bcher-kva.com for more', + options: LinkifyOptions(looseUrl: true)), + [ + TextElement('Visit '), + UrlElement('http://xn--bcher-kva.com', 'xn--bcher-kva.com'), + TextElement(' for more'), + ], + ); + }); + + test('Parses TLDs with more than 4 letters', () { + expectListEqual( + linkify('https://example.design'), + [UrlElement('https://example.design', 'example.design')], + ); + + expectListEqual( + linkify('https://example.travel'), + [UrlElement('https://example.travel', 'example.travel')], + ); + + expectListEqual( + linkify('https://example.cloud'), + [UrlElement('https://example.cloud', 'example.cloud')], + ); + + expectListEqual( + linkify('example.design', options: LinkifyOptions(looseUrl: true)), + [UrlElement('http://example.design', 'example.design')], + ); + + expectListEqual( + linkify('example.travel', options: LinkifyOptions(looseUrl: true)), + [UrlElement('http://example.travel', 'example.travel')], + ); + + expectListEqual( + linkify('example.cloud', options: LinkifyOptions(looseUrl: true)), + [UrlElement('http://example.cloud', 'example.cloud')], + ); + + expectListEqual( + linkify( + 'Check out example.design for more info', + options: LinkifyOptions(looseUrl: true), + ), + [ + TextElement('Check out '), + UrlElement('http://example.design', 'example.design'), + TextElement(' for more info'), + ], + ); + }); + + test('Parses ending period and trailing punctuation', () { expectListEqual( linkify("https://example.com/test."), [ @@ -201,6 +427,42 @@ void main() { TextElement(".") ], ); + + expectListEqual( + linkify('Check out https://example.com!'), + [ + TextElement('Check out '), + UrlElement('https://example.com', 'example.com'), + TextElement('!'), + ], + ); + + expectListEqual( + linkify('Visit https://example.com, then come back.'), + [ + TextElement('Visit '), + UrlElement('https://example.com', 'example.com'), + TextElement(', then come back.'), + ], + ); + + expectListEqual( + linkify('See https://example.com?'), + [ + TextElement('See '), + UrlElement('https://example.com', 'example.com'), + TextElement('?'), + ], + ); + + expectListEqual( + linkify('Go to example.com.', options: LinkifyOptions(looseUrl: true)), + [ + TextElement('Go to '), + UrlElement('http://example.com', 'example.com'), + TextElement('.'), + ], + ); }); test('Parses CR correctly.', () { @@ -313,4 +575,90 @@ void main() { ], ); }); + + test('Excludes wrapping parentheses from URLs', () { + expectListEqual( + linkify('Some text before (https://github.com/Cretezy/flutter_linkify).'), + [ + TextElement('Some text before ('), + UrlElement( + 'https://github.com/Cretezy/flutter_linkify', + 'github.com/Cretezy/flutter_linkify', + ), + TextElement(').'), + ], + ); + + expectListEqual( + linkify('Check this out (https://example.com)'), + [ + TextElement('Check this out ('), + UrlElement('https://example.com', 'example.com'), + TextElement(')'), + ], + ); + + expectListEqual( + linkify('Link: [https://example.com]'), + [ + TextElement('Link: ['), + UrlElement('https://example.com', 'example.com'), + TextElement(']'), + ], + ); + + expectListEqual( + linkify('Code: {https://example.com}'), + [ + TextElement('Code: {'), + UrlElement('https://example.com', 'example.com'), + TextElement('}'), + ], + ); + }); + + test('Excludes wrapping brackets from loose URLs', () { + expectListEqual( + linkify( + 'Some text before (example.com/path).', + options: LinkifyOptions(looseUrl: true), + ), + [ + TextElement('Some text before ('), + UrlElement('http://example.com/path', 'example.com/path'), + TextElement(').'), + ], + ); + + expectListEqual( + linkify( + 'Check [example.com]', + options: LinkifyOptions(looseUrl: true), + ), + [ + TextElement('Check ['), + UrlElement('http://example.com', 'example.com'), + TextElement(']'), + ], + ); + }); + + test('Does not exclude non-wrapping closing brackets', () { + expectListEqual( + linkify('https://example.com/path)'), + [ + UrlElement('https://example.com/path', 'example.com/path'), + TextElement(')'), + ], + ); + + expectListEqual( + linkify('No opening bracket https://example.com]'), + [ + TextElement('No opening bracket '), + UrlElement('https://example.com', 'example.com'), + TextElement(']'), + ], + ); + }); }