From 7da107817eeb796a6ad6bb43472d36f58658d078 Mon Sep 17 00:00:00 2001 From: Takuma IMAMURA <209989118+hyperfinitism@users.noreply.github.com> Date: Mon, 27 Apr 2026 21:08:14 +0900 Subject: [PATCH 1/8] feat: replace render-time text wrapping with browser-native wrapping Use `foreignObject` with CSS line-clamp to let the browser handle text wrapping natively instead of manually wrapping on the server. This provides better font-aware wrapping while keeping server-side line estimation for SVG height calculation. Signed-off-by: Takuma IMAMURA <209989118+hyperfinitism@users.noreply.github.com> --- packages/core/src/cards/gist.js | 47 ++++-- packages/core/src/cards/repo.js | 55 ++++---- packages/core/src/common/render.js | 157 +++++++++++++++++++++ packages/core/tests/renderGistCard.test.js | 24 ++-- packages/core/tests/renderRepoCard.test.js | 29 ++-- 5 files changed, 248 insertions(+), 64 deletions(-) diff --git a/packages/core/src/cards/gist.js b/packages/core/src/cards/gist.js index c8d6437db3177..080be2e3c84ad 100644 --- a/packages/core/src/cards/gist.js +++ b/packages/core/src/cards/gist.js @@ -2,22 +2,28 @@ import { default as Card } from "../common/Card.js"; import { getCardColors } from "../common/color.js"; -import { kFormatter, wrapTextMultiline } from "../common/fmt.js"; -import { encodeHTML } from "../common/html.js"; +import { kFormatter } from "../common/fmt.js"; import { icons } from "../common/icons.js"; import languageColors from "../common/languageColors.json" with { type: "json" }; import { parseEmojis } from "../common/ops.js"; import { + countWrappedLines, createLanguageNode, flexLayout, iconWithLabel, measureText, + wrappedTextNode, + wrappedTextStyles, } from "../common/render.js"; const ICON_SIZE = 16; const CARD_DEFAULT_WIDTH = 400; const X_OFFSET = 25; const HEADER_MAX_LENGTH = 35; +const DESCRIPTION_BOX_WIDTH = CARD_DEFAULT_WIDTH - 2 * X_OFFSET; +const DESCRIPTION_FONT_SIZE = 13; +const DESCRIPTION_LINE_HEIGHT_PX = 16; +const DESCRIPTION_MAX_LINES = 10; /** * @typedef {import('./types').GistCardOptions} GistCardOptions Gist card options. @@ -57,16 +63,28 @@ const renderGistCard = (gistData, options = {}) => { theme, }); - const lineWidth = 59; - const linesLimit = 10; const desc = parseEmojis(description || "No description provided"); - const multiLineDescription = wrapTextMultiline(desc, lineWidth, linesLimit); - const descriptionLines = multiLineDescription.length; - const descriptionSvg = multiLineDescription - .map( - (line) => `${encodeHTML(line)}`, - ) - .join(""); + // 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", + }); const lineHeight = descriptionLines > 3 ? 12 : 10; const height = @@ -124,16 +142,15 @@ const renderGistCard = (gistData, options = {}) => { }); card.setCSS(` - .description { font: 400 13px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor} } + .description { + font: 400 ${DESCRIPTION_FONT_SIZE}px 'Segoe UI', Ubuntu, Sans-Serif;${wrappedTextStyles(textColor)} } .gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor} } .icon { fill: ${iconColor} } `); card.setHideBorder(hide_border); return card.render(` - - ${descriptionSvg} - + ${descriptionSvg} ${starAndForkCount} diff --git a/packages/core/src/cards/repo.js b/packages/core/src/cards/repo.js index d5bab6f18ceaa..2242c43f51140 100644 --- a/packages/core/src/cards/repo.js +++ b/packages/core/src/cards/repo.js @@ -3,24 +3,27 @@ import { Card } from "../common/Card.js"; import { I18n } from "../common/I18n.js"; import { getCardColors } from "../common/color.js"; -import { kFormatter, wrapTextMultiline } from "../common/fmt.js"; -import { encodeHTML } from "../common/html.js"; +import { kFormatter } from "../common/fmt.js"; import { icons } from "../common/icons.js"; import { buildSearchFilter, clampValue, parseEmojis } from "../common/ops.js"; import { + countWrappedLines, createLanguageNode, flexLayout, iconWithLabel, measureText, + wrappedTextNode, + wrappedTextStyles, } from "../common/render.js"; import { repoCardLocales } from "../translations.js"; import { createTextNode } from "./stats.js"; const ICON_SIZE = 16; -const DESCRIPTION_LINE_WIDTH = 59; const CARD_DEFAULT_WIDTH = 400; const X_OFFSET = 25; +const DESCRIPTION_FONT_SIZE = 13; +const DESCRIPTION_LINE_HEIGHT_PX = 16; const DESCRIPTION_MAX_LINES = 3; /** @@ -177,27 +180,32 @@ const renderRepoCard = (repo, options = {}) => { const header = show_owner ? nameWithOwner : name; const langName = (primaryLanguage && primaryLanguage.name) || "Unspecified"; const langColor = (primaryLanguage && primaryLanguage.color) || "#333"; - const descriptionMaxLines = description_lines_count - ? clampValue(description_lines_count, 1, DESCRIPTION_MAX_LINES) - : DESCRIPTION_MAX_LINES; + const descriptionBoxWidth = card_width - 2 * X_OFFSET; const desc = parseEmojis(description || "No description provided"); - const multiLineDescription = wrapTextMultiline( - desc, - Math.round( - (card_width - CARD_DEFAULT_WIDTH) / 5.93 + DESCRIPTION_LINE_WIDTH, - ), - descriptionMaxLines, - ); + // 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 descriptionLinesCount = description_lines_count ? clampValue(description_lines_count, 1, DESCRIPTION_MAX_LINES) - : multiLineDescription.length; + : countWrappedLines( + desc, + DESCRIPTION_FONT_SIZE, + descriptionBoxWidth, + DESCRIPTION_MAX_LINES, + ); - const descriptionSvg = multiLineDescription - .map( - (line) => `${encodeHTML(line)}`, - ) - .join(""); + const descriptionSvg = wrappedTextNode({ + text: desc, + x: X_OFFSET, + y: 0, + width: descriptionBoxWidth, + height: descriptionLinesCount * DESCRIPTION_LINE_HEIGHT_PX, + lineCount: descriptionLinesCount, + className: "description", + testId: "description-text", + }); const extraHeight = Object.keys(STATS).length ? -7 + (Math.ceil(statItems.length / 2) + 1) * extraLHeight @@ -279,11 +287,12 @@ const renderRepoCard = (repo, options = {}) => { card.setHideBorder(hide_border); card.setHideTitle(false); card.setCSS(` - .description { font: 400 13px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${colors.textColor} } + .description { + font: 400 ${DESCRIPTION_FONT_SIZE}px 'Segoe UI', Ubuntu, Sans-Serif;${wrappedTextStyles(colors.textColor)} } .gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${colors.textColor} } .badge { font: 600 11px 'Segoe UI', Ubuntu, Sans-Serif; } .badge rect { opacity: 0.2 } - + .stat { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${colors.textColor} } .stagger { opacity: 0; @@ -316,9 +325,7 @@ const renderRepoCard = (repo, options = {}) => { : "" } - - ${descriptionSvg} - + ${descriptionSvg} ${starAndForkCount} diff --git a/packages/core/src/common/render.js b/packages/core/src/common/render.js index 7aba75c0409d7..8d8bdf3ee7ac0 100644 --- a/packages/core/src/common/render.js +++ b/packages/core/src/common/render.js @@ -86,6 +86,65 @@ const createProgressNode = ({ `; }; +/** + * 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; +`; + /** * 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) => + `${encodeHTML(line)}`, + ) + .join(""); + descriptionSvg = ` + ${descriptionSvg} + `; + } const lineHeight = descriptionLines > 3 ? 12 : 10; const height = @@ -143,7 +163,7 @@ const renderGistCard = (gistData, options = {}) => { card.setCSS(` .description { - font: 400 ${DESCRIPTION_FONT_SIZE}px 'Segoe UI', Ubuntu, Sans-Serif;${wrappedTextStyles(textColor)} } + font: 400 ${DESCRIPTION_FONT_SIZE}px 'Segoe UI', Ubuntu, Sans-Serif;fill: ${textColor};${wrappedTextStyles} } .gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor} } .icon { fill: ${iconColor} } `); diff --git a/packages/core/src/cards/repo.js b/packages/core/src/cards/repo.js index 2242c43f51140..150d6e30c2ec0 100644 --- a/packages/core/src/cards/repo.js +++ b/packages/core/src/cards/repo.js @@ -3,7 +3,8 @@ import { Card } from "../common/Card.js"; import { I18n } from "../common/I18n.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 { buildSearchFilter, clampValue, parseEmojis } from "../common/ops.js"; import { @@ -20,6 +21,7 @@ import { repoCardLocales } from "../translations.js"; import { createTextNode } from "./stats.js"; const ICON_SIZE = 16; +const DESCRIPTION_LINE_WIDTH = 59; const CARD_DEFAULT_WIDTH = 400; const X_OFFSET = 25; const DESCRIPTION_FONT_SIZE = 13; @@ -84,6 +86,7 @@ const renderRepoCard = (repo, options = {}) => { bg_color, card_width_input, show_owner = false, + browser_rendering = false, show = [], show_icons = true, number_format = "short", @@ -180,32 +183,57 @@ const renderRepoCard = (repo, options = {}) => { const header = show_owner ? nameWithOwner : name; const langName = (primaryLanguage && primaryLanguage.name) || "Unspecified"; const langColor = (primaryLanguage && primaryLanguage.color) || "#333"; - - const descriptionBoxWidth = card_width - 2 * X_OFFSET; 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 descriptionLinesCount = description_lines_count - ? clampValue(description_lines_count, 1, DESCRIPTION_MAX_LINES) - : countWrappedLines( - desc, - DESCRIPTION_FONT_SIZE, - descriptionBoxWidth, - DESCRIPTION_MAX_LINES, - ); - const descriptionSvg = wrappedTextNode({ - text: desc, - x: X_OFFSET, - y: 0, - width: descriptionBoxWidth, - height: descriptionLinesCount * DESCRIPTION_LINE_HEIGHT_PX, - lineCount: descriptionLinesCount, - className: "description", - testId: "description-text", - }); + let descriptionLinesCount, descriptionSvg; + if (browser_rendering) { + const descriptionBoxWidth = card_width - 2 * X_OFFSET; + // 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. + descriptionLinesCount = description_lines_count + ? clampValue(description_lines_count, 1, DESCRIPTION_MAX_LINES) + : countWrappedLines( + desc, + DESCRIPTION_FONT_SIZE, + descriptionBoxWidth, + DESCRIPTION_MAX_LINES, + ); + descriptionSvg = wrappedTextNode({ + text: desc, + x: X_OFFSET, + y: 0, + width: descriptionBoxWidth, + height: descriptionLinesCount * DESCRIPTION_LINE_HEIGHT_PX, + lineCount: descriptionLinesCount, + className: "description", + testId: "description-text", + }); + } else { + const descriptionMaxLines = description_lines_count + ? clampValue(description_lines_count, 1, DESCRIPTION_MAX_LINES) + : DESCRIPTION_MAX_LINES; + const multiLineDescription = wrapTextMultiline( + desc, + Math.round( + (card_width - CARD_DEFAULT_WIDTH) / 5.93 + DESCRIPTION_LINE_WIDTH, + ), + descriptionMaxLines, + ); + descriptionLinesCount = description_lines_count + ? clampValue(description_lines_count, 1, DESCRIPTION_MAX_LINES) + : multiLineDescription.length; + descriptionSvg = multiLineDescription + .map( + (line) => + `${encodeHTML(line)}`, + ) + .join(""); + descriptionSvg = ` + ${descriptionSvg} + `; + } const extraHeight = Object.keys(STATS).length ? -7 + (Math.ceil(statItems.length / 2) + 1) * extraLHeight @@ -288,7 +316,7 @@ const renderRepoCard = (repo, options = {}) => { card.setHideTitle(false); card.setCSS(` .description { - font: 400 ${DESCRIPTION_FONT_SIZE}px 'Segoe UI', Ubuntu, Sans-Serif;${wrappedTextStyles(colors.textColor)} } + font: 400 ${DESCRIPTION_FONT_SIZE}px 'Segoe UI', Ubuntu, Sans-Serif;fill: ${colors.textColor};${wrappedTextStyles} } .gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${colors.textColor} } .badge { font: 600 11px 'Segoe UI', Ubuntu, Sans-Serif; } .badge rect { opacity: 0.2 } diff --git a/packages/core/src/cards/types.d.ts b/packages/core/src/cards/types.d.ts index 08b9f933859e6..aff7b01ee7989 100644 --- a/packages/core/src/cards/types.d.ts +++ b/packages/core/src/cards/types.d.ts @@ -34,6 +34,7 @@ export type StatCardOptions = CommonOptions & { export type RepoCardOptions = CommonOptions & { show_owner: boolean; + browser_rendering: boolean; description_lines_count: number; card_width_input; show: Array; @@ -73,4 +74,5 @@ export type WakaTimeOptions = CommonOptions & { export type GistCardOptions = CommonOptions & { show_owner: boolean; + browser_rendering: boolean; }; diff --git a/packages/core/src/common/render.js b/packages/core/src/common/render.js index 10e49659ced94..3d3a630ee81fb 100644 --- a/packages/core/src/common/render.js +++ b/packages/core/src/common/render.js @@ -127,12 +127,8 @@ const wrappedTextNode = ({ * 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}; +const wrappedTextStyles = ` margin: 0; line-height: 1.2; overflow-wrap: anywhere; diff --git a/packages/core/tests/renderGistCard.test.js b/packages/core/tests/renderGistCard.test.js index 27b9f6c99869c..2dcfd72fc725f 100644 --- a/packages/core/tests/renderGistCard.test.js +++ b/packages/core/tests/renderGistCard.test.js @@ -111,7 +111,7 @@ describe("test renderGistCard", () => { const iconClassStyles = stylesObject[":host"][".icon "]; expect(headerClassStyles.fill.trim()).toBe(`#${customColors.title_color}`); - expect(descClassStyles.color.trim()).toBe(`#${customColors.text_color}`); + expect(descClassStyles.fill.trim()).toBe(`#${customColors.text_color}`); expect(iconClassStyles.fill.trim()).toBe(`#${customColors.icon_color}`); expect(queryByTestId(document.body, "card-bg")).toHaveAttribute( "fill", @@ -135,7 +135,7 @@ describe("test renderGistCard", () => { expect(headerClassStyles.fill.trim()).toBe( `#${themes[name].title_color}`, ); - expect(descClassStyles.color.trim()).toBe(`#${themes[name].text_color}`); + expect(descClassStyles.fill.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"); @@ -159,7 +159,7 @@ describe("test renderGistCard", () => { const iconClassStyles = stylesObject[":host"][".icon "]; expect(headerClassStyles.fill.trim()).toBe("#5a0"); - expect(descClassStyles.color.trim()).toBe(`#${themes.radical.text_color}`); + expect(descClassStyles.fill.trim()).toBe(`#${themes.radical.text_color}`); expect(iconClassStyles.fill.trim()).toBe(`#${themes.radical.icon_color}`); expect(queryByTestId(document.body, "card-bg")).toHaveAttribute( "fill", @@ -184,7 +184,7 @@ describe("test renderGistCard", () => { expect(headerClassStyles.fill.trim()).toBe( `#${themes.default.title_color}`, ); - expect(descClassStyles.color.trim()).toBe(`#${themes.default.text_color}`); + expect(descClassStyles.fill.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 fdea924937dcd..d99e12f082f18 100644 --- a/packages/core/tests/renderRepoCard.test.js +++ b/packages/core/tests/renderRepoCard.test.js @@ -136,7 +136,7 @@ describe("Test renderRepoCard", () => { const iconClassStyles = stylesObject[":host"][".icon "]; expect(headerClassStyles.fill.trim()).toBe("#2f80ed"); - expect(descClassStyles.color.trim()).toBe("#434d58"); + expect(descClassStyles.fill.trim()).toBe("#434d58"); expect(iconClassStyles.fill.trim()).toBe("#586069"); expect(queryByTestId(document.body, "card-bg")).toHaveAttribute( "fill", @@ -164,7 +164,7 @@ describe("Test renderRepoCard", () => { const iconClassStyles = stylesObject[":host"][".icon "]; expect(headerClassStyles.fill.trim()).toBe(`#${customColors.title_color}`); - expect(descClassStyles.color.trim()).toBe(`#${customColors.text_color}`); + expect(descClassStyles.fill.trim()).toBe(`#${customColors.text_color}`); expect(iconClassStyles.fill.trim()).toBe(`#${customColors.icon_color}`); expect(queryByTestId(document.body, "card-bg")).toHaveAttribute( "fill", @@ -188,7 +188,7 @@ describe("Test renderRepoCard", () => { expect(headerClassStyles.fill.trim()).toBe( `#${themes[name].title_color}`, ); - expect(descClassStyles.color.trim()).toBe(`#${themes[name].text_color}`); + expect(descClassStyles.fill.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"); @@ -212,7 +212,7 @@ describe("Test renderRepoCard", () => { const iconClassStyles = stylesObject[":host"][".icon "]; expect(headerClassStyles.fill.trim()).toBe("#5a0"); - expect(descClassStyles.color.trim()).toBe(`#${themes.radical.text_color}`); + expect(descClassStyles.fill.trim()).toBe(`#${themes.radical.text_color}`); expect(iconClassStyles.fill.trim()).toBe(`#${themes.radical.icon_color}`); expect(queryByTestId(document.body, "card-bg")).toHaveAttribute( "fill", @@ -237,7 +237,7 @@ describe("Test renderRepoCard", () => { expect(headerClassStyles.fill.trim()).toBe( `#${themes.default.title_color}`, ); - expect(descClassStyles.color.trim()).toBe(`#${themes.default.text_color}`); + expect(descClassStyles.fill.trim()).toBe(`#${themes.default.text_color}`); expect(iconClassStyles.fill.trim()).toBe(`#${themes.radical.icon_color}`); expect(queryByTestId(document.body, "card-bg")).toHaveAttribute( "fill", From 3a90d0fd18d311f35615e560498f73fed8f03b64 Mon Sep 17 00:00:00 2001 From: martin-mfg <2026226+martin-mfg@users.noreply.github.com> Date: Thu, 30 Apr 2026 09:28:14 +0200 Subject: [PATCH 4/8] tests --- .../__snapshots__/gist.test.js.snap | 10 ++++-- .../__snapshots__/pin.test.js.snap | 18 +++++++---- packages/core/src/cards/gist.js | 4 ++- packages/core/src/cards/repo.js | 4 ++- packages/core/tests/renderGistCard.test.js | 20 +++++++++++- packages/core/tests/renderRepoCard.test.js | 31 ++++++++++++++++++- 6 files changed, 75 insertions(+), 12 deletions(-) diff --git a/apps/backend/tests/public-instance/__snapshots__/gist.test.js.snap b/apps/backend/tests/public-instance/__snapshots__/gist.test.js.snap index 6e8e2779e7762..1a49f7000cb03 100644 --- a/apps/backend/tests/public-instance/__snapshots__/gist.test.js.snap +++ b/apps/backend/tests/public-instance/__snapshots__/gist.test.js.snap @@ -44,7 +44,10 @@ exports[`Test /api/gist contract > should match the public happy-path response s .header { font-size: 15.5px; } } - .description { font: 400 13px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 } + .description { + font: 400 13px 'Segoe UI', Ubuntu, Sans-Serif;fill: #434d58; + + } .gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 } .icon { fill: #586069 } @@ -141,7 +144,10 @@ exports[`Test /api/gist contract > should match the public many-params response .header { font-size: 15.5px; } } - .description { font: 400 13px 'Segoe UI', Ubuntu, Sans-Serif; fill: #abcdef } + .description { + font: 400 13px 'Segoe UI', Ubuntu, Sans-Serif;fill: #abcdef; + + } .gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #abcdef } .icon { fill: #ff00aa } diff --git a/apps/backend/tests/public-instance/__snapshots__/pin.test.js.snap b/apps/backend/tests/public-instance/__snapshots__/pin.test.js.snap index 959c008a0c8eb..70ec82182c9b0 100644 --- a/apps/backend/tests/public-instance/__snapshots__/pin.test.js.snap +++ b/apps/backend/tests/public-instance/__snapshots__/pin.test.js.snap @@ -100,11 +100,14 @@ exports[`Test /api/pin contract > should match the public happy-path response sn .header { font-size: 15.5px; } } - .description { font: 400 13px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 } + .description { + font: 400 13px 'Segoe UI', Ubuntu, Sans-Serif;fill: #434d58; + + } .gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 } .badge { font: 600 11px 'Segoe UI', Ubuntu, Sans-Serif; } .badge rect { opacity: 0.2 } - + .stat { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #434d58 } .stagger { opacity: 0; @@ -160,7 +163,7 @@ exports[`Test /api/pin contract > should match the public happy-path response sn - + Help us take over the world with a deeply customizableReact, TypeScript and GraphQL chat app that has enough textto wrap across multiple lines in the repository card. @@ -217,11 +220,14 @@ exports[`Test /api/pin contract > should match the public many-params response s .header { font-size: 15.5px; } } - .description { font: 400 13px 'Segoe UI', Ubuntu, Sans-Serif; fill: #abcdef } + .description { + font: 400 13px 'Segoe UI', Ubuntu, Sans-Serif;fill: #abcdef; + + } .gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #abcdef } .badge { font: 600 11px 'Segoe UI', Ubuntu, Sans-Serif; } .badge rect { opacity: 0.2 } - + .stat { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #abcdef } .stagger { opacity: 0; @@ -277,7 +283,7 @@ exports[`Test /api/pin contract > should match the public many-params response s - + Help us take over the world with a deeply customizable React, TypeScript and GraphQL... diff --git a/packages/core/src/cards/gist.js b/packages/core/src/cards/gist.js index 7ecc760bb53e0..b4ec7fc23fb3d 100644 --- a/packages/core/src/cards/gist.js +++ b/packages/core/src/cards/gist.js @@ -163,7 +163,9 @@ const renderGistCard = (gistData, options = {}) => { card.setCSS(` .description { - font: 400 ${DESCRIPTION_FONT_SIZE}px 'Segoe UI', Ubuntu, Sans-Serif;fill: ${textColor};${wrappedTextStyles} } + font: 400 ${DESCRIPTION_FONT_SIZE}px 'Segoe UI', Ubuntu, Sans-Serif;fill: ${textColor}; + ${browser_rendering ? wrappedTextStyles : ""} + } .gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor} } .icon { fill: ${iconColor} } `); diff --git a/packages/core/src/cards/repo.js b/packages/core/src/cards/repo.js index 150d6e30c2ec0..f3b54a3d5287f 100644 --- a/packages/core/src/cards/repo.js +++ b/packages/core/src/cards/repo.js @@ -316,7 +316,9 @@ const renderRepoCard = (repo, options = {}) => { card.setHideTitle(false); card.setCSS(` .description { - font: 400 ${DESCRIPTION_FONT_SIZE}px 'Segoe UI', Ubuntu, Sans-Serif;fill: ${colors.textColor};${wrappedTextStyles} } + font: 400 ${DESCRIPTION_FONT_SIZE}px 'Segoe UI', Ubuntu, Sans-Serif;fill: ${colors.textColor}; + ${browser_rendering ? wrappedTextStyles : ""} + } .gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${colors.textColor} } .badge { font: 600 11px 'Segoe UI', Ubuntu, Sans-Serif; } .badge rect { opacity: 0.2 } diff --git a/packages/core/tests/renderGistCard.test.js b/packages/core/tests/renderGistCard.test.js index 2dcfd72fc725f..22fed4d41bd9b 100644 --- a/packages/core/tests/renderGistCard.test.js +++ b/packages/core/tests/renderGistCard.test.js @@ -54,12 +54,30 @@ describe("test renderGistCard", () => { expect(header).toHaveTextContent("some-really-long-repo-name-for-test..."); }); - it("should clamp long descriptions to the configured line count", () => { + it("should trim description if description is too long", () => { 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", }); + 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"); + }); + + it("should respect browser_rendering=true", () => { + 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", + }, + { browser_rendering: true }, + ); // 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]; diff --git a/packages/core/tests/renderRepoCard.test.js b/packages/core/tests/renderRepoCard.test.js index d99e12f082f18..393531c7a84f4 100644 --- a/packages/core/tests/renderRepoCard.test.js +++ b/packages/core/tests/renderRepoCard.test.js @@ -62,13 +62,42 @@ describe("Test renderRepoCard", () => { ); }); - it("should clamp long descriptions to descriptionLinesCount lines", () => { + it("should trim description", () => { 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"); + + // Should not trim + document.body.innerHTML = renderRepoCard({ + ...data_repo.repository, + description: "Small text should not trim", + }); + + expect(document.getElementsByClassName("description")[0]).toHaveTextContent( + "Small text should not trim", + ); + }); + + it("should respect browser_rendering=true", () => { + 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", + }, + { browser_rendering: true }, + ); + // 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. From df225ac8a6d5b5bcdef478293791ec65a53f99e5 Mon Sep 17 00:00:00 2001 From: martin-mfg <2026226+martin-mfg@users.noreply.github.com> Date: Fri, 1 May 2026 10:54:56 +0200 Subject: [PATCH 5/8] add function 'splitWrappedText' --- packages/core/src/common/render.js | 117 +++++++++++++++++++---------- packages/core/tests/render.test.js | 49 +++++++++++- 2 files changed, 122 insertions(+), 44 deletions(-) diff --git a/packages/core/src/common/render.js b/packages/core/src/common/render.js index 3d3a630ee81fb..392863010b249 100644 --- a/packages/core/src/common/render.js +++ b/packages/core/src/common/render.js @@ -273,66 +273,85 @@ const measureText = (str, fontSize = 10) => { ]; const avg = 0.5279276315789471; + // 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) => - c.charCodeAt(0) < widths.length ? widths[c.charCodeAt(0)] : avg, - ) + .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 ); }; /** - * Estimate how many lines a string will wrap to when laid out greedily at the + * 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 estimate. + * @param {string} text Text to split. * @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`. + * @returns {string[]} Estimated wrapped lines. */ -const countWrappedLines = (text, fontSize, maxWidth, maxLines) => { +const splitWrappedText = (text, fontSize, maxWidth) => { if (!text) { - return 1; + return []; } // Tokenize the text into atoms representing line-break opportunities: - // - whitespace runs (collapsed/dropped at line edges per CSS rules); + // - 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. - // 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]/; // 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 1; + return []; } - const atomWidth = (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; + const takeFittingSegment = (token, availableWidth) => { + const characters = token.split(""); + let segment = ""; + let width = 0; + + for (const character of characters) { + const characterWidth = measureText(character); + if (segment && width + characterWidth > availableWidth) { + break; + } + segment += character; + width += characterWidth; } - return measureText(atom, fontSize); + + return { + segment, + width, + }; }; - let lines = 1; + const lines = [""]; let currentWidth = 0; for (const token of tokens) { @@ -341,33 +360,50 @@ const countWrappedLines = (text, fontSize, maxWidth, maxLines) => { if (currentWidth === 0) { continue; } + lines[lines.length - 1] += token; currentWidth += measureText(token); continue; } - const w = atomWidth(token); + let remaining = token; - if (currentWidth === 0) { - currentWidth = w; - } else if (currentWidth + w <= maxWidth) { - currentWidth += w; - } else { - lines += 1; - currentWidth = w; - } + while (remaining) { + const w = measureText(remaining); + if (currentWidth + w <= maxWidth) { + lines[lines.length - 1] += remaining; + currentWidth += w; + break; + } - // An atom wider than the box wraps mid-glyph (overflow-wrap: anywhere). - while (currentWidth > maxWidth) { - lines += 1; - currentWidth -= maxWidth; - } + if (currentWidth > 0) { + lines.push(""); + currentWidth = 0; + continue; + } - if (lines >= maxLines) { - return maxLines; + // 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 Math.min(lines, maxLines); + 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 { @@ -377,6 +413,7 @@ export { iconWithLabel, flexLayout, measureText, + splitWrappedText, countWrappedLines, wrappedTextNode, wrappedTextStyles, diff --git a/packages/core/tests/render.test.js b/packages/core/tests/render.test.js index bbae64a659351..96c490f6998c6 100644 --- a/packages/core/tests/render.test.js +++ b/packages/core/tests/render.test.js @@ -3,7 +3,50 @@ import { queryByTestId } from "@testing-library/dom"; import { describe, expect, it } from "vitest"; -import { countWrappedLines, renderError } from "../src/common/render.js"; +import { + countWrappedLines, + renderError, + splitWrappedText, +} from "../src/common/render.js"; + +describe("Test splitWrappedText", () => { + it("should return an empty array for empty text", () => { + expect(splitWrappedText("", 10, 200)).toEqual([]); + }); + + it("should split a two-word string across lines", () => { + expect(splitWrappedText("hello world", 10, 25)).toEqual([ + "hello", + "world", + ]); + }); + + it("should split a word wider than maxWidth", () => { + expect(splitWrappedText("aaaa", 10, 15)).toEqual(["aa", "aa"]); + }); + + it("should handle mix of short and long words", () => { + expect(splitWrappedText("short looooong", 10, 40)).toEqual([ + "short", + "looooon", + "g", + ]); + }); + + it("should handle complex whitespace characters", () => { + expect(splitWrappedText("One         two three", 10, 25)).toEqual([ + "One", + "     ", + "  ", + "two", + "three", + ]); + }); + + it("trailing spaces should not cause line breaks", () => { + expect(splitWrappedText("hi hi ", 10, 8)).toEqual(["hi", "hi"]); + }); +}); describe("Test countWrappedLines", () => { it("should return 1 for empty text", () => { @@ -27,9 +70,7 @@ describe("Test countWrappedLines", () => { }); it("should handle complex whitespace characters", () => { - expect( - countWrappedLines('"One         two three."', 10, 30, 10), - ).toBe(5); + expect(countWrappedLines("One         two three", 10, 25, 10)).toBe(5); }); it("trailing spaces should not cause line breaks", () => { From 37fc7822666bd381a19ea430944e840b1382e7be Mon Sep 17 00:00:00 2001 From: martin-mfg <2026226+martin-mfg@users.noreply.github.com> Date: Fri, 1 May 2026 12:27:37 +0200 Subject: [PATCH 6/8] use new wrapping logic also on server-side --- .../__snapshots__/gist.test.js.snap | 4 +-- .../__snapshots__/pin.test.js.snap | 2 +- packages/core/src/cards/gist.js | 8 ++++-- packages/core/src/cards/repo.js | 7 +++--- packages/core/src/common/fmt.js | 25 ++++++------------- packages/core/src/common/render.js | 15 ++++++----- packages/core/src/fetchers/stats.js | 2 +- packages/core/src/fetchers/top-languages.js | 2 +- packages/core/tests/fmt.test.js | 22 +++++++++++----- packages/core/tests/render.test.js | 6 +---- packages/core/tests/renderGistCard.test.js | 2 +- packages/core/tests/renderRepoCard.test.js | 5 ++-- 12 files changed, 52 insertions(+), 48 deletions(-) diff --git a/apps/backend/tests/public-instance/__snapshots__/gist.test.js.snap b/apps/backend/tests/public-instance/__snapshots__/gist.test.js.snap index 1a49f7000cb03..447bddb3b5b0c 100644 --- a/apps/backend/tests/public-instance/__snapshots__/gist.test.js.snap +++ b/apps/backend/tests/public-instance/__snapshots__/gist.test.js.snap @@ -93,7 +93,7 @@ exports[`Test /api/gist contract > should match the public happy-path response s - List of countries and territories in English and Spanish:name, continent, capital, dial code, country codes, TLD,and area in sq km. Lista de países y territorios enInglés y Español: nombre, continente, capital,código de teléfono, códigos de país,dominio y área en km cuadrados. Updated 2023 + List of countries and territories in English and Spanish:name, continent, capital, dial code, country codes, TLD, andarea in sq km. Lista de países y territorios en Inglés yEspañol: nombre, continente, capital, código de teléfono,códigos de país, dominio y área en km cuadrados. Updated2023 @@ -193,7 +193,7 @@ exports[`Test /api/gist contract > should match the public many-params response - List of countries and territories in English and Spanish:name, continent, capital, dial code, country codes, TLD,and area in sq km. Lista de países y territorios enInglés y Español: nombre, continente, capital,código de teléfono, códigos de país,dominio y área en km cuadrados. Updated 2023 + List of countries and territories in English and Spanish:name, continent, capital, dial code, country codes, TLD, andarea in sq km. Lista de países y territorios en Inglés yEspañol: nombre, continente, capital, código de teléfono,códigos de país, dominio y área en km cuadrados. Updated2023 diff --git a/apps/backend/tests/public-instance/__snapshots__/pin.test.js.snap b/apps/backend/tests/public-instance/__snapshots__/pin.test.js.snap index 70ec82182c9b0..4a90b1750d7a5 100644 --- a/apps/backend/tests/public-instance/__snapshots__/pin.test.js.snap +++ b/apps/backend/tests/public-instance/__snapshots__/pin.test.js.snap @@ -164,7 +164,7 @@ exports[`Test /api/pin contract > should match the public happy-path response sn - Help us take over the world with a deeply customizableReact, TypeScript and GraphQL chat app that has enough textto wrap across multiple lines in the repository card. + Help us take over the world with a deeply customizableReact, TypeScript and GraphQL chat app that has enoughtext to wrap across multiple lines in the repository card. diff --git a/packages/core/src/cards/gist.js b/packages/core/src/cards/gist.js index b4ec7fc23fb3d..a63ac955cf143 100644 --- a/packages/core/src/cards/gist.js +++ b/packages/core/src/cards/gist.js @@ -91,9 +91,13 @@ const renderGistCard = (gistData, options = {}) => { testId: "description-text", }); } else { - const lineWidth = 59; const linesLimit = 10; - const multiLineDescription = wrapTextMultiline(desc, lineWidth, linesLimit); + const multiLineDescription = wrapTextMultiline( + desc, + DESCRIPTION_BOX_WIDTH, + DESCRIPTION_FONT_SIZE, + linesLimit, + ); descriptionLines = multiLineDescription.length; descriptionSvg = multiLineDescription .map( diff --git a/packages/core/src/cards/repo.js b/packages/core/src/cards/repo.js index f3b54a3d5287f..98208a42690a4 100644 --- a/packages/core/src/cards/repo.js +++ b/packages/core/src/cards/repo.js @@ -184,10 +184,10 @@ const renderRepoCard = (repo, options = {}) => { const langName = (primaryLanguage && primaryLanguage.name) || "Unspecified"; const langColor = (primaryLanguage && primaryLanguage.color) || "#333"; const desc = parseEmojis(description || "No description provided"); + const descriptionBoxWidth = card_width - 2 * X_OFFSET; let descriptionLinesCount, descriptionSvg; if (browser_rendering) { - const descriptionBoxWidth = card_width - 2 * X_OFFSET; // 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 @@ -216,9 +216,8 @@ const renderRepoCard = (repo, options = {}) => { : DESCRIPTION_MAX_LINES; const multiLineDescription = wrapTextMultiline( desc, - Math.round( - (card_width - CARD_DEFAULT_WIDTH) / 5.93 + DESCRIPTION_LINE_WIDTH, - ), + descriptionBoxWidth, + DESCRIPTION_FONT_SIZE, descriptionMaxLines, ); descriptionLinesCount = description_lines_count diff --git a/packages/core/src/common/fmt.js b/packages/core/src/common/fmt.js index 5bc3ad14804ac..573f4a40b92ac 100644 --- a/packages/core/src/common/fmt.js +++ b/packages/core/src/common/fmt.js @@ -3,6 +3,7 @@ import wrap from "word-wrap"; import { encodeHTML } from "./html.js"; +import { splitWrappedText } from "./render.js"; /** * Retrieves num with suffix k(thousands) precise to given decimal places. @@ -57,26 +58,16 @@ const formatBytes = (bytes) => { * Split text over multiple lines based on the card width. * * @param {string} text Text to split. - * @param {number} width Line width in number of characters. + * @param {number} width Available wrap width in px. + * @param {number} fontSize Font size in px. * @param {number} maxLines Maximum number of lines. * @returns {string[]} Array of lines. */ -const wrapTextMultiline = (text, width = 59, maxLines = 3) => { - const fullWidthComma = ","; - const encoded = encodeHTML(text); - const isChinese = encoded.includes(fullWidthComma); - - let wrapped; - - if (isChinese) { - wrapped = encoded.split(fullWidthComma); // Chinese full punctuation - } else { - wrapped = wrap(encoded, { - width, - }).split("\n"); // Split wrapped lines to get an array of lines - } - - const lines = wrapped.map((line) => line.trim()).slice(0, maxLines); // Only consider maxLines lines +const wrapTextMultiline = (text, width, fontSize, maxLines = 3) => { + const wrapped = splitWrappedText(text, fontSize, width); + const lines = wrapped + .map((line) => encodeHTML(line.trim())) + .slice(0, maxLines); // Only consider maxLines lines // Add "..." to the last line if the text exceeds maxLines if (wrapped.length > maxLines) { diff --git a/packages/core/src/common/render.js b/packages/core/src/common/render.js index 392863010b249..1f8b9a95ded15 100644 --- a/packages/core/src/common/render.js +++ b/packages/core/src/common/render.js @@ -337,7 +337,7 @@ const splitWrappedText = (text, fontSize, maxWidth) => { let width = 0; for (const character of characters) { - const characterWidth = measureText(character); + const characterWidth = measureText(character, fontSize); if (segment && width + characterWidth > availableWidth) { break; } @@ -346,8 +346,8 @@ const splitWrappedText = (text, fontSize, maxWidth) => { } return { - segment, - width, + segment, + width, }; }; @@ -361,14 +361,14 @@ const splitWrappedText = (text, fontSize, maxWidth) => { continue; } lines[lines.length - 1] += token; - currentWidth += measureText(token); + currentWidth += measureText(token, fontSize); continue; } let remaining = token; while (remaining) { - const w = measureText(remaining); + const w = measureText(remaining, fontSize); if (currentWidth + w <= maxWidth) { lines[lines.length - 1] += remaining; currentWidth += w; @@ -403,7 +403,10 @@ const splitWrappedText = (text, fontSize, maxWidth) => { * @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); + return Math.min( + Math.max(1, splitWrappedText(text, fontSize, maxWidth).length), + maxLines, + ); }; export { diff --git a/packages/core/src/fetchers/stats.js b/packages/core/src/fetchers/stats.js index 1e2f3a551544b..602101bc61a7e 100644 --- a/packages/core/src/fetchers/stats.js +++ b/packages/core/src/fetchers/stats.js @@ -388,7 +388,7 @@ const fetchStats = async ( } if (res.data.errors[0].message) { throw new CustomError( - wrapTextMultiline(res.data.errors[0].message, 90, 1)[0], + wrapTextMultiline(res.data.errors[0].message, 525, 12)[0], res.statusText, ); } diff --git a/packages/core/src/fetchers/top-languages.js b/packages/core/src/fetchers/top-languages.js index 541bb3b15de30..8f782493e6588 100644 --- a/packages/core/src/fetchers/top-languages.js +++ b/packages/core/src/fetchers/top-languages.js @@ -94,7 +94,7 @@ const fetchTopLanguages = async ( } if (res.data.errors[0].message) { throw new CustomError( - wrapTextMultiline(res.data.errors[0].message, 90, 1)[0], + wrapTextMultiline(res.data.errors[0].message, 525, 12)[0], res.statusText, ); } diff --git a/packages/core/tests/fmt.test.js b/packages/core/tests/fmt.test.js index 21b5bfcb8fc32..4d723a49e16c3 100644 --- a/packages/core/tests/fmt.test.js +++ b/packages/core/tests/fmt.test.js @@ -72,7 +72,12 @@ describe("Test fmt.js", () => { it("wrapTextMultiline: should not wrap small texts", () => { { - let multiLineText = wrapTextMultiline("Small text should not wrap"); + let multiLineText = wrapTextMultiline( + "Small text should not wrap", + 130, + 11, + 3, + ); expect(multiLineText).toEqual(["Small text should not wrap"]); } }); @@ -80,26 +85,31 @@ describe("Test fmt.js", () => { it("wrapTextMultiline: should wrap large texts", () => { let multiLineText = wrapTextMultiline( "Hello world long long long text", - 20, + 130, + 11, 3, ); - expect(multiLineText).toEqual(["Hello world long", "long long text"]); + expect(multiLineText).toEqual(["Hello world long long long", "text"]); }); it("wrapTextMultiline: should wrap large texts and limit max lines", () => { let multiLineText = wrapTextMultiline( "Hello world long long long text", - 10, + 53, + 11, 2, ); expect(multiLineText).toEqual(["Hello", "world long..."]); }); - it("wrapTextMultiline: should wrap chinese by punctuation", () => { + it("wrapTextMultiline: should handle chinese characters", () => { let multiLineText = wrapTextMultiline( "专门为刚开始刷题的同学准备的算法基地,没有最细只有更细,立志用动画将晦涩难懂的算法说的通俗易懂!", + 130, + 11, + 3, ); expect(multiLineText.length).toEqual(3); - expect(multiLineText[0].length).toEqual(18 * 8); // &#xxxxx; x 8 + expect(multiLineText[0].length).toEqual(11 * 8); // &#xxxxx; x 8 }); }); diff --git a/packages/core/tests/render.test.js b/packages/core/tests/render.test.js index 96c490f6998c6..2e2491be30588 100644 --- a/packages/core/tests/render.test.js +++ b/packages/core/tests/render.test.js @@ -15,10 +15,7 @@ describe("Test splitWrappedText", () => { }); it("should split a two-word string across lines", () => { - expect(splitWrappedText("hello world", 10, 25)).toEqual([ - "hello", - "world", - ]); + expect(splitWrappedText("hello world", 10, 25)).toEqual(["hello", "world"]); }); it("should split a word wider than maxWidth", () => { @@ -76,7 +73,6 @@ describe("Test countWrappedLines", () => { it("trailing spaces should not cause line breaks", () => { expect(countWrappedLines("hi hi ", 10, 8, 10)).toBe(2); }); - }); describe("Test renderError", () => { diff --git a/packages/core/tests/renderGistCard.test.js b/packages/core/tests/renderGistCard.test.js index 22fed4d41bd9b..123f01c52a140 100644 --- a/packages/core/tests/renderGistCard.test.js +++ b/packages/core/tests/renderGistCard.test.js @@ -66,7 +66,7 @@ describe("test renderGistCard", () => { expect( document.getElementsByClassName("description")[0].children[1].textContent, - ).toBe("English-language pangram—a sentence that contains all"); + ).toBe("English-language pangram—a sentence that contains all of"); }); it("should respect browser_rendering=true", () => { diff --git a/packages/core/tests/renderRepoCard.test.js b/packages/core/tests/renderRepoCard.test.js index 393531c7a84f4..04fd8557fb6ea 100644 --- a/packages/core/tests/renderRepoCard.test.js +++ b/packages/core/tests/renderRepoCard.test.js @@ -29,7 +29,8 @@ describe("Test renderRepoCard", () => { expect(header).toHaveTextContent("convoychat"); expect(header).not.toHaveTextContent("anuraghazra"); expect(document.getElementsByClassName("description")[0]).toHaveTextContent( - "Help us take over the world! React + TS + GraphQL Chat App", + // no space between "Chat" and "App" because there's a line break there + "Help us take over the world! React + TS + GraphQL ChatApp", ); expect(queryByTestId(document.body, "stargazers")).toHaveTextContent("38k"); expect(queryByTestId(document.body, "forkcount")).toHaveTextContent("100"); @@ -75,7 +76,7 @@ describe("Test renderRepoCard", () => { expect( document.getElementsByClassName("description")[0].children[1].textContent, - ).toBe("English-language pangram—a sentence that contains all"); + ).toBe("English-language pangram—a sentence that contains all of"); // Should not trim document.body.innerHTML = renderRepoCard({ From dc96ebda8d3ea355be9be76b2bf3ce37350dfd7b Mon Sep 17 00:00:00 2001 From: martin-mfg <2026226+martin-mfg@users.noreply.github.com> Date: Fri, 1 May 2026 14:20:20 +0200 Subject: [PATCH 7/8] update numbers in 'measureText' --- .../__snapshots__/gist.test.js.snap | 20 +++---- .../__snapshots__/pin.test.js.snap | 8 +-- packages/core/src/common/render.js | 54 ++++++++++++------- packages/core/tests/fmt.test.js | 2 +- packages/core/tests/render.test.js | 4 +- packages/core/tests/renderStatsCard.test.js | 2 +- 6 files changed, 52 insertions(+), 38 deletions(-) diff --git a/apps/backend/tests/public-instance/__snapshots__/gist.test.js.snap b/apps/backend/tests/public-instance/__snapshots__/gist.test.js.snap index 447bddb3b5b0c..57e28a9205e46 100644 --- a/apps/backend/tests/public-instance/__snapshots__/gist.test.js.snap +++ b/apps/backend/tests/public-instance/__snapshots__/gist.test.js.snap @@ -30,7 +30,7 @@ exports[`Test /api/gist contract > should match the private missing-id response exports[`Test /api/gist contract > should match the public happy-path response snapshot 1`] = ` { - "content": " + "content": "