+ ${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;
+`;
+
/**
* Creates an icon with label to display repository/gist stats like forks, stars, etc.
*
@@ -228,6 +287,101 @@ const measureText = (str, fontSize = 10) => {
);
};
+/**
+ * Estimate how many lines a string will 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 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) => {
+ if (!text) {
+ return 1;
+ }
+
+ // Tokenize the text into atoms representing line-break opportunities:
+ // - whitespace runs (collapsed/dropped at line edges per CSS rules);
+ // - 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.
+ // 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]/;
+ const tokens = text.match(
+ /\s+|[\u3000-\u9FFF\uFF00-\uFFEF]|[^\s\u3000-\u9FFF\uFF00-\uFFEF]+/g,
+ );
+ if (!tokens) {
+ return 1;
+ }
+
+ const whitespaceWidth = (run) => {
+ const collapsed = run.replace(/[\t\n\r ]+/g, " ");
+ let width = 0;
+ for (const ch of collapsed) {
+ // U+3000 IDEOGRAPHIC SPACE is one em wide; measureText's ASCII-only
+ // table would otherwise fall back to the average and under-estimate it.
+ width += ch === "\u3000" ? fontSize : measureText(ch, fontSize);
+ }
+ return width;
+ };
+
+ const atomWidth = (atom) => {
+ // CJK glyphs are full-width by default; measureText's ASCII-only table
+ // would otherwise fall back to the average and under-estimate them.
+ if (atom.length === 1 && cjkRange.test(atom)) {
+ return fontSize;
+ }
+ return measureText(atom, fontSize);
+ };
+
+ let lines = 1;
+ let currentWidth = 0;
+
+ for (const token of tokens) {
+ if (/^\s+$/.test(token)) {
+ // Whitespace at the start of a line is dropped by browsers.
+ if (currentWidth === 0) {
+ continue;
+ }
+ currentWidth += whitespaceWidth(token);
+ continue;
+ }
+
+ const w = atomWidth(token);
+
+ if (currentWidth === 0) {
+ currentWidth = w;
+ } else if (currentWidth + w <= maxWidth) {
+ currentWidth += w;
+ } else {
+ lines += 1;
+ currentWidth = w;
+ }
+
+ // An atom wider than the box wraps mid-glyph (overflow-wrap: anywhere).
+ while (currentWidth > maxWidth) {
+ lines += 1;
+ currentWidth -= maxWidth;
+ }
+
+ if (lines >= maxLines) {
+ return maxLines;
+ }
+ }
+
+ return Math.min(lines, maxLines);
+};
+
export {
renderError,
createLanguageNode,
@@ -235,4 +389,7 @@ export {
iconWithLabel,
flexLayout,
measureText,
+ countWrappedLines,
+ wrappedTextNode,
+ wrappedTextStyles,
};
diff --git a/packages/core/tests/renderGistCard.test.js b/packages/core/tests/renderGistCard.test.js
index fe06b4b82f4e8..27b9f6c99869c 100644
--- a/packages/core/tests/renderGistCard.test.js
+++ b/packages/core/tests/renderGistCard.test.js
@@ -54,19 +54,21 @@ describe("test renderGistCard", () => {
expect(header).toHaveTextContent("some-really-long-repo-name-for-test...");
});
- it("should trim description if description os too long", () => {
+ it("should clamp long descriptions to the configured line count", () => {
document.body.innerHTML = renderGistCard({
...data,
description:
"The quick brown fox jumps over the lazy dog is an English-language pangram—a sentence that contains all of the letters of the English alphabet",
});
+ // The full description stays in the DOM; the CSS line-clamp on the
+ // foreignObject's inner div is what visually truncates the overflow.
+ const description = document.getElementsByClassName("description")[0];
+ expect(description).toHaveTextContent(
+ "The quick brown fox jumps over the lazy dog is an English-language pangram—a sentence that contains all of the letters of the English alphabet",
+ );
expect(
- document.getElementsByClassName("description")[0].children[0].textContent,
- ).toBe("The quick brown fox jumps over the lazy dog is an");
-
- expect(
- document.getElementsByClassName("description")[0].children[1].textContent,
- ).toBe("English-language pangram—a sentence that contains all");
+ Number(description.style.getPropertyValue("--lines")),
+ ).toBeGreaterThan(0);
});
it("should not trim description if it is short", () => {
@@ -109,7 +111,7 @@ describe("test renderGistCard", () => {
const iconClassStyles = stylesObject[":host"][".icon "];
expect(headerClassStyles.fill.trim()).toBe(`#${customColors.title_color}`);
- expect(descClassStyles.fill.trim()).toBe(`#${customColors.text_color}`);
+ expect(descClassStyles.color.trim()).toBe(`#${customColors.text_color}`);
expect(iconClassStyles.fill.trim()).toBe(`#${customColors.icon_color}`);
expect(queryByTestId(document.body, "card-bg")).toHaveAttribute(
"fill",
@@ -133,7 +135,7 @@ describe("test renderGistCard", () => {
expect(headerClassStyles.fill.trim()).toBe(
`#${themes[name].title_color}`,
);
- expect(descClassStyles.fill.trim()).toBe(`#${themes[name].text_color}`);
+ expect(descClassStyles.color.trim()).toBe(`#${themes[name].text_color}`);
expect(iconClassStyles.fill.trim()).toBe(`#${themes[name].icon_color}`);
const backgroundElement = queryByTestId(document.body, "card-bg");
const backgroundElementFill = backgroundElement.getAttribute("fill");
@@ -157,7 +159,7 @@ describe("test renderGistCard", () => {
const iconClassStyles = stylesObject[":host"][".icon "];
expect(headerClassStyles.fill.trim()).toBe("#5a0");
- expect(descClassStyles.fill.trim()).toBe(`#${themes.radical.text_color}`);
+ expect(descClassStyles.color.trim()).toBe(`#${themes.radical.text_color}`);
expect(iconClassStyles.fill.trim()).toBe(`#${themes.radical.icon_color}`);
expect(queryByTestId(document.body, "card-bg")).toHaveAttribute(
"fill",
@@ -182,7 +184,7 @@ describe("test renderGistCard", () => {
expect(headerClassStyles.fill.trim()).toBe(
`#${themes.default.title_color}`,
);
- expect(descClassStyles.fill.trim()).toBe(`#${themes.default.text_color}`);
+ expect(descClassStyles.color.trim()).toBe(`#${themes.default.text_color}`);
expect(iconClassStyles.fill.trim()).toBe(`#${themes.radical.icon_color}`);
expect(queryByTestId(document.body, "card-bg")).toHaveAttribute(
"fill",
diff --git a/packages/core/tests/renderRepoCard.test.js b/packages/core/tests/renderRepoCard.test.js
index f14f7998863d8..fdea924937dcd 100644
--- a/packages/core/tests/renderRepoCard.test.js
+++ b/packages/core/tests/renderRepoCard.test.js
@@ -62,22 +62,23 @@ describe("Test renderRepoCard", () => {
);
});
- it("should trim description", () => {
+ it("should clamp long descriptions to descriptionLinesCount lines", () => {
document.body.innerHTML = renderRepoCard({
...data_repo.repository,
description:
"The quick brown fox jumps over the lazy dog is an English-language pangram—a sentence that contains all of the letters of the English alphabet",
});
- expect(
- document.getElementsByClassName("description")[0].children[0].textContent,
- ).toBe("The quick brown fox jumps over the lazy dog is an");
-
- expect(
- document.getElementsByClassName("description")[0].children[1].textContent,
- ).toBe("English-language pangram—a sentence that contains all");
+ // Browser-side wrapping inside the foreignObject keeps the full text in
+ // the DOM; the CSS line-clamp truncates whatever exceeds the line budget
+ // at render time.
+ const description = document.getElementsByClassName("description")[0];
+ expect(description).toHaveTextContent(
+ "The quick brown fox jumps over the lazy dog is an English-language pangram—a sentence that contains all of the letters of the English alphabet",
+ );
+ expect(description.style.getPropertyValue("--lines")).toBe("3");
- // Should not trim
+ // Short descriptions should leave the full text visible without clamping.
document.body.innerHTML = renderRepoCard({
...data_repo.repository,
description: "Small text should not trim",
@@ -135,7 +136,7 @@ describe("Test renderRepoCard", () => {
const iconClassStyles = stylesObject[":host"][".icon "];
expect(headerClassStyles.fill.trim()).toBe("#2f80ed");
- expect(descClassStyles.fill.trim()).toBe("#434d58");
+ expect(descClassStyles.color.trim()).toBe("#434d58");
expect(iconClassStyles.fill.trim()).toBe("#586069");
expect(queryByTestId(document.body, "card-bg")).toHaveAttribute(
"fill",
@@ -163,7 +164,7 @@ describe("Test renderRepoCard", () => {
const iconClassStyles = stylesObject[":host"][".icon "];
expect(headerClassStyles.fill.trim()).toBe(`#${customColors.title_color}`);
- expect(descClassStyles.fill.trim()).toBe(`#${customColors.text_color}`);
+ expect(descClassStyles.color.trim()).toBe(`#${customColors.text_color}`);
expect(iconClassStyles.fill.trim()).toBe(`#${customColors.icon_color}`);
expect(queryByTestId(document.body, "card-bg")).toHaveAttribute(
"fill",
@@ -187,7 +188,7 @@ describe("Test renderRepoCard", () => {
expect(headerClassStyles.fill.trim()).toBe(
`#${themes[name].title_color}`,
);
- expect(descClassStyles.fill.trim()).toBe(`#${themes[name].text_color}`);
+ expect(descClassStyles.color.trim()).toBe(`#${themes[name].text_color}`);
expect(iconClassStyles.fill.trim()).toBe(`#${themes[name].icon_color}`);
const backgroundElement = queryByTestId(document.body, "card-bg");
const backgroundElementFill = backgroundElement.getAttribute("fill");
@@ -211,7 +212,7 @@ describe("Test renderRepoCard", () => {
const iconClassStyles = stylesObject[":host"][".icon "];
expect(headerClassStyles.fill.trim()).toBe("#5a0");
- expect(descClassStyles.fill.trim()).toBe(`#${themes.radical.text_color}`);
+ expect(descClassStyles.color.trim()).toBe(`#${themes.radical.text_color}`);
expect(iconClassStyles.fill.trim()).toBe(`#${themes.radical.icon_color}`);
expect(queryByTestId(document.body, "card-bg")).toHaveAttribute(
"fill",
@@ -236,7 +237,7 @@ describe("Test renderRepoCard", () => {
expect(headerClassStyles.fill.trim()).toBe(
`#${themes.default.title_color}`,
);
- expect(descClassStyles.fill.trim()).toBe(`#${themes.default.text_color}`);
+ expect(descClassStyles.color.trim()).toBe(`#${themes.default.text_color}`);
expect(iconClassStyles.fill.trim()).toBe(`#${themes.radical.icon_color}`);
expect(queryByTestId(document.body, "card-bg")).toHaveAttribute(
"fill",
From 17c249b984a548fb1056a889a211a95b06c16f4f Mon Sep 17 00:00:00 2001
From: martin-mfg <2026226+martin-mfg@users.noreply.github.com>
Date: Wed, 29 Apr 2026 10:15:44 +0200
Subject: [PATCH 2/8] modify whitespace logic and add tests
---
packages/core/src/common/render.js | 26 +++++++-------------
packages/core/tests/render.test.js | 39 +++++++++++++++++++++++++++---
2 files changed, 45 insertions(+), 20 deletions(-)
diff --git a/packages/core/src/common/render.js b/packages/core/src/common/render.js
index 8d8bdf3ee7ac0..10e49659ced94 100644
--- a/packages/core/src/common/render.js
+++ b/packages/core/src/common/render.js
@@ -317,28 +317,20 @@ const countWrappedLines = (text, fontSize, maxWidth, maxLines) => {
// 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]/;
+ // 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,
+ /\s|[\u3000-\u9FFF\uFF00-\uFFEF]|[^\s\u3000-\u9FFF\uFF00-\uFFEF]+/g,
);
if (!tokens) {
return 1;
}
- const whitespaceWidth = (run) => {
- const collapsed = run.replace(/[\t\n\r ]+/g, " ");
- let width = 0;
- for (const ch of collapsed) {
- // U+3000 IDEOGRAPHIC SPACE is one em wide; measureText's ASCII-only
- // table would otherwise fall back to the average and under-estimate it.
- width += ch === "\u3000" ? fontSize : measureText(ch, fontSize);
- }
- return width;
- };
-
const atomWidth = (atom) => {
- // CJK glyphs are full-width by default; measureText's ASCII-only table
- // would otherwise fall back to the average and under-estimate them.
- if (atom.length === 1 && cjkRange.test(atom)) {
+ // CJK glyphs and U+3000 IDEOGRAPHIC SPACE are full-width by default;
+ // measureText's ASCII-only table would otherwise fall back to the average
+ // and under-estimate them.
+ if (atom.length === 1 && cjkRange.test(atom) || atom === "\u3000") {
return fontSize;
}
return measureText(atom, fontSize);
@@ -348,12 +340,12 @@ const countWrappedLines = (text, fontSize, maxWidth, maxLines) => {
let currentWidth = 0;
for (const token of tokens) {
- if (/^\s+$/.test(token)) {
+ if (token === " ") {
// Whitespace at the start of a line is dropped by browsers.
if (currentWidth === 0) {
continue;
}
- currentWidth += whitespaceWidth(token);
+ currentWidth += measureText(token);
continue;
}
diff --git a/packages/core/tests/render.test.js b/packages/core/tests/render.test.js
index 9eb55d019ec2f..bbae64a659351 100644
--- a/packages/core/tests/render.test.js
+++ b/packages/core/tests/render.test.js
@@ -3,10 +3,43 @@
import { queryByTestId } from "@testing-library/dom";
import { describe, expect, it } from "vitest";
-import { renderError } from "../src/common/render.js";
+import { countWrappedLines, renderError } from "../src/common/render.js";
-describe("Test render.js", () => {
- it("should test renderError", () => {
+describe("Test countWrappedLines", () => {
+ it("should return 1 for empty text", () => {
+ expect(countWrappedLines("", 10, 200, 10)).toBe(1);
+ });
+
+ it("should return 1 when all text fits on a single line", () => {
+ expect(countWrappedLines("hi", 10, 200, 10)).toBe(1);
+ });
+
+ it("should return 2 when a two-word string wraps", () => {
+ expect(countWrappedLines("hello world", 10, 25, 10)).toBe(2);
+ });
+
+ it("should split a word wider than maxWidth (overflow-wrap: anywhere)", () => {
+ expect(countWrappedLines("aaaa", 10, 15, 10)).toBe(2);
+ });
+
+ it("should cap the result at maxLines", () => {
+ expect(countWrappedLines("word ".repeat(10), 10, 25, 3)).toBe(3);
+ });
+
+ it("should handle complex whitespace characters", () => {
+ expect(
+ countWrappedLines('"One two three."', 10, 30, 10),
+ ).toBe(5);
+ });
+
+ it("trailing spaces should not cause line breaks", () => {
+ expect(countWrappedLines("hi hi ", 10, 8, 10)).toBe(2);
+ });
+
+});
+
+describe("Test renderError", () => {
+ it("should contain error messages", () => {
document.body.innerHTML = renderError({ message: "Something went wrong" });
expect(
queryByTestId(document.body, "message")?.children[0],
From 7afca46a8257e59d26d0ccf40977cb77b2ccda83 Mon Sep 17 00:00:00 2001
From: martin-mfg <2026226+martin-mfg@users.noreply.github.com>
Date: Wed, 29 Apr 2026 22:49:12 +0200
Subject: [PATCH 3/8] add parameter to switch between old and new wrapping
---
docs/advanced_documentation.md | 2 +
packages/core/src/api/gist.js | 2 +
packages/core/src/api/pin.js | 2 +
packages/core/src/cards/gist.js | 64 +++++++++++------
packages/core/src/cards/repo.js | 80 +++++++++++++++-------
packages/core/src/cards/types.d.ts | 2 +
packages/core/src/common/render.js | 6 +-
packages/core/tests/renderGistCard.test.js | 8 +--
packages/core/tests/renderRepoCard.test.js | 10 +--
9 files changed, 114 insertions(+), 62 deletions(-)
diff --git a/docs/advanced_documentation.md b/docs/advanced_documentation.md
index 868979da1f976..29992e9793691 100644
--- a/docs/advanced_documentation.md
+++ b/docs/advanced_documentation.md
@@ -323,6 +323,7 @@ You can customize the appearance and behavior of the pinned repository card usin
| Name | Description | Type | Default value |
| --- | --- | --- | --- |
| `show_owner` | Shows the repo's owner name. | boolean | `false` |
+| `browser_rendering` | Compute text wrapping of repository description natively in the browser, instead of computing it server-side. | boolean | `false` |
| `description_lines_count` | Manually set the number of lines for the description. Specified value will be clamped between 1 and 3. If this parameter is not specified, the number of lines will be automatically adjusted according to the actual length of the description. | number | `null` |
| `card_width` | Sets the card's width manually. | number | `400px (approx.)` |
| `show_icons` | Shows icons near all stats enabled via `show`. | boolean | `true` |
@@ -368,6 +369,7 @@ You can customize the appearance and behavior of the gist card using the [common
| Name | Description | Type | Default value |
| --- | --- | --- | --- |
| `show_owner` | Shows the gist's owner name. | boolean | `false` |
+| `browser_rendering` | Compute text wrapping of gist description natively in the browser, instead of computing it server-side. | boolean | `false` |
### Demo
diff --git a/packages/core/src/api/gist.js b/packages/core/src/api/gist.js
index ebce051e0e6f3..d03071248dcde 100644
--- a/packages/core/src/api/gist.js
+++ b/packages/core/src/api/gist.js
@@ -23,6 +23,7 @@ export default async (
border_radius,
border_color,
show_owner,
+ browser_rendering,
hide_border,
},
pat = null,
@@ -59,6 +60,7 @@ export default async (
border_color,
locale: locale ? locale.toLowerCase() : null,
show_owner: parseBoolean(show_owner),
+ browser_rendering: parseBoolean(browser_rendering),
hide_border: parseBoolean(hide_border),
}),
};
diff --git a/packages/core/src/api/pin.js b/packages/core/src/api/pin.js
index 77107373b71f3..7d6ab9504221e 100644
--- a/packages/core/src/api/pin.js
+++ b/packages/core/src/api/pin.js
@@ -23,6 +23,7 @@ export default async (
card_width,
theme,
show_owner,
+ browser_rendering,
show,
show_icons,
number_format,
@@ -99,6 +100,7 @@ export default async (
border_color,
card_width_input: parseInt(card_width, 10),
show_owner: parseBoolean(show_owner),
+ browser_rendering: parseBoolean(browser_rendering),
show: showStats,
show_icons: parseBoolean(show_icons),
number_format,
diff --git a/packages/core/src/cards/gist.js b/packages/core/src/cards/gist.js
index 080be2e3c84ad..7ecc760bb53e0 100644
--- a/packages/core/src/cards/gist.js
+++ b/packages/core/src/cards/gist.js
@@ -2,7 +2,8 @@
import { default as Card } from "../common/Card.js";
import { getCardColors } from "../common/color.js";
-import { kFormatter } from "../common/fmt.js";
+import { kFormatter, wrapTextMultiline } from "../common/fmt.js";
+import { encodeHTML } from "../common/html.js";
import { icons } from "../common/icons.js";
import languageColors from "../common/languageColors.json" with { type: "json" };
import { parseEmojis } from "../common/ops.js";
@@ -49,6 +50,7 @@ const renderGistCard = (gistData, options = {}) => {
border_radius,
border_color,
show_owner = false,
+ browser_rendering = false,
hide_border = false,
} = options;
@@ -64,27 +66,45 @@ const renderGistCard = (gistData, options = {}) => {
});
const desc = parseEmojis(description || "No description provided");
- // The browser performs the actual text wrapping inside the foreignObject;
- // we only estimate the line count server-side so the SVG can reserve enough
- // height. The estimate uses measureText for font-aware widths instead of a
- // fixed character count.
- const descriptionLines = countWrappedLines(
- desc,
- DESCRIPTION_FONT_SIZE,
- DESCRIPTION_BOX_WIDTH,
- DESCRIPTION_MAX_LINES,
- );
- const descriptionSvg = wrappedTextNode({
- text: desc,
- x: X_OFFSET,
- y: 0,
- width: DESCRIPTION_BOX_WIDTH,
- height: descriptionLines * DESCRIPTION_LINE_HEIGHT_PX,
- lineCount: descriptionLines,
- className: "description",
- testId: "description-text",
- });
+ let descriptionLines, descriptionSvg;
+ if (browser_rendering) {
+ // The browser performs the actual text wrapping inside the foreignObject;
+ // we only estimate the line count server-side so the SVG can reserve enough
+ // height. The estimate uses measureText for font-aware widths instead of a
+ // fixed character count.
+ descriptionLines = countWrappedLines(
+ desc,
+ DESCRIPTION_FONT_SIZE,
+ DESCRIPTION_BOX_WIDTH,
+ DESCRIPTION_MAX_LINES,
+ );
+
+ descriptionSvg = wrappedTextNode({
+ text: desc,
+ x: X_OFFSET,
+ y: 0,
+ width: DESCRIPTION_BOX_WIDTH,
+ height: descriptionLines * DESCRIPTION_LINE_HEIGHT_PX,
+ lineCount: descriptionLines,
+ className: "description",
+ testId: "description-text",
+ });
+ } else {
+ const lineWidth = 59;
+ const linesLimit = 10;
+ const multiLineDescription = wrapTextMultiline(desc, lineWidth, linesLimit);
+ descriptionLines = multiLineDescription.length;
+ descriptionSvg = multiLineDescription
+ .map(
+ (line) =>
+ `