diff --git a/app/main.js b/app/main.js
index d410060..3dc4bbf 100644
--- a/app/main.js
+++ b/app/main.js
@@ -11,7 +11,7 @@ const {
const { GuideStore } = require('../core/store');
const { Settings } = require('../core/settings');
const { SearchIndex } = require('../core/search');
-const { TemplateManager, FORMATS } = require('../core/templates');
+const { TemplateManager, FORMATS, FORMAT_LABELS } = require('../core/templates');
const { buildRenderAst } = require('../core/renderast');
const { runExport, EXPORTERS } = require('../exporters');
const { runExportInWorker } = require('./export-runner');
@@ -579,13 +579,17 @@ function setupIpc() {
});
// export + preview
- h('export:formats', () => FORMATS.filter((f) => EXPORTERS[f]));
+ h('export:formats', () => FORMATS.filter((f) => EXPORTERS[f]).map((format) => ({
+ id: format,
+ label: FORMAT_LABELS[format] || format,
+ })));
h('export:defaults', ({ format }) => {
// Exporter modules expose DEFAULT_TEMPLATE; the dialog renders editable
// options from it (booleans -> checkbox, numbers -> number, strings -> text).
const mod = {
json: '../exporters/json',
markdown: '../exporters/markdown',
+ wikijs: '../exporters/wikijs',
'html-simple': '../exporters/html',
'html-rich': '../exporters/html',
confluence: '../exporters/confluence',
diff --git a/core/templates.js b/core/templates.js
index 1422383..225c884 100644
--- a/core/templates.js
+++ b/core/templates.js
@@ -11,7 +11,21 @@ const { writeJsonSync, readJsonSync, atomicWriteFileSync, nowIso } = require('./
* defaults, shareable as .sfglt zip files.
*/
-const FORMATS = ['json', 'markdown', 'html-simple', 'html-rich', 'confluence', 'pdf', 'gif', 'image-bundle', 'docx', 'pptx'];
+const FORMATS = ['json', 'markdown', 'wikijs', 'html-simple', 'html-rich', 'confluence', 'pdf', 'gif', 'image-bundle', 'docx', 'pptx'];
+
+const FORMAT_LABELS = {
+ json: 'JSON',
+ markdown: 'Markdown',
+ wikijs: 'Wiki.js',
+ 'html-simple': 'HTML (simple)',
+ 'html-rich': 'HTML (rich)',
+ confluence: 'Confluence',
+ pdf: 'PDF',
+ gif: 'GIF',
+ 'image-bundle': 'Image bundle',
+ docx: 'DOCX',
+ pptx: 'PPTX',
+};
class TemplateManager {
constructor(templatesDir) {
@@ -96,4 +110,4 @@ class TemplateManager {
}
}
-module.exports = { TemplateManager, FORMATS };
+module.exports = { TemplateManager, FORMATS, FORMAT_LABELS };
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 1ea0578..095560c 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -81,6 +81,7 @@ guide.json + step.json + settings
▼ hidden/skipped, focused-view geometry)
Render AST ──► exporters/json.js .json + steps-
/ images
──► exporters/markdown.js .md + steps-/ images
+ ──► exporters/wikijs.js .md + steps-/ images
──► exporters/html-simple.js single self-contained .html
──► exporters/html-rich.js checkboxes + floating TOC
──► exporters/pdf.js native PDF writer (core/pdf.js)
diff --git a/exporters/index.js b/exporters/index.js
index 92c5687..e2892c3 100644
--- a/exporters/index.js
+++ b/exporters/index.js
@@ -2,6 +2,7 @@
const { exportJson } = require('./json');
const { exportMarkdown } = require('./markdown');
+const { exportWikiJs } = require('./wikijs');
const { exportHtmlSimple, exportHtmlRich } = require('./html');
const { exportConfluence } = require('./confluence');
const { exportPdf } = require('./pdf');
@@ -14,6 +15,7 @@ const { exportPptx } = require('./pptx');
const EXPORTERS = {
json: exportJson,
markdown: exportMarkdown,
+ wikijs: exportWikiJs,
'html-simple': exportHtmlSimple,
'html-rich': exportHtmlRich,
confluence: exportConfluence,
diff --git a/exporters/markdown-guide.js b/exporters/markdown-guide.js
new file mode 100644
index 0000000..6f0fd02
--- /dev/null
+++ b/exporters/markdown-guide.js
@@ -0,0 +1,117 @@
+'use strict';
+
+const fs = require('node:fs');
+const path = require('node:path');
+const { guideSlug, writeStepImages, LEVEL_LABEL, stepContentGroups, codeBlockText } = require('./common');
+const { htmlToMarkdown } = require('./htmlmd');
+
+const DEFAULT_TEMPLATE = {
+ toc: true,
+ includeImages: true,
+ azureWiki: false,
+ imageMaxWidth: 0, // 0 = natural size
+};
+
+const WIKIJS_CALLOUT_CLASS = {
+ info: 'is-info',
+ success: 'is-success',
+ warn: 'is-warning',
+ error: 'is-danger',
+};
+
+function anchorFor(step) {
+ return `step-${step.number.replace(/\./g, '-')}`;
+}
+
+function quoteBody(text) {
+ return text ? text.replace(/\n/g, '\n> ') : '';
+}
+
+function emitBlock(lines, tb, { alertStyle = 'gfm' } = {}) {
+ const body = htmlToMarkdown(tb.descriptionHtml);
+ if (alertStyle === 'wikijs') {
+ const label = tb.title || LEVEL_LABEL[tb.level] || 'Note';
+ const className = WIKIJS_CALLOUT_CLASS[tb.level] || 'is-info';
+ lines.push(`> **${label}**`);
+ if (body) lines.push(`> ${quoteBody(body)}`);
+ lines.push(`{.${className}}`, '');
+ return;
+ }
+
+ const label = LEVEL_LABEL[tb.level] || 'Note';
+ lines.push(`> [!${label.toUpperCase()}]`);
+ if (tb.title) lines.push(`> **${tb.title}**`);
+ if (body) lines.push(`> ${quoteBody(body)}`);
+ lines.push('');
+}
+
+function renderMarkdownGuide(ast, outDir, template = {}, {
+ defaults = DEFAULT_TEMPLATE,
+ alertStyle = 'gfm',
+ tocTitle = 'Contents',
+ fileExt = '.md',
+} = {}) {
+ const tpl = { ...defaults, ...template };
+ fs.mkdirSync(outDir, { recursive: true });
+ const images = tpl.includeImages ? writeStepImages(ast, outDir) : new Map();
+ const lines = [];
+
+ lines.push(`# ${ast.guide.title}`, '');
+ 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)})`);
+ }
+ lines.push('');
+ }
+
+ for (const step of ast.steps) {
+ const heading = step.depth > 0 ? '###' : '##';
+ lines.push(``, '');
+ lines.push(`${heading} ${step.number}. ${step.title || 'Untitled step'}`, '');
+ if (step.skipped) lines.push('*(skipped)*', '');
+
+ const { before, rest } = stepContentGroups(step);
+ for (const tb of before) emitBlock(lines, tb, { alertStyle });
+
+ if (step.descriptionHtml) lines.push(htmlToMarkdown(step.descriptionHtml), '');
+
+ const img = images.get(step.stepId);
+ if (img) {
+ if (tpl.azureWiki && tpl.imageMaxWidth > 0) {
+ lines.push(``, '');
+ } else {
+ lines.push(``, '');
+ }
+ }
+
+ for (const block of rest) {
+ if (block.kind === 'text') {
+ emitBlock(lines, block, { alertStyle });
+ } else if (block.kind === 'code') {
+ lines.push(`\`\`\`${block.language || ''}`, codeBlockText(block), '```', '');
+ } else if (block.kind === 'table') {
+ if (!block.rows || !block.rows.length) continue;
+ const width = Math.max(...block.rows.map((r) => r.length));
+ const pad = (r) => { const c = [...r]; while (c.length < width) c.push(''); return c; };
+ lines.push(`| ${pad(block.rows[0]).join(' | ')} |`);
+ lines.push(`|${' --- |'.repeat(width)}`);
+ for (const row of block.rows.slice(1)) lines.push(`| ${pad(row).join(' | ')} |`);
+ lines.push('');
+ }
+ }
+ }
+
+ const file = path.join(outDir, `${guideSlug(ast)}${fileExt}`);
+ fs.writeFileSync(file, lines.join('\n').replace(/\n{3,}/g, '\n\n') + '\n');
+ return { file, imageCount: images.size };
+}
+
+module.exports = {
+ DEFAULT_TEMPLATE,
+ anchorFor,
+ renderMarkdownGuide,
+};
diff --git a/exporters/markdown.js b/exporters/markdown.js
index 5d6be64..8eb9aa7 100644
--- a/exporters/markdown.js
+++ b/exporters/markdown.js
@@ -1,96 +1,19 @@
'use strict';
-const fs = require('node:fs');
-const path = require('node:path');
-const { guideSlug, writeStepImages, LEVEL_LABEL, stepContentGroups, codeBlockText } = require('./common');
-const { htmlToMarkdown } = require('./htmlmd');
+const { DEFAULT_TEMPLATE, anchorFor, renderMarkdownGuide } = require('./markdown-guide');
/**
* Markdown exporter. Writes .md plus a steps-/ image folder.
* azureWiki mode emits resized image syntax (=WxH) Azure DevOps wikis accept.
*/
-const DEFAULT_TEMPLATE = {
- toc: true,
- includeImages: true,
- azureWiki: false,
- imageMaxWidth: 0, // 0 = natural size
-};
-
-function anchorFor(step) {
- return `step-${step.number.replace(/\./g, '-')}`;
-}
-
function exportMarkdown(ast, outDir, template = {}) {
- const tpl = { ...DEFAULT_TEMPLATE, ...template };
- fs.mkdirSync(outDir, { recursive: true });
- const images = tpl.includeImages ? writeStepImages(ast, outDir) : new Map();
- const lines = [];
-
- lines.push(`# ${ast.guide.title}`, '');
- if (ast.guide.descriptionHtml) lines.push(htmlToMarkdown(ast.guide.descriptionHtml), '');
-
- if (tpl.toc && ast.steps.length > 1) {
- lines.push('## Contents', '');
- for (const step of ast.steps) {
- const indent = ' '.repeat(step.depth);
- lines.push(`${indent}- [${step.number}. ${step.title || 'Untitled step'}](#${anchorFor(step)})`);
- }
- lines.push('');
- }
-
- for (const step of ast.steps) {
- const heading = step.depth > 0 ? '###' : '##';
- lines.push(``, '');
- lines.push(`${heading} ${step.number}. ${step.title || 'Untitled step'}`, '');
- if (step.skipped) lines.push('*(skipped)*', '');
-
- const { before, rest } = stepContentGroups(step);
- for (const tb of before) emitBlock(lines, tb);
-
- if (step.descriptionHtml) lines.push(htmlToMarkdown(step.descriptionHtml), '');
-
- const img = images.get(step.stepId);
- if (img) {
- if (tpl.azureWiki && tpl.imageMaxWidth > 0) {
- lines.push(``, '');
- } else {
- lines.push(``, '');
- }
- }
-
- for (const block of rest) {
- if (block.kind === 'text') {
- emitBlock(lines, block);
- } else if (block.kind === 'code') {
- lines.push(`\`\`\`${block.language || ''}`, codeBlockText(block), '```', '');
- } else if (block.kind === 'table') {
- if (!block.rows || !block.rows.length) continue;
- const width = Math.max(...block.rows.map((r) => r.length));
- const pad = (r) => { const c = [...r]; while (c.length < width) c.push(''); return c; };
- lines.push(`| ${pad(block.rows[0]).join(' | ')} |`);
- lines.push(`|${' --- |'.repeat(width)}`);
- for (const row of block.rows.slice(1)) lines.push(`| ${pad(row).join(' | ')} |`);
- lines.push('');
- }
- }
- }
-
- const file = path.join(outDir, `${guideSlug(ast)}.md`);
- fs.writeFileSync(file, lines.join('\n').replace(/\n{3,}/g, '\n\n') + '\n');
- return { file, imageCount: images.size };
-}
-
-function emitBlock(lines, tb) {
- const label = LEVEL_LABEL[tb.level] || 'Note';
- // GitHub-Flavored Markdown alert syntax — renders with a colored,
- // icon-labeled box on GitHub/Azure DevOps wikis and several other
- // viewers; degrades to a plain blockquote elsewhere.
- lines.push(`> [!${label.toUpperCase()}]`);
- if (tb.title) lines.push(`> **${tb.title}**`);
- const body = htmlToMarkdown(tb.descriptionHtml);
- if (body) lines.push(`> ${body.replace(/\n/g, '\n> ')}`);
- lines.push('');
+ return renderMarkdownGuide(ast, outDir, template, {
+ defaults: DEFAULT_TEMPLATE,
+ alertStyle: 'gfm',
+ tocTitle: 'Contents',
+ fileExt: '.md',
+ });
}
module.exports = { exportMarkdown, DEFAULT_TEMPLATE, anchorFor };
diff --git a/exporters/wikijs.js b/exporters/wikijs.js
new file mode 100644
index 0000000..a6b5bcd
--- /dev/null
+++ b/exporters/wikijs.js
@@ -0,0 +1,26 @@
+'use strict';
+
+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.
+ */
+
+const WIKIJS_TEMPLATE = {
+ toc: false,
+ includeImages: true,
+ imageMaxWidth: 0,
+};
+
+function exportWikiJs(ast, outDir, template = {}) {
+ return renderMarkdownGuide(ast, outDir, template, {
+ defaults: WIKIJS_TEMPLATE,
+ alertStyle: 'wikijs',
+ tocTitle: 'Contents',
+ fileExt: '.md',
+ });
+}
+
+module.exports = { exportWikiJs, DEFAULT_TEMPLATE: WIKIJS_TEMPLATE };
diff --git a/scripts/build-release.sh b/scripts/build-release.sh
index d3827fe..366b49b 100644
--- a/scripts/build-release.sh
+++ b/scripts/build-release.sh
@@ -97,7 +97,7 @@ Host: ${process.platform} ${process.arch} (node ${process.version})
- Portable tarball: ${files.find((f) => f.path.endsWith('.tar.gz'))?.path || 'not generated'}
- Debian package: ${files.find((f) => f.path.endsWith('.deb'))?.path || 'not generated'}
- Sample guide archive: ${files.find((f) => f.path.endsWith('sample-guide.sfgz'))?.path || 'not generated'}
-- Sample exports (9 formats): see examples/sample-exports/
+- Sample exports (10 formats): see examples/sample-exports/
- Full artifact list with sha256 checksums: artifacts_manifest.json
## Packaging tool availability
diff --git a/scripts/make-sample-guide.js b/scripts/make-sample-guide.js
index 13bb440..bffb313 100644
--- a/scripts/make-sample-guide.js
+++ b/scripts/make-sample-guide.js
@@ -223,7 +223,7 @@ function createGuide(store) {
function exportOutputs(store, guideId, root, manifest) {
const ast = buildRenderAst(store, guideId);
- const formats = ['json', 'markdown', 'html-simple', 'html-rich', 'pdf', 'gif', 'image-bundle', 'docx', 'pptx'];
+ const formats = ['json', 'markdown', 'wikijs', 'html-simple', 'html-rich', 'pdf', 'gif', 'image-bundle', 'docx', 'pptx'];
const outputs = {};
for (const format of formats) {
const outDir = path.join(root, 'sample-exports', format);
diff --git a/tests/checks/test_workflow_sample_artifacts.sh b/tests/checks/test_workflow_sample_artifacts.sh
index c34ad02..7b543da 100644
--- a/tests/checks/test_workflow_sample_artifacts.sh
+++ b/tests/checks/test_workflow_sample_artifacts.sh
@@ -21,7 +21,7 @@ for f in sample-manifest.json sample-guide.sfgz; do
done
for dir in sample-data sample-exports/json sample-exports/markdown sample-exports/html-simple \
- sample-exports/html-rich sample-exports/pdf sample-exports/gif \
+ sample-exports/wikijs sample-exports/html-rich sample-exports/pdf sample-exports/gif \
sample-exports/image-bundle sample-exports/docx sample-exports/pptx; do
if ! find "$SAMPLE_ROOT/$dir" -type f -print -quit | grep -q .; then
echo "Sample export directory is empty: $dir" >&2
@@ -34,7 +34,7 @@ const fs = require('node:fs');
const manifest = JSON.parse(fs.readFileSync(process.env.MANIFEST_FILE, 'utf8'));
if (manifest.format !== 'stepforge-sample-manifest') throw new Error('unexpected sample manifest format');
if (!manifest.guideId) throw new Error('missing guideId');
-if (!manifest.exports || Object.keys(manifest.exports).length < 9) throw new Error('missing sample exports');
+if (!manifest.exports || Object.keys(manifest.exports).length < 10) throw new Error('missing sample exports');
NODE
echo "sample artifacts OK"
diff --git a/tests/unit/exporters-binary.test.js b/tests/unit/exporters-binary.test.js
index 7504e39..3035475 100644
--- a/tests/unit/exporters-binary.test.js
+++ b/tests/unit/exporters-binary.test.js
@@ -376,6 +376,6 @@ test('a saved template changes exporter behavior through runExport', (t) => {
const withTemplate = runExport('pdf', ast, path.join(root, 'out2'), tm.load('pdf', 'no-cover'));
assert.ok(withTemplate.pageCount < withDefaults.pageCount, 'dropping cover+toc reduces pages');
- assert.equal(Object.keys(EXPORTERS).length, 10, 'all ten formats wired');
+ assert.equal(Object.keys(EXPORTERS).length, 11, 'all eleven formats wired');
assert.throws(() => runExport('exe', ast, path.join(root, 'out3')));
});
diff --git a/tests/unit/exporters-text.test.js b/tests/unit/exporters-text.test.js
index 185a087..c5f5086 100644
--- a/tests/unit/exporters-text.test.js
+++ b/tests/unit/exporters-text.test.js
@@ -8,6 +8,7 @@ const path = require('node:path');
const { buildRenderAst, renderStepImage } = require('../../core/renderast');
const { exportJson } = require('../../exporters/json');
const { exportMarkdown } = require('../../exporters/markdown');
+const { exportWikiJs } = require('../../exporters/wikijs');
const { exportHtmlSimple, exportHtmlRich } = require('../../exporters/html');
const { exportConfluence } = require('../../exporters/confluence');
const { htmlToMarkdown } = require('../../exporters/htmlmd');
@@ -143,6 +144,32 @@ 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) => {
+ const root = makeTmpDir('expwikijs');
+ t.after(() => rmrf(root));
+ const { store, guide } = buildFixtureGuide(path.join(root, 'data'));
+ const out = path.join(root, 'out');
+
+ const ast = buildRenderAst(store, guide.guideId);
+ const { file } = exportWikiJs(ast, out);
+ const md = fs.readFileSync(file, 'utf8');
+
+ 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.startsWith('## 1. Open AcmeSync settings')));
+ assert.ok(lines.some((l) => l.startsWith('> **Access**')));
+ assert.ok(lines.includes('> Admins only.'));
+ assert.ok(lines.includes('{.is-warning}'));
+
+ const imgRefs = [...md.matchAll(/!\[[^\]]*\]\(([^)]+)\)/g)].map((m) => m[1]);
+ assert.equal(imgRefs.length, 2);
+ for (const rel of imgRefs) {
+ const img = decodePng(fs.readFileSync(path.join(out, rel)));
+ assert.equal(img.width, 320);
+ }
+});
+
test('Confluence export writes storage-format XML and image attachments', (t) => {
const root = makeTmpDir('expconf');
t.after(() => rmrf(root));