📄(frontend) allowed partially export when MIT

When MIT, we were not able to export anything.
But actually, the export HTML and Print are allowed
for MIT, so we should allow them.
We now allow partially export when MIT, but the
AGPL export is still not allowed (pdf / odt / docx).
This commit is contained in:
Anthony LC
2026-07-31 15:44:30 +02:00
parent eed828d8c7
commit 30c905adfe
9 changed files with 170 additions and 272 deletions
+5 -4
View File
@@ -11,14 +11,15 @@ and this project adheres to
- ♿️(frontend) restore skip to content link after header redesign #2510
- 🌐(i18n) rename cn_CN to zh_CN, add eo_PL and zh_TW locales #2486
### Fixed
- 🐛(frontend) redirect homepage to login when homepage feat is disabled #2521
### Changed
- ♿️(frontend) use semantic `<dl>` structure in document info card #2379
### Fixed
- 🐛(frontend) redirect homepage to login when homepage feat is disabled #2521
- 📄(frontend) allowed partially export when MIT #2551
## [v5.4.1] - 2026-07-09
### Changed
@@ -1,7 +1,7 @@
import { afterAll, afterEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/docs/doc-export/components/ModalExport', () => ({
ModalExport: vi.fn(),
vi.mock('@/docs/doc-export/hooks/useExportAGPL', () => ({
useExportAGPL: vi.fn(),
}));
const originalEnv = process.env.NEXT_PUBLIC_PUBLISH_AS_MIT;
@@ -18,15 +18,15 @@ describe('useModuleExport', () => {
it('should return undefined when NEXT_PUBLIC_PUBLISH_AS_MIT is true', async () => {
process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = 'true';
const Export = await import('@/features/docs/doc-export/');
const Export = await import('@/docs/doc-export/hooks');
expect(Export.default).toBeUndefined();
});
it('should load modules when NEXT_PUBLIC_PUBLISH_AS_MIT is false', async () => {
process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = 'false';
const Export = await import('@/features/docs/doc-export/');
const Export = await import('@/docs/doc-export/hooks');
expect(Export.default).toHaveProperty('ModalExport');
expect(Export.default).toHaveProperty('useExportAGPL');
});
});
@@ -1,6 +1,3 @@
import { DOCXExporter } from '@blocknote/xl-docx-exporter';
import { ODTExporter } from '@blocknote/xl-odt-exporter';
import { PDFExporter } from '@blocknote/xl-pdf-exporter';
import {
Button,
Loader,
@@ -10,30 +7,19 @@ import {
VariantType,
useToastProvider,
} from '@gouvfr-lasuite/cunningham-react';
import { DocumentProps, pdf } from '@react-pdf/renderer';
import jsonemoji from 'emoji-datasource-apple' with { type: 'json' };
import i18next from 'i18next';
import JSZip from 'jszip';
import {
cloneElement,
isValidElement,
useEffect,
useRef,
useState,
} from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, ButtonCloseModal, Text } from '@/components';
import { useMediaUrl } from '@/core';
import { useEditorStore } from '@/docs/doc-editor/stores/useEditorStore';
import { Doc, useTrans } from '@/docs/doc-management';
import { type Doc, useTrans } from '@/docs/doc-management';
import { fallbackLng } from '@/i18n/config';
import { exportCorsResolveFileUrl } from '../api/exportResolveFileUrl';
import { docxDocsSchemaMappings } from '../mappingDocx';
import { odtDocsSchemaMappings } from '../mappingODT';
import { pdfDocsSchemaMappings } from '../mappingPDF';
import ModulesExport from '../hooks/';
import { downloadFile } from '../utils';
import {
addMediaFilesToZip,
@@ -42,13 +28,7 @@ import {
} from '../utils_html';
import { printDocumentWithStyles } from '../utils_print';
enum DocDownloadFormat {
HTML = 'html',
PDF = 'pdf',
DOCX = 'docx',
ODT = 'odt',
PRINT = 'print',
}
const useExportAGPL = ModulesExport?.useExportAGPL;
interface ModalExportProps {
onClose: () => void;
@@ -60,12 +40,13 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
const { toast } = useToastProvider();
const { editor } = useEditorStore();
const [isExporting, setIsExporting] = useState(false);
const [format, setFormat] = useState<DocDownloadFormat>(
DocDownloadFormat.PDF,
);
const { untitledDocument } = useTrans();
const mediaUrl = useMediaUrl();
const selectRef = useRef<HTMLDivElement>(null);
const exportAGPL = useExportAGPL?.(doc, editor);
const [format, setFormat] = useState(
exportAGPL?.formats.find((opt) => opt.value === 'pdf')?.value || 'html',
);
useEffect(() => {
const frameId = requestAnimationFrame(() => {
@@ -77,22 +58,18 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
return () => cancelAnimationFrame(frameId);
}, []);
const formatOptions = [
{ label: t('PDF'), value: DocDownloadFormat.PDF },
{ label: t('Docx'), value: DocDownloadFormat.DOCX },
{ label: t('ODT'), value: DocDownloadFormat.ODT },
{ label: t('HTML'), value: DocDownloadFormat.HTML },
{ label: t('Print'), value: DocDownloadFormat.PRINT },
];
const formatSelect = useMemo(() => {
const formatOptions = (exportAGPL?.formats || []).concat([
{ label: t('HTML'), value: 'html' },
{ label: t('Print'), value: 'print' },
]);
const formatLabels = Object.fromEntries(
formatOptions.map((opt) => [opt.value, opt.label]),
);
const formatLabels = Object.fromEntries(
formatOptions.map((opt) => [opt.value, opt.label]),
);
const downloadButtonAriaLabel =
format === DocDownloadFormat.PRINT
? t('Print')
: t('Download {{format}}', { format: formatLabels[format] });
return { formatOptions, formatLabels };
}, [t, exportAGPL?.formats]);
async function onSubmit() {
if (!editor) {
@@ -103,7 +80,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
setIsExporting(true);
// Handle print separately as it doesn't download a file
if (format === DocDownloadFormat.PRINT) {
if (format === 'print') {
printDocumentWithStyles();
setIsExporting(false);
onClose();
@@ -118,62 +95,9 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
const documentTitle = doc.title || untitledDocument;
const exportDocument = editor.document;
let blobExport: Blob;
if (format === DocDownloadFormat.PDF) {
const exporter = new PDFExporter(editor.schema, pdfDocsSchemaMappings, {
resolveFileUrl: async (url) => exportCorsResolveFileUrl(doc.id, url),
emojiSource: {
format: 'png',
builder(code) {
const emojisFound = jsonemoji.filter(
(e) =>
e.unified.split('-')[0].toLowerCase() ===
code.split('-')[0].toLowerCase(),
);
let blobExport = await exportAGPL?.docToBlob(format, documentTitle);
const emoji = emojisFound.find((e) =>
e.unified.toLocaleLowerCase().includes(code.toLowerCase()),
);
if (emoji) {
return `/assets/fonts/emoji/${emoji.image}`;
}
return '/assets/fonts/emoji/fallback.png';
},
},
});
const rawPdfDocument = (await exporter.toReactPDFDocument(
exportDocument,
)) as React.ReactElement<DocumentProps>;
// Add language, title and outline properties to improve PDF accessibility and navigation
const pdfDocument = isValidElement(rawPdfDocument)
? cloneElement(rawPdfDocument, {
language: i18next.language,
title: documentTitle,
pageMode: 'useOutlines',
})
: rawPdfDocument;
blobExport = await pdf(pdfDocument).toBlob();
} else if (format === DocDownloadFormat.DOCX) {
const exporter = new DOCXExporter(editor.schema, docxDocsSchemaMappings, {
resolveFileUrl: async (url) => exportCorsResolveFileUrl(doc.id, url),
});
blobExport = await exporter.toBlob(exportDocument, {
documentOptions: { title: documentTitle },
sectionOptions: {},
});
} else if (format === DocDownloadFormat.ODT) {
const exporter = new ODTExporter(editor.schema, odtDocsSchemaMappings, {
resolveFileUrl: async (url) => exportCorsResolveFileUrl(doc.id, url),
});
blobExport = await exporter.toODTDocument(exportDocument);
} else if (format === DocDownloadFormat.HTML) {
if (!blobExport && format === 'html') {
// Use BlockNote "full HTML" export so that we stay closer to the editor rendering.
const fullHtml = await editor.blocksToFullHTML();
@@ -206,14 +130,15 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
zip.file('styles.css', cssContent);
blobExport = await zip.generateAsync({ type: 'blob' });
} else {
}
if (!blobExport) {
toast(t('The export failed'), VariantType.ERROR);
setIsExporting(false);
return;
}
const downloadExtension =
format === DocDownloadFormat.HTML ? 'zip' : format;
const downloadExtension = format === 'html' ? 'zip' : format;
downloadFile(blobExport, `${filename}.${downloadExtension}`);
@@ -250,13 +175,19 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
</Button>
<Button
data-testid="doc-export-download-button"
aria-label={downloadButtonAriaLabel}
aria-label={
format === 'print'
? t('Print')
: t('Download {{format}}', {
format: formatSelect.formatLabels[format],
})
}
variant="primary"
fullWidth
onClick={() => void onSubmit()}
disabled={isExporting}
>
{format === DocDownloadFormat.PRINT ? t('Print') : t('Download')}
{format === 'print' ? t('Print') : t('Download')}
</Button>
</>
}
@@ -303,11 +234,9 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
clearable={false}
fullWidth
label={t('Format')}
options={formatOptions}
options={formatSelect.formatOptions}
value={format}
onChange={(options) =>
setFormat(options.target.value as DocDownloadFormat)
}
onChange={(options) => setFormat(options.target.value as string)}
/>
</Box>
@@ -0,0 +1,18 @@
/**
* To import export modules you must import from the index file.
* This is to ensure that the export modules are only loaded when
* the application is not published as MIT.
*/
import * as useExportAGPL from './useExportAGPL';
let modulesExport = undefined;
if (process.env.NEXT_PUBLIC_PUBLISH_AS_MIT === 'false') {
modulesExport = {
...useExportAGPL,
};
}
type ModulesExport = typeof useExportAGPL;
export default modulesExport as ModulesExport;
@@ -0,0 +1,98 @@
/**
* This exports modules are AGPL licensed and should only
* be used when the application is not published as MIT.
*/
import { DOCXExporter } from '@blocknote/xl-docx-exporter';
import { ODTExporter } from '@blocknote/xl-odt-exporter';
import { PDFExporter } from '@blocknote/xl-pdf-exporter';
import { DocumentProps, pdf } from '@react-pdf/renderer';
import jsonemoji from 'emoji-datasource-apple' with { type: 'json' };
import i18next from 'i18next';
import { cloneElement, isValidElement } from 'react';
import { useTranslation } from 'react-i18next';
import { DocsBlockNoteEditor } from '@/docs/doc-editor/types';
import { Doc } from '@/docs/doc-management/types';
import { exportCorsResolveFileUrl } from '../api/exportResolveFileUrl';
import { docxDocsSchemaMappings } from '../mappingDocx';
import { odtDocsSchemaMappings } from '../mappingODT';
import { pdfDocsSchemaMappings } from '../mappingPDF';
export const useExportAGPL = (doc: Doc, editor?: DocsBlockNoteEditor) => {
const { t } = useTranslation();
const docToBlob = async (format: string, documentTitle: string) => {
if (!editor) {
return;
}
const exportDocument = editor.document;
let blobExport: Blob | undefined = undefined;
if (format === 'pdf') {
const exporter = new PDFExporter(editor.schema, pdfDocsSchemaMappings, {
resolveFileUrl: async (url) => exportCorsResolveFileUrl(doc.id, url),
emojiSource: {
format: 'png',
builder(code) {
const emojisFound = jsonemoji.filter(
(e) =>
e.unified.split('-')[0].toLowerCase() ===
code.split('-')[0].toLowerCase(),
);
const emoji = emojisFound.find((e) =>
e.unified.toLocaleLowerCase().includes(code.toLowerCase()),
);
if (emoji) {
return `/assets/fonts/emoji/${emoji.image}`;
}
return '/assets/fonts/emoji/fallback.png';
},
},
});
const rawPdfDocument = (await exporter.toReactPDFDocument(
exportDocument,
)) as React.ReactElement<DocumentProps>;
// Add language, title and outline properties to improve PDF accessibility and navigation
const pdfDocument = isValidElement(rawPdfDocument)
? cloneElement(rawPdfDocument, {
language: i18next.language,
title: documentTitle,
pageMode: 'useOutlines',
})
: rawPdfDocument;
blobExport = await pdf(pdfDocument).toBlob();
} else if (format === 'docx') {
const exporter = new DOCXExporter(editor.schema, docxDocsSchemaMappings, {
resolveFileUrl: async (url) => exportCorsResolveFileUrl(doc.id, url),
});
blobExport = await exporter.toBlob(exportDocument, {
documentOptions: { title: documentTitle },
sectionOptions: {},
});
} else if (format === 'odt') {
const exporter = new ODTExporter(editor.schema, odtDocsSchemaMappings, {
resolveFileUrl: async (url) => exportCorsResolveFileUrl(doc.id, url),
});
blobExport = await exporter.toODTDocument(exportDocument);
}
return blobExport;
};
return {
formats: [
{ label: t('PDF'), value: 'pdf' },
{ label: t('Docx'), value: 'docx' },
{ label: t('ODT'), value: 'odt' },
],
docToBlob,
};
};
@@ -6,14 +6,4 @@
export * from './api';
export * from './utils';
export * from './utils_html';
import * as ModalExport from './components/ModalExport';
let modulesExport = undefined;
if (process.env.NEXT_PUBLIC_PUBLISH_AS_MIT === 'false') {
modulesExport = {
...ModalExport,
};
}
export default modulesExport;
export * from './components';
@@ -1,67 +0,0 @@
import { render, screen } from '@testing-library/react';
import { afterAll, beforeEach, describe, expect, vi } from 'vitest';
const originalEnv = process.env.NEXT_PUBLIC_PUBLISH_AS_MIT;
vi.mock('next/router', async () => ({
...(await vi.importActual('next/router')),
useRouter: () => ({
push: vi.fn(),
pathname: '/docs/doc-1',
}),
}));
vi.mock('@gouvfr-lasuite/ui-kit', async () => {
const actual = await vi.importActual('@gouvfr-lasuite/ui-kit');
return {
...actual,
DropdownMenu: ({ options, children }: any) => (
<>
{children}
<ul>
{options
.filter((o: any) => !o.isHidden)
.map((o: any) => (
<li key={o.label}>{o.label}</li>
))}
</ul>
</>
),
};
});
vi.mock('../hooks/useCopyCurrentEditorToClipboard', () => ({
useCopyCurrentEditorToClipboard: () => vi.fn(),
}));
const doc = {
nb_accesses: 1,
abilities: {
versions_list: true,
destroy: true,
},
};
describe('DocToolBox - Licence', () => {
afterAll(() => {
process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = originalEnv;
});
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
});
test('The export button is rendered when MIT version is deactivated', async () => {
process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = 'false';
const { AppWrapper } = await import('@/tests/utils');
const { DocToolBox } = await import('../components/DocToolBox');
render(<DocToolBox doc={doc as any} />, {
wrapper: AppWrapper,
});
expect(await screen.findByText('Download')).toBeInTheDocument();
}, 15000);
});
@@ -1,67 +0,0 @@
import { render, screen } from '@testing-library/react';
import { afterAll, beforeEach, describe, expect, vi } from 'vitest';
const originalEnv = process.env.NEXT_PUBLIC_PUBLISH_AS_MIT;
vi.mock('next/router', async () => ({
...(await vi.importActual('next/router')),
useRouter: () => ({
push: vi.fn(),
pathname: '/docs/doc-1',
}),
}));
vi.mock('@gouvfr-lasuite/ui-kit', async () => {
const actual = await vi.importActual('@gouvfr-lasuite/ui-kit');
return {
...actual,
DropdownMenu: ({ options, children }: any) => (
<>
{children}
<ul>
{options
.filter((o: any) => !o.isHidden)
.map((o: any) => (
<li key={o.label}>{o.label}</li>
))}
</ul>
</>
),
};
});
vi.mock('../hooks/useCopyCurrentEditorToClipboard', () => ({
useCopyCurrentEditorToClipboard: () => vi.fn(),
}));
const doc = {
nb_accesses: 1,
abilities: {
versions_list: true,
destroy: true,
},
};
describe('DocToolBox - Licence MIT', () => {
afterAll(() => {
process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = originalEnv;
});
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
});
test('The export button is not rendered when MIT version is activated', async () => {
process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = 'true';
const { AppWrapper } = await import('@/tests/utils');
const { DocToolBox } = await import('../components/DocToolBox');
render(<DocToolBox doc={doc as any} />, {
wrapper: AppWrapper,
});
expect(screen.queryByText('Download')).not.toBeInTheDocument();
}, 15000);
});
@@ -72,16 +72,13 @@ const ConfirmationLeaveModal = dynamic(
{ ssr: false },
);
const ModalExport =
process.env.NEXT_PUBLIC_PUBLISH_AS_MIT === 'false'
? dynamic(
() =>
import('@/docs/doc-export/components/ModalExport').then((mod) => ({
default: mod.ModalExport,
})),
{ ssr: false },
)
: null;
const ModalExport = dynamic(
() =>
import('@/docs/doc-export/components/ModalExport').then((mod) => ({
default: mod.ModalExport,
})),
{ ssr: false },
);
interface DocToolBoxProps {
doc: Doc;
@@ -167,7 +164,6 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
callback: () => {
setIsModalExportOpen(true);
},
isHidden: !ModalExport,
},
{
label: t('Copy as {{format}}', { format: 'Markdown' }),
@@ -247,7 +243,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
/>
</DropdownMenu>
{isModalExportOpen && ModalExport && (
{isModalExportOpen && (
<ModalExport
onClose={() => {
setIsModalExportOpen(false);