feat(career): theme polish, rich-text bullets, entry ordering, outline API
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:
@@ -124,6 +124,46 @@ public sealed class CvBuilderTests
|
||||
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("<script>", 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]
|
||||
public void Accent_override_reaches_the_css()
|
||||
{
|
||||
|
||||
@@ -50,11 +50,22 @@ public sealed class CvVariantController : ControllerBase
|
||||
accent = t.Accent,
|
||||
photoShape = t.PhotoShape,
|
||||
supportsIcons = t.DefaultIcons,
|
||||
atsFriendly = t.AtsFriendly,
|
||||
swatches = new[] { t.Accent, t.SidebarBg, t.Paper },
|
||||
});
|
||||
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")]
|
||||
public async Task<ActionResult<IEnumerable<CvVariantSummary>>> List(CancellationToken ct)
|
||||
{
|
||||
|
||||
@@ -35,6 +35,7 @@ public sealed class CvRenderSection
|
||||
|
||||
public sealed class CvRenderEntry
|
||||
{
|
||||
public string? Key { get; set; } // master ItemKey, for override + reorder targeting
|
||||
public string? Title { get; set; }
|
||||
public string? Subtitle { get; set; }
|
||||
public string? Meta { get; set; }
|
||||
@@ -106,6 +107,10 @@ public static class CvVariantResolver
|
||||
{
|
||||
if (cfg.Hidden) continue;
|
||||
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);
|
||||
}
|
||||
@@ -137,6 +142,7 @@ public static class CvVariantResolver
|
||||
if (ov?.Hidden == true) continue;
|
||||
section.Entries.Add(new CvRenderEntry
|
||||
{
|
||||
Key = job.Id,
|
||||
Title = Trim(ov?.Title) ?? Trim(job.Title),
|
||||
Subtitle = Trim(ov?.Subtitle) ?? JoinDot(job.Company, job.Location),
|
||||
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})";
|
||||
section.Entries.Add(new CvRenderEntry
|
||||
{
|
||||
Key = ed.Id,
|
||||
Title = Trim(ov?.Title) ?? title,
|
||||
Subtitle = Trim(ov?.Subtitle) ?? JoinDot(ed.Institution, ed.Location),
|
||||
Meta = DateRange(ed.Start, ed.End, false),
|
||||
@@ -175,6 +182,7 @@ public static class CvVariantResolver
|
||||
if (ov?.Hidden == true) continue;
|
||||
section.Entries.Add(new CvRenderEntry
|
||||
{
|
||||
Key = pr.Id,
|
||||
Title = Trim(ov?.Title) ?? Trim(pr.Name),
|
||||
Subtitle = Trim(ov?.Subtitle) ?? JoinDot(pr.Role, pr.Location),
|
||||
Meta = DateRange(pr.Start, pr.End, false),
|
||||
@@ -194,6 +202,7 @@ public static class CvVariantResolver
|
||||
if (ov?.Hidden == true) continue;
|
||||
section.Entries.Add(new CvRenderEntry
|
||||
{
|
||||
Key = c.Id,
|
||||
Title = Trim(ov?.Title) ?? Trim(c.Name),
|
||||
Subtitle = Trim(ov?.Subtitle) ?? JoinDot(c.Issuer, c.Location),
|
||||
Meta = Trim(c.Date),
|
||||
@@ -216,6 +225,18 @@ public static class CvVariantResolver
|
||||
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)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(itemKey)) return null;
|
||||
|
||||
@@ -27,6 +27,8 @@ public interface ICvVariantService
|
||||
Task<ThemedCvRenderResult?> RenderAsync(string ownerUserId, int id, CvRenderPerson person, CancellationToken ct);
|
||||
// Render arbitrary (unsaved) settings against the current master profile — live preview.
|
||||
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.
|
||||
Task<(ThemedCvRenderResult render, string ownerUserId)?> RenderPublicAsync(string publicSlug, CvRenderPerson person, CancellationToken ct);
|
||||
Task<string?> GetPublicOwnerAsync(string publicSlug, CancellationToken ct);
|
||||
@@ -153,6 +155,12 @@ public sealed class CvVariantService : ICvVariantService
|
||||
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)
|
||||
{
|
||||
var variant = await _db.CvVariants.IgnoreQueryFilters()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using JobTrackerApi.Models;
|
||||
|
||||
namespace JobTrackerApi.Services;
|
||||
@@ -152,7 +153,26 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
@@ -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-tags{{margin-top:1.4mm;}}
|
||||
{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;}}
|
||||
";
|
||||
}
|
||||
|
||||
+10
-6
@@ -47,6 +47,10 @@ public sealed class CvTheme
|
||||
|
||||
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).
|
||||
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.",
|
||||
Layout = "header-band", HeaderStyle = "band", HeadingStyle = "caps-rule",
|
||||
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()
|
||||
{
|
||||
@@ -71,7 +75,7 @@ public static class CvThemeCatalog
|
||||
Layout = "single", HeaderStyle = "plain", HeadingStyle = "plain",
|
||||
Accent = "#111827", HeadingColor = "#111827", Muted = "#6b7280",
|
||||
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()
|
||||
{
|
||||
@@ -80,7 +84,7 @@ public static class CvThemeCatalog
|
||||
Layout = "single", HeaderStyle = "centered", HeadingStyle = "underline",
|
||||
Accent = "#7c2d12", Ink = "#1c1917", Muted = "#57534e", Line = "#1c1917",
|
||||
HeadingFont = "Georgia, 'Times New Roman', serif", BodyFont = "Georgia, 'Times New Roman', serif",
|
||||
NameSizePt = 27, PhotoShape = "square",
|
||||
NameSizePt = 27, PhotoShape = "square", AtsFriendly = true,
|
||||
},
|
||||
new()
|
||||
{
|
||||
@@ -99,7 +103,7 @@ public static class CvThemeCatalog
|
||||
Layout = "single", HeaderStyle = "plain", HeadingStyle = "caps-rule",
|
||||
Accent = "#334155", Ink = "#111827", Muted = "#374151",
|
||||
HeadingFont = "Arial, Helvetica, sans-serif", BodyFont = "Arial, Helvetica, sans-serif",
|
||||
PhotoShape = "none",
|
||||
PhotoShape = "none", AtsFriendly = true,
|
||||
},
|
||||
new()
|
||||
{
|
||||
@@ -117,14 +121,14 @@ public static class CvThemeCatalog
|
||||
Layout = "single", HeaderStyle = "kicker", HeadingStyle = "underline",
|
||||
Accent = "#7c3aed", Ink = "#1f2937", Muted = "#4b5563",
|
||||
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()
|
||||
{
|
||||
Id = "creative", Name = "Creative", Category = "Creative",
|
||||
Description = "Bold accent sidebar and photo-forward header for design and product roles.",
|
||||
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",
|
||||
PhotoShape = "circle", DefaultIcons = true,
|
||||
SidebarSections = new() { "contact", "skills", "languages", "interests" },
|
||||
|
||||
@@ -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 bool Hidden { get; set; }
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user