mirror of
https://github.com/outline/outline.git
synced 2026-01-04 10:09:52 -06:00
feat: Support embed configuration (#3980)
* wip
* stash
* fix: make authenticationId nullable fk
* fix: apply generics to resolve compile time type errors
* fix: loosen integration settings
* chore: refactor into functional component
* feat: pass integrations all the way to embeds
* perf: avoid re-fetching integrations
* fix: change attr name to avoid type overlap
* feat: use hostname from embed settings in matcher
* Revert "feat: use hostname from embed settings in matcher"
This reverts commit e7485d9cda.
* feat: refactor into a class
* chore: refactor url regex formation as a util
* fix: escape regex special chars
* fix: remove in-house escapeRegExp in favor of lodash's
* fix: sanitize url
* perf: memoize embeds
* fix: rename hostname to url and allow spreading entire settings instead of just url
* fix: replace diagrams with drawio
* fix: rename
* fix: support self-hosted and saas both
* fix: assert on settings url
* fix: move embed integrations loading to hook
* fix: address review comments
* fix: use observer in favor of explicit state setters
* fix: refactor useEmbedIntegrations into useEmbeds
* fix: use translations for toasts
Co-authored-by: Tom Moor <tom.moor@gmail.com>
This commit is contained in:
@@ -1,12 +1,12 @@
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { deburr, sortBy } from "lodash";
|
||||
import { observer } from "mobx-react";
|
||||
import { DOMParser as ProsemirrorDOMParser } from "prosemirror-model";
|
||||
import { TextSelection } from "prosemirror-state";
|
||||
import * as React from "react";
|
||||
import { mergeRefs } from "react-merge-refs";
|
||||
import { Optional } from "utility-types";
|
||||
import insertFiles from "@shared/editor/commands/insertFiles";
|
||||
import embeds from "@shared/editor/embeds";
|
||||
import { Heading } from "@shared/editor/lib/getHeadings";
|
||||
import { getDataTransferFiles } from "@shared/utils/files";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
@@ -18,6 +18,7 @@ import ErrorBoundary from "~/components/ErrorBoundary";
|
||||
import HoverPreview from "~/components/HoverPreview";
|
||||
import type { Props as EditorProps, Editor as SharedEditor } from "~/editor";
|
||||
import useDictionary from "~/hooks/useDictionary";
|
||||
import useEmbeds from "~/hooks/useEmbeds";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import { NotFoundError } from "~/utils/errors";
|
||||
@@ -58,6 +59,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
const { documents } = useStores();
|
||||
const { showToast } = useToasts();
|
||||
const dictionary = useDictionary();
|
||||
const embeds = useEmbeds();
|
||||
const [
|
||||
activeLinkEvent,
|
||||
setActiveLinkEvent,
|
||||
@@ -310,4 +312,4 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
);
|
||||
}
|
||||
|
||||
export default React.forwardRef(Editor);
|
||||
export default observer(React.forwardRef(Editor));
|
||||
|
||||
@@ -7,9 +7,10 @@ import { Portal } from "react-portal";
|
||||
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
||||
import styled from "styled-components";
|
||||
import insertFiles from "@shared/editor/commands/insertFiles";
|
||||
import { EmbedDescriptor } from "@shared/editor/embeds";
|
||||
import { CommandFactory } from "@shared/editor/lib/Extension";
|
||||
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators";
|
||||
import { EmbedDescriptor, MenuItem } from "@shared/editor/types";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { depths } from "@shared/styles";
|
||||
import { getEventFiles } from "@shared/utils/files";
|
||||
import { AttachmentValidation } from "@shared/validations";
|
||||
@@ -427,10 +428,12 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
|
||||
|
||||
for (const embed of embeds) {
|
||||
if (embed.title) {
|
||||
embedItems.push({
|
||||
...embed,
|
||||
name: "embed",
|
||||
});
|
||||
embedItems.push(
|
||||
new EmbedDescriptor({
|
||||
...embed,
|
||||
name: "embed",
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { EditorState, Selection, Plugin, Transaction } from "prosemirror-state";
|
||||
import { Decoration, EditorView } from "prosemirror-view";
|
||||
import * as React from "react";
|
||||
import { DefaultTheme, ThemeProps } from "styled-components";
|
||||
import { EmbedDescriptor } from "@shared/editor/embeds";
|
||||
import Extension, { CommandFactory } from "@shared/editor/lib/Extension";
|
||||
import ExtensionManager from "@shared/editor/lib/ExtensionManager";
|
||||
import getHeadings from "@shared/editor/lib/getHeadings";
|
||||
@@ -25,8 +26,10 @@ import Mark from "@shared/editor/marks/Mark";
|
||||
import Node from "@shared/editor/nodes/Node";
|
||||
import ReactNode from "@shared/editor/nodes/ReactNode";
|
||||
import fullExtensionsPackage from "@shared/editor/packages/full";
|
||||
import { EmbedDescriptor, EventType } from "@shared/editor/types";
|
||||
import { EventType } from "@shared/editor/types";
|
||||
import { IntegrationType } from "@shared/types";
|
||||
import EventEmitter from "@shared/utils/events";
|
||||
import Integration from "~/models/Integration";
|
||||
import Flex from "~/components/Flex";
|
||||
import { Dictionary } from "~/hooks/useDictionary";
|
||||
import Logger from "~/utils/Logger";
|
||||
@@ -110,6 +113,8 @@ export type Props = {
|
||||
onShowToast: (message: string) => void;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
|
||||
embedIntegrations?: Integration<IntegrationType.Embed>[];
|
||||
};
|
||||
|
||||
type State = {
|
||||
|
||||
@@ -9,12 +9,14 @@ import {
|
||||
LinkIcon,
|
||||
TeamIcon,
|
||||
BeakerIcon,
|
||||
BuildingBlocksIcon,
|
||||
DownloadIcon,
|
||||
WebhooksIcon,
|
||||
} from "outline-icons";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Details from "~/scenes/Settings/Details";
|
||||
import Drawio from "~/scenes/Settings/Drawio";
|
||||
import Export from "~/scenes/Settings/Export";
|
||||
import Features from "~/scenes/Settings/Features";
|
||||
import Groups from "~/scenes/Settings/Groups";
|
||||
@@ -170,6 +172,14 @@ const useAuthorizedSettingsConfig = () => {
|
||||
group: t("Integrations"),
|
||||
icon: WebhooksIcon,
|
||||
},
|
||||
Drawio: {
|
||||
name: t("Draw.io"),
|
||||
path: "/settings/integrations/drawio",
|
||||
component: Drawio,
|
||||
enabled: can.update,
|
||||
group: t("Integrations"),
|
||||
icon: BuildingBlocksIcon,
|
||||
},
|
||||
Slack: {
|
||||
name: "Slack",
|
||||
path: "/settings/integrations/slack",
|
||||
|
||||
41
app/hooks/useEmbeds.ts
Normal file
41
app/hooks/useEmbeds.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { find } from "lodash";
|
||||
import * as React from "react";
|
||||
import embeds, { EmbedDescriptor } from "@shared/editor/embeds";
|
||||
import { IntegrationType } from "@shared/types";
|
||||
import Integration from "~/models/Integration";
|
||||
import Logger from "~/utils/Logger";
|
||||
import useStores from "./useStores";
|
||||
|
||||
export default function useEmbedIntegrations() {
|
||||
const { integrations } = useStores();
|
||||
|
||||
React.useEffect(() => {
|
||||
async function fetchEmbedIntegrations() {
|
||||
try {
|
||||
await integrations.fetchPage({
|
||||
limit: 100,
|
||||
type: IntegrationType.Embed,
|
||||
});
|
||||
} catch (err) {
|
||||
Logger.error("Failed to fetch embed integrations", err);
|
||||
}
|
||||
}
|
||||
|
||||
!integrations.isLoaded && fetchEmbedIntegrations();
|
||||
}, [integrations]);
|
||||
|
||||
return React.useMemo(
|
||||
() =>
|
||||
embeds.map((e) => {
|
||||
const em: Integration<IntegrationType.Embed> | undefined = find(
|
||||
integrations.orderedData,
|
||||
(i) => i.service === e.component.name.toLowerCase()
|
||||
);
|
||||
return new EmbedDescriptor({
|
||||
...e,
|
||||
settings: em?.settings,
|
||||
});
|
||||
}),
|
||||
[integrations.orderedData]
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,9 @@
|
||||
import { observable } from "mobx";
|
||||
import type { IntegrationSettings } from "@shared/types";
|
||||
import BaseModel from "~/models/BaseModel";
|
||||
import Field from "./decorators/Field";
|
||||
|
||||
type Settings = {
|
||||
url: string;
|
||||
channel: string;
|
||||
channelId: string;
|
||||
};
|
||||
|
||||
class Integration extends BaseModel {
|
||||
class Integration<T = unknown> extends BaseModel {
|
||||
id: string;
|
||||
|
||||
type: string;
|
||||
@@ -21,7 +16,7 @@ class Integration extends BaseModel {
|
||||
@observable
|
||||
events: string[];
|
||||
|
||||
settings: Settings;
|
||||
settings: IntegrationSettings<T>;
|
||||
}
|
||||
|
||||
export default Integration;
|
||||
|
||||
101
app/scenes/Settings/Drawio.tsx
Normal file
101
app/scenes/Settings/Drawio.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { head } from "lodash";
|
||||
import { observer } from "mobx-react";
|
||||
import { BuildingBlocksIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { IntegrationType } from "@shared/types";
|
||||
import Integration from "~/models/Integration";
|
||||
import Button from "~/components/Button";
|
||||
import Heading from "~/components/Heading";
|
||||
import { ReactHookWrappedInput as Input } from "~/components/Input";
|
||||
import Scene from "~/components/Scene";
|
||||
import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
|
||||
type FormData = {
|
||||
url: string;
|
||||
};
|
||||
|
||||
const SERVICE_NAME = "diagrams";
|
||||
|
||||
function Drawio() {
|
||||
const { integrations } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const { showToast } = useToasts();
|
||||
|
||||
React.useEffect(() => {
|
||||
integrations.fetchPage({
|
||||
service: SERVICE_NAME,
|
||||
type: IntegrationType.Embed,
|
||||
});
|
||||
}, [integrations]);
|
||||
|
||||
const integration = head(integrations.orderedData) as
|
||||
| Integration<IntegrationType.Embed>
|
||||
| undefined;
|
||||
|
||||
const { register, handleSubmit: formHandleSubmit, formState } = useForm<
|
||||
FormData
|
||||
>({
|
||||
mode: "all",
|
||||
defaultValues: {
|
||||
url: integration?.settings.url,
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
async (data: FormData) => {
|
||||
try {
|
||||
await integrations.save({
|
||||
id: integration?.id,
|
||||
type: IntegrationType.Embed,
|
||||
service: SERVICE_NAME,
|
||||
settings: {
|
||||
url: data.url,
|
||||
},
|
||||
});
|
||||
|
||||
showToast(t("Settings saved"), {
|
||||
type: "success",
|
||||
});
|
||||
} catch (err) {
|
||||
showToast(err.message, {
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
},
|
||||
[integrations, integration, t, showToast]
|
||||
);
|
||||
|
||||
return (
|
||||
<Scene title="Draw.io" icon={<BuildingBlocksIcon color="currentColor" />}>
|
||||
<Heading>Draw.io</Heading>
|
||||
|
||||
<Text type="secondary">
|
||||
<Trans>
|
||||
Add your self-hosted draw.io installation url here to enable automatic
|
||||
embedding of diagrams within documents.
|
||||
</Trans>
|
||||
<form onSubmit={formHandleSubmit(handleSubmit)}>
|
||||
<p>
|
||||
<Input
|
||||
label={t("Draw.io deployment")}
|
||||
placeholder={"https://app.diagrams.net/"}
|
||||
pattern="https?://.*"
|
||||
{...register("url", {
|
||||
required: true,
|
||||
})}
|
||||
/>
|
||||
<Button type="submit" disabled={formState.isSubmitting}>
|
||||
{formState.isSubmitting ? `${t("Saving")}…` : t("Save")}
|
||||
</Button>
|
||||
</p>
|
||||
</form>
|
||||
</Text>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(Drawio);
|
||||
@@ -3,6 +3,7 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { IntegrationType } from "@shared/types";
|
||||
import Collection from "~/models/Collection";
|
||||
import Integration from "~/models/Integration";
|
||||
import Button from "~/components/Button";
|
||||
@@ -124,7 +125,9 @@ function Slack() {
|
||||
<SlackListItem
|
||||
key={integration.id}
|
||||
collection={collection}
|
||||
integration={integration}
|
||||
integration={
|
||||
integration as Integration<IntegrationType.Post>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
|
||||
import styled from "styled-components";
|
||||
import { IntegrationType } from "@shared/types";
|
||||
import Collection from "~/models/Collection";
|
||||
import Integration from "~/models/Integration";
|
||||
import Button from "~/components/Button";
|
||||
@@ -17,7 +18,7 @@ import Text from "~/components/Text";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
|
||||
type Props = {
|
||||
integration: Integration;
|
||||
integration: Integration<IntegrationType.Post>;
|
||||
collection: Collection;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
"use strict";
|
||||
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
await queryInterface.changeColumn("integrations", "authenticationId", {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: true,
|
||||
});
|
||||
},
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
await queryInterface.changeColumn("integrations", "authenticationId", {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: false,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -5,7 +5,10 @@ import {
|
||||
Table,
|
||||
DataType,
|
||||
Scopes,
|
||||
IsIn,
|
||||
} from "sequelize-typescript";
|
||||
import { IntegrationType } from "@shared/types";
|
||||
import type { IntegrationSettings } from "@shared/types";
|
||||
import Collection from "./Collection";
|
||||
import IntegrationAuthentication from "./IntegrationAuthentication";
|
||||
import Team from "./Team";
|
||||
@@ -13,6 +16,15 @@ import User from "./User";
|
||||
import IdModel from "./base/IdModel";
|
||||
import Fix from "./decorators/Fix";
|
||||
|
||||
export enum IntegrationService {
|
||||
Diagrams = "diagrams",
|
||||
Slack = "slack",
|
||||
}
|
||||
|
||||
export enum UserCreatableIntegrationService {
|
||||
Diagrams = "diagrams",
|
||||
}
|
||||
|
||||
@Scopes(() => ({
|
||||
withAuthentication: {
|
||||
include: [
|
||||
@@ -26,16 +38,19 @@ import Fix from "./decorators/Fix";
|
||||
}))
|
||||
@Table({ tableName: "integrations", modelName: "integration" })
|
||||
@Fix
|
||||
class Integration extends IdModel {
|
||||
class Integration<T = unknown> extends IdModel {
|
||||
@IsIn([Object.values(IntegrationType)])
|
||||
@Column
|
||||
type: string;
|
||||
|
||||
@IsIn([Object.values(IntegrationService)])
|
||||
@Column
|
||||
service: string;
|
||||
|
||||
@Column(DataType.JSONB)
|
||||
settings: Record<string, any>;
|
||||
settings: IntegrationSettings<T>;
|
||||
|
||||
@IsIn([["documents.update", "documents.publish"]])
|
||||
@Column(DataType.ARRAY(DataType.STRING))
|
||||
events: string[];
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import fetch from "fetch-with-proxy";
|
||||
import { Op } from "sequelize";
|
||||
import { IntegrationType } from "@shared/types";
|
||||
import env from "@server/env";
|
||||
import { Document, Integration, Collection, Team } from "@server/models";
|
||||
import { presentSlackAttachment } from "@server/presenters";
|
||||
@@ -32,7 +33,7 @@ export default class SlackProcessor extends BaseProcessor {
|
||||
}
|
||||
|
||||
async integrationCreated(event: IntegrationEvent) {
|
||||
const integration = await Integration.findOne({
|
||||
const integration = (await Integration.findOne({
|
||||
where: {
|
||||
id: event.modelId,
|
||||
service: "slack",
|
||||
@@ -45,7 +46,7 @@ export default class SlackProcessor extends BaseProcessor {
|
||||
as: "collection",
|
||||
},
|
||||
],
|
||||
});
|
||||
})) as Integration<IntegrationType.Post>;
|
||||
if (!integration) {
|
||||
return;
|
||||
}
|
||||
@@ -93,7 +94,7 @@ export default class SlackProcessor extends BaseProcessor {
|
||||
return;
|
||||
}
|
||||
|
||||
const integration = await Integration.findOne({
|
||||
const integration = (await Integration.findOne({
|
||||
where: {
|
||||
teamId: document.teamId,
|
||||
collectionId: document.collectionId,
|
||||
@@ -105,7 +106,7 @@ export default class SlackProcessor extends BaseProcessor {
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
})) as Integration<IntegrationType.Post>;
|
||||
if (!integration) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import Router from "koa-router";
|
||||
import { has } from "lodash";
|
||||
import { IntegrationType } from "@shared/types";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import { Event } from "@server/models";
|
||||
import Integration from "@server/models/Integration";
|
||||
import Integration, {
|
||||
UserCreatableIntegrationService,
|
||||
} from "@server/models/Integration";
|
||||
import { authorize } from "@server/policies";
|
||||
import { presentIntegration } from "@server/presenters";
|
||||
import { assertSort, assertUuid, assertArray } from "@server/validation";
|
||||
import {
|
||||
assertSort,
|
||||
assertUuid,
|
||||
assertArray,
|
||||
assertIn,
|
||||
assertUrl,
|
||||
} from "@server/validation";
|
||||
import pagination from "./middlewares/pagination";
|
||||
|
||||
const router = new Router();
|
||||
@@ -33,8 +43,35 @@ router.post("integrations.list", auth(), pagination(), async (ctx) => {
|
||||
};
|
||||
});
|
||||
|
||||
router.post("integrations.update", auth(), async (ctx) => {
|
||||
const { id, events } = ctx.body;
|
||||
router.post("integrations.create", auth({ admin: true }), async (ctx) => {
|
||||
const { type, service, settings } = ctx.body;
|
||||
|
||||
assertIn(type, Object.values(IntegrationType));
|
||||
|
||||
const { user } = ctx.state;
|
||||
authorize(user, "createIntegration", user.team);
|
||||
|
||||
assertIn(service, Object.values(UserCreatableIntegrationService));
|
||||
|
||||
if (has(settings, "url")) {
|
||||
assertUrl(settings.url);
|
||||
}
|
||||
|
||||
const integration = await Integration.create({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
service,
|
||||
settings,
|
||||
type,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
data: presentIntegration(integration),
|
||||
};
|
||||
});
|
||||
|
||||
router.post("integrations.update", auth({ admin: true }), async (ctx) => {
|
||||
const { id, events = [], settings } = ctx.body;
|
||||
assertUuid(id, "id is required");
|
||||
|
||||
const { user } = ctx.state;
|
||||
@@ -43,12 +80,18 @@ router.post("integrations.update", auth(), async (ctx) => {
|
||||
|
||||
assertArray(events, "events must be an array");
|
||||
|
||||
if (integration.type === "post") {
|
||||
if (has(settings, "url")) {
|
||||
assertUrl(settings.url);
|
||||
}
|
||||
|
||||
if (integration.type === IntegrationType.Post) {
|
||||
integration.events = events.filter((event: string) =>
|
||||
["documents.update", "documents.publish"].includes(event)
|
||||
);
|
||||
}
|
||||
|
||||
integration.settings = settings;
|
||||
|
||||
await integration.save();
|
||||
|
||||
ctx.body = {
|
||||
@@ -56,7 +99,7 @@ router.post("integrations.update", auth(), async (ctx) => {
|
||||
};
|
||||
});
|
||||
|
||||
router.post("integrations.delete", auth(), async (ctx) => {
|
||||
router.post("integrations.delete", auth({ admin: true }), async (ctx) => {
|
||||
const { id } = ctx.body;
|
||||
assertUuid(id, "id is required");
|
||||
|
||||
|
||||
@@ -50,6 +50,17 @@ export const assertEmail = (value = "", message?: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const assertUrl = (value = "", message?: string) => {
|
||||
if (
|
||||
!validator.isURL(value, {
|
||||
protocols: ["http", "https"],
|
||||
require_valid_protocol: true,
|
||||
})
|
||||
) {
|
||||
throw ValidationError(message ?? `${value} is an invalid url!`);
|
||||
}
|
||||
};
|
||||
|
||||
export const assertUuid = (value: unknown, message?: string) => {
|
||||
if (typeof value !== "string") {
|
||||
throw ValidationError(message);
|
||||
|
||||
@@ -3,43 +3,38 @@ import Frame from "../components/Frame";
|
||||
import Image from "../components/Image";
|
||||
import { EmbedProps as Props } from ".";
|
||||
|
||||
const URL_REGEX = /^https:\/\/viewer\.diagrams\.net\/(?!proxy).*(title=\\w+)?/;
|
||||
function Diagrams(props: Props) {
|
||||
const { embed } = props;
|
||||
const embedUrl = props.attrs.matches[0];
|
||||
const params = new URL(embedUrl).searchParams;
|
||||
const titlePrefix = embed.settings?.url ? "Draw.io" : "Diagrams.net";
|
||||
const title = params.get("title")
|
||||
? `${titlePrefix} (${params.get("title")})`
|
||||
: titlePrefix;
|
||||
|
||||
export default class Diagrams extends React.Component<Props> {
|
||||
static ENABLED = [URL_REGEX];
|
||||
|
||||
get embedUrl() {
|
||||
return this.props.attrs.matches[0];
|
||||
}
|
||||
|
||||
get title() {
|
||||
let title = "Diagrams.net";
|
||||
const url = new URL(this.embedUrl);
|
||||
const documentTitle = url.searchParams.get("title");
|
||||
|
||||
if (documentTitle) {
|
||||
title += ` (${documentTitle})`;
|
||||
}
|
||||
|
||||
return title;
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Frame
|
||||
{...this.props}
|
||||
src={this.embedUrl}
|
||||
title={this.title}
|
||||
border
|
||||
icon={
|
||||
<Image
|
||||
src="/images/diagrams.png"
|
||||
alt="Diagrams.net"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Frame
|
||||
{...props}
|
||||
src={embedUrl}
|
||||
icon={
|
||||
<Image
|
||||
src="/images/diagrams.png"
|
||||
alt="Diagrams.net"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
}
|
||||
canonicalUrl={props.attrs.href}
|
||||
title={title}
|
||||
border
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Diagrams.ENABLED = [
|
||||
/^https:\/\/viewer\.diagrams\.net\/(?!proxy).*(title=\\w+)?/,
|
||||
];
|
||||
|
||||
Diagrams.URL_PATH_REGEX = /\/(?!proxy).*(title=\\w+)?/;
|
||||
|
||||
export default Diagrams;
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { EditorState } from "prosemirror-state";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { EmbedDescriptor } from "@shared/editor/types";
|
||||
import { IntegrationType } from "../../types";
|
||||
import type { IntegrationSettings } from "../../types";
|
||||
import { urlRegex } from "../../utils/urls";
|
||||
import Image from "../components/Image";
|
||||
import Abstract from "./Abstract";
|
||||
import Airtable from "./Airtable";
|
||||
@@ -55,10 +58,57 @@ export type EmbedProps = {
|
||||
};
|
||||
};
|
||||
|
||||
function matcher(Component: React.ComponentType<EmbedProps>) {
|
||||
return (url: string): boolean | [] | RegExpMatchArray => {
|
||||
const Img = styled(Image)`
|
||||
border-radius: 2px;
|
||||
background: #fff;
|
||||
box-shadow: 0 0 0 1px #fff;
|
||||
margin: 4px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
`;
|
||||
|
||||
export class EmbedDescriptor {
|
||||
icon: React.FC<any>;
|
||||
name?: string;
|
||||
title?: string;
|
||||
shortcut?: string;
|
||||
keywords?: string;
|
||||
tooltip?: string;
|
||||
defaultHidden?: boolean;
|
||||
attrs?: Record<string, any>;
|
||||
visible?: boolean;
|
||||
active?: (state: EditorState) => boolean;
|
||||
component: typeof React.Component | React.FC<any>;
|
||||
settings?: IntegrationSettings<IntegrationType.Embed>;
|
||||
|
||||
constructor(options: Omit<EmbedDescriptor, "matcher">) {
|
||||
this.icon = options.icon;
|
||||
this.name = options.name;
|
||||
this.title = options.title;
|
||||
this.shortcut = options.shortcut;
|
||||
this.keywords = options.keywords;
|
||||
this.tooltip = options.tooltip;
|
||||
this.defaultHidden = options.defaultHidden;
|
||||
this.attrs = options.attrs;
|
||||
this.visible = options.visible;
|
||||
this.active = options.active;
|
||||
this.component = options.component;
|
||||
this.settings = options.settings;
|
||||
}
|
||||
|
||||
matcher(url: string): boolean | [] | RegExpMatchArray {
|
||||
const regex = urlRegex(this.settings?.url);
|
||||
|
||||
// @ts-expect-error not aware of static
|
||||
const regexes = Component.ENABLED;
|
||||
const regexes = this.component.ENABLED;
|
||||
|
||||
regex &&
|
||||
regexes.unshift(
|
||||
new RegExp(
|
||||
// @ts-expect-error not aware of static
|
||||
`^${regex.source}${this.component.URL_PATH_REGEX.source}$`
|
||||
)
|
||||
);
|
||||
|
||||
for (const regex of regexes) {
|
||||
const result = url.match(regex);
|
||||
@@ -69,324 +119,273 @@ function matcher(Component: React.ComponentType<EmbedProps>) {
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const Img = styled(Image)`
|
||||
border-radius: 2px;
|
||||
background: #fff;
|
||||
box-shadow: 0 0 0 1px #fff;
|
||||
margin: 4px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
`;
|
||||
|
||||
const embeds: EmbedDescriptor[] = [
|
||||
{
|
||||
new EmbedDescriptor({
|
||||
title: "Abstract",
|
||||
keywords: "design",
|
||||
defaultHidden: true,
|
||||
icon: () => <Img src="/images/abstract.png" alt="Abstract" />,
|
||||
component: Abstract,
|
||||
matcher: matcher(Abstract),
|
||||
},
|
||||
{
|
||||
}),
|
||||
new EmbedDescriptor({
|
||||
title: "Airtable",
|
||||
keywords: "spreadsheet",
|
||||
icon: () => <Img src="/images/airtable.png" alt="Airtable" />,
|
||||
component: Airtable,
|
||||
matcher: matcher(Airtable),
|
||||
},
|
||||
{
|
||||
}),
|
||||
new EmbedDescriptor({
|
||||
title: "Berrycast",
|
||||
keywords: "video",
|
||||
defaultHidden: true,
|
||||
icon: () => <Img src="/images/berrycast.png" alt="Berrycast" />,
|
||||
component: Berrycast,
|
||||
matcher: matcher(Berrycast),
|
||||
},
|
||||
{
|
||||
}),
|
||||
new EmbedDescriptor({
|
||||
title: "Bilibili",
|
||||
keywords: "video",
|
||||
defaultHidden: true,
|
||||
icon: () => <Img src="/images/bilibili.png" alt="Bilibili" />,
|
||||
component: Bilibili,
|
||||
matcher: matcher(Bilibili),
|
||||
},
|
||||
{
|
||||
}),
|
||||
new EmbedDescriptor({
|
||||
title: "Cawemo",
|
||||
keywords: "bpmn process",
|
||||
defaultHidden: true,
|
||||
icon: () => <Img src="/images/cawemo.png" alt="Cawemo" />,
|
||||
component: Cawemo,
|
||||
matcher: matcher(Cawemo),
|
||||
},
|
||||
{
|
||||
}),
|
||||
new EmbedDescriptor({
|
||||
title: "ClickUp",
|
||||
keywords: "project",
|
||||
icon: () => <Img src="/images/clickup.png" alt="ClickUp" />,
|
||||
component: ClickUp,
|
||||
matcher: matcher(ClickUp),
|
||||
},
|
||||
{
|
||||
}),
|
||||
new EmbedDescriptor({
|
||||
title: "Codepen",
|
||||
keywords: "code editor",
|
||||
icon: () => <Img src="/images/codepen.png" alt="Codepen" />,
|
||||
component: Codepen,
|
||||
matcher: matcher(Codepen),
|
||||
},
|
||||
{
|
||||
}),
|
||||
new EmbedDescriptor({
|
||||
title: "DBDiagram",
|
||||
keywords: "diagrams database",
|
||||
icon: () => <Img src="/images/dbdiagram.png" alt="DBDiagram" />,
|
||||
component: DBDiagram,
|
||||
matcher: matcher(DBDiagram),
|
||||
},
|
||||
{
|
||||
}),
|
||||
new EmbedDescriptor({
|
||||
title: "Descript",
|
||||
keywords: "audio",
|
||||
icon: () => <Img src="/images/descript.png" alt="Descript" />,
|
||||
component: Descript,
|
||||
matcher: matcher(Descript),
|
||||
},
|
||||
{
|
||||
}),
|
||||
new EmbedDescriptor({
|
||||
title: "Figma",
|
||||
keywords: "design svg vector",
|
||||
icon: () => <Img src="/images/figma.png" alt="Figma" />,
|
||||
component: Figma,
|
||||
matcher: matcher(Figma),
|
||||
},
|
||||
{
|
||||
}),
|
||||
new EmbedDescriptor({
|
||||
title: "Framer",
|
||||
keywords: "design prototyping",
|
||||
icon: () => <Img src="/images/framer.png" alt="Framer" />,
|
||||
component: Framer,
|
||||
matcher: matcher(Framer),
|
||||
},
|
||||
{
|
||||
}),
|
||||
new EmbedDescriptor({
|
||||
title: "GitHub Gist",
|
||||
keywords: "code",
|
||||
icon: () => <Img src="/images/github-gist.png" alt="GitHub" />,
|
||||
component: Gist,
|
||||
matcher: matcher(Gist),
|
||||
},
|
||||
{
|
||||
}),
|
||||
new EmbedDescriptor({
|
||||
title: "Gliffy",
|
||||
keywords: "diagram",
|
||||
icon: () => <Img src="/images/gliffy.png" alt="Gliffy" />,
|
||||
component: Gliffy,
|
||||
matcher: matcher(Gliffy),
|
||||
},
|
||||
{
|
||||
}),
|
||||
new EmbedDescriptor({
|
||||
title: "Diagrams.net",
|
||||
keywords: "diagrams drawio",
|
||||
icon: () => <Img src="/images/diagrams.png" alt="Diagrams.net" />,
|
||||
component: Diagrams,
|
||||
matcher: matcher(Diagrams),
|
||||
},
|
||||
{
|
||||
}),
|
||||
new EmbedDescriptor({
|
||||
title: "Google Drawings",
|
||||
keywords: "drawings",
|
||||
icon: () => <Img src="/images/google-drawings.png" alt="Google Drawings" />,
|
||||
component: GoogleDrawings,
|
||||
matcher: matcher(GoogleDrawings),
|
||||
},
|
||||
{
|
||||
}),
|
||||
new EmbedDescriptor({
|
||||
title: "Google Drive",
|
||||
keywords: "drive",
|
||||
icon: () => <Img src="/images/google-drive.png" alt="Google Drive" />,
|
||||
component: GoogleDrive,
|
||||
matcher: matcher(GoogleDrive),
|
||||
},
|
||||
{
|
||||
}),
|
||||
new EmbedDescriptor({
|
||||
title: "Google Docs",
|
||||
keywords: "documents word",
|
||||
icon: () => <Img src="/images/google-docs.png" alt="Google Docs" />,
|
||||
component: GoogleDocs,
|
||||
matcher: matcher(GoogleDocs),
|
||||
},
|
||||
{
|
||||
}),
|
||||
new EmbedDescriptor({
|
||||
title: "Google Sheets",
|
||||
keywords: "excel spreadsheet",
|
||||
icon: () => <Img src="/images/google-sheets.png" alt="Google Sheets" />,
|
||||
component: GoogleSheets,
|
||||
matcher: matcher(GoogleSheets),
|
||||
},
|
||||
{
|
||||
}),
|
||||
new EmbedDescriptor({
|
||||
title: "Google Slides",
|
||||
keywords: "presentation slideshow",
|
||||
icon: () => <Img src="/images/google-slides.png" alt="Google Slides" />,
|
||||
component: GoogleSlides,
|
||||
matcher: matcher(GoogleSlides),
|
||||
},
|
||||
{
|
||||
}),
|
||||
new EmbedDescriptor({
|
||||
title: "Google Calendar",
|
||||
keywords: "calendar",
|
||||
icon: () => <Img src="/images/google-calendar.png" alt="Google Calendar" />,
|
||||
component: GoogleCalendar,
|
||||
matcher: matcher(GoogleCalendar),
|
||||
},
|
||||
{
|
||||
}),
|
||||
new EmbedDescriptor({
|
||||
title: "Google Data Studio",
|
||||
keywords: "bi business intelligence",
|
||||
icon: () => (
|
||||
<Img src="/images/google-datastudio.png" alt="Google Data Studio" />
|
||||
),
|
||||
component: GoogleDataStudio,
|
||||
matcher: matcher(GoogleDataStudio),
|
||||
},
|
||||
{
|
||||
}),
|
||||
new EmbedDescriptor({
|
||||
title: "Google Forms",
|
||||
keywords: "form survey",
|
||||
icon: () => <Img src="/images/google-forms.png" alt="Google Forms" />,
|
||||
component: GoogleForms,
|
||||
matcher: matcher(GoogleForms),
|
||||
},
|
||||
{
|
||||
}),
|
||||
new EmbedDescriptor({
|
||||
title: "Grist",
|
||||
keywords: "spreadsheet",
|
||||
icon: () => <Img src="/images/grist.png" alt="Grist" />,
|
||||
component: Grist,
|
||||
matcher: matcher(Grist),
|
||||
},
|
||||
{
|
||||
}),
|
||||
new EmbedDescriptor({
|
||||
title: "InVision",
|
||||
keywords: "design prototype",
|
||||
defaultHidden: true,
|
||||
icon: () => <Img src="/images/invision.png" alt="InVision" />,
|
||||
component: InVision,
|
||||
matcher: matcher(InVision),
|
||||
},
|
||||
{
|
||||
}),
|
||||
new EmbedDescriptor({
|
||||
title: "JSFiddle",
|
||||
keywords: "code",
|
||||
defaultHidden: true,
|
||||
icon: () => <Img src="/images/jsfiddle.png" alt="JSFiddle" />,
|
||||
component: JSFiddle,
|
||||
matcher: matcher(JSFiddle),
|
||||
},
|
||||
{
|
||||
}),
|
||||
new EmbedDescriptor({
|
||||
title: "Loom",
|
||||
keywords: "video screencast",
|
||||
icon: () => <Img src="/images/loom.png" alt="Loom" />,
|
||||
component: Loom,
|
||||
matcher: matcher(Loom),
|
||||
},
|
||||
{
|
||||
}),
|
||||
new EmbedDescriptor({
|
||||
title: "Lucidchart",
|
||||
keywords: "chart",
|
||||
icon: () => <Img src="/images/lucidchart.png" alt="Lucidchart" />,
|
||||
component: Lucidchart,
|
||||
matcher: matcher(Lucidchart),
|
||||
},
|
||||
{
|
||||
}),
|
||||
new EmbedDescriptor({
|
||||
title: "Marvel",
|
||||
keywords: "design prototype",
|
||||
icon: () => <Img src="/images/marvel.png" alt="Marvel" />,
|
||||
component: Marvel,
|
||||
matcher: matcher(Marvel),
|
||||
},
|
||||
{
|
||||
}),
|
||||
new EmbedDescriptor({
|
||||
title: "Mindmeister",
|
||||
keywords: "mindmap",
|
||||
icon: () => <Img src="/images/mindmeister.png" alt="Mindmeister" />,
|
||||
component: Mindmeister,
|
||||
matcher: matcher(Mindmeister),
|
||||
},
|
||||
{
|
||||
}),
|
||||
new EmbedDescriptor({
|
||||
title: "Miro",
|
||||
keywords: "whiteboard",
|
||||
icon: () => <Img src="/images/miro.png" alt="Miro" />,
|
||||
component: Miro,
|
||||
matcher: matcher(Miro),
|
||||
},
|
||||
{
|
||||
}),
|
||||
new EmbedDescriptor({
|
||||
title: "Mode",
|
||||
keywords: "analytics",
|
||||
defaultHidden: true,
|
||||
icon: () => <Img src="/images/mode-analytics.png" alt="Mode" />,
|
||||
component: ModeAnalytics,
|
||||
matcher: matcher(ModeAnalytics),
|
||||
},
|
||||
{
|
||||
}),
|
||||
new EmbedDescriptor({
|
||||
title: "Otter.ai",
|
||||
keywords: "audio transcription meeting notes",
|
||||
defaultHidden: true,
|
||||
icon: () => <Img src="/images/otter.png" alt="Otter.ai" />,
|
||||
component: Otter,
|
||||
matcher: matcher(Otter),
|
||||
},
|
||||
{
|
||||
}),
|
||||
new EmbedDescriptor({
|
||||
title: "Pitch",
|
||||
keywords: "presentation",
|
||||
defaultHidden: true,
|
||||
icon: () => <Img src="/images/pitch.png" alt="Pitch" />,
|
||||
component: Pitch,
|
||||
matcher: matcher(Pitch),
|
||||
},
|
||||
{
|
||||
}),
|
||||
new EmbedDescriptor({
|
||||
title: "Prezi",
|
||||
keywords: "presentation",
|
||||
icon: () => <Img src="/images/prezi.png" alt="Prezi" />,
|
||||
component: Prezi,
|
||||
matcher: matcher(Prezi),
|
||||
},
|
||||
{
|
||||
}),
|
||||
new EmbedDescriptor({
|
||||
title: "Scribe",
|
||||
keywords: "screencast",
|
||||
icon: () => <Img src="/images/scribe.png" alt="Scribe" />,
|
||||
component: Scribe,
|
||||
matcher: matcher(Scribe),
|
||||
},
|
||||
{
|
||||
}),
|
||||
new EmbedDescriptor({
|
||||
title: "Spotify",
|
||||
keywords: "music",
|
||||
icon: () => <Img src="/images/spotify.png" alt="Spotify" />,
|
||||
component: Spotify,
|
||||
matcher: matcher(Spotify),
|
||||
},
|
||||
{
|
||||
}),
|
||||
new EmbedDescriptor({
|
||||
title: "Tldraw",
|
||||
keywords: "draw schematics diagrams",
|
||||
icon: () => <Img src="/images/tldraw.png" alt="Tldraw" />,
|
||||
component: Tldraw,
|
||||
matcher: matcher(Tldraw),
|
||||
},
|
||||
{
|
||||
}),
|
||||
new EmbedDescriptor({
|
||||
title: "Trello",
|
||||
keywords: "kanban",
|
||||
icon: () => <Img src="/images/trello.png" alt="Trello" />,
|
||||
component: Trello,
|
||||
matcher: matcher(Trello),
|
||||
},
|
||||
{
|
||||
}),
|
||||
new EmbedDescriptor({
|
||||
title: "Typeform",
|
||||
keywords: "form survey",
|
||||
icon: () => <Img src="/images/typeform.png" alt="Typeform" />,
|
||||
component: Typeform,
|
||||
matcher: matcher(Typeform),
|
||||
},
|
||||
{
|
||||
}),
|
||||
new EmbedDescriptor({
|
||||
title: "Vimeo",
|
||||
keywords: "video",
|
||||
icon: () => <Img src="/images/vimeo.png" alt="Vimeo" />,
|
||||
component: Vimeo,
|
||||
matcher: matcher(Vimeo),
|
||||
},
|
||||
{
|
||||
}),
|
||||
new EmbedDescriptor({
|
||||
title: "Whimsical",
|
||||
keywords: "whiteboard",
|
||||
icon: () => <Img src="/images/whimsical.png" alt="Whimsical" />,
|
||||
component: Whimsical,
|
||||
matcher: matcher(Whimsical),
|
||||
},
|
||||
{
|
||||
}),
|
||||
new EmbedDescriptor({
|
||||
title: "YouTube",
|
||||
keywords: "google video",
|
||||
icon: () => <Img src="/images/youtube.png" alt="YouTube" />,
|
||||
component: YouTube,
|
||||
matcher: matcher(YouTube),
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
export default embeds;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { EmbedDescriptor, MenuItem } from "../types";
|
||||
import { EmbedDescriptor } from "../embeds";
|
||||
import { MenuItem } from "../types";
|
||||
|
||||
type Item = MenuItem | EmbedDescriptor;
|
||||
|
||||
|
||||
@@ -107,6 +107,7 @@ export default class Embed extends Node {
|
||||
attrs={{ ...node.attrs, matches }}
|
||||
isEditable={isEditable}
|
||||
isSelected={isSelected}
|
||||
embed={embed}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import MarkdownIt from "markdown-it";
|
||||
import Token from "markdown-it/lib/token";
|
||||
import { EmbedDescriptor } from "../types";
|
||||
import { EmbedDescriptor } from "../embeds";
|
||||
|
||||
function isParagraph(token: Token) {
|
||||
return token.type === "paragraph_open";
|
||||
|
||||
@@ -27,12 +27,6 @@ export type MenuItem = {
|
||||
active?: (state: EditorState) => boolean;
|
||||
};
|
||||
|
||||
export type EmbedDescriptor = MenuItem & {
|
||||
icon: React.FC<any>;
|
||||
matcher: (url: string) => boolean | [] | RegExpMatchArray;
|
||||
component: typeof React.Component | React.FC<any>;
|
||||
};
|
||||
|
||||
export type ComponentProps = {
|
||||
theme: DefaultTheme;
|
||||
node: ProsemirrorNode;
|
||||
|
||||
@@ -202,6 +202,7 @@
|
||||
"Export": "Export",
|
||||
"Webhooks": "Webhooks",
|
||||
"Integrations": "Integrations",
|
||||
"Draw.io": "Draw.io",
|
||||
"Insert column after": "Insert column after",
|
||||
"Insert column before": "Insert column before",
|
||||
"Insert row after": "Insert row after",
|
||||
@@ -623,6 +624,8 @@
|
||||
"Choose a subdomain to enable a login page just for your team.": "Choose a subdomain to enable a login page just for your team.",
|
||||
"Start view": "Start view",
|
||||
"This is the screen that team members will first see when they sign in.": "This is the screen that team members will first see when they sign in.",
|
||||
"Add your self-hosted draw.io installation url here to enable automatic embedding of diagrams within documents.": "Add your self-hosted draw.io installation url here to enable automatic embedding of diagrams within documents.",
|
||||
"Draw.io deployment": "Draw.io deployment",
|
||||
"Export in progress…": "Export in progress…",
|
||||
"Export deleted": "Export deleted",
|
||||
"A full export might take some time, consider exporting a single document or collection. The exported data is a zip of your documents in Markdown format. You may leave this page once the export has started – if you have notifications enabled, we will email a link to <em>{{ userEmail }}</em> when it’s complete.": "A full export might take some time, consider exporting a single document or collection. The exported data is a zip of your documents in Markdown format. You may leave this page once the export has started – if you have notifications enabled, we will email a link to <em>{{ userEmail }}</em> when it’s complete.",
|
||||
|
||||
@@ -21,3 +21,20 @@ export type PublicEnv = {
|
||||
GOOGLE_ANALYTICS_ID: string | undefined;
|
||||
RELEASE: string | undefined;
|
||||
};
|
||||
|
||||
export enum IntegrationType {
|
||||
Post = "post",
|
||||
Command = "command",
|
||||
Embed = "embed",
|
||||
}
|
||||
|
||||
export type IntegrationSettings<T> = T extends IntegrationType.Embed
|
||||
? { url: string }
|
||||
: T extends IntegrationType.Post
|
||||
? { url: string; channel: string; channelId: string }
|
||||
: T extends IntegrationType.Post
|
||||
? { serviceTeamId: string }
|
||||
:
|
||||
| { url: string }
|
||||
| { url: string; channel: string; channelId: string }
|
||||
| { serviceTeamId: string };
|
||||
|
||||
14
shared/utils/urls.test.ts
Normal file
14
shared/utils/urls.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { urlRegex } from "./urls";
|
||||
|
||||
describe("#urlRegex", () => {
|
||||
it("should return undefined for invalid urls", () => {
|
||||
expect(urlRegex(undefined)).toBeUndefined();
|
||||
expect(urlRegex(null)).toBeUndefined();
|
||||
expect(urlRegex("invalid url!")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return corresponding regex otherwise", () => {
|
||||
const regex = urlRegex("https://docs.google.com");
|
||||
expect(regex?.source).toBe(/https:\/\/docs\.google\.com/.source);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { escapeRegExp } from "lodash";
|
||||
import env from "../env";
|
||||
import { parseDomain } from "./domains";
|
||||
|
||||
@@ -92,3 +93,13 @@ export function sanitizeUrl(url: string | null | undefined) {
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
export function urlRegex(url: string | null | undefined): RegExp | undefined {
|
||||
if (!url || !isUrl(url)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const urlObj = new URL(sanitizeUrl(url) as string);
|
||||
|
||||
return new RegExp(escapeRegExp(`${urlObj.protocol}//${urlObj.host}`));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user