Skip to content

Commit f5f64f2

Browse files
authored
Merge branch 'main' into main
2 parents 899a0d1 + 091b63f commit f5f64f2

21 files changed

Lines changed: 510 additions & 178 deletions

CONTRIBUTING.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,59 @@ The connector will check your npm authentication, generate a connection token, a
110110
- We care about good types – never cast things to `any` 💪
111111
- Validate rather than just assert
112112

113+
### Server API patterns
114+
115+
#### Input validation with Valibot
116+
117+
Use Valibot schemas from `#shared/schemas/` to validate API inputs. This ensures type safety and provides consistent error messages:
118+
119+
```typescript
120+
import * as v from 'valibot'
121+
import { PackageRouteParamsSchema } from '#shared/schemas/package'
122+
123+
// In your handler:
124+
const { packageName, version } = v.parse(PackageRouteParamsSchema, {
125+
packageName: rawPackageName,
126+
version: rawVersion,
127+
})
128+
```
129+
130+
#### Error handling with `handleApiError`
131+
132+
Use the `handleApiError` utility for consistent error handling in API routes. It re-throws H3 errors (like 404s) and wraps other errors with a fallback message:
133+
134+
```typescript
135+
import { ERROR_NPM_FETCH_FAILED } from '#shared/utils/constants'
136+
137+
try {
138+
// API logic...
139+
} catch (error: unknown) {
140+
handleApiError(error, {
141+
statusCode: 502,
142+
message: ERROR_NPM_FETCH_FAILED,
143+
})
144+
}
145+
```
146+
147+
#### URL parameter parsing with `parsePackageParams`
148+
149+
Use `parsePackageParams` to extract package name and version from URL segments:
150+
151+
```typescript
152+
const pkgParamSegments = getRouterParam(event, 'pkg')?.split('/') ?? []
153+
const { rawPackageName, rawVersion } = parsePackageParams(pkgParamSegments)
154+
```
155+
156+
This handles patterns like `/pkg`, `/pkg/v/1.0.0`, `/@scope/pkg`, and `/@scope/pkg/v/1.0.0`.
157+
158+
#### Constants
159+
160+
Define error messages and other string constants in `#shared/utils/constants.ts` to ensure consistency across the codebase:
161+
162+
```typescript
163+
export const ERROR_NPM_FETCH_FAILED = 'Failed to fetch package from npm registry.'
164+
```
165+
113166
### Import order
114167

115168
1. Type imports first (`import type { ... }`)

app/composables/useRepoMeta.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -466,6 +466,56 @@ const sourcehutAdapter: ProviderAdapter = {
466466
},
467467
}
468468

469+
const tangledAdapter: ProviderAdapter = {
470+
id: 'tangled',
471+
472+
parse(url) {
473+
const host = url.hostname.toLowerCase()
474+
if (
475+
host !== 'tangled.sh' &&
476+
host !== 'www.tangled.sh' &&
477+
host !== 'tangled.org' &&
478+
host !== 'www.tangled.org'
479+
) {
480+
return null
481+
}
482+
483+
const parts = url.pathname.split('/').filter(Boolean)
484+
if (parts.length < 2) return null
485+
486+
// Tangled uses owner/repo format (owner is a domain-like identifier)
487+
const owner = decodeURIComponent(parts[0] ?? '').trim()
488+
const repo = decodeURIComponent(parts[1] ?? '')
489+
.trim()
490+
.replace(/\.git$/i, '')
491+
492+
if (!owner || !repo) return null
493+
494+
return { provider: 'tangled', owner, repo }
495+
},
496+
497+
links(ref) {
498+
const base = `https://tangled.sh/${ref.owner}/${ref.repo}`
499+
return {
500+
repo: base,
501+
stars: base, // Tangled shows stars on the repo page
502+
forks: `${base}/fork`,
503+
}
504+
},
505+
506+
async fetchMeta(_ref, links) {
507+
// Tangled doesn't have a public API for repo stats yet
508+
// Just return basic info without fetching
509+
return {
510+
provider: 'tangled',
511+
url: links.repo,
512+
stars: 0,
513+
forks: 0,
514+
links,
515+
}
516+
},
517+
}
518+
469519
// Order matters: more specific adapters should come before generic ones
470520
const providers: readonly ProviderAdapter[] = [
471521
githubAdapter,
@@ -474,6 +524,7 @@ const providers: readonly ProviderAdapter[] = [
474524
codebergAdapter,
475525
giteeAdapter,
476526
sourcehutAdapter,
527+
tangledAdapter,
477528
giteaAdapter, // Generic Gitea adapter last as fallback for self-hosted instances
478529
] as const
479530

app/pages/[...package].vue

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ const PROVIDER_ICONS: Record<string, string> = {
164164
gitea: 'i-simple-icons-gitea',
165165
gitee: 'i-simple-icons-gitee',
166166
sourcehut: 'i-simple-icons-sourcehut',
167+
tangled: 'i-custom-tangled',
167168
}
168169
169170
const repoProviderIcon = computed(() => {
@@ -451,6 +452,7 @@ defineOgImageComponent('Package', {
451452
class="i-solar-eye-scan-outline w-3.5 h-3.5 inline-block"
452453
aria-hidden="true"
453454
/>
455+
<span class="sr-only">Inspect dependency tree</span>
454456
</a>
455457
</dd>
456458
</div>
@@ -568,7 +570,7 @@ defineOgImageComponent('Package', {
568570
jsr
569571
</a>
570572
</li>
571-
<li class="flex-grow">
573+
<li class="sm:flex-grow">
572574
<a
573575
:href="`https://socket.dev/npm/package/${pkg.name}/overview/${displayVersion?.version ?? 'latest'}`"
574576
target="_blank"
@@ -670,6 +672,7 @@ defineOgImageComponent('Package', {
670672
</div>
671673
</div>
672674
<button
675+
type="button"
673676
class="absolute top-3 right-3 px-2 py-1 font-mono text-xs text-fg-muted bg-bg-subtle/80 border border-border rounded transition-colors duration-200 hover:(text-fg border-border-hover) active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50"
674677
@click="copyInstallCommand"
675678
>

nuxt.config.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,13 @@ export default defineNuxtConfig({
111111

112112
vite: {
113113
optimizeDeps: {
114-
include: ['@vueuse/core', 'vue-data-ui/vue-ui-sparkline', 'virtua/vue'],
114+
include: [
115+
'@vueuse/core',
116+
'vue-data-ui/vue-ui-sparkline',
117+
'virtua/vue',
118+
'semver',
119+
'validate-npm-package-name',
120+
],
115121
},
116122
},
117123
})

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
"shiki": "^3.21.0",
4545
"ufo": "^1.6.3",
4646
"unplugin-vue-router": "^0.19.2",
47+
"valibot": "^1.2.0",
4748
"validate-npm-package-name": "^7.0.2",
4849
"virtua": "^0.48.3",
4950
"vue": "3.5.27",

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

server/api/jsr/[...pkg].get.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import * as v from 'valibot'
2+
import { PackageNameSchema } from '#shared/schemas/package'
3+
import { CACHE_MAX_AGE_ONE_HOUR, ERROR_JSR_FETCH_FAILED } from '#shared/utils/constants'
14
import type { JsrPackageInfo } from '#shared/types/jsr'
25

36
/**
@@ -11,17 +14,25 @@ import type { JsrPackageInfo } from '#shared/types/jsr'
1114
export default defineCachedEventHandler<Promise<JsrPackageInfo>>(
1215
async event => {
1316
const pkgPath = getRouterParam(event, 'pkg')
14-
if (!pkgPath) {
15-
throw createError({ statusCode: 400, message: 'Package name is required' })
16-
}
17-
assertValidPackageName(pkgPath)
1817

19-
return await fetchJsrPackageInfo(pkgPath)
18+
try {
19+
const packageName = v.parse(PackageNameSchema, pkgPath)
20+
21+
return await fetchJsrPackageInfo(packageName)
22+
} catch (error: unknown) {
23+
handleApiError(error, {
24+
statusCode: 502,
25+
message: ERROR_JSR_FETCH_FAILED,
26+
})
27+
}
2028
},
2129
{
22-
maxAge: 60 * 60, // 1 hour
30+
maxAge: CACHE_MAX_AGE_ONE_HOUR,
2331
swr: true,
2432
name: 'api-jsr-package',
25-
getKey: event => getRouterParam(event, 'pkg') ?? '',
33+
getKey: event => {
34+
const pkg = getRouterParam(event, 'pkg') ?? ''
35+
return `jsr:v1:${pkg.replace(/\/+$/, '').trim()}`
36+
},
2637
},
2738
)
Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,28 @@
1+
import * as v from 'valibot'
2+
import { PackageNameSchema } from '#shared/schemas/package'
3+
import { CACHE_MAX_AGE_ONE_HOUR, ERROR_NPM_FETCH_FAILED } from '#shared/utils/constants'
4+
15
export default defineCachedEventHandler(
26
async event => {
3-
const pkg = getRouterParam(event, 'pkg')
4-
if (!pkg) {
5-
throw createError({ statusCode: 400, message: 'Package name is required' })
6-
}
7+
try {
8+
const pkg = getRouterParam(event, 'pkg')
79

8-
assertValidPackageName(pkg)
10+
const packageName = v.parse(PackageNameSchema, pkg)
911

10-
try {
11-
return await fetchNpmPackage(pkg)
12-
} catch (error) {
13-
if (error && typeof error === 'object' && 'statusCode' in error) {
14-
throw error
15-
}
16-
throw createError({ statusCode: 502, message: 'Failed to fetch package from npm registry' })
12+
return await fetchNpmPackage(packageName)
13+
} catch (error: unknown) {
14+
handleApiError(error, {
15+
statusCode: 502,
16+
message: ERROR_NPM_FETCH_FAILED,
17+
})
1718
}
1819
},
1920
{
20-
maxAge: 60 * 60, // 1 hour
21+
maxAge: CACHE_MAX_AGE_ONE_HOUR,
2122
swr: true,
22-
getKey: event => getRouterParam(event, 'pkg') ?? '',
23+
getKey: event => {
24+
const pkg = getRouterParam(event, 'pkg') ?? ''
25+
return `packument:v1:${pkg.replace(/\/+$/, '').trim()}`
26+
},
2327
},
2428
)

server/api/registry/analysis/[...pkg].get.ts

Lines changed: 24 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,31 @@
1+
import * as v from 'valibot'
2+
import { PackageRouteParamsSchema } from '#shared/schemas/package'
13
import type { PackageAnalysis, ExtendedPackageJson } from '#shared/utils/package-analysis'
24
import {
35
analyzePackage,
46
getTypesPackageName,
57
hasBuiltInTypes,
68
} from '#shared/utils/package-analysis'
7-
8-
const NPM_REGISTRY = 'https://registry.npmjs.org'
9+
import {
10+
NPM_REGISTRY,
11+
CACHE_MAX_AGE_ONE_DAY,
12+
ERROR_PACKAGE_ANALYSIS_FAILED,
13+
} from '#shared/utils/constants'
914

1015
export default defineCachedEventHandler(
1116
async event => {
12-
const pkgParam = getRouterParam(event, 'pkg')
13-
if (!pkgParam) {
14-
throw createError({ statusCode: 400, message: 'Package name is required' })
15-
}
16-
1717
// Parse package name and optional version from path
1818
// e.g., "vue" or "vue/v/3.4.0" or "@nuxt/kit" or "@nuxt/kit/v/1.0.0"
19-
const segments = pkgParam.split('/')
20-
let packageName: string
21-
let version: string | undefined
19+
const pkgParamSegments = getRouterParam(event, 'pkg')?.split('/') ?? []
2220

23-
const vIndex = segments.indexOf('v')
24-
if (vIndex !== -1 && vIndex < segments.length - 1) {
25-
packageName = segments.slice(0, vIndex).join('/')
26-
version = segments.slice(vIndex + 1).join('/')
27-
} else {
28-
packageName = segments.join('/')
29-
}
21+
const { rawPackageName, rawVersion } = parsePackageParams(pkgParamSegments)
3022

3123
try {
24+
const { packageName, version } = v.parse(PackageRouteParamsSchema, {
25+
packageName: rawPackageName,
26+
version: rawVersion,
27+
})
28+
3229
// Fetch package data
3330
const encodedName = encodePackageName(packageName)
3431
const versionSuffix = version ? `/${version}` : '/latest'
@@ -39,8 +36,8 @@ export default defineCachedEventHandler(
3936
// Only check for @types package if the package doesn't ship its own types
4037
let typesPackageExists = false
4138
if (!hasBuiltInTypes(pkg)) {
42-
const typesPackageName = getTypesPackageName(packageName)
43-
typesPackageExists = await checkPackageExists(typesPackageName)
39+
const typesPkgName = getTypesPackageName(packageName)
40+
typesPackageExists = await checkPackageExists(typesPkgName)
4441
}
4542

4643
const analysis = analyzePackage(pkg, { typesPackageExists })
@@ -50,20 +47,20 @@ export default defineCachedEventHandler(
5047
version: pkg.version ?? version ?? 'latest',
5148
...analysis,
5249
} satisfies PackageAnalysisResponse
53-
} catch (error) {
54-
if (error && typeof error === 'object' && 'statusCode' in error) {
55-
throw error
56-
}
57-
throw createError({
50+
} catch (error: unknown) {
51+
handleApiError(error, {
5852
statusCode: 502,
59-
message: 'Failed to analyze package',
53+
message: ERROR_PACKAGE_ANALYSIS_FAILED,
6054
})
6155
}
6256
},
6357
{
64-
maxAge: 60 * 60 * 24, // 24 hours - analysis rarely changes
58+
maxAge: CACHE_MAX_AGE_ONE_DAY, // 24 hours - analysis rarely changes
6559
swr: true,
66-
getKey: event => getRouterParam(event, 'pkg') ?? '',
60+
getKey: event => {
61+
const pkg = getRouterParam(event, 'pkg') ?? ''
62+
return `analysis:v1:${pkg.replace(/\/+$/, '').trim()}`
63+
},
6764
},
6865
)
6966

0 commit comments

Comments
 (0)