mirror of
https://github.com/suitenumerique/docs.git
synced 2026-09-23 10:05:10 +02:00
✨(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:
@@ -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,
|
||||
|
||||
+155
@@ -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();
|
||||
});
|
||||
});
|
||||
+38
@@ -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');
|
||||
});
|
||||
});
|
||||
+73
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user