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,263 @@
1
+ import { useAction, useMutation, useQuery } from 'convex/react'
2
+ import { useEffect, useMemo, useRef, useState } from 'react'
3
+ import ReactMarkdown from 'react-markdown'
4
+ import remarkGfm from 'remark-gfm'
5
+ import { api } from '../../convex/_generated/api'
6
+ import type { Doc } from '../../convex/_generated/dataModel'
7
+ import type { PublicSoul, PublicUser } from '../lib/publicUser'
8
+ import { isModerator } from '../lib/roles'
9
+ import { useAuthStatus } from '../lib/useAuthStatus'
10
+
11
+ type SoulDetailPageProps = {
12
+ slug: string
13
+ }
14
+
15
+ type SoulBySlugResult = {
16
+ soul: PublicSoul
17
+ latestVersion: Doc<'soulVersions'> | null
18
+ owner: PublicUser | null
19
+ } | null
20
+
21
+ export function SoulDetailPage({ slug }: SoulDetailPageProps) {
22
+ const { isAuthenticated, me } = useAuthStatus()
23
+ const result = useQuery(api.souls.getBySlug, { slug }) as SoulBySlugResult | undefined
24
+ const toggleStar = useMutation(api.soulStars.toggle)
25
+ const addComment = useMutation(api.soulComments.add)
26
+ const removeComment = useMutation(api.soulComments.remove)
27
+ const getReadme = useAction(api.souls.getReadme)
28
+ const ensureSoulSeeds = useAction(api.seed.ensureSoulSeeds)
29
+ const seedEnsuredRef = useRef(false)
30
+ const [readme, setReadme] = useState<string | null>(null)
31
+ const [readmeError, setReadmeError] = useState<string | null>(null)
32
+ const [comment, setComment] = useState('')
33
+
34
+ const isLoadingSoul = result === undefined
35
+ const soul = result?.soul
36
+ const owner = result?.owner
37
+ const latestVersion = result?.latestVersion
38
+ const versions = useQuery(
39
+ api.souls.listVersions,
40
+ soul ? { soulId: soul._id, limit: 50 } : 'skip',
41
+ ) as Doc<'soulVersions'>[] | undefined
42
+
43
+ const isStarred = useQuery(
44
+ api.soulStars.isStarred,
45
+ isAuthenticated && soul ? { soulId: soul._id } : 'skip',
46
+ )
47
+
48
+ const comments = useQuery(
49
+ api.soulComments.listBySoul,
50
+ soul ? { soulId: soul._id, limit: 50 } : 'skip',
51
+ ) as Array<{ comment: Doc<'soulComments'>; user: PublicUser | null }> | undefined
52
+
53
+ const readmeContent = useMemo(() => {
54
+ if (!readme) return null
55
+ return stripFrontmatter(readme)
56
+ }, [readme])
57
+
58
+ useEffect(() => {
59
+ if (seedEnsuredRef.current) return
60
+ seedEnsuredRef.current = true
61
+ void ensureSoulSeeds({})
62
+ }, [ensureSoulSeeds])
63
+
64
+ useEffect(() => {
65
+ if (!latestVersion) return
66
+ setReadme(null)
67
+ setReadmeError(null)
68
+ let cancelled = false
69
+ void getReadme({ versionId: latestVersion._id })
70
+ .then((data) => {
71
+ if (cancelled) return
72
+ setReadme(data.text)
73
+ })
74
+ .catch((error) => {
75
+ if (cancelled) return
76
+ setReadmeError(error instanceof Error ? error.message : 'Failed to load SOUL.md')
77
+ setReadme(null)
78
+ })
79
+ return () => {
80
+ cancelled = true
81
+ }
82
+ }, [latestVersion, getReadme])
83
+
84
+ if (isLoadingSoul) {
85
+ return (
86
+ <main className="section">
87
+ <div className="card">
88
+ <div className="loading-indicator">Loading soul…</div>
89
+ </div>
90
+ </main>
91
+ )
92
+ }
93
+
94
+ if (result === null || !soul) {
95
+ return (
96
+ <main className="section">
97
+ <div className="card">Soul not found.</div>
98
+ </main>
99
+ )
100
+ }
101
+
102
+ const ownerHandle = owner?.handle ?? owner?.name ?? null
103
+ const downloadBase = `${import.meta.env.VITE_CONVEX_SITE_URL}/api/v1/souls/${soul.slug}/file`
104
+
105
+ return (
106
+ <main className="section">
107
+ <div className="skill-detail-stack">
108
+ <div className="card skill-hero">
109
+ <div className="skill-hero-header">
110
+ <div className="skill-hero-title">
111
+ <h1 className="section-title" style={{ margin: 0 }}>
112
+ {soul.displayName}
113
+ </h1>
114
+ <p className="section-subtitle">{soul.summary ?? 'No summary provided.'}</p>
115
+ <div className="stat">
116
+ ⭐ {soul.stats.stars} · ⤓ {soul.stats.downloads} · {soul.stats.versions} versions
117
+ </div>
118
+ {ownerHandle ? (
119
+ <div className="stat">
120
+ by <a href={`/u/${ownerHandle}`}>@{ownerHandle}</a>
121
+ </div>
122
+ ) : null}
123
+ <div className="skill-actions">
124
+ {isAuthenticated ? (
125
+ <button
126
+ className={`star-toggle${isStarred ? ' is-active' : ''}`}
127
+ type="button"
128
+ onClick={() => void toggleStar({ soulId: soul._id })}
129
+ aria-label={isStarred ? 'Unstar soul' : 'Star soul'}
130
+ >
131
+ <span aria-hidden="true">★</span>
132
+ </button>
133
+ ) : null}
134
+ </div>
135
+ </div>
136
+ <div className="skill-hero-cta">
137
+ <div className="skill-version-pill">
138
+ <span className="skill-version-label">Current version</span>
139
+ <strong>v{latestVersion?.version ?? '—'}</strong>
140
+ </div>
141
+ <a
142
+ className="btn btn-primary"
143
+ href={`${downloadBase}?path=SOUL.md`}
144
+ aria-label="Download SOUL.md"
145
+ >
146
+ Download SOUL.md
147
+ </a>
148
+ </div>
149
+ </div>
150
+ </div>
151
+
152
+ <div className="card">
153
+ <div className="skill-readme markdown">
154
+ {readmeContent ? (
155
+ <ReactMarkdown remarkPlugins={[remarkGfm]}>{readmeContent}</ReactMarkdown>
156
+ ) : readmeError ? (
157
+ <div className="stat">Failed to load SOUL.md: {readmeError}</div>
158
+ ) : (
159
+ <div className="loading-indicator">Loading SOUL.md…</div>
160
+ )}
161
+ </div>
162
+ </div>
163
+
164
+ <div className="card">
165
+ <h2 className="section-title" style={{ fontSize: '1.2rem', marginBottom: 8 }}>
166
+ Versions
167
+ </h2>
168
+ <div className="version-scroll">
169
+ <div className="version-list">
170
+ {(versions ?? []).map((version) => (
171
+ <div key={version._id} className="version-row">
172
+ <div className="version-info">
173
+ <div>
174
+ v{version.version} · {new Date(version.createdAt).toLocaleDateString()}
175
+ {version.changelogSource === 'auto' ? (
176
+ <span style={{ color: 'var(--ink-soft)' }}> · auto</span>
177
+ ) : null}
178
+ </div>
179
+ <div style={{ color: '#5c554e', whiteSpace: 'pre-wrap' }}>
180
+ {version.changelog}
181
+ </div>
182
+ </div>
183
+ <div className="version-actions">
184
+ <a
185
+ className="btn version-zip"
186
+ href={`${downloadBase}?path=SOUL.md&version=${encodeURIComponent(
187
+ version.version,
188
+ )}`}
189
+ >
190
+ SOUL.md
191
+ </a>
192
+ </div>
193
+ </div>
194
+ ))}
195
+ </div>
196
+ </div>
197
+ </div>
198
+
199
+ <div className="card">
200
+ <h2 className="section-title" style={{ fontSize: '1.2rem', margin: 0 }}>
201
+ Comments
202
+ </h2>
203
+ {isAuthenticated ? (
204
+ <form
205
+ onSubmit={(event) => {
206
+ event.preventDefault()
207
+ if (!comment.trim()) return
208
+ void addComment({ soulId: soul._id, body: comment.trim() }).then(() =>
209
+ setComment(''),
210
+ )
211
+ }}
212
+ className="comment-form"
213
+ >
214
+ <textarea
215
+ className="comment-input"
216
+ rows={4}
217
+ value={comment}
218
+ onChange={(event) => setComment(event.target.value)}
219
+ placeholder="Leave a note…"
220
+ />
221
+ <button className="btn comment-submit" type="submit">
222
+ Post comment
223
+ </button>
224
+ </form>
225
+ ) : (
226
+ <p className="section-subtitle">Sign in to comment.</p>
227
+ )}
228
+ <div style={{ display: 'grid', gap: 12, marginTop: 16 }}>
229
+ {(comments ?? []).length === 0 ? (
230
+ <div className="stat">No comments yet.</div>
231
+ ) : (
232
+ (comments ?? []).map((entry) => (
233
+ <div key={entry.comment._id} className="stat" style={{ alignItems: 'flex-start' }}>
234
+ <div>
235
+ <strong>@{entry.user?.handle ?? entry.user?.name ?? 'user'}</strong>
236
+ <div style={{ color: '#5c554e' }}>{entry.comment.body}</div>
237
+ </div>
238
+ {isAuthenticated && me && (me._id === entry.comment.userId || isModerator(me)) ? (
239
+ <button
240
+ className="btn"
241
+ type="button"
242
+ onClick={() => void removeComment({ commentId: entry.comment._id })}
243
+ >
244
+ Delete
245
+ </button>
246
+ ) : null}
247
+ </div>
248
+ ))
249
+ )}
250
+ </div>
251
+ </div>
252
+ </div>
253
+ </main>
254
+ )
255
+ }
256
+
257
+ function stripFrontmatter(content: string) {
258
+ const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
259
+ if (!normalized.startsWith('---')) return content
260
+ const endIndex = normalized.indexOf('\n---', 3)
261
+ if (endIndex === -1) return content
262
+ return normalized.slice(endIndex + 4).replace(/^\n+/, '')
263
+ }
@@ -0,0 +1,18 @@
1
+ import { useMutation } from 'convex/react'
2
+ import { useEffect, useRef } from 'react'
3
+ import { api } from '../../convex/_generated/api'
4
+ import { useAuthStatus } from '../lib/useAuthStatus'
5
+
6
+ export function UserBootstrap() {
7
+ const { isAuthenticated, isLoading } = useAuthStatus()
8
+ const ensureUser = useMutation(api.users.ensure)
9
+ const didRun = useRef(false)
10
+
11
+ useEffect(() => {
12
+ if (isLoading || !isAuthenticated || didRun.current) return
13
+ didRun.current = true
14
+ void ensureUser()
15
+ }, [isAuthenticated, isLoading, ensureUser])
16
+
17
+ return null
18
+ }
@@ -0,0 +1,67 @@
1
+ import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
2
+ import * as React from 'react'
3
+ import { cn } from '../../lib/utils'
4
+
5
+ const DropdownMenu = DropdownMenuPrimitive.Root
6
+ const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
7
+ const DropdownMenuGroup = DropdownMenuPrimitive.Group
8
+ const DropdownMenuPortal = DropdownMenuPrimitive.Portal
9
+ const DropdownMenuSub = DropdownMenuPrimitive.Sub
10
+ const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
11
+
12
+ const DropdownMenuContent = React.forwardRef<
13
+ React.ElementRef<typeof DropdownMenuPrimitive.Content>,
14
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
15
+ >(({ className, sideOffset = 8, ...props }, ref) => (
16
+ <DropdownMenuPrimitive.Portal>
17
+ <DropdownMenuPrimitive.Content
18
+ ref={ref}
19
+ sideOffset={sideOffset}
20
+ className={cn(
21
+ 'z-50 min-w-[180px] rounded-xl border border-[color:var(--line)] bg-[color:var(--surface)] p-2 text-[color:var(--ink)] shadow-[var(--shadow)]',
22
+ className,
23
+ )}
24
+ {...props}
25
+ />
26
+ </DropdownMenuPrimitive.Portal>
27
+ ))
28
+ DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
29
+
30
+ const DropdownMenuItem = React.forwardRef<
31
+ React.ElementRef<typeof DropdownMenuPrimitive.Item>,
32
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>
33
+ >(({ className, ...props }, ref) => (
34
+ <DropdownMenuPrimitive.Item
35
+ ref={ref}
36
+ className={cn(
37
+ 'flex cursor-pointer select-none items-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold text-[color:var(--ink)] outline-none transition-colors focus:bg-[color:rgba(255,107,74,0.12)] data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
38
+ className,
39
+ )}
40
+ {...props}
41
+ />
42
+ ))
43
+ DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
44
+
45
+ const DropdownMenuSeparator = React.forwardRef<
46
+ React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
47
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
48
+ >(({ className, ...props }, ref) => (
49
+ <DropdownMenuPrimitive.Separator
50
+ ref={ref}
51
+ className={cn('my-1 h-px bg-[color:var(--line)]', className)}
52
+ {...props}
53
+ />
54
+ ))
55
+ DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
56
+
57
+ export {
58
+ DropdownMenu,
59
+ DropdownMenuTrigger,
60
+ DropdownMenuContent,
61
+ DropdownMenuItem,
62
+ DropdownMenuSeparator,
63
+ DropdownMenuGroup,
64
+ DropdownMenuPortal,
65
+ DropdownMenuSub,
66
+ DropdownMenuRadioGroup,
67
+ }
@@ -0,0 +1,35 @@
1
+ import * as ToggleGroupPrimitive from '@radix-ui/react-toggle-group'
2
+ import * as React from 'react'
3
+ import { cn } from '../../lib/utils'
4
+
5
+ const ToggleGroup = React.forwardRef<
6
+ React.ElementRef<typeof ToggleGroupPrimitive.Root>,
7
+ React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root>
8
+ >(({ className, ...props }, ref) => (
9
+ <ToggleGroupPrimitive.Root
10
+ ref={ref}
11
+ className={cn(
12
+ 'inline-flex items-center gap-1 rounded-full border border-[color:var(--line)] bg-[color:var(--surface)] p-1',
13
+ className,
14
+ )}
15
+ {...props}
16
+ />
17
+ ))
18
+ ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
19
+
20
+ const ToggleGroupItem = React.forwardRef<
21
+ React.ElementRef<typeof ToggleGroupPrimitive.Item>,
22
+ React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item>
23
+ >(({ className, ...props }, ref) => (
24
+ <ToggleGroupPrimitive.Item
25
+ ref={ref}
26
+ className={cn(
27
+ 'inline-flex h-9 w-9 items-center justify-center rounded-full text-[color:var(--ink-soft)] transition-colors hover:text-[color:var(--ink)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:rgba(255,107,74,0.4)] data-[state=on]:bg-[color:var(--accent)] data-[state=on]:text-white',
28
+ className,
29
+ )}
30
+ {...props}
31
+ />
32
+ ))
33
+ ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName
34
+
35
+ export { ToggleGroup, ToggleGroupItem }
@@ -0,0 +1,3 @@
1
+ import { ConvexReactClient } from 'convex/react'
2
+
3
+ export const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL as string)
@@ -0,0 +1,29 @@
1
+ import type { Doc, Id } from '../../convex/_generated/dataModel'
2
+
3
+ type BadgeKind = Doc<'skillBadges'>['kind']
4
+
5
+ type SkillBadgeMap = Partial<Record<BadgeKind, { byUserId: Id<'users'>; at: number }>>
6
+
7
+ type SkillLike = { badges?: SkillBadgeMap | null }
8
+
9
+ type BadgeLabel = 'Deprecated' | 'Official' | 'Highlighted'
10
+
11
+ export function isSkillHighlighted(skill: SkillLike) {
12
+ return Boolean(skill.badges?.highlighted)
13
+ }
14
+
15
+ export function isSkillOfficial(skill: SkillLike) {
16
+ return Boolean(skill.badges?.official)
17
+ }
18
+
19
+ export function isSkillDeprecated(skill: SkillLike) {
20
+ return Boolean(skill.badges?.deprecated)
21
+ }
22
+
23
+ export function getSkillBadges(skill: SkillLike): BadgeLabel[] {
24
+ const badges: BadgeLabel[] = []
25
+ if (isSkillDeprecated(skill)) badges.push('Deprecated')
26
+ if (isSkillOfficial(skill)) badges.push('Official')
27
+ if (isSkillHighlighted(skill)) badges.push('Highlighted')
28
+ return badges
29
+ }
@@ -0,0 +1,163 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ buildFileDiffList,
4
+ getDefaultDiffSelection,
5
+ resolveLatestVersionId,
6
+ resolvePreviousVersionId,
7
+ selectDefaultFilePath,
8
+ sortVersionsBySemver,
9
+ } from './diffing'
10
+
11
+ describe('diffing', () => {
12
+ it('sorts versions by semver descending', () => {
13
+ const ordered = sortVersionsBySemver([
14
+ { id: 'a', version: '1.0.0' },
15
+ { id: 'b', version: '2.0.0' },
16
+ { id: 'c', version: '1.5.0' },
17
+ ])
18
+ expect(ordered.map((entry) => entry.version)).toEqual(['2.0.0', '1.5.0', '1.0.0'])
19
+ })
20
+
21
+ it('sorts valid semver ahead of invalid entries', () => {
22
+ const ordered = sortVersionsBySemver([
23
+ { id: 'a', version: 'not-a-version' },
24
+ { id: 'b', version: '1.0.0' },
25
+ { id: 'c', version: '2.0.0' },
26
+ ])
27
+ expect(ordered.map((entry) => entry.version)).toEqual(['2.0.0', '1.0.0', 'not-a-version'])
28
+ })
29
+
30
+ it('sorts when only one entry is valid', () => {
31
+ const ordered = sortVersionsBySemver([
32
+ { id: 'a', version: 'nope' },
33
+ { id: 'b', version: '1.0.0' },
34
+ ])
35
+ expect(ordered.map((entry) => entry.version)).toEqual(['1.0.0', 'nope'])
36
+ })
37
+
38
+ it('sorts invalid entries lexicographically', () => {
39
+ const ordered = sortVersionsBySemver([
40
+ { id: 'a', version: 'beta' },
41
+ { id: 'b', version: 'alpha' },
42
+ ])
43
+ expect(ordered.map((entry) => entry.version)).toEqual(['alpha', 'beta'])
44
+ })
45
+
46
+ it('resolves latest from tag when present', () => {
47
+ const latestId = resolveLatestVersionId(
48
+ [
49
+ { id: 'a', version: '1.0.0' },
50
+ { id: 'b', version: '2.0.0' },
51
+ ],
52
+ { latest: 'a' },
53
+ )
54
+ expect(latestId).toBe('a')
55
+ })
56
+
57
+ it('returns null when no versions exist', () => {
58
+ const latestId = resolveLatestVersionId([], undefined)
59
+ expect(latestId).toBeNull()
60
+ })
61
+
62
+ it('resolves previous via semver predecessor', () => {
63
+ const latestId = 'b'
64
+ const previousId = resolvePreviousVersionId(
65
+ [
66
+ { id: 'a', version: '1.0.0' },
67
+ { id: 'b', version: '2.0.0' },
68
+ { id: 'c', version: '1.5.0' },
69
+ ],
70
+ latestId,
71
+ )
72
+ expect(previousId).toBe('c')
73
+ })
74
+
75
+ it('falls back to second entry when latest missing', () => {
76
+ const previousId = resolvePreviousVersionId(
77
+ [
78
+ { id: 'a', version: '2.0.0' },
79
+ { id: 'b', version: '1.0.0' },
80
+ { id: 'c', version: '0.5.0' },
81
+ ],
82
+ 'missing',
83
+ )
84
+ expect(previousId).toBe('b')
85
+ })
86
+
87
+ it('returns default selection previous vs latest', () => {
88
+ const selection = getDefaultDiffSelection(
89
+ [
90
+ { id: 'a', version: '1.0.0' },
91
+ { id: 'b', version: '2.0.0' },
92
+ ],
93
+ { latest: 'b' },
94
+ )
95
+ expect(selection).toEqual({ leftId: 'a', rightId: 'b' })
96
+ })
97
+
98
+ it('builds file diff list with statuses', () => {
99
+ const diff = buildFileDiffList(
100
+ [
101
+ { path: 'SKILL.md', sha256: 'aaa', size: 10 },
102
+ { path: 'a.ts', sha256: 'bbb', size: 10 },
103
+ ],
104
+ [
105
+ { path: 'SKILL.md', sha256: 'aaa', size: 10 },
106
+ { path: 'b.ts', sha256: 'ccc', size: 10 },
107
+ { path: 'a.ts', sha256: 'ddd', size: 10 },
108
+ ],
109
+ )
110
+ const statusByPath = Object.fromEntries(diff.map((item) => [item.path, item.status]))
111
+ expect(statusByPath['SKILL.md']).toBe('same')
112
+ expect(statusByPath['a.ts']).toBe('changed')
113
+ expect(statusByPath['b.ts']).toBe('added')
114
+ })
115
+
116
+ it('orders file diff list by change status then path', () => {
117
+ const diff = buildFileDiffList(
118
+ [
119
+ { path: 'c.txt', sha256: 'aaa', size: 1 },
120
+ { path: 'a.txt', sha256: 'bbb', size: 1 },
121
+ ],
122
+ [
123
+ { path: 'a.txt', sha256: 'ccc', size: 1 },
124
+ { path: 'b.txt', sha256: 'ddd', size: 1 },
125
+ ],
126
+ )
127
+ expect(diff.map((item) => item.path)).toEqual(['a.txt', 'b.txt', 'c.txt'])
128
+ })
129
+
130
+ it('orders file diff list alphabetically for same status', () => {
131
+ const diff = buildFileDiffList(
132
+ [
133
+ { path: 'b.txt', sha256: 'aaa', size: 1 },
134
+ { path: 'a.txt', sha256: 'bbb', size: 1 },
135
+ ],
136
+ [
137
+ { path: 'b.txt', sha256: 'aaa', size: 1 },
138
+ { path: 'a.txt', sha256: 'bbb', size: 1 },
139
+ ],
140
+ )
141
+ expect(diff.map((item) => item.path)).toEqual(['a.txt', 'b.txt'])
142
+ })
143
+
144
+ it('selects SKILL.md as default file when present', () => {
145
+ const path = selectDefaultFilePath([
146
+ { path: 'notes.md', status: 'changed' },
147
+ { path: 'SKILL.md', status: 'same' },
148
+ ])
149
+ expect(path).toBe('SKILL.md')
150
+ })
151
+
152
+ it('falls back to first changed file when SKILL.md missing', () => {
153
+ const path = selectDefaultFilePath([
154
+ { path: 'alpha.txt', status: 'same' },
155
+ { path: 'beta.txt', status: 'changed' },
156
+ ])
157
+ expect(path).toBe('beta.txt')
158
+ })
159
+
160
+ it('returns null when no file entries exist', () => {
161
+ expect(selectDefaultFilePath([])).toBeNull()
162
+ })
163
+ })
@@ -0,0 +1,106 @@
1
+ import semver from 'semver'
2
+
3
+ export const MAX_DIFF_FILE_BYTES = 200 * 1024
4
+
5
+ type TagMap<IdType extends string> = Record<string, IdType>
6
+
7
+ export type VersionEntry<IdType extends string = string> = {
8
+ id: IdType
9
+ version: string
10
+ }
11
+
12
+ export type FileMeta = {
13
+ path: string
14
+ sha256: string
15
+ size: number
16
+ }
17
+
18
+ export type FileDiffStatus = 'added' | 'removed' | 'changed' | 'same'
19
+
20
+ export type FileDiffItem = {
21
+ path: string
22
+ status: FileDiffStatus
23
+ left?: FileMeta
24
+ right?: FileMeta
25
+ }
26
+
27
+ export function sortVersionsBySemver<IdType extends string>(versions: VersionEntry<IdType>[]) {
28
+ return [...versions].sort((a, b) => {
29
+ const aValid = Boolean(semver.valid(a.version))
30
+ const bValid = Boolean(semver.valid(b.version))
31
+ if (aValid && bValid) return semver.rcompare(a.version, b.version)
32
+ if (aValid) return -1
33
+ if (bValid) return 1
34
+ return a.version.localeCompare(b.version)
35
+ })
36
+ }
37
+
38
+ export function resolveLatestVersionId<IdType extends string>(
39
+ versions: VersionEntry<IdType>[],
40
+ tags?: TagMap<IdType>,
41
+ ) {
42
+ if (tags?.latest) return tags.latest
43
+ return sortVersionsBySemver(versions)[0]?.id ?? null
44
+ }
45
+
46
+ export function resolvePreviousVersionId<IdType extends string>(
47
+ versions: VersionEntry<IdType>[],
48
+ latestId?: IdType | null,
49
+ ) {
50
+ const ordered = sortVersionsBySemver(versions)
51
+ if (!latestId) return ordered[1]?.id ?? null
52
+ const latestIndex = ordered.findIndex((entry) => entry.id === latestId)
53
+ if (latestIndex === -1) return ordered[1]?.id ?? null
54
+ return ordered[latestIndex + 1]?.id ?? null
55
+ }
56
+
57
+ export function getDefaultDiffSelection<IdType extends string>(
58
+ versions: VersionEntry<IdType>[],
59
+ tags?: TagMap<IdType>,
60
+ ) {
61
+ const latestId = resolveLatestVersionId(versions, tags)
62
+ const previousId = resolvePreviousVersionId(versions, latestId)
63
+ return {
64
+ leftId: previousId ?? latestId ?? null,
65
+ rightId: latestId ?? previousId ?? null,
66
+ }
67
+ }
68
+
69
+ export function buildFileDiffList(leftFiles: FileMeta[], rightFiles: FileMeta[]) {
70
+ const entries = new Map<string, { left?: FileMeta; right?: FileMeta }>()
71
+ for (const file of leftFiles) {
72
+ entries.set(file.path, { left: file })
73
+ }
74
+ for (const file of rightFiles) {
75
+ const existing = entries.get(file.path) ?? {}
76
+ entries.set(file.path, { ...existing, right: file })
77
+ }
78
+
79
+ const statusRank: Record<FileDiffStatus, number> = {
80
+ changed: 0,
81
+ added: 1,
82
+ removed: 2,
83
+ same: 3,
84
+ }
85
+
86
+ return Array.from(entries.entries())
87
+ .map(([path, info]) => {
88
+ let status: FileDiffStatus = 'same'
89
+ if (info.left && !info.right) status = 'removed'
90
+ else if (!info.left && info.right) status = 'added'
91
+ else if (info.left?.sha256 !== info.right?.sha256) status = 'changed'
92
+ return { path, status, left: info.left, right: info.right }
93
+ })
94
+ .sort((a, b) => {
95
+ const statusDiff = statusRank[a.status] - statusRank[b.status]
96
+ if (statusDiff !== 0) return statusDiff
97
+ return a.path.localeCompare(b.path)
98
+ })
99
+ }
100
+
101
+ export function selectDefaultFilePath(items: FileDiffItem[]) {
102
+ const readme = items.find((item) => item.path.toLowerCase() === 'skill.md')
103
+ if (readme) return readme.path
104
+ const changed = items.find((item) => item.status !== 'same')
105
+ return changed?.path ?? items[0]?.path ?? null
106
+ }