fumadocs-epub 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/index.d.ts +64 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +258 -0
- package/dist/index.js.map +1 -0
- package/package.json +63 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2023 Fuma
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { LoaderConfig, LoaderOutput, Page } from "fumadocs-core/source";
|
|
2
|
+
|
|
3
|
+
//#region src/types.d.ts
|
|
4
|
+
interface EpubConfig {
|
|
5
|
+
/** Book title */
|
|
6
|
+
title: string;
|
|
7
|
+
/** Author name(s) */
|
|
8
|
+
author?: string | string[];
|
|
9
|
+
/** Book description */
|
|
10
|
+
description?: string;
|
|
11
|
+
/** Language code (e.g. 'en') */
|
|
12
|
+
language?: string;
|
|
13
|
+
/** Publisher name */
|
|
14
|
+
publisher?: string;
|
|
15
|
+
/** Cover image - URL, file path, or path relative to public dir */
|
|
16
|
+
cover?: string;
|
|
17
|
+
/** Output file path - if set, writes to file in addition to returning buffer */
|
|
18
|
+
outputPath?: string;
|
|
19
|
+
/** Filter: include only these pages. Applied first (only pages where includePages(page) === true are considered). */
|
|
20
|
+
includePages?: (page: Page) => boolean;
|
|
21
|
+
/** Filter: exclude these pages. Applied after includePages (removes pages from the include-filtered set). */
|
|
22
|
+
excludePages?: (page: Page) => boolean;
|
|
23
|
+
/** Custom CSS for the EPUB */
|
|
24
|
+
css?: string;
|
|
25
|
+
/** Public directory for resolving /public/... image paths */
|
|
26
|
+
publicDir?: string;
|
|
27
|
+
}
|
|
28
|
+
interface EpubExportOptions<Config extends LoaderConfig = LoaderConfig> extends EpubConfig {
|
|
29
|
+
/** Fumadocs source (from loader()) */
|
|
30
|
+
source: LoaderOutput<Config>;
|
|
31
|
+
/** fucntion to get page Markdown content */
|
|
32
|
+
getMarkdown?: (page: Page<Config['source']['pageData']>) => string | undefined | Promise<string | undefined>;
|
|
33
|
+
}
|
|
34
|
+
//#endregion
|
|
35
|
+
//#region src/default-styles.d.ts
|
|
36
|
+
/**
|
|
37
|
+
* Minimal, readable CSS stylesheet suitable for e-readers.
|
|
38
|
+
*/
|
|
39
|
+
declare const defaultEpubStyles = "\nbody {\n font-family: Georgia, serif;\n font-size: 1em;\n line-height: 1.6;\n margin: 1em;\n}\n\nh1, h2, h3, h4, h5, h6 {\n font-family: Georgia, serif;\n margin-top: 1.5em;\n margin-bottom: 0.5em;\n page-break-after: avoid;\n}\n\nh1 { font-size: 1.5em; }\nh2 { font-size: 1.3em; }\nh3 { font-size: 1.15em; }\n\npre, code {\n font-family: \"Consolas\", \"Monaco\", \"Courier New\", monospace;\n font-size: 0.9em;\n}\n\npre {\n background: #f5f5f5;\n padding: 1em;\n overflow-x: auto;\n page-break-inside: avoid;\n}\n\ncode {\n background: #f5f5f5;\n padding: 0.2em 0.4em;\n}\n\npre code {\n background: none;\n padding: 0;\n}\n\nimg {\n max-width: 100%;\n height: auto;\n}\n\nblockquote {\n margin: 1em 0;\n padding-left: 1em;\n border-left: 4px solid #ccc;\n color: #555;\n}\n\ntable {\n border-collapse: collapse;\n width: 100%;\n page-break-inside: avoid;\n}\n\nth, td {\n border: 1px solid #ddd;\n padding: 0.5em;\n}\n\nth {\n background: #f5f5f5;\n}\n\np {\n margin: 0.75em 0;\n}\n\nul, ol {\n margin: 0.75em 0;\n padding-left: 1.5em;\n}\n\na {\n color: #0066cc;\n}\n";
|
|
40
|
+
//#endregion
|
|
41
|
+
//#region src/index.d.ts
|
|
42
|
+
/**
|
|
43
|
+
* Export Fumadocs documentation to EPUB format.
|
|
44
|
+
*
|
|
45
|
+
* @param options - Export configuration with source and epub config
|
|
46
|
+
* @returns Promise resolving to the EPUB file as a Buffer
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* ```ts
|
|
50
|
+
* import { exportEpub } from 'fumadocs-epub';
|
|
51
|
+
* import { source } from '@/lib/source';
|
|
52
|
+
*
|
|
53
|
+
* const buffer = await exportEpub({
|
|
54
|
+
* source,
|
|
55
|
+
* title: 'My Documentation',
|
|
56
|
+
* author: 'My Team',
|
|
57
|
+
* cover: '/cover.png',
|
|
58
|
+
* });
|
|
59
|
+
* ```
|
|
60
|
+
*/
|
|
61
|
+
declare function exportEpub<C extends LoaderConfig>(options: EpubExportOptions<C>): Promise<Buffer>;
|
|
62
|
+
//#endregion
|
|
63
|
+
export { type EpubConfig, type EpubExportOptions, defaultEpubStyles, exportEpub };
|
|
64
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","names":[],"sources":["../src/types.ts","../src/default-styles.ts","../src/index.ts"],"mappings":";;;UAEiB,UAAA;;EAEf,KAAA;EAFyB;EAIzB,MAAA;EAc0B;EAZ1B,WAAA;EAFA;EAIA,QAAA;EAAA;EAEA,SAAA;EAEA;EAAA,KAAA;EAIA;EAFA,UAAA;EAEgB;EAAhB,YAAA,IAAgB,IAAA,EAAM,IAAA;EAEA;EAAtB,YAAA,IAAgB,IAAA,EAAM,IAAA;EAEtB;EAAA,GAAA;EAES;EAAT,SAAA;AAAA;AAAA,UAGe,iBAAA,gBAAiC,YAAA,GAAe,YAAA,UAAsB,UAAA;EAArD;EAEhC,MAAA,EAAQ,YAAA,CAAa,MAAA;EAF0C;EAI/D,WAAA,IACE,IAAA,EAAM,IAAA,CAAK,MAAA,iDACa,OAAA;AAAA;;;;;;cC9Bf,iBAAA;;;;;;;;;;;;;;;;;;;;;;iBC4DS,UAAA,WAAqB,YAAA,CAAA,CACzC,OAAA,EAAS,iBAAA,CAAkB,CAAA,IAC1B,OAAA,CAAQ,MAAA"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import epub from "epub-gen-memory";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { flattenTree } from "fumadocs-core/page-tree";
|
|
5
|
+
import remarkGfm from "remark-gfm";
|
|
6
|
+
import remarkRehype from "remark-rehype";
|
|
7
|
+
import rehypeStringify from "rehype-stringify";
|
|
8
|
+
import { visit } from "unist-util-visit";
|
|
9
|
+
import { remark } from "remark";
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
|
|
12
|
+
//#region src/toc-builder.ts
|
|
13
|
+
/**
|
|
14
|
+
* Get pages in the order they appear in the page tree (navigation order).
|
|
15
|
+
* Uses flattenTree to traverse the tree depth-first, resolving each Item to a page.
|
|
16
|
+
*/
|
|
17
|
+
function getPagesInTreeOrder(pageTree, getNodePage) {
|
|
18
|
+
const mainItems = flattenTree(pageTree.children);
|
|
19
|
+
const fallbackItems = pageTree.fallback ? flattenTree(pageTree.fallback.children) : [];
|
|
20
|
+
const items = [...mainItems, ...fallbackItems];
|
|
21
|
+
const pages = [];
|
|
22
|
+
for (const item of items) {
|
|
23
|
+
const page = getNodePage(item);
|
|
24
|
+
if (page) pages.push(page);
|
|
25
|
+
}
|
|
26
|
+
return pages;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
//#endregion
|
|
30
|
+
//#region src/image-resolver.ts
|
|
31
|
+
/**
|
|
32
|
+
* Resolve image src to file:// URL for epub-gen-memory to embed.
|
|
33
|
+
* Handles relative paths (resolved from page dir), absolute paths, and remote URLs.
|
|
34
|
+
*/
|
|
35
|
+
function resolveImageSrc(src, pageDir, publicDir) {
|
|
36
|
+
if (src.startsWith("file://")) return src;
|
|
37
|
+
if (src.startsWith("http://") || src.startsWith("https://")) return src;
|
|
38
|
+
if (src.startsWith("data:")) return src;
|
|
39
|
+
if (src.startsWith("//")) return `https:${src}`;
|
|
40
|
+
if (src.startsWith("/")) {
|
|
41
|
+
const resolved = path.join(publicDir, src.slice(1));
|
|
42
|
+
if (fs.existsSync(resolved)) return pathToFileURL(resolved).href;
|
|
43
|
+
}
|
|
44
|
+
const resolved = path.resolve(pageDir, src);
|
|
45
|
+
if (fs.existsSync(resolved)) return pathToFileURL(resolved).href;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
//#endregion
|
|
49
|
+
//#region src/markdown-to-html.ts
|
|
50
|
+
const processor = remark().use(remarkGfm).use(remarkResolveImg).use(remarkRehype, { allowDangerousHtml: true }).use(rehypeStringify, { allowDangerousHtml: true });
|
|
51
|
+
function remarkResolveImg() {
|
|
52
|
+
return (tree, file) => {
|
|
53
|
+
const { pageDir, publicDir } = file.data;
|
|
54
|
+
if (!pageDir || !publicDir) return;
|
|
55
|
+
visit(tree, "image", (node, idx, parent) => {
|
|
56
|
+
const resolved = resolveImageSrc(node.url, pageDir, publicDir);
|
|
57
|
+
if (resolved) node.url = resolved;
|
|
58
|
+
else if (idx !== void 0 && parent) parent.children.splice(idx, 1);
|
|
59
|
+
});
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Convert markdown string to HTML suitable for EPUB chapters.
|
|
64
|
+
* Uses unified pipeline: remark-parse -> remark-gfm -> remark-rehype -> rehype-stringify
|
|
65
|
+
*/
|
|
66
|
+
async function markdownToHtml(markdown, pageDir, publicDir) {
|
|
67
|
+
const result = await processor.process({
|
|
68
|
+
value: markdown,
|
|
69
|
+
data: {
|
|
70
|
+
pageDir,
|
|
71
|
+
publicDir
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
return String(result);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
//#endregion
|
|
78
|
+
//#region src/default-styles.ts
|
|
79
|
+
/**
|
|
80
|
+
* Minimal, readable CSS stylesheet suitable for e-readers.
|
|
81
|
+
*/
|
|
82
|
+
const defaultEpubStyles = `
|
|
83
|
+
body {
|
|
84
|
+
font-family: Georgia, serif;
|
|
85
|
+
font-size: 1em;
|
|
86
|
+
line-height: 1.6;
|
|
87
|
+
margin: 1em;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
h1, h2, h3, h4, h5, h6 {
|
|
91
|
+
font-family: Georgia, serif;
|
|
92
|
+
margin-top: 1.5em;
|
|
93
|
+
margin-bottom: 0.5em;
|
|
94
|
+
page-break-after: avoid;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
h1 { font-size: 1.5em; }
|
|
98
|
+
h2 { font-size: 1.3em; }
|
|
99
|
+
h3 { font-size: 1.15em; }
|
|
100
|
+
|
|
101
|
+
pre, code {
|
|
102
|
+
font-family: "Consolas", "Monaco", "Courier New", monospace;
|
|
103
|
+
font-size: 0.9em;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
pre {
|
|
107
|
+
background: #f5f5f5;
|
|
108
|
+
padding: 1em;
|
|
109
|
+
overflow-x: auto;
|
|
110
|
+
page-break-inside: avoid;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
code {
|
|
114
|
+
background: #f5f5f5;
|
|
115
|
+
padding: 0.2em 0.4em;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
pre code {
|
|
119
|
+
background: none;
|
|
120
|
+
padding: 0;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
img {
|
|
124
|
+
max-width: 100%;
|
|
125
|
+
height: auto;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
blockquote {
|
|
129
|
+
margin: 1em 0;
|
|
130
|
+
padding-left: 1em;
|
|
131
|
+
border-left: 4px solid #ccc;
|
|
132
|
+
color: #555;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
table {
|
|
136
|
+
border-collapse: collapse;
|
|
137
|
+
width: 100%;
|
|
138
|
+
page-break-inside: avoid;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
th, td {
|
|
142
|
+
border: 1px solid #ddd;
|
|
143
|
+
padding: 0.5em;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
th {
|
|
147
|
+
background: #f5f5f5;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
p {
|
|
151
|
+
margin: 0.75em 0;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
ul, ol {
|
|
155
|
+
margin: 0.75em 0;
|
|
156
|
+
padding-left: 1.5em;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
a {
|
|
160
|
+
color: #0066cc;
|
|
161
|
+
}
|
|
162
|
+
`;
|
|
163
|
+
|
|
164
|
+
//#endregion
|
|
165
|
+
//#region src/index.ts
|
|
166
|
+
/**
|
|
167
|
+
* Get page directory for resolving relative image paths
|
|
168
|
+
*/
|
|
169
|
+
function getPageDir(page, cwd) {
|
|
170
|
+
if (page.absolutePath) return path.dirname(page.absolutePath);
|
|
171
|
+
return path.resolve(cwd, path.dirname(page.path));
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Resolve cover image path - support file://, http(s)://, /public/..., or relative
|
|
175
|
+
*/
|
|
176
|
+
function resolveCoverPath(cover, cwd, publicDir) {
|
|
177
|
+
if (!cover) return void 0;
|
|
178
|
+
if (cover.startsWith("file://") || cover.startsWith("http://") || cover.startsWith("https://")) return cover;
|
|
179
|
+
if (cover.startsWith("/")) {
|
|
180
|
+
const base = publicDir ?? path.join(cwd, "public");
|
|
181
|
+
return pathToFileUrl(path.join(base, cover.slice(1)));
|
|
182
|
+
}
|
|
183
|
+
return pathToFileUrl(path.resolve(cwd, cover));
|
|
184
|
+
}
|
|
185
|
+
function pathToFileUrl(filePath) {
|
|
186
|
+
return pathToFileURL(filePath).href;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Export Fumadocs documentation to EPUB format.
|
|
190
|
+
*
|
|
191
|
+
* @param options - Export configuration with source and epub config
|
|
192
|
+
* @returns Promise resolving to the EPUB file as a Buffer
|
|
193
|
+
*
|
|
194
|
+
* @example
|
|
195
|
+
* ```ts
|
|
196
|
+
* import { exportEpub } from 'fumadocs-epub';
|
|
197
|
+
* import { source } from '@/lib/source';
|
|
198
|
+
*
|
|
199
|
+
* const buffer = await exportEpub({
|
|
200
|
+
* source,
|
|
201
|
+
* title: 'My Documentation',
|
|
202
|
+
* author: 'My Team',
|
|
203
|
+
* cover: '/cover.png',
|
|
204
|
+
* });
|
|
205
|
+
* ```
|
|
206
|
+
*/
|
|
207
|
+
async function exportEpub(options) {
|
|
208
|
+
const cwd = process.cwd();
|
|
209
|
+
const { source, getMarkdown = async (page) => {
|
|
210
|
+
try {
|
|
211
|
+
return await page.data.getText("raw");
|
|
212
|
+
} catch {
|
|
213
|
+
console.warn(`Failed to get processed markdown for page "${page.absolutePath ?? page.path}", please specify the "getMarkdown" option.`);
|
|
214
|
+
}
|
|
215
|
+
}, title, author = "anonymous", description, language = "en", publisher = "anonymous", cover, outputPath, includePages, excludePages, css, publicDir = path.resolve("public") } = options;
|
|
216
|
+
const orderedPages = getPagesInTreeOrder(source.getPageTree(), (node) => source.getNodePage?.(node)).filter((p) => p != null);
|
|
217
|
+
const filteredPages = (orderedPages.length > 0 ? orderedPages : source.getPages()).filter((page) => {
|
|
218
|
+
if (includePages && !includePages(page)) return false;
|
|
219
|
+
if (excludePages && excludePages(page)) return false;
|
|
220
|
+
return true;
|
|
221
|
+
});
|
|
222
|
+
const chapters = [];
|
|
223
|
+
for (const page of filteredPages) {
|
|
224
|
+
const markdown = await getMarkdown(page);
|
|
225
|
+
if (!markdown) continue;
|
|
226
|
+
const html = await markdownToHtml(markdown, getPageDir(page, cwd), publicDir);
|
|
227
|
+
const pageTitle = page.data.title ?? page.slugs[page.slugs.length - 1] ?? "Untitled";
|
|
228
|
+
chapters.push({
|
|
229
|
+
title: pageTitle,
|
|
230
|
+
content: html
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
const buffer = await epub({
|
|
234
|
+
title,
|
|
235
|
+
author: Array.isArray(author) ? author : [author],
|
|
236
|
+
description,
|
|
237
|
+
lang: language,
|
|
238
|
+
publisher,
|
|
239
|
+
cover: resolveCoverPath(cover, cwd, publicDir) ?? cover,
|
|
240
|
+
css: css ?? defaultEpubStyles,
|
|
241
|
+
prependChapterTitles: true,
|
|
242
|
+
numberChaptersInTOC: true,
|
|
243
|
+
tocTitle: "Table of Contents"
|
|
244
|
+
}, chapters.map((ch) => ({
|
|
245
|
+
title: ch.title,
|
|
246
|
+
content: ch.content
|
|
247
|
+
})));
|
|
248
|
+
if (outputPath) {
|
|
249
|
+
const fs = await import("node:fs/promises");
|
|
250
|
+
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
|
251
|
+
await fs.writeFile(outputPath, buffer);
|
|
252
|
+
}
|
|
253
|
+
return buffer;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
//#endregion
|
|
257
|
+
export { defaultEpubStyles, exportEpub };
|
|
258
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/toc-builder.ts","../src/image-resolver.ts","../src/markdown-to-html.ts","../src/default-styles.ts","../src/index.ts"],"sourcesContent":["import type * as PageTree from 'fumadocs-core/page-tree';\nimport { flattenTree } from 'fumadocs-core/page-tree';\n\n/**\n * Get pages in the order they appear in the page tree (navigation order).\n * Uses flattenTree to traverse the tree depth-first, resolving each Item to a page.\n */\nexport function getPagesInTreeOrder<Page>(\n pageTree: PageTree.Root,\n getNodePage: (node: PageTree.Item) => Page | undefined,\n): Page[] {\n const mainItems = flattenTree(pageTree.children);\n const fallbackItems = pageTree.fallback ? flattenTree(pageTree.fallback.children) : [];\n const items = [...mainItems, ...fallbackItems];\n const pages: Page[] = [];\n\n for (const item of items) {\n const page = getNodePage(item);\n if (page) {\n pages.push(page);\n }\n }\n\n return pages;\n}\n","import path from 'node:path';\nimport { pathToFileURL } from 'node:url';\nimport fs from 'node:fs';\n\n/**\n * Resolve image src to file:// URL for epub-gen-memory to embed.\n * Handles relative paths (resolved from page dir), absolute paths, and remote URLs.\n */\nexport function resolveImageSrc(\n src: string,\n pageDir: string,\n publicDir: string,\n): string | undefined {\n // Already a file:// URL\n if (src.startsWith('file://')) {\n return src;\n }\n\n // Remote URL - epub-gen-memory can fetch these\n if (src.startsWith('http://') || src.startsWith('https://')) {\n return src;\n }\n\n // Data URL - pass through\n if (src.startsWith('data:')) {\n return src;\n }\n\n // Protocol-relative URL (//cdn.example.com/...) - normalize to https:\n if (src.startsWith('//')) {\n return `https:${src}`;\n }\n\n // Absolute path starting with / - could be from public dir\n if (src.startsWith('/')) {\n const resolved = path.join(publicDir, src.slice(1));\n if (fs.existsSync(resolved)) return pathToFileURL(resolved).href;\n }\n // Relative path or others - resolve from page directory\n const resolved = path.resolve(pageDir, src);\n if (fs.existsSync(resolved)) return pathToFileURL(resolved).href;\n}\n","import type { Transformer } from 'unified';\nimport remarkGfm from 'remark-gfm';\nimport remarkRehype from 'remark-rehype';\nimport rehypeStringify from 'rehype-stringify';\nimport type { Root } from 'mdast';\nimport { visit } from 'unist-util-visit';\nimport { remark } from 'remark';\nimport { resolveImageSrc } from './image-resolver';\n\nconst processor = remark()\n .use(remarkGfm)\n .use(remarkResolveImg)\n .use(remarkRehype, { allowDangerousHtml: true })\n .use(rehypeStringify, { allowDangerousHtml: true });\n\ndeclare module 'vfile' {\n export interface DataMap {\n pageDir: string;\n publicDir: string;\n }\n}\n\nfunction remarkResolveImg(): Transformer<Root, Root> {\n return (tree, file) => {\n const { pageDir, publicDir } = file.data;\n if (!pageDir || !publicDir) return;\n\n visit(tree, 'image', (node, idx, parent) => {\n const resolved = resolveImageSrc(node.url, pageDir, publicDir);\n if (resolved) {\n node.url = resolved;\n } else if (idx !== undefined && parent) {\n parent.children.splice(idx, 1);\n }\n });\n };\n}\n\n/**\n * Convert markdown string to HTML suitable for EPUB chapters.\n * Uses unified pipeline: remark-parse -> remark-gfm -> remark-rehype -> rehype-stringify\n */\nexport async function markdownToHtml(\n markdown: string,\n pageDir: string,\n publicDir: string,\n): Promise<string> {\n const result = await processor.process({\n value: markdown,\n data: {\n pageDir,\n publicDir,\n },\n });\n return String(result);\n}\n","/**\n * Minimal, readable CSS stylesheet suitable for e-readers.\n */\nexport const defaultEpubStyles = `\nbody {\n font-family: Georgia, serif;\n font-size: 1em;\n line-height: 1.6;\n margin: 1em;\n}\n\nh1, h2, h3, h4, h5, h6 {\n font-family: Georgia, serif;\n margin-top: 1.5em;\n margin-bottom: 0.5em;\n page-break-after: avoid;\n}\n\nh1 { font-size: 1.5em; }\nh2 { font-size: 1.3em; }\nh3 { font-size: 1.15em; }\n\npre, code {\n font-family: \"Consolas\", \"Monaco\", \"Courier New\", monospace;\n font-size: 0.9em;\n}\n\npre {\n background: #f5f5f5;\n padding: 1em;\n overflow-x: auto;\n page-break-inside: avoid;\n}\n\ncode {\n background: #f5f5f5;\n padding: 0.2em 0.4em;\n}\n\npre code {\n background: none;\n padding: 0;\n}\n\nimg {\n max-width: 100%;\n height: auto;\n}\n\nblockquote {\n margin: 1em 0;\n padding-left: 1em;\n border-left: 4px solid #ccc;\n color: #555;\n}\n\ntable {\n border-collapse: collapse;\n width: 100%;\n page-break-inside: avoid;\n}\n\nth, td {\n border: 1px solid #ddd;\n padding: 0.5em;\n}\n\nth {\n background: #f5f5f5;\n}\n\np {\n margin: 0.75em 0;\n}\n\nul, ol {\n margin: 0.75em 0;\n padding-left: 1.5em;\n}\n\na {\n color: #0066cc;\n}\n`;\n","import epub, { type Options } from 'epub-gen-memory';\nimport path from 'node:path';\nimport { pathToFileURL } from 'node:url';\nimport type { LoaderConfig, Page } from 'fumadocs-core/source';\nimport { getPagesInTreeOrder } from './toc-builder';\nimport { markdownToHtml } from './markdown-to-html';\nimport { defaultEpubStyles } from './default-styles';\nimport type { EpubExportOptions } from './types';\n\nexport { defaultEpubStyles } from './default-styles';\nexport type { EpubConfig, EpubExportOptions } from './types';\n\n/**\n * Get page directory for resolving relative image paths\n */\nfunction getPageDir(page: Page, cwd: string): string {\n if (page.absolutePath) {\n return path.dirname(page.absolutePath);\n }\n return path.resolve(cwd, path.dirname(page.path));\n}\n\n/**\n * Resolve cover image path - support file://, http(s)://, /public/..., or relative\n */\nfunction resolveCoverPath(\n cover: string | undefined,\n cwd: string,\n publicDir?: string,\n): string | undefined {\n if (!cover) return undefined;\n if (cover.startsWith('file://') || cover.startsWith('http://') || cover.startsWith('https://'))\n return cover;\n if (cover.startsWith('/')) {\n const base = publicDir ?? path.join(cwd, 'public');\n return pathToFileUrl(path.join(base, cover.slice(1)));\n }\n return pathToFileUrl(path.resolve(cwd, cover));\n}\n\nfunction pathToFileUrl(filePath: string): string {\n return pathToFileURL(filePath).href;\n}\n\n/**\n * Export Fumadocs documentation to EPUB format.\n *\n * @param options - Export configuration with source and epub config\n * @returns Promise resolving to the EPUB file as a Buffer\n *\n * @example\n * ```ts\n * import { exportEpub } from 'fumadocs-epub';\n * import { source } from '@/lib/source';\n *\n * const buffer = await exportEpub({\n * source,\n * title: 'My Documentation',\n * author: 'My Team',\n * cover: '/cover.png',\n * });\n * ```\n */\nexport async function exportEpub<C extends LoaderConfig>(\n options: EpubExportOptions<C>,\n): Promise<Buffer> {\n const cwd = process.cwd();\n const {\n source,\n getMarkdown = async (page) => {\n try {\n return await (page.data as { getText: (type: string) => Promise<string> }).getText('raw');\n } catch {\n console.warn(\n `Failed to get processed markdown for page \"${page.absolutePath ?? page.path}\", please specify the \"getMarkdown\" option.`,\n );\n }\n },\n title,\n author = 'anonymous',\n description,\n language = 'en',\n publisher = 'anonymous',\n cover,\n outputPath,\n includePages,\n excludePages,\n css,\n publicDir = path.resolve('public'),\n } = options;\n\n // Get pages in tree order (navigation order)\n const pageTree = source.getPageTree();\n const orderedPages = getPagesInTreeOrder(pageTree, (node) => source.getNodePage?.(node)).filter(\n (p): p is Page => p != null,\n );\n\n // Fallback to getPages() if tree order yields no pages (e.g. i18n)\n const pages = orderedPages.length > 0 ? orderedPages : source.getPages();\n\n // Filter pages\n const filteredPages = pages.filter((page) => {\n if (includePages && !includePages(page)) return false;\n if (excludePages && excludePages(page)) return false;\n return true;\n });\n\n const chapters: { title: string; content: string }[] = [];\n\n for (const page of filteredPages) {\n const markdown = await getMarkdown(page);\n if (!markdown) continue;\n\n const pageDir = getPageDir(page, cwd);\n const html = await markdownToHtml(markdown, pageDir, publicDir);\n const pageTitle = page.data.title ?? page.slugs[page.slugs.length - 1] ?? 'Untitled';\n\n chapters.push({\n title: pageTitle,\n content: html,\n });\n }\n\n const epubOptions: Options = {\n title,\n author: Array.isArray(author) ? author : [author],\n description,\n lang: language,\n publisher,\n cover: resolveCoverPath(cover, cwd, publicDir) ?? cover,\n css: css ?? defaultEpubStyles,\n prependChapterTitles: true,\n numberChaptersInTOC: true,\n tocTitle: 'Table of Contents',\n };\n\n const content = chapters.map((ch) => ({\n title: ch.title,\n content: ch.content,\n }));\n\n const buffer = await epub(epubOptions, content);\n\n if (outputPath) {\n const fs = await import('node:fs/promises');\n await fs.mkdir(path.dirname(outputPath), { recursive: true });\n await fs.writeFile(outputPath, buffer);\n }\n return buffer;\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAOA,SAAgB,oBACd,UACA,aACQ;CACR,MAAM,YAAY,YAAY,SAAS,SAAS;CAChD,MAAM,gBAAgB,SAAS,WAAW,YAAY,SAAS,SAAS,SAAS,GAAG,EAAE;CACtF,MAAM,QAAQ,CAAC,GAAG,WAAW,GAAG,cAAc;CAC9C,MAAM,QAAgB,EAAE;AAExB,MAAK,MAAM,QAAQ,OAAO;EACxB,MAAM,OAAO,YAAY,KAAK;AAC9B,MAAI,KACF,OAAM,KAAK,KAAK;;AAIpB,QAAO;;;;;;;;;ACfT,SAAgB,gBACd,KACA,SACA,WACoB;AAEpB,KAAI,IAAI,WAAW,UAAU,CAC3B,QAAO;AAIT,KAAI,IAAI,WAAW,UAAU,IAAI,IAAI,WAAW,WAAW,CACzD,QAAO;AAIT,KAAI,IAAI,WAAW,QAAQ,CACzB,QAAO;AAIT,KAAI,IAAI,WAAW,KAAK,CACtB,QAAO,SAAS;AAIlB,KAAI,IAAI,WAAW,IAAI,EAAE;EACvB,MAAM,WAAW,KAAK,KAAK,WAAW,IAAI,MAAM,EAAE,CAAC;AACnD,MAAI,GAAG,WAAW,SAAS,CAAE,QAAO,cAAc,SAAS,CAAC;;CAG9D,MAAM,WAAW,KAAK,QAAQ,SAAS,IAAI;AAC3C,KAAI,GAAG,WAAW,SAAS,CAAE,QAAO,cAAc,SAAS,CAAC;;;;;AC/B9D,MAAM,YAAY,QAAQ,CACvB,IAAI,UAAU,CACd,IAAI,iBAAiB,CACrB,IAAI,cAAc,EAAE,oBAAoB,MAAM,CAAC,CAC/C,IAAI,iBAAiB,EAAE,oBAAoB,MAAM,CAAC;AASrD,SAAS,mBAA4C;AACnD,SAAQ,MAAM,SAAS;EACrB,MAAM,EAAE,SAAS,cAAc,KAAK;AACpC,MAAI,CAAC,WAAW,CAAC,UAAW;AAE5B,QAAM,MAAM,UAAU,MAAM,KAAK,WAAW;GAC1C,MAAM,WAAW,gBAAgB,KAAK,KAAK,SAAS,UAAU;AAC9D,OAAI,SACF,MAAK,MAAM;YACF,QAAQ,UAAa,OAC9B,QAAO,SAAS,OAAO,KAAK,EAAE;IAEhC;;;;;;;AAQN,eAAsB,eACpB,UACA,SACA,WACiB;CACjB,MAAM,SAAS,MAAM,UAAU,QAAQ;EACrC,OAAO;EACP,MAAM;GACJ;GACA;GACD;EACF,CAAC;AACF,QAAO,OAAO,OAAO;;;;;;;;ACnDvB,MAAa,oBAAoB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;ACYjC,SAAS,WAAW,MAAY,KAAqB;AACnD,KAAI,KAAK,aACP,QAAO,KAAK,QAAQ,KAAK,aAAa;AAExC,QAAO,KAAK,QAAQ,KAAK,KAAK,QAAQ,KAAK,KAAK,CAAC;;;;;AAMnD,SAAS,iBACP,OACA,KACA,WACoB;AACpB,KAAI,CAAC,MAAO,QAAO;AACnB,KAAI,MAAM,WAAW,UAAU,IAAI,MAAM,WAAW,UAAU,IAAI,MAAM,WAAW,WAAW,CAC5F,QAAO;AACT,KAAI,MAAM,WAAW,IAAI,EAAE;EACzB,MAAM,OAAO,aAAa,KAAK,KAAK,KAAK,SAAS;AAClD,SAAO,cAAc,KAAK,KAAK,MAAM,MAAM,MAAM,EAAE,CAAC,CAAC;;AAEvD,QAAO,cAAc,KAAK,QAAQ,KAAK,MAAM,CAAC;;AAGhD,SAAS,cAAc,UAA0B;AAC/C,QAAO,cAAc,SAAS,CAAC;;;;;;;;;;;;;;;;;;;;;AAsBjC,eAAsB,WACpB,SACiB;CACjB,MAAM,MAAM,QAAQ,KAAK;CACzB,MAAM,EACJ,QACA,cAAc,OAAO,SAAS;AAC5B,MAAI;AACF,UAAO,MAAO,KAAK,KAAwD,QAAQ,MAAM;UACnF;AACN,WAAQ,KACN,8CAA8C,KAAK,gBAAgB,KAAK,KAAK,6CAC9E;;IAGL,OACA,SAAS,aACT,aACA,WAAW,MACX,YAAY,aACZ,OACA,YACA,cACA,cACA,KACA,YAAY,KAAK,QAAQ,SAAS,KAChC;CAIJ,MAAM,eAAe,oBADJ,OAAO,aAAa,GACe,SAAS,OAAO,cAAc,KAAK,CAAC,CAAC,QACtF,MAAiB,KAAK,KACxB;CAMD,MAAM,iBAHQ,aAAa,SAAS,IAAI,eAAe,OAAO,UAAU,EAG5C,QAAQ,SAAS;AAC3C,MAAI,gBAAgB,CAAC,aAAa,KAAK,CAAE,QAAO;AAChD,MAAI,gBAAgB,aAAa,KAAK,CAAE,QAAO;AAC/C,SAAO;GACP;CAEF,MAAM,WAAiD,EAAE;AAEzD,MAAK,MAAM,QAAQ,eAAe;EAChC,MAAM,WAAW,MAAM,YAAY,KAAK;AACxC,MAAI,CAAC,SAAU;EAGf,MAAM,OAAO,MAAM,eAAe,UADlB,WAAW,MAAM,IAAI,EACgB,UAAU;EAC/D,MAAM,YAAY,KAAK,KAAK,SAAS,KAAK,MAAM,KAAK,MAAM,SAAS,MAAM;AAE1E,WAAS,KAAK;GACZ,OAAO;GACP,SAAS;GACV,CAAC;;CAqBJ,MAAM,SAAS,MAAM,KAlBQ;EAC3B;EACA,QAAQ,MAAM,QAAQ,OAAO,GAAG,SAAS,CAAC,OAAO;EACjD;EACA,MAAM;EACN;EACA,OAAO,iBAAiB,OAAO,KAAK,UAAU,IAAI;EAClD,KAAK,OAAO;EACZ,sBAAsB;EACtB,qBAAqB;EACrB,UAAU;EACX,EAEe,SAAS,KAAK,QAAQ;EACpC,OAAO,GAAG;EACV,SAAS,GAAG;EACb,EAAE,CAE4C;AAE/C,KAAI,YAAY;EACd,MAAM,KAAK,MAAM,OAAO;AACxB,QAAM,GAAG,MAAM,KAAK,QAAQ,WAAW,EAAE,EAAE,WAAW,MAAM,CAAC;AAC7D,QAAM,GAAG,UAAU,YAAY,OAAO;;AAExC,QAAO"}
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "fumadocs-epub",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "EPUB export for Fumadocs documentation",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"documentation",
|
|
7
|
+
"epub",
|
|
8
|
+
"export",
|
|
9
|
+
"fumadocs"
|
|
10
|
+
],
|
|
11
|
+
"homepage": "https://fumadocs.dev",
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"author": "Fuma Nama",
|
|
14
|
+
"repository": "github:fuma-nama/fumadocs",
|
|
15
|
+
"files": [
|
|
16
|
+
"dist/*"
|
|
17
|
+
],
|
|
18
|
+
"type": "module",
|
|
19
|
+
"main": "./dist/index.js",
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./dist/index.d.ts",
|
|
24
|
+
"import": "./dist/index.js"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"epub-gen-memory": "^1.1.2",
|
|
32
|
+
"rehype-stringify": "^10.0.1",
|
|
33
|
+
"remark": "^15.0.1",
|
|
34
|
+
"remark-gfm": "^4.0.1",
|
|
35
|
+
"remark-rehype": "^11.1.2",
|
|
36
|
+
"unified": "^11.0.5",
|
|
37
|
+
"unist-util-visit": "^5.1.0"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/mdast": "^4.0.4",
|
|
41
|
+
"@types/node": "25.3.3",
|
|
42
|
+
"rimraf": "^6.0.0",
|
|
43
|
+
"tsdown": "0.20.3",
|
|
44
|
+
"typescript": "^5.9.3",
|
|
45
|
+
"vfile": "^6.0.3",
|
|
46
|
+
"eslint-config-custom": "0.0.0",
|
|
47
|
+
"tsconfig": "0.0.0",
|
|
48
|
+
"fumadocs-core": "16.6.10"
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"fumadocs-core": "^15.0.0 || ^16.0.0"
|
|
52
|
+
},
|
|
53
|
+
"engines": {
|
|
54
|
+
"node": ">=18"
|
|
55
|
+
},
|
|
56
|
+
"scripts": {
|
|
57
|
+
"build": "tsdown",
|
|
58
|
+
"clean": "rimraf dist",
|
|
59
|
+
"dev": "tsdown --watch --clean false",
|
|
60
|
+
"lint": "eslint .",
|
|
61
|
+
"types:check": "tsc --noEmit"
|
|
62
|
+
}
|
|
63
|
+
}
|