diff --git a/.gitmodules b/.gitmodules
index 62876e832359d456fdcc14776227c4a95b32fce0..037914b8e31c8c9c7a6d84d74657b54524ff3513 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -19,3 +19,11 @@
 	path = extras/packaging/update/sparkle/Sparkle
 	url = https://github.com/sparkle-project/Sparkle.git
 	ignore = dirty
+[submodule "3rdparty/md4c"]
+	path = 3rdparty/md4c
+	url = https://github.com/mity/md4c.git
+	ignore = dirty
+[submodule "3rdparty/tidy-html5"]
+	path = 3rdparty/tidy-html5
+	url = https://github.com/htacg/tidy-html5.git
+	ignore = dirty
diff --git a/3rdparty/md4c b/3rdparty/md4c
new file mode 160000
index 0000000000000000000000000000000000000000..e9ff661ff818ee94a4a231958d9b6768dc6882c9
--- /dev/null
+++ b/3rdparty/md4c
@@ -0,0 +1 @@
+Subproject commit e9ff661ff818ee94a4a231958d9b6768dc6882c9
diff --git a/3rdparty/tidy-html5 b/3rdparty/tidy-html5
new file mode 160000
index 0000000000000000000000000000000000000000..d08ddc2860aa95ba8e301343a30837f157977cba
--- /dev/null
+++ b/3rdparty/tidy-html5
@@ -0,0 +1 @@
+Subproject commit d08ddc2860aa95ba8e301343a30837f157977cba
diff --git a/CMakeLists.txt b/CMakeLists.txt
index e9cac8f50dc6d98c4911f4ca7d9cf4ad7b1b35f1..bf3132e709aa7784af9616868d5fcb57b44fcd29 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -29,6 +29,18 @@ else()
   project(jami)
 endif()
 
+option(WITH_DAEMON_SUBMODULE "Build with daemon submodule" OFF)
+option(ENABLE_TESTS "Build with tests" OFF)
+option(WITH_WEBENGINE "Build with WebEngine" ON)
+if(WITH_WEBENGINE)
+  add_definitions(-DWITH_WEBENGINE)
+endif()
+
+# init some variables for includes, libs, etc.
+set(CLIENT_INCLUDE_DIRS, "")
+set(CLIENT_LINK_DIRS, "")
+set(CLIENT_LIBS, "")
+
 set(CMAKE_CXX_STANDARD 17)
 set(CMAKE_CXX_STANDARD_REQUIRED ON)
 set(CMAKE_CXX_FLAGS_DEBUG "-Og -ggdb")
@@ -41,12 +53,9 @@ set(CMAKE_INCLUDE_CURRENT_DIR ON)
 # Main project directories:
 
 # jami-daemon
-if(NOT DEFINED WITH_DAEMON_SUBMODULE)
-  set(WITH_DAEMON_SUBMODULE false)
-  # daemon
+if(NOT WITH_DAEMON_SUBMODULE)
   set(DAEMON_DIR ${PROJECT_SOURCE_DIR}/../daemon)
 else()
-  # daemon
   set(DAEMON_DIR ${PROJECT_SOURCE_DIR}/daemon)
 endif()
 # src
@@ -93,7 +102,6 @@ add_subdirectory(${LIBCLIENT_SRC_DIR})
 set(QT_MODULES
   Quick
   Network
-  NetworkAuth
   Svg
   Gui
   Qml
@@ -106,10 +114,6 @@ set(QT_MODULES
   Widgets
   Positioning)
 
-if(NOT DEFINED WITH_WEBENGINE)
-  set(WITH_WEBENGINE true)
-endif()
-
 if(WITH_WEBENGINE)
   list(APPEND QT_MODULES
     WebEngineCore
@@ -232,7 +236,9 @@ set(COMMON_SOURCES
   ${APP_SRC_DIR}/callparticipantsmodel.cpp
   ${APP_SRC_DIR}/tipsmodel.cpp
   ${APP_SRC_DIR}/positioning.cpp
-  ${APP_SRC_DIR}/currentcall.cpp)
+  ${APP_SRC_DIR}/currentcall.cpp
+  ${APP_SRC_DIR}/messageparser.cpp
+  ${APP_SRC_DIR}/previewengine.cpp)
 
 set(COMMON_HEADERS
   ${APP_SRC_DIR}/avatarimageprovider.h
@@ -293,16 +299,9 @@ set(COMMON_HEADERS
   ${APP_SRC_DIR}/callparticipantsmodel.h
   ${APP_SRC_DIR}/tipsmodel.h
   ${APP_SRC_DIR}/positioning.h
-  ${APP_SRC_DIR}/currentcall.h)
-
-if(WITH_WEBENGINE)
-  list(APPEND COMMON_SOURCES
-    ${APP_SRC_DIR}/previewengine.cpp)
-  add_definitions(-DWITH_WEBENGINE)
-else()
-  list(APPEND COMMON_SOURCES
-    ${APP_SRC_DIR}/nowebengine/previewengine.cpp)
-endif()
+  ${APP_SRC_DIR}/currentcall.h
+  ${APP_SRC_DIR}/messageparser.h
+  ${APP_SRC_DIR}/htmlparser.h)
 
 # For libavutil/avframe.
 set(LIBJAMI_CONTRIB_DIR "${DAEMON_DIR}/contrib")
@@ -494,10 +493,26 @@ if(ENABLE_LIBWRAP)
     ${LIBCLIENT_SRC_DIR}/qtwrapper/instancemanager_wrap.h)
 endif()
 
+# SFPM
 set(BUILD_SFPM_PIC ON CACHE BOOL "enable -fPIC for SFPM" FORCE)
 add_subdirectory(3rdparty/SortFilterProxyModel)
 set(SFPM_OBJECTS $<TARGET_OBJECTS:SortFilterProxyModel>)
 
+# md4c
+set(BUILD_SHARED_LIBS OFF CACHE BOOL "Don't build shared md4c library" FORCE)
+add_subdirectory(3rdparty/md4c EXCLUDE_FROM_ALL)
+list(APPEND CLIENT_LINK_DIRS ${MD4C_BINARY_DIR}/src)
+list(APPEND CLIENT_INCLUDE_DIRS ${MD4C_SOURCE_DIR}/src)
+list(APPEND CLIENT_LIBS md4c-html)
+
+# tidy-html5
+set(BUILD_SHARED_LIB OFF CACHE BOOL "Don't build shared tidy library" FORCE)
+set(SUPPORT_CONSOLE_APP OFF CACHE BOOL "Don't build tidy console app" FORCE)
+add_subdirectory(3rdparty/tidy-html5 EXCLUDE_FROM_ALL)
+list(APPEND CLIENT_LINK_DIRS ${tidy_BINARY_DIR}/Release)
+list(APPEND CLIENT_INCLUDE_DIRS ${tidy_SOURCE_DIR}/include)
+list(APPEND CLIENT_LIBS tidy-static)
+
 # common executable sources
 qt_add_executable(
   ${PROJECT_NAME}
@@ -520,9 +535,7 @@ if(MSVC)
     PROPERTIES
     WIN32_EXECUTABLE TRUE)
 
-  target_link_libraries(
-    ${PROJECT_NAME}
-    PRIVATE
+  list(APPEND CLIENT_LIBS
     ${JAMID_LIB}
     ${GNUTLS_LIB}
     ${LIBCLIENT_NAME}
@@ -559,9 +572,7 @@ if(MSVC)
   # executable name
   set_target_properties(${PROJECT_NAME} PROPERTIES OUTPUT_NAME "Jami")
 elseif (NOT APPLE)
-  target_link_libraries(
-    ${PROJECT_NAME}
-    PRIVATE
+  list(APPEND CLIENT_LIBS
     ${QT_LIBS}
     ${LIBCLIENT_NAME}
     ${qrencode}
@@ -693,13 +704,14 @@ else()
     set(libs ${libs} ${SPARKLE_FRAMEWORK})
   endif(ENABLE_SPARKLE)
   target_sources(${PROJECT_NAME} PRIVATE ${resources})
-  target_link_libraries(${PROJECT_NAME} PRIVATE ${libs})
-  FILE(GLOB CONTRIB ${LIBJAMI_CONTRIB_DIR}/apple-darwin/lib/*.a)
+  list(APPEND CLIENT_LIBS ${libs})
+
+  file(GLOB CONTRIB ${LIBJAMI_CONTRIB_DIR}/apple-darwin/lib/*.a)
+  list(APPEND CLIENT_LIBS ${CONTRIB})
 
-  target_link_libraries(${PROJECT_NAME} PRIVATE ${CONTRIB})
   find_package(Iconv REQUIRED)
-   target_link_libraries(${PROJECT_NAME} PRIVATE Iconv::Iconv)
-  target_link_libraries(${PROJECT_NAME} PRIVATE
+  list(APPEND CLIENT_LIBS Iconv::Iconv)
+  list(APPEND CLIENT_LIBS
     "-framework AVFoundation"
     "-framework CoreAudio -framework CoreMedia -framework CoreVideo"
     "-framework VideoToolbox -framework AudioUnit"
@@ -761,6 +773,10 @@ else()
       endif()
 endif()
 
+target_include_directories(${PROJECT_NAME} PRIVATE ${CLIENT_INCLUDE_DIRS})
+target_link_directories(${PROJECT_NAME} PRIVATE ${CLIENT_LINK_DIRS})
+target_link_libraries(${PROJECT_NAME} PRIVATE ${CLIENT_LIBS})
+
 qt_import_qml_plugins(${PROJECT_NAME})
 qt_finalize_executable(${PROJECT_NAME})
 
diff --git a/extras/packaging/gnu-linux/Jenkinsfile b/extras/packaging/gnu-linux/Jenkinsfile
index 87dff35bf4818e488d83e19a9b82c0b74255bf15..566a5bf49a5f5ee5077004fe42780ca62f9aab58 100644
--- a/extras/packaging/gnu-linux/Jenkinsfile
+++ b/extras/packaging/gnu-linux/Jenkinsfile
@@ -34,6 +34,8 @@
 // Configuration globals.
 def SUBMODULES = ['daemon',
                   '3rdparty/SortFilterProxyModel',
+                  '3rdparty/md4c',
+                  '3rdparty/tidy-html5',
                   '3rdparty/qrencode-win32',
                   'extras/packaging/update/sparkle/Sparkle']
 def TARGETS = [:]
diff --git a/resources/webengine/linkify-string.js b/resources/webengine/linkify-string.js
deleted file mode 100644
index b7fb03f8991333cda04175c308b5d94cbbb1e0e0..0000000000000000000000000000000000000000
--- a/resources/webengine/linkify-string.js
+++ /dev/null
@@ -1,100 +0,0 @@
-/*
-* Copyright (c) 2021 SoapBox Innovations Inc.
-*
-* Permission is hereby granted, free of charge, to any person obtaining a copy
-* of this software and associated documentation files (the "Software"), to deal
-* in the Software without restriction, including without limitation the rights
-* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-* copies of the Software, and to permit persons to whom the Software is
-* furnished to do so, subject to the following conditions:
-*
-* The above copyright notice and this permission notice shall be included in
-* all copies or substantial portions of the Software.
-*
-* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-* THE SOFTWARE.
-*/
-var linkifyStr = (function (linkifyjs) {
-	'use strict';
-
-	/**
-		Convert strings of text into linkable HTML text
-	*/
-
-	function escapeText(text) {
-	  return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
-	}
-
-	function escapeAttr(href) {
-	  return href.replace(/"/g, '&quot;');
-	}
-
-	function attributesToString(attributes) {
-	  var result = [];
-
-	  for (var attr in attributes) {
-	    var val = attributes[attr] + '';
-	    result.push(attr + "=\"" + escapeAttr(val) + "\"");
-	  }
-
-	  return result.join(' ');
-	}
-
-	function defaultRender(_ref) {
-	  var tagName = _ref.tagName,
-	      attributes = _ref.attributes,
-	      content = _ref.content;
-	  return "<" + tagName + " " + attributesToString(attributes) + ">" + escapeText(content) + "</" + tagName + ">";
-	}
-	/**
-	 * Convert a plan text string to an HTML string with links. Expects that the
-	 * given strings does not contain any HTML entities. Use the linkify-html
-	 * interface if you need to parse HTML entities.
-	 *
-	 * @param {string} str string to linkify
-	 * @param {import('linkifyjs').Opts} [opts] overridable options
-	 * @returns {string}
-	 */
-
-
-	function linkifyStr(str, opts) {
-	  if (opts === void 0) {
-	    opts = {};
-	  }
-
-	  opts = new linkifyjs.Options(opts, defaultRender);
-	  var tokens = linkifyjs.tokenize(str);
-	  var result = [];
-
-	  for (var i = 0; i < tokens.length; i++) {
-	    var token = tokens[i];
-
-	    if (token.t === 'nl' && opts.get('nl2br')) {
-	      result.push('<br>\n');
-	    } else if (!token.isLink || !opts.check(token)) {
-	      result.push(escapeText(token.toString()));
-	    } else {
-	      result.push(opts.render(token));
-	    }
-	  }
-
-	  return result.join('');
-	}
-
-	if (!String.prototype.linkify) {
-	  Object.defineProperty(String.prototype, 'linkify', {
-	    writable: false,
-	    value: function linkify(options) {
-	      return linkifyStr(this, options);
-	    }
-	  });
-	}
-
-	return linkifyStr;
-
-})(linkify);
\ No newline at end of file
diff --git a/resources/webengine/linkify.js b/resources/webengine/linkify.js
deleted file mode 100644
index 7363cbdb42424d37879257ae390deeb917deeff7..0000000000000000000000000000000000000000
--- a/resources/webengine/linkify.js
+++ /dev/null
@@ -1,3501 +0,0 @@
-/*
-* Copyright (c) 2021 SoapBox Innovations Inc.
-*
-* Permission is hereby granted, free of charge, to any person obtaining a copy
-* of this software and associated documentation files (the "Software"), to deal
-* in the Software without restriction, including without limitation the rights
-* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-* copies of the Software, and to permit persons to whom the Software is
-* furnished to do so, subject to the following conditions:
-*
-* The above copyright notice and this permission notice shall be included in
-* all copies or substantial portions of the Software.
-*
-* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-* THE SOFTWARE.
-*/
-var linkify = (function (exports) {
-	'use strict';
-
-	// THIS FILE IS AUTOMATICALLY GENERATED DO NOT EDIT DIRECTLY
-	// https://data.iana.org/TLD/tlds-alpha-by-domain.txt
-	var tlds = 'aaa \
-aarp \
-abarth \
-abb \
-abbott \
-abbvie \
-abc \
-able \
-abogado \
-abudhabi \
-ac \
-academy \
-accenture \
-accountant \
-accountants \
-aco \
-actor \
-ad \
-adac \
-ads \
-adult \
-ae \
-aeg \
-aero \
-aetna \
-af \
-afl \
-africa \
-ag \
-agakhan \
-agency \
-ai \
-aig \
-airbus \
-airforce \
-airtel \
-akdn \
-al \
-alfaromeo \
-alibaba \
-alipay \
-allfinanz \
-allstate \
-ally \
-alsace \
-alstom \
-am \
-amazon \
-americanexpress \
-americanfamily \
-amex \
-amfam \
-amica \
-amsterdam \
-analytics \
-android \
-anquan \
-anz \
-ao \
-aol \
-apartments \
-app \
-apple \
-aq \
-aquarelle \
-ar \
-arab \
-aramco \
-archi \
-army \
-arpa \
-art \
-arte \
-as \
-asda \
-asia \
-associates \
-at \
-athleta \
-attorney \
-au \
-auction \
-audi \
-audible \
-audio \
-auspost \
-author \
-auto \
-autos \
-avianca \
-aw \
-aws \
-ax \
-axa \
-az \
-azure \
-ba \
-baby \
-baidu \
-banamex \
-bananarepublic \
-band \
-bank \
-bar \
-barcelona \
-barclaycard \
-barclays \
-barefoot \
-bargains \
-baseball \
-basketball \
-bauhaus \
-bayern \
-bb \
-bbc \
-bbt \
-bbva \
-bcg \
-bcn \
-bd \
-be \
-beats \
-beauty \
-beer \
-bentley \
-berlin \
-best \
-bestbuy \
-bet \
-bf \
-bg \
-bh \
-bharti \
-bi \
-bible \
-bid \
-bike \
-bing \
-bingo \
-bio \
-biz \
-bj \
-black \
-blackfriday \
-blockbuster \
-blog \
-bloomberg \
-blue \
-bm \
-bms \
-bmw \
-bn \
-bnpparibas \
-bo \
-boats \
-boehringer \
-bofa \
-bom \
-bond \
-boo \
-book \
-booking \
-bosch \
-bostik \
-boston \
-bot \
-boutique \
-box \
-br \
-bradesco \
-bridgestone \
-broadway \
-broker \
-brother \
-brussels \
-bs \
-bt \
-bugatti \
-build \
-builders \
-business \
-buy \
-buzz \
-bv \
-bw \
-by \
-bz \
-bzh \
-ca \
-cab \
-cafe \
-cal \
-call \
-calvinklein \
-cam \
-camera \
-camp \
-cancerresearch \
-canon \
-capetown \
-capital \
-capitalone \
-car \
-caravan \
-cards \
-care \
-career \
-careers \
-cars \
-casa \
-case \
-cash \
-casino \
-cat \
-catering \
-catholic \
-cba \
-cbn \
-cbre \
-cbs \
-cc \
-cd \
-center \
-ceo \
-cern \
-cf \
-cfa \
-cfd \
-cg \
-ch \
-chanel \
-channel \
-charity \
-chase \
-chat \
-cheap \
-chintai \
-christmas \
-chrome \
-church \
-ci \
-cipriani \
-circle \
-cisco \
-citadel \
-citi \
-citic \
-city \
-cityeats \
-ck \
-cl \
-claims \
-cleaning \
-click \
-clinic \
-clinique \
-clothing \
-cloud \
-club \
-clubmed \
-cm \
-cn \
-co \
-coach \
-codes \
-coffee \
-college \
-cologne \
-com \
-comcast \
-commbank \
-community \
-company \
-compare \
-computer \
-comsec \
-condos \
-construction \
-consulting \
-contact \
-contractors \
-cooking \
-cookingchannel \
-cool \
-coop \
-corsica \
-country \
-coupon \
-coupons \
-courses \
-cpa \
-cr \
-credit \
-creditcard \
-creditunion \
-cricket \
-crown \
-crs \
-cruise \
-cruises \
-cu \
-cuisinella \
-cv \
-cw \
-cx \
-cy \
-cymru \
-cyou \
-cz \
-dabur \
-dad \
-dance \
-data \
-date \
-dating \
-datsun \
-day \
-dclk \
-dds \
-de \
-deal \
-dealer \
-deals \
-degree \
-delivery \
-dell \
-deloitte \
-delta \
-democrat \
-dental \
-dentist \
-desi \
-design \
-dev \
-dhl \
-diamonds \
-diet \
-digital \
-direct \
-directory \
-discount \
-discover \
-dish \
-diy \
-dj \
-dk \
-dm \
-dnp \
-do \
-docs \
-doctor \
-dog \
-domains \
-dot \
-download \
-drive \
-dtv \
-dubai \
-dunlop \
-dupont \
-durban \
-dvag \
-dvr \
-dz \
-earth \
-eat \
-ec \
-eco \
-edeka \
-edu \
-education \
-ee \
-eg \
-email \
-emerck \
-energy \
-engineer \
-engineering \
-enterprises \
-epson \
-equipment \
-er \
-ericsson \
-erni \
-es \
-esq \
-estate \
-et \
-etisalat \
-eu \
-eurovision \
-eus \
-events \
-exchange \
-expert \
-exposed \
-express \
-extraspace \
-fage \
-fail \
-fairwinds \
-faith \
-family \
-fan \
-fans \
-farm \
-farmers \
-fashion \
-fast \
-fedex \
-feedback \
-ferrari \
-ferrero \
-fi \
-fiat \
-fidelity \
-fido \
-film \
-final \
-finance \
-financial \
-fire \
-firestone \
-firmdale \
-fish \
-fishing \
-fit \
-fitness \
-fj \
-fk \
-flickr \
-flights \
-flir \
-florist \
-flowers \
-fly \
-fm \
-fo \
-foo \
-food \
-foodnetwork \
-football \
-ford \
-forex \
-forsale \
-forum \
-foundation \
-fox \
-fr \
-free \
-fresenius \
-frl \
-frogans \
-frontdoor \
-frontier \
-ftr \
-fujitsu \
-fun \
-fund \
-furniture \
-futbol \
-fyi \
-ga \
-gal \
-gallery \
-gallo \
-gallup \
-game \
-games \
-gap \
-garden \
-gay \
-gb \
-gbiz \
-gd \
-gdn \
-ge \
-gea \
-gent \
-genting \
-george \
-gf \
-gg \
-ggee \
-gh \
-gi \
-gift \
-gifts \
-gives \
-giving \
-gl \
-glass \
-gle \
-global \
-globo \
-gm \
-gmail \
-gmbh \
-gmo \
-gmx \
-gn \
-godaddy \
-gold \
-goldpoint \
-golf \
-goo \
-goodyear \
-goog \
-google \
-gop \
-got \
-gov \
-gp \
-gq \
-gr \
-grainger \
-graphics \
-gratis \
-green \
-gripe \
-grocery \
-group \
-gs \
-gt \
-gu \
-guardian \
-gucci \
-guge \
-guide \
-guitars \
-guru \
-gw \
-gy \
-hair \
-hamburg \
-hangout \
-haus \
-hbo \
-hdfc \
-hdfcbank \
-health \
-healthcare \
-help \
-helsinki \
-here \
-hermes \
-hgtv \
-hiphop \
-hisamitsu \
-hitachi \
-hiv \
-hk \
-hkt \
-hm \
-hn \
-hockey \
-holdings \
-holiday \
-homedepot \
-homegoods \
-homes \
-homesense \
-honda \
-horse \
-hospital \
-host \
-hosting \
-hot \
-hoteles \
-hotels \
-hotmail \
-house \
-how \
-hr \
-hsbc \
-ht \
-hu \
-hughes \
-hyatt \
-hyundai \
-ibm \
-icbc \
-ice \
-icu \
-id \
-ie \
-ieee \
-ifm \
-ikano \
-il \
-im \
-imamat \
-imdb \
-immo \
-immobilien \
-in \
-inc \
-industries \
-infiniti \
-info \
-ing \
-ink \
-institute \
-insurance \
-insure \
-int \
-international \
-intuit \
-investments \
-io \
-ipiranga \
-iq \
-ir \
-irish \
-is \
-ismaili \
-ist \
-istanbul \
-it \
-itau \
-itv \
-jaguar \
-java \
-jcb \
-je \
-jeep \
-jetzt \
-jewelry \
-jio \
-jll \
-jm \
-jmp \
-jnj \
-jo \
-jobs \
-joburg \
-jot \
-joy \
-jp \
-jpmorgan \
-jprs \
-juegos \
-juniper \
-kaufen \
-kddi \
-ke \
-kerryhotels \
-kerrylogistics \
-kerryproperties \
-kfh \
-kg \
-kh \
-ki \
-kia \
-kids \
-kim \
-kinder \
-kindle \
-kitchen \
-kiwi \
-km \
-kn \
-koeln \
-komatsu \
-kosher \
-kp \
-kpmg \
-kpn \
-kr \
-krd \
-kred \
-kuokgroup \
-kw \
-ky \
-kyoto \
-kz \
-la \
-lacaixa \
-lamborghini \
-lamer \
-lancaster \
-lancia \
-land \
-landrover \
-lanxess \
-lasalle \
-lat \
-latino \
-latrobe \
-law \
-lawyer \
-lb \
-lc \
-lds \
-lease \
-leclerc \
-lefrak \
-legal \
-lego \
-lexus \
-lgbt \
-li \
-lidl \
-life \
-lifeinsurance \
-lifestyle \
-lighting \
-like \
-lilly \
-limited \
-limo \
-lincoln \
-linde \
-link \
-lipsy \
-live \
-living \
-lk \
-llc \
-llp \
-loan \
-loans \
-locker \
-locus \
-loft \
-lol \
-london \
-lotte \
-lotto \
-love \
-lpl \
-lplfinancial \
-lr \
-ls \
-lt \
-ltd \
-ltda \
-lu \
-lundbeck \
-luxe \
-luxury \
-lv \
-ly \
-ma \
-macys \
-madrid \
-maif \
-maison \
-makeup \
-man \
-management \
-mango \
-map \
-market \
-marketing \
-markets \
-marriott \
-marshalls \
-maserati \
-mattel \
-mba \
-mc \
-mckinsey \
-md \
-me \
-med \
-media \
-meet \
-melbourne \
-meme \
-memorial \
-men \
-menu \
-merckmsd \
-mg \
-mh \
-miami \
-microsoft \
-mil \
-mini \
-mint \
-mit \
-mitsubishi \
-mk \
-ml \
-mlb \
-mls \
-mm \
-mma \
-mn \
-mo \
-mobi \
-mobile \
-moda \
-moe \
-moi \
-mom \
-monash \
-money \
-monster \
-mormon \
-mortgage \
-moscow \
-moto \
-motorcycles \
-mov \
-movie \
-mp \
-mq \
-mr \
-ms \
-msd \
-mt \
-mtn \
-mtr \
-mu \
-museum \
-music \
-mutual \
-mv \
-mw \
-mx \
-my \
-mz \
-na \
-nab \
-nagoya \
-name \
-natura \
-navy \
-nba \
-nc \
-ne \
-nec \
-net \
-netbank \
-netflix \
-network \
-neustar \
-new \
-news \
-next \
-nextdirect \
-nexus \
-nf \
-nfl \
-ng \
-ngo \
-nhk \
-ni \
-nico \
-nike \
-nikon \
-ninja \
-nissan \
-nissay \
-nl \
-no \
-nokia \
-northwesternmutual \
-norton \
-now \
-nowruz \
-nowtv \
-np \
-nr \
-nra \
-nrw \
-ntt \
-nu \
-nyc \
-nz \
-obi \
-observer \
-office \
-okinawa \
-olayan \
-olayangroup \
-oldnavy \
-ollo \
-om \
-omega \
-one \
-ong \
-onl \
-online \
-ooo \
-open \
-oracle \
-orange \
-org \
-organic \
-origins \
-osaka \
-otsuka \
-ott \
-ovh \
-pa \
-page \
-panasonic \
-paris \
-pars \
-partners \
-parts \
-party \
-passagens \
-pay \
-pccw \
-pe \
-pet \
-pf \
-pfizer \
-pg \
-ph \
-pharmacy \
-phd \
-philips \
-phone \
-photo \
-photography \
-photos \
-physio \
-pics \
-pictet \
-pictures \
-pid \
-pin \
-ping \
-pink \
-pioneer \
-pizza \
-pk \
-pl \
-place \
-play \
-playstation \
-plumbing \
-plus \
-pm \
-pn \
-pnc \
-pohl \
-poker \
-politie \
-porn \
-post \
-pr \
-pramerica \
-praxi \
-press \
-prime \
-pro \
-prod \
-productions \
-prof \
-progressive \
-promo \
-properties \
-property \
-protection \
-pru \
-prudential \
-ps \
-pt \
-pub \
-pw \
-pwc \
-py \
-qa \
-qpon \
-quebec \
-quest \
-racing \
-radio \
-re \
-read \
-realestate \
-realtor \
-realty \
-recipes \
-red \
-redstone \
-redumbrella \
-rehab \
-reise \
-reisen \
-reit \
-reliance \
-ren \
-rent \
-rentals \
-repair \
-report \
-republican \
-rest \
-restaurant \
-review \
-reviews \
-rexroth \
-rich \
-richardli \
-ricoh \
-ril \
-rio \
-rip \
-ro \
-rocher \
-rocks \
-rodeo \
-rogers \
-room \
-rs \
-rsvp \
-ru \
-rugby \
-ruhr \
-run \
-rw \
-rwe \
-ryukyu \
-sa \
-saarland \
-safe \
-safety \
-sakura \
-sale \
-salon \
-samsclub \
-samsung \
-sandvik \
-sandvikcoromant \
-sanofi \
-sap \
-sarl \
-sas \
-save \
-saxo \
-sb \
-sbi \
-sbs \
-sc \
-sca \
-scb \
-schaeffler \
-schmidt \
-scholarships \
-school \
-schule \
-schwarz \
-science \
-scot \
-sd \
-se \
-search \
-seat \
-secure \
-security \
-seek \
-select \
-sener \
-services \
-ses \
-seven \
-sew \
-sex \
-sexy \
-sfr \
-sg \
-sh \
-shangrila \
-sharp \
-shaw \
-shell \
-shia \
-shiksha \
-shoes \
-shop \
-shopping \
-shouji \
-show \
-showtime \
-si \
-silk \
-sina \
-singles \
-site \
-sj \
-sk \
-ski \
-skin \
-sky \
-skype \
-sl \
-sling \
-sm \
-smart \
-smile \
-sn \
-sncf \
-so \
-soccer \
-social \
-softbank \
-software \
-sohu \
-solar \
-solutions \
-song \
-sony \
-soy \
-spa \
-space \
-sport \
-spot \
-sr \
-srl \
-ss \
-st \
-stada \
-staples \
-star \
-statebank \
-statefarm \
-stc \
-stcgroup \
-stockholm \
-storage \
-store \
-stream \
-studio \
-study \
-style \
-su \
-sucks \
-supplies \
-supply \
-support \
-surf \
-surgery \
-suzuki \
-sv \
-swatch \
-swiss \
-sx \
-sy \
-sydney \
-systems \
-sz \
-tab \
-taipei \
-talk \
-taobao \
-target \
-tatamotors \
-tatar \
-tattoo \
-tax \
-taxi \
-tc \
-tci \
-td \
-tdk \
-team \
-tech \
-technology \
-tel \
-temasek \
-tennis \
-teva \
-tf \
-tg \
-th \
-thd \
-theater \
-theatre \
-tiaa \
-tickets \
-tienda \
-tiffany \
-tips \
-tires \
-tirol \
-tj \
-tjmaxx \
-tjx \
-tk \
-tkmaxx \
-tl \
-tm \
-tmall \
-tn \
-to \
-today \
-tokyo \
-tools \
-top \
-toray \
-toshiba \
-total \
-tours \
-town \
-toyota \
-toys \
-tr \
-trade \
-trading \
-training \
-travel \
-travelchannel \
-travelers \
-travelersinsurance \
-trust \
-trv \
-tt \
-tube \
-tui \
-tunes \
-tushu \
-tv \
-tvs \
-tw \
-tz \
-ua \
-ubank \
-ubs \
-ug \
-uk \
-unicom \
-university \
-uno \
-uol \
-ups \
-us \
-uy \
-uz \
-va \
-vacations \
-vana \
-vanguard \
-vc \
-ve \
-vegas \
-ventures \
-verisign \
-vermögensberater \
-vermögensberatung \
-versicherung \
-vet \
-vg \
-vi \
-viajes \
-video \
-vig \
-viking \
-villas \
-vin \
-vip \
-virgin \
-visa \
-vision \
-viva \
-vivo \
-vlaanderen \
-vn \
-vodka \
-volkswagen \
-volvo \
-vote \
-voting \
-voto \
-voyage \
-vu \
-vuelos \
-wales \
-walmart \
-walter \
-wang \
-wanggou \
-watch \
-watches \
-weather \
-weatherchannel \
-webcam \
-weber \
-website \
-wed \
-wedding \
-weibo \
-weir \
-wf \
-whoswho \
-wien \
-wiki \
-williamhill \
-win \
-windows \
-wine \
-winners \
-wme \
-wolterskluwer \
-woodside \
-work \
-works \
-world \
-wow \
-ws \
-wtc \
-wtf \
-xbox \
-xerox \
-xfinity \
-xihuan \
-xin \
-xxx \
-xyz \
-yachts \
-yahoo \
-yamaxun \
-yandex \
-ye \
-yodobashi \
-yoga \
-yokohama \
-you \
-youtube \
-yt \
-yun \
-za \
-zappos \
-zara \
-zero \
-zip \
-zm \
-zone \
-zuerich \
-zw'.split(' '); // Internationalized domain names containing non-ASCII
-
-	var utlds = 'ελ \
-ευ \
-бг \
-бел \
-дети \
-ею \
-католик \
-ком \
-мкд \
-мон \
-москва \
-онлайн \
-орг \
-рус \
-рф \
-сайт \
-срб \
-укр \
-қаз \
-Õ°Õ¡Õµ \
-ישראל \
-קום \
-ابوظبي \
-اتصالات \
-ارامكو \
-الاردن \
-البحرين \
-الجزائر \
-السعودية \
-العليان \
-المغرب \
-امارات \
-ایران \
-بارت \
-بازار \
-بيتك \
-بھارت \
-تونس \
-سودان \
-سورية \
-شبكة \
-عراق \
-عرب \
-عمان \
-فلسطين \
-قطر \
-كاثوليك \
-كوم \
-مصر \
-مليسيا \
-موريتانيا \
-موقع \
-همراه \
-پاکستان \
-ڀارت \
-कॉम \
-नेट \
-भारत \
-भारतम् \
-भारोत \
-संगठन \
-বাংলা \
-ভারত \
-ভাৰত \
-ਭਾਰਤ \
-ભારત \
-ଭାରତ \
-இந்தியா \
-இலங்கை \
-சிங்கப்பூர் \
-భారత్ \
-ಭಾರತ \
-ഭാരതം \
-ලංකා \
-คอม \
-ไทย \
-ລາວ \
-გე \
-みんな \
-アマゾン \
-クラウド \
-グーグル \
-コム \
-ストア \
-セール \
-ファッション \
-ポイント \
-世界 \
-中信 \
-中国 \
-中國 \
-中文网 \
-亚马逊 \
-企业 \
-佛山 \
-信息 \
-健康 \
-八卦 \
-公司 \
-公益 \
-台湾 \
-台灣 \
-商城 \
-商店 \
-商标 \
-嘉里 \
-嘉里大酒店 \
-在线 \
-大拿 \
-天主教 \
-娱乐 \
-å®¶é›» \
-广东 \
-微博 \
-慈善 \
-我爱你 \
-手机 \
-招聘 \
-政务 \
-政府 \
-新加坡 \
-æ–°é—» \
-æ—¶å°š \
-書籍 \
-机构 \
-淡马锡 \
-游戏 \
-澳門 \
-点看 \
-移动 \
-组织机构 \
-网址 \
-网店 \
-网站 \
-网络 \
-联通 \
-诺基亚 \
-谷歌 \
-购物 \
-通販 \
-集团 \
-電訊盈科 \
-飞利浦 \
-食品 \
-餐厅 \
-香格里拉 \
-香港 \
-ë‹·ë„· \
-ë‹·ì»´ \
-삼성 \
-한국'.split(' ');
-
-	/**
-	 * @template A
-	 * @template B
-	 * @param {A} target
-	 * @param {B} properties
-	 * @return {A & B}
-	 */
-	var assign = function assign(target, properties) {
-	  for (var key in properties) {
-	    target[key] = properties[key];
-	  }
-
-	  return target;
-	};
-
-	/**
-	 * Finite State Machine generation utilities
-	 */
-	/**
-	 * @template T
-	 * @typedef {{ [group: string]: T[] }} Collections
-	 */
-
-	/**
-	 * @typedef {{ [group: string]: true }} Flags
-	 */
-	// Keys in scanner Collections instances
-
-	var numeric = 'numeric';
-	var ascii = 'ascii';
-	var alpha = 'alpha';
-	var asciinumeric = 'asciinumeric';
-	var alphanumeric = 'alphanumeric';
-	var domain = 'domain';
-	var emoji = 'emoji';
-	var scheme = 'scheme';
-	var slashscheme = 'slashscheme';
-	var whitespace = 'whitespace';
-	/**
-	 * @template T
-	 * @param {string} name
-	 * @param {Collections<T>} groups to register in
-	 * @returns {T[]} Current list of tokens in the given collection
-	 */
-
-	function registerGroup(name, groups) {
-	  if (!(name in groups)) {
-	    groups[name] = [];
-	  }
-
-	  return groups[name];
-	}
-	/**
-	 * @template T
-	 * @param {T} t token to add
-	 * @param {Collections<T>} groups
-	 * @param {Flags} flags
-	 */
-
-
-	function addToGroups(t, flags, groups) {
-	  if (flags[numeric]) {
-	    flags[asciinumeric] = true;
-	    flags[alphanumeric] = true;
-	  }
-
-	  if (flags[ascii]) {
-	    flags[asciinumeric] = true;
-	    flags[alpha] = true;
-	  }
-
-	  if (flags[asciinumeric]) {
-	    flags[alphanumeric] = true;
-	  }
-
-	  if (flags[alpha]) {
-	    flags[alphanumeric] = true;
-	  }
-
-	  if (flags[alphanumeric]) {
-	    flags[domain] = true;
-	  }
-
-	  if (flags[emoji]) {
-	    flags[domain] = true;
-	  }
-
-	  for (var k in flags) {
-	    var group = registerGroup(k, groups);
-
-	    if (group.indexOf(t) < 0) {
-	      group.push(t);
-	    }
-	  }
-	}
-	/**
-	 * @template T
-	 * @param {T} t token to check
-	 * @param {Collections<T>} groups
-	 * @returns {Flags} group flags that contain this token
-	 */
-
-	function flagsForToken(t, groups) {
-	  var result = {};
-
-	  for (var c in groups) {
-	    if (groups[c].indexOf(t) >= 0) {
-	      result[c] = true;
-	    }
-	  }
-
-	  return result;
-	}
-	/**
-	 * @template T
-	 * @typedef {null | T } Transition
-	 */
-
-	/**
-	 * Define a basic state machine state. j is the list of character transitions,
-	 * jr is the list of regex-match transitions, jd is the default state to
-	 * transition to t is the accepting token type, if any. If this is the terminal
-	 * state, then it does not emit a token.
-	 *
-	 * The template type T represents the type of the token this state accepts. This
-	 * should be a string (such as of the token exports in `text.js`) or a
-	 * MultiToken subclass (from `multi.js`)
-	 *
-	 * @template T
-	 * @param {T} [token] Token that this state emits
-	 */
-
-
-	function State(token) {
-	  if (token === void 0) {
-	    token = null;
-	  }
-
-	  // this.n = null; // DEBUG: State name
-
-	  /** @type {{ [input: string]: State<T> }} j */
-	  this.j = {}; // IMPLEMENTATION 1
-	  // this.j = []; // IMPLEMENTATION 2
-
-	  /** @type {[RegExp, State<T>][]} jr */
-
-	  this.jr = [];
-	  /** @type {?State<T>} jd */
-
-	  this.jd = null;
-	  /** @type {?T} t */
-
-	  this.t = token;
-	}
-	/**
-	 * Scanner token groups
-	 * @type Collections<string>
-	 */
-
-	State.groups = {};
-	State.prototype = {
-	  accepts: function accepts() {
-	    return !!this.t;
-	  },
-
-	  /**
-	   * Follow an existing transition from the given input to the next state.
-	   * Does not mutate.
-	   * @param {string} input character or token type to transition on
-	   * @returns {?State<T>} the next state, if any
-	   */
-	  go: function go(input) {
-	    var state = this;
-	    var nextState = state.j[input];
-
-	    if (nextState) {
-	      return nextState;
-	    }
-
-	    for (var i = 0; i < state.jr.length; i++) {
-	      var regex = state.jr[i][0];
-	      var _nextState = state.jr[i][1]; // note: might be empty to prevent default jump
-
-	      if (_nextState && regex.test(input)) {
-	        return _nextState;
-	      }
-	    } // Nowhere left to jump! Return default, if any
-
-
-	    return state.jd;
-	  },
-
-	  /**
-	   * Whether the state has a transition for the given input. Set the second
-	   * argument to true to only look for an exact match (and not a default or
-	   * regular-expression-based transition)
-	   * @param {string} input
-	   * @param {boolean} exactOnly
-	   */
-	  has: function has(input, exactOnly) {
-	    if (exactOnly === void 0) {
-	      exactOnly = false;
-	    }
-
-	    return exactOnly ? input in this.j : !!this.go(input);
-	  },
-
-	  /**
-	   * Short for "transition all"; create a transition from the array of items
-	   * in the given list to the same final resulting state.
-	   * @param {string | string[]} inputs Group of inputs to transition on
-	   * @param {Transition<T> | State<T>} [next] Transition options
-	   * @param {Flags} [flags] Collections flags to add token to
-	   * @param {Collections<T>} [groups] Master list of token groups
-	   */
-	  ta: function ta(inputs, next, flags, groups) {
-	    for (var i = 0; i < inputs.length; i++) {
-	      this.tt(inputs[i], next, flags, groups);
-	    }
-	  },
-
-	  /**
-	   * Short for "take regexp transition"; defines a transition for this state
-	   * when it encounters a token which matches the given regular expression
-	   * @param {RegExp} regexp Regular expression transition (populate first)
-	   * @param {T | State<T>} [next] Transition options
-	   * @param {Flags} [flags] Collections flags to add token to
-	   * @param {Collections<T>} [groups] Master list of token groups
-	   * @returns {State<T>} taken after the given input
-	   */
-	  tr: function tr(regexp, next, flags, groups) {
-	    groups = groups || State.groups;
-	    var nextState;
-
-	    if (next && next.j) {
-	      nextState = next;
-	    } else {
-	      // Token with maybe token groups
-	      nextState = new State(next);
-
-	      if (flags && groups) {
-	        addToGroups(next, flags, groups);
-	      }
-	    }
-
-	    this.jr.push([regexp, nextState]);
-	    return nextState;
-	  },
-
-	  /**
-	   * Short for "take transitions", will take as many sequential transitions as
-	   * the length of the given input and returns the
-	   * resulting final state.
-	   * @param {string | string[]} input
-	   * @param {T | State<T>} [next] Transition options
-	   * @param {Flags} [flags] Collections flags to add token to
-	   * @param {Collections<T>} [groups] Master list of token groups
-	   * @returns {State<T>} taken after the given input
-	   */
-	  ts: function ts(input, next, flags, groups) {
-	    var state = this;
-	    var len = input.length;
-
-	    if (!len) {
-	      return state;
-	    }
-
-	    for (var i = 0; i < len - 1; i++) {
-	      state = state.tt(input[i]);
-	    }
-
-	    return state.tt(input[len - 1], next, flags, groups);
-	  },
-
-	  /**
-	   * Short for "take transition", this is a method for building/working with
-	   * state machines.
-	   *
-	   * If a state already exists for the given input, returns it.
-	   *
-	   * If a token is specified, that state will emit that token when reached by
-	   * the linkify engine.
-	   *
-	   * If no state exists, it will be initialized with some default transitions
-	   * that resemble existing default transitions.
-	   *
-	   * If a state is given for the second argument, that state will be
-	   * transitioned to on the given input regardless of what that input
-	   * previously did.
-	   *
-	   * Specify a token group flags to define groups that this token belongs to.
-	   * The token will be added to corresponding entires in the given groups
-	   * object.
-	   *
-	   * @param {string} input character, token type to transition on
-	   * @param {T | State<T>} [next] Transition options
-	   * @param {Flags} [flags] Collections flags to add token to
-	   * @param {Collections<T>} [groups] Master list of groups
-	   * @returns {State<T>} taken after the given input
-	   */
-	  tt: function tt(input, next, flags, groups) {
-	    groups = groups || State.groups;
-	    var state = this; // Check if existing state given, just a basic transition
-
-	    if (next && next.j) {
-	      state.j[input] = next;
-	      return next;
-	    }
-
-	    var t = next; // Take the transition with the usual default mechanisms and use that as
-	    // a template for creating the next state
-
-	    var nextState,
-	        templateState = state.go(input);
-
-	    if (templateState) {
-	      nextState = new State();
-	      assign(nextState.j, templateState.j);
-	      nextState.jr.push.apply(nextState.jr, templateState.jr);
-	      nextState.jd = templateState.jd;
-	      nextState.t = templateState.t;
-	    } else {
-	      nextState = new State();
-	    }
-
-	    if (t) {
-	      // Ensure newly token is in the same groups as the old token
-	      if (groups) {
-	        if (nextState.t && typeof nextState.t === 'string') {
-	          var allFlags = assign(flagsForToken(nextState.t, groups), flags);
-	          addToGroups(t, allFlags, groups);
-	        } else if (flags) {
-	          addToGroups(t, flags, groups);
-	        }
-	      }
-
-	      nextState.t = t; // overwrite anything that was previously there
-	    }
-
-	    state.j[input] = nextState;
-	    return nextState;
-	  }
-	}; // Helper functions to improve minification (not exported outside linkifyjs module)
-
-	/**
-	 * @template T
-	 * @param {State<T>} state
-	 * @param {string | string[]} input
-	 * @param {Flags} [flags]
-	 * @param {Collections<T>} [groups]
-	 */
-
-	var ta = function ta(state, input, next, flags, groups) {
-	  return state.ta(input, next, flags, groups);
-	};
-	/**
-	 * @template T
-	 * @param {State<T>} state
-	 * @param {RegExp} regexp
-	 * @param {T | State<T>} [next]
-	 * @param {Flags} [flags]
-	 * @param {Collections<T>} [groups]
-	 */
-
-	var tr = function tr(state, regexp, next, flags, groups) {
-	  return state.tr(regexp, next, flags, groups);
-	};
-	/**
-	 * @template T
-	 * @param {State<T>} state
-	 * @param {string | string[]} input
-	 * @param {T | State<T>} [next]
-	 * @param {Flags} [flags]
-	 * @param {Collections<T>} [groups]
-	 */
-
-	var ts = function ts(state, input, next, flags, groups) {
-	  return state.ts(input, next, flags, groups);
-	};
-	/**
-	 * @template T
-	 * @param {State<T>} state
-	 * @param {string} input
-	 * @param {T | State<T>} [next]
-	 * @param {Collections<T>} [groups]
-	 * @param {Flags} [flags]
-	 */
-
-	var tt = function tt(state, input, next, flags, groups) {
-	  return state.tt(input, next, flags, groups);
-	};
-
-	/******************************************************************************
-	Text Tokens
-	Identifiers for token outputs from the regexp scanner
-	******************************************************************************/
-	// A valid web domain token
-	var WORD = 'WORD'; // only contains a-z
-
-	var UWORD = 'UWORD'; // contains letters other than a-z, used for IDN
-	// Special case of word
-
-	var LOCALHOST = 'LOCALHOST'; // Valid top-level domain, special case of WORD (see tlds.js)
-
-	var TLD = 'TLD'; // Valid IDN TLD, special case of UWORD (see tlds.js)
-
-	var UTLD = 'UTLD'; // The scheme portion of a web URI protocol. Supported types include: `mailto`,
-	// `file`, and user-defined custom protocols. Limited to schemes that contain
-	// only letters
-
-	var SCHEME = 'SCHEME'; // Similar to SCHEME, except makes distinction for schemes that must always be
-	// followed by `://`, not just `:`. Supported types include `http`, `https`,
-	// `ftp`, `ftps`
-
-	var SLASH_SCHEME = 'SLASH_SCHEME'; // Any sequence of digits 0-9
-
-	var NUM = 'NUM'; // Any number of consecutive whitespace characters that are not newline
-
-	var WS = 'WS'; // New line (unix style)
-
-	var NL$1 = 'NL'; // \n
-	// Opening/closing bracket classes
-
-	var OPENBRACE = 'OPENBRACE'; // {
-
-	var OPENBRACKET = 'OPENBRACKET'; // [
-
-	var OPENANGLEBRACKET = 'OPENANGLEBRACKET'; // <
-
-	var OPENPAREN = 'OPENPAREN'; // (
-
-	var CLOSEBRACE = 'CLOSEBRACE'; // }
-
-	var CLOSEBRACKET = 'CLOSEBRACKET'; // ]
-
-	var CLOSEANGLEBRACKET = 'CLOSEANGLEBRACKET'; // >
-
-	var CLOSEPAREN = 'CLOSEPAREN'; // )
-	// Various symbols
-
-	var AMPERSAND = 'AMPERSAND'; // &
-
-	var APOSTROPHE = 'APOSTROPHE'; // '
-
-	var ASTERISK = 'ASTERISK'; // *
-
-	var AT = 'AT'; // @
-
-	var BACKSLASH = 'BACKSLASH'; // \
-
-	var BACKTICK = 'BACKTICK'; // `
-
-	var CARET = 'CARET'; // ^
-
-	var COLON = 'COLON'; // :
-
-	var COMMA = 'COMMA'; // ,
-
-	var DOLLAR = 'DOLLAR'; // $
-
-	var DOT = 'DOT'; // .
-
-	var EQUALS = 'EQUALS'; // =
-
-	var EXCLAMATION = 'EXCLAMATION'; // !
-
-	var HYPHEN = 'HYPHEN'; // -
-
-	var PERCENT = 'PERCENT'; // %
-
-	var PIPE = 'PIPE'; // |
-
-	var PLUS = 'PLUS'; // +
-
-	var POUND = 'POUND'; // #
-
-	var QUERY = 'QUERY'; // ?
-
-	var QUOTE = 'QUOTE'; // "
-
-	var SEMI = 'SEMI'; // ;
-
-	var SLASH = 'SLASH'; // /
-
-	var TILDE = 'TILDE'; // ~
-
-	var UNDERSCORE = 'UNDERSCORE'; // _
-	// Emoji symbol
-
-	var EMOJI$1 = 'EMOJI'; // Default token - anything that is not one of the above
-
-	var SYM = 'SYM';
-
-	var tk = /*#__PURE__*/Object.freeze({
-		__proto__: null,
-		WORD: WORD,
-		UWORD: UWORD,
-		LOCALHOST: LOCALHOST,
-		TLD: TLD,
-		UTLD: UTLD,
-		SCHEME: SCHEME,
-		SLASH_SCHEME: SLASH_SCHEME,
-		NUM: NUM,
-		WS: WS,
-		NL: NL$1,
-		OPENBRACE: OPENBRACE,
-		OPENBRACKET: OPENBRACKET,
-		OPENANGLEBRACKET: OPENANGLEBRACKET,
-		OPENPAREN: OPENPAREN,
-		CLOSEBRACE: CLOSEBRACE,
-		CLOSEBRACKET: CLOSEBRACKET,
-		CLOSEANGLEBRACKET: CLOSEANGLEBRACKET,
-		CLOSEPAREN: CLOSEPAREN,
-		AMPERSAND: AMPERSAND,
-		APOSTROPHE: APOSTROPHE,
-		ASTERISK: ASTERISK,
-		AT: AT,
-		BACKSLASH: BACKSLASH,
-		BACKTICK: BACKTICK,
-		CARET: CARET,
-		COLON: COLON,
-		COMMA: COMMA,
-		DOLLAR: DOLLAR,
-		DOT: DOT,
-		EQUALS: EQUALS,
-		EXCLAMATION: EXCLAMATION,
-		HYPHEN: HYPHEN,
-		PERCENT: PERCENT,
-		PIPE: PIPE,
-		PLUS: PLUS,
-		POUND: POUND,
-		QUERY: QUERY,
-		QUOTE: QUOTE,
-		SEMI: SEMI,
-		SLASH: SLASH,
-		TILDE: TILDE,
-		UNDERSCORE: UNDERSCORE,
-		EMOJI: EMOJI$1,
-		SYM: SYM
-	});
-
-	// Note that these two Unicode ones expand into a really big one with Babel
-	var ASCII_LETTER = /[a-z]/;
-	var LETTER = /(?:[A-Za-z\xAA\xB5\xBA\xC0-\xD6\xD8-\xF6\xF8-\u02C1\u02C6-\u02D1\u02E0-\u02E4\u02EC\u02EE\u0370-\u0374\u0376\u0377\u037A-\u037D\u037F\u0386\u0388-\u038A\u038C\u038E-\u03A1\u03A3-\u03F5\u03F7-\u0481\u048A-\u052F\u0531-\u0556\u0559\u0560-\u0588\u05D0-\u05EA\u05EF-\u05F2\u0620-\u064A\u066E\u066F\u0671-\u06D3\u06D5\u06E5\u06E6\u06EE\u06EF\u06FA-\u06FC\u06FF\u0710\u0712-\u072F\u074D-\u07A5\u07B1\u07CA-\u07EA\u07F4\u07F5\u07FA\u0800-\u0815\u081A\u0824\u0828\u0840-\u0858\u0860-\u086A\u0870-\u0887\u0889-\u088E\u08A0-\u08C9\u0904-\u0939\u093D\u0950\u0958-\u0961\u0971-\u0980\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2\u09B6-\u09B9\u09BD\u09CE\u09DC\u09DD\u09DF-\u09E1\u09F0\u09F1\u09FC\u0A05-\u0A0A\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39\u0A59-\u0A5C\u0A5E\u0A72-\u0A74\u0A85-\u0A8D\u0A8F-\u0A91\u0A93-\u0AA8\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABD\u0AD0\u0AE0\u0AE1\u0AF9\u0B05-\u0B0C\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B35-\u0B39\u0B3D\u0B5C\u0B5D\u0B5F-\u0B61\u0B71\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB9\u0BD0\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C39\u0C3D\u0C58-\u0C5A\u0C5D\u0C60\u0C61\u0C80\u0C85-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3\u0CB5-\u0CB9\u0CBD\u0CDD\u0CDE\u0CE0\u0CE1\u0CF1\u0CF2\u0D04-\u0D0C\u0D0E-\u0D10\u0D12-\u0D3A\u0D3D\u0D4E\u0D54-\u0D56\u0D5F-\u0D61\u0D7A-\u0D7F\u0D85-\u0D96\u0D9A-\u0DB1\u0DB3-\u0DBB\u0DBD\u0DC0-\u0DC6\u0E01-\u0E30\u0E32\u0E33\u0E40-\u0E46\u0E81\u0E82\u0E84\u0E86-\u0E8A\u0E8C-\u0EA3\u0EA5\u0EA7-\u0EB0\u0EB2\u0EB3\u0EBD\u0EC0-\u0EC4\u0EC6\u0EDC-\u0EDF\u0F00\u0F40-\u0F47\u0F49-\u0F6C\u0F88-\u0F8C\u1000-\u102A\u103F\u1050-\u1055\u105A-\u105D\u1061\u1065\u1066\u106E-\u1070\u1075-\u1081\u108E\u10A0-\u10C5\u10C7\u10CD\u10D0-\u10FA\u10FC-\u1248\u124A-\u124D\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310\u1312-\u1315\u1318-\u135A\u1380-\u138F\u13A0-\u13F5\u13F8-\u13FD\u1401-\u166C\u166F-\u167F\u1681-\u169A\u16A0-\u16EA\u16F1-\u16F8\u1700-\u1711\u171F-\u1731\u1740-\u1751\u1760-\u176C\u176E-\u1770\u1780-\u17B3\u17D7\u17DC\u1820-\u1878\u1880-\u1884\u1887-\u18A8\u18AA\u18B0-\u18F5\u1900-\u191E\u1950-\u196D\u1970-\u1974\u1980-\u19AB\u19B0-\u19C9\u1A00-\u1A16\u1A20-\u1A54\u1AA7\u1B05-\u1B33\u1B45-\u1B4C\u1B83-\u1BA0\u1BAE\u1BAF\u1BBA-\u1BE5\u1C00-\u1C23\u1C4D-\u1C4F\u1C5A-\u1C7D\u1C80-\u1C88\u1C90-\u1CBA\u1CBD-\u1CBF\u1CE9-\u1CEC\u1CEE-\u1CF3\u1CF5\u1CF6\u1CFA\u1D00-\u1DBF\u1E00-\u1F15\u1F18-\u1F1D\u1F20-\u1F45\u1F48-\u1F4D\u1F50-\u1F57\u1F59\u1F5B\u1F5D\u1F5F-\u1F7D\u1F80-\u1FB4\u1FB6-\u1FBC\u1FBE\u1FC2-\u1FC4\u1FC6-\u1FCC\u1FD0-\u1FD3\u1FD6-\u1FDB\u1FE0-\u1FEC\u1FF2-\u1FF4\u1FF6-\u1FFC\u2071\u207F\u2090-\u209C\u2102\u2107\u210A-\u2113\u2115\u2119-\u211D\u2124\u2126\u2128\u212A-\u212D\u212F-\u2139\u213C-\u213F\u2145-\u2149\u214E\u2183\u2184\u2C00-\u2CE4\u2CEB-\u2CEE\u2CF2\u2CF3\u2D00-\u2D25\u2D27\u2D2D\u2D30-\u2D67\u2D6F\u2D80-\u2D96\u2DA0-\u2DA6\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE\u2DD0-\u2DD6\u2DD8-\u2DDE\u2E2F\u3005\u3006\u3031-\u3035\u303B\u303C\u3041-\u3096\u309D-\u309F\u30A1-\u30FA\u30FC-\u30FF\u3105-\u312F\u3131-\u318E\u31A0-\u31BF\u31F0-\u31FF\u3400-\u4DBF\u4E00-\uA48C\uA4D0-\uA4FD\uA500-\uA60C\uA610-\uA61F\uA62A\uA62B\uA640-\uA66E\uA67F-\uA69D\uA6A0-\uA6E5\uA717-\uA71F\uA722-\uA788\uA78B-\uA7CA\uA7D0\uA7D1\uA7D3\uA7D5-\uA7D9\uA7F2-\uA801\uA803-\uA805\uA807-\uA80A\uA80C-\uA822\uA840-\uA873\uA882-\uA8B3\uA8F2-\uA8F7\uA8FB\uA8FD\uA8FE\uA90A-\uA925\uA930-\uA946\uA960-\uA97C\uA984-\uA9B2\uA9CF\uA9E0-\uA9E4\uA9E6-\uA9EF\uA9FA-\uA9FE\uAA00-\uAA28\uAA40-\uAA42\uAA44-\uAA4B\uAA60-\uAA76\uAA7A\uAA7E-\uAAAF\uAAB1\uAAB5\uAAB6\uAAB9-\uAABD\uAAC0\uAAC2\uAADB-\uAADD\uAAE0-\uAAEA\uAAF2-\uAAF4\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E\uAB30-\uAB5A\uAB5C-\uAB69\uAB70-\uABE2\uAC00-\uD7A3\uD7B0-\uD7C6\uD7CB-\uD7FB\uF900-\uFA6D\uFA70-\uFAD9\uFB00-\uFB06\uFB13-\uFB17\uFB1D\uFB1F-\uFB28\uFB2A-\uFB36\uFB38-\uFB3C\uFB3E\uFB40\uFB41\uFB43\uFB44\uFB46-\uFBB1\uFBD3-\uFD3D\uFD50-\uFD8F\uFD92-\uFDC7\uFDF0-\uFDFB\uFE70-\uFE74\uFE76-\uFEFC\uFF21-\uFF3A\uFF41-\uFF5A\uFF66-\uFFBE\uFFC2-\uFFC7\uFFCA-\uFFCF\uFFD2-\uFFD7\uFFDA-\uFFDC]|\uD800[\uDC00-\uDC0B\uDC0D-\uDC26\uDC28-\uDC3A\uDC3C\uDC3D\uDC3F-\uDC4D\uDC50-\uDC5D\uDC80-\uDCFA\uDE80-\uDE9C\uDEA0-\uDED0\uDF00-\uDF1F\uDF2D-\uDF40\uDF42-\uDF49\uDF50-\uDF75\uDF80-\uDF9D\uDFA0-\uDFC3\uDFC8-\uDFCF]|\uD801[\uDC00-\uDC9D\uDCB0-\uDCD3\uDCD8-\uDCFB\uDD00-\uDD27\uDD30-\uDD63\uDD70-\uDD7A\uDD7C-\uDD8A\uDD8C-\uDD92\uDD94\uDD95\uDD97-\uDDA1\uDDA3-\uDDB1\uDDB3-\uDDB9\uDDBB\uDDBC\uDE00-\uDF36\uDF40-\uDF55\uDF60-\uDF67\uDF80-\uDF85\uDF87-\uDFB0\uDFB2-\uDFBA]|\uD802[\uDC00-\uDC05\uDC08\uDC0A-\uDC35\uDC37\uDC38\uDC3C\uDC3F-\uDC55\uDC60-\uDC76\uDC80-\uDC9E\uDCE0-\uDCF2\uDCF4\uDCF5\uDD00-\uDD15\uDD20-\uDD39\uDD80-\uDDB7\uDDBE\uDDBF\uDE00\uDE10-\uDE13\uDE15-\uDE17\uDE19-\uDE35\uDE60-\uDE7C\uDE80-\uDE9C\uDEC0-\uDEC7\uDEC9-\uDEE4\uDF00-\uDF35\uDF40-\uDF55\uDF60-\uDF72\uDF80-\uDF91]|\uD803[\uDC00-\uDC48\uDC80-\uDCB2\uDCC0-\uDCF2\uDD00-\uDD23\uDE80-\uDEA9\uDEB0\uDEB1\uDF00-\uDF1C\uDF27\uDF30-\uDF45\uDF70-\uDF81\uDFB0-\uDFC4\uDFE0-\uDFF6]|\uD804[\uDC03-\uDC37\uDC71\uDC72\uDC75\uDC83-\uDCAF\uDCD0-\uDCE8\uDD03-\uDD26\uDD44\uDD47\uDD50-\uDD72\uDD76\uDD83-\uDDB2\uDDC1-\uDDC4\uDDDA\uDDDC\uDE00-\uDE11\uDE13-\uDE2B\uDE80-\uDE86\uDE88\uDE8A-\uDE8D\uDE8F-\uDE9D\uDE9F-\uDEA8\uDEB0-\uDEDE\uDF05-\uDF0C\uDF0F\uDF10\uDF13-\uDF28\uDF2A-\uDF30\uDF32\uDF33\uDF35-\uDF39\uDF3D\uDF50\uDF5D-\uDF61]|\uD805[\uDC00-\uDC34\uDC47-\uDC4A\uDC5F-\uDC61\uDC80-\uDCAF\uDCC4\uDCC5\uDCC7\uDD80-\uDDAE\uDDD8-\uDDDB\uDE00-\uDE2F\uDE44\uDE80-\uDEAA\uDEB8\uDF00-\uDF1A\uDF40-\uDF46]|\uD806[\uDC00-\uDC2B\uDCA0-\uDCDF\uDCFF-\uDD06\uDD09\uDD0C-\uDD13\uDD15\uDD16\uDD18-\uDD2F\uDD3F\uDD41\uDDA0-\uDDA7\uDDAA-\uDDD0\uDDE1\uDDE3\uDE00\uDE0B-\uDE32\uDE3A\uDE50\uDE5C-\uDE89\uDE9D\uDEB0-\uDEF8]|\uD807[\uDC00-\uDC08\uDC0A-\uDC2E\uDC40\uDC72-\uDC8F\uDD00-\uDD06\uDD08\uDD09\uDD0B-\uDD30\uDD46\uDD60-\uDD65\uDD67\uDD68\uDD6A-\uDD89\uDD98\uDEE0-\uDEF2\uDFB0]|\uD808[\uDC00-\uDF99]|\uD809[\uDC80-\uDD43]|\uD80B[\uDF90-\uDFF0]|[\uD80C\uD81C-\uD820\uD822\uD840-\uD868\uD86A-\uD86C\uD86F-\uD872\uD874-\uD879\uD880-\uD883][\uDC00-\uDFFF]|\uD80D[\uDC00-\uDC2E]|\uD811[\uDC00-\uDE46]|\uD81A[\uDC00-\uDE38\uDE40-\uDE5E\uDE70-\uDEBE\uDED0-\uDEED\uDF00-\uDF2F\uDF40-\uDF43\uDF63-\uDF77\uDF7D-\uDF8F]|\uD81B[\uDE40-\uDE7F\uDF00-\uDF4A\uDF50\uDF93-\uDF9F\uDFE0\uDFE1\uDFE3]|\uD821[\uDC00-\uDFF7]|\uD823[\uDC00-\uDCD5\uDD00-\uDD08]|\uD82B[\uDFF0-\uDFF3\uDFF5-\uDFFB\uDFFD\uDFFE]|\uD82C[\uDC00-\uDD22\uDD50-\uDD52\uDD64-\uDD67\uDD70-\uDEFB]|\uD82F[\uDC00-\uDC6A\uDC70-\uDC7C\uDC80-\uDC88\uDC90-\uDC99]|\uD835[\uDC00-\uDC54\uDC56-\uDC9C\uDC9E\uDC9F\uDCA2\uDCA5\uDCA6\uDCA9-\uDCAC\uDCAE-\uDCB9\uDCBB\uDCBD-\uDCC3\uDCC5-\uDD05\uDD07-\uDD0A\uDD0D-\uDD14\uDD16-\uDD1C\uDD1E-\uDD39\uDD3B-\uDD3E\uDD40-\uDD44\uDD46\uDD4A-\uDD50\uDD52-\uDEA5\uDEA8-\uDEC0\uDEC2-\uDEDA\uDEDC-\uDEFA\uDEFC-\uDF14\uDF16-\uDF34\uDF36-\uDF4E\uDF50-\uDF6E\uDF70-\uDF88\uDF8A-\uDFA8\uDFAA-\uDFC2\uDFC4-\uDFCB]|\uD837[\uDF00-\uDF1E]|\uD838[\uDD00-\uDD2C\uDD37-\uDD3D\uDD4E\uDE90-\uDEAD\uDEC0-\uDEEB]|\uD839[\uDFE0-\uDFE6\uDFE8-\uDFEB\uDFED\uDFEE\uDFF0-\uDFFE]|\uD83A[\uDC00-\uDCC4\uDD00-\uDD43\uDD4B]|\uD83B[\uDE00-\uDE03\uDE05-\uDE1F\uDE21\uDE22\uDE24\uDE27\uDE29-\uDE32\uDE34-\uDE37\uDE39\uDE3B\uDE42\uDE47\uDE49\uDE4B\uDE4D-\uDE4F\uDE51\uDE52\uDE54\uDE57\uDE59\uDE5B\uDE5D\uDE5F\uDE61\uDE62\uDE64\uDE67-\uDE6A\uDE6C-\uDE72\uDE74-\uDE77\uDE79-\uDE7C\uDE7E\uDE80-\uDE89\uDE8B-\uDE9B\uDEA1-\uDEA3\uDEA5-\uDEA9\uDEAB-\uDEBB]|\uD869[\uDC00-\uDEDF\uDF00-\uDFFF]|\uD86D[\uDC00-\uDF38\uDF40-\uDFFF]|\uD86E[\uDC00-\uDC1D\uDC20-\uDFFF]|\uD873[\uDC00-\uDEA1\uDEB0-\uDFFF]|\uD87A[\uDC00-\uDFE0]|\uD87E[\uDC00-\uDE1D]|\uD884[\uDC00-\uDF4A])/; // Any Unicode character with letter data type
-
-	var EMOJI = /(?:[#\*0-9\xA9\xAE\u203C\u2049\u2122\u2139\u2194-\u2199\u21A9\u21AA\u231A\u231B\u2328\u23CF\u23E9-\u23F3\u23F8-\u23FA\u24C2\u25AA\u25AB\u25B6\u25C0\u25FB-\u25FE\u2600-\u2604\u260E\u2611\u2614\u2615\u2618\u261D\u2620\u2622\u2623\u2626\u262A\u262E\u262F\u2638-\u263A\u2640\u2642\u2648-\u2653\u265F\u2660\u2663\u2665\u2666\u2668\u267B\u267E\u267F\u2692-\u2697\u2699\u269B\u269C\u26A0\u26A1\u26A7\u26AA\u26AB\u26B0\u26B1\u26BD\u26BE\u26C4\u26C5\u26C8\u26CE\u26CF\u26D1\u26D3\u26D4\u26E9\u26EA\u26F0-\u26F5\u26F7-\u26FA\u26FD\u2702\u2705\u2708-\u270D\u270F\u2712\u2714\u2716\u271D\u2721\u2728\u2733\u2734\u2744\u2747\u274C\u274E\u2753-\u2755\u2757\u2763\u2764\u2795-\u2797\u27A1\u27B0\u27BF\u2934\u2935\u2B05-\u2B07\u2B1B\u2B1C\u2B50\u2B55\u3030\u303D\u3297\u3299]|\uD83C[\uDC04\uDCCF\uDD70\uDD71\uDD7E\uDD7F\uDD8E\uDD91-\uDD9A\uDDE6-\uDDFF\uDE01\uDE02\uDE1A\uDE2F\uDE32-\uDE3A\uDE50\uDE51\uDF00-\uDF21\uDF24-\uDF93\uDF96\uDF97\uDF99-\uDF9B\uDF9E-\uDFF0\uDFF3-\uDFF5\uDFF7-\uDFFF]|\uD83D[\uDC00-\uDCFD\uDCFF-\uDD3D\uDD49-\uDD4E\uDD50-\uDD67\uDD6F\uDD70\uDD73-\uDD7A\uDD87\uDD8A-\uDD8D\uDD90\uDD95\uDD96\uDDA4\uDDA5\uDDA8\uDDB1\uDDB2\uDDBC\uDDC2-\uDDC4\uDDD1-\uDDD3\uDDDC-\uDDDE\uDDE1\uDDE3\uDDE8\uDDEF\uDDF3\uDDFA-\uDE4F\uDE80-\uDEC5\uDECB-\uDED2\uDED5-\uDED7\uDEDD-\uDEE5\uDEE9\uDEEB\uDEEC\uDEF0\uDEF3-\uDEFC\uDFE0-\uDFEB\uDFF0]|\uD83E[\uDD0C-\uDD3A\uDD3C-\uDD45\uDD47-\uDDFF\uDE70-\uDE74\uDE78-\uDE7C\uDE80-\uDE86\uDE90-\uDEAC\uDEB0-\uDEBA\uDEC0-\uDEC5\uDED0-\uDED9\uDEE0-\uDEE7\uDEF0-\uDEF6])/; // Any Unicode emoji character
-
-	var EMOJI_VARIATION$1 = /\ufe0f/;
-	var DIGIT = /\d/;
-	var SPACE = /\s/;
-
-	var regexp = /*#__PURE__*/Object.freeze({
-		__proto__: null,
-		ASCII_LETTER: ASCII_LETTER,
-		LETTER: LETTER,
-		EMOJI: EMOJI,
-		EMOJI_VARIATION: EMOJI_VARIATION$1,
-		DIGIT: DIGIT,
-		SPACE: SPACE
-	});
-
-	/**
-		The scanner provides an interface that takes a string of text as input, and
-		outputs an array of tokens instances that can be used for easy URL parsing.
-	*/
-	var NL = '\n'; // New line character
-
-	var EMOJI_VARIATION = "\uFE0F"; // Variation selector, follows heart and others
-
-	var EMOJI_JOINER = "\u200D"; // zero-width joiner
-
-	/**
-	 * Scanner output token:
-	 * - `t` is the token name (e.g., 'NUM', 'EMOJI', 'TLD')
-	 * - `v` is the value of the token (e.g., '123', '❤️', 'com')
-	 * - `s` is the start index of the token in the original string
-	 * - `e` is the end index of the token in the original string
-	 * @typedef {{t: string, v: string, s: number, e: number}} Token
-	 */
-
-	/**
-	 * @template T
-	 * @typedef {{ [collection: string]: T[] }} Collections
-	 */
-
-	/**
-	 * Initialize the scanner character-based state machine for the given start
-	 * state
-	 * @param {[string, boolean][]} customSchemes List of custom schemes, where each
-	 * item is a length-2 tuple with the first element set to the string scheme, and
-	 * the second element set to `true` if the `://` after the scheme is optional
-	 */
-
-	function init$2(customSchemes) {
-	  var _tr, _tr2, _tr3, _tr4, _tt, _tr5;
-
-	  if (customSchemes === void 0) {
-	    customSchemes = [];
-	  }
-
-	  // Frequently used states (name argument removed during minification)
-
-	  /** @type Collections<string> */
-	  var groups = {}; // of tokens
-
-	  State.groups = groups;
-	  /** @type State<string> */
-
-	  var Start = new State(); // States for special URL symbols that accept immediately after start
-
-	  tt(Start, "'", APOSTROPHE);
-	  tt(Start, '{', OPENBRACE);
-	  tt(Start, '[', OPENBRACKET);
-	  tt(Start, '<', OPENANGLEBRACKET);
-	  tt(Start, '(', OPENPAREN);
-	  tt(Start, '}', CLOSEBRACE);
-	  tt(Start, ']', CLOSEBRACKET);
-	  tt(Start, '>', CLOSEANGLEBRACKET);
-	  tt(Start, ')', CLOSEPAREN);
-	  tt(Start, '&', AMPERSAND);
-	  tt(Start, '*', ASTERISK);
-	  tt(Start, '@', AT);
-	  tt(Start, '`', BACKTICK);
-	  tt(Start, '^', CARET);
-	  tt(Start, ':', COLON);
-	  tt(Start, ',', COMMA);
-	  tt(Start, '$', DOLLAR);
-	  tt(Start, '.', DOT);
-	  tt(Start, '=', EQUALS);
-	  tt(Start, '!', EXCLAMATION);
-	  tt(Start, '-', HYPHEN);
-	  tt(Start, '%', PERCENT);
-	  tt(Start, '|', PIPE);
-	  tt(Start, '+', PLUS);
-	  tt(Start, '#', POUND);
-	  tt(Start, '?', QUERY);
-	  tt(Start, '"', QUOTE);
-	  tt(Start, '/', SLASH);
-	  tt(Start, ';', SEMI);
-	  tt(Start, '~', TILDE);
-	  tt(Start, '_', UNDERSCORE);
-	  tt(Start, '\\', BACKSLASH);
-	  var Num = tr(Start, DIGIT, NUM, (_tr = {}, _tr[numeric] = true, _tr));
-	  tr(Num, DIGIT, Num); // State which emits a word token
-
-	  var Word = tr(Start, ASCII_LETTER, WORD, (_tr2 = {}, _tr2[ascii] = true, _tr2));
-	  tr(Word, ASCII_LETTER, Word); // Same as previous, but specific to non-fsm.ascii alphabet words
-
-	  var UWord = tr(Start, LETTER, UWORD, (_tr3 = {}, _tr3[alpha] = true, _tr3));
-	  tr(UWord, ASCII_LETTER); // Non-accepting
-
-	  tr(UWord, LETTER, UWord); // Whitespace jumps
-	  // Tokens of only non-newline whitespace are arbitrarily long
-	  // If any whitespace except newline, more whitespace!
-
-	  var Ws = tr(Start, SPACE, WS, (_tr4 = {}, _tr4[whitespace] = true, _tr4));
-	  tt(Start, NL, NL$1, (_tt = {}, _tt[whitespace] = true, _tt));
-	  tt(Ws, NL); // non-accepting state to avoid mixing whitespaces
-
-	  tr(Ws, SPACE, Ws); // Emoji tokens. They are not grouped by the scanner except in cases where a
-	  // zero-width joiner is present
-
-	  var Emoji = tr(Start, EMOJI, EMOJI$1, (_tr5 = {}, _tr5[emoji] = true, _tr5));
-	  tr(Emoji, EMOJI, Emoji);
-	  tt(Emoji, EMOJI_VARIATION, Emoji); // tt(Start, EMOJI_VARIATION, Emoji); // This one is sketchy
-
-	  var EmojiJoiner = tt(Emoji, EMOJI_JOINER);
-	  tr(EmojiJoiner, EMOJI, Emoji); // tt(EmojiJoiner, EMOJI_VARIATION, Emoji); // also sketchy
-	  // Generates states for top-level domains
-	  // Note that this is most accurate when tlds are in alphabetical order
-
-	  var wordjr = [[ASCII_LETTER, Word]];
-	  var uwordjr = [[ASCII_LETTER, null], [LETTER, UWord]];
-
-	  for (var i = 0; i < tlds.length; i++) {
-	    fastts(Start, tlds[i], TLD, WORD, wordjr);
-	  }
-
-	  for (var _i = 0; _i < utlds.length; _i++) {
-	    fastts(Start, utlds[_i], UTLD, UWORD, uwordjr);
-	  }
-
-	  addToGroups(TLD, {
-	    tld: true,
-	    ascii: true
-	  }, groups);
-	  addToGroups(UTLD, {
-	    utld: true,
-	    alpha: true
-	  }, groups); // Collect the states generated by different protocols. NOTE: If any new TLDs
-	  // get added that are also protocols, set the token to be the same as the
-	  // protocol to ensure parsing works as expected.
-
-	  fastts(Start, 'file', SCHEME, WORD, wordjr);
-	  fastts(Start, 'mailto', SCHEME, WORD, wordjr);
-	  fastts(Start, 'http', SLASH_SCHEME, WORD, wordjr);
-	  fastts(Start, 'https', SLASH_SCHEME, WORD, wordjr);
-	  fastts(Start, 'ftp', SLASH_SCHEME, WORD, wordjr);
-	  fastts(Start, 'ftps', SLASH_SCHEME, WORD, wordjr);
-	  addToGroups(SCHEME, {
-	    scheme: true,
-	    ascii: true
-	  }, groups);
-	  addToGroups(SLASH_SCHEME, {
-	    slashscheme: true,
-	    ascii: true
-	  }, groups); // Register custom schemes. Assumes each scheme is asciinumeric with hyphens
-
-	  customSchemes = customSchemes.sort(function (a, b) {
-	    return a[0] > b[0] ? 1 : -1;
-	  });
-
-	  for (var _i2 = 0; _i2 < customSchemes.length; _i2++) {
-	    var _ref, _ref2;
-
-	    var sch = customSchemes[_i2][0];
-	    var optionalSlashSlash = customSchemes[_i2][1];
-	    var flags = optionalSlashSlash ? (_ref = {}, _ref[scheme] = true, _ref) : (_ref2 = {}, _ref2[slashscheme] = true, _ref2);
-
-	    if (sch.indexOf('-') >= 0) {
-	      flags[domain] = true;
-	    } else if (!ASCII_LETTER.test(sch)) {
-	      flags[numeric] = true; // numbers only
-	    } else if (DIGIT.test(sch)) {
-	      flags[asciinumeric] = true;
-	    } else {
-	      flags[ascii] = true;
-	    }
-
-	    ts(Start, sch, sch, flags);
-	  } // Localhost token
-
-
-	  ts(Start, 'localhost', LOCALHOST, {
-	    ascii: true
-	  }); // Set default transition for start state (some symbol)
-
-	  Start.jd = new State(SYM);
-	  return {
-	    start: Start,
-	    tokens: assign({
-	      groups: groups
-	    }, tk)
-	  };
-	}
-	/**
-		Given a string, returns an array of TOKEN instances representing the
-		composition of that string.
-
-		@method run
-		@param {State<string>} start scanner starting state
-		@param {string} str input string to scan
-		@return {Token[]} list of tokens, each with a type and value
-	*/
-
-	function run$1(start, str) {
-	  // State machine is not case sensitive, so input is tokenized in lowercased
-	  // form (still returns regular case). Uses selective `toLowerCase` because
-	  // lowercasing the entire string causes the length and character position to
-	  // vary in some non-English strings with V8-based runtimes.
-	  var iterable = stringToArray(str.replace(/[A-Z]/g, function (c) {
-	    return c.toLowerCase();
-	  }));
-	  var charCount = iterable.length; // <= len if there are emojis, etc
-
-	  var tokens = []; // return value
-	  // cursor through the string itself, accounting for characters that have
-	  // width with length 2 such as emojis
-
-	  var cursor = 0; // Cursor through the array-representation of the string
-
-	  var charCursor = 0; // Tokenize the string
-
-	  while (charCursor < charCount) {
-	    var state = start;
-	    var nextState = null;
-	    var tokenLength = 0;
-	    var latestAccepting = null;
-	    var sinceAccepts = -1;
-	    var charsSinceAccepts = -1;
-
-	    while (charCursor < charCount && (nextState = state.go(iterable[charCursor]))) {
-	      state = nextState; // Keep track of the latest accepting state
-
-	      if (state.accepts()) {
-	        sinceAccepts = 0;
-	        charsSinceAccepts = 0;
-	        latestAccepting = state;
-	      } else if (sinceAccepts >= 0) {
-	        sinceAccepts += iterable[charCursor].length;
-	        charsSinceAccepts++;
-	      }
-
-	      tokenLength += iterable[charCursor].length;
-	      cursor += iterable[charCursor].length;
-	      charCursor++;
-	    } // Roll back to the latest accepting state
-
-
-	    cursor -= sinceAccepts;
-	    charCursor -= charsSinceAccepts;
-	    tokenLength -= sinceAccepts; // No more jumps, just make a new token from the last accepting one
-
-	    tokens.push({
-	      t: latestAccepting.t,
-	      // token type/name
-	      v: str.slice(cursor - tokenLength, cursor),
-	      // string value
-	      s: cursor - tokenLength,
-	      // start index
-	      e: cursor // end index (excluding)
-
-	    });
-	  }
-
-	  return tokens;
-	}
-	/**
-	 * Convert a String to an Array of characters, taking into account that some
-	 * characters like emojis take up two string indexes.
-	 *
-	 * Adapted from core-js (MIT license)
-	 * https://github.com/zloirock/core-js/blob/2d69cf5f99ab3ea3463c395df81e5a15b68f49d9/packages/core-js/internals/string-multibyte.js
-	 *
-	 * @function stringToArray
-	 * @param {string} str
-	 * @returns {string[]}
-	 */
-
-	function stringToArray(str) {
-	  var result = [];
-	  var len = str.length;
-	  var index = 0;
-
-	  while (index < len) {
-	    var first = str.charCodeAt(index);
-	    var second = void 0;
-	    var char = first < 0xd800 || first > 0xdbff || index + 1 === len || (second = str.charCodeAt(index + 1)) < 0xdc00 || second > 0xdfff ? str[index] // single character
-	    : str.slice(index, index + 2); // two-index characters
-
-	    result.push(char);
-	    index += char.length;
-	  }
-
-	  return result;
-	}
-	/**
-	 * Fast version of ts function for when transition defaults are well known
-	 * @param {State<string>} state
-	 * @param {string} input
-	 * @param {string} t
-	 * @param {string} defaultt
-	 * @param {[RegExp, State<string>][]} jr
-	 * @returns {State<string>}
-	 */
-
-	function fastts(state, input, t, defaultt, jr) {
-	  var next;
-	  var len = input.length;
-
-	  for (var i = 0; i < len - 1; i++) {
-	    var char = input[i];
-
-	    if (state.j[char]) {
-	      next = state.j[char];
-	    } else {
-	      next = new State(defaultt);
-	      next.jr = jr.slice();
-	      state.j[char] = next;
-	    }
-
-	    state = next;
-	  }
-
-	  next = new State(t);
-	  next.jr = jr.slice();
-	  state.j[input[len - 1]] = next;
-	  return next;
-	}
-
-	function _inheritsLoose(subClass, superClass) {
-	  subClass.prototype = Object.create(superClass.prototype);
-	  subClass.prototype.constructor = subClass;
-
-	  _setPrototypeOf(subClass, superClass);
-	}
-
-	function _setPrototypeOf(o, p) {
-	  _setPrototypeOf = Object.setPrototypeOf || function _setPrototypeOf(o, p) {
-	    o.__proto__ = p;
-	    return o;
-	  };
-
-	  return _setPrototypeOf(o, p);
-	}
-
-	/**
-	 * An object where each key is a valid DOM Event Name such as `click` or `focus`
-	 * and each value is an event handler function.
-	 *
-	 * https://developer.mozilla.org/en-US/docs/Web/API/Element#events
-	 * @typedef {?{ [event: string]: Function }} EventListeners
-	 */
-
-	/**
-	 * All formatted properties required to render a link, including `tagName`,
-	 * `attributes`, `content` and `eventListeners`.
-	 * @typedef {{ tagName: any, attributes: {[attr: string]: any}, content: string,
-	 * eventListeners: EventListeners }} IntermediateRepresentation
-	 */
-
-	/**
-	 * Specify either an object described by the template type `O` or a function.
-	 *
-	 * The function takes a string value (usually the link's href attribute), the
-	 * link type (`'url'`, `'hashtag`', etc.) and an internal token representation
-	 * of the link. It should return an object of the template type `O`
-	 * @template O
-	 * @typedef {O | ((value: string, type: string, token: MultiToken) => O)} OptObj
-	 */
-
-	/**
-	 * Specify either a function described by template type `F` or an object.
-	 *
-	 * Each key in the object should be a link type (`'url'`, `'hashtag`', etc.). Each
-	 * value should be a function with template type `F` that is called when the
-	 * corresponding link type is encountered.
-	 * @template F
-	 * @typedef {F | { [type: string]: F}} OptFn
-	 */
-
-	/**
-	 * Specify either a value with template type `V`, a function that returns `V` or
-	 * an object where each value resolves to `V`.
-	 *
-	 * The function takes a string value (usually the link's href attribute), the
-	 * link type (`'url'`, `'hashtag`', etc.) and an internal token representation
-	 * of the link. It should return an object of the template type `V`
-	 *
-	 * For the object, each key should be a link type (`'url'`, `'hashtag`', etc.).
-	 * Each value should either have type `V` or a function that returns V. This
-	 * function similarly takes a string value and a token.
-	 *
-	 * Example valid types for `Opt<string>`:
-	 *
-	 * ```js
-	 * 'hello'
-	 * (value, type, token) => 'world'
-	 * { url: 'hello', email: (value, token) => 'world'}
-	 * ```
-	 * @template V
-	 * @typedef {V | ((value: string, type: string, token: MultiToken) => V) | { [type: string]: V | ((value: string, token: MultiToken) => V) }} Opt
-	 */
-
-	/**
-	 * See available options: https://linkify.js.org/docs/options.html
-	 * @typedef {{
-	 * 	defaultProtocol?: string,
-	 *  events?: OptObj<EventListeners>,
-	 * 	format?: Opt<string>,
-	 * 	formatHref?: Opt<string>,
-	 * 	nl2br?: boolean,
-	 * 	tagName?: Opt<any>,
-	 * 	target?: Opt<string>,
-	 * 	rel?: Opt<string>,
-	 * 	validate?: Opt<boolean>,
-	 * 	truncate?: Opt<number>,
-	 * 	className?: Opt<string>,
-	 * 	attributes?: OptObj<({ [attr: string]: any })>,
-	 *  ignoreTags?: string[],
-	 * 	render?: OptFn<((ir: IntermediateRepresentation) => any)>
-	 * }} Opts
-	 */
-
-	/**
-	 * @type Required<Opts>
-	 */
-
-	var defaults = {
-	  defaultProtocol: 'http',
-	  events: null,
-	  format: noop,
-	  formatHref: noop,
-	  nl2br: false,
-	  tagName: 'a',
-	  target: null,
-	  rel: null,
-	  validate: true,
-	  truncate: Infinity,
-	  className: null,
-	  attributes: null,
-	  ignoreTags: [],
-	  render: null
-	};
-	/**
-	 * Utility class for linkify interfaces to apply specified
-	 * {@link Opts formatting and rendering options}.
-	 *
-	 * @param {Opts | Options} [opts] Option value overrides.
-	 * @param {(ir: IntermediateRepresentation) => any} [defaultRender] (For
-	 *   internal use) default render function that determines how to generate an
-	 *   HTML element based on a link token's derived tagName, attributes and HTML.
-	 *   Similar to render option
-	 */
-
-	function Options(opts, defaultRender) {
-	  if (defaultRender === void 0) {
-	    defaultRender = null;
-	  }
-
-	  var o = assign({}, defaults);
-
-	  if (opts) {
-	    o = assign(o, opts instanceof Options ? opts.o : opts);
-	  } // Ensure all ignored tags are uppercase
-
-
-	  var ignoredTags = o.ignoreTags;
-	  var uppercaseIgnoredTags = [];
-
-	  for (var i = 0; i < ignoredTags.length; i++) {
-	    uppercaseIgnoredTags.push(ignoredTags[i].toUpperCase());
-	  }
-	  /** @protected */
-
-
-	  this.o = o;
-
-	  if (defaultRender) {
-	    this.defaultRender = defaultRender;
-	  }
-
-	  this.ignoreTags = uppercaseIgnoredTags;
-	}
-	Options.prototype = {
-	  o: defaults,
-
-	  /**
-	   * @type string[]
-	   */
-	  ignoreTags: [],
-
-	  /**
-	   * @param {IntermediateRepresentation} ir
-	   * @returns {any}
-	   */
-	  defaultRender: function defaultRender(ir) {
-	    return ir;
-	  },
-
-	  /**
-	   * Returns true or false based on whether a token should be displayed as a
-	   * link based on the user options.
-	   * @param {MultiToken} token
-	   * @returns {boolean}
-	   */
-	  check: function check(token) {
-	    return this.get('validate', token.toString(), token);
-	  },
-	  // Private methods
-
-	  /**
-	   * Resolve an option's value based on the value of the option and the given
-	   * params. If operator and token are specified and the target option is
-	   * callable, automatically calls the function with the given argument.
-	   * @template {keyof Opts} K
-	   * @param {K} key Name of option to use
-	   * @param {string} [operator] will be passed to the target option if it's a
-	   * function. If not specified, RAW function value gets returned
-	   * @param {MultiToken} [token] The token from linkify.tokenize
-	   * @returns {Opts[K] | any}
-	   */
-	  get: function get(key, operator, token) {
-	    var isCallable = operator != null;
-	    var option = this.o[key];
-
-	    if (!option) {
-	      return option;
-	    }
-
-	    if (typeof option === 'object') {
-	      option = token.t in option ? option[token.t] : defaults[key];
-
-	      if (typeof option === 'function' && isCallable) {
-	        option = option(operator, token);
-	      }
-	    } else if (typeof option === 'function' && isCallable) {
-	      option = option(operator, token.t, token);
-	    }
-
-	    return option;
-	  },
-
-	  /**
-	   * @template {keyof Opts} L
-	   * @param {L} key Name of options object to use
-	   * @param {string} [operator]
-	   * @param {MultiToken} [token]
-	   * @returns {Opts[L] | any}
-	   */
-	  getObj: function getObj(key, operator, token) {
-	    var obj = this.o[key];
-
-	    if (typeof obj === 'function' && operator != null) {
-	      obj = obj(operator, token.t, token);
-	    }
-
-	    return obj;
-	  },
-
-	  /**
-	   * Convert the given token to a rendered element that may be added to the
-	   * calling-interface's DOM
-	   * @param {MultiToken} token Token to render to an HTML element
-	   * @returns {any} Render result; e.g., HTML string, DOM element, React
-	   *   Component, etc.
-	   */
-	  render: function render(token) {
-	    var ir = token.render(this); // intermediate representation
-
-	    var renderFn = this.get('render', null, token) || this.defaultRender;
-	    return renderFn(ir, token.t, token);
-	  }
-	};
-
-	function noop(val) {
-	  return val;
-	}
-
-	var options = /*#__PURE__*/Object.freeze({
-		__proto__: null,
-		defaults: defaults,
-		Options: Options,
-		assign: assign
-	});
-
-	/******************************************************************************
-		Multi-Tokens
-		Tokens composed of arrays of TextTokens
-	******************************************************************************/
-
-	/**
-	 * @param {string} value
-	 * @param {Token[]} tokens
-	 */
-
-	function MultiToken(value, tokens) {
-	  this.t = 'token';
-	  this.v = value;
-	  this.tk = tokens;
-	}
-	/**
-	 * Abstract class used for manufacturing tokens of text tokens. That is rather
-	 * than the value for a token being a small string of text, it's value an array
-	 * of text tokens.
-	 *
-	 * Used for grouping together URLs, emails, hashtags, and other potential
-	 * creations.
-	 * @class MultiToken
-	 * @property {string} t
-	 * @property {string} v
-	 * @property {Token[]} tk
-	 * @abstract
-	 */
-
-	MultiToken.prototype = {
-	  isLink: false,
-
-	  /**
-	   * Return the string this token represents.
-	   * @return {string}
-	   */
-	  toString: function toString() {
-	    return this.v;
-	  },
-
-	  /**
-	   * What should the value for this token be in the `href` HTML attribute?
-	   * Returns the `.toString` value by default.
-	   * @param {string} [scheme]
-	   * @return {string}
-	  */
-	  toHref: function toHref(scheme) {
-	    return this.toString();
-	  },
-
-	  /**
-	   * @param {Options} options Formatting options
-	   * @returns {string}
-	   */
-	  toFormattedString: function toFormattedString(options) {
-	    var val = this.toString();
-	    var truncate = options.get('truncate', val, this);
-	    var formatted = options.get('format', val, this);
-	    return truncate && formatted.length > truncate ? formatted.substring(0, truncate) + '…' : formatted;
-	  },
-
-	  /**
-	   *
-	   * @param {Options} options
-	   * @returns {string}
-	   */
-	  toFormattedHref: function toFormattedHref(options) {
-	    return options.get('formatHref', this.toHref(options.get('defaultProtocol')), this);
-	  },
-
-	  /**
-	   * The start index of this token in the original input string
-	   * @returns {number}
-	   */
-	  startIndex: function startIndex() {
-	    return this.tk[0].s;
-	  },
-
-	  /**
-	   * The end index of this token in the original input string (up to this
-	   * index but not including it)
-	   * @returns {number}
-	   */
-	  endIndex: function endIndex() {
-	    return this.tk[this.tk.length - 1].e;
-	  },
-
-	  /**
-	  	Returns an object  of relevant values for this token, which includes keys
-	  	* type - Kind of token ('url', 'email', etc.)
-	  	* value - Original text
-	  	* href - The value that should be added to the anchor tag's href
-	  		attribute
-	  		@method toObject
-	  	@param {string} [protocol] `'http'` by default
-	  */
-	  toObject: function toObject(protocol) {
-	    if (protocol === void 0) {
-	      protocol = defaults.defaultProtocol;
-	    }
-
-	    return {
-	      type: this.t,
-	      value: this.toString(),
-	      isLink: this.isLink,
-	      href: this.toHref(protocol),
-	      start: this.startIndex(),
-	      end: this.endIndex()
-	    };
-	  },
-
-	  /**
-	   *
-	   * @param {Options} options Formatting option
-	   */
-	  toFormattedObject: function toFormattedObject(options) {
-	    return {
-	      type: this.t,
-	      value: this.toFormattedString(options),
-	      isLink: this.isLink,
-	      href: this.toFormattedHref(options),
-	      start: this.startIndex(),
-	      end: this.endIndex()
-	    };
-	  },
-
-	  /**
-	   * Whether this token should be rendered as a link according to the given options
-	   * @param {Options} options
-	   * @returns {boolean}
-	   */
-	  validate: function validate(options) {
-	    return options.get('validate', this.toString(), this);
-	  },
-
-	  /**
-	   * Return an object that represents how this link should be rendered.
-	   * @param {Options} options Formattinng options
-	   */
-	  render: function render(options) {
-	    var token = this;
-	    var href = this.toFormattedHref(options);
-	    var tagName = options.get('tagName', href, token);
-	    var content = this.toFormattedString(options);
-	    var attributes = {};
-	    var className = options.get('className', href, token);
-	    var target = options.get('target', href, token);
-	    var rel = options.get('rel', href, token);
-	    var attrs = options.getObj('attributes', href, token);
-	    var eventListeners = options.getObj('events', href, token);
-	    attributes.href = href;
-
-	    if (className) {
-	      attributes.class = className;
-	    }
-
-	    if (target) {
-	      attributes.target = target;
-	    }
-
-	    if (rel) {
-	      attributes.rel = rel;
-	    }
-
-	    if (attrs) {
-	      assign(attributes, attrs);
-	    }
-
-	    return {
-	      tagName: tagName,
-	      attributes: attributes,
-	      content: content,
-	      eventListeners: eventListeners
-	    };
-	  }
-	}; // Base token
-	/**
-	 * Create a new token that can be emitted by the parser state machine
-	 * @param {string} type readable type of the token
-	 * @param {object} props properties to assign or override, including isLink = true or false
-	 * @returns {new (value: string, tokens: Token[]) => MultiToken} new token class
-	 */
-
-	function createTokenClass(type, props) {
-	  var Token = /*#__PURE__*/function (_MultiToken) {
-	    _inheritsLoose(Token, _MultiToken);
-
-	    function Token(value, tokens) {
-	      var _this;
-
-	      _this = _MultiToken.call(this, value, tokens) || this;
-	      _this.t = type;
-	      return _this;
-	    }
-
-	    return Token;
-	  }(MultiToken);
-
-	  for (var p in props) {
-	    Token.prototype[p] = props[p];
-	  }
-
-	  Token.t = type;
-	  return Token;
-	}
-	/**
-		Represents a list of tokens making up a valid email address
-	*/
-
-	var Email = createTokenClass('email', {
-	  isLink: true,
-	  toHref: function toHref() {
-	    return 'mailto:' + this.toString();
-	  }
-	});
-	/**
-		Represents some plain text
-	*/
-
-	var Text = createTokenClass('text');
-	/**
-		Multi-linebreak token - represents a line break
-		@class Nl
-	*/
-
-	var Nl = createTokenClass('nl');
-	/**
-		Represents a list of text tokens making up a valid URL
-		@class Url
-	*/
-
-	var Url = createTokenClass('url', {
-	  isLink: true,
-
-	  /**
-	  	Lowercases relevant parts of the domain and adds the protocol if
-	  	required. Note that this will not escape unsafe HTML characters in the
-	  	URL.
-	  		@param {string} [scheme] default scheme (e.g., 'https')
-	  	@return {string} the full href
-	  */
-	  toHref: function toHref(scheme) {
-	    if (scheme === void 0) {
-	      scheme = defaults.defaultProtocol;
-	    }
-
-	    // Check if already has a prefix scheme
-	    return this.hasProtocol() ? this.v : scheme + "://" + this.v;
-	  },
-
-	  /**
-	   * Check whether this URL token has a protocol
-	   * @return {boolean}
-	   */
-	  hasProtocol: function hasProtocol() {
-	    var tokens = this.tk;
-	    return tokens.length >= 2 && tokens[0].t !== LOCALHOST && tokens[1].t === COLON;
-	  }
-	});
-
-	var multi = /*#__PURE__*/Object.freeze({
-		__proto__: null,
-		MultiToken: MultiToken,
-		Base: MultiToken,
-		createTokenClass: createTokenClass,
-		Email: Email,
-		Text: Text,
-		Nl: Nl,
-		Url: Url
-	});
-
-	/**
-		Not exactly parser, more like the second-stage scanner (although we can
-		theoretically hotswap the code here with a real parser in the future... but
-		for a little URL-finding utility abstract syntax trees may be a little
-		overkill).
-
-		URL format: http://en.wikipedia.org/wiki/URI_scheme
-		Email format: http://en.wikipedia.org/wiki/EmailAddress (links to RFC in
-		reference)
-
-		@module linkify
-		@submodule parser
-		@main run
-	*/
-
-	var makeState = function makeState(arg) {
-	  return new State(arg);
-	};
-	/**
-	 * Generate the parser multi token-based state machine
-	 * @param {{ groups: Collections<string> }} tokens
-	 */
-
-
-	function init$1(_ref) {
-	  var groups = _ref.groups;
-	  // Types of characters the URL can definitely end in
-	  var qsAccepting = groups.domain.concat([AMPERSAND, ASTERISK, AT, BACKSLASH, BACKTICK, CARET, DOLLAR, EQUALS, HYPHEN, NUM, PERCENT, PIPE, PLUS, POUND, SLASH, SYM, TILDE, UNDERSCORE]); // Types of tokens that can follow a URL and be part of the query string
-	  // but cannot be the very last characters
-	  // Characters that cannot appear in the URL at all should be excluded
-
-	  var qsNonAccepting = [APOSTROPHE, CLOSEANGLEBRACKET, CLOSEBRACE, CLOSEBRACKET, CLOSEPAREN, COLON, COMMA, DOT, EXCLAMATION, OPENANGLEBRACKET, OPENBRACE, OPENBRACKET, OPENPAREN, QUERY, QUOTE, SEMI]; // For addresses without the mailto prefix
-	  // Tokens allowed in the localpart of the email
-
-	  var localpartAccepting = [AMPERSAND, APOSTROPHE, ASTERISK, BACKSLASH, BACKTICK, CARET, CLOSEBRACE, DOLLAR, EQUALS, HYPHEN, NUM, OPENBRACE, PERCENT, PIPE, PLUS, POUND, QUERY, SLASH, SYM, TILDE, UNDERSCORE]; // The universal starting state.
-
-	  /**
-	   * @type State<Token>
-	   */
-
-	  var Start = makeState();
-	  var Localpart = tt(Start, TILDE); // Local part of the email address
-
-	  ta(Localpart, localpartAccepting, Localpart);
-	  ta(Localpart, groups.domain, Localpart);
-	  var Domain = makeState(),
-	      Scheme = makeState(),
-	      SlashScheme = makeState();
-	  ta(Start, groups.domain, Domain); // parsed string ends with a potential domain name (A)
-
-	  ta(Start, groups.scheme, Scheme); // e.g., 'mailto'
-
-	  ta(Start, groups.slashscheme, SlashScheme); // e.g., 'http'
-
-	  ta(Domain, localpartAccepting, Localpart);
-	  ta(Domain, groups.domain, Domain);
-	  var LocalpartAt = tt(Domain, AT); // Local part of the email address plus @
-
-	  tt(Localpart, AT, LocalpartAt); // close to an email address now
-
-	  var LocalpartDot = tt(Localpart, DOT); // Local part of the email address plus '.' (localpart cannot end in .)
-
-	  ta(LocalpartDot, localpartAccepting, Localpart);
-	  ta(LocalpartDot, groups.domain, Localpart);
-	  var EmailDomain = makeState();
-	  ta(LocalpartAt, groups.domain, EmailDomain); // parsed string starts with local email info + @ with a potential domain name
-
-	  ta(EmailDomain, groups.domain, EmailDomain);
-	  var EmailDomainDot = tt(EmailDomain, DOT); // domain followed by DOT
-
-	  ta(EmailDomainDot, groups.domain, EmailDomain);
-	  var Email$1 = makeState(Email); // Possible email address (could have more tlds)
-
-	  ta(EmailDomainDot, groups.tld, Email$1);
-	  ta(EmailDomainDot, groups.utld, Email$1);
-	  tt(LocalpartAt, LOCALHOST, Email$1); // Hyphen can jump back to a domain name
-
-	  var EmailDomainHyphen = tt(EmailDomain, HYPHEN); // parsed string starts with local email info + @ with a potential domain name
-
-	  ta(EmailDomainHyphen, groups.domain, EmailDomain);
-	  ta(Email$1, groups.domain, EmailDomain);
-	  tt(Email$1, DOT, EmailDomainDot);
-	  tt(Email$1, HYPHEN, EmailDomainHyphen); // Final possible email states
-
-	  var EmailColon = tt(Email$1, COLON); // URL followed by colon (potential port number here)
-
-	  /*const EmailColonPort = */
-
-	  ta(EmailColon, groups.numeric, Email); // URL followed by colon and port numner
-	  // Account for dots and hyphens. Hyphens are usually parts of domain names
-	  // (but not TLDs)
-
-	  var DomainHyphen = tt(Domain, HYPHEN); // domain followed by hyphen
-
-	  var DomainDot = tt(Domain, DOT); // domain followed by DOT
-
-	  ta(DomainHyphen, groups.domain, Domain);
-	  ta(DomainDot, localpartAccepting, Localpart);
-	  ta(DomainDot, groups.domain, Domain);
-	  var DomainDotTld = makeState(Url); // Simplest possible URL with no query string
-
-	  ta(DomainDot, groups.tld, DomainDotTld);
-	  ta(DomainDot, groups.utld, DomainDotTld);
-	  ta(DomainDotTld, groups.domain, Domain);
-	  ta(DomainDotTld, localpartAccepting, Localpart);
-	  tt(DomainDotTld, DOT, DomainDot);
-	  tt(DomainDotTld, HYPHEN, DomainHyphen);
-	  tt(DomainDotTld, AT, LocalpartAt);
-	  var DomainDotTldColon = tt(DomainDotTld, COLON); // URL followed by colon (potential port number here)
-
-	  var DomainDotTldColonPort = makeState(Url); // TLD followed by a port number
-
-	  ta(DomainDotTldColon, groups.numeric, DomainDotTldColonPort); // Long URL with optional port and maybe query string
-
-	  var Url$1 = makeState(Url); // URL with extra symbols at the end, followed by an opening bracket
-
-	  var UrlNonaccept = makeState(); // URL followed by some symbols (will not be part of the final URL)
-	  // Query strings
-
-	  ta(Url$1, qsAccepting, Url$1);
-	  ta(Url$1, qsNonAccepting, UrlNonaccept);
-	  ta(UrlNonaccept, qsAccepting, Url$1);
-	  ta(UrlNonaccept, qsNonAccepting, UrlNonaccept); // Become real URLs after `SLASH` or `COLON NUM SLASH`
-	  // Here works with or without scheme:// prefix
-
-	  tt(DomainDotTld, SLASH, Url$1);
-	  tt(DomainDotTldColonPort, SLASH, Url$1); // Note that domains that begin with schemes are treated slighly differently
-
-	  var UriPrefix = tt(Scheme, COLON); // e.g., 'mailto:' or 'http://'
-
-	  var SlashSchemeColon = tt(SlashScheme, COLON); // e.g., 'http:'
-
-	  var SlashSchemeColonSlash = tt(SlashSchemeColon, SLASH); // e.g., 'http:/'
-
-	  tt(SlashSchemeColonSlash, SLASH, UriPrefix); // Scheme states can transition to domain states
-
-	  ta(Scheme, groups.domain, Domain);
-	  tt(Scheme, DOT, DomainDot);
-	  tt(Scheme, HYPHEN, DomainHyphen);
-	  ta(SlashScheme, groups.domain, Domain);
-	  tt(SlashScheme, DOT, DomainDot);
-	  tt(SlashScheme, HYPHEN, DomainHyphen); // Force URL with scheme prefix followed by anything sane
-
-	  ta(UriPrefix, groups.domain, Url$1);
-	  tt(UriPrefix, SLASH, Url$1); // URL, followed by an opening bracket
-
-	  var UrlOpenbrace = tt(Url$1, OPENBRACE); // URL followed by {
-
-	  var UrlOpenbracket = tt(Url$1, OPENBRACKET); // URL followed by [
-
-	  var UrlOpenanglebracket = tt(Url$1, OPENANGLEBRACKET); // URL followed by <
-
-	  var UrlOpenparen = tt(Url$1, OPENPAREN); // URL followed by (
-
-	  tt(UrlNonaccept, OPENBRACE, UrlOpenbrace);
-	  tt(UrlNonaccept, OPENBRACKET, UrlOpenbracket);
-	  tt(UrlNonaccept, OPENANGLEBRACKET, UrlOpenanglebracket);
-	  tt(UrlNonaccept, OPENPAREN, UrlOpenparen); // Closing bracket component. This character WILL be included in the URL
-
-	  tt(UrlOpenbrace, CLOSEBRACE, Url$1);
-	  tt(UrlOpenbracket, CLOSEBRACKET, Url$1);
-	  tt(UrlOpenanglebracket, CLOSEANGLEBRACKET, Url$1);
-	  tt(UrlOpenparen, CLOSEPAREN, Url$1);
-	  tt(UrlOpenbrace, CLOSEBRACE, Url$1); // URL that beings with an opening bracket, followed by a symbols.
-	  // Note that the final state can still be `UrlOpenbrace` (if the URL only
-	  // has a single opening bracket for some reason).
-
-	  var UrlOpenbraceQ = makeState(Url); // URL followed by { and some symbols that the URL can end it
-
-	  var UrlOpenbracketQ = makeState(Url); // URL followed by [ and some symbols that the URL can end it
-
-	  var UrlOpenanglebracketQ = makeState(Url); // URL followed by < and some symbols that the URL can end it
-
-	  var UrlOpenparenQ = makeState(Url); // URL followed by ( and some symbols that the URL can end it
-
-	  ta(UrlOpenbrace, qsAccepting, UrlOpenbraceQ);
-	  ta(UrlOpenbracket, qsAccepting, UrlOpenbracketQ);
-	  ta(UrlOpenanglebracket, qsAccepting, UrlOpenanglebracketQ);
-	  ta(UrlOpenparen, qsAccepting, UrlOpenparenQ);
-	  var UrlOpenbraceSyms = makeState(); // UrlOpenbrace followed by some symbols it cannot end it
-
-	  var UrlOpenbracketSyms = makeState(); // UrlOpenbracketQ followed by some symbols it cannot end it
-
-	  var UrlOpenanglebracketSyms = makeState(); // UrlOpenanglebracketQ followed by some symbols it cannot end it
-
-	  var UrlOpenparenSyms = makeState(); // UrlOpenparenQ followed by some symbols it cannot end it
-
-	  ta(UrlOpenbrace, qsNonAccepting);
-	  ta(UrlOpenbracket, qsNonAccepting);
-	  ta(UrlOpenanglebracket, qsNonAccepting);
-	  ta(UrlOpenparen, qsNonAccepting); // URL that begins with an opening bracket, followed by some symbols
-
-	  ta(UrlOpenbraceQ, qsAccepting, UrlOpenbraceQ);
-	  ta(UrlOpenbracketQ, qsAccepting, UrlOpenbracketQ);
-	  ta(UrlOpenanglebracketQ, qsAccepting, UrlOpenanglebracketQ);
-	  ta(UrlOpenparenQ, qsAccepting, UrlOpenparenQ);
-	  ta(UrlOpenbraceQ, qsNonAccepting, UrlOpenbraceQ);
-	  ta(UrlOpenbracketQ, qsNonAccepting, UrlOpenbracketQ);
-	  ta(UrlOpenanglebracketQ, qsNonAccepting, UrlOpenanglebracketQ);
-	  ta(UrlOpenparenQ, qsNonAccepting, UrlOpenparenQ);
-	  ta(UrlOpenbraceSyms, qsAccepting, UrlOpenbraceSyms);
-	  ta(UrlOpenbracketSyms, qsAccepting, UrlOpenbracketQ);
-	  ta(UrlOpenanglebracketSyms, qsAccepting, UrlOpenanglebracketQ);
-	  ta(UrlOpenparenSyms, qsAccepting, UrlOpenparenQ);
-	  ta(UrlOpenbraceSyms, qsNonAccepting, UrlOpenbraceSyms);
-	  ta(UrlOpenbracketSyms, qsNonAccepting, UrlOpenbracketSyms);
-	  ta(UrlOpenanglebracketSyms, qsNonAccepting, UrlOpenanglebracketSyms);
-	  ta(UrlOpenparenSyms, qsNonAccepting, UrlOpenparenSyms); // Close brace/bracket to become regular URL
-
-	  tt(UrlOpenbracketQ, CLOSEBRACKET, Url$1);
-	  tt(UrlOpenanglebracketQ, CLOSEANGLEBRACKET, Url$1);
-	  tt(UrlOpenparenQ, CLOSEPAREN, Url$1);
-	  tt(UrlOpenbraceQ, CLOSEBRACE, Url$1);
-	  tt(UrlOpenbracketSyms, CLOSEBRACKET, Url$1);
-	  tt(UrlOpenanglebracketSyms, CLOSEANGLEBRACKET, Url$1);
-	  tt(UrlOpenparenSyms, CLOSEPAREN, Url$1);
-	  tt(UrlOpenbraceSyms, CLOSEPAREN, Url$1);
-	  tt(Start, LOCALHOST, DomainDotTld); // localhost is a valid URL state
-
-	  tt(Start, NL$1, Nl); // single new line
-
-	  return {
-	    start: Start,
-	    tokens: tk
-	  };
-	}
-	/**
-	 * Run the parser state machine on a list of scanned string-based tokens to
-	 * create a list of multi tokens, each of which represents a URL, email address,
-	 * plain text, etc.
-	 *
-	 * @param {State<MultiToken>} start parser start state
-	 * @param {string} input the original input used to generate the given tokens
-	 * @param {Token[]} tokens list of scanned tokens
-	 * @returns {MultiToken[]}
-	 */
-
-	function run(start, input, tokens) {
-	  var len = tokens.length;
-	  var cursor = 0;
-	  var multis = [];
-	  var textTokens = [];
-
-	  while (cursor < len) {
-	    var state = start;
-	    var secondState = null;
-	    var nextState = null;
-	    var multiLength = 0;
-	    var latestAccepting = null;
-	    var sinceAccepts = -1;
-
-	    while (cursor < len && !(secondState = state.go(tokens[cursor].t))) {
-	      // Starting tokens with nowhere to jump to.
-	      // Consider these to be just plain text
-	      textTokens.push(tokens[cursor++]);
-	    }
-
-	    while (cursor < len && (nextState = secondState || state.go(tokens[cursor].t))) {
-	      // Get the next state
-	      secondState = null;
-	      state = nextState; // Keep track of the latest accepting state
-
-	      if (state.accepts()) {
-	        sinceAccepts = 0;
-	        latestAccepting = state;
-	      } else if (sinceAccepts >= 0) {
-	        sinceAccepts++;
-	      }
-
-	      cursor++;
-	      multiLength++;
-	    }
-
-	    if (sinceAccepts < 0) {
-	      // No accepting state was found, part of a regular text token add
-	      // the first text token to the text tokens array and try again from
-	      // the next
-	      cursor -= multiLength;
-
-	      if (cursor < len) {
-	        textTokens.push(tokens[cursor]);
-	        cursor++;
-	      }
-	    } else {
-	      // Accepting state!
-	      // First close off the textTokens (if available)
-	      if (textTokens.length > 0) {
-	        multis.push(initMultiToken(Text, input, textTokens));
-	        textTokens = [];
-	      } // Roll back to the latest accepting state
-
-
-	      cursor -= sinceAccepts;
-	      multiLength -= sinceAccepts; // Create a new multitoken
-
-	      var Multi = latestAccepting.t;
-	      var subtokens = tokens.slice(cursor - multiLength, cursor);
-	      multis.push(initMultiToken(Multi, input, subtokens));
-	    }
-	  } // Finally close off the textTokens (if available)
-
-
-	  if (textTokens.length > 0) {
-	    multis.push(initMultiToken(Text, input, textTokens));
-	  }
-
-	  return multis;
-	}
-	/**
-	 * Utility function for instantiating a new multitoken with all the relevant
-	 * fields during parsing.
-	 * @param {new (value: string, tokens: Token[]) => MultiToken} Multi class to instantiate
-	 * @param {string} input original input string
-	 * @param {Token[]} tokens consecutive tokens scanned from input string
-	 * @returns {MultiToken}
-	 */
-
-	function initMultiToken(Multi, input, tokens) {
-	  var startIdx = tokens[0].s;
-	  var endIdx = tokens[tokens.length - 1].e;
-	  var value = input.slice(startIdx, endIdx);
-	  return new Multi(value, tokens);
-	}
-
-	var warn = typeof console !== 'undefined' && console && console.warn || function () {};
-
-	var warnAdvice = 'To avoid this warning, please register all custom schemes before invoking linkify the first time.'; // Side-effect initialization state
-
-	var INIT = {
-	  scanner: null,
-	  parser: null,
-	  tokenQueue: [],
-	  pluginQueue: [],
-	  customSchemes: [],
-	  initialized: false
-	};
-	/**
-	 * @typedef {{
-	 * 	start: State<string>,
-	 * 	tokens: { groups: Collections<string> } & typeof tk
-	 * }} ScannerInit
-	 */
-
-	/**
-	 * @typedef {{
-	 * 	start: State<MultiToken>,
-	 * 	tokens: typeof multi
-	 * }} ParserInit
-	 */
-
-	/**
-	 * @typedef {(arg: { scanner: ScannerInit }) => void} TokenPlugin
-	 */
-
-	/**
-	 * @typedef {(arg: { scanner: ScannerInit, parser: ParserInit }) => void} Plugin
-	 */
-
-	/**
-	 * De-register all plugins and reset the internal state-machine. Used for
-	 * testing; not required in practice.
-	 * @private
-	 */
-
-	function reset() {
-	  State.groups = {};
-	  INIT.scanner = null;
-	  INIT.parser = null;
-	  INIT.tokenQueue = [];
-	  INIT.pluginQueue = [];
-	  INIT.customSchemes = [];
-	  INIT.initialized = false;
-	}
-	/**
-	 * Register a token plugin to allow the scanner to recognize additional token
-	 * types before the parser state machine is constructed from the results.
-	 * @param {string} name of plugin to register
-	 * @param {TokenPlugin} plugin function that accepts the scanner state machine
-	 * and available scanner tokens and collections and extends the state machine to
-	 * recognize additional tokens or groups.
-	 */
-
-	function registerTokenPlugin(name, plugin) {
-	  if (typeof plugin !== 'function') {
-	    throw new Error("linkifyjs: Invalid token plugin " + plugin + " (expects function)");
-	  }
-
-	  for (var i = 0; i < INIT.tokenQueue.length; i++) {
-	    if (name === INIT.tokenQueue[i][0]) {
-	      warn("linkifyjs: token plugin \"" + name + "\" already registered - will be overwritten");
-	      INIT.tokenQueue[i] = [name, plugin];
-	      return;
-	    }
-	  }
-
-	  INIT.tokenQueue.push([name, plugin]);
-
-	  if (INIT.initialized) {
-	    warn("linkifyjs: already initialized - will not register token plugin \"" + name + "\" until you manually call linkify.init(). " + warnAdvice);
-	  }
-	}
-	/**
-	 * Register a linkify plugin
-	 * @param {string} name of plugin to register
-	 * @param {Plugin} plugin function that accepts the parser state machine and
-	 * extends the parser to recognize additional link types
-	 */
-
-	function registerPlugin(name, plugin) {
-	  if (typeof plugin !== 'function') {
-	    throw new Error("linkifyjs: Invalid plugin " + plugin + " (expects function)");
-	  }
-
-	  for (var i = 0; i < INIT.pluginQueue.length; i++) {
-	    if (name === INIT.pluginQueue[i][0]) {
-	      warn("linkifyjs: plugin \"" + name + "\" already registered - will be overwritten");
-	      INIT.pluginQueue[i] = [name, plugin];
-	      return;
-	    }
-	  }
-
-	  INIT.pluginQueue.push([name, plugin]);
-
-	  if (INIT.initialized) {
-	    warn("linkifyjs: already initialized - will not register plugin \"" + name + "\" until you manually call linkify.init(). " + warnAdvice);
-	  }
-	}
-	/**
-	 * Detect URLs with the following additional protocol. Anything with format
-	 * "protocol://..." will be considered a link. If `optionalSlashSlash` is set to
-	 * `true`, anything with format "protocol:..." will be considered a link.
-	 * @param {string} protocol
-	 * @param {boolean} [optionalSlashSlash]
-	 */
-
-	function registerCustomProtocol(scheme, optionalSlashSlash) {
-	  if (optionalSlashSlash === void 0) {
-	    optionalSlashSlash = false;
-	  }
-
-	  if (INIT.initialized) {
-	    warn("linkifyjs: already initialized - will not register custom scheme \"" + scheme + "\" until you manually call linkify.init(). " + warnAdvice);
-	  }
-
-	  if (!/^[0-9a-z]+(-[0-9a-z]+)*$/.test(scheme)) {
-	    throw new Error('linkifyjs: incorrect scheme format.\n 1. Must only contain digits, lowercase ASCII letters or "-"\n 2. Cannot start or end with "-"\n 3. "-" cannot repeat');
-	  }
-
-	  INIT.customSchemes.push([scheme, optionalSlashSlash]);
-	}
-	/**
-	 * Initialize the linkify state machine. Called automatically the first time
-	 * linkify is called on a string, but may be called manually as well.
-	 */
-
-	function init() {
-	  // Initialize scanner state machine and plugins
-	  INIT.scanner = init$2(INIT.customSchemes);
-
-	  for (var i = 0; i < INIT.tokenQueue.length; i++) {
-	    INIT.tokenQueue[i][1]({
-	      scanner: INIT.scanner
-	    });
-	  } // Initialize parser state machine and plugins
-
-
-	  INIT.parser = init$1(INIT.scanner.tokens);
-
-	  for (var _i = 0; _i < INIT.pluginQueue.length; _i++) {
-	    INIT.pluginQueue[_i][1]({
-	      scanner: INIT.scanner,
-	      parser: INIT.parser
-	    });
-	  }
-
-	  INIT.initialized = true;
-	}
-	/**
-	 * Parse a string into tokens that represent linkable and non-linkable sub-components
-	 * @param {string} str
-	 * @return {MultiToken[]} tokens
-	 */
-
-	function tokenize(str) {
-	  if (!INIT.initialized) {
-	    init();
-	  }
-
-	  return run(INIT.parser.start, str, run$1(INIT.scanner.start, str));
-	}
-	/**
-	 * Find a list of linkable items in the given string.
-	 * @param {string} str string to find links in
-	 * @param {string | Opts} [type] either formatting options or specific type of
-	 * links to find, e.g., 'url' or 'email'
-	 * @param {Opts} [opts] formatting options for final output. Cannot be specified
-	 * if opts already provided in `type` argument
-	*/
-
-	function find(str, type, opts) {
-	  if (type === void 0) {
-	    type = null;
-	  }
-
-	  if (opts === void 0) {
-	    opts = null;
-	  }
-
-	  if (type && typeof type === 'object') {
-	    if (opts) {
-	      throw Error("linkifyjs: Invalid link type " + type + "; must be a string");
-	    }
-
-	    opts = type;
-	    type = null;
-	  }
-
-	  var options = new Options(opts);
-	  var tokens = tokenize(str);
-	  var filtered = [];
-
-	  for (var i = 0; i < tokens.length; i++) {
-	    var token = tokens[i];
-
-	    if (token.isLink && (!type || token.t === type)) {
-	      filtered.push(token.toFormattedObject(options));
-	    }
-	  }
-
-	  return filtered;
-	}
-	/**
-	 * Is the given string valid linkable text of some sort. Note that this does not
-	 * trim the text for you.
-	 *
-	 * Optionally pass in a second `type` param, which is the type of link to test
-	 * for.
-	 *
-	 * For example,
-	 *
-	 *     linkify.test(str, 'email');
-	 *
-	 * Returns `true` if str is a valid email.
-	 * @param {string} str string to test for links
-	 * @param {string} [type] optional specific link type to look for
-	 * @returns boolean true/false
-	 */
-
-	function test(str, type) {
-	  if (type === void 0) {
-	    type = null;
-	  }
-
-	  var tokens = tokenize(str);
-	  return tokens.length === 1 && tokens[0].isLink && (!type || tokens[0].t === type);
-	}
-
-	exports.MultiToken = MultiToken;
-	exports.Options = Options;
-	exports.State = State;
-	exports.createTokenClass = createTokenClass;
-	exports.find = find;
-	exports.init = init;
-	exports.multi = multi;
-	exports.options = options;
-	exports.regexp = regexp;
-	exports.registerCustomProtocol = registerCustomProtocol;
-	exports.registerPlugin = registerPlugin;
-	exports.registerTokenPlugin = registerTokenPlugin;
-	exports.reset = reset;
-	exports.stringToArray = stringToArray;
-	exports.test = test;
-	exports.tokenize = tokenize;
-
-	Object.defineProperty(exports, '__esModule', { value: true });
-
-	return exports;
-
-})({});
\ No newline at end of file
diff --git a/resources/webengine/previewInfo.js b/resources/webengine/previewInfo.js
deleted file mode 100644
index 83a14cae92775ce08436f8c09b6420bb2ec82a33..0000000000000000000000000000000000000000
--- a/resources/webengine/previewInfo.js
+++ /dev/null
@@ -1,126 +0,0 @@
-/* MIT License
-
-Copyright (c) 2019 Andrej Gajdos
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.*/
-
-/**
- * Retrieves the title of a webpage which is used to fill out the preview of a hyperlink
- * @param doc the DOM of the url that is being previewed
- * @returns the title of the given webpage
- */
-
-function getTitle(doc){
-    const og_title = doc.querySelector('meta[property="og:title"]')
-    if (og_title !== null && og_title.content.length > 0) {
-        return og_title.content
-    }
-    const twitter_title = doc.querySelector('meta[name="twitter:title"]')
-    if (twitter_title !== null && twitter_title.content.length > 0) {
-        return twitter_title.content
-    }
-    const doc_title = doc.title
-    if (doc_title !== null && doc_title.length > 0) {
-        return doc_title
-    }
-    if (doc.querySelector("h1") !== null){
-        const header_1 = doc.querySelector("h1").innerHTML
-        if (header_1 !== null && header_1.length > 0) {
-            return header_1
-        }
-    }
-    if (doc.querySelector("h2") !== null){
-        const header_2 = doc.querySelector("h2").innerHTML
-        if (header_2 !== null && header_2.length > 0) {
-            return header_2
-        }
-    }
-    return null
-}
-
-/**
- * Obtains a description of the webpage for the hyperlink preview
- * @param doc the DOM of the url that is being previewed
- * @returns a description of the webpage
- */
-function getDescription(doc){
-    const og_description = doc.querySelector('meta[property="og:description"]')
-    if (og_description !== null && og_description.content.length > 0) {
-        return og_description.content
-    }
-    const twitter_description = doc.querySelector('meta[name="twitter:description"]')
-    if (twitter_description !== null && twitter_description.content.length > 0) {
-        return twitter_description.content
-    }
-    const meta_description = doc.querySelector('meta[name="description"]')
-    if (meta_description !== null && meta_description.content.length > 0) {
-        return meta_description.content
-    }
-    var all_paragraphs = doc.querySelectorAll("p")
-    let first_visible_paragraph = null
-    for (var i = 0; i < all_paragraphs.length; i++) {
-        if (all_paragraphs[i].offsetParent !== null &&
-                !all_paragraphs[i].childElementCount !== 0) {
-            first_visible_paragraph = all_paragraphs[i].textContent
-            break
-        }
-    }
-    return first_visible_paragraph
-}
-
-/**
- * Gets the image that represents a webpage.
- * @param doc the DOM of the url that is being previewed
- * @returns the image representing the url or null if no such image was found
- */
-function getImage(doc) {
-    const og_image = doc.querySelector('meta[property="og:image"]')
-    if (og_image !== null && og_image.content.length > 0){
-        return og_image.content
-    }
-    const image_rel_link = doc.querySelector('link[rel="image_src"]')
-    if (image_rel_link !== null && image_rel_link.href.length > 0){
-        return image_rel_link.href
-    }
-    const twitter_img = doc.querySelector('meta[name="twitter:image"]')
-    if (twitter_img !== null && twitter_img.content.length > 0) {
-        return twitter_img.content
-    }
-
-    let imgs = Array.from(doc.getElementsByTagName("img"))
-    if (imgs.length > 0) {
-        imgs = imgs.filter(img => {
-            let add_image = true
-            if (img.naturalWidth > img.naturalHeight) {
-                if (img.naturalWidth / img.naturalHeight > 3) {
-                    add_image = false
-                }
-            } else {
-                if (img.naturalHeight / img.naturalWidth > 3) {
-                    add_image = false
-                }
-            }
-            if (img.naturalHeight <= 50 || img.naturalWidth <= 50) {
-                add_image = false
-            }
-            return add_image
-        })
-    }
-    return null
-}
diff --git a/resources/webengine/previewInterop.js b/resources/webengine/previewInterop.js
deleted file mode 100644
index 6e4709bf486b59e90495493f01c82c195b07a9e5..0000000000000000000000000000000000000000
--- a/resources/webengine/previewInterop.js
+++ /dev/null
@@ -1,93 +0,0 @@
-_ = new QWebChannel(qt.webChannelTransport, function (channel) {
-    window.jsbridge = channel.objects.jsbridge
-})
-
-function log(msg) {
-    window.jsbridge.log(msg)
-}
-
-function getPreviewInfo(messageId, url) {
-    var title = null
-    var description = null
-    var image = null
-    var u = new URL(url)
-    if (u.protocol === '') {
-        url = "https://".concat(url)
-    }
-    var domain = (new URL(url))
-    fetch(url, {
-              mode: 'no-cors',
-              headers: {'Set-Cookie': 'SameSite=None; Secure'}
-          }).then(function (response) {
-        const contentType = response.headers.get('content-type');
-        if (!contentType || !contentType.includes('text/html')) {
-            return
-        }
-        return response.body
-    }).then(body => {
-        const reader = body.getReader();
-
-        return new ReadableStream({
-          start(controller) {
-            return pump();
-
-            function pump() {
-                return reader.read().then(({ done, value }) => {
-                    // When no more data needs to be consumed, close the stream
-                    if (done) {
-                        controller.close();
-                        return;
-                    }
-                    if(value.byteLength > 2*1024*1024) {
-                        controller.close();
-                        return;
-                    }
-
-                    // Enqueue the next data chunk into our target stream
-                    controller.enqueue(value);
-                    return pump();
-                });
-            }
-          }
-        })
-      }, e => Promise.reject(e))
-      .then(stream => new Response(stream))
-      .then(response => response.text())
-      .then(function (html) {
-        // create DOM from html string
-        var parser = new DOMParser()
-        var doc = parser.parseFromString(html, "text/html")
-        if (!url.includes("twitter.com")){
-            title = getTitle(doc)
-        } else {
-            title = "Twitter. It's what's happening."
-        }
-        image = getImage(doc, url)
-        description = getDescription(doc)
-        domain = (domain.hostname).replace("www.", "")
-    }).catch(function (err) {
-        log("Error occured while fetching document: " + err)
-    }).finally(() => {
-        window.jsbridge.emitInfoReady(messageId, {
-            'title': title,
-            'image': image,
-            'description': description,
-            'url': url,
-            'domain': domain,
-        })
-    })
-}
-
-function parseMessage(messageId, message, showPreview, color='#0645AD') {
-    var links = linkify.find(message)
-    if (links.length === 0) {
-        return
-    }
-    if (showPreview)
-        getPreviewInfo(messageId, links[0].href)
-    window.jsbridge.emitLinkified(messageId, linkifyStr(message, {
-        attributes: {
-          style: "color:" + color + ";"
-        }
-    }))
-}
diff --git a/src/app/MainApplicationWindow.qml b/src/app/MainApplicationWindow.qml
index e657b310370ba96123984d293f6600d02514b635..44935cfeacfe52210dccf5d5dfc5468ad90c3b4e 100644
--- a/src/app/MainApplicationWindow.qml
+++ b/src/app/MainApplicationWindow.qml
@@ -329,15 +329,15 @@ ApplicationWindow {
         function onUpdateErrorOccurred(error) {
             presentUpdateInfoDialog((function () {
                         switch (error) {
-                        case NetWorkManager.ACCESS_DENIED:
+                        case NetworkManager.ACCESS_DENIED:
                             return JamiStrings.genericError;
-                        case NetWorkManager.DISCONNECTED:
+                        case NetworkManager.DISCONNECTED:
                             return JamiStrings.networkDisconnected;
-                        case NetWorkManager.NETWORK_ERROR:
+                        case NetworkManager.NETWORK_ERROR:
                             return JamiStrings.updateNetworkError;
-                        case NetWorkManager.SSL_ERROR:
+                        case NetworkManager.SSL_ERROR:
                             return JamiStrings.updateSSLError;
-                        case NetWorkManager.CANCELED:
+                        case NetworkManager.CANCELED:
                             return JamiStrings.updateDownloadCanceled;
                         default:
                             return {};
diff --git a/src/app/commoncomponents/TextMessageDelegate.qml b/src/app/commoncomponents/TextMessageDelegate.qml
index b46d430693f84d8d64c1518c0630fe1305a17d97..978f8f82976cd8bde60f00244ad2d32f3d857ed5 100644
--- a/src/app/commoncomponents/TextMessageDelegate.qml
+++ b/src/app/commoncomponents/TextMessageDelegate.qml
@@ -51,10 +51,11 @@ SBSMessageBase {
             padding: isEmojiOnly ? 0 : JamiTheme.preferredMarginSize
             anchors.right: isOutgoing ? parent.right : undefined
             text: {
-                if (LinkifiedBody !== "" && Linkified.length === 0) {
-                    MessagesAdapter.parseMessageUrls(Id, Body, UtilsAdapter.getAppValue(Settings.DisplayHyperlinkPreviews), root.colorUrl);
+                if (Body !== "" && ParsedBody.length === 0) {
+                    MessagesAdapter.parseMessage(Id, Body, UtilsAdapter.getAppValue(Settings.DisplayHyperlinkPreviews), root.colorUrl, CurrentConversation.color);
+                    return ""
                 }
-                return (LinkifiedBody !== "") ? LinkifiedBody : "*(" + JamiStrings.deletedMessage + ")*";
+                return (ParsedBody !== "") ? ParsedBody : "*(" + JamiStrings.deletedMessage + ")*";
             }
             horizontalAlignment: Text.AlignLeft
 
@@ -76,7 +77,8 @@ SBSMessageBase {
             font.pixelSize: isEmojiOnly ? JamiTheme.chatviewEmojiSize : JamiTheme.emojiBubbleSize
             font.hintingPreference: Font.PreferNoHinting
             renderType: Text.NativeRendering
-            textFormat: Text.MarkdownText
+            textFormat: Text.RichText
+            clip: true
             onLinkHovered: root.hoveredLink = hoveredLink
             onLinkActivated: Qt.openUrlExternally(new URL(hoveredLink))
             readOnly: true
@@ -227,7 +229,7 @@ SBSMessageBase {
                         renderType: Text.NativeRendering
                         textFormat: TextEdit.RichText
                         color: UtilsAdapter.luma(bubble.color) ? JamiTheme.chatviewTextColorLight : JamiTheme.chatviewTextColorDark
-                        visible: LinkPreviewInfo.title !== null
+                        visible: LinkPreviewInfo.title.length > 0
                         text: LinkPreviewInfo.title
                     }
                     Label {
@@ -237,7 +239,7 @@ SBSMessageBase {
                         wrapMode: Label.WrapAtWordBoundaryOrAnywhere
                         renderType: Text.NativeRendering
                         textFormat: TextEdit.RichText
-                        visible: LinkPreviewInfo.description !== null
+                        visible: LinkPreviewInfo.description.length > 0
                         font.underline: root.hoveredLink
                         text: LinkPreviewInfo.description
                         color: root.colorUrl
@@ -263,10 +265,5 @@ SBSMessageBase {
             duration: 100
         }
     }
-    Component.onCompleted: {
-        if (Linkified.length === 0) {
-            MessagesAdapter.parseMessageUrls(Id, Body, UtilsAdapter.getAppValue(Settings.DisplayHyperlinkPreviews), root.colorUrl);
-        }
-        opacity = 1;
-    }
+    Component.onCompleted: opacity = 1;
 }
diff --git a/src/app/connectivitymonitor.cpp b/src/app/connectivitymonitor.cpp
index f3b04559fc49d90b9b0bfc45ab61857bae0b331b..b622a892ad237e6ee75fd932b5c7db207b1fb47f 100644
--- a/src/app/connectivitymonitor.cpp
+++ b/src/app/connectivitymonitor.cpp
@@ -183,7 +183,7 @@ primaryConnectionChanged(NMClient* nm, GParamSpec*, ConnectivityMonitor* cm)
 {
     auto connection = nm_client_get_primary_connection(nm);
     logConnectionInfo(connection);
-    cm->connectivityChanged();
+    Q_EMIT cm->connectivityChanged();
 }
 
 static void
diff --git a/src/app/connectivitymonitor.h b/src/app/connectivitymonitor.h
index 9e2ac65dfa2775db278181212331b51f078eadf1..e74a22c7c214ddc0370c8cd8cf8e41ad9276d9a8 100644
--- a/src/app/connectivitymonitor.h
+++ b/src/app/connectivitymonitor.h
@@ -20,7 +20,6 @@
 
 #include <QObject>
 
-#ifdef Q_OS_WIN
 class ConnectivityMonitor final : public QObject
 {
     Q_OBJECT
@@ -34,6 +33,7 @@ Q_SIGNALS:
     void connectivityChanged();
 
 private:
+#ifdef Q_OS_WIN
     void destroy();
 
     struct INetworkListManager* pNetworkListManager_;
@@ -41,21 +41,5 @@ private:
     struct IConnectionPoint* pConnectPoint_;
     class NetworkEventHandler* netEventHandler_;
     unsigned long cookie_;
-};
-
-#else
-// TODO: platform implementations should be in the daemon.
-
-class ConnectivityMonitor final : public QObject
-{
-    Q_OBJECT
-public:
-    explicit ConnectivityMonitor(QObject* parent = nullptr);
-    ~ConnectivityMonitor();
-
-    bool isOnline();
-
-Q_SIGNALS:
-    void connectivityChanged();
-};
 #endif // Q_OS_WIN
+};
diff --git a/src/app/htmlparser.h b/src/app/htmlparser.h
new file mode 100644
index 0000000000000000000000000000000000000000..c656542b9a8937f718373a9ad63ba026b43d5e8a
--- /dev/null
+++ b/src/app/htmlparser.h
@@ -0,0 +1,107 @@
+/*
+ * Copyright (C) 2023 Savoir-faire Linux Inc.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include <QObject>
+#include <QVariantMap>
+
+#include "tidy.h"
+#include "tidybuffio.h"
+
+// This class is used to parse HTML strings. It uses the libtidy library to parse
+// the HTML and traverse the DOM tree. It can be used to extract a list of tags
+// and their values from an HTML string.
+// Currently, it is used to extract the <a> and <pre> tags from a message body,
+// and in the future it can be used in conjunction with QtNetwork to generate link
+// previews without having to use QtWebEngine.
+class HtmlParser : public QObject
+{
+    Q_OBJECT
+public:
+    HtmlParser(QObject* parent = nullptr)
+        : QObject(parent)
+    {
+        doc_ = tidyCreate();
+        tidyOptSetBool(doc_, TidyQuiet, yes);
+        tidyOptSetBool(doc_, TidyShowWarnings, no);
+    }
+
+    ~HtmlParser()
+    {
+        tidyRelease(doc_);
+    }
+
+    bool parseHtmlString(const QString& html)
+    {
+        return tidyParseString(doc_, html.toLocal8Bit().data()) >= 0;
+    }
+
+    using TagInfoList = QMap<TidyTagId, QList<QString>>;
+
+    // A function that traverses the DOM tree and fills a QVariantMap with a list
+    // of the tags and their values. The result is structured as follows:
+    // {tagId1: ["tagValue1", "tagValue2", ...],
+    //  tagId: ["tagValue1", "tagValue2", ...],
+    //  ... }
+    TagInfoList getTags(QList<TidyTagId> tags, int maxDepth = -1)
+    {
+        TagInfoList result;
+        traverseNode(
+            tidyGetRoot(doc_),
+            tags,
+            [&result](const QString& value, TidyTagId tag) { result[tag].append(value); },
+            maxDepth);
+
+        return result;
+    }
+
+    QString getFirstTagValue(TidyTagId tag, int maxDepth = -1)
+    {
+        QString result;
+        traverseNode(
+            tidyGetRoot(doc_),
+            {tag},
+            [&result](const QString& value, TidyTagId) { result = value; },
+            maxDepth);
+        return result;
+    }
+
+private:
+    void traverseNode(TidyNode node,
+                      QList<TidyTagId> tags,
+                      const std::function<void(const QString&, TidyTagId)>& cb,
+                      int depth = -1)
+    {
+        TidyBuffer nodeValue = {};
+        for (auto tag : tags) {
+            if (tidyNodeGetId(node) == tag && tidyNodeGetText(doc_, node, &nodeValue) == yes && cb) {
+                cb(QString::fromLocal8Bit(nodeValue.bp), tag);
+                if (depth != -1 && --depth == 0) {
+                    return;
+                }
+            }
+        }
+
+        // Traverse the children of the current node.
+        for (TidyNode child = tidyGetChild(node); child; child = tidyGetNext(child)) {
+            traverseNode(child, tags, cb, depth);
+        }
+    }
+
+    TidyDoc doc_;
+};
diff --git a/src/app/mainapplication.cpp b/src/app/mainapplication.cpp
index b310cfbb8205bc2a6aad76bcb7fbd4839534dbb8..9ed5eff04578224dfe5db6873e36df88955fae9d 100644
--- a/src/app/mainapplication.cpp
+++ b/src/app/mainapplication.cpp
@@ -122,7 +122,7 @@ MainApplication::init()
     connectivityMonitor_.reset(new ConnectivityMonitor(this));
     settingsManager_.reset(new AppSettingsManager(this));
     systemTray_.reset(new SystemTray(settingsManager_.get(), this));
-    previewEngine_.reset(new PreviewEngine(this));
+    previewEngine_.reset(new PreviewEngine(connectivityMonitor_.get(), this));
 
     QObject::connect(settingsManager_.get(),
                      &AppSettingsManager::retranslate,
diff --git a/src/app/mainview/components/EditContainer.qml b/src/app/mainview/components/EditContainer.qml
index 43b80df1a077911632cb72f3201950fa8e98fea3..2c78dafd1c1a739d4ba2dad46d076cafda18c35b 100644
--- a/src/app/mainview/components/EditContainer.qml
+++ b/src/app/mainview/components/EditContainer.qml
@@ -31,7 +31,7 @@ Rectangle {
     property var body: {
         if (MessagesAdapter.editId === "")
             return "";
-        return MessagesAdapter.dataForInteraction(MessagesAdapter.editId, MessageList.LinkifiedBody);
+        return MessagesAdapter.dataForInteraction(MessagesAdapter.editId, MessageList.ParsedBody);
     }
 
     RowLayout {
diff --git a/src/app/messageparser.cpp b/src/app/messageparser.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..7941e12dbefc5ca424eef323abdd64bf1939c479
--- /dev/null
+++ b/src/app/messageparser.cpp
@@ -0,0 +1,184 @@
+/*
+ * Copyright (C) 2023 Savoir-faire Linux Inc.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "messageparser.h"
+
+#include "previewengine.h"
+#include "htmlparser.h"
+
+#include <QRegularExpression>
+#include <QtConcurrent>
+
+#include "md4c-html.h"
+
+MessageParser::MessageParser(PreviewEngine* previewEngine, QObject* parent)
+    : QObject(parent)
+    , previewEngine_(previewEngine)
+    , htmlParser_(new HtmlParser(this))
+    , threadPool_(new QThreadPool(this))
+{
+    threadPool_->setMaxThreadCount(1);
+    connect(previewEngine_, &PreviewEngine::infoReady, this, &MessageParser::linkInfoReady);
+}
+
+void
+MessageParser::parseMessage(const QString& messageId,
+                            const QString& msg,
+                            bool previewLinks,
+                            const QColor& linkColor,
+                            const QColor& backgroundColor)
+{
+    // Run everything here on a separate thread.
+    threadPool_->start(
+        [this, messageId, md = msg, previewLinks, linkColor, backgroundColor]() mutable -> void {
+            preprocessMarkdown(md);
+            auto html = markdownToHtml(md.toUtf8().constData());
+
+            // Now that we have the HTML, we can parse it to get a list of tags and their values.
+            // We are only interested in the <a> and <pre> tags.
+            htmlParser_->parseHtmlString(html);
+            auto tagsMap = htmlParser_->getTags({TidyTag_A, TidyTag_DEL, TidyTag_PRE});
+
+            static QString styleTag("<style>%1</style>");
+            QString style;
+
+            // Check for any <pre> tags. If there are any, we need to:
+            // 1. add some CSS to color them.
+            // 2. add some CSS to make them wrap.
+            if (tagsMap.contains(TidyTag_PRE)) {
+                auto textColor = (0.2126 * backgroundColor.red() + 0.7152 * backgroundColor.green()
+                                  + 0.0722 * backgroundColor.blue())
+                                         < 153
+                                     ? QColor(255, 255, 255)
+                                     : QColor(0, 0, 0);
+                style += QString("pre,code{background-color:%1;"
+                                 "color:%2;white-space:pre-wrap;}")
+                             .arg(backgroundColor.name(), textColor.name());
+            }
+
+            // md4c makes DEL tags instead of S tags for ~~strikethrough-text~~,
+            // so we need to style the DEL tag content.
+            if (tagsMap.contains(TidyTag_DEL)) {
+                style += QString("del{text-decoration:line-through;}");
+            }
+
+            // Check for any <a> tags. If there are any, we need to:
+            // 1. add some CSS to color them.
+            // 2. parse them to get a preview IF the user has enabled link previews.
+            if (tagsMap.contains(TidyTag_A)) {
+                style += QString("a{color:%1;}").arg(linkColor.name());
+
+                // Update the UI before we start parsing the link.
+                html.prepend(QString(styleTag).arg(style));
+                Q_EMIT messageParsed(messageId, html);
+
+                // If the user has enabled link previews, then we need to generate the link preview.
+                if (previewLinks) {
+                    // Get the first link in the message.
+                    auto anchorTag = tagsMap[TidyTag_A].first();
+                    static QRegularExpression hrefRegex("href=\"(.*?)\"");
+                    auto match = hrefRegex.match(anchorTag);
+                    if (match.hasMatch()) {
+                        Q_EMIT previewEngine_->parseLink(messageId, match.captured(1));
+                    }
+                }
+
+                return;
+            }
+
+            // If the message didn't contain any links, then we can just update the UI.
+            html.prepend(QString(styleTag).arg(style));
+            Q_EMIT messageParsed(messageId, html);
+        });
+}
+
+void
+MessageParser::preprocessMarkdown(QString& markdown)
+{
+    // Match all instances of the linefeed character.
+    static QRegularExpression newlineRegex("\n");
+    static const QString newline = "  \n";
+
+    // Replace all instances of the linefeed character with 2 spaces + a linefeed character
+    // in order to force a line break in the HTML.
+    // Note: we should only do this for non-code fenced blocks.
+    static QRegularExpression codeFenceRe("`{1,3}([\\s\\S]*?)`{1,3}");
+    auto match = codeFenceRe.globalMatch(markdown);
+
+    // If there are no code blocks, then we can just replace all linefeeds with 2 spaces
+    // + a linefeed, and we're done.
+    if (!match.hasNext()) {
+        markdown.replace(newlineRegex, newline);
+        return;
+    }
+
+    // Save each block of text and code. The text blocks will be
+    // processed for line breaks and the code blocks will be left
+    // as is.
+    enum BlockType { Text, Code };
+    QVector<QPair<BlockType, QString>> codeBlocks;
+
+    int start = 0;
+    while (match.hasNext()) {
+        auto m = match.next();
+        auto nonCodelength = m.capturedStart() - start;
+        if (nonCodelength) {
+            codeBlocks.push_back({Text, markdown.mid(start, nonCodelength)});
+        }
+        codeBlocks.push_back({Code, m.captured(0)});
+        start = m.capturedStart() + m.capturedLength();
+    }
+    // There may be some text after the last code block.
+    if (start < markdown.size()) {
+        codeBlocks.push_back({Text, markdown.mid(start)});
+    }
+
+    // Now we can process the text blocks.
+    markdown.clear();
+    for (auto& block : codeBlocks) {
+        if (block.first == Text) {
+            // Replace all newlines with two spaces and a newline.
+            block.second.replace(newlineRegex, newline);
+        }
+        markdown += block.second;
+    }
+}
+
+// A callback function that will be called by the md4c library (`md_html`) to output the HTML.
+static void
+htmlChunkCb(const MD_CHAR* data, MD_SIZE data_size, void* userData)
+{
+    QByteArray* array = static_cast<QByteArray*>(userData);
+    if (data_size > 0) {
+        array->append(data, int(data_size));
+    }
+};
+
+QString
+MessageParser::markdownToHtml(const char* markdown)
+{
+    static auto md_flags = MD_FLAG_PERMISSIVEAUTOLINKS | MD_FLAG_NOINDENTEDCODEBLOCKS
+                           | MD_FLAG_TASKLISTS | MD_FLAG_STRIKETHROUGH | MD_FLAG_UNDERLINE;
+    size_t data_len = strlen(markdown);
+    if (data_len <= 0) {
+        return QString();
+    } else {
+        QByteArray array;
+        int result = md_html(markdown, MD_SIZE(data_len), &htmlChunkCb, &array, md_flags, 0);
+        return result == 0 ? QString::fromUtf8(array) : QString();
+    }
+}
diff --git a/src/app/messageparser.h b/src/app/messageparser.h
new file mode 100644
index 0000000000000000000000000000000000000000..81edb5e9b066e53fdb2bba7776eb6d403ccc3cd2
--- /dev/null
+++ b/src/app/messageparser.h
@@ -0,0 +1,77 @@
+/*
+ * Copyright (C) 2023 Savoir-faire Linux Inc.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include <QObject>
+#include <QColor>
+#include <QThreadPool>
+
+class PreviewEngine;
+class HtmlParser;
+
+// This class is used to parse messages and encapsulate the logic that
+// prepares a message for display in the UI. The basic steps are:
+// 1. preprocess the markdown message (e.g. handle line breaks)
+// 2. transform markdown syntax into HTML
+// 3. generate previews for the first link in the message (if any)
+// 4. add the appropriate CSS classes to the HTML (e.g. for links, code blocks, etc.)
+//
+// Step 3. is done asynchronously, so the message is displayed as soon as possible
+// and the preview is added later.
+class MessageParser final : public QObject
+{
+    Q_OBJECT
+    Q_DISABLE_COPY(MessageParser)
+public:
+    // Create a new MessageParser instance. We take an instance of PreviewEngine.
+    explicit MessageParser(PreviewEngine* previewEngine, QObject* parent = nullptr);
+    ~MessageParser() = default;
+
+    // Parse the message. This will emit the messageParsed signal when the
+    // message is ready to be displayed.
+    void parseMessage(const QString& messageId,
+                      const QString& msg,
+                      bool previewLinks,
+                      const QColor& linkColor,
+                      const QColor& backgroundColor);
+
+    // Emitted when the message is ready to be displayed.
+    Q_SIGNAL void messageParsed(const QString& msgId, const QString& msg);
+
+    // Emitted when the message preview is ready to be displayed.
+    Q_SIGNAL void linkInfoReady(const QString& msgId, const QVariantMap& info);
+
+private:
+    // Preprocess the markdown message (e.g. handle line breaks).
+    void preprocessMarkdown(QString& markdown);
+
+    // Transform markdown syntax into HTML.
+    QString markdownToHtml(const char* markdown);
+
+    // Generate a preview for the given link, then emit the messageParsed signal.
+    void generatePreview(const QString& msgId, const QString& link);
+
+    // The PreviewEngine instance used to generate previews.
+    PreviewEngine* previewEngine_;
+
+    // An instance of HtmlParser used to parse HTML.
+    HtmlParser* htmlParser_;
+
+    // Used to queue parse operations.
+    QThreadPool* threadPool_;
+};
diff --git a/src/app/messagesadapter.cpp b/src/app/messagesadapter.cpp
index dfbf0f7feedb5716f2e1a3bf3e90d322710ad226..0f18e2f7ed1d3af34a8fd0a5a2d2a40ea53f3031 100644
--- a/src/app/messagesadapter.cpp
+++ b/src/app/messagesadapter.cpp
@@ -25,6 +25,7 @@
 
 #include "appsettingsmanager.h"
 #include "qtutils.h"
+#include "messageparser.h"
 
 #include <api/datatransfermodel.h>
 
@@ -48,7 +49,7 @@ MessagesAdapter::MessagesAdapter(AppSettingsManager* settingsManager,
                                  QObject* parent)
     : QmlAdapterBase(instance, parent)
     , settingsManager_(settingsManager)
-    , previewEngine_(previewEngine)
+    , messageParser_(new MessageParser(previewEngine, this))
     , filteredMsgListModel_(new FilteredMsgListModel(this))
     , mediaInteractions_(std::make_unique<MessageListModel>())
     , timestampTimer_(new QTimer(this))
@@ -71,8 +72,8 @@ MessagesAdapter::MessagesAdapter(AppSettingsManager* settingsManager,
         set_mediaMessageListModel(QVariant::fromValue(mediaInteractions_.get()));
     });
 
-    connect(previewEngine_, &PreviewEngine::infoReady, this, &MessagesAdapter::onPreviewInfoReady);
-    connect(previewEngine_, &PreviewEngine::linkified, this, &MessagesAdapter::onMessageLinkified);
+    connect(messageParser_, &MessageParser::messageParsed, this, &MessagesAdapter::onMessageParsed);
+    connect(messageParser_, &MessageParser::linkInfoReady, this, &MessagesAdapter::onLinkInfoReady);
 
     connect(timestampTimer_, &QTimer::timeout, this, &MessagesAdapter::timestampUpdated);
     timestampTimer_->start(timestampUpdateIntervalMs_);
@@ -445,6 +446,24 @@ MessagesAdapter::onNewInteraction(const QString& convUid,
     }
 }
 
+void
+MessagesAdapter::onMessageParsed(const QString& messageId, const QString& parsed)
+{
+    const QString& convId = lrcInstance_->get_selectedConvUid();
+    const QString& accId = lrcInstance_->get_currentAccountId();
+    auto& conversation = lrcInstance_->getConversationFromConvUid(convId, accId);
+    conversation.interactions->setParsedMessage(messageId, parsed);
+}
+
+void
+MessagesAdapter::onLinkInfoReady(const QString& messageId, const QVariantMap& info)
+{
+    const QString& convId = lrcInstance_->get_selectedConvUid();
+    const QString& accId = lrcInstance_->get_currentAccountId();
+    auto& conversation = lrcInstance_->getConversationFromConvUid(convId, accId);
+    conversation.interactions->addHyperlinkInfo(messageId, info);
+}
+
 void
 MessagesAdapter::acceptInvitation(const QString& convId)
 {
@@ -540,15 +559,6 @@ MessagesAdapter::removeContact(const QString& convUid, bool banContact)
     accInfo.contactModel->removeContact(contactUri, banContact);
 }
 
-void
-MessagesAdapter::onPreviewInfoReady(QString messageId, QVariantMap info)
-{
-    const QString& convId = lrcInstance_->get_selectedConvUid();
-    const QString& accId = lrcInstance_->get_currentAccountId();
-    auto& conversation = lrcInstance_->getConversationFromConvUid(convId, accId);
-    conversation.interactions->addHyperlinkInfo(messageId, info);
-}
-
 void
 MessagesAdapter::onConversationMessagesLoaded(uint32_t loadingRequestId, const QString& convId)
 {
@@ -558,21 +568,13 @@ MessagesAdapter::onConversationMessagesLoaded(uint32_t loadingRequestId, const Q
 }
 
 void
-MessagesAdapter::parseMessageUrls(const QString& messageId,
-                                  const QString& msg,
-                                  bool showPreview,
-                                  QColor color)
+MessagesAdapter::parseMessage(const QString& msgId,
+                              const QString& msg,
+                              bool showPreview,
+                              const QColor& linkColor,
+                              const QColor& backgroundColor)
 {
-    previewEngine_->parseMessage(messageId, msg, showPreview, color);
-}
-
-void
-MessagesAdapter::onMessageLinkified(const QString& messageId, const QString& linkified)
-{
-    const QString& convId = lrcInstance_->get_selectedConvUid();
-    const QString& accId = lrcInstance_->get_currentAccountId();
-    auto& conversation = lrcInstance_->getConversationFromConvUid(convId, accId);
-    conversation.interactions->linkifyMessage(messageId, linkified);
+    messageParser_->parseMessage(msgId, msg, showPreview, linkColor, backgroundColor);
 }
 
 void
diff --git a/src/app/messagesadapter.h b/src/app/messagesadapter.h
index 9d63b9e817ac565e7b512aa22c9ee1969654bdad..ec8bebfc8a0dfd25374897bf0258f129ed31ad2c 100644
--- a/src/app/messagesadapter.h
+++ b/src/app/messagesadapter.h
@@ -28,6 +28,9 @@
 
 #include <QSortFilterProxyModel>
 
+class AppSettingsManager;
+class MessageParser;
+
 class FilteredMsgListModel final : public QSortFilterProxyModel
 {
     Q_OBJECT
@@ -50,8 +53,6 @@ public:
     };
 };
 
-class AppSettingsManager;
-
 class MessagesAdapter final : public QmlAdapterBase
 {
     Q_OBJECT
@@ -74,7 +75,6 @@ Q_SIGNALS:
     void newMessageBarPlaceholderText(QString& placeholderText);
     void newFilePasted(QString filePath);
     void newTextPasted();
-    void previewInformationToQML(QString messageId, QStringList previewInformation);
     void moreMessagesLoaded(qint32 loadingRequestId);
     void timestampUpdated();
     void fileCopied(const QString& dest);
@@ -123,10 +123,11 @@ protected:
     Q_INVOKABLE QString getFormattedDay(const quint64 timestamp);
     Q_INVOKABLE QString getFormattedTime(const quint64 timestamp);
     Q_INVOKABLE QString getBestFormattedDate(const quint64 timestamp);
-    Q_INVOKABLE void parseMessageUrls(const QString& messageId,
-                                      const QString& msg,
-                                      bool showPreview,
-                                      QColor color = "#0645AD");
+    Q_INVOKABLE void parseMessage(const QString& msgId,
+                                  const QString& msg,
+                                  bool previewLinks,
+                                  const QColor& linkColor = QColor(0x06, 0x45, 0xad),
+                                  const QColor& backgroundColor = QColor(0x0, 0x0, 0x0));
     Q_INVOKABLE void onPaste();
     Q_INVOKABLE int getIndexOfMessage(const QString& messageId) const;
     Q_INVOKABLE QString getStatusString(int status);
@@ -147,9 +148,9 @@ private Q_SLOTS:
     void onNewInteraction(const QString& convUid,
                           const QString& interactionId,
                           const interaction::Info& interaction);
-    void onPreviewInfoReady(QString messageIndex, QVariantMap urlInMessage);
+    void onMessageParsed(const QString& messageId, const QString& parsed);
+    void onLinkInfoReady(const QString& messageIndex, const QVariantMap& info);
     void onConversationMessagesLoaded(uint32_t requestId, const QString& convId);
-    void onMessageLinkified(const QString& messageId, const QString& linkified);
     void onComposingStatusChanged(const QString& convId,
                                   const QString& contactUri,
                                   bool isComposing);
@@ -160,7 +161,8 @@ private:
     QList<QString> conversationTypersUrlToName(const QSet<QString>& typersSet);
 
     AppSettingsManager* settingsManager_;
-    PreviewEngine* previewEngine_;
+    MessageParser* messageParser_;
+
     FilteredMsgListModel* filteredMsgListModel_;
 
     static constexpr const int loadChunkSize_ {20};
diff --git a/src/app/networkmanager.cpp b/src/app/networkmanager.cpp
index db45d4b92d8b5b1fb7a5446d28f81c4b57f8e125..4e84049942426e92fd49b73045ff4becc85e7346 100644
--- a/src/app/networkmanager.cpp
+++ b/src/app/networkmanager.cpp
@@ -1,7 +1,5 @@
-/*!
+/*
  * Copyright (C) 2019-2023 Savoir-faire Linux Inc.
- * Author: Mingrui Zhang <mingrui.zhang@savoirfairelinux.com>
- * Author: Andreas Traczyk <andreas.traczyk@savoirfairelinux.com>
  *
  * This program is free software; you can redistribute it and/or modify
  * it under the terms of the GNU General Public License as published by
@@ -20,23 +18,29 @@
 #include "networkmanager.h"
 
 #include "connectivitymonitor.h"
-#include "utils.h"
 
 #include <QMetaEnum>
 #include <QtNetwork>
 
-NetWorkManager::NetWorkManager(ConnectivityMonitor* cm, QObject* parent)
+NetworkManager::NetworkManager(ConnectivityMonitor* cm, QObject* parent)
     : QObject(parent)
     , manager_(new QNetworkAccessManager(this))
-    , reply_(nullptr)
     , connectivityMonitor_(cm)
     , lastConnectionState_(cm->isOnline())
 {
-    Q_EMIT statusChanged(GetStatus::IDLE);
-
-    connect(connectivityMonitor_, &ConnectivityMonitor::connectivityChanged, [this] {
-        cancelRequest();
-
+#if QT_CONFIG(ssl)
+    connect(manager_,
+            &QNetworkAccessManager::sslErrors,
+            this,
+            [this](QNetworkReply* reply, const QList<QSslError>& errors) {
+                Q_UNUSED(reply);
+                Q_FOREACH (const QSslError& error, errors) {
+                    qWarning() << Q_FUNC_INFO << error.errorString();
+                    Q_EMIT errorOccured(GetError::SSL_ERROR, error.errorString());
+                }
+            });
+#endif
+    connect(connectivityMonitor_, &ConnectivityMonitor::connectivityChanged, this, [this] {
         auto connected = connectivityMonitor_->isOnline();
         if (connected && !lastConnectionState_) {
             manager_->deleteLater();
@@ -48,126 +52,16 @@ NetWorkManager::NetWorkManager(ConnectivityMonitor* cm, QObject* parent)
 }
 
 void
-NetWorkManager::get(const QUrl& url, const DoneCallBack& doneCb, const QString& path)
+NetworkManager::sendGetRequest(const QUrl& url,
+                               std::function<void(const QByteArray&)> onDoneCallback)
 {
-    if (!connectivityMonitor_->isOnline()) {
-        Q_EMIT errorOccured(GetError::DISCONNECTED);
-        return;
-    }
-
-    if (reply_ && reply_->isRunning()) {
-        qWarning() << Q_FUNC_INFO << "currently downloading";
-        return;
-    } else if (url.isEmpty()) {
-        qWarning() << Q_FUNC_INFO << "missing url";
-        return;
-    }
-
-    if (!path.isEmpty()) {
-        QFileInfo fileInfo(url.path());
-        QString fileName = fileInfo.fileName();
-
-        file_.reset(new QFile(path + "/" + fileName));
-        if (!file_->open(QIODevice::WriteOnly)) {
-            Q_EMIT errorOccured(GetError::ACCESS_DENIED);
-            file_.reset(nullptr);
-            return;
+    auto reply = manager_->get(QNetworkRequest(url));
+    QObject::connect(reply, &QNetworkReply::finished, this, [reply, onDoneCallback]() {
+        if (reply->error() == QNetworkReply::NoError) {
+            onDoneCallback(reply->readAll());
+        } else {
+            onDoneCallback(reply->errorString().toUtf8());
         }
-    }
-
-    QNetworkRequest request(url);
-    reply_ = manager_->get(request);
-
-    Q_EMIT statusChanged(GetStatus::STARTED);
-
-    connect(reply_,
-            QOverload<QNetworkReply::NetworkError>::of(&QNetworkReply::errorOccurred),
-            [this, doneCb, path](QNetworkReply::NetworkError error) {
-                reply_->disconnect();
-                reset(true);
-                qWarning() << Q_FUNC_INFO << "NetworkError: "
-                           << QMetaEnum::fromType<QNetworkReply::NetworkError>().valueToKey(error);
-                Q_EMIT errorOccured(GetError::NETWORK_ERROR);
-            });
-
-    connect(reply_, &QNetworkReply::finished, [this, doneCb, path]() {
-        reply_->disconnect();
-        QString response = {};
-        if (path.isEmpty())
-            response = QString(reply_->readAll());
-        reset(!path.isEmpty());
-        Q_EMIT statusChanged(GetStatus::FINISHED);
-        if (doneCb)
-            doneCb(response);
+        reply->deleteLater();
     });
-
-    connect(reply_,
-            &QNetworkReply::downloadProgress,
-            this,
-            &NetWorkManager::downloadProgressChanged);
-
-    connect(reply_, &QNetworkReply::readyRead, this, &NetWorkManager::onHttpReadyRead);
-
-#if QT_CONFIG(ssl)
-    connect(reply_,
-            SIGNAL(sslErrors(const QList<QSslError>&)),
-            this,
-            SLOT(onSslErrors(QList<QSslError>)),
-            Qt::UniqueConnection);
-#endif
-}
-
-void
-NetWorkManager::reset(bool flush)
-{
-    reply_->deleteLater();
-    reply_ = nullptr;
-    if (file_ && flush) {
-        file_->flush();
-        file_->close();
-        file_.reset(nullptr);
-    }
-}
-
-void
-NetWorkManager::onSslErrors(const QList<QSslError>& sslErrors)
-{
-#if QT_CONFIG(ssl)
-    reply_->disconnect();
-    reset(true);
-
-    QString errorsString;
-    for (const QSslError& error : sslErrors) {
-        if (errorsString.length() > 0) {
-            errorsString += "\n";
-        }
-        errorsString += error.errorString();
-    }
-    Q_EMIT errorOccured(GetError::SSL_ERROR, errorsString);
-    return;
-#else
-    Q_UNUSED(sslErrors);
-#endif
-}
-
-void
-NetWorkManager::onHttpReadyRead()
-{
-    /*
-     * This slot gets called every time the QNetworkReply has new data.
-     * We read all of its new data and write it into the file.
-     * That way we use less RAM than when reading it at the finished()
-     * signal of the QNetworkReply
-     */
-    if (file_)
-        file_->write(reply_->readAll());
-}
-
-void
-NetWorkManager::cancelRequest()
-{
-    if (reply_) {
-        reply_->abort();
-        Q_EMIT errorOccured(GetError::CANCELED);
-    }
 }
diff --git a/src/app/networkmanager.h b/src/app/networkmanager.h
index 2609834b96b7294bc06f705db6f0ec7685c3e89d..bf80b0d95c0c2cdb7ac374063292218eee6dd397 100644
--- a/src/app/networkmanager.h
+++ b/src/app/networkmanager.h
@@ -1,7 +1,5 @@
-/*!
+/*
  * Copyright (C) 2019-2023 Savoir-faire Linux Inc.
- * Author: Mingrui Zhang <mingrui.zhang@savoirfairelinux.com>
- * Author: Andreas Traczyk <andreas.traczyk@savoirfairelinux.com>
  *
  * This program is free software; you can redistribute it and/or modify
  * it under the terms of the GNU General Public License as published by
@@ -27,50 +25,26 @@
 class QNetworkAccessManager;
 class ConnectivityMonitor;
 
-class NetWorkManager : public QObject
+class NetworkManager : public QObject
 {
     Q_OBJECT
 public:
-    explicit NetWorkManager(ConnectivityMonitor* cm, QObject* parent = nullptr);
-    virtual ~NetWorkManager() = default;
-
-    enum GetStatus { IDLE, STARTED, FINISHED };
+    explicit NetworkManager(ConnectivityMonitor* cm, QObject* parent = nullptr);
+    virtual ~NetworkManager() = default;
 
     enum GetError { DISCONNECTED, NETWORK_ERROR, ACCESS_DENIED, SSL_ERROR, CANCELED };
     Q_ENUM(GetError)
 
-    using DoneCallBack = std::function<void(const QString&)>;
-
-    /*!
-     * using qt get request to store the reply in file
-     * @param url - network address
-     * @param doneCb - done callback
-     * @param path - optional file saving path, if empty
-     * a string will be passed as the second paramter of doneCb
-     */
-    void get(const QUrl& url, const DoneCallBack& doneCb = {}, const QString& path = {});
-
-    /*!
-     * manually abort the current request
-     */
-    Q_INVOKABLE void cancelRequest();
+    void sendGetRequest(const QUrl& url, std::function<void(const QByteArray&)> onDoneCallback);
 
 Q_SIGNALS:
-    void statusChanged(GetStatus error);
-    void downloadProgressChanged(qint64 bytesRead, qint64 totalBytes);
     void errorOccured(GetError error, const QString& msg = {});
 
-private Q_SLOTS:
-    void onSslErrors(const QList<QSslError>& sslErrors);
-    void onHttpReadyRead();
+protected:
+    QNetworkAccessManager* manager_;
 
 private:
-    void reset(bool flush = true);
-
-    QNetworkAccessManager* manager_;
-    QNetworkReply* reply_;
-    QScopedPointer<QFile> file_;
     ConnectivityMonitor* connectivityMonitor_;
     bool lastConnectionState_;
 };
-Q_DECLARE_METATYPE(NetWorkManager*)
+Q_DECLARE_METATYPE(NetworkManager*)
diff --git a/src/app/nowebengine/previewengine.cpp b/src/app/nowebengine/previewengine.cpp
deleted file mode 100644
index aeab27467fad491aea86b567e9667dfa7a5b8b64..0000000000000000000000000000000000000000
--- a/src/app/nowebengine/previewengine.cpp
+++ /dev/null
@@ -1,52 +0,0 @@
-/*
- * Copyright (C) 2022-2023 Savoir-faire Linux Inc.
- * Author: Kateryna Kostiuk <kateryna.kostiuk@savoirfairelinux.com>
- *
- * This program is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation; either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program.  If not, see <https://www.gnu.org/licenses/>.
- */
-
-#include "previewengine.h"
-
-struct PreviewEngine::Impl : public QObject
-{
-    Impl(PreviewEngine&)
-        : QObject(nullptr)
-    {}
-};
-
-PreviewEngine::PreviewEngine(QObject* parent)
-    : QObject(parent)
-    , pimpl_(std::make_unique<Impl>(*this))
-{}
-
-PreviewEngine::~PreviewEngine() {}
-
-void
-PreviewEngine::parseMessage(const QString&, const QString&, bool, QColor)
-{}
-
-void
-PreviewEngine::log(const QString&)
-{}
-
-void
-PreviewEngine::emitInfoReady(const QString&, const QVariantMap&)
-{}
-
-void
-PreviewEngine::emitLinkified(const QString&, const QString&)
-{}
-
-#include "moc_previewengine.cpp"
-#include "previewengine.moc"
diff --git a/src/app/os/macos/updatemanager.mm b/src/app/os/macos/updatemanager.mm
index ac4d73dcf502c5bb3cbdc52b2f93c0dc798907d4..124f081cd7d1b68d80657204edd799d65cfcff46 100644
--- a/src/app/os/macos/updatemanager.mm
+++ b/src/app/os/macos/updatemanager.mm
@@ -84,7 +84,7 @@ UpdateManager::UpdateManager(const QString& url,
                              ConnectivityMonitor* cm,
                              LRCInstance* instance,
                              QObject* parent)
-    : NetWorkManager(cm, parent)
+    : NetworkManager(cm, parent)
     , pimpl_(std::make_unique<Impl>())
 {}
 
diff --git a/src/app/previewengine.cpp b/src/app/previewengine.cpp
index 2b8609be3553362e272e140b52d1d13c7ffee7c9..bd9f1bc65500cc3fd1b558dd0cc94ee8bb089ec5 100644
--- a/src/app/previewengine.cpp
+++ b/src/app/previewengine.cpp
@@ -1,7 +1,5 @@
 /*
  * Copyright (C) 2021-2023 Savoir-faire Linux Inc.
- * Author: Trevor Tabah <trevor.tabah@savoirfairelinux.com>
- * Author: Andreas Traczyk <andreas.traczyk@savoirfairelinux.com>
  *
  * This program is free software; you can redistribute it and/or modify
  * it under the terms of the GNU General Public License as published by
@@ -19,101 +17,103 @@
 
 #include "previewengine.h"
 
-#include "utils.h"
+#include <QRegularExpression>
 
-#include <QWebEngineScript>
-#include <QWebEngineProfile>
-#include <QWebEngineSettings>
-
-#include <QtWebChannel>
-#include <QWebEnginePage>
-
-struct PreviewEngine::Impl : public QWebEnginePage
+static QString
+getInnerHtml(const QString& tag)
 {
-public:
-    PreviewEngine& parent_;
-    QWebChannel* channel_;
-
-    Impl(PreviewEngine& parent)
-        : QWebEnginePage((QObject*) nullptr)
-        , parent_(parent)
-    {
-        QWebEngineProfile* profile = QWebEngineProfile::defaultProfile();
-
-        QDir dataDir(QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation));
-        dataDir.cdUp();
-        auto cachePath = dataDir.absolutePath() + "/jami";
-        profile->setCachePath(cachePath);
-        profile->setPersistentStoragePath(cachePath);
-        profile->setPersistentCookiesPolicy(QWebEngineProfile::NoPersistentCookies);
-        profile->setHttpCacheType(QWebEngineProfile::NoCache);
-
-        settings()->setAttribute(QWebEngineSettings::JavascriptEnabled, true);
-        settings()->setAttribute(QWebEngineSettings::ScrollAnimatorEnabled, false);
-        settings()->setAttribute(QWebEngineSettings::ErrorPageEnabled, false);
-        settings()->setAttribute(QWebEngineSettings::PluginsEnabled, false);
-        settings()->setAttribute(QWebEngineSettings::ScreenCaptureEnabled, false);
-        settings()->setAttribute(QWebEngineSettings::LinksIncludedInFocusChain, false);
-        settings()->setAttribute(QWebEngineSettings::LocalStorageEnabled, false);
-        settings()->setAttribute(QWebEngineSettings::AllowRunningInsecureContent, true);
-        settings()->setAttribute(QWebEngineSettings::LocalContentCanAccessRemoteUrls, true);
-        settings()->setAttribute(QWebEngineSettings::XSSAuditingEnabled, false);
-        settings()->setAttribute(QWebEngineSettings::LocalContentCanAccessFileUrls, true);
+    static const QRegularExpression re(">([^<]+)<");
+    const auto match = re.match(tag);
+    return match.hasMatch() ? match.captured(1) : QString {};
+};
 
-        channel_ = new QWebChannel(this);
-        channel_->registerObject(QStringLiteral("jsbridge"), &parent_);
+const QRegularExpression PreviewEngine::newlineRe("\\n");
 
-        setWebChannel(channel_);
-        runJavaScript(Utils::QByteArrayFromFile(":webengine/linkify.js"),
-                      QWebEngineScript::MainWorld);
-        runJavaScript(Utils::QByteArrayFromFile(":webengine/linkify-string.js"),
-                      QWebEngineScript::MainWorld);
-        runJavaScript(Utils::QByteArrayFromFile(":webengine/qwebchannel.js"),
-                      QWebEngineScript::MainWorld);
-        runJavaScript(Utils::QByteArrayFromFile(":webengine/previewInfo.js"),
-                      QWebEngineScript::MainWorld);
-        runJavaScript(Utils::QByteArrayFromFile(":webengine/previewInterop.js"),
-                      QWebEngineScript::MainWorld);
-    }
+PreviewEngine::PreviewEngine(ConnectivityMonitor* cm, QObject* parent)
+    : NetworkManager(cm, parent)
+    , htmlParser_(new HtmlParser(this))
+{
+    // Connect on a queued connection to avoid blocking caller thread.
+    connect(this, &PreviewEngine::parseLink, this, &PreviewEngine::onParseLink, Qt::QueuedConnection);
+}
 
-    void parseMessage(const QString& messageId, const QString& msg, bool showPreview, QColor color)
-    {
-        QString colorStr = "'" + color.name() + "'";
-        runJavaScript(QString("parseMessage(`%1`, `%2`, %3, %4)")
-                          .arg(messageId, msg, showPreview ? "true" : "false", colorStr));
+QString
+PreviewEngine::getTagContent(QList<QString>& tags, const QString& value)
+{
+    Q_FOREACH (auto tag, tags) {
+        const QRegularExpression re("(property|name)=\"(og:|twitter:|)" + value
+                                    + "\".*?content=\"([^\"]+)\"");
+
+        const auto match = re.match(tag.remove(newlineRe));
+        if (match.hasMatch()) {
+            return match.captured(3);
+        }
     }
-};
-
-PreviewEngine::PreviewEngine(QObject* parent)
-    : QObject(parent)
-    , pimpl_(std::make_unique<Impl>(*this))
-{}
-
-PreviewEngine::~PreviewEngine() {}
+    return QString {};
+}
 
-void
-PreviewEngine::parseMessage(const QString& messageId,
-                            const QString& msg,
-                            bool showPreview,
-                            QColor color)
+QString
+PreviewEngine::getTitle(HtmlParser::TagInfoList& metaTags)
 {
-    pimpl_->parseMessage(messageId, msg, showPreview, color);
+    // Try with opengraph/twitter props
+    QString title = getTagContent(metaTags[TidyTag_META], "title");
+    if (title.isEmpty()) { // Try with title tag
+        title = getInnerHtml(htmlParser_->getFirstTagValue(TidyTag_TITLE));
+    }
+    if (title.isEmpty()) { // Try with h1 tag
+        title = getInnerHtml(htmlParser_->getFirstTagValue(TidyTag_H1));
+    }
+    if (title.isEmpty()) { // Try with h2 tag
+        title = getInnerHtml(htmlParser_->getFirstTagValue(TidyTag_H2));
+    }
+    return title;
 }
 
-void
-PreviewEngine::log(const QString& str)
+QString
+PreviewEngine::getDescription(HtmlParser::TagInfoList& metaTags)
 {
-    qDebug() << str;
+    // Try with og/twitter props
+    QString d = getTagContent(metaTags[TidyTag_META], "description");
+    if (d.isEmpty()) { // Try with first paragraph
+        d = getInnerHtml(htmlParser_->getFirstTagValue(TidyTag_P));
+    }
+    return d;
 }
 
-void
-PreviewEngine::emitInfoReady(const QString& messageId, const QVariantMap& info)
+QString
+PreviewEngine::getImage(HtmlParser::TagInfoList& metaTags)
 {
-    Q_EMIT infoReady(messageId, info);
+    static const QRegularExpression newlineRe("\\n");
+    // Try with og/twitter props
+    QString image = getTagContent(metaTags[TidyTag_META], "image");
+    if (image.isEmpty()) { // Try with href of link tag (rel="image_src")
+        auto tags = htmlParser_->getTags({TidyTag_LINK});
+        Q_FOREACH (auto tag, tags[TidyTag_LINK]) {
+            static const QRegularExpression re("rel=\"image_src\".*?href=\"([^\"]+)\"");
+            const auto match = re.match(tag.remove(newlineRe));
+            if (match.hasMatch()) {
+                return match.captured(1);
+            }
+        }
+    }
+    return image;
 }
 
 void
-PreviewEngine::emitLinkified(const QString& messageId, const QString& linkifiedStr)
+PreviewEngine::onParseLink(const QString& messageId, const QString& link)
 {
-    Q_EMIT linkified(messageId, linkifiedStr);
+    sendGetRequest(QUrl(link), [this, messageId, link](const QByteArray& html) {
+        htmlParser_->parseHtmlString(html);
+        auto metaTags = htmlParser_->getTags({TidyTag_META});
+        QString domain = QUrl(link).host();
+        if (domain.isEmpty()) {
+            domain = link;
+        }
+        Q_EMIT infoReady(messageId,
+                         {{"title", getTitle(metaTags)},
+                          {"description", getDescription(metaTags)},
+                          {"image", getImage(metaTags)},
+                          {"url", link},
+                          {"domain", domain}});
+    });
 }
diff --git a/src/app/previewengine.h b/src/app/previewengine.h
index be9e3f6b5c44ac6e239362ea66a40fdb4bf6d611..db14a96886179febfea78db377d11ed95e996a03 100644
--- a/src/app/previewengine.h
+++ b/src/app/previewengine.h
@@ -1,7 +1,5 @@
 /*
  * Copyright (C) 2021-2023 Savoir-faire Linux Inc.
- * Author: Trevor Tabah <trevor.tabah@savoirfairelinux.com>
- * Author: Andreas Traczyk <andreas.traczyk@savoirfairelinux.com>
  *
  * This program is free software; you can redistribute it and/or modify
  * it under the terms of the GNU General Public License as published by
@@ -19,31 +17,32 @@
 
 #pragma once
 
-#include <QColor>
-#include <QObject>
+#include "networkmanager.h"
 
-class PreviewEngine : public QObject
+#include "htmlparser.h"
+
+class PreviewEngine final : public NetworkManager
 {
     Q_OBJECT
     Q_DISABLE_COPY(PreviewEngine)
 public:
-    PreviewEngine(QObject* parent = nullptr);
-    ~PreviewEngine();
-
-    void parseMessage(const QString& messageId,
-                      const QString& msg,
-                      bool showPreview,
-                      QColor color = "#0645AD");
-
-    Q_INVOKABLE void log(const QString& str);
-    Q_INVOKABLE void emitInfoReady(const QString& messageId, const QVariantMap& info);
-    Q_INVOKABLE void emitLinkified(const QString& messageId, const QString& linkifiedStr);
+    PreviewEngine(ConnectivityMonitor* cm, QObject* parent = nullptr);
+    ~PreviewEngine() = default;
 
 Q_SIGNALS:
+    void parseLink(const QString& messageId, const QString& link);
     void infoReady(const QString& messageId, const QVariantMap& info);
-    void linkified(const QString& messageId, const QString& linkifiedStr);
 
 private:
-    struct Impl;
-    std::unique_ptr<Impl> pimpl_;
+    Q_SLOT void onParseLink(const QString& messageId, const QString& link);
+
+    // An instance of HtmlParser used to parse HTML.
+    HtmlParser* htmlParser_;
+
+    QString getTagContent(QList<QString>& tags, const QString& value);
+    QString getTitle(HtmlParser::TagInfoList& metaTags);
+    QString getDescription(HtmlParser::TagInfoList& metaTags);
+    QString getImage(HtmlParser::TagInfoList& metaTags);
+
+    static const QRegularExpression newlineRe;
 };
diff --git a/src/app/qmlregister.cpp b/src/app/qmlregister.cpp
index bc7a88dd49d016d43ebd8b9bf6ad8b855447766f..f3f9ffe97637db388b34100126730534ab180d93 100644
--- a/src/app/qmlregister.cpp
+++ b/src/app/qmlregister.cpp
@@ -226,7 +226,7 @@ registerTypes(QQmlEngine* engine,
 
     // Enums
     QML_REGISTERUNCREATABLE(NS_ENUMS, Settings)
-    QML_REGISTERUNCREATABLE(NS_ENUMS, NetWorkManager)
+    QML_REGISTERUNCREATABLE(NS_ENUMS, NetworkManager)
     QML_REGISTERUNCREATABLE(NS_ENUMS, WizardViewStepModel)
     QML_REGISTERUNCREATABLE(NS_ENUMS, DeviceItemListModel)
     QML_REGISTERUNCREATABLE(NS_ENUMS, VideoInputDeviceModel)
diff --git a/src/app/settingsview/components/UpdateDownloadDialog.qml b/src/app/settingsview/components/UpdateDownloadDialog.qml
index 74d0894e18b09d030db4121fa7321ebaac553625..e0bab64a715af78cb88d99f5640375dad0090e3e 100644
--- a/src/app/settingsview/components/UpdateDownloadDialog.qml
+++ b/src/app/settingsview/components/UpdateDownloadDialog.qml
@@ -36,7 +36,7 @@ SimpleMessageDialog {
     Connections {
         target: UpdateManager
 
-        function onUpdateDownloadProgressChanged(bytesRead, totalBytes) {
+        function onDownloadProgressChanged(bytesRead, totalBytes) {
             downloadDialog.setDownloadProgress(bytesRead, totalBytes);
         }
 
@@ -98,10 +98,10 @@ SimpleMessageDialog {
     buttonTitles: [JamiStrings.optionCancel]
     buttonStyles: [SimpleMessageDialog.ButtonStyle.TintedBlue]
     buttonCallBacks: [function () {
-            UpdateManager.cancelUpdate();
+            UpdateManager.cancelDownload();
         }]
     onVisibleChanged: {
         if (!visible)
-            UpdateManager.cancelUpdate();
+            UpdateManager.cancelDownload();
     }
 }
diff --git a/src/app/updatemanager.cpp b/src/app/updatemanager.cpp
index 652fbaca127da2fbfa805eb61c6d1cdbbdcca80f..180cd2b3f624aac1a065a86f1e4cd293e30e00fb 100644
--- a/src/app/updatemanager.cpp
+++ b/src/app/updatemanager.cpp
@@ -39,7 +39,7 @@ static constexpr char betaMsiSubUrl[] = "/beta/jami.beta.x64.msi";
 
 struct UpdateManager::Impl : public QObject
 {
-    Impl(const QString& url, ConnectivityMonitor* cm, LRCInstance* instance, UpdateManager& parent)
+    Impl(const QString& url, LRCInstance* instance, UpdateManager& parent)
         : QObject(nullptr)
         , parent_(parent)
         , lrcInstance_(instance)
@@ -60,14 +60,14 @@ struct UpdateManager::Impl : public QObject
         // Fail without UI if this is a programmatic check.
         if (!quiet)
             connect(&parent_,
-                    &NetWorkManager::errorOccured,
+                    &NetworkManager::errorOccured,
                     &parent_,
                     &UpdateManager::updateErrorOccurred);
 
         cleanUpdateFiles();
         QUrl versionUrl {isBeta ? QUrl::fromUserInput(baseUrlString_ + betaVersionSubUrl)
                                 : QUrl::fromUserInput(baseUrlString_ + versionSubUrl)};
-        parent_.get(versionUrl, [this, quiet](const QString& latestVersionString) {
+        parent_.sendGetRequest(versionUrl, [this, quiet](const QByteArray& latestVersionString) {
             if (latestVersionString.isEmpty()) {
                 qWarning() << "Error checking version";
                 if (!quiet)
@@ -92,16 +92,12 @@ struct UpdateManager::Impl : public QObject
     {
         parent_.disconnect();
         connect(&parent_,
-                &NetWorkManager::errorOccured,
+                &NetworkManager::errorOccured,
                 &parent_,
                 &UpdateManager::updateErrorOccurred);
-        connect(&parent_, &NetWorkManager::statusChanged, this, [this](GetStatus status) {
+        connect(&parent_, &UpdateManager::statusChanged, this, [this](GetStatus status) {
             switch (status) {
             case GetStatus::STARTED:
-                connect(&parent_,
-                        &NetWorkManager::downloadProgressChanged,
-                        &parent_,
-                        &UpdateManager::updateDownloadProgressChanged);
                 Q_EMIT parent_.updateDownloadStarted();
                 break;
             case GetStatus::FINISHED:
@@ -115,9 +111,11 @@ struct UpdateManager::Impl : public QObject
         QUrl downloadUrl {(beta || isBeta) ? QUrl::fromUserInput(baseUrlString_ + betaMsiSubUrl)
                                            : QUrl::fromUserInput(baseUrlString_ + msiSubUrl)};
 
-        parent_.get(
+        parent_.downloadFile(
             downloadUrl,
-            [this, downloadUrl](const QString&) {
+            [this, downloadUrl](bool success, const QString& errorMessage) {
+                Q_UNUSED(success)
+                Q_UNUSED(errorMessage)
                 lrcInstance_->finish();
                 Q_EMIT lrcInstance_->quitEngineRequested();
                 auto args = QString(" /passive /norestart WIXNONUILAUNCH=1");
@@ -132,7 +130,7 @@ struct UpdateManager::Impl : public QObject
 
     void cancelUpdate()
     {
-        parent_.cancelRequest();
+        parent_.cancelDownload();
     };
 
     void setAutoUpdateCheck(bool state)
@@ -175,11 +173,14 @@ UpdateManager::UpdateManager(const QString& url,
                              ConnectivityMonitor* cm,
                              LRCInstance* instance,
                              QObject* parent)
-    : NetWorkManager(cm, parent)
-    , pimpl_(std::make_unique<Impl>(url, cm, instance, *this))
+    : NetworkManager(cm, parent)
+    , pimpl_(std::make_unique<Impl>(url, instance, *this))
 {}
 
-UpdateManager::~UpdateManager() {}
+UpdateManager::~UpdateManager()
+{
+    cancelDownload();
+}
 
 void
 UpdateManager::checkForUpdates(bool quiet)
@@ -225,3 +226,112 @@ UpdateManager::isAutoUpdaterEnabled()
 {
     return false;
 }
+
+void
+UpdateManager::cancelDownload()
+{
+    if (downloadReply_) {
+        Q_EMIT errorOccured(GetError::CANCELED);
+        downloadReply_->abort();
+        resetDownload();
+    }
+}
+
+void
+UpdateManager::downloadFile(const QUrl& url,
+                            std::function<void(bool, const QString&)> onDoneCallback,
+                            const QString& filePath)
+{
+    // If there is already a download in progress, return.
+    if (downloadReply_ && downloadReply_->isRunning()) {
+        qWarning() << Q_FUNC_INFO << "Download already in progress";
+        return;
+    }
+
+    // Clean up any previous download.
+    resetDownload();
+
+    // If the url is invalid, return.
+    if (!url.isValid()) {
+        Q_EMIT errorOccured(GetError::NETWORK_ERROR, "Invalid url");
+        return;
+    }
+
+    // If the file path is empty, return.
+    if (filePath.isEmpty()) {
+        Q_EMIT errorOccured(GetError::NETWORK_ERROR, "Invalid file path");
+        return;
+    }
+
+    // Create the file. Return if it cannot be created.
+    QFileInfo fileInfo(url.path());
+    QString fileName = fileInfo.fileName();
+    file_.reset(new QFile(filePath + "/" + fileName));
+    if (!file_->open(QIODevice::WriteOnly)) {
+        Q_EMIT errorOccured(GetError::ACCESS_DENIED);
+        file_.reset();
+        qWarning() << Q_FUNC_INFO << "Could not open file for writing";
+        return;
+    }
+
+    // Start the download.
+    QNetworkRequest request(url);
+    downloadReply_ = manager_->get(request);
+
+    connect(downloadReply_, &QNetworkReply::readyRead, this, [=]() {
+        if (file_ && file_->isOpen()) {
+            file_->write(downloadReply_->readAll());
+        }
+    });
+
+    connect(downloadReply_,
+            &QNetworkReply::downloadProgress,
+            this,
+            [=](qint64 bytesReceived, qint64 bytesTotal) {
+                Q_EMIT downloadProgressChanged(bytesReceived, bytesTotal);
+            });
+
+    connect(downloadReply_,
+            QOverload<QNetworkReply::NetworkError>::of(&QNetworkReply::errorOccurred),
+            this,
+            [this](QNetworkReply::NetworkError error) {
+                downloadReply_->disconnect();
+                resetDownload();
+                qWarning() << Q_FUNC_INFO
+                           << QMetaEnum::fromType<QNetworkReply::NetworkError>().valueToKey(error);
+                Q_EMIT errorOccured(GetError::NETWORK_ERROR);
+            });
+
+    connect(downloadReply_, &QNetworkReply::finished, this, [this, onDoneCallback]() {
+        bool success = false;
+        QString errorMessage;
+        if (downloadReply_->error() == QNetworkReply::NoError) {
+            resetDownload();
+            success = true;
+        } else {
+            errorMessage = downloadReply_->errorString();
+            resetDownload();
+        }
+        onDoneCallback(success, errorMessage);
+        Q_EMIT statusChanged(GetStatus::FINISHED);
+    });
+
+    Q_EMIT statusChanged(GetStatus::STARTED);
+}
+
+void
+UpdateManager::resetDownload()
+{
+    if (downloadReply_) {
+        downloadReply_->deleteLater();
+        downloadReply_ = nullptr;
+    }
+    if (file_) {
+        if (file_->isOpen()) {
+            file_->flush();
+            file_->close();
+        }
+        file_->deleteLater();
+        file_.reset();
+    }
+}
diff --git a/src/app/updatemanager.h b/src/app/updatemanager.h
index ab4f19d12a4b3baf6e6268e410b43e08ca258498..c28fe75a918f79eccf22365c6743f05f8985794d 100644
--- a/src/app/updatemanager.h
+++ b/src/app/updatemanager.h
@@ -25,7 +25,7 @@
 class LRCInstance;
 class ConnectivityMonitor;
 
-class UpdateManager final : public NetWorkManager
+class UpdateManager final : public NetworkManager
 {
     Q_OBJECT
     Q_DISABLE_COPY(UpdateManager)
@@ -36,6 +36,9 @@ public:
                            QObject* parent = nullptr);
     ~UpdateManager();
 
+    enum GetStatus { STARTED, FINISHED };
+    Q_ENUM(GetStatus)
+
     Q_INVOKABLE void checkForUpdates(bool quiet = false);
     Q_INVOKABLE void applyUpdates(bool beta = false);
     Q_INVOKABLE void cancelUpdate();
@@ -43,15 +46,28 @@ public:
     Q_INVOKABLE bool isCurrentVersionBeta();
     Q_INVOKABLE bool isUpdaterEnabled();
     Q_INVOKABLE bool isAutoUpdaterEnabled();
+    Q_INVOKABLE void cancelDownload();
+
+    void downloadFile(const QUrl& url,
+                      std::function<void(bool, const QString&)> onDoneCallback,
+                      const QString& filePath);
 
 Q_SIGNALS:
+    void statusChanged(GetStatus status);
+    void downloadProgressChanged(qint64 bytesRead, qint64 totalBytes);
+
     void updateCheckReplyReceived(bool ok, bool found = false);
-    void updateErrorOccurred(const NetWorkManager::GetError& error);
+    void updateErrorOccurred(const NetworkManager::GetError& error);
     void updateDownloadStarted();
     void updateDownloadProgressChanged(qint64 bytesRead, qint64 totalBytes);
     void updateDownloadFinished();
     void appCloseRequested();
 
+private:
+    void resetDownload();
+    QNetworkReply* downloadReply_ {nullptr};
+    QScopedPointer<QFile> file_;
+
 private:
     struct Impl;
     std::unique_ptr<Impl> pimpl_;
diff --git a/src/libclient/api/interaction.h b/src/libclient/api/interaction.h
index 26b5a3861aa6ae252e6ee8f3880e21a965752685..1c07b320c56fc5db023c3074f72190b9aa058140 100644
--- a/src/libclient/api/interaction.h
+++ b/src/libclient/api/interaction.h
@@ -297,7 +297,7 @@ public:
  * @var isRead
  * @var commit
  * @var linkPreviewInfo
- * @var linkified
+ * @var parsedBody
  */
 struct Info
 {
@@ -312,7 +312,7 @@ struct Info
     bool isRead = false;
     MapStringString commit;
     QVariantMap linkPreviewInfo = {};
-    QString linkified;
+    QString parsedBody = {};
     QVariantMap reactions;
     QString react_to;
     QVector<Body> previousBodies;
diff --git a/src/libclient/messagelistmodel.cpp b/src/libclient/messagelistmodel.cpp
index 1ab2eb5dc5dbd75aff48dc9c83b709af614d33d1..108165ca986b6da7210f501b85eb1bbc0dfd97ae 100644
--- a/src/libclient/messagelistmodel.cpp
+++ b/src/libclient/messagelistmodel.cpp
@@ -454,14 +454,8 @@ MessageListModel::dataForItem(item_t item, int, int role) const
         return QVariant(item.second.isRead);
     case Role::LinkPreviewInfo:
         return QVariant(item.second.linkPreviewInfo);
-    case Role::Linkified:
-        return QVariant(item.second.linkified);
-    case Role::LinkifiedBody: {
-        if (!item.second.linkified.isEmpty()) {
-            return QVariant(item.second.linkified);
-        }
-        return QVariant(item.second.body);
-    }
+    case Role::ParsedBody:
+        return QVariant(item.second.parsedBody);
     case Role::ActionUri:
         return QVariant(item.second.commit["uri"]);
     case Role::ConfId:
@@ -484,9 +478,9 @@ MessageListModel::dataForItem(item_t item, int, int role) const
     case Role::ReplyToBody: {
         if (repliedMsg == -1)
             return QVariant("");
-        auto linkified = data(repliedMsg, Role::Linkified).toString();
-        if (!linkified.isEmpty())
-            return QVariant(linkified);
+        auto parsed = data(repliedMsg, Role::ParsedBody).toString();
+        if (!parsed.isEmpty())
+            return QVariant(parsed);
         return QVariant(data(repliedMsg, Role::Body).toString());
     }
     case Role::TotalSize:
@@ -551,15 +545,15 @@ MessageListModel::addHyperlinkInfo(const QString& messageId, const QVariantMap&
 }
 
 void
-MessageListModel::linkifyMessage(const QString& messageId, const QString& linkified)
+MessageListModel::setParsedMessage(const QString& messageId, const QString& parsed)
 {
     int index = getIndexOfMessage(messageId);
     if (index == -1) {
         return;
     }
     QModelIndex modelIndex = QAbstractListModel::index(index, 0);
-    interactions_[index].second.linkified = linkified;
-    Q_EMIT dataChanged(modelIndex, modelIndex, {Role::Linkified, Role::LinkifiedBody});
+    interactions_[index].second.parsedBody = parsed;
+    Q_EMIT dataChanged(modelIndex, modelIndex, {Role::ParsedBody});
 }
 
 void
@@ -712,12 +706,11 @@ MessageListModel::editMessage(const QString& msgId, interaction::Info& info)
             }
         }
         info.body = it->rbegin()->body;
-        info.linkified.clear();
+        info.parsedBody.clear();
         editedBodies_.erase(it);
         emitDataChanged(msgId,
                         {MessageList::Role::Body,
-                         MessageList::Role::Linkified,
-                         MessageList::Role::LinkifiedBody,
+                         MessageList::Role::ParsedBody,
                          MessageList::Role::PreviousBodies,
                          MessageList::Role::IsEmojiOnly});
 
diff --git a/src/libclient/messagelistmodel.h b/src/libclient/messagelistmodel.h
index 5a6c14def80bd4a5d00c32b1358e2dad1e8111ff..7345862fdf62f09feea7b5e4fc2e69c425797c97 100644
--- a/src/libclient/messagelistmodel.h
+++ b/src/libclient/messagelistmodel.h
@@ -45,8 +45,7 @@ struct Info;
     X(ConfId) \
     X(DeviceId) \
     X(LinkPreviewInfo) \
-    X(Linkified) \
-    X(LinkifiedBody) \
+    X(ParsedBody) \
     X(PreviousBodies) \
     X(Reactions) \
     X(ReplyTo) \
@@ -124,7 +123,7 @@ public:
     bool contains(const QString& msgId);
     int getIndexOfMessage(const QString& messageId) const;
     void addHyperlinkInfo(const QString& messageId, const QVariantMap& info);
-    void linkifyMessage(const QString& messageId, const QString& linkified);
+    void setParsedMessage(const QString& messageId, const QString& parsed);
 
     void setRead(const QString& peer, const QString& messageId);
     QString getRead(const QString& peer);
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index a0797e41295e9f818c5caab5352af1d44ffa3183..d50908cfafa38450f72e40d1c049793e23b7d935 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -38,12 +38,18 @@ set(TEST_QML_RESOURCES
     ${CMAKE_SOURCE_DIR}/src/app/resources.qrc)
 
 # Common jami files
-add_library(test_common_obj OBJECT ${COMMON_SOURCES} ${COMMON_HEADERS})
+add_library(test_common_obj OBJECT
+    ${COMMON_SOURCES}
+    ${COMMON_HEADERS})
+
+target_include_directories(test_common_obj PRIVATE
+    ${CLIENT_INCLUDE_DIRS}
+    ${CMAKE_CURRENT_SOURCE_DIR}
+    ${CMAKE_SOURCE_DIR}/src)
+target_link_directories(test_common_obj PRIVATE ${CLIENT_LINK_DIRS})
 target_link_libraries(test_common_obj ${QML_TEST_LIBS})
 target_compile_definitions(test_common_obj PRIVATE ENABLE_TESTS="ON")
 
-include_directories(${CMAKE_CURRENT_SOURCE_DIR} ${CMAKE_SOURCE_DIR}/src)
-
 set(COMMON_TESTS_SOURCES
     ${QML_RESOURCES}
     ${QML_RESOURCES_QML}
@@ -77,28 +83,18 @@ set(UNIT_TESTS_SOURCE_FILES
     ${CMAKE_SOURCE_DIR}/tests/unittests/main_unittest.cpp
     ${CMAKE_SOURCE_DIR}/tests/unittests/account_unittest.cpp
     ${CMAKE_SOURCE_DIR}/tests/unittests/contact_unittest.cpp
+    ${CMAKE_SOURCE_DIR}/tests/unittests/messageparser_unittest.cpp
     ${CMAKE_SOURCE_DIR}/tests/unittests/globaltestenvironment.h
     ${COMMON_TESTS_SOURCES})
 
 set(ALL_TESTS_LIBS
     ${QML_TEST_LIBS}
     gtest
-    ${ringclient}
-    ${qrencode}
-    ${X11}
-    ${LIBNM_LIBRARIES}
-    ${LIBNOTIFY_LIBRARIES}
-    ${LIBGDKPIXBUF_LIBRARIES}
-    ${WINDOWS_LIBS})
+    ${CLIENT_LIBS})
 
 set(ALL_TESTS_INCLUDES
     ${TESTS_INCLUDES}
-    ${LRC}/include/libringclient
-    ${LRC}/include
-    ${LIBNM_INCLUDE_DIRS}
-    ${LIBNOTIFY_INCLUDE_DIRS}
-    ${LIBGDKPIXBUF_INCLUDE_DIRS}
-    ${WINDOWS_INCLUDES})
+    ${CLIENT_INCLUDE_DIRS})
 
 function(setup_test TEST_NAME TEST_SOURCES TEST_INPUT)
     string(TOLOWER ${TEST_NAME} TEST_BINARY_NAME)
@@ -120,4 +116,4 @@ setup_test(Qml_Tests
 
 # Unit tests
 setup_test(Unit_Tests
-    "${UNIT_TESTS_SOURCE_FILES}" "")
\ No newline at end of file
+    "${UNIT_TESTS_SOURCE_FILES}" "")
diff --git a/tests/qml/main.cpp b/tests/qml/main.cpp
index d7c0165f518e901b495e90a20ce30a4841f7a405..4ab0725d47b51acabbf5a7dba085928e2896a31d 100644
--- a/tests/qml/main.cpp
+++ b/tests/qml/main.cpp
@@ -59,7 +59,7 @@ public Q_SLOTS:
         connectivityMonitor_.reset(new ConnectivityMonitor(this));
         settingsManager_.reset(new AppSettingsManager(this));
         systemTray_.reset(new SystemTray(settingsManager_.get(), this));
-        previewEngine_.reset(new PreviewEngine(this));
+        previewEngine_.reset(new PreviewEngine(connectivityMonitor_.get(), this));
 
         QFontDatabase::addApplicationFont(":/images/FontAwesome.otf");
 
diff --git a/tests/unittests/globaltestenvironment.h b/tests/unittests/globaltestenvironment.h
index ae69eb8557ef0d97817a2c26b01985f08ca8673b..8a45c229e41da5b23688df3abab0bd8d9ad47f0e 100644
--- a/tests/unittests/globaltestenvironment.h
+++ b/tests/unittests/globaltestenvironment.h
@@ -21,7 +21,8 @@
 #include "appsettingsmanager.h"
 #include "connectivitymonitor.h"
 #include "systemtray.h"
-
+#include "previewengine.h"
+#include "messageparser.h"
 #include "accountadapter.h"
 
 #include <QTest>
@@ -55,6 +56,9 @@ public:
                                                 systemTray.get(),
                                                 lrcInstance.data(),
                                                 nullptr));
+
+        previewEngine.reset(new PreviewEngine(connectivityMonitor.get(), nullptr));
+        messageParser.reset(new MessageParser(previewEngine.data(), nullptr));
     }
 
     void TearDown()
@@ -75,6 +79,8 @@ public:
     QScopedPointer<ConnectivityMonitor> connectivityMonitor;
     QScopedPointer<AppSettingsManager> settingsManager;
     QScopedPointer<SystemTray> systemTray;
+    QScopedPointer<PreviewEngine> previewEngine;
+    QScopedPointer<MessageParser> messageParser;
 };
 
 extern TestEnvironment globalEnv;
diff --git a/tests/unittests/messageparser_unittest.cpp b/tests/unittests/messageparser_unittest.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..4cbe16d9509c878ba88ec6573dd25709a5c39748
--- /dev/null
+++ b/tests/unittests/messageparser_unittest.cpp
@@ -0,0 +1,171 @@
+/*
+ * Copyright (C) 2023 Savoir-faire Linux Inc.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "globaltestenvironment.h"
+
+class MessageParserFixture : public ::testing::Test
+{
+public:
+    // Prepare unit test context. Called at
+    // prior each unit test execution
+    void SetUp() override {}
+
+    // Close unit test context. Called
+    // after each unit test ending
+    void TearDown() override {}
+};
+
+/*!
+ * WHEN  We parse a markdown text body with no link.
+ * THEN  The HTML body should be returned correctly without the link.
+ */
+TEST_F(MessageParserFixture, TextIsParsedCorrectly)
+{
+    auto linkColor = QColor::fromRgb(0, 0, 255);
+    auto backgroundColor = QColor::fromRgb(0, 0, 255);
+
+    QSignalSpy messageParsedSpy(globalEnv.messageParser.data(), &MessageParser::messageParsed);
+    QSignalSpy linkInfoReadySpy(globalEnv.messageParser.data(), &MessageParser::linkInfoReady);
+
+    globalEnv.messageParser->parseMessage("msgId_01",
+                                          "This is a **bold** text",
+                                          true,
+                                          linkColor,
+                                          backgroundColor);
+
+    // Wait for the messageParsed signal which should be emitted once.
+    messageParsedSpy.wait();
+    EXPECT_EQ(messageParsedSpy.count(), 1);
+
+    QList<QVariant> messageParserArguments = messageParsedSpy.takeFirst();
+    EXPECT_TRUE(messageParserArguments.at(0).typeId() == qMetaTypeId<QString>());
+    EXPECT_EQ(messageParserArguments.at(0).toString(), "msgId_01");
+    EXPECT_TRUE(messageParserArguments.at(1).typeId() == qMetaTypeId<QString>());
+    EXPECT_EQ(messageParserArguments.at(1).toString(),
+              "<style></style><p>This is a <strong>bold</strong> text</p>\n");
+
+    // No link info should be returned.
+    linkInfoReadySpy.wait();
+    EXPECT_EQ(linkInfoReadySpy.count(), 0);
+}
+
+/*!
+ * WHEN  We parse a text body with a link.
+ * THEN  The HTML body should be returned correctly including the link.
+ */
+TEST_F(MessageParserFixture, ALinkIsParsedCorrectly)
+{
+    auto linkColor = QColor::fromRgb(0, 0, 255);
+    auto backgroundColor = QColor::fromRgb(0, 0, 255);
+
+    QSignalSpy messageParsedSpy(globalEnv.messageParser.data(), &MessageParser::messageParsed);
+    QSignalSpy linkInfoReadySpy(globalEnv.messageParser.data(), &MessageParser::linkInfoReady);
+
+    // Parse a message with a link.
+    globalEnv.messageParser->parseMessage("msgId_02",
+                                          "https://www.google.com",
+                                          true,
+                                          linkColor,
+                                          backgroundColor);
+
+    // Wait for the messageParsed signal which should be emitted once.
+    messageParsedSpy.wait();
+    EXPECT_EQ(messageParsedSpy.count(), 1);
+
+    QList<QVariant> messageParserArguments = messageParsedSpy.takeFirst();
+    EXPECT_TRUE(messageParserArguments.at(0).typeId() == qMetaTypeId<QString>());
+    EXPECT_EQ(messageParserArguments.at(0).toString(), "msgId_02");
+    EXPECT_TRUE(messageParserArguments.at(1).typeId() == qMetaTypeId<QString>());
+    EXPECT_EQ(messageParserArguments.at(1).toString(),
+              "<style>a{color:#0000ff;}</style><p><a "
+              "href=\"https://www.google.com\">https://www.google.com</a></p>\n");
+
+    // Wait for the linkInfoReady signal which should be emitted once.
+    linkInfoReadySpy.wait();
+    EXPECT_EQ(linkInfoReadySpy.count(), 1);
+
+    QList<QVariant> linkInfoReadyArguments = linkInfoReadySpy.takeFirst();
+    EXPECT_TRUE(linkInfoReadyArguments.at(0).typeId() == qMetaTypeId<QString>());
+    EXPECT_EQ(linkInfoReadyArguments.at(0).toString(), "msgId_02");
+    EXPECT_TRUE(linkInfoReadyArguments.at(1).typeId() == qMetaTypeId<QVariantMap>());
+    QVariantMap linkInfo = linkInfoReadyArguments.at(1).toMap();
+    EXPECT_EQ(linkInfo["url"].toString(), "https://www.google.com");
+    // The rest of the link info is not tested here.
+}
+
+/*!
+ * WHEN  We parse a text body with end of line characters.
+ * THEN  The HTML body should be returned correctly with the end of line characters.
+ */
+TEST_F(MessageParserFixture, EndOfLineCharactersAreParsedCorrectly)
+{
+    auto linkColor = QColor::fromRgb(0, 0, 255);
+    auto backgroundColor = QColor::fromRgb(0, 0, 255);
+
+    QSignalSpy messageParsedSpy(globalEnv.messageParser.data(), &MessageParser::messageParsed);
+    QSignalSpy linkInfoReadySpy(globalEnv.messageParser.data(), &MessageParser::linkInfoReady);
+
+    // Parse a message with a link.
+    globalEnv.messageParser->parseMessage("msgId_03",
+                                          "Text with\n2 lines",
+                                          true,
+                                          linkColor,
+                                          backgroundColor);
+
+    // Wait for the messageParsed signal which should be emitted once.
+    messageParsedSpy.wait();
+    EXPECT_EQ(messageParsedSpy.count(), 1);
+
+    QList<QVariant> messageParserArguments = messageParsedSpy.takeFirst();
+    EXPECT_TRUE(messageParserArguments.at(0).typeId() == qMetaTypeId<QString>());
+    EXPECT_EQ(messageParserArguments.at(0).toString(), "msgId_03");
+    EXPECT_TRUE(messageParserArguments.at(1).typeId() == qMetaTypeId<QString>());
+    EXPECT_EQ(messageParserArguments.at(1).toString(),
+              "<style></style><p>Text with<br>\n2 lines</p>\n");
+}
+
+/*!
+ * WHEN  We parse a text body with some fenced code.
+ * THEN  The HTML body should be returned correctly with the code wrapped in a <pre> tag.
+ */
+TEST_F(MessageParserFixture, FencedCodeIsParsedCorrectly)
+{
+    auto linkColor = QColor::fromRgb(0, 0, 255);
+    auto backgroundColor = QColor::fromRgb(0, 0, 255);
+
+    QSignalSpy messageParsedSpy(globalEnv.messageParser.data(), &MessageParser::messageParsed);
+    QSignalSpy linkInfoReadySpy(globalEnv.messageParser.data(), &MessageParser::linkInfoReady);
+
+    // Parse a message with a link.
+    globalEnv.messageParser->parseMessage("msgId_04",
+                                          "Text with \n```\ncode\n```",
+                                          true,
+                                          linkColor,
+                                          backgroundColor);
+
+    // Wait for the messageParsed signal which should be emitted once.
+    messageParsedSpy.wait();
+    EXPECT_EQ(messageParsedSpy.count(), 1);
+
+    QList<QVariant> messageParserArguments = messageParsedSpy.takeFirst();
+    EXPECT_TRUE(messageParserArguments.at(0).typeId() == qMetaTypeId<QString>());
+    EXPECT_EQ(messageParserArguments.at(0).toString(), "msgId_04");
+    EXPECT_TRUE(messageParserArguments.at(1).typeId() == qMetaTypeId<QString>());
+    EXPECT_EQ(messageParserArguments.at(1).toString(),
+              "<style>pre,code{background-color:#0000ff;color:#ffffff;white-space:pre-wrap;"
+              "}</style><p>Text with</p>\n<pre><code>code\n</code></pre>\n");
+}