fix: constrain clawtributors avatar rendering

This commit is contained in:
masonxhuang
2026-05-06 19:32:42 +08:00
parent bb25e48972
commit 7babc90d4e
3 changed files with 221 additions and 43 deletions

View File

@@ -0,0 +1,113 @@
import type { Entry } from "./update-clawtributors.types.js";
export type RenderableClawtributorEntry = Pick<Entry, "display" | "html_url" | "avatar_url">;
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 `<a href="${escapeHtmlAttribute(entry.html_url)}"><img src="${escapeHtmlAttribute(entry.avatar_url)}" width="${size}" height="${size}" alt="${label}" title="${label}"/></a>`;
}
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 = /<a href="([^"]+)"><img src="([^"]+)"[^>]*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 = /<img src="([^"]+)"[^>]*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, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
function decodeHtmlAttribute(value: string): string {
return value
.replace(/&quot;/g, '"')
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&amp;/g, "&");
}
function fallbackHref(value: string): string {
const encoded = encodeURIComponent(value.trim());
return encoded ? `https://github.com/search?q=${encoded}` : "https://github.com";
}

View File

@@ -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 = /<a href="([^"]+)"><img src="([^"]+)"[^>]*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 = /<img src="([^"]+)"[^>]*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[] {

View File

@@ -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: "<!-- clawtributors:start -->",
endMarker: "<!-- clawtributors:end -->",
},
);
expect(block).toContain('width="48"');
expect(block).toContain('height="48"');
expect(block).toContain(
'<a href="https://github.com/18-RAJAT"><img src="https://avatars.githubusercontent.com/u/78920780?v=4&amp;s=48" width="48" height="48" alt="Rajat Joshi" title="Rajat Joshi"/></a>',
);
});
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: "<!-- clawtributors:start -->",
endMarker: "<!-- clawtributors:end -->",
});
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: "<!-- clawtributors:start -->",
endMarker: "<!-- clawtributors:end -->",
});
expect(parseRenderedClawtributorEntries(block)).toHaveLength(entries.length);
});
});