@lupinum/nuxt-pdf 0.3.1 → 0.4.0-beta.1

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/CHANGELOG.md CHANGED
@@ -4,6 +4,34 @@ All notable changes to `@lupinum/nuxt-pdf` are recorded here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project adheres
5
5
  to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## 0.4.0-beta.1 - 2026-08-22
8
+
9
+ ### Added
10
+
11
+ - Add strict PDF primitive stubs so invalid props fail during authoring instead
12
+ of reaching the renderer.
13
+ - Add safer local and remote image admission, configured-font validation, and
14
+ clearer development preview diagnostics.
15
+ - Add source-derived documentation checks for primitive props, error codes,
16
+ page sizes, render limits, public exports, routes, and complete examples.
17
+
18
+ ### Changed
19
+
20
+ - Rebuild the documentation around a first-render tutorial, focused task
21
+ guides, complete API reference, production guidance, and compatibility
22
+ evidence.
23
+ - Expand the playground with reusable invoice components, production document
24
+ recipes, and stronger preview scenarios.
25
+ - Refresh the documentation design with stable Ginko packages and the Nuxt
26
+ visual language.
27
+
28
+ ### Fixed
29
+
30
+ - Keep release workflows tied to the exact verified artifact and independent
31
+ of unnecessary GitHub tokens.
32
+ - Build documentation when no previous Vercel deployment baseline exists.
33
+ - Prepare Nuxt-generated types before clean package and documentation builds.
34
+
7
35
  ## 0.3.1 - 2026-08-13
8
36
 
9
37
  ### Fixed
package/CONFORMANCE.md CHANGED
@@ -1,11 +1,11 @@
1
- # Nuxt PDF 0.3.1 conformance
1
+ # Nuxt PDF 0.4.0-beta.1 conformance
2
2
 
3
3
  Nuxt PDF claims behavioral compatibility for a deliberately small, tested
4
4
  corpus. It does not claim full React PDF API or test-suite compatibility.
5
5
 
6
6
  ## Version boundary
7
7
 
8
- | Layer | 0.3.1 boundary |
8
+ | Layer | 0.4.0-beta.1 boundary |
9
9
  |---|---|
10
10
  | Node.js | `^22.14.0`, `^24.0.0`, or `^26.0.0` |
11
11
  | Nuxt | `^4.4.8` |
@@ -158,7 +158,9 @@ claimed.
158
158
  **Images** (`test/corpus/images.test.ts`):
159
159
 
160
160
  - JPEG file paths, base64 `data:` URLs, and `{ data, format }` buffer sources all
161
- decode and render;
161
+ decode in the lower-engine paired fixture. The public resource boundary
162
+ accepts bundled paths, admitted byte sources, and allowlisted HTTPS images;
163
+ it blocks `data:` URL strings on every public render path;
162
164
  - explicit width+height, single-dimension aspect scaling, and percent width
163
165
  against the page content box size the laid-out box (reviewed baseline);
164
166
  - `objectFit` `contain` letterboxes and `cover` crops while the box stays fixed
@@ -202,7 +204,7 @@ checking.
202
204
 
203
205
  ### Vue and Nuxt authoring
204
206
 
205
- The 0.3.1 tests verify:
207
+ The 0.4.0-beta.1 tests verify:
206
208
 
207
209
  - `PdfDocument`, `PdfPage`, `PdfView`, `PdfText`, `PdfImage`, `PdfLink`, and
208
210
  `PdfNote`;
@@ -215,8 +217,9 @@ The 0.3.1 tests verify:
215
217
  - framework-owned `PdfStyle` and exact primitive prop types checked through a
216
218
  real Vue SFC, plus closed per-primitive runtime allowlists that reject
217
219
  unknown, DOM/event, removed, and wrong-host props without echoing values;
218
- - exactly one `src`/`source` for `PdfImage`, exactly one `href`/`src` for
219
- `PdfLink`, and context-specific page-flow/SVG `PdfText` invariants;
220
+ - a required `src` for `PdfImage`, a required `href` for `PdfLink` (an `href`
221
+ starting with `#` is an internal destination), and context-specific
222
+ page-flow/SVG `PdfText` invariants;
220
223
  - one completed render held behind immutable byte, buffer, and `Response`
221
224
  conversions, with exact frozen resolved metadata and one frozen, content-free
222
225
  diagnostics object shared by the public result and development preview; the
@@ -287,7 +290,7 @@ and exposes it through one composable and existing props. The tested boundary:
287
290
  `PDF_LIMIT_EXCEEDED` `NuxtPdfError` attributed to the template key and file
288
291
  through the same boundary as every other render failure.
289
292
  - **Named destinations resolve to a section's first page.** A node's `id` becomes
290
- a named destination; a `PdfLink` `src="#id"` jumps to it. When the id sits on a
293
+ a named destination; a `PdfLink` `href="#id"` jumps to it. When the id sits on a
291
294
  node that spans a page boundary, both the printed number and the jump target
292
295
  resolve to the section's **first** page (a deliberate divergence from React PDF,
293
296
  whose last-writer-wins destination table points at the last page). This holds on
@@ -317,8 +320,10 @@ and exposes it through one composable and existing props. The tested boundary:
317
320
 
318
321
  ### Local resources
319
322
 
320
- The module validates and embeds configured resources during the Nuxt build.
321
- The tested boundary includes:
323
+ The module validates configured resources during the Nuxt build. Production
324
+ builds embed validated image bytes in the server bundle; development builds
325
+ point at the source files and re-read them per render, so an edited image
326
+ shows up without a restart. The tested boundary includes:
322
327
 
323
328
  - PNG and JPEG extension/signature validation and source byte limits;
324
329
  - TTF and OTF signature/extension/SFNT table-directory validation,
@@ -432,7 +437,7 @@ parser, not two. Claimed:
432
437
  Verified end-to-end against a real rendered template, including assertion
433
438
  failure messages, in `test/test-utils-public.test.ts`.
434
439
 
435
- ## Explicitly not claimed in 0.3.1
440
+ ## Explicitly not claimed in 0.4.0-beta.1
436
441
 
437
442
  - Full React PDF component, hook, browser-helper, or test-suite parity.
438
443
  - React runtime compatibility or React-shaped dynamic callback results.
@@ -446,7 +451,7 @@ failure messages, in `test/test-utils-public.test.ts`.
446
451
  - Nuxt 3, Node 20, or versions outside the table above.
447
452
  - Authenticated remote fetches, request headers/bodies, credentialed requests,
448
453
  proxies, or private-IP/DNS-rebinding protection. Opt-in allowlisted remote
449
- images and fonts are claimed above under "Opt-in remote resources".
454
+ images are claimed above. Remote fonts are not supported.
450
455
  - SVG image files (as an image source), SVG `Marker`, alternate gradient
451
456
  coordinate systems/transforms/inheritance, and `preserveAspectRatio` modes.
452
457
  Radial-gradient inner radius (`fr`) is also absent because the pinned renderer
package/README.md CHANGED
@@ -1,21 +1,33 @@
1
1
  <p align="center">
2
- <img src="docs/public/logo.svg" width="128" alt="Nuxt PDF">
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset="docs/public/icon-dark.svg">
4
+ <source media="(prefers-color-scheme: light)" srcset="docs/public/icon-light.svg">
5
+ <img src="docs/public/icon-light.svg" width="128" alt="Nuxt PDF icon">
6
+ </picture>
3
7
  </p>
4
8
 
5
- <h1 align="center">Nuxt PDF</h1>
9
+ <h1 align="center">
10
+ <picture>
11
+ <source media="(prefers-color-scheme: dark)" srcset="docs/public/wordmark-dark.svg">
12
+ <source media="(prefers-color-scheme: light)" srcset="docs/public/wordmark-light.svg">
13
+ <img src="docs/public/wordmark-light.svg" width="256" alt="Nuxt PDF">
14
+ </picture>
15
+ </h1>
6
16
 
7
17
  <p align="center">
8
18
  Author and render PDFs with Vue components in Nuxt.
9
19
  </p>
10
20
 
11
21
  <p align="center">
12
- <a href="https://www.npmjs.com/package/@lupinum/nuxt-pdf"><img src="https://img.shields.io/npm/v/@lupinum/nuxt-pdf?color=315d3b" alt="npm version"></a>
22
+ <a href="https://www.npmjs.com/package/@lupinum/nuxt-pdf"><img src="https://img.shields.io/npm/v/@lupinum/nuxt-pdf?color=00DC82" alt="npm version"></a>
13
23
  <a href="https://github.com/lupinum-dev/nuxt-pdf/actions/workflows/ci.yml"><img src="https://github.com/lupinum-dev/nuxt-pdf/actions/workflows/ci.yml/badge.svg" alt="CI status"></a>
14
- <a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-315d3b" alt="MIT license"></a>
24
+ <a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-00DC82" alt="MIT license"></a>
25
+ <a href="https://discord.gg/RPH6SeA36N"><img src="https://img.shields.io/badge/Discord-18181B?logo=discord" alt="Discord"></a>
26
+ <a href="https://deepwiki.com/lupinum-dev/nuxt-pdf"><img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki"></a>
15
27
  </p>
16
28
 
17
29
  > [!IMPORTANT]
18
- > Nuxt PDF is an external alpha. Its tested behavior is stable within each
30
+ > Nuxt PDF is in public beta. Its tested behavior is stable within each
19
31
  > release, but a minor release can contain documented breaking changes before
20
32
  > version 1.0.
21
33
 
@@ -65,7 +77,7 @@ export default defineNuxtConfig({
65
77
 
66
78
  Create `pdfs/invoice.vue`:
67
79
 
68
- ```vue
80
+ ```vue [pdfs/invoice.vue]
69
81
  <script setup lang="ts">
70
82
  type InvoiceProps = {
71
83
  invoice: {
@@ -80,6 +92,7 @@ const props = defineProps<InvoiceProps>()
80
92
  definePdf<InvoiceProps>({
81
93
  title: ({ invoice }) => `Invoice ${invoice.number}`,
82
94
  filename: ({ invoice }) => `invoice-${invoice.number}.pdf`,
95
+ language: 'en-GB',
83
96
  sampleData: {
84
97
  invoice: {
85
98
  customer: 'Ada Lovelace',
@@ -123,14 +136,42 @@ export default defineEventHandler(async () => {
123
136
  })
124
137
  ```
125
138
 
126
- Start Nuxt. Open these development routes:
139
+ Start Nuxt:
140
+
141
+ ```bash
142
+ pnpm dev
143
+ ```
144
+
145
+ Download the PDF from a second terminal:
146
+
147
+ ```bash
148
+ curl -o invoice.pdf http://localhost:3000/api/invoice
149
+ ```
150
+
151
+ Open these development routes:
127
152
 
128
153
  - `/_pdf` lists the discovered templates.
129
154
  - `/_pdf/invoice` shows the document preview.
130
155
  - `/api/invoice` returns the production-style PDF response.
131
156
 
132
- Restart `nuxt dev` after you first enable the module. This action generates
133
- the typed `#pdf` registry.
157
+ Nuxt PDF regenerates the typed `#pdf` registry whenever templates change. If
158
+ your editor still shows `#pdf` as untyped, run `nuxt prepare` once to refresh
159
+ the generated types.
160
+
161
+ ## Discord
162
+
163
+ Join the Lupinum OSS community to discuss Nuxt PDF, ask questions, and share
164
+ what you build.
165
+
166
+ <p align="center">
167
+ <a href="https://discord.gg/RPH6SeA36N">
168
+ <picture>
169
+ <source media="(prefers-color-scheme: dark)" srcset="docs/public/discord-dark.svg">
170
+ <source media="(prefers-color-scheme: light)" srcset="docs/public/discord-light.svg">
171
+ <img src="docs/public/discord-light.svg" width="500" alt="Join the Lupinum OSS Discord">
172
+ </picture>
173
+ </a>
174
+ </p>
134
175
 
135
176
  ## How it works
136
177
 
@@ -181,6 +222,10 @@ assets, links, bookmarks, tables of contents, testing, and the complete API
181
222
  reference. Read [CONFORMANCE.md](./CONFORMANCE.md) for the exact tested behavior
182
223
  and known limitations.
183
224
 
225
+ Coding agents should start with
226
+ [`/llms.txt`](https://nuxt-pdf.lupinum.com/llms.txt). It links to concise raw
227
+ Markdown for common tasks, reference pages, errors, and compatibility evidence.
228
+
184
229
  ## Contributing and development
185
230
 
186
231
  ```bash
package/dist/module.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
- "name": "nuxt-pdf",
2
+ "name": "@lupinum/nuxt-pdf",
3
+ "version": "0.4.0-beta.1",
3
4
  "configKey": "pdf",
5
+ "docs": "https://nuxt-pdf.lupinum.com",
4
6
  "compatibility": {
5
- "nuxt": "^4.4.8"
7
+ "nuxt": ">=4.4.8"
6
8
  },
7
- "version": "0.3.1",
8
9
  "builder": {
9
10
  "@nuxt/module-builder": "1.0.2",
10
11
  "unbuild": "unknown"
package/dist/module.mjs CHANGED
@@ -1,11 +1,12 @@
1
1
  import { stat } from 'node:fs/promises';
2
- import { join, isAbsolute, resolve } from 'node:path';
3
- import { defineNuxtModule, createResolver, getLayerDirectories, createIsIgnored, addServerTemplate, addTemplate, addImports, addTypeTemplate, addServerHandler } from '@nuxt/kit';
4
- import { d as discoverPdfTemplates, a as discoverPdfComponentFiles, b as discoverPdfImageFiles, c as bundlePdfFonts, e as createPdfSfcPlugin, f as classifyPdfWatchEvent } from './shared/nuxt-pdf.D2ZziYn4.mjs';
5
2
  import { Buffer } from 'node:buffer';
3
+ import { join, isAbsolute, resolve, relative } from 'node:path';
4
+ import { defineNuxtModule, createResolver, getLayerDirectories, createIsIgnored, addServerTemplate, addTemplate, addImports, addTypeTemplate, logger, addServerHandler, addComponent } from '@nuxt/kit';
6
5
  import { joinURL } from 'ufo';
6
+ import { d as discoverPdfTemplates, a as discoverPdfComponentFiles, b as discoverPdfImageFiles, c as bundlePdfFonts, e as createPdfSfcPlugin, f as classifyPdfWatchEvent } from './shared/nuxt-pdf._V584vX4.mjs';
7
+ import { pdfImageFormatFromKey, loadPdfImageAsset } from '../dist/runtime/server/assets/resolve-asset.js';
8
+ import { PDF_STUB_NAMES } from '../dist/runtime/components/stubs.js';
7
9
  import { normalizeRemoteAssetPolicy } from '../dist/runtime/server/assets/remote.js';
8
- import { loadPdfImageAsset } from '../dist/runtime/server/assets/resolve-asset.js';
9
10
  import { normalizePdfLimits, DEFAULT_PDF_RENDER_LIMITS } from '../dist/runtime/server/render-limits.js';
10
11
  import 'node:fs';
11
12
  import '@vue/compiler-sfc';
@@ -14,6 +15,8 @@ import 'esbuild';
14
15
  import 'unimport';
15
16
  import '../dist/runtime/shared/template.js';
16
17
 
18
+ const version = "0.4.0-beta.1";
19
+
17
20
  const compareText = (left, right) => {
18
21
  if (left < right) return -1;
19
22
  if (left > right) return 1;
@@ -31,14 +34,20 @@ const runtimeOptionsSource = (options) => {
31
34
  const assets = options.assets ?? [];
32
35
  const fonts = options.fonts ?? [];
33
36
  const { remote, limits } = options;
37
+ for (const asset of assets) {
38
+ if (asset.root !== void 0 === (asset.dataB64 !== void 0)) {
39
+ throw new TypeError(
40
+ `PDF image asset "${asset.key}" must declare exactly one of root or dataB64.`
41
+ );
42
+ }
43
+ }
34
44
  if (assets.length === 0 && fonts.length === 0 && !remote && !limits) return [];
35
45
  const lines = ["", "const __pdfRuntimeOptions = Object.freeze({"];
36
46
  if (assets.length > 0) {
37
47
  lines.push(" assets: Object.freeze({");
38
48
  for (const asset of assets) {
39
- lines.push(
40
- ` ${quote$1(asset.key)}: Object.freeze({ data: __pdfBuffer.from(${quote$1(Buffer.from(asset.data).toString("base64"))}, 'base64'), format: ${quote$1(asset.format)} }),`
41
- );
49
+ const entry = asset.root !== void 0 ? `{ format: ${quote$1(asset.format)}, root: ${quote$1(asset.root)} }` : `{ dataB64: ${quote$1(asset.dataB64 ?? "")}, format: ${quote$1(asset.format)} }`;
50
+ lines.push(` ${quote$1(asset.key)}: Object.freeze(${entry}),`);
42
51
  }
43
52
  lines.push(" }),");
44
53
  }
@@ -86,9 +95,6 @@ const generatePdfRuntimeRegistry = (templates, options) => {
86
95
  const lines = [
87
96
  `import { ${runtimeImports} } from ${quote$1(options.runtimeImport)}`
88
97
  ];
89
- if ((options.assets?.length ?? 0) > 0) {
90
- lines.push("import { Buffer as __pdfBuffer } from 'node:buffer'");
91
- }
92
98
  ordered.forEach((template, index) => {
93
99
  lines.push(
94
100
  `import __pdfTemplate${index} from ${quote$1(importPath(template.filePath))}`
@@ -219,10 +225,12 @@ export {}
219
225
  `;
220
226
  const module$1 = defineNuxtModule({
221
227
  meta: {
222
- name: "nuxt-pdf",
228
+ name: "@lupinum/nuxt-pdf",
229
+ version: version,
223
230
  configKey: "pdf",
231
+ docs: "https://nuxt-pdf.lupinum.com",
224
232
  compatibility: {
225
- nuxt: "^4.4.8"
233
+ nuxt: ">=4.4.8"
226
234
  }
227
235
  },
228
236
  defaults: {
@@ -248,13 +256,24 @@ const module$1 = defineNuxtModule({
248
256
  const limits = normalizePdfLimits(options.limits);
249
257
  const resolvedLimits = limits ?? DEFAULT_PDF_RENDER_LIMITS;
250
258
  const imageFiles = await discoverPdfImageFiles(layers, isIgnored);
251
- const assets = await Promise.all(imageFiles.map(
252
- (image) => loadPdfImageAsset(image.key, {
253
- roots: [image.rootDir],
254
- maxBytes: resolvedLimits.maxImageBytes,
255
- maxPixels: resolvedLimits.maxImagePixels
256
- })
257
- ));
259
+ const assetEntries = [];
260
+ for (const image of imageFiles) {
261
+ const format = pdfImageFormatFromKey(image.key);
262
+ if (nuxt.options.dev) {
263
+ assetEntries.push({ format, key: image.key, root: image.rootDir });
264
+ } else {
265
+ const loaded = await loadPdfImageAsset(image.key, {
266
+ roots: [image.rootDir],
267
+ maxBytes: resolvedLimits.maxImageBytes,
268
+ maxPixels: resolvedLimits.maxImagePixels
269
+ });
270
+ assetEntries.push({
271
+ dataB64: Buffer.from(loaded.data).toString("base64"),
272
+ format,
273
+ key: image.key
274
+ });
275
+ }
276
+ }
258
277
  const fontRoots = await existingDirectories(
259
278
  layers.map((layer) => join(layer.rootDir, "pdfs", "fonts"))
260
279
  );
@@ -272,7 +291,7 @@ const module$1 = defineNuxtModule({
272
291
  addServerTemplate({
273
292
  filename: "#pdf",
274
293
  getContents: () => generatePdfRuntimeRegistry(templates, {
275
- assets,
294
+ assets: assetEntries,
276
295
  development: nuxt.options.dev,
277
296
  fonts,
278
297
  remote,
@@ -342,6 +361,9 @@ const module$1 = defineNuxtModule({
342
361
  isIgnored
343
362
  );
344
363
  if (action === "restart") {
364
+ logger.info(
365
+ `nuxt-pdf: ${relative(nuxt.options.rootDir, absolutePath) || absolutePath} changed; restarting to rebuild the PDF registry\u2026`
366
+ );
345
367
  await nuxt.callHook("restart");
346
368
  return;
347
369
  }
@@ -361,6 +383,21 @@ const module$1 = defineNuxtModule({
361
383
  });
362
384
  addServerHandler({ route: "/_pdf", handler: previewHandler });
363
385
  addServerHandler({ route: "/_pdf/**", handler: previewHandler });
386
+ nuxt.hook("devtools:customTabs", (tabs) => {
387
+ tabs.push({
388
+ name: "nuxt-pdf",
389
+ title: "PDF",
390
+ icon: "i-lucide-file-text",
391
+ view: { type: "iframe", src: joinURL(nuxt.options.app.baseURL || "/", "_pdf") }
392
+ });
393
+ });
394
+ for (const name of PDF_STUB_NAMES) {
395
+ addComponent({
396
+ name,
397
+ export: name,
398
+ filePath: resolver.resolve("./runtime/components/stubs")
399
+ });
400
+ }
364
401
  }
365
402
  }
366
403
  });
@@ -71,22 +71,16 @@ export type PdfImageSource = string | Uint8Array | ArrayBuffer | {
71
71
  uri: string;
72
72
  format?: 'png' | 'jpg';
73
73
  };
74
- type PdfImageSourceProp = {
74
+ export type PdfImageProps = PdfBaseProps & PdfBookmarkProp & {
75
+ /** Local bundled path, remote allowlisted URL, or inline bytes. */
75
76
  src: PdfImageSource;
76
- source?: never;
77
- } | {
78
- src?: never;
79
- source: PdfImageSource;
80
77
  };
81
- export type PdfImageProps = PdfBaseProps & PdfBookmarkProp & PdfImageSourceProp;
82
- type PdfLinkTarget = {
78
+ export type PdfLinkProps = PdfBaseProps & {
79
+ /**
80
+ * Link target. A string starting with `#` jumps to the element whose `id`
81
+ * matches the rest of the string; every other value opens as an external URL.
82
+ */
83
83
  href: string;
84
- src?: never;
85
- } | {
86
- href?: never;
87
- src: string;
88
- };
89
- export type PdfLinkProps = PdfBaseProps & PdfLinkTarget & {
90
84
  wrap?: boolean;
91
85
  hitSlop?: {
92
86
  top?: number;
@@ -0,0 +1,10 @@
1
+ import { defineComponent } from 'vue';
2
+ /**
3
+ * Development-only guards for the global `Pdf*` component names. Types declare
4
+ * these names globally so `pdfs/*.vue` templates typecheck, which means an
5
+ * accidental `<PdfText>` inside an ordinary app component also typechecks —
6
+ * while resolving to nothing at runtime. Registering these stubs turns that
7
+ * silent failure into an immediate, actionable error.
8
+ */
9
+ export declare const PDF_STUB_NAMES: readonly ["PdfCircle", "PdfClipPath", "PdfDefs", "PdfDocument", "PdfEllipse", "PdfG", "PdfImage", "PdfLine", "PdfLinearGradient", "PdfLink", "PdfNote", "PdfPage", "PdfPath", "PdfPolygon", "PdfPolyline", "PdfRadialGradient", "PdfRect", "PdfStop", "PdfSvg", "PdfText", "PdfTspan", "PdfView"];
10
+ export declare const createPdfStubs: () => Record<string, ReturnType<typeof defineComponent>>;
@@ -0,0 +1,37 @@
1
+ import { defineComponent } from "vue";
2
+ import { NuxtPdfError, PDF_ERROR_CODES } from "../shared/errors.js";
3
+ export const PDF_STUB_NAMES = [
4
+ "PdfCircle",
5
+ "PdfClipPath",
6
+ "PdfDefs",
7
+ "PdfDocument",
8
+ "PdfEllipse",
9
+ "PdfG",
10
+ "PdfImage",
11
+ "PdfLine",
12
+ "PdfLinearGradient",
13
+ "PdfLink",
14
+ "PdfNote",
15
+ "PdfPage",
16
+ "PdfPath",
17
+ "PdfPolygon",
18
+ "PdfPolyline",
19
+ "PdfRadialGradient",
20
+ "PdfRect",
21
+ "PdfStop",
22
+ "PdfSvg",
23
+ "PdfText",
24
+ "PdfTspan",
25
+ "PdfView"
26
+ ];
27
+ const stubMessage = (name) => `<${name}> only works inside a discovered pdfs/*.vue template rendered by @lupinum/nuxt-pdf. Move this component under pdfs/, or replace it with ordinary Vue/HTML components.`;
28
+ const createPdfStub = (name) => defineComponent({
29
+ name,
30
+ setup() {
31
+ throw new NuxtPdfError(PDF_ERROR_CODES.TemplateInvalid, stubMessage(name));
32
+ },
33
+ render: () => null
34
+ });
35
+ export const createPdfStubs = () => Object.fromEntries(
36
+ PDF_STUB_NAMES.map((name) => [name, createPdfStub(name)])
37
+ );
@@ -1,3 +1,5 @@
1
1
  import type { RendererOptions } from 'vue';
2
- import { type PdfHostElement, type PdfHostNode } from './types.js';
2
+ import { type PdfElementType, type PdfHostElement, type PdfHostNode } from './types.js';
3
+ /** Closed runtime prop surface; TypeScript alone cannot reject Vue attrs. */
4
+ export declare const PDF_PROP_KEYS: Record<PdfElementType, ReadonlySet<string>>;
3
5
  export declare const patchPdfProp: RendererOptions<PdfHostNode, PdfHostElement>['patchProp'];
@@ -26,7 +26,7 @@ const SVG_PRESENTATION_PROP_NAMES = [
26
26
  "strokeWidth",
27
27
  "transform"
28
28
  ];
29
- const PDF_PROP_KEYS = {
29
+ export const PDF_PROP_KEYS = {
30
30
  [PDF_PRIMITIVES.Document]: exactPropNames()([
31
31
  "author",
32
32
  "creationDate",
@@ -67,14 +67,12 @@ const PDF_PROP_KEYS = {
67
67
  [PDF_PRIMITIVES.Image]: exactPropNames()([
68
68
  ...BASE_PROP_NAMES,
69
69
  ...BOOKMARK_PROP_NAMES,
70
- "source",
71
70
  "src"
72
71
  ]),
73
72
  [PDF_PRIMITIVES.Link]: exactPropNames()([
74
73
  ...BASE_PROP_NAMES,
75
74
  "hitSlop",
76
75
  "href",
77
- "src",
78
76
  "wrap"
79
77
  ]),
80
78
  [PDF_PRIMITIVES.Note]: exactPropNames()(BASE_PROP_NAMES),
@@ -256,18 +256,15 @@ const validatePage = (node) => {
256
256
  };
257
257
  const validateLinkAndImageSources = (node) => {
258
258
  if (node.type === PDF_PRIMITIVES.Image) {
259
- if (hasProp(node, "src") === hasProp(node, "source")) {
260
- treeInvalid('<PdfImage> requires exactly one of "src" or "source".');
259
+ if (!hasProp(node, "src")) {
260
+ treeInvalid('<PdfImage> requires a "src" prop.');
261
261
  }
262
262
  }
263
263
  if (node.type === PDF_PRIMITIVES.Link) {
264
- const hasHref = hasProp(node, "href");
265
- const hasSrc = hasProp(node, "src");
266
- if (hasHref === hasSrc) {
267
- treeInvalid('<PdfLink> requires exactly one of "href" or "src".');
264
+ if (!hasProp(node, "href")) {
265
+ treeInvalid('<PdfLink> requires an "href" prop.');
268
266
  }
269
- const target = hasHref ? node.props.href : node.props.src;
270
- if (typeof target !== "string" || target.trim() === "") {
267
+ if (typeof node.props.href !== "string" || node.props.href.trim() === "") {
271
268
  treeInvalid("<PdfLink> targets must be non-empty strings.");
272
269
  }
273
270
  }
@@ -4,14 +4,23 @@ import { type RemoteAssetPolicy, type RemoteRequestState } from './remote.js';
4
4
  import { type RenderLimits } from '../render-limits.js';
5
5
  export { PDF_ASSET_ERROR_CODES, PdfAssetError, type PdfAssetErrorCode, } from './errors.js';
6
6
  export type PdfImageFormat = 'jpg' | 'png';
7
- export type PdfImageAsset = Readonly<{
8
- data: Uint8Array;
7
+ /**
8
+ * One discovered local image. In development the entry points at the source
9
+ * file on disk so edits show up without a restart; in production the validated
10
+ * bytes travel as base64 inside the server bundle so every Nitro preset stays
11
+ * self-contained. Both forms resolve through the same validation boundary.
12
+ */
13
+ export type PdfImageAssetEntry = Readonly<{
14
+ format: PdfImageFormat;
15
+ root: string;
16
+ } | {
17
+ dataB64: string;
9
18
  format: PdfImageFormat;
10
19
  }>;
11
- export type LoadedPdfImageAsset = PdfImageAsset & Readonly<{
20
+ export type PdfImageAssetMap = Readonly<Record<string, PdfImageAssetEntry>>;
21
+ export type LoadedPdfImageAsset = Readonly<{
12
22
  key: string;
13
- }>;
14
- export type PdfImageAssetMap = Readonly<Record<string, PdfImageAsset>>;
23
+ }> & ResolvedPdfImageAsset;
15
24
  export interface LoadPdfImageAssetOptions {
16
25
  roots: readonly string[];
17
26
  maxBytes?: number;
@@ -28,6 +37,13 @@ export interface ResolvePdfImageAssetsOptions {
28
37
  */
29
38
  state?: PdfImageResolutionState;
30
39
  }
40
+ type ResolvedPdfImageAsset = Readonly<{
41
+ data: Buffer;
42
+ format: PdfImageFormat;
43
+ height: number;
44
+ pixels: number;
45
+ width: number;
46
+ }>;
31
47
  type ImageBudgetState = {
32
48
  bytes: number;
33
49
  pixels: number;
@@ -39,6 +55,8 @@ export interface PdfImageResolutionState {
39
55
  readonly remote: RemoteRequestState;
40
56
  readonly resolved: ImageResolutionCache;
41
57
  }
58
+ /** Format for a discovered asset key, validated at module setup time. */
59
+ export declare const pdfImageFormatFromKey: (key: string) => PdfImageFormat;
42
60
  /**
43
61
  * Loads and validates one local image while the Nuxt module still has access to
44
62
  * source files. Generated server code should store the returned bytes under
@@ -72,6 +72,12 @@ const formatFromExtension = (key) => {
72
72
  if (extension === ".jpg" || extension === ".jpeg") return "jpg";
73
73
  return invalid("PDF images must be PNG or JPEG files.");
74
74
  };
75
+ export const pdfImageFormatFromKey = (key) => {
76
+ const extension = extname(key).toLowerCase();
77
+ if (extension === ".png") return "png";
78
+ if (extension === ".jpg" || extension === ".jpeg") return "jpg";
79
+ throw new TypeError(`PDF images must be PNG or JPEG files: "${key}".`);
80
+ };
75
81
  const claimedFormat = (value) => {
76
82
  if (value === void 0) return void 0;
77
83
  if (typeof value !== "string") {
@@ -270,7 +276,30 @@ export const loadPdfImageAsset = async (relativePath, options) => {
270
276
  "The local PDF image was not found in a configured asset root."
271
277
  );
272
278
  };
273
- const resolveLocalImage = (source, assets, maxBytes, maxPixels, declaredFormat) => {
279
+ const RESOLVED_IMAGE_CACHE_BYTES = 32 * 1024 * 1024;
280
+ const resolvedImageCache = /* @__PURE__ */ new Map();
281
+ let resolvedImageCacheBytes = 0;
282
+ const embeddedImageCache = /* @__PURE__ */ new WeakMap();
283
+ const rememberResolvedImage = (cacheKey, image) => {
284
+ if (resolvedImageCache.has(cacheKey)) return;
285
+ resolvedImageCache.set(cacheKey, image);
286
+ resolvedImageCacheBytes += image.data.byteLength;
287
+ while (resolvedImageCacheBytes > RESOLVED_IMAGE_CACHE_BYTES && resolvedImageCache.size > 1) {
288
+ const oldest = resolvedImageCache.keys().next().value;
289
+ if (oldest === void 0) break;
290
+ resolvedImageCacheBytes -= resolvedImageCache.get(oldest).data.byteLength;
291
+ resolvedImageCache.delete(oldest);
292
+ }
293
+ };
294
+ const cachedResolvedImage = (cacheKey) => {
295
+ const cached = resolvedImageCache.get(cacheKey);
296
+ if (!cached) return void 0;
297
+ return {
298
+ ...cached,
299
+ data: Buffer.from(cached.data)
300
+ };
301
+ };
302
+ const resolveLocalImage = async (source, assets, maxBytes, maxPixels, declaredFormat) => {
274
303
  const key = canonicalAssetKey(source);
275
304
  const pathFormat = formatFromExtension(key);
276
305
  const sourceFormat = claimedFormat(declaredFormat);
@@ -279,20 +308,66 @@ const resolveLocalImage = (source, assets, maxBytes, maxPixels, declaredFormat)
279
308
  }
280
309
  if (!hasOwn(assets, key)) {
281
310
  return invalid(
282
- "The local PDF image was not included in the generated asset map."
311
+ `The local PDF image "${key}" was not found under pdfs/assets. Add the file or fix the path.`
283
312
  );
284
313
  }
285
- const asset = assets[key];
286
- if (!asset || typeof asset !== "object") {
314
+ const entry = assets[key];
315
+ if (!entry || typeof entry !== "object") {
287
316
  return invalid("The generated PDF image asset is invalid.");
288
317
  }
289
- const assetFormat = claimedFormat(asset.format);
318
+ const assetFormat = claimedFormat(entry.format);
290
319
  if (!assetFormat || assetFormat !== pathFormat) {
291
320
  return invalid(
292
321
  "The generated PDF image format does not match its asset key."
293
322
  );
294
323
  }
295
- return validateImageBytes(asset.data, maxBytes, assetFormat, maxPixels);
324
+ if ("dataB64" in entry) {
325
+ const limitsKey = `${maxBytes}\0${maxPixels}`;
326
+ let byLimits = embeddedImageCache.get(entry);
327
+ if (!byLimits) {
328
+ byLimits = /* @__PURE__ */ new Map();
329
+ embeddedImageCache.set(entry, byLimits);
330
+ }
331
+ const cached2 = byLimits.get(limitsKey);
332
+ if (cached2) return { ...cached2, data: Buffer.from(cached2.data) };
333
+ const resolved2 = validateImageBytes(
334
+ Buffer.from(entry.dataB64, "base64"),
335
+ maxBytes,
336
+ assetFormat,
337
+ maxPixels
338
+ );
339
+ byLimits.set(limitsKey, resolved2);
340
+ return { ...resolved2, data: Buffer.from(resolved2.data) };
341
+ }
342
+ let fileStat;
343
+ try {
344
+ fileStat = await stat(resolve(entry.root, ...key.split("/")));
345
+ } catch (error) {
346
+ if (isMissingFileError(error)) {
347
+ return invalid(`The local PDF image "${key}" was not found under pdfs/assets.`);
348
+ }
349
+ return invalid("The local PDF image cannot be inspected.", error);
350
+ }
351
+ if (!fileStat.isFile()) {
352
+ return invalid("The local PDF image is not a regular file.");
353
+ }
354
+ const cacheKey = `disk\0${key}\0${fileStat.mtimeMs}\0${fileStat.size}\0${maxBytes}\0${maxPixels}`;
355
+ const cached = cachedResolvedImage(cacheKey);
356
+ if (cached) return cached;
357
+ const loaded = await loadPdfImageAsset(key, {
358
+ roots: [entry.root],
359
+ maxBytes,
360
+ maxPixels
361
+ });
362
+ const resolved = Object.freeze({
363
+ data: loaded.data,
364
+ format: assetFormat,
365
+ height: loaded.height,
366
+ pixels: loaded.pixels,
367
+ width: loaded.width
368
+ });
369
+ rememberResolvedImage(cacheKey, resolved);
370
+ return { ...resolved, data: Buffer.from(resolved.data) };
296
371
  };
297
372
  const isRemoteCandidate = (source) => /^https?:/i.test(source);
298
373
  const imageResolutionCacheKey = (source) => {
@@ -443,12 +518,10 @@ export const resolvePdfImageAssets = async (document, options) => {
443
518
  if (hasOwn(node.props, "srcSet") && node.props.srcSet !== void 0) {
444
519
  return blocked("PDF image srcSet sources are blocked.");
445
520
  }
446
- const hasSrc = hasOwn(node.props, "src") && node.props.src !== void 0;
447
- const hasSource = hasOwn(node.props, "source") && node.props.source !== void 0;
448
- if (hasSrc === hasSource) {
449
- return invalid("Each PDF image must have exactly one src or source prop.");
521
+ if (!hasOwn(node.props, "src") || node.props.src === void 0) {
522
+ return invalid("Each PDF image must have a src prop.");
450
523
  }
451
- targets.push({ node, prop: hasSrc ? "src" : "source" });
524
+ targets.push({ node });
452
525
  }
453
526
  if (targets.length > limits.maxImages) {
454
527
  return imageLimitExceeded(
@@ -459,7 +532,7 @@ export const resolvePdfImageAssets = async (document, options) => {
459
532
  try {
460
533
  resolved = await Promise.all(targets.map(
461
534
  (target) => resolveImageBuffer(
462
- target.node.props[target.prop],
535
+ target.node.props.src,
463
536
  options.assets,
464
537
  limits,
465
538
  options.remote,
@@ -475,7 +548,7 @@ export const resolvePdfImageAssets = async (document, options) => {
475
548
  }
476
549
  targets.forEach((target, index) => {
477
550
  const data = resolved[index];
478
- target.node.props[target.prop] = data;
551
+ target.node.props.src = data;
479
552
  state.resolved.set(data, Promise.resolve(data));
480
553
  });
481
554
  return document;
@@ -5,4 +5,5 @@ export type PdfFontStore = {
5
5
  readonly [pdfFontStoreBrand]: true;
6
6
  };
7
7
  export declare const createPdfFontStore: (fonts?: readonly BundledPdfFontDescriptor[]) => PdfFontStore;
8
+ export declare const getSharedPdfFontStore: (fonts?: readonly BundledPdfFontDescriptor[]) => PdfFontStore;
8
9
  export {};
@@ -17,3 +17,16 @@ export const createPdfFontStore = (fonts = []) => {
17
17
  }
18
18
  return fontStore;
19
19
  };
20
+ const sameFontList = (left, right) => left.length === right.length && left.every((font, index) => {
21
+ const other = right[index];
22
+ return font.family === other.family && font.src === other.src && font.fontStyle === other.fontStyle && font.fontWeight === other.fontWeight;
23
+ });
24
+ let sharedFontStore;
25
+ export const getSharedPdfFontStore = (fonts = []) => {
26
+ if (sharedFontStore && sameFontList(sharedFontStore.fonts, fonts)) {
27
+ return sharedFontStore.store;
28
+ }
29
+ const store = createPdfFontStore(fonts);
30
+ sharedFontStore = { fonts, store };
31
+ return store;
32
+ };
@@ -127,7 +127,7 @@ const indexPage = (registry, rootPath) => {
127
127
  if (templates.length === 0) {
128
128
  return htmlResponse(
129
129
  "PDF templates",
130
- "<header><h1>PDF templates</h1></header><p>No templates found. Add <code>pdfs/invoice.vue</code> and restart the development server.</p>"
130
+ "<header><h1>PDF templates</h1></header><p>No templates found. Add <code>pdfs/invoice.vue</code>; Nuxt PDF restarts and registers it automatically.</p>"
131
131
  );
132
132
  }
133
133
  const cards = templates.map((template) => {
@@ -225,7 +225,7 @@ const viewerPage = async (template, props, rootPath, scenario, hmrClientPath2 =
225
225
  }
226
226
  return htmlResponse(
227
227
  title,
228
- `<header><h1>${escapeHtml(title)}</h1>${actions}</header>${nav}${body}<script type="module">import { createHotContext } from ${JSON.stringify(hmrClientPath2)};createHotContext('/_pdf').on('nuxt-pdf:update',()=>location.reload());<\/script>`
228
+ `<header><h1>${escapeHtml(title)}</h1>${actions}</header>${nav}${body}<script type="module">import { createHotContext } from ${JSON.stringify(hmrClientPath2)};createHotContext('/_pdf').on('nuxt-pdf:update',()=>{const frame=document.querySelector('iframe');if(frame&&frame.contentWindow){frame.contentWindow.location.reload();}else{location.reload();}});<\/script>`
229
229
  );
230
230
  };
231
231
  export const renderPdfPreview = async (registry, request = {}) => {
@@ -17,7 +17,7 @@ import {
17
17
  enforceTreeLimits,
18
18
  resolvePdfRenderLimits
19
19
  } from "./render-limits.js";
20
- import { createPdfFontStore } from "./engine/fonts.js";
20
+ import { getSharedPdfFontStore } from "./engine/fonts.js";
21
21
  import { createPdfRenderResult } from "./result.js";
22
22
  const EMPTY_SCENARIOS = Object.freeze({});
23
23
  const EMPTY_ASSETS = Object.freeze({});
@@ -123,7 +123,7 @@ const renderTemplate = async (component, props, metadata, options, maxPasses, li
123
123
  component,
124
124
  props
125
125
  );
126
- const fontStore = createPdfFontStore(options.fonts);
126
+ const fontStore = getSharedPdfFontStore(options.fonts);
127
127
  if (mounted.usesPageNumbers) {
128
128
  const live = mounted;
129
129
  const result2 = await renderDocumentMultiPass(
@@ -22,7 +22,7 @@ const classifyPdfWatchEvent = (event, absolutePath, layers, isIgnored) => {
22
22
  if (PDF_STRUCTURE_EVENTS.has(event)) return "restart";
23
23
  if (event !== "change") return "ignore";
24
24
  const [rootDirectory] = pathWithinPdfs.split(/[\\/]/);
25
- return rootDirectory === "assets" || rootDirectory === "fonts" ? "restart" : "refresh";
25
+ return rootDirectory === "fonts" ? "restart" : "refresh";
26
26
  }
27
27
  return "ignore";
28
28
  };
package/dist/test.mjs CHANGED
@@ -2,7 +2,7 @@ import { createRequire } from 'node:module';
2
2
  import { defineComponent, h } from 'vue';
3
3
  import { normalizeRemoteAssetPolicy } from '../dist/runtime/server/assets/remote.js';
4
4
  import { createPdfTemplate } from '../dist/runtime/server/registry.js';
5
- import { normalizePdfLimits, resolvePdfRenderLimits } from '../dist/runtime/server/render-limits.js';
5
+ import { normalizePdfLimits } from '../dist/runtime/server/render-limits.js';
6
6
  import { PDF_DEFINITION_PROPERTY } from '../dist/runtime/shared/template.js';
7
7
  import { Buffer } from 'node:buffer';
8
8
  import { existsSync } from 'node:fs';
@@ -10,8 +10,8 @@ import { mkdir, writeFile, rm, readFile, readdir } from 'node:fs/promises';
10
10
  import { resolve, dirname, join, relative, basename } from 'node:path';
11
11
  import { pathToFileURL, fileURLToPath } from 'node:url';
12
12
  import { build } from 'esbuild';
13
- import { t as templateKeyFromRelativePath, b as discoverPdfImageFiles, c as bundlePdfFonts, g as compilePdfSfc } from './shared/nuxt-pdf.D2ZziYn4.mjs';
14
- import { loadPdfImageAsset } from '../dist/runtime/server/assets/resolve-asset.js';
13
+ import { t as templateKeyFromRelativePath, b as discoverPdfImageFiles, c as bundlePdfFonts, g as compilePdfSfc } from './shared/nuxt-pdf._V584vX4.mjs';
14
+ import { pdfImageFormatFromKey } from '../dist/runtime/server/assets/resolve-asset.js';
15
15
  import '@vue/compiler-sfc';
16
16
  import '@jridgewell/remapping';
17
17
  import 'unimport';
@@ -571,18 +571,15 @@ async function renderPdfSfc(filename, props, options = {}) {
571
571
  throw new Error(`PDF SFC ${JSON.stringify(entry)} must be a template directly inside pdfs/ or one of its feature directories.`);
572
572
  }
573
573
  const normalizedLimits = normalizePdfLimits(options.limits);
574
- const limits = resolvePdfRenderLimits(normalizedLimits);
575
574
  const [component, imageFiles, fonts] = await Promise.all([
576
575
  loadPdfSfc(entry),
577
576
  discoverPdfImageFiles([{ name: "test", rootDir }]),
578
577
  bundlePdfFonts(options.fonts ?? [], { fontRoots: [join(rootDir, "pdfs", "fonts")] })
579
578
  ]);
580
- const loadedAssets = await Promise.all(imageFiles.map((image) => loadPdfImageAsset(image.key, {
581
- roots: [image.rootDir],
582
- maxBytes: limits.maxImageBytes,
583
- maxPixels: limits.maxImagePixels
584
- })));
585
- const assets = Object.fromEntries(loadedAssets.map((asset) => [asset.key, asset]));
579
+ const assets = Object.fromEntries(imageFiles.map((image) => [
580
+ image.key,
581
+ Object.freeze({ format: pdfImageFormatFromKey(image.key), root: image.rootDir })
582
+ ]));
586
583
  return renderPreparedPdfTemplate(component, props, {
587
584
  assets,
588
585
  file: `pdfs/${relativePath}`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lupinum/nuxt-pdf",
3
- "version": "0.3.1",
3
+ "version": "0.4.0-beta.1",
4
4
  "description": "Author and render PDFs with Vue components in Nuxt",
5
5
  "keywords": [
6
6
  "nuxt",
@@ -56,9 +56,10 @@
56
56
  "build": "nuxt prepare && nuxt-module-build build",
57
57
  "api:write": "node scripts/check-api-report.mjs --write",
58
58
  "changelog": "changelogen --no-output",
59
- "docs:build": "pnpm build && pnpm --dir docs build",
59
+ "docs:build": "pnpm docs:theme && pnpm build && pnpm --dir docs build",
60
60
  "docs:build:vercel": "node scripts/build-docs-vercel.mjs",
61
61
  "docs:dev": "pnpm --dir docs dev",
62
+ "docs:theme": "node scripts/check-docs-theme.mjs",
62
63
  "format": "eslint . --fix",
63
64
  "format:check": "eslint .",
64
65
  "lint": "eslint .",
@@ -70,7 +71,7 @@
70
71
  "release:verify": "pnpm verify && pnpm release:pack",
71
72
  "test:artifact": "node scripts/check-release-artifact.mjs",
72
73
  "test:dependencies": "node scripts/check-production-dependencies.mjs",
73
- "test:docs": "node scripts/check-documentation.mjs && pnpm --dir docs check",
74
+ "test:docs": "node scripts/check-documentation.mjs && pnpm docs:theme && pnpm --dir docs check",
74
75
  "test:package": "node scripts/check-package.mjs",
75
76
  "test:package-metadata": "node scripts/check-api-report.mjs",
76
77
  "test:performance": "node --expose-gc ./node_modules/vitest/vitest.mjs run --config vitest.performance.config.ts",
@@ -139,6 +140,7 @@
139
140
  "typescript": "~5.9.3",
140
141
  "vitest": "^4.1.8",
141
142
  "vue": "3.5.40",
142
- "vue-tsc": "^3.3.3"
143
+ "vue-tsc": "^3.3.3",
144
+ "yaml": "2.9.0"
143
145
  }
144
146
  }