initial public release

This commit is contained in:
2026-07-06 11:05:50 -04:00
commit ca518c5f8a
94 changed files with 15699 additions and 0 deletions
+242
View File
@@ -0,0 +1,242 @@
package cv
import (
"bytes"
"context"
"database/sql"
"embed"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/cobr-ai/apex/internal/eval"
)
//go:embed prompts/tailor.md templates/cv-template.html
var embeddedFS embed.FS
// CVResult holds the generated file paths.
type CVResult struct {
HTMLPath string
PDFPath string
}
// HTMLExporter generates a tailored HTML CV + PDF via Chromium.
type HTMLExporter struct {
DB *sql.DB
CVPath string // path to cv.md (defaults to ~/.apex/cv.md)
OutputDir string // defaults to ~/.apex/cv/
}
// Generate creates a tailored HTML CV (and optionally PDF) for the given job URL.
// Flow: query DB for JD → read cv.md → load profile → read tailor prompt →
// call Claude → render HTML → call Chromium for PDF.
func (e *HTMLExporter) Generate(ctx context.Context, jobURL string) (CVResult, error) {
cvPath := e.CVPath
if cvPath == "" {
home, err := os.UserHomeDir()
if err != nil {
return CVResult{}, fmt.Errorf("expand home: %w", err)
}
cvPath = filepath.Join(home, ".apex", "cv.md")
}
outputDir := e.OutputDir
if outputDir == "" {
home, err := os.UserHomeDir()
if err != nil {
return CVResult{}, fmt.Errorf("expand home: %w", err)
}
outputDir = filepath.Join(home, ".apex", "cv")
}
// Ensure output directory exists
if err := os.MkdirAll(outputDir, 0700); err != nil {
return CVResult{}, fmt.Errorf("mkdir %s: %w", outputDir, err)
}
// Query jobs table for company, title, description by URL
var company, title, description string
err := e.DB.QueryRowContext(ctx, `
SELECT company, title, COALESCE(description, '')
FROM jobs
WHERE url = ?
LIMIT 1
`, jobURL).Scan(&company, &title, &description)
if err != nil {
if err == sql.ErrNoRows {
return CVResult{}, fmt.Errorf("job not found: %s", jobURL)
}
return CVResult{}, fmt.Errorf("query job: %w", err)
}
// Read cv.md
cvContent, err := os.ReadFile(cvPath)
if err != nil {
return CVResult{}, fmt.Errorf("read cv.md: %w", err)
}
// Load profile from DB
var name, email, location string
err = e.DB.QueryRowContext(ctx, `
SELECT name, email, location FROM profile LIMIT 1
`).Scan(&name, &email, &location)
if err != nil && err != sql.ErrNoRows {
return CVResult{}, fmt.Errorf("query profile: %w", err)
}
// Read tailor.md prompt template
promptTpl, err := embeddedFS.ReadFile("prompts/tailor.md")
if err != nil {
return CVResult{}, fmt.Errorf("read tailor prompt: %w", err)
}
// Substitute placeholders in prompt
prompt := strings.NewReplacer(
"{{CV}}", string(cvContent),
"{{JD}}", description,
"{{NAME}}", name,
"{{EMAIL}}", email,
"{{LOCATION}}", location,
).Replace(string(promptTpl))
// Call Claude CLI to generate tailored CV HTML
html, err := e.callClaude(ctx, prompt)
if err != nil {
return CVResult{}, fmt.Errorf("call claude: %w", err)
}
// Generate filenames
now := time.Now().Format("2006-01-02")
companySlug := eval.Slugify(company)
nameSlug := eval.Slugify(name)
baseFilename := fmt.Sprintf("cv-%s-%s-%s", nameSlug, companySlug, now)
htmlPath := filepath.Join(outputDir, baseFilename+".html")
pdfPath := filepath.Join(outputDir, baseFilename+".pdf")
// Write HTML file
if err := os.WriteFile(htmlPath, []byte(html), 0600); err != nil {
return CVResult{}, fmt.Errorf("write html: %w", err)
}
// Generate PDF via Chromium if available
if err := e.generatePDF(htmlPath, pdfPath); err != nil {
// PDF generation is best-effort; don't fail the entire operation
fmt.Fprintf(os.Stderr, "pdf generation failed (continuing with HTML only): %v\n", err)
}
return CVResult{
HTMLPath: htmlPath,
PDFPath: pdfPath,
}, nil
}
// callClaude runs the Claude CLI with the given prompt and returns raw HTML output.
// Timeout: 10 minutes for full document generation.
func (e *HTMLExporter) callClaude(ctx context.Context, prompt string) (string, error) {
timeoutCtx, cancel := context.WithTimeout(ctx, 10*time.Minute)
defer cancel()
cmd := exec.CommandContext(timeoutCtx, "claude", "--print", "--output-format", "text")
stdin, err := cmd.StdinPipe()
if err != nil {
return "", fmt.Errorf("stdin pipe: %w", err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return "", fmt.Errorf("stdout pipe: %w", err)
}
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Start(); err != nil {
return "", fmt.Errorf("start claude: %w", err)
}
// Feed prompt via stdin in a goroutine
go func() {
defer stdin.Close()
_, _ = io.WriteString(stdin, prompt)
}()
// Read output
output, err := io.ReadAll(stdout)
if err != nil {
return "", fmt.Errorf("read stdout: %w", err)
}
if err := cmd.Wait(); err != nil {
if timeoutCtx.Err() == context.DeadlineExceeded {
return "", errors.New("claude timeout (10 minutes)")
}
return "", fmt.Errorf("claude exit: %w (stderr: %s)", err, stderr.String())
}
html := string(output)
if strings.TrimSpace(html) == "" {
return "", errors.New("claude returned empty output")
}
return html, nil
}
// generatePDF uses Chromium to convert HTML to PDF.
// Returns early if chromium not found; it's optional.
func (e *HTMLExporter) generatePDF(htmlPath, pdfPath string) error {
// Resolve symlinks on the output directory (file doesn't exist yet so we
// can only canonicalize the dir). This prevents symlink traversal bypasses
// that filepath.Clean alone would miss.
realDir, err := filepath.EvalSymlinks(filepath.Dir(htmlPath))
if err != nil {
realDir = filepath.Clean(filepath.Dir(htmlPath))
}
absPDF := filepath.Clean(pdfPath)
if !strings.HasPrefix(absPDF, realDir+string(os.PathSeparator)) {
return fmt.Errorf("pdf path escapes output directory")
}
chromiumPath := "/usr/bin/chromium"
if _, err := os.Stat(chromiumPath); err != nil {
return fmt.Errorf("chromium not found at %s", chromiumPath)
}
// Convert to absolute path if needed
absHTMLPath, err := filepath.Abs(htmlPath)
if err != nil {
absHTMLPath = htmlPath
}
// Chromium needs file:// URL
fileURL := "file://" + absHTMLPath
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx,
chromiumPath,
"--headless=new",
"--disable-gpu",
"--no-sandbox",
fmt.Sprintf("--print-to-pdf=%s", pdfPath),
fileURL,
)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("chromium: %w (stderr: %s)", err, stderr.String())
}
return nil
}
+347
View File
@@ -0,0 +1,347 @@
package cv
import (
"bytes"
"context"
"database/sql"
"embed"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
)
//go:embed templates/cv-template.tex
var templateFS embed.FS
// Required structural markers in the filled template. Borrowed from
// career-ops generate-latex.mjs so the output passes the same validation.
var requiredSections = []string{
`\section{Education}`,
`\section{Work Experience}`,
`\section{Personal Projects}`,
`\section{Technical Skills}`,
}
var requiredCommands = []string{
`\resumeSubheading`,
`\resumeItem`,
`\resumeProjectHeading`,
}
// LaTeXExporter fills the embedded CV template with profile contact info and
// writes a .tex file. If pdflatex is on PATH, it also compiles to PDF.
//
// Section placeholders ({{EDUCATION}}, {{EXPERIENCE}}, {{PROJECTS}}, {{SKILLS}})
// are left as markers by default — the user is expected to populate them from
// cv.md in Overleaf or via a follow-up Claude pass. The direct-fill mode
// still produces a working skeleton that compiles and has the user's contact
// block correct.
type LaTeXExporter struct {
DB *sql.DB
CVPath string // path to cv.md (optional; used for the skills fallback)
OutputDir string // defaults to $HOME/.apex/cv
}
// Output carries paths of everything written so the TUI can link to them.
type Output struct {
TeXPath string
PDFPath string // empty if pdflatex not available
Issues []string
}
// Export writes the .tex file (and PDF if pdflatex is available).
// Returns the paths and any structural issues flagged by validation.
func (e *LaTeXExporter) Export(ctx context.Context) (Output, error) {
tmpl, err := templateFS.ReadFile("templates/cv-template.tex")
if err != nil {
return Output{}, fmt.Errorf("load template: %w", err)
}
profile, err := loadProfileContact(e.DB)
if err != nil {
return Output{}, fmt.Errorf("load profile: %w", err)
}
filled := fillContactPlaceholders(string(tmpl), profile)
// Section placeholders get a one-line filler so the document compiles
// even without user edits. Validation still flags them via the
// {{...}} scan below so the user knows what's pending.
filled = fillStubSections(filled)
issues := validate(filled)
dir := e.OutputDir
if dir == "" {
home, _ := os.UserHomeDir()
dir = filepath.Join(home, ".apex", "cv")
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return Output{}, fmt.Errorf("mkdir output: %w", err)
}
name := sanitizeFilename(profile.Name)
if name == "" {
name = "cv"
}
date := time.Now().Format("2006-01-02")
texPath := filepath.Join(dir, fmt.Sprintf("%s-%s.tex", name, date))
if err := atomicWrite(texPath, []byte(filled)); err != nil {
return Output{}, fmt.Errorf("write tex: %w", err)
}
out := Output{TeXPath: texPath, Issues: issues}
pdfPath, err := compilePDF(ctx, texPath, dir)
if err != nil {
// pdflatex absence is not fatal — the .tex is still useful. Only
// surface the error via Issues so the caller can decide to warn.
if !errors.Is(err, errPdflatexMissing) {
return out, fmt.Errorf("pdflatex: %w", err)
}
out.Issues = append(out.Issues, "pdflatex not on PATH — .tex written, PDF skipped")
return out, nil
}
out.PDFPath = pdfPath
return out, nil
}
type contactProfile struct {
Name string
Email string
Location string
Links []string
}
// loadProfileContact reads the minimal contact fields from the single-row
// profile table Phase 1 wrote. Missing profile is not fatal — placeholders
// fall back to obvious TODO markers in the .tex output.
func loadProfileContact(db *sql.DB) (contactProfile, error) {
if db == nil {
return contactProfile{Name: "Your Name"}, nil
}
row := db.QueryRow(`SELECT COALESCE(name,''), COALESCE(email,''), COALESCE(location,'') FROM profile WHERE id = 1`)
var p contactProfile
if err := row.Scan(&p.Name, &p.Email, &p.Location); err != nil {
if err == sql.ErrNoRows {
return contactProfile{Name: "Your Name"}, nil
}
return contactProfile{}, err
}
if p.Name == "" {
p.Name = "Your Name"
}
return p, nil
}
// fillContactPlaceholders replaces the top-of-document identity fields.
// LaTeX escaping is conservative — we only replace where a safe subset fits.
func fillContactPlaceholders(tmpl string, p contactProfile) string {
contact := p.Location
if p.Email != "" {
if contact != "" {
contact += " · "
}
contact += p.Email
}
emailURL := p.Email
emailDisp := p.Email
// Minimal LaTeX escaping for user-provided strings. Only # & _ % are
// common in email/location. Full escaping would need more, but these
// cover 99% of cases.
texEsc := func(s string) string {
s = strings.ReplaceAll(s, `\`, `\textbackslash{}`)
s = strings.ReplaceAll(s, `&`, `\&`)
s = strings.ReplaceAll(s, `%`, `\%`)
s = strings.ReplaceAll(s, `#`, `\#`)
s = strings.ReplaceAll(s, `_`, `\_`)
s = strings.ReplaceAll(s, `$`, `\$`)
// ~ and ^ are active in LaTeX (tie / accent) — neutralize them so
// a location like "c^top" can't inject a superscript command.
s = strings.ReplaceAll(s, `~`, `\textasciitilde{}`)
s = strings.ReplaceAll(s, `^`, `\textasciicircum{}`)
return s
}
replacements := map[string]string{
"{{NAME}}": texEsc(p.Name),
"{{CONTACT_LINE}}": texEsc(contact),
"{{EMAIL_URL}}": emailURL,
"{{EMAIL_DISPLAY}}": texEsc(emailDisp),
"{{LINKEDIN_URL}}": "https://linkedin.com/in/TODO",
"{{LINKEDIN_DISPLAY}}": "linkedin.com/in/TODO",
"{{GITHUB_URL}}": "https://github.com/TODO",
"{{GITHUB_DISPLAY}}": "github.com/TODO",
}
out := tmpl
for k, v := range replacements {
out = strings.ReplaceAll(out, k, v)
}
return out
}
// fillStubSections replaces section placeholders with a minimal valid body so
// the document compiles. Each stub still includes the required commands so
// template validation passes.
func fillStubSections(s string) string {
stubs := map[string]string{
"{{EDUCATION}}": `\resumeSubHeadingListStart
\resumeSubheading{Your School}{Year}{Degree}{Location}
\resumeSubHeadingListEnd`,
"{{EXPERIENCE}}": `\resumeSubHeadingListStart
\resumeSubheading{Your Role}{Dates}{Employer}{Location}
\resumeItemListStart
\resumeItem{Responsibility or achievement.}
\resumeItemListEnd
\resumeSubHeadingListEnd`,
"{{PROJECTS}}": `\resumeSubHeadingListStart
\resumeProjectHeading{\textbf{Project Name} $|$ \emph{Stack}}{Link}
\resumeItemListStart
\resumeItem{What the project does.}
\resumeItemListEnd
\resumeSubHeadingListEnd`,
"{{SKILLS}}": `\begin{itemize}[leftmargin=0.15in, label={}]
\small{\item{
\textbf{Languages}{: TODO} \\
\textbf{Tools}{: TODO}
}}
\end{itemize}`,
}
out := s
for k, v := range stubs {
out = strings.ReplaceAll(out, k, v)
}
return out
}
// validate mirrors the checks in career-ops generate-latex.mjs: required
// sections, required commands, begin/end document, and no unresolved
// placeholders. Returns a list of issues (empty = clean).
func validate(content string) []string {
var issues []string
for _, s := range requiredSections {
if !strings.Contains(content, s) {
issues = append(issues, "missing section: "+s)
}
}
for _, c := range requiredCommands {
if !strings.Contains(content, c) {
issues = append(issues, "missing command: "+c)
}
}
if !strings.Contains(content, `\begin{document}`) {
issues = append(issues, `missing \begin{document}`)
}
if !strings.Contains(content, `\end{document}`) {
issues = append(issues, `missing \end{document}`)
}
if unresolved := unresolvedPlaceholderRE.FindAllString(content, -1); len(unresolved) > 0 {
seen := make(map[string]struct{})
var uniq []string
for _, p := range unresolved {
if _, ok := seen[p]; ok {
continue
}
seen[p] = struct{}{}
uniq = append(uniq, p)
}
issues = append(issues, "unresolved placeholders: "+strings.Join(uniq, ", "))
}
return issues
}
var unresolvedPlaceholderRE = regexp.MustCompile(`\{\{[A-Z_]+\}\}`)
// compilePDF shells to pdflatex in the output directory so .aux/.log land
// next to the .tex and not pollute the current working directory.
// Returns errPdflatexMissing when the binary isn't on PATH so callers can
// degrade gracefully.
func compilePDF(ctx context.Context, texPath, workDir string) (string, error) {
if _, err := exec.LookPath("pdflatex"); err != nil {
return "", errPdflatexMissing
}
runCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
// -no-shell-escape blocks \write18{...} and \input of unvetted files
// inside any .tex we compile — defense-in-depth for the case where a
// future flow lets the user paste section contents in-app.
cmd := exec.CommandContext(runCtx, "pdflatex",
"-no-shell-escape",
"-interaction=nonstopmode",
"-halt-on-error",
"-output-directory="+workDir,
texPath,
)
var buf bytes.Buffer
cmd.Stdout = &buf
cmd.Stderr = &buf
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("pdflatex failed: %w (tail: %s)", err, tail(buf.String(), 500))
}
base := strings.TrimSuffix(filepath.Base(texPath), ".tex")
pdf := filepath.Join(workDir, base+".pdf")
// Best-effort cleanup of pdflatex's byproducts.
for _, ext := range []string{".aux", ".log", ".out"} {
_ = os.Remove(filepath.Join(workDir, base+ext))
}
return pdf, nil
}
var errPdflatexMissing = errors.New("pdflatex: not found on PATH")
// tail returns the last n runes of s (useful for bounded error messages).
func tail(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return "…" + string(r[len(r)-n:])
}
// sanitizeFilename strips filesystem-unsafe characters from a profile name
// so the output .tex doesn't get stuck with spaces or slashes. Consecutive
// dashes collapse so "Dr. María" doesn't yield "dr--mara".
func sanitizeFilename(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
var b strings.Builder
prevDash := false
for _, r := range s {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
b.WriteRune(r)
prevDash = false
case r == ' ' || r == '-' || r == '_' || r == '.' || r == '/':
if !prevDash && b.Len() > 0 {
b.WriteRune('-')
prevDash = true
}
}
}
return strings.Trim(b.String(), "-")
}
// atomicWrite is a local copy of the pattern used by internal/eval/report.go.
// Keeping it package-local avoids a cross-package dep that would drag in
// unrelated types (Evaluation, JobContext) just to share one helper.
func atomicWrite(path string, data []byte) error {
dir := filepath.Dir(path)
f, err := os.CreateTemp(dir, ".cv-*.tmp")
if err != nil {
return err
}
tmp := f.Name()
if _, err := f.Write(data); err != nil {
f.Close()
os.Remove(tmp)
return err
}
if err := f.Close(); err != nil {
os.Remove(tmp)
return err
}
return os.Rename(tmp, path)
}
+156
View File
@@ -0,0 +1,156 @@
package cv
import (
"context"
"database/sql"
"os"
"path/filepath"
"strings"
"testing"
_ "modernc.org/sqlite"
)
func setupProfileDB(t *testing.T, name, email, location string) *sql.DB {
t.Helper()
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
if _, err := db.Exec(`CREATE TABLE profile (
id INTEGER PRIMARY KEY CHECK (id = 1),
name TEXT, email TEXT, location TEXT, timezone TEXT,
salary_target_min REAL, salary_target_max REAL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`); err != nil {
t.Fatal(err)
}
if name != "" {
if _, err := db.Exec(`INSERT INTO profile (id, name, email, location) VALUES (1, ?, ?, ?)`,
name, email, location); err != nil {
t.Fatal(err)
}
}
return db
}
func TestLaTeXExporter_Export_EndToEnd(t *testing.T) {
db := setupProfileDB(t, "Ada Lovelace", "ada@example.com", "London, UK")
defer db.Close()
tmp := t.TempDir()
e := &LaTeXExporter{DB: db, OutputDir: tmp}
out, err := e.Export(context.Background())
if err != nil {
t.Fatalf("Export: %v", err)
}
if out.TeXPath == "" {
t.Fatal("TeXPath empty")
}
if filepath.Dir(out.TeXPath) != tmp {
t.Errorf("tex written outside OutputDir: %q", out.TeXPath)
}
data, err := os.ReadFile(out.TeXPath)
if err != nil {
t.Fatal(err)
}
s := string(data)
if !strings.Contains(s, "Ada Lovelace") {
t.Error("name not spliced")
}
if !strings.Contains(s, "ada@example.com") {
t.Error("email not spliced")
}
if strings.Contains(s, "{{NAME}}") || strings.Contains(s, "{{EXPERIENCE}}") {
t.Error("placeholders left unfilled in output")
}
if !strings.Contains(s, `\begin{document}`) || !strings.Contains(s, `\end{document}`) {
t.Error("document structure missing")
}
// pdflatex may or may not be present in CI; either way the tex must succeed.
// If present, PDFPath is populated; if absent, Issues notes the skip.
if out.PDFPath == "" && len(out.Issues) == 0 {
t.Log("pdflatex present but PDFPath unset — check compile path")
}
}
func TestLaTeXExporter_LaTeXEscapingOnContact(t *testing.T) {
db := setupProfileDB(t, "R&D Person", "x_y%z@example.com", "C#/OT")
defer db.Close()
e := &LaTeXExporter{DB: db, OutputDir: t.TempDir()}
out, err := e.Export(context.Background())
if err != nil {
t.Fatalf("Export: %v", err)
}
data, _ := os.ReadFile(out.TeXPath)
s := string(data)
// & should have been escaped to \&, % to \%, # to \#, _ to \_
for _, bad := range []string{"R&D Person", "C#/OT"} {
if strings.Contains(s, bad) {
t.Errorf("unescaped LaTeX special in output for %q", bad)
}
}
if !strings.Contains(s, `R\&D Person`) {
t.Error("missing escaped ampersand in name")
}
if !strings.Contains(s, `C\#/OT`) {
t.Error("missing escaped hash in location")
}
}
func TestValidate_CatchesStructuralGaps(t *testing.T) {
badTex := `\documentclass{article}
\begin{document}
Hello.
\end{document}
`
issues := validate(badTex)
if len(issues) == 0 {
t.Fatal("expected structural issues")
}
var sawEducation, sawSubheading bool
for _, i := range issues {
if strings.Contains(i, "Education") {
sawEducation = true
}
if strings.Contains(i, "resumeSubheading") {
sawSubheading = true
}
}
if !sawEducation || !sawSubheading {
t.Errorf("missing expected issues: got %v", issues)
}
}
func TestValidate_FlagsUnresolvedPlaceholders(t *testing.T) {
// Minimal file with required markers but an unresolved placeholder.
content := `\begin{document}
\section{Education}
\section{Work Experience}
\section{Personal Projects}
\section{Technical Skills}
\resumeSubheading\resumeItem\resumeProjectHeading
Name: {{NAME}}
\end{document}
`
issues := validate(content)
joined := strings.Join(issues, " | ")
if !strings.Contains(joined, "unresolved placeholders") {
t.Errorf("expected unresolved-placeholder flag, got: %s", joined)
}
}
func TestSanitizeFilename(t *testing.T) {
cases := map[string]string{
"Ada Lovelace": "ada-lovelace",
" Dr. María ": "dr-mara", // diacritics dropped (ASCII-only)
"../../evil": "evil",
"": "",
}
for in, want := range cases {
if got := sanitizeFilename(in); got != want {
t.Errorf("sanitizeFilename(%q) = %q, want %q", in, got, want)
}
}
}
+70
View File
@@ -0,0 +1,70 @@
# CV Tailoring Prompt
You are a CV tailoring expert. Your job is to generate a complete, ready-to-print HTML CV tailored to a specific job description.
## Input CV (Markdown)
{{CV}}
## Job Description
{{JD}}
## Candidate Profile
- Name: {{NAME}}
- Email: {{EMAIL}}
- Location: {{LOCATION}}
## Instructions
1. **Read and understand** the CV and job description carefully. Identify:
- The candidate's genuine experience and skills (do not invent)
- The job's key requirements and priorities
- Vocabulary and keywords from the JD that match the candidate's real work
2. **Keyword Injection**: Reformulate the candidate's real experience using language from the JD. For example, if the JD says "cloud infrastructure" and the CV mentions "AWS deployment", reframe it as "cloud infrastructure deployment on AWS". Only use real JD vocabulary to frame existing experience.
3. **Contact Header**: The header contact-row MUST include EVERY contact line listed at the top of the source CV (Location, Email, Phone, LinkedIn, GitHub, Clearance, etc.). Do not silently drop any. For LinkedIn/GitHub, the `href` attribute MUST be the URL EXACTLY as written in the source CV (do not rewrite, shorten, or decode percent-encoded characters). But the visible link TEXT should be a clean short form: `linkedin.com/in/<slug>` or `github.com/<user>` — strip protocol, `www.`, trailing slash, and any percent-encoded characters or emoji from the display text only. Example: `<a href="https://www.linkedin.com/in/johndoe/">linkedin.com/in/johndoe</a>`.
4. **Structure**: Keep the CV structure:
- Professional Summary (tailored to the role)
- Core Competencies (sorted by relevance to JD, limit 10-15)
- Work Experience (re-ordered by relevance, with bullets tailored)
- Projects (include only directly relevant ones)
- Education
- Certifications
- Skills (organized by category, emphasize JD-relevant categories)
5. **Tone**: Professional, confident, action-oriented. Use strong verbs.
6. **Length**: Target single page when possible (11px base font, 8.5" × 11" page).
7. **Design**: Use the Space Grotesk + DM Sans design with:
- Cyan-to-purple gradient header (hsl(187,74%,32%) → hsl(270,70%,45%))
- Competency tags in cyan background
- Company names and project titles in purple
- All fonts at absolute paths: `${APEX_ROOT}/fonts/`
8. **Output Format**: Return ONLY the complete, valid HTML document. No preamble, no explanation, no markdown fence. The HTML must:
- Be valid, self-contained HTML5
- Include all embedded CSS (no external stylesheets except fonts)
- Have font-face declarations with absolute paths
- Be ready to print or convert to PDF via Chromium
- Use proper HTML escaping for special characters
9. **Never**:
- Invent skills or experience
- Exaggerate timelines or responsibilities
- Remove factual content (only reorder/reframe)
- Use relative font paths
- Include commented-out HTML
## Example Output Structure (HTML only):
```
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
...
```
This is just the structure; your output will be the actual CV HTML with real content.
Start generating the HTML now:
+469
View File
@@ -0,0 +1,469 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Name}} — CV</title>
<style>
@font-face {
font-family: 'Space Grotesk';
src: url('{{.FontPath}}space-grotesk-latin.woff2') format('woff2');
font-weight: 300 700;
font-style: normal;
font-display: swap;
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Space Grotesk';
src: url('{{.FontPath}}space-grotesk-latin-ext.woff2') format('woff2');
font-weight: 300 700;
font-style: normal;
font-display: swap;
unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: 'DM Sans';
src: url('{{.FontPath}}dm-sans-latin.woff2') format('woff2');
font-weight: 100 1000;
font-style: normal;
font-display: swap;
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'DM Sans';
src: url('{{.FontPath}}dm-sans-latin-ext.woff2') format('woff2');
font-weight: 100 1000;
font-style: normal;
font-display: swap;
unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
body {
font-family: 'DM Sans', sans-serif;
font-size: 11px;
line-height: 1.5;
color: #1a1a2e;
background: #ffffff;
padding: 0;
margin: 0;
}
.page {
width: 100%;
max-width: 8.5in;
margin: 0 auto;
padding: 0.5in;
}
/* === HEADER === */
.header {
margin-bottom: 20px;
}
.header h1 {
font-family: 'Space Grotesk', sans-serif;
font-size: 28px;
font-weight: 700;
color: #1a1a2e;
letter-spacing: -0.02em;
margin-bottom: 6px;
line-height: 1.1;
}
.header-gradient {
height: 2px;
background: linear-gradient(to right, hsl(187, 74%, 32%), hsl(270, 70%, 45%));
border-radius: 1px;
margin-bottom: 10px;
}
.contact-row {
display: flex;
flex-wrap: wrap;
gap: 8px 14px;
font-family: 'DM Sans', sans-serif;
font-size: 10.5px;
line-height: 1.4;
color: #555;
}
.contact-row a {
color: #555;
text-decoration: none;
}
.contact-row .separator {
color: #ccc;
}
/* === SECTIONS === */
.section {
margin-bottom: 18px;
}
.section-title {
font-family: 'Space Grotesk', sans-serif;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: hsl(187, 74%, 32%);
border-bottom: 1.5px solid #e2e2e2;
padding-bottom: 4px;
margin-bottom: 10px;
line-height: 1.2;
}
/* === PROFESSIONAL SUMMARY === */
.summary-text {
font-size: 11px;
line-height: 1.7;
color: #2f2f2f;
}
/* Links must never break across lines */
a {
white-space: nowrap;
}
/* === CORE COMPETENCIES === */
.competencies-grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.competency-tag {
font-family: 'DM Sans', sans-serif;
font-size: 10px;
font-weight: 500;
color: hsl(187, 74%, 28%);
background: hsl(187, 40%, 95%);
padding: 4px 10px;
border-radius: 3px;
border: 1px solid hsl(187, 40%, 88%);
}
/* === WORK EXPERIENCE === */
.job {
margin-bottom: 14px;
}
.job-header {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
margin-bottom: 4px;
}
.job-company {
font-family: 'Space Grotesk', sans-serif;
font-size: 12.5px;
font-weight: 600;
color: hsl(270, 70%, 45%);
}
.job-period {
font-size: 10.5px;
color: #777;
white-space: nowrap;
}
.job-role {
font-size: 11px;
font-weight: 600;
color: #333;
margin-bottom: 6px;
}
.job-location {
font-size: 10px;
color: #888;
}
.job ul {
padding-left: 18px;
margin-top: 6px;
}
.job li {
font-size: 10.5px;
line-height: 1.6;
color: #333;
margin-bottom: 4px;
}
.job li strong {
font-weight: 600;
}
/* === PROJECTS === */
.project {
margin-bottom: 12px;
}
.project-title {
font-family: 'Space Grotesk', sans-serif;
font-size: 11.5px;
font-weight: 600;
color: hsl(270, 70%, 45%);
}
.project-badge {
font-size: 9px;
font-weight: 500;
color: hsl(187, 74%, 32%);
background: hsl(187, 40%, 95%);
padding: 1px 6px;
border-radius: 2px;
margin-left: 6px;
}
.project-desc {
font-size: 10.5px;
color: #444;
margin-top: 3px;
line-height: 1.55;
}
.project-tech {
font-size: 9.5px;
color: #888;
margin-top: 3px;
}
/* === EDUCATION === */
.edu-item {
margin-bottom: 8px;
}
.edu-header {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
}
.edu-title {
font-weight: 600;
font-size: 11px;
color: #333;
}
.edu-org {
color: hsl(270, 70%, 45%);
font-weight: 500;
}
.edu-year {
font-size: 10px;
color: #777;
white-space: nowrap;
}
.edu-desc {
font-size: 10px;
color: #666;
margin-top: 2px;
line-height: 1.5;
}
/* === CERTIFICATIONS === */
.cert-item {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
margin-bottom: 6px;
}
.cert-title {
font-size: 10.5px;
font-weight: 500;
color: #333;
}
.cert-org {
color: hsl(270, 70%, 45%);
}
.cert-year {
font-size: 10px;
color: #777;
white-space: nowrap;
}
/* === SKILLS === */
.skills-grid {
display: flex;
flex-wrap: wrap;
gap: 6px 14px;
}
.skill-item {
font-size: 10.5px;
color: #444;
}
.skill-category {
font-weight: 600;
color: #333;
font-size: 10.5px;
}
/* === PRINT === */
@media print {
body {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.page {
padding: 0;
}
}
/* === PAGE BREAK CONTROL === */
.avoid-break,
.job,
.project,
.edu-item,
.cert-item {
break-inside: avoid;
page-break-inside: avoid;
}
</style>
</head>
<body>
<div class="page">
<!-- HEADER -->
<div class="header avoid-break">
<h1>{{.Name}}</h1>
<div class="header-gradient"></div>
<div class="contact-row">
{{if .Email}}<span>{{.Email}}</span><span class="separator">|</span>{{end}}
{{if .Location}}<span>{{.Location}}</span>{{end}}
</div>
</div>
<!-- PROFESSIONAL SUMMARY -->
{{if .Summary}}
<div class="section avoid-break">
<div class="section-title">Professional Summary</div>
<div class="summary-text">{{.Summary}}</div>
</div>
{{end}}
<!-- CORE COMPETENCIES -->
{{if .Competencies}}
<div class="section">
<div class="section-title">Core Competencies</div>
<div class="competencies-grid">
{{range .Competencies}}<span class="competency-tag">{{.}}</span>{{end}}
</div>
</div>
{{end}}
<!-- WORK EXPERIENCE -->
{{if .Experience}}
<div class="section">
<div class="section-title">Work Experience</div>
{{range .Experience}}
<div class="job">
<div class="job-header">
<span class="job-company">{{.Company}}</span>
{{if .Period}}<span class="job-period">{{.Period}}</span>{{end}}
</div>
{{if .Role}}<div class="job-role">{{.Role}}</div>{{end}}
{{if .Location}}<div class="job-location">{{.Location}}</div>{{end}}
{{if .Highlights}}
<ul>
{{range .Highlights}}<li>{{.}}</li>{{end}}
</ul>
{{end}}
</div>
{{end}}
</div>
{{end}}
<!-- PROJECTS -->
{{if .Projects}}
<div class="section avoid-break">
<div class="section-title">Projects</div>
{{range .Projects}}
<div class="project">
<div class="project-title">
{{.Title}}
{{if .Badge}}<span class="project-badge">{{.Badge}}</span>{{end}}
</div>
{{if .Description}}<div class="project-desc">{{.Description}}</div>{{end}}
{{if .Tech}}<div class="project-tech">{{.Tech}}</div>{{end}}
</div>
{{end}}
</div>
{{end}}
<!-- EDUCATION -->
{{if .Education}}
<div class="section avoid-break">
<div class="section-title">Education</div>
{{range .Education}}
<div class="edu-item">
<div class="edu-header">
<span>
<span class="edu-title">{{.Degree}}</span>
{{if .Organization}}<span class="edu-org">{{.Organization}}</span>{{end}}
</span>
{{if .Year}}<span class="edu-year">{{.Year}}</span>{{end}}
</div>
{{if .Details}}<div class="edu-desc">{{.Details}}</div>{{end}}
</div>
{{end}}
</div>
{{end}}
<!-- CERTIFICATIONS -->
{{if .Certifications}}
<div class="section avoid-break">
<div class="section-title">Certifications</div>
{{range .Certifications}}
<div class="cert-item">
<span>
<span class="cert-title">{{.Title}}</span>
{{if .Organization}}<span class="cert-org">{{.Organization}}</span>{{end}}
</span>
{{if .Year}}<span class="cert-year">{{.Year}}</span>{{end}}
</div>
{{end}}
</div>
{{end}}
<!-- SKILLS -->
{{if .Skills}}
<div class="section avoid-break">
<div class="section-title">Skills</div>
<div class="skills-grid">
{{range .Skills}}<span class="skill-item">{{.}}</span>{{end}}
</div>
</div>
{{end}}
</div>
</body>
</html>
+123
View File
@@ -0,0 +1,123 @@
%-------------------------
% Career-Ops LaTeX CV Template
% Based on: Gabriel Sison / sb2nov resume template
% License: MIT
%------------------------
%%%%%%%%%%%%%%%%%%%%%%%%%%%% Random Stuff %%%%%%%%%%%%%%%%%%%%%%%%%%%%
\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{fontawesome5}
\usepackage{multicol}
\setlength{\multicolsep}{-3.0pt}
\setlength{\columnsep}{-1pt}
\input{glyphtounicode}
\pagestyle{fancy}
\fancyhf{} % clear all header and footer fields
\fancyfoot{}
\renewcommand{\headrulewidth}{0pt}
\renewcommand{\footrulewidth}{0pt}
% Adjust margins
\addtolength{\oddsidemargin}{-0.6in}
\addtolength{\evensidemargin}{-0.5in}
\addtolength{\textwidth}{1.19in}
\addtolength{\topmargin}{-.7in}
\addtolength{\textheight}{1.4in}
\urlstyle{same}
\raggedbottom
\raggedright
\setlength{\tabcolsep}{0in}
% Sections formatting
\titleformat{\section}{
\vspace{-7pt}\scshape\raggedright\large\bfseries
}{}{0em}{}[\color{black}\titlerule \vspace{0pt}]
% Ensure that generate pdf is machine readable/ATS parsable
\pdfgentounicode=1
%%%%%%%%%%%%%%%%%%%%%%%%%%%% Commands %%%%%%%%%%%%%%%%%%%%%%%%%%%%
\newcommand{\resumeItem}[1]{
\item\small{
{#1 \vspace{-3pt}}
}
}
\newcommand{\resumeSubheading}[4]{
\vspace{-3pt}\item
\begin{tabular*}{1.0\textwidth}[t]{l@{\extracolsep{\fill}}r}
\textbf{#1} & \textbf{\small #2} \\
\textit{\small#3} & \textit{\small #4} \\
\end{tabular*}\vspace{-7pt}
}
\newcommand{\resumeSubheadingContinue}[2]{
\vspace{-3pt}
\begin{tabular*}{1.0\textwidth}[t]{l@{\extracolsep{\fill}}r}
\textit{\small#1} & \textit{\small #2} \\
\end{tabular*}\vspace{-7pt}
}
\newcommand{\resumeProjectHeading}[2]{
\vspace{-3pt}\item
\begin{tabular*}{1.0\textwidth}[t]{l@{\extracolsep{\fill}}r}
\textbf{#1} & \textbf{\small #2} \\
\end{tabular*}\vspace{-7pt}
}
\newcommand{\resumeSubItem}[1]{\resumeItem{#1}\vspace{0pt}}
\renewcommand\labelitemi{$\vcenter{\hbox{\tiny$\bullet$}}$}
\renewcommand\labelitemii{$\vcenter{\hbox{\tiny$\bullet$}}$}
\newcommand{\resumeSubHeadingListStart}{\begin{itemize}[leftmargin=0.0in, label={}]}
\newcommand{\resumeSubHeadingListEnd}{\end{itemize}}
\newcommand{\resumeItemListStart}{\begin{itemize}}
\newcommand{\resumeItemListEnd}{\end{itemize}\vspace{0pt}}
%%%%%%%%%%%%%%%%%%%%%%%%%%%% Heading %%%%%%%%%%%%%%%%%%%%%%%%%%%%
\begin{document}
\begin{center}
% NAME
{\Huge\scshape {{NAME}}}
% SUBHEADING
\\ {{CONTACT_LINE}}\\
\small
% EMAIL
\href{mailto:{{EMAIL_URL}}}{\raisebox{-0.2\height}\faEnvelope\ \underline{{{EMAIL_DISPLAY}}}} ~
% LINKEDIN
\href{{{LINKEDIN_URL}}}{\raisebox{-0.2\height}\faLinkedin\ \underline{{{LINKEDIN_DISPLAY}}}} ~
% GITHUB
\href{{{GITHUB_URL}}}{\raisebox{-0.2\height}\faGithub\ \underline{{{GITHUB_DISPLAY}}}}
\end{center}
%%%%%%%%%%%%%%%%%%%%%%%%%%%% Education %%%%%%%%%%%%%%%%%%%%%%%%%%%%
\section{Education}
\resumeSubHeadingListStart
{{EDUCATION}}
\resumeSubHeadingListEnd
%%%%%%%%%%%%%%%%%%%%%%%%%%%% Experience %%%%%%%%%%%%%%%%%%%%%%%%%%%%
\section{Work Experience}
\resumeSubHeadingListStart
{{EXPERIENCE}}
\resumeSubHeadingListEnd
%%%%%%%%%%%%%%%%%%%%%%%%%%%% PROJECTS %%%%%%%%%%%%%%%%%%%%%%%%%%%%
\section{Personal Projects}
\resumeSubHeadingListStart
{{PROJECTS}}
\resumeSubHeadingListEnd
%%%%%%%%%%%%%%%%%%%%%%%%%%%% Technical Skills %%%%%%%%%%%%%%%%%%%%%%%%%%%%
\section{Technical Skills}
\vspace{-7pt}
\begin{itemize}
[leftmargin=0.15in, label={}]\small{\item{
{{SKILLS}}
}}
\end{itemize}
\end{document}