refactor(cv): centralize tailored templates
CI and Deploy / test (push) Successful in 5m35s
CI and Deploy / deploy (push) Successful in 2m6s

This commit is contained in:
cesnimda
2026-08-27 20:47:59 +02:00
parent 6b3acf7b07
commit 7c4e6fb5b7
6 changed files with 101 additions and 83 deletions
@@ -520,6 +520,20 @@ public sealed class JobApplicationsApplicationPackageTests
Assert.Contains("curved", edinburgh.Html, StringComparison.OrdinalIgnoreCase); 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] [Fact]
public void Template_renderer_wraps_long_content_and_escapes_sidebar_values() public void Template_renderer_wraps_long_content_and_escapes_sidebar_values()
{ {
@@ -73,15 +73,7 @@ public sealed partial class ProfileCvController : ControllerBase
RenderOptions = new TailoredCvRenderOptions RenderOptions = new TailoredCvRenderOptions
{ {
ShowPhoto = true, ShowPhoto = true,
AccentColor = templateId switch AccentColor = TailoredCvTemplateCatalog.Resolve(templateId).AccentColor,
{
"harvard" => "brick",
"auckland" => "emerald",
"edinburgh" => "plum",
"monarch" => "#7c2d12",
"fjord" => "#0f4c5c",
_ => "slate",
},
SectionOrder = new List<string> { "summary", "skills", "experience", "education", "custom" }, 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) private static string DescribeRewriteTemplate(string templateId)
{ {
return templateId.ToLowerInvariant() switch return TailoredCvTemplateCatalog.Resolve(templateId).RewriteGuidance;
{
"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."
};
} }
private static string NormalizeTemplateId(string? value) private static string NormalizeTemplateId(string? value) => TailoredCvTemplateCatalog.NormalizeId(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? NormalizeRewriteSectionName(string? value) private static string? NormalizeRewriteSectionName(string? value)
{ {
@@ -642,17 +620,16 @@ public sealed partial class ProfileCvController : ControllerBase
private static IReadOnlyList<CvTemplateDescriptor> GetCvTemplateDescriptors() private static IReadOnlyList<CvTemplateDescriptor> GetCvTemplateDescriptors()
{ {
// 7-arg shape matches CvTemplateDescriptor in ProfileCvDtos.cs. The LayoutFamily/AtsRating return TailoredCvTemplateCatalog.Templates
// fields the branch added here are deferred with the rest of the ATS-badge work (Phase 4). .Select(template => new CvTemplateDescriptor(
return new[] template.Id,
{ template.Title,
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" }), template.Tone,
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" }), template.AccentColor,
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" }), template.PreviewTagline,
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" }), template.PreviewSummary,
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" }), template.PreviewBullets.ToList()))
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" }), .ToList();
};
} }
private TailoredCvRenderResult RenderProfileCv(TailoredCvDocument document, ApplicationUser user, string targetRole, string? companyName) 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"];
}
}
+6 -37
View File
@@ -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) public TailoredCvRenderResult Render(TailoredCvDocument? document, string? templateId, string candidateName, string jobTitle, string? companyName, string? photoDataUrl = null)
{ {
var normalized = TailoredCvDraftJson.Normalize(document); var normalized = TailoredCvDraftJson.Normalize(document);
var effectiveTemplateId = NormalizeTemplateId(templateId ?? normalized.TemplateId); var effectiveTemplateId = TailoredCvTemplateCatalog.NormalizeId(templateId ?? normalized.TemplateId);
normalized.TemplateId = effectiveTemplateId; normalized.TemplateId = effectiveTemplateId;
var suggestedFileName = Slugify($"{candidateName}-{jobTitle}-{effectiveTemplateId}") + ".pdf"; var suggestedFileName = Slugify($"{candidateName}-{jobTitle}-{effectiveTemplateId}") + ".pdf";
var html = effectiveTemplateId switch var html = effectiveTemplateId switch
@@ -31,25 +31,9 @@ public sealed class CvTemplateRenderer : ICvTemplateRenderer
return new TailoredCvRenderResult(effectiveTemplateId, suggestedFileName, html); 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) 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 showPhoto = document.RenderOptions.ShowPhoto && !string.IsNullOrWhiteSpace(photoDataUrl);
var body = RenderMainSections(document, accent, headingStyle: "caps-rule"); var body = RenderMainSections(document, accent, headingStyle: "caps-rule");
var companyFocusMarkup = string.IsNullOrWhiteSpace(companyName) ? string.Empty : $"<span>Company focus: {Encode(companyName)}</span>"; 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) 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 body = RenderMainSections(document, accent, headingStyle: "harvard");
var contactLine = string.Join(" &nbsp;•&nbsp; ", new[] var contactLine = string.Join(" &nbsp;•&nbsp; ", 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) 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 showPhoto = document.RenderOptions.ShowPhoto && !string.IsNullOrWhiteSpace(photoDataUrl);
var sidebarSections = new StringBuilder(); var sidebarSections = new StringBuilder();
sidebarSections.Append(RenderSidebarMetaSection("Personal Details", new[] 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) 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 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 photoMarkup = showPhoto ? $"<div class=\"monarch-photo\"><img src=\"{EncodeAttribute(photoDataUrl)}\" alt=\"Profile photo\" /></div>" : string.Empty;
var body = RenderMainSections(document, accent, headingStyle: "sidebar"); 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) 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 showPhoto = document.RenderOptions.ShowPhoto && !string.IsNullOrWhiteSpace(photoDataUrl);
var body = RenderMainSections(document, accent, headingStyle: "sidebar"); var body = RenderMainSections(document, accent, headingStyle: "sidebar");
var photoMarkup = showPhoto ? $"<div class=\"fjord-photo\"><img src=\"{EncodeAttribute(photoDataUrl)}\" alt=\"Profile photo\" /></div>" : string.Empty; 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)}"; 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 Encode(string? value) => WebUtility.HtmlEncode(value ?? string.Empty);
private static string EncodeAttribute(string? value) => WebUtility.HtmlEncode(value ?? string.Empty).Replace("'", "&#39;", StringComparison.Ordinal); private static string EncodeAttribute(string? value) => WebUtility.HtmlEncode(value ?? string.Empty).Replace("'", "&#39;", StringComparison.Ordinal);
+9 -2
View File
@@ -2,11 +2,12 @@
## Current ## 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 ## 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 ## 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] 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] 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] 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.
--- ---