|
| 1 | +/** |
| 2 | + * Shortcut suggestions: the sites a user keeps going back to, minus the ones a |
| 3 | + * shortcut already reaches, each with a keyword nothing else answers to. |
| 4 | + * |
| 5 | + * Pure, like `resolve.ts`: no `chrome.*` and no DOM. The pages come from |
| 6 | + * `chrome.history` through `lib/history.ts`, which is the only file that knows |
| 7 | + * where they came from, so this ranking is testable with a plain array. |
| 8 | + * |
| 9 | + * A suggestion only ever becomes a shortcut through the ordinary New shortcut |
| 10 | + * form (`prefillFor` → `#new?prefill=`), so nothing here writes, and every |
| 11 | + * keyword still meets `validateAlias` on the way in. |
| 12 | + */ |
| 13 | + |
| 14 | +import { SEARCH_ENGINES } from './commands'; |
| 15 | +import { buildKeyMap } from './resolve'; |
| 16 | +import type { Command } from './types'; |
| 17 | +import { validateAlias } from './validate'; |
| 18 | + |
| 19 | +export interface VisitedPage { |
| 20 | + url: string; |
| 21 | + title?: string; |
| 22 | + visitCount?: number; |
| 23 | + typedCount?: number; |
| 24 | +} |
| 25 | + |
| 26 | +export interface Suggestion { |
| 27 | + alias: string; |
| 28 | + /** The site's origin: a shortcut to the home page, never to one deep link. */ |
| 29 | + url: string; |
| 30 | + name: string; |
| 31 | + /** Hostname without `www.`: what a dismissal records. */ |
| 32 | + host: string; |
| 33 | + score: number; |
| 34 | +} |
| 35 | + |
| 36 | +/** Typing an address is the habit a keyword replaces, so it counts triple. */ |
| 37 | +const TYPED_WEIGHT = 3; |
| 38 | +/** Below this a site was visited, not returned to. */ |
| 39 | +const MIN_SCORE = 5; |
| 40 | + |
| 41 | +export function suggestShortcuts( |
| 42 | + pages: VisitedPage[], |
| 43 | + commands: Command[], |
| 44 | + dismissed: string[], |
| 45 | + limit = 5, |
| 46 | +): Suggestion[] { |
| 47 | + const skip = new Set(dismissed.map((host) => host.toLowerCase())); |
| 48 | + for (const engine of SEARCH_ENGINES) skip.add(bareHost(engine.host)); |
| 49 | + for (const cmd of commands) { |
| 50 | + for (const url of [cmd.url, cmd.searchUrl]) { |
| 51 | + const host = hostOf(url ?? ''); |
| 52 | + if (host) skip.add(host); |
| 53 | + } |
| 54 | + } |
| 55 | + |
| 56 | + const sites = new Map<string, { score: number; origin: string; title: string; best: number }>(); |
| 57 | + for (const page of pages) { |
| 58 | + let url: URL; |
| 59 | + try { |
| 60 | + url = new URL(page.url); |
| 61 | + } catch { |
| 62 | + continue; |
| 63 | + } |
| 64 | + if (url.protocol !== 'https:' && url.protocol !== 'http:') continue; |
| 65 | + const host = bareHost(url.hostname); |
| 66 | + if (skip.has(host) || !isPublicHost(host)) continue; |
| 67 | + const score = (page.visitCount ?? 0) + TYPED_WEIGHT * (page.typedCount ?? 0); |
| 68 | + const site = sites.get(host) ?? { score: 0, origin: `${url.origin}/`, title: '', best: -1 }; |
| 69 | + site.score += score; |
| 70 | + // The name comes off the most visited page of the site, which is usually |
| 71 | + // the one titled after the site rather than after one document on it. |
| 72 | + if (score > site.best) { |
| 73 | + site.best = score; |
| 74 | + site.title = page.title ?? ''; |
| 75 | + } |
| 76 | + sites.set(host, site); |
| 77 | + } |
| 78 | + |
| 79 | + const taken = new Set(buildKeyMap(commands).keys()); |
| 80 | + const out: Suggestion[] = []; |
| 81 | + const ranked = [...sites].filter(([, s]) => s.score >= MIN_SCORE); |
| 82 | + ranked.sort((a, b) => b[1].score - a[1].score || a[0].localeCompare(b[0])); |
| 83 | + for (const [host, site] of ranked) { |
| 84 | + if (out.length >= limit) break; |
| 85 | + const alias = pickAlias(host, taken); |
| 86 | + if (!alias) continue; |
| 87 | + taken.add(alias); |
| 88 | + out.push({ alias, url: site.origin, name: siteName(site.title, host), host, score: site.score }); |
| 89 | + } |
| 90 | + return out; |
| 91 | +} |
| 92 | + |
| 93 | +/** The `#new?prefill=` text `parsePrefill` reads back: keyword, URL, name. */ |
| 94 | +export function prefillFor(s: Suggestion): string { |
| 95 | + return `${s.alias} ${s.url} ${s.name}`; |
| 96 | +} |
| 97 | + |
| 98 | +function hostOf(url: string): string { |
| 99 | + try { |
| 100 | + return bareHost(new URL(url).hostname); |
| 101 | + } catch { |
| 102 | + return ''; |
| 103 | + } |
| 104 | +} |
| 105 | + |
| 106 | +function bareHost(host: string): string { |
| 107 | + return host.toLowerCase().replace(/^www\./, ''); |
| 108 | +} |
| 109 | + |
| 110 | +function isPublicHost(host: string): boolean { |
| 111 | + if (!host.includes('.') || host.endsWith('.local') || host.endsWith('.localhost')) return false; |
| 112 | + // An IPv4 address or a bracketed IPv6 one names a machine, not a site. |
| 113 | + return !/^[\d.]+$/.test(host) && !host.startsWith('['); |
| 114 | +} |
| 115 | + |
| 116 | +/** |
| 117 | + * The label a person would call the site by: `linear.app` → `linear`, |
| 118 | + * `mail.proton.me` → `proton`, `bbc.co.uk` → `bbc`. Then the subdomain |
| 119 | + * (`docs.google.com` → `docs`), then a prefix, then a numbered one. |
| 120 | + */ |
| 121 | +function pickAlias(host: string, taken: Set<string>): string { |
| 122 | + const main = mainLabel(host); |
| 123 | + const labels = host.split('.'); |
| 124 | + const candidates = [main, labels[0]!, main.slice(0, 2), main.slice(0, 3)]; |
| 125 | + for (let n = 2; n < 10; n++) candidates.push(`${main}${n}`); |
| 126 | + for (const candidate of candidates) { |
| 127 | + const check = validateAlias(candidate.replace(/[^a-z0-9-]/g, '')); |
| 128 | + if (check.ok && check.alias.length > 1 && !taken.has(check.alias)) return check.alias; |
| 129 | + } |
| 130 | + return ''; |
| 131 | +} |
| 132 | + |
| 133 | +function mainLabel(host: string): string { |
| 134 | + const labels = host.split('.'); |
| 135 | + // ponytail: no public-suffix list. Two short trailing labels (co.uk, com.au) |
| 136 | + // are read as one suffix; a rarer shape just gets a less obvious keyword. |
| 137 | + const suffix = |
| 138 | + labels.length > 2 && labels.at(-1)!.length <= 3 && labels.at(-2)!.length <= 3 ? 2 : 1; |
| 139 | + return labels[labels.length - suffix - 1] ?? labels[0]!; |
| 140 | +} |
| 141 | + |
| 142 | +/** `Linear – Plan and build products` → `Linear`; no title → `Linear` off the host. */ |
| 143 | +function siteName(title: string, host: string): string { |
| 144 | + const lead = title.split(/\s+[|\-–—·:]\s+/)[0]?.trim() ?? ''; |
| 145 | + if (lead && lead.length <= 40) return lead; |
| 146 | + const label = mainLabel(host); |
| 147 | + return label.charAt(0).toUpperCase() + label.slice(1); |
| 148 | +} |
0 commit comments