fix: inline code blocks, code blocks and links have saner behaviour (#3318)

* fix: removed backticks in inline code blocks

* added better error handling while cancelling uploads

* fix: inline code blocks, code blocks and links have saner behaviour

- Inline code blocks are now exitable, don't have backticks, have better padding vertically and better regex matching
- Code blocks on the top and bottom of the document are now exitable via Up and Down Arrow keys
- Links are now exitable while being autolinkable via a custom re-write of the tiptap-link-extension

* fix: more robust link checking
This commit is contained in:
M. Palanikannan 2024-01-11 18:29:41 +05:30 committed by sriram veeraghanta
parent 2cd5dbcd02
commit 27762ea500
12 changed files with 567 additions and 31 deletions

View File

@ -30,10 +30,10 @@
"dependencies": { "dependencies": {
"@tiptap/core": "^2.1.13", "@tiptap/core": "^2.1.13",
"@tiptap/extension-blockquote": "^2.1.13", "@tiptap/extension-blockquote": "^2.1.13",
"@tiptap/extension-code": "^2.1.13",
"@tiptap/extension-code-block-lowlight": "^2.1.13", "@tiptap/extension-code-block-lowlight": "^2.1.13",
"@tiptap/extension-color": "^2.1.13", "@tiptap/extension-color": "^2.1.13",
"@tiptap/extension-image": "^2.1.13", "@tiptap/extension-image": "^2.1.13",
"@tiptap/extension-link": "^2.1.13",
"@tiptap/extension-list-item": "^2.1.13", "@tiptap/extension-list-item": "^2.1.13",
"@tiptap/extension-mention": "^2.1.13", "@tiptap/extension-mention": "^2.1.13",
"@tiptap/extension-task-item": "^2.1.13", "@tiptap/extension-task-item": "^2.1.13",
@ -48,6 +48,7 @@
"clsx": "^1.2.1", "clsx": "^1.2.1",
"highlight.js": "^11.8.0", "highlight.js": "^11.8.0",
"jsx-dom-cjs": "^8.0.3", "jsx-dom-cjs": "^8.0.3",
"linkifyjs": "^4.1.3",
"lowlight": "^3.0.0", "lowlight": "^3.0.0",
"lucide-react": "^0.294.0", "lucide-react": "^0.294.0",
"react-moveable": "^0.54.2", "react-moveable": "^0.54.2",

View File

@ -12,6 +12,11 @@
display: none; display: none;
} }
.ProseMirror code::before,
.ProseMirror code::after {
display: none;
}
.ProseMirror .is-empty::before { .ProseMirror .is-empty::before {
content: attr(data-placeholder); content: attr(data-placeholder);
float: left; float: left;

View File

@ -0,0 +1,31 @@
import { markInputRule, markPasteRule } from "@tiptap/core";
import Code from "@tiptap/extension-code";
export const inputRegex = /(?<!`)`([^`]*)`(?!`)/;
export const pasteRegex = /(?<!`)`([^`]+)`(?!`)/g;
export const CustomCodeInlineExtension = Code.extend({
exitable: true,
inclusive: false,
addInputRules() {
return [
markInputRule({
find: inputRegex,
type: this.type,
}),
];
},
addPasteRules() {
return [
markPasteRule({
find: pasteRegex,
type: this.type,
}),
];
},
}).configure({
HTMLAttributes: {
class: "rounded-md bg-custom-primary-30 mx-1 px-1 py-[2px] font-mono font-medium text-custom-text-1000",
spellcheck: "false",
},
});

View File

@ -6,10 +6,61 @@ import ts from "highlight.js/lib/languages/typescript";
const lowlight = createLowlight(common); const lowlight = createLowlight(common);
lowlight.register("ts", ts); lowlight.register("ts", ts);
export const CustomCodeBlock = CodeBlockLowlight.extend({ import { Selection } from "@tiptap/pm/state";
export const CustomCodeBlockExtension = CodeBlockLowlight.extend({
addKeyboardShortcuts() { addKeyboardShortcuts() {
return { return {
Tab: ({ editor }) => { Tab: ({ editor }) => {
const { state } = editor;
const { selection } = state;
const { $from, empty } = selection;
if (!empty || $from.parent.type !== this.type) {
return false;
}
// Use ProseMirror's insertText transaction to insert the tab character
const tr = state.tr.insertText("\t", $from.pos, $from.pos);
editor.view.dispatch(tr);
return true;
},
ArrowUp: ({ editor }) => {
const { state } = editor;
const { selection } = state;
const { $from, empty } = selection;
if (!empty || $from.parent.type !== this.type) {
return false;
}
const isAtStart = $from.parentOffset === 0;
if (!isAtStart) {
return false;
}
// Check if codeBlock is the first node
const isFirstNode = $from.depth === 1 && $from.index($from.depth - 1) === 0;
if (isFirstNode) {
// Insert a new paragraph at the start of the document and move the cursor to it
return editor.commands.command(({ tr }) => {
const node = editor.schema.nodes.paragraph.create();
tr.insert(0, node);
tr.setSelection(Selection.near(tr.doc.resolve(1)));
return true;
});
}
return false;
},
ArrowDown: ({ editor }) => {
if (!this.options.exitOnArrowDown) {
return false;
}
const { state } = editor; const { state } = editor;
const { selection, doc } = state; const { selection, doc } = state;
const { $from, empty } = selection; const { $from, empty } = selection;
@ -18,7 +69,28 @@ export const CustomCodeBlock = CodeBlockLowlight.extend({
return false; return false;
} }
return editor.commands.insertContent(" "); const isAtEnd = $from.parentOffset === $from.parent.nodeSize - 2;
if (!isAtEnd) {
return false;
}
const after = $from.after();
if (after === undefined) {
return false;
}
const nodeAfter = doc.nodeAt(after);
if (nodeAfter) {
return editor.commands.command(({ tr }) => {
tr.setSelection(Selection.near(doc.resolve(after)));
return true;
});
}
return editor.commands.exitCode();
}, },
}; };
}, },

View File

@ -0,0 +1,118 @@
import {
combineTransactionSteps,
findChildrenInRange,
getChangedRanges,
getMarksBetween,
NodeWithPos,
} from "@tiptap/core";
import { MarkType } from "@tiptap/pm/model";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { find } from "linkifyjs";
type AutolinkOptions = {
type: MarkType;
validate?: (url: string) => boolean;
};
export function autolink(options: AutolinkOptions): Plugin {
return new Plugin({
key: new PluginKey("autolink"),
appendTransaction: (transactions, oldState, newState) => {
const docChanges = transactions.some((transaction) => transaction.docChanged) && !oldState.doc.eq(newState.doc);
const preventAutolink = transactions.some((transaction) => transaction.getMeta("preventAutolink"));
if (!docChanges || preventAutolink) {
return;
}
const { tr } = newState;
const transform = combineTransactionSteps(oldState.doc, [...transactions]);
const changes = getChangedRanges(transform);
changes.forEach(({ newRange }) => {
// Now lets see if we can add new links.
const nodesInChangedRanges = findChildrenInRange(newState.doc, newRange, (node) => node.isTextblock);
let textBlock: NodeWithPos | undefined;
let textBeforeWhitespace: string | undefined;
if (nodesInChangedRanges.length > 1) {
// Grab the first node within the changed ranges (ex. the first of two paragraphs when hitting enter).
textBlock = nodesInChangedRanges[0];
textBeforeWhitespace = newState.doc.textBetween(
textBlock.pos,
textBlock.pos + textBlock.node.nodeSize,
undefined,
" "
);
} else if (
nodesInChangedRanges.length &&
// We want to make sure to include the block seperator argument to treat hard breaks like spaces.
newState.doc.textBetween(newRange.from, newRange.to, " ", " ").endsWith(" ")
) {
textBlock = nodesInChangedRanges[0];
textBeforeWhitespace = newState.doc.textBetween(textBlock.pos, newRange.to, undefined, " ");
}
if (textBlock && textBeforeWhitespace) {
const wordsBeforeWhitespace = textBeforeWhitespace.split(" ").filter((s) => s !== "");
if (wordsBeforeWhitespace.length <= 0) {
return false;
}
const lastWordBeforeSpace = wordsBeforeWhitespace[wordsBeforeWhitespace.length - 1];
const lastWordAndBlockOffset = textBlock.pos + textBeforeWhitespace.lastIndexOf(lastWordBeforeSpace);
if (!lastWordBeforeSpace) {
return false;
}
find(lastWordBeforeSpace)
.filter((link) => link.isLink)
// Calculate link position.
.map((link) => ({
...link,
from: lastWordAndBlockOffset + link.start + 1,
to: lastWordAndBlockOffset + link.end + 1,
}))
// ignore link inside code mark
.filter((link) => {
if (!newState.schema.marks.code) {
return true;
}
return !newState.doc.rangeHasMark(link.from, link.to, newState.schema.marks.code);
})
// validate link
.filter((link) => {
if (options.validate) {
return options.validate(link.value);
}
return true;
})
// Add link mark.
.forEach((link) => {
if (getMarksBetween(link.from, link.to, newState.doc).some((item) => item.mark.type === options.type)) {
return;
}
tr.addMark(
link.from,
link.to,
options.type.create({
href: link.href,
})
);
});
}
});
if (!tr.steps.length) {
return;
}
return tr;
},
});
}

View File

@ -0,0 +1,42 @@
import { getAttributes } from "@tiptap/core";
import { MarkType } from "@tiptap/pm/model";
import { Plugin, PluginKey } from "@tiptap/pm/state";
type ClickHandlerOptions = {
type: MarkType;
};
export function clickHandler(options: ClickHandlerOptions): Plugin {
return new Plugin({
key: new PluginKey("handleClickLink"),
props: {
handleClick: (view, pos, event) => {
if (event.button !== 0) {
return false;
}
const eventTarget = event.target as HTMLElement;
if (eventTarget.nodeName !== "A") {
return false;
}
const attrs = getAttributes(view.state, options.type.name);
const link = event.target as HTMLLinkElement;
const href = link?.href ?? attrs.href;
const target = link?.target ?? attrs.target;
if (link && href) {
if (view.editable) {
window.open(href, target);
}
return true;
}
return false;
},
},
});
}

View File

@ -0,0 +1,52 @@
import { Editor } from "@tiptap/core";
import { MarkType } from "@tiptap/pm/model";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { find } from "linkifyjs";
type PasteHandlerOptions = {
editor: Editor;
type: MarkType;
};
export function pasteHandler(options: PasteHandlerOptions): Plugin {
return new Plugin({
key: new PluginKey("handlePasteLink"),
props: {
handlePaste: (view, event, slice) => {
const { state } = view;
const { selection } = state;
const { empty } = selection;
if (empty) {
return false;
}
let textContent = "";
slice.content.forEach((node) => {
textContent += node.textContent;
});
const link = find(textContent).find((item) => item.isLink && item.value === textContent);
if (!textContent || !link) {
return false;
}
const html = event.clipboardData?.getData("text/html");
const hrefRegex = /href="([^"]*)"/;
const existingLink = html?.match(hrefRegex);
const url = existingLink ? existingLink[1] : link.href;
options.editor.commands.setMark(options.type, {
href: url,
});
return true;
},
},
});
}

View File

@ -0,0 +1,219 @@
import { Mark, markPasteRule, mergeAttributes } from "@tiptap/core";
import { Plugin } from "@tiptap/pm/state";
import { find, registerCustomProtocol, reset } from "linkifyjs";
import { autolink } from "src/ui/extensions/custom-link/helpers/autolink";
import { clickHandler } from "src/ui/extensions/custom-link/helpers/clickHandler";
import { pasteHandler } from "src/ui/extensions/custom-link/helpers/pasteHandler";
export interface LinkProtocolOptions {
scheme: string;
optionalSlashes?: boolean;
}
export interface LinkOptions {
autolink: boolean;
inclusive: boolean;
protocols: Array<LinkProtocolOptions | string>;
openOnClick: boolean;
linkOnPaste: boolean;
HTMLAttributes: Record<string, any>;
validate?: (url: string) => boolean;
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
link: {
setLink: (attributes: {
href: string;
target?: string | null;
rel?: string | null;
class?: string | null;
}) => ReturnType;
toggleLink: (attributes: {
href: string;
target?: string | null;
rel?: string | null;
class?: string | null;
}) => ReturnType;
unsetLink: () => ReturnType;
};
}
}
export const CustomLinkExtension = Mark.create<LinkOptions>({
name: "link",
priority: 1000,
keepOnSplit: false,
onCreate() {
this.options.protocols.forEach((protocol) => {
if (typeof protocol === "string") {
registerCustomProtocol(protocol);
return;
}
registerCustomProtocol(protocol.scheme, protocol.optionalSlashes);
});
},
onDestroy() {
reset();
},
inclusive() {
return this.options.inclusive;
},
addOptions() {
return {
openOnClick: true,
linkOnPaste: true,
autolink: true,
inclusive: false,
protocols: [],
HTMLAttributes: {
target: "_blank",
rel: "noopener noreferrer nofollow",
class: null,
},
validate: undefined,
};
},
addAttributes() {
return {
href: {
default: null,
},
target: {
default: this.options.HTMLAttributes.target,
},
rel: {
default: this.options.HTMLAttributes.rel,
},
class: {
default: this.options.HTMLAttributes.class,
},
};
},
parseHTML() {
return [
{
tag: "a[href]",
getAttrs: (node) => {
if (typeof node === "string" || !(node instanceof HTMLElement)) {
return null;
}
const href = node.getAttribute("href")?.toLowerCase() || "";
if (href.startsWith("javascript:") || href.startsWith("data:") || href.startsWith("vbscript:")) {
return false;
}
return {};
},
},
];
},
renderHTML({ HTMLAttributes }) {
const href = HTMLAttributes.href?.toLowerCase() || "";
if (href.startsWith("javascript:") || href.startsWith("data:") || href.startsWith("vbscript:")) {
return ["a", mergeAttributes(this.options.HTMLAttributes, { ...HTMLAttributes, href: "" }), 0];
}
return ["a", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
},
addCommands() {
return {
setLink:
(attributes) =>
({ chain }) =>
chain().setMark(this.name, attributes).setMeta("preventAutolink", true).run(),
toggleLink:
(attributes) =>
({ chain }) =>
chain()
.toggleMark(this.name, attributes, { extendEmptyMarkRange: true })
.setMeta("preventAutolink", true)
.run(),
unsetLink:
() =>
({ chain }) =>
chain().unsetMark(this.name, { extendEmptyMarkRange: true }).setMeta("preventAutolink", true).run(),
};
},
addPasteRules() {
return [
markPasteRule({
find: (text) =>
find(text)
.filter((link) => {
if (this.options.validate) {
return this.options.validate(link.value);
}
return true;
})
.filter((link) => link.isLink)
.map((link) => ({
text: link.value,
index: link.start,
data: link,
})),
type: this.type,
getAttributes: (match, pasteEvent) => {
const html = pasteEvent?.clipboardData?.getData("text/html");
const hrefRegex = /href="([^"]*)"/;
const existingLink = html?.match(hrefRegex);
if (existingLink) {
return {
href: existingLink[1],
};
}
return {
href: match.data?.href,
};
},
}),
];
},
addProseMirrorPlugins() {
const plugins: Plugin[] = [];
if (this.options.autolink) {
plugins.push(
autolink({
type: this.type,
validate: this.options.validate,
})
);
}
if (this.options.openOnClick) {
plugins.push(
clickHandler({
type: this.type,
})
);
}
if (this.options.linkOnPaste) {
plugins.push(
pasteHandler({
editor: this.editor,
type: this.type,
})
);
}
return plugins;
},
});

View File

@ -1,5 +1,4 @@
import StarterKit from "@tiptap/starter-kit"; import StarterKit from "@tiptap/starter-kit";
import TiptapLink from "@tiptap/extension-link";
import TiptapUnderline from "@tiptap/extension-underline"; import TiptapUnderline from "@tiptap/extension-underline";
import TextStyle from "@tiptap/extension-text-style"; import TextStyle from "@tiptap/extension-text-style";
import { Color } from "@tiptap/extension-color"; import { Color } from "@tiptap/extension-color";
@ -19,13 +18,15 @@ import { isValidHttpUrl } from "src/lib/utils";
import { Mentions } from "src/ui/mentions"; import { Mentions } from "src/ui/mentions";
import { CustomKeymap } from "src/ui/extensions/keymap"; import { CustomKeymap } from "src/ui/extensions/keymap";
import { CustomCodeBlock } from "src/ui/extensions/code"; import { CustomCodeBlockExtension } from "src/ui/extensions/code";
import { CustomQuoteExtension } from "src/ui/extensions/quote"; import { CustomQuoteExtension } from "src/ui/extensions/quote";
import { ListKeymap } from "src/ui/extensions/custom-list-keymap"; import { ListKeymap } from "src/ui/extensions/custom-list-keymap";
import { DeleteImage } from "src/types/delete-image"; import { DeleteImage } from "src/types/delete-image";
import { IMentionSuggestion } from "src/types/mention-suggestion"; import { IMentionSuggestion } from "src/types/mention-suggestion";
import { RestoreImage } from "src/types/restore-image"; import { RestoreImage } from "src/types/restore-image";
import { CustomLinkExtension } from "src/ui/extensions/custom-link";
import { CustomCodeInlineExtension } from "./code-inline";
export const CoreEditorExtensions = ( export const CoreEditorExtensions = (
mentionConfig: { mentionConfig: {
@ -52,12 +53,7 @@ export const CoreEditorExtensions = (
class: "leading-normal -mb-2", class: "leading-normal -mb-2",
}, },
}, },
code: { code: false,
HTMLAttributes: {
class: "rounded-md bg-custom-primary-30 mx-1 px-1 py-1 font-mono font-medium text-custom-text-1000",
spellcheck: "false",
},
},
codeBlock: false, codeBlock: false,
horizontalRule: false, horizontalRule: false,
dropcursor: { dropcursor: {
@ -70,10 +66,12 @@ export const CoreEditorExtensions = (
}), }),
CustomKeymap, CustomKeymap,
ListKeymap, ListKeymap,
TiptapLink.configure({ CustomLinkExtension.configure({
autolink: false, openOnClick: true,
autolink: true,
linkOnPaste: true,
protocols: ["http", "https"], protocols: ["http", "https"],
validate: (url) => isValidHttpUrl(url), validate: (url: string) => isValidHttpUrl(url),
HTMLAttributes: { HTMLAttributes: {
class: class:
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer", "text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
@ -92,13 +90,14 @@ export const CoreEditorExtensions = (
class: "not-prose pl-2", class: "not-prose pl-2",
}, },
}), }),
CustomCodeBlock,
TaskItem.configure({ TaskItem.configure({
HTMLAttributes: { HTMLAttributes: {
class: "flex items-start my-4", class: "flex items-start my-4",
}, },
nested: true, nested: true,
}), }),
CustomCodeBlockExtension,
CustomCodeInlineExtension,
Markdown.configure({ Markdown.configure({
html: true, html: true,
transformCopiedText: true, transformCopiedText: true,

View File

@ -1,5 +1,4 @@
import StarterKit from "@tiptap/starter-kit"; import StarterKit from "@tiptap/starter-kit";
import TiptapLink from "@tiptap/extension-link";
import TiptapUnderline from "@tiptap/extension-underline"; import TiptapUnderline from "@tiptap/extension-underline";
import TextStyle from "@tiptap/extension-text-style"; import TextStyle from "@tiptap/extension-text-style";
import { Color } from "@tiptap/extension-color"; import { Color } from "@tiptap/extension-color";
@ -18,6 +17,7 @@ import { ReadOnlyImageExtension } from "src/ui/extensions/image/read-only-image"
import { isValidHttpUrl } from "src/lib/utils"; import { isValidHttpUrl } from "src/lib/utils";
import { Mentions } from "src/ui/mentions"; import { Mentions } from "src/ui/mentions";
import { IMentionSuggestion } from "src/types/mention-suggestion"; import { IMentionSuggestion } from "src/types/mention-suggestion";
import { CustomLinkExtension } from "src/ui/extensions/custom-link";
export const CoreReadOnlyEditorExtensions = (mentionConfig: { export const CoreReadOnlyEditorExtensions = (mentionConfig: {
mentionSuggestions: IMentionSuggestion[]; mentionSuggestions: IMentionSuggestion[];
@ -59,7 +59,7 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: {
gapcursor: false, gapcursor: false,
}), }),
Gapcursor, Gapcursor,
TiptapLink.configure({ CustomLinkExtension.configure({
protocols: ["http", "https"], protocols: ["http", "https"],
validate: (url) => isValidHttpUrl(url), validate: (url) => isValidHttpUrl(url),
HTMLAttributes: { HTMLAttributes: {

View File

@ -65,12 +65,16 @@ export class FileService extends APIService {
getUploadFileFunction(workspaceSlug: string): (file: File) => Promise<string> { getUploadFileFunction(workspaceSlug: string): (file: File) => Promise<string> {
return async (file: File) => { return async (file: File) => {
const formData = new FormData(); try {
formData.append("asset", file); const formData = new FormData();
formData.append("attributes", JSON.stringify({})); formData.append("asset", file);
formData.append("attributes", JSON.stringify({}));
const data = await this.uploadFile(workspaceSlug, formData); const data = await this.uploadFile(workspaceSlug, formData);
return data.asset; return data.asset;
} catch (e) {
console.error(e);
}
}; };
} }

View File

@ -2492,13 +2492,6 @@
resolved "https://registry.yarnpkg.com/@tiptap/extension-italic/-/extension-italic-2.1.13.tgz#1e9521dea002c8d6de833d9fd928d4617623eab8" resolved "https://registry.yarnpkg.com/@tiptap/extension-italic/-/extension-italic-2.1.13.tgz#1e9521dea002c8d6de833d9fd928d4617623eab8"
integrity sha512-HyDJfuDn5hzwGKZiANcvgz6wcum6bEgb4wmJnfej8XanTMJatNVv63TVxCJ10dSc9KGpPVcIkg6W8/joNXIEbw== integrity sha512-HyDJfuDn5hzwGKZiANcvgz6wcum6bEgb4wmJnfej8XanTMJatNVv63TVxCJ10dSc9KGpPVcIkg6W8/joNXIEbw==
"@tiptap/extension-link@^2.1.13":
version "2.1.13"
resolved "https://registry.yarnpkg.com/@tiptap/extension-link/-/extension-link-2.1.13.tgz#ae4abd7c43292e3a1841488bfc7a687b2f014249"
integrity sha512-wuGMf3zRtMHhMrKm9l6Tft5M2N21Z0UP1dZ5t1IlOAvOeYV2QZ5UynwFryxGKLO0NslCBLF/4b/HAdNXbfXWUA==
dependencies:
linkifyjs "^4.1.0"
"@tiptap/extension-list-item@^2.1.13": "@tiptap/extension-list-item@^2.1.13":
version "2.1.13" version "2.1.13"
resolved "https://registry.yarnpkg.com/@tiptap/extension-list-item/-/extension-list-item-2.1.13.tgz#3c62127df97974f3196866ec00ee397f4c9acdc4" resolved "https://registry.yarnpkg.com/@tiptap/extension-list-item/-/extension-list-item-2.1.13.tgz#3c62127df97974f3196866ec00ee397f4c9acdc4"
@ -2802,7 +2795,7 @@
dependencies: dependencies:
"@types/react" "*" "@types/react" "*"
"@types/react@*", "@types/react@18.2.42", "@types/react@^18.2.42": "@types/react@*", "@types/react@^18.2.42":
version "18.2.42" version "18.2.42"
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.42.tgz#6f6b11a904f6d96dda3c2920328a97011a00aba7" resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.42.tgz#6f6b11a904f6d96dda3c2920328a97011a00aba7"
integrity sha512-c1zEr96MjakLYus/wPnuWDo1/zErfdU9rNsIGmE+NV71nx88FG9Ttgo5dqorXTu/LImX2f63WBP986gJkMPNbA== integrity sha512-c1zEr96MjakLYus/wPnuWDo1/zErfdU9rNsIGmE+NV71nx88FG9Ttgo5dqorXTu/LImX2f63WBP986gJkMPNbA==
@ -6071,7 +6064,7 @@ linkify-it@^5.0.0:
dependencies: dependencies:
uc.micro "^2.0.0" uc.micro "^2.0.0"
linkifyjs@^4.1.0: linkifyjs@^4.1.3:
version "4.1.3" version "4.1.3"
resolved "https://registry.yarnpkg.com/linkifyjs/-/linkifyjs-4.1.3.tgz#0edbc346428a7390a23ea2e5939f76112c9ae07f" resolved "https://registry.yarnpkg.com/linkifyjs/-/linkifyjs-4.1.3.tgz#0edbc346428a7390a23ea2e5939f76112c9ae07f"
integrity sha512-auMesunaJ8yfkHvK4gfg1K0SaKX/6Wn9g2Aac/NwX+l5VdmFZzo/hdPGxEOETj+ryRa4/fiOPjeeKURSAJx1sg== integrity sha512-auMesunaJ8yfkHvK4gfg1K0SaKX/6Wn9g2Aac/NwX+l5VdmFZzo/hdPGxEOETj+ryRa4/fiOPjeeKURSAJx1sg==