From 7babc90d4e20e45c6f3732053364acdcd392eb04 Mon Sep 17 00:00:00 2001 From: masonxhuang Date: Wed, 6 May 2026 19:32:42 +0800 Subject: [PATCH] fix: constrain clawtributors avatar rendering --- scripts/update-clawtributors-render.ts | 113 ++++++++++++++++++ scripts/update-clawtributors.ts | 54 ++------- .../update-clawtributors-render.test.ts | 97 +++++++++++++++ 3 files changed, 221 insertions(+), 43 deletions(-) create mode 100644 scripts/update-clawtributors-render.ts create mode 100644 test/scripts/update-clawtributors-render.test.ts diff --git a/scripts/update-clawtributors-render.ts b/scripts/update-clawtributors-render.ts new file mode 100644 index 00000000000..7c906377dff --- /dev/null +++ b/scripts/update-clawtributors-render.ts @@ -0,0 +1,113 @@ +import type { Entry } from "./update-clawtributors.types.js"; + +export type RenderableClawtributorEntry = Pick; + +export type RenderClawtributorsBlockOptions = { + perLine: number; + avatarSize: number; + startMarker: string; + endMarker: string; +}; + +export function renderClawtributorsBlock( + entries: readonly RenderableClawtributorEntry[], + options: RenderClawtributorsBlockOptions, +): string { + const lines = renderClawtributorsLines(entries, options.perLine, options.avatarSize); + const block = `${options.startMarker}\n${lines.join("\n")}\n${options.endMarker}`; + const renderedCount = parseRenderedClawtributorEntries(block).length; + if (renderedCount !== entries.length) { + throw new Error( + `Rendered clawtributors count mismatch: expected ${entries.length}, got ${renderedCount}`, + ); + } + return block; +} + +export function renderClawtributorsLines( + entries: readonly RenderableClawtributorEntry[], + perLine: number, + avatarSize: number, +): string[] { + const lines: string[] = []; + for (let i = 0; i < entries.length; i += perLine) { + const chunk = entries.slice(i, i + perLine); + const parts = chunk.map((entry) => renderClawtributorEntry(entry, avatarSize)); + lines.push(parts.join(" ")); + } + return lines; +} + +export function renderClawtributorEntry( + entry: RenderableClawtributorEntry, + avatarSize: number, +): string { + const size = String(avatarSize); + const label = escapeHtmlAttribute(entry.display); + return `${label}`; +} + +export function parseRenderedClawtributorEntries( + content: string, +): Array<{ display: string; html_url: string; avatar_url: string }> { + const entries: Array<{ display: string; html_url: string; avatar_url: string }> = []; + const markdown = /\[!\[([^\]]+)\]\(([^)]+)\)\]\(([^)]+)\)/g; + for (const match of content.matchAll(markdown)) { + const [, alt, src, href] = match; + if (!href || !src || !alt) { + continue; + } + entries.push({ html_url: href, avatar_url: src, display: alt.replace(/\\([\\[\]])/g, "$1") }); + } + const linked = /]*alt="([^"]+)"[^>]*>/g; + for (const match of content.matchAll(linked)) { + const [, href, src, alt] = match; + if (!href || !src || !alt) { + continue; + } + entries.push({ + html_url: decodeHtmlAttribute(href), + avatar_url: decodeHtmlAttribute(src), + display: decodeHtmlAttribute(alt), + }); + } + const standalone = /]*alt="([^"]+)"[^>]*>/g; + for (const match of content.matchAll(standalone)) { + const [, src, alt] = match; + if (!src || !alt) { + continue; + } + const decodedSrc = decodeHtmlAttribute(src); + const decodedAlt = decodeHtmlAttribute(alt); + if (entries.some((entry) => entry.display === decodedAlt && entry.avatar_url === decodedSrc)) { + continue; + } + entries.push({ + html_url: fallbackHref(decodedAlt), + avatar_url: decodedSrc, + display: decodedAlt, + }); + } + return entries; +} + +function escapeHtmlAttribute(value: string): string { + return value + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(//g, ">"); +} + +function decodeHtmlAttribute(value: string): string { + return value + .replace(/"/g, '"') + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/&/g, "&"); +} + +function fallbackHref(value: string): string { + const encoded = encodeURIComponent(value.trim()); + return encoded ? `https://github.com/search?q=${encoded}` : "https://github.com"; +} diff --git a/scripts/update-clawtributors.ts b/scripts/update-clawtributors.ts index fff49f98074..d6a0fc72d4e 100644 --- a/scripts/update-clawtributors.ts +++ b/scripts/update-clawtributors.ts @@ -1,6 +1,10 @@ import { execFileSync, execSync } from "node:child_process"; import { readFileSync, writeFileSync } from "node:fs"; import { resolve } from "node:path"; +import { + parseRenderedClawtributorEntries, + renderClawtributorsBlock, +} from "./update-clawtributors-render.js"; import type { ApiContributor, Entry, MapConfig, User } from "./update-clawtributors.types.js"; const REPO = "openclaw/openclaw"; @@ -290,16 +294,12 @@ visibleEntries.sort((a, b) => { return a.display.localeCompare(b.display); }); -const markdownLines: string[] = []; -for (let i = 0; i < visibleEntries.length; i += PER_LINE) { - const chunk = visibleEntries.slice(i, i + PER_LINE); - const parts = chunk.map((entry) => { - return `[![${escapeMarkdownLabel(entry.display)}](${entry.avatar_url})](${entry.html_url})`; - }); - markdownLines.push(parts.join(" ")); -} - -const block = `${CLAWTRIBUTORS_START}\n${markdownLines.join("\n")}\n${CLAWTRIBUTORS_END}`; +const block = renderClawtributorsBlock(visibleEntries, { + perLine: PER_LINE, + avatarSize: AVATAR_SIZE, + startMarker: CLAWTRIBUTORS_START, + endMarker: CLAWTRIBUTORS_END, +}); const hiddenBlock = buildHiddenReadmeBlock(entries, visibleEntries); const hiddenRange = findHiddenReadmeRange(currentReadme); const readmeWithoutMeta = hiddenRange @@ -674,10 +674,6 @@ function normalizeIdentifier(value: string): string { return value.toLowerCase().replace(/[^a-z0-9]/g, ""); } -function escapeMarkdownLabel(value: string): string { - return value.replace(/([\\[\]])/g, "\\$1"); -} - function parseReadmeEntries( content: string, ): Array<{ display: string; html_url: string; avatar_url: string }> { @@ -686,35 +682,7 @@ function parseReadmeEntries( return []; } const block = content.slice(range.start, range.end); - const entries: Array<{ display: string; html_url: string; avatar_url: string }> = []; - const markdown = /\[!\[([^\]]+)\]\(([^)]+)\)\]\(([^)]+)\)/g; - for (const match of block.matchAll(markdown)) { - const [, alt, src, href] = match; - if (!href || !src || !alt) { - continue; - } - entries.push({ html_url: href, avatar_url: src, display: alt.replace(/\\([\\[\]])/g, "$1") }); - } - const linked = /]*alt="([^"]+)"[^>]*>/g; - for (const match of block.matchAll(linked)) { - const [, href, src, alt] = match; - if (!href || !src || !alt) { - continue; - } - entries.push({ html_url: href, avatar_url: src, display: alt }); - } - const standalone = /]*alt="([^"]+)"[^>]*>/g; - for (const match of block.matchAll(standalone)) { - const [, src, alt] = match; - if (!src || !alt) { - continue; - } - if (entries.some((entry) => entry.display === alt && entry.avatar_url === src)) { - continue; - } - entries.push({ html_url: fallbackHref(alt), avatar_url: src, display: alt }); - } - return entries; + return parseRenderedClawtributorEntries(block); } function parseHiddenReadmeLogins(content: string): string[] { diff --git a/test/scripts/update-clawtributors-render.test.ts b/test/scripts/update-clawtributors-render.test.ts new file mode 100644 index 00000000000..fbce7b6bc53 --- /dev/null +++ b/test/scripts/update-clawtributors-render.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; +import { + parseRenderedClawtributorEntries, + renderClawtributorsBlock, +} from "../../scripts/update-clawtributors-render.js"; + +describe("scripts/update-clawtributors-render", () => { + it("renders explicit avatar dimensions for every entry", () => { + const block = renderClawtributorsBlock( + [ + { + display: "Andy", + html_url: "https://github.com/andyk-ms", + avatar_url: "https://avatars.githubusercontent.com/u/91510251?v=4&s=48", + }, + { + display: "Rajat Joshi", + html_url: "https://github.com/18-RAJAT", + avatar_url: "https://avatars.githubusercontent.com/u/78920780?v=4&s=48", + }, + ], + { + perLine: 10, + avatarSize: 48, + startMarker: "", + endMarker: "", + }, + ); + + expect(block).toContain('width="48"'); + expect(block).toContain('height="48"'); + expect(block).toContain( + 'Rajat Joshi', + ); + }); + + it("round-trips rendered html entries without losing contributors", () => { + const entries = [ + { + display: "Andy", + html_url: "https://github.com/andyk-ms", + avatar_url: "https://avatars.githubusercontent.com/u/91510251?v=4&s=48", + }, + { + display: "Rajat Joshi", + html_url: "https://github.com/18-RAJAT", + avatar_url: "https://avatars.githubusercontent.com/u/78920780?v=4&s=48", + }, + { + display: 'Tom & "Jerry"', + html_url: "https://github.com/example", + avatar_url: "https://avatars.githubusercontent.com/u/1?v=4&s=48", + }, + ]; + + const block = renderClawtributorsBlock(entries, { + perLine: 2, + avatarSize: 48, + startMarker: "", + endMarker: "", + }); + const parsed = parseRenderedClawtributorEntries(block); + + expect(parsed).toEqual(entries); + }); + + it("parses legacy markdown entries for seed compatibility", () => { + const parsed = parseRenderedClawtributorEntries( + "[![Rajat Joshi](https://avatars.githubusercontent.com/u/78920780?v=4&s=48)](https://github.com/18-RAJAT)", + ); + + expect(parsed).toEqual([ + { + display: "Rajat Joshi", + html_url: "https://github.com/18-RAJAT", + avatar_url: "https://avatars.githubusercontent.com/u/78920780?v=4&s=48", + }, + ]); + }); + + it("keeps rendered contributor count aligned with the input set", () => { + const entries = Array.from({ length: 3 }, (_, index) => ({ + display: `Contributor ${index + 1}`, + html_url: `https://github.com/example-${index + 1}`, + avatar_url: `https://avatars.githubusercontent.com/u/${index + 1}?v=4&s=48`, + })); + + const block = renderClawtributorsBlock(entries, { + perLine: 2, + avatarSize: 48, + startMarker: "", + endMarker: "", + }); + + expect(parseRenderedClawtributorEntries(block)).toHaveLength(entries.length); + }); +});