@mccaskie-labs/workbench-pdf 0.0.0-stage → 0.1.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 +11 -0
- package/README.md +58 -2
- package/dist/brand.d.ts +12 -0
- package/dist/brand.js +17 -0
- package/dist/document.d.ts +40 -0
- package/dist/document.js +84 -0
- package/dist/errors.d.ts +8 -0
- package/dist/errors.js +14 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +4 -0
- package/dist/layout.d.ts +12 -0
- package/dist/layout.js +104 -0
- package/dist/logo.d.ts +3 -0
- package/dist/logo.js +43 -0
- package/dist/maker.d.ts +71 -0
- package/dist/maker.js +164 -0
- package/package.json +48 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# @mccaskie-labs/workbench-pdf
|
|
2
|
+
|
|
3
|
+
## 0.1.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- No code change. The first version published by the publish workflow, which confirms the package's npm trusted publisher.
|
|
8
|
+
|
|
9
|
+
## 0.1.0
|
|
10
|
+
|
|
11
|
+
- First version: `createPdfMaker` with the standard branded layout (`document`) and the app's own HTML (`html`), `renderDocument` for previews, and `WorkbenchPdfError` (`docs/spec.md` §10, 11 November 2026).
|
package/README.md
CHANGED
|
@@ -1,3 +1,59 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @mccaskie-labs/workbench-pdf
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Branded PDFs for apps built on Workbench, a private platform. The app's content is printed with its logo, colours and fonts, in the app's own Worker through Cloudflare Browser Rendering (now called Browser Run). Nothing goes through the Workbench Gateway and the app needs no API token: the Worker's `BROWSER` binding is the only access. Published publicly so Workbench projects can install it with no token; it is not supported for other uses.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npm install @mccaskie-labs/workbench-pdf @mccaskie-labs/workbench-config @cloudflare/puppeteer zod
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
`wrangler.jsonc`:
|
|
10
|
+
|
|
11
|
+
```jsonc
|
|
12
|
+
"compatibility_flags": ["nodejs_compat"],
|
|
13
|
+
"browser": { "binding": "BROWSER" }
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import { defineBrand } from '@mccaskie-labs/workbench-config'
|
|
18
|
+
import { createPdfMaker } from '@mccaskie-labs/workbench-pdf'
|
|
19
|
+
import brandFile from '../brand.json'
|
|
20
|
+
|
|
21
|
+
const brand = defineBrand(brandFile)
|
|
22
|
+
|
|
23
|
+
export default {
|
|
24
|
+
async fetch(req, env) {
|
|
25
|
+
const pdf = createPdfMaker({ browser: env.BROWSER, brand })
|
|
26
|
+
const bytes = await pdf.document({
|
|
27
|
+
title: 'Invoice WBP-0042',
|
|
28
|
+
subtitle: 'Probe runs for October 2026',
|
|
29
|
+
date: '11 November 2026',
|
|
30
|
+
blocks: [
|
|
31
|
+
{ type: 'facts', items: [{ label: 'Due', value: '25 November 2026' }] },
|
|
32
|
+
{ type: 'table', columns: ['Item', 'Amount'], align: ['left', 'right'], rows: [['Image probe', '₱112.50']] },
|
|
33
|
+
{ type: 'note', text: 'Paid by card on file.' },
|
|
34
|
+
],
|
|
35
|
+
})
|
|
36
|
+
return new Response(bytes, { headers: { 'content-type': 'application/pdf' } })
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
| Export | What it does |
|
|
42
|
+
| --- | --- |
|
|
43
|
+
| `createPdfMaker({ browser, brand, size?, timeoutMs? })` | The maker. Checks the brand at once (`invalid_brand`). `size` is `A4` (default) or `Letter`; `timeoutMs` is 1,000 to 120,000, default 30,000 |
|
|
44
|
+
| `pdf.document(doc, { size?, landscape? })` | The standard layout: logo and name across the top, the title, subtitle and date, then the blocks; the footer on every page has the name, site, `footer` line and "Page n of m". Returns the PDF bytes |
|
|
45
|
+
| `pdf.html(body, { title, standardStyles?, header?, javascript?, allowOrigins?, size?, landscape? })` | The app's own HTML (a body fragment, trusted as given) with the brand's fonts, colours, header and footer. The brand is in CSS variables: `--brand-primary`, `--brand-text`, `--brand-background`, `--brand-muted`, `--brand-heading-font`, `--brand-body-font` |
|
|
46
|
+
| `pdf.renderDocument(doc)` | The standard layout's HTML without printing, for previews and tests |
|
|
47
|
+
| `WorkbenchPdfError` | Every failure: `code` and `retryable` |
|
|
48
|
+
|
|
49
|
+
Blocks for `document()`, all plain text (escaped; there is no way to pass HTML): `heading` (`level` 2 or 3), `paragraph`, `list` (`ordered?`), `table` (`columns`, `rows`, `align?` of `left` or `right` per column), `facts` (label and value pairs), `note` and `pageBreak`. Format dates, numbers and money with the app's country pack before passing them in. Limits: 2,000 blocks, 20,000 characters in one text and 2,000,000 in all.
|
|
50
|
+
|
|
51
|
+
**What the page can reach.** It runs no script (unless `html()` gets `javascript: true`) and loads nothing but `data:` URIs and the brand's font stylesheets (Google Fonts' font files included), plus any https origins the app lists in `allowOrigins`. The logo is fetched once per maker by the Worker, checked (PNG, JPEG or SVG, at most 1 MiB) and inlined. One browser is opened per PDF and always closed.
|
|
52
|
+
|
|
53
|
+
Error codes: `invalid_document`, `invalid_brand`, `logo_unavailable`, `browser_unavailable` (no binding, or Browser Rendering's limit reached; retryable), `timeout` and `render_failed` (both retryable). Messages never include the document's text. The package doesn't retry.
|
|
54
|
+
|
|
55
|
+
**Cost.** Browser time is billed per account: Workers Paid includes 10 browser hours and 10 concurrent browsers a month, then US$0.09 an hour and US$2 a browser (checked 11 November 2026). A PDF takes a few seconds. On Workers Free the limit is 10 minutes a day.
|
|
56
|
+
|
|
57
|
+
**Tests.** Pass `launch` (a fake browser) and `fetch` (a fake logo host) to `createPdfMaker`; nothing then reaches the network. Local `wrangler dev` runs the binding against a local Chrome.
|
|
58
|
+
|
|
59
|
+
Runs in the Cloudflare Workers runtime (with `nodejs_compat`). `@cloudflare/puppeteer` 1.4 or later and `zod` 4 are peer dependencies.
|
package/dist/brand.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type Brand, type ResolvedBrand } from "@mccaskie-labs/workbench-config";
|
|
2
|
+
export interface PrintFont {
|
|
3
|
+
family: string;
|
|
4
|
+
url?: string;
|
|
5
|
+
}
|
|
6
|
+
export type PrintBrand = ResolvedBrand & {
|
|
7
|
+
print: {
|
|
8
|
+
heading: PrintFont;
|
|
9
|
+
body: PrintFont;
|
|
10
|
+
};
|
|
11
|
+
};
|
|
12
|
+
export declare function printBrand(input: Brand | ResolvedBrand): PrintBrand;
|
package/dist/brand.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// The app's brand file (brand.json, checked by workbench-config's
|
|
2
|
+
// defineBrand), checked again here so a hand-built object fails clearly, with
|
|
3
|
+
// the fonts filled in for print.
|
|
4
|
+
import { brandSchema, resolveBrand } from "@mccaskie-labs/workbench-config";
|
|
5
|
+
import { WorkbenchPdfError } from "./errors.js";
|
|
6
|
+
const SYSTEM = { family: "system-ui" };
|
|
7
|
+
export function printBrand(input) {
|
|
8
|
+
const result = brandSchema.safeParse(input);
|
|
9
|
+
if (!result.success) {
|
|
10
|
+
const fields = [...new Set(result.error.issues.map((i) => i.path.join(".") || "(brand)"))];
|
|
11
|
+
throw new WorkbenchPdfError("invalid_brand", `brand: check ${fields.join(", ")}`);
|
|
12
|
+
}
|
|
13
|
+
const brand = resolveBrand(result.data);
|
|
14
|
+
const body = brand.fonts?.body ?? SYSTEM;
|
|
15
|
+
const heading = brand.fonts?.heading ?? body;
|
|
16
|
+
return { ...brand, print: { heading, body } };
|
|
17
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export type Block = {
|
|
2
|
+
type: "heading";
|
|
3
|
+
text: string;
|
|
4
|
+
level?: 2 | 3;
|
|
5
|
+
} | {
|
|
6
|
+
type: "paragraph";
|
|
7
|
+
text: string;
|
|
8
|
+
} | {
|
|
9
|
+
type: "list";
|
|
10
|
+
items: string[];
|
|
11
|
+
ordered?: boolean;
|
|
12
|
+
} | {
|
|
13
|
+
type: "table";
|
|
14
|
+
columns: string[];
|
|
15
|
+
rows: string[][];
|
|
16
|
+
align?: ("left" | "right")[];
|
|
17
|
+
} | {
|
|
18
|
+
type: "facts";
|
|
19
|
+
items: {
|
|
20
|
+
label: string;
|
|
21
|
+
value: string;
|
|
22
|
+
}[];
|
|
23
|
+
} | {
|
|
24
|
+
type: "note";
|
|
25
|
+
text: string;
|
|
26
|
+
} | {
|
|
27
|
+
type: "pageBreak";
|
|
28
|
+
};
|
|
29
|
+
export interface PdfDocument {
|
|
30
|
+
title: string;
|
|
31
|
+
subtitle?: string;
|
|
32
|
+
/** Shown under the title as given; format it with the app's country pack. */
|
|
33
|
+
date?: string;
|
|
34
|
+
blocks: Block[];
|
|
35
|
+
}
|
|
36
|
+
export declare const MAX_BLOCKS = 2000;
|
|
37
|
+
export declare const MAX_TEXT = 20000;
|
|
38
|
+
export declare const MAX_TOTAL_TEXT = 2000000;
|
|
39
|
+
/** Throws `invalid_document` naming the first problem. Never echoes the text. */
|
|
40
|
+
export declare function checkDocument(doc: PdfDocument): void;
|
package/dist/document.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// The standard layout's content: plain text in a few block types. Every
|
|
2
|
+
// string is text, escaped when the page is built; there is no way to pass
|
|
3
|
+
// HTML here (use `html()` for the app's own markup).
|
|
4
|
+
import { WorkbenchPdfError } from "./errors.js";
|
|
5
|
+
export const MAX_BLOCKS = 2000;
|
|
6
|
+
export const MAX_TEXT = 20_000;
|
|
7
|
+
export const MAX_TOTAL_TEXT = 2_000_000;
|
|
8
|
+
const bad = (what) => {
|
|
9
|
+
throw new WorkbenchPdfError("invalid_document", what);
|
|
10
|
+
};
|
|
11
|
+
/** Throws `invalid_document` naming the first problem. Never echoes the text. */
|
|
12
|
+
export function checkDocument(doc) {
|
|
13
|
+
if (typeof doc !== "object" || doc === null)
|
|
14
|
+
bad("document is missing");
|
|
15
|
+
let total = 0;
|
|
16
|
+
const text = (v, where, optional = false) => {
|
|
17
|
+
if (optional && v === undefined)
|
|
18
|
+
return;
|
|
19
|
+
if (typeof v !== "string")
|
|
20
|
+
bad(`${where} must be text`);
|
|
21
|
+
const s = v;
|
|
22
|
+
if (s.length > MAX_TEXT)
|
|
23
|
+
bad(`${where} is longer than ${MAX_TEXT} characters`);
|
|
24
|
+
total += s.length;
|
|
25
|
+
};
|
|
26
|
+
text(doc.title, "title");
|
|
27
|
+
if (doc.title.trim() === "")
|
|
28
|
+
bad("title is empty");
|
|
29
|
+
text(doc.subtitle, "subtitle", true);
|
|
30
|
+
text(doc.date, "date", true);
|
|
31
|
+
if (!Array.isArray(doc.blocks))
|
|
32
|
+
bad("blocks must be a list");
|
|
33
|
+
if (doc.blocks.length > MAX_BLOCKS)
|
|
34
|
+
bad(`more than ${MAX_BLOCKS} blocks`);
|
|
35
|
+
doc.blocks.forEach((b, i) => {
|
|
36
|
+
const at = `blocks[${i}]`;
|
|
37
|
+
switch (b?.type) {
|
|
38
|
+
case "heading":
|
|
39
|
+
text(b.text, `${at}.text`);
|
|
40
|
+
if (b.level !== undefined && b.level !== 2 && b.level !== 3)
|
|
41
|
+
bad(`${at}.level must be 2 or 3`);
|
|
42
|
+
break;
|
|
43
|
+
case "paragraph":
|
|
44
|
+
case "note":
|
|
45
|
+
text(b.text, `${at}.text`);
|
|
46
|
+
break;
|
|
47
|
+
case "list":
|
|
48
|
+
if (!Array.isArray(b.items))
|
|
49
|
+
bad(`${at}.items must be a list`);
|
|
50
|
+
b.items.forEach((item, j) => text(item, `${at}.items[${j}]`));
|
|
51
|
+
break;
|
|
52
|
+
case "table": {
|
|
53
|
+
if (!Array.isArray(b.columns) || b.columns.length === 0)
|
|
54
|
+
bad(`${at}.columns must be a non-empty list`);
|
|
55
|
+
b.columns.forEach((c, j) => text(c, `${at}.columns[${j}]`));
|
|
56
|
+
if (!Array.isArray(b.rows))
|
|
57
|
+
bad(`${at}.rows must be a list`);
|
|
58
|
+
b.rows.forEach((row, r) => {
|
|
59
|
+
if (!Array.isArray(row) || row.length !== b.columns.length)
|
|
60
|
+
bad(`${at}.rows[${r}] must have ${b.columns.length} cells`);
|
|
61
|
+
row.forEach((cell, j) => text(cell, `${at}.rows[${r}][${j}]`));
|
|
62
|
+
});
|
|
63
|
+
if (b.align !== undefined && (!Array.isArray(b.align) || b.align.some((a) => a !== "left" && a !== "right"))) {
|
|
64
|
+
bad(`${at}.align must list "left" or "right"`);
|
|
65
|
+
}
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
case "facts":
|
|
69
|
+
if (!Array.isArray(b.items))
|
|
70
|
+
bad(`${at}.items must be a list`);
|
|
71
|
+
b.items.forEach((f, j) => {
|
|
72
|
+
text(f?.label, `${at}.items[${j}].label`);
|
|
73
|
+
text(f?.value, `${at}.items[${j}].value`);
|
|
74
|
+
});
|
|
75
|
+
break;
|
|
76
|
+
case "pageBreak":
|
|
77
|
+
break;
|
|
78
|
+
default:
|
|
79
|
+
bad(`${at}.type is not a known block`);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
if (total > MAX_TOTAL_TEXT)
|
|
83
|
+
bad(`more than ${MAX_TOTAL_TEXT} characters in all`);
|
|
84
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type PdfErrorCode = "invalid_document" | "invalid_brand" | "logo_unavailable" | "browser_unavailable" | "timeout" | "render_failed";
|
|
2
|
+
export declare class WorkbenchPdfError extends Error {
|
|
3
|
+
readonly code: PdfErrorCode;
|
|
4
|
+
readonly retryable: boolean;
|
|
5
|
+
constructor(code: PdfErrorCode, message: string, options?: {
|
|
6
|
+
cause?: unknown;
|
|
7
|
+
});
|
|
8
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Every failure the package raises is a WorkbenchPdfError with a stable code.
|
|
2
|
+
// Callers branch on `code`, never on the message. Messages never include the
|
|
3
|
+
// document's text.
|
|
4
|
+
const RETRYABLE = new Set(["browser_unavailable", "timeout", "render_failed"]);
|
|
5
|
+
export class WorkbenchPdfError extends Error {
|
|
6
|
+
code;
|
|
7
|
+
retryable;
|
|
8
|
+
constructor(code, message, options = {}) {
|
|
9
|
+
super(message, options.cause === undefined ? undefined : { cause: options.cause });
|
|
10
|
+
this.name = "WorkbenchPdfError";
|
|
11
|
+
this.code = code;
|
|
12
|
+
this.retryable = RETRYABLE.has(code);
|
|
13
|
+
}
|
|
14
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { createPdfMaker } from "./maker.ts";
|
|
2
|
+
export { WorkbenchPdfError, type PdfErrorCode } from "./errors.ts";
|
|
3
|
+
export { MAX_BLOCKS, MAX_TEXT, MAX_TOTAL_TEXT } from "./document.ts";
|
|
4
|
+
export { MAX_LOGO_BYTES } from "./logo.ts";
|
|
5
|
+
export type { Block, PdfDocument } from "./document.ts";
|
|
6
|
+
export type { BrowserBinding, BrowserLike, HtmlOptions, Launch, PageLike, PageSize, PdfMaker, PdfMakerOptions, PrintOptions, RequestLike } from "./maker.ts";
|
package/dist/index.js
ADDED
package/dist/layout.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { PrintBrand } from "./brand.ts";
|
|
2
|
+
import type { PdfDocument } from "./document.ts";
|
|
3
|
+
/** The whole standard-layout page. `logoSrc` is normally a data: URI. */
|
|
4
|
+
export declare function standardPage(brand: PrintBrand, doc: PdfDocument, logoSrc: string): string;
|
|
5
|
+
/** The app's own HTML (trusted as given) inside the brand's page. */
|
|
6
|
+
export declare function ownPage(brand: PrintBrand, body: string, title: string, opts: {
|
|
7
|
+
standardStyles: boolean;
|
|
8
|
+
header: boolean;
|
|
9
|
+
logoSrc: string;
|
|
10
|
+
}): string;
|
|
11
|
+
/** Chrome's print footer: its own small page, so every style is inline. */
|
|
12
|
+
export declare function footerTemplate(brand: PrintBrand): string;
|
package/dist/layout.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Builds the page the browser prints: the brand's look, then either the
|
|
2
|
+
// standard layout's blocks or the app's own HTML.
|
|
3
|
+
/** Plain text made safe in HTML text and quoted attributes. */
|
|
4
|
+
const escape = (value) => value.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
5
|
+
// A family name inside a CSS string, so no value can end the string or the <style>.
|
|
6
|
+
const cssString = (value) => `"${value.replace(/[\\"<>\n\r]/g, (c) => `\\${c.charCodeAt(0).toString(16)} `)}"`;
|
|
7
|
+
const stack = (f) => `${cssString(f.family)}, system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif`;
|
|
8
|
+
/** The <head> parts every page shares: font stylesheets and the brand's CSS variables. */
|
|
9
|
+
function brandHead(brand, title) {
|
|
10
|
+
const urls = [...new Set([brand.print.heading.url, brand.print.body.url].filter((u) => !!u))];
|
|
11
|
+
const links = urls.map((u) => `<link rel="stylesheet" href="${escape(u)}">`).join("");
|
|
12
|
+
const c = brand.colours;
|
|
13
|
+
return (`<meta charset="utf-8"><title>${escape(title)}</title>${links}` +
|
|
14
|
+
`<style>:root{--brand-primary:${c.primary};--brand-text:${c.text};--brand-background:${c.background};--brand-muted:${c.muted};` +
|
|
15
|
+
`--brand-heading-font:${stack(brand.print.heading)};--brand-body-font:${stack(brand.print.body)}}</style>`);
|
|
16
|
+
}
|
|
17
|
+
const STANDARD_CSS = `
|
|
18
|
+
*{box-sizing:border-box}
|
|
19
|
+
html{-webkit-print-color-adjust:exact;print-color-adjust:exact}
|
|
20
|
+
body{margin:0;background:var(--brand-background);color:var(--brand-text);font-family:var(--brand-body-font);font-size:10.5pt;line-height:1.5}
|
|
21
|
+
h1,h2,h3{font-family:var(--brand-heading-font);color:var(--brand-primary);line-height:1.25;margin:1.2em 0 .4em;break-after:avoid}
|
|
22
|
+
h1{font-size:20pt;margin:0}
|
|
23
|
+
h2{font-size:14pt}
|
|
24
|
+
h3{font-size:11.5pt}
|
|
25
|
+
p{margin:0 0 .7em}
|
|
26
|
+
ul,ol{margin:0 0 .7em;padding-left:1.4em}
|
|
27
|
+
li{margin:.15em 0}
|
|
28
|
+
table{width:100%;border-collapse:collapse;margin:.4em 0 1em;font-size:9.5pt}
|
|
29
|
+
thead{display:table-header-group}
|
|
30
|
+
th{text-align:left;font-weight:600;background:color-mix(in srgb,var(--brand-primary) 12%,var(--brand-background));color:var(--brand-text)}
|
|
31
|
+
th,td{padding:5px 8px;border-bottom:1px solid color-mix(in srgb,var(--brand-muted) 35%,transparent);vertical-align:top}
|
|
32
|
+
tr{break-inside:avoid}
|
|
33
|
+
.r{text-align:right;font-variant-numeric:tabular-nums}
|
|
34
|
+
dl.facts{display:grid;grid-template-columns:max-content 1fr;gap:4px 16px;margin:.4em 0 1em}
|
|
35
|
+
dl.facts dt{color:var(--brand-muted)}
|
|
36
|
+
dl.facts dd{margin:0}
|
|
37
|
+
.note{border-left:3px solid var(--brand-primary);background:color-mix(in srgb,var(--brand-primary) 6%,var(--brand-background));padding:8px 12px;margin:.4em 0 1em}
|
|
38
|
+
.subtitle{color:var(--brand-muted);font-size:12pt;margin:.2em 0 0}
|
|
39
|
+
.date{color:var(--brand-muted);font-size:9.5pt;margin:.3em 0 1.2em}
|
|
40
|
+
.page-break{break-after:page}
|
|
41
|
+
`;
|
|
42
|
+
function logoTag(brand, src) {
|
|
43
|
+
const size = (brand.logo.width ? ` width="${brand.logo.width}"` : "") + (brand.logo.height ? ` height="${brand.logo.height}"` : "");
|
|
44
|
+
return `<img src="${escape(src)}" alt="${escape(brand.logo.alt ?? brand.name)}"${size}>`;
|
|
45
|
+
}
|
|
46
|
+
const HEADER_CSS = `.brand-head{display:flex;align-items:center;justify-content:space-between;gap:16px;padding-bottom:12px;margin-bottom:18px;border-bottom:2px solid var(--brand-primary)}.brand-head img{max-height:48px;max-width:200px}.brand-head .name{font-family:var(--brand-heading-font);color:var(--brand-muted);font-size:9pt}`;
|
|
47
|
+
/** The logo and the app's name across the top of the first page. */
|
|
48
|
+
function brandHeader(brand, logoSrc) {
|
|
49
|
+
return `<header class="brand-head">${logoTag(brand, logoSrc)}<span class="name">${escape(brand.name)}</span></header>`;
|
|
50
|
+
}
|
|
51
|
+
function block(b) {
|
|
52
|
+
switch (b.type) {
|
|
53
|
+
case "heading": {
|
|
54
|
+
const tag = b.level === 3 ? "h3" : "h2";
|
|
55
|
+
return `<${tag}>${escape(b.text)}</${tag}>`;
|
|
56
|
+
}
|
|
57
|
+
case "paragraph":
|
|
58
|
+
return `<p>${escape(b.text)}</p>`;
|
|
59
|
+
case "note":
|
|
60
|
+
return `<div class="note">${escape(b.text)}</div>`;
|
|
61
|
+
case "list": {
|
|
62
|
+
const tag = b.ordered ? "ol" : "ul";
|
|
63
|
+
return `<${tag}>${b.items.map((i) => `<li>${escape(i)}</li>`).join("")}</${tag}>`;
|
|
64
|
+
}
|
|
65
|
+
case "table": {
|
|
66
|
+
const cls = (j) => (b.align?.[j] === "right" ? ' class="r"' : "");
|
|
67
|
+
const head = b.columns.map((c, j) => `<th${cls(j)}>${escape(c)}</th>`).join("");
|
|
68
|
+
const rows = b.rows.map((r) => `<tr>${r.map((c, j) => `<td${cls(j)}>${escape(c)}</td>`).join("")}</tr>`).join("");
|
|
69
|
+
return `<table><thead><tr>${head}</tr></thead><tbody>${rows}</tbody></table>`;
|
|
70
|
+
}
|
|
71
|
+
case "facts":
|
|
72
|
+
return `<dl class="facts">${b.items.map((f) => `<dt>${escape(f.label)}</dt><dd>${escape(f.value)}</dd>`).join("")}</dl>`;
|
|
73
|
+
case "pageBreak":
|
|
74
|
+
return `<div class="page-break"></div>`;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** The whole standard-layout page. `logoSrc` is normally a data: URI. */
|
|
78
|
+
export function standardPage(brand, doc, logoSrc) {
|
|
79
|
+
const sub = doc.subtitle ? `<p class="subtitle">${escape(doc.subtitle)}</p>` : "";
|
|
80
|
+
const date = doc.date ? `<p class="date">${escape(doc.date)}</p>` : `<div class="date"></div>`;
|
|
81
|
+
return (`<!doctype html><html><head>${brandHead(brand, doc.title)}<style>${STANDARD_CSS}${HEADER_CSS}</style></head><body>` +
|
|
82
|
+
`${brandHeader(brand, logoSrc)}<h1>${escape(doc.title)}</h1>${sub}${date}` +
|
|
83
|
+
`<main>${doc.blocks.map(block).join("")}</main></body></html>`);
|
|
84
|
+
}
|
|
85
|
+
/** The app's own HTML (trusted as given) inside the brand's page. */
|
|
86
|
+
export function ownPage(brand, body, title, opts) {
|
|
87
|
+
const css = opts.standardStyles ? `<style>${STANDARD_CSS}</style>` : "";
|
|
88
|
+
const header = opts.header ? `<style>${HEADER_CSS}</style>${brandHeader(brand, opts.logoSrc)}` : "";
|
|
89
|
+
return `<!doctype html><html><head>${brandHead(brand, title)}${css}</head><body>${header}${body}</body></html>`;
|
|
90
|
+
}
|
|
91
|
+
/** Chrome's print footer: its own small page, so every style is inline. */
|
|
92
|
+
export function footerTemplate(brand) {
|
|
93
|
+
let site = brand.siteUrl;
|
|
94
|
+
try {
|
|
95
|
+
site = new URL(brand.siteUrl).host;
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// brand.siteUrl was checked; keep it as given.
|
|
99
|
+
}
|
|
100
|
+
const left = [brand.name, site, brand.footer].filter(Boolean).map((s) => escape(s)).join(" · ");
|
|
101
|
+
return (`<div style="width:100%;font-size:7.5pt;font-family:${escape(stack(brand.print.body))};color:${brand.colours.muted};` +
|
|
102
|
+
`padding:0 15mm;display:flex;justify-content:space-between;gap:12px">` +
|
|
103
|
+
`<span>${left}</span><span style="white-space:nowrap">Page <span class="pageNumber"></span> of <span class="totalPages"></span></span></div>`);
|
|
104
|
+
}
|
package/dist/logo.d.ts
ADDED
package/dist/logo.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Fetches the brand logo once and inlines it as a data URI, so the browser
|
|
2
|
+
// loads nothing from the logo's host and a slow host can't stall the print.
|
|
3
|
+
import { WorkbenchPdfError } from "./errors.js";
|
|
4
|
+
export const MAX_LOGO_BYTES = 1024 * 1024;
|
|
5
|
+
function sniff(bytes) {
|
|
6
|
+
if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47)
|
|
7
|
+
return "image/png";
|
|
8
|
+
if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff)
|
|
9
|
+
return "image/jpeg";
|
|
10
|
+
const head = new TextDecoder().decode(bytes.subarray(0, 512)).trimStart().toLowerCase();
|
|
11
|
+
if (head.startsWith("<svg") || (head.startsWith("<?xml") && head.includes("<svg")))
|
|
12
|
+
return "image/svg+xml";
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
function base64(bytes) {
|
|
16
|
+
let s = "";
|
|
17
|
+
for (let i = 0; i < bytes.length; i += 0x8000)
|
|
18
|
+
s += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
|
19
|
+
return btoa(s);
|
|
20
|
+
}
|
|
21
|
+
const fail = (why, cause) => new WorkbenchPdfError("logo_unavailable", `logo: ${why}`, { cause });
|
|
22
|
+
/** The logo as a `data:` URI. PNG, JPEG or SVG only, at most 1 MiB, within `timeoutMs`. */
|
|
23
|
+
export async function logoDataUri(url, fetcher, timeoutMs = 10_000) {
|
|
24
|
+
let res;
|
|
25
|
+
try {
|
|
26
|
+
res = await fetcher(url, { redirect: "follow", signal: AbortSignal.timeout(timeoutMs) });
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
throw fail("could not be fetched", err);
|
|
30
|
+
}
|
|
31
|
+
if (!res.ok)
|
|
32
|
+
throw fail(`the host answered ${res.status}`);
|
|
33
|
+
const length = Number(res.headers.get("content-length") ?? "0");
|
|
34
|
+
if (length > MAX_LOGO_BYTES)
|
|
35
|
+
throw fail("larger than 1 MiB");
|
|
36
|
+
const bytes = new Uint8Array(await res.arrayBuffer());
|
|
37
|
+
if (bytes.length > MAX_LOGO_BYTES)
|
|
38
|
+
throw fail("larger than 1 MiB");
|
|
39
|
+
const kind = sniff(bytes);
|
|
40
|
+
if (!kind)
|
|
41
|
+
throw fail("not PNG, JPEG or SVG");
|
|
42
|
+
return `data:${kind};base64,${base64(bytes)}`;
|
|
43
|
+
}
|
package/dist/maker.d.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { Brand, ResolvedBrand } from "@mccaskie-labs/workbench-config";
|
|
2
|
+
import { type PdfDocument } from "./document.ts";
|
|
3
|
+
/** A Browser Rendering binding: what `env.BROWSER` is. */
|
|
4
|
+
export interface BrowserBinding {
|
|
5
|
+
fetch: typeof fetch;
|
|
6
|
+
}
|
|
7
|
+
/** The parts of a Puppeteer page the package uses. */
|
|
8
|
+
export interface PageLike {
|
|
9
|
+
setJavaScriptEnabled(enabled: boolean): Promise<void>;
|
|
10
|
+
setRequestInterception(on: boolean): Promise<void>;
|
|
11
|
+
on(event: "request", handler: (request: RequestLike) => void): unknown;
|
|
12
|
+
setContent(html: string, options: {
|
|
13
|
+
waitUntil: "networkidle0";
|
|
14
|
+
timeout: number;
|
|
15
|
+
}): Promise<void>;
|
|
16
|
+
pdf(options: Record<string, unknown>): Promise<Uint8Array>;
|
|
17
|
+
}
|
|
18
|
+
export interface RequestLike {
|
|
19
|
+
url(): string;
|
|
20
|
+
isInterceptResolutionHandled?(): boolean;
|
|
21
|
+
continue(): Promise<void>;
|
|
22
|
+
abort(reason?: string): Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
export interface BrowserLike {
|
|
25
|
+
newPage(): Promise<PageLike>;
|
|
26
|
+
close(): Promise<void>;
|
|
27
|
+
}
|
|
28
|
+
/** Opens a browser on the binding. Defaults to `@cloudflare/puppeteer`'s `launch`. */
|
|
29
|
+
export type Launch = (binding: BrowserBinding) => Promise<BrowserLike>;
|
|
30
|
+
export type PageSize = "A4" | "Letter";
|
|
31
|
+
export interface PdfMakerOptions {
|
|
32
|
+
/** The Worker's Browser Rendering binding (`"browser": { "binding": "BROWSER" }` in wrangler.jsonc). */
|
|
33
|
+
browser: BrowserBinding | undefined;
|
|
34
|
+
/** The app's brand: `defineBrand(brandFile)` from workbench-config. */
|
|
35
|
+
brand: Brand | ResolvedBrand;
|
|
36
|
+
/** Default page size. A4 unless given. */
|
|
37
|
+
size?: PageSize;
|
|
38
|
+
/** Time allowed for one PDF, in ms. Default 30,000. */
|
|
39
|
+
timeoutMs?: number;
|
|
40
|
+
/** For tests. */
|
|
41
|
+
launch?: Launch;
|
|
42
|
+
/** For tests: fetches the logo. Defaults to the global fetch. */
|
|
43
|
+
fetch?: typeof fetch;
|
|
44
|
+
}
|
|
45
|
+
export interface PrintOptions {
|
|
46
|
+
size?: PageSize;
|
|
47
|
+
landscape?: boolean;
|
|
48
|
+
}
|
|
49
|
+
export interface HtmlOptions extends PrintOptions {
|
|
50
|
+
/** The PDF's title (document metadata). */
|
|
51
|
+
title: string;
|
|
52
|
+
/** The standard layout's typography under the app's HTML. Default true. */
|
|
53
|
+
standardStyles?: boolean;
|
|
54
|
+
/** The logo and name across the top of the first page. Default true. */
|
|
55
|
+
header?: boolean;
|
|
56
|
+
/** Let the page run its own script. Default false. */
|
|
57
|
+
javascript?: boolean;
|
|
58
|
+
/** https origins the page may load from, besides the brand's fonts, e.g. ["https://images.example.com"]. */
|
|
59
|
+
allowOrigins?: string[];
|
|
60
|
+
}
|
|
61
|
+
export interface PdfMaker {
|
|
62
|
+
/** The standard branded layout. */
|
|
63
|
+
document(doc: PdfDocument, options?: PrintOptions): Promise<Uint8Array>;
|
|
64
|
+
/** The app's own HTML (a body fragment, trusted as given) with the brand's fonts, colours and footer. */
|
|
65
|
+
html(body: string, options: HtmlOptions): Promise<Uint8Array>;
|
|
66
|
+
/** The standard layout's HTML, without printing: for previews and tests. */
|
|
67
|
+
renderDocument(doc: PdfDocument): Promise<string>;
|
|
68
|
+
}
|
|
69
|
+
/** Whether the page may load `url`. data: and about: always; https only from `origins`. */
|
|
70
|
+
export declare function allowed(url: string, origins: readonly string[]): boolean;
|
|
71
|
+
export declare function createPdfMaker(options: PdfMakerOptions): PdfMaker;
|
package/dist/maker.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// Prints a page to PDF in the app's own Worker through its Browser Rendering
|
|
2
|
+
// binding. One browser per PDF, always closed. The page runs no script unless
|
|
3
|
+
// the app asks, and may load nothing but data: URIs and the brand's fonts
|
|
4
|
+
// (plus origins the app names), so content can't reach other hosts.
|
|
5
|
+
import { printBrand } from "./brand.js";
|
|
6
|
+
import { checkDocument } from "./document.js";
|
|
7
|
+
import { WorkbenchPdfError } from "./errors.js";
|
|
8
|
+
import { footerTemplate, ownPage, standardPage } from "./layout.js";
|
|
9
|
+
import { logoDataUri } from "./logo.js";
|
|
10
|
+
const GOOGLE_FONT_FILES = "https://fonts.gstatic.com";
|
|
11
|
+
function fontOrigins(brand) {
|
|
12
|
+
const out = new Set();
|
|
13
|
+
for (const f of [brand.print.heading, brand.print.body]) {
|
|
14
|
+
if (!f.url)
|
|
15
|
+
continue;
|
|
16
|
+
const origin = new URL(f.url).origin;
|
|
17
|
+
out.add(origin);
|
|
18
|
+
if (origin === "https://fonts.googleapis.com")
|
|
19
|
+
out.add(GOOGLE_FONT_FILES);
|
|
20
|
+
}
|
|
21
|
+
return [...out];
|
|
22
|
+
}
|
|
23
|
+
function checkOrigins(list) {
|
|
24
|
+
if (list === undefined)
|
|
25
|
+
return [];
|
|
26
|
+
if (!Array.isArray(list))
|
|
27
|
+
throw new WorkbenchPdfError("invalid_document", "allowOrigins must be a list");
|
|
28
|
+
return list.map((o) => {
|
|
29
|
+
let url;
|
|
30
|
+
try {
|
|
31
|
+
url = new URL(o);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
throw new WorkbenchPdfError("invalid_document", "allowOrigins must be https origins");
|
|
35
|
+
}
|
|
36
|
+
if (url.protocol !== "https:" || url.origin !== o.replace(/\/$/, ""))
|
|
37
|
+
throw new WorkbenchPdfError("invalid_document", "allowOrigins must be https origins");
|
|
38
|
+
return url.origin;
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
/** Whether the page may load `url`. data: and about: always; https only from `origins`. */
|
|
42
|
+
export function allowed(url, origins) {
|
|
43
|
+
if (url.startsWith("data:") || url === "about:blank")
|
|
44
|
+
return true;
|
|
45
|
+
try {
|
|
46
|
+
const u = new URL(url);
|
|
47
|
+
return u.protocol === "https:" && origins.includes(u.origin);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
async function defaultLaunch(binding) {
|
|
54
|
+
const puppeteer = await import("@cloudflare/puppeteer");
|
|
55
|
+
return (await puppeteer.default.launch(binding));
|
|
56
|
+
}
|
|
57
|
+
function withTimeout(work, ms) {
|
|
58
|
+
let timer;
|
|
59
|
+
const limit = new Promise((_, reject) => {
|
|
60
|
+
timer = setTimeout(() => reject(new WorkbenchPdfError("timeout", `the PDF took longer than ${ms} ms`)), ms);
|
|
61
|
+
});
|
|
62
|
+
return Promise.race([work, limit]).finally(() => clearTimeout(timer));
|
|
63
|
+
}
|
|
64
|
+
const isLimit = (err) => /429|rate limit|limit exceeded|too many/i.test(String(err?.message ?? err));
|
|
65
|
+
export function createPdfMaker(options) {
|
|
66
|
+
const brand = printBrand(options.brand);
|
|
67
|
+
const timeoutMs = options.timeoutMs ?? 30_000;
|
|
68
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 1000 || timeoutMs > 120_000) {
|
|
69
|
+
throw new WorkbenchPdfError("invalid_document", "timeoutMs must be between 1,000 and 120,000");
|
|
70
|
+
}
|
|
71
|
+
const launch = options.launch ?? defaultLaunch;
|
|
72
|
+
const fetcher = options.fetch ?? ((input, init) => fetch(input, init));
|
|
73
|
+
const fonts = fontOrigins(brand);
|
|
74
|
+
let logo;
|
|
75
|
+
// Fetched once per maker; a failure is not kept, so the next PDF tries again.
|
|
76
|
+
const logoSrc = () => {
|
|
77
|
+
logo ??= logoDataUri(brand.logo.url, fetcher).catch((err) => {
|
|
78
|
+
logo = undefined;
|
|
79
|
+
throw err;
|
|
80
|
+
});
|
|
81
|
+
return logo;
|
|
82
|
+
};
|
|
83
|
+
async function print(html, opts) {
|
|
84
|
+
const size = opts.size ?? options.size ?? "A4";
|
|
85
|
+
if (size !== "A4" && size !== "Letter")
|
|
86
|
+
throw new WorkbenchPdfError("invalid_document", 'size must be "A4" or "Letter"');
|
|
87
|
+
if (!options.browser || typeof options.browser.fetch !== "function") {
|
|
88
|
+
throw new WorkbenchPdfError("browser_unavailable", "no Browser Rendering binding: add \"browser\": { \"binding\": \"BROWSER\" } to wrangler.jsonc");
|
|
89
|
+
}
|
|
90
|
+
let browser;
|
|
91
|
+
let gaveUp = false;
|
|
92
|
+
try {
|
|
93
|
+
const opening = launch(options.browser);
|
|
94
|
+
// A browser that opens after the time limit is closed, not left running.
|
|
95
|
+
opening.then((late) => {
|
|
96
|
+
if (gaveUp)
|
|
97
|
+
void late.close().catch(() => undefined);
|
|
98
|
+
}, () => undefined);
|
|
99
|
+
browser = await withTimeout(opening, timeoutMs).catch((err) => {
|
|
100
|
+
gaveUp = true;
|
|
101
|
+
throw err;
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
if (err instanceof WorkbenchPdfError)
|
|
106
|
+
throw err;
|
|
107
|
+
throw new WorkbenchPdfError("browser_unavailable", isLimit(err) ? "Browser Rendering limit reached; try again shortly" : "could not open a browser", { cause: err });
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
return await withTimeout((async () => {
|
|
111
|
+
const page = await browser.newPage();
|
|
112
|
+
await page.setJavaScriptEnabled(opts.javascript);
|
|
113
|
+
await page.setRequestInterception(true);
|
|
114
|
+
page.on("request", (req) => {
|
|
115
|
+
if (req.isInterceptResolutionHandled?.())
|
|
116
|
+
return;
|
|
117
|
+
void (allowed(req.url(), opts.origins) ? req.continue() : req.abort("blockedbyclient")).catch(() => undefined);
|
|
118
|
+
});
|
|
119
|
+
await page.setContent(html, { waitUntil: "networkidle0", timeout: timeoutMs });
|
|
120
|
+
const bytes = await page.pdf({
|
|
121
|
+
format: size,
|
|
122
|
+
landscape: opts.landscape === true,
|
|
123
|
+
printBackground: true,
|
|
124
|
+
displayHeaderFooter: true,
|
|
125
|
+
headerTemplate: "<span></span>",
|
|
126
|
+
footerTemplate: footerTemplate(brand),
|
|
127
|
+
margin: { top: "15mm", bottom: "18mm", left: "15mm", right: "15mm" },
|
|
128
|
+
timeout: timeoutMs,
|
|
129
|
+
});
|
|
130
|
+
return new Uint8Array(bytes);
|
|
131
|
+
})(), timeoutMs);
|
|
132
|
+
}
|
|
133
|
+
catch (err) {
|
|
134
|
+
if (err instanceof WorkbenchPdfError)
|
|
135
|
+
throw err;
|
|
136
|
+
const timedOut = /timeout|timed out/i.test(String(err?.message ?? err));
|
|
137
|
+
throw new WorkbenchPdfError(timedOut ? "timeout" : "render_failed", timedOut ? "the page did not finish loading" : "the browser failed while printing", { cause: err });
|
|
138
|
+
}
|
|
139
|
+
finally {
|
|
140
|
+
await browser.close().catch(() => undefined);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
async function renderDocument(doc) {
|
|
144
|
+
checkDocument(doc);
|
|
145
|
+
return standardPage(brand, doc, await logoSrc());
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
renderDocument,
|
|
149
|
+
async document(doc, opts = {}) {
|
|
150
|
+
const html = await renderDocument(doc);
|
|
151
|
+
return print(html, { ...opts, javascript: false, origins: fonts });
|
|
152
|
+
},
|
|
153
|
+
async html(body, opts) {
|
|
154
|
+
if (typeof body !== "string" || body.length > 5_000_000)
|
|
155
|
+
throw new WorkbenchPdfError("invalid_document", "html must be text of at most 5,000,000 characters");
|
|
156
|
+
if (typeof opts?.title !== "string" || opts.title.trim() === "")
|
|
157
|
+
throw new WorkbenchPdfError("invalid_document", "title is required");
|
|
158
|
+
const origins = [...fonts, ...checkOrigins(opts.allowOrigins)];
|
|
159
|
+
const header = opts.header !== false;
|
|
160
|
+
const page = ownPage(brand, body, opts.title, { standardStyles: opts.standardStyles !== false, header, logoSrc: header ? await logoSrc() : "" });
|
|
161
|
+
return print(page, { ...opts, javascript: opts.javascript === true, origins });
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,50 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mccaskie-labs/workbench-pdf",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Branded PDFs for Workbench apps: the app's content printed with its logo, colours and fonts, in its own Worker through the Browser Rendering binding.",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/mccaskie-labs/workbench.git",
|
|
10
|
+
"directory": "packages/pdf"
|
|
11
|
+
},
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=24"
|
|
14
|
+
},
|
|
15
|
+
"sideEffects": false,
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"default": "./dist/index.js"
|
|
20
|
+
},
|
|
21
|
+
"./package.json": "./package.json"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist",
|
|
25
|
+
"README.md",
|
|
26
|
+
"CHANGELOG.md"
|
|
27
|
+
],
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"typecheck": "tsc -p tsconfig.check.json && tsc -p test/tsconfig.json",
|
|
33
|
+
"test": "vitest run",
|
|
34
|
+
"build": "node scripts/build.mjs",
|
|
35
|
+
"prepack": "node scripts/build.mjs"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@mccaskie-labs/workbench-config": "^0.1.13"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@cloudflare/puppeteer": "1.4.0",
|
|
42
|
+
"@types/node": "24.19.2",
|
|
43
|
+
"vitest": "^5.1.0",
|
|
44
|
+
"zod": "4.6.7"
|
|
45
|
+
},
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"@cloudflare/puppeteer": "^1.4.0",
|
|
48
|
+
"zod": "^4.1.0"
|
|
49
|
+
}
|
|
50
|
+
}
|