diff --git a/.gitignore b/.gitignore index a463743..173519d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules dist/ .tmp/ tmp/ +.DS_Store \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 274b895..5b7d61a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,11 +1,17 @@ { "editor.defaultFormatter": "biomejs.biome", "editor.formatOnSave": true, + "editor.formatOnSaveMode": "file", "[javascript]": { "editor.defaultFormatter": "biomejs.biome" }, "[typescript]": { - "editor.defaultFormatter": "biomejs.biome" + "editor.defaultFormatter": "biomejs.biome", + "editor.formatOnSave": true + }, + "[typescriptreact]": { + "editor.defaultFormatter": "biomejs.biome", + "editor.formatOnSave": true }, "[json]": { "editor.defaultFormatter": "biomejs.biome" diff --git a/biome.json b/biome.json index 5c99f6b..6fae468 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.6/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.7/schema.json", "vcs": { "enabled": true, "clientKind": "git", diff --git a/package.json b/package.json index b6d3c11..f10ceaa 100644 --- a/package.json +++ b/package.json @@ -16,18 +16,20 @@ "README.md" ], "scripts": { - "build": "biome check && tsup src/cli.tsx --format esm --clean && cp -r src/humans dist/ && npm run build:pdfs", - "build:ci": "biome check && tsup src/cli.tsx --format esm --clean && cp -r src/humans dist/", + "build": "biome check && tsup src/cli.tsx --format esm --clean && npm run copy:humans && npm run copy:pdfs && npm run build:pdfs", + "build:ci": "biome check && tsup src/cli.tsx --format esm --clean && npm run copy:humans", "build:pdfs": "tsx scripts/pdf-gen/generate.ts --all", - "predev": "npm run build:pdfs", + "predev": "npm run build:pdfs && npm run copy:pdfs", "dev": "tsx src/cli.tsx", "test": "vitest run", "format": "biome format --write", "lint": "biome lint", "lint:fix": "biome lint --write", "biome:all": "biome format --write && biome lint --write", - "gen-pdf": "tsx scripts/pdf-gen/generate.ts", - "gen-json": "tsx scripts/json-gen/generate.ts", + "gen:pdf": "tsx scripts/pdf-gen/generate.ts", + "copy:pdfs": "tsx scripts/copy-pdfs/copy-pdfs.ts", + "copy:humans": "cp -r src/humans dist/", + "gen:json": "tsx scripts/json-gen/generate.ts", "prepublishOnly": "npm run build" }, "keywords": [], diff --git a/public/craig-cv.pdf b/public/craig-cv.pdf new file mode 100644 index 0000000..91088ea Binary files /dev/null and b/public/craig-cv.pdf differ diff --git a/scripts/copy-pdfs/__tests__/copy-pdfs-test.ts b/scripts/copy-pdfs/__tests__/copy-pdfs-test.ts new file mode 100644 index 0000000..18aa974 --- /dev/null +++ b/scripts/copy-pdfs/__tests__/copy-pdfs-test.ts @@ -0,0 +1 @@ +import "../_tests_/copy-pdfs.test"; diff --git a/scripts/copy-pdfs/_tests_/copy-pdfs.test.ts b/scripts/copy-pdfs/_tests_/copy-pdfs.test.ts new file mode 100644 index 0000000..4efa71a --- /dev/null +++ b/scripts/copy-pdfs/_tests_/copy-pdfs.test.ts @@ -0,0 +1,76 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { copyStaticPdfs } from "../copy-pdfs"; + +function makeTempDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), "run-cv-copy-pdfs-")); +} + +describe("copyStaticPdfs", () => { + let tempRoot: string; + + beforeEach(() => { + tempRoot = makeTempDir(); + }); + + afterEach(() => { + fs.rmSync(tempRoot, { recursive: true, force: true }); + }); + + it("copies PDF files from public into dist/pdf", () => { + const publicDir = path.join(tempRoot, "public"); + const distPdfDir = path.join(tempRoot, "dist", "pdf"); + const writes: string[] = []; + + fs.mkdirSync(publicDir, { recursive: true }); + fs.writeFileSync(path.join(publicDir, "craig-cv.pdf"), "CRAIG PDF"); + fs.writeFileSync(path.join(publicDir, "baldur-cv.pdf"), "BALDUR PDF"); + fs.writeFileSync(path.join(publicDir, "notes.txt"), "ignore me"); + + const result = copyStaticPdfs({ + publicDir, + distPdfDir, + stdout: { + write(chunk: string) { + writes.push(chunk); + return true; + }, + }, + }); + + expect(result.copiedFiles).toEqual(["baldur-cv.pdf", "craig-cv.pdf"]); + expect(fs.readFileSync(path.join(distPdfDir, "craig-cv.pdf"), "utf8")).toBe( + "CRAIG PDF", + ); + expect( + fs.readFileSync(path.join(distPdfDir, "baldur-cv.pdf"), "utf8"), + ).toBe("BALDUR PDF"); + expect(fs.existsSync(path.join(distPdfDir, "notes.txt"))).toBe(false); + expect(writes).toEqual([ + `Copied 2 static PDF file(s) to ${path.resolve(distPdfDir)}\n`, + ]); + }); + + it("throws when the public directory is missing", () => { + const publicDir = path.join(tempRoot, "missing-public"); + const distPdfDir = path.join(tempRoot, "dist", "pdf"); + + expect(() => copyStaticPdfs({ publicDir, distPdfDir })).toThrow( + `Public directory not found: ${path.resolve(publicDir)}`, + ); + }); + + it("throws when the public directory has no PDF files", () => { + const publicDir = path.join(tempRoot, "public"); + const distPdfDir = path.join(tempRoot, "dist", "pdf"); + + fs.mkdirSync(publicDir, { recursive: true }); + fs.writeFileSync(path.join(publicDir, "notes.txt"), "ignore me"); + + expect(() => copyStaticPdfs({ publicDir, distPdfDir })).toThrow( + `No PDF files found in ${path.resolve(publicDir)}`, + ); + }); +}); diff --git a/scripts/copy-pdfs/copy-pdfs.ts b/scripts/copy-pdfs/copy-pdfs.ts new file mode 100644 index 0000000..20d5b1a --- /dev/null +++ b/scripts/copy-pdfs/copy-pdfs.ts @@ -0,0 +1,75 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +interface CopyStaticPdfsOptions { + publicDir: string; + distPdfDir: string; + stdout?: Pick; +} + +interface CopyStaticPdfsResult { + copiedFiles: string[]; + distPdfDir: string; +} + +export function getCopyPdfPaths() { + const __dirname = path.dirname(fileURLToPath(import.meta.url)); + return { + publicDir: path.resolve(__dirname, "../../public"), + distPdfDir: path.resolve(__dirname, "../../dist/pdf"), + }; +} + +export function copyStaticPdfs({ + publicDir, + distPdfDir, + stdout = process.stdout, +}: CopyStaticPdfsOptions): CopyStaticPdfsResult { + const resolvedPublicDir = path.resolve(publicDir); + const resolvedDistPdfDir = path.resolve(distPdfDir); + + if (!fs.existsSync(resolvedPublicDir)) { + throw new Error(`Public directory not found: ${resolvedPublicDir}`); + } + + fs.mkdirSync(resolvedDistPdfDir, { recursive: true }); + + const files = fs + .readdirSync(resolvedPublicDir, { withFileTypes: true }) + .filter((entry) => entry.isFile()) + .map((entry) => entry.name) + .filter((name) => name.toLowerCase().endsWith(".pdf")); + + if (files.length === 0) { + throw new Error(`No PDF files found in ${resolvedPublicDir}`); + } + + for (const file of files) { + const source = path.join(resolvedPublicDir, file); + const target = path.join(resolvedDistPdfDir, file); + fs.copyFileSync(source, target); + } + + stdout.write( + `Copied ${files.length} static PDF file(s) to ${resolvedDistPdfDir}\n`, + ); + + return { + copiedFiles: files, + distPdfDir: resolvedDistPdfDir, + }; +} + +function main() { + const { publicDir, distPdfDir } = getCopyPdfPaths(); + copyStaticPdfs({ publicDir, distPdfDir }); +} + +function isDirectExecution() { + return process.argv[1] === fileURLToPath(import.meta.url); +} + +if (isDirectExecution()) { + main(); +} diff --git a/src/App.tsx b/src/App.tsx index c32e766..a7526c3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,9 +1,8 @@ #!/usr/bin/env node import fs from "node:fs"; -import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { Box, Text, useInput } from "ink"; +import { Box, Text, useApp } from "ink"; import BigText from "ink-big-text"; import Gradient from "ink-gradient"; import SelectInput from "ink-select-input"; @@ -15,8 +14,16 @@ import { LoadingScreen } from "./components/LoadingScreen"; import { MarkdownRenderer } from "./components/MarkdownRenderer"; import { SkillBadge } from "./components/SkillBadge"; import { getHuman, getPage } from "./cvParser"; +import { useAppKeyboard } from "./hooks/use-app-keyboard"; import { theme } from "./styles/theme"; -import type { HighlightedItem, HumanManifest, MenuItem, Page } from "./types"; +import type { HighlightedItem, HumanManifest, Page } from "./types"; +import { executeMenuAction } from "./utils/menu-action-executor"; +import { + findMenuItemByValue, + findMenuItemIndexByValue, + getMenuSelectItems, + resolveMenuAction, +} from "./utils/menu-actions"; // navigation helpers moved out of App import { canDrillIn, @@ -57,6 +64,7 @@ function getPdfRoot() { const PDF_ROOT = getPdfRoot(); export function App({ name }: AppProps) { + const { exit } = useApp(); const [human, setHuman] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); @@ -131,114 +139,69 @@ export function App({ name }: AppProps) { return () => clearInterval(interval); }, []); - useInput((input, key) => { - // Global navigation should always work: back and quit - if (key.escape || key.leftArrow || (input === "b" && history.length > 1)) { - if (history.length > 1) { - setHistory((prev) => prev.slice(0, -1)); - } - return; - } - - if (input === "q" && (history.length <= 1 || error)) { - // clear terminal first - process.stdout.write("\x1Bc"); - // then exit - process.exit(0); - } - - // If on a contact page, handle command input (left/right should not interfere) - if (isContactPage) { - if (input === "m" && contactInfo.email) { - const subject = encodeURIComponent("Accessed run-cv, initiating comms"); - const body = encodeURIComponent( - "Having navigated the digital tapestry of your run-cv, I'm reaching out to establish a more direct line of communication. I'm impressed with what I've seen and would like to discuss potential opportunities.", - ); - open(`mailto:${contactInfo.email}?subject=${subject}&body=${body}`, { - app: { name: "Mail" }, - }); - } - if (input === "p" && contactInfo.linkedin) { - open(contactInfo.linkedin); - } - return; - } - - // arrow-right should drill in when an item is highlighted and we have a non-empty menu - if ( - key.rightArrow && - canDrillIn(currentPage, highlightedItem) && - highlightedItem - ) { - handleSelect(highlightedItem); - return; - } - - // Handle space selection for menu items as before - if ( - input === " " && - canDrillIn(currentPage, highlightedItem) && - highlightedItem - ) { - handleSelect(highlightedItem); - } + useAppKeyboard({ + canGoBack: history.length > 1, + hasError: !!error, + isContactPage, + highlightedItem, + canUsePrimaryAction: canDrillIn(currentPage, highlightedItem), + contactInfo, + onBack: () => setHistory((prev) => prev.slice(0, -1)), + onQuit: () => exit(), + onPrimaryAction: handleSelect, + onContactEmail: handleContactEmail, + onContactLinkedIn: handleContactLinkedIn, }); async function handleSelect(item: { value: string }) { if (!currentPage || !human || !item) return; - // 1. Find the specific menu item object to check for extra metadata (like 'theme') - const selectedMenuItem = currentPage.menu?.find( - (m: MenuItem) => m.file === item.value || m.theme === item.value, + const selectedMenuItem = findMenuItemByValue(currentPage.menu, item.value); + const selectedIndex = findMenuItemIndexByValue( + currentPage.menu, + item.value, ); - // 2. SAVE NAVIGATION MEMORY (Existing logic) - const selectedIndex = - currentPage.menu?.findIndex( - (i: MenuItem) => i.file === item.value || i.theme === item.value, - ) ?? 0; + if (selectedIndex >= 0) { + setNavigationMemory((prev) => ({ + ...prev, + [currentPage.dir]: selectedIndex, + })); + } - setNavigationMemory((prev) => ({ - ...prev, - [currentPage.dir]: selectedIndex, - })); + const action = resolveMenuAction(selectedMenuItem, human.name); - // 3. CHECK FOR DOWNLOAD ACTION - // If the menu item has a 'theme' property, it's a PDF download request - if (selectedMenuItem?.theme) { - const filename = `${human.name.toLowerCase()}-${selectedMenuItem.theme}-cv.pdf`; - const internalPdfPath = path.join(PDF_ROOT, filename); + await executeMenuAction({ + action, + currentDir: currentPage.dir, + pdfRoot: PDF_ROOT, + loadPage: getPage, + onNavigate: (nextPage) => setHistory((prev) => [...prev, nextPage]), + onError: (message) => setError(message), + }); + } - // Cross-platform way to target the Downloads folder - const downloadsFolder = path.join(os.homedir(), "Downloads"); - const userDownloadPath = path.join(downloadsFolder, filename); + function handleContactEmail() { + if (!contactInfo.email) { + return; + } - try { - if (fs.existsSync(internalPdfPath)) { - // 1. Copy to the actual Downloads folder - fs.copyFileSync(internalPdfPath, userDownloadPath); + const subject = encodeURIComponent("Accessed run-cv, initiating comms"); + const body = encodeURIComponent( + "Having navigated the digital tapestry of your run-cv, I'm reaching out to establish a more direct line of communication. I'm impressed with what I've seen and would like to discuss potential opportunities.", + ); - // 2. Open the file from the Downloads folder - await open(userDownloadPath); + open(`mailto:${contactInfo.email}?subject=${subject}&body=${body}`, { + app: { name: "Mail" }, + }); + } - // Optionally: Update UI to show it was saved to Downloads - // (You'd need a state variable for a "Success" message) - } else { - setError(`ARCHIVE ERROR: Resource ${filename} not found in package.`); - } - } catch (err) { - setError(`SYSTEM·FAILURE:·${(err as Error).message}`); - } + function handleContactLinkedIn() { + if (!contactInfo.linkedin) { return; } - // 4. STANDARD NAVIGATION (Existing logic) - try { - const nextPage = await getPage(currentPage.dir, item.value); - setHistory((prev) => [...prev, nextPage]); - } catch (err) { - setError((err as Error).message); - } + open(contactInfo.linkedin); } const navigationHint = computeNavigationHint(history); @@ -303,11 +266,7 @@ export function App({ name }: AppProps) { ({ - label: item.label, - // Use file if it exists, otherwise use the theme name as the value - value: item.file || item.theme || "", - }))} + items={getMenuSelectItems(currentPage.menu)} onSelect={handleSelect} onHighlight={setHighlightedItem} initialIndex={navigationMemory[currentPage.dir] || 0} diff --git a/src/__tests__/menu-action-executor.test.ts b/src/__tests__/menu-action-executor.test.ts new file mode 100644 index 0000000..5eec6c8 --- /dev/null +++ b/src/__tests__/menu-action-executor.test.ts @@ -0,0 +1,103 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { vi } from "vitest"; + +import type { Page } from "../types"; +import { executeMenuAction } from "../utils/menu-action-executor"; +import type { MenuAction } from "../utils/menu-actions"; + +vi.mock("open", () => { + return { + default: vi.fn(async () => {}), + }; +}); + +interface RunExecutorOptions { + currentDir?: string; + pdfRoot?: string; + loadPage?: (baseDir: string, file: string) => Promise; +} + +async function runExecutor(action: MenuAction, options?: RunExecutorOptions) { + const errors: string[] = []; + const navigations: Page[] = []; + + await executeMenuAction({ + action, + currentDir: options?.currentDir ?? "/tmp/current", + pdfRoot: options?.pdfRoot ?? "/tmp/pdf", + loadPage: async (baseDir: string, file: string) => { + if (options?.loadPage) { + return options.loadPage(baseDir, file); + } + + return { dir: baseDir, file, content: "", menu: [] }; + }, + onNavigate: (nextPage) => { + navigations.push(nextPage); + }, + onError: (message) => { + errors.push(message); + }, + }); + + return { errors, navigations }; +} + +describe("menu action executor", () => { + it("opens URL actions in default browser", async () => { + const { default: open } = await import("open"); + const openSpy = vi.mocked(open); + + await runExecutor({ + type: "open-url", + url: "https://github.com/burglekitt/worktree", + }); + + expect(openSpy).toHaveBeenCalledWith( + "https://github.com/burglekitt/worktree", + ); + }); + + it("navigates for markdown actions", async () => { + const { errors, navigations } = await runExecutor( + { type: "navigate", file: "projects/index.md" }, + { + currentDir: "/tmp/human", + }, + ); + + expect(errors).toEqual([]); + expect(navigations).toHaveLength(1); + expect(navigations[0]).toEqual({ + dir: "/tmp/human", + file: "projects/index.md", + content: "", + menu: [], + }); + }); + + it("downloads static PDF actions", async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "menu-exec-")); + const pdfRoot = path.join(tempRoot, "dist", "pdf"); + fs.mkdirSync(pdfRoot, { recursive: true }); + fs.writeFileSync(path.join(pdfRoot, "craig-cv.pdf"), "PDF"); + + const copySpy = vi.spyOn(fs, "copyFileSync").mockImplementation(() => {}); + + const { errors } = await runExecutor( + { + type: "download-pdf", + filename: "craig-cv.pdf", + }, + { pdfRoot }, + ); + + expect(errors).toEqual([]); + expect(copySpy).toHaveBeenCalledTimes(1); + + copySpy.mockRestore(); + fs.rmSync(tempRoot, { recursive: true, force: true }); + }); +}); diff --git a/src/__tests__/menu-actions.test.ts b/src/__tests__/menu-actions.test.ts new file mode 100644 index 0000000..d95c975 --- /dev/null +++ b/src/__tests__/menu-actions.test.ts @@ -0,0 +1,98 @@ +import type { MenuItem } from "../types"; +import { + findMenuItemByValue, + findMenuItemIndexByValue, + getMenuItemValue, + getMenuSelectItems, + resolveMenuAction, +} from "../utils/menu-actions"; + +describe("menu actions", () => { + it("resolves a theme menu item to generated PDF action", () => { + const action = resolveMenuAction( + { label: "Terminal", theme: "terminal" }, + "Craig", + ); + + expect(action).toEqual({ + type: "download-pdf", + filename: "craig-terminal-cv.pdf", + }); + }); + + it("resolves a static PDF file menu item to download action", () => { + const action = resolveMenuAction( + { label: "ATS", file: "craig-cv.pdf" }, + "Craig", + ); + + expect(action).toEqual({ + type: "download-pdf", + filename: "craig-cv.pdf", + }); + }); + + it("resolves URL menu item to open-url action", () => { + const action = resolveMenuAction( + { label: "Worktree", url: "https://github.com/burglekitt/worktree" }, + "Craig", + ); + + expect(action).toEqual({ + type: "open-url", + url: "https://github.com/burglekitt/worktree", + }); + }); + + it("supports legacy link frontmatter field for compatibility", () => { + const action = resolveMenuAction( + { label: "Legacy", link: "https://example.com" }, + "Craig", + ); + + expect(action).toEqual({ + type: "open-url", + url: "https://example.com", + }); + }); + + it("resolves markdown file menu item to navigate action", () => { + const action = resolveMenuAction( + { label: "Career", file: "career/index.md" }, + "Craig", + ); + + expect(action).toEqual({ + type: "navigate", + file: "career/index.md", + }); + }); + + it("maps menu values and lookup helpers for mixed menu actions", () => { + const menu: MenuItem[] = [ + { label: "Career", file: "career/index.md" }, + { label: "Terminal", theme: "terminal" }, + { label: "Worktree", url: "https://github.com/burglekitt/worktree" }, + ]; + + expect(getMenuItemValue(menu[0])).toBe("career/index.md"); + expect(getMenuItemValue(menu[1])).toBe("terminal"); + expect(getMenuItemValue(menu[2])).toBe( + "https://github.com/burglekitt/worktree", + ); + + expect(getMenuSelectItems(menu)).toEqual([ + { label: "Career", value: "career/index.md" }, + { label: "Terminal", value: "terminal" }, + { + label: "Worktree", + value: "https://github.com/burglekitt/worktree", + }, + ]); + + expect(findMenuItemByValue(menu, "terminal")?.label).toBe("Terminal"); + expect( + findMenuItemIndexByValue(menu, "https://github.com/burglekitt/worktree"), + ).toBe(2); + }); +}); diff --git a/src/__tests__/navigation-utils.test.ts b/src/__tests__/navigation-utils.test.ts index e18abed..a47c36b 100644 --- a/src/__tests__/navigation-utils.test.ts +++ b/src/__tests__/navigation-utils.test.ts @@ -109,4 +109,24 @@ describe("drill-in guard", () => { expect(highlighted).toEqual({ label: "Vintage", value: "vintage" }); expect(canDrillIn(downloadPage, highlighted)).toBe(true); }); + + it("handles URL menu items correctly", () => { + const projectsPage: Page = { + dir: "projects", + file: "index.md", + content: "", + menu: [ + { label: "Worktree", url: "https://github.com/burglekitt/worktree" }, + ], + }; + const highlighted = computeHighlightedItem(projectsPage, { + projects: 0, + }) as HighlightedItem; + + expect(highlighted).toEqual({ + label: "Worktree", + value: "https://github.com/burglekitt/worktree", + }); + expect(canDrillIn(projectsPage, highlighted)).toBe(true); + }); }); diff --git a/src/cli.tsx b/src/cli.tsx index c77e065..eefc369 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -4,8 +4,9 @@ import { render } from "ink"; import meow from "meow"; import { App } from "./App"; -const cli = meow( - ` +async function main() { + const cli = meow( + ` Usage $ run-cv @@ -13,9 +14,20 @@ const cli = meow( $ run-cv willow $ run-cv madmardigan `, - { - importMeta: import.meta, - }, -); + { + importMeta: import.meta, + }, + ); -render(); + const { waitUntilExit } = render(); + + await waitUntilExit(); + process.stdout.write("\x1B[3J\x1B[2J\x1B[H"); +} + +main().catch((error: unknown) => { + if (error instanceof Error) { + process.stderr.write(`${error.message}\n`); + } + process.exit(1); +}); diff --git a/src/hooks/use-app-keyboard.ts b/src/hooks/use-app-keyboard.ts new file mode 100644 index 0000000..b74797b --- /dev/null +++ b/src/hooks/use-app-keyboard.ts @@ -0,0 +1,67 @@ +import { useInput } from "ink"; +import type { HighlightedItem } from "../types"; + +interface ContactInfo { + email?: string; + linkedin?: string; +} + +interface UseAppKeyboardOptions { + canGoBack: boolean; + hasError: boolean; + isContactPage: boolean; + highlightedItem: HighlightedItem | null; + canUsePrimaryAction: boolean; + contactInfo: ContactInfo; + onBack: () => void; + onQuit: () => void; + onPrimaryAction: (item: HighlightedItem) => void; + onContactEmail: () => void; + onContactLinkedIn: () => void; +} + +export function useAppKeyboard({ + canGoBack, + hasError, + isContactPage, + highlightedItem, + canUsePrimaryAction, + contactInfo, + onBack, + onQuit, + onPrimaryAction, + onContactEmail, + onContactLinkedIn, +}: UseAppKeyboardOptions): void { + useInput((input, key) => { + if (key.escape || key.leftArrow || (input === "b" && canGoBack)) { + if (canGoBack) { + onBack(); + } + return; + } + + if (input === "q" && (!canGoBack || hasError)) { + onQuit(); + return; + } + + if (isContactPage) { + if (input === "m" && contactInfo.email) { + onContactEmail(); + } + if (input === "p" && contactInfo.linkedin) { + onContactLinkedIn(); + } + return; + } + + if ( + (key.rightArrow || input === " ") && + canUsePrimaryAction && + highlightedItem + ) { + onPrimaryAction(highlightedItem); + } + }); +} diff --git a/src/humans/baldur/introduction.md b/src/humans/baldur/introduction.md index 277f2aa..a0c1098 100644 --- a/src/humans/baldur/introduction.md +++ b/src/humans/baldur/introduction.md @@ -11,6 +11,8 @@ menu: file: hobbies/index.md - label: Volunteer file: volunteer/index.md + - label: Projects + file: projects/index.md - label: Contact file: contact.md - label: Download CV diff --git a/src/humans/baldur/projects/index.md b/src/humans/baldur/projects/index.md new file mode 100644 index 0000000..7a05ac4 --- /dev/null +++ b/src/humans/baldur/projects/index.md @@ -0,0 +1,12 @@ +--- +title: "Projects" +menu: + - label: Worktree + url: https://github.com/burglekitt/worktree + - label: GMT (Coming soon) + url: https://github.com/burglekitt/gmt +--- + +# Projects + +I'm building open-source projects! diff --git a/src/humans/craig/download-cv/index.md b/src/humans/craig/download-cv/index.md index c754904..3857bc4 100644 --- a/src/humans/craig/download-cv/index.md +++ b/src/humans/craig/download-cv/index.md @@ -5,6 +5,8 @@ menu: theme: "terminal" - label: "Download: Vintage Archive (Sepia)" theme: "vintage" + - label: "Download: HR/ATS-friendly CV" + file: "craig-cv.pdf" --- # DOWNLOAD MISSION LOGS diff --git a/src/humans/craig/introduction.md b/src/humans/craig/introduction.md index a302d37..dc1eda6 100644 --- a/src/humans/craig/introduction.md +++ b/src/humans/craig/introduction.md @@ -1,6 +1,6 @@ --- name: Craig -role: Senior Frontend Architect | Staff Frontend Engineer (Systems Architecture) +role: Senior Frontend Engineer | Staff Frontend Engineer (Systems Architecture) skills: React, TypeScript, Next.js, Node.js, GraphQL, Temporal, Agentic AI menu: - label: Career @@ -9,6 +9,8 @@ menu: file: education/index.md - label: Organizations file: organizations/index.md + - label: Projects + file: projects/index.md - label: Contact file: contact.md - label: Download CV @@ -17,7 +19,7 @@ menu: -## Senior Frontend Architect | Global Infrastructure & Agentic AI Orchestration +## Senior Frontend Engineer | Global Infrastructure & Agentic AI Orchestration Engineering high-availability platforms where data integrity is mission-critical. From U.S. DoD intelligence to global DDI infrastructure (DNS/DHCP/IPAM), I specialize in bridging complex backends with intuitive, resilient user experiences. diff --git a/src/humans/craig/pdf-config.md b/src/humans/craig/pdf-config.md index d63012f..4af0d91 100644 --- a/src/humans/craig/pdf-config.md +++ b/src/humans/craig/pdf-config.md @@ -5,7 +5,7 @@ sections: - career - education - organizations -header: "Craig Curtis - Senior Frontend Architect | Staff Frontend Engineer (Systems Architecture)" +header: "Craig Curtis - Senior Frontend Engineer | Staff Frontend Engineer (Systems Architecture)" footer: "We built this PDF! burglekitt/run-cv" --- diff --git a/src/humans/craig/projects/index.md b/src/humans/craig/projects/index.md new file mode 100644 index 0000000..7a05ac4 --- /dev/null +++ b/src/humans/craig/projects/index.md @@ -0,0 +1,12 @@ +--- +title: "Projects" +menu: + - label: Worktree + url: https://github.com/burglekitt/worktree + - label: GMT (Coming soon) + url: https://github.com/burglekitt/gmt +--- + +# Projects + +I'm building open-source projects! diff --git a/src/types.ts b/src/types.ts index 30012de..1582f4f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,6 +4,8 @@ export interface MenuItem { label: string; file?: string; theme?: string; + url?: string; + link?: string; } export interface Page { diff --git a/src/utils/menu-action-executor.ts b/src/utils/menu-action-executor.ts new file mode 100644 index 0000000..e3ada99 --- /dev/null +++ b/src/utils/menu-action-executor.ts @@ -0,0 +1,69 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import open from "open"; +import type { Page } from "../types"; +import type { MenuAction } from "./menu-actions"; + +interface ExecuteMenuActionOptions { + action: MenuAction; + currentDir: string; + pdfRoot: string; + loadPage: (baseDir: string, file: string) => Promise; + onNavigate: (nextPage: Page) => void; + onError: (message: string) => void; +} + +export async function executeMenuAction({ + action, + currentDir, + pdfRoot, + loadPage, + onNavigate, + onError, +}: ExecuteMenuActionOptions): Promise { + if (action.type === "download-pdf") { + await downloadPdf(action.filename, pdfRoot, onError); + return; + } + + if (action.type === "open-url") { + try { + await open(action.url); + } catch (error) { + onError(`SYSTEM·FAILURE:·${(error as Error).message}`); + } + return; + } + + if (action.type === "navigate") { + try { + const nextPage = await loadPage(currentDir, action.file); + onNavigate(nextPage); + } catch (error) { + onError((error as Error).message); + } + } +} + +async function downloadPdf( + filename: string, + pdfRoot: string, + onError: (message: string) => void, +): Promise { + const internalPdfPath = path.join(pdfRoot, filename); + const downloadsFolder = path.join(os.homedir(), "Downloads"); + const userDownloadPath = path.join(downloadsFolder, filename); + + try { + if (!fs.existsSync(internalPdfPath)) { + onError(`ARCHIVE ERROR: Resource ${filename} not found in package.`); + return; + } + + fs.copyFileSync(internalPdfPath, userDownloadPath); + await open(userDownloadPath); + } catch (error) { + onError(`SYSTEM·FAILURE:·${(error as Error).message}`); + } +} diff --git a/src/utils/menu-actions.ts b/src/utils/menu-actions.ts new file mode 100644 index 0000000..9812e5c --- /dev/null +++ b/src/utils/menu-actions.ts @@ -0,0 +1,94 @@ +import type { MenuItem } from "../types"; + +export interface MenuSelectItem { + label: string; + value: string; +} + +export type MenuAction = + | { type: "download-pdf"; filename: string } + | { type: "open-url"; url: string } + | { type: "navigate"; file: string } + | { type: "noop" }; + +export function getMenuItemValue(item: MenuItem): string | undefined { + return item.file ?? item.theme ?? item.url ?? item.link; +} + +export function getMenuSelectItems(menu: MenuItem[]): MenuSelectItem[] { + return menu + .map((item) => { + const value = getMenuItemValue(item); + if (!value) { + return null; + } + + return { + label: item.label, + value, + }; + }) + .filter((item): item is MenuSelectItem => item !== null); +} + +export function findMenuItemByValue( + menu: MenuItem[] | undefined, + value: string, +): MenuItem | undefined { + if (!menu) { + return undefined; + } + + return menu.find((item) => getMenuItemValue(item) === value); +} + +export function findMenuItemIndexByValue( + menu: MenuItem[] | undefined, + value: string, +): number { + if (!menu) { + return -1; + } + + return menu.findIndex((item) => getMenuItemValue(item) === value); +} + +export function resolveMenuAction( + menuItem: MenuItem | undefined, + humanName: string, +): MenuAction { + if (!menuItem) { + return { type: "noop" }; + } + + if (menuItem.theme) { + return { + type: "download-pdf", + filename: `${humanName.toLowerCase()}-${menuItem.theme}-cv.pdf`, + }; + } + + if (menuItem.file?.toLowerCase().endsWith(".pdf")) { + return { + type: "download-pdf", + filename: menuItem.file, + }; + } + + if (menuItem.file) { + return { + type: "navigate", + file: menuItem.file, + }; + } + + const externalUrl = menuItem.url ?? menuItem.link; + if (externalUrl) { + return { + type: "open-url", + url: externalUrl, + }; + } + + return { type: "noop" }; +} diff --git a/src/utils/navigation-utils.ts b/src/utils/navigation-utils.ts index 2b100a8..4ca368d 100644 --- a/src/utils/navigation-utils.ts +++ b/src/utils/navigation-utils.ts @@ -1,4 +1,5 @@ import type { HighlightedItem, Page } from "../types"; +import { getMenuItemValue } from "./menu-actions"; export function computeNavigationHint(history: Page[]): string { // only show instructions on the very first, root page @@ -17,8 +18,7 @@ export function computeHighlightedItem( const menuItem = currentPage.menu[idx]; if (!menuItem) return null; - // menuItem.file and menuItem.theme are both optional; pick one that exists. - const val = menuItem.file ?? menuItem.theme; + const val = getMenuItemValue(menuItem); if (!val) { // there is nothing to drill into return null; diff --git a/vitest.config.ts b/vitest.config.ts index 8d28705..d76dfb8 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,6 +4,6 @@ export default defineConfig({ test: { globals: true, environment: "node", - include: ["scripts/json-gen/_tests_/**/*.ts", "src/__tests__/**/*.ts"], + include: ["**/*.test.ts"], }, });