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; } 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) { function isHiddenName(name) {
return name.length === 0 || name[0] === '.' || name[0] === '_'; return name.length === 0 || name[0] === '.' || name[0] === '_';
} }
@ -202,8 +228,8 @@
events().statusError('No path for ' + node.name); events().statusError('No path for ' + node.name);
return; return;
} }
var url = path.replace(/\.[^./]+$/, '') + '.' + fmt; var url = convertUrl(path, fmt);
var name = node.name.replace(/\.[^./]+$/, '') + '.' + fmt; var name = convertUrl(node.name, fmt);
events().statusInfo('Exporting ' + name + '…'); events().statusInfo('Exporting ' + name + '…');
downloadUrl(name, url); downloadUrl(name, url);
setTimeout(function () { events().statusClear(); }, 2500); setTimeout(function () { events().statusClear(); }, 2500);
@ -212,6 +238,8 @@
window.app.modules.download = { window.app.modules.download = {
downloadFile: downloadFile, downloadFile: downloadFile,
downloadFolder: downloadFolder, 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 appliesToFolderLike(node) { return !!(node && (node.isDir || node.isZip)); }
function appliesToFile(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): // The Export submenu's convertible-format set comes from the download
// a file of one of these extensions can be exported as the other two. // module's canonical matrix (download.exportTargets), which mirrors the
var EXPORT_FORMATS = ['md', 'docx', 'html']; // 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 cap() { return window.zddc && window.zddc.cap; }
function canVerb(node, verb) { function canVerb(node, verb) {
@ -181,9 +188,10 @@
} }
}, },
{ {
// Export submenu: a folder offers ".zip" (both modes); a md/docx/html // Export submenu: a folder offers ".zip" (both modes); a convertible
// file offers the OTHER two formats (server-side conversion, so // file (md/docx/html) offers its server-side conversion targets —
// server mode only). A zip is already an archive — no Export. // 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'], id: 'export', group: 'io', surfaces: ['row'],
label: 'Export', label: 'Export',
appliesTo: function (ctx) { appliesTo: function (ctx) {
@ -191,7 +199,7 @@
if (!n || n.virtual) return false; if (!n || n.virtual) return false;
if (n.isDir) return true; if (n.isDir) return true;
if (n.isZip) return false; if (n.isZip) return false;
return isServer() && EXPORT_FORMATS.indexOf((n.ext || '').toLowerCase()) !== -1; return isServer() && exportTargets(n.ext).length > 0;
}, },
items: function (ctx) { items: function (ctx) {
var n = ctx.node; var n = ctx.node;
@ -200,8 +208,8 @@
if (n.isDir) { if (n.isDir) {
return [{ label: '.zip', action: function () { d.downloadFolder(n); } }]; return [{ label: '.zip', action: function () { d.downloadFolder(n); } }];
} }
var cur = (n.ext || '').toLowerCase(); // exportTargets already excludes the source format.
return EXPORT_FORMATS.filter(function (f) { return f !== cur; }).map(function (fmt) { return exportTargets(n.ext).map(function (fmt) {
return { label: '.' + fmt, action: function () { d.exportFile(n, fmt); } }; return { label: '.' + fmt, action: function () { d.exportFile(n, fmt); } };
}); });
} }

View file

@ -465,11 +465,18 @@
// and routes through ServeConverted. Cleaner than the // and routes through ServeConverted. Cleaner than the
// old `?convert=` query form — right-clicking the link // old `?convert=` query form — right-clicking the link
// gives a sensible "Save as <file>.docx" prompt. // 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'); var a = document.createElement('a');
a.className = 'btn btn-sm btn-secondary md-shell__download'; 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 // target=_blank: clicks open in a new tab. The server
// sends Content-Disposition: inline, so the new tab // sends Content-Disposition: inline, so the new tab
// either renders (HTML → web page; PDF → browser's // either renders (HTML → web page; PDF → browser's