@ledgerhq/live-common
Version:
Common ground for the Ledger Live apps
159 lines (134 loc) • 3.58 kB
text/typescript
import { useMemo } from "react";
export type HtmlLinkSegment =
| {
type: "text";
content: string;
}
| {
type: "link";
label: string;
href: string;
};
const ANCHOR_OPEN_REGEX = /<a\b[^>]*>/gi;
const HREF_ATTR_REGEX = /href\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s'">]+))/i;
const CLOSING_ANCHOR = "</a>";
export const splitHtmlLinkSegments = (input: string): HtmlLinkSegment[] => {
const segments: HtmlLinkSegment[] = [];
if (!input) {
return segments;
}
const lowerInput = input.toLowerCase();
let cursor = 0;
ANCHOR_OPEN_REGEX.lastIndex = 0;
const extractHrefValue = (tag: string): string | null => {
const match = HREF_ATTR_REGEX.exec(tag);
if (!match) return null;
return match[1] ?? match[2] ?? match[3] ?? null;
};
let match: RegExpExecArray | null;
while ((match = ANCHOR_OPEN_REGEX.exec(input)) !== null) {
const startIndex = match.index;
const tag = match[0];
const contentStartIndex = startIndex + tag.length;
const closingIndex = lowerInput.indexOf(CLOSING_ANCHOR, contentStartIndex);
if (closingIndex === -1) {
break;
}
if (startIndex > cursor) {
const textBefore = input.slice(cursor, startIndex);
if (textBefore) {
segments.push({
type: "text",
content: textBefore,
});
}
}
const href = extractHrefValue(tag);
if (href) {
const label = input.slice(contentStartIndex, closingIndex);
segments.push({
type: "link",
href,
label,
});
} else {
const fallbackText = input.slice(startIndex, closingIndex + CLOSING_ANCHOR.length);
segments.push({
type: "text",
content: fallbackText,
});
}
cursor = closingIndex + CLOSING_ANCHOR.length;
ANCHOR_OPEN_REGEX.lastIndex = cursor;
}
if (cursor < input.length) {
const textAfter = input.slice(cursor);
if (textAfter) {
segments.push({
type: "text",
content: textAfter,
});
}
}
return segments;
};
export const validateLedgerUrl = (
href: string,
): { isHttp: boolean; isAllowedLedgerDomain: boolean } => {
try {
const url = new URL(href);
const isHttp = url.protocol === "http:" || url.protocol === "https:";
if (!isHttp) {
return {
isHttp: false,
isAllowedLedgerDomain: false,
};
}
const hostname = url.hostname.toLowerCase();
const isAllowedLedgerDomain = hostname === "ledger.com" || hostname.endsWith(".ledger.com");
return {
isHttp,
isAllowedLedgerDomain,
};
} catch {
return {
isHttp: false,
isAllowedLedgerDomain: false,
};
}
};
export const buildHtmlDisplaySegments = (input: string): HtmlLinkSegment[] => {
return splitHtmlLinkSegments(input).map(segment => {
if (segment.type === "link") {
const { isHttp, isAllowedLedgerDomain } = validateLedgerUrl(segment.href);
if (isHttp && isAllowedLedgerDomain) {
return segment;
}
return {
type: "text",
content: segment.label,
};
}
return segment;
});
};
export const useHtmlLinkSegments = (
html?: string | null,
): {
segments: HtmlLinkSegment[];
hasLinks: boolean;
} => {
return useMemo(() => {
if (!html) {
return {
segments: [],
hasLinks: false,
};
}
const segments = buildHtmlDisplaySegments(html);
return {
segments,
hasLinks: segments.some(segment => segment.type === "link"),
};
}, [html]);
};