Polish exported documents and TOCs

This commit is contained in:
2026-06-15 14:22:31 -05:00
parent b84b7883fe
commit 61e3c812a0
11 changed files with 668 additions and 119 deletions
+6 -4
View File
@@ -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 = {}) {
</head>
<body>
<h1>${escapeXml(ast.guide.title)}</h1>
<div style="border-bottom: 4px solid #2563eb; margin: 14px 0 18px;"></div>
${guideMetaLines(ast).map((line) => `<p><strong>${escapeXml(line.split(': ')[0])}:</strong> ${escapeXml(line.slice(line.indexOf(': ') + 2))}</p>`).join('\n')}
<p><em>${escapeXml(guideSummary(ast))}</em></p>
${ast.guide.descriptionHtml ? `<div>${stepLinkRewrite(ast.guide.descriptionHtml, ast)}</div>` : ''}
${tpl.toc && ast.steps.length > 1 ? '<ac:structured-macro ac:name="toc"></ac:structured-macro>' : ''}
${stepXml}
</body>
</html>
+40
View File
@@ -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,
};
+85 -19
View File
@@ -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) {
`</pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>`;
}
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('<w:r><w:br w:type="page"/></w:r>');
}
function headingStyleForDepth(depth) {
return `Heading${Math.min(3, depth + 1)}`;
}
function table(rows) {
const cols = Math.max(...rows.map((r) => r.length));
const grid = `<w:tblGrid>${'<w:gridCol w:w="2400"/>'.repeat(cols)}</w:tblGrid>`;
@@ -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(`<Relationship Id="rId${relId}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/${name}"/>`);
}
const body = [];
body.push(p(run(ast.guide.title, { bold: true, size: 48 })));
body.push(p(
run(ast.guide.title, { bold: true, size: 48 }),
'<w:pBdr><w:bottom w:val="single" w:sz="24" w:space="12" w:color="2563EB"/></w:pBdr>'
));
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 }),
'<w:pBdr><w:bottom w:val="single" w:sz="20" w:space="8" w:color="2563EB"/></w:pBdr>'
));
body.push(p(
`<w:fldSimple w:instr="TOC \\o &quot;1-3&quot; \\h \\z \\u"><w:r><w:rPr><w:i/></w:rPr><w:t xml:space="preserve">Update contents in Word</w:t></w:r></w:fldSimple>`
));
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' }) : ''}`,
`<w:shd w:val="clear" w:fill="${style.fill}"/><w:pBdr><w:left w:val="single" w:sz="24" w:space="4" w:color="${style.color}"/></w:pBdr>`
));
};
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 ? '<w:pageBreakBefore/>' : ''));
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 ? '<w:pageBreakBefore/>' : ''}<w:pStyle w:val="${headingStyleForDepth(step.depth)}"/>`
));
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(`<Relationship Id="rId${relCounter}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/${name}"/>`);
body.push(p(drawing(relCounter, img.width, img.height, tpl.imageWidthTwips)));
rels.push(`<Relationship Id="rId${relId}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/${name}"/>`);
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 }) : ''),
`<w:shd w:val="clear" w:fill="${style.fill}"/><w:pBdr><w:left w:val="single" w:sz="24" w:space="4" w:color="${style.color}"/></w:pBdr>`
));
}
const settingsXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:updateFields w:val="true"/>
</w:settings>`;
const documentXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
@@ -152,6 +215,7 @@ ${body.join('\n')}
<Default Extension="xml" ContentType="application/xml"/>
<Default Extension="png" ContentType="image/png"/>
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
<Override PartName="/word/settings.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml"/>
</Types>`,
},
{
@@ -166,16 +230,18 @@ ${body.join('\n')}
name: 'word/_rels/document.xml.rels',
data: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings" Target="settings.xml"/>
${rels.join('\n')}
</Relationships>`,
},
{ 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 };
+449 -70
View File
@@ -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 `<div class="block block-${tb.level}"><strong>${escapeHtml(LEVEL_LABEL[tb.level] || 'Note')}${tb.title ? `: ${escapeHtml(tb.title)}` : ''}</strong>${tb.descriptionHtml ? `<div>${tb.descriptionHtml}</div>` : ''}</div>`;
}
function renderMetaChips(ast) {
return [
...guideMetaLines(ast).map((line) => `<span class="chip">${escapeHtml(line)}</span>`),
`<span class="chip muted">${escapeHtml(guideSummary(ast))}</span>`,
].join('');
}
function renderTocList(ast) {
return tocEntries(ast).map((entry) => `
<li class="d${entry.depth}">
<a href="#${entry.anchor}">
<span class="num">${escapeHtml(entry.number)}</span>
<span class="label">${escapeHtml(entry.title)}</span>
</a>
</li>
`).join('');
}
function renderCover(ast, tpl) {
return `
<section class="cover">
<div class="eyebrow">StepForge export</div>
<h1>${escapeHtml(ast.guide.title)}</h1>
<div class="rule" style="background:${tpl.accentColor}"></div>
${ast.guide.descriptionHtml ? `<div class="desc">${stepLinkRewrite(ast.guide.descriptionHtml, ast)}</div>` : ''}
<div class="meta">${renderMetaChips(ast)}</div>
</section>`;
}
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 ? `
<h2>
<label class="check"><input type="checkbox" class="step-done" data-step="${escapeHtml(step.stepId)}"></label>
<span class="step-num">${escapeHtml(step.number)}</span>
<span class="step-title">${escapeHtml(step.title || 'Untitled step')}</span>
<span class="status-chip status-${step.status || 'todo'}${step.skipped ? ' skipped' : ''}">${escapeHtml(statusText)}</span>
</h2>` : `<h2>${title}</h2>`;
return `
<section class="step-card${step.skipped ? ' skipped' : ''}${rich && selected ? ' selected' : ''}" id="${anchorFor(step)}">
${head}
<div class="step-body">
${stepBodyHtml(step, ast, images, tpl)}
</div>
</section>`;
}
function renderRichToc(ast) {
return tocEntries(ast).map((entry) => `
<li class="d${entry.depth}">
<a href="#${entry.anchor}">
<span class="num">${escapeHtml(entry.number)}</span>
<span class="label">${escapeHtml(entry.title)}</span>
</a>
</li>
`).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) => `
<section class="step${step.skipped ? ' skipped' : ''}" id="${anchorFor(step)}">
<h2>${escapeHtml(step.number)}. ${escapeHtml(step.title || 'Untitled step')}</h2>
${stepBodyHtml(step, ast, images, tpl)}
</section>`).join('\n');
const toc = tpl.toc && ast.steps.length > 1
? `
<section class="toc-card">
<h2>Contents</h2>
<ul class="toc-list">
${renderTocList(ast)}
</ul>
</section>`
: '';
const stepsHtml = ast.steps.map((step) => renderStepCard(step, ast, images, tpl)).join('\n');
const html = `<!DOCTYPE html>
<html lang="en">
@@ -112,11 +509,13 @@ function exportHtmlSimple(ast, outDir, template = {}) {
<title>${escapeHtml(ast.guide.title)}</title>
<style>${BASE_CSS}${tpl.customCss}</style>
</head>
<body>
<h1>${escapeHtml(ast.guide.title)}</h1>
${ast.guide.descriptionHtml ? `<div class="desc">${ast.guide.descriptionHtml}</div>` : ''}
<body style="${bodyStyle(tpl)}">
<div class="doc doc-simple">
${renderCover(ast, tpl)}
${toc}
${stepsHtml}
<footer><small>Generated by StepForge on ${escapeHtml(ast.generatedAt)} ${ast.steps.length} steps</small></footer>
<footer class="footer-note">Generated by StepForge on ${escapeHtml(ast.generatedAt)} · ${ast.steps.length} step${ast.steps.length === 1 ? '' : 's'}</footer>
</div>
</body>
</html>
`;
@@ -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
? `
<aside class="toc-panel">
<section class="toc-card">
<h2>Contents</h2>
<ul class="toc-list">
${renderRichToc(ast)}
</ul>
</section>
</aside>`
: '';
const tocHtml = ast.steps.map((step) =>
`<li class="d${step.depth}"><a href="#${anchorFor(step)}">${escapeHtml(step.number)}. ${escapeHtml(step.title || 'Untitled step')}</a></li>`
).join('\n');
const stepsHtml = ast.steps.map((step) => `
<section class="step${step.skipped ? ' skipped' : ''}" id="${anchorFor(step)}">
<h2>
<label class="check"><input type="checkbox" class="step-done" data-step="${escapeHtml(step.stepId)}"></label>
${escapeHtml(step.number)}. ${escapeHtml(step.title || 'Untitled step')}
</h2>
${stepBodyHtml(step, ast, images, tpl)}
</section>`).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 = {}) {
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(ast.guide.title)}</title>
<style>${BASE_CSS}${richCss}${tpl.customCss}</style>
<style>${BASE_CSS}${RICH_CSS}${tpl.customCss}</style>
</head>
<body>
<div class="layout">
<nav class="toc"><strong>Contents</strong><ul>
<body style="${bodyStyle(tpl)}">
<div class="doc layout-rich">
${tocHtml}
</ul></nav>
<main>
<h1>${escapeHtml(ast.guide.title)}</h1>
${ast.guide.descriptionHtml ? `<div class="desc">${ast.guide.descriptionHtml}</div>` : ''}
<div class="progress"><div class="label"></div><div class="bar"><div class="fill"></div></div></div>
${renderCover(ast, tpl)}
<div class="progress progress-card">
<div class="label"></div>
<div class="bar"><div class="fill"></div></div>
</div>
${stepsHtml}
<footer><small>Generated by StepForge on ${escapeHtml(ast.generatedAt)} ${ast.steps.length} steps</small></footer>
<footer class="footer-note">Generated by StepForge on ${escapeHtml(ast.generatedAt)} · ${ast.steps.length} step${ast.steps.length === 1 ? '' : 's'}</footer>
</main>
</div>
<script>${script}</script>
+3 -1
View File
@@ -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,
+3
View File
@@ -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,
+8 -3
View File
@@ -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('<div style="height:4px;background:#2563eb;border-radius:999px;margin:12px 0 18px;"></div>', '');
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('');
}
+42 -10
View File
@@ -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) {
`<p:txBody><a:bodyPr wrap="square"><a:normAutofit/></a:bodyPr><a:lstStyle/>${runsXml}</p:txBody></p:sp>`;
}
function rectShape(x, y, w, h, fill) {
return `<p:sp><p:nvSpPr><p:cNvPr id="${shapeIdCounter++}" name="Rect"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr>` +
`<p:spPr><a:xfrm><a:off x="${x}" y="${y}"/><a:ext cx="${w}" cy="${h}"/></a:xfrm>` +
`<a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:solidFill><a:srgbClr val="${fill}"/></a:solidFill><a:ln><a:noFill/></a:ln></p:spPr></p:sp>`;
}
function para(text, { size = 1800, bold = false, color = '111827' } = {}) {
return `<a:p><a:r><a:rPr lang="en-US" sz="${size}" b="${bold ? 1 : 0}" dirty="0"><a:solidFill><a:srgbClr val="${color}"/></a:solidFill></a:rPr><a:t>${escapeXml(text)}</a:t></a:r></a:p>`;
}
@@ -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 });
+2 -3
View File
@@ -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,
};
+27 -6
View File
@@ -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 &quot;1-3&quot; \\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(/<p:sldId /g) || []).length, slideCount);
assert.ok(entries.get('ppt/slides/slide2.xml').toString('utf8').includes('Contents'));
// image rels on slides resolve to media files.
for (let i = 1; i <= slideCount; i++) {
const rels = entries.get(`ppt/slides/_rels/slide${i}.xml.rels`).toString('utf8');
+3 -3
View File
@@ -144,7 +144,7 @@ test('Markdown export: TOC anchors resolve, images exist, blocks rendered', (t)
assert.equal(lines[warnIdx + 2], '> 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(/<li class="d\d"><a href="#([^"]+)"/g)].map((m) => m[1]);
const tocAnchors = [...html.matchAll(/<li class="d\d">\s*<a href="#([^"]+)"/g)].map((m) => m[1]);
const sectionIds = [...html.matchAll(/<section class="step[^"]*" id="([^"]+)"/g)].map((m) => m[1]);
assert.deepEqual(tocAnchors, sectionIds);
assert.equal(sectionIds.length, 3);