Files
github-stats-extended/packages/core/src/common/render.js
T

443 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @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 `<g transform="${transform}">${item}</g>`;
});
};
/**
* 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 `
<g data-testid="primary-lang">
<circle data-testid="lang-color" cx="0" cy="-5" r="6" fill="${langColor}" />
<text data-testid="lang-name" class="gray" x="15">${langName}</text>
</g>
`;
};
/**
* 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 `
<svg width="${width}" x="${x}" y="${y}">
<rect data-testid="progress-background" rx="5" ry="5" x="0" y="0" width="${width}" height="8" fill="${progressBarBackgroundColor}"></rect>
<svg data-testid="lang-progress" width="${progressPercentage}%">
<rect
height="8"
fill="${color}"
rx="5" ry="5" x="0" y="0"
class="lang-progress"
style="animation-delay: ${delay}ms;"
/>
</svg>
</svg>
`;
};
/**
* 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 `
<foreignObject x="${x}" y="${y}" width="${width}" height="${height}">
<div xmlns="http://www.w3.org/1999/xhtml" class="${className}" style="--lines: ${lineCount};"${testIdAttr}>${encodeHTML(
text,
)}</div>
</foreignObject>
`;
};
/**
* 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 = `
<svg
class="icon"
y="-12"
viewBox="0 0 16 16"
version="1.1"
width="${iconSize}"
height="${iconSize}"
>
${icon}
</svg>
`;
const text = `<text data-testid="${testid}" class="gray">${label}</text>`;
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<typeof getCardColors>[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 `
<svg width="${ERROR_CARD_LENGTH}" height="120" viewBox="0 0 ${ERROR_CARD_LENGTH} 120" fill="${bgColor}" xmlns="http://www.w3.org/2000/svg">
<style>
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${titleColor} }
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor} }
.gray { fill: #858585 }
</style>
<rect x="0.5" y="0.5" width="${
ERROR_CARD_LENGTH - 1
}" height="99%" rx="4.5" fill="${bgColor}" stroke="${borderColor}"/>
<text x="25" y="45" class="text">Something went wrong!${
UPSTREAM_API_ERRORS.includes(secondaryMessage) || !show_repo_link
? ""
: " file an issue at https://tinyurl.com/github-stats"
}</text>
<text data-testid="message" x="25" y="55" class="text small">
<tspan x="25" dy="18">${encodeHTML(message)}</tspan>
<tspan x="25" dy="18" class="gray">${secondaryMessage}</tspan>
</text>
</svg>
`;
};
/**
* 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,
};