Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 6 additions & 13 deletions lib/src/email.dart
Original file line number Diff line number Diff line change
@@ -1,10 +1,5 @@
import 'package:linkify/linkify.dart';

final _emailRegex = RegExp(
r'^(.*?)((mailto:)?[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z][A-Z]+)',
caseSensitive: false,
dotAll: true,
);
import 'package:linkify/src/email_matcher.dart';

class EmailLinkifier extends Linkifier {
const EmailLinkifier();
Expand All @@ -15,22 +10,20 @@ class EmailLinkifier extends Linkifier {

for (var element in elements) {
if (element is TextElement) {
final match = _emailRegex.firstMatch(element.text);
final match = emailRegex.firstMatch(element.text);

if (match == null) {
list.add(element);
} else {
final text = element.text.replaceFirst(match.group(0)!, '');

if (match.group(1)?.isNotEmpty == true) {
list.add(TextElement(match.group(1)!));
if (match.group(emailPrefixGroup)?.isNotEmpty == true) {
list.add(TextElement(match.group(emailPrefixGroup)!));
}

if (match.group(2)?.isNotEmpty == true) {
if (match.group(emailElementGroup)?.isNotEmpty == true) {
// Always humanize emails
list.add(EmailElement(
match.group(2)!.replaceFirst(RegExp(r'mailto:'), ''),
));
list.add(EmailElement(match.group(emailAddressGroup)!));
}

if (text.isNotEmpty) {
Expand Down
18 changes: 18 additions & 0 deletions lib/src/email_matcher.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
const emailPrefixGroup = 1;
const emailElementGroup = 2;
const emailAddressGroup = 4;

const emailLocalPartCharacterClass = r'A-Z0-9._%+\-';
const emailTokenCharacterClass = '$emailLocalPartCharacterClass@';

const domainNamePattern = r'(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+'
r'(?:[A-Z]{2,63}|xn--[A-Z0-9-]{2,59})';

final emailRegex = RegExp(
r'^(.*?)((mailto:)?('
'[$emailLocalPartCharacterClass]+@'
'$domainNamePattern'
r'))(?![A-Z0-9_-]|\.[A-Z0-9])',
caseSensitive: false,
dotAll: true,
);
178 changes: 161 additions & 17 deletions lib/src/url.dart
Original file line number Diff line number Diff line change
@@ -1,22 +1,58 @@
import 'package:linkify/linkify.dart';
import 'package:linkify/src/email_matcher.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@:%_\+.~#?&//="'`]*))''',
caseSensitive: false,
dotAll: true,
const _ordinaryLooseUrlPrefixPattern =
'(?:.*?)(?:^|[^$emailTokenCharacterClass])';
const _consecutivePeriodsLooseUrlPrefixPattern =
'(?:.*?[^$emailTokenCharacterClass])?'
'[$emailLocalPartCharacterClass]*\\.\\.';

const _urlSuffixPattern = r'(?:[\/?#][^\s<>\x22\x27]*'
r'[^\s<>\x22\x27\.,;:!?]|\/)?';

const _protocolWithOptionalUserInfoPattern =
r"(?:https?:\/\/)(?:[A-Z0-9._~!$&'()*+,;=:%\-]+@)?";
const _localhostWithOptionalPortPattern = r'localhost(?::\d{1,5})?';
const _localhostWithPortPattern = r'localhost(?::\d{1,5})';
const _networkHostPattern = r'(?:\[(?:[0-9a-f:.]+)\]|(?:\d{1,3}\.){3}\d{1,3}|'
'$domainNamePattern'
r')(?::\d{1,5})?';

const _looseUrlCandidatePattern = '(?:'
'$_protocolWithOptionalUserInfoPattern'
'(?:$_localhostWithOptionalPortPattern|$_networkHostPattern)'
'|'
'$_localhostWithPortPattern'
'|'
'$_networkHostPattern'
')'
'$_urlSuffixPattern'
r'\.?';

final _ordinaryLooseUrlRegex = _createLooseUrlRegex(
_ordinaryLooseUrlPrefixPattern,
);
final _consecutivePeriodsLooseUrlRegex = _createLooseUrlRegex(
_consecutivePeriodsLooseUrlPrefixPattern,
);

final _protocolIdentifierRegex = RegExp(
r'^(https?:\/\/)',
caseSensitive: false,
);

const _openingBracketByClosingBracket = {
')': '(',
']': '[',
'}': '{',
};

class UrlLinkifier extends Linkifier {
const UrlLinkifier();

Expand All @@ -27,13 +63,15 @@ class UrlLinkifier extends Linkifier {
for (var element in elements) {
if (element is TextElement) {
var match = options.looseUrl
? _looseUrlRegex.firstMatch(element.text)
? _firstLooseUrlMatch(element.text)
: _urlRegex.firstMatch(element.text);

if (match == null) {
list.add(element);
} else {
final text = element.text.replaceFirst(match.group(0)!, '');
final remainingTextAfterMatch =
element.text.replaceFirst(match.group(0)!, '');
var text = remainingTextAfterMatch;

if (match.group(1)?.isNotEmpty == true) {
list.add(TextElement(match.group(1)!));
Expand All @@ -42,13 +80,46 @@ class UrlLinkifier extends Linkifier {
if (match.group(2)?.isNotEmpty == true) {
var originalUrl = match.group(2)!;
var originText = originalUrl;
String? end;

if ((options.excludeLastPeriod) &&
originalUrl[originalUrl.length - 1] == ".") {
end = ".";
originText = originText.substring(0, originText.length - 1);
originalUrl = originalUrl.substring(0, originalUrl.length - 1);
while (true) {
final wrapperStart = _trailingWrapperStart(originalUrl);
if (wrapperStart != null) {
text = originalUrl.substring(wrapperStart) + text;
originText = originText.substring(0, wrapperStart);
originalUrl = originalUrl.substring(0, wrapperStart);
continue;
}

if (options.excludeLastPeriod && originalUrl.endsWith('.')) {
text = '.$text';
originText = originText.substring(0, originText.length - 1);
originalUrl = originalUrl.substring(0, originalUrl.length - 1);
continue;
}

break;
}

if (!_hasUrlAuthority(originalUrl)) {
if (match.group(1)?.isNotEmpty == true) {
list.removeLast();
}
final parsedRemaining = remainingTextAfterMatch.isEmpty
? <LinkifyElement>[]
: parse([TextElement(remainingTextAfterMatch)], options);
if (parsedRemaining.isNotEmpty &&
parsedRemaining.first is TextElement) {
list.add(
TextElement(
match.group(0)! + parsedRemaining.first.text,
),
);
list.addAll(parsedRemaining.skip(1));
} else {
list.add(TextElement(match.group(0)!));
list.addAll(parsedRemaining);
}
continue;
}

var url = originalUrl;
Expand All @@ -74,10 +145,6 @@ class UrlLinkifier extends Linkifier {
} else {
list.add(UrlElement(originalUrl, null, originText));
}

if (end != null) {
list.add(TextElement(end));
}
}

if (text.isNotEmpty) {
Expand All @@ -93,6 +160,83 @@ class UrlLinkifier extends Linkifier {
}
}

bool _endsWithUnmatchedClosingBracket(String value) {
final closingBracket = value[value.length - 1];
final openingBracket = _openingBracketByClosingBracket[closingBracket];
if (openingBracket == null) {
return false;
}

return _countCharacter(value, closingBracket) >
_countCharacter(value, openingBracket);
}

int? _trailingWrapperStart(String value) {
var bracketIndex = value.length - 1;
while (bracketIndex >= 0 && value[bracketIndex] == '.') {
bracketIndex--;
}

if (bracketIndex < 0) {
return null;
}

final valueThroughBracket = value.substring(0, bracketIndex + 1);
return _endsWithUnmatchedClosingBracket(valueThroughBracket)
? bracketIndex
: null;
}

int _countCharacter(String value, String character) {
var count = 0;
for (var index = 0; index < value.length; index++) {
if (value[index] == character) {
count++;
}
}
return count;
}

bool _hasUrlAuthority(String value) {
final valueWithoutLastPeriod =
value.endsWith('.') ? value.substring(0, value.length - 1) : value;

if (valueWithoutLastPeriod.toLowerCase() == 'www') {
return false;
}

final normalizedValue =
valueWithoutLastPeriod.startsWith(_protocolIdentifierRegex)
? valueWithoutLastPeriod
: 'http://$valueWithoutLastPeriod';
return Uri.tryParse(normalizedValue)?.host.isNotEmpty == true;
}

RegExp _createLooseUrlRegex(String prefixPattern) => RegExp(
'^($prefixPattern)($_looseUrlCandidatePattern)'
r'(?=$|[\s<>\x22\x27\)\]\}\.,;:!?])',
caseSensitive: false,
dotAll: true,
);

RegExpMatch? _firstLooseUrlMatch(String text) {
final ordinaryMatch = _ordinaryLooseUrlRegex.firstMatch(text);
final consecutivePeriodsMatch =
_consecutivePeriodsLooseUrlRegex.firstMatch(text);

if (ordinaryMatch == null) {
return consecutivePeriodsMatch;
}
if (consecutivePeriodsMatch == null) {
return ordinaryMatch;
}

return ordinaryMatch.group(1)!.length <=
consecutivePeriodsMatch.group(1)!.length
? ordinaryMatch
: consecutivePeriodsMatch;
}

/// Represents an element containing a link
class UrlElement extends LinkableElement {
UrlElement(String url, [String? text, String? originText])
Expand Down
Loading