UNPKG

@revenuecat/purchases-ui-js

Version:

Web components for Paywalls. Powered by RevenueCat

135 lines (134 loc) 5.84 kB
import { FontSizes, FontWeights, TextAlignments, } from "../../types"; import { mapBackground } from "../../utils/background-utils"; import { css, mapColorInfo, mapColorMode, mapSize, mapSpacing, } from "../../utils/base-utils"; import { DEFAULT_TEXT_COLOR } from "../../utils/constants"; import { getScopedFontFamily, isFontRCFMManaged } from "../../utils/font-utils"; export const defaultColor = { light: { type: "hex", value: DEFAULT_TEXT_COLOR }, }; export function mapTextColor(colorMode, scheme) { const info = mapColorMode(colorMode, scheme); const color = mapColorInfo(info); switch (info.type) { case "alias": case "hex": return { color }; case "linear": case "radial": return { color: "transparent", background: color, "-webkit-background-clip": "text", "background-clip": "text", }; } } /** * Generates comprehensive styles for text components by combining text, component and size styles * @param props - Text component properties including font, color, background, spacing etc. * @returns Object containing text inline styles and the appropriate HTML tag to render */ export const getTextComponentStyles = (colorMode, props, fonts) => { const { font_name, font_size, font_weight, font_weight_int, horizontal_alignment, color = defaultColor, padding, margin, size, } = props; const font = fonts[font_name ?? ""]; const fontFamily = font?.web?.family; return css({ display: "block", width: mapSize(size.width), height: mapSize(size.height), margin: mapSpacing(margin), padding: mapSpacing(padding), ...mapTextColor(colorMode, color), "text-align": TextAlignments[horizontal_alignment] || TextAlignments.leading, "font-weight": font_weight_int ?? FontWeights[font_weight] ?? FontWeights.regular, "font-size": Number.isInteger(Number(font_size)) ? `${font_size}px` : FontSizes[font_size] || FontSizes.body_m, "font-family": isFontRCFMManaged(font_name ?? "") ? getScopedFontFamily(fontFamily ?? "") : "sans-serif", }); }; export function getTextWrapperInlineStyles(colorMode, _restProps, size, background_color) { return css({ display: "block", position: "relative", width: mapSize(size.width), height: mapSize(size.height), ...mapBackground(colorMode, background_color, null), }); } const HTML_ESCAPE_MAP = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;", }; /** * Escapes the HTML-special characters in `text` so that user-controlled * localization values cannot inject markup. None of the markdown control * characters handled below are HTML-special, so escaping first is safe. */ function escapeHtml(text) { return text.replace(/[&<>"']/g, (character) => HTML_ESCAPE_MAP[character]); } const SAFE_URL_SCHEMES = ["http", "https", "mailto", "tel"]; /** * Returns the URL if it is safe to use as a link `href`, otherwise `null`. * Relative and protocol-relative URLs are allowed; absolute URLs are only * allowed for an explicit scheme allowlist. Whitespace and control characters * (code points <= 0x20) are stripped and the scheme is lowercased before * comparison so obfuscated payloads such as "jAvA\tscript:" are still rejected. */ function sanitizeUrl(url) { const normalized = Array.from(url) .filter((character) => character.charCodeAt(0) > 0x20) .join("") .toLowerCase(); const schemeMatch = normalized.match(/^([a-z][a-z0-9+.-]*):/); if (schemeMatch && !SAFE_URL_SCHEMES.includes(schemeMatch[1])) { return null; } return url; } export function getHtmlFromMarkdown(text) { if (!text) return ""; // Escape HTML first so attacker-controlled markup (e.g. `<img onerror=...>`) // can never reach the `{@html}` sink. After this, the only HTML in the output // is the fixed tag allowlist produced by the rules below. const escapedHtml = escapeHtml(text); const escapedMarkdownCharacters = []; const textWithEscapedMarkdownPlaceholders = escapedHtml.replaceAll(/\\([\\`*_[\]{}()#+\-.!~])/g, (_, escapedCharacter) => { escapedMarkdownCharacters.push(escapedCharacter); return `\0RC_ESCAPED_MARKDOWN_${escapedMarkdownCharacters.length - 1}\0`; }); const regexpDictionary = { newLine: { regexp: /\\\n/g, output: "<br/>" }, bold: { regexp: /\*\*(.*?)\*\*/g, output: "<b>$1</b>" }, italic: { regexp: /\*(.*?)\*/g, output: "<i>$1</i>" }, strikethrough: { regexp: /~(.*?)~/g, output: "<s>$1</s>" }, code: { regexp: /`(.*?)`/g, output: "<span style='font-family: monospace'>$1</span>", }, link: { regexp: /\[(.*?)\]\((.*?)\)/g, output: (_match, label, url) => { const safeUrl = sanitizeUrl(url); if (safeUrl === null) { // Drop the unsafe href and render the (already escaped) label only. return label; } return `<a href="${safeUrl}" target='_blank' rel='noopener noreferrer'>${label}</a>`; }, }, }; const parsedText = Object.values(regexpDictionary).reduce((parsedText, { regexp, output }) => { return typeof output === "string" ? parsedText.replaceAll(regexp, output) : parsedText.replaceAll(regexp, output); }, textWithEscapedMarkdownPlaceholders); return escapedMarkdownCharacters.reduce((restoredText, escapedMarkdownCharacter, index) => restoredText.replaceAll(`\0RC_ESCAPED_MARKDOWN_${index}\0`, escapedMarkdownCharacter), parsedText); }