UNPKG

@atlaskit/editor-common

Version:

A package that contains common classes and components for editor and renderer

189 lines (182 loc) • 6.48 kB
// File has been copied to packages/editor/editor-plugin-ai/src/provider/markdown-transformer/utils/hyperlink.ts // If changes are made to this file, please make the same update in the linked file. import { isSafeUrl, linkify, normalizeUrl as normaliseLinkHref } from '@atlaskit/adf-schema'; import { getBooleanFF } from '@atlaskit/platform-feature-flags'; import { ACTION, ACTION_SUBJECT, ACTION_SUBJECT_ID, EVENT_TYPE } from '../analytics/types'; import { shouldAutoLinkifyTld } from './should-auto-linkify-tld'; import { mapSlice } from './slice'; // Regular expression for a windows filepath in the format <DRIVE LETTER>:\<folder name>\ export const FILEPATH_REGEXP = /([a-zA-Z]:|\\)([^\/:*?<>"|]+\\)?([^\/:*?<>"| ]+(?=\s?))?/gim; // Don't linkify if starts with $ or { export const DONTLINKIFY_REGEXP = /^(\$|{)/; /** * Instance of class LinkMatcher are used in autoformatting in place of Regex. * Hence it has been made similar to regex with an exec method. * Extending it directly from class Regex was introducing some issues, thus that has been avoided. */ export class LinkMatcher { static create() { class LinkMatcherRegex { exec(str) { const stringsBySpace = str.slice(0, str.length - 1).split(' '); const lastStringBeforeSpace = stringsBySpace[stringsBySpace.length - 1]; const isLastStringValid = lastStringBeforeSpace.length > 0; if (!str.endsWith(' ') || !isLastStringValid) { return null; } if (DONTLINKIFY_REGEXP.test(lastStringBeforeSpace)) { return null; } const links = linkify.match(lastStringBeforeSpace); if (!links || links.length === 0) { return null; } const lastMatch = links[links.length - 1]; const lastLink = links[links.length - 1]; lastLink.input = str.substring(lastMatch.index); lastLink.length = lastLink.lastIndex - lastLink.index + 1; lastLink.index = str.lastIndexOf(lastStringBeforeSpace) + lastMatch.index; return lastLink; } } return new LinkMatcherRegex(); } } /** * Adds protocol to url if needed. */ export function normalizeUrl(url) { if (!url) { return ''; } if (isSafeUrl(url)) { return url; } return normaliseLinkHref(url); } /** * Linkify content in a slice (eg. after a rich text paste) */ export function linkifyContent(schema) { return slice => mapSlice(slice, (node, parent) => { const isAllowedInParent = !parent || parent.type !== schema.nodes.codeBlock; const link = node.type.schema.marks.link; if (link === undefined) { throw new Error('Link not in schema - unable to linkify content'); } if (isAllowedInParent && node.isText && !link.isInSet(node.marks)) { const linkified = []; const text = node.text; const matches = getBooleanFF('platform.linking-platform.prevent-suspicious-linkification') ? findLinkMatches(text).filter(match => shouldAutoLinkifyTld(match.title)) : findLinkMatches(text); let pos = 0; const filepaths = findFilepaths(text); matches.forEach(match => { if (isLinkInMatches(match.start, filepaths)) { return; } if (match.start > 0) { linkified.push(node.cut(pos, match.start)); } linkified.push(node.cut(match.start, match.end).mark(link.create({ href: normalizeUrl(match.href) }).addToSet(node.marks))); pos = match.end; }); if (pos < text.length) { linkified.push(node.cut(pos)); } return linkified; } return node; }); } export function getLinkDomain(url) { // Remove protocol and www., if either exists const withoutProtocol = url.toLowerCase().replace(/^(.*):\/\//, ''); const withoutWWW = withoutProtocol.replace(/^(www\.)/, ''); // Remove port, fragment, path, query string return withoutWWW.replace(/[:\/?#](.*)$/, ''); } export function isFromCurrentDomain(url) { if (!window || !window.location) { return false; } const currentDomain = window.location.hostname; const linkDomain = getLinkDomain(url); return currentDomain === linkDomain; } function findLinkMatches(text) { const matches = []; let linkMatches = text && linkify.match(text); if (linkMatches && linkMatches.length > 0) { linkMatches.forEach(match => { matches.push({ start: match.index, end: match.lastIndex, title: match.raw, href: match.url }); }); } return matches; } export const findFilepaths = (text, offset = 0) => { // Creation of a copy of the RegExp is necessary as lastIndex is stored on it when we run .exec() const localRegExp = new RegExp(FILEPATH_REGEXP); let match; const matchesList = []; const maxFilepathSize = 260; while ((match = localRegExp.exec(text)) !== null) { const start = match.index + offset; let end = localRegExp.lastIndex + offset; if (end - start > maxFilepathSize) { end = start + maxFilepathSize; } // We don't care about big strings of text that are pretending to be filepaths!! matchesList.push({ startIndex: start, endIndex: end }); } return matchesList; }; export const isLinkInMatches = (linkStart, matchesList) => { for (let i = 0; i < matchesList.length; i++) { if (linkStart >= matchesList[i].startIndex && linkStart < matchesList[i].endIndex) { return true; } } return false; }; export function getLinkCreationAnalyticsEvent(inputMethod, url) { return { action: ACTION.INSERTED, actionSubject: ACTION_SUBJECT.DOCUMENT, actionSubjectId: ACTION_SUBJECT_ID.LINK, attributes: { inputMethod, fromCurrentDomain: isFromCurrentDomain(url) }, eventType: EVENT_TYPE.TRACK, nonPrivacySafeAttributes: { linkDomain: getLinkDomain(url) } }; } export const canLinkBeCreatedInRange = (from, to) => state => { if (!state.doc.rangeHasMark(from, to, state.schema.marks.link)) { const $from = state.doc.resolve(from); const $to = state.doc.resolve(to); const link = state.schema.marks.link; if ($from.parent === $to.parent && $from.parent.isTextblock) { if ($from.parent.type.allowsMarkType(link)) { let allowed = true; state.doc.nodesBetween(from, to, node => { allowed = allowed && !node.marks.some(m => m.type.excludes(link)); return allowed; }); return allowed; } } } return false; };