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.
- package/.env.local.example +19 -0
- package/.github/workflows/ci.yml +40 -0
- package/.oxlintrc.json +3 -0
- package/AGENTS.md +45 -0
- package/CHANGELOG.md +138 -0
- package/DEPRECATIONS.md +7 -0
- package/LICENSE +21 -0
- package/README.md +150 -0
- package/biome.json +41 -0
- package/convex/_generated/api.d.ts +153 -0
- package/convex/_generated/api.js +23 -0
- package/convex/_generated/dataModel.d.ts +60 -0
- package/convex/_generated/server.d.ts +143 -0
- package/convex/_generated/server.js +93 -0
- package/convex/auth.config.ts +8 -0
- package/convex/auth.ts +19 -0
- package/convex/comments.ts +88 -0
- package/convex/crons.ts +34 -0
- package/convex/devSeed.ts +459 -0
- package/convex/devSeedExtra.ts +541 -0
- package/convex/downloads.ts +78 -0
- package/convex/githubBackups.ts +170 -0
- package/convex/githubBackupsNode.ts +183 -0
- package/convex/githubImport.ts +317 -0
- package/convex/githubSoulBackups.ts +170 -0
- package/convex/githubSoulBackupsNode.ts +186 -0
- package/convex/http.ts +194 -0
- package/convex/httpApi.handlers.test.ts +488 -0
- package/convex/httpApi.test.ts +70 -0
- package/convex/httpApi.ts +305 -0
- package/convex/httpApiV1.handlers.test.ts +584 -0
- package/convex/httpApiV1.ts +1172 -0
- package/convex/leaderboards.ts +39 -0
- package/convex/lib/access.ts +36 -0
- package/convex/lib/apiTokenAuth.ts +36 -0
- package/convex/lib/badges.ts +50 -0
- package/convex/lib/changelog.test.ts +34 -0
- package/convex/lib/changelog.ts +278 -0
- package/convex/lib/embeddings.ts +38 -0
- package/convex/lib/githubBackup.ts +443 -0
- package/convex/lib/githubImport.test.ts +247 -0
- package/convex/lib/githubImport.ts +425 -0
- package/convex/lib/githubSoulBackup.ts +443 -0
- package/convex/lib/leaderboards.ts +103 -0
- package/convex/lib/moderation.ts +42 -0
- package/convex/lib/public.ts +89 -0
- package/convex/lib/searchText.test.ts +46 -0
- package/convex/lib/searchText.ts +27 -0
- package/convex/lib/skillBackfill.test.ts +34 -0
- package/convex/lib/skillBackfill.ts +67 -0
- package/convex/lib/skillPublish.test.ts +28 -0
- package/convex/lib/skillPublish.ts +284 -0
- package/convex/lib/skillStats.ts +80 -0
- package/convex/lib/skills.test.ts +197 -0
- package/convex/lib/skills.ts +273 -0
- package/convex/lib/soulChangelog.ts +273 -0
- package/convex/lib/soulPublish.ts +236 -0
- package/convex/lib/tokens.test.ts +33 -0
- package/convex/lib/tokens.ts +51 -0
- package/convex/lib/webhooks.test.ts +91 -0
- package/convex/lib/webhooks.ts +112 -0
- package/convex/maintenance.test.ts +270 -0
- package/convex/maintenance.ts +840 -0
- package/convex/rateLimits.ts +50 -0
- package/convex/schema.ts +472 -0
- package/convex/search.test.ts +12 -0
- package/convex/search.ts +254 -0
- package/convex/seed.test.ts +37 -0
- package/convex/seed.ts +254 -0
- package/convex/seedSouls.ts +111 -0
- package/convex/skillStatEvents.ts +568 -0
- package/convex/skills.ts +1606 -0
- package/convex/soulComments.ts +88 -0
- package/convex/soulDownloads.ts +14 -0
- package/convex/soulStars.ts +71 -0
- package/convex/souls.ts +570 -0
- package/convex/stars.ts +108 -0
- package/convex/statsMaintenance.ts +205 -0
- package/convex/telemetry.ts +434 -0
- package/convex/tokens.ts +88 -0
- package/convex/tsconfig.json +7 -0
- package/convex/uploads.ts +20 -0
- package/convex/users.ts +122 -0
- package/convex/webhooks.ts +50 -0
- package/convex.json +3 -0
- package/docs/README.md +32 -0
- package/docs/api.md +51 -0
- package/docs/architecture.md +61 -0
- package/docs/auth.md +54 -0
- package/docs/cli.md +117 -0
- package/docs/deploy.md +78 -0
- package/docs/diffing.md +84 -0
- package/docs/github-import.md +171 -0
- package/docs/http-api.md +187 -0
- package/docs/manual-testing.md +64 -0
- package/docs/mintlify.md +43 -0
- package/docs/quickstart.md +120 -0
- package/docs/skill-format.md +58 -0
- package/docs/soul-format.md +37 -0
- package/docs/spec.md +177 -0
- package/docs/telemetry.md +91 -0
- package/docs/troubleshooting.md +49 -0
- package/docs/webhook.md +51 -0
- package/e2e/menu-smoke.pw.test.ts +49 -0
- package/e2e/pilothub.e2e.test.ts +494 -0
- package/e2e/search-exact.pw.test.ts +97 -0
- package/package.json +84 -0
- package/packages/pilothub/LICENSE +22 -0
- package/packages/pilothub/README.md +57 -0
- package/packages/pilothub/bin/pilothub.js +2 -0
- package/packages/pilothub/package.json +41 -0
- package/packages/pilothub/src/browserAuth.test.ts +96 -0
- package/packages/pilothub/src/browserAuth.ts +174 -0
- package/packages/pilothub/src/cli/buildInfo.ts +94 -0
- package/packages/pilothub/src/cli/commands/auth.ts +97 -0
- package/packages/pilothub/src/cli/commands/delete.test.ts +73 -0
- package/packages/pilothub/src/cli/commands/delete.ts +83 -0
- package/packages/pilothub/src/cli/commands/publish.test.ts +122 -0
- package/packages/pilothub/src/cli/commands/publish.ts +108 -0
- package/packages/pilothub/src/cli/commands/skills.test.ts +191 -0
- package/packages/pilothub/src/cli/commands/skills.ts +380 -0
- package/packages/pilothub/src/cli/commands/star.ts +46 -0
- package/packages/pilothub/src/cli/commands/sync.test.ts +310 -0
- package/packages/pilothub/src/cli/commands/sync.ts +200 -0
- package/packages/pilothub/src/cli/commands/syncHelpers.test.ts +26 -0
- package/packages/pilothub/src/cli/commands/syncHelpers.ts +427 -0
- package/packages/pilothub/src/cli/commands/syncTypes.ts +27 -0
- package/packages/pilothub/src/cli/commands/unstar.ts +48 -0
- package/packages/pilothub/src/cli/helpStyle.ts +45 -0
- package/packages/pilothub/src/cli/pilotbotConfig.test.ts +159 -0
- package/packages/pilothub/src/cli/pilotbotConfig.ts +147 -0
- package/packages/pilothub/src/cli/registry.test.ts +63 -0
- package/packages/pilothub/src/cli/registry.ts +43 -0
- package/packages/pilothub/src/cli/scanSkills.test.ts +64 -0
- package/packages/pilothub/src/cli/scanSkills.ts +84 -0
- package/packages/pilothub/src/cli/slug.ts +16 -0
- package/packages/pilothub/src/cli/types.ts +12 -0
- package/packages/pilothub/src/cli/ui.ts +75 -0
- package/packages/pilothub/src/cli.ts +311 -0
- package/packages/pilothub/src/config.ts +36 -0
- package/packages/pilothub/src/discovery.test.ts +75 -0
- package/packages/pilothub/src/discovery.ts +19 -0
- package/packages/pilothub/src/http.test.ts +156 -0
- package/packages/pilothub/src/http.ts +301 -0
- package/packages/pilothub/src/schema/ark.ts +29 -0
- package/packages/pilothub/src/schema/index.ts +5 -0
- package/packages/pilothub/src/schema/routes.ts +22 -0
- package/packages/pilothub/src/schema/schemas.ts +260 -0
- package/packages/pilothub/src/schema/textFiles.test.ts +23 -0
- package/packages/pilothub/src/schema/textFiles.ts +66 -0
- package/packages/pilothub/src/skills.test.ts +191 -0
- package/packages/pilothub/src/skills.ts +172 -0
- package/packages/pilothub/src/types.ts +10 -0
- package/packages/pilothub/tsconfig.json +14 -0
- package/packages/schema/README.md +3 -0
- package/packages/schema/dist/ark.d.ts +4 -0
- package/packages/schema/dist/ark.js +26 -0
- package/packages/schema/dist/ark.js.map +1 -0
- package/packages/schema/dist/index.d.ts +5 -0
- package/packages/schema/dist/index.js +5 -0
- package/packages/schema/dist/index.js.map +1 -0
- package/packages/schema/dist/routes.d.ts +21 -0
- package/packages/schema/dist/routes.js +22 -0
- package/packages/schema/dist/routes.js.map +1 -0
- package/packages/schema/dist/schemas.d.ts +297 -0
- package/packages/schema/dist/schemas.js +243 -0
- package/packages/schema/dist/schemas.js.map +1 -0
- package/packages/schema/dist/textFiles.d.ts +5 -0
- package/packages/schema/dist/textFiles.js +66 -0
- package/packages/schema/dist/textFiles.js.map +1 -0
- package/packages/schema/package.json +26 -0
- package/packages/schema/src/ark.ts +29 -0
- package/packages/schema/src/index.ts +5 -0
- package/packages/schema/src/routes.ts +22 -0
- package/packages/schema/src/schemas.test.ts +123 -0
- package/packages/schema/src/schemas.ts +287 -0
- package/packages/schema/src/textFiles.test.ts +23 -0
- package/packages/schema/src/textFiles.ts +66 -0
- package/packages/schema/tsconfig.json +15 -0
- package/pilothub +46 -0
- package/playwright.config.ts +33 -0
- package/public/.well-known/pilothub.json +6 -0
- package/public/api/v1/openapi.json +379 -0
- package/public/favicon.ico +0 -0
- package/public/logo192.png +0 -0
- package/public/logo512.png +0 -0
- package/public/manifest.json +25 -0
- package/public/og.png +0 -0
- package/public/og.svg +98 -0
- package/public/pilot-logo.png +0 -0
- package/public/pilot-mark.png +0 -0
- package/public/robots.txt +3 -0
- package/public/tanstack-circle-logo.png +0 -0
- package/public/tanstack-word-logo-white.svg +1 -0
- package/scripts/check-peer-deps.ts +56 -0
- package/scripts/docs-list.ts +148 -0
- package/scripts/run-playwright-local.sh +14 -0
- package/server/og/fetchSkillOgMeta.ts +27 -0
- package/server/og/fetchSoulOgMeta.ts +27 -0
- package/server/og/ogAssets.ts +80 -0
- package/server/og/skillOgSvg.test.ts +59 -0
- package/server/og/skillOgSvg.ts +258 -0
- package/server/og/soulOgSvg.ts +209 -0
- package/server/routes/og/skill.png.ts +103 -0
- package/server/routes/og/soul.png.ts +111 -0
- package/src/__tests__/skill-detail-page.test.tsx +86 -0
- package/src/__tests__/skills-index.test.tsx +145 -0
- package/src/__tests__/upload.route.test.tsx +228 -0
- package/src/components/AppProviders.tsx +19 -0
- package/src/components/ClientOnly.tsx +18 -0
- package/src/components/Footer.tsx +29 -0
- package/src/components/Header.tsx +295 -0
- package/src/components/InstallSwitcher.tsx +53 -0
- package/src/components/SkillCard.tsx +36 -0
- package/src/components/SkillDetailPage.tsx +817 -0
- package/src/components/SkillDiffCard.tsx +485 -0
- package/src/components/SoulCard.tsx +19 -0
- package/src/components/SoulDetailPage.tsx +263 -0
- package/src/components/UserBootstrap.tsx +18 -0
- package/src/components/ui/dropdown-menu.tsx +67 -0
- package/src/components/ui/toggle-group.tsx +35 -0
- package/src/convex/client.ts +3 -0
- package/src/lib/badges.ts +29 -0
- package/src/lib/diffing.test.ts +163 -0
- package/src/lib/diffing.ts +106 -0
- package/src/lib/gravatar.test.ts +9 -0
- package/src/lib/gravatar.ts +158 -0
- package/src/lib/og.test.ts +142 -0
- package/src/lib/og.ts +156 -0
- package/src/lib/publicUser.ts +39 -0
- package/src/lib/roles.ts +19 -0
- package/src/lib/site.test.ts +130 -0
- package/src/lib/site.ts +84 -0
- package/src/lib/theme-transition.test.ts +134 -0
- package/src/lib/theme-transition.ts +134 -0
- package/src/lib/theme.test.tsx +88 -0
- package/src/lib/theme.ts +43 -0
- package/src/lib/uploadFiles.jsdom.test.ts +33 -0
- package/src/lib/uploadFiles.test.ts +123 -0
- package/src/lib/uploadFiles.ts +245 -0
- package/src/lib/uploadUtils.test.ts +78 -0
- package/src/lib/uploadUtils.ts +93 -0
- package/src/lib/useAuthStatus.ts +12 -0
- package/src/lib/utils.test.ts +9 -0
- package/src/lib/utils.ts +6 -0
- package/src/logo.svg +12 -0
- package/src/routeTree.gen.ts +345 -0
- package/src/router.tsx +17 -0
- package/src/routes/$owner/$slug.tsx +55 -0
- package/src/routes/__root.tsx +136 -0
- package/src/routes/admin.tsx +11 -0
- package/src/routes/cli/auth.tsx +168 -0
- package/src/routes/dashboard.tsx +97 -0
- package/src/routes/import.tsx +415 -0
- package/src/routes/index.tsx +252 -0
- package/src/routes/management.tsx +529 -0
- package/src/routes/settings.tsx +203 -0
- package/src/routes/skills/index.tsx +422 -0
- package/src/routes/souls/$slug.tsx +55 -0
- package/src/routes/souls/index.tsx +243 -0
- package/src/routes/stars.tsx +68 -0
- package/src/routes/u/$handle.tsx +307 -0
- package/src/routes/upload/utils.ts +81 -0
- package/src/routes/upload.tsx +499 -0
- package/src/styles.css +2718 -0
- package/tsconfig.json +24 -0
- package/tsconfig.oxlint.json +16 -0
- package/vercel.json +8 -0
- package/vite.config.ts +48 -0
- package/vitest.config.ts +47 -0
- package/vitest.e2e.config.ts +11 -0
- 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, '<')
|
|
16
|
+
.replace(/>/g, '>')
|
|
17
|
+
.replace(/"/g, '"')
|
|
18
|
+
.replace(/'/g, ''')
|
|
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
|
+
}
|