@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.
- package/.github/PULL_REQUEST_TEMPLATE.md +14 -0
- package/.husky/pre-commit +3 -0
- package/.prettierignore +18 -0
- package/.prettierrc +10 -0
- package/README.md +7 -1
- package/eslint.config.js +3 -1
- package/latex/resume.tex +3 -3
- package/package.json +18 -11
- package/scripts/config.mjs +22 -0
- package/scripts/convert-latex.mjs +32 -713
- package/scripts/converter.mjs +76 -0
- package/scripts/html-helpers.mjs +64 -0
- package/scripts/html-template.mjs +166 -0
- package/scripts/html-transformers.mjs +366 -0
- package/scripts/latex-parser.mjs +74 -0
- package/scripts/utils.mjs +29 -0
- package/src/App.tsx +3 -1
- package/src/components/DownloadFromDriveButton.tsx +6 -4
- package/src/index.css +9 -3
- package/src/utils/googleDriveUtils.ts +1 -1
- package/src/utils/resumeUtils.ts +1 -1
- package/vite.config.ts +1 -2
|
@@ -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('&');
|
|
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
|
+
}
|