Polish exported documents and TOCs
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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
@@ -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 "1-3" \\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
@@ -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,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,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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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(/<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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user