@dytsou/resume-converter 2.0.3 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Main conversion logic
3
+ * Orchestrates the LaTeX to HTML conversion process
4
+ */
5
+
6
+ import { parse } from '@unified-latex/unified-latex-util-parse';
7
+ import { unified } from 'unified';
8
+ import { unifiedLatexToHast } from '@unified-latex/unified-latex-to-hast';
9
+ import { toHtml } from 'hast-util-to-html';
10
+
11
+ import { extractMetadata } from './utils.mjs';
12
+ import { extractMacroMatches } from './latex-parser.mjs';
13
+ import { replaceIconMacros } from './html-helpers.mjs';
14
+ import {
15
+ processTitleBlock,
16
+ promoteHeadings,
17
+ processAbstract,
18
+ replaceMathPipes,
19
+ processContactHeader,
20
+ processTrioHeadings,
21
+ processQuadDetails,
22
+ mergeDateRanges,
23
+ processTechnicalSkills,
24
+ processQuadHeadings,
25
+ processListMacros,
26
+ processHeadingListMacros,
27
+ cleanupParagraphWrappers,
28
+ applyFinalCleanups,
29
+ } from './html-transformers.mjs';
30
+ import { wrapInHtmlTemplate } from './html-template.mjs';
31
+
32
+ /**
33
+ * Converts LaTeX content to styled HTML
34
+ */
35
+ export function convertLatexToHtml(latexContent, filename) {
36
+ try {
37
+ // Parse LaTeX to AST
38
+ const ast = parse(latexContent);
39
+
40
+ // Convert AST to HAST (HTML AST)
41
+ const hast = unified().use(unifiedLatexToHast).runSync(ast);
42
+
43
+ // Generate raw HTML from HAST
44
+ let html = toHtml(hast);
45
+
46
+ // Extract metadata
47
+ const metadata = extractMetadata(latexContent);
48
+
49
+ // Extract macro matches for processing
50
+ const macros = extractMacroMatches(latexContent);
51
+
52
+ // Apply transformations in order
53
+ html = processTitleBlock(html, metadata);
54
+ html = promoteHeadings(html);
55
+ html = processAbstract(html);
56
+ html = replaceMathPipes(html);
57
+ html = processContactHeader(html);
58
+ html = processTrioHeadings(html, macros.trio);
59
+ html = processQuadDetails(html, macros.quadDetails);
60
+ html = mergeDateRanges(html);
61
+ html = processTechnicalSkills(html, macros.sectionType);
62
+ html = processQuadHeadings(html, macros.quadHeading);
63
+ html = processListMacros(html);
64
+ html = processHeadingListMacros(html);
65
+ html = cleanupParagraphWrappers(html);
66
+ html = replaceIconMacros(html);
67
+ html = applyFinalCleanups(html);
68
+
69
+ // Wrap in full HTML document
70
+ const styledHtml = wrapInHtmlTemplate(html, metadata);
71
+
72
+ return { html: styledHtml, metadata, success: true, error: null };
73
+ } catch (error) {
74
+ return { html: null, metadata: null, success: false, error: error.message };
75
+ }
76
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * HTML transformation helper functions
3
+ */
4
+
5
+ import { ICON_MAP } from './config.mjs';
6
+
7
+ /**
8
+ * Replaces FontAwesome icon macros with proper HTML elements
9
+ */
10
+ export function replaceIconMacros(html) {
11
+ return html.replace(
12
+ /<span class="macro macro-(fa\w+)"><\/span>/g,
13
+ (_, macro) => {
14
+ const cls = ICON_MAP[macro] || 'fas fa-circle';
15
+ return `<i class="${cls}"></i>`;
16
+ }
17
+ );
18
+ }
19
+
20
+ /**
21
+ * Cleans up text by removing line breaks and normalizing spaces
22
+ */
23
+ export function cleanText(text) {
24
+ return text
25
+ .replace(/<br class="linebreak">/g, ' ')
26
+ .replace(
27
+ /<span class="inline-math">\|<\/span>/g,
28
+ '<span class="sep">·</span>'
29
+ )
30
+ .replace(/class="href"/g, '')
31
+ .trim();
32
+ }
33
+
34
+ /**
35
+ * Extracts URL and display text from LaTeX \href command
36
+ */
37
+ export function parseHrefCommand(hrefString) {
38
+ const match = hrefString.match(/\\href\{([^}]+)\}\{([^}]+)\}/);
39
+ if (!match) return null;
40
+
41
+ const [, url, text] = match;
42
+ const cleanedText = text
43
+ .replace(/\\uline\{([^}]+)\}/g, '$1')
44
+ .replace(/\\uline\{([^}]*)$/g, '$1');
45
+
46
+ return { url, text: cleanedText };
47
+ }
48
+
49
+ /**
50
+ * Converts href command to HTML anchor element
51
+ */
52
+ export function hrefToAnchor(hrefString, makeBold = false) {
53
+ if (!hrefString.includes('\\href{')) {
54
+ return makeBold ? `<strong>${hrefString}</strong>` : hrefString;
55
+ }
56
+
57
+ const parsed = parseHrefCommand(hrefString);
58
+ if (!parsed) {
59
+ return makeBold ? `<strong>${hrefString}</strong>` : hrefString;
60
+ }
61
+
62
+ const content = makeBold ? `<strong>${parsed.text}</strong>` : parsed.text;
63
+ return `<a href="${parsed.url}" target="_blank" rel="noopener noreferrer">${content}</a>`;
64
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * HTML template and CSS styles
3
+ */
4
+
5
+ /**
6
+ * Returns the CSS styles for the HTML document
7
+ */
8
+ export function getStyles() {
9
+ return `
10
+ html, body {
11
+ background: #fff;
12
+ margin: 0;
13
+ padding: 0;
14
+ }
15
+ body {
16
+ max-width: 950px;
17
+ width: 100%;
18
+ margin: 0 auto;
19
+ padding: 2rem;
20
+ font-family: "Source Sans 3", system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
21
+ line-height: 1.6;
22
+ color: #000;
23
+ background: #fff;
24
+ box-sizing: border-box;
25
+ }
26
+ * { box-sizing: border-box; }
27
+ h1, h2, h3, h4, h5, h6 {
28
+ margin-top: 1.5em;
29
+ margin-bottom: 0.5em;
30
+ font-weight: bold;
31
+ }
32
+ h1 { font-size: 5em; text-align: center; }
33
+ h2 { font-size: 1.5em; font-variant: small-caps; color: #1e3a8a; border-bottom: 1px solid #1e3a8a; padding-bottom: 0.25rem; margin-top: 1.2em; }
34
+ h3 { font-size: 1.2em; }
35
+ .author { text-align: center; font-style: italic; margin: 1em 0; }
36
+ .date { text-align: center; margin-bottom: 2em; }
37
+ .title { margin-top: 0.5em; }
38
+ p { margin: 1em 0; text-align: left; }
39
+ .theorem, .lemma, .proposition, .corollary {
40
+ font-style: italic;
41
+ margin: 1em 0;
42
+ padding: 0.5em;
43
+ border-left: 3px solid #333;
44
+ }
45
+ .proof { margin: 1em 0 1em 2em; }
46
+ code, pre {
47
+ font-family: "Courier New", monospace;
48
+ background: #f5f5f5;
49
+ padding: 0.2em 0.4em;
50
+ }
51
+ pre { padding: 1em; overflow-x: auto; }
52
+ .equation { margin: 1em 0; overflow-x: auto; }
53
+ a { color: #0066cc; text-decoration: none; cursor: pointer; }
54
+ a:hover { color: #004499; text-decoration: none; }
55
+ a:visited { color: #551a8b; text-decoration: none; }
56
+ .resume-items { margin: 0.25rem 0 1rem 1.25rem; }
57
+ .resume-items li { margin: 0.25rem 0; }
58
+ .resume-heading-list { margin: 0.25rem 0 0.5rem 0; }
59
+ .contact { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 0.75rem; margin-bottom: 1rem; }
60
+ .contact.centered { grid-template-columns: 1fr; text-align: center; }
61
+ .contact.centered .contact-name { justify-self: center; }
62
+ .contact.centered .contact-links { justify-self: center; }
63
+ .contact-name { font-size: 1.75rem; font-weight: 700; }
64
+ .contact-links { color: #111; display: flex; flex-wrap: wrap; justify-content: center; gap: 0.5rem; align-items: center; }
65
+ .contact-links a { display: inline-flex; align-items: center; gap: 0.25rem; }
66
+ .contact-links i {
67
+ color: #1e3a8a;
68
+ font-style: normal;
69
+ font-variant: normal;
70
+ text-rendering: auto;
71
+ -webkit-font-smoothing: antialiased;
72
+ display: inline-block;
73
+ font-family: "Font Awesome 6 Free", "Font Awesome 6 Brands", "Font Awesome 6 Pro";
74
+ font-weight: 900;
75
+ }
76
+ .contact-links i.fab { font-family: "Font Awesome 6 Brands"; font-weight: 400; }
77
+ .contact-links > i { display: inline-flex; align-items: center; gap: 0.25rem; }
78
+ .contact-sep { color: #666; margin: 0 0.25rem; }
79
+ .contact-mobile { display: inline-flex; align-items: center; gap: 0.25rem; }
80
+ .contact-right { text-align: right; white-space: nowrap; }
81
+ .sep { margin: 0 0.35rem; color: #666; }
82
+ .trio { display: grid; grid-template-columns: 1fr auto auto; gap: 0.5rem; align-items: baseline; margin: 0.25rem 0; position: relative; }
83
+ .trio-title { justify-self: start; white-space: nowrap; }
84
+ .trio-tech { position: absolute; left: 50%; transform: translateX(-50%); color: #374151; white-space: nowrap; }
85
+ .trio-link { justify-self: end; white-space: nowrap; }
86
+ .quad, .quad-details { margin: 0.25rem 0; }
87
+ .row { display: grid; grid-template-columns: 1fr auto; align-items: baseline; }
88
+ .row .left, .row .right { white-space: nowrap; }
89
+ .row .right { text-align: right; color: #374151; }
90
+ .skill-row { display: grid; grid-template-columns: 0.28fr 0.01fr 0.71fr; align-items: start; gap: 0.5rem; }
91
+ .skill-label { font-weight: 700; }
92
+ .skill-sep { text-align: center; }
93
+ .macro { display: none; }
94
+ .converter-footer {
95
+ margin-top: 3rem;
96
+ padding-top: 1rem;
97
+ border-top: 1px solid #e5e7eb;
98
+ text-align: center;
99
+ font-size: 0.875rem;
100
+ color: #6b7280;
101
+ }
102
+ .converter-footer a { color: #3b82f6; text-decoration: none; }
103
+ .converter-footer a:hover { text-decoration: underline; }
104
+
105
+ @media (max-width: 768px) {
106
+ body { padding: 1rem; }
107
+ h1 { font-size: 5em; }
108
+ h2 { font-size: 1.25em; margin-top: 1em; }
109
+ .contact-name { font-size: 1.5rem; }
110
+ .contact-links { font-size: 0.9rem; gap: 0.4rem; }
111
+ .trio { grid-template-columns: 1fr; gap: 0.25rem; margin: 0.5rem 0; }
112
+ .trio-title { justify-self: start; white-space: normal; }
113
+ .trio-tech { position: static; transform: none; left: auto; justify-self: start; white-space: normal; }
114
+ .trio-link { justify-self: start; white-space: normal; }
115
+ .row { grid-template-columns: 1fr; gap: 0.25rem; }
116
+ .row .left, .row .right { white-space: normal; }
117
+ .row .right { text-align: left; }
118
+ .skill-row { grid-template-columns: 1fr; gap: 0.25rem; }
119
+ .skill-label { margin-bottom: 0.25rem; }
120
+ .skill-sep { display: none; }
121
+ .skill-content { margin-left: 0; }
122
+ .contact { grid-template-columns: 1fr; gap: 0.5rem; }
123
+ .contact-right { text-align: left; white-space: normal; }
124
+ }
125
+
126
+ @media (max-width: 480px) {
127
+ body { padding: 0.75rem; }
128
+ h1 { font-size: 5em; }
129
+ h2 { font-size: 1.1em; }
130
+ .contact-name { font-size: 1.25rem; }
131
+ .contact-links { font-size: 0.85rem; flex-direction: column; align-items: flex-start; }
132
+ .resume-items { margin-left: 1rem; }
133
+ }
134
+ `;
135
+ }
136
+
137
+ /**
138
+ * Wraps content in full HTML document template
139
+ */
140
+ export function wrapInHtmlTemplate(content, metadata) {
141
+ const currentYear = new Date().getFullYear();
142
+ return `<!DOCTYPE html>
143
+ <html lang="en">
144
+ <head>
145
+ <meta charset="UTF-8">
146
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
147
+ <title>${metadata.title}</title>
148
+ <script id="MathJax-script" async src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"></script>
149
+ <link rel="preconnect" href="https://fonts.googleapis.com">
150
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
151
+ <link href="https://fonts.googleapis.com/css2?family=Source+Sans+3:ital,wght@0,300..900;1,300..900&display=swap" rel="stylesheet">
152
+ <!-- Font Awesome 6.5.2 - Primary CDN -->
153
+ <link rel="stylesheet" href="https://use.fontawesome.com/releases/v6.5.2/css/all.css" integrity="sha384-B4dIYHKNBt8Bc12p+WXckhzcICo0wtJAoU8YZTY5qE0Id1GSseTk6S+L3BlXeVIU" crossorigin="anonymous" />
154
+ <!-- Font Awesome Fallback CDN -->
155
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer" />
156
+ <style>${getStyles()}</style>
157
+ </head>
158
+ <body>
159
+ ${content}
160
+ <div class="converter-footer">
161
+ Generated with <a href="https://github.com/dytsou/resume" target="_blank" rel="noopener">LaTeX to HTML Converter</a><br>
162
+ © ${currentYear} Tsou, Dong-You. Licensed under <a href="https://github.com/dytsou/resume/blob/main/LICENSE" target="_blank" rel="noopener">MIT License</a>
163
+ </div>
164
+ </body>
165
+ </html>`;
166
+ }
@@ -0,0 +1,366 @@
1
+ /**
2
+ * HTML post-processing transformation functions
3
+ * Each function applies a specific transformation to the HTML
4
+ */
5
+
6
+ import { LIST_MARKERS } from './config.mjs';
7
+ import { replaceIconMacros, cleanText, hrefToAnchor } from './html-helpers.mjs';
8
+
9
+ /**
10
+ * Replaces \maketitle placeholder with proper title block
11
+ */
12
+ export function processTitleBlock(html, metadata) {
13
+ const maketitleRegex = /<p><span class="macro macro-maketitle"><\/span><\/p>/;
14
+ const titleBlock = `
15
+ <h1 class="title">${metadata.title}</h1>
16
+ <div class="author">${metadata.author}</div>
17
+ <div class="date">${metadata.date}</div>
18
+ `;
19
+
20
+ return maketitleRegex.test(html)
21
+ ? html.replace(maketitleRegex, titleBlock)
22
+ : html;
23
+ }
24
+
25
+ /**
26
+ * Promotes heading levels (section->h2, subsection->h3)
27
+ */
28
+ export function promoteHeadings(html) {
29
+ // Promote h4 to h3 first to avoid double promotion
30
+ let result = html
31
+ .replace(/<h4(\b[^>]*)>/g, '<h3$1>')
32
+ .replace(/<\/h4>/g, '</h3>');
33
+
34
+ // Then promote h3 to h2
35
+ result = result
36
+ .replace(/<h3(\b[^>]*)>/g, '<h2$1>')
37
+ .replace(/<\/h3>/g, '</h2>');
38
+
39
+ return result;
40
+ }
41
+
42
+ /**
43
+ * Adds Abstract heading before abstract environment
44
+ */
45
+ export function processAbstract(html) {
46
+ return html.replace(
47
+ /(<div class="environment abstract">)/,
48
+ '<h2>Abstract</h2>$1'
49
+ );
50
+ }
51
+
52
+ /**
53
+ * Replaces math pipe separators with typographic dots
54
+ */
55
+ export function replaceMathPipes(html) {
56
+ return html.replace(
57
+ /<span class="inline-math">\|<\/span>/g,
58
+ '<span class="sep">·</span>'
59
+ );
60
+ }
61
+
62
+ /**
63
+ * Converts tabular contact header to grid layout
64
+ */
65
+ export function processContactHeader(html) {
66
+ return html.replace(
67
+ /<div class="environment tabular(?:\*|x)">([\s\S]*?)<\/div>(?=\s*<h2>)/,
68
+ (match, inner) => {
69
+ const innerClean = replaceIconMacros(
70
+ inner
71
+ .replace(/\n/g, ' ')
72
+ .replace(/>\s*[Xlcrp@{}]+(?=\s|<)/g, ' ')
73
+ .replace(/<span class="vspace"[^>]*><\/span>/g, ' ')
74
+ .replace(/<span class="macro macro-uline"><\/span>/g, '')
75
+ .replace(/\s{2,}/g, ' ')
76
+ .trim()
77
+ );
78
+
79
+ const nameMatch = innerClean.match(
80
+ /<span class="textsize-Huge">([\s\S]*?)<\/span>/
81
+ );
82
+ const name = nameMatch ? nameMatch[1].trim() : '';
83
+ const afterName = innerClean.replace(
84
+ /^[\s\S]*?<br class="linebreak">\s*/,
85
+ ''
86
+ );
87
+ const ampIdx = afterName.indexOf('&#x26;');
88
+
89
+ let primary = afterName;
90
+ let secondary = '';
91
+ if (ampIdx !== -1) {
92
+ primary = afterName.slice(0, ampIdx).trim();
93
+ secondary = afterName.slice(ampIdx + 5).trim();
94
+ }
95
+
96
+ let primaryClean = cleanText(primary);
97
+ // Wrap mobile icon and phone number together
98
+ primaryClean = primaryClean.replace(
99
+ /(<i class="fas fa-(?:mobile|mobile-alt|phone)"><\/i>)\s*([+\d\s\-]+)/g,
100
+ '<span class="contact-mobile">$1 $2</span>'
101
+ );
102
+
103
+ const secondaryClean = cleanText(secondary).replace(
104
+ /^\s*[;:,\-–]\s*/,
105
+ ''
106
+ );
107
+ const alignmentClass = secondaryClean
108
+ ? 'contact dual'
109
+ : 'contact centered';
110
+ const rightColumn = secondaryClean
111
+ ? `<div class="contact-right">${secondaryClean}</div>`
112
+ : '';
113
+
114
+ return `
115
+ <div class="${alignmentClass}">
116
+ <div class="contact-left">
117
+ <div class="contact-name">${name}</div>
118
+ <div class="contact-links">${primaryClean}</div>
119
+ </div>
120
+ ${rightColumn}
121
+ </div>`;
122
+ }
123
+ );
124
+ }
125
+
126
+ /**
127
+ * Processes resumeTrioHeading macros
128
+ */
129
+ export function processTrioHeadings(html, trioMatches) {
130
+ let trioIdx = 0;
131
+
132
+ return html.replace(
133
+ /<span class="macro macro-resumeTrioHeading"><\/span>([\s\S]*?)(?=(<ul|<span|<\/div|<\/p))/g,
134
+ (m) => {
135
+ const parsed = trioMatches[trioIdx++];
136
+ if (!parsed) return m;
137
+
138
+ const [, title, tech, linkRaw] = parsed;
139
+ const anchorHtml = hrefToAnchor(linkRaw);
140
+
141
+ return `
142
+ <div class="trio">
143
+ <div class="trio-title"><strong>${title.trim()}</strong></div>
144
+ <div class="trio-tech"><em>${tech.trim()}</em></div>
145
+ <div class="trio-link">${anchorHtml}</div>
146
+ </div>`;
147
+ }
148
+ );
149
+ }
150
+
151
+ /**
152
+ * Processes resumeQuadHeadingDetails macros
153
+ */
154
+ export function processQuadDetails(html, quadDetailsMatches) {
155
+ let idx = 0;
156
+
157
+ return html.replace(
158
+ /<span class="macro macro-resumeQuadHeadingDetails"><\/span>([\s\S]*?)(?=(<ul|<span|<\/div|<\/p))/g,
159
+ (m) => {
160
+ const parsed = quadDetailsMatches[idx++];
161
+ if (!parsed) return m;
162
+
163
+ const [, url, dateText, roleText] = parsed;
164
+
165
+ const cleanDateText = dateText
166
+ .replace(/\s*--\s*/g, ' – ')
167
+ .replace(/\s+/g, ' ')
168
+ .trim();
169
+
170
+ const cleanRoleText = roleText.replace(/\s+/g, ' ').trim();
171
+ const anchorHtml = hrefToAnchor(url, true);
172
+
173
+ return `
174
+ <div class="quad-details">
175
+ <div class="row"><div class="left">${anchorHtml}</div><div class="right"><span class="date">${cleanDateText}</span></div></div>
176
+ <div class="row"><div class="left"><em>${cleanRoleText}</em></div><div class="right"></div></div>
177
+ </div>`;
178
+ }
179
+ );
180
+ }
181
+
182
+ /**
183
+ * Merges split date ranges in quad-details blocks
184
+ */
185
+ export function mergeDateRanges(html) {
186
+ // Case A: "Oct 2023 –" + left em starting with "Present ..."
187
+ return html.replace(
188
+ /(<div class="quad-details">[\s\S]*?<span class="date">)\s*([^<]*?–)\s*(<\/span>[\s\S]*?<div class="left"><em>)\s*Present\s+([^<]*?)(<\/em>)/g,
189
+ (m, pre, startDash, mid, roleRest, end) =>
190
+ `${pre}${startDash} Present${mid}${roleRest}${end}`
191
+ );
192
+ }
193
+
194
+ /**
195
+ * Processes Technical Skills section with sectionType macros
196
+ */
197
+ export function processTechnicalSkills(html, sectionTypeMatches) {
198
+ const rows = sectionTypeMatches
199
+ .map((s) => {
200
+ const label = s[1].trim().replace(/\\&/g, '&');
201
+ const sep = s[2].trim();
202
+ const content = s[3].trim();
203
+
204
+ return `
205
+ <div class="skill-row">
206
+ <div class="skill-label"><strong>${label}</strong></div>
207
+ <div class="skill-sep">${sep}</div>
208
+ <div class="skill-content">${content}</div>
209
+ </div>`;
210
+ })
211
+ .join('');
212
+
213
+ return html.replace(
214
+ /<h2>Technical Skills<\/h2>[\s\S]*?(?=<h2>)/,
215
+ `<h2>Technical Skills</h2><div class="resume-heading-list">${rows}</div>\n`
216
+ );
217
+ }
218
+
219
+ /**
220
+ * Processes resumeQuadHeading macros (for Education section)
221
+ */
222
+ export function processQuadHeadings(html, quadHeadingMatches) {
223
+ let idx = 0;
224
+
225
+ return html.replace(
226
+ /<span class="macro macro-resumeQuadHeading"><\/span>([^<]*?)\s*(Bachelor of[^<]*?)\s*([A-Z][a-z]{2,9}\.\s\d{4}\s[–-]\s[A-Z][a-z]{2,9}\.\s\d{4})/g,
227
+ (m, pre, degreeLine, dates) => {
228
+ const parsed = quadHeadingMatches[idx++];
229
+
230
+ if (parsed) {
231
+ const uni = parsed[1].trim();
232
+ const loc = parsed[2].trim();
233
+ const degree = parsed[3].trim();
234
+ const dateRange = (parsed[4] || dates)
235
+ .replace(/\s*--\s*/g, ' – ')
236
+ .replace(/\s+/g, ' ')
237
+ .trim();
238
+
239
+ return `
240
+ <div class="quad">
241
+ <div class="row"><div class="left"><strong>${uni}</strong></div><div class="right">${loc}</div></div>
242
+ <div class="row"><div class="left"><em>${degree}</em></div><div class="right"><em>${dateRange}</em></div></div>
243
+ </div>`;
244
+ }
245
+
246
+ // Fallback parsing
247
+ const preTrim = (pre || '').replace(/\s+/g, ' ').trim();
248
+ let uni = preTrim;
249
+ let loc = '';
250
+
251
+ const cityCountryMatch = preTrim.match(
252
+ /^(.*?),\s*([A-Za-z''\- ]+,\s*[A-Za-z''\- ]+)$/
253
+ );
254
+ if (cityCountryMatch) {
255
+ uni = cityCountryMatch[1].trim();
256
+ loc = cityCountryMatch[2].trim();
257
+ }
258
+
259
+ const cleanDates = dates
260
+ .replace(/\s*--\s*/g, ' – ')
261
+ .replace(/\s+/g, ' ')
262
+ .trim();
263
+
264
+ return `
265
+ <div class="quad">
266
+ <div class="row"><div class="left"><strong>${uni}</strong></div><div class="right">${loc}</div></div>
267
+ <div class="row"><div class="left"><em>${degreeLine}</em></div><div class="right"><em>${cleanDates}</em></div></div>
268
+ </div>`;
269
+ }
270
+ );
271
+ }
272
+
273
+ /**
274
+ * Converts custom list macros to semantic HTML lists
275
+ */
276
+ export function processListMacros(html) {
277
+ let result = html;
278
+ let searchStart = 0;
279
+
280
+ while (true) {
281
+ const startIdx = result.indexOf(LIST_MARKERS.start, searchStart);
282
+ if (startIdx === -1) break;
283
+
284
+ const endIdx = result.indexOf(LIST_MARKERS.end, startIdx);
285
+ if (endIdx === -1) break;
286
+
287
+ const before = result.slice(0, startIdx);
288
+ const inner = result.slice(startIdx + LIST_MARKERS.start.length, endIdx);
289
+ const after = result.slice(endIdx + LIST_MARKERS.end.length);
290
+
291
+ const parts = inner.split(LIST_MARKERS.item).map((s) => s.trim());
292
+ const items = parts
293
+ .filter((part) => part)
294
+ .map((part) => `<li>${part}</li>`)
295
+ .join('');
296
+
297
+ const ul = `<ul class="resume-items">${items}</ul>`;
298
+ result = before + ul + after;
299
+ searchStart = before.length + ul.length;
300
+ }
301
+
302
+ return result;
303
+ }
304
+
305
+ /**
306
+ * Processes heading list macros
307
+ */
308
+ export function processHeadingListMacros(html) {
309
+ return html
310
+ .replace(
311
+ /<span class="macro macro-resumeHeadingListStart"><\/span>/g,
312
+ '<div class="resume-heading-list">'
313
+ )
314
+ .replace(
315
+ /<span class="macro macro-resumeHeadingListEnd"><\/span>/g,
316
+ '</div>'
317
+ );
318
+ }
319
+
320
+ /**
321
+ * Cleans up paragraph wrappers around block elements
322
+ */
323
+ export function cleanupParagraphWrappers(html) {
324
+ return html
325
+ .replace(/<p>\s*(<ul class="resume-items">)/g, '$1')
326
+ .replace(/(<\/ul>)\s*<\/p>/g, '$1')
327
+ .replace(/<p>\s*(<div class="resume-heading-list">)/g, '$1')
328
+ .replace(/(<\/div>)\s*<\/p>/g, '$1')
329
+ .replace(
330
+ /<p>\s*(<(?:div) class="(?:contact|trio|quad|quad-details)">)/g,
331
+ '$1'
332
+ )
333
+ .replace(/(<\/(?:div)>)\s*<\/p>/g, '$1')
334
+ .replace(/<\/p>\s*(<div class="trio">)/g, '$1')
335
+ .replace(
336
+ /(<\/div>\s*<\/div>\s*)<div class="trio-tech">[\s\S]*?<\/div>\s*<div class="trio-link">[\s\S]*?<\/div>/,
337
+ '$1'
338
+ );
339
+ }
340
+
341
+ /**
342
+ * Applies final HTML cleanup and fixes
343
+ */
344
+ export function applyFinalCleanups(html) {
345
+ return (
346
+ html
347
+ // Fix missing spaces after percent symbols
348
+ .replace(/(\d+)%([a-zA-Z])/g, '$1% $2')
349
+ // Clean up leftover macro artifacts
350
+ .replace(/<span class="macro macro-uline"><\/span>Source Code<\/a>/g, '')
351
+ // Remove stray class="href" attributes
352
+ .replace(/class="href"/g, '')
353
+ // Fix double spaces in href attributes
354
+ .replace(/<a href=/g, '<a href=')
355
+ // Add target="_blank" to external links
356
+ .replace(
357
+ /<a href="(https?:\/\/[^"]+)"/g,
358
+ '<a href="$1" target="_blank" rel="noopener noreferrer"'
359
+ )
360
+ // Clean up duplicate target="_blank" attributes
361
+ .replace(
362
+ /target="_blank" rel="noopener noreferrer" target="_blank" rel="noopener noreferrer"/g,
363
+ 'target="_blank" rel="noopener noreferrer"'
364
+ )
365
+ );
366
+ }