import type { MatchResult, MatchedSkill, RankedItem } from "../shared/types"; import { formatRange, type Corpus } from "./db"; /** Skill names that collide with common English words — match these case-sensitively. */ const CASE_SENSITIVE = new Set(["Go", "R", "C", "Swift", "Dart", "Unity", "Agile", "Matrix"]); /** Hand-curated aliases beyond the automatic parenthetical/slash splits. */ const EXTRA_ALIASES: Record = { Go: ["Golang"], Kubernetes: ["K8s"], "Google Cloud": ["GCP"], "Test-Driven Development": ["TDD"], "Continuous Integration": ["CI/CD", "CI"], "React.js": ["ReactJS"], "Protocol Buffers": ["Protobufs", "Protobuf"], Protobufs: ["Protocol Buffers", "Protobuf"], "Matrix Protocol": ["Matrix"], "Decentralized Identifiers (DIDs)": ["DID"], "Cloudflare Workers": ["Cloudflare"], PostgreSQL: ["Postgres"], }; /** "WebAssembly (WASM)" -> ["WebAssembly (WASM)", "WebAssembly", "WASM"]; "HTML/CSS" -> [..., "HTML", "CSS"]. */ function aliasesFor(name: string): string[] { const out = [name]; const paren = /^(.*?)\s*\((.*?)\)$/.exec(name); if (paren) { out.push(paren[1].trim()); for (const part of paren[2].split(/\s*(?:[&,/·]|and)\s*/)) if (part.trim()) out.push(part.trim()); } else if (name.includes("/") && name !== "CI/CD") { for (const part of name.split("/")) if (part.trim()) out.push(part.trim()); } for (const extra of EXTRA_ALIASES[name] ?? []) out.push(extra); return [...new Set(out)]; } function countHits(text: string, name: string): number { let hits = 0; for (const alias of aliasesFor(name)) { if (alias.length < 2 && !CASE_SENSITIVE.has(alias)) continue; const escaped = alias.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const flags = CASE_SENSITIVE.has(alias) ? "g" : "gi"; const re = new RegExp(`(?, ): RankedItem { const matchedSkills = itemSkills.filter((s) => hitsByName.has(s)); let bulletHits = 0; for (const bullet of bullets) { const lower = bullet.toLowerCase(); for (const s of matchedSkills) { const stem = s.replace(/\s*\(.*\)$/, "").toLowerCase(); if (lower.includes(stem)) bulletHits++; } } const score = matchedSkills.length * 3 + Math.min(bulletHits, 6); return { type, slug, title, company, dates, score, matchedSkills, bullets }; } /** Deterministic skill-keyword match of the corpus against extracted job text. */ export function matchCorpus(corpus: Corpus, jdText: string): MatchResult { const categories = new Map(); for (const s of corpus.skills) categories.set(s.name, s.category); const universe = new Set(corpus.skills.map((s) => s.name)); for (const item of corpus.experience) for (const s of item.skills) universe.add(s); for (const item of corpus.projects) for (const s of item.skills) universe.add(s); const hitsByName = new Map(); for (const name of universe) { const hits = countHits(jdText, name); if (hits > 0) hitsByName.set(name, hits); } const skills: MatchedSkill[] = [...hitsByName.entries()] .map(([name, hits]) => ({ name, hits, category: categories.get(name) ?? ("domain" as const) })) .sort((a, b) => b.hits - a.hits); const items: RankedItem[] = []; for (const e of corpus.experience) { items.push( rankItem("experience", e.slug, e.title, e.company, formatRange(e.start_date, e.end_date, "month"), e.bullets, e.skills, hitsByName), ); } for (const p of corpus.projects) { items.push( rankItem("project", p.slug, p.title, undefined, formatRange(p.start_date, p.end_date, "year"), p.bullets, p.skills, hitsByName), ); } items.sort((a, b) => b.score - a.score); return { skills, items }; }