@maistik/nuxt-pdf 1.0.17

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 ADDED
@@ -0,0 +1,381 @@
1
+ # @maistik/nuxt-pdf
2
+
3
+ A powerful Nuxt 3/4 module for server-side PDF generation using Handlebars templates. Generate beautiful, data-driven PDFs with support for multiple providers (Gotenberg, Browserless, Puppeteer Core) and built-in internationalization.
4
+
5
+ ## Features
6
+
7
+ - 🎨 **Handlebars Templates** - Lightweight templating without Vue overhead
8
+ - 🌍 **Internationalization** - Built-in i18n support with `{{t}}` helper
9
+ - 🔄 **Provider Agnostic** - Support for Gotenberg, Browserless, and Puppeteer Core
10
+ - 🎯 **SSR Safe** - Everything runs server-side in Nitro/Node
11
+ - 🧩 **Composable API** - Easy-to-use `usePdf()` composable
12
+ - 📱 **Responsive Design** - CSS-based layouts with print media queries
13
+ - 🎪 **Playground** - Demo app with sample templates
14
+
15
+ ## Quick Start
16
+
17
+ ### Installation
18
+
19
+ ```bash
20
+ npm install @maistik/nuxt-pdf handlebars
21
+ ```
22
+
23
+ ### Configuration
24
+
25
+ Add the module to your `nuxt.config.ts`:
26
+
27
+ ```typescript
28
+ export default defineNuxtConfig({
29
+ modules: ['@maistik/nuxt-pdf'],
30
+ pdf: {
31
+ provider: 'puppeteer', // or 'gotenberg' or 'browserless'
32
+ components: ['pdf'],
33
+ sharedComponents: ['pdf/partials'],
34
+ enableI18n: true,
35
+ defaultLocale: 'en',
36
+ availableLocales: ['en', 'es', 'fr'],
37
+ i18nMessages: {
38
+ en: {
39
+ invoice: {
40
+ title: 'Invoice',
41
+ total: 'Total'
42
+ }
43
+ },
44
+ es: {
45
+ invoice: {
46
+ title: 'Factura',
47
+ total: 'Total'
48
+ }
49
+ }
50
+ },
51
+ providers: {
52
+ puppeteer: {
53
+ launchOptions: {
54
+ headless: true,
55
+ args: ['--no-sandbox']
56
+ }
57
+ }
58
+ }
59
+ }
60
+ })
61
+ ```
62
+
63
+ ### Create a Template
64
+
65
+ Create `pdf/Invoice.hbs` in your project:
66
+
67
+ ```handlebars
68
+ <style>
69
+ @page { size: A4; margin: 20mm; }
70
+ body { font-family: Arial, sans-serif; }
71
+ .header { text-align: center; margin-bottom: 30px; }
72
+ .total { font-weight: bold; font-size: 18px; }
73
+ </style>
74
+
75
+ <div class="header">
76
+ <h1>{{t "invoice.title"}}</h1>
77
+ <p>Invoice #{{invoiceNumber}}</p>
78
+ </div>
79
+
80
+ <table>
81
+ {{#each items}}
82
+ <tr>
83
+ <td>{{this.description}}</td>
84
+ <td>{{formatCurrency this.price}}</td>
85
+ </tr>
86
+ {{/each}}
87
+ </table>
88
+
89
+ <div class="total">
90
+ {{t "invoice.total"}}: {{formatCurrency total}}
91
+ </div>
92
+ ```
93
+
94
+ ### Generate PDFs
95
+
96
+ Use the composable in your Vue components:
97
+
98
+ ```vue
99
+ <script setup>
100
+ const { generate, download } = usePdf()
101
+
102
+ const generateInvoice = async () => {
103
+ const data = {
104
+ invoiceNumber: 'INV-001',
105
+ items: [
106
+ { description: 'Service', price: 100 }
107
+ ],
108
+ total: 100
109
+ }
110
+
111
+ // Generate and preview
112
+ const blob = await generate('Invoice', data, { format: 'A4' }, 'en')
113
+
114
+ // Or download directly
115
+ await download('Invoice', data, { format: 'A4' }, 'invoice.pdf', 'en')
116
+ }
117
+ </script>
118
+ ```
119
+
120
+ ## Providers
121
+
122
+ ### Puppeteer Core (Local)
123
+
124
+ Best for development and on-premise deployments:
125
+
126
+ ```typescript
127
+ pdf: {
128
+ provider: 'puppeteer',
129
+ providers: {
130
+ puppeteer: {
131
+ launchOptions: {
132
+ headless: true,
133
+ args: ['--no-sandbox', '--disable-setuid-sandbox']
134
+ }
135
+ }
136
+ }
137
+ }
138
+ ```
139
+
140
+ ### Gotenberg (Docker)
141
+
142
+ Perfect for containerized environments:
143
+
144
+ ```typescript
145
+ pdf: {
146
+ provider: 'gotenberg',
147
+ providers: {
148
+ gotenberg: {
149
+ url: 'http://gotenberg:3000'
150
+ }
151
+ }
152
+ }
153
+ ```
154
+
155
+ ### Browserless (Cloud)
156
+
157
+ Great for serverless deployments:
158
+
159
+ ```typescript
160
+ pdf: {
161
+ provider: 'browserless',
162
+ providers: {
163
+ browserless: {
164
+ url: 'https://chrome.browserless.io',
165
+ apiKey: process.env.BROWSERLESS_API_KEY
166
+ }
167
+ }
168
+ }
169
+ ```
170
+
171
+ ## Built-in Helpers
172
+
173
+ ### Internationalization
174
+ ```handlebars
175
+ {{t "invoice.title"}} <!-- Outputs localized text -->
176
+ ```
177
+
178
+ ### Currency Formatting
179
+ ```handlebars
180
+ {{formatCurrency 1234.56}} <!-- $1,234.56 -->
181
+ {{formatCurrency 1234.56 "EUR"}} <!-- €1,234.56 -->
182
+ ```
183
+
184
+ ### Date Formatting
185
+ ```handlebars
186
+ {{formatDate date}} <!-- 12/25/2024 -->
187
+ {{formatDate date "full"}} <!-- Wednesday, December 25, 2024 -->
188
+ ```
189
+
190
+ ### Number Formatting
191
+ ```handlebars
192
+ {{formatNumber 1234.56}} <!-- 1,234.56 -->
193
+ {{formatNumber 0.15 style="percent"}} <!-- 15% -->
194
+ ```
195
+
196
+ ### Math Operations
197
+ ```handlebars
198
+ {{add 10 5}} <!-- 15 -->
199
+ {{subtract 10 5}} <!-- 5 -->
200
+ {{multiply 10 5}} <!-- 50 -->
201
+ {{divide 10 5}} <!-- 2 -->
202
+ {{percentage 25 100}} <!-- 25% -->
203
+ ```
204
+
205
+ ## Custom Helpers
206
+
207
+ You can define your own custom helpers in the configuration:
208
+
209
+ ```typescript
210
+ pdf: {
211
+ customHelpers: {
212
+ // Simple value transformation
213
+ customFormat: (value: any) => `[${value}]`,
214
+
215
+ // String manipulation
216
+ repeat: (str: string, times: number) => str.repeat(times || 1),
217
+
218
+ // Block helper with conditional logic
219
+ ifEquals: function(this: any, arg1: any, arg2: any, options: any) {
220
+ return (arg1 === arg2) ? options.fn(this) : options.inverse(this)
221
+ }
222
+ }
223
+ }
224
+ ```
225
+
226
+ Use them in templates:
227
+ ```handlebars
228
+ {{customFormat "hello"}} <!-- [hello] -->
229
+ {{repeat "★" 5}} <!-- ★★★★★ -->
230
+ {{#ifEquals status "active"}}Active User{{else}}Inactive User{{/ifEquals}}
231
+ ```
232
+
233
+ ### String Helpers
234
+ ```handlebars
235
+ {{upper "hello world"}} <!-- HELLO WORLD -->
236
+ {{lower "HELLO WORLD"}} <!-- hello world -->
237
+ {{capitalize "hello world"}} <!-- Hello world -->
238
+ {{truncate "Long text here" 10}} <!-- Long text... -->
239
+ ```
240
+
241
+ ### Line Calculations
242
+ ```handlebars
243
+ {{lineTotal quantity price}} <!-- quantity * price -->
244
+ ```
245
+
246
+ ## Template Features
247
+
248
+ ### Automatic Enrichment
249
+
250
+ The module automatically enriches your data based on template names:
251
+
252
+ **Invoice Templates** get:
253
+ - `subtotal` - Sum of all line items
254
+ - `tax` - Calculated tax amount
255
+ - `total` - Subtotal + tax
256
+ - `dueDate` - Calculated from issue date + payment terms
257
+
258
+ **Sales Report Templates** get:
259
+ - `quarters` - Quarterly breakdown of sales data
260
+ - `rating` - Performance rating based on total sales
261
+
262
+ ### CSS Styling
263
+
264
+ Use standard CSS with print-specific rules:
265
+
266
+ ```css
267
+ @page {
268
+ size: A4;
269
+ margin: 20mm;
270
+ }
271
+
272
+ .page-break {
273
+ page-break-before: always;
274
+ }
275
+
276
+ @media print {
277
+ .no-break {
278
+ page-break-inside: avoid;
279
+ }
280
+ }
281
+ ```
282
+
283
+ ### Partials
284
+
285
+ Create reusable components in your `sharedComponents` directory:
286
+
287
+ ```handlebars
288
+ <!-- pdf/partials/header.hbs -->
289
+ <div class="header">
290
+ <h1>{{title}}</h1>
291
+ <p>{{subtitle}}</p>
292
+ </div>
293
+ ```
294
+
295
+ Use in templates:
296
+ ```handlebars
297
+ {{> header title="My Document" subtitle="Generated Report"}}
298
+ ```
299
+
300
+ ## API Reference
301
+
302
+ ### `usePdf()`
303
+
304
+ The main composable for PDF operations:
305
+
306
+ ```typescript
307
+ const {
308
+ generate, // (template, data, options?, locale?) => Promise<Blob>
309
+ download, // (template, data, options?, filename?, locale?) => Promise<void>
310
+ getAvailableLocales, // () => string[]
311
+ getDefaultLocale // () => string
312
+ } = usePdf()
313
+ ```
314
+
315
+ ### Options
316
+
317
+ ```typescript
318
+ interface PdfOptions {
319
+ format?: 'A4' | 'Letter' | 'Legal'
320
+ margin?: {
321
+ top?: number
322
+ bottom?: number
323
+ left?: number
324
+ right?: number
325
+ }
326
+ landscape?: boolean
327
+ printBackground?: boolean
328
+ pageBreak?: {
329
+ before?: string[]
330
+ after?: string[]
331
+ avoid?: string[]
332
+ }
333
+ }
334
+ ```
335
+
336
+ ## Development
337
+
338
+ ### Playground
339
+
340
+ The module includes a full playground application:
341
+
342
+ ```bash
343
+ npm run dev
344
+ ```
345
+
346
+ This starts a demo app with sample Invoice and Sales Report templates in multiple languages.
347
+
348
+ ### Building
349
+
350
+ ```bash
351
+ npm run build
352
+ ```
353
+
354
+ ### Testing
355
+
356
+ ```bash
357
+ npm run test
358
+ ```
359
+
360
+ ## Examples
361
+
362
+ Check out the `playground/` directory for complete examples including:
363
+
364
+ - **Invoice Template** - Complete invoice with line items, taxes, and totals
365
+ - **Sales Report Template** - Comprehensive report with metrics and quarterly breakdown
366
+ - **Multi-language Support** - Templates in English, Spanish, and French
367
+ - **Multiple Providers** - Configuration examples for all supported providers
368
+
369
+ ## License
370
+
371
+ MIT
372
+
373
+ ## Contributing
374
+
375
+ Contributions are welcome! Please read our contributing guidelines and submit pull requests to our repository.
376
+
377
+ ## Support
378
+
379
+ - 📖 [Documentation](https://github.com/Maistik-Studio/nuxt-pdf)
380
+ - 🐛 [Issue Tracker](https://github.com/Maistik-Studio/nuxt-pdf/issues)
381
+ - 💬 [Discussions](https://github.com/Maistik-Studio/nuxt-pdf/discussions)
@@ -0,0 +1,44 @@
1
+ import { HelperDelegate } from 'handlebars';
2
+
3
+ interface PdfModuleOptions {
4
+ provider: 'gotenberg' | 'browserless' | 'puppeteer';
5
+ components: string[];
6
+ sharedComponents: string[];
7
+ enableI18n: boolean;
8
+ defaultLocale: string;
9
+ availableLocales: string[];
10
+ i18nMessages: Record<string, Record<string, string>>;
11
+ providers: {
12
+ gotenberg: {
13
+ url: string;
14
+ };
15
+ browserless: {
16
+ url: string;
17
+ apiKey: string;
18
+ };
19
+ puppeteer: {
20
+ launchOptions: Record<string, any>;
21
+ };
22
+ };
23
+ defaultOptions: {
24
+ format: string;
25
+ margin: {
26
+ top: number;
27
+ bottom: number;
28
+ left: number;
29
+ right: number;
30
+ };
31
+ landscape: boolean;
32
+ printBackground: boolean;
33
+ pageBreak: {
34
+ before: string[];
35
+ after: string[];
36
+ avoid: string[];
37
+ };
38
+ };
39
+ customHelpers?: Record<string, HelperDelegate>;
40
+ }
41
+ declare const _default: any;
42
+
43
+ export { _default as default };
44
+ export type { PdfModuleOptions };
@@ -0,0 +1,44 @@
1
+ import { HelperDelegate } from 'handlebars';
2
+
3
+ interface PdfModuleOptions {
4
+ provider: 'gotenberg' | 'browserless' | 'puppeteer';
5
+ components: string[];
6
+ sharedComponents: string[];
7
+ enableI18n: boolean;
8
+ defaultLocale: string;
9
+ availableLocales: string[];
10
+ i18nMessages: Record<string, Record<string, string>>;
11
+ providers: {
12
+ gotenberg: {
13
+ url: string;
14
+ };
15
+ browserless: {
16
+ url: string;
17
+ apiKey: string;
18
+ };
19
+ puppeteer: {
20
+ launchOptions: Record<string, any>;
21
+ };
22
+ };
23
+ defaultOptions: {
24
+ format: string;
25
+ margin: {
26
+ top: number;
27
+ bottom: number;
28
+ left: number;
29
+ right: number;
30
+ };
31
+ landscape: boolean;
32
+ printBackground: boolean;
33
+ pageBreak: {
34
+ before: string[];
35
+ after: string[];
36
+ avoid: string[];
37
+ };
38
+ };
39
+ customHelpers?: Record<string, HelperDelegate>;
40
+ }
41
+ declare const _default: any;
42
+
43
+ export { _default as default };
44
+ export type { PdfModuleOptions };
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "@maistik/nuxt-pdf",
3
+ "configKey": "pdf",
4
+ "compatibility": {
5
+ "nuxt": "^3.0.0"
6
+ },
7
+ "version": "1.0.17",
8
+ "builder": {
9
+ "@nuxt/module-builder": "1.0.1",
10
+ "unbuild": "3.5.0"
11
+ }
12
+ }
@@ -0,0 +1,113 @@
1
+ import { defineNuxtModule, createResolver, addServerHandler, addImports } from '@nuxt/kit';
2
+ import { existsSync, readdirSync, statSync, readFileSync } from 'fs';
3
+ import { join } from 'path';
4
+
5
+ const module = defineNuxtModule({
6
+ meta: {
7
+ name: "@maistik/nuxt-pdf",
8
+ configKey: "pdf",
9
+ compatibility: {
10
+ nuxt: "^3.0.0"
11
+ }
12
+ },
13
+ defaults: {
14
+ provider: "gotenberg",
15
+ components: ["pdf"],
16
+ sharedComponents: ["pdf/partials"],
17
+ enableI18n: true,
18
+ defaultLocale: "en",
19
+ availableLocales: ["en"],
20
+ i18nMessages: {},
21
+ providers: {
22
+ gotenberg: {
23
+ url: "http://localhost:3000"
24
+ },
25
+ browserless: {
26
+ url: "https://chrome.browserless.io",
27
+ apiKey: ""
28
+ },
29
+ puppeteer: {
30
+ launchOptions: {
31
+ headless: true,
32
+ args: ["--no-sandbox", "--disable-setuid-sandbox"]
33
+ }
34
+ }
35
+ },
36
+ customHelpers: {},
37
+ defaultOptions: {
38
+ format: "A4",
39
+ margin: {
40
+ top: 20,
41
+ bottom: 20,
42
+ left: 20,
43
+ right: 20
44
+ },
45
+ landscape: false,
46
+ printBackground: true,
47
+ pageBreak: {
48
+ before: ["always"],
49
+ after: ["always"],
50
+ avoid: [".no-break"]
51
+ }
52
+ }
53
+ },
54
+ setup(options, nuxt) {
55
+ const resolver = createResolver(import.meta.url);
56
+ const templateSources = {};
57
+ const partialSources = {};
58
+ for (const componentDir of options.components) {
59
+ const fullPath = join(nuxt.options.srcDir, componentDir);
60
+ if (existsSync(fullPath)) {
61
+ scanDirectory(fullPath, templateSources, ".hbs");
62
+ }
63
+ }
64
+ for (const sharedDir of options.sharedComponents) {
65
+ const fullPath = join(nuxt.options.srcDir, sharedDir);
66
+ if (existsSync(fullPath)) {
67
+ scanDirectory(fullPath, partialSources, ".hbs");
68
+ }
69
+ }
70
+ nuxt.options.runtimeConfig.pdf = {
71
+ provider: options.provider,
72
+ enableI18n: options.enableI18n,
73
+ defaultLocale: options.defaultLocale,
74
+ availableLocales: options.availableLocales,
75
+ i18nMessages: options.i18nMessages,
76
+ providers: options.providers,
77
+ defaultOptions: options.defaultOptions,
78
+ templateSources,
79
+ partialSources,
80
+ customHelpers: options.customHelpers || {}
81
+ };
82
+ nuxt.options.runtimeConfig.public.pdf = {
83
+ enableI18n: options.enableI18n,
84
+ defaultLocale: options.defaultLocale,
85
+ availableLocales: options.availableLocales
86
+ };
87
+ addServerHandler({
88
+ route: "/api/pdf",
89
+ handler: resolver.resolve("./runtime/server/api/pdf.post")
90
+ });
91
+ addImports({
92
+ name: "usePdf",
93
+ as: "usePdf",
94
+ from: resolver.resolve("./runtime/composables/usePdf")
95
+ });
96
+ }
97
+ });
98
+ function scanDirectory(dir, sources, extension) {
99
+ if (!existsSync(dir)) return;
100
+ const files = readdirSync(dir);
101
+ for (const file of files) {
102
+ const filePath = join(dir, file);
103
+ const stat = statSync(filePath);
104
+ if (stat.isDirectory()) {
105
+ scanDirectory(filePath, sources, extension);
106
+ } else if (file.endsWith(extension)) {
107
+ const templateName = file.replace(extension, "");
108
+ sources[templateName] = readFileSync(filePath, "utf-8");
109
+ }
110
+ }
111
+ }
112
+
113
+ export { module as default };
@@ -0,0 +1,22 @@
1
+ export interface PdfGenerateOptions {
2
+ format?: string;
3
+ margin?: {
4
+ top?: number;
5
+ bottom?: number;
6
+ left?: number;
7
+ right?: number;
8
+ };
9
+ landscape?: boolean;
10
+ printBackground?: boolean;
11
+ pageBreak?: {
12
+ before?: string[];
13
+ after?: string[];
14
+ avoid?: string[];
15
+ };
16
+ }
17
+ export declare function usePdf(): {
18
+ generate: (template: string, data: any, options?: PdfGenerateOptions, locale?: string) => Promise<Blob>;
19
+ download: (template: string, data: any, options?: PdfGenerateOptions, filename?: string, locale?: string) => Promise<void>;
20
+ getAvailableLocales: () => string[];
21
+ getDefaultLocale: () => string;
22
+ };
@@ -0,0 +1,42 @@
1
+ import { useRuntimeConfig } from "#imports";
2
+ export function usePdf() {
3
+ const config = useRuntimeConfig();
4
+ const generate = async (template, data, options = {}, locale) => {
5
+ const response = await $fetch("/api/pdf", {
6
+ method: "POST",
7
+ body: {
8
+ template,
9
+ ctx: {
10
+ data,
11
+ options,
12
+ locale: locale || config.public.pdf.defaultLocale
13
+ }
14
+ },
15
+ responseType: "arrayBuffer"
16
+ });
17
+ return new Blob([response], { type: "application/pdf" });
18
+ };
19
+ const download = async (template, data, options = {}, filename = "document.pdf", locale) => {
20
+ const blob = await generate(template, data, options, locale);
21
+ const url = URL.createObjectURL(blob);
22
+ const link = document.createElement("a");
23
+ link.href = url;
24
+ link.download = filename;
25
+ document.body.appendChild(link);
26
+ link.click();
27
+ document.body.removeChild(link);
28
+ URL.revokeObjectURL(url);
29
+ };
30
+ const getAvailableLocales = () => {
31
+ return config.public.pdf.availableLocales || ["en"];
32
+ };
33
+ const getDefaultLocale = () => {
34
+ return config.public.pdf.defaultLocale || "en";
35
+ };
36
+ return {
37
+ generate,
38
+ download,
39
+ getAvailableLocales,
40
+ getDefaultLocale
41
+ };
42
+ }
@@ -0,0 +1,2 @@
1
+ declare const _default: any;
2
+ export default _default;
@@ -0,0 +1,31 @@
1
+ import { defineEventHandler, readBody, setHeader } from "h3";
2
+ import { useRuntimeConfig } from "#imports";
3
+ import { compilePdfComponent } from "../../utils/compiler.js";
4
+ import { createPdfProvider } from "../../utils/providers.js";
5
+ export default defineEventHandler(async (event) => {
6
+ try {
7
+ const config = useRuntimeConfig();
8
+ const { template, ctx } = await readBody(event);
9
+ if (!template || !ctx) {
10
+ throw new Error("Missing template or context");
11
+ }
12
+ const { data, options = {}, locale } = ctx;
13
+ const mergedOptions = { ...config.pdf.defaultOptions, ...options };
14
+ const messages = config.pdf.enableI18n ? config.pdf.i18nMessages[locale || config.pdf.defaultLocale] || {} : {};
15
+ const html = compilePdfComponent(
16
+ template,
17
+ { data, options: mergedOptions, locale },
18
+ messages,
19
+ config.pdf.templateSources,
20
+ config.pdf.partialSources
21
+ );
22
+ const provider = createPdfProvider(config.pdf.provider, config.pdf.providers);
23
+ const pdfBuffer = await provider.generatePdf(html, mergedOptions);
24
+ setHeader(event, "Content-Type", "application/pdf");
25
+ setHeader(event, "Content-Length", pdfBuffer.length.toString());
26
+ return pdfBuffer;
27
+ } catch (error) {
28
+ console.error("PDF generation error:", error);
29
+ throw error;
30
+ }
31
+ });
@@ -0,0 +1,36 @@
1
+ export interface PdfContext {
2
+ data: any;
3
+ options: PdfOptions;
4
+ locale?: string;
5
+ }
6
+ export interface PdfOptions {
7
+ format?: 'A4' | 'Letter' | 'Legal';
8
+ margin?: {
9
+ top?: number;
10
+ bottom?: number;
11
+ left?: number;
12
+ right?: number;
13
+ };
14
+ landscape?: boolean;
15
+ printBackground?: boolean;
16
+ pageBreak?: {
17
+ before?: string[];
18
+ after?: string[];
19
+ avoid?: string[];
20
+ };
21
+ }
22
+ export interface PdfProviderConfig {
23
+ gotenberg: {
24
+ url: string;
25
+ };
26
+ browserless: {
27
+ url: string;
28
+ apiKey: string;
29
+ };
30
+ puppeteer: {
31
+ launchOptions: Record<string, any>;
32
+ };
33
+ }
34
+ export interface PdfProvider {
35
+ generatePdf(html: string, options: any): Promise<Buffer>;
36
+ }
File without changes
@@ -0,0 +1,5 @@
1
+ export declare function compilePdfComponent(templateName: string, ctx: {
2
+ data: any;
3
+ options: any;
4
+ locale?: string;
5
+ }, messages: Record<string, string>, templateSources: Record<string, string>, partialSources: Record<string, string>): string;
@@ -0,0 +1,185 @@
1
+ import { useRuntimeConfig } from "#imports";
2
+ import Handlebars from "handlebars";
3
+ export function compilePdfComponent(templateName, ctx, messages, templateSources, partialSources) {
4
+ const handlebars = Handlebars.create();
5
+ handlebars.registerHelper("t", function(key) {
6
+ const keys = key.split(".");
7
+ let value = messages;
8
+ for (const k of keys) {
9
+ if (value && typeof value === "object" && k in value) {
10
+ value = value[k];
11
+ } else {
12
+ return key;
13
+ }
14
+ }
15
+ return typeof value === "string" ? value : key;
16
+ });
17
+ handlebars.registerHelper("formatCurrency", function(value, currencyOrOptions) {
18
+ if (typeof value !== "number") {
19
+ return value;
20
+ }
21
+ let currency = "USD";
22
+ if (typeof currencyOrOptions === "string") {
23
+ currency = currencyOrOptions;
24
+ } else if (currencyOrOptions && typeof currencyOrOptions.hash?.currency === "string") {
25
+ currency = currencyOrOptions.hash.currency;
26
+ }
27
+ if (!/^[A-Z]{3}$/.test(currency)) {
28
+ currency = "USD";
29
+ }
30
+ return new Intl.NumberFormat(ctx.locale || "en-US", {
31
+ style: "currency",
32
+ currency
33
+ }).format(value);
34
+ });
35
+ handlebars.registerHelper("lineTotal", function(qty, price) {
36
+ return (qty * price).toFixed(2);
37
+ });
38
+ handlebars.registerHelper("eq", function(a, b, options) {
39
+ const isEqual = a === b;
40
+ if (options && typeof options.fn === "function") {
41
+ return isEqual ? options.fn(this) : options.inverse(this);
42
+ }
43
+ return isEqual;
44
+ });
45
+ handlebars.registerHelper(
46
+ "ne",
47
+ (a, b, opts) => a !== b ? opts.fn(this) : opts.inverse(this)
48
+ );
49
+ handlebars.registerHelper(
50
+ "gt",
51
+ (a, b, opts) => a > b ? opts.fn(this) : opts.inverse(this)
52
+ );
53
+ handlebars.registerHelper("formatDate", function(date, formatOrOptions) {
54
+ let style = "short";
55
+ if (typeof formatOrOptions === "string") {
56
+ style = formatOrOptions;
57
+ } else if (formatOrOptions && typeof formatOrOptions.hash?.format === "string") {
58
+ style = formatOrOptions.hash.format;
59
+ }
60
+ if (!["full", "long", "medium", "short"].includes(style)) {
61
+ style = "short";
62
+ }
63
+ let dateObj;
64
+ if (typeof date === "string") {
65
+ dateObj = new Date(date);
66
+ } else if (date instanceof Date) {
67
+ dateObj = date;
68
+ } else {
69
+ dateObj = /* @__PURE__ */ new Date();
70
+ }
71
+ if (isNaN(dateObj.getTime())) {
72
+ dateObj = /* @__PURE__ */ new Date();
73
+ }
74
+ return new Intl.DateTimeFormat(ctx.locale || "en-US", {
75
+ dateStyle: style
76
+ }).format(dateObj);
77
+ });
78
+ handlebars.registerHelper("formatNumber", function(value, options = {}) {
79
+ if (typeof value !== "number") return value;
80
+ return new Intl.NumberFormat(ctx.locale || "en-US", options).format(value);
81
+ });
82
+ try {
83
+ const config = useRuntimeConfig();
84
+ const customHelpers = config.pdf.customHelpers;
85
+ if (customHelpers && typeof customHelpers === "object") {
86
+ Object.entries(customHelpers).forEach(([name, helper]) => {
87
+ if (typeof helper === "function") {
88
+ handlebars.registerHelper(name, helper);
89
+ }
90
+ });
91
+ }
92
+ } catch (error) {
93
+ console.warn("Failed to load custom helpers:", error);
94
+ }
95
+ handlebars.registerHelper("upper", function(str) {
96
+ return String(str || "").toUpperCase();
97
+ });
98
+ handlebars.registerHelper("lower", function(str) {
99
+ return String(str || "").toLowerCase();
100
+ });
101
+ handlebars.registerHelper("capitalize", function(str) {
102
+ return String(str || "").charAt(0).toUpperCase() + String(str || "").slice(1).toLowerCase();
103
+ });
104
+ handlebars.registerHelper("truncate", function(str, length) {
105
+ const text = String(str || "");
106
+ return text.length > length ? `${text.substring(0, length)}...` : text;
107
+ });
108
+ handlebars.registerHelper("multiply", function(a, b) {
109
+ return (a || 0) * (b || 0);
110
+ });
111
+ handlebars.registerHelper("add", function(a, b) {
112
+ return (a || 0) + (b || 0);
113
+ });
114
+ handlebars.registerHelper("subtract", function(a, b) {
115
+ return (a || 0) - (b || 0);
116
+ });
117
+ handlebars.registerHelper("divide", function(a, b) {
118
+ return b !== 0 ? (a || 0) / b : 0;
119
+ });
120
+ handlebars.registerHelper("percentage", function(value, total) {
121
+ return total !== 0 ? `${((value || 0) / total * 100).toFixed(2)}%` : "0%";
122
+ });
123
+ Object.entries(partialSources).forEach(([name, source]) => {
124
+ handlebars.registerPartial(name, source);
125
+ });
126
+ const templateSource = templateSources[templateName];
127
+ if (!templateSource) {
128
+ throw new Error(`Template "${templateName}" not found`);
129
+ }
130
+ const template = handlebars.compile(templateSource);
131
+ const enrichedData = enrichContextForTemplate(templateName, ctx.data);
132
+ return template({
133
+ ...enrichedData,
134
+ options: ctx.options,
135
+ locale: ctx.locale
136
+ });
137
+ }
138
+ function enrichContextForTemplate(templateName, data) {
139
+ const enriched = { ...data };
140
+ if (templateName.toLowerCase().includes("invoice")) {
141
+ if (enriched.items && Array.isArray(enriched.items)) {
142
+ enriched.subtotal = enriched.items.reduce((sum, item) => {
143
+ return sum + item.quantity * item.price;
144
+ }, 0);
145
+ enriched.tax = enriched.subtotal * (enriched.taxRate || 0.1);
146
+ enriched.total = enriched.subtotal + enriched.tax;
147
+ if (enriched.issueDate && enriched.paymentTerms) {
148
+ const issueDate = new Date(enriched.issueDate);
149
+ const dueDate = new Date(issueDate);
150
+ dueDate.setDate(dueDate.getDate() + enriched.paymentTerms);
151
+ enriched.dueDate = dueDate;
152
+ }
153
+ }
154
+ }
155
+ if (templateName.toLowerCase().includes("salesreport")) {
156
+ if (enriched.salesData && Array.isArray(enriched.salesData)) {
157
+ enriched.quarters = calculateQuarters(enriched.salesData);
158
+ const totalSales = enriched.salesData.reduce((sum, item) => sum + item.amount, 0);
159
+ enriched.rating = calculatePerformanceRating(totalSales);
160
+ }
161
+ }
162
+ return enriched;
163
+ }
164
+ function calculateQuarters(salesData) {
165
+ const quarters = [
166
+ { name: "Q1", months: [0, 1, 2], total: 0 },
167
+ { name: "Q2", months: [3, 4, 5], total: 0 },
168
+ { name: "Q3", months: [6, 7, 8], total: 0 },
169
+ { name: "Q4", months: [9, 10, 11], total: 0 }
170
+ ];
171
+ salesData.forEach((item) => {
172
+ const month = new Date(item.date).getMonth();
173
+ const quarter = quarters.find((q) => q.months.includes(month));
174
+ if (quarter) {
175
+ quarter.total += item.amount;
176
+ }
177
+ });
178
+ return quarters;
179
+ }
180
+ function calculatePerformanceRating(totalSales) {
181
+ if (totalSales >= 1e5) return "Excellent";
182
+ if (totalSales >= 75e3) return "Good";
183
+ if (totalSales >= 5e4) return "Average";
184
+ return "Needs Improvement";
185
+ }
@@ -0,0 +1,2 @@
1
+ import type { PdfProvider } from '../types.js';
2
+ export declare function createPdfProvider(providerType: string, config: any): PdfProvider;
@@ -0,0 +1,118 @@
1
+ import FormData from "form-data";
2
+ export function createPdfProvider(providerType, config) {
3
+ switch (providerType) {
4
+ case "gotenberg":
5
+ return new GotenbergProvider(config.gotenberg);
6
+ case "browserless":
7
+ return new BrowserlessProvider(config.browserless);
8
+ case "puppeteer":
9
+ return new PuppeteerProvider(config.puppeteer);
10
+ default:
11
+ throw new Error(`Unknown PDF provider: ${providerType}`);
12
+ }
13
+ }
14
+ class GotenbergProvider {
15
+ constructor(config) {
16
+ this.config = config;
17
+ }
18
+ async generatePdf(html, options) {
19
+ const form = new FormData();
20
+ const htmlBuffer = Buffer.from(html, "utf8");
21
+ form.append("files", htmlBuffer, {
22
+ filename: "index.html",
23
+ contentType: "text/html",
24
+ knownLength: htmlBuffer.length
25
+ });
26
+ if (options.format) {
27
+ const dimensions = this.getPageDimensions(options.format);
28
+ form.append("paperWidth", dimensions.width);
29
+ form.append("paperHeight", dimensions.height);
30
+ }
31
+ if (options.margin) {
32
+ form.append("marginTop", (options.margin.top / 25.4).toString());
33
+ form.append("marginBottom", (options.margin.bottom / 25.4).toString());
34
+ form.append("marginLeft", (options.margin.left / 25.4).toString());
35
+ form.append("marginRight", (options.margin.right / 25.4).toString());
36
+ }
37
+ if (options.landscape) {
38
+ form.append("landscape", "true");
39
+ }
40
+ if (options.printBackground) {
41
+ form.append("printBackground", "true");
42
+ }
43
+ form.append("waitForNetworkIdle", "true");
44
+ form.append("waitDelay", "1s");
45
+ const bodyBuffer = form.getBuffer();
46
+ const headers = {
47
+ ...form.getHeaders(),
48
+ // multipart/form-data; boundary=XXX
49
+ "Content-Length": bodyBuffer.length.toString()
50
+ };
51
+ const response = await fetch(`${this.config.url}/forms/chromium/convert/html`, {
52
+ method: "POST",
53
+ body: bodyBuffer,
54
+ headers
55
+ });
56
+ if (!response.ok) {
57
+ throw new Error(`Gotenberg error: ${response.statusText}`);
58
+ }
59
+ return Buffer.from(await response.arrayBuffer());
60
+ }
61
+ getPageDimensions(format) {
62
+ const formats = {
63
+ A4: { width: "8.27in", height: "11.69in" },
64
+ Letter: { width: "8.5in", height: "11in" },
65
+ Legal: { width: "8.5in", height: "14in" }
66
+ };
67
+ return formats[format] || formats["A4"];
68
+ }
69
+ }
70
+ class BrowserlessProvider {
71
+ constructor(config) {
72
+ this.config = config;
73
+ }
74
+ async generatePdf(html, options) {
75
+ const response = await fetch(`${this.config.url}/pdf?token=${this.config.apiKey}`, {
76
+ method: "POST",
77
+ headers: {
78
+ "Content-Type": "application/json"
79
+ },
80
+ body: JSON.stringify({
81
+ html,
82
+ options: {
83
+ format: options.format || "A4",
84
+ margin: options.margin,
85
+ landscape: options.landscape || false,
86
+ printBackground: options.printBackground !== false
87
+ }
88
+ })
89
+ });
90
+ if (!response.ok) {
91
+ throw new Error(`Browserless error: ${response.statusText}`);
92
+ }
93
+ return Buffer.from(await response.arrayBuffer());
94
+ }
95
+ }
96
+ class PuppeteerProvider {
97
+ constructor(config) {
98
+ this.config = config;
99
+ }
100
+ async generatePdf(html, options) {
101
+ const puppeteer = await import("puppeteer").then((m) => m.default);
102
+ const browser = await puppeteer.launch(this.config.launchOptions);
103
+ try {
104
+ const page = await browser.newPage();
105
+ await page.setContent(html, { waitUntil: "networkidle0" });
106
+ const pdfOptions = {
107
+ format: options.format || "A4",
108
+ margin: options.margin,
109
+ landscape: options.landscape || false,
110
+ printBackground: options.printBackground !== false
111
+ };
112
+ const pdfBuffer = await page.pdf(pdfOptions);
113
+ return Buffer.from(pdfBuffer);
114
+ } finally {
115
+ await browser.close();
116
+ }
117
+ }
118
+ }
@@ -0,0 +1,9 @@
1
+ import type { NuxtModule } from '@nuxt/schema'
2
+
3
+ import type { default as Module } from './module.mjs'
4
+
5
+ export type ModuleOptions = typeof Module extends NuxtModule<infer O> ? Partial<O> : Record<string, any>
6
+
7
+ export { default } from './module.mjs'
8
+
9
+ export { type PdfModuleOptions } from './module.mjs'
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@maistik/nuxt-pdf",
3
+ "version": "1.0.17",
4
+ "description": "A Nuxt 3 module for server-side PDF generation using Handlebars templates",
5
+ "type": "module",
6
+ "private": false,
7
+ "main": "./dist/module.mjs",
8
+ "files": [
9
+ "dist"
10
+ ],
11
+ "scripts": {
12
+ "build": "nuxt-module-build prepare && nuxt-module-build build",
13
+ "dev": "nuxi dev playground",
14
+ "dev:build": "nuxi build playground",
15
+ "dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxi prepare playground",
16
+ "release": "npm run lint && npm run test:unit && npm run prepack && changelogen --release && npm publish --access public && git push --follow-tags",
17
+ "lint": "eslint .",
18
+ "lint:fix": "eslint . --fix",
19
+ "test": "vitest run && playwright test",
20
+ "test:watch": "vitest watch",
21
+ "test:unit": "vitest run",
22
+ "test:e2e": "playwright test",
23
+ "test:e2e:ui": "playwright test --ui",
24
+ "prepack": "nuxt-module-build build"
25
+ },
26
+ "dependencies": {
27
+ "@nuxt/kit": "^3.17.7",
28
+ "handlebars": "^4.7.8",
29
+ "puppeteer": "^24.14.0",
30
+ "form-data": "^4.0.4"
31
+ },
32
+ "devDependencies": {
33
+ "@nuxt/devtools": "latest",
34
+ "@nuxt/eslint-config": "^1.6.0",
35
+ "@nuxt/module-builder": "^1.0.1",
36
+ "@nuxt/schema": "^3.17.7",
37
+ "@nuxt/test-utils": "^3.19.2",
38
+ "@playwright/test": "^1.49.1",
39
+ "changelogen": "^0.6.2",
40
+ "eslint": "^9.31.0",
41
+ "nuxt": "^3.17.7",
42
+ "unbuild": "^3.5.0",
43
+ "vitest": "^3.2.4"
44
+ },
45
+ "keywords": [
46
+ "nuxt",
47
+ "nuxt3",
48
+ "pdf",
49
+ "handlebars",
50
+ "gotenberg",
51
+ "browserless",
52
+ "puppeteer"
53
+ ],
54
+ "author": "Maistik Studio",
55
+ "license": "MIT",
56
+ "repository": {
57
+ "type": "git",
58
+ "url": "https://github.com/Maistik-Studio/nuxt-pdf"
59
+ },
60
+ "packageManager": "pnpm@9.15.3"
61
+ }