@formepdf/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ export interface BuildOptions {
2
+ output: string;
3
+ dataPath?: string;
4
+ }
5
+ export declare function buildPdf(inputPath: string, options: BuildOptions): Promise<void>;
package/dist/build.js ADDED
@@ -0,0 +1,81 @@
1
+ import { readFile, writeFile } from 'node:fs/promises';
2
+ import { resolve, join } from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
4
+ import { isValidElement } from 'react';
5
+ import { bundleFile, BUNDLE_DIR } from './bundle.js';
6
+ import { renderDocumentWithLayout } from '@formepdf/core';
7
+ export async function buildPdf(inputPath, options) {
8
+ const absoluteInput = resolve(inputPath);
9
+ console.log(`Building ${absoluteInput}...`);
10
+ try {
11
+ const code = await bundleFile(absoluteInput);
12
+ // Write temp file inside CLI package dir so Node resolves @formepdf/* deps
13
+ const tmpFile = join(BUNDLE_DIR, `.forme-build-${Date.now()}.mjs`);
14
+ await writeFile(tmpFile, code);
15
+ try {
16
+ const mod = await import(pathToFileURL(tmpFile).href);
17
+ const element = await resolveElement(mod, options.dataPath);
18
+ const { pdf } = await renderDocumentWithLayout(element);
19
+ const outputPath = resolve(options.output);
20
+ await writeFile(outputPath, pdf);
21
+ console.log(`Written ${pdf.length} bytes to ${outputPath}`);
22
+ }
23
+ finally {
24
+ // Clean up temp file
25
+ const { unlink } = await import('node:fs/promises');
26
+ await unlink(tmpFile).catch(() => { });
27
+ }
28
+ }
29
+ catch (err) {
30
+ const message = err instanceof Error ? err.message : String(err);
31
+ console.error(`\n ${message.split('\n').join('\n ')}\n`);
32
+ process.exit(1);
33
+ }
34
+ }
35
+ async function resolveElement(mod, dataPath) {
36
+ const exported = mod.default;
37
+ if (exported === undefined) {
38
+ throw new Error(`No default export found.\n\n` +
39
+ ` Your file must export a Forme element or a function that returns one:\n\n` +
40
+ ` export default (\n` +
41
+ ` <Document>\n` +
42
+ ` <Text>Hello</Text>\n` +
43
+ ` </Document>\n` +
44
+ ` );\n\n` +
45
+ ` Or with data:\n\n` +
46
+ ` export default function Report(data) {\n` +
47
+ ` return <Document><Text>{data.title}</Text></Document>\n` +
48
+ ` }`);
49
+ }
50
+ if (typeof exported === 'function') {
51
+ const data = dataPath ? await loadJsonData(dataPath) : {};
52
+ const result = await exported(data);
53
+ if (!isValidElement(result)) {
54
+ throw new Error(`Default export function did not return a valid Forme element.\n` +
55
+ ` Got: ${typeof result}\n` +
56
+ ` Make sure your function returns a <Document> element.`);
57
+ }
58
+ return result;
59
+ }
60
+ if (isValidElement(exported)) {
61
+ if (dataPath) {
62
+ console.warn(`Warning: --data flag provided but default export is a static element, not a function.\n` +
63
+ ` The data file will be ignored. Export a function to use --data.`);
64
+ }
65
+ return exported;
66
+ }
67
+ throw new Error(`Default export is not a valid Forme element.\n` +
68
+ ` Got: ${typeof exported}\n` +
69
+ ` Expected: a <Document> element or a function that returns one.`);
70
+ }
71
+ async function loadJsonData(dataPath) {
72
+ const absolutePath = resolve(dataPath);
73
+ const raw = await readFile(absolutePath, 'utf-8');
74
+ try {
75
+ return JSON.parse(raw);
76
+ }
77
+ catch {
78
+ throw new Error(`Failed to parse data file as JSON: ${absolutePath}\n` +
79
+ ` Make sure the file contains valid JSON.`);
80
+ }
81
+ }
@@ -0,0 +1,2 @@
1
+ export declare const BUNDLE_DIR: string;
2
+ export declare function bundleFile(filePath: string): Promise<string>;
package/dist/bundle.js ADDED
@@ -0,0 +1,87 @@
1
+ import { build } from 'esbuild';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { dirname, join } from 'node:path';
4
+ const __dirname = dirname(fileURLToPath(import.meta.url));
5
+ /// The temp directory for bundled output — placed inside CLI package
6
+ /// so that Node's module resolution finds @formepdf/react, @formepdf/core, react.
7
+ export const BUNDLE_DIR = join(__dirname, '..');
8
+ /// esbuild plugin that intercepts react/jsx-dev-runtime to capture source
9
+ /// locations in a global WeakMap. React 19 no longer stores _source on
10
+ /// elements, so we wrap jsxDEV to do it ourselves.
11
+ const formeJsxSourcePlugin = {
12
+ name: 'forme-jsx-source',
13
+ setup(pluginBuild) {
14
+ pluginBuild.onResolve({ filter: /^react\/jsx-dev-runtime$/ }, () => ({
15
+ path: 'forme-jsx-dev-runtime',
16
+ namespace: 'forme-jsx',
17
+ }));
18
+ pluginBuild.onLoad({ filter: /.*/, namespace: 'forme-jsx' }, () => {
19
+ const cwd = pluginBuild.initialOptions.absWorkingDir || process.cwd();
20
+ return {
21
+ contents: `
22
+ import { jsx, Fragment } from 'react/jsx-runtime';
23
+ import { resolve, isAbsolute } from 'node:path';
24
+ export { Fragment };
25
+ if (!globalThis.__formeSourceMap) globalThis.__formeSourceMap = new WeakMap();
26
+ const _cwd = ${JSON.stringify(cwd)};
27
+ export function jsxDEV(type, props, key, isStaticChildren, source, self) {
28
+ const el = jsx(type, props, key);
29
+ if (source && source.fileName) {
30
+ try {
31
+ const file = isAbsolute(source.fileName) ? source.fileName : resolve(_cwd, source.fileName);
32
+ globalThis.__formeSourceMap.set(el, { file, line: source.lineNumber, column: source.columnNumber });
33
+ } catch(e) {}
34
+ }
35
+ return el;
36
+ }
37
+ `,
38
+ resolveDir: cwd,
39
+ loader: 'js',
40
+ };
41
+ });
42
+ },
43
+ };
44
+ /// Bundle a TSX/JSX file into an ESM string that can be dynamically imported.
45
+ export async function bundleFile(filePath) {
46
+ try {
47
+ const result = await build({
48
+ entryPoints: [filePath],
49
+ bundle: true,
50
+ format: 'esm',
51
+ platform: 'node',
52
+ write: false,
53
+ jsx: 'automatic',
54
+ jsxDev: true,
55
+ target: 'node20',
56
+ external: ['react', '@formepdf/react', '@formepdf/core'],
57
+ plugins: [formeJsxSourcePlugin],
58
+ });
59
+ return result.outputFiles[0].text;
60
+ }
61
+ catch (err) {
62
+ // Format esbuild errors with file location and source context
63
+ if (isBuildFailure(err)) {
64
+ const messages = [];
65
+ for (const error of err.errors) {
66
+ let loc = '';
67
+ if (error.location) {
68
+ const { file, line, column, lineText } = error.location;
69
+ loc = ` ${file}:${line}:${column}\n`;
70
+ if (lineText) {
71
+ loc += ` ${lineText}\n`;
72
+ loc += ` ${' '.repeat(column)}^\n`;
73
+ }
74
+ }
75
+ messages.push(`${error.text}\n${loc}`);
76
+ }
77
+ throw new Error(`Build error:\n${messages.join('\n')}`);
78
+ }
79
+ throw err;
80
+ }
81
+ }
82
+ function isBuildFailure(err) {
83
+ return (err !== null &&
84
+ typeof err === 'object' &&
85
+ 'errors' in err &&
86
+ Array.isArray(err.errors));
87
+ }
package/dist/dev.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ export interface DevOptions {
2
+ port: number;
3
+ dataPath?: string;
4
+ }
5
+ export declare function startDevServer(inputPath: string, options: DevOptions): void;
package/dist/dev.js ADDED
@@ -0,0 +1,290 @@
1
+ import { createServer } from 'node:http';
2
+ import { readFile, writeFile, unlink } from 'node:fs/promises';
3
+ import { resolve, basename, join } from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
5
+ import { watch } from 'chokidar';
6
+ import { WebSocketServer } from 'ws';
7
+ import open from 'open';
8
+ import { isValidElement } from 'react';
9
+ import { bundleFile, BUNDLE_DIR } from './bundle.js';
10
+ import { renderPdfWithLayout } from '@formepdf/core';
11
+ export function startDevServer(inputPath, options) {
12
+ const absoluteInput = resolve(inputPath);
13
+ const fileName = basename(absoluteInput);
14
+ const port = options.port;
15
+ const dataPath = options.dataPath ? resolve(options.dataPath) : undefined;
16
+ let currentPdf = null;
17
+ let currentLayout = null;
18
+ let lastRenderTime = 0;
19
+ let lastError = null;
20
+ let firstRender = true;
21
+ // Override state
22
+ let pageSizeOverride = null;
23
+ let inMemoryData = null;
24
+ let useInMemoryData = false;
25
+ // ── HTTP Server ──────────────────────────────────────────────
26
+ const server = createServer(async (req, res) => {
27
+ const url = req.url ?? '/';
28
+ if (url === '/' || url === '/index.html') {
29
+ const html = await getPreviewHtml();
30
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
31
+ res.end(html);
32
+ return;
33
+ }
34
+ if (url === '/pdf') {
35
+ if (currentPdf) {
36
+ res.writeHead(200, {
37
+ 'Content-Type': 'application/pdf',
38
+ 'Content-Length': String(currentPdf.length),
39
+ });
40
+ res.end(currentPdf);
41
+ }
42
+ else {
43
+ res.writeHead(503, { 'Content-Type': 'text/plain' });
44
+ res.end(lastError ?? 'PDF not yet rendered');
45
+ }
46
+ return;
47
+ }
48
+ if (url === '/layout') {
49
+ if (currentLayout) {
50
+ const json = JSON.stringify(currentLayout);
51
+ res.writeHead(200, { 'Content-Type': 'application/json' });
52
+ res.end(json);
53
+ }
54
+ else {
55
+ res.writeHead(503, { 'Content-Type': 'text/plain' });
56
+ res.end('Layout not yet available');
57
+ }
58
+ return;
59
+ }
60
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
61
+ res.end('Not found');
62
+ });
63
+ // ── WebSocket ────────────────────────────────────────────────
64
+ const wss = new WebSocketServer({ server });
65
+ const clients = new Set();
66
+ wss.on('connection', async (ws) => {
67
+ clients.add(ws);
68
+ ws.on('close', () => clients.delete(ws));
69
+ // Send init message with data and page size state
70
+ let dataContent = null;
71
+ if (dataPath) {
72
+ try {
73
+ dataContent = await readFile(dataPath, 'utf-8');
74
+ }
75
+ catch { /* ignore */ }
76
+ }
77
+ const initMsg = {
78
+ type: 'init',
79
+ hasData: !!dataPath,
80
+ dataContent,
81
+ pageSizeOverride,
82
+ };
83
+ if (ws.readyState === ws.OPEN) {
84
+ ws.send(JSON.stringify(initMsg));
85
+ }
86
+ ws.on('message', (raw) => {
87
+ let msg;
88
+ try {
89
+ msg = JSON.parse(raw.toString());
90
+ }
91
+ catch {
92
+ return;
93
+ }
94
+ if (msg.type === 'setPageSize') {
95
+ pageSizeOverride = { width: msg.width, height: msg.height };
96
+ triggerRebuild();
97
+ }
98
+ if (msg.type === 'clearPageSize') {
99
+ pageSizeOverride = null;
100
+ triggerRebuild();
101
+ }
102
+ if (msg.type === 'updateData') {
103
+ inMemoryData = msg.data;
104
+ useInMemoryData = true;
105
+ triggerRebuild();
106
+ }
107
+ if (msg.type === 'saveData' && dataPath) {
108
+ const content = msg.content;
109
+ writeFile(dataPath, content, 'utf-8').catch((err) => {
110
+ console.error(`Failed to save data file: ${err}`);
111
+ });
112
+ }
113
+ });
114
+ });
115
+ function broadcast(message) {
116
+ const data = JSON.stringify(message);
117
+ for (const ws of clients) {
118
+ if (ws.readyState === ws.OPEN) {
119
+ ws.send(data);
120
+ }
121
+ }
122
+ }
123
+ // ── Build + Render ───────────────────────────────────────────
124
+ let buildCounter = 0;
125
+ async function rebuild() {
126
+ const buildId = ++buildCounter;
127
+ const start = performance.now();
128
+ try {
129
+ const code = await bundleFile(absoluteInput);
130
+ // Skip if a newer build started
131
+ if (buildId !== buildCounter)
132
+ return;
133
+ // Write temp file inside CLI package dir so Node resolves @formepdf/* deps
134
+ const tmpFile = join(BUNDLE_DIR, `.forme-dev-${Date.now()}.mjs`);
135
+ await writeFile(tmpFile, code);
136
+ let mod;
137
+ try {
138
+ mod = await import(pathToFileURL(tmpFile).href);
139
+ }
140
+ finally {
141
+ await unlink(tmpFile).catch(() => { });
142
+ }
143
+ const overrideData = useInMemoryData ? inMemoryData : undefined;
144
+ const element = await resolveElement(mod, dataPath, overrideData);
145
+ // Serialize JSX to document JSON, apply overrides, then render
146
+ const { serialize } = await import('@formepdf/react');
147
+ const doc = serialize(element);
148
+ // Apply page size override
149
+ if (pageSizeOverride) {
150
+ const { width, height } = pageSizeOverride;
151
+ const customSize = { Custom: { width, height } };
152
+ // Override defaultPage size
153
+ if (doc.defaultPage && typeof doc.defaultPage === 'object') {
154
+ doc.defaultPage.size = customSize;
155
+ }
156
+ // Override each Page node's config size
157
+ if (Array.isArray(doc.children)) {
158
+ for (const child of doc.children) {
159
+ if (child && typeof child === 'object' && child.kind && typeof child.kind === 'object') {
160
+ const kind = child.kind;
161
+ if (kind.type === 'Page' && kind.config && typeof kind.config === 'object') {
162
+ kind.config.size = customSize;
163
+ }
164
+ }
165
+ }
166
+ }
167
+ }
168
+ const { pdf, layout } = await renderPdfWithLayout(JSON.stringify(doc));
169
+ // Skip if a newer build started
170
+ if (buildId !== buildCounter)
171
+ return;
172
+ currentPdf = pdf;
173
+ currentLayout = layout;
174
+ lastError = null;
175
+ lastRenderTime = Math.round(performance.now() - start);
176
+ const pageCount = layout?.pages?.length ?? 0;
177
+ if (firstRender) {
178
+ firstRender = false;
179
+ const url = `http://localhost:${port}`;
180
+ console.log(`Forme dev server\n`);
181
+ console.log(` Watching: ${fileName}${dataPath ? ` + ${basename(dataPath)}` : ''}`);
182
+ console.log(` Rendered: ${pageCount} page${pageCount !== 1 ? 's' : ''} in ${lastRenderTime}ms`);
183
+ console.log(` Preview: ${url}\n`);
184
+ open(url);
185
+ }
186
+ else {
187
+ console.log(`Rebuilt in ${lastRenderTime}ms (${pageCount} page${pageCount !== 1 ? 's' : ''})`);
188
+ }
189
+ broadcast({ type: 'reload', renderTime: lastRenderTime });
190
+ }
191
+ catch (err) {
192
+ const message = err instanceof Error ? err.message : String(err);
193
+ lastError = message;
194
+ console.error(`Build error: ${message}`);
195
+ broadcast({ type: 'error', message });
196
+ }
197
+ }
198
+ // ── File Watcher ─────────────────────────────────────────────
199
+ let debounceTimer = null;
200
+ function triggerRebuild() {
201
+ if (debounceTimer)
202
+ clearTimeout(debounceTimer);
203
+ debounceTimer = setTimeout(rebuild, 100);
204
+ }
205
+ const watchPaths = [absoluteInput, ...(dataPath ? [dataPath] : [])];
206
+ const watcher = watch(watchPaths, { ignoreInitial: true });
207
+ watcher.on('change', (changedPath) => {
208
+ // If data file changed on disk, reset in-memory override and push new content
209
+ if (dataPath && resolve(changedPath) === dataPath) {
210
+ useInMemoryData = false;
211
+ inMemoryData = null;
212
+ readFile(dataPath, 'utf-8').then((content) => {
213
+ broadcast({ type: 'dataUpdate', content });
214
+ }).catch(() => { });
215
+ }
216
+ triggerRebuild();
217
+ });
218
+ // ── Graceful shutdown ───────────────────────────────────────
219
+ process.on('SIGINT', () => {
220
+ console.log('\nShutting down...');
221
+ watcher.close();
222
+ wss.close();
223
+ server.close(() => process.exit(0));
224
+ // Force exit after 2s if graceful shutdown stalls
225
+ setTimeout(() => process.exit(0), 2000);
226
+ });
227
+ // ── Start ────────────────────────────────────────────────────
228
+ server.listen(port, () => {
229
+ rebuild();
230
+ });
231
+ }
232
+ async function resolveElement(mod, dataPath, overrideData) {
233
+ const exported = mod.default;
234
+ if (exported === undefined) {
235
+ throw new Error(`No default export found.\n\n` +
236
+ ` Your file must export a Forme element or a function that returns one:\n\n` +
237
+ ` export default (\n` +
238
+ ` <Document>\n` +
239
+ ` <Text>Hello</Text>\n` +
240
+ ` </Document>\n` +
241
+ ` );`);
242
+ }
243
+ if (typeof exported === 'function') {
244
+ let data = {};
245
+ if (overrideData !== undefined) {
246
+ data = overrideData;
247
+ }
248
+ else if (dataPath) {
249
+ const raw = await readFile(dataPath, 'utf-8');
250
+ try {
251
+ data = JSON.parse(raw);
252
+ }
253
+ catch {
254
+ throw new Error(`Failed to parse data file as JSON: ${dataPath}\n` +
255
+ ` Make sure the file contains valid JSON.`);
256
+ }
257
+ }
258
+ const result = await exported(data);
259
+ if (!isValidElement(result)) {
260
+ throw new Error(`Default export function did not return a valid Forme element.\n` +
261
+ ` Got: ${typeof result}`);
262
+ }
263
+ return result;
264
+ }
265
+ if (isValidElement(exported)) {
266
+ return exported;
267
+ }
268
+ throw new Error(`Default export is not a valid Forme element.\n` +
269
+ ` Got: ${typeof exported}\n` +
270
+ ` Expected: a <Document> element or a function that returns one.`);
271
+ }
272
+ async function getPreviewHtml() {
273
+ // Try to load the preview HTML from the package's own file tree
274
+ const possiblePaths = [
275
+ // When running from dist/ (built)
276
+ new URL('./preview/index.html', import.meta.url),
277
+ // When running from src/ (dev with ts-node)
278
+ new URL('../src/preview/index.html', import.meta.url),
279
+ ];
280
+ for (const p of possiblePaths) {
281
+ try {
282
+ return await readFile(p, 'utf-8');
283
+ }
284
+ catch {
285
+ continue;
286
+ }
287
+ }
288
+ // Inline fallback
289
+ return `<!DOCTYPE html><html><body><h1>Preview HTML not found</h1></body></html>`;
290
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync } from 'node:fs';
3
+ import { parseArgs } from 'node:util';
4
+ import { resolve } from 'node:path';
5
+ import { startDevServer } from './dev.js';
6
+ import { buildPdf } from './build.js';
7
+ const USAGE = `
8
+ forme - Page-native PDF rendering engine
9
+
10
+ Usage:
11
+ forme dev <file.tsx> Start dev server with live preview
12
+ forme build <file.tsx> Render PDF to disk
13
+
14
+ Options:
15
+ -o, --output <path> Output PDF path (build only, default: output.pdf)
16
+ -d, --data <path> JSON data file to pass to template function
17
+ -p, --port <number> Dev server port (default: 4242)
18
+ -h, --help Show this help message
19
+
20
+ Examples:
21
+ forme dev src/invoice.tsx
22
+ forme build src/invoice.tsx -o invoice.pdf
23
+ forme build src/report.tsx --data data.json -o report.pdf
24
+
25
+ Data flag:
26
+ If your template exports a function instead of a JSX element,
27
+ use --data to pass a JSON file as the function argument:
28
+
29
+ // report.tsx
30
+ export default function Report(data: { title: string }) {
31
+ return <Document><Text>{data.title}</Text></Document>
32
+ }
33
+
34
+ forme build report.tsx --data '{"title": "Q4 Report"}'
35
+ `;
36
+ function main() {
37
+ const { values, positionals } = parseArgs({
38
+ allowPositionals: true,
39
+ options: {
40
+ output: { type: 'string', short: 'o', default: 'output.pdf' },
41
+ data: { type: 'string', short: 'd' },
42
+ port: { type: 'string', short: 'p', default: '4242' },
43
+ help: { type: 'boolean', short: 'h', default: false },
44
+ },
45
+ });
46
+ if (values.help || positionals.length === 0) {
47
+ console.log(USAGE.trim());
48
+ process.exit(0);
49
+ }
50
+ const [command, inputPath] = positionals;
51
+ if (!inputPath) {
52
+ console.error(`Error: Missing input file.\n`);
53
+ console.log(USAGE.trim());
54
+ process.exit(1);
55
+ }
56
+ // Validate input file exists
57
+ const absoluteInput = resolve(inputPath);
58
+ if (!existsSync(absoluteInput)) {
59
+ console.error(`Error: Input file not found: ${absoluteInput}`);
60
+ process.exit(1);
61
+ }
62
+ // Validate data file exists if provided
63
+ const dataPath = values.data;
64
+ if (dataPath) {
65
+ const absoluteData = resolve(dataPath);
66
+ if (!existsSync(absoluteData)) {
67
+ console.error(`Error: Data file not found: ${absoluteData}`);
68
+ process.exit(1);
69
+ }
70
+ }
71
+ switch (command) {
72
+ case 'dev':
73
+ startDevServer(inputPath, { port: Number(values.port), dataPath });
74
+ break;
75
+ case 'build':
76
+ buildPdf(inputPath, { output: values.output, dataPath });
77
+ break;
78
+ default:
79
+ console.error(`Unknown command: ${command}\n`);
80
+ console.log(USAGE.trim());
81
+ process.exit(1);
82
+ }
83
+ }
84
+ main();