diff --git a/CHANGELOG.md b/CHANGELOG.md
index ac490d64a..b4708a20f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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 `
` 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
diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/__tests__/ExportMIT.test.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/__tests__/ExportMIT.test.tsx
index 3e5f3c3c0..b614a74cd 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-export/__tests__/ExportMIT.test.tsx
+++ b/src/frontend/apps/impress/src/features/docs/doc-export/__tests__/ExportMIT.test.tsx
@@ -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');
});
});
diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/components/ModalExport.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/components/ModalExport.tsx
index b504c9fc9..f7a6cee02 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-export/components/ModalExport.tsx
+++ b/src/frontend/apps/impress/src/features/docs/doc-export/components/ModalExport.tsx
@@ -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.PDF,
- );
const { untitledDocument } = useTrans();
const mediaUrl = useMediaUrl();
const selectRef = useRef(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;
-
- // 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) => {
>
}
@@ -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)}
/>
diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/hooks/index.ts b/src/frontend/apps/impress/src/features/docs/doc-export/hooks/index.ts
new file mode 100644
index 000000000..4cbea5953
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-export/hooks/index.ts
@@ -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;
diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/hooks/useExportAGPL.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/hooks/useExportAGPL.tsx
new file mode 100644
index 000000000..a803e3aeb
--- /dev/null
+++ b/src/frontend/apps/impress/src/features/docs/doc-export/hooks/useExportAGPL.tsx
@@ -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;
+
+ // 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,
+ };
+};
diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/index.ts b/src/frontend/apps/impress/src/features/docs/doc-export/index.ts
index 6306d9b08..2397d43bf 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-export/index.ts
+++ b/src/frontend/apps/impress/src/features/docs/doc-export/index.ts
@@ -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';
diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/__tests__/DocToolBoxLicence.spec.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/__tests__/DocToolBoxLicence.spec.tsx
deleted file mode 100644
index c6116ac5e..000000000
--- a/src/frontend/apps/impress/src/features/docs/doc-header/__tests__/DocToolBoxLicence.spec.tsx
+++ /dev/null
@@ -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}
-
- {options
- .filter((o: any) => !o.isHidden)
- .map((o: any) => (
- - {o.label}
- ))}
-
- >
- ),
- };
-});
-
-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(, {
- wrapper: AppWrapper,
- });
-
- expect(await screen.findByText('Download')).toBeInTheDocument();
- }, 15000);
-});
diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/__tests__/DocToolBoxLicenceMIT.spec.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/__tests__/DocToolBoxLicenceMIT.spec.tsx
deleted file mode 100644
index aab64de00..000000000
--- a/src/frontend/apps/impress/src/features/docs/doc-header/__tests__/DocToolBoxLicenceMIT.spec.tsx
+++ /dev/null
@@ -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}
-
- {options
- .filter((o: any) => !o.isHidden)
- .map((o: any) => (
- - {o.label}
- ))}
-
- >
- ),
- };
-});
-
-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(, {
- wrapper: AppWrapper,
- });
-
- expect(screen.queryByText('Download')).not.toBeInTheDocument();
- }, 15000);
-});
diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx
index 8ad47dda4..7138fe9ed 100644
--- a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx
+++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx
@@ -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) => {
/>
- {isModalExportOpen && ModalExport && (
+ {isModalExportOpen && (
{
setIsModalExportOpen(false);