├── .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 |
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 | 
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 |
--------------------------------------------------------------------------------