Compare commits

..
Author SHA1 Message Date
Val Alexander f7c72bb56c docs: add logo replacement design spec 2026-04-21 13:35:18 -05:00
58 changed files with 408 additions and 516 deletions
+2 -5
View File
@@ -10,9 +10,7 @@ dist-ssr
*.local
.vercel
count.txt
.env*
!.env.local.example
!.env.example
.env
.nitro
.tanstack
.wrangler
@@ -29,5 +27,4 @@ test-results
convex/_generated/
skills-lock.json
*/skills/*
skills/*
.codex/*
skills/*
+1 -31
View File
@@ -1,6 +1,5 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "clawhub",
@@ -26,7 +25,6 @@
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@resvg/resvg-wasm": "^2.6.2",
"@shikijs/rehype": "^4.0.2",
"@tailwindcss/vite": "^4.2.2",
"@tanstack/react-devtools": "0.10.0",
"@tanstack/react-router": "1.168.1",
@@ -45,13 +43,12 @@
"ignore": "^7.0.5",
"lucide-react": "^0.577.0",
"monaco-editor": "^0.55.1",
"next": "^16.2.3",
"next-themes": "^0.4.6",
"nitro": "3.0.260311-beta",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-markdown": "^10.1.0",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"semver": "^7.7.4",
"shiki": "^4.0.2",
@@ -59,7 +56,6 @@
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.2",
"tw-animate-css": "^1.4.0",
"unist-util-visit": "^5.1.0",
"vite-tsconfig-paths": "^6.1.1",
"yaml": "^2.8.3",
"zod": "^4.3.6",
@@ -595,8 +591,6 @@
"@shikijs/primitive": ["@shikijs/primitive@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw=="],
"@shikijs/rehype": ["@shikijs/rehype@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@types/hast": "^3.0.4", "hast-util-to-string": "^3.0.1", "shiki": "4.0.2", "unified": "^11.0.5", "unist-util-visit": "^5.1.0" } }, "sha512-cmPlKLD8JeojasNFoY64162ScpEdEdQUMuVodPCrv1nx1z3bjmGwoKWDruQWa/ejSznImlaeB0Ty6Q3zPaVQAA=="],
"@shikijs/themes": ["@shikijs/themes@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA=="],
"@shikijs/types": ["@shikijs/types@4.0.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg=="],
@@ -967,26 +961,12 @@
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"hast-util-from-parse5": ["hast-util-from-parse5@8.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "hastscript": "^9.0.0", "property-information": "^7.0.0", "vfile": "^6.0.0", "vfile-location": "^5.0.0", "web-namespaces": "^2.0.0" } }, "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg=="],
"hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="],
"hast-util-raw": ["hast-util-raw@9.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "hast-util-from-parse5": "^8.0.0", "hast-util-to-parse5": "^8.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "parse5": "^7.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw=="],
"hast-util-sanitize": ["hast-util-sanitize@5.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "unist-util-position": "^5.0.0" } }, "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg=="],
"hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="],
"hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="],
"hast-util-to-parse5": ["hast-util-to-parse5@8.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA=="],
"hast-util-to-string": ["hast-util-to-string@3.0.1", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A=="],
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
"hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="],
"hookable": ["hookable@6.1.0", "", {}, "sha512-ZoKZSJgu8voGK2geJS+6YtYjvIzu9AOM/KZXsBxr83uhLL++e9pEv/dlgwgy3dvHg06kTz6JOh1hk3C8Ceiymw=="],
"html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="],
@@ -1303,10 +1283,6 @@
"regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="],
"rehype-raw": ["rehype-raw@7.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-raw": "^9.0.0", "vfile": "^6.0.0" } }, "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww=="],
"rehype-sanitize": ["rehype-sanitize@6.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-sanitize": "^5.0.0" } }, "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg=="],
"remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="],
"remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="],
@@ -1465,8 +1441,6 @@
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
"vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="],
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
"vite": ["vite@8.0.5", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.12", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-nmu43Qvq9UopTRfMx2jOYW5l16pb3iDC1JH6yMuPkpVbzK0k+L7dfsEDH4jRgYFmsg0sTAqkojoZgzLMlwHsCQ=="],
@@ -1479,8 +1453,6 @@
"w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="],
"web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],
"webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="],
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
@@ -1623,8 +1595,6 @@
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"hast-util-raw/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
"htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
+7 -4
View File
@@ -1,7 +1,10 @@
import { internal } from "./_generated/api";
import { api, internal } from "./_generated/api";
// Asserts that the internal-only download counters remain internal-only.
// Public exposure is prevented at runtime by `internalMutation`; this file
// just pins the public references that *should* exist.
void internal.downloads.recordDownloadInternal;
void internal.soulDownloads.incrementInternal;
// @ts-expect-error download counters must not be publicly callable
void api.downloads.increment;
// @ts-expect-error soul download counters must not be publicly callable
void api.soulDownloads.increment;
+1 -1
View File
@@ -229,7 +229,7 @@ export async function applyCommentScamResultInternalHandler(
ok: true,
shouldBan,
banned: !banResult.alreadyBanned,
alreadyBanned: banResult.alreadyBanned,
alreadyBanned: Boolean(banResult.alreadyBanned),
protectedRole: false,
wouldBan: false,
};
+1 -1
View File
@@ -509,7 +509,7 @@ describe("comments mutations", () => {
if (id === "skills:1") {
return { _id: "skills:1", softDeletedAt: undefined, moderationStatus: "active" };
}
if (id.startsWith("comments:reported-")) return reportedComment;
if (String(id).startsWith("comments:reported-")) return reportedComment;
if (id === "skills:active") {
return { _id: "skills:active", softDeletedAt: undefined, moderationStatus: "active" };
}
+1 -1
View File
@@ -704,7 +704,7 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request)
summary: mod.summary,
engineVersion: mod.engineVersion,
updatedAt: mod.updatedAt,
evidence: sanitizeEvidence(mod.evidence, isOwner || isStaff),
evidence: sanitizeEvidence(mod.evidence, Boolean(isOwner || isStaff)),
legacyReason: isOwner || isStaff ? mod.reason : null,
}
: null,
+2 -2
View File
@@ -1,9 +1,9 @@
import type { Scheduler } from "convex/server";
export function scheduleNextBatchIfNeeded(
export function scheduleNextBatchIfNeeded<TArgs extends { cursor?: string }>(
scheduler: Scheduler,
fn: unknown,
args: { cursor?: string } & Record<string, unknown>,
args: TArgs,
isDone: boolean,
continueCursor: string | null,
) {
+4 -4
View File
@@ -279,8 +279,8 @@ function decodeJwt(jwt: string) {
const parts = jwt.trim().split(".");
if (parts.length !== 3) throw new Error("Invalid GitHub OIDC token format");
const [encodedHeader, encodedPayload, encodedSignature] = parts;
const header = parseJsonSegment(encodedHeader, "header") as JwtHeader;
const payload = parseJsonSegment(encodedPayload, "payload") as JwtPayload;
const header = parseJsonSegment<JwtHeader>(encodedHeader, "header");
const payload = parseJsonSegment<JwtPayload>(encodedPayload, "payload");
return {
header,
payload,
@@ -289,9 +289,9 @@ function decodeJwt(jwt: string) {
};
}
function parseJsonSegment(segment: string, label: string): unknown {
function parseJsonSegment<T>(segment: string, label: string) {
try {
return JSON.parse(new TextDecoder().decode(base64UrlToBytes(segment)));
return JSON.parse(new TextDecoder().decode(base64UrlToBytes(segment))) as T;
} catch {
throw new Error(`Invalid GitHub OIDC ${label}`);
}
+6 -6
View File
@@ -144,13 +144,13 @@ export async function deletePackageSearchDigests(
}
}
function hasDigestChanged(
existing: Record<string, unknown>,
fields: Record<string, unknown>,
): boolean {
function hasDigestChanged<
TExisting extends Record<string, unknown>,
TFields extends Record<string, unknown>,
>(existing: TExisting, fields: TFields): boolean {
for (const key of Object.keys(fields)) {
const oldValue = existing[key];
const newValue = fields[key];
const oldValue = (existing as Record<string, unknown>)[key];
const newValue = (fields as Record<string, unknown>)[key];
if (oldValue === newValue) continue;
if (JSON.stringify(oldValue) !== JSON.stringify(newValue)) return true;
}
+4 -4
View File
@@ -372,7 +372,7 @@ function parseDependencyDeclarations(input: unknown): Array<{
version?: string;
url?: string;
repository?: string;
} = { name: obj.name.trim(), type: depType };
} = { name: String(obj.name).trim(), type: depType };
if (typeof obj.version === "string") decl.version = obj.version.trim();
if (typeof obj.url === "string") decl.url = obj.url.trim();
if (typeof obj.repository === "string") decl.repository = obj.repository.trim();
@@ -432,7 +432,7 @@ function parseFrontmatterLevelDeclarations(
// Parse primaryEnv from top-level frontmatter
if (typeof frontmatter.primaryEnv === "string") {
metadata.primaryEnv = frontmatter.primaryEnv.trim();
metadata.primaryEnv = String(frontmatter.primaryEnv).trim();
}
const envVars = parseEnvVarDeclarations(frontmatter.env);
@@ -441,13 +441,13 @@ function parseFrontmatterLevelDeclarations(
const dependencies = parseDependencyDeclarations(frontmatter.dependencies);
if (dependencies.length > 0) metadata.dependencies = dependencies;
if (typeof frontmatter.author === "string") metadata.author = frontmatter.author.trim();
if (typeof frontmatter.author === "string") metadata.author = String(frontmatter.author).trim();
const links = parseSkillLinks(frontmatter.links);
if (links) metadata.links = links;
if (typeof frontmatter.homepage === "string") {
metadata.homepage = frontmatter.homepage.trim();
metadata.homepage = String(frontmatter.homepage).trim();
}
return Object.keys(metadata).length > 0
+1 -1
View File
@@ -191,7 +191,7 @@ describe("skills.getPendingScanSkillsInternal", () => {
const versionId = skill.latestVersionId as string;
return [
versionId,
{ _id: versionId, sha256hash: `${versionId.slice(-8)}${"f".repeat(56)}` },
{ _id: versionId, sha256hash: `${String(versionId).slice(-8)}${"f".repeat(56)}` },
];
}),
);
+4 -4
View File
@@ -653,7 +653,7 @@ describe("skills anti-spam guards", () => {
const runAfter = vi.fn();
const db = {
get: vi.fn(async (tableOrId: string, maybeId?: string) => {
const key = maybeId ?? tableOrId;
const key = String(maybeId ?? tableOrId);
if (storedSkills.has(key)) return storedSkills.get(key);
if (key === "users:owner") {
return {
@@ -759,7 +759,7 @@ describe("skills anti-spam guards", () => {
patch,
insert,
normalizeId: vi.fn((tableName: string, id: string) =>
id.startsWith(`${tableName}:`) ? id : null,
String(id).startsWith(`${tableName}:`) ? id : null,
),
};
@@ -840,7 +840,7 @@ describe("skills anti-spam guards", () => {
});
const db = {
get: vi.fn(async (tableOrId: string, maybeId?: string) => {
const key = maybeId ?? tableOrId;
const key = String(maybeId ?? tableOrId);
if (storedSkills.has(key)) return storedSkills.get(key);
if (key === "users:owner") {
return {
@@ -948,7 +948,7 @@ describe("skills anti-spam guards", () => {
patch,
insert,
normalizeId: vi.fn((tableName: string, id: string) =>
id.startsWith(`${tableName}:`) ? id : null,
String(id).startsWith(`${tableName}:`) ? id : null,
),
};
+5 -2
View File
@@ -4794,6 +4794,8 @@ export const escalateByVtInternal = internalMutation({
slug: skill.slug,
});
}
return { ok: true, skillId: version.skillId, versionId: version._id };
},
});
@@ -6262,8 +6264,9 @@ export const insertVersion = internalMutation({
// Trusted publishers (and moderators/admins) bypass auto-hide for pending scans.
// Keep moderationReason as pending.scan so the VT poller keeps working.
const isTrustedPublisher =
user.trustedPublisher || user.role === "admin" || user.role === "moderator";
const isTrustedPublisher = Boolean(
user.trustedPublisher || user.role === "admin" || user.role === "moderator",
);
const staticSnapshot = buildModerationSnapshot({
staticScan: args.staticScan,
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 394 KiB

@@ -0,0 +1,182 @@
# Logo Replacement Design
Date: 2026-04-21
Topic: Comprehensive logo replacement using the provided lobster artwork
## Summary
Replace every current application logo surface with the user-provided lobster artwork while preserving the existing UI layout and copy. This includes in-app logo images, favicon and install icon assets, and manifest/head wiring. The existing wide social preview image `public/og.png` remains unchanged. Instead, `public/og-logo.png` is included in the replacement asset pack as a standalone logo export and is not wired into site metadata.
## Goals
- Replace all current logo imagery with the provided lobster art.
- Preserve existing layout structure in header, mobile navigation, and hero content.
- Provide dedicated asset files for browser, install, and app surfaces rather than relying on one large source image everywhere.
- Keep runtime references stable where possible by replacing existing filenames in place.
- Improve browser/device logo behavior by adding standard favicon and touch icon variants.
## Non-Goals
- No header, navigation, or hero layout redesign.
- No typography or copy changes to the `ClawHub` wordmark text.
- No change to the existing social preview card asset `public/og.png`.
- No full vector redraw of the lobster artwork from scratch.
## Scope
### In Scope
- Replace:
- `public/clawd-logo.png`
- `public/clawd-mark.png`
- `public/logo192.png`
- `public/logo512.png`
- `public/favicon.ico`
- Add or update:
- `public/favicon-16x16.png`
- `public/favicon-32x32.png`
- `public/apple-touch-icon.png`
- `public/logo.jpg`
- `public/logo.svg`
- `public/og-logo.png`
- Update runtime/browser metadata:
- root document link tags in `src/routes/__root.tsx`
- `public/manifest.json`
### Out of Scope
- `public/og.png`
- Any route-level social metadata currently using `og.png`
- Any non-logo artwork or unrelated illustration assets
## Current State
- The app currently references `public/clawd-logo.png` in the desktop and mobile header.
- The homepage hero references `public/clawd-mark.png`.
- The root document exposes `/favicon.ico`, `/logo192.png`, and `/manifest.json`.
- The web app manifest references `favicon.ico`, `logo192.png`, and `logo512.png`.
- The site-wide OG metadata still references `og.png`.
## Recommended Approach
Use the provided lobster image as the master artwork and derive a small asset pack tailored to each output surface.
Why this approach:
- It satisfies the request to replace the logo everywhere it appears.
- It avoids visual degradation from blindly reusing one oversized raster in tiny favicon contexts.
- It minimizes application code changes by preserving the established filenames used by the UI.
## Asset Plan
### Master Asset
Create one high-resolution square source derived from the attached lobster artwork. This will be the basis for all exported formats.
### Replacement Assets
- `clawd-logo.png`
- High-resolution square PNG used by header/mobile brand image references.
- `clawd-mark.png`
- High-resolution square PNG used by hero/logo-only surfaces.
- `logo192.png`
- 192×192 install icon.
- `logo512.png`
- 512×512 install icon.
- `favicon.ico`
- Multi-size favicon generated from the same master for browser tab use.
- `favicon-16x16.png`
- Explicit raster favicon for browsers that prefer PNG.
- `favicon-32x32.png`
- Explicit raster favicon for higher-density tab/bookmark use.
- `apple-touch-icon.png`
- 180×180 touch icon for iOS home screen usage.
- `logo.jpg`
- Flattened JPEG export for contexts where a non-transparent logo file is useful.
- `logo.svg`
- SVG wrapper asset that embeds the logo image in an SVG container so an SVG logo file exists for downstream usage without falsely claiming the art is natively vector.
- `og-logo.png`
- Logo-focused branded raster asset retained separately from the existing wide social card `og.png`.
## Runtime Wiring
### Application UI
- Keep existing JSX references to `clawd-logo.png` and `clawd-mark.png` unless a clearer dedicated asset path becomes necessary.
- Do not replace image elements with text or SVG components.
### Root Head Tags
Update `src/routes/__root.tsx` to use dedicated icon assets:
- `rel="icon"` should include PNG favicon variants in addition to the ICO.
- `rel="apple-touch-icon"` should point to `apple-touch-icon.png`.
- `rel="manifest"` remains `manifest.json`.
- OG/Twitter metadata remains wired to `og.png` and is not changed.
### Web App Manifest
Update `public/manifest.json` so install surfaces reference the replacement icon assets. Keep the manifest conservative and omit maskable-specific `purpose` values for this change.
## Data Flow
1. Start from the provided lobster artwork.
2. Export optimized raster variants for each target size.
3. Replace or add files in `public/`.
4. Update root document links and manifest entries.
5. Build the app and verify the logo surfaces still render without layout regressions.
## Error Handling And Risks
### Small-Size Legibility
Risk: the artwork is detailed and may lose clarity at favicon sizes.
Mitigation:
- Generate dedicated 16×16 and 32×32 outputs instead of relying only on browser downscaling.
- Prefer the ICO plus PNG favicon set to maximize compatibility.
### Raster-As-Vector Expectations
Risk: a pure SVG redraw would be time-consuming and subjective.
Mitigation:
- Provide `logo.svg` as an SVG container asset, while using raster files for browser/runtime surfaces that need visual fidelity.
### Unintended Social Preview Changes
Risk: a broad asset refresh accidentally changes OG behavior.
Mitigation:
- Explicitly leave `og.png` and its metadata references untouched.
- Treat `og-logo.png` as a separate logo asset only.
## Testing And Verification
- Confirm the generated files exist in `public/` with expected dimensions.
- Run the production build to ensure asset references still resolve.
- Spot-check the following surfaces:
- desktop header brand image
- mobile navigation brand image
- homepage hero lobster image
- browser favicon and touch icon wiring
- manifest icon references
- Verify that `og.png` remains unchanged and the site metadata still references it.
## Implementation Notes
- Use minimal code churn: replace files in place where existing paths are already correct.
- Add new icon files only where they improve browser/device handling.
- Keep the change tightly scoped to branding assets and metadata.
## Acceptance Criteria
- Every current application logo surface displays the provided lobster artwork instead of the previous brand image.
- Favicon, touch icon, and install icons resolve to replacement assets.
- Header/mobile/hero layout remains unchanged.
- `og.png` is not modified.
- `og-logo.png` exists as part of the updated asset pack.
- The app builds successfully after the change.
+1 -4
View File
@@ -52,7 +52,6 @@
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@resvg/resvg-wasm": "^2.6.2",
"@shikijs/rehype": "^4.0.2",
"@tailwindcss/vite": "^4.2.2",
"@tanstack/react-devtools": "0.10.0",
"@tanstack/react-router": "1.168.1",
@@ -71,13 +70,12 @@
"ignore": "^7.0.5",
"lucide-react": "^0.577.0",
"monaco-editor": "^0.55.1",
"next": "^16.2.3",
"next-themes": "^0.4.6",
"nitro": "3.0.260311-beta",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-markdown": "^10.1.0",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"semver": "^7.7.4",
"shiki": "^4.0.2",
@@ -85,7 +83,6 @@
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.2",
"tw-animate-css": "^1.4.0",
"unist-util-visit": "^5.1.0",
"vite-tsconfig-paths": "^6.1.1",
"yaml": "^2.8.3",
"zod": "^4.3.6"
+1 -1
View File
@@ -178,7 +178,7 @@ function addConfigRoots(
const extraDirs = config.skills?.load?.extraDirs ?? [];
for (const dir of extraDirs) {
const resolved = resolveUserPath(dir);
const resolved = resolveUserPath(String(dir));
if (!resolved) continue;
const label = `${prefix}Extra: ${basename(resolved) || resolved}`;
pushRoot(roots, labels, resolved, label);
+1 -1
View File
@@ -22,7 +22,7 @@ export async function cmdLoginFlow(
fail("Token required (use --token or remove --no-browser)");
}
const label = (options.label ?? "CLI token").trim() || "CLI token";
const label = String(options.label ?? "CLI token").trim() || "CLI token";
const receiver = await startLoopbackAuthServer();
const discovery = await discoverRegistryFromSite(opts.site).catch(() => null);
const authBase = discovery?.authBase?.trim() || opts.site;
+2 -2
View File
@@ -54,7 +54,7 @@ export async function cmdDeleteSkill(
if (!options.yes) {
if (!allowPrompt) fail("Pass --yes (no input)");
const ok = await promptConfirm(formatPrompt(labels, slug));
if (!ok) return undefined;
if (!ok) return;
}
const token = await requireAuthToken();
@@ -88,7 +88,7 @@ export async function cmdUndeleteSkill(
if (!options.yes) {
if (!allowPrompt) fail("Pass --yes (no input)");
const ok = await promptConfirm(formatPrompt(labels, slug));
if (!ok) return undefined;
if (!ok) return;
}
const token = await requireAuthToken();
@@ -149,7 +149,7 @@ describe("github publish source helpers", () => {
const workdir = await makeTmpDir();
const restoreFetch =
input.includes("/tree/") || input.includes("/blob/")
? mockGitHubCommitLookup([(expected as { ref?: string }).ref ?? ""])
? mockGitHubCommitLookup([String((expected as { ref?: string }).ref ?? "")])
: null;
try {
await expect(resolveSourceInput(input, { workdir })).resolves.toEqual(expected);
@@ -33,13 +33,13 @@ export async function cmdBanUser(
{ id: options.id, fuzzy: options.fuzzy },
allowPrompt,
);
if (!resolved) return undefined;
if (!resolved) return;
if (!options.yes) {
if (!allowPrompt) fail("Pass --yes (no input)");
const ok = await promptConfirm(
`Ban ${resolved.label}? (requires moderator/admin; deletes owned skills)`,
);
if (!ok) return undefined;
if (!ok) return;
}
const spinner = createSpinner(`Banning ${resolved.label}`);
@@ -90,11 +90,11 @@ export async function cmdSetRole(
{ id: options.id, fuzzy: options.fuzzy },
allowPrompt,
);
if (!resolved) return undefined;
if (!resolved) return;
if (!options.yes) {
if (!allowPrompt) fail("Pass --yes (no input)");
const ok = await promptConfirm(`Set role for ${resolved.label} to ${role}? (admin only)`);
if (!ok) return undefined;
if (!ok) return;
}
const spinner = createSpinner(`Setting role for ${resolved.label}`);
@@ -218,7 +218,7 @@ function formatUserList(users: UserSearchItem[]) {
function normalizeRole(value: string) {
const role = value.trim().toLowerCase();
if (role === "user" || role === "moderator" || role === "admin") return role;
return fail("Role must be user|moderator|admin");
fail("Role must be user|moderator|admin");
}
function formatDeletedSkills(count: number) {
@@ -44,7 +44,7 @@ export async function cmdRenameSkill(
inputAllowed,
`Rename ${slug} to ${newSlug}? Old slug will redirect.`,
);
if (!confirmed) return undefined;
if (!confirmed) return;
const token = await requireAuthToken();
const registry = await getRegistry(opts, { cache: true });
@@ -86,7 +86,7 @@ export async function cmdMergeSkill(
inputAllowed,
`Merge ${sourceSlug} into ${targetSlug}? Source slug will redirect and stop listing publicly.`,
);
if (!confirmed) return undefined;
if (!confirmed) return;
const token = await requireAuthToken();
const registry = await getRegistry(opts, { cache: true });
@@ -77,7 +77,7 @@ function getPublishPayload() {
function getUploadedFileNames() {
const form = getPublishForm();
return (form.getAll("files") as Array<Blob & { name?: string }>)
.map((file) => file.name ?? "")
.map((file) => String(file.name ?? ""))
.sort();
}
@@ -782,7 +782,7 @@ function detectPackageFamily(
if (explicit) return explicit;
if (fileSet.has("openclaw.plugin.json")) return "code-plugin";
if (fileSet.has("openclaw.bundle.json")) return "bundle-plugin";
return fail("Could not detect package family. Use --family.");
fail("Could not detect package family. Use --family.");
}
function parseTags(value: string) {
@@ -79,7 +79,7 @@ describe("cmdPublish", () => {
expect(payload.acceptLicenseTerms).toBe(true);
expect(payload.tags).toEqual(["latest"]);
const files = publishForm.getAll("files") as Array<Blob & { name?: string }>;
expect(files.map((file) => file.name ?? "").sort()).toEqual(["SKILL.md", "notes.md"]);
expect(files.map((file) => String(file.name ?? "")).sort()).toEqual(["SKILL.md", "notes.md"]);
} finally {
await rm(workdir, { recursive: true, force: true });
}
+1 -1
View File
@@ -478,7 +478,7 @@ function resolveExploreSort(raw?: string): { sort: ExploreSort; apiSort: ApiExpl
if (normalized === "trending") {
return { sort: "trending", apiSort: "trending" };
}
return fail(
fail(
`Invalid sort "${raw}". Use newest, downloads, rating, installs, installsAllTime, or trending.`,
);
}
+1 -1
View File
@@ -18,7 +18,7 @@ export async function cmdStarSkill(
if (!options.yes) {
if (!allowPrompt) fail("Pass --yes (no input)");
const ok = await promptConfirm(`Star ${slug}?`);
if (!ok) return undefined;
if (!ok) return;
}
const token = await requireAuthToken();
@@ -282,7 +282,7 @@ export async function selectToUpload(
required: false,
});
if (isCancel(picked)) fail("Canceled");
const selected = picked.map((key) => valueByKey.get(key)).filter(Boolean) as Candidate[];
const selected = picked.map((key) => valueByKey.get(String(key))).filter(Boolean) as Candidate[];
return selected;
}
@@ -75,7 +75,7 @@ export async function cmdTransferRequest(
inputAllowed,
`Transfer ${slug} to @${toHandle}? Recipient must accept.`,
);
if (!confirmed) return undefined;
if (!confirmed) return;
const token = await requireAuthToken();
const registry = await getRegistry(opts, { cache: true });
@@ -160,7 +160,7 @@ async function runTransferDecision(
inputAllowed,
`${spec.verb} transfer of ${slug}?`,
);
if (!confirmed) return undefined;
if (!confirmed) return;
const token = await requireAuthToken();
const registry = await getRegistry(opts, { cache: true });
+1 -1
View File
@@ -18,7 +18,7 @@ export async function cmdUnstarSkill(
if (!options.yes) {
if (!allowPrompt) fail("Pass --yes (no input)");
const ok = await promptConfirm(`Unstar ${slug}?`);
if (!ok) return undefined;
if (!ok) return;
}
const token = await requireAuthToken();
+2 -2
View File
@@ -40,7 +40,7 @@ export async function promptHidden(prompt: string) {
export async function promptConfirm(prompt: string) {
const answer = await confirm({ message: prompt });
if (isCancel(answer)) return false;
return answer;
return Boolean(answer);
}
export function openInBrowser(url: string) {
@@ -70,7 +70,7 @@ export function openInBrowser(url: string) {
}
export function isInteractive() {
return process.stdout.isTTY && stdin.isTTY;
return Boolean(process.stdout.isTTY && stdin.isTTY);
}
export function createSpinner(text: string) {
+2 -2
View File
@@ -381,7 +381,7 @@ function getRetryDelayMs(attemptError: unknown, random: () => number): number {
cause?: unknown;
error?: unknown;
};
const attemptNumber = Math.max(1, failed.attemptNumber ?? 1);
const attemptNumber = Math.max(1, Number(failed.attemptNumber ?? 1));
const rootError = failed.cause ?? failed.error ?? attemptError;
if (rootError instanceof HttpStatusError && rootError.rateLimit.retryAfterSeconds !== undefined) {
return rootError.rateLimit.retryAfterSeconds * 1000 + jitterMs(RETRY_AFTER_JITTER_MS, random);
@@ -568,7 +568,7 @@ async function fetchJsonFormViaCurl(
await deps.writeFileImpl(filePath, bytes);
formArgs.push("-F", `${key}=@${filePath};filename=${filename}`);
} else {
formArgs.push("-F", `${key}=${value}`);
formArgs.push("-F", `${key}=${String(value)}`);
}
}
+3 -3
View File
@@ -133,9 +133,9 @@ export async function readSkillOrigin(skillFolder: string): Promise<SkillOrigin
}
return {
version: 1,
registry: parsed.registry,
slug: parsed.slug,
installedVersion: parsed.installedVersion,
registry: String(parsed.registry),
slug: String(parsed.slug),
installedVersion: String(parsed.installedVersion),
installedAt: parsed.installedAt,
};
} catch {
+7
View File
@@ -412,6 +412,13 @@ describe("SkillDetailPage", () => {
render(<SkillDetailPage slug="weather" />);
expect(
(
await screen.findAllByText(
/free to use, modify, and redistribute\. no attribution required\./i,
)
).length,
).toBeGreaterThan(0);
expect(
screen.queryByText(/Reports require a reason\. Abuse may result in a ban\./i),
).toBeNull();
-2
View File
@@ -23,8 +23,6 @@ export function InstallSwitcher({ exampleSlug = "sonoscli" }: InstallSwitcherPro
return `pnpm dlx clawhub@latest install ${exampleSlug}`;
case "bun":
return `bunx clawhub@latest install ${exampleSlug}`;
default:
return `npx clawhub@latest install ${exampleSlug}`;
}
}, [exampleSlug, pm]);
-195
View File
@@ -1,195 +0,0 @@
/* @vitest-environment jsdom */
import { render, waitFor } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { MarkdownPreview } from "./MarkdownPreview";
function renderMarkdown(source: string) {
// Disable Shiki highlighting to keep the tree synchronous for assertions.
const { container } = render(<MarkdownPreview highlight={false}>{source}</MarkdownPreview>);
return container;
}
describe("MarkdownPreview — raw HTML passthrough", () => {
it("renders an <h1 align=\"center\"> block as a real <h1>", () => {
const container = renderMarkdown(`<h1 align="center">Hello logo</h1>`);
const h1 = container.querySelector("h1");
expect(h1).not.toBeNull();
expect(h1?.textContent).toBe("Hello logo");
});
it("renders a <div align=\"center\"> block as a real <div>", () => {
const container = renderMarkdown(`<div align="center">centered</div>`);
const div = container.querySelector("div[align=\"center\"]");
expect(div).not.toBeNull();
expect(div?.textContent).toBe("centered");
});
it("renders <picture> with <source> + <img> fallback", () => {
const container = renderMarkdown(
`<picture><source media="(prefers-color-scheme: dark)" srcset="dark.png"/><img alt="Logo" src="light.png"/></picture>`,
);
expect(container.querySelector("picture")).not.toBeNull();
expect(container.querySelector("picture source")).not.toBeNull();
const img = container.querySelector("picture img");
expect(img).not.toBeNull();
expect(img?.getAttribute("alt")).toBe("Logo");
expect(img?.getAttribute("src")).toBe("light.png");
});
it("renders standalone <img> tags with src and alt", () => {
const container = renderMarkdown(`<img src="screenshot.png" alt="Demo screenshot"/>`);
const img = container.querySelector("img");
expect(img).not.toBeNull();
// Relative paths render as-is — only external http(s) URLs get proxied.
expect(img?.getAttribute("src")).toBe("screenshot.png");
expect(img?.getAttribute("alt")).toBe("Demo screenshot");
});
it("routes external https <img> URLs through /_vercel/image", () => {
const container = renderMarkdown(
`<img src="https://raw.githubusercontent.com/foo/bar/main/logo.png" alt="logo"/>`,
);
const img = container.querySelector("img");
expect(img?.getAttribute("src")).toBe(
"/_vercel/image?url=https%3A%2F%2Fraw.githubusercontent.com%2Ffoo%2Fbar%2Fmain%2Flogo.png&w=1024&q=75",
);
});
it("routes external markdown ![](url) images through /_vercel/image", () => {
const container = renderMarkdown(`![logo](https://img.shields.io/badge/x-y-blue.svg)`);
const img = container.querySelector("img");
expect(img?.getAttribute("src")).toBe(
"/_vercel/image?url=https%3A%2F%2Fimg.shields.io%2Fbadge%2Fx-y-blue.svg&w=1024&q=75",
);
});
it("renders <br/> as a real line break", () => {
const container = renderMarkdown(`line one<br/>line two`);
expect(container.querySelector("br")).not.toBeNull();
});
it("renders the Opik README banner (centered h1 + picture + img)", () => {
const opikBanner = `<h1 align="center">
<a href="https://www.comet.com/">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="dark.svg"/>
<img alt="Comet Opik logo" src="light.svg" width="200"/>
</picture>
</a>
<br/>OpenClaw Opik Observability Plugin
</h1>`;
const container = renderMarkdown(opikBanner);
expect(container.querySelector("h1")).not.toBeNull();
expect(container.querySelector("picture")).not.toBeNull();
const img = container.querySelector("img");
expect(img?.getAttribute("alt")).toBe("Comet Opik logo");
// And the escaped tag must NOT be present as literal text anywhere.
expect(container.textContent ?? "").not.toContain("<picture>");
});
});
describe("MarkdownPreview — standard markdown still renders", () => {
it("renders ATX headings", () => {
const container = renderMarkdown(`## Why This Plugin`);
const h2 = container.querySelector("h2");
expect(h2?.textContent).toBe("Why This Plugin");
});
it("renders markdown links", () => {
const container = renderMarkdown(`[Opik](https://example.com/opik)`);
const a = container.querySelector("a");
expect(a?.getAttribute("href")).toBe("https://example.com/opik");
expect(a?.textContent).toBe("Opik");
});
it("renders inline code", () => {
const container = renderMarkdown("Use `@opik/opik-openclaw` now.");
const code = container.querySelector("code");
expect(code?.textContent).toBe("@opik/opik-openclaw");
});
it("renders unordered lists", () => {
const container = renderMarkdown(`- one\n- two\n- three`);
const items = container.querySelectorAll("li");
expect(items.length).toBe(3);
expect(items[0].textContent).toBe("one");
});
it("renders GFM tables", () => {
const container = renderMarkdown(
[
"| Key | Value |",
"| --- | ----- |",
"| a | 1 |",
"| b | 2 |",
].join("\n"),
);
expect(container.querySelector("table")).not.toBeNull();
expect(container.querySelectorAll("tbody tr").length).toBe(2);
});
it("renders fenced code blocks as <pre><code>", () => {
const container = renderMarkdown("```ts\nconst x = 1;\n```");
const code = container.querySelector("pre code");
expect(code).not.toBeNull();
expect(code?.textContent).toContain("const x = 1;");
});
});
describe("MarkdownPreview — syntax highlighting", () => {
it("shiki-highlights fenced code blocks (produces colored <span> tokens)", async () => {
const { container } = render(
<MarkdownPreview>{"```ts\nconst x: number = 1;\n```"}</MarkdownPreview>,
);
await waitFor(
() => {
const pre = container.querySelector("pre");
// Shiki wraps the output in <pre class="shiki ..."> and tokens are
// <span style="color:#...">.
expect(pre?.className ?? "").toMatch(/shiki/);
const coloredSpans = container.querySelectorAll("pre span[style*='color']");
expect(coloredSpans.length).toBeGreaterThan(0);
},
{ timeout: 8000 },
);
// Raw code text must still be present after highlighting
expect(container.querySelector("pre")?.textContent).toContain("const x");
});
it("leaves the highlight prop honored — highlight={false} renders plain <pre><code>", () => {
const { container } = render(
<MarkdownPreview highlight={false}>{"```ts\nconst x = 1;\n```"}</MarkdownPreview>,
);
const pre = container.querySelector("pre");
// No shiki class, no colored spans
expect(pre?.className ?? "").not.toMatch(/shiki/);
expect(container.querySelectorAll("pre span[style*='color']").length).toBe(0);
expect(pre?.textContent).toContain("const x = 1;");
});
});
describe("MarkdownPreview — sanitization of malicious HTML", () => {
it("strips <script> tags", () => {
const container = renderMarkdown(`hello<script>window.__pwn = 1;</script>world`);
expect(container.querySelector("script")).toBeNull();
expect(container.textContent ?? "").not.toContain("window.__pwn");
});
it("strips onerror handlers on <img>", () => {
const container = renderMarkdown(`<img src="x" onerror="window.__pwn = 1" alt="x"/>`);
const img = container.querySelector("img");
// The img itself can render; the handler must be gone.
expect(img?.getAttribute("onerror")).toBeNull();
});
it("strips javascript: hrefs on anchors", () => {
const container = renderMarkdown(`<a href="javascript:alert(1)">click</a>`);
const a = container.querySelector("a");
// Either the href is removed entirely or rewritten — it must not start with javascript:
const href = a?.getAttribute("href") ?? "";
expect(href.toLowerCase().startsWith("javascript:")).toBe(false);
});
});
+69 -92
View File
@@ -1,117 +1,94 @@
import rehypeShikiFromHighlighter from "@shikijs/rehype/core";
import { useEffect, useMemo, useState } from "react";
import ReactMarkdown from "react-markdown";
import rehypeRaw from "rehype-raw";
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
import remarkGfm from "remark-gfm";
import type { HighlighterGeneric } from "shiki";
import type { PluggableList } from "unified";
import { rehypeProxyImages } from "../lib/rehypeProxyImages";
import { parse } from "@create-markdown/core";
import { blocksToHTML, renderAsync, shikiPlugin } from "@create-markdown/preview";
import { useEffect, useRef, useState } from "react";
import { cn } from "../lib/utils";
interface MarkdownPreviewProps {
children: string;
className?: string;
/** Enable Shiki syntax highlighting for fenced code blocks. Default: true. */
/** Enable Shiki syntax highlighting for code blocks (async). Default: true */
highlight?: boolean;
}
const schema = {
...defaultSchema,
tagNames: [...(defaultSchema.tagNames ?? []), "picture", "source"],
attributes: {
...defaultSchema.attributes,
"*": [...(defaultSchema.attributes?.["*"] ?? []), "align"],
img: [...(defaultSchema.attributes?.img ?? []), "width", "height"],
source: ["media", "srcSet", "srcset", "type"],
picture: [],
},
};
// Order matters: rehype-sanitize runs BEFORE rehype-shiki so sanitize only
// sees user-authored HTML; shiki's trusted styled output flows through after.
// rehypeProxyImages rewrites after sanitize so we rewrite only already-safe
// <img src="..."> nodes (sanitize strips event handlers, javascript: URLs).
const baseRehype: PluggableList = [
rehypeRaw,
[rehypeSanitize, schema],
rehypeProxyImages,
];
const SHIKI_THEME = "github-dark";
const SHIKI_LANGS = [
"bash",
"sh",
"shell",
"ts",
"tsx",
"js",
"jsx",
"json",
"yaml",
"md",
"python",
"nix",
"http",
"html",
"css",
"toml",
"rust",
"go",
"dockerfile",
"diff",
];
type AnyHighlighter = HighlighterGeneric<string, string>;
let highlighterPromise: Promise<AnyHighlighter> | null = null;
function loadHighlighter(): Promise<AnyHighlighter> {
if (!highlighterPromise) {
highlighterPromise = import("shiki").then(
({ createHighlighter }) =>
createHighlighter({
themes: [SHIKI_THEME],
langs: SHIKI_LANGS,
}) as Promise<AnyHighlighter>,
);
}
return highlighterPromise;
/**
* Auto-link bare URLs in HTML that aren't already inside anchor tags or attributes.
* Matches http/https URLs in text nodes only (not inside tags).
*/
function autolinkURLs(html: string): string {
// Split HTML into tags and text segments, then only linkify text segments
return html.replace(
/(<[^>]*>)|((https?:\/\/)[^\s<>"')\]]+)/gi,
(match, tag: string | undefined, url: string | undefined) => {
// If it's an HTML tag, leave it alone
if (tag) return tag;
// If it's a bare URL in text content, wrap it
if (url) {
// Trim trailing punctuation that's likely not part of the URL
const trailingPunct = /[.,;:!?)]+$/.exec(url);
const cleanUrl = trailingPunct ? url.slice(0, -trailingPunct[0].length) : url;
const suffix = trailingPunct ? trailingPunct[0] : "";
return `<a href="${cleanUrl}" target="_blank" rel="noopener noreferrer">${cleanUrl}</a>${suffix}`;
}
return match;
},
);
}
/**
* Rich markdown preview using @create-markdown/preview.
* Renders markdown HTML with optional Shiki syntax highlighting.
* Falls back to synchronous (unhighlighted) rendering while Shiki loads.
*/
export function MarkdownPreview({ children, className, highlight = true }: MarkdownPreviewProps) {
const [highlighter, setHighlighter] = useState<AnyHighlighter | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
// Initial sync render (no highlighting) for instant display
const [html, setHtml] = useState(() => {
try {
const blocks = parse(children);
return autolinkURLs(blocksToHTML(blocks));
} catch {
return "";
}
});
useEffect(() => {
let cancelled = false;
if (highlight) {
loadHighlighter()
.then((h) => {
if (!cancelled) setHighlighter(h);
// Re-parse synchronously on content change
try {
const blocks = parse(children);
const syncHtml = autolinkURLs(blocksToHTML(blocks));
setHtml(syncHtml);
if (!highlight) return;
// Async render with Shiki syntax highlighting
void renderAsync(blocks, {
plugins: [shikiPlugin({ theme: "github-dark" })],
})
.then((highlighted) => {
if (!cancelled) {
setHtml(autolinkURLs(highlighted));
}
})
.catch(() => {
// Shiki failed to initialize — keep plain rendering.
// Shiki failed to load — keep the sync render
});
} catch {
// Parse failed — clear
setHtml("");
}
return () => {
cancelled = true;
};
}, [highlight]);
const rehypePlugins = useMemo<PluggableList>(() => {
if (highlight && highlighter) {
return [
...baseRehype,
[rehypeShikiFromHighlighter, highlighter, { theme: SHIKI_THEME }],
];
}
return baseRehype;
}, [highlight, highlighter]);
}, [children, highlight]);
return (
<div className={cn("markdown", className)}>
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={rehypePlugins}>
{children}
</ReactMarkdown>
</div>
<div
ref={containerRef}
className={cn("markdown", className)}
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
+21 -18
View File
@@ -236,26 +236,29 @@ export function SkillDetailPage({
}, [navigate, ownerParam, slug, wantsCanonicalRedirect]);
useEffect(() => {
let cancelled = false;
if (latestVersion && !(loadedReadmeVersionId === latestVersion._id && (readme !== null || readmeError !== null))) {
setReadme(null);
setReadmeError(null);
setLoadedReadmeVersionId(latestVersion._id);
void getReadme({ versionId: latestVersion._id })
.then((data) => {
if (cancelled) return;
setReadme(data.text);
setLoadedReadmeVersionId(latestVersion._id);
})
.catch((error) => {
if (cancelled) return;
setReadmeError(error instanceof Error ? error.message : "Failed to load README");
setReadme(null);
setLoadedReadmeVersionId(latestVersion._id);
});
if (!latestVersion) return;
if (loadedReadmeVersionId === latestVersion._id && (readme !== null || readmeError !== null)) {
return;
}
setReadme(null);
setReadmeError(null);
setLoadedReadmeVersionId(latestVersion._id);
let cancelled = false;
void getReadme({ versionId: latestVersion._id })
.then((data) => {
if (cancelled) return;
setReadme(data.text);
setLoadedReadmeVersionId(latestVersion._id);
})
.catch((error) => {
if (cancelled) return;
setReadmeError(error instanceof Error ? error.message : "Failed to load README");
setReadme(null);
setLoadedReadmeVersionId(latestVersion._id);
});
return () => {
cancelled = true;
};
+1 -6
View File
@@ -2,11 +2,8 @@ import { lazy, Suspense } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { Doc, Id } from "../../convex/_generated/dataModel";
import { rehypeProxyImages } from "../lib/rehypeProxyImages";
import { SkillVersionsPanel } from "./SkillVersionsPanel";
const REHYPE_PLUGINS = [rehypeProxyImages];
const SkillDiffCard = lazy(() =>
import("./SkillDiffCard").then((module) => ({ default: module.SkillDiffCard })),
);
@@ -99,9 +96,7 @@ export function SkillDetailTabs({
<div className="tab-body">
{readmeContent ? (
<div className="markdown">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={REHYPE_PLUGINS}>
{readmeContent}
</ReactMarkdown>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{readmeContent}</ReactMarkdown>
</div>
) : readmeError ? (
<div className="empty-state px-[var(--space-4)] py-[var(--space-6)]">
+2 -2
View File
@@ -232,7 +232,7 @@ export function SkillDiffCard({ skill, versions, variant = "card" }: SkillDiffCa
}, [getFileText, leftVersionId, rightVersionId, selectedItem]);
useEffect(() => {
if (!monaco || typeof document === "undefined") return () => {};
if (!monaco || typeof document === "undefined") return;
const syncTheme = () => applyMonacoTheme(monaco);
const observer = new MutationObserver(syncTheme);
observer.observe(document.documentElement, {
@@ -248,7 +248,7 @@ export function SkillDiffCard({ skill, versions, variant = "card" }: SkillDiffCa
}, [monaco]);
useEffect(() => {
if (typeof window === "undefined") return () => {};
if (typeof window === "undefined") return;
const mediaQuery = window.matchMedia(`(max-width: ${MOBILE_DIFF_BREAKPOINT}px)`);
const syncViewMode = () => {
if (!userSelectedViewModeRef.current) {
+4 -1
View File
@@ -1,5 +1,8 @@
import type { ClawdisSkillMetadata } from "clawhub-schema";
import { PLATFORM_SKILL_LICENSE } from "clawhub-schema/licenseConstants";
import {
PLATFORM_SKILL_LICENSE,
PLATFORM_SKILL_LICENSE_SUMMARY,
} from "clawhub-schema/licenseConstants";
import { Calendar, Download, Package, Scale, Star, Tag } from "lucide-react";
import type { Id } from "../../convex/_generated/dataModel";
import { formatCompactStat } from "../lib/numberFormat";
+13 -14
View File
@@ -95,21 +95,20 @@ export function SoulDetailPage({ slug }: SoulDetailPageProps) {
}, [ensureSoulSeeds]);
useEffect(() => {
if (!latestVersion) return;
setReadme(null);
setReadmeError(null);
let cancelled = false;
if (latestVersion) {
setReadme(null);
setReadmeError(null);
void getReadme({ versionId: latestVersion._id })
.then((data) => {
if (cancelled) return;
setReadme(data.text);
})
.catch((error) => {
if (cancelled) return;
setReadmeError(error instanceof Error ? error.message : "Failed to load SOUL.md");
setReadme(null);
});
}
void getReadme({ versionId: latestVersion._id })
.then((data) => {
if (cancelled) return;
setReadme(data.text);
})
.catch((error) => {
if (cancelled) return;
setReadmeError(error instanceof Error ? error.message : "Failed to load SOUL.md");
setReadme(null);
});
return () => {
cancelled = true;
};
+3
View File
@@ -141,6 +141,9 @@ function rhex(n: number) {
}
function hex(x: number[]) {
for (let i = 0; i < x.length; i += 1) {
x[i] = Number(x[i]);
}
return x.map(rhex).join("");
}
+1 -1
View File
@@ -368,7 +368,7 @@ describe("fetchPackages", () => {
const result = await fetchPackageVersion("demo-plugin", "1.2.3+build/meta");
expect(result?.version?.version).toBe("1.2.3");
expect(result.version?.version).toBe("1.2.3");
expect(fetchMock.mock.calls[0]?.[0]).toBe(
"https://registry.example/api/v1/packages/demo-plugin/versions/1.2.3%2Bbuild%2Fmeta",
);
+4 -4
View File
@@ -52,18 +52,18 @@ export function normalizePackageUploadPath(
return parts.slice(1).join("/") || (parts.at(-1) ?? "");
}
function getRawUploadPath(file: UploadablePackageFile) {
function getRawUploadPath<TFile extends UploadablePackageFile>(file: TFile) {
return file.webkitRelativePath?.trim() || file.name;
}
function getNormalizedUploadPath(
file: UploadablePackageFile,
function getNormalizedUploadPath<TFile extends UploadablePackageFile>(
file: TFile,
options: NormalizePackageUploadPathOptions = {},
) {
return normalizePackageUploadPath(getRawUploadPath(file), options) || file.name;
}
function shouldStripSharedTopLevelFolder(files: UploadablePackageFile[]) {
function shouldStripSharedTopLevelFolder<TFile extends UploadablePackageFile>(files: TFile[]) {
if (files.length === 0) return false;
const partsList = files
.map((file) => getNormalizedUploadPath(file))
-26
View File
@@ -1,26 +0,0 @@
import type { Root } from "hast";
import { visit } from "unist-util-visit";
/**
* Routes external http(s) <img> sources through Vercel's image optimizer at
* /_vercel/image, which enforces the allow-list, SVG rejection, and caching
* declared in vercel.json. Local paths, relative paths, and data: URIs pass
* through unchanged only external schemes are treated as untrusted.
*
* `w` is required by the optimizer and must match a value in the `sizes`
* array in vercel.json, so we always pass 1024. The <img width="..."> HTML
* attribute still drives layout this only controls served resolution.
*/
export function rehypeProxyImages() {
return (tree: Root) => {
visit(tree, "element", (node) => {
if (node.tagName !== "img") return;
const src = node.properties?.src;
if (typeof src !== "string" || !/^https?:\/\//i.test(src)) return;
node.properties = {
...node.properties,
src: `/_vercel/image?url=${encodeURIComponent(src)}&w=1024&q=75`,
};
});
};
}
+1 -1
View File
@@ -29,5 +29,5 @@ export function isDevRuntime() {
if (nodeEnv) {
return nodeEnv !== "production";
}
return import.meta.env.DEV;
return Boolean(import.meta.env.DEV);
}
+9 -9
View File
@@ -14,8 +14,8 @@ describe("theme", () => {
<button type="button" onClick={() => setMode("dark")}>
dark
</button>
<button type="button" onClick={() => setFamily("claw")}>
claw
<button type="button" onClick={() => setFamily("hub")}>
hub
</button>
</div>
);
@@ -27,7 +27,7 @@ describe("theme", () => {
value: {
getItem: (key: string) => (key in store ? store[key] : null),
setItem: (key: string, value: string) => {
store[key] = value;
store[key] = String(value);
},
removeItem: (key: string) => {
delete store[key];
@@ -58,7 +58,7 @@ describe("theme", () => {
"clawhub-theme-selection",
JSON.stringify({ theme: "hub", mode: "light" }),
);
expect(getStoredThemeSelection()).toEqual({ theme: "claw", mode: "light" });
expect(getStoredThemeSelection()).toEqual({ theme: "hub", mode: "light" });
window.localStorage.clear();
window.localStorage.setItem("clawhub-theme", "dark");
@@ -70,10 +70,10 @@ describe("theme", () => {
});
it("applies family and resolved mode to the document", () => {
applyTheme("dark", "claw");
applyTheme("dark", "hub");
expect(document.documentElement.dataset.theme).toBe("dark");
expect(document.documentElement.dataset.themeResolved).toBe("dark");
expect(document.documentElement.dataset.themeFamily).toBe("claw");
expect(document.documentElement.dataset.themeFamily).toBe("hub");
expect(document.documentElement.classList.contains("dark")).toBe(true);
applyTheme("light", "claw");
@@ -104,15 +104,15 @@ describe("theme", () => {
expect(screen.getByTestId("mode").textContent).toBe("system");
expect(screen.getByTestId("family").textContent).toBe("claw");
fireEvent.click(screen.getByRole("button", { name: "claw" }));
fireEvent.click(screen.getByRole("button", { name: "hub" }));
fireEvent.click(screen.getByRole("button", { name: "dark" }));
await waitFor(() => {
expect(document.documentElement.dataset.themeFamily).toBe("claw");
expect(document.documentElement.dataset.themeFamily).toBe("hub");
expect(document.documentElement.dataset.themeResolved).toBe("dark");
});
expect(window.localStorage.getItem("clawhub-theme")).toBe("dark");
expect(window.localStorage.getItem("clawhub-theme-name")).toBe("claw");
expect(window.localStorage.getItem("clawhub-theme-name")).toBe("hub");
});
});
+2 -2
View File
@@ -166,13 +166,13 @@ export function useThemeMode() {
}, []);
useEffect(() => {
if (!isHydrated) return () => {};
if (!isHydrated) return;
applyThemeSelection(selection);
persistThemeSelection(selection);
syncCustomThemeFromStorage();
if (selection.mode !== 'system' || typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
return () => {};
return;
}
const media = window.matchMedia('(prefers-color-scheme: dark)');
+1 -1
View File
@@ -55,7 +55,7 @@ export function useUnifiedSearch(
setPluginCount(0);
setUserCount(0);
setIsSearching(false);
return () => {};
return;
}
requestRef.current += 1;
+1 -2
View File
@@ -9,12 +9,11 @@ import {
ShieldOff,
UserX,
} from 'lucide-react';
import type { ReactNode } from 'react';
import { Badge } from '../components/ui/badge';
import { Button } from '../components/ui/button';
import { getSiteMode, getSiteName, getSiteUrlForMode } from '../lib/site';
export function renderWithInlineCode(text: string): ReactNode[] {
export function renderWithInlineCode(text: string): (string | JSX.Element)[] {
const parts = text.split(/(`[^`]+`)/g);
return parts.map((part, i) => {
if (part.startsWith('`') && part.endsWith('`')) {
+1 -1
View File
@@ -463,7 +463,7 @@ export function ImportGitHub() {
>
<input
type="checkbox"
checked={selected[file.path]}
checked={Boolean(selected[file.path])}
onChange={() =>
setSelected((prev) => ({ ...prev, [file.path]: !prev[file.path] }))
}
+12 -11
View File
@@ -284,18 +284,19 @@ function SkillsHome() {
});
}
const drawClaw = (context: CanvasRenderingContext2D, size: number) => {
const drawClaw = (ctx: CanvasRenderingContext2D, size: number) => {
// Simple lobster claw shape
context.beginPath();
context.moveTo(0, size * 0.5);
context.quadraticCurveTo(-size * 0.6, size * 0.2, -size * 0.4, -size * 0.3);
context.quadraticCurveTo(-size * 0.2, -size * 0.6, 0, -size * 0.3);
context.quadraticCurveTo(size * 0.2, -size * 0.6, size * 0.4, -size * 0.3);
context.quadraticCurveTo(size * 0.6, size * 0.2, 0, size * 0.5);
context.closePath();
context.fill();
ctx.beginPath();
ctx.moveTo(0, size * 0.5);
ctx.quadraticCurveTo(-size * 0.6, size * 0.2, -size * 0.4, -size * 0.3);
ctx.quadraticCurveTo(-size * 0.2, -size * 0.6, 0, -size * 0.3);
ctx.quadraticCurveTo(size * 0.2, -size * 0.6, size * 0.4, -size * 0.3);
ctx.quadraticCurveTo(size * 0.6, size * 0.2, 0, size * 0.5);
ctx.closePath();
ctx.fill();
};
let raf: number;
const draw = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
let alive = false;
@@ -331,13 +332,13 @@ function SkillsHome() {
ctx.restore();
}
if (alive) {
requestAnimationFrame(draw);
raf = requestAnimationFrame(draw);
} else {
ctx.clearRect(0, 0, canvas.width, canvas.height);
canvas.style.display = "none";
}
};
requestAnimationFrame(draw);
raf = requestAnimationFrame(draw);
};
const renderSlotReel = (reelIdx: 0 | 1 | 2) => {
+1 -1
View File
@@ -438,7 +438,7 @@ function PluginDetailRoute() {
{key.replace(/([A-Z])/g, " $1").replace(/^./, (s) => s.toUpperCase())}
</dt>
<dd className="min-w-0 break-all font-mono text-xs text-[color:var(--ink)]">
{value}
{String(value)}
</dd>
</div>
))}
+3 -3
View File
@@ -146,7 +146,7 @@ export function useSkillsBrowseModel({
}, [searchKey]);
useEffect(() => {
if (!hasQuery) return () => {};
if (!hasQuery) return;
searchRequest.current += 1;
const requestId = searchRequest.current;
setIsSearching(true);
@@ -269,9 +269,9 @@ export function useSkillsBrowseModel({
}, [isLoadingMore]);
useEffect(() => {
if (!canLoadMore || typeof IntersectionObserver === "undefined") return () => {};
if (!canLoadMore || typeof IntersectionObserver === "undefined") return;
const target = loadMoreRef.current;
if (!target) return () => {};
if (!target) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
+1 -1
View File
@@ -244,7 +244,7 @@ function InstalledSection(props: {
<div key={`${root.rootId}:${entry.skill.slug}`} className="telemetry-skill-row">
<a
className="telemetry-skill-link"
href={`/${encodeURIComponent(entry.skill.ownerUserId)}/${entry.skill.slug}`}
href={`/${encodeURIComponent(String(entry.skill.ownerUserId))}/${entry.skill.slug}`}
>
<span>{entry.skill.displayName}</span>
<span className="telemetry-skill-slug">/{entry.skill.slug}</span>
+1 -25
View File
@@ -30,29 +30,5 @@
"source": "/api/:path*",
"destination": "https://wry-manatee-359.convex.site/api/:path*"
}
],
"images": {
"sizes": [256, 640, 1024, 1920],
"formats": ["image/webp"],
"minimumCacheTTL": 86400,
"dangerouslyAllowSVG": false,
"remotePatterns": [
{ "protocol": "https", "hostname": "raw.githubusercontent.com" },
{ "protocol": "https", "hostname": "user-images.githubusercontent.com" },
{ "protocol": "https", "hostname": "avatars.githubusercontent.com" },
{ "protocol": "https", "hostname": "camo.githubusercontent.com" },
{ "protocol": "https", "hostname": "github.com" },
{ "protocol": "https", "hostname": "raw.github.com" },
{ "protocol": "https", "hostname": "img.shields.io" },
{ "protocol": "https", "hostname": "shields.io" },
{ "protocol": "https", "hostname": "cdn.jsdelivr.net" },
{ "protocol": "https", "hostname": "i.imgur.com" },
{ "protocol": "https", "hostname": "codecov.io" },
{ "protocol": "https", "hostname": "coveralls.io" },
{ "protocol": "https", "hostname": "codefactor.io" },
{ "protocol": "https", "hostname": "badgen.net" },
{ "protocol": "https", "hostname": "flat.badgen.net" },
{ "protocol": "https", "hostname": "gitlab.com" }
]
}
]
}