(frontend) add markdown download option

Export Markdown as a ZIP containing the document and same-origin media.
Normalize filenames and restore the export UI after asynchronous failures.
Cover media packaging, modal behavior, filenames, and the browser flow.

Signed-off-by: Gokul Krishna <gokul.06krishna@gmail.com>
This commit is contained in:
Gokul Krishna
2026-09-22 09:33:53 +02:00
committed by Anthony LC
parent 74d822bb27
commit 3fa9186289
8 changed files with 575 additions and 53 deletions
+1
View File
@@ -31,6 +31,7 @@ and this project adheres to
- 🔧(backend) fine tune redis cache options #2658
- ✨(frontend) make the full last-update date available #1215
- 💄(frontend) redesign email confirmation standalone page #2601
- ✨(frontend) add markdown download option #2608
### Changed
@@ -30,7 +30,7 @@ test.describe('Doc Export', () => {
await expect(page.getByTestId('modal-export-title')).toBeVisible();
await expect(
page.getByText(
'Export your document to download in .pdf, .docx, .odt or .html(zip) format.',
'Export your document to download in .pdf, .docx, .odt, .md(zip) or .html(zip) format.',
),
).toBeVisible();
await expect(page.getByRole('combobox', { name: 'Format' })).toBeVisible();
@@ -94,6 +94,98 @@ test.describe('Doc Export', () => {
expect(download.suggestedFilename()).toBe(`${randomDoc}.odt`);
});
test('it exports a markdown-only document as md', async ({
page,
browserName,
}) => {
const [randomDoc] = await createDoc(
page,
'doc-editor-markdown-only',
browserName,
1,
);
await verifyDocName(page, randomDoc);
await writeInEditor({ page, text: 'Hello Markdown export' });
await clickInEditorMenu(page, 'Download');
await page.getByRole('combobox', { name: 'Format' }).click();
await page.getByRole('option', { name: 'Markdown' }).click();
const downloadPromise = page.waitForEvent('download');
await page.getByTestId('doc-export-download-button').click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${randomDoc}.md`);
const markdownBuffer = await cs.toBuffer(await download.createReadStream());
expect(markdownBuffer.toString('utf8')).toContain('Hello Markdown export');
});
test('it exports markdown with media as a zip', async ({
page,
browserName,
}) => {
const [randomDoc] = await createDoc(
page,
'doc-editor-markdown',
browserName,
1,
);
await verifyDocName(page, randomDoc);
await writeInEditor({ page, text: 'Hello Markdown export' });
await openSuggestionMenu({
page,
suggestion: 'Resizable image with caption',
});
const fileChooserPromise = page.waitForEvent('filechooser');
await page.getByText('Upload image').click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(path.join(__dirname, 'assets/test.svg'));
const image = page
.locator('.--docs--editor-container img.bn-visual-media')
.first();
await expect(image).toBeAttached({ timeout: 10000 });
await expect(image).toHaveAttribute('src', /.*\.svg/);
await expect(image).not.toHaveAttribute('src', /media-check/, {
timeout: 10000,
});
await clickInEditorMenu(page, 'Download');
await page.getByRole('combobox', { name: 'Format' }).click();
await page.getByRole('option', { name: 'Markdown' }).click();
const downloadPromise = page.waitForEvent('download');
await page.getByTestId('doc-export-download-button').click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe(`${randomDoc}.zip`);
const zipBuffer = await cs.toBuffer(await download.createReadStream());
const zip = await JSZip.loadAsync(zipBuffer);
const markdownFile = zip.file(`${randomDoc}.md`);
expect(markdownFile).not.toBeNull();
const markdown = await markdownFile!.async('string');
expect(markdown).toContain('Hello Markdown export');
const mediaFiles = Object.keys(zip.files).filter(
(filename) => filename !== `${randomDoc}.md`,
);
expect(mediaFiles).toHaveLength(1);
expect(mediaFiles[0]).toMatch(/\.svg$/);
expect(markdown).toContain(mediaFiles[0]);
expect(markdown).not.toContain('/media/');
});
test('it exports the doc to html zip', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(
page,
@@ -0,0 +1,155 @@
import { VariantType } from '@gouvfr-lasuite/ui-components';
import { act, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { type Doc } from '@/docs/doc-management/types';
import { AppWrapper } from '@/tests/utils';
import { ModalExport } from '../components/ModalExport';
const mocks = vi.hoisted(() => ({
addMediaFilesToMarkdownZip: vi.fn(),
blocksToMarkdownLossy: vi.fn(),
downloadFile: vi.fn(),
docToBlob: vi.fn(),
toast: vi.fn(),
}));
vi.mock('@gouvfr-lasuite/ui-components', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@gouvfr-lasuite/ui-components')>();
return {
...actual,
useToastProvider: () => ({ toast: mocks.toast }),
};
});
vi.mock('@/core', () => ({
useMediaUrl: () => 'https://media.test',
}));
vi.mock('@/docs/doc-editor/stores/useEditorStore', () => ({
useEditorStore: () => ({
editor: {
document: [],
blocksToMarkdownLossy: mocks.blocksToMarkdownLossy,
},
}),
}));
vi.mock('@/docs/doc-management', () => ({
useTrans: () => ({ untitledDocument: 'Untitled document' }),
}));
vi.mock('../hooks/', () => ({
default: {
useExportAGPL: () => ({
formats: [{ label: 'PDF', value: 'pdf', labelDescription: '.pdf' }],
docToBlob: mocks.docToBlob,
}),
},
}));
vi.mock('../utils', async (importOriginal) => {
const actual = await importOriginal<typeof import('../utils')>();
return {
...actual,
downloadFile: mocks.downloadFile,
};
});
vi.mock('../utils_markdown', () => ({
addMediaFilesToMarkdownZip: mocks.addMediaFilesToMarkdownZip,
}));
describe('ModalExport', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.addMediaFilesToMarkdownZip.mockResolvedValue(0);
mocks.blocksToMarkdownLossy.mockResolvedValue('# Roadmap');
mocks.docToBlob.mockResolvedValue(undefined);
});
test('downloads Markdown directly when no media is archived', async () => {
const onClose = vi.fn();
const user = userEvent.setup();
render(
<ModalExport doc={{ title: 'Roadmap' } as Doc} onClose={onClose} />,
{ wrapper: AppWrapper },
);
await user.click(screen.getByRole('combobox', { name: 'Format' }));
await user.click(screen.getByRole('option', { name: /Markdown/ }));
await user.click(screen.getByTestId('doc-export-download-button'));
await waitFor(() =>
expect(mocks.downloadFile).toHaveBeenCalledWith(
expect.objectContaining({ type: 'text/markdown;charset=utf-8' }),
'roadmap.md',
),
);
expect(onClose).toHaveBeenCalledOnce();
});
test('downloads a ZIP when Markdown contains archived media', async () => {
mocks.addMediaFilesToMarkdownZip.mockResolvedValue(1);
const onClose = vi.fn();
const user = userEvent.setup();
render(
<ModalExport doc={{ title: 'Roadmap' } as Doc} onClose={onClose} />,
{ wrapper: AppWrapper },
);
await user.click(screen.getByRole('combobox', { name: 'Format' }));
await user.click(screen.getByRole('option', { name: /Markdown/ }));
await user.click(screen.getByTestId('doc-export-download-button'));
await waitFor(() =>
expect(mocks.downloadFile).toHaveBeenCalledWith(
expect.any(Blob),
'roadmap.zip',
),
);
expect(onClose).toHaveBeenCalledOnce();
});
test('clears the loading state when Markdown media export fails', async () => {
let rejectMediaExport: (reason: Error) => void = () => undefined;
mocks.addMediaFilesToMarkdownZip.mockImplementation(
() =>
new Promise<void>((_resolve, reject) => {
rejectMediaExport = reject;
}),
);
const onClose = vi.fn();
const user = userEvent.setup();
render(
<ModalExport doc={{ title: 'Roadmap' } as Doc} onClose={onClose} />,
{ wrapper: AppWrapper },
);
await user.click(screen.getByRole('combobox', { name: 'Format' }));
await user.click(screen.getByRole('option', { name: /Markdown/ }));
const downloadButton = screen.getByTestId('doc-export-download-button');
await user.click(downloadButton);
await waitFor(() => expect(downloadButton).toBeDisabled());
await act(async () => {
rejectMediaExport(new Error('Media export failed'));
});
await waitFor(() => expect(downloadButton).toBeEnabled());
expect(mocks.toast).toHaveBeenCalledWith(
'The export failed',
VariantType.ERROR,
);
expect(onClose).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,38 @@
import { describe, expect, test } from 'vitest';
import { getExportFilename } from '../utils';
describe('getExportFilename', () => {
test('normalizes document titles for export', () => {
expect(getExportFilename('R\u00e9sum\u00e9 Roadmap')).toBe(
'resume-roadmap',
);
});
test('replaces archive path separators', () => {
expect(getExportFilename('Roadmap/2026\\Final')).toBe('roadmap-2026-final');
});
test('replaces characters rejected by Windows filesystems', () => {
expect(getExportFilename('Q1: Sales * Draft? <Final> | "Notes"')).toBe(
'q1--sales---draft---final-----notes-',
);
});
test.each(['CON', 'nul.txt', 'COM1', 'lpt9.log'])(
'protects the reserved Windows device name %s',
(title) => {
expect(getExportFilename(title)).toBe(`_${title.toLowerCase()}`);
},
);
test('trims trailing spaces and dots', () => {
expect(getExportFilename('Quarterly report... ')).toBe(
'quarterly-report',
);
});
test('uses a fallback when sanitization removes the title', () => {
expect(getExportFilename('...')).toBe('document');
});
});
@@ -0,0 +1,73 @@
import JSZip from 'jszip';
import { describe, expect, test, vi } from 'vitest';
import { addMediaFilesToMarkdownZip } from '../utils_markdown';
describe('addMediaFilesToMarkdownZip', () => {
test('localizes same-origin media without mutating unrelated URLs', async () => {
const blocks = [
{
type: 'image',
props: { url: 'https://media.test/media/photo.png' },
},
{
type: 'image',
props: { url: 'https://external.test/photo.png' },
},
{
type: 'image',
props: { url: 'data:image/png;base64,aGVsbG8=' },
},
];
const zip = new JSZip();
const imageBlob = new Blob(['image'], { type: 'image/png' });
const resolveMedia = vi.fn().mockResolvedValue(imageBlob);
const mediaFileCount = await addMediaFilesToMarkdownZip(
blocks,
zip,
'https://media.test',
resolveMedia,
);
expect(mediaFileCount).toBe(1);
expect(resolveMedia).toHaveBeenCalledOnce();
expect(resolveMedia).toHaveBeenCalledWith(
'https://media.test/media/photo.png',
);
expect(blocks[0].props.url).toBe('1-photo.png');
expect(blocks[1].props.url).toBe('https://external.test/photo.png');
expect(blocks[2].props.url).toBe('data:image/png;base64,aGVsbG8=');
expect(zip.file('1-photo.png')).not.toBeNull();
});
test('finds nested media and keeps its URL when fetching fails', async () => {
const blocks = [
{
type: 'columnList',
children: [
{
type: 'image',
props: { url: '/media/nested.svg' },
},
],
},
];
const zip = new JSZip();
const resolveMedia = vi.fn().mockResolvedValue('/media/nested.svg');
const mediaFileCount = await addMediaFilesToMarkdownZip(
blocks,
zip,
'https://media.test',
resolveMedia,
);
expect(mediaFileCount).toBe(0);
expect(resolveMedia).toHaveBeenCalledWith(
'https://media.test/media/nested.svg',
);
expect(blocks[0].children[0].props.url).toBe('/media/nested.svg');
expect(Object.keys(zip.files)).toHaveLength(0);
});
});
@@ -20,12 +20,13 @@ import { useToast } from '@/hooks';
import { fallbackLng } from '@/i18n/config';
import ModulesExport from '../hooks/';
import { downloadFile } from '../utils';
import { downloadFile, getExportFilename } from '../utils';
import {
addMediaFilesToZip,
generateHtmlDocument,
improveHtmlAccessibility,
} from '../utils_html';
import { addMediaFilesToMarkdownZip } from '../utils_markdown';
const useExportAGPL = ModulesExport?.useExportAGPL;
@@ -59,6 +60,11 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
const formatSelect = useMemo(() => {
const formatOptions = (exportAGPL?.formats || []).concat([
{
label: t('Markdown'),
value: 'markdown',
labelDescription: t('.md(zip)'),
},
{
label: t('HTML'),
value: 'html',
@@ -83,6 +89,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
return { formatOptions, formatLabels, allFormatsLabel };
}, [t, exportAGPL?.formats]);
/** Exports the selected format and always releases the loading state. */
async function onSubmit() {
if (!editor) {
toast(t('The export failed'), VariantType.ERROR);
@@ -90,72 +97,101 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
}
setIsExporting(true);
let shouldClose = false;
const filename = (doc.title || untitledDocument)
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/\s/g, '-');
try {
const documentTitle = doc.title || untitledDocument;
const filename = getExportFilename(documentTitle);
let downloadExtension = format === 'markdown' ? 'md' : format;
const documentTitle = doc.title || untitledDocument;
let blobExport = await exportAGPL?.docToBlob(format, documentTitle);
let blobExport = await exportAGPL?.docToBlob(format, documentTitle);
if (!blobExport && format === 'markdown') {
const zip = new JSZip();
const blocks = structuredClone(editor.document);
if (!blobExport && format === 'html') {
// Use BlockNote "full HTML" export so that we stay closer to the editor rendering.
const fullHtml = await editor.blocksToFullHTML();
const mediaFileCount = await addMediaFilesToMarkdownZip(
blocks,
zip,
mediaUrl,
);
// Parse HTML and fetch media so that we can package a fully offline HTML document in a ZIP.
const domParser = new DOMParser();
const parsedDocument = domParser.parseFromString(fullHtml, 'text/html');
const markdown = await editor.blocksToMarkdownLossy(blocks);
const zip = new JSZip();
if (mediaFileCount === 0) {
blobExport = new Blob([markdown], {
type: 'text/markdown;charset=utf-8',
});
} else {
zip.file(`${filename}.md`, markdown);
blobExport = await zip.generateAsync({ type: 'blob' });
downloadExtension = 'zip';
}
}
improveHtmlAccessibility(parsedDocument, documentTitle);
await addMediaFilesToZip(parsedDocument, zip, mediaUrl);
if (!blobExport && format === 'html') {
// Use BlockNote "full HTML" export so that we stay closer to the editor rendering.
const fullHtml = await editor.blocksToFullHTML();
const lang = i18next.language || fallbackLng;
const body = parsedDocument.body;
const editorHtmlWithLocalMedia = body ? body.innerHTML : '';
// Parse HTML and fetch media so that we can package a fully offline HTML document in a ZIP.
const domParser = new DOMParser();
const parsedDocument = domParser.parseFromString(fullHtml, 'text/html');
const htmlContent = generateHtmlDocument(
documentTitle,
editorHtmlWithLocalMedia,
lang,
const zip = new JSZip();
improveHtmlAccessibility(parsedDocument, documentTitle);
await addMediaFilesToZip(parsedDocument, zip, mediaUrl);
const lang = i18next.language || fallbackLng;
const body = parsedDocument.body;
const editorHtmlWithLocalMedia = body ? body.innerHTML : '';
const htmlContent = generateHtmlDocument(
documentTitle,
editorHtmlWithLocalMedia,
lang,
);
zip.file('index.html', htmlContent);
// CSS Styles
const cssResponse = await fetch(
new URL(
'../assets/export-html-styles.txt',
import.meta.url,
).toString(),
);
const cssContent = await cssResponse.text();
zip.file('styles.css', cssContent);
blobExport = await zip.generateAsync({ type: 'blob' });
downloadExtension = 'zip';
}
if (!blobExport) {
toast(t('The export failed'), VariantType.ERROR);
return;
}
downloadFile(blobExport, `${filename}.${downloadExtension}`);
toast(
t('Your {{format}} was downloaded succesfully', {
format,
}),
VariantType.SUCCESS,
);
zip.file('index.html', htmlContent);
// CSS Styles
const cssResponse = await fetch(
new URL('../assets/export-html-styles.txt', import.meta.url).toString(),
);
const cssContent = await cssResponse.text();
zip.file('styles.css', cssContent);
blobExport = await zip.generateAsync({ type: 'blob' });
}
if (!blobExport) {
shouldClose = true;
} catch {
toast(t('The export failed'), VariantType.ERROR);
} finally {
setIsExporting(false);
return;
}
const downloadExtension = format === 'html' ? 'zip' : format;
downloadFile(blobExport, `${filename}.${downloadExtension}`);
toast(
t('Your {{format}} was downloaded succesfully', {
format,
}),
VariantType.SUCCESS,
);
setIsExporting(false);
onClose();
if (shouldClose) {
onClose();
}
}
return (
@@ -7,6 +7,32 @@ import { Canvg } from 'canvg';
import { IParagraphOptions, ShadingType } from 'docx';
import React from 'react';
const WINDOWS_RESERVED_FILENAME =
/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i;
/**
* Converts a document title into a safe filename for exported files.
*/
export function getExportFilename(title: string): string {
const filename = title
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.trim()
.replace(/\s+/g, '-')
.replace(/[<>:"/\\|?*]/g, '-')
.split('')
.map((character) => (character.charCodeAt(0) < 32 ? '-' : character))
.join('')
.replace(/[. ]+$/g, '');
if (!filename) {
return 'document';
}
return WINDOWS_RESERVED_FILENAME.test(filename) ? `_${filename}` : filename;
}
/**
* Triggers a browser download of a Blob with the given filename.
*
@@ -0,0 +1,101 @@
import JSZip from 'jszip';
import { isSafeUrl } from '@/utils/url';
import { exportResolveFileUrl } from './api';
import { deriveMediaFilename } from './utils_html';
type MediaResolver = (url: string) => Promise<Blob | string>;
interface MediaReference {
props: Record<string, unknown>;
src: string;
}
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null;
/** Collects media URL properties from a nested editor block tree. */
const collectMediaReferences = (
blocks: unknown[],
references: MediaReference[],
) => {
blocks.forEach((block) => {
if (!isRecord(block)) {
return;
}
const props = block.props;
if (isRecord(props) && typeof props.url === 'string' && props.url) {
references.push({ props, src: props.url });
}
if (Array.isArray(block.children)) {
collectMediaReferences(block.children, references);
}
});
};
/**
* Adds resolvable same-origin media to a Markdown archive and rewrites the
* corresponding block URLs to archive-local filenames.
*/
export const addMediaFilesToMarkdownZip = async (
blocks: unknown[],
zip: JSZip,
mediaUrl: string,
resolveMedia: MediaResolver = exportResolveFileUrl,
): Promise<number> => {
const references: MediaReference[] = [];
collectMediaReferences(blocks, references);
let mediaOrigin: string;
try {
mediaOrigin = new URL(mediaUrl).origin;
} catch {
return 0;
}
const mediaFiles = await Promise.all(
references.map(async ({ props, src }, index) => {
if (src.startsWith('data:')) {
return null;
}
let url: URL;
try {
url = new URL(src, mediaUrl);
} catch {
return null;
}
if (url.origin !== mediaOrigin || !isSafeUrl(url.href)) {
return null;
}
const blob = await resolveMedia(url.href);
if (!(blob instanceof Blob)) {
return null;
}
const filename = deriveMediaFilename({
src: url.href,
index,
blob,
});
props.url = filename;
return { filename, blob };
}),
);
let mediaFileCount = 0;
mediaFiles.forEach((mediaFile) => {
if (mediaFile) {
zip.file(mediaFile.filename, mediaFile.blob);
mediaFileCount += 1;
}
});
return mediaFileCount;
};