Port over linkifyJS to shared-components. (#32731)
* Port over linkifyJS to shared-components. * Drop rubbish * update lock * quickfix test * drop group id * Modernize tests * Remove stories that aren't in use. * Complete working version * Add copyright * tidy up * update lock * Update snaps * update snap * undo change * remove unused * More test updates * fix typo * fix margin on preview * move margin block * snapupdate * prettier * cleanup a test mistake * Fixup sonar issues * Don't expose linkifyjs to applications, just provide helper functions. * Add story for documentation. * remove $ * Use a const * typo * cleanup var name * remove console line * Changes checkpoint * Convert to context * Revert unrelated change. * more cleanup * Add a test to cover ignoring incoming data elements * Make tests happy * Update tests for LinkedText * Underlines! * fix lock * remove unused linkify packages * import move * Remove mod to remove underline * undo * fix snap * another snapshot fix * Tidy up based on review. * fix story * Pass in args
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
.container {
|
||||
a {
|
||||
color: var(--cpd-color-text-link-external);
|
||||
}
|
||||
margin: 0;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type ComponentProps } from "react";
|
||||
import { fn } from "storybook/test";
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { LinkedText } from "./LinkedText";
|
||||
import { LinkedTextContext } from "./LinkedTextContext";
|
||||
|
||||
const meta = {
|
||||
title: "Utils/LinkedText",
|
||||
component: LinkedText,
|
||||
decorators: [
|
||||
(Story, { args }) => (
|
||||
<LinkedTextContext.Provider
|
||||
value={{
|
||||
userIdListener: args.userIdListener,
|
||||
roomAliasListener: args.roomAliasListener,
|
||||
urlTargetTransformer: args.urlTargetTransformer,
|
||||
hrefTransformer: args.hrefTransformer,
|
||||
}}
|
||||
>
|
||||
<Story />
|
||||
</LinkedTextContext.Provider>
|
||||
),
|
||||
],
|
||||
args: {
|
||||
children: "I love working on https://matrix.org.",
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
} satisfies Meta<ComponentProps<typeof LinkedText> & ComponentProps<typeof LinkedTextContext>["value"]>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const WithUserId: Story = {
|
||||
args: {
|
||||
children: "I love talking to @alice:example.org.",
|
||||
userIdListener: fn(),
|
||||
},
|
||||
};
|
||||
|
||||
export const WithRoomAlias: Story = {
|
||||
args: {
|
||||
children: "I love talking in #general:example.org.",
|
||||
roomAliasListener: fn(),
|
||||
},
|
||||
};
|
||||
|
||||
export const WithCustomUrlTarget: Story = {
|
||||
args: {
|
||||
urlTargetTransformer: () => "_fake_target",
|
||||
},
|
||||
tags: ["skip-test"],
|
||||
};
|
||||
|
||||
export const WithCustomHref: Story = {
|
||||
args: {
|
||||
hrefTransformer: () => {
|
||||
return "https://example.org";
|
||||
},
|
||||
},
|
||||
tags: ["skip-test"],
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { render } from "@test-utils";
|
||||
import { describe, it, expect, vitest } from "vitest";
|
||||
import React from "react";
|
||||
import { composeStories } from "@storybook/react-vite";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import * as stories from "./LinkedText.stories.tsx";
|
||||
import { LinkedText } from "./LinkedText.tsx";
|
||||
import { LinkifyOptionalSlashProtocols, PERMITTED_URL_SCHEMES } from "../linkify";
|
||||
import { LinkedTextContext } from "./LinkedTextContext.tsx";
|
||||
|
||||
const { Default, WithUserId, WithRoomAlias, WithCustomHref, WithCustomUrlTarget } = composeStories(stories);
|
||||
|
||||
describe("LinkedText", () => {
|
||||
it.each(
|
||||
PERMITTED_URL_SCHEMES.filter((protocol) => !LinkifyOptionalSlashProtocols.includes(protocol)).map(
|
||||
(protocol) => `${protocol}://abcdef/`,
|
||||
),
|
||||
)("renders protocol with no optional slash '%s'", (path) => {
|
||||
const { getByRole } = render(
|
||||
<LinkedTextContext value={{}}>
|
||||
<LinkedText>Check out this link {path}</LinkedText>
|
||||
</LinkedTextContext>,
|
||||
);
|
||||
expect(getByRole("link")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each(LinkifyOptionalSlashProtocols.map((protocol) => `${protocol}://abcdef`))(
|
||||
"renders protocol with optional slash '%s'",
|
||||
(path) => {
|
||||
const { getByRole } = render(
|
||||
<LinkedTextContext value={{}}>
|
||||
<LinkedText>Check out this link {path}</LinkedText>
|
||||
</LinkedTextContext>,
|
||||
);
|
||||
expect(getByRole("link")).toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it("renders a standard link", () => {
|
||||
const { container } = render(<Default />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders a user ID", () => {
|
||||
const { container } = render(<WithUserId />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders a room alias", () => {
|
||||
const { container } = render(<WithRoomAlias />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders a custom target", () => {
|
||||
const { container } = render(<WithCustomUrlTarget />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders a custom href", () => {
|
||||
const { container } = render(<WithCustomHref />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("supports setting an onLinkClicked handler", async () => {
|
||||
const fn = vitest.fn();
|
||||
const { getAllByRole } = render(
|
||||
<LinkedTextContext value={{}}>
|
||||
<LinkedText onLinkClick={fn}>Check out this link https://google.com and example.org</LinkedText>
|
||||
</LinkedTextContext>,
|
||||
);
|
||||
const links = getAllByRole("link");
|
||||
expect(links).toHaveLength(2);
|
||||
await userEvent.click(links[0]);
|
||||
await userEvent.click(links[1]);
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { Link, Text } from "@vector-im/compound-web";
|
||||
import React, { type ComponentProps } from "react";
|
||||
import classNames from "classnames";
|
||||
import Linkify from "linkify-react";
|
||||
|
||||
import styles from "./LinkedText.module.css";
|
||||
import { generateLinkedTextOptions } from "../linkify";
|
||||
import { useLinkedTextContext } from "./LinkedTextContext";
|
||||
|
||||
export type LinkedTextProps = ComponentProps<typeof Text> & {
|
||||
/**
|
||||
* Handler for when a link within the component is clicked. This will run
|
||||
* *before* any LinkedTextContext handlers are run.
|
||||
* @param ev The event raised by the click.
|
||||
*/
|
||||
onLinkClick?: (ev: MouseEvent) => void;
|
||||
};
|
||||
/**
|
||||
* A component that renders URLs as clickable links inside some plain text.
|
||||
*
|
||||
* Requires a `<LinkedTextContext.Provider>`
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <LinkedTextContext.Provider value={...}>
|
||||
* <LinkedText>
|
||||
* I love working on https://matrix.org
|
||||
* </LinkedText>
|
||||
* </LinkedTextContext.Provider>
|
||||
* ```
|
||||
*/
|
||||
export function LinkedText({ children, className, onLinkClick, ...textProps }: LinkedTextProps): React.ReactNode {
|
||||
const options = useLinkedTextContext();
|
||||
const linkifyOptions = generateLinkedTextOptions({ ...options, onLinkClick });
|
||||
return (
|
||||
<Linkify
|
||||
className={classNames(styles.container, className)}
|
||||
as={Text}
|
||||
options={{ ...linkifyOptions, render: Link }}
|
||||
{...textProps}
|
||||
>
|
||||
{children}
|
||||
</Linkify>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
import type { LinkEventListener, LinkifyMatrixOpaqueIdType } from "../linkify";
|
||||
|
||||
export interface LinkedTextConfiguration {
|
||||
/**
|
||||
* Event handlers for URL links.
|
||||
*/
|
||||
urlListener?: (href: string) => LinkEventListener;
|
||||
/**
|
||||
* Event handlers for room alias links.
|
||||
*/
|
||||
roomAliasListener?: (href: string) => LinkEventListener;
|
||||
/**
|
||||
* Event handlers for user ID links.
|
||||
*/
|
||||
userIdListener?: (href: string) => LinkEventListener;
|
||||
/**
|
||||
* Function that can be used to transform the `target` attribute on links, depending on the `href`.
|
||||
*/
|
||||
urlTargetTransformer?: (href: string) => string;
|
||||
/**
|
||||
* Function that can be used to transform the `href` attribute on links, depending on the current href and target type.
|
||||
*/
|
||||
hrefTransformer?: (href: string, target: LinkifyMatrixOpaqueIdType) => string;
|
||||
}
|
||||
|
||||
export const LinkedTextContext = createContext<LinkedTextConfiguration | null>(null);
|
||||
LinkedTextContext.displayName = "LinkedTextContext";
|
||||
|
||||
/**
|
||||
* A hook to get the linked text configuration from the context. Will throw if no LinkedTextContext is found.
|
||||
* @throws If no LinkedTextContext context is found
|
||||
* @returns The linked text configuration from the context
|
||||
*/
|
||||
export function useLinkedTextContext(): LinkedTextConfiguration {
|
||||
const config = useContext(LinkedTextContext);
|
||||
|
||||
if (!config) {
|
||||
throw new Error("useLinkedTextContextOpts must be used within an LinkedTextContext.Provider");
|
||||
}
|
||||
return config;
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`LinkedText > renders a custom href 1`] = `
|
||||
<div>
|
||||
<p
|
||||
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 container"
|
||||
>
|
||||
I love working on
|
||||
<a
|
||||
data-linkified="true"
|
||||
href="https://example.org"
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
>
|
||||
https://matrix.org
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`LinkedText > renders a custom target 1`] = `
|
||||
<div>
|
||||
<p
|
||||
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 container"
|
||||
>
|
||||
I love working on
|
||||
<a
|
||||
data-linkified="true"
|
||||
href="https://matrix.org"
|
||||
rel="noreferrer noopener"
|
||||
target="_fake_target"
|
||||
>
|
||||
https://matrix.org
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`LinkedText > renders a room alias 1`] = `
|
||||
<div>
|
||||
<p
|
||||
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 container"
|
||||
>
|
||||
I love talking in
|
||||
<a
|
||||
data-linkified="true"
|
||||
href="#general:example.org"
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
>
|
||||
#general:example.org
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`LinkedText > renders a standard link 1`] = `
|
||||
<div>
|
||||
<p
|
||||
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 container"
|
||||
>
|
||||
I love working on
|
||||
<a
|
||||
data-linkified="true"
|
||||
href="https://matrix.org"
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
>
|
||||
https://matrix.org
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`LinkedText > renders a user ID 1`] = `
|
||||
<div>
|
||||
<p
|
||||
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 container"
|
||||
>
|
||||
I love talking to
|
||||
<a
|
||||
data-linkified="true"
|
||||
href="@alice:example.org"
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
>
|
||||
@alice:example.org
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,9 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
export { LinkedText, type LinkedTextProps } from "./LinkedText";
|
||||
export { LinkedTextContext, useLinkedTextContext } from "./LinkedTextContext";
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { Markdown } from "@storybook/addon-docs/blocks";
|
||||
|
||||
import type { Meta } from "@storybook/react-vite";
|
||||
import LinkifyMatrixOpaqueIdType from "../../typedoc/enumerations/LinkifyMatrixOpaqueIdType.md?raw";
|
||||
import findLinksInString from "../../typedoc/functions/findLinksInString.md?raw";
|
||||
import isLinkable from "../../typedoc/functions/isLinkable.md?raw";
|
||||
import linkifyHtml from "../../typedoc/functions/linkifyHtml.md?raw";
|
||||
import linkifyString from "../../typedoc/functions/linkifyString.md?raw";
|
||||
import generateLinkedTextOptions from "../../typedoc/functions/generateLinkedTextOptions.md?raw";
|
||||
import LinkedTextOptions from "../../typedoc/interfaces/LinkedTextOptions.md?raw";
|
||||
|
||||
const meta = {
|
||||
title: "utils/linkify",
|
||||
parameters: {
|
||||
docs: {
|
||||
page: () => (
|
||||
<>
|
||||
<h1>Linkify utilities</h1>
|
||||
<p>Supporting functions and types for parsing links from HTML/strings.</p>
|
||||
<h2>LinkifyMatrixOpaqueIdType</h2>
|
||||
<Markdown>{LinkifyMatrixOpaqueIdType}</Markdown>
|
||||
<h2>findLinksInString</h2>
|
||||
<Markdown>{findLinksInString}</Markdown>
|
||||
<h2>isLinkable</h2>
|
||||
<Markdown>{isLinkable}</Markdown>
|
||||
<h2>linkifyHtml</h2>
|
||||
<Markdown>{linkifyHtml}</Markdown>
|
||||
<h2>linkifyString</h2>
|
||||
<Markdown>{linkifyString}</Markdown>
|
||||
<h2>generateLinkedTextOptions</h2>
|
||||
<Markdown>{generateLinkedTextOptions}</Markdown>
|
||||
<h3>LinkedTextOptions</h3>
|
||||
<Markdown>{LinkedTextOptions}</Markdown>
|
||||
</>
|
||||
),
|
||||
},
|
||||
},
|
||||
tags: ["autodocs", "skip-test"],
|
||||
} satisfies Meta;
|
||||
|
||||
export default meta;
|
||||
|
||||
// Docs-only story - renders nothing but triggers autodocs
|
||||
export const Docs = {
|
||||
render: () => null,
|
||||
};
|
||||
@@ -0,0 +1,411 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { findLinksInString, isLinkable, linkifyHtml, LinkifyMatrixOpaqueIdType } from "./linkify";
|
||||
|
||||
describe("linkify-matrix", () => {
|
||||
const linkTypesByInitialCharacter: Record<string, string> = {
|
||||
"#": "roomalias",
|
||||
"@": "userid",
|
||||
};
|
||||
|
||||
describe.each(Object.entries(linkTypesByInitialCharacter))("handles '%s' (%s)", (char, type) => {
|
||||
it("should not parse " + char + "foo without domain", () => {
|
||||
const test = char + "foo";
|
||||
const found = findLinksInString(test);
|
||||
expect(isLinkable(test)).toEqual(false);
|
||||
expect(found).toEqual([]);
|
||||
});
|
||||
describe("ip v4 tests", () => {
|
||||
it("should properly parse IPs v4 as the domain name", () => {
|
||||
const test = char + "potato:1.2.3.4";
|
||||
expect(isLinkable(test)).toEqual(true);
|
||||
const found = findLinksInString(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: char + "potato:1.2.3.4",
|
||||
type,
|
||||
isLink: true,
|
||||
start: 0,
|
||||
end: test.length,
|
||||
value: char + "potato:1.2.3.4",
|
||||
},
|
||||
]);
|
||||
});
|
||||
it("should properly parse IPs v4 with port as the domain name with attached", () => {
|
||||
const test = char + "potato:1.2.3.4:1337";
|
||||
expect(isLinkable(test)).toEqual(true);
|
||||
const found = findLinksInString(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: char + "potato:1.2.3.4:1337",
|
||||
type,
|
||||
isLink: true,
|
||||
start: 0,
|
||||
end: test.length,
|
||||
value: char + "potato:1.2.3.4:1337",
|
||||
},
|
||||
]);
|
||||
});
|
||||
it("should properly parse IPs v4 as the domain name while ignoring missing port", () => {
|
||||
const test = char + "potato:1.2.3.4:";
|
||||
expect(isLinkable(test)).toEqual(false);
|
||||
const found = findLinksInString(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: char + "potato:1.2.3.4",
|
||||
type,
|
||||
isLink: true,
|
||||
start: 0,
|
||||
end: test.length - 1,
|
||||
value: char + "potato:1.2.3.4",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
// Currently those tests are failing, as there's missing implementation.
|
||||
describe.skip("ip v6 tests", () => {
|
||||
it("should properly parse IPs v6 as the domain name", () => {
|
||||
const test = char + "username:[1234:5678::abcd]";
|
||||
expect(isLinkable(test)).toEqual(true);
|
||||
const found = findLinksInString(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: char + "username:[1234:5678::abcd]",
|
||||
type,
|
||||
isLink: true,
|
||||
start: 0,
|
||||
end: test.length,
|
||||
value: char + "username:[1234:5678::abcd]",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should properly parse IPs v6 with port as the domain name", () => {
|
||||
const test = char + "username:[1234:5678::abcd]:1337";
|
||||
expect(isLinkable(test)).toEqual(true);
|
||||
const found = findLinksInString(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: char + "username:[1234:5678::abcd]:1337",
|
||||
type,
|
||||
isLink: true,
|
||||
start: 0,
|
||||
end: test.length,
|
||||
value: char + "username:[1234:5678::abcd]:1337",
|
||||
},
|
||||
]);
|
||||
});
|
||||
// eslint-disable-next-line max-len
|
||||
it("should properly parse IPs v6 while ignoring dangling comma when without port name as the domain name", () => {
|
||||
const test = char + "username:[1234:5678::abcd]:";
|
||||
expect(isLinkable(test)).toEqual(true);
|
||||
const found = findLinksInString(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: char + "username:[1234:5678::abcd]:",
|
||||
type,
|
||||
isLink: true,
|
||||
start: 0,
|
||||
end: test.length - 1,
|
||||
value: char + "username:[1234:5678::abcd]:",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
it("properly parses " + char + "_foonetic_xkcd:matrix.org", () => {
|
||||
const test = "" + char + "_foonetic_xkcd:matrix.org";
|
||||
expect(isLinkable(test)).toEqual(true);
|
||||
const found = findLinksInString(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: char + "_foonetic_xkcd:matrix.org",
|
||||
type,
|
||||
value: char + "_foonetic_xkcd:matrix.org",
|
||||
start: 0,
|
||||
end: test.length,
|
||||
isLink: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
it("properly parses " + char + "localhost:foo.com", () => {
|
||||
const test = char + "localhost:foo.com";
|
||||
expect(isLinkable(test)).toEqual(true);
|
||||
const found = findLinksInString(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: char + "localhost:foo.com",
|
||||
type,
|
||||
value: char + "localhost:foo.com",
|
||||
start: 0,
|
||||
end: test.length,
|
||||
isLink: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
it("properly parses " + char + "foo:localhost", () => {
|
||||
const test = char + "foo:localhost";
|
||||
expect(isLinkable(test)).toEqual(true);
|
||||
const found = findLinksInString(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: char + "foo:localhost",
|
||||
type,
|
||||
value: char + "foo:localhost",
|
||||
start: 0,
|
||||
end: test.length,
|
||||
isLink: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
it("accept " + char + "foo:bar.com", () => {
|
||||
const test = "" + char + "foo:bar.com";
|
||||
expect(isLinkable(test)).toEqual(true);
|
||||
const found = findLinksInString(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: char + "foo:bar.com",
|
||||
type,
|
||||
value: char + "foo:bar.com",
|
||||
start: 0,
|
||||
end: test.length,
|
||||
isLink: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
it("accept " + char + "foo:com (mostly for (TLD|DOMAIN)+ mixing)", () => {
|
||||
const test = "" + char + "foo:com";
|
||||
expect(isLinkable(test)).toEqual(true);
|
||||
const found = findLinksInString(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: char + "foo:com",
|
||||
type,
|
||||
value: char + "foo:com",
|
||||
start: 0,
|
||||
end: test.length,
|
||||
isLink: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
it("accept repeated TLDs (e.g .org.uk)", () => {
|
||||
const test = "" + char + "foo:bar.org.uk";
|
||||
expect(isLinkable(test)).toEqual(true);
|
||||
const found = findLinksInString(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: char + "foo:bar.org.uk",
|
||||
type,
|
||||
value: char + "foo:bar.org.uk",
|
||||
start: 0,
|
||||
end: test.length,
|
||||
isLink: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
it("accept hyphens in name " + char + "foo-bar:server.com", () => {
|
||||
const test = "" + char + "foo-bar:server.com";
|
||||
expect(isLinkable(test)).toEqual(true);
|
||||
const found = findLinksInString(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: char + "foo-bar:server.com",
|
||||
type,
|
||||
value: char + "foo-bar:server.com",
|
||||
start: 0,
|
||||
end: test.length,
|
||||
isLink: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
it("ignores trailing `:`", () => {
|
||||
const test = "" + char + "foo:bar.com:";
|
||||
expect(isLinkable(test)).toEqual(false);
|
||||
const found = findLinksInString(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
type,
|
||||
value: char + "foo:bar.com",
|
||||
href: char + "foo:bar.com",
|
||||
start: 0,
|
||||
end: test.length - ":".length,
|
||||
isLink: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
it("accept :NUM (port specifier)", () => {
|
||||
const test = "" + char + "foo:bar.com:2225";
|
||||
expect(isLinkable(test)).toEqual(true);
|
||||
const found = findLinksInString(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: char + "foo:bar.com:2225",
|
||||
type,
|
||||
value: char + "foo:bar.com:2225",
|
||||
start: 0,
|
||||
end: test.length,
|
||||
isLink: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
it("ignores duplicate :NUM (double port specifier)", () => {
|
||||
const test = "" + char + "foo:bar.com:2225:1234";
|
||||
expect(isLinkable(test)).toEqual(false);
|
||||
const found = findLinksInString(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: char + "foo:bar.com:2225",
|
||||
type,
|
||||
value: char + "foo:bar.com:2225",
|
||||
start: 0,
|
||||
end: 17,
|
||||
isLink: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
it("ignores all the trailing :", () => {
|
||||
const test = "" + char + "foo:bar.com::::";
|
||||
expect(isLinkable(test)).toEqual(false);
|
||||
const found = findLinksInString(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: char + "foo:bar.com",
|
||||
type,
|
||||
value: char + "foo:bar.com",
|
||||
end: test.length - 4,
|
||||
start: 0,
|
||||
isLink: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
it("properly parses room alias with dots in name", () => {
|
||||
const test = "" + char + "foo.asdf:bar.com::::";
|
||||
expect(isLinkable(test)).toEqual(false);
|
||||
const found = findLinksInString(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: char + "foo.asdf:bar.com",
|
||||
type,
|
||||
value: char + "foo.asdf:bar.com",
|
||||
start: 0,
|
||||
end: test.length - ":".repeat(4).length,
|
||||
isLink: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
it("does not parse room alias with too many separators", () => {
|
||||
const test = "" + char + "foo:::bar.com";
|
||||
expect(isLinkable(test)).toEqual(false);
|
||||
const found = findLinksInString(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: "http://bar.com",
|
||||
type: "url",
|
||||
value: "bar.com",
|
||||
isLink: true,
|
||||
start: 7,
|
||||
end: test.length,
|
||||
},
|
||||
]);
|
||||
});
|
||||
it("properly parses room alias with hyphen in domain part", () => {
|
||||
const test = "" + char + "foo:bar.com-baz.com";
|
||||
expect(isLinkable(test)).toEqual(true);
|
||||
const found = findLinksInString(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: char + "foo:bar.com-baz.com",
|
||||
type,
|
||||
value: char + "foo:bar.com-baz.com",
|
||||
end: 20,
|
||||
start: 0,
|
||||
isLink: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("userid plugin", () => {
|
||||
it("allows dots in localparts", () => {
|
||||
const test = "@test.:matrix.org";
|
||||
expect(isLinkable(test)).toEqual(true);
|
||||
const found = findLinksInString(test);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: test,
|
||||
type: "userid",
|
||||
value: test,
|
||||
start: 0,
|
||||
end: test.length,
|
||||
|
||||
isLink: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("matrix uri", () => {
|
||||
const acceptedMatrixUris = [
|
||||
"matrix:u/foo_bar:server.uk",
|
||||
"matrix:r/foo-bar:server.uk",
|
||||
"matrix:roomid/somewhere:example.org?via=elsewhere.ca",
|
||||
"matrix:r/somewhere:example.org",
|
||||
"matrix:r/somewhere:example.org/e/event",
|
||||
"matrix:roomid/somewhere:example.org/e/event?via=elsewhere.ca",
|
||||
"matrix:u/alice:example.org?action=chat",
|
||||
];
|
||||
for (const matrixUri of acceptedMatrixUris) {
|
||||
it("accepts " + matrixUri, () => {
|
||||
expect(isLinkable(matrixUri)).toEqual(true);
|
||||
const found = findLinksInString(matrixUri);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: matrixUri,
|
||||
type: LinkifyMatrixOpaqueIdType.URL,
|
||||
value: matrixUri,
|
||||
end: matrixUri.length,
|
||||
start: 0,
|
||||
isLink: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("matrix-prefixed domains", () => {
|
||||
const acceptedDomains = ["matrix.org", "matrix.to", "matrix-help.org", "matrix123.org"];
|
||||
for (const domain of acceptedDomains) {
|
||||
it("accepts " + domain, () => {
|
||||
expect(isLinkable(domain)).toEqual(true);
|
||||
const found = findLinksInString(domain);
|
||||
expect(found).toEqual([
|
||||
{
|
||||
href: `http://${domain}`,
|
||||
type: LinkifyMatrixOpaqueIdType.URL,
|
||||
value: domain,
|
||||
end: domain.length,
|
||||
start: 0,
|
||||
isLink: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
}
|
||||
});
|
||||
describe("linkifyHtml", () => {
|
||||
it("removes any existing data-linkified", () => {
|
||||
expect(
|
||||
linkifyHtml("<span data-linkfied><a data-linkfied href='evil://com'>evil.com</a></span>"),
|
||||
).toMatchInlineSnapshot(
|
||||
`"<span data-linkfied=""><a data-linkfied="" href="evil://com">evil.com</a></span>"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,312 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import * as linkifyjs from "linkifyjs";
|
||||
import { default as linkifyString } from "linkify-string"; // Only exported by this file, but imported for jsdoc.
|
||||
import { default as linkifyHtml } from "linkify-html"; // Only exported by this file, but imported for jsdoc.
|
||||
|
||||
/**
|
||||
* This file describes common linkify configuration settings such as supported protocols.
|
||||
* The instance of "linkifyjs" is the canonical instance that all dependant apps should use.
|
||||
*
|
||||
* Plugins should be configured inside this file exclusively so as to avoid contamination of
|
||||
* the global state.
|
||||
*/
|
||||
|
||||
/**
|
||||
* List of supported protocols natively by linkify. Kept in sync with upstreanm.
|
||||
* @see https://github.com/nfrasser/linkifyjs/blob/main/packages/linkifyjs/src/scanner.mjs#L171-L177
|
||||
*/
|
||||
export const LinkifySupportedProtocols = ["file", "mailto", "http", "https", "ftp", "ftps"];
|
||||
|
||||
/**
|
||||
* Protocols that do not require a slash in the URL.
|
||||
*/
|
||||
export const LinkifyOptionalSlashProtocols = [
|
||||
"bitcoin",
|
||||
"geo",
|
||||
"im",
|
||||
"magnet",
|
||||
"mailto",
|
||||
"matrix",
|
||||
"news",
|
||||
"openpgp4fpr",
|
||||
"sip",
|
||||
"sms",
|
||||
"smsto",
|
||||
"tel",
|
||||
"urn",
|
||||
"xmpp",
|
||||
];
|
||||
|
||||
/**
|
||||
* URL schemes that are safe to be resolved by the app consuming the library.
|
||||
*/
|
||||
export const PERMITTED_URL_SCHEMES = [...LinkifySupportedProtocols, ...LinkifyOptionalSlashProtocols];
|
||||
|
||||
export enum LinkifyMatrixOpaqueIdType {
|
||||
URL = "url",
|
||||
UserId = "userid",
|
||||
RoomAlias = "roomalias",
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin function for linkifyjs to find Matrix Room or User IDs.
|
||||
*
|
||||
* Should be used exclusively by a `registerPlugin` function call.
|
||||
*/
|
||||
function parseOpaqueIdsToMatrixIds({
|
||||
scanner,
|
||||
parser,
|
||||
token,
|
||||
name,
|
||||
}: {
|
||||
scanner: linkifyjs.ScannerInit;
|
||||
parser: linkifyjs.ParserInit;
|
||||
token: "#" | "@";
|
||||
name: LinkifyMatrixOpaqueIdType;
|
||||
}): void {
|
||||
const {
|
||||
DOT,
|
||||
// IPV4 necessity
|
||||
NUM,
|
||||
COLON,
|
||||
SYM,
|
||||
SLASH,
|
||||
EQUALS,
|
||||
HYPHEN,
|
||||
UNDERSCORE,
|
||||
} = scanner.tokens;
|
||||
|
||||
// Contains NUM, WORD, UWORD, EMOJI, TLD, UTLD, SCHEME, SLASH_SCHEME and LOCALHOST plus custom protocols (e.g. "matrix")
|
||||
const { domain } = scanner.tokens.groups;
|
||||
|
||||
// Tokens we need that are not contained in the domain group
|
||||
const additionalLocalpartTokens = [DOT, SYM, SLASH, EQUALS, UNDERSCORE, HYPHEN];
|
||||
const additionalDomainpartTokens = [HYPHEN];
|
||||
|
||||
const matrixToken = linkifyjs.createTokenClass(name, { isLink: true });
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const matrixTokenState = new linkifyjs.State(matrixToken) as any as linkifyjs.State<linkifyjs.MultiToken>; // linkify doesn't appear to type this correctly
|
||||
|
||||
const matrixTokenWithPort = linkifyjs.createTokenClass(name, { isLink: true });
|
||||
const matrixTokenWithPortState = new linkifyjs.State(
|
||||
matrixTokenWithPort,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) as any as linkifyjs.State<linkifyjs.MultiToken>; // linkify doesn't appear to type this correctly
|
||||
|
||||
const initialState = parser.start.tt(token);
|
||||
|
||||
// Localpart
|
||||
const localpartState = new linkifyjs.State<linkifyjs.MultiToken>();
|
||||
initialState.ta(domain, localpartState);
|
||||
initialState.ta(additionalLocalpartTokens, localpartState);
|
||||
localpartState.ta(domain, localpartState);
|
||||
localpartState.ta(additionalLocalpartTokens, localpartState);
|
||||
|
||||
// Domainpart
|
||||
const domainStateDot = localpartState.tt(COLON);
|
||||
domainStateDot.ta(domain, matrixTokenState);
|
||||
domainStateDot.ta(additionalDomainpartTokens, matrixTokenState);
|
||||
matrixTokenState.ta(domain, matrixTokenState);
|
||||
matrixTokenState.ta(additionalDomainpartTokens, matrixTokenState);
|
||||
matrixTokenState.tt(DOT, domainStateDot);
|
||||
|
||||
// Port suffixes
|
||||
matrixTokenState.tt(COLON).tt(NUM, matrixTokenWithPortState);
|
||||
}
|
||||
|
||||
export type LinkEventListener = linkifyjs.EventListeners;
|
||||
|
||||
export interface LinkedTextOptions {
|
||||
/**
|
||||
* Event handlers for URL links.
|
||||
*/
|
||||
urlListener?: (href: string) => LinkEventListener;
|
||||
/**
|
||||
* Event handlers for room alias links.
|
||||
*/
|
||||
roomAliasListener?: (href: string) => LinkEventListener;
|
||||
/**
|
||||
* Event handlers for user ID links.
|
||||
*/
|
||||
userIdListener?: (href: string) => LinkEventListener;
|
||||
/**
|
||||
* Function that can be used to transform the `target` attribute on links, depending on the `href`.
|
||||
*/
|
||||
urlTargetTransformer?: (href: string) => string;
|
||||
/**
|
||||
* Function that can be used to transform the `href` attribute on links, depending on the current href and target type.
|
||||
*/
|
||||
hrefTransformer?: (href: string, target: LinkifyMatrixOpaqueIdType) => string;
|
||||
/**
|
||||
* Function called before all listeners when a link is clicked.
|
||||
*/
|
||||
onLinkClick?: (ev: MouseEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a linkifyjs options object that is reasonably paired down
|
||||
* to just the essentials required for an Element client.
|
||||
*
|
||||
* @return A `linkifyjs` `Opts` object. Used by `linkifyString` and `linkifyHtml
|
||||
* @see {@link linkifyHtml}
|
||||
* @see {@link linkifyString}
|
||||
*/
|
||||
export function generateLinkedTextOptions({
|
||||
urlListener,
|
||||
roomAliasListener,
|
||||
userIdListener,
|
||||
urlTargetTransformer,
|
||||
hrefTransformer,
|
||||
onLinkClick,
|
||||
}: LinkedTextOptions): linkifyjs.Opts {
|
||||
const events = (href: string, type: string): LinkEventListener => {
|
||||
switch (type as LinkifyMatrixOpaqueIdType) {
|
||||
case LinkifyMatrixOpaqueIdType.URL: {
|
||||
if (urlListener) {
|
||||
return urlListener(href);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case LinkifyMatrixOpaqueIdType.UserId:
|
||||
if (userIdListener) {
|
||||
return userIdListener(href);
|
||||
}
|
||||
break;
|
||||
case LinkifyMatrixOpaqueIdType.RoomAlias:
|
||||
if (roomAliasListener) {
|
||||
return roomAliasListener(href);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
|
||||
const attributes = (href: string, type: string): Record<string, unknown> => {
|
||||
const attrs: Record<string, unknown> = {
|
||||
[`data-${LINKIFIED_DATA_ATTRIBUTE}`]: "true",
|
||||
};
|
||||
// linkify-react doesn't respect `events` and needs it mapping to React attributes
|
||||
// so we need to manually add the click handler to the attributes
|
||||
// https://linkify.js.org/docs/linkify-react.html#events
|
||||
const options = events(href, type);
|
||||
if (options?.click) {
|
||||
attrs.onClick = options.click;
|
||||
}
|
||||
if (onLinkClick) {
|
||||
attrs.onClick = (ev: MouseEvent) => {
|
||||
onLinkClick(ev);
|
||||
options?.click?.(ev);
|
||||
};
|
||||
}
|
||||
|
||||
return attrs;
|
||||
};
|
||||
|
||||
return {
|
||||
rel: "noreferrer noopener",
|
||||
ignoreTags: ["a", "pre", "code"],
|
||||
defaultProtocol: "https",
|
||||
events,
|
||||
attributes,
|
||||
target(href, type) {
|
||||
if (type === LinkifyMatrixOpaqueIdType.URL && urlTargetTransformer) {
|
||||
return urlTargetTransformer(href);
|
||||
}
|
||||
return "_blank";
|
||||
},
|
||||
...(hrefTransformer
|
||||
? {
|
||||
formatHref: (href, type) => hrefTransformer(href, type as LinkifyMatrixOpaqueIdType),
|
||||
}
|
||||
: undefined),
|
||||
// By default, ignore Matrix ID types.
|
||||
// Other applications may implement their own version of LinkifyComponent.
|
||||
validate: (_value, type: string) =>
|
||||
!!(type === LinkifyMatrixOpaqueIdType.UserId && userIdListener) ||
|
||||
!!(type === LinkifyMatrixOpaqueIdType.RoomAlias && roomAliasListener) ||
|
||||
type === LinkifyMatrixOpaqueIdType.URL,
|
||||
} satisfies linkifyjs.Opts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds all links in a given string.
|
||||
*
|
||||
* @param str A string that may contain one or more strings.
|
||||
* @returns A set of all links in the string.
|
||||
*/
|
||||
export function findLinksInString(str: string): ReturnType<typeof linkifyjs.find> {
|
||||
return linkifyjs.find(str);
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the provided value something that would be converted to a clickable
|
||||
* link.
|
||||
*
|
||||
* E.g. 'https://matrix.org', `matrix.org` or 'example@matrix.org'
|
||||
*
|
||||
* @param str A string value to be tested if the entire value is linkable.
|
||||
* @returns Whether or not the `str` value is a link.
|
||||
* @see `PERMITTED_URL_SCHEMES` for permitted links.
|
||||
* @see {@link linkifyjs.test}
|
||||
*/
|
||||
export function isLinkable(str: string): boolean {
|
||||
return linkifyjs.test(str);
|
||||
}
|
||||
|
||||
/**
|
||||
* `data-linkified` is applied to all links generated by the linkifaction functions and `<LinkedText>`.
|
||||
*/
|
||||
export const LINKIFIED_DATA_ATTRIBUTE = "linkified";
|
||||
|
||||
export { linkifyString, linkifyHtml };
|
||||
|
||||
// Linkifyjs MUST be configured globally as it has no ability to be instanced seperately
|
||||
// so we ensure it's always configured the same way.
|
||||
let linkifyJSConfigured = false;
|
||||
function configureLinkifyJS(): void {
|
||||
if (linkifyJSConfigured) {
|
||||
return;
|
||||
}
|
||||
// Register plugins
|
||||
linkifyjs.registerPlugin(LinkifyMatrixOpaqueIdType.RoomAlias, ({ scanner, parser }) => {
|
||||
const token = scanner.tokens.POUND as "#";
|
||||
parseOpaqueIdsToMatrixIds({
|
||||
scanner,
|
||||
parser,
|
||||
token,
|
||||
name: LinkifyMatrixOpaqueIdType.RoomAlias,
|
||||
});
|
||||
});
|
||||
|
||||
linkifyjs.registerPlugin(LinkifyMatrixOpaqueIdType.UserId, ({ scanner, parser }) => {
|
||||
const token = scanner.tokens.AT as "@";
|
||||
parseOpaqueIdsToMatrixIds({
|
||||
scanner,
|
||||
parser,
|
||||
token,
|
||||
name: LinkifyMatrixOpaqueIdType.UserId,
|
||||
});
|
||||
});
|
||||
|
||||
// 'mxc' is specialcased. They can be linked to
|
||||
linkifyjs.registerCustomProtocol("mxc", false);
|
||||
|
||||
// Linkify supports some common protocols but not others, register all permitted url schemes if unsupported
|
||||
// https://github.com/nfrasser/linkifyjs/blob/main/packages/linkifyjs/src/scanner.mjs#L171-L177
|
||||
// This also handles registering the `matrix:` protocol scheme
|
||||
PERMITTED_URL_SCHEMES.forEach((scheme) => {
|
||||
if (!LinkifySupportedProtocols.includes(scheme)) {
|
||||
linkifyjs.registerCustomProtocol(scheme, LinkifyOptionalSlashProtocols.includes(scheme));
|
||||
}
|
||||
});
|
||||
linkifyJSConfigured = true;
|
||||
}
|
||||
|
||||
configureLinkifyJS();
|
||||
Reference in New Issue
Block a user