@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.
@@ -1,719 +1,35 @@
1
- import { readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync } from 'fs';
2
- import { join, basename, extname } from 'path';
3
- import { parse } from '@unified-latex/unified-latex-util-parse';
4
- import { unified } from 'unified';
5
- import { unifiedLatexToHast } from '@unified-latex/unified-latex-to-hast';
6
- import { toHtml } from 'hast-util-to-html';
7
-
8
- const LATEX_DIR = './latex';
9
- const OUTPUT_DIR = './public/converted-docs';
10
- const MANIFEST_FILE = './public/documents-manifest.json';
11
-
12
- function ensureDirectoryExists(dir) {
13
- if (!existsSync(dir)) {
14
- mkdirSync(dir, { recursive: true });
15
- }
16
- }
17
-
18
- function extractMetadata(latexContent) {
19
- const titleMatch = latexContent.match(/\\title\{([^}]+)\}/);
20
- const authorMatch = latexContent.match(/\\author\{([^}]+)\}/);
21
- const dateMatch = latexContent.match(/\\date\{([^}]+)\}/);
22
-
23
- return {
24
- title: titleMatch ? titleMatch[1] : 'Untitled Document',
25
- author: authorMatch ? authorMatch[1] : 'Unknown Author',
26
- date: dateMatch ? dateMatch[1] : new Date().toISOString().split('T')[0]
27
- };
28
- }
29
-
30
- function convertLatexToHtml(latexContent, filename) {
31
- try {
32
- const ast = parse(latexContent);
33
- const hast = unified().use(unifiedLatexToHast).runSync(ast);
34
- // Generate raw HTML from HAST
35
- let html = toHtml(hast);
36
-
37
- // Extract metadata for title block
38
- const metadata = extractMetadata(latexContent);
39
-
40
- // Post-process HTML to better mirror LaTeX semantics/typography
41
- // 1) Remove the placeholder produced for \\maketitle and inject a proper title block
42
- const maketitleRegex = /<p><span class="macro macro-maketitle"><\/span><\/p>/;
43
- const titleBlock = `\n<h1 class="title">${metadata.title}<\/h1>\n<div class="author">${metadata.author}<\/div>\n<div class="date">${metadata.date}<\/div>\n`;
44
- if (maketitleRegex.test(html)) {
45
- html = html.replace(maketitleRegex, titleBlock);
46
- }
47
-
48
- // 2) Promote heading levels (section->h2, subsection->h3)
49
- // Promote h4 to h3 first to avoid double promotion issues
50
- html = html.replace(/<h4(\b[^>]*)>/g, '<h3$1>')
51
- .replace(/<\/h4>/g, '<\/h3>');
52
- // Then promote h3 to h2
53
- html = html.replace(/<h3(\b[^>]*)>/g, '<h2$1>')
54
- .replace(/<\/h3>/g, '<\/h2>');
55
-
56
- // 3) Add an "Abstract" heading before the abstract environment, if present
57
- html = html.replace(
58
- /(<div class="environment abstract">)/,
59
- '<h2>Abstract<\/h2>$1'
60
- );
61
-
62
- // 4) Resume-specific post-processing to approximate LaTeX packages/macros (always resume)
63
- {
64
- // Function to parse LaTeX macro arguments with proper brace counting
65
- const parseLatexMacro = (content, macroName) => {
66
- const matches = [];
67
- const regex = new RegExp(`\\\\${macroName}\\{`, 'g');
68
- let match;
69
-
70
-
71
- while ((match = regex.exec(content)) !== null) {
72
- const startPos = match.index;
73
- let pos = match.index + match[0].length - 1; // Position after opening brace
74
- const args = [];
75
- let currentArg = '';
76
- let braceCount = 1;
77
- let argCount = 0;
78
-
79
- // Parse arguments (3 for resumeQuadHeadingDetails and resumeTrioHeading, 4 for resumeQuadHeading)
80
- const maxArgs = macroName === 'resumeQuadHeading' ? 4 : 3;
81
- while (argCount < maxArgs && pos < content.length) {
82
- pos++;
83
- const char = content[pos];
84
-
85
- if (char === '{') {
86
- braceCount++;
87
- currentArg += char;
88
- } else if (char === '}') {
89
- braceCount--;
90
- if (braceCount === 0) {
91
- // End of current argument
92
- args.push(currentArg.trim());
93
- argCount++;
94
- currentArg = '';
95
- // Look for next argument
96
- while (pos < content.length && content[pos] !== '{') {
97
- pos++;
98
- }
99
- if (pos < content.length) {
100
- braceCount = 1; // Reset for next argument
101
- }
102
- } else {
103
- // Still inside nested braces
104
- currentArg += char;
105
- }
106
- } else {
107
- currentArg += char;
108
- }
109
- }
110
-
111
- if (args.length >= maxArgs) {
112
- const fullMatch = content.substring(startPos, pos + 1);
113
- matches.push([fullMatch, ...args]);
114
- }
115
- }
116
-
117
- return matches;
118
- };
119
-
120
- // Pre-parse macro arguments from LaTeX source so we can align by {}
121
- // Use the parseLatexMacro function to properly handle nested braces
122
- const trioMatches = parseLatexMacro(latexContent, 'resumeTrioHeading');
123
- let trioIdx = 0;
124
- // Parse resumeQuadHeadingDetails with robust nested braces handling
125
- const quadDetailsMatches = [];
126
-
127
- quadDetailsMatches.push(...parseLatexMacro(latexContent, 'resumeQuadHeadingDetails'));
128
-
129
- // Also parse resumeQuadHeading (used in Education section)
130
- const quadHeadingMatches = parseLatexMacro(latexContent, 'resumeQuadHeading');
131
-
132
- // Debug: log the trio matches
133
- console.log('Trio matches:', trioMatches);
134
- let quadDetailsIdx = 0;
135
- const sectionTypeMatches = Array.from(
136
- latexContent.matchAll(/\\resumeSectionType\{([^}]*)\}\{([^}]*)\}\{([^}]*)\}/g)
137
- );
138
- let sectionTypeIdx = 0;
139
- // Replace math pipes with typographic separators
140
- html = html.replace(/<span class="inline-math">\|<\/span>/g, '<span class="sep">·<\/span>');
141
-
142
- // Convert first tabular (tabular* or tabularx) contact header into a two-column grid up to next section
143
- html = html.replace(
144
- /<div class="environment tabular(?:\*|x)">([\s\S]*?)<\/div>(?=\s*<h2>)/,
145
- (match, inner) => {
146
- const iconMap = {
147
- faLinkedin: 'fab fa-linkedin',
148
- faGithub: 'fab fa-github',
149
- faEnvelope: 'fas fa-envelope',
150
- faMobile: 'fas fa-mobile'
151
- };
152
-
153
- const replaceIcons = text =>
154
- text.replace(/<span class="macro macro-(fa\w+)"><\/span>/g, (_, macro) => {
155
- const cls = iconMap[macro] || 'fas fa-circle';
156
- return `<i class="${cls}"></i>`;
157
- });
158
-
159
- const innerClean = replaceIcons(
160
- inner
161
- .replace(/\n/g, ' ')
162
- .replace(/>\s*[Xlcrp@{}]+(?=\s|<)/g, ' ')
163
- .replace(/<span class="vspace"[^>]*><\/span>/g, ' ')
164
- .replace(/<span class="macro macro-uline"><\/span>/g, '')
165
- .replace(/\s{2,}/g, ' ')
166
- .trim()
167
- );
168
-
169
- const nameMatch = innerClean.match(/<span class="textsize-Huge">([\s\S]*?)<\/span>/);
170
- const name = nameMatch ? nameMatch[1].trim() : '';
171
- const afterName = innerClean.replace(/^[\s\S]*?<br class="linebreak">\s*/, '');
172
- const ampIdx = afterName.indexOf('&#x26;');
173
- let primary = afterName;
174
- let secondary = '';
175
- if (ampIdx !== -1) {
176
- primary = afterName.slice(0, ampIdx).trim();
177
- secondary = afterName.slice(ampIdx + 5).trim();
178
- }
179
-
180
- const clean = text =>
181
- text
182
- .replace(/<br class="linebreak">/g, ' ')
183
- .replace(/<span class="inline-math">\|<\/span>/g, '<span class="sep">·<\/span>')
184
- .replace(/class="href"/g, '')
185
- .trim();
186
-
187
- // Process contact links: wrap mobile icon with phone number
188
- let primaryClean = clean(primary);
189
-
190
- // Wrap mobile icon and phone number together (handle fa-mobile, fa-mobile-alt, and fa-phone)
191
- primaryClean = primaryClean.replace(/(<i class="fas fa-(?:mobile|mobile-alt|phone)"><\/i>)\s*([+\d\s\-]+)/g, '<span class="contact-mobile">$1 $2</span>');
192
-
193
- const secondaryClean = clean(secondary).replace(/^\s*[;:,\-–]\s*/, '');
194
-
195
- const alignmentClass = secondaryClean ? 'contact dual' : 'contact centered';
196
- const rightColumn = secondaryClean ? `<div class="contact-right">${secondaryClean}</div>` : '';
197
-
198
- return `\n<div class="${alignmentClass}">\n <div class="contact-left">\n <div class="contact-name">${name}<\/div>\n <div class="contact-links">${primaryClean}<\/div>\n <\/div>\n ${rightColumn}\n<\/div>`;
199
- }
200
- );
201
-
202
- // Convert Trio heading macro into semantic three-column row (brace-driven)
203
- html = html.replace(
204
- /<span class="macro macro-resumeTrioHeading"><\/span>([\s\S]*?)(?=(<ul|<span|<\/div|<\/p))/g,
205
- (m, content) => {
206
- const parsed = trioMatches[trioIdx++] || null;
207
- if (!parsed) {
208
- // Fallback to original content if parsing failed
209
- return m;
210
- }
211
-
212
- let [fullMatch, title, tech, link] = parsed;
213
-
214
- // Debug: log the parsed data
215
- console.log('Trio parsed data:', { title, tech, link });
216
- console.log('Full match:', fullMatch);
217
- console.log('Link contains href:', link.includes('\\href{'));
218
- console.log('Link length:', link.length);
1
+ /**
2
+ * Entry point for LaTeX to HTML conversion
3
+ * Processes all LaTeX files and generates HTML
4
+ */
219
5
 
220
- // Check if the link is incomplete (missing closing braces)
221
- if (link.includes('\\href{') && !link.includes('}{')) {
222
- console.log('Incomplete href detected, trying to fix...');
223
- // Try to find the complete href in the full match
224
- const completeHrefMatch = fullMatch.match(/\\href\{[^}]+\}\{[^}]+\}/);
225
- if (completeHrefMatch) {
226
- console.log('Found complete href:', completeHrefMatch[0]);
227
- // Update the link with the complete href
228
- const updatedLink = completeHrefMatch[0];
229
- console.log('Updated link:', updatedLink);
230
- // Use the updated link for processing
231
- link = updatedLink;
232
- } else {
233
- // If we can't find the complete href in the full match, try to find it in the original LaTeX content
234
- const originalHrefMatch = latexContent.match(/\\href\{[^}]+\}\{[^}]+\}/g);
235
- if (originalHrefMatch) {
236
- // Find the href that matches our incomplete link
237
- const matchingHref = originalHrefMatch.find(href => href.startsWith(link));
238
- if (matchingHref) {
239
- console.log('Found matching href in original content:', matchingHref);
240
- link = matchingHref;
241
- }
242
- }
243
- }
244
- }
245
-
246
- // Check if the link contains \href command (from LaTeX source)
247
- let anchorHtml = '';
248
- if (link.includes('\\href{')) {
249
- // Extract URL and display text from \href{url}{text} format
250
- // Handle nested commands like \href{url}{\uline{text}}
251
- const hrefMatch = link.match(/\\href\{([^}]+)\}\{([^}]+)\}/);
252
- if (hrefMatch) {
253
- const [, hrefUrl, hrefText] = hrefMatch;
254
- // Clean up any nested LaTeX commands in the display text
255
- const cleanText = hrefText.replace(/\\uline\{([^}]+)\}/g, '$1').replace(/\\uline\{([^}]*)$/g, '$1');
256
- anchorHtml = `<a href="${hrefUrl}" target="_blank" rel="noopener noreferrer">${cleanText}</a>`;
257
- } else {
258
- // Try a more complex pattern to handle deeply nested commands
259
- const complexMatch = link.match(/\\href\{([^}]+)\}\{([^}]+)\}/);
260
- if (complexMatch) {
261
- const [, hrefUrl, hrefText] = complexMatch;
262
- const cleanText = hrefText.replace(/\\uline\{([^}]+)\}/g, '$1').replace(/\\uline\{([^}]*)$/g, '$1');
263
- anchorHtml = `<a href="${hrefUrl}" target="_blank" rel="noopener noreferrer">${cleanText}</a>`;
264
- } else {
265
- // Fallback if \href parsing fails
266
- anchorHtml = link;
267
- }
268
- }
269
- } else {
270
- // If no \href, treat as plain text
271
- anchorHtml = link;
272
- }
273
-
274
- return `\n<div class="trio">\n <div class="trio-title"><strong>${title.trim()}<\/strong><\/div>\n <div class="trio-tech"><em>${tech.trim()}<\/em><\/div>\n <div class="trio-link">${anchorHtml}<\/div>\n<\/div>`;
275
- }
276
- );
277
-
278
- // Merge date ranges like "Oct 2023 –" with trailing "Present" or month on next row
279
- html = html.replace(
280
- /(<div class=\"quad-details\">[\s\S]*?<div class=\"right\"><span class=\"date\">\s*([^<]*?–)\s*<\/span><\/div>[\s\S]*?<div class=\"left\"><em>)\s*(Present|[A-Z][a-z]{2}\s\d{4})\s*([^<]*)(<\/em><\/div><div class=\"right\">)/g,
281
- (m, prefix, startDash, word, restRole, suffix) => {
282
- const date = `${startDash} ${word}`.replace(/\s+/g, ' ').trim();
283
- const role = restRole.replace(/^\s*[–-]?\s*/, '').trim();
284
- return `${prefix}${role}${suffix}<span class=\"date\">${date}<\/span>`;
285
- }
286
- );
287
-
288
- // Convert QuadHeadingDetails macro into 2x2 block (brace-driven)
289
- // Use parsed data from LaTeX source to ensure proper structure
290
- html = html.replace(
291
- /<span class="macro macro-resumeQuadHeadingDetails"><\/span>([\s\S]*?)(?=(<ul|<span|<\/div|<\/p))/g,
292
- (m, content) => {
293
- const parsed = quadDetailsMatches[quadDetailsIdx++] || null;
294
- if (!parsed) {
295
- // Fallback to original content if parsing failed
296
- return m;
297
- }
298
-
299
- const [fullMatch, url, dateText, roleText] = parsed;
300
-
301
- // Clean up date text - preserve periods in month abbreviations and fix dash formatting
302
- const cleanDateText = dateText.replace(/\s*--\s*/g, ' – ')
303
- .replace(/\s+/g, ' ')
304
- .trim();
305
-
306
- // Clean up role text
307
- const cleanRoleText = roleText.replace(/\s+/g, ' ').trim();
308
-
309
- // Check if the URL contains \href command (from LaTeX source)
310
- let anchorHtml = '';
311
- if (url.includes('\\href{')) {
312
- // Extract URL and display text from \href{url}{text} format
313
- const hrefMatch = url.match(/\\href\{([^}]+)\}\{([^}]+)\}/);
314
- if (hrefMatch) {
315
- const [, hrefUrl, hrefText] = hrefMatch;
316
- // Make the link text bold while keeping it as a clickable link
317
- anchorHtml = `<a href="${hrefUrl}" target="_blank" rel="noopener noreferrer"><strong>${hrefText}</strong></a>`;
318
- } else {
319
- // Fallback if \href parsing fails
320
- anchorHtml = `<strong>${url}</strong>`;
321
- }
322
- } else {
323
- // If no \href, treat as plain text and make it bold
324
- anchorHtml = `<strong>${url}</strong>`;
325
- }
326
-
327
- return `\n<div class="quad-details">\n <div class="row"><div class="left">${anchorHtml}<\/div><div class="right"><span class="date">${cleanDateText}<\/span><\/div><\/div>\n <div class="row"><div class="left"><em>${cleanRoleText}<\/em><\/div><div class="right"><\/div><\/div>\n<\/div>`;
328
- }
329
- );
330
-
331
- // Clean up any remaining duplicate content that might have been created
332
- // This handles cases where the macro replacement left behind raw text
333
- html = html.replace(
334
- /(<a[^>]*>.*?<\/a>)([A-Z][a-z]{2,4}\.\s\d{4}\s[–-]\s[A-Z][a-z]{2,4}\.\s\d{4})\s*([^<]*?)(?=<ul|<span|<\/div|<\/p)/g,
335
- (m, anchor, dateRange, role) => {
336
- // Only process if this looks like a duplicate (no surrounding div structure)
337
- if (!m.includes('class="quad-details"') && !m.includes('class="row"')) {
338
- return ''; // Remove the duplicate
339
- }
340
- return m; // Keep the original if it's already properly formatted
341
- }
342
- );
343
-
344
- // After rendering quad-details, merge split date ranges with role prefixes
345
- // Case A: "Oct 2023 –" + left em starting with "Present ..."
346
- html = html.replace(
347
- /(<div class=\"quad-details\">[\s\S]*?<span class=\"date\">)\s*([^<]*?–)\s*(<\/span>[\s\S]*?<div class=\"left\"><em>)\s*Present\s+([^<]*?)(<\/em>)/g,
348
- (m, pre, startDash, mid, roleRest, end) => `${pre}${startDash} Present${mid}${roleRest}${end}`
349
- );
350
-
351
- // Case B: Disabled - was causing incorrect merging of complete date ranges with role text
352
- // The issue is now fixed in the QuadHeadingDetails parsing above
353
-
354
- // Replace entire Technical Skills block using brace-driven rows up to the next <h2>
355
- {
356
- const rows = sectionTypeMatches.map(s => {
357
- const label = s[1].trim();
358
- const sep = s[2].trim();
359
- const content = s[3].trim();
360
- // Remove LaTeX escaping from ampersands in labels
361
- const cleanLabel = label.replace(/\\&/g, '&');
362
- return `\n<div class=\"skill-row\">\n <div class=\"skill-label\"><strong>${cleanLabel}<\/strong><\/div>\n <div class=\"skill-sep\">${sep}<\/div>\n <div class=\"skill-content\">${content}<\/div>\n<\/div>`;
363
- }).join('');
364
- html = html.replace(/<h2>Technical Skills<\/h2>[\s\S]*?(?=<h2>)/, `<h2>Technical Skills<\/h2><div class=\"resume-heading-list\">${rows}<\/div>\n`);
365
- }
366
-
367
- // Convert QuadHeading macro into 2x2 block using parsed data
368
- let quadHeadingIdx = 0;
369
- html = html.replace(
370
- /<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,
371
- (m, pre, degreeLine, dates) => {
372
- const parsed = quadHeadingMatches[quadHeadingIdx++] || null;
373
- if (parsed) {
374
- // Use parsed data from LaTeX source
375
- const uni = parsed[1].trim();
376
- const loc = parsed[2].trim();
377
- const degree = parsed[3].trim();
378
- let dateRange = parsed[4] ? parsed[4].trim() : dates;
379
-
380
- // Clean up date range - preserve periods and fix dash formatting
381
- dateRange = dateRange.replace(/\s*--\s*/g, ' – ')
382
- .replace(/\s+/g, ' ')
383
- .trim();
384
-
385
- return `\n<div class="quad">\n <div class="row"><div class="left"><strong>${uni}<\/strong><\/div><div class="right">${loc}<\/div><\/div>\n <div class="row"><div class="left"><em>${degree}<\/em><\/div><div class="right"><em>${dateRange}<\/em><\/div><\/div>\n<\/div>`;
386
- } else {
387
- // Fallback to original logic if parsing failed
388
- const preTrim = (pre || '').replace(/\s+/g, ' ').trim();
389
- let uni = preTrim;
390
- let loc = '';
391
- // Case 1: Standard trailing ", City, Country"
392
- const cityCountryMatch = preTrim.match(/^(.*?),\s*([A-Za-z'’\- ]+,\s*[A-Za-z'’\- ]+)$/);
393
- if (cityCountryMatch) {
394
- uni = cityCountryMatch[1].trim();
395
- loc = cityCountryMatch[2].trim();
396
- } else {
397
- const gluedMatch = preTrim.match(/^(.*?University)\s*(Hsinchu,\s*Taiwan)$/i);
398
- if (gluedMatch) {
399
- uni = gluedMatch[1].trim();
400
- loc = gluedMatch[2].trim();
401
- }
402
- }
403
- const cleanDates = dates.replace(/\s*--\s*/g, ' – ').replace(/\s+/g, ' ').trim();
404
- return `\n<div class="quad">\n <div class="row"><div class="left"><strong>${uni}<\/strong><\/div><div class="right">${loc}<\/div><\/div>\n <div class="row"><div class="left"><em>${degreeLine}<\/em><\/div><div class="right"><em>${cleanDates}<\/em><\/div><\/div>\n<\/div>`;
405
- }
406
- }
407
- );
408
-
409
- // Convert custom list macros to semantic lists
410
- const LIST_START = '<span class="macro macro-resumeItemListStart"></span>';
411
- const LIST_END = '<span class="macro macro-resumeItemListEnd"></span>';
412
- const ITEM = '<span class="macro macro-resumeItem"></span>';
413
-
414
- // Process each list block iteratively
415
- let searchStart = 0;
416
- while (true) {
417
- const startIdx = html.indexOf(LIST_START, searchStart);
418
- if (startIdx === -1) break;
419
- const endIdx = html.indexOf(LIST_END, startIdx);
420
- if (endIdx === -1) break;
421
-
422
- const before = html.slice(0, startIdx);
423
- const inner = html.slice(startIdx + LIST_START.length, endIdx);
424
- const after = html.slice(endIdx + LIST_END.length);
425
-
426
- // Split by ITEM markers; ignore leading empty chunk
427
- const parts = inner.split(ITEM).map(s => s.trim());
428
- const items = [];
429
- for (const part of parts) {
430
- if (!part) continue;
431
- // Close item content when it reaches the next ITEM or end; since we split, wrap directly
432
- items.push(`<li>${part}</li>`);
433
- }
434
- const ul = `<ul class="resume-items">${items.join('')}</ul>`;
435
- html = before + ul + after;
436
- searchStart = before.length + ul.length;
437
- }
438
-
439
- // Remove container list macros that can appear around headings
440
- html = html
441
- .replace(/<span class="macro macro-resumeHeadingListStart"><\/span>/g, '<div class="resume-heading-list">')
442
- .replace(/<span class="macro macro-resumeHeadingListEnd"><\/span>/g, '<\/div>');
443
-
444
- // Tidy up stray paragraph wrappers around our new blocks
445
- html = html
446
- .replace(/<p>\s*(<ul class="resume-items">)/g, '$1')
447
- .replace(/(<\/ul>)\s*<\/p>/g, '$1')
448
- .replace(/<p>\s*(<div class="resume-heading-list">)/g, '$1')
449
- .replace(/(<\/div>)\s*<\/p>/g, '$1')
450
- .replace(/<p>\s*(<(?:div) class=\"(?:contact|trio|quad|quad-details)\">)/g, '$1')
451
- .replace(/(<\/(?:div)>)\s*<\/p>/g, '$1')
452
- .replace(/<\/p>\s*(<div class=\"trio\">)/g, '$1')
453
- .replace(/(<\/div>\s*<\/div>\s*)<div class=\"trio-tech\">[\s\S]*?<\/div>\s*<div class=\"trio-link\">[\s\S]*?<\/div>/, '$1');
454
-
455
- // Fix missing spaces after percent symbols
456
- html = html.replace(/(\d+)%([a-zA-Z])/g, '$1% $2');
457
-
458
- // Clean up leftover macro artifacts from Trio headings
459
- html = html.replace(/<span class="macro macro-uline"><\/span>Source Code<\/a>/g, '');
460
-
461
- // Global FontAwesome icon replacement - ensure all icon macros are converted
462
- const iconMap = {
463
- faLinkedin: 'fab fa-linkedin',
464
- faGithub: 'fab fa-github',
465
- faEnvelope: 'fas fa-envelope',
466
- faMobile: 'fas fa-mobile'
467
- };
468
- html = html.replace(/<span class="macro macro-(fa\w+)"><\/span>/g, (_, macro) => {
469
- const cls = iconMap[macro] || 'fas fa-circle';
470
- return `<i class="${cls}"></i>`;
471
- });
472
-
473
- // Remove any remaining class="href" attributes that might have been missed
474
- html = html.replace(/class="href"/g, '');
475
-
476
- // Fix any double spaces in href attributes
477
- html = html.replace(/<a href=/g, '<a href=');
478
-
479
- // Add target="_blank" to all external links (not mailto or relative links)
480
- html = html.replace(/<a href="(https?:\/\/[^"]+)"/g, '<a href="$1" target="_blank" rel="noopener noreferrer"');
481
-
482
- // Clean up duplicate target="_blank" attributes
483
- html = html.replace(/target="_blank" rel="noopener noreferrer" target="_blank" rel="noopener noreferrer"/g, 'target="_blank" rel="noopener noreferrer"');
484
- }
485
-
486
-
487
- const styledHtml = `
488
- <!DOCTYPE html>
489
- <html lang="en">
490
- <head>
491
- <meta charset="UTF-8">
492
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
493
- <title>${metadata.title}</title>
494
- <script id="MathJax-script" async src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"></script>
495
- <link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><link href="https://fonts.googleapis.com/css2?family=Source+Sans+3:ital,wght@0,300..900;1,300..900&display=swap" rel="stylesheet">
496
- <!-- Font Awesome 6.5.2 - Primary CDN -->
497
- <link rel="stylesheet" href="https://use.fontawesome.com/releases/v6.5.2/css/all.css" integrity="sha384-B4dIYHKNBt8Bc12p+WXckhzcICo0wtJAoU8YZTY5qE0Id1GSseTk6S+L3BlXeVIU" crossorigin="anonymous" />
498
- <!-- Font Awesome Fallback CDN -->
499
- <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer" />
500
- <style>
501
- html, body {
502
- background: #fff;
503
- margin: 0;
504
- padding: 0;
505
- }
506
- body {
507
- max-width: 950px;
508
- width: 100%;
509
- margin: 0 auto;
510
- padding: 2rem;
511
- font-family: "Source Sans 3", system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
512
- line-height: 1.6;
513
- color: #000;
514
- background: #fff;
515
- box-sizing: border-box;
516
- }
517
- * { box-sizing: border-box; }
518
- h1, h2, h3, h4, h5, h6 {
519
- margin-top: 1.5em;
520
- margin-bottom: 0.5em;
521
- font-weight: bold;
522
- }
523
- h1 { font-size: 5em; text-align: center; }
524
- h2 { font-size: 1.5em; font-variant: small-caps; color: #1e3a8a; border-bottom: 1px solid #1e3a8a; padding-bottom: 0.25rem; margin-top: 1.2em; }
525
- h3 { font-size: 1.2em; }
526
- .author { text-align: center; font-style: italic; margin: 1em 0; }
527
- .date { text-align: center; margin-bottom: 2em; }
528
- .title { margin-top: 0.5em; }
529
- p { margin: 1em 0; text-align: left; }
530
- .theorem, .lemma, .proposition, .corollary {
531
- font-style: italic;
532
- margin: 1em 0;
533
- padding: 0.5em;
534
- border-left: 3px solid #333;
535
- }
536
- .proof {
537
- margin: 1em 0 1em 2em;
538
- }
539
- code, pre {
540
- font-family: "Courier New", monospace;
541
- background: #f5f5f5;
542
- padding: 0.2em 0.4em;
543
- }
544
- pre {
545
- padding: 1em;
546
- overflow-x: auto;
547
- }
548
- .equation {
549
- margin: 1em 0;
550
- overflow-x: auto;
551
- }
552
- a { color: #0066cc; text-decoration: none; cursor: pointer; }
553
- a:hover { color: #004499; text-decoration: none; }
554
- a:visited { color: #551a8b; text-decoration: none; }
555
- .resume-items { margin: 0.25rem 0 1rem 1.25rem; }
556
- .resume-items li { margin: 0.25rem 0; }
557
- .resume-heading-list { margin: 0.25rem 0 0.5rem 0; }
558
- .contact { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 0.75rem; margin-bottom: 1rem; }
559
- .contact.centered { grid-template-columns: 1fr; text-align: center; }
560
- .contact.centered .contact-name { justify-self: center; }
561
- .contact.centered .contact-links { justify-self: center; }
562
- .contact-name { font-size: 1.75rem; font-weight: 700; }
563
- .contact-links { color: #111; display: flex; flex-wrap: wrap; justify-content: center; gap: 0.5rem; align-items: center; }
564
- .contact-links a { display: inline-flex; align-items: center; gap: 0.25rem; }
565
- .contact-links i {
566
- color: #1e3a8a;
567
- font-style: normal;
568
- font-variant: normal;
569
- text-rendering: auto;
570
- -webkit-font-smoothing: antialiased;
571
- display: inline-block;
572
- font-family: "Font Awesome 6 Free", "Font Awesome 6 Brands", "Font Awesome 6 Pro";
573
- font-weight: 900;
574
- }
575
- .contact-links i.fab { font-family: "Font Awesome 6 Brands"; font-weight: 400; }
576
- .contact-links > i { display: inline-flex; align-items: center; gap: 0.25rem; }
577
- .contact-sep { color: #666; margin: 0 0.25rem; }
578
- .contact-mobile { display: inline-flex; align-items: center; gap: 0.25rem; }
579
- .contact-right { text-align: right; white-space: nowrap; }
580
- .sep { margin: 0 0.35rem; color: #666; }
581
- .trio { display: grid; grid-template-columns: 1fr auto auto; gap: 0.5rem; align-items: baseline; margin: 0.25rem 0; position: relative; }
582
- .trio-title { justify-self: start; white-space: nowrap; }
583
- .trio-tech { position: absolute; left: 50%; transform: translateX(-50%); color: #374151; white-space: nowrap; }
584
- .trio-link { justify-self: end; white-space: nowrap; }
585
- .quad, .quad-details { margin: 0.25rem 0; }
586
- .row { display: grid; grid-template-columns: 1fr auto; align-items: baseline; }
587
- .row .left, .row .right { white-space: nowrap; }
588
- .row .right { text-align: right; color: #374151; }
589
- .skill-row { display: grid; grid-template-columns: 0.28fr 0.01fr 0.71fr; align-items: start; gap: 0.5rem; }
590
- .skill-label { font-weight: 700; }
591
- .skill-sep { text-align: center; }
592
- .macro { display: none; }
593
- .converter-footer {
594
- margin-top: 3rem;
595
- padding-top: 1rem;
596
- border-top: 1px solid #e5e7eb;
597
- text-align: center;
598
- font-size: 0.875rem;
599
- color: #6b7280;
600
- }
601
- .converter-footer a {
602
- color: #3b82f6;
603
- text-decoration: none;
604
- }
605
- .converter-footer a:hover {
606
- text-decoration: underline;
607
- }
608
- @media (max-width: 768px) {
609
- body {
610
- padding: 1rem;
611
- }
612
- h1 { font-size: 5em; }
613
- h2 { font-size: 1.25em; margin-top: 1em; }
614
- .contact-name { font-size: 1.5rem; }
615
- .contact-links { font-size: 0.9rem; gap: 0.4rem; }
616
- .trio {
617
- grid-template-columns: 1fr;
618
- gap: 0.25rem;
619
- margin: 0.5rem 0;
620
- }
621
- .trio-title {
622
- justify-self: start;
623
- white-space: normal;
624
- }
625
- .trio-tech {
626
- position: static;
627
- transform: none;
628
- left: auto;
629
- justify-self: start;
630
- white-space: normal;
631
- }
632
- .trio-link {
633
- justify-self: start;
634
- white-space: normal;
635
- }
636
- .row {
637
- grid-template-columns: 1fr;
638
- gap: 0.25rem;
639
- }
640
- .row .left, .row .right {
641
- white-space: normal;
642
- }
643
- .row .right {
644
- text-align: left;
645
- }
646
- .skill-row {
647
- grid-template-columns: 1fr;
648
- gap: 0.25rem;
649
- }
650
- .skill-label {
651
- margin-bottom: 0.25rem;
652
- }
653
- .skill-sep {
654
- display: none;
655
- }
656
- .skill-content {
657
- margin-left: 0;
658
- }
659
- .contact {
660
- grid-template-columns: 1fr;
661
- gap: 0.5rem;
662
- }
663
- .contact-right {
664
- text-align: left;
665
- white-space: normal;
666
- }
667
- }
668
- @media (max-width: 480px) {
669
- body {
670
- padding: 0.75rem;
671
- }
672
- h1 { font-size: 5em; }
673
- h2 { font-size: 1.1em; }
674
- .contact-name { font-size: 1.25rem; }
675
- .contact-links {
676
- font-size: 0.85rem;
677
- flex-direction: column;
678
- align-items: flex-start;
679
- }
680
- .resume-items {
681
- margin-left: 1rem;
682
- }
683
- }
684
- </style>
685
- </head>
686
- <body>
687
- ${html}
688
- <div class="converter-footer">
689
- Generated with <a href="https://github.com/dytsou/resume" target="_blank" rel="noopener">LaTeX to HTML Converter</a><br>
690
- © 2025 Tsou, Dong-You. Licensed under <a href="https://github.com/dytsou/resume/blob/main/LICENSE" target="_blank" rel="noopener">MIT License</a>
691
- </div>
692
- </body>
693
- </html>
694
- `.trim();
6
+ import { readFileSync, writeFileSync, readdirSync, existsSync } from 'fs';
7
+ import { join, basename, extname } from 'path';
695
8
 
696
- return { html: styledHtml, metadata, success: true, error: null };
697
- } catch (error) {
698
- return { html: null, metadata: null, success: false, error: error.message };
699
- }
700
- }
9
+ import { CONFIG } from './config.mjs';
10
+ import { ensureDirectoryExists } from './utils.mjs';
11
+ import { convertLatexToHtml } from './converter.mjs';
701
12
 
702
- function main() {
13
+ /**
14
+ * Main function - processes all LaTeX files and generates HTML
15
+ */
16
+ export function main() {
703
17
  console.log('Starting LaTeX to HTML conversion...');
704
18
 
705
- ensureDirectoryExists(OUTPUT_DIR);
19
+ ensureDirectoryExists(CONFIG.outputDir);
706
20
 
707
- if (!existsSync(LATEX_DIR)) {
708
- console.error(`Error: LaTeX directory not found: ${LATEX_DIR}`);
21
+ if (!existsSync(CONFIG.latexDir)) {
22
+ console.error(`Error: LaTeX directory not found: ${CONFIG.latexDir}`);
709
23
  process.exit(1);
710
24
  }
711
25
 
712
- const files = readdirSync(LATEX_DIR).filter(file => extname(file) === '.tex');
26
+ const files = readdirSync(CONFIG.latexDir).filter(
27
+ (file) => extname(file) === '.tex'
28
+ );
713
29
 
714
30
  if (files.length === 0) {
715
31
  console.log('No LaTeX files found in the latex directory.');
716
- writeFileSync(MANIFEST_FILE, JSON.stringify([], null, 2));
32
+ writeFileSync(CONFIG.manifestFile, JSON.stringify([], null, 2));
717
33
  process.exit(0);
718
34
  }
719
35
 
@@ -723,9 +39,9 @@ function main() {
723
39
  let hasErrors = false;
724
40
 
725
41
  for (const file of files) {
726
- const filePath = join(LATEX_DIR, file);
42
+ const filePath = join(CONFIG.latexDir, file);
727
43
  const fileBasename = basename(file, '.tex');
728
- const outputPath = join(OUTPUT_DIR, `${fileBasename}.html`);
44
+ const outputPath = join(CONFIG.outputDir, `${fileBasename}.html`);
729
45
 
730
46
  console.log(`Converting: ${file}`);
731
47
 
@@ -742,9 +58,8 @@ function main() {
742
58
  title: result.metadata.title,
743
59
  author: result.metadata.author,
744
60
  date: result.metadata.date,
745
- // Use a relative path so it works under any Vite base URL
746
61
  htmlPath: `converted-docs/${fileBasename}.html`,
747
- lastConverted: new Date().toISOString()
62
+ lastConverted: new Date().toISOString(),
748
63
  });
749
64
 
750
65
  console.log(` ✓ Successfully converted to: ${outputPath}`);
@@ -758,15 +73,19 @@ function main() {
758
73
  }
759
74
  }
760
75
 
761
- writeFileSync(MANIFEST_FILE, JSON.stringify(manifest, null, 2));
762
- console.log(`\nManifest written to: ${MANIFEST_FILE}`);
763
- console.log(`Successfully converted: ${manifest.length}/${files.length} files`);
76
+ writeFileSync(CONFIG.manifestFile, JSON.stringify(manifest, null, 2));
77
+ console.log(`\nManifest written to: ${CONFIG.manifestFile}`);
78
+ console.log(
79
+ `Successfully converted: ${manifest.length}/${files.length} files`
80
+ );
764
81
 
765
82
  if (hasErrors) {
766
- console.error('\n❌ Some files failed to convert. Fix errors before deployment.');
83
+ console.error(
84
+ '\nSome files failed to convert. Fix errors before deployment.'
85
+ );
767
86
  process.exit(1);
768
87
  } else {
769
- console.log('\n✅ All LaTeX files converted successfully!');
88
+ console.log('\nAll LaTeX files converted successfully!');
770
89
  process.exit(0);
771
90
  }
772
91
  }