feat(browse): unify export formats via one matrix; add PDF to Export menu

The Export context-menu offered only md↔docx↔html (a symmetric set), so PDF
— which the server supports only as md→pdf — was missing. The markdown
editor's DOCX/HTML/PDF buttons hardcoded their own list, so the two could
drift.

Introduce a single source of truth in download.js: EXPORT_MATRIX mirrors
zddc/internal/convert.Convert() exactly (md→docx|html|pdf, docx→md|html,
html→md|docx), exposed as download.exportTargets(ext) + download.convertUrl().
The Export submenu and the editor's buttons both consume it, so a .md file now
offers PDF in the menu and the two surfaces can never disagree. PDF stays
markdown-only (no docx→pdf / html→pdf path exists server-side).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-06-05 08:12:01 -05:00
parent 509839dba9
commit 3823946d4f
3 changed files with 58 additions and 15 deletions

View file

@ -24,6 +24,32 @@
function events() { return window.app.modules.events; }
// Canonical document-conversion matrix — mirrors zddc/internal/convert
// Convert(): which target formats a given source extension can be exported
// to. PDF is markdown-only (md→pdf) because the server has no docx→pdf /
// html→pdf path. This is the SINGLE source of truth for both the Export
// context-menu (download.exportTargets) and the markdown editor's
// DOCX/HTML/PDF buttons (preview-markdown.js), so the two never drift.
var EXPORT_MATRIX = {
md: ['docx', 'html', 'pdf'],
docx: ['md', 'html'],
html: ['md', 'docx']
};
// exportTargets returns the formats a file of extension `ext` can be
// exported to (excludes the source format itself), or [] if `ext` is not a
// convertible source. Case-insensitive.
function exportTargets(ext) {
return EXPORT_MATRIX[String(ext || '').toLowerCase()] || [];
}
// convertUrl maps a source path/URL to its sibling virtual-conversion URL
// (foo.md → foo.pdf). zddc-server recognises the sibling-extension pattern
// and converts on the fly. Shared by exportFile and the editor buttons.
function convertUrl(path, fmt) {
return String(path || '').replace(/\.[^./]+$/, '') + '.' + fmt;
}
function isHiddenName(name) {
return name.length === 0 || name[0] === '.' || name[0] === '_';
}
@ -202,8 +228,8 @@
events().statusError('No path for ' + node.name);
return;
}
var url = path.replace(/\.[^./]+$/, '') + '.' + fmt;
var name = node.name.replace(/\.[^./]+$/, '') + '.' + fmt;
var url = convertUrl(path, fmt);
var name = convertUrl(node.name, fmt);
events().statusInfo('Exporting ' + name + '…');
downloadUrl(name, url);
setTimeout(function () { events().statusClear(); }, 2500);
@ -212,6 +238,8 @@
window.app.modules.download = {
downloadFile: downloadFile,
downloadFolder: downloadFolder,
exportFile: exportFile
exportFile: exportFile,
exportTargets: exportTargets,
convertUrl: convertUrl
};
})();

View file

@ -47,9 +47,16 @@
function appliesToFolderLike(node) { return !!(node && (node.isDir || node.isZip)); }
function appliesToFile(node) { return !!(node && !node.isDir && !node.isZip); }
// Formats the Export submenu offers for a file (server-side conversion):
// a file of one of these extensions can be exported as the other two.
var EXPORT_FORMATS = ['md', 'docx', 'html'];
// The Export submenu's convertible-format set comes from the download
// module's canonical matrix (download.exportTargets), which mirrors the
// server's conversion matrix — the single source of truth shared with the
// markdown editor's DOCX/HTML/PDF buttons. exportTargets(ext) returns the
// target formats for a source extension (e.g. md → docx, html, pdf), or []
// when the extension isn't a convertible source.
function exportTargets(ext) {
var d = window.app.modules.download;
return (d && d.exportTargets) ? d.exportTargets(ext) : [];
}
function cap() { return window.zddc && window.zddc.cap; }
function canVerb(node, verb) {
@ -181,9 +188,10 @@
}
},
{
// Export submenu: a folder offers ".zip" (both modes); a md/docx/html
// file offers the OTHER two formats (server-side conversion, so
// server mode only). A zip is already an archive — no Export.
// Export submenu: a folder offers ".zip" (both modes); a convertible
// file (md/docx/html) offers its server-side conversion targets —
// md → docx/html/pdf, docx → md/html, html → md/docx (server mode
// only). A zip is already an archive — no Export.
id: 'export', group: 'io', surfaces: ['row'],
label: 'Export',
appliesTo: function (ctx) {
@ -191,7 +199,7 @@
if (!n || n.virtual) return false;
if (n.isDir) return true;
if (n.isZip) return false;
return isServer() && EXPORT_FORMATS.indexOf((n.ext || '').toLowerCase()) !== -1;
return isServer() && exportTargets(n.ext).length > 0;
},
items: function (ctx) {
var n = ctx.node;
@ -200,8 +208,8 @@
if (n.isDir) {
return [{ label: '.zip', action: function () { d.downloadFolder(n); } }];
}
var cur = (n.ext || '').toLowerCase();
return EXPORT_FORMATS.filter(function (f) { return f !== cur; }).map(function (fmt) {
// exportTargets already excludes the source format.
return exportTargets(n.ext).map(function (fmt) {
return { label: '.' + fmt, action: function () { d.exportFile(n, fmt); } };
});
}

View file

@ -465,11 +465,18 @@
// and routes through ServeConverted. Cleaner than the
// old `?convert=` query form — right-clicking the link
// gives a sensible "Save as <file>.docx" prompt.
var mdUrlBase = node.url.replace(/\.md$/i, '');
['docx', 'html', 'pdf'].forEach(function (fmt) {
//
// Format set + URL come from the download module's canonical
// conversion matrix (download.exportTargets / convertUrl) — the
// SAME source of truth the Export context-menu uses, so the
// editor's buttons and the menu never offer different formats.
var dl = window.app.modules.download;
var mdTargets = (dl && dl.exportTargets) ? dl.exportTargets('md') : ['docx', 'html', 'pdf'];
mdTargets.forEach(function (fmt) {
var a = document.createElement('a');
a.className = 'btn btn-sm btn-secondary md-shell__download';
a.href = mdUrlBase + '.' + fmt;
a.href = (dl && dl.convertUrl) ? dl.convertUrl(node.url, fmt)
: node.url.replace(/\.md$/i, '') + '.' + fmt;
// target=_blank: clicks open in a new tab. The server
// sends Content-Disposition: inline, so the new tab
// either renders (HTML → web page; PDF → browser's