Compare commits

..
Author SHA1 Message Date
Val Alexander f7c72bb56c docs: add logo replacement design spec 2026-04-21 13:35:18 -05:00
7 changed files with 254 additions and 291 deletions
-30
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",
@@ -51,8 +49,6 @@
"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",
@@ -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=="],
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.
-3
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",
@@ -77,8 +76,6 @@
"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",
-176
View File
@@ -1,176 +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();
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);
});
});
+72 -82
View File
@@ -1,104 +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 { 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.
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;
/**
* 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(() => {
if (!highlight) return;
let cancelled = false;
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" })],
})
.catch(() => {
// Shiki failed to initialize — keep plain rendering.
});
.then((highlighted) => {
if (!cancelled) {
setHtml(autolinkURLs(highlighted));
}
})
.catch(() => {
// Shiki failed to load — keep the sync render
});
} catch {
// Parse failed — clear
setHtml("");
}
return () => {
cancelled = true;
};
}, [highlight]);
const rehypePlugins = useMemo(() => {
if (highlight && highlighter) {
return [
...baseRehype,
[rehypeShikiFromHighlighter, highlighter, { theme: SHIKI_THEME }] as const,
];
}
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 }}
/>
);
}