mirror of
https://github.com/Shadowfita/docmost.git
synced 2025-11-13 00:02:30 +10:00
feat: Individual page export in Markdown and HTML formats (#80)
* fix maths node * render default html width * Add page export module * with support for html and markdown exports * Page export UI * Add PDF print too * remove unused import
This commit is contained in:
@ -26,6 +26,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"emoji-mart": "^5.6.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"jotai": "^2.8.3",
|
||||
"jotai-optics": "^0.4.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
|
||||
@ -3,7 +3,7 @@ import React from "react";
|
||||
import { TitleEditor } from "@/features/editor/title-editor";
|
||||
import PageEditor from "@/features/editor/page-editor";
|
||||
import { Container } from "@mantine/core";
|
||||
import { useAtom } from "jotai/index";
|
||||
import { useAtom } from "jotai";
|
||||
import { userAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
|
||||
const MemoizedTitleEditor = React.memo(TitleEditor);
|
||||
|
||||
@ -163,3 +163,4 @@
|
||||
.actionIconGroup {
|
||||
background: var(--mantine-color-body);
|
||||
}
|
||||
|
||||
|
||||
@ -2,5 +2,10 @@
|
||||
height: 100%;
|
||||
padding: 8px 20px;
|
||||
margin: 64px auto;
|
||||
|
||||
@media print {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -8,5 +8,7 @@
|
||||
@import "./youtube.css";
|
||||
@import "./media.css";
|
||||
@import "./code.css";
|
||||
@import "./print.css";
|
||||
|
||||
|
||||
|
||||
|
||||
@ -4,6 +4,10 @@
|
||||
color: #adb5bd;
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror .is-empty::before {
|
||||
@ -12,9 +16,17 @@
|
||||
color: #adb5bd;
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror table .is-editor-empty:first-child::before,
|
||||
.ProseMirror table .is-empty::before {
|
||||
content: '';
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
11
apps/client/src/features/editor/styles/print.css
Normal file
11
apps/client/src/features/editor/styles/print.css
Normal file
@ -0,0 +1,11 @@
|
||||
@media print {
|
||||
.mantine-AppShell-header,
|
||||
.mantine-AppShell-navbar,
|
||||
.mantine-AppShell-aside{
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.mantine-AppShell-main {
|
||||
padding-top: 0 !important;
|
||||
}
|
||||
}
|
||||
@ -17,5 +17,9 @@
|
||||
&.ProseMirror-selectednode {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,16 +2,18 @@ import { ActionIcon, Group, Menu, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconArrowsHorizontal,
|
||||
IconDots,
|
||||
IconDownload,
|
||||
IconHistory,
|
||||
IconLink,
|
||||
IconMessage,
|
||||
IconPrinter,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
import React from "react";
|
||||
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
|
||||
import { useAtom } from "jotai";
|
||||
import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
||||
import { useClipboard } from "@mantine/hooks";
|
||||
import { useClipboard, useDisclosure } from "@mantine/hooks";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
@ -21,6 +23,7 @@ import { extractPageSlugId } from "@/lib";
|
||||
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
|
||||
import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx";
|
||||
import { PageWidthToggle } from "@/features/user/components/page-width-pref.tsx";
|
||||
import PageExportModal from "@/features/page/components/page-export-modal.tsx";
|
||||
|
||||
interface PageHeaderMenuProps {
|
||||
readOnly?: boolean;
|
||||
@ -57,6 +60,8 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
});
|
||||
const { openDeleteModal } = useDeletePageModal();
|
||||
const [tree] = useAtom(treeApiAtom);
|
||||
const [opened, { open: openExportModal, close: closeExportModal }] =
|
||||
useDisclosure(false);
|
||||
|
||||
const handleCopyLink = () => {
|
||||
const pageUrl =
|
||||
@ -66,6 +71,12 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
notifications.show({ message: "Link copied" });
|
||||
};
|
||||
|
||||
const handlePrint = () => {
|
||||
setTimeout(() => {
|
||||
window.print();
|
||||
}, 250);
|
||||
};
|
||||
|
||||
const openHistoryModal = () => {
|
||||
setHistoryModalOpen(true);
|
||||
};
|
||||
@ -75,55 +86,79 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu
|
||||
shadow="xl"
|
||||
position="bottom-end"
|
||||
offset={20}
|
||||
width={200}
|
||||
withArrow
|
||||
arrowPosition="center"
|
||||
>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="default" style={{ border: "none" }}>
|
||||
<IconDots size={20} stroke={2} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<>
|
||||
<Menu
|
||||
shadow="xl"
|
||||
position="bottom-end"
|
||||
offset={20}
|
||||
width={200}
|
||||
withArrow
|
||||
arrowPosition="center"
|
||||
>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="default" style={{ border: "none" }}>
|
||||
<IconDots size={20} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<IconLink size={16} stroke={2} />}
|
||||
onClick={handleCopyLink}
|
||||
>
|
||||
Copy link
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<IconLink size={16} />}
|
||||
onClick={handleCopyLink}
|
||||
>
|
||||
Copy link
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item leftSection={<IconArrowsHorizontal size={16} stroke={2} />}>
|
||||
<Group wrap="nowrap">
|
||||
<PageWidthToggle label="Full width" />
|
||||
</Group>
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconArrowsHorizontal size={16} />}>
|
||||
<Group wrap="nowrap">
|
||||
<PageWidthToggle label="Full width" />
|
||||
</Group>
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<IconHistory size={16} stroke={2} />}
|
||||
onClick={openHistoryModal}
|
||||
>
|
||||
Page history
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconHistory size={16} />}
|
||||
onClick={openHistoryModal}
|
||||
>
|
||||
Page history
|
||||
</Menu.Item>
|
||||
|
||||
{!readOnly && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
color={"red"}
|
||||
leftSection={<IconTrash size={16} stroke={2} />}
|
||||
onClick={handleDeletePage}
|
||||
>
|
||||
Delete
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
<Menu.Divider />
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<IconDownload size={16} />}
|
||||
onClick={openExportModal}
|
||||
>
|
||||
Export
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<IconPrinter size={16} />}
|
||||
onClick={handlePrint}
|
||||
>
|
||||
Print PDF
|
||||
</Menu.Item>
|
||||
|
||||
{!readOnly && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
color={"red"}
|
||||
leftSection={<IconTrash size={16} />}
|
||||
onClick={handleDeletePage}
|
||||
>
|
||||
Delete
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
|
||||
<PageExportModal
|
||||
pageId={page.id}
|
||||
open={opened}
|
||||
onClose={closeExportModal}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -8,4 +8,8 @@
|
||||
top: var(--app-shell-header-offset, 0rem);
|
||||
inset-inline-start: var(--app-shell-navbar-offset, 0rem);
|
||||
inset-inline-end: var(--app-shell-aside-offset, 0rem);
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,84 @@
|
||||
import { Modal, Button, Group, Text, Select } from "@mantine/core";
|
||||
import { exportPage } from "@/features/page/services/page-service.ts";
|
||||
import { useState } from "react";
|
||||
import * as React from "react";
|
||||
import { ExportFormat } from "@/features/page/types/page.types.ts";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
|
||||
interface PageExportModalProps {
|
||||
pageId: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function PageExportModal({
|
||||
pageId,
|
||||
open,
|
||||
onClose,
|
||||
}: PageExportModalProps) {
|
||||
const [format, setFormat] = useState<ExportFormat>(ExportFormat.Markdown);
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
await exportPage({ pageId: pageId, format });
|
||||
onClose();
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
message: "Export failed:" + err.response?.data.message,
|
||||
color: "red",
|
||||
});
|
||||
console.error("export error", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (format: ExportFormat) => {
|
||||
setFormat(format);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
opened={open}
|
||||
onClose={onClose}
|
||||
size="350"
|
||||
centered
|
||||
withCloseButton={false}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<div>
|
||||
<Text size="md">Export format</Text>
|
||||
</div>
|
||||
<ExportFormatSelection onChange={handleChange} />
|
||||
</Group>
|
||||
|
||||
<Group justify="flex-start" mt="md">
|
||||
<Button onClick={onClose} variant="default">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleExport}>Export</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface ExportFormatSelection {
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
function ExportFormatSelection({ onChange }: ExportFormatSelection) {
|
||||
return (
|
||||
<Select
|
||||
data={[
|
||||
{ value: "markdown", label: "Markdown" },
|
||||
{ value: "html", label: "HTML" },
|
||||
]}
|
||||
defaultValue={ExportFormat.Markdown}
|
||||
onChange={onChange}
|
||||
styles={{ wrapper: { maxWidth: 120 } }}
|
||||
comboboxProps={{ width: "120" }}
|
||||
allowDeselect={false}
|
||||
withCheckIcon={false}
|
||||
aria-label="Select export format"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -1,11 +1,13 @@
|
||||
import api from "@/lib/api-client";
|
||||
import {
|
||||
IExportPageParams,
|
||||
IMovePage,
|
||||
IPage,
|
||||
IPageInput,
|
||||
SidebarPagesParams,
|
||||
} from "@/features/page/types/page.types";
|
||||
import { IAttachment, IPagination } from "@/lib/types.ts";
|
||||
import { saveAs } from "file-saver";
|
||||
|
||||
export async function createPage(data: Partial<IPage>): Promise<IPage> {
|
||||
const req = await api.post<IPage>("/pages/create", data);
|
||||
@ -53,18 +55,28 @@ export async function getRecentChanges(
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function exportPage(data: IExportPageParams): Promise<void> {
|
||||
const req = await api.post("/pages/export", data, {
|
||||
responseType: "blob",
|
||||
});
|
||||
|
||||
const fileName = req?.headers["content-disposition"]
|
||||
.split("filename=")[1]
|
||||
.replace(/"/g, "");
|
||||
|
||||
saveAs(req.data, fileName);
|
||||
}
|
||||
|
||||
export async function uploadFile(file: File, pageId: string) {
|
||||
const formData = new FormData();
|
||||
formData.append("pageId", pageId);
|
||||
formData.append("file", file);
|
||||
|
||||
// should be file endpoint
|
||||
const req = await api.post<IAttachment>("/files/upload", formData, {
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
});
|
||||
// console.log("req", req);
|
||||
|
||||
return req;
|
||||
}
|
||||
|
||||
@ -44,3 +44,13 @@ export interface IPageInput {
|
||||
coverPhoto: string;
|
||||
position: string;
|
||||
}
|
||||
|
||||
export interface IExportPageParams {
|
||||
pageId: string;
|
||||
format: ExportFormat;
|
||||
}
|
||||
|
||||
export enum ExportFormat {
|
||||
HTML = "html",
|
||||
Markdown = "markdown",
|
||||
}
|
||||
|
||||
@ -95,9 +95,11 @@ function ChangePasswordForm({ onClose }: ChangePasswordFormProps) {
|
||||
{...form.getInputProps("newPassword")}
|
||||
/>
|
||||
|
||||
<Button type="submit" disabled={isLoading} loading={isLoading}>
|
||||
Change password
|
||||
</Button>
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button type="submit" disabled={isLoading} loading={isLoading}>
|
||||
Change password
|
||||
</Button>
|
||||
</Group>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@ -26,11 +26,16 @@ api.interceptors.request.use(
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => {
|
||||
// we need the response headers
|
||||
if (response.request.responseURL.includes("/api/pages/export")) {
|
||||
return response;
|
||||
}
|
||||
|
||||
return response.data;
|
||||
},
|
||||
(error) => {
|
||||
@ -67,7 +72,7 @@ api.interceptors.response.use(
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function redirectToLogin() {
|
||||
|
||||
@ -22,6 +22,10 @@ export interface IRoleData {
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
data: T;
|
||||
}
|
||||
|
||||
export type IPaginationMeta = {
|
||||
limit: number;
|
||||
page: number;
|
||||
|
||||
@ -12,6 +12,7 @@ import { QueueModule } from './integrations/queue/queue.module';
|
||||
import { StaticModule } from './integrations/static/static.module';
|
||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||
import { HealthModule } from './integrations/health/health.module';
|
||||
import { ExportModule } from './integrations/export/export.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@ -23,6 +24,7 @@ import { HealthModule } from './integrations/health/health.module';
|
||||
QueueModule,
|
||||
StaticModule,
|
||||
HealthModule,
|
||||
ExportModule,
|
||||
StorageModule.forRootAsync({
|
||||
imports: [EnvironmentModule],
|
||||
}),
|
||||
|
||||
@ -60,7 +60,7 @@ export const tiptapExtensions = [
|
||||
Callout,
|
||||
] as any;
|
||||
|
||||
export function jsonToHtml(tiptapJson: JSONContent) {
|
||||
export function jsonToHtml(tiptapJson: any) {
|
||||
return generateHTML(tiptapJson, tiptapExtensions);
|
||||
}
|
||||
|
||||
|
||||
26
apps/server/src/integrations/export/dto/export-dto.ts
Normal file
26
apps/server/src/integrations/export/dto/export-dto.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import {
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
} from 'class-validator';
|
||||
|
||||
export enum ExportFormat {
|
||||
HTML = 'html',
|
||||
Markdown = 'markdown',
|
||||
}
|
||||
|
||||
export class ExportPageDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
pageId: string;
|
||||
|
||||
@IsString()
|
||||
@IsIn(['html', 'markdown'])
|
||||
format: ExportFormat;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
includeFiles?: boolean;
|
||||
}
|
||||
69
apps/server/src/integrations/export/export.controller.ts
Normal file
69
apps/server/src/integrations/export/export.controller.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
NotFoundException,
|
||||
Post,
|
||||
Res,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ExportService } from './export.service';
|
||||
import { ExportPageDto } from './dto/export-dto';
|
||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||
import { User } from '@docmost/db/types/entity.types';
|
||||
import SpaceAbilityFactory from '../../core/casl/abilities/space-ability.factory';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import {
|
||||
SpaceCaslAction,
|
||||
SpaceCaslSubject,
|
||||
} from '../../core/casl/interfaces/space-ability.type';
|
||||
import { FastifyReply } from 'fastify';
|
||||
import { sanitize } from 'sanitize-filename-ts';
|
||||
import { getExportExtension } from './utils';
|
||||
import { getMimeType } from '../../common/helpers';
|
||||
|
||||
@Controller()
|
||||
export class ImportController {
|
||||
constructor(
|
||||
private readonly importService: ExportService,
|
||||
private readonly pageRepo: PageRepo,
|
||||
private readonly spaceAbility: SpaceAbilityFactory,
|
||||
) {}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('pages/export')
|
||||
async exportPage(
|
||||
@Body() dto: ExportPageDto,
|
||||
@AuthUser() user: User,
|
||||
@Res() res: FastifyReply,
|
||||
) {
|
||||
const page = await this.pageRepo.findById(dto.pageId, {
|
||||
includeContent: true,
|
||||
});
|
||||
|
||||
if (!page) {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
const rawContent = await this.importService.exportPage(dto.format, page);
|
||||
|
||||
const fileExt = getExportExtension(dto.format);
|
||||
const fileName = sanitize(page.title || 'Untitled') + fileExt;
|
||||
|
||||
res.headers({
|
||||
'Content-Type': getMimeType(fileExt),
|
||||
'Content-Disposition': 'attachment; filename="' + fileName + '"',
|
||||
});
|
||||
|
||||
res.send(rawContent);
|
||||
}
|
||||
}
|
||||
9
apps/server/src/integrations/export/export.module.ts
Normal file
9
apps/server/src/integrations/export/export.module.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ExportService } from './export.service';
|
||||
import { ImportController } from './export.controller';
|
||||
|
||||
@Module({
|
||||
providers: [ExportService],
|
||||
controllers: [ImportController],
|
||||
})
|
||||
export class ExportModule {}
|
||||
34
apps/server/src/integrations/export/export.service.ts
Normal file
34
apps/server/src/integrations/export/export.service.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { jsonToHtml } from '../../collaboration/collaboration.util';
|
||||
import { turndown } from './turndown-utils';
|
||||
import { ExportFormat } from './dto/export-dto';
|
||||
import { Page } from '@docmost/db/types/entity.types';
|
||||
|
||||
@Injectable()
|
||||
export class ExportService {
|
||||
async exportPage(format: string, page: Page) {
|
||||
const titleNode = {
|
||||
type: 'heading',
|
||||
attrs: { level: 1 },
|
||||
content: [{ type: 'text', text: page.title }],
|
||||
};
|
||||
|
||||
let prosemirrorJson: any = page.content || { type: 'doc', content: [] };
|
||||
|
||||
if (page.title) {
|
||||
prosemirrorJson.content.unshift(titleNode);
|
||||
}
|
||||
|
||||
const pageHtml = jsonToHtml(prosemirrorJson);
|
||||
|
||||
if (format === ExportFormat.HTML) {
|
||||
return `<!DOCTYPE html><html><head><title>${page.title}</title></head><body>${pageHtml}</body></html>`;
|
||||
}
|
||||
|
||||
if (format === ExportFormat.Markdown) {
|
||||
return turndown(pageHtml);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
100
apps/server/src/integrations/export/turndown-utils.ts
Normal file
100
apps/server/src/integrations/export/turndown-utils.ts
Normal file
@ -0,0 +1,100 @@
|
||||
import * as TurndownService from '@joplin/turndown';
|
||||
import * as TurndownPluginGfm from '@joplin/turndown-plugin-gfm';
|
||||
|
||||
export function turndown(html: string): string {
|
||||
const turndownService = new TurndownService({
|
||||
headingStyle: 'atx',
|
||||
codeBlockStyle: 'fenced',
|
||||
hr: '---',
|
||||
bulletListMarker: '-',
|
||||
});
|
||||
const tables = TurndownPluginGfm.tables;
|
||||
const strikethrough = TurndownPluginGfm.strikethrough;
|
||||
const highlightedCodeBlock = TurndownPluginGfm.highlightedCodeBlock;
|
||||
|
||||
turndownService.use([
|
||||
tables,
|
||||
strikethrough,
|
||||
highlightedCodeBlock,
|
||||
taskList,
|
||||
callout,
|
||||
toggleListTitle,
|
||||
toggleListBody,
|
||||
listParagraph,
|
||||
]);
|
||||
|
||||
return turndownService.turndown(html).replaceAll('<br>', ' ');
|
||||
}
|
||||
|
||||
function listParagraph(turndownService: TurndownService) {
|
||||
turndownService.addRule('paragraph', {
|
||||
filter: ['p'],
|
||||
replacement: (content: any, node: HTMLInputElement) => {
|
||||
if (node.parentElement?.nodeName === 'LI') {
|
||||
return content;
|
||||
}
|
||||
|
||||
return `\n\n${content}\n\n`;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function callout(turndownService: TurndownService) {
|
||||
turndownService.addRule('callout', {
|
||||
filter: function (node: HTMLInputElement) {
|
||||
return (
|
||||
node.nodeName === 'DIV' && node.getAttribute('data-type') === 'callout'
|
||||
);
|
||||
},
|
||||
replacement: function (content: any, node: HTMLInputElement) {
|
||||
const calloutType = node.getAttribute('data-callout-type');
|
||||
return `\n\n:::${calloutType}\n${content.trim()}\n:::\n\n`;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function taskList(turndownService: TurndownService) {
|
||||
turndownService.addRule('taskListItem', {
|
||||
filter: function (node: HTMLInputElement) {
|
||||
return (
|
||||
node.getAttribute('data-type') === 'taskItem' &&
|
||||
node.parentNode.nodeName === 'UL'
|
||||
);
|
||||
},
|
||||
replacement: function (content: any, node: HTMLInputElement) {
|
||||
const checkbox = node.querySelector(
|
||||
'input[type="checkbox"]',
|
||||
) as HTMLInputElement;
|
||||
const isChecked = checkbox.checked;
|
||||
|
||||
return `- ${isChecked ? '[x]' : '[ ]'} ${content.trim()} \n`;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function toggleListTitle(turndownService: TurndownService) {
|
||||
turndownService.addRule('toggleListTitle', {
|
||||
filter: function (node: HTMLInputElement) {
|
||||
return (
|
||||
node.nodeName === 'SUMMARY' && node.parentNode.nodeName === 'DETAILS'
|
||||
);
|
||||
},
|
||||
replacement: function (content: any, node: HTMLInputElement) {
|
||||
return '- ' + content;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function toggleListBody(turndownService: TurndownService) {
|
||||
turndownService.addRule('toggleListContent', {
|
||||
filter: function (node: HTMLInputElement) {
|
||||
return (
|
||||
node.getAttribute('data-type') === 'detailsContent' &&
|
||||
node.parentNode.nodeName === 'DETAILS'
|
||||
);
|
||||
},
|
||||
replacement: function (content: any, node: HTMLInputElement) {
|
||||
return ` ${content.replace(/\n/g, '\n ')} `;
|
||||
},
|
||||
});
|
||||
}
|
||||
12
apps/server/src/integrations/export/utils.ts
Normal file
12
apps/server/src/integrations/export/utils.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { ExportFormat } from './dto/export-dto';
|
||||
|
||||
export function getExportExtension(format: string) {
|
||||
if (format === ExportFormat.HTML) {
|
||||
return '.html';
|
||||
}
|
||||
|
||||
if (format === ExportFormat.Markdown) {
|
||||
return '.md';
|
||||
}
|
||||
return;
|
||||
}
|
||||
Reference in New Issue
Block a user