nexdf 0.1.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/README.md +126 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +101 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +223 -0
- package/package.json +51 -0
- package/templates/basic/basic.html +51 -0
package/README.md
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# nexdf
|
|
2
|
+
|
|
3
|
+
Plug-and-play PDF generation for Next.js using customizable HTML/HTMX templates and Handlebars placeholders.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Ready `/api/pdf` endpoint (via one scaffold command)
|
|
8
|
+
- Unlimited placeholders (`{{anyField}}`)
|
|
9
|
+
- Tailwind-friendly templates you can edit directly
|
|
10
|
+
- A4-ready default template
|
|
11
|
+
- Fast runtime path: browser prewarm + page pool reuse + template compile cache
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install nexdf
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Quick start
|
|
20
|
+
|
|
21
|
+
1) Scaffold endpoint + template files in your Next.js app root:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npx nexdf
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
This creates:
|
|
28
|
+
|
|
29
|
+
- `app/api/pdf/route.ts`
|
|
30
|
+
- `templates/pdf/basic.html`
|
|
31
|
+
|
|
32
|
+
2) Ensure Node runtime in route (generated by default):
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
import { createPdfRouteHandler } from "nexdf";
|
|
36
|
+
|
|
37
|
+
export const runtime = "nodejs";
|
|
38
|
+
|
|
39
|
+
export const POST = createPdfRouteHandler({
|
|
40
|
+
templatesDir: process.cwd() + "/templates/pdf",
|
|
41
|
+
defaultTemplate: "basic.html",
|
|
42
|
+
poolSize: 2
|
|
43
|
+
});
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
3) Call your endpoint:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
curl -X POST http://localhost:3000/api/pdf \
|
|
50
|
+
-H "content-type: application/json" \
|
|
51
|
+
-d '{
|
|
52
|
+
"filename": "invoice.pdf",
|
|
53
|
+
"templatePath": "basic.html",
|
|
54
|
+
"data": {
|
|
55
|
+
"title": "Invoice #001",
|
|
56
|
+
"subtitle": "Thanks for your business",
|
|
57
|
+
"fromName": "Acme",
|
|
58
|
+
"fromEmail": "billing@acme.com",
|
|
59
|
+
"toName": "Jane",
|
|
60
|
+
"toEmail": "jane@example.com",
|
|
61
|
+
"summary": "Any placeholder can be added",
|
|
62
|
+
"generatedAt": "2026-02-13"
|
|
63
|
+
}
|
|
64
|
+
}' --output invoice.pdf
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Template customization
|
|
68
|
+
|
|
69
|
+
Edit `templates/pdf/basic.html` and style with Tailwind classes.
|
|
70
|
+
|
|
71
|
+
Add as many placeholders as you want:
|
|
72
|
+
|
|
73
|
+
```html
|
|
74
|
+
<h1>{{title}}</h1>
|
|
75
|
+
<p>{{customerName}}</p>
|
|
76
|
+
<p>{{orderId}}</p>
|
|
77
|
+
<p>{{anyCustomField}}</p>
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## API
|
|
81
|
+
|
|
82
|
+
`POST /api/pdf`
|
|
83
|
+
|
|
84
|
+
Body:
|
|
85
|
+
|
|
86
|
+
```json
|
|
87
|
+
{
|
|
88
|
+
"filename": "document.pdf",
|
|
89
|
+
"templatePath": "basic.html",
|
|
90
|
+
"data": {
|
|
91
|
+
"title": "Any value"
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Runtime requirements
|
|
97
|
+
|
|
98
|
+
- Next route runtime must be `nodejs`
|
|
99
|
+
- Chrome/Chromium must be available
|
|
100
|
+
- Set executable path if needed:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
export PUPPETEER_EXECUTABLE_PATH="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Performance notes
|
|
107
|
+
|
|
108
|
+
- Prewarms Chromium when route handler is created
|
|
109
|
+
- Reuses hot pages via an internal pool (`poolSize`)
|
|
110
|
+
- Caches compiled templates with file mtime invalidation
|
|
111
|
+
|
|
112
|
+
Note: true zero cold start is not possible if your hosting platform starts a brand-new container/process.
|
|
113
|
+
|
|
114
|
+
## Programmatic usage
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
import { createPdfRouteHandler, prewarmPdfEngine } from "nexdf";
|
|
118
|
+
|
|
119
|
+
await prewarmPdfEngine({ poolSize: 2 });
|
|
120
|
+
|
|
121
|
+
export const POST = createPdfRouteHandler({
|
|
122
|
+
templatesDir: process.cwd() + "/templates/pdf",
|
|
123
|
+
defaultTemplate: "basic.html",
|
|
124
|
+
poolSize: 2
|
|
125
|
+
});
|
|
126
|
+
```
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli/index.ts
|
|
4
|
+
import { mkdir, writeFile, access } from "fs/promises";
|
|
5
|
+
import { constants as fsConstants } from "fs";
|
|
6
|
+
import path from "path";
|
|
7
|
+
var cwd = process.cwd();
|
|
8
|
+
var routeFilePath = path.join(cwd, "app", "api", "pdf", "route.ts");
|
|
9
|
+
var templateDir = path.join(cwd, "templates", "pdf");
|
|
10
|
+
var templatePath = path.join(templateDir, "basic.html");
|
|
11
|
+
var routeTemplate = `import { createPdfRouteHandler } from "nexdf";
|
|
12
|
+
|
|
13
|
+
export const runtime = "nodejs";
|
|
14
|
+
|
|
15
|
+
export const POST = createPdfRouteHandler({
|
|
16
|
+
templatesDir: process.cwd() + "/templates/pdf",
|
|
17
|
+
defaultTemplate: "basic.html",
|
|
18
|
+
poolSize: 2
|
|
19
|
+
});
|
|
20
|
+
`;
|
|
21
|
+
var defaultHtmlTemplate = `<!doctype html>
|
|
22
|
+
<html lang="en">
|
|
23
|
+
<head>
|
|
24
|
+
<meta charset="UTF-8" />
|
|
25
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
26
|
+
<title>{{title}}</title>
|
|
27
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
28
|
+
<style>
|
|
29
|
+
@page {
|
|
30
|
+
size: A4;
|
|
31
|
+
margin: 0;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
html,
|
|
35
|
+
body {
|
|
36
|
+
margin: 0;
|
|
37
|
+
padding: 0;
|
|
38
|
+
}
|
|
39
|
+
</style>
|
|
40
|
+
</head>
|
|
41
|
+
<body class="bg-white">
|
|
42
|
+
<main class="mx-auto box-border h-[297mm] w-[210mm] overflow-hidden bg-white px-[12mm] py-[14mm]">
|
|
43
|
+
<header class="mb-8 border-b border-slate-200 pb-6">
|
|
44
|
+
<h1 class="text-2xl font-bold text-slate-900">{{title}}</h1>
|
|
45
|
+
<p class="mt-1 text-sm text-slate-500">{{subtitle}}</p>
|
|
46
|
+
</header>
|
|
47
|
+
|
|
48
|
+
<section class="grid grid-cols-2 gap-6 text-sm">
|
|
49
|
+
<article>
|
|
50
|
+
<h2 class="mb-2 font-semibold text-slate-700">From</h2>
|
|
51
|
+
<p class="text-slate-600">{{fromName}}</p>
|
|
52
|
+
<p class="text-slate-500">{{fromEmail}}</p>
|
|
53
|
+
</article>
|
|
54
|
+
<article>
|
|
55
|
+
<h2 class="mb-2 font-semibold text-slate-700">To</h2>
|
|
56
|
+
<p class="text-slate-600">{{toName}}</p>
|
|
57
|
+
<p class="text-slate-500">{{toEmail}}</p>
|
|
58
|
+
</article>
|
|
59
|
+
</section>
|
|
60
|
+
|
|
61
|
+
<section class="mt-8 rounded-xl bg-slate-50 p-5">
|
|
62
|
+
<h3 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Summary</h3>
|
|
63
|
+
<p class="mt-2 text-slate-700">{{summary}}</p>
|
|
64
|
+
</section>
|
|
65
|
+
|
|
66
|
+
<footer class="mt-10 border-t border-slate-200 pt-5 text-xs text-slate-400">
|
|
67
|
+
Generated on {{generatedAt}}
|
|
68
|
+
</footer>
|
|
69
|
+
</main>
|
|
70
|
+
</body>
|
|
71
|
+
</html>
|
|
72
|
+
`;
|
|
73
|
+
async function exists(filePath) {
|
|
74
|
+
try {
|
|
75
|
+
await access(filePath, fsConstants.F_OK);
|
|
76
|
+
return true;
|
|
77
|
+
} catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
async function main() {
|
|
82
|
+
await mkdir(path.dirname(routeFilePath), { recursive: true });
|
|
83
|
+
await mkdir(templateDir, { recursive: true });
|
|
84
|
+
if (!await exists(routeFilePath)) {
|
|
85
|
+
await writeFile(routeFilePath, routeTemplate, "utf8");
|
|
86
|
+
console.log("Created app/api/pdf/route.ts");
|
|
87
|
+
} else {
|
|
88
|
+
console.log("Skipped app/api/pdf/route.ts (already exists)");
|
|
89
|
+
}
|
|
90
|
+
if (!await exists(templatePath)) {
|
|
91
|
+
await writeFile(templatePath, defaultHtmlTemplate, "utf8");
|
|
92
|
+
console.log("Created templates/pdf/basic.html");
|
|
93
|
+
} else {
|
|
94
|
+
console.log("Skipped templates/pdf/basic.html (already exists)");
|
|
95
|
+
}
|
|
96
|
+
console.log("PDF endpoint is ready at /api/pdf");
|
|
97
|
+
}
|
|
98
|
+
main().catch((error) => {
|
|
99
|
+
console.error(error);
|
|
100
|
+
process.exit(1);
|
|
101
|
+
});
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { PDFOptions } from 'puppeteer-core';
|
|
2
|
+
|
|
3
|
+
type TemplateData = Record<string, unknown>;
|
|
4
|
+
declare function renderTemplateString(template: string, data: TemplateData): string;
|
|
5
|
+
declare function renderTemplateFile(templatePath: string, data: TemplateData): Promise<string>;
|
|
6
|
+
declare function listPlaceholders(template: string): string[];
|
|
7
|
+
|
|
8
|
+
type PdfRequestBody = {
|
|
9
|
+
data?: TemplateData;
|
|
10
|
+
templatePath?: string;
|
|
11
|
+
filename?: string;
|
|
12
|
+
};
|
|
13
|
+
type CreatePdfRouteHandlerOptions = {
|
|
14
|
+
templatesDir?: string;
|
|
15
|
+
defaultTemplate?: string;
|
|
16
|
+
prewarm?: boolean;
|
|
17
|
+
poolSize?: number;
|
|
18
|
+
};
|
|
19
|
+
declare function createPdfRouteHandler(options?: CreatePdfRouteHandlerOptions): (request: Request) => Promise<Response>;
|
|
20
|
+
|
|
21
|
+
type GeneratePdfInput = {
|
|
22
|
+
html: string;
|
|
23
|
+
pdf?: PDFOptions;
|
|
24
|
+
executablePath?: string;
|
|
25
|
+
};
|
|
26
|
+
type PrewarmPdfEngineOptions = {
|
|
27
|
+
executablePath?: string;
|
|
28
|
+
poolSize?: number;
|
|
29
|
+
};
|
|
30
|
+
declare function prewarmPdfEngine(options?: PrewarmPdfEngineOptions): Promise<void>;
|
|
31
|
+
declare function generatePdfBuffer(input: GeneratePdfInput): Promise<Buffer>;
|
|
32
|
+
|
|
33
|
+
export { type CreatePdfRouteHandlerOptions, type GeneratePdfInput, type PdfRequestBody, type PrewarmPdfEngineOptions, type TemplateData, createPdfRouteHandler, generatePdfBuffer, listPlaceholders, prewarmPdfEngine, renderTemplateFile, renderTemplateString };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// src/next.ts
|
|
2
|
+
import { access } from "fs/promises";
|
|
3
|
+
import { constants as fsConstants } from "fs";
|
|
4
|
+
import path from "path";
|
|
5
|
+
|
|
6
|
+
// src/pdf.ts
|
|
7
|
+
import puppeteer from "puppeteer-core";
|
|
8
|
+
var DEFAULT_EXECUTABLE_PATHS = [
|
|
9
|
+
process.env.PUPPETEER_EXECUTABLE_PATH,
|
|
10
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
11
|
+
"/usr/bin/google-chrome",
|
|
12
|
+
"/usr/bin/chromium",
|
|
13
|
+
"/snap/bin/chromium"
|
|
14
|
+
].filter((value) => Boolean(value));
|
|
15
|
+
var sharedBrowserPromise = null;
|
|
16
|
+
var idlePages = [];
|
|
17
|
+
var allPages = /* @__PURE__ */ new Set();
|
|
18
|
+
var waitQueue = [];
|
|
19
|
+
var sharedPoolSize = 2;
|
|
20
|
+
function getBrowser(executablePath) {
|
|
21
|
+
if (!sharedBrowserPromise) {
|
|
22
|
+
sharedBrowserPromise = puppeteer.launch({
|
|
23
|
+
headless: true,
|
|
24
|
+
executablePath,
|
|
25
|
+
args: ["--no-sandbox", "--disable-setuid-sandbox"]
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
return sharedBrowserPromise;
|
|
29
|
+
}
|
|
30
|
+
function getExecutablePath(inputPath) {
|
|
31
|
+
const executablePath = inputPath || DEFAULT_EXECUTABLE_PATHS[0];
|
|
32
|
+
if (!executablePath) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
"Chrome/Chromium executable not found. Set PUPPETEER_EXECUTABLE_PATH or pass executablePath."
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
return executablePath;
|
|
38
|
+
}
|
|
39
|
+
async function acquirePage(browser) {
|
|
40
|
+
while (true) {
|
|
41
|
+
const cached = idlePages.pop();
|
|
42
|
+
if (cached && !cached.isClosed()) {
|
|
43
|
+
return cached;
|
|
44
|
+
}
|
|
45
|
+
if (allPages.size < sharedPoolSize) {
|
|
46
|
+
const page = await browser.newPage();
|
|
47
|
+
allPages.add(page);
|
|
48
|
+
return page;
|
|
49
|
+
}
|
|
50
|
+
await new Promise((resolve) => {
|
|
51
|
+
waitQueue.push(resolve);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function releasePage(page) {
|
|
56
|
+
if (page.isClosed()) {
|
|
57
|
+
allPages.delete(page);
|
|
58
|
+
const next2 = waitQueue.shift();
|
|
59
|
+
if (next2) {
|
|
60
|
+
next2();
|
|
61
|
+
}
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
idlePages.push(page);
|
|
65
|
+
const next = waitQueue.shift();
|
|
66
|
+
if (next) {
|
|
67
|
+
next();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
async function prewarmPdfEngine(options = {}) {
|
|
71
|
+
const executablePath = getExecutablePath(options.executablePath);
|
|
72
|
+
sharedPoolSize = Math.max(1, options.poolSize ?? sharedPoolSize);
|
|
73
|
+
const browser = await getBrowser(executablePath);
|
|
74
|
+
if (idlePages.length > 0 || allPages.size > 0) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const page = await browser.newPage();
|
|
78
|
+
await page.setContent("<html><body></body></html>", { waitUntil: "domcontentloaded" });
|
|
79
|
+
allPages.add(page);
|
|
80
|
+
idlePages.push(page);
|
|
81
|
+
}
|
|
82
|
+
async function closeSharedBrowser() {
|
|
83
|
+
if (!sharedBrowserPromise) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const browser = await sharedBrowserPromise;
|
|
87
|
+
sharedBrowserPromise = null;
|
|
88
|
+
idlePages.length = 0;
|
|
89
|
+
allPages.clear();
|
|
90
|
+
waitQueue.length = 0;
|
|
91
|
+
await browser.close();
|
|
92
|
+
}
|
|
93
|
+
process.once("exit", () => {
|
|
94
|
+
void closeSharedBrowser();
|
|
95
|
+
});
|
|
96
|
+
process.once("SIGINT", () => {
|
|
97
|
+
void closeSharedBrowser().finally(() => process.exit(0));
|
|
98
|
+
});
|
|
99
|
+
process.once("SIGTERM", () => {
|
|
100
|
+
void closeSharedBrowser().finally(() => process.exit(0));
|
|
101
|
+
});
|
|
102
|
+
async function generatePdfBuffer(input) {
|
|
103
|
+
const executablePath = getExecutablePath(input.executablePath);
|
|
104
|
+
const browser = await getBrowser(executablePath);
|
|
105
|
+
const page = await acquirePage(browser);
|
|
106
|
+
try {
|
|
107
|
+
await page.emulateMediaType("print");
|
|
108
|
+
await page.setContent(input.html, {
|
|
109
|
+
waitUntil: "domcontentloaded"
|
|
110
|
+
});
|
|
111
|
+
const output = await page.pdf({
|
|
112
|
+
format: "A4",
|
|
113
|
+
margin: {
|
|
114
|
+
top: "0mm",
|
|
115
|
+
right: "0mm",
|
|
116
|
+
bottom: "0mm",
|
|
117
|
+
left: "0mm"
|
|
118
|
+
},
|
|
119
|
+
printBackground: true,
|
|
120
|
+
preferCSSPageSize: true,
|
|
121
|
+
...input.pdf ?? {}
|
|
122
|
+
});
|
|
123
|
+
return Buffer.from(output);
|
|
124
|
+
} finally {
|
|
125
|
+
releasePage(page);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// src/template.ts
|
|
130
|
+
import { readFile, stat } from "fs/promises";
|
|
131
|
+
import Handlebars from "handlebars/dist/cjs/handlebars.js";
|
|
132
|
+
var compiledTemplateCache = /* @__PURE__ */ new Map();
|
|
133
|
+
function renderTemplateString(template, data) {
|
|
134
|
+
const compiled = Handlebars.compile(template, { noEscape: true });
|
|
135
|
+
return compiled(data);
|
|
136
|
+
}
|
|
137
|
+
async function renderTemplateFile(templatePath, data) {
|
|
138
|
+
const templateStat = await stat(templatePath);
|
|
139
|
+
const cached = compiledTemplateCache.get(templatePath);
|
|
140
|
+
if (cached && cached.mtimeMs === templateStat.mtimeMs) {
|
|
141
|
+
return cached.compiled(data);
|
|
142
|
+
}
|
|
143
|
+
const template = await readFile(templatePath, "utf8");
|
|
144
|
+
const compiled = Handlebars.compile(template, { noEscape: true });
|
|
145
|
+
compiledTemplateCache.set(templatePath, {
|
|
146
|
+
mtimeMs: templateStat.mtimeMs,
|
|
147
|
+
compiled
|
|
148
|
+
});
|
|
149
|
+
return compiled(data);
|
|
150
|
+
}
|
|
151
|
+
function listPlaceholders(template) {
|
|
152
|
+
const parsed = Handlebars.parse(template);
|
|
153
|
+
const names = /* @__PURE__ */ new Set();
|
|
154
|
+
const visit = (node) => {
|
|
155
|
+
if (!node || typeof node !== "object") {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (Array.isArray(node)) {
|
|
159
|
+
for (const child of node) {
|
|
160
|
+
visit(child);
|
|
161
|
+
}
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const typedNode = node;
|
|
165
|
+
if (typedNode.type === "MustacheStatement") {
|
|
166
|
+
const pathNode = typedNode.path;
|
|
167
|
+
if (pathNode?.original) {
|
|
168
|
+
names.add(pathNode.original);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
for (const value of Object.values(typedNode)) {
|
|
172
|
+
visit(value);
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
visit(parsed);
|
|
176
|
+
return [...names];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// src/next.ts
|
|
180
|
+
var DEFAULT_TEMPLATE_NAME = "basic.html";
|
|
181
|
+
function createPdfRouteHandler(options = {}) {
|
|
182
|
+
const templatesDir = options.templatesDir ?? path.join(process.cwd(), "templates", "pdf");
|
|
183
|
+
const defaultTemplate = options.defaultTemplate ?? DEFAULT_TEMPLATE_NAME;
|
|
184
|
+
const prewarm = options.prewarm ?? true;
|
|
185
|
+
if (prewarm) {
|
|
186
|
+
void prewarmPdfEngine({ poolSize: options.poolSize });
|
|
187
|
+
}
|
|
188
|
+
return async function POST(request) {
|
|
189
|
+
try {
|
|
190
|
+
const body = await request.json();
|
|
191
|
+
const filename = body.filename || "document.pdf";
|
|
192
|
+
const templateName = body.templatePath || defaultTemplate;
|
|
193
|
+
const safeTemplatePath = path.resolve(templatesDir, templateName);
|
|
194
|
+
if (!safeTemplatePath.startsWith(path.resolve(templatesDir))) {
|
|
195
|
+
return Response.json(
|
|
196
|
+
{ error: "Invalid templatePath. Template must be inside templates directory." },
|
|
197
|
+
{ status: 400 }
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
await access(safeTemplatePath, fsConstants.R_OK);
|
|
201
|
+
const html = await renderTemplateFile(safeTemplatePath, body.data ?? {});
|
|
202
|
+
const pdfBuffer = await generatePdfBuffer({ html });
|
|
203
|
+
return new Response(new Uint8Array(pdfBuffer), {
|
|
204
|
+
status: 200,
|
|
205
|
+
headers: {
|
|
206
|
+
"content-type": "application/pdf",
|
|
207
|
+
"content-disposition": `inline; filename="${filename}"`
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
} catch (error) {
|
|
211
|
+
const message = error instanceof Error ? error.message : "Unknown error while generating PDF";
|
|
212
|
+
return Response.json({ error: message }, { status: 500 });
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
export {
|
|
217
|
+
createPdfRouteHandler,
|
|
218
|
+
generatePdfBuffer,
|
|
219
|
+
listPlaceholders,
|
|
220
|
+
prewarmPdfEngine,
|
|
221
|
+
renderTemplateFile,
|
|
222
|
+
renderTemplateString
|
|
223
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "nexdf",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Plug-and-play Next.js PDF endpoint with customizable HTML/HTMX templates",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"nextjs",
|
|
8
|
+
"pdf",
|
|
9
|
+
"template",
|
|
10
|
+
"handlebars",
|
|
11
|
+
"tailwind",
|
|
12
|
+
"htmx"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=20"
|
|
16
|
+
},
|
|
17
|
+
"type": "module",
|
|
18
|
+
"main": "dist/index.js",
|
|
19
|
+
"types": "dist/index.d.ts",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"default": "./dist/index.js"
|
|
24
|
+
},
|
|
25
|
+
"./cli": {
|
|
26
|
+
"types": "./dist/cli/index.d.ts",
|
|
27
|
+
"default": "./dist/cli/index.js"
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"bin": {
|
|
31
|
+
"nexdf": "dist/cli/index.js"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"dist",
|
|
35
|
+
"templates",
|
|
36
|
+
"README.md"
|
|
37
|
+
],
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "tsup src/index.ts src/cli/index.ts --format esm --dts --out-dir dist",
|
|
40
|
+
"typecheck": "tsc --noEmit"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"handlebars": "^4.7.8",
|
|
44
|
+
"puppeteer-core": "^24.2.0"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@types/node": "^22.13.4",
|
|
48
|
+
"tsup": "^8.3.5",
|
|
49
|
+
"typescript": "^5.7.3"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>{{title}}</title>
|
|
7
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
8
|
+
<style>
|
|
9
|
+
@page {
|
|
10
|
+
size: A4;
|
|
11
|
+
margin: 0;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
html,
|
|
15
|
+
body {
|
|
16
|
+
margin: 0;
|
|
17
|
+
padding: 0;
|
|
18
|
+
}
|
|
19
|
+
</style>
|
|
20
|
+
</head>
|
|
21
|
+
<body class="bg-white">
|
|
22
|
+
<main class="mx-auto box-border h-[297mm] w-[210mm] overflow-hidden bg-white px-[12mm] py-[14mm]">
|
|
23
|
+
<header class="mb-8 border-b border-slate-200 pb-6">
|
|
24
|
+
<h1 class="text-2xl font-bold text-slate-900">{{title}}</h1>
|
|
25
|
+
<p class="mt-1 text-sm text-slate-500">{{subtitle}}</p>
|
|
26
|
+
</header>
|
|
27
|
+
|
|
28
|
+
<section class="grid grid-cols-2 gap-6 text-sm">
|
|
29
|
+
<article>
|
|
30
|
+
<h2 class="mb-2 font-semibold text-slate-700">From</h2>
|
|
31
|
+
<p class="text-slate-600">{{fromName}}</p>
|
|
32
|
+
<p class="text-slate-500">{{fromEmail}}</p>
|
|
33
|
+
</article>
|
|
34
|
+
<article>
|
|
35
|
+
<h2 class="mb-2 font-semibold text-slate-700">To</h2>
|
|
36
|
+
<p class="text-slate-600">{{toName}}</p>
|
|
37
|
+
<p class="text-slate-500">{{toEmail}}</p>
|
|
38
|
+
</article>
|
|
39
|
+
</section>
|
|
40
|
+
|
|
41
|
+
<section class="mt-8 rounded-xl bg-slate-50 p-5">
|
|
42
|
+
<h3 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Summary</h3>
|
|
43
|
+
<p class="mt-2 text-slate-700">{{summary}}</p>
|
|
44
|
+
</section>
|
|
45
|
+
|
|
46
|
+
<footer class="mt-10 border-t border-slate-200 pt-5 text-xs text-slate-400">
|
|
47
|
+
Generated on {{generatedAt}}
|
|
48
|
+
</footer>
|
|
49
|
+
</main>
|
|
50
|
+
</body>
|
|
51
|
+
</html>
|