refactor(cv): centralize tailored templates
This commit is contained in:
@@ -520,6 +520,20 @@ public sealed class JobApplicationsApplicationPackageTests
|
||||
Assert.Contains("curved", edinburgh.Html, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tailored_template_catalog_preserves_legacy_ids_and_defaults()
|
||||
{
|
||||
Assert.Equal(
|
||||
new[] { "ats-minimal", "harvard", "auckland", "edinburgh", "monarch", "fjord" },
|
||||
TailoredCvTemplateCatalog.Templates.Select(template => template.Id));
|
||||
Assert.Equal("ats-minimal", TailoredCvTemplateCatalog.NormalizeId("legacy-text"));
|
||||
Assert.Equal("ats-minimal", TailoredCvTemplateCatalog.NormalizeId("unknown"));
|
||||
Assert.Equal("brick", TailoredCvTemplateCatalog.Resolve("harvard").AccentColor);
|
||||
Assert.Contains("Harvard template", TailoredCvTemplateCatalog.Resolve("harvard").RewriteGuidance);
|
||||
Assert.Equal("#047857", TailoredCvTemplateCatalog.ResolveAccent("emerald"));
|
||||
Assert.Equal("#123456", TailoredCvTemplateCatalog.ResolveAccent("#123456"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Template_renderer_wraps_long_content_and_escapes_sidebar_values()
|
||||
{
|
||||
|
||||
@@ -73,15 +73,7 @@ public sealed partial class ProfileCvController : ControllerBase
|
||||
RenderOptions = new TailoredCvRenderOptions
|
||||
{
|
||||
ShowPhoto = true,
|
||||
AccentColor = templateId switch
|
||||
{
|
||||
"harvard" => "brick",
|
||||
"auckland" => "emerald",
|
||||
"edinburgh" => "plum",
|
||||
"monarch" => "#7c2d12",
|
||||
"fjord" => "#0f4c5c",
|
||||
_ => "slate",
|
||||
},
|
||||
AccentColor = TailoredCvTemplateCatalog.Resolve(templateId).AccentColor,
|
||||
SectionOrder = new List<string> { "summary", "skills", "experience", "education", "custom" },
|
||||
}
|
||||
});
|
||||
|
||||
@@ -571,32 +571,10 @@ public sealed partial class ProfileCvController : ControllerBase
|
||||
|
||||
private static string DescribeRewriteTemplate(string templateId)
|
||||
{
|
||||
return templateId.ToLowerInvariant() switch
|
||||
{
|
||||
"harvard" => "Harvard template: refined, traditional, strong hierarchy, restrained and credible.",
|
||||
"auckland" => "Auckland template: modern sidebar layout, crisp highlights, confident but readable.",
|
||||
"edinburgh" => "Edinburgh template: polished editorial layout with stronger visual personality and premium spacing.",
|
||||
"monarch" => "Monarch template: executive, premium, high-contrast emphasis on summary and leadership signals.",
|
||||
"fjord" => "Fjord template: calm technical layout with clear information density and practical scanability.",
|
||||
_ => "ATS Minimal template: clean, compact, scanner-friendly, and easy to tailor."
|
||||
};
|
||||
return TailoredCvTemplateCatalog.Resolve(templateId).RewriteGuidance;
|
||||
}
|
||||
|
||||
private static string NormalizeTemplateId(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"base" => "ats-minimal",
|
||||
"legacy-text" => "ats-minimal",
|
||||
"harvard" => "harvard",
|
||||
"auckland" => "auckland",
|
||||
"edinburgh" => "edinburgh",
|
||||
"monarch" => "monarch",
|
||||
"fjord" => "fjord",
|
||||
_ => "ats-minimal"
|
||||
};
|
||||
}
|
||||
private static string NormalizeTemplateId(string? value) => TailoredCvTemplateCatalog.NormalizeId(value);
|
||||
|
||||
private static string? NormalizeRewriteSectionName(string? value)
|
||||
{
|
||||
@@ -642,17 +620,16 @@ public sealed partial class ProfileCvController : ControllerBase
|
||||
|
||||
private static IReadOnlyList<CvTemplateDescriptor> GetCvTemplateDescriptors()
|
||||
{
|
||||
// 7-arg shape matches CvTemplateDescriptor in ProfileCvDtos.cs. The LayoutFamily/AtsRating
|
||||
// fields the branch added here are deferred with the rest of the ATS-badge work (Phase 4).
|
||||
return new[]
|
||||
{
|
||||
new CvTemplateDescriptor("ats-minimal", "ATS Minimal", "Scanner-friendly", "slate", "Compact, direct, and easy to parse.", "Best for broad application flows and recruiter scanning.", new List<string> { "Tight hierarchy", "Keyword-friendly", "Low visual risk" }),
|
||||
new CvTemplateDescriptor("harvard", "Harvard", "Traditional", "brick", "Formal and restrained.", "Good for conservative hiring flows or academic-adjacent applications.", new List<string> { "Classic serif rhythm", "Strong chronology", "Credible tone" }),
|
||||
new CvTemplateDescriptor("auckland", "Auckland", "Modern sidebar", "emerald", "Sharper highlights with a contemporary cadence.", "Pulls key strengths into a faster visual scan.", new List<string> { "Sidebar details", "Compact highlights", "Modern contrast" }),
|
||||
new CvTemplateDescriptor("edinburgh", "Edinburgh", "Editorial", "plum", "More personality without losing clarity.", "Useful when the CV should feel polished and distinctive.", new List<string> { "Premium spacing", "Stronger personality", "Readable density" }),
|
||||
new CvTemplateDescriptor("monarch", "Monarch", "Executive", "#7c2d12", "High-contrast leadership emphasis.", "Works well for senior, strategic, or client-facing roles.", new List<string> { "Executive summary weight", "Premium accenting", "Decision-maker friendly" }),
|
||||
new CvTemplateDescriptor("fjord", "Fjord", "Technical", "#0f4c5c", "Calm, dense, technical layout.", "Optimized for engineering resumes with richer project and skills detail.", new List<string> { "Technical depth", "Dense but readable", "Practical hierarchy" }),
|
||||
};
|
||||
return TailoredCvTemplateCatalog.Templates
|
||||
.Select(template => new CvTemplateDescriptor(
|
||||
template.Id,
|
||||
template.Title,
|
||||
template.Tone,
|
||||
template.AccentColor,
|
||||
template.PreviewTagline,
|
||||
template.PreviewSummary,
|
||||
template.PreviewBullets.ToList()))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private TailoredCvRenderResult RenderProfileCv(TailoredCvDocument document, ApplicationUser user, string targetRole, string? companyName)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
namespace JobTrackerApi.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Compatibility catalog for the job-specific CV draft flow. These IDs predate the
|
||||
/// document builder theme catalog, so they remain stable while their metadata and
|
||||
/// defaults have one owner.
|
||||
/// </summary>
|
||||
public sealed record TailoredCvTemplate(
|
||||
string Id,
|
||||
string Title,
|
||||
string Tone,
|
||||
string AccentColor,
|
||||
string PreviewTagline,
|
||||
string PreviewSummary,
|
||||
IReadOnlyList<string> PreviewBullets,
|
||||
string RewriteGuidance);
|
||||
|
||||
public static class TailoredCvTemplateCatalog
|
||||
{
|
||||
public static readonly IReadOnlyList<TailoredCvTemplate> Templates = new[]
|
||||
{
|
||||
new TailoredCvTemplate("ats-minimal", "ATS Minimal", "Scanner-friendly", "slate", "Compact, direct, and easy to parse.", "Best for broad application flows and recruiter scanning.", new[] { "Tight hierarchy", "Keyword-friendly", "Low visual risk" }, "ATS Minimal template: clean, compact, scanner-friendly, and easy to tailor."),
|
||||
new TailoredCvTemplate("harvard", "Harvard", "Traditional", "brick", "Formal and restrained.", "Good for conservative hiring flows or academic-adjacent applications.", new[] { "Classic serif rhythm", "Strong chronology", "Credible tone" }, "Harvard template: refined, traditional, strong hierarchy, restrained and credible."),
|
||||
new TailoredCvTemplate("auckland", "Auckland", "Modern sidebar", "emerald", "Sharper highlights with a contemporary cadence.", "Pulls key strengths into a faster visual scan.", new[] { "Sidebar details", "Compact highlights", "Modern contrast" }, "Auckland template: modern sidebar layout, crisp highlights, confident but readable."),
|
||||
new TailoredCvTemplate("edinburgh", "Edinburgh", "Editorial", "plum", "More personality without losing clarity.", "Useful when the CV should feel polished and distinctive.", new[] { "Premium spacing", "Stronger personality", "Readable density" }, "Edinburgh template: polished editorial layout with stronger visual personality and premium spacing."),
|
||||
new TailoredCvTemplate("monarch", "Monarch", "Executive", "#7c2d12", "High-contrast leadership emphasis.", "Works well for senior, strategic, or client-facing roles.", new[] { "Executive summary weight", "Premium accenting", "Decision-maker friendly" }, "Monarch template: executive, premium, high-contrast emphasis on summary and leadership signals."),
|
||||
new TailoredCvTemplate("fjord", "Fjord", "Technical", "#0f4c5c", "Calm, dense, technical layout.", "Optimized for engineering resumes with richer project and skills detail.", new[] { "Technical depth", "Dense but readable", "Practical hierarchy" }, "Fjord template: calm technical layout with clear information density and practical scanability."),
|
||||
};
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, string> NamedAccents =
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["slate"] = "#334155",
|
||||
["blue"] = "#1d4ed8",
|
||||
["emerald"] = "#047857",
|
||||
["plum"] = "#7c3aed",
|
||||
["brick"] = "#b45309",
|
||||
};
|
||||
|
||||
public static string NormalizeId(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
if (normalized is "base" or "legacy-text") return Templates[0].Id;
|
||||
return Templates.Any(template => template.Id == normalized) ? normalized : Templates[0].Id;
|
||||
}
|
||||
|
||||
public static TailoredCvTemplate Resolve(string? value)
|
||||
{
|
||||
var id = NormalizeId(value);
|
||||
return Templates.First(template => template.Id == id);
|
||||
}
|
||||
|
||||
public static string ResolveAccent(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim();
|
||||
if (NamedAccents.TryGetValue(normalized, out var accent)) return accent;
|
||||
return normalized.StartsWith('#') ? normalized : NamedAccents["slate"];
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ public sealed class CvTemplateRenderer : ICvTemplateRenderer
|
||||
public TailoredCvRenderResult Render(TailoredCvDocument? document, string? templateId, string candidateName, string jobTitle, string? companyName, string? photoDataUrl = null)
|
||||
{
|
||||
var normalized = TailoredCvDraftJson.Normalize(document);
|
||||
var effectiveTemplateId = NormalizeTemplateId(templateId ?? normalized.TemplateId);
|
||||
var effectiveTemplateId = TailoredCvTemplateCatalog.NormalizeId(templateId ?? normalized.TemplateId);
|
||||
normalized.TemplateId = effectiveTemplateId;
|
||||
var suggestedFileName = Slugify($"{candidateName}-{jobTitle}-{effectiveTemplateId}") + ".pdf";
|
||||
var html = effectiveTemplateId switch
|
||||
@@ -31,25 +31,9 @@ public sealed class CvTemplateRenderer : ICvTemplateRenderer
|
||||
return new TailoredCvRenderResult(effectiveTemplateId, suggestedFileName, html);
|
||||
}
|
||||
|
||||
private static string NormalizeTemplateId(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"base" => "ats-minimal",
|
||||
"legacy-text" => "ats-minimal",
|
||||
"harvard" => "harvard",
|
||||
"auckland" => "auckland",
|
||||
"edinburgh" => "edinburgh",
|
||||
"monarch" => "monarch",
|
||||
"fjord" => "fjord",
|
||||
_ => "ats-minimal"
|
||||
};
|
||||
}
|
||||
|
||||
private static string RenderAtsMinimal(TailoredCvDocument document, string candidateName, string jobTitle, string? companyName, string? photoDataUrl)
|
||||
{
|
||||
var accent = ResolveAccent(document.RenderOptions.AccentColor);
|
||||
var accent = TailoredCvTemplateCatalog.ResolveAccent(document.RenderOptions.AccentColor);
|
||||
var showPhoto = document.RenderOptions.ShowPhoto && !string.IsNullOrWhiteSpace(photoDataUrl);
|
||||
var body = RenderMainSections(document, accent, headingStyle: "caps-rule");
|
||||
var companyFocusMarkup = string.IsNullOrWhiteSpace(companyName) ? string.Empty : $"<span>Company focus: {Encode(companyName)}</span>";
|
||||
@@ -93,7 +77,7 @@ public sealed class CvTemplateRenderer : ICvTemplateRenderer
|
||||
|
||||
private static string RenderHarvard(TailoredCvDocument document, string candidateName, string jobTitle, string? companyName)
|
||||
{
|
||||
var accent = ResolveAccent(document.RenderOptions.AccentColor);
|
||||
var accent = TailoredCvTemplateCatalog.ResolveAccent(document.RenderOptions.AccentColor);
|
||||
var body = RenderMainSections(document, accent, headingStyle: "harvard");
|
||||
var contactLine = string.Join(" • ", new[]
|
||||
{
|
||||
@@ -135,7 +119,7 @@ public sealed class CvTemplateRenderer : ICvTemplateRenderer
|
||||
|
||||
private static string RenderSidebar(TailoredCvDocument document, string candidateName, string jobTitle, string? companyName, string? photoDataUrl, string templateLabel, bool roundedPhoto, bool curvedHeader)
|
||||
{
|
||||
var accent = ResolveAccent(document.RenderOptions.AccentColor);
|
||||
var accent = TailoredCvTemplateCatalog.ResolveAccent(document.RenderOptions.AccentColor);
|
||||
var showPhoto = document.RenderOptions.ShowPhoto && !string.IsNullOrWhiteSpace(photoDataUrl);
|
||||
var sidebarSections = new StringBuilder();
|
||||
sidebarSections.Append(RenderSidebarMetaSection("Personal Details", new[]
|
||||
@@ -207,7 +191,7 @@ public sealed class CvTemplateRenderer : ICvTemplateRenderer
|
||||
|
||||
private static string RenderMonarch(TailoredCvDocument document, string candidateName, string jobTitle, string? companyName, string? photoDataUrl)
|
||||
{
|
||||
var accent = ResolveAccent(document.RenderOptions.AccentColor);
|
||||
var accent = TailoredCvTemplateCatalog.ResolveAccent(document.RenderOptions.AccentColor);
|
||||
var showPhoto = document.RenderOptions.ShowPhoto && !string.IsNullOrWhiteSpace(photoDataUrl);
|
||||
var photoMarkup = showPhoto ? $"<div class=\"monarch-photo\"><img src=\"{EncodeAttribute(photoDataUrl)}\" alt=\"Profile photo\" /></div>" : string.Empty;
|
||||
var body = RenderMainSections(document, accent, headingStyle: "sidebar");
|
||||
@@ -259,7 +243,7 @@ public sealed class CvTemplateRenderer : ICvTemplateRenderer
|
||||
|
||||
private static string RenderFjord(TailoredCvDocument document, string candidateName, string jobTitle, string? companyName, string? photoDataUrl)
|
||||
{
|
||||
var accent = ResolveAccent(document.RenderOptions.AccentColor);
|
||||
var accent = TailoredCvTemplateCatalog.ResolveAccent(document.RenderOptions.AccentColor);
|
||||
var showPhoto = document.RenderOptions.ShowPhoto && !string.IsNullOrWhiteSpace(photoDataUrl);
|
||||
var body = RenderMainSections(document, accent, headingStyle: "sidebar");
|
||||
var photoMarkup = showPhoto ? $"<div class=\"fjord-photo\"><img src=\"{EncodeAttribute(photoDataUrl)}\" alt=\"Profile photo\" /></div>" : string.Empty;
|
||||
@@ -445,21 +429,6 @@ public sealed class CvTemplateRenderer : ICvTemplateRenderer
|
||||
return $"{normalizedStart} - {(isCurrent ? "Present" : string.IsNullOrWhiteSpace(normalizedEnd) ? "Present" : normalizedEnd)}";
|
||||
}
|
||||
|
||||
private static string ResolveAccent(string? accentColor)
|
||||
{
|
||||
var normalized = (accentColor ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"slate" => "#334155",
|
||||
"blue" => "#1d4ed8",
|
||||
"emerald" => "#047857",
|
||||
"plum" => "#7c3aed",
|
||||
"brick" => "#b45309",
|
||||
_ when normalized.StartsWith("#") => normalized,
|
||||
_ => "#334155"
|
||||
};
|
||||
}
|
||||
|
||||
private static string Encode(string? value) => WebUtility.HtmlEncode(value ?? string.Empty);
|
||||
private static string EncodeAttribute(string? value) => WebUtility.HtmlEncode(value ?? string.Empty).Replace("'", "'", StringComparison.Ordinal);
|
||||
|
||||
|
||||
+9
-2
@@ -2,11 +2,12 @@
|
||||
|
||||
## Current
|
||||
|
||||
- [ ] Run the complete backend, frontend, browser, migration, PDF, Compose and diff verification matrix; fix any regressions.
|
||||
- [ ] Commit and deploy the centralized job-specific CV compatibility catalog; verify live release health.
|
||||
|
||||
## Next
|
||||
|
||||
- [ ] Audit remaining TODOs/dead CV code after the full regression run and document any genuine environment-only blockers.
|
||||
- [ ] Complete authenticated production CV Builder editing/PDF smoke when a safe signed-in test session is available.
|
||||
- [ ] Remove the two temporary Gitea credential-transfer files after explicit deletion confirmation.
|
||||
|
||||
## Completed
|
||||
|
||||
@@ -19,6 +20,12 @@
|
||||
- [x] Added shared A4/Letter page grouping for browser preview and Chromium PDF, including regression coverage for long multi-page Code CVs and searchable linked PDF output.
|
||||
- [x] Improved DOCX structural extraction, Norwegian headings/languages/dates, contact and website parsing, summary/skills/education fallbacks, and confidence-preserving import output.
|
||||
- [x] Benchmarked both supplied reference DOCX variants without committing personal data: 100% structural coverage and consistency, with zero suspicious locations.
|
||||
- [x] Ran the complete backend, frontend, browser, PDF, Compose and diff verification matrix without product regressions.
|
||||
- [x] Installed .NET 9.0.317, corrected user PATH precedence, configured Git author/authentication and pushed the CV rebuild.
|
||||
- [x] Hardened the self-hosted CI runner against corrupt SDK installs and intermittent Roslyn exit-139 crashes without weakening build/test gates.
|
||||
- [x] Deployed release 266 and verified public health plus the Career and CV Builder application routes.
|
||||
- [x] Audited CV TODOs and obsolete-code candidates; confirmed the remaining old renderer is an active job-specific compatibility path rather than dead Builder code.
|
||||
- [x] Centralized the compatibility path's template IDs, aliases, accents, chooser metadata and AI rewrite guidance while preserving saved IDs and HTML rendering.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user