Project Interview
Ready
๐ Auto-summarizing
๐ก Tutorial
๐ Restart
๐ค
Send
Ready to finish?
Generate Article โ
๐ Article Preview
×
Loading...
Close
๐พ Download Article
๐ Publish Article
โ ๏ธ Confirm Restart
This will delete all interview history. Type
DELETE
to confirm:
Cancel
Delete & Restart
* * HOW IT WORKS: * 1. Parses the Markdown string line-by-line into a simple AST (headings, * paragraphs, bold/italic inline, bullet lists, numbered lists, hr). * 2. Maps each AST node to docx.js Paragraph / TextRun objects using * proper HeadingLevel values, LevelFormat.BULLET, etc. * 3. Packs the Document to a Blob via Packer.toBlob() and triggers download. * * USAGE (drop-in replacement โ same call signature as the original): * downloadArticle() // reads `currentArticle` from outer scope, same as before */ // โโโ Inline-text parser โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ // Converts a Markdown inline string (bold, italic, code, plain) into an array // of docx TextRun objects. function parseInlineMarkdown(text) { const { TextRun } = docx; const runs = []; // Regex: **bold**, *italic*, `code`, or plain text chunks const pattern = /(\*\*(.+?)\*\*|\*(.+?)\*|`(.+?)`|[^*`]+)/g; let match; while ((match = pattern.exec(text)) !== null) { const full = match[0]; if (full.startsWith("**")) { runs.push(new TextRun({ text: match[2], bold: true })); } else if (full.startsWith("*")) { runs.push(new TextRun({ text: match[3], italics: true })); } else if (full.startsWith("`")) { runs.push( new TextRun({ text: match[4], font: "Courier New", size: 20, // 10pt โ slightly smaller for inline code }) ); } else { if (full) runs.push(new TextRun({ text: full })); } } return runs.length ? runs : [new TextRun({ text: text })]; } // โโโ Markdown โ docx Paragraphs โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ function markdownToDocxChildren(markdown) { const { Paragraph, TextRun, HeadingLevel, AlignmentType, LevelFormat, } = docx; const lines = markdown.split("\n"); const children = []; // We need a numbered-list counter that resets on non-list lines let orderedIndex = 0; for (let i = 0; i < lines.length; i++) { const raw = lines[i]; const line = raw.trimEnd(); // โโ Blank line โ spacer paragraph if (!line.trim()) { orderedIndex = 0; // reset ordered counter on gap children.push( new Paragraph({ children: [], spacing: { after: 80 }, }) ); continue; } // โโ ATX Headings # ## ### #### const headingMatch = line.match(/^(#{1,4})\s+(.+)/); if (headingMatch) { orderedIndex = 0; const level = headingMatch[1].length; const headingLevelMap = { 1: HeadingLevel.HEADING_1, 2: HeadingLevel.HEADING_2, 3: HeadingLevel.HEADING_3, 4: HeadingLevel.HEADING_4, }; children.push( new Paragraph({ heading: headingLevelMap[level] || HeadingLevel.HEADING_3, children: [new TextRun({ text: headingMatch[2].trim() })], spacing: { before: level === 1 ? 320 : 200, after: 120 }, }) ); continue; } // โโ Setext H1 (underline ===) if (i + 1 < lines.length && /^=+$/.test(lines[i + 1].trim()) && line.trim()) { orderedIndex = 0; children.push( new Paragraph({ heading: HeadingLevel.HEADING_1, children: [new TextRun({ text: line.trim() })], spacing: { before: 320, after: 120 }, }) ); i++; // skip underline row continue; } // โโ Setext H2 (underline ---) if (i + 1 < lines.length && /^-+$/.test(lines[i + 1].trim()) && line.trim()) { orderedIndex = 0; children.push( new Paragraph({ heading: HeadingLevel.HEADING_2, children: [new TextRun({ text: line.trim() })], spacing: { before: 200, after: 120 }, }) ); i++; // skip underline row continue; } // โโ Horizontal rule (---, ***, ___) if (/^(---+|\*\*\*+|___+)\s*$/.test(line)) { orderedIndex = 0; children.push( new Paragraph({ children: [], border: { bottom: { style: "single", size: 6, color: "AAAAAA", space: 1 }, }, spacing: { before: 160, after: 160 }, }) ); continue; } // โโ Unordered list - item / * item / + item const ulMatch = line.match(/^(\s*)[-*+]\s+(.+)/); if (ulMatch) { orderedIndex = 0; const depth = Math.floor(ulMatch[1].length / 2); // supports up to 2-level nesting children.push( new Paragraph({ numbering: { reference: "bullets", level: Math.min(depth, 1) }, children: parseInlineMarkdown(ulMatch[2]), spacing: { after: 60 }, }) ); continue; } // โโ Ordered list 1. item const olMatch = line.match(/^(\s*)\d+\.\s+(.+)/); if (olMatch) { orderedIndex++; children.push( new Paragraph({ numbering: { reference: "numbers", level: 0 }, children: parseInlineMarkdown(olMatch[2]), spacing: { after: 60 }, }) ); continue; } // โโ Blockquote > text const bqMatch = line.match(/^>\s*(.*)/); if (bqMatch) { orderedIndex = 0; children.push( new Paragraph({ children: parseInlineMarkdown(bqMatch[1]), indent: { left: 720 }, border: { left: { style: "single", size: 12, color: "AAAAAA", space: 8 }, }, spacing: { before: 80, after: 80 }, }) ); continue; } // โโ Default: regular paragraph orderedIndex = 0; children.push( new Paragraph({ children: parseInlineMarkdown(line), spacing: { after: 120 }, }) ); } return children; } // โโโ Main downloadArticle() โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ async function downloadArticle() { if (!currentArticle) { alert("No article to download."); return; } const { Document, Packer, Paragraph, TextRun, HeadingLevel, AlignmentType, LevelFormat, } = docx; // `docx` is exposed globally by the CDN bundle // โโ Extract a title from the first H1 in the article, fallback to date const titleMatch = currentArticle.match(/^#\s+(.+)/m); const articleTitle = titleMatch ? titleMatch[1].trim() : "Project Article"; const dateStr = new Date().toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric", }); // โโ Build document const doc = new Document({ // Document metadata (visible in File โ Properties) creator: "Project Interview Tool", description: "Auto-generated project article", title: articleTitle, // โโ Override built-in heading styles so they render cleanly styles: { default: { document: { run: { font: "Calibri", size: 24 }, // 12pt body }, }, paragraphStyles: [ { id: "Heading1", name: "Heading 1", basedOn: "Normal", next: "Normal", quickFormat: true, run: { size: 36, bold: true, font: "Calibri", color: "2E4057" }, paragraph: { spacing: { before: 320, after: 160 }, outlineLevel: 0, }, }, { id: "Heading2", name: "Heading 2", basedOn: "Normal", next: "Normal", quickFormat: true, run: { size: 28, bold: true, font: "Calibri", color: "3A6186" }, paragraph: { spacing: { before: 240, after: 120 }, outlineLevel: 1, }, }, { id: "Heading3", name: "Heading 3", basedOn: "Normal", next: "Normal", quickFormat: true, run: { size: 24, bold: true, font: "Calibri", color: "555555" }, paragraph: { spacing: { before: 200, after: 80 }, outlineLevel: 2, }, }, { id: "Heading4", name: "Heading 4", basedOn: "Normal", next: "Normal", quickFormat: true, run: { size: 24, bold: true, italics: true, font: "Calibri", color: "777777" }, paragraph: { spacing: { before: 160, after: 80 }, outlineLevel: 3, }, }, ], }, // โโ Bullet and numbered list configs (required โ never use unicode bullets) numbering: { config: [ { reference: "bullets", levels: [ { level: 0, format: LevelFormat.BULLET, text: "\u2022", alignment: AlignmentType.LEFT, style: { paragraph: { indent: { left: 720, hanging: 360 } }, }, }, { level: 1, format: LevelFormat.BULLET, text: "\u25E6", alignment: AlignmentType.LEFT, style: { paragraph: { indent: { left: 1080, hanging: 360 } }, }, }, ], }, { reference: "numbers", levels: [ { level: 0, format: LevelFormat.DECIMAL, text: "%1.", alignment: AlignmentType.LEFT, style: { paragraph: { indent: { left: 720, hanging: 360 } }, }, }, ], }, ], }, sections: [ { properties: { page: { // US Letter, 1-inch margins size: { width: 12240, height: 15840 }, margin: { top: 1440, right: 1440, bottom: 1440, left: 1440 }, }, }, children: [ // โโ Cover / title block new Paragraph({ children: [ new TextRun({ text: articleTitle, bold: true, size: 48, // 24pt font: "Calibri", color: "2E4057", }), ], alignment: AlignmentType.CENTER, spacing: { before: 480, after: 160 }, }), new Paragraph({ children: [ new TextRun({ text: `Generated on ${dateStr} ยท Project Interview Tool`, size: 18, // 9pt color: "888888", italics: true, font: "Calibri", }), ], alignment: AlignmentType.CENTER, spacing: { after: 480 }, border: { bottom: { style: "single", size: 6, color: "CCCCCC", space: 1 }, }, }), // โโ Article body converted from Markdown ...markdownToDocxChildren(currentArticle), ], }, ], }); // โโ Pack โ Blob โ trigger download try { const blob = await Packer.toBlob(doc); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = articleTitle.replace(/[^a-z0-9]/gi, "-").toLowerCase() + "-" + new Date().toISOString().split("T")[0] + ".docx"; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } catch (err) { console.error("โ DOCX generation failed:", err); alert("Failed to generate .docx file.\n\nFalling back to Markdown download."); // Graceful fallback to the original Markdown download const fallback = new Blob([currentArticle], { type: "text/markdown" }); const url = URL.createObjectURL(fallback); const a = document.createElement("a"); a.href = url; a.download = "article-" + new Date().toISOString().split("T")[0] + ".md"; a.click(); URL.revokeObjectURL(url); } } function closeModal() { document.getElementById('modal').classList.add('hidden'); document.getElementById('modal').classList.remove('flex'); } let recognition; let isRecording = false; if ('webkitSpeechRecognition' in window || 'SpeechRecognition' in window) { const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; recognition = new SpeechRecognition(); recognition.continuous = true; recognition.interimResults = true; recognition.lang = 'en-US'; recognition.onstart = () => { isRecording = true; document.getElementById('mic-btn').classList.add('recording'); }; recognition.onend = () => { isRecording = false; document.getElementById('mic-btn').classList.remove('recording'); }; recognition.onresult = (event) => { let finalTranscript = ''; for (let i = 0; i < event.results.length; i++) { if (event.results[i].isFinal) { finalTranscript += event.results[i][0].transcript + ' '; } } const input = document.getElementById('user-input'); if (finalTranscript) { const pos = input.selectionStart; const before = input.value.substring(0, pos); const after = input.value.substring(pos); input.value = before + finalTranscript + after; input.setSelectionRange(pos + finalTranscript.length, pos + finalTranscript.length); } }; recognition.onerror = (event) => { isRecording = false; document.getElementById('mic-btn').classList.remove('recording'); if (event.error === 'not-allowed') { alert('Microphone permission denied.'); } }; } function toggleVoice() { if (!recognition) { alert("Voice not supported. Use Chrome/Edge/Safari."); return; } if (isRecording) { recognition.stop(); } else { try { recognition.start(); } catch (e) { console.error(e); } } } document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { closeModal(); cancelRestart(); } }); window.sendMessage = sendMessage; window.generateArticle = generateArticle; window.submitToGithub = submitToGithub; window.downloadArticle = downloadArticle; window.closeModal = closeModal; window.confirmRestart = confirmRestart; window.cancelRestart = cancelRestart; window.executeRestart = executeRestart; window.checkRestartInput = checkRestartInput; window.toggleVoice = toggleVoice; window.handleKeyPress = handleKeyPress; window.startTutorial = startTutorial; initializeApp();