- {item.type === 'analysis' ? 'Analysis Result' : 'Generated Letter'}
+ {item.type === 'analysis' ? 'Analysis Result' : item.type === 'resume' ? 'Generated Resume' : 'Generated Letter'}
@@ -249,9 +277,9 @@ export default function HistoryPage() {
minute: '2-digit',
})}
-
+
- {item.type === 'analysis' ? 'Open Analysis' : 'Open Letter'}
+ {item.type === 'analysis' ? 'Open Analysis' : item.type === 'resume' ? 'Open Resume' : 'Open Letter'}
diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx
index 33550ca..fac2a69 100644
--- a/src/app/dashboard/page.tsx
+++ b/src/app/dashboard/page.tsx
@@ -6,6 +6,7 @@ import {
BarChart3,
Clock,
Compass,
+ FileCode2,
FileText,
Loader2,
Sparkles,
@@ -45,6 +46,13 @@ const actions = [
icon: Sparkles,
cta: 'Create letter',
},
+ {
+ title: 'Build LaTeX Resume',
+ description: 'Generate a job-tailored resume in popular LaTeX templates.',
+ href: '/dashboard/resume-builder',
+ icon: FileCode2,
+ cta: 'Open builder',
+ },
{
title: 'View History',
description: 'Review previous analyses and letters so you can iterate fast.',
diff --git a/src/app/dashboard/resume-builder/[slug]/page.tsx b/src/app/dashboard/resume-builder/[slug]/page.tsx
new file mode 100644
index 0000000..2cbe7e4
--- /dev/null
+++ b/src/app/dashboard/resume-builder/[slug]/page.tsx
@@ -0,0 +1,506 @@
+'use client'
+
+import {
+ AlertCircle,
+ ChevronDown,
+ ChevronUp,
+ Copy,
+ Download,
+ Eye,
+ FileCode2,
+ History,
+ Loader2,
+ RefreshCw,
+ Save,
+ Sparkles,
+} from 'lucide-react'
+import Link from 'next/link'
+import { useParams } from 'next/navigation'
+import { useCallback, useEffect, useRef, useState } from 'react'
+
+import { Button } from '@/components/ui/button'
+import { Textarea } from '@/components/ui/textarea'
+
+interface ResumeVersion {
+ id: string
+ version: number
+ latexSource: string
+ templateId: string
+ resumeName?: string
+ jobDescription?: string
+ sourceAnalysisId?: string
+ customTemplateName?: string
+ createdAt: string
+}
+
+interface SessionResponse {
+ slug: string
+ latestVersion: number
+ versions: ResumeVersion[]
+ requestId: string
+}
+
+function downloadText(content: string, fileName: string) {
+ const blob = new Blob([content], { type: 'application/x-tex;charset=utf-8' })
+ const url = URL.createObjectURL(blob)
+ const link = document.createElement('a')
+ link.href = url
+ link.download = fileName
+ document.body.appendChild(link)
+ link.click()
+ link.remove()
+ URL.revokeObjectURL(url)
+}
+
+function downloadBlob(blob: Blob, fileName: string) {
+ const url = URL.createObjectURL(blob)
+ const link = document.createElement('a')
+ link.href = url
+ link.download = fileName
+ document.body.appendChild(link)
+ link.click()
+ link.remove()
+ URL.revokeObjectURL(url)
+}
+
+export default function ResumeBuilderSlugPage() {
+ const { slug } = useParams<{ slug: string }>()
+
+ const [isLoading, setIsLoading] = useState(true)
+ const [error, setError] = useState(null)
+
+ const [versions, setVersions] = useState([])
+ const [activeVersionId, setActiveVersionId] = useState(null)
+ const [historyOpen, setHistoryOpen] = useState(false)
+ const [fullViewerOpen, setFullViewerOpen] = useState(false)
+
+ const [latexSource, setLatexSource] = useState('')
+ const [isSavingVersion, setIsSavingVersion] = useState(false)
+
+ const [previewUrl, setPreviewUrl] = useState(null)
+ const [previewBlob, setPreviewBlob] = useState(null)
+ const [isRendering, setIsRendering] = useState(false)
+ const [renderError, setRenderError] = useState(null)
+ const [lastCompileLog, setLastCompileLog] = useState(null)
+ const [isAiFixing, setIsAiFixing] = useState(false)
+
+ const [copied, setCopied] = useState(false)
+
+ const debounceRef = useRef(null)
+ const objectUrlsRef = useRef>(new Set())
+
+ const trackUrl = useCallback((url: string) => {
+ objectUrlsRef.current.add(url)
+ return url
+ }, [])
+
+ const revokeUrl = useCallback((url?: string | null) => {
+ if (!url) return
+ if (objectUrlsRef.current.has(url)) {
+ URL.revokeObjectURL(url)
+ objectUrlsRef.current.delete(url)
+ }
+ }, [])
+
+ const renderLatex = useCallback(async (source: string) => {
+ setIsRendering(true)
+ setRenderError(null)
+
+ try {
+ const response = await fetch('/api/render-latex', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ latexSource: source }),
+ })
+
+ if (!response.ok) {
+ const data = await response.json().catch(() => ({}))
+ const compileLog = typeof data?.details?.log === 'string' ? data.details.log : ''
+ setLastCompileLog(compileLog || null)
+ const logLine = compileLog.split('\n').find((line: string) => line.trim().length > 0)
+ const reason = logLine ? ` ${logLine.slice(0, 220)}` : ''
+ throw new Error(`${data.message || data.error || 'Failed to render LaTeX'}${reason}`)
+ }
+
+ const blob = await response.blob()
+ setLastCompileLog(null)
+ setPreviewBlob(blob)
+ const url = trackUrl(URL.createObjectURL(blob))
+ setPreviewUrl((current) => {
+ revokeUrl(current)
+ return url
+ })
+ } catch (err) {
+ setRenderError(err instanceof Error ? err.message : 'Failed to render PDF preview')
+ } finally {
+ setIsRendering(false)
+ }
+ }, [revokeUrl, trackUrl])
+
+ useEffect(() => {
+ if (!slug) {
+ return
+ }
+
+ async function loadSession() {
+ try {
+ const response = await fetch(`/api/resume-builder/${slug}`)
+ if (!response.ok) {
+ if (response.status === 404) {
+ setError('Resume builder session not found')
+ return
+ }
+ if (response.status === 403) {
+ setError('You do not have access to this session')
+ return
+ }
+ if (response.status === 401) {
+ setError('Please sign in to view this session')
+ return
+ }
+ throw new Error('Failed to load session')
+ }
+
+ const data = await response.json() as SessionResponse
+ setVersions(data.versions)
+
+ const latest = data.versions[0]
+ if (latest) {
+ setActiveVersionId(latest.id)
+ setLatexSource(latest.latexSource)
+ await renderLatex(latest.latexSource)
+ }
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to load session')
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ void loadSession()
+ }, [renderLatex, slug])
+
+ useEffect(() => {
+ if (!latexSource.trim()) {
+ return
+ }
+
+ if (debounceRef.current) {
+ window.clearTimeout(debounceRef.current)
+ }
+
+ debounceRef.current = window.setTimeout(() => {
+ void renderLatex(latexSource)
+ }, 650)
+
+ return () => {
+ if (debounceRef.current) {
+ window.clearTimeout(debounceRef.current)
+ }
+ }
+ }, [latexSource, renderLatex])
+
+ useEffect(() => {
+ const trackedUrls = objectUrlsRef.current
+ return () => {
+ if (debounceRef.current) {
+ window.clearTimeout(debounceRef.current)
+ }
+
+ for (const url of trackedUrls) {
+ URL.revokeObjectURL(url)
+ }
+ trackedUrls.clear()
+ }
+ }, [])
+
+ const saveVersion = async () => {
+ if (!slug || !latexSource.trim()) {
+ return
+ }
+
+ setIsSavingVersion(true)
+ try {
+ const response = await fetch(`/api/resume-builder/${slug}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ latexSource }),
+ })
+
+ const data = await response.json()
+ if (!response.ok) {
+ throw new Error(data.message || data.error || 'Failed to save version')
+ }
+
+ const createdVersion: ResumeVersion = {
+ id: data.id,
+ version: data.version,
+ latexSource,
+ templateId: versions[0]?.templateId || 'custom',
+ resumeName: versions[0]?.resumeName,
+ jobDescription: versions[0]?.jobDescription,
+ sourceAnalysisId: versions[0]?.sourceAnalysisId,
+ customTemplateName: versions[0]?.customTemplateName,
+ createdAt: data.createdAt,
+ }
+
+ setVersions((current) => [createdVersion, ...current])
+ setActiveVersionId(createdVersion.id)
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to save version')
+ } finally {
+ setIsSavingVersion(false)
+ }
+ }
+
+ const handleCopyLatex = async () => {
+ await navigator.clipboard.writeText(latexSource)
+ setCopied(true)
+ setTimeout(() => setCopied(false), 1500)
+ }
+
+ const handleAiFixLatex = async () => {
+ if (!latexSource.trim()) {
+ return
+ }
+
+ setIsAiFixing(true)
+ try {
+ const response = await fetch('/api/fix-latex', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ latexSource,
+ compileLog: lastCompileLog || renderError || undefined,
+ }),
+ })
+
+ const data = await response.json()
+ if (!response.ok) {
+ throw new Error(data.message || data.error || 'Failed to apply AI LaTeX fix')
+ }
+
+ const fixedLatex = typeof data.fixedLatex === 'string' ? data.fixedLatex : ''
+ if (!fixedLatex.trim()) {
+ throw new Error('AI did not return any fixed LaTeX source')
+ }
+
+ setLatexSource(fixedLatex)
+ setRenderError(null)
+ setLastCompileLog(null)
+ await renderLatex(fixedLatex)
+ } catch (err) {
+ setRenderError(err instanceof Error ? err.message : 'Failed to auto-fix LaTeX')
+ } finally {
+ setIsAiFixing(false)
+ }
+ }
+
+ const activeVersion = versions.find((item) => item.id === activeVersionId) || versions[0] || null
+ const previewEmbedSrc = previewUrl ? `${previewUrl}#page=1&view=FitH&zoom=page-fit&navpanes=0&toolbar=0&scrollbar=0` : null
+
+ if (isLoading) {
+ return (
+
+
+
+
Loading resume builder session...
+
+
+ )
+ }
+
+ if (error) {
+ return (
+
+
+
+
Unable to open session
+
{error}
+
+ Back to Builder
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
+
+
+
+ Resume Builder Session
+
+
/dashboard/resume-builder/{slug}
+ {activeVersion && (
+
+ Version {activeVersion.version} • {new Date(activeVersion.createdAt).toLocaleString('en-US')}
+
+ )}
+
+
+
+
+
+ setHistoryOpen((current) => !current)}
+ className="flex w-full items-center justify-between rounded-xl border border-border/70 bg-background/70 px-4 py-3 text-left"
+ >
+
+
+ Version History ({versions.length})
+
+ {historyOpen ? : }
+
+
+ {historyOpen && (
+
+ {versions.map((version) => (
+
{
+ setActiveVersionId(version.id)
+ setLatexSource(version.latexSource)
+ void renderLatex(version.latexSource)
+ }}
+ className={`rounded-lg border p-3 text-left text-xs ${activeVersionId === version.id ? 'border-primary bg-primary/10' : 'border-border/70 bg-background/70'}`}
+ >
+ Version {version.version}
+ {new Date(version.createdAt).toLocaleString('en-US')}
+
+ ))}
+
+ )}
+
+
+
+
+
+
LaTeX Editor
+
+ void handleAiFixLatex()}
+ disabled={isAiFixing || isRendering || !latexSource.trim()}
+ >
+ {isAiFixing ? : }
+ AI Fix
+
+
+
+ {copied ? 'Copied' : 'Copy .tex'}
+
+ downloadText(latexSource, `${slug}.tex`)}>
+
+ Download .tex
+
+ void saveVersion()} disabled={isSavingVersion || !latexSource.trim()}>
+ {isSavingVersion ? : }
+ Save Version
+
+
+
+
+
+
+
+
Rendered PDF Preview
+
+ {isRendering && (
+
+
+ Rendering...
+
+ )}
+ void renderLatex(latexSource)} disabled={isRendering}>
+
+ Re-render
+
+ {
+ if (previewBlob) {
+ downloadBlob(previewBlob, `${slug}.pdf`)
+ }
+ }}
+ >
+
+ Download PDF
+
+ setFullViewerOpen(true)}
+ >
+
+ Full Viewer
+
+
+
+
+ {renderError ? (
+
+ {renderError}
+
+ ) : previewUrl ? (
+
+
+
+ ) : (
+
+
+ Preview will appear after rendering.
+
+ )}
+
+
+
+
+ {fullViewerOpen && previewUrl && (
+
+
+
+
Full PDF Viewer
+
+ window.open(previewUrl, '_blank', 'noopener,noreferrer')}
+ >
+ Open in New Tab
+
+ setFullViewerOpen(false)}>
+ Close
+
+
+
+
+
+
+ )}
+
+ )
+}
diff --git a/src/app/dashboard/resume-builder/new/page.tsx b/src/app/dashboard/resume-builder/new/page.tsx
new file mode 100644
index 0000000..06d33b0
--- /dev/null
+++ b/src/app/dashboard/resume-builder/new/page.tsx
@@ -0,0 +1,246 @@
+'use client'
+
+import {
+ AlertCircle,
+ ArrowLeft,
+ Loader2,
+ Sparkles,
+} from 'lucide-react'
+import Link from 'next/link'
+import { useRouter } from 'next/navigation'
+import { useEffect, useRef, useState } from 'react'
+
+import { GenerationProgress } from '@/components/dashboard/GenerationProgress'
+import { useGenerationFlow } from '@/hooks/useGenerationFlow'
+import type { ResumeTemplateId } from '@/lib/resume-latex'
+
+type ResumeBuilderSourceDraft =
+ | {
+ kind: 'manual'
+ resumeText: string
+ resumeName: string
+ jobDescription: string
+ }
+ | {
+ kind: 'analysis'
+ analysisId: string
+ resumeName: string
+ jobDescription: string
+ jobTitle?: string
+ companyName?: string
+ }
+
+interface ResumeBuilderDraft {
+ source: ResumeBuilderSourceDraft
+ template?: {
+ templateId: ResumeTemplateId
+ customTemplateName?: string
+ customTemplateLatex?: string
+ }
+}
+
+interface TailoredResumeResponse {
+ templateId: ResumeTemplateId
+ documentId?: string
+ builderSlug?: string
+ version?: number
+ requestId: string
+}
+
+interface SavedResumeRecord {
+ name?: string
+ textContent?: string
+}
+
+const RESUME_BUILDER_DRAFT_KEY = 'resumeBuilderFlowDraftV1'
+
+const LOADING_STEPS = [
+ 'Reading job description and constraints...',
+ 'Extracting relevant profile signals...',
+ 'Tailoring content to role keywords...',
+ 'Formatting LaTeX with selected template...',
+ 'Compiling validation preview...',
+ 'Saving private builder session...',
+]
+
+function readDraft(): ResumeBuilderDraft | null {
+ if (typeof window === 'undefined') {
+ return null
+ }
+
+ const raw = window.sessionStorage.getItem(RESUME_BUILDER_DRAFT_KEY)
+ if (!raw) {
+ return null
+ }
+
+ try {
+ return JSON.parse(raw) as ResumeBuilderDraft
+ } catch {
+ window.sessionStorage.removeItem(RESUME_BUILDER_DRAFT_KEY)
+ return null
+ }
+}
+
+function createSlug(input: string) {
+ const cleaned = input.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-')
+ const base = cleaned.length > 0 ? cleaned : 'resume-builder'
+ return `${base}-${Math.random().toString(36).slice(2, 9)}`
+}
+
+export default function ResumeBuilderNewPage() {
+ const router = useRouter()
+ const startedRef = useRef(false)
+
+ const [error, setError] = useState(null)
+
+ const {
+ isGenerating,
+ loadingStep,
+ estimatedSecondsRemaining,
+ runGeneration,
+ cancelGeneration,
+ } = useGenerationFlow(LOADING_STEPS, { estimatedTotalSeconds: 22 })
+
+ useEffect(() => {
+ if (startedRef.current) {
+ return
+ }
+ startedRef.current = true
+
+ const draft = readDraft()
+ if (!draft?.source || !draft.template) {
+ setError('Missing Step 1/Step 2 data. Start again from resume builder.')
+ return
+ }
+
+ const run = async () => {
+ try {
+ const source = draft.source
+ const template = draft.template
+ if (!template) {
+ throw new Error('Template is missing. Return to Step 2.')
+ }
+
+ let resolvedResumeText = source.kind === 'manual' ? source.resumeText : ''
+ if (!resolvedResumeText && source.kind === 'analysis') {
+ const resumesRes = await fetch('/api/resumes?limit=100')
+ if (!resumesRes.ok) {
+ throw new Error('Failed to load resumes for selected analysis')
+ }
+
+ const resumesPayload = await resumesRes.json()
+ const resumes: SavedResumeRecord[] = Array.isArray(resumesPayload.resumes) ? resumesPayload.resumes : []
+ const matched = resumes.find((item) => item.name === source.resumeName)
+
+ if (!matched?.textContent) {
+ throw new Error(`Source resume "${source.resumeName}" not found in saved resumes`)
+ }
+
+ resolvedResumeText = matched.textContent
+ }
+
+ if (!resolvedResumeText) {
+ throw new Error('Missing resume text for generation')
+ }
+
+ const slugSeed = source.kind === 'analysis'
+ ? (source.resumeName || source.jobTitle || 'resume-builder')
+ : (source.resumeName || 'resume-builder')
+ const builderSlug = createSlug(slugSeed)
+
+ const response = await runGeneration((signal) => fetch('/api/generate-resume-latex', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ resumeText: resolvedResumeText,
+ resumeName: source.resumeName,
+ jobDescription: source.jobDescription,
+ templateId: template.templateId,
+ builderSlug,
+ sourceAnalysisId: source.kind === 'analysis' ? source.analysisId : undefined,
+ customTemplateName: template.templateId === 'custom' ? template.customTemplateName : undefined,
+ customTemplateLatex: template.templateId === 'custom' ? template.customTemplateLatex : undefined,
+ idempotencyKey: crypto.randomUUID(),
+ }),
+ signal,
+ }))
+
+ const data = await response.json() as TailoredResumeResponse & { message?: string; error?: string }
+ if (!response.ok) {
+ throw new Error(data.message || data.error || 'Failed to build resume')
+ }
+
+ router.replace(`/dashboard/resume-builder/${data.builderSlug || builderSlug}`)
+ } catch (err) {
+ if (err instanceof Error && err.message.includes('canceled')) {
+ setError('Resume generation canceled')
+ return
+ }
+
+ setError(err instanceof Error ? err.message : 'Failed to build resume')
+ }
+ }
+
+ void run()
+ }, [router, runGeneration])
+
+ if (error) {
+ return (
+
+
+
+
Unable to build resume
+
{error}
+
+
+
+ Back to Step 2
+
+
+
+
+ Restart Flow
+
+
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
+
+
+ {isGenerating ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+ Cancel
+
+
+
+
+ )
+}
diff --git a/src/app/dashboard/resume-builder/page.tsx b/src/app/dashboard/resume-builder/page.tsx
new file mode 100644
index 0000000..917044b
--- /dev/null
+++ b/src/app/dashboard/resume-builder/page.tsx
@@ -0,0 +1,335 @@
+'use client'
+
+import { useUser } from '@stackframe/stack'
+import {
+ AlertCircle,
+ CheckCircle,
+ Clock,
+ FileCode2,
+ Sparkles,
+} from 'lucide-react'
+import { useRouter } from 'next/navigation'
+import { useEffect, useState } from 'react'
+
+import { ResumeSelect } from '@/components/resume/ResumeSelect'
+import { Button } from '@/components/ui/button'
+import { Textarea } from '@/components/ui/textarea'
+import { useResumes } from '@/hooks/useResumes'
+
+type HistoryType = 'analysis' | 'cover-letter'
+type InputMode = 'manual' | 'analysis'
+
+const RESUME_BUILDER_DRAFT_KEY = 'resumeBuilderFlowDraftV1'
+
+interface SearchHistoryItem {
+ id: string
+ type: HistoryType
+ analysisType?: string
+ companyName?: string
+ resumeName?: string
+ jobTitle?: string
+ jobDescription?: string
+ createdAt: string
+}
+
+type RecentAnalysis = SearchHistoryItem & { type: 'analysis' }
+
+type ResumeBuilderSourceDraft =
+ | {
+ kind: 'manual'
+ resumeText: string
+ resumeName: string
+ jobDescription: string
+ }
+ | {
+ kind: 'analysis'
+ analysisId: string
+ resumeName: string
+ jobDescription: string
+ jobTitle?: string
+ companyName?: string
+ }
+
+interface ResumeBuilderDraft {
+ source: ResumeBuilderSourceDraft
+ template?: {
+ templateId: 'jake-classic' | 'deedy-modern' | 'sb2nov-ats' | 'custom'
+ customTemplateName?: string
+ customTemplateLatex?: string
+ }
+}
+
+function readDraft(): ResumeBuilderDraft | null {
+ if (typeof window === 'undefined') {
+ return null
+ }
+
+ const raw = window.sessionStorage.getItem(RESUME_BUILDER_DRAFT_KEY)
+ if (!raw) {
+ return null
+ }
+
+ try {
+ return JSON.parse(raw) as ResumeBuilderDraft
+ } catch {
+ window.sessionStorage.removeItem(RESUME_BUILDER_DRAFT_KEY)
+ return null
+ }
+}
+
+function writeDraft(draft: ResumeBuilderDraft) {
+ if (typeof window === 'undefined') {
+ return
+ }
+ window.sessionStorage.setItem(RESUME_BUILDER_DRAFT_KEY, JSON.stringify(draft))
+}
+
+export default function ResumeBuilderStep1Page() {
+ const user = useUser()
+ const router = useRouter()
+ const { resumes, isLoading: resumesLoading } = useResumes(100)
+
+ const [inputMode, setInputMode] = useState('manual')
+ const [jobDescription, setJobDescription] = useState('')
+ const [resumeText, setResumeText] = useState(null)
+ const [resumeName, setResumeName] = useState(null)
+
+ const [isInitializing, setIsInitializing] = useState(true)
+ const [recentAnalyses, setRecentAnalyses] = useState([])
+ const [error, setError] = useState(null)
+
+ useEffect(() => {
+ const existing = readDraft()
+ if (existing?.source?.kind === 'manual') {
+ setInputMode('manual')
+ setResumeName(existing.source.resumeName)
+ setResumeText(existing.source.resumeText)
+ setJobDescription(existing.source.jobDescription)
+ }
+ }, [])
+
+ useEffect(() => {
+ async function loadRecentAnalyses() {
+ if (!user?.id) {
+ setIsInitializing(false)
+ return
+ }
+
+ try {
+ const response = await fetch('/api/search-history?limit=20')
+ if (!response.ok) {
+ return
+ }
+
+ const data = await response.json()
+ const allHistory: SearchHistoryItem[] = Array.isArray(data.history) ? data.history : []
+ const analyses = allHistory
+ .filter((item): item is RecentAnalysis => item.type === 'analysis' && item.analysisType === 'match')
+ .slice(0, 8)
+
+ setRecentAnalyses(analyses)
+ } catch (loadError) {
+ console.error('Failed to load recent analyses', loadError)
+ } finally {
+ setIsInitializing(false)
+ }
+ }
+
+ void loadRecentAnalyses()
+ }, [user?.id])
+
+ const handleContinueManual = () => {
+ if (!resumeText || !resumeName) {
+ setError('Please select a resume first')
+ return
+ }
+
+ if (!jobDescription.trim()) {
+ setError('Please enter a job description')
+ return
+ }
+
+ writeDraft({
+ source: {
+ kind: 'manual',
+ resumeText,
+ resumeName,
+ jobDescription: jobDescription.trim(),
+ },
+ })
+
+ setError(null)
+ router.push('/dashboard/resume-builder/step-2')
+ }
+
+ const handleUseAnalysis = (analysis: RecentAnalysis) => {
+ if (!analysis.jobDescription || !analysis.resumeName) {
+ setError('This analysis is missing source resume or job description')
+ return
+ }
+
+ const matchedResume = resumes.find((item) => item.name === analysis.resumeName)
+ if (!matchedResume) {
+ setError(`Source resume "${analysis.resumeName}" is not available in your saved resumes`)
+ return
+ }
+
+ writeDraft({
+ source: {
+ kind: 'analysis',
+ analysisId: analysis.id,
+ resumeName: analysis.resumeName,
+ jobDescription: analysis.jobDescription,
+ jobTitle: analysis.jobTitle,
+ companyName: analysis.companyName,
+ },
+ })
+
+ setError(null)
+ router.push('/dashboard/resume-builder/step-2')
+ }
+
+ return (
+
+
+
+
+
+
+
+ Resume Builder • Step 1/3
+
+ Choose your source
+
+ Start with current resume + job description, or select an existing match analysis and reuse its exact resume/job description.
+
+
+
+
+
+
+ setInputMode('manual')}
+ className={`flex-1 rounded-lg px-3 py-2 text-sm font-semibold ${inputMode === 'manual' ? 'bg-primary text-primary-foreground shadow' : 'text-muted-foreground'}`}
+ >
+ Manual Input
+
+ setInputMode('analysis')}
+ className={`flex-1 rounded-lg px-3 py-2 text-sm font-semibold ${inputMode === 'analysis' ? 'bg-primary text-primary-foreground shadow' : 'text-muted-foreground'}`}
+ >
+ Use Previous Analysis
+
+
+
+ {inputMode === 'manual' ? (
+
+
+
Current resume
+ {
+ setResumeText(text)
+ setResumeName(name)
+ }}
+ selectedName={resumeName ?? undefined}
+ />
+
+
+
+
Job description
+
+
+
+
+ Continue to Step 2
+
+
+
+
+ ) : (
+
+ {isInitializing || resumesLoading ? (
+
Loading previous analyses...
+ ) : recentAnalyses.length === 0 ? (
+
No recent match analyses available.
+ ) : (
+ recentAnalyses.map((analysis) => {
+ const resumeExists = resumes.some((item) => item.name === analysis.resumeName)
+ return (
+
+
{analysis.jobTitle || 'Saved analysis'}
+
Resume: {analysis.resumeName || 'Unknown'}
+
+ {analysis.companyName || 'Unknown company'} • {new Date(analysis.createdAt).toLocaleDateString('en-US')}
+
+
+ {resumeExists ? 'Ready: source resume found' : 'Missing source resume in saved resumes'}
+
+
handleUseAnalysis(analysis)}
+ disabled={!resumeExists}
+ >
+ Use This Analysis
+
+
+ )
+ })
+ )}
+
+ )}
+
+ {error && (
+
+ )}
+
+
+
+
+
+
+ )
+}
diff --git a/src/app/dashboard/resume-builder/step-2/page.tsx b/src/app/dashboard/resume-builder/step-2/page.tsx
new file mode 100644
index 0000000..9810896
--- /dev/null
+++ b/src/app/dashboard/resume-builder/step-2/page.tsx
@@ -0,0 +1,460 @@
+'use client'
+
+import {
+ AlertCircle,
+ Check,
+ ChevronLeft,
+ Eye,
+ FileCode2,
+ Loader2,
+ Upload,
+} from 'lucide-react'
+import { useRouter } from 'next/navigation'
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+
+import { Button } from '@/components/ui/button'
+import {
+ buildLatexResume,
+ buildLatexResumeFromCustomTemplate,
+ type BuiltInResumeTemplateId,
+ RESUME_TEMPLATE_OPTIONS,
+ type ResumeTemplateId,
+ TEMPLATE_PREVIEW_DATA,
+} from '@/lib/resume-latex'
+
+type ResumeBuilderSourceDraft =
+ | {
+ kind: 'manual'
+ resumeText: string
+ resumeName: string
+ jobDescription: string
+ }
+ | {
+ kind: 'analysis'
+ analysisId: string
+ resumeName: string
+ jobDescription: string
+ jobTitle?: string
+ companyName?: string
+ }
+
+interface ResumeBuilderDraft {
+ source: ResumeBuilderSourceDraft
+ template?: {
+ templateId: ResumeTemplateId
+ customTemplateName?: string
+ customTemplateLatex?: string
+ }
+}
+
+const RESUME_BUILDER_DRAFT_KEY = 'resumeBuilderFlowDraftV1'
+
+function readDraft(): ResumeBuilderDraft | null {
+ if (typeof window === 'undefined') {
+ return null
+ }
+
+ const raw = window.sessionStorage.getItem(RESUME_BUILDER_DRAFT_KEY)
+ if (!raw) {
+ return null
+ }
+
+ try {
+ return JSON.parse(raw) as ResumeBuilderDraft
+ } catch {
+ window.sessionStorage.removeItem(RESUME_BUILDER_DRAFT_KEY)
+ return null
+ }
+}
+
+function writeDraft(draft: ResumeBuilderDraft) {
+ if (typeof window === 'undefined') {
+ return
+ }
+ window.sessionStorage.setItem(RESUME_BUILDER_DRAFT_KEY, JSON.stringify(draft))
+}
+
+export default function ResumeBuilderStep2Page() {
+ const router = useRouter()
+
+ const [isBooting, setIsBooting] = useState(true)
+ const [draft, setDraft] = useState(null)
+
+ const [templateId, setTemplateId] = useState('jake-classic')
+ const [customTemplateName, setCustomTemplateName] = useState(null)
+ const [customTemplateLatex, setCustomTemplateLatex] = useState(null)
+
+ const [templatePreviewUrls, setTemplatePreviewUrls] = useState>>({})
+ const [templatePreviewLoading, setTemplatePreviewLoading] = useState>>({})
+ const [customPreviewUrl, setCustomPreviewUrl] = useState(null)
+ const [customPreviewLoading, setCustomPreviewLoading] = useState(false)
+ const [viewerUrl, setViewerUrl] = useState(null)
+ const viewerEmbedSrc = viewerUrl ? `${viewerUrl}#page=1&view=FitH&zoom=page-fit&navpanes=0&toolbar=0&scrollbar=0` : null
+
+ const [error, setError] = useState(null)
+
+ const objectUrlsRef = useRef>(new Set())
+
+ const trackUrl = useCallback((url: string) => {
+ objectUrlsRef.current.add(url)
+ return url
+ }, [])
+
+ const revokeUrl = useCallback((url?: string | null) => {
+ if (!url) return
+ if (objectUrlsRef.current.has(url)) {
+ URL.revokeObjectURL(url)
+ objectUrlsRef.current.delete(url)
+ }
+ }, [])
+
+ const renderLatexToPdfUrl = useCallback(async (latexSource: string): Promise => {
+ const response = await fetch('/api/render-latex', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ latexSource }),
+ })
+
+ if (!response.ok) {
+ const data = await response.json().catch(() => ({}))
+ const compileLog = typeof data?.details?.log === 'string' ? data.details.log : ''
+ const logLine = compileLog.split('\n').find((line: string) => line.trim().length > 0)
+ const reason = logLine ? ` ${logLine.slice(0, 180)}` : ''
+ throw new Error(`${data.message || data.error || 'Failed to render preview'}${reason}`)
+ }
+
+ const blob = await response.blob()
+ return trackUrl(URL.createObjectURL(blob))
+ }, [trackUrl])
+
+ useEffect(() => {
+ const loaded = readDraft()
+ if (!loaded?.source) {
+ router.replace('/dashboard/resume-builder')
+ return
+ }
+
+ setDraft(loaded)
+ if (loaded.template) {
+ setTemplateId(loaded.template.templateId)
+ setCustomTemplateName(loaded.template.customTemplateName || null)
+ setCustomTemplateLatex(loaded.template.customTemplateLatex || null)
+ }
+
+ setIsBooting(false)
+ }, [router])
+
+ useEffect(() => {
+ let disposed = false
+
+ async function loadTemplatePreviews() {
+ const templateIds = RESUME_TEMPLATE_OPTIONS.map((template) => template.id)
+ setTemplatePreviewLoading(Object.fromEntries(templateIds.map((id) => [id, true])) as Partial>)
+
+ await Promise.all(templateIds.map(async (id) => {
+ try {
+ const latex = buildLatexResume(id, TEMPLATE_PREVIEW_DATA)
+ const previewUrl = await renderLatexToPdfUrl(latex)
+ if (disposed) {
+ revokeUrl(previewUrl)
+ return
+ }
+
+ setTemplatePreviewUrls((current) => {
+ const previous = current[id]
+ if (previous) {
+ revokeUrl(previous)
+ }
+ return { ...current, [id]: previewUrl }
+ })
+ } catch {
+ // noop
+ } finally {
+ if (!disposed) {
+ setTemplatePreviewLoading((current) => ({ ...current, [id]: false }))
+ }
+ }
+ }))
+ }
+
+ void loadTemplatePreviews()
+
+ return () => {
+ disposed = true
+ }
+ }, [renderLatexToPdfUrl, revokeUrl])
+
+ useEffect(() => {
+ let disposed = false
+
+ async function loadCustomPreview() {
+ if (!customTemplateLatex) {
+ setCustomPreviewUrl((current) => {
+ revokeUrl(current)
+ return null
+ })
+ return
+ }
+
+ setCustomPreviewLoading(true)
+ try {
+ const latex = buildLatexResumeFromCustomTemplate(customTemplateLatex, TEMPLATE_PREVIEW_DATA)
+ const url = await renderLatexToPdfUrl(latex)
+ if (disposed) {
+ revokeUrl(url)
+ return
+ }
+
+ setCustomPreviewUrl((current) => {
+ revokeUrl(current)
+ return url
+ })
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to render custom template preview')
+ } finally {
+ if (!disposed) {
+ setCustomPreviewLoading(false)
+ }
+ }
+ }
+
+ void loadCustomPreview()
+
+ return () => {
+ disposed = true
+ }
+ }, [customTemplateLatex, renderLatexToPdfUrl, revokeUrl])
+
+ useEffect(() => {
+ const tracked = objectUrlsRef.current
+ return () => {
+ for (const url of tracked) {
+ URL.revokeObjectURL(url)
+ }
+ tracked.clear()
+ }
+ }, [])
+
+ const selectedTemplateName = useMemo(() => {
+ if (templateId === 'custom') {
+ return customTemplateName || 'Custom Template'
+ }
+
+ return RESUME_TEMPLATE_OPTIONS.find((template) => template.id === templateId)?.name || 'Template'
+ }, [customTemplateName, templateId])
+
+ const handleCustomTemplateUpload = async (file: File) => {
+ if (!file.name.toLowerCase().endsWith('.tex')) {
+ setError('Please upload a .tex file')
+ return
+ }
+
+ const content = await file.text()
+ setCustomTemplateName(file.name)
+ setCustomTemplateLatex(content)
+ setTemplateId('custom')
+ setError(null)
+ }
+
+ const continueToBuild = () => {
+ if (!draft) {
+ return
+ }
+
+ if (templateId === 'custom' && !customTemplateLatex) {
+ setError('Upload a custom .tex template before continuing')
+ return
+ }
+
+ writeDraft({
+ ...draft,
+ template: {
+ templateId,
+ customTemplateName: templateId === 'custom' ? customTemplateName || undefined : undefined,
+ customTemplateLatex: templateId === 'custom' ? customTemplateLatex || undefined : undefined,
+ },
+ })
+
+ setError(null)
+ router.push('/dashboard/resume-builder/new')
+ }
+
+ if (isBooting) {
+ return (
+
+
+
+ Loading step 2...
+
+
+ )
+ }
+
+ return (
+
+
+
+
+
+
+
+ Resume Builder • Step 2/3
+
+ Choose and preview template
+
+ Pick a built-in template or upload custom `.tex`. Every template can be opened in a larger live preview before generation.
+
+
+
+
+
+
router.push('/dashboard/resume-builder')}>
+
+ Back to Step 1
+
+
+
+
Selected: {selectedTemplateName}
+
+
+
+
+ {RESUME_TEMPLATE_OPTIONS.map((template) => (
+
+
+
+
{template.name}
+
{template.description}
+
+ {templateId === template.id &&
}
+
+
+ {templatePreviewLoading[template.id] ? (
+
+
+ Rendering preview...
+
+ ) : templatePreviewUrls[template.id] ? (
+
+ ) : (
+
+ Preview unavailable
+
+ )}
+
+
+ setTemplateId(template.id)}>
+ {templateId === template.id ? 'Selected' : 'Select'}
+
+ setViewerUrl(templatePreviewUrls[template.id] || null)}
+ disabled={!templatePreviewUrls[template.id]}
+ >
+
+ Open
+
+
+
+ ))}
+
+
+
+
+
+
Custom `.tex` template
+
Upload your own LaTeX template and preview it with sample resume data.
+
+ {templateId === 'custom' &&
}
+
+
+
+
+ {customTemplateName ? `Replace ${customTemplateName}` : 'Upload .tex file'}
+ {
+ const file = event.target.files?.[0]
+ if (file) {
+ void handleCustomTemplateUpload(file)
+ }
+ }}
+ />
+
+
+ {customTemplateName && (
+ Loaded: {customTemplateName}
+ )}
+
+
+ setTemplateId('custom')} disabled={!customTemplateLatex}>
+ {templateId === 'custom' ? 'Selected' : 'Select Custom'}
+
+ setViewerUrl(customPreviewUrl)}
+ disabled={!customPreviewUrl || customPreviewLoading}
+ >
+ {customPreviewLoading ? : }
+ Open Preview
+
+
+
+
+ {error && (
+
+ )}
+
+
+
+ Generate JD Tailored Resume
+
+
+
+
+
+ {viewerUrl && (
+
+
+
+
Template Preview
+
+ window.open(viewerUrl, '_blank', 'noopener,noreferrer')}
+ >
+ Open in New Tab
+
+ setViewerUrl(null)}>
+ Close
+
+
+
+
+
+
+ )}
+
+ )
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 6fa2387..19d4335 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -39,7 +39,7 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
-
+
diff --git a/src/components/dashboard/history/HistoryFilterTabs.tsx b/src/components/dashboard/history/HistoryFilterTabs.tsx
index 34ce0dd..a0938c1 100644
--- a/src/components/dashboard/history/HistoryFilterTabs.tsx
+++ b/src/components/dashboard/history/HistoryFilterTabs.tsx
@@ -12,6 +12,7 @@ export function HistoryFilterTabs({ filter, onChange }: HistoryFilterTabsProps)
{ key: 'all' as const, label: 'All' },
{ key: 'analysis' as const, label: 'Analyses' },
{ key: 'cover-letter' as const, label: 'Cover Letters' },
+ { key: 'resume' as const, label: 'Resumes' },
].map((tab) => (
-
-
-
+
ATS
diff --git a/src/lib/contracts/api.ts b/src/lib/contracts/api.ts
index c0f8a0c..9f77955 100644
--- a/src/lib/contracts/api.ts
+++ b/src/lib/contracts/api.ts
@@ -3,6 +3,7 @@ import { z } from 'zod';
export const toneSchema = z.enum(['professional', 'friendly', 'enthusiastic']);
export const lengthSchema = z.enum(['concise', 'standard', 'detailed']);
export const analysisTypeSchema = z.enum(['overview', 'keywords', 'match', 'coverLetter']);
+export const resumeTemplateIdSchema = z.enum(['jake-classic', 'deedy-modern', 'sb2nov-ats', 'custom']);
const freeTextSchema = z
.string()
@@ -52,6 +53,19 @@ export const coverLetterRequestSchema = analyzeRequestSchema
length: lengthSchema.default('standard'),
});
+export const tailoredResumeRequestSchema = z.object({
+ resumeText: z.string().trim().min(1, 'Resume text is required').max(50000, 'Resume text is too long (max 50,000 characters)'),
+ jobDescription: z.string().trim().min(1, 'Job description is required').max(15000, 'Job description is too long (max 15,000 characters)'),
+ templateId: resumeTemplateIdSchema.default('jake-classic'),
+ resumeName: optionalFreeTextSchema,
+ builderSlug: z.string().trim().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'Invalid builder slug format').min(4).max(120).optional(),
+ sourceAnalysisId: z.string().trim().min(1).max(128).optional(),
+ customTemplateName: optionalFreeTextSchema,
+ customTemplateLatex: z.string().trim().min(1).max(180000).optional(),
+ forceRegenerate: z.boolean().optional(),
+ idempotencyKey: z.string().trim().min(8).max(128).optional(),
+});
+
export const paginationSchema = z.object({
limit: z.coerce.number().int().min(1).max(100).default(50),
cursor: z.string().trim().min(1).optional(),
@@ -66,12 +80,15 @@ export const apiErrorSchema = z.object({
export const historyItemSchema = z.object({
id: z.string(),
- type: z.enum(['analysis', 'cover-letter']),
+ type: z.enum(['analysis', 'cover-letter', 'resume']),
analysisType: z.string().optional(),
companyName: z.string().optional(),
resumeName: z.string().optional(),
jobTitle: z.string().optional(),
jobDescription: z.string().optional(),
+ templateId: z.string().optional(),
+ builderSlug: z.string().optional(),
+ version: z.number().optional(),
createdAt: z.string(),
result: z.string(),
});
@@ -101,6 +118,18 @@ export const coverLetterResponseSchema = z.object({
requestId: z.string(),
});
+export const tailoredResumeResponseSchema = z.object({
+ latexSource: z.string(),
+ structuredData: z.record(z.string(), z.unknown()),
+ templateId: resumeTemplateIdSchema,
+ cached: z.boolean(),
+ source: z.enum(['database']).optional(),
+ documentId: z.string().optional(),
+ builderSlug: z.string().optional(),
+ version: z.number().optional(),
+ requestId: z.string(),
+});
+
export const resumesResponseSchema = z.object({
resumes: z.array(
z.object({
@@ -137,5 +166,6 @@ export const draftResponseSchema = z.object({
export type AnalyzeRequest = z.infer
;
export type CoverLetterRequest = z.infer;
+export type TailoredResumeRequest = z.infer;
export type PaginationInput = z.infer;
export type ApiError = z.infer;
diff --git a/src/lib/convex-server.ts b/src/lib/convex-server.ts
index ce1d568..b895c3e 100644
--- a/src/lib/convex-server.ts
+++ b/src/lib/convex-server.ts
@@ -13,10 +13,15 @@ const convexFunctions = {
getUserAnalyses: "functions:getUserAnalyses",
getUserCoverLetters: "functions:getUserCoverLetters",
getUserResumes: "functions:getUserResumes",
+ getUserTailoredResumes: "functions:getUserTailoredResumes",
+ getTailoredResumeVersionsBySlug: "functions:getTailoredResumeVersionsBySlug",
getUserStats: "functions:getUserStats",
saveAnalysis: "functions:saveAnalysis",
saveCoverLetter: "functions:saveCoverLetter",
saveResume: "functions:saveResume",
+ getTailoredResume: "functions:getTailoredResume",
+ getTailoredResumeById: "functions:getTailoredResumeById",
+ saveTailoredResume: "functions:saveTailoredResume",
} as const;
type ConvexClient = ConvexHttpClient & {
@@ -82,16 +87,39 @@ export interface Resume {
export interface SearchHistoryItem {
id: string;
- type: "analysis" | "cover-letter";
+ type: "analysis" | "cover-letter" | "resume";
analysisType?: string;
companyName?: string;
resumeName?: string;
jobTitle?: string;
jobDescription?: string;
+ templateId?: string;
+ builderSlug?: string;
+ version?: number;
createdAt: string;
result: string;
}
+export interface TailoredResume {
+ _id: string;
+ _creationTime: number;
+ userId: string;
+ resumeHash: string;
+ jobDescriptionHash: string;
+ templateId: string;
+ jobTitle?: string;
+ companyName?: string;
+ resumeName?: string;
+ jobDescription?: string;
+ structuredData: string;
+ latexSource: string;
+ builderSlug?: string;
+ version?: number;
+ sourceAnalysisId?: string;
+ customTemplateName?: string;
+ customTemplateSource?: string;
+}
+
// Helper: Generate hash for caching
export function generateHash(text: string): string {
return crypto.createHash("sha256").update(text).digest("hex");
@@ -302,6 +330,88 @@ export async function getUserCoverLetters(
}
}
+// ─── Tailored Resume Functions ───────────────────────────────────────
+
+export async function saveTailoredResume(
+ data: Omit
+): Promise {
+ const client = getClient();
+ const result = await client.mutation(convexFunctions.saveTailoredResume, data);
+ return result as unknown as TailoredResume;
+}
+
+export async function getTailoredResume(
+ userId: string,
+ resumeHash: string,
+ jobDescriptionHash: string,
+ templateId: string
+): Promise {
+ try {
+ const client = getClient();
+ const doc = await client.query(convexFunctions.getTailoredResume, {
+ userId,
+ resumeHash,
+ jobDescriptionHash,
+ templateId,
+ });
+ return doc as unknown as TailoredResume | null;
+ } catch (error) {
+ console.error("Error fetching tailored resume:", error);
+ return null;
+ }
+}
+
+export async function getTailoredResumeById(
+ tailoredResumeId: string
+): Promise {
+ try {
+ const client = getClient();
+ const doc = await client.query(convexFunctions.getTailoredResumeById, {
+ tailoredResumeId: tailoredResumeId as Id<"tailoredResumes">,
+ });
+ return doc as unknown as TailoredResume | null;
+ } catch (error) {
+ console.error("Error fetching tailored resume by id:", error);
+ return null;
+ }
+}
+
+export async function getUserTailoredResumes(
+ userId: string,
+ limit = 20
+): Promise {
+ try {
+ const client = getClient();
+ const docs = await client.query(convexFunctions.getUserTailoredResumes, {
+ userId,
+ limit,
+ });
+ return docs as unknown as TailoredResume[];
+ } catch (error) {
+ console.error("Error fetching user tailored resumes:", error);
+ return [];
+ }
+}
+
+export async function getTailoredResumeVersionsBySlug(
+ userId: string,
+ builderSlug: string,
+ limit = 30,
+): Promise {
+ try {
+ const client = getClient();
+ const docs = await client.query(convexFunctions.getTailoredResumeVersionsBySlug, {
+ userId,
+ builderSlug,
+ limit,
+ });
+ return docs as unknown as TailoredResume[];
+ } catch (error) {
+ console.error("Error fetching tailored resume versions by slug:", error);
+ return [];
+ }
+}
+
// ─── Stats ───────────────────────────────────────────────────────────
export async function getUserStats(userId: string): Promise<{
diff --git a/src/lib/gemini.ts b/src/lib/gemini.ts
index 038988b..e4a5315 100644
--- a/src/lib/gemini.ts
+++ b/src/lib/gemini.ts
@@ -140,6 +140,64 @@ interface AnalysisOptions {
achievements?: string;
}
+export interface TailoredResumeSectionItem {
+ title: string;
+ subtitle?: string;
+ date?: string;
+ location?: string;
+ bullets: string[];
+}
+
+export interface TailoredResumeData {
+ fullName?: string;
+ email?: string;
+ phone?: string;
+ location?: string;
+ linkedin?: string;
+ github?: string;
+ website?: string;
+ summary: string;
+ skills: string[];
+ experience: TailoredResumeSectionItem[];
+ projects: TailoredResumeSectionItem[];
+ education: TailoredResumeSectionItem[];
+ certifications: string[];
+ additional: string[];
+ targetTitle?: string;
+ keywordsUsed: string[];
+}
+
+function createGeminiModel(analysisType: AnalysisType | 'tailoredResume' | 'latexFix') {
+ const modelName = process.env.MODEL_NAME || 'gemini-2.5-flash';
+ return genAI.getGenerativeModel({
+ model: modelName,
+ generationConfig: {
+ temperature: analysisType === 'coverLetter' ? 0.8 : analysisType === 'latexFix' ? 0.2 : 0.4,
+ maxOutputTokens: 16384,
+ },
+ });
+}
+
+function stripMarkdownJsonFence(input: string): string {
+ const trimmed = input.trim();
+ if (!trimmed.startsWith('```')) {
+ return trimmed;
+ }
+
+ const withoutStart = trimmed.replace(/^```(?:json)?\s*/i, '');
+ return withoutStart.replace(/\s*```$/, '').trim();
+}
+
+function stripMarkdownCodeFence(input: string): string {
+ const trimmed = input.trim();
+ if (!trimmed.startsWith('```')) {
+ return trimmed;
+ }
+
+ const withoutStart = trimmed.replace(/^```(?:latex|tex|text)?\s*/i, '');
+ return withoutStart.replace(/\s*```$/, '').trim();
+}
+
export async function analyzeResume(
resumeText: string,
jobDescription: string,
@@ -150,16 +208,7 @@ export async function analyzeResume(
throw new Error('Google Gemini API key is not configured. Please set GOOGLE_API_KEY environment variable.');
}
- // Using gemini-2.5-flash (thinking model) - requires higher token limits
- // as thinking tokens count against maxOutputTokens
- const modelName = process.env.MODEL_NAME || 'gemini-2.5-flash';
- const model = genAI.getGenerativeModel({
- model: modelName,
- generationConfig: {
- temperature: analysisType === 'coverLetter' ? 0.8 : 0.4,
- maxOutputTokens: 16384, // Higher limit to accommodate thinking + response
- },
- });
+ const model = createGeminiModel(analysisType);
let prompt: string;
@@ -216,3 +265,125 @@ ${jobDescription}`;
throw error;
}
}
+
+export async function generateTailoredResumeData(
+ resumeText: string,
+ jobDescription: string
+): Promise {
+ if (!apiKey) {
+ throw new Error('Google Gemini API key is not configured. Please set GOOGLE_API_KEY environment variable.');
+ }
+
+ const model = createGeminiModel('tailoredResume');
+ const prompt = `You are an expert ATS resume writer.
+Generate tailored resume content from the SOURCE RESUME and JOB DESCRIPTION.
+
+CRITICAL RULES:
+1) Use only information from SOURCE RESUME. Do not invent employers, dates, degrees, certifications, metrics, or technologies.
+2) Focus on relevance to the JOB DESCRIPTION keywords and responsibilities.
+3) Keep bullets concise and impact-focused.
+4) Return ONLY valid JSON, no markdown, no commentary.
+
+Return exactly this shape:
+{
+ "fullName": "string or empty",
+ "email": "string or empty",
+ "phone": "string or empty",
+ "location": "string or empty",
+ "linkedin": "string or empty",
+ "github": "string or empty",
+ "website": "string or empty",
+ "summary": "2-4 line professional summary",
+ "skills": ["max 18 skills ordered by relevance"],
+ "experience": [
+ {
+ "title": "Role title",
+ "subtitle": "Company",
+ "date": "Date range",
+ "location": "Location",
+ "bullets": ["3-5 bullets"]
+ }
+ ],
+ "projects": [
+ {
+ "title": "Project name",
+ "subtitle": "Tech stack or context",
+ "date": "Date range or empty",
+ "location": "Location or empty",
+ "bullets": ["2-4 bullets"]
+ }
+ ],
+ "education": [
+ {
+ "title": "Degree",
+ "subtitle": "Institution",
+ "date": "Date range",
+ "location": "Location",
+ "bullets": ["optional bullets, may be empty"]
+ }
+ ],
+ "certifications": ["optional"],
+ "additional": ["optional extras like awards/publications"],
+ "targetTitle": "best-fit role title from job description",
+ "keywordsUsed": ["important JD keywords reflected in the resume content"]
+}
+
+SOURCE RESUME:
+${resumeText}
+
+JOB DESCRIPTION:
+${jobDescription}`;
+
+ const result = await model.generateContent(prompt);
+ const text = result.response.text();
+
+ if (!text || text.trim() === '') {
+ throw new Error('AI returned an empty response. Please try again.');
+ }
+
+ const normalized = stripMarkdownJsonFence(text);
+ try {
+ return JSON.parse(normalized) as TailoredResumeData;
+ } catch (error) {
+ console.error('Failed to parse tailored resume JSON', { error, text: normalized });
+ throw new Error('AI returned invalid structured resume data. Please try again.');
+ }
+}
+
+export async function fixLatexCompilationError(
+ latexSource: string,
+ compileLog?: string,
+): Promise {
+ if (!apiKey) {
+ throw new Error('Google Gemini API key is not configured. Please set GOOGLE_API_KEY environment variable.');
+ }
+
+ const model = createGeminiModel('latexFix');
+ const boundedLog = (compileLog || '').slice(0, 12000);
+
+ const prompt = `You are a strict LaTeX repair assistant.
+Fix the provided .tex source so it compiles with pdflatex.
+
+Rules:
+1) Return ONLY the full corrected LaTeX source.
+2) Do not wrap output in markdown fences.
+3) Preserve document content and structure as much as possible.
+4) Apply minimal safe fixes for compilation errors (escape special chars like &, %, _, # when needed).
+5) Keep class/packages unless they directly break compile.
+
+Compiler log:
+${boundedLog || '(no compiler log provided)'}
+
+Original LaTeX:
+${latexSource}`;
+
+ const result = await model.generateContent(prompt);
+ const text = result.response.text();
+ const normalized = stripMarkdownCodeFence(text);
+
+ if (!normalized || normalized.trim().length === 0) {
+ throw new Error('AI returned an empty LaTeX fix response.');
+ }
+
+ return normalized;
+}
diff --git a/src/lib/latex-render.ts b/src/lib/latex-render.ts
new file mode 100644
index 0000000..51ad175
--- /dev/null
+++ b/src/lib/latex-render.ts
@@ -0,0 +1,67 @@
+const DEFAULT_RENDER_API_BASE = 'https://latexonline.cc';
+const URL_MODE_MAX_SOURCE_LENGTH = 6000;
+
+export function getLatexRenderApiBase(): string {
+ return (process.env.LATEX_RENDER_API_BASE || DEFAULT_RENDER_API_BASE).replace(/\/+$/, '');
+}
+
+export function buildLatexCompileUrl(latexSource: string): string {
+ const base = getLatexRenderApiBase();
+ return `${base}/compile?text=${encodeURIComponent(latexSource)}`;
+}
+
+function octalField(value: number, width: number): string {
+ const octal = value.toString(8);
+ return octal.padStart(width - 1, '0') + '\0';
+}
+
+// Build a minimal POSIX tar archive with a single file.
+function createSingleFileTar(filename: string, content: Buffer): Buffer {
+ const header = Buffer.alloc(512, 0);
+ header.write(filename.slice(0, 100), 0, 'utf8');
+ header.write(octalField(0o644, 8), 100, 'ascii');
+ header.write(octalField(0, 8), 108, 'ascii');
+ header.write(octalField(0, 8), 116, 'ascii');
+ header.write(octalField(content.length, 12), 124, 'ascii');
+ header.write(octalField(Math.floor(Date.now() / 1000), 12), 136, 'ascii');
+ header.fill(0x20, 148, 156);
+ header.write('0', 156, 'ascii');
+ header.write('ustar\0', 257, 'ascii');
+ header.write('00', 263, 'ascii');
+
+ let checksum = 0;
+ for (let i = 0; i < 512; i += 1) {
+ checksum += header[i];
+ }
+ const checksumText = checksum.toString(8).padStart(6, '0');
+ header.write(checksumText, 148, 'ascii');
+ header[154] = 0;
+ header[155] = 0x20;
+
+ const paddedContentLength = Math.ceil(content.length / 512) * 512;
+ const paddedContent = Buffer.alloc(paddedContentLength, 0);
+ content.copy(paddedContent, 0);
+
+ return Buffer.concat([header, paddedContent, Buffer.alloc(1024, 0)]);
+}
+
+export function shouldUseLatexUploadMode(latexSource: string): boolean {
+ return latexSource.length > URL_MODE_MAX_SOURCE_LENGTH;
+}
+
+export async function compileLatexViaUpload(latexSource: string): Promise {
+ const base = getLatexRenderApiBase();
+ const tarBuffer = createSingleFileTar('main.tex', Buffer.from(latexSource, 'utf8'));
+ const tarArrayBuffer = tarBuffer.buffer.slice(
+ tarBuffer.byteOffset,
+ tarBuffer.byteOffset + tarBuffer.byteLength,
+ ) as ArrayBuffer;
+ const formData = new FormData();
+ formData.append('file', new Blob([tarArrayBuffer], { type: 'application/x-tar' }), 'main.tar');
+
+ return fetch(`${base}/data?target=main.tex`, {
+ method: 'POST',
+ body: formData,
+ cache: 'no-store',
+ });
+}
diff --git a/src/lib/resume-latex.ts b/src/lib/resume-latex.ts
new file mode 100644
index 0000000..97a2c10
--- /dev/null
+++ b/src/lib/resume-latex.ts
@@ -0,0 +1,463 @@
+import type { TailoredResumeData, TailoredResumeSectionItem } from '@/lib/gemini';
+
+export const BUILT_IN_RESUME_TEMPLATE_IDS = ['jake-classic', 'deedy-modern', 'sb2nov-ats'] as const;
+export const JAKE_CLASSIC_TEMPLATE_PUBLIC_PATH = "/jake's_resume.tex";
+
+export type BuiltInResumeTemplateId = (typeof BUILT_IN_RESUME_TEMPLATE_IDS)[number];
+export type ResumeTemplateId = BuiltInResumeTemplateId | 'custom';
+
+export interface ResumeTemplateOption {
+ id: BuiltInResumeTemplateId;
+ name: string;
+ description: string;
+ atsFriendly: boolean;
+}
+
+export const RESUME_TEMPLATE_OPTIONS: ResumeTemplateOption[] = [
+ {
+ id: 'jake-classic',
+ name: "Jake's Resume",
+ description: "Jake Gutierrez's classic one-page LaTeX resume template (public/jake's_resume.tex).",
+ atsFriendly: true,
+ },
+ {
+ id: 'deedy-modern',
+ name: 'Deedy Modern',
+ description: 'Dense one-page format inspired by Deedy-style technical resumes.',
+ atsFriendly: true,
+ },
+ {
+ id: 'sb2nov-ats',
+ name: 'SB2Nov ATS',
+ description: 'Simple ATS-friendly formatting with minimal visual noise.',
+ atsFriendly: true,
+ },
+];
+
+function cleanList(values: string[] | undefined, maxItems: number): string[] {
+ return (values ?? [])
+ .map((value) => value.trim())
+ .filter(Boolean)
+ .slice(0, maxItems);
+}
+
+function cleanSectionItems(items: TailoredResumeSectionItem[] | undefined, maxItems: number): TailoredResumeSectionItem[] {
+ return (items ?? [])
+ .map((item) => ({
+ title: item.title?.trim() ?? '',
+ subtitle: item.subtitle?.trim() || undefined,
+ date: item.date?.trim() || undefined,
+ location: item.location?.trim() || undefined,
+ bullets: cleanList(item.bullets, 6),
+ }))
+ .filter((item) => item.title.length > 0)
+ .slice(0, maxItems);
+}
+
+function normalizeLatexText(input: string): string {
+ return input
+ .replace(/\r\n?/g, '\n')
+ .replace(/\u00A0/g, ' ')
+ .replace(/[‘’]/g, '\'')
+ .replace(/[“”]/g, '"')
+ .replace(/[–—]/g, '-')
+ .replace(/•/g, '-')
+ .replace(/…/g, '...')
+ .replace(/[^\x09\x0A\x0D\x20-\x7E]/g, '');
+}
+
+export function escapeLatex(input: string): string {
+ return normalizeLatexText(input)
+ .replace(/\\/g, '\\textbackslash{}')
+ .replace(/&/g, '\\&')
+ .replace(/%/g, '\\%')
+ .replace(/\$/g, '\\$')
+ .replace(/#/g, '\\#')
+ .replace(/_/g, '\\_')
+ .replace(/{/g, '\\{')
+ .replace(/}/g, '\\}')
+ .replace(/~/g, '\\textasciitilde{}')
+ .replace(/\^/g, '\\textasciicircum{}');
+}
+
+function renderBullets(items: string[]): string {
+ if (items.length === 0) {
+ return '';
+ }
+
+ const rows = items.map((item) => `\\item ${escapeLatex(item)}`).join('\n');
+ return `\\begin{itemize}[leftmargin=*, itemsep=2pt, topsep=3pt]\n${rows}\n\\end{itemize}`;
+}
+
+function renderEntry(entry: TailoredResumeSectionItem): string {
+ const headerParts = [entry.title, entry.subtitle].filter(Boolean).map((value) => `\\textbf{${escapeLatex(value as string)}}`);
+ const left = headerParts.join(' -- ');
+ const rightParts = [entry.location, entry.date].filter(Boolean).map((value) => escapeLatex(value as string));
+ const right = rightParts.join(' | ');
+
+ const header = right.length > 0
+ ? `\\textbf{${escapeLatex(entry.title)}} ${entry.subtitle ? `\\textit{${escapeLatex(entry.subtitle)}}` : ''} \\hfill ${right}`
+ : left;
+
+ const bullets = renderBullets(entry.bullets);
+ return `${header}\n${bullets}`.trim();
+}
+
+function renderJakeBullets(items: string[]): string {
+ if (items.length === 0) {
+ return '';
+ }
+
+ const rows = items.map((item) => ` \\resumeItem{${escapeLatex(item)}}`).join('\n');
+ return ` \\resumeItemListStart\n${rows}\n \\resumeItemListEnd`;
+}
+
+function renderJakeEntry(entry: TailoredResumeSectionItem): string {
+ const topRight = entry.date ? escapeLatex(entry.date) : '';
+ const bottomLeft = entry.subtitle ? escapeLatex(entry.subtitle) : '';
+ const bottomRight = entry.location ? escapeLatex(entry.location) : '';
+
+ const heading = ` \\resumeSubheading\n {${escapeLatex(entry.title)}}{${topRight}}\n {${bottomLeft}}{${bottomRight}}`;
+ const bullets = renderJakeBullets(entry.bullets);
+ return bullets ? `${heading}\n${bullets}` : heading;
+}
+
+function renderJakeEntrySection(title: string, entries: TailoredResumeSectionItem[]): string {
+ if (entries.length === 0) {
+ return '';
+ }
+
+ return `\\section{${escapeLatex(title)}}\n \\resumeSubHeadingListStart\n${entries.map(renderJakeEntry).join('\n\n')}\n \\resumeSubHeadingListEnd`;
+}
+
+function renderJakeProjectEntry(entry: TailoredResumeSectionItem): string {
+ const subtitle = entry.subtitle ? ` $|$ \\emph{${escapeLatex(entry.subtitle)}}` : '';
+ const date = entry.date ? escapeLatex(entry.date) : '';
+ const heading = ` \\resumeProjectHeading\n {\\textbf{${escapeLatex(entry.title)}}${subtitle}}{${date}}`;
+ const bullets = renderJakeBullets(entry.bullets);
+ return bullets ? `${heading}\n${bullets}` : heading;
+}
+
+function renderJakeProjectsSection(entries: TailoredResumeSectionItem[]): string {
+ if (entries.length === 0) {
+ return '';
+ }
+
+ return `\\section{Projects}\n \\resumeSubHeadingListStart\n${entries.map(renderJakeProjectEntry).join('\n\n')}\n \\resumeSubHeadingListEnd`;
+}
+
+function renderSection(title: string, content: string): string {
+ if (!content.trim()) {
+ return '';
+ }
+
+ return `\\section*{${escapeLatex(title)}}\n${content}`;
+}
+
+function renderSkills(skills: string[]): string {
+ if (skills.length === 0) {
+ return '';
+ }
+
+ return escapeLatex(skills.join(' | '));
+}
+
+function renderContact(data: TailoredResumeData): string {
+ const parts = [data.email, data.phone, data.location, data.linkedin, data.github, data.website]
+ .filter((value): value is string => Boolean(value && value.trim()))
+ .map((value) => escapeLatex(value.trim()));
+
+ return parts.join(' \\textbar{} ');
+}
+
+function buildHref(value: string): string {
+ const trimmed = normalizeLatexText(value).trim();
+ if (!trimmed) {
+ return '';
+ }
+
+ if (/^(https?:\/\/|mailto:)/i.test(trimmed)) {
+ return trimmed;
+ }
+
+ return `https://${trimmed}`;
+}
+
+function renderJakeContact(data: TailoredResumeData): string {
+ const parts: string[] = [];
+
+ if (data.phone?.trim()) {
+ parts.push(escapeLatex(data.phone.trim()));
+ }
+
+ if (data.email?.trim()) {
+ const email = data.email.trim();
+ parts.push(`\\href{mailto:${email}}{\\underline{${escapeLatex(email)}}}`);
+ }
+
+ for (const value of [data.linkedin, data.github, data.website]) {
+ if (!value?.trim()) {
+ continue;
+ }
+
+ const trimmed = value.trim();
+ parts.push(`\\href{${buildHref(trimmed)}}{\\underline{${escapeLatex(trimmed)}}}`);
+ }
+
+ return parts.join(' $|$ ');
+}
+
+function renderBody(templateId: BuiltInResumeTemplateId, data: TailoredResumeData): string {
+ const summary = data.summary.trim();
+ const skills = cleanList(data.skills, 18);
+ const experience = cleanSectionItems(data.experience, 5);
+ const projects = cleanSectionItems(data.projects, 4);
+ const education = cleanSectionItems(data.education, 3);
+ const certifications = cleanList(data.certifications, 8);
+ const additional = cleanList(data.additional, 8);
+
+ if (templateId === 'jake-classic') {
+ const skillsInline = skills.map((item) => escapeLatex(item)).join(', ');
+ const sections = [
+ renderJakeEntrySection('Education', education),
+ renderJakeEntrySection('Experience', experience),
+ renderJakeProjectsSection(projects),
+ skills.length > 0
+ ? `\\section{Technical Skills}\n \\begin{itemize}[leftmargin=0.15in, label={}]\n \\small{\\item{\\textbf{Skills}{: ${skillsInline}}}}\n \\end{itemize}`
+ : '',
+ ].filter(Boolean);
+
+ return sections.join('\n\n');
+ }
+
+ const sections = [
+ summary ? renderSection('Summary', escapeLatex(summary)) : '',
+ renderSection('Skills', renderSkills(skills)),
+ renderSection('Experience', experience.map(renderEntry).join('\n\n')),
+ renderSection('Projects', projects.map(renderEntry).join('\n\n')),
+ renderSection('Education', education.map(renderEntry).join('\n\n')),
+ certifications.length > 0 ? renderSection('Certifications', certifications.map((item) => `\\textbullet{} ${escapeLatex(item)}`).join('\\\\\n')) : '',
+ additional.length > 0 ? renderSection('Additional', additional.map((item) => `\\textbullet{} ${escapeLatex(item)}`).join('\\\\\n')) : '',
+ ].filter(Boolean);
+
+ return sections.join('\n\n');
+}
+
+function buildTemplatePreamble(templateId: BuiltInResumeTemplateId): string {
+ if (templateId === 'deedy-modern') {
+ return `\\documentclass[11pt]{article}
+\\usepackage[margin=0.65in]{geometry}
+\\usepackage{enumitem}
+\\usepackage[T1]{fontenc}
+\\usepackage[utf8]{inputenc}
+\\usepackage{lmodern}
+\\setlength{\\parindent}{0pt}
+\\setlength{\\parskip}{5pt}
+\\pagenumbering{gobble}
+\\begin{document}`;
+ }
+
+ if (templateId === 'sb2nov-ats') {
+ return `\\documentclass[11pt]{article}
+\\usepackage[margin=0.75in]{geometry}
+\\usepackage{enumitem}
+\\usepackage[T1]{fontenc}
+\\usepackage[utf8]{inputenc}
+\\usepackage{helvet}
+\\renewcommand{\\familydefault}{\\sfdefault}
+\\setlength{\\parindent}{0pt}
+\\setlength{\\parskip}{4pt}
+\\pagenumbering{gobble}
+\\begin{document}`;
+ }
+
+ return `\\documentclass[letterpaper,11pt]{article}
+\\usepackage{latexsym}
+\\usepackage[empty]{fullpage}
+\\usepackage{titlesec}
+\\usepackage{marvosym}
+\\usepackage[usenames,dvipsnames]{color}
+\\usepackage{verbatim}
+\\usepackage{enumitem}
+\\usepackage[hidelinks]{hyperref}
+\\usepackage{fancyhdr}
+\\usepackage[english]{babel}
+\\usepackage{tabularx}
+\\usepackage[T1]{fontenc}
+\\usepackage[utf8]{inputenc}
+\\input{glyphtounicode}
+\\pagestyle{fancy}
+\\fancyhf{}
+\\fancyfoot{}
+\\renewcommand{\\headrulewidth}{0pt}
+\\renewcommand{\\footrulewidth}{0pt}
+\\addtolength{\\oddsidemargin}{-0.5in}
+\\addtolength{\\evensidemargin}{-0.5in}
+\\addtolength{\\textwidth}{1in}
+\\addtolength{\\topmargin}{-.5in}
+\\addtolength{\\textheight}{1.0in}
+\\urlstyle{same}
+\\raggedbottom
+\\raggedright
+\\setlength{\\tabcolsep}{0in}
+\\titleformat{\\section}{\\vspace{-4pt}\\scshape\\raggedright\\large}{}{0em}{}[\\color{black}\\titlerule \\vspace{-5pt}]
+\\pdfgentounicode=1
+\\newcommand{\\resumeItem}[1]{\\item\\small{{#1 \\vspace{-2pt}}}}
+\\newcommand{\\resumeSubheading}[4]{\\vspace{-2pt}\\item\\begin{tabular*}{0.97\\textwidth}[t]{l@{\\extracolsep{\\fill}}r}\\textbf{#1} & #2 \\\\\\textit{\\small#3} & \\textit{\\small #4} \\\\\\end{tabular*}\\vspace{-7pt}}
+\\newcommand{\\resumeSubSubheading}[2]{\\item\\begin{tabular*}{0.97\\textwidth}{l@{\\extracolsep{\\fill}}r}\\textit{\\small#1} & \\textit{\\small #2} \\\\\\end{tabular*}\\vspace{-7pt}}
+\\newcommand{\\resumeProjectHeading}[2]{\\item\\begin{tabular*}{0.97\\textwidth}{l@{\\extracolsep{\\fill}}r}\\small#1 & #2 \\\\\\end{tabular*}\\vspace{-7pt}}
+\\newcommand{\\resumeSubItem}[1]{\\resumeItem{#1}\\vspace{-4pt}}
+\\renewcommand\\labelitemii{$\\vcenter{\\hbox{\\tiny$\\bullet$}}$}
+\\newcommand{\\resumeSubHeadingListStart}{\\begin{itemize}[leftmargin=0.15in, label={}]}
+\\newcommand{\\resumeSubHeadingListEnd}{\\end{itemize}}
+\\newcommand{\\resumeItemListStart}{\\begin{itemize}}
+\\newcommand{\\resumeItemListEnd}{\\end{itemize}\\vspace{-5pt}}
+\\pagenumbering{gobble}
+\\begin{document}`;
+}
+
+export function buildLatexResume(templateId: BuiltInResumeTemplateId, rawData: TailoredResumeData): string {
+ const data: TailoredResumeData = {
+ fullName: rawData.fullName?.trim(),
+ email: rawData.email?.trim(),
+ phone: rawData.phone?.trim(),
+ location: rawData.location?.trim(),
+ linkedin: rawData.linkedin?.trim(),
+ github: rawData.github?.trim(),
+ website: rawData.website?.trim(),
+ summary: rawData.summary?.trim() ?? '',
+ skills: rawData.skills ?? [],
+ experience: rawData.experience ?? [],
+ projects: rawData.projects ?? [],
+ education: rawData.education ?? [],
+ certifications: rawData.certifications ?? [],
+ additional: rawData.additional ?? [],
+ targetTitle: rawData.targetTitle?.trim(),
+ keywordsUsed: rawData.keywordsUsed ?? [],
+ };
+
+ const name = escapeLatex(data.fullName || 'Candidate Name');
+ const contact = renderContact(data);
+ const headline = data.targetTitle?.trim() ? `\\textit{${escapeLatex(data.targetTitle.trim())}}` : '';
+ const body = renderBody(templateId, data);
+
+ const preamble = buildTemplatePreamble(templateId);
+
+ if (templateId === 'jake-classic') {
+ const contactParts = renderJakeContact(data);
+ return `${preamble}
+\\begin{center}
+ \\textbf{\\Huge \\scshape ${name}} \\\\ \\vspace{1pt}
+ ${contactParts ? `\\small ${contactParts}` : ''}
+\\end{center}
+
+${body}
+
+\\end{document}
+`;
+ }
+
+ return `${preamble}
+\\begin{center}
+{\\LARGE \\textbf{${name}}}\\\\
+${headline}
+${contact ? `${contact}\\\\` : ''}
+\\end{center}
+
+${body}
+
+\\end{document}
+`;
+}
+
+function toJsonString(value: unknown): string {
+ return JSON.stringify(value, null, 2);
+}
+
+export function buildLatexResumeFromCustomTemplate(templateSource: string, rawData: TailoredResumeData): string {
+ const builtInFallback = buildLatexResume('jake-classic', rawData);
+ const experience = cleanSectionItems(rawData.experience, 6);
+ const projects = cleanSectionItems(rawData.projects, 6);
+ const education = cleanSectionItems(rawData.education, 4);
+ const skills = cleanList(rawData.skills, 30);
+ const certifications = cleanList(rawData.certifications, 15);
+ const additional = cleanList(rawData.additional, 15);
+
+ const replacements: Record = {
+ '{{fullName}}': escapeLatex(rawData.fullName?.trim() || ''),
+ '{{email}}': escapeLatex(rawData.email?.trim() || ''),
+ '{{phone}}': escapeLatex(rawData.phone?.trim() || ''),
+ '{{location}}': escapeLatex(rawData.location?.trim() || ''),
+ '{{linkedin}}': escapeLatex(rawData.linkedin?.trim() || ''),
+ '{{github}}': escapeLatex(rawData.github?.trim() || ''),
+ '{{website}}': escapeLatex(rawData.website?.trim() || ''),
+ '{{summary}}': escapeLatex(rawData.summary?.trim() || ''),
+ '{{targetTitle}}': escapeLatex(rawData.targetTitle?.trim() || ''),
+ '{{skills}}': escapeLatex(skills.join(', ')),
+ '{{skills_latex}}': renderSkills(skills),
+ '{{experience_entries}}': experience.map(renderEntry).join('\n\n'),
+ '{{projects_entries}}': projects.map(renderEntry).join('\n\n'),
+ '{{education_entries}}': education.map(renderEntry).join('\n\n'),
+ '{{certifications}}': certifications.map((item) => `\\textbullet{} ${escapeLatex(item)}`).join('\\\\\n'),
+ '{{additional}}': additional.map((item) => `\\textbullet{} ${escapeLatex(item)}`).join('\\\\\n'),
+ '{{keywordsUsed}}': escapeLatex((rawData.keywordsUsed ?? []).join(', ')),
+ '{{structuredDataJson}}': escapeLatex(toJsonString(rawData)),
+ '{{generated_resume}}': builtInFallback,
+ };
+
+ let output = templateSource;
+ for (const [placeholder, value] of Object.entries(replacements)) {
+ output = output.split(placeholder).join(value);
+ }
+ return output;
+}
+
+export const TEMPLATE_PREVIEW_DATA: TailoredResumeData = {
+ fullName: 'Jordan Rivera',
+ email: 'jordan.rivera@example.com',
+ phone: '+1 (555) 010-2193',
+ location: 'San Francisco, CA',
+ linkedin: 'linkedin.com/in/jordanrivera',
+ github: 'github.com/jordanrivera',
+ website: 'jordanrivera.dev',
+ summary: 'Product-minded software engineer with 6+ years building scalable web platforms, AI-assisted workflows, and data-heavy applications.',
+ skills: ['TypeScript', 'Next.js', 'Node.js', 'PostgreSQL', 'Redis', 'Docker', 'GraphQL', 'CI/CD'],
+ experience: [
+ {
+ title: 'Senior Software Engineer',
+ subtitle: 'BlueWave Systems',
+ date: '2022-Present',
+ location: 'Remote',
+ bullets: [
+ 'Led migration to Next.js App Router and reduced page load times by 34%.',
+ 'Built AI-assisted resume and cover letter workflows used by 50k+ users.',
+ 'Introduced observability dashboards that cut incident resolution time in half.',
+ ],
+ },
+ ],
+ projects: [
+ {
+ title: 'Hiring Intelligence Platform',
+ subtitle: 'Next.js, Convex, Gemini API',
+ date: '2024',
+ location: 'Remote',
+ bullets: [
+ 'Designed role-matching pipeline to score resumes against job descriptions.',
+ 'Implemented robust caching and idempotency for high-volume generation APIs.',
+ ],
+ },
+ ],
+ education: [
+ {
+ title: 'B.S. Computer Science',
+ subtitle: 'University of California, Davis',
+ date: '2017-2021',
+ location: 'Davis, CA',
+ bullets: [],
+ },
+ ],
+ certifications: ['AWS Certified Developer - Associate'],
+ additional: ['Speaker: Bay Area JS Meetup (2025)'],
+ targetTitle: 'Senior Full Stack Engineer',
+ keywordsUsed: ['scalable systems', 'AI workflows', 'cloud deployment'],
+};
diff --git a/src/types/domain.ts b/src/types/domain.ts
index 9cb47a1..61c8139 100644
--- a/src/types/domain.ts
+++ b/src/types/domain.ts
@@ -1,7 +1,7 @@
export type Tone = 'professional' | 'friendly' | 'enthusiastic';
export type LetterLength = 'concise' | 'standard' | 'detailed';
export type AnalysisType = 'overview' | 'keywords' | 'match' | 'coverLetter';
-export type HistoryType = 'analysis' | 'cover-letter';
+export type HistoryType = 'analysis' | 'cover-letter' | 'resume';
export interface ResumeItem {
_id: string;
@@ -48,4 +48,18 @@ export interface HistoryCoverLetterItem {
result: string;
}
-export type HistoryItem = HistoryAnalysisItem | HistoryCoverLetterItem;
+export interface HistoryResumeItem {
+ id: string;
+ type: 'resume';
+ resumeName?: string;
+ jobTitle?: string;
+ companyName?: string;
+ jobDescription?: string;
+ templateId?: string;
+ builderSlug?: string;
+ version?: number;
+ createdAt: string;
+ result: string;
+}
+
+export type HistoryItem = HistoryAnalysisItem | HistoryCoverLetterItem | HistoryResumeItem;
diff --git a/test/app/api/generate-resume-latex/route.test.ts b/test/app/api/generate-resume-latex/route.test.ts
new file mode 100644
index 0000000..7adcf4f
--- /dev/null
+++ b/test/app/api/generate-resume-latex/route.test.ts
@@ -0,0 +1,105 @@
+import { NextRequest } from 'next/server';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { POST } from '@/app/api/generate-resume-latex/route';
+import { checkRateLimit, getAuthenticatedUser } from '@/lib/auth';
+import { getTailoredResume, saveTailoredResume } from '@/lib/convex-server';
+import { generateTailoredResumeData } from '@/lib/gemini';
+
+vi.mock('@/lib/auth', () => ({
+ getAuthenticatedUser: vi.fn(),
+ checkRateLimit: vi.fn(),
+}));
+
+vi.mock('@/lib/gemini', () => ({
+ generateTailoredResumeData: vi.fn(),
+}));
+
+vi.mock('@/lib/convex-server', () => ({
+ getTailoredResume: vi.fn(),
+ saveTailoredResume: vi.fn(),
+ generateHash: vi.fn((value: string) => `hash-${value.length}`),
+}));
+
+describe('/api/generate-resume-latex', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(getAuthenticatedUser).mockResolvedValue('u1');
+ vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true, remaining: 10, resetIn: 1000 });
+ vi.mocked(getTailoredResume).mockResolvedValue(null);
+ vi.mocked(generateTailoredResumeData).mockResolvedValue({
+ summary: 'Software engineer focused on distributed systems.',
+ skills: ['TypeScript', 'Next.js', 'Node.js'],
+ experience: [],
+ projects: [],
+ education: [],
+ certifications: [],
+ additional: [],
+ keywordsUsed: ['scalability'],
+ targetTitle: 'Senior Software Engineer',
+ });
+ vi.mocked(saveTailoredResume).mockResolvedValue({ _id: 'tr-1' } as never);
+ });
+
+ it('returns 400 for invalid payload', async () => {
+ const req = new NextRequest('http://localhost/api/generate-resume-latex', {
+ method: 'POST',
+ body: JSON.stringify({}),
+ });
+
+ const res = await POST(req);
+ const data = await res.json();
+
+ expect(res.status).toBe(400);
+ expect(data.code).toBe('VALIDATION_ERROR');
+ });
+
+ it('returns cached tailored resume when available', async () => {
+ vi.mocked(getTailoredResume).mockResolvedValue({
+ _id: 'tr-cache',
+ templateId: 'jake-classic',
+ latexSource: '\\documentclass{article}',
+ structuredData: JSON.stringify({ summary: 'cached' }),
+ } as never);
+
+ const req = new NextRequest('http://localhost/api/generate-resume-latex', {
+ method: 'POST',
+ body: JSON.stringify({
+ resumeText: 'resume',
+ jobDescription: 'job',
+ templateId: 'jake-classic',
+ }),
+ });
+
+ const res = await POST(req);
+ const data = await res.json();
+
+ expect(res.status).toBe(200);
+ expect(data.cached).toBe(true);
+ expect(data.documentId).toBe('tr-cache');
+ expect(generateTailoredResumeData).not.toHaveBeenCalled();
+ });
+
+ it('generates and saves a tailored resume', async () => {
+ const req = new NextRequest('http://localhost/api/generate-resume-latex', {
+ method: 'POST',
+ body: JSON.stringify({
+ resumeText: 'resume text',
+ jobDescription: 'job description',
+ templateId: 'deedy-modern',
+ resumeName: 'Resume.pdf',
+ }),
+ });
+
+ const res = await POST(req);
+ const data = await res.json();
+
+ expect(res.status).toBe(200);
+ expect(data.cached).toBe(false);
+ expect(data.templateId).toBe('deedy-modern');
+ expect(data.documentId).toBe('tr-1');
+ expect(data.latexSource).toContain('documentclass');
+ expect(generateTailoredResumeData).toHaveBeenCalledTimes(1);
+ expect(saveTailoredResume).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/test/app/api/render-latex/route.test.ts b/test/app/api/render-latex/route.test.ts
new file mode 100644
index 0000000..62a561e
--- /dev/null
+++ b/test/app/api/render-latex/route.test.ts
@@ -0,0 +1,66 @@
+import { NextRequest } from 'next/server';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { POST } from '@/app/api/render-latex/route';
+import { getAuthenticatedUser } from '@/lib/auth';
+
+vi.mock('@/lib/auth', () => ({
+ getAuthenticatedUser: vi.fn(),
+}));
+
+describe('/api/render-latex', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(getAuthenticatedUser).mockResolvedValue('u1');
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it('returns 400 for invalid payload', async () => {
+ const request = new NextRequest('http://localhost/api/render-latex', {
+ method: 'POST',
+ body: JSON.stringify({ latexSource: '' }),
+ });
+
+ const response = await POST(request);
+ expect(response.status).toBe(400);
+ });
+
+ it('returns compiled pdf', async () => {
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(new Uint8Array([1, 2, 3]), {
+ status: 200,
+ headers: { 'content-type': 'application/pdf' },
+ })));
+
+ const request = new NextRequest('http://localhost/api/render-latex', {
+ method: 'POST',
+ body: JSON.stringify({ latexSource: '\\documentclass{article}\\begin{document}Hello\\end{document}' }),
+ });
+
+ const response = await POST(request);
+ const buffer = await response.arrayBuffer();
+
+ expect(response.status).toBe(200);
+ expect(response.headers.get('content-type')).toBe('application/pdf');
+ expect(buffer.byteLength).toBeGreaterThan(0);
+ });
+
+ it('returns compile error on upstream failure', async () => {
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('bad latex', {
+ status: 400,
+ })));
+
+ const request = new NextRequest('http://localhost/api/render-latex', {
+ method: 'POST',
+ body: JSON.stringify({ latexSource: '\\badcommand' }),
+ });
+
+ const response = await POST(request);
+ const data = await response.json();
+
+ expect(response.status).toBe(422);
+ expect(data.code).toBe('LATEX_COMPILE_FAILED');
+ });
+});
diff --git a/test/app/dashboard/analysis/[slug]/page.test.tsx b/test/app/dashboard/analysis/[slug]/page.test.tsx
index d894557..db7924b 100644
--- a/test/app/dashboard/analysis/[slug]/page.test.tsx
+++ b/test/app/dashboard/analysis/[slug]/page.test.tsx
@@ -1,5 +1,4 @@
import { render, screen, waitFor } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
import type { ReactNode } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
@@ -81,7 +80,7 @@ describe('/dashboard/analysis/[slug] flow', () => {
expect(await screen.findByText('Generation failed')).toBeInTheDocument();
});
- it('retries generation after failure when user clicks retry', async () => {
+ it('retries generation automatically after a transient failure', async () => {
sessionStorage.setItem('pendingAnalysisGeneration', JSON.stringify({
resumeText: 'Resume text',
jobDescription: 'Job description',
@@ -89,7 +88,6 @@ describe('/dashboard/analysis/[slug] flow', () => {
}));
mockFetch
- .mockResolvedValueOnce(createResponse(false, 500, { error: 'Generation failed' }))
.mockResolvedValueOnce(createResponse(false, 500, { error: 'Generation failed' }))
.mockResolvedValueOnce(createResponse(true, 200, {
documentId: 'analysis-retry-1',
@@ -99,9 +97,6 @@ describe('/dashboard/analysis/[slug] flow', () => {
render( );
- const retryButton = await screen.findByRole('button', { name: /Retry Generation/i });
- await userEvent.click(retryButton);
-
await waitFor(() => {
expect(mockReplace).toHaveBeenCalledWith('/dashboard/analysis/analysis-retry-1');
});
diff --git a/test/app/dashboard/cover-letter/[slug]/page.test.tsx b/test/app/dashboard/cover-letter/[slug]/page.test.tsx
index 1deaf92..4cea877 100644
--- a/test/app/dashboard/cover-letter/[slug]/page.test.tsx
+++ b/test/app/dashboard/cover-letter/[slug]/page.test.tsx
@@ -1,5 +1,4 @@
import { render, screen, waitFor } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
import type { ReactNode } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
@@ -81,7 +80,7 @@ describe('/dashboard/cover-letter/[slug] flow', () => {
expect(await screen.findByText('Cover letter generation failed')).toBeInTheDocument();
});
- it('retries generation after failure when user clicks retry', async () => {
+ it('retries generation automatically after a transient failure', async () => {
sessionStorage.setItem('pendingCoverLetterGeneration', JSON.stringify({
resumeText: 'Resume text',
jobDescription: 'Job description',
@@ -90,7 +89,6 @@ describe('/dashboard/cover-letter/[slug] flow', () => {
}));
mockFetch
- .mockResolvedValueOnce(createResponse(false, 500, { error: 'Cover letter generation failed' }))
.mockResolvedValueOnce(createResponse(false, 500, { error: 'Cover letter generation failed' }))
.mockResolvedValueOnce(createResponse(true, 200, {
documentId: 'cover-retry-1',
@@ -99,9 +97,6 @@ describe('/dashboard/cover-letter/[slug] flow', () => {
render( );
- const retryButton = await screen.findByRole('button', { name: /Retry Generation/i });
- await userEvent.click(retryButton);
-
await waitFor(() => {
expect(mockReplace).toHaveBeenCalledWith('/dashboard/cover-letter/cover-retry-1');
});
diff --git a/test/lib/gemini.test.ts b/test/lib/gemini.test.ts
index 64caf23..9108fe5 100644
--- a/test/lib/gemini.test.ts
+++ b/test/lib/gemini.test.ts
@@ -1,6 +1,6 @@
import { beforeEach,describe, expect, it, vi } from 'vitest';
-import { analyzeResume } from '@/lib/gemini';
+import { analyzeResume, generateTailoredResumeData } from '@/lib/gemini';
const { mockGenerateContent, mockGetGenerativeModel } = vi.hoisted(() => ({
mockGenerateContent: vi.fn(),
@@ -87,4 +87,25 @@ describe('gemini', () => {
expect(prompt).toContain('300 words');
expect(prompt).toContain('4 paragraphs');
});
+
+ it('should parse structured JSON for tailored resume generation', async () => {
+ mockGenerateContent.mockResolvedValue({
+ response: {
+ text: () => JSON.stringify({
+ summary: 'summary',
+ skills: ['TypeScript'],
+ experience: [],
+ projects: [],
+ education: [],
+ certifications: [],
+ additional: [],
+ keywordsUsed: ['typescript'],
+ }),
+ },
+ });
+
+ const result = await generateTailoredResumeData('resume', 'job');
+ expect(result.summary).toBe('summary');
+ expect(result.skills).toEqual(['TypeScript']);
+ });
});
diff --git a/test/lib/resume-latex.test.ts b/test/lib/resume-latex.test.ts
new file mode 100644
index 0000000..4b68c52
--- /dev/null
+++ b/test/lib/resume-latex.test.ts
@@ -0,0 +1,47 @@
+import { describe, expect, it } from 'vitest';
+
+import { buildLatexResume, escapeLatex } from '@/lib/resume-latex';
+
+describe('resume-latex', () => {
+ it('escapes LaTeX special characters', () => {
+ const value = '50% growth & $1000 #1 _dev_';
+ const escaped = escapeLatex(value);
+
+ expect(escaped).toContain('\\%');
+ expect(escaped).toContain('\\&');
+ expect(escaped).toContain('\\$');
+ expect(escaped).toContain('\\#');
+ expect(escaped).toContain('\\_');
+ });
+
+ it('builds latex document for template', () => {
+ const output = buildLatexResume('jake-classic', {
+ fullName: 'Ada Lovelace',
+ email: 'ada@example.com',
+ summary: 'Engineer building reliable systems.',
+ skills: ['TypeScript', 'Node.js'],
+ experience: [
+ {
+ title: 'Software Engineer',
+ subtitle: 'Acme',
+ date: '2022-2025',
+ location: 'Remote',
+ bullets: ['Improved API latency by 30%'],
+ },
+ ],
+ projects: [],
+ education: [],
+ certifications: [],
+ additional: [],
+ keywordsUsed: ['api'],
+ targetTitle: 'Senior Engineer',
+ });
+
+ expect(output).toContain('\\documentclass');
+ expect(output).toContain('Ada Lovelace');
+ expect(output).toContain('Software Engineer');
+ expect(output).toContain('\\section{Experience}');
+ expect(output).toContain('\\section{Technical Skills}');
+ expect(output).toContain('\\end{document}');
+ });
+});