diff --git a/frontend/package.json b/frontend/package.json
index bd25da86..38e7f0b7 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -83,6 +83,7 @@
"tailwindcss": "^3.4.1",
"terser-webpack-plugin": "^5.3.10",
"thread-loader": "^4.0.4",
+ "tlds": "^1.259.0",
"ts-loader": "^9.2.6",
"tsconfig-paths-webpack-plugin": "^4.1.0",
"type-fest": "^4.39.1",
diff --git a/frontend/src/components/ui/config-details.ts b/frontend/src/components/ui/config-details.ts
index 599c54b4..3a54daf5 100644
--- a/frontend/src/components/ui/config-details.ts
+++ b/frontend/src/components/ui/config-details.ts
@@ -24,6 +24,7 @@ import { isApiError } from "@/utils/api";
import { DEPTH_SUPPORTED_SCOPES, isPageScopeType } from "@/utils/crawler";
import { humanizeSchedule } from "@/utils/cron";
import { pluralOf } from "@/utils/pluralize";
+import { richText } from "@/utils/rich-text";
import { getServerDefaults } from "@/utils/workflow";
/**
@@ -312,7 +313,7 @@ export class ConfigDetails extends BtrixElement {
crawlConfig?.description
? html`
- ${crawlConfig.description}
+ ${richText(crawlConfig.description)}
`
: undefined,
diff --git a/frontend/src/features/collections/collections-grid.ts b/frontend/src/features/collections/collections-grid.ts
index 0add88e1..ca141fcd 100644
--- a/frontend/src/features/collections/collections-grid.ts
+++ b/frontend/src/features/collections/collections-grid.ts
@@ -14,6 +14,7 @@ import { textSeparator } from "@/layouts/separator";
import { RouteNamespace } from "@/routes";
import { CollectionAccess, type PublicCollection } from "@/types/collection";
import { pluralOf } from "@/utils/pluralize";
+import { richText } from "@/utils/rich-text";
import { tw } from "@/utils/tailwind";
/**
@@ -149,7 +150,7 @@ export class CollectionsGrid extends BtrixElement {
- ${collection.caption}
+ ${richText(collection.caption, { shortenOnly: true })}
`}
diff --git a/frontend/src/pages/collections/collection.ts b/frontend/src/pages/collections/collection.ts
index d5bb9a54..1c6f8db8 100644
--- a/frontend/src/pages/collections/collection.ts
+++ b/frontend/src/pages/collections/collection.ts
@@ -11,6 +11,7 @@ import { page } from "@/layouts/page";
import { RouteNamespace } from "@/routes";
import type { PublicCollection } from "@/types/collection";
import { formatRwpTimestamp } from "@/utils/replay";
+import { richText } from "@/utils/rich-text";
enum Tab {
Replay = "replay",
@@ -116,7 +117,9 @@ export class Collection extends BtrixElement {
if (collection.caption) {
header.secondary = html`
- ${collection.caption}
+
+ ${richText(collection.caption)}
+
`;
}
diff --git a/frontend/src/pages/org/archived-item-detail/archived-item-detail.ts b/frontend/src/pages/org/archived-item-detail/archived-item-detail.ts
index 4083353d..20a17d28 100644
--- a/frontend/src/pages/org/archived-item-detail/archived-item-detail.ts
+++ b/frontend/src/pages/org/archived-item-detail/archived-item-detail.ts
@@ -30,6 +30,7 @@ import {
import { humanizeExecutionSeconds } from "@/utils/executionTimeFormatter";
import { isArchivingDisabled } from "@/utils/orgs";
import { pluralOf } from "@/utils/pluralize";
+import { richText } from "@/utils/rich-text";
import { tw } from "@/utils/tailwind";
import "./ui/qa";
@@ -901,7 +902,7 @@ export class ArchivedItemDetail extends BtrixElement {
this.item!.description?.length,
() =>
html`
-${this.item?.description}
+ ${richText(this.item?.description ?? "")}
`,
() => noneText,
diff --git a/frontend/src/pages/org/browser-profiles-detail.ts b/frontend/src/pages/org/browser-profiles-detail.ts
index 68009361..9a4f092e 100644
--- a/frontend/src/pages/org/browser-profiles-detail.ts
+++ b/frontend/src/pages/org/browser-profiles-detail.ts
@@ -17,6 +17,7 @@ import { isApiError } from "@/utils/api";
import { maxLengthValidator } from "@/utils/form";
import { isArchivingDisabled } from "@/utils/orgs";
import { pluralOf } from "@/utils/pluralize";
+import { richText } from "@/utils/rich-text";
const DESCRIPTION_MAXLENGTH = 500;
@@ -251,12 +252,13 @@ export class BrowserProfilesDetail extends BtrixElement {
${this.profile
- ? this.profile.description ||
- html`
-
- ${msg("No description added.")}
-
- `
+ ? this.profile.description
+ ? richText(this.profile.description)
+ : html`
+
+ ${msg("No description added.")}
+
+ `
: nothing}
diff --git a/frontend/src/pages/org/collection-detail.ts b/frontend/src/pages/org/collection-detail.ts
index 9f2132b6..dc37d61b 100644
--- a/frontend/src/pages/org/collection-detail.ts
+++ b/frontend/src/pages/org/collection-detail.ts
@@ -38,6 +38,7 @@ import type { ArchivedItem, Crawl, Upload } from "@/types/crawler";
import type { CrawlState } from "@/types/crawlState";
import { pluralOf } from "@/utils/pluralize";
import { formatRwpTimestamp } from "@/utils/replay";
+import { richText } from "@/utils/rich-text";
import { tw } from "@/utils/tailwind";
const ABORT_REASON_THROTTLE = "throttled";
@@ -203,7 +204,7 @@ export class CollectionDetail extends BtrixElement {
${this.collection
? this.collection.caption
? html`
- ${this.collection.caption}
+ ${richText(this.collection.caption)}
`
: html` html`
-
${publicDescription}
+
+ ${richText(publicDescription)}
+
`,
)}
${when(this.org?.publicUrl, (urlStr) => {
@@ -158,12 +162,12 @@ export class Dashboard extends BtrixElement {
label=${msg("Website")}
>
- ${url.href.split("//")[1].replace(/\/$/, "")}
+ ${toShortUrl(url.href, null)}
`;
diff --git a/frontend/src/pages/public/org.ts b/frontend/src/pages/public/org.ts
index 2770a89a..7d325f15 100644
--- a/frontend/src/pages/public/org.ts
+++ b/frontend/src/pages/public/org.ts
@@ -11,6 +11,8 @@ import type { APIPaginatedList, APISortQuery } from "@/types/api";
import { CollectionAccess, type Collection } from "@/types/collection";
import type { OrgData, PublicOrgCollections } from "@/types/org";
import { SortDirection } from "@/types/utils";
+import { richText } from "@/utils/rich-text";
+import { toShortUrl } from "@/utils/url-helpers";
@localized()
@customElement("btrix-public-org")
@@ -152,7 +154,9 @@ export class PublicOrg extends BtrixElement {
${when(
org.description,
(description) => html`
- ${description}
+
+ ${richText(description)}
+
`,
)}
${when(org.url, (urlStr) => {
@@ -173,12 +177,12 @@ export class PublicOrg extends BtrixElement {
label=${msg("Website")}
>
- ${url.href.split("//")[1].replace(/\/$/, "")}
+ ${toShortUrl(url.href, null)}
`;
diff --git a/frontend/src/stories/utils/RichText.mdx b/frontend/src/stories/utils/RichText.mdx
new file mode 100644
index 00000000..1f399d6b
--- /dev/null
+++ b/frontend/src/stories/utils/RichText.mdx
@@ -0,0 +1,30 @@
+import {
+ Controls,
+ Primary,
+ Stories,
+ Title,
+} from "@storybook/addon-docs/blocks";
+
+import { Canvas, Meta } from "@storybook/addon-docs/blocks";
+
+import * as RichTextStories from "./RichText.stories";
+
+
+
+
+
+This is a rich text renderer that converts links in plain text into real links,
+in a similar way to the way social media posts often do. Links always open in a
+new tab, and the link detection is generally pretty forgiving.
+
+This should generally be used when displaying descriptions or other
+medium-length user-generated plain text, e.g. org or workflow descriptions.
+
+For longer text, consider using a more complete markdown setup, e.g. a
+Collection’s “About” section.
+
+
+
+
+
+
diff --git a/frontend/src/stories/utils/RichText.stories.ts b/frontend/src/stories/utils/RichText.stories.ts
new file mode 100644
index 00000000..eb8e819d
--- /dev/null
+++ b/frontend/src/stories/utils/RichText.stories.ts
@@ -0,0 +1,134 @@
+import type { Meta, StoryContext, StoryObj } from "@storybook/web-components";
+
+import { renderComponent, type RenderProps } from "./RichText";
+
+import { tw } from "@/utils/tailwind";
+
+const meta = {
+ title: "Utils/Rich Text",
+ render: renderComponent,
+ argTypes: {
+ options: {
+ name: "options",
+ description: "Optional options object, see below for details",
+ },
+ linkClass: {
+ name: "options.linkClass",
+ control: "text",
+ description: "CSS class to apply to links",
+ table: {
+ type: { summary: "string" },
+ defaultValue: {
+ summary:
+ "text-cyan-500 font-medium transition-colors hover:text-cyan-600",
+ },
+ },
+ },
+ maxLength: {
+ name: "options.maxLength",
+ control: {
+ type: "select",
+ },
+ // Hack: Storybook seems to convert null to undefined, so instead I'm using the string "null" and converting it back to null wherever it's used. See other places where this comment appears.
+ // -ESG
+ options: ["null", 5, 10, 15, 20],
+ description: "Maximum length of path portion of URLs",
+ table: {
+ type: { summary: "number | null" },
+ defaultValue: {
+ summary: "15",
+ },
+ },
+ },
+ shortenOnly: {
+ name: "options.shortenOnly",
+ control: {
+ type: "boolean",
+ },
+ description: "Whether to shorten URLs only",
+ table: {
+ type: { summary: "boolean" },
+ defaultValue: {
+ summary: "false",
+ },
+ },
+ },
+ },
+ args: {
+ content:
+ "Rich text example content with a link to https://example.com and a link without a protocol to webrecorder.net here. Long URLs like this one are cut short unless maxLength is overridden: https://webrecorder.net/blog/2025-05-28-create-use-and-automate-actions-with-custom-behaviors-in-browsertrix/#the-story-of-behaviors-in-browsertrix.",
+ },
+ parameters: {
+ docs: {
+ source: {
+ language: "typescript",
+ transform: (
+ code: string,
+ {
+ args: { content, linkClass, maxLength, shortenOnly },
+ }: StoryContext,
+ ) =>
+ `import { richText } from "@/utils/rich-text";
+
+const content = ${JSON.stringify(content)};
+
+// Inside a Lit element, or wherever \`TemplateResult\`s are accepted:
+richText(content${
+ linkClass || maxLength || shortenOnly
+ ? `, { ${[
+ linkClass && `linkClass: ${JSON.stringify(linkClass)}`,
+ // Hack: convert "null" back to null (see above)
+ // -ESG
+ (typeof maxLength === "number" ||
+ (maxLength as unknown as string) === "null") &&
+ `maxLength: ${
+ (maxLength as unknown as string) === "null"
+ ? "null"
+ : JSON.stringify(maxLength)
+ }`,
+ shortenOnly && `shortenOnly: ${JSON.stringify(shortenOnly)}`,
+ ]
+ .filter(Boolean)
+ .join(", ")} }`
+ : ``
+ });
+
+ `,
+ },
+ },
+ },
+} satisfies Meta<
+ RenderProps & {
+ options?: {
+ linkClass?: string;
+ maxLength?: number | null;
+ shortenOnly?: boolean;
+ };
+ }
+>;
+
+export default meta;
+type Story = StoryObj;
+
+export const Basic: Story = {
+ args: {},
+};
+
+export const ShortenOnly: Story = {
+ args: {
+ shortenOnly: true,
+ },
+};
+
+export const MaxLength: Story = {
+ args: {
+ // Hack: use "null" instead of null (see above)
+ maxLength: "null" as unknown as null,
+ },
+};
+
+export const CustomLinkStyles: Story = {
+ args: {
+ linkClass: tw`rounded-md bg-purple-50 px-0.5 py-px italic text-purple-600 ring-1 ring-purple-300 hover:text-purple-800`,
+ },
+};
diff --git a/frontend/src/stories/utils/RichText.ts b/frontend/src/stories/utils/RichText.ts
new file mode 100644
index 00000000..6a2c4204
--- /dev/null
+++ b/frontend/src/stories/utils/RichText.ts
@@ -0,0 +1,24 @@
+import { html } from "lit";
+
+import { richText } from "@/utils/rich-text";
+
+export type RenderProps = {
+ content: string;
+ linkClass?: string;
+ shortenOnly?: boolean;
+ maxLength?: number | null;
+};
+
+export const renderComponent = ({
+ content,
+ linkClass,
+ shortenOnly,
+ maxLength,
+}: RenderProps) => {
+ return html`${richText(content, {
+ linkClass,
+ shortenOnly,
+ // Hack: convert "null" back to null (see note in RichText.stories.ts)
+ maxLength: (maxLength as unknown as string) === "null" ? null : maxLength,
+ })}`;
+};
diff --git a/frontend/src/utils/rich-text.ts b/frontend/src/utils/rich-text.ts
new file mode 100644
index 00000000..d78f6147
--- /dev/null
+++ b/frontend/src/utils/rich-text.ts
@@ -0,0 +1,61 @@
+import { html } from "lit";
+import { guard } from "lit/directives/guard.js";
+
+import { definitelyUrl, detectLinks, toShortUrl } from "./url-helpers";
+
+/**
+ * This is a rich text renderer that converts links in plain text into real links, in a similar way to the way social media posts often do.
+ * Links always open in a new tab, and the link detection is generally pretty forgiving.
+ *
+ * This should generally be used when displaying descriptions or other medium-length user-generated plain text, e.g. org or workflow descriptions.
+ *
+ * For longer text, consider using a more complete markdown setup, e.g. a Collection’s “About” section.
+ *
+ * Options:
+ * - linkClass: The CSS class to apply to the links. Has some useful defaults, but can be overridden if necessary.
+ * - shortenOnly: Whether to only shorten the links, without converting them to real links. Useful when being used inside another link block (e.g. card links)
+ * - maxLength: The maximum length of path portion of the shortened URL. Defaults to 15 characters.
+ */
+export function richText(
+ content: string,
+ options: {
+ linkClass?: string;
+ shortenOnly?: boolean;
+ maxLength?: number | null;
+ } = {},
+) {
+ const {
+ shortenOnly,
+ linkClass = shortenOnly
+ ? "font-medium"
+ : "text-cyan-500 font-medium transition-colors hover:text-cyan-600",
+ maxLength = 15,
+ } = options;
+ const links = detectLinks(content);
+ return guard(
+ [content, linkClass, maxLength, shortenOnly],
+ () =>
+ html`${links.map((segment) => {
+ if (typeof segment === "string") {
+ return segment;
+ } else {
+ const url = definitelyUrl(segment.link);
+ if (!url) {
+ return segment.link;
+ }
+ if (shortenOnly) {
+ return html`${toShortUrl(segment.link, maxLength)}`;
+ }
+ return html`${toShortUrl(segment.link, maxLength)}`;
+ }
+ })}`,
+ );
+}
diff --git a/frontend/src/utils/url-helpers.ts b/frontend/src/utils/url-helpers.ts
new file mode 100644
index 00000000..74ada5f9
--- /dev/null
+++ b/frontend/src/utils/url-helpers.ts
@@ -0,0 +1,115 @@
+// Adapted from https://github.com/bluesky-social/social-app/blob/main/src/lib/strings/url-helpers.ts and https://github.com/bluesky-social/social-app/blob/main/src/lib/strings/rich-text-detection.ts
+
+import TLDs from "tlds";
+
+export function isValidDomain(str: string): boolean {
+ return !!TLDs.find((tld) => {
+ const i = str.lastIndexOf(tld);
+ if (i === -1) {
+ return false;
+ }
+ return str.charAt(i - 1) === "." && i === str.length - tld.length;
+ });
+}
+
+/**
+ * Shortens a URL for use in rich text, etc. Remove protocol, trims "www." from the beginning of hosts, and trims pathname to a max length (configurable)
+ * @param url URL to shorten
+ * @param maxLength Max pathname length. Set to null to disable.
+ */
+export function toShortUrl(url: string, maxLength: number | null = 15): string {
+ try {
+ const urlp = new URL(url);
+ if (urlp.protocol !== "http:" && urlp.protocol !== "https:") {
+ return url;
+ }
+ const path =
+ (urlp.pathname === "/" ? "" : urlp.pathname) + urlp.search + urlp.hash;
+ if (maxLength && path.length > maxLength) {
+ return urlp.host + path.slice(0, maxLength - 2) + "...";
+ }
+ if (urlp.host.startsWith("www.")) {
+ return urlp.host.slice(4) + path;
+ }
+ return urlp.host + path;
+ } catch (e) {
+ return url;
+ }
+}
+
+// passes URL.parse, and has a TLD etc
+export function definitelyUrl(maybeUrl: string) {
+ try {
+ if (maybeUrl.endsWith(".")) return null;
+
+ // Prepend 'https://' if the input doesn't start with a protocol
+ if (!maybeUrl.startsWith("https://") && !maybeUrl.startsWith("http://")) {
+ maybeUrl = "https://" + maybeUrl;
+ }
+
+ const url = new URL(maybeUrl);
+
+ // Extract the hostname and split it into labels
+ const hostname = url.hostname;
+ const labels = hostname.split(".");
+
+ // Ensure there are at least two labels (e.g., 'example' and 'com')
+ if (labels.length < 2) return null;
+
+ const tld = labels[labels.length - 1];
+
+ // Check that the TLD is at least two characters long and contains only letters
+ if (!/^[a-z]{2,}$/i.test(tld)) return null;
+
+ return url.toString();
+ } catch {
+ return null;
+ }
+}
+
+interface DetectedLink {
+ link: string;
+}
+type DetectedLinkable = string | DetectedLink;
+export function detectLinks(text: string): DetectedLinkable[] {
+ const re =
+ /((^|\s|\()@[a-z0-9.-]*)|((^|\s|\()https?:\/\/[\S]+)|((^|\s|\()(?[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*)/gi;
+ const segments = [];
+ let match;
+ let start = 0;
+ while ((match = re.exec(text))) {
+ let matchIndex = match.index;
+ let matchValue = match[0];
+
+ if (match.groups?.domain && !isValidDomain(match.groups.domain)) {
+ continue;
+ }
+
+ if (/\s|\(/.test(matchValue)) {
+ // HACK
+ // skip the starting space
+ // we have to do this because RN doesnt support negative lookaheads
+ // -prf
+ matchIndex++;
+ matchValue = matchValue.slice(1);
+ }
+
+ // strip ending punctuation
+ if (/[.,;!?]$/.test(matchValue)) {
+ matchValue = matchValue.slice(0, -1);
+ }
+ if (/[)]$/.test(matchValue) && !matchValue.includes("(")) {
+ matchValue = matchValue.slice(0, -1);
+ }
+
+ if (start !== matchIndex) {
+ segments.push(text.slice(start, matchIndex));
+ }
+ segments.push({ link: matchValue });
+ start = matchIndex + matchValue.length;
+ }
+ if (start < text.length) {
+ segments.push(text.slice(start));
+ }
+ return segments;
+}
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
index 4232eb4f..17414cab 100644
--- a/frontend/tsconfig.json
+++ b/frontend/tsconfig.json
@@ -2,7 +2,7 @@
"compilerOptions": {
"outDir": "./dist/",
"module": "esnext",
- "target": "es6",
+ "target": "ES2018",
"moduleResolution": "bundler",
"allowJs": true,
"strict": true,
diff --git a/frontend/web-test-runner.config.mjs b/frontend/web-test-runner.config.mjs
index a97a083d..08b10853 100644
--- a/frontend/web-test-runner.config.mjs
+++ b/frontend/web-test-runner.config.mjs
@@ -55,6 +55,7 @@ export default {
}),
esbuildPlugin({
ts: true,
+ json: true,
tsconfig: fileURLToPath(new URL("./tsconfig.json", import.meta.url)),
target: "esnext",
define: defineConfig,
diff --git a/frontend/yarn.lock b/frontend/yarn.lock
index 5d7f100b..7f598638 100644
--- a/frontend/yarn.lock
+++ b/frontend/yarn.lock
@@ -10659,6 +10659,11 @@ tinyspy@^3.0.0:
resolved "https://registry.yarnpkg.com/tinyspy/-/tinyspy-3.0.2.tgz#86dd3cf3d737b15adcf17d7887c84a75201df20a"
integrity sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==
+tlds@^1.259.0:
+ version "1.259.0"
+ resolved "https://registry.yarnpkg.com/tlds/-/tlds-1.259.0.tgz#f7e536e1fab65d7282443399417d317c309da3a3"
+ integrity sha512-AldGGlDP0PNgwppe2quAvuBl18UcjuNtOnDuUkqhd6ipPqrYYBt3aTxK1QTsBVknk97lS2JcafWMghjGWFtunw==
+
tmp@^0.0.33:
version "0.0.33"
resolved "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz"