// @ts-check import { getCardColors } from "./color.js"; import { SECONDARY_ERROR_MESSAGES, TRY_AGAIN_LATER } from "./error.js"; import { encodeHTML } from "./html.js"; import { clampValue } from "./ops.js"; /** * Auto layout utility, allows us to layout things vertically or horizontally with * proper gaping. * * @param {object} props Function properties. * @param {string[]} props.items Array of items to layout. * @param {number} props.gap Gap between items. * @param {"column" | "row"=} props.direction Direction to layout items. * @param {number[]=} props.sizes Array of sizes for each item. * @returns {string[]} Array of items with proper layout. */ const flexLayout = ({ items, gap, direction, sizes = [] }) => { let lastSize = 0; // filter() for filtering out empty strings return items.filter(Boolean).map((item, i) => { const size = sizes[i] || 0; let transform = `translate(${lastSize}, 0)`; if (direction === "column") { transform = `translate(0, ${lastSize})`; } lastSize += size + gap; return `${item}`; }); }; /** * Creates a node to display the primary programming language of the repository/gist. * * @param {string} langName Language name. * @param {string} langColor Language color. * @returns {string} Language display SVG object. */ const createLanguageNode = (langName, langColor) => { return ` ${langName} `; }; /** * Create a node to indicate progress in percentage along a horizontal line. * * @param {Object} params Object that contains the createProgressNode parameters. * @param {number} params.x X-axis position. * @param {number} params.y Y-axis position. * @param {number} params.width Width of progress bar. * @param {string} params.color Progress color. * @param {number} params.progress Progress value. * @param {string} params.progressBarBackgroundColor Progress bar bg color. * @param {number} params.delay Delay before animation starts. * @returns {string} Progress node. */ const createProgressNode = ({ x, y, width, color, progress, progressBarBackgroundColor, delay, }) => { const progressPercentage = clampValue(progress, 2, 100); return ` `; }; /** * Renders multi-line text via a `foreignObject` so the browser performs * native, font-aware wrapping. Content overflowing `lineCount` lines is * clipped (with an ellipsis on the last visible line) by CSS line-clamp. * * @param {object} props Function properties. * @param {string} props.text Text to render (will be HTML-encoded). * @param {number} props.x X position of the foreignObject. * @param {number} props.y Y position of the foreignObject. * @param {number} props.width Width of the wrap box. * @param {number} props.height Height of the wrap box. * @param {number} props.lineCount Maximum number of lines to display. * @param {string} props.className CSS class applied to the inner element. * @param {string=} props.testId Optional test id for the inner element. * @returns {string} foreignObject SVG node. */ const wrappedTextNode = ({ text, x, y, width, height, lineCount, className, testId, }) => { const testIdAttr = testId ? ` data-testid="${testId}"` : ""; return `
${encodeHTML( text, )}
`; }; /** * CSS rules used to render multi-line text inside a `foreignObject`. Apply this * to a CSS class (e.g. `.description`) shared with `wrappedTextNode` so the * browser handles wrapping and the line count is taken from the `--lines` * custom property set on the element. * * @param {string} color Text color (CSS `color` property). * @returns {string} CSS rules block (without the surrounding selector). */ const wrappedTextStyles = (color) => ` color: ${color}; margin: 0; line-height: 1.2; overflow-wrap: anywhere; word-break: break-word; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: var(--lines); line-clamp: var(--lines); overflow: hidden; text-overflow: ellipsis; padding-bottom: 0.15em; `; /** * Creates an icon with label to display repository/gist stats like forks, stars, etc. * * @param {string} icon The icon to display. * @param {number|string} label The label to display. * @param {string} testid The testid to assign to the label. * @param {number} iconSize The size of the icon. * @returns {string} Icon with label SVG object. */ const iconWithLabel = (icon, label, testid, iconSize) => { if (typeof label === "number" && label <= 0) { return ""; } const iconSvg = ` ${icon} `; const text = `${label}`; return flexLayout({ items: [iconSvg, text], gap: 20 }).join(""); }; // Script parameters. const ERROR_CARD_LENGTH = 576.5; const UPSTREAM_API_ERRORS = [ TRY_AGAIN_LATER, SECONDARY_ERROR_MESSAGES.MAX_RETRY, ]; /** * Renders error message on the card. * * @param {object} args Function arguments. * @param {string} args.message Main error message. * @param {string} [args.secondaryMessage=""] The secondary error message. * @param {object} [args.renderOptions={}] Render options. * @param {string=} args.renderOptions.title_color Card title color. * @param {string=} args.renderOptions.text_color Card text color. * @param {string=} args.renderOptions.bg_color Card background color. * @param {string=} args.renderOptions.border_color Card border color. * @param {Parameters[0]["theme"]=} args.renderOptions.theme Card theme. * @param {boolean=} args.renderOptions.show_repo_link Whether to show repo link or not. * @returns {string} The SVG markup. */ const renderError = ({ message, secondaryMessage = "", renderOptions = {}, }) => { const { title_color, text_color, bg_color, border_color, theme = "default", show_repo_link = true, } = renderOptions; // returns theme based colors with proper overrides and defaults const { titleColor, textColor, bgColor, borderColor } = getCardColors({ title_color, text_color, icon_color: "", bg_color, border_color, ring_color: "", theme, }); return ` Something went wrong!${ UPSTREAM_API_ERRORS.includes(secondaryMessage) || !show_repo_link ? "" : " file an issue at https://tinyurl.com/github-stats" } ${encodeHTML(message)} ${secondaryMessage} `; }; /** * Retrieve text length based on Segoe UI font. * * @see https://stackoverflow.com/a/48172630/10629172 * @param {string} str String to measure. * @param {number} fontSize Font size. * @returns {number} Text length. */ const measureText = (str, fontSize = 10) => { // prettier-ignore const widths = [ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0.2733333110809326, 0.28499999046325686, 0.39166667461395266, 0.5900000095367431, 0.5383333206176758, 0.8183333396911621, 0.8, 0.22999999523162842, 0.30166666507720946, 0.30166666507720946, 0.41666665077209475, 0.6833333492279052, 0.21666667461395264, 0.4, 0.21666667461395264, 0.3900000095367432, 0.5383333206176758, 0.5383333206176758, 0.5383333206176758, 0.5383333206176758, 0.5383333206176758, 0.5383333206176758, 0.5383333206176758, 0.5383333206176758, 0.5383333206176758, 0.5383333206176758, 0.21666667461395264, 0.21666667461395264, 0.6833333492279052, 0.6833333492279052, 0.6833333492279052, 0.4483333110809326, 0.9550000190734863, 0.6449999809265137, 0.5733333110809327, 0.6183333396911621, 0.7016666889190674, 0.5066666603088379, 0.48833332061767576, 0.6866666793823242, 0.7099999904632568, 0.26666667461395266, 0.35666666030883787, 0.5800000190734863, 0.4699999809265137, 0.8983333587646485, 0.7483333110809326, 0.753333330154419, 0.5599999904632569, 0.753333330154419, 0.5983333110809326, 0.5316666603088379, 0.5233333110809326, 0.6866666793823242, 0.621666669845581, 0.9333333015441895, 0.5900000095367431, 0.553333330154419, 0.5699999809265137, 0.30166666507720946, 0.37833333015441895, 0.30166666507720946, 0.6833333492279052, 0.41500000953674315, 0.26833333969116213, 0.5083333492279053, 0.5883333206176757, 0.4616666793823242, 0.5883333206176757, 0.5233333110809326, 0.3133333444595337, 0.5883333206176757, 0.5666666507720948, 0.24166667461395264, 0.24166667461395264, 0.49666666984558105, 0.24166667461395264, 0.8616666793823242, 0.5666666507720948, 0.5866666793823242, 0.5883333206176757, 0.5883333206176757, 0.3483333349227905, 0.425, 0.33833334445953367, 0.5666666507720948, 0.4783333301544189, 0.7233333110809326, 0.45833334922790525, 0.4833333492279053, 0.45166668891906736, 0.30166666507720946, 0.24000000953674316, 0.30166666507720946, 0.6833333492279052, ]; const avg = 0.5131403493881227; // CJK character range: U+3000–U+9FFF (CJK Symbols/Punctuation, Hiragana, // Katakana, CJK Unified Ideographs incl. Extension A) plus U+FF00–U+FFEF // (Halfwidth/Fullwidth Forms — fullwidth ASCII, fullwidth punctuation). const cjkRange = /[\u3000-\u9FFF\uFF00-\uFFEF]/; return ( str .split("") .map((c) => { if (cjkRange.test(c) || c === "\u3000") { // CJK glyphs and U+3000 IDEOGRAPHIC SPACE are full-width by default; return 1; } if (c.charCodeAt(0) < widths.length) { return widths[c.charCodeAt(0)]; } else { return avg; } }) .reduce((cur, acc) => acc + cur) * fontSize ); }; /** * Split text into the lines it would wrap to when laid out greedily at the * given font size inside a box of width `maxWidth`. Uses `measureText` so the * estimate reflects actual font metrics rather than a fixed character count. * The browser still does the real wrap inside the foreignObject; this is only * used to size the SVG. * * @param {string} text Text to split. * @param {number} fontSize Font size in px (matches `measureText`). * @param {number} maxWidth Available wrap width in px. * @returns {string[]} Estimated wrapped lines. */ const splitWrappedText = (text, fontSize, maxWidth) => { if (!text) { return []; } // Tokenize the text into atoms representing line-break opportunities: // - ASCII whitespace runs (collapsed/dropped at line edges per CSS rules); // - non-ASCII whitespaces // - a single CJK codepoint (browsers break between any two CJK chars, // punctuation or not, so each one is its own atom and ~1 em wide); // - a run of non-whitespace non-CJK characters (a "word" that can only // break at its boundaries, like Latin script). // Korean Hangul (U+AC00–U+D7AF) is intentionally NOT in the CJK range // because Korean wraps at word boundaries by default in HTML. // ASCII whitespace is collapsed to a single space per CSS `white-space: normal;` text = text.replace(/[\t\n\r ]+/g, " "); const tokens = text.match( /\s|[\u3000-\u9FFF\uFF00-\uFFEF]|[^\s\u3000-\u9FFF\uFF00-\uFFEF]+/g, ); if (!tokens) { return []; } const takeFittingSegment = (token, availableWidth) => { const characters = token.split(""); let segment = ""; let width = 0; for (const character of characters) { const characterWidth = measureText(character, fontSize); if (segment && width + characterWidth > availableWidth) { break; } segment += character; width += characterWidth; } return { segment, width, }; }; const lines = [""]; let currentWidth = 0; for (const token of tokens) { if (token === " ") { // Whitespace at the start of a line is dropped by browsers. if (currentWidth === 0) { continue; } lines[lines.length - 1] += token; currentWidth += measureText(token, fontSize); continue; } let remaining = token; while (remaining) { const w = measureText(remaining, fontSize); if (currentWidth + w <= maxWidth) { lines[lines.length - 1] += remaining; currentWidth += w; break; } if (currentWidth > 0) { lines.push(""); currentWidth = 0; continue; } // An atom wider than the box wraps mid-glyph (overflow-wrap: anywhere). const { segment, width } = takeFittingSegment(remaining, maxWidth); lines[lines.length - 1] += segment; currentWidth = width; remaining = remaining.slice(segment.length); } } return lines.map((line) => line.replace(/[\t\n\r ]+$/, "")); }; /** * Estimate how many lines a string will wrap to when laid out greedily at the * given font size inside a box of width `maxWidth`, capped at `maxLines`. * * @param {string} text Text to estimate. * @param {number} fontSize Font size in px (matches `measureText`). * @param {number} maxWidth Available wrap width in px. * @param {number} maxLines Cap on the returned line count. * @returns {number} Estimated line count, at least 1, at most `maxLines`. */ const countWrappedLines = (text, fontSize, maxWidth, maxLines) => { return Math.min( Math.max(1, splitWrappedText(text, fontSize, maxWidth).length), maxLines, ); }; export { renderError, createLanguageNode, createProgressNode, iconWithLabel, flexLayout, measureText, splitWrappedText, countWrappedLines, wrappedTextNode, wrappedTextStyles, };