feat(export): Export note works in markdown mode (md, html, docx, pdf)
Some checks are pending
Build and push Joplock image / build-and-push (push) Waiting to run

Export was rendered-mode only: the toolbar button was hidden in markdown mode,
the menu hid HTML/DOCX/PDF, the mobile sheet hid Export, and each exporter
alerted "only available in rendered mode".

Markdown mode now posts the live markdown (format:'markdown'). The HTML and DOCX
endpoints render it with renderMarkdown() (PDF already did) before the shared
strip-links / inline-images pipeline, so images and attachments come out
identical to rendered-mode exports. DOCX without a format keeps the legacy
pandoc-native markdown input; rendered mode is unchanged.

Tests: tests/exportMarkdownMode.test.js (pandoc stubbed; server pipeline + client
routing) and a Playwright test that downloads .md/.html/.docx/.pdf from markdown
mode against the real container, plus a mobile-sheet check.
This commit is contained in:
igor 2026-10-09 09:17:09 +13:00
parent 653f480432
commit b6b0618697
6 changed files with 301 additions and 30 deletions

View file

@ -32,6 +32,7 @@ Use this guide when working in this repository.
- **Auth**: reuses Joplin Server `sessionId` cookie
- **DB access**: reads direct from shared Postgres; writes go through stock Joplin Server API
- **Exports**: `POST /api/export/{docx,pdf,html}` in `app/routes/api.js` — DOCX and PDF are rendered server-side with `pandoc` (PDF via `weasyprint` plus pandoc's print CSS), HTML is a single self-contained file with inlined theme CSS, base64 images and base64 attachment links. `public/html-docx.js` is the legacy client-side DOCX path.
- Exports work in BOTH editor modes. Rendered mode posts TinyMCE HTML (`format:'html'`); markdown mode posts the markdown (`format:'markdown'`, via `_exportSource()`) and all three endpoints render it with `renderMarkdown()` before the shared strip-links / inline-images pipeline, so images and attachments come out the same in either mode. DOCX with no `format` keeps the legacy pandoc-native markdown input. Pinned by `tests/exportMarkdownMode.test.js` (pandoc stubbed) and the real-container download test in `playwright-tests/md-extras.spec.js`.
- **Ops surface**: admin user management (`/admin/users`), `pg_dump` backup/restore (`/admin/backups`, `/admin/restore`), break-glass recovery mode (`/recovery`), and login rate limiting — all served by the same sidecar process.
- **Script load order** (`app/templates/pages.js`): `htmx` → `turndown` → `codemirror` → `cm-extras` → `prism` → `tinymce` → `hljs` → `html-docx` → inline config → `app.js` (`defer`). `app.js`, `codemirror.min.js` and `cm-extras.js` are cache-busted with `ASSET_VERSION`.

View file

@ -971,14 +971,18 @@ const handleExportDocx = async (url, request, response, ctx) => {
response.end(JSON.stringify({ error: 'content is required' }));
return true;
}
const inputFormat = format === 'html' ? 'html' : 'markdown';
// format 'markdown' (sent by markdown mode) is rendered with the same
// renderMarkdown() as rendered mode / the PDF export, so Joplin `:/id`
// images and attachment links resolve instead of reaching pandoc raw.
// Any other value keeps the legacy pandoc-native markdown input.
const renderedFromMarkdown = format === 'markdown';
const inputFormat = format === 'html' || renderedFromMarkdown ? 'html' : 'markdown';
// Pre-process HTML the same way as PDF: strip dead resource links and
// inline resource images as base64 data URIs so pandoc can embed them.
// Markdown input is left unchanged — pandoc handles it natively.
let processedContent = content;
let processedContent = renderedFromMarkdown ? renderMarkdown(content) : content;
if (inputFormat === 'html') {
processedContent = stripResourceLinks(content);
processedContent = stripResourceLinks(processedContent);
processedContent = await inlineResourceImages(processedContent, auth.user.id, ctx.itemService);
}
@ -1200,7 +1204,8 @@ const handleExportPdf = async (url, request, response, ctx) => {
// ---------------------------------------------------------------------------
// POST /api/export/html — single self-contained HTML file: inlined theme CSS,
// base64 images, base64 attachment links. No pandoc needed — the source is
// already TinyMCE-rendered HTML.
// TinyMCE-rendered HTML (rendered mode) or markdown rendered server-side with
// renderMarkdown() (markdown mode, format:'markdown').
// ---------------------------------------------------------------------------
const escapeHtmlAttr = value => `${value || ''}`
@ -1219,13 +1224,15 @@ const handleExportHtml = async (url, request, response, ctx) => {
return true;
}
const body = await parseBody(request);
const { content, title } = body;
if (!content) {
const { content: rawContent, title, format } = body;
if (!rawContent) {
response.writeHead(400, { 'Content-Type': 'application/json' });
response.end(JSON.stringify({ error: 'content is required' }));
return true;
}
const theme = /^[a-z0-9-]{1,40}$/.test(`${body.theme || ''}`) ? body.theme : 'earth';
// Markdown mode sends markdown; render it like rendered mode / PDF do.
const content = format === 'markdown' ? renderMarkdown(rawContent) : rawContent;
const imagedHtml = await inlineResourceImages(content, auth.user.id, ctx.itemService);
const inlinedHtml = await inlineResourceLinks(imagedHtml, auth.user.id, ctx.itemService);

View file

@ -222,6 +222,50 @@ test.describe('Markdown mode extras', () => {
expect(content).toBe('"\u2b07\ufe0f"');
});
test('Export note works in markdown mode: .md, .html, .docx and .pdf', async ({ page }) => {
test.setTimeout(150000);
await setDoc(page, '# Export me\n\nBody with **bold** text.\n\n- [x] done\n- [ ] todo\n\n');
await dropFile(page, { name: 'export-pic.png', mime: 'image/png', data: IMG_B64 });
await expect(page.locator('#editor-panel .cm-jl-img img')).toBeVisible({ timeout: 15000 });
await waitForSaved(page);
const readDownload = async (itemText) => {
await page.locator('#editor-panel #export-note-btn').click();
const menu = page.locator('#export-menu');
await expect(menu).toBeVisible();
// Every format is offered while in markdown mode.
await expect(menu.locator('button', { hasText: /Markdown|HTML|Word|PDF/ })).toHaveCount(4);
const [download] = await Promise.all([
page.waitForEvent('download', { timeout: 90000 }),
menu.locator('button', { hasText: itemText }).click(),
]);
const file = await download.path();
return { name: download.suggestedFilename(), data: fs.readFileSync(file) };
};
const md = await readDownload('Markdown');
expect(md.name).toMatch(/\.md$/);
expect(md.data.toString('utf8')).toContain('# Export me');
const html = await readDownload('HTML');
expect(html.name).toMatch(/\.html$/);
const htmlText = html.data.toString('utf8');
expect(htmlText).toContain('<h1>Export me</h1>');
expect(htmlText).toContain('data:image/png;base64,');
expect(htmlText).not.toMatch(/:\/[0-9a-f]{32}/);
const docx = await readDownload('Word');
expect(docx.name).toMatch(/\.docx$/);
expect(docx.data.subarray(0, 2).toString()).toBe('PK');
expect(docx.data.includes(Buffer.from('word/media/'))).toBe(true); // the image is embedded
const pdf = await readDownload('PDF');
expect(pdf.name).toMatch(/\.pdf$/);
expect(pdf.data.subarray(0, 5).toString()).toBe('%PDF-');
expect(pdf.data.length).toBeGreaterThan(2000);
await page.screenshot({ path: 'test-results/mdx-export.png' });
});
test('clicking a preview selects its source so Delete removes the image', async ({ page }) => {
await setDoc(page, 'Top\n\n');
await dropFile(page, { name: 'del.png', mime: 'image/png', data: IMG_B64 });
@ -471,6 +515,12 @@ test.describe('Markdown mode extras (mobile shell)', () => {
await expect(status).toBeVisible();
const sb = await status.boundingBox();
expect(sb.y + sb.height).toBeLessThanOrEqual(page.viewportSize().height + 1);
// Export is reachable from the mobile editor menu in markdown mode too.
await page.locator('#mobile-editor-menu-btn').click();
await expect(page.locator('#mobile-ctx-export')).toBeVisible();
await page.locator('#mobile-ctx-export').click();
await expect(page.locator('#export-menu')).toBeVisible();
await expect(page.locator('#export-menu #export-pdf-btn')).toBeVisible();
await page.screenshot({ path: 'test-results/mdx-mobile.png' });
} finally {
await teardownTestData(page, { titlePrefixes: ['pw-mdx-mobile'] });

View file

@ -481,7 +481,7 @@ function tinyMCESetContent(html){if(_tinymceEditor)_tinymceEditor.setContent(htm
function tinyMCESyncToTA(){var ta=getTA();if(ta&&_tinymceEditor){var _synNoteId=_formNoteId(activeEditorForm());if(_tinymceContentNoteId&&_synNoteId&&_tinymceContentNoteId!==_synNoteId){_log('tinyMCESyncToTA skipped: TinyMCE content belongs to another note',_tinymceContentNoteId,_synNoteId);return false}var host=document.getElementById('tinymce-host');if(!host||!host.classList.contains('tinymce-host-visible')){_log('tinyMCESyncToTA skipped: TinyMCE host not visible');return false}var html=_tinymceEditor.getContent();var md=tinymceToMarkdown(html,ta.value);if(ta.value!==md){ta.value=md;ta.dispatchEvent(new Event('input',{bubbles:true}));return true}}return false}
function _isMarkdownModeActive(){return _editorMode==='markdown'||_editorMode==='md'}
/* ---------------- Note export (rendered mode only): MD / HTML / DOCX / PDF ---------------- */
/* ---------------- Note export (both modes): MD / HTML / DOCX / PDF ---------------- */
function _downloadBlob(blob,filename){
var url=URL.createObjectURL(blob);
var a=document.createElement('a');
@ -511,11 +511,6 @@ function toggleExportMenu(anchorEl){
||document.getElementById('mobile-editor-menu-btn');
}
if(!menu||!btn)return;
var mdMode=_isMarkdownModeActive();
['#export-html-btn','#export-docx-btn','#export-pdf-btn'].forEach(function(sel){
var b=menu.querySelector(sel);
if(b)b.style.display=mdMode?'none':'';
});
if(!menu.hidden){menu.hidden=true;return}
menu.hidden=false;
var r=btn.getBoundingClientRect();
@ -541,15 +536,29 @@ document.addEventListener('click',function(e){
});
window.addEventListener('scroll',closeExportMenu,true);
window.addEventListener('resize',closeExportMenu);
// What to send to the HTML / DOCX / PDF exporters. Rendered mode sends the
// TinyMCE HTML. Markdown mode sends the markdown itself (format:'markdown'); the
// server renders it with renderMarkdown() — the same renderer rendered mode loads
// its body from — so images, attachments, tables, checkboxes and code blocks come
// out identical in both modes.
function _exportSource(){
if(_isMarkdownModeActive()){
cmSyncToTA();
var ta=getTA();
return {content:ta?ta.value:'',format:'markdown'};
}
return {content:tinyMCEContent(),format:'html'};
}
function exportNoteAsMarkdown(){
cmSyncToTA();
var ta=getTA();
var md=ta?ta.value:'';
_downloadBlob(new Blob([md],{type:'text/markdown'}),_exportFilenameBase()+'.md');
}
function exportNoteAsHtml(){
if(_isMarkdownModeActive()){alert('HTML export is only available in rendered mode. Switch to rendered mode to export.');return}
var html=tinyMCEContent();
if(!html){alert('Nothing to export.');return}
var src=_exportSource();
var html=src.content;
if(!html||!String(html).trim()){alert('Nothing to export.');return}
var title=document.querySelector('.editor-title')?.textContent||document.getElementById('note-title')?.value||'note';
var theme='earth';
var classes=(document.body.className||'').split(/\s+/);
@ -558,18 +567,17 @@ function exportNoteAsHtml(){
}
var btn=document.getElementById('export-note-btn');
if(btn){btn.disabled=true;btn.style.opacity='0.5'}
fetch('/api/export/html',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({content:html,title:title,theme:theme})})
fetch('/api/export/html',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({content:html,format:src.format,title:title,theme:theme})})
.then(function(r){if(!r.ok)throw new Error('Export failed: '+r.status);return r.blob()})
.then(function(blob){var url=URL.createObjectURL(blob);var a=document.createElement('a');a.href=url;a.download=(title.replace(/[^a-zA-Z0-9_-]/g,'_').slice(0,80)||'note')+'.html';document.body.appendChild(a);a.click();document.body.removeChild(a);URL.revokeObjectURL(url)})
.catch(function(err){console.error('HTML export failed:',err);alert('HTML export failed: '+err.message)})
.finally(function(){if(btn){btn.disabled=false;btn.style.opacity=''}});
}
function exportNoteAsDocx(){
if(_isMarkdownModeActive()){alert('DOCX export is only available in rendered mode. Switch to rendered mode to export.');return}
var html=tinyMCEContent();
if(!html){alert('Nothing to export.');return}
var format='html';
var content=html;
var src=_exportSource();
if(!src.content||!String(src.content).trim()){alert('Nothing to export.');return}
var format=src.format;
var content=src.content;
var title=document.querySelector('.editor-title')?.textContent||document.getElementById('note-title')?.value||'note';
var btn=document.getElementById('export-note-btn');
if(btn){btn.disabled=true;btn.style.opacity='0.5'}
@ -580,11 +588,10 @@ function exportNoteAsDocx(){
.finally(function(){if(btn){btn.disabled=false;btn.style.opacity=''}});
}
function exportNoteAsPdf(){
if(_isMarkdownModeActive()){alert('PDF export is only available in rendered mode. Switch to rendered mode to export.');return}
var html=tinyMCEContent();
if(!html){alert('Nothing to export.');return}
var format='html';
var content=html;
var src=_exportSource();
if(!src.content||!String(src.content).trim()){alert('Nothing to export.');return}
var format=src.format;
var content=src.content;
var title=document.querySelector('.editor-title')?.textContent||document.getElementById('note-title')?.value||'note';
var btn=document.getElementById('export-note-btn');
if(btn){btn.disabled=true;btn.style.opacity='0.5'}
@ -5195,7 +5202,7 @@ function confirmLogout(event){
var moveBtn=document.getElementById('mobile-ctx-move');
var delBtn=document.getElementById('mobile-ctx-delete');
if(titleEl)titleEl.textContent=noteTitle||'Untitled';
if(exportBtn)exportBtn.style.display=(opts.isEditorContext&&!_isMarkdownModeActive())?'':'none';
if(exportBtn)exportBtn.style.display=opts.isEditorContext?'':'none';
if(metaEl){
var mbody=document.getElementById('mobile-editor-body');
var metaSrc=mbody?mbody.querySelector('#note-meta'):null;

View file

@ -1502,8 +1502,7 @@ body.editor-markdown-mode .editor-toolbar {
margin: 0 4px;
}
/* Export menu (rendered mode only) */
.editor-markdown-mode #export-note-btn { display: none; }
/* Export menu */
.export-menu {
position: fixed;
z-index: 500;

View file

@ -0,0 +1,207 @@
'use strict';
// Export in MARKDOWN mode. Markdown mode sends the note's markdown with
// format:'markdown'; the server renders it with renderMarkdown() (the renderer
// rendered mode loads its body from) and runs the same image-inlining pipeline.
// pandoc / weasyprint are not required: child_process.spawn is stubbed and the
// stub records what the exporter would have piped into pandoc.
const test = require('node:test');
const assert = require('node:assert/strict');
const childProcess = require('node:child_process');
const { EventEmitter } = require('node:events');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const spawned = [];
childProcess.spawn = (cmd, args) => {
const proc = new EventEmitter();
let stdin = '';
proc.stdin = { write: d => { stdin += d; }, end: () => {} };
proc.stdout = new EventEmitter();
proc.stderr = new EventEmitter();
spawned.push({ cmd, args, stdin: () => stdin });
setImmediate(() => {
proc.stdout.emit('data', Buffer.from('FAKE-OUTPUT'));
proc.emit('close', 0);
});
return proc;
};
// Required AFTER the stub so api.js's `const { spawn } = require('child_process')` picks it up.
const { handleExportHtml, handleExportDocx, handleExportPdf } = require('../app/routes/api');
const RID = 'a1b2c3d4e5f6789012345678abcdef00';
const MD = `# Trip report\n\nSee the photo:\n\n![shot](:/${RID})\n\nAnd the [spec sheet](:/${RID}).\n\n- [x] done\n- [ ] todo\n\n| a | b |\n|---|---|\n| 1 | 2 |\n`;
const req = body => {
const r = new EventEmitter();
r.headers = { 'content-type': 'application/json' };
r.method = 'POST';
r.setEncoding = () => {};
process.nextTick(() => { r.emit('data', JSON.stringify(body)); r.emit('end'); });
return r;
};
const res = () => {
const chunks = [];
const out = { status: null, headers: null, writeHead(s, h) { out.status = s; out.headers = h; }, end(d) { if (d) chunks.push(d); out.done = true; }, body: () => chunks.map(c => Buffer.isBuffer(c) ? c.toString('latin1') : c).join('') };
return out;
};
const ctx = () => ({
authenticatedUser: async () => ({ user: { id: 'u1', sessionId: 's1' } }),
itemService: {
resourceBlobByUserId: async () => Buffer.from('PNGDATA'),
resourceMetaByUserId: async () => ({ mime: 'image/png', filename: 'shot.png' }),
},
});
const wait = async r => { for (let i = 0; i < 50 && !r.done; i++) await new Promise(x => setTimeout(x, 5)); };
test('HTML export of markdown: rendered, theme + inlined image, no raw :/id left', async () => {
const r = res();
await handleExportHtml(new URL('http://x/api/export/html'), req({ content: MD, format: 'markdown', title: 'Trip', theme: 'earth' }), r, ctx());
assert.equal(r.status, 200);
const body = r.body();
assert.ok(body.includes('<h1>Trip report</h1>'), 'markdown heading rendered to HTML');
assert.ok(body.includes('data:image/png;base64,'), 'image inlined');
assert.ok(!body.includes(`:/${RID}`), 'no raw Joplin resource reference leaks into the export');
assert.ok(!body.includes(`/resources/${RID}`), 'no authenticated resource URL leaks into the export');
assert.ok(/<table/.test(body) && /md-checkbox/.test(body), 'tables and task checkboxes rendered');
assert.ok(body.includes('class="theme-earth"'));
});
test('HTML export without format still treats content as HTML (rendered mode unchanged)', async () => {
const r = res();
await handleExportHtml(new URL('http://x/api/export/html'), req({ content: '<p>**not markdown**</p>', title: 'n' }), r, ctx());
assert.ok(r.body().includes('<p>**not markdown**</p>'));
});
test('DOCX export of markdown: rendered to HTML, images inlined, pandoc told html (not markdown)', async () => {
spawned.length = 0;
const r = res();
await handleExportDocx(new URL('http://x/api/export/docx'), req({ content: MD, format: 'markdown', title: 'Trip' }), r, ctx());
await wait(r);
assert.equal(r.status, 200);
assert.ok(r.headers['Content-Disposition'].includes('Trip.docx'));
assert.equal(spawned.length, 1);
const { args, stdin } = spawned[0];
assert.deepEqual(args.slice(0, 4), ['-f', 'html', '-t', 'docx']);
const html = stdin();
assert.ok(html.includes('<h1>Trip report</h1>'));
assert.ok(html.includes('data:image/png;base64,'), 'image embedded so pandoc can put it in the .docx');
assert.ok(!html.includes(`:/${RID}`) && !html.includes(`/resources/${RID}`));
assert.ok(!/<a\b[^>]*resources\//.test(html), 'dead attachment link anchors stripped');
});
test('DOCX export keeps html input and the legacy pandoc-markdown input for other callers', async () => {
spawned.length = 0;
let r = res();
await handleExportDocx(new URL('http://x/api/export/docx'), req({ content: '<p>hi</p>', format: 'html', title: 'n' }), r, ctx());
await wait(r);
assert.deepEqual(spawned[0].args.slice(0, 2), ['-f', 'html']);
assert.equal(spawned[0].stdin(), '<p>hi</p>');
spawned.length = 0;
r = res();
await handleExportDocx(new URL('http://x/api/export/docx'), req({ content: '# hi', title: 'n' }), r, ctx());
await wait(r);
assert.deepEqual(spawned[0].args.slice(0, 2), ['-f', 'markdown']);
assert.equal(spawned[0].stdin(), '# hi');
});
test('PDF export of markdown: rendered, images inlined, handed to pandoc as html', async () => {
spawned.length = 0;
const r = res();
await handleExportPdf(new URL('http://x/api/export/pdf'), req({ content: MD, format: 'markdown', title: 'Trip' }), r, ctx());
await wait(r);
assert.equal(r.status, 200);
assert.equal(spawned.length, 1);
assert.deepEqual(spawned[0].args.slice(0, 4), ['-f', 'html', '-t', 'pdf']);
const html = spawned[0].stdin();
assert.ok(html.includes('<h1>Trip report</h1>') && html.includes('data:image/png;base64,'));
assert.ok(!html.includes(`:/${RID}`) && !html.includes(`/resources/${RID}`));
});
// ---------------------------------------------------------------------------
// Client: markdown mode must reach the exporters (it used to alert "only
// available in rendered mode"), sending markdown from the live CM document.
// ---------------------------------------------------------------------------
const appSrc = fs.readFileSync(path.join(__dirname, '../public/app.js'), 'utf8');
function extractFn(name) {
const start = appSrc.indexOf(`function ${name}(`);
assert.ok(start !== -1, `${name} not found`);
let depth = 0;
for (let i = start; i < appSrc.length; i++) {
if (appSrc[i] === '{') depth++;
else if (appSrc[i] === '}') { depth--; if (depth === 0) return appSrc.slice(start, i + 1); }
}
throw new Error('unbalanced');
}
function clientCtx({ mode, cmDoc, tinyHtml }) {
const alerts = [];
const posts = [];
const ta = { value: '' };
const ctx = vm.createContext({
alert: m => alerts.push(m),
Blob: class { constructor(parts, o) { this.parts = parts; this.type = o && o.type; } },
URL: { createObjectURL: () => 'blob:x', revokeObjectURL: () => {} },
setTimeout: fn => fn(),
console,
Promise,
_editorMode: mode,
document: {
body: { className: 'theme-nord x', appendChild() {}, removeChild() {} },
querySelector: sel => (sel === '.editor-title' ? { textContent: 'My Note' } : null),
getElementById: () => null,
createElement: () => ({ click() {}, remove() {} }),
},
fetch: (url, opts) => { posts.push({ url, body: JSON.parse(opts.body) }); return Promise.resolve({ ok: true, blob: () => Promise.resolve({}) }); },
cmSyncToTA: () => { ta.value = cmDoc; },
getTA: () => ta,
tinyMCEContent: () => tinyHtml || '',
});
vm.runInContext('function queryActiveEditor(){return null}', ctx);
for (const fn of ['_isMarkdownModeActive', '_exportSource', 'exportNoteAsHtml', 'exportNoteAsDocx', 'exportNoteAsPdf']) vm.runInContext(extractFn(fn), ctx);
return { ctx, alerts, posts };
}
const flush = () => new Promise(r => setImmediate(r));
test('client: every export format works in markdown mode and sends the markdown', async () => {
const { ctx, alerts, posts } = clientCtx({ mode: 'markdown', cmDoc: MD });
vm.runInContext('exportNoteAsHtml();exportNoteAsDocx();exportNoteAsPdf();', ctx);
await flush();
assert.deepEqual(alerts, [], 'no "only available in rendered mode" alerts');
assert.deepEqual(posts.map(p => p.url), ['/api/export/html', '/api/export/docx', '/api/export/pdf']);
for (const p of posts) {
assert.equal(p.body.format, 'markdown');
assert.equal(p.body.content, MD);
assert.equal(p.body.title, 'My Note');
}
assert.equal(posts[0].body.theme, 'nord');
});
test('client: rendered mode still sends TinyMCE html', async () => {
const { ctx, alerts, posts } = clientCtx({ mode: 'rich', cmDoc: 'IGNORED', tinyHtml: '<p>rendered</p>' });
vm.runInContext('exportNoteAsHtml();exportNoteAsDocx();exportNoteAsPdf();', ctx);
await flush();
assert.deepEqual(alerts, []);
for (const p of posts) { assert.equal(p.body.format, 'html'); assert.equal(p.body.content, '<p>rendered</p>'); }
});
test('client: an empty note says "Nothing to export" instead of posting', async () => {
const { ctx, alerts, posts } = clientCtx({ mode: 'markdown', cmDoc: ' \n' });
vm.runInContext('exportNoteAsHtml();exportNoteAsDocx();exportNoteAsPdf();', ctx);
await flush();
assert.equal(posts.length, 0);
assert.deepEqual(alerts, ['Nothing to export.', 'Nothing to export.', 'Nothing to export.']);
});
test('export entry points are visible in markdown mode (toolbar button, menu items, mobile sheet)', () => {
const css = fs.readFileSync(path.join(__dirname, '../public/styles.css'), 'utf8');
assert.ok(!/\.editor-markdown-mode\s+#export-note-btn\s*\{\s*display:\s*none/.test(css), 'toolbar export button must not be hidden in markdown mode');
const toggle = extractFn('toggleExportMenu');
assert.ok(!toggle.includes('_isMarkdownModeActive'), 'the export menu must offer HTML/DOCX/PDF in markdown mode too');
assert.ok(!/mobile-ctx-export'\);[\s\S]{0,400}_isMarkdownModeActive/.test(appSrc), 'mobile export entry must not be gated on rendered mode');
});