'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, stepContentGroups, codeBlockText } = require('./common'); const { anchorFor, tocEntries, guideMetaLines, guideSummary } = require('./document-layout'); /** * 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, toc: true, accentColor: '#2563eb', customCss: '', }; 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 blockHtml(tb) { return `
${escapeHtml(LEVEL_LABEL[tb.level] || 'Note')}${tb.title ? `: ${escapeHtml(tb.title)}` : ''}${tb.descriptionHtml ? `
${tb.descriptionHtml}
` : ''}
`; } function renderBlockListHtml(blocks) { return blocks.map((block) => blockHtml(block)).join('\n'); } function renderMetaChips(ast) { return [ ...guideMetaLines(ast).map((line) => `${escapeHtml(line)}`), `${escapeHtml(guideSummary(ast))}`, ].join(''); } function renderTocList(ast) { return tocEntries(ast).map((entry) => `
  • ${escapeHtml(entry.number)} ${escapeHtml(entry.title)}
  • `).join(''); } function renderCover(ast, tpl) { return `
    StepForge export

    ${escapeHtml(ast.guide.title)}

    ${ast.guide.descriptionHtml ? `
    ${stepLinkRewrite(ast.guide.descriptionHtml, ast)}
    ` : ''}
    ${renderMetaChips(ast)}
    `; } function renderStepCard(step, ast, images, tpl, { rich = false, selected = false } = {}) { const title = `${escapeHtml(step.number)}. ${escapeHtml(step.title || 'Untitled step')}`; const statusText = step.skipped ? 'Skipped' : step.status === 'in-progress' ? 'In progress' : step.status === 'done' ? 'Done' : 'Todo'; const head = rich ? `

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

    ` : `

    ${title}

    `; const { beforeTitle, afterTitle, beforeDescription, afterDescription, beforeImage, afterImage, rest, } = stepContentGroups(step); return `
    ${renderBlockListHtml(beforeTitle)} ${head} ${renderBlockListHtml(afterTitle)}
    ${renderStepBodyHtml({ beforeDescription, afterDescription, beforeImage, afterImage, rest }, step, ast, images, tpl)}
    `; } function renderRichToc(ast) { return tocEntries(ast).map((entry) => `
  • ${escapeHtml(entry.number)} ${escapeHtml(entry.title)}
  • `).join(''); } function hexToRgb(hex) { const m = /^#?([0-9a-f]{6})$/i.exec(String(hex || '').trim()); if (!m) return [37, 99, 235]; const n = Number.parseInt(m[1], 16); return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff]; } function bodyStyle(tpl) { const [r, g, b] = hexToRgb(tpl.accentColor); return `--accent:${tpl.accentColor};--accent-rgb:${r},${g},${b};`; } function renderStepBodyHtml(groups, step, ast, images, tpl) { const parts = []; const { beforeDescription, afterDescription, beforeImage, afterImage, rest, } = groups; for (const tb of beforeDescription) parts.push(blockHtml(tb)); if (step.descriptionHtml) parts.push(`
    ${stepLinkRewrite(step.descriptionHtml, ast)}
    `); for (const tb of afterDescription) parts.push(blockHtml(tb)); for (const tb of beforeImage) parts.push(blockHtml(tb)); const img = images.get(step.stepId); if (img && tpl.includeImages) { parts.push(`Step ${escapeHtml(step.number)}`); } for (const tb of afterImage) parts.push(blockHtml(tb)); for (const block of rest) { if (block.kind === 'text') { parts.push(blockHtml(block)); } else if (block.kind === 'code') { parts.push(`
    ${escapeHtml(codeBlockText(block))}
    `); } else if (block.kind === 'table') { if (!block.rows || !block.rows.length) continue; const [head, ...bodyRows] = block.rows; parts.push('' + head.map((c) => ``).join('') + '' + bodyRows.map((r) => '' + r.map((c) => ``).join('') + '').join('') + '
    ${escapeHtml(c)}
    ${escapeHtml(c)}
    '); } } return parts.filter(Boolean).join('\n'); } const BASE_CSS = ` :root { --bg: #f4f7fb; --bg-2: #eef3f9; --panel: rgba(255, 255, 255, 0.92); --panel-strong: #ffffff; --panel-soft: #f8fbff; --text: #152033; --muted: #637084; --border: rgba(119, 134, 156, 0.22); --shadow: 0 18px 50px rgba(15, 23, 42, 0.08); } html { scroll-behavior: smooth; } body { margin: 0; min-height: 100vh; color: var(--text); background: radial-gradient(circle at top right, rgba(var(--accent-rgb), 0.16), transparent 26%), radial-gradient(circle at bottom left, rgba(14, 165, 233, 0.11), transparent 22%), linear-gradient(180deg, var(--bg), var(--bg-2)); line-height: 1.6; font-family: "Aptos", "Segoe UI", "Helvetica Neue", Arial, sans-serif; } a { color: var(--accent); text-decoration: none; } a:hover { text-decoration: underline; } img.shot { max-width: 100%; height: auto; border: 1px solid var(--border); border-radius: 18px; margin: 16px 0; box-shadow: var(--shadow); } pre.code { background: #0f172a; color: #e2e8f0; padding: 16px 18px; border-radius: 18px; overflow-x: auto; box-shadow: var(--shadow); } pre.code code { font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; } table { width: 100%; border-collapse: collapse; margin: 14px 0; background: var(--panel-strong); border: 1px solid var(--border); border-radius: 16px; overflow: hidden; box-shadow: var(--shadow); } th, td { border-bottom: 1px solid rgba(119, 134, 156, 0.16); padding: 10px 12px; text-align: left; } thead th { background: var(--panel-soft); color: var(--text); font-size: .88rem; letter-spacing: .02em; text-transform: uppercase; } tbody tr:last-child td { border-bottom: 0; } .doc { width: min(1120px, calc(100% - 32px)); margin: 0 auto; padding: 24px 0 40px; } .cover, .toc-card, .step-card, .progress-card { background: var(--panel); border: 1px solid var(--border); border-radius: 24px; box-shadow: var(--shadow); backdrop-filter: blur(10px); } .cover { padding: 34px 36px 28px; margin-bottom: 22px; } .cover .eyebrow { color: var(--accent); text-transform: uppercase; letter-spacing: .16em; font-size: .74rem; font-weight: 800; margin-bottom: 14px; } .cover h1 { font-size: clamp(2rem, 4vw, 3.6rem); line-height: 1.02; margin: 0; letter-spacing: -0.04em; } .cover .rule { width: 132px; height: 6px; margin: 18px 0 16px; border-radius: 999px; box-shadow: 0 10px 24px rgba(var(--accent-rgb), 0.22); } .cover .desc { max-width: 76ch; color: var(--muted); font-size: 1.02rem; } .cover .meta { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 18px; } .chip { display: inline-flex; align-items: center; gap: 6px; padding: 7px 11px; border-radius: 999px; background: rgba(var(--accent-rgb), 0.10); color: var(--accent); border: 1px solid rgba(var(--accent-rgb), 0.14); font-size: .86rem; font-weight: 650; } .chip.muted { background: rgba(255, 255, 255, 0.55); color: var(--muted); border-color: rgba(119, 134, 156, 0.16); } .toc-card { padding: 20px 22px; margin-bottom: 24px; } .toc-card h2 { margin: 0 0 16px; font-size: 1.08rem; letter-spacing: .02em; } .toc-list { list-style: none; margin: 0; padding: 0; } .toc-list li { margin: 0; } .toc-list a { display: flex; gap: 12px; align-items: flex-start; padding: 9px 12px; border-radius: 14px; color: inherit; } .toc-list a:hover { background: rgba(var(--accent-rgb), 0.08); text-decoration: none; } .toc-list .num { min-width: 44px; color: var(--accent); font-weight: 800; } .toc-list .label { flex: 1; } .toc-list li.d1 a { padding-left: 24px; } .toc-list li.d2 a { padding-left: 44px; } .toc-list li.d3 a { padding-left: 64px; } .step-card { margin: 20px 0; padding: 22px 24px 18px; border-left: 6px solid var(--accent); } .step-card h2 { display: flex; align-items: flex-start; gap: 12px; margin: 0 0 16px; font-size: 1.28rem; line-height: 1.15; } .step-card h2 .step-num { display: inline-flex; align-items: center; justify-content: center; min-width: 2.1rem; height: 2.1rem; padding: 0 10px; border-radius: 999px; background: rgba(var(--accent-rgb), 0.11); color: var(--accent); font-size: .9rem; font-weight: 800; letter-spacing: .02em; flex: none; } .step-card h2 .step-title { flex: 1; } .status-chip { display: inline-flex; align-items: center; height: 1.65rem; padding: 0 9px; border-radius: 999px; font-size: .72rem; letter-spacing: .08em; text-transform: uppercase; font-weight: 800; color: #334155; background: var(--panel-soft); border: 1px solid rgba(119, 134, 156, 0.18); } .status-chip.status-done { color: #047857; background: #ecfdf5; border-color: rgba(16, 185, 129, 0.22); } .status-chip.status-in-progress { color: #b45309; background: #fffbeb; border-color: rgba(245, 158, 11, 0.22); } .status-chip.status-todo { color: #475569; } .status-chip.skipped { color: #92400e; background: #fffbeb; border-color: rgba(245, 158, 11, 0.24); } .step-card.skipped { opacity: .76; } .step-body > .desc { color: #243044; } .block { position: relative; border-left: 4px solid var(--accent); background: rgba(var(--accent-rgb), 0.08); padding: 14px 16px 14px 54px; margin: 14px 0; border-radius: 18px; overflow: hidden; } .block::before { content: 'i'; position: absolute; left: 16px; top: 14px; width: 22px; height: 22px; border-radius: 999px; display: inline-flex; align-items: center; justify-content: center; background: rgba(var(--accent-rgb), 0.15); color: var(--accent); font-weight: 900; font-size: 13px; } .block strong { color: var(--text); } .block-warn { border-color: #f59e0b; background: #fffbeb; } .block-warn::before { content: '!'; background: rgba(245, 158, 11, 0.16); color: #b45309; } .block-warn strong { color: #92400e; } .block-error { border-color: #ef4444; background: #fef2f2; } .block-error::before { content: '!'; background: rgba(239, 68, 68, 0.14); color: #b91c1c; } .block-error strong { color: #b91c1c; } .block-success { border-color: #10b981; background: #ecfdf5; } .block-success::before { content: '✓'; background: rgba(16, 185, 129, 0.14); color: #047857; } .block-success strong { color: #047857; } .footer-note { margin-top: 20px; color: var(--muted); font-size: .84rem; } .skipped { opacity: .55; } @media (prefers-color-scheme: dark) { :root { --bg: #0b1220; --bg-2: #0f172a; --panel: rgba(17, 24, 39, 0.9); --panel-strong: #111827; --panel-soft: #162033; --text: #e5e7eb; --muted: #98a2b3; --border: rgba(148, 163, 184, 0.18); --shadow: 0 16px 40px rgba(0, 0, 0, 0.25); } .status-chip { color: #d1d5db; } .status-chip.skipped { color: #fbbf24; background: rgba(245, 158, 11, 0.12); } .cover .desc, .step-body > .desc { color: #cbd5e1; } .chip.muted { background: rgba(15, 23, 42, 0.75); color: #cbd5e1; } .block { background: rgba(37, 99, 235, 0.12); } .block-warn { background: rgba(245, 158, 11, 0.12); } .block-error { background: rgba(239, 68, 68, 0.12); } .block-success { background: rgba(16, 185, 129, 0.12); } table { background: var(--panel-strong); } th, td { border-bottom-color: rgba(148, 163, 184, 0.12); } } `; const RICH_CSS = ` .layout-rich { display: grid; grid-template-columns: 300px minmax(0, 1fr); gap: 24px; align-items: start; } .toc-panel { position: sticky; top: 20px; align-self: start; } .toc-card { margin: 0; max-height: calc(100vh - 40px); overflow: auto; } .progress-card { margin-bottom: 22px; padding: 14px 18px; } .progress { position: sticky; top: 0; z-index: 2; background: transparent; } .progress .label { font-size: .86rem; font-weight: 700; color: var(--muted); margin-bottom: 8px; } .progress .bar { height: 8px; background: rgba(148, 163, 184, 0.18); border-radius: 999px; overflow: hidden; } .progress .fill { height: 100%; width: 0; background: var(--accent); transition: width .2s ease; } label.check { margin-right: 0; } label.check input { transform: translateY(1px); } .step-card h2 .step-title { min-width: 0; } @media (max-width: 960px) { .layout-rich { grid-template-columns: 1fr; } .toc-panel { position: static; } .toc-card { max-height: none; } } `; function exportHtmlSimple(ast, outDir, template = {}) { const tpl = { ...DEFAULT_TEMPLATE, ...template }; fs.mkdirSync(outDir, { recursive: true }); const images = tpl.includeImages ? renderAllImages(ast) : new Map(); const toc = tpl.toc && ast.steps.length > 1 ? `

    Contents

    ` : ''; const stepsHtml = ast.steps.map((step) => renderStepCard(step, ast, images, tpl)).join('\n'); const html = ` ${escapeHtml(ast.guide.title)}
    ${renderCover(ast, tpl)} ${toc} ${stepsHtml}
    Generated by StepForge on ${escapeHtml(ast.generatedAt)} · ${ast.steps.length} step${ast.steps.length === 1 ? '' : 's'}
    `; 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 = tpl.toc && ast.steps.length > 1 ? ` ` : ''; const stepsHtml = ast.steps.map((step) => renderStepCard(step, ast, images, tpl, { rich: true })).join('\n'); 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)}
    ${tocHtml}
    ${renderCover(ast, tpl)}
    ${stepsHtml}
    Generated by StepForge on ${escapeHtml(ast.generatedAt)} · ${ast.steps.length} step${ast.steps.length === 1 ? '' : 's'}
    `; 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 };