diff --git a/README.md b/README.md index 6c97420..2199096 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,26 @@ Terminal-style personal website with command-driven navigation. ## Editable Content - Resume path: `public/data/site-config.json` +- GitHub owner for live repository browsing: `public/data/site-config.json` - Project/domain list: `public/data/projects.json` - Canonical resume file location: `public/resume.pdf` +## Terminal Repository Browser + +The site can surface Jakob's public GitHub repositories directly from the +terminal: + +```bash +repo --list +repo --lang C +repo jakobs-ls-remake +repos systems +``` + +Curated project metadata from `public/data/projects.json` is merged with the +GitHub public repositories API at runtime, so deployed demos and source links +stay discoverable even if the live API is temporarily unavailable. + ## Build ```bash diff --git a/public/data/site-config.json b/public/data/site-config.json index addb720..ebb75c3 100644 --- a/public/data/site-config.json +++ b/public/data/site-config.json @@ -1,3 +1,4 @@ { - "resumeUrl": "https://resume.jjalangtry.com" + "resumeUrl": "https://resume.jjalangtry.com", + "githubOwner": "jjalangtry" } diff --git a/src/lib/terminal/index.js b/src/lib/terminal/index.js index 446b4ae..c0a9ac6 100644 --- a/src/lib/terminal/index.js +++ b/src/lib/terminal/index.js @@ -49,6 +49,8 @@ export const COMMAND_LIST = [ "exit", ]; +export const DEFAULT_GITHUB_OWNER = "jjalangtry"; + export function buildProjectsListOutput(projects) { if (!Array.isArray(projects) || projects.length === 0) { return "No projects are configured yet."; @@ -513,11 +515,362 @@ export function buildReposOutput(projectGroups) { } lines.push("├" + "─".repeat(W) + "┤"); - lines.push(row(" Tip: 'projects' for full pane · github.com/JJALANGTRY")); + lines.push(row(" Tip: 'repo --lang C' · 'repo [name]' opens source")); lines.push("└" + "─".repeat(W) + "┘"); return lines.join("\n"); } +function slugifyRepositoryName(name) { + return String(name || "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +function stripGitSuffix(url) { + return String(url || "").replace(/\.git$/i, ""); +} + +function normalizeRepositoryUrl(url) { + return stripGitSuffix(url) + .trim() + .replace(/^git\+/, "") + .replace(/\/$/, "") + .toLowerCase(); +} + +function isGithubUrl(url) { + return /^https?:\/\/github\.com\//i.test(String(url || "")); +} + +function formatRepoDate(dateString) { + if (!dateString) return ""; + const date = new Date(dateString); + if (Number.isNaN(date.getTime())) return ""; + return date.toISOString().slice(0, 10); +} + +function normalizeCuratedRepository(project, section) { + if (!project || !project.name) return null; + + const sourceUrl = + project.repo || (isGithubUrl(project.url) ? project.url : ""); + const homepage = + project.homepage || + project.demo || + (sourceUrl && project.url && !isGithubUrl(project.url) ? project.url : ""); + const fallbackUrl = sourceUrl || project.url || ""; + + if (!fallbackUrl) return null; + + return { + name: project.name, + slug: slugifyRepositoryName(project.name), + url: stripGitSuffix(fallbackUrl), + homepage: homepage || "", + description: project.description || "", + language: project.language || "", + section, + source: "curated", + isFork: Boolean(project.isFork), + stars: Number(project.stars || 0), + updatedAt: project.updatedAt || project.updated_at || "", + topics: Array.isArray(project.topics) ? project.topics : [], + }; +} + +function normalizeGithubApiRepository(repo) { + if (!repo || !repo.name || !repo.html_url) return null; + + return { + name: repo.name, + slug: slugifyRepositoryName(repo.name), + url: stripGitSuffix(repo.html_url), + homepage: repo.homepage || "", + description: repo.description || "", + language: repo.language || "", + section: repo.fork ? "forks" : "github", + source: "github-api", + isFork: Boolean(repo.fork), + stars: Number(repo.stargazers_count || 0), + updatedAt: repo.pushed_at || repo.updated_at || "", + topics: Array.isArray(repo.topics) ? repo.topics : [], + }; +} + +function mergeRepository(existing, incoming) { + if (!existing) return incoming; + const topics = Array.from( + new Set([...(existing.topics || []), ...(incoming.topics || [])]), + ); + + return { + ...existing, + description: existing.description || incoming.description || "", + language: existing.language || incoming.language || "", + homepage: existing.homepage || incoming.homepage || "", + isFork: existing.isFork || incoming.isFork, + stars: Math.max(existing.stars || 0, incoming.stars || 0), + updatedAt: incoming.updatedAt || existing.updatedAt || "", + topics, + source: + existing.source === incoming.source + ? existing.source + : `${existing.source}+${incoming.source}`, + }; +} + +export function buildRepositoryCatalog(projectGroups, apiRepos = []) { + const groups = projectGroups || {}; + const entries = []; + + ["featured", "contributions", "github"].forEach((section) => { + (groups[section] || []).forEach((project) => { + const repo = normalizeCuratedRepository(project, section); + if (repo) entries.push(repo); + }); + }); + + if (Array.isArray(apiRepos)) { + apiRepos.forEach((repo) => { + const normalized = normalizeGithubApiRepository(repo); + if (normalized) entries.push(normalized); + }); + } + + const byKey = new Map(); + entries.forEach((repo) => { + const urlKey = normalizeRepositoryUrl(repo.url); + const key = urlKey || repo.slug; + byKey.set(key, mergeRepository(byKey.get(key), repo)); + }); + + const sectionOrder = { + featured: 0, + github: 1, + contributions: 2, + forks: 3, + }; + + return Array.from(byKey.values()).sort((a, b) => { + const sectionDiff = + (sectionOrder[a.section] ?? 9) - (sectionOrder[b.section] ?? 9); + if (sectionDiff !== 0) return sectionDiff; + + const aUpdated = a.updatedAt ? new Date(a.updatedAt).getTime() : 0; + const bUpdated = b.updatedAt ? new Date(b.updatedAt).getTime() : 0; + if (aUpdated !== bUpdated) return bUpdated - aUpdated; + + return a.name.localeCompare(b.name); + }); +} + +function tokenizeRepositoryArgs(argsString) { + const tokens = []; + let current = ""; + let quote = null; + + for (const ch of String(argsString || "")) { + if ((ch === '"' || ch === "'") && !quote) { + quote = ch; + } else if (ch === quote) { + quote = null; + } else if (/\s/.test(ch) && !quote) { + if (current) { + tokens.push(current); + current = ""; + } + } else { + current += ch; + } + } + + if (current) tokens.push(current); + return tokens; +} + +export function parseRepositoryCommandArgs(argsString) { + const result = { + query: "", + language: "", + limit: 12, + list: false, + refresh: false, + }; + const queryParts = []; + const tokens = tokenizeRepositoryArgs(argsString); + + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + const lower = token.toLowerCase(); + + if (lower === "--list" || lower === "-a") { + result.list = true; + result.limit = Infinity; + } else if (lower === "--all") { + result.limit = Infinity; + } else if (lower === "--refresh") { + result.refresh = true; + } else if (lower === "--lang" || lower === "--language" || lower === "-l") { + result.language = tokens[i + 1] || ""; + i++; + } else if (lower.startsWith("--lang=")) { + result.language = token.slice(7); + } else if (lower.startsWith("--language=")) { + result.language = token.slice(11); + } else if (lower === "--limit" || lower === "-n") { + const parsed = Number.parseInt(tokens[i + 1], 10); + if (Number.isFinite(parsed) && parsed > 0) result.limit = parsed; + i++; + } else if (lower.startsWith("--limit=")) { + const parsed = Number.parseInt(token.slice(8), 10); + if (Number.isFinite(parsed) && parsed > 0) result.limit = parsed; + } else if (lower === "--search" || lower === "-s") { + if (tokens[i + 1]) queryParts.push(tokens[i + 1]); + i++; + } else if (lower.startsWith("--search=")) { + queryParts.push(token.slice(9)); + } else { + queryParts.push(token); + } + } + + result.query = queryParts.join(" ").trim(); + return result; +} + +export function filterRepositoryCatalog(catalog, options = {}) { + if (!Array.isArray(catalog)) return []; + const query = String(options.query || "") + .trim() + .toLowerCase(); + const querySlug = slugifyRepositoryName(query); + const language = String(options.language || "") + .trim() + .toLowerCase(); + + return catalog.filter((repo) => { + if (!repo) return false; + if (language && String(repo.language || "").toLowerCase() !== language) { + return false; + } + if (!query) return true; + + const searchable = [ + repo.name, + repo.slug, + repo.description, + repo.language, + repo.url, + repo.homepage, + ...(repo.topics || []), + ] + .filter(Boolean) + .join(" ") + .toLowerCase(); + + return searchable.includes(query) || repo.slug.includes(querySlug); + }); +} + +export function findRepositoryByQuery(query, catalog) { + const normalized = String(query || "") + .trim() + .toLowerCase(); + const slug = slugifyRepositoryName(normalized); + if (!normalized || !Array.isArray(catalog)) return null; + + return ( + catalog.find((repo) => { + if (!repo) return false; + const urlName = String(repo.url || "") + .replace(/\/$/, "") + .split("/") + .pop() + .toLowerCase(); + return ( + String(repo.name || "").toLowerCase() === normalized || + repo.slug === slug || + urlName === normalized || + slugifyRepositoryName(urlName) === slug + ); + }) || null + ); +} + +export function buildRepositoryBrowserOutput(catalog, options = {}) { + const repos = Array.isArray(catalog) ? catalog : []; + const matches = filterRepositoryCatalog(repos, options); + const limit = Number.isFinite(options.limit) ? options.limit : matches.length; + const visible = matches.slice(0, limit); + const filters = []; + + if (options.language) filters.push(`language=${options.language}`); + if (options.query) filters.push(`query="${options.query}"`); + + if (matches.length === 0) { + const suffix = filters.length ? ` for ${filters.join(", ")}` : ""; + return `No repositories found${suffix}. Try 'repo --list' or 'repo --lang C'.`; + } + + const lines = []; + lines.push( + `Repository catalog (${matches.length}${matches.length !== repos.length ? ` of ${repos.length}` : ""})`, + ); + if (filters.length) lines.push(`Filters: ${filters.join(" ")}`); + lines.push(""); + lines.push(" NAME LANG UPDATED COMMAND"); + lines.push( + " --------------------------- --------- ---------- ----------------", + ); + + visible.forEach((repo) => { + const name = String(repo.name || "untitled") + .slice(0, 27) + .padEnd(27); + const language = String(repo.language || "n/a") + .slice(0, 9) + .padEnd(9); + const updated = (formatRepoDate(repo.updatedAt) || repo.section || "n/a") + .slice(0, 10) + .padEnd(10); + lines.push(` ${name} ${language} ${updated} repo ${repo.slug}`); + }); + + if (visible.length < matches.length) { + lines.push(""); + lines.push( + ` Showing ${visible.length} of ${matches.length}. Use 'repo --list' or 'repo --limit ${matches.length}' for more.`, + ); + } + + lines.push(""); + lines.push("Open a repo with: repo [name-or-slug]"); + lines.push("Filter examples: repo --lang C | repo --search systems"); + return lines.join("\n"); +} + +export function buildRepositoryDetailOutput(repo) { + if (!repo) return null; + const lines = []; + lines.push(`${repo.name}`); + lines.push("=".repeat(String(repo.name || "").length)); + if (repo.description) lines.push(repo.description); + lines.push(""); + lines.push(`Language: ${repo.language || "n/a"}`); + if (repo.updatedAt) lines.push(`Updated: ${formatRepoDate(repo.updatedAt)}`); + if (repo.stars) lines.push(`Stars: ${repo.stars}`); + if (repo.topics && repo.topics.length > 0) { + lines.push(`Topics: ${repo.topics.join(", ")}`); + } + lines.push(`Source: ${repo.url}`); + if (repo.homepage) lines.push(`Demo: ${repo.homepage}`); + lines.push(""); + lines.push(`Command: repo ${repo.slug}`); + return lines.join("\n"); +} + /** * Builds a terminal-style ASCII contribution chart from GitHub contributions API data. * @param {Array<{date: string, count: number, level: number}>} contributions - Array of {date, count, level} diff --git a/src/scripts/terminal.js b/src/scripts/terminal.js index 034d81c..eb5f552 100644 --- a/src/scripts/terminal.js +++ b/src/scripts/terminal.js @@ -14,7 +14,13 @@ import { buildContactOutput, buildStatsOutput, buildReposOutput, + buildRepositoryCatalog, + buildRepositoryBrowserOutput, + buildRepositoryDetailOutput, + findRepositoryByQuery, + parseRepositoryCommandArgs, buildContributionChartAscii, + DEFAULT_GITHUB_OWNER, getRandomFortune, flipText, safeCalc, @@ -161,6 +167,7 @@ let isMobileDevice = false; // Flag to track if we're on a mobile device const DEFAULT_SITE_CONFIG = { resumeUrl: "https://resume.jjalangtry.com", + githubOwner: DEFAULT_GITHUB_OWNER, }; const DEFAULT_PROJECTS = [ @@ -201,6 +208,8 @@ function loadTerminalDataFromDOM() { } const terminalData = loadTerminalDataFromDOM(); +let repositoryCatalogCache = null; +let repositoryCatalogLoad = null; // Load terminal logic functions. In a real module setup we would import, but // for a vanilla script we need to ensure the logic exists here if not bundled. @@ -763,6 +772,123 @@ function findProjectByCommand(command) { }); } +function getGithubOwner() { + return terminalData.siteConfig?.githubOwner || DEFAULT_GITHUB_OWNER; +} + +function getCuratedRepositoryCatalog() { + return buildRepositoryCatalog(terminalData.projectGroups); +} + +async function loadRepositoryCatalog(options = {}) { + if (repositoryCatalogCache && !options.refresh) { + return { catalog: repositoryCatalogCache, error: null, live: true }; + } + + if (!repositoryCatalogLoad || options.refresh) { + repositoryCatalogLoad = (async () => { + const owner = getGithubOwner(); + try { + const apiRepos = await fetchGithubRepositories(owner); + repositoryCatalogCache = buildRepositoryCatalog( + terminalData.projectGroups, + apiRepos, + ); + return { catalog: repositoryCatalogCache, error: null, live: true }; + } catch (error) { + repositoryCatalogCache = getCuratedRepositoryCatalog(); + return { catalog: repositoryCatalogCache, error, live: false }; + } finally { + repositoryCatalogLoad = null; + } + })(); + } + + return repositoryCatalogLoad; +} + +async function fetchGithubRepositories(owner) { + const repos = []; + const encodedOwner = encodeURIComponent(owner); + const perPage = 100; + + for (let page = 1; page <= 5; page++) { + const apiUrl = `https://api.github.com/users/${encodedOwner}/repos?per_page=${perPage}&page=${page}&sort=updated&type=owner`; + const response = await fetch(apiUrl, { + headers: { Accept: "application/vnd.github+json" }, + }); + + if (!response.ok) { + throw new Error(`GitHub API returned ${response.status}`); + } + + const pageRepos = await response.json(); + if (!Array.isArray(pageRepos)) { + throw new Error("GitHub API returned an unexpected payload"); + } + + repos.push(...pageRepos); + if (pageRepos.length < perPage) break; + } + + return repos; +} + +function withRepositoryCatalogNote(output, result) { + if (!result.error) return output; + return `${output}\n\nNote: live GitHub sync failed (${result.error.message}); showing curated repositories.`; +} + +async function displayRepositoryBrowser(argsString = "") { + const args = parseRepositoryCommandArgs(argsString); + appendOutput("Loading repository catalog...", "info-text"); + + const result = await loadRepositoryCatalog({ refresh: args.refresh }); + const output = buildRepositoryBrowserOutput(result.catalog, { + query: args.query, + language: args.language, + limit: args.limit, + }); + appendOutput(withRepositoryCatalogNote(output, result), "info-text"); +} + +async function displayOrOpenRepository(argsString = "") { + const args = parseRepositoryCommandArgs(argsString); + const hasFilter = Boolean(args.language || args.list || args.query); + + if (!hasFilter) { + appendOutput("Opening GitHub profile..."); + window.open(`https://github.com/${getGithubOwner()}`, "_blank"); + return; + } + + appendOutput("Loading repository catalog...", "info-text"); + const result = await loadRepositoryCatalog({ refresh: args.refresh }); + + if (args.query && !args.language) { + const exactMatch = findRepositoryByQuery(args.query, result.catalog); + if (exactMatch) { + appendOutput( + withRepositoryCatalogNote( + buildRepositoryDetailOutput(exactMatch), + result, + ), + "info-text", + ); + appendOutput(`Opening ${exactMatch.name} source...`); + window.open(exactMatch.url, "_blank"); + return; + } + } + + const output = buildRepositoryBrowserOutput(result.catalog, { + query: args.query, + language: args.language, + limit: args.limit, + }); + appendOutput(withRepositoryCatalogNote(output, result), "info-text"); +} + function openResume() { const resumeUrl = terminalData.siteConfig?.resumeUrl || DEFAULT_SITE_CONFIG.resumeUrl; @@ -799,12 +925,12 @@ function executeCommand(command, options = {}) { contact contact info date current date/time email email jakob echo print text github github profile flip upside-down text - repos github repos fortune random quote - resume view resume grep regex search/pipe - blog read blog posts matrix digital rain - projects projects pane qr QR code generator - close close pane weather weather forecast - snake play snake + repo open repo by name fortune random quote + repos github repos grep regex search/pipe + resume view resume matrix digital rain + blog read blog posts qr QR code generator + projects projects pane weather weather forecast + close close pane snake play snake SYSTEM AUTH & CONTENT (login required) ──────────────────────────────── ──────────────────────────────── @@ -1033,9 +1159,7 @@ Currently seeking opportunities in software engineering.`, } break; case "repo": - // Alias for github command - appendOutput("Opening GitHub profile..."); - window.open("https://github.com/JJALANGTRY", "_blank"); + displayOrOpenRepository(); break; case "converter": appendOutput("Opening Link Converter..."); @@ -1094,6 +1218,12 @@ Currently seeking opportunities in software engineering.`, executeCurlCommand(args); break; + } else if (normalizedCommand.startsWith("repos ")) { + displayRepositoryBrowser(command.substring(6).trim()); + break; + } else if (normalizedCommand.startsWith("repo ")) { + displayOrOpenRepository(command.substring(5).trim()); + break; } else if (normalizedCommand.startsWith("qr ")) { const target = command.substring(3).trim(); if (!target) { @@ -2962,7 +3092,7 @@ function getHelpDetails() { usage: "github", examples: ["github", "repo"], notes: - 'The command "repo" is an alias for "github" and performs the same action.', + "Use 'repo [name]' to open a specific source repository from the terminal.", }, grep: { desc: "Search with regex patterns, wildcards, and flags.", @@ -3032,10 +3162,10 @@ function getHelpDetails() { }, repos: { desc: "Display GitHub repositories and contributions in a terminal-style ASCII view.", - usage: "repos", - examples: ["repos"], + usage: "repos [filter]", + examples: ["repos", "repos systems", "repos --lang C"], notes: - "Shows deployed projects, contributions to other repos, and more. Run 'projects' for the interactive pane.", + "Without filters, shows the curated overview and contribution chart. With filters, syncs public GitHub repos and renders a searchable catalog.", }, close: { desc: "Close the tmux-style projects split pane.", @@ -3045,10 +3175,16 @@ function getHelpDetails() { "Also available via 'exit' or the keyboard shortcut Ctrl+B then q.", }, repo: { - desc: 'Alias for the "github" command. Opens Jakob\'s GitHub profile.', - usage: "repo", - examples: ["repo", "github"], - notes: "This is just an alternative way to access the github command.", + desc: "Open or search Jakob's public GitHub repositories from the terminal.", + usage: "repo [name-or-slug] [--lang language] [--list]", + examples: [ + "repo", + "repo jakobs-ls-remake", + "repo --lang C", + "repo --search systems", + ], + notes: + "No arguments opens the GitHub profile. Exact repo names open source directly; filters render a browsable catalog.", }, resume: { desc: "View Jakob's resume in a new browser tab.", @@ -3968,11 +4104,14 @@ function executePipeline(input) { const firstCmd = filtered[0].trim().toLowerCase(); if ( firstCmd === "repos" || + firstCmd === "repo" || + firstCmd.startsWith("repos ") || + firstCmd.startsWith("repo ") || firstCmd.startsWith("weather ") || firstCmd.startsWith("curl ") ) { appendOutput( - "Pipe is not supported for async commands (repos, weather, curl).", + "Pipe is not supported for async commands (repo, repos, weather, curl).", "error-text", ); return; diff --git a/tests/unit/terminal.test.js b/tests/unit/terminal.test.js index b19b10f..e2974a2 100644 --- a/tests/unit/terminal.test.js +++ b/tests/unit/terminal.test.js @@ -25,6 +25,12 @@ import { buildContactOutput, buildStatsOutput, buildReposOutput, + buildRepositoryCatalog, + buildRepositoryBrowserOutput, + buildRepositoryDetailOutput, + filterRepositoryCatalog, + findRepositoryByQuery, + parseRepositoryCommandArgs, buildContributionChartAscii, estimateReadingTime, getRandomFortune, @@ -110,6 +116,286 @@ describe("terminal helpers", () => { expect(output).toContain("No repositories configured"); }); + it("merges curated projects with live GitHub repository metadata", () => { + const catalog = buildRepositoryCatalog( + { + featured: [ + { + name: "Link Converter", + url: "https://convert.jjalangtry.com", + repo: "https://github.com/jjalangtry/convert-jakoblangtry-com", + description: "Convert music links", + language: "TypeScript", + }, + ], + contributions: [], + github: [ + { + name: "jakobs-ls-remake", + url: "https://github.com/jjalangtry/jakobs-ls-remake", + description: "Reimplementation of ls using low-level C", + language: "C", + }, + ], + }, + [ + { + name: "convert-jakoblangtry-com", + html_url: "https://github.com/jjalangtry/convert-jakoblangtry-com", + description: "GitHub API description", + language: "TypeScript", + homepage: "https://convert.jjalangtry.com", + stargazers_count: 7, + pushed_at: "2026-05-01T12:00:00Z", + topics: ["astro"], + }, + { + name: "wordlehelper", + html_url: "https://github.com/jjalangtry/wordlehelper", + description: "Systems programming wordle solver", + language: "C", + stargazers_count: 2, + pushed_at: "2026-04-30T12:00:00Z", + topics: ["systems"], + }, + ], + ); + + expect(catalog.map((repo) => repo.name)).toContain("Link Converter"); + expect(catalog.map((repo) => repo.name)).toContain("wordlehelper"); + + const converter = findRepositoryByQuery( + "convert-jakoblangtry-com", + catalog, + ); + expect(converter.description).toBe("Convert music links"); + expect(converter.stars).toBe(7); + expect(converter.topics).toContain("astro"); + expect(converter.source).toContain("curated"); + expect(converter.source).toContain("github-api"); + }); + + it("parses repository command filters and quoted searches", () => { + expect( + parseRepositoryCommandArgs('--lang C --limit 5 "systems code"'), + ).toEqual({ + query: "systems code", + language: "C", + limit: 5, + list: false, + refresh: false, + }); + + expect(parseRepositoryCommandArgs("--list --refresh")).toEqual({ + query: "", + language: "", + limit: Infinity, + list: true, + refresh: true, + }); + }); + + it("filters repository catalog by language and search text", () => { + const catalog = buildRepositoryCatalog( + { + featured: [], + contributions: [], + github: [ + { + name: "Unix-Permissions-Game", + url: "https://github.com/jjalangtry/Unix-Permissions-Game", + description: "ncurses quiz game", + language: "C", + }, + { + name: "read-faster", + url: "https://github.com/jjalangtry/read-faster", + description: "RSVP speed reading app", + language: "Swift", + }, + ], + }, + [], + ); + + expect(filterRepositoryCatalog(catalog, { language: "C" })).toHaveLength(1); + expect( + filterRepositoryCatalog(catalog, { query: "permission" })[0].name, + ).toBe("Unix-Permissions-Game"); + expect( + findRepositoryByQuery("unix-permissions-game", catalog).language, + ).toBe("C"); + }); + + it("builds repository browser and detail terminal output", () => { + const catalog = buildRepositoryCatalog( + { + featured: [], + contributions: [], + github: [ + { + name: "jakobs-ls-remake", + url: "https://github.com/jjalangtry/jakobs-ls-remake", + description: "Reimplementation of ls using low-level C", + language: "C", + updatedAt: "2026-05-01T12:00:00Z", + }, + ], + }, + [], + ); + + const output = buildRepositoryBrowserOutput(catalog, { language: "C" }); + expect(output).toContain("Repository catalog"); + expect(output).toContain("Filters: language=C"); + expect(output).toContain("repo jakobs-ls-remake"); + + const detail = buildRepositoryDetailOutput(catalog[0]); + expect(detail).toContain("jakobs-ls-remake"); + expect(detail).toContain("Source:"); + expect(detail).toContain("Language: C"); + }); + + it("handles repository catalog fallback and API edge cases", () => { + expect(buildRepositoryCatalog(null, null)).toEqual([]); + + const catalog = buildRepositoryCatalog( + { + featured: [ + null, + { name: "No URL" }, + { + name: "Demo Only", + url: "https://demo.example.com", + demo: "https://demo-alt.example.com", + }, + { + name: "Git URL", + url: "https://github.com/jjalangtry/git-url.git", + topics: "not-an-array", + }, + ], + contributions: [ + { + name: "Contrib", + url: "https://example.org/project", + repo: "https://github.com/example/project", + updated_at: "not-a-date", + }, + ], + }, + [ + null, + { name: "No html url" }, + { + name: "forked-tool", + html_url: "https://github.com/jjalangtry/forked-tool", + fork: true, + updated_at: "2026-04-01T00:00:00Z", + topics: "not-an-array", + }, + { + name: "plain-tool", + html_url: "https://github.com/jjalangtry/plain-tool", + homepage: "", + stargazers_count: undefined, + updated_at: "2026-03-01T00:00:00Z", + }, + ], + ); + + expect(catalog.map((repo) => repo.name)).toEqual( + expect.arrayContaining([ + "Demo Only", + "Git URL", + "Contrib", + "forked-tool", + ]), + ); + expect(findRepositoryByQuery("git-url", catalog).url).toBe( + "https://github.com/jjalangtry/git-url", + ); + expect(findRepositoryByQuery("forked-tool", catalog).section).toBe("forks"); + expect( + buildRepositoryBrowserOutput(catalog, { query: "missing" }), + ).toContain('No repositories found for query="missing"'); + }); + + it("parses repository command flag variants", () => { + expect(parseRepositoryCommandArgs("-l C -n 2 -s kernel")).toMatchObject({ + query: "kernel", + language: "C", + limit: 2, + }); + expect( + parseRepositoryCommandArgs("--language=Assembly --limit=3 --search=nes"), + ).toMatchObject({ + query: "nes", + language: "Assembly", + limit: 3, + }); + expect(parseRepositoryCommandArgs("--all --limit nope repo name")).toEqual({ + query: "repo name", + language: "", + limit: Infinity, + list: false, + refresh: false, + }); + expect(parseRepositoryCommandArgs("--lang")).toMatchObject({ + language: "", + query: "", + }); + }); + + it("covers repository filtering, lookup, and output fallbacks", () => { + const catalog = [ + null, + { + name: "Systems Notes", + slug: "systems-notes", + url: "https://github.com/jjalangtry/systems-notes", + homepage: "https://systems.example.com", + description: "", + language: "", + section: "github", + stars: 4, + updatedAt: "bad-date", + topics: ["c", "unix"], + }, + { + name: "Swift App", + slug: "swift-app", + url: "https://github.com/jjalangtry/swift-app", + description: "iOS app", + language: "Swift", + section: "github", + updatedAt: "2026-05-01T00:00:00Z", + topics: [], + }, + ]; + + expect(filterRepositoryCatalog(null)).toEqual([]); + expect(filterRepositoryCatalog(catalog, { language: "Rust" })).toEqual([]); + expect(filterRepositoryCatalog(catalog, { query: "unix" })[0].name).toBe( + "Systems Notes", + ); + expect(findRepositoryByQuery("", catalog)).toBeNull(); + expect(findRepositoryByQuery("swift app", null)).toBeNull(); + expect(findRepositoryByQuery("swift-app", catalog).name).toBe("Swift App"); + + const limited = buildRepositoryBrowserOutput(catalog, { limit: 1 }); + expect(limited).toContain("Showing 1 of 2"); + expect(buildRepositoryBrowserOutput(undefined)).toContain( + "No repositories found", + ); + + const detail = buildRepositoryDetailOutput(catalog[1]); + expect(detail).toContain("Stars: 4"); + expect(detail).toContain("Topics: c, unix"); + expect(detail).toContain("Demo: https://systems.example.com"); + expect(buildRepositoryDetailOutput(null)).toBeNull(); + }); + it("builds contribution chart ASCII from API data", () => { const contributions = [ { date: "2026-03-12", count: 4, level: 1 },