feat(career): theme polish, rich-text bullets, entry ordering, outline API
CI and Deploy / test (push) Failing after 1m55s
CI and Deploy / deploy (push) Has been skipped

Phase 4.5 backend enablers.
- Themes (priority 3): AtsFriendly flag on single-column themes (surfaced in
  GET /api/cv/themes), print-quality page-break rules (entries never split
  across a page; headings stay with content; widow/orphan control), darkened
  the creative sidebar for AA contrast.
- Rich text (priority 1): bullets/summary support **bold**, *italic*,
  __underline__, [text](url) via a safe inline pass — everything is HTML-escaped
  first, so no user tag can survive; only the whitelist emits markup.
- Entry ordering (priority 1): CvSectionSetting.ItemOrder reorders entries
  within a section by ItemKey, never touching the master profile.
- Outline API: GET /api/cv/outline returns the master profile as sections+entries
  with ItemKeys, so the Content tab can render editable per-item rows.
- 3 new tests (22 total in the builder suite).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-07-18 14:38:35 +02:00
parent 585047da9e
commit e3b255f226
7 changed files with 121 additions and 7 deletions
+40
View File
@@ -124,6 +124,46 @@ public sealed class CvBuilderTests
Assert.DoesNotContain("class=\"sidebar\"", minimal.Html); Assert.DoesNotContain("class=\"sidebar\"", minimal.Html);
} }
[Fact]
public void Bullets_support_safe_inline_markdown_and_escape_everything_else()
{
var renderer = new ThemedCvRenderer();
var profile = new StructuredCvProfile { Summary = { "**bold** and *italic* and __under__ and [site](https://x.io) <script>alert(1)</script>" } };
var model = CvVariantResolver.Build(profile, new CvVariantSettings(), "F", null);
var html = renderer.Render(model, CvThemeCatalog.Resolve("modern"), new CvVariantSettings { ThemeId = "modern" }).Html;
Assert.Contains("<strong>bold</strong>", html);
Assert.Contains("<em>italic</em>", html);
Assert.Contains("<u>under</u>", html);
Assert.Contains("<a href=\"https://x.io\">site</a>", html);
Assert.Contains("&lt;script&gt;", html); // the real tag is escaped, inert
Assert.DoesNotContain("<script>", html);
}
[Fact]
public void Item_order_reorders_entries_without_touching_the_master()
{
var profile = Rich(); // jobs: job1 (Senior Eng), job2 (Eng)
var settings = new CvVariantSettings
{
Sections = { new CvSectionSetting { Key = "experience", ItemOrder = new() { "job2", "job1" } } },
};
var model = CvVariantResolver.Build(profile, settings, "F", null);
var exp = model.Sections.First(s => s.Key == "experience");
Assert.Equal(new[] { "Eng", "Senior Eng" }, exp.Entries.Select(e => e.Title));
Assert.Equal(new[] { "Senior Eng", "Eng" }, profile.Jobs.Select(j => j.Title)); // master untouched
}
[Fact]
public void Rendered_output_has_print_break_rules_and_themes_expose_ats_flag()
{
var renderer = new ThemedCvRenderer();
var model = CvVariantResolver.Build(Rich(), new CvVariantSettings(), "F", null);
var html = renderer.Render(model, CvThemeCatalog.Resolve("ats-classic"), new CvVariantSettings { ThemeId = "ats-classic" }).Html;
Assert.Contains("break-inside:avoid", html);
Assert.True(CvThemeCatalog.Resolve("ats-classic").AtsFriendly);
Assert.False(CvThemeCatalog.Resolve("technical").AtsFriendly); // sidebar = not ATS-safe
}
[Fact] [Fact]
public void Accent_override_reaches_the_css() public void Accent_override_reaches_the_css()
{ {
@@ -50,11 +50,22 @@ public sealed class CvVariantController : ControllerBase
accent = t.Accent, accent = t.Accent,
photoShape = t.PhotoShape, photoShape = t.PhotoShape,
supportsIcons = t.DefaultIcons, supportsIcons = t.DefaultIcons,
atsFriendly = t.AtsFriendly,
swatches = new[] { t.Accent, t.SidebarBg, t.Paper }, swatches = new[] { t.Accent, t.SidebarBg, t.Paper },
}); });
return Ok(themes); return Ok(themes);
} }
// The master profile as sections+entries (with ItemKeys) — the Content tab reads this to render
// editable rows without duplicating the profile shape on the client.
[HttpGet("outline")]
public async Task<ActionResult<CvRenderModel>> Outline(CancellationToken ct)
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
return Ok(await _variants.OutlineAsync(user.Id, Person(user), ct));
}
[HttpGet("variants")] [HttpGet("variants")]
public async Task<ActionResult<IEnumerable<CvVariantSummary>>> List(CancellationToken ct) public async Task<ActionResult<IEnumerable<CvVariantSummary>>> List(CancellationToken ct)
{ {
+21
View File
@@ -35,6 +35,7 @@ public sealed class CvRenderSection
public sealed class CvRenderEntry public sealed class CvRenderEntry
{ {
public string? Key { get; set; } // master ItemKey, for override + reorder targeting
public string? Title { get; set; } public string? Title { get; set; }
public string? Subtitle { get; set; } public string? Subtitle { get; set; }
public string? Meta { get; set; } public string? Meta { get; set; }
@@ -106,6 +107,10 @@ public static class CvVariantResolver
{ {
if (cfg.Hidden) continue; if (cfg.Hidden) continue;
if (!string.IsNullOrWhiteSpace(cfg.Title)) section.Title = cfg.Title!.Trim(); if (!string.IsNullOrWhiteSpace(cfg.Title)) section.Title = cfg.Title!.Trim();
if (cfg.ItemOrder is { Count: > 0 } && section.Entries.Count > 1)
{
section.Entries = ReorderByKey(section.Entries, cfg.ItemOrder);
}
} }
if (!section.IsEmpty) model.Sections.Add(section); if (!section.IsEmpty) model.Sections.Add(section);
} }
@@ -137,6 +142,7 @@ public static class CvVariantResolver
if (ov?.Hidden == true) continue; if (ov?.Hidden == true) continue;
section.Entries.Add(new CvRenderEntry section.Entries.Add(new CvRenderEntry
{ {
Key = job.Id,
Title = Trim(ov?.Title) ?? Trim(job.Title), Title = Trim(ov?.Title) ?? Trim(job.Title),
Subtitle = Trim(ov?.Subtitle) ?? JoinDot(job.Company, job.Location), Subtitle = Trim(ov?.Subtitle) ?? JoinDot(job.Company, job.Location),
Meta = DateRange(job.Start, job.End, job.IsCurrent), Meta = DateRange(job.Start, job.End, job.IsCurrent),
@@ -157,6 +163,7 @@ public static class CvVariantResolver
var title = string.IsNullOrWhiteSpace(ed.QualificationLevel) ? Trim(ed.Qualification) : $"{ed.Qualification} ({ed.QualificationLevel})"; var title = string.IsNullOrWhiteSpace(ed.QualificationLevel) ? Trim(ed.Qualification) : $"{ed.Qualification} ({ed.QualificationLevel})";
section.Entries.Add(new CvRenderEntry section.Entries.Add(new CvRenderEntry
{ {
Key = ed.Id,
Title = Trim(ov?.Title) ?? title, Title = Trim(ov?.Title) ?? title,
Subtitle = Trim(ov?.Subtitle) ?? JoinDot(ed.Institution, ed.Location), Subtitle = Trim(ov?.Subtitle) ?? JoinDot(ed.Institution, ed.Location),
Meta = DateRange(ed.Start, ed.End, false), Meta = DateRange(ed.Start, ed.End, false),
@@ -175,6 +182,7 @@ public static class CvVariantResolver
if (ov?.Hidden == true) continue; if (ov?.Hidden == true) continue;
section.Entries.Add(new CvRenderEntry section.Entries.Add(new CvRenderEntry
{ {
Key = pr.Id,
Title = Trim(ov?.Title) ?? Trim(pr.Name), Title = Trim(ov?.Title) ?? Trim(pr.Name),
Subtitle = Trim(ov?.Subtitle) ?? JoinDot(pr.Role, pr.Location), Subtitle = Trim(ov?.Subtitle) ?? JoinDot(pr.Role, pr.Location),
Meta = DateRange(pr.Start, pr.End, false), Meta = DateRange(pr.Start, pr.End, false),
@@ -194,6 +202,7 @@ public static class CvVariantResolver
if (ov?.Hidden == true) continue; if (ov?.Hidden == true) continue;
section.Entries.Add(new CvRenderEntry section.Entries.Add(new CvRenderEntry
{ {
Key = c.Id,
Title = Trim(ov?.Title) ?? Trim(c.Name), Title = Trim(ov?.Title) ?? Trim(c.Name),
Subtitle = Trim(ov?.Subtitle) ?? JoinDot(c.Issuer, c.Location), Subtitle = Trim(ov?.Subtitle) ?? JoinDot(c.Issuer, c.Location),
Meta = Trim(c.Date), Meta = Trim(c.Date),
@@ -216,6 +225,18 @@ public static class CvVariantResolver
return section; return section;
} }
// Sort entries by the variant's explicit ItemOrder; entries whose key isn't listed (or is null)
// keep their master order and come after the ordered ones.
private static List<CvRenderEntry> ReorderByKey(List<CvRenderEntry> entries, List<string> order)
{
var rank = new Dictionary<string, int>(StringComparer.Ordinal);
for (var i = 0; i < order.Count; i++) if (!string.IsNullOrWhiteSpace(order[i])) rank[order[i]] = i;
return entries
.Select((e, i) => (e, primary: e.Key != null && rank.TryGetValue(e.Key, out var r) ? r : int.MaxValue, i))
.OrderBy(x => x.primary).ThenBy(x => x.i)
.Select(x => x.e).ToList();
}
private static CvItemOverride? OverrideFor(CvVariantSettings settings, string? itemKey) private static CvItemOverride? OverrideFor(CvVariantSettings settings, string? itemKey)
{ {
if (string.IsNullOrWhiteSpace(itemKey)) return null; if (string.IsNullOrWhiteSpace(itemKey)) return null;
@@ -27,6 +27,8 @@ public interface ICvVariantService
Task<ThemedCvRenderResult?> RenderAsync(string ownerUserId, int id, CvRenderPerson person, CancellationToken ct); Task<ThemedCvRenderResult?> RenderAsync(string ownerUserId, int id, CvRenderPerson person, CancellationToken ct);
// Render arbitrary (unsaved) settings against the current master profile — live preview. // Render arbitrary (unsaved) settings against the current master profile — live preview.
Task<ThemedCvRenderResult> RenderSettingsAsync(string ownerUserId, CvVariantSettings settings, CvRenderPerson person, CancellationToken ct); Task<ThemedCvRenderResult> RenderSettingsAsync(string ownerUserId, CvVariantSettings settings, CvRenderPerson person, CancellationToken ct);
// The master profile resolved to sections+entries (with ItemKeys) for the Content tab to edit.
Task<CvRenderModel> OutlineAsync(string ownerUserId, CvRenderPerson person, CancellationToken ct);
// Render a public variant by slug (anonymous). Returns null if the slug is unknown or not public. // Render a public variant by slug (anonymous). Returns null if the slug is unknown or not public.
Task<(ThemedCvRenderResult render, string ownerUserId)?> RenderPublicAsync(string publicSlug, CvRenderPerson person, CancellationToken ct); Task<(ThemedCvRenderResult render, string ownerUserId)?> RenderPublicAsync(string publicSlug, CvRenderPerson person, CancellationToken ct);
Task<string?> GetPublicOwnerAsync(string publicSlug, CancellationToken ct); Task<string?> GetPublicOwnerAsync(string publicSlug, CancellationToken ct);
@@ -153,6 +155,12 @@ public sealed class CvVariantService : ICvVariantService
return RenderInternal(profile, settings, person); return RenderInternal(profile, settings, person);
} }
public async Task<CvRenderModel> OutlineAsync(string ownerUserId, CvRenderPerson person, CancellationToken ct)
{
var profile = await _career.LoadStructuredAsync(ownerUserId, ct);
return CvVariantResolver.Build(profile, new CvVariantSettings(), person.FallbackName, person.PhotoDataUrl);
}
public async Task<(ThemedCvRenderResult render, string ownerUserId)?> RenderPublicAsync(string publicSlug, CvRenderPerson person, CancellationToken ct) public async Task<(ThemedCvRenderResult render, string ownerUserId)?> RenderPublicAsync(string publicSlug, CvRenderPerson person, CancellationToken ct)
{ {
var variant = await _db.CvVariants.IgnoreQueryFilters() var variant = await _db.CvVariants.IgnoreQueryFilters()
+27 -1
View File
@@ -1,6 +1,7 @@
using System.Globalization; using System.Globalization;
using System.Net; using System.Net;
using System.Text; using System.Text;
using System.Text.RegularExpressions;
using JobTrackerApi.Models; using JobTrackerApi.Models;
namespace JobTrackerApi.Services; namespace JobTrackerApi.Services;
@@ -152,7 +153,26 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
} }
private static string Items(IEnumerable<string> items) => private static string Items(IEnumerable<string> items) =>
string.Join("", items.Select(i => $"<li>{Enc(i)}</li>")); string.Join("", items.Select(i => $"<li>{Inline(i)}</li>"));
// Safe inline rich text for bullets/summary. HTML-escape EVERYTHING first (so any user markup is
// inert), then re-introduce a tiny whitelist: **bold**, *italic*, __underline__, [text](url) with
// http(s)/mailto only. Because escaping runs first, no user-supplied tag can survive — the only
// tags in the output are the ones these patterns emit.
private static readonly Regex LinkRx = new(@"\[([^\]]+)\]\((https?://[^\s)]+|mailto:[^\s)]+)\)", RegexOptions.Compiled);
private static readonly Regex BoldRx = new(@"\*\*(.+?)\*\*", RegexOptions.Compiled);
private static readonly Regex UnderlineRx = new(@"__(.+?)__", RegexOptions.Compiled);
private static readonly Regex ItalicRx = new(@"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)", RegexOptions.Compiled);
private static string Inline(string? value)
{
var s = Enc(value);
s = LinkRx.Replace(s, m => $"<a href=\"{m.Groups[2].Value}\">{m.Groups[1].Value}</a>");
s = BoldRx.Replace(s, "<strong>$1</strong>");
s = UnderlineRx.Replace(s, "<u>$1</u>");
s = ItalicRx.Replace(s, "<em>$1</em>");
return s;
}
private static string BuildCss(CvTheme t, string accent, string headingColor, string headingFont, string bodyFont, double density, (string w, string h) page, bool twoColumn) private static string BuildCss(CvTheme t, string accent, string headingColor, string headingFont, string bodyFont, double density, (string w, string h) page, bool twoColumn)
{ {
@@ -219,6 +239,12 @@ h1,h2{{font-family:{headingFont};}}
.entry-subtitle{{color:{t.Muted};font-size:{F(t.BodySizePt)}pt;margin:.4mm 0 1.2mm 0;}} .entry-subtitle{{color:{t.Muted};font-size:{F(t.BodySizePt)}pt;margin:.4mm 0 1.2mm 0;}}
.entry-tags{{margin-top:1.4mm;}} .entry-tags{{margin-top:1.4mm;}}
{layoutCss} {layoutCss}
/* Print quality: keep an entry whole across a page break, keep a heading with its content, and
avoid single dangling lines. Chromium honours these in the Playwright PDF pass. */
.entry{{break-inside:avoid;page-break-inside:avoid;}}
.section-title{{break-after:avoid;page-break-after:avoid;}}
.tag,.contact-item{{break-inside:avoid;}}
.bullets li{{orphans:2;widows:2;}}
@page{{size:{page.w} {page.h};margin:0;}} @page{{size:{page.w} {page.h};margin:0;}}
"; ";
} }
+10 -6
View File
@@ -47,6 +47,10 @@ public sealed class CvTheme
public bool DefaultIcons { get; init; } public bool DefaultIcons { get; init; }
// ATS-friendly = single-column, no sidebar tables/graphics that trip naive resume parsers. Set on
// the single-column themes; surfaced in GET /api/cv/themes so the picker can badge it.
public bool AtsFriendly { get; init; }
// Which section keys render in the sidebar for two-column layouts (ignored for single/header-band). // Which section keys render in the sidebar for two-column layouts (ignored for single/header-band).
public List<string> SidebarSections { get; init; } = new() { "contact", "skills", "languages" }; public List<string> SidebarSections { get; init; } = new() { "contact", "skills", "languages" };
} }
@@ -62,7 +66,7 @@ public static class CvThemeCatalog
Description = "Clean SaaS-style layout with a confident accent header.", Description = "Clean SaaS-style layout with a confident accent header.",
Layout = "header-band", HeaderStyle = "band", HeadingStyle = "caps-rule", Layout = "header-band", HeaderStyle = "band", HeadingStyle = "caps-rule",
Accent = "#2563eb", HeadingFont = "'Segoe UI', Roboto, Arial, sans-serif", BodyFont = "'Segoe UI', Roboto, Arial, sans-serif", Accent = "#2563eb", HeadingFont = "'Segoe UI', Roboto, Arial, sans-serif", BodyFont = "'Segoe UI', Roboto, Arial, sans-serif",
PhotoShape = "rounded", DefaultIcons = true, PhotoShape = "rounded", DefaultIcons = true, AtsFriendly = true,
}, },
new() new()
{ {
@@ -71,7 +75,7 @@ public static class CvThemeCatalog
Layout = "single", HeaderStyle = "plain", HeadingStyle = "plain", Layout = "single", HeaderStyle = "plain", HeadingStyle = "plain",
Accent = "#111827", HeadingColor = "#111827", Muted = "#6b7280", Accent = "#111827", HeadingColor = "#111827", Muted = "#6b7280",
HeadingFont = "'Helvetica Neue', Arial, sans-serif", BodyFont = "'Helvetica Neue', Arial, sans-serif", HeadingFont = "'Helvetica Neue', Arial, sans-serif", BodyFont = "'Helvetica Neue', Arial, sans-serif",
SectionGapMm = 7, EntryGapMm = 5, PhotoShape = "none", SectionGapMm = 7, EntryGapMm = 5, PhotoShape = "none", AtsFriendly = true,
}, },
new() new()
{ {
@@ -80,7 +84,7 @@ public static class CvThemeCatalog
Layout = "single", HeaderStyle = "centered", HeadingStyle = "underline", Layout = "single", HeaderStyle = "centered", HeadingStyle = "underline",
Accent = "#7c2d12", Ink = "#1c1917", Muted = "#57534e", Line = "#1c1917", Accent = "#7c2d12", Ink = "#1c1917", Muted = "#57534e", Line = "#1c1917",
HeadingFont = "Georgia, 'Times New Roman', serif", BodyFont = "Georgia, 'Times New Roman', serif", HeadingFont = "Georgia, 'Times New Roman', serif", BodyFont = "Georgia, 'Times New Roman', serif",
NameSizePt = 27, PhotoShape = "square", NameSizePt = 27, PhotoShape = "square", AtsFriendly = true,
}, },
new() new()
{ {
@@ -99,7 +103,7 @@ public static class CvThemeCatalog
Layout = "single", HeaderStyle = "plain", HeadingStyle = "caps-rule", Layout = "single", HeaderStyle = "plain", HeadingStyle = "caps-rule",
Accent = "#334155", Ink = "#111827", Muted = "#374151", Accent = "#334155", Ink = "#111827", Muted = "#374151",
HeadingFont = "Arial, Helvetica, sans-serif", BodyFont = "Arial, Helvetica, sans-serif", HeadingFont = "Arial, Helvetica, sans-serif", BodyFont = "Arial, Helvetica, sans-serif",
PhotoShape = "none", PhotoShape = "none", AtsFriendly = true,
}, },
new() new()
{ {
@@ -117,14 +121,14 @@ public static class CvThemeCatalog
Layout = "single", HeaderStyle = "kicker", HeadingStyle = "underline", Layout = "single", HeaderStyle = "kicker", HeadingStyle = "underline",
Accent = "#7c3aed", Ink = "#1f2937", Muted = "#4b5563", Accent = "#7c3aed", Ink = "#1f2937", Muted = "#4b5563",
HeadingFont = "Georgia, 'Times New Roman', serif", BodyFont = "'Segoe UI', Arial, sans-serif", HeadingFont = "Georgia, 'Times New Roman', serif", BodyFont = "'Segoe UI', Arial, sans-serif",
NameSizePt = 26, SectionGapMm = 7, PhotoShape = "circle", NameSizePt = 26, SectionGapMm = 7, PhotoShape = "circle", AtsFriendly = true,
}, },
new() new()
{ {
Id = "creative", Name = "Creative", Category = "Creative", Id = "creative", Name = "Creative", Category = "Creative",
Description = "Bold accent sidebar and photo-forward header for design and product roles.", Description = "Bold accent sidebar and photo-forward header for design and product roles.",
Layout = "sidebar-left", HeaderStyle = "band", HeadingStyle = "bar", Layout = "sidebar-left", HeaderStyle = "band", HeadingStyle = "bar",
Accent = "#db2777", Ink = "#18181b", Muted = "#52525b", SidebarBg = "#db2777", SidebarInk = "#ffffff", Accent = "#db2777", Ink = "#18181b", Muted = "#52525b", SidebarBg = "#be185d", SidebarInk = "#ffffff",
HeadingFont = "'Poppins', 'Segoe UI', Arial, sans-serif", BodyFont = "'Segoe UI', Arial, sans-serif", HeadingFont = "'Poppins', 'Segoe UI', Arial, sans-serif", BodyFont = "'Segoe UI', Arial, sans-serif",
PhotoShape = "circle", DefaultIcons = true, PhotoShape = "circle", DefaultIcons = true,
SidebarSections = new() { "contact", "skills", "languages", "interests" }, SidebarSections = new() { "contact", "skills", "languages", "interests" },
+4
View File
@@ -39,6 +39,10 @@ public sealed class CvSectionSetting
public string Key { get; set; } = string.Empty; // summary|skills|experience|education|projects|certifications|languages|interests|links|custom:<k> public string Key { get; set; } = string.Empty; // summary|skills|experience|education|projects|certifications|languages|interests|links|custom:<k>
public bool Hidden { get; set; } public bool Hidden { get; set; }
public string? Title { get; set; } // renamed heading public string? Title { get; set; } // renamed heading
// Explicit entry order for this section, as a list of item ItemKeys. Entries not listed keep their
// master order and come last. Null => master profile order. Lets a variant reorder entries without
// touching the master profile.
public List<string>? ItemOrder { get; set; }
} }
public sealed class CvItemOverride public sealed class CvItemOverride