'use strict'; const fs = require('node:fs'); const path = require('node:path'); const { escapeHtml } = require('../core/util'); const { encodePng } = require('../core/png'); const { guideSlug, renderAllImages, LEVEL_LABEL, stepBlocks, codeBlockText } = require('./common'); /** * HTML exporters. Both variants are fully self-contained single files: * screenshots are embedded as data URIs, styles are inline, and there are * no external (network) references of any kind. * * - simple: lightweight, copy-paste friendly markup. * - rich: floating TOC, per-step checkboxes with progress persisted in the * browser's localStorage (local only), and a progress bar. */ const DEFAULT_TEMPLATE = { includeImages: true, accentColor: '#2563eb', customCss: '', }; function anchorFor(step) { return `step-${step.number.replace(/\./g, '-')}`; } function dataUri(img) { return `data:image/png;base64,${encodePng(img).toString('base64')}`; } function stepLinkRewrite(html, ast) { // step: hrefs become local anchors when the target step is exported. return html.replace(/href="step:([^"]+)"/g, (m, id) => { const target = ast.steps.find((s) => s.stepId === id); return target ? `href="#${anchorFor(target)}"` : 'data-missing-step-link="true"'; }); } function blocksHtml(step, position) { return stepBlocks(step) .filter((tb) => tb.position === position) .map((tb) => `
${escapeHtml(LEVEL_LABEL[tb.level] || 'Note')}${tb.title ? `: ${escapeHtml(tb.title)}` : ''}${tb.descriptionHtml ? `
${tb.descriptionHtml}
` : ''}
`) .join('\n'); } function stepBodyHtml(step, ast, images, tpl) { const parts = []; parts.push(blocksHtml(step, 'before-description')); if (step.descriptionHtml) parts.push(`
${stepLinkRewrite(step.descriptionHtml, ast)}
`); const img = images.get(step.stepId); if (img && tpl.includeImages) { parts.push(`Step ${escapeHtml(step.number)}`); } for (const block of stepBlocks(step).filter((item) => item.kind !== 'text')) { if (block.kind === 'code') { parts.push(`
${escapeHtml(codeBlockText(block))}
`); } else if (block.kind === 'table') { if (!block.rows || !block.rows.length) continue; const [head, ...rest] = block.rows; parts.push('' + head.map((c) => ``).join('') + '' + rest.map((r) => '' + r.map((c) => ``).join('') + '').join('') + '
${escapeHtml(c)}
${escapeHtml(c)}
'); } } parts.push(blocksHtml(step, 'after-description')); parts.push(blocksHtml(step, 'after-image')); return parts.filter(Boolean).join('\n'); } const BASE_CSS = ` body { font-family: system-ui, -apple-system, "Segoe UI", sans-serif; margin: 0 auto; max-width: 860px; padding: 24px; color: #1f2937; background: #ffffff; line-height: 1.55; } h1 { font-size: 1.7em; margin-bottom: .2em; } h2 { font-size: 1.2em; margin-top: 1.6em; border-bottom: 1px solid #e5e7eb; padding-bottom: .25em; } img.shot { max-width: 100%; height: auto; border: 1px solid #e5e7eb; border-radius: 6px; margin: .6em 0; } pre.code { background: #f3f4f6; padding: 12px; border-radius: 6px; overflow-x: auto; } table { border-collapse: collapse; margin: .6em 0; } th, td { border: 1px solid #d1d5db; padding: 4px 10px; text-align: left; } .block { border-left: 4px solid #9ca3af; background: #f9fafb; padding: 8px 12px; margin: .6em 0; border-radius: 0 6px 6px 0; } .block-warn { border-color: #f59e0b; background: #fffbeb; } .block-error { border-color: #ef4444; background: #fef2f2; } .block-success { border-color: #10b981; background: #ecfdf5; } .skipped { opacity: .55; } @media (prefers-color-scheme: dark) { body { background: #111827; color: #e5e7eb; } h2 { border-color: #374151; } pre.code, .block { background: #1f2937; } th, td { border-color: #4b5563; } } `; function exportHtmlSimple(ast, outDir, template = {}) { const tpl = { ...DEFAULT_TEMPLATE, ...template }; fs.mkdirSync(outDir, { recursive: true }); const images = tpl.includeImages ? renderAllImages(ast) : new Map(); const stepsHtml = ast.steps.map((step) => `

${escapeHtml(step.number)}. ${escapeHtml(step.title || 'Untitled step')}

${stepBodyHtml(step, ast, images, tpl)}
`).join('\n'); const html = ` ${escapeHtml(ast.guide.title)}

${escapeHtml(ast.guide.title)}

${ast.guide.descriptionHtml ? `
${ast.guide.descriptionHtml}
` : ''} ${stepsHtml} `; const file = path.join(outDir, `${guideSlug(ast)}.html`); fs.writeFileSync(file, html); return { file, imageCount: images.size }; } function exportHtmlRich(ast, outDir, template = {}) { const tpl = { ...DEFAULT_TEMPLATE, ...template }; fs.mkdirSync(outDir, { recursive: true }); const images = tpl.includeImages ? renderAllImages(ast) : new Map(); const storageKey = `stepforge-progress-${ast.guide.id}`; const tocHtml = ast.steps.map((step) => `
  • ${escapeHtml(step.number)}. ${escapeHtml(step.title || 'Untitled step')}
  • ` ).join('\n'); const stepsHtml = ast.steps.map((step) => `

    ${escapeHtml(step.number)}. ${escapeHtml(step.title || 'Untitled step')}

    ${stepBodyHtml(step, ast, images, tpl)}
    `).join('\n'); const richCss = ` .layout { display: flex; gap: 28px; max-width: 1180px; margin: 0 auto; } nav.toc { position: sticky; top: 16px; align-self: flex-start; min-width: 220px; max-width: 280px; max-height: calc(100vh - 32px); overflow-y: auto; font-size: .92em; border: 1px solid #e5e7eb; border-radius: 8px; padding: 14px; } nav.toc ul { list-style: none; margin: 0; padding: 0; } nav.toc li { margin: .25em 0; } nav.toc li.d1 { padding-left: 14px; } nav.toc li.d2 { padding-left: 28px; } nav.toc a { color: inherit; text-decoration: none; } nav.toc a:hover { color: ${tpl.accentColor}; } main { flex: 1; min-width: 0; } .progress { position: sticky; top: 0; background: inherit; padding: 8px 0; z-index: 2; } .progress .bar { height: 6px; background: #e5e7eb; border-radius: 3px; overflow: hidden; } .progress .fill { height: 100%; width: 0; background: ${tpl.accentColor}; transition: width .2s; } label.check { margin-right: 8px; } section.step.done h2 { text-decoration: line-through; opacity: .6; } @media (max-width: 900px) { .layout { flex-direction: column; } nav.toc { position: static; max-width: none; } } @media (prefers-color-scheme: dark) { nav.toc { border-color: #374151; } .progress .bar { background: #374151; } } `; const script = ` (function () { var key = ${JSON.stringify(storageKey)}; var state = {}; try { state = JSON.parse(localStorage.getItem(key) || '{}'); } catch (e) {} var boxes = document.querySelectorAll('input.step-done'); function refresh() { var done = 0; boxes.forEach(function (b) { b.closest('section').classList.toggle('done', b.checked); if (b.checked) done++; }); var fill = document.querySelector('.progress .fill'); if (fill) fill.style.width = (boxes.length ? (100 * done / boxes.length) : 0) + '%'; var label = document.querySelector('.progress .label'); if (label) label.textContent = done + ' / ' + boxes.length + ' steps done'; } boxes.forEach(function (b) { b.checked = !!state[b.dataset.step]; b.addEventListener('change', function () { state[b.dataset.step] = b.checked; try { localStorage.setItem(key, JSON.stringify(state)); } catch (e) {} refresh(); }); }); refresh(); })(); `; const html = ` ${escapeHtml(ast.guide.title)}

    ${escapeHtml(ast.guide.title)}

    ${ast.guide.descriptionHtml ? `
    ${ast.guide.descriptionHtml}
    ` : ''}
    ${stepsHtml}
    Generated by StepForge on ${escapeHtml(ast.generatedAt)} — ${ast.steps.length} steps
    `; const file = path.join(outDir, `${guideSlug(ast)}-rich.html`); fs.writeFileSync(file, html); return { file, imageCount: images.size }; } module.exports = { exportHtmlSimple, exportHtmlRich, DEFAULT_TEMPLATE, anchorFor };