@dytsou/resume-converter 2.0.2
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/.env.example +6 -0
- package/.github/dependabot.yml +44 -0
- package/.github/workflows/ci.yml +73 -0
- package/.github/workflows/deploy.yml +56 -0
- package/.github/workflows/publish.yml +241 -0
- package/LICENSE +21 -0
- package/README.md +186 -0
- package/eslint.config.js +28 -0
- package/index.html +15 -0
- package/latex/resume.tex +273 -0
- package/package.json +39 -0
- package/scripts/convert-latex.mjs +774 -0
- package/src/App.tsx +47 -0
- package/src/components/DownloadFromDriveButton.tsx +104 -0
- package/src/index.css +122 -0
- package/src/main.tsx +10 -0
- package/src/utils/googleDriveUtils.ts +64 -0
- package/src/utils/resumeUtils.ts +26 -0
- package/src/vite-env.d.ts +1 -0
- package/tsconfig.app.json +24 -0
- package/tsconfig.json +7 -0
- package/tsconfig.node.json +22 -0
- package/vite.config.ts +10 -0
|
@@ -0,0 +1,774 @@
|
|
|
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('&');
|
|
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);
|
|
219
|
+
|
|
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();
|
|
695
|
+
|
|
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
|
+
}
|
|
701
|
+
|
|
702
|
+
function main() {
|
|
703
|
+
console.log('Starting LaTeX to HTML conversion...');
|
|
704
|
+
|
|
705
|
+
ensureDirectoryExists(OUTPUT_DIR);
|
|
706
|
+
|
|
707
|
+
if (!existsSync(LATEX_DIR)) {
|
|
708
|
+
console.error(`Error: LaTeX directory not found: ${LATEX_DIR}`);
|
|
709
|
+
process.exit(1);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
const files = readdirSync(LATEX_DIR).filter(file => extname(file) === '.tex');
|
|
713
|
+
|
|
714
|
+
if (files.length === 0) {
|
|
715
|
+
console.log('No LaTeX files found in the latex directory.');
|
|
716
|
+
writeFileSync(MANIFEST_FILE, JSON.stringify([], null, 2));
|
|
717
|
+
process.exit(0);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
console.log(`Found ${files.length} LaTeX file(s) to convert.`);
|
|
721
|
+
|
|
722
|
+
const manifest = [];
|
|
723
|
+
let hasErrors = false;
|
|
724
|
+
|
|
725
|
+
for (const file of files) {
|
|
726
|
+
const filePath = join(LATEX_DIR, file);
|
|
727
|
+
const fileBasename = basename(file, '.tex');
|
|
728
|
+
const outputPath = join(OUTPUT_DIR, `${fileBasename}.html`);
|
|
729
|
+
|
|
730
|
+
console.log(`Converting: ${file}`);
|
|
731
|
+
|
|
732
|
+
try {
|
|
733
|
+
const latexContent = readFileSync(filePath, 'utf-8');
|
|
734
|
+
const result = convertLatexToHtml(latexContent, fileBasename);
|
|
735
|
+
|
|
736
|
+
if (result.success) {
|
|
737
|
+
writeFileSync(outputPath, result.html);
|
|
738
|
+
|
|
739
|
+
manifest.push({
|
|
740
|
+
id: fileBasename,
|
|
741
|
+
filename: file,
|
|
742
|
+
title: result.metadata.title,
|
|
743
|
+
author: result.metadata.author,
|
|
744
|
+
date: result.metadata.date,
|
|
745
|
+
// Use a relative path so it works under any Vite base URL
|
|
746
|
+
htmlPath: `converted-docs/${fileBasename}.html`,
|
|
747
|
+
lastConverted: new Date().toISOString()
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
console.log(` ✓ Successfully converted to: ${outputPath}`);
|
|
751
|
+
} else {
|
|
752
|
+
console.error(` ✗ Failed to convert ${file}: ${result.error}`);
|
|
753
|
+
hasErrors = true;
|
|
754
|
+
}
|
|
755
|
+
} catch (error) {
|
|
756
|
+
console.error(` ✗ Error processing ${file}: ${error.message}`);
|
|
757
|
+
hasErrors = true;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
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`);
|
|
764
|
+
|
|
765
|
+
if (hasErrors) {
|
|
766
|
+
console.error('\n❌ Some files failed to convert. Fix errors before deployment.');
|
|
767
|
+
process.exit(1);
|
|
768
|
+
} else {
|
|
769
|
+
console.log('\n✅ All LaTeX files converted successfully!');
|
|
770
|
+
process.exit(0);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
main();
|