@revenuecat/purchases-ui-js
Version:
Web components for Paywalls. Powered by RevenueCat
135 lines (134 loc) • 5.84 kB
JavaScript
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 = {
"&": "&",
"<": "<",
">": ">",
'"': """,
"'": "'",
};
/**
* 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);
}