mirror of
https://github.com/Foundry376/Mailspring.git
synced 2025-09-04 11:44:47 +08:00
rm(autolinker): Use our own very simple autolinker
Summary: Autolinker is a great open source project but it attempts to parse HTML with regexp, is quite slow, and hangs on specific emails https://github.com/nylas/N1/issues/1540 This is super bad, and also super unnecessary. I think this should do the trick. Note: I changed the urlRegex in our Utils to be much more liberal. It now matches anything that looks like a URL, not just things with the http:// and https:// prefixes. It's used in the LinkEditor and onboarding screen (detecting auth errors with urls) and I think it should be ok? Test Plan: Need to write some tests Reviewers: evan, juan Reviewed By: juan Differential Revision: https://phab.nylas.com/D2725
This commit is contained in:
parent
1e765a6b62
commit
c76582194a
16 changed files with 2853 additions and 160 deletions
|
@ -13,6 +13,7 @@
|
||||||
"rules": {
|
"rules": {
|
||||||
"react/prop-types": [2, {"ignore": ["children"]}],
|
"react/prop-types": [2, {"ignore": ["children"]}],
|
||||||
"react/no-multi-comp": [0],
|
"react/no-multi-comp": [0],
|
||||||
|
"react/sort-comp": [2],
|
||||||
"eqeqeq": [2, "smart"],
|
"eqeqeq": [2, "smart"],
|
||||||
"id-length": [0],
|
"id-length": [0],
|
||||||
"object-curly-spacing": [0],
|
"object-curly-spacing": [0],
|
||||||
|
|
54
internal_packages/message-list/lib/autolinker.es6
Normal file
54
internal_packages/message-list/lib/autolinker.es6
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
import {RegExpUtils, DOMUtils} from 'nylas-exports';
|
||||||
|
|
||||||
|
function _runOnTextNode(node, matchers) {
|
||||||
|
if (node.parentElement) {
|
||||||
|
const withinScript = node.parentElement.tagName === "SCRIPT";
|
||||||
|
const withinStyle = node.parentElement.tagName === "STYLE";
|
||||||
|
const withinA = (node.parentElement.closest('a') !== null);
|
||||||
|
if (withinScript || withinA || withinStyle) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (node.textContent.trim().length < 4) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const [prefix, regex] of matchers) {
|
||||||
|
regex.lastIndex = 0;
|
||||||
|
const match = regex.exec(node.textContent);
|
||||||
|
if (match !== null) {
|
||||||
|
const href = `${prefix}${match[0]}`;
|
||||||
|
const range = document.createRange();
|
||||||
|
range.setStart(node, match.index);
|
||||||
|
range.setEnd(node, match.index + match[0].length);
|
||||||
|
const aTag = DOMUtils.wrap(range, 'A');
|
||||||
|
aTag.href = href;
|
||||||
|
aTag.title = href;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function autolink(doc) {
|
||||||
|
// Traverse the new DOM tree and make things that look like links clickable,
|
||||||
|
// and ensure anything with an href has a title attribute.
|
||||||
|
const textWalker = document.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT);
|
||||||
|
const matchers = [
|
||||||
|
['mailto:', RegExpUtils.emailRegex()],
|
||||||
|
['tel:', RegExpUtils.phoneRegex()],
|
||||||
|
['', RegExpUtils.urlRegex({matchEntireString: false})],
|
||||||
|
];
|
||||||
|
|
||||||
|
while (textWalker.nextNode()) {
|
||||||
|
_runOnTextNode(textWalker.currentNode, matchers);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Traverse the new DOM tree and make sure everything with an href has a title.
|
||||||
|
const aTagWalker = document.createTreeWalker(doc.body, NodeFilter.SHOW_ELEMENT, {
|
||||||
|
acceptNode: (node) =>
|
||||||
|
(node.href && !node.title) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP
|
||||||
|
,
|
||||||
|
});
|
||||||
|
while (aTagWalker.nextNode()) {
|
||||||
|
aTagWalker.currentNode.title = aTagWalker.currentNode.href;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,89 +0,0 @@
|
||||||
React = require 'react'
|
|
||||||
_ = require "underscore"
|
|
||||||
{EventedIFrame} = require 'nylas-component-kit'
|
|
||||||
{Utils, QuotedHTMLTransformer} = require 'nylas-exports'
|
|
||||||
|
|
||||||
EmailFrameStylesStore = require './email-frame-styles-store'
|
|
||||||
|
|
||||||
class EmailFrame extends React.Component
|
|
||||||
@displayName = 'EmailFrame'
|
|
||||||
|
|
||||||
@propTypes:
|
|
||||||
content: React.PropTypes.string.isRequired
|
|
||||||
|
|
||||||
render: =>
|
|
||||||
<EventedIFrame ref="iframe" seamless="seamless" searchable={true}
|
|
||||||
onResize={@_setFrameHeight}/>
|
|
||||||
|
|
||||||
componentDidMount: =>
|
|
||||||
@_mounted = true
|
|
||||||
@_writeContent()
|
|
||||||
@_unlisten = EmailFrameStylesStore.listen(@_writeContent)
|
|
||||||
|
|
||||||
componentWillUnmount: =>
|
|
||||||
@_mounted = false
|
|
||||||
@_unlisten?()
|
|
||||||
|
|
||||||
componentDidUpdate: =>
|
|
||||||
@_writeContent()
|
|
||||||
|
|
||||||
shouldComponentUpdate: (nextProps, nextState) =>
|
|
||||||
not Utils.isEqualReact(nextProps, @props) or
|
|
||||||
not Utils.isEqualReact(nextState, @state)
|
|
||||||
|
|
||||||
_writeContent: =>
|
|
||||||
@_lastComputedHeight = 0
|
|
||||||
domNode = React.findDOMNode(@)
|
|
||||||
doc = domNode.contentDocument
|
|
||||||
return unless doc
|
|
||||||
|
|
||||||
doc.open()
|
|
||||||
|
|
||||||
# NOTE: The iframe must have a modern DOCTYPE. The lack of this line
|
|
||||||
# will cause some bizzare non-standards compliant rendering with the
|
|
||||||
# message bodies. This is particularly felt with <table> elements use
|
|
||||||
# the `border-collapse: collapse` css property while setting a
|
|
||||||
# `padding`.
|
|
||||||
doc.write("<!DOCTYPE html>")
|
|
||||||
styles = EmailFrameStylesStore.styles()
|
|
||||||
if (styles)
|
|
||||||
doc.write("<style>#{styles}</style>")
|
|
||||||
doc.write("<div id='inbox-html-wrapper'>#{@_emailContent()}</div>")
|
|
||||||
doc.close()
|
|
||||||
|
|
||||||
# Notify the EventedIFrame that we've replaced it's document (with `open`)
|
|
||||||
# so it can attach event listeners again.
|
|
||||||
@refs.iframe.documentWasReplaced()
|
|
||||||
domNode.height = '0px'
|
|
||||||
@_setFrameHeight()
|
|
||||||
|
|
||||||
_getFrameHeight: (doc) ->
|
|
||||||
return 0 unless doc
|
|
||||||
return doc.body?.scrollHeight ? doc.documentElement?.scrollHeight ? 0
|
|
||||||
|
|
||||||
_setFrameHeight: =>
|
|
||||||
return unless @_mounted
|
|
||||||
|
|
||||||
domNode = React.findDOMNode(@)
|
|
||||||
height = @_getFrameHeight(domNode.contentDocument)
|
|
||||||
|
|
||||||
# Why 5px? Some emails have elements with a height of 100%, and then put
|
|
||||||
# tracking pixels beneath that. In these scenarios, the scrollHeight of the
|
|
||||||
# message is always <100% + 1px>, which leads us to resize them constantly.
|
|
||||||
# This is a hack, but I'm not sure of a better solution.
|
|
||||||
if Math.abs(height - @_lastComputedHeight) > 5
|
|
||||||
domNode.height = "#{height}px"
|
|
||||||
@_lastComputedHeight = height
|
|
||||||
|
|
||||||
unless domNode.contentDocument?.readyState is 'complete'
|
|
||||||
_.defer => @_setFrameHeight()
|
|
||||||
|
|
||||||
_emailContent: =>
|
|
||||||
# When showing quoted text, always return the pure content
|
|
||||||
if @props.showQuotedText
|
|
||||||
@props.content
|
|
||||||
else
|
|
||||||
QuotedHTMLTransformer.removeQuotedHTML(@props.content, keepIfWholeBodyIsQuote: true)
|
|
||||||
|
|
||||||
|
|
||||||
module.exports = EmailFrame
|
|
119
internal_packages/message-list/lib/email-frame.jsx
Normal file
119
internal_packages/message-list/lib/email-frame.jsx
Normal file
|
@ -0,0 +1,119 @@
|
||||||
|
import React from 'react';
|
||||||
|
import _ from "underscore";
|
||||||
|
import {EventedIFrame} from 'nylas-component-kit';
|
||||||
|
import {Utils, QuotedHTMLTransformer} from 'nylas-exports';
|
||||||
|
import {autolink} from './autolinker';
|
||||||
|
import EmailFrameStylesStore from './email-frame-styles-store';
|
||||||
|
|
||||||
|
export default class EmailFrame extends React.Component {
|
||||||
|
static displayName = 'EmailFrame';
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
content: React.PropTypes.string.isRequired,
|
||||||
|
showQuotedText: React.PropTypes.bool,
|
||||||
|
};
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this._mounted = true;
|
||||||
|
this._writeContent();
|
||||||
|
this._unlisten = EmailFrameStylesStore.listen(this._writeContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
shouldComponentUpdate(nextProps, nextState) {
|
||||||
|
return (!Utils.isEqualReact(nextProps, this.props) ||
|
||||||
|
!Utils.isEqualReact(nextState, this.state));
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate() {
|
||||||
|
this._writeContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this._mounted = false;
|
||||||
|
if (this._unlisten) {
|
||||||
|
this._unlisten();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_emailContent = () => {
|
||||||
|
// When showing quoted text, always return the pure content
|
||||||
|
if (this.props.showQuotedText) {
|
||||||
|
return this.props.content;
|
||||||
|
}
|
||||||
|
return QuotedHTMLTransformer.removeQuotedHTML(this.props.content, {
|
||||||
|
keepIfWholeBodyIsQuote: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_writeContent = () => {
|
||||||
|
this._lastComputedHeight = 0;
|
||||||
|
const domNode = React.findDOMNode(this);
|
||||||
|
const doc = domNode.contentDocument;
|
||||||
|
if (!doc) { return; }
|
||||||
|
doc.open();
|
||||||
|
|
||||||
|
// NOTE: The iframe must have a modern DOCTYPE. The lack of this line
|
||||||
|
// will cause some bizzare non-standards compliant rendering with the
|
||||||
|
// message bodies. This is particularly felt with <table> elements use
|
||||||
|
// the `border-collapse: collapse` css property while setting a
|
||||||
|
// `padding`.
|
||||||
|
doc.write("<!DOCTYPE html>");
|
||||||
|
const styles = EmailFrameStylesStore.styles();
|
||||||
|
if (styles) {
|
||||||
|
doc.write(`<style>${styles}</style>`);
|
||||||
|
}
|
||||||
|
doc.write(`<div id='inbox-html-wrapper'>${this._emailContent()}</div>`);
|
||||||
|
doc.close();
|
||||||
|
|
||||||
|
autolink(doc);
|
||||||
|
|
||||||
|
// Notify the EventedIFrame that we've replaced it's document (with `open`)
|
||||||
|
// so it can attach event listeners again.
|
||||||
|
this.refs.iframe.documentWasReplaced();
|
||||||
|
domNode.height = '0px';
|
||||||
|
this._setFrameHeight();
|
||||||
|
}
|
||||||
|
|
||||||
|
_getFrameHeight = (doc) => {
|
||||||
|
if (doc && doc.body) {
|
||||||
|
return doc.body.scrollHeight;
|
||||||
|
}
|
||||||
|
if (doc && doc.documentElement) {
|
||||||
|
return doc.documentElement.scrollHeight;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
_setFrameHeight = () => {
|
||||||
|
if (!this._mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const domNode = React.findDOMNode(this);
|
||||||
|
const height = this._getFrameHeight(domNode.contentDocument);
|
||||||
|
|
||||||
|
// Why 5px? Some emails have elements with a height of 100%, and then put
|
||||||
|
// tracking pixels beneath that. In these scenarios, the scrollHeight of the
|
||||||
|
// message is always <100% + 1px>, which leads us to resize them constantly.
|
||||||
|
// This is a hack, but I'm not sure of a better solution.
|
||||||
|
if (Math.abs(height - this._lastComputedHeight) > 5) {
|
||||||
|
domNode.height = `${height}px`;
|
||||||
|
this._lastComputedHeight = height;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (domNode.contentDocument.readyState !== 'complete') {
|
||||||
|
_.defer(()=> this._setFrameHeight());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<EventedIFrame
|
||||||
|
ref="iframe"
|
||||||
|
seamless="seamless"
|
||||||
|
searchable
|
||||||
|
onResize={this._setFrameHeight}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -13,7 +13,6 @@ ThreadArchiveButton = require './thread-archive-button'
|
||||||
ThreadTrashButton = require './thread-trash-button'
|
ThreadTrashButton = require './thread-trash-button'
|
||||||
ThreadToggleUnreadButton = require './thread-toggle-unread-button'
|
ThreadToggleUnreadButton = require './thread-toggle-unread-button'
|
||||||
|
|
||||||
AutolinkerExtension = require './plugins/autolinker-extension'
|
|
||||||
TrackingPixelsExtension = require './plugins/tracking-pixels-extension'
|
TrackingPixelsExtension = require './plugins/tracking-pixels-extension'
|
||||||
|
|
||||||
module.exports =
|
module.exports =
|
||||||
|
@ -47,7 +46,6 @@ module.exports =
|
||||||
ComponentRegistry.register MessageListHiddenMessagesToggle,
|
ComponentRegistry.register MessageListHiddenMessagesToggle,
|
||||||
role: 'MessageListHeaders'
|
role: 'MessageListHeaders'
|
||||||
|
|
||||||
ExtensionRegistry.MessageView.register AutolinkerExtension
|
|
||||||
ExtensionRegistry.MessageView.register TrackingPixelsExtension
|
ExtensionRegistry.MessageView.register TrackingPixelsExtension
|
||||||
|
|
||||||
deactivate: ->
|
deactivate: ->
|
||||||
|
@ -59,7 +57,6 @@ module.exports =
|
||||||
ComponentRegistry.unregister MessageToolbarItems
|
ComponentRegistry.unregister MessageToolbarItems
|
||||||
ComponentRegistry.unregister SidebarPluginContainer
|
ComponentRegistry.unregister SidebarPluginContainer
|
||||||
ComponentRegistry.unregister SidebarParticipantPicker
|
ComponentRegistry.unregister SidebarParticipantPicker
|
||||||
ExtensionRegistry.MessageView.unregister AutolinkerExtension
|
|
||||||
ExtensionRegistry.MessageView.unregister TrackingPixelsExtension
|
ExtensionRegistry.MessageView.unregister TrackingPixelsExtension
|
||||||
|
|
||||||
serialize: -> @state
|
serialize: -> @state
|
||||||
|
|
|
@ -1,31 +0,0 @@
|
||||||
Autolinker = require 'autolinker'
|
|
||||||
{RegExpUtils, MessageViewExtension} = require 'nylas-exports'
|
|
||||||
|
|
||||||
class AutolinkerExtension extends MessageViewExtension
|
|
||||||
|
|
||||||
@formatMessageBody: ({message}) ->
|
|
||||||
# Apply the autolinker pass to make emails and links clickable
|
|
||||||
message.body = Autolinker.link(message.body, {twitter: false})
|
|
||||||
|
|
||||||
# Ensure that the hrefs in the email always have alt text so you can't hide
|
|
||||||
# the target of links
|
|
||||||
# https://regex101.com/r/cH0qM7/1
|
|
||||||
titleRe = -> /title\s.*?=\s.*?['"](.*)['"]/gi
|
|
||||||
message.body = message.body.replace RegExpUtils.linkTagRegex(), (match, openTagPrefix, aTagHref, openTagSuffix, content, closingTag) =>
|
|
||||||
if not content or not closingTag
|
|
||||||
return match
|
|
||||||
|
|
||||||
openTag = openTagPrefix + aTagHref + openTagSuffix
|
|
||||||
|
|
||||||
if titleRe().test(openTag)
|
|
||||||
oldTitle = titleRe().exec(openTag)[1]
|
|
||||||
title = """ title="#{oldTitle} | #{aTagHref}" """
|
|
||||||
openTag = openTag.replace(titleRe(), title)
|
|
||||||
else
|
|
||||||
title = """ title="#{aTagHref}" """
|
|
||||||
tagLen = openTag.length
|
|
||||||
openTag = openTag.slice(0, tagLen - 1) + title + openTag.slice(tagLen - 1, tagLen)
|
|
||||||
|
|
||||||
return openTag + content + closingTag
|
|
||||||
|
|
||||||
module.exports = AutolinkerExtension
|
|
|
@ -7,8 +7,5 @@
|
||||||
"private": true,
|
"private": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"nylas": "*"
|
"nylas": "*"
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"autolinker": "0.18.1"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,28 +0,0 @@
|
||||||
Autolinker = require 'autolinker'
|
|
||||||
AutolinkerExtension = require '../lib/plugins/autolinker-extension'
|
|
||||||
|
|
||||||
describe "AutolinkerExtension", ->
|
|
||||||
beforeEach ->
|
|
||||||
spyOn(Autolinker, 'link').andCallFake (txt) => txt
|
|
||||||
|
|
||||||
it "should call through to Autolinker", ->
|
|
||||||
AutolinkerExtension.formatMessageBody(message: {body:'body'})
|
|
||||||
expect(Autolinker.link).toHaveBeenCalledWith('body', {twitter: false})
|
|
||||||
|
|
||||||
it "should add a title to everything with an href", ->
|
|
||||||
message =
|
|
||||||
body: """
|
|
||||||
<a href="apple.com">hello world!</a>
|
|
||||||
<a href = "http://apple.com">hello world!</a>
|
|
||||||
<a href ='http://apple.com'>hello world!</a>
|
|
||||||
<a href ='mailto://'>hello world!</a>
|
|
||||||
"""
|
|
||||||
expected =
|
|
||||||
body: """
|
|
||||||
<a href="apple.com" title="apple.com" >hello world!</a>
|
|
||||||
<a href = "http://apple.com" title="http://apple.com" >hello world!</a>
|
|
||||||
<a href ='http://apple.com' title="http://apple.com" >hello world!</a>
|
|
||||||
<a href ='mailto://' title="mailto://" >hello world!</a>
|
|
||||||
"""
|
|
||||||
AutolinkerExtension.formatMessageBody({message})
|
|
||||||
expect(message.body).toEqual(expected.body)
|
|
|
@ -0,0 +1,184 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<title></title>
|
||||||
|
</head>
|
||||||
|
<body style="margin: 0; padding: 0;" bgcolor="#FFFFFF">
|
||||||
|
<table width="100%" height="100%" style="min-width: 348px;" border=
|
||||||
|
"0" cellspacing="0" cellpadding="0">
|
||||||
|
<tr height="32px">
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
<tr align="center">
|
||||||
|
<td width="32px"></td>
|
||||||
|
<td>
|
||||||
|
<table border="0" cellspacing="0" cellpadding="0" style=
|
||||||
|
"max-width: 600px;">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
||||||
|
<tr>
|
||||||
|
<td align="left"><img width="92px" height="32px" src=
|
||||||
|
"cid:google_logo" style="display: block;"></td>
|
||||||
|
<td align="right"><img width="32px" height="32px" style=
|
||||||
|
"display: block;" src="cid:keyhole"></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr height="16">
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<table bgcolor="#4184F3" width="100%" border="0" cellspacing="0"
|
||||||
|
cellpadding="0" style=
|
||||||
|
"min-width: 332px; max-width: 600px; border: 1px solid #E0E0E0; border-bottom: 0; border-top-left-radius: 3px; border-top-right-radius: 3px;">
|
||||||
|
<tr>
|
||||||
|
<td height="72px" colspan="3"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td width="32px"></td>
|
||||||
|
<td style=
|
||||||
|
"font-family: Roboto-Regular,Helvetica,Arial,sans-serif; font-size: 24px; color: #FFFFFF; line-height: 1.25;">
|
||||||
|
New sign-in from Chrome on Mac</td>
|
||||||
|
<td width="32px"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td height="18px" colspan="3"></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<table bgcolor="#FAFAFA" width="100%" border="0" cellspacing="0"
|
||||||
|
cellpadding="0" style=
|
||||||
|
"min-width: 332px; max-width: 600px; border: 1px solid #F0F0F0; border-bottom: 1px solid #C0C0C0; border-top: 0; border-bottom-left-radius: 3px; border-bottom-right-radius: 3px;">
|
||||||
|
<tr height="16px">
|
||||||
|
<td width="32px" rowspan="3"></td>
|
||||||
|
<td></td>
|
||||||
|
<td width="32px" rowspan="3"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<table style="min-width: 300px;" border="0" cellspacing="0"
|
||||||
|
cellpadding="0">
|
||||||
|
<tr>
|
||||||
|
<td style=
|
||||||
|
"font-family: Roboto-Regular,Helvetica,Arial,sans-serif; font-size: 13px; color: #202020; line-height: 1.5;">
|
||||||
|
Hi Ben,</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style=
|
||||||
|
"font-family: Roboto-Regular,Helvetica,Arial,sans-serif; font-size: 13px; color: #202020; line-height: 1.5;">
|
||||||
|
Your Google Account careless@foundry376.com was just used to sign
|
||||||
|
in from <span style="white-space:nowrap;">Chrome</span> on
|
||||||
|
<span style="white-space:nowrap;">Mac</span>.
|
||||||
|
<table border="0" cellspacing="0" cellpadding="0" style=
|
||||||
|
"margin-top: 48px; margin-bottom: 48px;">
|
||||||
|
<tr valign="middle">
|
||||||
|
<td width="32px"></td>
|
||||||
|
<td align="center"><img src="cid:profilephoto" width="48px" height=
|
||||||
|
"48px" style="display: block; border-radius: 50%;"></td>
|
||||||
|
<td width="16px"></td>
|
||||||
|
<td style="line-height: 1;"><span style=
|
||||||
|
"font-family: Roboto-Regular,Helvetica,Arial,sans-serif;font-size: 20px; color: #202020;">
|
||||||
|
Ben Gotow (Careless)</span><br>
|
||||||
|
<span style=
|
||||||
|
"font-family: Roboto-Regular,Helvetica,Arial,sans-serif;font-size: 13px; color: #727272;">
|
||||||
|
careless@foundry376.com</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr valign="middle">
|
||||||
|
<td width="32px" height="24px"></td>
|
||||||
|
<td align="center" height="24px"><img src="cid:down_arrow" width=
|
||||||
|
"4px" height="10px" style="display: block;"></td>
|
||||||
|
</tr>
|
||||||
|
<tr valign="top">
|
||||||
|
<td width="32px"></td>
|
||||||
|
<td align="center"><img src="cid:osx" width="48px" height="48px"
|
||||||
|
style="display: block;"></td>
|
||||||
|
<td width="16px"></td>
|
||||||
|
<td style="line-height: 1.5;"><span style=
|
||||||
|
"font-family: Roboto-Regular,Helvetica,Arial,sans-serif; font-size: 16px; color: #202020;">
|
||||||
|
Mac</span><br>
|
||||||
|
<span style=
|
||||||
|
"font-family: Roboto-Regular,Helvetica,Arial,sans-serif; font-size: 13px; color: #727272;">
|
||||||
|
Monday, July 13, 2015 3:49 PM (Pacific Daylight Time)<br>
|
||||||
|
San Francisco, CA, USA*<br>
|
||||||
|
Chrome</span></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<b>Don't recognize this activity?</b><br>
|
||||||
|
Review your <a href=
|
||||||
|
"https://accounts.google.com/AccountChooser?Email=careless@foundry376.com&am%E2%80%A6//security.google.com/settings/security/activity/nt/1436827773000?rfn%3D31"
|
||||||
|
style="text-decoration: none; color: #4285F4;" target=
|
||||||
|
"_blank">recently used devices</a> now.<br>
|
||||||
|
<br>
|
||||||
|
Why are we sending this? We take security very seriously and we
|
||||||
|
want to keep you in the loop on important actions in your
|
||||||
|
account.<br>
|
||||||
|
We were unable to determine whether you have used this browser or
|
||||||
|
device with your account before. This can happen when you sign in
|
||||||
|
for the first time on a new computer, phone or browser, when you
|
||||||
|
use your browser's incognito or private browsing mode or clear your
|
||||||
|
cookies, or when somebody else is accessing your account.</td>
|
||||||
|
</tr>
|
||||||
|
<tr height="32px">
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style=
|
||||||
|
"font-family: Roboto-Regular,Helvetica,Arial,sans-serif; font-size: 13px; color: #202020; line-height: 1.5;">
|
||||||
|
Best,<br>
|
||||||
|
The Google Accounts team</td>
|
||||||
|
</tr>
|
||||||
|
<tr height="16px">
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style=
|
||||||
|
"font-family: Roboto-Regular,Helvetica,Arial,sans-serif; font-size: 12px; color: #B9B9B9; line-height: 1.5;">
|
||||||
|
*The location is approximate and determined by the IP address it
|
||||||
|
was coming from.<br>
|
||||||
|
This email can't receive replies. To give us feedback on this
|
||||||
|
alert, <a href=
|
||||||
|
"https://support.google.com/accounts/contact/device_alert_feedback?hl=en"
|
||||||
|
style="text-decoration: none; color: #4285F4;" target=
|
||||||
|
"_blank">click here</a>.<br>
|
||||||
|
For more information, visit the <a href=
|
||||||
|
"https://support.google.com/accounts/answer/2733203" style=
|
||||||
|
"text-decoration: none; color: #4285F4;" target="_blank">Google
|
||||||
|
Accounts Help Center</a>.</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr height="32px">
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr height="16">
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style=
|
||||||
|
"max-width: 600px; font-family: Roboto-Regular,Helvetica,Arial,sans-serif; font-size: 10px; color: #BCBCBC; line-height: 1.5;">
|
||||||
|
You received this mandatory email service announcement to update
|
||||||
|
you about important changes to your Google product or account.<br>
|
||||||
|
<div style="direction: ltr; text-align: left">© 2015 Google Inc.,
|
||||||
|
1600 Amphitheatre Parkway, Mountain View, CA 94043, USA</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
<td width="32px"></td>
|
||||||
|
</tr>
|
||||||
|
<tr height="32px">
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -0,0 +1,153 @@
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<title></title>
|
||||||
|
|
||||||
|
|
||||||
|
<table width="100%" height="100%" style="min-width: 348px;" border="0" cellspacing="0" cellpadding="0">
|
||||||
|
<tbody><tr height="32px">
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
<tr align="center">
|
||||||
|
<td width="32px"></td>
|
||||||
|
<td>
|
||||||
|
<table border="0" cellspacing="0" cellpadding="0" style="max-width: 600px;">
|
||||||
|
<tbody><tr>
|
||||||
|
<td>
|
||||||
|
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
||||||
|
<tbody><tr>
|
||||||
|
<td align="left"><img width="92px" height="32px" src="cid:google_logo" style="display: block;"></td>
|
||||||
|
<td align="right"><img width="32px" height="32px" style="display: block;" src="cid:keyhole"></td>
|
||||||
|
</tr>
|
||||||
|
</tbody></table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr height="16">
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<table bgcolor="#4184F3" width="100%" border="0" cellspacing="0" cellpadding="0" style="min-width: 332px; max-width: 600px; border: 1px solid #E0E0E0; border-bottom: 0; border-top-left-radius: 3px; border-top-right-radius: 3px;">
|
||||||
|
<tbody><tr>
|
||||||
|
<td height="72px" colspan="3"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td width="32px"></td>
|
||||||
|
<td style="font-family: Roboto-Regular,Helvetica,Arial,sans-serif; font-size: 24px; color: #FFFFFF; line-height: 1.25;">
|
||||||
|
New sign-in from Chrome on Mac</td>
|
||||||
|
<td width="32px"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td height="18px" colspan="3"></td>
|
||||||
|
</tr>
|
||||||
|
</tbody></table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<table bgcolor="#FAFAFA" width="100%" border="0" cellspacing="0" cellpadding="0" style="min-width: 332px; max-width: 600px; border: 1px solid #F0F0F0; border-bottom: 1px solid #C0C0C0; border-top: 0; border-bottom-left-radius: 3px; border-bottom-right-radius: 3px;">
|
||||||
|
<tbody><tr height="16px">
|
||||||
|
<td width="32px" rowspan="3"></td>
|
||||||
|
<td></td>
|
||||||
|
<td width="32px" rowspan="3"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<table style="min-width: 300px;" border="0" cellspacing="0" cellpadding="0">
|
||||||
|
<tbody><tr>
|
||||||
|
<td style="font-family: Roboto-Regular,Helvetica,Arial,sans-serif; font-size: 13px; color: #202020; line-height: 1.5;">
|
||||||
|
Hi Ben,</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="font-family: Roboto-Regular,Helvetica,Arial,sans-serif; font-size: 13px; color: #202020; line-height: 1.5;">
|
||||||
|
Your Google Account <a href="mailto:careless@foundry376.com" title="mailto:careless@foundry376.com">careless@foundry376.com</a> was just used to sign
|
||||||
|
in from <span style="white-space:nowrap;">Chrome</span> on
|
||||||
|
<span style="white-space:nowrap;">Mac</span>.
|
||||||
|
<table border="0" cellspacing="0" cellpadding="0" style="margin-top: 48px; margin-bottom: 48px;">
|
||||||
|
<tbody><tr valign="middle">
|
||||||
|
<td width="32px"></td>
|
||||||
|
<td align="center"><img src="cid:profilephoto" width="48px" height="48px" style="display: block; border-radius: 50%;"></td>
|
||||||
|
<td width="16px"></td>
|
||||||
|
<td style="line-height: 1;"><span style="font-family: Roboto-Regular,Helvetica,Arial,sans-serif;font-size: 20px; color: #202020;">
|
||||||
|
Ben Gotow (Careless)</span><br>
|
||||||
|
<span style="font-family: Roboto-Regular,Helvetica,Arial,sans-serif;font-size: 13px; color: #727272;">
|
||||||
|
<a href="mailto:careless@foundry376.com" title="mailto:careless@foundry376.com">careless@foundry376.com</a></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr valign="middle">
|
||||||
|
<td width="32px" height="24px"></td>
|
||||||
|
<td align="center" height="24px"><img src="cid:down_arrow" width="4px" height="10px" style="display: block;"></td>
|
||||||
|
</tr>
|
||||||
|
<tr valign="top">
|
||||||
|
<td width="32px"></td>
|
||||||
|
<td align="center"><img src="cid:osx" width="48px" height="48px" style="display: block;"></td>
|
||||||
|
<td width="16px"></td>
|
||||||
|
<td style="line-height: 1.5;"><span style="font-family: Roboto-Regular,Helvetica,Arial,sans-serif; font-size: 16px; color: #202020;">
|
||||||
|
Mac</span><br>
|
||||||
|
<span style="font-family: Roboto-Regular,Helvetica,Arial,sans-serif; font-size: 13px; color: #727272;">
|
||||||
|
Monday, July 13, 2015 3:49 PM (Pacific Daylight Time)<br>
|
||||||
|
San Francisco, CA, USA*<br>
|
||||||
|
Chrome</span></td>
|
||||||
|
</tr>
|
||||||
|
</tbody></table>
|
||||||
|
<b>Don't recognize this activity?</b><br>
|
||||||
|
Review your <a href="https://accounts.google.com/AccountChooser?Email=careless@foundry376.com&am%E2%80%A6//security.google.com/settings/security/activity/nt/1436827773000?rfn%3D31" style="text-decoration: none; color: #4285F4;" target="_blank" title="https://accounts.google.com/AccountChooser?Email=careless@foundry376.com&am%E2%80%A6//security.google.com/settings/security/activity/nt/1436827773000?rfn%3D31">recently used devices</a> now.<br>
|
||||||
|
<br>
|
||||||
|
Why are we sending this? We take security very seriously and we
|
||||||
|
want to keep you in the loop on important actions in your
|
||||||
|
account.<br>
|
||||||
|
We were unable to determine whether you have used this browser or
|
||||||
|
device with your account before. This can happen when you sign in
|
||||||
|
for the first time on a new computer, phone or browser, when you
|
||||||
|
use your browser's incognito or private browsing mode or clear your
|
||||||
|
cookies, or when somebody else is accessing your account.</td>
|
||||||
|
</tr>
|
||||||
|
<tr height="32px">
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="font-family: Roboto-Regular,Helvetica,Arial,sans-serif; font-size: 13px; color: #202020; line-height: 1.5;">
|
||||||
|
Best,<br>
|
||||||
|
The Google Accounts team</td>
|
||||||
|
</tr>
|
||||||
|
<tr height="16px">
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="font-family: Roboto-Regular,Helvetica,Arial,sans-serif; font-size: 12px; color: #B9B9B9; line-height: 1.5;">
|
||||||
|
*The location is approximate and determined by the IP address it
|
||||||
|
was coming from.<br>
|
||||||
|
This email can't receive replies. To give us feedback on this
|
||||||
|
alert, <a href="https://support.google.com/accounts/contact/device_alert_feedback?hl=en" style="text-decoration: none; color: #4285F4;" target="_blank" title="https://support.google.com/accounts/contact/device_alert_feedback?hl=en">click here</a>.<br>
|
||||||
|
For more information, visit the <a href="https://support.google.com/accounts/answer/2733203" style="text-decoration: none; color: #4285F4;" target="_blank" title="https://support.google.com/accounts/answer/2733203">Google
|
||||||
|
Accounts Help Center</a>.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody></table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr height="32px">
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</tbody></table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr height="16">
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="max-width: 600px; font-family: Roboto-Regular,Helvetica,Arial,sans-serif; font-size: 10px; color: #BCBCBC; line-height: 1.5;">
|
||||||
|
You received this mandatory email service announcement to update
|
||||||
|
you about important changes to your Google product or account.<br>
|
||||||
|
<div style="direction: ltr; text-align: left">© 2015 Google Inc.,
|
||||||
|
1600 Amphitheatre Parkway, Mountain View, CA 94043, USA</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody></table>
|
||||||
|
</td>
|
||||||
|
<td width="32px"></td>
|
||||||
|
</tr>
|
||||||
|
<tr height="32px">
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</tbody></table>
|
||||||
|
|
||||||
|
|
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
@ -0,0 +1,26 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title></title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
http://apple.com/
|
||||||
|
<div><br></div>
|
||||||
|
<div>https://dropbox.com/</div>
|
||||||
|
<div><br></div>
|
||||||
|
<div>whatever.com</div>
|
||||||
|
<div><br></div>
|
||||||
|
<div>kinda-looks-like-a-link.com</div>
|
||||||
|
<div><br></div>
|
||||||
|
<div>ftp://helloworld.com/asd</div>
|
||||||
|
<div><br></div>
|
||||||
|
<div>540-250-2334</div>
|
||||||
|
<div><br></div>
|
||||||
|
<div>+1-524-123-3333</div>
|
||||||
|
<div><br></div>
|
||||||
|
<div>550.555.1234</div>
|
||||||
|
<div><br></div>
|
||||||
|
<div>bengotow@gmail.com</div>
|
||||||
|
<div><br></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -0,0 +1,28 @@
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<title></title>
|
||||||
|
|
||||||
|
<a href="
|
||||||
|
http://apple.com/" title="
|
||||||
|
http://apple.com/">
|
||||||
|
http://apple.com/</a>
|
||||||
|
<div><br></div>
|
||||||
|
<div><a href="https://dropbox.com/" title="https://dropbox.com/">https://dropbox.com/</a></div>
|
||||||
|
<div><br></div>
|
||||||
|
<div><a href="whatever.com" title="whatever.com">whatever.com</a></div>
|
||||||
|
<div><br></div>
|
||||||
|
<div><a href="kinda-looks-like-a-link.com" title="kinda-looks-like-a-link.com">kinda-looks-like-a-link.com</a></div>
|
||||||
|
<div><br></div>
|
||||||
|
<div><a href="ftp://helloworld.com/asd" title="ftp://helloworld.com/asd">ftp://helloworld.com/asd</a></div>
|
||||||
|
<div><br></div>
|
||||||
|
<div><a href="tel:540-250-2334" title="tel:540-250-2334">540-250-2334</a></div>
|
||||||
|
<div><br></div>
|
||||||
|
<div><a href="tel:+1-524-123-3333" title="tel:+1-524-123-3333">+1-524-123-3333</a></div>
|
||||||
|
<div><br></div>
|
||||||
|
<div><a href="550.555.1234" title="550.555.1234">550.555.1234</a></div>
|
||||||
|
<div><br></div>
|
||||||
|
<div><a href="mailto:bengotow@gmail.com" title="mailto:bengotow@gmail.com">bengotow@gmail.com</a></div>
|
||||||
|
<div><br></div>
|
||||||
|
|
||||||
|
|
24
internal_packages/message-list/spec/autolinker-spec.es6
Normal file
24
internal_packages/message-list/spec/autolinker-spec.es6
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
import {autolink} from '../lib/autolinker';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
describe("autolink", () => {
|
||||||
|
const fixturesDir = path.join(__dirname, 'autolinker-fixtures');
|
||||||
|
fs.readdirSync(fixturesDir).filter(filename =>
|
||||||
|
filename.indexOf('-in.html') !== -1
|
||||||
|
).forEach((filename) => {
|
||||||
|
it(`should properly autolink a variety of email bodies ${filename}`, () => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
const inputPath = path.join(fixturesDir, filename);
|
||||||
|
const expectedPath = inputPath.replace('-in', '-out');
|
||||||
|
|
||||||
|
const input = fs.readFileSync(inputPath).toString();
|
||||||
|
const expected = fs.readFileSync(expectedPath).toString();
|
||||||
|
|
||||||
|
div.innerHTML = input;
|
||||||
|
autolink({body: div});
|
||||||
|
|
||||||
|
expect(div.innerHTML).toEqual(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -14,6 +14,9 @@ RegExpUtils =
|
||||||
# https://en.wikipedia.org/wiki/Email_address#Local_part
|
# https://en.wikipedia.org/wiki/Email_address#Local_part
|
||||||
emailRegex: -> new RegExp(/([a-z.A-Z0-9!#$%&'*+\-/=?^_`{|}~;:]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,63})/g)
|
emailRegex: -> new RegExp(/([a-z.A-Z0-9!#$%&'*+\-/=?^_`{|}~;:]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,63})/g)
|
||||||
|
|
||||||
|
# http://stackoverflow.com/questions/16631571/javascript-regular-expression-detect-all-the-phone-number-from-the-page-source
|
||||||
|
phoneRegex: -> new RegExp(/(?:\+?(\d{1,3}))?[- (]*(\d{3})[- )]*(\d{3})[- ]*(\d{4})(?: *x(\d+))?\b/g)
|
||||||
|
|
||||||
# http://stackoverflow.com/a/16463966
|
# http://stackoverflow.com/a/16463966
|
||||||
# http://www.regexpal.com/?fam=93928
|
# http://www.regexpal.com/?fam=93928
|
||||||
# NOTE: This does not match full urls with `http` protocol components.
|
# NOTE: This does not match full urls with `http` protocol components.
|
||||||
|
@ -22,15 +25,12 @@ RegExpUtils =
|
||||||
# https://www.safaribooksonline.com/library/view/regular-expressions-cookbook/9780596802837/ch07s16.html
|
# https://www.safaribooksonline.com/library/view/regular-expressions-cookbook/9780596802837/ch07s16.html
|
||||||
ipAddressRegex: -> new RegExp(/^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/i)
|
ipAddressRegex: -> new RegExp(/^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/i)
|
||||||
|
|
||||||
# Test cases: https://regex101.com/r/pD7iS5/2
|
# Test cases: https://regex101.com/r/pD7iS5/3
|
||||||
# http://daringfireball.net/2010/07/improved_regex_for_matching_urls
|
|
||||||
# https://mathiasbynens.be/demo/url-regex
|
|
||||||
# This is the Gruber Regex.
|
|
||||||
urlRegex: ({matchEntireString} = {}) ->
|
urlRegex: ({matchEntireString} = {}) ->
|
||||||
if matchEntireString
|
if matchEntireString
|
||||||
new RegExp(/^\b((?:https?:\/\/|www\d{0,3}[.]|[a-z0-9.\-]+[.][a-z]{2,4}\/)(?:[^\s()<>]+|\(([^\s()<>]+|(\([^\s()<>]+\)))*\))+(?:\(([^\s()<>]+|(\([^\s()<>]+\)))*\)|[^\s`!()\[\]{};:'".,<>?«»“”‘’]))$/)
|
new RegExp(/^((([A-Za-z]{3,9}:(?:\/\/)?)(?:[\-;:&=\+\$,\w]+@)?[A-Za-z0-9\.\-]+|(?:[\w\-]+\.|[\-;:&=\+\$,\w]+@)[A-Za-z0-9\.\-]+)((?:\/[\+~%\/\.\w\-_]*)?\??(?:[\-\+=&;%@\.\w_]*)#?(?:[\.\!\/\\\w]*))?)/g)
|
||||||
else
|
else
|
||||||
new RegExp(/\b((?:https?:\/\/|www\d{0,3}[.]|[a-z0-9.\-]+[.][a-z]{2,4}\/)(?:[^\s()<>]+|\(([^\s()<>]+|(\([^\s()<>]+\)))*\))+(?:\(([^\s()<>]+|(\([^\s()<>]+\)))*\)|[^\s`!()\[\]{};:'".,<>?«»“”‘’]))$/)
|
new RegExp(/(?:^|\s)((([A-Za-z]{3,9}:(?:\/\/)?)(?:[\-;:&=\+\$,\w]+@)?[A-Za-z0-9\.\-]+|(?:[\w\-]+\.|[\-;:&=\+\$,\w]+@)[A-Za-z0-9\.\-]+)((?:\/[\+~%\/\.\w\-_]*)?\??(?:[\-\+=&;%@\.\w_]*)#?(?:[\.\!\/\\\w]*))?)/g)
|
||||||
|
|
||||||
# Test cases: https://regex101.com/r/jD5zC7/2
|
# Test cases: https://regex101.com/r/jD5zC7/2
|
||||||
# Returns the following capturing groups:
|
# Returns the following capturing groups:
|
||||||
|
|
Loading…
Add table
Reference in a new issue