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:
@@ -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;}}
|
||||
";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user