- Native PDF exporter (cover, TOC, bookmarks, images, code, tables, text blocks); validated under Ghostscript - Animated GIF exporter (title card, title overlay, progress bar) - Image bundle exporter with watermark compositing - DOCX and PPTX emitters (hand-built OOXML over our zip writer) with structural + relationship + XML well-formedness validation in tests - Per-format template manager with .sfglt share archives - Unified export dispatcher covering all nine formats - 10 more workflow tests (52 total) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { zipSync, unzipSync } = require('./zip');
|
||||
const { writeJsonSync, readJsonSync, atomicWriteFileSync, nowIso } = require('./util');
|
||||
|
||||
/**
|
||||
* Per-format export templates stored under settings/templates/<format>/.
|
||||
* Templates are plain JSON option objects merged over each exporter's
|
||||
* defaults, shareable as .sfglt zip files.
|
||||
*/
|
||||
|
||||
const FORMATS = ['json', 'markdown', 'html-simple', 'html-rich', 'pdf', 'gif', 'image-bundle', 'docx', 'pptx'];
|
||||
|
||||
class TemplateManager {
|
||||
constructor(templatesDir) {
|
||||
this.dir = templatesDir;
|
||||
}
|
||||
|
||||
formatDir(format) {
|
||||
if (!FORMATS.includes(format)) throw new Error(`unknown export format: ${format}`);
|
||||
return path.join(this.dir, format);
|
||||
}
|
||||
|
||||
fileFor(format, name) {
|
||||
if (!/^[A-Za-z0-9 _-]+$/.test(name)) throw new Error(`bad template name: ${name}`);
|
||||
return path.join(this.formatDir(format), `${name}.template.json`);
|
||||
}
|
||||
|
||||
list(format) {
|
||||
const dir = this.formatDir(format);
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
return fs.readdirSync(dir)
|
||||
.filter((f) => f.endsWith('.template.json'))
|
||||
.map((f) => f.slice(0, -'.template.json'.length))
|
||||
.sort();
|
||||
}
|
||||
|
||||
load(format, name) {
|
||||
const file = this.fileFor(format, name);
|
||||
if (!fs.existsSync(file)) return null;
|
||||
return readJsonSync(file).options || {};
|
||||
}
|
||||
|
||||
save(format, name, options) {
|
||||
writeJsonSync(this.fileFor(format, name), {
|
||||
schemaVersion: 1, format, name, updatedAt: nowIso(), options,
|
||||
});
|
||||
return name;
|
||||
}
|
||||
|
||||
rename(format, oldName, newName) {
|
||||
const src = this.fileFor(format, oldName);
|
||||
if (!fs.existsSync(src)) throw new Error(`template not found: ${oldName}`);
|
||||
const options = readJsonSync(src).options;
|
||||
this.save(format, newName, options);
|
||||
fs.rmSync(src);
|
||||
}
|
||||
|
||||
duplicate(format, name, copyName) {
|
||||
const options = this.load(format, name);
|
||||
if (options === null) throw new Error(`template not found: ${name}`);
|
||||
return this.save(format, copyName || `${name} copy`, options);
|
||||
}
|
||||
|
||||
remove(format, name) {
|
||||
fs.rmSync(this.fileFor(format, name), { force: true });
|
||||
}
|
||||
|
||||
/** Export one template as a shareable .sfglt file. */
|
||||
exportTemplate(format, name, destFile) {
|
||||
const options = this.load(format, name);
|
||||
if (options === null) throw new Error(`template not found: ${name}`);
|
||||
const manifest = { format: 'stepforge-template-archive', formatVersion: 1, exportFormat: format, name, exportedAt: nowIso() };
|
||||
atomicWriteFileSync(destFile, zipSync([
|
||||
{ name: 'manifest.json', data: JSON.stringify(manifest, null, 2) },
|
||||
{ name: 'template.json', data: JSON.stringify({ options }, null, 2) },
|
||||
]));
|
||||
return destFile;
|
||||
}
|
||||
|
||||
/** Import a .sfglt file; returns { format, name }. */
|
||||
importTemplate(file) {
|
||||
const entries = new Map(unzipSync(fs.readFileSync(file)).map((e) => [e.name, e.data]));
|
||||
if (!entries.has('manifest.json') || !entries.has('template.json')) {
|
||||
throw new Error('not a StepForge template archive');
|
||||
}
|
||||
const manifest = JSON.parse(entries.get('manifest.json').toString('utf8'));
|
||||
if (manifest.format !== 'stepforge-template-archive') throw new Error('unsupported template archive');
|
||||
const { options } = JSON.parse(entries.get('template.json').toString('utf8'));
|
||||
let name = manifest.name || 'imported';
|
||||
if (this.list(manifest.exportFormat).includes(name)) name = `${name} (imported)`;
|
||||
this.save(manifest.exportFormat, name, options || {});
|
||||
return { format: manifest.exportFormat, name };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { TemplateManager, FORMATS };
|
||||
+2
-1
@@ -98,7 +98,8 @@ function slugify(text, fallback = 'untitled') {
|
||||
const slug = String(text || '')
|
||||
.normalize('NFKD')
|
||||
.replace(/[̀-ͯ]/g, '')
|
||||
.replace(/[^a-zA-Z0-9._ -]+/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._ -]+/g, '')
|
||||
.trim()
|
||||
.replace(/\s+/g, '-')
|
||||
.slice(0, 80);
|
||||
|
||||
Reference in New Issue
Block a user