pilothub 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (272) hide show
  1. package/.env.local.example +19 -0
  2. package/.github/workflows/ci.yml +40 -0
  3. package/.oxlintrc.json +3 -0
  4. package/AGENTS.md +45 -0
  5. package/CHANGELOG.md +138 -0
  6. package/DEPRECATIONS.md +7 -0
  7. package/LICENSE +21 -0
  8. package/README.md +150 -0
  9. package/biome.json +41 -0
  10. package/convex/_generated/api.d.ts +153 -0
  11. package/convex/_generated/api.js +23 -0
  12. package/convex/_generated/dataModel.d.ts +60 -0
  13. package/convex/_generated/server.d.ts +143 -0
  14. package/convex/_generated/server.js +93 -0
  15. package/convex/auth.config.ts +8 -0
  16. package/convex/auth.ts +19 -0
  17. package/convex/comments.ts +88 -0
  18. package/convex/crons.ts +34 -0
  19. package/convex/devSeed.ts +459 -0
  20. package/convex/devSeedExtra.ts +541 -0
  21. package/convex/downloads.ts +78 -0
  22. package/convex/githubBackups.ts +170 -0
  23. package/convex/githubBackupsNode.ts +183 -0
  24. package/convex/githubImport.ts +317 -0
  25. package/convex/githubSoulBackups.ts +170 -0
  26. package/convex/githubSoulBackupsNode.ts +186 -0
  27. package/convex/http.ts +194 -0
  28. package/convex/httpApi.handlers.test.ts +488 -0
  29. package/convex/httpApi.test.ts +70 -0
  30. package/convex/httpApi.ts +305 -0
  31. package/convex/httpApiV1.handlers.test.ts +584 -0
  32. package/convex/httpApiV1.ts +1172 -0
  33. package/convex/leaderboards.ts +39 -0
  34. package/convex/lib/access.ts +36 -0
  35. package/convex/lib/apiTokenAuth.ts +36 -0
  36. package/convex/lib/badges.ts +50 -0
  37. package/convex/lib/changelog.test.ts +34 -0
  38. package/convex/lib/changelog.ts +278 -0
  39. package/convex/lib/embeddings.ts +38 -0
  40. package/convex/lib/githubBackup.ts +443 -0
  41. package/convex/lib/githubImport.test.ts +247 -0
  42. package/convex/lib/githubImport.ts +425 -0
  43. package/convex/lib/githubSoulBackup.ts +443 -0
  44. package/convex/lib/leaderboards.ts +103 -0
  45. package/convex/lib/moderation.ts +42 -0
  46. package/convex/lib/public.ts +89 -0
  47. package/convex/lib/searchText.test.ts +46 -0
  48. package/convex/lib/searchText.ts +27 -0
  49. package/convex/lib/skillBackfill.test.ts +34 -0
  50. package/convex/lib/skillBackfill.ts +67 -0
  51. package/convex/lib/skillPublish.test.ts +28 -0
  52. package/convex/lib/skillPublish.ts +284 -0
  53. package/convex/lib/skillStats.ts +80 -0
  54. package/convex/lib/skills.test.ts +197 -0
  55. package/convex/lib/skills.ts +273 -0
  56. package/convex/lib/soulChangelog.ts +273 -0
  57. package/convex/lib/soulPublish.ts +236 -0
  58. package/convex/lib/tokens.test.ts +33 -0
  59. package/convex/lib/tokens.ts +51 -0
  60. package/convex/lib/webhooks.test.ts +91 -0
  61. package/convex/lib/webhooks.ts +112 -0
  62. package/convex/maintenance.test.ts +270 -0
  63. package/convex/maintenance.ts +840 -0
  64. package/convex/rateLimits.ts +50 -0
  65. package/convex/schema.ts +472 -0
  66. package/convex/search.test.ts +12 -0
  67. package/convex/search.ts +254 -0
  68. package/convex/seed.test.ts +37 -0
  69. package/convex/seed.ts +254 -0
  70. package/convex/seedSouls.ts +111 -0
  71. package/convex/skillStatEvents.ts +568 -0
  72. package/convex/skills.ts +1606 -0
  73. package/convex/soulComments.ts +88 -0
  74. package/convex/soulDownloads.ts +14 -0
  75. package/convex/soulStars.ts +71 -0
  76. package/convex/souls.ts +570 -0
  77. package/convex/stars.ts +108 -0
  78. package/convex/statsMaintenance.ts +205 -0
  79. package/convex/telemetry.ts +434 -0
  80. package/convex/tokens.ts +88 -0
  81. package/convex/tsconfig.json +7 -0
  82. package/convex/uploads.ts +20 -0
  83. package/convex/users.ts +122 -0
  84. package/convex/webhooks.ts +50 -0
  85. package/convex.json +3 -0
  86. package/docs/README.md +32 -0
  87. package/docs/api.md +51 -0
  88. package/docs/architecture.md +61 -0
  89. package/docs/auth.md +54 -0
  90. package/docs/cli.md +117 -0
  91. package/docs/deploy.md +78 -0
  92. package/docs/diffing.md +84 -0
  93. package/docs/github-import.md +171 -0
  94. package/docs/http-api.md +187 -0
  95. package/docs/manual-testing.md +64 -0
  96. package/docs/mintlify.md +43 -0
  97. package/docs/quickstart.md +120 -0
  98. package/docs/skill-format.md +58 -0
  99. package/docs/soul-format.md +37 -0
  100. package/docs/spec.md +177 -0
  101. package/docs/telemetry.md +91 -0
  102. package/docs/troubleshooting.md +49 -0
  103. package/docs/webhook.md +51 -0
  104. package/e2e/menu-smoke.pw.test.ts +49 -0
  105. package/e2e/pilothub.e2e.test.ts +494 -0
  106. package/e2e/search-exact.pw.test.ts +97 -0
  107. package/package.json +84 -0
  108. package/packages/pilothub/LICENSE +22 -0
  109. package/packages/pilothub/README.md +57 -0
  110. package/packages/pilothub/bin/pilothub.js +2 -0
  111. package/packages/pilothub/package.json +41 -0
  112. package/packages/pilothub/src/browserAuth.test.ts +96 -0
  113. package/packages/pilothub/src/browserAuth.ts +174 -0
  114. package/packages/pilothub/src/cli/buildInfo.ts +94 -0
  115. package/packages/pilothub/src/cli/commands/auth.ts +97 -0
  116. package/packages/pilothub/src/cli/commands/delete.test.ts +73 -0
  117. package/packages/pilothub/src/cli/commands/delete.ts +83 -0
  118. package/packages/pilothub/src/cli/commands/publish.test.ts +122 -0
  119. package/packages/pilothub/src/cli/commands/publish.ts +108 -0
  120. package/packages/pilothub/src/cli/commands/skills.test.ts +191 -0
  121. package/packages/pilothub/src/cli/commands/skills.ts +380 -0
  122. package/packages/pilothub/src/cli/commands/star.ts +46 -0
  123. package/packages/pilothub/src/cli/commands/sync.test.ts +310 -0
  124. package/packages/pilothub/src/cli/commands/sync.ts +200 -0
  125. package/packages/pilothub/src/cli/commands/syncHelpers.test.ts +26 -0
  126. package/packages/pilothub/src/cli/commands/syncHelpers.ts +427 -0
  127. package/packages/pilothub/src/cli/commands/syncTypes.ts +27 -0
  128. package/packages/pilothub/src/cli/commands/unstar.ts +48 -0
  129. package/packages/pilothub/src/cli/helpStyle.ts +45 -0
  130. package/packages/pilothub/src/cli/pilotbotConfig.test.ts +159 -0
  131. package/packages/pilothub/src/cli/pilotbotConfig.ts +147 -0
  132. package/packages/pilothub/src/cli/registry.test.ts +63 -0
  133. package/packages/pilothub/src/cli/registry.ts +43 -0
  134. package/packages/pilothub/src/cli/scanSkills.test.ts +64 -0
  135. package/packages/pilothub/src/cli/scanSkills.ts +84 -0
  136. package/packages/pilothub/src/cli/slug.ts +16 -0
  137. package/packages/pilothub/src/cli/types.ts +12 -0
  138. package/packages/pilothub/src/cli/ui.ts +75 -0
  139. package/packages/pilothub/src/cli.ts +311 -0
  140. package/packages/pilothub/src/config.ts +36 -0
  141. package/packages/pilothub/src/discovery.test.ts +75 -0
  142. package/packages/pilothub/src/discovery.ts +19 -0
  143. package/packages/pilothub/src/http.test.ts +156 -0
  144. package/packages/pilothub/src/http.ts +301 -0
  145. package/packages/pilothub/src/schema/ark.ts +29 -0
  146. package/packages/pilothub/src/schema/index.ts +5 -0
  147. package/packages/pilothub/src/schema/routes.ts +22 -0
  148. package/packages/pilothub/src/schema/schemas.ts +260 -0
  149. package/packages/pilothub/src/schema/textFiles.test.ts +23 -0
  150. package/packages/pilothub/src/schema/textFiles.ts +66 -0
  151. package/packages/pilothub/src/skills.test.ts +191 -0
  152. package/packages/pilothub/src/skills.ts +172 -0
  153. package/packages/pilothub/src/types.ts +10 -0
  154. package/packages/pilothub/tsconfig.json +14 -0
  155. package/packages/schema/README.md +3 -0
  156. package/packages/schema/dist/ark.d.ts +4 -0
  157. package/packages/schema/dist/ark.js +26 -0
  158. package/packages/schema/dist/ark.js.map +1 -0
  159. package/packages/schema/dist/index.d.ts +5 -0
  160. package/packages/schema/dist/index.js +5 -0
  161. package/packages/schema/dist/index.js.map +1 -0
  162. package/packages/schema/dist/routes.d.ts +21 -0
  163. package/packages/schema/dist/routes.js +22 -0
  164. package/packages/schema/dist/routes.js.map +1 -0
  165. package/packages/schema/dist/schemas.d.ts +297 -0
  166. package/packages/schema/dist/schemas.js +243 -0
  167. package/packages/schema/dist/schemas.js.map +1 -0
  168. package/packages/schema/dist/textFiles.d.ts +5 -0
  169. package/packages/schema/dist/textFiles.js +66 -0
  170. package/packages/schema/dist/textFiles.js.map +1 -0
  171. package/packages/schema/package.json +26 -0
  172. package/packages/schema/src/ark.ts +29 -0
  173. package/packages/schema/src/index.ts +5 -0
  174. package/packages/schema/src/routes.ts +22 -0
  175. package/packages/schema/src/schemas.test.ts +123 -0
  176. package/packages/schema/src/schemas.ts +287 -0
  177. package/packages/schema/src/textFiles.test.ts +23 -0
  178. package/packages/schema/src/textFiles.ts +66 -0
  179. package/packages/schema/tsconfig.json +15 -0
  180. package/pilothub +46 -0
  181. package/playwright.config.ts +33 -0
  182. package/public/.well-known/pilothub.json +6 -0
  183. package/public/api/v1/openapi.json +379 -0
  184. package/public/favicon.ico +0 -0
  185. package/public/logo192.png +0 -0
  186. package/public/logo512.png +0 -0
  187. package/public/manifest.json +25 -0
  188. package/public/og.png +0 -0
  189. package/public/og.svg +98 -0
  190. package/public/pilot-logo.png +0 -0
  191. package/public/pilot-mark.png +0 -0
  192. package/public/robots.txt +3 -0
  193. package/public/tanstack-circle-logo.png +0 -0
  194. package/public/tanstack-word-logo-white.svg +1 -0
  195. package/scripts/check-peer-deps.ts +56 -0
  196. package/scripts/docs-list.ts +148 -0
  197. package/scripts/run-playwright-local.sh +14 -0
  198. package/server/og/fetchSkillOgMeta.ts +27 -0
  199. package/server/og/fetchSoulOgMeta.ts +27 -0
  200. package/server/og/ogAssets.ts +80 -0
  201. package/server/og/skillOgSvg.test.ts +59 -0
  202. package/server/og/skillOgSvg.ts +258 -0
  203. package/server/og/soulOgSvg.ts +209 -0
  204. package/server/routes/og/skill.png.ts +103 -0
  205. package/server/routes/og/soul.png.ts +111 -0
  206. package/src/__tests__/skill-detail-page.test.tsx +86 -0
  207. package/src/__tests__/skills-index.test.tsx +145 -0
  208. package/src/__tests__/upload.route.test.tsx +228 -0
  209. package/src/components/AppProviders.tsx +19 -0
  210. package/src/components/ClientOnly.tsx +18 -0
  211. package/src/components/Footer.tsx +29 -0
  212. package/src/components/Header.tsx +295 -0
  213. package/src/components/InstallSwitcher.tsx +53 -0
  214. package/src/components/SkillCard.tsx +36 -0
  215. package/src/components/SkillDetailPage.tsx +817 -0
  216. package/src/components/SkillDiffCard.tsx +485 -0
  217. package/src/components/SoulCard.tsx +19 -0
  218. package/src/components/SoulDetailPage.tsx +263 -0
  219. package/src/components/UserBootstrap.tsx +18 -0
  220. package/src/components/ui/dropdown-menu.tsx +67 -0
  221. package/src/components/ui/toggle-group.tsx +35 -0
  222. package/src/convex/client.ts +3 -0
  223. package/src/lib/badges.ts +29 -0
  224. package/src/lib/diffing.test.ts +163 -0
  225. package/src/lib/diffing.ts +106 -0
  226. package/src/lib/gravatar.test.ts +9 -0
  227. package/src/lib/gravatar.ts +158 -0
  228. package/src/lib/og.test.ts +142 -0
  229. package/src/lib/og.ts +156 -0
  230. package/src/lib/publicUser.ts +39 -0
  231. package/src/lib/roles.ts +19 -0
  232. package/src/lib/site.test.ts +130 -0
  233. package/src/lib/site.ts +84 -0
  234. package/src/lib/theme-transition.test.ts +134 -0
  235. package/src/lib/theme-transition.ts +134 -0
  236. package/src/lib/theme.test.tsx +88 -0
  237. package/src/lib/theme.ts +43 -0
  238. package/src/lib/uploadFiles.jsdom.test.ts +33 -0
  239. package/src/lib/uploadFiles.test.ts +123 -0
  240. package/src/lib/uploadFiles.ts +245 -0
  241. package/src/lib/uploadUtils.test.ts +78 -0
  242. package/src/lib/uploadUtils.ts +93 -0
  243. package/src/lib/useAuthStatus.ts +12 -0
  244. package/src/lib/utils.test.ts +9 -0
  245. package/src/lib/utils.ts +6 -0
  246. package/src/logo.svg +12 -0
  247. package/src/routeTree.gen.ts +345 -0
  248. package/src/router.tsx +17 -0
  249. package/src/routes/$owner/$slug.tsx +55 -0
  250. package/src/routes/__root.tsx +136 -0
  251. package/src/routes/admin.tsx +11 -0
  252. package/src/routes/cli/auth.tsx +168 -0
  253. package/src/routes/dashboard.tsx +97 -0
  254. package/src/routes/import.tsx +415 -0
  255. package/src/routes/index.tsx +252 -0
  256. package/src/routes/management.tsx +529 -0
  257. package/src/routes/settings.tsx +203 -0
  258. package/src/routes/skills/index.tsx +422 -0
  259. package/src/routes/souls/$slug.tsx +55 -0
  260. package/src/routes/souls/index.tsx +243 -0
  261. package/src/routes/stars.tsx +68 -0
  262. package/src/routes/u/$handle.tsx +307 -0
  263. package/src/routes/upload/utils.ts +81 -0
  264. package/src/routes/upload.tsx +499 -0
  265. package/src/styles.css +2718 -0
  266. package/tsconfig.json +24 -0
  267. package/tsconfig.oxlint.json +16 -0
  268. package/vercel.json +8 -0
  269. package/vite.config.ts +48 -0
  270. package/vitest.config.ts +47 -0
  271. package/vitest.e2e.config.ts +11 -0
  272. package/vitest.setup.ts +1 -0
@@ -0,0 +1,209 @@
1
+ import { FONT_MONO, FONT_SANS } from './ogAssets'
2
+
3
+ export type SoulOgSvgParams = {
4
+ markDataUrl: string
5
+ title: string
6
+ description: string
7
+ ownerLabel: string
8
+ versionLabel: string
9
+ footer: string
10
+ }
11
+
12
+ function escapeXml(value: string) {
13
+ return value
14
+ .replace(/&/g, '&')
15
+ .replace(/</g, '&lt;')
16
+ .replace(/>/g, '&gt;')
17
+ .replace(/"/g, '&quot;')
18
+ .replace(/'/g, '&#39;')
19
+ }
20
+
21
+ function wrapText(value: string, maxChars: number, maxLines: number) {
22
+ const words = value.trim().split(/\s+/).filter(Boolean)
23
+ const lines: string[] = []
24
+ let current = ''
25
+
26
+ function pushLine(line: string) {
27
+ if (!line) return
28
+ lines.push(line)
29
+ }
30
+
31
+ function splitLongWord(word: string) {
32
+ if (word.length <= maxChars) return [word]
33
+ const parts: string[] = []
34
+ let remaining = word
35
+ while (remaining.length > maxChars) {
36
+ parts.push(`${remaining.slice(0, maxChars - 1)}…`)
37
+ remaining = remaining.slice(maxChars - 1)
38
+ }
39
+ if (remaining) parts.push(remaining)
40
+ return parts
41
+ }
42
+
43
+ for (const word of words) {
44
+ if (word.length > maxChars) {
45
+ if (current) {
46
+ pushLine(current)
47
+ current = ''
48
+ if (lines.length >= maxLines - 1) break
49
+ }
50
+ const parts = splitLongWord(word)
51
+ for (const part of parts) {
52
+ pushLine(part)
53
+ if (lines.length >= maxLines) break
54
+ }
55
+ current = ''
56
+ if (lines.length >= maxLines - 1) break
57
+ continue
58
+ }
59
+
60
+ const next = current ? `${current} ${word}` : word
61
+ if (next.length <= maxChars) {
62
+ current = next
63
+ continue
64
+ }
65
+ pushLine(current)
66
+ current = word
67
+ if (lines.length >= maxLines - 1) break
68
+ }
69
+ if (lines.length < maxLines && current) pushLine(current)
70
+ if (lines.length > maxLines) lines.length = maxLines
71
+
72
+ const usedWords = lines.join(' ').split(/\s+/).filter(Boolean).length
73
+ if (usedWords < words.length) {
74
+ const last = lines.at(-1) ?? ''
75
+ const trimmed = last.length > maxChars ? last.slice(0, maxChars) : last
76
+ lines[lines.length - 1] = `${trimmed.replace(/\s+$/g, '').replace(/[.。,;:!?]+$/g, '')}…`
77
+ }
78
+ return lines
79
+ }
80
+
81
+ export function buildSoulOgSvg(params: SoulOgSvgParams) {
82
+ const rawTitle = params.title.trim() || 'SoulHub'
83
+ const rawDescription = params.description.trim() || 'SOUL.md bundle on SoulHub.'
84
+
85
+ const cardX = 72
86
+ const cardY = 96
87
+ const cardW = 640
88
+ const cardH = 456
89
+ const cardR = 34
90
+
91
+ const titleLines = wrapText(rawTitle, 22, 2)
92
+ const descLines = wrapText(rawDescription, 42, 3)
93
+
94
+ const titleFontSize = titleLines.length > 1 || rawTitle.length > 24 ? 72 : 80
95
+ const titleY = titleLines.length > 1 ? 258 : 280
96
+ const titleLineHeight = 84
97
+
98
+ const descY = titleLines.length > 1 ? 395 : 380
99
+ const descLineHeight = 34
100
+
101
+ const pillText = `${params.ownerLabel} • ${params.versionLabel}`
102
+ const footerY = cardY + cardH - 18
103
+
104
+ const titleTspans = titleLines
105
+ .map((line, index) => {
106
+ const dy = index === 0 ? 0 : titleLineHeight
107
+ return `<tspan x="114" dy="${dy}">${escapeXml(line)}</tspan>`
108
+ })
109
+ .join('')
110
+
111
+ const descTspans = descLines
112
+ .map((line, index) => {
113
+ const dy = index === 0 ? 0 : descLineHeight
114
+ return `<tspan x="114" dy="${dy}">${escapeXml(line)}</tspan>`
115
+ })
116
+ .join('')
117
+
118
+ return `<?xml version="1.0" encoding="UTF-8"?>
119
+ <svg width="1200" height="630" viewBox="0 0 1200 630" fill="none" xmlns="http://www.w3.org/2000/svg">
120
+ <defs>
121
+ <linearGradient id="bg" x1="0" y1="0" x2="1200" y2="630" gradientUnits="userSpaceOnUse">
122
+ <stop stop-color="#0E1314"/>
123
+ <stop offset="0.55" stop-color="#142021"/>
124
+ <stop offset="1" stop-color="#0E1314"/>
125
+ </linearGradient>
126
+
127
+ <radialGradient id="glowGold" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(300 80) rotate(120) scale(520 420)">
128
+ <stop stop-color="#E7B96B" stop-opacity="0.45"/>
129
+ <stop offset="1" stop-color="#E7B96B" stop-opacity="0"/>
130
+ </radialGradient>
131
+
132
+ <radialGradient id="glowTeal" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(1040 140) rotate(140) scale(520 420)">
133
+ <stop stop-color="#6AD6C4" stop-opacity="0.35"/>
134
+ <stop offset="1" stop-color="#6AD6C4" stop-opacity="0"/>
135
+ </radialGradient>
136
+
137
+ <filter id="softBlur" x="-40%" y="-40%" width="180%" height="180%">
138
+ <feGaussianBlur stdDeviation="24"/>
139
+ </filter>
140
+
141
+ <filter id="cardShadow" x="-20%" y="-20%" width="140%" height="140%">
142
+ <feDropShadow dx="0" dy="18" stdDeviation="26" flood-color="#000000" flood-opacity="0.6"/>
143
+ </filter>
144
+
145
+ <linearGradient id="pill" x1="0" y1="0" x2="520" y2="0" gradientUnits="userSpaceOnUse">
146
+ <stop stop-color="#E7B96B" stop-opacity="0.26"/>
147
+ <stop offset="1" stop-color="#E7B96B" stop-opacity="0.12"/>
148
+ </linearGradient>
149
+
150
+ <linearGradient id="stroke" x1="0" y1="0" x2="0" y2="1">
151
+ <stop stop-color="#FFFFFF" stop-opacity="0.18"/>
152
+ <stop offset="1" stop-color="#FFFFFF" stop-opacity="0.08"/>
153
+ </linearGradient>
154
+
155
+ <clipPath id="cardClip">
156
+ <rect x="${cardX}" y="${cardY}" width="${cardW}" height="${cardH}" rx="${cardR}"/>
157
+ </clipPath>
158
+ </defs>
159
+
160
+ <rect width="1200" height="630" fill="url(#bg)"/>
161
+ <circle cx="300" cy="80" r="520" fill="url(#glowGold)" filter="url(#softBlur)"/>
162
+ <circle cx="1040" cy="140" r="520" fill="url(#glowTeal)" filter="url(#softBlur)"/>
163
+
164
+ <g opacity="0.12">
165
+ <path d="M0 90 C180 130 360 50 540 96 C720 142 840 220 1200 170" stroke="#FFFFFF" stroke-opacity="0.12" stroke-width="2"/>
166
+ <path d="M0 190 C240 250 400 170 600 214 C800 258 960 330 1200 300" stroke="#FFFFFF" stroke-opacity="0.1" stroke-width="2"/>
167
+ <path d="M0 450 C240 390 460 520 660 470 C860 420 1000 500 1200 460" stroke="#FFFFFF" stroke-opacity="0.08" stroke-width="2"/>
168
+ </g>
169
+
170
+ <g opacity="0.24" filter="url(#softBlur)">
171
+ <image href="${params.markDataUrl}" x="740" y="70" width="560" height="560" preserveAspectRatio="xMidYMid meet"/>
172
+ </g>
173
+
174
+ <g filter="url(#cardShadow)">
175
+ <rect x="${cardX}" y="${cardY}" width="${cardW}" height="${cardH}" rx="${cardR}" fill="#1B201F" fill-opacity="0.92" stroke="url(#stroke)"/>
176
+ </g>
177
+
178
+ <g clip-path="url(#cardClip)">
179
+ <image href="${params.markDataUrl}" x="108" y="134" width="46" height="46" preserveAspectRatio="xMidYMid meet"/>
180
+
181
+ <g>
182
+ <rect x="166" y="136" width="520" height="42" rx="21" fill="url(#pill)" stroke="#E7B96B" stroke-opacity="0.3"/>
183
+ <text x="186" y="163"
184
+ fill="#F7F1E8"
185
+ font-size="18"
186
+ font-weight="600"
187
+ font-family="${FONT_SANS}, sans-serif"
188
+ opacity="0.92">${escapeXml(pillText)}</text>
189
+ </g>
190
+
191
+ <text x="114" y="${titleY}"
192
+ fill="#F7F1E8"
193
+ font-size="${titleFontSize}"
194
+ font-weight="800"
195
+ font-family="${FONT_SANS}, sans-serif">${titleTspans}</text>
196
+
197
+ <text x="114" y="${descY}"
198
+ fill="#C7BFB5"
199
+ font-size="26"
200
+ font-weight="500"
201
+ font-family="${FONT_SANS}, sans-serif">${descTspans}</text>
202
+
203
+ <text x="114" y="${footerY}"
204
+ fill="#B7B0A6"
205
+ font-size="18"
206
+ font-family="${FONT_MONO}, monospace">${escapeXml(params.footer)}</text>
207
+ </g>
208
+ </svg>`
209
+ }
@@ -0,0 +1,103 @@
1
+ import { initWasm, Resvg } from '@resvg/resvg-wasm'
2
+ import { defineEventHandler, getQuery, getRequestHost, setHeader } from 'h3'
3
+
4
+ import { fetchSkillOgMeta } from '../../og/fetchSkillOgMeta'
5
+ import {
6
+ FONT_MONO,
7
+ FONT_SANS,
8
+ getFontBuffers,
9
+ getMarkDataUrl,
10
+ getResvgWasm,
11
+ } from '../../og/ogAssets'
12
+ import { buildSkillOgSvg } from '../../og/skillOgSvg'
13
+
14
+ type OgQuery = {
15
+ slug?: string
16
+ owner?: string
17
+ version?: string
18
+ title?: string
19
+ description?: string
20
+ v?: string
21
+ }
22
+
23
+ let wasmInitPromise: Promise<void> | null = null
24
+
25
+ function cleanString(value: unknown) {
26
+ if (typeof value !== 'string') return ''
27
+ return value.trim()
28
+ }
29
+
30
+ function getApiBase(eventHost: string | null) {
31
+ const direct = process.env.VITE_CONVEX_SITE_URL?.trim()
32
+ if (direct) return direct
33
+
34
+ const site = process.env.SITE_URL?.trim() || process.env.VITE_SITE_URL?.trim()
35
+ if (site) return site
36
+
37
+ if (eventHost) return `https://${eventHost}`
38
+ return 'https://pilothub.com'
39
+ }
40
+
41
+ async function ensureWasm() {
42
+ if (!wasmInitPromise) {
43
+ wasmInitPromise = getResvgWasm().then((wasm) => initWasm(wasm))
44
+ }
45
+ await wasmInitPromise
46
+ }
47
+
48
+ export default defineEventHandler(async (event) => {
49
+ const query = getQuery(event) as OgQuery
50
+ const slug = cleanString(query.slug)
51
+ if (!slug) {
52
+ setHeader(event, 'Content-Type', 'text/plain; charset=utf-8')
53
+ return 'Missing `slug` query param.'
54
+ }
55
+
56
+ const ownerFromQuery = cleanString(query.owner)
57
+ const versionFromQuery = cleanString(query.version)
58
+ const titleFromQuery = cleanString(query.title)
59
+ const descriptionFromQuery = cleanString(query.description)
60
+
61
+ const needFetch = !titleFromQuery || !descriptionFromQuery || !ownerFromQuery || !versionFromQuery
62
+ const meta = needFetch ? await fetchSkillOgMeta(slug, getApiBase(getRequestHost(event))) : null
63
+
64
+ const owner = ownerFromQuery || meta?.owner || ''
65
+ const version = versionFromQuery || meta?.version || ''
66
+ const title = titleFromQuery || meta?.displayName || slug
67
+ const description = descriptionFromQuery || meta?.summary || ''
68
+
69
+ const ownerLabel = owner ? `@${owner}` : 'pilothub'
70
+ const versionLabel = version ? `v${version}` : 'latest'
71
+ const footer = owner ? `pilothub.com/${owner}/${slug}` : `pilothub.com/skills/${slug}`
72
+
73
+ const cacheKey = version ? 'public, max-age=31536000, immutable' : 'public, max-age=3600'
74
+ setHeader(event, 'Cache-Control', cacheKey)
75
+ setHeader(event, 'Content-Type', 'image/png')
76
+
77
+ const [markDataUrl, fontBuffers] = await Promise.all([
78
+ getMarkDataUrl(),
79
+ ensureWasm().then(() => getFontBuffers()),
80
+ ])
81
+
82
+ const svg = buildSkillOgSvg({
83
+ markDataUrl,
84
+ title,
85
+ description,
86
+ ownerLabel,
87
+ versionLabel,
88
+ footer,
89
+ })
90
+
91
+ const resvg = new Resvg(svg, {
92
+ fitTo: { mode: 'width', value: 1200 },
93
+ font: {
94
+ fontBuffers,
95
+ defaultFontFamily: FONT_SANS,
96
+ sansSerifFamily: FONT_SANS,
97
+ monospaceFamily: FONT_MONO,
98
+ },
99
+ })
100
+ const png = resvg.render().asPng()
101
+ resvg.free()
102
+ return png
103
+ })
@@ -0,0 +1,111 @@
1
+ import { initWasm, Resvg } from '@resvg/resvg-wasm'
2
+ import { defineEventHandler, getQuery, getRequestHost, setHeader } from 'h3'
3
+
4
+ import type { SoulOgMeta } from '../../og/fetchSoulOgMeta'
5
+ import { fetchSoulOgMeta } from '../../og/fetchSoulOgMeta'
6
+ import {
7
+ FONT_MONO,
8
+ FONT_SANS,
9
+ getFontBuffers,
10
+ getMarkDataUrl,
11
+ getResvgWasm,
12
+ } from '../../og/ogAssets'
13
+ import { buildSoulOgSvg } from '../../og/soulOgSvg'
14
+
15
+ type OgQuery = {
16
+ slug?: string
17
+ owner?: string
18
+ version?: string
19
+ title?: string
20
+ description?: string
21
+ v?: string
22
+ }
23
+
24
+ let wasmInitPromise: Promise<void> | null = null
25
+
26
+ function cleanString(value: unknown) {
27
+ if (typeof value !== 'string') return ''
28
+ return value.trim()
29
+ }
30
+
31
+ function getApiBase(eventHost: string | null) {
32
+ const direct = process.env.VITE_CONVEX_SITE_URL?.trim()
33
+ if (direct) return direct
34
+
35
+ const site = process.env.SITE_URL?.trim() || process.env.VITE_SITE_URL?.trim()
36
+ if (site) return site
37
+
38
+ if (eventHost) return `https://${eventHost}`
39
+ return 'https://onlycrabs.ai'
40
+ }
41
+
42
+ async function ensureWasm() {
43
+ if (!wasmInitPromise) {
44
+ wasmInitPromise = getResvgWasm().then((wasm) => initWasm(wasm))
45
+ }
46
+ await wasmInitPromise
47
+ }
48
+
49
+ function buildFooter(slug: string, owner: string | null) {
50
+ if (owner) return `@${owner}/${slug}`
51
+ return `souls/${slug}`
52
+ }
53
+
54
+ export default defineEventHandler(async (event) => {
55
+ const query = getQuery(event) as OgQuery
56
+ const slug = cleanString(query.slug)
57
+ if (!slug) {
58
+ setHeader(event, 'Content-Type', 'text/plain; charset=utf-8')
59
+ return 'Missing `slug` query param.'
60
+ }
61
+
62
+ const ownerFromQuery = cleanString(query.owner)
63
+ const versionFromQuery = cleanString(query.version)
64
+ const titleFromQuery = cleanString(query.title)
65
+ const descriptionFromQuery = cleanString(query.description)
66
+
67
+ const needFetch = !titleFromQuery || !descriptionFromQuery || !ownerFromQuery || !versionFromQuery
68
+ const meta: SoulOgMeta | null = needFetch
69
+ ? await fetchSoulOgMeta(slug, getApiBase(getRequestHost(event)))
70
+ : null
71
+
72
+ const owner = ownerFromQuery || meta?.owner || ''
73
+ const version = versionFromQuery || meta?.version || ''
74
+ const title = titleFromQuery || meta?.displayName || slug
75
+ const description = descriptionFromQuery || meta?.summary || ''
76
+
77
+ const ownerLabel = owner ? `@${owner}` : 'SoulHub'
78
+ const versionLabel = version ? `v${version}` : 'latest'
79
+ const footer = buildFooter(slug, owner || null)
80
+
81
+ const cacheKey = version ? 'public, max-age=31536000, immutable' : 'public, max-age=3600'
82
+ setHeader(event, 'Cache-Control', cacheKey)
83
+ setHeader(event, 'Content-Type', 'image/png')
84
+
85
+ const [markDataUrl, fontBuffers] = await Promise.all([
86
+ getMarkDataUrl(),
87
+ ensureWasm().then(() => getFontBuffers()),
88
+ ])
89
+
90
+ const svg = buildSoulOgSvg({
91
+ markDataUrl,
92
+ title,
93
+ description,
94
+ ownerLabel,
95
+ versionLabel,
96
+ footer,
97
+ })
98
+
99
+ const resvg = new Resvg(svg, {
100
+ fitTo: { mode: 'width', value: 1200 },
101
+ font: {
102
+ fontBuffers,
103
+ defaultFontFamily: FONT_SANS,
104
+ sansSerifFamily: FONT_SANS,
105
+ monospaceFamily: FONT_MONO,
106
+ },
107
+ })
108
+ const png = resvg.render().asPng()
109
+ resvg.free()
110
+ return png
111
+ })
@@ -0,0 +1,86 @@
1
+ import { render, screen, waitFor } from '@testing-library/react'
2
+ import { vi } from 'vitest'
3
+
4
+ import { SkillDetailPage } from '../components/SkillDetailPage'
5
+
6
+ const navigateMock = vi.fn()
7
+ const useAuthStatusMock = vi.fn()
8
+
9
+ vi.mock('@tanstack/react-router', () => ({
10
+ useNavigate: () => navigateMock,
11
+ }))
12
+
13
+ const useQueryMock = vi.fn()
14
+ const getReadmeMock = vi.fn()
15
+
16
+ vi.mock('convex/react', () => ({
17
+ useQuery: (...args: unknown[]) => useQueryMock(...args),
18
+ useMutation: () => vi.fn(),
19
+ useAction: () => getReadmeMock,
20
+ }))
21
+
22
+ vi.mock('../lib/useAuthStatus', () => ({
23
+ useAuthStatus: () => useAuthStatusMock(),
24
+ }))
25
+
26
+ describe('SkillDetailPage', () => {
27
+ beforeEach(() => {
28
+ useQueryMock.mockReset()
29
+ getReadmeMock.mockReset()
30
+ navigateMock.mockReset()
31
+ useAuthStatusMock.mockReset()
32
+ getReadmeMock.mockResolvedValue({ text: '' })
33
+ useAuthStatusMock.mockReturnValue({
34
+ isAuthenticated: false,
35
+ isLoading: false,
36
+ me: null,
37
+ })
38
+ useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
39
+ if (args === 'skip') return undefined
40
+ return undefined
41
+ })
42
+ })
43
+
44
+ it('shows a loading indicator while loading', () => {
45
+ useQueryMock.mockImplementationOnce(() => undefined) // getBySlug
46
+
47
+ render(<SkillDetailPage slug="weather" />)
48
+ expect(screen.getByText(/Loading skill/i)).toBeTruthy()
49
+ expect(screen.queryByText(/Skill not found/i)).toBeNull()
50
+ })
51
+
52
+ it('shows not found when skill query resolves to null', async () => {
53
+ useQueryMock.mockImplementationOnce(() => null) // getBySlug
54
+
55
+ render(<SkillDetailPage slug="missing-skill" />)
56
+ expect(await screen.findByText(/Skill not found/i)).toBeTruthy()
57
+ })
58
+
59
+ it('redirects legacy routes to canonical owner/slug', async () => {
60
+ useQueryMock.mockImplementationOnce(() => ({
61
+ skill: {
62
+ _id: 'skills:1',
63
+ slug: 'weather',
64
+ displayName: 'Weather',
65
+ summary: 'Get current weather.',
66
+ ownerUserId: 'users:1',
67
+ tags: {},
68
+ stats: { stars: 0, downloads: 0 },
69
+ },
70
+ owner: { handle: 'steipete', name: 'Peter' },
71
+ latestVersion: { _id: 'skillVersions:1', version: '1.0.0', parsed: {} },
72
+ }))
73
+
74
+ render(<SkillDetailPage slug="weather" redirectToCanonical />)
75
+ expect(screen.getByText(/Loading skill/i)).toBeTruthy()
76
+
77
+ await waitFor(() => {
78
+ expect(navigateMock).toHaveBeenCalled()
79
+ })
80
+ expect(navigateMock).toHaveBeenCalledWith({
81
+ to: '/$owner/$slug',
82
+ params: { owner: 'steipete', slug: 'weather' },
83
+ replace: true,
84
+ })
85
+ })
86
+ })
@@ -0,0 +1,145 @@
1
+ /* @vitest-environment jsdom */
2
+ import { act, fireEvent, render, screen } from '@testing-library/react'
3
+ import type { ReactNode } from 'react'
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5
+
6
+ import { SkillsIndex } from '../routes/skills/index'
7
+
8
+ const navigateMock = vi.fn()
9
+ const useActionMock = vi.fn()
10
+ const usePaginatedQueryMock = vi.fn()
11
+ let searchMock: Record<string, unknown> = {}
12
+
13
+ vi.mock('@tanstack/react-router', () => ({
14
+ createFileRoute: () => (_config: { component: unknown; validateSearch: unknown }) => ({
15
+ useNavigate: () => navigateMock,
16
+ useSearch: () => searchMock,
17
+ }),
18
+ Link: (props: { children: ReactNode }) => <a href="/">{props.children}</a>,
19
+ }))
20
+
21
+ vi.mock('convex/react', () => ({
22
+ useAction: (...args: unknown[]) => useActionMock(...args),
23
+ }))
24
+
25
+ vi.mock('convex-helpers/react', () => ({
26
+ usePaginatedQuery: (...args: unknown[]) => usePaginatedQueryMock(...args),
27
+ }))
28
+
29
+ describe('SkillsIndex', () => {
30
+ beforeEach(() => {
31
+ usePaginatedQueryMock.mockReset()
32
+ useActionMock.mockReset()
33
+ navigateMock.mockReset()
34
+ searchMock = {}
35
+ useActionMock.mockReturnValue(() => Promise.resolve([]))
36
+ // Default: return empty results with Exhausted status
37
+ usePaginatedQueryMock.mockReturnValue({
38
+ results: [],
39
+ status: 'Exhausted',
40
+ loadMore: vi.fn(),
41
+ })
42
+ })
43
+
44
+ afterEach(() => {
45
+ vi.useRealTimers()
46
+ vi.unstubAllGlobals()
47
+ })
48
+
49
+ it('requests the first skills page', () => {
50
+ render(<SkillsIndex />)
51
+ // usePaginatedQuery should be called with the API endpoint and empty args
52
+ expect(usePaginatedQueryMock).toHaveBeenCalledWith(
53
+ expect.anything(),
54
+ {},
55
+ { initialNumItems: 25 },
56
+ )
57
+ })
58
+
59
+ it('renders an empty state when no skills are returned', () => {
60
+ render(<SkillsIndex />)
61
+ expect(screen.getByText('No skills match that filter.')).toBeTruthy()
62
+ })
63
+
64
+ it('skips list query and calls search when query is set', async () => {
65
+ searchMock = { q: 'remind' }
66
+ const actionFn = vi.fn().mockResolvedValue([])
67
+ useActionMock.mockReturnValue(actionFn)
68
+ vi.useFakeTimers()
69
+
70
+ render(<SkillsIndex />)
71
+
72
+ // usePaginatedQuery should be called with 'skip' when there's a search query
73
+ expect(usePaginatedQueryMock).toHaveBeenCalledWith(expect.anything(), 'skip', {
74
+ initialNumItems: 25,
75
+ })
76
+ await act(async () => {
77
+ await vi.runAllTimersAsync()
78
+ })
79
+ expect(actionFn).toHaveBeenCalledWith({
80
+ query: 'remind',
81
+ highlightedOnly: false,
82
+ limit: 25,
83
+ })
84
+ await act(async () => {
85
+ await vi.runAllTimersAsync()
86
+ })
87
+ expect(actionFn).toHaveBeenCalledWith({
88
+ query: 'remind',
89
+ highlightedOnly: false,
90
+ limit: 25,
91
+ })
92
+ })
93
+
94
+ it('loads more results when search pagination is requested', async () => {
95
+ searchMock = { q: 'remind' }
96
+ vi.stubGlobal('IntersectionObserver', undefined)
97
+ const actionFn = vi
98
+ .fn()
99
+ .mockResolvedValueOnce(makeSearchResults(25))
100
+ .mockResolvedValueOnce(makeSearchResults(50))
101
+ useActionMock.mockReturnValue(actionFn)
102
+ vi.useFakeTimers()
103
+
104
+ render(<SkillsIndex />)
105
+ await act(async () => {
106
+ await vi.runAllTimersAsync()
107
+ })
108
+
109
+ const loadMoreButton = screen.getByRole('button', { name: 'Load more' })
110
+ await act(async () => {
111
+ fireEvent.click(loadMoreButton)
112
+ await vi.runAllTimersAsync()
113
+ })
114
+
115
+ expect(actionFn).toHaveBeenLastCalledWith({
116
+ query: 'remind',
117
+ highlightedOnly: false,
118
+ limit: 50,
119
+ })
120
+ })
121
+ })
122
+
123
+ function makeSearchResults(count: number) {
124
+ return Array.from({ length: count }, (_, index) => ({
125
+ score: 0.9,
126
+ skill: {
127
+ _id: `skill_${index}`,
128
+ slug: `skill-${index}`,
129
+ displayName: `Skill ${index}`,
130
+ summary: `Summary ${index}`,
131
+ tags: {},
132
+ stats: {
133
+ downloads: 0,
134
+ installsCurrent: 0,
135
+ installsAllTime: 0,
136
+ stars: 0,
137
+ versions: 1,
138
+ comments: 0,
139
+ },
140
+ createdAt: 0,
141
+ updatedAt: 0,
142
+ },
143
+ version: null,
144
+ }))
145
+ }