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
+21
View File
@@ -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()
+27 -1
View File
@@ -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;}}
";
}