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:
Will Hunt
2026-03-12 15:54:01 +00:00
committed by GitHub
parent d38eb4fdb4
commit c02db4ebb8
44 changed files with 1508 additions and 839 deletions
@@ -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;
}
@@ -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();