mirror of
https://github.com/docmost/docmost.git
synced 2026-06-22 10:51:49 +10:00
Merge branch 'main' into personal-spaces
This commit is contained in:
@@ -398,6 +398,8 @@
|
|||||||
"Insert mermaid diagram": "Insert mermaid diagram",
|
"Insert mermaid diagram": "Insert mermaid diagram",
|
||||||
"Insert and design Drawio diagrams": "Insert and design Drawio diagrams",
|
"Insert and design Drawio diagrams": "Insert and design Drawio diagrams",
|
||||||
"Insert current date": "Insert current date",
|
"Insert current date": "Insert current date",
|
||||||
|
"Time": "Time",
|
||||||
|
"Insert current time": "Insert current time",
|
||||||
"Draw and sketch excalidraw diagrams": "Draw and sketch excalidraw diagrams",
|
"Draw and sketch excalidraw diagrams": "Draw and sketch excalidraw diagrams",
|
||||||
"Multiple": "Multiple",
|
"Multiple": "Multiple",
|
||||||
"Turn into": "Turn into",
|
"Turn into": "Turn into",
|
||||||
|
|||||||
@@ -6,13 +6,21 @@ import {
|
|||||||
Select,
|
Select,
|
||||||
Switch,
|
Switch,
|
||||||
Divider,
|
Divider,
|
||||||
|
Tooltip,
|
||||||
|
Badge,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { exportPage } from "@/features/page/services/page-service.ts";
|
import {
|
||||||
|
exportPage,
|
||||||
|
exportPageToDocx,
|
||||||
|
} from "@/features/page/services/page-service.ts";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { ExportFormat } from "@/features/page/types/page.types.ts";
|
import { ExportFormat } from "@/features/page/types/page.types.ts";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { exportSpace } from "@/features/space/services/space-service";
|
import { exportSpace } from "@/features/space/services/space-service";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Feature } from "@/ee/features";
|
||||||
|
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||||
|
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
|
||||||
|
|
||||||
interface ExportModalProps {
|
interface ExportModalProps {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -32,17 +40,25 @@ export default function ExportModal({
|
|||||||
const [includeAttachments, setIncludeAttachments] = useState<boolean>(false);
|
const [includeAttachments, setIncludeAttachments] = useState<boolean>(false);
|
||||||
const [isExporting, setIsExporting] = useState<boolean>(false);
|
const [isExporting, setIsExporting] = useState<boolean>(false);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const upgradeLabel = useUpgradeLabel();
|
||||||
|
const isDocx = format === ExportFormat.Docx;
|
||||||
|
const docxEntitled = useHasFeature(Feature.DOCX_EXPORT);
|
||||||
|
const blockedByLicense = isDocx && !docxEntitled;
|
||||||
|
|
||||||
const handleExport = async () => {
|
const handleExport = async () => {
|
||||||
setIsExporting(true);
|
setIsExporting(true);
|
||||||
try {
|
try {
|
||||||
if (type === "page") {
|
if (type === "page") {
|
||||||
await exportPage({
|
if (format === ExportFormat.Docx) {
|
||||||
pageId: id,
|
await exportPageToDocx({ pageId: id });
|
||||||
format,
|
} else {
|
||||||
includeChildren,
|
await exportPage({
|
||||||
includeAttachments,
|
pageId: id,
|
||||||
});
|
format,
|
||||||
|
includeChildren,
|
||||||
|
includeAttachments,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (type === "space") {
|
if (type === "space") {
|
||||||
await exportSpace({ spaceId: id, format, includeAttachments });
|
await exportSpace({ spaceId: id, format, includeAttachments });
|
||||||
@@ -88,10 +104,15 @@ export default function ExportModal({
|
|||||||
<div>
|
<div>
|
||||||
<Text size="md">{t("Format")}</Text>
|
<Text size="md">{t("Format")}</Text>
|
||||||
</div>
|
</div>
|
||||||
<ExportFormatSelection format={format} onChange={handleChange} />
|
<ExportFormatSelection
|
||||||
|
format={format}
|
||||||
|
onChange={handleChange}
|
||||||
|
includeDocx={type === "page"}
|
||||||
|
docxEntitled={docxEntitled}
|
||||||
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{type === "page" && (
|
{type === "page" && !isDocx && (
|
||||||
<>
|
<>
|
||||||
<Divider my="sm" />
|
<Divider my="sm" />
|
||||||
|
|
||||||
@@ -143,7 +164,16 @@ export default function ExportModal({
|
|||||||
<Button onClick={onClose} variant="default">
|
<Button onClick={onClose} variant="default">
|
||||||
{t("Cancel")}
|
{t("Cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleExport} loading={isExporting}>{t("Export")}</Button>
|
<Tooltip label={upgradeLabel} disabled={!blockedByLicense} withArrow>
|
||||||
|
<Button
|
||||||
|
onClick={handleExport}
|
||||||
|
loading={isExporting}
|
||||||
|
disabled={blockedByLicense}
|
||||||
|
data-disabled={blockedByLicense || undefined}
|
||||||
|
>
|
||||||
|
{t("Export")}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
</Group>
|
</Group>
|
||||||
</Modal.Body>
|
</Modal.Body>
|
||||||
</Modal.Content>
|
</Modal.Content>
|
||||||
@@ -154,23 +184,49 @@ export default function ExportModal({
|
|||||||
interface ExportFormatSelection {
|
interface ExportFormatSelection {
|
||||||
format: ExportFormat;
|
format: ExportFormat;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
|
includeDocx?: boolean;
|
||||||
|
docxEntitled?: boolean;
|
||||||
}
|
}
|
||||||
function ExportFormatSelection({ format, onChange }: ExportFormatSelection) {
|
function ExportFormatSelection({
|
||||||
|
format,
|
||||||
|
onChange,
|
||||||
|
includeDocx,
|
||||||
|
docxEntitled,
|
||||||
|
}: ExportFormatSelection) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const data = [
|
||||||
|
{ value: "markdown", label: "Markdown" },
|
||||||
|
{ value: "html", label: "HTML" },
|
||||||
|
...(includeDocx
|
||||||
|
? [{ value: "docx", label: "Word (.docx)", disabled: !docxEntitled }]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
data={[
|
data={data}
|
||||||
{ value: "markdown", label: "Markdown" },
|
|
||||||
{ value: "html", label: "HTML" },
|
|
||||||
]}
|
|
||||||
defaultValue={format}
|
defaultValue={format}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
styles={{ wrapper: { maxWidth: 120 } }}
|
styles={{ wrapper: { maxWidth: 140 }, option: { opacity: 1 } }}
|
||||||
comboboxProps={{ width: "120" }}
|
comboboxProps={{ width: 200 }}
|
||||||
allowDeselect={false}
|
allowDeselect={false}
|
||||||
withCheckIcon={false}
|
withCheckIcon={false}
|
||||||
aria-label={t("Select export format")}
|
aria-label={t("Select export format")}
|
||||||
|
renderOption={({ option }) =>
|
||||||
|
option.value === "docx" && !docxEntitled ? (
|
||||||
|
<div>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{option.label}
|
||||||
|
</Text>
|
||||||
|
<Badge size="xs" mt={4}>
|
||||||
|
{t("Enterprise")}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Text size="sm">{option.label}</Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,4 +20,5 @@ export const Feature = {
|
|||||||
TEMPLATES: 'templates',
|
TEMPLATES: 'templates',
|
||||||
VIEWER_COMMENTS: 'comment:viewer',
|
VIEWER_COMMENTS: 'comment:viewer',
|
||||||
PERSONAL_SPACES: 'spaces:personal',
|
PERSONAL_SPACES: 'spaces:personal',
|
||||||
|
DOCX_EXPORT: 'export:docx',
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
IconMenu4,
|
IconMenu4,
|
||||||
IconPageBreak,
|
IconPageBreak,
|
||||||
IconCalendar,
|
IconCalendar,
|
||||||
|
IconClock,
|
||||||
IconAppWindow,
|
IconAppWindow,
|
||||||
IconSitemap,
|
IconSitemap,
|
||||||
IconColumns3,
|
IconColumns3,
|
||||||
@@ -474,6 +475,25 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
|||||||
.run();
|
.run();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Time",
|
||||||
|
description: "Insert current time",
|
||||||
|
searchTerms: ["time", "now", "clock"],
|
||||||
|
icon: IconClock,
|
||||||
|
command: ({ editor, range }: CommandProps) => {
|
||||||
|
const currentTime = new Date().toLocaleTimeString(i18n.language, {
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "numeric",
|
||||||
|
});
|
||||||
|
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.deleteRange(range)
|
||||||
|
.insertContent(currentTime)
|
||||||
|
.run();
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Status",
|
title: "Status",
|
||||||
description: "Insert inline status badge.",
|
description: "Insert inline status badge.",
|
||||||
|
|||||||
@@ -132,6 +132,25 @@ export async function exportPage(data: IExportPageParams): Promise<void> {
|
|||||||
saveAs(req.data, decodedFileName);
|
saveAs(req.data, decodedFileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function exportPageToDocx(data: { pageId: string }): Promise<void> {
|
||||||
|
const req = await api.post("/docx-export", data, {
|
||||||
|
responseType: "blob",
|
||||||
|
});
|
||||||
|
|
||||||
|
const fileName = req?.headers["content-disposition"]
|
||||||
|
.split("filename=")[1]
|
||||||
|
.replace(/"/g, "");
|
||||||
|
|
||||||
|
let decodedFileName = fileName;
|
||||||
|
try {
|
||||||
|
decodedFileName = decodeURIComponent(fileName);
|
||||||
|
} catch (err) {
|
||||||
|
// fallback to raw filename
|
||||||
|
}
|
||||||
|
|
||||||
|
saveAs(req.data, decodedFileName);
|
||||||
|
}
|
||||||
|
|
||||||
export async function importPage(file: File, spaceId: string) {
|
export async function importPage(file: File, spaceId: string) {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("spaceId", spaceId);
|
formData.append("spaceId", spaceId);
|
||||||
|
|||||||
@@ -98,4 +98,5 @@ export interface IExportPageParams {
|
|||||||
export enum ExportFormat {
|
export enum ExportFormat {
|
||||||
HTML = "html",
|
HTML = "html",
|
||||||
Markdown = "markdown",
|
Markdown = "markdown",
|
||||||
|
Docx = "docx",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ const api: AxiosInstance = axios.create({
|
|||||||
api.interceptors.response.use(
|
api.interceptors.response.use(
|
||||||
(response) => {
|
(response) => {
|
||||||
// we need the response headers for these endpoints
|
// we need the response headers for these endpoints
|
||||||
const exemptEndpoints = ["/api/pages/export", "/api/spaces/export"];
|
const exemptEndpoints = [
|
||||||
|
"/api/pages/export",
|
||||||
|
"/api/spaces/export",
|
||||||
|
"/api/docx-export",
|
||||||
|
];
|
||||||
if (response.request.responseURL) {
|
if (response.request.responseURL) {
|
||||||
const path = new URL(response.request.responseURL)?.pathname;
|
const path = new URL(response.request.responseURL)?.pathname;
|
||||||
if (path && exemptEndpoints.includes(path)) {
|
if (path && exemptEndpoints.includes(path)) {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2020",
|
"target": "ES2021",
|
||||||
"useDefineForClassFields": true,
|
"useDefineForClassFields": true,
|
||||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
"lib": ["ES2021", "DOM", "DOM.Iterable"],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
|||||||
+21
-20
@@ -30,14 +30,14 @@
|
|||||||
"test:e2e": "jest --config test/jest-e2e.json"
|
"test:e2e": "jest --config test/jest-e2e.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/google": "^3.0.52",
|
"@ai-sdk/google": "3.0.52",
|
||||||
"@ai-sdk/openai": "^3.0.47",
|
"@ai-sdk/openai": "3.0.47",
|
||||||
"@ai-sdk/openai-compatible": "^2.0.37",
|
"@ai-sdk/openai-compatible": "2.0.37",
|
||||||
"@aws-sdk/client-s3": "3.1050.0",
|
"@aws-sdk/client-s3": "3.1050.0",
|
||||||
"@aws-sdk/lib-storage": "3.1050.0",
|
"@aws-sdk/lib-storage": "3.1050.0",
|
||||||
"@aws-sdk/s3-request-presigner": "3.1050.0",
|
"@aws-sdk/s3-request-presigner": "3.1050.0",
|
||||||
"@azure/storage-blob": "12.31.0",
|
"@azure/storage-blob": "12.31.0",
|
||||||
"@clickhouse/client": "^1.18.2",
|
"@clickhouse/client": "1.18.2",
|
||||||
"@docmost/pdf-inspector": "1.9.6",
|
"@docmost/pdf-inspector": "1.9.6",
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
"@fastify/multipart": "^10.0.0",
|
"@fastify/multipart": "^10.0.0",
|
||||||
@@ -65,19 +65,19 @@
|
|||||||
"@nestjs/websockets": "^11.1.19",
|
"@nestjs/websockets": "^11.1.19",
|
||||||
"@node-saml/passport-saml": "^5.1.0",
|
"@node-saml/passport-saml": "^5.1.0",
|
||||||
"@socket.io/redis-adapter": "^8.3.0",
|
"@socket.io/redis-adapter": "^8.3.0",
|
||||||
"ai": "^6.0.134",
|
"ai": "6.0.134",
|
||||||
"ai-sdk-ollama": "^3.8.1",
|
"ai-sdk-ollama": "3.8.1",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "6.0.0",
|
||||||
"bowser": "^2.14.1",
|
"bowser": "2.14.1",
|
||||||
"bullmq": "^5.76.10",
|
"bullmq": "5.76.10",
|
||||||
"cache-manager": "^7.2.8",
|
"cache-manager": "7.2.8",
|
||||||
"cheerio": "^1.2.0",
|
"cheerio": "1.2.0",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "0.5.1",
|
||||||
"class-validator": "^0.15.1",
|
"class-validator": "0.15.1",
|
||||||
"cookie": "^1.1.1",
|
"cookie": "1.1.1",
|
||||||
"fast-bm25": "0.0.5",
|
"fast-bm25": "0.0.5",
|
||||||
"fastify-ip": "^2.0.0",
|
"fastify-ip": "2.0.0",
|
||||||
"fs-extra": "^11.3.4",
|
"fs-extra": "11.3.4",
|
||||||
"happy-dom": "20.8.9",
|
"happy-dom": "20.8.9",
|
||||||
"ioredis": "^5.10.1",
|
"ioredis": "^5.10.1",
|
||||||
"js-tiktoken": "^1.0.21",
|
"js-tiktoken": "^1.0.21",
|
||||||
@@ -114,9 +114,9 @@
|
|||||||
"scimmy": "1.3.5",
|
"scimmy": "1.3.5",
|
||||||
"socket.io": "^4.8.3",
|
"socket.io": "^4.8.3",
|
||||||
"stripe": "^17.7.0",
|
"stripe": "^17.7.0",
|
||||||
"tlds": "^1.261.0",
|
"tlds": "1.261.0",
|
||||||
"tmp-promise": "^3.0.3",
|
"tmp-promise": "3.0.3",
|
||||||
"tseep": "^1.3.1",
|
"tseep": "1.3.1",
|
||||||
"typesense": "^3.0.5",
|
"typesense": "^3.0.5",
|
||||||
"undici": "7.24.0",
|
"undici": "7.24.0",
|
||||||
"ws": "^8.20.1",
|
"ws": "^8.20.1",
|
||||||
@@ -192,7 +192,8 @@
|
|||||||
"moduleNameMapper": {
|
"moduleNameMapper": {
|
||||||
"^@docmost/db/(.*)$": "<rootDir>/database/$1",
|
"^@docmost/db/(.*)$": "<rootDir>/database/$1",
|
||||||
"^@docmost/transactional/(.*)$": "<rootDir>/integrations/transactional/$1",
|
"^@docmost/transactional/(.*)$": "<rootDir>/integrations/transactional/$1",
|
||||||
"^@docmost/ee/(.*)$": "<rootDir>/ee/$1"
|
"^@docmost/ee/(.*)$": "<rootDir>/ee/$1",
|
||||||
|
"^src/(.*)$": "<rootDir>/$1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export const Feature = {
|
|||||||
TEMPLATES: 'templates',
|
TEMPLATES: 'templates',
|
||||||
PDF_EXPORT: 'export:pdf',
|
PDF_EXPORT: 'export:pdf',
|
||||||
PERSONAL_SPACES: 'spaces:personal',
|
PERSONAL_SPACES: 'spaces:personal',
|
||||||
|
DOCX_EXPORT: 'export:docx',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type FeatureKey = (typeof Feature)[keyof typeof Feature];
|
export type FeatureKey = (typeof Feature)[keyof typeof Feature];
|
||||||
|
|||||||
+1
-1
Submodule apps/server/src/ee updated: d20a3931cf...efd4d10dfd
+22
-21
@@ -19,15 +19,15 @@
|
|||||||
"clean": "rm -rf apps/*/dist packages/*/dist apps/client/node_modules/.vite"
|
"clean": "rm -rf apps/*/dist packages/*/dist apps/client/node_modules/.vite"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@braintree/sanitize-url": "^7.1.2",
|
"@braintree/sanitize-url": "7.1.2",
|
||||||
"@casl/ability": "6.8.0",
|
"@casl/ability": "6.8.0",
|
||||||
"@docmost/editor-ext": "workspace:*",
|
"@docmost/editor-ext": "workspace:*",
|
||||||
"@floating-ui/dom": "^1.7.3",
|
"@floating-ui/dom": "1.7.3",
|
||||||
"@hocuspocus/provider": "3.4.4",
|
"@hocuspocus/provider": "3.4.4",
|
||||||
"@hocuspocus/server": "3.4.4",
|
"@hocuspocus/server": "3.4.4",
|
||||||
"@hocuspocus/transformer": "3.4.4",
|
"@hocuspocus/transformer": "3.4.4",
|
||||||
"@joplin/turndown": "^4.0.82",
|
"@joplin/turndown": "4.0.82",
|
||||||
"@joplin/turndown-plugin-gfm": "^1.0.64",
|
"@joplin/turndown-plugin-gfm": "1.0.64",
|
||||||
"@sindresorhus/slugify": "3.0.0",
|
"@sindresorhus/slugify": "3.0.0",
|
||||||
"@tiptap/core": "3.20.4",
|
"@tiptap/core": "3.20.4",
|
||||||
"@tiptap/extension-audio": "3.20.4",
|
"@tiptap/extension-audio": "3.20.4",
|
||||||
@@ -58,31 +58,32 @@
|
|||||||
"@tiptap/starter-kit": "3.20.4",
|
"@tiptap/starter-kit": "3.20.4",
|
||||||
"@tiptap/suggestion": "3.20.4",
|
"@tiptap/suggestion": "3.20.4",
|
||||||
"@tiptap/y-tiptap": "3.0.2",
|
"@tiptap/y-tiptap": "3.0.2",
|
||||||
"bytes": "^3.1.2",
|
"bytes": "3.1.2",
|
||||||
"cross-env": "^10.1.0",
|
"cross-env": "10.1.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "4.1.0",
|
||||||
"diff": "8.0.3",
|
"diff": "8.0.3",
|
||||||
|
"docx": "9.7.1",
|
||||||
"dompurify": "3.4.1",
|
"dompurify": "3.4.1",
|
||||||
"fractional-indexing-jittered": "^1.0.0",
|
"fractional-indexing-jittered": "1.0.0",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "11.11.1",
|
||||||
"image-dimensions": "^2.5.0",
|
"image-dimensions": "2.5.0",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "3.10.1",
|
||||||
"linkifyjs": "^4.3.2",
|
"linkifyjs": "4.3.2",
|
||||||
"marked": "17.0.5",
|
"marked": "17.0.5",
|
||||||
"ms": "3.0.0-canary.1",
|
"ms": "3.0.0-canary.1",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "1.5.4",
|
||||||
"rfc6902": "5.2.0",
|
"rfc6902": "5.2.0",
|
||||||
"uuid": "^14.0.0",
|
"uuid": "14.0.0",
|
||||||
"y-indexeddb": "^9.0.12",
|
"y-indexeddb": "9.0.12",
|
||||||
"y-prosemirror": "1.3.7",
|
"y-prosemirror": "1.3.7",
|
||||||
"yjs": "^13.6.30"
|
"yjs": "^13.6.30"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nx/js": "22.6.1",
|
"@nx/js": "22.6.1",
|
||||||
"@types/bytes": "^3.1.5",
|
"@types/bytes": "3.1.5",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "1.5.6",
|
||||||
"@types/turndown": "^5.0.6",
|
"@types/turndown": "5.0.6",
|
||||||
"concurrently": "^9.2.1",
|
"concurrently": "9.2.1",
|
||||||
"nx": "22.6.1",
|
"nx": "22.6.1",
|
||||||
"tsx": "^4.21.0"
|
"tsx": "^4.21.0"
|
||||||
},
|
},
|
||||||
@@ -133,8 +134,8 @@
|
|||||||
"axios": "1.16.0",
|
"axios": "1.16.0",
|
||||||
"langsmith": "0.7.0",
|
"langsmith": "0.7.0",
|
||||||
"follow-redirects": "1.16.0",
|
"follow-redirects": "1.16.0",
|
||||||
"protobufjs": "7.5.8",
|
"protobufjs": "7.5.8",
|
||||||
"ip-address": "10.1.1"
|
"ip-address": "10.1.1"
|
||||||
},
|
},
|
||||||
"neverBuiltDependencies": []
|
"neverBuiltDependencies": []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"name": "@docmost/editor-ext",
|
"name": "@docmost/editor-ext",
|
||||||
"homepage": "https://docmost.com",
|
"homepage": "https://docmost.com",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"sideEffects": false,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc --build",
|
"build": "tsc --build",
|
||||||
"dev": "tsc --watch"
|
"dev": "tsc --watch"
|
||||||
|
|||||||
@@ -34,3 +34,7 @@ export * from "./lib/pdf";
|
|||||||
export * from "./lib/page-break";
|
export * from "./lib/page-break";
|
||||||
export * from "./lib/resizable-nodeview";
|
export * from "./lib/resizable-nodeview";
|
||||||
|
|
||||||
|
export {
|
||||||
|
pageNodeToDocxBuffer,
|
||||||
|
type DocxImageResolver,
|
||||||
|
} from "./lib/prosemirror-docx";
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
# `prosemirror-docx`
|
||||||
|
|
||||||
|
[](https://www.npmjs.com/package/prosemirror-docx)
|
||||||
|
[](https://github.com/curvenote/prosemirror-docx)
|
||||||
|
[
|
||||||
|
[](https://github.com/curvenote/prosemirror-docx/blob/master/LICENSE)
|
||||||
|

|
||||||
|
|
||||||
|
Export a [prosemirror](https://prosemirror.net/) document to a Microsoft Word file, using [docx](https://docx.js.org/).
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
`prosemirror-docx` has a similar structure to [prosemirror-markdown](https://github.com/prosemirror/prosemirror-markdown), with a `DocxSerializerState` object that you write to as you walk the document. It is a light wrapper around <https://docx.js.org/>, which actually does the export. Currently `prosemirror-docx` is write only (i.e. can export to, but can’t read from `*.docx`), and has most of the basic nodes covered (see below).
|
||||||
|
|
||||||
|
[Curvenote](https://curvenote.com) uses this to export from [@curvenote/editor](https://github.com/curvenote/editor) to word docs, but this library currently only has dependence on `docx`, `prosemirror-model` and `image-dimensions` - and similar to `prosemirror-markdown`, the serialization schema can be edited externally (see `Extended usage` below).
|
||||||
|
|
||||||
|
## Basic usage
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { defaultDocxSerializer, writeDocx } from 'prosemirror-docx';
|
||||||
|
import { EditorState } from 'prosemirror-state';
|
||||||
|
import { writeFileSync } from 'fs'; // Or some other way to write a file
|
||||||
|
|
||||||
|
// Set up your prosemirror state/document as you normally do
|
||||||
|
const state = EditorState.create({ schema: mySchema });
|
||||||
|
|
||||||
|
// If there are images, we will need to preload the buffers
|
||||||
|
const opts = {
|
||||||
|
getImageBuffer(src: string) {
|
||||||
|
return anImageBuffer;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create a doc in memory, and then write it to disk
|
||||||
|
const wordDocument = defaultDocxSerializer.serialize(state.doc, opts);
|
||||||
|
|
||||||
|
await writeDocx(wordDocument).then((buffer) => {
|
||||||
|
writeFileSync('HelloWorld.docx', buffer);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Advanced usage
|
||||||
|
|
||||||
|
If you need to access the underlying state and modify the final docx `Document` you can use the last argument of `serialize` to pass in a callback function that receives the `DocxSerializerState`.
|
||||||
|
|
||||||
|
This function needs to return an `IPropertiesOptions` type, ie. the config that should be passed to a `Document`. Your options will be spread with the default options, so you can override any of the defaults.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const wordDocument = defaultDocxSerializer.serialize(state.doc, opts, (state) => {
|
||||||
|
return {
|
||||||
|
numbering: {
|
||||||
|
config: state.numbering,
|
||||||
|
},
|
||||||
|
fonts: [], // embed fonts,
|
||||||
|
styles: {
|
||||||
|
paragraphStyles,
|
||||||
|
default: {
|
||||||
|
heading1: paragraphStyles[1],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
See the [docx documentation](https://docx.js.org/#/usage/document) for more details on the options you can pass in.
|
||||||
|
|
||||||
|
## Extended usage
|
||||||
|
|
||||||
|
Instead of using the `defaultDocxSerializer` you can override or provide custom serializers.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { DocxSerializer, defaultNodes, defaultMarks } from 'prosemirror-docx';
|
||||||
|
|
||||||
|
const nodeSerializer = {
|
||||||
|
...defaultNodes,
|
||||||
|
my_paragraph(state, node) {
|
||||||
|
state.renderInline(node);
|
||||||
|
state.closeBlock(node);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const myDocxSerializer = new DocxSerializer(nodeSerializer, defaultMarks);
|
||||||
|
```
|
||||||
|
|
||||||
|
The `state` is the `DocxSerializerState` and has helper methods to interact with `docx`.
|
||||||
|
|
||||||
|
If the exported content includes image links that require fetching the image data, you can use asynchronous APIs. Here's a demo example:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { DocxSerializerAsync, defaultAsyncNodes, defaultMarks } from 'prosemirror-docx';
|
||||||
|
import { EditorState } from 'prosemirror-state';
|
||||||
|
import { writeFileSync } from 'fs';
|
||||||
|
|
||||||
|
const state = EditorState.create({ schema: mySchema });
|
||||||
|
|
||||||
|
export const docxSerializer = new DocxSerializerAsync(
|
||||||
|
{
|
||||||
|
...defaultAsyncNodes,
|
||||||
|
async image(state, node) {
|
||||||
|
const { src } = node.attrs;
|
||||||
|
await state.image(src, 70, 'center', undefined, 'png');
|
||||||
|
state.closeBlock(node);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultMarks,
|
||||||
|
);
|
||||||
|
|
||||||
|
// If there are images, we will need to preload the buffers
|
||||||
|
const opts = {
|
||||||
|
async getImageBuffer(src: string) {
|
||||||
|
const arrayBuffer = await fetch(src).then((res) => res.arrayBuffer());
|
||||||
|
return new Uint8Array(arrayBuffer);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create a doc in memory, and then write it to disk
|
||||||
|
const wordDocument = docxSerializer.serializeAsync(state.doc, opts);
|
||||||
|
|
||||||
|
await writeDocx(wordDocument).then((buffer) => {
|
||||||
|
writeFileSync('HelloWorld.docx', buffer);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Supported Nodes
|
||||||
|
|
||||||
|
- text
|
||||||
|
- paragraph
|
||||||
|
- heading (levels)
|
||||||
|
- TODO: Support numbering of headings
|
||||||
|
- blockquote
|
||||||
|
- code_block
|
||||||
|
- TODO: No styles supported
|
||||||
|
- horizontal_rule
|
||||||
|
- hard_break
|
||||||
|
- ordered_list
|
||||||
|
- unordered_list
|
||||||
|
- list_item
|
||||||
|
- image
|
||||||
|
- math
|
||||||
|
- equations (numbered & unnumbered)
|
||||||
|
- tables
|
||||||
|
|
||||||
|
Planned:
|
||||||
|
|
||||||
|
- Internal References (e.g. see Table 1)
|
||||||
|
|
||||||
|
## Supported Marks
|
||||||
|
|
||||||
|
- em
|
||||||
|
- strong
|
||||||
|
- link
|
||||||
|
- Note: this is actually treated as a node in docx, so ignored as a prosemirror mark, but supported.
|
||||||
|
- code
|
||||||
|
- subscript
|
||||||
|
- superscript
|
||||||
|
- strikethrough
|
||||||
|
- underline
|
||||||
|
- smallcaps
|
||||||
|
- allcaps
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- [Prosemirror Docs](https://prosemirror.net/docs/)
|
||||||
|
- [docx](https://docx.js.org/)
|
||||||
|
- [prosemirror-markdown](https://github.com/ProseMirror/prosemirror-markdown) - similar implementation for markdown!
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// MIT - https://github.com/curvenote/prosemirror-docx/
|
||||||
|
export type { SectionConfig, SerializationState } from './types';
|
||||||
|
export type {
|
||||||
|
MarkSerializer,
|
||||||
|
NodeSerializer,
|
||||||
|
NodeSerializerAsync,
|
||||||
|
Options,
|
||||||
|
OptionsAsync,
|
||||||
|
} from './serializer';
|
||||||
|
|
||||||
|
export {
|
||||||
|
DocxSerializerStateAsync,
|
||||||
|
DocxSerializerAsync,
|
||||||
|
DocxSerializerState,
|
||||||
|
DocxSerializer,
|
||||||
|
MAX_IMAGE_WIDTH,
|
||||||
|
} from './serializer';
|
||||||
|
export {
|
||||||
|
defaultAsyncNodes,
|
||||||
|
defaultMarks,
|
||||||
|
pageNodeToDocxBuffer,
|
||||||
|
type DocxImageResolver,
|
||||||
|
} from './schema';
|
||||||
|
export { writeDocx, createDocFromState, buildDoc } from './utils';
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { AlignmentType, convertInchesToTwip, ILevelsOptions, LevelFormat } from 'docx';
|
||||||
|
import { INumbering } from './types';
|
||||||
|
|
||||||
|
function basicIndentStyle(indent: number): Pick<ILevelsOptions, 'style' | 'alignment'> {
|
||||||
|
return {
|
||||||
|
alignment: AlignmentType.START,
|
||||||
|
style: {
|
||||||
|
paragraph: {
|
||||||
|
indent: { left: convertInchesToTwip(indent), hanging: convertInchesToTwip(0.18) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const numbered = Array(3)
|
||||||
|
.fill([LevelFormat.DECIMAL, LevelFormat.LOWER_LETTER, LevelFormat.LOWER_ROMAN])
|
||||||
|
.flat()
|
||||||
|
.map((format, level) => ({
|
||||||
|
level,
|
||||||
|
format,
|
||||||
|
text: `%${level + 1}.`,
|
||||||
|
...basicIndentStyle((level + 1) / 2),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const bullets = Array(3)
|
||||||
|
.fill(['●', '○', '■'])
|
||||||
|
.flat()
|
||||||
|
.map((text, level) => ({
|
||||||
|
level,
|
||||||
|
format: LevelFormat.BULLET,
|
||||||
|
text,
|
||||||
|
...basicIndentStyle((level + 1) / 2),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
numbered,
|
||||||
|
bullets,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NumberingStyles = keyof typeof styles;
|
||||||
|
|
||||||
|
export function createNumbering(reference: string, style: NumberingStyles): INumbering {
|
||||||
|
return {
|
||||||
|
reference,
|
||||||
|
levels: styles[style],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import { HeadingLevel, ShadingType } from 'docx';
|
||||||
|
import { Node } from 'prosemirror-model';
|
||||||
|
import {
|
||||||
|
DocxSerializerAsync,
|
||||||
|
MarkSerializer,
|
||||||
|
NodeSerializerAsync,
|
||||||
|
OptionsAsync,
|
||||||
|
} from './serializer';
|
||||||
|
import { writeDocx } from './utils';
|
||||||
|
|
||||||
|
export type DocxImageResolver = OptionsAsync['getImageBuffer'];
|
||||||
|
|
||||||
|
// docx requires a 6-digit hex color (no leading #). Convert #rgb, #rrggbb,
|
||||||
|
// and rgb()/rgba() inputs to 6-digit hex; return undefined for anything else
|
||||||
|
// (named colors, hsl, etc.) so the caller omits the color rather than letting
|
||||||
|
// docx throw "Invalid hex value".
|
||||||
|
function toDocxColor(input?: string): string | undefined {
|
||||||
|
if (!input) return undefined;
|
||||||
|
const value = input.trim().toLowerCase();
|
||||||
|
const hex = value.startsWith('#') ? value.slice(1) : value;
|
||||||
|
if (/^[0-9a-f]{6}$/.test(hex)) return hex;
|
||||||
|
if (/^[0-9a-f]{3}$/.test(hex)) {
|
||||||
|
return hex
|
||||||
|
.split('')
|
||||||
|
.map((ch) => ch + ch)
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
const rgb = value.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
|
||||||
|
if (rgb) {
|
||||||
|
const channel = (n: string) =>
|
||||||
|
Math.max(0, Math.min(255, parseInt(n, 10)))
|
||||||
|
.toString(16)
|
||||||
|
.padStart(2, '0');
|
||||||
|
return channel(rgb[1]) + channel(rgb[2]) + channel(rgb[3]);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Images and diagrams embed via the image resolver; the URL (with its file
|
||||||
|
// extension) is passed through so docx can infer the image type.
|
||||||
|
const renderImage: NodeSerializerAsync[string] = async (state, node) => {
|
||||||
|
const src = node.attrs?.src || node.attrs?.attachmentId;
|
||||||
|
if (src) {
|
||||||
|
try {
|
||||||
|
await state.image(src, 100);
|
||||||
|
} catch {
|
||||||
|
// Unrenderable/missing image: skip rather than fail the whole export.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.closeBlock(node);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Non-embeddable media render as a labelled line.
|
||||||
|
const renderFileLine: NodeSerializerAsync[string] = (state, node) => {
|
||||||
|
const label =
|
||||||
|
node.attrs?.name || node.attrs?.src || node.attrs?.url || 'attachment';
|
||||||
|
state.text(label);
|
||||||
|
state.closeBlock(node);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderEmbedLine: NodeSerializerAsync[string] = (state, node) => {
|
||||||
|
const label = node.attrs?.src || node.attrs?.url || 'embed';
|
||||||
|
state.text(label);
|
||||||
|
state.closeBlock(node);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const defaultAsyncNodes: NodeSerializerAsync = {
|
||||||
|
text(state, node) {
|
||||||
|
state.text(node.text ?? '');
|
||||||
|
},
|
||||||
|
async paragraph(state, node) {
|
||||||
|
await state.renderInline(node);
|
||||||
|
state.closeBlock(node);
|
||||||
|
},
|
||||||
|
async heading(state, node) {
|
||||||
|
await state.renderInline(node);
|
||||||
|
const heading = [
|
||||||
|
HeadingLevel.HEADING_1,
|
||||||
|
HeadingLevel.HEADING_2,
|
||||||
|
HeadingLevel.HEADING_3,
|
||||||
|
HeadingLevel.HEADING_4,
|
||||||
|
HeadingLevel.HEADING_5,
|
||||||
|
HeadingLevel.HEADING_6,
|
||||||
|
][(node.attrs.level ?? 1) - 1];
|
||||||
|
state.closeBlock(node, { heading });
|
||||||
|
},
|
||||||
|
async blockquote(state, node) {
|
||||||
|
await state.renderContent(node, { style: 'IntenseQuote' });
|
||||||
|
},
|
||||||
|
async codeBlock(state, node) {
|
||||||
|
await state.renderContent(node);
|
||||||
|
state.closeBlock(node);
|
||||||
|
},
|
||||||
|
horizontalRule(state, node) {
|
||||||
|
state.closeBlock(node, { thematicBreak: true });
|
||||||
|
state.closeBlock(node);
|
||||||
|
},
|
||||||
|
hardBreak(state) {
|
||||||
|
state.addRunOptions({ break: 1 });
|
||||||
|
},
|
||||||
|
async bulletList(state, node) {
|
||||||
|
await state.renderList(node, 'bullets');
|
||||||
|
},
|
||||||
|
async orderedList(state, node) {
|
||||||
|
await state.renderList(node, 'numbered');
|
||||||
|
},
|
||||||
|
async listItem(state, node) {
|
||||||
|
await state.renderListItem(node);
|
||||||
|
},
|
||||||
|
async taskList(state, node) {
|
||||||
|
await state.renderList(node, 'bullets');
|
||||||
|
},
|
||||||
|
async taskItem(state, node) {
|
||||||
|
if (state.currentNumbering) {
|
||||||
|
state.addParagraphOptions({ numbering: state.currentNumbering });
|
||||||
|
}
|
||||||
|
state.text(node.attrs?.checked ? '☑ ' : '☐ ');
|
||||||
|
await state.renderContent(node);
|
||||||
|
},
|
||||||
|
async table(state, node) {
|
||||||
|
await state.table(node);
|
||||||
|
},
|
||||||
|
// Docmost stores LaTeX in attrs.text.
|
||||||
|
mathInline(state, node) {
|
||||||
|
state.math(node.attrs?.text ?? '', { inline: true });
|
||||||
|
},
|
||||||
|
mathBlock(state, node) {
|
||||||
|
state.math(node.attrs?.text ?? '', { inline: false, numbered: false });
|
||||||
|
state.closeBlock(node);
|
||||||
|
},
|
||||||
|
image: renderImage,
|
||||||
|
drawio: renderImage,
|
||||||
|
excalidraw: renderImage,
|
||||||
|
video: renderFileLine,
|
||||||
|
audio: renderFileLine,
|
||||||
|
pdf: renderFileLine,
|
||||||
|
attachment: renderFileLine,
|
||||||
|
embed: renderEmbedLine,
|
||||||
|
youtube: renderEmbedLine,
|
||||||
|
async callout(state, node) {
|
||||||
|
await state.renderContent(node, { style: 'IntenseQuote' });
|
||||||
|
},
|
||||||
|
async details(state, node) {
|
||||||
|
await state.renderContent(node);
|
||||||
|
},
|
||||||
|
async detailsSummary(state, node) {
|
||||||
|
await state.renderInline(node);
|
||||||
|
state.closeBlock(node, { heading: HeadingLevel.HEADING_4 });
|
||||||
|
},
|
||||||
|
async detailsContent(state, node) {
|
||||||
|
await state.renderContent(node);
|
||||||
|
},
|
||||||
|
async columns(state, node) {
|
||||||
|
await state.renderContent(node);
|
||||||
|
},
|
||||||
|
async column(state, node) {
|
||||||
|
await state.renderContent(node);
|
||||||
|
},
|
||||||
|
async transclusionSource(state, node) {
|
||||||
|
await state.renderContent(node);
|
||||||
|
},
|
||||||
|
mention(state, node) {
|
||||||
|
state.text(`@${node.attrs?.label ?? ''}`);
|
||||||
|
},
|
||||||
|
status(state, node) {
|
||||||
|
state.text(`[${node.attrs?.text ?? ''}]`);
|
||||||
|
},
|
||||||
|
pageBreak(state, node) {
|
||||||
|
state.closeBlock(node, { pageBreakBefore: true });
|
||||||
|
},
|
||||||
|
// No usable static export representation: skip without failing.
|
||||||
|
subpages() {},
|
||||||
|
transclusionReference() {},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const defaultMarks: MarkSerializer = {
|
||||||
|
bold() {
|
||||||
|
return { bold: true };
|
||||||
|
},
|
||||||
|
italic() {
|
||||||
|
return { italics: true };
|
||||||
|
},
|
||||||
|
strike() {
|
||||||
|
return { strike: true };
|
||||||
|
},
|
||||||
|
underline() {
|
||||||
|
return { underline: {} };
|
||||||
|
},
|
||||||
|
code() {
|
||||||
|
return {
|
||||||
|
font: { name: 'Monospace' },
|
||||||
|
color: '000000',
|
||||||
|
shading: { type: ShadingType.SOLID, color: 'D2D3D2', fill: 'D2D3D2' },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
superscript() {
|
||||||
|
return { superScript: true };
|
||||||
|
},
|
||||||
|
subscript() {
|
||||||
|
return { subScript: true };
|
||||||
|
},
|
||||||
|
link() {
|
||||||
|
// Handled specifically in the serializer; Word treats links as nodes.
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
highlight(_state, _node, mark) {
|
||||||
|
const fill = toDocxColor(mark.attrs?.color);
|
||||||
|
return fill
|
||||||
|
? { shading: { type: ShadingType.CLEAR, fill } }
|
||||||
|
: { highlight: 'yellow' };
|
||||||
|
},
|
||||||
|
// @tiptap/extension-color stores the color on the textStyle mark.
|
||||||
|
textStyle(_state, _node, mark) {
|
||||||
|
const color = toDocxColor(mark.attrs?.color);
|
||||||
|
return color ? { color } : {};
|
||||||
|
},
|
||||||
|
// Comments are editor-only; drop the annotation in the export.
|
||||||
|
comment() {
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function pageNodeToDocxBuffer(
|
||||||
|
doc: Node,
|
||||||
|
getImageBuffer: DocxImageResolver,
|
||||||
|
): Promise<Buffer> {
|
||||||
|
const serializer = new DocxSerializerAsync(defaultAsyncNodes, defaultMarks);
|
||||||
|
const wordDoc = await serializer.serializeAsync(
|
||||||
|
doc,
|
||||||
|
{ getImageBuffer },
|
||||||
|
// docx's built-in heading styles are blue (#2E74B5 / #1F4D78). The editor
|
||||||
|
// has no heading color, so override the default heading run colors to the
|
||||||
|
// normal text color. Sizes/italics mirror docx's own defaults so only the
|
||||||
|
// color changes.
|
||||||
|
() =>
|
||||||
|
({
|
||||||
|
styles: {
|
||||||
|
default: {
|
||||||
|
heading1: { run: { color: '000000', size: 32 } },
|
||||||
|
heading2: { run: { color: '000000', size: 26 } },
|
||||||
|
heading3: { run: { color: '000000', size: 24 } },
|
||||||
|
heading4: { run: { color: '000000', italics: true } },
|
||||||
|
heading5: { run: { color: '000000' } },
|
||||||
|
heading6: { run: { color: '000000' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}) as any,
|
||||||
|
);
|
||||||
|
return writeDocx(wordDoc);
|
||||||
|
}
|
||||||
@@ -0,0 +1,925 @@
|
|||||||
|
import { Node, Mark } from 'prosemirror-model';
|
||||||
|
import {
|
||||||
|
IParagraphOptions,
|
||||||
|
IRunOptions,
|
||||||
|
Paragraph,
|
||||||
|
TextRun,
|
||||||
|
ExternalHyperlink,
|
||||||
|
ParagraphChild,
|
||||||
|
MathRun,
|
||||||
|
Math,
|
||||||
|
TabStopType,
|
||||||
|
TabStopPosition,
|
||||||
|
SequentialIdentifier,
|
||||||
|
Bookmark,
|
||||||
|
ImageRun,
|
||||||
|
AlignmentType,
|
||||||
|
Table,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
ITableCellOptions,
|
||||||
|
InternalHyperlink,
|
||||||
|
SimpleField,
|
||||||
|
FootnoteReferenceRun,
|
||||||
|
IImageOptions,
|
||||||
|
Document,
|
||||||
|
ITableOptions,
|
||||||
|
ITableRowOptions,
|
||||||
|
IPropertiesOptions,
|
||||||
|
} from 'docx';
|
||||||
|
import { imageDimensionsFromData } from 'image-dimensions';
|
||||||
|
import { createNumbering, NumberingStyles } from './numbering';
|
||||||
|
import { buildDoc, createShortId } from './utils';
|
||||||
|
import { IFootnotes, INumbering, Mutable, SectionConfig, SerializationState } from './types';
|
||||||
|
|
||||||
|
// This is duplicated from @curvenote/schema
|
||||||
|
export type AlignOptions = 'left' | 'center' | 'right';
|
||||||
|
|
||||||
|
export type NodeSerializer = Record<
|
||||||
|
string,
|
||||||
|
(state: DocxSerializerState, node: Node, parent: Node, index: number) => void
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type NodeSerializerAsync = Record<
|
||||||
|
string,
|
||||||
|
(state: DocxSerializerStateAsync, node: Node, parent: Node, index: number) => void | Promise<void>
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type MarkSerializer = Record<
|
||||||
|
string,
|
||||||
|
(state: DocxSerializerState | DocxSerializerStateAsync, node: Node, mark: Mark) => IRunOptions
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type Options = {
|
||||||
|
getImageBuffer: (src: string) => Uint8Array;
|
||||||
|
sections?: SectionConfig[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OptionsAsync = {
|
||||||
|
getImageBuffer: (src: string) => Uint8Array | Promise<Uint8Array>;
|
||||||
|
sections?: SectionConfig[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IMathOpts = {
|
||||||
|
inline?: boolean;
|
||||||
|
id?: string | null;
|
||||||
|
numbered?: boolean;
|
||||||
|
};
|
||||||
|
export type ImageType = 'jpg' | 'png' | 'gif' | 'bmp';
|
||||||
|
|
||||||
|
export const MAX_IMAGE_WIDTH = 600;
|
||||||
|
|
||||||
|
function createReferenceBookmark(
|
||||||
|
id: string,
|
||||||
|
kind: 'Equation' | 'Figure' | 'Table',
|
||||||
|
before?: string,
|
||||||
|
after?: string,
|
||||||
|
) {
|
||||||
|
const textBefore = before ? [new TextRun(before)] : [];
|
||||||
|
const textAfter = after ? [new TextRun(after)] : [];
|
||||||
|
return new Bookmark({
|
||||||
|
id,
|
||||||
|
children: [...textBefore, new SequentialIdentifier(kind), ...textAfter],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DocxSerializerState {
|
||||||
|
nodes: NodeSerializer;
|
||||||
|
|
||||||
|
options: Options;
|
||||||
|
|
||||||
|
marks: MarkSerializer;
|
||||||
|
|
||||||
|
children: (Paragraph | Table)[];
|
||||||
|
|
||||||
|
sections: Array<{
|
||||||
|
config: SectionConfig;
|
||||||
|
children: (Paragraph | Table)[];
|
||||||
|
}>;
|
||||||
|
|
||||||
|
currentSectionIndex = 0;
|
||||||
|
|
||||||
|
numbering: INumbering[];
|
||||||
|
|
||||||
|
footnotes: IFootnotes = {};
|
||||||
|
|
||||||
|
nextRunOpts?: IRunOptions;
|
||||||
|
|
||||||
|
current: ParagraphChild[] = [];
|
||||||
|
|
||||||
|
currentLink?: { link: string; children: IRunOptions[] };
|
||||||
|
|
||||||
|
// Optionally add options
|
||||||
|
nextParentParagraphOpts?: IParagraphOptions;
|
||||||
|
|
||||||
|
currentNumbering?: { reference: string; level: number };
|
||||||
|
|
||||||
|
constructor(nodes: NodeSerializer, marks: MarkSerializer, options: Options) {
|
||||||
|
this.nodes = nodes;
|
||||||
|
this.marks = marks;
|
||||||
|
this.options = options ?? ({} as Options);
|
||||||
|
this.children = [];
|
||||||
|
this.numbering = [];
|
||||||
|
|
||||||
|
// Initialize sections
|
||||||
|
if (options.sections && options.sections.length > 0) {
|
||||||
|
this.sections = options.sections.map((config) => ({
|
||||||
|
config,
|
||||||
|
children: [],
|
||||||
|
}));
|
||||||
|
this.children = this.sections[0].children;
|
||||||
|
} else {
|
||||||
|
this.sections = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderContent(parent: Node, opts?: IParagraphOptions) {
|
||||||
|
parent.forEach((node, _, i) => {
|
||||||
|
if (opts) this.addParagraphOptions(opts);
|
||||||
|
this.render(node, parent, i);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
render(node: Node, parent: Node, index: number) {
|
||||||
|
if (typeof parent === 'number') throw new Error('!');
|
||||||
|
if (!this.nodes[node.type.name])
|
||||||
|
throw new Error(`Token type \`${node.type.name}\` not supported by Word renderer`);
|
||||||
|
this.nodes[node.type.name](this, node, parent, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderMarks(node: Node, marks: Mark[]): IRunOptions {
|
||||||
|
return marks
|
||||||
|
.map((mark) => {
|
||||||
|
return this.marks[mark.type.name]?.(this, node, mark);
|
||||||
|
})
|
||||||
|
.reduce((a, b) => ({ ...a, ...b }), {});
|
||||||
|
}
|
||||||
|
|
||||||
|
renderInline(parent: Node) {
|
||||||
|
// Pop the stack over to this object when we encounter a link, and closeLink restores it
|
||||||
|
let currentLink: { link: string; stack: ParagraphChild[] } | undefined;
|
||||||
|
const closeLink = () => {
|
||||||
|
if (!currentLink) return;
|
||||||
|
const hyperlink = new ExternalHyperlink({
|
||||||
|
link: currentLink.link,
|
||||||
|
// child: this.current[0],
|
||||||
|
children: this.current,
|
||||||
|
});
|
||||||
|
this.current = [...currentLink.stack, hyperlink];
|
||||||
|
currentLink = undefined;
|
||||||
|
};
|
||||||
|
const openLink = (href: string) => {
|
||||||
|
const sameLink = href === currentLink?.link;
|
||||||
|
this.addRunOptions({ style: 'Hyperlink' });
|
||||||
|
// TODO: https://github.com/dolanmiu/docx/issues/1119
|
||||||
|
// Remove the if statement here and oneLink!
|
||||||
|
const oneLink = true;
|
||||||
|
if (!oneLink) {
|
||||||
|
closeLink();
|
||||||
|
} else {
|
||||||
|
if (currentLink && sameLink) return;
|
||||||
|
if (currentLink && !sameLink) {
|
||||||
|
// Close previous, and open a new one
|
||||||
|
closeLink();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
currentLink = {
|
||||||
|
link: href,
|
||||||
|
stack: this.current,
|
||||||
|
};
|
||||||
|
this.current = [];
|
||||||
|
};
|
||||||
|
const progress = (node: Node, offset: number, index: number) => {
|
||||||
|
const links = node.marks.filter((m) => m.type.name === 'link');
|
||||||
|
const hasLink = links.length > 0;
|
||||||
|
if (hasLink) {
|
||||||
|
openLink(links[0].attrs.href);
|
||||||
|
} else if (!hasLink && currentLink) {
|
||||||
|
closeLink();
|
||||||
|
}
|
||||||
|
if (node.isText) {
|
||||||
|
this.text(node.text, this.renderMarks(node, [...node.marks]));
|
||||||
|
} else {
|
||||||
|
this.render(node, parent, index);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
parent.forEach(progress);
|
||||||
|
// Must call close at the end of everything, just in case
|
||||||
|
closeLink();
|
||||||
|
}
|
||||||
|
|
||||||
|
renderList(node: Node, style: NumberingStyles) {
|
||||||
|
if (!this.currentNumbering) {
|
||||||
|
const nextId = createShortId();
|
||||||
|
this.numbering.push(createNumbering(nextId, style));
|
||||||
|
this.currentNumbering = { reference: nextId, level: 0 };
|
||||||
|
} else {
|
||||||
|
const { reference, level } = this.currentNumbering;
|
||||||
|
this.currentNumbering = { reference, level: level + 1 };
|
||||||
|
}
|
||||||
|
this.renderContent(node);
|
||||||
|
if (this.currentNumbering.level === 0) {
|
||||||
|
delete this.currentNumbering;
|
||||||
|
} else {
|
||||||
|
const { reference, level } = this.currentNumbering;
|
||||||
|
this.currentNumbering = { reference, level: level - 1 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is a pass through to the paragraphs, etc. underneath they will close the block
|
||||||
|
renderListItem(node: Node) {
|
||||||
|
if (!this.currentNumbering) throw new Error('Trying to create a list item without a list?');
|
||||||
|
this.addParagraphOptions({ numbering: this.currentNumbering });
|
||||||
|
this.renderContent(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
addParagraphOptions(opts: IParagraphOptions) {
|
||||||
|
this.nextParentParagraphOpts = { ...this.nextParentParagraphOpts, ...opts };
|
||||||
|
}
|
||||||
|
|
||||||
|
addRunOptions(opts: IRunOptions) {
|
||||||
|
this.nextRunOpts = { ...this.nextRunOpts, ...opts };
|
||||||
|
}
|
||||||
|
|
||||||
|
text(text: string | null | undefined, opts?: IRunOptions) {
|
||||||
|
if (!text) return;
|
||||||
|
this.current.push(new TextRun({ text, ...this.nextRunOpts, ...opts }));
|
||||||
|
delete this.nextRunOpts;
|
||||||
|
}
|
||||||
|
|
||||||
|
math(latex: string, opts: IMathOpts = { inline: true }) {
|
||||||
|
if (opts.inline || !opts.numbered) {
|
||||||
|
this.current.push(new Math({ children: [new MathRun(latex)] }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = opts.id ?? createShortId();
|
||||||
|
this.current = [
|
||||||
|
new TextRun('\t'),
|
||||||
|
new Math({
|
||||||
|
children: [new MathRun(latex)],
|
||||||
|
}),
|
||||||
|
new TextRun('\t('),
|
||||||
|
createReferenceBookmark(id, 'Equation'),
|
||||||
|
new TextRun(')'),
|
||||||
|
];
|
||||||
|
this.addParagraphOptions({
|
||||||
|
tabStops: [
|
||||||
|
{
|
||||||
|
type: TabStopType.CENTER,
|
||||||
|
position: TabStopPosition.MAX / 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: TabStopType.RIGHT,
|
||||||
|
position: TabStopPosition.MAX,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// not sure what this actually is, seems to be close for 8.5x11
|
||||||
|
maxImageWidth = MAX_IMAGE_WIDTH;
|
||||||
|
|
||||||
|
image(
|
||||||
|
src: string,
|
||||||
|
widthPercent = 70,
|
||||||
|
align: AlignOptions = 'center',
|
||||||
|
imageRunOpts?: IImageOptions,
|
||||||
|
imageType?: ImageType,
|
||||||
|
) {
|
||||||
|
const buffer = this.options.getImageBuffer(src);
|
||||||
|
const dimensions = imageDimensionsFromData(buffer);
|
||||||
|
/* If the image is not a valid image, don't add it */
|
||||||
|
if (!dimensions) return;
|
||||||
|
const aspect = dimensions.height / dimensions.width;
|
||||||
|
const width = this.maxImageWidth * (widthPercent / 100);
|
||||||
|
let it;
|
||||||
|
try {
|
||||||
|
it = imageType || (src.replace(/.*\./, '').toLowerCase() as any);
|
||||||
|
} catch (e) {
|
||||||
|
it = 'png';
|
||||||
|
}
|
||||||
|
this.current.push(
|
||||||
|
new ImageRun({
|
||||||
|
data: buffer,
|
||||||
|
...imageRunOpts,
|
||||||
|
type: it,
|
||||||
|
transformation: {
|
||||||
|
...(imageRunOpts?.transformation || {}),
|
||||||
|
width,
|
||||||
|
height: width * aspect,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let alignment: string;
|
||||||
|
switch (align) {
|
||||||
|
case 'right':
|
||||||
|
alignment = AlignmentType.RIGHT;
|
||||||
|
break;
|
||||||
|
case 'left':
|
||||||
|
alignment = AlignmentType.LEFT;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
alignment = AlignmentType.CENTER;
|
||||||
|
}
|
||||||
|
this.addParagraphOptions({
|
||||||
|
alignment: alignment as any,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
table(
|
||||||
|
node: Node,
|
||||||
|
opts: {
|
||||||
|
getCellOptions?: (cell: Node) => ITableCellOptions;
|
||||||
|
getRowOptions?: (row: Node) => Omit<ITableRowOptions, 'children'>;
|
||||||
|
tableOptions?: Omit<ITableOptions, 'rows'>;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const { getCellOptions, getRowOptions, tableOptions } = opts;
|
||||||
|
const actualChildren = this.children;
|
||||||
|
const rows: TableRow[] = [];
|
||||||
|
node.content.forEach((row) => {
|
||||||
|
const cells: TableCell[] = [];
|
||||||
|
// Check if all cells are headers in this row
|
||||||
|
let tableHeader = true;
|
||||||
|
row.content.forEach((cell) => {
|
||||||
|
if (cell.type.name !== 'tableHeader') {
|
||||||
|
tableHeader = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// This scales images inside of tables
|
||||||
|
this.maxImageWidth = MAX_IMAGE_WIDTH / row.content.childCount;
|
||||||
|
row.content.forEach((cell) => {
|
||||||
|
this.children = [];
|
||||||
|
this.renderContent(cell);
|
||||||
|
const tableCellOpts: Mutable<ITableCellOptions> = { children: this.children };
|
||||||
|
const colspan = cell.attrs.colspan ?? 1;
|
||||||
|
const rowspan = cell.attrs.rowspan ?? 1;
|
||||||
|
if (colspan > 1) tableCellOpts.columnSpan = colspan;
|
||||||
|
if (rowspan > 1) tableCellOpts.rowSpan = rowspan;
|
||||||
|
cells.push(
|
||||||
|
new TableCell({
|
||||||
|
...tableCellOpts,
|
||||||
|
...(getCellOptions?.(cell) || {}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
rows.push(new TableRow({ ...(getRowOptions?.(row) || {}), children: cells, tableHeader }));
|
||||||
|
});
|
||||||
|
this.maxImageWidth = MAX_IMAGE_WIDTH;
|
||||||
|
const table = new Table({ ...tableOptions, rows });
|
||||||
|
actualChildren.push(table);
|
||||||
|
// If there are multiple tables, this seperates them
|
||||||
|
actualChildren.push(new Paragraph(''));
|
||||||
|
this.children = actualChildren;
|
||||||
|
}
|
||||||
|
|
||||||
|
captionLabel(id: string, kind: 'Figure' | 'Table', { suffix } = { suffix: ': ' }) {
|
||||||
|
this.current.push(...[createReferenceBookmark(id, kind, `${kind} `), new TextRun(suffix)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$footnoteCounter = 0;
|
||||||
|
|
||||||
|
footnote(node: Node) {
|
||||||
|
const { current, nextRunOpts } = this;
|
||||||
|
// Delete everything and work with the footnote inline on the current
|
||||||
|
this.current = [];
|
||||||
|
delete this.nextRunOpts;
|
||||||
|
|
||||||
|
this.$footnoteCounter += 1;
|
||||||
|
this.renderInline(node);
|
||||||
|
this.footnotes[this.$footnoteCounter] = {
|
||||||
|
children: [new Paragraph({ children: this.current })],
|
||||||
|
};
|
||||||
|
this.current = current;
|
||||||
|
this.nextRunOpts = nextRunOpts;
|
||||||
|
this.current.push(new FootnoteReferenceRun(this.$footnoteCounter));
|
||||||
|
}
|
||||||
|
|
||||||
|
closeBlock(node: Node, props?: IParagraphOptions) {
|
||||||
|
const paragraph = new Paragraph({
|
||||||
|
children: this.current,
|
||||||
|
...this.nextParentParagraphOpts,
|
||||||
|
...props,
|
||||||
|
});
|
||||||
|
this.current = [];
|
||||||
|
delete this.nextParentParagraphOpts;
|
||||||
|
this.children.push(paragraph);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move to the next section. If no more sections are available,
|
||||||
|
* this will be ignored (content continues in current section).
|
||||||
|
*/
|
||||||
|
nextSection() {
|
||||||
|
if (this.currentSectionIndex < this.sections.length - 1) {
|
||||||
|
this.currentSectionIndex += 1;
|
||||||
|
this.children = this.sections[this.currentSectionIndex].children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the current section's configuration
|
||||||
|
*/
|
||||||
|
setSectionConfig(config: Partial<SectionConfig>) {
|
||||||
|
this.sections[this.currentSectionIndex].config = {
|
||||||
|
...this.sections[this.currentSectionIndex].config,
|
||||||
|
...config,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new section with the given configuration and switch to it
|
||||||
|
*/
|
||||||
|
addSection(config: SectionConfig = {}) {
|
||||||
|
this.sections.push({
|
||||||
|
config,
|
||||||
|
children: [],
|
||||||
|
});
|
||||||
|
this.currentSectionIndex = this.sections.length - 1;
|
||||||
|
this.children = this.sections[this.currentSectionIndex].children;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current section index
|
||||||
|
*/
|
||||||
|
getCurrentSectionIndex(): number {
|
||||||
|
return this.currentSectionIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current section configuration
|
||||||
|
*/
|
||||||
|
getCurrentSectionConfig(): SectionConfig {
|
||||||
|
return this.sections[this.currentSectionIndex].config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current serialization state for document creation
|
||||||
|
*/
|
||||||
|
getSerializationState(): SerializationState {
|
||||||
|
return {
|
||||||
|
numbering: this.numbering,
|
||||||
|
sections: this.sections,
|
||||||
|
footnotes: this.footnotes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
createReference(id: string, before?: string, after?: string) {
|
||||||
|
const children: ParagraphChild[] = [];
|
||||||
|
if (before) children.push(new TextRun(before));
|
||||||
|
children.push(new SimpleField(`REF ${id} \\h`));
|
||||||
|
if (after) children.push(new TextRun(after));
|
||||||
|
const ref = new InternalHyperlink({ anchor: id, children });
|
||||||
|
this.current.push(ref);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DocxSerializer {
|
||||||
|
nodes: NodeSerializer;
|
||||||
|
|
||||||
|
marks: MarkSerializer;
|
||||||
|
|
||||||
|
constructor(nodes: NodeSerializer, marks: MarkSerializer) {
|
||||||
|
this.nodes = nodes;
|
||||||
|
this.marks = marks;
|
||||||
|
}
|
||||||
|
|
||||||
|
serialize(
|
||||||
|
content: Node,
|
||||||
|
options: Options,
|
||||||
|
getDocumentOptions?: (state: SerializationState) => IPropertiesOptions,
|
||||||
|
): Document {
|
||||||
|
const state = new DocxSerializerState(this.nodes, this.marks, options);
|
||||||
|
state.renderContent(content);
|
||||||
|
return buildDoc(state, getDocumentOptions?.(state));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DocxSerializerStateAsync {
|
||||||
|
nodes: NodeSerializerAsync;
|
||||||
|
|
||||||
|
options: OptionsAsync;
|
||||||
|
|
||||||
|
marks: MarkSerializer;
|
||||||
|
|
||||||
|
children: (Paragraph | Table)[];
|
||||||
|
|
||||||
|
sections: Array<{
|
||||||
|
config: SectionConfig;
|
||||||
|
children: (Paragraph | Table)[];
|
||||||
|
}>;
|
||||||
|
|
||||||
|
currentSectionIndex = 0;
|
||||||
|
|
||||||
|
numbering: INumbering[];
|
||||||
|
|
||||||
|
footnotes: IFootnotes = {};
|
||||||
|
|
||||||
|
nextRunOpts?: IRunOptions;
|
||||||
|
|
||||||
|
current: ParagraphChild[] = [];
|
||||||
|
|
||||||
|
currentLink?: { link: string; children: IRunOptions[] };
|
||||||
|
|
||||||
|
// Optionally add options
|
||||||
|
nextParentParagraphOpts?: IParagraphOptions;
|
||||||
|
|
||||||
|
currentNumbering?: { reference: string; level: number };
|
||||||
|
|
||||||
|
constructor(nodes: NodeSerializerAsync, marks: MarkSerializer, options: OptionsAsync) {
|
||||||
|
this.nodes = nodes;
|
||||||
|
this.marks = marks;
|
||||||
|
this.options = options ?? ({} as OptionsAsync);
|
||||||
|
this.children = [];
|
||||||
|
this.numbering = [];
|
||||||
|
|
||||||
|
// Initialize sections
|
||||||
|
if (options.sections && options.sections.length > 0) {
|
||||||
|
this.sections = options.sections.map((config) => ({
|
||||||
|
config,
|
||||||
|
children: [],
|
||||||
|
}));
|
||||||
|
this.children = this.sections[0].children;
|
||||||
|
} else {
|
||||||
|
this.sections = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async renderContent(parent: Node, opts?: IParagraphOptions) {
|
||||||
|
for (let i = 0; i < parent.childCount; i += 1) {
|
||||||
|
const node = parent.child(i);
|
||||||
|
if (opts) this.addParagraphOptions(opts);
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await this.render(node, parent, i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async render(node: Node, parent: Node, index: number) {
|
||||||
|
if (typeof parent === 'number') throw new Error('!');
|
||||||
|
if (!this.nodes[node.type.name])
|
||||||
|
throw new Error(`Token type \`${node.type.name}\` not supported by Word renderer`);
|
||||||
|
await Promise.resolve(this.nodes[node.type.name](this, node, parent, index));
|
||||||
|
}
|
||||||
|
|
||||||
|
renderMarks(node: Node, marks: Mark[]): IRunOptions {
|
||||||
|
return marks
|
||||||
|
.map((mark) => {
|
||||||
|
return this.marks[mark.type.name]?.(this, node, mark);
|
||||||
|
})
|
||||||
|
.reduce((a, b) => ({ ...a, ...b }), {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async renderInline(parent: Node) {
|
||||||
|
// Pop the stack over to this object when we encounter a link, and closeLink restores it
|
||||||
|
let currentLink: { link: string; stack: ParagraphChild[] } | undefined;
|
||||||
|
const closeLink = () => {
|
||||||
|
if (!currentLink) return;
|
||||||
|
const hyperlink = new ExternalHyperlink({
|
||||||
|
link: currentLink.link,
|
||||||
|
// child: this.current[0],
|
||||||
|
children: this.current,
|
||||||
|
});
|
||||||
|
this.current = [...currentLink.stack, hyperlink];
|
||||||
|
currentLink = undefined;
|
||||||
|
};
|
||||||
|
const openLink = (href: string) => {
|
||||||
|
const sameLink = href === currentLink?.link;
|
||||||
|
this.addRunOptions({ style: 'Hyperlink' });
|
||||||
|
// TODO: https://github.com/dolanmiu/docx/issues/1119
|
||||||
|
// Remove the if statement here and oneLink!
|
||||||
|
const oneLink = true;
|
||||||
|
if (!oneLink) {
|
||||||
|
closeLink();
|
||||||
|
} else {
|
||||||
|
if (currentLink && sameLink) return;
|
||||||
|
if (currentLink && !sameLink) {
|
||||||
|
// Close previous, and open a new one
|
||||||
|
closeLink();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
currentLink = {
|
||||||
|
link: href,
|
||||||
|
stack: this.current,
|
||||||
|
};
|
||||||
|
this.current = [];
|
||||||
|
};
|
||||||
|
const progress = async (node: Node, offset: number, index: number) => {
|
||||||
|
const links = node.marks.filter((m) => m.type.name === 'link');
|
||||||
|
const hasLink = links.length > 0;
|
||||||
|
if (hasLink) {
|
||||||
|
openLink(links[0].attrs.href);
|
||||||
|
} else if (!hasLink && currentLink) {
|
||||||
|
closeLink();
|
||||||
|
}
|
||||||
|
if (node.isText) {
|
||||||
|
this.text(node.text, this.renderMarks(node, [...node.marks]));
|
||||||
|
} else {
|
||||||
|
await this.render(node, parent, index);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Process nodes sequentially to maintain order
|
||||||
|
for (let i = 0; i < parent.childCount; i += 1) {
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await progress(parent.child(i), 0, i);
|
||||||
|
}
|
||||||
|
// Must call close at the end of everything, just in case
|
||||||
|
closeLink();
|
||||||
|
}
|
||||||
|
|
||||||
|
async renderList(node: Node, style: NumberingStyles) {
|
||||||
|
if (!this.currentNumbering) {
|
||||||
|
const nextId = createShortId();
|
||||||
|
this.numbering.push(createNumbering(nextId, style));
|
||||||
|
this.currentNumbering = { reference: nextId, level: 0 };
|
||||||
|
} else {
|
||||||
|
const { reference, level } = this.currentNumbering;
|
||||||
|
this.currentNumbering = { reference, level: level + 1 };
|
||||||
|
}
|
||||||
|
await this.renderContent(node);
|
||||||
|
if (this.currentNumbering.level === 0) {
|
||||||
|
delete this.currentNumbering;
|
||||||
|
} else {
|
||||||
|
const { reference, level } = this.currentNumbering;
|
||||||
|
this.currentNumbering = { reference, level: level - 1 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is a pass through to the paragraphs, etc. underneath they will close the block
|
||||||
|
async renderListItem(node: Node) {
|
||||||
|
if (!this.currentNumbering) throw new Error('Trying to create a list item without a list?');
|
||||||
|
this.addParagraphOptions({ numbering: this.currentNumbering });
|
||||||
|
await this.renderContent(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
addParagraphOptions(opts: IParagraphOptions) {
|
||||||
|
this.nextParentParagraphOpts = { ...this.nextParentParagraphOpts, ...opts };
|
||||||
|
}
|
||||||
|
|
||||||
|
addRunOptions(opts: IRunOptions) {
|
||||||
|
this.nextRunOpts = { ...this.nextRunOpts, ...opts };
|
||||||
|
}
|
||||||
|
|
||||||
|
text(text: string | null | undefined, opts?: IRunOptions) {
|
||||||
|
if (!text) return;
|
||||||
|
this.current.push(new TextRun({ text, ...this.nextRunOpts, ...opts }));
|
||||||
|
delete this.nextRunOpts;
|
||||||
|
}
|
||||||
|
|
||||||
|
math(latex: string, opts: IMathOpts = { inline: true }) {
|
||||||
|
if (opts.inline || !opts.numbered) {
|
||||||
|
this.current.push(new Math({ children: [new MathRun(latex)] }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = opts.id ?? createShortId();
|
||||||
|
this.current = [
|
||||||
|
new TextRun('\t'),
|
||||||
|
new Math({
|
||||||
|
children: [new MathRun(latex)],
|
||||||
|
}),
|
||||||
|
new TextRun('\t('),
|
||||||
|
createReferenceBookmark(id, 'Equation'),
|
||||||
|
new TextRun(')'),
|
||||||
|
];
|
||||||
|
this.addParagraphOptions({
|
||||||
|
tabStops: [
|
||||||
|
{
|
||||||
|
type: TabStopType.CENTER,
|
||||||
|
position: TabStopPosition.MAX / 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: TabStopType.RIGHT,
|
||||||
|
position: TabStopPosition.MAX,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// not sure what this actually is, seems to be close for 8.5x11
|
||||||
|
maxImageWidth = MAX_IMAGE_WIDTH;
|
||||||
|
|
||||||
|
async image(
|
||||||
|
src: string,
|
||||||
|
widthPercent = 70,
|
||||||
|
align: AlignOptions = 'center',
|
||||||
|
imageRunOpts?: IImageOptions,
|
||||||
|
imageType?: ImageType,
|
||||||
|
) {
|
||||||
|
const buffer = await Promise.resolve(this.options.getImageBuffer(src));
|
||||||
|
const dimensions = imageDimensionsFromData(buffer);
|
||||||
|
/* If the image is not a valid image, don't add it */
|
||||||
|
if (!dimensions) return;
|
||||||
|
const aspect = dimensions.height / dimensions.width;
|
||||||
|
const width = this.maxImageWidth * (widthPercent / 100);
|
||||||
|
let it;
|
||||||
|
try {
|
||||||
|
it = imageType || (src.replace(/.*\./, '').toLowerCase() as any);
|
||||||
|
} catch (e) {
|
||||||
|
it = 'png';
|
||||||
|
}
|
||||||
|
this.current.push(
|
||||||
|
new ImageRun({
|
||||||
|
data: buffer,
|
||||||
|
...imageRunOpts,
|
||||||
|
type: it,
|
||||||
|
transformation: {
|
||||||
|
...(imageRunOpts?.transformation || {}),
|
||||||
|
width,
|
||||||
|
height: width * aspect,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let alignment: string;
|
||||||
|
switch (align) {
|
||||||
|
case 'right':
|
||||||
|
alignment = AlignmentType.RIGHT;
|
||||||
|
break;
|
||||||
|
case 'left':
|
||||||
|
alignment = AlignmentType.LEFT;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
alignment = AlignmentType.CENTER;
|
||||||
|
}
|
||||||
|
this.addParagraphOptions({
|
||||||
|
alignment: alignment as any,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async table(
|
||||||
|
node: Node,
|
||||||
|
opts: {
|
||||||
|
getCellOptions?: (cell: Node) => ITableCellOptions;
|
||||||
|
getRowOptions?: (row: Node) => Omit<ITableRowOptions, 'children'>;
|
||||||
|
tableOptions?: Omit<ITableOptions, 'rows'>;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const { getCellOptions, getRowOptions, tableOptions } = opts;
|
||||||
|
const actualChildren = this.children;
|
||||||
|
const rows: TableRow[] = [];
|
||||||
|
|
||||||
|
for (let rowIndex = 0; rowIndex < node.content.childCount; rowIndex += 1) {
|
||||||
|
const row = node.content.child(rowIndex);
|
||||||
|
const cells: TableCell[] = [];
|
||||||
|
// Check if all cells are headers in this row
|
||||||
|
let tableHeader = true;
|
||||||
|
|
||||||
|
// Check if all cells in the row are headers
|
||||||
|
for (let cellIndex = 0; cellIndex < row.content.childCount; cellIndex += 1) {
|
||||||
|
const cell = row.content.child(cellIndex);
|
||||||
|
if (cell.type.name !== 'tableHeader') {
|
||||||
|
tableHeader = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// This scales images inside of tables
|
||||||
|
this.maxImageWidth = MAX_IMAGE_WIDTH / row.content.childCount;
|
||||||
|
|
||||||
|
// Iterate through cells and ensure order
|
||||||
|
for (let cellIndex = 0; cellIndex < row.content.childCount; cellIndex += 1) {
|
||||||
|
const cell = row.content.child(cellIndex);
|
||||||
|
this.children = [];
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await this.renderContent(cell); // Ensure order
|
||||||
|
const tableCellOpts: Mutable<ITableCellOptions> = { children: this.children };
|
||||||
|
const colspan = cell.attrs.colspan ?? 1;
|
||||||
|
const rowspan = cell.attrs.rowspan ?? 1;
|
||||||
|
if (colspan > 1) tableCellOpts.columnSpan = colspan;
|
||||||
|
if (rowspan > 1) tableCellOpts.rowSpan = rowspan;
|
||||||
|
cells.push(
|
||||||
|
new TableCell({
|
||||||
|
...tableCellOpts,
|
||||||
|
...(getCellOptions?.(cell) || {}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.push(new TableRow({ ...(getRowOptions?.(row) || {}), children: cells, tableHeader }));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.maxImageWidth = MAX_IMAGE_WIDTH;
|
||||||
|
const table = new Table({ ...tableOptions, rows });
|
||||||
|
actualChildren.push(table);
|
||||||
|
// If there are multiple tables, this separates them
|
||||||
|
actualChildren.push(new Paragraph(''));
|
||||||
|
this.children = actualChildren;
|
||||||
|
}
|
||||||
|
|
||||||
|
captionLabel(id: string, kind: 'Figure' | 'Table', { suffix } = { suffix: ': ' }) {
|
||||||
|
this.current.push(...[createReferenceBookmark(id, kind, `${kind} `), new TextRun(suffix)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$footnoteCounter = 0;
|
||||||
|
|
||||||
|
async footnote(node: Node) {
|
||||||
|
const { current, nextRunOpts } = this;
|
||||||
|
// Delete everything and work with the footnote inline on the current
|
||||||
|
this.current = [];
|
||||||
|
delete this.nextRunOpts;
|
||||||
|
|
||||||
|
this.$footnoteCounter += 1;
|
||||||
|
await this.renderInline(node);
|
||||||
|
this.footnotes[this.$footnoteCounter] = {
|
||||||
|
children: [new Paragraph({ children: this.current })],
|
||||||
|
};
|
||||||
|
this.current = current;
|
||||||
|
this.nextRunOpts = nextRunOpts;
|
||||||
|
this.current.push(new FootnoteReferenceRun(this.$footnoteCounter));
|
||||||
|
}
|
||||||
|
|
||||||
|
closeBlock(node: Node, props?: IParagraphOptions) {
|
||||||
|
const paragraph = new Paragraph({
|
||||||
|
children: this.current,
|
||||||
|
...this.nextParentParagraphOpts,
|
||||||
|
...props,
|
||||||
|
});
|
||||||
|
this.current = [];
|
||||||
|
delete this.nextParentParagraphOpts;
|
||||||
|
this.children.push(paragraph);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move to the next section. If no more sections are available,
|
||||||
|
* this will be ignored (content continues in current section).
|
||||||
|
*/
|
||||||
|
nextSection() {
|
||||||
|
if (this.currentSectionIndex < this.sections.length - 1) {
|
||||||
|
this.currentSectionIndex += 1;
|
||||||
|
this.children = this.sections[this.currentSectionIndex].children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the current section's configuration
|
||||||
|
*/
|
||||||
|
setSectionConfig(config: Partial<SectionConfig>) {
|
||||||
|
this.sections[this.currentSectionIndex].config = {
|
||||||
|
...this.sections[this.currentSectionIndex].config,
|
||||||
|
...config,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new section with the given configuration and switch to it
|
||||||
|
*/
|
||||||
|
addSection(config: SectionConfig = {}) {
|
||||||
|
this.sections.push({
|
||||||
|
config,
|
||||||
|
children: [],
|
||||||
|
});
|
||||||
|
this.currentSectionIndex = this.sections.length - 1;
|
||||||
|
this.children = this.sections[this.currentSectionIndex].children;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current section index
|
||||||
|
*/
|
||||||
|
getCurrentSectionIndex(): number {
|
||||||
|
return this.currentSectionIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current section configuration
|
||||||
|
*/
|
||||||
|
getCurrentSectionConfig(): SectionConfig {
|
||||||
|
return this.sections[this.currentSectionIndex].config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current serialization state for document creation
|
||||||
|
*/
|
||||||
|
getSerializationState(): SerializationState {
|
||||||
|
return {
|
||||||
|
numbering: this.numbering,
|
||||||
|
sections: this.sections,
|
||||||
|
footnotes: this.footnotes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
createReference(id: string, before?: string, after?: string) {
|
||||||
|
const children: ParagraphChild[] = [];
|
||||||
|
if (before) children.push(new TextRun(before));
|
||||||
|
children.push(new SimpleField(`REF ${id} \\h`));
|
||||||
|
if (after) children.push(new TextRun(after));
|
||||||
|
const ref = new InternalHyperlink({ anchor: id, children });
|
||||||
|
this.current.push(ref);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DocxSerializerAsync {
|
||||||
|
nodes: NodeSerializerAsync;
|
||||||
|
|
||||||
|
marks: MarkSerializer;
|
||||||
|
|
||||||
|
constructor(nodes: NodeSerializerAsync, marks: MarkSerializer) {
|
||||||
|
this.nodes = nodes;
|
||||||
|
this.marks = marks;
|
||||||
|
}
|
||||||
|
|
||||||
|
async serializeAsync(
|
||||||
|
content: Node,
|
||||||
|
options: OptionsAsync,
|
||||||
|
getDocumentOptions?: (state: SerializationState) => IPropertiesOptions,
|
||||||
|
) {
|
||||||
|
const state = new DocxSerializerStateAsync(this.nodes, this.marks, options);
|
||||||
|
await state.renderContent(content);
|
||||||
|
return buildDoc(state, getDocumentOptions?.(state));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { INumberingOptions, Paragraph, ISectionOptions } from 'docx';
|
||||||
|
|
||||||
|
export type Mutable<T> = {
|
||||||
|
-readonly [k in keyof T]: T[k];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IFootnotes = Mutable<
|
||||||
|
Readonly<
|
||||||
|
Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
readonly children: readonly Paragraph[];
|
||||||
|
}
|
||||||
|
>
|
||||||
|
>
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type INumbering = INumberingOptions['config'][0];
|
||||||
|
|
||||||
|
export interface SectionConfig {
|
||||||
|
properties?: ISectionOptions['properties'];
|
||||||
|
headers?: ISectionOptions['headers'];
|
||||||
|
footers?: ISectionOptions['footers'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SerializationState {
|
||||||
|
numbering: INumberingOptions['config'];
|
||||||
|
sections?: Array<{
|
||||||
|
config: SectionConfig;
|
||||||
|
children: ISectionOptions['children'];
|
||||||
|
}>;
|
||||||
|
children?: ISectionOptions['children'];
|
||||||
|
footnotes?: IFootnotes;
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import {
|
||||||
|
Document,
|
||||||
|
INumberingOptions,
|
||||||
|
IPropertiesOptions,
|
||||||
|
ISectionOptions,
|
||||||
|
Packer,
|
||||||
|
SectionType,
|
||||||
|
} from 'docx';
|
||||||
|
import { Node as ProsemirrorNode } from 'prosemirror-model';
|
||||||
|
import { IFootnotes, SerializationState } from './types';
|
||||||
|
|
||||||
|
export function createShortId() {
|
||||||
|
return Math.random().toString(36).slice(2, 11);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDoc(state: SerializationState, opts?: IPropertiesOptions): Document {
|
||||||
|
let sections = state?.sections?.length
|
||||||
|
? state.sections.map((section) => ({
|
||||||
|
properties: section.config.properties || {
|
||||||
|
type: SectionType.CONTINUOUS,
|
||||||
|
},
|
||||||
|
headers: section.config.headers,
|
||||||
|
footers: section.config.footers,
|
||||||
|
children: section.children,
|
||||||
|
}))
|
||||||
|
: undefined;
|
||||||
|
if (!sections) {
|
||||||
|
sections = [
|
||||||
|
{
|
||||||
|
headers: undefined,
|
||||||
|
footers: undefined,
|
||||||
|
properties: {
|
||||||
|
type: SectionType.CONTINUOUS,
|
||||||
|
},
|
||||||
|
children: state?.children || [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = new Document({
|
||||||
|
footnotes: state.footnotes,
|
||||||
|
numbering: {
|
||||||
|
config: state.numbering,
|
||||||
|
},
|
||||||
|
sections,
|
||||||
|
...(opts || {}),
|
||||||
|
});
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated - use `buildDoc` instead
|
||||||
|
* Creates a docx document from the given state.
|
||||||
|
* */
|
||||||
|
export function createDocFromState(state: {
|
||||||
|
numbering: INumberingOptions['config'];
|
||||||
|
children: ISectionOptions['children'];
|
||||||
|
footnotes?: IFootnotes;
|
||||||
|
}) {
|
||||||
|
return buildDoc({
|
||||||
|
numbering: state.numbering,
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
config: {},
|
||||||
|
children: state.children,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
footnotes: state.footnotes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeDocx(
|
||||||
|
doc: Document,
|
||||||
|
/**
|
||||||
|
* @deprecated use `.then()` or `await` instead
|
||||||
|
*/
|
||||||
|
write?: ((buffer: Buffer) => void) | ((buffer: Buffer) => Promise<void>),
|
||||||
|
) {
|
||||||
|
const buffer = await Packer.toBuffer(doc);
|
||||||
|
await write?.(buffer);
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLatexFromNode(node: ProsemirrorNode): string {
|
||||||
|
let math = '';
|
||||||
|
node.forEach((child) => {
|
||||||
|
if (child.isText) math += child.text;
|
||||||
|
// TODO: improve this as we may have other things in the future
|
||||||
|
});
|
||||||
|
return math;
|
||||||
|
}
|
||||||
Generated
+82
-41
@@ -53,7 +53,7 @@ importers:
|
|||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@braintree/sanitize-url':
|
'@braintree/sanitize-url':
|
||||||
specifier: ^7.1.2
|
specifier: 7.1.2
|
||||||
version: 7.1.2
|
version: 7.1.2
|
||||||
'@casl/ability':
|
'@casl/ability':
|
||||||
specifier: 6.8.0
|
specifier: 6.8.0
|
||||||
@@ -62,7 +62,7 @@ importers:
|
|||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:packages/editor-ext
|
version: link:packages/editor-ext
|
||||||
'@floating-ui/dom':
|
'@floating-ui/dom':
|
||||||
specifier: ^1.7.3
|
specifier: 1.7.3
|
||||||
version: 1.7.3
|
version: 1.7.3
|
||||||
'@hocuspocus/provider':
|
'@hocuspocus/provider':
|
||||||
specifier: 3.4.4
|
specifier: 3.4.4
|
||||||
@@ -74,10 +74,10 @@ importers:
|
|||||||
specifier: 3.4.4
|
specifier: 3.4.4
|
||||||
version: 3.4.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)(y-prosemirror@1.3.7(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30))(yjs@13.6.30)
|
version: 3.4.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)(y-prosemirror@1.3.7(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30))(yjs@13.6.30)
|
||||||
'@joplin/turndown':
|
'@joplin/turndown':
|
||||||
specifier: ^4.0.82
|
specifier: 4.0.82
|
||||||
version: 4.0.82
|
version: 4.0.82
|
||||||
'@joplin/turndown-plugin-gfm':
|
'@joplin/turndown-plugin-gfm':
|
||||||
specifier: ^1.0.64
|
specifier: 1.0.64
|
||||||
version: 1.0.64
|
version: 1.0.64
|
||||||
'@sindresorhus/slugify':
|
'@sindresorhus/slugify':
|
||||||
specifier: 3.0.0
|
specifier: 3.0.0
|
||||||
@@ -170,34 +170,37 @@ importers:
|
|||||||
specifier: 3.0.2
|
specifier: 3.0.2
|
||||||
version: 3.0.2(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30)
|
version: 3.0.2(prosemirror-model@1.25.1)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.30))(yjs@13.6.30)
|
||||||
bytes:
|
bytes:
|
||||||
specifier: ^3.1.2
|
specifier: 3.1.2
|
||||||
version: 3.1.2
|
version: 3.1.2
|
||||||
cross-env:
|
cross-env:
|
||||||
specifier: ^10.1.0
|
specifier: 10.1.0
|
||||||
version: 10.1.0
|
version: 10.1.0
|
||||||
date-fns:
|
date-fns:
|
||||||
specifier: ^4.1.0
|
specifier: 4.1.0
|
||||||
version: 4.1.0
|
version: 4.1.0
|
||||||
diff:
|
diff:
|
||||||
specifier: 8.0.3
|
specifier: 8.0.3
|
||||||
version: 8.0.3
|
version: 8.0.3
|
||||||
|
docx:
|
||||||
|
specifier: 9.7.1
|
||||||
|
version: 9.7.1
|
||||||
dompurify:
|
dompurify:
|
||||||
specifier: 3.4.1
|
specifier: 3.4.1
|
||||||
version: 3.4.1
|
version: 3.4.1
|
||||||
fractional-indexing-jittered:
|
fractional-indexing-jittered:
|
||||||
specifier: ^1.0.0
|
specifier: 1.0.0
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
highlight.js:
|
highlight.js:
|
||||||
specifier: ^11.11.1
|
specifier: 11.11.1
|
||||||
version: 11.11.1
|
version: 11.11.1
|
||||||
image-dimensions:
|
image-dimensions:
|
||||||
specifier: ^2.5.0
|
specifier: 2.5.0
|
||||||
version: 2.5.0
|
version: 2.5.0
|
||||||
jszip:
|
jszip:
|
||||||
specifier: ^3.10.1
|
specifier: 3.10.1
|
||||||
version: 3.10.1
|
version: 3.10.1
|
||||||
linkifyjs:
|
linkifyjs:
|
||||||
specifier: ^4.3.2
|
specifier: 4.3.2
|
||||||
version: 4.3.2
|
version: 4.3.2
|
||||||
marked:
|
marked:
|
||||||
specifier: 17.0.5
|
specifier: 17.0.5
|
||||||
@@ -206,16 +209,16 @@ importers:
|
|||||||
specifier: 3.0.0-canary.1
|
specifier: 3.0.0-canary.1
|
||||||
version: 3.0.0-canary.1
|
version: 3.0.0-canary.1
|
||||||
qrcode:
|
qrcode:
|
||||||
specifier: ^1.5.4
|
specifier: 1.5.4
|
||||||
version: 1.5.4
|
version: 1.5.4
|
||||||
rfc6902:
|
rfc6902:
|
||||||
specifier: 5.2.0
|
specifier: 5.2.0
|
||||||
version: 5.2.0
|
version: 5.2.0
|
||||||
uuid:
|
uuid:
|
||||||
specifier: ^14.0.0
|
specifier: 14.0.0
|
||||||
version: 14.0.0
|
version: 14.0.0
|
||||||
y-indexeddb:
|
y-indexeddb:
|
||||||
specifier: ^9.0.12
|
specifier: 9.0.12
|
||||||
version: 9.0.12(yjs@13.6.30)
|
version: 9.0.12(yjs@13.6.30)
|
||||||
y-prosemirror:
|
y-prosemirror:
|
||||||
specifier: 1.3.7
|
specifier: 1.3.7
|
||||||
@@ -228,16 +231,16 @@ importers:
|
|||||||
specifier: 22.6.1
|
specifier: 22.6.1
|
||||||
version: 22.6.1(@babel/traverse@7.28.5)(nx@22.6.1)
|
version: 22.6.1(@babel/traverse@7.28.5)(nx@22.6.1)
|
||||||
'@types/bytes':
|
'@types/bytes':
|
||||||
specifier: ^3.1.5
|
specifier: 3.1.5
|
||||||
version: 3.1.5
|
version: 3.1.5
|
||||||
'@types/qrcode':
|
'@types/qrcode':
|
||||||
specifier: ^1.5.6
|
specifier: 1.5.6
|
||||||
version: 1.5.6
|
version: 1.5.6
|
||||||
'@types/turndown':
|
'@types/turndown':
|
||||||
specifier: ^5.0.6
|
specifier: 5.0.6
|
||||||
version: 5.0.6
|
version: 5.0.6
|
||||||
concurrently:
|
concurrently:
|
||||||
specifier: ^9.2.1
|
specifier: 9.2.1
|
||||||
version: 9.2.1
|
version: 9.2.1
|
||||||
nx:
|
nx:
|
||||||
specifier: 22.6.1
|
specifier: 22.6.1
|
||||||
@@ -484,13 +487,13 @@ importers:
|
|||||||
apps/server:
|
apps/server:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@ai-sdk/google':
|
'@ai-sdk/google':
|
||||||
specifier: ^3.0.52
|
specifier: 3.0.52
|
||||||
version: 3.0.52(zod@4.3.6)
|
version: 3.0.52(zod@4.3.6)
|
||||||
'@ai-sdk/openai':
|
'@ai-sdk/openai':
|
||||||
specifier: ^3.0.47
|
specifier: 3.0.47
|
||||||
version: 3.0.47(zod@4.3.6)
|
version: 3.0.47(zod@4.3.6)
|
||||||
'@ai-sdk/openai-compatible':
|
'@ai-sdk/openai-compatible':
|
||||||
specifier: ^2.0.37
|
specifier: 2.0.37
|
||||||
version: 2.0.37(zod@4.3.6)
|
version: 2.0.37(zod@4.3.6)
|
||||||
'@aws-sdk/client-s3':
|
'@aws-sdk/client-s3':
|
||||||
specifier: 3.1050.0
|
specifier: 3.1050.0
|
||||||
@@ -505,7 +508,7 @@ importers:
|
|||||||
specifier: 12.31.0
|
specifier: 12.31.0
|
||||||
version: 12.31.0
|
version: 12.31.0
|
||||||
'@clickhouse/client':
|
'@clickhouse/client':
|
||||||
specifier: ^1.18.2
|
specifier: 1.18.2
|
||||||
version: 1.18.2
|
version: 1.18.2
|
||||||
'@docmost/pdf-inspector':
|
'@docmost/pdf-inspector':
|
||||||
specifier: 1.9.6
|
specifier: 1.9.6
|
||||||
@@ -589,43 +592,43 @@ importers:
|
|||||||
specifier: ^8.3.0
|
specifier: ^8.3.0
|
||||||
version: 8.3.0(socket.io-adapter@2.5.4)
|
version: 8.3.0(socket.io-adapter@2.5.4)
|
||||||
ai:
|
ai:
|
||||||
specifier: ^6.0.134
|
specifier: 6.0.134
|
||||||
version: 6.0.134(zod@4.3.6)
|
version: 6.0.134(zod@4.3.6)
|
||||||
ai-sdk-ollama:
|
ai-sdk-ollama:
|
||||||
specifier: ^3.8.1
|
specifier: 3.8.1
|
||||||
version: 3.8.1(ai@6.0.134(zod@4.3.6))(zod@4.3.6)
|
version: 3.8.1(ai@6.0.134(zod@4.3.6))(zod@4.3.6)
|
||||||
bcrypt:
|
bcrypt:
|
||||||
specifier: ^6.0.0
|
specifier: 6.0.0
|
||||||
version: 6.0.0
|
version: 6.0.0
|
||||||
bowser:
|
bowser:
|
||||||
specifier: ^2.14.1
|
specifier: 2.14.1
|
||||||
version: 2.14.1
|
version: 2.14.1
|
||||||
bullmq:
|
bullmq:
|
||||||
specifier: ^5.76.10
|
specifier: 5.76.10
|
||||||
version: 5.76.10
|
version: 5.76.10
|
||||||
cache-manager:
|
cache-manager:
|
||||||
specifier: ^7.2.8
|
specifier: 7.2.8
|
||||||
version: 7.2.8
|
version: 7.2.8
|
||||||
cheerio:
|
cheerio:
|
||||||
specifier: ^1.2.0
|
specifier: 1.2.0
|
||||||
version: 1.2.0
|
version: 1.2.0
|
||||||
class-transformer:
|
class-transformer:
|
||||||
specifier: ^0.5.1
|
specifier: 0.5.1
|
||||||
version: 0.5.1
|
version: 0.5.1
|
||||||
class-validator:
|
class-validator:
|
||||||
specifier: ^0.15.1
|
specifier: 0.15.1
|
||||||
version: 0.15.1
|
version: 0.15.1
|
||||||
cookie:
|
cookie:
|
||||||
specifier: ^1.1.1
|
specifier: 1.1.1
|
||||||
version: 1.1.1
|
version: 1.1.1
|
||||||
fast-bm25:
|
fast-bm25:
|
||||||
specifier: 0.0.5
|
specifier: 0.0.5
|
||||||
version: 0.0.5(typescript@5.9.3)
|
version: 0.0.5(typescript@5.9.3)
|
||||||
fastify-ip:
|
fastify-ip:
|
||||||
specifier: ^2.0.0
|
specifier: 2.0.0
|
||||||
version: 2.0.0
|
version: 2.0.0
|
||||||
fs-extra:
|
fs-extra:
|
||||||
specifier: ^11.3.4
|
specifier: 11.3.4
|
||||||
version: 11.3.4
|
version: 11.3.4
|
||||||
happy-dom:
|
happy-dom:
|
||||||
specifier: 20.8.9
|
specifier: 20.8.9
|
||||||
@@ -736,13 +739,13 @@ importers:
|
|||||||
specifier: ^17.7.0
|
specifier: ^17.7.0
|
||||||
version: 17.7.0
|
version: 17.7.0
|
||||||
tlds:
|
tlds:
|
||||||
specifier: ^1.261.0
|
specifier: 1.261.0
|
||||||
version: 1.261.0
|
version: 1.261.0
|
||||||
tmp-promise:
|
tmp-promise:
|
||||||
specifier: ^3.0.3
|
specifier: 3.0.3
|
||||||
version: 3.0.3
|
version: 3.0.3
|
||||||
tseep:
|
tseep:
|
||||||
specifier: ^1.3.1
|
specifier: 1.3.1
|
||||||
version: 1.3.1
|
version: 1.3.1
|
||||||
typesense:
|
typesense:
|
||||||
specifier: ^3.0.5
|
specifier: ^3.0.5
|
||||||
@@ -6306,6 +6309,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
|
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
docx@9.7.1:
|
||||||
|
resolution: {integrity: sha512-ilXFf9Moz47ABjFpDiA5s1w9lpb4EFSp7+5iiJSbfyYDM+bpZdAgLlSr7fW4aXhVe/E+F6QCv0EvRVFEd5CsWg==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
dom-accessibility-api@0.5.16:
|
dom-accessibility-api@0.5.16:
|
||||||
resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
|
resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
|
||||||
|
|
||||||
@@ -6977,6 +6984,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
|
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
hash.js@1.1.7:
|
||||||
|
resolution: {integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==}
|
||||||
|
|
||||||
hashery@1.4.0:
|
hashery@1.4.0:
|
||||||
resolution: {integrity: sha512-Wn2i1In6XFxl8Az55kkgnFRiAlIAushzh26PTjL2AKtQcEfXrcLa7Hn5QOWGZEf3LU057P9TwwZjFyxfS1VuvQ==}
|
resolution: {integrity: sha512-Wn2i1In6XFxl8Az55kkgnFRiAlIAushzh26PTjL2AKtQcEfXrcLa7Hn5QOWGZEf3LU057P9TwwZjFyxfS1VuvQ==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
@@ -8111,6 +8121,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
|
resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
|
minimalistic-assert@1.0.1:
|
||||||
|
resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==}
|
||||||
|
|
||||||
minimatch@10.2.4:
|
minimatch@10.2.4:
|
||||||
resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==}
|
resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==}
|
||||||
engines: {node: 18 || 20 || >=22}
|
engines: {node: 18 || 20 || >=22}
|
||||||
@@ -10264,6 +10277,10 @@ packages:
|
|||||||
xml-encryption@3.1.0:
|
xml-encryption@3.1.0:
|
||||||
resolution: {integrity: sha512-PV7qnYpoAMXbf1kvQkqMScLeQpjCMixddAKq9PtqVrho8HnYbBOWNfG0kA4R7zxQDo7w9kiYAyzS/ullAyO55Q==}
|
resolution: {integrity: sha512-PV7qnYpoAMXbf1kvQkqMScLeQpjCMixddAKq9PtqVrho8HnYbBOWNfG0kA4R7zxQDo7w9kiYAyzS/ullAyO55Q==}
|
||||||
|
|
||||||
|
xml-js@1.6.11:
|
||||||
|
resolution: {integrity: sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
xml-name-validator@5.0.0:
|
xml-name-validator@5.0.0:
|
||||||
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
|
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -10276,6 +10293,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==}
|
resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==}
|
||||||
engines: {node: '>=4.0.0'}
|
engines: {node: '>=4.0.0'}
|
||||||
|
|
||||||
|
xml@1.0.1:
|
||||||
|
resolution: {integrity: sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==}
|
||||||
|
|
||||||
xmlbuilder@10.1.1:
|
xmlbuilder@10.1.1:
|
||||||
resolution: {integrity: sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==}
|
resolution: {integrity: sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==}
|
||||||
engines: {node: '>=4.0'}
|
engines: {node: '>=4.0'}
|
||||||
@@ -14449,7 +14469,7 @@ snapshots:
|
|||||||
|
|
||||||
'@tiptap/extension-bubble-menu@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)':
|
'@tiptap/extension-bubble-menu@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@floating-ui/dom': 1.7.4
|
'@floating-ui/dom': 1.7.3
|
||||||
'@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
|
'@tiptap/core': 3.20.4(@tiptap/pm@3.20.4)
|
||||||
'@tiptap/pm': 3.20.4
|
'@tiptap/pm': 3.20.4
|
||||||
optional: true
|
optional: true
|
||||||
@@ -16620,6 +16640,15 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
esutils: 2.0.3
|
esutils: 2.0.3
|
||||||
|
|
||||||
|
docx@9.7.1:
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 25.5.0
|
||||||
|
hash.js: 1.1.7
|
||||||
|
jszip: 3.10.1
|
||||||
|
nanoid: 5.1.7
|
||||||
|
xml: 1.0.1
|
||||||
|
xml-js: 1.6.11
|
||||||
|
|
||||||
dom-accessibility-api@0.5.16: {}
|
dom-accessibility-api@0.5.16: {}
|
||||||
|
|
||||||
dom-accessibility-api@0.6.3: {}
|
dom-accessibility-api@0.6.3: {}
|
||||||
@@ -17541,6 +17570,11 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
has-symbols: 1.1.0
|
has-symbols: 1.1.0
|
||||||
|
|
||||||
|
hash.js@1.1.7:
|
||||||
|
dependencies:
|
||||||
|
inherits: 2.0.4
|
||||||
|
minimalistic-assert: 1.0.1
|
||||||
|
|
||||||
hashery@1.4.0:
|
hashery@1.4.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
hookified: 1.15.1
|
hookified: 1.15.1
|
||||||
@@ -18821,6 +18855,8 @@ snapshots:
|
|||||||
|
|
||||||
min-indent@1.0.1: {}
|
min-indent@1.0.1: {}
|
||||||
|
|
||||||
|
minimalistic-assert@1.0.1: {}
|
||||||
|
|
||||||
minimatch@10.2.4:
|
minimatch@10.2.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
brace-expansion: 5.0.6
|
brace-expansion: 5.0.6
|
||||||
@@ -20163,8 +20199,7 @@ snapshots:
|
|||||||
|
|
||||||
sax@1.4.1: {}
|
sax@1.4.1: {}
|
||||||
|
|
||||||
sax@1.6.0:
|
sax@1.6.0: {}
|
||||||
optional: true
|
|
||||||
|
|
||||||
saxes@6.0.0:
|
saxes@6.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -21223,6 +21258,10 @@ snapshots:
|
|||||||
escape-html: 1.0.3
|
escape-html: 1.0.3
|
||||||
xpath: 0.0.32
|
xpath: 0.0.32
|
||||||
|
|
||||||
|
xml-js@1.6.11:
|
||||||
|
dependencies:
|
||||||
|
sax: 1.6.0
|
||||||
|
|
||||||
xml-name-validator@5.0.0: {}
|
xml-name-validator@5.0.0: {}
|
||||||
|
|
||||||
xml-naming@0.1.0: {}
|
xml-naming@0.1.0: {}
|
||||||
@@ -21232,6 +21271,8 @@ snapshots:
|
|||||||
sax: 1.4.1
|
sax: 1.4.1
|
||||||
xmlbuilder: 11.0.1
|
xmlbuilder: 11.0.1
|
||||||
|
|
||||||
|
xml@1.0.1: {}
|
||||||
|
|
||||||
xmlbuilder@10.1.1: {}
|
xmlbuilder@10.1.1: {}
|
||||||
|
|
||||||
xmlbuilder@11.0.1: {}
|
xmlbuilder@11.0.1: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user