├── .tool-versions ├── .husky ├── post-checkout ├── post-commit ├── post-merge ├── post-rewrite └── pre-commit ├── vercel.json ├── .gitattributes ├── .markdownlint.json ├── .vscode ├── settings.json └── extensions.json ├── public ├── favicon.ico ├── google1c622e992c7854ea.html ├── og-image.png ├── vk-image.png ├── favicon-16x16.png ├── favicon-32x32.png ├── mstile-150x150.png ├── apple-touch-icon.png ├── android-chrome-144x144.png ├── browserconfig.xml ├── site.webmanifest ├── safari-pinned-tab.svg └── opensearch.xml ├── assets └── vscode-launchx.png ├── .gitignore ├── pnpm-workspace.yaml ├── tsconfig.json ├── pages ├── shared │ ├── json-types.ts │ ├── external-link.tsx │ ├── error-page-body.tsx │ ├── page-metadata.tsx │ └── destinations.ts ├── 404.page.tsx ├── _app.page.tsx ├── index.page │ ├── clickable-code.tsx │ ├── example.tsx │ ├── input-form.tsx │ └── available-destinations.tsx ├── _error.page.tsx ├── api │ └── jump.handler.ts ├── _document.page.tsx ├── _app.page │ └── page-layout.tsx └── index.page.tsx ├── .prettierignore ├── next.config.ts ├── .markdownlintignore ├── cli ├── main.js ├── package.json └── cli.js ├── eslint.config.ts ├── .github ├── renovate.json └── workflows │ └── ci.yaml ├── LICENSE.md ├── package.json └── README.md /.tool-versions: -------------------------------------------------------------------------------- 1 | nodejs 24.11.1 2 | -------------------------------------------------------------------------------- /.husky/post-checkout: -------------------------------------------------------------------------------- 1 | pnpm install 2 | -------------------------------------------------------------------------------- /.husky/post-commit: -------------------------------------------------------------------------------- 1 | pnpm install 2 | -------------------------------------------------------------------------------- /.husky/post-merge: -------------------------------------------------------------------------------- 1 | pnpm install 2 | -------------------------------------------------------------------------------- /.husky/post-rewrite: -------------------------------------------------------------------------------- 1 | pnpm install 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | pnpm lint-staged 2 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "github": { 3 | "silent": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | 3 | /.husky/** linguist-generated=true 4 | -------------------------------------------------------------------------------- /.markdownlint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@kachkaev/markdownlint-config" 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kachkaev/njt/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/google1c622e992c7854ea.html: -------------------------------------------------------------------------------- 1 | google-site-verification: google1c622e992c7854ea.html -------------------------------------------------------------------------------- /public/og-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kachkaev/njt/HEAD/public/og-image.png -------------------------------------------------------------------------------- /public/vk-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kachkaev/njt/HEAD/public/vk-image.png -------------------------------------------------------------------------------- /assets/vscode-launchx.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kachkaev/njt/HEAD/assets/vscode-launchx.png -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kachkaev/njt/HEAD/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kachkaev/njt/HEAD/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kachkaev/njt/HEAD/public/mstile-150x150.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kachkaev/njt/HEAD/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/android-chrome-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kachkaev/njt/HEAD/public/android-chrome-144x144.png -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "DavidAnson.vscode-markdownlint", 4 | "dbaeumer.vscode-eslint", 5 | "esbenp.prettier-vscode" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## dependencies 2 | node_modules 3 | 4 | ## next.js 5 | .next/ 6 | next-env.d.ts 7 | out/ 8 | tsconfig.tsbuildinfo 9 | 10 | ## misc 11 | .DS_Store 12 | .env* 13 | 14 | ## custom 15 | cli/README.md 16 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | ignoredBuiltDependencies: 2 | - sharp ## Install script is only required when compiling sharp from source - https://sharp.pixelplumbing.com/install 3 | 4 | nodeLinker: isolated 5 | 6 | packages: 7 | - cli 8 | -------------------------------------------------------------------------------- /public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #42a73f 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@tsconfig/next/tsconfig.json", 4 | "@tsconfig/strictest/tsconfig.json" 5 | ], 6 | "compilerOptions": { 7 | "module": "ESNext", 8 | "target": "ESNext" 9 | }, 10 | "exclude": [], 11 | "include": ["next-env.d.ts", "**/*.cjs", "**/*.ts", "**/*.tsx"] 12 | } 13 | -------------------------------------------------------------------------------- /pages/shared/json-types.ts: -------------------------------------------------------------------------------- 1 | // https://github.com/microsoft/TypeScript/issues/1897#issuecomment-557057387 2 | export type JsonPrimitive = string | number | boolean | null; 3 | export type JsonValue = JsonPrimitive | JsonObject | JsonArray; 4 | export type JsonObject = { [member: string]: JsonValue }; 5 | export type JsonArray = JsonValue[]; 6 | -------------------------------------------------------------------------------- /pages/shared/external-link.tsx: -------------------------------------------------------------------------------- 1 | import type * as React from "react"; 2 | 3 | export function ExternalLink({ 4 | children, 5 | href, 6 | ...rest 7 | }: React.ComponentProps<"a">) { 8 | return ( 9 | 10 | {children ?? href?.replace(/^https?:\/\//i, "").replace(/^www\./i, "")} 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /pages/404.page.tsx: -------------------------------------------------------------------------------- 1 | import { ErrorPageBody } from "./shared/error-page-body"; 2 | import { PageMetadata } from "./shared/page-metadata"; 3 | 4 | export default function Page() { 5 | const message = "page not found"; 6 | 7 | return ( 8 | <> 9 | 10 | 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "njt (npm jump to)", 3 | "short_name": "njt (npm jump to)", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-144x144.png", 7 | "sizes": "144x144", 8 | "type": "image/png" 9 | } 10 | ], 11 | "theme_color": "#ffffff", 12 | "background_color": "#ffffff", 13 | "display": "standalone" 14 | } 15 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | ####################### 2 | ## Specific to Prettier 3 | ####################### 4 | 5 | ## ignore all files (but still allow sub-folder scanning) 6 | * 7 | !*/ 8 | 9 | ## allow certain file types 10 | !*.cjs 11 | !*.js 12 | !*.json 13 | !*.md 14 | !*.mjs 15 | !*.ts 16 | !*.tsx 17 | !*.yml 18 | 19 | ## Allow certain files without extensions 20 | !.husky/* 21 | !Dockerfile 22 | 23 | ######################### 24 | ## Shared between linters 25 | ######################### 26 | 27 | .git/ 28 | pnpm-lock.yaml 29 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | compiler: { styledComponents: true }, 5 | 6 | pageExtensions: ["page.tsx", "handler.ts"], 7 | 8 | productionBrowserSourceMaps: true, 9 | 10 | reactCompiler: true, 11 | reactStrictMode: true, 12 | 13 | rewrites: () => [ 14 | { 15 | source: "/jump", 16 | destination: "/api/jump", 17 | }, 18 | ], 19 | 20 | typescript: { ignoreBuildErrors: true }, 21 | }; 22 | 23 | export default nextConfig; 24 | -------------------------------------------------------------------------------- /pages/_app.page.tsx: -------------------------------------------------------------------------------- 1 | import { Analytics } from "@vercel/analytics/react"; 2 | import type { AppProps } from "next/app"; 3 | import * as React from "react"; 4 | 5 | import { PageLayout } from "./_app.page/page-layout"; 6 | 7 | export default function App({ Component, pageProps }: AppProps) { 8 | React.useEffect(() => { 9 | document.body.className = document.body.className.replace("no-js", "js"); 10 | }, []); 11 | 12 | return ( 13 | 14 | 15 | 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /public/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pages/index.page/clickable-code.tsx: -------------------------------------------------------------------------------- 1 | import { styled } from "styled-components"; 2 | 3 | export const ClickableCode = styled.code` 4 | border-bottom: 1px dotted transparent; 5 | color: inherit; 6 | .js & { 7 | cursor: pointer; 8 | border-bottom-color: rgba(27, 31, 35, 0.3); 9 | 10 | :active { 11 | background: rgba(27, 31, 35, 0.3); 12 | } 13 | } 14 | 15 | @media (prefers-color-scheme: dark) { 16 | .js & { 17 | border-bottom-color: rgba(127, 127, 127, 0.5); 18 | 19 | .js &:active { 20 | background: rgba(127, 127, 127, 0.5); 21 | } 22 | } 23 | } 24 | `; 25 | -------------------------------------------------------------------------------- /.markdownlintignore: -------------------------------------------------------------------------------- 1 | ########################### 2 | ## Specific to Markdownlint 3 | ########################### 4 | 5 | ## ignore all files (but still allow sub-folder scanning) 6 | * 7 | !*/ 8 | 9 | ## allow certain file types 10 | !*.md 11 | 12 | ######################## 13 | ## Same as in .gitignore 14 | ######################## 15 | 16 | ## dependencies 17 | node_modules 18 | 19 | ## next.js 20 | /.next/ 21 | /out/ 22 | tsconfig.tsbuildinfo 23 | 24 | ## misc 25 | .DS_Store 26 | .env* 27 | 28 | ## custom 29 | cli/README.md 30 | 31 | ######################### 32 | ## Shared between linters 33 | ######################### 34 | 35 | .git/ 36 | pnpm-lock.yaml 37 | -------------------------------------------------------------------------------- /public/opensearch.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | njt 4 | npm jump to 5 | https://njt.vercel.app/favicon-32x32.png 6 | njt 7 | 8 | 9 | https://njt.vercel.app 10 | 11 | -------------------------------------------------------------------------------- /cli/main.js: -------------------------------------------------------------------------------- 1 | import { readFileSync } from "node:fs"; 2 | import path from "node:path"; 3 | 4 | import open from "open"; 5 | 6 | export function getPackageVersion() { 7 | const filePath = new URL(import.meta.url).pathname; 8 | const packageJsonPath = path.resolve(path.dirname(filePath), "package.json"); 9 | const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")); 10 | 11 | return packageJson.version; 12 | } 13 | 14 | export function generateUrl(query) { 15 | return `https://njt.vercel.app/jump?from=cli%40${getPackageVersion()}&to=${encodeURIComponent( 16 | query, 17 | )}`; 18 | } 19 | 20 | export async function openUrl(url, browser) { 21 | await open(url, { app: browser }); 22 | } 23 | -------------------------------------------------------------------------------- /eslint.config.ts: -------------------------------------------------------------------------------- 1 | import { generateNextConfigs } from "@kachkaev/eslint-config-next"; 2 | import { defineConfig } from "eslint/config"; 3 | import typescriptEslint from "typescript-eslint"; 4 | 5 | export default defineConfig([ 6 | ...generateNextConfigs(), 7 | 8 | { 9 | files: ["cli/**/*.js"], 10 | extends: [typescriptEslint.configs.disableTypeChecked], 11 | rules: { 12 | "@eslint-react/no-unused-props": "off", 13 | "@typescript-eslint/explicit-module-boundary-types": "off", 14 | }, 15 | }, 16 | 17 | // TODO: Remove after migrating to app router 18 | { 19 | files: ["pages/**/*.page.tsx", "pages/**/*.handler.ts"], 20 | rules: { 21 | "import/no-default-export": "off", 22 | }, 23 | }, 24 | ]); 25 | -------------------------------------------------------------------------------- /pages/_error.page.tsx: -------------------------------------------------------------------------------- 1 | import type { NextPage } from "next"; 2 | 3 | import { ErrorPageBody } from "./shared/error-page-body"; 4 | import { PageMetadata } from "./shared/page-metadata"; 5 | 6 | // eslint-disable-next-line react/function-component-definition -- needed for getInitialProps 7 | const Page: NextPage<{ statusCode: number }> = ({ statusCode }) => { 8 | const message = "unknown error"; 9 | 10 | return ( 11 | <> 12 | 13 | 14 | 15 | ); 16 | }; 17 | 18 | Page.getInitialProps = ({ res, err }) => { 19 | const statusCode = res ? res.statusCode : (err?.statusCode ?? 500); 20 | 21 | return { statusCode }; 22 | }; 23 | 24 | export default Page; 25 | -------------------------------------------------------------------------------- /pages/shared/error-page-body.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { styled } from "styled-components"; 3 | 4 | const Container = styled.div` 5 | text-align: center; 6 | padding-top: 1.5em; 7 | `; 8 | const StatusCode = styled.h2` 9 | font-size: 8em; 10 | margin: 0; 11 | font-weight: normal; 12 | line-height: 1em; 13 | opacity: 0.2; 14 | `; 15 | 16 | const Message = styled.div` 17 | font-size: 2em; 18 | margin-bottom: 1.5em; 19 | opacity: 0.3; 20 | `; 21 | 22 | export function ErrorPageBody({ 23 | statusCode, 24 | message, 25 | }: { 26 | statusCode: number; 27 | message: string; 28 | }) { 29 | return ( 30 | 31 | {statusCode} 32 | {message} 33 | 34 | 🐸 → home page 35 | 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /pages/api/jump.handler.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiHandler } from "next"; 2 | 3 | import { resolveDestination } from "../shared/destinations"; 4 | 5 | const handler: NextApiHandler = async (req, res) => { 6 | let destinationUrl = "/"; 7 | 8 | const to = typeof req.query["to"] === "string" ? req.query["to"] : ""; 9 | 10 | const [rawPackageName, rawDestination] = to 11 | .split(" ") 12 | .filter((chunk) => chunk.length); 13 | 14 | if (rawPackageName) { 15 | const resolvedDestination = await resolveDestination( 16 | rawPackageName, 17 | rawDestination, 18 | ); 19 | 20 | if (resolvedDestination.outcome === "success") { 21 | destinationUrl = resolvedDestination.url; 22 | } 23 | } 24 | 25 | res.writeHead(302, { 26 | Location: destinationUrl, 27 | }); 28 | 29 | res.end(); 30 | }; 31 | 32 | export default handler; 33 | -------------------------------------------------------------------------------- /pages/index.page/example.tsx: -------------------------------------------------------------------------------- 1 | import { styled } from "styled-components"; 2 | 3 | import { ExternalLink } from "../shared/external-link"; 4 | import { ClickableCode } from "./clickable-code"; 5 | 6 | const Remark = styled.span` 7 | white-space: nowrap; 8 | `; 9 | 10 | const LinkRow = styled.span` 11 | display: block; 12 | white-space: nowrap; 13 | overflow: hidden; 14 | text-overflow: ellipsis; 15 | `; 16 | 17 | export function Example({ 18 | onToClick, 19 | remark, 20 | to, 21 | url, 22 | }: { 23 | onToClick?: (text: string) => void; 24 | remark: string; 25 | to: string; 26 | url: string; 27 | }) { 28 | function handleCodeClick() { 29 | onToClick?.(to); 30 | } 31 | 32 | return ( 33 |

34 | 35 | njt {to}{" "} 36 | ({remark}) 37 | 38 | 39 | 🐸 → 40 | 41 |

42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /cli/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "njt", 3 | "version": "3.0.0", 4 | "description": "npm jump to: a quick navigation tool for npm packages", 5 | "keywords": [ 6 | "frog", 7 | "jump", 8 | "meta", 9 | "njt", 10 | "npm", 11 | "npmjs", 12 | "productivity", 13 | "search", 14 | "search-engine", 15 | "shortcuts", 16 | "vercel", 17 | "vercel-deployment" 18 | ], 19 | "homepage": "https://njt.vercel.app", 20 | "bugs": "https://github.com/kachkaev/njt/issues", 21 | "repository": { 22 | "type": "git", 23 | "url": "https://github.com/kachkaev/njt.git" 24 | }, 25 | "license": "BSD-3-Clause", 26 | "author": { 27 | "name": "Alexander Kachkaev", 28 | "email": "alexander@kachkaev.ru" 29 | }, 30 | "type": "module", 31 | "main": "main.js", 32 | "bin": "cli.js", 33 | "scripts": { 34 | "prepack": "pnpm ncp ../README.md README.md" 35 | }, 36 | "dependencies": { 37 | "chalk": "^5.3.0", 38 | "commander": "^12.0.0", 39 | "find-package-json": "^1.2.0", 40 | "open": "^10.0.0" 41 | }, 42 | "devDependencies": { 43 | "ncp": "^2.0.0" 44 | }, 45 | "engines": { 46 | "node": ">=16" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /pages/shared/page-metadata.tsx: -------------------------------------------------------------------------------- 1 | import Head from "next/head"; 2 | 3 | function getBaseUrl() { 4 | const hostname = process.env["NEXT_PUBLIC_VERCEL_URL"] ?? "njt.vercel.app"; 5 | const protocol = hostname.split(":")[0] === "localhost" ? "http" : "https"; 6 | 7 | return `${protocol}://${hostname}`; 8 | } 9 | 10 | export function PageMetadata({ 11 | title = "njt (npm jump to)", 12 | description = "a quick navigation tool for npm packages", 13 | }: { 14 | title?: string; 15 | description?: string; 16 | }) { 17 | const baseUrl = getBaseUrl(); 18 | 19 | return ( 20 | 21 | {title} 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended", 5 | ":dependencyDashboardApproval", 6 | ":disableDigestUpdates", 7 | ":maintainLockFilesWeekly", 8 | ":semanticCommitsDisabled", 9 | "helpers:pinGitHubActionDigestsToSemver" 10 | ], 11 | "packageRules": [ 12 | { 13 | "autoApprove": true, 14 | "automerge": true, 15 | "dependencyDashboardApproval": false, 16 | "enabled": true, 17 | "minimumReleaseAge": "2 days", 18 | "matchManagers": ["npm"], 19 | "matchPackageNames": ["/^pnpm$/"], 20 | "schedule": ["every weekend"] 21 | }, 22 | { 23 | "autoApprove": true, 24 | "automerge": true, 25 | "dependencyDashboardApproval": false, 26 | "enabled": true, 27 | "matchUpdateTypes": ["patch"], 28 | "matchCurrentValue": "!/^0\\./", 29 | "matchManagers": ["npm"], 30 | "minimumReleaseAge": "2 days", 31 | "schedule": ["every weekend"] 32 | }, 33 | { 34 | "autoApprove": true, 35 | "automerge": true, 36 | "dependencyDashboardApproval": false, 37 | "enabled": true, 38 | "matchDepTypes": ["devDependencies"], 39 | "matchManagers": ["npm"], 40 | "minimumReleaseAge": "2 days", 41 | "schedule": ["every weekend"] 42 | } 43 | ], 44 | "postUpdateOptions": ["pnpmDedupe"], 45 | "prConcurrentLimit": 5, 46 | "prHourlyLimit": 1, 47 | "rangeStrategy": "pin", 48 | "timezone": "Etc/UTC" 49 | } 50 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # BSD 3-Clause License 2 | 3 | Copyright © 2019-present Alexander Kachkaev () 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | - Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | - Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | - Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "license": "BSD-3-Clause", 4 | "type": "module", 5 | "scripts": { 6 | "build": "next build", 7 | "dev": "next dev", 8 | "fix": "npm-run-all --continue-on-error \"fix:*\"", 9 | "fix:eslint": "next typegen && eslint --fix --max-warnings=0", 10 | "fix:markdownlint": "markdownlint --dot --fix .", 11 | "fix:pnpm-dedupe": "pnpm dedupe", 12 | "fix:prettier": "prettier --write .", 13 | "postinstall": "husky", 14 | "lint": "npm-run-all --continue-on-error \"lint:*\"", 15 | "lint:eslint": "next typegen && eslint --max-warnings=0", 16 | "lint:markdownlint": "markdownlint --dot .", 17 | "lint:pnpm-dedupe": "pnpm dedupe --check", 18 | "lint:prettier": "prettier --check .", 19 | "lint:tsc": "next typegen && tsc --project .", 20 | "start": "next start" 21 | }, 22 | "lint-staged": { 23 | "**": [ 24 | "suppress-exit-code eslint --fix", 25 | "suppress-exit-code markdownlint --fix", 26 | "suppress-exit-code prettier --write" 27 | ] 28 | }, 29 | "prettier": "@kachkaev/prettier-config", 30 | "dependencies": { 31 | "@vercel/analytics": "1.5.0", 32 | "hosted-git-info": "8.1.0", 33 | "lru-cache": "10.4.3", 34 | "next": "16.0.10", 35 | "react": "19.2.3", 36 | "react-dom": "19.2.3", 37 | "styled-components": "6.1.19", 38 | "styled-normalize": "8.1.1", 39 | "url": "0.11.4" 40 | }, 41 | "devDependencies": { 42 | "@kachkaev/eslint-config-next": "1.0.1", 43 | "@kachkaev/markdownlint-config": "1.0.0", 44 | "@kachkaev/prettier-config": "2.1.4", 45 | "@next/eslint-plugin-next": "16.0.10", 46 | "@tsconfig/next": "2.0.5", 47 | "@tsconfig/strictest": "2.0.8", 48 | "@types/hosted-git-info": "3.0.5", 49 | "@types/node": "24.10.1", 50 | "@types/react": "19.2.7", 51 | "babel-plugin-react-compiler": "1.0.0", 52 | "eslint": "9.39.2", 53 | "husky": "9.1.7", 54 | "lint-staged": "16.2.7", 55 | "markdownlint-cli": "0.47.0", 56 | "npm-run-all2": "8.0.4", 57 | "prettier": "3.7.4", 58 | "suppress-exit-code": "3.2.0", 59 | "typescript": "5.9.3", 60 | "typescript-eslint": "8.50.0" 61 | }, 62 | "packageManager": "pnpm@10.26.1" 63 | } 64 | -------------------------------------------------------------------------------- /pages/_document.page.tsx: -------------------------------------------------------------------------------- 1 | import Document, { 2 | type DocumentContext, 3 | Head, 4 | Html, 5 | Main, 6 | NextScript, 7 | } from "next/document"; 8 | import * as React from "react"; 9 | import { ServerStyleSheet } from "styled-components"; 10 | 11 | export default class MyDocument extends Document { 12 | static override async getInitialProps(ctx: DocumentContext) { 13 | const sheet = new ServerStyleSheet(); 14 | const originalRenderPage = ctx.renderPage; 15 | 16 | try { 17 | ctx.renderPage = () => 18 | originalRenderPage({ 19 | enhanceApp: (App) => (props) => 20 | sheet.collectStyles(), 21 | }); 22 | 23 | const initialProps = await Document.getInitialProps(ctx); 24 | 25 | return { 26 | ...initialProps, 27 | styles: [ 28 | 29 | {initialProps.styles} 30 | {sheet.getStyleElement()} 31 | , 32 | ], 33 | }; 34 | } finally { 35 | sheet.seal(); 36 | } 37 | } 38 | 39 | override render() { 40 | return ( 41 | 42 | 43 | 44 | 49 | 55 | 61 | 62 | 63 | 69 | 70 | 71 | 72 | 73 |
74 | 75 | 76 | 77 | ); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | lint: 13 | name: Lint 14 | runs-on: ubuntu-24.04 15 | steps: 16 | - name: Check out repository 17 | uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 18 | 19 | - name: Setup pnpm 20 | uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 21 | 22 | - name: Setup node 23 | uses: actions/setup-node@v6 24 | with: 25 | cache: pnpm 26 | node-version-file: .tool-versions 27 | 28 | - name: Install dependencies 29 | run: pnpm install 30 | 31 | - name: Run pnpm lint:eslint 32 | if: ${{ success() || failure() }} 33 | run: | 34 | if ! pnpm lint:eslint; then 35 | echo '' 36 | echo '' 37 | echo 'ℹ️ ℹ️ ℹ️' 38 | echo 'Try running `pnpm fix:eslint` locally to apply autofixes.' 39 | echo 'ℹ️ ℹ️ ℹ️' 40 | exit 1 41 | fi 42 | 43 | - name: Run pnpm lint:markdownlint 44 | if: ${{ success() || failure() }} 45 | run: | 46 | if ! pnpm lint:markdownlint; then 47 | echo '' 48 | echo '' 49 | echo 'ℹ️ ℹ️ ℹ️' 50 | echo 'Try running `pnpm fix:markdownlint` locally to apply autofixes.' 51 | echo 'ℹ️ ℹ️ ℹ️' 52 | exit 1 53 | fi 54 | 55 | - name: Run pnpm lint:pnpm-dedupe 56 | if: ${{ success() || failure() }} 57 | run: | 58 | if ! pnpm lint:pnpm-dedupe; then 59 | echo '' 60 | echo '' 61 | echo 'ℹ️ ℹ️ ℹ️' 62 | echo 'Some dependencies can be deduplicated, which will make pnpm-lock.yaml' 63 | echo 'lighter and potentially save us from unexplainable bugs.' 64 | echo 'Please run `pnpm fix:pnpm-dedupe` locally and commit pnpm-lock.yaml.' 65 | echo 'ℹ️ ℹ️ ℹ️' 66 | exit 1 67 | fi 68 | 69 | - name: Run pnpm lint:prettier 70 | if: ${{ success() || failure() }} 71 | run: | 72 | if ! pnpm lint:prettier; then 73 | echo '' 74 | echo '' 75 | echo 'ℹ️ ℹ️ ℹ️' 76 | echo 'Try running `pnpm fix:prettier` locally to apply autofixes.' 77 | echo 'ℹ️ ℹ️ ℹ️' 78 | exit 1 79 | fi 80 | 81 | - name: Run pnpm lint:tsc 82 | if: ${{ success() || failure() }} 83 | run: | 84 | if ! pnpm lint:tsc; then 85 | echo '' 86 | echo '' 87 | echo 'ℹ️ ℹ️ ℹ️' 88 | echo 'Please fix the above errors locally for the check to pass.' 89 | echo 'If you don’t see them, try merging target branch into yours.' 90 | echo 'ℹ️ ℹ️ ℹ️' 91 | exit 1 92 | fi 93 | -------------------------------------------------------------------------------- /pages/_app.page/page-layout.tsx: -------------------------------------------------------------------------------- 1 | import type * as React from "react"; 2 | import { createGlobalStyle, css, styled } from "styled-components"; 3 | import normalize from "styled-normalize"; 4 | 5 | import { ExternalLink } from "../shared/external-link"; 6 | 7 | const base = css` 8 | body { 9 | color: #24292e; 10 | font-family: 11 | "-apple-system", 12 | BlinkMacSystemFont, 13 | Avenir Next, 14 | Avenir, 15 | Helvetica, 16 | sans-serif; 17 | margin: 0; 18 | line-height: 1.4em; 19 | 20 | @media (prefers-color-scheme: dark) { 21 | background: #24292e; 22 | color: #fff; 23 | } 24 | } 25 | 26 | a { 27 | color: #0366d6; 28 | text-decoration: none; 29 | 30 | @media (prefers-color-scheme: dark) { 31 | color: #59a7ff; 32 | } 33 | 34 | :hover { 35 | text-decoration: underline; 36 | } 37 | } 38 | 39 | code { 40 | padding: 0.1em 0.2em; 41 | border-radius: 3px; 42 | color: inherit; 43 | 44 | background: rgba(27, 31, 35, 0.05); 45 | 46 | @media (prefers-color-scheme: dark) { 47 | background: rgba(127, 127, 127, 0.3); 48 | } 49 | } 50 | `; 51 | 52 | const GlobalStyle = createGlobalStyle` 53 | ${ 54 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- TODO: Remove together with styled components 55 | normalize 56 | } 57 | ${base} 58 | `; 59 | 60 | const Container = styled.div` 61 | margin: 0 auto; 62 | padding: 0 20px 50px; 63 | position: relative; 64 | max-width: 35em; 65 | min-width: 270px; 66 | `; 67 | 68 | const TopSection = styled.div` 69 | padding: 80px 0 40px; 70 | 71 | @media (max-width: 700px) { 72 | padding: 40px 0 20px; 73 | } 74 | 75 | @media (max-width: 550px) { 76 | padding: 10px 0 0px; 77 | } 78 | `; 79 | 80 | const Title = styled.h1` 81 | margin: 0; 82 | font-size: 48px; 83 | line-height: 1.4em; 84 | text-align: center; 85 | `; 86 | const Description = styled.div` 87 | font-weight: bold; 88 | text-align: center; 89 | `; 90 | 91 | const ExternalLinks = styled.div` 92 | margin: 10px auto 0; 93 | text-align: center; 94 | & > * { 95 | margin: 0 8px; 96 | } 97 | `; 98 | 99 | export function PageLayout({ children }: { children: React.ReactNode }) { 100 | return ( 101 | 102 | 103 | 104 | 🐸 njt 🐸 105 | 🐸 npm jump to  🐸 106 | 107 | 108 | github 109 | 110 | 111 | npm 112 | 113 | 114 | {children} 115 | 116 | 117 | ); 118 | } 119 | -------------------------------------------------------------------------------- /cli/cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import chalk from "chalk"; 4 | import { program } from "commander"; 5 | 6 | import { generateUrl, getPackageVersion, openUrl } from "./main.js"; 7 | 8 | const green = chalk.green; 9 | const code = chalk.dim; 10 | 11 | // eslint-disable-next-line no-console -- Allowed in CLI 12 | const log = console.log; 13 | 14 | program 15 | .version(getPackageVersion()) 16 | .name("njt") 17 | .usage(" [destination]") 18 | .description( 19 | // prettier-ignore 20 | `🐸 ✨ 🐸 ✨ 🐸 21 | npm jump to: a quick navigation tool for npm packages 22 | 23 | https://njt.vercel.app 24 | 25 | ${ /* When updating, remember to reflect changes in README.md and src/ui/PageContentsForIndex/AvailableDestinations.tsx */''} 26 | Available destinations 27 | ---------------------- 28 | ${green('b')} → package cost estimation on https://bundlephobia.com 29 | ${green('c')} → changelog 30 | ${green('g')} → github (gitlab, etc.) repository root 31 | ${green('h')} → homepage (aliased as ${green('w')} for website or ${green('d')} for docs) 32 | ${green('i')} → issues 33 | ${green('n')} → package info on https://www.npmjs.com 34 | ${green('p')} → pull requests (aliased as ${green('m')} for merge requests) 35 | ${green('r')} → list of github releases 36 | ${green('s')} → source (often same as repository root, but can be its subdirectory in case of a monorepo) 37 | ${green('t')} → list of git tags 38 | ${green('u')} → package contents preview on https://unpkg.com 39 | ${green('v')} → list of package versions with dates on https://www.npmjs.com 40 | ${green('y')} → package page on https://yarnpkg.com (mirror registry for https://www.npmjs.com) 41 | ${green('.')} → browse GitHub / GitLab code 42 | 43 | Omitting the destination or entering an non-existing one takes you to the package page on https://www.npmjs.com as if you used ${green('n')}. 44 | 45 | 46 | Examples 47 | -------- 48 | ${code('njt prettier')} (no specified destination) 49 | 🐸 → https://www.npmjs.com/package/prettier 50 | 51 | ${code('njt prettier h')} (homepage) 52 | 🐸 → https://prettier.io 53 | 54 | ${code('njt prettier s')} (source) 55 | 🐸 → https://github.com/prettier/prettier 56 | 57 | ${code('njt prettier r')} (releases) 58 | 🐸 → https://github.com/prettier/prettier/releases 59 | 60 | ${code('njt prettier y')} (yarn) 61 | 🐸 → https://yarnpkg.com/package/prettier 62 | 63 | 64 | Pro tip 65 | ------- 66 | When you specify . instead of a package name, njt takes the name from the nearest package.json file. 67 | `, 68 | ) 69 | .parse(process.argv); 70 | 71 | if (program.rawArgs.length < 3) { 72 | log(program.help()); 73 | process.exit(1); 74 | } 75 | 76 | const args = [...program.args]; 77 | if (args[0] === ".") { 78 | const { default: finder } = await import("find-package-json"); 79 | const finderInstance = finder(); 80 | const packageJsonSearchResult = finderInstance.next(); 81 | if (!packageJsonSearchResult.value) { 82 | log(` 83 | ${chalk.red( 84 | "You specified package name as . but package.json was not found in the current folder or in parent folders.", 85 | )} 86 | Change directly or replace . with a package name. 87 | 88 | 🐸 https://njt.vercel.app 89 | `); 90 | process.exit(1); 91 | } 92 | log(` 93 | Resolved . as ${packageJsonSearchResult.filename}`); 94 | const packageName = packageJsonSearchResult.value.name; 95 | if (!packageName) { 96 | log(` 97 | ${chalk.red( 98 | 'You specified package name as . but "name" field was not found in the resolved package.json file.', 99 | )} 100 | Change directly or replace . with a package name. 101 | 102 | 🐸 https://njt.vercel.app 103 | `); 104 | process.exit(1); 105 | } 106 | args[0] = packageName; 107 | } 108 | 109 | openUrl( 110 | generateUrl(args.join(" ")), 111 | process.env.NJT_BROWSER || process.env.BROWSER, 112 | ); 113 | -------------------------------------------------------------------------------- /pages/index.page/input-form.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { styled } from "styled-components"; 3 | 4 | const verticalFormPadding = 20; 5 | 6 | const Form = styled.form` 7 | display: block; 8 | white-space: nowrap; 9 | font-size: 2em; 10 | padding: ${verticalFormPadding}px 0 0; 11 | width: 100%; 12 | max-width: 100%; 13 | position: relative; 14 | line-height: 1em; 15 | 16 | @media (max-width: 600px) { 17 | font-size: 1.8em; 18 | } 19 | @media (max-width: 550px) { 20 | font-size: 1.6em; 21 | } 22 | @media (max-width: 510px) { 23 | font-size: 1.5em; 24 | } 25 | @media (max-width: 450px) { 26 | font-size: 1.4em; 27 | } 28 | @media (max-width: 420px) { 29 | font-size: 1.3em; 30 | } 31 | @media (max-width: 400px) { 32 | font-size: 1.25em; 33 | } 34 | @media (max-width: 370px) { 35 | font-size: 1.1em; 36 | } 37 | @media (max-width: 350px) { 38 | font-size: 1em; 39 | } 40 | `; 41 | 42 | const Label = styled.label` 43 | padding: 0.3em 0 0 0.7em; 44 | font-family: monospace; 45 | display: inline-block; 46 | position: absolute; 47 | top: ${verticalFormPadding + 1}px; 48 | left: 0; 49 | pointer-events: none; 50 | `; 51 | 52 | const Input = styled.input` 53 | display: inline-block; 54 | padding: 0.3em 4em 0.3em 3em; 55 | color: inherit; 56 | line-height: inherit; 57 | font-family: monospace; 58 | width: 100%; 59 | max-width: 100%; 60 | box-sizing: border-box; 61 | margin: 0; 62 | border: 1px solid rgba(127, 127, 127, 0.5); 63 | border-radius: 5px; 64 | -webkit-appearance: none; 65 | /* transition: all 0.2s ease-in-out; */ 66 | 67 | background: rgba(27, 31, 35, 0.05); 68 | 69 | @media (prefers-color-scheme: dark) { 70 | background: rgba(127, 127, 127, 0.3); 71 | } 72 | 73 | ::placeholder { 74 | color: rgba(127, 127, 127, 0.7); 75 | } 76 | 77 | :focus { 78 | outline: none !important; 79 | border: 1px solid #42a73f; 80 | box-shadow: 0 0 10px #7cd679; 81 | } 82 | `; 83 | 84 | const SubmitButton = styled.button` 85 | border: none; 86 | background: transparent; 87 | line-height: inherit; 88 | padding: 0.25em 0.4em 0.3em 0; 89 | cursor: pointer; 90 | color: inherit; 91 | 92 | position: absolute; 93 | top: ${verticalFormPadding + 1}px; 94 | right: 0; 95 | 96 | :active { 97 | top: ${verticalFormPadding + 2}px; 98 | } 99 | 100 | :focus { 101 | outline: none !important; 102 | } 103 | `; 104 | 105 | export function InputForm({ 106 | text, 107 | onTextChange, 108 | }: { 109 | text?: string; 110 | onTextChange?: (value: string) => void; 111 | }) { 112 | const formRef = React.useRef(null); 113 | 114 | const previousToValue = React.useRef(undefined); 115 | const toInputRef = React.useRef(null); 116 | 117 | function handleInputChange({ 118 | currentTarget: { value }, 119 | }: React.ChangeEvent): void { 120 | previousToValue.current = value; 121 | onTextChange?.(value); 122 | } 123 | const focusAndSelectAll = React.useCallback(() => { 124 | const input = toInputRef.current; 125 | if (input) { 126 | input.focus({ preventScroll: true }); 127 | input.setSelectionRange(0, input.value.length); 128 | } 129 | }, [toInputRef]); 130 | 131 | React.useEffect(() => { 132 | if (previousToValue.current !== text) { 133 | focusAndSelectAll(); 134 | } 135 | if (typeof previousToValue.current === "string" && formRef.current) { 136 | formRef.current.scrollIntoView({ 137 | block: "nearest", 138 | inline: "nearest", 139 | behavior: "smooth", 140 | }); 141 | } 142 | previousToValue.current = text; 143 | }, [focusAndSelectAll, text, previousToValue, formRef]); 144 | 145 | const [from, setFrom] = React.useState("noscript"); 146 | const fromInputRef = React.useRef(null); 147 | 148 | React.useEffect(() => { 149 | // eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect -- expected use (state change on mount) 150 | setFrom("bookmark"); 151 | }, []); 152 | 153 | function handleFormSubmit() { 154 | if (fromInputRef.current) { 155 | fromInputRef.current.value = "web"; 156 | } 157 | 158 | return true; 159 | } 160 | 161 | return ( 162 |
163 | 164 | 165 | 174 | 🐸 → 175 |
176 | ); 177 | } 178 | -------------------------------------------------------------------------------- /pages/index.page/available-destinations.tsx: -------------------------------------------------------------------------------- 1 | import type * as React from "react"; 2 | import { styled } from "styled-components"; 3 | 4 | import { ExternalLink } from "../shared/external-link"; 5 | import { ClickableCode } from "./clickable-code"; 6 | 7 | const Ul = styled.ul` 8 | padding-left: 0; 9 | overflow: hidden; 10 | `; 11 | 12 | const Item = styled.li<{ highlighted?: boolean }>` 13 | list-style: none; 14 | /* transition: color 0.2s ease-in-out; */ 15 | white-space: nowrap; 16 | 17 | ${(props) => (props.highlighted ? "color: #42a73f" : "")}; 18 | `; 19 | 20 | const Keyword = styled(ClickableCode)<{ 21 | onClick: React.MouseEventHandler; 22 | children: string; 23 | }>` 24 | display: inline-block; 25 | `; 26 | 27 | const Arrow = styled.span` 28 | display: inline-block; 29 | :after { 30 | display: inline-block; 31 | content: "→"; 32 | } 33 | `; 34 | 35 | const Info = styled.span` 36 | display: inline-block; 37 | white-space: normal; 38 | vertical-align: top; 39 | margin-right: 2.5em; 40 | `; 41 | 42 | type KeywordInfo = { 43 | keywords: string[]; 44 | info: React.ReactNode; 45 | }; 46 | 47 | export function AvailableDestinations({ 48 | selectedDestination, 49 | onSelectedDestinationChange, 50 | }: { 51 | selectedDestination: string | undefined; 52 | onSelectedDestinationChange: (selectedDestination: string) => void; 53 | }) { 54 | function handleKeywordClick({ 55 | currentTarget, 56 | }: React.MouseEvent): void { 57 | onSelectedDestinationChange(currentTarget.textContent); 58 | } 59 | 60 | // When updating, remember to reflect changes in README.md and cli/cli.js 61 | const keywordInfos: KeywordInfo[] = [ 62 | { 63 | keywords: ["b"], 64 | info: ( 65 | <> 66 | package cost estimation on{" "} 67 | 68 | 69 | ), 70 | }, 71 | { 72 | keywords: ["c"], 73 | info: "changelog", 74 | }, 75 | { 76 | keywords: ["g"], 77 | info: <>github (gitlab, etc.) repository root, 78 | }, 79 | { 80 | keywords: ["h", "w", "d"], 81 | info: ( 82 | <> 83 | homepage (aliased as w{" "} 84 | for website or d{" "} 85 | for docs) 86 | 87 | ), 88 | }, 89 | { 90 | keywords: ["i"], 91 | info: <>issues, 92 | }, 93 | { 94 | keywords: ["n"], 95 | info: ( 96 | <> 97 | package info on 98 | 99 | ), 100 | }, 101 | { 102 | keywords: ["p", "m"], 103 | info: ( 104 | <> 105 | pull requests (aliased as{" "} 106 | m for merge 107 | requests) 108 | 109 | ), 110 | }, 111 | { 112 | keywords: ["r"], 113 | info: "list of github releases", 114 | }, 115 | { 116 | keywords: ["s"], 117 | info: ( 118 | <> 119 | source (often same as repository root, but can be 120 | its subdirectory in case of a monorepo) 121 | 122 | ), 123 | }, 124 | { 125 | keywords: ["t"], 126 | info: "list of git tags", 127 | }, 128 | { 129 | keywords: ["u"], 130 | info: ( 131 | <> 132 | package contents preview on 133 | 134 | ), 135 | }, 136 | { 137 | keywords: ["v"], 138 | info: ( 139 | <> 140 | list of package versions with dates on{" "} 141 | 142 | 143 | ), 144 | }, 145 | { 146 | keywords: ["y"], 147 | info: ( 148 | <> 149 | package page on (mirror 150 | registry for ) 151 | 152 | ), 153 | }, 154 | { 155 | keywords: ["."], 156 | info: <>browse GitHub / GitLab code, 157 | }, 158 | ]; 159 | 160 | return ( 161 | <> 162 |
    163 | {keywordInfos.map(({ keywords, info }) => ( 164 | 172 | {keywords[0] ?? ""}{" "} 173 | {info} 174 | 175 | ))} 176 |
177 |

178 | Omitting the destination or entering an non-existing one takes you to 179 | the package page on as if 180 | you used n. 181 |

182 | 183 | ); 184 | } 185 | -------------------------------------------------------------------------------- /pages/index.page.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { styled } from "styled-components"; 3 | 4 | import { AvailableDestinations } from "./index.page/available-destinations"; 5 | import { Example } from "./index.page/example"; 6 | import { InputForm } from "./index.page/input-form"; 7 | import { ExternalLink } from "./shared/external-link"; 8 | import { PageMetadata } from "./shared/page-metadata"; 9 | 10 | const H2 = styled.h2` 11 | margin-top: 3em; 12 | font-size: 1em; 13 | `; 14 | 15 | const ExamplePackages = styled.div``; 16 | 17 | const ExamplePackage = styled.div<{ clickable: boolean }>` 18 | display: inline-block; 19 | margin-right: 0.5em; 20 | cursor: default; 21 | ${(props) => 22 | props.clickable 23 | ? ` 24 | border-bottom: 1px dotted #24292e66; 25 | cursor: pointer; 26 | ` 27 | : ""}; 28 | `; 29 | 30 | const remarkByDestination = { 31 | "": "no specified destination", 32 | h: "homepage", 33 | s: "source", 34 | r: "releases", 35 | y: "yarn", 36 | }; 37 | 38 | // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- keys don't get correct typings automatically 39 | const remarkByDestinationEntries = Object.entries(remarkByDestination) as Array< 40 | [DestinationKey, string] 41 | >; 42 | 43 | type DestinationKey = keyof typeof remarkByDestination; 44 | 45 | const exampleUrlByPackageAndDestination: Record< 46 | string, 47 | Record 48 | > = { 49 | prettier: { 50 | "": "https://www.npmjs.com/package/prettier", 51 | h: "https://prettier.io", 52 | s: "https://github.com/prettier/prettier", 53 | r: "https://github.com/prettier/prettier/releases", 54 | y: "https://yarnpkg.com/package/prettier", 55 | }, 56 | react: { 57 | "": "https://www.npmjs.com/package/react", 58 | h: "https://reactjs.org", 59 | s: "https://github.com/facebook/react/tree/main/packages/react", 60 | r: "https://github.com/facebook/react/releases", 61 | y: "https://yarnpkg.com/package/react", 62 | }, 63 | lodash: { 64 | "": "https://www.npmjs.com/package/lodash", 65 | h: "https://lodash.com", 66 | s: "https://github.com/lodash/lodash", 67 | r: "https://github.com/lodash/lodash/releases", 68 | y: "https://yarnpkg.com/package/lodash", 69 | }, 70 | typescript: { 71 | "": "https://www.npmjs.com/package/typescript", 72 | h: "https://www.typescriptlang.org", 73 | s: "https://github.com/Microsoft/TypeScript", 74 | r: "https://github.com/Microsoft/TypeScript/releases", 75 | y: "https://yarnpkg.com/package/typescript", 76 | }, 77 | }; 78 | 79 | function Page() { 80 | const [examplePackage, setExamplePackage] = React.useState( 81 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- exampleUrlByPackageAndDestination is static and non-empty 82 | () => Object.keys(exampleUrlByPackageAndDestination)[0]!, 83 | ); 84 | 85 | function handleExamplePackageClick(event: React.MouseEvent) { 86 | setExamplePackage(event.currentTarget.textContent); 87 | } 88 | 89 | const [inputText, setInputText] = React.useState(""); 90 | 91 | function handleExampleClick(text: string): void { 92 | setInputText(" "); 93 | setTimeout(() => { 94 | setInputText(text); 95 | }, 0); 96 | } 97 | 98 | function handleSelectedDestinationChange(destination: string): void { 99 | setInputText((currentInputText) => { 100 | const currentPackage = currentInputText.trim().split(" ", 1)[0]; 101 | 102 | return `${(currentPackage ?? "") || examplePackage} ${destination}`; 103 | }); 104 | } 105 | 106 | return ( 107 | <> 108 | 109 | 110 | 111 |

Available destinations

112 | 116 | 117 |

Examples

118 | 119 | {Object.keys(exampleUrlByPackageAndDestination).map( 120 | (currentExamplePackage) => ( 121 | 126 | {currentExamplePackage} 127 | 128 | ), 129 | )} 130 | 131 | {remarkByDestinationEntries.map(([destination, remark]) => { 132 | const destinationLookup = 133 | exampleUrlByPackageAndDestination[examplePackage]; 134 | 135 | if (!destinationLookup) { 136 | return; 137 | } 138 | 139 | return ( 140 | 147 | ); 148 | })} 149 | 150 |

More!

151 |

152 | njt gives you an even bigger productivity boost when 153 | integrated into browser or terminal. See instructions in{" "} 154 | 155 | GitHub repo’s README 156 | 157 | . 158 |

159 |

160 | Crafted by{" "} 161 | 162 | Alexander Kachkaev 163 | {" "} 164 | using Next.js, 165 | hosted by  166 | Vercel 167 |  💚 168 |

169 | 170 | ); 171 | } 172 | 173 | export default Page; 174 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

🐸 njt 🐸

2 | 3 |

4 | 🐸 npm jump to 🐸
5 | njt.vercel.app 6 |

7 | 8 | Do you type package names in your search engine and then navigate to their source, homepage, changelog and so on? 🕐🕑🕒🕓🕔 9 | 10 | [Save five seconds thousands of times](https://xkcd.com/1205/) by quickly jumping to the right URL: 11 | 12 | ```txt 13 | 🐸✨🐸✨🐸 14 | njt [destination] 15 | 🐸✨🐸✨🐸 16 | ``` 17 | 18 | ## Available destinations 19 | 20 | 21 | 22 | - `b` → package cost estimation on [bundlephobia.com](https://bundlephobia.com) 23 | - `c` → changelog 24 | - `g` → github (gitlab, etc.) repository root 25 | - `h` → homepage (aliased as `w` for website or `d` for docs) 26 | - `i` → issues 27 | - `n` → package info on [npmjs.com](https://www.npmjs.com) 28 | - `p` → pull requests (aliased as `m` for merge requests) 29 | - `r` → list of github releases 30 | - `s` → source (often same as repository root, but can be its subdirectory in case of a monorepo) 31 | - `t` → list of git tags 32 | - `u` → package contents preview on [unpkg.com](https://unpkg.com) 33 | - `v` → list of package versions with dates on [npmjs.com](https://www.npmjs.com) 34 | - `y` → package page on [yarnpkg.com](https://yarnpkg.com) (mirror registry for [npmjs.com](https://www.npmjs.com)) 35 | - `.` → browse GitHub / GitLab code 36 | 37 | Omitting the destination or entering an non-existing one takes you to the package page on [npmjs.com](https://www.npmjs.com) as if you used `n`. 38 | 39 | ## Examples 40 | 41 | `njt prettier` (no specified destination) 42 | 🐸 → 43 | 44 | `njt prettier h` (homepage) 45 | 🐸 → 46 | 47 | `njt prettier s` (source) 48 | 🐸 → 49 | 50 | `njt prettier r` (releases) 51 | 🐸 → 52 | 53 | `njt prettier y` (yarn) 54 | 🐸 → 55 | 56 | ## Getting `njt` 57 | 58 | There are several environments in which you can access `njt`. 59 | Pick your favourite or use ’em all! 60 | 61 | ### 🟢 Command-line tool 62 | 63 | Install `njt` globally [from npm](https://www.npmjs.com/package/njt) by running this command in your terminal: 64 | 65 | ```bash 66 | npm install --global njt 67 | ``` 68 | 69 | You are all set. 70 | Now try executing `njt [destination]` with some real arguments. 71 | For example, these two commands will take you to the Lodash **g**ithub repo and **h**omepage, respectively: 72 | 73 | ```bash 74 | njt lodash g 75 | njt lodash h 76 | ``` 77 | 78 | A list of supported destinations will be shown if you launch `njt` without arguments. 79 | 80 | To uninstall, run `npm remove --global njt`. 81 | To reinstall or upgrade, run `npm install --global njt` again. 82 | 83 | **Pro tip 💡** When you specify `.` instead of a package name, `njt` takes the name from the nearest `package.json` file. 84 | 85 | **Pro tip 💡** To customise which browser you want to open, set an environment variable called `NJT_BROWSER` (or just `BROWSER`) with the app name of your choice. 86 | The value [may vary](https://www.npmjs.com/package/open#app) based on your OS. 87 | Note that setting `BROWSER` instead of `NJT_BROWSER` can affect other tools, which may or may not be desired. 88 | 89 | ### 🟢 Custom search engine in Chrome 90 | 91 | 1. Open Chrome settings, e.g. by navigating to `chrome://settings` 92 | 1. Navigate to _Manage search engines_ section (e.g. by typing its name in the _Search settings_ field) 93 | 1. Click _Add_ next to _Other search engines_ 94 | 1. Fill in the _Add search engine_ form: 95 | 96 | | Field | Value | 97 | | ----------------------------- | ----------------------------------------------- | 98 | | Search engine | `njt (npm jump to)` | 99 | | Keyword | `njt` | 100 | | Url with %s in place of query | `https://njt.vercel.app/jump?from=chrome&to=%s` | 101 | 102 | 1. Press _Add_ 103 | 104 | From now on, typing `njt [destination]` in the address bar will take you directly to a page you want. 105 | For example, `njt react h` will take you to the [React.js homepage](https://reactjs.org). 106 | 107 | To uninstall, open _Manage search engines_ section in Chrome settings, click on three dots next to _Other search engines → njt_ and hit _Remove from list_. 108 | 109 | **Pro tip 💡** You can use `n` instead of `njt` as a keyword to avoid typing two extra characters each time. 110 | The command to type in Chrome address bar will become `n [destination]` 🚀 111 | 112 | ### 🟢 Search bookmark in Firefox 113 | 114 | You can use `njt` right from the address bar in Firefox. 115 | 116 | 1. Open [njt.vercel.app](https://njt.vercel.app) 117 | 1. Right-click on the search input field 118 | 1. In the context menu, select _Add Keyword for this Search..._ 119 | 1. You’ll see a small form; type `njt` into the _Keyword_ field 120 | 1. Press _Save_ 121 | 122 | From now on, typing `njt [destination]` in the address bar will take you directly to a page you want. 123 | For example, `njt react h` will take you to the [React.js homepage](https://reactjs.org). 124 | 125 | To uninstall, open Firefox bookmarks from the main menu, search for `njt` and remove the bookmark. 126 | 127 | **Pro tip 💡** You can use `n` instead of `njt` as a search keyword to avoid typing two extra characters each time. 128 | The command to type in Firefox address bar will become `n [destination]` 🚀 129 | 130 | ### 🟢 Alfred web search 131 | 132 | Want to hop directly from [Alfred launcher](https://www.alfredapp.com/)? 133 | 134 | 1. Open _Preferences_ → _Features_ → _Web Search_ 135 | 1. Click _Add Custom Search_ 136 | 1. Fill in the form: 137 | 138 | | Field | Value | 139 | | ---------- | ---------------------------------------------------- | 140 | | Search URL | `https://njt.vercel.app/jump?from=alfred&to={query}` | 141 | | Title | `Search njt for '{query}'` | 142 | | Keyword | `njt` | 143 | | Icon | drag from | 144 | 145 | 1. Press _Save_ 146 | 147 | Alternatively, copy and open this special Alfred link to get all the above steps done for you: 148 | 149 | ```txt 150 | alfred://customsearch/Search%20njt%20for%20%27%7Bquery%7D%27/njt/utf8/nospace/https%3A%2F%2Fnjt.vercel.app%2Fjump%3Ffrom%3Dalfred%26to%3D%7Bquery%7D 151 | ``` 152 | 153 | **Pro tip 💡** You can use `n` instead of `njt` as a search keyword to avoid typing two extra characters each time. 154 | The command to type in Alfred address bar will become `n [destination]` 🚀 155 | 156 | You can also create variants with your favorite `njt` suffixes to jump to your favorite locations in even fewer characters. 157 | For example, keyword `ng` can be a shortcut to `njt {query} g`. 158 | 159 | ### 🟢 VSCode 160 | 161 | If you use Visual Studio Code, you can add njt to the command palette via [LaunchX](https://marketplace.visualstudio.com/items?itemName=neibla.launchx) extension. 162 | 163 | 1. [Install the extension](vscode:extension/neibla.launchx) 164 | 165 | 1. Open the command palette 166 | 167 | 1. Type `njt` and press Enter 168 | 169 | ![njt in VSCode command palette](assets/vscode-launchx.png) 170 | 171 | 1. Type your search and press Enter again 172 | 173 | **Pro tip 💡** Use `ctrl+alt+n` to bypass the command palette. 174 | 175 | ### 🟢 DuckDuckGo bang 176 | 177 | > DuckDuckGo bang is awaiting approval (please help if you know how to speed up the process). 178 | 179 | If you use [duckduckgo.com](https://duckduckgo.com) as your primary search engine, type `!njt [destination]` in its search field (note the leading exclamation mark). 180 | This trick is possible thanks to DuckDuckGo’s awesome [bang feature](https://duckduckgo.com/bang). 181 | 182 | ### 🟢 Online search field on the `njt`’s mini-website 183 | 184 | Open [njt.vercel.app](https://njt.vercel.app), type your query, press Enter. 185 | This method is a bit slower than the other ones because it involves opening a web page with an input form. 186 | On the plus side, it works everywhere and does not require setup. 187 | 188 | Thanks to [Vercel](https://vercel.com) for hosting [njt.vercel.app](https://njt.vercel.app) 💚 189 | 190 | ### ❓More ways 191 | 192 | Are you a search shortcut guru? 193 | Feel free [to suggest](https://github.com/kachkaev/njt/issues/new?title=New+entry+point+suggestion) another entry point to `njt` and save people’s time around the world! 194 | 195 | ## How `njt` works 196 | 197 | ### Query resolution 198 | 199 | The logic of `njt` is centralized and located within the `njt.vercel.app/jump` endpoint ([source](pages/api/jump.handler.ts)). 200 | 201 | All `njt` interfaces submit user queries to `https://njt.vercel.app/jump?from=UI_ID&to=USER_QUERY`, from which you are redirected to the destination. 202 | 203 | For queries like `njt ` or `njt y`, the redirects are straightforward: `https://www.npmjs.com/package/` or `https://yarnpkg.com/package/`. 204 | 205 | Most other cases involve a look into `package.json` for the latest version of the searched package. 206 | This file is fetched from [www.npmjs.com](https://www.npmjs.com). 207 | It contains the location of the repository, the homepage and some other fields which are used to construct the destination URL. 208 | 209 | ### Privacy 210 | 211 | Official `njt` interfaces and the `njt.vercel.app/jump` endpoint do not store submitted queries. 212 | Since [njt.vercel.app](https://njt.vercel.app) is hosted by Vercel, performance and usage data is logged by the infrastructure (see [Vercel Analytics](https://vercel.com/analytics)). 213 | 214 | When `njt` navigates to `https://njt.vercel.app/jump?from=UI_ID&to=USER_QUERY`, parameter `from=UI_ID` is sent to the endpoint alongside the user query. 215 | The value is currently ignored but it may be used in the future for resolving queries or for analysing the popularity of `njt` interfaces. 216 | 217 | ## Prior art 218 | 219 | Shortcuts to some of the `njt` destinations are built into `npm` cli: 220 | 221 | 📦 [`npm home ` or `npm docs `](https://docs.npmjs.com/cli/docs) 222 | ⭥ 223 | 🐸 `njt h` (homepage) 224 | 225 | --- 226 | 227 | 📦 [`npm issues ` or `npm bugs `](https://docs.npmjs.com/cli/bugs) 228 | ⭥ 229 | 🐸 `njt i` (issues) 230 | 231 | --- 232 | 233 | 📦 [`npm repo `](https://docs.npmjs.com/cli/repo) 234 | ⭥ 235 | 🐸 `njt g` (github, gitlab, etc. repo) 236 | 237 | With `njt`, you have access to more shortcuts in multiple environments, which makes you more productive day to day. 238 | -------------------------------------------------------------------------------- /pages/shared/destinations.ts: -------------------------------------------------------------------------------- 1 | import hostedGitInfo from "hosted-git-info"; 2 | import { LRUCache } from "lru-cache"; 3 | 4 | import type { JsonObject } from "./json-types"; 5 | 6 | export type SuccessfullyResolvedDestination = { 7 | outcome: "success"; 8 | url: string; 9 | }; 10 | 11 | export type UnresolvedDestination = { 12 | outcome: "error"; 13 | error: string; 14 | }; 15 | 16 | export type ResolvedDestination = 17 | | SuccessfullyResolvedDestination 18 | | UnresolvedDestination; 19 | 20 | export type DestinationConfig = { 21 | keywords: string[]; 22 | generateUrl: ( 23 | packageName: string, 24 | ) => Promise | string | undefined; 25 | }; 26 | 27 | const packageMetadataCache = new LRUCache({ 28 | max: 10_000, 29 | ttl: 1000 * 60, 30 | }); 31 | 32 | async function getPackageMetadata(packageName: string): Promise { 33 | if (!packageMetadataCache.has(packageName)) { 34 | const response = await fetch(`https://registry.npmjs.com/${packageName}`); 35 | packageMetadataCache.set( 36 | packageName, 37 | // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- TODO: replace with zod 38 | (await response.json()) as JsonObject, 39 | ); 40 | } 41 | const result = packageMetadataCache.get(packageName); 42 | if (result instanceof Error) { 43 | throw result; 44 | } else if (!result) { 45 | throw new Error(`Unexpected empty cache for ${packageName}`); 46 | } 47 | 48 | return result; 49 | } 50 | 51 | // Inspired by https://github.com/npm/cli/blob/0a0fdff3edca1ea2f0a2d87a0568751f369fd0c4/lib/repo.js#L37-L50 52 | function handleUnknownHostedUrl(url: string): string | undefined { 53 | try { 54 | const idx = url.indexOf("@"); 55 | const fixedUrl = 56 | idx === -1 ? url : url.slice(idx + 1).replace(/:(\D+)/, "/$1"); 57 | const parsedUrl = new URL(fixedUrl); 58 | const protocol = parsedUrl.protocol === "https:" ? "https:" : "http:"; 59 | 60 | return `${protocol}//${parsedUrl.host || ""}${( 61 | parsedUrl.pathname || "" 62 | ).replace(/\.git$/, "")}`; 63 | } catch { 64 | return undefined; 65 | } 66 | } 67 | 68 | async function getRepoUrl( 69 | packageName: string, 70 | { skipDirectoryTrimming }: { skipDirectoryTrimming?: boolean } = {}, 71 | ): Promise { 72 | // Reference implementation: https://github.com/npm/cli/blob/latest/lib/repo.js 73 | const packageMetadata = await getPackageMetadata(packageName); 74 | // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- TODO: replace with zod 75 | const rawUrl = (packageMetadata["repository"] as JsonObject)["url"]; 76 | if (typeof rawUrl !== "string") { 77 | return undefined; 78 | } 79 | const info = hostedGitInfo.fromUrl(rawUrl); 80 | let result = info ? info.browse() : handleUnknownHostedUrl(rawUrl); 81 | 82 | // Some packages (e.g. babel and babel-cli) mistakenly specify repository URL with directory. It needs to be trimmed 83 | if (!skipDirectoryTrimming && result) { 84 | result = result.replace( 85 | /^https:\/\/github\.com\/([^/]+)\/([^/]+).*/i, 86 | "https://github.com/$1/$2", 87 | ); 88 | } 89 | 90 | return result; 91 | } 92 | 93 | function isGitHub(url: string) { 94 | return url.includes("://github.com"); 95 | } 96 | 97 | function isGitLab(url: string) { 98 | return url.includes("://gitlab.com"); 99 | } 100 | 101 | const destinationConfigs: DestinationConfig[] = [ 102 | { 103 | keywords: ["b"], 104 | generateUrl: (packageName) => 105 | `https://bundlephobia.com/result?p=${packageName}`, 106 | }, 107 | { 108 | keywords: ["c"], 109 | generateUrl: async (packageName) => { 110 | const repoUrl = await getRepoUrl(packageName); 111 | 112 | if (!repoUrl) { 113 | return; 114 | } 115 | 116 | const [, githubOwner, githubRepo] = 117 | /^https:\/\/github\.com\/([^/]+)\/([^/]+).*/i.exec(repoUrl) ?? []; 118 | 119 | // Covers GitHub repos 120 | if (githubOwner && githubRepo) { 121 | const apiUrl = `https://api.github.com/repos/${githubOwner}/${githubRepo}/contents`; 122 | 123 | let contents: JsonObject[] = []; 124 | try { 125 | const response = await fetch(apiUrl); 126 | // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- TODO: replace with zod 127 | contents = (await response.json()) as JsonObject[]; 128 | } catch { 129 | // noop 130 | } 131 | 132 | for (const item of contents) { 133 | if ( 134 | typeof item["name"] === "string" && 135 | /^changelog/i.test(item["name"]) && 136 | typeof item["html_url"] === "string" 137 | ) { 138 | return item["html_url"]; 139 | } 140 | } 141 | } 142 | 143 | // Fallback even if was not found above 144 | return `${repoUrl}/blob/HEAD/CHANGELOG.md`; 145 | }, 146 | }, 147 | { 148 | keywords: ["g"], 149 | generateUrl: async (packageName) => { 150 | return await getRepoUrl(packageName, { 151 | skipDirectoryTrimming: true, 152 | }); 153 | }, 154 | }, 155 | { 156 | keywords: ["h", "w", "d"], 157 | generateUrl: async (packageName) => { 158 | // Reference implementation: https://github.com/npm/cli/blob/latest/lib/docs.js 159 | const packageMetadata = await getPackageMetadata(packageName); 160 | 161 | return typeof packageMetadata["homepage"] === "string" 162 | ? packageMetadata["homepage"] 163 | : undefined; 164 | }, 165 | }, 166 | { 167 | keywords: ["i"], 168 | generateUrl: async (packageName) => { 169 | // Reference implementation: https://github.com/npm/cli/blob/latest/lib/bugs.js 170 | const packageMetadata = await getPackageMetadata(packageName); 171 | const bugsField = packageMetadata["bugs"]; 172 | const directUrl = 173 | typeof bugsField === "string" 174 | ? bugsField 175 | : typeof bugsField === "object" && 176 | bugsField && 177 | "url" in bugsField && 178 | typeof bugsField["url"] === "string" 179 | ? bugsField["url"] 180 | : undefined; 181 | if (directUrl) { 182 | return directUrl; 183 | } 184 | const repoUrl = await getRepoUrl(packageName); 185 | 186 | if (repoUrl) { 187 | return `${repoUrl}/issues`; 188 | } 189 | 190 | return repoUrl; 191 | }, 192 | }, 193 | { 194 | keywords: ["n", ""], 195 | generateUrl: (packageName) => `https://npmjs.com/package/${packageName}`, 196 | }, 197 | { 198 | keywords: ["p", "m"], 199 | generateUrl: async (packageName) => { 200 | const repoUrl = await getRepoUrl(packageName); 201 | if (repoUrl && isGitHub(repoUrl)) { 202 | return `${repoUrl}/pulls`; 203 | } else if (repoUrl && isGitLab(repoUrl)) { 204 | return `${repoUrl}/merge_requests`; 205 | } 206 | 207 | return repoUrl; 208 | }, 209 | }, 210 | { 211 | keywords: ["r"], 212 | generateUrl: async (packageName) => { 213 | const repoUrl = await getRepoUrl(packageName); 214 | if (repoUrl && isGitHub(repoUrl)) { 215 | return `${repoUrl}/releases`; 216 | } else if (repoUrl && isGitLab(repoUrl)) { 217 | return `${repoUrl}/-/tags`; 218 | } 219 | 220 | return repoUrl; 221 | }, 222 | }, 223 | { 224 | keywords: ["s"], 225 | generateUrl: async (packageName) => { 226 | const repoUrl = await getRepoUrl(packageName, { 227 | skipDirectoryTrimming: true, 228 | }); 229 | const packageMetadata = await getPackageMetadata(packageName); 230 | // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- TODO: replace with zod 231 | const sourceDirectory = (packageMetadata["repository"] as JsonObject)[ 232 | "directory" 233 | ]; 234 | if (repoUrl && typeof sourceDirectory === "string") { 235 | return `${repoUrl}/tree/master/${sourceDirectory}`; 236 | } 237 | 238 | return repoUrl; 239 | }, 240 | }, 241 | { 242 | keywords: ["t"], 243 | generateUrl: async (packageName) => { 244 | const repoUrl = await getRepoUrl(packageName); 245 | if (repoUrl && isGitHub(repoUrl)) { 246 | return `${repoUrl}/tags`; 247 | } else if (repoUrl && isGitLab(repoUrl)) { 248 | return `${repoUrl}/-/tags`; 249 | } 250 | 251 | return repoUrl; 252 | }, 253 | }, 254 | { 255 | keywords: ["v"], 256 | generateUrl: (packageName) => 257 | `https://npmjs.com/package/${packageName}?activeTab=versions`, 258 | }, 259 | { 260 | keywords: ["u"], 261 | generateUrl: (packageName) => `https://unpkg.com/browse/${packageName}/`, 262 | }, 263 | { 264 | keywords: ["y"], 265 | generateUrl: (packageName) => `https://yarnpkg.com/package/${packageName}`, 266 | }, 267 | { 268 | keywords: ["."], 269 | generateUrl: async (packageName) => { 270 | const repoUrl = await getRepoUrl(packageName); 271 | 272 | if (repoUrl && isGitHub(repoUrl)) { 273 | return repoUrl.replace("://github.com", "://github.dev"); 274 | } 275 | 276 | if (repoUrl && isGitLab(repoUrl)) { 277 | return repoUrl.replace("://gitlab.com", "://gitlab.com/-/ide/project"); 278 | } 279 | 280 | return repoUrl; 281 | }, 282 | }, 283 | ]; 284 | 285 | const destinationConfigByKeyword: Record = {}; 286 | 287 | for (const destinationConfig of destinationConfigs) { 288 | for (const keyword of destinationConfig.keywords) { 289 | if (destinationConfigByKeyword[keyword]) { 290 | throw new Error( 291 | `Keyword ${keyword} is used in more than one destination`, 292 | ); 293 | } 294 | destinationConfigByKeyword[keyword] = destinationConfig; 295 | } 296 | } 297 | 298 | export async function resolveDestination( 299 | rawPackageName: string, 300 | rawDestination = "", 301 | ): Promise { 302 | const packageName = rawPackageName 303 | .toLowerCase() 304 | .replace("https://www.npmjs.com/package/", "") // https://www.npmjs.com/package/@types/react-dom 305 | .replaceAll(/[–—−]/g, "-") // package names with misc dashes (not hyphens) 306 | .replace(/\?activeTab=\w+$/, "") // https://www.npmjs.com/package/@types/react-dom?activeTab=versions 307 | .replace(/\/v\/[\w.-]+/, "") // https://www.npmjs.com/package/@types/react-dom/v/18.0.9 308 | .replace("https://yarnpkg.com/package/", "") // https://yarnpkg.com/package/@types/react-dom 309 | .replace( 310 | // eslint-disable-next-line regexp/no-unused-capturing-group -- TODO: investigate 311 | /^https:\/\/unpkg.com\/browse\/(@?[\w.-]+(\/[\w.-]+)?)@([\w.-]+)\/$/, // https://unpkg.com/browse/@types/react-dom@18.0.9/ 312 | "$1", 313 | ); 314 | 315 | try { 316 | const url = 317 | await destinationConfigByKeyword[ 318 | rawDestination[0]?.toLowerCase() ?? "" 319 | ]?.generateUrl(packageName); 320 | if (!url) { 321 | throw new Error("Unexpected empty URL"); 322 | } 323 | 324 | return { 325 | outcome: "success", 326 | url, 327 | }; 328 | } catch { 329 | return { 330 | outcome: "success", 331 | url: 332 | (await destinationConfigByKeyword[""]?.generateUrl(rawPackageName)) ?? 333 | "", 334 | }; 335 | } 336 | } 337 | --------------------------------------------------------------------------------