diff --git a/.github/workflows/react-starter.yml b/.github/workflows/react-starter.yml index 779ece1..f9ff21b 100644 --- a/.github/workflows/react-starter.yml +++ b/.github/workflows/react-starter.yml @@ -3,9 +3,9 @@ name: React starter on: push: branches: [main] - paths: ['examples/react/**', 'tools/check-react-starter.py', 'tools/prepare-react-check.mjs', 'tools/browser-requirements.txt', 'tools/starter_standard_fonts.py', 'tools/starter_engine_fixes.py', 'tools/starter-engine-2.5.22.json', 'tools/starter-engine-2.5.24.json', '.github/workflows/react-starter.yml'] + paths: ['examples/react/**', 'tools/check-react-starter.py', 'tools/check-react-development.py', 'tools/prepare-react-check.mjs', 'tools/react_component_checks.py', 'tools/browser-requirements.txt', 'tools/starter_standard_fonts.py', 'tools/starter_engine_fixes.py', 'tools/starter-engine-2.5.22.json', 'tools/starter-engine-2.5.24.json', '.github/workflows/react-starter.yml'] pull_request: - paths: ['examples/react/**', 'tools/check-react-starter.py', 'tools/prepare-react-check.mjs', 'tools/browser-requirements.txt', 'tools/starter_standard_fonts.py', 'tools/starter_engine_fixes.py', 'tools/starter-engine-2.5.22.json', 'tools/starter-engine-2.5.24.json', '.github/workflows/react-starter.yml'] + paths: ['examples/react/**', 'tools/check-react-starter.py', 'tools/check-react-development.py', 'tools/prepare-react-check.mjs', 'tools/react_component_checks.py', 'tools/browser-requirements.txt', 'tools/starter_standard_fonts.py', 'tools/starter_engine_fixes.py', 'tools/starter-engine-2.5.22.json', 'tools/starter-engine-2.5.24.json', '.github/workflows/react-starter.yml'] workflow_dispatch: permissions: @@ -47,6 +47,9 @@ jobs: - name: Check StrictMode lifecycle and downloads if: matrix.browser == 'chrome' run: python tools/check-react-starter.py --dist examples/react/dist-strict --out output/react-strict --browser chrome --strict + - name: Check actual TSX and stylesheet edits in the Vite development server + if: matrix.browser == 'chrome' + run: python tools/check-react-development.py --project examples/react --out output/react-development - uses: actions/upload-artifact@v4 if: always() with: @@ -55,4 +58,5 @@ jobs: output/react-baseline/ output/react-${{ matrix.browser }}/ output/react-strict/ + output/react-development/ retention-days: 14 diff --git a/README.md b/README.md index 1879b3a..132a6dc 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,9 @@ runtime or build step is required for ordinary JavaScript functions. Try a complete project online: [JavaScript + Vite](https://docs.fullbleed.dev/assets/browser-starter/edit-online.html) or [React + TypeScript](https://docs.fullbleed.dev/assets/react-starter/edit-online.html). -Edit the HTML/CSS templates, preview the result, and download a PDF. Each starter -also includes a ZIP for local development. +The React project starts with a typed component invoice and editable line items; +the JavaScript project includes HTML/CSS templates. Preview the result and +download a PDF. Each starter also includes a ZIP for local development. Using Vue? The [Vue and TypeScript starter](examples/vue) includes editable templates, automatic previews, downloads, and a reusable `usePdfPreview` composable. @@ -125,8 +126,9 @@ data; connect your own authenticated record lookup when adapting it. ## Use it in a browser -For React, use the [React and TypeScript starter](examples/react) with automatic -previews, editable templates, cancellation, and a reusable component-lifecycle hook. +For React, use the [React and TypeScript starter](examples/react) with a +component-authored invoice, automatic previews, editable data, cancellation and +a reusable preview hook. Its dedicated print TSX files use Fullbleed's JSX runtime. Start with the [complete browser application](examples/browser): editable HTML/CSS, invoice and report designs, previews, cancellation, and PDF downloads. diff --git a/docs/components.md b/docs/components.md index 761e931..4a18eb3 100644 --- a/docs/components.md +++ b/docs/components.md @@ -67,6 +67,12 @@ For a whole document project, TypeScript options are `"jsx": "react-jsx"` and CommonJS are supported. JSX expressions use `renderDocument` because their TypeScript type can represent a document, element, fragment or text. +In a React/Vite application, keep print modules outside React Fast Refresh +boundaries so edits to their imported styles reach the preview. The +[React starter](../examples/react) includes the per-file pragma, Vite exclusion +and a document-aware preview hook. Its development check edits both TSX and +styles while preserving form values. + This is React-inspired synchronous composition for print output. React hooks, events, refs, React component libraries and async components are not supported. Fetch data before composing the document. `key` is accepted and ignored because diff --git a/examples/react/.gitignore b/examples/react/.gitignore index 2c8108b..441942f 100644 --- a/examples/react/.gitignore +++ b/examples/react/.gitignore @@ -3,3 +3,4 @@ dist/ dist-strict/ public/fullbleed/ output/ +.document-check/ diff --git a/examples/react/README.md b/examples/react/README.md index 1579ffc..cb4d6c4 100644 --- a/examples/react/README.md +++ b/examples/react/README.md @@ -1,9 +1,10 @@ # Fullbleed React PDF starter -A React and TypeScript application with automatic PDF previews, editable HTML/CSS -templates, cancellation, and downloads. Change a form field and the actual PDF -updates after a short pause. Includes a designed invoice and three-page report; -all sample organizations, records, and amounts are fictional. MIT licensed. +A React and TypeScript application with a component-authored invoice, automatic +PDF previews, cancellation, and downloads. Change the customer, line items, +color or payment status and the actual PDF updates after a short pause. The +print template uses typed functions, props, mapped rows, conditional content and +attached styles. All sample data is fictional. MIT licensed. ## Run @@ -21,32 +22,47 @@ Turn off **Update automatically** to render only when you choose **Generate PDF* ## Connect your data -`src/App.tsx` holds the React form, templates, and preview UI. The invoice uses -`{{customer}}` and `{{reference}}` in HTML and `{{ink}}` in CSS. The app escapes -customer text before filling the template and accepts a six-digit hex ink color. -Replace the fixed line items, totals, addresses, and dates with your data and -calculations when building a real application. - -Use **Edit the HTML & CSS** to try another layout. To keep it, edit the matching -`src/invoice.html`, `src/invoice.css`, `src/report.html`, or `src/report.css` file. -Use [supported print CSS](https://docs.fullbleed.dev/css-coverage/); the engine -renders supplied static HTML/CSS rather than capturing the React page. +`src/App.tsx` holds the React form and preview UI. `src/documents/Invoice.tsx` +builds the document with Fullbleed components. Its `@jsxImportSource fullbleed/ui` +pragma selects the print JSX runtime for that file; the web UI still uses React. +Call `Invoice({ data, ink })` to compose a document and pass it to the preview +hook. Use ordinary functions for reusable print pieces. React hooks belong in +the UI, outside these synchronous document functions. + +`src/documents/data.ts` defines the data, integer-cents formatting and form +validation. The demo accepts up to 20 line items and whole hours; negative rates +create credits. `src/documents/invoiceStyles.ts` holds reusable print styles, +composed with the chosen ink color. Supply your own dates and records when +building an application. Text is escaped by the component authoring layer. + +Edit either print source file while Vite is running to update the PDF. The Vite +config excludes `src/documents/` from React Fast Refresh so print-component and +style changes propagate to the React form. See the React plugin's +[exclude option](https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-react#exclude). + +The design selector also includes the existing HTML/CSS invoice and three-page +report. These have an **Edit the HTML & CSS** panel and keep edits until reload. +Their source files remain `src/invoice.html`, `src/invoice.css`, `src/report.html` +and `src/report.css`. Open `?design=invoice` or `?design=report` for a direct link. +See [supported print CSS](https://docs.fullbleed.dev/css-coverage/) for layout features. ## Reuse the hook -Copy `src/usePdfPreview.ts` into your client application, install -`fullbleed@0.5.1`, and copy the runtime with +Copy `src/usePdfPreview.ts` and the `src/documents/` directory into your client application, install +`fullbleed@0.6.0`, and copy the runtime with `npx fullbleed-browser-assets public/fullbleed`. Keep the document input stable with `useMemo` so ordinary component updates do not schedule another render: ```tsx import { useMemo } from 'react'; import { usePdfPreview } from './usePdfPreview'; +import { Invoice } from './documents/Invoice'; +import type { InvoiceData } from './documents/data'; -export function DocumentPreview({ html, css }: { html: string; css: string }) { +export function DocumentPreview({ data }: { data: InvoiceData }) { const input = useMemo(() => ({ - html, css, previewDpi: 96, maxPages: 20, timeoutMs: 30_000, - }), [html, css]); + document: Invoice({ data }), previewDpi: 96, maxPages: 20, timeoutMs: 30_000, + }), [data]); const pdf = usePdfPreview(input, '/fullbleed/'); return
@@ -65,6 +81,8 @@ The hook schedules a preview after 450 ms without input changes. Use its third argument `{ auto: false }` for on-demand generation, or `{ delayMs: 800 }` for a longer pause. Cancel stops a pending timer or active job until the next input change or `generate()` call. Changing preview mode also resets its output. +Pass `null` while inputs are invalid to clear old output without starting a +worker. The hook also accepts `{ html, css, ...options }` for existing templates. It hides stale downloads immediately, aborts superseded work, ignores obsolete results, and revokes replaced Blob URLs. Unmounting aborts the active request and @@ -94,15 +112,20 @@ WebAssembly, and Web Crypto are required. ## Versions and verification -The lockfile pins Fullbleed 0.5.1 / engine 2.5.24, React 19.3.0, TypeScript 7.0.2, +The lockfile pins Fullbleed 0.6.0 / engine 2.5.24, React 19.3.0, TypeScript 7.0.2, and Vite 8.3.2. No Fullbleed engine or package release is needed for this example. The repository's `React starter` workflow builds the locked project and checks -real PDF downloads with an independent reader. It compares default invoice and -report PDF bytes with the installed Node package, checks mobile layout, exercises -input races, cancellation, editing, recovery, and unmount/remount, and records +real PDF downloads with an independent reader. Six component cases cover the +default invoice, paid state, edited text/color, empty items, a credit and twenty +rows across three pages. Their PDFs and every preview match the installed Node +package. The existing HTML invoice and report also keep their output checks. +The workflow checks mobile layout, input races, cancellation, editing, recovery, +and unmount/remount, and records worker API handles and Blob URL cleanup. A separate React development build -checks the same flow with StrictMode enabled. Checks run in Chrome, Firefox and +checks the same flow with StrictMode enabled. A real Vite development-server +check edits print TSX and stylesheet files, verifies the resulting PDFs, and +checks that form values survive the updates. Checks run in Chrome, Firefox and Playwright WebKit; WebKit testing is not branded Safari certification. This is ordinary PDF generation and preview rendering. PDF/A, PDF/UA, PDF/X and diff --git a/examples/react/index.html b/examples/react/index.html index 6ebf6d7..196b2b2 100644 --- a/examples/react/index.html +++ b/examples/react/index.html @@ -3,7 +3,7 @@ - + React PDF starter | Fullbleed diff --git a/examples/react/package-lock.json b/examples/react/package-lock.json index e9ca8ab..cba2b2c 100644 --- a/examples/react/package-lock.json +++ b/examples/react/package-lock.json @@ -8,7 +8,7 @@ "name": "fullbleed-react-starter", "version": "1.0.0", "dependencies": { - "fullbleed": "0.5.1", + "fullbleed": "0.6.0", "react": "19.3.0", "react-dom": "19.3.0" }, @@ -760,9 +760,9 @@ } }, "node_modules/fullbleed": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/fullbleed/-/fullbleed-0.5.1.tgz", - "integrity": "sha512-NSrRDtxqMqm1/tZdEeJm1rSRSvLwL9xpX4emE0cV9wTYSIBuynoQwgV+WbC04/OXfCZpPau7vt5hS0zxYbZaMQ==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/fullbleed/-/fullbleed-0.6.0.tgz", + "integrity": "sha512-8tptrfaBbP4cjHohzPDPD1Lh3NuBFbRyVebn4kQhFsSX2VsMJwR6GURImfr9tbO3+iPjsZBFwZFxRYz238xc8g==", "license": "MIT", "dependencies": { "@bjorn3/browser_wasi_shim": "0.4.2" diff --git a/examples/react/package.json b/examples/react/package.json index 2aef167..7817642 100644 --- a/examples/react/package.json +++ b/examples/react/package.json @@ -3,7 +3,9 @@ "version": "1.0.0", "private": true, "type": "module", - "engines": { "node": ">=22.12.0" }, + "engines": { + "node": ">=22.12.0" + }, "scripts": { "assets": "fullbleed-browser-assets public/fullbleed", "dev": "npm run assets && vite --host 127.0.0.1", @@ -12,7 +14,7 @@ "preview": "vite preview --host 127.0.0.1" }, "dependencies": { - "fullbleed": "0.5.1", + "fullbleed": "0.6.0", "react": "19.3.0", "react-dom": "19.3.0" }, diff --git a/examples/react/src/App.tsx b/examples/react/src/App.tsx index 848e70e..0897695 100644 --- a/examples/react/src/App.tsx +++ b/examples/react/src/App.tsx @@ -1,44 +1,63 @@ // SPDX-License-Identifier: MIT -import { useMemo, useState } from 'react'; +import { useMemo, useRef, useState } from 'react'; import { usePdfPreview } from './usePdfPreview'; +import type { PdfInput } from './usePdfPreview'; +import { Invoice } from './documents/Invoice'; +import { initialItems, parseItems, sample } from './documents/data'; +import type { ItemDraft } from './documents/data'; +import componentSource from './documents/Invoice.tsx?raw'; import invoiceHtml from './invoice.html?raw'; import invoiceCss from './invoice.css?raw'; import reportHtml from './report.html?raw'; import reportCss from './report.css?raw'; const defaults = { invoice: { html: invoiceHtml, css: invoiceCss }, report: { html: reportHtml, css: reportCss } }; -type Design = keyof typeof defaults; +type Design = keyof typeof defaults | 'components'; +const requestedDesign = new URL(location.href).searchParams.get('design'); +const initialDesign: Design = requestedDesign === 'invoice' || requestedDesign === 'report' ? requestedDesign : 'components'; const assetBaseUrl = new URL('fullbleed/', new URL(import.meta.env.BASE_URL, location.href)).href; const escapeHtml = (value: string) => value.replace(/[&<>"']/g, character => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', })[character]!); function DocumentEditor() { - const [design, setDesign] = useState('invoice'); + const [design, setDesign] = useState(initialDesign); const [templates, setTemplates] = useState(defaults); const [customer, setCustomer] = useState('Maple & Finch'); const [reference, setReference] = useState('NS-1042'); const [ink, setInk] = useState('#17382e'); + const [paid, setPaid] = useState(false); + const [items, setItems] = useState(initialItems); + const nextItem = useRef(sample.items.length + 1); const [autoPreview, setAutoPreview] = useState(true); - const template = templates[design]; - const inputError = !template.html.trim() ? 'Enter document HTML before generating.' - : design === 'invoice' && (!customer.trim() || !reference.trim()) ? 'Enter a customer name and invoice reference.' - : design === 'invoice' && !/^#[\da-f]{6}$/i.test(ink) ? 'Enter a six-digit ink color, such as #17382e.' : ''; - const input = useMemo(() => { + const template = design === 'components' ? null : templates[design]; + const parsed = useMemo(() => parseItems(items), [items]); + const isInvoice = design !== 'report'; + const inputError = template && !template.html.trim() ? 'Enter document HTML before generating.' + : isInvoice && (!customer.trim() || !reference.trim()) ? 'Enter a customer name and invoice reference.' + : isInvoice && !/^#[\da-f]{6}$/i.test(ink) ? 'Enter a six-digit ink color, such as #17382e.' + : design === 'components' ? parsed.error : ''; + const input = useMemo(() => { + if (inputError) return null; + if (design === 'components') return { + document: Invoice({ data: { ...sample, customer: customer.trim(), number: reference.trim(), paid, items: parsed.items }, ink }), + previewDpi: 96, maxPages: 20, timeoutMs: 30_000, + }; const values: Record = { customer: customer.trim(), reference: reference.trim() }; - const valid = design !== 'invoice' || (values.customer && values.reference && /^#[\da-f]{6}$/i.test(ink)); return { - html: valid ? template.html.replace(/{{(customer|reference)}}/g, (_, key: string) => escapeHtml(values[key])) : '', - css: template.css.replaceAll('{{ink}}', /^#[\da-f]{6}$/i.test(ink) ? ink : '#17382e'), + html: template!.html.replace(/{{(customer|reference)}}/g, (_, key: string) => escapeHtml(values[key])), + css: template!.css.replaceAll('{{ink}}', /^#[\da-f]{6}$/i.test(ink) ? ink : '#17382e'), previewDpi: 96, maxPages: 20, timeoutMs: 30_000, }; - }, [template, customer, reference, ink, design]); + }, [template, customer, reference, ink, design, paid, parsed, inputError]); const pdf = usePdfPreview(input, assetBaseUrl, { auto: autoPreview }); - const filename = design === 'invoice' ? `invoice-${reference.replace(/[^\w-]+/g, '-').slice(0, 32)}.pdf` : 'community-report.pdf'; + const filename = isInvoice ? `invoice-${reference.replace(/[^\w-]+/g, '-').slice(0, 32)}.pdf` : 'community-report.pdf'; const pending = pdf.status === 'queued' || pdf.status === 'rendering'; - const editTemplate = (field: 'html' | 'css', value: string) => setTemplates(previous => ({ - ...previous, [design]: { ...previous[design], [field]: value }, - })); + const editTemplate = (field: 'html' | 'css', value: string) => { + if (design !== 'components') setTemplates(previous => ({ ...previous, [design]: { ...previous[design], [field]: value } })); + }; + const editItem = (id: number, field: keyof Omit, value: string) => + setItems(previous => previous.map(item => item.id === id ? { ...item, [field]: value } : item)); const messages: Record = { MISSING_GLYPHS: 'Some characters need another font. Add font bytes to the input or edit the text.', ENGINE_LOAD_FAILED: 'The PDF runtime could not load. Check that all runtime files were deployed.', @@ -52,6 +71,7 @@ function DocumentEditor() { if (pdf.status === 'error') status = inputError ? inputError : messages[pdf.error!.code] ?? pdf.error!.message; if (pdf.result) status = `Ready. ${pdf.result.pages} ${pdf.result.pages === 1 ? 'page' : 'pages'} · ${Math.ceil(pdf.result.bytes / 1024)} KiB. Your PDF is ready to download.`; + if (inputError) status = inputError; return <>
@@ -59,9 +79,9 @@ function DocumentEditor() {
01

Change the details

- {design === 'invoice' && <> + {isInvoice && <> setCustomer(event.target.value)} /> @@ -72,13 +92,32 @@ function DocumentEditor() { onChange={event => setInk(event.target.value)} /> {ink.toUpperCase()}Your print palette
} -

{design === 'invoice' ? 'Fictional invoice. Items and amounts are fixed for this example.' : 'A three-page report. All organizations, records and amounts are fictional.'}

+ {design === 'components' && <> + +
Edit line items ({items.length}) + {items.map((item, index) =>
+ Item {index + 1} + editItem(item.id, 'name', event.target.value)} /> + editItem(item.id, 'detail', event.target.value)} /> +
editItem(item.id, 'quantity', event.target.value)} />
+
editItem(item.id, 'rate', event.target.value)} />
+ +
)} +
+
+

Up to 20 items. Use a negative rate for a credit. Amounts are calculated in cents.

+
+ } +

{design === 'components' ? 'Fictional sample data. Change the rows and payment status to see components respond.' : design === 'invoice' ? 'Fictional invoice. Items and amounts are fixed for this example.' : 'A three-page report. All organizations, records and amounts are fictional.'}

- + {pending && }
-

{status}

+

{status}

@@ -93,16 +132,20 @@ function DocumentEditor() {
-
+ {design === 'components' ?
+ See the document component +

This invoice is built with functions, typed props, mapped rows and conditional content. Edit src/documents/Invoice.tsx and invoiceStyles.ts in the online project or downloaded source.

+
{componentSource}
+
:
Edit the HTML & CSS

Paste your own print template. {autoPreview ? 'Changes update the PDF automatically.' : 'Choose Generate PDF to apply your changes.'} Invoice values use {'{{customer}}'}, {'{{reference}}'}, and {'{{ink}}'}. These edits last until reload.

-