diff --git a/exporters/confluence.js b/exporters/confluence.js index 2f00a76..675ef15 100644 --- a/exporters/confluence.js +++ b/exporters/confluence.js @@ -5,6 +5,7 @@ const path = require('node:path'); const { slugify, escapeXml } = require('../core/util'); const { encodePng } = require('../core/png'); const { guideSlug, renderAllImages, stepContentGroups, codeBlockText } = require('./common'); +const { anchorFor, guideMetaLines, guideSummary } = require('./document-layout'); /** * Confluence storage-format export. Writes a single XHTML document plus a @@ -14,6 +15,7 @@ const { guideSlug, renderAllImages, stepContentGroups, codeBlockText } = require const DEFAULT_TEMPLATE = { includeImages: true, + toc: true, }; const MACRO_FOR_LEVEL = { @@ -23,10 +25,6 @@ const MACRO_FOR_LEVEL = { success: 'tip', }; -function anchorFor(step) { - return `step-${step.number.replace(/\./g, '-')}`; -} - function stepLinkRewrite(html, ast) { return String(html || '').replace(/href="step:([^"]+)"/g, (m, id) => { const target = ast.steps.find((s) => s.stepId === id); @@ -112,7 +110,11 @@ function exportConfluence(ast, outDir, template = {}) {

${escapeXml(ast.guide.title)}

+
+ ${guideMetaLines(ast).map((line) => `

${escapeXml(line.split(': ')[0])}: ${escapeXml(line.slice(line.indexOf(': ') + 2))}

`).join('\n')} +

${escapeXml(guideSummary(ast))}

${ast.guide.descriptionHtml ? `
${stepLinkRewrite(ast.guide.descriptionHtml, ast)}
` : ''} + ${tpl.toc && ast.steps.length > 1 ? '' : ''} ${stepXml} diff --git a/exporters/document-layout.js b/exporters/document-layout.js new file mode 100644 index 0000000..1cec7f9 --- /dev/null +++ b/exporters/document-layout.js @@ -0,0 +1,40 @@ +'use strict'; + +function anchorFor(stepOrNumber) { + const number = typeof stepOrNumber === 'string' + ? stepOrNumber + : stepOrNumber && stepOrNumber.number; + return `step-${String(number || '').replace(/\./g, '-')}`; +} + +function tocEntries(ast, { maxDepth = Infinity } = {}) { + return (ast.steps || []).map((step) => ({ + step, + anchor: anchorFor(step), + number: step.number, + title: step.title || 'Untitled step', + depth: Math.min(Number.isFinite(step.depth) ? step.depth : 0, maxDepth), + })); +} + +function guideMetaLines(ast) { + const meta = ast?.guide?.metadata || {}; + return [ + meta.author && `Author: ${meta.author}`, + meta.coAuthors && `Co-authors: ${meta.coAuthors}`, + meta.organization && `Organization: ${meta.organization}`, + ].filter(Boolean); +} + +function guideSummary(ast) { + const count = ast?.steps?.length || 0; + const generated = String(ast?.generatedAt || '').slice(0, 10); + return `${count} step${count === 1 ? '' : 's'} · generated ${generated}`; +} + +module.exports = { + anchorFor, + tocEntries, + guideMetaLines, + guideSummary, +}; diff --git a/exporters/docx.js b/exporters/docx.js index d79bf6e..bd7d445 100644 --- a/exporters/docx.js +++ b/exporters/docx.js @@ -6,6 +6,8 @@ const { zipSync } = require('../core/zip'); const { escapeXml } = require('../core/util'); const { encodePng } = require('../core/png'); const { guideSlug, renderAllImages, LEVEL_LABEL, stepContentGroups, codeBlockText } = require('./common'); +const { guideMetaLines, guideSummary } = require('./document-layout'); +const raster = require('../core/raster'); /** * DOCX exporter: WordprocessingML built directly (no dependency), one @@ -15,6 +17,7 @@ const { guideSlug, renderAllImages, LEVEL_LABEL, stepContentGroups, codeBlockTex const DEFAULT_TEMPLATE = { includeImages: true, + includeToc: true, imageWidthTwips: 9000, // ~15.9cm inside A4 margins }; @@ -64,6 +67,28 @@ function drawing(relId, widthPx, heightPx, maxWidthTwips) { ``; } +function calloutIcon(level) { + const img = raster.createImage(24, 24, [0, 0, 0, 0]); + const fill = { + info: [37, 99, 235, 255], + success: [16, 185, 129, 255], + warn: [245, 158, 11, 255], + error: [239, 68, 68, 255], + }[level] || [37, 99, 235, 255]; + raster.fillOval(img, 1, 1, 22, 22, fill); + const glyph = level === 'success' ? 'v' : level === 'warn' ? '!' : level === 'error' ? 'x' : 'i'; + raster.drawTextCentered(img, 12, 13, glyph, 12, [255, 255, 255, 255]); + return img; +} + +function pageBreak() { + return p(''); +} + +function headingStyleForDepth(depth) { + return `Heading${Math.min(3, depth + 1)}`; +} + function table(rows) { const cols = Math.max(...rows.map((r) => r.length)); const grid = `${''.repeat(cols)}`; @@ -87,29 +112,71 @@ function exportDocx(ast, outDir, template = {}) { const media = []; // { name, data } const rels = []; // relationship XML strings - let relCounter = 0; + let relCounter = 1; // rId1 reserved for settings.xml + let stepImageCount = 0; + + const iconRelIds = {}; + for (const level of ['info', 'success', 'warn', 'error']) { + const icon = calloutIcon(level); + const relId = ++relCounter; + iconRelIds[level] = relId; + const name = `callout-${level}.png`; + media.push({ name, data: encodePng(icon) }); + rels.push(``); + } const body = []; - body.push(p(run(ast.guide.title, { bold: true, size: 48 }))); + body.push(p( + run(ast.guide.title, { bold: true, size: 48 }), + '' + )); if (ast.guide.descriptionText) body.push(p(run(ast.guide.descriptionText, { size: 22, color: '444444' }))); - body.push(p(run(`${ast.steps.length} steps — generated ${ast.generatedAt.slice(0, 10)}`, { size: 18, color: '888888' }))); + for (const line of guideMetaLines(ast)) body.push(p(run(line, { size: 20, color: '6B7280' }))); + body.push(p(run(guideSummary(ast), { size: 18, color: '888888' }))); + + body.push(pageBreak()); + + if (tpl.includeToc && ast.steps.length > 1) { + body.push(p( + run('Contents', { bold: true, size: 28 }), + '' + )); + body.push(p( + `Update contents in Word` + )); + body.push(pageBreak()); + } + + const emitTextBlock = (tb) => { + const style = LEVEL_STYLE[tb.level] || LEVEL_STYLE.info; + const iconRelId = iconRelIds[tb.level] || iconRelIds.info; + const label = `${LEVEL_LABEL[tb.level] || 'Note'}${tb.title ? `: ${tb.title}` : ''}`; + body.push(p( + `${drawing(iconRelId, 16, 16, 240)}${run(label, { bold: true, size: 20, color: style.color })}${tb.descriptionText ? run('\n' + tb.descriptionText, { size: 20, color: '1F2937' }) : ''}`, + `` + )); + }; for (const step of ast.steps) { - const headSize = step.depth > 0 ? 26 : 30; - body.push(p(run(`${step.number}. ${step.title || 'Untitled step'}`, { bold: true, size: headSize }), - step.forceNewPage ? '' : '')); + const headingLevel = Math.min(3, Math.max(1, step.depth + 1)); + const headSize = headingLevel === 1 ? 30 : headingLevel === 2 ? 26 : 22; + body.push(p( + run(`${step.number}. ${step.title || 'Untitled step'}`, { bold: true, size: headSize }), + `${step.forceNewPage ? '' : ''}` + )); const { before, rest } = stepContentGroups(step); for (const tb of before) emitTextBlock(tb); - if (step.descriptionText) body.push(p(run(step.descriptionText))); + if (step.descriptionText) body.push(p(run(step.descriptionText, { size: 20, color: '1F2937' }))); const img = images.get(step.stepId); if (img) { - relCounter += 1; + const relId = ++relCounter; const name = `image${relCounter}.png`; media.push({ name, data: encodePng(img) }); - rels.push(``); - body.push(p(drawing(relCounter, img.width, img.height, tpl.imageWidthTwips))); + rels.push(``); + body.push(p(drawing(relId, img.width, img.height, tpl.imageWidthTwips))); + stepImageCount += 1; } for (const block of rest) { @@ -124,14 +191,10 @@ function exportDocx(ast, outDir, template = {}) { } } - function emitTextBlock(tb) { - const label = `${LEVEL_LABEL[tb.level] || 'Note'}${tb.title ? `: ${tb.title}` : ''}`; - const style = LEVEL_STYLE[tb.level] || LEVEL_STYLE.info; - body.push(p( - run(label, { bold: true, size: 20, color: style.color }) + (tb.descriptionText ? run('\n' + tb.descriptionText, { size: 20 }) : ''), - `` - )); - } + const settingsXml = ` + + +`; const documentXml = ` + `, }, { @@ -166,16 +230,18 @@ ${body.join('\n')} name: 'word/_rels/document.xml.rels', data: ` + ${rels.join('\n')} `, }, + { name: 'word/settings.xml', data: settingsXml }, ...media.map((m) => ({ name: `word/media/${m.name}`, data: m.data, store: true })), ]; fs.mkdirSync(outDir, { recursive: true }); const file = path.join(outDir, `${guideSlug(ast)}.docx`); fs.writeFileSync(file, zipSync(entries)); - return { file, imageCount: media.length }; + return { file, imageCount: stepImageCount }; } module.exports = { exportDocx, DEFAULT_TEMPLATE }; diff --git a/exporters/html.js b/exporters/html.js index 852973e..d0aaeb0 100644 --- a/exporters/html.js +++ b/exporters/html.js @@ -5,6 +5,7 @@ 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: @@ -18,14 +19,11 @@ const { guideSlug, renderAllImages, LEVEL_LABEL, stepContentGroups, codeBlockTex const DEFAULT_TEMPLATE = { includeImages: true, + toc: 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')}`; } @@ -42,6 +40,83 @@ function blockHtml(tb) { return `
${escapeHtml(LEVEL_LABEL[tb.level] || 'Note')}${tb.title ? `: ${escapeHtml(tb.title)}` : ''}${tb.descriptionHtml ? `
${tb.descriptionHtml}
` : ''}
`; } +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}

    `; + return ` +
    + ${head} +
    + ${stepBodyHtml(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 stepBodyHtml(step, ast, images, tpl) { const parts = []; const { before, rest } = stepContentGroups(step); @@ -68,28 +143,346 @@ function stepBodyHtml(step, ast, images, tpl) { } 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 #3b82f6; background: #eff6ff; padding: 8px 12px; margin: .6em 0; border-radius: 0 6px 6px 0; } - .block strong { color: #1d4ed8; } + :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 strong { color: #b45309; } + .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) { - body { background: #111827; color: #e5e7eb; } - h2 { border-color: #374151; } - pre.code, .block { background: #1f2937; } - th, td { border-color: #4b5563; } + :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; } } `; @@ -97,12 +490,16 @@ 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 toc = tpl.toc && ast.steps.length > 1 + ? ` +
    +

    Contents

    +
      + ${renderTocList(ast)} +
    +
    ` + : ''; + const stepsHtml = ast.steps.map((step) => renderStepCard(step, ast, images, tpl)).join('\n'); const html = ` @@ -112,11 +509,13 @@ function exportHtmlSimple(ast, outDir, template = {}) { ${escapeHtml(ast.guide.title)} - -

    ${escapeHtml(ast.guide.title)}

    -${ast.guide.descriptionHtml ? `
    ${ast.guide.descriptionHtml}
    ` : ''} + +
    +${renderCover(ast, tpl)} +${toc} ${stepsHtml} -
    Generated by StepForge on ${escapeHtml(ast.generatedAt)} — ${ast.steps.length} steps
    +
    Generated by StepForge on ${escapeHtml(ast.generatedAt)} · ${ast.steps.length} step${ast.steps.length === 1 ? '' : 's'}
    +
    `; @@ -130,39 +529,19 @@ function exportHtmlRich(ast, outDir, 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 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 stepsHtml = ast.steps.map((step) => renderStepCard(step, ast, images, tpl, { rich: true })).join('\n'); const script = ` (function () { @@ -199,19 +578,19 @@ function exportHtmlRich(ast, outDir, template = {}) { ${escapeHtml(ast.guide.title)} - + - -
    -
    -

    ${escapeHtml(ast.guide.title)}

    -${ast.guide.descriptionHtml ? `
    ${ast.guide.descriptionHtml}
    ` : ''} -
    +${renderCover(ast, tpl)} +
    +
    +
    +
    ${stepsHtml} -
    Generated by StepForge on ${escapeHtml(ast.generatedAt)} — ${ast.steps.length} steps
    +
    Generated by StepForge on ${escapeHtml(ast.generatedAt)} · ${ast.steps.length} step${ast.steps.length === 1 ? '' : 's'}
    diff --git a/exporters/image-bundle.js b/exporters/image-bundle.js index 6842112..815118d 100644 --- a/exporters/image-bundle.js +++ b/exporters/image-bundle.js @@ -3,6 +3,7 @@ const fs = require('node:fs'); const path = require('node:path'); const { guideSlug, writeStepImages } = require('./common'); +const { tocEntries, guideSummary } = require('./document-layout'); const raster = require('../core/raster'); const { decodePng, encodePng } = require('../core/png'); @@ -39,7 +40,8 @@ function exportImageBundle(ast, outDir, template = {}) { const meta = { format: 'stepforge-image-bundle', version: 1, - guide: { title: ast.guide.title, generatedAt: ast.generatedAt }, + guide: { title: ast.guide.title, generatedAt: ast.generatedAt, summary: guideSummary(ast) }, + toc: tocEntries(ast).map(({ number, title, depth, anchor }) => ({ number, title, depth, anchor })), steps: ast.steps.map((step) => ({ number: step.number, title: step.title, diff --git a/exporters/json.js b/exporters/json.js index 8e9d789..f199520 100644 --- a/exporters/json.js +++ b/exporters/json.js @@ -3,6 +3,7 @@ const fs = require('node:fs'); const path = require('node:path'); const { guideSlug, writeStepImages, stepBlocks, codeBlockText } = require('./common'); +const { tocEntries, guideSummary } = require('./document-layout'); /** * JSON exporter: structured guide + steps, annotated screenshots written to @@ -29,7 +30,9 @@ function exportJson(ast, outDir, template = {}) { descriptionHtml: ast.guide.descriptionHtml, createdAt: ast.guide.createdAt, updatedAt: ast.guide.updatedAt, + summary: guideSummary(ast), }, + toc: tocEntries(ast).map(({ number, title, depth, anchor }) => ({ number, title, depth, anchor })), steps: ast.steps.map((step) => ({ number: step.number, kind: step.kind, diff --git a/exporters/markdown-guide.js b/exporters/markdown-guide.js index 6f0fd02..a10fcfc 100644 --- a/exporters/markdown-guide.js +++ b/exporters/markdown-guide.js @@ -4,6 +4,7 @@ const fs = require('node:fs'); const path = require('node:path'); const { guideSlug, writeStepImages, LEVEL_LABEL, stepContentGroups, codeBlockText } = require('./common'); const { htmlToMarkdown } = require('./htmlmd'); +const { tocEntries, guideMetaLines, guideSummary } = require('./document-layout'); const DEFAULT_TEMPLATE = { toc: true, @@ -57,13 +58,17 @@ function renderMarkdownGuide(ast, outDir, template = {}, { const lines = []; lines.push(`# ${ast.guide.title}`, ''); + lines.push('
    ', ''); + const metaLines = guideMetaLines(ast); + if (metaLines.length) lines.push(metaLines.join(' · '), ''); + lines.push(`*${guideSummary(ast)}*`, ''); if (ast.guide.descriptionHtml) lines.push(htmlToMarkdown(ast.guide.descriptionHtml), ''); if (tpl.toc && ast.steps.length > 1) { lines.push(`## ${tocTitle}`, ''); - for (const step of ast.steps) { - const indent = ' '.repeat(step.depth); - lines.push(`${indent}- [${step.number}. ${step.title || 'Untitled step'}](#${anchorFor(step)})`); + for (const entry of tocEntries(ast)) { + const indent = ' '.repeat(entry.depth); + lines.push(`${indent}- [${entry.number}. ${entry.title}](#${entry.anchor})`); } lines.push(''); } diff --git a/exporters/pptx.js b/exporters/pptx.js index 89b2286..f822a1f 100644 --- a/exporters/pptx.js +++ b/exporters/pptx.js @@ -6,6 +6,7 @@ const { zipSync } = require('../core/zip'); const { escapeXml } = require('../core/util'); const { encodePng } = require('../core/png'); const { guideSlug, renderAllImages } = require('./common'); +const { tocEntries, guideMetaLines, guideSummary } = require('./document-layout'); /** * PPTX exporter: a title slide plus one 16:9 slide per step (title bar + @@ -15,6 +16,7 @@ const { guideSlug, renderAllImages } = require('./common'); const DEFAULT_TEMPLATE = { includeImages: true, titleSlide: true, + includeToc: true, }; const SLIDE_W = 12192000; // EMU, 16:9 @@ -29,6 +31,12 @@ function textBox(x, y, w, h, runsXml) { `${runsXml}`; } +function rectShape(x, y, w, h, fill) { + return `` + + `` + + ``; +} + function para(text, { size = 1800, bold = false, color = '111827' } = {}) { return `${escapeXml(text)}`; } @@ -85,22 +93,46 @@ function exportPptx(ast, outDir, template = {}) { const images = tpl.includeImages ? renderAllImages(ast) : new Map(); const slides = []; // { xml, rels: [{id, target}], media: [{name, data}] } + const toc = tpl.includeToc && ast.steps.length > 1 ? tocEntries(ast) : []; if (tpl.titleSlide) { + const metaLines = guideMetaLines(ast); + let titleContent = rectShape(0, 0, SLIDE_W, 18000, '2563EB'); + titleContent += textBox(914400, 2050000, SLIDE_W - 1828800, 1200000, para(ast.guide.title, { size: 4000, bold: true })); + titleContent += rectShape(914400, 3300000, 2200000, 14000, '2563EB'); + titleContent += textBox(914400, 3500000, SLIDE_W - 1828800, 1100000, + [para(guideSummary(ast), { size: 1800, color: '6B7280' }), + ...metaLines.map((line) => para(line, { size: 1500, color: '6B7280' }))].join('')); slides.push({ - xml: slideXml( - textBox(914400, 2300000, SLIDE_W - 1828800, 1200000, para(ast.guide.title, { size: 4000, bold: true })) + - textBox(914400, 3600000, SLIDE_W - 1828800, 800000, - para(`${ast.steps.length} steps — ${ast.generatedAt.slice(0, 10)}`, { size: 1800, color: '6B7280' })) - ), + xml: slideXml(titleContent), + rels: [], media: [], + }); + } + + if (toc.length) { + let tocContent = rectShape(0, 0, SLIDE_W, 18000, '2563EB'); + tocContent += textBox(914400, 760000, SLIDE_W - 1828800, 700000, para('Contents', { size: 3000, bold: true })); + tocContent += rectShape(914400, 1500000, 1600000, 14000, '2563EB'); + tocContent += textBox(914400, 1680000, SLIDE_W - 1828800, 450000, para(guideSummary(ast), { size: 1500, color: '6B7280' })); + toc.forEach((entry, index) => { + const x = 914400 + (entry.depth * 220000); + const y = 2300000 + (index * 255000); + tocContent += rectShape(x, y + 78000, 24000, 90000, '2563EB'); + tocContent += textBox(x + 48000, y, SLIDE_W - x - 1200000, 220000, + para(`${entry.number}. ${entry.title}`, { size: entry.depth === 0 ? 1550 : 1450, bold: entry.depth === 0 })); + }); + slides.push({ + xml: slideXml(tocContent), rels: [], media: [], }); } let mediaCounter = 0; for (const step of ast.steps) { - let content = textBox(457200, 274638, SLIDE_W - 914400, 700000, - para(`${step.number}. ${step.title || 'Untitled step'}`, { size: 2400, bold: true })); + let content = rectShape(0, 0, SLIDE_W, 18000, '2563EB'); + content += textBox(457200, 420000, SLIDE_W - 914400, 620000, + para(`${step.number}. ${step.title || 'Untitled step'}`, { size: 2600, bold: true })); + content += rectShape(457200, 1120000, 2400000, 12000, '2563EB'); const rels = []; const media = []; @@ -112,14 +144,14 @@ function exportPptx(ast, outDir, template = {}) { const relId = 2; // rId1 = layout, rId2 = image rels.push({ id: relId, name }); // Fit image into a centered region below the title. - const maxW = SLIDE_W - 1219200, maxH = SLIDE_H - 1554638 - (step.descriptionText ? 700000 : 200000); + const maxW = SLIDE_W - 1219200, maxH = SLIDE_H - 1870000 - (step.descriptionText ? 650000 : 260000); let w = img.width * EMU_PER_PX, h = img.height * EMU_PER_PX; const scale = Math.min(maxW / w, maxH / h, 1); w = Math.round(w * scale); h = Math.round(h * scale); - content += picture(relId, Math.round((SLIDE_W - w) / 2), 1054638, w, h); + content += picture(relId, Math.round((SLIDE_W - w) / 2), 1500000, w, h); } if (step.descriptionText) { - content += textBox(457200, SLIDE_H - 850000, SLIDE_W - 914400, 700000, + content += textBox(457200, SLIDE_H - 720000, SLIDE_W - 914400, 600000, para(step.descriptionText.slice(0, 300), { size: 1400, color: '374151' })); } slides.push({ xml: slideXml(content), rels, media }); diff --git a/exporters/wikijs.js b/exporters/wikijs.js index a6b5bcd..456407b 100644 --- a/exporters/wikijs.js +++ b/exporters/wikijs.js @@ -4,12 +4,11 @@ const { DEFAULT_TEMPLATE, renderMarkdownGuide } = require('./markdown-guide'); /** * Wiki.js markdown exporter. Same step/body structure as the generic - * Markdown exporter, but omits the manual Contents section by default and - * emits Wiki.js-friendly callout blocks. + * Markdown exporter, but uses Wiki.js-friendly callout blocks. */ const WIKIJS_TEMPLATE = { - toc: false, + toc: true, includeImages: true, imageMaxWidth: 0, }; diff --git a/tests/unit/exporters-binary.test.js b/tests/unit/exporters-binary.test.js index 3035475..b8d97e0 100644 --- a/tests/unit/exporters-binary.test.js +++ b/tests/unit/exporters-binary.test.js @@ -285,25 +285,45 @@ test('DOCX export: valid OPC package, well-formed XML, resolvable image rels', ( assert.equal(imageCount, 2); const entries = new Map(unzipSync(fs.readFileSync(file)).map((e) => [e.name, e.data])); - for (const required of ['[Content_Types].xml', '_rels/.rels', 'word/document.xml', 'word/_rels/document.xml.rels']) { + for (const required of ['[Content_Types].xml', '_rels/.rels', 'word/document.xml', 'word/_rels/document.xml.rels', 'word/settings.xml']) { assert.ok(entries.has(required), `missing ${required}`); } assertWellFormedXml(entries.get('word/document.xml').toString('utf8'), 'document.xml'); assertWellFormedXml(entries.get('[Content_Types].xml').toString('utf8'), 'content types'); + assertWellFormedXml(entries.get('word/settings.xml').toString('utf8'), 'settings.xml'); // Every relationship target exists in the package, every embed has a rel. const relsXml = entries.get('word/_rels/document.xml.rels').toString('utf8'); const relTargets = [...relsXml.matchAll(/Target="([^"]+)"/g)].map((m) => m[1]); - assert.equal(relTargets.length, 2); - for (const target of relTargets) { + assert.equal(relTargets.length, 7); + assert.ok(relTargets.includes('settings.xml')); + + const mediaTargets = relTargets.filter((target) => target.startsWith('media/')); + assert.equal(mediaTargets.length, 6); + const iconTargets = mediaTargets.filter((target) => target.includes('callout-')); + const imageTargets = mediaTargets.filter((target) => target.includes('image')); + assert.equal(iconTargets.length, 4); + assert.equal(imageTargets.length, 2); + + for (const target of iconTargets) { + assert.ok(entries.has(`word/${target}`), `relationship target ${target} present`); + const img = decodePng(entries.get(`word/${target}`)); + assert.equal(img.width, 24); + } + for (const target of imageTargets) { assert.ok(entries.has(`word/${target}`), `relationship target ${target} present`); const img = decodePng(entries.get(`word/${target}`)); assert.equal(img.width, 320); } const docXml = entries.get('word/document.xml').toString('utf8'); const embeds = [...docXml.matchAll(/r:embed="(rId\d+)"/g)].map((m) => m[1]); - const relIds = [...relsXml.matchAll(/Id="(rId\d+)"/g)].map((m) => m[1]); - assert.deepEqual(embeds.sort(), relIds.sort()); + const relIds = [...relsXml.matchAll(/Id="(rId\d+)"/g)].map((m) => m[1]).filter((id) => id !== 'rId1'); + for (const id of embeds) { + assert.ok(relIds.includes(id), `missing relationship for ${id}`); + } + assert.ok(docXml.includes('TOC \\o "1-3" \\h \\z \\u')); + assert.ok(docXml.includes('w:pStyle w:val="Heading1"')); + assert.ok(docXml.includes('w:pStyle w:val="Heading2"')); // unzip CLI also accepts the package (it is a plain zip). assert.ok(entries.size >= 6); @@ -313,7 +333,7 @@ test('PPTX export: slides per step, master/layout/theme present, rels resolve', const { ast, root } = fixtureAst(t, 'pptx'); const { file, slideCount, imageCount } = exportPptx(ast, path.join(root, 'out')); - assert.equal(slideCount, 4, 'title slide + 3 steps'); + assert.equal(slideCount, 5, 'title slide + contents slide + 3 steps'); assert.equal(imageCount, 2); const entries = new Map(unzipSync(fs.readFileSync(file)).map((e) => [e.name, e.data])); for (const required of [ @@ -331,6 +351,7 @@ test('PPTX export: slides per step, master/layout/theme present, rels resolve', // presentation.xml references each slide and the count matches. const pres = entries.get('ppt/presentation.xml').toString('utf8'); assert.equal((pres.match(/ Admins only.'); }); -test('Wiki.js export: TOC is omitted, wiki callouts render, images exist', (t) => { +test('Wiki.js export: TOC is included, wiki callouts render, images exist', (t) => { const root = makeTmpDir('expwikijs'); t.after(() => rmrf(root)); const { store, guide } = buildFixtureGuide(path.join(root, 'data')); @@ -156,7 +156,7 @@ test('Wiki.js export: TOC is omitted, wiki callouts render, images exist', (t) = const lines = md.split('\n'); assert.equal(lines[0], '# Configure AcmeSync backups'); - assert.ok(!lines.some((l) => l === '## Contents')); + assert.ok(lines.some((l) => l === '## Contents')); assert.ok(lines.some((l) => l.startsWith('## 1. Open AcmeSync settings'))); assert.ok(lines.some((l) => l.startsWith('> **Access**'))); assert.ok(lines.includes('> Admins only.')); @@ -238,7 +238,7 @@ test('Rich HTML export: TOC matches sections, checkboxes per step, local-only pe const { file } = exportHtmlRich(ast, out); const html = fs.readFileSync(file, 'utf8'); - const tocAnchors = [...html.matchAll(/
  • m[1]); + const tocAnchors = [...html.matchAll(/
  • \s* m[1]); const sectionIds = [...html.matchAll(/
    m[1]); assert.deepEqual(tocAnchors, sectionIds); assert.equal(sectionIds.length, 3);