mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 17:02:11 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea0824878d | ||
|
|
4787be4eb1 | ||
|
|
89246f1927 | ||
|
|
f4ddccbead | ||
|
|
3cafcbf873 | ||
|
|
13064a7897 |
@@ -10,7 +10,7 @@
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge" alt="MIT License"></a>
|
||||
</p>
|
||||
|
||||
ClawHub is the **public skill registry for Clawdbot**: publish, version, and search text-based agent skills (a `SKILL.md` plus supporting files).
|
||||
ClawHub is the **public skill registry for OpenClaw**: publish, version, and search text-based agent skills (a `SKILL.md` plus supporting files).
|
||||
It's designed for fast browsing + a CLI-friendly API, with moderation hooks and vector search.
|
||||
It also now exposes a native **OpenClaw package catalog** for code plugins and bundle plugins.
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "clawhub",
|
||||
@@ -25,6 +26,7 @@
|
||||
"@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",
|
||||
@@ -49,6 +51,8 @@
|
||||
"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",
|
||||
@@ -591,6 +595,8 @@
|
||||
|
||||
"@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=="],
|
||||
@@ -961,12 +967,26 @@
|
||||
|
||||
"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=="],
|
||||
@@ -1283,6 +1303,10 @@
|
||||
|
||||
"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=="],
|
||||
@@ -1441,6 +1465,8 @@
|
||||
|
||||
"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=="],
|
||||
@@ -1453,6 +1479,8 @@
|
||||
|
||||
"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=="],
|
||||
@@ -1595,6 +1623,8 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 94 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 394 KiB |
@@ -0,0 +1,135 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expectHealthyPage, trackRuntimeErrors } from "./helpers/runtimeErrors";
|
||||
|
||||
// Only run in mobile projects — skip on desktop
|
||||
test.beforeEach(({}, testInfo) => {
|
||||
test.skip(
|
||||
!testInfo.project.name.includes("mobile"),
|
||||
"mobile-only test",
|
||||
);
|
||||
});
|
||||
|
||||
test("browse page has no horizontal overflow on mobile", async ({ page }) => {
|
||||
const errors = trackRuntimeErrors(page);
|
||||
|
||||
await page.goto("/skills?sort=downloads", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.getByRole("heading", { name: /^Skills/ })).toBeVisible();
|
||||
await expect(page.locator(".skill-card, .skill-list-item").first()).toBeVisible();
|
||||
|
||||
const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth);
|
||||
const clientWidth = await page.evaluate(() => document.documentElement.clientWidth);
|
||||
expect(scrollWidth).toBeLessThanOrEqual(clientWidth + 1);
|
||||
|
||||
await expectHealthyPage(page, errors);
|
||||
});
|
||||
|
||||
test("browse sidebar toggle opens and closes filters", async ({ page }) => {
|
||||
const errors = trackRuntimeErrors(page);
|
||||
|
||||
await page.goto("/skills?sort=downloads", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.getByRole("heading", { name: /^Skills/ })).toBeVisible();
|
||||
|
||||
const filterButton = page.getByRole("button", { name: "Toggle filters" });
|
||||
await expect(filterButton).toBeVisible();
|
||||
|
||||
// Sidebar should be hidden initially
|
||||
const sidebar = page.locator(".browse-sidebar");
|
||||
await expect(sidebar).not.toBeVisible();
|
||||
|
||||
// Open sidebar
|
||||
await filterButton.click();
|
||||
await expect(sidebar).toBeVisible();
|
||||
|
||||
// Close sidebar
|
||||
await filterButton.click();
|
||||
await expect(sidebar).not.toBeVisible();
|
||||
|
||||
await expectHealthyPage(page, errors);
|
||||
});
|
||||
|
||||
test("card grid fits within viewport on mobile", async ({ page }) => {
|
||||
const errors = trackRuntimeErrors(page);
|
||||
|
||||
await page.goto("/skills?sort=downloads&view=cards", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator(".skill-card").first()).toBeVisible();
|
||||
|
||||
const card = page.locator(".skill-card").first();
|
||||
const cardBox = await card.boundingBox();
|
||||
const viewport = page.viewportSize()!;
|
||||
|
||||
// Card should not exceed viewport width
|
||||
expect(cardBox!.width).toBeLessThanOrEqual(viewport.width);
|
||||
|
||||
await expectHealthyPage(page, errors);
|
||||
});
|
||||
|
||||
test("skill detail page has no horizontal overflow on mobile", async ({ page, request }) => {
|
||||
const errors = trackRuntimeErrors(page);
|
||||
|
||||
const response = await request.get("/api/v1/skills/gifgrep");
|
||||
test.skip(!response.ok(), "gifgrep fixture missing");
|
||||
|
||||
const payload = (await response.json()) as {
|
||||
owner?: { handle?: string | null };
|
||||
skill?: { slug?: string | null; displayName?: string | null };
|
||||
};
|
||||
const ownerHandle = payload.owner?.handle?.trim();
|
||||
const slug = payload.skill?.slug?.trim();
|
||||
test.skip(!ownerHandle || !slug || !payload.skill?.displayName, "fixture missing owner handle, slug, or displayName");
|
||||
|
||||
await page.goto(`/${ownerHandle}/${slug}`, { waitUntil: "domcontentloaded" });
|
||||
await expect(
|
||||
page.getByRole("heading", { name: payload.skill!.displayName! }),
|
||||
).toBeVisible();
|
||||
|
||||
const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth);
|
||||
const clientWidth = await page.evaluate(() => document.documentElement.clientWidth);
|
||||
expect(scrollWidth).toBeLessThanOrEqual(clientWidth + 1);
|
||||
|
||||
await expectHealthyPage(page, errors);
|
||||
});
|
||||
|
||||
test("detail tabs are scrollable and touch-friendly on mobile", async ({ page, request }) => {
|
||||
const errors = trackRuntimeErrors(page);
|
||||
|
||||
const response = await request.get("/api/v1/skills/gifgrep");
|
||||
test.skip(!response.ok(), "gifgrep fixture missing");
|
||||
|
||||
const payload = (await response.json()) as {
|
||||
owner?: { handle?: string | null };
|
||||
skill?: { slug?: string | null };
|
||||
};
|
||||
const ownerHandle = payload.owner?.handle?.trim();
|
||||
const slug = payload.skill?.slug?.trim();
|
||||
test.skip(!ownerHandle || !slug, "fixture missing");
|
||||
|
||||
await page.goto(`/${ownerHandle}/${slug}`, { waitUntil: "domcontentloaded" });
|
||||
|
||||
// All standard tabs should be accessible (even if scrolled)
|
||||
for (const tabName of ["README", "Files", "Versions"]) {
|
||||
const tab = page.getByRole("button", { name: tabName });
|
||||
await tab.scrollIntoViewIfNeeded();
|
||||
await expect(tab).toBeVisible();
|
||||
|
||||
// Touch target should be at least 44px
|
||||
const box = await tab.boundingBox();
|
||||
expect(box!.height).toBeGreaterThanOrEqual(44);
|
||||
}
|
||||
|
||||
await expectHealthyPage(page, errors);
|
||||
});
|
||||
|
||||
test("search input font size prevents iOS zoom", async ({ page }) => {
|
||||
const errors = trackRuntimeErrors(page);
|
||||
|
||||
await page.goto("/skills?sort=downloads", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const input = page.locator(".browse-search-input");
|
||||
await expect(input).toBeVisible();
|
||||
|
||||
const fontSize = await input.evaluate((el) => getComputedStyle(el).fontSize);
|
||||
// iOS Safari zooms the page when an input has font-size below 16px
|
||||
expect(parseFloat(fontSize)).toBeGreaterThanOrEqual(16);
|
||||
|
||||
await expectHealthyPage(page, errors);
|
||||
});
|
||||
@@ -52,6 +52,7 @@
|
||||
"@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",
|
||||
@@ -76,6 +77,8 @@
|
||||
"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",
|
||||
|
||||
@@ -29,5 +29,13 @@ export default defineConfig({
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
{
|
||||
name: "mobile-chrome",
|
||||
use: { ...devices["Pixel 7"] },
|
||||
},
|
||||
{
|
||||
name: "mobile-safari",
|
||||
use: { ...devices["iPhone 14"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 133 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 292 KiB After Width: | Height: | Size: 281 KiB |
+102
@@ -0,0 +1,102 @@
|
||||
<svg width="1200" height="630" viewBox="0 0 1200 630" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<radialGradient id="bgGlowRight" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(1048 328) rotate(180) scale(358 260)">
|
||||
<stop stop-color="#7B1F18" stop-opacity="0.58"/>
|
||||
<stop offset="0.45" stop-color="#431210" stop-opacity="0.26"/>
|
||||
<stop offset="1" stop-color="#030305" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="bgGlowBottom" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(192 610) rotate(-90) scale(180 420)">
|
||||
<stop stop-color="#A12A1D" stop-opacity="0.2"/>
|
||||
<stop offset="1" stop-color="#030305" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="frameStroke" x1="40" y1="106" x2="1146" y2="530" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#6D3437" stop-opacity="0.8"/>
|
||||
<stop offset="0.55" stop-color="#DF5D35" stop-opacity="0.36"/>
|
||||
<stop offset="1" stop-color="#FF6D39" stop-opacity="0.9"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="frameGlow" x1="164" y1="164" x2="1114" y2="476" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#130D10"/>
|
||||
<stop offset="0.5" stop-color="#170B0E"/>
|
||||
<stop offset="1" stop-color="#261011"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="logoStroke" x1="112" y1="140" x2="398" y2="430" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#4C2A28" stop-opacity="0.55"/>
|
||||
<stop offset="1" stop-color="#E05831" stop-opacity="0.28"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="searchStroke" x1="146" y1="480" x2="1068" y2="480" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#7F342A" stop-opacity="0.65"/>
|
||||
<stop offset="1" stop-color="#FF6F37" stop-opacity="0.85"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="buttonFill" x1="838" y1="445" x2="1084" y2="510" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#D55335"/>
|
||||
<stop offset="1" stop-color="#EB6A3E"/>
|
||||
</linearGradient>
|
||||
<filter id="softBlur" x="-30%" y="-30%" width="160%" height="160%">
|
||||
<feGaussianBlur stdDeviation="20"/>
|
||||
</filter>
|
||||
<filter id="glowBlur" x="-40%" y="-40%" width="180%" height="180%">
|
||||
<feGaussianBlur stdDeviation="10"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<rect width="1200" height="630" fill="#030305"/>
|
||||
<rect width="1200" height="630" fill="url(#bgGlowRight)"/>
|
||||
<rect width="1200" height="630" fill="url(#bgGlowBottom)"/>
|
||||
|
||||
<g opacity="0.22">
|
||||
<circle cx="998" cy="406" r="1.8" fill="#FF7649"/>
|
||||
<circle cx="1036" cy="446" r="1.4" fill="#FF7649"/>
|
||||
<circle cx="1088" cy="492" r="1.2" fill="#FF7649"/>
|
||||
<circle cx="1116" cy="540" r="1.1" fill="#FF7649"/>
|
||||
<circle cx="964" cy="502" r="1.3" fill="#FF7649"/>
|
||||
<circle cx="880" cy="528" r="1.3" fill="#FF7649"/>
|
||||
<circle cx="716" cy="452" r="1.1" fill="#FF7649"/>
|
||||
<circle cx="622" cy="396" r="1.4" fill="#FF7649"/>
|
||||
<circle cx="188" cy="558" r="1.3" fill="#FF7649"/>
|
||||
<circle cx="152" cy="580" r="1.1" fill="#FF7649"/>
|
||||
<circle cx="92" cy="594" r="1.4" fill="#FF7649"/>
|
||||
</g>
|
||||
|
||||
<path d="M870 146C990 166 1082 230 1142 328" stroke="#AA3D2B" stroke-opacity="0.16" stroke-width="2"/>
|
||||
<path d="M926 190C1036 234 1108 306 1168 420" stroke="#AA3D2B" stroke-opacity="0.12" stroke-width="2"/>
|
||||
<path d="M1044 374H1200" stroke="#B74A36" stroke-opacity="0.28" stroke-width="2"/>
|
||||
<path d="M24 522H164" stroke="#B74A36" stroke-opacity="0.22" stroke-width="2"/>
|
||||
|
||||
<rect x="42" y="108" width="1092" height="430" rx="42" fill="url(#frameGlow)"/>
|
||||
<rect x="42.75" y="108.75" width="1090.5" height="428.5" rx="41.25" stroke="url(#frameStroke)" stroke-width="1.5"/>
|
||||
<rect x="113" y="148" width="292" height="292" rx="38" fill="#09090C"/>
|
||||
<rect x="113.75" y="148.75" width="290.5" height="290.5" rx="37.25" stroke="url(#logoStroke)" stroke-width="1.5"/>
|
||||
|
||||
<ellipse cx="1118" cy="180" rx="86" ry="42" fill="#FF6532" fill-opacity="0.18" filter="url(#glowBlur)"/>
|
||||
<ellipse cx="1018" cy="328" rx="208" ry="164" fill="#8A2218" fill-opacity="0.12" filter="url(#softBlur)"/>
|
||||
<ellipse cx="96" cy="532" rx="48" ry="10" fill="#FF5E35" fill-opacity="0.24" filter="url(#softBlur)"/>
|
||||
<ellipse cx="572" cy="494" rx="302" ry="12" fill="#FF5E35" fill-opacity="0.12" filter="url(#softBlur)"/>
|
||||
|
||||
<image href="clawd-logo.png" x="124" y="158" width="270" height="270" preserveAspectRatio="xMidYMid meet"/>
|
||||
|
||||
<text x="500" y="256" fill="#F8EEE8" font-size="88" font-weight="900" letter-spacing="-4.2" font-family="'Helvetica Neue', Helvetica, Arial, sans-serif">ClawHub.ai</text>
|
||||
<text x="500" y="338" fill="#F8EEE8" font-size="42" font-weight="800" letter-spacing="-1.2" font-family="'Helvetica Neue', Helvetica, Arial, sans-serif">Equip. Install. <tspan fill="#FF6236">Build.</tspan></text>
|
||||
<text x="500" y="390" fill="#E1D4CF" font-size="23" font-weight="500" letter-spacing="-0.15" font-family="'Helvetica Neue', Helvetica, Arial, sans-serif">
|
||||
<tspan x="500" dy="0">Developer tools and agent skills</tspan>
|
||||
<tspan x="500" dy="28">for your next project.</tspan>
|
||||
</text>
|
||||
|
||||
<g>
|
||||
<rect x="148" y="432" width="924" height="104" rx="31" fill="#11090D"/>
|
||||
<rect x="148.75" y="432.75" width="922.5" height="102.5" rx="30.25" stroke="url(#searchStroke)" stroke-width="1.5"/>
|
||||
<circle cx="220" cy="484" r="19" stroke="#FFF9F3" stroke-width="5"/>
|
||||
<line x1="233" y1="497" x2="249" y2="513" stroke="#FFF9F3" stroke-width="5" stroke-linecap="round"/>
|
||||
<text x="272" y="495" fill="#DCD0CB" font-size="31" font-weight="600" letter-spacing="-0.4" font-family="'Helvetica Neue', Helvetica, Arial, sans-serif">What are you looking for?</text>
|
||||
<rect x="824" y="450" width="258" height="66" rx="21" fill="url(#buttonFill)"/>
|
||||
<text x="953" y="494" text-anchor="middle" fill="#FFF8F1" font-size="28" font-weight="800" font-family="'Helvetica Neue', Helvetica, Arial, sans-serif">Search tools</text>
|
||||
</g>
|
||||
|
||||
<g>
|
||||
<rect x="292" y="574" width="214" height="44" rx="22" fill="#1B1317" stroke="#3A292C"/>
|
||||
<text x="399" y="603" text-anchor="middle" fill="#EEE3DF" font-size="18" font-weight="500" font-family="'Helvetica Neue', Helvetica, Arial, sans-serif">self-improving</text>
|
||||
<rect x="530" y="574" width="248" height="44" rx="22" fill="#1B1317" stroke="#3A292C"/>
|
||||
<text x="654" y="603" text-anchor="middle" fill="#EEE3DF" font-size="18" font-weight="500" font-family="'Helvetica Neue', Helvetica, Arial, sans-serif">GitHub integration</text>
|
||||
<rect x="802" y="574" width="180" height="44" rx="22" fill="#1B1317" stroke="#3A292C"/>
|
||||
<text x="892" y="603" text-anchor="middle" fill="#EEE3DF" font-size="18" font-weight="500" font-family="'Helvetica Neue', Helvetica, Arial, sans-serif">dashboard</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.6 KiB |
@@ -0,0 +1,176 @@
|
||||
/* @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();
|
||||
expect(img?.getAttribute("src")).toBe("screenshot.png");
|
||||
expect(img?.getAttribute("alt")).toBe("Demo screenshot");
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,94 +1,104 @@
|
||||
import { parse } from "@create-markdown/core";
|
||||
import { blocksToHTML, renderAsync, shikiPlugin } from "@create-markdown/preview";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
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 { cn } from "../lib/utils";
|
||||
|
||||
interface MarkdownPreviewProps {
|
||||
children: string;
|
||||
className?: string;
|
||||
/** Enable Shiki syntax highlighting for code blocks (async). Default: true */
|
||||
/** Enable Shiki syntax highlighting for fenced code blocks. Default: true. */
|
||||
highlight?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
},
|
||||
);
|
||||
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.
|
||||
const baseRehype = [rehypeRaw, [rehypeSanitize, schema]] as const;
|
||||
|
||||
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 }),
|
||||
);
|
||||
}
|
||||
return highlighterPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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 "";
|
||||
}
|
||||
});
|
||||
const [highlighter, setHighlighter] = useState<AnyHighlighter | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!highlight) return;
|
||||
let cancelled = false;
|
||||
|
||||
// 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" })],
|
||||
loadHighlighter()
|
||||
.then((h) => {
|
||||
if (!cancelled) setHighlighter(h);
|
||||
})
|
||||
.then((highlighted) => {
|
||||
if (!cancelled) {
|
||||
setHtml(autolinkURLs(highlighted));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Shiki failed to load — keep the sync render
|
||||
});
|
||||
} catch {
|
||||
// Parse failed — clear
|
||||
setHtml("");
|
||||
}
|
||||
|
||||
.catch(() => {
|
||||
// Shiki failed to initialize — keep plain rendering.
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [children, highlight]);
|
||||
}, [highlight]);
|
||||
|
||||
const rehypePlugins = useMemo(() => {
|
||||
if (highlight && highlighter) {
|
||||
return [
|
||||
...baseRehype,
|
||||
[rehypeShikiFromHighlighter, highlighter, { theme: SHIKI_THEME }] as const,
|
||||
];
|
||||
}
|
||||
return baseRehype;
|
||||
}, [highlight, highlighter]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn("markdown", className)}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
<div className={cn("markdown", className)}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={rehypePlugins}>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ export function SkillCommentsPanel({ skillId, isAuthenticated, me }: SkillCommen
|
||||
comments.map((entry) => (
|
||||
<div
|
||||
key={entry.comment._id}
|
||||
className="flex gap-3 rounded-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:var(--surface)] p-3"
|
||||
className="comment-entry flex gap-3 rounded-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:var(--surface)] p-3"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<strong className="text-sm">
|
||||
|
||||
@@ -38,7 +38,7 @@ type SizeWarning = {
|
||||
};
|
||||
|
||||
const EMPTY_DIFF_TEXT = "";
|
||||
const MOBILE_DIFF_BREAKPOINT = 860;
|
||||
const MOBILE_DIFF_BREAKPOINT = 768;
|
||||
|
||||
function getDefaultViewMode() {
|
||||
if (typeof window === "undefined") return "split";
|
||||
|
||||
@@ -10,13 +10,15 @@ import Header from "../components/Header";
|
||||
import { getSiteDescription, getSiteMode, getSiteName, getSiteUrlForMode } from "../lib/site";
|
||||
import appCss from "../styles.css?url";
|
||||
|
||||
const OG_IMAGE_VERSION = "20260420-12";
|
||||
|
||||
export const Route = createRootRoute({
|
||||
head: () => {
|
||||
const mode = getSiteMode();
|
||||
const siteName = getSiteName(mode);
|
||||
const siteDescription = getSiteDescription(mode);
|
||||
const siteUrl = getSiteUrlForMode(mode);
|
||||
const ogImage = `${siteUrl}/og.png`;
|
||||
const ogImage = `${siteUrl}/og.png?v=${OG_IMAGE_VERSION}`;
|
||||
|
||||
return {
|
||||
meta: [
|
||||
@@ -117,6 +119,11 @@ function RootDocument({ children }: { children: React.ReactNode }) {
|
||||
<html lang="en">
|
||||
<head>
|
||||
<HeadContent />
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `(function(){try{var d=document.documentElement,s='clawhub-theme-selection',k='clawhub-theme',n='clawhub-theme-name',l='clawdhub-theme';var sel;try{var raw=localStorage.getItem(s);if(raw){sel=JSON.parse(raw)}}catch(e){}if(!sel){var m=localStorage.getItem(k),t=localStorage.getItem(n);if(m||t){sel={theme:t||'claw',mode:m||'system'}}else{var lg=localStorage.getItem(l);if(lg){var map={dark:'dark',light:'light',system:'system',defaultTheme:'dark',docsTheme:'light',lightTheme:'dark',landingTheme:'dark',newTheme:'dark',openknot:'dark',fieldmanual:'dark',clawdash:'light'};sel={theme:'claw',mode:map[lg]||'system'}}}}if(!sel)sel={theme:'claw',mode:'system'};var themes=['claw'],modes=['system','light','dark'];if(themes.indexOf(sel.theme)<0)sel.theme='claw';if(modes.indexOf(sel.mode)<0)sel.mode='system';var resolved=sel.mode==='system'?(window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'):sel.mode;d.dataset.theme=resolved;d.dataset.themeResolved=resolved;d.dataset.themeMode=sel.mode;d.dataset.themeFamily=sel.theme;if(resolved==='dark')d.classList.add('dark');else d.classList.remove('dark')}catch(e){}})()`,
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<AppProviders>
|
||||
|
||||
@@ -438,7 +438,8 @@ function SkillsHome() {
|
||||
/>
|
||||
<kbd>/</kbd>
|
||||
<button type="submit" className="home-v2-search-go">
|
||||
Search <ArrowRight size={16} />
|
||||
<span className="home-v2-search-go-label">Search</span>{" "}
|
||||
<ArrowRight size={16} />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
+103
-6
@@ -2031,7 +2031,7 @@ code {
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(280px, 100%), 1fr));
|
||||
gap: 16px;
|
||||
max-width: 100%;
|
||||
}
|
||||
@@ -3394,7 +3394,7 @@ code {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface-muted);
|
||||
text-align: right;
|
||||
min-width: 150px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.skill-version-label {
|
||||
@@ -3445,7 +3445,7 @@ code {
|
||||
.skill-hero-panels {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(240px, 100%), 1fr));
|
||||
}
|
||||
|
||||
.skill-panel {
|
||||
@@ -3603,6 +3603,10 @@ code {
|
||||
max-height: 220px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
}
|
||||
|
||||
.diff-monaco {
|
||||
max-height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
.diff-pill {
|
||||
@@ -3757,6 +3761,15 @@ code {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface-muted);
|
||||
align-self: flex-start;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
max-width: 100%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab-header::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
@@ -3768,6 +3781,8 @@ code {
|
||||
color: var(--ink-soft);
|
||||
cursor: pointer;
|
||||
min-height: 44px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab-button.is-active {
|
||||
@@ -4196,9 +4211,18 @@ code {
|
||||
}
|
||||
|
||||
.skill-hero-cta {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.skill-hero-cta .btn {
|
||||
width: auto;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tag-form {
|
||||
grid-template-columns: 1fr;
|
||||
align-items: stretch;
|
||||
@@ -4240,6 +4264,14 @@ code {
|
||||
row-gap: 8px;
|
||||
}
|
||||
|
||||
.browse-search-input {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.navbar-search-input {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
@@ -5956,7 +5988,12 @@ html.theme-transition::view-transition-new(theme) {
|
||||
}
|
||||
|
||||
.skill-hero-title h1 {
|
||||
font-size: 1.35rem;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.skill-hero-note {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
@@ -5967,6 +6004,43 @@ html.theme-transition::view-transition-new(theme) {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.detail-meta-bar {
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.meta-bar-stats {
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.skill-hero-sidebar-meta {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.file-viewer {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.file-viewer-body {
|
||||
max-height: 260px;
|
||||
}
|
||||
|
||||
.file-list-body {
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
.comment-entry {
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
.markdown pre {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.markdown img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.scan-result-row {
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 6px 10px;
|
||||
@@ -6606,6 +6680,8 @@ html.theme-transition::view-transition-new(theme) {
|
||||
@media (max-width: 560px) {
|
||||
.skill-list-item {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 14px 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.marketplace-icon {
|
||||
@@ -6702,6 +6778,7 @@ html.theme-transition::view-transition-new(theme) {
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
min-height: 44px;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
@@ -6902,7 +6979,21 @@ html.theme-transition::view-transition-new(theme) {
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.browse-page {
|
||||
padding: 16px 18px 40px;
|
||||
padding: 16px 16px 40px;
|
||||
}
|
||||
|
||||
.browse-page-search {
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.browse-results-toolbar {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.browse-view-btn {
|
||||
min-height: 44px;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
}
|
||||
|
||||
.browse-layout {
|
||||
@@ -7878,7 +7969,7 @@ html.theme-transition::view-transition-new(theme) {
|
||||
height: 1.15em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
justify-content: left;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -8747,6 +8838,12 @@ html.theme-transition::view-transition-new(theme) {
|
||||
padding: 20px;
|
||||
gap: 16px;
|
||||
}
|
||||
.home-v2-search-go-label {
|
||||
display: none;
|
||||
}
|
||||
.home-v2-search-go {
|
||||
padding: 13px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ═══ HOME V2 — Full-width overrides ═══ */
|
||||
|
||||
Reference in New Issue
Block a user