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-<title>/ images + ──► exporters/wikijs.js .md + steps-<title>/ 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(`<a id="${anchorFor(step)}"></a>`, ''); + 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(`![Step ${step.number}](${img.relPath} =${tpl.imageMaxWidth}x)`, ''); + } else { + lines.push(`![Step ${step.number}](${img.relPath})`, ''); + } + } + + 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 <slug>.md plus a steps-<slug>/ 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(`<a id="${anchorFor(step)}"></a>`, ''); - 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(`![Step ${step.number}](${img.relPath} =${tpl.imageMaxWidth}x)`, ''); - } else { - lines.push(`![Step ${step.number}](${img.relPath})`, ''); - } - } - - 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));