g1t/apps/web/app/lib/search.ts
| 1 | /** |
| 2 | * Building search queries and addresses on the site: the filters on the |
| 3 | * search page change the query people can see and edit, never something |
| 4 | * hidden, so a search is always one shareable address. |
| 5 | */ |
| 6 | import type { SearchType } from "@g1t/contracts"; |
| 7 | |
| 8 | /** The qualifiers a search understands, as the help lists them. */ |
| 9 | export const QUALIFIERS: { example: string; means: string }[] = [ |
| 10 | { example: "repo:acme/web", means: "Only in one repository" }, |
| 11 | { example: "org:acme", means: "Only in one workspace (also workspace:acme)" }, |
| 12 | { example: "language:rust", means: "Code in a language, or repositories mostly in it" }, |
| 13 | { example: "path:src/", means: "Code under a path; path:*.rs for a pattern" }, |
| 14 | { example: "is:issue is:pr", means: "Only issues, or only pull requests" }, |
| 15 | { example: "is:open is:closed is:merged", means: "Issues and pull requests by state" }, |
| 16 | { example: "author:ana", means: "Opened by someone" }, |
| 17 | { example: "label:bug", means: "Issues and pull requests with a label" }, |
| 18 | { example: "\"exact phrase\"", means: "These words together, in this order" }, |
| 19 | { example: "-word -label:wontfix", means: "Leave out what has it" }, |
| 20 | ]; |
| 21 | |
| 22 | /** Languages offered by the language filter; any other can be typed. */ |
| 23 | export const LANGUAGES = [ |
| 24 | "rust", |
| 25 | "typescript", |
| 26 | "tsx", |
| 27 | "javascript", |
| 28 | "python", |
| 29 | "go", |
| 30 | "ruby", |
| 31 | "java", |
| 32 | "kotlin", |
| 33 | "swift", |
| 34 | "c", |
| 35 | "c++", |
| 36 | "c#", |
| 37 | "php", |
| 38 | "elixir", |
| 39 | "shell", |
| 40 | "sql", |
| 41 | "html", |
| 42 | "css", |
| 43 | "markdown", |
| 44 | "yaml", |
| 45 | "toml", |
| 46 | "json", |
| 47 | ]; |
| 48 | |
| 49 | /** What each tab is called. */ |
| 50 | export const TYPE_LABELS: Record<SearchType, string> = { |
| 51 | repositories: "Repositories", |
| 52 | code: "Code", |
| 53 | issues: "Issues", |
| 54 | pulls: "Pull requests", |
| 55 | people: "People", |
| 56 | }; |
| 57 | |
| 58 | /** Splits a query into its tokens, keeping quoted runs whole. */ |
| 59 | export function tokens(query: string): string[] { |
| 60 | return query.match(/(?:[^\s"]+|"[^"]*"?)+/g) ?? []; |
| 61 | } |
| 62 | |
| 63 | /** The value of a qualifier in a query, the first if it is given more than once. */ |
| 64 | export function qualifier(query: string, key: string): string | null { |
| 65 | for (const token of tokens(query)) { |
| 66 | const [name, ...rest] = token.split(":"); |
| 67 | if (name?.toLowerCase() === key && rest.length) return rest.join(":").replace(/^"|"$/g, "") || null; |
| 68 | } |
| 69 | return null; |
| 70 | } |
| 71 | |
| 72 | /** |
| 73 | * The query with `key:value` in place of any `key:` it had; with `value` |
| 74 | * null, without it. `aliases` are other names for the same qualifier |
| 75 | * (`org` and `workspace`), removed as well. Kept in the order written. |
| 76 | */ |
| 77 | export function withQualifier(query: string, key: string, value: string | null, aliases: string[] = []): string { |
| 78 | const names = new Set([key, ...aliases]); |
| 79 | const kept = tokens(query).filter((token) => { |
| 80 | const name = token.split(":")[0]?.toLowerCase() ?? ""; |
| 81 | return !(token.includes(":") && names.has(name)); |
| 82 | }); |
| 83 | if (value) kept.push(`${key}:${/\s/.test(value) ? `"${value}"` : value}`); |
| 84 | return kept.join(" "); |
| 85 | } |
| 86 | |
| 87 | /** `is:` values of one family set to `value`, the family's others removed. */ |
| 88 | export function withIs(query: string, family: string[], value: string | null): string { |
| 89 | const kept = tokens(query).filter((token) => { |
| 90 | const [name, rest] = token.split(":"); |
| 91 | return !(name?.toLowerCase() === "is" && family.includes((rest ?? "").toLowerCase())); |
| 92 | }); |
| 93 | if (value) kept.push(`is:${value}`); |
| 94 | return kept.join(" "); |
| 95 | } |
| 96 | |
| 97 | /** The `is:` value of a family the query has, if any. */ |
| 98 | export function isValue(query: string, family: string[]): string | null { |
| 99 | for (const token of tokens(query)) { |
| 100 | const [name, rest] = token.split(":"); |
| 101 | if (name?.toLowerCase() === "is" && family.includes((rest ?? "").toLowerCase())) return rest!.toLowerCase(); |
| 102 | } |
| 103 | return null; |
| 104 | } |
| 105 | |
| 106 | /** The address of a search. */ |
| 107 | export function searchHref(query: string, type?: SearchType | null, page?: number): string { |
| 108 | const params = new URLSearchParams(); |
| 109 | params.set("q", query.trim()); |
| 110 | if (type) params.set("type", type); |
| 111 | if (page && page > 1) params.set("page", String(page)); |
| 112 | return `/search?${params.toString()}`; |
| 113 | } |
| 114 | |
| 115 | /** A count as a tab shows it: up to the cap, then "1k+". */ |
| 116 | export function shortCount(count: number, cap = 1000): string { |
| 117 | if (count > cap - 1) return "1k+"; |
| 118 | return String(count); |
| 119 | } |